Repository: AppFlowy-IO/AppFlowy Branch: main Commit: bbe886fcdd52 Files: 3555 Total size: 18.0 MB Directory structure: gitextract_qoew7893/ ├── .dockerignore ├── .githooks/ │ ├── commit-msg │ ├── pre-commit │ └── pre-push ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ └── feature_request.yaml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── actions/ │ │ ├── flutter_build/ │ │ │ └── action.yml │ │ └── flutter_integration_test/ │ │ └── action.yml │ └── workflows/ │ ├── android_ci.yaml.bak │ ├── build_command.yml │ ├── commit_lint.yml │ ├── docker_ci.yml │ ├── flutter_ci.yaml │ ├── ios_ci.yaml │ ├── mobile_ci.yml │ ├── ninja_i18n.yml │ ├── release.yml │ ├── rust_ci.yaml │ ├── rust_coverage.yml │ └── translation_notify.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── ROADMAP.md ├── codemagic.yaml ├── commitlint.config.js ├── doc/ │ ├── CONTRIBUTING.md │ └── roadmap.md ├── frontend/ │ ├── .vscode/ │ │ ├── launch.json │ │ └── tasks.json │ ├── Makefile.toml │ ├── appflowy_flutter/ │ │ ├── .gitignore │ │ ├── .metadata │ │ ├── Makefile │ │ ├── README.md │ │ ├── analysis_options.yaml │ │ ├── android/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── app/ │ │ │ │ ├── build.gradle │ │ │ │ └── src/ │ │ │ │ ├── debug/ │ │ │ │ │ └── AndroidManifest.xml │ │ │ │ ├── main/ │ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ ├── Classes/ │ │ │ │ │ │ └── binding.h │ │ │ │ │ ├── kotlin/ │ │ │ │ │ │ └── com/ │ │ │ │ │ │ └── example/ │ │ │ │ │ │ └── app_flowy/ │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ │ └── res/ │ │ │ │ │ ├── drawable/ │ │ │ │ │ │ ├── launcher_background.xml │ │ │ │ │ │ └── launcher_foreground.xml │ │ │ │ │ ├── drawable-v21/ │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ │ │ ├── ic_launcher.xml │ │ │ │ │ │ └── ic_launcher_round.xml │ │ │ │ │ ├── values/ │ │ │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ │ │ └── styles.xml │ │ │ │ │ └── values-night/ │ │ │ │ │ └── styles.xml │ │ │ │ └── profile/ │ │ │ │ └── AndroidManifest.xml │ │ │ ├── build.gradle │ │ │ ├── gradle/ │ │ │ │ └── wrapper/ │ │ │ │ └── gradle-wrapper.properties │ │ │ ├── gradle.properties │ │ │ └── settings.gradle │ │ ├── assets/ │ │ │ ├── built_in_prompts.json │ │ │ ├── fonts/ │ │ │ │ └── .gitkeep │ │ │ ├── google_fonts/ │ │ │ │ ├── Poppins/ │ │ │ │ │ └── OFL.txt │ │ │ │ └── Roboto_Mono/ │ │ │ │ └── LICENSE.txt │ │ │ ├── icons/ │ │ │ │ └── icons.json │ │ │ ├── template/ │ │ │ │ ├── readme.afdoc │ │ │ │ └── readme.json │ │ │ ├── test/ │ │ │ │ └── workspaces/ │ │ │ │ ├── database/ │ │ │ │ │ ├── v020.afdb │ │ │ │ │ └── v069.afdb │ │ │ │ └── markdowns/ │ │ │ │ ├── markdown_with_table.md │ │ │ │ ├── test1.md │ │ │ │ └── test2.md │ │ │ └── translations/ │ │ │ └── mr-IN.json │ │ ├── build.yaml │ │ ├── cargokit_options.yaml │ │ ├── dart_dependency_validator.yaml │ │ ├── dev.env │ │ ├── devtools_options.yaml │ │ ├── distribute_options.yaml │ │ ├── dsa_pub.pem │ │ ├── integration_test/ │ │ │ ├── desktop/ │ │ │ │ ├── board/ │ │ │ │ │ ├── board_add_row_test.dart │ │ │ │ │ ├── board_field_test.dart │ │ │ │ │ ├── board_group_test.dart │ │ │ │ │ ├── board_hide_groups_test.dart │ │ │ │ │ ├── board_row_test.dart │ │ │ │ │ └── board_test_runner.dart │ │ │ │ ├── chat/ │ │ │ │ │ └── chat_page_test.dart │ │ │ │ ├── cloud/ │ │ │ │ │ ├── cloud_runner.dart │ │ │ │ │ ├── data_migration/ │ │ │ │ │ │ ├── anon_user_data_migration_test.dart │ │ │ │ │ │ └── data_migration_test_runner.dart │ │ │ │ │ ├── database/ │ │ │ │ │ │ ├── database_image_test.dart │ │ │ │ │ │ └── database_test_runner.dart │ │ │ │ │ ├── document/ │ │ │ │ │ │ ├── document_ai_writer_test.dart │ │ │ │ │ │ ├── document_copy_link_to_block_test.dart │ │ │ │ │ │ ├── document_option_actions_test.dart │ │ │ │ │ │ ├── document_publish_test.dart │ │ │ │ │ │ └── document_test_runner.dart │ │ │ │ │ ├── set_env.dart │ │ │ │ │ ├── sidebar/ │ │ │ │ │ │ ├── sidebar_icon_test.dart │ │ │ │ │ │ ├── sidebar_move_page_test.dart │ │ │ │ │ │ ├── sidebar_rename_untitled_test.dart │ │ │ │ │ │ └── sidebar_search_test.dart │ │ │ │ │ ├── uncategorized/ │ │ │ │ │ │ ├── appflowy_cloud_auth_test.dart │ │ │ │ │ │ ├── document_sync_test.dart │ │ │ │ │ │ ├── uncategorized_test_runner.dart │ │ │ │ │ │ └── user_setting_sync_test.dart │ │ │ │ │ └── workspace/ │ │ │ │ │ ├── change_name_and_icon_test.dart │ │ │ │ │ ├── collaborative_workspace_test.dart │ │ │ │ │ ├── share_menu_test.dart │ │ │ │ │ ├── tabs_test.dart │ │ │ │ │ ├── workspace_icon_test.dart │ │ │ │ │ ├── workspace_settings_test.dart │ │ │ │ │ └── workspace_test_runner.dart │ │ │ │ ├── command_palette/ │ │ │ │ │ ├── command_palette_test.dart │ │ │ │ │ ├── command_palette_test_runner.dart │ │ │ │ │ ├── folder_search_test.dart │ │ │ │ │ └── recent_history_test.dart │ │ │ │ ├── database/ │ │ │ │ │ ├── database_calendar_test.dart │ │ │ │ │ ├── database_cell_test.dart │ │ │ │ │ ├── database_field_settings_test.dart │ │ │ │ │ ├── database_field_test.dart │ │ │ │ │ ├── database_filter_test.dart │ │ │ │ │ ├── database_icon_test.dart │ │ │ │ │ ├── database_media_test.dart │ │ │ │ │ ├── database_reminder_test.dart │ │ │ │ │ ├── database_row_cover_test.dart │ │ │ │ │ ├── database_row_page_test.dart │ │ │ │ │ ├── database_setting_test.dart │ │ │ │ │ ├── database_share_test.dart │ │ │ │ │ ├── database_sort_test.dart │ │ │ │ │ ├── database_test_runner_1.dart │ │ │ │ │ ├── database_test_runner_2.dart │ │ │ │ │ └── database_view_test.dart │ │ │ │ ├── document/ │ │ │ │ │ ├── document_alignment_test.dart │ │ │ │ │ ├── document_app_lifecycle_test.dart │ │ │ │ │ ├── document_block_option_test.dart │ │ │ │ │ ├── document_callout_test.dart │ │ │ │ │ ├── document_codeblock_paste_test.dart │ │ │ │ │ ├── document_copy_and_paste_test.dart │ │ │ │ │ ├── document_create_and_delete_test.dart │ │ │ │ │ ├── document_customer_test.dart │ │ │ │ │ ├── document_deletion_test.dart │ │ │ │ │ ├── document_find_menu_test.dart │ │ │ │ │ ├── document_inline_page_reference_test.dart │ │ │ │ │ ├── document_inline_sub_page_test.dart │ │ │ │ │ ├── document_link_preview_test.dart │ │ │ │ │ ├── document_more_actions_test.dart │ │ │ │ │ ├── document_option_action_test.dart │ │ │ │ │ ├── document_selection_test.dart │ │ │ │ │ ├── document_shortcuts_test.dart │ │ │ │ │ ├── document_sub_page_test.dart │ │ │ │ │ ├── document_test_runner_1.dart │ │ │ │ │ ├── document_test_runner_2.dart │ │ │ │ │ ├── document_test_runner_3.dart │ │ │ │ │ ├── document_test_runner_4.dart │ │ │ │ │ ├── document_text_direction_test.dart │ │ │ │ │ ├── document_title_test.dart │ │ │ │ │ ├── document_toolbar_test.dart │ │ │ │ │ ├── document_with_cover_image_test.dart │ │ │ │ │ ├── document_with_database_test.dart │ │ │ │ │ ├── document_with_date_reminder_test.dart │ │ │ │ │ ├── document_with_file_test.dart │ │ │ │ │ ├── document_with_image_block_test.dart │ │ │ │ │ ├── document_with_inline_math_equation_test.dart │ │ │ │ │ ├── document_with_inline_page_test.dart │ │ │ │ │ ├── document_with_link_test.dart │ │ │ │ │ ├── document_with_multi_image_block_test.dart │ │ │ │ │ ├── document_with_outline_block_test.dart │ │ │ │ │ ├── document_with_simple_table_test.dart │ │ │ │ │ ├── document_with_toggle_heading_block_test.dart │ │ │ │ │ ├── document_with_toggle_list_test.dart │ │ │ │ │ └── edit_document_test.dart │ │ │ │ ├── first_test/ │ │ │ │ │ └── first_test.dart │ │ │ │ ├── grid/ │ │ │ │ │ ├── grid_calculations_test.dart │ │ │ │ │ ├── grid_edit_row_test.dart │ │ │ │ │ ├── grid_filter_and_sort_test.dart │ │ │ │ │ ├── grid_reopen_test.dart │ │ │ │ │ ├── grid_reorder_row_test.dart │ │ │ │ │ ├── grid_row_test.dart │ │ │ │ │ ├── grid_test_extensions.dart │ │ │ │ │ └── grid_test_runner_1.dart │ │ │ │ ├── reminder/ │ │ │ │ │ └── document_reminder_test.dart │ │ │ │ ├── settings/ │ │ │ │ │ ├── notifications_settings_test.dart │ │ │ │ │ ├── settings_billing_test.dart │ │ │ │ │ ├── settings_runner.dart │ │ │ │ │ ├── shortcuts_settings_test.dart │ │ │ │ │ └── sign_in_page_settings_test.dart │ │ │ │ ├── sidebar/ │ │ │ │ │ ├── rename_current_item_test.dart │ │ │ │ │ ├── sidebar_expand_test.dart │ │ │ │ │ ├── sidebar_favorites_test.dart │ │ │ │ │ ├── sidebar_icon_test.dart │ │ │ │ │ ├── sidebar_recent_icon_test.dart │ │ │ │ │ ├── sidebar_test.dart │ │ │ │ │ ├── sidebar_test_runner.dart │ │ │ │ │ └── sidebar_view_item_test.dart │ │ │ │ └── uncategorized/ │ │ │ │ ├── board_test.dart │ │ │ │ ├── code_block_language_selector_test.dart │ │ │ │ ├── emoji_shortcut_test.dart │ │ │ │ ├── empty_document_test.dart │ │ │ │ ├── hotkeys_test.dart │ │ │ │ ├── import_files_test.dart │ │ │ │ ├── language_test.dart │ │ │ │ ├── share_markdown_test.dart │ │ │ │ ├── switch_folder_test.dart │ │ │ │ ├── tabs_test.dart │ │ │ │ ├── uncategorized_test_runner_1.dart │ │ │ │ └── zoom_in_out_test.dart │ │ │ ├── desktop_runner_1.dart │ │ │ ├── desktop_runner_2.dart │ │ │ ├── desktop_runner_3.dart │ │ │ ├── desktop_runner_4.dart │ │ │ ├── desktop_runner_5.dart │ │ │ ├── desktop_runner_6.dart │ │ │ ├── desktop_runner_7.dart │ │ │ ├── desktop_runner_8.dart │ │ │ ├── desktop_runner_9.dart │ │ │ ├── mobile/ │ │ │ │ ├── cloud/ │ │ │ │ │ ├── cloud_runner.dart │ │ │ │ │ ├── document/ │ │ │ │ │ │ ├── publish_test.dart │ │ │ │ │ │ └── share_link_test.dart │ │ │ │ │ ├── space/ │ │ │ │ │ │ └── space_test.dart │ │ │ │ │ └── workspace/ │ │ │ │ │ └── workspace_operations_test.dart │ │ │ │ ├── document/ │ │ │ │ │ ├── at_menu_test.dart │ │ │ │ │ ├── document_test_runner.dart │ │ │ │ │ ├── icon_test.dart │ │ │ │ │ ├── page_style_test.dart │ │ │ │ │ ├── plus_menu_test.dart │ │ │ │ │ ├── simple_table_test.dart │ │ │ │ │ ├── slash_menu_test.dart │ │ │ │ │ ├── title_test.dart │ │ │ │ │ └── toolbar_test.dart │ │ │ │ ├── home_page/ │ │ │ │ │ └── create_new_page_test.dart │ │ │ │ ├── search/ │ │ │ │ │ └── search_test.dart │ │ │ │ ├── settings/ │ │ │ │ │ ├── default_text_direction_test.dart │ │ │ │ │ └── scale_factor_test.dart │ │ │ │ └── sign_in/ │ │ │ │ └── anonymous_sign_in_test.dart │ │ │ ├── mobile_runner_1.dart │ │ │ ├── runner.dart │ │ │ └── shared/ │ │ │ ├── ai_test_op.dart │ │ │ ├── auth_operation.dart │ │ │ ├── base.dart │ │ │ ├── common_operations.dart │ │ │ ├── constants.dart │ │ │ ├── data.dart │ │ │ ├── database_test_op.dart │ │ │ ├── dir.dart │ │ │ ├── document_test_operations.dart │ │ │ ├── emoji.dart │ │ │ ├── expectation.dart │ │ │ ├── ime.dart │ │ │ ├── keyboard.dart │ │ │ ├── mock/ │ │ │ │ ├── mock_ai.dart │ │ │ │ ├── mock_file_picker.dart │ │ │ │ └── mock_url_launcher.dart │ │ │ ├── settings.dart │ │ │ ├── util.dart │ │ │ └── workspace.dart │ │ ├── ios/ │ │ │ ├── .gitignore │ │ │ ├── Flutter/ │ │ │ │ ├── AppFrameworkInfo.plist │ │ │ │ ├── Debug.xcconfig │ │ │ │ └── Release.xcconfig │ │ │ ├── Podfile │ │ │ ├── Runner/ │ │ │ │ ├── AppDelegate.swift │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── LaunchImage.imageset/ │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── README.md │ │ │ │ ├── Base.lproj/ │ │ │ │ │ ├── LaunchScreen.storyboard │ │ │ │ │ └── Main.storyboard │ │ │ │ ├── Info.plist │ │ │ │ ├── Runner-Bridging-Header.h │ │ │ │ └── Runner.entitlements │ │ │ ├── Runner.xcodeproj/ │ │ │ │ ├── project.pbxproj │ │ │ │ ├── project.xcworkspace/ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ │ └── xcshareddata/ │ │ │ │ └── xcschemes/ │ │ │ │ └── Runner.xcscheme │ │ │ └── Runner.xcworkspace/ │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata/ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ │ ├── lib/ │ │ │ ├── ai/ │ │ │ │ ├── ai.dart │ │ │ │ ├── service/ │ │ │ │ │ ├── ai_entities.dart │ │ │ │ │ ├── ai_model_state_notifier.dart │ │ │ │ │ ├── ai_prompt_database_selector_cubit.dart │ │ │ │ │ ├── ai_prompt_input_bloc.dart │ │ │ │ │ ├── ai_prompt_selector_cubit.dart │ │ │ │ │ ├── appflowy_ai_service.dart │ │ │ │ │ ├── error.dart │ │ │ │ │ ├── select_model_bloc.dart │ │ │ │ │ └── view_selector_cubit.dart │ │ │ │ └── widgets/ │ │ │ │ ├── ai_prompt_modal/ │ │ │ │ │ ├── ai_prompt_category_list.dart │ │ │ │ │ ├── ai_prompt_database_modal.dart │ │ │ │ │ ├── ai_prompt_modal.dart │ │ │ │ │ ├── ai_prompt_onboarding.dart │ │ │ │ │ ├── ai_prompt_preview.dart │ │ │ │ │ └── ai_prompt_visible_list.dart │ │ │ │ ├── loading_indicator.dart │ │ │ │ ├── prompt_input/ │ │ │ │ │ ├── action_buttons.dart │ │ │ │ │ ├── browse_prompts_button.dart │ │ │ │ │ ├── desktop_prompt_input.dart │ │ │ │ │ ├── file_attachment_list.dart │ │ │ │ │ ├── layout_define.dart │ │ │ │ │ ├── mention_page_bottom_sheet.dart │ │ │ │ │ ├── mention_page_menu.dart │ │ │ │ │ ├── mentioned_page_text_span.dart │ │ │ │ │ ├── predefined_format_buttons.dart │ │ │ │ │ ├── prompt_input_text_controller.dart │ │ │ │ │ ├── select_model_menu.dart │ │ │ │ │ ├── select_sources_bottom_sheet.dart │ │ │ │ │ ├── select_sources_menu.dart │ │ │ │ │ └── send_button.dart │ │ │ │ └── view_selector.dart │ │ │ ├── core/ │ │ │ │ ├── config/ │ │ │ │ │ ├── kv.dart │ │ │ │ │ └── kv_keys.dart │ │ │ │ ├── frameless_window.dart │ │ │ │ ├── helpers/ │ │ │ │ │ ├── helpers.dart │ │ │ │ │ ├── target_platform.dart │ │ │ │ │ └── url_launcher.dart │ │ │ │ ├── network_monitor.dart │ │ │ │ └── notification/ │ │ │ │ ├── document_notification.dart │ │ │ │ ├── folder_notification.dart │ │ │ │ ├── grid_notification.dart │ │ │ │ ├── notification_helper.dart │ │ │ │ ├── search_notification.dart │ │ │ │ └── user_notification.dart │ │ │ ├── date/ │ │ │ │ └── date_service.dart │ │ │ ├── env/ │ │ │ │ ├── backend_env.dart │ │ │ │ ├── cloud_env.dart │ │ │ │ ├── cloud_env_test.dart │ │ │ │ └── env.dart │ │ │ ├── features/ │ │ │ │ ├── page_access_level/ │ │ │ │ │ ├── data/ │ │ │ │ │ │ └── repositories/ │ │ │ │ │ │ ├── page_access_level_repository.dart │ │ │ │ │ │ └── rust_page_access_level_repository_impl.dart │ │ │ │ │ └── logic/ │ │ │ │ │ ├── page_access_level_bloc.dart │ │ │ │ │ ├── page_access_level_event.dart │ │ │ │ │ └── page_access_level_state.dart │ │ │ │ ├── settings/ │ │ │ │ │ ├── data/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ └── user_data_location.dart │ │ │ │ │ │ └── repositories/ │ │ │ │ │ │ ├── rust_settings_repository_impl.dart │ │ │ │ │ │ └── settings_repository.dart │ │ │ │ │ ├── logic/ │ │ │ │ │ │ ├── data_location_bloc.dart │ │ │ │ │ │ ├── data_location_event.dart │ │ │ │ │ │ └── data_location_state.dart │ │ │ │ │ └── settings.dart │ │ │ │ ├── share_tab/ │ │ │ │ │ ├── data/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── models.dart │ │ │ │ │ │ │ ├── share_access_level.dart │ │ │ │ │ │ │ ├── share_popover_group_id.dart │ │ │ │ │ │ │ ├── share_role.dart │ │ │ │ │ │ │ ├── share_section_type.dart │ │ │ │ │ │ │ ├── shared_group.dart │ │ │ │ │ │ │ └── shared_user.dart │ │ │ │ │ │ └── repositories/ │ │ │ │ │ │ ├── local_share_with_user_repository_impl.dart │ │ │ │ │ │ ├── rust_share_with_user_repository_impl.dart │ │ │ │ │ │ └── share_with_user_repository.dart │ │ │ │ │ ├── logic/ │ │ │ │ │ │ ├── share_tab_bloc.dart │ │ │ │ │ │ ├── share_tab_event.dart │ │ │ │ │ │ └── share_tab_state.dart │ │ │ │ │ └── presentation/ │ │ │ │ │ ├── share_tab.dart │ │ │ │ │ └── widgets/ │ │ │ │ │ ├── access_level_list_widget.dart │ │ │ │ │ ├── copy_link_widget.dart │ │ │ │ │ ├── edit_access_level_widget.dart │ │ │ │ │ ├── general_access_section.dart │ │ │ │ │ ├── guest_tag.dart │ │ │ │ │ ├── people_with_access_section.dart │ │ │ │ │ ├── share_with_user_widget.dart │ │ │ │ │ ├── shared_group_widget.dart │ │ │ │ │ ├── shared_user_widget.dart │ │ │ │ │ ├── turn_into_member_widget.dart │ │ │ │ │ └── upgrade_to_pro_widget.dart │ │ │ │ ├── shared_section/ │ │ │ │ │ ├── data/ │ │ │ │ │ │ └── repositories/ │ │ │ │ │ │ ├── local_shared_pages_repository_impl.dart │ │ │ │ │ │ ├── rust_shared_pages_repository_impl.dart │ │ │ │ │ │ └── shared_pages_repository.dart │ │ │ │ │ ├── logic/ │ │ │ │ │ │ ├── shared_section_bloc.dart │ │ │ │ │ │ ├── shared_section_event.dart │ │ │ │ │ │ └── shared_section_state.dart │ │ │ │ │ ├── models/ │ │ │ │ │ │ └── shared_page.dart │ │ │ │ │ └── presentation/ │ │ │ │ │ ├── m_shared_section.dart │ │ │ │ │ ├── shared_section.dart │ │ │ │ │ └── widgets/ │ │ │ │ │ ├── m_shared_page_list.dart │ │ │ │ │ ├── m_shared_section_header.dart │ │ │ │ │ ├── refresh_button.dart │ │ │ │ │ ├── shared_page_actions_button.dart │ │ │ │ │ ├── shared_page_list.dart │ │ │ │ │ ├── shared_section_empty.dart │ │ │ │ │ ├── shared_section_error.dart │ │ │ │ │ ├── shared_section_header.dart │ │ │ │ │ └── shared_section_loading.dart │ │ │ │ ├── util/ │ │ │ │ │ └── extensions.dart │ │ │ │ ├── view_management/ │ │ │ │ │ ├── README.md │ │ │ │ │ └── logic/ │ │ │ │ │ ├── view_event.dart │ │ │ │ │ ├── view_management_bloc.dart │ │ │ │ │ └── view_state.dart │ │ │ │ └── workspace/ │ │ │ │ ├── data/ │ │ │ │ │ └── repositories/ │ │ │ │ │ ├── rust_workspace_repository_impl.dart │ │ │ │ │ └── workspace_repository.dart │ │ │ │ └── logic/ │ │ │ │ ├── workspace_bloc.dart │ │ │ │ ├── workspace_event.dart │ │ │ │ └── workspace_state.dart │ │ │ ├── flutter/ │ │ │ │ └── af_dropdown_menu.dart │ │ │ ├── main.dart │ │ │ ├── mobile/ │ │ │ │ ├── application/ │ │ │ │ │ ├── base/ │ │ │ │ │ │ └── mobile_view_page_bloc.dart │ │ │ │ │ ├── mobile_router.dart │ │ │ │ │ ├── notification/ │ │ │ │ │ │ └── notification_reminder_bloc.dart │ │ │ │ │ ├── page_style/ │ │ │ │ │ │ └── document_page_style_bloc.dart │ │ │ │ │ ├── recent/ │ │ │ │ │ │ └── recent_view_bloc.dart │ │ │ │ │ └── user_profile/ │ │ │ │ │ └── user_profile_bloc.dart │ │ │ │ └── presentation/ │ │ │ │ ├── base/ │ │ │ │ │ ├── animated_gesture.dart │ │ │ │ │ ├── app_bar/ │ │ │ │ │ │ ├── app_bar.dart │ │ │ │ │ │ └── app_bar_actions.dart │ │ │ │ │ ├── flowy_search_text_field.dart │ │ │ │ │ ├── mobile_view_page.dart │ │ │ │ │ ├── option_color_list.dart │ │ │ │ │ ├── type_option_menu_item.dart │ │ │ │ │ └── view_page/ │ │ │ │ │ ├── app_bar_buttons.dart │ │ │ │ │ └── more_bottom_sheet.dart │ │ │ │ ├── bottom_sheet/ │ │ │ │ │ ├── bottom_sheet.dart │ │ │ │ │ ├── bottom_sheet_action_widget.dart │ │ │ │ │ ├── bottom_sheet_add_new_page.dart │ │ │ │ │ ├── bottom_sheet_block_action_widget.dart │ │ │ │ │ ├── bottom_sheet_buttons.dart │ │ │ │ │ ├── bottom_sheet_drag_handler.dart │ │ │ │ │ ├── bottom_sheet_edit_link_widget.dart │ │ │ │ │ ├── bottom_sheet_header.dart │ │ │ │ │ ├── bottom_sheet_media_upload.dart │ │ │ │ │ ├── bottom_sheet_rename_widget.dart │ │ │ │ │ ├── bottom_sheet_view_item.dart │ │ │ │ │ ├── bottom_sheet_view_item_body.dart │ │ │ │ │ ├── bottom_sheet_view_page.dart │ │ │ │ │ ├── default_mobile_action_pane.dart │ │ │ │ │ ├── show_mobile_bottom_sheet.dart │ │ │ │ │ └── show_transition_bottom_sheet.dart │ │ │ │ ├── chat/ │ │ │ │ │ └── mobile_chat_screen.dart │ │ │ │ ├── database/ │ │ │ │ │ ├── board/ │ │ │ │ │ │ ├── board.dart │ │ │ │ │ │ ├── mobile_board_page.dart │ │ │ │ │ │ ├── mobile_board_screen.dart │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ ├── group_card_header.dart │ │ │ │ │ │ ├── mobile_board_trailing.dart │ │ │ │ │ │ ├── mobile_hidden_groups_column.dart │ │ │ │ │ │ └── widgets.dart │ │ │ │ │ ├── card/ │ │ │ │ │ │ ├── card.dart │ │ │ │ │ │ ├── card_detail/ │ │ │ │ │ │ │ ├── mobile_card_detail_screen.dart │ │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ │ ├── mobile_create_field_button.dart │ │ │ │ │ │ │ ├── mobile_row_property_list.dart │ │ │ │ │ │ │ ├── option_text_field.dart │ │ │ │ │ │ │ ├── row_page_button.dart │ │ │ │ │ │ │ └── widgets.dart │ │ │ │ │ │ └── mobile_card_content.dart │ │ │ │ │ ├── date_picker/ │ │ │ │ │ │ └── mobile_date_picker_screen.dart │ │ │ │ │ ├── field/ │ │ │ │ │ │ ├── mobile_create_field_screen.dart │ │ │ │ │ │ ├── mobile_edit_field_screen.dart │ │ │ │ │ │ ├── mobile_field_bottom_sheets.dart │ │ │ │ │ │ ├── mobile_field_picker_list.dart │ │ │ │ │ │ ├── mobile_full_field_editor.dart │ │ │ │ │ │ └── mobile_quick_field_editor.dart │ │ │ │ │ ├── mobile_calendar_events_empty.dart │ │ │ │ │ ├── mobile_calendar_events_screen.dart │ │ │ │ │ ├── mobile_calendar_screen.dart │ │ │ │ │ ├── mobile_grid_screen.dart │ │ │ │ │ └── view/ │ │ │ │ │ ├── database_field_list.dart │ │ │ │ │ ├── database_filter_bottom_sheet.dart │ │ │ │ │ ├── database_filter_bottom_sheet_cubit.dart │ │ │ │ │ ├── database_filter_condition_list.dart │ │ │ │ │ ├── database_sort_bottom_sheet.dart │ │ │ │ │ ├── database_sort_bottom_sheet_cubit.dart │ │ │ │ │ ├── database_view_layout.dart │ │ │ │ │ ├── database_view_list.dart │ │ │ │ │ ├── database_view_quick_actions.dart │ │ │ │ │ └── edit_database_view_screen.dart │ │ │ │ ├── editor/ │ │ │ │ │ └── mobile_editor_screen.dart │ │ │ │ ├── favorite/ │ │ │ │ │ ├── mobile_favorite_folder.dart │ │ │ │ │ └── mobile_favorite_page.dart │ │ │ │ ├── home/ │ │ │ │ │ ├── favorite_folder/ │ │ │ │ │ │ ├── favorite_space.dart │ │ │ │ │ │ ├── mobile_home_favorite_folder.dart │ │ │ │ │ │ └── mobile_home_favorite_folder_header.dart │ │ │ │ │ ├── home.dart │ │ │ │ │ ├── home_space/ │ │ │ │ │ │ └── home_space.dart │ │ │ │ │ ├── mobile_folders.dart │ │ │ │ │ ├── mobile_home_page.dart │ │ │ │ │ ├── mobile_home_page_header.dart │ │ │ │ │ ├── mobile_home_setting_page.dart │ │ │ │ │ ├── mobile_home_trash_page.dart │ │ │ │ │ ├── recent_folder/ │ │ │ │ │ │ ├── mobile_home_recent_views.dart │ │ │ │ │ │ ├── mobile_recent_view.dart │ │ │ │ │ │ └── recent_space.dart │ │ │ │ │ ├── section_folder/ │ │ │ │ │ │ ├── mobile_home_section_folder.dart │ │ │ │ │ │ └── mobile_home_section_folder_header.dart │ │ │ │ │ ├── setting/ │ │ │ │ │ │ └── settings_popup_menu.dart │ │ │ │ │ ├── shared/ │ │ │ │ │ │ ├── empty_placeholder.dart │ │ │ │ │ │ └── mobile_page_card.dart │ │ │ │ │ ├── space/ │ │ │ │ │ │ ├── constants.dart │ │ │ │ │ │ ├── manage_space_widget.dart │ │ │ │ │ │ ├── mobile_space.dart │ │ │ │ │ │ ├── mobile_space_header.dart │ │ │ │ │ │ ├── mobile_space_menu.dart │ │ │ │ │ │ ├── space_menu_bottom_sheet.dart │ │ │ │ │ │ ├── space_permission_bottom_sheet.dart │ │ │ │ │ │ └── widgets.dart │ │ │ │ │ ├── tab/ │ │ │ │ │ │ ├── _round_underline_tab_indicator.dart │ │ │ │ │ │ ├── _tab_bar.dart │ │ │ │ │ │ ├── ai_bubble_button.dart │ │ │ │ │ │ ├── mobile_space_tab.dart │ │ │ │ │ │ └── space_order_bloc.dart │ │ │ │ │ └── workspaces/ │ │ │ │ │ ├── create_workspace_menu.dart │ │ │ │ │ ├── workspace_menu_bottom_sheet.dart │ │ │ │ │ └── workspace_more_options.dart │ │ │ │ ├── inline_actions/ │ │ │ │ │ ├── mobile_inline_actions_handler.dart │ │ │ │ │ ├── mobile_inline_actions_menu.dart │ │ │ │ │ └── mobile_inline_actions_menu_group.dart │ │ │ │ ├── mobile_bottom_navigation_bar.dart │ │ │ │ ├── notifications/ │ │ │ │ │ ├── mobile_notifications_multiple_select_page.dart │ │ │ │ │ ├── mobile_notifications_page.dart │ │ │ │ │ ├── mobile_notifications_screen.dart │ │ │ │ │ └── widgets/ │ │ │ │ │ ├── color.dart │ │ │ │ │ ├── empty.dart │ │ │ │ │ ├── header.dart │ │ │ │ │ ├── mobile_notification_tab_bar.dart │ │ │ │ │ ├── multi_select_notification_item.dart │ │ │ │ │ ├── notification_item.dart │ │ │ │ │ ├── settings_popup_menu.dart │ │ │ │ │ ├── shared.dart │ │ │ │ │ ├── slide_actions.dart │ │ │ │ │ ├── tab.dart │ │ │ │ │ ├── tab_bar.dart │ │ │ │ │ └── widgets.dart │ │ │ │ ├── page_item/ │ │ │ │ │ ├── mobile_slide_action_button.dart │ │ │ │ │ ├── mobile_view_item.dart │ │ │ │ │ └── mobile_view_item_add_button.dart │ │ │ │ ├── presentation.dart │ │ │ │ ├── root_placeholder_page.dart │ │ │ │ ├── search/ │ │ │ │ │ ├── mobile_search_ask_ai_entrance.dart │ │ │ │ │ ├── mobile_search_cell.dart │ │ │ │ │ ├── mobile_search_icon.dart │ │ │ │ │ ├── mobile_search_page.dart │ │ │ │ │ ├── mobile_search_reference_bottom_sheet.dart │ │ │ │ │ ├── mobile_search_result.dart │ │ │ │ │ ├── mobile_search_summary_cell.dart │ │ │ │ │ ├── mobile_search_textfield.dart │ │ │ │ │ ├── mobile_view_ancestors.dart │ │ │ │ │ └── view_ancestor_cache.dart │ │ │ │ ├── selection_menu/ │ │ │ │ │ ├── mobile_selection_menu.dart │ │ │ │ │ ├── mobile_selection_menu_item.dart │ │ │ │ │ ├── mobile_selection_menu_item_widget.dart │ │ │ │ │ ├── mobile_selection_menu_widget.dart │ │ │ │ │ └── slash_keyboard_service_interceptor.dart │ │ │ │ ├── setting/ │ │ │ │ │ ├── about/ │ │ │ │ │ │ ├── about.dart │ │ │ │ │ │ └── about_setting_group.dart │ │ │ │ │ ├── ai/ │ │ │ │ │ │ └── ai_settings_group.dart │ │ │ │ │ ├── appearance/ │ │ │ │ │ │ ├── appearance_setting_group.dart │ │ │ │ │ │ ├── rtl_setting.dart │ │ │ │ │ │ ├── text_scale_setting.dart │ │ │ │ │ │ └── theme_setting.dart │ │ │ │ │ ├── cloud/ │ │ │ │ │ │ ├── appflowy_cloud_page.dart │ │ │ │ │ │ └── cloud_setting_group.dart │ │ │ │ │ ├── font/ │ │ │ │ │ │ ├── font_picker_screen.dart │ │ │ │ │ │ └── font_setting.dart │ │ │ │ │ ├── language/ │ │ │ │ │ │ └── language_picker_screen.dart │ │ │ │ │ ├── language_setting_group.dart │ │ │ │ │ ├── launch_settings_page.dart │ │ │ │ │ ├── notifications_setting_group.dart │ │ │ │ │ ├── personal_info/ │ │ │ │ │ │ ├── edit_username_bottom_sheet.dart │ │ │ │ │ │ ├── personal_info.dart │ │ │ │ │ │ └── personal_info_setting_group.dart │ │ │ │ │ ├── self_host/ │ │ │ │ │ │ └── self_host_bottom_sheet.dart │ │ │ │ │ ├── self_host_setting_group.dart │ │ │ │ │ ├── setting.dart │ │ │ │ │ ├── support_setting_group.dart │ │ │ │ │ ├── user_session_setting_group.dart │ │ │ │ │ ├── widgets/ │ │ │ │ │ │ ├── mobile_setting_group_widget.dart │ │ │ │ │ │ ├── mobile_setting_item_widget.dart │ │ │ │ │ │ ├── mobile_setting_trailing.dart │ │ │ │ │ │ └── widgets.dart │ │ │ │ │ └── workspace/ │ │ │ │ │ ├── add_members_screen.dart │ │ │ │ │ ├── invite_member_by_link.dart │ │ │ │ │ ├── invite_members_screen.dart │ │ │ │ │ ├── member_list.dart │ │ │ │ │ └── workspace_setting_group.dart │ │ │ │ └── widgets/ │ │ │ │ ├── flowy_mobile_option_decorate_box.dart │ │ │ │ ├── flowy_mobile_quick_action_button.dart │ │ │ │ ├── flowy_mobile_search_text_field.dart │ │ │ │ ├── flowy_mobile_state_container.dart │ │ │ │ ├── flowy_option_tile.dart │ │ │ │ ├── navigation_bar_button.dart │ │ │ │ ├── show_flowy_mobile_confirm_dialog.dart │ │ │ │ └── widgets.dart │ │ │ ├── plugins/ │ │ │ │ ├── ai_chat/ │ │ │ │ │ ├── application/ │ │ │ │ │ │ ├── ai_chat_prelude.dart │ │ │ │ │ │ ├── ai_model_switch_listener.dart │ │ │ │ │ │ ├── chat_ai_message_bloc.dart │ │ │ │ │ │ ├── chat_bloc.dart │ │ │ │ │ │ ├── chat_edit_document_service.dart │ │ │ │ │ │ ├── chat_entity.dart │ │ │ │ │ │ ├── chat_input_control_cubit.dart │ │ │ │ │ │ ├── chat_input_file_bloc.dart │ │ │ │ │ │ ├── chat_member_bloc.dart │ │ │ │ │ │ ├── chat_message_handler.dart │ │ │ │ │ │ ├── chat_message_height_manager.dart │ │ │ │ │ │ ├── chat_message_listener.dart │ │ │ │ │ │ ├── chat_message_service.dart │ │ │ │ │ │ ├── chat_message_stream.dart │ │ │ │ │ │ ├── chat_notification.dart │ │ │ │ │ │ ├── chat_select_message_bloc.dart │ │ │ │ │ │ ├── chat_settings_manager.dart │ │ │ │ │ │ ├── chat_stream_manager.dart │ │ │ │ │ │ ├── chat_user_cubit.dart │ │ │ │ │ │ └── chat_user_message_bloc.dart │ │ │ │ │ ├── chat.dart │ │ │ │ │ ├── chat_page.dart │ │ │ │ │ └── presentation/ │ │ │ │ │ ├── animated_chat_list.dart │ │ │ │ │ ├── animated_chat_list_reversed.dart │ │ │ │ │ ├── chat_avatar.dart │ │ │ │ │ ├── chat_editor_style.dart │ │ │ │ │ ├── chat_input/ │ │ │ │ │ │ └── mobile_chat_input.dart │ │ │ │ │ ├── chat_message_selector_banner.dart │ │ │ │ │ ├── chat_page/ │ │ │ │ │ │ ├── chat_animation_list_widget.dart │ │ │ │ │ │ ├── chat_content_page.dart │ │ │ │ │ │ ├── chat_footer.dart │ │ │ │ │ │ ├── chat_message_widget.dart │ │ │ │ │ │ ├── load_chat_message_status_ready.dart │ │ │ │ │ │ └── text_message_widget.dart │ │ │ │ │ ├── chat_related_question.dart │ │ │ │ │ ├── chat_welcome_page.dart │ │ │ │ │ ├── layout_define.dart │ │ │ │ │ ├── message/ │ │ │ │ │ │ ├── ai_change_format_bottom_sheet.dart │ │ │ │ │ │ ├── ai_change_model_bottom_sheet.dart │ │ │ │ │ │ ├── ai_markdown_text.dart │ │ │ │ │ │ ├── ai_message_action_bar.dart │ │ │ │ │ │ ├── ai_message_bubble.dart │ │ │ │ │ │ ├── ai_metadata.dart │ │ │ │ │ │ ├── ai_text_message.dart │ │ │ │ │ │ ├── error_text_message.dart │ │ │ │ │ │ ├── message_util.dart │ │ │ │ │ │ ├── user_message_bubble.dart │ │ │ │ │ │ └── user_text_message.dart │ │ │ │ │ ├── scroll_to_bottom.dart │ │ │ │ │ └── widgets/ │ │ │ │ │ └── message_height_calculator.dart │ │ │ │ ├── base/ │ │ │ │ │ ├── color/ │ │ │ │ │ │ ├── color_picker.dart │ │ │ │ │ │ └── color_picker_screen.dart │ │ │ │ │ ├── drag_handler.dart │ │ │ │ │ ├── emoji/ │ │ │ │ │ │ ├── emoji_picker.dart │ │ │ │ │ │ ├── emoji_picker_header.dart │ │ │ │ │ │ ├── emoji_picker_screen.dart │ │ │ │ │ │ └── emoji_text.dart │ │ │ │ │ └── icon/ │ │ │ │ │ └── icon_widget.dart │ │ │ │ ├── blank/ │ │ │ │ │ └── blank.dart │ │ │ │ ├── database/ │ │ │ │ │ ├── application/ │ │ │ │ │ │ ├── calculations/ │ │ │ │ │ │ │ ├── calculation_type_ext.dart │ │ │ │ │ │ │ ├── calculations_listener.dart │ │ │ │ │ │ │ └── calculations_service.dart │ │ │ │ │ │ ├── cell/ │ │ │ │ │ │ │ ├── bloc/ │ │ │ │ │ │ │ │ ├── checkbox_cell_bloc.dart │ │ │ │ │ │ │ │ ├── checklist_cell_bloc.dart │ │ │ │ │ │ │ │ ├── date_cell_bloc.dart │ │ │ │ │ │ │ │ ├── date_cell_editor_bloc.dart │ │ │ │ │ │ │ │ ├── media_cell_bloc.dart │ │ │ │ │ │ │ │ ├── number_cell_bloc.dart │ │ │ │ │ │ │ │ ├── relation_cell_bloc.dart │ │ │ │ │ │ │ │ ├── relation_row_search_bloc.dart │ │ │ │ │ │ │ │ ├── select_option_cell_bloc.dart │ │ │ │ │ │ │ │ ├── select_option_cell_editor_bloc.dart │ │ │ │ │ │ │ │ ├── summary_cell_bloc.dart │ │ │ │ │ │ │ │ ├── summary_row_bloc.dart │ │ │ │ │ │ │ │ ├── text_cell_bloc.dart │ │ │ │ │ │ │ │ ├── time_cell_bloc.dart │ │ │ │ │ │ │ │ ├── timestamp_cell_bloc.dart │ │ │ │ │ │ │ │ ├── translate_cell_bloc.dart │ │ │ │ │ │ │ │ ├── translate_row_bloc.dart │ │ │ │ │ │ │ │ └── url_cell_bloc.dart │ │ │ │ │ │ │ ├── cell_cache.dart │ │ │ │ │ │ │ ├── cell_controller.dart │ │ │ │ │ │ │ ├── cell_controller_builder.dart │ │ │ │ │ │ │ ├── cell_data_loader.dart │ │ │ │ │ │ │ └── cell_data_persistence.dart │ │ │ │ │ │ ├── database_controller.dart │ │ │ │ │ │ ├── defines.dart │ │ │ │ │ │ ├── field/ │ │ │ │ │ │ │ ├── field_cell_bloc.dart │ │ │ │ │ │ │ ├── field_controller.dart │ │ │ │ │ │ │ ├── field_editor_bloc.dart │ │ │ │ │ │ │ ├── field_info.dart │ │ │ │ │ │ │ ├── filter_entities.dart │ │ │ │ │ │ │ ├── sort_entities.dart │ │ │ │ │ │ │ └── type_option/ │ │ │ │ │ │ │ ├── edit_select_option_bloc.dart │ │ │ │ │ │ │ ├── number_format_bloc.dart │ │ │ │ │ │ │ ├── relation_type_option_cubit.dart │ │ │ │ │ │ │ ├── select_option_type_option_bloc.dart │ │ │ │ │ │ │ ├── select_type_option_actions.dart │ │ │ │ │ │ │ ├── translate_type_option_bloc.dart │ │ │ │ │ │ │ └── type_option_data_parser.dart │ │ │ │ │ │ ├── layout/ │ │ │ │ │ │ │ └── layout_bloc.dart │ │ │ │ │ │ ├── row/ │ │ │ │ │ │ │ ├── related_row_detail_bloc.dart │ │ │ │ │ │ │ ├── row_banner_bloc.dart │ │ │ │ │ │ │ ├── row_cache.dart │ │ │ │ │ │ │ ├── row_controller.dart │ │ │ │ │ │ │ ├── row_list.dart │ │ │ │ │ │ │ └── row_service.dart │ │ │ │ │ │ ├── setting/ │ │ │ │ │ │ │ ├── group_bloc.dart │ │ │ │ │ │ │ ├── property_bloc.dart │ │ │ │ │ │ │ ├── setting_listener.dart │ │ │ │ │ │ │ └── setting_service.dart │ │ │ │ │ │ ├── share_bloc.dart │ │ │ │ │ │ ├── sync/ │ │ │ │ │ │ │ ├── database_sync_bloc.dart │ │ │ │ │ │ │ └── database_sync_state_listener.dart │ │ │ │ │ │ ├── tab_bar_bloc.dart │ │ │ │ │ │ └── view/ │ │ │ │ │ │ ├── view_cache.dart │ │ │ │ │ │ └── view_listener.dart │ │ │ │ │ ├── board/ │ │ │ │ │ │ ├── application/ │ │ │ │ │ │ │ ├── board_actions_bloc.dart │ │ │ │ │ │ │ ├── board_bloc.dart │ │ │ │ │ │ │ └── group_controller.dart │ │ │ │ │ │ ├── board.dart │ │ │ │ │ │ ├── group_ext.dart │ │ │ │ │ │ ├── presentation/ │ │ │ │ │ │ │ ├── board_page.dart │ │ │ │ │ │ │ ├── toolbar/ │ │ │ │ │ │ │ │ └── board_setting_bar.dart │ │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ │ ├── board_checkbox_column_header.dart │ │ │ │ │ │ │ ├── board_column_header.dart │ │ │ │ │ │ │ ├── board_editable_column_header.dart │ │ │ │ │ │ │ ├── board_focus_scope.dart │ │ │ │ │ │ │ ├── board_hidden_groups.dart │ │ │ │ │ │ │ └── board_shortcut_container.dart │ │ │ │ │ │ └── tests/ │ │ │ │ │ │ └── integrate_test/ │ │ │ │ │ │ └── card_test.dart │ │ │ │ │ ├── calendar/ │ │ │ │ │ │ ├── application/ │ │ │ │ │ │ │ ├── calendar_bloc.dart │ │ │ │ │ │ │ ├── calendar_event_editor_bloc.dart │ │ │ │ │ │ │ ├── calendar_setting_bloc.dart │ │ │ │ │ │ │ └── unschedule_event_bloc.dart │ │ │ │ │ │ ├── calendar.dart │ │ │ │ │ │ └── presentation/ │ │ │ │ │ │ ├── calendar_day.dart │ │ │ │ │ │ ├── calendar_event_card.dart │ │ │ │ │ │ ├── calendar_event_editor.dart │ │ │ │ │ │ ├── calendar_page.dart │ │ │ │ │ │ ├── layout/ │ │ │ │ │ │ │ └── sizes.dart │ │ │ │ │ │ └── toolbar/ │ │ │ │ │ │ ├── calendar_layout_setting.dart │ │ │ │ │ │ └── calendar_setting_bar.dart │ │ │ │ │ ├── domain/ │ │ │ │ │ │ ├── cell_listener.dart │ │ │ │ │ │ ├── cell_service.dart │ │ │ │ │ │ ├── checklist_cell_service.dart │ │ │ │ │ │ ├── database_view_service.dart │ │ │ │ │ │ ├── date_cell_service.dart │ │ │ │ │ │ ├── field_backend_service.dart │ │ │ │ │ │ ├── field_listener.dart │ │ │ │ │ │ ├── field_service.dart │ │ │ │ │ │ ├── field_settings_listener.dart │ │ │ │ │ │ ├── field_settings_service.dart │ │ │ │ │ │ ├── filter_listener.dart │ │ │ │ │ │ ├── filter_service.dart │ │ │ │ │ │ ├── group_listener.dart │ │ │ │ │ │ ├── group_service.dart │ │ │ │ │ │ ├── layout_service.dart │ │ │ │ │ │ ├── layout_setting_listener.dart │ │ │ │ │ │ ├── row_listener.dart │ │ │ │ │ │ ├── row_meta_listener.dart │ │ │ │ │ │ ├── select_option_cell_service.dart │ │ │ │ │ │ ├── sort_listener.dart │ │ │ │ │ │ ├── sort_service.dart │ │ │ │ │ │ └── type_option_service.dart │ │ │ │ │ ├── grid/ │ │ │ │ │ │ ├── application/ │ │ │ │ │ │ │ ├── calculations/ │ │ │ │ │ │ │ │ ├── calculations_bloc.dart │ │ │ │ │ │ │ │ └── field_type_calc_ext.dart │ │ │ │ │ │ │ ├── filter/ │ │ │ │ │ │ │ │ ├── filter_editor_bloc.dart │ │ │ │ │ │ │ │ └── select_option_loader.dart │ │ │ │ │ │ │ ├── grid_accessory_bloc.dart │ │ │ │ │ │ │ ├── grid_bloc.dart │ │ │ │ │ │ │ ├── grid_header_bloc.dart │ │ │ │ │ │ │ ├── row/ │ │ │ │ │ │ │ │ ├── mobile_row_detail_bloc.dart │ │ │ │ │ │ │ │ ├── row_bloc.dart │ │ │ │ │ │ │ │ ├── row_detail_bloc.dart │ │ │ │ │ │ │ │ └── row_document_bloc.dart │ │ │ │ │ │ │ ├── simple_text_filter_bloc.dart │ │ │ │ │ │ │ └── sort/ │ │ │ │ │ │ │ └── sort_editor_bloc.dart │ │ │ │ │ │ ├── grid.dart │ │ │ │ │ │ └── presentation/ │ │ │ │ │ │ ├── grid_page.dart │ │ │ │ │ │ ├── grid_scroll.dart │ │ │ │ │ │ ├── layout/ │ │ │ │ │ │ │ ├── layout.dart │ │ │ │ │ │ │ └── sizes.dart │ │ │ │ │ │ ├── mobile_grid_page.dart │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ ├── calculations/ │ │ │ │ │ │ │ ├── calculate_cell.dart │ │ │ │ │ │ │ ├── calculation_selector.dart │ │ │ │ │ │ │ ├── calculation_type_item.dart │ │ │ │ │ │ │ ├── calculations_row.dart │ │ │ │ │ │ │ └── remove_calculation_button.dart │ │ │ │ │ │ ├── common/ │ │ │ │ │ │ │ └── type_option_separator.dart │ │ │ │ │ │ ├── filter/ │ │ │ │ │ │ │ ├── choicechip/ │ │ │ │ │ │ │ │ ├── checkbox.dart │ │ │ │ │ │ │ │ ├── checklist.dart │ │ │ │ │ │ │ │ ├── choicechip.dart │ │ │ │ │ │ │ │ ├── date.dart │ │ │ │ │ │ │ │ ├── number.dart │ │ │ │ │ │ │ │ ├── select_option/ │ │ │ │ │ │ │ │ │ ├── condition_list.dart │ │ │ │ │ │ │ │ │ ├── option_list.dart │ │ │ │ │ │ │ │ │ └── select_option.dart │ │ │ │ │ │ │ │ ├── text.dart │ │ │ │ │ │ │ │ ├── time.dart │ │ │ │ │ │ │ │ └── url.dart │ │ │ │ │ │ │ ├── condition_button.dart │ │ │ │ │ │ │ ├── create_filter_list.dart │ │ │ │ │ │ │ ├── disclosure_button.dart │ │ │ │ │ │ │ ├── filter_menu.dart │ │ │ │ │ │ │ └── filter_menu_item.dart │ │ │ │ │ │ ├── footer/ │ │ │ │ │ │ │ └── grid_footer.dart │ │ │ │ │ │ ├── header/ │ │ │ │ │ │ │ ├── desktop_field_cell.dart │ │ │ │ │ │ │ ├── field_type_extension.dart │ │ │ │ │ │ │ ├── grid_header.dart │ │ │ │ │ │ │ ├── mobile_field_button.dart │ │ │ │ │ │ │ └── mobile_grid_header.dart │ │ │ │ │ │ ├── mobile_fab.dart │ │ │ │ │ │ ├── row/ │ │ │ │ │ │ │ ├── action.dart │ │ │ │ │ │ │ ├── mobile_row.dart │ │ │ │ │ │ │ └── row.dart │ │ │ │ │ │ ├── shortcuts.dart │ │ │ │ │ │ ├── sort/ │ │ │ │ │ │ │ ├── create_sort_list.dart │ │ │ │ │ │ │ ├── order_panel.dart │ │ │ │ │ │ │ ├── sort_choice_button.dart │ │ │ │ │ │ │ ├── sort_editor.dart │ │ │ │ │ │ │ └── sort_menu.dart │ │ │ │ │ │ └── toolbar/ │ │ │ │ │ │ ├── filter_button.dart │ │ │ │ │ │ ├── grid_setting_bar.dart │ │ │ │ │ │ ├── sort_button.dart │ │ │ │ │ │ └── view_database_button.dart │ │ │ │ │ ├── tab_bar/ │ │ │ │ │ │ ├── desktop/ │ │ │ │ │ │ │ ├── setting_menu.dart │ │ │ │ │ │ │ ├── tab_bar_add_button.dart │ │ │ │ │ │ │ └── tab_bar_header.dart │ │ │ │ │ │ ├── mobile/ │ │ │ │ │ │ │ └── mobile_tab_bar_header.dart │ │ │ │ │ │ └── tab_bar_view.dart │ │ │ │ │ └── widgets/ │ │ │ │ │ ├── card/ │ │ │ │ │ │ ├── card.dart │ │ │ │ │ │ ├── card_bloc.dart │ │ │ │ │ │ └── container/ │ │ │ │ │ │ ├── accessory.dart │ │ │ │ │ │ └── card_container.dart │ │ │ │ │ ├── cell/ │ │ │ │ │ │ ├── card_cell_builder.dart │ │ │ │ │ │ ├── card_cell_skeleton/ │ │ │ │ │ │ │ ├── card_cell.dart │ │ │ │ │ │ │ ├── checkbox_card_cell.dart │ │ │ │ │ │ │ ├── checklist_card_cell.dart │ │ │ │ │ │ │ ├── date_card_cell.dart │ │ │ │ │ │ │ ├── media_card_cell.dart │ │ │ │ │ │ │ ├── number_card_cell.dart │ │ │ │ │ │ │ ├── relation_card_cell.dart │ │ │ │ │ │ │ ├── select_option_card_cell.dart │ │ │ │ │ │ │ ├── summary_card_cell.dart │ │ │ │ │ │ │ ├── text_card_cell.dart │ │ │ │ │ │ │ ├── time_card_cell.dart │ │ │ │ │ │ │ ├── timestamp_card_cell.dart │ │ │ │ │ │ │ ├── translate_card_cell.dart │ │ │ │ │ │ │ └── url_card_cell.dart │ │ │ │ │ │ ├── card_cell_style_maps/ │ │ │ │ │ │ │ ├── calendar_card_cell_style.dart │ │ │ │ │ │ │ ├── desktop_board_card_cell_style.dart │ │ │ │ │ │ │ └── mobile_board_card_cell_style.dart │ │ │ │ │ │ ├── desktop_grid/ │ │ │ │ │ │ │ ├── desktop_grid_checkbox_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_checklist_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_date_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_media_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_number_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_relation_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_select_option_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_summary_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_text_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_time_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_timestamp_cell.dart │ │ │ │ │ │ │ ├── desktop_grid_translate_cell.dart │ │ │ │ │ │ │ └── desktop_grid_url_cell.dart │ │ │ │ │ │ ├── desktop_row_detail/ │ │ │ │ │ │ │ ├── desktop_row_detail_checkbox_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_checklist_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_date_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_media_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_number_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_relation_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_select_option_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_summary_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_text_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_time_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_timestamp_cell.dart │ │ │ │ │ │ │ ├── desktop_row_detail_url_cell.dart │ │ │ │ │ │ │ └── destop_row_detail_translate_cell.dart │ │ │ │ │ │ ├── editable_cell_builder.dart │ │ │ │ │ │ ├── editable_cell_skeleton/ │ │ │ │ │ │ │ ├── checkbox.dart │ │ │ │ │ │ │ ├── checklist.dart │ │ │ │ │ │ │ ├── date.dart │ │ │ │ │ │ │ ├── media.dart │ │ │ │ │ │ │ ├── number.dart │ │ │ │ │ │ │ ├── relation.dart │ │ │ │ │ │ │ ├── select_option.dart │ │ │ │ │ │ │ ├── summary.dart │ │ │ │ │ │ │ ├── text.dart │ │ │ │ │ │ │ ├── time.dart │ │ │ │ │ │ │ ├── timestamp.dart │ │ │ │ │ │ │ ├── translate.dart │ │ │ │ │ │ │ └── url.dart │ │ │ │ │ │ ├── mobile_grid/ │ │ │ │ │ │ │ ├── mobile_grid_checkbox_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_checklist_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_date_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_number_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_relation_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_select_option_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_summary_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_text_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_time_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_timestamp_cell.dart │ │ │ │ │ │ │ ├── mobile_grid_translate_cell.dart │ │ │ │ │ │ │ └── mobile_grid_url_cell.dart │ │ │ │ │ │ └── mobile_row_detail/ │ │ │ │ │ │ ├── mobile_row_detail_checkbox_cell.dart │ │ │ │ │ │ ├── mobile_row_detail_checklist_cell.dart │ │ │ │ │ │ ├── mobile_row_detail_date_cell.dart │ │ │ │ │ │ ├── mobile_row_detail_number_cell.dart │ │ │ │ │ │ ├── mobile_row_detail_relation_cell.dart │ │ │ │ │ │ ├── mobile_row_detail_select_cell_option.dart │ │ │ │ │ │ ├── mobile_row_detail_summary_cell.dart │ │ │ │ │ │ ├── mobile_row_detail_text_cell.dart │ │ │ │ │ │ ├── mobile_row_detail_time_cell.dart │ │ │ │ │ │ ├── mobile_row_detail_timestamp_cell.dart │ │ │ │ │ │ ├── mobile_row_detail_translate_cell.dart │ │ │ │ │ │ └── mobile_row_detail_url_cell.dart │ │ │ │ │ ├── cell_editor/ │ │ │ │ │ │ ├── checklist_cell_editor.dart │ │ │ │ │ │ ├── checklist_cell_textfield.dart │ │ │ │ │ │ ├── checklist_progress_bar.dart │ │ │ │ │ │ ├── date_cell_editor.dart │ │ │ │ │ │ ├── extension.dart │ │ │ │ │ │ ├── media_cell_editor.dart │ │ │ │ │ │ ├── mobile_checklist_cell_editor.dart │ │ │ │ │ │ ├── mobile_media_cell_editor.dart │ │ │ │ │ │ ├── mobile_select_option_editor.dart │ │ │ │ │ │ ├── relation_cell_editor.dart │ │ │ │ │ │ ├── select_option_cell_editor.dart │ │ │ │ │ │ └── select_option_text_field.dart │ │ │ │ │ ├── database_layout_ext.dart │ │ │ │ │ ├── database_view_widget.dart │ │ │ │ │ ├── field/ │ │ │ │ │ │ ├── field_editor.dart │ │ │ │ │ │ ├── field_type_list.dart │ │ │ │ │ │ └── type_option_editor/ │ │ │ │ │ │ ├── builder.dart │ │ │ │ │ │ ├── checkbox.dart │ │ │ │ │ │ ├── checklist.dart │ │ │ │ │ │ ├── date/ │ │ │ │ │ │ │ └── date_time_format.dart │ │ │ │ │ │ ├── date.dart │ │ │ │ │ │ ├── media.dart │ │ │ │ │ │ ├── multi_select.dart │ │ │ │ │ │ ├── number.dart │ │ │ │ │ │ ├── relation.dart │ │ │ │ │ │ ├── rich_text.dart │ │ │ │ │ │ ├── select/ │ │ │ │ │ │ │ ├── select_option.dart │ │ │ │ │ │ │ └── select_option_editor.dart │ │ │ │ │ │ ├── single_select.dart │ │ │ │ │ │ ├── summary.dart │ │ │ │ │ │ ├── time.dart │ │ │ │ │ │ ├── timestamp.dart │ │ │ │ │ │ ├── translate.dart │ │ │ │ │ │ └── url.dart │ │ │ │ │ ├── group/ │ │ │ │ │ │ └── database_group.dart │ │ │ │ │ ├── media_file_type_ext.dart │ │ │ │ │ ├── row/ │ │ │ │ │ │ ├── accessory/ │ │ │ │ │ │ │ ├── cell_accessory.dart │ │ │ │ │ │ │ └── cell_shortcuts.dart │ │ │ │ │ │ ├── cells/ │ │ │ │ │ │ │ ├── cell_container.dart │ │ │ │ │ │ │ └── mobile_cell_container.dart │ │ │ │ │ │ ├── relation_row_detail.dart │ │ │ │ │ │ ├── row_action.dart │ │ │ │ │ │ ├── row_banner.dart │ │ │ │ │ │ ├── row_detail.dart │ │ │ │ │ │ ├── row_document.dart │ │ │ │ │ │ └── row_property.dart │ │ │ │ │ ├── setting/ │ │ │ │ │ │ ├── database_layout_selector.dart │ │ │ │ │ │ ├── database_setting_action.dart │ │ │ │ │ │ ├── database_settings_list.dart │ │ │ │ │ │ ├── field_visibility_extension.dart │ │ │ │ │ │ ├── mobile_database_controls.dart │ │ │ │ │ │ ├── setting_button.dart │ │ │ │ │ │ └── setting_property_list.dart │ │ │ │ │ └── share_button.dart │ │ │ │ ├── database_document/ │ │ │ │ │ ├── database_document_page.dart │ │ │ │ │ ├── database_document_plugin.dart │ │ │ │ │ └── presentation/ │ │ │ │ │ ├── database_document_title.dart │ │ │ │ │ └── database_document_title_bloc.dart │ │ │ │ ├── document/ │ │ │ │ │ ├── application/ │ │ │ │ │ │ ├── doc_sync_state_listener.dart │ │ │ │ │ │ ├── document_appearance_cubit.dart │ │ │ │ │ │ ├── document_awareness_metadata.dart │ │ │ │ │ │ ├── document_bloc.dart │ │ │ │ │ │ ├── document_collab_adapter.dart │ │ │ │ │ │ ├── document_collaborators_bloc.dart │ │ │ │ │ │ ├── document_data_pb_extension.dart │ │ │ │ │ │ ├── document_diff.dart │ │ │ │ │ │ ├── document_listener.dart │ │ │ │ │ │ ├── document_rules.dart │ │ │ │ │ │ ├── document_service.dart │ │ │ │ │ │ ├── document_sync_bloc.dart │ │ │ │ │ │ ├── document_validator.dart │ │ │ │ │ │ ├── editor_transaction_adapter.dart │ │ │ │ │ │ └── prelude.dart │ │ │ │ │ ├── document.dart │ │ │ │ │ ├── document_page.dart │ │ │ │ │ └── presentation/ │ │ │ │ │ ├── banner.dart │ │ │ │ │ ├── collaborator_avatar_stack.dart │ │ │ │ │ ├── compact_mode_event.dart │ │ │ │ │ ├── document_collaborators.dart │ │ │ │ │ ├── editor_configuration.dart │ │ │ │ │ ├── editor_drop_handler.dart │ │ │ │ │ ├── editor_drop_manager.dart │ │ │ │ │ ├── editor_notification.dart │ │ │ │ │ ├── editor_page.dart │ │ │ │ │ ├── editor_plugins/ │ │ │ │ │ │ ├── actions/ │ │ │ │ │ │ │ ├── block_action_add_button.dart │ │ │ │ │ │ │ ├── block_action_button.dart │ │ │ │ │ │ │ ├── block_action_list.dart │ │ │ │ │ │ │ ├── block_action_option_button.dart │ │ │ │ │ │ │ ├── block_action_option_cubit.dart │ │ │ │ │ │ │ ├── drag_to_reorder/ │ │ │ │ │ │ │ │ ├── draggable_option_button.dart │ │ │ │ │ │ │ │ ├── draggable_option_button_feedback.dart │ │ │ │ │ │ │ │ ├── option_button.dart │ │ │ │ │ │ │ │ ├── util.dart │ │ │ │ │ │ │ │ └── visual_drag_area.dart │ │ │ │ │ │ │ ├── mobile_block_action_buttons.dart │ │ │ │ │ │ │ └── option/ │ │ │ │ │ │ │ ├── align_option_action.dart │ │ │ │ │ │ │ ├── color_option_action.dart │ │ │ │ │ │ │ ├── depth_option_action.dart │ │ │ │ │ │ │ ├── divider_option_action.dart │ │ │ │ │ │ │ ├── option_actions.dart │ │ │ │ │ │ │ └── turn_into_option_action.dart │ │ │ │ │ │ ├── ai/ │ │ │ │ │ │ │ ├── ai_writer_block_component.dart │ │ │ │ │ │ │ ├── ai_writer_toolbar_item.dart │ │ │ │ │ │ │ ├── operations/ │ │ │ │ │ │ │ │ ├── ai_writer_block_operations.dart │ │ │ │ │ │ │ │ ├── ai_writer_cubit.dart │ │ │ │ │ │ │ │ ├── ai_writer_entities.dart │ │ │ │ │ │ │ │ └── ai_writer_node_extension.dart │ │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ │ ├── ai_writer_gesture_detector.dart │ │ │ │ │ │ │ ├── ai_writer_prompt_input_more_button.dart │ │ │ │ │ │ │ ├── ai_writer_scroll_wrapper.dart │ │ │ │ │ │ │ └── ai_writer_suggestion_actions.dart │ │ │ │ │ │ ├── align_toolbar_item/ │ │ │ │ │ │ │ ├── align_toolbar_item.dart │ │ │ │ │ │ │ └── custom_text_align_command.dart │ │ │ │ │ │ ├── background_color/ │ │ │ │ │ │ │ └── theme_background_color.dart │ │ │ │ │ │ ├── base/ │ │ │ │ │ │ │ ├── backtick_character_command.dart │ │ │ │ │ │ │ ├── build_context_extension.dart │ │ │ │ │ │ │ ├── built_in_page_widget.dart │ │ │ │ │ │ │ ├── cover_title_command.dart │ │ │ │ │ │ │ ├── emoji_picker_button.dart │ │ │ │ │ │ │ ├── font_colors.dart │ │ │ │ │ │ │ ├── format_arrow_character.dart │ │ │ │ │ │ │ ├── insert_page_command.dart │ │ │ │ │ │ │ ├── link_to_page_widget.dart │ │ │ │ │ │ │ ├── markdown_text_robot.dart │ │ │ │ │ │ │ ├── page_reference_commands.dart │ │ │ │ │ │ │ ├── selectable_item_list_menu.dart │ │ │ │ │ │ │ ├── selectable_svg_widget.dart │ │ │ │ │ │ │ ├── string_extension.dart │ │ │ │ │ │ │ ├── text_robot.dart │ │ │ │ │ │ │ └── toolbar_extension.dart │ │ │ │ │ │ ├── block_menu/ │ │ │ │ │ │ │ └── block_menu_button.dart │ │ │ │ │ │ ├── block_transaction_handler/ │ │ │ │ │ │ │ └── block_transaction_handler.dart │ │ │ │ │ │ ├── bulleted_list/ │ │ │ │ │ │ │ └── bulleted_list_icon.dart │ │ │ │ │ │ ├── callout/ │ │ │ │ │ │ │ ├── callout_block_component.dart │ │ │ │ │ │ │ └── callout_block_shortcuts.dart │ │ │ │ │ │ ├── code_block/ │ │ │ │ │ │ │ ├── code_block_copy_button.dart │ │ │ │ │ │ │ ├── code_block_language_selector.dart │ │ │ │ │ │ │ ├── code_block_menu_item.dart │ │ │ │ │ │ │ └── code_language_screen.dart │ │ │ │ │ │ ├── columns/ │ │ │ │ │ │ │ ├── simple_column_block_component.dart │ │ │ │ │ │ │ ├── simple_column_block_width_resizer.dart │ │ │ │ │ │ │ ├── simple_column_node_extension.dart │ │ │ │ │ │ │ ├── simple_columns_block_component.dart │ │ │ │ │ │ │ └── simple_columns_block_constant.dart │ │ │ │ │ │ ├── context_menu/ │ │ │ │ │ │ │ └── custom_context_menu.dart │ │ │ │ │ │ ├── copy_and_paste/ │ │ │ │ │ │ │ ├── clipboard_service.dart │ │ │ │ │ │ │ ├── custom_copy_command.dart │ │ │ │ │ │ │ ├── custom_cut_command.dart │ │ │ │ │ │ │ ├── custom_paste_command.dart │ │ │ │ │ │ │ ├── paste_from_block_link.dart │ │ │ │ │ │ │ ├── paste_from_file.dart │ │ │ │ │ │ │ ├── paste_from_html.dart │ │ │ │ │ │ │ ├── paste_from_image.dart │ │ │ │ │ │ │ ├── paste_from_in_app_json.dart │ │ │ │ │ │ │ └── paste_from_plain_text.dart │ │ │ │ │ │ ├── cover/ │ │ │ │ │ │ │ ├── document_immersive_cover.dart │ │ │ │ │ │ │ └── document_immersive_cover_bloc.dart │ │ │ │ │ │ ├── database/ │ │ │ │ │ │ │ ├── database_view_block_component.dart │ │ │ │ │ │ │ ├── inline_database_menu_item.dart │ │ │ │ │ │ │ └── referenced_database_menu_item.dart │ │ │ │ │ │ ├── delta/ │ │ │ │ │ │ │ └── text_delta_extension.dart │ │ │ │ │ │ ├── desktop_toolbar/ │ │ │ │ │ │ │ ├── color_picker.dart │ │ │ │ │ │ │ ├── desktop_floating_toolbar.dart │ │ │ │ │ │ │ ├── link/ │ │ │ │ │ │ │ │ ├── link_create_menu.dart │ │ │ │ │ │ │ │ ├── link_edit_menu.dart │ │ │ │ │ │ │ │ ├── link_extension.dart │ │ │ │ │ │ │ │ ├── link_hover_menu.dart │ │ │ │ │ │ │ │ ├── link_replace_menu.dart │ │ │ │ │ │ │ │ ├── link_search_text_field.dart │ │ │ │ │ │ │ │ └── link_styles.dart │ │ │ │ │ │ │ └── toolbar_animation.dart │ │ │ │ │ │ ├── error/ │ │ │ │ │ │ │ └── error_block_component_builder.dart │ │ │ │ │ │ ├── extensions/ │ │ │ │ │ │ │ └── flowy_tint_extension.dart │ │ │ │ │ │ ├── file/ │ │ │ │ │ │ │ ├── file_block.dart │ │ │ │ │ │ │ ├── file_block_component.dart │ │ │ │ │ │ │ ├── file_block_menu.dart │ │ │ │ │ │ │ ├── file_selection_menu.dart │ │ │ │ │ │ │ ├── file_upload_menu.dart │ │ │ │ │ │ │ ├── file_util.dart │ │ │ │ │ │ │ └── mobile_file_upload_menu.dart │ │ │ │ │ │ ├── find_and_replace/ │ │ │ │ │ │ │ └── find_and_replace_menu.dart │ │ │ │ │ │ ├── font/ │ │ │ │ │ │ │ └── customize_font_toolbar_item.dart │ │ │ │ │ │ ├── header/ │ │ │ │ │ │ │ ├── cover_editor.dart │ │ │ │ │ │ │ ├── cover_editor_bloc.dart │ │ │ │ │ │ │ ├── cover_title.dart │ │ │ │ │ │ │ ├── custom_cover_picker.dart │ │ │ │ │ │ │ ├── custom_cover_picker_bloc.dart │ │ │ │ │ │ │ ├── desktop_cover.dart │ │ │ │ │ │ │ ├── document_cover_widget.dart │ │ │ │ │ │ │ └── emoji_icon_widget.dart │ │ │ │ │ │ ├── heading/ │ │ │ │ │ │ │ └── heading_toolbar_item.dart │ │ │ │ │ │ ├── i18n/ │ │ │ │ │ │ │ └── editor_i18n.dart │ │ │ │ │ │ ├── image/ │ │ │ │ │ │ │ ├── common.dart │ │ │ │ │ │ │ ├── custom_image_block_component/ │ │ │ │ │ │ │ │ ├── custom_image_block_component.dart │ │ │ │ │ │ │ │ ├── image_menu.dart │ │ │ │ │ │ │ │ └── unsupport_image_widget.dart │ │ │ │ │ │ │ ├── image_picker_screen.dart │ │ │ │ │ │ │ ├── image_placeholder.dart │ │ │ │ │ │ │ ├── image_selection_menu.dart │ │ │ │ │ │ │ ├── image_util.dart │ │ │ │ │ │ │ ├── mobile_image_toolbar_item.dart │ │ │ │ │ │ │ ├── multi_image_block_component/ │ │ │ │ │ │ │ │ ├── image_render.dart │ │ │ │ │ │ │ │ ├── layouts/ │ │ │ │ │ │ │ │ │ ├── image_browser_layout.dart │ │ │ │ │ │ │ │ │ ├── image_grid_layout.dart │ │ │ │ │ │ │ │ │ └── multi_image_layouts.dart │ │ │ │ │ │ │ │ ├── multi_image_block_component.dart │ │ │ │ │ │ │ │ ├── multi_image_menu.dart │ │ │ │ │ │ │ │ └── multi_image_placeholder.dart │ │ │ │ │ │ │ ├── resizeable_image.dart │ │ │ │ │ │ │ ├── unsplash_image_widget.dart │ │ │ │ │ │ │ └── upload_image_menu/ │ │ │ │ │ │ │ ├── upload_image_menu.dart │ │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ │ ├── embed_image_url_widget.dart │ │ │ │ │ │ │ └── upload_image_file_widget.dart │ │ │ │ │ │ ├── inline_math_equation/ │ │ │ │ │ │ │ ├── inline_math_equation.dart │ │ │ │ │ │ │ └── inline_math_equation_toolbar_item.dart │ │ │ │ │ │ ├── keyboard_interceptor/ │ │ │ │ │ │ │ └── keyboard_interceptor.dart │ │ │ │ │ │ ├── link_embed/ │ │ │ │ │ │ │ ├── link_embed_block_component.dart │ │ │ │ │ │ │ └── link_embed_menu.dart │ │ │ │ │ │ ├── link_preview/ │ │ │ │ │ │ │ ├── custom_link_parser.dart │ │ │ │ │ │ │ ├── custom_link_preview.dart │ │ │ │ │ │ │ ├── custom_link_preview_block_component.dart │ │ │ │ │ │ │ ├── default_selectable_mixin.dart │ │ │ │ │ │ │ ├── link_parsers/ │ │ │ │ │ │ │ │ ├── default_parser.dart │ │ │ │ │ │ │ │ └── youtube_parser.dart │ │ │ │ │ │ │ ├── link_preview_menu.dart │ │ │ │ │ │ │ ├── paste_as/ │ │ │ │ │ │ │ │ └── paste_as_menu.dart │ │ │ │ │ │ │ └── shared.dart │ │ │ │ │ │ ├── math_equation/ │ │ │ │ │ │ │ ├── math_equation_block_component.dart │ │ │ │ │ │ │ ├── math_equation_shortcut.dart │ │ │ │ │ │ │ └── mobile_math_equation_toolbar_item.dart │ │ │ │ │ │ ├── mention/ │ │ │ │ │ │ │ ├── child_page_transaction_handler.dart │ │ │ │ │ │ │ ├── date_transaction_handler.dart │ │ │ │ │ │ │ ├── mention_block.dart │ │ │ │ │ │ │ ├── mention_date_block.dart │ │ │ │ │ │ │ ├── mention_link_block.dart │ │ │ │ │ │ │ ├── mention_link_error_preview.dart │ │ │ │ │ │ │ ├── mention_link_preview.dart │ │ │ │ │ │ │ ├── mention_page_bloc.dart │ │ │ │ │ │ │ ├── mention_page_block.dart │ │ │ │ │ │ │ └── mobile_page_selector_sheet.dart │ │ │ │ │ │ ├── menu/ │ │ │ │ │ │ │ └── menu_extension.dart │ │ │ │ │ │ ├── migration/ │ │ │ │ │ │ │ └── editor_migration.dart │ │ │ │ │ │ ├── mobile_floating_toolbar/ │ │ │ │ │ │ │ └── custom_mobile_floating_toolbar.dart │ │ │ │ │ │ ├── mobile_toolbar_item/ │ │ │ │ │ │ │ ├── mobile_add_block_toolbar_item.dart │ │ │ │ │ │ │ └── mobile_block_settings_screen.dart │ │ │ │ │ │ ├── mobile_toolbar_v3/ │ │ │ │ │ │ │ ├── _get_selection_color.dart │ │ │ │ │ │ │ ├── aa_menu/ │ │ │ │ │ │ │ │ ├── _align_items.dart │ │ │ │ │ │ │ │ ├── _bius_items.dart │ │ │ │ │ │ │ │ ├── _block_items.dart │ │ │ │ │ │ │ │ ├── _close_keyboard_or_menu_button.dart │ │ │ │ │ │ │ │ ├── _color_item.dart │ │ │ │ │ │ │ │ ├── _color_list.dart │ │ │ │ │ │ │ │ ├── _font_item.dart │ │ │ │ │ │ │ │ ├── _heading_and_text_items.dart │ │ │ │ │ │ │ │ ├── _indent_items.dart │ │ │ │ │ │ │ │ ├── _menu_item.dart │ │ │ │ │ │ │ │ ├── _popup_menu.dart │ │ │ │ │ │ │ │ └── _toolbar_theme.dart │ │ │ │ │ │ │ ├── aa_toolbar_item.dart │ │ │ │ │ │ │ ├── add_attachment_item.dart │ │ │ │ │ │ │ ├── add_block_menu_item_builder.dart │ │ │ │ │ │ │ ├── add_block_toolbar_item.dart │ │ │ │ │ │ │ ├── appflowy_mobile_toolbar.dart │ │ │ │ │ │ │ ├── appflowy_mobile_toolbar_item.dart │ │ │ │ │ │ │ ├── basic_toolbar_item.dart │ │ │ │ │ │ │ ├── indent_outdent_toolbar_item.dart │ │ │ │ │ │ │ ├── keyboard_height_observer.dart │ │ │ │ │ │ │ ├── link_toolbar_item.dart │ │ │ │ │ │ │ ├── list_toolbar_item.dart │ │ │ │ │ │ │ ├── more_toolbar_item.dart │ │ │ │ │ │ │ ├── toolbar_item_builder.dart │ │ │ │ │ │ │ ├── undo_redo_toolbar_item.dart │ │ │ │ │ │ │ └── util.dart │ │ │ │ │ │ ├── numbered_list/ │ │ │ │ │ │ │ └── numbered_list_icon.dart │ │ │ │ │ │ ├── outline/ │ │ │ │ │ │ │ └── outline_block_component.dart │ │ │ │ │ │ ├── page_block/ │ │ │ │ │ │ │ └── custom_page_block_component.dart │ │ │ │ │ │ ├── page_style/ │ │ │ │ │ │ │ ├── _page_cover_bottom_sheet.dart │ │ │ │ │ │ │ ├── _page_style_cover_image.dart │ │ │ │ │ │ │ ├── _page_style_icon.dart │ │ │ │ │ │ │ ├── _page_style_icon_bloc.dart │ │ │ │ │ │ │ ├── _page_style_layout.dart │ │ │ │ │ │ │ ├── _page_style_util.dart │ │ │ │ │ │ │ └── page_style_bottom_sheet.dart │ │ │ │ │ │ ├── parsers/ │ │ │ │ │ │ │ ├── callout_node_parser.dart │ │ │ │ │ │ │ ├── custom_image_node_parser.dart │ │ │ │ │ │ │ ├── custom_paragraph_node_parser.dart │ │ │ │ │ │ │ ├── database_node_parser.dart │ │ │ │ │ │ │ ├── document_markdown_parsers.dart │ │ │ │ │ │ │ ├── file_block_node_parser.dart │ │ │ │ │ │ │ ├── link_preview_node_parser.dart │ │ │ │ │ │ │ ├── markdown_code_parser.dart │ │ │ │ │ │ │ ├── markdown_parsers.dart │ │ │ │ │ │ │ ├── markdown_simple_table_parser.dart │ │ │ │ │ │ │ ├── math_equation_node_parser.dart │ │ │ │ │ │ │ ├── simple_table_node_parser.dart │ │ │ │ │ │ │ ├── sub_page_node_parser.dart │ │ │ │ │ │ │ └── toggle_list_node_parser.dart │ │ │ │ │ │ ├── plugins.dart │ │ │ │ │ │ ├── quote/ │ │ │ │ │ │ │ ├── quote_block_component.dart │ │ │ │ │ │ │ └── quote_block_shortcuts.dart │ │ │ │ │ │ ├── shared_context/ │ │ │ │ │ │ │ └── shared_context.dart │ │ │ │ │ │ ├── shortcuts/ │ │ │ │ │ │ │ ├── character_shortcuts.dart │ │ │ │ │ │ │ ├── command_shortcuts.dart │ │ │ │ │ │ │ ├── custom_delete_command.dart │ │ │ │ │ │ │ ├── exit_edit_mode_command.dart │ │ │ │ │ │ │ ├── heading_block_shortcuts.dart │ │ │ │ │ │ │ └── numbered_list_block_shortcuts.dart │ │ │ │ │ │ ├── simple_table/ │ │ │ │ │ │ │ ├── simple_table.dart │ │ │ │ │ │ │ ├── simple_table_block_component.dart │ │ │ │ │ │ │ ├── simple_table_cell_block_component.dart │ │ │ │ │ │ │ ├── simple_table_constants.dart │ │ │ │ │ │ │ ├── simple_table_more_action.dart │ │ │ │ │ │ │ ├── simple_table_operations/ │ │ │ │ │ │ │ │ ├── simple_table_content_operation.dart │ │ │ │ │ │ │ │ ├── simple_table_delete_operation.dart │ │ │ │ │ │ │ │ ├── simple_table_duplicate_operation.dart │ │ │ │ │ │ │ │ ├── simple_table_header_operation.dart │ │ │ │ │ │ │ │ ├── simple_table_insert_operation.dart │ │ │ │ │ │ │ │ ├── simple_table_map_operation.dart │ │ │ │ │ │ │ │ ├── simple_table_node_extension.dart │ │ │ │ │ │ │ │ ├── simple_table_operations.dart │ │ │ │ │ │ │ │ ├── simple_table_reorder_operation.dart │ │ │ │ │ │ │ │ └── simple_table_style_operation.dart │ │ │ │ │ │ │ ├── simple_table_row_block_component.dart │ │ │ │ │ │ │ ├── simple_table_shortcuts/ │ │ │ │ │ │ │ │ ├── simple_table_arrow_down_command.dart │ │ │ │ │ │ │ │ ├── simple_table_arrow_left_command.dart │ │ │ │ │ │ │ │ ├── simple_table_arrow_right_command.dart │ │ │ │ │ │ │ │ ├── simple_table_arrow_up_command.dart │ │ │ │ │ │ │ │ ├── simple_table_backspace_command.dart │ │ │ │ │ │ │ │ ├── simple_table_command_extension.dart │ │ │ │ │ │ │ │ ├── simple_table_commands.dart │ │ │ │ │ │ │ │ ├── simple_table_enter_command.dart │ │ │ │ │ │ │ │ ├── simple_table_navigation_command.dart │ │ │ │ │ │ │ │ ├── simple_table_select_all_command.dart │ │ │ │ │ │ │ │ └── simple_table_tab_command.dart │ │ │ │ │ │ │ └── simple_table_widgets/ │ │ │ │ │ │ │ ├── _desktop_simple_table_widget.dart │ │ │ │ │ │ │ ├── _mobile_simple_table_widget.dart │ │ │ │ │ │ │ ├── _simple_table_bottom_sheet_actions.dart │ │ │ │ │ │ │ ├── simple_table_action_sheet.dart │ │ │ │ │ │ │ ├── simple_table_add_column_and_row_button.dart │ │ │ │ │ │ │ ├── simple_table_add_column_button.dart │ │ │ │ │ │ │ ├── simple_table_add_row_button.dart │ │ │ │ │ │ │ ├── simple_table_align_button.dart │ │ │ │ │ │ │ ├── simple_table_background_menu.dart │ │ │ │ │ │ │ ├── simple_table_basic_button.dart │ │ │ │ │ │ │ ├── simple_table_border_builder.dart │ │ │ │ │ │ │ ├── simple_table_bottom_sheet.dart │ │ │ │ │ │ │ ├── simple_table_column_resize_handle.dart │ │ │ │ │ │ │ ├── simple_table_divider.dart │ │ │ │ │ │ │ ├── simple_table_feedback.dart │ │ │ │ │ │ │ ├── simple_table_more_action_popup.dart │ │ │ │ │ │ │ ├── simple_table_reorder_button.dart │ │ │ │ │ │ │ ├── simple_table_widget.dart │ │ │ │ │ │ │ └── widgets.dart │ │ │ │ │ │ ├── slash_menu/ │ │ │ │ │ │ │ ├── slash_command.dart │ │ │ │ │ │ │ ├── slash_menu_items/ │ │ │ │ │ │ │ │ ├── ai_writer_item.dart │ │ │ │ │ │ │ │ ├── bulleted_list_item.dart │ │ │ │ │ │ │ │ ├── callout_item.dart │ │ │ │ │ │ │ │ ├── code_block_item.dart │ │ │ │ │ │ │ │ ├── database_items.dart │ │ │ │ │ │ │ │ ├── date_item.dart │ │ │ │ │ │ │ │ ├── divider_item.dart │ │ │ │ │ │ │ │ ├── emoji_item.dart │ │ │ │ │ │ │ │ ├── file_item.dart │ │ │ │ │ │ │ │ ├── heading_items.dart │ │ │ │ │ │ │ │ ├── image_item.dart │ │ │ │ │ │ │ │ ├── math_equation_item.dart │ │ │ │ │ │ │ │ ├── mobile_items.dart │ │ │ │ │ │ │ │ ├── numbered_list_item.dart │ │ │ │ │ │ │ │ ├── outline_item.dart │ │ │ │ │ │ │ │ ├── paragraph_item.dart │ │ │ │ │ │ │ │ ├── photo_gallery_item.dart │ │ │ │ │ │ │ │ ├── quote_item.dart │ │ │ │ │ │ │ │ ├── simple_columns_item.dart │ │ │ │ │ │ │ │ ├── simple_table_item.dart │ │ │ │ │ │ │ │ ├── slash_menu_item_builder.dart │ │ │ │ │ │ │ │ ├── slash_menu_items.dart │ │ │ │ │ │ │ │ ├── sub_page_item.dart │ │ │ │ │ │ │ │ ├── todo_list_item.dart │ │ │ │ │ │ │ │ └── toggle_list_item.dart │ │ │ │ │ │ │ └── slash_menu_items_builder.dart │ │ │ │ │ │ ├── sub_page/ │ │ │ │ │ │ │ ├── block_transaction_handler.dart │ │ │ │ │ │ │ ├── sub_page_block_component.dart │ │ │ │ │ │ │ └── sub_page_transaction_handler.dart │ │ │ │ │ │ ├── table/ │ │ │ │ │ │ │ ├── table_menu.dart │ │ │ │ │ │ │ └── table_option_action.dart │ │ │ │ │ │ ├── todo_list/ │ │ │ │ │ │ │ └── todo_list_icon.dart │ │ │ │ │ │ ├── toggle/ │ │ │ │ │ │ │ ├── toggle_block_component.dart │ │ │ │ │ │ │ └── toggle_block_shortcuts.dart │ │ │ │ │ │ ├── toolbar_item/ │ │ │ │ │ │ │ ├── custom_format_toolbar_items.dart │ │ │ │ │ │ │ ├── custom_hightlight_color_toolbar_item.dart │ │ │ │ │ │ │ ├── custom_link_toolbar_item.dart │ │ │ │ │ │ │ ├── custom_placeholder_toolbar_item.dart │ │ │ │ │ │ │ ├── custom_text_align_toolbar_item.dart │ │ │ │ │ │ │ ├── custom_text_color_toolbar_item.dart │ │ │ │ │ │ │ ├── more_option_toolbar_item.dart │ │ │ │ │ │ │ ├── text_heading_toolbar_item.dart │ │ │ │ │ │ │ ├── text_suggestions_toolbar_item.dart │ │ │ │ │ │ │ └── toolbar_id_enum.dart │ │ │ │ │ │ ├── transaction_handler/ │ │ │ │ │ │ │ ├── block_transaction_handler.dart │ │ │ │ │ │ │ ├── editor_transaction_handler.dart │ │ │ │ │ │ │ ├── editor_transaction_service.dart │ │ │ │ │ │ │ └── mention_transaction_handler.dart │ │ │ │ │ │ ├── undo_redo/ │ │ │ │ │ │ │ └── custom_undo_redo_commands.dart │ │ │ │ │ │ └── video/ │ │ │ │ │ │ └── video_block_component.dart │ │ │ │ │ └── editor_style.dart │ │ │ │ ├── emoji/ │ │ │ │ │ ├── emoji_actions_command.dart │ │ │ │ │ ├── emoji_handler.dart │ │ │ │ │ └── emoji_menu.dart │ │ │ │ ├── inline_actions/ │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ ├── child_page.dart │ │ │ │ │ │ ├── date_reference.dart │ │ │ │ │ │ ├── inline_page_reference.dart │ │ │ │ │ │ └── reminder_reference.dart │ │ │ │ │ ├── inline_actions_command.dart │ │ │ │ │ ├── inline_actions_menu.dart │ │ │ │ │ ├── inline_actions_result.dart │ │ │ │ │ ├── inline_actions_service.dart │ │ │ │ │ ├── service_handler.dart │ │ │ │ │ └── widgets/ │ │ │ │ │ ├── inline_actions_handler.dart │ │ │ │ │ └── inline_actions_menu_group.dart │ │ │ │ ├── shared/ │ │ │ │ │ ├── callback_shortcuts.dart │ │ │ │ │ ├── cover_type_ext.dart │ │ │ │ │ ├── share/ │ │ │ │ │ │ ├── _shared.dart │ │ │ │ │ │ ├── constants.dart │ │ │ │ │ │ ├── export_tab.dart │ │ │ │ │ │ ├── publish_color_extension.dart │ │ │ │ │ │ ├── publish_name_generator.dart │ │ │ │ │ │ ├── publish_tab.dart │ │ │ │ │ │ ├── share_bloc.dart │ │ │ │ │ │ ├── share_button.dart │ │ │ │ │ │ ├── share_menu.dart │ │ │ │ │ │ └── share_tab.dart │ │ │ │ │ └── sync_indicator.dart │ │ │ │ ├── trash/ │ │ │ │ │ ├── application/ │ │ │ │ │ │ ├── prelude.dart │ │ │ │ │ │ ├── trash_bloc.dart │ │ │ │ │ │ ├── trash_listener.dart │ │ │ │ │ │ └── trash_service.dart │ │ │ │ │ ├── src/ │ │ │ │ │ │ ├── sizes.dart │ │ │ │ │ │ ├── trash_cell.dart │ │ │ │ │ │ └── trash_header.dart │ │ │ │ │ ├── trash.dart │ │ │ │ │ └── trash_page.dart │ │ │ │ └── util.dart │ │ │ ├── shared/ │ │ │ │ ├── af_image.dart │ │ │ │ ├── af_role_pb_extension.dart │ │ │ │ ├── af_user_profile_extension.dart │ │ │ │ ├── appflowy_cache_manager.dart │ │ │ │ ├── appflowy_network_image.dart │ │ │ │ ├── appflowy_network_svg.dart │ │ │ │ ├── clipboard_state.dart │ │ │ │ ├── colors.dart │ │ │ │ ├── conditional_listenable_builder.dart │ │ │ │ ├── custom_image_cache_manager.dart │ │ │ │ ├── easy_localiation_service.dart │ │ │ │ ├── error_code/ │ │ │ │ │ └── error_code_map.dart │ │ │ │ ├── error_page/ │ │ │ │ │ └── error_page.dart │ │ │ │ ├── feature_flags.dart │ │ │ │ ├── feedback_gesture_detector.dart │ │ │ │ ├── flowy_error_page.dart │ │ │ │ ├── flowy_gradient_colors.dart │ │ │ │ ├── google_fonts_extension.dart │ │ │ │ ├── icon_emoji_picker/ │ │ │ │ │ ├── colors.dart │ │ │ │ │ ├── emoji_search_bar.dart │ │ │ │ │ ├── emoji_skin_tone.dart │ │ │ │ │ ├── flowy_icon_emoji_picker.dart │ │ │ │ │ ├── icon.dart │ │ │ │ │ ├── icon_color_picker.dart │ │ │ │ │ ├── icon_picker.dart │ │ │ │ │ ├── icon_search_bar.dart │ │ │ │ │ ├── icon_uploader.dart │ │ │ │ │ ├── recent_icons.dart │ │ │ │ │ └── tab.dart │ │ │ │ ├── list_extension.dart │ │ │ │ ├── loading.dart │ │ │ │ ├── markdown_to_document.dart │ │ │ │ ├── patterns/ │ │ │ │ │ ├── common_patterns.dart │ │ │ │ │ ├── date_time_patterns.dart │ │ │ │ │ └── file_type_patterns.dart │ │ │ │ ├── permission/ │ │ │ │ │ └── permission_checker.dart │ │ │ │ ├── popup_menu/ │ │ │ │ │ └── appflowy_popup_menu.dart │ │ │ │ ├── settings/ │ │ │ │ │ └── show_settings.dart │ │ │ │ ├── text_field/ │ │ │ │ │ └── text_filed_with_metric_lines.dart │ │ │ │ ├── time_format.dart │ │ │ │ ├── version_checker/ │ │ │ │ │ └── version_checker.dart │ │ │ │ └── window_title_bar.dart │ │ │ ├── startup/ │ │ │ │ ├── deps_resolver.dart │ │ │ │ ├── entry_point.dart │ │ │ │ ├── launch_configuration.dart │ │ │ │ ├── plugin/ │ │ │ │ │ ├── plugin.dart │ │ │ │ │ └── src/ │ │ │ │ │ ├── runner.dart │ │ │ │ │ └── sandbox.dart │ │ │ │ ├── startup.dart │ │ │ │ └── tasks/ │ │ │ │ ├── af_navigator_observer.dart │ │ │ │ ├── app_widget.dart │ │ │ │ ├── app_window_size_manager.dart │ │ │ │ ├── appflowy_cloud_task.dart │ │ │ │ ├── auto_update_task.dart │ │ │ │ ├── debug_task.dart │ │ │ │ ├── deeplink/ │ │ │ │ │ ├── deeplink_handler.dart │ │ │ │ │ ├── expire_login_deeplink_handler.dart │ │ │ │ │ ├── invitation_deeplink_handler.dart │ │ │ │ │ ├── login_deeplink_handler.dart │ │ │ │ │ ├── open_app_deeplink_handler.dart │ │ │ │ │ └── payment_deeplink_handler.dart │ │ │ │ ├── device_info_task.dart │ │ │ │ ├── feature_flag_task.dart │ │ │ │ ├── file_storage_task.dart │ │ │ │ ├── generate_router.dart │ │ │ │ ├── hot_key.dart │ │ │ │ ├── load_plugin.dart │ │ │ │ ├── localization.dart │ │ │ │ ├── memory_leak_detector.dart │ │ │ │ ├── platform_error_catcher.dart │ │ │ │ ├── platform_service.dart │ │ │ │ ├── prelude.dart │ │ │ │ ├── recent_service_task.dart │ │ │ │ ├── rust_sdk.dart │ │ │ │ └── windows.dart │ │ │ ├── user/ │ │ │ │ ├── application/ │ │ │ │ │ ├── anon_user_bloc.dart │ │ │ │ │ ├── auth/ │ │ │ │ │ │ ├── af_cloud_auth_service.dart │ │ │ │ │ │ ├── af_cloud_mock_auth_service.dart │ │ │ │ │ │ ├── auth_error.dart │ │ │ │ │ │ ├── auth_service.dart │ │ │ │ │ │ ├── backend_auth_service.dart │ │ │ │ │ │ └── device_id.dart │ │ │ │ │ ├── notification_filter/ │ │ │ │ │ │ └── notification_filter_bloc.dart │ │ │ │ │ ├── password/ │ │ │ │ │ │ ├── password_bloc.dart │ │ │ │ │ │ └── password_http_service.dart │ │ │ │ │ ├── prelude.dart │ │ │ │ │ ├── reminder/ │ │ │ │ │ │ ├── reminder_bloc.dart │ │ │ │ │ │ ├── reminder_extension.dart │ │ │ │ │ │ ├── reminder_listener.dart │ │ │ │ │ │ └── reminder_service.dart │ │ │ │ │ ├── sign_in_bloc.dart │ │ │ │ │ ├── sign_up_bloc.dart │ │ │ │ │ ├── splash_bloc.dart │ │ │ │ │ ├── user_auth_listener.dart │ │ │ │ │ ├── user_listener.dart │ │ │ │ │ ├── user_service.dart │ │ │ │ │ ├── user_settings_service.dart │ │ │ │ │ └── workspace_error_bloc.dart │ │ │ │ ├── domain/ │ │ │ │ │ └── auth_state.dart │ │ │ │ └── presentation/ │ │ │ │ ├── anon_user.dart │ │ │ │ ├── helpers/ │ │ │ │ │ ├── handle_open_workspace_error.dart │ │ │ │ │ └── helpers.dart │ │ │ │ ├── presentation.dart │ │ │ │ ├── router.dart │ │ │ │ ├── screens/ │ │ │ │ │ ├── screens.dart │ │ │ │ │ ├── sign_in_screen/ │ │ │ │ │ │ ├── desktop_sign_in_screen.dart │ │ │ │ │ │ ├── mobile_loading_screen.dart │ │ │ │ │ │ ├── mobile_sign_in_screen.dart │ │ │ │ │ │ ├── sign_in_screen.dart │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ ├── anonymous_sign_in_button/ │ │ │ │ │ │ │ └── anonymous_sign_in_button.dart │ │ │ │ │ │ ├── anonymous_sign_in_button.dart │ │ │ │ │ │ ├── continue_with/ │ │ │ │ │ │ │ ├── back_to_login_in_button.dart │ │ │ │ │ │ │ ├── continue_with_button.dart │ │ │ │ │ │ │ ├── continue_with_email.dart │ │ │ │ │ │ │ ├── continue_with_email_and_password.dart │ │ │ │ │ │ │ ├── continue_with_magic_link_or_passcode_page.dart │ │ │ │ │ │ │ ├── continue_with_password.dart │ │ │ │ │ │ │ ├── continue_with_password_page.dart │ │ │ │ │ │ │ ├── forgot_password_page.dart │ │ │ │ │ │ │ ├── reset_password.dart │ │ │ │ │ │ │ ├── reset_password_page.dart │ │ │ │ │ │ │ ├── set_new_password.dart │ │ │ │ │ │ │ ├── title_logo.dart │ │ │ │ │ │ │ └── verifying_button.dart │ │ │ │ │ │ ├── logo/ │ │ │ │ │ │ │ └── logo.dart │ │ │ │ │ │ ├── magic_link_sign_in_buttons.dart │ │ │ │ │ │ ├── sign_in_agreement.dart │ │ │ │ │ │ ├── sign_in_anonymous_button.dart │ │ │ │ │ │ ├── sign_in_or_logout_button.dart │ │ │ │ │ │ ├── switch_sign_in_sign_up_button.dart │ │ │ │ │ │ ├── third_party_sign_in_button/ │ │ │ │ │ │ │ ├── third_party_sign_in_button.dart │ │ │ │ │ │ │ └── third_party_sign_in_buttons.dart │ │ │ │ │ │ └── widgets.dart │ │ │ │ │ ├── skip_log_in_screen.dart │ │ │ │ │ ├── splash_screen.dart │ │ │ │ │ ├── workspace_error_screen.dart │ │ │ │ │ └── workspace_start_screen/ │ │ │ │ │ ├── desktop_workspace_start_screen.dart │ │ │ │ │ ├── mobile_workspace_start_screen.dart │ │ │ │ │ └── workspace_start_screen.dart │ │ │ │ └── widgets/ │ │ │ │ ├── auth_form_container.dart │ │ │ │ ├── flowy_logo_title.dart │ │ │ │ ├── folder_widget.dart │ │ │ │ └── widgets.dart │ │ │ ├── util/ │ │ │ │ ├── built_in_svgs.dart │ │ │ │ ├── color_generator/ │ │ │ │ │ └── color_generator.dart │ │ │ │ ├── color_to_hex_string.dart │ │ │ │ ├── debounce.dart │ │ │ │ ├── default_extensions.dart │ │ │ │ ├── expand_views.dart │ │ │ │ ├── field_type_extension.dart │ │ │ │ ├── file_extension.dart │ │ │ │ ├── font_family_extension.dart │ │ │ │ ├── int64_extension.dart │ │ │ │ ├── json_print.dart │ │ │ │ ├── levenshtein.dart │ │ │ │ ├── navigator_context_extension.dart │ │ │ │ ├── share_log_files.dart │ │ │ │ ├── string_extension.dart │ │ │ │ ├── theme_extension.dart │ │ │ │ ├── theme_mode_extension.dart │ │ │ │ ├── throttle.dart │ │ │ │ ├── time.dart │ │ │ │ └── xfile_ext.dart │ │ │ └── workspace/ │ │ │ ├── application/ │ │ │ │ ├── action_navigation/ │ │ │ │ │ ├── action_navigation_bloc.dart │ │ │ │ │ └── navigation_action.dart │ │ │ │ ├── appearance_defaults.dart │ │ │ │ ├── command_palette/ │ │ │ │ │ ├── command_palette_bloc.dart │ │ │ │ │ ├── search_result_ext.dart │ │ │ │ │ ├── search_result_list_bloc.dart │ │ │ │ │ └── search_service.dart │ │ │ │ ├── edit_panel/ │ │ │ │ │ ├── edit_context.dart │ │ │ │ │ └── edit_panel_bloc.dart │ │ │ │ ├── export/ │ │ │ │ │ └── document_exporter.dart │ │ │ │ ├── favorite/ │ │ │ │ │ ├── favorite_bloc.dart │ │ │ │ │ ├── favorite_listener.dart │ │ │ │ │ ├── favorite_service.dart │ │ │ │ │ └── prelude.dart │ │ │ │ ├── home/ │ │ │ │ │ ├── home_bloc.dart │ │ │ │ │ ├── home_setting_bloc.dart │ │ │ │ │ └── prelude.dart │ │ │ │ ├── menu/ │ │ │ │ │ ├── menu_user_bloc.dart │ │ │ │ │ ├── prelude.dart │ │ │ │ │ └── sidebar_sections_bloc.dart │ │ │ │ ├── notification/ │ │ │ │ │ └── notification_service.dart │ │ │ │ ├── recent/ │ │ │ │ │ ├── cached_recent_service.dart │ │ │ │ │ ├── prelude.dart │ │ │ │ │ ├── recent_listener.dart │ │ │ │ │ └── recent_views_bloc.dart │ │ │ │ ├── settings/ │ │ │ │ │ ├── ai/ │ │ │ │ │ │ ├── local_ai_bloc.dart │ │ │ │ │ │ ├── local_ai_on_boarding_bloc.dart │ │ │ │ │ │ ├── local_llm_listener.dart │ │ │ │ │ │ ├── ollama_setting_bloc.dart │ │ │ │ │ │ └── settings_ai_bloc.dart │ │ │ │ │ ├── appearance/ │ │ │ │ │ │ ├── appearance_cubit.dart │ │ │ │ │ │ ├── base_appearance.dart │ │ │ │ │ │ ├── desktop_appearance.dart │ │ │ │ │ │ └── mobile_appearance.dart │ │ │ │ │ ├── appflowy_cloud_setting_bloc.dart │ │ │ │ │ ├── appflowy_cloud_urls_bloc.dart │ │ │ │ │ ├── application_data_storage.dart │ │ │ │ │ ├── billing/ │ │ │ │ │ │ └── settings_billing_bloc.dart │ │ │ │ │ ├── cloud_setting_bloc.dart │ │ │ │ │ ├── cloud_setting_listener.dart │ │ │ │ │ ├── create_file_settings_cubit.dart │ │ │ │ │ ├── date_time/ │ │ │ │ │ │ ├── date_format_ext.dart │ │ │ │ │ │ └── time_format_ext.dart │ │ │ │ │ ├── file_storage/ │ │ │ │ │ │ └── file_storage_listener.dart │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ └── notification_settings_cubit.dart │ │ │ │ │ ├── plan/ │ │ │ │ │ │ ├── settings_plan_bloc.dart │ │ │ │ │ │ ├── workspace_subscription_ext.dart │ │ │ │ │ │ └── workspace_usage_ext.dart │ │ │ │ │ ├── prelude.dart │ │ │ │ │ ├── setting_file_importer_bloc.dart │ │ │ │ │ ├── settings_dialog_bloc.dart │ │ │ │ │ ├── settings_file_exporter_cubit.dart │ │ │ │ │ ├── share/ │ │ │ │ │ │ ├── export_service.dart │ │ │ │ │ │ └── import_service.dart │ │ │ │ │ ├── shortcuts/ │ │ │ │ │ │ ├── settings_shortcuts_cubit.dart │ │ │ │ │ │ ├── settings_shortcuts_service.dart │ │ │ │ │ │ └── shortcuts_model.dart │ │ │ │ │ └── workspace/ │ │ │ │ │ └── workspace_settings_bloc.dart │ │ │ │ ├── sidebar/ │ │ │ │ │ ├── billing/ │ │ │ │ │ │ └── sidebar_plan_bloc.dart │ │ │ │ │ ├── folder/ │ │ │ │ │ │ └── folder_bloc.dart │ │ │ │ │ ├── rename_view/ │ │ │ │ │ │ └── rename_view_bloc.dart │ │ │ │ │ └── space/ │ │ │ │ │ ├── space_bloc.dart │ │ │ │ │ └── space_search_bloc.dart │ │ │ │ ├── subscription_success_listenable/ │ │ │ │ │ └── subscription_success_listenable.dart │ │ │ │ ├── tabs/ │ │ │ │ │ └── tabs_bloc.dart │ │ │ │ ├── user/ │ │ │ │ │ ├── prelude.dart │ │ │ │ │ ├── settings_user_bloc.dart │ │ │ │ │ └── user_workspace_bloc.dart │ │ │ │ ├── view/ │ │ │ │ │ ├── prelude.dart │ │ │ │ │ ├── view_bloc.dart │ │ │ │ │ ├── view_ext.dart │ │ │ │ │ ├── view_listener.dart │ │ │ │ │ └── view_service.dart │ │ │ │ ├── view_info/ │ │ │ │ │ └── view_info_bloc.dart │ │ │ │ ├── view_title/ │ │ │ │ │ ├── view_title_bar_bloc.dart │ │ │ │ │ └── view_title_bloc.dart │ │ │ │ └── workspace/ │ │ │ │ ├── prelude.dart │ │ │ │ ├── workspace_bloc.dart │ │ │ │ ├── workspace_listener.dart │ │ │ │ ├── workspace_sections_listener.dart │ │ │ │ └── workspace_service.dart │ │ │ └── presentation/ │ │ │ ├── command_palette/ │ │ │ │ ├── command_palette.dart │ │ │ │ ├── navigation_bloc_extension.dart │ │ │ │ └── widgets/ │ │ │ │ ├── page_preview.dart │ │ │ │ ├── recent_views_list.dart │ │ │ │ ├── search_ask_ai_entrance.dart │ │ │ │ ├── search_field.dart │ │ │ │ ├── search_icon.dart │ │ │ │ ├── search_recent_view_cell.dart │ │ │ │ ├── search_result_cell.dart │ │ │ │ ├── search_results_list.dart │ │ │ │ └── search_summary_cell.dart │ │ │ ├── home/ │ │ │ │ ├── af_focus_manager.dart │ │ │ │ ├── desktop_home_screen.dart │ │ │ │ ├── errors/ │ │ │ │ │ └── workspace_failed_screen.dart │ │ │ │ ├── home_layout.dart │ │ │ │ ├── home_sizes.dart │ │ │ │ ├── home_stack.dart │ │ │ │ ├── hotkeys.dart │ │ │ │ ├── menu/ │ │ │ │ │ ├── menu_shared_state.dart │ │ │ │ │ ├── sidebar/ │ │ │ │ │ │ ├── favorites/ │ │ │ │ │ │ │ ├── favorite_folder.dart │ │ │ │ │ │ │ ├── favorite_menu.dart │ │ │ │ │ │ │ ├── favorite_menu_bloc.dart │ │ │ │ │ │ │ ├── favorite_more_actions.dart │ │ │ │ │ │ │ ├── favorite_pin_action.dart │ │ │ │ │ │ │ └── favorite_pin_bloc.dart │ │ │ │ │ │ ├── folder/ │ │ │ │ │ │ │ ├── _folder_header.dart │ │ │ │ │ │ │ └── _section_folder.dart │ │ │ │ │ │ ├── footer/ │ │ │ │ │ │ │ ├── sidebar_footer.dart │ │ │ │ │ │ │ ├── sidebar_footer_button.dart │ │ │ │ │ │ │ ├── sidebar_toast.dart │ │ │ │ │ │ │ └── sidebar_upgrade_application_button.dart │ │ │ │ │ │ ├── header/ │ │ │ │ │ │ │ ├── sidebar_top_menu.dart │ │ │ │ │ │ │ └── sidebar_user.dart │ │ │ │ │ │ ├── import/ │ │ │ │ │ │ │ ├── import_panel.dart │ │ │ │ │ │ │ └── import_type.dart │ │ │ │ │ │ ├── move_to/ │ │ │ │ │ │ │ └── move_page_menu.dart │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ ├── sidebar_folder.dart │ │ │ │ │ │ │ ├── sidebar_new_page_button.dart │ │ │ │ │ │ │ └── sidebar_setting.dart │ │ │ │ │ │ ├── sidebar.dart │ │ │ │ │ │ ├── slider_menu_hover_trigger.dart │ │ │ │ │ │ ├── space/ │ │ │ │ │ │ │ ├── _extension.dart │ │ │ │ │ │ │ ├── create_space_popup.dart │ │ │ │ │ │ │ ├── manage_space_popup.dart │ │ │ │ │ │ │ ├── shared_widget.dart │ │ │ │ │ │ │ ├── sidebar_space.dart │ │ │ │ │ │ │ ├── sidebar_space_header.dart │ │ │ │ │ │ │ ├── sidebar_space_menu.dart │ │ │ │ │ │ │ ├── space_action_type.dart │ │ │ │ │ │ │ ├── space_icon.dart │ │ │ │ │ │ │ ├── space_icon_popup.dart │ │ │ │ │ │ │ ├── space_migration.dart │ │ │ │ │ │ │ └── space_more_popup.dart │ │ │ │ │ │ └── workspace/ │ │ │ │ │ │ ├── _sidebar_import_notion.dart │ │ │ │ │ │ ├── _sidebar_workspace_actions.dart │ │ │ │ │ │ ├── _sidebar_workspace_icon.dart │ │ │ │ │ │ ├── _sidebar_workspace_menu.dart │ │ │ │ │ │ ├── sidebar_workspace.dart │ │ │ │ │ │ └── workspace_notifier.dart │ │ │ │ │ └── view/ │ │ │ │ │ ├── draggable_view_item.dart │ │ │ │ │ ├── view_action_type.dart │ │ │ │ │ ├── view_add_button.dart │ │ │ │ │ ├── view_item.dart │ │ │ │ │ └── view_more_action_button.dart │ │ │ │ ├── navigation.dart │ │ │ │ ├── tabs/ │ │ │ │ │ ├── flowy_tab.dart │ │ │ │ │ └── tabs_manager.dart │ │ │ │ └── toast.dart │ │ │ ├── notifications/ │ │ │ │ ├── notification_panel.dart │ │ │ │ ├── number_red_dot.dart │ │ │ │ ├── reminder_extension.dart │ │ │ │ └── widgets/ │ │ │ │ ├── flowy_tab.dart │ │ │ │ ├── inbox_action_bar.dart │ │ │ │ ├── notification_button.dart │ │ │ │ ├── notification_content_v2.dart │ │ │ │ ├── notification_hub_title.dart │ │ │ │ ├── notification_item.dart │ │ │ │ ├── notification_item_v2.dart │ │ │ │ ├── notification_tab.dart │ │ │ │ ├── notification_tab_bar.dart │ │ │ │ ├── notification_view.dart │ │ │ │ └── notifications_hub_empty.dart │ │ │ ├── settings/ │ │ │ │ ├── pages/ │ │ │ │ │ ├── about/ │ │ │ │ │ │ └── app_version.dart │ │ │ │ │ ├── account/ │ │ │ │ │ │ ├── account.dart │ │ │ │ │ │ ├── account_deletion.dart │ │ │ │ │ │ ├── account_sign_in_out.dart │ │ │ │ │ │ ├── account_user_profile.dart │ │ │ │ │ │ ├── email/ │ │ │ │ │ │ │ └── email_section.dart │ │ │ │ │ │ └── password/ │ │ │ │ │ │ ├── change_password.dart │ │ │ │ │ │ ├── error_extensions.dart │ │ │ │ │ │ ├── password_suffix_icon.dart │ │ │ │ │ │ └── setup_password.dart │ │ │ │ │ ├── fix_data_widget.dart │ │ │ │ │ ├── setting_ai_view/ │ │ │ │ │ │ ├── local_ai_setting.dart │ │ │ │ │ │ ├── local_settings_ai_view.dart │ │ │ │ │ │ ├── model_selection.dart │ │ │ │ │ │ ├── ollama_setting.dart │ │ │ │ │ │ ├── plugin_status_indicator.dart │ │ │ │ │ │ └── settings_ai_view.dart │ │ │ │ │ ├── settings_account_view.dart │ │ │ │ │ ├── settings_billing_view.dart │ │ │ │ │ ├── settings_manage_data_view.dart │ │ │ │ │ ├── settings_plan_comparison_dialog.dart │ │ │ │ │ ├── settings_plan_view.dart │ │ │ │ │ ├── settings_shortcuts_view.dart │ │ │ │ │ ├── settings_workspace_view.dart │ │ │ │ │ └── sites/ │ │ │ │ │ ├── constants.dart │ │ │ │ │ ├── domain/ │ │ │ │ │ │ ├── domain_header.dart │ │ │ │ │ │ ├── domain_item.dart │ │ │ │ │ │ ├── domain_more_action.dart │ │ │ │ │ │ ├── domain_settings_dialog.dart │ │ │ │ │ │ └── home_page_menu.dart │ │ │ │ │ ├── publish_info_view_item.dart │ │ │ │ │ ├── published_page/ │ │ │ │ │ │ ├── published_view_item.dart │ │ │ │ │ │ ├── published_view_item_header.dart │ │ │ │ │ │ ├── published_view_more_action.dart │ │ │ │ │ │ └── published_view_settings_dialog.dart │ │ │ │ │ ├── settings_sites_bloc.dart │ │ │ │ │ └── settings_sites_view.dart │ │ │ │ ├── settings_dialog.dart │ │ │ │ ├── shared/ │ │ │ │ │ ├── af_dropdown_menu_entry.dart │ │ │ │ │ ├── document_color_setting_button.dart │ │ │ │ │ ├── flowy_gradient_button.dart │ │ │ │ │ ├── setting_action.dart │ │ │ │ │ ├── setting_list_tile.dart │ │ │ │ │ ├── setting_value_dropdown.dart │ │ │ │ │ ├── settings_actionable_input.dart │ │ │ │ │ ├── settings_alert_dialog.dart │ │ │ │ │ ├── settings_body.dart │ │ │ │ │ ├── settings_category.dart │ │ │ │ │ ├── settings_category_spacer.dart │ │ │ │ │ ├── settings_dashed_divider.dart │ │ │ │ │ ├── settings_dropdown.dart │ │ │ │ │ ├── settings_header.dart │ │ │ │ │ ├── settings_input_field.dart │ │ │ │ │ ├── settings_radio_select.dart │ │ │ │ │ ├── settings_subcategory.dart │ │ │ │ │ └── single_setting_action.dart │ │ │ │ └── widgets/ │ │ │ │ ├── _restart_app_button.dart │ │ │ │ ├── cancel_plan_survey_dialog.dart │ │ │ │ ├── emoji_picker/ │ │ │ │ │ ├── emoji_picker.dart │ │ │ │ │ ├── emoji_shortcut_event.dart │ │ │ │ │ └── src/ │ │ │ │ │ ├── default_emoji_picker_view.dart │ │ │ │ │ ├── emji_picker_config.dart │ │ │ │ │ ├── emoji_lists.dart │ │ │ │ │ ├── emoji_picker.dart │ │ │ │ │ ├── emoji_picker_builder.dart │ │ │ │ │ ├── emoji_view_state.dart │ │ │ │ │ ├── flowy_emoji_picker_config.dart │ │ │ │ │ └── models/ │ │ │ │ │ ├── emoji_category_models.dart │ │ │ │ │ ├── emoji_model.dart │ │ │ │ │ └── recent_emoji_model.dart │ │ │ │ ├── feature_flags/ │ │ │ │ │ ├── feature_flag_page.dart │ │ │ │ │ └── mobile_feature_flag_screen.dart │ │ │ │ ├── files/ │ │ │ │ │ ├── settings_export_file_widget.dart │ │ │ │ │ └── settings_file_exporter_widget.dart │ │ │ │ ├── members/ │ │ │ │ │ ├── invitation/ │ │ │ │ │ │ ├── invite_member_by_email.dart │ │ │ │ │ │ ├── invite_member_by_link.dart │ │ │ │ │ │ ├── m_invite_member_by_email.dart │ │ │ │ │ │ ├── m_invite_member_by_link.dart │ │ │ │ │ │ └── member_http_service.dart │ │ │ │ │ ├── workspace_member_bloc.dart │ │ │ │ │ └── workspace_member_page.dart │ │ │ │ ├── setting_appflowy_cloud.dart │ │ │ │ ├── setting_cloud.dart │ │ │ │ ├── setting_local_cloud.dart │ │ │ │ ├── setting_third_party_login.dart │ │ │ │ ├── settings_menu.dart │ │ │ │ ├── settings_menu_element.dart │ │ │ │ ├── settings_notifications_view.dart │ │ │ │ ├── theme_upload/ │ │ │ │ │ ├── theme_confirm_delete_dialog.dart │ │ │ │ │ ├── theme_upload.dart │ │ │ │ │ ├── theme_upload_button.dart │ │ │ │ │ ├── theme_upload_decoration.dart │ │ │ │ │ ├── theme_upload_failure_widget.dart │ │ │ │ │ ├── theme_upload_learn_more_button.dart │ │ │ │ │ ├── theme_upload_loading_widget.dart │ │ │ │ │ ├── theme_upload_view.dart │ │ │ │ │ └── upload_new_theme_widget.dart │ │ │ │ ├── utils/ │ │ │ │ │ ├── form_factor.dart │ │ │ │ │ └── hex_opacity_string_extension.dart │ │ │ │ └── web_url_hint_widget.dart │ │ │ └── widgets/ │ │ │ ├── date_picker/ │ │ │ │ ├── appflowy_date_picker_base.dart │ │ │ │ ├── desktop_date_picker.dart │ │ │ │ ├── mobile_date_picker.dart │ │ │ │ ├── utils/ │ │ │ │ │ ├── date_time_format_ext.dart │ │ │ │ │ ├── layout.dart │ │ │ │ │ └── user_time_format_ext.dart │ │ │ │ └── widgets/ │ │ │ │ ├── clear_date_button.dart │ │ │ │ ├── date_picker.dart │ │ │ │ ├── date_picker_dialog.dart │ │ │ │ ├── date_time_settings.dart │ │ │ │ ├── date_time_text_field.dart │ │ │ │ ├── date_type_option_button.dart │ │ │ │ ├── end_time_button.dart │ │ │ │ ├── mobile_date_editor.dart │ │ │ │ ├── mobile_date_header.dart │ │ │ │ └── reminder_selector.dart │ │ │ ├── dialog_v2.dart │ │ │ ├── dialogs.dart │ │ │ ├── draggable_item/ │ │ │ │ └── draggable_item.dart │ │ │ ├── edit_panel/ │ │ │ │ ├── edit_panel.dart │ │ │ │ └── panel_animation.dart │ │ │ ├── favorite_button.dart │ │ │ ├── float_bubble/ │ │ │ │ ├── question_bubble.dart │ │ │ │ ├── social_media_section.dart │ │ │ │ └── version_section.dart │ │ │ ├── image_viewer/ │ │ │ │ ├── image_provider.dart │ │ │ │ ├── interactive_image_toolbar.dart │ │ │ │ └── interactive_image_viewer.dart │ │ │ ├── more_view_actions/ │ │ │ │ ├── more_view_actions.dart │ │ │ │ └── widgets/ │ │ │ │ ├── common_view_action.dart │ │ │ │ ├── font_size_action.dart │ │ │ │ ├── font_size_stepper.dart │ │ │ │ ├── lock_page_action.dart │ │ │ │ └── view_meta_info.dart │ │ │ ├── pop_up_action.dart │ │ │ ├── rename_view_popover.dart │ │ │ ├── sidebar_resizer.dart │ │ │ ├── tab_bar_item.dart │ │ │ ├── toggle/ │ │ │ │ └── toggle.dart │ │ │ ├── user_avatar.dart │ │ │ └── view_title_bar.dart │ │ ├── linux/ │ │ │ ├── .gitignore │ │ │ ├── CMakeLists.txt │ │ │ ├── flutter/ │ │ │ │ ├── CMakeLists.txt │ │ │ │ └── dart_ffi/ │ │ │ │ └── binding.h │ │ │ ├── main.cc │ │ │ ├── my_application.cc │ │ │ ├── my_application.h │ │ │ └── packaging/ │ │ │ ├── deb/ │ │ │ │ └── make_config.yaml │ │ │ └── rpm/ │ │ │ └── make_config.yaml │ │ ├── macos/ │ │ │ ├── .gitignore │ │ │ ├── Flutter/ │ │ │ │ ├── Flutter-Debug.xcconfig │ │ │ │ └── Flutter-Release.xcconfig │ │ │ ├── Podfile │ │ │ ├── Runner/ │ │ │ │ ├── AppDelegate.swift │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ └── AppIcon.appiconset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── Base.lproj/ │ │ │ │ │ └── MainMenu.xib │ │ │ │ ├── Configs/ │ │ │ │ │ ├── AppInfo.xcconfig │ │ │ │ │ ├── Debug.xcconfig │ │ │ │ │ ├── Release.xcconfig │ │ │ │ │ └── Warnings.xcconfig │ │ │ │ ├── DebugProfile.entitlements │ │ │ │ ├── Info.plist │ │ │ │ ├── MainFlutterWindow.swift │ │ │ │ └── Release.entitlements │ │ │ ├── Runner.xcodeproj/ │ │ │ │ ├── project.pbxproj │ │ │ │ ├── project.xcworkspace/ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ │ └── xcshareddata/ │ │ │ │ └── xcschemes/ │ │ │ │ └── Runner.xcscheme │ │ │ ├── Runner.xcworkspace/ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ └── xcshareddata/ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ └── build/ │ │ │ └── ios/ │ │ │ └── XCBuildData/ │ │ │ └── PIFCache/ │ │ │ ├── project/ │ │ │ │ └── PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1-json │ │ │ └── workspace/ │ │ │ └── WORKSPACE@v11_hash=(null)_subobjects=9b6915bad2214bcc5eb58b855fe7b55a-json │ │ ├── packages/ │ │ │ ├── appflowy_backend/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .metadata │ │ │ │ ├── CHANGELOG.md │ │ │ │ ├── LICENSE │ │ │ │ ├── README.md │ │ │ │ ├── analysis_options.yaml │ │ │ │ ├── android/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── build.gradle │ │ │ │ │ ├── gradle/ │ │ │ │ │ │ └── wrapper/ │ │ │ │ │ │ └── gradle-wrapper.properties │ │ │ │ │ ├── gradle.properties │ │ │ │ │ ├── settings.gradle │ │ │ │ │ └── src/ │ │ │ │ │ └── main/ │ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ │ └── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── plugin/ │ │ │ │ │ └── appflowy_backend/ │ │ │ │ │ └── AppFlowyBackendPlugin.kt │ │ │ │ ├── example/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .metadata │ │ │ │ │ ├── README.md │ │ │ │ │ ├── analysis_options.yaml │ │ │ │ │ ├── android/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ ├── build.gradle │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── debug/ │ │ │ │ │ │ │ │ └── AndroidManifest.xml │ │ │ │ │ │ │ ├── main/ │ │ │ │ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ │ │ │ │ ├── kotlin/ │ │ │ │ │ │ │ │ │ └── com/ │ │ │ │ │ │ │ │ │ └── plugin/ │ │ │ │ │ │ │ │ │ └── flowy_sdk_example/ │ │ │ │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ │ │ │ │ └── res/ │ │ │ │ │ │ │ │ ├── drawable/ │ │ │ │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ │ │ │ ├── drawable-v21/ │ │ │ │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ │ │ │ ├── values/ │ │ │ │ │ │ │ │ │ └── styles.xml │ │ │ │ │ │ │ │ └── values-night/ │ │ │ │ │ │ │ │ └── styles.xml │ │ │ │ │ │ │ └── profile/ │ │ │ │ │ │ │ └── AndroidManifest.xml │ │ │ │ │ │ ├── build.gradle │ │ │ │ │ │ ├── gradle/ │ │ │ │ │ │ │ └── wrapper/ │ │ │ │ │ │ │ └── gradle-wrapper.properties │ │ │ │ │ │ ├── gradle.properties │ │ │ │ │ │ └── settings.gradle │ │ │ │ │ ├── integration_test/ │ │ │ │ │ │ ├── app_test.dart │ │ │ │ │ │ └── driver.dart │ │ │ │ │ ├── ios/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── Flutter/ │ │ │ │ │ │ │ ├── AppFrameworkInfo.plist │ │ │ │ │ │ │ ├── Debug.xcconfig │ │ │ │ │ │ │ └── Release.xcconfig │ │ │ │ │ │ ├── Podfile │ │ │ │ │ │ ├── Runner/ │ │ │ │ │ │ │ ├── AppDelegate.swift │ │ │ │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ │ │ │ └── LaunchImage.imageset/ │ │ │ │ │ │ │ │ ├── Contents.json │ │ │ │ │ │ │ │ └── README.md │ │ │ │ │ │ │ ├── Base.lproj/ │ │ │ │ │ │ │ │ ├── LaunchScreen.storyboard │ │ │ │ │ │ │ │ └── Main.storyboard │ │ │ │ │ │ │ ├── Info.plist │ │ │ │ │ │ │ └── Runner-Bridging-Header.h │ │ │ │ │ │ ├── Runner.xcodeproj/ │ │ │ │ │ │ │ ├── project.pbxproj │ │ │ │ │ │ │ ├── project.xcworkspace/ │ │ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ └── xcschemes/ │ │ │ │ │ │ │ └── Runner.xcscheme │ │ │ │ │ │ └── Runner.xcworkspace/ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ │ │ ├── lib/ │ │ │ │ │ │ └── main.dart │ │ │ │ │ ├── macos/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── Flutter/ │ │ │ │ │ │ │ ├── Flutter-Debug.xcconfig │ │ │ │ │ │ │ └── Flutter-Release.xcconfig │ │ │ │ │ │ ├── Podfile │ │ │ │ │ │ ├── Runner/ │ │ │ │ │ │ │ ├── AppDelegate.swift │ │ │ │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ │ │ │ └── AppIcon.appiconset/ │ │ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ │ │ ├── Base.lproj/ │ │ │ │ │ │ │ │ └── MainMenu.xib │ │ │ │ │ │ │ ├── Configs/ │ │ │ │ │ │ │ │ ├── AppInfo.xcconfig │ │ │ │ │ │ │ │ ├── Debug.xcconfig │ │ │ │ │ │ │ │ ├── Release.xcconfig │ │ │ │ │ │ │ │ └── Warnings.xcconfig │ │ │ │ │ │ │ ├── DebugProfile.entitlements │ │ │ │ │ │ │ ├── Info.plist │ │ │ │ │ │ │ ├── MainFlutterWindow.swift │ │ │ │ │ │ │ └── Release.entitlements │ │ │ │ │ │ ├── Runner.xcodeproj/ │ │ │ │ │ │ │ ├── project.pbxproj │ │ │ │ │ │ │ ├── project.xcworkspace/ │ │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ └── xcschemes/ │ │ │ │ │ │ │ └── Runner.xcscheme │ │ │ │ │ │ └── Runner.xcworkspace/ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ │ │ ├── pubspec.yaml │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── widget_test.dart │ │ │ │ │ └── windows/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ ├── flutter/ │ │ │ │ │ │ └── CMakeLists.txt │ │ │ │ │ └── runner/ │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ ├── Runner.rc │ │ │ │ │ ├── flutter_window.cpp │ │ │ │ │ ├── flutter_window.h │ │ │ │ │ ├── main.cpp │ │ │ │ │ ├── resource.h │ │ │ │ │ ├── runner.exe.manifest │ │ │ │ │ ├── utils.cpp │ │ │ │ │ ├── utils.h │ │ │ │ │ ├── win32_window.cpp │ │ │ │ │ └── win32_window.h │ │ │ │ ├── ios/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── Assets/ │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ ├── Classes/ │ │ │ │ │ │ ├── AppFlowyBackendPlugin.h │ │ │ │ │ │ ├── AppFlowyBackendPlugin.m │ │ │ │ │ │ ├── AppFlowyBackendPlugin.swift │ │ │ │ │ │ └── binding.h │ │ │ │ │ └── appflowy_backend.podspec │ │ │ │ ├── lib/ │ │ │ │ │ ├── appflowy_backend.dart │ │ │ │ │ ├── appflowy_backend_method_channel.dart │ │ │ │ │ ├── appflowy_backend_platform_interface.dart │ │ │ │ │ ├── dispatch/ │ │ │ │ │ │ ├── dispatch.dart │ │ │ │ │ │ └── error.dart │ │ │ │ │ ├── ffi.dart │ │ │ │ │ ├── log.dart │ │ │ │ │ └── rust_stream.dart │ │ │ │ ├── linux/ │ │ │ │ │ └── Classes/ │ │ │ │ │ └── binding.h │ │ │ │ ├── macos/ │ │ │ │ │ ├── Classes/ │ │ │ │ │ │ ├── AppFlowyBackendPlugin.swift │ │ │ │ │ │ └── binding.h │ │ │ │ │ └── appflowy_backend.podspec │ │ │ │ ├── pubspec.yaml │ │ │ │ ├── test/ │ │ │ │ │ ├── appflowy_backend_method_channel_test.dart │ │ │ │ │ └── appflowy_backend_test.dart │ │ │ │ └── windows/ │ │ │ │ ├── .gitignore │ │ │ │ ├── CMakeLists.txt │ │ │ │ ├── app_flowy_backend_plugin.h │ │ │ │ ├── appflowy_backend_plugin.cpp │ │ │ │ ├── appflowy_backend_plugin_c_api.cpp │ │ │ │ └── include/ │ │ │ │ └── appflowy_backend/ │ │ │ │ ├── app_flowy_backend_plugin.h │ │ │ │ └── appflowy_backend_plugin_c_api.h │ │ │ ├── appflowy_popover/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .metadata │ │ │ │ ├── CHANGELOG.md │ │ │ │ ├── LICENSE │ │ │ │ ├── README.md │ │ │ │ ├── analysis_options.yaml │ │ │ │ ├── example/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .metadata │ │ │ │ │ ├── README.md │ │ │ │ │ ├── analysis_options.yaml │ │ │ │ │ ├── android/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ ├── build.gradle │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── debug/ │ │ │ │ │ │ │ │ └── AndroidManifest.xml │ │ │ │ │ │ │ ├── main/ │ │ │ │ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ │ │ │ │ ├── kotlin/ │ │ │ │ │ │ │ │ │ └── com/ │ │ │ │ │ │ │ │ │ └── example/ │ │ │ │ │ │ │ │ │ └── example/ │ │ │ │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ │ │ │ │ └── res/ │ │ │ │ │ │ │ │ ├── drawable/ │ │ │ │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ │ │ │ ├── drawable-v21/ │ │ │ │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ │ │ │ ├── values/ │ │ │ │ │ │ │ │ │ └── styles.xml │ │ │ │ │ │ │ │ └── values-night/ │ │ │ │ │ │ │ │ └── styles.xml │ │ │ │ │ │ │ └── profile/ │ │ │ │ │ │ │ └── AndroidManifest.xml │ │ │ │ │ │ ├── build.gradle │ │ │ │ │ │ ├── gradle/ │ │ │ │ │ │ │ └── wrapper/ │ │ │ │ │ │ │ └── gradle-wrapper.properties │ │ │ │ │ │ ├── gradle.properties │ │ │ │ │ │ └── settings.gradle │ │ │ │ │ ├── ios/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── Flutter/ │ │ │ │ │ │ │ ├── AppFrameworkInfo.plist │ │ │ │ │ │ │ ├── Debug.xcconfig │ │ │ │ │ │ │ └── Release.xcconfig │ │ │ │ │ │ ├── Runner/ │ │ │ │ │ │ │ ├── AppDelegate.swift │ │ │ │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ │ │ │ └── LaunchImage.imageset/ │ │ │ │ │ │ │ │ ├── Contents.json │ │ │ │ │ │ │ │ └── README.md │ │ │ │ │ │ │ ├── Base.lproj/ │ │ │ │ │ │ │ │ ├── LaunchScreen.storyboard │ │ │ │ │ │ │ │ └── Main.storyboard │ │ │ │ │ │ │ ├── Info.plist │ │ │ │ │ │ │ └── Runner-Bridging-Header.h │ │ │ │ │ │ ├── Runner.xcodeproj/ │ │ │ │ │ │ │ ├── project.pbxproj │ │ │ │ │ │ │ ├── project.xcworkspace/ │ │ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ └── xcschemes/ │ │ │ │ │ │ │ └── Runner.xcscheme │ │ │ │ │ │ └── Runner.xcworkspace/ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ │ │ ├── lib/ │ │ │ │ │ │ ├── example_button.dart │ │ │ │ │ │ └── main.dart │ │ │ │ │ ├── linux/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ │ ├── flutter/ │ │ │ │ │ │ │ └── CMakeLists.txt │ │ │ │ │ │ ├── main.cc │ │ │ │ │ │ ├── my_application.cc │ │ │ │ │ │ └── my_application.h │ │ │ │ │ ├── macos/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── Flutter/ │ │ │ │ │ │ │ ├── Flutter-Debug.xcconfig │ │ │ │ │ │ │ └── Flutter-Release.xcconfig │ │ │ │ │ │ ├── Runner/ │ │ │ │ │ │ │ ├── AppDelegate.swift │ │ │ │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ │ │ │ └── AppIcon.appiconset/ │ │ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ │ │ ├── Base.lproj/ │ │ │ │ │ │ │ │ └── MainMenu.xib │ │ │ │ │ │ │ ├── Configs/ │ │ │ │ │ │ │ │ ├── AppInfo.xcconfig │ │ │ │ │ │ │ │ ├── Debug.xcconfig │ │ │ │ │ │ │ │ ├── Release.xcconfig │ │ │ │ │ │ │ │ └── Warnings.xcconfig │ │ │ │ │ │ │ ├── DebugProfile.entitlements │ │ │ │ │ │ │ ├── Info.plist │ │ │ │ │ │ │ ├── MainFlutterWindow.swift │ │ │ │ │ │ │ └── Release.entitlements │ │ │ │ │ │ ├── Runner.xcodeproj/ │ │ │ │ │ │ │ ├── project.pbxproj │ │ │ │ │ │ │ ├── project.xcworkspace/ │ │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ └── xcschemes/ │ │ │ │ │ │ │ └── Runner.xcscheme │ │ │ │ │ │ └── Runner.xcworkspace/ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ │ │ ├── pubspec.yaml │ │ │ │ │ ├── web/ │ │ │ │ │ │ ├── index.html │ │ │ │ │ │ └── manifest.json │ │ │ │ │ └── windows/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ ├── flutter/ │ │ │ │ │ │ └── CMakeLists.txt │ │ │ │ │ └── runner/ │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ ├── Runner.rc │ │ │ │ │ ├── flutter_window.cpp │ │ │ │ │ ├── flutter_window.h │ │ │ │ │ ├── main.cpp │ │ │ │ │ ├── resource.h │ │ │ │ │ ├── runner.exe.manifest │ │ │ │ │ ├── utils.cpp │ │ │ │ │ ├── utils.h │ │ │ │ │ ├── win32_window.cpp │ │ │ │ │ └── win32_window.h │ │ │ │ ├── lib/ │ │ │ │ │ ├── appflowy_popover.dart │ │ │ │ │ └── src/ │ │ │ │ │ ├── follower.dart │ │ │ │ │ ├── layout.dart │ │ │ │ │ ├── mask.dart │ │ │ │ │ ├── mutex.dart │ │ │ │ │ └── popover.dart │ │ │ │ ├── pubspec.yaml │ │ │ │ └── test/ │ │ │ │ └── popover_test.dart │ │ │ ├── appflowy_result/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .metadata │ │ │ │ ├── CHANGELOG.md │ │ │ │ ├── LICENSE │ │ │ │ ├── README.md │ │ │ │ ├── analysis_options.yaml │ │ │ │ ├── lib/ │ │ │ │ │ ├── appflowy_result.dart │ │ │ │ │ └── src/ │ │ │ │ │ ├── async_result.dart │ │ │ │ │ └── result.dart │ │ │ │ └── pubspec.yaml │ │ │ ├── appflowy_ui/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .metadata │ │ │ │ ├── CHANGELOG.md │ │ │ │ ├── LICENSE │ │ │ │ ├── README.md │ │ │ │ ├── analysis_options.yaml │ │ │ │ ├── example/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .metadata │ │ │ │ │ ├── README.md │ │ │ │ │ ├── analysis_options.yaml │ │ │ │ │ ├── lib/ │ │ │ │ │ │ ├── main.dart │ │ │ │ │ │ └── src/ │ │ │ │ │ │ ├── avatar/ │ │ │ │ │ │ │ └── avatar_page.dart │ │ │ │ │ │ ├── buttons/ │ │ │ │ │ │ │ └── buttons_page.dart │ │ │ │ │ │ ├── dropdown_menu/ │ │ │ │ │ │ │ └── dropdown_menu_page.dart │ │ │ │ │ │ ├── menu/ │ │ │ │ │ │ │ └── menu_page.dart │ │ │ │ │ │ ├── modal/ │ │ │ │ │ │ │ └── modal_page.dart │ │ │ │ │ │ └── textfield/ │ │ │ │ │ │ └── textfield_page.dart │ │ │ │ │ ├── macos/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── Flutter/ │ │ │ │ │ │ │ ├── Flutter-Debug.xcconfig │ │ │ │ │ │ │ └── Flutter-Release.xcconfig │ │ │ │ │ │ ├── Podfile │ │ │ │ │ │ ├── Runner/ │ │ │ │ │ │ │ ├── AppDelegate.swift │ │ │ │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ │ │ │ └── AppIcon.appiconset/ │ │ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ │ │ ├── Base.lproj/ │ │ │ │ │ │ │ │ └── MainMenu.xib │ │ │ │ │ │ │ ├── Configs/ │ │ │ │ │ │ │ │ ├── AppInfo.xcconfig │ │ │ │ │ │ │ │ ├── Debug.xcconfig │ │ │ │ │ │ │ │ ├── Release.xcconfig │ │ │ │ │ │ │ │ └── Warnings.xcconfig │ │ │ │ │ │ │ ├── DebugProfile.entitlements │ │ │ │ │ │ │ ├── Info.plist │ │ │ │ │ │ │ ├── MainFlutterWindow.swift │ │ │ │ │ │ │ └── Release.entitlements │ │ │ │ │ │ ├── Runner.xcodeproj/ │ │ │ │ │ │ │ ├── project.pbxproj │ │ │ │ │ │ │ ├── project.xcworkspace/ │ │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ └── xcschemes/ │ │ │ │ │ │ │ └── Runner.xcscheme │ │ │ │ │ │ ├── Runner.xcworkspace/ │ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ │ │ │ └── RunnerTests/ │ │ │ │ │ │ └── RunnerTests.swift │ │ │ │ │ ├── pubspec.yaml │ │ │ │ │ └── test/ │ │ │ │ │ └── widget_test.dart │ │ │ │ ├── lib/ │ │ │ │ │ ├── appflowy_ui.dart │ │ │ │ │ └── src/ │ │ │ │ │ ├── component/ │ │ │ │ │ │ ├── avatar/ │ │ │ │ │ │ │ └── avatar.dart │ │ │ │ │ │ ├── button/ │ │ │ │ │ │ │ ├── base_button/ │ │ │ │ │ │ │ │ ├── base.dart │ │ │ │ │ │ │ │ ├── base_button.dart │ │ │ │ │ │ │ │ └── base_text_button.dart │ │ │ │ │ │ │ ├── button.dart │ │ │ │ │ │ │ ├── filled_button/ │ │ │ │ │ │ │ │ ├── filled_button.dart │ │ │ │ │ │ │ │ ├── filled_icon_text_button.dart │ │ │ │ │ │ │ │ └── filled_text_button.dart │ │ │ │ │ │ │ ├── ghost_button/ │ │ │ │ │ │ │ │ ├── ghost_button.dart │ │ │ │ │ │ │ │ ├── ghost_icon_text_button.dart │ │ │ │ │ │ │ │ └── ghost_text_button.dart │ │ │ │ │ │ │ └── outlined_button/ │ │ │ │ │ │ │ ├── outlined_button.dart │ │ │ │ │ │ │ ├── outlined_icon_text_button.dart │ │ │ │ │ │ │ └── outlined_text_button.dart │ │ │ │ │ │ ├── component.dart │ │ │ │ │ │ ├── dropdown_menu/ │ │ │ │ │ │ │ └── dropdown_menu.dart │ │ │ │ │ │ ├── menu/ │ │ │ │ │ │ │ ├── menu.dart │ │ │ │ │ │ │ ├── menu_item.dart │ │ │ │ │ │ │ ├── section.dart │ │ │ │ │ │ │ └── text_menu_item.dart │ │ │ │ │ │ ├── modal/ │ │ │ │ │ │ │ ├── dimension.dart │ │ │ │ │ │ │ └── modal.dart │ │ │ │ │ │ ├── popover/ │ │ │ │ │ │ │ ├── anchor.dart │ │ │ │ │ │ │ ├── popover.dart │ │ │ │ │ │ │ └── shadcn/ │ │ │ │ │ │ │ ├── _mouse_area.dart │ │ │ │ │ │ │ └── _portal.dart │ │ │ │ │ │ ├── separator/ │ │ │ │ │ │ │ └── divider.dart │ │ │ │ │ │ └── textfield/ │ │ │ │ │ │ └── textfield.dart │ │ │ │ │ └── theme/ │ │ │ │ │ ├── appflowy_theme.dart │ │ │ │ │ ├── data/ │ │ │ │ │ │ ├── appflowy_default/ │ │ │ │ │ │ │ ├── primitive.dart │ │ │ │ │ │ │ └── semantic.dart │ │ │ │ │ │ ├── built_in_themes.dart │ │ │ │ │ │ ├── custom/ │ │ │ │ │ │ │ └── custom_theme.dart │ │ │ │ │ │ └── shared.dart │ │ │ │ │ ├── definition/ │ │ │ │ │ │ ├── border_radius/ │ │ │ │ │ │ │ └── border_radius.dart │ │ │ │ │ │ ├── color_scheme/ │ │ │ │ │ │ │ ├── background_color_scheme.dart │ │ │ │ │ │ │ ├── badge_color_scheme.dart │ │ │ │ │ │ │ ├── border_color_scheme.dart │ │ │ │ │ │ │ ├── brand_color_scheme.dart │ │ │ │ │ │ │ ├── color_scheme.dart │ │ │ │ │ │ │ ├── fill_color_scheme.dart │ │ │ │ │ │ │ ├── icon_color_scheme.dart │ │ │ │ │ │ │ ├── other_color_scheme.dart │ │ │ │ │ │ │ ├── surface_color_scheme.dart │ │ │ │ │ │ │ ├── surface_container_color_scheme.dart │ │ │ │ │ │ │ └── text_color_scheme.dart │ │ │ │ │ │ ├── shadow/ │ │ │ │ │ │ │ └── shadow.dart │ │ │ │ │ │ ├── spacing/ │ │ │ │ │ │ │ └── spacing.dart │ │ │ │ │ │ ├── text_style/ │ │ │ │ │ │ │ ├── base/ │ │ │ │ │ │ │ │ └── default_text_style.dart │ │ │ │ │ │ │ └── text_style.dart │ │ │ │ │ │ └── theme_data.dart │ │ │ │ │ └── theme.dart │ │ │ │ ├── pubspec.yaml │ │ │ │ └── script/ │ │ │ │ ├── Primitive.Mode 1.tokens.json │ │ │ │ ├── Semantic.Dark Mode.tokens.json │ │ │ │ ├── Semantic.Light Mode.tokens.json │ │ │ │ └── generate_theme.dart │ │ │ ├── flowy_infra/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .metadata │ │ │ │ ├── LICENSE │ │ │ │ ├── analysis_options.yaml │ │ │ │ ├── lib/ │ │ │ │ │ ├── colorscheme/ │ │ │ │ │ │ ├── colorscheme.dart │ │ │ │ │ │ ├── dandelion.dart │ │ │ │ │ │ ├── default_colorscheme.dart │ │ │ │ │ │ ├── lavender.dart │ │ │ │ │ │ └── lemonade.dart │ │ │ │ │ ├── file_picker/ │ │ │ │ │ │ ├── file_picker_impl.dart │ │ │ │ │ │ └── file_picker_service.dart │ │ │ │ │ ├── icon_data.dart │ │ │ │ │ ├── language.dart │ │ │ │ │ ├── notifier.dart │ │ │ │ │ ├── platform_extension.dart │ │ │ │ │ ├── plugins/ │ │ │ │ │ │ ├── bloc/ │ │ │ │ │ │ │ ├── dynamic_plugin_bloc.dart │ │ │ │ │ │ │ ├── dynamic_plugin_event.dart │ │ │ │ │ │ │ └── dynamic_plugin_state.dart │ │ │ │ │ │ └── service/ │ │ │ │ │ │ ├── location_service.dart │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── exceptions.dart │ │ │ │ │ │ │ ├── flowy_dynamic_plugin.dart │ │ │ │ │ │ │ └── plugin_type.dart │ │ │ │ │ │ └── plugin_service.dart │ │ │ │ │ ├── size.dart │ │ │ │ │ ├── theme.dart │ │ │ │ │ ├── theme_extension.dart │ │ │ │ │ ├── time/ │ │ │ │ │ │ ├── duration.dart │ │ │ │ │ │ └── prelude.dart │ │ │ │ │ ├── utils/ │ │ │ │ │ │ └── color_converter.dart │ │ │ │ │ └── uuid.dart │ │ │ │ └── pubspec.yaml │ │ │ ├── flowy_infra_ui/ │ │ │ │ ├── .gitignore │ │ │ │ ├── .metadata │ │ │ │ ├── CHANGELOG.md │ │ │ │ ├── LICENSE │ │ │ │ ├── README.md │ │ │ │ ├── analysis_options.yaml │ │ │ │ ├── android/ │ │ │ │ │ ├── .classpath │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .project │ │ │ │ │ ├── .settings/ │ │ │ │ │ │ └── org.eclipse.buildship.core.prefs │ │ │ │ │ ├── build.gradle │ │ │ │ │ ├── gradle/ │ │ │ │ │ │ └── wrapper/ │ │ │ │ │ │ └── gradle-wrapper.properties │ │ │ │ │ ├── gradle.properties │ │ │ │ │ ├── settings.gradle │ │ │ │ │ └── src/ │ │ │ │ │ └── main/ │ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ │ ├── java/ │ │ │ │ │ │ └── com/ │ │ │ │ │ │ └── example/ │ │ │ │ │ │ └── flowy_infra_ui/ │ │ │ │ │ │ ├── FlowyInfraUIPlugin.java │ │ │ │ │ │ └── event/ │ │ │ │ │ │ └── KeyboardEventHandler.java │ │ │ │ │ └── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── example/ │ │ │ │ │ └── flowy_infra_ui/ │ │ │ │ │ └── FlowyInfraUiPlugin.kt │ │ │ │ ├── example/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .metadata │ │ │ │ │ ├── README.md │ │ │ │ │ ├── analysis_options.yaml │ │ │ │ │ ├── android/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── .project │ │ │ │ │ │ ├── .settings/ │ │ │ │ │ │ │ └── org.eclipse.buildship.core.prefs │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ ├── .classpath │ │ │ │ │ │ │ ├── .project │ │ │ │ │ │ │ ├── .settings/ │ │ │ │ │ │ │ │ └── org.eclipse.buildship.core.prefs │ │ │ │ │ │ │ ├── build.gradle │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── debug/ │ │ │ │ │ │ │ │ └── AndroidManifest.xml │ │ │ │ │ │ │ ├── main/ │ │ │ │ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ │ │ │ │ ├── kotlin/ │ │ │ │ │ │ │ │ │ └── com/ │ │ │ │ │ │ │ │ │ └── example/ │ │ │ │ │ │ │ │ │ ├── example/ │ │ │ │ │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ │ │ │ │ │ └── flowy_infra_ui_example/ │ │ │ │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ │ │ │ │ └── res/ │ │ │ │ │ │ │ │ ├── drawable/ │ │ │ │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ │ │ │ ├── drawable-v21/ │ │ │ │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ │ │ │ ├── values/ │ │ │ │ │ │ │ │ │ └── styles.xml │ │ │ │ │ │ │ │ └── values-night/ │ │ │ │ │ │ │ │ └── styles.xml │ │ │ │ │ │ │ └── profile/ │ │ │ │ │ │ │ └── AndroidManifest.xml │ │ │ │ │ │ ├── build.gradle │ │ │ │ │ │ ├── gradle/ │ │ │ │ │ │ │ └── wrapper/ │ │ │ │ │ │ │ └── gradle-wrapper.properties │ │ │ │ │ │ ├── gradle.properties │ │ │ │ │ │ └── settings.gradle │ │ │ │ │ ├── example/ │ │ │ │ │ │ └── android/ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── main/ │ │ │ │ │ │ └── java/ │ │ │ │ │ │ └── com/ │ │ │ │ │ │ └── example/ │ │ │ │ │ │ └── flowy_infra_ui_example/ │ │ │ │ │ │ └── FlutterActivity.java │ │ │ │ │ ├── ios/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── Flutter/ │ │ │ │ │ │ │ ├── AppFrameworkInfo.plist │ │ │ │ │ │ │ ├── Debug.xcconfig │ │ │ │ │ │ │ └── Release.xcconfig │ │ │ │ │ │ ├── Podfile │ │ │ │ │ │ ├── Runner/ │ │ │ │ │ │ │ ├── AppDelegate.swift │ │ │ │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ │ │ │ └── LaunchImage.imageset/ │ │ │ │ │ │ │ │ ├── Contents.json │ │ │ │ │ │ │ │ └── README.md │ │ │ │ │ │ │ ├── Base.lproj/ │ │ │ │ │ │ │ │ ├── LaunchScreen.storyboard │ │ │ │ │ │ │ │ └── Main.storyboard │ │ │ │ │ │ │ ├── Info.plist │ │ │ │ │ │ │ └── Runner-Bridging-Header.h │ │ │ │ │ │ ├── Runner.xcodeproj/ │ │ │ │ │ │ │ ├── project.pbxproj │ │ │ │ │ │ │ ├── project.xcworkspace/ │ │ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ └── xcschemes/ │ │ │ │ │ │ │ └── Runner.xcscheme │ │ │ │ │ │ └── Runner.xcworkspace/ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ │ │ ├── lib/ │ │ │ │ │ │ ├── home/ │ │ │ │ │ │ │ ├── demo_item.dart │ │ │ │ │ │ │ └── home_screen.dart │ │ │ │ │ │ ├── keyboard/ │ │ │ │ │ │ │ └── keyboard_screen.dart │ │ │ │ │ │ ├── main.dart │ │ │ │ │ │ └── overlay/ │ │ │ │ │ │ └── overlay_screen.dart │ │ │ │ │ ├── linux/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ │ ├── flutter/ │ │ │ │ │ │ │ └── CMakeLists.txt │ │ │ │ │ │ ├── main.cc │ │ │ │ │ │ ├── my_application.cc │ │ │ │ │ │ └── my_application.h │ │ │ │ │ ├── macos/ │ │ │ │ │ │ ├── .gitignore │ │ │ │ │ │ ├── Flutter/ │ │ │ │ │ │ │ ├── Flutter-Debug.xcconfig │ │ │ │ │ │ │ └── Flutter-Release.xcconfig │ │ │ │ │ │ ├── Podfile │ │ │ │ │ │ ├── Runner/ │ │ │ │ │ │ │ ├── AppDelegate.swift │ │ │ │ │ │ │ ├── Assets.xcassets/ │ │ │ │ │ │ │ │ └── AppIcon.appiconset/ │ │ │ │ │ │ │ │ └── Contents.json │ │ │ │ │ │ │ ├── Base.lproj/ │ │ │ │ │ │ │ │ └── MainMenu.xib │ │ │ │ │ │ │ ├── Configs/ │ │ │ │ │ │ │ │ ├── AppInfo.xcconfig │ │ │ │ │ │ │ │ ├── Debug.xcconfig │ │ │ │ │ │ │ │ ├── Release.xcconfig │ │ │ │ │ │ │ │ └── Warnings.xcconfig │ │ │ │ │ │ │ ├── DebugProfile.entitlements │ │ │ │ │ │ │ ├── Info.plist │ │ │ │ │ │ │ ├── MainFlutterWindow.swift │ │ │ │ │ │ │ └── Release.entitlements │ │ │ │ │ │ ├── Runner.xcodeproj/ │ │ │ │ │ │ │ ├── project.pbxproj │ │ │ │ │ │ │ ├── project.xcworkspace/ │ │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ │ └── xcschemes/ │ │ │ │ │ │ │ └── Runner.xcscheme │ │ │ │ │ │ └── Runner.xcworkspace/ │ │ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ │ │ └── xcshareddata/ │ │ │ │ │ │ └── IDEWorkspaceChecks.plist │ │ │ │ │ ├── pubspec.yaml │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── widget_test.dart │ │ │ │ │ ├── web/ │ │ │ │ │ │ ├── index.html │ │ │ │ │ │ └── manifest.json │ │ │ │ │ └── windows/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ ├── flutter/ │ │ │ │ │ │ └── CMakeLists.txt │ │ │ │ │ └── runner/ │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ ├── Runner.rc │ │ │ │ │ ├── flutter_window.cpp │ │ │ │ │ ├── flutter_window.h │ │ │ │ │ ├── main.cpp │ │ │ │ │ ├── resource.h │ │ │ │ │ ├── runner.exe.manifest │ │ │ │ │ ├── utils.cpp │ │ │ │ │ ├── utils.h │ │ │ │ │ ├── win32_window.cpp │ │ │ │ │ └── win32_window.h │ │ │ │ ├── flowy_infra_ui_platform_interface/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .metadata │ │ │ │ │ ├── CHANGELOG.md │ │ │ │ │ ├── LICENSE │ │ │ │ │ ├── README.md │ │ │ │ │ ├── analysis_options.yaml │ │ │ │ │ ├── lib/ │ │ │ │ │ │ ├── flowy_infra_ui_platform_interface.dart │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── method_channel_flowy_infra_ui.dart │ │ │ │ │ ├── pubspec.yaml │ │ │ │ │ └── test/ │ │ │ │ │ └── flowy_infra_ui_platform_interface_test.dart │ │ │ │ ├── flowy_infra_ui_web/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .metadata │ │ │ │ │ ├── CHANGELOG.md │ │ │ │ │ ├── LICENSE │ │ │ │ │ ├── README.md │ │ │ │ │ ├── analysis_options.yaml │ │ │ │ │ ├── lib/ │ │ │ │ │ │ └── flowy_infra_ui_web.dart │ │ │ │ │ ├── pubspec.yaml │ │ │ │ │ └── test/ │ │ │ │ │ └── flowy_infra_ui_web_test.dart │ │ │ │ ├── ios/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── Assets/ │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ ├── Classes/ │ │ │ │ │ │ ├── Event/ │ │ │ │ │ │ │ └── KeyboardEventHandler.swift │ │ │ │ │ │ ├── FlowyInfraUIPlugin.h │ │ │ │ │ │ ├── FlowyInfraUIPlugin.m │ │ │ │ │ │ └── SwiftFlowyInfraUIPlugin.swift │ │ │ │ │ └── flowy_infra_ui.podspec │ │ │ │ ├── lib/ │ │ │ │ │ ├── basis.dart │ │ │ │ │ ├── flowy_infra_ui.dart │ │ │ │ │ ├── flowy_infra_ui_web.dart │ │ │ │ │ ├── src/ │ │ │ │ │ │ ├── flowy_overlay/ │ │ │ │ │ │ │ ├── appflowy_popover.dart │ │ │ │ │ │ │ ├── flowy_dialog.dart │ │ │ │ │ │ │ ├── flowy_overlay.dart │ │ │ │ │ │ │ ├── flowy_popover_layout.dart │ │ │ │ │ │ │ ├── layout.dart │ │ │ │ │ │ │ ├── list_overlay.dart │ │ │ │ │ │ │ └── option_overlay.dart │ │ │ │ │ │ ├── focus/ │ │ │ │ │ │ │ └── auto_unfocus_overlay.dart │ │ │ │ │ │ └── keyboard/ │ │ │ │ │ │ └── keyboard_visibility_detector.dart │ │ │ │ │ ├── style_widget/ │ │ │ │ │ │ ├── bar_title.dart │ │ │ │ │ │ ├── button.dart │ │ │ │ │ │ ├── close_button.dart │ │ │ │ │ │ ├── color_picker.dart │ │ │ │ │ │ ├── container.dart │ │ │ │ │ │ ├── decoration.dart │ │ │ │ │ │ ├── divider.dart │ │ │ │ │ │ ├── extension.dart │ │ │ │ │ │ ├── hover.dart │ │ │ │ │ │ ├── icon_button.dart │ │ │ │ │ │ ├── image_icon.dart │ │ │ │ │ │ ├── primary_rounded_button.dart │ │ │ │ │ │ ├── progress_indicator.dart │ │ │ │ │ │ ├── scrollbar.dart │ │ │ │ │ │ ├── scrolling/ │ │ │ │ │ │ │ ├── styled_list.dart │ │ │ │ │ │ │ ├── styled_scroll_bar.dart │ │ │ │ │ │ │ └── styled_scrollview.dart │ │ │ │ │ │ ├── snap_bar.dart │ │ │ │ │ │ ├── text.dart │ │ │ │ │ │ ├── text_field.dart │ │ │ │ │ │ ├── text_input.dart │ │ │ │ │ │ └── toolbar_button.dart │ │ │ │ │ └── widget/ │ │ │ │ │ ├── buttons/ │ │ │ │ │ │ ├── base_styled_button.dart │ │ │ │ │ │ ├── primary_button.dart │ │ │ │ │ │ └── secondary_button.dart │ │ │ │ │ ├── constraint_flex_view.dart │ │ │ │ │ ├── dialog/ │ │ │ │ │ │ ├── dialog_size.dart │ │ │ │ │ │ └── styled_dialogs.dart │ │ │ │ │ ├── flowy_tooltip.dart │ │ │ │ │ ├── ignore_parent_gesture.dart │ │ │ │ │ ├── mouse_hover_builder.dart │ │ │ │ │ ├── rounded_button.dart │ │ │ │ │ ├── rounded_input_field.dart │ │ │ │ │ ├── route/ │ │ │ │ │ │ └── animation.dart │ │ │ │ │ ├── separated_flex.dart │ │ │ │ │ └── spacing.dart │ │ │ │ ├── linux/ │ │ │ │ │ ├── CMakeLists.txt │ │ │ │ │ ├── flowy_infra_u_i_plugin.cc │ │ │ │ │ ├── flowy_infra_ui_plugin.cc │ │ │ │ │ └── include/ │ │ │ │ │ └── flowy_infra_ui/ │ │ │ │ │ ├── flowy_infra_u_i_plugin.h │ │ │ │ │ └── flowy_infra_ui_plugin.h │ │ │ │ ├── macos/ │ │ │ │ │ ├── Classes/ │ │ │ │ │ │ └── FlowyInfraUiPlugin.swift │ │ │ │ │ └── flowy_infra_ui.podspec │ │ │ │ ├── pubspec.yaml │ │ │ │ ├── test/ │ │ │ │ │ └── flowy_infra_ui_test.dart │ │ │ │ └── windows/ │ │ │ │ ├── .gitignore │ │ │ │ ├── CMakeLists.txt │ │ │ │ ├── flowy_infra_ui_plugin.cpp │ │ │ │ └── include/ │ │ │ │ └── flowy_infra_ui/ │ │ │ │ ├── flowy_infra_u_i_plugin.h │ │ │ │ └── flowy_infra_ui_plugin.h │ │ │ └── flowy_svg/ │ │ │ ├── .github/ │ │ │ │ ├── ISSUE_TEMPLATE/ │ │ │ │ │ ├── bug_report.md │ │ │ │ │ ├── build.md │ │ │ │ │ ├── chore.md │ │ │ │ │ ├── ci.md │ │ │ │ │ ├── config.yml │ │ │ │ │ ├── documentation.md │ │ │ │ │ ├── feature_request.md │ │ │ │ │ ├── performance.md │ │ │ │ │ ├── refactor.md │ │ │ │ │ ├── revert.md │ │ │ │ │ ├── style.md │ │ │ │ │ └── test.md │ │ │ │ ├── PULL_REQUEST_TEMPLATE.md │ │ │ │ ├── cspell.json │ │ │ │ ├── dependabot.yaml │ │ │ │ └── workflows/ │ │ │ │ └── main.yaml │ │ │ ├── .gitignore │ │ │ ├── analysis_options.yaml │ │ │ ├── bin/ │ │ │ │ ├── flowy_svg.dart │ │ │ │ └── options.dart │ │ │ ├── lib/ │ │ │ │ ├── flowy_svg.dart │ │ │ │ └── src/ │ │ │ │ └── flowy_svg.dart │ │ │ └── pubspec.yaml │ │ ├── pubspec.lock │ │ ├── pubspec.yaml │ │ ├── test/ │ │ │ ├── bloc_test/ │ │ │ │ ├── ai_writer_test/ │ │ │ │ │ └── ai_writer_bloc_test.dart │ │ │ │ ├── app_setting_test/ │ │ │ │ │ ├── appearance_test.dart │ │ │ │ │ └── document_appearance_test.dart │ │ │ │ ├── board_test/ │ │ │ │ │ ├── create_card_test.dart │ │ │ │ │ ├── create_or_edit_field_test.dart │ │ │ │ │ ├── group_by_checkbox_field_test.dart │ │ │ │ │ ├── group_by_date_test.dart │ │ │ │ │ ├── group_by_multi_select_field_test.dart │ │ │ │ │ ├── group_by_unsupport_field_test.dart │ │ │ │ │ └── util.dart │ │ │ │ ├── chat_test/ │ │ │ │ │ ├── chat_load_message_test.dart │ │ │ │ │ └── util.dart │ │ │ │ ├── grid_test/ │ │ │ │ │ ├── cell/ │ │ │ │ │ │ ├── checklist_cell_bloc_test.dart │ │ │ │ │ │ ├── date_cell_bloc_test.dart │ │ │ │ │ │ ├── select_option_cell_test.dart │ │ │ │ │ │ └── text_cell_bloc_test.dart │ │ │ │ │ ├── field/ │ │ │ │ │ │ ├── field_cell_bloc_test.dart │ │ │ │ │ │ └── field_editor_bloc_test.dart │ │ │ │ │ ├── filter/ │ │ │ │ │ │ ├── filter_editor_bloc_test.dart │ │ │ │ │ │ └── filter_entities_test.dart │ │ │ │ │ ├── grid_bloc_test.dart │ │ │ │ │ ├── sort/ │ │ │ │ │ │ └── sort_editor_bloc_test.dart │ │ │ │ │ └── util.dart │ │ │ │ ├── home_test/ │ │ │ │ │ ├── home_bloc_test.dart │ │ │ │ │ ├── sidebar_section_bloc_test.dart │ │ │ │ │ ├── trash_bloc_test.dart │ │ │ │ │ └── view_bloc_test.dart │ │ │ │ ├── lib/ │ │ │ │ │ └── features/ │ │ │ │ │ ├── settings/ │ │ │ │ │ │ └── data_location_bloc_test.dart │ │ │ │ │ ├── share_section/ │ │ │ │ │ │ └── shared_section_bloc_test.dart │ │ │ │ │ └── share_tab/ │ │ │ │ │ └── share_tab_bloc_test.dart │ │ │ │ ├── shortcuts_test/ │ │ │ │ │ └── shortcuts_cubit_test.dart │ │ │ │ ├── view_selector_test.dart │ │ │ │ └── workspace_test/ │ │ │ │ └── workspace_bloc_test.dart │ │ │ ├── unit_test/ │ │ │ │ ├── algorithm/ │ │ │ │ │ └── levenshtein_test.dart │ │ │ │ ├── deeplink/ │ │ │ │ │ └── deeplink_test.dart │ │ │ │ ├── document/ │ │ │ │ │ ├── document_diff/ │ │ │ │ │ │ └── document_diff_test.dart │ │ │ │ │ ├── html/ │ │ │ │ │ │ ├── _html_samples.dart │ │ │ │ │ │ └── paste_from_html_test.dart │ │ │ │ │ ├── option_menu/ │ │ │ │ │ │ └── block_action_option_cubit_test.dart │ │ │ │ │ ├── shortcuts/ │ │ │ │ │ │ ├── format_shortcut_test.dart │ │ │ │ │ │ └── toggle_list_shortcut_test.dart │ │ │ │ │ ├── text_robot/ │ │ │ │ │ │ ├── markdown_text_robot_test.dart │ │ │ │ │ │ └── text_robot_test.dart │ │ │ │ │ └── turn_into/ │ │ │ │ │ └── turn_into_test.dart │ │ │ │ ├── editor/ │ │ │ │ │ ├── editor_drop_test.dart │ │ │ │ │ ├── editor_migration_test.dart │ │ │ │ │ ├── editor_style_test.dart │ │ │ │ │ ├── file_block_test.dart │ │ │ │ │ ├── share_markdown_test.dart │ │ │ │ │ └── transaction_adapter_test.dart │ │ │ │ ├── image/ │ │ │ │ │ └── appflowy_network_image_test.dart │ │ │ │ ├── link_preview/ │ │ │ │ │ └── link_preview_test.dart │ │ │ │ ├── markdown/ │ │ │ │ │ └── markdown_parser_test.dart │ │ │ │ ├── search/ │ │ │ │ │ └── split_search_test.dart │ │ │ │ ├── select_option_split_text_input.dart │ │ │ │ ├── settings/ │ │ │ │ │ ├── shortcuts/ │ │ │ │ │ │ └── settings_shortcut_service_test.dart │ │ │ │ │ └── theme_missing_keys_test.dart │ │ │ │ ├── simple_table/ │ │ │ │ │ ├── simple_table_contente_operation_test.dart │ │ │ │ │ ├── simple_table_delete_operation_test.dart │ │ │ │ │ ├── simple_table_duplicate_operation_test.dart │ │ │ │ │ ├── simple_table_header_operation_test.dart │ │ │ │ │ ├── simple_table_insert_operation_test.dart │ │ │ │ │ ├── simple_table_markdown_test.dart │ │ │ │ │ ├── simple_table_reorder_operation_test.dart │ │ │ │ │ ├── simple_table_style_operation_test.dart │ │ │ │ │ └── simple_table_test_helper.dart │ │ │ │ ├── theme/ │ │ │ │ │ └── theme_test.dart │ │ │ │ ├── url_launcher/ │ │ │ │ │ └── url_launcher_test.dart │ │ │ │ └── util/ │ │ │ │ ├── recent_icons_test.dart │ │ │ │ └── time.dart │ │ │ ├── util.dart │ │ │ └── widget_test/ │ │ │ ├── confirm_dialog_test.dart │ │ │ ├── date_picker_test.dart │ │ │ ├── direction_setting_test.dart │ │ │ ├── lib/ │ │ │ │ └── features/ │ │ │ │ ├── share_section/ │ │ │ │ │ ├── refresh_button_test.dart │ │ │ │ │ ├── shared_page_actions_button_test.dart │ │ │ │ │ ├── shared_pages_list_test.dart │ │ │ │ │ ├── shared_section_error_test.dart │ │ │ │ │ ├── shared_section_header_test.dart │ │ │ │ │ └── shared_section_loading_test.dart │ │ │ │ └── share_tab/ │ │ │ │ ├── access_level_list_widget_test.dart │ │ │ │ ├── copy_link_widget_test.dart │ │ │ │ ├── edit_access_level_widget_test.dart │ │ │ │ ├── general_access_section_test.dart │ │ │ │ ├── people_with_access_section_test.dart │ │ │ │ ├── share_with_user_widget_test.dart │ │ │ │ ├── shared_group_widget_test.dart │ │ │ │ └── shared_user_widget_test.dart │ │ │ ├── select_option_text_field_test.dart │ │ │ ├── spae_cion_test.dart │ │ │ ├── test_asset_bundle.dart │ │ │ ├── test_material_app.dart │ │ │ ├── theme_font_family_setting_test.dart │ │ │ └── widget_test_wrapper.dart │ │ ├── web/ │ │ │ ├── index.html │ │ │ └── manifest.json │ │ └── windows/ │ │ ├── .gitignore │ │ ├── CMakeLists.txt │ │ ├── flutter/ │ │ │ └── CMakeLists.txt │ │ └── runner/ │ │ ├── CMakeLists.txt │ │ ├── Runner.rc │ │ ├── flutter_window.cpp │ │ ├── flutter_window.h │ │ ├── main.cpp │ │ ├── resource.h │ │ ├── runner.exe.manifest │ │ ├── utils.cpp │ │ ├── utils.h │ │ ├── win32_window.cpp │ │ └── win32_window.h │ ├── resources/ │ │ └── translations/ │ │ ├── am-ET.json │ │ ├── ar-SA.json │ │ ├── ca-ES.json │ │ ├── ckb-KU.json │ │ ├── cs-CZ.json │ │ ├── de-DE.json │ │ ├── el-GR.json │ │ ├── en-GB.json │ │ ├── en-US.json │ │ ├── es-VE.json │ │ ├── eu-ES.json │ │ ├── fa.json │ │ ├── fr-CA.json │ │ ├── fr-FR.json │ │ ├── ga-IE.json │ │ ├── he.json │ │ ├── hin.json │ │ ├── hu-HU.json │ │ ├── id-ID.json │ │ ├── it-IT.json │ │ ├── ja-JP.json │ │ ├── ko-KR.json │ │ ├── mr-IN.json │ │ ├── pl-PL.json │ │ ├── pt-BR.json │ │ ├── pt-PT.json │ │ ├── ru-RU.json │ │ ├── sv-SE.json │ │ ├── th-TH.json │ │ ├── tr-TR.json │ │ ├── uk-UA.json │ │ ├── ur.json │ │ ├── vi-VN.json │ │ ├── vi.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── rust-lib/ │ │ ├── .gitignore │ │ ├── .vscode/ │ │ │ └── launch.json │ │ ├── Cargo.toml │ │ ├── build-tool/ │ │ │ ├── flowy-ast/ │ │ │ │ ├── Cargo.toml │ │ │ │ └── src/ │ │ │ │ ├── ast.rs │ │ │ │ ├── ctxt.rs │ │ │ │ ├── event_attrs.rs │ │ │ │ ├── lib.rs │ │ │ │ ├── node_attrs.rs │ │ │ │ ├── pb_attrs.rs │ │ │ │ ├── symbol.rs │ │ │ │ └── ty_ext.rs │ │ │ ├── flowy-codegen/ │ │ │ │ ├── Cargo.toml │ │ │ │ └── src/ │ │ │ │ ├── ast.rs │ │ │ │ ├── dart_event/ │ │ │ │ │ ├── dart_event.rs │ │ │ │ │ ├── event_template.rs │ │ │ │ │ ├── event_template.tera │ │ │ │ │ └── mod.rs │ │ │ │ ├── flowy_toml.rs │ │ │ │ ├── lib.rs │ │ │ │ ├── protobuf_file/ │ │ │ │ │ ├── ast.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── proto_gen.rs │ │ │ │ │ ├── proto_info.rs │ │ │ │ │ └── template/ │ │ │ │ │ ├── derive_meta/ │ │ │ │ │ │ ├── derive_meta.rs │ │ │ │ │ │ ├── derive_meta.tera │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── proto_file/ │ │ │ │ │ ├── enum.tera │ │ │ │ │ ├── enum_template.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── struct.tera │ │ │ │ │ └── struct_template.rs │ │ │ │ ├── ts_event/ │ │ │ │ │ ├── event_template.rs │ │ │ │ │ ├── event_template.tera │ │ │ │ │ └── mod.rs │ │ │ │ └── util.rs │ │ │ └── flowy-derive/ │ │ │ ├── .gitignore │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── dart_event/ │ │ │ │ └── mod.rs │ │ │ ├── lib.rs │ │ │ ├── node/ │ │ │ │ └── mod.rs │ │ │ └── proto_buf/ │ │ │ ├── deserialize.rs │ │ │ ├── enum_serde.rs │ │ │ ├── mod.rs │ │ │ ├── serialize.rs │ │ │ └── util.rs │ │ ├── collab-integrate/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── collab_builder.rs │ │ │ ├── config.rs │ │ │ ├── instant_indexed_data_provider.rs │ │ │ ├── lib.rs │ │ │ └── plugin_provider.rs │ │ ├── covtest.rs │ │ ├── dart-ffi/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── binding.h │ │ │ ├── build.rs │ │ │ └── src/ │ │ │ ├── appflowy_yaml.rs │ │ │ ├── c.rs │ │ │ ├── env_serde.rs │ │ │ ├── lib.rs │ │ │ ├── model/ │ │ │ │ ├── ffi_request.rs │ │ │ │ ├── ffi_response.rs │ │ │ │ └── mod.rs │ │ │ └── notification/ │ │ │ ├── mod.rs │ │ │ └── sender.rs │ │ ├── event-integration-test/ │ │ │ ├── Cargo.toml │ │ │ ├── src/ │ │ │ │ ├── chat_event.rs │ │ │ │ ├── database_event.rs │ │ │ │ ├── document/ │ │ │ │ │ ├── document_event.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── utils.rs │ │ │ │ ├── document_event.rs │ │ │ │ ├── event_builder.rs │ │ │ │ ├── folder_event.rs │ │ │ │ ├── lib.rs │ │ │ │ └── user_event.rs │ │ │ └── tests/ │ │ │ ├── asset/ │ │ │ │ ├── database_template_1.afdb │ │ │ │ ├── japan_trip.md │ │ │ │ └── project.csv │ │ │ ├── chat/ │ │ │ │ ├── chat_message_test.rs │ │ │ │ ├── local_chat_test.rs │ │ │ │ └── mod.rs │ │ │ ├── database/ │ │ │ │ ├── af_cloud/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── summarize_row_test.rs │ │ │ │ │ ├── translate_row_test.rs │ │ │ │ │ └── util.rs │ │ │ │ ├── local_test/ │ │ │ │ │ ├── calculate_test.rs │ │ │ │ │ ├── event_test.rs │ │ │ │ │ ├── group_test.rs │ │ │ │ │ └── mod.rs │ │ │ │ └── mod.rs │ │ │ ├── document/ │ │ │ │ ├── af_cloud_test/ │ │ │ │ │ ├── edit_test.rs │ │ │ │ │ ├── file_upload_test.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── local_test/ │ │ │ │ │ ├── edit_test.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── snapshot_test.rs │ │ │ │ └── mod.rs │ │ │ ├── folder/ │ │ │ │ ├── local_test/ │ │ │ │ │ ├── folder_test.rs │ │ │ │ │ ├── import_test.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── publish_database_test.rs │ │ │ │ │ ├── publish_document_test.rs │ │ │ │ │ ├── script.rs │ │ │ │ │ ├── subscription_test.rs │ │ │ │ │ └── test.rs │ │ │ │ └── mod.rs │ │ │ ├── main.rs │ │ │ ├── search/ │ │ │ │ ├── document_title_content_search.rs │ │ │ │ └── mod.rs │ │ │ ├── sql_test/ │ │ │ │ ├── chat_message_ordering_test.rs │ │ │ │ ├── chat_message_test.rs │ │ │ │ └── mod.rs │ │ │ ├── user/ │ │ │ │ ├── af_cloud_test/ │ │ │ │ │ ├── auth_test.rs │ │ │ │ │ ├── import_af_data_folder_test.rs │ │ │ │ │ ├── member_test.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── util.rs │ │ │ │ │ └── workspace_test.rs │ │ │ │ ├── local_test/ │ │ │ │ │ ├── auth_test.rs │ │ │ │ │ ├── helper.rs │ │ │ │ │ ├── import_af_data_local_test.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── user_awareness_test.rs │ │ │ │ │ └── user_profile_test.rs │ │ │ │ ├── migration_test/ │ │ │ │ │ ├── document_test.rs │ │ │ │ │ ├── history_user_db/ │ │ │ │ │ │ └── README.md │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── version_test.rs │ │ │ │ └── mod.rs │ │ │ └── util.rs │ │ ├── flowy-ai/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ ├── dev.env │ │ │ ├── src/ │ │ │ │ ├── ai_manager.rs │ │ │ │ ├── ai_tool/ │ │ │ │ │ ├── markdown.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── pdf.rs │ │ │ │ ├── chat.rs │ │ │ │ ├── completion.rs │ │ │ │ ├── embeddings/ │ │ │ │ │ ├── context.rs │ │ │ │ │ ├── document_indexer.rs │ │ │ │ │ ├── embedder.rs │ │ │ │ │ ├── indexer.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── scheduler.rs │ │ │ │ │ └── store.rs │ │ │ │ ├── entities.rs │ │ │ │ ├── event_handler.rs │ │ │ │ ├── event_map.rs │ │ │ │ ├── lib.rs │ │ │ │ ├── local_ai/ │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── chains/ │ │ │ │ │ │ │ ├── context_question_chain.rs │ │ │ │ │ │ │ ├── conversation_chain.rs │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ └── related_question_chain.rs │ │ │ │ │ │ ├── format_prompt.rs │ │ │ │ │ │ ├── llm.rs │ │ │ │ │ │ ├── llm_chat.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── retriever/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ ├── multi_source_retriever.rs │ │ │ │ │ │ │ └── sqlite_retriever.rs │ │ │ │ │ │ └── summary_memory.rs │ │ │ │ │ ├── completion/ │ │ │ │ │ │ ├── chain.rs │ │ │ │ │ │ ├── impls.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── stream_interpreter.rs │ │ │ │ │ │ └── writer.rs │ │ │ │ │ ├── controller.rs │ │ │ │ │ ├── database/ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── summary.rs │ │ │ │ │ │ └── translate.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── prompt/ │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── request.rs │ │ │ │ │ ├── resource.rs │ │ │ │ │ └── stream_util.rs │ │ │ │ ├── mcp/ │ │ │ │ │ ├── manager.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── middleware/ │ │ │ │ │ ├── chat_service_mw.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── model_select.rs │ │ │ │ ├── model_select_test.rs │ │ │ │ ├── notification.rs │ │ │ │ ├── offline/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── offline_message_sync.rs │ │ │ │ ├── search/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── summary.rs │ │ │ │ └── stream_message.rs │ │ │ └── tests/ │ │ │ ├── asset/ │ │ │ │ └── japan_trip.md │ │ │ ├── chat_test/ │ │ │ │ ├── mod.rs │ │ │ │ ├── qa_test.rs │ │ │ │ └── related_question_test.rs │ │ │ ├── complete_test/ │ │ │ │ └── mod.rs │ │ │ ├── main.rs │ │ │ ├── summary_test/ │ │ │ │ └── mod.rs │ │ │ └── translate_test/ │ │ │ └── mod.rs │ │ ├── flowy-ai-pub/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── cloud.rs │ │ │ ├── entities.rs │ │ │ ├── lib.rs │ │ │ ├── persistence/ │ │ │ │ ├── chat_message_sql.rs │ │ │ │ ├── chat_sql.rs │ │ │ │ ├── collab_metadata_sql.rs │ │ │ │ ├── collab_sql.rs │ │ │ │ ├── local_model_sql.rs │ │ │ │ └── mod.rs │ │ │ └── user_service.rs │ │ ├── flowy-core/ │ │ │ ├── .gitignore │ │ │ ├── Cargo.toml │ │ │ ├── assets/ │ │ │ │ └── read_me.json │ │ │ └── src/ │ │ │ ├── app_life_cycle.rs │ │ │ ├── config.rs │ │ │ ├── deps_resolve/ │ │ │ │ ├── chat_deps.rs │ │ │ │ ├── cloud_service_impl.rs │ │ │ │ ├── collab_deps.rs │ │ │ │ ├── database_deps.rs │ │ │ │ ├── document_deps.rs │ │ │ │ ├── file_storage_deps.rs │ │ │ │ ├── folder_deps/ │ │ │ │ │ ├── folder_deps_chat_impl.rs │ │ │ │ │ ├── folder_deps_database_impl.rs │ │ │ │ │ ├── folder_deps_doc_impl.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── reminder_deps.rs │ │ │ │ ├── search_deps.rs │ │ │ │ └── user_deps.rs │ │ │ ├── folder_view_observer.rs │ │ │ ├── full_indexed_data_provider.rs │ │ │ ├── indexed_data_consumer.rs │ │ │ ├── indexing_data_runner.rs │ │ │ ├── lib.rs │ │ │ ├── log_filter.rs │ │ │ ├── module.rs │ │ │ └── server_layer.rs │ │ ├── flowy-database-pub/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── cloud.rs │ │ │ └── lib.rs │ │ ├── flowy-database2/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ ├── src/ │ │ │ │ ├── entities/ │ │ │ │ │ ├── board_entities.rs │ │ │ │ │ ├── calculation/ │ │ │ │ │ │ ├── calculation_changeset.rs │ │ │ │ │ │ ├── calculation_entities.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── calendar_entities.rs │ │ │ │ │ ├── cell_entities.rs │ │ │ │ │ ├── database_entities.rs │ │ │ │ │ ├── field_entities.rs │ │ │ │ │ ├── field_settings_entities.rs │ │ │ │ │ ├── file_entities.rs │ │ │ │ │ ├── filter_entities/ │ │ │ │ │ │ ├── checkbox_filter.rs │ │ │ │ │ │ ├── checklist_filter.rs │ │ │ │ │ │ ├── date_filter.rs │ │ │ │ │ │ ├── filter_changeset.rs │ │ │ │ │ │ ├── media_filter.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── number_filter.rs │ │ │ │ │ │ ├── relation_filter.rs │ │ │ │ │ │ ├── select_option_filter.rs │ │ │ │ │ │ ├── text_filter.rs │ │ │ │ │ │ ├── time_filter.rs │ │ │ │ │ │ └── util.rs │ │ │ │ │ ├── group_entities/ │ │ │ │ │ │ ├── configuration.rs │ │ │ │ │ │ ├── group.rs │ │ │ │ │ │ ├── group_changeset.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── macros.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── parser.rs │ │ │ │ │ ├── position_entities.rs │ │ │ │ │ ├── row_entities.rs │ │ │ │ │ ├── setting_entities.rs │ │ │ │ │ ├── share_entities.rs │ │ │ │ │ ├── sort_entities.rs │ │ │ │ │ ├── type_option_entities/ │ │ │ │ │ │ ├── checkbox_entities.rs │ │ │ │ │ │ ├── checklist_entities.rs │ │ │ │ │ │ ├── date_entities.rs │ │ │ │ │ │ ├── media_entities.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── number_entities.rs │ │ │ │ │ │ ├── relation_entities.rs │ │ │ │ │ │ ├── select_option_entities.rs │ │ │ │ │ │ ├── summary_entities.rs │ │ │ │ │ │ ├── text_entities.rs │ │ │ │ │ │ ├── time_entities.rs │ │ │ │ │ │ ├── timestamp_entities.rs │ │ │ │ │ │ ├── translate_entities.rs │ │ │ │ │ │ └── url_entities.rs │ │ │ │ │ └── view_entities.rs │ │ │ │ ├── event_handler.rs │ │ │ │ ├── event_map.rs │ │ │ │ ├── lib.rs │ │ │ │ ├── manager.rs │ │ │ │ ├── notification.rs │ │ │ │ ├── services/ │ │ │ │ │ ├── calculations/ │ │ │ │ │ │ ├── cache.rs │ │ │ │ │ │ ├── controller.rs │ │ │ │ │ │ ├── entities.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── service.rs │ │ │ │ │ │ └── task.rs │ │ │ │ │ ├── cell/ │ │ │ │ │ │ ├── cell_data_cache.rs │ │ │ │ │ │ ├── cell_operation.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── type_cell_data.rs │ │ │ │ │ ├── database/ │ │ │ │ │ │ ├── database_editor.rs │ │ │ │ │ │ ├── database_observe.rs │ │ │ │ │ │ ├── entities.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── util.rs │ │ │ │ │ ├── database_view/ │ │ │ │ │ │ ├── layout_deps.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── notifier.rs │ │ │ │ │ │ ├── view_calculations.rs │ │ │ │ │ │ ├── view_editor.rs │ │ │ │ │ │ ├── view_filter.rs │ │ │ │ │ │ ├── view_group.rs │ │ │ │ │ │ ├── view_operation.rs │ │ │ │ │ │ ├── view_sort.rs │ │ │ │ │ │ └── views.rs │ │ │ │ │ ├── field/ │ │ │ │ │ │ ├── field_builder.rs │ │ │ │ │ │ ├── field_operation.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── type_option_transform.rs │ │ │ │ │ │ └── type_options/ │ │ │ │ │ │ ├── checkbox_type_option/ │ │ │ │ │ │ │ ├── checkbox_filter.rs │ │ │ │ │ │ │ ├── checkbox_tests.rs │ │ │ │ │ │ │ ├── checkbox_type_option.rs │ │ │ │ │ │ │ ├── checkbox_type_option_entities.rs │ │ │ │ │ │ │ └── mod.rs │ │ │ │ │ │ ├── checklist_type_option/ │ │ │ │ │ │ │ ├── checklist_filter.rs │ │ │ │ │ │ │ ├── checklist_type_option.rs │ │ │ │ │ │ │ └── mod.rs │ │ │ │ │ │ ├── date_type_option/ │ │ │ │ │ │ │ ├── date_filter.rs │ │ │ │ │ │ │ ├── date_tests.rs │ │ │ │ │ │ │ ├── date_type_option.rs │ │ │ │ │ │ │ └── mod.rs │ │ │ │ │ │ ├── media_type_option/ │ │ │ │ │ │ │ ├── media_file.rs │ │ │ │ │ │ │ ├── media_filter.rs │ │ │ │ │ │ │ ├── media_type_option.rs │ │ │ │ │ │ │ └── mod.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── number_type_option/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ ├── number_filter.rs │ │ │ │ │ │ │ ├── number_type_option.rs │ │ │ │ │ │ │ └── number_type_option_entities.rs │ │ │ │ │ │ ├── relation_type_option/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ ├── relation.rs │ │ │ │ │ │ │ └── relation_entities.rs │ │ │ │ │ │ ├── selection_type_option/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ ├── multi_select_type_option.rs │ │ │ │ │ │ │ ├── select_filter.rs │ │ │ │ │ │ │ ├── select_option_tests.rs │ │ │ │ │ │ │ ├── select_type_option.rs │ │ │ │ │ │ │ ├── single_select_type_option.rs │ │ │ │ │ │ │ └── type_option_transform.rs │ │ │ │ │ │ ├── summary_type_option/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ └── summary.rs │ │ │ │ │ │ ├── text_type_option/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ ├── text_filter.rs │ │ │ │ │ │ │ ├── text_tests.rs │ │ │ │ │ │ │ └── text_type_option.rs │ │ │ │ │ │ ├── time_type_option/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ ├── time.rs │ │ │ │ │ │ │ └── time_filter.rs │ │ │ │ │ │ ├── timestamp_type_option/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ └── timestamp_type_option.rs │ │ │ │ │ │ ├── translate_type_option/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ └── translate.rs │ │ │ │ │ │ ├── type_option.rs │ │ │ │ │ │ ├── type_option_cell.rs │ │ │ │ │ │ ├── url_type_option/ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ ├── url_tests.rs │ │ │ │ │ │ │ ├── url_type_option.rs │ │ │ │ │ │ │ └── url_type_option_entities.rs │ │ │ │ │ │ └── util.rs │ │ │ │ │ ├── field_settings/ │ │ │ │ │ │ ├── entities.rs │ │ │ │ │ │ ├── field_settings_builder.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── filter/ │ │ │ │ │ │ ├── controller.rs │ │ │ │ │ │ ├── entities.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── task.rs │ │ │ │ │ ├── group/ │ │ │ │ │ │ ├── action.rs │ │ │ │ │ │ ├── configuration.rs │ │ │ │ │ │ ├── controller.rs │ │ │ │ │ │ ├── controller_impls/ │ │ │ │ │ │ │ ├── checkbox_controller.rs │ │ │ │ │ │ │ ├── date_controller.rs │ │ │ │ │ │ │ ├── default_controller.rs │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ ├── select_option_controller/ │ │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ │ ├── multi_select_controller.rs │ │ │ │ │ │ │ │ ├── single_select_controller.rs │ │ │ │ │ │ │ │ └── util.rs │ │ │ │ │ │ │ └── url_controller.rs │ │ │ │ │ │ ├── entities.rs │ │ │ │ │ │ ├── group_builder.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── setting/ │ │ │ │ │ │ ├── entities.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── share/ │ │ │ │ │ │ ├── csv/ │ │ │ │ │ │ │ ├── export.rs │ │ │ │ │ │ │ ├── import.rs │ │ │ │ │ │ │ └── mod.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── snapshot/ │ │ │ │ │ │ ├── entities.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ └── sort/ │ │ │ │ │ ├── controller.rs │ │ │ │ │ ├── entities.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── task.rs │ │ │ │ ├── template.rs │ │ │ │ └── utils/ │ │ │ │ ├── cache.rs │ │ │ │ └── mod.rs │ │ │ └── tests/ │ │ │ ├── database/ │ │ │ │ ├── block_test/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── row_test.rs │ │ │ │ │ └── script.rs │ │ │ │ ├── calculations_test/ │ │ │ │ │ ├── calculation_test.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── script.rs │ │ │ │ ├── cell_test/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── script.rs │ │ │ │ │ └── test.rs │ │ │ │ ├── database_editor.rs │ │ │ │ ├── field_settings_test/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── script.rs │ │ │ │ │ └── test.rs │ │ │ │ ├── field_test/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── script.rs │ │ │ │ │ ├── test.rs │ │ │ │ │ └── util.rs │ │ │ │ ├── filter_test/ │ │ │ │ │ ├── advanced_filter_test.rs │ │ │ │ │ ├── checkbox_filter_test.rs │ │ │ │ │ ├── checklist_filter_test.rs │ │ │ │ │ ├── date_filter_test.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── number_filter_test.rs │ │ │ │ │ ├── script.rs │ │ │ │ │ ├── select_option_filter_test.rs │ │ │ │ │ ├── text_filter_test.rs │ │ │ │ │ └── time_filter_test.rs │ │ │ │ ├── group_test/ │ │ │ │ │ ├── date_group_test.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── script.rs │ │ │ │ │ ├── test.rs │ │ │ │ │ └── url_group_test.rs │ │ │ │ ├── layout_test/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── script.rs │ │ │ │ │ └── test.rs │ │ │ │ ├── mock_data/ │ │ │ │ │ ├── board_mock_data.rs │ │ │ │ │ ├── calendar_mock_data.rs │ │ │ │ │ ├── grid_mock_data.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── pre_fill_cell_test/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── pre_fill_row_according_to_filter_test.rs │ │ │ │ │ ├── pre_fill_row_with_payload_test.rs │ │ │ │ │ └── script.rs │ │ │ │ ├── share_test/ │ │ │ │ │ ├── export_test.rs │ │ │ │ │ └── mod.rs │ │ │ │ └── sort_test/ │ │ │ │ ├── mod.rs │ │ │ │ ├── multi_sort_test.rs │ │ │ │ ├── script.rs │ │ │ │ └── single_sort_test.rs │ │ │ └── main.rs │ │ ├── flowy-date/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ └── src/ │ │ │ ├── entities.rs │ │ │ ├── event_handler.rs │ │ │ ├── event_map.rs │ │ │ └── lib.rs │ │ ├── flowy-document/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ ├── src/ │ │ │ │ ├── deps.rs │ │ │ │ ├── document.rs │ │ │ │ ├── document_data.rs │ │ │ │ ├── entities.rs │ │ │ │ ├── event_handler.rs │ │ │ │ ├── event_map.rs │ │ │ │ ├── lib.rs │ │ │ │ ├── manager.rs │ │ │ │ ├── notification.rs │ │ │ │ ├── parse.rs │ │ │ │ ├── parser/ │ │ │ │ │ ├── constant.rs │ │ │ │ │ ├── document_data_parser.rs │ │ │ │ │ ├── external/ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── parser.rs │ │ │ │ │ │ └── utils.rs │ │ │ │ │ ├── json/ │ │ │ │ │ │ ├── block.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── parser.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── parser_entities.rs │ │ │ │ │ └── utils.rs │ │ │ │ └── reminder.rs │ │ │ └── tests/ │ │ │ ├── assets/ │ │ │ │ ├── html/ │ │ │ │ │ ├── bulleted_list.html │ │ │ │ │ ├── callout.html │ │ │ │ │ ├── code.html │ │ │ │ │ ├── divider.html │ │ │ │ │ ├── google_docs.html │ │ │ │ │ ├── heading.html │ │ │ │ │ ├── image.html │ │ │ │ │ ├── math_equation.html │ │ │ │ │ ├── notion.html │ │ │ │ │ ├── numbered_list.html │ │ │ │ │ ├── paragraph.html │ │ │ │ │ ├── quote.html │ │ │ │ │ ├── simple.html │ │ │ │ │ ├── todo_list.html │ │ │ │ │ └── toggle_list.html │ │ │ │ ├── json/ │ │ │ │ │ ├── bulleted_list.json │ │ │ │ │ ├── callout.json │ │ │ │ │ ├── code.json │ │ │ │ │ ├── divider.json │ │ │ │ │ ├── google_docs.json │ │ │ │ │ ├── heading.json │ │ │ │ │ ├── image.json │ │ │ │ │ ├── initial_document.json │ │ │ │ │ ├── math_equation.json │ │ │ │ │ ├── notion.json │ │ │ │ │ ├── numbered_list.json │ │ │ │ │ ├── paragraph.json │ │ │ │ │ ├── plain_text.json │ │ │ │ │ ├── quote.json │ │ │ │ │ ├── range_1.json │ │ │ │ │ ├── range_2.json │ │ │ │ │ ├── simple.json │ │ │ │ │ ├── todo_list.json │ │ │ │ │ └── toggle_list.json │ │ │ │ └── text/ │ │ │ │ ├── bulleted_list.txt │ │ │ │ ├── callout.txt │ │ │ │ ├── code.txt │ │ │ │ ├── divider.txt │ │ │ │ ├── heading.txt │ │ │ │ ├── image.txt │ │ │ │ ├── math_equation.txt │ │ │ │ ├── numbered_list.txt │ │ │ │ ├── paragraph.txt │ │ │ │ ├── plain_text.txt │ │ │ │ ├── quote.txt │ │ │ │ ├── todo_list.txt │ │ │ │ └── toggle_list.txt │ │ │ ├── document/ │ │ │ │ ├── document_insert_test.rs │ │ │ │ ├── document_redo_undo_test.rs │ │ │ │ ├── document_test.rs │ │ │ │ ├── event_handler_test.rs │ │ │ │ ├── mod.rs │ │ │ │ └── util.rs │ │ │ ├── file_storage.rs │ │ │ ├── main.rs │ │ │ └── parser/ │ │ │ ├── document_data_parser_test.rs │ │ │ ├── html/ │ │ │ │ ├── mod.rs │ │ │ │ └── parser_test.rs │ │ │ ├── json/ │ │ │ │ ├── block_test.rs │ │ │ │ ├── mod.rs │ │ │ │ └── parser_test.rs │ │ │ ├── mod.rs │ │ │ └── parse_to_html_text/ │ │ │ ├── mod.rs │ │ │ ├── test.rs │ │ │ └── utils.rs │ │ ├── flowy-document-pub/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── cloud.rs │ │ │ └── lib.rs │ │ ├── flowy-error/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ └── src/ │ │ │ ├── code.rs │ │ │ ├── errors.rs │ │ │ ├── impl_from/ │ │ │ │ ├── cloud.rs │ │ │ │ ├── collab.rs │ │ │ │ ├── collab_persistence.rs │ │ │ │ ├── database.rs │ │ │ │ ├── dispatch.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── reqwest.rs │ │ │ │ ├── serde.rs │ │ │ │ ├── tantivy.rs │ │ │ │ └── url.rs │ │ │ └── lib.rs │ │ ├── flowy-folder/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ └── src/ │ │ │ ├── entities/ │ │ │ │ ├── icon.rs │ │ │ │ ├── import.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── parser/ │ │ │ │ │ ├── empty_str.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── trash/ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── trash_id.rs │ │ │ │ │ ├── view/ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── view_id.rs │ │ │ │ │ │ ├── view_name.rs │ │ │ │ │ │ └── view_thumbnail.rs │ │ │ │ │ └── workspace/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── workspace_desc.rs │ │ │ │ │ ├── workspace_id.rs │ │ │ │ │ └── workspace_name.rs │ │ │ │ ├── publish.rs │ │ │ │ ├── trash.rs │ │ │ │ ├── view.rs │ │ │ │ └── workspace.rs │ │ │ ├── event_handler.rs │ │ │ ├── event_map.rs │ │ │ ├── lib.rs │ │ │ ├── manager.rs │ │ │ ├── manager_init.rs │ │ │ ├── manager_observer.rs │ │ │ ├── notification.rs │ │ │ ├── publish_util.rs │ │ │ ├── share/ │ │ │ │ ├── import.rs │ │ │ │ └── mod.rs │ │ │ ├── user_default.rs │ │ │ ├── util.rs │ │ │ └── view_operation.rs │ │ ├── flowy-folder-pub/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── cloud.rs │ │ │ ├── entities.rs │ │ │ ├── lib.rs │ │ │ ├── query.rs │ │ │ └── sql/ │ │ │ ├── mod.rs │ │ │ ├── workspace_shared_user_sql.rs │ │ │ └── workspace_shared_view_sql.rs │ │ ├── flowy-notification/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ └── src/ │ │ │ ├── builder.rs │ │ │ ├── debounce.rs │ │ │ ├── entities/ │ │ │ │ ├── mod.rs │ │ │ │ └── subject.rs │ │ │ └── lib.rs │ │ ├── flowy-search/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ ├── src/ │ │ │ │ ├── document/ │ │ │ │ │ ├── cloud_search_handler.rs │ │ │ │ │ ├── local_search_handler.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── entities/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── notification.rs │ │ │ │ │ ├── query.rs │ │ │ │ │ ├── result.rs │ │ │ │ │ └── search_filter.rs │ │ │ │ ├── event_handler.rs │ │ │ │ ├── event_map.rs │ │ │ │ ├── lib.rs │ │ │ │ └── services/ │ │ │ │ ├── manager.rs │ │ │ │ └── mod.rs │ │ │ └── tests/ │ │ │ ├── main.rs │ │ │ └── tantivy_test.rs │ │ ├── flowy-search-pub/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── cloud.rs │ │ │ ├── entities.rs │ │ │ ├── lib.rs │ │ │ ├── schema.rs │ │ │ ├── tantivy_state.rs │ │ │ └── tantivy_state_init.rs │ │ ├── flowy-server/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── af_cloud/ │ │ │ │ ├── define.rs │ │ │ │ ├── impls/ │ │ │ │ │ ├── chat.rs │ │ │ │ │ ├── database.rs │ │ │ │ │ ├── document.rs │ │ │ │ │ ├── file_storage.rs │ │ │ │ │ ├── folder.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── search.rs │ │ │ │ │ ├── user/ │ │ │ │ │ │ ├── cloud_service_impl.rs │ │ │ │ │ │ ├── dto.rs │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ └── util.rs │ │ │ │ │ └── util.rs │ │ │ │ ├── mod.rs │ │ │ │ └── server.rs │ │ │ ├── lib.rs │ │ │ ├── local_server/ │ │ │ │ ├── impls/ │ │ │ │ │ ├── chat.rs │ │ │ │ │ ├── database.rs │ │ │ │ │ ├── document.rs │ │ │ │ │ ├── folder.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── search.rs │ │ │ │ │ └── user.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── server.rs │ │ │ │ ├── template/ │ │ │ │ │ ├── create_workspace.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── uid.rs │ │ │ │ └── util.rs │ │ │ ├── response.rs │ │ │ ├── server.rs │ │ │ └── util.rs │ │ ├── flowy-server-pub/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── af_cloud_config.rs │ │ │ ├── lib.rs │ │ │ ├── native/ │ │ │ │ ├── af_cloud_config.rs │ │ │ │ └── mod.rs │ │ │ └── wasm/ │ │ │ ├── af_cloud_config.rs │ │ │ └── mod.rs │ │ ├── flowy-sqlite/ │ │ │ ├── Cargo.toml │ │ │ ├── diesel.toml │ │ │ ├── migrations/ │ │ │ │ ├── 2023-06-05-023648_user/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2023-06-05-135652_collab_snapshot/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2023-07-12-135810_user_auth_type/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2023-07-21-081348_user_workspace/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2023-08-02-083250_user_migration/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2023-08-14-162155_user_encrypt/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2023-10-09-094834_user_stability_ai_key/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2023-10-24-074032_user_updated_at/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2023-11-19-040403_rocksdb_backup/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-01-07-041005_recreate_snapshot_table/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-03-09-031208_user_workspace_icon/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-05-23-061639_chat_message/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-06-14-020242_workspace_member/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-06-16-131359_file_upload/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-06-22-082201_user_ai_model/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-06-26-015936_chat_setting/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-08-05-024351_chat_message_metadata/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-08-07-093650_chat_metadata/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-08-20-061727_file_upload_finish/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-11-08-102351_workspace_member_count/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-12-12-102351_workspace_role/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2024-12-29-061706_collab_metadata/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2025-04-17-042326_chat_metadata/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2025-04-17-142713_offline_chat_message/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2025-04-18-132232_user_workspace_auth_type/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2025-04-22-150142_workspace_member_joined_at/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2025-04-25-071459_local_ai_model/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2025-04-28-070644_collab_table/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2025-05-06-131915_chat_summary/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2025-05-19-074647_create_shared_views_table/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ ├── 2025-06-04-072900_create_shared_users_table/ │ │ │ │ │ ├── down.sql │ │ │ │ │ └── up.sql │ │ │ │ └── 2025-06-04-123016_update_shared_users_table_order/ │ │ │ │ ├── down.sql │ │ │ │ └── up.sql │ │ │ └── src/ │ │ │ ├── kv/ │ │ │ │ ├── kv.rs │ │ │ │ ├── mod.rs │ │ │ │ └── schema.rs │ │ │ ├── lib.rs │ │ │ ├── schema.rs │ │ │ └── sqlite_impl/ │ │ │ ├── conn_ext.rs │ │ │ ├── database.rs │ │ │ ├── errors.rs │ │ │ ├── mod.rs │ │ │ ├── pool.rs │ │ │ └── pragma.rs │ │ ├── flowy-sqlite-vec/ │ │ │ ├── Cargo.toml │ │ │ ├── migrations/ │ │ │ │ └── 001-init/ │ │ │ │ └── up.sql │ │ │ ├── src/ │ │ │ │ ├── db.rs │ │ │ │ ├── entities.rs │ │ │ │ ├── lib.rs │ │ │ │ └── migration.rs │ │ │ └── tests/ │ │ │ └── main.rs │ │ ├── flowy-storage/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ ├── src/ │ │ │ │ ├── entities.rs │ │ │ │ ├── event_handler.rs │ │ │ │ ├── event_map.rs │ │ │ │ ├── file_cache.rs │ │ │ │ ├── lib.rs │ │ │ │ ├── manager.rs │ │ │ │ ├── notification.rs │ │ │ │ ├── sqlite_sql.rs │ │ │ │ └── uploader.rs │ │ │ └── tests/ │ │ │ └── multiple_part_upload_test.rs │ │ ├── flowy-storage-pub/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── chunked_byte.rs │ │ │ ├── cloud.rs │ │ │ ├── lib.rs │ │ │ └── storage.rs │ │ ├── flowy-user/ │ │ │ ├── Cargo.toml │ │ │ ├── Flowy.toml │ │ │ ├── build.rs │ │ │ └── src/ │ │ │ ├── entities/ │ │ │ │ ├── auth.rs │ │ │ │ ├── date_time.rs │ │ │ │ ├── import_data.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── parser/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── user_email.rs │ │ │ │ │ ├── user_icon.rs │ │ │ │ │ ├── user_id.rs │ │ │ │ │ ├── user_name.rs │ │ │ │ │ ├── user_openai_key.rs │ │ │ │ │ ├── user_password.rs │ │ │ │ │ └── user_stability_ai_key.rs │ │ │ │ ├── realtime.rs │ │ │ │ ├── reminder.rs │ │ │ │ ├── user_profile.rs │ │ │ │ ├── user_setting.rs │ │ │ │ └── workspace.rs │ │ │ ├── event_handler.rs │ │ │ ├── event_map.rs │ │ │ ├── lib.rs │ │ │ ├── migrations/ │ │ │ │ ├── anon_user_workspace.rs │ │ │ │ ├── doc_key_with_workspace.rs │ │ │ │ ├── document_empty_content.rs │ │ │ │ ├── migration.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── session_migration.rs │ │ │ │ ├── util.rs │ │ │ │ ├── workspace_and_favorite_v1.rs │ │ │ │ └── workspace_trash_v1.rs │ │ │ ├── notification.rs │ │ │ ├── services/ │ │ │ │ ├── authenticate_user.rs │ │ │ │ ├── billing_check.rs │ │ │ │ ├── cloud_config.rs │ │ │ │ ├── collab_interact.rs │ │ │ │ ├── data_import/ │ │ │ │ │ ├── appflowy_data_import.rs │ │ │ │ │ ├── importer.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── db.rs │ │ │ │ ├── entities.rs │ │ │ │ └── mod.rs │ │ │ └── user_manager/ │ │ │ ├── manager.rs │ │ │ ├── manager_history_user.rs │ │ │ ├── manager_user_awareness.rs │ │ │ ├── manager_user_encryption.rs │ │ │ ├── manager_user_workspace.rs │ │ │ └── mod.rs │ │ ├── flowy-user-pub/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── cloud.rs │ │ │ ├── entities.rs │ │ │ ├── lib.rs │ │ │ ├── session.rs │ │ │ ├── sql/ │ │ │ │ ├── member_sql.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── user_sql.rs │ │ │ │ ├── workspace_setting_sql.rs │ │ │ │ └── workspace_sql.rs │ │ │ └── workspace_service.rs │ │ ├── lib-dispatch/ │ │ │ ├── Cargo.toml │ │ │ ├── src/ │ │ │ │ ├── byte_trait.rs │ │ │ │ ├── data.rs │ │ │ │ ├── dispatcher.rs │ │ │ │ ├── errors/ │ │ │ │ │ ├── errors.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── lib.rs │ │ │ │ ├── macros.rs │ │ │ │ ├── module/ │ │ │ │ │ ├── container.rs │ │ │ │ │ ├── data.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── module.rs │ │ │ │ ├── request/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── payload.rs │ │ │ │ │ └── request.rs │ │ │ │ ├── response/ │ │ │ │ │ ├── builder.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── responder.rs │ │ │ │ │ └── response.rs │ │ │ │ ├── runtime.rs │ │ │ │ ├── service/ │ │ │ │ │ ├── boxed.rs │ │ │ │ │ ├── handler.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── service.rs │ │ │ │ └── util/ │ │ │ │ ├── mod.rs │ │ │ │ └── ready.rs │ │ │ └── tests/ │ │ │ └── api/ │ │ │ ├── main.rs │ │ │ └── module.rs │ │ ├── lib-infra/ │ │ │ ├── Cargo.toml │ │ │ ├── src/ │ │ │ │ ├── box_any.rs │ │ │ │ ├── compression.rs │ │ │ │ ├── encryption/ │ │ │ │ │ └── mod.rs │ │ │ │ ├── file_util.rs │ │ │ │ ├── isolate_stream.rs │ │ │ │ ├── lib.rs │ │ │ │ ├── native/ │ │ │ │ │ ├── future.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── priority_task/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── queue.rs │ │ │ │ │ ├── scheduler.rs │ │ │ │ │ ├── store.rs │ │ │ │ │ └── task.rs │ │ │ │ ├── ref_map.rs │ │ │ │ ├── stream_util.rs │ │ │ │ ├── util.rs │ │ │ │ ├── validator_fn.rs │ │ │ │ └── wasm/ │ │ │ │ ├── future.rs │ │ │ │ └── mod.rs │ │ │ └── tests/ │ │ │ ├── main.rs │ │ │ └── task_test/ │ │ │ ├── mod.rs │ │ │ ├── script.rs │ │ │ ├── task_cancel_test.rs │ │ │ └── task_order_test.rs │ │ ├── lib-log/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── layer.rs │ │ │ ├── lib.rs │ │ │ └── stream_log.rs │ │ ├── rust-toolchain.toml │ │ └── rustfmt.toml │ ├── rust-toolchain.toml │ └── scripts/ │ ├── code_generation/ │ │ ├── flowy_icons/ │ │ │ ├── generate_flowy_icons.cmd │ │ │ └── generate_flowy_icons.sh │ │ ├── freezed/ │ │ │ ├── generate_freezed.cmd │ │ │ └── generate_freezed.sh │ │ ├── generate.cmd │ │ ├── generate.sh │ │ └── language_files/ │ │ ├── generate_language_files.cmd │ │ └── generate_language_files.sh │ ├── docker-buildfiles/ │ │ ├── Dockerfile │ │ ├── README.md │ │ └── docker-compose.yml │ ├── flatpack-buildfiles/ │ │ ├── .gitignore │ │ ├── dbus-interface.xml │ │ ├── io.appflowy.AppFlowy.desktop │ │ ├── io.appflowy.AppFlowy.launcher.desktop │ │ ├── io.appflowy.AppFlowy.metainfo.xml │ │ ├── io.appflowy.AppFlowy.service │ │ ├── io.appflowy.AppFlowy.yml │ │ └── launcher.sh │ ├── flutter_release_build/ │ │ ├── build_flowy.dart │ │ ├── build_universal_package_for_macos.sh │ │ └── tool.dart │ ├── install_dev_env/ │ │ ├── install_ios.sh │ │ ├── install_linux.sh │ │ ├── install_macos.sh │ │ └── install_windows.sh │ ├── linux_distribution/ │ │ ├── appimage/ │ │ │ ├── AppImageBuilder.yml │ │ │ ├── build_appimage.sh │ │ │ └── io.appflowy.AppFlowy.desktop │ │ ├── deb/ │ │ │ ├── AppFlowy.desktop │ │ │ ├── DEBIAN/ │ │ │ │ ├── control │ │ │ │ ├── postinst │ │ │ │ └── postrm │ │ │ ├── README.md │ │ │ └── build_deb.sh │ │ ├── flatpak/ │ │ │ └── README.md │ │ └── packaging/ │ │ ├── io.appflowy.AppFlowy.launcher.desktop │ │ ├── io.appflowy.AppFlowy.metainfo.xml │ │ ├── io.appflowy.AppFlowy.service │ │ └── launcher.sh │ ├── linux_installer/ │ │ ├── control │ │ ├── postinst │ │ └── postrm │ ├── makefile/ │ │ ├── desktop.toml │ │ ├── docker.toml │ │ ├── env.toml │ │ ├── flutter.toml │ │ ├── mobile.toml │ │ ├── protobuf.toml │ │ ├── tauri.toml │ │ ├── tests.toml │ │ ├── tool.toml │ │ └── web.toml │ ├── tool/ │ │ ├── update_client_api_rev.sh │ │ ├── update_collab_rev.sh │ │ ├── update_collab_source.sh │ │ └── update_local_ai_rev.sh │ ├── white_label/ │ │ ├── code_white_label.sh │ │ ├── font_white_label.sh │ │ ├── i18n_white_label.sh │ │ ├── icon_white_label.sh │ │ ├── white_label.sh │ │ └── windows_white_label.sh │ └── windows_installer/ │ └── inno_setup_config.iss ├── install.sh ├── project.inlang/ │ ├── project_id │ └── settings.json └── project.inlang.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git ================================================ FILE: .githooks/commit-msg ================================================ #!/bin/sh # # An example hook script to check the commit log message. # Called by "git commit" with one argument, the name of the file # that has the commit message. The hook should exit with non-zero # status after issuing an appropriate message if it wants to stop the # commit. The hook is allowed to edit the commit message file. YELLOW="\e[93m" GREEN="\e[32m" RED="\e[31m" ENDCOLOR="\e[0m" printMessage() { printf "${YELLOW}AppFlowy : $1${ENDCOLOR}\n" } printSuccess() { printf "${GREEN}AppFlowy : $1${ENDCOLOR}\n" } printError() { printf "${RED}AppFlowy : $1${ENDCOLOR}\n" } printMessage "Running the AppFlowy commit-msg hook." # This example catches duplicate Signed-off-by lines. test "" = "$(grep '^Signed-off-by: ' "$1" | sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { echo >&2 Duplicate Signed-off-by lines. exit 1 } .githooks/gitlint \ --msg-file=$1 \ --subject-regex="^(build|chore|ci|docs|feat|feature|fix|perf|refactor|revert|style|test)(.*)?:\s?.*" \ --subject-maxlen=150 \ --subject-minlen=10 \ --body-regex=".*" \ --max-parents=1 if [ $? -ne 0 ] then printError "Please fix your commit message to match AppFlowy coding standards" printError "https://docs.appflowy.io/docs/documentation/software-contributions/conventions/git-conventions" exit 1 fi ================================================ FILE: .githooks/pre-commit ================================================ #!/usr/bin/env bash YELLOW="\e[93m" GREEN="\e[32m" RED="\e[31m" ENDCOLOR="\e[0m" printMessage() { printf "${YELLOW}AppFlowy : $1${ENDCOLOR}\n" } printSuccess() { printf "${GREEN}AppFlowy : $1${ENDCOLOR}\n" } printError() { printf "${RED}AppFlowy : $1${ENDCOLOR}\n" } printMessage "Running local AppFlowy pre-commit hook." #flutter format . ##https://gist.github.com/benmccallum/28e4f216d9d72f5965133e6c43aaff6e limit=$(( 1 * 2**20 )) # 1MB function file_too_large(){ filename=$0 filesize=$(( $1 / 2**20 )) cat < /dev/null 2>&1 then against=HEAD else against=empty_tree fi for file in $( git diff-index --cached --name-only $against ); do file_size=$( ls -la $file | awk '{ print $5 }') if [ "$file_size" -gt "$limit" ]; then file_too_large $filename $file_size exit 1; fi done ================================================ FILE: .githooks/pre-push ================================================ #!/usr/bin/env bash YELLOW="\e[93m" GREEN="\e[32m" RED="\e[31m" ENDCOLOR="\e[0m" printMessage() { printf "${YELLOW}AppFlowy : $1${ENDCOLOR}\n" } printSuccess() { printf "${GREEN}AppFlowy : $1${ENDCOLOR}\n" } printError() { printf "${RED}AppFlowy : $1${ENDCOLOR}\n" } printMessage "Running local AppFlowy pre-push hook." if [[ `git status --porcelain` ]]; then printError "This script needs to run against committed code only. Please commit or stash you changes." exit 1 fi # #printMessage "Running the Flutter analyzer" #flutter analyze # #if [ $? -ne 0 ]; then # printError "Flutter analyzer error" # exit 1 #fi # #printMessage "Finished running the Flutter analyzer" ================================================ FILE: .github/FUNDING.yml ================================================ ko_fi: appflowy ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ name: Bug Report description: Create a bug report to help us improve title: "[Bug] " body: - type: textarea id: desc attributes: label: Bug Description description: A clear and concise description of what the bug is validations: required: true - type: textarea id: reproduce attributes: label: How to Reproduce description: What steps can we take to reproduce this behavior? validations: required: true - type: textarea id: expected attributes: label: Expected Behavior description: A clear and concise description of what you expected to happen validations: required: true - type: input id: os attributes: label: Operating System description: What OS are you seeing this bug on? validations: required: true - type: input id: version attributes: label: AppFlowy Version(s) description: What version(s) of AppFlowy do you see this bug on? validations: required: true - type: textarea id: screenshots attributes: label: Screenshots description: If applicable, please add screenshots to help explain your problem - type: textarea id: context attributes: label: Additional Context description: Add any additonal context about the problem here ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yaml ================================================ name: Feature Request description: Suggest an idea for AppFlowy title: "[FR] " body: - type: textarea id: desc attributes: label: Description description: Describe your suggested feature and the main use cases validations: required: true - type: textarea id: users attributes: label: Impact description: What types of users can benefit from using the suggested feature? validations: required: true - type: textarea id: context attributes: label: Additional Context description: Add any additonal context about the feature here ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ### Feature Preview --- #### PR Checklist - [ ] My code adheres to [AppFlowy's Conventions](https://docs.appflowy.io/docs/documentation/software-contributions/conventions) - [ ] I've listed at least one issue that this PR fixes in the description above. - [ ] I've added a test(s) to validate changes in this PR, or this PR only contains semantic changes. - [ ] All existing tests are passing. ================================================ FILE: .github/actions/flutter_build/action.yml ================================================ name: Flutter Integration Test description: Run integration tests for AppFlowy inputs: os: description: "The operating system to run the tests on" required: true flutter_version: description: "The version of Flutter to use" required: true rust_toolchain: description: "The version of Rust to use" required: true cargo_make_version: description: "The version of cargo-make to use" required: true rust_target: description: "The target to build for" required: true flutter_profile: description: "The profile to build with" required: true runs: using: "composite" steps: - name: Checkout source code uses: actions/checkout@v4 - name: Install Rust toolchain id: rust_toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ inputs.rust_toolchain }} target: ${{ inputs.rust_target }} override: true profile: minimal - name: Install flutter id: flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ inputs.flutter_version }} cache: true - uses: Swatinem/rust-cache@v2 with: prefix-key: ${{ inputs.os }} workspaces: | frontend/rust-lib cache-all-crates: true - uses: taiki-e/install-action@v2 with: tool: cargo-make@${{ inputs.cargo_make_version }}, duckscript_cli - name: Install prerequisites working-directory: frontend shell: bash run: | case $RUNNER_OS in Linux) sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list sudo apt-get update sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libcurl4-openssl-dev ;; Windows) vcpkg integrate install vcpkg update ;; macOS) # No additional prerequisites needed for macOS ;; esac cargo make appflowy-flutter-deps-tools - name: Build AppFlowy working-directory: frontend run: cargo make --profile ${{ inputs.flutter_profile }} appflowy-core-dev shell: bash - name: Run code generation working-directory: frontend run: cargo make code_generation shell: bash - name: Flutter Analyzer working-directory: frontend/appflowy_flutter run: flutter analyze . shell: bash - name: Compress appflowy_flutter run: tar -czf appflowy_flutter.tar.gz frontend/appflowy_flutter shell: bash - uses: actions/upload-artifact@v4 with: name: ${{ github.run_id }}-${{ matrix.os }} path: appflowy_flutter.tar.gz ================================================ FILE: .github/actions/flutter_integration_test/action.yml ================================================ name: Flutter Integration Test description: Run integration tests for AppFlowy inputs: test_path: description: "The path to the integration test file" required: true flutter_version: description: "The version of Flutter to use" required: true rust_toolchain: description: "The version of Rust to use" required: true cargo_make_version: description: "The version of cargo-make to use" required: true rust_target: description: "The target to build for" required: true runs: using: "composite" steps: - name: Checkout source code uses: actions/checkout@v4 - name: Install Rust toolchain id: rust_toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ inputs.RUST_TOOLCHAIN }} target: ${{ inputs.rust_target }} override: true profile: minimal - name: Install flutter id: flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ inputs.flutter_version }} cache: true - uses: taiki-e/install-action@v2 with: tool: cargo-make@${{ inputs.cargo_make_version }} - name: Install prerequisites working-directory: frontend run: | sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list sudo apt-get update sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager libcurl4-openssl-dev shell: bash - name: Enable Flutter Desktop run: | flutter config --enable-linux-desktop shell: bash - uses: actions/download-artifact@v4 with: name: ${{ github.run_id }}-ubuntu-latest - name: Uncompressed appflowy_flutter run: tar -xf appflowy_flutter.tar.gz shell: bash - name: Run Flutter integration tests working-directory: frontend/appflowy_flutter run: | export DISPLAY=:99 sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & sudo apt-get install network-manager flutter test ${{ inputs.test_path }} -d Linux --coverage shell: bash ================================================ FILE: .github/workflows/android_ci.yaml.bak ================================================ name: Android CI on: push: branches: - "main" paths: - ".github/workflows/mobile_ci.yaml" - "frontend/**" pull_request: branches: - "main" paths: - ".github/workflows/mobile_ci.yaml" - "frontend/**" - "!frontend/appflowy_tauri/**" env: CARGO_TERM_COLOR: always FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.85.0" CARGO_MAKE_VERSION: "0.37.18" CLOUD_VERSION: 0.6.54-amd64 concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: build: if: github.event.pull_request.draft != true strategy: fail-fast: true matrix: os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - name: Check storage space run: df -h # the following step is required to avoid running out of space - name: Maximize build space if: matrix.os == 'ubuntu-latest' run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc sudo rm -rf "/usr/local/share/boost" sudo rm -rf "$AGENT_TOOLSDIRECTORY" sudo docker image prune --all --force sudo rm -rf /opt/hostedtoolcache/codeQL sudo rm -rf ${GITHUB_WORKSPACE}/.git - name: Check storage space run: df -h - name: Checkout appflowy cloud code uses: actions/checkout@v4 with: repository: AppFlowy-IO/AppFlowy-Cloud path: AppFlowy-Cloud - name: Prepare appflowy cloud env working-directory: AppFlowy-Cloud run: | # log level cp deploy.env .env sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env - name: Run Docker-Compose working-directory: AppFlowy-Cloud env: APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }} APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }} run: | container_id=$(docker ps --filter name=appflowy-cloud-appflowy_cloud-1 -q) if [ -z "$container_id" ]; then echo "AppFlowy-Cloud container is not running. Pulling and starting the container..." docker compose pull docker compose up -d echo "Waiting for the container to be ready..." sleep 10 else running_image=$(docker inspect --format='{{index .Config.Image}}' "$container_id") if [ "$running_image" != "appflowy-cloud:$APPFLOWY_CLOUD_VERSION" ]; then echo "AppFlowy-Cloud is running with an incorrect version. Restarting with the correct version..." # Remove all containers if any exist if [ "$(docker ps -aq)" ]; then docker rm -f $(docker ps -aq) else echo "No containers to remove." fi # Remove all volumes if any exist if [ "$(docker volume ls -q)" ]; then docker volume rm $(docker volume ls -q) else echo "No volumes to remove." fi docker compose pull docker compose up -d echo "Waiting for the container to be ready..." sleep 10 docker ps -a docker compose logs else echo "AppFlowy-Cloud is running with the correct version." fi fi - name: Checkout source code uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: 11 - name: Install Rust toolchain id: rust_toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} override: true profile: minimal - name: Install flutter id: flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - uses: gradle/gradle-build-action@v3 with: gradle-version: 8.10 - uses: davidB/rust-cargo-make@v1 with: version: ${{ env.CARGO_MAKE_VERSION }} - name: Install prerequisites working-directory: frontend run: | rustup target install aarch64-linux-android rustup target install x86_64-linux-android rustup target add armv7-linux-androideabi cargo install --force --locked duckscript_cli cargo install cargo-ndk if [ "$RUNNER_OS" == "Linux" ]; then sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list sudo apt-get update sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev sudo apt-get install keybinder-3.0 libnotify-dev sudo apt-get install gcc-multilib elif [ "$RUNNER_OS" == "Windows" ]; then vcpkg integrate install elif [ "$RUNNER_OS" == "macOS" ]; then echo 'do nothing' fi cargo make appflowy-flutter-deps-tools shell: bash - name: Build AppFlowy working-directory: frontend run: | cargo make --profile development-android appflowy-core-dev-android cargo make --profile development-android code_generation cd rust-lib cargo clean - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Run integration tests # https://github.com/ReactiveCircus/android-emulator-runner uses: reactivecircus/android-emulator-runner@v2 with: api-level: 33 arch: x86_64 disk-size: 2048M working-directory: frontend/appflowy_flutter disable-animations: true force-avd-creation: false target: google_apis script: flutter test integration_test/mobile/cloud/cloud_runner.dart ================================================ FILE: .github/workflows/build_command.yml ================================================ name: build on: repository_dispatch: types: [build-command] jobs: build: runs-on: ubuntu-latest steps: - name: notify appflowy_builder run: | platform=${{ github.event.client_payload.slash_command.args.unnamed.arg1 }} build_name=${{ github.event.client_payload.slash_command.args.named.build_name }} branch=${{ github.event.client_payload.slash_command.args.named.ref }} build_type="" arch="" if [ "$platform" = "android" ]; then build_type="apk" elif [ "$platform" = "macos" ]; then arch="universal" fi params=$(jq -n \ --arg ref "main" \ --arg repo "LucasXu0/AppFlowy" \ --arg branch "$branch" \ --arg build_name "$build_name" \ --arg build_type "$build_type" \ --arg arch "$arch" \ '{ref: $ref, inputs: {repo: $repo, branch: $branch, build_name: $build_name, build_type: $build_type, arch: $arch}} | del(.inputs | .. | select(. == ""))') echo "params: $params" curl -L \ -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${{ secrets.TOKEN }}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ https://api.github.com/repos/AppFlowy-IO/AppFlowy-Builder/actions/workflows/$platform.yaml/dispatches \ -d "$params" ================================================ FILE: .github/workflows/commit_lint.yml ================================================ name: Commit messages lint on: [pull_request, push] jobs: commitlint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@v4 ================================================ FILE: .github/workflows/docker_ci.yml ================================================ name: Docker-CI on: push: branches: [ "main", "release/*" ] pull_request: branches: [ "main", "release/*" ] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: build-app: if: github.event.pull_request.draft != true runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 # cache the docker layers # don't cache anything temporarly, because it always triggers "no space left on device" error # - name: Cache Docker layers # uses: actions/cache@v3 # with: # path: /tmp/.buildx-cache # key: ${{ runner.os }}-buildx-${{ github.sha }} # restore-keys: | # ${{ runner.os }}-buildx- - name: Build the app uses: docker/build-push-action@v5 with: context: . file: ./frontend/scripts/docker-buildfiles/Dockerfile push: false # cache-from: type=local,src=/tmp/.buildx-cache # cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max # - name: Move cache # run: | # rm -rf /tmp/.buildx-cache # mv /tmp/.buildx-cache-new /tmp/.buildx-cache ================================================ FILE: .github/workflows/flutter_ci.yaml ================================================ name: Flutter-CI on: push: branches: - "main" - "release/*" paths: - ".github/workflows/flutter_ci.yaml" - ".github/actions/flutter_build/**" - "frontend/rust-lib/**" - "frontend/appflowy_flutter/**" - "frontend/resources/**" pull_request: branches: - "main" - "release/*" paths: - ".github/workflows/flutter_ci.yaml" - ".github/actions/flutter_build/**" - "frontend/rust-lib/**" - "frontend/appflowy_flutter/**" - "frontend/resources/**" env: CARGO_TERM_COLOR: always FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.85.0" CARGO_MAKE_VERSION: "0.37.18" CLOUD_VERSION: 0.9.49-amd64 concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: prepare-linux: if: github.event.pull_request.draft != true strategy: fail-fast: true matrix: os: [ ubuntu-latest ] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.os }} steps: # the following step is required to avoid running out of space - name: Maximize build space run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc sudo rm -rf "/usr/local/share/boost" sudo rm -rf "$AGENT_TOOLSDIRECTORY" - name: Checkout source code uses: actions/checkout@v4 - name: Flutter build uses: ./.github/actions/flutter_build with: os: ${{ matrix.os }} flutter_version: ${{ env.FLUTTER_VERSION }} rust_toolchain: ${{ env.RUST_TOOLCHAIN }} cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} rust_target: ${{ matrix.target }} flutter_profile: ${{ matrix.flutter_profile }} prepare-windows: if: github.event.pull_request.draft != true strategy: fail-fast: true matrix: os: [ windows-latest ] include: - os: windows-latest flutter_profile: development-windows-x86 target: x86_64-pc-windows-msvc runs-on: ${{ matrix.os }} steps: - name: Checkout source code uses: actions/checkout@v4 - name: Flutter build uses: ./.github/actions/flutter_build with: os: ${{ matrix.os }} flutter_version: ${{ env.FLUTTER_VERSION }} DISABLE_CI_TEST_LOG: "true" rust_toolchain: ${{ env.RUST_TOOLCHAIN }} cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} rust_target: ${{ matrix.target }} flutter_profile: ${{ matrix.flutter_profile }} prepare-macos: if: github.event.pull_request.draft != true strategy: fail-fast: true matrix: os: [ macos-latest ] include: - os: macos-latest flutter_profile: development-mac-x86_64 target: x86_64-apple-darwin runs-on: ${{ matrix.os }} steps: - name: Checkout source code uses: actions/checkout@v4 - name: Flutter build uses: ./.github/actions/flutter_build with: os: ${{ matrix.os }} flutter_version: ${{ env.FLUTTER_VERSION }} rust_toolchain: ${{ env.RUST_TOOLCHAIN }} cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} rust_target: ${{ matrix.target }} flutter_profile: ${{ matrix.flutter_profile }} unit_test: needs: [ prepare-linux ] if: github.event.pull_request.draft != true strategy: fail-fast: false matrix: os: [ ubuntu-latest ] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.os }} steps: - name: Checkout source code uses: actions/checkout@v4 - name: Install Rust toolchain id: rust_toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} target: ${{ matrix.target }} override: true profile: minimal - name: Install flutter id: flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - uses: Swatinem/rust-cache@v2 with: prefix-key: ${{ matrix.os }} workspaces: | frontend/rust-lib cache-all-crates: true - uses: taiki-e/install-action@v2 with: tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}, duckscript_cli - name: Install prerequisites working-directory: frontend run: | if [ "$RUNNER_OS" == "Linux" ]; then sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list sudo apt-get update sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libcurl4-openssl-dev fi shell: bash - name: Enable Flutter Desktop run: | if [ "$RUNNER_OS" == "Linux" ]; then flutter config --enable-linux-desktop elif [ "$RUNNER_OS" == "macOS" ]; then flutter config --enable-macos-desktop elif [ "$RUNNER_OS" == "Windows" ]; then git config --system core.longpaths true flutter config --enable-windows-desktop fi shell: bash - uses: actions/download-artifact@v4 with: name: ${{ github.run_id }}-${{ matrix.os }} - name: Uncompress appflowy_flutter run: tar -xf appflowy_flutter.tar.gz - name: Run flutter pub get working-directory: frontend run: cargo make pub_get - name: Run Flutter unit tests env: DISABLE_EVENT_LOG: true DISABLE_CI_TEST_LOG: "true" working-directory: frontend run: | if [ "$RUNNER_OS" == "macOS" ]; then cargo make dart_unit_test elif [ "$RUNNER_OS" == "Linux" ]; then cargo make dart_unit_test_no_build elif [ "$RUNNER_OS" == "Windows" ]; then cargo make dart_unit_test_no_build fi shell: bash cloud_integration_test: needs: [ prepare-linux ] strategy: fail-fast: false matrix: os: [ ubuntu-latest ] include: - os: ubuntu-latest flutter_profile: development-linux-x86_64 target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.os }} steps: - name: Checkout appflowy cloud code uses: actions/checkout@v4 with: repository: AppFlowy-IO/AppFlowy-Cloud path: AppFlowy-Cloud - name: Prepare appflowy cloud env working-directory: AppFlowy-Cloud run: | # log level cp deploy.env .env sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env - name: Run Docker-Compose working-directory: AppFlowy-Cloud env: APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }} APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }} run: | container_id=$(docker ps --filter name=appflowy-cloud-appflowy_cloud-1 -q) if [ -z "$container_id" ]; then echo "AppFlowy-Cloud container is not running. Pulling and starting the container..." docker compose pull docker compose up -d echo "Waiting for the container to be ready..." sleep 10 else running_image=$(docker inspect --format='{{index .Config.Image}}' "$container_id") if [ "$running_image" != "appflowy-cloud:$APPFLOWY_CLOUD_VERSION" ]; then echo "AppFlowy-Cloud is running with an incorrect version. Restarting with the correct version..." # Remove all containers if any exist if [ "$(docker ps -aq)" ]; then docker rm -f $(docker ps -aq) else echo "No containers to remove." fi # Remove all volumes if any exist if [ "$(docker volume ls -q)" ]; then docker volume rm $(docker volume ls -q) else echo "No volumes to remove." fi docker compose pull docker compose up -d echo "Waiting for the container to be ready..." sleep 10 docker ps -a docker compose logs else echo "AppFlowy-Cloud is running with the correct version." fi fi - name: Checkout source code uses: actions/checkout@v4 - name: Install flutter id: flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - uses: taiki-e/install-action@v2 with: tool: cargo-make@${{ env.CARGO_MAKE_VERSION }} - name: Install prerequisites working-directory: frontend run: | sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list sudo apt-get update sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev libcurl4-openssl-dev shell: bash - name: Enable Flutter Desktop run: | flutter config --enable-linux-desktop shell: bash - uses: actions/download-artifact@v4 with: name: ${{ github.run_id }}-${{ matrix.os }} - name: Uncompressed appflowy_flutter run: | tar -xf appflowy_flutter.tar.gz ls -al - name: Run flutter pub get working-directory: frontend run: cargo make pub_get - name: Run Flutter integration tests working-directory: frontend/appflowy_flutter run: | export DISPLAY=:99 sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & sudo apt-get install network-manager docker ps -a flutter test integration_test/desktop/cloud/cloud_runner.dart -d Linux --coverage shell: bash integration_test: needs: [ prepare-linux ] if: github.event.pull_request.draft != true strategy: fail-fast: false matrix: os: [ ubuntu-latest ] test_number: [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ] include: - os: ubuntu-latest target: "x86_64-unknown-linux-gnu" runs-on: ${{ matrix.os }} steps: - name: Checkout source code uses: actions/checkout@v4 - name: Flutter Integration Test ${{ matrix.test_number }} uses: ./.github/actions/flutter_integration_test with: test_path: integration_test/desktop_runner_${{ matrix.test_number }}.dart flutter_version: ${{ env.FLUTTER_VERSION }} rust_toolchain: ${{ env.RUST_TOOLCHAIN }} cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} rust_target: ${{ matrix.target }} ================================================ FILE: .github/workflows/ios_ci.yaml ================================================ name: iOS CI on: push: branches: - "main" paths: - ".github/workflows/mobile_ci.yaml" - "frontend/**" - "!frontend/appflowy_web_app/**" pull_request: branches: - "main" paths: - ".github/workflows/mobile_ci.yaml" - "frontend/**" - "!frontend/appflowy_web_app/**" env: FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.85.0" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: integration-tests: runs-on: macos-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} target: aarch64-apple-ios-sim override: true profile: minimal - name: Install Flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - uses: Swatinem/rust-cache@v2 with: prefix-key: macos-latest workspaces: | frontend/rust-lib - uses: davidB/rust-cargo-make@v1 with: version: "0.37.15" - name: Install prerequisites working-directory: frontend run: | rustup target install aarch64-apple-ios-sim cargo install --force --locked duckscript_cli cargo install cargo-lipo cargo make appflowy-flutter-deps-tools shell: bash - name: Build AppFlowy working-directory: frontend run: | cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios cargo make --profile development-ios-arm64-sim code_generation # - uses: futureware-tech/simulator-action@v3 # id: simulator-action # with: # model: "iPhone 15" # shutdown_after_job: false # - name: Run AppFlowy on simulator # working-directory: frontend/appflowy_flutter # run: | # flutter run -d ${{ steps.simulator-action.outputs.udid }} & # pid=$! # sleep 500 # kill $pid # continue-on-error: true # # Integration tests # - name: Run integration tests # working-directory: frontend/appflowy_flutter # # The integration tests are flaky and sometimes fail with "Connection timed out": # # Don't block the CI. If the tests fail, the CI will still pass. # # Instead, we're using Code Magic to re-run the tests to check if they pass. # continue-on-error: true # run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }} ================================================ FILE: .github/workflows/mobile_ci.yml ================================================ name: Mobile-CI on: workflow_dispatch: inputs: branch: description: "Branch to build" required: true default: "main" workflow_id: description: "Codemagic workflow ID" required: true default: "ios-workflow" type: choice options: - ios-workflow - android-workflow env: CODEMAGIC_API_TOKEN: ${{ secrets.CODEMAGIC_API_TOKEN }} APP_ID: "6731d2f427e7c816080c3674" jobs: trigger-mobile-build: runs-on: ubuntu-latest steps: - name: Trigger Codemagic Build id: trigger_build run: | RESPONSE=$(curl -X POST \ --header "Content-Type: application/json" \ --header "x-auth-token: $CODEMAGIC_API_TOKEN" \ --data '{ "appId": "${{ env.APP_ID }}", "workflowId": "${{ github.event.inputs.workflow_id }}", "branch": "${{ github.event.inputs.branch }}" }' \ https://api.codemagic.io/builds) BUILD_ID=$(echo $RESPONSE | jq -r '.buildId') echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT echo "build_id=$BUILD_ID" - name: Wait for build and check status id: check_status run: | while true; do curl -X GET \ --header "Content-Type: application/json" \ --header "x-auth-token: $CODEMAGIC_API_TOKEN" \ https://api.codemagic.io/builds/${{ steps.trigger_build.outputs.build_id }} > /tmp/response.json RESPONSE_WITHOUT_COMMAND=$(cat /tmp/response.json | jq 'walk(if type == "object" and has("subactions") then .subactions |= map(del(.command)) else . end)') STATUS=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.build.status') if [ "$STATUS" = "finished" ]; then SUCCESS=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.success') BUILD_URL=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.buildUrl') echo "status=$STATUS" >> $GITHUB_OUTPUT echo "success=$SUCCESS" >> $GITHUB_OUTPUT echo "build_url=$BUILD_URL" >> $GITHUB_OUTPUT break elif [ "$STATUS" = "failed" ]; then echo "status=failed" >> $GITHUB_OUTPUT break fi sleep 60 done - name: Slack Notification uses: 8398a7/action-slack@v3 if: always() with: status: ${{ steps.check_status.outputs.success == 'true' && 'success' || 'failure' }} fields: repo,message,commit,author,action,eventName,ref,workflow,job,took text: | Mobile CI Build Result Branch: ${{ github.event.inputs.branch }} Workflow: ${{ github.event.inputs.workflow_id }} Build URL: ${{ steps.check_status.outputs.build_url }} env: SLACK_WEBHOOK_URL: ${{ secrets.RELEASE_SLACK_WEBHOOK }} ================================================ FILE: .github/workflows/ninja_i18n.yml ================================================ name: Ninja i18n action on: pull_request_target: # explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings permissions: pull-requests: write jobs: ninja-i18n: name: Ninja i18n - GitHub Lint Action runs-on: ubuntu-latest steps: - name: Checkout id: checkout uses: actions/checkout@v4 - name: Run Ninja i18n id: ninja-i18n uses: opral/ninja-i18n-action@main env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: push: tags: - "*" env: FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.85.0" jobs: create-release: runs-on: ubuntu-latest env: RELEASE_NOTES_PATH: /tmp/release_notes outputs: upload_url: ${{ steps.create_release.outputs.upload_url }} steps: - name: Checkout uses: actions/checkout@v4 - name: Build release notes run: | touch ${{ env.RELEASE_NOTES_PATH }} cat CHANGELOG.md | sed -e '/./{H;$!d;}' -e "x;/##\ Version\ ${{ github.ref_name }}/"'!d;' >> ${{ env.RELEASE_NOTES_PATH }} - name: Create release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: v${{ github.ref }} body_path: ${{ env.RELEASE_NOTES_PATH }} # the package name should be with the format: AppFlowy--- build-for-windows: name: ${{ matrix.job.target }} (${{ matrix.job.os }}) needs: create-release env: WINDOWS_APP_RELEASE_PATH: frontend\appflowy_flutter\product\${{ github.ref_name }}\windows WINDOWS_ZIP_NAME: AppFlowy-${{ github.ref_name }}-windows-x86_64.zip WINDOWS_INSTALLER_NAME: AppFlowy-${{ github.ref_name }}-windows-x86_64 runs-on: ${{ matrix.job.os }} strategy: fail-fast: false matrix: job: - { target: x86_64-pc-windows-msvc, os: windows-2019 } steps: - name: Checkout source code uses: actions/checkout@v4 - name: Install flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} target: ${{ matrix.job.target }} override: true components: rustfmt profile: minimal - name: Install prerequisites working-directory: frontend run: | vcpkg integrate install cargo install --force --locked cargo-make cargo install --force --locked duckscript_cli - name: Build Windows app working-directory: frontend # the cargo make script has to be run separately because of file locking issues run: | flutter config --enable-windows-desktop dart ./scripts/flutter_release_build/build_flowy.dart exclude-directives . ${{ github.ref_name }} cargo make --env APP_VERSION=${{ github.ref_name }} --profile production-windows-x86 appflowy dart ./scripts/flutter_release_build/build_flowy.dart include-directives . ${{ github.ref_name }} - name: Archive Asset uses: vimtor/action-zip@v1 with: files: ${{ env.WINDOWS_APP_RELEASE_PATH }}\ dest: ${{ env.WINDOWS_APP_RELEASE_PATH }}\${{ env.WINDOWS_ZIP_NAME }} - name: Copy installer config & icon file working-directory: frontend run: | cp scripts/windows_installer/* ../${{ env.WINDOWS_APP_RELEASE_PATH }} - name: Build installer executable working-directory: ${{ env.WINDOWS_APP_RELEASE_PATH }} run: | iscc /F${{ env.WINDOWS_INSTALLER_NAME }} inno_setup_config.iss /DAppVersion=${{ github.ref_name }} - name: Upload Asset id: upload-release-asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.WINDOWS_APP_RELEASE_PATH }}\${{ env.WINDOWS_ZIP_NAME }} asset_name: ${{ env.WINDOWS_ZIP_NAME }} asset_content_type: application/octet-stream - name: Upload Installer Asset id: upload-installer-asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.WINDOWS_APP_RELEASE_PATH }}\Output\${{ env.WINDOWS_INSTALLER_NAME }}.exe asset_name: ${{ env.WINDOWS_INSTALLER_NAME }}.exe asset_content_type: application/octet-stream build-for-macOS-x86_64: name: ${{ matrix.job.target }} (${{ matrix.job.os }}) [${{ matrix.job.extra-build-args }}] runs-on: ${{ matrix.job.os }} needs: create-release env: MACOS_APP_RELEASE_PATH: frontend/appflowy_flutter/product/${{ github.ref_name }}/macos/Release MACOS_X86_ZIP_NAME: AppFlowy-${{ github.ref_name }}-macos-x86_64.zip MACOS_DMG_NAME: AppFlowy-${{ github.ref_name }}-macos-x86_64 strategy: fail-fast: false matrix: job: - { target: x86_64-apple-darwin, os: macos-13, extra-build-args: "" } steps: - name: Checkout source code uses: actions/checkout@v4 - name: Install flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} target: ${{ matrix.job.target }} override: true components: rustfmt profile: minimal - name: Install prerequisites working-directory: frontend run: | cargo install --force --locked cargo-make cargo install --force --locked duckscript_cli - name: Build AppFlowy working-directory: frontend run: | flutter config --enable-macos-desktop dart ./scripts/flutter_release_build/build_flowy.dart run . ${{ github.ref_name }} - name: Codesign AppFlowy run: | echo ${{ secrets.MACOS_CERTIFICATE }} | base64 --decode > certificate.p12 security create-keychain -p action build.keychain security default-keychain -s build.keychain security unlock-keychain -p action build.keychain security import certificate.p12 -k build.keychain -P ${{ secrets.MACOS_CERTIFICATE_PWD }} -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k action build.keychain /usr/bin/codesign --force --options runtime --deep --sign "${{ secrets.MACOS_CODESIGN_ID }}" "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" -v - name: Create macOS dmg run: | brew install create-dmg i=0 until [[ -e "${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg" ]]; do create-dmg \ --volname ${{ env.MACOS_DMG_NAME }} \ --hide-extension "AppFlowy.app" \ --background frontend/scripts/dmg_assets/AppFlowyInstallerBackground.jpg \ --window-size 600 450 \ --icon-size 94 \ --icon "AppFlowy.app" 141 249 \ --app-drop-link 458 249 \ "${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg" \ "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" || true if [[ $i -eq 10 ]]; then echo 'Error: create-dmg did not succeed even after 10 tries.' exit 1 fi i=$((i+1)) done - name: Notarize AppFlowy run: | xcrun notarytool submit ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg --apple-id ${{ secrets.MACOS_NOTARY_USER }} --team-id ${{ secrets.MACOS_TEAM_ID }} --password ${{ secrets.MACOS_NOTARY_PWD }} -v -f "json" --wait - name: Archive Asset working-directory: ${{ env.MACOS_APP_RELEASE_PATH }} run: zip --symlinks -qr ${{ env.MACOS_X86_ZIP_NAME }} AppFlowy.app - name: Upload Asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_X86_ZIP_NAME }} asset_name: ${{ env.MACOS_X86_ZIP_NAME }} asset_content_type: application/octet-stream - name: Upload DMG Asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg asset_name: ${{ env.MACOS_DMG_NAME }}.dmg asset_content_type: application/octet-stream build-for-macOS-universal: name: ${{ matrix.job.target }} (${{ matrix.job.os }}) [${{ matrix.job.extra-build-args }}] runs-on: ${{ matrix.job.os }} needs: create-release env: MACOS_APP_RELEASE_PATH: frontend/appflowy_flutter/product/${{ github.ref_name }}/macos/Release MACOS_AARCH64_ZIP_NAME: AppFlowy-${{ github.ref_name }}-macos-universal.zip MACOS_DMG_NAME: AppFlowy-${{ github.ref_name }}-macos-universal strategy: fail-fast: false matrix: job: - { targets: "aarch64-apple-darwin,x86_64-apple-darwin", os: macos-14, extra-build-args: "", } steps: - name: Checkout source code uses: actions/checkout@v4 - name: Install flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} targets: ${{ matrix.job.targets }} components: rustfmt - name: Install prerequisites working-directory: frontend run: | cargo install --force --locked cargo-make cargo install --force --locked duckscript_cli - name: Build AppFlowy working-directory: frontend run: | flutter config --enable-macos-desktop sh scripts/flutter_release_build/build_universal_package_for_macos.sh ${{ github.ref_name }} - name: Codesign AppFlowy run: | echo ${{ secrets.MACOS_CERTIFICATE }} | base64 --decode > certificate.p12 security create-keychain -p action build.keychain security default-keychain -s build.keychain security unlock-keychain -p action build.keychain security import certificate.p12 -k build.keychain -P ${{ secrets.MACOS_CERTIFICATE_PWD }} -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k action build.keychain /usr/bin/codesign --force --options runtime --deep --sign "${{ secrets.MACOS_CODESIGN_ID }}" "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" -v - name: Create macOS dmg run: | brew install create-dmg create-dmg \ --volname ${{ env.MACOS_DMG_NAME }} \ --hide-extension "AppFlowy.app" \ --background frontend/scripts/dmg_assets/AppFlowyInstallerBackground.jpg \ --window-size 600 450 \ --icon-size 94 \ --icon "AppFlowy.app" 141 249 \ --app-drop-link 458 249 \ "${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg" \ "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" - name: Notarize AppFlowy run: | xcrun notarytool submit ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg --apple-id ${{ secrets.MACOS_NOTARY_USER }} --team-id ${{ secrets.MACOS_TEAM_ID }} --password ${{ secrets.MACOS_NOTARY_PWD }} -v -f "json" --wait - name: Archive Asset working-directory: ${{ env.MACOS_APP_RELEASE_PATH }} run: zip --symlinks -qr ${{ env.MACOS_AARCH64_ZIP_NAME }} AppFlowy.app - name: Upload Asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_AARCH64_ZIP_NAME }} asset_name: ${{ env.MACOS_AARCH64_ZIP_NAME }} asset_content_type: application/octet-stream - name: Upload DMG Asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg asset_name: ${{ env.MACOS_DMG_NAME }}.dmg asset_content_type: application/octet-stream build-for-linux: name: ${{ matrix.job.target }} (${{ matrix.job.os }}) [${{ matrix.job.extra-build-args }}] runs-on: ${{ matrix.job.os }} needs: create-release env: LINUX_APP_RELEASE_PATH: frontend/appflowy_flutter/product/${{ github.ref_name }}/linux/Release LINUX_ZIP_NAME: AppFlowy-${{ matrix.job.target }}-x86_64.tar.gz LINUX_PACKAGE_DEB_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.deb LINUX_PACKAGE_RPM_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.rpm LINUX_PACKAGE_TMP_RPM_NAME: AppFlowy-${{ github.ref_name }}-2.x86_64.rpm LINUX_PACKAGE_TMP_APPIMAGE_NAME: AppFlowy-${{ github.ref_name }}-x86_64.AppImage LINUX_PACKAGE_APPIMAGE_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.AppImage LINUX_PACKAGE_ZIP_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.tar.gz strategy: fail-fast: false matrix: job: - { arch: x86_64, target: x86_64-unknown-linux-gnu, os: ubuntu-22.04, extra-build-args: "", flutter_profile: production-linux-x86_64, } steps: - name: Checkout source code uses: actions/checkout@v4 - name: Install flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} target: ${{ matrix.job.target }} override: true components: rustfmt profile: minimal - name: Install prerequisites working-directory: frontend run: | sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo apt-get update sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev libcurl4-openssl-dev sudo apt-get install keybinder-3.0 sudo apt-get install -y alien libnotify-dev sudo apt install libfuse2 source $HOME/.cargo/env cargo install --force --locked cargo-make cargo install --force --locked duckscript_cli rustup target add ${{ matrix.job.target }} - name: Install gcc-aarch64-linux-gnu if: ${{ matrix.job.target == 'aarch64-unknown-linux-gnu' }} working-directory: frontend run: | sudo apt-get install -qy binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libgtk-3-0 - name: Build AppFlowy working-directory: frontend run: | flutter config --enable-linux-desktop dart ./scripts/flutter_release_build/build_flowy.dart run . ${{ github.ref_name }} - name: Archive Asset working-directory: ${{ env.LINUX_APP_RELEASE_PATH }} run: tar -czf ${{ env.LINUX_ZIP_NAME }} * - name: Build Linux package (.deb) working-directory: frontend run: | sh scripts/linux_distribution/deb/build_deb.sh appflowy_flutter/product/${{ github.ref_name }}/linux/Release ${{ github.ref_name }} ${{ env.LINUX_PACKAGE_DEB_NAME }} - name: Build Linux package (.rpm) working-directory: ${{ env.LINUX_APP_RELEASE_PATH }} run: | sudo alien -r ${{ env.LINUX_PACKAGE_DEB_NAME }} cp -r ${{ env.LINUX_PACKAGE_TMP_RPM_NAME }} ${{ env.LINUX_PACKAGE_RPM_NAME }} - name: Build Linux package (.AppImage) working-directory: frontend continue-on-error: true run: | sh scripts/linux_distribution/appimage/build_appimage.sh ${{ github.ref_name }} cd .. cp -r frontend/${{ env.LINUX_PACKAGE_TMP_APPIMAGE_NAME }} ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_APPIMAGE_NAME }} - name: Upload Asset id: upload-release-asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_ZIP_NAME }} asset_name: ${{ env.LINUX_PACKAGE_ZIP_NAME }} asset_content_type: application/octet-stream - name: Upload Debian package id: upload-release-asset-install-package-deb uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_DEB_NAME }} asset_name: ${{ env.LINUX_PACKAGE_DEB_NAME }} asset_content_type: application/octet-stream - name: Upload RPM package id: upload-release-asset-install-package-rpm uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_RPM_NAME }} asset_name: ${{ env.LINUX_PACKAGE_RPM_NAME }} asset_content_type: application/octet-stream - name: Upload AppImage package id: upload-release-asset-install-package-appimage uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_APPIMAGE_NAME }} asset_name: ${{ env.LINUX_PACKAGE_APPIMAGE_NAME }} asset_content_type: application/octet-stream build-for-docker: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . file: ./frontend/scripts/docker-buildfiles/Dockerfile builder: ${{ steps.buildx.outputs.name }} push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_client:${{ github.ref_name }} cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache,mode=max notify-failure: runs-on: ubuntu-latest needs: - build-for-macOS-x86_64 - build-for-windows - build-for-linux if: failure() steps: - uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} text: | 🔴🔴🔴Workflow ${{ github.workflow }} in repository ${{ github.repository }} was failed 🔴🔴🔴. fields: repo,message,author,eventName,ref,workflow env: SLACK_WEBHOOK_URL: ${{ secrets.RELEASE_SLACK_WEBHOOK }} if: always() notify-discord: runs-on: ubuntu-latest needs: [ build-for-linux, build-for-windows, build-for-macOS-x86_64, build-for-macOS-universal, ] steps: - name: Notify Discord run: | curl -H "Content-Type: application/json" -d '{"username": "release@appflowy", "content": "🎉 AppFlowy ${{ github.ref_name }} is available. https://github.com/AppFlowy-IO/AppFlowy/releases/tag/'${{ github.ref_name }}'"}' "https://discord.com/api/webhooks/${{ secrets.DISCORD }}" shell: bash ================================================ FILE: .github/workflows/rust_ci.yaml ================================================ name: Rust-CI on: push: branches: - "main" - "develop" - "release/*" paths: - "frontend/rust-lib/**" - ".github/workflows/rust_ci.yaml" pull_request: branches: - "main" - "develop" - "release/*" env: CARGO_TERM_COLOR: always CLOUD_VERSION: 0.9.49-amd64 RUST_TOOLCHAIN: "1.85.0" jobs: ubuntu-job: runs-on: ubuntu-latest steps: - name: Set timezone for action uses: szenius/set-timezone@v2.0 with: timezoneLinux: "US/Pacific" - name: Maximize build space run: | sudo rm -rf /usr/share/dotnet sudo rm -rf /opt/ghc sudo rm -rf "/usr/local/share/boost" sudo rm -rf "$AGENT_TOOLSDIRECTORY" sudo docker image prune --all --force - name: Checkout source code uses: actions/checkout@v4 - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} override: true components: rustfmt, clippy profile: minimal - uses: Swatinem/rust-cache@v2 with: prefix-key: ${{ runner.os }} cache-on-failure: true workspaces: | frontend/rust-lib - name: Checkout appflowy cloud code uses: actions/checkout@v4 with: repository: AppFlowy-IO/AppFlowy-Cloud path: AppFlowy-Cloud - name: Prepare appflowy cloud env working-directory: AppFlowy-Cloud run: | cp deploy.env .env sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env - name: Ensure AppFlowy-Cloud is Running with Correct Version working-directory: AppFlowy-Cloud env: APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }} APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }} run: | # Remove all containers if any exist if [ "$(docker ps -aq)" ]; then docker rm -f $(docker ps -aq) else echo "No containers to remove." fi # Remove all volumes if any exist if [ "$(docker volume ls -q)" ]; then docker volume rm $(docker volume ls -q) else echo "No volumes to remove." fi docker compose pull docker compose up -d echo "Waiting for the container to be ready..." sleep 10 docker ps -a docker compose logs - name: Run rust-lib tests working-directory: frontend/rust-lib env: RUST_LOG: info RUST_BACKTRACE: 1 af_cloud_test_base_url: http://localhost af_cloud_test_ws_url: ws://localhost/ws/v1 af_cloud_test_gotrue_url: http://localhost/gotrue run: | DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart" -- --skip local_ollama_test - name: rustfmt rust-lib run: cargo fmt --all -- --check working-directory: frontend/rust-lib/ - name: clippy rust-lib run: cargo clippy --all-targets -- -D warnings working-directory: frontend/rust-lib - name: "Debug: show Appflowy-Cloud container logs" if: failure() working-directory: AppFlowy-Cloud run: | docker compose logs appflowy_cloud - name: Clean up Docker images run: | docker image prune -af docker volume prune -f ================================================ FILE: .github/workflows/rust_coverage.yml ================================================ name: Rust code coverage on: push: branches: - "main" - "release/*" paths: - "frontend/rust-lib/**" env: CARGO_TERM_COLOR: always FLUTTER_VERSION: "3.27.4" RUST_TOOLCHAIN: "1.85.0" jobs: tests: runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Install Rust toolchain id: rust_toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ env.RUST_TOOLCHAIN }} target: ${{ matrix.job.target }} override: true profile: minimal - name: Install flutter id: flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - name: Install prerequisites working-directory: frontend run: | cargo install --force --locked cargo-make cargo install --force --locked duckscript_cli - uses: Swatinem/rust-cache@v2 with: prefix-key: ${{ matrix.job.os }} - name: Install code-coverage tools working-directory: frontend run: | sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo apt-get update sudo apt-get install keybinder-3.0 cargo install grcov rustup component add llvm-tools-preview - name: Run tests working-directory: frontend run: cargo make rust_unit_test_with_coverage ================================================ FILE: .github/workflows/translation_notify.yml ================================================ name: Translation Notify on: push: branches: [ main ] paths: - "frontend/appflowy_flutter/assets/translations/en.json" jobs: Discord-Notify: runs-on: ubuntu-latest steps: - uses: Ilshidur/action-discord@master env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: args: | @appflowytranslators English UI strings has been updated. Link to changes: ${{github.event.compare}} ================================================ FILE: .gitignore ================================================ # Generated by Cargo # will have compiled files and executables # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html # backend /target/ # These are backup files generated by rustfmt **/*.rs.bk **/target/ **/*.db .idea/ **/temp/** .ruby-version package-lock.json yarn.lock node_modules **/.proto_cache **/.cache **/.DS_Store **/resources/proto # ignore settings.json frontend/.vscode/settings.json # Commit the highest level pubspec.lock, but ignore the others pubspec.lock !frontend/appflowy_flutter/pubspec.lock # ignore tool used for commit linting .githooks/gitlint .githooks/gitlint.exe .fvm/ **/AppFlowy-Collab/ # ignore generated assets frontend/package frontend/*.deb **/Cargo.toml.bak **/.cargo/** ================================================ FILE: CHANGELOG.md ================================================ # Release Notes ## Version 0.9.9 – 11/09/2025 ### Desktop #### New Features - Workspace user profile: customize your avatar, about me, and profile card banner ### Mobile #### New Features - Workspace user profile: customize your avatar, about me, and profile card banner - Two new Android widgets: Recent Pages and Favorites - A new iOS widget: Quick Page Access - iOS Share to AppFlowy: quickly save browser page links with optional notes to a target AppFlowy page ## Version 0.9.8 – 28/08/2025 ### Desktop #### New Features - Bulk add collaborators with Can view / Can edit permissions via the Share menu - Enable setting the start of the week to Monday to control default calendar layouts in the calendar view and date picker - Sync date and time formats, language, and week start day across devices under the same user account - Offer 10 premium Select-option colors to Pro Plan users - Support managing pending members via Settings → Members ### Mobile #### New Features - Support iOS widgets for quick access to recent and favorite pages - Offer 10 premium Select option colors to Pro Plan users - Support managing pending members via Settings → Members ## Version 0.9.7 – 13/08/2025 ### Desktop #### New Features - Mention or assign persons in documents via '@' or '/' - Mentioned persons get notified about the mention via email - More colors are available for database Select options and page covers - Back up your AppFlowy workspace: Export your workspace as a ZIP file and import it back at any time - GPT-5 is now available in AppFlowy ### Mobile #### New Features - Mention or assign persons in documents via '@' or '/' - Mentioned persons get notified about the mention via email - More colors are available for database Select options and page cover #### Bug Fixes - Fixed login page UI overflow issues on small screen devices ## Version 0.9.6 – 05/08/2025 ### Bug Fixes - Fixed some syncing issues - Improved the Share menu - Fixed some UI issues related to database filters and sorts ## Version 0.9.5 – 17/07/2025 ### Desktop #### New Features - Vault Workspace: A new workspace type, private and offline. AI runs locally with no data transfer. Supports switching embedding models, chatting with files (PDF, Markdown, TXT). Includes RAG search with AI-generated overviews. - Revamped color pickers in documents: Expanded palette with support for custom colors ### Mobile #### New Features - iOS In‑App Sign‑In: Sign in directly within the iOS app - New colors: Improved text and background color options Bug Fixes - Add a network connection indicator - Fix sync bugs and issues with WebSocket connections. ## Version 0.9.4 – 02/07/2025 ### Desktop #### New Features - Private page sharing: Add members to private pages with Can View or Can Edit permissions - Guest editor collaboration: Invite non-members (guest editors) to collaborate in real-time on your pages - Shared with me: Browse all pages shared with you under the new Shared with me section - New syncing protocol: Optimized for faster, more reliable multi-user and multi-device data sync ### Mobile #### New Features - Shared page collaboration: View and edit pages that have been shared with you on iOS and Android - New syncing protocol: Optimized for faster, more reliable multi-user and multi-device data sync ## Version 0.9.3 - 28/05/2025 ### Desktop #### New Features - Meet AppFlowy Workspace AI Search: Quickly find pages by searching titles, keywords, or asking natural-language questions - AI Overviews: Ask natural questions and receive instant AI-generated summaries with source links, inspired by Google's AI Overviews - Revamped Search Panel: A cleaner, smarter interface to help you search faster and more effectively - Custom Prompts: Load a database page as the source for your own custom AI prompts #### Bug Fixes - Fixed misalignment in database view after setting maxDocumentWidth - Centered embedded link when the site name is empty - Fixed issue where row observer was not clearing as expected - Fixed issue where workspace name reverted after being updated - Aligned checkbox icon with the first line of text ### Mobile #### New Features - Meet AppFlowy Workspace AI Search: Quickly find pages by searching titles, keywords, or asking natural-language questions - AI Overviews: Ask natural questions and receive instant AI-generated summaries with source links, inspired by Google's AI Overviews - Revamped Search Tab: A redesigned interface that helps you find what you need more efficiently #### Bug Fixes - Fixed issue where font size reset after restarting the app ## Version 0.9.2 - 14/05/2025 ### Desktop #### New Features - Supported AI Overview in Search to answer user queries based on their entire workspace - Revamped the Search panel in the desktop app - Enabled loading custom prompts from an AppFlowy database page #### Bug Fixes - Improved inserting emojis using the colon (:) - Supported automatically filling the link name with the URL if the name is left empty ### Mobile #### Bug Fixes - Supported automatically filling the link name with the URL if the name is left empty ## Version 0.9.1 - 01/05/2025 ### Desktop #### New Features - Added AppFlowy Prompt Library to AI Chat and Document's Ask AI - Revamped the desktop in-app notification center - Supported login with password, as well as forgot and change password options - Supported copying link to invite members - Improved the Settings' Members tab with new metadata: member avatar and joined time #### Bug Fixes - Fixed data loss when using anonymous local - Fixed crash when trying to delete an emoji - Fixed Windows scaling issue - Correctly displayed mention text by decoding web content ### Mobile #### New Features - Supported workspace search - Improved UX for links in documents - Supported changing password in Mobile Settings - Added support for inviting members via links #### Bug Fixes - Correctly displayed mention text by decoding web content ## Version 0.9.0 - 30/04/2025 ### Desktop #### New Features - Added AppFlowy Prompt Library to AI Chat and Document's Ask AI - Revamped the desktop in-app notification center - Supported login with password, as well as forgot and change password options - Supported copying link to invite members - Improved the Settings' Members tab with new metadata: member avatar and joined time #### Bug Fixes - Fixed crash when trying to delete an emoji - Fixed Windows scaling issue - Correctly displayed mention text by decoding web content ### Mobile #### New Features - Supported workspace search - Improved UX for links in documents - Supported changing password in Mobile Settings - Added support for inviting members via links #### Bug Fixes - Correctly displayed mention text by decoding web content ## Version 0.8.9 - 16/04/2025 ### Desktop #### New Features - Supported pasting a link as a mention, providing a more condensed visualization of linked content - Supported converting between link formats (e.g. transforming a mention into a bookmark) - Improved the link editing experience with enhanced UX - Added OTP (One-Time Password) support for sign-in authentication - Added latest AI models: GPT-4.1, GPT-4.1-mini, and Claude 3.7 Sonnet #### Bug Fixes - Fixed an issue where properties were not displaying in the row detail page - Fixed a bug where Undo didn't work in the row detail page - Fixed an issue where blocks didn't grow when the grid got bigger - Fixed several bugs related to AI writers ### Mobile #### New Features - Added sign-in with OTP (One-Time Password) #### Bug Fixes - Fixed an issue where the slash menu sometimes failed to display - Updated the mention page block to handle page selection with more context. ## Version 0.8.8 - 01/04/2025 ### New Features - Added support for selecting AI models in AI writer - Revamped link menu in toolbar - Added support for using ":" to add emojis in documents - Passed the history of past AI prompts and responses to AI writer ### Bug Fixes - Improved AI writer scrolling user experience - Fixed issue where checklist items would disappear during reordering - Fixed numbered lists generated by AI to maintain the same index as the input ## Version 0.8.7 - 18/03/2025 ### New Features - Made local AI free and integrated with Ollama - Supported nested lists within callout and quote blocks - Revamped the document's floating toolbar and added Turn Into - Enabled custom icons in callout blocks ### Bug Fixes - Fixed occasional incorrect positioning of the slash menu - Improved AI Chat and AI Writers with various bug fixes - Adjusted the columns block to match the width of the editor - Fixed a potential segfault caused by infinite recursion in the trash view - Resolved an issue where the first added cover might be invisible - Fixed adding cover images via Unsplash ## Version 0.8.6 - 06/03/2025 ### Bug Fixes - Fix the incorrect title positioning when adjusting the document width setting - Enhance the user experience of the icon color picker for smoother interactions - Add missing icons to the database to ensure completeness and consistency - Resolve the issue with links not functioning correctly on Linux systems - Improve the outline feature to work seamlessly within columns - Center the bulleted list icon within columns for better visual alignment - Enable dragging blocks under tables in the second column to enhance flexibility - Disable the AI writer feature within tables to prevent conflicts and improve usability - Automatically enable the header row when converting content from Markdown to ensure proper formatting - Use the "Undo" function to revert the auto-formatting ## Version 0.8.5 - 04/03/2025 ### New Features - Columns in Documents: Arrange content side by side using drag-and-drop or the slash menu - AI Writers: New AI assistants in documents with response formatting options (list, table, text with images, image-only), follow-up questions, contextual memory, and more - Compact Mode for Databases: Enable compact mode for grid and kanban views (full-page and inline) to increase information density, displaying more data per screen ### Bug Fixes - Fixed an issue where callout blocks couldn’t be deleted when appearing as the first line in a document - Fixed a bug preventing the relation field in databases from opening - Fixed an issue where links in documents were unclickable on Linux ## Version 0.8.4 - 18/02/2025 ### New Features - Switch AI mode on mobile - Support locking page - Support uploading svg file as icon - Support the slash, at, and plus menus on mobile ### Bug Fixes - Gallery not rendering in row page - Save image should not copy the image (mobile) - Support exporting more content to markdown ## Version 0.8.2 - 23/01/2025 ### New Features - Customized database view icons - Support for uploading images as custom icons - Enabled selecting multiple AI messages to save into a document - Added the ability to scale the app's display size on mobile - Support for pasting image links without file extensions ### Bug Fixes - Fixed an issue where pasting tables from other apps wasn't working - Fixed homepage URL issues in Settings - Fixed an issue where the 'Cancel' button was not visible on the Shortcuts page ## Version 0.8.1 - 14/01/2025 ### New Features - AI Chat Layout Options: Customize how AI responses appear with new layouts—List, Table, Image with Text, and Media Only - DALL-E Integration: Generate stunning AI images from text prompts, now available in AI Chat - Improved Desktop Search: Find what you need faster using keywords or by asking questions in natural language - Self-Hosting: Configure web server URLs directly in Settings to enable features like Publish, Copy Link to Share, Custom URLs, and more - Sidebar Enhancement: Drag to reorder your favorited pages in the Sidebar - Mobile Table Resizing: Adjust column widths in Simple Tables by long pressing the column borders on mobile ### Bug Fixes - Resolved an icon rendering issue in callout blocks, tab bars, and search results - Enhanced image reliability: Retry functionality ensures images load successfully if the first attempt fails ## Version 0.8.0 - 06/01/2025 ### Bug Fixes - Fixed error displaying in the page style menu - Fixed filter logic in the icon picker - Fixed error displaying in the Favorite/Recent page - Fixed the color picker displaying when tapping down - Fixed icons not being supported in subpage blocks - Fixed recent icon functionality in the space icon menu - Fixed "Insert Below" not auto-scrolling the table - Fixed a to-do item with an emoji automatically creating a soft break - Fixed header row/column tap areas being too small - Fixed simple table alignment not working for items that wrap - Fixed web content reverting after removing the inline code format on desktop - Fixed inability to make changes to a row or column in the table when opening a new tab - Fixed changing the language to CKB-KU causing a gray screen on mobile ## Version 0.7.9 - 30/12/2024 ### New Features - Meet AppFlowy Web (Lite): Use AppFlowy directly in your browser. - Create beautiful documents with 22 content types and markdown support - Use Quick Note to save anything you want to remember—like meeting notes, a grocery list, or to-dos - Invite members to your workspace for seamless collaboration - Create multiple public/private spaces to better organize your content - Simple Table is now available on Mobile, designed specifically for mobile devices. - Create and manage Simple Table blocks on Mobile with easy-to-use action menus. - Use the '+' button in the fixed toolbar to easily add a content block into a table cell on Mobile - Use '/' to insert a content block into a table cell on Desktop - Add pages as AI sources in AI chat, enabling you to ask questions about the selected sources - Add messages to an editable document while chatting with AI side by side - The new Emoji menu now includes Icons with a Recent section for quickly reusing emojis/icons - Drag a page from the sidebar into a document to easily mention the page without typing its title - Paste as plain text, a new option in the right-click paste menu ### Bug Fixes - Fixed misalignment in numbered lists - Resolved several bugs in the emoji menu - Fixed a bug with checklist items ## Version 0.7.8 - 18/12/2024 ### New Features image - Meet Simple Table 2.0: - Insert a list into a table cell - Insert images, quotes, callouts, and code blocks into a table cell - Drag to move rows or columns - Toggle header rows or columns on/off - Distribute columns evenly - Adjust to page width - Enjoy a new UI/UX for a seamless experience - Revamped mention page interactions in AI Chat - Improved AppFlowy AI service ### Bug Fixes - Fixed an error when opening files in the database in local mode - Fixed arrow up/down navigation not working for selecting a language in Code Block - Fixed an issue where deleting multiple blocks using the drag button on the document page didn’t work ## Version 0.7.7 - 09/12/2024 ### Bug Fixes - Fixed sidebar menu resize regression - Fixed AI chat loading issues - Fixed inability to open local files in database - Fixed mentions remaining in notifications after removal from document - Fixed event card closing when clicking on empty space - Fixed keyboard shortcut issues ## Version 0.7.6 - 03/12/2024 ### New Features - Revamped the simple table UI - Added support for capturing images from camera on mobile ### Bug Fixes - Improved markdown rendering capabilities in AI writer - Fixed an issue where pressing Enter on a collapsed toggle list would add an unnecessary new line - Fixed an issue where creating a document from slash menu could insert content at incorrect position ## Version 0.7.5 - 25/11/2024 ### Bug Fixes - Improved chat response parsing - Fixed toggle list icon direction for RTL mode - Fixed cross blocks formatting not reflecting in float toolbar - Fixed unable to click inside the toggle list to create a new paragraph - Fixed open file error 50 on macOS - Fixed upload file exceed limit error ## Version 0.7.4 - 19/11/2024 ### New Features - Support uploading WebP and BMP images - Support managing workspaces on mobile - Support adding toggle headings on mobile - Improve the AI chat page UI ### Bug Fixes - Optimized the workspace menu loading performance - Optimized tab switching performance - Fixed searching issues in Document page ## Version 0.7.3 - 07/11/2024 ### New Features - Enable custom URLs for published pages - Support toggling headings - Create a subpage by typing in the document - Turn selected blocks into a subpage - Add a manual date picker for the Date property ### Bug Fixes - Fixed an issue where the workspace owner was unable to delete spaces created by others - Fixed cursor height inconsistencies with text height - Fixed editing issues in Kanban cards - Fixed an issue preventing images or files from being dropped into empty paragraphs ## Version 0.7.2 - 22/10/2024 ### New Features - Copy link to block - Support turn into in document - Enable sharing links and publishing pages on mobile - Enable drag and drop in row documents - Right-click on page in sidebar to open more actions - Create new subpage in document using `+` character - Allow reordering checklist item ### Bug Fixes - Fixed issue with inability to cancel inline code format in French IME - Fixed delete with Shift or Ctrl shortcuts not working in documents - Fixed the issues with incorrect time zone being used in filters. ## Version 0.7.1 - 07/10/2024 ### New Features - Copy link to share and open it in a browser - Enable the ability to edit the page title within the body of the document - Filter by last modified, created at, or a date range - Allow customization of database property icons - Support CTRL/CMD+X to delete the current line when the selection is collapsed in the document - Support window tiling on macOS - Add filters to grid views on mobile - Create and manage workspaces on mobile - Automatically convert property types for imported CSV files ### Bug Fixes - Fixed calculations with filters applied - Fixed issues with importing data folders into a cloud account - Fixed French IME backtick issues - Fixed selection gesture bugs on mobile ## Version 0.7.0 - 19/09/2024 ### New Features - Support reordering blocks in document with drag and drop - Support for adding a cover to a row/card in databases - Added support for accessing settings on the sign-in page - Added "Move to" option to the document menu in top right corner - Support for adjusting the document width from settings - Show full name of a group on hover - Colored group names in kanban boards - Support "Ask AI" on multiple lines of text - Support for keyboard gestures to move cursor on Mobile - Added markdown support for quickly inserting a code block using three backticks ### Bug Fixes - Fixed a critical bug where the backtick character would crash the application - Fixed an issue with signing-in from the settings dialog where the dialog would persist - Fixed a visual bug with icon alignment in primary cell of database rows - Fixed a bug with filters applied where new rows were inserted in wrong position - Fixed a bug where "Untitled" would override the name of the row - Fixed page title not updating after renaming from "More"-menu - Fixed File block breaking row detail document - Fixed issues with reordering rows with sorting rules applied - Improvements to the File & Media type in Database - Performance improvement in Grid view - Fixed filters sometimes not applying properly in databases ## Version 0.6.9 - 09/09/2024 ### New Features - Added a new property type, 'Files & media' - Supported Apple Sign-in - Displayed the page icon next to the row name when the row page contains nested notes - Enabled Delete Account in Settings - Included a collapsible navigation menu in your published site ### Bug Fixes - Fixed the space name color issue in the community themes - Fixed database filters and sorting issues - Fixed the issue of not being able to fully display the title on Kanban cards - Fixed the inability to see the entire text of a checklist item when it's more than one line long - Fixed hide/unhide buttons in the No Status group - Fixed the inability to edit group names on Kanban boards - Made error codes more user-friendly - Added leading zeros to day and month in date format ## Version 0.6.8 - 22/08/2024 ### New Features - Enabled viewing data inside a database record on mobile. - Added the ability to invite members to a workspace on mobile. - Introduced Ask AI in the Home tab on mobile. - Import CSV files with up to 1,000 rows. - Convert properties from one type to another while preserving the data. - Optimized the speed of opening documents and databases. - Improved syncing performance across devices. - Added support for a monochrome app icon on Android. ### Bug Fixes - Removed the Wayland header from the AppImage build. - Fixed the issue where pasting a web image on mobile failed. - Corrected the Local AI state when switching between different workspaces. - Fixed high CPU usage when opening large databases. ## Version 0.6.7 - 13/08/2024 ### New Features - Redesigned the icon picker design on Desktop. - Redesigned the notification page on Mobile. ### Bug Fixes - Enhance the toolbar tooltip functionality on Desktop. - Enhance the slash menu user experience on Desktop. - Fixed the issue where list style overrides occurred during text pasting. - Fixed the issue where linking multiple databases in the same document could cause random loss of focus. ## Version 0.6.6 - 30/07/2024 ### New Features - Upgrade your workspace to a premium plan to unlock more features and storage. - Image galleries and drag-and-drop image support in documents. ### Bug Fixes - Fix minor UI issues on Desktop and Mobile. ## Version 0.6.5 - 24/07/2024 ### New Features - Publish a Database to the Web ## Version 0.6.4 - 16/07/2024 ### New Features - Enhanced the message style on the AI chat page. - Added the ability to choose cursor color and selection color from a palette in settings page. ### Bug Fixes - Optimized the performance for loading recent pages. - Fixed an issue where the cursor would jump randomly when typing in the document title on mobile. ## Version 0.6.3 - 08/07/2024 ### New Features - Publish a Document to the Web ## Version 0.6.2 - 01/07/2024 ### New Features - Added support for duplicating spaces. - Added support for moving pages across spaces. - Undo markdown formatting with `Ctrl + Z` or `Cmd + Z`. - Improved shortcuts settings UI. ### Bug Fixes - Fixed unable to zoom in with `Ctrl` and `+` or `Cmd` and `+` on some keyboards. - Fixed unable to paste nested lists in existing lists. ## Version 0.6.1 - 22/06/2024 ### New Features - Introduced the "Space" feature to help you organize your pages more efficiently. ### Bug Fixes - Resolved shortcut conflicts on the board page. - Resolved an issue where underscores could cause the editor to freeze. ## Version 0.6.0 - 19/06/2024 ### New Features - Introduced the "Space" feature to help you organize your pages more efficiently. ### Bug Fixes - Resolved shortcut conflicts on the board page. - Resolved an issue where underscores could cause the editor to freeze. ## Version 0.5.9 - 06/06/2024 ### New Features - Revamped the sidebar for both Desktop and Mobile. - Added support for embedding videos in documents. - Introduced a hotkey (Cmd/Ctrl + 0) to reset the app scale. - Supported searching the workspace by page title. ### Bug Fixes - Fixed the issue preventing the use of Backspace to delete words in Kanban boards. ## Version 0.5.8 - 05/20/2024 ### New Features - Improvement to the Callout block to insert new lines - New settings page "Manage data" replaced the "Files" page - New settings page "Workspace" replaced the "Appearance" and "Language" pages - A custom implementation of a title bar for Windows users - Added support for selecting Cards in kanban and performing grouped keyboard shortcuts - Added support for default system font family - Support for scaling the application up/down using a keyboard shortcut (CMD/CTRL + PLUS/MINUS) ### Bug Fixes - Resolved and refined the UI on Mobile - Resolved issue with text editing in database - Improved appearance of empty text cells in kanban/calendar - Resolved an issue where a page's more actions (delete, duplicate) did not work properly - Resolved and inconsistency in padding on get started screen on Desktop ## Version 0.5.7 - 05/10/2024 ### Bug Fixes - Resolved page opening issue on Android. - Fixed text input inconsistency on Kanban board cards. ## Version 0.5.6 - 05/07/2024 ### New Features - Team collaboration is live! Add members to your workspace to edit and collaborate on pages together. - Collaborate in real time on the same page with other members. Edits made by others will appear instantly. - Create multiple workspaces for different kinds of content. - Customize your entire page on mobile through the Page Style menu with options for layout, font, font size, emoji, and cover image. - Open a row record as a full page. ### Bug Fixes - Resolved issue with setting background color for the Simple Table block. - Adjusted toolbar for various screen sizes. - Added a request for photo permission before uploading images on mobile. - Exported creation and last modification timestamps to CSV. ## Version 0.5.5 - 04/24/2024 ### New Features - Improved the display of code blocks with line numbers - Added support for signing in using Magic Link ### Bug Fixes - Fixed the database synchronization indicator issue - Resolved the issue with opening the mentioned page on mobile - Cleared the collaboration status when the user exits AppFlowy ## Version 0.5.4 - 04/08/2024 ### New Features - Introduced support for displaying a synchronization indicator within documents and databases to enhance user awareness of data sync status - Revamped the select option cell editor in database - Improved translations for Spanish, German, Kurdish, and Vietnamese - Supported Android 6 and newer versions ### Bug Fixes - Resolved an issue where twelve-hour time formats were not being parsed correctly in databases - Fixed a bug affecting the user interface of the single select option filter - Fixed various minor UI issues ## Version 0.5.3 - 03/21/2024 ### New Features - Added build support for 32-bit Android devices - Introduced filters for KanBan boards for enhanced organization - Introduced the new "Relations" column type in Grids - Expanded language support with the addition of Greek - Enhanced toolbar design for Mobile devices - Introduced a command palette feature with initial support for page search ### Bug Fixes - Rectified the issue of incomplete row data in Grids when adding new rows with active filters - Enhanced the logic governing the filtering of number and select/multi-select fields for improved accuracy - Implemented UI refinements on both Desktop and Mobile platforms, enriching the overall user experience of AppFlowy ## Version 0.5.2 - 03/13/2024 ### Bug Fixes - Import csv file. ## Version 0.5.1 - 03/11/2024 ### New Features - Introduced support for performing generic calculations on databases. - Implemented functionality for easily duplicating calendar events. - Added the ability to duplicate fields with cell data, facilitating smoother data management. - Now supports customizing font styles and colors prior to typing. - Enhanced the checklist user experience with the integration of keyboard shortcuts. - Improved the dark mode experience on mobile devices. ### Bug Fixes - Fixed an issue with some pages failing to sync properly. - Fixed an issue where links without the http(s) scheme could not be opened, ensuring consistent link functionality. - Fixed an issue that prevented numbers from being inserted before heading blocks. - Fixed the inline page reference update mechanism to accurately reflect workspace changes. - Fixed an issue that made it difficult to resize images in certain cases. - Enhanced image loading reliability by clearing the image cache when images fail to load. - Resolved a problem preventing the launching of URLs on some Linux distributions. ## Version 0.5.0 - 02/26/2024 ### New Features - Added support for scaling text on mobile platforms for better readability. - Introduced a toggle for favorites directly from the documents' top bar. - Optimized the image upload process and added error messaging for failed uploads. - Implemented depth control for outline block components. - New checklist task creation is now more intuitive, with prompts appearing on hover over list items in the row detail page. - Enhanced sorting capabilities, allowing reordering and addition of multiple sorts. - Expanded sorting and filtering options to include more field types like checklist, creation time, and modification time. - Added support for field calculations within databases. ### Bug Fixes - Fixed an issue where inserting an image from Unsplash in local mode was not possible. - Fixed undo/redo functionality in lists. - Fixed data loss issues when converting between block types. - Fixed a bug where newly created rows were not being automatically sorted. - Fixed issues related to deleting a sorting field or sort not removing existing sorts properly. ### Notes - Windows 7, Windows 8, and iOS 11 are not yet supported due to the upgrade to Flutter 3.22.0. ## Version 0.4.9 - 02/17/2024 ### Bug Fixes - Resolved the issue that caused users to be redirected to the Sign In page ## Version 0.4.8 - 02/13/2024 ### Bug Fixes - Fixed a possible error when loading workspaces ## Version 0.4.6 - 02/03/2024 ### Bug Fixes - Fixed refresh token bug ## Version 0.4.5 - 02/01/2024 ### Bug Fixes - Fixed WebSocket connection issue ## Version 0.4.4 - 01/31/2024 ### New Features - Added functionality for uploading images to cloud storage. - Enabled anonymous sign-in option for mobile platform users. - Introduced the ability to customize cloud settings directly from the startup page. - Added support for inserting reminders on the mobile platform. - Overhauled the user interface on mobile devices, including improvements to the action bottom sheet, editor toolbar, database details page, and app bar. - Implemented a shortcut (F2 key) to rename the current view. ### Bug Fixes - Fixed an issue where the font family was not displaying correctly on the mobile platform. - Resolved a problem with the mobile row detail title not updating correctly. - Fixed issues related to deleting images and refactored the image actions menu for better usability. - Fixed other known issues. # Release Notes ## Version 0.4.3 - 01/16/2024 ### Bug Fixes - Fixed file name too long issue ## Version 0.4.2 - 01/15/2024 AppFlowy for Android is available to download on GitHub. If you’ve been using our desktop app, it’s important to read [this guide](https://docs.appflowy.io/docs/guides/sync-desktop-and-mobile) before logging into the mobile app. ### New Features - Enhanced RTL (Right-to-Left) support for mobile platforms. - Optimized selection gesture system on mobile. - Optimized the mobile toolbar menu. - Improved reference menu (‘@’ menu). - Updated privacy policy. - Improved the data import process for AppFlowy by implementing a progress indicator and compressing the data to enhance efficiency. - Enhanced the utilization of local disk space to optimize storage consumption. ### Bug Fixes - Fixed sign-in cancellation issue on mobile. - Resolved keyboard close bug on Android. ## Version 0.4.1 - 01/03/2024 ### Bug fixes - Fix import AppFlowy data folder ## Version 0.4.0 - 12/30/2023 1. Added capability to import data from an AppFlowy data folder. For detailed information, please see [AppFlowy Data Storage Documentation](https://docs.appflowy.io/docs/appflowy/product/data-storage). 2. Enhanced user interface and fixed various bugs. 3. Improved the efficiency of data synchronization in AppFlowy Cloud ## Version 0.3.9.1 - 12/07/2023 ### Bug fixes - Fix potential blank pages that may occur in an empty document ## Version 0.3.9 - 12/07/2023 ### New Features - Support inserting a new field to the left or right of an existing one ### Bug fixes - Fix some emojis are shown in black/white - Fix unable to rename a subpage of subpage ## Version 0.3.8 - 11/13/2023 ### New Features - Support hiding any stack in a board - Support customizing page icons in menu - Display visual hint when card contains notes - Quick action for adding new stack to a board - Support more ways of inserting page references in documents - Shift + click on a checkbox to power toggle its children ### Bug fixes - Improved color of the "Share"-button text - Text overflow issue in Calendar properties - Default font (Roboto) added to application - Placeholder added for the editor inside a Card - Toggle notifications in settings have been fixed - Dialog for linking board/grid/calendar opens in correct position - Quick add Card in Board at top, correctly adds a new Card at the top ## Version 0.3.7 - 10/30/2023 ### New Features - Support showing checklist items inline in row page. - Support inserting date from slash menu. - Support renaming a stack directly by clicking on the stack name. - Show the detailed reminder content in the notification center. - Save card order in Board view. - Allow to hide the ungrouped stack. - Segmented the checklist progress bar. ### Bug fixes - Optimize side panel animation. - Fix calendar with hidden date or title doesn't show options correctly. - Fix the horizontal scroll bar disappears in Grid view. - Improve setting tab UI in Grid view. - Improve theme of the code block. - Fix some UI issues. ## Version 0.3.6 - 10/16/2023 ### New Features - Support setting Markdown styles through keyboard shortcuts. - Added Ukrainian language. - Support auto-hiding sidebar feature, ensuring a streamlined view even when resizing to a smaller window. - Support toggling the notifitcation on/off. - Added Lemonade theme. ### Bug fixes - Improve Vietnamese translations. - Improve reminder feature. - Fix some UI issues. ## Version 0.3.5 - 10/09/2023 ### New Features - Added support for browsing and inserting images from Unsplash. - Revamp and unify the emoji picker throughout AppFlowy. ### Bug fixes - Improve layout of the settings page. - Improve design of the restore page banner. - Improve UX of the reminders. - Other UI fixes. ## Version 0.3.4 - 10/02/2023 ### New Features - Added support for creating a reminder. - Added support for finding and replacing in the document page. - Added support for showing the hidden fields in row detail page. - Adjust the toolbar style in RTL mode. ### Bug fixes - Improve snackbar UI design. - Improve dandelion theme. - Improve id-ID and pl-PL language translations. ## Version 0.3.3 - 09/24/2023 ### New Features - Added an end date field to the time cell in the database. - Added Support for customizing the font family from GoogleFonts in the editor. - Set the uploaded image to cover by default. - Added Support for resetting the user icon on settings page - Add Urdu language translations. ### Bug fixes - Default colors for the blocks except for the callout were not transparent. - Option/Alt + click to add a block above didn't work on the first line. - Unable to paste HTML content containing `` tag. - Unable to select the text from anywhere in the line. - The selection in the editor didn't clear when editing the inline database. - Added a bottom border to new property column in the database. - Set minimum width of 50px for grid fields. ## Version 0.3.2 - 09/18/2023 ### New Features - Improve the performance of the editor, now it is much faster when editing a large document. - Support for reordering the rows of the database on Windows. - Revamp the row detail page of the database. - Revamp the checklist cell editor of the database. ### Bug fixes - Some UI issues ## Version 0.3.1 - 09/04/2023 ### New Features - Improve CJK (Chinese, Japanese, Korean) input method support. - Share a database in CSV format. - Support for aligning the block component with the toolbar. - Support for editing name when creating a new page. - Support for inserting a table in the document page. - Database views allow for independent field visibility toggling. ### Bug fixes - Paste multiple lines in code block. - Some UI issues ## Version 0.3.0 - 08/22/2023 ### New Features - Improve paste features: - Paste HTML content from website. - Paste image from clipboard. - Support Group by Date in Kanban Board. - Notarize the macOS package, which is now verified by Apple. - Add Persian language translations. ### Bug fixes - Some UI issues ## Version 0.2.9 - 08/08/2023 ### New Features - Improve tab and shortcut, click with alt/option to open a page in new tab. - Improve database tab bar UI. ### Bug fixes - Add button and more action button of the favorite section doesn't work. - Fix euro currency number format. - Some UI issues ## Version 0.2.8 - 08/03/2023 ### New Features - Nestable personal folder that supports drag and drop - Support for favorite folders. - Support for sorting by date in Grid view. - Add a duplicate button in the Board context menu. ### Bug fixes - Improve readability in Callout - Some UI issues ## Version 0.2.7 - 07/18/2023 ### New Features - Open page in new tab - Create toggle lists to keep things tidy in your pages - Alt/Option + click to add a text block above ### Bug fixes - Pasting into a Grid property crashed on Windows - Double-click a link to open ## Version 0.2.6 - 07/11/2023 ### New Features - Dynamic load themes - Inline math equation ## Version 0.2.5 - 07/02/2023 ### New Features - Insert local images - Mention a page - Outlines (Table of contents) - Added support for aligning the image by image menu ### Bug fixes - Some UI issues ## Version 0.2.4 - 06/23/2023 ### Bug fixes: - Unable to copy and paste a word - Some UI issues ## Version 0.2.3 - 06/21/2023 ### New Features - Added support for creating multiple database views for existing database ## Version 0.2.2 - 06/15/2023 ### New Features - Added support for embedding a document in the database's row detail page - Added support for inserting an emoji in the database's row detail page ### Other Updates - Added language selector on the welcome page - Added support for importing multiple markdown files all at once ## Version 0.2.1 - 06/11/2023 ### New Features - Added support for creating or referencing a calendar in the document - Added `+` icon in grid's add field ### Other Updates - Added vertical padding for progress bar - Hide url cell accessory when the content is empty ### Bug fixes: - Fixed unable to export markdown - Fixed adding vertical padding for progress bar - Fixed database view didn't update after the database layout changed. ## Version 0.2.0 - 06/08/2023 ### New Features - Improved checklists to support each cell having its own list - Drag and drop calendar events - Switch layouts (calendar, grid, kanban) of a database - New database properties: 'Updated At' and 'Created At' - Enabled hiding properties on the row detail page - Added support for reordering and saving row order in different database views. - Enabled each database view to have its own settings, including filter and sort options - Added support to convert `“` (double quote) into a block quote - Added support to convert `***` (three stars) into a divider - Added support for an 'Add' button to insert a paragraph in a document and display the slash menu - Added support for an 'Option' button to delete, duplicate, and customize block actions ### Other Updates - Added support for importing v0.1.x documents and databases - Added support for database import and export to CSV - Optimized scroll behavior in documents. - Redesigned the launch page ### Bug fixes - Fixed bugs related to numbers - Fixed issues with referenced databases in documents - Fixed menu overflow issues in documents ### Data migration The data format of this version is not compatible with previous versions. Therefore, to migrate your data to the new version, you need to use the export and import functions. Please follow the guide to learn how to export and import your data. #### Export files in v0.1.6 https://github.com/AppFlowy-IO/AppFlowy/assets/11863087/0c89bf2b-cd97-4a7b-b627-59df8d2967d9 #### Import files in v0.2.0 https://github.com/AppFlowy-IO/AppFlowy/assets/11863087/7b392f35-4972-497a-8a7f-f38efced32e2 ## Version 0.1.5 - 11/05/2023 ### Bug Fixes - Fix: calendar dates don't match with weekdays. - Fix: sort numbers in Grid. ## Version 0.1.4 - 04/05/2023 ### New features - Use AppFlowy’s calendar views to plan and manage tasks and deadlines. - Writing can be improved with the help of OpenAI. ## Version 0.1.3 - 24/04/2023 ### New features - Launch the official Dark Mode. - Customize the font color and highlight color by setting a hex color value and an opacity level. ### Bug Fixes - Fix: the slash menu can be triggered by all other keyboards than English. - Fix: convert the single asterisk to italic text and the double asterisks to bold text. ## Version 0.1.2 - 03/28/2023 ### Bug Fixes - Fix: update calendar selected range. - Fix: duplicate view. ## Version 0.1.1 - 03/21/2023 ### New features - AppFlowy brings the power of OpenAI into your AppFlowy pages. Ask AI to write anything for you in AppFlowy. - Support adding a cover image to your page, making your pages beautiful. - More shortcuts become available. Click on '?' at the bottom right to access our shortcut guide. ### Bug Fixes - Fix some bugs ## Version 0.1.0 - 02/09/2023 ### New features - Support linking a Board or Grid into the Document page - Integrate a callout plugin implemented by community members - Optimize user interface ### Bug Fixes - Fix some bugs ## Version 0.0.9.1 - 01/03/2023 ### New features - New theme - Support changing text color of editor - Optimize user interface ### Bug Fixes - Fix some grid bugs ## Version 0.0.9 - 12/21/2022 ### New features - Enable the user to define where to store their data - Support inserting Emojis through the slash command ### Bug Fixes - Fix some bugs ## Version 0.0.8.1 - 12/09/2022 ### New features - Support following your default system theme - Improve the filter in Grid ### Bug Fixes - Copy/Paste ## Version 0.0.8 - 11/30/2022 ### New features - Table-view database - support column type: Checklist - Board-view database - support column type: Checklist - Customize font size: small, medium, large ## Version 0.0.7.1 - 11/30/2022 ### Bug Fixes - Fix some bugs ## Version 0.0.7 - 11/27/2022 ### New features - Support adding filters by the text/checkbox/single-select property in Grid ## Version 0.0.6.2 - 10/30/2022 - Fix some bugs ## Version 0.0.6.1 - 10/26/2022 ### New features - Optimize appflowy_editor dark mode style ### Bug Fixes - Unable to copy the text with checkbox or link style ## Version 0.0.6 - 10/23/2022 ### New features - Integrate **appflowy_editor** ## Version 0.0.5.3 - 09/26/2022 ### New features - Open the next page automatically after deleting the current page - Refresh the Kanban board after altering a property type ### Bug Fixes - Fix switch board bug - Fix delete the Kanban board's row error - Remove duplicate time format - Fix can't delete field in property edit panel - Adjust some display UI issues ## Version 0.0.5.2 - 09/16/2022 ### New features - Enable adding a new card to the "No Status" group - Fix some bugs ### Bug Fixes - Fix cannot open AppFlowy error - Fix delete the Kanban board's row error ## Version 0.0.5.1 - 09/14/2022 ### New features - Enable deleting a field in board - Fix some bugs ## Version 0.0.5 - 09/08/2022 ### New features - Kanban Board like Notion and Trello beta Boards are the best way to manage projects & tasks. Use them to group your databases by select, multiselect, and checkbox.

- Set up columns that represent a specific phase of the project cycle and use cards to represent each project/task - Drag and drop a card from one phase/column to another phase/column - Update database properties in the Board view by clicking on a property and making edits on the card ### Other Features & Improvements - Settings allow users to change avatars - Click and drag the right edge to resize your sidebar - And many user interface improvements (link) ## Version 0.0.5 - beta.2 - beta.1 - 09/01/2022 ### New features - Board-view database - Support start editing after creating a new card - Support editing the card directly by clicking the edit button - Add the `No Status` column to display the cards while their status is empty ### Bug Fixes - Optimize insert card animation - Fix some UI bugs ## Version 0.0.5 - beta.1 - 08/25/2022 ### New features - Board-view database - Group by single select - drag and drop cards - insert / delete cards ![Aug-25-2022 16-22-38](https://user-images.githubusercontent.com/86001920/186614248-23186dfe-410e-427a-8cc6-865b1f79e074.gif) ## Version 0.0.4 - 06/06/2022 - Drag to adjust the width of a column - Upgrade to Flutter 3.0 - Native support for M1 chip - Date supports time formats - New property: URL - Keyboard shortcuts support for Grid: press Enter to leave the edit mode; control c/v to copy-paste cell values ### Bug Fixes - Fixed some bugs ## Version 0.0.4 - beta.3 - 05/02/2022 - Drag to reorder app/ view/ field - Row record opens as a page - Auto resize the height of the row in the grid - Support more number formats - Search column options, supporting Single-select, Multi-select, and number format ![May-03-2022 10-03-00](https://user-images.githubusercontent.com/86001920/166394640-a8f1f3bc-5f20-4033-93e9-16bc308d7005.gif) ### Bug Fixes & Improvements - Improved row/cell data cache - Fixed some bugs ## Version 0.0.4 - beta.2 - 04/11/2022 - Support properties: Text, Number, Date, Checkbox, Select, Multi-select - Insert / delete rows - Add / delete / hide columns - Edit property ![](https://user-images.githubusercontent.com/12026239/162753644-bf2f4e7a-2367-4d48-87e6-35e244e83a5b.png) ## Version 0.0.4 - beta.1 - 04/08/2022 v0.0.4 - beta.1 is pre-release ### New features - Table-view database - support column types: Text, Checkbox, Single-select, Multi-select, Numbers - hide / delete columns - insert rows ## Version 0.0.3 - 02/23/2022 v0.0.3 is production ready, available on Linux, macOS, and Windows ### New features - Dark Mode - Support new languages: French, Italian, Russian, Simplified Chinese, Spanish - Add Settings: Toggle on Dark Mode; Select a language - Show device info - Add tooltip on the toolbar icons Bug fixes and improvements - Increased height of action - CPU performance issue - Fix potential data parser error - More foundation work for online collaboration ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at annie@appflowy.io. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: LICENSE ================================================ 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. Copyright (C) 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 . ================================================ FILE: README.md ================================================

AppFlowy
⭐️ The Open Source Alternative To Notion ⭐️

AppFlowy is the AI workspace where you achieve more without losing control of your data

License: AGPL

WebsiteForumDiscordRedditTwitter

AppFlowy Kanban Board for To-dos

AppFlowy Databases for Tasks and Projects

AppFlowy Sites for Beautiful documentation

AppFlowy AI

AppFlowy Templates



Work across devices

Work across devices

Work across devices

## User Installation - [Download AppFlowy Desktop (macOS, Windows, and Linux)](https://github.com/AppFlowy-IO/AppFlowy/releases) - Other channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/) - Available on - [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone - [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is not supported - [Self-hosting AppFlowy](https://appflowy.com/docs/Step-by-step-Self-Hosting-Guide---From-Zero-to-Production) - [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source) ## Built With - [Flutter](https://flutter.dev/) - [Rust](https://www.rust-lang.org/) ## Stay Up-to-Date

AppFlowy Github - how to star the repo

## Getting Started with development Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific development instructions ## Roadmap - [AppFlowy Roadmap ReadMe](https://docs.appflowy.io/docs/appflowy/roadmap) - [AppFlowy Public Roadmap](https://github.com/orgs/AppFlowy-IO/projects/5/views/12) If you'd like to propose a feature, submit a feature request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+)
If you'd like to report a bug, submit a bug report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+) ## **Releases** Please see the [changelog](https://appflowy.com/what-is-new) for more details about a given release. ## Contributing Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. Please look at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy) for details. If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains the community, **Congratulations!** You are now an official contributor to AppFlowy. ## Translations 🌎🗺 [![translation badge](https://inlang.com/badge?url=github.com/AppFlowy-IO/AppFlowy)](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy?ref=badge) To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or run `npx inlang machine translate` to add missing translations. ## Join the community to build AppFlowy together ## Why Are We Building This? Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations. These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative workplace management tools also have their constraints. The limitations we encountered using these tools and our past work experience with collaborative productivity tools have led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to come up with a one-size fits all solution in such a fragmented market. When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up, in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention the speed and native experience. The same may apply to individual users as well. All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs well. - To individuals, we would like to offer Notion's functionality, data security, and cross-platform native experience. - To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term maintainability. We decided to achieve this mission by upholding the three most fundamental values: - Data privacy first - Reliable native experience - Community-driven extensibility We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the knowledge and wheels of making complex workplace management tools while enabling people and businesses to create beautiful things on their own by equipping them with a versatile toolbox of building blocks. ## License Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for more information. ## Acknowledgments Special thanks to these amazing projects which help power AppFlowy: - [cargo-make](https://github.com/sagiegurari/cargo-make) - [contrib.rocks](https://contrib.rocks) - [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui) ================================================ FILE: ROADMAP.md ================================================ ## Our [roadmap](https://github.com/orgs/AppFlowy-IO/projects/5/views/12) is where you can learn about the features we’re working on, their status, when we expect to release them, and how you can help us. ## Find more information about how to use our official AppFlowy public roadmap on [Gitbook](https://appflowy.gitbook.io/docs/essential-documentation/roadmap). ================================================ FILE: codemagic.yaml ================================================ workflows: ios-workflow: name: iOS Workflow instance_type: mac_mini_m2 max_build_duration: 30 environment: flutter: 3.27.4 xcode: latest cocoapods: default scripts: - name: Build Flutter script: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source "$HOME/.cargo/env" rustc --version cargo --version cd frontend rustup target install aarch64-apple-ios-sim cargo install --force cargo-make cargo install --force --locked duckscript_cli cargo install --force cargo-lipo cargo make appflowy-flutter-deps-tools cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios cargo make --profile development-ios-arm64-sim code_generation - name: iOS integration tests script: | cd frontend/appflowy_flutter flutter emulators --launch apple_ios_simulator flutter -d iPhone test integration_test/runner.dart artifacts: - build/ios/ipa/*.ipa - /tmp/xcodebuild_logs/*.log - flutter_drive.log publishing: email: recipients: - lucas.xu@appflowy.io notify: success: true failure: true ================================================ FILE: commitlint.config.js ================================================ // module.exports = {extends: ['@commitlint/config-conventional']} module.exports = { rules: { 'header-max-length': [2, 'always', 100], 'type-enum': [2, 'always', ['build', 'chore', 'ci', 'docs', 'feat', 'feature', 'fix', 'refactor', 'style', 'test']], 'type-empty': [2, 'never'], 'type-case': [2, 'always', 'lower-case'], 'subject-empty': [2, 'never'], 'subject-case': [ 0, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], ], 'body-leading-blank': [2, 'always'], 'body-max-line-length': [2, 'always', 200], 'body-case': [0, 'never', []], 'footer-leading-blank': [1, 'always'], 'footer-max-line-length': [2, 'always', 100] }, }; ================================================ FILE: doc/CONTRIBUTING.md ================================================

The Open Source Notion Alternative.

# Contributing to AppFlowy Hello, and welcome! Whether you are trying to report a bug, proposing a feature request, or want to work on the code you should go visit [Contributing to AppFlowy](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/contributing-to-appflowy) We look forward to hearing from you! ================================================ FILE: doc/roadmap.md ================================================ [AppFlowy Roadmap ReadMe](https://appflowy.gitbook.io/docs/essential-documentation/roadmap) [AppFlowy Public Roadmap](https://github.com/orgs/AppFlowy-IO/projects/5/views/12) ================================================ FILE: frontend/.vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { // This task only builds the Dart code of AppFlowy. // It supports both the desktop and mobile version. "name": "AF: Build Dart Only", "request": "launch", "program": "./lib/main.dart", "type": "dart", "env": { "RUST_LOG": "debug", }, // uncomment the following line to testing performance. // "flutterMode": "profile", "cwd": "${workspaceRoot}/appflowy_flutter" }, { // This task builds the Rust and Dart code of AppFlowy. "name": "AF-desktop: Build All", "request": "launch", "program": "./lib/main.dart", "type": "dart", "preLaunchTask": "AF: Build Appflowy Core", "env": { "RUST_LOG": "trace", "RUST_BACKTRACE": "1" }, "cwd": "${workspaceRoot}/appflowy_flutter" }, { // This task builds will: // - call the clean task, // - rebuild all the generated Files (including freeze and language files) // - rebuild the the Rust and Dart code of AppFlowy. "name": "AF-desktop: Clean + Rebuild All", "request": "launch", "program": "./lib/main.dart", "type": "dart", "preLaunchTask": "AF: Clean + Rebuild All", "env": { "RUST_LOG": "trace" }, "cwd": "${workspaceRoot}/appflowy_flutter" }, { "name": "AF-iOS: Build All", "request": "launch", "program": "./lib/main.dart", "type": "dart", "preLaunchTask": "AF: Build Appflowy Core For iOS", "env": { "RUST_LOG": "trace" }, "cwd": "${workspaceRoot}/appflowy_flutter" }, { "name": "AF-iOS: Clean + Rebuild All", "request": "launch", "program": "./lib/main.dart", "type": "dart", "preLaunchTask": "AF: Clean + Rebuild All (iOS)", "env": { "RUST_LOG": "trace" }, "cwd": "${workspaceRoot}/appflowy_flutter" }, { "name": "AF-iOS-Simulator: Build All", "request": "launch", "program": "./lib/main.dart", "type": "dart", "preLaunchTask": "AF: Build Appflowy Core For iOS Simulator", "env": { "RUST_LOG": "trace" }, "cwd": "${workspaceRoot}/appflowy_flutter" }, { "name": "AF-iOS-Simulator: Clean + Rebuild All", "request": "launch", "program": "./lib/main.dart", "type": "dart", "preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)", "env": { "RUST_LOG": "trace" }, "cwd": "${workspaceRoot}/appflowy_flutter" }, { "name": "AF-Android: Build All", "request": "launch", "program": "./lib/main.dart", "type": "dart", "preLaunchTask": "AF: Build Appflowy Core For Android", "env": { "RUST_LOG": "trace" }, "cwd": "${workspaceRoot}/appflowy_flutter" }, { "name": "AF-Android: Clean + Rebuild All", "request": "launch", "program": "./lib/main.dart", "type": "dart", "preLaunchTask": "AF: Clean + Rebuild All (Android)", "env": { "RUST_LOG": "trace" }, "cwd": "${workspaceRoot}/appflowy_flutter" }, { "name": "AF-desktop: Debug Rust", "type": "lldb", "request": "attach", "pid": "${command:pickMyProcess}" // To launch the application directly, use the following configuration: // "request": "launch", // "program": "[YOUR_APPLICATION_PATH]", }, ] } ================================================ FILE: frontend/.vscode/tasks.json ================================================ { "version": "2.0.0", // https://code.visualstudio.com/docs/editor/tasks // https://gist.github.com/deadalusai/9e13e36d61ec7fb72148 // ${workspaceRoot}: the root folder of the team // ${file}: the current opened file // ${fileBasename}: the current opened file's basename // ${fileDirname}: the current opened file's dirname // ${fileExtname}: the current opened file's extension // ${cwd}: the current working directory of the spawned process "tasks": [ { "label": "AF: Clean + Rebuild All", "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "AF: Dart Clean", "AF: Flutter Clean", "AF: Build Appflowy Core", "AF: Flutter Pub Get", "AF: Generate Language Files", "AF: Generate Freezed Files", "AF: Generate Svg Files" ], "presentation": { "reveal": "always", "panel": "new" } }, { "label": "AF: Clean + Rebuild All (iOS)", "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "AF: Dart Clean", "AF: Flutter Clean", "AF: Build Appflowy Core For iOS", "AF: Flutter Pub Get", "AF: Generate Language Files", "AF: Generate Freezed Files", "AF: Generate Svg Files" ], "presentation": { "reveal": "always", "panel": "new" } }, { "label": "AF: Clean + Rebuild All (iOS Simulator)", "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "AF: Dart Clean", "AF: Flutter Clean", "AF: Build Appflowy Core For iOS Simulator", "AF: Flutter Pub Get", "AF: Generate Language Files", "AF: Generate Freezed Files", "AF: Generate Svg Files" ], "presentation": { "reveal": "always", "panel": "new" } }, { "label": "AF: Clean + Rebuild All (Android)", "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "AF: Dart Clean", "AF: Flutter Clean", "AF: Build Appflowy Core For Android", "AF: Flutter Pub Get", "AF: Generate Language Files", "AF: Generate Freezed Files", "AF: Generate Svg Files" ], "presentation": { "reveal": "always", "panel": "new" } }, { "label": "AF: Build Appflowy Core", "type": "shell", "windows": { "command": "cargo make --profile development-windows-x86 appflowy-core-dev" }, "linux": { "command": "cargo make --profile \"development-linux-$(uname -m)\" appflowy-core-dev" }, "osx": { "command": "cargo make --profile \"development-mac-$(uname -m)\" appflowy-core-dev" }, "group": "build", "options": { "cwd": "${workspaceFolder}" } }, { "label": "AF: Build Appflowy Core For iOS", "type": "shell", "command": "cargo make --profile development-ios-arm64 appflowy-core-dev-ios", "group": "build", "options": { "cwd": "${workspaceFolder}" } }, { "label": "AF: Build Appflowy Core For iOS Simulator", "type": "shell", "command": "cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios", "group": "build", "options": { "cwd": "${workspaceFolder}" } }, { "label": "AF: Build Appflowy Core For Android", "type": "shell", "command": "cargo make --profile development-android appflowy-core-dev-android", "group": "build", "options": { "cwd": "${workspaceFolder}" } }, { "label": "AF: Code Gen", "type": "shell", "dependsOrder": "sequence", "dependsOn": [ "AF: Flutter Clean", "AF: Flutter Pub Get", "AF: Generate Language Files", "AF: Generate Freezed Files", "AF: Generate Svg Files" ], "group": { "kind": "build", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" } }, { "label": "AF: Dart Clean", "type": "shell", "command": "cargo make flutter_clean", "group": "build", "options": { "cwd": "${workspaceFolder}" } }, { "label": "AF: Flutter Clean", "type": "shell", "command": "flutter clean", "options": { "cwd": "${workspaceFolder}/appflowy_flutter" } }, { "label": "AF: Flutter Pub Get", "type": "shell", "command": "flutter pub get", "options": { "cwd": "${workspaceFolder}/appflowy_flutter" } }, { "label": "AF: Generate Freezed Files", "type": "shell", "command": "sh ./scripts/code_generation/freezed/generate_freezed.sh", "options": { "cwd": "${workspaceFolder}" }, "group": "build", "windows": { "options": { "shell": { "executable": "cmd.exe", "args": [ "/d", "/c", ".\\scripts\\code_generation\\freezed\\generate_freezed.cmd" ] } } } }, { "label": "AF: Generate Language Files", "type": "shell", "command": "sh ./scripts/code_generation/language_files/generate_language_files.sh", "windows": { "options": { "shell": { "executable": "cmd.exe", "args": [ "/d", "/c", ".\\scripts\\code_generation\\language_files\\generate_language_files.cmd" ] } } }, "group": "build", "options": { "cwd": "${workspaceFolder}" } }, { "label": "AF: Generate Svg Files", "type": "shell", "command": "sh ./scripts/code_generation/flowy_icons/generate_flowy_icons.sh", "windows": { "options": { "shell": { "executable": "cmd.exe", "args": [ "/d", "/c", ".\\scripts\\code_generation\\flowy_icons\\generate_flowy_icons.cmd" ] } } }, "group": "build", "options": { "cwd": "${workspaceFolder}" } }, { "label": "AF: flutter build aar", "type": "flutter", "command": "flutter", "args": [ "build", "aar" ], "group": "build", "problemMatcher": [], "detail": "appflowy_flutter" }, { "label": "AF: Generate Env File", "type": "shell", "command": "dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs", "options": { "cwd": "${workspaceFolder}/appflowy_flutter" } } ] } ================================================ FILE: frontend/Makefile.toml ================================================ #https://github.com/sagiegurari/cargo-make extend = [ { path = "scripts/makefile/desktop.toml" }, { path = "scripts/makefile/mobile.toml" }, { path = "scripts/makefile/protobuf.toml" }, { path = "scripts/makefile/tests.toml" }, { path = "scripts/makefile/docker.toml" }, { path = "scripts/makefile/env.toml" }, { path = "scripts/makefile/flutter.toml" }, { path = "scripts/makefile/tool.toml" }, { path = "scripts/makefile/tauri.toml" }, { path = "scripts/makefile/web.toml" }, ] [config] on_error_task = "catch" [tasks.catch] run_task = { name = ["restore-crate-type"] } [env] RUST_LOG = "info" CARGO_PROFILE = "dev" CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" APPFLOWY_VERSION = "0.9.9" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" # CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html # If you update the macOS's CRATE_TYPE, don't forget to update the # appflowy_backend.podspec # for staticlib: # s.static_framework = true # s.vendored_libraries = "libdart_ffi.a" # for cdylib: # s.vendored_libraries = "libdart_ffi.dylib" # # Remember to update the ffi.dart: # for staticlib: # if (Platform.isMacOS) return DynamicLibrary.open('${prefix}/libdart_ffi.a'); # for cdylib: # if (Platform.isMacOS) return DynamicLibrary.open('${prefix}/libdart_ffi.dylib'); CRATE_TYPE = "staticlib" LIB_EXT = "a" APP_ENVIRONMENT = "local" FLUTTER_FLOWY_SDK_PATH = "appflowy_flutter/packages/appflowy_backend" TAURI_BACKEND_SERVICE_PATH = "appflowy_tauri/src/services/backend" WEB_BACKEND_SERVICE_PATH = "appflowy_web/src/services/backend" TAURI_APP_BACKEND_SERVICE_PATH = "appflowy_web_app/src/application/services/tauri-services/backend" # Test default config TEST_CRATE_TYPE = "cdylib" TEST_LIB_EXT = "dylib" TEST_BUILD_FLAG = "debug" TEST_COMPILE_TARGET = "x86_64-apple-darwin" [env.development-mac-arm64] RUST_LOG = "info" TARGET_OS = "macos" RUST_COMPILE_TARGET = "aarch64-apple-darwin" BUILD_FLAG = "debug" FLUTTER_OUTPUT_DIR = "Debug" PRODUCT_EXT = "app" BUILD_ARCHS = "arm64" BUILD_ACTIVE_ARCHS_ONLY = true CRATE_TYPE = "staticlib" [env.development-mac-x86_64] RUST_LOG = "info" TARGET_OS = "macos" RUST_COMPILE_TARGET = "x86_64-apple-darwin" BUILD_FLAG = "debug" FLUTTER_OUTPUT_DIR = "Debug" PRODUCT_EXT = "app" BUILD_ARCHS = "x86_64" BUILD_ACTIVE_ARCHS_ONLY = true CRATE_TYPE = "staticlib" [env.production-mac-arm64] CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "macos" RUST_COMPILE_TARGET = "aarch64-apple-darwin" FLUTTER_OUTPUT_DIR = "Release" PRODUCT_EXT = "app" APP_ENVIRONMENT = "production" BUILD_ARCHS = "arm64" BUILD_ACTIVE_ARCHS_ONLY = false CRATE_TYPE = "staticlib" [env.production-mac-x86_64] CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "macos" RUST_COMPILE_TARGET = "x86_64-apple-darwin" FLUTTER_OUTPUT_DIR = "Release" PRODUCT_EXT = "app" APP_ENVIRONMENT = "production" BUILD_ARCHS = "x86_64" BUILD_ACTIVE_ARCHS_ONLY = false CRATE_TYPE = "staticlib" [env.production-mac-universal] CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "macos" FLUTTER_OUTPUT_DIR = "Release" PRODUCT_EXT = "app" BUILD_ACTIVE_ARCHS_ONLY = false APP_ENVIRONMENT = "production" [env.development-windows-x86] TARGET_OS = "windows" RUST_COMPILE_TARGET = "x86_64-pc-windows-msvc" BUILD_FLAG = "debug" FLUTTER_OUTPUT_DIR = "Debug" PRODUCT_EXT = "exe" CRATE_TYPE = "cdylib" LIB_EXT = "dll" [env.production-windows-x86] CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "windows" RUST_COMPILE_TARGET = "x86_64-pc-windows-msvc" FLUTTER_OUTPUT_DIR = "Release" PRODUCT_EXT = "exe" CRATE_TYPE = "cdylib" LIB_EXT = "dll" BUILD_ARCHS = "x64" APP_ENVIRONMENT = "production" [env.development-linux-x86_64] TARGET_OS = "linux" RUST_COMPILE_TARGET = "x86_64-unknown-linux-gnu" BUILD_FLAG = "debug" CRATE_TYPE = "cdylib" FLUTTER_OUTPUT_DIR = "Debug" LIB_EXT = "so" LINUX_ARCH = "x64" [env.production-linux-x86_64] CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "linux" RUST_COMPILE_TARGET = "x86_64-unknown-linux-gnu" CRATE_TYPE = "cdylib" FLUTTER_OUTPUT_DIR = "Release" LIB_EXT = "so" LINUX_ARCH = "x64" APP_ENVIRONMENT = "production" [env.development-linux-aarch64] TARGET_OS = "linux" RUST_COMPILE_TARGET = "aarch64-unknown-linux-gnu" BUILD_FLAG = "debug" CRATE_TYPE = "cdylib" FLUTTER_OUTPUT_DIR = "Debug" LIB_EXT = "so" LINUX_ARCH = "arm64" FLUTTER_DESKTOP_FEATURES = "dart,openssl_vendored" [env.production-linux-aarch64] CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "linux" RUST_COMPILE_TARGET = "aarch64-unknown-linux-gnu" CRATE_TYPE = "cdylib" FLUTTER_OUTPUT_DIR = "Release" LIB_EXT = "so" LINUX_ARCH = "arm64" APP_ENVIRONMENT = "production" FLUTTER_DESKTOP_FEATURES = "dart,openssl_vendored" [env.development-ios-arm64-sim] BUILD_FLAG = "debug" TARGET_OS = "ios" FLUTTER_OUTPUT_DIR = "Debug" RUST_COMPILE_TARGET = "aarch64-apple-ios-sim" BUILD_ARCHS = "arm64" CRATE_TYPE = "staticlib" [env.development-ios-arm64] BUILD_FLAG = "debug" TARGET_OS = "ios" FLUTTER_OUTPUT_DIR = "Debug" RUST_COMPILE_TARGET = "aarch64-apple-ios" BUILD_ARCHS = "arm64" CRATE_TYPE = "staticlib" [env.production-ios-arm64] BUILD_FLAG = "release" TARGET_OS = "ios" FLUTTER_OUTPUT_DIR = "Release" RUST_COMPILE_TARGET = "aarch64-apple-ios" BUILD_ARCHS = "arm64" CRATE_TYPE = "staticlib" [env.development-android] BUILD_FLAG = "debug" TARGET_OS = "android" CRATE_TYPE = "cdylib" FLUTTER_OUTPUT_DIR = "Debug" LIB_EXT = "so" PRODUCT_EXT = "apk" FLUTTER_DESKTOP_FEATURES = "dart,openssl_vendored" [env.production-android] BUILD_FLAG = "release" TARGET_OS = "android" CRATE_TYPE = "cdylib" FLUTTER_OUTPUT_DIR = "Release" PRODUCT_EXT = "apk" LIB_EXT = "so" [tasks.echo_env] script = [''' echo "-------- Env Parameters --------" echo CRATE_TYPE: ${CRATE_TYPE} echo BUILD_FLAG: ${BUILD_FLAG} echo TARGET_OS: ${TARGET_OS} echo RUST_COMPILE_TARGET: ${RUST_COMPILE_TARGET} echo FEATURES: ${FLUTTER_DESKTOP_FEATURES} echo PRODUCT_EXT: ${PRODUCT_EXT} echo APP_ENVIRONMENT: ${APP_ENVIRONMENT} echo BUILD_ARCHS: ${BUILD_ARCHS} echo BUILD_VERSION: ${BUILD_VERSION} echo RUST_VERSION: "$(rustc --version)" '''] script_runner = "@shell" [tasks.setup-crate-type] private = true script = [ """ toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml val = replace ${toml} "staticlib" ${CRATE_TYPE} result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} assert ${result} """, ] script_runner = "@duckscript" [tasks.restore-crate-type] private = true script = [ """ toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml val = replace ${toml} ${CRATE_TYPE} "staticlib" result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} assert ${result} """, ] script_runner = "@duckscript" [env.test-macos-x86_64] TEST_CRATE_TYPE = "cdylib" TEST_LIB_EXT = "dylib" # For the moment, the DynamicLibrary only supports open x86_64 architectures binary. TEST_COMPILE_TARGET = "x86_64-apple-darwin" [env.test-macos-arm64] TEST_CRATE_TYPE = "cdylib" TEST_LIB_EXT = "dylib" TEST_COMPILE_TARGET = "aarch64-apple-darwin" [env.test-linux] TEST_CRATE_TYPE = "cdylib" TEST_LIB_EXT = "so" TEST_COMPILE_TARGET = "x86_64-unknown-linux-gnu" [env.test-windows] TEST_CRATE_TYPE = "cdylib" TEST_LIB_EXT = "dll" TEST_COMPILE_TARGET = "x86_64-pc-windows-msvc" [tasks.setup-test-crate-type] private = true script = [ """ toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml val = replace ${toml} "staticlib" ${TEST_CRATE_TYPE} result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} assert ${result} """, ] script_runner = "@duckscript" [tasks.restore-test-crate-type] private = true script = [ """ toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml val = replace ${toml} ${TEST_CRATE_TYPE} "staticlib" result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} assert ${result} """, ] script_runner = "@duckscript" [tasks.test-build] condition = { env_set = ["FLUTTER_FLOWY_SDK_PATH"] } script = [""" cd ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/flowy-net cargo build -vv --features=dart """] script_runner = "@shell" ================================================ FILE: frontend/appflowy_flutter/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # 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 .packages .pub-cache/ .pub/ /build/ generated* Generated* # Web related lib/generated_plugin_registrant.dart # Language related generated files lib/generated/ # Freezed generated files *.g.dart *.freezed.dart # 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 /packages/flowy_protobuf /packages/flutter-quill product/** windows/flutter/dart_ffi/ **/**/*.dylib **/**/*.a **/**/*.lib **/**/*.dll **/**/*.so **/**/Brewfile.lock.json **/.sandbox **/.vscode/ .env .env.* coverage/ **/failures/*.png assets/translations/ assets/flowy_icons/* ================================================ FILE: frontend/appflowy_flutter/.metadata ================================================ # 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. version: revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 channel: unknown project_type: app # Tracks metadata for the flutter migrate command migration: platforms: - platform: root create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 - platform: android create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 # 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' ================================================ FILE: frontend/appflowy_flutter/Makefile ================================================ .PHONY: freeze_build, free_watch freeze_build: dart run build_runner build -d watch: dart run build_runner watch ================================================ FILE: frontend/appflowy_flutter/README.md ================================================

AppFlowy_Flutter

> Documentation for Contributors This Repository contains the codebase for the frontend of the application, currently we use Flutter as our frontend framework. ### Platforms Supported Using Flutter 💻 - Linux - macOS - Windows > We are actively working on support for Android & iOS! _Additionally, we are working on a Web version built with Tauri!_ ### Am I Eligible to Contribute? Yes! You are eligible to contribute, check out the ways in which you can [contribute to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy). Some of the ways in which you can contribute are: - Non-Coding Contributions - Documentation - Feature Requests and Feedbacks - Report Bugs - Improve Translations - Coding Contributions To contribute to `AppFlowy_Flutter` codebase specifically (coding contribution) we suggest you to have basic knowledge of Flutter. In case you are new to Flutter, we suggest you learn the basics, and then contribute afterwards. To get started with Flutter read [here](https://flutter.dev/docs/get-started/codelab). ### What OS should I use for development? We support all OS for Development i.e. Linux, MacOS and Windows. However, most of us promote macOS and Linux over Windows. We have detailed [docs](https://docs.appflowy.io/docs/documentation/appflowy/from-source/environment-setup) on how to setup `AppFlowy_Flutter` on your local system respectively per operating system. ### Getting Started ❇ We have detailed documentation on how to [get started](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy) with the project, and make your first contribution. However, we do have some specific picks for you: - [Code Architecture](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/frontend/codemap) - [Styleguide & Conventions](https://docs.appflowy.io/docs/documentation/software-contributions/conventions/naming-conventions) - [Making Your First PR](https://docs.appflowy.io/docs/documentation/software-contributions/submitting-code/submitting-your-first-pull-request) - [All AppFlowy Documentation](https://docs.appflowy.io/docs/documentation/appflowy) - Contribution guide, build and run, debugging, testing, localization, etc. ### Need Help? - New to GitHub? Follow [these](https://docs.appflowy.io/docs/documentation/software-contributions/submitting-code/setting-up-your-repositories) steps to get started - Stuck Somewhere? Join our [Discord](https://discord.gg/9Q2xaN37tV), we're there to help you! - Find out more about the [community initiatives](https://docs.appflowy.io/docs/appflowy/community). ================================================ FILE: frontend/appflowy_flutter/analysis_options.yaml ================================================ include: package:flutter_lints/flutter.yaml analyzer: exclude: - "**/*.g.dart" - "**/*.freezed.dart" - "packages/**/*.dart" linter: rules: - require_trailing_commas - prefer_collection_literals - prefer_final_fields - prefer_final_in_for_each - prefer_final_locals - sized_box_for_whitespace - use_decorated_box - unnecessary_parenthesis - unnecessary_await_in_return - unnecessary_raw_strings - avoid_unnecessary_containers - avoid_redundant_argument_values - avoid_unused_constructor_parameters - always_declare_return_types - sort_constructors_first - unawaited_futures errors: invalid_annotation_target: ignore ================================================ FILE: frontend/appflowy_flutter/android/.gitignore ================================================ gradle-wrapper.jar /.gradle /captures/ /gradlew /gradlew.bat /local.properties GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app key.properties **/*.keystore **/*.jks .cxx ================================================ FILE: frontend/appflowy_flutter/android/README.md ================================================ # Description This is a guide on how to build the rust SDK for AppFlowy on android. Compiling the sdk is easy it just needs a few tweaks. When compiling for android we need the following pre-requisites: - Android NDK Tools. (v24 has been tested). - Cargo NDK. (@latest version). **Getting the tools** - Install cargo-ndk ```bash cargo install cargo-ndk```. - [Download](https://developer.android.com/ndk/downloads/) Android NDK version 24. - When downloading Android NDK you can get the compressed version as a standalone from the site. Or you can download it through [Android Studio](https://developer.android.com/studio). - After downloading the two you need to set the environment variables. For Windows that's a separate process. On macOS and Linux the process is similar. - The variables needed are '$ANDROID_NDK_HOME', this will point to where the NDK is located. --- **Cargo Config File** This code needs to be written in ~/.cargo/config, this helps cargo know where to locate the android tools(linker and archiver). **NB** Keep in mind just replace 'user' with your own user name. Or just point it to the location of where you put the NDK. ```toml [target.aarch64-linux-android] ar = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" linker = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android29-clang" [target.armv7-linux-androideabi] ar = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" linker = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi29-clang" [target.i686-linux-android] ar = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" linker = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android29-clang" [target.x86_64-linux-android] ar = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" linker = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android29-clang" ``` **Clang Fix** In order to get clang to work properly with version 24 you need to create this file. libgcc.a, then add this one line. ``` INPUT(-lunwind) ``` **Folder path: 'Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/lib64/clang/14.0.1/lib/linux'.** After that you have to copy this file into three different folders namely aarch64, arm, i386 and x86_64. We have to do this so we Android NDK can find clang on our system, if we used NDK 22 we wouldn't have to do this process. Though using NDK v22 will not give us a lot of features to work with. This GitHub [issue](https://github.com/fzyzcjy/flutter_rust_bridge/issues/419) explains the reason why we are doing this. --- **Android NDK** After installing the NDK tools for android you should export the PATH to your config file (.vimrc, .zshrc, .profile, .bashrc file), That way it can be found. ```vim export PATH=/home/sean/Android/Sdk/ndk/24.0.8215888 ``` ================================================ FILE: frontend/appflowy_flutter/android/app/build.gradle ================================================ def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) } } def flutterRoot = localProperties.getProperty('flutter.sdk') if (flutterRoot == null) { throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { flutterVersionName = '1.0' } apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } android { compileSdkVersion 35 ndkVersion "24.0.8215888" compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } sourceSets { main.java.srcDirs += 'src/main/kotlin' main.jniLibs.srcDirs += 'jniLibs/' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "io.appflowy.appflowy" minSdkVersion 29 targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true externalNativeBuild { cmake { arguments "-DANDROID_ARM_NEON=TRUE", "-DANDROID_STL=c++_shared" } } } signingConfigs { release { keyAlias keystoreProperties['keyAlias'] keyPassword keystoreProperties['keyPassword'] storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null storePassword keystoreProperties['storePassword'] } } buildTypes { release { // use release instead when publishing the application to google play. // signingConfig signingConfigs.release signingConfig signingConfigs.debug } } namespace 'io.appflowy.appflowy' externalNativeBuild { cmake { path "src/main/CMakeLists.txt" } } // only support arm64-v8a defaultConfig { ndk { abiFilters "arm64-v8a" } } } flutter { source '../..' } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "com.android.support:multidex:2.0.1" } ================================================ FILE: frontend/appflowy_flutter/android/app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.10.0) project(AppFlowy) message(CONFIGURE_LOG "NDK PATH: ${ANDROID_NDK}") message(CONFIGURE_LOG "Copying libc++_shared.so") # arm64-v8a file(COPY ${ANDROID_NDK}/sources/cxx-stl/llvm-libc++/libs/arm64-v8a/libc++_shared.so DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/jniLibs/arm64-v8a ) # armeabi-v7a file(COPY ${ANDROID_NDK}/sources/cxx-stl/llvm-libc++/libs/armeabi-v7a/libc++_shared.so DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/jniLibs/armeabi-v7a ) # x86_64 file(COPY ${ANDROID_NDK}/sources/cxx-stl/llvm-libc++/libs/x86_64/libc++_shared.so DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/jniLibs/x86_64 ) ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/Classes/binding.h ================================================ #include #include #include #include int64_t init_sdk(int64_t port, char *data); void async_event(int64_t port, const uint8_t *input, uintptr_t len); const uint8_t *sync_event(const uint8_t *input, uintptr_t len); int32_t set_stream_port(int64_t port); int32_t set_log_stream_port(int64_t port); void link_me_please(void); void rust_log(int64_t level, const char *data); void set_env(const char *data); ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/kotlin/com/example/app_flowy/MainActivity.kt ================================================ package io.appflowy.appflowy import io.flutter.embedding.android.FlutterActivity class MainActivity: FlutterActivity() { } ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/res/drawable/launcher_background.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/res/drawable/launcher_foreground.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/res/drawable-v21/launch_background.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/res/values/ic_launcher_background.xml ================================================ #FFFFFF ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/res/values/styles.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/app/src/main/res/values-night/styles.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/app/src/profile/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/android/build.gradle ================================================ buildscript { ext.kotlin_version = '1.8.0' repositories { google() mavenCentral() } dependencies { classpath 'com.android.tools.build:gradle:8.1.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() mavenCentral() } } rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" project.evaluationDependsOn(':app') } tasks.register("clean", Delete) { delete rootProject.buildDir } ================================================ FILE: frontend/appflowy_flutter/android/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: frontend/appflowy_flutter/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true org.gradle.caching=true android.suppressUnsupportedCompileSdk=33 ================================================ FILE: frontend/appflowy_flutter/android/settings.gradle ================================================ include ':app' def localPropertiesFile = new File(rootProject.projectDir, "local.properties") def properties = new Properties() assert localPropertiesFile.exists() localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() def plugins = new Properties() def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') if(pluginsFile.exists()){ pluginsFile.withReader('UTF-8'){reader -> plugins.load(reader)} } plugins.each{name, path -> def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() include ":$name" project(":$name").projectDir = pluginDirectory } ================================================ FILE: frontend/appflowy_flutter/assets/built_in_prompts.json ================================================ { "prompts": [ { "id": "builtin_prompt_1", "name": "Linux terminal", "category": "other", "content": "I want you to act as a linux terminal. I will type commands and you will reply with what the terminal should show. I want you to only reply with the terminal output inside one unique code block, and nothing else. do not write explanations. do not type commands unless I instruct you to do so.\nThis is my first command:\n\n[command]", "example": "I want you to act as a linux terminal. I will type commands and you will reply with what the terminal should show. I want you to only reply with the terminal output inside one unique code block, and nothing else. do not write explanations. do not type commands unless I instruct you to do so.\nThis is my first command:\n\nls -l" }, { "id": "builtin_prompt_2", "name": "Cover Letter Writer and Advisor", "category": "business", "content": "I want you to act as a career advisor, who can assist in crafting a compelling cover letter for job applications. Share insights on how to structure the letter, highlight relevant skills and experiences, and express enthusiasm for the role. Offer advice on how to tailor the letter to the specific job description, company culture, and industry standards. Provide tips on maintaining a professional tone, proofreading for errors, and making a memorable impression on hiring managers. My first request is:\n\n[request]", "example": "I want you to act as a career advisor, who can assist in crafting a compelling cover letter for job applications. Share insights on how to structure the letter, highlight relevant skills and experiences, and express enthusiasm for the role. Offer advice on how to tailor the letter to the specific job description, company culture, and industry standards. Provide tips on maintaining a professional tone, proofreading for errors, and making a memorable impression on hiring managers. My first request is:\n\nHelp me write a cover letter for a software engineer position at a tech startup, focusing on my coding skills, problem-solving ability, and experience with agile development methodologies." }, { "id": "builtin_prompt_3", "name": "Business Plan Consultant", "category": "business", "content": "I want you to act as a business plan consultant, who can guide me through the process of creating a comprehensive and persuasive business plan for a new startup or existing business. Discuss key elements to include in the plan, such as executive summary, company overview, market analysis, marketing strategies, organizational structure, and financial projections. Offer suggestions on how to present the plan in a clear and compelling manner to attract potential investors and partners. My first request is:\n\n[request]", "example": "I want you to act as a business plan consultant, who can guide me through the process of creating a comprehensive and persuasive business plan for a new startup or existing business. Discuss key elements to include in the plan, such as executive summary, company overview, market analysis, marketing strategies, organizational structure, and financial projections. Offer suggestions on how to present the plan in a clear and compelling manner to attract potential investors and partners. My first request is:\n\nHelp me outline a business plan for an online fitness coaching platform targeting busy professionals." }, { "id": "builtin_prompt_4", "name": "Niche Identifier", "category": "business", "content": "As an online business consultant, your task is to help an aspiring entrepreneur identify 5 profitable niches for starting an online business. Consider factors such as market demand, competition, target audience, and potential for growth. For each niche, provide a brief description of the products or services that could be offered, the target customer demographics, and the potential marketing strategies to reach them. Suggest low-cost ways to validate the niche ideas and test their viability before investing significant time or money." }, { "id": "builtin_prompt_5", "name": "Chief Executive Officer", "category": "business", "content": "I want you to act as a CEO, who can provide strategic insights into running a successful organization. Share advice on setting the company's vision and mission, building a high-performing team, managing resources, and driving growth. Offer guidance on making tough decisions, dealing with risks and crises, and maintaining stakeholder relationships. Also, discuss the responsibilities of a CEO in ensuring ethical conduct and corporate social responsibility. My first request is:\n\n[request]", "example": "I want you to act as a CEO, who can provide strategic insights into running a successful organization. Share advice on setting the company's vision and mission, building a high-performing team, managing resources, and driving growth. Offer guidance on making tough decisions, dealing with risks and crises, and maintaining stakeholder relationships. Also, discuss the responsibilities of a CEO in ensuring ethical conduct and corporate social responsibility. My first request is:\n\nProvide a strategic plan for a startup tech company in its first year of operation, focusing on key objectives, potential challenges, and growth strategies." }, { "id": "builtin_prompt_6", "name": "Accountant", "category": "business", "content": "I want you to act as an accountant and come up with creative ways to manage finances. You’ll need to consider budgeting, investment strategies and risk management when creating a financial plan for your client. In some cases, you may also need to provide advice on taxation laws and regulations in order to help them maximize their profits. My first suggestion request is:\n\n[request].", "example": "I want you to act as an accountant and come up with creative ways to manage finances. You’ll need to consider budgeting, investment strategies and risk management when creating a financial plan for your client. In some cases, you may also need to provide advice on taxation laws and regulations in order to help them maximize their profits. My first suggestion request is:\n\nCreate a financial plan for a small business that focuses on cost savings and long-term investments" }, { "id": "builtin_prompt_7", "name": "Creative Branding Strategist", "category": "business", "content": "You are a creative branding strategist, specializing in helping small businesses establish a strong and memorable brand identity. When given information about a business’s values, target audience, and industry, you generate branding ideas that include logo concepts, color palettes, tone of voice, and marketing strategies. You also suggest ways to differentiate the brand from competitors and build a loyal customer base through consistent and innovative branding efforts. Reply in English using professional tone for everyone. This is my first request:\n\n[request]", "example": "You are a creative branding strategist, specializing in helping small businesses establish a strong and memorable brand identity. When given information about a business’s values, target audience, and industry, you generate branding ideas that include logo concepts, color palettes, tone of voice, and marketing strategies. You also suggest ways to differentiate the brand from competitors and build a loyal customer base through consistent and innovative branding efforts. Reply in English using professional tone for everyone. This is my first request:\n\nHelp me create a branding strategy for a new organic skincare line targeting eco-conscious consumers." }, { "id": "builtin_prompt_8", "name": "Emoji-fy", "category": "other", "content": "Your task is to take the following plain text message provided and convert it into an expressive, emoji-rich message that conveys the same meaning and intent. Replace key words and phrases with relevant emojis wherever possible to add visual interest and emotion. Use emojis creatively but ensure the message remains clear and easy to understand. Do not alter the core message or add new information.\n\n[message]" }, { "id": "builtin_prompt_9", "name": "Explain like I’m 5", "category": "education", "content": "Explain [concept or question, e.g., What is gravity?, What does money do?, How do plants grow?] in a way that a 5-year-old would understand. Use very simple language, short sentences, and relatable examples from everyday life (like toys, animals, food, or playground activities). Avoid technical jargon and aim to make the explanation fun and clear.\n\nIf helpful, include a quick story or analogy to illustrate the idea. You can end the explanation with a cheerful summary or a fun fact to keep it engaging.", "isFeatured": true }, { "id": "builtin_prompt_10", "name": "Social Media Master", "category": "marketing", "content": "Create an engaging and upbeat social media post for [platform, e.g., Instagram/Twitter/LinkedIn] about [topic or event, e.g., product launch, personal milestone, motivational message]. The tone should be [desired tone, e.g., energetic, friendly, professional] and include [specific elements, e.g., a call to action, hashtags, emojis]. Make it appealing to [target audience, e.g., young professionals, fitness enthusiasts, small business owners]." }, { "id": "builtin_prompt_11", "name": "Prompt Enhancer", "category": "other", "content": "Act as a Prompt Enhancer AI that takes user-input prompts and transforms them into more engaging, detailed, and thought-provoking questions. Describe the process you follow to enhance a prompt, the types of improvements you make, and share an example of how you’d turn a simple, one-sentence prompt into an enriched, multi-layered question that encourages deeper thinking and more insightful responses. Reply in English using professional tone for everyone." }, { "id": "builtin_prompt_12", "name": "Learning with Historical Figures", "category": "education", "content": "Pretend you are [famous figure] explaining [topic] to me. Let’s have a dialogue where I can ask questions to understand better." }, { "id": "builtin_prompt_13", "name": "Salesperson", "category": "marketing", "content": "I want you to act as a salesperson. Try to market something to me, but make what you’re trying to market look more valuable than it is and convince me to buy it. Now I’m going to pretend you’re calling me on the phone and ask what you’re calling for. Hello, what did you call for? Reply in English using professional tone for everyone." }, { "id": "builtin_prompt_14", "name": "Marketing Funnel Framework", "category": "marketing", "content": "Using the 'Marketing Funnel' framework, please write a marketing campaign outline that targets [awareness consideration conversion] stage of the customer journey and aligns with the goals of each stage. Highlight the [features] of our [product/service] and explain how it can [solve a problem] or [achieve a goal] for [ideal customer persona]." }, { "id": "builtin_prompt_15", "name": "Email Marketing Campaign", "category": "marketing", "content": "As an experienced email marketer, your goal is to create an engaging email campaign to promote [insert product/service/event]. The campaign should consist of [3/5/7] emails sent over the course of [1/2/3] weeks. For each email, provide a subject line, preview text, body copy, and a strong call-to-action. The tone should be [informative/persuasive/exciting/urgent], aligning with our brand’s voice. Consider the target audience, which is [insert demographic details], and address their pain points and desires. Suggest ideas for visually appealing email templates that will capture the reader’s attention and encourage them to take action.\n\n[Text here]" }, { "id": "builtin_prompt_16", "name": "Referral Marketing Program", "category": "marketing", "content": "As a growth marketing expert, your task is to design a referral marketing program for [insert company name] to incentivize existing customers to refer new business. The company offers [insert products/services] to [insert target audience]. Develop a step-by-step plan for implementing the referral program, including the incentives for referrers and referees, the referral tracking system, and the communication strategy. Ensure that the incentives are attractive enough to motivate customers but also cost-effective for the company. Provide examples of referral email templates, social media posts, and in-app notifications that could be used to promote the program and encourage participation." }, { "id": "builtin_prompt_17", "name": "Personal Trainer", "category": "healthAndFitness", "content": "I want you to act as a personal trainer, who can create customized fitness programs, provide exercise instructions, and offer guidance on healthy lifestyle choices. Assess my current fitness level, goals, and preferences to develop a tailored workout plan that includes strength training, cardiovascular exercises, and flexibility routines. Share tips on proper exercise form, injury prevention, and progressive overload to ensure continuous improvement. Offer advice on nutrition, hydration, and rest to support my overall well-being. My first request is:\n\n[request]", "example": "I want you to act as a personal trainer, who can create customized fitness programs, provide exercise instructions, and offer guidance on healthy lifestyle choices. Assess my current fitness level, goals, and preferences to develop a tailored workout plan that includes strength training, cardiovascular exercises, and flexibility routines. Share tips on proper exercise form, injury prevention, and progressive overload to ensure continuous improvement. Offer advice on nutrition, hydration, and rest to support my overall well-being. My first request is:\n\nDesign a 4-week workout plan for me to improve my overall strength and endurance, considering my limited access to gym equipment." }, { "id": "builtin_prompt_18", "name": "Nutrition Expert", "category": "healthAndFitness", "content": "You are a highly trusted and knowledgeable health, fitness, and nutrition expert. Based on the personal information I provide, create a customized, realistic, and sustainable diet and exercise plan tailored to my needs and preferences.\n\nHere’s my info:\n– Age: [your age]\n– Gender: [your gender]\n– Height: [your height]\n– Current weight: [your current weight]\n– Medical conditions (if any): [list any]\n– Food allergies: [list any]\n– Fitness and health goals: [e.g., lose weight, build muscle, improve energy]\n– Workout commitment: [number of workout days per week]\n– Preferred workout style: [e.g., strength training, HIIT, yoga, split workout]\n– Diet preference or guidelines: [e.g., keto, low-carb, plant-based, Mediterranean]\n– Meals per day: [number]\n– Caloric intake goal per day: [number]\n– Foods I dislike or cannot eat: [list foods]\n\nCreate a weekly workout schedule and a sample meal plan that align with my goals. Explain your reasoning behind each recommendation (e.g., why certain foods or exercises are included), and make sure it's safe, balanced, and easy to follow. Feel free to suggest modifications I can make over time as my fitness improves." }, { "id": "builtin_prompt_19", "name": "AI Doctor", "category": "healthAndFitness", "content": "I want you to act as an AI assisted doctor. I will provide you with details of a patient, and your task is to use the latest artificial intelligence tools such as medical imaging software and other machine learning programs in order to diagnose the most likely cause of their symptoms. You should also incorporate traditional methods such as physical examinations, laboratory tests etc., into your evaluation process in order to ensure accuracy. My first request is:\n\n[request]", "example": "I want you to act as an AI assisted doctor. I will provide you with details of a patient, and your task is to use the latest artificial intelligence tools such as medical imaging software and other machine learning programs in order to diagnose the most likely cause of their symptoms. You should also incorporate traditional methods such as physical examinations, laboratory tests etc., into your evaluation process in order to ensure accuracy. My first request is:\n\nI need help diagnosing a case of severe abdominal pain." }, { "id": "builtin_prompt_20", "name": "Psychologist", "category": "healthAndFitness", "content": "I want you to act a psychologist. I will provide you my thoughts. I want you to give me scientific suggestions that will make me feel better. My first thought is:\n\n[thought]", "example": "I want you to act a psychologist. I will provide you my thoughts. I want you to give me scientific suggestions that will make me feel better. My first thought is:\n\nI feel overwhelmed with my workload and can’t seem to focus." }, { "id": "builtin_prompt_21", "name": "Budget Travel Ticker Advisor", "category": "travel", "content": "You are a cheap travel ticket advisor specializing in finding the most affordable transportation options for your clients. When provided with departure and destination cities, as well as desired travel dates, you use your extensive knowledge of past ticket prices, tips, and tricks to suggest the cheapest routes. Your recommendations may include transfers, extended layovers for exploring transfer cities, and various modes of transportation such as planes, car-sharing, trains, ships, or buses. Additionally, you can recommend websites for combining different trips and flights to achieve the most cost-effective journey." }, { "id": "builtin_prompt_22", "name": "Travel Guide", "category": "travel", "content": "I want you to act as a travel guide. I will write you my location and you will suggest a place to visit near my location. In some cases, I will also give you the type of places I will visit. You will also suggest me places of similar type that are close to my first location.\n\n[location]" }, { "id": "builtin_prompt_23", "name": "Travel Preparation Assistant", "category": "travel", "content": "You are an expert travel preparation assistant. My goal is to have you help me plan and pack for an upcoming trip.\n\nFirst, I need to provide you with some information about my trip:\n\n1. Type of trip: [e.g., vacation, business, backpacking, cruise]\n2. Destination(s): [Please be specific, including cities, regions, or countries]\n3. Dates of travel: [Start date - End date]\n4. Primary purpose: [e.g., relaxation, adventure, work, cultural immersion]\n5. Traveling with: [e.g., alone, family, friends, colleagues]\n6. Typical weather at destination during travel dates: [If known, describe. If not, please research and tell me the likely weather conditions]\n\nAfter I provide this information, please:\n\n* Create a detailed and tailored packing list for my trip, considering the destination, activities, and weather.\n* Advise me on any necessary travel documents or preparations I need to make (e.g., visas, vaccinations, travel insurance).\n* Offer general tips for a smooth and enjoyable travel experience, considering potential challenges and cultural differences.\n\nPlease ask clarifying questions as needed.\n\nLet's begin! Here's the information about my trip: [Paste your answers to the questions above here]" }, { "id": "builtin_prompt_24", "name": "Financial planner", "category": "education", "content": "I want you to act as a financial planner, who can provide a roadmap and guidance on how to accumulate wealth to [financial goal]. Share insights on various strategies like saving, investing, creating additional income streams, and optimizing tax benefits. Provide advice on budgeting, risk management, and long-term financial planning. Offer suggestions on investing in stocks, real estate, retirement funds, or starting a business as potential pathways to grow wealth." }, { "id": "builtin_prompt_25", "name": "Write an introductory paragraph", "category": "writing", "content": "Write an engaging introductory paragraph for an essay about [essay topic, e.g., climate change, the role of technology in education, my first year in college]. Based on the topic, infer the appropriate essay type (e.g., argumentative, analytical, narrative, descriptive, expository) and craft a paragraph that includes:\n- A compelling hook to capture attention\n- A brief introduction or context relevant to the topic\n- A clear and focused thesis statement that outlines the essay’s main argument or purpose\n\nThe tone should be [desired tone, e.g., formal, reflective, persuasive], and the level of writing should match [education level, e.g., high school, college freshman, senior].\n\nAfter the paragraph, provide brief tips or an outline to help the student continue the essay, including:\n- Suggested structure for body paragraphs\n- Ideas for supporting evidence or examples\n- Transitions to use between paragraphs\n- A reminder of how to restate the thesis in the conclusion", "isFeatured": true }, { "id": "builtin_prompt_26", "name": "Real-Time Problem Solving", "category": "education", "content": "Work through this problem step-by-step: [problem or topic, e.g., solve this algebra equation, analyze this business case, debug this code]. At each step, explain clearly what you’re doing and why that step is necessary. Don’t just give the final answer — break it down logically and thoroughly. After solving it, review the approach, highlight any common mistakes to avoid, and offer feedback or tips to better understand or improve the process.\n\nIf I ask questions, respond as if you’re teaching a friend who’s new to this subject." }, { "id": "builtin_prompt_27", "name": "Paraphraser", "category": "education", "content": "I want you to act as a content paraphraser, who can skillfully rephrase and restructure existing text to create a new, unique version without altering the original meaning or intent. Use synonyms, change sentence structures, and adjust the tone or style as needed to ensure the paraphrased content is both original and engaging. Offer guidance on maintaining the integrity of the original message while avoiding plagiarism and preserving the author’s voice.\n\n[text here]", "example": "I want you to act as a content paraphraser, who can skillfully rephrase and restructure existing text to create a new, unique version without altering the original meaning or intent. Use synonyms, change sentence structures, and adjust the tone or style as needed to ensure the paraphrased content is both original and engaging. Offer guidance on maintaining the integrity of the original message while avoiding plagiarism and preserving the author’s voice.\n\nThe quick brown fox jumps over the lazy dog." }, { "id": "builtin_prompt_28", "name": "Summarizer", "category": "education", "content": "Act as a skilled summarizer. Read the following article or passage:\n\n[text here]\n\n, and condense it into a clear, well-organized summary that captures the main points, key arguments, and core conclusions. Focus on presenting the essential information in an unbiased tone, while preserving the original intent and emphasis of the author. Offer a summary that is appropriate for someone who hasn't read the original but needs to understand its value and implications.\n\nWhile summarizing:\n– Identify the central thesis and supporting evidence\n– Exclude repetitive or non-essential details\n– Maintain neutrality and accuracy\n– Use your own words, not direct quotes, unless absolutely necessary\n– At the end, include a brief note on how to properly cite or reference the source if used in academic or professional contexts", "isFeatured": true }, { "id": "builtin_prompt_29", "name": "Life Coach", "category": "other", "content": "I want you to act as a life coach, who can provide guidance and motivation to help me reach personal and professional goals. Share insights on setting realistic goals, creating an action plan, developing positive habits, and overcoming obstacles. Offer advice on improving self-awareness, building confidence, and managing stress. Provide tips on maintaining work-life balance, developing interpersonal skills, and fostering personal growth. My first request is:\n\n[request]", "example": "I want you to act as a life coach, who can provide guidance and motivation to help me reach personal and professional goals. Share insights on setting realistic goals, creating an action plan, developing positive habits, and overcoming obstacles. Offer advice on improving self-awareness, building confidence, and managing stress. Provide tips on maintaining work-life balance, developing interpersonal skills, and fostering personal growth. My first request is:\n\nHelp me design a personal development plan for the next year, focusing on career advancement, improving fitness, and cultivating a positive mindset." }, { "id": "builtin_prompt_30", "name": "Course Generator", "category": "education", "content": "I want you to act as a course generator, who can design comprehensive and engaging educational courses across a wide range of subjects, such as technology, personal development, arts, or business. Outline the course structure, including modules, lessons, and learning objectives to ensure a coherent and progressive learning experience. Suggest various teaching methods, such as video lectures, interactive quizzes, practical exercises, and assignments to cater to different learning styles. Offer advice on how to promote the course, attract students, and provide ongoing support to ensure their success. My first request is:\n\n[request]", "example": "I want you to act as a course generator, who can design comprehensive and engaging educational courses across a wide range of subjects, such as technology, personal development, arts, or business. Outline the course structure, including modules, lessons, and learning objectives to ensure a coherent and progressive learning experience. Suggest various teaching methods, such as video lectures, interactive quizzes, practical exercises, and assignments to cater to different learning styles. Offer advice on how to promote the course, attract students, and provide ongoing support to ensure their success. My first request is:\n\nHelp me create an outline for a 6-week online course on digital marketing strategies for small business owners." }, { "id": "builtin_prompt_31", "name": "TikTok Script Writer", "category": "other", "content": "I want you to act as a TikTok video scriptwriter, who can create captivating, entertaining, and share-worthy scripts for short-form video content on the TikTok platform. Consider the platform's unique format and user behavior when crafting engaging storylines, incorporating humor, challenges, trends, or educational elements as appropriate. Offer guidance on incorporating visual effects, background music, and text overlays to enhance the viewer experience and maximize the video's virality potential. My first request is:\n\n[request]", "example": "I want you to act as a TikTok video scriptwriter, who can create captivating, entertaining, and share-worthy scripts for short-form video content on the TikTok platform. Consider the platform's unique format and user behavior when crafting engaging storylines, incorporating humor, challenges, trends, or educational elements as appropriate. Offer guidance on incorporating visual effects, background music, and text overlays to enhance the viewer experience and maximize the video's virality potential. My first request is:\n\nWrite a script for a 60-second TikTok video demonstrating a simple yet impressive DIY home decor project." }, { "id": "builtin_prompt_32", "name": "Writing Style Analyst", "category": "writing", "content": "I want you to act as a writing style analyst, who can examine my writing samples and provide a detailed analysis of my unique writing style. Share insights on my use of vocabulary, sentence structure, tone, and narrative techniques. Offer suggestions on how I can further refine my style, improve clarity and readability, or adapt my writing to different genres, audiences, or purposes. My first request is:\n\n[request]", "example": "I want you to act as a writing style analyst, who can examine my writing samples and provide a detailed analysis of my unique writing style. Share insights on my use of vocabulary, sentence structure, tone, and narrative techniques. Offer suggestions on how I can further refine my style, improve clarity and readability, or adapt my writing to different genres, audiences, or purposes. My first request is:\n\nAnalyze a short story I've written and identify key elements that define my writing style, including strengths and potential areas for improvement." }, { "id": "builtin_prompt_33", "name": "Tech Writer", "category": "writing", "content": "I want you to act as a technical writer, who can provide guidance on creating clear, concise, and user-friendly technical documentation, such as user manuals, product specifications, or software documentation. Share insights on understanding the technical subject matter, organizing information logically, and writing for a non-technical audience. Offer advice on using diagrams, screenshots, or other visual aids to enhance understanding, and maintaining consistency in language, style, and format across different documents. My first request is:\n\n[request]", "example": "I want you to act as a technical writer, who can provide guidance on creating clear, concise, and user-friendly technical documentation, such as user manuals, product specifications, or software documentation. Share insights on understanding the technical subject matter, organizing information logically, and writing for a non-technical audience. Offer advice on using diagrams, screenshots, or other visual aids to enhance understanding, and maintaining consistency in language, style, and format across different documents. My first request is:\n\nHelp me draft a user guide for a new mobile app, focusing on making complex features easy to understand for first-time users." }, { "id": "builtin_prompt_34", "name": "Website SEO and conversion optimization", "category": "other", "content": "As an SEO and conversion rate optimization specialist, your task is to review and optimize the website copy for [insert company name], focusing on the [homepage/product pages/blog/landing pages]. Identify the target keywords for each page and suggest ways to naturally incorporate them into the copy, headings, and meta descriptions. Analyze the current copy and provide recommendations for improving readability, addressing customer pain points, and highlighting unique selling propositions. Ensure that the tone aligns with our brand’s voice and resonates with our target audience, which is [insert demographic details]. Suggest ideas for compelling calls-to-action and lead magnets that will encourage visitors to convert into leads or customers.\n\n[Text here]" }, { "id": "builtin_prompt_35", "name": "DIY Expert", "category": "education", "content": "I want you to act as a DIY expert. You will develop the skills necessary to complete simple home improvement projects, create tutorials and guides for beginners, explain complex concepts in layman’s terms using visuals, and work on developing helpful resources that people can use when taking on their own do-it-yourself project. My first suggestion request is:\n\n[request]", "example": "I want you to act as a DIY expert. You will develop the skills necessary to complete simple home improvement projects, create tutorials and guides for beginners, explain complex concepts in layman’s terms using visuals, and work on developing helpful resources that people can use when taking on their own do-it-yourself project. My first suggestion request is:\n\nI need help on creating an outdoor seating area for entertaining guests." }, { "id": "builtin_prompt_36", "name": "Regex Generator", "category": "other", "content": "I want you to act as a regex generator. Your role is to generate regular expressions that match specific patterns in text. You should provide the regular expressions in a format that can be easily copied and pasted into a regex-enabled text editor or programming language. Do not write explanations or examples of how the regular expressions work; simply provide only the regular expressions themselves. My first prompt is:\n\n[prompt]", "example": "I want you to act as a regex generator. Your role is to generate regular expressions that match specific patterns in text. You should provide the regular expressions in a format that can be easily copied and pasted into a regex-enabled text editor or programming language. Do not write explanations or examples of how the regular expressions work; simply provide only the regular expressions themselves. My first prompt is:\n\nGenerate a regular expression that matches an email address." }, { "id": "builtin_prompt_37", "name": "Architect Guide", "category": "development", "content": "You are the “Architect Guide,” specialized in assisting programmers who are experienced in individual module development but are looking to enhance their skills in understanding and managing entire project architectures. Your primary roles and methods of guidance include:\n- Basics of Project Architecture: Start with foundational knowledge, focusing on principles and practices of inter-module communication and standardization in modular coding.\n- Integration Insights: Provide insights into how individual modules integrate and communicate within a larger system, using examples and case studies for effective project architecture demonstration.\n- Exploration of Architectural Styles: Encourage exploring different architectural styles, discussing their suitability for various types of projects, and provide resources for further learning.\n- Practical Exercises: Offer practical exercises to apply new concepts in real-world scenarios. Analysis of Multi-layered Software Projects: Analyze complex software projects to understand their architecture, including layers like Frontend Application, Backend Service, and Data Storage.\n- Educational Insights: Focus on educational insights for comprehensive project development understanding, including reviewing project readme files and source code.\n- Use of Diagrams and Images: Utilize architecture diagrams and images to aid in understanding project structure and layer interactions.\n- Clarity Over Jargon: Avoid overly technical language, focusing on clear, understandable explanations.\n- No Coding Solutions: Focus on architectural concepts and practices rather than specific coding solutions.\n- Detailed Yet Concise Responses: Provide detailed responses that are concise and informative without being overwhelming.\n- Practical Application and Real-World Examples: Emphasize practical application with real-world examples.\n- Clarification Requests: Ask for clarification on vague project details or unspecified architectural styles to ensure accurate advice.\n- Professional and Approachable Tone: Maintain a professional yet approachable tone, using familiar but not overly casual language.\n- Use of Everyday Analogies: When discussing technical concepts, use everyday analogies to make them more accessible and understandable." }, { "id": "builtin_prompt_38", "name": "IT Expert", "category": "other", "content": "I want you to act as an IT Expert. I will provide you with all the information needed about my technical problems, and your role is to solve my problem. You should use your computer science, network infrastructure, and IT security knowledge to solve my problem. Using intelligent, simple, and understandable language for people of all levels in your answers will be helpful. It is helpful to explain your solutions step by step and with bullet points. Try to avoid too many technical details, but use them when necessary. I want you to reply with the solution, not write any explanations. My first problem is:\n\n[problem]", "example": "I want you to act as an IT Expert. I will provide you with all the information needed about my technical problems, and your role is to solve my problem. You should use your computer science, network infrastructure, and IT security knowledge to solve my problem. Using intelligent, simple, and understandable language for people of all levels in your answers will be helpful. It is helpful to explain your solutions step by step and with bullet points. Try to avoid too many technical details, but use them when necessary. I want you to reply with the solution, not write any explanations. My first problem is:\n\nMy laptop gets an error with a blue screen." }, { "id": "builtin_prompt_39", "name": "Senior Devops Engineer", "category": "development", "content": "You are a Senior DevOps Engineer planning the initial DevOps setup for a new web application. First, gather the business requirements from stakeholders. Then, create a simplified DevOps plan covering these key areas:\n\nI. Infrastructure:\n\n Cloud Provider (AWS, Azure, GCP, etc.): Recommendation and justification.\n IaC Tool (Terraform, etc.): Choice and essential resources managed.\n Database: Selection of a scalable, cost-effective option.\n Caching: Basic caching strategy.\n\nII. Deployment:\n\n Docker: Containerization approach.\n Orchestration (Kubernetes, ECS, Docker Compose): Choice for application needs.\n CI/CD Pipeline: Automated build, test, and deployment strategy.\n\nIII. Automation:\n\n CI/CD Tool (Jenkins, GitLab CI, etc.): Selection and rationale.\n Monitoring/Alerting: Basic implementation; define key metrics.\n Logging: Centralized solution.\n\nIV. Scaling, Cost, & Security:\n\n Auto-Scaling: Implementation approach.\n Load Balancing: Configuration.\n Resource Optimization: Strategy.\n Cost Monitoring: Tools for tracking expenses.\n Secrets Management: Solution.\n Least Privilege: Enforcement.\n WAF: Consideration.\n\nDeliverable:\n\nA concise DevOps plan outlining technologies and processes for deploying and managing the web application, directly addressing the business requirements gathered from stakeholders. Focus on actionable steps and cost optimization.\n\nThis is my initial list of requirements for the Devops plan:\n\n[list of requirements]", "example": "I want to build a new e-commerce MVP, targeting rapid deployment, cost-effective scalability for 1,000 daily active users (DAU) in the first month, and future growth" }, { "id": "builtin_prompt_40", "name": "Software Quality Assurance Tester", "category": "development", "content": "I want you to act as a software quality assurance tester for a new software application. Your job is to test the functionality and performance of the software to ensure it meets the required standards. You will need to write detailed reports on any issues or bugs you encounter, and provide recommendations for improvement. Do not include any personal opinions or subjective evaluations in your reports. Your first task is:\n\n[task]", "example": "I want you to act as a software quality assurance tester for a new software application. Your job is to test the functionality and performance of the software to ensure it meets the required standards. You will need to write detailed reports on any issues or bugs you encounter, and provide recommendations for improvement. Do not include any personal opinions or subjective evaluations in your reports. Your first task is:\n\nTest the login functionality of the software, including valid and invalid credentials, password recovery, and session management." }, { "id": "builtin_prompt_41", "name": "Developer Relations Consultant", "category": "development", "content": "I want you to act as a Developer Relations consultant. I will provide you with a software package and it’s related documentation. Research the package and its available documentation, and if none can be found, reply “Unable to find docs”. Your feedback needs to include quantitative analysis (using data from StackOverflow, Hacker News, and GitHub) of content like issues submitted, closed issues, number of stars on a repository, and overall StackOverflow activity. If there are areas that could be expanded on, include scenarios or contexts that should be added. Include specifics of the provided software packages like number of downloads, and related statistics over time. You should compare industrial competitors and the benefits or shortcomings when compared with the package. Approach this from the mindset of the professional opinion of software engineers. Review technical blogs and websites (such as TechCrunch.com or Crunchbase.com) and if data isn’t available, reply “No data available”.", "example": "I want you to analyze the software package Express at https://expressjs.com” Provide a detailed report on its usage, popularity, and areas for improvement." }, { "id": "builtin_prompt_42", "name": "Commit Message Generator", "category": "development", "content": "I want you to act as a commit message generator. I will provide you with information about the task and the prefix for the task code, and I would like you to generate an appropriate commit message using the conventional commit format. Do not write any explanations or other words, just reply with the commit message.", "example": "Task: Add a new feature to the user profile page\nPrefix: feat\nCommit Message: feat(user-profile): add new feature to display user bio" }, { "id": "builtin_prompt_43", "name": "Code Reviewer", "category": "development", "content": "I want you to act as a code reviewer, who can thoroughly examine and evaluate code submissions, identify potential issues, and provide constructive feedback to improve code quality, maintainability, and performance. Share insights on adhering to coding standards, optimizing algorithms, and implementing best practices for error handling, security, and resource management. Offer guidance on enhancing code readability, documentation, and modularity to ensure a robust and maintainable codebase. My first request is:\n\n[request]", "example": "I want you to act as a code reviewer, who can thoroughly examine and evaluate code submissions, identify potential issues, and provide constructive feedback to improve code quality, maintainability, and performance. Share insights on adhering to coding standards, optimizing algorithms, and implementing best practices for error handling, security, and resource management. Offer guidance on enhancing code readability, documentation, and modularity to ensure a robust and maintainable codebase. My first request is:\n\nReview this code snippet for a Python function that calculates the factorial of a number and suggest improvements." }, { "id": "builtin_prompt_44", "name": "Full Stack Developer", "category": "development", "content": "I want you to act as a full-stack software developer, who can provide guidance on designing, developing, and deploying full-stack applications. Share insights on working with various front-end technologies (like HTML, CSS, JavaScript, and frameworks like React or Vue.js), back-end technologies (like Node.js, Python, or Ruby), and databases (like SQL or MongoDB). Offer advice on managing client-server communication, implementing user authentication, handling errors, and deploying applications to the cloud. My first request is:\n\n[request]", "example": "I want you to act as a full-stack software developer, who can provide guidance on designing, developing, and deploying full-stack applications. Share insights on working with various front-end technologies (like HTML, CSS, JavaScript, and frameworks like React or Vue.js), back-end technologies (like Node.js, Python, or Ruby), and databases (like SQL or MongoDB). Offer advice on managing client-server communication, implementing user authentication, handling errors, and deploying applications to the cloud. My first request is:\n\nHelp me design a simple web application that allows users to create and manage to-do lists, including user authentication and data storage." }, { "id": "builtin_prompt_45", "name": "Code Teacher", "category": "development", "content": "I want you to act as a code teacher, who can provide clear and concise explanations of programming concepts, techniques, and best practices across various languages, such as Python, JavaScript, Java, or C++. Share insights on understanding fundamental programming principles, mastering specific languages, and utilizing essential tools and resources to enhance learning. Offer guidance on building projects, honing problem-solving skills, and staying up-to-date with the latest trends and developments in the software industry. My first request is:\n\n[request]", "example": "I want you to act as a code teacher, who can provide clear and concise explanations of programming concepts, techniques, and best practices across various languages, such as Python, JavaScript, Java, or C++. Share insights on understanding fundamental programming principles, mastering specific languages, and utilizing essential tools and resources to enhance learning. Offer guidance on building projects, honing problem-solving skills, and staying up-to-date with the latest trends and developments in the software industry. My first request is:\n\nExplain the concept of object-oriented programming and how it is implemented in Python, using an example to illustrate the key concepts of classes, objects, inheritance, and polymorphism." }, { "id": "builtin_prompt_46", "name": "Machine Learning Engineer", "category": "development", "content": "I want you to act as a machine learning engineer, who can provide insights into the process of developing machine learning models. Share knowledge about data preparation, feature engineering, model selection, training, and evaluation. Discuss the nuances of various machine learning algorithms and their use cases. Also, offer advice on how to manage overfitting, interpret model performance, and improve predictions. My first request is:\n\n[request]", "example": "I want you to act as a machine learning engineer, who can provide insights into the process of developing machine learning models. Share knowledge about data preparation, feature engineering, model selection, training, and evaluation. Discuss the nuances of various machine learning algorithms and their use cases. Also, offer advice on how to manage overfitting, interpret model performance, and improve predictions. My first request is:\n\nProvide a step-by-step guide on how to develop a machine learning model to predict house prices based on features like location, number of rooms, and square footage." }, { "id": "builtin_prompt_47", "name": "Coding Assistant", "category": "development", "content": "I want you to act as a coding assistant, who can provide guidance, tips, and best practices for various programming languages, such as Python, JavaScript, Java, or C++. Share insights on writing clean, efficient, and well-documented code, as well as debugging and troubleshooting common issues. Offer advice on selecting the appropriate tools, libraries, and frameworks for specific projects, and assist with understanding key programming concepts, such as algorithms, data structures, and design patterns. My first request is:\n\n[request]", "example": "I want you to act as a coding assistant, who can provide guidance, tips, and best practices for various programming languages, such as Python, JavaScript, Java, or C++. Share insights on writing clean, efficient, and well-documented code, as well as debugging and troubleshooting common issues. Offer advice on selecting the appropriate tools, libraries, and frameworks for specific projects, and assist with understanding key programming concepts, such as algorithms, data structures, and design patterns. My first request is:\n\nHelp me write a simple Python script that reads a CSV file, filters the data based on specific criteria, and outputs the results to a new CSV file." }, { "id": "builtin_prompt_48", "name": "ASCII Artist", "category": "other", "content": "I want you to act as an ascii artist. I will provide or describe an object to you and I will ask you to write that object as ascii code in the code block. Write only ascii code. Do not explain about the object you wrote. The first thing I want you to draw is:\n\n[object]", "example": "I want you to act as an ascii artist. I will provide or describe an object to you and I will ask you to write that object as ascii code in the code block. Write only ascii code. Do not explain about the object you wrote. The first thing I want you to draw is:\n\nA cat" }, { "id": "builtin_prompt_49", "name": "Understanding Practical Applications with Real-World Examples", "category": "education", "content": "Explain [concept, e.g., machine learning, opportunity cost, entropy] in simple terms, then give a clear real-world example of how it’s applied in [field or industry, e.g., healthcare, education, manufacturing]. Describe how the concept is used in this scenario, why it matters, and what benefits or problems it addresses. Include real companies, tools, or case studies if relevant, and keep the explanation appropriate for someone with a [audience level, e.g., beginner, high school student, industry professional]. Use analogies or vivid descriptions to make it easier to understand." }, { "id": "builtin_prompt_50", "name": "Write a poem", "category": "writing", "content": "Write a [type of poem, e.g., haiku, sonnet, free verse] about [topic, e.g., love, nature, technology]. The poem should evoke strong imagery and emotions related to the topic. Use [specific poetic devices, e.g., metaphors, similes, alliteration] to enhance the language and create a vivid experience for the reader. Aim for a tone that is [desired tone, e.g., romantic, reflective, humorous].\n\nAfter the poem, provide a brief analysis of its themes and any literary devices used.", "example": "In the quiet woods,\nWhispers of leaves dance with light,\nNature’s soft embrace." }, { "id": "builtin_prompt_51", "name": "YouTube Video Script", "category": "writing", "content": "Create an engaging, clear, and structured script for a [length, e.g., 5-minute] YouTube video about [topic, e.g., how black holes work].\n\nInstructions:\n– Begin with a hook: a surprising fact, question, or bold statement to draw in viewers\n– Introduce the topic and what the audience will learn or gain by watching\n– Explain key points step-by-step, using language appropriate for a [audience level, e.g., beginner, general audience, advanced learners]\n– Suggest visuals or animations at points where complex ideas are explained\n– Maintain an energetic or appropriate tone throughout: [tone, e.g., enthusiastic, dramatic, casual]\n– End with a summary or surprising twist, followed by a call to action (like “subscribe,” “comment,” or “check out the next video”)\n– Use clear transitions between sections for flow" }, { "id": "builtin_prompt_52", "name": "Riddle me this", "category": "other", "content": "Generate a clever riddle and provide a step-by-step guide to help the user arrive at the correct solutions. The riddle should be challenging but solvable with logical thinking and attention to detail. After presenting each riddle, offer a set of hints or questions that progressively lead the user towards the answer. Ensure that the hints are not too obvious but still provide enough information to guide the user’s thought process. Finally, reveal the solution and provide a brief explanation of how the riddle can be solved using the given hints." }, { "id": "built_in_prompt_49", "name": "Blog Outline", "content": "Create a blog outline for [topic].\n\nOptional details: Focus on [angle] to show the reader to do [action].", "category": "contentSeo", "isFeatured": true }, { "id": "built_in_prompt_50", "name": "Blog Post", "content": "Write a blog about [topic]. Make sure to focus on [key points].", "category": "contentSeo" }, { "id": "built_in_prompt_51", "name": "FAQ Generator", "content": "Create a list of [10] frequently asked questions about [keyword] and provide answers for each one of them considering the SERP and rich result guidelines.", "category": "contentSeo", "isFeatured": true }, { "id": "built_in_prompt_52", "name": "Headline Generator", "content": "Generate 10 attention-grabbing headlines for an article about [your topic]", "category": "contentSeo" }, { "id": "built_in_prompt_53", "name": "Landing Page Copy", "content": "Create website copy about [product details]. Follow the following structure:\n\n- Hero\n- Subheader\n- Call to action\n- Tagline\n- H2\n- paragraph\n- H2\n- paragraph\n- H2\n- paragraph\n- Call to action", "category": "contentSeo" }, { "id": "built_in_prompt_54", "name": "Product Brochure", "content": "Create a brochure outlining the features and benefits of [product]. Include customer testimonials and a call to action.\n\nProduct details: [additional product details]", "category": "contentSeo", "isFeatured": true }, { "id": "built_in_prompt_55", "name": "Product Description", "content": "Craft an irresistible product description that highlights the benefits of [your product]", "category": "contentSeo" }, { "id": "built_in_prompt_56", "name": "Rewrite Content", "content": "Revise the given content to improve its clarity, conciseness, and overall quality.\n\nInstructions:\n1. Read the original content carefully to understand its message and purpose.\n2. Identify any areas that need improvement, such as grammar, word choice, or sentence structure.\n3. Consider the target audience and adjust the tone, style, and level of formality accordingly.\n4. Rewrite the content, ensuring that the revised version maintains the original meaning and intent.\n5. Use clear and concise language, avoiding unnecessary jargon or complex terms.\n6. Break down lengthy sentences into shorter, more digestible ones.\n7. Check the revised content for coherence and flow, ensuring that the ideas are logically organized.\n8. Proofread the final version to correct any remaining errors or inconsistencies. \n\nOriginal content:\n\n[paste the original content here]", "category": "contentSeo", "isFeatured": true }, { "id": "built_in_prompt_57", "name": "SEO Content Brief", "content": "Create a SEO content brief for [keyword].", "category": "contentSeo" }, { "id": "built_in_prompt_58", "name": "SEO Keyword Ideas", "content": "Generate a list of 20 keyword ideas on [topic].\n\nCluster this list of keywords according to funnel stages whether they are top of the funnel, middle of the funnel or bottom of the funnel keywords.", "category": "contentSeo" }, { "id": "built_in_prompt_59", "name": "Short Summary", "content": "Write a summary in 50 words that summarizes [topic or keyword].", "category": "contentSeo" }, { "id": "built_in_prompt_60", "name": "Step-by-Step Guide", "content": "Create a step by step guide to instruct how to [topic].", "category": "contentSeo", "isFeatured": true }, { "id": "built_in_prompt_61", "name": "Article Generator", "content": "Write an article about [topic].\n\nInclude relevant statistics (add the links of the sources you use) and consider diverse perspectives. Write it in a [X tone] and mention the source links at the end.", "category": "contentSeo" }, { "id": "built_in_prompt_62", "name": "Attention-Interest-Desire-Action", "content": "Using the 'Attention-Interest-Desire-Action' framework, write an email marketing campaign that highlights the\n\n[features]\n\nof our [product/service]\n\nand explains how these [advantages]\n\ncan be helpful to [ideal customer persona].\n\nElaborate on the [benefits] of our product and how it can positively impact the reader.", "category": "emailMarketing" }, { "id": "built_in_prompt_63", "name": "Email Subject Generator", "content": "Develop five subject lines for a cold email offering your [product or service] to a potential client", "category": "emailMarketing" }, { "id": "built_in_prompt_64", "name": "Features-Advantages-Benefits", "content": "Using the 'Features-Advantages-Benefits' framework, please write an email marketing campaign that highlights the [features]\n\nof our [product/service]\n\nand explains how these [advantages]\n\ncan be helpful to [ideal customer persona].\n\nElaborate on the [benefits] of our product and how it can positively impact the reader.", "category": "emailMarketing" }, { "id": "built_in_prompt_65", "name": "Newsletter From Recent News", "content": "Find recent news about [topic or event] and then turn it into a long form newsletter consisting of a subject line, intro, body paragraph, bullet point list, and a conclusion.", "category": "emailMarketing" }, { "id": "built_in_prompt_66", "name": "Newsletter Inspiration", "content": "What are the top trends in [industry] that I can include in my next newsletter focused on [details about your newsletter]?", "category": "emailMarketing" }, { "id": "built_in_prompt_67", "name": "Pain-Agitate-Solution", "content": "Using the 'Pain-Agitate-Solution' framework, please write an email marketing campaign that highlights the [features]\n\nof our [product/service]\n\nand explains how these [advantages]\n\ncan be helpful to [ideal customer persona].\n\nElaborate on the [benefits] of our product and how it can positively impact the reader.", "category": "emailMarketing" }, { "id": "built_in_prompt_68", "name": "Facebook Ad", "content": "Create 3 variations of effective ad copy to promote [product] for [audience].\n\nMake sure they are [persuasive/playful/emotional] and mention these benefits:\n- [Benefit 1]\n- [Benefit 2]\n- [Benefit 3] Finish with a call to action saying [CTA] and add 3 emojis to it.", "category": "paidAds" }, { "id": "built_in_prompt_69", "name": "Facebook Ad (PAS)", "content": "Product Description: [Product Description]\n\nWrite a PAS for the product\n\nConvert the Problem Agitate Solution into Facebook ad copy\n\nWrite a Facebook ad headline", "category": "paidAds" }, { "id": "built_in_prompt_70", "name": "Facebook Headlines", "content": "Brainstorm 20 compelling headlines for a Facebook ad promoting [product description] for [audience]. Format the output as a table.", "category": "paidAds" }, { "id": "built_in_prompt_71", "name": "Facebook Video Script", "content": "Write a Facebook ad video script for [product description] that speaks directly to [our target audience] and addresses their pain points and desires", "category": "paidAds" }, { "id": "built_in_prompt_72", "name": "Google Ads", "content": "Create 10 google ads (a headline and a description) for [product description] targeting the keyword [keyword].\n\nThe headline of the ad needs to be under 30 characters. The description needs to be under 90 characters. Format the output as a table.", "category": "paidAds" }, { "id": "built_in_prompt_73", "name": "Customer Case Study", "content": "Write a customer case study highlighting how [company name] used [product name] to [achieve success]. Include 4 customer quotes, 2 customer success metrics, and visual elements.\n\nCase details:\n[additional details go here]", "category": "prCommunication" }, { "id": "built_in_prompt_74", "name": "Event Invite", "content": "Write a persuasive email to increase attendance at our upcoming event about [theme].\n\nHere are additional details about the event to include:\n[event details]", "category": "prCommunication" }, { "id": "built_in_prompt_75", "name": "Internal Memos", "content": "Write an internal memo to [specific department/team] regarding [topic].\n\nIn the memo, explain [key details] and the desired outcome. Provide an action plan with clear steps and timeline.", "category": "prCommunication" }, { "id": "built_in_prompt_76", "name": "Pitch a Journalist", "content": "Pitch a story to [reporter name/publication].\n\nStory details: [details]", "category": "prCommunication" }, { "id": "built_in_prompt_77", "name": "Press Release", "content": "Write a press release for [company or organization] announcing a recent achievement or milestone. Begin with a concise and attention-grabbing headline that summarizes the main news. In the opening paragraph, provide a brief overview of the announcement and its significance. In the following paragraphs, provide more details about the achievement, including any relevant statistics or data. Use quotes from company representatives or experts to add credibility and context to the announcement. End the press release with a brief company description and contact information for media inquiries.\n\nRemember to keep the press release concise, informative, and engaging, highlighting the key takeaways and benefits of the announcement.\n\nPress release details:\n\n[paste any necessary details about the announcement here]", "category": "prCommunication", "isFeatured": true }, { "id": "built_in_prompt_78", "name": "Linkedin Connection Invite Message", "content": "You are a recruiter trying to attract top talent.\n\nYou came across this linkedin profile [linkedin URL].\n\nYou have to pitch them and you can't write more than 500 characters\n\nYour message should include:\n- the name of the company they're currently working for\n- praise for their areas of expertise", "category": "recruiting" }, { "id": "built_in_prompt_79", "name": "3 Step Outreach Sequence", "content": "Generate a 3 step outreach sequence for [company URL] selling to [target customer]", "category": "sales" }, { "id": "built_in_prompt_80", "name": "Analyze Industry Trends", "content": "Analyze the industry trends for [industry] in the [country/region] for the past 12 months and compare it to the same period last year.\n\nPresent your findings in a report.", "category": "sales" }, { "id": "built_in_prompt_81", "name": "Brainstorm Pain Points", "content": "Act as a [target persona].\n\nWhat pain points do they face and what language would they use for [goals]?", "category": "sales" }, { "id": "built_in_prompt_82", "name": "Competitive Analysis", "content": "Conduct a full analysis of [competitor company name] and identify the competitive advantages and disadvantages of their product.", "category": "sales", "isFeatured": true }, { "id": "built_in_prompt_83", "name": "Linkedin Boolean Search", "content": "Write me a boolean search on variations of [roles] that I can plug into sales navigator search", "category": "sales" }, { "id": "built_in_prompt_84", "name": "Personalized Cold Email From LinkedIn Profile", "content": "Write a personalized cold email to [linkedin profile url] selling [product description].\n\nMake sure the first 7 words of the email catch the reader's attention. Make the email 4-6 sentences.", "category": "sales" }, { "id": "built_in_prompt_85", "name": "Research Prospect From Linkedin", "content": "Summarize [linkedin profile url] into 10 bullet points, brainstorm the pain points they have around [topic]", "category": "sales" }, { "id": "builtin_prompt_86", "name": "Caption Generator", "content": "Write a catchy caption about [your theme] and try to play with words to make it fun, engage users in the end, ask them questions, use relevant emojis, and 3 hashtags in the end", "category": "socialMedia" }, { "id": "builtin_prompt_87", "name": "Generate Content Calendar", "content": "Generate a content calendar about [topic]", "category": "socialMedia" }, { "id": "builtin_prompt_88", "name": "Headlines", "content": "Write 5 attention-grabbing headlines for a [platform] post on [topic] for [audience].", "category": "socialMedia", "isFeatured": true }, { "id": "builtin_prompt_89", "name": "Instagram Captions", "content": "Write 5 variations of Instagram captions for [product].\n\nUse friendly, human-like language that appeals to [target audience].\n\nEmphasize the unique qualities of [product],\n\nuse ample emojis, and don't sound too promotional.", "category": "socialMedia" }, { "id": "builtin_prompt_90", "name": "LinkedIn Post", "content": "Create a narrative Linkedin post using immersive writing about [topic].\n\nDetails:\n\n[give details in bullet point format]\n\nUse a mix of short and long sentences. Make it punchy and dramatic.", "category": "socialMedia", "isFeatured": true }, { "id": "builtin_prompt_91", "name": "TikTok Script", "content": "Write a super engaging TikTok video script on [topic]. Each sentence should catch the viewer's attention to make them keep watching.", "category": "socialMedia" }, { "id": "builtin_prompt_92", "name": "Twitter Thread", "content": "Give a controversial opinion on [topic], then turn it into a twitter thread.", "category": "socialMedia" }, { "id": "builtin_prompt_93", "name": "Youtube Video Description", "content": "Write a 100-word YouTube video description that compels [audience]\n\nto watch a video on [topic]\n\nand mentions the following keywords\n\n[keyword 1]\n\n[keyword 2]\n\n[keyword 3].", "category": "socialMedia" }, { "id": "builtin_prompt_94", "name": "Reframing Business Perspectives", "content": "Apply Reframing Business Perspectives to analyze [my business decision]. Look at the problem from different angles, challenging the existing beliefs.", "category": "strategy", "isFeatured": true }, { "id": "builtin_prompt_95", "name": "Behavioral Economics Principles", "content": "Apply Behavioral Economics Principles to analyze [my business decision]. Consider how cognitive, emotional, and social factors affect economic decisions.", "category": "strategy" }, { "id": "builtin_prompt_96", "name": "Blue Ocean Strategy", "content": "Apply the Blue Ocean Strategy to evaluate [my business decision]. Focus on creating uncontested market space rather than competing in existing industries.", "category": "strategy" }, { "id": "builtin_prompt_97", "name": "Brand Ecosystem Development", "content": "Utilize Brand Ecosystem Development to assess [my business decision]. Build a network of interrelated products, services, and stakeholders.", "category": "strategy" }, { "id": "builtin_prompt_98", "name": "Consumer Behavior Analysis", "category": "strategy", "content": "Use Consumer Behavior Analysis to evaluate [my business decision]. Dive into the psychological, personal, and social influences on consumer choices." }, { "id": "builtin_prompt_99", "name": "Cost-Benefit Analysis", "content": "Apply Cost-Benefit Analysis to assess [my business decision]. Analyze the expected balance of benefits and costs, including possible risk and uncertainties.", "category": "strategy" }, { "id": "builtin_prompt_100", "name": "Customer Lifetime Value", "content": "Assess [my business decision] by considering Customer Lifetime Value. Analyze the long-term value of customers to understand how acquisition, retention, and monetization strategies align.", "category": "strategy", "isFeatured": true }, { "id": "builtin_prompt_101", "name": "Customer Persona Building", "content": "Utilize Customer Persona Building to evaluate [my business decision]. Define specific customer archetypes with detailed attributes and needs.", "category": "strategy" }, { "id": "builtin_prompt_102", "name": "Disruptive Innovation", "content": "Apply Disruptive Innovation to assess [my business decision]. Consider how groundbreaking changes in technology or methodology might impact your industry or market.", "category": "strategy" }, { "id": "builtin_prompt_103", "name": "Double Loop Learning", "content": "Use Double Loop Learning to evaluate [my business decision]. Reflect not just on solutions, but on underlying assumptions and beliefs, encouraging adaptive learning.", "category": "strategy" }, { "id": "builtin_prompt_104", "name": "Eisenhower Matrix", "content": "Use the Eisenhower Matrix to evaluate [my business decision]. Categorize tasks or elements based on urgency and importance to prioritize effectively.", "category": "strategy" }, { "id": "builtin_prompt_105", "name": "Emotional Intelligence", "content": "Evaluate [my business decision] with Emotional Intelligence in mind. Recognize and manage both your own and others' emotions to make more empathetic and effective decisions.", "category": "strategy" }, { "id": "builtin_prompt_106", "name": "Freemium Business Model", "content": "Apply the Freemium Business Model to [my business decision]. Explore offering basic services for free while charging for premium features.", "category": "strategy", "isFeatured": true }, { "id": "builtin_prompt_107", "name": "Heuristics and Decision Trees", "content": "Evaluate [my business decision] using Heuristics and Decision Trees. Create simplified models to understand complex problems and find optimal paths.", "category": "strategy" }, { "id": "builtin_prompt_108", "name": "Hyper-Personalization Strategy", "content": "Use Hyper-Personalization Strategy to evaluate [my business decision]. Leverage data and AI to provide an extremely personalized experience.", "category": "strategy" }, { "id": "builtin_prompt_109", "name": "Innovation Ambition Matrix", "content": "Evaluate [my business decision] through the Innovation Ambition Matrix. Plot initiatives on a matrix to balance core enhancements, adjacent opportunities, and transformational innovations.", "category": "strategy" }, { "id": "builtin_prompt_110", "name": "Jobs to Be Done Framework", "content": "Assess [my business decision] by applying the Jobs to Be Done Framework. Focus on the problems customers are trying to solve.", "category": "strategy" }, { "id": "builtin_prompt_111", "name": "Kano Model Analysis", "content": "Evaluate [my business decision] using the Kano Model Analysis. Prioritize customer needs into basic, performance, and excitement categories.", "category": "strategy" }, { "id": "builtin_prompt_112", "name": "Lean Startup Principles", "content": "Apply Lean Startup Principles to [my business decision]. Focus on creating a minimum viable product, measuring its success, and learning from the results.", "category": "strategy", "isFeatured": true }, { "id": "builtin_prompt_113", "name": "Long Tail Strategy", "content": "Analyze [my business decision] focusing on the Long Tail Strategy. Consider how niche markets or products may contribute to overall success.", "category": "strategy" }, { "id": "builtin_prompt_114", "name": "Network Effects", "content": "Analyze [my business decision] through the understanding of Network Effects. Consider how the value of a product or service increases as more people use it.", "category": "strategy" }, { "id": "builtin_prompt_115", "name": "Pre-Mortem Analysis", "content": "Utilize Pre-Mortem Analysis to assess [my business decision]. Imagine a future failure of the decision and work backward to identify potential causes and mitigation strategies.", "category": "strategy" }, { "id": "builtin_prompt_116", "name": "Prospect Theory", "content": "Utilize Prospect Theory to assess [my business decision]. Understand how people perceive gains and losses and how that can influence decision-making.", "category": "strategy" }, { "id": "builtin_prompt_117", "name": "Pygmalion Effect", "content": "Apply the Pygmalion Effect to analyze [my business decision]. Recognize how expectations can influence outcomes, both positively and negatively.", "category": "strategy" }, { "id": "builtin_prompt_118", "name": "Resource-Based View", "content": "Apply the Resource-Based View to evaluate [my business decision]. Focus on leveraging the company's internal strengths and weaknesses in relation to external opportunities and threats.", "category": "strategy" }, { "id": "builtin_prompt_119", "name": "Risk-Reward Analysis", "content": "Analyze [my business decision] through Risk-Reward Analysis. Evaluate the potential risks against the potential rewards to understand the balance and make an informed decision.", "category": "strategy" }, { "id": "builtin_prompt_120", "name": "Scenario Planning", "content": "Apply Scenario Planning to assess [my business decision]. Create different future scenarios and analyze how the decision performs in each to identify potential risks and opportunities." }, { "id": "builtin_prompt_121", "name": "Six Thinking Hats", "content": "Evaluate [my business decision] through the Six Thinking Hats method. Analyze the decision from different perspectives such as logical, emotional, cautious, creative, and more.", "category": "strategy", "isFeatured": true }, { "id": "builtin_prompt_122", "name": "Temporal Discounting", "content": "Use Temporal Discounting to analyze [my business decision]. Consider how the value of outcomes changes over time and how that might influence the decision-making process.", "category": "strategy" }, { "id": "builtin_prompt_123", "name": "The Five Whys Technique", "content": "Utilize the Five Whys Technique to analyze [my business decision]. Ask 'why?' multiple times to get to the root cause of problems or challenges.", "category": "strategy" }, { "id": "builtin_prompt_124", "name": "The OODA Loop (Observe, Orient, Decide, Act)", "content": "Use the OODA Loop to evaluate [my business decision]. Cycle through observing the situation, orienting yourself, making a decision, and taking action, then repeating as necessary.", "category": "strategy" }, { "id": "builtin_prompt_125", "name": "Value Chain Analysis", "content": "Apply Value Chain Analysis to evaluate [my business decision]. Examine all the activities performed by a company to create value and find opportunities for competitive advantage.", "category": "strategy" }, { "id": "builtin_prompt_126", "name": "Viral Loop Strategy", "content": "Use Viral Loop Strategy to assess [my business decision]. Construct a process where existing users help in recruiting new ones.", "category": "strategy" }, { "id": "builtin_prompt_127", "name": "Focus on Results", "content": "Can you provide me with a case study template for [CLIENT NAME]'s [PRODUCT/SERVICE] that showcases the results achieved? Please suggest the key metrics and KPIs that should be included. Use [AGENCY NAME], [RESULTS], and [KEY METRICS] as placeholders for customization.", "category": "caseStudies" }, { "id": "builtin_prompt_128", "name": "Showcase Digital Transformation", "content": "How would you structure a case study for [CLIENT NAME] that focuses on their journey of digital transformation? Please suggest the main sections, such as the challenges, the solutions, and the outcomes achieved. Use [AGENCY NAME], [CLIENT NAME], and [OUTCOMES] as placeholders for customization.", "category": "caseStudies" }, { "id": "builtin_prompt_129", "name": "Highlighting Collaboration", "content": "Can you generate a case study template for [CLIENT NAME] that highlights their partnership with [PARTNER NAME]? Please include details on the collaboration process, the benefits for both parties, and the outcomes achieved. Use [AGENCY NAME], [CLIENT NAME], and [PARTNER NAME] as placeholders for customization.", "category": "caseStudies" }, { "id": "builtin_prompt_130", "name": "Achieving Goals", "content": "How would you structure a case study for [CLIENT NAME] that showcases their success in achieving sustainability goals? Please suggest the main sections, such as the challenges, the solutions, and the impact achieved. Use [AGENCY NAME], [CLIENT NAME], and [IMPACT] as placeholders for customization.", "category": "caseStudies" }, { "id": "builtin_prompt_131", "name": "Focus on Strategy", "content": "Can you provide me with a case study template for [CLIENT NAME] that highlights their social media strategy? Please include details on the objectives, the tactics, and the results achieved. Use [AGENCY NAME], [CLIENT NAME], and [RESULTS] as placeholders for customization.", "category": "caseStudies" }, { "id": "builtin_prompt_132", "name": "Highlight Unique Features", "content": "Can you write a benefit-driven sales copy that highlights the unique features and benefits of [product/service]?", "category": "salesCopy" }, { "id": "builtin_prompt_133", "name": "Explain the Benefits", "content": "How would you describe the benefits of [product/service] to a potential customer in a way that would convince them to purchase?", "category": "salesCopy" }, { "id": "builtin_prompt_134", "name": "Explain the Need", "content": "Can you craft a benefit-driven sales copy that explains why [product/service] is the best solution for [customer's problem or need]?", "category": "salesCopy" }, { "id": "builtin_prompt_135", "name": "Crafting Convincing Copy", "content": "How would you sell the benefits of [product/service] to a customer who is on the fence about making a purchase?", "category": "salesCopy" }, { "id": "builtin_prompt_136", "name": "Overcome Objections", "content": "How would you sell the benefits of [product/service] to a customer who is skeptical about its effectiveness for [customer's problem or need]?", "category": "salesCopy" }, { "id": "builtin_prompt_137", "name": "Make Your Product Stand Out (Long-Form)", "content": "Can you write an extended sales copy describing the unique features and benefits of [product/service] that sets it apart from the competition and makes it a must-have for [target audience]?", "category": "salesCopy" }, { "id": "builtin_prompt_138", "name": "Top Features (Long-Form)", "content": "Can you write an extended sales copy on what are the top features of [product/service] that [target audience] will love?", "category": "salesCopy" }, { "id": "builtin_prompt_139", "name": "Explaining the Process (Long-Form)", "content": "Can you write an extended sales copy explaining in detail the process of using [product/service] and how it can help [target audience] achieve their goals?", "category": "salesCopy" }, { "id": "builtin_prompt_140", "name": "Differentiating Your Product (Long-Form)", "content": "Can you write an extended sales copy on what makes [product/service] stand out from similar offerings in the market, and why should [target audience] choose it over the competition?", "category": "salesCopy" }, { "id": "builtin_prompt_141", "name": "Convincing Sales Copy (Long-Form)", "content": "Can you write an extended sales copy convincing me why [product/service] is the missing piece [target audience] needs to take their [aspect of life/business] to the next level?", "category": "salesCopy" }, { "id": "builtin_prompt_142", "name": "Engaging Sales Pitches (Medium-Form)", "content": "Write a sales pitch for a [product/service] that highlights its unique features and benefits.", "category": "salesCopy" }, { "id": "builtin_prompt_143", "name": "Persuasive Emails (Medium-Form)", "content": "Write a persuasive email to a potential customer explaining why they should choose [company name] for their [product/service] needs.", "category": "salesCopy" }, { "id": "builtin_prompt_144", "name": "Persuasive Blog Posts (Medium-Form)", "content": "Write a persuasive blog post about the benefits of using [product/service] for [specific industry/target market].", "category": "salesCopy" }, { "id": "builtin_prompt_145", "name": "Problem-Solving Sales Pitches (Medium-Form)", "content": "Write a sales pitch for a [product or service] that emphasizes its unique features and benefits. Include specific details about how it can solve a problem or improve the customer's life.", "category": "salesCopy" }, { "id": "built_in_prompt_146", "name": "Translating Ad Copy", "content": "Translate the following ad copy into [language]: [ad copy]", "category": "salesCopy", "isFeatured": true }, { "id": "built_in_prompt_147", "name": "Persuasive Ad Copy", "content": "Rewrite the following ad copy to make it more persuasive: [ad copy]", "category": "salesCopy" }, { "id": "built_in_prompt_148", "name": "Translating Ad Copy Alternative Phrasing", "content": "What are some alternative ways to phrase the following ad copy in [language]: [ad copy]", "category": "salesCopy", "isFeatured": true }, { "id": "built_in_prompt_149", "name": "Translating Ad Copy Compelling CTAs", "content": "Write a compelling call-to-action for our [product/service] in [language]: [product/service description]", "category": "salesCopy" }, { "id": "built_in_prompt_150", "name": "Translating Ad Copy Catchy Taglines", "content": "Create a catchy tagline for our new [product/service] in [language]: [product/service description]", "category": "salesCopy" }, { "id": "built_in_prompt_151", "name": "Make Ad Copy More Interesting Grab Audience Attention", "content": "I am trying to make my ad copy for [product/service] more interesting. Can you help me come up with a catchy headline and a unique selling point that will grab people's attention?", "category": "salesCopy" }, { "id": "built_in_prompt_152", "name": "Make Ad Copy More Interesting Create Memorable Products", "content": "I am looking to create an ad campaign for [product/service] that stands out. Can you help me write ad copy that is engaging, memorable and persuasive?", "category": "salesCopy" }, { "id": "built_in_prompt_153", "name": "Make Ad Copy More Interesting Evoke Emotion", "content": "I want to create an ad for [product/service] that evokes emotions and resonates with [target audience]. Can you help me write copy that will connect with them on a deeper level?", "category": "salesCopy" }, { "id": "built_in_prompt_154", "name": "Make Ad Copy More Interesting Entice Your Audience", "content": "Introduce our new [product/service] in a way that highlights its unique features and benefits.", "category": "salesCopy" }, { "id": "built_in_prompt_155", "name": "Make Ad Copy More Interesting Achieve Specific Goals", "content": "Write an ad copy that showcases how our [product/service] can help [target audience] achieve [specific goal].", "category": "salesCopy" }, { "id": "built_in_prompt_156", "name": "Crafting Unique USP's Being the Company of Choice", "content": "Why is [type of company/business]'s [product/service] the best choice for [target audience] looking for [desired outcome]?", "category": "salesCopy" }, { "id": "built_in_prompt_157", "name": "Crafting Unique USP's Highlighting Unique Solutions", "content": "What unique solution does [type of company/business] offer to solve the [pain point] faced by [target audience]?", "category": "salesCopy" }, { "id": "built_in_prompt_158", "name": "Crafting Unique USP's Distinct Approaches to Industry Challenges", "content": "What makes [specific topic company/business] approach to [industry challenge] different and more effective?", "category": "salesCopy" }, { "id": "built_in_prompt_159", "name": "Providing Superior Benefits", "content": "How does [specific type of company/business] [product/service feature] provide a superior [customer benefit] compared to others in the market?", "category": "salesCopy" }, { "id": "built_in_prompt_160", "name": "Features-Advantages-Benefits' framework", "content": "Using the 'Features-Advantages-Benefits' framework, please write a copy that highlights the [features] of our [product/service] and explains how these [advantages] can be helpful to [ideal customer persona]. Elaborate on the [benefits] of our product and how it can positively impact the reader.", "category": "copyWriting" }, { "id": "built_in_prompt_161", "name": "PASTOR' framework", "content": "Write a copy using the 'PASTOR' framework to address the pain points of [ideal customer persona] and present our [product/service] as the solution. Identify the [problem] they are facing, amplify the consequences of not solving it, tell a [story] related to the problem, include [testimonials] from happy customers, present our [offer], and ask for a response.", "category": "copyWriting" }, { "id": "built_in_prompt_162", "name": "Before-After-Bridge' framework", "content": "Using the 'Before-After-Bridge' framework, please write a copy that presents the current situation with a [problem] faced by ideal customer persona]. Show them the world after using our [product/service] and how it has improved their situation. Then, provide a [bridge] to show them how they can get to that improved state by using our product.", "category": "copyWriting" }, { "id": "built_in_prompt_163", "name": "Attention-Interest-Desire-Action' framework", "content": "Using the 'Before-After-Bridge' framework, please write a copy that presents the current situation with a [problem] faced by ideal customer persona]. Show them the world after using our [product/service] and how it has improved their situation. Then, provide a [bridge] to show them how they can get to that improved state by using our product.", "category": "copyWriting" }, { "id": "built_in_prompt_164", "name": "Problem-Agitate-Solve' framework", "content": "Using the 'Problem-Agitate-Solve' framework, please write a copy that identifies the most painful [problem] faced by [ideal customer persona] and agitates the issue to show why it is a bad situation. Then, present our [product/service] as the logical solution to the problem.", "category": "copyWriting" }, { "id": "built_in_prompt_165", "name": "Star-Story-Solution' framework", "content": "Using the 'Features-Advantages-Benefits' framework, please write a copy that highlights the [features] of our [product/service] and explains how these [advantages] can be helpful to [ideal customer persona]. Elaborate on the [benefits] of our product and how it can positively impact the reader.", "category": "copyWriting", "isFeatured": true }, { "id": "built_in_prompt_166", "name": "Picture-Promise-Prove-Push' framework", "content": "Write a copy using the 'Picture-Promise-Prove-Push' framework to paint a picture that gets the attention and creates desire for our [product/service] in ideal customer persona]. Describe how our product will deliver on its promises, provide testimonials to back up those promises, and give a little push to encourage the reader to take action.", "category": "copyWriting" }, { "id": "built_in_prompt_167", "name": "Awareness-Comprehension-Conviction-Action’ framework", "content": "Write a copy using the 'Awareness-Comprehension-Conviction-Action’ framework to present the situation or [problem] faced by [ideal customer persona] and help them understand it. Create the desired conviction in the reader to use our [product/service] as the solution and make them take action.", "category": "copyWriting" }, { "id": "built_in_prompt_168", "name": "5 Basic Objections' framework", "content": "Using the '5 Basic Objections' framework, please write a copy that addresses and refutes the common objections of [ideal customer personal: lack of time, lack of money, concerns that the product won't work for them, lack of belief in the product or company, and the belief that they don't need the product. Include talking points such as [unique selling point] and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_169", "name": "Four C's' framework", "content": "Write a copy using the 'Four C's' framework to create clear, concise, compelling, and credible copy for [ideal customer persona]. Use this checklist to ensure that our message is effectively communicated and persuades the reader to take action. Include talking points such as [unique selling point] and [desired action].", "category": "copyWriting", "isFeatured": true }, { "id": "built_in_prompt_170", "name": "Consistent-Contrasting' framework", "content": "Please write a copy using the 'Consistent-Contrasting' framework to convert leads into customers. Use a consistent message or theme throughout the copy, but incorporate contrasting language or images to draw the reader's attention and keep them engaged. Include talking points such as [product/service], [unique selling point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_171", "name": "Strong-Weak' framework", "content": "Write a copy using the 'Strong-Weak' framework to persuade [ideal customer persona] to take action. Use strong language and images to emphasize the benefits of our [product/service], but also acknowledge any potential weaknesses or limitations in a transparent and honest way. Include talking points such as [unique selling point], [pain point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_172", "name": "Emotion-Logic' framework", "content": "Using the 'Emotion-Logic' framework, please write a copy that connects with [ideal customer persona] and creates desire for our [product/service]. Use emotional appeals to connect with the reader, but also use logical arguments to convince them to take action. Include talking points such as [emotion], [pain point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_173", "name": "Personal-Universal' framework", "content": "Craft a copy using the 'Personal-Universal' framework to make our [product/service] relatable to [ideal customer persona]. Use \"you\" language and address their specific needs and desires, but also connect our product to universal human experiences and values. Include talking points such as [unique selling point], [pain point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_174", "name": "Urgency-Patience' framework", "content": "Write a copy using the 'Urgency-Patience' framework to encourage [ideal customer persona] to take action. Create a sense of urgency to encourage the reader to act now, but also remind them that using our [product/service] will bring long-term benefits that are worth waiting for. Include talking points such as [unique selling point], [pain point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_175", "name": "Expectation-Surprise' framework", "content": "Please write a copy using the 'Expectation-Surprise' framework to generate interest and encourage action from [ideal customer persona]. Set expectations for the reader about what they can expect from our [product/service], but then surprise them with unexpected benefits or features that exceed those expectations. Include talking points such as [unique selling point], [pain point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_176", "name": "Exclusive-Inclusive' framework", "content": "Write a copy using the 'Exclusive-Inclusive' framework to position our [product/service] as elite and desirable to [ideal customer persona]. Make it clear that our product is exclusive or elite in some way, but also emphasize that it is accessible and inclusive to a wide range of customers. Include talking points such as [unique selling point], [pain point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_177", "name": "Positive-Negative' framework", "content": "Using the 'Positive-Negative' framework, please write a copy that focuses on the positive aspects of our [product/service] and the benefits it will bring to [ideal customer persona]. Also acknowledge and address any potential negative consequences or drawbacks in a constructive way. Include talking points such as [unique selling point], [pain point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_178", "name": "Past-Present-Future' framework", "content": "Create a copy using the 'Past-Present-Future' framework to connect our [product/service] to [ideal customer persona]'s past experiences or memories. Show how it can improve their present situation, and then show how it can shape their future in a positive way. Include talking points such as [unique selling point], [pain point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_179", "name": "Friend-Expert' framework", "content": "Write a copy using the 'Friend-Expert' framework to establish a connection with [ideal customer persona] and position our brand or [product/service] as an expert in our field. Use a friendly and approachable tone to connect with the reader, but also highlight our credibility and expertise in our field. Include talking points such as [unique selling point], [pain point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_180", "name": "Pain-Agitate-Relief' framework", "content": "Please write a copy using the 'Pain-Agitate-Relief' framework to convert leads into customers. Identify the pain points faced by [ideal customer personal], amplify the negative consequences of not addressing these pain points, and present our [product/service] as the solution that brings relief. Include variables such as [product/service], [unique selling point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_181", "name": "Solution-Savings-Social Proof' framework", "content": "Write a copy using the 'Solution-Savings-Social Proof' framework to persuade [ideal customer persona] to take action. Clearly state the problem our [product/service] solves, emphasize the time, money, or other resources that the customer can save by using our product, and use customer testimonials or social proof to demonstrate the effectiveness of our solution. Include variables such as [product/service], [unique selling point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_182", "name": "‘6 W's' framework", "content": "Write a copy using the '6 W's' framework to convert leads into customers. Identify [ideal customer persona] as the target audience, clearly describe our [product/service] and what it does, highlight any time-sensitive aspects of our offer or the problem it solves, specify where the product or service can be purchased or used, clearly explain the benefits and value of our [product/service], and explain how the product or service works and how the customer can obtain it. Include variables such as [product/service], [unique selling point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_183", "name": "Story-Solve-Sell' framework", "content": "Create a copy using the 'Story-Solve-Sell' framework to convert leads into customers. Tell a compelling story that connects with [ideal customer persona] and relates to the problem our [product/service] solves, clearly demonstrate how our product solves the problem, and make a strong call to action to convince the reader to purchase or take the desired action. Include variables such as [product/service], [unique selling point], and [desired action].", "category": "copyWriting" }, { "id": "built_in_prompt_184", "name": "Outlining Your Course", "content": "As a Professor at a [Software] Company, design a comprehensive outline for a 12-week educational course focused on [software development] techniques. Include weekly topics, learning objectives, and suggested teaching materials.", "category": "education" }, { "id": "built_in_prompt_185", "name": "Detailed Lesson Plans", "content": "Create a detailed lesson plan for a 90-minute workshop on [agile software development principles] for [professionals]. Ensure the plan covers key concepts, activities, and learning takeaways for participants.", "category": "education", "isFeatured": true }, { "id": "built_in_prompt_186", "name": "Proposing a Virtual Series", "content": "Draft a proposal for a series of virtual webinars addressing emerging trends in [software development], discussing the potential topics for the webinars and their educational value for the audience.", "category": "education" }, { "id": "built_in_prompt_187", "name": "Training Students on Best Practices", "content": "As a Professor at a [Software] Company, outline a curriculum for a 6-week training program targeted at upskilling [developers] in [cybersecurity] best practices. Include course modules, learning objectives, and assessment strategies.", "category": "education" }, { "id": "built_in_prompt_188", "name": "Teaching Skills Relevant to Company Needs", "content": "Design a syllabus for a four-week, intensive [coding] bootcamp focused on teaching [programming languages] and [software tools] relevant to your company's needs. Consider the skills participants need to learn and how to structure the course for optimal engagement and retention.", "category": "education" }, { "id": "built_in_prompt_189", "name": "Angles and Subtopics", "content": "For my podcast episode on [TOPIC], can you provide me with [NUMBER] unique and [ADJECTIVE] angles or subtopics that will appeal to [TYPE OF AUDIENCE], and suggest [NUMBER] potential guests who can offer their expertise on each subtopic?", "category": "podcastProduction", "isFeatured": true }, { "id": "built_in_prompt_190", "name": "Identifying Different Viewpoints", "content": "Can you help me plan a podcast episode on [TOPIC], by identifying [NUMBER] different viewpoints on [SUBTOPIC] and providing [TYPE OF INFORMATION] on each, while incorporating [TYPE OF MEDIA] to add depth and interest?", "category": "podcastProduction" }, { "id": "built_in_prompt_191", "name": "Creating a Podcast Series", "content": "I want to create a series of podcast episodes on [TOPIC], can you help me brainstorm [NUMBER] overarching themes, [NUMBER] subtopics for each theme, and [TYPE OF INFORMATION] for each subtopic that will keep listeners engaged throughout the entire series?", "category": "podcastProduction" }, { "id": "built_in_prompt_192", "name": "Tailoring to Specific Demographics", "content": "When creating a podcast episode about [TOPIC] for a [SPECIFIC DEMOGRAPHIC] audience, what are some [NUMBER] [ADJECTIVE] ways to hook their attention at the beginning and keep them engaged throughout the episode? How can I address potential concerns or questions that they might have about the topic in an informative and empathetic manner?", "category": "podcastProduction" }, { "id": "built_in_prompt_193", "name": "Integrating Elements", "content": "As a podcast creator, I want to integrate [SPECIFIC ELEMENT] into my episode about [TOPIC] in a [ADJECTIVE] way that enhances my audience's listening experience. What are some [NUMBER] strategies or tools I could use to accomplish this goal? Could you suggest any examples of podcasts that have successfully implemented this element in their episodes?", "category": "podcastProduction" }, { "id": "built_in_prompt_194", "name": "Cold Calling Complete Sales Copy", "content": "Can you provide me with a complete sales copy about [product/service] for a cold call to a potential client, including an opening, presentation, overcoming objections, and close?", "category": "salesCopy" }, { "id": "built_in_prompt_195", "name": "Cold Calling Showcasing a Product/ Service", "content": "Can you draft a copy for a sales cold call that effectively showcases the [product/service] to [Prospect Name] and leads to a successful close?", "category": "salesCopy" }, { "id": "built_in_prompt_196", "name": "Cold Calling Tailoring Copy to Specific Audiences", "content": "Can you formulate a sales copy for [product/service] for a cold call to [Specific audience] that covers the aspects of introduction, demonstration, objection handling, and closing?", "category": "salesCopy" }, { "id": "built_in_prompt_197", "name": "Successful Pitch Examples", "content": "Can you provide me an example of a successful pitch for [product/service] to a [specific audience] potential client?", "category": "salesCopy", "isFeatured": true }, { "id": "built_in_prompt_198", "name": "Presenting Product Value", "content": "Write me a sales cold calling copy by presenting the value of [product/service] to [prospective customer name] in the most effective way.", "category": "salesCopy" }, { "id": "built_in_prompt_199", "name": "Client Proposal Highlighting Benefits", "content": "Can you please write a B2B proposal for [Company] that highlights the benefits of using our [Product/Service] and how it can help them achieve their [specific business goal]?", "category": "salesCopy", "isFeatured": true }, { "id": "built_in_prompt_200", "name": "Client Proposal Improving Specific Processes", "content": "Can you draft a B2B proposal for [Company Name] that explains how our [Product/Service] can improve their [specific business process] and increase their [specific metric]?", "category": "salesCopy" }, { "id": "built_in_prompt_201", "name": "Client Proposal Industry Specific Proposals", "content": "I am looking to write a proposal for a potential client in the [industry] industry. Can you help me create a compelling introduction and outline the key points and benefits of my [product/service]?", "category": "salesCopy" }, { "id": "built_in_prompt_202", "name": "Client Proposal B2B Proposals Comparing Solutions", "content": "Can you compose a B2B proposal for [Company Name] that showcases the unique features of our [Product/Service] and how it compares to similar solutions in the market?", "category": "salesCopy" }, { "id": "built_in_prompt_203", "name": "Client Proposal Winning Over a New Client", "content": "I am trying to win over a new client for my [product/service]. Can you help me write a persuasive proposal that highlights the benefits and value of [offering]?", "category": "salesCopy" }, { "id": "built_in_prompt_204", "name": "Meeting Notes Highlighting Key Takeaways", "content": "Can you summarize a meeting on [topic of meeting] by highlighting the key takeaways? The notes of the meeting: [notes]", "category": "work" }, { "id": "built_in_prompt_205", "name": "Meeting Notes Summarizing Objectives", "content": "Can you summarize the objectives discussed in a meeting and the action items decided? The notes of the meeting: [notes]", "category": "work" }, { "id": "built_in_prompt_206", "name": "Meeting Notes Summarizing Decisions and Next Steps", "content": "Can you summarize the decisions made during a meeting about [specific issue] and the next steps outlined? The notes of the meeting: [notes]", "category": "work" }, { "id": "built_in_prompt_207", "name": "Meeting Notes Summarizing Progress Updates", "content": "Can you summarize the progress update given in a meeting on [project/task] and the future plans discussed? The notes of the meeting: [notes]", "category": "work", "isFeatured": true }, { "id": "built_in_prompt_208", "name": "Meeting Notes Summarizing Key Points and Solutions", "content": "Can you summarize the key points raised during a [team/department/etc.] meeting and the solutions proposed? The notes of the meeting: [notes]", "category": "work" }, { "id": "built_in_prompt_209", "name": "Generate Long-tail Keywords The Definition", "content": "Can you define [keyword] in a few words?", "category": "contentSeo" }, { "id": "built_in_prompt_210", "name": "Generate Long-tail Keywords A Short Description", "content": "What is a short description for [keyword]?", "category": "contentSeo" }, { "id": "built_in_prompt_211", "name": "Generate Long-tail Keywords The Central Idea", "content": "What is the central idea of [keyword]?", "category": "contentSeo" }, { "id": "built_in_prompt_212", "name": "Generate Long-tail Keywords The Core", "content": "What is the core of [keyword]?", "category": "contentSeo" }, { "id": "built_in_prompt_213", "name": "Generate Long-tail Keywords The Key Elements", "content": "What are the key elements of [keyword]?", "category": "contentSeo", "isFeatured": true }, { "id": "built_in_prompt_214", "name": "Customer Surveys Key Questions to Ask", "content": "What are some key questions to ask in a customer survey to gauge [product/service] satisfaction?", "category": "customerSuccess", "isFeatured": true }, { "id": "built_in_prompt_215", "name": "Customer Surveys Examples of Open-Ended Questions", "content": "Can you provide some examples of open-ended questions to include in a customer survey for [company/industry]?", "category": "customerSuccess" }, { "id": "built_in_prompt_216", "name": "Customer Surveys Best Practices to Gather Feedback", "content": "What are some best practices for creating a customer survey to gather valuable feedback on [specific aspect of product/service]?", "category": "customerSuccess" }, { "id": "built_in_prompt_217", "name": "Customer Surveys Important Metrics to Track", "content": "What are the most important metrics to track in a customer survey to measure [product/service] success?", "category": "customerSuccess" }, { "id": "built_in_prompt_218", "name": "Customer Surveys Creative Approaches", "content": "Can you suggest some creative approaches to designing customer survey questions for [company/industry]?", "category": "customerSuccess" }, { "id": "built_in_prompt_219", "name": "Meeting Agenda Including Specific Topics and Relevant Items", "content": "Can you create me a meeting agenda for [UPCOMING MEETING] on [DATE] at [TIME], including topics such as [LIST OF TOPICS] and any other items that may be relevant, such as [ADDITIONAL ITEMS]? Please make sure to allocate appropriate time for each topic.", "category": "work", "isFeatured": true }, { "id": "built_in_prompt_220", "name": "Meeting Agenda Including Key Topics and Supporting Materials", "content": "I need your help creating a comprehensive meeting agenda for our next meeting. Can you suggest key topics to include, such as [LIST OF POSSIBLE TOPICS], and any supporting materials we may need, such as [MATERIALS]? Please ensure that the agenda reflects our objectives for the meeting.", "category": "work" }, { "id": "built_in_prompt_221", "name": "Meeting Agenda Covering all Necessary Items", "content": "I'm struggling to create an agenda for our [TYPE OF MEETING] meeting. Can you please create a detailed agenda that covers all necessary items and information, such as [LIST OF ITEMS AND INFORMATION]? Additionally, can you provide any tips or best practices for running an effective meeting?", "category": "work" }, { "id": "built_in_prompt_222", "name": "Meeting Agenda Creating an Agenda for Specific Departments", "content": "Can you help me create a meeting agenda for a [TEAM/DEPARTMENT/DIVISION] meeting with [NUMBER] participants, including [SPECIFIC INDIVIDUALS OR DEPARTMENTS], [AND/OR OTHER CRITERIA]?", "category": "work" }, { "id": "built_in_prompt_223", "name": "Meeting Agenda Ensuring the Agenda is Inclusive of All Participants", "content": "How can I make sure the meeting agenda is inclusive and addresses the needs of all participants, especially those with [SPECIFIC CHARACTERISTICS OR PERSPECTIVES], [WHILE STILL ACHIEVING SPECIFIC OUTCOMES SUCH AS DECISION-MAKING OR CONSENSUS-BUILDING]?", "category": "work" }, { "id": "built_in_prompt_224", "name": "Goal Setting Important Outcomes or Deliverables", "content": "Crafting a goal for [specific project or task]: What are the most important outcomes or deliverables you hope to achieve?", "category": "work" }, { "id": "built_in_prompt_225", "name": "Goal Setting Tracking Progress", "content": "I want to track progress towards [specific goal], what is the best way to do that?", "category": "work", "isFeatured": true }, { "id": "built_in_prompt_226", "name": "Goal Setting Evaluating Effectiveness", "content": "I want to evaluate the effectiveness of my [OKRs or goal], can you give me some suggestions on how to do that?", "category": "work" }, { "id": "built_in_prompt_227", "name": "Goal Setting Performance Targets", "content": "In order to reach my goal of [insert goal], I need to set performance targets for myself. For example, I could aim to [insert action] [insert number] times per week. Can you help me with that?", "category": "work" }, { "id": "built_in_prompt_228", "name": "Goal Setting OKRs", "content": "Write a list of specific, measurable, and attainable goals for [your company/project] using the OKR framework.", "category": "work", "isFeatured": true } ] } ================================================ FILE: frontend/appflowy_flutter/assets/fonts/.gitkeep ================================================ ================================================ FILE: frontend/appflowy_flutter/assets/google_fonts/Poppins/OFL.txt ================================================ Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ----------------------------------------------------------- PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. ================================================ FILE: frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/LICENSE.txt ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: frontend/appflowy_flutter/assets/icons/icons.json ================================================ { "artificial_intelligence": [ { "name": "ai-chip-spark", "keywords": [ "chip", "processor", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-cloud-spark", "keywords": [ "cloud", "internet", "server", "network", "artificial", "intelligence", "ai" ], "content": "\n \n \n \n \n \n \n \n \n\n" }, { "name": "ai-edit-spark", "keywords": [ "change", "edit", "modify", "pencil", "write", "writing", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-email-generator-spark", "keywords": [ "mail", "envelope", "inbox", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-gaming-spark", "keywords": [ "remote", "control", "controller", "technology", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-landscape-image-spark", "keywords": [ "picture", "photography", "photo", "image", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-music-spark", "keywords": [ "music", "audio", "note", "entertainment", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-portrait-image-spark", "keywords": [ "picture", "photography", "photo", "image", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-variation-spark", "keywords": [ "module", "application", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-navigation-spark", "keywords": [ "map", "location", "direction", "travel", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-network-spark", "keywords": [ "globe", "internet", "world", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-prompt-spark", "keywords": [ "app", "code", "apps", "window", "website", "web", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-redo-spark", "keywords": [ "arrow", "refresh", "sync", "synchronize", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-science-spark", "keywords": [ "atom", "scientific", "experiment", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-settings-spark", "keywords": [ "cog", "gear", "settings", "machine", "artificial", "intelligence" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-technology-spark", "keywords": [ "lightbulb", "idea", "bright", "lighting", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-upscale-spark", "keywords": [ "magnifier", "zoom", "view", "find", "search", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-vehicle-spark-1", "keywords": [ "car", "automated", "transportation", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "artificial-intelligence-spark", "keywords": [ "brain", "thought", "ai", "automated", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "computer_devices": [ { "name": "adobe", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "alt", "keywords": [ "windows", "key", "alt", "pc", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "amazon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "android", "keywords": [ "android", "code", "apps", "bugdroid", "programming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "app-store", "keywords": [], "content": "\n\n\n" }, { "name": "apple", "keywords": [ "os", "system", "apple" ], "content": "\n\n\n" }, { "name": "asterisk-1", "keywords": [ "asterisk", "star", "keyboard" ], "content": "\n\n\n" }, { "name": "battery-alert-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "alert", "warning" ], "content": "\n\n\n" }, { "name": "battery-charging", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "charging" ], "content": "\n\n\n" }, { "name": "battery-empty-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "empty", "power", "battery" ], "content": "\n\n\n" }, { "name": "battery-empty-2", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "empty", "power", "battery" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "battery-full-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "full" ], "content": "\n\n\n" }, { "name": "battery-low-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "low" ], "content": "\n\n\n" }, { "name": "battery-medium-1", "keywords": [ "phone", "mobile", "charge", "medium", "device", "electricity", "power", "battery" ], "content": "\n\n\n" }, { "name": "bluetooth", "keywords": [ "bluetooth", "internet", "server", "network", "wireless", "connection" ], "content": "\n\n\n" }, { "name": "bluetooth-disabled", "keywords": [ "bluetooth", "internet", "server", "network", "wireless", "disabled", "off", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bluetooth-searching", "keywords": [ "bluetooth", "internet", "server", "network", "wireless", "searching", "connecting", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-wifi", "keywords": [ "wireless", "wifi", "internet", "server", "network", "browser", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chrome", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "command", "keywords": [ "mac", "command", "apple", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "computer-chip-1", "keywords": [ "computer", "device", "chip", "electronics", "cpu", "microprocessor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "computer-chip-2", "keywords": [ "core", "microprocessor", "device", "electronics", "chip", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "computer-pc-desktop", "keywords": [ "screen", "desktop", "monitor", "device", "electronics", "display", "pc", "computer" ], "content": "\n\n\n" }, { "name": "controller", "keywords": [ "remote", "quadcopter", "drones", "flying", "drone", "control", "controller", "technology", "fly" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "controller-1", "keywords": [ "remote", "quadcopter", "drones", "flying", "drone", "control", "controller", "technology", "fly" ], "content": "\n\n\n" }, { "name": "controller-wireless", "keywords": [ "remote", "gaming", "drones", "drone", "control", "controller", "technology", "console" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cursor-click", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cyborg", "keywords": [ "artificial", "robotics", "intelligence", "machine", "technology", "android" ], "content": "\n\n\n" }, { "name": "cyborg-2", "keywords": [ "artificial", "robotics", "intelligence", "machine", "technology", "android" ], "content": "\n\n\n" }, { "name": "database", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-check", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "check", "approve" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-lock", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "password", "security", "protection", "lock", "secure" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-refresh", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "refresh" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-remove", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "remove", "delete", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-server-1", "keywords": [ "server", "network", "internet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-server-2", "keywords": [ "server", "network", "internet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-setting", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "setting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "delete-keyboard", "keywords": [], "content": "\n\n\n" }, { "name": "desktop-chat", "keywords": [ "bubble", "chat", "customer", "service", "conversation", "display", "device" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-check", "keywords": [ "success", "approve", "device", "display", "desktop", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-code", "keywords": [ "desktop", "device", "display", "computer", "code", "terminal", "html", "css", "programming", "system" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-delete", "keywords": [ "device", "remove", "display", "computer", "deny", "desktop", "fail", "failure", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-dollar", "keywords": [ "cash", "desktop", "display", "device", "notification", "computer", "money", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-emoji", "keywords": [ "device", "display", "desktop", "padlock", "smiley" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-favorite-star", "keywords": [ "desktop", "device", "display", "like", "favorite", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-game", "keywords": [ "controller", "display", "device", "computer", "games", "leisure" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-help", "keywords": [ "device", "help", "information", "display", "desktop", "question", "info" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "device-database-encryption-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discord", "keywords": [], "content": "\n\n\n" }, { "name": "drone", "keywords": [ "artificial", "robotics", "intelligence", "machine", "technology", "android", "flying" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dropbox", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "eject", "keywords": [ "eject", "unmount", "dismount", "remove", "keyboard" ], "content": "\n\n\n" }, { "name": "electric-cord-1", "keywords": [ "electricity", "electronic", "appliances", "device", "cord", "cable", "plug", "connection" ], "content": "\n\n\n" }, { "name": "electric-cord-3", "keywords": [ "electricity", "electronic", "appliances", "device", "cord", "cable", "plug", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "facebook-1", "keywords": [ "media", "facebook", "social" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "figma", "keywords": [], "content": "\n\n\n" }, { "name": "floppy-disk", "keywords": [ "disk", "floppy", "electronics", "device", "disc", "computer", "storage" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gmail", "keywords": [], "content": "\n\n\n" }, { "name": "google", "keywords": [ "media", "google", "social" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "google-drive", "keywords": [], "content": "\n\n\n" }, { "name": "hand-held", "keywords": [ "tablet", "kindle", "device", "electronics", "ipad", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hand-held-tablet-drawing", "keywords": [ "tablet", "kindle", "device", "electronics", "ipad", "digital", "drawing", "canvas" ], "content": "\n\n\n" }, { "name": "hand-held-tablet-writing", "keywords": [ "tablet", "kindle", "device", "electronics", "ipad", "writing", "digital", "paper", "notepad" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hard-disk", "keywords": [ "device", "disc", "drive", "disk", "electronics", "platter", "turntable", "raid", "storage" ], "content": "\n\n\n" }, { "name": "hard-drive-1", "keywords": [ "disk", "device", "electronics", "disc", "drive", "raid", "storage" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "instagram", "keywords": [], "content": "\n\n\n" }, { "name": "keyboard", "keywords": [ "keyboard", "device", "electronics", "dvorak", "qwerty" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "keyboard-virtual", "keywords": [ "remote", "device", "electronics", "qwerty", "keyboard", "virtual", "interface" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "keyboard-wireless-2", "keywords": [ "remote", "device", "wireless", "electronics", "qwerty", "keyboard", "bluetooth" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "laptop-charging", "keywords": [ "device", "laptop", "electronics", "computer", "notebook", "charging" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "linkedin", "keywords": [ "network", "linkedin", "professional" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "local-storage-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "meta", "keywords": [], "content": "\n\n\n" }, { "name": "mouse", "keywords": [ "device", "electronics", "mouse" ], "content": "\n\n\n" }, { "name": "mouse-wireless", "keywords": [ "remote", "wireless", "device", "electronics", "mouse", "computer" ], "content": "\n\n\n" }, { "name": "mouse-wireless-1", "keywords": [ "remote", "wireless", "device", "electronics", "mouse", "computer" ], "content": "\n\n\n" }, { "name": "netflix", "keywords": [], "content": "\n\n\n" }, { "name": "network", "keywords": [ "network", "server", "internet", "ethernet", "connection" ], "content": "\n\n\n" }, { "name": "next", "keywords": [ "next", "arrow", "right", "keyboard" ], "content": "\n\n\n" }, { "name": "paypal", "keywords": [ "payment", "paypal" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-store", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "printer", "keywords": [ "scan", "device", "electronics", "printer", "print", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "return-2", "keywords": [ "arrow", "return", "enter", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screen-1", "keywords": [ "screen", "device", "electronics", "monitor", "diplay", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screen-2", "keywords": [ "screen", "device", "electronics", "monitor", "diplay", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screen-curve", "keywords": [ "screen", "curved", "device", "electronics", "monitor", "diplay", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screensaver-monitor-wallpaper", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shift", "keywords": [ "key", "shift", "up", "arrow", "keyboard" ], "content": "\n\n\n" }, { "name": "shredder", "keywords": [ "device", "electronics", "shred", "paper", "cut", "destroy", "remove", "delete" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "signal-loading", "keywords": [ "bracket", "loading", "internet", "angle", "signal", "server", "network", "connecting", "connection" ], "content": "\n\n\n" }, { "name": "slack", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spotify", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "telegram", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tiktok", "keywords": [], "content": "\n\n\n" }, { "name": "tinder", "keywords": [], "content": "\n\n\n" }, { "name": "twitter", "keywords": [ "media", "twitter", "social" ], "content": "\n\n\n" }, { "name": "usb-drive", "keywords": [ "usb", "drive", "stick", "memory", "storage", "data", "connection" ], "content": "\n\n\n" }, { "name": "virtual-reality", "keywords": [ "gaming", "virtual", "gear", "controller", "reality", "games", "headset", "technology", "vr", "eyewear" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "voice-mail", "keywords": [ "mic", "audio", "mike", "music", "microphone" ], "content": "\n\n\n" }, { "name": "voice-mail-off", "keywords": [ "mic", "audio", "mike", "music", "microphone", "mute", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "VPN-connection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "watch-1", "keywords": [ "device", "timepiece", "cirle", "electronics", "face", "blank", "watch", "smart" ], "content": "\n\n\n" }, { "name": "watch-2", "keywords": [ "device", "square", "timepiece", "electronics", "face", "blank", "watch", "smart" ], "content": "\n\n\n" }, { "name": "watch-circle-charging", "keywords": [ "device", "timepiece", "circle", "watch", "round", "charge", "charging", "power" ], "content": "\n\n\n" }, { "name": "watch-circle-heartbeat-monitor-1", "keywords": [ "device", "timepiece", "circle", "watch", "round", "heart", "beat", "monitor", "healthcare" ], "content": "\n\n\n" }, { "name": "watch-circle-heartbeat-monitor-2", "keywords": [ "device", "timepiece", "circle", "watch", "round", "heart", "beat", "monitor", "healthcare" ], "content": "\n\n\n" }, { "name": "watch-circle-menu", "keywords": [ "device", "timepiece", "circle", "watch", "round", "menu", "list", "option", "app" ], "content": "\n\n\n" }, { "name": "watch-circle-time", "keywords": [ "device", "timepiece", "circle", "watch", "round", "time", "clock", "analog" ], "content": "\n\n\n" }, { "name": "webcam", "keywords": [ "webcam", "camera", "future", "tech", "chat", "skype", "technology", "video" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "webcam-video", "keywords": [ "work", "video", "meeting", "camera", "company", "conference", "office" ], "content": "\n\n\n" }, { "name": "webcam-video-circle", "keywords": [ "work", "video", "meeting", "camera", "company", "conference", "office" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "webcam-video-off", "keywords": [ "work", "video", "meeting", "camera", "company", "conference", "office", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "whatsapp", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi", "keywords": [ "wireless", "wifi", "internet", "server", "network", "connection" ], "content": "\n\n\n" }, { "name": "wifi-antenna", "keywords": [ "wireless", "wifi", "internet", "server", "network", "antenna", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi-disabled", "keywords": [ "wireless", "wifi", "internet", "server", "network", "disabled", "off", "offline", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi-horizontal", "keywords": [ "wireless", "wifi", "internet", "server", "network", "horizontal", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi-router", "keywords": [ "wireless", "wifi", "internet", "server", "network", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "windows", "keywords": [ "os", "system", "microsoft" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "culture": [ { "name": "christian-cross-1", "keywords": [ "religion", "christian", "cross", "culture", "bold" ], "content": "\n\n\n" }, { "name": "christian-cross-2", "keywords": [ "religion", "christian", "cross", "culture", "bold" ], "content": "\n\n\n" }, { "name": "christianity", "keywords": [ "religion", "jesus", "christianity", "christ", "fish", "culture" ], "content": "\n\n\n" }, { "name": "dhammajak", "keywords": [ "religion", "dhammajak", "culture", "bhuddhism", "buddish" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hexagram", "keywords": [ "star", "jew", "jewish", "judaism", "hexagram", "culture", "religion", "david" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hinduism", "keywords": [ "religion", "hinduism", "culture", "hindu" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "islam", "keywords": [ "religion", "islam", "moon", "crescent", "muslim", "culture", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "news-paper", "keywords": [ "newspaper", "periodical", "fold", "content", "entertainment" ], "content": "\n\n\n" }, { "name": "peace-symbol", "keywords": [ "religion", "peace", "war", "culture", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "politics-compaign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "politics-speech", "keywords": [], "content": "\n\n\n" }, { "name": "politics-vote-2", "keywords": [], "content": "\n\n\n" }, { "name": "ticket-1", "keywords": [ "hobby", "ticket", "event", "entertainment", "stub", "theater", "entertainment", "culture" ], "content": "\n\n\n" }, { "name": "tickets", "keywords": [ "hobby", "ticket", "event", "entertainment", "stub", "theater", "entertainment", "culture" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "yin-yang-symbol", "keywords": [ "religion", "tao", "yin", "yang", "taoism", "culture", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-1", "keywords": [ "sign", "astrology", "stars", "space", "scorpio" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-10", "keywords": [ "sign", "astrology", "stars", "space", "pisces" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-11", "keywords": [ "sign", "astrology", "stars", "space", "sagittarius" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-12", "keywords": [ "sign", "astrology", "stars", "space", "cancer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-2", "keywords": [ "sign", "astrology", "stars", "space", "virgo" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-3", "keywords": [ "sign", "astrology", "stars", "space", "leo" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-4", "keywords": [ "sign", "astrology", "stars", "space", "aquarius" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-5", "keywords": [ "sign", "astrology", "stars", "space", "taurus" ], "content": "\n\n\n" }, { "name": "zodiac-6", "keywords": [ "sign", "astrology", "stars", "space", "capricorn" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-7", "keywords": [ "sign", "astrology", "stars", "space", "ares" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-8", "keywords": [ "sign", "astrology", "stars", "space", "libra" ], "content": "\n\n\n" }, { "name": "zodiac-9", "keywords": [ "sign", "astrology", "stars", "space", "gemini" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "entertainment": [ { "name": "balloon", "keywords": [ "hobby", "entertainment", "party", "balloon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bow", "keywords": [ "entertainment", "gaming", "bow", "weapon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-fast-forward-1", "keywords": [ "button", "controls", "fast", "forward", "movies", "television", "video", "tv" ], "content": "\n\n\n" }, { "name": "button-fast-forward-2", "keywords": [ "button", "controls", "fast", "forward", "movies", "television", "video", "tv" ], "content": "\n\n\n" }, { "name": "button-next", "keywords": [ "button", "television", "buttons", "movies", "skip", "next", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-pause-2", "keywords": [ "button", "television", "buttons", "movies", "tv", "pause", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-play", "keywords": [ "button", "television", "buttons", "movies", "play", "tv", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-power-1", "keywords": [ "power", "button", "on", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-previous", "keywords": [ "button", "television", "buttons", "movies", "skip", "previous", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-record-3", "keywords": [ "button", "television", "buttons", "movies", "record", "tv", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-rewind-1", "keywords": [ "rewind", "television", "button", "movies", "buttons", "tv", "video", "controls" ], "content": "\n\n\n" }, { "name": "button-rewind-2", "keywords": [ "rewind", "television", "button", "movies", "buttons", "tv", "video", "controls" ], "content": "\n\n\n" }, { "name": "button-stop", "keywords": [ "button", "television", "buttons", "movies", "stop", "tv", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "camera-video", "keywords": [ "film", "television", "tv", "camera", "movies", "video", "recorder" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cards", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chess-bishop", "keywords": [], "content": "\n\n\n" }, { "name": "chess-king", "keywords": [], "content": "\n\n\n" }, { "name": "chess-knight", "keywords": [], "content": "\n\n\n" }, { "name": "chess-pawn", "keywords": [], "content": "\n\n\n" }, { "name": "cloud-gaming-1", "keywords": [ "entertainment", "cloud", "gaming" ], "content": "\n\n\n" }, { "name": "clubs-symbol", "keywords": [ "entertainment", "gaming", "card", "clubs", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "diamonds-symbol", "keywords": [ "entertainment", "gaming", "card", "diamonds", "symbol" ], "content": "\n\n\n" }, { "name": "dice-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-5", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-6", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dices-entertainment-gaming-dices", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "earpods", "keywords": [ "airpods", "audio", "earpods", "music", "earbuds", "true", "wireless", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "epic-games-1", "keywords": [ "epic", "games", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "esports", "keywords": [ "entertainment", "gaming", "esports" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fireworks-rocket", "keywords": [ "hobby", "entertainment", "party", "fireworks", "rocket" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gameboy", "keywords": [ "entertainment", "gaming", "device", "gameboy" ], "content": "\n\n\n" }, { "name": "gramophone", "keywords": [ "music", "audio", "note", "gramophone", "player", "vintage", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hearts-symbol", "keywords": [ "entertainment", "gaming", "card", "hearts", "symbol" ], "content": "\n\n\n" }, { "name": "music-equalizer", "keywords": [ "music", "audio", "note", "wave", "sound", "equalizer", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "music-note-1", "keywords": [ "music", "audio", "note", "entertainment" ], "content": "\n\n\n" }, { "name": "music-note-2", "keywords": [ "music", "audio", "note", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "music-note-off-1", "keywords": [ "music", "audio", "note", "off", "mute", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "music-note-off-2", "keywords": [ "music", "audio", "note", "off", "mute", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "nintendo-switch", "keywords": [ "nintendo", "switch", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-vesus-one", "keywords": [ "entertainment", "gaming", "one", "vesus", "one" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pacman", "keywords": [ "entertainment", "gaming", "pacman", "video" ], "content": "\n\n\n" }, { "name": "party-popper", "keywords": [ "hobby", "entertainment", "party", "popper", "confetti", "event" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-4", "keywords": [ "screen", "television", "display", "player", "movies", "players", "tv", "media", "video", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-5", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video", "stack", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-8", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video", "stack", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-9", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video", "stack", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-folder", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-station", "keywords": [ "play", "station", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "radio", "keywords": [ "antenna", "audio", "music", "radio", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "recording-tape-bubble-circle", "keywords": [ "phone", "device", "mail", "mobile", "voice", "machine", "answering", "chat", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "recording-tape-bubble-square", "keywords": [ "phone", "device", "mail", "mobile", "voice", "machine", "answering", "chat", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "song-recommendation", "keywords": [ "song", "recommendation", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spades-symbol", "keywords": [ "entertainment", "gaming", "card", "spades", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "speaker-1", "keywords": [ "speaker", "music", "audio", "subwoofer", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "speaker-2", "keywords": [ "speakers", "music", "audio", "entertainment" ], "content": "\n\n\n" }, { "name": "stream", "keywords": [ "stream", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tape-cassette-record", "keywords": [ "music", "entertainment", "tape", "cassette", "record" ], "content": "\n\n\n" }, { "name": "volume-down", "keywords": [ "speaker", "down", "volume", "control", "audio", "music", "decrease", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-level-high", "keywords": [ "speaker", "high", "volume", "control", "audio", "music", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-level-low", "keywords": [ "volume", "speaker", "lower", "down", "control", "music", "low", "audio", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-level-off", "keywords": [ "volume", "speaker", "control", "music", "audio", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-mute", "keywords": [ "speaker", "remove", "volume", "control", "audio", "music", "mute", "off", "cross", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-off", "keywords": [ "speaker", "music", "mute", "volume", "control", "audio", "off", "mute", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "vr-headset-1", "keywords": [ "entertainment", "gaming", "vr", "headset" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "vr-headset-2", "keywords": [ "entertainment", "gaming", "vr", "headset" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "xbox", "keywords": [ "xbox", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "food_drink": [ { "name": "beer-mug", "keywords": [ "beer", "cook", "brewery", "drink", "mug", "cooking", "nutrition", "brew", "brewing", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "beer-pitch", "keywords": [ "drink", "glass", "beer", "pitch" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "burger", "keywords": [ "burger", "fast", "cook", "cooking", "nutrition", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "burrito-fastfood", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cake-slice", "keywords": [ "cherry", "cake", "birthday", "event", "special", "sweet", "bake" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "candy-cane", "keywords": [ "candy", "sweet", "cane", "christmas" ], "content": "\n\n\n" }, { "name": "champagne-party-alcohol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cheese", "keywords": [ "cook", "cheese", "animal", "products", "cooking", "nutrition", "dairy", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cherries", "keywords": [ "cook", "plant", "cherry", "plants", "cooking", "nutrition", "vegetarian", "fruit", "food", "cherries" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chicken-grilled-stream", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cocktail", "keywords": [ "cook", "alcohol", "food", "cocktail", "drink", "cooking", "nutrition", "alcoholic", "beverage", "glass" ], "content": "\n\n\n" }, { "name": "coffee-bean", "keywords": [ "cook", "cooking", "nutrition", "coffee", "bean" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "coffee-mug", "keywords": [ "coffee", "cook", "cup", "drink", "mug", "cooking", "nutrition", "cafe", "caffeine", "food" ], "content": "\n\n\n" }, { "name": "coffee-takeaway-cup", "keywords": [ "cup", "coffee", "hot", "takeaway", "drink", "caffeine" ], "content": "\n\n\n" }, { "name": "donut", "keywords": [ "dessert", "donut" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fork-knife", "keywords": [ "fork", "spoon", "knife", "food", "dine", "cook", "utensils", "eat", "restaurant", "dining", "kitchenware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fork-spoon", "keywords": [ "fork", "spoon", "food", "dine", "cook", "utensils", "eat", "restaurant", "dining", "kitchenware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ice-cream-2", "keywords": [ "cook", "frozen", "popsicle", "freezer", "nutrition", "cream", "stick", "cold", "ice", "cooking" ], "content": "\n\n\n" }, { "name": "ice-cream-3", "keywords": [ "cook", "frozen", "cone", "cream", "ice", "cooking", "nutrition", "freezer", "cold", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lemon-fruit-seasoning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "microwave", "keywords": [ "cook", "food", "appliances", "cooking", "nutrition", "appliance", "microwave", "kitchenware" ], "content": "\n\n\n" }, { "name": "milkshake", "keywords": [ "milkshake", "drink", "takeaway", "cup", "cold", "beverage" ], "content": "\n\n\n" }, { "name": "popcorn", "keywords": [ "cook", "corn", "movie", "snack", "cooking", "nutrition", "bake", "popcorn" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pork-meat", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "refrigerator", "keywords": [ "fridge", "cook", "appliances", "cooking", "nutrition", "freezer", "appliance", "food", "kitchenware" ], "content": "\n\n\n" }, { "name": "serving-dome", "keywords": [ "cook", "tool", "dome", "kitchen", "serving", "paltter", "dish", "tools", "food", "kitchenware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shrimp", "keywords": [ "sea", "food", "shrimp" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "strawberry", "keywords": [ "fruit", "sweet", "berries", "plant", "strawberry" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tea-cup", "keywords": [ "herbal", "cook", "tea", "tisane", "cup", "drink", "cooking", "nutrition", "mug", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "toast", "keywords": [ "bread", "toast", "breakfast" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "water-glass", "keywords": [ "glass", "water", "juice", "drink", "liquid" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wine", "keywords": [ "drink", "cook", "glass", "cooking", "wine", "nutrition", "food" ], "content": "\n\n\n" } ], "health": [ { "name": "ambulance", "keywords": [ "car", "emergency", "health", "medical", "ambulance" ], "content": "\n\n\n" }, { "name": "bacteria-virus-cells-biology", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bandage", "keywords": [ "health", "medical", "hospital", "medicine", "capsule", "bandage", "vaccine" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blood-bag-donation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blood-donate-drop", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blood-drop-donation", "keywords": [], "content": "\n\n\n" }, { "name": "brain", "keywords": [ "medical", "health", "brain" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brain-cognitive", "keywords": [ "health", "medical", "brain", "cognitive", "specialities" ], "content": "\n\n\n" }, { "name": "call-center-support-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "checkup-medical-report-clipboard", "keywords": [], "content": "\n\n\n" }, { "name": "ear-hearing", "keywords": [ "health", "medical", "hearing", "ear" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "eye-optic", "keywords": [ "health", "medical", "eye", "optic" ], "content": "\n\n\n" }, { "name": "flu-mask", "keywords": [ "health", "medical", "hospital", "mask", "flu", "vaccine", "protection" ], "content": "\n\n\n" }, { "name": "health-care-2", "keywords": [ "health", "medical", "hospital", "heart", "care", "symbol" ], "content": "\n\n\n" }, { "name": "heart-rate-pulse-graph", "keywords": [], "content": "\n\n\n" }, { "name": "heart-rate-search", "keywords": [ "health", "medical", "monitor", "heart", "rate", "search" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hospital-sign-circle", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "circle", "emergency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hospital-sign-square", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "square", "emergency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insurance-hand", "keywords": [ "health", "medical", "insurance", "hand", "cross" ], "content": "\n\n\n" }, { "name": "medical-bag", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "bag", "medicine", "medkit" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "medical-cross-sign-healthcare", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "medical-cross-symbol", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "emergency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "medical-files-report-history", "keywords": [], "content": "\n\n\n" }, { "name": "medical-ribbon-1", "keywords": [ "ribbon", "medical", "cancer", "health", "beauty", "symbol" ], "content": "\n\n\n" }, { "name": "medical-search-diagnosis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "microscope-observation-sciene", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "nurse-assistant-emergency", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "nurse-hat", "keywords": [ "health", "medical", "hospital", "nurse", "doctor", "cap" ], "content": "\n\n\n" }, { "name": "online-medical-call-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "online-medical-service-monitor", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "online-medical-web-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "petri-dish-lab-equipment", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pharmacy", "keywords": [ "health", "medical", "pharmacy", "sign", "medicine", "mortar", "pestle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "prescription-pills-drugs-healthcare", "keywords": [], "content": "\n\n\n" }, { "name": "sign-cross-square", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "cross", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sos-help-emergency-sign", "keywords": [], "content": "\n\n\n" }, { "name": "stethoscope", "keywords": [ "instrument", "health", "medical", "stethoscope" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "syringe", "keywords": [ "instrument", "medical", "syringe", "health", "beauty", "needle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tablet-capsule", "keywords": [ "health", "medical", "hospital", "medicine", "capsule", "tablet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tooth", "keywords": [ "health", "medical", "tooth" ], "content": "\n\n\n" }, { "name": "virus-antivirus", "keywords": [ "health", "medical", "covid19", "flu", "influenza", "virus", "antivirus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "waiting-appointments-calendar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wheelchair", "keywords": [ "health", "medical", "hospital", "wheelchair", "disable", "help", "sign" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "images_photography": [ { "name": "auto-flash", "keywords": [], "content": "\n\n\n" }, { "name": "camera-1", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures" ], "content": "\n\n\n" }, { "name": "camera-disabled", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures", "disabled", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "camera-loading", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures", "loading", "option", "setting" ], "content": "\n\n\n" }, { "name": "camera-square", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures", "frame", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "composition-oval", "keywords": [ "camera", "frame", "composition", "photography", "pictures", "landscape", "photo", "oval" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "composition-vertical", "keywords": [ "camera", "portrait", "frame", "vertical", "composition", "photography", "photo" ], "content": "\n\n\n" }, { "name": "compsition-horizontal", "keywords": [ "camera", "horizontal", "panorama", "composition", "photography", "photo", "pictures" ], "content": "\n\n\n" }, { "name": "edit-image-photo", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "film-roll-1", "keywords": [ "photos", "camera", "shutter", "picture", "photography", "pictures", "photo", "film", "roll" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "film-slate", "keywords": [ "pictures", "photo", "film", "slate" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flash-1", "keywords": [ "flash", "power", "connect", "charge", "electricity", "lightning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flash-2", "keywords": [ "flash", "power", "connect", "charge", "electricity", "lightning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flash-3", "keywords": [ "flash", "power", "connect", "charge", "electricity", "lightning" ], "content": "\n\n\n" }, { "name": "flash-off", "keywords": [ "flash", "power", "connect", "charge", "off", "electricity", "lightning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flower", "keywords": [ "photos", "photo", "picture", "camera", "photography", "pictures", "flower", "image" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "focus-points", "keywords": [ "camera", "frame", "photography", "pictures", "photo", "focus", "position" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "landscape-2", "keywords": [ "photos", "photo", "landscape", "picture", "photography", "camera", "pictures", "image" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "landscape-setting", "keywords": [ "design", "composition", "horizontal", "lanscape" ], "content": "\n\n\n" }, { "name": "laptop-camera", "keywords": [ "photos", "photo", "picture", "photography", "camera", "pictures", "laptop", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mobile-phone-camera", "keywords": [ "photos", "photo", "picture", "photography", "camera", "pictures", "phone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "orientation-landscape", "keywords": [ "photos", "photo", "orientation", "landscape", "picture", "photography", "camera", "pictures", "image" ], "content": "\n\n\n" }, { "name": "orientation-portrait", "keywords": [ "photos", "photo", "orientation", "portrait", "picture", "photography", "camera", "pictures", "image" ], "content": "\n\n\n" }, { "name": "polaroid-four", "keywords": [ "photos", "camera", "polaroid", "picture", "photography", "pictures", "four", "photo", "image" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "interface_essential": [ { "name": "add-1", "keywords": [ "expand", "cross", "buttons", "button", "more", "remove", "plus", "add", "+", "mathematics", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-bell-notification", "keywords": [ "notification", "alarm", "alert", "bell", "add" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-circle", "keywords": [ "button", "remove", "cross", "add", "buttons", "plus", "circle", "+", "mathematics", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-layer-2", "keywords": [ "layer", "add", "design", "plus", "layers", "square", "box" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-square", "keywords": [ "square", "remove", "cross", "buttons", "add", "plus", "button", "+", "mathematics", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "alarm-clock", "keywords": [ "time", "tock", "stopwatch", "measure", "clock", "tick" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-back-1", "keywords": [ "back", "design", "layer", "layers", "pile", "stack", "arrange", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-center", "keywords": [ "text", "alignment", "align", "paragraph", "centered", "formatting", "center" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-front-1", "keywords": [ "design", "front", "layer", "layers", "pile", "stack", "arrange", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-left", "keywords": [ "paragraph", "text", "alignment", "align", "left", "formatting", "right" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-right", "keywords": [ "rag", "paragraph", "text", "alignment", "align", "right", "formatting", "left" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ampersand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "archive-box", "keywords": [ "box", "content", "banker", "archive", "file" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-bend-left-down-2", "keywords": [ "arrow", "bend", "curve", "change", "direction", "left", "to", "down" ], "content": "\n\n\n" }, { "name": "arrow-bend-right-down-2", "keywords": [ "arrow", "bend", "curve", "change", "direction", "right", "to", "down" ], "content": "\n\n\n" }, { "name": "arrow-crossover-down", "keywords": [ "cross", "move", "over", "arrow", "arrows", "down" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-crossover-left", "keywords": [ "cross", "move", "over", "arrow", "arrows", "left" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-crossover-right", "keywords": [ "cross", "move", "over", "arrow", "arrows", "ight" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-crossover-up", "keywords": [ "cross", "move", "over", "arrow", "arrows", "right" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-cursor-1", "keywords": [ "mouse", "select", "cursor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-cursor-2", "keywords": [ "mouse", "select", "cursor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-curvy-up-down-1", "keywords": [ "both", "direction", "arrow", "curvy", "diagram", "zigzag", "vertical" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-curvy-up-down-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-down-2", "keywords": [ "down", "move", "arrow", "arrows" ], "content": "\n\n\n" }, { "name": "arrow-down-dashed-square", "keywords": [ "arrow", "keyboard", "button", "down", "square", "dashes" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-expand", "keywords": [ "expand", "small", "bigger", "retract", "smaller", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-infinite-loop", "keywords": [ "arrow", "diagram", "loop", "infinity", "repeat" ], "content": "\n\n\n" }, { "name": "arrow-move", "keywords": [ "move", "button", "arrows", "direction" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-horizontal-1", "keywords": [ "arrows", "load", "arrow", "sync", "square", "loading", "reload", "synchronize" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-horizontal-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-vertical-1", "keywords": [ "arrows", "load", "arrow", "sync", "square", "loading", "reload", "synchronize" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-vertical-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-roadmap", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-round-left", "keywords": [ "diagram", "round", "arrow", "left" ], "content": "\n\n\n" }, { "name": "arrow-round-right", "keywords": [ "diagram", "round", "arrow", "right" ], "content": "\n\n\n" }, { "name": "arrow-shrink", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-shrink-diagonal-1", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-shrink-diagonal-2", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-transfer-diagonal-1", "keywords": [ "arrows", "arrow", "server", "data", "diagonal", "internet", "transfer", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-transfer-diagonal-2", "keywords": [ "arrows", "arrow", "server", "data", "diagonal", "internet", "transfer", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-transfer-diagonal-3", "keywords": [ "arrows", "arrow", "server", "data", "diagonal", "internet", "transfer", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-up-1", "keywords": [ "arrow", "up", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-up-dashed-square", "keywords": [ "arrow", "keyboard", "button", "up", "square", "dashes" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ascending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "attribution", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blank-calendar", "keywords": [ "blank", "calendar", "date", "day", "month", "empty" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blank-notepad", "keywords": [ "content", "notes", "book", "notepad", "notebook" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "block-bell-notification", "keywords": [ "notification", "alarm", "alert", "bell", "block" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bomb", "keywords": [ "delete", "bomb", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bookmark", "keywords": [ "bookmarks", "tags", "favorite" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "braces-circle", "keywords": [ "interface", "math", "braces", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brightness-1", "keywords": [ "bright", "adjust", "brightness", "adjustment", "sun", "raise", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brightness-2", "keywords": [ "bright", "adjust", "brightness", "adjustment", "sun", "raise", "controls", "half" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brightness-3", "keywords": [ "bright", "adjust", "brightness", "adjustment", "sun", "raise", "controls", "dot", "small" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "broken-link-2", "keywords": [ "break", "broken", "hyperlink", "link", "remove", "unlink", "chain" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bullet-list", "keywords": [ "points", "bullet", "unordered", "list", "lists", "bullets" ], "content": "\n\n\n" }, { "name": "calendar-add", "keywords": [ "add", "calendar", "date", "day", "month" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "calendar-edit", "keywords": [ "calendar", "date", "day", "compose", "edit", "note" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "calendar-jump-to-date", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "calendar-star", "keywords": [ "calendar", "date", "day", "favorite", "like", "month", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "celsius", "keywords": [ "degrees", "temperature", "centigrade", "celsius", "degree", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "check", "keywords": [ "check", "form", "validation", "checkmark", "success", "add", "addition", "tick" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "check-square", "keywords": [ "check", "form", "validation", "checkmark", "success", "add", "addition", "box", "square", "tick" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "circle", "keywords": [ "geometric", "circle", "round", "design", "shape", "shapes", "shape" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "circle-clock", "keywords": [ "clock", "loading", "measure", "time", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "clipboard-add", "keywords": [ "edit", "task", "edition", "add", "clipboard", "form" ], "content": "\n\n\n" }, { "name": "clipboard-check", "keywords": [ "checkmark", "edit", "task", "edition", "checklist", "check", "success", "clipboard", "form" ], "content": "\n\n\n" }, { "name": "clipboard-remove", "keywords": [ "edit", "task", "edition", "remove", "delete", "clipboard", "form" ], "content": "\n\n\n" }, { "name": "cloud", "keywords": [ "cloud", "meteorology", "cloudy", "overcast", "cover", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cog", "keywords": [ "work", "loading", "cog", "gear", "settings", "machine" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "color-palette", "keywords": [ "color", "palette", "company", "office", "supplies", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "color-picker", "keywords": [ "color", "colors", "design", "dropper", "eye", "eyedrop", "eyedropper", "painting", "picker" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "color-swatches", "keywords": [ "color", "colors", "design", "painting", "palette", "sample", "swatch" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cone-shape", "keywords": [], "content": "\n\n\n" }, { "name": "convert-PDF-2", "keywords": [ "essential", "files", "folder", "convert", "to", "PDF" ], "content": "\n\n\n" }, { "name": "copy-paste", "keywords": [ "clipboard", "copy", "cut", "paste" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "creative-commons", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "crop-selection", "keywords": [ "artboard", "crop", "design", "image", "picture" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "crown", "keywords": [ "reward", "social", "rating", "media", "queen", "vip", "king", "crown" ], "content": "\n\n\n" }, { "name": "customer-support-1", "keywords": [ "customer", "headset", "help", "microphone", "phone", "support" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cut", "keywords": [ "coupon", "cut", "discount", "price", "prices", "scissors" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dark-dislay-mode", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dashboard-3", "keywords": [ "app", "application", "dashboard", "home", "layout", "vertical" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dashboard-circle", "keywords": [ "app", "application", "dashboard", "home", "layout", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "delete-1", "keywords": [ "remove", "add", "button", "buttons", "delete", "cross", "x", "mathematics", "multiply", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "descending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "disable-bell-notification", "keywords": [ "disable", "silent", "notification", "off", "silence", "alarm", "bell", "alert" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "disable-heart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "division-circle", "keywords": [ "interface", "math", "divided", "by", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-box-1", "keywords": [ "arrow", "box", "down", "download", "internet", "network", "server", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-circle", "keywords": [ "arrow", "circle", "down", "download", "internet", "network", "server", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-computer", "keywords": [ "action", "actions", "computer", "desktop", "device", "display", "download", "monitor", "screen" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-file", "keywords": [], "content": "\n\n\n" }, { "name": "empty-clipboard", "keywords": [ "work", "plain", "clipboard", "task", "list", "company", "office" ], "content": "\n\n\n" }, { "name": "equal-sign", "keywords": [ "interface", "math", "equal", "sign", "mathematics" ], "content": "\n\n\n" }, { "name": "expand", "keywords": [ "big", "bigger", "design", "expand", "larger", "resize", "size", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "expand-horizontal-1", "keywords": [ "expand", "resize", "bigger", "horizontal", "smaller", "size", "arrow", "arrows", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "expand-window-2", "keywords": [ "expand", "small", "bigger", "retract", "smaller", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "face-scan-1", "keywords": [ "identification", "angle", "secure", "human", "id", "person", "face", "security", "brackets" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "factorial", "keywords": [ "interface", "math", "number", "factorial", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fahrenheit", "keywords": [ "degrees", "temperature", "fahrenheit", "degree", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fastforward-clock", "keywords": [ "time", "clock", "reset", "stopwatch", "circle", "measure", "loading" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "file-add-alternate", "keywords": [ "file", "common", "add" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "file-delete-alternate", "keywords": [ "file", "common", "delete", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "file-remove-alternate", "keywords": [ "file", "common", "remove", "minus", "subtract" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "filter-2", "keywords": [ "funnel", "filter", "angle", "oil" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fingerprint-1", "keywords": [ "identification", "password", "touch", "id", "secure", "fingerprint", "finger", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fingerprint-2", "keywords": [ "identification", "password", "touch", "id", "secure", "fingerprint", "finger", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fist", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fit-to-height-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flip-vertical-arrow-2", "keywords": [ "arrow", "design", "flip", "reflect", "up", "down" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flip-vertical-circle-1", "keywords": [ "flip", "bottom", "object", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flip-vertical-square-2", "keywords": [ "design", "up", "flip", "reflect", "vertical" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "folder-add", "keywords": [ "add", "folder", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "folder-check", "keywords": [ "remove", "check", "folder" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "folder-delete", "keywords": [ "remove", "minus", "folder", "subtract", "delete" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "front-camera", "keywords": [], "content": "\n\n\n" }, { "name": "gif-format", "keywords": [], "content": "\n\n\n" }, { "name": "give-gift", "keywords": [ "reward", "social", "rating", "media", "queen", "vip", "gift" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "glasses", "keywords": [ "vision", "sunglasses", "protection", "spectacles", "correction", "sun", "eye", "glasses" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "half-star-1", "keywords": [ "reward", "rating", "rate", "social", "star", "media", "favorite", "like", "stars", "half" ], "content": "\n\n\n" }, { "name": "hand-cursor", "keywords": [ "hand", "select", "cursor", "finger" ], "content": "\n\n\n" }, { "name": "hand-grab", "keywords": [ "hand", "select", "cursor", "finger", "grab" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heading-1-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heading-2-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heading-3-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heart", "keywords": [ "reward", "social", "rating", "media", "heart", "it", "like", "favorite", "love" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "help-chat-2", "keywords": [ "bubble", "help", "mark", "message", "query", "question", "speech", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "help-question-1", "keywords": [ "circle", "faq", "frame", "help", "info", "mark", "more", "query", "question" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-10", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n" }, { "name": "hierarchy-13", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-14", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-2", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-4", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n" }, { "name": "hierarchy-7", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "home-3", "keywords": [ "home", "house", "roof", "shelter" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "home-4", "keywords": [ "home", "house", "roof", "shelter" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "horizontal-menu-circle", "keywords": [ "navigation", "dots", "three", "circle", "button", "horizontal", "menu" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "humidity-none", "keywords": [ "humidity", "drop", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "image-blur", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "image-saturation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "information-circle", "keywords": [ "information", "frame", "info", "more", "help", "point", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "input-box", "keywords": [ "cursor", "text", "formatting", "type", "format" ], "content": "\n\n\n" }, { "name": "insert-side", "keywords": [ "points", "bullet", "align", "paragraph", "formatting", "bullets", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insert-top-left", "keywords": [ "alignment", "wrap", "formatting", "paragraph", "image", "left", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insert-top-right", "keywords": [ "paragraph", "image", "text", "alignment", "wrap", "right", "formatting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "invisible-1", "keywords": [ "disable", "eye", "eyeball", "hide", "off", "view" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "invisible-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "jump-object", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "key", "keywords": [ "entry", "key", "lock", "login", "pass", "unlock", "access" ], "content": "\n\n\n" }, { "name": "keyhole-lock-circle", "keywords": [ "circle", "frame", "key", "keyhole", "lock", "locked", "secure", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lasso-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layers-1", "keywords": [ "design", "layer", "layers", "pile", "stack", "align" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layers-2", "keywords": [ "design", "layer", "layers", "pile", "stack", "align" ], "content": "\n\n\n" }, { "name": "layout-window-1", "keywords": [ "column", "layout", "layouts", "left", "sidebar" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layout-window-11", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layout-window-2", "keywords": [ "column", "header", "layout", "layouts", "masthead", "sidebar" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layout-window-8", "keywords": [ "grid", "header", "layout", "layouts", "masthead" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lightbulb", "keywords": [ "lighting", "light", "incandescent", "bulb", "lights" ], "content": "\n\n\n" }, { "name": "like-1", "keywords": [ "reward", "social", "up", "rating", "media", "like", "thumb", "hand" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "link-chain", "keywords": [ "create", "hyperlink", "link", "make", "unlink", "connection", "chain" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "live-video", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lock-rotation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "login-1", "keywords": [ "arrow", "enter", "frame", "left", "login", "point", "rectangle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "logout-1", "keywords": [ "arrow", "exit", "frame", "leave", "logout", "rectangle", "right" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "loop-1", "keywords": [ "multimedia", "multi", "button", "repeat", "media", "loop", "infinity", "controls" ], "content": "\n\n\n" }, { "name": "magic-wand-2", "keywords": [ "design", "magic", "star", "supplies", "tool", "wand" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "magnifying-glass", "keywords": [ "glass", "search", "magnifying" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "magnifying-glass-circle", "keywords": [ "circle", "glass", "search", "magnifying" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "manual-book", "keywords": [], "content": "\n\n\n" }, { "name": "megaphone-2", "keywords": [ "bullhorn", "loud", "megaphone", "share", "speaker", "transmit" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "minimize-window-2", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "moon-cloud", "keywords": [ "cloud", "meteorology", "cloudy", "partly", "sunny", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "move-left", "keywords": [ "move", "left", "arrows" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "move-right", "keywords": [ "move", "right", "arrows" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "multiple-file-2", "keywords": [ "double", "common", "file" ], "content": "\n\n\n" }, { "name": "music-folder-song", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "new-file", "keywords": [ "empty", "common", "file", "content" ], "content": "\n\n\n" }, { "name": "new-folder", "keywords": [ "empty", "folder" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "new-sticky-note", "keywords": [ "empty", "common", "file" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "not-equal-sign", "keywords": [ "interface", "math", "not", "equal", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ok-hand", "keywords": [], "content": "\n\n\n" }, { "name": "one-finger-drag-horizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-finger-drag-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-finger-hold", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-finger-tap", "keywords": [], "content": "\n\n\n" }, { "name": "open-book", "keywords": [ "content", "books", "book", "open" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "open-umbrella", "keywords": [ "storm", "rain", "umbrella", "open", "weather" ], "content": "\n\n\n" }, { "name": "padlock-square-1", "keywords": [ "combination", "combo", "lock", "locked", "padlock", "secure", "security", "shield", "keyhole" ], "content": "\n\n\n" }, { "name": "page-setting", "keywords": [ "page", "setting", "square", "triangle", "circle", "line", "combination", "variation" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paint-bucket", "keywords": [ "bucket", "color", "colors", "design", "paint", "painting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paint-palette", "keywords": [ "color", "colors", "design", "paint", "painting", "palette" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paintbrush-1", "keywords": [ "brush", "color", "colors", "design", "paint", "painting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paintbrush-2", "keywords": [ "brush", "color", "colors", "design", "paint", "painting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paperclip-1", "keywords": [ "attachment", "link", "paperclip", "unlink" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paragraph", "keywords": [ "alignment", "paragraph", "formatting", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-divide", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-exclude", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-intersect", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-merge", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-minus-front-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-trim", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-union", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "peace-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pen-3", "keywords": [ "content", "creation", "edit", "pen", "pens", "write" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pen-draw", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pen-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pencil", "keywords": [ "change", "edit", "modify", "pencil", "write", "writing" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pentagon", "keywords": [ "pentagon", "design", "geometric", "shape", "shapes", "shape" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pi-symbol-circle", "keywords": [ "interface", "math", "pi", "sign", "mathematics", "22", "7" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pictures-folder-memories", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "podium", "keywords": [ "work", "desk", "notes", "company", "presentation", "office", "podium", "microphone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "polygon", "keywords": [ "polygon", "octangle", "design", "geometric", "shape", "shapes", "shape" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "praying-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "projector-board", "keywords": [ "projector", "screen", "work", "meeting", "presentation" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pyramid-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "quotation-2", "keywords": [ "quote", "quotation", "format", "formatting", "open", "close", "marks", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "radioactive-2", "keywords": [ "warning", "radioactive", "radiation", "emergency", "danger", "safety" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rain-cloud", "keywords": [ "cloud", "rain", "rainy", "meteorology", "precipitation", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "recycle-bin-2", "keywords": [ "remove", "delete", "empty", "bin", "trash", "garbage" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ringing-bell-notification", "keywords": [ "notification", "vibrate", "ring", "sound", "alarm", "alert", "bell", "noise" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rock-and-roll-hand", "keywords": [], "content": "\n\n\n" }, { "name": "rotate-angle-45", "keywords": [ "rotate", "angle", "company", "office", "supplies", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "round-cap", "keywords": [], "content": "\n\n\n" }, { "name": "satellite-dish", "keywords": [ "broadcast", "satellite", "share", "transmit", "satellite" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "scanner", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "search-visual", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "select-circle-area-1", "keywords": [ "select", "area", "object", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "share-link", "keywords": [ "share", "transmit" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-1", "keywords": [ "shield", "protection", "security", "defend", "crime", "war", "cover" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-2", "keywords": [ "shield", "protection", "security", "defend", "crime", "war", "cover" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-check", "keywords": [ "shield", "protection", "security", "defend", "crime", "war", "cover", "check" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-cross", "keywords": [ "shield", "secure", "security", "cross", "add", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shrink-horizontal-1", "keywords": [ "resize", "shrink", "bigger", "horizontal", "smaller", "size", "arrow", "arrows", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shuffle", "keywords": [ "multimedia", "shuffle", "multi", "button", "controls", "media" ], "content": "\n\n\n" }, { "name": "sigma", "keywords": [ "formula", "text", "format", "sigma", "formatting", "sum" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "skull-1", "keywords": [ "crash", "death", "delete", "die", "error", "garbage", "remove", "skull", "trash" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sleep", "keywords": [], "content": "\n\n\n" }, { "name": "snow-flake", "keywords": [ "winter", "freeze", "snow", "freezing", "ice", "cold", "weather", "snowflake" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sort-descending", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spiral-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "split-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spray-paint", "keywords": [ "can", "color", "colors", "design", "paint", "painting", "spray" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "square-brackets-circle", "keywords": [ "interface", "math", "brackets", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "square-cap", "keywords": [], "content": "\n\n\n" }, { "name": "square-clock", "keywords": [ "clock", "loading", "frame", "measure", "time", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "square-root-x-circle", "keywords": [ "interface", "math", "square", "root", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "star-1", "keywords": [ "reward", "rating", "rate", "social", "star", "media", "favorite", "like", "stars" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "star-2", "keywords": [ "reward", "rating", "rate", "social", "star", "media", "favorite", "like", "stars", "spark" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "star-badge", "keywords": [ "ribbon", "reward", "like", "social", "rating", "media" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "straight-cap", "keywords": [], "content": "\n\n\n" }, { "name": "subtract-1", "keywords": [ "button", "delete", "buttons", "subtract", "horizontal", "remove", "line", "add", "mathematics", "math", "minus" ], "content": "\n\n\n" }, { "name": "subtract-circle", "keywords": [ "delete", "add", "circle", "subtract", "button", "buttons", "remove", "mathematics", "math", "minus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "subtract-square", "keywords": [ "subtract", "buttons", "remove", "add", "button", "square", "delete", "mathematics", "math", "minus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sun-cloud", "keywords": [ "cloud", "meteorology", "cloudy", "partly", "sunny", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "synchronize-disable", "keywords": [ "arrows", "loading", "load", "sync", "synchronize", "arrow", "reload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "synchronize-warning", "keywords": [ "arrow", "fail", "notification", "sync", "warning", "failure", "synchronize", "error" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "table-lamp-1", "keywords": [ "lighting", "light", "incandescent", "bulb", "lights", "table", "lamp" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tag", "keywords": [ "tags", "bookmark", "favorite" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "text-flow-rows", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "text-square", "keywords": [ "text", "options", "formatting", "format", "square", "color", "border", "fill" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "text-style", "keywords": [ "text", "style", "formatting", "format" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "thermometer", "keywords": [ "temperature", "thermometer", "weather", "level", "meter", "mercury", "measure" ], "content": "\n\n\n" }, { "name": "trending-content", "keywords": [ "lit", "flame", "torch", "trending" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "trophy", "keywords": [ "reward", "rating", "trophy", "social", "award", "media" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "two-finger-drag-hotizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "two-finger-tap", "keywords": [], "content": "\n\n\n" }, { "name": "underline-text-1", "keywords": [ "text", "underline", "formatting", "format" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-box-1", "keywords": [ "arrow", "box", "download", "internet", "network", "server", "up", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-circle", "keywords": [ "arrow", "circle", "download", "internet", "network", "server", "up", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-computer", "keywords": [ "action", "actions", "computer", "desktop", "device", "display", "monitor", "screen", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-file", "keywords": [], "content": "\n\n\n" }, { "name": "user-add-plus", "keywords": [ "actions", "add", "close", "geometric", "human", "person", "plus", "single", "up", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-check-validate", "keywords": [ "actions", "close", "checkmark", "check", "geometric", "human", "person", "single", "success", "up", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-circle-single", "keywords": [ "circle", "geometric", "human", "person", "single", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-identifier-card", "keywords": [], "content": "\n\n\n" }, { "name": "user-multiple-circle", "keywords": [ "close", "geometric", "human", "multiple", "person", "up", "user", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-multiple-group", "keywords": [ "close", "geometric", "human", "multiple", "person", "up", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-profile-focus", "keywords": [ "close", "geometric", "human", "person", "profile", "focus", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-protection-2", "keywords": [ "shield", "secure", "security", "profile", "person" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-remove-subtract", "keywords": [ "actions", "remove", "close", "geometric", "human", "person", "minus", "single", "up", "user" ], "content": "\n\n\n" }, { "name": "user-single-neutral-male", "keywords": [ "close", "geometric", "human", "person", "single", "up", "user", "male" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-sync-online-in-person", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "vertical-slider-square", "keywords": [ "adjustment", "adjust", "controls", "fader", "vertical", "settings", "slider", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "video-swap-camera", "keywords": [], "content": "\n\n\n" }, { "name": "visible", "keywords": [ "eye", "eyeball", "open", "view" ], "content": "\n\n\n" }, { "name": "voice-scan-2", "keywords": [ "identification", "secure", "id", "soundwave", "sound", "voice", "brackets", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "waning-cresent-moon", "keywords": [ "night", "new", "moon", "crescent", "weather", "time", "waning" ], "content": "\n\n\n" }, { "name": "warning-octagon", "keywords": [ "frame", "alert", "warning", "octagon", "exclamation", "caution" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "warning-triangle", "keywords": [ "frame", "alert", "warning", "triangle", "exclamation", "caution" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "mail": [ { "name": "chat-bubble-oval", "keywords": [ "messages", "message", "bubble", "chat", "oval" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-oval-notification", "keywords": [ "messages", "message", "bubble", "chat", "oval", "notify", "ping" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-oval-smiley-1", "keywords": [ "messages", "message", "bubble", "chat", "oval", "smiley", "smile" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-oval-smiley-2", "keywords": [ "messages", "message", "bubble", "chat", "oval", "smiley", "smile" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-block", "keywords": [ "messages", "message", "bubble", "chat", "square", "block" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-question", "keywords": [ "bubble", "square", "messages", "notification", "chat", "message", "question", "help" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-warning", "keywords": [ "bubble", "square", "messages", "notification", "chat", "message", "warning", "alert" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-write", "keywords": [ "messages", "message", "bubble", "chat", "square", "write", "review", "pen", "pencil", "compose" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-text-square", "keywords": [ "messages", "message", "bubble", "text", "square", "chat" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-typing-oval", "keywords": [ "messages", "message", "bubble", "typing", "chat" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-two-bubbles-oval", "keywords": [ "messages", "message", "bubble", "chat", "oval", "conversation" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discussion-converstion-reply", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "happy-face", "keywords": [ "smiley", "chat", "message", "smile", "emoji", "face", "satisfied" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-block", "keywords": [ "mail", "envelope", "email", "message", "block", "spam", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-favorite", "keywords": [ "mail", "envelope", "email", "message", "star", "favorite", "important", "bookmark" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-favorite-heart", "keywords": [ "mail", "envelope", "email", "message", "heart", "favorite", "like", "love", "important", "bookmark" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-lock", "keywords": [ "mail", "envelope", "email", "message", "secure", "password", "lock", "encryption" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-tray-1", "keywords": [ "mail", "email", "outbox", "drawer", "empty", "open", "inbox", "arrow", "down" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-tray-2", "keywords": [ "mail", "email", "outbox", "drawer", "empty", "open", "inbox", "arrow", "up" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-incoming", "keywords": [ "inbox", "envelope", "email", "message", "down", "arrow", "inbox" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-search", "keywords": [ "inbox", "envelope", "email", "message", "search" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-send-email-message", "keywords": [ "send", "email", "paper", "airplane", "deliver" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-send-envelope", "keywords": [ "envelope", "email", "message", "unopened", "sealed", "close" ], "content": "\n\n\n" }, { "name": "mail-send-reply-all", "keywords": [ "email", "message", "reply", "all", "actions", "action", "arrow" ], "content": "\n\n\n" }, { "name": "sad-face", "keywords": [ "smiley", "chat", "message", "emoji", "sad", "face", "unsatisfied" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "send-email", "keywords": [ "mail", "send", "email", "paper", "airplane" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sign-at", "keywords": [ "mail", "email", "at", "sign", "read", "address" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sign-hashtag", "keywords": [ "mail", "sharp", "sign", "hashtag", "tag" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-angry", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-cool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-crying-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-cute", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-drool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-emoji-kiss-nervous", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-emoji-terrified", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-grumpy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-happy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-in-love", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-kiss", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-laughing-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "map_travel": [ { "name": "airplane", "keywords": [ "travel", "plane", "adventure", "airplane", "transportation" ], "content": "\n\n\n" }, { "name": "airport-plane-transit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "airport-plane", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "airport-security", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "anchor", "keywords": [ "anchor", "marina", "harbor", "port", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "baggage", "keywords": [ "check", "baggage", "travel", "adventure", "luggage", "bag", "checked", "airport" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "beach", "keywords": [ "island", "waves", "outdoor", "recreation", "tree", "beach", "palm", "wave", "water", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bicycle-bike", "keywords": [], "content": "\n\n\n" }, { "name": "braille-blind", "keywords": [ "disability", "braille", "blind" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bus", "keywords": [ "transportation", "travel", "bus", "transit", "transport", "motorcoach", "public" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "camping-tent", "keywords": [ "outdoor", "recreation", "camping", "tent", "teepee", "tipi", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cane", "keywords": [ "disability", "cane" ], "content": "\n\n\n" }, { "name": "capitol", "keywords": [ "capitol", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "car-battery-charging", "keywords": [], "content": "\n\n\n" }, { "name": "car-taxi-1", "keywords": [ "transportation", "travel", "taxi", "transport", "cab", "car" ], "content": "\n\n\n\n\n" }, { "name": "city-hall", "keywords": [ "city", "hall", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "compass-navigator", "keywords": [], "content": "\n\n\n" }, { "name": "crutch", "keywords": [ "disability", "crutch" ], "content": "\n\n\n" }, { "name": "dangerous-zone-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "earth-1", "keywords": [ "planet", "earth", "globe", "world" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "earth-airplane", "keywords": [ "travel", "plane", "trip", "airplane", "international", "adventure", "globe", "world", "airport" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "emergency-exit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fire-alarm-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fire-extinguisher-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gas-station-fuel-petroleum", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hearing-deaf-1", "keywords": [ "disability", "hearing", "deaf" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hearing-deaf-2", "keywords": [ "disability", "hearing", "deaf" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "high-speed-train-front", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hot-spring", "keywords": [ "relax", "location", "outdoor", "recreation", "spa", "travel", "places" ], "content": "\n\n\n" }, { "name": "hotel-air-conditioner", "keywords": [ "heating", "ac", "air", "hvac", "cool", "cooling", "cold", "hot", "conditioning", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hotel-bed-2", "keywords": [ "bed", "double", "bedroom", "bedrooms", "queen", "king", "full", "hotel", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hotel-laundry", "keywords": [ "laundry", "machine", "hotel" ], "content": "\n\n\n" }, { "name": "hotel-one-star", "keywords": [ "one", "star", "reviews", "review", "rating", "hotel", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hotel-shower-head", "keywords": [ "bathe", "bath", "bathroom", "shower", "water", "head", "hotel" ], "content": "\n\n\n" }, { "name": "hotel-two-star", "keywords": [ "two", "stars", "reviews", "review", "rating", "hotel", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "information-desk-customer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "information-desk", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "iron", "keywords": [ "laundry", "iron", "heat", "hotel" ], "content": "\n\n\n" }, { "name": "ladder", "keywords": [ "business", "product", "metaphor", "ladder" ], "content": "\n\n\n" }, { "name": "lift", "keywords": [ "arrow", "up", "human", "down", "person", "user", "lift", "elevator" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lift-disability", "keywords": [ "arrow", "up", "human", "down", "person", "user", "lift", "elevator", "disability", "wheelchair", "accessible" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-compass-1", "keywords": [ "arrow", "compass", "location", "gps", "map", "maps", "point" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-pin-3", "keywords": [ "navigation", "map", "maps", "pin", "gps", "location" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-pin-disabled", "keywords": [ "navigation", "map", "maps", "pin", "gps", "location", "disabled", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-target-1", "keywords": [ "navigation", "location", "map", "services", "maps", "gps", "target" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lost-and-found", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "man-symbol", "keywords": [ "geometric", "gender", "boy", "person", "male", "human", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "map-fold", "keywords": [ "navigation", "map", "maps", "gps", "travel", "fold" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "navigation-arrow-off", "keywords": [ "compass", "arrow", "map", "bearing", "navigation", "maps", "heading", "gps", "off", "disable" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "navigation-arrow-on", "keywords": [ "compass", "arrow", "map", "bearing", "navigation", "maps", "heading", "gps", "off", "disable" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "parking-sign", "keywords": [ "discount", "coupon", "parking", "price", "prices", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "parliament", "keywords": [ "travel", "places", "parliament" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "passport", "keywords": [ "travel", "book", "id", "adventure", "visa", "airport" ], "content": "\n\n\n" }, { "name": "pet-paw", "keywords": [ "paw", "foot", "animals", "pets", "footprint", "track", "hotel" ], "content": "\n\n\n" }, { "name": "pets-allowed", "keywords": [ "travel", "wayfinder", "pets", "allowed" ], "content": "\n\n\n" }, { "name": "pool-ladder", "keywords": [ "pool", "stairs", "swim", "swimming", "water", "ladder", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rock-slide", "keywords": [ "hill", "cliff", "sign", "danger", "stone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sail-ship", "keywords": [ "travel", "boat", "transportation", "transport", "ocean", "ship", "sea", "water" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "school-bus-side", "keywords": [], "content": "\n\n\n" }, { "name": "smoke-detector", "keywords": [ "smoke", "alert", "fire", "signal" ], "content": "\n\n\n" }, { "name": "smoking-area", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "snorkle", "keywords": [ "diving", "scuba", "outdoor", "recreation", "ocean", "mask", "water", "sea", "snorkle", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "steering-wheel", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "street-road", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "street-sign", "keywords": [ "crossroad", "street", "sign", "metaphor", "directions", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "take-off", "keywords": [ "travel", "plane", "adventure", "airplane", "take", "off", "airport" ], "content": "\n\n\n" }, { "name": "toilet-man", "keywords": [ "travel", "wayfinder", "toilet", "man" ], "content": "\n\n\n" }, { "name": "toilet-sign-man-woman-2", "keywords": [ "toilet", "sign", "restroom", "bathroom", "user", "human", "person" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "toilet-women", "keywords": [ "travel", "wayfinder", "toilet", "women" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "traffic-cone", "keywords": [ "street", "sign", "traffic", "cone", "road" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "triangle-flag", "keywords": [ "navigation", "map", "maps", "flag", "gps", "location", "destination", "goal" ], "content": "\n\n\n" }, { "name": "wheelchair-1", "keywords": [ "person", "access", "wheelchair", "accomodation", "human", "disability", "disabled", "user" ], "content": "\n\n\n" }, { "name": "woman-symbol", "keywords": [ "geometric", "gender", "female", "person", "human", "user" ], "content": "\n\n\n" } ], "money_shopping": [ { "name": "annoncement-megaphone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "backpack", "keywords": [ "bag", "backpack", "school", "baggage", "cloth", "clothing", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-dollar", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-pound", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-rupee", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-suitcase-1", "keywords": [ "product", "business", "briefcase" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-suitcase-2", "keywords": [ "product", "business", "briefcase" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-yen", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ball", "keywords": [ "sports", "ball", "sport", "basketball", "shopping", "catergories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bank", "keywords": [ "institution", "saving", "bank", "payment", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "beanie", "keywords": [ "beanie", "winter", "hat", "warm", "cloth", "clothing", "wearable", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bill-1", "keywords": [ "billing", "bills", "payment", "finance", "cash", "currency", "money", "accounting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bill-2", "keywords": [ "currency", "billing", "payment", "finance", "cash", "bill", "money", "accounting" ], "content": "\n\n\n" }, { "name": "bill-4", "keywords": [ "accounting", "billing", "payment", "finance", "cash", "currency", "money", "bill", "dollar", "stack" ], "content": "\n\n\n" }, { "name": "bill-cashless", "keywords": [ "currency", "billing", "payment", "finance", "no", "cash", "bill", "money", "accounting", "cashless" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "binance-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "binance", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bitcoin", "keywords": [ "crypto", "circle", "payment", "blokchain", "finance", "bitcoin", "money", "currency" ], "content": "\n\n\n" }, { "name": "bow-tie", "keywords": [ "bow", "tie", "dress", "gentleman", "cloth", "clothing", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "briefcase-dollar", "keywords": [ "briefcase", "payment", "cash", "money", "finance", "baggage", "bag" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "building-2", "keywords": [ "real", "home", "tower", "building", "house", "estate" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "business-card", "keywords": [ "name", "card", "business", "information", "money", "payment" ], "content": "\n\n\n" }, { "name": "business-handshake", "keywords": [ "deal", "contract", "business", "money", "payment", "agreement" ], "content": "\n\n\n" }, { "name": "business-idea-money", "keywords": [], "content": "\n\n\n" }, { "name": "business-profession-home-office", "keywords": [ "workspace", "home", "office", "work", "business", "remote", "working" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "business-progress-bar-2", "keywords": [ "business", "production", "arrow", "workflow", "money", "flag", "timeline" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "business-user-curriculum", "keywords": [], "content": "\n\n\n" }, { "name": "calculator-1", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "calculate", "math" ], "content": "\n\n\n" }, { "name": "calculator-2", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "calculate", "math", "sign" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cane", "keywords": [ "walking", "stick", "cane", "accessories", "gentleman", "accessories" ], "content": "\n\n\n" }, { "name": "chair", "keywords": [ "chair", "business", "product", "comfort", "decoration", "sit", "furniture" ], "content": "\n\n\n" }, { "name": "closet", "keywords": [ "closet", "dressing", "dresser", "product", "decoration", "cloth", "clothing", "cabinet", "furniture" ], "content": "\n\n\n" }, { "name": "coin-share", "keywords": [ "payment", "cash", "money", "finance", "receive", "give", "coin", "hand" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "coins-stack", "keywords": [ "accounting", "billing", "payment", "stack", "cash", "coins", "currency", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "credit-card-1", "keywords": [ "credit", "pay", "payment", "debit", "card", "finance", "plastic", "money", "atm" ], "content": "\n\n\n" }, { "name": "credit-card-2", "keywords": [ "deposit", "payment", "finance", "atm", "withdraw", "atm" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "diamond-2", "keywords": [ "diamond", "money", "payment", "finance", "wealth", "jewelry" ], "content": "\n\n\n" }, { "name": "discount-percent-badge", "keywords": [ "shop", "shops", "stores", "discount", "coupon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discount-percent-circle", "keywords": [ "store", "shop", "shops", "stores", "discount", "coupon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discount-percent-coupon", "keywords": [ "shop", "shops", "stores", "discount", "coupon", "voucher" ], "content": "\n\n\n" }, { "name": "discount-percent-cutout", "keywords": [ "store", "shop", "shops", "stores", "discount", "coupon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discount-percent-fire", "keywords": [ "shop", "shops", "stores", "discount", "coupon", "hot", "trending" ], "content": "\n\n\n" }, { "name": "dollar-coin", "keywords": [ "accounting", "billing", "payment", "cash", "coin", "currency", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dollar-coin-1", "keywords": [ "accounting", "billing", "payment", "cash", "coin", "currency", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dressing-table", "keywords": [ "makeup", "dressing", "table", "mirror", "cabinet", "product", "decoration", "furniture" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ethereum", "keywords": [ "crypto", "circle", "payment", "blokchain", "finance", "ethereum", "eth", "currency" ], "content": "\n\n\n" }, { "name": "ethereum-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "eth", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "euro", "keywords": [ "exchange", "payment", "euro", "forex", "finance", "foreign", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gift", "keywords": [ "reward", "box", "social", "present", "gift", "media", "rating", "bow" ], "content": "\n\n\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "gift-2", "keywords": [ "reward", "box", "social", "present", "gift", "media", "rating", "bow" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gold", "keywords": [ "gold", "money", "payment", "bars", "finance", "wealth", "bullion", "jewelry" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph", "keywords": [ "analytics", "business", "product", "graph", "data", "chart", "analysis" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph-arrow-decrease", "keywords": [ "down", "stats", "graph", "descend", "right", "arrow" ], "content": "\n\n\n" }, { "name": "graph-arrow-increase", "keywords": [ "ascend", "growth", "up", "arrow", "stats", "graph", "right", "grow" ], "content": "\n\n\n" }, { "name": "graph-bar-decrease", "keywords": [ "arrow", "product", "performance", "down", "decrease", "graph", "business", "chart" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph-bar-increase", "keywords": [ "up", "product", "performance", "increase", "arrow", "graph", "business", "chart" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph-dot", "keywords": [ "product", "data", "bars", "analysis", "analytics", "graph", "business", "chart", "dot" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "investment-selection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "justice-hammer", "keywords": [ "hammer", "work", "mallet", "office", "company", "gavel", "justice", "judge", "arbitration", "court" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "justice-scale-1", "keywords": [ "office", "work", "scale", "justice", "company", "arbitration", "balance", "court" ], "content": "\n\n\n" }, { "name": "justice-scale-2", "keywords": [ "office", "work", "scale", "justice", "unequal", "company", "arbitration", "unbalance", "court" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lipstick", "keywords": [ "fashion", "beauty", "lip", "lipstick", "makeup", "shopping" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "make-up-brush", "keywords": [ "fashion", "beauty", "make", "up", "brush" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "moustache", "keywords": [ "fashion", "beauty", "moustache", "grooming" ], "content": "\n\n\n" }, { "name": "mouth-lip", "keywords": [ "fashion", "beauty", "mouth", "lip" ], "content": "\n\n\n" }, { "name": "necklace", "keywords": [ "diamond", "money", "payment", "finance", "wealth", "accessory", "necklace", "jewelry" ], "content": "\n\n\n" }, { "name": "necktie", "keywords": [ "necktie", "businessman", "business", "cloth", "clothing", "gentleman", "accessories" ], "content": "\n\n\n" }, { "name": "payment-10", "keywords": [ "deposit", "payment", "finance", "atm", "transfer", "dollar" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "payment-cash-out-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "pie-chart", "keywords": [ "product", "data", "analysis", "analytics", "pie", "business", "chart" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "piggy-bank", "keywords": [ "institution", "saving", "bank", "payment", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "polka-dot-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "polka", "dot", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "production-belt", "keywords": [ "production", "produce", "box", "belt", "factory", "product", "package", "business" ], "content": "\n\n\n" }, { "name": "qr-code", "keywords": [ "codes", "tags", "code", "qr" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt-add", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt", "add", "plus", "new" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt-check", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt", "check", "confirm" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt-subtract", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt", "subtract", "minus", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "safe-vault", "keywords": [ "saving", "combo", "payment", "safe", "combination", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "scanner-3", "keywords": [ "payment", "electronic", "cash", "dollar", "codes", "tags", "upc", "barcode", "qr" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "scanner-bar-code", "keywords": [ "codes", "tags", "upc", "barcode" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shelf", "keywords": [ "shelf", "drawer", "cabinet", "prodcut", "decoration", "furniture" ], "content": "\n\n\n" }, { "name": "shopping-bag-hand-bag-2", "keywords": [ "shopping", "bag", "purse", "goods", "item", "products" ], "content": "\n\n\n" }, { "name": "shopping-basket-1", "keywords": [ "shopping", "basket" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-basket-2", "keywords": [ "shopping", "basket" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-1", "keywords": [ "shopping", "cart", "checkout" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-2", "keywords": [ "shopping", "cart", "checkout" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-3", "keywords": [ "shopping", "cart", "checkout" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-add", "keywords": [ "shopping", "cart", "checkout", "add", "plus", "new" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-check", "keywords": [ "shopping", "cart", "checkout", "check", "confirm" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-subtract", "keywords": [ "shopping", "cart", "checkout", "subtract", "minus", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "signage-3", "keywords": [ "street", "sandwich", "shops", "shop", "stores", "board", "sign", "store" ], "content": "\n\n\n" }, { "name": "signage-4", "keywords": [ "street", "billboard", "shops", "shop", "stores", "board", "sign", "ads", "banner" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "startup", "keywords": [ "shop", "rocket", "launch", "startup" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "stock", "keywords": [ "price", "stock", "wallstreet", "dollar", "money", "currency", "fluctuate", "candlestick", "business" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "store-1", "keywords": [ "store", "shop", "shops", "stores" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "store-2", "keywords": [ "store", "shop", "shops", "stores" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "store-computer", "keywords": [ "store", "shop", "shops", "stores", "online", "computer", "website", "desktop", "app" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "subscription-cashflow", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tag", "keywords": [ "codes", "tags", "tag", "product", "label" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tall-hat", "keywords": [ "tall", "hat", "cloth", "clothing", "wearable", "magician", "gentleman", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "target", "keywords": [ "shop", "bullseye", "arrow", "target" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "target-3", "keywords": [ "shop", "bullseye", "shooting", "target" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wallet", "keywords": [ "money", "payment", "finance", "wallet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wallet-purse", "keywords": [ "money", "payment", "finance", "wallet", "purse" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "xrp-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "xrp", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "yuan", "keywords": [ "exchange", "payment", "forex", "finance", "yuan", "foreign", "currency" ], "content": "\n\n\n" }, { "name": "yuan-circle", "keywords": [ "exchange", "payment", "forex", "finance", "yuan", "foreign", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "nature_ecology": [ { "name": "affordable-and-clean-energy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "alien", "keywords": [ "science", "extraterristerial", "life", "form", "space", "universe", "head", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bone", "keywords": [ "nature", "pet", "dog", "bone", "food", "snack" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cat-1", "keywords": [ "nature", "head", "cat", "pet", "animals", "felyne" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "circle-flask", "keywords": [ "science", "experiment", "lab", "flask", "chemistry", "solution" ], "content": "\n\n\n" }, { "name": "clean-water-and-sanitation", "keywords": [], "content": "\n\n\n" }, { "name": "comet", "keywords": [ "nature", "meteor", "fall", "space", "object", "danger" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "decent-work-and-economic-growth", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dna", "keywords": [ "science", "biology", "experiment", "lab", "science" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "erlenmeyer-flask", "keywords": [ "science", "experiment", "lab", "flask", "chemistry", "solution" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flower", "keywords": [ "nature", "plant", "tree", "flower", "petals", "bloom" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "galaxy-1", "keywords": [ "science", "space", "universe", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "galaxy-2", "keywords": [ "science", "space", "universe", "astronomy" ], "content": "\n\n\n" }, { "name": "gender-equality", "keywords": [], "content": "\n\n\n" }, { "name": "good-health-and-well-being", "keywords": [], "content": "\n\n\n" }, { "name": "industry-innovation-and-infrastructure", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "leaf", "keywords": [ "nature", "environment", "leaf", "ecology", "plant", "plants", "eco" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "log", "keywords": [ "nature", "tree", "plant", "circle", "round", "log" ], "content": "\n\n\n" }, { "name": "no-poverty", "keywords": [], "content": "\n\n\n" }, { "name": "octopus", "keywords": [ "nature", "sealife", "animals" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "planet", "keywords": [ "science", "solar", "system", "ring", "planet", "saturn", "space", "astronomy", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "potted-flower-tulip", "keywords": [ "nature", "flower", "plant", "tree", "pot" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "quality-education", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rainbow", "keywords": [ "nature", "arch", "rain", "colorful", "rainbow", "curve", "half", "circle" ], "content": "\n\n\n" }, { "name": "recycle-1", "keywords": [ "nature", "sign", "environment", "protect", "save", "arrows" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "reduced-inequalities", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rose", "keywords": [ "nature", "flower", "rose", "plant", "tree" ], "content": "\n\n\n" }, { "name": "shell", "keywords": [ "nature", "sealife", "animals" ], "content": "\n\n\n" }, { "name": "shovel-rake", "keywords": [ "nature", "crops", "plants" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sprout", "keywords": [], "content": "\n\n\n" }, { "name": "telescope", "keywords": [ "science", "experiment", "star", "gazing", "sky", "night", "space", "universe", "astronomy", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "test-tube", "keywords": [ "science", "experiment", "lab", "chemistry", "test", "tube", "solution" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tidal-wave", "keywords": [ "nature", "ocean", "wave" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "tree-2", "keywords": [ "nature", "tree", "plant", "circle", "round", "park" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tree-3", "keywords": [ "nature", "tree", "plant", "cloud", "shape", "park" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "volcano", "keywords": [ "nature", "eruption", "erupt", "mountain", "volcano", "lava", "magma", "explosion" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "windmill", "keywords": [], "content": "\n\n\n" }, { "name": "zero-hunger", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "phone": [ { "name": "airplane-disabled", "keywords": [ "server", "plane", "airplane", "disabled", "off", "wireless", "mode", "internet", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "airplane-enabled", "keywords": [ "server", "plane", "airplane", "enabled", "on", "wireless", "mode", "internet", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "back-camera-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "camera", "lenses" ], "content": "\n\n\n" }, { "name": "call-hang-up", "keywords": [ "phone", "telephone", "mobile", "device", "smartphone", "call", "hang", "up" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cellular-network-4g", "keywords": [], "content": "\n\n\n" }, { "name": "cellular-network-5g", "keywords": [], "content": "\n\n\n" }, { "name": "cellular-network-lte", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "contact-phonebook-2", "keywords": [], "content": "\n\n\n" }, { "name": "hang-up-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "telephone", "hang", "up" ], "content": "\n\n\n" }, { "name": "hang-up-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "telephone", "hang", "up" ], "content": "\n\n\n" }, { "name": "incoming-call", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing", "incoming", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "missed-call", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "missed", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-alarm-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "bell", "alarm" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-application-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "box" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-application-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "box" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-message-alert", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "message", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "outgoing-call", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing", "outgoing", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "phone", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "phone-mobile-phone", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone" ], "content": "\n\n\n" }, { "name": "phone-qr", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "qr", "code", "scan" ], "content": "\n\n\n" }, { "name": "phone-ringing-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing", "incoming", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "phone-ringing-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing" ], "content": "\n\n\n" }, { "name": "signal-full", "keywords": [ "phone", "mobile", "device", "signal", "wireless", "smartphone", "iphone", "bar", "bars", "full", "android" ], "content": "\n\n\n" }, { "name": "signal-low", "keywords": [ "phone", "mobile", "device", "signal", "wireless", "smartphone", "iphone", "bar", "low", "bars", "android" ], "content": "\n\n\n" }, { "name": "signal-medium", "keywords": [ "smartphone", "phone", "mobile", "device", "iphone", "signal", "medium", "wireless", "bar", "bars", "android" ], "content": "\n\n\n" }, { "name": "signal-none", "keywords": [ "phone", "mobile", "device", "signal", "wireless", "smartphone", "iphone", "bar", "bars", "no", "zero", "android" ], "content": "\n\n\n" } ], "programing": [ { "name": "application-add", "keywords": [ "application", "new", "add", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bracket", "keywords": [ "code", "angle", "programming", "file", "bracket" ], "content": "\n\n\n" }, { "name": "browser-add", "keywords": [ "app", "code", "apps", "add", "window", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-block", "keywords": [ "block", "access", "denied", "window", "browser", "privacy", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-build", "keywords": [ "build", "website", "development", "window", "code", "web", "backend", "browser", "dev" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-check", "keywords": [ "checkmark", "pass", "window", "app", "code", "success", "check", "apps" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-delete", "keywords": [ "app", "code", "apps", "fail", "delete", "window", "remove", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-hash", "keywords": [ "window", "hash", "code", "internet", "language", "browser", "web", "tag" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-lock", "keywords": [ "secure", "password", "window", "browser", "lock", "security", "login", "encryption" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-multiple-window", "keywords": [ "app", "code", "apps", "two", "window", "cascade" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-remove", "keywords": [ "app", "code", "apps", "subtract", "window", "minus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-website-1", "keywords": [ "app", "code", "apps", "window", "website", "web" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug", "keywords": [ "code", "bug", "security", "programming", "secure", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-antivirus-debugging", "keywords": [ "code", "bug", "security", "programming", "secure", "computer", "antivirus", "block", "protection", "malware", "debugging" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-antivirus-shield", "keywords": [ "code", "bug", "security", "programming", "secure", "computer", "antivirus", "shield", "protection", "malware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-virus-browser", "keywords": [ "bug", "browser", "file", "virus", "threat", "danger", "internet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-virus-document", "keywords": [ "bug", "document", "file", "virus", "threat", "danger" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-virus-folder", "keywords": [ "bug", "document", "folder", "virus", "threat", "danger" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-add", "keywords": [ "cloud", "network", "internet", "add", "server", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-block", "keywords": [ "cloud", "network", "internet", "block", "server", "deny" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-check", "keywords": [ "cloud", "network", "internet", "check", "server", "approve" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-data-transfer", "keywords": [ "cloud", "data", "transfer", "internet", "server", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-refresh", "keywords": [ "cloud", "network", "internet", "server", "refresh" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-share", "keywords": [ "cloud", "network", "internet", "server", "share" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-warning", "keywords": [ "cloud", "network", "internet", "server", "warning", "alert" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-wifi", "keywords": [ "cloud", "wifi", "internet", "server", "network" ], "content": "\n\n\n" }, { "name": "code-analysis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "code-monitor-1", "keywords": [ "code", "tags", "angle", "bracket", "monitor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "code-monitor-2", "keywords": [ "code", "tags", "angle", "image", "ui", "ux", "design" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "css-three", "keywords": [ "language", "three", "code", "programming", "html", "css" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "curly-brackets", "keywords": [], "content": "\n\n\n" }, { "name": "file-code-1", "keywords": [ "code", "files", "angle", "programming", "file", "bracket" ], "content": "\n\n\n" }, { "name": "incognito-mode", "keywords": [ "internet", "safe", "mode", "browser" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insert-cloud-video", "keywords": [], "content": "\n\n\n" }, { "name": "markdown-circle-programming", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "markdown-document-programming", "keywords": [], "content": "\n\n\n" }, { "name": "module-puzzle-1", "keywords": [ "code", "puzzle", "module", "programming", "plugin", "piece" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "module-puzzle-3", "keywords": [ "code", "puzzle", "module", "programming", "plugin", "piece" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "module-three", "keywords": [ "code", "three", "module", "programming", "plugin" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rss-square", "keywords": [ "wireless", "rss", "feed", "square", "transmit", "broadcast" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "shipping": [ { "name": "box-sign", "keywords": [ "box", "package", "label", "delivery", "shipment", "shipping", "this", "way", "up", "arrow", "sign", "sticker" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "container", "keywords": [ "box", "package", "label", "delivery", "shipment", "shipping", "container" ], "content": "\n\n\n" }, { "name": "fragile", "keywords": [ "fragile", "shipping", "glass", "delivery", "wine", "crack", "shipment", "sign", "sticker" ], "content": "\n\n\n" }, { "name": "parachute-drop", "keywords": [ "package", "box", "fulfillment", "cart", "warehouse", "shipping", "delivery", "drop", "parachute" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-add", "keywords": [ "shipping", "parcel", "shipment", "add" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-check", "keywords": [ "shipping", "parcel", "shipment", "check", "approved" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-download", "keywords": [ "shipping", "parcel", "shipment", "download" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-remove", "keywords": [ "shipping", "parcel", "shipment", "remove", "subtract" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-upload", "keywords": [ "shipping", "parcel", "shipment", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipping-box-1", "keywords": [ "box", "package", "label", "delivery", "shipment", "shipping" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipping-truck", "keywords": [ "truck", "shipping", "delivery", "transfer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "transfer-motorcycle", "keywords": [ "motorcycle", "shipping", "delivery", "courier", "transfer" ], "content": "\n\n\n" }, { "name": "transfer-van", "keywords": [ "van", "shipping", "delivery", "transfer" ], "content": "\n\n\n" }, { "name": "warehouse-1", "keywords": [ "delivery", "warehouse", "shipping", "fulfillment" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "work_education": [ { "name": "book-reading", "keywords": [ "book", "reading", "learning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "class-lesson", "keywords": [ "class", "lesson", "education", "teacher" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "collaborations-idea", "keywords": [ "collaborations", "idea", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "definition-search-book", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dictionary-language-book", "keywords": [], "content": "\n\n\n" }, { "name": "global-learning", "keywords": [ "global", "learning", "education" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graduation-cap", "keywords": [ "graduation", "cap", "education" ], "content": "\n\n\n" }, { "name": "group-meeting-call", "keywords": [ "group", "meeting", "call", "work" ], "content": "\n\n\n" }, { "name": "office-building-1", "keywords": [ "office", "building", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "office-worker", "keywords": [ "office", "worker", "human", "resources" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "search-dollar", "keywords": [ "search", "pay", "product", "currency", "query", "magnifying", "cash", "business", "money", "glass" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "strategy-tasks", "keywords": [ "strategy", "tasks", "work" ], "content": "\n\n\n" }, { "name": "task-list", "keywords": [ "task", "list", "work" ], "content": "\n\n\n" }, { "name": "workspace-desk", "keywords": [ "workspace", "desk", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ] } ================================================ FILE: frontend/appflowy_flutter/assets/template/readme.afdoc ================================================ { "document": { "type": "editor", "children": [ { "type": "cover" }, { "type": "text", "attributes": { "subtype": "heading", "heading": "h1" }, "delta": [{ "insert": "Welcome to AppFlowy!" }] }, { "type": "text", "attributes": { "subtype": "heading", "heading": "h2" }, "delta": [{ "insert": "Here are the basics" }] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": null }, "delta": [{ "insert": "Click anywhere and just start typing." }] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": false }, "delta": [ { "insert": "Highlight ", "attributes": { "backgroundColor": "0x4dffeb3b" } }, { "insert": "any text, and use the editing menu to " }, { "insert": "style", "attributes": { "italic": true } }, { "insert": " " }, { "insert": "your", "attributes": { "bold": true } }, { "insert": " " }, { "insert": "writing", "attributes": { "underline": true } }, { "insert": " " }, { "insert": "however", "attributes": { "code": true } }, { "insert": " you " }, { "insert": "like.", "attributes": { "strikethrough": true } } ] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": null }, "delta": [ { "insert": "As soon as you type " }, { "insert": "/", "attributes": { "code": true, "color": "0xff00b5ff" } }, { "insert": " a menu will pop up. Select " }, { "insert": "different types", "attributes": { "backgroundColor": "0x4d9c27b0" } }, { "insert": " of content blocks you can add." } ] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": null }, "delta": [ { "insert": "Type " }, { "insert": "/", "attributes": { "code": true } }, { "insert": " followed by " }, { "insert": "/bullet", "attributes": { "code": true } }, { "insert": " or " }, { "insert": "/num", "attributes": { "code": true } }, { "insert": " to create a list.", "attributes": { "code": false } } ] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": true }, "delta": [ { "insert": "Click " }, { "insert": "+ New Page ", "attributes": { "code": true } }, { "insert": "button at the bottom of your sidebar to add a new page." } ] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": null }, "delta": [ { "insert": "Click " }, { "insert": "+", "attributes": { "code": true } }, { "insert": " next to any page title in the sidebar to " }, { "insert": "quickly", "attributes": { "color": "0xff8427e0" } }, { "insert": " add a new subpage, " }, { "insert": "Document", "attributes": { "code": true } }, { "insert": ", ", "attributes": { "code": false } }, { "insert": "Grid", "attributes": { "code": true } }, { "insert": ", or ", "attributes": { "code": false } }, { "insert": "Kanban Board", "attributes": { "code": true } }, { "insert": ".", "attributes": { "code": false } } ] }, { "type": "text", "delta": [] }, { "type": "divider" }, { "type": "text", "attributes": { "checkbox": null }, "delta": [] }, { "type": "text", "attributes": { "subtype": "heading", "checkbox": null, "heading": "h2" }, "delta": [{ "insert": "Keyboard shortcuts, markdown, and code block" }] }, { "type": "text", "attributes": { "subtype": "number-list", "number": 1, "heading": null }, "delta": [ { "insert": "Keyboard shortcuts " }, { "insert": "guide", "attributes": { "href": "https://appflowy.gitbook.io/docs/essential-documentation/shortcuts" } }, { "retain": 1, "attributes": { "strikethrough": true } } ] }, { "type": "text", "attributes": { "subtype": "number-list", "number": 2, "heading": null }, "delta": [ { "insert": "Markdown " }, { "insert": "reference", "attributes": { "href": "https://appflowy.gitbook.io/docs/essential-documentation/markdown" } }, { "retain": 1, "attributes": { "strikethrough": true } } ] }, { "type": "text", "attributes": { "number": 3, "subtype": "number-list" }, "delta": [ { "insert": "Type " }, { "insert": "/code", "attributes": { "code": true } }, { "insert": " to insert a code block", "attributes": { "code": false } } ] }, { "type": "text", "attributes": { "subtype": "code_block", "number": 3, "heading": null, "number-list": null, "theme": "vs", "language": "rust" }, "delta": [ { "insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}" }, { "retain": 1, "attributes": { "strikethrough": true } } ] }, { "type": "text", "attributes": { "checkbox": null }, "delta": [] }, { "type": "text", "attributes": { "subtype": "heading", "checkbox": null, "heading": "h2" }, "delta": [{ "insert": "Have a question❓" }] }, { "type": "text", "attributes": { "subtype": "quote" }, "delta": [ { "insert": "Click " }, { "insert": "?", "attributes": { "code": true } }, { "insert": " at the bottom right for help and support." } ] }, { "type": "text", "delta": [] }, { "type": "callout", "children": [ { "type": "text", "delta": [] }, { "type": "text", "attributes": { "subtype": "heading", "heading": "h2" }, "delta": [{ "insert": "Like AppFlowy? Follow us:" }] }, { "type": "text", "attributes": { "subtype": "bulleted-list" }, "delta": [ { "insert": "GitHub", "attributes": { "href": "https://github.com/AppFlowy-IO/AppFlowy" } } ] }, { "type": "text", "attributes": { "subtype": "bulleted-list" }, "delta": [ { "insert": "Twitter", "attributes": { "href": "https://twitter.com/appflowy" } }, { "insert": ": @appflowy" } ] }, { "type": "text", "attributes": { "subtype": "bulleted-list" }, "delta": [ { "insert": "Newsletter", "attributes": { "href": "https://blog-appflowy.ghost.io/" } } ] } ], "attributes": { "emoji": "😀" } }, { "type": "text", "delta": [] }, { "type": "text", "attributes": { "subtype": null, "heading": null }, "delta": [] }, { "type": "text", "attributes": { "subtype": null, "heading": null }, "delta": [] } ] } } ================================================ FILE: frontend/appflowy_flutter/assets/template/readme.json ================================================ { "document": { "type": "editor", "children": [ { "type": "cover" }, { "type": "text", "attributes": { "subtype": "heading", "heading": "h1" }, "delta": [{ "insert": "Welcome to AppFlowy!" }] }, { "type": "text", "attributes": { "subtype": "heading", "heading": "h2" }, "delta": [{ "insert": "Here are the basics" }] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": null }, "delta": [{ "insert": "Click anywhere and just start typing." }] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": false }, "delta": [ { "insert": "Highlight ", "attributes": { "backgroundColor": "0x4dffeb3b" } }, { "insert": "any text, and use the editing menu to " }, { "insert": "style", "attributes": { "italic": true } }, { "insert": " " }, { "insert": "your", "attributes": { "bold": true } }, { "insert": " " }, { "insert": "writing", "attributes": { "underline": true } }, { "insert": " " }, { "insert": "however", "attributes": { "code": true } }, { "insert": " you " }, { "insert": "like.", "attributes": { "strikethrough": true } } ] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": null }, "delta": [ { "insert": "As soon as you type " }, { "insert": "/", "attributes": { "code": true, "color": "0xff00b5ff" } }, { "insert": " a menu will pop up. Select " }, { "insert": "different types", "attributes": { "backgroundColor": "0x4d9c27b0" } }, { "insert": " of content blocks you can add." } ] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": null }, "delta": [ { "insert": "Type " }, { "insert": "/", "attributes": { "code": true } }, { "insert": " followed by " }, { "insert": "/bullet", "attributes": { "code": true } }, { "insert": " or " }, { "insert": "/num", "attributes": { "code": true } }, { "insert": " to create a list.", "attributes": { "code": false } } ] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": true }, "delta": [ { "insert": "Click " }, { "insert": "+ New Page", "attributes": { "code": true } }, { "insert": " button at the bottom of your sidebar to add a new page." } ] }, { "type": "text", "attributes": { "subtype": "checkbox", "checkbox": null }, "delta": [ { "insert": "Click " }, { "insert": "+", "attributes": { "code": true } }, { "insert": " next to any page title in the sidebar to " }, { "insert": "quickly", "attributes": { "color": "0xff8427e0" } }, { "insert": " add a new subpage, " }, { "insert": "Document", "attributes": { "code": true } }, { "insert": ", ", "attributes": { "code": false } }, { "insert": "Grid", "attributes": { "code": true } }, { "insert": ", or ", "attributes": { "code": false } }, { "insert": "Kanban Board", "attributes": { "code": true } }, { "insert": ".", "attributes": { "code": false } } ] }, { "type": "text", "delta": [] }, { "type": "divider" }, { "type": "text", "attributes": { "checkbox": null }, "delta": [] }, { "type": "text", "attributes": { "subtype": "heading", "checkbox": null, "heading": "h2" }, "delta": [{ "insert": "Keyboard shortcuts, markdown, and code block" }] }, { "type": "text", "attributes": { "subtype": "number-list", "number": 1, "heading": null }, "delta": [ { "insert": "Keyboard shortcuts " }, { "insert": "guide", "attributes": { "href": "https://appflowy.gitbook.io/docs/essential-documentation/shortcuts" } }, { "retain": 1, "attributes": { "strikethrough": true } } ] }, { "type": "text", "attributes": { "subtype": "number-list", "number": 2, "heading": null }, "delta": [ { "insert": "Markdown " }, { "insert": "reference", "attributes": { "href": "https://appflowy.gitbook.io/docs/essential-documentation/markdown" } }, { "retain": 1, "attributes": { "strikethrough": true } } ] }, { "type": "text", "attributes": { "number": 3, "subtype": "number-list" }, "delta": [ { "insert": "Type " }, { "insert": "/code", "attributes": { "code": true } }, { "insert": " to insert a code block", "attributes": { "code": false } } ] }, { "type": "text", "attributes": { "subtype": "code_block", "number": 3, "heading": null, "number-list": null, "theme": "vs", "language": "rust" }, "delta": [ { "insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}" }, { "retain": 1, "attributes": { "strikethrough": true } } ] }, { "type": "text", "attributes": { "checkbox": null }, "delta": [] }, { "type": "text", "attributes": { "subtype": "heading", "checkbox": null, "heading": "h2" }, "delta": [{ "insert": "Have a question❓" }] }, { "type": "text", "attributes": { "subtype": "quote" }, "delta": [ { "insert": "Click " }, { "insert": "?", "attributes": { "code": true } }, { "insert": " at the bottom right for help and support." } ] }, { "type": "text", "delta": [] }, { "type": "callout", "children": [ { "type": "text", "delta": [] }, { "type": "text", "attributes": { "subtype": "heading", "heading": "h2" }, "delta": [{ "insert": "Like AppFlowy? Follow us:" }] }, { "type": "text", "attributes": { "subtype": "bulleted-list" }, "delta": [ { "insert": "GitHub", "attributes": { "href": "https://github.com/AppFlowy-IO/AppFlowy" } } ] }, { "type": "text", "attributes": { "subtype": "bulleted-list" }, "delta": [ { "insert": "Twitter", "attributes": { "href": "https://twitter.com/appflowy" } }, { "insert": ": @appflowy" } ] }, { "type": "text", "attributes": { "subtype": "bulleted-list" }, "delta": [ { "insert": "Newsletter", "attributes": { "href": "https://blog-appflowy.ghost.io/" } } ] } ], "attributes": { "emoji": "😀" } }, { "type": "text", "delta": [] }, { "type": "text", "attributes": { "subtype": null, "heading": null }, "delta": [] }, { "type": "text", "attributes": { "subtype": null, "heading": null }, "delta": [] } ] } } ================================================ FILE: frontend/appflowy_flutter/assets/test/workspaces/database/v020.afdb ================================================ "{""id"":""2_OVWb"",""name"":""Name"",""field_type"":0,""visibility"":true,""width"":150,""type_options"":{""0"":{""data"":""""}},""is_primary"":true}","{""id"":""xjmOSi"",""name"":""Type"",""field_type"":3,""visibility"":true,""width"":150,""type_options"":{""3"":{""content"":""{\""options\"":[{\""id\"":\""t1WZ\"",\""name\"":\""s6\"",\""color\"":\""Lime\""},{\""id\"":\""GzNa\"",\""name\"":\""s5\"",\""color\"":\""Yellow\""},{\""id\"":\""l_8w\"",\""name\"":\""s4\"",\""color\"":\""Orange\""},{\""id\"":\""TzVT\"",\""name\"":\""s3\"",\""color\"":\""LightPink\""},{\""id\"":\""b5WF\"",\""name\"":\""s2\"",\""color\"":\""Pink\""},{\""id\"":\""AcHA\"",\""name\"":\""s1\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}","{""id"":""Hpbiwr"",""name"":""Done"",""field_type"":5,""visibility"":true,""width"":150,""type_options"":{""5"":{""is_selected"":false}},""is_primary"":false}","{""id"":""F7WLnw"",""name"":""checklist"",""field_type"":7,""visibility"":true,""width"":120,""type_options"":{""0"":{""data"":""""},""7"":{}},""is_primary"":false}","{""id"":""KABhMe"",""name"":""number"",""field_type"":1,""visibility"":true,""width"":120,""type_options"":{""1"":{""format"":0,""symbol"":""RUB"",""scale"":0,""name"":""Number""},""0"":{""scale"":0,""data"":"""",""format"":0,""name"":""Number"",""symbol"":""RUB""}},""is_primary"":false}","{""id"":""lEn6Bv"",""name"":""date"",""field_type"":2,""visibility"":true,""width"":120,""type_options"":{""2"":{""field_type"":2,""time_format"":1,""timezone_id"":"""",""date_format"":3},""0"":{""field_type"":2,""date_format"":3,""time_format"":1,""data"":"""",""timezone_id"":""""}},""is_primary"":false}","{""id"":""B8Prnx"",""name"":""url"",""field_type"":6,""visibility"":true,""width"":120,""type_options"":{""6"":{""content"":"""",""url"":""""},""0"":{""content"":"""",""data"":"""",""url"":""""}},""is_primary"":false}","{""id"":""MwUow4"",""name"":""multi-select"",""field_type"":4,""visibility"":true,""width"":240,""type_options"":{""0"":{""content"":""{\""options\"":[],\""disable_color\"":false}"",""data"":""""},""4"":{""content"":""{\""options\"":[{\""id\"":\""__Us\"",\""name\"":\""m7\"",\""color\"":\""Green\""},{\""id\"":\""n9-g\"",\""name\"":\""m6\"",\""color\"":\""Lime\""},{\""id\"":\""KFYu\"",\""name\"":\""m5\"",\""color\"":\""Yellow\""},{\""id\"":\""KftP\"",\""name\"":\""m4\"",\""color\"":\""Orange\""},{\""id\"":\""5lWo\"",\""name\"":\""m3\"",\""color\"":\""LightPink\""},{\""id\"":\""Djrz\"",\""name\"":\""m2\"",\""color\"":\""Pink\""},{\""id\"":\""2uRu\"",\""name\"":\""m1\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}" "{""field_type"":0,""created_at"":1686793246,""data"":""A"",""last_modified"":1686793246}","{""last_modified"":1686793275,""created_at"":1686793261,""data"":""AcHA"",""field_type"":3}","{""created_at"":1686793241,""field_type"":5,""last_modified"":1686793241,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""pi1A\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""6Pym\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""erEe\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""pi1A\"",\""6Pym\""]}"",""created_at"":1686793302,""field_type"":7,""last_modified"":1686793308}","{""created_at"":1686793333,""field_type"":1,""data"":""-1"",""last_modified"":1686793333}","{""last_modified"":1686793370,""field_type"":2,""data"":""1685583770"",""include_time"":false,""created_at"":1686793370}","{""created_at"":1686793395,""data"":""appflowy.io"",""field_type"":6,""last_modified"":1686793399,""url"":""https://appflowy.io""}","{""last_modified"":1686793446,""field_type"":4,""data"":""2uRu"",""created_at"":1686793428}" "{""last_modified"":1686793247,""data"":""B"",""field_type"":0,""created_at"":1686793247}","{""created_at"":1686793278,""data"":""b5WF"",""field_type"":3,""last_modified"":1686793278}","{""created_at"":1686793292,""last_modified"":1686793292,""data"":""Yes"",""field_type"":5}","{""data"":""{\""options\"":[{\""id\"":\""YHDO\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""QjtW\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""K2nM\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""YHDO\""]}"",""field_type"":7,""last_modified"":1686793318,""created_at"":1686793311}","{""data"":""-2"",""last_modified"":1686793335,""created_at"":1686793335,""field_type"":1}","{""field_type"":2,""data"":""1685670174"",""include_time"":false,""created_at"":1686793374,""last_modified"":1686793374}","{""last_modified"":1686793403,""field_type"":6,""created_at"":1686793399,""url"":"""",""data"":""no url""}","{""data"":""2uRu,Djrz"",""field_type"":4,""last_modified"":1686793449,""created_at"":1686793449}" "{""data"":""C"",""created_at"":1686793248,""last_modified"":1686793248,""field_type"":0}","{""created_at"":1686793280,""field_type"":3,""data"":""TzVT"",""last_modified"":1686793280}","{""data"":""Yes"",""last_modified"":1686793292,""field_type"":5,""created_at"":1686793292}","{""last_modified"":1686793329,""field_type"":7,""created_at"":1686793322,""data"":""{\""options\"":[{\""id\"":\""iWM1\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""WDvF\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""w3k7\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""iWM1\"",\""WDvF\"",\""w3k7\""]}""}","{""field_type"":1,""last_modified"":1686793339,""data"":""0.1"",""created_at"":1686793339}","{""last_modified"":1686793377,""data"":""1685756577"",""created_at"":1686793377,""include_time"":false,""field_type"":2}","{""created_at"":1686793403,""field_type"":6,""data"":""appflowy.io"",""last_modified"":1686793408,""url"":""https://appflowy.io""}","{""data"":""2uRu,Djrz,5lWo"",""created_at"":1686793453,""last_modified"":1686793454,""field_type"":4}" "{""data"":""D"",""last_modified"":1686793249,""created_at"":1686793249,""field_type"":0}","{""data"":""l_8w"",""created_at"":1686793284,""last_modified"":1686793284,""field_type"":3}","{""data"":""Yes"",""created_at"":1686793293,""last_modified"":1686793293,""field_type"":5}",,"{""field_type"":1,""last_modified"":1686793341,""created_at"":1686793341,""data"":""0.2""}","{""created_at"":1686793379,""last_modified"":1686793379,""field_type"":2,""data"":""1685842979"",""include_time"":false}","{""last_modified"":1686793419,""field_type"":6,""created_at"":1686793408,""data"":""https://github.com/AppFlowy-IO/"",""url"":""https://github.com/AppFlowy-IO/""}","{""data"":""2uRu,Djrz,5lWo"",""last_modified"":1686793459,""field_type"":4,""created_at"":1686793459}" "{""field_type"":0,""last_modified"":1686793250,""created_at"":1686793250,""data"":""E""}","{""field_type"":3,""last_modified"":1686793290,""created_at"":1686793290,""data"":""GzNa""}","{""last_modified"":1686793294,""created_at"":1686793294,""data"":""Yes"",""field_type"":5}",,"{""created_at"":1686793346,""field_type"":1,""last_modified"":1686793346,""data"":""1""}","{""last_modified"":1686793383,""data"":""1685929383"",""field_type"":2,""include_time"":false,""created_at"":1686793383}","{""field_type"":6,""url"":"""",""data"":"""",""last_modified"":1686793421,""created_at"":1686793419}","{""field_type"":4,""last_modified"":1686793465,""data"":""2uRu,Djrz,5lWo,KFYu,KftP"",""created_at"":1686793463}" "{""field_type"":0,""created_at"":1686793251,""data"":"""",""last_modified"":1686793289}",,,,"{""data"":""2"",""field_type"":1,""created_at"":1686793347,""last_modified"":1686793347}","{""include_time"":false,""data"":""1685929385"",""last_modified"":1686793385,""field_type"":2,""created_at"":1686793385}",, "{""created_at"":1686793254,""field_type"":0,""last_modified"":1686793288,""data"":""""}",,,,"{""created_at"":1686793351,""last_modified"":1686793351,""data"":""10"",""field_type"":1}","{""include_time"":false,""data"":""1686879792"",""field_type"":2,""created_at"":1686793392,""last_modified"":1686793392}",, ,,,,"{""last_modified"":1686793354,""created_at"":1686793354,""field_type"":1,""data"":""11""}",,, ,,,,"{""field_type"":1,""last_modified"":1686793356,""data"":""12"",""created_at"":1686793356}",,, ,,,,,,, ================================================ FILE: frontend/appflowy_flutter/assets/test/workspaces/database/v069.afdb ================================================ "{""id"":""RGmzka"",""name"":""Name"",""field_type"":0,""type_options"":{""0"":{""data"":""""}},""is_primary"":true}","{""id"":""oYoH-q"",""name"":""Time Slot"",""field_type"":2,""type_options"":{""0"":{""date_format"":3,""data"":"""",""time_format"":1,""timezone_id"":""""},""2"":{""date_format"":3,""time_format"":1,""timezone_id"":""""}},""is_primary"":false}","{""id"":""zVrp17"",""name"":""Amount"",""field_type"":1,""type_options"":{""1"":{""scale"":0,""format"":4,""name"":""Number"",""symbol"":""RUB""},""0"":{""data"":"""",""symbol"":""RUB"",""name"":""Number"",""format"":0,""scale"":0}},""is_primary"":false}","{""id"":""_p4EGt"",""name"":""Delta"",""field_type"":1,""type_options"":{""1"":{""name"":""Number"",""format"":36,""symbol"":""RUB"",""scale"":0},""0"":{""data"":"""",""symbol"":""RUB"",""name"":""Number"",""format"":0,""scale"":0}},""is_primary"":false}","{""id"":""Z909lc"",""name"":""Email"",""field_type"":6,""type_options"":{""6"":{""url"":"""",""content"":""""},""0"":{""data"":"""",""content"":"""",""url"":""""}},""is_primary"":false}","{""id"":""dBrSc7"",""name"":""Registration Complete"",""field_type"":5,""type_options"":{""5"":{}},""is_primary"":false}","{""id"":""VoigvK"",""name"":""Progress"",""field_type"":7,""type_options"":{""0"":{""data"":""""},""7"":{}},""is_primary"":false}","{""id"":""gbbQwh"",""name"":""Attachments"",""field_type"":14,""type_options"":{""0"":{""data"":"""",""content"":""{\""files\"":[]}""},""14"":{""content"":""{\""files\"":[]}""}},""is_primary"":false}","{""id"":""id3L0G"",""name"":""Priority"",""field_type"":3,""type_options"":{""3"":{""content"":""{\""options\"":[{\""id\"":\""cplL\"",\""name\"":\""VIP\"",\""color\"":\""Purple\""},{\""id\"":\""GSf_\"",\""name\"":\""High\"",\""color\"":\""Blue\""},{\""id\"":\""qnja\"",\""name\"":\""Medium\"",\""color\"":\""Green\""}],\""disable_color\"":false}""}},""is_primary"":false}","{""id"":""541SFC"",""name"":""Tags"",""field_type"":4,""type_options"":{""0"":{""data"":"""",""content"":""{\""options\"":[],\""disable_color\"":false}""},""4"":{""content"":""{\""options\"":[{\""id\"":\""1i4f\"",\""name\"":\""Education\"",\""color\"":\""Yellow\""},{\""id\"":\""yORP\"",\""name\"":\""Health\"",\""color\"":\""Orange\""},{\""id\"":\""SEUo\"",\""name\"":\""Hobby\"",\""color\"":\""LightPink\""},{\""id\"":\""uRAO\"",\""name\"":\""Family\"",\""color\"":\""Pink\""},{\""id\"":\""R9I7\"",\""name\"":\""Work\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}","{""id"":""lg0B7O"",""name"":""Last modified"",""field_type"":8,""type_options"":{""0"":{""time_format"":1,""field_type"":8,""date_format"":3,""data"":"""",""include_time"":true},""8"":{""date_format"":3,""field_type"":8,""time_format"":1,""include_time"":true}},""is_primary"":false}","{""id"":""5riGR7"",""name"":""Created at"",""field_type"":9,""type_options"":{""0"":{""field_type"":9,""include_time"":true,""date_format"":3,""time_format"":1,""data"":""""},""9"":{""include_time"":true,""field_type"":9,""date_format"":3,""time_format"":1}},""is_primary"":false}" "{""data"":""Olaf"",""created_at"":1726063289,""last_modified"":1726063289,""field_type"":0}","{""last_modified"":1726122374,""created_at"":1726110045,""reminder_id"":"""",""is_range"":true,""include_time"":true,""end_timestamp"":""1725415200"",""field_type"":2,""data"":""1725256800""}","{""field_type"":1,""data"":""55200"",""last_modified"":1726063592,""created_at"":1726063592}","{""last_modified"":1726062441,""created_at"":1726062441,""data"":""0.5"",""field_type"":1}","{""created_at"":1726063719,""last_modified"":1726063732,""data"":""doyouwannabuildasnowman@arendelle.gov"",""field_type"":6}",,"{""field_type"":7,""last_modified"":1726064207,""data"":""{\""options\"":[{\""id\"":\""oqXQ\"",\""name\"":\""find elsa\"",\""color\"":\""Purple\""},{\""id\"":\""eQwp\"",\""name\"":\""find anna\"",\""color\"":\""Purple\""},{\""id\"":\""5-B3\"",\""name\"":\""play in the summertime\"",\""color\"":\""Purple\""},{\""id\"":\""UBFn\"",\""name\"":\""get a personal flurry\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""oqXQ\"",\""eQwp\"",\""UBFn\""]}"",""created_at"":1726064129}",,"{""created_at"":1726065208,""data"":""cplL"",""last_modified"":1726065282,""field_type"":3}","{""field_type"":4,""data"":""1i4f"",""last_modified"":1726105102,""created_at"":1726105102}","{""field_type"":8,""data"":""1726122374""}","{""data"":""1726060476"",""field_type"":9}" "{""field_type"":0,""last_modified"":1726063323,""data"":""Beatrice"",""created_at"":1726063323}",,"{""last_modified"":1726063638,""data"":""828600"",""created_at"":1726063607,""field_type"":1}","{""field_type"":1,""created_at"":1726062488,""data"":""-2.25"",""last_modified"":1726062488}","{""last_modified"":1726063790,""data"":""btreee17@gmail.com"",""field_type"":6,""created_at"":1726063790}","{""created_at"":1726062718,""data"":""Yes"",""field_type"":5,""last_modified"":1726062724}","{""created_at"":1726064277,""data"":""{\""options\"":[{\""id\"":\""BDuH\"",\""name\"":\""get the leaf node\"",\""color\"":\""Purple\""},{\""id\"":\""GXAr\"",\""name\"":\""upgrade to b+\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""field_type"":7,""last_modified"":1726064293}",,"{""data"":""GSf_"",""created_at"":1726065288,""last_modified"":1726065288,""field_type"":3}","{""created_at"":1726105110,""data"":""yORP,uRAO"",""last_modified"":1726105111,""field_type"":4}","{""data"":""1726105111"",""field_type"":8}","{""field_type"":9,""data"":""1726060476""}" "{""last_modified"":1726063355,""created_at"":1726063355,""field_type"":0,""data"":""Lancelot""}","{""data"":""1726468159"",""is_range"":true,""end_timestamp"":""1726727359"",""reminder_id"":"""",""include_time"":false,""field_type"":2,""created_at"":1726122403,""last_modified"":1726122559}","{""created_at"":1726063617,""last_modified"":1726063617,""data"":""22500"",""field_type"":1}","{""data"":""11.6"",""last_modified"":1726062504,""field_type"":1,""created_at"":1726062504}","{""field_type"":6,""data"":""sir.lancelot@gmail.com"",""last_modified"":1726063812,""created_at"":1726063812}","{""data"":""No"",""field_type"":5,""last_modified"":1726062724,""created_at"":1726062375}",,,"{""data"":""cplL"",""created_at"":1726065286,""last_modified"":1726065286,""field_type"":3}","{""last_modified"":1726105237,""data"":""SEUo"",""created_at"":1726105237,""field_type"":4}","{""field_type"":8,""data"":""1726122559""}","{""field_type"":9,""data"":""1726060476""}" "{""data"":""Scotty"",""last_modified"":1726063399,""created_at"":1726063399,""field_type"":0}","{""reminder_id"":"""",""last_modified"":1726122418,""include_time"":true,""data"":""1725868800"",""end_timestamp"":""1726646400"",""created_at"":1726122381,""field_type"":2,""is_range"":true}","{""created_at"":1726063650,""last_modified"":1726063650,""data"":""10900"",""field_type"":1}","{""data"":""0"",""created_at"":1726062581,""last_modified"":1726062581,""field_type"":1}","{""last_modified"":1726063835,""created_at"":1726063835,""field_type"":6,""data"":""scottylikestosing@outlook.com""}","{""data"":""Yes"",""field_type"":5,""created_at"":1726062718,""last_modified"":1726062718}","{""created_at"":1726064309,""data"":""{\""options\"":[{\""id\"":\""Cw0K\"",\""name\"":\""vocal warmup\"",\""color\"":\""Purple\""},{\""id\"":\""nYMo\"",\""name\"":\""mixed voice training\"",\""color\"":\""Purple\""},{\""id\"":\""i-OX\"",\""name\"":\""belting training\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""Cw0K\"",\""nYMo\"",\""i-OX\""]}"",""field_type"":7,""last_modified"":1726064325}","{""last_modified"":1726122911,""created_at"":1726122835,""data"":[""{\""id\"":\""746a741d-98f8-4cc6-b807-a82d2e78c221\"",\""name\"":\""googlelogo_color_272x92dp.png\"",\""url\"":\""https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}"",""{\""id\"":\""cbbab3ee-32ab-4438-a909-3f69f935a8bd\"",\""name\"":\""tL_v571NdZ0.svg\"",\""url\"":\""https://static.xx.fbcdn.net/rsrc.php/y9/r/tL_v571NdZ0.svg\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Link\""}""],""field_type"":14}",,"{""data"":""SEUo,yORP"",""field_type"":4,""last_modified"":1726105123,""created_at"":1726105115}","{""data"":""1726122911"",""field_type"":8}","{""data"":""1726060539"",""field_type"":9}" "{""field_type"":0,""created_at"":1726063405,""last_modified"":1726063421,""data"":""""}",,,"{""last_modified"":1726062625,""field_type"":1,""data"":"""",""created_at"":1726062607}",,"{""data"":""No"",""last_modified"":1726062702,""created_at"":1726062393,""field_type"":5}",,,,,"{""data"":""1726063421"",""field_type"":8}","{""data"":""1726060539"",""field_type"":9}" "{""field_type"":0,""data"":""Thomas"",""last_modified"":1726063421,""created_at"":1726063421}","{""reminder_id"":"""",""field_type"":2,""data"":""1725627600"",""is_range"":false,""created_at"":1726122583,""last_modified"":1726122593,""end_timestamp"":"""",""include_time"":true}","{""last_modified"":1726063666,""field_type"":1,""data"":""465800"",""created_at"":1726063666}","{""last_modified"":1726062516,""field_type"":1,""created_at"":1726062516,""data"":""-0.03""}","{""field_type"":6,""last_modified"":1726063848,""created_at"":1726063848,""data"":""tfp3827@gmail.com""}","{""field_type"":5,""last_modified"":1726062725,""data"":""Yes"",""created_at"":1726062376}","{""created_at"":1726064344,""data"":""{\""options\"":[{\""id\"":\""D6X8\"",\""name\"":\""brainstorm\"",\""color\"":\""Purple\""},{\""id\"":\""XVN9\"",\""name\"":\""schedule\"",\""color\"":\""Purple\""},{\""id\"":\""nJx8\"",\""name\"":\""shoot\"",\""color\"":\""Purple\""},{\""id\"":\""7Mrm\"",\""name\"":\""edit\"",\""color\"":\""Purple\""},{\""id\"":\""o6vg\"",\""name\"":\""publish\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""D6X8\""]}"",""last_modified"":1726064379,""field_type"":7}",,"{""last_modified"":1726065298,""created_at"":1726065298,""field_type"":3,""data"":""GSf_""}","{""data"":""yORP,SEUo"",""field_type"":4,""last_modified"":1726105229,""created_at"":1726105229}","{""data"":""1726122593"",""field_type"":8}","{""field_type"":9,""data"":""1726060540""}" "{""data"":""Juan"",""last_modified"":1726063423,""created_at"":1726063423,""field_type"":0}","{""created_at"":1726122510,""reminder_id"":"""",""include_time"":false,""is_range"":true,""last_modified"":1726122515,""data"":""1725604115"",""end_timestamp"":""1725776915"",""field_type"":2}","{""field_type"":1,""created_at"":1726063677,""last_modified"":1726063677,""data"":""93100""}","{""field_type"":1,""data"":""4.86"",""created_at"":1726062597,""last_modified"":1726062597}",,"{""last_modified"":1726062377,""field_type"":5,""data"":""Yes"",""created_at"":1726062377}","{""last_modified"":1726064412,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""tTDq\"",\""name\"":\""complete onboarding\"",\""color\"":\""Purple\""},{\""id\"":\""E8Ds\"",\""name\"":\""contact support\"",\""color\"":\""Purple\""},{\""id\"":\""RoGN\"",\""name\"":\""get started\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""tTDq\"",\""E8Ds\""]}"",""created_at"":1726064396}",,"{""created_at"":1726065278,""field_type"":3,""data"":""qnja"",""last_modified"":1726065278}","{""data"":""R9I7,yORP,1i4f"",""field_type"":4,""created_at"":1726105126,""last_modified"":1726105127}","{""data"":""1726122515"",""field_type"":8}","{""data"":""1726060541"",""field_type"":9}" "{""data"":""Alex"",""created_at"":1726063432,""last_modified"":1726063432,""field_type"":0}","{""reminder_id"":"""",""data"":""1725292800"",""include_time"":true,""last_modified"":1726122448,""created_at"":1726122422,""is_range"":true,""end_timestamp"":""1725551940"",""field_type"":2}","{""field_type"":1,""last_modified"":1726063683,""created_at"":1726063683,""data"":""3560""}","{""created_at"":1726062561,""data"":""1.96"",""last_modified"":1726062561,""field_type"":1}","{""last_modified"":1726063952,""created_at"":1726063931,""data"":""al3x1343@protonmail.com"",""field_type"":6}","{""last_modified"":1726062375,""field_type"":5,""created_at"":1726062375,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""qNyr\"",\""name\"":\""finish reading book\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064616,""last_modified"":1726064616,""field_type"":7}",,"{""data"":""qnja"",""created_at"":1726065272,""last_modified"":1726065272,""field_type"":3}","{""created_at"":1726105180,""last_modified"":1726105180,""field_type"":4,""data"":""R9I7,1i4f""}","{""field_type"":8,""data"":""1726122448""}","{""field_type"":9,""data"":""1726060541""}" "{""last_modified"":1726063478,""created_at"":1726063436,""field_type"":0,""data"":""Alexander""}",,"{""field_type"":1,""last_modified"":1726063691,""created_at"":1726063691,""data"":""2073""}","{""field_type"":1,""data"":""0.5"",""last_modified"":1726062577,""created_at"":1726062577}","{""last_modified"":1726063991,""field_type"":6,""created_at"":1726063991,""data"":""alexandernotthedra@gmail.com""}","{""field_type"":5,""last_modified"":1726062378,""created_at"":1726062377,""data"":""No""}",,,"{""created_at"":1726065291,""data"":""GSf_"",""last_modified"":1726065291,""field_type"":3}","{""last_modified"":1726105142,""created_at"":1726105133,""data"":""SEUo"",""field_type"":4}","{""field_type"":8,""data"":""1726105142""}","{""field_type"":9,""data"":""1726060542""}" "{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://samplelib.com/lib/preview/png/sample-blue-200x200.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}" "{""field_type"":0,""last_modified"":1726063467,""data"":""Joanna"",""created_at"":1726063467}","{""include_time"":false,""end_timestamp"":""1727072893"",""is_range"":true,""last_modified"":1726122493,""created_at"":1726122483,""data"":""1726554493"",""field_type"":2,""reminder_id"":""""}","{""last_modified"":1726065463,""data"":""16470"",""field_type"":1,""created_at"":1726065463}","{""created_at"":1726062626,""field_type"":1,""last_modified"":1726062626,""data"":""-5.36""}","{""last_modified"":1726064069,""data"":""joannastrawberry29+hello@gmail.com"",""created_at"":1726064069,""field_type"":6}",,"{""field_type"":7,""created_at"":1726064444,""last_modified"":1726064460,""data"":""{\""options\"":[{\""id\"":\""ZxJz\"",\""name\"":\""post on Twitter\"",\""color\"":\""Purple\""},{\""id\"":\""upwi\"",\""name\"":\""watch Youtube videos\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""upwi\""]}""}",,"{""created_at"":1726065317,""last_modified"":1726065317,""field_type"":3,""data"":""qnja""}","{""field_type"":4,""last_modified"":1726105173,""data"":""uRAO,yORP"",""created_at"":1726105170}","{""data"":""1726122493"",""field_type"":8}","{""data"":""1726060545"",""field_type"":9}" "{""last_modified"":1726063457,""created_at"":1726063457,""data"":""George"",""field_type"":0}","{""include_time"":true,""reminder_id"":"""",""field_type"":2,""is_range"":true,""created_at"":1726122521,""end_timestamp"":""1725829200"",""data"":""1725822900"",""last_modified"":1726122535}","{""last_modified"":1726065493,""field_type"":1,""data"":""9500"",""created_at"":1726065493}","{""last_modified"":1726062680,""created_at"":1726062680,""field_type"":1,""data"":""1.7""}","{""data"":""plgeorgebball@gmail.com"",""field_type"":6,""last_modified"":1726064087,""created_at"":1726064036}",,"{""last_modified"":1726064513,""data"":""{\""options\"":[{\""id\"":\""zy0x\"",\""name\"":\""game vs celtics\"",\""color\"":\""Purple\""},{\""id\"":\""WJsv\"",\""name\"":\""training\"",\""color\"":\""Purple\""},{\""id\"":\""w-f8\"",\""name\"":\""game vs spurs\"",\""color\"":\""Purple\""},{\""id\"":\""p1VQ\"",\""name\"":\""game vs knicks\"",\""color\"":\""Purple\""},{\""id\"":\""VjUA\"",\""name\"":\""recovery\"",\""color\"":\""Purple\""},{\""id\"":\""sQ8X\"",\""name\"":\""don't get injured\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064486,""field_type"":7}",,"{""field_type"":3,""last_modified"":1726065310,""data"":""qnja"",""created_at"":1726065310}","{""created_at"":1726105205,""field_type"":4,""last_modified"":1726105249,""data"":""R9I7,1i4f,yORP,SEUo""}","{""data"":""1726122535"",""field_type"":8}","{""field_type"":9,""data"":""1726060546""}" "{""data"":""Judy"",""created_at"":1726063475,""field_type"":0,""last_modified"":1726063487}","{""end_timestamp"":"""",""reminder_id"":"""",""data"":""1726640950"",""field_type"":2,""include_time"":false,""created_at"":1726122550,""last_modified"":1726122550,""is_range"":false}",,,"{""created_at"":1726063882,""field_type"":6,""last_modified"":1726064000,""data"":""judysmithjr@outlook.com""}","{""last_modified"":1726062712,""field_type"":5,""data"":""Yes"",""created_at"":1726062712}","{""created_at"":1726064549,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""j8cC\"",\""name\"":\""finish training\"",\""color\"":\""Purple\""},{\""id\"":\""SmSk\"",\""name\"":\""brainwash\"",\""color\"":\""Purple\""},{\""id\"":\""mnf5\"",\""name\"":\""welcome to ba sing se\"",\""color\"":\""Purple\""},{\""id\"":\""hcrj\"",\""name\"":\""don't mess up\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""j8cC\"",\""SmSk\"",\""mnf5\"",\""hcrj\""]}"",""last_modified"":1726064591}",,,"{""field_type"":4,""last_modified"":1726105152,""created_at"":1726105152,""data"":""R9I7""}","{""field_type"":8,""data"":""1726122550""}","{""field_type"":9,""data"":""1726060549""}" ================================================ FILE: frontend/appflowy_flutter/assets/test/workspaces/markdowns/markdown_with_table.md ================================================ # AppFlowy Test Markdown import with table # Table | S.No. | Column 2 | | --- | --- | | 1. | row 1 | | 2. | row 2 | | 3. | row 3 | | 4. | row 4 | | 5. | row 5 | ================================================ FILE: frontend/appflowy_flutter/assets/test/workspaces/markdowns/test1.md ================================================ # Welcome to AppFlowy! ## Here are the basics - [ ] Click anywhere and just start typing. - [ ] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~ - [ ] As soon as you type /a menu will pop up. Select different types of content blocks you can add. - [ ] Type `/` followed by `/bullet` or `/num` to create a list. - [x] Click `+ New Page `button at the bottom of your sidebar to add a new page. - [ ] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`. --- ## Keyboard shortcuts, markdown, and code block 1. Keyboard shortcuts [guide](https://appflowy.gitbook.io/docs/essential-documentation/shortcuts) 1. Markdown [reference](https://appflowy.gitbook.io/docs/essential-documentation/markdown) 1. Type `/code` to insert a code block ## Have a question❓ > Click `?` at the bottom right for help and support. ================================================ FILE: frontend/appflowy_flutter/assets/test/workspaces/markdowns/test2.md ================================================ # test ================================================ FILE: frontend/appflowy_flutter/assets/translations/mr-IN.json ================================================ { "appName": "AppFlowy", "defaultUsername": "मी", "welcomeText": "@:appName मध्ये आ पले स्वागत आहे.", "welcomeTo": "मध्ये आ पले स्वागत आ हे", "githubStarText": "GitHub वर स्टार करा", "subscribeNewsletterText": "वृत्तपत्राची सदस्यता घ्या", "letsGoButtonText": "क्विक स्टार्ट", "title": "Title", "youCanAlso": "तुम्ही देखील", "and": "आ णि", "failedToOpenUrl": "URL उघडण्यात अयशस्वी: {}", "blockActions": { "addBelowTooltip": "खाली जोडण्यासाठी क्लिक करा", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "वर जोडण्यासाठी", "dragTooltip": "Drag to move", "openMenuTooltip": "मेनू उघडण्यासाठी क्लिक करा" }, "signUp": { "buttonText": "साइन अप", "title": "साइन अप to @:appName", "getStartedText": "सुरुवात करा", "emptyPasswordError": "पासवर्ड रिकामा असू शकत नाही", "repeatPasswordEmptyError": "Repeat पासवर्ड रिकामा असू शकत नाही", "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", "alreadyHaveAnAccount": "आधीच खाते आहे?", "emailHint": "Email", "passwordHint": "Password", "repeatPasswordHint": "पासवर्ड पुन्हा लिहा", "signUpWith": "यामध्ये साइन अप करा:" }, "signIn": { "loginTitle": "@:appName मध्ये लॉगिन करा", "loginButtonText": "लॉगिन", "loginStartWithAnonymous": "अनामिक सत्रासह पुढे जा", "continueAnonymousUser": "अनामिक सत्रासह पुढे जा", "anonymous": "अनामिक", "buttonText": "साइन इन", "signingInText": "साइन इन होत आहे...", "forgotPassword": "पासवर्ड विसरलात?", "emailHint": "ईमेल", "passwordHint": "पासवर्ड", "dontHaveAnAccount": "तुमचं खाते नाही?", "createAccount": "खाते तयार करा", "repeatPasswordEmptyError": "पुन्हा पासवर्ड रिकामा असू शकत नाही", "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", "syncPromptMessage": "डेटा सिंक होण्यास थोडा वेळ लागू शकतो. कृपया हे पृष्ठ बंद करू नका", "or": "किंवा", "signInWithGoogle": "Google सह पुढे जा", "signInWithGithub": "GitHub सह पुढे जा", "signInWithDiscord": "Discord सह पुढे जा", "signInWithApple": "Apple सह पुढे जा", "continueAnotherWay": "इतर पर्यायांनी पुढे जा", "signUpWithGoogle": "Google सह साइन अप करा", "signUpWithGithub": "GitHub सह साइन अप करा", "signUpWithDiscord": "Discord सह साइन अप करा", "signInWith": "यासह पुढे जा:", "signInWithEmail": "ईमेलसह पुढे जा", "signInWithMagicLink": "पुढे जा", "signUpWithMagicLink": "Magic Link सह साइन अप करा", "pleaseInputYourEmail": "कृपया तुमचा ईमेल पत्ता टाका", "settings": "सेटिंग्ज", "magicLinkSent": "Magic Link पाठवण्यात आली आहे!", "invalidEmail": "कृपया वैध ईमेल पत्ता टाका", "alreadyHaveAnAccount": "आधीच खाते आहे?", "logIn": "लॉगिन", "generalError": "काहीतरी चुकलं. कृपया नंतर प्रयत्न करा", "limitRateError": "सुरक्षेच्या कारणास्तव, तुम्ही दर ६० सेकंदांतून एकदाच Magic Link मागवू शकता", "magicLinkSentDescription": "तुमच्या ईमेलवर Magic Link पाठवण्यात आली आहे. लॉगिन पूर्ण करण्यासाठी लिंकवर क्लिक करा. ही लिंक ५ मिनिटांत कालबाह्य होईल." }, "workspace": { "chooseWorkspace": "तुमचे workspace निवडा", "defaultName": "माझे Workspace", "create": "नवीन workspace तयार करा", "new": "नवीन workspace", "importFromNotion": "Notion मधून आयात करा", "learnMore": "अधिक जाणून घ्या", "reset": "workspace रीसेट करा", "renameWorkspace": "workspace चे नाव बदला", "workspaceNameCannotBeEmpty": "workspace चे नाव रिकामे असू शकत नाही", "resetWorkspacePrompt": "workspace रीसेट केल्यास त्यातील सर्व पृष्ठे आणि डेटा हटवले जातील. तुम्हाला workspace रीसेट करायचे आहे का? पर्यायी म्हणून तुम्ही सपोर्ट टीमशी संपर्क करू शकता.", "hint": "workspace", "notFoundError": "workspace सापडले नाही", "failedToLoad": "काहीतरी चूक झाली! workspace लोड होण्यात अयशस्वी. कृपया @:appName चे कोणतेही उघडे instance बंद करा आणि पुन्हा प्रयत्न करा.", "errorActions": { "reportIssue": "समस्या नोंदवा", "reportIssueOnGithub": "Github वर समस्या नोंदवा", "exportLogFiles": "लॉग फाइल्स निर्यात करा", "reachOut": "Discord वर संपर्क करा" }, "menuTitle": "कार्यक्षेत्रे", "deleteWorkspaceHintText": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील.", "createSuccess": "कार्यक्षेत्र यशस्वीरित्या तयार झाले", "createFailed": "कार्यक्षेत्र तयार करण्यात अयशस्वी", "createLimitExceeded": "तुम्ही तुमच्या खात्यासाठी परवानगी दिलेल्या कार्यक्षेत्र मर्यादेपर्यंत पोहोचलात. अधिक कार्यक्षेत्रासाठी GitHub वर विनंती करा.", "deleteSuccess": "कार्यक्षेत्र यशस्वीरित्या हटवले गेले", "deleteFailed": "कार्यक्षेत्र हटवण्यात अयशस्वी", "openSuccess": "कार्यक्षेत्र यशस्वीरित्या उघडले", "openFailed": "कार्यक्षेत्र उघडण्यात अयशस्वी", "renameSuccess": "कार्यक्षेत्राचे नाव यशस्वीरित्या बदलले", "renameFailed": "कार्यक्षेत्राचे नाव बदलण्यात अयशस्वी", "updateIconSuccess": "कार्यक्षेत्राचे चिन्ह यशस्वीरित्या अद्यतनित केले", "updateIconFailed": "कार्यक्षेत्राचे चिन्ह अद्यतनित करण्यात अयशस्वी", "cannotDeleteTheOnlyWorkspace": "फक्त एकच कार्यक्षेत्र असल्यास ते हटवता येत नाही", "fetchWorkspacesFailed": "कार्यक्षेत्रे मिळवण्यात अयशस्वी", "leaveCurrentWorkspace": "कार्यक्षेत्र सोडा", "leaveCurrentWorkspacePrompt": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का?" }, "shareAction": { "buttonText": "शेअर करा", "workInProgress": "लवकरच येत आहे", "markdown": "Markdown", "html": "HTML", "clipboard": "क्लिपबोर्डवर कॉपी करा", "csv": "CSV", "copyLink": "लिंक कॉपी करा", "publishToTheWeb": "वेबवर प्रकाशित करा", "publishToTheWebHint": "AppFlowy सह वेबसाइट तयार करा", "publish": "प्रकाशित करा", "unPublish": "अप्रकाशित करा", "visitSite": "साइटला भेट द्या", "exportAsTab": "या स्वरूपात निर्यात करा", "publishTab": "प्रकाशित करा", "shareTab": "शेअर करा", "publishOnAppFlowy": "AppFlowy वर प्रकाशित करा", "shareTabTitle": "सहकार्य करण्यासाठी आमंत्रित करा", "shareTabDescription": "कोणासही सहज सहकार्य करण्यासाठी", "copyLinkSuccess": "लिंक क्लिपबोर्डवर कॉपी केली", "copyShareLink": "शेअर लिंक कॉपी करा", "copyLinkFailed": "लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", "copyLinkToBlockSuccess": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी केली", "copyLinkToBlockFailed": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", "manageAllSites": "सर्व साइट्स व्यवस्थापित करा", "updatePathName": "पथाचे नाव अपडेट करा" }, "moreAction": { "small": "लहान", "medium": "मध्यम", "large": "मोठा", "fontSize": "फॉन्ट आकार", "import": "Import", "moreOptions": "अधिक पर्याय", "wordCount": "शब्द संख्या: {}", "charCount": "अक्षर संख्या: {}", "createdAt": "निर्मिती: {}", "deleteView": "हटवा", "duplicateView": "प्रत बनवा", "wordCountLabel": "शब्द संख्या: ", "charCountLabel": "अक्षर संख्या: ", "createdAtLabel": "निर्मिती: ", "syncedAtLabel": "सिंक केले: ", "saveAsNewPage": "संदेश पृष्ठात जोडा", "saveAsNewPageDisabled": "उपलब्ध संदेश नाहीत" }, "importPanel": { "textAndMarkdown": "मजकूर आणि Markdown", "documentFromV010": "v0.1.0 पासून दस्तऐवज", "databaseFromV010": "v0.1.0 पासून डेटाबेस", "notionZip": "Notion निर्यात केलेली Zip फाईल", "csv": "CSV", "database": "डेटाबेस" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "फाईल ड्रॅग आणि ड्रॉप करा, क्लिक करा ", "placeholderUpload": "अपलोड", "placeholderRight": ", किंवा इमेज लिंक पेस्ट करा.", "dropToUpload": "अपलोडसाठी फाईल ड्रॉप करा", "change": "बदला" } }, "disclosureAction": { "rename": "नाव बदला", "delete": "हटवा", "duplicate": "प्रत बनवा", "unfavorite": "आवडतीतून काढा", "favorite": "आवडतीत जोडा", "openNewTab": "नवीन टॅबमध्ये उघडा", "moveTo": "या ठिकाणी हलवा", "addToFavorites": "आवडतीत जोडा", "copyLink": "लिंक कॉपी करा", "changeIcon": "आयकॉन बदला", "collapseAllPages": "सर्व उपपृष्ठे संकुचित करा", "movePageTo": "पृष्ठ हलवा", "move": "हलवा", "lockPage": "पृष्ठ लॉक करा" }, "blankPageTitle": "रिक्त पृष्ठ", "newPageText": "नवीन पृष्ठ", "newDocumentText": "नवीन दस्तऐवज", "newGridText": "नवीन ग्रिड", "newCalendarText": "नवीन कॅलेंडर", "newBoardText": "नवीन बोर्ड", "chat": { "newChat": "AI गप्पा", "inputMessageHint": "@:appName AI ला विचार करा", "inputLocalAIMessageHint": "@:appName लोकल AI ला विचार करा", "unsupportedCloudPrompt": "ही सुविधा फक्त @:appName Cloud वापरताना उपलब्ध आहे", "relatedQuestion": "सूचवलेले", "serverUnavailable": "कनेक्शन गमावले. कृपया तुमचे इंटरनेट तपासा आणि पुन्हा प्रयत्न करा", "aiServerUnavailable": "AI सेवा सध्या अनुपलब्ध आहे. कृपया नंतर पुन्हा प्रयत्न करा.", "retry": "पुन्हा प्रयत्न करा", "clickToRetry": "पुन्हा प्रयत्न करण्यासाठी क्लिक करा", "regenerateAnswer": "उत्तर पुन्हा तयार करा", "question1": "Kanban वापरून कामे कशी व्यवस्थापित करायची", "question2": "GTD पद्धत समजावून सांगा", "question3": "Rust का वापरावा", "question4": "माझ्या स्वयंपाकघरात असलेल्या वस्तूंनी रेसिपी", "question5": "माझ्या पृष्ठासाठी एक चित्र तयार करा", "question6": "या आठवड्याची माझी कामांची यादी तयार करा", "aiMistakePrompt": "AI चुकू शकतो. महत्त्वाची माहिती तपासा.", "chatWithFilePrompt": "तुम्हाला फाइलसोबत गप्पा मारायच्या आहेत का?", "indexFileSuccess": "फाईल यशस्वीरित्या अनुक्रमित केली गेली", "inputActionNoPages": "काहीही पृष्ठे सापडली नाहीत", "referenceSource": { "zero": "0 स्रोत सापडले", "one": "{count} स्रोत सापडला", "other": "{count} स्रोत सापडले" } }, "clickToMention": "पृष्ठाचा उल्लेख करा", "uploadFile": "PDFs, मजकूर किंवा markdown फाइल्स जोडा", "questionDetail": "नमस्कार {}! मी तुम्हाला कशी मदत करू शकतो?", "indexingFile": "{} अनुक्रमित करत आहे", "generatingResponse": "उत्तर तयार होत आहे", "selectSources": "स्रोत निवडा", "currentPage": "सध्याचे पृष्ठ", "sourcesLimitReached": "तुम्ही फक्त ३ मुख्य दस्तऐवज आणि त्यांची उपदस्तऐवज निवडू शकता", "sourceUnsupported": "सध्या डेटाबेससह चॅटिंगसाठी आम्ही समर्थन करत नाही", "regenerate": "पुन्हा प्रयत्न करा", "addToPageButton": "संदेश पृष्ठावर जोडा", "addToPageTitle": "या पृष्ठात संदेश जोडा...", "addToNewPage": "नवीन पृष्ठ तयार करा", "addToNewPageName": "\"{}\" मधून काढलेले संदेश", "addToNewPageSuccessToast": "संदेश जोडण्यात आला", "openPagePreviewFailedToast": "पृष्ठ उघडण्यात अयशस्वी", "changeFormat": { "actionButton": "फॉरमॅट बदला", "confirmButton": "या फॉरमॅटसह पुन्हा तयार करा", "textOnly": "मजकूर", "imageOnly": "फक्त प्रतिमा", "textAndImage": "मजकूर आणि प्रतिमा", "text": "परिच्छेद", "bullet": "बुलेट यादी", "number": "क्रमांकित यादी", "table": "सारणी", "blankDescription": "उत्तराचे फॉरमॅट ठरवा", "defaultDescription": "स्वयंचलित उत्तर फॉरमॅट", "textWithImageDescription": "@:chat.changeFormat.text प्रतिमेसह", "numberWithImageDescription": "@:chat.changeFormat.number प्रतिमेसह", "bulletWithImageDescription": "@:chat.changeFormat.bullet प्रतिमेसह", " tableWithImageDescription": "@:chat.changeFormat.table प्रतिमेसह" }, "switchModel": { "label": "मॉडेल बदला", "localModel": "स्थानिक मॉडेल", "cloudModel": "क्लाऊड मॉडेल", "autoModel": "स्वयंचलित" }, "selectBanner": { "saveButton": "… मध्ये जोडा", "selectMessages": "संदेश निवडा", "nSelected": "{} निवडले गेले", "allSelected": "सर्व निवडले गेले" }, "stopTooltip": "उत्पन्न करणे थांबवा", "trash": { "text": "कचरा", "restoreAll": "सर्व पुनर्संचयित करा", "restore": "पुनर्संचयित करा", "deleteAll": "सर्व हटवा", "pageHeader": { "fileName": "फाईलचे नाव", "lastModified": "शेवटचा बदल", "created": "निर्मिती" } }, "confirmDeleteAll": { "title": "कचरापेटीतील सर्व पृष्ठे", "caption": "तुम्हाला कचरापेटीतील सर्व काही हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." }, "confirmRestoreAll": { "title": "कचरापेटीतील सर्व पृष्ठे पुनर्संचयित करा", "caption": "ही कृती पूर्ववत केली जाऊ शकत नाही." }, "restorePage": { "title": "पुनर्संचयित करा: {}", "caption": "तुम्हाला हे पृष्ठ पुनर्संचयित करायचे आहे का?" }, "mobile": { "actions": "कचरा क्रिया", "empty": "कचरापेटीत कोणतीही पृष्ठे किंवा जागा नाहीत", "emptyDescription": "जे आवश्यक नाही ते कचरापेटीत हलवा.", "isDeleted": "हटवले गेले आहे", "isRestored": "पुनर्संचयित केले गेले आहे" }, "confirmDeleteTitle": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का?", "deletePagePrompt": { "text": "हे पृष्ठ कचरापेटीत आहे", "restore": "पृष्ठ पुनर्संचयित करा", "deletePermanent": "कायमचे हटवा", "deletePermanentDescription": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." }, "dialogCreatePageNameHint": "पृष्ठाचे नाव", "questionBubble": { "shortcuts": "शॉर्टकट्स", "whatsNew": "नवीन काय आहे?", "help": "मदत आणि समर्थन", "markdown": "Markdown", "debug": { "name": "डीबग माहिती", "success": "डीबग माहिती क्लिपबोर्डवर कॉपी केली!", "fail": "डीबग माहिती कॉपी करता आली नाही" }, "feedback": "अभिप्राय" }, "menuAppHeader": { "moreButtonToolTip": "हटवा, नाव बदला आणि अधिक...", "addPageTooltip": "तत्काळ एक पृष्ठ जोडा", "defaultNewPageName": "शीर्षक नसलेले", "renameDialog": "नाव बदला", "pageNameSuffix": "प्रत" }, "noPagesInside": "अंदर कोणतीही पृष्ठे नाहीत", "toolbar": { "undo": "पूर्ववत करा", "redo": "पुन्हा करा", "bold": "ठळक", "italic": "तिरकस", "underline": "अधोरेखित", "strike": "मागे ओढलेले", "numList": "क्रमांकित यादी", "bulletList": "बुलेट यादी", "checkList": "चेक यादी", "inlineCode": "इनलाइन कोड", "quote": "उद्धरण ब्लॉक", "header": "शीर्षक", "highlight": "हायलाइट", "color": "रंग", "addLink": "लिंक जोडा" }, "tooltip": { "lightMode": "लाइट मोडमध्ये स्विच करा", "darkMode": "डार्क मोडमध्ये स्विच करा", "openAsPage": "पृष्ठ म्हणून उघडा", "addNewRow": "नवीन पंक्ती जोडा", "openMenu": "मेनू उघडण्यासाठी क्लिक करा", "dragRow": "पंक्तीचे स्थान बदलण्यासाठी ड्रॅग करा", "viewDataBase": "डेटाबेस पहा", "referencePage": "हे {name} संदर्भित आहे", "addBlockBelow": "खाली एक ब्लॉक जोडा", "aiGenerate": "निर्मिती करा" }, "sideBar": { "closeSidebar": "साइडबार बंद करा", "openSidebar": "साइडबार उघडा", "expandSidebar": "पूर्ण पृष्ठावर विस्तारित करा", "personal": "वैयक्तिक", "private": "खाजगी", "workspace": "कार्यक्षेत्र", "favorites": "आवडती", "clickToHidePrivate": "खाजगी जागा लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने फक्त तुम्हाला दिसतील", "clickToHideWorkspace": "कार्यक्षेत्र लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने सर्व सदस्यांना दिसतील", "clickToHidePersonal": "वैयक्तिक जागा लपवण्यासाठी क्लिक करा", "clickToHideFavorites": "आवडती जागा लपवण्यासाठी क्लिक करा", "addAPage": "नवीन पृष्ठ जोडा", "addAPageToPrivate": "खाजगी जागेत पृष्ठ जोडा", "addAPageToWorkspace": "कार्यक्षेत्रात पृष्ठ जोडा", "recent": "अलीकडील", "today": "आज", "thisWeek": "या आठवड्यात", "others": "पूर्वीच्या आवडती", "earlier": "पूर्वीचे", "justNow": "आत्ताच", "minutesAgo": "{count} मिनिटांपूर्वी", "lastViewed": "शेवटी पाहिलेले", "favoriteAt": "आवडते म्हणून चिन्हांकित", "emptyRecent": "अलीकडील पृष्ठे नाहीत", "emptyRecentDescription": "तुम्ही पाहिलेली पृष्ठे येथे सहज पुन्हा मिळवण्यासाठी दिसतील.", "emptyFavorite": "आवडती पृष्ठे नाहीत", "emptyFavoriteDescription": "पृष्ठांना आवडते म्हणून चिन्हांकित करा—ते येथे झपाट्याने प्रवेशासाठी दिसतील!", "removePageFromRecent": "हे पृष्ठ अलीकडील यादीतून काढायचे?", "removeSuccess": "यशस्वीरित्या काढले गेले", "favoriteSpace": "आवडती", "RecentSpace": "अलीकडील", "Spaces": "जागा", "upgradeToPro": "Pro मध्ये अपग्रेड करा", "upgradeToAIMax": "अमर्यादित AI अनलॉक करा", "storageLimitDialogTitle": "तुमचा मोफत स्टोरेज संपला आहे. अमर्यादित स्टोरेजसाठी अपग्रेड करा", "storageLimitDialogTitleIOS": "तुमचा मोफत स्टोरेज संपला आहे.", "aiResponseLimitTitle": "तुमचे मोफत AI प्रतिसाद संपले आहेत. कृपया Pro प्लॅनला अपग्रेड करा किंवा AI add-on खरेदी करा", "aiResponseLimitDialogTitle": "AI प्रतिसाद मर्यादा गाठली आहे", "aiResponseLimit": "तुमचे मोफत AI प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max किंवा Pro प्लॅन क्लिक करा", "askOwnerToUpgradeToPro": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे. कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा", "askOwnerToUpgradeToProIOS": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे.", "askOwnerToUpgradeToAIMax": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला प्लॅन अपग्रेड किंवा AI add-ons खरेदी करण्यास सांगा", "askOwnerToUpgradeToAIMaxIOS": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत.", "purchaseAIMax": "तुमच्या कार्यक्षेत्राचे AI प्रतिमा प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला AI Max खरेदी करण्यास सांगा", "aiImageResponseLimit": "तुमचे AI प्रतिमा प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max क्लिक करा", "purchaseStorageSpace": "स्टोरेज स्पेस खरेदी करा", "singleFileProPlanLimitationDescription": "तुम्ही मोफत प्लॅनमध्ये परवानगी दिलेल्या फाइल अपलोड आकाराची मर्यादा ओलांडली आहे. कृपया Pro प्लॅनमध्ये अपग्रेड करा", "purchaseAIResponse": "AI प्रतिसाद खरेदी करा", "askOwnerToUpgradeToLocalAI": "कार्यक्षेत्र मालकाला ऑन-डिव्हाइस AI सक्षम करण्यास सांगा", "upgradeToAILocal": "अत्याधिक गोपनीयतेसाठी तुमच्या डिव्हाइसवर लोकल मॉडेल चालवा", "upgradeToAILocalDesc": "PDFs सोबत गप्पा मारा, तुमचे लेखन सुधारा आणि लोकल AI वापरून टेबल्स आपोआप भरा" }, "notifications": { "export": { "markdown": "टीप Markdown मध्ये निर्यात केली", "path": "Documents/flowy" } }, "contactsPage": { "title": "संपर्क", "whatsHappening": "या आठवड्यात काय घडत आहे?", "addContact": "संपर्क जोडा", "editContact": "संपर्क संपादित करा" }, "button": { "ok": "ठीक आहे", "confirm": "खात्री करा", "done": "पूर्ण", "cancel": "रद्द करा", "signIn": "साइन इन", "signOut": "साइन आउट", "complete": "पूर्ण करा", "save": "जतन करा", "generate": "निर्माण करा", "esc": "ESC", "keep": "ठेवा", "tryAgain": "पुन्हा प्रयत्न करा", "discard": "टाका", "replace": "बदला", "insertBelow": "खाली घाला", "insertAbove": "वर घाला", "upload": "अपलोड करा", "edit": "संपादित करा", "delete": "हटवा", "copy": "कॉपी करा", "duplicate": "प्रत बनवा", "putback": "परत ठेवा", "update": "अद्यतनित करा", "share": "शेअर करा", "removeFromFavorites": "आवडतीतून काढा", "removeFromRecent": "अलीकडील यादीतून काढा", "addToFavorites": "आवडतीत जोडा", "favoriteSuccessfully": "आवडतीत यशस्वीरित्या जोडले", "unfavoriteSuccessfully": "आवडतीतून यशस्वीरित्या काढले", "duplicateSuccessfully": "प्रत यशस्वीरित्या तयार झाली", "rename": "नाव बदला", "helpCenter": "मदत केंद्र", "add": "जोड़ा", "yes": "होय", "no": "नाही", "clear": "साफ करा", "remove": "काढा", "dontRemove": "काढू नका", "copyLink": "लिंक कॉपी करा", "align": "जुळवा", "login": "लॉगिन", "logout": "लॉगआउट", "deleteAccount": "खाते हटवा", "back": "मागे", "signInGoogle": "Google सह पुढे जा", "signInGithub": "GitHub सह पुढे जा", "signInDiscord": "Discord सह पुढे जा", "more": "अधिक", "create": "तयार करा", "close": "बंद करा", "next": "पुढे", "previous": "मागील", "submit": "सबमिट करा", "download": "डाउनलोड करा", "backToHome": "मुख्यपृष्ठावर परत जा", "viewing": "पाहत आहात", "editing": "संपादन करत आहात", "gotIt": "समजले", "retry": "पुन्हा प्रयत्न करा", "uploadFailed": "अपलोड अयशस्वी.", "copyLinkOriginal": "मूळ दुव्याची कॉपी करा" }, "label": { "welcome": "स्वागत आहे!", "firstName": "पहिले नाव", "middleName": "मधले नाव", "lastName": "आडनाव", "stepX": "पायरी {X}" }, "oAuth": { "err": { "failedTitle": "तुमच्या खात्याशी कनेक्ट होता आले नाही.", "failedMsg": "कृपया खात्री करा की तुम्ही ब्राउझरमध्ये साइन-इन प्रक्रिया पूर्ण केली आहे." }, "google": { "title": "GOOGLE साइन-इन", "instruction1": "तुमचे Google Contacts आयात करण्यासाठी, तुम्हाला तुमच्या वेब ब्राउझरचा वापर करून या अ‍ॅप्लिकेशनला अधिकृत करणे आवश्यक आहे.", "instruction2": "ही कोड आयकॉनवर क्लिक करून किंवा मजकूर निवडून क्लिपबोर्डवर कॉपी करा:", "instruction3": "तुमच्या वेब ब्राउझरमध्ये खालील दुव्यावर जा आणि वरील कोड टाका:", "instruction4": "साइनअप पूर्ण झाल्यावर खालील बटण क्लिक करा:" } }, "settings": { "title": "सेटिंग्ज", "popupMenuItem": { "settings": "सेटिंग्ज", "members": "सदस्य", "trash": "कचरा", "helpAndSupport": "मदत आणि समर्थन" }, "sites": { "title": "साइट्स", "namespaceTitle": "नेमस्पेस", "namespaceDescription": "तुमचा नेमस्पेस आणि मुख्यपृष्ठ व्यवस्थापित करा", "namespaceHeader": "नेमस्पेस", "homepageHeader": "मुख्यपृष्ठ", "updateNamespace": "नेमस्पेस अद्यतनित करा", "removeHomepage": "मुख्यपृष्ठ हटवा", "selectHomePage": "एक पृष्ठ निवडा", "clearHomePage": "या नेमस्पेससाठी मुख्यपृष्ठ साफ करा", "customUrl": "स्वतःची URL", "namespace": { "description": "हे बदल सर्व प्रकाशित पृष्ठांवर लागू होतील जे या नेमस्पेसवर चालू आहेत", "tooltip": "कोणताही अनुचित नेमस्पेस आम्ही काढून टाकण्याचा अधिकार राखून ठेवतो", "updateExistingNamespace": "विद्यमान नेमस्पेस अद्यतनित करा", "upgradeToPro": "मुख्यपृष्ठ सेट करण्यासाठी Pro प्लॅनमध्ये अपग्रेड करा", "redirectToPayment": "पेमेंट पृष्ठावर वळवत आहे...", "onlyWorkspaceOwnerCanSetHomePage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ सेट करू शकतो", "pleaseAskOwnerToSetHomePage": "कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा" }, "publishedPage": { "title": "सर्व प्रकाशित पृष्ठे", "description": "तुमची प्रकाशित पृष्ठे व्यवस्थापित करा", "page": "पृष्ठ", "pathName": "पथाचे नाव", "date": "प्रकाशन तारीख", "emptyHinText": "या कार्यक्षेत्रात तुमच्याकडे कोणतीही प्रकाशित पृष्ठे नाहीत", "noPublishedPages": "प्रकाशित पृष्ठे नाहीत", "settings": "प्रकाशन सेटिंग्ज", "clickToOpenPageInApp": "पृष्ठ अ‍ॅपमध्ये उघडा", "clickToOpenPageInBrowser": "पृष्ठ ब्राउझरमध्ये उघडा" } } }, "error": { "failedToGeneratePaymentLink": "Pro प्लॅनसाठी पेमेंट लिंक तयार करण्यात अयशस्वी", "failedToUpdateNamespace": "नेमस्पेस अद्यतनित करण्यात अयशस्वी", "proPlanLimitation": "नेमस्पेस अद्यतनित करण्यासाठी तुम्हाला Pro प्लॅनमध्ये अपग्रेड करणे आवश्यक आहे", "namespaceAlreadyInUse": "नेमस्पेस आधीच वापरात आहे, कृपया दुसरे प्रयत्न करा", "invalidNamespace": "अवैध नेमस्पेस, कृपया दुसरे प्रयत्न करा", "namespaceLengthAtLeast2Characters": "नेमस्पेस किमान २ अक्षरे लांब असावे", "onlyWorkspaceOwnerCanUpdateNamespace": "फक्त कार्यक्षेत्र मालकच नेमस्पेस अद्यतनित करू शकतो", "onlyWorkspaceOwnerCanRemoveHomepage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ हटवू शकतो", "setHomepageFailed": "मुख्यपृष्ठ सेट करण्यात अयशस्वी", "namespaceTooLong": "नेमस्पेस खूप लांब आहे, कृपया दुसरे प्रयत्न करा", "namespaceTooShort": "नेमस्पेस खूप लहान आहे, कृपया दुसरे प्रयत्न करा", "namespaceIsReserved": "हा नेमस्पेस राखीव आहे, कृपया दुसरे प्रयत्न करा", "updatePathNameFailed": "पथाचे नाव अद्यतनित करण्यात अयशस्वी", "removeHomePageFailed": "मुख्यपृष्ठ हटवण्यात अयशस्वी", "publishNameContainsInvalidCharacters": "पथाच्या नावामध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", "publishNameTooShort": "पथाचे नाव खूप लहान आहे, कृपया दुसरे प्रयत्न करा", "publishNameTooLong": "पथाचे नाव खूप लांब आहे, कृपया दुसरे प्रयत्न करा", "publishNameAlreadyInUse": "हे पथाचे नाव आधीच वापरले गेले आहे, कृपया दुसरे प्रयत्न करा", "namespaceContainsInvalidCharacters": "नेमस्पेसमध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", "publishPermissionDenied": "फक्त कार्यक्षेत्र मालक किंवा पृष्ठ प्रकाशकच प्रकाशन सेटिंग्ज व्यवस्थापित करू शकतो", "publishNameCannotBeEmpty": "पथाचे नाव रिकामे असू शकत नाही, कृपया दुसरे प्रयत्न करा" }, "success": { "namespaceUpdated": "नेमस्पेस यशस्वीरित्या अद्यतनित केला", "setHomepageSuccess": "मुख्यपृष्ठ यशस्वीरित्या सेट केले", "updatePathNameSuccess": "पथाचे नाव यशस्वीरित्या अद्यतनित केले", "removeHomePageSuccess": "मुख्यपृष्ठ यशस्वीरित्या हटवले" }, "accountPage": { "menuLabel": "खाते आणि अ‍ॅप", "title": "माझे खाते", "general": { "title": "खात्याचे नाव आणि प्रोफाइल प्रतिमा", "changeProfilePicture": "प्रोफाइल प्रतिमा बदला" }, "email": { "title": "ईमेल", "actions": { "change": "ईमेल बदला" } }, "login": { "title": "खाते लॉगिन", "loginLabel": "लॉगिन", "logoutLabel": "लॉगआउट" }, "isUpToDate": "@:appName अद्ययावत आहे!", "officialVersion": "आवृत्ती {version} (अधिकृत बिल्ड)" }, "workspacePage": { "menuLabel": "कार्यक्षेत्र", "title": "कार्यक्षेत्र", "description": "तुमचे कार्यक्षेत्र स्वरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक/वेळ फॉरमॅट आणि भाषा सानुकूलित करा.", "workspaceName": { "title": "कार्यक्षेत्राचे नाव" }, "workspaceIcon": { "title": "कार्यक्षेत्राचे चिन्ह", "description": "तुमच्या कार्यक्षेत्रासाठी प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे चिन्ह साइडबार आणि सूचना मध्ये दिसेल." }, "appearance": { "title": "दृश्यरूप", "description": "कार्यक्षेत्राचे दृश्यरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक, वेळ आणि भाषा सानुकूलित करा.", "options": { "system": "स्वयंचलित", "light": "लाइट", "dark": "डार्क" } } }, "resetCursorColor": { "title": "दस्तऐवज कर्सरचा रंग रीसेट करा", "description": "तुम्हाला कर्सरचा रंग रीसेट करायचा आहे का?" }, "resetSelectionColor": { "title": "दस्तऐवज निवडीचा रंग रीसेट करा", "description": "तुम्हाला निवडीचा रंग रीसेट करायचा आहे का?" }, "resetWidth": { "resetSuccess": "दस्तऐवजाची रुंदी यशस्वीरित्या रीसेट केली" }, "theme": { "title": "थीम", "description": "पूर्व-निर्धारित थीम निवडा किंवा तुमची स्वतःची थीम अपलोड करा.", "uploadCustomThemeTooltip": "स्वतःची थीम अपलोड करा" }, "workspaceFont": { "title": "कार्यक्षेत्र फॉन्ट", "noFontHint": "कोणताही फॉन्ट सापडला नाही, कृपया दुसरा शब्द वापरून पहा." }, "textDirection": { "title": "मजकूर दिशा", "leftToRight": "डावीकडून उजवीकडे", "rightToLeft": "उजवीकडून डावीकडे", "auto": "स्वयंचलित", "enableRTLItems": "RTL टूलबार घटक सक्षम करा" }, "layoutDirection": { "title": "लेआउट दिशा", "leftToRight": "डावीकडून उजवीकडे", "rightToLeft": "उजवीकडून डावीकडे" }, "dateTime": { "title": "दिनांक आणि वेळ", "example": "{} वाजता {} ({})", "24HourTime": "२४-तास वेळ", "dateFormat": { "label": "दिनांक फॉरमॅट", "local": "स्थानिक", "us": "US", "iso": "ISO", "friendly": "सुलभ", "dmy": "D/M/Y" } }, "language": { "title": "भाषा" }, "deleteWorkspacePrompt": { "title": "कार्यक्षेत्र हटवा", "content": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही, आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील." }, "leaveWorkspacePrompt": { "title": "कार्यक्षेत्र सोडा", "content": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का? यानंतर तुम्हाला यामधील सर्व पृष्ठे आणि डेटावर प्रवेश मिळणार नाही.", "success": "तुम्ही कार्यक्षेत्र यशस्वीरित्या सोडले.", "fail": "कार्यक्षेत्र सोडण्यात अयशस्वी." }, "manageWorkspace": { "title": "कार्यक्षेत्र व्यवस्थापित करा", "leaveWorkspace": "कार्यक्षेत्र सोडा", "deleteWorkspace": "कार्यक्षेत्र हटवा" }, "manageDataPage": { "menuLabel": "डेटा व्यवस्थापित करा", "title": "डेटा व्यवस्थापन", "description": "@:appName मध्ये स्थानिक डेटा संचयन व्यवस्थापित करा किंवा तुमचा विद्यमान डेटा आयात करा.", "dataStorage": { "title": "फाइल संचयन स्थान", "tooltip": "जिथे तुमच्या फाइल्स संग्रहित आहेत ते स्थान", "actions": { "change": "मार्ग बदला", "open": "फोल्डर उघडा", "openTooltip": "सध्याच्या डेटा फोल्डरचे स्थान उघडा", "copy": "मार्ग कॉपी करा", "copiedHint": "मार्ग कॉपी केला!", "resetTooltip": "मूलभूत स्थानावर रीसेट करा" }, "resetDialog": { "title": "तुम्हाला खात्री आहे का?", "description": "पथ मूलभूत डेटा स्थानावर रीसेट केल्याने तुमचा डेटा हटवला जाणार नाही. तुम्हाला सध्याचा डेटा पुन्हा आयात करायचा असल्यास, कृपया आधी त्याचा पथ कॉपी करा." } }, "importData": { "title": "डेटा आयात करा", "tooltip": "@:appName बॅकअप/डेटा फोल्डरमधून डेटा आयात करा", "description": "बाह्य @:appName डेटा फोल्डरमधून डेटा कॉपी करा", "action": "फाइल निवडा" }, "encryption": { "title": "एनक्रिप्शन", "tooltip": "तुमचा डेटा कसा संग्रहित आणि एनक्रिप्ट केला जातो ते व्यवस्थापित करा", "descriptionNoEncryption": "एनक्रिप्शन चालू केल्याने सर्व डेटा एनक्रिप्ट केला जाईल. ही क्रिया पूर्ववत केली जाऊ शकत नाही.", "descriptionEncrypted": "तुमचा डेटा एनक्रिप्टेड आहे.", "action": "डेटा एनक्रिप्ट करा", "dialog": { "title": "संपूर्ण डेटा एनक्रिप्ट करायचा?", "description": "तुमचा संपूर्ण डेटा एनक्रिप्ट केल्याने तो सुरक्षित राहील. ही क्रिया पूर्ववत केली जाऊ शकत नाही. तुम्हाला पुढे जायचे आहे का?" } }, "cache": { "title": "कॅशे साफ करा", "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", "dialog": { "title": "कॅशे साफ करा", "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", "successHint": "कॅशे साफ झाली!" } }, "data": { "fixYourData": "तुमचा डेटा सुधारा", "fixButton": "सुधारा", "fixYourDataDescription": "तुमच्या डेटामध्ये काही अडचण येत असल्यास, तुम्ही येथे ती सुधारू शकता." } }, "shortcutsPage": { "menuLabel": "शॉर्टकट्स", "title": "शॉर्टकट्स", "editBindingHint": "नवीन बाइंडिंग टाका", "searchHint": "शोधा", "actions": { "resetDefault": "मूलभूत रीसेट करा" }, "errorPage": { "message": "शॉर्टकट्स लोड करण्यात अयशस्वी: {}", "howToFix": "कृपया पुन्हा प्रयत्न करा. समस्या कायम राहिल्यास GitHub वर संपर्क साधा." }, "resetDialog": { "title": "शॉर्टकट्स रीसेट करा", "description": "हे सर्व कीबाइंडिंग्जना मूळ स्थितीत रीसेट करेल. ही क्रिया पूर्ववत करता येणार नाही. तुम्हाला खात्री आहे का?", "buttonLabel": "रीसेट करा" }, "conflictDialog": { "title": "{} आधीच वापरले जात आहे", "descriptionPrefix": "हे कीबाइंडिंग सध्या ", "descriptionSuffix": " यामध्ये वापरले जात आहे. तुम्ही हे कीबाइंडिंग बदलल्यास, ते {} मधून काढले जाईल.", "confirmLabel": "पुढे जा" }, "editTooltip": "कीबाइंडिंग संपादित करण्यासाठी क्लिक करा", "keybindings": { "toggleToDoList": "टू-डू सूची चालू/बंद करा", "insertNewParagraphInCodeblock": "नवीन परिच्छेद टाका", "pasteInCodeblock": "कोडब्लॉकमध्ये पेस्ट करा", "selectAllCodeblock": "सर्व निवडा", "indentLineCodeblock": "ओळीच्या सुरुवातीला दोन स्पेस टाका", "outdentLineCodeblock": "ओळीच्या सुरुवातीची दोन स्पेस काढा", "twoSpacesCursorCodeblock": "कर्सरवर दोन स्पेस टाका", "copy": "निवड कॉपी करा", "paste": "मजकुरात पेस्ट करा", "cut": "निवड कट करा", "alignLeft": "मजकूर डावीकडे संरेखित करा", "alignCenter": "मजकूर मधोमध संरेखित करा", "alignRight": "मजकूर उजवीकडे संरेखित करा", "insertInlineMathEquation": "इनलाइन गणितीय सूत्र टाका", "undo": "पूर्ववत करा", "redo": "पुन्हा करा", "convertToParagraph": "ब्लॉक परिच्छेदात रूपांतरित करा", "backspace": "हटवा", "deleteLeftWord": "डावीकडील शब्द हटवा", "deleteLeftSentence": "डावीकडील वाक्य हटवा", "delete": "उजवीकडील अक्षर हटवा", "deleteMacOS": "डावीकडील अक्षर हटवा", "deleteRightWord": "उजवीकडील शब्द हटवा", "moveCursorLeft": "कर्सर डावीकडे हलवा", "moveCursorBeginning": "कर्सर सुरुवातीला हलवा", "moveCursorLeftWord": "कर्सर एक शब्द डावीकडे हलवा", "moveCursorLeftSelect": "निवडा आणि कर्सर डावीकडे हलवा", "moveCursorBeginSelect": "निवडा आणि कर्सर सुरुवातीला हलवा", "moveCursorLeftWordSelect": "निवडा आणि कर्सर एक शब्द डावीकडे हलवा", "moveCursorRight": "कर्सर उजवीकडे हलवा", "moveCursorEnd": "कर्सर शेवटी हलवा", "moveCursorRightWord": "कर्सर एक शब्द उजवीकडे हलवा", "moveCursorRightSelect": "निवडा आणि कर्सर उजवीकडे हलवा", "moveCursorEndSelect": "निवडा आणि कर्सर शेवटी हलवा", "moveCursorRightWordSelect": "निवडा आणि कर्सर एक शब्द उजवीकडे हलवा", "moveCursorUp": "कर्सर वर हलवा", "moveCursorTopSelect": "निवडा आणि कर्सर वर हलवा", "moveCursorTop": "कर्सर वर हलवा", "moveCursorUpSelect": "निवडा आणि कर्सर वर हलवा", "moveCursorBottomSelect": "निवडा आणि कर्सर खाली हलवा", "moveCursorBottom": "कर्सर खाली हलवा", "moveCursorDown": "कर्सर खाली हलवा", "moveCursorDownSelect": "निवडा आणि कर्सर खाली हलवा", "home": "वर स्क्रोल करा", "end": "खाली स्क्रोल करा", "toggleBold": "बोल्ड चालू/बंद करा", "toggleItalic": "इटालिक चालू/बंद करा", "toggleUnderline": "अधोरेखित चालू/बंद करा", "toggleStrikethrough": "स्ट्राईकथ्रू चालू/बंद करा", "toggleCode": "इनलाइन कोड चालू/बंद करा", "toggleHighlight": "हायलाईट चालू/बंद करा", "showLinkMenu": "लिंक मेनू दाखवा", "openInlineLink": "इनलाइन लिंक उघडा", "openLinks": "सर्व निवडलेले लिंक उघडा", "indent": "इंडेंट", "outdent": "आउटडेंट", "exit": "संपादनातून बाहेर पडा", "pageUp": "एक पृष्ठ वर स्क्रोल करा", "pageDown": "एक पृष्ठ खाली स्क्रोल करा", "selectAll": "सर्व निवडा", "pasteWithoutFormatting": "फॉरमॅटिंगशिवाय पेस्ट करा", "showEmojiPicker": "इमोजी निवडकर्ता दाखवा", "enterInTableCell": "टेबलमध्ये नवीन ओळ जोडा", "leftInTableCell": "टेबलमध्ये डावीकडील सेलमध्ये जा", "rightInTableCell": "टेबलमध्ये उजवीकडील सेलमध्ये जा", "upInTableCell": "टेबलमध्ये वरील सेलमध्ये जा", "downInTableCell": "टेबलमध्ये खालील सेलमध्ये जा", "tabInTableCell": "टेबलमध्ये पुढील सेलमध्ये जा", "shiftTabInTableCell": "टेबलमध्ये मागील सेलमध्ये जा", "backSpaceInTableCell": "सेलच्या सुरुवातीला थांबा" }, "commands": { "codeBlockNewParagraph": "कोडब्लॉकच्या शेजारी नवीन परिच्छेद टाका", "codeBlockIndentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीला दोन स्पेस टाका", "codeBlockOutdentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीची दोन स्पेस काढा", "codeBlockAddTwoSpaces": "कोडब्लॉकमध्ये कर्सरच्या ठिकाणी दोन स्पेस टाका", "codeBlockSelectAll": "कोडब्लॉकमधील सर्व मजकूर निवडा", "codeBlockPasteText": "कोडब्लॉकमध्ये मजकूर पेस्ट करा", "textAlignLeft": "मजकूर डावीकडे संरेखित करा", "textAlignCenter": "मजकूर मधोमध संरेखित करा", "textAlignRight": "मजकूर उजवीकडे संरेखित करा" }, "couldNotLoadErrorMsg": "शॉर्टकट लोड करता आले नाहीत. पुन्हा प्रयत्न करा", "couldNotSaveErrorMsg": "शॉर्टकट सेव्ह करता आले नाहीत. पुन्हा प्रयत्न करा" }, "aiPage": { "title": "AI सेटिंग्ज", "menuLabel": "AI सेटिंग्ज", "keys": { "enableAISearchTitle": "AI शोध", "aiSettingsDescription": "AppFlowy AI चालवण्यासाठी तुमचा पसंतीचा मॉडेल निवडा. आता GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet आणि Ollama मधील मॉडेल्सचा समावेश आहे.", "loginToEnableAIFeature": "AI वैशिष्ट्ये फक्त @:appName Cloud मध्ये लॉगिन केल्यानंतरच सक्षम होतात. तुमच्याकडे @:appName खाते नसेल तर 'माय अकाउंट' मध्ये जाऊन साइन अप करा.", "llmModel": "भाषा मॉडेल", "llmModelType": "भाषा मॉडेल प्रकार", "downloadLLMPrompt": "{} डाउनलोड करा", "downloadAppFlowyOfflineAI": "AI ऑफलाइन पॅकेज डाउनलोड केल्याने तुमच्या डिव्हाइसवर AI चालवता येईल. तुम्हाला पुढे जायचे आहे का?", "downloadLLMPromptDetail": "{} स्थानिक मॉडेल डाउनलोड करण्यासाठी सुमारे {} संचयन लागेल. तुम्हाला पुढे जायचे आहे का?", "downloadBigFilePrompt": "डाउनलोड पूर्ण होण्यासाठी सुमारे १० मिनिटे लागू शकतात", "downloadAIModelButton": "डाउनलोड करा", "downloadingModel": "डाउनलोड करत आहे", "localAILoaded": "स्थानिक AI मॉडेल यशस्वीरित्या जोडले गेले आणि वापरण्यास सज्ज आहे", "localAIStart": "स्थानिक AI सुरू होत आहे. जर हळू वाटत असेल, तर ते बंद करून पुन्हा सुरू करून पहा", "localAILoading": "स्थानिक AI Chat मॉडेल लोड होत आहे...", "localAIStopped": "स्थानिक AI थांबले आहे", "localAIRunning": "स्थानिक AI चालू आहे", "localAINotReadyRetryLater": "स्थानिक AI सुरू होत आहे, कृपया नंतर पुन्हा प्रयत्न करा", "localAIDisabled": "तुम्ही स्थानिक AI वापरत आहात, पण ते अकार्यक्षम आहे. कृपया सेटिंग्जमध्ये जाऊन ते सक्षम करा किंवा दुसरे मॉडेल वापरून पहा", "localAIInitializing": "स्थानिक AI लोड होत आहे. तुमच्या डिव्हाइसवर अवलंबून याला काही मिनिटे लागू शकतात", "localAINotReadyTextFieldPrompt": "स्थानिक AI लोड होत असताना संपादन करता येणार नाही", "failToLoadLocalAI": "स्थानिक AI सुरू करण्यात अयशस्वी.", "restartLocalAI": "पुन्हा सुरू करा", "disableLocalAITitle": "स्थानिक AI अकार्यक्षम करा", "disableLocalAIDescription": "तुम्हाला स्थानिक AI अकार्यक्षम करायचा आहे का?", "localAIToggleTitle": "AppFlowy स्थानिक AI (LAI)", "localAIToggleSubTitle": "AppFlowy मध्ये अत्याधुनिक स्थानिक AI मॉडेल्स वापरून गोपनीयता आणि सुरक्षा सुनिश्चित करा", "offlineAIInstruction1": "हे अनुसरा", "offlineAIInstruction2": "सूचना", "offlineAIInstruction3": "ऑफलाइन AI सक्षम करण्यासाठी.", "offlineAIDownload1": "जर तुम्ही AppFlowy AI डाउनलोड केले नसेल, तर कृपया", "offlineAIDownload2": "डाउनलोड", "offlineAIDownload3": "करा", "activeOfflineAI": "सक्रिय", "downloadOfflineAI": "डाउनलोड करा", "openModelDirectory": "फोल्डर उघडा", "laiNotReady": "स्थानिक AI अ‍ॅप योग्य प्रकारे इन्स्टॉल झालेले नाही.", "ollamaNotReady": "Ollama सर्व्हर तयार नाही.", "pleaseFollowThese": "कृपया हे अनुसरा", "instructions": "सूचना", "installOllamaLai": "Ollama आणि AppFlowy स्थानिक AI सेटअप करण्यासाठी.", "modelsMissing": "आवश्यक मॉडेल्स सापडत नाहीत.", "downloadModel": "त्यांना डाउनलोड करण्यासाठी." } }, "planPage": { "menuLabel": "योजना", "title": "दर योजना", "planUsage": { "title": "योजनेचा वापर सारांश", "storageLabel": "स्टोरेज", "storageUsage": "{} पैकी {} GB", "unlimitedStorageLabel": "अमर्यादित स्टोरेज", "collaboratorsLabel": "सदस्य", "collaboratorsUsage": "{} पैकी {}", "aiResponseLabel": "AI प्रतिसाद", "aiResponseUsage": "{} पैकी {}", "unlimitedAILabel": "अमर्यादित AI प्रतिसाद", "proBadge": "प्रो", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "मॅकसाठी ऑन-डिव्हाइस AI", "memberProToggle": "अधिक सदस्य आणि अमर्यादित AI", "aiMaxToggle": "अमर्यादित AI आणि प्रगत मॉडेल्सचा प्रवेश", "aiOnDeviceToggle": "जास्त गोपनीयतेसाठी स्थानिक AI", "aiCredit": { "title": "@:appName AI क्रेडिट जोडा", "price": "{}", "priceDescription": "1,000 क्रेडिट्ससाठी", "purchase": "AI खरेदी करा", "info": "प्रत्येक कार्यक्षेत्रासाठी 1,000 AI क्रेडिट्स जोडा आणि आपल्या कामाच्या प्रवाहात सानुकूलनीय AI सहजपणे एकत्र करा — अधिक स्मार्ट आणि जलद निकालांसाठी:", "infoItemOne": "प्रत्येक डेटाबेससाठी 10,000 प्रतिसाद", "infoItemTwo": "प्रत्येक कार्यक्षेत्रासाठी 1,000 प्रतिसाद" }, "currentPlan": { "bannerLabel": "सद्य योजना", "freeTitle": "फ्री", "proTitle": "प्रो", "teamTitle": "टीम", "freeInfo": "2 सदस्यांपर्यंत वैयक्तिक वापरासाठी उत्तम", "proInfo": "10 सदस्यांपर्यंत लहान आणि मध्यम टीमसाठी योग्य", "teamInfo": "सर्व उत्पादनक्षम आणि सुव्यवस्थित टीमसाठी योग्य", "upgrade": "योजना बदला", "canceledInfo": "तुमची योजना रद्द करण्यात आली आहे, {} रोजी तुम्हाला फ्री योजनेवर डाऊनग्रेड केले जाईल." }, "addons": { "title": "ऍड-ऑन्स", "addLabel": "जोडा", "activeLabel": "जोडले गेले", "aiMax": { "title": "AI Max", "description": "प्रगत AI मॉडेल्ससह अमर्यादित AI प्रतिसाद आणि दरमहा 50 AI प्रतिमा", "price": "{}", "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)" }, "aiOnDevice": { "title": "मॅकसाठी ऑन-डिव्हाइस AI", "description": "तुमच्या डिव्हाइसवर Mistral 7B, LLAMA 3 आणि अधिक स्थानिक मॉडेल्स चालवा", "price": "{}", "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)", "recommend": "M1 किंवा नवीनतम शिफारस केली जाते" } }, "deal": { "bannerLabel": "नववर्षाचे विशेष ऑफर!", "title": "तुमची टीम वाढवा!", "info": "Pro आणि Team योजनांवर 10% सूट मिळवा! @:appName AI सह शक्तिशाली नवीन वैशिष्ट्यांसह तुमचे कार्यक्षेत्र अधिक कार्यक्षम बनवा.", "viewPlans": "योजना पहा" } } }, "billingPage": { "menuLabel": "बिलिंग", "title": "बिलिंग", "plan": { "title": "योजना", "freeLabel": "फ्री", "proLabel": "प्रो", "planButtonLabel": "योजना बदला", "billingPeriod": "बिलिंग कालावधी", "periodButtonLabel": "कालावधी संपादित करा" }, "paymentDetails": { "title": "पेमेंट तपशील", "methodLabel": "पेमेंट पद्धत", "methodButtonLabel": "पद्धत संपादित करा" }, "addons": { "title": "ऍड-ऑन्स", "addLabel": "जोडा", "removeLabel": "काढा", "renewLabel": "नवीन करा", "aiMax": { "label": "AI Max", "description": "अमर्यादित AI आणि प्रगत मॉडेल्स अनलॉक करा", "activeDescription": "पुढील बिलिंग तारीख {} आहे", "canceledDescription": "AI Max {} पर्यंत उपलब्ध असेल" }, "aiOnDevice": { "label": "मॅकसाठी ऑन-डिव्हाइस AI", "description": "तुमच्या डिव्हाइसवर अमर्यादित ऑन-डिव्हाइस AI अनलॉक करा", "activeDescription": "पुढील बिलिंग तारीख {} आहे", "canceledDescription": "मॅकसाठी ऑन-डिव्हाइस AI {} पर्यंत उपलब्ध असेल" }, "removeDialog": { "title": "{} काढा", "description": "तुम्हाला {plan} काढायचे आहे का? तुम्हाला तत्काळ {plan} चे सर्व फिचर्स आणि फायदे वापरण्याचा अधिकार गमावावा लागेल." } }, "currentPeriodBadge": "सद्य कालावधी", "changePeriod": "कालावधी बदला", "planPeriod": "{} कालावधी", "monthlyInterval": "मासिक", "monthlyPriceInfo": "प्रति सदस्य, मासिक बिलिंग", "annualInterval": "वार्षिक", "annualPriceInfo": "प्रति सदस्य, वार्षिक बिलिंग" }, "comparePlanDialog": { "title": "योजना तुलना आणि निवड", "planFeatures": "योजनेची\nवैशिष्ट्ये", "current": "सध्याची", "actions": { "upgrade": "अपग्रेड करा", "downgrade": "डाऊनग्रेड करा", "current": "सध्याची" }, "freePlan": { "title": "फ्री", "description": "२ सदस्यांपर्यंत व्यक्तींसाठी सर्व काही व्यवस्थित करण्यासाठी", "price": "{}", "priceInfo": "सदैव फ्री" }, "proPlan": { "title": "प्रो", "description": "लहान टीम्ससाठी प्रोजेक्ट्स व ज्ञान व्यवस्थापनासाठी", "price": "{}", "priceInfo": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग\n\n{} मासिक बिलिंगसाठी" }, "planLabels": { "itemOne": "वर्कस्पेसेस", "itemTwo": "सदस्य", "itemThree": "स्टोरेज", "itemFour": "रिअल-टाइम सहकार्य", "itemFive": "मोबाईल अ‍ॅप", "itemSix": "AI प्रतिसाद", "itemSeven": "AI प्रतिमा", "itemFileUpload": "फाइल अपलोड", "customNamespace": "सानुकूल नेमस्पेस", "tooltipSix": "‘Lifetime’ म्हणजे या प्रतिसादांची मर्यादा कधीही रीसेट केली जात नाही", "intelligentSearch": "स्मार्ट शोध", "tooltipSeven": "तुमच्या कार्यक्षेत्रासाठी URL चा भाग सानुकूलित करू देते", "customNamespaceTooltip": "सानुकूल प्रकाशित साइट URL" }, "freeLabels": { "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", "itemTwo": "२ पर्यंत", "itemThree": "५ GB", "itemFour": "होय", "itemFive": "होय", "itemSix": "१० कायमस्वरूपी", "itemSeven": "२ कायमस्वरूपी", "itemFileUpload": "७ MB पर्यंत", "intelligentSearch": "स्मार्ट शोध" }, "proLabels": { "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", "itemTwo": "१० पर्यंत", "itemThree": "अमर्यादित", "itemFour": "होय", "itemFive": "होय", "itemSix": "अमर्यादित", "itemSeven": "दर महिन्याला १० प्रतिमा", "itemFileUpload": "अमर्यादित", "intelligentSearch": "स्मार्ट शोध" }, "paymentSuccess": { "title": "तुम्ही आता {} योजनेवर आहात!", "description": "तुमचे पेमेंट यशस्वीरित्या पूर्ण झाले असून तुमची योजना @:appName {} मध्ये अपग्रेड झाली आहे. तुम्ही 'योजना' पृष्ठावर तपशील पाहू शकता." }, "downgradeDialog": { "title": "तुम्हाला योजना डाऊनग्रेड करायची आहे का?", "description": "तुमची योजना डाऊनग्रेड केल्यास तुम्ही फ्री योजनेवर परत जाल. काही सदस्यांना या कार्यक्षेत्राचा प्रवेश मिळणार नाही आणि तुम्हाला स्टोरेज मर्यादेप्रमाणे जागा रिकामी करावी लागू शकते.", "downgradeLabel": "योजना डाऊनग्रेड करा" } }, "cancelSurveyDialog": { "title": "तुम्ही जात आहात याचे दुःख आहे", "description": "तुम्ही जात आहात याचे आम्हाला खरोखरच दुःख वाटते. @:appName सुधारण्यासाठी तुमचा अभिप्राय आम्हाला महत्त्वाचा वाटतो. कृपया काही क्षण घेऊन काही प्रश्नांची उत्तरे द्या.", "commonOther": "इतर", "otherHint": "तुमचे उत्तर येथे लिहा", "questionOne": { "question": "तुम्ही तुमची @:appName Pro सदस्यता का रद्द केली?", "answerOne": "खर्च खूप जास्त आहे", "answerTwo": "वैशिष्ट्ये अपेक्षांनुसार नव्हती", "answerThree": "यापेक्षा चांगला पर्याय सापडला", "answerFour": "वापर फारसा केला नाही, त्यामुळे खर्च योग्य वाटला नाही", "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" }, "questionTwo": { "question": "भविष्यात @:appName Pro सदस्यता पुन्हा घेण्याची शक्यता किती आहे?", "answerOne": "खूप शक्यता आहे", "answerTwo": "काहीशी शक्यता आहे", "answerThree": "निश्चित नाही", "answerFour": "अल्प शक्यता", "answerFive": "एकदम कमी शक्यता" }, "questionThree": { "question": "तुमच्या सदस्यत्वादरम्यान कोणते Pro वैशिष्ट्य सर्वात उपयुक्त वाटले?", "answerOne": "अनेक वापरकर्त्यांशी सहकार्य", "answerTwo": "लांब कालावधीची आवृत्ती इतिहास", "answerThree": "अमर्यादित AI प्रतिसाद", "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" }, "questionFour": { "question": "@:appName वापरण्याचा तुमचा एकूण अनुभव कसा होता?", "answerOne": "खूप छान", "answerTwo": "चांगला", "answerThree": "सरासरी", "answerFour": "सरासरीपेक्षा कमी", "answerFive": "असंतोषजनक" } }, "common": { "uploadingFile": "फाईल अपलोड होत आहे. कृपया अ‍ॅप बंद करू नका", "uploadNotionSuccess": "तुमची Notion zip फाईल यशस्वीरित्या अपलोड झाली आहे. आयात पूर्ण झाल्यानंतर तुम्हाला पुष्टीकरण ईमेल मिळेल", "reset": "रीसेट करा" }, "menu": { "appearance": "दृश्यरूप", "language": "भाषा", "user": "वापरकर्ता", "files": "फाईल्स", "notifications": "सूचना", "open": "सेटिंग्ज उघडा", "logout": "लॉगआउट", "logoutPrompt": "तुम्हाला नक्की लॉगआउट करायचे आहे का?", "selfEncryptionLogoutPrompt": "तुम्हाला खात्रीने लॉगआउट करायचे आहे का? कृपया खात्री करा की तुम्ही एनक्रिप्शन गुप्तकी कॉपी केली आहे", "syncSetting": "सिंक्रोनायझेशन सेटिंग", "cloudSettings": "क्लाऊड सेटिंग्ज", "enableSync": "सिंक्रोनायझेशन सक्षम करा", "enableSyncLog": "सिंक लॉगिंग सक्षम करा", "enableSyncLogWarning": "सिंक समस्यांचे निदान करण्यात मदतीसाठी धन्यवाद. हे तुमचे डॉक्युमेंट संपादन स्थानिक फाईलमध्ये लॉग करेल. कृपया सक्षम केल्यानंतर अ‍ॅप बंद करून पुन्हा उघडा", "enableEncrypt": "डेटा एन्क्रिप्ट करा", "cloudURL": "बेस URL", "webURL": "वेब URL", "invalidCloudURLScheme": "अवैध स्कीम", "cloudServerType": "क्लाऊड सर्व्हर", "cloudServerTypeTip": "कृपया लक्षात घ्या की क्लाऊड सर्व्हर बदलल्यास तुम्ही सध्या लॉगिन केलेले खाते लॉगआउट होऊ शकते", "cloudLocal": "स्थानिक", "cloudAppFlowy": "@:appName Cloud", "cloudAppFlowySelfHost": "@:appName क्लाऊड सेल्फ-होस्टेड", "appFlowyCloudUrlCanNotBeEmpty": "क्लाऊड URL रिकामा असू शकत नाही", "clickToCopy": "क्लिपबोर्डवर कॉपी करा", "selfHostStart": "जर तुमच्याकडे सर्व्हर नसेल, तर कृपया हे पाहा", "selfHostContent": "दस्तऐवज", "selfHostEnd": "तुमचा स्वतःचा सर्व्हर कसा होस्ट करावा यासाठी मार्गदर्शनासाठी", "pleaseInputValidURL": "कृपया वैध URL टाका", "changeUrl": "सेल्फ-होस्टेड URL {} मध्ये बदला", "cloudURLHint": "तुमच्या सर्व्हरचा बेस URL टाका", "webURLHint": "तुमच्या वेब सर्व्हरचा बेस URL टाका", "cloudWSURL": "वेबसॉकेट URL", "cloudWSURLHint": "तुमच्या सर्व्हरचा वेबसॉकेट पत्ता टाका", "restartApp": "अ‍ॅप रीस्टार्ट करा", "restartAppTip": "बदल प्रभावी होण्यासाठी अ‍ॅप रीस्टार्ट करा. कृपया लक्षात घ्या की यामुळे सध्याचे खाते लॉगआउट होऊ शकते.", "changeServerTip": "सर्व्हर बदलल्यानंतर, बदल लागू करण्यासाठी तुम्हाला 'रीस्टार्ट' बटणावर क्लिक करणे आवश्यक आहे", "enableEncryptPrompt": "तुमचा डेटा सुरक्षित करण्यासाठी एन्क्रिप्शन सक्रिय करा. ही गुप्तकी सुरक्षित ठेवा; एकदा सक्षम केल्यावर ती बंद करता येणार नाही. जर हरवली तर तुमचा डेटा पुन्हा मिळवता येणार नाही. कॉपी करण्यासाठी क्लिक करा", "inputEncryptPrompt": "कृपया खालीलसाठी तुमची एनक्रिप्शन गुप्तकी टाका:", "clickToCopySecret": "गुप्तकी कॉपी करण्यासाठी क्लिक करा", "configServerSetting": "तुमच्या सर्व्हर सेटिंग्ज कॉन्फिगर करा", "configServerGuide": "`Quick Start` निवडल्यानंतर, `Settings` → \"Cloud Settings\" मध्ये जा आणि तुमचा सेल्फ-होस्टेड सर्व्हर कॉन्फिगर करा.", "inputTextFieldHint": "तुमची गुप्तकी", "historicalUserList": "वापरकर्ता लॉगिन इतिहास", "historicalUserListTooltip": "ही यादी तुमची अनामिक खाती दर्शवते. तपशील पाहण्यासाठी खात्यावर क्लिक करा. अनामिक खाती 'सुरुवात करा' बटणावर क्लिक करून तयार केली जातात", "openHistoricalUser": "अनामिक खाते उघडण्यासाठी क्लिक करा", "customPathPrompt": "@:appName डेटा फोल्डर Google Drive सारख्या क्लाऊड-सिंक फोल्डरमध्ये साठवणे धोकादायक ठरू शकते. फोल्डरमधील डेटाबेस अनेक ठिकाणांवरून एकाच वेळी ऍक्सेस/संपादित केल्यास डेटा सिंक समस्या किंवा भ्रष्ट होण्याची शक्यता असते.", "importAppFlowyData": "बाह्य @:appName फोल्डरमधून डेटा आयात करा", "importingAppFlowyDataTip": "डेटा आयात सुरू आहे. कृपया अ‍ॅप बंद करू नका", "importAppFlowyDataDescription": "बाह्य @:appName फोल्डरमधून डेटा कॉपी करून सध्याच्या AppFlowy डेटा फोल्डरमध्ये आयात करा", "importSuccess": "@:appName डेटा फोल्डर यशस्वीरित्या आयात झाला", "importFailed": "@:appName डेटा फोल्डर आयात करण्यात अयशस्वी", "importGuide": "अधिक माहितीसाठी, कृपया संदर्भित दस्तऐवज पहा" }, "notifications": { "enableNotifications": { "label": "सूचना सक्षम करा", "hint": "स्थानिक सूचना दिसू नयेत यासाठी बंद करा." }, "showNotificationsIcon": { "label": "सूचना चिन्ह दाखवा", "hint": "साइडबारमध्ये सूचना चिन्ह लपवण्यासाठी बंद करा." }, "archiveNotifications": { "allSuccess": "सर्व सूचना यशस्वीरित्या संग्रहित केल्या", "success": "सूचना यशस्वीरित्या संग्रहित केली" }, "markAsReadNotifications": { "allSuccess": "सर्व वाचलेल्या म्हणून चिन्हांकित केल्या", "success": "वाचलेले म्हणून चिन्हांकित केले" }, "action": { "markAsRead": "वाचलेले म्हणून चिन्हांकित करा", "multipleChoice": "अधिक निवडा", "archive": "संग्रहित करा" }, "settings": { "settings": "सेटिंग्ज", "markAllAsRead": "सर्व वाचलेले म्हणून चिन्हांकित करा", "archiveAll": "सर्व संग्रहित करा" }, "emptyInbox": { "title": "इनबॉक्स झिरो!", "description": "इथे सूचना मिळवण्यासाठी रिमाइंडर सेट करा." }, "emptyUnread": { "title": "कोणतीही न वाचलेली सूचना नाही", "description": "तुम्ही सर्व वाचले आहे!" }, "emptyArchived": { "title": "कोणतीही संग्रहित सूचना नाही", "description": "संग्रहित सूचना इथे दिसतील." }, "tabs": { "inbox": "इनबॉक्स", "unread": "न वाचलेले", "archived": "संग्रहित" }, "refreshSuccess": "सूचना यशस्वीरित्या रीफ्रेश केल्या", "titles": { "notifications": "सूचना", "reminder": "रिमाइंडर" } }, "appearance": { "resetSetting": "रीसेट", "fontFamily": { "label": "फॉन्ट फॅमिली", "search": "शोध", "defaultFont": "सिस्टम" }, "themeMode": { "label": "थीम मोड", "light": "लाइट मोड", "dark": "डार्क मोड", "system": "सिस्टमशी जुळवा" }, "fontScaleFactor": "फॉन्ट स्केल घटक", "displaySize": "डिस्प्ले आकार", "documentSettings": { "cursorColor": "डॉक्युमेंट कर्सरचा रंग", "selectionColor": "डॉक्युमेंट निवडीचा रंग", "width": "डॉक्युमेंटची रुंदी", "changeWidth": "बदला", "pickColor": "रंग निवडा", "colorShade": "रंगाची छटा", "opacity": "अपारदर्शकता", "hexEmptyError": "Hex रंग रिकामा असू शकत नाही", "hexLengthError": "Hex व्हॅल्यू 6 अंकांची असावी", "hexInvalidError": "अवैध Hex व्हॅल्यू", "opacityEmptyError": "अपारदर्शकता रिकामी असू शकत नाही", "opacityRangeError": "अपारदर्शकता 1 ते 100 दरम्यान असावी", "app": "अ‍ॅप", "flowy": "Flowy", "apply": "लागू करा" }, "layoutDirection": { "label": "लेआउट दिशा", "hint": "तुमच्या स्क्रीनवरील कंटेंटचा प्रवाह नियंत्रित करा — डावीकडून उजवीकडे किंवा उजवीकडून डावीकडे.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "मूलभूत मजकूर दिशा", "hint": "मजकूर डावीकडून सुरू व्हावा की उजवीकडून याचे पूर्वनिर्धारित निर्धारण करा.", "ltr": "LTR", "rtl": "RTL", "auto": "स्वयं", "fallback": "लेआउट दिशेशी जुळवा" }, "themeUpload": { "button": "अपलोड", "uploadTheme": "थीम अपलोड करा", "description": "खालील बटण वापरून तुमची स्वतःची @:appName थीम अपलोड करा.", "loading": "कृपया प्रतीक्षा करा. आम्ही तुमची थीम पडताळत आहोत आणि अपलोड करत आहोत...", "uploadSuccess": "तुमची थीम यशस्वीरित्या अपलोड झाली आहे", "deletionFailure": "थीम हटवण्यात अयशस्वी. कृपया ती मॅन्युअली हटवून पाहा.", "filePickerDialogTitle": ".flowy_plugin फाईल निवडा", "urlUploadFailure": "URL उघडण्यात अयशस्वी: {}" }, "theme": "थीम", "builtInsLabel": "अंतर्गत थीम्स", "pluginsLabel": "प्लगइन्स", "dateFormat": { "label": "दिनांक फॉरमॅट", "local": "स्थानिक", "us": "US", "iso": "ISO", "friendly": "अनौपचारिक", "dmy": "D/M/Y" }, "timeFormat": { "label": "वेळ फॉरमॅट", "twelveHour": "१२ तास", "twentyFourHour": "२४ तास" }, "showNamingDialogWhenCreatingPage": "पृष्ठ तयार करताना नाव विचारणारा डायलॉग दाखवा", "enableRTLToolbarItems": "RTL टूलबार आयटम्स सक्षम करा", "members": { "title": "सदस्य सेटिंग्ज", "inviteMembers": "सदस्यांना आमंत्रण द्या", "inviteHint": "ईमेलद्वारे आमंत्रण द्या", "sendInvite": "आमंत्रण पाठवा", "copyInviteLink": "आमंत्रण दुवा कॉपी करा", "label": "सदस्य", "user": "वापरकर्ता", "role": "भूमिका", "removeFromWorkspace": "वर्कस्पेसमधून काढा", "removeFromWorkspaceSuccess": "वर्कस्पेसमधून यशस्वीरित्या काढले", "removeFromWorkspaceFailed": "वर्कस्पेसमधून काढण्यात अयशस्वी", "owner": "मालक", "guest": "अतिथी", "member": "सदस्य", "memberHintText": "सदस्य पृष्ठे वाचू व संपादित करू शकतो", "guestHintText": "अतिथी पृष्ठे वाचू शकतो, प्रतिक्रिया देऊ शकतो, टिप्पणी करू शकतो, आणि परवानगी असल्यास काही पृष्ठे संपादित करू शकतो.", "emailInvalidError": "अवैध ईमेल, कृपया तपासा व पुन्हा प्रयत्न करा", "emailSent": "ईमेल पाठवला गेला आहे, कृपया इनबॉक्स तपासा", "members": "सदस्य", "membersCount": { "zero": "{} सदस्य", "one": "{} सदस्य", "other": "{} सदस्य" }, "inviteFailedDialogTitle": "आमंत्रण पाठवण्यात अयशस्वी", "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, अधिक सदस्यांसाठी कृपया अपग्रेड करा.", "inviteFailedMemberLimitMobile": "तुमच्या वर्कस्पेसने सदस्य मर्यादा गाठली आहे.", "memberLimitExceeded": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आमंत्रित करण्यासाठी कृपया ", "memberLimitExceededUpgrade": "अपग्रेड करा", "memberLimitExceededPro": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आवश्यक असल्यास संपर्क करा", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "सदस्य जोडण्यात अयशस्वी", "addMemberSuccess": "सदस्य यशस्वीरित्या जोडला गेला", "removeMember": "सदस्य काढा", "areYouSureToRemoveMember": "तुम्हाला हा सदस्य काढायचा आहे का?", "inviteMemberSuccess": "आमंत्रण यशस्वीरित्या पाठवले गेले", "failedToInviteMember": "सदस्य आमंत्रित करण्यात अयशस्वी", "workspaceMembersError": "अरे! काहीतरी चूक झाली आहे", "workspaceMembersErrorDescription": "आम्ही सध्या सदस्यांची यादी लोड करू शकत नाही. कृपया नंतर पुन्हा प्रयत्न करा" } }, "files": { "copy": "कॉपी करा", "defaultLocation": "फाईल्स आणि डेटाचा संचय स्थान", "exportData": "तुमचा डेटा निर्यात करा", "doubleTapToCopy": "पथ कॉपी करण्यासाठी दोनदा टॅप करा", "restoreLocation": "@:appName चे मूळ स्थान पुनर्संचयित करा", "customizeLocation": "इतर फोल्डर उघडा", "restartApp": "बदल लागू करण्यासाठी कृपया अ‍ॅप रीस्टार्ट करा.", "exportDatabase": "डेटाबेस निर्यात करा", "selectFiles": "निर्यात करण्यासाठी फाईल्स निवडा", "selectAll": "सर्व निवडा", "deselectAll": "सर्व निवड रद्द करा", "createNewFolder": "नवीन फोल्डर तयार करा", "createNewFolderDesc": "तुमचा डेटा कुठे साठवायचा हे सांगा", "defineWhereYourDataIsStored": "तुमचा डेटा कुठे साठवला जातो हे ठरवा", "open": "उघडा", "openFolder": "आधीक फोल्डर उघडा", "openFolderDesc": "तुमच्या विद्यमान @:appName फोल्डरमध्ये वाचन व लेखन करा", "folderHintText": "फोल्डरचे नाव", "location": "नवीन फोल्डर तयार करत आहे", "locationDesc": "तुमच्या @:appName डेटासाठी नाव निवडा", "browser": "ब्राउझ करा", "create": "तयार करा", "set": "सेट करा", "folderPath": "फोल्डर साठवण्याचा मार्ग", "locationCannotBeEmpty": "मार्ग रिकामा असू शकत नाही", "pathCopiedSnackbar": "फाईल संचय मार्ग क्लिपबोर्डवर कॉपी केला!", "changeLocationTooltips": "डेटा डिरेक्टरी बदला", "change": "बदला", "openLocationTooltips": "इतर डेटा डिरेक्टरी उघडा", "openCurrentDataFolder": "सध्याची डेटा डिरेक्टरी उघडा", "recoverLocationTooltips": "@:appName च्या मूळ डेटा डिरेक्टरीवर रीसेट करा", "exportFileSuccess": "फाईल यशस्वीरित्या निर्यात झाली!", "exportFileFail": "फाईल निर्यात करण्यात अयशस्वी!", "export": "निर्यात करा", "clearCache": "कॅशे साफ करा", "clearCacheDesc": "प्रतिमा लोड होत नाहीत किंवा फॉन्ट अयोग्यरित्या दिसत असल्यास, कॅशे साफ करून पाहा. ही क्रिया तुमचा वापरकर्ता डेटा हटवणार नाही.", "areYouSureToClearCache": "तुम्हाला नक्की कॅशे साफ करायची आहे का?", "clearCacheSuccess": "कॅशे यशस्वीरित्या साफ झाली!" }, "user": { "name": "नाव", "email": "ईमेल", "tooltipSelectIcon": "चिन्ह निवडा", "selectAnIcon": "चिन्ह निवडा", "pleaseInputYourOpenAIKey": "कृपया तुमची AI की टाका", "clickToLogout": "सध्याचा वापरकर्ता लॉगआउट करण्यासाठी क्लिक करा" }, "mobile": { "personalInfo": "वैयक्तिक माहिती", "username": "वापरकर्तानाव", "usernameEmptyError": "वापरकर्तानाव रिकामे असू शकत नाही", "about": "विषयी", "pushNotifications": "पुश सूचना", "support": "सपोर्ट", "joinDiscord": "Discord मध्ये सहभागी व्हा", "privacyPolicy": "गोपनीयता धोरण", "userAgreement": "वापरकर्ता करार", "termsAndConditions": "अटी व शर्ती", "userprofileError": "वापरकर्ता प्रोफाइल लोड करण्यात अयशस्वी", "userprofileErrorDescription": "कृपया लॉगआउट करून पुन्हा लॉगिन करा आणि त्रुटी अजूनही येते का ते पहा.", "selectLayout": "लेआउट निवडा", "selectStartingDay": "सप्ताहाचा प्रारंभ दिवस निवडा", "version": "आवृत्ती" }, "grid": { "deleteView": "तुम्हाला हे दृश्य हटवायचे आहे का?", "createView": "नवीन", "title": { "placeholder": "नाव नाही" }, "settings": { "filter": "फिल्टर", "sort": "क्रमवारी", "sortBy": "यावरून क्रमवारी लावा", "properties": "गुणधर्म", "reorderPropertiesTooltip": "गुणधर्मांचे स्थान बदला", "group": "समूह", "addFilter": "फिल्टर जोडा", "deleteFilter": "फिल्टर हटवा", "filterBy": "यावरून फिल्टर करा", "typeAValue": "मूल्य लिहा...", "layout": "लेआउट", "compactMode": "कॉम्पॅक्ट मोड", "databaseLayout": "लेआउट", "viewList": { "zero": "० दृश्ये", "one": "{count} दृश्य", "other": "{count} दृश्ये" }, "editView": "दृश्य संपादित करा", "boardSettings": "बोर्ड सेटिंग", "calendarSettings": "कॅलेंडर सेटिंग", "createView": "नवीन दृश्य", "duplicateView": "दृश्याची प्रत बनवा", "deleteView": "दृश्य हटवा", "numberOfVisibleFields": "{} दर्शविले" }, "filter": { "empty": "कोणतेही सक्रिय फिल्टर नाहीत", "addFilter": "फिल्टर जोडा", "cannotFindCreatableField": "फिल्टर करण्यासाठी योग्य फील्ड सापडले नाही", "conditon": "अट", "where": "जिथे" }, "textFilter": { "contains": "अंतर्भूत आहे", "doesNotContain": "अंतर्भूत नाही", "endsWith": "याने समाप्त होते", "startWith": "याने सुरू होते", "is": "आहे", "isNot": "नाही", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही", "choicechipPrefix": { "isNot": "नाही", "startWith": "याने सुरू होते", "endWith": "याने समाप्त होते", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही" } }, "checkboxFilter": { "isChecked": "निवडलेले आहे", "isUnchecked": "निवडलेले नाही", "choicechipPrefix": { "is": "आहे" } }, "checklistFilter": { "isComplete": "पूर्ण झाले आहे", "isIncomplted": "अपूर्ण आहे" }, "selectOptionFilter": { "is": "आहे", "isNot": "नाही", "contains": "अंतर्भूत आहे", "doesNotContain": "अंतर्भूत नाही", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही" }, "dateFilter": { "is": "या दिवशी आहे", "before": "पूर्वी आहे", "after": "नंतर आहे", "onOrBefore": "या दिवशी किंवा त्याआधी आहे", "onOrAfter": "या दिवशी किंवा त्यानंतर आहे", "between": "दरम्यान आहे", "empty": "रिकामे आहे", "notEmpty": "रिकामे नाही", "startDate": "सुरुवातीची तारीख", "endDate": "शेवटची तारीख", "choicechipPrefix": { "before": "पूर्वी", "after": "नंतर", "between": "दरम्यान", "onOrBefore": "या दिवशी किंवा त्याआधी", "onOrAfter": "या दिवशी किंवा त्यानंतर", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही" } }, "numberFilter": { "equal": "बरोबर आहे", "notEqual": "बरोबर नाही", "lessThan": "पेक्षा कमी आहे", "greaterThan": "पेक्षा जास्त आहे", "lessThanOrEqualTo": "किंवा कमी आहे", "greaterThanOrEqualTo": "किंवा जास्त आहे", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही" }, "field": { "label": "गुणधर्म", "hide": "गुणधर्म लपवा", "show": "गुणधर्म दर्शवा", "insertLeft": "डावीकडे जोडा", "insertRight": "उजवीकडे जोडा", "duplicate": "प्रत बनवा", "delete": "हटवा", "wrapCellContent": "पाठ लपेटा", "clear": "सेल्स रिकामे करा", "switchPrimaryFieldTooltip": "प्राथमिक फील्डचा प्रकार बदलू शकत नाही", "textFieldName": "मजकूर", "checkboxFieldName": "चेकबॉक्स", "dateFieldName": "तारीख", "updatedAtFieldName": "शेवटचे अपडेट", "createdAtFieldName": "तयार झाले", "numberFieldName": "संख्या", "singleSelectFieldName": "सिंगल सिलेक्ट", "multiSelectFieldName": "मल्टीसिलेक्ट", "urlFieldName": "URL", "checklistFieldName": "चेकलिस्ट", "relationFieldName": "संबंध", "summaryFieldName": "AI सारांश", "timeFieldName": "वेळ", "mediaFieldName": "फाईल्स आणि मीडिया", "translateFieldName": "AI भाषांतर", "translateTo": "मध्ये भाषांतर करा", "numberFormat": "संख्या स्वरूप", "dateFormat": "तारीख स्वरूप", "includeTime": "वेळ जोडा", "isRange": "शेवटची तारीख", "dateFormatFriendly": "महिना दिवस, वर्ष", "dateFormatISO": "वर्ष-महिना-दिनांक", "dateFormatLocal": "महिना/दिवस/वर्ष", "dateFormatUS": "वर्ष/महिना/दिवस", "dateFormatDayMonthYear": "दिवस/महिना/वर्ष", "timeFormat": "वेळ स्वरूप", "invalidTimeFormat": "अवैध स्वरूप", "timeFormatTwelveHour": "१२ तास", "timeFormatTwentyFourHour": "२४ तास", "clearDate": "तारीख हटवा", "dateTime": "तारीख व वेळ", "startDateTime": "सुरुवातीची तारीख व वेळ", "endDateTime": "शेवटची तारीख व वेळ", "failedToLoadDate": "तारीख मूल्य लोड करण्यात अयशस्वी", "selectTime": "वेळ निवडा", "selectDate": "तारीख निवडा", "visibility": "दृश्यता", "propertyType": "गुणधर्माचा प्रकार", "addSelectOption": "पर्याय जोडा", "typeANewOption": "नवीन पर्याय लिहा", "optionTitle": "पर्याय", "addOption": "पर्याय जोडा", "editProperty": "गुणधर्म संपादित करा", "newProperty": "नवीन गुणधर्म", "openRowDocument": "पृष्ठ म्हणून उघडा", "deleteFieldPromptMessage": "तुम्हाला खात्री आहे का? हा गुणधर्म आणि त्याचा डेटा हटवला जाईल", "clearFieldPromptMessage": "तुम्हाला खात्री आहे का? या कॉलममधील सर्व सेल्स रिकामे होतील", "newColumn": "नवीन कॉलम", "format": "स्वरूप", "reminderOnDateTooltip": "या सेलमध्ये अनुस्मारक शेड्यूल केले आहे", "optionAlreadyExist": "पर्याय आधीच अस्तित्वात आहे" }, "rowPage": { "newField": "नवीन फील्ड जोडा", "fieldDragElementTooltip": "मेनू उघडण्यासाठी क्लिक करा", "showHiddenFields": { "one": "{count} लपलेले फील्ड दाखवा", "many": "{count} लपलेली फील्ड दाखवा", "other": "{count} लपलेली फील्ड दाखवा" }, "hideHiddenFields": { "one": "{count} लपलेले फील्ड लपवा", "many": "{count} लपलेली फील्ड लपवा", "other": "{count} लपलेली फील्ड लपवा" }, "openAsFullPage": "पूर्ण पृष्ठ म्हणून उघडा", "moreRowActions": "अधिक पंक्ती क्रिया" }, "sort": { "ascending": "चढत्या क्रमाने", "descending": "उतरत्या क्रमाने", "by": "द्वारे", "empty": "सक्रिय सॉर्ट्स नाहीत", "cannotFindCreatableField": "सॉर्टसाठी योग्य फील्ड सापडले नाही", "deleteAllSorts": "सर्व सॉर्ट्स हटवा", "addSort": "सॉर्ट जोडा", "sortsActive": "सॉर्टिंग करत असताना {intention} करू शकत नाही", "removeSorting": "या दृश्यातील सर्व सॉर्ट्स हटवायचे आहेत का?", "fieldInUse": "या फील्डवर आधीच सॉर्टिंग चालू आहे" }, "row": { "label": "पंक्ती", "duplicate": "प्रत बनवा", "delete": "हटवा", "titlePlaceholder": "शीर्षक नाही", "textPlaceholder": "रिक्त", "copyProperty": "गुणधर्म क्लिपबोर्डवर कॉपी केला", "count": "संख्या", "newRow": "नवीन पंक्ती", "loadMore": "अधिक लोड करा", "action": "क्रिया", "add": "खाली जोडा वर क्लिक करा", "drag": "हलवण्यासाठी ड्रॅग करा", "deleteRowPrompt": "तुम्हाला खात्री आहे का की ही पंक्ती हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", "deleteCardPrompt": "तुम्हाला खात्री आहे का की हे कार्ड हटवायचे आहे? ही क्रिया पूर्ववत करता येणार नाही.", "dragAndClick": "हलवण्यासाठी ड्रॅग करा, मेनू उघडण्यासाठी क्लिक करा", "insertRecordAbove": "वर रेकॉर्ड जोडा", "insertRecordBelow": "खाली रेकॉर्ड जोडा", "noContent": "माहिती नाही", "reorderRowDescription": "पंक्तीचे पुन्हा क्रमांकन", "createRowAboveDescription": "वर पंक्ती तयार करा", "createRowBelowDescription": "खाली पंक्ती जोडा" }, "selectOption": { "create": "तयार करा", "purpleColor": "जांभळा", "pinkColor": "गुलाबी", "lightPinkColor": "फिकट गुलाबी", "orangeColor": "नारंगी", "yellowColor": "पिवळा", "limeColor": "लिंबू", "greenColor": "हिरवा", "aquaColor": "आक्वा", "blueColor": "निळा", "deleteTag": "टॅग हटवा", "colorPanelTitle": "रंग", "panelTitle": "पर्याय निवडा किंवा नवीन तयार करा", "searchOption": "पर्याय शोधा", "searchOrCreateOption": "पर्याय शोधा किंवा तयार करा", "createNew": "नवीन तयार करा", "orSelectOne": "किंवा पर्याय निवडा", "typeANewOption": "नवीन पर्याय टाइप करा", "tagName": "टॅग नाव" }, "checklist": { "taskHint": "कार्याचे वर्णन", "addNew": "नवीन कार्य जोडा", "submitNewTask": "तयार करा", "hideComplete": "पूर्ण कार्ये लपवा", "showComplete": "सर्व कार्ये दाखवा" }, "url": { "launch": "बाह्य ब्राउझरमध्ये लिंक उघडा", "copy": "लिंक क्लिपबोर्डवर कॉपी करा", "textFieldHint": "URL टाका", "copiedNotification": "क्लिपबोर्डवर कॉपी केले!" }, "relation": { "relatedDatabasePlaceLabel": "संबंधित डेटाबेस", "relatedDatabasePlaceholder": "काही नाही", "inRelatedDatabase": "या मध्ये", "rowSearchTextFieldPlaceholder": "शोध", "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया खालील यादीतून एक निवडा:", "emptySearchResult": "कोणतीही नोंद सापडली नाही", "linkedRowListLabel": "{count} लिंक केलेल्या पंक्ती", "unlinkedRowListLabel": "आणखी एक पंक्ती लिंक करा" }, "menuName": "ग्रिड", "referencedGridPrefix": "दृश्य", "calculate": "गणना करा", "calculationTypeLabel": { "none": "काही नाही", "average": "सरासरी", "max": "कमाल", "median": "मध्यम", "min": "किमान", "sum": "बेरीज", "count": "मोजणी", "countEmpty": "रिकाम्यांची मोजणी", "countEmptyShort": "रिक्त", "countNonEmpty": "रिक्त नसलेल्यांची मोजणी", "countNonEmptyShort": "भरलेले" }, "media": { "rename": "पुन्हा नाव द्या", "download": "डाउनलोड करा", "expand": "मोठे करा", "delete": "हटवा", "moreFilesHint": "+{}", "addFileOrImage": "फाईल किंवा लिंक जोडा", "attachmentsHint": "{}", "addFileMobile": "फाईल जोडा", "extraCount": "+{}", "deleteFileDescription": "तुम्हाला खात्री आहे का की ही फाईल हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", "showFileNames": "फाईलचे नाव दाखवा", "downloadSuccess": "फाईल डाउनलोड झाली", "downloadFailedToken": "फाईल डाउनलोड अयशस्वी, वापरकर्ता टोकन अनुपलब्ध", "setAsCover": "कव्हर म्हणून सेट करा", "openInBrowser": "ब्राउझरमध्ये उघडा", "embedLink": "फाईल लिंक एम्बेड करा" } }, "document": { "menuName": "दस्तऐवज", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "तयार करत आहे...", "slashMenu": { "board": { "selectABoardToLinkTo": "लिंक करण्यासाठी बोर्ड निवडा", "createANewBoard": "नवीन बोर्ड तयार करा" }, "grid": { "selectAGridToLinkTo": "लिंक करण्यासाठी ग्रिड निवडा", "createANewGrid": "नवीन ग्रिड तयार करा" }, "calendar": { "selectACalendarToLinkTo": "लिंक करण्यासाठी दिनदर्शिका निवडा", "createANewCalendar": "नवीन दिनदर्शिका तयार करा" }, "document": { "selectADocumentToLinkTo": "लिंक करण्यासाठी दस्तऐवज निवडा" }, "name": { "textStyle": "मजकुराची शैली", "list": "यादी", "toggle": "टॉगल", "fileAndMedia": "फाईल व मीडिया", "simpleTable": "सोपे टेबल", "visuals": "दृश्य घटक", "document": "दस्तऐवज", "advanced": "प्रगत", "text": "मजकूर", "heading1": "शीर्षक 1", "heading2": "शीर्षक 2", "heading3": "शीर्षक 3", "image": "प्रतिमा", "bulletedList": "बुलेट यादी", "numberedList": "क्रमांकित यादी", "todoList": "करण्याची यादी", "doc": "दस्तऐवज", "linkedDoc": "पृष्ठाशी लिंक करा", "grid": "ग्रिड", "linkedGrid": "लिंक केलेला ग्रिड", "kanban": "कानबन", "linkedKanban": "लिंक केलेला कानबन", "calendar": "दिनदर्शिका", "linkedCalendar": "लिंक केलेली दिनदर्शिका", "quote": "उद्धरण", "divider": "विभाजक", "table": "टेबल", "callout": "महत्त्वाचा मजकूर", "outline": "रूपरेषा", "mathEquation": "गणिती समीकरण", "code": "कोड", "toggleList": "टॉगल यादी", "toggleHeading1": "टॉगल शीर्षक 1", "toggleHeading2": "टॉगल शीर्षक 2", "toggleHeading3": "टॉगल शीर्षक 3", "emoji": "इमोजी", "aiWriter": "AI ला काहीही विचारा", "dateOrReminder": "दिनांक किंवा स्मरणपत्र", "photoGallery": "फोटो गॅलरी", "file": "फाईल", "twoColumns": "२ स्तंभ", "threeColumns": "३ स्तंभ", "fourColumns": "४ स्तंभ" }, "subPage": { "name": "दस्तऐवज", "keyword1": "उपपृष्ठ", "keyword2": "पृष्ठ", "keyword3": "चाइल्ड पृष्ठ", "keyword4": "पृष्ठ जोडा", "keyword5": "एम्बेड पृष्ठ", "keyword6": "नवीन पृष्ठ", "keyword7": "पृष्ठ तयार करा", "keyword8": "दस्तऐवज" } }, "selectionMenu": { "outline": "रूपरेषा", "codeBlock": "कोड ब्लॉक" }, "plugins": { "referencedBoard": "संदर्भित बोर्ड", "referencedGrid": "संदर्भित ग्रिड", "referencedCalendar": "संदर्भित दिनदर्शिका", "referencedDocument": "संदर्भित दस्तऐवज", "aiWriter": { "userQuestion": "AI ला काहीही विचारा", "continueWriting": "लेखन सुरू ठेवा", "fixSpelling": "स्पेलिंग व व्याकरण सुधारणा", "improveWriting": "लेखन सुधारित करा", "summarize": "सारांश द्या", "explain": "स्पष्टीकरण द्या", "makeShorter": "लहान करा", "makeLonger": "मोठे करा" }, "autoGeneratorMenuItemName": "AI लेखक", "autoGeneratorTitleName": "AI: काहीही लिहिण्यासाठी AI ला विचारा...", "autoGeneratorLearnMore": "अधिक जाणून घ्या", "autoGeneratorGenerate": "उत्पन्न करा", "autoGeneratorHintText": "AI ला विचारा...", "autoGeneratorCantGetOpenAIKey": "AI की मिळवू शकलो नाही", "autoGeneratorRewrite": "पुन्हा लिहा", "smartEdit": "AI ला विचारा", "aI": "AI", "smartEditFixSpelling": "स्पेलिंग आणि व्याकरण सुधारा", "warning": "⚠️ AI उत्तरं चुकीची किंवा दिशाभूल करणारी असू शकतात.", "smartEditSummarize": "सारांश द्या", "smartEditImproveWriting": "लेखन सुधारित करा", "smartEditMakeLonger": "लांब करा", "smartEditCouldNotFetchResult": "AI कडून उत्तर मिळवता आले नाही", "smartEditCouldNotFetchKey": "AI की मिळवता आली नाही", "smartEditDisabled": "सेटिंग्जमध्ये AI कनेक्ट करा", "appflowyAIEditDisabled": "AI वैशिष्ट्ये सक्षम करण्यासाठी साइन इन करा", "discardResponse": "AI उत्तर फेकून द्यायचं आहे का?", "createInlineMathEquation": "समीकरण तयार करा", "fonts": "फॉन्ट्स", "insertDate": "तारीख जोडा", "emoji": "इमोजी", "toggleList": "टॉगल यादी", "emptyToggleHeading": "रिकामे टॉगल h{}. मजकूर जोडण्यासाठी क्लिक करा.", "emptyToggleList": "रिकामी टॉगल यादी. मजकूर जोडण्यासाठी क्लिक करा.", "emptyToggleHeadingWeb": "रिकामे टॉगल h{level}. मजकूर जोडण्यासाठी क्लिक करा", "quoteList": "उद्धरण यादी", "numberedList": "क्रमांकित यादी", "bulletedList": "बुलेट यादी", "todoList": "करण्याची यादी", "callout": "ठळक मजकूर", "simpleTable": { "moreActions": { "color": "रंग", "align": "पंक्तिबद्ध करा", "delete": "हटा", "duplicate": "डुप्लिकेट करा", "insertLeft": "डावीकडे घाला", "insertRight": "उजवीकडे घाला", "insertAbove": "वर घाला", "insertBelow": "खाली घाला", "headerColumn": "हेडर स्तंभ", "headerRow": "हेडर ओळ", "clearContents": "सामग्री साफ करा", "setToPageWidth": "पृष्ठाच्या रुंदीप्रमाणे सेट करा", "distributeColumnsWidth": "स्तंभ समान करा", "duplicateRow": "ओळ डुप्लिकेट करा", "duplicateColumn": "स्तंभ डुप्लिकेट करा", "textColor": "मजकूराचा रंग", "cellBackgroundColor": "सेलचा पार्श्वभूमी रंग", "duplicateTable": "टेबल डुप्लिकेट करा" }, "clickToAddNewRow": "नवीन ओळ जोडण्यासाठी क्लिक करा", "clickToAddNewColumn": "नवीन स्तंभ जोडण्यासाठी क्लिक करा", "clickToAddNewRowAndColumn": "नवीन ओळ आणि स्तंभ जोडण्यासाठी क्लिक करा", "headerName": { "table": "टेबल", "alignText": "मजकूर पंक्तिबद्ध करा" } }, "cover": { "changeCover": "कव्हर बदला", "colors": "रंग", "images": "प्रतिमा", "clearAll": "सर्व साफ करा", "abstract": "ऍबस्ट्रॅक्ट", "addCover": "कव्हर जोडा", "addLocalImage": "स्थानिक प्रतिमा जोडा", "invalidImageUrl": "अवैध प्रतिमा URL", "failedToAddImageToGallery": "प्रतिमा गॅलरीत जोडता आली नाही", "enterImageUrl": "प्रतिमा URL लिहा", "add": "जोडा", "back": "मागे", "saveToGallery": "गॅलरीत जतन करा", "removeIcon": "आयकॉन काढा", "removeCover": "कव्हर काढा", "pasteImageUrl": "प्रतिमा URL पेस्ट करा", "or": "किंवा", "pickFromFiles": "फाईल्समधून निवडा", "couldNotFetchImage": "प्रतिमा मिळवता आली नाही", "imageSavingFailed": "प्रतिमा जतन करणे अयशस्वी", "addIcon": "आयकॉन जोडा", "changeIcon": "आयकॉन बदला", "coverRemoveAlert": "हे हटवल्यानंतर कव्हरमधून काढले जाईल.", "alertDialogConfirmation": "तुम्हाला खात्री आहे का? तुम्हाला पुढे जायचे आहे?" }, "mathEquation": { "name": "गणिती समीकरण", "addMathEquation": "TeX समीकरण जोडा", "editMathEquation": "गणिती समीकरण संपादित करा" }, "optionAction": { "click": "क्लिक", "toOpenMenu": "मेनू उघडण्यासाठी", "drag": "ओढा", "toMove": "हलवण्यासाठी", "delete": "हटा", "duplicate": "डुप्लिकेट करा", "turnInto": "मध्ये बदला", "moveUp": "वर हलवा", "moveDown": "खाली हलवा", "color": "रंग", "align": "पंक्तिबद्ध करा", "left": "डावीकडे", "center": "मध्यभागी", "right": "उजवीकडे", "defaultColor": "डिफॉल्ट", "depth": "खोली", "copyLinkToBlock": "ब्लॉकसाठी लिंक कॉपी करा" }, "image": { "addAnImage": "प्रतिमा जोडा", "copiedToPasteBoard": "प्रतिमेची लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", "addAnImageDesktop": "प्रतिमा जोडा", "addAnImageMobile": "एक किंवा अधिक प्रतिमा जोडण्यासाठी क्लिक करा", "dropImageToInsert": "प्रतिमा ड्रॉप करून जोडा", "imageUploadFailed": "प्रतिमा अपलोड करण्यात अयशस्वी", "imageDownloadFailed": "प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", "imageDownloadFailedToken": "युजर टोकन नसल्यामुळे प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", "errorCode": "त्रुटी कोड" }, "photoGallery": { "name": "फोटो गॅलरी", "imageKeyword": "प्रतिमा", "imageGalleryKeyword": "प्रतिमा गॅलरी", "photoKeyword": "फोटो", "photoBrowserKeyword": "फोटो ब्राउझर", "galleryKeyword": "गॅलरी", "addImageTooltip": "प्रतिमा जोडा", "changeLayoutTooltip": "लेआउट बदला", "browserLayout": "ब्राउझर", "gridLayout": "ग्रिड", "deleteBlockTooltip": "संपूर्ण गॅलरी हटवा" }, "math": { "copiedToPasteBoard": "समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे" }, "urlPreview": { "copiedToPasteBoard": "लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", "convertToLink": "एंबेड लिंकमध्ये रूपांतर करा" }, "outline": { "addHeadingToCreateOutline": "सामग्री यादी तयार करण्यासाठी शीर्षके जोडा.", "noMatchHeadings": "जुळणारी शीर्षके आढळली नाहीत." }, "table": { "addAfter": "नंतर जोडा", "addBefore": "आधी जोडा", "delete": "हटा", "clear": "सामग्री साफ करा", "duplicate": "डुप्लिकेट करा", "bgColor": "पार्श्वभूमीचा रंग" }, "contextMenu": { "copy": "कॉपी करा", "cut": "कापा", "paste": "पेस्ट करा", "pasteAsPlainText": "साध्या मजकूराच्या स्वरूपात पेस्ट करा" }, "action": "कृती", "database": { "selectDataSource": "डेटा स्रोत निवडा", "noDataSource": "डेटा स्रोत नाही", "selectADataSource": "डेटा स्रोत निवडा", "toContinue": "पुढे जाण्यासाठी", "newDatabase": "नवीन डेटाबेस", "linkToDatabase": "डेटाबेसशी लिंक करा" }, "date": "तारीख", "video": { "label": "व्हिडिओ", "emptyLabel": "व्हिडिओ जोडा", "placeholder": "व्हिडिओ लिंक पेस्ट करा", "copiedToPasteBoard": "व्हिडिओ लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", "insertVideo": "व्हिडिओ जोडा", "invalidVideoUrl": "ही URL सध्या समर्थित नाही.", "invalidVideoUrlYouTube": "YouTube सध्या समर्थित नाही.", "supportedFormats": "समर्थित स्वरूप: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "फाईल", "uploadTab": "अपलोड", "uploadMobile": "फाईल निवडा", "uploadMobileGallery": "फोटो गॅलरीमधून", "networkTab": "लिंक एम्बेड करा", "placeholderText": "फाईल अपलोड किंवा एम्बेड करा", "placeholderDragging": "फाईल ड्रॉप करून अपलोड करा", "dropFileToUpload": "फाईल ड्रॉप करून अपलोड करा", "fileUploadHint": "फाईल ड्रॅग आणि ड्रॉप करा किंवा क्लिक करा ", "fileUploadHintSuffix": "ब्राउझ करा", "networkHint": "फाईल लिंक पेस्ट करा", "networkUrlInvalid": "अवैध URL. कृपया तपासा आणि पुन्हा प्रयत्न करा.", "networkAction": "एम्बेड", "fileTooBigError": "फाईल खूप मोठी आहे, कृपया 10MB पेक्षा कमी फाईल अपलोड करा", "renameFile": { "title": "फाईलचे नाव बदला", "description": "या फाईलसाठी नवीन नाव लिहा", "nameEmptyError": "फाईलचे नाव रिकामे असू शकत नाही." }, "uploadedAt": "{} रोजी अपलोड केले", "linkedAt": "{} रोजी लिंक जोडली", "failedToOpenMsg": "उघडण्यात अयशस्वी, फाईल सापडली नाही" }, "subPage": { "handlingPasteHint": " - (पेस्ट प्रक्रिया करत आहे)", "errors": { "failedDeletePage": "पृष्ठ हटवण्यात अयशस्वी", "failedCreatePage": "पृष्ठ तयार करण्यात अयशस्वी", "failedMovePage": "हे पृष्ठ हलवण्यात अयशस्वी", "failedDuplicatePage": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी", "failedDuplicateFindView": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी - मूळ दृश्य सापडले नाही" } }, "cannotMoveToItsChildren": "मुलांमध्ये हलवू शकत नाही" }, "outlineBlock": { "placeholder": "सामग्री सूची" }, "textBlock": { "placeholder": "कमांडसाठी '/' टाइप करा" }, "title": { "placeholder": "शीर्षक नाही" }, "imageBlock": { "placeholder": "प्रतिमा जोडण्यासाठी क्लिक करा", "upload": { "label": "अपलोड", "placeholder": "प्रतिमा अपलोड करण्यासाठी क्लिक करा" }, "url": { "label": "प्रतिमेची URL", "placeholder": "प्रतिमेची URL टाका" }, "ai": { "label": "AI द्वारे प्रतिमा तयार करा", "placeholder": "AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" }, "stability_ai": { "label": "Stability AI द्वारे प्रतिमा तयार करा", "placeholder": "Stability AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" }, "support": "प्रतिमेचा कमाल आकार 5MB आहे. समर्थित स्वरूप: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "अवैध प्रतिमा", "invalidImageSize": "प्रतिमेचा आकार 5MB पेक्षा कमी असावा", "invalidImageFormat": "प्रतिमेचे स्वरूप समर्थित नाही. समर्थित स्वरूप: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "अवैध प्रतिमेची URL", "noImage": "अशी फाईल किंवा निर्देशिका नाही", "multipleImagesFailed": "एक किंवा अधिक प्रतिमा अपलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा" }, "embedLink": { "label": "लिंक एम्बेड करा", "placeholder": "प्रतिमेची लिंक पेस्ट करा किंवा टाका" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "प्रतिमा शोधा", "pleaseInputYourOpenAIKey": "कृपया सेटिंग्ज पृष्ठात आपला AI की प्रविष्ट करा", "saveImageToGallery": "प्रतिमा जतन करा", "failedToAddImageToGallery": "प्रतिमा जतन करण्यात अयशस्वी", "successToAddImageToGallery": "प्रतिमा 'Photos' मध्ये जतन केली", "unableToLoadImage": "प्रतिमा लोड करण्यात अयशस्वी", "maximumImageSize": "कमाल प्रतिमा अपलोड आकार 10MB आहे", "uploadImageErrorImageSizeTooBig": "प्रतिमेचा आकार 10MB पेक्षा कमी असावा", "imageIsUploading": "प्रतिमा अपलोड होत आहे", "openFullScreen": "पूर्ण स्क्रीनमध्ये उघडा", "interactiveViewer": { "toolbar": { "previousImageTooltip": "मागील प्रतिमा", "nextImageTooltip": "पुढील प्रतिमा", "zoomOutTooltip": "लहान करा", "zoomInTooltip": "मोठी करा", "changeZoomLevelTooltip": "झूम पातळी बदला", "openLocalImage": "प्रतिमा उघडा", "downloadImage": "प्रतिमा डाउनलोड करा", "closeViewer": "इंटरअॅक्टिव्ह व्ह्युअर बंद करा", "scalePercentage": "{}%", "deleteImageTooltip": "प्रतिमा हटवा" } } }, "codeBlock": { "language": { "label": "भाषा", "placeholder": "भाषा निवडा", "auto": "स्वयंचलित" }, "copyTooltip": "कॉपी करा", "searchLanguageHint": "भाषा शोधा", "codeCopiedSnackbar": "कोड क्लिपबोर्डवर कॉपी झाला!" }, "inlineLink": { "placeholder": "लिंक पेस्ट करा किंवा टाका", "openInNewTab": "नवीन टॅबमध्ये उघडा", "copyLink": "लिंक कॉपी करा", "removeLink": "लिंक काढा", "url": { "label": "लिंक URL", "placeholder": "लिंक URL टाका" }, "title": { "label": "लिंक शीर्षक", "placeholder": "लिंक शीर्षक टाका" } }, "mention": { "placeholder": "कोणीतरी, पृष्ठ किंवा तारीख नमूद करा...", "page": { "label": "पृष्ठाला लिंक करा", "tooltip": "पृष्ठ उघडण्यासाठी क्लिक करा" }, "deleted": "हटवले गेले", "deletedContent": "ही सामग्री अस्तित्वात नाही किंवा हटवण्यात आली आहे", "noAccess": "प्रवेश नाही", "deletedPage": "हटवलेले पृष्ठ", "trashHint": " - ट्रॅशमध्ये", "morePages": "अजून पृष्ठे" }, "toolbar": { "resetToDefaultFont": "डीफॉल्ट फॉन्टवर परत जा", "textSize": "मजकूराचा आकार", "textColor": "मजकूराचा रंग", "h1": "मथळा 1", "h2": "मथळा 2", "h3": "मथळा 3", "alignLeft": "डावीकडे संरेखित करा", "alignRight": "उजवीकडे संरेखित करा", "alignCenter": "मध्यभागी संरेखित करा", "link": "लिंक", "textAlign": "मजकूर संरेखन", "moreOptions": "अधिक पर्याय", "font": "फॉन्ट", "inlineCode": "इनलाइन कोड", "suggestions": "सूचना", "turnInto": "मध्ये रूपांतरित करा", "equation": "समीकरण", "insert": "घाला", "linkInputHint": "लिंक पेस्ट करा किंवा पृष्ठे शोधा", "pageOrURL": "पृष्ठ किंवा URL", "linkName": "लिंकचे नाव", "linkNameHint": "लिंकचे नाव प्रविष्ट करा" }, "errorBlock": { "theBlockIsNotSupported": "ब्लॉक सामग्री पार्स करण्यात अक्षम", "clickToCopyTheBlockContent": "ब्लॉक सामग्री कॉपी करण्यासाठी क्लिक करा", "blockContentHasBeenCopied": "ब्लॉक सामग्री कॉपी केली आहे.", "parseError": "{} ब्लॉक पार्स करताना त्रुटी आली.", "copyBlockContent": "ब्लॉक सामग्री कॉपी करा" }, "mobilePageSelector": { "title": "पृष्ठ निवडा", "failedToLoad": "पृष्ठ यादी लोड करण्यात अयशस्वी", "noPagesFound": "कोणतीही पृष्ठे सापडली नाहीत" }, "attachmentMenu": { "choosePhoto": "फोटो निवडा", "takePicture": "फोटो काढा", "chooseFile": "फाईल निवडा" } }, "board": { "column": { "label": "स्तंभ", "createNewCard": "नवीन", "renameGroupTooltip": "गटाचे नाव बदलण्यासाठी क्लिक करा", "createNewColumn": "नवीन गट जोडा", "addToColumnTopTooltip": "वर नवीन कार्ड जोडा", "addToColumnBottomTooltip": "खाली नवीन कार्ड जोडा", "renameColumn": "स्तंभाचे नाव बदला", "hideColumn": "लपवा", "newGroup": "नवीन गट", "deleteColumn": "हटवा", "deleteColumnConfirmation": "हा गट आणि त्यामधील सर्व कार्ड्स हटवले जातील. तुम्हाला खात्री आहे का?" }, "hiddenGroupSection": { "sectionTitle": "लपवलेले गट", "collapseTooltip": "लपवलेले गट लपवा", "expandTooltip": "लपवलेले गट पाहा" }, "cardDetail": "कार्ड तपशील", "cardActions": "कार्ड क्रिया", "cardDuplicated": "कार्डची प्रत तयार झाली", "cardDeleted": "कार्ड हटवले गेले", "showOnCard": "कार्ड तपशिलावर दाखवा", "setting": "सेटिंग", "propertyName": "गुणधर्माचे नाव", "menuName": "बोर्ड", "showUngrouped": "गटात नसलेली कार्ड्स दाखवा", "ungroupedButtonText": "गट नसलेली", "ungroupedButtonTooltip": "ज्या कार्ड्स कोणत्याही गटात नाहीत", "ungroupedItemsTitle": "बोर्डमध्ये जोडण्यासाठी क्लिक करा", "groupBy": "या आधारावर गट करा", "groupCondition": "गट स्थिती", "referencedBoardPrefix": "याचे दृश्य", "notesTooltip": "नोट्स आहेत", "mobile": { "editURL": "URL संपादित करा", "showGroup": "गट दाखवा", "showGroupContent": "हा गट बोर्डवर दाखवायचा आहे का?", "failedToLoad": "बोर्ड दृश्य लोड होण्यात अयशस्वी" }, "dateCondition": { "weekOf": "{} - {} ची आठवडा", "today": "आज", "yesterday": "काल", "tomorrow": "उद्या", "lastSevenDays": "शेवटचे ७ दिवस", "nextSevenDays": "पुढील ७ दिवस", "lastThirtyDays": "शेवटचे ३० दिवस", "nextThirtyDays": "पुढील ३० दिवस" }, "noGroup": "गटासाठी कोणताही गुणधर्म निवडलेला नाही", "noGroupDesc": "बोर्ड दृश्य दाखवण्यासाठी गट करण्याचा एक गुणधर्म आवश्यक आहे", "media": { "cardText": "{} {}", "fallbackName": "फायली" } }, "calendar": { "menuName": "कॅलेंडर", "defaultNewCalendarTitle": "नाव नाही", "newEventButtonTooltip": "नवीन इव्हेंट जोडा", "navigation": { "today": "आज", "jumpToday": "आजवर जा", "previousMonth": "मागील महिना", "nextMonth": "पुढील महिना", "views": { "day": "दिवस", "week": "आठवडा", "month": "महिना", "year": "वर्ष" } }, "mobileEventScreen": { "emptyTitle": "सध्या कोणतेही इव्हेंट नाहीत", "emptyBody": "या दिवशी इव्हेंट तयार करण्यासाठी प्लस बटणावर क्लिक करा." }, "settings": { "showWeekNumbers": "आठवड्याचे क्रमांक दाखवा", "showWeekends": "सप्ताहांत दाखवा", "firstDayOfWeek": "आठवड्याची सुरुवात", "layoutDateField": "कॅलेंडर मांडणी दिनांकानुसार", "changeLayoutDateField": "मांडणी फील्ड बदला", "noDateTitle": "तारीख नाही", "noDateHint": { "zero": "नियोजित नसलेली इव्हेंट्स येथे दिसतील", "one": "{count} नियोजित नसलेली इव्हेंट", "other": "{count} नियोजित नसलेल्या इव्हेंट्स" }, "unscheduledEventsTitle": "नियोजित नसलेल्या इव्हेंट्स", "clickToAdd": "कॅलेंडरमध्ये जोडण्यासाठी क्लिक करा", "name": "कॅलेंडर सेटिंग्ज", "clickToOpen": "रेकॉर्ड उघडण्यासाठी क्लिक करा" }, "referencedCalendarPrefix": "याचे दृश्य", "quickJumpYear": "या वर्षावर जा", "duplicateEvent": "इव्हेंट डुप्लिकेट करा" }, "errorDialog": { "title": "@:appName त्रुटी", "howToFixFallback": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया GitHub पेजवर त्रुटीबद्दल माहिती देणारे एक issue सबमिट करा.", "howToFixFallbackHint1": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया ", "howToFixFallbackHint2": " पेजवर त्रुटीचे वर्णन करणारे issue सबमिट करा.", "github": "GitHub वर पहा" }, "search": { "label": "शोध", "sidebarSearchIcon": "पृष्ठ शोधा आणि पटकन जा", "placeholder": { "actions": "कृती शोधा..." } }, "message": { "copy": { "success": "कॉपी झाले!", "fail": "कॉपी करू शकत नाही" } }, "unSupportBlock": "सध्याचा व्हर्जन या ब्लॉकला समर्थन देत नाही.", "views": { "deleteContentTitle": "तुम्हाला हे {pageType} हटवायचे आहे का?", "deleteContentCaption": "हे {pageType} हटवल्यास, तुम्ही ते trash मधून पुनर्संचयित करू शकता." }, "colors": { "custom": "सानुकूल", "default": "डीफॉल्ट", "red": "लाल", "orange": "संत्रा", "yellow": "पिवळा", "green": "हिरवा", "blue": "निळा", "purple": "जांभळा", "pink": "गुलाबी", "brown": "तपकिरी", "gray": "करड्या रंगाचा" }, "emoji": { "emojiTab": "इमोजी", "search": "इमोजी शोधा", "noRecent": "अलीकडील कोणतेही इमोजी नाहीत", "noEmojiFound": "कोणतेही इमोजी सापडले नाहीत", "filter": "फिल्टर", "random": "योगायोगाने", "selectSkinTone": "त्वचेचा टोन निवडा", "remove": "इमोजी काढा", "categories": { "smileys": "स्मायली आणि भावना", "people": "लोक", "animals": "प्राणी आणि निसर्ग", "food": "अन्न", "activities": "क्रिया", "places": "स्थळे", "objects": "वस्तू", "symbols": "चिन्हे", "flags": "ध्वज", "nature": "निसर्ग", "frequentlyUsed": "नेहमी वापरलेले" }, "skinTone": { "default": "डीफॉल्ट", "light": "हलका", "mediumLight": "मध्यम-हलका", "medium": "मध्यम", "mediumDark": "मध्यम-गडद", "dark": "गडद" }, "openSourceIconsFrom": "खुल्या स्रोताचे आयकॉन्स" }, "inlineActions": { "noResults": "निकाल नाही", "recentPages": "अलीकडील पृष्ठे", "pageReference": "पृष्ठ संदर्भ", "docReference": "दस्तऐवज संदर्भ", "boardReference": "बोर्ड संदर्भ", "calReference": "कॅलेंडर संदर्भ", "gridReference": "ग्रिड संदर्भ", "date": "तारीख", "reminder": { "groupTitle": "स्मरणपत्र", "shortKeyword": "remind" }, "createPage": "\"{}\" उप-पृष्ठ तयार करा" }, "datePicker": { "dateTimeFormatTooltip": "सेटिंग्जमध्ये तारीख आणि वेळ फॉरमॅट बदला", "dateFormat": "तारीख फॉरमॅट", "includeTime": "वेळ समाविष्ट करा", "isRange": "शेवटची तारीख", "timeFormat": "वेळ फॉरमॅट", "clearDate": "तारीख साफ करा", "reminderLabel": "स्मरणपत्र", "selectReminder": "स्मरणपत्र निवडा", "reminderOptions": { "none": "काहीही नाही", "atTimeOfEvent": "इव्हेंटच्या वेळी", "fiveMinsBefore": "५ मिनिटे आधी", "tenMinsBefore": "१० मिनिटे आधी", "fifteenMinsBefore": "१५ मिनिटे आधी", "thirtyMinsBefore": "३० मिनिटे आधी", "oneHourBefore": "१ तास आधी", "twoHoursBefore": "२ तास आधी", "onDayOfEvent": "इव्हेंटच्या दिवशी", "oneDayBefore": "१ दिवस आधी", "twoDaysBefore": "२ दिवस आधी", "oneWeekBefore": "१ आठवडा आधी", "custom": "सानुकूल" } }, "relativeDates": { "yesterday": "काल", "today": "आज", "tomorrow": "उद्या", "oneWeek": "१ आठवडा" }, "notificationHub": { "title": "सूचना", "mobile": { "title": "अपडेट्स" }, "emptyTitle": "सर्व पूर्ण झाले!", "emptyBody": "कोणतीही प्रलंबित सूचना किंवा कृती नाहीत. शांततेचा आनंद घ्या.", "tabs": { "inbox": "इनबॉक्स", "upcoming": "आगामी" }, "actions": { "markAllRead": "सर्व वाचलेल्या म्हणून चिन्हित करा", "showAll": "सर्व", "showUnreads": "न वाचलेल्या" }, "filters": { "ascending": "आरोही", "descending": "अवरोही", "groupByDate": "तारीखेनुसार गटबद्ध करा", "showUnreadsOnly": "फक्त न वाचलेल्या दाखवा", "resetToDefault": "डीफॉल्टवर रीसेट करा" } }, "reminderNotification": { "title": "स्मरणपत्र", "message": "तुम्ही विसरण्याआधी हे तपासण्याचे लक्षात ठेवा!", "tooltipDelete": "हटवा", "tooltipMarkRead": "वाचले म्हणून चिन्हित करा", "tooltipMarkUnread": "न वाचले म्हणून चिन्हित करा" }, "findAndReplace": { "find": "शोधा", "previousMatch": "मागील जुळणारे", "nextMatch": "पुढील जुळणारे", "close": "बंद करा", "replace": "बदला", "replaceAll": "सर्व बदला", "noResult": "कोणतेही निकाल नाहीत", "caseSensitive": "केस सेंसिटिव्ह", "searchMore": "अधिक निकालांसाठी शोधा" }, "error": { "weAreSorry": "आम्ही क्षमस्व आहोत", "loadingViewError": "हे दृश्य लोड करण्यात अडचण येत आहे. कृपया तुमचे इंटरनेट कनेक्शन तपासा, अ‍ॅप रीफ्रेश करा, आणि समस्या कायम असल्यास आमच्याशी संपर्क साधा.", "syncError": "इतर डिव्हाइसमधून डेटा सिंक झाला नाही", "syncErrorHint": "कृपया हे पृष्ठ शेवटचे जिथे संपादित केले होते त्या डिव्हाइसवर उघडा, आणि मग सध्याच्या डिव्हाइसवर पुन्हा उघडा.", "clickToCopy": "एरर कोड कॉपी करण्यासाठी क्लिक करा" }, "editor": { "bold": "जाड", "bulletedList": "बुलेट यादी", "bulletedListShortForm": "बुलेट", "checkbox": "चेकबॉक्स", "embedCode": "कोड एम्बेड करा", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "हायलाइट", "color": "रंग", "image": "प्रतिमा", "date": "तारीख", "page": "पृष्ठ", "italic": "तिरका", "link": "लिंक", "numberedList": "क्रमांकित यादी", "numberedListShortForm": "क्रमांकित", "toggleHeading1ShortForm": "Toggle H1", "toggleHeading2ShortForm": "Toggle H2", "toggleHeading3ShortForm": "Toggle H3", "quote": "कोट", "strikethrough": "ओढून टाका", "text": "मजकूर", "underline": "अधोरेखित", "fontColorDefault": "डीफॉल्ट", "fontColorGray": "धूसर", "fontColorBrown": "तपकिरी", "fontColorOrange": "केशरी", "fontColorYellow": "पिवळा", "fontColorGreen": "हिरवा", "fontColorBlue": "निळा", "fontColorPurple": "जांभळा", "fontColorPink": "पिंग", "fontColorRed": "लाल", "backgroundColorDefault": "डीफॉल्ट पार्श्वभूमी", "backgroundColorGray": "धूसर पार्श्वभूमी", "backgroundColorBrown": "तपकिरी पार्श्वभूमी", "backgroundColorOrange": "केशरी पार्श्वभूमी", "backgroundColorYellow": "पिवळी पार्श्वभूमी", "backgroundColorGreen": "हिरवी पार्श्वभूमी", "backgroundColorBlue": "निळी पार्श्वभूमी", "backgroundColorPurple": "जांभळी पार्श्वभूमी", "backgroundColorPink": "पिंग पार्श्वभूमी", "backgroundColorRed": "लाल पार्श्वभूमी", "backgroundColorLime": "लिंबू पार्श्वभूमी", "backgroundColorAqua": "पाण्याचा पार्श्वभूमी", "done": "पूर्ण", "cancel": "रद्द करा", "tint1": "टिंट 1", "tint2": "टिंट 2", "tint3": "टिंट 3", "tint4": "टिंट 4", "tint5": "टिंट 5", "tint6": "टिंट 6", "tint7": "टिंट 7", "tint8": "टिंट 8", "tint9": "टिंट 9", "lightLightTint1": "जांभळा", "lightLightTint2": "पिंग", "lightLightTint3": "फिकट पिंग", "lightLightTint4": "केशरी", "lightLightTint5": "पिवळा", "lightLightTint6": "लिंबू", "lightLightTint7": "हिरवा", "lightLightTint8": "पाणी", "lightLightTint9": "निळा", "urlHint": "URL", "mobileHeading1": "Heading 1", "mobileHeading2": "Heading 2", "mobileHeading3": "Heading 3", "mobileHeading4": "Heading 4", "mobileHeading5": "Heading 5", "mobileHeading6": "Heading 6", "textColor": "मजकूराचा रंग", "backgroundColor": "पार्श्वभूमीचा रंग", "addYourLink": "तुमची लिंक जोडा", "openLink": "लिंक उघडा", "copyLink": "लिंक कॉपी करा", "removeLink": "लिंक काढा", "editLink": "लिंक संपादित करा", "linkText": "मजकूर", "linkTextHint": "कृपया मजकूर प्रविष्ट करा", "linkAddressHint": "कृपया URL प्रविष्ट करा", "highlightColor": "हायलाइट रंग", "clearHighlightColor": "हायलाइट काढा", "customColor": "स्वतःचा रंग", "hexValue": "Hex मूल्य", "opacity": "अपारदर्शकता", "resetToDefaultColor": "डीफॉल्ट रंगावर रीसेट करा", "ltr": "LTR", "rtl": "RTL", "auto": "स्वयंचलित", "cut": "कट", "copy": "कॉपी", "paste": "पेस्ट", "find": "शोधा", "select": "निवडा", "selectAll": "सर्व निवडा", "previousMatch": "मागील जुळणारे", "nextMatch": "पुढील जुळणारे", "closeFind": "बंद करा", "replace": "बदला", "replaceAll": "सर्व बदला", "regex": "Regex", "caseSensitive": "केस सेंसिटिव्ह", "uploadImage": "प्रतिमा अपलोड करा", "urlImage": "URL प्रतिमा", "incorrectLink": "चुकीची लिंक", "upload": "अपलोड", "chooseImage": "प्रतिमा निवडा", "loading": "लोड करत आहे", "imageLoadFailed": "प्रतिमा लोड करण्यात अयशस्वी", "divider": "विभाजक", "table": "तक्त्याचे स्वरूप", "colAddBefore": "यापूर्वी स्तंभ जोडा", "rowAddBefore": "यापूर्वी पंक्ती जोडा", "colAddAfter": "यानंतर स्तंभ जोडा", "rowAddAfter": "यानंतर पंक्ती जोडा", "colRemove": "स्तंभ काढा", "rowRemove": "पंक्ती काढा", "colDuplicate": "स्तंभ डुप्लिकेट", "rowDuplicate": "पंक्ती डुप्लिकेट", "colClear": "सामग्री साफ करा", "rowClear": "सामग्री साफ करा", "slashPlaceHolder": "'/' टाइप करा आणि घटक जोडा किंवा टाइप सुरू करा", "typeSomething": "काहीतरी लिहा...", "toggleListShortForm": "टॉगल", "quoteListShortForm": "कोट", "mathEquationShortForm": "सूत्र", "codeBlockShortForm": "कोड" }, "favorite": { "noFavorite": "कोणतेही आवडते पृष्ठ नाही", "noFavoriteHintText": "पृष्ठाला डावीकडे स्वाइप करा आणि ते आवडत्या यादीत जोडा", "removeFromSidebar": "साइडबारमधून काढा", "addToSidebar": "साइडबारमध्ये पिन करा" }, "cardDetails": { "notesPlaceholder": "/ टाइप करा ब्लॉक घालण्यासाठी, किंवा टाइप करायला सुरुवात करा" }, "blockPlaceholders": { "todoList": "करण्याची यादी", "bulletList": "यादी", "numberList": "क्रमांकित यादी", "quote": "कोट", "heading": "मथळा {}" }, "titleBar": { "pageIcon": "पृष्ठ चिन्ह", "language": "भाषा", "font": "फॉन्ट", "actions": "क्रिया", "date": "तारीख", "addField": "फील्ड जोडा", "userIcon": "वापरकर्त्याचे चिन्ह" }, "noLogFiles": "कोणतीही लॉग फाइल्स नाहीत", "newSettings": { "myAccount": { "title": "माझे खाते", "subtitle": "तुमचा प्रोफाइल सानुकूल करा, खाते सुरक्षा व्यवस्थापित करा, AI कीज पहा किंवा लॉगिन करा.", "profileLabel": "खाते नाव आणि प्रोफाइल चित्र", "profileNamePlaceholder": "तुमचे नाव प्रविष्ट करा", "accountSecurity": "खाते सुरक्षा", "2FA": "2-स्टेप प्रमाणीकरण", "aiKeys": "AI कीज", "accountLogin": "खाते लॉगिन", "updateNameError": "नाव अपडेट करण्यात अयशस्वी", "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", "aboutAppFlowy": "@:appName विषयी", "deleteAccount": { "title": "खाते हटवा", "subtitle": "तुमचे खाते आणि सर्व डेटा कायमचे हटवा.", "description": "तुमचे खाते कायमचे हटवले जाईल आणि सर्व वर्कस्पेसमधून प्रवेश काढून टाकला जाईल.", "deleteMyAccount": "माझे खाते हटवा", "dialogTitle": "खाते हटवा", "dialogContent1": "तुम्हाला खात्री आहे की तुम्ही तुमचे खाते कायमचे हटवू इच्छिता?", "dialogContent2": "ही क्रिया पूर्ववत केली जाऊ शकत नाही. हे सर्व वर्कस्पेसमधून प्रवेश हटवेल, खाजगी वर्कस्पेस मिटवेल, आणि सर्व शेअर्ड वर्कस्पेसमधून काढून टाकेल.", "confirmHint1": "कृपया पुष्टीसाठी \"@:newSettings.myAccount.deleteAccount.confirmHint3\" टाइप करा.", "confirmHint2": "मला समजले आहे की ही क्रिया अपरिवर्तनीय आहे आणि माझे खाते व सर्व संबंधित डेटा कायमचा हटवला जाईल.", "confirmHint3": "DELETE MY ACCOUNT", "checkToConfirmError": "हटवण्यासाठी पुष्टी बॉक्स निवडणे आवश्यक आहे", "failedToGetCurrentUser": "वर्तमान वापरकर्त्याचा ईमेल मिळवण्यात अयशस्वी", "confirmTextValidationFailed": "तुमचा पुष्टी मजकूर \"@:newSettings.myAccount.deleteAccount.confirmHint3\" शी जुळत नाही", "deleteAccountSuccess": "खाते यशस्वीरित्या हटवले गेले" } }, "workplace": { "name": "वर्कस्पेस", "title": "वर्कस्पेस सेटिंग्स", "subtitle": "तुमचा वर्कस्पेस लुक, थीम, फॉन्ट, मजकूर लेआउट, तारीख, वेळ आणि भाषा सानुकूल करा.", "workplaceName": "वर्कस्पेसचे नाव", "workplaceNamePlaceholder": "वर्कस्पेसचे नाव टाका", "workplaceIcon": "वर्कस्पेस चिन्ह", "workplaceIconSubtitle": "एक प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे साइडबार आणि सूचना मध्ये दर्शवले जाईल.", "renameError": "वर्कस्पेसचे नाव बदलण्यात अयशस्वी", "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", "chooseAnIcon": "चिन्ह निवडा", "appearance": { "name": "दृश्यरूप", "themeMode": { "auto": "स्वयंचलित", "light": "प्रकाश मोड", "dark": "गडद मोड" }, "language": "भाषा" } }, "syncState": { "syncing": "सिंक्रोनायझ करत आहे", "synced": "सिंक्रोनायझ झाले", "noNetworkConnected": "नेटवर्क कनेक्ट केलेले नाही" } }, "pageStyle": { "title": "पृष्ठ शैली", "layout": "लेआउट", "coverImage": "मुखपृष्ठ प्रतिमा", "pageIcon": "पृष्ठ चिन्ह", "colors": "रंग", "gradient": "ग्रेडियंट", "backgroundImage": "पार्श्वभूमी प्रतिमा", "presets": "पूर्वनियोजित", "photo": "फोटो", "unsplash": "Unsplash", "pageCover": "पृष्ठ कव्हर", "none": "काही नाही", "openSettings": "सेटिंग्स उघडा", "photoPermissionTitle": "@:appName तुमच्या फोटो लायब्ररीमध्ये प्रवेश करू इच्छित आहे", "photoPermissionDescription": "तुमच्या कागदपत्रांमध्ये प्रतिमा जोडण्यासाठी @:appName ला तुमच्या फोटोंमध्ये प्रवेश आवश्यक आहे", "cameraPermissionTitle": "@:appName तुमच्या कॅमेऱ्याला प्रवेश करू इच्छित आहे", "cameraPermissionDescription": "कॅमेऱ्यातून प्रतिमा जोडण्यासाठी @:appName ला तुमच्या कॅमेऱ्याचा प्रवेश आवश्यक आहे", "doNotAllow": "परवानगी देऊ नका", "image": "प्रतिमा" }, "commandPalette": { "placeholder": "शोधा किंवा प्रश्न विचारा...", "bestMatches": "सर्वोत्तम जुळवणी", "recentHistory": "अलीकडील इतिहास", "navigateHint": "नेव्हिगेट करण्यासाठी", "loadingTooltip": "आम्ही निकाल शोधत आहोत...", "betaLabel": "बेटा", "betaTooltip": "सध्या आम्ही फक्त दस्तऐवज आणि पृष्ठ शोध समर्थन करतो", "fromTrashHint": "कचरापेटीतून", "noResultsHint": "आपण जे शोधत आहात ते सापडले नाही, कृपया दुसरा शब्द वापरून शोधा.", "clearSearchTooltip": "शोध फील्ड साफ करा" }, "space": { "delete": "हटवा", "deleteConfirmation": "हटवा: ", "deleteConfirmationDescription": "या स्पेसमधील सर्व पृष्ठे हटवली जातील आणि कचरापेटीत टाकली जातील, आणि प्रकाशित पृष्ठे अनपब्लिश केली जातील.", "rename": "स्पेसचे नाव बदला", "changeIcon": "चिन्ह बदला", "manage": "स्पेस व्यवस्थापित करा", "addNewSpace": "स्पेस तयार करा", "collapseAllSubPages": "सर्व उपपृष्ठे संकुचित करा", "createNewSpace": "नवीन स्पेस तयार करा", "createSpaceDescription": "तुमचे कार्य अधिक चांगल्या प्रकारे आयोजित करण्यासाठी अनेक सार्वजनिक व खाजगी स्पेस तयार करा.", "spaceName": "स्पेसचे नाव", "spaceNamePlaceholder": "उदा. मार्केटिंग, अभियांत्रिकी, HR", "permission": "स्पेस परवानगी", "publicPermission": "सार्वजनिक", "publicPermissionDescription": "पूर्ण प्रवेशासह सर्व वर्कस्पेस सदस्य", "privatePermission": "खाजगी", "privatePermissionDescription": "फक्त तुम्हाला या स्पेसमध्ये प्रवेश आहे", "spaceIconBackground": "पार्श्वभूमीचा रंग", "spaceIcon": "चिन्ह", "dangerZone": "धोकादायक क्षेत्र", "unableToDeleteLastSpace": "शेवटची स्पेस हटवता येणार नाही", "unableToDeleteSpaceNotCreatedByYou": "तुमच्याद्वारे तयार न केलेली स्पेस हटवता येणार नाही", "enableSpacesForYourWorkspace": "तुमच्या वर्कस्पेससाठी स्पेस सक्षम करा", "title": "स्पेसेस", "defaultSpaceName": "सामान्य", "upgradeSpaceTitle": "स्पेस सक्षम करा", "upgradeSpaceDescription": "तुमच्या वर्कस्पेसचे अधिक चांगल्या प्रकारे व्यवस्थापन करण्यासाठी अनेक सार्वजनिक आणि खाजगी स्पेस तयार करा.", "upgrade": "अपग्रेड", "upgradeYourSpace": "अनेक स्पेस तयार करा", "quicklySwitch": "पुढील स्पेसवर पटकन स्विच करा", "duplicate": "स्पेस डुप्लिकेट करा", "movePageToSpace": "पृष्ठ स्पेसमध्ये हलवा", "cannotMovePageToDatabase": "पृष्ठ डेटाबेसमध्ये हलवता येणार नाही", "switchSpace": "स्पेस स्विच करा", "spaceNameCannotBeEmpty": "स्पेसचे नाव रिकामे असू शकत नाही", "success": { "deleteSpace": "स्पेस यशस्वीरित्या हटवली", "renameSpace": "स्पेसचे नाव यशस्वीरित्या बदलले", "duplicateSpace": "स्पेस यशस्वीरित्या डुप्लिकेट केली", "updateSpace": "स्पेस यशस्वीरित्या अपडेट केली" }, "error": { "deleteSpace": "स्पेस हटवण्यात अयशस्वी", "renameSpace": "स्पेसचे नाव बदलण्यात अयशस्वी", "duplicateSpace": "स्पेस डुप्लिकेट करण्यात अयशस्वी", "updateSpace": "स्पेस अपडेट करण्यात अयशस्वी" }, "createSpace": "स्पेस तयार करा", "manageSpace": "स्पेस व्यवस्थापित करा", "renameSpace": "स्पेसचे नाव बदला", "mSpaceIconColor": "स्पेस चिन्हाचा रंग", "mSpaceIcon": "स्पेस चिन्ह" }, "publish": { "hasNotBeenPublished": "हे पृष्ठ अजून प्रकाशित केलेले नाही", "spaceHasNotBeenPublished": "स्पेस प्रकाशित करण्यासाठी समर्थन नाही", "reportPage": "पृष्ठाची तक्रार करा", "databaseHasNotBeenPublished": "डेटाबेस प्रकाशित करण्यास समर्थन नाही.", "createdWith": "यांनी तयार केले", "downloadApp": "AppFlowy डाउनलोड करा", "copy": { "codeBlock": "कोड ब्लॉकची सामग्री क्लिपबोर्डवर कॉपी केली गेली आहे", "imageBlock": "प्रतिमा लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", "mathBlock": "गणितीय समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे", "fileBlock": "फाइल लिंक क्लिपबोर्डवर कॉपी केली गेली आहे" }, "containsPublishedPage": "या पृष्ठात एक किंवा अधिक प्रकाशित पृष्ठे आहेत. तुम्ही पुढे गेल्यास ती अनपब्लिश होतील. तुम्हाला हटवणे चालू ठेवायचे आहे का?", "publishSuccessfully": "यशस्वीरित्या प्रकाशित झाले", "unpublishSuccessfully": "यशस्वीरित्या अनपब्लिश झाले", "publishFailed": "प्रकाशित करण्यात अयशस्वी", "unpublishFailed": "अनपब्लिश करण्यात अयशस्वी", "noAccessToVisit": "या पृष्ठावर प्रवेश नाही...", "createWithAppFlowy": "AppFlowy ने वेबसाइट तयार करा", "fastWithAI": "AI सह जलद आणि सोपे.", "tryItNow": "आत्ताच वापरून पहा", "onlyGridViewCanBePublished": "फक्त Grid view प्रकाशित केला जाऊ शकतो", "database": { "zero": "{} निवडलेले दृश्य प्रकाशित करा", "one": "{} निवडलेली दृश्ये प्रकाशित करा", "many": "{} निवडलेली दृश्ये प्रकाशित करा", "other": "{} निवडलेली दृश्ये प्रकाशित करा" }, "mustSelectPrimaryDatabase": "प्राथमिक दृश्य निवडणे आवश्यक आहे", "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया किमान एक डेटाबेस निवडा.", "unableToDeselectPrimaryDatabase": "प्राथमिक डेटाबेस अननिवड करता येणार नाही", "saveThisPage": "या टेम्पलेटपासून सुरू करा", "duplicateTitle": "तुम्हाला हे कुठे जोडायचे आहे", "selectWorkspace": "वर्कस्पेस निवडा", "addTo": "मध्ये जोडा", "duplicateSuccessfully": "तुमच्या वर्कस्पेसमध्ये जोडले गेले", "duplicateSuccessfullyDescription": "AppFlowy स्थापित केले नाही? तुम्ही 'डाउनलोड' वर क्लिक केल्यावर डाउनलोड आपोआप सुरू होईल.", "downloadIt": "डाउनलोड करा", "openApp": "अ‍ॅपमध्ये उघडा", "duplicateFailed": "डुप्लिकेट करण्यात अयशस्वी", "membersCount": { "zero": "सदस्य नाहीत", "one": "1 सदस्य", "many": "{count} सदस्य", "other": "{count} सदस्य" }, "useThisTemplate": "हा टेम्पलेट वापरा" }, "web": { "continue": "पुढे जा", "or": "किंवा", "continueWithGoogle": "Google सह पुढे जा", "continueWithGithub": "GitHub सह पुढे जा", "continueWithDiscord": "Discord सह पुढे जा", "continueWithApple": "Apple सह पुढे जा", "moreOptions": "अधिक पर्याय", "collapse": "आकुंचन", "signInAgreement": "\"पुढे जा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", "signInLocalAgreement": "\"सुरुवात करा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", "and": "आणि", "termOfUse": "वापर अटी", "privacyPolicy": "गोपनीयता धोरण", "signInError": "साइन इन त्रुटी", "login": "साइन अप किंवा लॉग इन करा", "fileBlock": { "uploadedAt": "{time} रोजी अपलोड केले", "linkedAt": "{time} रोजी लिंक जोडली", "empty": "फाईल अपलोड करा किंवा एम्बेड करा", "uploadFailed": "अपलोड अयशस्वी, कृपया पुन्हा प्रयत्न करा", "retry": "पुन्हा प्रयत्न करा" }, "importNotion": "Notion वरून आयात करा", "import": "आयात करा", "importSuccess": "यशस्वीरित्या अपलोड केले", "importSuccessMessage": "आम्ही तुम्हाला आयात पूर्ण झाल्यावर सूचित करू. त्यानंतर, तुम्ही साइडबारमध्ये तुमची आयात केलेली पृष्ठे पाहू शकता.", "importFailed": "आयात अयशस्वी, कृपया फाईल फॉरमॅट तपासा", "dropNotionFile": "तुमची Notion zip फाईल येथे ड्रॉप करा किंवा ब्राउझ करा", "error": { "pageNameIsEmpty": "पृष्ठाचे नाव रिकामे आहे, कृपया दुसरे नाव वापरून पहा" } }, "globalComment": { "comments": "टिप्पण्या", "addComment": "टिप्पणी जोडा", "reactedBy": "यांनी प्रतिक्रिया दिली", "addReaction": "प्रतिक्रिया जोडा", "reactedByMore": "आणि {count} इतर", "showSeconds": { "one": "1 सेकंदापूर्वी", "other": "{count} सेकंदांपूर्वी", "zero": "आत्ताच", "many": "{count} सेकंदांपूर्वी" }, "showMinutes": { "one": "1 मिनिटापूर्वी", "other": "{count} मिनिटांपूर्वी", "many": "{count} मिनिटांपूर्वी" }, "showHours": { "one": "1 तासापूर्वी", "other": "{count} तासांपूर्वी", "many": "{count} तासांपूर्वी" }, "showDays": { "one": "1 दिवसापूर्वी", "other": "{count} दिवसांपूर्वी", "many": "{count} दिवसांपूर्वी" }, "showMonths": { "one": "1 महिन्यापूर्वी", "other": "{count} महिन्यांपूर्वी", "many": "{count} महिन्यांपूर्वी" }, "showYears": { "one": "1 वर्षापूर्वी", "other": "{count} वर्षांपूर्वी", "many": "{count} वर्षांपूर्वी" }, "reply": "उत्तर द्या", "deleteComment": "टिप्पणी हटवा", "youAreNotOwner": "तुम्ही या टिप्पणीचे मालक नाही", "confirmDeleteDescription": "तुम्हाला ही टिप्पणी हटवायची आहे याची खात्री आहे का?", "hasBeenDeleted": "हटवले गेले", "replyingTo": "याला उत्तर देत आहे", "noAccessDeleteComment": "तुम्हाला ही टिप्पणी हटवण्याची परवानगी नाही", "collapse": "संकुचित करा", "readMore": "अधिक वाचा", "failedToAddComment": "टिप्पणी जोडण्यात अयशस्वी", "commentAddedSuccessfully": "टिप्पणी यशस्वीरित्या जोडली गेली.", "commentAddedSuccessTip": "तुम्ही नुकतीच एक टिप्पणी जोडली किंवा उत्तर दिले आहे. वर जाऊन ताजी टिप्पण्या पाहायच्या का?" }, "template": { "asTemplate": "टेम्पलेट म्हणून जतन करा", "name": "टेम्पलेट नाव", "description": "टेम्पलेट वर्णन", "about": "टेम्पलेट माहिती", "deleteFromTemplate": "टेम्पलेटमधून हटवा", "preview": "टेम्पलेट पूर्वदृश्य", "categories": "टेम्पलेट श्रेणी", "isNewTemplate": "नवीन टेम्पलेटमध्ये पिन करा", "featured": "वैशिष्ट्यीकृतमध्ये पिन करा", "relatedTemplates": "संबंधित टेम्पलेट्स", "requiredField": "{field} आवश्यक आहे", "addCategory": "\"{category}\" जोडा", "addNewCategory": "नवीन श्रेणी जोडा", "addNewCreator": "नवीन निर्माता जोडा", "deleteCategory": "श्रेणी हटवा", "editCategory": "श्रेणी संपादित करा", "editCreator": "निर्माता संपादित करा", "category": { "name": "श्रेणीचे नाव", "icon": "श्रेणी चिन्ह", "bgColor": "श्रेणी पार्श्वभूमीचा रंग", "priority": "श्रेणी प्राधान्य", "desc": "श्रेणीचे वर्णन", "type": "श्रेणी प्रकार", "icons": "श्रेणी चिन्हे", "colors": "श्रेणी रंग", "byUseCase": "वापराच्या आधारे", "byFeature": "वैशिष्ट्यांनुसार", "deleteCategory": "श्रेणी हटवा", "deleteCategoryDescription": "तुम्हाला ही श्रेणी हटवायची आहे का?", "typeToSearch": "श्रेणी शोधण्यासाठी टाइप करा..." }, "creator": { "label": "टेम्पलेट निर्माता", "name": "निर्मात्याचे नाव", "avatar": "निर्मात्याचा अवतार", "accountLinks": "निर्मात्याचे खाते दुवे", "uploadAvatar": "अवतार अपलोड करण्यासाठी क्लिक करा", "deleteCreator": "निर्माता हटवा", "deleteCreatorDescription": "तुम्हाला हा निर्माता हटवायचा आहे का?", "typeToSearch": "निर्माते शोधण्यासाठी टाइप करा..." }, "uploadSuccess": "टेम्पलेट यशस्वीरित्या अपलोड झाले", "uploadSuccessDescription": "तुमचे टेम्पलेट यशस्वीरित्या अपलोड झाले आहे. आता तुम्ही ते टेम्पलेट गॅलरीमध्ये पाहू शकता.", "viewTemplate": "टेम्पलेट पहा", "deleteTemplate": "टेम्पलेट हटवा", "deleteSuccess": "टेम्पलेट यशस्वीरित्या हटवले गेले", "deleteTemplateDescription": "याचा वर्तमान पृष्ठ किंवा प्रकाशित स्थितीवर परिणाम होणार नाही. तुम्हाला हे टेम्पलेट हटवायचे आहे का?", "addRelatedTemplate": "संबंधित टेम्पलेट जोडा", "removeRelatedTemplate": "संबंधित टेम्पलेट हटवा", "uploadAvatar": "अवतार अपलोड करा", "searchInCategory": "{category} मध्ये शोधा", "label": "टेम्पलेट्स" }, "fileDropzone": { "dropFile": "फाइल अपलोड करण्यासाठी येथे क्लिक करा किंवा ड्रॅग करा", "uploading": "अपलोड करत आहे...", "uploadFailed": "अपलोड अयशस्वी", "uploadSuccess": "अपलोड यशस्वी", "uploadSuccessDescription": "फाइल यशस्वीरित्या अपलोड झाली आहे", "uploadFailedDescription": "फाइल अपलोड अयशस्वी झाली आहे", "uploadingDescription": "फाइल अपलोड होत आहे" }, "gallery": { "preview": "पूर्ण स्क्रीनमध्ये उघडा", "copy": "कॉपी करा", "download": "डाउनलोड", "prev": "मागील", "next": "पुढील", "resetZoom": "झूम रिसेट करा", "zoomIn": "झूम इन", "zoomOut": "झूम आउट" }, "invitation": { "join": "सामील व्हा", "on": "वर", "invitedBy": "यांनी आमंत्रित केले", "membersCount": { "zero": "{count} सदस्य", "one": "{count} सदस्य", "many": "{count} सदस्य", "other": "{count} सदस्य" }, "tip": "तुम्हाला खालील माहितीच्या आधारे या कार्यक्षेत्रात सामील होण्यासाठी आमंत्रित करण्यात आले आहे. ही माहिती चुकीची असल्यास, कृपया प्रशासकाशी संपर्क साधा.", "joinWorkspace": "वर्कस्पेसमध्ये सामील व्हा", "success": "तुम्ही यशस्वीरित्या वर्कस्पेसमध्ये सामील झाला आहात", "successMessage": "आता तुम्ही सर्व पृष्ठे आणि कार्यक्षेत्रे वापरू शकता.", "openWorkspace": "AppFlowy उघडा", "alreadyAccepted": "तुम्ही आधीच आमंत्रण स्वीकारले आहे", "errorModal": { "title": "काहीतरी चुकले आहे", "description": "तुमचे सध्याचे खाते {email} कदाचित या वर्कस्पेसमध्ये प्रवेशासाठी पात्र नाही. कृपया योग्य खात्याने लॉग इन करा किंवा वर्कस्पेस मालकाशी संपर्क साधा.", "contactOwner": "मालकाशी संपर्क करा", "close": "मुख्यपृष्ठावर परत जा", "changeAccount": "खाते बदला" } }, "requestAccess": { "title": "या पृष्ठासाठी प्रवेश नाही", "subtitle": "तुम्ही या पृष्ठाच्या मालकाकडून प्रवेशासाठी विनंती करू शकता. मंजुरीनंतर तुम्हाला हे पृष्ठ पाहता येईल.", "requestAccess": "प्रवेशाची विनंती करा", "backToHome": "मुख्यपृष्ठावर परत जा", "tip": "तुम्ही सध्या म्हणून लॉग इन आहात.", "mightBe": "कदाचित तुम्हाला दुसऱ्या खात्याने लॉग इन करणे आवश्यक आहे.", "successful": "विनंती यशस्वीपणे पाठवली गेली", "successfulMessage": "मालकाने मंजुरी दिल्यावर तुम्हाला सूचित केले जाईल.", "requestError": "प्रवेशाची विनंती अयशस्वी", "repeatRequestError": "तुम्ही यासाठी आधीच विनंती केली आहे" }, "approveAccess": { "title": "वर्कस्पेसमध्ये सामील होण्यासाठी विनंती मंजूर करा", "requestSummary": " यांनी मध्ये सामील होण्यासाठी आणि पाहण्यासाठी विनंती केली आहे", "upgrade": "अपग्रेड", "downloadApp": "AppFlowy डाउनलोड करा", "approveButton": "मंजूर करा", "approveSuccess": "मंजूर यशस्वी", "approveError": "मंजुरी अयशस्वी. कृपया वर्कस्पेस मर्यादा ओलांडलेली नाही याची खात्री करा", "getRequestInfoError": "विनंतीची माहिती मिळवण्यात अयशस्वी", "memberCount": { "zero": "कोणतेही सदस्य नाहीत", "one": "1 सदस्य", "many": "{count} सदस्य", "other": "{count} सदस्य" }, "alreadyProTitle": "वर्कस्पेस योजना मर्यादा गाठली आहे", "alreadyProMessage": "अधिक सदस्यांसाठी शी संपर्क साधा", "repeatApproveError": "तुम्ही ही विनंती आधीच मंजूर केली आहे", "ensurePlanLimit": "कृपया खात्री करा की योजना मर्यादा ओलांडलेली नाही. जर ओलांडली असेल तर वर्कस्पेस योजना करा किंवा करा.", "requestToJoin": "मध्ये सामील होण्यासाठी विनंती केली", "asMember": "सदस्य म्हणून" }, "upgradePlanModal": { "title": "Pro प्लॅनवर अपग्रेड करा", "message": "{name} ने फ्री सदस्य मर्यादा गाठली आहे. अधिक सदस्य आमंत्रित करण्यासाठी Pro प्लॅनवर अपग्रेड करा.", "upgradeSteps": "AppFlowy वर तुमची योजना कशी अपग्रेड करावी:", "step1": "1. सेटिंग्जमध्ये जा", "step2": "2. 'योजना' वर क्लिक करा", "step3": "3. 'योजना बदला' निवडा", "appNote": "नोंद:", "actionButton": "अपग्रेड करा", "downloadLink": "अ‍ॅप डाउनलोड करा", "laterButton": "नंतर", "refreshNote": "यशस्वी अपग्रेडनंतर, तुमची नवीन वैशिष्ट्ये सक्रिय करण्यासाठी वर क्लिक करा.", "refresh": "येथे" }, "breadcrumbs": { "label": "ब्रेडक्रम्स" }, "time": { "justNow": "आत्ताच", "seconds": { "one": "1 सेकंद", "other": "{count} सेकंद" }, "minutes": { "one": "1 मिनिट", "other": "{count} मिनिटे" }, "hours": { "one": "1 तास", "other": "{count} तास" }, "days": { "one": "1 दिवस", "other": "{count} दिवस" }, "weeks": { "one": "1 आठवडा", "other": "{count} आठवडे" }, "months": { "one": "1 महिना", "other": "{count} महिने" }, "years": { "one": "1 वर्ष", "other": "{count} वर्षे" }, "ago": "पूर्वी", "yesterday": "काल", "today": "आज" }, "members": { "zero": "सदस्य नाहीत", "one": "1 सदस्य", "many": "{count} सदस्य", "other": "{count} सदस्य" }, "tabMenu": { "close": "बंद करा", "closeDisabledHint": "पिन केलेले टॅब बंद करता येत नाही, कृपया आधी अनपिन करा", "closeOthers": "इतर टॅब बंद करा", "closeOthersHint": "हे सर्व अनपिन केलेले टॅब्स बंद करेल या टॅब वगळता", "closeOthersDisabledHint": "सर्व टॅब पिन केलेले आहेत, बंद करण्यासाठी कोणतेही टॅब सापडले नाहीत", "favorite": "आवडते", "unfavorite": "आवडते काढा", "favoriteDisabledHint": "हे दृश्य आवडते म्हणून जतन करता येत नाही", "pinTab": "पिन करा", "unpinTab": "अनपिन करा" }, "openFileMessage": { "success": "फाइल यशस्वीरित्या उघडली", "fileNotFound": "फाइल सापडली नाही", "noAppToOpenFile": "ही फाइल उघडण्यासाठी कोणतेही अ‍ॅप उपलब्ध नाही", "permissionDenied": "ही फाइल उघडण्यासाठी परवानगी नाही", "unknownError": "फाइल उघडण्यात अयशस्वी" }, "inviteMember": { "requestInviteMembers": "तुमच्या वर्कस्पेसमध्ये आमंत्रित करा", "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, कृपया ", "upgrade": "अपग्रेड करा", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "आमंत्रण पाठवा", "inviteAlready": "तुम्ही या ईमेलला आधीच आमंत्रित केले आहे: {email}", "inviteSuccess": "आमंत्रण यशस्वीपणे पाठवले", "description": "खाली ईमेल कॉमा वापरून टाका. शुल्क सदस्य संख्येवर आधारित असते.", "emails": "ईमेल" }, "quickNote": { "label": "झटपट नोंद", "quickNotes": "झटपट नोंदी", "search": "झटपट नोंदी शोधा", "collapseFullView": "पूर्ण दृश्य लपवा", "expandFullView": "पूर्ण दृश्य उघडा", "createFailed": "झटपट नोंद तयार करण्यात अयशस्वी", "quickNotesEmpty": "कोणत्याही झटपट नोंदी नाहीत", "emptyNote": "रिकामी नोंद", "deleteNotePrompt": "निवडलेली नोंद कायमची हटवली जाईल. तुम्हाला नक्की ती हटवायची आहे का?", "addNote": "नवीन नोंद", "noAdditionalText": "अधिक माहिती नाही" }, "subscribe": { "upgradePlanTitle": "योजना तुलना करा आणि निवडा", "yearly": "वार्षिक", "save": "{discount}% बचत", "monthly": "मासिक", "priceIn": "किंमत येथे: ", "free": "फ्री", "pro": "प्रो", "freeDescription": "2 सदस्यांपर्यंत वैयक्तिक वापरकर्त्यांसाठी सर्व काही आयोजित करण्यासाठी", "proDescription": "प्रकल्प आणि टीम नॉलेज व्यवस्थापित करण्यासाठी लहान टीमसाठी", "proDuration": { "monthly": "प्रति सदस्य प्रति महिना\nमासिक बिलिंग", "yearly": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग" }, "cancel": "खालच्या योजनेवर जा", "changePlan": "प्रो योजनेवर अपग्रेड करा", "everythingInFree": "फ्री योजनेतील सर्व काही +", "currentPlan": "सध्याची योजना", "freeDuration": "कायम", "freePoints": { "first": "1 सहकार्यात्मक वर्कस्पेस (2 सदस्यांपर्यंत)", "second": "अमर्यादित पृष्ठे आणि ब्लॉक्स", "three": "5 GB संचयन", "four": "बुद्धिमान शोध", "five": "20 AI प्रतिसाद", "six": "मोबाईल अ‍ॅप", "seven": "रिअल-टाइम सहकार्य" }, "proPoints": { "first": "अमर्यादित संचयन", "second": "10 वर्कस्पेस सदस्यांपर्यंत", "three": "अमर्यादित AI प्रतिसाद", "four": "अमर्यादित फाइल अपलोड्स", "five": "कस्टम नेमस्पेस" }, "cancelPlan": { "title": "आपल्याला जाताना पाहून वाईट वाटते", "success": "आपली सदस्यता यशस्वीरित्या रद्द झाली आहे", "description": "आपल्याला जाताना वाईट वाटते. कृपया आम्हाला सुधारण्यासाठी तुमचे अभिप्राय कळवा. खालील काही प्रश्नांना उत्तर द्या.", "commonOther": "इतर", "otherHint": "आपले उत्तर येथे लिहा", "questionOne": { "question": "तुम्ही AppFlowy Pro सदस्यता का रद्द केली?", "answerOne": "खर्च खूप जास्त आहे", "answerTwo": "वैशिष्ट्ये अपेक्षेनुसार नव्हती", "answerThree": "चांगला पर्याय सापडला", "answerFour": "खर्च योग्य ठरण्यासाठी वापर पुरेसा नव्हता", "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" }, "questionTwo": { "question": "तुम्ही भविष्यात AppFlowy Pro पुन्हा घेण्याची शक्यता किती आहे?", "answerOne": "खूप शक्यता आहे", "answerTwo": "काहीशी शक्यता आहे", "answerThree": "निश्चित नाही", "answerFour": "अल्प शक्यता आहे", "answerFive": "शक्यता नाही" }, "questionThree": { "question": "सदस्यतेदरम्यान कोणते Pro फिचर तुम्हाला सर्वात जास्त उपयोगी वाटले?", "answerOne": "मल्टी-यूजर सहकार्य", "answerTwo": "लांब कालावधीसाठी आवृत्ती इतिहास", "answerThree": "अमर्यादित AI प्रतिसाद", "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" }, "questionFour": { "question": "AppFlowy बाबत तुमचा एकूण अनुभव कसा होता?", "answerOne": "छान", "answerTwo": "चांगला", "answerThree": "सामान्य", "answerFour": "थोडासा वाईट", "answerFive": "असंतोषजनक" } } }, "ai": { "contentPolicyViolation": "संवेदनशील सामग्रीमुळे प्रतिमा निर्मिती अयशस्वी झाली. कृपया तुमचे इनपुट पुन्हा लिहा आणि पुन्हा प्रयत्न करा.", "textLimitReachedDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अनलिमिटेड प्रतिसादांसाठी प्रो योजना घेण्याचा किंवा AI अ‍ॅड-ऑन खरेदी करण्याचा विचार करा.", "imageLimitReachedDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया प्रो योजना घ्या किंवा AI अ‍ॅड-ऑन खरेदी करा.", "limitReachedAction": { "textDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अधिक प्रतिसाद मिळवण्यासाठी कृपया", "imageDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया", "upgrade": "अपग्रेड करा", "toThe": "या योजनेवर", "proPlan": "प्रो योजना", "orPurchaseAn": "किंवा खरेदी करा", "aiAddon": "AI अ‍ॅड-ऑन" }, "editing": "संपादन करत आहे", "analyzing": "विश्लेषण करत आहे", "continueWritingEmptyDocumentTitle": "लेखन सुरू ठेवता आले नाही", "continueWritingEmptyDocumentDescription": "तुमच्या दस्तऐवजातील मजकूर वाढवण्यात अडचण येत आहे. कृपया एक छोटं परिचय लिहा, मग आम्ही पुढे नेऊ!", "more": "अधिक" }, "autoUpdate": { "criticalUpdateTitle": "अद्यतन आवश्यक आहे", "criticalUpdateDescription": "तुमचा अनुभव सुधारण्यासाठी आम्ही सुधारणा केल्या आहेत! कृपया {currentVersion} वरून {newVersion} वर अद्यतन करा.", "criticalUpdateButton": "अद्यतन करा", "bannerUpdateTitle": "नवीन आवृत्ती उपलब्ध!", "bannerUpdateDescription": "नवीन वैशिष्ट्ये आणि सुधारणांसाठी. अद्यतनासाठी 'Update' क्लिक करा.", "bannerUpdateButton": "अद्यतन करा", "settingsUpdateTitle": "नवीन आवृत्ती ({newVersion}) उपलब्ध!", "settingsUpdateDescription": "सध्याची आवृत्ती: {currentVersion} (अधिकृत बिल्ड) → {newVersion}", "settingsUpdateButton": "अद्यतन करा", "settingsUpdateWhatsNew": "काय नवीन आहे" }, "lockPage": { "lockPage": "लॉक केलेले", "reLockPage": "पुन्हा लॉक करा", "lockTooltip": "अनवधानाने संपादन टाळण्यासाठी पृष्ठ लॉक केले आहे. अनलॉक करण्यासाठी क्लिक करा.", "pageLockedToast": "पृष्ठ लॉक केले आहे. कोणी अनलॉक करेपर्यंत संपादन अक्षम आहे.", "lockedOperationTooltip": "पृष्ठ अनवधानाने संपादनापासून वाचवण्यासाठी लॉक केले आहे." }, "suggestion": { "accept": "स्वीकारा", "keep": "जसे आहे तसे ठेवा", "discard": "रद्द करा", "close": "बंद करा", "tryAgain": "पुन्हा प्रयत्न करा", "rewrite": "पुन्हा लिहा", "insertBelow": "खाली टाका" } } ================================================ FILE: frontend/appflowy_flutter/build.yaml ================================================ ================================================ FILE: frontend/appflowy_flutter/cargokit_options.yaml ================================================ use_precompiled_binaries: true ================================================ FILE: frontend/appflowy_flutter/dart_dependency_validator.yaml ================================================ # dart_dependency_validator.yaml allow_pins: true include: - "lib/**" exclude: - "packages/**" ignore: - analyzer ================================================ FILE: frontend/appflowy_flutter/dev.env ================================================ APPFLOWY_CLOUD_URL= ================================================ FILE: frontend/appflowy_flutter/devtools_options.yaml ================================================ extensions: ================================================ FILE: frontend/appflowy_flutter/distribute_options.yaml ================================================ output: dist/ releases: - name: dev jobs: - name: release-dev-linux-deb package: platform: linux target: deb - name: release-dev-linux-rpm package: platform: linux target: rpm ================================================ FILE: frontend/appflowy_flutter/dsa_pub.pem ================================================ -----BEGIN PUBLIC KEY----- MIIGQzCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0YruaT rrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7tJ8mG 4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy7xyw +sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL7iTV KiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqHOpf5 b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdpqm4Z QRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFyJiJW YWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ5EhG G4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAhLswu 6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPhAsVA 6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxCxMTp q1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIAPxbd 0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+Vuix/ 4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/8Wrb K13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqTQJg7 hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19tKcO s6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQdbsCz Axp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzfJ4v4 uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6TjcfV Wthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NClWgZn ixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3wm7NB +fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcTilaN C9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkDggIGAAKCAgEAt1DHYZoeXY0r vYXmxdNO6zfnbz1GGZHXpakzm9h4BrxPDP5J8DQ9ZeVVKg5+cU9AyMO3cZHp7wkx k6IB+ZDUpqO1D3lWriRl2fI8cS4edI0fzpnW1nyhhFD4MbKmP+v27aH+DhZ4Up3y GMmJTLmKiYx1EgZp7Sx77PBYDVMsKKd3h9+Hjp2YtUTfD2lleAmC+wcQGZiNtGw/ eKpsmUVnWrepOdntWTtCQi1OvfcHaF2QmgktCq+68hbDNYWaXmzVIiQqrdv/zzOG hCFIrRGWemrxL0iFG4Pzc4UfOINsISQLcUxRuF6pQWPxF8O/mWKfzAeqWxmIujUM EoSEuI3yQ8VjlYpW/8FSK7UhgnHBHOpCJWPWs/vQXAnaUR2PYyzuIzhVEhFs8YA8 iBIKnixIC2hu0YbEk3TBr/TRcbd7mDw9Mq7NT88xzdU13+Wh+4zhdX3rtBHYzBtI 7GaONGUNyY4h0duoyLpH6dxevaeKN6/bEdzYESjoE58QA88CpnAZGhJVphAba4cb w6GTDhK3RlPWh6hRqJwLDILGtnJS3UKeBDRmKMqNuqmHqPjyAAvt9JBO8lzjoLgf 1cDsXHNWBVwA2jsX2CukNJPlY1Fa3MWhdaUXmy6QGMSisr1sptvBt1Phry8T2u+P Y29SB4jvwqls268rP0cWqy4WXwlVwuc= -----END PUBLIC KEY----- ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; const defaultFirstCardName = 'Card 1'; const defaultLastCardName = 'Card 3'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('board add row test:', () { testWidgets('from header', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); final firstCard = find.byType(RowCard).first; expect( find.descendant( of: firstCard, matching: find.text(defaultFirstCardName), ), findsOneWidget, ); await tester.tap( find .descendant( of: find.byType(BoardColumnHeader), matching: find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.add_s, ), ) .at(1), ); await tester.pumpAndSettle(); const newCardName = 'Card 4'; await tester.enterText( find.descendant( of: firstCard, matching: find.byType(TextField), ), newCardName, ); await tester.pumpAndSettle(const Duration(milliseconds: 500)); await tester.tap(find.byType(AppFlowyBoard)); await tester.pumpAndSettle(); expect( find.descendant( of: find.byType(RowCard).first, matching: find.text(newCardName), ), findsOneWidget, ); }); testWidgets('from footer', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); final lastCard = find.byType(RowCard).last; expect( find.descendant( of: lastCard, matching: find.text(defaultLastCardName), ), findsOneWidget, ); await tester.tapButton( find.byType(BoardColumnFooter).at(1), ); const newCardName = 'Card 4'; await tester.enterText( find.descendant( of: find.byType(BoardColumnFooter), matching: find.byType(TextField), ), newCardName, ); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(const Duration(milliseconds: 500)); await tester.tap(find.byType(AppFlowyBoard)); await tester.pumpAndSettle(); expect( find.descendant( of: find.byType(RowCard).last, matching: find.text(newCardName), ), findsOneWidget, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('board field test', () { testWidgets('change field type whithin card #5360', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); const name = 'Card 1'; final card1 = find.text(name); await tester.tapButton(card1); const fieldName = "test change field"; await tester.createField( FieldType.RichText, name: fieldName, layout: ViewLayoutPB.Board, ); await tester.dismissRowDetailPage(); await tester.tapButton(card1); await tester.changeFieldTypeOfFieldWithName( fieldName, FieldType.Checkbox, layout: ViewLayoutPB.Board, ); await tester.hoverOnWidget(find.text('Card 2')); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/board/board_group_test.dart ================================================ import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('board group test:', () { testWidgets('move row to another group', (tester) async { const card1Name = 'Card 1'; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); final card1 = find.ancestor( of: find.text(card1Name), matching: find.byType(RowCard), ); final doingGroup = find.text('Doing'); final doingGroupCenter = tester.getCenter(doingGroup); final card1Center = tester.getCenter(card1); await tester.timedDrag( card1, doingGroupCenter.translate(-card1Center.dx, -card1Center.dy), const Duration(seconds: 1), ); await tester.pumpAndSettle(); await tester.tap(card1); await tester.pumpAndSettle(); final card1StatusFinder = find.descendant( of: find.byType(RowPropertyList), matching: find.descendant( of: find.byType(SelectOptionTag), matching: find.byType(Text), ), ); expect(card1StatusFinder, findsNWidgets(1)); final card1StatusText = tester.widget(card1StatusFinder).data; expect(card1StatusText, 'Doing'); }); testWidgets('rename group', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); final headers = find.byType(BoardColumnHeader); expect(headers, findsNWidgets(4)); // try to tap no status final noStatus = headers.first; expect( find.descendant(of: noStatus, matching: find.text("No Status")), findsOneWidget, ); await tester.tapButton(noStatus); expect( find.descendant(of: noStatus, matching: find.byType(TextField)), findsNothing, ); // tap on To Do and edit it final todo = headers.at(1); expect( find.descendant(of: todo, matching: find.text("To Do")), findsOneWidget, ); await tester.tapButton(todo); await tester.enterText( find.descendant(of: todo, matching: find.byType(TextField)), "tada", ); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); final newHeaders = find.byType(BoardColumnHeader); expect(newHeaders, findsNWidgets(4)); final tada = find.byType(BoardColumnHeader).at(1); expect( find.descendant(of: tada, matching: find.byType(TextField)), findsNothing, ); expect( find.descendant( of: tada, matching: find.text("tada"), ), findsOneWidget, ); }); testWidgets('edit select option from row detail', (tester) async { const card1Name = 'Card 1'; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); await tester.tapButton( find.descendant( of: find.byType(RowCard), matching: find.text(card1Name), ), ); await tester.tapGridFieldWithNameInRowDetailPage("Status"); await tester.tapButton( find.byWidgetPredicate( (widget) => widget is SelectOptionTagCell && widget.option.name == "To Do", ), ); final editor = find.byType(SelectOptionEditor); await tester.enterText( find.descendant(of: editor, matching: find.byType(TextField)), "tada", ); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); await tester.dismissFieldEditor(); await tester.dismissRowDetailPage(); final newHeaders = find.byType(BoardColumnHeader); expect(newHeaders, findsNWidgets(4)); final tada = find.byType(BoardColumnHeader).at(1); expect( find.descendant(of: tada, matching: find.byType(TextField)), findsNothing, ); expect( find.descendant( of: tada, matching: find.text("tada"), ), findsOneWidget, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database/board/presentation/widgets/board_hidden_groups.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('board group options:', () { testWidgets('expand/collapse hidden groups', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); final collapseFinder = find.byFlowySvg(FlowySvgs.pull_left_outlined_s); final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s); // Is expanded by default expect(collapseFinder, findsNothing); expect(expandFinder, findsOneWidget); // Collapse hidden groups await tester.tap(expandFinder); await tester.pumpAndSettle(); // Is collapsed expect(collapseFinder, findsOneWidget); expect(expandFinder, findsNothing); // Expand hidden groups await tester.tap(collapseFinder); await tester.pumpAndSettle(); // Is expanded expect(collapseFinder, findsNothing); expect(expandFinder, findsOneWidget); }); testWidgets('hide first group, and show it again', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s); await tester.tapButton(expandFinder); // Tap the options of the first group final optionsFinder = find .descendant( of: find.byType(BoardColumnHeader), matching: find.byFlowySvg(FlowySvgs.details_horizontal_s), ) .first; await tester.tap(optionsFinder); await tester.pumpAndSettle(); // Tap the hide option await tester.tap(find.byFlowySvg(FlowySvgs.hide_s)); await tester.pumpAndSettle(); int shownGroups = tester.widgetList(find.byType(BoardColumnHeader)).length; // We still show Doing, Done, No Status expect(shownGroups, 3); final hiddenCardFinder = find.byType(HiddenGroupCard); await tester.hoverOnWidget(hiddenCardFinder); await tester.tap(find.byFlowySvg(FlowySvgs.show_m)); await tester.pumpAndSettle(); shownGroups = tester.widgetList(find.byType(BoardColumnHeader)).length; expect(shownGroups, 4); }); testWidgets('delete a group', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); expect(tester.widgetList(find.byType(BoardColumnHeader)).length, 4); // tap group option button for the first group. Delete shouldn't show up await tester.tapButton( find .descendant( of: find.byType(BoardColumnHeader), matching: find.byFlowySvg(FlowySvgs.details_horizontal_s), ) .first, ); expect(find.byFlowySvg(FlowySvgs.delete_s), findsNothing); // dismiss the popup await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); // tap group option button for the first group. Delete should show up await tester.tapButton( find .descendant( of: find.byType(BoardColumnHeader), matching: find.byFlowySvg(FlowySvgs.details_horizontal_s), ) .at(1), ); expect(find.byFlowySvg(FlowySvgs.delete_s), findsOneWidget); // Tap the delete button and confirm await tester.tapButton(find.byFlowySvg(FlowySvgs.delete_s)); await tester.tapButtonWithName(LocaleKeys.space_delete.tr()); // Expect number of groups to decrease by one expect(tester.widgetList(find.byType(BoardColumnHeader)).length, 3); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/board/board_row_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:time/time.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('board row test', () { testWidgets('edit item in ToDo card', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); const name = 'Card 1'; final card1 = find.ancestor( matching: find.byType(RowCard), of: find.text(name), ); await tester.hoverOnWidget( card1, onHover: () async { final editCard = find.byType(EditCardAccessory); await tester.tapButton(editCard); }, ); await tester.showKeyboard(card1); tester.testTextInput.enterText(""); await tester.pump(300.milliseconds); tester.testTextInput.enterText("a"); await tester.pump(300.milliseconds); expect(find.text('a'), findsOneWidget); }); testWidgets('delete item in ToDo card', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); const name = 'Card 1'; final card1 = find.text(name); await tester.hoverOnWidget( card1, onHover: () async { final moreOption = find.byType(MoreCardOptionsAccessory); await tester.tapButton(moreOption); }, ); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); expect(find.text(name), findsNothing); }); testWidgets('duplicate item in ToDo card', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); const name = 'Card 1'; final card1 = find.text(name); await tester.hoverOnWidget( card1, onHover: () async { final moreOption = find.byType(MoreCardOptionsAccessory); await tester.tapButton(moreOption); }, ); await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr()); expect(find.textContaining(name, findRichText: true), findsNWidgets(2)); }); testWidgets('duplicate item in ToDo card then delete', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); const name = 'Card 1'; final card1 = find.text(name); await tester.hoverOnWidget( card1, onHover: () async { final moreOption = find.byType(MoreCardOptionsAccessory); await tester.tapButton(moreOption); }, ); await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr()); expect(find.textContaining(name, findRichText: true), findsNWidgets(2)); // get the last widget that contains the name final duplicatedCard = find.textContaining(name, findRichText: true).last; await tester.hoverOnWidget( duplicatedCard, onHover: () async { final moreOption = find.byType(MoreCardOptionsAccessory); await tester.tapButton(moreOption); }, ); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); expect(find.textContaining(name, findRichText: true), findsNWidgets(1)); }); testWidgets('add new group', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); // assert number of groups tester.assertNumberOfGroups(4); // scroll the board horizontally to ensure add new group button appears await tester.scrollBoardToEnd(); // assert and click on add new group button tester.assertNewGroupTextField(false); await tester.tapNewGroupButton(); tester.assertNewGroupTextField(true); // enter new group name and submit await tester.enterNewGroupName('needs design', submit: true); // assert number of groups has increased tester.assertNumberOfGroups(5); // assert text field has disappeared await tester.scrollBoardToEnd(); tester.assertNewGroupTextField(false); // click on add new group button await tester.tapNewGroupButton(); tester.assertNewGroupTextField(true); // type some things await tester.enterNewGroupName('needs planning', submit: false); // click on clear button and assert empty contents await tester.clearNewGroupTextField(); // press escape to cancel await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); tester.assertNewGroupTextField(false); // click on add new group button await tester.tapNewGroupButton(); tester.assertNewGroupTextField(true); // press elsewhere to cancel await tester.tap(find.byType(AppFlowyBoard)); await tester.pumpAndSettle(); tester.assertNewGroupTextField(false); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/board/board_test_runner.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'board_add_row_test.dart' as board_add_row_test; import 'board_group_test.dart' as board_group_test; import 'board_row_test.dart' as board_row_test; import 'board_field_test.dart' as board_field_test; import 'board_hide_groups_test.dart' as board_hide_groups_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Board integration tests board_row_test.main(); board_add_row_test.main(); board_group_test.main(); board_field_test.main(); board_hide_groups_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/chat/chat_page_test.dart ================================================ import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/ai_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('chat page:', () { testWidgets('send messages with default messages', (tester) async { skipAIChatWelcomePage = true; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a chat page final pageName = 'Untitled'; await tester.createNewPageWithNameUnderParent( name: pageName, layout: ViewLayoutPB.Chat, openAfterCreated: false, ); await tester.pumpAndSettle(const Duration(milliseconds: 300)); final userId = '457037009907617792'; final user = User(id: userId, lastName: 'Lucas'); final aiUserId = '0'; final aiUser = User(id: aiUserId, lastName: 'AI'); await tester.loadDefaultMessages( [ Message.text( id: '1746776401', text: 'How to use Kanban to manage tasks?', author: user, createdAt: DateTime.now().add(const Duration(seconds: 1)), ), Message.text( id: '1746776401_ans', text: 'I couldn’t find any relevant information in the sources you selected. Please try asking a different question', author: aiUser, createdAt: DateTime.now().add(const Duration(seconds: 2)), ), Message.text( id: '1746776402', text: 'How to use Kanban to manage tasks?', author: user, createdAt: DateTime.now().add(const Duration(seconds: 3)), ), Message.text( id: '1746776402_ans', text: 'I couldn’t find any relevant information in the sources you selected. Please try asking a different question', author: aiUser, createdAt: DateTime.now().add(const Duration(seconds: 4)), ), ].reversed.toList(), ); await tester.pumpAndSettle(Duration(seconds: 1)); // start chat final int messageId = 1; // send a message await tester.sendUserMessage( Message.text( id: messageId.toString(), text: 'How to use AppFlowy?', author: user, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); // receive a message await tester.receiveAIMessage( Message.text( id: '${messageId}_ans', text: '''# How to Use AppFlowy - Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile) - Create an account or sign in when you first launch the app - The main interface shows your workspace with a sidebar for navigation and a content area''', author: aiUser, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); final chatBloc = tester.getCurrentChatBloc(); expect(chatBloc.chatController.messages.length, equals(6)); }); testWidgets('send messages without default messages', (tester) async { skipAIChatWelcomePage = true; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a chat page final pageName = 'Untitled'; await tester.createNewPageWithNameUnderParent( name: pageName, layout: ViewLayoutPB.Chat, openAfterCreated: false, ); await tester.pumpAndSettle(const Duration(milliseconds: 300)); final userId = '457037009907617792'; final user = User(id: userId, lastName: 'Lucas'); final aiUserId = '0'; final aiUser = User(id: aiUserId, lastName: 'AI'); // start chat int messageId = 1; // round 1 { // send a message await tester.sendUserMessage( Message.text( id: messageId.toString(), text: 'How to use AppFlowy?', author: user, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); // receive a message await tester.receiveAIMessage( Message.text( id: '${messageId}_ans', text: '''# How to Use AppFlowy - Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile) - Create an account or sign in when you first launch the app - The main interface shows your workspace with a sidebar for navigation and a content area''', author: aiUser, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); messageId++; } // round 2 { // send a message await tester.sendUserMessage( Message.text( id: messageId.toString(), text: 'How to use AppFlowy?', author: user, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); // receive a message await tester.receiveAIMessage( Message.text( id: '${messageId}_ans', text: 'I couldn’t find any relevant information in the sources you selected. Please try asking a different question', author: aiUser, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); messageId++; } // round 3 { // send a message await tester.sendUserMessage( Message.text( id: messageId.toString(), text: 'What document formatting options are available?', author: user, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); // receive a message await tester.receiveAIMessage( Message.text( id: '${messageId}_ans', text: '# AppFlowy Document Formatting\n- Basic formatting: Bold, italic, underline, strikethrough\n- Headings: 6 levels of headings for structuring content\n- Lists: Bullet points, numbered lists, and checklists\n- Code blocks: Format text as code with syntax highlighting\n- Tables: Create and format data tables\n- Embedded content: Add images, files, and other rich media', author: aiUser, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); messageId++; } // round 4 { // send a message await tester.sendUserMessage( Message.text( id: messageId.toString(), text: 'How do I export my data from AppFlowy?', author: user, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); // receive a message await tester.receiveAIMessage( Message.text( id: '${messageId}_ans', text: '# Exporting from AppFlowy\n- Export documents in multiple formats: Markdown, HTML, PDF\n- Export databases as CSV or Excel files\n- Batch export entire workspaces for backup\n- Use the export menu (three dots → Export) on any page\n- Exported files maintain most formatting and structure', author: aiUser, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); messageId++; } // round 5 { // send a message await tester.sendUserMessage( Message.text( id: messageId.toString(), text: 'Is there a mobile version of AppFlowy?', author: user, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); // receive a message await tester.receiveAIMessage( Message.text( id: '${messageId}_ans', text: '# AppFlowy on Mobile\n- Yes, AppFlowy is available for iOS and Android devices\n- Download from the App Store or Google Play Store\n- Mobile app includes core functionality: document editing, databases, and boards\n- Offline mode allows working without internet connection\n- Sync automatically when you reconnect\n- Responsive design adapts to different screen sizes', author: aiUser, createdAt: DateTime.now(), ), ); await tester.pumpAndSettle(Duration(seconds: 1)); messageId++; } final chatBloc = tester.getCurrentChatBloc(); expect(chatBloc.chatController.messages.length, equals(10)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart ================================================ import 'data_migration/data_migration_test_runner.dart' as data_migration_test_runner; import 'database/database_test_runner.dart' as database_test_runner; import 'document/document_test_runner.dart' as document_test_runner; import 'set_env.dart' as preset_af_cloud_env_test; import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test; import 'sidebar/sidebar_search_test.dart' as sidebar_search_test; import 'sidebar/sidebar_move_page_test.dart' as sidebar_move_page_test; import 'sidebar/sidebar_rename_untitled_test.dart' as sidebar_rename_untitled_test; import 'uncategorized/uncategorized_test_runner.dart' as uncategorized_test_runner; import 'workspace/workspace_test_runner.dart' as workspace_test_runner; Future main() async { // don't remove this test, it can prevent the test from failing. { preset_af_cloud_env_test.main(); data_migration_test_runner.main(); // uncategorized uncategorized_test_runner.main(); // workspace workspace_test_runner.main(); } // sidebar // don't remove this test, it can prevent the test from failing. { preset_af_cloud_env_test.main(); sidebar_move_page_test.main(); sidebar_rename_untitled_test.main(); sidebar_icon_test.main(); sidebar_search_test.main(); } // database // don't remove this test, it can prevent the test from failing. { preset_af_cloud_env_test.main(); database_test_runner.main(); } // document // don't remove this test, it can prevent the test from failing. { preset_af_cloud_env_test.main(); document_test_runner.main(); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('appflowy cloud', () { testWidgets('anon user -> sign in -> open imported space', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapAnonymousSignInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Test Document'; await tester.createNewPageWithNameUnderParent(name: pageName); tester.expectToSeePageName(pageName); // rename the name of the anon user await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); await tester.pumpAndSettle(); await tester.enterUserName('local_user'); // Scroll to sign-in await tester.tapButton(find.byType(AccountSignInOutButton)); // sign up with Google await tester.tapGoogleLoginInButton(); // await tester.pumpAndSettle(const Duration(seconds: 16)); // open the imported space await tester.expectToSeeHomePage(); await tester.clickSpaceHeader(); // After import the anon user data, we will create a new space for it await tester.openSpace("Getting started"); await tester.openPage(pageName); await tester.pumpAndSettle(); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/data_migration_test_runner.dart ================================================ void main() async { // anon_user_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_image_test.dart ================================================ import 'dart:io'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, ResizableImage; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../../shared/constants.dart'; import '../../../shared/database_test_op.dart'; import '../../../shared/mock/mock_file_picker.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // copy link to block group('database image:', () { testWidgets('insert image', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // open the first row detail page and upload an image await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Grid, pageName: 'database image', ); await tester.openFirstRowDetailPage(); // insert an image block { await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_image.tr(), ); } // upload an image { final image = await rootBundle.load('assets/test/images/sample.jpeg'); final tempDirectory = await getTemporaryDirectory(); final imagePath = p.join(tempDirectory.path, 'sample.jpeg'); final file = File(imagePath) ..writeAsBytesSync(image.buffer.asUint8List()); mockPickFilePaths( paths: [imagePath], ); await getIt().set(KVKeys.kCloudType, '0'); await tester.tapButtonWithName( LocaleKeys.document_imageBlock_upload_placeholder.tr(), ); await tester.pumpAndSettle(); expect(find.byType(ResizableImage), findsOneWidget); final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; expect(node.type, ImageBlockKeys.type); expect(node.attributes[ImageBlockKeys.url], isNotEmpty); // remove the temp file file.deleteSync(); } }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/database/database_test_runner.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'database_image_test.dart' as database_image_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); database_image_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart ================================================ import 'package:appflowy/ai/service/ai_entities.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/ai_test_op.dart'; import '../../../shared/constants.dart'; import '../../../shared/mock/mock_ai.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('AI Writer:', () { testWidgets('the ai writer transaction should only apply in memory', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, aiRepositoryBuilder: () => MockAIRepository(), ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_aiWriter.tr(), ); expect(find.byType(AiWriterBlockComponent), findsOneWidget); // switch to another page await tester.openPage(Constants.gettingStartedPageName); // switch back to the page await tester.openPage(pageName); // expect the ai writer block is not in the document expect(find.byType(AiWriterBlockComponent), findsNothing); }); testWidgets('Improve writing', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); await tester.editor.tapLineOfEditorAt(0); // insert a paragraph final text = 'I have an apple'; await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText(text); await tester.editor.updateSelection( Selection( start: Position(path: [0]), end: Position(path: [0], offset: text.length), ), ); await tester.pumpAndSettle(); await tester.tapButton(find.byType(ImproveWritingButton)); final editorState = tester.editor.getCurrentEditorState(); final document = editorState.document; expect(document.root.children.length, 3); expect(document.root.children[1].type, ParagraphBlockKeys.type); expect( document.root.children[1].delta!.toPlainText(), 'I have an apple and a banana', ); }); testWidgets('fix grammar', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); await tester.editor.tapLineOfEditorAt(0); // insert a paragraph final text = 'We didn’t had enough money'; await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText(text); await tester.editor.updateSelection( Selection( start: Position(path: [0]), end: Position(path: [0], offset: text.length), ), ); await tester.pumpAndSettle(); await tester.selectAIWriter(AiWriterCommand.fixSpellingAndGrammar); final editorState = tester.editor.getCurrentEditorState(); final document = editorState.document; expect(document.root.children.length, 3); expect(document.root.children[1].type, ParagraphBlockKeys.type); expect( document.root.children[1].delta!.toPlainText(), 'We didn’t have enough money', ); }); testWidgets('ask ai', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudDevelop, aiRepositoryBuilder: () => MockAIRepository( validator: _CompletionHistoryValidator(), ), ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); await tester.editor.tapLineOfEditorAt(0); // insert a paragraph final text = 'What is TPU?'; await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText(text); await tester.editor.updateSelection( Selection( start: Position(path: [0]), end: Position(path: [0], offset: text.length), ), ); await tester.pumpAndSettle(); await tester.selectAIWriter(AiWriterCommand.userQuestion); await tester.enterTextInPromptTextField("Explain the concept of TPU"); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); // await tester.selectModel("GPT-4o-mini"); await tester.enterTextInPromptTextField("How about GPU?"); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); }); }); } class _CompletionHistoryValidator extends StreamCompletionValidator { static const _expectedResponses = { 1: "What is TPU?", 3: [ "What is TPU?", "Explain the concept of TPU", "TPU is a tensor processing unit that is designed to accelerate machine", ], 5: [ "What is TPU?", "Explain the concept of TPU", "TPU is a tensor processing unit that is designed to accelerate machine", "How about GPU?", "GPU is a graphics processing unit that is designed to accelerate machine learning tasks.", ], }; @override bool validate( String text, String? objectId, CompletionTypePB completionType, PredefinedFormat? format, List sourceIds, List history, ) { assert(completionType == CompletionTypePB.UserQuestion); if (history.isEmpty) return false; final expectedMessages = _expectedResponses[history.length]; if (expectedMessages == null) return false; if (expectedMessages is String) { _assertMessage(history[0].content, expectedMessages); return true; } else if (expectedMessages is List) { for (var i = 0; i < expectedMessages.length; i++) { _assertMessage(history[i].content, expectedMessages[i]); } return true; } return false; } void _assertMessage(String actual, String expected) { assert( actual.trim() == expected, "expected '$expected', but got '${actual.trim()}'", ); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_copy_link_to_block_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/document_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // copy link to block group('copy link to block:', () { testWidgets('copy link to check if the clipboard has the correct content', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // open getting started page await tester.openPage(Constants.gettingStartedPageName); await tester.editor.copyLinkToBlock([0]); await tester.pumpAndSettle(Durations.short1); // check the clipboard final content = await Clipboard.getData(Clipboard.kTextPlain); expect( content?.text, matches(appflowySharePageLinkPattern), ); }); testWidgets('copy link to block(another page) and paste it in doc', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // open getting started page await tester.openPage(Constants.gettingStartedPageName); await tester.editor.copyLinkToBlock([0]); // create a new page and paste it const pageName = 'copy link to block'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // paste the link to the new page await tester.editor.tapLineOfEditorAt(0); await tester.editor.paste(); await tester.pumpAndSettle(); // check the content of the block final node = tester.editor.getNodeAtPath([0]); final delta = node.delta!; final insert = (delta.first as TextInsert).text; final attributes = delta.first.attributes; expect(insert, MentionBlockKeys.mentionChar); final mention = attributes?[MentionBlockKeys.mention] as Map; expect(mention[MentionBlockKeys.type], MentionType.page.name); expect(mention[MentionBlockKeys.blockId], isNotNull); expect(mention[MentionBlockKeys.pageId], isNotNull); expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.textContaining( Constants.gettingStartedPageName, findRichText: true, ), ), findsOneWidget, ); expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.textContaining( // the pasted block content is 'Welcome to AppFlowy' 'Welcome to AppFlowy', findRichText: true, ), ), findsOneWidget, ); // tap the mention block to jump to the page await tester.tapButton(find.byType(MentionPageBlock)); await tester.pumpAndSettle(); // expect to go to the getting started page final documentPage = find.byType(DocumentPage); expect(documentPage, findsOneWidget); expect( tester.widget(documentPage).view.name, Constants.gettingStartedPageName, ); // and the block is selected expect( tester.widget(documentPage).initialBlockId, mention[MentionBlockKeys.blockId], ); expect( tester.editor.getCurrentEditorState().selection, Selection.collapsed( Position( path: [0], ), ), ); }); testWidgets('copy link to block(same page) and paste it in doc', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // create a new page and paste it const pageName = 'copy link to block'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // copy the link to block from the first line const inputText = 'Hello World'; await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText(inputText); await tester.ime.insertCharacter('\n'); await tester.pumpAndSettle(); await tester.editor.copyLinkToBlock([0]); // paste the link to the second line await tester.editor.tapLineOfEditorAt(1); await tester.editor.paste(); await tester.pumpAndSettle(); // check the content of the block final node = tester.editor.getNodeAtPath([1]); final delta = node.delta!; final insert = (delta.first as TextInsert).text; final attributes = delta.first.attributes; expect(insert, MentionBlockKeys.mentionChar); final mention = attributes?[MentionBlockKeys.mention] as Map; expect(mention[MentionBlockKeys.type], MentionType.page.name); expect(mention[MentionBlockKeys.blockId], isNotNull); expect(mention[MentionBlockKeys.pageId], isNotNull); expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.textContaining( inputText, findRichText: true, ), ), findsNWidgets(2), ); // edit the pasted block await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText('!'); await tester.pumpAndSettle(); // check the content of the block expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.textContaining( '$inputText!', findRichText: true, ), ), findsNWidgets(2), ); // tap the mention block await tester.tapButton(find.byType(MentionPageBlock)); expect( tester.editor.getCurrentEditorState().selection, Selection.collapsed( Position( path: [0], ), ), ); }); testWidgets('''1. copy link to block from another page 2. paste the link to the new page 3. delete the original page 4. check the content of the block, it should be no access to the page ''', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // open getting started page await tester.openPage(Constants.gettingStartedPageName); await tester.editor.copyLinkToBlock([0]); // create a new page and paste it const pageName = 'copy link to block'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // paste the link to the new page await tester.editor.tapLineOfEditorAt(0); await tester.editor.paste(); await tester.pumpAndSettle(); // tap the mention block to jump to the page await tester.tapButton(find.byType(MentionPageBlock)); await tester.pumpAndSettle(); // expect to go to the getting started page final documentPage = find.byType(DocumentPage); expect(documentPage, findsOneWidget); expect( tester.widget(documentPage).view.name, Constants.gettingStartedPageName, ); // delete the getting started page await tester.hoverOnPageName( Constants.gettingStartedPageName, onHover: () async => tester.tapDeletePageButton(), ); tester.expectToSeeDocumentBanner(); tester.expectNotToSeePageName(gettingStarted); // delete the page permanently await tester.tapDeletePermanentlyButton(); // go back the page await tester.openPage(pageName); await tester.pumpAndSettle(); // check the content of the block // it should be no access to the page expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.findTextInFlowyText( LocaleKeys.document_mention_noAccess.tr(), ), ), findsOneWidget, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document option actions:', () { testWidgets('drag block to the top', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // open getting started page await tester.openPage(Constants.gettingStartedPageName); // before move final beforeMoveBlock = tester.editor.getNodeAtPath([1]); // move the desktop guide to the top, above the getting started await tester.editor.dragBlock( [1], const Offset(20, -80), ); // wait for the move animation to complete await tester.pumpAndSettle(Durations.short1); // check if the block is moved to the top final afterMoveBlock = tester.editor.getNodeAtPath([0]); expect(afterMoveBlock.delta, beforeMoveBlock.delta); }); testWidgets('drag block to other block\'s child', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // open getting started page await tester.openPage(Constants.gettingStartedPageName); // before move final beforeMoveBlock = tester.editor.getNodeAtPath([10]); // move the checkbox to the child of the block at path [9] await tester.editor.dragBlock( [10], const Offset(120, -20), ); // wait for the move animation to complete await tester.pumpAndSettle(Durations.short1); // check if the block is moved to the child of the block at path [9] final afterMoveBlock = tester.editor.getNodeAtPath([9, 0]); expect(afterMoveBlock.delta, beforeMoveBlock.delta); }); testWidgets('hover on the block and delete it', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // open getting started page await tester.openPage(Constants.gettingStartedPageName); // before delete final path = [1]; final beforeDeletedBlock = tester.editor.getNodeAtPath(path); // hover on the block and delete it final optionButton = find.byWidgetPredicate( (widget) => widget is DraggableOptionButton && widget.blockComponentContext.node.path.equals(path), ); await tester.hoverOnWidget( optionButton, onHover: () async { // click the delete button await tester.tapButton(optionButton); }, ); await tester.pumpAndSettle(Durations.short1); // click the delete button final deleteButton = find.findTextInFlowyText(LocaleKeys.button_delete.tr()); await tester.tapButton(deleteButton); // wait for the deletion await tester.pumpAndSettle(Durations.short1); // check if the block is deleted final afterDeletedBlock = tester.editor.getNodeAtPath([1]); expect(afterDeletedBlock.id, isNot(equals(beforeDeletedBlock.id))); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_publish_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/shared/share/publish_tab.dart'; import 'package:appflowy/plugins/shared/share/share_menu.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Publish:', () { testWidgets('publish document', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // open the publish menu await tester.openPublishMenu(); // publish the document final publishButton = find.byType(PublishButton); final unpublishButton = find.byType(UnPublishButton); await tester.tapButton(publishButton); // expect to see unpublish, visit site and manage all sites button expect(unpublishButton, findsOneWidget); expect(find.text(LocaleKeys.shareAction_visitSite.tr()), findsOneWidget); // unpublish the document await tester.tapButton(unpublishButton); // expect to see publish button expect(publishButton, findsOneWidget); }); testWidgets('rename path name', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // open the publish menu await tester.openPublishMenu(); // publish the document final publishButton = find.byType(PublishButton); await tester.tapButton(publishButton); // rename the path name final inputField = find.descendant( of: find.byType(ShareMenu), matching: find.byType(TextField), ); // rename with invalid name await tester.tap(inputField); await tester.enterText(inputField, '&&&&????'); await tester.tapButton(find.text(LocaleKeys.button_save.tr())); await tester.pumpAndSettle(); // expect to see the toast with error message final errorToast1 = find.text( LocaleKeys.settings_sites_error_publishNameContainsInvalidCharacters .tr(), ); await tester.pumpUntilFound(errorToast1); await tester.pumpUntilNotFound(errorToast1); // rename with long name await tester.tap(inputField); await tester.enterText(inputField, 'long-path-name' * 200); await tester.tapButton(find.text(LocaleKeys.button_save.tr())); await tester.pumpAndSettle(); // expect to see the toast with error message final errorToast2 = find.text( LocaleKeys.settings_sites_error_publishNameTooLong.tr(), ); await tester.pumpUntilFound(errorToast2); await tester.pumpUntilNotFound(errorToast2); // rename with empty name await tester.tap(inputField); await tester.enterText(inputField, ''); await tester.tapButton(find.text(LocaleKeys.button_save.tr())); await tester.pumpAndSettle(); // expect to see the toast with error message final errorToast3 = find.text( LocaleKeys.settings_sites_error_publishNameCannotBeEmpty.tr(), ); await tester.pumpUntilFound(errorToast3); await tester.pumpUntilNotFound(errorToast3); // input the new path name await tester.tap(inputField); await tester.enterText(inputField, 'new-path-name'); // click save button await tester.tapButton(find.text(LocaleKeys.button_save.tr())); await tester.pumpAndSettle(); // expect to see the toast with success message final successToast = find.text( LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); await tester.pumpUntilFound(successToast); await tester.pumpUntilNotFound(successToast); // click the copy link button await tester.tapButton( find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg.path == FlowySvgs.m_toolbar_link_m.path, ), ); await tester.pumpAndSettle(); // check the clipboard has the link final content = await Clipboard.getData(Clipboard.kTextPlain); expect( content?.text?.contains('new-path-name'), isTrue, ); }); testWidgets('re-publish the document', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // open the publish menu await tester.openPublishMenu(); // publish the document final publishButton = find.byType(PublishButton); await tester.tapButton(publishButton); // rename the path name final inputField = find.descendant( of: find.byType(ShareMenu), matching: find.byType(TextField), ); // input the new path name const newName = 'new-path-name'; await tester.enterText(inputField, newName); // click save button await tester.tapButton(find.text(LocaleKeys.button_save.tr())); await tester.pumpAndSettle(); // expect to see the toast with success message final successToast = find.text( LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); await tester.pumpUntilNotFound(successToast); // unpublish the document final unpublishButton = find.byType(UnPublishButton); await tester.tapButton(unpublishButton); final unpublishSuccessToast = find.text( LocaleKeys.publish_unpublishSuccessfully.tr(), ); await tester.pumpUntilNotFound(unpublishSuccessToast); // re-publish the document await tester.tapButton(publishButton); // expect to see the toast with success message final rePublishSuccessToast = find.text( LocaleKeys.publish_publishSuccessfully.tr(), ); await tester.pumpUntilNotFound(rePublishSuccessToast); // check the clipboard has the link final content = await Clipboard.getData(Clipboard.kTextPlain); expect( content?.text?.contains(newName), isTrue, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_test_runner.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'document_ai_writer_test.dart' as document_ai_writer_test; import 'document_copy_link_to_block_test.dart' as document_copy_link_to_block_test; import 'document_option_actions_test.dart' as document_option_actions_test; import 'document_publish_test.dart' as document_publish_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); document_option_actions_test.main(); document_copy_link_to_block_test.main(); document_publish_test.main(); document_ai_writer_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/set_env.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; // This test is meaningless, just for preventing the CI from failing. void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Preset cloud env', () { testWidgets('use self-hosted cloud', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.pumpAndSettle(); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_icon_test.dart ================================================ import 'dart:convert'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/emoji.dart'; import '../../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); testWidgets('Change slide bar space icon', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); final emojiIconData = await tester.loadIcon(); final firstIcon = IconsData.fromJson(jsonDecode(emojiIconData.emoji)); await tester.hoverOnWidget( find.byType(SidebarSpaceHeader), onHover: () async { final moreOption = find.byType(SpaceMorePopup); await tester.tapButton(moreOption); expect(find.byType(FlowyIconEmojiPicker), findsNothing); await tester.tapSvgButton(SpaceMoreActionType.changeIcon.leftIconSvg); expect(find.byType(FlowyIconEmojiPicker), findsOneWidget); }, ); final icons = find.byWidgetPredicate( (w) => w is FlowySvg && w.svgString == firstIcon.svgString, ); expect(icons, findsOneWidget); await tester.tapIcon(EmojiIconData.icon(firstIcon)); final spaceHeader = find.byType(SidebarSpaceHeader); final spaceIcon = find.descendant( of: spaceHeader, matching: find.byWidgetPredicate( (w) => w is FlowySvg && w.svgString == firstIcon.svgString, ), ); expect(spaceIcon, findsOneWidget); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_move_page_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('sidebar move page: ', () { testWidgets('create a new document and move it to Getting started', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // click the ... button and move to Getting started await tester.hoverOnPageName( pageName, onHover: () async { await tester.tapPageOptionButton(); await tester.tapButtonWithName( LocaleKeys.disclosureAction_moveTo.tr(), ); }, ); // expect to see two pages // one is in the sidebar, the other is in the move to page list // 1. Getting started // 2. To-dos final gettingStarted = find.findTextInFlowyText( Constants.gettingStartedPageName, ); final toDos = find.findTextInFlowyText(Constants.toDosPageName); await tester.pumpUntilFound(gettingStarted); await tester.pumpUntilFound(toDos); expect(gettingStarted, findsNWidgets(2)); // skip the length check on Linux temporarily, // because it failed in expect check but the previous pumpUntilFound is successful if (!UniversalPlatform.isLinux) { expect(toDos, findsNWidgets(2)); // hover on the todos page, and will see a forbidden icon await tester.hoverOnWidget( toDos.last, onHover: () async { final tooltips = find.byTooltip( LocaleKeys.space_cannotMovePageToDatabase.tr(), ); expect(tooltips, findsOneWidget); }, ); await tester.pumpAndSettle(); } // Attempt right-click on the page name and expect not to see await tester.tap(gettingStarted.last, buttons: kSecondaryButton); await tester.pumpAndSettle(); expect( find.text(LocaleKeys.disclosureAction_moveTo.tr()), findsOneWidget, ); // move the current page to Getting started await tester.tapButton( gettingStarted.last, ); await tester.pumpAndSettle(); // after moving, expect to not see the page name in the sidebar final page = tester.findPageName(pageName); expect(page, findsNothing); // click to expand the getting started page await tester.expandOrCollapsePage( pageName: Constants.gettingStartedPageName, layout: ViewLayoutPB.Document, ); await tester.pumpAndSettle(); // expect to see the page name in the getting started page final pageInGettingStarted = tester.findPageName( pageName, parentName: Constants.gettingStartedPageName, ); expect(pageInGettingStarted, findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_rename_untitled_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('Rename empty name view (untitled)', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, ); // click the ... button and open rename dialog await tester.hoverOnPageName( ViewLayoutPB.Document.defaultName, onHover: () async { await tester.tapPageOptionButton(); await tester.tapButtonWithName( LocaleKeys.disclosureAction_rename.tr(), ); }, ); await tester.pumpAndSettle(); expect(find.byType(AFTextFieldDialog), findsOneWidget); final textField = tester.widget( find.descendant( of: find.byType(AFTextFieldDialog), matching: find.byType(AFTextField), ), ); expect( textField.controller!.text, LocaleKeys.menuAppHeader_defaultNewPageName.tr(), ); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_search_test.dart ================================================ import 'package:appflowy/ai/widgets/prompt_input/desktop_prompt_input.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); }); testWidgets('Test for searching', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); /// show searching page final searchingButton = find.text(LocaleKeys.search_label.tr()); await tester.tapButton(searchingButton); final askAIButton = find.byType(SearchAskAiEntrance); expect(askAIButton, findsOneWidget); /// searching for [gettingStarted] final searchField = find.byType(SearchField); final textFiled = find.descendant(of: searchField, matching: find.byType(TextField)); await tester.enterText(textFiled, gettingStarted); await tester.pumpAndSettle(Duration(seconds: 1)); /// tap ask AI button await tester.tapButton(askAIButton); expect(find.byType(DesktopPromptInput), findsOneWidget); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('appflowy cloud auth', () { testWidgets('sign in', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); }); testWidgets('sign out', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); // Open the setting page and sign out await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); // Scroll to sign-out await tester.scrollUntilVisible( find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); await tester.tapButton(find.byType(AccountSignInOutButton)); tester.expectToSeeText(LocaleKeys.button_yes.tr()); await tester.tapButtonWithName(LocaleKeys.button_yes.tr()); // Go to the sign in page again await tester.pumpAndSettle(const Duration(seconds: 5)); tester.expectToSeeGoogleLoginButton(); }); testWidgets('sign in as anonymous', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapSignInAsGuest(); // should not see the sync setting page when sign in as anonymous await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); await tester.tapButton(find.byType(AccountSignInOutButton)); tester.expectToSeeGoogleLoginButton(); }); testWidgets('enable sync', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); // Open the setting page and sign out await tester.openSettings(); await tester.openSettingsPage(SettingsPage.cloud); await tester.pumpAndSettle(); // the switch should be on by default tester.assertAppFlowyCloudEnableSyncSwitchValue(true); await tester.toggleEnableSync(AppFlowyCloudEnableSync); // wait for the switch animation await tester.wait(250); // the switch should be off tester.assertAppFlowyCloudEnableSyncSwitchValue(false); // the switch should be on after toggling await tester.toggleEnableSync(AppFlowyCloudEnableSync); tester.assertAppFlowyCloudEnableSyncSwitchValue(true); await tester.wait(250); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/document_sync_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final email = '${uuid()}@appflowy.io'; const inputContent = 'Hello world, this is a test document'; // The test will create a new document called Sample, and sync it to the server. // Then the test will logout the user, and login with the same user. The data will // be synced from the server. group('appflowy cloud document', () { testWidgets('sync local docuemnt to server', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, email: email, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // create a new document called Sample await tester.createNewPage(); // focus on the editor await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText(inputContent); expect(find.text(inputContent, findRichText: true), findsOneWidget); // 6 seconds for data sync await tester.waitForSeconds(6); await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); await tester.logout(); }); testWidgets('sync doc from server', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, email: email, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePage(); // the latest document will be opened, so the content must be the inputContent await tester.pumpAndSettle(); expect(find.text(inputContent, findRichText: true), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/uncategorized_test_runner.dart ================================================ import 'appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test; import 'user_setting_sync_test.dart' as user_sync_test; void main() async { appflowy_cloud_auth_test.main(); user_sync_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/user_setting_sync_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/account_user_profile.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final email = '${uuid()}@appflowy.io'; const name = 'nathan'; group('appflowy cloud setting', () { testWidgets('sync user name and icon to server', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, email: email, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); await tester.enterUserName(name); await tester.pumpAndSettle(const Duration(seconds: 6)); await tester.logout(); await tester.pumpAndSettle(const Duration(seconds: 2)); }); }); testWidgets('get user icon and name from server', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, email: email, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); await tester.pumpAndSettle(); await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); // Verify name final profileSetting = tester.widget(find.byType(AccountUserProfile)) as AccountUserProfile; expect(profileSetting.name, name); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/util.dart'; import '../../../shared/workspace.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); const icon = '😄'; const name = 'AppFlowy'; final email = '${uuid()}@appflowy.io'; testWidgets('change name and icon', (tester) async { // only run the test when the feature flag is on if (!FeatureFlag.collaborativeWorkspace.isOn) { return; } await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, email: email, // use the same email to check the next test ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); var workspaceIcon = tester.widget( find.byType(WorkspaceIcon), ); expect(workspaceIcon.workspaceIcon, ''); await tester.openWorkspaceMenu(); await tester.changeWorkspaceIcon(icon); await tester.changeWorkspaceName(name); await tester.pumpUntilNotFound( find.text(LocaleKeys.workspace_renameSuccess.tr()), ); workspaceIcon = tester.widget( find.byType(WorkspaceIcon), ); expect(workspaceIcon.workspaceIcon, icon); expect(workspaceIcon.workspaceName, name); }); testWidgets('verify the result again after relaunching', (tester) async { // only run the test when the feature flag is on if (!FeatureFlag.collaborativeWorkspace.isOn) { return; } await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, email: email, // use the same email to check the next test ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // check the result again final workspaceIcon = tester.widget( find.byType(WorkspaceIcon), ); expect(workspaceIcon.workspaceIcon, icon); expect(workspaceIcon.workspaceName, name); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/collaborative_workspace_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('collaborative workspace:', () { // combine the create and delete workspace test to reduce the time testWidgets('create a new workspace, open it and then delete it', (tester) async { // only run the test when the feature flag is on if (!FeatureFlag.collaborativeWorkspace.isOn) { return; } await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const name = 'AppFlowy.IO'; // the workspace will be opened after created await tester.createCollaborativeWorkspace(name); final loading = find.byType(Loading); await tester.pumpUntilNotFound(loading); // delete the newly created workspace await tester.openCollaborativeWorkspaceMenu(); final items = find.byType(WorkspaceMenuItem); expect(items, findsNWidgets(2)); final lastWorkspace = items.last; expect( tester.widget(lastWorkspace).workspace.name, name, ); await tester.hoverOnWidget( lastWorkspace, onHover: () async { // click the more button final moreButton = find.byType(WorkspaceMoreActionList); expect(moreButton, findsOneWidget); await tester.tapButton(moreButton); // click the delete button final deleteButton = find.text(LocaleKeys.button_delete.tr()); expect(deleteButton, findsOneWidget); await tester.tapButton(deleteButton); // see the delete confirm dialog final confirm = find.text(LocaleKeys.workspace_deleteWorkspaceHintText.tr()); expect(confirm, findsOneWidget); await tester.tapButton(find.text(LocaleKeys.button_ok.tr())); // delete success final success = find.text(LocaleKeys.workspace_createSuccess.tr()); await tester.pumpUntilFound(success); }, ); }); testWidgets('check the member count immediately after creating a workspace', (tester) async { // only run the test when the feature flag is on if (!FeatureFlag.collaborativeWorkspace.isOn) { return; } await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const name = 'AppFlowy.IO'; // the workspace will be opened after created await tester.createCollaborativeWorkspace(name); final loading = find.byType(Loading); await tester.pumpUntilNotFound(loading); await tester.openCollaborativeWorkspaceMenu(); // expect to see the member count final memberCount = find.text('1 member'); expect(memberCount, findsAny); }); testWidgets('workspace menu popover behavior test', (tester) async { // only run the test when the feature flag is on if (!FeatureFlag.collaborativeWorkspace.isOn) { return; } await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const name = 'AppFlowy.IO'; // the workspace will be opened after created await tester.createCollaborativeWorkspace(name); final loading = find.byType(Loading); await tester.pumpUntilNotFound(loading); await tester.openCollaborativeWorkspaceMenu(); // hover on the workspace and click the more button final workspaceItem = find.byWidgetPredicate( (w) => w is WorkspaceMenuItem && w.workspace.name == name, ); // the workspace menu shouldn't conflict with logout await tester.hoverOnWidget( workspaceItem, onHover: () async { final moreButton = find.byWidgetPredicate( (w) => w is WorkspaceMoreActionList && w.workspace.name == name, ); expect(moreButton, findsOneWidget); await tester.tapButton(moreButton); expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); final logoutButton = find.byType(WorkspaceMoreButton); await tester.tapButton(logoutButton); expect(find.text(LocaleKeys.button_logout.tr()), findsOneWidget); expect(moreButton, findsNothing); await tester.tapButton(moreButton); expect(find.text(LocaleKeys.button_logout.tr()), findsNothing); expect(moreButton, findsOneWidget); }, ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); // clicking on the more action button for the same workspace shouldn't do // anything await tester.openCollaborativeWorkspaceMenu(); await tester.hoverOnWidget( workspaceItem, onHover: () async { final moreButton = find.byWidgetPredicate( (w) => w is WorkspaceMoreActionList && w.workspace.name == name, ); expect(moreButton, findsOneWidget); await tester.tapButton(moreButton); expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); // click it again await tester.tapButton(moreButton); // nothing should happen expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); }, ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); // clicking on the more button of another workspace should close the menu // for this one await tester.openCollaborativeWorkspaceMenu(); final moreButton = find.byWidgetPredicate( (w) => w is WorkspaceMoreActionList && w.workspace.name == name, ); await tester.hoverOnWidget( workspaceItem, onHover: () async { expect(moreButton, findsOneWidget); await tester.tapButton(moreButton); expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); }, ); final otherWorspaceItem = find.byWidgetPredicate( (w) => w is WorkspaceMenuItem && w.workspace.name != name, ); final otherMoreButton = find.byWidgetPredicate( (w) => w is WorkspaceMoreActionList && w.workspace.name != name, ); await tester.hoverOnWidget( otherWorspaceItem, onHover: () async { expect(otherMoreButton, findsOneWidget); await tester.tapButton(otherMoreButton); expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget); expect(moreButton, findsNothing); }, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/share_menu_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/plugins/shared/share/share_menu.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Share menu:', () { testWidgets('share tab', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // click the share button await tester.tapShareButton(); // expect the share menu is shown final shareMenu = find.byType(ShareMenu); expect(shareMenu, findsOneWidget); // click the copy link button final copyLinkButton = find.textContaining( LocaleKeys.button_copyLink.tr(), ); await tester.tapButton(copyLinkButton); // read the clipboard content final clipboardContent = await getIt().getData(); final plainText = clipboardContent.plainText; expect( plainText, matches(appflowySharePageLinkPattern), ); final shareValues = plainText! .replaceAll('${ShareConstants.defaultBaseWebDomain}/app/', '') .split('/'); final workspaceId = shareValues[0]; expect(workspaceId, isNotEmpty); final pageId = shareValues[1]; expect(pageId, isNotEmpty); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/tabs_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Tabs', () { testWidgets('close other tabs before opening a new workspace', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const name = 'AppFlowy.IO'; // the workspace will be opened after created await tester.createCollaborativeWorkspace(name); final loading = find.byType(Loading); await tester.pumpUntilNotFound(loading); // create new tabs in the workspace expect(find.byType(FlowyTab), findsNothing); const documentOneName = 'document one'; const documentTwoName = 'document two'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: documentOneName, ); await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: documentTwoName, ); /// Open second menu item in a new tab await tester.openAppInNewTab(documentOneName, ViewLayoutPB.Document); /// Open third menu item in a new tab await tester.openAppInNewTab(documentTwoName, ViewLayoutPB.Document); expect( find.descendant( of: find.byType(TabsManager), matching: find.byType(FlowyTab), ), findsNWidgets(2), ); // switch to the another workspace final Finder items = find.byType(WorkspaceMenuItem); await tester.openCollaborativeWorkspaceMenu(); await tester.pumpUntilFound(items); expect(items, findsNWidgets(2)); // open the first workspace await tester.tap(items.first); await tester.pumpUntilNotFound(loading); expect(find.byType(FlowyTab), findsNothing); }); testWidgets('the space view should not be opened', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); expect(find.byType(AppFlowyEditorPage), findsOneWidget); expect(find.text('Blank page'), findsNothing); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_icon_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/util.dart'; import '../../../shared/workspace.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('workspace icon:', () { testWidgets('remove icon from workspace', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); await tester.openWorkspaceMenu(); // click the workspace icon await tester.tapButton( find.descendant( of: find.byType(WorkspaceMenuItem), matching: find.byType(WorkspaceIcon), ), ); // click the remove icon button await tester.tapButton( find.text(LocaleKeys.button_remove.tr()), ); // nothing should happen expect( find.text(LocaleKeys.workspace_updateIconSuccess.tr()), findsNothing, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_settings_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/shared/share/publish_tab.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('workspace settings: ', () { testWidgets( 'change document width', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); await tester.openSettings(); await tester.openSettingsPage(SettingsPage.workspace); final documentWidthSettings = find.findTextInFlowyText( LocaleKeys.settings_appearance_documentSettings_width.tr(), ); final scrollable = find.ancestor( of: find.byType(SettingsWorkspaceView), matching: find.descendant( of: find.byType(SingleChildScrollView), matching: find.byType(Scrollable), ), ); await tester.scrollUntilVisible( documentWidthSettings, 0, scrollable: scrollable, ); await tester.pumpAndSettle(); // change the document width final slider = find.byType(Slider); final oldValue = tester.widget(slider).value; await tester.drag(slider, const Offset(-100, 0)); await tester.pumpAndSettle(); // check the document width is changed expect(tester.widget(slider).value, lessThan(oldValue)); // click the reset button final resetButton = find.descendant( of: find.byType(DocumentPaddingSetting), matching: find.byType(SettingsResetButton), ); await tester.tap(resetButton); await tester.pumpAndSettle(); // check the document width is reset expect( tester.widget(slider).value, EditorStyleCustomizer.maxDocumentWidth, ); }, ); }); group('sites settings:', () { testWidgets( 'manage published page, set it as homepage, remove the homepage', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // open the publish menu await tester.openPublishMenu(); // publish the document await tester.tapButton(find.byType(PublishButton)); // click empty area to close the publish menu await tester.tapAt(Offset.zero); await tester.pumpAndSettle(); // check if the page is published in sites page await tester.openSettings(); await tester.openSettingsPage(SettingsPage.sites); // wait the backend return the sites data await tester.wait(1000); // check if the page is published in sites page final pageItem = find.byWidgetPredicate( (widget) => widget is PublishedViewItem && widget.publishInfoView.view.name == pageName, ); if (pageItem.evaluate().isEmpty) { return; } expect(pageItem, findsOneWidget); // comment it out because it's not allowed to update the namespace in free plan // // set it to homepage // await tester.tapButton( // find.textContaining( // LocaleKeys.settings_sites_selectHomePage.tr(), // ), // ); // await tester.tapButton( // find.descendant( // of: find.byType(SelectHomePageMenu), // matching: find.text(pageName), // ), // ); // await tester.pumpAndSettle(); // // check if the page is set to homepage // final homePageItem = find.descendant( // of: find.byType(DomainItem), // matching: find.text(pageName), // ); // expect(homePageItem, findsOneWidget); // // remove the homepage // await tester.tapButton(find.byType(DomainMoreAction)); // await tester.tapButton( // find.text(LocaleKeys.settings_sites_removeHomepage.tr()), // ); // await tester.pumpAndSettle(); // // check if the page is removed from homepage // expect(homePageItem, findsNothing); }); testWidgets('update namespace', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // check if the page is published in sites page await tester.openSettings(); await tester.openSettingsPage(SettingsPage.sites); // wait the backend return the sites data await tester.wait(1000); // update the domain final domainMoreAction = find.byType(DomainMoreAction); await tester.tapButton(domainMoreAction); final updateNamespaceButton = find.text( LocaleKeys.settings_sites_updateNamespace.tr(), ); await tester.pumpUntilFound(updateNamespaceButton); // click the update namespace button await tester.tapButton(updateNamespaceButton); // comment it out because it's not allowed to update the namespace in free plan // expect to see the dialog // await tester.updateNamespace('&&&???'); // // need to upgrade to pro plan to update the namespace // final errorToast = find.text( // LocaleKeys.settings_sites_error_proPlanLimitation.tr(), // ); // await tester.pumpUntilFound(errorToast); // expect(errorToast, findsOneWidget); // await tester.pumpUntilNotFound(errorToast); // comment it out because it's not allowed to update the namespace in free plan // // short namespace // await tester.updateNamespace('a'); // // expect to see the toast with error message // final errorToast2 = find.text( // LocaleKeys.settings_sites_error_namespaceTooShort.tr(), // ); // await tester.pumpUntilFound(errorToast2); // expect(errorToast2, findsOneWidget); // await tester.pumpUntilNotFound(errorToast2); // // valid namespace // await tester.updateNamespace('AppFlowy'); // // expect to see the toast with success message // final successToast = find.text( // LocaleKeys.settings_sites_success_namespaceUpdated.tr(), // ); // await tester.pumpUntilFound(successToast); // expect(successToast, findsOneWidget); }); testWidgets(''' More actions for published page: 1. visit site 2. copy link 3. settings 4. unpublish 5. custom url ''', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); const pageName = 'Document'; await tester.createNewPageInSpace( spaceName: Constants.generalSpaceName, layout: ViewLayoutPB.Document, pageName: pageName, ); // open the publish menu await tester.openPublishMenu(); // publish the document await tester.tapButton(find.byType(PublishButton)); // click empty area to close the publish menu await tester.tapAt(Offset.zero); await tester.pumpAndSettle(); // check if the page is published in sites page await tester.openSettings(); await tester.openSettingsPage(SettingsPage.sites); // wait the backend return the sites data await tester.wait(2000); // check if the page is published in sites page final pageItem = find.byWidgetPredicate( (widget) => widget is PublishedViewItem && widget.publishInfoView.view.name == pageName, ); if (pageItem.evaluate().isEmpty) { return; } expect(pageItem, findsOneWidget); final copyLinkItem = find.text(LocaleKeys.shareAction_copyLink.tr()); final customUrlItem = find.text(LocaleKeys.settings_sites_customUrl.tr()); final unpublishItem = find.text(LocaleKeys.shareAction_unPublish.tr()); // custom url final publishMoreAction = find.byType(PublishedViewMoreAction); // click the copy link button { await tester.tapButton(publishMoreAction); await tester.pumpAndSettle(); await tester.pumpUntilFound(copyLinkItem); await tester.tapButton(copyLinkItem); await tester.pumpAndSettle(); await tester.pumpUntilNotFound(copyLinkItem); final clipboardContent = await getIt().getData(); final plainText = clipboardContent.plainText; expect( plainText, contains(pageName), ); } // custom url { await tester.tapButton(publishMoreAction); await tester.pumpAndSettle(); await tester.pumpUntilFound(customUrlItem); await tester.tapButton(customUrlItem); await tester.pumpAndSettle(); await tester.pumpUntilNotFound(customUrlItem); // see the custom url dialog final customUrlDialog = find.byType(PublishedViewSettingsDialog); expect(customUrlDialog, findsOneWidget); // rename the custom url final textField = find.descendant( of: customUrlDialog, matching: find.byType(TextField), ); await tester.enterText(textField, 'hello-world'); await tester.pumpAndSettle(); // click the save button final saveButton = find.descendant( of: customUrlDialog, matching: find.text(LocaleKeys.button_save.tr()), ); await tester.tapButton(saveButton); await tester.pumpAndSettle(); // expect to see the toast with success message final successToast = find.text( LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); await tester.pumpUntilFound(successToast); expect(successToast, findsOneWidget); } // unpublish { await tester.tapButton(publishMoreAction); await tester.pumpAndSettle(); await tester.pumpUntilFound(unpublishItem); await tester.tapButton(unpublishItem); await tester.pumpAndSettle(); await tester.pumpUntilNotFound(unpublishItem); // expect to see the toast with success message final successToast = find.text( LocaleKeys.publish_unpublishSuccessfully.tr(), ); await tester.pumpUntilFound(successToast); expect(successToast, findsOneWidget); await tester.pumpUntilNotFound(successToast); // check if the page is unpublished in sites page expect(pageItem, findsNothing); } }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_test_runner.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'change_name_and_icon_test.dart' as change_name_and_icon_test; import 'collaborative_workspace_test.dart' as collaborative_workspace_test; import 'share_menu_test.dart' as share_menu_test; import 'tabs_test.dart' as tabs_test; import 'workspace_icon_test.dart' as workspace_icon_test; // import 'workspace_settings_test.dart' as workspace_settings_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Enable it after implementing the share feature // workspace_settings_test.main(); share_menu_test.main(); collaborative_workspace_test.main(); change_name_and_icon_test.main(); workspace_icon_test.main(); tabs_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/command_palette/command_palette_test.dart ================================================ import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/page_preview.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Command Palette', () { testWidgets('Toggle command palette', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.toggleCommandPalette(); expect(find.byType(CommandPaletteModal), findsOneWidget); await tester.toggleCommandPalette(); expect(find.byType(CommandPaletteModal), findsNothing); }); }); group('Search', () { testWidgets('Test for searching', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'Switch To New Page'); await tester.pumpAndSettle(); /// tap getting started page await tester.tapButton( find.descendant( of: find.byType(HomeSideBar), matching: find.textContaining(gettingStarted), ), ); /// show searching page final searchingButton = find.text(LocaleKeys.search_label.tr()); await tester.tapButton(searchingButton); final askAIButton = find.byType(SearchAskAiEntrance); expect(askAIButton, findsNothing); final recentList = find.byType(RecentViewsList); expect(recentList, findsOneWidget); /// there is [gettingStarted] in recent list final gettingStartedRecentCell = find.descendant( of: recentList, matching: find.textContaining(gettingStarted), ); expect(gettingStartedRecentCell, findsAtLeast(1)); /// hover to show preview await tester.hoverOnWidget(gettingStartedRecentCell.first); final pagePreview = find.byType(PagePreview); expect(pagePreview, findsOneWidget); final gettingStartedPreviewTitle = find.descendant( of: pagePreview, matching: find.textContaining(gettingStarted), ); expect(gettingStartedPreviewTitle, findsOneWidget); /// searching for [gettingStarted] final searchField = find.byType(SearchField); final textFiled = find.descendant(of: searchField, matching: find.byType(TextField)); await tester.enterText(textFiled, gettingStarted); await tester.pumpAndSettle(Duration(seconds: 1)); /// there is [gettingStarted] in result list final resultList = find.byType(SearchResultList); expect(resultList, findsOneWidget); final resultCells = find.byType(SearchResultCell); expect(resultCells, findsAtLeast(1)); /// hover to show preview await tester.hoverOnWidget(resultCells.first); expect(find.byType(PagePreview), findsOneWidget); /// clear search content final clearButton = find.byFlowySvg(FlowySvgs.search_clear_m); await tester.tapButton(clearButton); expect(find.byType(SearchResultList), findsNothing); expect(find.byType(RecentViewsList), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/command_palette/command_palette_test_runner.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'command_palette_test.dart' as command_palette_test; import 'folder_search_test.dart' as folder_search_test; import 'recent_history_test.dart' as recent_history_test; void startTesting() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Command Palette integration tests command_palette_test.main(); folder_search_test.main(); recent_history_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart ================================================ import 'dart:convert'; import 'dart:math'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); }); group('Folder Search', () { testWidgets('Search for views', (tester) async { const firstDocument = "ViewOne"; const secondDocument = "ViewOna"; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: firstDocument); await tester.createNewPageWithNameUnderParent(name: secondDocument); await tester.toggleCommandPalette(); expect(find.byType(CommandPaletteModal), findsOneWidget); final searchFieldFinder = find.descendant( of: find.byType(SearchField), matching: find.byType(FlowyTextField), ); await tester.enterText(searchFieldFinder, secondDocument); await tester.pumpAndSettle(const Duration(milliseconds: 200)); // Expect two search results "ViewOna" and "ViewOne" (Distance 1 to ViewOna) expect(find.byType(SearchResultCell), findsNWidgets(2)); // The score should be higher for "ViewOna" thus it should be shown first final secondDocumentWidget = tester .widget(find.byType(SearchResultCell).first) as SearchResultCell; expect(secondDocumentWidget.item.displayName, secondDocument); // Change search to "ViewOne" await tester.enterText(searchFieldFinder, firstDocument); await tester.pumpAndSettle(const Duration(seconds: 1)); // The score should be higher for "ViewOne" thus it should be shown first final firstDocumentWidget = tester.widget( find.byType(SearchResultCell).first, ) as SearchResultCell; expect(firstDocumentWidget.item.displayName, firstDocument); }); testWidgets('Displaying icons in search results', (tester) async { final randomValue = Random().nextInt(10000) + 10000; final pageNames = ['First Page-$randomValue', 'Second Page-$randomValue']; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final emojiIconData = await tester.loadIcon(); /// create two pages for (final pageName in pageNames) { await tester.createNewPageWithNameUnderParent(name: pageName); await tester.updatePageIconInTitleBarByName( name: pageName, layout: ViewLayoutPB.Document, icon: emojiIconData, ); } await tester.toggleCommandPalette(); /// search for `Page` final searchFieldFinder = find.descendant( of: find.byType(SearchField), matching: find.byType(FlowyTextField), ); await tester.enterText(searchFieldFinder, 'Page-$randomValue'); await tester.pumpAndSettle(const Duration(milliseconds: 200)); expect(find.byType(SearchResultCell), findsNWidgets(2)); /// check results final svgs = find.descendant( of: find.byType(SearchResultCell), matching: find.byType(FlowySvg), ); expect(svgs, findsNWidgets(2)); final firstSvg = svgs.first.evaluate().first.widget as FlowySvg, lastSvg = svgs.last.evaluate().first.widget as FlowySvg; final iconData = IconsData.fromJson(jsonDecode(emojiIconData.emoji)); /// icon displayed correctly expect(firstSvg.svgString, iconData.svgString); expect(lastSvg.svgString, iconData.svgString); testWidgets('select the content in document and search', (tester) async { const firstDocument = ''; // empty document await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: firstDocument); await tester.editor.updateSelection( Selection( start: Position( path: [0], ), end: Position( path: [0], offset: 10, ), ), ); await tester.pumpAndSettle(); expect( find.byType(FloatingToolbar), findsOneWidget, ); await tester.toggleCommandPalette(); expect(find.byType(CommandPaletteModal), findsOneWidget); expect( find.text(LocaleKeys.menuAppHeader_defaultNewPageName.tr()), findsOneWidget, ); expect( find.text(firstDocument), findsOneWidget, ); }); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart ================================================ import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Recent History', () { testWidgets('Search for views', (tester) async { const firstDocument = "First"; const secondDocument = "Second"; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: firstDocument); await tester.createNewPageWithNameUnderParent(name: secondDocument); await tester.toggleCommandPalette(); expect(find.byType(CommandPaletteModal), findsOneWidget); // Expect history list expect(find.byType(RecentViewsList), findsOneWidget); // Expect three recent history items expect(find.byType(SearchRecentViewCell), findsNWidgets(3)); // Expect the first item to be the last viewed document final firstDocumentWidget = tester.widget(find.byType(SearchRecentViewCell).first) as SearchRecentViewCell; expect(firstDocumentWidget.view.name, secondDocument); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart ================================================ import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_editor.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); group('calendar', () { testWidgets('update calendar layout', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Calendar, ); // open setting await tester.tapDatabaseSettingButton(); await tester.tapDatabaseLayoutButton(); await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board); await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board); await tester.tapDatabaseSettingButton(); await tester.tapDatabaseLayoutButton(); await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Grid); await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Grid); await tester.pumpAndSettle(); }); testWidgets('calendar start from day setting', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create calendar view const name = 'calendar'; await tester.createNewPageWithNameUnderParent( name: name, layout: ViewLayoutPB.Calendar, ); // Open setting await tester.tapDatabaseSettingButton(); await tester.tapCalendarLayoutSettingButton(); // select the first day of week is Monday await tester.tapFirstDayOfWeek(); await tester.tapFirstDayOfWeekStartFromMonday(); // Open the other page and open the new calendar page again await tester.openPage(gettingStarted); await tester.pumpAndSettle(const Duration(milliseconds: 300)); await tester.openPage(name, layout: ViewLayoutPB.Calendar); // Open setting again and check the start from Monday is selected await tester.tapDatabaseSettingButton(); await tester.tapCalendarLayoutSettingButton(); await tester.tapFirstDayOfWeek(); tester.assertFirstDayOfWeekStartFromMonday(); await tester.pumpAndSettle(); }); testWidgets('creating and editing calendar events', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create the calendar view await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Calendar, ); // Scroll until today's date cell is visible await tester.scrollToToday(); // Hover over today's calendar cell await tester.hoverOnTodayCalendarCell( // Tap on create new event button onHover: tester.tapAddCalendarEventButton, ); // Make sure that the event editor popup is shown tester.assertEventEditorOpen(); tester.assertNumberOfEventsInCalendar(1); // Dismiss the event editor popup await tester.dismissEventEditor(); // Double click on today's calendar cell to create a new event await tester.doubleClickCalendarCell(DateTime.now()); // Make sure that the event is inserted in the cell tester.assertNumberOfEventsInCalendar(2); // Click on the event await tester.openCalendarEvent(index: 0); tester.assertEventEditorOpen(); // Change the title of the event await tester.editEventTitle('hello world'); await tester.dismissEventEditor(); // Make sure that the event is edited tester.assertNumberOfEventsInCalendar(1, title: 'hello world'); tester.assertNumberOfEventsOnSpecificDay(2, DateTime.now()); // Click on the event await tester.openCalendarEvent(index: 0); tester.assertEventEditorOpen(); // Click on the open icon await tester.openEventToRowDetailPage(); tester.assertRowDetailPageOpened(); // Duplicate the event await tester.tapRowDetailPageRowActionButton(); await tester.tapRowDetailPageDuplicateRowButton(); await tester.dismissRowDetailPage(); // Check that there are 2 events tester.assertNumberOfEventsInCalendar(2, title: 'hello world'); tester.assertNumberOfEventsOnSpecificDay(3, DateTime.now()); // Delete an event await tester.openCalendarEvent(index: 1); await tester.deleteEventFromEventEditor(); // Check that there is 1 event tester.assertNumberOfEventsInCalendar(1, title: 'hello world'); tester.assertNumberOfEventsOnSpecificDay(2, DateTime.now()); // Delete event from row detail page await tester.openCalendarEvent(index: 0); await tester.openEventToRowDetailPage(); tester.assertRowDetailPageOpened(); await tester.tapRowDetailPageRowActionButton(); await tester.tapRowDetailPageDeleteRowButton(); // Check that there is 0 event tester.assertNumberOfEventsInCalendar(0, title: 'hello world'); tester.assertNumberOfEventsOnSpecificDay(1, DateTime.now()); }); testWidgets('create and duplicate calendar event', (tester) async { const customTitle = "EventTitleCustom"; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create the calendar view await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Calendar, ); // Scroll until today's date cell is visible await tester.scrollToToday(); // Hover over today's calendar cell await tester.hoverOnTodayCalendarCell( // Tap on create new event button onHover: () async => tester.tapAddCalendarEventButton(), ); // Make sure that the event editor popup is shown tester.assertEventEditorOpen(); tester.assertNumberOfEventsInCalendar(1); // Change the title of the event await tester.editEventTitle(customTitle); // Duplicate event final duplicateBtnFinder = find .descendant( of: find.byType(CalendarEventEditor), matching: find.byType( FlowyIconButton, ), ) .first; await tester.tap(duplicateBtnFinder); await tester.pumpAndSettle(); tester.assertNumberOfEventsInCalendar(2, title: customTitle); }); testWidgets('rescheduling events', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create the calendar view await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Calendar, ); // Create a new event on the first of this month final today = DateTime.now(); final firstOfThisMonth = DateTime(today.year, today.month); await tester.doubleClickCalendarCell(firstOfThisMonth); await tester.dismissEventEditor(); // Drag and drop the event onto the next week, same day await tester.dragDropRescheduleCalendarEvent(); // Make sure that the event has been rescheduled to the new date final sameDayNextWeek = firstOfThisMonth.add(const Duration(days: 7)); tester.assertNumberOfEventsInCalendar(1); tester.assertNumberOfEventsOnSpecificDay(1, sameDayNextWeek); // Delete the event await tester.openCalendarEvent(index: 0, date: sameDayNextWeek); await tester.deleteEventFromEventEditor(); // Create another event on the 5th of this month final fifthOfThisMonth = DateTime(today.year, today.month, 5); await tester.doubleClickCalendarCell(fifthOfThisMonth); await tester.dismissEventEditor(); // Make sure that the event is on the 4t tester.assertNumberOfEventsOnSpecificDay(1, fifthOfThisMonth); // Click on the event await tester.openCalendarEvent(index: 0, date: fifthOfThisMonth); // Open the date editor of the event await tester.tapDateCellInRowDetailPage(); await tester.findDateEditor(findsOneWidget); // Edit the event's date final newDate = fifthOfThisMonth.add(const Duration(days: 1)); await tester.selectDay(content: newDate.day); await tester.dismissCellEditor(); // Dismiss the event editor await tester.dismissEventEditor(); // Make sure that the event is edited tester.assertNumberOfEventsInCalendar(1); tester.assertNumberOfEventsOnSpecificDay(1, newDate); // Click on the unscheduled events button await tester.openUnscheduledEventsPopup(); // Assert that nothing shows up tester.findUnscheduledPopup(findsNothing, 0); // Click on the event in the calendar await tester.openCalendarEvent(index: 0, date: newDate); // Open the date editor of the event await tester.tapDateCellInRowDetailPage(); await tester.findDateEditor(findsOneWidget); // Clear the date of the event await tester.clearDate(); // Dismiss the event editor await tester.dismissEventEditor(); tester.assertNumberOfEventsInCalendar(0); // Click on the unscheduled events button await tester.openUnscheduledEventsPopup(); // Assert that a popup appears and 1 unscheduled event tester.findUnscheduledPopup(findsOneWidget, 1); // Click on the unscheduled event await tester.clickUnscheduledEvent(); tester.assertRowDetailPageOpened(); }); testWidgets('filter calendar events', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create the calendar view await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Calendar, ); // Create a new event on the first of this month final today = DateTime.now(); final firstOfThisMonth = DateTime(today.year, today.month); await tester.doubleClickCalendarCell(firstOfThisMonth); await tester.dismissEventEditor(); tester.assertNumberOfEventsInCalendar(1); await tester.openCalendarEvent(index: 0, date: firstOfThisMonth); await tester.tapButton(finderForFieldType(FieldType.MultiSelect)); await tester.createOption(name: "asdf"); await tester.createOption(name: "qwer"); await tester.selectOption(name: "asdf"); await tester.dismissCellEditor(); await tester.dismissCellEditor(); await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType(FieldType.MultiSelect, "Tags"); await tester.tapFilterButtonInGrid('Tags'); await tester.tapOptionFilterWithName('asdf'); await tester.dismissCellEditor(); tester.assertNumberOfEventsInCalendar(0); await tester.tapFilterButtonInGrid('Tags'); await tester.tapOptionFilterWithName('asdf'); await tester.dismissCellEditor(); tester.assertNumberOfEventsInCalendar(1); await tester.tapFilterButtonInGrid('Tags'); await tester.tapOptionFilterWithName('asdf'); await tester.dismissCellEditor(); tester.assertNumberOfEventsInCalendar(0); final secondOfThisMonth = DateTime(today.year, today.month, 2); await tester.doubleClickCalendarCell(secondOfThisMonth); await tester.dismissEventEditor(); tester.assertNumberOfEventsInCalendar(1); await tester.openCalendarEvent(index: 0, date: secondOfThisMonth); await tester.tapButton(finderForFieldType(FieldType.MultiSelect)); await tester.selectOption(name: "asdf"); await tester.dismissCellEditor(); await tester.dismissCellEditor(); tester.assertNumberOfEventsInCalendar(0); await tester.tapFilterButtonInGrid('Tags'); await tester.changeSelectFilterCondition( SelectOptionFilterConditionPB.OptionIsEmpty, ); await tester.dismissCellEditor(); tester.assertNumberOfEventsInCalendar(1); tester.assertNumberOfEventsOnSpecificDay(1, secondOfThisMonth); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart ================================================ import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:intl/intl.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('edit grid cell:', () { testWidgets('text', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.editCell( rowIndex: 0, fieldType: FieldType.RichText, input: 'hello world', ); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.RichText, content: 'hello world', ); await tester.pumpAndSettle(); }); // Make sure the text cells are filled with the right content when there are // multiple text cell testWidgets('multiple text cells', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'my grid', layout: ViewLayoutPB.Grid, ); await tester.createField(FieldType.RichText, name: 'description'); await tester.editCell( rowIndex: 0, fieldType: FieldType.RichText, input: 'hello', ); await tester.editCell( rowIndex: 0, fieldType: FieldType.RichText, input: 'world', cellIndex: 1, ); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.RichText, content: 'hello', ); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.RichText, content: 'world', cellIndex: 1, ); await tester.pumpAndSettle(); }); testWidgets('number', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.Number; // Create a number field await tester.createField(fieldType); await tester.editCell( rowIndex: 0, fieldType: fieldType, input: '-1', ); // edit the next cell to force the previous cell at row 0 to lose focus await tester.editCell( rowIndex: 1, fieldType: fieldType, input: '0.2', ); // -1 -> -1 tester.assertCellContent( rowIndex: 0, fieldType: fieldType, content: '-1', ); // edit the next cell to force the previous cell at row 1 to lose focus await tester.editCell( rowIndex: 2, fieldType: fieldType, input: '.1', ); // 0.2 -> 0.2 tester.assertCellContent( rowIndex: 1, fieldType: fieldType, content: '0.2', ); // edit the next cell to force the previous cell at row 2 to lose focus await tester.editCell( rowIndex: 0, fieldType: fieldType, input: '', ); // .1 -> 0.1 tester.assertCellContent( rowIndex: 2, fieldType: fieldType, content: '0.1', ); await tester.pumpAndSettle(); }); testWidgets('checkbox', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.assertCheckboxCell(rowIndex: 0, isSelected: false); await tester.tapCheckboxCellInGrid(rowIndex: 0); await tester.assertCheckboxCell(rowIndex: 0, isSelected: true); await tester.tapCheckboxCellInGrid(rowIndex: 1); await tester.tapCheckboxCellInGrid(rowIndex: 2); await tester.assertCheckboxCell(rowIndex: 1, isSelected: true); await tester.assertCheckboxCell(rowIndex: 2, isSelected: true); await tester.pumpAndSettle(); }); testWidgets('created time', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.CreatedTime; // Create a create time field // The create time field is not editable await tester.createField(fieldType); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findDateEditor(findsNothing); await tester.pumpAndSettle(); }); testWidgets('last modified time', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.LastEditedTime; // Create a last time field // The last time field is not editable await tester.createField(fieldType); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findDateEditor(findsNothing); await tester.pumpAndSettle(); }); testWidgets('date time', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.DateTime; await tester.createField(fieldType); // Tap the cell to invoke the field editor await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findDateEditor(findsOneWidget); // Toggle include time await tester.toggleIncludeTime(); // Dismiss the cell editor await tester.dismissCellEditor(); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findDateEditor(findsOneWidget); // Turn off include time await tester.toggleIncludeTime(); // Select a date DateTime now = DateTime.now(); await tester.selectDay(content: now.day); await tester.dismissCellEditor(); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.DateTime, content: DateFormat('MMM dd, y').format(now), ); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); // Toggle include time now = DateTime.now(); await tester.toggleIncludeTime(); await tester.dismissCellEditor(); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.DateTime, content: DateFormat('MMM dd, y HH:mm').format(now), ); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findDateEditor(findsOneWidget); // Change date format await tester.tapChangeDateTimeFormatButton(); await tester.changeDateFormat(); await tester.dismissCellEditor(); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.DateTime, content: DateFormat('dd/MM/y HH:mm').format(now), ); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findDateEditor(findsOneWidget); // Change time format await tester.tapChangeDateTimeFormatButton(); await tester.changeTimeFormat(); await tester.dismissCellEditor(); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.DateTime, content: DateFormat('dd/MM/y hh:mm a').format(now), ); await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findDateEditor(findsOneWidget); // Clear the date and time await tester.clearDate(); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.DateTime, content: '', ); await tester.pumpAndSettle(); }); testWidgets('single select', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const fieldType = FieldType.SingleSelect; // When create a grid, it will create a single select field by default await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Tap the cell to invoke the selection option editor await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findSelectOptionEditor(findsOneWidget); // Create a new select option await tester.createOption(name: 'tag 1'); await tester.dismissCellEditor(); // Make sure the option is created and displayed in the cell tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: 'tag 1', ); await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findSelectOptionEditor(findsOneWidget); // Create another select option await tester.createOption(name: 'tag 2'); await tester.dismissCellEditor(); tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: 'tag 2', ); tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsOneWidget, ); await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findSelectOptionEditor(findsOneWidget); // switch to first option await tester.selectOption(name: 'tag 1'); await tester.dismissCellEditor(); tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: 'tag 1', ); tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsOneWidget, ); await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findSelectOptionEditor(findsOneWidget); // Deselect the currently-selected option await tester.selectOption(name: 'tag 1'); await tester.dismissCellEditor(); tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsNothing, ); await tester.pumpAndSettle(); }); testWidgets('multi select', (tester) async { final tags = [ 'tag 1', 'tag 2', 'tag 3', 'tag 4', ]; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.MultiSelect; await tester.createField(fieldType, name: fieldType.i18n); // Tap the cell to invoke the selection option editor await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findSelectOptionEditor(findsOneWidget); // Create a new select option await tester.createOption(name: tags.first); await tester.dismissCellEditor(); // Make sure the option is created and displayed in the cell tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: tags.first, ); await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findSelectOptionEditor(findsOneWidget); // Create some other select options await tester.createOption(name: tags[1]); await tester.createOption(name: tags[2]); await tester.createOption(name: tags[3]); await tester.dismissCellEditor(); for (final tag in tags) { tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: tag, ); } tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsNWidgets(4), ); await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findSelectOptionEditor(findsOneWidget); // Deselect all options for (final tag in tags) { await tester.selectOption(name: tag); } await tester.dismissCellEditor(); tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsNothing, ); await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); await tester.findSelectOptionEditor(findsOneWidget); // Select some options await tester.selectOption(name: tags[1]); await tester.selectOption(name: tags[3]); await tester.dismissCellEditor(); tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: tags[1], ); tester.findSelectOptionWithNameInGrid( rowIndex: 0, name: tags[3], ); tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 0, matcher: findsNWidgets(2), ); await tester.pumpAndSettle(); }); testWidgets('checklist', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.Checklist; await tester.createField(fieldType); // assert that there is no progress bar in the grid tester.assertChecklistCellInGrid(rowIndex: 0, percent: null); // tap on the first checklist cell await tester.tapChecklistCellInGrid(rowIndex: 0); // assert that the checklist editor is shown tester.assertChecklistEditorVisible(visible: true); // create a new task with enter await tester.createNewChecklistTask(name: "task 1", enter: true); // assert that the task is displayed tester.assertChecklistTaskInEditor( index: 0, name: "task 1", isChecked: false, ); // update the task's name await tester.renameChecklistTask(index: 0, name: "task 11"); // assert that the task's name is updated tester.assertChecklistTaskInEditor( index: 0, name: "task 11", isChecked: false, ); // dismiss new task editor await tester.dismissCellEditor(); // dismiss checklist cell editor await tester.dismissCellEditor(); // assert that progress bar is shown in grid at 0% tester.assertChecklistCellInGrid(rowIndex: 0, percent: 0); // start editing the first checklist cell again await tester.tapChecklistCellInGrid(rowIndex: 0); // create another task with the create button await tester.createNewChecklistTask(name: "task 2", button: true); // assert that the task was inserted tester.assertChecklistTaskInEditor( index: 1, name: "task 2", isChecked: false, ); // mark it as complete await tester.checkChecklistTask(index: 1); // assert that the task was checked in the editor tester.assertChecklistTaskInEditor( index: 1, name: "task 2", isChecked: true, ); // dismiss checklist editor await tester.dismissCellEditor(); await tester.dismissCellEditor(); // assert that progressbar is shown in grid at 50% tester.assertChecklistCellInGrid(rowIndex: 0, percent: 0.5); // re-open the cell editor await tester.tapChecklistCellInGrid(rowIndex: 0); // hover over first task and delete it await tester.deleteChecklistTask(index: 0); // dismiss cell editor await tester.dismissCellEditor(); // assert that progressbar is shown in grid at 100% tester.assertChecklistCellInGrid(rowIndex: 0, percent: 1); // re-open the cell edior await tester.tapChecklistCellInGrid(rowIndex: 0); // delete the remaining task await tester.deleteChecklistTask(index: 0); // dismiss the cell editor await tester.dismissCellEditor(); // check that the progress bar is not viisble tester.assertChecklistCellInGrid(rowIndex: 0, percent: null); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart ================================================ import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('grid field settings test:', () { testWidgets('field visibility', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a database and add a linked database view await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); // create a field await tester.scrollToRight(find.byType(GridPage)); await tester.tapNewPropertyButton(); await tester.renameField('New field 1'); await tester.dismissFieldEditor(); // hide the field await tester.tapGridFieldWithName('New field 1'); await tester.tapHidePropertyButton(); tester.noFieldWithName('New field 1'); // create another field, New field 1 to be hidden still await tester.tapNewPropertyButton(); await tester.dismissFieldEditor(); tester.noFieldWithName('New field 1'); // go back to inline database view, expect field to be shown await tester.tapTabBarLinkedViewByViewName('Untitled'); tester.findFieldWithName('New field 1'); // go back to linked database view, expect field to be hidden await tester.tapTabBarLinkedViewByViewName('Grid'); tester.noFieldWithName('New field 1'); // use the settings button to show the field await tester.tapDatabaseSettingButton(); await tester.tapViewPropertiesButton(); await tester.tapViewTogglePropertyVisibilityButtonByName('New field 1'); await tester.dismissFieldEditor(); tester.findFieldWithName('New field 1'); // open first row in popup then hide the field await tester.openFirstRowDetailPage(); await tester.tapGridFieldWithNameInRowDetailPage('New field 1'); await tester.tapHidePropertyButtonInFieldEditor(); await tester.dismissRowDetailPage(); tester.noFieldWithName('New field 1'); // the field should still be sort and filter-able await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.RichText, "New field 1", ); await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1"); }); testWidgets('field cell width', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a database and add a linked database view await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); // create a field await tester.scrollToRight(find.byType(GridPage)); await tester.tapNewPropertyButton(); await tester.renameField('New field 1'); await tester.dismissFieldEditor(); // check the width of the field expect(tester.getFieldWidth('New field 1'), 150); // change the width of the field await tester.changeFieldWidth('New field 1', 200); expect(tester.getFieldWidth('New field 1'), 205); // create another field, New field 1 to be same width await tester.tapNewPropertyButton(); await tester.dismissFieldEditor(); expect(tester.getFieldWidth('New field 1'), 205); // go back to inline database view, expect New field 1 to be 150px await tester.tapTabBarLinkedViewByViewName('Untitled'); expect(tester.getFieldWidth('New field 1'), 150); // go back to linked database view, expect New field 1 to be 205px await tester.tapTabBarLinkedViewByViewName('Grid'); expect(tester.getFieldWidth('New field 1'), 205); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/select/select_option.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); group('grid edit field test:', () { testWidgets('rename existing field', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Invoke the field editor await tester.tapGridFieldWithName('Name'); await tester.renameField('hello world'); await tester.dismissFieldEditor(); await tester.tapGridFieldWithName('hello world'); await tester.pumpAndSettle(); }); testWidgets('edit field icon', (tester) async { const icon = 'artificial_intelligence/ai-upscale-spark'; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); tester.assertFieldSvg('Name', FieldType.RichText); // choose specific icon await tester.tapGridFieldWithName('Name'); await tester.changeFieldIcon(icon); await tester.dismissFieldEditor(); tester.assertFieldCustomSvg('Name', icon); // remove icon await tester.tapGridFieldWithName('Name'); await tester.changeFieldIcon(''); await tester.dismissFieldEditor(); tester.assertFieldSvg('Name', FieldType.RichText); await tester.pumpAndSettle(); }); testWidgets('update field type of existing field', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Invoke the field editor await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Checkbox); await tester.assertFieldTypeWithFieldName( 'Type', FieldType.Checkbox, ); await tester.pumpAndSettle(); }); testWidgets('create a field and rename it', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a field await tester.createField(FieldType.Checklist); tester.findFieldWithName(FieldType.Checklist.i18n); // editing field type during field creation should change title await tester.createField(FieldType.MultiSelect); tester.findFieldWithName(FieldType.MultiSelect.i18n); // not if the user changes the title manually though const name = "New field"; await tester.createField(FieldType.DateTime); await tester.tapGridFieldWithName(FieldType.DateTime.i18n); await tester.renameField(name); await tester.tapEditFieldButton(); await tester.tapSwitchFieldTypeButton(); await tester.selectFieldType(FieldType.URL); tester.findFieldWithName(name); }); testWidgets('delete field', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a field await tester.createField(FieldType.Checkbox, name: 'New field 1'); // Delete the field await tester.tapGridFieldWithName('New field 1'); await tester.tapDeletePropertyButton(); // confirm delete await tester.tapButtonWithName(LocaleKeys.space_delete.tr()); tester.noFieldWithName('New field 1'); await tester.pumpAndSettle(); }); testWidgets('duplicate field', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a field await tester.createField(FieldType.RichText, name: 'New field 1'); // duplicate the field await tester.tapGridFieldWithName('New field 1'); await tester.tapDuplicatePropertyButton(); tester.findFieldWithName('New field 1 (copy)'); await tester.pumpAndSettle(); }); testWidgets('insert field on either side of a field', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.scrollToRight(find.byType(GridPage)); // insert new field to the right await tester.tapGridFieldWithName('Type'); await tester.tapInsertFieldButton(left: false, name: 'Right'); await tester.dismissFieldEditor(); tester.findFieldWithName('Right'); // insert new field to the left await tester.tapGridFieldWithName('Type'); await tester.tapInsertFieldButton(left: true, name: "Left"); await tester.dismissFieldEditor(); tester.findFieldWithName('Left'); await tester.pumpAndSettle(); }); testWidgets('clear cells under field', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.scrollToRight(find.byType(GridPage)); // edit the data await tester.editCell( rowIndex: 0, fieldType: FieldType.RichText, input: 'Hello', ); await tester.editCell( rowIndex: 1, fieldType: FieldType.RichText, input: 'World', ); expect(find.text('Hello'), findsOneWidget); expect(find.text('World'), findsOneWidget); // clear the cells await tester.tapGridFieldWithName('Name'); await tester.tapClearCellsButton(); await tester.tapButtonWithName(LocaleKeys.button_confirm.tr()); expect(find.text('Hello'), findsNothing); expect(find.text('World'), findsNothing); }); testWidgets('create list of fields', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); for (final fieldType in [ FieldType.Checklist, FieldType.DateTime, FieldType.Number, FieldType.URL, FieldType.MultiSelect, FieldType.LastEditedTime, FieldType.CreatedTime, FieldType.Checkbox, ]) { await tester.createField(fieldType); // After update the field type, the cells should be updated tester.findCellByFieldType(fieldType); await tester.pumpAndSettle(); } }); testWidgets('field types with empty type option editor', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); for (final fieldType in [ FieldType.RichText, FieldType.Checkbox, FieldType.Checklist, FieldType.URL, ]) { await tester.createField(fieldType); // open the field editor await tester.tapGridFieldWithName(fieldType.i18n); await tester.tapEditFieldButton(); // check type option editor is empty tester.expectEmptyTypeOptionEditor(); await tester.dismissFieldEditor(); } }); testWidgets('number field type option', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.scrollToRight(find.byType(GridPage)); // create a number field await tester.createField(FieldType.Number); // enter some data into the first number cell await tester.editCell( rowIndex: 0, fieldType: FieldType.Number, input: '123', ); // edit the next cell to force the previous cell at row 0 to lose focus await tester.editCell( rowIndex: 1, fieldType: FieldType.Number, input: '0.2', ); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.Number, content: '123', ); // open editor and change number format await tester.tapGridFieldWithName(FieldType.Number.i18n); await tester.tapEditFieldButton(); await tester.changeNumberFieldFormat(); await tester.dismissFieldEditor(); // assert number format has been changed tester.assertCellContent( rowIndex: 0, fieldType: FieldType.Number, content: '\$123', ); }); testWidgets('add option', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Grid, ); // invoke the field editor await tester.tapGridFieldWithName('Type'); await tester.tapEditFieldButton(); // tap 'add option' button await tester.tapAddSelectOptionButton(); const text = 'Hello AppFlowy'; final inputField = find.descendant( of: find.byType(CreateOptionTextField), matching: find.byType(TextField), ); await tester.enterText(inputField, text); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(const Duration(milliseconds: 500)); // check the result tester.expectToSeeText(text); }); testWidgets('date time field type options', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.scrollToRight(find.byType(GridPage)); // create a date field await tester.createField(FieldType.DateTime); // edit the first date cell await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); await tester.toggleIncludeTime(); final now = DateTime.now(); await tester.selectDay(content: now.day); await tester.dismissCellEditor(); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.DateTime, content: DateFormat('MMM dd, y HH:mm').format(now), ); // open editor and change date & time format await tester.tapGridFieldWithName(FieldType.DateTime.i18n); await tester.tapEditFieldButton(); await tester.changeDateFormat(); await tester.changeTimeFormat(); await tester.dismissFieldEditor(); // assert date format has been changed tester.assertCellContent( rowIndex: 0, fieldType: FieldType.DateTime, content: DateFormat('dd/MM/y hh:mm a').format(now), ); }); testWidgets('text in viewport while typing', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.changeCalculateAtIndex(0, CalculationType.Count); // add very large text with 200 lines final largeText = List.generate( 200, (index) => 'Line ${index + 1}', ).join('\n'); await tester.editCell( rowIndex: 2, fieldType: FieldType.RichText, input: largeText, ); // checks if last line is in view port tester.expectToSeeText('Line 200'); }); // Disable this test because it fails on CI randomly // testWidgets('last modified and created at field type options', // (tester) async { // await tester.initializeAppFlowy(); // await tester.tapGoButton(); // await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // final created = DateTime.now(); // // create a created at field // await tester.tapNewPropertyButton(); // await tester.renameField(FieldType.CreatedTime.i18n); // await tester.tapSwitchFieldTypeButton(); // await tester.selectFieldType(FieldType.CreatedTime); // await tester.dismissFieldEditor(); // // create a last modified field // await tester.tapNewPropertyButton(); // await tester.renameField(FieldType.LastEditedTime.i18n); // await tester.tapSwitchFieldTypeButton(); // // get time just before modifying // final modified = DateTime.now(); // // create a last modified field (cont'd) // await tester.selectFieldType(FieldType.LastEditedTime); // await tester.dismissFieldEditor(); // tester.assertCellContent( // rowIndex: 0, // fieldType: FieldType.CreatedTime, // content: DateFormat('MMM dd, y HH:mm').format(created), // ); // tester.assertCellContent( // rowIndex: 0, // fieldType: FieldType.LastEditedTime, // content: DateFormat('MMM dd, y HH:mm').format(modified), // ); // // open field editor and change date & time format // await tester.tapGridFieldWithName(FieldType.LastEditedTime.i18n); // await tester.tapEditFieldButton(); // await tester.changeDateFormat(); // await tester.changeTimeFormat(); // await tester.dismissFieldEditor(); // // open field editor and change date & time format // await tester.tapGridFieldWithName(FieldType.CreatedTime.i18n); // await tester.tapEditFieldButton(); // await tester.changeDateFormat(); // await tester.changeTimeFormat(); // await tester.dismissFieldEditor(); // // assert format has been changed // tester.assertCellContent( // rowIndex: 0, // fieldType: FieldType.CreatedTime, // content: DateFormat('dd/MM/y hh:mm a').format(created), // ); // tester.assertCellContent( // rowIndex: 0, // fieldType: FieldType.LastEditedTime, // content: DateFormat('dd/MM/y hh:mm a').format(modified), // ); // }); testWidgets('select option transform', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Grid, ); // invoke the field editor of existing Single-Select field Type await tester.tapGridFieldWithName('Type'); await tester.tapEditFieldButton(); // add some select options await tester.tapAddSelectOptionButton(); for (final optionName in ['A', 'B', 'C']) { final inputField = find.descendant( of: find.byType(CreateOptionTextField), matching: find.byType(TextField), ); await tester.enterText(inputField, optionName); await tester.testTextInput.receiveAction(TextInputAction.done); } await tester.dismissFieldEditor(); // select A in first row's cell under the Type field await tester.tapCellInGrid( rowIndex: 0, fieldType: FieldType.SingleSelect, ); await tester.selectOption(name: 'A'); await tester.dismissCellEditor(); tester.findSelectOptionWithNameInGrid(name: 'A', rowIndex: 0); await tester.changeFieldTypeOfFieldWithName('Type', FieldType.RichText); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.RichText, content: "A", cellIndex: 1, ); // add some random text in the second row await tester.editCell( rowIndex: 1, fieldType: FieldType.RichText, input: "random", cellIndex: 1, ); tester.assertCellContent( rowIndex: 1, fieldType: FieldType.RichText, content: "random", cellIndex: 1, ); await tester.changeFieldTypeOfFieldWithName( 'Type', FieldType.SingleSelect, ); tester.findSelectOptionWithNameInGrid(name: 'A', rowIndex: 0); tester.assertNumberOfSelectedOptionsInGrid( rowIndex: 1, matcher: findsNothing, ); // create a new field for testing await tester.createField(FieldType.RichText, name: 'Test'); // edit the first 2 rows await tester.editCell( rowIndex: 0, fieldType: FieldType.RichText, input: "E,F", cellIndex: 1, ); await tester.editCell( rowIndex: 1, fieldType: FieldType.RichText, input: "G", cellIndex: 1, ); await tester.changeFieldTypeOfFieldWithName( 'Test', FieldType.MultiSelect, ); tester.assertMultiSelectOption(contents: ['E', 'F'], rowIndex: 0); tester.assertMultiSelectOption(contents: ['G'], rowIndex: 1); await tester.tapCellInGrid( rowIndex: 2, fieldType: FieldType.MultiSelect, ); await tester.selectOption(name: 'G'); await tester.createOption(name: 'H'); await tester.dismissCellEditor(); tester.findSelectOptionWithNameInGrid(name: 'A', rowIndex: 0); tester.assertMultiSelectOption(contents: ['G', 'H'], rowIndex: 2); await tester.changeFieldTypeOfFieldWithName( 'Test', FieldType.RichText, ); tester.assertCellContent( rowIndex: 2, fieldType: FieldType.RichText, content: "G,H", cellIndex: 1, ); await tester.changeFieldTypeOfFieldWithName( 'Test', FieldType.MultiSelect, ); tester.assertMultiSelectOption(contents: ['E', 'F'], rowIndex: 0); tester.assertMultiSelectOption(contents: ['G'], rowIndex: 1); tester.assertMultiSelectOption(contents: ['G', 'H'], rowIndex: 2); }); testWidgets('date time transform', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.scrollToRight(find.byType(GridPage)); // create a date field await tester.createField(FieldType.DateTime); // edit the first date cell await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); final now = DateTime.now(); await tester.toggleIncludeTime(); await tester.selectDay(content: now.day); await tester.dismissCellEditor(); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.DateTime, content: DateFormat('MMM dd, y HH:mm').format(now), ); await tester.changeFieldTypeOfFieldWithName( 'Date', FieldType.RichText, ); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.RichText, content: DateFormat('MMM dd, y HH:mm').format(now), cellIndex: 1, ); await tester.editCell( rowIndex: 1, fieldType: FieldType.RichText, input: "Oct 5, 2024", cellIndex: 1, ); tester.assertCellContent( rowIndex: 1, fieldType: FieldType.RichText, content: "Oct 5, 2024", cellIndex: 1, ); await tester.changeFieldTypeOfFieldWithName( 'Date', FieldType.DateTime, ); tester.assertCellContent( rowIndex: 0, fieldType: FieldType.DateTime, content: DateFormat('MMM dd, y').format(now), ); tester.assertCellContent( rowIndex: 1, fieldType: FieldType.DateTime, content: "Oct 05, 2024", ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart ================================================ import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('grid filter:', () { testWidgets('add text filter', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType(FieldType.RichText, 'Name'); await tester.tapFilterButtonInGrid('Name'); // enter 'A' in the filter text field tester.assertNumberOfRowsInGridPage(10); await tester.enterTextInTextFilter('A'); tester.assertNumberOfRowsInGridPage(1); // after remove the filter, the grid should show all rows await tester.enterTextInTextFilter(''); tester.assertNumberOfRowsInGridPage(10); await tester.enterTextInTextFilter('B'); tester.assertNumberOfRowsInGridPage(1); // open the menu to delete the filter await tester.tapDisclosureButtonInFinder(find.byType(TextFilterEditor)); await tester.tapDeleteFilterButtonInGrid(); tester.assertNumberOfRowsInGridPage(10); await tester.pumpAndSettle(); }); testWidgets('add checkbox filter', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType(FieldType.Checkbox, 'Done'); tester.assertNumberOfRowsInGridPage(5); await tester.tapFilterButtonInGrid('Done'); await tester.tapCheckboxFilterButtonInGrid(); await tester.tapUnCheckedButtonOnCheckboxFilter(); tester.assertNumberOfRowsInGridPage(5); await tester .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); await tester.tapDeleteFilterButtonInGrid(); tester.assertNumberOfRowsInGridPage(10); await tester.pumpAndSettle(); }); testWidgets('add checklist filter', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType(FieldType.Checklist, 'checklist'); // By default, the condition of checklist filter is 'uncompleted' tester.assertNumberOfRowsInGridPage(9); await tester.tapFilterButtonInGrid('checklist'); await tester.tapChecklistFilterButtonInGrid(); await tester.tapCompletedButtonOnChecklistFilter(); tester.assertNumberOfRowsInGridPage(1); await tester.pumpAndSettle(); }); testWidgets('add single select filter', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType(FieldType.SingleSelect, 'Type'); await tester.tapFilterButtonInGrid('Type'); // select the option 's6' await tester.tapOptionFilterWithName('s6'); tester.assertNumberOfRowsInGridPage(0); // unselect the option 's6' await tester.tapOptionFilterWithName('s6'); tester.assertNumberOfRowsInGridPage(10); // select the option 's5' await tester.tapOptionFilterWithName('s5'); tester.assertNumberOfRowsInGridPage(1); // select the option 's4' await tester.tapOptionFilterWithName('s4'); // The row with 's4' should be shown. tester.assertNumberOfRowsInGridPage(2); await tester.pumpAndSettle(); }); testWidgets('add multi select filter', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.MultiSelect, 'multi-select', ); await tester.tapFilterButtonInGrid('multi-select'); await tester.scrollOptionFilterListByOffset(const Offset(0, -200)); // select the option 'm1'. Any option with 'm1' should be shown. await tester.tapOptionFilterWithName('m1'); tester.assertNumberOfRowsInGridPage(5); await tester.tapOptionFilterWithName('m1'); // select the option 'm2'. Any option with 'm2' should be shown. await tester.tapOptionFilterWithName('m2'); tester.assertNumberOfRowsInGridPage(4); await tester.tapOptionFilterWithName('m2'); // select the option 'm4'. Any option with 'm4' should be shown. await tester.tapOptionFilterWithName('m4'); tester.assertNumberOfRowsInGridPage(1); await tester.pumpAndSettle(); }); testWidgets('add date filter', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType(FieldType.DateTime, 'date'); // By default, the condition of date filter is current day and time tester.assertNumberOfRowsInGridPage(0); await tester.tapFilterButtonInGrid('date'); await tester.changeDateFilterCondition(DateTimeFilterCondition.before); tester.assertNumberOfRowsInGridPage(7); await tester.changeDateFilterCondition(DateTimeFilterCondition.isEmpty); tester.assertNumberOfRowsInGridPage(3); await tester.pumpAndSettle(); }); testWidgets('add timestamp filter', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.createField( FieldType.CreatedTime, name: 'Created at', ); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.CreatedTime, 'Created at', ); await tester.pumpAndSettle(); tester.assertNumberOfRowsInGridPage(3); await tester.tapFilterButtonInGrid('Created at'); await tester.changeDateFilterCondition(DateTimeFilterCondition.before); tester.assertNumberOfRowsInGridPage(0); await tester.pumpAndSettle(); }); testWidgets('create new row when filters don\'t autofill', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.RichText, 'Name', ); tester.assertNumberOfRowsInGridPage(3); await tester.tapCreateRowButtonInGrid(); tester.assertNumberOfRowsInGridPage(4); await tester.tapFilterButtonInGrid('Name'); await tester .changeTextFilterCondition(TextFilterConditionPB.TextIsNotEmpty); await tester.dismissCellEditor(); tester.assertNumberOfRowsInGridPage(0); await tester.tapCreateRowButtonInGrid(); tester.assertNumberOfRowsInGridPage(0); expect(find.byType(RowDetailPage), findsOneWidget); await tester.pumpAndSettle(); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_icon_test.dart ================================================ import 'dart:convert'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/emoji.dart'; import '../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); testWidgets('change icon', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final iconData = await tester.loadIcon(); const pageName = 'Database'; await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Grid, name: pageName, ); /// create board final addButton = find.byType(AddDatabaseViewButton); await tester.tapButton(addButton); await tester.tapButton( find.text( '${LocaleKeys.grid_createView.tr()} ${DatabaseLayoutPB.Board.layoutName}', findRichText: true, ), ); /// create calendar await tester.tapButton(addButton); await tester.tapButton( find.text( '${LocaleKeys.grid_createView.tr()} ${DatabaseLayoutPB.Calendar.layoutName}', findRichText: true, ), ); final databaseTabBarItem = find.byType(DatabaseTabBarItem); expect(databaseTabBarItem, findsNWidgets(3)); final gridItem = databaseTabBarItem.first, boardItem = databaseTabBarItem.at(1), calendarItem = databaseTabBarItem.last; /// change the icon of grid /// the first tapping is to select specific item /// the second tapping is to show the menu await tester.tapButton(gridItem); await tester.tapButton(gridItem); /// change icon await tester .tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr())); await tester.tapIcon(iconData, enableColor: false); final gridIcon = find.descendant( of: gridItem, matching: find.byType(RawEmojiIconWidget), ); final gridIconWidget = gridIcon.evaluate().first.widget as RawEmojiIconWidget; final iconsData = IconsData.fromJson(jsonDecode(iconData.emoji)); final gridIconsData = IconsData.fromJson(jsonDecode(gridIconWidget.emoji.emoji)); expect(gridIconsData.iconName, iconsData.iconName); /// change the icon of board await tester.tapButton(boardItem); await tester.tapButton(boardItem); await tester .tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr())); await tester.tapIcon(iconData, enableColor: false); final boardIcon = find.descendant( of: boardItem, matching: find.byType(RawEmojiIconWidget), ); final boardIconWidget = boardIcon.evaluate().first.widget as RawEmojiIconWidget; final boardIconsData = IconsData.fromJson(jsonDecode(boardIconWidget.emoji.emoji)); expect(boardIconsData.iconName, iconsData.iconName); /// change the icon of calendar await tester.tapButton(calendarItem); await tester.tapButton(calendarItem); await tester .tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr())); await tester.tapIcon(iconData, enableColor: false); final calendarIcon = find.descendant( of: calendarItem, matching: find.byType(RawEmojiIconWidget), ); final calendarIconWidget = calendarIcon.evaluate().first.widget as RawEmojiIconWidget; final calendarIconsData = IconsData.fromJson(jsonDecode(calendarIconWidget.emoji.emoji)); expect(calendarIconsData.iconName, iconsData.iconName); }); testWidgets('change database icon from sidebar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final iconData = await tester.loadIcon(); final icon = IconsData.fromJson(jsonDecode(iconData.emoji)), emoji = '😄'; const pageName = 'Database'; await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Grid, name: pageName, ); final viewItem = find.descendant( of: find.byType(SidebarFolder), matching: find.byWidgetPredicate( (w) => w is ViewItem && w.view.name == pageName, ), ); /// change icon to emoji await tester.tapButton( find.descendant( of: viewItem, matching: find.byType(FlowySvg), ), ); await tester.tapEmoji(emoji); final iconWidget = find.descendant( of: viewItem, matching: find.byType(RawEmojiIconWidget), ); expect( (iconWidget.evaluate().first.widget as RawEmojiIconWidget).emoji.emoji, emoji, ); /// the icon will not be displayed in database item Finder databaseIcon = find.descendant( of: find.byType(DatabaseTabBarItem), matching: find.byType(FlowySvg), ); expect( (databaseIcon.evaluate().first.widget as FlowySvg).svg, FlowySvgs.icon_grid_s, ); /// change emoji to icon await tester.tapButton(iconWidget); await tester.tapIcon(iconData); expect( (iconWidget.evaluate().first.widget as RawEmojiIconWidget).emoji.emoji, iconData.emoji, ); databaseIcon = find.descendant( of: find.byType(DatabaseTabBarItem), matching: find.byType(RawEmojiIconWidget), ); final databaseIconWidget = databaseIcon.evaluate().first.widget as RawEmojiIconWidget; final databaseIconsData = IconsData.fromJson(jsonDecode(databaseIconWidget.emoji.emoji)); expect(icon.svgString, databaseIconsData.svgString); expect(icon.color, isNotEmpty); expect(icon.color, databaseIconsData.color); /// the icon in database item should not show the color expect(databaseIconWidget.enableColor, false); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart ================================================ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../shared/database_test_op.dart'; import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('media type option in database', () { testWidgets('add media field and add files two times', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Invoke the field editor await tester.tapGridFieldWithName('Type'); await tester.tapEditFieldButton(); // Change to media type await tester.tapSwitchFieldTypeButton(); await tester.selectFieldType(FieldType.Media); await tester.dismissFieldEditor(); // Open media cell editor await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media); await tester.findMediaCellEditor(findsOneWidget); // Prepare files for upload from local final firstImage = await rootBundle.load('assets/test/images/sample.jpeg'); final secondImage = await rootBundle.load('assets/test/images/sample.gif'); final tempDirectory = await getTemporaryDirectory(); final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); final firstFile = File(firstImagePath) ..writeAsBytesSync(firstImage.buffer.asUint8List()); final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); final secondFile = File(secondImagePath) ..writeAsBytesSync(secondImage.buffer.asUint8List()); mockPickFilePaths(paths: [firstImagePath]); await getIt().set(KVKeys.kCloudType, '0'); // Click on add file button in the Media Cell Editor await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); await tester.pumpAndSettle(); // Tap on the upload interaction await tester.tapFileUploadHint(); // Expect one file expect(find.byType(RenderMedia), findsOneWidget); // Mock second file mockPickFilePaths(paths: [secondImagePath]); // Click on add file button in the Media Cell Editor await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); await tester.pumpAndSettle(); // Tap on the upload interaction await tester.tapFileUploadHint(); await tester.pumpAndSettle(); // Expect two files expect(find.byType(RenderMedia), findsNWidgets(2)); // Remove the temp files await Future.wait([firstFile.delete(), secondFile.delete()]); }); testWidgets('add two files at once', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Invoke the field editor await tester.tapGridFieldWithName('Type'); await tester.tapEditFieldButton(); // Change to media type await tester.tapSwitchFieldTypeButton(); await tester.selectFieldType(FieldType.Media); await tester.dismissFieldEditor(); // Open media cell editor await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media); await tester.findMediaCellEditor(findsOneWidget); // Prepare files for upload from local final firstImage = await rootBundle.load('assets/test/images/sample.jpeg'); final secondImage = await rootBundle.load('assets/test/images/sample.gif'); final tempDirectory = await getTemporaryDirectory(); final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); final firstFile = File(firstImagePath) ..writeAsBytesSync(firstImage.buffer.asUint8List()); final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); final secondFile = File(secondImagePath) ..writeAsBytesSync(secondImage.buffer.asUint8List()); mockPickFilePaths(paths: [firstImagePath, secondImagePath]); await getIt().set(KVKeys.kCloudType, '0'); // Click on add file button in the Media Cell Editor await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); await tester.pumpAndSettle(); // Tap on the upload interaction await tester.tapFileUploadHint(); // Expect two files expect(find.byType(RenderMedia), findsNWidgets(2)); // Remove the temp files await Future.wait([firstFile.delete(), secondFile.delete()]); }); testWidgets('delete files', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Invoke the field editor await tester.tapGridFieldWithName('Type'); await tester.tapEditFieldButton(); // Change to media type await tester.tapSwitchFieldTypeButton(); await tester.selectFieldType(FieldType.Media); await tester.dismissFieldEditor(); // Open media cell editor await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media); await tester.findMediaCellEditor(findsOneWidget); // Prepare files for upload from local final firstImage = await rootBundle.load('assets/test/images/sample.jpeg'); final secondImage = await rootBundle.load('assets/test/images/sample.gif'); final tempDirectory = await getTemporaryDirectory(); final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); final firstFile = File(firstImagePath) ..writeAsBytesSync(firstImage.buffer.asUint8List()); final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); final secondFile = File(secondImagePath) ..writeAsBytesSync(secondImage.buffer.asUint8List()); mockPickFilePaths(paths: [firstImagePath, secondImagePath]); await getIt().set(KVKeys.kCloudType, '0'); // Click on add file button in the Media Cell Editor await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); await tester.pumpAndSettle(); // Tap on the upload interaction await tester.tapFileUploadHint(); // Expect two files expect(find.byType(RenderMedia), findsNWidgets(2)); // Tap on the three dots menu for the first RenderMedia final mediaMenuFinder = find.descendant( of: find.byType(RenderMedia), matching: find.byFlowySvg(FlowySvgs.three_dots_s), ); await tester.tap(mediaMenuFinder.first); await tester.pumpAndSettle(); // Tap on the delete button await tester.tap(find.text(LocaleKeys.grid_media_delete.tr())); await tester.pumpAndSettle(); // Tap on Delete button in the confirmation dialog await tester.tap( find.descendant( of: find.byType(SpaceCancelOrConfirmButton), matching: find.text(LocaleKeys.grid_media_delete.tr()), ), ); await tester.pumpAndSettle(const Duration(seconds: 1)); // Expect one file expect(find.byType(RenderMedia), findsOneWidget); // Remove the temp files await Future.wait([firstFile.delete(), secondFile.delete()]); }); testWidgets('show file names', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Invoke the field editor await tester.tapGridFieldWithName('Type'); await tester.tapEditFieldButton(); // Change to media type await tester.tapSwitchFieldTypeButton(); await tester.selectFieldType(FieldType.Media); await tester.dismissFieldEditor(); // Open media cell editor await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media); await tester.findMediaCellEditor(findsOneWidget); // Prepare files for upload from local final firstImage = await rootBundle.load('assets/test/images/sample.jpeg'); final secondImage = await rootBundle.load('assets/test/images/sample.gif'); final tempDirectory = await getTemporaryDirectory(); final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); final firstFile = File(firstImagePath) ..writeAsBytesSync(firstImage.buffer.asUint8List()); final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); final secondFile = File(secondImagePath) ..writeAsBytesSync(secondImage.buffer.asUint8List()); mockPickFilePaths(paths: [firstImagePath, secondImagePath]); await getIt().set(KVKeys.kCloudType, '0'); // Click on add file button in the Media Cell Editor await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); await tester.pumpAndSettle(); // Tap on the upload interaction await tester.tapFileUploadHint(); // Expect two files expect(find.byType(RenderMedia), findsNWidgets(2)); await tester.dismissCellEditor(); await tester.pumpAndSettle(); // Open first row in row detail view then toggle show file names await tester.openFirstRowDetailPage(); await tester.pumpAndSettle(); // Expect file names to not be shown (hidden) expect(find.text('sample.jpeg'), findsNothing); expect(find.text('sample.gif'), findsNothing); await tester.tapGridFieldWithNameInRowDetailPage('Type'); await tester.pumpAndSettle(); // Toggle show file names await tester.tap(find.byType(Toggle)); await tester.pumpAndSettle(); // Expect file names to be shown expect(find.text('sample.jpeg'), findsOneWidget); expect(find.text('sample.gif'), findsOneWidget); await tester.dismissRowDetailPage(); await tester.pumpAndSettle(); // Remove the temp files await Future.wait([firstFile.delete(), secondFile.delete()]); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_reminder_test.dart ================================================ import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('reminder in database', () { testWidgets('add date field and add reminder', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Invoke the field editor await tester.tapGridFieldWithName('Type'); await tester.tapEditFieldButton(); // Change to date type await tester.tapSwitchFieldTypeButton(); await tester.selectFieldType(FieldType.DateTime); await tester.dismissFieldEditor(); // Open date picker await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); await tester.findDateEditor(findsOneWidget); // Select date final isToday = await tester.selectLastDateInPicker(); // Select "On day of event" reminder await tester.selectReminderOption(ReminderOption.onDayOfEvent); // Expect "On day of event" to be displayed tester.expectSelectedReminder(ReminderOption.onDayOfEvent); // Dismiss the cell/date editor await tester.dismissCellEditor(); // Open date picker again await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); await tester.findDateEditor(findsOneWidget); // Expect "On day of event" to be displayed tester.expectSelectedReminder(ReminderOption.onDayOfEvent); // Dismiss the cell/date editor await tester.dismissCellEditor(); int tabIndex = 1; final now = DateTime.now(); if (isToday && now.hour >= 9) { tabIndex = 0; } // Open "Upcoming" in Notification hub await tester.openNotificationHub(tabIndex: tabIndex); // Expect 1 notification tester.expectNotificationItems(1); }); testWidgets('navigate from reminder to open row', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Invoke the field editor await tester.tapGridFieldWithName('Type'); await tester.tapEditFieldButton(); // Change to date type await tester.tapSwitchFieldTypeButton(); await tester.selectFieldType(FieldType.DateTime); await tester.dismissFieldEditor(); // Open date picker await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); await tester.findDateEditor(findsOneWidget); // Select date final isToday = await tester.selectLastDateInPicker(); // Select "On day of event"-reminder await tester.selectReminderOption(ReminderOption.onDayOfEvent); // Expect "On day of event" to be displayed tester.expectSelectedReminder(ReminderOption.onDayOfEvent); // Dismiss the cell/date editor await tester.dismissCellEditor(); // Open date picker again await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); await tester.findDateEditor(findsOneWidget); // Expect "On day of event" to be displayed tester.expectSelectedReminder(ReminderOption.onDayOfEvent); // Dismiss the cell/date editor await tester.dismissCellEditor(); // Create and Navigate to a new document await tester.createNewPageWithNameUnderParent(); await tester.pumpAndSettle(); int tabIndex = 1; final now = DateTime.now(); if (isToday && now.hour >= 9) { tabIndex = 0; } // Open correct tab in Notification hub await tester.openNotificationHub(tabIndex: tabIndex); // Expect 1 notification tester.expectNotificationItems(1); // Tap on the notification await tester.tap(find.byType(NotificationItem)); await tester.pumpAndSettle(); // Expect to see Row Editor Dialog tester.expectToSeeRowDetailsPageDialog(); }); testWidgets( 'toggle include time sets reminder option correctly', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( layout: ViewLayoutPB.Grid, ); // Invoke the field editor await tester.tapGridFieldWithName('Type'); await tester.tapEditFieldButton(); // Change to date type await tester.tapSwitchFieldTypeButton(); await tester.selectFieldType(FieldType.DateTime); await tester.dismissFieldEditor(); // Open date picker await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); await tester.findDateEditor(findsOneWidget); // Select date await tester.selectLastDateInPicker(); // Select "On day of event"-reminder await tester.selectReminderOption(ReminderOption.onDayOfEvent); // Expect "On day of event" to be displayed tester.expectSelectedReminder(ReminderOption.onDayOfEvent); // Dismiss the cell/date editor await tester.dismissCellEditor(); // Open date picker again await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); await tester.findDateEditor(findsOneWidget); // Expect "On day of event" to be displayed tester.expectSelectedReminder(ReminderOption.onDayOfEvent); // Toggle include time on await tester.toggleIncludeTime(); // Expect "At time of event" to be displayed tester.expectSelectedReminder(ReminderOption.atTimeOfEvent); // Dismiss the cell/date editor await tester.dismissCellEditor(); // Open date picker again await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); await tester.findDateEditor(findsOneWidget); // Expect "At time of event" to be displayed tester.expectSelectedReminder(ReminderOption.atTimeOfEvent); // Select "One hour before"-reminder await tester.selectReminderOption(ReminderOption.oneHourBefore); // Expect "One hour before" to be displayed tester.expectSelectedReminder(ReminderOption.oneHourBefore); // Toggle include time off await tester.toggleIncludeTime(); // Expect "On day of event" to be displayed tester.expectSelectedReminder(ReminderOption.onDayOfEvent); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_row_cover_test.dart ================================================ import 'dart:io'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/af_image.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../shared/database_test_op.dart'; import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('database row cover', () { testWidgets('add and remove cover from Row Detail Card', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Open first row in row detail view await tester.openFirstRowDetailPage(); await tester.pumpAndSettle(); // Expect no cover expect(find.byType(RowCover), findsNothing); // Hover on RowBanner to show Add Cover button await tester.hoverRowBanner(); // Click on Add Cover button await tester.tapAddCoverButton(); // Expect a cover to be shown - the default asset cover expect(find.byType(RowCover), findsOneWidget); // Tap on the delete cover button await tester.tapButton(find.byType(DeleteCoverButton)); await tester.pumpAndSettle(); // Expect no cover to be shown expect(find.byType(AFImage), findsNothing); }); testWidgets('add and change cover and check in Board', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); await tester.pumpAndSettle(); // Open "Card 1" await tester.tap(find.text('Card 1'), warnIfMissed: false); await tester.pumpAndSettle(); // Expect no cover expect(find.byType(RowCover), findsNothing); // Hover on RowBanner to show Add Cover button await tester.hoverRowBanner(); // Click on Add Cover button await tester.tapAddCoverButton(); // Expect default cover to be shown expect(find.byType(RowCover), findsOneWidget); // Prepare image for upload from local final image = await rootBundle.load('assets/test/images/sample.jpeg'); final tempDirectory = await getTemporaryDirectory(); final imagePath = p.join(tempDirectory.path, 'sample.jpeg'); final file = File(imagePath) ..writeAsBytesSync(image.buffer.asUint8List()); mockPickFilePaths(paths: [imagePath]); await getIt().set(KVKeys.kCloudType, '0'); // Hover on RowBanner to show Change Cover button await tester.hoverRowBanner(); // Tap on the change cover button await tester.tapButtonWithName( LocaleKeys.document_plugins_cover_changeCover.tr(), ); await tester.pumpAndSettle(); // Change tab to Upload tab await tester.tapButtonWithName( LocaleKeys.document_imageBlock_upload_label.tr(), ); // Tab on the upload button await tester.tapButtonWithName( LocaleKeys.document_imageBlock_upload_placeholder.tr(), ); // Expect one cover expect(find.byType(RowCover), findsOneWidget); // Expect the cover to be shown both in RowCover and in CardCover expect(find.byType(AFImage), findsNWidgets(2)); // Dismiss Row Detail Page await tester.dismissRowDetailPage(); // Expect a cover to be shown in CardCover expect( find.descendant( of: find.byType(CardCover), matching: find.byType(AFImage), ), findsOneWidget, ); // Remove the temp file await Future.wait([file.delete()]); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/plugins/document/document_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/emoji.dart'; import '../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); group('grid row detail page:', () { testWidgets('opens', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); // Make sure that the row page is opened tester.assertRowDetailPageOpened(); // Each row detail page should have a document await tester.assertDocumentExistInRowDetailPage(); }); testWidgets('add and update emoji', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); await tester.hoverRowBanner(); await tester.openEmojiPicker(); await tester.tapEmoji('😀'); // expect to find the emoji selected final firstEmojiFinder = find.byWidgetPredicate( (w) => w is FlowyText && w.text == '😀', ); // There are 2 eomjis - one in the row banner and another in the primary cell expect(firstEmojiFinder, findsNWidgets(2)); // Update existing selected emoji - tap on it to update await tester.tapButton(find.byType(EmojiIconWidget)); await tester.pumpAndSettle(); await tester.tapEmoji('😅'); // The emoji already displayed in the row banner final emojiText = find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == '😅', ); // The number of emoji should be two. One in the row displayed in the grid // one in the row detail page. expect(emojiText, findsNWidgets(2)); // insert a sub page in database await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.pumpAndSettle(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_subPage_name.tr(), offset: 100, ); await tester.pumpAndSettle(); // the row detail page should be closed final rowDetailPage = find.byType(RowDetailPage); await tester.pumpUntilNotFound(rowDetailPage); // expect to see a document page final documentPage = find.byType(DocumentPage); expect(documentPage, findsOneWidget); }); testWidgets('remove emoji', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); await tester.hoverRowBanner(); await tester.openEmojiPicker(); await tester.tapEmoji('😀'); // Remove the emoji await tester.tapButton(find.byType(EmojiIconWidget)); await tester.tapButton(find.text(LocaleKeys.button_remove.tr())); final emojiText = find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == '😀', ); expect(emojiText, findsNothing); }); testWidgets('create list of fields', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); for (final fieldType in [ FieldType.Checklist, FieldType.DateTime, FieldType.Number, FieldType.URL, FieldType.MultiSelect, FieldType.LastEditedTime, FieldType.CreatedTime, FieldType.Checkbox, ]) { await tester.tapRowDetailPageCreatePropertyButton(); // Open the type option menu await tester.tapSwitchFieldTypeButton(); await tester.selectFieldType(fieldType); final field = find.descendant( of: find.byType(RowDetailPage), matching: find.byWidgetPredicate( (widget) => widget is FieldCellButton && widget.field.name == fieldType.i18n, ), ); expect(field, findsOneWidget); // After update the field type, the cells should be updated tester.findCellByFieldType(fieldType); await tester.scrollRowDetailByOffset(const Offset(0, -50)); } }); testWidgets('change order of fields and cells', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); // Assert that the first field in the row details page is the select // option type tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect); // Reorder first field in list final gesture = await tester.hoverOnFieldInRowDetail(index: 0); await tester.pumpAndSettle(); await tester.reorderFieldInRowDetail(offset: 30); // Orders changed, now the checkbox is first tester.assertFirstFieldInRowDetailByType(FieldType.Checkbox); await gesture.removePointer(); await tester.pumpAndSettle(); // Reorder second field in list await tester.hoverOnFieldInRowDetail(index: 1); await tester.pumpAndSettle(); await tester.reorderFieldInRowDetail(offset: -30); // First field is now back to select option tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect); }); testWidgets('hide and show hidden fields', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); // Assert that the show hidden fields button isn't visible tester.assertToggleShowHiddenFieldsVisibility(false); // Hide the first field in the field list await tester.tapGridFieldWithNameInRowDetailPage("Type"); await tester.tapHidePropertyButtonInFieldEditor(); // Assert that the field is now hidden tester.noFieldWithName("Type"); // Assert that the show hidden fields button appears tester.assertToggleShowHiddenFieldsVisibility(true); // Click on the show hidden fields button await tester.toggleShowHiddenFields(); // Assert that the hidden field is shown again and that the show // hidden fields button is still present tester.findFieldWithName("Type"); tester.assertToggleShowHiddenFieldsVisibility(true); // Click hide hidden fields await tester.toggleShowHiddenFields(); // Assert that the hidden field has vanished tester.noFieldWithName("Type"); // Click show hidden fields await tester.toggleShowHiddenFields(); // delete the hidden field await tester.tapGridFieldWithNameInRowDetailPage("Type"); await tester.tapDeletePropertyInFieldEditor(); // Assert that the that the show hidden fields button is gone tester.assertToggleShowHiddenFieldsVisibility(false); }); testWidgets('update the contents of the document and re-open it', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); // Wait for the document to be loaded await tester.wait(500); // Focus on the editor final textBlock = find.byType(ParagraphBlockComponentWidget); await tester.tapAt(tester.getCenter(textBlock)); await tester.pumpAndSettle(); // Input some text const inputText = 'Hello World'; await tester.ime.insertText(inputText); expect( find.textContaining(inputText, findRichText: true), findsOneWidget, ); // Tap outside to dismiss the field await tester.tapAt(Offset.zero); await tester.pumpAndSettle(); // Re-open the document await tester.openFirstRowDetailPage(); expect( find.textContaining(inputText, findRichText: true), findsOneWidget, ); }); testWidgets( 'check if the title wraps properly when a long text is inserted', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); // Wait for the document to be loaded await tester.wait(500); // Focus on the editor final textField = find .descendant( of: find.byType(SimpleDialog), matching: find.byType(TextField), ) .first; // Input a long text await tester.enterText(textField, 'Long text' * 25); await tester.pumpAndSettle(); // Tap outside to dismiss the field await tester.tapAt(Offset.zero); await tester.pumpAndSettle(); // Check if there is any overflow in the widget tree expect(tester.takeException(), isNull); // Re-open the document await tester.openFirstRowDetailPage(); // Check again if there is any overflow in the widget tree expect(tester.takeException(), isNull); }); testWidgets('delete row', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); await tester.tapRowDetailPageRowActionButton(); await tester.tapRowDetailPageDeleteRowButton(); await tester.tapEscButton(); tester.assertNumberOfRowsInGridPage(2); }); testWidgets('duplicate row', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Create a new grid await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Hover first row and then open the row page await tester.openFirstRowDetailPage(); await tester.tapRowDetailPageRowActionButton(); await tester.tapRowDetailPageDuplicateRowButton(); await tester.tapEscButton(); tester.assertNumberOfRowsInGridPage(4); }); testWidgets('edit checklist cell', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); const fieldType = FieldType.Checklist; await tester.createField(fieldType); await tester.openFirstRowDetailPage(); await tester.hoverOnWidget( find.byType(ChecklistRowDetailCell), onHover: () async { await tester.tapButton(find.byType(ChecklistItemControl)); }, ); tester.assertPhantomChecklistItemAtIndex(index: 0); await tester.enterText(find.byType(PhantomChecklistItem), 'task 1'); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(const Duration(milliseconds: 500)); tester.assertChecklistTaskInEditor( index: 0, name: "task 1", isChecked: false, ); tester.assertPhantomChecklistItemAtIndex(index: 1); tester.assertPhantomChecklistItemContent(""); await tester.enterText(find.byType(PhantomChecklistItem), 'task 2'); await tester.pumpAndSettle(); await tester.hoverOnWidget( find.byType(ChecklistRowDetailCell), onHover: () async { await tester.tapButton(find.byType(ChecklistItemControl)); }, ); tester.assertChecklistTaskInEditor( index: 1, name: "task 2", isChecked: false, ); tester.assertPhantomChecklistItemAtIndex(index: 2); tester.assertPhantomChecklistItemContent(""); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect(find.byType(PhantomChecklistItem), findsNothing); await tester.renameChecklistTask(index: 0, name: "task -1", enter: false); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); tester.assertChecklistTaskInEditor( index: 0, name: "task -1", isChecked: false, ); tester.assertPhantomChecklistItemAtIndex(index: 1); await tester.enterText(find.byType(PhantomChecklistItem), 'task 0'); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(const Duration(milliseconds: 500)); tester.assertPhantomChecklistItemAtIndex(index: 2); await tester.checkChecklistTask(index: 1); expect(find.byType(PhantomChecklistItem), findsNothing); expect(find.byType(ChecklistItem), findsNWidgets(3)); tester.assertChecklistTaskInEditor( index: 0, name: "task -1", isChecked: false, ); tester.assertChecklistTaskInEditor( index: 1, name: "task 0", isChecked: true, ); tester.assertChecklistTaskInEditor( index: 2, name: "task 2", isChecked: false, ); await tester.tapButton( find.descendant( of: find.byType(ProgressAndHideCompleteButton), matching: find.byType(FlowyIconButton), ), ); expect(find.byType(ChecklistItem), findsNWidgets(2)); tester.assertChecklistTaskInEditor( index: 0, name: "task -1", isChecked: false, ); tester.assertChecklistTaskInEditor( index: 1, name: "task 2", isChecked: false, ); await tester.renameChecklistTask(index: 1, name: "task 3", enter: false); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.renameChecklistTask(index: 0, name: "task 1", enter: false); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.enterText(find.byType(PhantomChecklistItem), 'task 2'); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(const Duration(milliseconds: 500)); tester.assertChecklistTaskInEditor( index: 0, name: "task 1", isChecked: false, ); tester.assertChecklistTaskInEditor( index: 1, name: "task 2", isChecked: false, ); tester.assertChecklistTaskInEditor( index: 2, name: "task 3", isChecked: false, ); tester.assertPhantomChecklistItemAtIndex(index: 2); await tester.checkChecklistTask(index: 1); expect(find.byType(ChecklistItem), findsNWidgets(2)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_setting_test.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('grid', () { testWidgets('update layout', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // open setting await tester.tapDatabaseSettingButton(); // select the layout await tester.tapDatabaseLayoutButton(); // select layout by board await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board); await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board); await tester.pumpAndSettle(); }); testWidgets('update layout multiple times', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // open setting await tester.tapDatabaseSettingButton(); await tester.tapDatabaseLayoutButton(); await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board); await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board); await tester.tapDatabaseSettingButton(); await tester.tapDatabaseLayoutButton(); await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Calendar); await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Calendar); await tester.pumpAndSettle(); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_share_test.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('database', () { testWidgets('import v0.2.0 database data', (tester) async { await tester.openTestDatabase(v020GridFileName); // wait the database data is loaded await tester.pumpAndSettle(const Duration(microseconds: 500)); // check the text cell final textCells = ['A', 'B', 'C', 'D', 'E', '', '', '', '', '']; for (final (index, content) in textCells.indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.RichText, content: content, ); } // check the checkbox cell final checkboxCells = [ true, true, true, true, true, false, false, false, false, false, ]; for (final (index, content) in checkboxCells.indexed) { await tester.assertCheckboxCell( rowIndex: index, isSelected: content, ); } // check the number cell final numberCells = [ '-1', '-2', '0.1', '0.2', '1', '2', '10', '11', '12', '', ]; for (final (index, content) in numberCells.indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.Number, content: content, ); } // check the url cell final urlCells = [ 'appflowy.io', 'no url', 'appflowy.io', 'https://github.com/AppFlowy-IO/', '', '', ]; for (final (index, content) in urlCells.indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.URL, content: content, ); } // check the single select cell final singleSelectCells = [ 's1', 's2', 's3', 's4', 's5', '', '', '', '', '', ]; for (final (index, content) in singleSelectCells.indexed) { await tester.assertSingleSelectOption( rowIndex: index, content: content, ); } // check the multi select cell final List> multiSelectCells = [ ['m1'], ['m1', 'm2'], ['m1', 'm2', 'm3'], ['m1', 'm2', 'm3'], ['m1', 'm2', 'm3', 'm4', 'm5'], [], [], [], [], [], ]; for (final (index, contents) in multiSelectCells.indexed) { tester.assertMultiSelectOption( rowIndex: index, contents: contents, ); } // check the checklist cell final List checklistCells = [ 0.67, 0.33, 1.0, null, null, null, null, null, null, null, ]; for (final (index, percent) in checklistCells.indexed) { tester.assertChecklistCellInGrid( rowIndex: index, percent: percent, ); } // check the date cell final List dateCells = [ 'Jun 01, 2023', 'Jun 02, 2023', 'Jun 03, 2023', 'Jun 04, 2023', 'Jun 05, 2023', 'Jun 05, 2023', 'Jun 16, 2023', '', '', '', ]; for (final (index, content) in dateCells.indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.DateTime, content: content, ); } }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_sort_test.dart ================================================ import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_editor.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('grid sort:', () { testWidgets('text sort', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); // check the text cell order final textCells = [ 'A', 'B', 'C', 'D', 'E', '', '', '', '', '', ]; for (final (index, content) in textCells.indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.RichText, content: content, ); } // open the sort menu and select order by descending await tester.tapSortMenuInSettingBar(); await tester.tapEditSortConditionButtonByFieldName('Name'); await tester.tapSortByDescending(); for (final (index, content) in [ 'E', 'D', 'C', 'B', 'A', '', '', '', '', '', ].indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.RichText, content: content, ); } // delete all sorts await tester.tapSortMenuInSettingBar(); await tester.tapDeleteAllSortsButton(); // check the text cell order for (final (index, content) in [ 'A', 'B', 'C', 'D', 'E', '', '', '', '', '', ].indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.RichText, content: content, ); } await tester.pumpAndSettle(); }); testWidgets('checkbox', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.Checkbox, 'Done'); // check the checkbox cell order for (final (index, content) in [ false, false, false, false, false, true, true, true, true, true, ].indexed) { await tester.assertCheckboxCell( rowIndex: index, isSelected: content, ); } // open the sort menu and select order by descending await tester.tapSortMenuInSettingBar(); await tester.tapEditSortConditionButtonByFieldName('Done'); await tester.tapSortByDescending(); for (final (index, content) in [ true, true, true, true, true, false, false, false, false, false, ].indexed) { await tester.assertCheckboxCell( rowIndex: index, isSelected: content, ); } await tester.pumpAndSettle(); }); testWidgets('number', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.Number, 'number'); // check the number cell order for (final (index, content) in [ '-2', '-1', '0.1', '0.2', '1', '2', '10', '11', '12', '', ].indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.Number, content: content, ); } // open the sort menu and select order by descending await tester.tapSortMenuInSettingBar(); await tester.tapEditSortConditionButtonByFieldName('number'); await tester.tapSortByDescending(); for (final (index, content) in [ '12', '11', '10', '2', '1', '0.2', '0.1', '-1', '-2', '', ].indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.Number, content: content, ); } await tester.pumpAndSettle(); }); testWidgets('checkbox and number', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.Checkbox, 'Done'); // open the sort menu and sort checkbox by descending await tester.tapSortMenuInSettingBar(); await tester.tapEditSortConditionButtonByFieldName('Done'); await tester.tapSortByDescending(); for (final (index, content) in [ true, true, true, true, true, false, false, false, false, false, ].indexed) { await tester.assertCheckboxCell( rowIndex: index, isSelected: content, ); } // add another sort, this time by number descending await tester.tapSortMenuInSettingBar(); await tester.tapCreateSortByFieldTypeInSortMenu( FieldType.Number, 'number', ); await tester.tapEditSortConditionButtonByFieldName('number'); await tester.tapSortByDescending(); // check checkbox cell order for (final (index, content) in [ true, true, true, true, true, false, false, false, false, false, ].indexed) { await tester.assertCheckboxCell( rowIndex: index, isSelected: content, ); } // check number cell order for (final (index, content) in [ '1', '0.2', '0.1', '-1', '-2', '12', '11', '10', '2', '', ].indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.Number, content: content, ); } await tester.pumpAndSettle(); }); testWidgets('reorder sort', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.Checkbox, 'Done'); // open the sort menu and sort checkbox by descending await tester.tapSortMenuInSettingBar(); await tester.tapEditSortConditionButtonByFieldName('Done'); await tester.tapSortByDescending(); // add another sort, this time by number descending await tester.tapSortMenuInSettingBar(); await tester.tapCreateSortByFieldTypeInSortMenu( FieldType.Number, 'number', ); await tester.tapEditSortConditionButtonByFieldName('number'); await tester.tapSortByDescending(); // check checkbox cell order for (final (index, content) in [ true, true, true, true, true, false, false, false, false, false, ].indexed) { await tester.assertCheckboxCell( rowIndex: index, isSelected: content, ); } // check number cell order for (final (index, content) in [ '1', '0.2', '0.1', '-1', '-2', '12', '11', '10', '2', '', ].indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.Number, content: content, ); } // reorder sort await tester.tapSortMenuInSettingBar(); await tester.reorderSort( (FieldType.Number, 'number'), (FieldType.Checkbox, 'Done'), ); // check checkbox cell order for (final (index, content) in [ false, false, false, false, true, true, true, true, true, false, ].indexed) { await tester.assertCheckboxCell( rowIndex: index, isSelected: content, ); } // check the number cell order for (final (index, content) in [ '12', '11', '10', '2', '1', '0.2', '0.1', '-1', '-2', '', ].indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.Number, content: content, ); } }); testWidgets('edit field', (tester) async { await tester.openTestDatabase(v020GridFileName); // create a number sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.Number, 'number'); // check the number cell order for (final (index, content) in [ '-2', '-1', '0.1', '0.2', '1', '2', '10', '11', '12', '', ].indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.Number, content: content, ); } final textCells = [ 'B', 'A', 'C', 'D', 'E', '', '', '', '', '', ]; for (final (index, content) in textCells.indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.RichText, content: content, ); } // edit the name of the number field await tester.tapGridFieldWithName('number'); await tester.renameField('hello world'); await tester.dismissFieldEditor(); await tester.tapGridFieldWithName('hello world'); await tester.dismissFieldEditor(); // expect name to be changed as well await tester.tapSortMenuInSettingBar(); final sortItem = find.ancestor( of: find.text('hello world'), matching: find.byType(DatabaseSortItem), ); expect(sortItem, findsOneWidget); // change the field type of the field to checkbox await tester.tapGridFieldWithName('hello world'); await tester.changeFieldTypeOfFieldWithName( 'hello world', FieldType.Checkbox, ); // expect name to be changed as well await tester.tapSortMenuInSettingBar(); expect(sortItem, findsOneWidget); final newTextCells = [ 'A', 'B', 'C', 'D', 'E', '', '', '', '', '', ]; for (final (index, content) in newTextCells.indexed) { tester.assertCellContent( rowIndex: index, fieldType: FieldType.RichText, content: content, ); } }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_test_runner_1.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'database_cell_test.dart' as database_cell_test; import 'database_field_settings_test.dart' as database_field_settings_test; import 'database_field_test.dart' as database_field_test; import 'database_row_page_test.dart' as database_row_page_test; import 'database_setting_test.dart' as database_setting_test; import 'database_share_test.dart' as database_share_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); database_cell_test.main(); database_field_test.main(); database_field_settings_test.main(); database_share_test.main(); database_row_page_test.main(); database_setting_test.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_test_runner_2.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'database_calendar_test.dart' as database_calendar_test; import 'database_filter_test.dart' as database_filter_test; import 'database_media_test.dart' as database_media_test; import 'database_row_cover_test.dart' as database_row_cover_test; import 'database_share_test.dart' as database_share_test; import 'database_sort_test.dart' as database_sort_test; import 'database_view_test.dart' as database_view_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); database_filter_test.main(); database_sort_test.main(); database_view_test.main(); database_calendar_test.main(); database_media_test.main(); database_row_cover_test.main(); database_share_test.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('database', () { testWidgets('create linked view', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Create board view await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Board); tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Board); // Create grid view await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Grid); // Create calendar view await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Calendar); tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Calendar); await tester.pumpAndSettle(); }); testWidgets('rename and delete linked view', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Create board view await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Board); tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Board); // rename board view await tester.renameLinkedView( tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Board), 'new board', ); final findBoard = tester.findTabBarLinkViewByViewName('new board'); expect(findBoard, findsOneWidget); // delete the board await tester.deleteDatebaseView(findBoard); expect(tester.findTabBarLinkViewByViewName('new board'), findsNothing); await tester.pumpAndSettle(); }); testWidgets('delete the last database view', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Create board view await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Board); tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Board); // delete the board await tester.deleteDatebaseView( tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Board), ); await tester.pumpAndSettle(); }); testWidgets('insert grid in column', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); /// create page and show slash menu await tester.createNewPageWithNameUnderParent(name: 'test page'); await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.pumpAndSettle(); /// create a column await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_twoColumns.tr(), ); final actionList = find.byType(BlockActionList); expect(actionList, findsNWidgets(2)); final position = tester.getCenter(actionList.last); /// tap the second child of column await tester.tapAt(position.copyWith(dx: position.dx + 50)); /// create a grid await tester.editor.showSlashMenu(); await tester.pumpAndSettle(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_grid.tr(), ); final grid = find.byType(GridPageContent); expect(grid, findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/keyboard.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document alignment', () { testWidgets('edit alignment in toolbar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final selection = Selection.single( path: [0], startOffset: 0, endOffset: 1, ); // click the first line of the readme await tester.editor.tapLineOfEditorAt(0); await tester.editor.updateSelection(selection); await tester.pumpAndSettle(); // click the align center await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m); await tester .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_center_m); // expect to see the align center final editorState = tester.editor.getCurrentEditorState(); final first = editorState.getNodeAtPath([0])!; expect(first.attributes[blockComponentAlign], 'center'); // click the align right await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m); await tester .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_right_m); expect(first.attributes[blockComponentAlign], 'right'); // click the align left await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m); await tester .tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_left_m); expect(first.attributes[blockComponentAlign], 'left'); }); testWidgets('edit alignment using shortcut', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // click the first line of the readme await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); final editorState = tester.editor.getCurrentEditorState(); final first = editorState.getNodeAtPath([0])!; // expect to see text aligned to the right await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyR, ], tester: tester, withKeyUp: true, ); expect(first.attributes[blockComponentAlign], rightAlignmentKey); // expect to see text aligned to the center await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyC, ], tester: tester, withKeyUp: true, ); expect(first.attributes[blockComponentAlign], centerAlignmentKey); // expect to see text aligned to the left await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyL, ], tester: tester, withKeyUp: true, ); expect(first.attributes[blockComponentAlign], leftAlignmentKey); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_app_lifecycle_test.dart ================================================ import 'dart:ui'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Editor AppLifeCycle tests', () { testWidgets( 'Selection is added back after pausing AppFlowy', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final selection = Selection.single(path: [4], startOffset: 0); await tester.editor.updateSelection(selection); binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); expect(tester.editor.getCurrentEditorState().selection, null); binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); expect(tester.editor.getCurrentEditorState().selection, selection); }, ); testWidgets( 'Null selection is retained after pausing AppFlowy', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final selection = Selection.single(path: [4], startOffset: 0); await tester.editor.updateSelection(selection); await tester.editor.updateSelection(null); binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); expect(tester.editor.getCurrentEditorState().selection, null); binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); expect(tester.editor.getCurrentEditorState().selection, null); }, ); testWidgets( 'Non-collapsed selection is retained after pausing AppFlowy', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final selection = Selection( start: Position(path: [3]), end: Position(path: [3], offset: 8), ); await tester.editor.updateSelection(selection); binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); expect(tester.editor.getCurrentEditorState().selection, selection); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_block_option_test.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Block option interaction tests', () { testWidgets('has correct block selection on tap option button', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // We edit the document by entering some characters, to ensure the document has focus await tester.editor.updateSelection( Selection.collapsed(Position(path: [2])), ); // Insert character 'a' three times - easy to identify await tester.ime.insertText('aaa'); await tester.pumpAndSettle(); final editorState = tester.editor.getCurrentEditorState(); final node = editorState.getNodeAtPath([2]); expect(node?.delta?.toPlainText(), startsWith('aaa')); final multiSelection = Selection( start: Position(path: [2], offset: 3), end: Position(path: [4], offset: 40), ); // Select multiple items await tester.editor.updateSelection(multiSelection); await tester.pumpAndSettle(); // Press the block option menu await tester.editor.hoverAndClickOptionMenuButton([2]); await tester.pumpAndSettle(); // Expect the selection to be Block type and not have changed expect(editorState.selectionType, SelectionType.block); expect(editorState.selection, multiSelection); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_callout_test.dart ================================================ import 'dart:convert'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/icon/icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/emoji.dart'; import '../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); testWidgets('callout with emoji icon picker', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final emojiIconData = await tester.loadIcon(); /// create a new document await tester.createNewPageWithNameUnderParent(); /// tap the first line of the document await tester.editor.tapLineOfEditorAt(0); /// create callout await tester.editor.showSlashMenu(); await tester.pumpAndSettle(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_callout.tr(), ); /// select an icon final emojiPickerButton = find.descendant( of: find.byType(CalloutBlockComponentWidget), matching: find.byType(EmojiPickerButton), ); await tester.tapButton(emojiPickerButton); await tester.tapIcon(emojiIconData); /// verification results final iconData = IconsData.fromJson(jsonDecode(emojiIconData.emoji)); final iconWidget = find .descendant( of: emojiPickerButton, matching: find.byType(IconWidget), ) .evaluate() .first .widget as IconWidget; final iconWidgetData = iconWidget.iconsData; expect(iconWidgetData.svgString, iconData.svgString); expect(iconWidgetData.iconName, iconData.iconName); expect(iconWidgetData.groupName, iconData.groupName); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_codeblock_paste_test.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('paste in codeblock:', () { testWidgets('paste multiple lines in codeblock', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(name: 'Test Document'); // focus on the editor await tester.tapButton(find.byType(AppFlowyEditor)); // mock the clipboard const lines = 3; final text = List.generate(lines, (index) => 'line $index').join('\n'); AppFlowyClipboard.mockSetData(AppFlowyClipboardData(text: text)); ClipboardService.mockSetData(ClipboardServiceData(plainText: text)); await insertCodeBlockInDocument(tester); // paste the text await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); final editorState = tester.editor.getCurrentEditorState(); expect(editorState.document.root.children.length, 1); expect( editorState.getNodeAtPath([0])!.delta!.toPlainText(), text, ); }); }); } /// Inserts an codeBlock in the document Future insertCodeBlockInDocument(WidgetTester tester) async { // open the actions menu and insert the codeBlock await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_code.tr(), offset: 150, ); // wait for the codeBlock to be inserted await tester.pumpAndSettle(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('copy and paste in document:', () { testWidgets('paste multiple lines at the first line', (tester) async { // mock the clipboard const lines = 3; await tester.pasteContent( plainText: List.generate(lines, (index) => 'line $index').join('\n'), (editorState) { expect(editorState.document.root.children.length, 1); final text = editorState.document.root.children.first.delta!.toPlainText(); final textLines = text.split('\n'); for (var i = 0; i < lines; i++) { expect( textLines[i], 'line $i', ); } }, ); }); // ## **User Installation** // - [Windows/Mac/Linux](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/mac-windows-linux-packages) // - [Docker](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/installing-with-docker) // - [Source](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/from-source) testWidgets('paste content from html, sample 1', (tester) async { await tester.pasteContent( html: '''

User Installation

''', (editorState) { expect(editorState.document.root.children.length, 4); final node1 = editorState.getNodeAtPath([0])!; final node2 = editorState.getNodeAtPath([1])!; final node3 = editorState.getNodeAtPath([2])!; final node4 = editorState.getNodeAtPath([3])!; expect(node1.delta!.toJson(), [ { "insert": "User Installation", "attributes": {"bold": true}, } ]); expect(node2.delta!.toJson(), [ { "insert": "Windows/Mac/Linux", "attributes": { "href": "https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/mac-windows-linux-packages", }, } ]); expect( node3.delta!.toJson(), [ { "insert": "Docker", "attributes": { "href": "https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/installing-with-docker", }, } ], ); expect( node4.delta!.toJson(), [ { "insert": "Source", "attributes": { "href": "https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/from-source", }, } ], ); }, ); }); testWidgets('paste code from VSCode', (tester) async { await tester.pasteContent( html: '''
void main() {
runApp(const MyApp());
}
''', (editorState) { expect(editorState.document.root.children.length, 3); final node1 = editorState.getNodeAtPath([0])!; final node2 = editorState.getNodeAtPath([1])!; final node3 = editorState.getNodeAtPath([2])!; expect(node1.type, ParagraphBlockKeys.type); expect(node2.type, ParagraphBlockKeys.type); expect(node3.type, ParagraphBlockKeys.type); expect(node1.delta!.toJson(), [ {'insert': 'void main() {'}, ]); expect(node2.delta!.toJson(), [ {'insert': " runApp(const MyApp());"}, ]); expect(node3.delta!.toJson(), [ {"insert": "}"}, ]); }); }); testWidgets('paste bulleted list in numbered list', (tester) async { const inAppJson = '{"document":{"type":"page","children":[{"type":"bulleted_list","children":[{"type":"bulleted_list","data":{"delta":[{"insert":"World"}]}}],"data":{"delta":[{"insert":"Hello"}]}}]}}'; await tester.pasteContent( inAppJson: inAppJson, beforeTest: (editorState) async { final transaction = editorState.transaction; // Insert two numbered list nodes // 1. Parent One // 2. transaction.insertNodes( [0], [ Node( type: NumberedListBlockKeys.type, attributes: { 'delta': [ {"insert": "One"}, ], }, ), Node( type: NumberedListBlockKeys.type, attributes: {'delta': []}, ), ], ); // Set the selection to the second numbered list node (which has empty delta) transaction.afterSelection = Selection.collapsed(Position(path: [1])); await editorState.apply(transaction); await tester.pumpAndSettle(); }, (editorState) { final secondNode = editorState.getNodeAtPath([1]); expect(secondNode?.delta?.toPlainText(), 'Hello'); expect(secondNode?.children.length, 1); final childNode = secondNode?.children.first; expect(childNode?.delta?.toPlainText(), 'World'); expect(childNode?.type, BulletedListBlockKeys.type); }, ); }); testWidgets('paste text on part of bullet list', (tester) async { const plainText = 'test'; await tester.pasteContent( plainText: plainText, beforeTest: (editorState) async { final transaction = editorState.transaction; transaction.insertNodes( [0], [ Node( type: BulletedListBlockKeys.type, attributes: { 'delta': [ {"insert": "bullet list"}, ], }, ), ], ); // Set the selection to the second numbered list node (which has empty delta) transaction.afterSelection = Selection( start: Position(path: [0], offset: 7), end: Position(path: [0], offset: 11), ); await editorState.apply(transaction); await tester.pumpAndSettle(); }, (editorState) { final node = editorState.getNodeAtPath([0]); expect(node?.delta?.toPlainText(), 'bullet test'); expect(node?.type, BulletedListBlockKeys.type); }, ); }); testWidgets('paste image(png) from memory', (tester) async { final image = await rootBundle.load('assets/test/images/sample.png'); final bytes = image.buffer.asUint8List(); await tester.pasteContent(image: ('png', bytes), (editorState) { expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, ImageBlockKeys.type); expect(node.attributes[ImageBlockKeys.url], isNotNull); }); }); testWidgets('paste image(jpeg) from memory', (tester) async { final image = await rootBundle.load('assets/test/images/sample.jpeg'); final bytes = image.buffer.asUint8List(); await tester.pasteContent(image: ('jpeg', bytes), (editorState) { expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, ImageBlockKeys.type); expect(node.attributes[ImageBlockKeys.url], isNotNull); }); }); testWidgets('paste image(gif) from memory', (tester) async { final image = await rootBundle.load('assets/test/images/sample.gif'); final bytes = image.buffer.asUint8List(); await tester.pasteContent(image: ('gif', bytes), (editorState) { expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, ImageBlockKeys.type); expect(node.attributes[ImageBlockKeys.url], isNotNull); }); }); testWidgets( 'format the selected text to href when pasting url if available', (tester) async { const text = 'appflowy'; const url = 'https://appflowy.io'; await tester.pasteContent( plainText: url, beforeTest: (editorState) async { await tester.ime.insertText(text); await tester.editor.updateSelection( Selection.single( path: [0], startOffset: 0, endOffset: text.length, ), ); }, (editorState) { final node = editorState.getNodeAtPath([0])!; expect(node.type, ParagraphBlockKeys.type); expect(node.delta!.toJson(), [ { 'insert': text, 'attributes': {'href': url}, } ]); }, ); }, ); // https://github.com/AppFlowy-IO/AppFlowy/issues/3263 testWidgets( 'paste the image from clipboard when html and image are both available', (tester) async { const html = '''image'''; final image = await rootBundle.load('assets/test/images/sample.png'); final bytes = image.buffer.asUint8List(); await tester.pasteContent( html: html, image: ('png', bytes), (editorState) { expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, ImageBlockKeys.type); }, ); }, ); testWidgets('paste the html content contains section', (tester) async { const html = '''
AppFlowy
Hello World
'''; await tester.pasteContent(html: html, (editorState) { expect(editorState.document.root.children.length, 2); final node1 = editorState.getNodeAtPath([0])!; final node2 = editorState.getNodeAtPath([1])!; expect(node1.type, ParagraphBlockKeys.type); expect(node2.type, ParagraphBlockKeys.type); }); }); testWidgets('paste the html from google translation', (tester) async { const html = '''
new force
Assessment focus: potential motivations, empathy

➢Personality characteristics and potential motivations:
-Reflection of self-worth
-Need to be respected
-Have a unique definition of success
-Be true to your own lifestyle
'''; await tester.pasteContent(html: html, (editorState) { expect(editorState.document.root.children.length, 8); }); }); testWidgets( 'auto convert url to link preview block', (tester) async { const url = 'https://appflowy.io'; await tester.pasteContent(plainText: url, (editorState) async { final pasteAsMenu = find.byType(PasteAsMenu); expect(pasteAsMenu, findsOneWidget); final bookmarkButton = find.text( LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(), ); await tester.tapButton(bookmarkButton); // the second one is the paragraph node expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, LinkPreviewBlockKeys.type); expect(node.attributes[LinkPreviewBlockKeys.url], url); }); // hover on the link preview block // click the more button // and select convert to link await tester.hoverOnWidget( find.byType(CustomLinkPreviewWidget), onHover: () async { /// show menu final menu = find.byType(CustomLinkPreviewMenu); expect(menu, findsOneWidget); await tester.tapButton(menu); final convertToLinkButton = find.text( LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl .tr(), ); expect(convertToLinkButton, findsOneWidget); await tester.tapButton(convertToLinkButton); }, ); final editorState = tester.editor.getCurrentEditorState(); final textNode = editorState.getNodeAtPath([0])!; expect(textNode.type, ParagraphBlockKeys.type); expect(textNode.delta!.toJson(), [ { 'insert': url, 'attributes': {'href': url}, } ]); }, ); testWidgets( 'ctrl/cmd+z to undo the auto convert url to link preview block', (tester) async { const url = 'https://appflowy.io'; await tester.pasteContent(plainText: url, (editorState) async { final pasteAsMenu = find.byType(PasteAsMenu); expect(pasteAsMenu, findsOneWidget); final bookmarkButton = find.text( LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(), ); await tester.tapButton(bookmarkButton); // the second one is the paragraph node expect(editorState.document.root.children.length, 1); final node = editorState.getNodeAtPath([0])!; expect(node.type, LinkPreviewBlockKeys.type); expect(node.attributes[LinkPreviewBlockKeys.url], url); }); await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: UniversalPlatform.isLinux || UniversalPlatform.isWindows, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); final editorState = tester.editor.getCurrentEditorState(); final node = editorState.getNodeAtPath([0])!; expect(node.type, ParagraphBlockKeys.type); expect(node.delta!.toJson(), [ { 'insert': url, 'attributes': {'href': url}, } ]); }, ); testWidgets( 'paste the nodes start with non-delta node', (tester) async { await tester.pasteContent((_) {}); const text = 'Hello World'; final editorState = tester.editor.getCurrentEditorState(); final transaction = editorState.transaction; // [image_block] // [paragraph_block] transaction.insertNodes([ 0, ], [ customImageNode(url: ''), paragraphNode(text: text), ]); await editorState.apply(transaction); await tester.pumpAndSettle(); await tester.editor.tapLineOfEditorAt(0); // select all and copy await tester.simulateKeyEvent( LogicalKeyboardKey.keyA, isControlPressed: UniversalPlatform.isLinux || UniversalPlatform.isWindows, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyC, isControlPressed: UniversalPlatform.isLinux || UniversalPlatform.isWindows, isMetaPressed: UniversalPlatform.isMacOS, ); // put the cursor to the end of the paragraph block await tester.editor.tapLineOfEditorAt(0); // paste the content await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: UniversalPlatform.isLinux || UniversalPlatform.isWindows, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); // expect the image and the paragraph block are inserted below the cursor expect(editorState.getNodeAtPath([0])!.type, CustomImageBlockKeys.type); expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type); expect(editorState.getNodeAtPath([2])!.type, CustomImageBlockKeys.type); expect(editorState.getNodeAtPath([3])!.type, ParagraphBlockKeys.type); }, ); testWidgets('paste the url without protocol', (tester) async { // paste the image that from local file const plainText = '1.jpg'; final image = await rootBundle.load('assets/test/images/sample.jpeg'); final bytes = image.buffer.asUint8List(); await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes), (editorState) { final node = editorState.getNodeAtPath([0])!; expect(node.type, ImageBlockKeys.type); expect(node.attributes[ImageBlockKeys.url], isNotEmpty); }); }); testWidgets('paste the image url', (tester) async { const plainText = 'http://example.com/1.jpg'; final image = await rootBundle.load('assets/test/images/sample.jpeg'); final bytes = image.buffer.asUint8List(); await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes), (editorState) { final node = editorState.getNodeAtPath([0])!; expect(node.type, ImageBlockKeys.type); expect(node.attributes[ImageBlockKeys.url], isNotEmpty); }); }); const testMarkdownText = ''' # I'm h1 ## I'm h2 ### I'm h3 #### I'm h4 ##### I'm h5 ###### I'm h6'''; testWidgets('paste markdowns', (tester) async { await tester.pasteContent( plainText: testMarkdownText, (editorState) { final children = editorState.document.root.children; expect(children.length, 6); for (int i = 1; i <= children.length; i++) { final text = children[i - 1].delta!.toPlainText(); expect(text, 'I\'m h$i'); } }, ); }); testWidgets('paste markdowns as plain', (tester) async { await tester.pasteContent( plainText: testMarkdownText, pasteAsPlain: true, (editorState) { final children = editorState.document.root.children; expect(children.length, 6); for (int i = 1; i <= children.length; i++) { final text = children[i - 1].delta!.toPlainText(); final expectText = '${'#' * i} I\'m h$i'; expect(text, expectText); } }, ); }); }); } extension on WidgetTester { Future pasteContent( FutureOr Function(EditorState editorState) test, { Future Function(EditorState editorState)? beforeTest, String? plainText, String? html, String? inAppJson, bool pasteAsPlain = false, (String, Uint8List?)? image, }) async { await initializeAppFlowy(); await tapAnonymousSignInButton(); // create a new document await createNewPageWithNameUnderParent(); // tap the editor await tapButton(find.byType(AppFlowyEditor)); await beforeTest?.call(editor.getCurrentEditorState()); // mock the clipboard await getIt().setData( ClipboardServiceData( plainText: plainText, html: html, inAppJson: inAppJson, image: image, ), ); // paste the text await simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isShiftPressed: pasteAsPlain, isMetaPressed: Platform.isMacOS, ); await pumpAndSettle(const Duration(milliseconds: 1000)); await test(editor.getCurrentEditorState()); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('create and delete the document:', () { testWidgets('create a new document when launching app in first time', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final finder = find.text(gettingStarted, findRichText: true); await tester.pumpUntilFound(finder, timeout: const Duration(seconds: 2)); // create a new document const pageName = 'Test Document'; await tester.createNewPageWithNameUnderParent(name: pageName); // expect to see a new document tester.expectToSeePageName(pageName); // and with one paragraph block expect(find.byType(ParagraphBlockComponentWidget), findsOneWidget); }); testWidgets('delete the readme page and restore it', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // delete the readme page await tester.hoverOnPageName( gettingStarted, onHover: () async => tester.tapDeletePageButton(), ); // the banner should show up and the readme page should be gone tester.expectToSeeDocumentBanner(); tester.expectNotToSeePageName(gettingStarted); // restore the readme page await tester.tapRestoreButton(); // the banner should be gone and the readme page should be back tester.expectNotToSeeDocumentBanner(); tester.expectToSeePageName(gettingStarted); }); testWidgets('delete the readme page and delete it permanently', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // delete the readme page await tester.hoverOnPageName( gettingStarted, onHover: () async => tester.tapDeletePageButton(), ); // the banner should show up and the readme page should be gone tester.expectToSeeDocumentBanner(); tester.expectNotToSeePageName(gettingStarted); // delete the page permanently await tester.tapDeletePermanentlyButton(); // the banner should be gone and the readme page should be gone tester.expectNotToSeeDocumentBanner(); tester.expectNotToSeePageName(gettingStarted); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_customer_test.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('customer:', () { testWidgets('backtick issue - inline code', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const pageName = 'backtick issue'; await tester.createNewPageWithNameUnderParent(name: pageName); // focus on the editor await tester.tap(find.byType(AppFlowyEditor)); // input backtick const text = '`Hello` AppFlowy'; for (var i = 0; i < text.length; i++) { await tester.ime.insertCharacter(text[i]); } final node = tester.editor.getNodeAtPath([0]); expect( node.delta?.toJson(), equals([ { "insert": "Hello", "attributes": {"code": true}, }, {"insert": " AppFlowy"}, ]), ); }); testWidgets('backtick issue - inline code', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const pageName = 'backtick issue'; await tester.createNewPageWithNameUnderParent(name: pageName); // focus on the editor await tester.tap(find.byType(AppFlowyEditor)); // input backtick const text = '```'; for (var i = 0; i < text.length; i++) { await tester.ime.insertCharacter(text[i]); } final node = tester.editor.getNodeAtPath([0]); expect(node.type, equals(CodeBlockKeys.type)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_deletion_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; import 'document_inline_page_reference_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Document deletion', () { testWidgets('Trash breadcrumb', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // This test shares behavior with the inline page reference test, thus // we utilize the same helper functions there. final name = await createDocumentToReference(tester); await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); await triggerReferenceDocumentBySlashMenu(tester); // Search for prefix of document await enterDocumentText(tester); // Select result final optionFinder = find.descendant( of: find.byType(InlineActionsHandler), matching: find.text(name), ); await tester.tap(optionFinder); await tester.pumpAndSettle(); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); // Delete the page await tester.hoverOnPageName( name, onHover: () async => tester.tapDeletePageButton(), ); await tester.pumpAndSettle(); // Navigate to the deleted page from the inline mention await tester.tap(mentionBlock); await tester.pumpUntilFound(find.byType(TrashBreadcrumb)); expect(find.byType(TrashBreadcrumb), findsOneWidget); // Navigate using the trash breadcrumb await tester.tap( find.descendant( of: find.byType(TrashBreadcrumb), matching: find.text( LocaleKeys.trash_text.tr(), ), ), ); await tester.pumpUntilFound(find.text(LocaleKeys.trash_restoreAll.tr())); // Restore all await tester.tap(find.text(LocaleKeys.trash_restoreAll.tr())); await tester.pumpAndSettle(); await tester.tap(find.text(LocaleKeys.trash_restore.tr())); await tester.pumpAndSettle(); // Navigate back to the document await tester.openPage('Getting started'); await tester.pumpAndSettle(); await tester.tap(mentionBlock); await tester.pumpAndSettle(); expect(find.byType(TrashBreadcrumb), findsNothing); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart ================================================ import 'dart:math'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); String generateRandomString(int len) { final r = Random(); return String.fromCharCodes( List.generate(len, (index) => r.nextInt(33) + 89), ); } testWidgets( 'document find menu test', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); // tap editor to get focus await tester.tapButton(find.byType(AppFlowyEditor)); // set clipboard data final data = [ "123456\n\n", ...List.generate(100, (_) => "${generateRandomString(50)}\n\n"), "1234567\n\n", ...List.generate(100, (_) => "${generateRandomString(50)}\n\n"), "12345678\n\n", ...List.generate(100, (_) => "${generateRandomString(50)}\n\n"), ].join(); await getIt().setData( ClipboardServiceData( plainText: data, ), ); // paste await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: UniversalPlatform.isLinux || UniversalPlatform.isWindows, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); // go back to beginning of document // FIXME: Cannot run Ctrl+F unless selection is on screen await tester.editor .updateSelection(Selection.collapsed(Position(path: [0]))); await tester.pumpAndSettle(); expect(find.byType(FindAndReplaceMenuWidget), findsNothing); // press cmd/ctrl+F to display the find menu await tester.simulateKeyEvent( LogicalKeyboardKey.keyF, isControlPressed: UniversalPlatform.isLinux || UniversalPlatform.isWindows, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget); final textField = find.descendant( of: find.byType(FindAndReplaceMenuWidget), matching: find.byType(TextField), ); await tester.enterText( textField, "123456", ); await tester.pumpAndSettle(); await tester.pumpAndSettle(); expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.text("123456", findRichText: true), ), findsOneWidget, ); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); await tester.pumpAndSettle(); expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.text("1234567", findRichText: true), ), findsOneWidget, ); await tester.showKeyboard(textField); await tester.idle(); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); await tester.pumpAndSettle(); expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.text("12345678", findRichText: true), ), findsOneWidget, ); // tap next button, go back to beginning of document await tester.tapButton( find.descendant( of: find.byType(FindMenu), matching: find.byFlowySvg(FlowySvgs.arrow_down_s), ), ); expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.text("123456", findRichText: true), ), findsOneWidget, ); /// press cmd/ctrl+F to display the find menu await tester.simulateKeyEvent( LogicalKeyboardKey.keyF, isControlPressed: UniversalPlatform.isLinux || UniversalPlatform.isWindows, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget); /// press esc to dismiss the find menu await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect(find.byType(FindAndReplaceMenuWidget), findsNothing); }, ); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_inline_page_reference_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/keyboard.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('insert inline document reference', () { testWidgets('insert by slash menu', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final name = await createDocumentToReference(tester); await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); await triggerReferenceDocumentBySlashMenu(tester); // Search for prefix of document await enterDocumentText(tester); // Select result final optionFinder = find.descendant( of: find.byType(InlineActionsHandler), matching: find.text(name), ); await tester.tap(optionFinder); await tester.pumpAndSettle(); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); }); testWidgets('insert by `[[` character shortcut', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final name = await createDocumentToReference(tester); await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); await tester.ime.insertText('[['); await tester.pumpAndSettle(); // Select result await tester.editor.tapAtMenuItemWithName(name); await tester.pumpAndSettle(); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); }); testWidgets('insert by `+` character shortcut', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final name = await createDocumentToReference(tester); await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); await tester.ime.insertText('+'); await tester.pumpAndSettle(); // Select result await tester.editor.tapAtMenuItemWithName(name); await tester.pumpAndSettle(); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); }); }); } Future createDocumentToReference(WidgetTester tester) async { final name = 'document_${uuid()}'; await tester.createNewPageWithNameUnderParent( name: name, openAfterCreated: false, ); // This is a workaround since the openAfterCreated // option does not work in createNewPageWithName method await tester.tap(find.byType(SingleInnerViewItem).first); await tester.pumpAndSettle(); return name; } Future triggerReferenceDocumentBySlashMenu(WidgetTester tester) async { await tester.editor.showSlashMenu(); await tester.pumpAndSettle(); // Search for referenced document action await enterDocumentText(tester); // Select item await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.enter, ], tester: tester, withKeyUp: true, ); await tester.pumpAndSettle(); } Future enterDocumentText(WidgetTester tester) async { await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.keyD, LogicalKeyboardKey.keyO, LogicalKeyboardKey.keyC, LogicalKeyboardKey.keyU, LogicalKeyboardKey.keyM, LogicalKeyboardKey.keyE, LogicalKeyboardKey.keyN, LogicalKeyboardKey.keyT, ], tester: tester, withKeyUp: true, ); await tester.pumpAndSettle(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_inline_sub_page_test.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/keyboard.dart'; import '../../shared/util.dart'; const _firstDocName = "Inline Sub Page Mention"; const _createdPageName = "hi world"; // Test cases that are covered in this file: // - [x] Insert sub page mention from action menu (+) // - [x] Delete sub page mention from editor // - [x] Delete page from sidebar // - [x] Delete page from sidebar and then trash // - [x] Undo delete sub page mention // - [x] Cut+paste in same document // - [x] Cut+paste in different document // - [x] Cut+paste in same document and then paste again in same document // - [x] Turn paragraph with sub page mention into a heading // - [x] Turn heading with sub page mention into a paragraph // - [x] Duplicate a Block containing two sub page mentions void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document inline sub-page mention tests:', () { testWidgets('Insert (& delete) a sub page mention from action menu', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); await tester.insertInlineSubPageFromPlusMenu(); await tester.expandOrCollapsePage( pageName: _firstDocName, layout: ViewLayoutPB.Document, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsOneWidget); // Delete from editor await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNothing); expect(find.byType(MentionSubPageBlock), findsNothing); // Undo await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsOneWidget); // Move to trash (delete from sidebar) await tester.rightClickOnPageName(_createdPageName); await tester.tapButtonWithName(ViewMoreActionType.delete.name); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsOneWidget); expect(find.byType(MentionSubPageBlock), findsOneWidget); expect( find.text(LocaleKeys.document_mention_trashHint.tr()), findsOneWidget, ); // Delete from trash await tester.tapTrashButton(); await tester.pumpAndSettle(); await tester.tap(find.text(LocaleKeys.trash_deleteAll.tr())); await tester.pumpAndSettle(); await tester.tap(find.text(LocaleKeys.button_delete.tr())); await tester.pumpAndSettle(); await tester.openPage(_firstDocName); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNothing); expect(find.byType(MentionSubPageBlock), findsOneWidget); expect( find.text(LocaleKeys.document_mention_deletedPage.tr()), findsOneWidget, ); }); testWidgets( 'Cut+paste in same document and cut+paste in different document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); await tester.insertInlineSubPageFromPlusMenu(); await tester.expandOrCollapsePage( pageName: _firstDocName, layout: ViewLayoutPB.Document, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsOneWidget); // Cut from editor await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyX, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNothing); expect(find.byType(MentionSubPageBlock), findsNothing); // Paste in same document await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsOneWidget); // Cut again await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyX, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); // Create another document const anotherDocName = "Another Document"; await tester.createOpenRenameDocumentUnderParent( name: anotherDocName, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNothing); expect(find.byType(MentionSubPageBlock), findsNothing); await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); // Paste in document await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpUntilFound(find.byType(MentionSubPageBlock)); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsOneWidget); await tester.expandOrCollapsePage( pageName: anotherDocName, layout: ViewLayoutPB.Document, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); }); testWidgets( 'Cut+paste in same docuemnt and then paste again in same document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); await tester.insertInlineSubPageFromPlusMenu(); await tester.expandOrCollapsePage( pageName: _firstDocName, layout: ViewLayoutPB.Document, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsOneWidget); // Cut from editor await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyX, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNothing); expect(find.byType(MentionSubPageBlock), findsNothing); // Paste in same document await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsOneWidget); // Paste again await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(const Duration(seconds: 2)); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsNWidgets(2)); expect(find.text('$_createdPageName (copy)'), findsNWidgets(2)); }); testWidgets('Turn into w/ sub page mentions', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); await tester.insertInlineSubPageFromPlusMenu(); await tester.expandOrCollapsePage( pageName: _firstDocName, layout: ViewLayoutPB.Document, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsOneWidget); final headingText = LocaleKeys.document_slashMenu_name_heading1.tr(); final paragraphText = LocaleKeys.document_slashMenu_name_text.tr(); // Turn into heading await tester.editor.openTurnIntoMenu([0]); await tester.tapButton(find.findTextInFlowyText(headingText)); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsOneWidget); // Turn into paragraph await tester.editor.openTurnIntoMenu([0]); await tester.tapButton(find.findTextInFlowyText(paragraphText)); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsNWidgets(2)); expect(find.byType(MentionSubPageBlock), findsOneWidget); }); testWidgets('Duplicate a block containing two sub page mentions', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); await tester.insertInlineSubPageFromPlusMenu(); // Copy paste it await tester.editor.updateSelection( Selection( start: Position(path: [0]), end: Position(path: [0], offset: 1), ), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyC, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsOneWidget); expect(find.text("$_createdPageName (copy)"), findsOneWidget); expect(find.byType(MentionSubPageBlock), findsNWidgets(2)); // Duplicate node from block action menu await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr()); await tester.pumpAndSettle(); expect(find.text(_createdPageName), findsOneWidget); expect(find.text("$_createdPageName (copy)"), findsNWidgets(2)); expect(find.text("$_createdPageName (copy) (copy)"), findsOneWidget); }); testWidgets('Cancel inline page reference menu by space', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); await tester.editor.tapLineOfEditorAt(0); await tester.editor.showPlusMenu(); // Cancel by space await tester.simulateKeyEvent( LogicalKeyboardKey.space, ); await tester.pumpAndSettle(); expect(find.byType(InlineActionsMenu), findsNothing); }); }); } extension _InlineSubPageTestHelper on WidgetTester { Future insertInlineSubPageFromPlusMenu() async { await editor.tapLineOfEditorAt(0); await editor.showPlusMenu(); // Workaround to allow typing a document name await FlowyTestKeyboard.simulateKeyDownEvent( tester: this, withKeyUp: true, [ LogicalKeyboardKey.keyH, LogicalKeyboardKey.keyI, LogicalKeyboardKey.space, LogicalKeyboardKey.keyW, LogicalKeyboardKey.keyO, LogicalKeyboardKey.keyR, LogicalKeyboardKey.keyL, LogicalKeyboardKey.keyD, ], ); await FlowyTestKeyboard.simulateKeyDownEvent( tester: this, withKeyUp: true, [LogicalKeyboardKey.enter], ); await pumpUntilFound(find.byType(MentionSubPageBlock)); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); const avaliableLink = 'https://appflowy.io/', unavailableLink = 'www.thereIsNoting.com'; Future preparePage(WidgetTester tester, {String? pageName}) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: pageName); await tester.editor.tapLineOfEditorAt(0); } Future pasteLink(WidgetTester tester, String link) async { await getIt() .setData(ClipboardServiceData(plainText: link)); /// paste the link await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(Duration(seconds: 1)); } Future pasteAs( WidgetTester tester, String link, PasteMenuType type, { Duration waitTime = const Duration(milliseconds: 500), }) async { await pasteLink(tester, link); final convertToMentionButton = find.text(type.title); await tester.tapButton(convertToMentionButton); await tester.pumpAndSettle(waitTime); } void checkUrl(Node node, String link) { expect(node.type, ParagraphBlockKeys.type); expect(node.delta!.toJson(), [ { 'insert': link, 'attributes': {'href': link}, } ]); } void checkMention(Node node, String link) { final delta = node.delta!; final insert = (delta.first as TextInsert).text; final attributes = delta.first.attributes; expect(insert, MentionBlockKeys.mentionChar); final mention = attributes?[MentionBlockKeys.mention] as Map; expect(mention[MentionBlockKeys.type], MentionType.externalLink.name); expect(mention[MentionBlockKeys.url], avaliableLink); } void checkBookmark(Node node, String link) { expect(node.type, LinkPreviewBlockKeys.type); expect(node.attributes[LinkPreviewBlockKeys.url], link); } void checkEmbed(Node node, String link) { expect(node.type, LinkPreviewBlockKeys.type); expect(node.attributes[LinkEmbedKeys.previewType], LinkEmbedKeys.embed); expect(node.attributes[LinkPreviewBlockKeys.url], link); } group('Paste as URL', () { Future pasteAndTurnInto( WidgetTester tester, String link, String title, ) async { await pasteLink(tester, link); final convertToLinkButton = find .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr()); await tester.tapButton(convertToLinkButton); /// hover link and turn into mention await tester.hoverOnWidget( find.byType(LinkHoverTrigger), onHover: () async { final turnintoButton = find.byFlowySvg(FlowySvgs.turninto_m); await tester.tapButton(turnintoButton); final convertToButton = find.text(title); await tester.tapButton(convertToButton); await tester.pumpAndSettle(Duration(seconds: 1)); }, ); } testWidgets('paste a link', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteLink(tester, link); final convertToLinkButton = find .text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr()); await tester.tapButton(convertToLinkButton); final node = tester.editor.getNodeAtPath([0]); checkUrl(node, link); }); testWidgets('paste a link and turn into mention', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAndTurnInto( tester, link, LinkConvertMenuCommand.toMention.title, ); /// check metion values final node = tester.editor.getNodeAtPath([0]); checkMention(node, link); }); testWidgets('paste a link and turn into bookmark', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAndTurnInto( tester, link, LinkConvertMenuCommand.toBookmark.title, ); /// check metion values final node = tester.editor.getNodeAtPath([0]); checkBookmark(node, link); }); testWidgets('paste a link and turn into embed', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAndTurnInto( tester, link, LinkConvertMenuCommand.toEmbed.title, ); /// check metion values final node = tester.editor.getNodeAtPath([0]); checkEmbed(node, link); }); }); group('Paste as Mention', () { Future pasteAsMention(WidgetTester tester, String link) => pasteAs(tester, link, PasteMenuType.mention); String getMentionLink(Node node) { final insert = node.delta?.first as TextInsert?; final mention = insert?.attributes?[MentionBlockKeys.mention] as Map?; return mention?[MentionBlockKeys.url] ?? ''; } Future hoverMentionAndClick( WidgetTester tester, String command, ) async { final mentionLink = find.byType(MentionLinkBlock); expect(mentionLink, findsOneWidget); await tester.hoverOnWidget( mentionLink, onHover: () async { final errorPreview = find.byType(MentionLinkErrorPreview); expect(errorPreview, findsOneWidget); final convertButton = find.byFlowySvg(FlowySvgs.turninto_m); await tester.tapButton(convertButton); final menuButton = find.text(command); await tester.tapButton(menuButton); }, ); } testWidgets('paste a link as mention', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsMention(tester, link); final node = tester.editor.getNodeAtPath([0]); checkMention(node, link); }); testWidgets('paste as mention and copy link', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsMention(tester, link); final mentionLink = find.byType(MentionLinkBlock); expect(mentionLink, findsOneWidget); await tester.hoverOnWidget( mentionLink, onHover: () async { final preview = find.byType(MentionLinkPreview); if (!preview.hasFound) { final copyButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); await tester.tapButton(copyButton); } else { final moreOptionButton = find.byFlowySvg(FlowySvgs.toolbar_more_m); await tester.tapButton(moreOptionButton); final copyButton = find.text(MentionLinktMenuCommand.copyLink.title); await tester.tapButton(copyButton); } }, ); final clipboardContent = await getIt().getData(); expect(clipboardContent.plainText, link); }); testWidgets('paste as error mention and turninto url', (tester) async { String link = unavailableLink; await preparePage(tester); await pasteAsMention(tester, link); Node node = tester.editor.getNodeAtPath([0]); link = getMentionLink(node); await hoverMentionAndClick( tester, MentionLinktErrorMenuCommand.toURL.title, ); node = tester.editor.getNodeAtPath([0]); checkUrl(node, link); }); testWidgets('paste as error mention and turninto embed', (tester) async { String link = unavailableLink; await preparePage(tester); await pasteAsMention(tester, link); Node node = tester.editor.getNodeAtPath([0]); link = getMentionLink(node); await hoverMentionAndClick( tester, MentionLinktErrorMenuCommand.toEmbed.title, ); node = tester.editor.getNodeAtPath([0]); checkEmbed(node, link); }); testWidgets('paste as error mention and turninto bookmark', (tester) async { String link = unavailableLink; await preparePage(tester); await pasteAsMention(tester, link); Node node = tester.editor.getNodeAtPath([0]); link = getMentionLink(node); await hoverMentionAndClick( tester, MentionLinktErrorMenuCommand.toBookmark.title, ); node = tester.editor.getNodeAtPath([0]); checkBookmark(node, link); }); testWidgets('paste as error mention and remove link', (tester) async { String link = unavailableLink; await preparePage(tester); await pasteAsMention(tester, link); Node node = tester.editor.getNodeAtPath([0]); link = getMentionLink(node); await hoverMentionAndClick( tester, MentionLinktErrorMenuCommand.removeLink.title, ); node = tester.editor.getNodeAtPath([0]); expect(node.type, ParagraphBlockKeys.type); expect(node.delta!.toJson(), [ {'insert': link}, ]); }); }); group('Paste as Bookmark', () { Future pasteAsBookmark(WidgetTester tester, String link) => pasteAs(tester, link, PasteMenuType.bookmark); Future hoverAndClick( WidgetTester tester, LinkPreviewMenuCommand command, ) async { final bookmark = find.byType(CustomLinkPreviewBlockComponent); expect(bookmark, findsOneWidget); await tester.hoverOnWidget( bookmark, onHover: () async { final menuButton = find.byFlowySvg(FlowySvgs.toolbar_more_m); await tester.tapButton(menuButton); final commandButton = find.text(command.title); await tester.tapButton(commandButton); }, ); } testWidgets('paste a link as bookmark', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsBookmark(tester, link); final node = tester.editor.getNodeAtPath([0]); checkBookmark(node, link); }); testWidgets('paste a link as bookmark and convert to mention', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsBookmark(tester, link); await hoverAndClick(tester, LinkPreviewMenuCommand.convertToMention); final node = tester.editor.getNodeAtPath([0]); checkMention(node, link); }); testWidgets('paste a link as bookmark and convert to url', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsBookmark(tester, link); await hoverAndClick(tester, LinkPreviewMenuCommand.convertToUrl); final node = tester.editor.getNodeAtPath([0]); checkUrl(node, link); }); testWidgets('paste a link as bookmark and convert to embed', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsBookmark(tester, link); await hoverAndClick(tester, LinkPreviewMenuCommand.convertToEmbed); final node = tester.editor.getNodeAtPath([0]); checkEmbed(node, link); }); testWidgets('paste a link as bookmark and copy link', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsBookmark(tester, link); await hoverAndClick(tester, LinkPreviewMenuCommand.copyLink); final clipboardContent = await getIt().getData(); expect(clipboardContent.plainText, link); }); testWidgets('paste a link as bookmark and replace link', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsBookmark(tester, link); await hoverAndClick(tester, LinkPreviewMenuCommand.replace); await tester.simulateKeyEvent( LogicalKeyboardKey.keyA, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.simulateKeyEvent(LogicalKeyboardKey.delete); await tester.enterText(find.byType(TextFormField), unavailableLink); await tester.tapButton(find.text(LocaleKeys.button_replace.tr())); final node = tester.editor.getNodeAtPath([0]); checkBookmark(node, unavailableLink); }); testWidgets('paste a link as bookmark and remove link', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsBookmark(tester, link); await hoverAndClick(tester, LinkPreviewMenuCommand.removeLink); final node = tester.editor.getNodeAtPath([0]); expect(node.type, ParagraphBlockKeys.type); expect(node.delta!.toJson(), [ {'insert': link}, ]); }); }); group('Paste as Embed', () { Future pasteAsEmbed(WidgetTester tester, String link) => pasteAs(tester, link, PasteMenuType.embed); Future hoverAndConvert( WidgetTester tester, LinkEmbedConvertCommand command, ) async { final embed = find.byType(LinkEmbedBlockComponent); expect(embed, findsOneWidget); await tester.hoverOnWidget( embed, onHover: () async { final menuButton = find.byFlowySvg(FlowySvgs.turninto_m); await tester.tapButton(menuButton); final commandButton = find.text(command.title); await tester.tapButton(commandButton); }, ); } testWidgets('paste a link as embed', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsEmbed(tester, link); final node = tester.editor.getNodeAtPath([0]); checkEmbed(node, link); }); testWidgets('paste a link as bookmark and convert to mention', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsEmbed(tester, link); await hoverAndConvert(tester, LinkEmbedConvertCommand.toMention); final node = tester.editor.getNodeAtPath([0]); checkMention(node, link); }); testWidgets('paste a link as bookmark and convert to url', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsEmbed(tester, link); await hoverAndConvert(tester, LinkEmbedConvertCommand.toURL); final node = tester.editor.getNodeAtPath([0]); checkUrl(node, link); }); testWidgets('paste a link as bookmark and convert to bookmark', (tester) async { final link = avaliableLink; await preparePage(tester); await pasteAsEmbed(tester, link); await hoverAndConvert(tester, LinkEmbedConvertCommand.toBookmark); final node = tester.editor.getNodeAtPath([0]); checkBookmark(node, link); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_more_actions_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('MoreViewActions', () { testWidgets('can duplicate and delete from menu', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.pumpAndSettle(); final pageFinder = find.byType(ViewItem); expect(pageFinder, findsNWidgets(1)); // Duplicate await tester.openMoreViewActions(); await tester.duplicateByMoreViewActions(); await tester.pumpAndSettle(); expect(pageFinder, findsNWidgets(2)); // Delete await tester.openMoreViewActions(); await tester.deleteByMoreViewActions(); await tester.pumpAndSettle(); expect(pageFinder, findsNWidgets(1)); }); }); testWidgets('count title towards word count', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); Finder title = tester.editor.findDocumentTitle(''); await tester.openMoreViewActions(); final viewMetaInfo = find.byType(ViewMetaInfo); expect(viewMetaInfo, findsOneWidget); ViewMetaInfo viewMetaInfoWidget = viewMetaInfo.evaluate().first.widget as ViewMetaInfo; Counters titleCounter = viewMetaInfoWidget.titleCounters!; expect(titleCounter.charCount, 0); expect(titleCounter.wordCount, 0); /// input [str1] within title const str1 = 'Hello', str2 = '$str1 AppFlowy', str3 = '$str2!', str4 = 'Hello world'; await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.tapButton(title); await tester.enterText(title, str1); await tester.pumpAndSettle(const Duration(seconds: 1)); await tester.openMoreViewActions(); viewMetaInfoWidget = viewMetaInfo.evaluate().first.widget as ViewMetaInfo; titleCounter = viewMetaInfoWidget.titleCounters!; expect(titleCounter.charCount, str1.length); expect(titleCounter.wordCount, 1); /// input [str2] within title title = tester.editor.findDocumentTitle(str1); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.tapButton(title); await tester.enterText(title, str2); await tester.pumpAndSettle(const Duration(seconds: 1)); await tester.openMoreViewActions(); viewMetaInfoWidget = viewMetaInfo.evaluate().first.widget as ViewMetaInfo; titleCounter = viewMetaInfoWidget.titleCounters!; expect(titleCounter.charCount, str2.length); expect(titleCounter.wordCount, 2); /// input [str3] within title title = tester.editor.findDocumentTitle(str2); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.tapButton(title); await tester.enterText(title, str3); await tester.pumpAndSettle(const Duration(seconds: 1)); await tester.openMoreViewActions(); viewMetaInfoWidget = viewMetaInfo.evaluate().first.widget as ViewMetaInfo; titleCounter = viewMetaInfoWidget.titleCounters!; expect(titleCounter.charCount, str3.length); expect(titleCounter.wordCount, 2); /// input [str4] within document await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.editor .updateSelection(Selection.collapsed(Position(path: [0]))); await tester.pumpAndSettle(); await tester.editor .getCurrentEditorState() .insertTextAtCurrentSelection(str4); await tester.pumpAndSettle(const Duration(seconds: 1)); await tester.openMoreViewActions(); final texts = find.descendant(of: viewMetaInfo, matching: find.byType(FlowyText)); expect(texts, findsNWidgets(3)); viewMetaInfoWidget = viewMetaInfo.evaluate().first.widget as ViewMetaInfo; titleCounter = viewMetaInfoWidget.titleCounters!; final Counters documentCounters = viewMetaInfoWidget.documentCounters!; final wordCounter = texts.evaluate().elementAt(0).widget as FlowyText, charCounter = texts.evaluate().elementAt(1).widget as FlowyText; final numberFormat = NumberFormat(); expect( wordCounter.text, LocaleKeys.moreAction_wordCount.tr( args: [ numberFormat .format(titleCounter.wordCount + documentCounters.wordCount) .toString(), ], ), ); expect( charCounter.text, LocaleKeys.moreAction_charCount.tr( args: [ numberFormat .format( titleCounter.charCount + documentCounters.charCount, ) .toString(), ], ), ); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // +, ... button beside the block component. group('block option action:', () { Future turnIntoBlock( WidgetTester tester, Path path, { required String menuText, required String afterType, }) async { await tester.editor.openTurnIntoMenu(path); await tester.tapButton( find.findTextInFlowyText(menuText), ); final node = tester.editor.getCurrentEditorState().getNodeAtPath(path); expect(node?.type, afterType); } testWidgets('''click + to add a block after current selection, and click + and option key to add a block before current selection''', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); var editorState = tester.editor.getCurrentEditorState(); expect(editorState.getNodeAtPath([1])?.delta?.toPlainText(), isNotEmpty); // add a new block after the current selection await tester.editor.hoverAndClickOptionAddButton([0], false); // await tester.pumpAndSettle(); expect(editorState.getNodeAtPath([1])?.delta?.toPlainText(), isEmpty); // cancel the selection menu await tester.tapAt(Offset.zero); await tester.editor.hoverAndClickOptionAddButton([0], true); await tester.pumpAndSettle(); expect(editorState.getNodeAtPath([0])?.delta?.toPlainText(), isEmpty); // cancel the selection menu await tester.tapAt(Offset.zero); await tester.tapAt(Offset.zero); await tester.createNewPageWithNameUnderParent(name: 'test'); await tester.openPage(gettingStarted); // check the status again editorState = tester.editor.getCurrentEditorState(); expect(editorState.getNodeAtPath([0])?.delta?.toPlainText(), isEmpty); expect(editorState.getNodeAtPath([2])?.delta?.toPlainText(), isEmpty); }); testWidgets('turn into - single line', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const name = 'Test Document'; await tester.createNewPageWithNameUnderParent(name: name); await tester.openPage(name); await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText('turn into'); // click the block option button to convert it to another blocks final values = { LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type, LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type, LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type, LocaleKeys.editor_bulletedListShortForm.tr(): BulletedListBlockKeys.type, LocaleKeys.editor_numberedListShortForm.tr(): NumberedListBlockKeys.type, LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type, LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type, LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type, LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type, }; for (final value in values.entries) { final menuText = value.key; final afterType = value.value; await turnIntoBlock( tester, [0], menuText: menuText, afterType: afterType, ); } }); testWidgets('turn into - multi lines', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const name = 'Test Document'; await tester.createNewPageWithNameUnderParent(name: name); await tester.openPage(name); await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText('turn into 1'); await tester.ime.insertCharacter('\n'); await tester.ime.insertText('turn into 2'); // click the block option button to convert it to another blocks final values = { LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type, LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type, LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type, LocaleKeys.editor_bulletedListShortForm.tr(): BulletedListBlockKeys.type, LocaleKeys.editor_numberedListShortForm.tr(): NumberedListBlockKeys.type, LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type, LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type, LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type, LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type, }; for (final value in values.entries) { final editorState = tester.editor.getCurrentEditorState(); editorState.selection = Selection( start: Position(path: [0]), end: Position(path: [1], offset: 2), ); final menuText = value.key; final afterType = value.value; await turnIntoBlock( tester, [0], menuText: menuText, afterType: afterType, ); } }); testWidgets( 'selecting the parent should deselect all the child nodes as well', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const name = 'Test Document'; await tester.createNewPageWithNameUnderParent(name: name); await tester.openPage(name); // create a nested list // Item 1 // Nested Item 1 await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText('Item 1'); await tester.ime.insertCharacter('\n'); await tester.simulateKeyEvent(LogicalKeyboardKey.tab); await tester.ime.insertText('Nested Item 1'); // select the 'Nested Item 1' and then tap the option button of the 'Item 1' final editorState = tester.editor.getCurrentEditorState(); final selection = Selection.collapsed( Position(path: [0, 0], offset: 1), ); editorState.selection = selection; await tester.pumpAndSettle(); expect(editorState.selection, selection); await tester.editor.hoverAndClickOptionMenuButton([0]); expect(editorState.selection, Selection.collapsed(Position(path: [0]))); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document selection:', () { testWidgets('select text from start to end by pan gesture ', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); final editor = tester.editor; final editorState = editor.getCurrentEditorState(); // insert a paragraph final transaction = editorState.transaction; transaction.insertNode( [0], paragraphNode( text: '''Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.''', ), ); await editorState.apply(transaction); await tester.pumpAndSettle(Durations.short1); final textBlocks = find.byType(AppFlowyRichText); final topLeft = tester.getTopLeft(textBlocks.at(0)); final gesture = await tester.startGesture( topLeft, pointer: 7, ); await tester.pumpAndSettle(); for (var i = 0; i < 10; i++) { await gesture.moveBy(const Offset(10, 0)); await tester.pump(Durations.short1); } expect(editorState.selection!.start.offset, 0); }); testWidgets('select and delete text', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); /// create a new document await tester.createNewPageWithNameUnderParent(); /// input text final editor = tester.editor; final editorState = editor.getCurrentEditorState(); const inputText = 'Test for text selection and deletion'; final texts = inputText.split(' '); await editor.tapLineOfEditorAt(0); await tester.ime.insertText(inputText); /// selecte and delete int index = 0; while (texts.isNotEmpty) { final text = texts.removeAt(0); await tester.editor.updateSelection( Selection( start: Position(path: [0], offset: index), end: Position(path: [0], offset: index + text.length), ), ); await tester.simulateKeyEvent(LogicalKeyboardKey.delete); index++; } /// excpete the text value is correct final node = editorState.getNodeAtPath([0])!; final nodeText = node.delta?.toPlainText() ?? ''; expect(nodeText, ' ' * (index - 1)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_shortcuts_test.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document shortcuts:', () { testWidgets('custom cut command', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const pageName = 'Test Document Shortcuts'; await tester.createNewPageWithNameUnderParent(name: pageName); // focus on the editor await tester.tap(find.byType(AppFlowyEditor)); // mock the data final editorState = tester.editor.getCurrentEditorState(); final transaction = editorState.transaction; const text1 = '1. First line'; const text2 = '2. Second line'; transaction.insertNodes([ 0, ], [ paragraphNode(text: text1), paragraphNode(text: text2), ]); await editorState.apply(transaction); await tester.pumpAndSettle(); // focus on the end of the first line await tester.editor.updateSelection( Selection.collapsed( Position(path: [0], offset: text1.length), ), ); // press the keybinding await tester.simulateKeyEvent( LogicalKeyboardKey.keyX, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); // check the clipboard final clipboard = await Clipboard.getData(Clipboard.kTextPlain); expect( clipboard?.text, equals(text1), ); final node = tester.editor.getNodeAtPath([0]); expect( node.delta?.toPlainText(), equals(text2), ); // select the whole line await tester.editor.updateSelection( Selection.single( path: [0], startOffset: 0, endOffset: text2.length, ), ); // press the keybinding await tester.simulateKeyEvent( LogicalKeyboardKey.keyX, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); // all the text should be deleted expect( node.delta?.toPlainText(), equals(''), ); final clipboard2 = await Clipboard.getData(Clipboard.kTextPlain); expect( clipboard2?.text, equals(text2), ); }); testWidgets( 'custom copy command - copy whole line when selection is collapsed', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const pageName = 'Test Document Shortcuts'; await tester.createNewPageWithNameUnderParent(name: pageName); // focus on the editor await tester.tap(find.byType(AppFlowyEditor)); // mock the data final editorState = tester.editor.getCurrentEditorState(); final transaction = editorState.transaction; const text1 = '1. First line'; transaction.insertNodes([ 0, ], [ paragraphNode(text: text1), ]); await editorState.apply(transaction); await tester.pumpAndSettle(); // focus on the end of the first line await tester.editor.updateSelection( Selection.collapsed( Position(path: [0], offset: text1.length), ), ); // press the keybinding await tester.simulateKeyEvent( LogicalKeyboardKey.keyC, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); // check the clipboard final clipboard = await Clipboard.getData(Clipboard.kTextPlain); expect( clipboard?.text, equals(text1), ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_sub_page_test.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/emoji.dart'; import '../../shared/util.dart'; // Test cases for the Document SubPageBlock that needs to be covered: // - [x] Insert a new SubPageBlock from Slash menu items (Expect it will create a child view under current view) // - [x] Delete a SubPageBlock from Block Action Menu (Expect the view is moved to trash / deleted) // - [x] Delete a SubPageBlock with backspace when selected (Expect the view is moved to trash / deleted) // - [x] Copy+paste a SubPageBlock in same Document (Expect a new view is created under current view with same content and name) // - [x] Copy+paste a SubPageBlock in different Document (Expect a new view is created under current view with same content and name) // - [x] Cut+paste a SubPageBlock in same Document (Expect the view to be deleted on Cut, and brought back on Paste) // - [x] Cut+paste a SubPageBlock in different Document (Expect the view to be deleted on Cut, and brought back on Paste) // - [x] Undo adding a SubPageBlock (Expect the view to be deleted) // - [x] Undo delete of a SubPageBlock (Expect the view to be brought back to original position) // - [x] Redo adding a SubPageBlock (Expect the view to be restored) // - [x] Redo delete of a SubPageBlock (Expect the view to be moved to trash again) // - [x] Renaming a child view (Expect the view name to be updated in the document) // - [x] Deleting a view (to trash) linked to a SubPageBlock deleted the SubPageBlock (Expect the SubPageBlock to be deleted) // - [x] Duplicating a SubPageBlock node from Action Menu (Expect a new view is created under current view with same content and name + (copy)) // - [x] Dragging a SubPageBlock node to a new position in the document (Expect everything to be normal) /// The defaut page name is empty, if we're looking for a "text" we can look for /// [LocaleKeys.menuAppHeader_defaultNewPageName] but it won't work for eg. hoverOnPageName /// as it looks at the text provided instead of the actual displayed text. /// const _defaultPageName = ""; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); group('Document SubPageBlock tests', () { testWidgets('Insert a new SubPageBlock from Slash menu items', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); expect( find.text(LocaleKeys.menuAppHeader_defaultNewPageName.tr()), findsNWidgets(3), ); }); testWidgets('Rename and then Delete a SubPageBlock from Block Action Menu', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); await tester.pumpAndSettle(); expect(find.text('Child page'), findsNothing); }); testWidgets('Copy+paste a SubPageBlock in same Document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); await tester.editor.hoverAndClickOptionAddButton([0], false); await tester.editor.tapLineOfEditorAt(1); // This is a workaround to allow CTRL+A and CTRL+C to work to copy // the SubPageBlock as well. await tester.ime.insertText('ABC'); await tester.simulateKeyEvent( LogicalKeyboardKey.keyA, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); await tester.simulateKeyEvent( LogicalKeyboardKey.keyC, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); await tester.editor.hoverAndClickOptionAddButton([1], false); await tester.editor.tapLineOfEditorAt(2); await tester.pumpAndSettle(); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(const Duration(seconds: 5)); expect(find.byType(SubPageBlockComponent), findsNWidgets(2)); expect(find.text('Child page'), findsNWidgets(2)); expect(find.text('Child page (copy)'), findsNWidgets(2)); }); testWidgets('Copy+paste a SubPageBlock in different Document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); await tester.editor.hoverAndClickOptionAddButton([0], false); await tester.editor.tapLineOfEditorAt(1); // This is a workaround to allow CTRL+A and CTRL+C to work to copy // the SubPageBlock as well. await tester.ime.insertText('ABC'); await tester.simulateKeyEvent( LogicalKeyboardKey.keyA, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); await tester.simulateKeyEvent( LogicalKeyboardKey.keyC, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock-2'); await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); await tester.expandOrCollapsePage( pageName: 'SubPageBlock-2', layout: ViewLayoutPB.Document, ); expect(find.byType(SubPageBlockComponent), findsOneWidget); expect(find.text('Child page'), findsOneWidget); expect(find.text('Child page (copy)'), findsNWidgets(2)); }); testWidgets('Cut+paste a SubPageBlock in same Document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); await tester.editor .updateSelection(Selection.single(path: [0], startOffset: 0)); await tester.simulateKeyEvent( LogicalKeyboardKey.keyX, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(SubPageBlockComponent), findsNothing); expect(find.text('Child page'), findsNothing); await tester.editor.tapLineOfEditorAt(0); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(SubPageBlockComponent), findsOneWidget); expect(find.text('Child page'), findsNWidgets(2)); }); testWidgets('Cut+paste a SubPageBlock in different Document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); await tester.editor .updateSelection(Selection.single(path: [0], startOffset: 0)); await tester.simulateKeyEvent( LogicalKeyboardKey.keyX, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(SubPageBlockComponent), findsNothing); expect(find.text('Child page'), findsNothing); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock-2'); await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); await tester.expandOrCollapsePage( pageName: 'SubPageBlock-2', layout: ViewLayoutPB.Document, ); expect(find.byType(SubPageBlockComponent), findsOneWidget); expect(find.text('Child page'), findsNWidgets(2)); expect(find.text('Child page (copy)'), findsNothing); }); testWidgets('Undo delete of a SubPageBlock', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); await tester.pumpAndSettle(); expect(find.text('Child page'), findsNothing); expect(find.byType(SubPageBlockComponent), findsNothing); // Since there is no selection active in editor before deleting Node, // we need to give focus back to the editor await tester.editor .updateSelection(Selection.collapsed(Position(path: [0]))); await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.text('Child page'), findsNWidgets(2)); expect(find.byType(SubPageBlockComponent), findsOneWidget); }); // Redo: undoing deleting a subpage block, then redoing to delete it again // -> Add a subpage block // -> Delete // -> Undo // -> Redo testWidgets('Redo delete of a SubPageBlock', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(true); await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); // Delete await tester.editor.hoverAndClickOptionMenuButton([1]); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); await tester.pumpAndSettle(); expect(find.text('Child page'), findsNothing); expect(find.byType(SubPageBlockComponent), findsNothing); await tester.editor.tapLineOfEditorAt(0); // Undo await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(SubPageBlockComponent), findsOneWidget); expect(find.text('Child page'), findsNWidgets(2)); // Redo await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isShiftPressed: true, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(SubPageBlockComponent), findsNothing); expect(find.text('Child page'), findsNothing); }); testWidgets('Delete a view from sidebar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); expect(find.byType(SubPageBlockComponent), findsOneWidget); await tester.hoverOnPageName( 'Child page', onHover: () async { await tester.tapDeletePageButton(); }, ); await tester.pumpAndSettle(const Duration(seconds: 1)); expect(find.text('Child page'), findsNothing); expect(find.byType(SubPageBlockComponent), findsNothing); }); testWidgets('Duplicate SubPageBlock from Block Menu', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(); await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); expect(find.text('Child page'), findsNWidgets(2)); await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr()); await tester.pumpAndSettle(); expect(find.text('Child page'), findsNWidgets(2)); expect(find.text('Child page (copy)'), findsNWidgets(2)); expect(find.byType(SubPageBlockComponent), findsNWidgets(2)); }); testWidgets('Drag SubPageBlock to top of Document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); await tester.insertSubPageFromSlashMenu(true); expect(find.byType(SubPageBlockComponent), findsOneWidget); final beforeNode = tester.editor.getNodeAtPath([1]); await tester.editor.dragBlock([1], const Offset(20, -45)); await tester.pumpAndSettle(Durations.long1); final afterNode = tester.editor.getNodeAtPath([0]); expect(afterNode.type, SubPageBlockKeys.type); expect(afterNode.type, beforeNode.type); expect(find.byType(SubPageBlockComponent), findsOneWidget); }); testWidgets('turn into page', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); final editorState = tester.editor.getCurrentEditorState(); // Insert nested list final transaction = editorState.transaction; transaction.insertNode( [0], bulletedListNode( text: 'Parent', children: [ bulletedListNode(text: 'Child 1'), bulletedListNode(text: 'Child 2'), ], ), ); await editorState.apply(transaction); await tester.pumpAndSettle(); expect(find.byType(SubPageBlockComponent), findsNothing); await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButtonWithName( LocaleKeys.document_plugins_optionAction_turnInto.tr(), ); await tester.pumpAndSettle(); await tester.tap(find.text(LocaleKeys.editor_page.tr())); await tester.pumpAndSettle(); expect(find.byType(SubPageBlockComponent), findsOneWidget); await tester.expandOrCollapsePage( pageName: 'SubPageBlock', layout: ViewLayoutPB.Document, ); expect(find.text('Parent'), findsNWidgets(2)); }); testWidgets('Displaying icon of subpage', (tester) async { const firstPage = 'FirstPage'; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(name: firstPage); final icon = await tester.loadIcon(); /// create subpage await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_subPage_name.tr(), offset: 100, ); /// add icon await tester.editor.hoverOnCoverToolbar(); await tester.editor.tapAddIconButton(); await tester.tapIcon(icon); await tester.pumpAndSettle(); await tester.openPage(firstPage); await tester.expandOrCollapsePage( pageName: firstPage, layout: ViewLayoutPB.Document, ); /// check if there is a icon in document final iconWidget = find.byWidgetPredicate((w) { if (w is! RawEmojiIconWidget) return false; final iconData = w.emoji.emoji; return iconData == icon.emoji; }); expect(iconWidget, findsOneWidget); }); }); } extension _SubPageTestHelper on WidgetTester { Future insertSubPageFromSlashMenu([bool withTextNode = false]) async { await editor.tapLineOfEditorAt(0); if (withTextNode) { await ime.insertText('ABC'); await editor.getCurrentEditorState().insertNewLine(); await pumpAndSettle(); } await editor.showSlashMenu(); await editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_subPage_name.tr(), offset: 100, ); // Navigate to the previous page to see the SubPageBlock await openPage('SubPageBlock'); await pumpAndSettle(); await pumpUntilFound(find.byType(SubPageBlockComponent)); } Future renamePageWithSecondary( String currentName, String newName, ) async { await hoverOnPageName(currentName, onHover: () async => pumpAndSettle()); await rightClickOnPageName(currentName); await tapButtonWithName(ViewMoreActionType.rename.name); await enterText(find.byType(AFTextField), newName); await tapButton(find.text(LocaleKeys.button_confirm.tr())); await pumpAndSettle(); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_1.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'document_create_and_delete_test.dart' as document_create_and_delete_test; import 'document_with_cover_image_test.dart' as document_with_cover_image_test; import 'document_with_database_test.dart' as document_with_database_test; import 'document_with_inline_math_equation_test.dart' as document_with_inline_math_equation_test; import 'document_with_inline_page_test.dart' as document_with_inline_page_test; import 'edit_document_test.dart' as document_edit_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Document integration tests document_create_and_delete_test.main(); document_edit_test.main(); document_with_database_test.main(); document_with_inline_page_test.main(); document_with_inline_math_equation_test.main(); document_with_cover_image_test.main(); // Don't add new tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_2.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'document_app_lifecycle_test.dart' as document_app_lifecycle_test; import 'document_deletion_test.dart' as document_deletion_test; import 'document_inline_sub_page_test.dart' as document_inline_sub_page_test; import 'document_option_action_test.dart' as document_option_action_test; import 'document_title_test.dart' as document_title_test; import 'document_with_date_reminder_test.dart' as document_with_date_reminder_test; import 'document_with_toggle_heading_block_test.dart' as document_with_toggle_heading_block_test; import 'document_sub_page_test.dart' as document_sub_page_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Document integration tests document_title_test.main(); document_app_lifecycle_test.main(); document_with_date_reminder_test.main(); document_deletion_test.main(); document_option_action_test.main(); document_inline_sub_page_test.main(); document_with_toggle_heading_block_test.main(); document_sub_page_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_3.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'document_alignment_test.dart' as document_alignment_test; import 'document_codeblock_paste_test.dart' as document_codeblock_paste_test; import 'document_copy_and_paste_test.dart' as document_copy_and_paste_test; import 'document_text_direction_test.dart' as document_text_direction_test; import 'document_with_outline_block_test.dart' as document_with_outline_block; import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Document integration tests document_with_outline_block.main(); document_with_toggle_list_test.main(); document_copy_and_paste_test.main(); document_codeblock_paste_test.main(); document_alignment_test.main(); document_text_direction_test.main(); // Don't add new tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'document_block_option_test.dart' as document_block_option_test; import 'document_find_menu_test.dart' as document_find_menu_test; import 'document_inline_page_reference_test.dart' as document_inline_page_reference_test; import 'document_more_actions_test.dart' as document_more_actions_test; import 'document_shortcuts_test.dart' as document_shortcuts_test; import 'document_toolbar_test.dart' as document_toolbar_test; import 'document_with_file_test.dart' as document_with_file_test; import 'document_with_image_block_test.dart' as document_with_image_block_test; import 'document_with_multi_image_block_test.dart' as document_with_multi_image_block_test; import 'document_with_simple_table_test.dart' as document_with_simple_table_test; import 'document_link_preview_test.dart' as document_link_preview_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Document integration tests document_with_image_block_test.main(); document_with_multi_image_block_test.main(); document_inline_page_reference_test.main(); document_more_actions_test.main(); document_with_file_test.main(); document_shortcuts_test.main(); document_block_option_test.main(); document_find_menu_test.main(); document_toolbar_test.main(); document_with_simple_table_test.main(); document_link_preview_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_text_direction_test.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('text direction', () { testWidgets( '''no text direction items will be displayed in the default/LTR mode, and three text direction items will be displayed when toggle is enabled.''', (tester) async { // combine the two tests into one to avoid the time-consuming process of initializing the app await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final selection = Selection.single( path: [0], startOffset: 0, endOffset: 1, ); // click the first line of the readme await tester.editor.tapLineOfEditorAt(0); await tester.editor.updateSelection(selection); await tester.pumpAndSettle(); // because this icons are defined in the appflowy_editor package, we can't fetch the icons by SVG data. [textDirectionItems] final textDirectionIconNames = [ 'toolbar/text_direction_auto', 'toolbar/text_direction_ltr', 'toolbar/text_direction_rtl', ]; // no text direction items by default var button = find.byWidgetPredicate( (widget) => widget is SVGIconItemWidget && textDirectionIconNames.contains(widget.iconName), ); expect(button, findsNothing); // switch to the RTL mode await tester.toggleEnableRTLToolbarItems(); await tester.editor.tapLineOfEditorAt(0); await tester.editor.updateSelection(selection); await tester.pumpAndSettle(); button = find.byWidgetPredicate( (widget) => widget is SVGIconItemWidget && textDirectionIconNames.contains(widget.iconName), ); expect(button, findsNWidgets(3)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_title_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../shared/constants.dart'; import '../../shared/util.dart'; const _testDocumentName = 'Test Document'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document title:', () { testWidgets('create a new document and edit title', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); expect(title, findsOneWidget); // input name await tester.enterText(title, _testDocumentName); await tester.pumpAndSettle(); final newTitle = tester.editor.findDocumentTitle(_testDocumentName); expect(newTitle, findsOneWidget); // press enter to create a new line await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); const firstLine = 'First line of text'; await tester.ime.insertText(firstLine); await tester.pumpAndSettle(); final firstLineText = find.text(firstLine, findRichText: true); expect(firstLineText, findsOneWidget); // press cmd/ctrl+left to move the cursor to the start of the line if (UniversalPlatform.isMacOS) { await tester.simulateKeyEvent( LogicalKeyboardKey.arrowLeft, isMetaPressed: true, ); } else { await tester.simulateKeyEvent(LogicalKeyboardKey.home); } await tester.pumpAndSettle(); // press arrow left to delete the first line await tester.simulateKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pumpAndSettle(); // check if the title is on focus final titleOnFocus = tester.editor.findDocumentTitle(_testDocumentName); final titleWidget = tester.widget(titleOnFocus); expect(titleWidget.focusNode?.hasFocus, isTrue); // press the right arrow key to move the cursor to the first line await tester.simulateKeyEvent(LogicalKeyboardKey.arrowRight); // check if the title is not on focus expect(titleWidget.focusNode?.hasFocus, isFalse); final editorState = tester.editor.getCurrentEditorState(); expect(editorState.selection, Selection.collapsed(Position(path: [0]))); // press the backspace key to go to the title await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); expect(editorState.selection, null); expect(titleWidget.focusNode?.hasFocus, isTrue); }); testWidgets('check if the title is saved', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); expect(title, findsOneWidget); // input name await tester.enterText(title, _testDocumentName); await tester.pumpAndSettle(); if (UniversalPlatform.isLinux) { // wait for the name to be saved await tester.wait(250); } // go to the get started page await tester.tapButton( tester.findPageName(Constants.gettingStartedPageName), ); // go back to the page await tester.tapButton(tester.findPageName(_testDocumentName)); // check if the title is saved final testDocumentTitle = tester.editor.findDocumentTitle( _testDocumentName, ); expect(testDocumentTitle, findsOneWidget); }); testWidgets('arrow up from first line moves focus to title', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); await tester.enterText(title, _testDocumentName); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.ime.insertText('First line of text'); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.home); // press the arrow upload await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp); final titleWidget = tester.widget( tester.editor.findDocumentTitle(_testDocumentName), ); expect(titleWidget.focusNode?.hasFocus, isTrue); final editorState = tester.editor.getCurrentEditorState(); expect(editorState.selection, null); }); testWidgets( 'backspace at start of first line moves focus to title and deletes empty paragraph', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); await tester.enterText(title, _testDocumentName); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); final editorState = tester.editor.getCurrentEditorState(); expect(editorState.document.root.children.length, equals(2)); await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); final titleWidget = tester.widget( tester.editor.findDocumentTitle(_testDocumentName), ); expect(titleWidget.focusNode?.hasFocus, isTrue); // at least one empty paragraph node is created expect(editorState.document.root.children.length, equals(1)); }); testWidgets('arrow right from end of title moves focus to first line', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); await tester.enterText(title, _testDocumentName); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.ime.insertText('First line of text'); await tester.tapButton( tester.editor.findDocumentTitle(_testDocumentName), ); await tester.simulateKeyEvent(LogicalKeyboardKey.end); await tester.simulateKeyEvent(LogicalKeyboardKey.arrowRight); final editorState = tester.editor.getCurrentEditorState(); expect( editorState.selection, Selection.collapsed( Position(path: [0]), ), ); }); testWidgets('change the title via sidebar, check the title is updated', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); expect(title, findsOneWidget); await tester.hoverOnPageName( '', onHover: () async { await tester.renamePage(_testDocumentName); await tester.pumpAndSettle(); }, ); await tester.pumpAndSettle(); final newTitle = tester.editor.findDocumentTitle(_testDocumentName); expect(newTitle, findsOneWidget); }); testWidgets('execute undo and redo in title', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); await tester.enterText(title, _testDocumentName); // press a random key to make the undo stack not empty await tester.simulateKeyEvent(LogicalKeyboardKey.keyA); await tester.pumpAndSettle(); // undo await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, ); // wait for the undo to be applied await tester.pumpAndSettle(Durations.long1); // expect the title is empty expect( tester .widget( tester.editor.findDocumentTitle(''), ) .controller ?.text, '', ); // redo await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, isShiftPressed: true, ); await tester.pumpAndSettle(Durations.short1); if (UniversalPlatform.isMacOS) { expect( tester .widget( tester.editor.findDocumentTitle(_testDocumentName), ) .controller ?.text, _testDocumentName, ); } }); testWidgets('escape key should exit the editing mode', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); await tester.enterText(title, _testDocumentName); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect( tester .widget( tester.editor.findDocumentTitle(_testDocumentName), ) .focusNode ?.hasFocus, isFalse, ); }); testWidgets('press arrow down key in title, check if the cursor flashes', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); await tester.enterText(title, _testDocumentName); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); const inputText = 'Hello World'; await tester.ime.insertText(inputText); await tester.tapButton( tester.editor.findDocumentTitle(_testDocumentName), ); await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown); final editorState = tester.editor.getCurrentEditorState(); expect( editorState.selection, Selection.collapsed( Position(path: [0], offset: inputText.length), ), ); }); testWidgets( 'hover on the cover title, check if the add icon & add cover button are shown', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); final title = tester.editor.findDocumentTitle(''); await tester.hoverOnWidget( title, onHover: () async { expect(find.byType(DocumentCoverWidget), findsOneWidget); }, ); await tester.pumpAndSettle(); }); testWidgets('paste text in title, check if the text is updated', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); await Clipboard.setData(const ClipboardData(text: _testDocumentName)); final title = tester.editor.findDocumentTitle(''); await tester.tapButton(title); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isMetaPressed: UniversalPlatform.isMacOS, isControlPressed: !UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); final newTitle = tester.editor.findDocumentTitle(_testDocumentName); expect(newTitle, findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); Future selectText(WidgetTester tester, String text) async { await tester.editor.updateSelection( Selection.single( path: [0], startOffset: 0, endOffset: text.length, ), ); } Future prepareForToolbar(WidgetTester tester, String text) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText(text); await selectText(tester, text); } group('document toolbar:', () { testWidgets('font family', (tester) async { await prepareForToolbar(tester, 'font family'); // tap more options button await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_more_m); // tap the font family button final fontFamilyButton = find.byKey(kFontFamilyToolbarItemKey); await tester.tapButton(fontFamilyButton); // expect to see the font family dropdown immediately expect(find.byType(FontFamilyDropDown), findsOneWidget); // click the font family 'Abel' const abel = 'Abel'; await tester.tapButton(find.text(abel)); // check the text is updated to 'Abel' final editorState = tester.editor.getCurrentEditorState(); expect( editorState.getDeltaAttributeValueInSelection( AppFlowyRichTextKeys.fontFamily, ), abel, ); }); testWidgets('heading 1~3', (tester) async { const text = 'heading'; await prepareForToolbar(tester, text); Future testChangeHeading( FlowySvgData svg, String title, int level, ) async { /// tap suggestions item final suggestionsButton = find.byKey(kSuggestionsItemKey); await tester.tapButton(suggestionsButton); /// tap item await tester.ensureVisible(find.byFlowySvg(svg)); await tester.tapButton(find.byFlowySvg(svg)); /// check the type of node is [HeadingBlockKeys.type] await selectText(tester, text); final editorState = tester.editor.getCurrentEditorState(); final selection = editorState.selection!; final node = editorState.getNodeAtPath(selection.start.path)!, nodeLevel = node.attributes[HeadingBlockKeys.level]!; expect(node.type, HeadingBlockKeys.type); expect(nodeLevel, level); /// show toolbar again await selectText(tester, text); /// the text of suggestions item should be changed expect( find.descendant(of: suggestionsButton, matching: find.text(title)), findsOneWidget, ); } await testChangeHeading( FlowySvgs.type_h1_m, LocaleKeys.document_toolbar_h1.tr(), 1, ); await testChangeHeading( FlowySvgs.type_h2_m, LocaleKeys.document_toolbar_h2.tr(), 2, ); await testChangeHeading( FlowySvgs.type_h3_m, LocaleKeys.document_toolbar_h3.tr(), 3, ); }); testWidgets('toggle 1~3', (tester) async { const text = 'toggle'; await prepareForToolbar(tester, text); Future testChangeToggle( FlowySvgData svg, String title, int? level, ) async { /// tap suggestions item final suggestionsButton = find.byKey(kSuggestionsItemKey); await tester.tapButton(suggestionsButton); /// tap item await tester.ensureVisible(find.byFlowySvg(svg)); await tester.tapButton(find.byFlowySvg(svg)); /// check the type of node is [HeadingBlockKeys.type] await selectText(tester, text); final editorState = tester.editor.getCurrentEditorState(); final selection = editorState.selection!; final node = editorState.getNodeAtPath(selection.start.path)!, nodeLevel = node.attributes[ToggleListBlockKeys.level]; expect(node.type, ToggleListBlockKeys.type); expect(nodeLevel, level); /// show toolbar again await selectText(tester, text); /// the text of suggestions item should be changed expect( find.descendant(of: suggestionsButton, matching: find.text(title)), findsOneWidget, ); } await testChangeToggle( FlowySvgs.type_toggle_list_m, LocaleKeys.editor_toggleListShortForm.tr(), null, ); await testChangeToggle( FlowySvgs.type_toggle_h1_m, LocaleKeys.editor_toggleHeading1ShortForm.tr(), 1, ); await testChangeToggle( FlowySvgs.type_toggle_h2_m, LocaleKeys.editor_toggleHeading2ShortForm.tr(), 2, ); await testChangeToggle( FlowySvgs.type_toggle_h3_m, LocaleKeys.editor_toggleHeading3ShortForm.tr(), 3, ); }); testWidgets('toolbar will not rebuild after click item', (tester) async { const text = 'Test rebuilding'; await prepareForToolbar(tester, text); Finder toolbar = find.byType(DesktopFloatingToolbar); Element toolbarElement = toolbar.evaluate().first; final elementHashcode = toolbarElement.hashCode; final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m), underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m), italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m); /// tap format buttons await tester.tapButton(boldButton); await tester.tapButton(underlineButton); await tester.tapButton(italicButton); toolbar = find.byType(DesktopFloatingToolbar); toolbarElement = toolbar.evaluate().first; /// check if the toolbar is not rebuilt expect(elementHashcode, toolbarElement.hashCode); final editorState = tester.editor.getCurrentEditorState(); /// check text formats expect( editorState .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold), true, ); expect( editorState .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic), true, ); expect( editorState .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline), true, ); }); }); group('document toolbar: link', () { String? getLinkFromNode(Node node) { for (final insert in node.delta!) { final link = insert.attributes?.href; if (link != null) return link; } return null; } bool isPageLink(Node node) { for (final insert in node.delta!) { final isPage = insert.attributes?.isPage; if (isPage == true) return true; } return false; } String getNodeText(Node node) { for (final insert in node.delta!) { if (insert is TextInsert) return insert.text; } return ''; } testWidgets('insert link and remove link', (tester) async { const text = 'insert link', link = 'https://test.appflowy.cloud'; await prepareForToolbar(tester, text); final toolbar = find.byType(DesktopFloatingToolbar); expect(toolbar, findsOneWidget); /// tap link button to show CreateLinkMenu final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); await tester.tapButton(linkButton); final createLinkMenu = find.byType(LinkCreateMenu); expect(createLinkMenu, findsOneWidget); /// test esc to close await tester.simulateKeyEvent(LogicalKeyboardKey.escape); expect(toolbar, findsNothing); /// show toolbar again await tester.editor.tapLineOfEditorAt(0); await selectText(tester, text); await tester.tapButton(linkButton); /// insert link final textField = find.descendant( of: createLinkMenu, matching: find.byType(TextFormField), ); await tester.enterText(textField, link); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); Node node = tester.editor.getNodeAtPath([0]); expect(getLinkFromNode(node), link); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); /// hover link await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); final hoverMenu = find.byType(LinkHoverMenu); expect(hoverMenu, findsOneWidget); /// copy link final copyButton = find.descendant( of: hoverMenu, matching: find.byFlowySvg(FlowySvgs.toolbar_link_m), ); await tester.tapButton(copyButton); final clipboardContent = await getIt().getData(); final plainText = clipboardContent.plainText; expect(plainText, link); /// remove link await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m)); node = tester.editor.getNodeAtPath([0]); expect(getLinkFromNode(node), null); }); testWidgets('insert link and edit link', (tester) async { const text = 'edit link', link = 'https://test.appflowy.cloud', afterText = '$text after'; await prepareForToolbar(tester, text); /// tap link button to show CreateLinkMenu final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); await tester.tapButton(linkButton); /// search for page and select it final textField = find.descendant( of: find.byType(LinkCreateMenu), matching: find.byType(TextFormField), ); await tester.enterText(textField, gettingStarted); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); Node node = tester.editor.getNodeAtPath([0]); expect(isPageLink(node), true); expect(getLinkFromNode(node) == link, false); /// hover link await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); /// click edit button to show LinkEditMenu final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m); await tester.tapButton(editButton); final linkEditMenu = find.byType(LinkEditMenu); expect(linkEditMenu, findsOneWidget); /// change the link text final titleField = find.descendant( of: linkEditMenu, matching: find.byType(TextFormField), ); await tester.enterText(titleField, afterText); await tester.pumpAndSettle(); await tester.tapButton( find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)), ); final linkField = find.ancestor( of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()), matching: find.byType(TextFormField), ); await tester.enterText(linkField, link); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); /// apply the change final applyButton = find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr()); await tester.tapButton(applyButton); node = tester.editor.getNodeAtPath([0]); expect(isPageLink(node), false); expect(getLinkFromNode(node), link); expect(getNodeText(node), afterText); }); testWidgets('insert link and clear link name', (tester) async { const text = 'edit link', link = 'https://test.appflowy.cloud'; await prepareForToolbar(tester, text); /// tap link button to show CreateLinkMenu final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); await tester.tapButton(linkButton); /// search for page and select it final textField = find.descendant( of: find.byType(LinkCreateMenu), matching: find.byType(TextFormField), ); await tester.enterText(textField, link); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); Node node = tester.editor.getNodeAtPath([0]); expect(getLinkFromNode(node), link); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); /// hover link await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); /// click edit button to show LinkEditMenu final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m); await tester.tapButton(editButton); final linkEditMenu = find.byType(LinkEditMenu); expect(linkEditMenu, findsOneWidget); /// clear the link name final titleField = find.descendant( of: linkEditMenu, matching: find.byType(TextFormField), ); await tester.enterText(titleField, ''); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); node = tester.editor.getNodeAtPath([0]); expect(getNodeText(node), link); }); testWidgets('insert link and clear link name and remove link', (tester) async { const text = 'edit link', link = 'https://test.appflowy.cloud'; await prepareForToolbar(tester, text); /// tap link button to show CreateLinkMenu final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); await tester.tapButton(linkButton); /// search for page and select it final textField = find.descendant( of: find.byType(LinkCreateMenu), matching: find.byType(TextFormField), ); await tester.enterText(textField, link); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); Node node = tester.editor.getNodeAtPath([0]); expect(getLinkFromNode(node), link); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); /// hover link await tester.hoverOnWidget(find.byType(LinkHoverTrigger)); /// click edit button to show LinkEditMenu final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m); await tester.tapButton(editButton); final linkEditMenu = find.byType(LinkEditMenu); expect(linkEditMenu, findsOneWidget); /// clear the link name final titleField = find.descendant( of: linkEditMenu, matching: find.byType(TextFormField), ); await tester.enterText(titleField, ''); await tester.pumpAndSettle(); await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m)); node = tester.editor.getNodeAtPath([0]); expect(getNodeText(node), link); expect(getLinkFromNode(node), null); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../shared/emoji.dart'; import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); group('cover image:', () { testWidgets('document cover tests', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); tester.expectToSeeNoDocumentCover(); // Hover over cover toolbar to show 'Add Cover' and 'Add Icon' buttons await tester.editor.hoverOnCoverToolbar(); // Insert a document cover await tester.editor.tapOnAddCover(); tester.expectToSeeDocumentCover(CoverType.asset); // Hover over the cover to show the 'Change Cover' and delete buttons await tester.editor.hoverOnCover(); tester.expectChangeCoverAndDeleteButton(); // Change cover to a solid color background await tester.editor.tapOnChangeCover(); await tester.editor.switchSolidColorBackground(); await tester.editor.dismissCoverPicker(); tester.expectToSeeDocumentCover(CoverType.color); // Change cover to a network image const imageUrl = "https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy/main/frontend/appflowy_flutter/assets/images/appflowy_launch_splash.jpg"; await tester.editor.hoverOnCover(); await tester.editor.tapOnChangeCover(); await tester.editor.addNetworkImageCover(imageUrl); tester.expectToSeeDocumentCover(CoverType.file); // Remove the cover await tester.editor.hoverOnCover(); await tester.editor.tapOnRemoveCover(); tester.expectToSeeNoDocumentCover(); }); testWidgets('document cover local image tests', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); tester.expectToSeeNoDocumentCover(); // Hover over cover toolbar to show 'Add Cover' and 'Add Icon' buttons await tester.editor.hoverOnCoverToolbar(); // Insert a document cover await tester.editor.tapOnAddCover(); tester.expectToSeeDocumentCover(CoverType.asset); // Hover over the cover to show the 'Change Cover' and delete buttons await tester.editor.hoverOnCover(); tester.expectChangeCoverAndDeleteButton(); // Change cover to a local image image final imagePath = await rootBundle.load('assets/test/images/sample.jpeg'); final tempDirectory = await getTemporaryDirectory(); final localImagePath = p.join(tempDirectory.path, 'sample.jpeg'); final imageFile = File(localImagePath) ..writeAsBytesSync(imagePath.buffer.asUint8List()); await tester.editor.hoverOnCover(); await tester.editor.tapOnChangeCover(); final uploadButton = find.findTextInFlowyText( LocaleKeys.document_imageBlock_upload_label.tr(), ); await tester.tapButton(uploadButton); mockPickFilePaths(paths: [localImagePath]); await tester.tapButtonWithName( LocaleKeys.document_imageBlock_upload_placeholder.tr(), ); await tester.pumpAndSettle(); tester.expectToSeeDocumentCover(CoverType.file); // Remove the cover await tester.editor.hoverOnCover(); await tester.editor.tapOnRemoveCover(); tester.expectToSeeNoDocumentCover(); // Test if deleteImageFromLocalStorage(localImagePath) function is called once await tester.pump(kDoubleTapTimeout); expect(deleteImageTestCounter, 1); // delete temp files await imageFile.delete(); }); testWidgets('document icon tests', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); tester.expectToSeeDocumentIcon('⭐️'); // Insert a document icon await tester.editor.tapGettingStartedIcon(); await tester.tapEmoji('😀'); tester.expectToSeeDocumentIcon('😀'); // Remove the document icon from the cover toolbar await tester.editor.hoverOnCoverToolbar(); await tester.editor.tapRemoveIconButton(); tester.expectToSeeDocumentIcon(null); // Add the icon back for further testing await tester.editor.hoverOnCoverToolbar(); await tester.editor.tapAddIconButton(); await tester.tapEmoji('😀'); tester.expectToSeeDocumentIcon('😀'); // Change the document icon await tester.editor.tapOnIconWidget(); await tester.tapEmoji('😅'); tester.expectToSeeDocumentIcon('😅'); // Remove the document icon from the icon picker await tester.editor.tapOnIconWidget(); await tester.editor.tapRemoveIconButton(isInPicker: true); tester.expectToSeeDocumentIcon(null); }); testWidgets('icon and cover at the same time', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); tester.expectToSeeDocumentIcon('⭐️'); tester.expectToSeeNoDocumentCover(); // Insert a document icon await tester.editor.tapGettingStartedIcon(); await tester.tapEmoji('😀'); // Insert a document cover await tester.editor.hoverOnCoverToolbar(); await tester.editor.tapOnAddCover(); // Expect to see the icon and cover at the same time tester.expectToSeeDocumentIcon('😀'); tester.expectToSeeDocumentCover(CoverType.asset); // Hover over the cover toolbar and see that neither icons are shown await tester.editor.hoverOnCoverToolbar(); tester.expectToSeeEmptyDocumentHeaderToolbar(); }); testWidgets('shuffle icon', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.editor.tapGettingStartedIcon(); // click the shuffle button await tester.tapButton( find.byTooltip(LocaleKeys.emoji_random.tr()), ); tester.expectDocumentIconNotNull(); }); testWidgets('change skin tone', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.editor.tapGettingStartedIcon(); final searchEmojiTextField = find.byWidgetPredicate( (widget) => widget is TextField && widget.decoration!.hintText == LocaleKeys.search_label.tr(), ); await tester.enterText( searchEmojiTextField, 'punch', ); // change skin tone await tester.editor.changeEmojiSkinTone(EmojiSkinTone.dark); // select an icon with skin tone const punch = '👊🏿'; await tester.tapEmoji(punch); tester.expectToSeeDocumentIcon(punch); tester.expectViewHasIcon( gettingStarted, ViewLayoutPB.Document, EmojiIconData.emoji(punch), ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart ================================================ import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/row/row.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('database view in document', () { testWidgets('insert a referenced grid', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertLinkedDatabase(tester, ViewLayoutPB.Grid); // validate the referenced grid is inserted expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.byType(GridPage), ), findsOneWidget, ); // https://github.com/AppFlowy-IO/AppFlowy/issues/3533 // test: the selection of editor should be clear when editing the grid await tester.editor.updateSelection( Selection.collapsed( Position(path: [1]), ), ); final gridTextCell = find.byType(EditableTextCell).first; await tester.tapButton(gridTextCell); expect(tester.editor.getCurrentEditorState().selection, isNull); }); testWidgets('insert a referenced board', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertLinkedDatabase(tester, ViewLayoutPB.Board); // validate the referenced board is inserted expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.byType(DesktopBoardPage), ), findsOneWidget, ); }); testWidgets('insert multiple referenced boards', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new grid final id = uuid(); final name = '${ViewLayoutPB.Board.name}_$id'; await tester.createNewPageWithNameUnderParent( name: name, layout: ViewLayoutPB.Board, openAfterCreated: false, ); // create a new document await tester.createNewPageWithNameUnderParent( name: 'insert_a_reference_${ViewLayoutPB.Board.name}', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a referenced view await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( ViewLayoutPB.Board.slashMenuLinkedName, ); final referencedDatabase1 = find.descendant( of: find.byType(InlineActionsHandler), matching: find.findTextInFlowyText(name), ); expect(referencedDatabase1, findsOneWidget); await tester.tapButton(referencedDatabase1); await tester.editor.tapLineOfEditorAt(1); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( ViewLayoutPB.Board.slashMenuLinkedName, ); final referencedDatabase2 = find.descendant( of: find.byType(InlineActionsHandler), matching: find.findTextInFlowyText(name), ); expect(referencedDatabase2, findsOneWidget); await tester.tapButton(referencedDatabase2); expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.byType(DesktopBoardPage), ), findsNWidgets(2), ); }); testWidgets('insert a referenced calendar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertLinkedDatabase(tester, ViewLayoutPB.Calendar); // validate the referenced grid is inserted expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.byType(CalendarPage), ), findsOneWidget, ); }); testWidgets('create a grid inside a document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await createInlineDatabase(tester, ViewLayoutPB.Grid); // validate the inline grid is created expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.byType(GridPage), ), findsOneWidget, ); }); testWidgets('create a board inside a document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await createInlineDatabase(tester, ViewLayoutPB.Board); // validate the inline board is created expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.byType(DesktopBoardPage), ), findsOneWidget, ); }); testWidgets('create a calendar inside a document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await createInlineDatabase(tester, ViewLayoutPB.Calendar); // validate the inline calendar is created expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.byType(CalendarPage), ), findsOneWidget, ); }); testWidgets('insert a referenced grid with many rows (load more option)', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertLinkedDatabase(tester, ViewLayoutPB.Grid); // validate the referenced grid is inserted expect( find.descendant( of: find.byType(AppFlowyEditor), matching: find.byType(GridPage), ), findsOneWidget, ); // https://github.com/AppFlowy-IO/AppFlowy/issues/3533 // test: the selection of editor should be clear when editing the grid await tester.editor.updateSelection( Selection.collapsed( Position(path: [1]), ), ); final gridTextCell = find.byType(EditableTextCell).first; await tester.tapButton(gridTextCell); expect(tester.editor.getCurrentEditorState().selection, isNull); final editorScrollable = find .descendant( of: find.byType(AppFlowyEditor), matching: find.byWidgetPredicate( (w) => w is Scrollable && w.axis == Axis.vertical, ), ) .first; // Add 100 Rows to the linked database final addRowFinder = find.byType(GridAddRowButton); for (var i = 0; i < 100; i++) { await tester.scrollUntilVisible( addRowFinder, 100, scrollable: editorScrollable, ); await tester.tapButton(addRowFinder); await tester.pumpAndSettle(); } // Since all rows visible are those we added, we should see all of them expect(find.byType(GridRow), findsNWidgets(103)); // Navigate to getting started await tester.openPage(gettingStarted); // Navigate back to the document await tester.openPage('insert_a_reference_${ViewLayoutPB.Grid.name}'); // We see only 25 Grid Rows expect(find.byType(GridRow), findsNWidgets(25)); // We see Add row and load more button expect(find.byType(GridAddRowButton), findsOneWidget); expect(find.byType(GridRowLoadMoreButton), findsOneWidget); // Load more rows, expect 50 visible await _loadMoreRows(tester, editorScrollable, 50); // Load more rows, expect 75 visible await _loadMoreRows(tester, editorScrollable, 75); // Load more rows, expect 100 visible await _loadMoreRows(tester, editorScrollable, 100); // Load more rows, expect 103 visible await _loadMoreRows(tester, editorScrollable, 103); // We no longer see load more option expect(find.byType(GridRowLoadMoreButton), findsNothing); }); }); } Future _loadMoreRows( WidgetTester tester, Finder scrollable, [ int? expectedRows, ]) async { await tester.scrollUntilVisible( find.byType(GridRowLoadMoreButton), 100, scrollable: scrollable, ); await tester.pumpAndSettle(); await tester.tap(find.byType(GridRowLoadMoreButton)); await tester.pumpAndSettle(); if (expectedRows != null) { expect(find.byType(GridRow), findsNWidgets(expectedRows)); } } /// Insert a referenced database of [layout] into the document Future insertLinkedDatabase( WidgetTester tester, ViewLayoutPB layout, ) async { // create a new grid final id = uuid(); final name = '${layout.name}_$id'; await tester.createNewPageWithNameUnderParent( name: name, layout: layout, openAfterCreated: false, ); // create a new document await tester.createNewPageWithNameUnderParent( name: 'insert_a_reference_${layout.name}', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a referenced view await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( layout.slashMenuLinkedName, ); final linkToPageMenu = find.byType(InlineActionsHandler); expect(linkToPageMenu, findsOneWidget); final referencedDatabase = find.descendant( of: linkToPageMenu, matching: find.findTextInFlowyText(name), ); expect(referencedDatabase, findsOneWidget); await tester.tapButton(referencedDatabase); } Future createInlineDatabase( WidgetTester tester, ViewLayoutPB layout, ) async { // create a new document final documentName = 'insert_a_inline_${layout.name}'; await tester.createNewPageWithNameUnderParent( name: documentName, ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a referenced view await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( layout.slashMenuName, offset: 100, ); await tester.pumpAndSettle(); final childViews = tester .widget(tester.findPageName(documentName)) .view .childViews; expect(childViews.length, 1); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_date_reminder_test.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:table_calendar/table_calendar.dart'; import '../../shared/util.dart'; void main() { setUp(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); }); group('date or reminder block in document:', () { testWidgets("insert date with time block", (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'Date with time test', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), ); final dateTimeSettings = DateTimeSettingsPB( dateFormat: UserDateFormatPB.Friendly, timeFormat: UserTimeFormatPB.TwentyFourHour, ); final DateTime currentDateTime = DateTime.now(); final String formattedDate = dateTimeSettings.dateFormat.formatDate(currentDateTime, false); // get current date in editor expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate'), findsOneWidget); // tap on date field await tester.tap(find.byType(MentionDateBlock)); await tester.pumpAndSettle(); // tap the toggle of include time await tester.tap(find.byType(Toggle)); await tester.pumpAndSettle(); // add time 11:12 final textField = find .descendant( of: find.byType(DesktopAppFlowyDatePicker), matching: find.byType(TextField), ) .last; await tester.pumpUntilFound(textField); await tester.enterText(textField, "11:12"); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); // we will get field with current date and 11:12 as time expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate 11:12'), findsOneWidget); }); testWidgets("insert date with reminder block", (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'Date with reminder test', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), ); final dateTimeSettings = DateTimeSettingsPB( dateFormat: UserDateFormatPB.Friendly, timeFormat: UserTimeFormatPB.TwentyFourHour, ); final DateTime currentDateTime = DateTime.now(); final String formattedDate = dateTimeSettings.dateFormat.formatDate(currentDateTime, false); // get current date in editor expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate'), findsOneWidget); // tap on date field await tester.tap(find.byType(MentionDateBlock)); await tester.pumpAndSettle(); // tap reminder and set reminder to 1 day before await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr())); await tester.pumpAndSettle(); await tester.tap( find.textContaining( LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), ), ); await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); // we will get field with current date reminder_clock.svg icon expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate'), findsOneWidget); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); }); testWidgets("copy, cut and paste a date mention", (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'copy, cut and paste a date mention', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), ); final dateTimeSettings = DateTimeSettingsPB( dateFormat: UserDateFormatPB.Friendly, timeFormat: UserTimeFormatPB.TwentyFourHour, ); final DateTime currentDateTime = DateTime.now(); final String formattedDate = dateTimeSettings.dateFormat.formatDate(currentDateTime, false); // get current date in editor expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate'), findsOneWidget); // update selection and copy await tester.editor.updateSelection( Selection( start: Position(path: [0]), end: Position(path: [0], offset: 1), ), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyC, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); // update selection and paste await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(MentionDateBlock), findsNWidgets(2)); expect(find.text('@$formattedDate'), findsNWidgets(2)); // update selection and cut await tester.editor.updateSelection( Selection( start: Position(path: [0], offset: 1), end: Position(path: [0], offset: 2), ), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyX, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate'), findsOneWidget); // update selection and paste await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(MentionDateBlock), findsNWidgets(2)); expect(find.text('@$formattedDate'), findsNWidgets(2)); }); testWidgets("copy, cut and paste a reminder mention", (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'copy, cut and paste a reminder mention', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), ); // trigger popup await tester.tapButton(find.byType(MentionDateBlock)); await tester.pumpAndSettle(); // set date to be fifteenth of the last month await tester.tap( find.descendant( of: find.byType(DesktopAppFlowyDatePicker), matching: find.byFlowySvg(FlowySvgs.arrow_left_s), ), ); await tester.pumpAndSettle(); await tester.tap( find.descendant( of: find.byType(TableCalendar), matching: find.text(15.toString()), ), ); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); // add a reminder await tester.tap(find.byType(MentionDateBlock)); await tester.pumpAndSettle(); await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr())); await tester.pumpAndSettle(); await tester.tap( find.textContaining( LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), ), ); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); // verify final dateTimeSettings = DateTimeSettingsPB( dateFormat: UserDateFormatPB.Friendly, timeFormat: UserTimeFormatPB.TwentyFourHour, ); final now = DateTime.now(); final fifteenthOfLastMonth = DateTime(now.year, now.month - 1, 15); final formattedDate = dateTimeSettings.dateFormat.formatDate(fifteenthOfLastMonth, false); expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate'), findsOneWidget); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); expect(getIt().state.reminders.map((e) => e.id).length, 1); // update selection and copy await tester.editor.updateSelection( Selection( start: Position(path: [0]), end: Position(path: [0], offset: 1), ), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyC, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); // update selection and paste await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(MentionDateBlock), findsNWidgets(2)); expect(find.text('@$formattedDate'), findsNWidgets(2)); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2)); expect( getIt().state.reminders.map((e) => e.id).toSet().length, 2, ); // update selection and cut await tester.editor.updateSelection( Selection( start: Position(path: [0], offset: 1), end: Position(path: [0], offset: 2), ), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyX, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate'), findsOneWidget); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); expect(getIt().state.reminders.map((e) => e.id).length, 1); // update selection and paste await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await tester.pumpAndSettle(); expect(find.byType(MentionDateBlock), findsNWidgets(2)); expect(find.text('@$formattedDate'), findsNWidgets(2)); expect(find.byType(MentionDateBlock), findsNWidgets(2)); expect(find.text('@$formattedDate'), findsNWidgets(2)); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2)); expect( getIt().state.reminders.map((e) => e.id).toSet().length, 2, ); }); testWidgets("delete, undo and redo a reminder mention", (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'delete, undo and redo a reminder mention', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), ); // trigger popup await tester.tapButton(find.byType(MentionDateBlock)); await tester.pumpAndSettle(); // set date to be fifteenth of the last month await tester.tap( find.descendant( of: find.byType(DesktopAppFlowyDatePicker), matching: find.byFlowySvg(FlowySvgs.arrow_left_s), ), ); await tester.pumpAndSettle(); await tester.tap( find.descendant( of: find.byType(TableCalendar), matching: find.text(15.toString()), ), ); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); // add a reminder await tester.tap(find.byType(MentionDateBlock)); await tester.pumpAndSettle(); await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr())); await tester.pumpAndSettle(); await tester.tap( find.textContaining( LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), ), ); await tester.pumpAndSettle(); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); // verify final dateTimeSettings = DateTimeSettingsPB( dateFormat: UserDateFormatPB.Friendly, timeFormat: UserTimeFormatPB.TwentyFourHour, ); final now = DateTime.now(); final fifteenthOfLastMonth = DateTime(now.year, now.month - 1, 15); final formattedDate = dateTimeSettings.dateFormat.formatDate(fifteenthOfLastMonth, false); expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate'), findsOneWidget); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); expect(getIt().state.reminders.map((e) => e.id).length, 1); // update selection and backspace to delete the mention await tester.editor.updateSelection( Selection.collapsed(Position(path: [0], offset: 1)), ); await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); await tester.pumpAndSettle(); expect(find.byType(MentionDateBlock), findsNothing); expect(find.text('@$formattedDate'), findsNothing); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing); expect(getIt().state.reminders.isEmpty, isTrue); // undo await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: Platform.isWindows || Platform.isLinux, isMetaPressed: Platform.isMacOS, ); expect(find.byType(MentionDateBlock), findsOneWidget); expect(find.text('@$formattedDate'), findsOneWidget); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); expect(getIt().state.reminders.map((e) => e.id).length, 1); // redo await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: Platform.isWindows || Platform.isLinux, isMetaPressed: Platform.isMacOS, isShiftPressed: true, ); expect(find.byType(MentionDateBlock), findsNothing); expect(find.text('@$formattedDate'), findsNothing); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing); expect(getIt().state.reminders.isEmpty, isTrue); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart ================================================ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); group('file block in document', () { testWidgets('insert a file from local file + rename file', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(name: 'Insert file test'); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_file.tr(), ); expect(find.byType(FileBlockComponent), findsOneWidget); expect(find.byType(FileUploadMenu), findsOneWidget); final image = await rootBundle.load('assets/test/images/sample.jpeg'); final tempDirectory = await getTemporaryDirectory(); final filePath = p.join(tempDirectory.path, 'sample.jpeg'); final file = File(filePath)..writeAsBytesSync(image.buffer.asUint8List()); mockPickFilePaths(paths: [filePath]); await getIt().set(KVKeys.kCloudType, '0'); await tester.tapFileUploadHint(); await tester.pumpAndSettle(); expect(find.byType(FileUploadMenu), findsNothing); expect(find.byType(FileBlockComponent), findsOneWidget); final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; expect(node.type, FileBlockKeys.type); expect(node.attributes[FileBlockKeys.url], isNotEmpty); expect( node.attributes[FileBlockKeys.urlType], FileUrlType.local.toIntValue(), ); // Check the name of the file is correctly extracted expect(node.attributes[FileBlockKeys.name], 'sample.jpeg'); expect(find.text('sample.jpeg'), findsOneWidget); const newName = "Renamed file"; // Hover on the widget to see the three dots to open FileBlockMenu await tester.hoverOnWidget( find.byType(FileBlockComponent), onHover: () async { await tester.tap(find.byType(FileMenuTrigger)); await tester.pumpAndSettle(); await tester.tap( find.text(LocaleKeys.document_plugins_file_renameFile_title.tr()), ); }, ); await tester.pumpAndSettle(); expect(find.byType(FlowyTextField), findsOneWidget); await tester.enterText(find.byType(FlowyTextField), newName); await tester.pump(); await tester.tap(find.text(LocaleKeys.button_save.tr())); await tester.pumpAndSettle(); final updatedNode = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; expect(updatedNode.attributes[FileBlockKeys.name], newName); expect(find.text(newName), findsOneWidget); // remove the temp file file.deleteSync(); }); testWidgets('insert a file from network', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(name: 'Insert file test'); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_file.tr(), ); expect(find.byType(FileBlockComponent), findsOneWidget); expect(find.byType(FileUploadMenu), findsOneWidget); // Navigate to integrate link tab await tester.tapButtonWithName( LocaleKeys.document_plugins_file_networkTab.tr(), ); await tester.pumpAndSettle(); const url = 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640'; await tester.enterText( find.descendant( of: find.byType(FileUploadMenu), matching: find.byType(FlowyTextField), ), url, ); await tester.tapButton( find.descendant( of: find.byType(FileUploadMenu), matching: find.text( LocaleKeys.document_plugins_file_networkAction.tr(), findRichText: true, ), ), ); await tester.pumpAndSettle(); expect(find.byType(FileUploadMenu), findsNothing); expect(find.byType(FileBlockComponent), findsOneWidget); final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; expect(node.type, FileBlockKeys.type); expect(node.attributes[FileBlockKeys.url], isNotEmpty); expect( node.attributes[FileBlockKeys.urlType], FileUrlType.network.toIntValue(), ); // Check the name is correctly extracted from the url expect( node.attributes[FileBlockKeys.name], 'photo-1469474968028-56623f02e42e', ); expect(find.text('photo-1469474968028-56623f02e42e'), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart ================================================ import 'dart:io'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, ResizableImage; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); group('image block in document', () { testWidgets('insert an image from local file', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_image.tr(), ); expect(find.byType(CustomImageBlockComponent), findsOneWidget); expect(find.byType(ImagePlaceholder), findsOneWidget); expect( find.descendant( of: find.byType(ImagePlaceholder), matching: find.byType(AppFlowyPopover), ), findsOneWidget, ); expect(find.byType(UploadImageMenu), findsOneWidget); final image = await rootBundle.load('assets/test/images/sample.jpeg'); final tempDirectory = await getTemporaryDirectory(); final imagePath = p.join(tempDirectory.path, 'sample.jpeg'); final file = File(imagePath) ..writeAsBytesSync(image.buffer.asUint8List()); mockPickFilePaths( paths: [imagePath], ); await getIt().set(KVKeys.kCloudType, '0'); await tester.tapButtonWithName( LocaleKeys.document_imageBlock_upload_placeholder.tr(), ); await tester.pumpAndSettle(); expect(find.byType(ResizableImage), findsOneWidget); final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; expect(node.type, ImageBlockKeys.type); expect(node.attributes[ImageBlockKeys.url], isNotEmpty); // remove the temp file file.deleteSync(); }); testWidgets('insert two images from local file at once', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_image.tr(), ); expect(find.byType(CustomImageBlockComponent), findsOneWidget); expect(find.byType(ImagePlaceholder), findsOneWidget); expect( find.descendant( of: find.byType(ImagePlaceholder), matching: find.byType(AppFlowyPopover), ), findsOneWidget, ); expect(find.byType(UploadImageMenu), findsOneWidget); final firstImage = await rootBundle.load('assets/test/images/sample.jpeg'); final secondImage = await rootBundle.load('assets/test/images/sample.gif'); final tempDirectory = await getTemporaryDirectory(); final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); final firstFile = File(firstImagePath) ..writeAsBytesSync(firstImage.buffer.asUint8List()); final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); final secondFile = File(secondImagePath) ..writeAsBytesSync(secondImage.buffer.asUint8List()); mockPickFilePaths(paths: [firstImagePath, secondImagePath]); await getIt().set(KVKeys.kCloudType, '0'); await tester.tapButtonWithName( LocaleKeys.document_imageBlock_upload_placeholder.tr(), ); await tester.pumpAndSettle(); expect(find.byType(ResizableImage), findsNWidgets(2)); final firstNode = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; expect(firstNode.type, ImageBlockKeys.type); expect(firstNode.attributes[ImageBlockKeys.url], isNotEmpty); final secondNode = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; expect(secondNode.type, ImageBlockKeys.type); expect(secondNode.attributes[ImageBlockKeys.url], isNotEmpty); // remove the temp files await Future.wait([firstFile.delete(), secondFile.delete()]); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); group('inline math equation in document', () { testWidgets('insert an inline math equation', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'math equation', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a inline page const formula = 'E = MC ^ 2'; await tester.ime.insertText(formula); await tester.editor.updateSelection( Selection.single(path: [0], startOffset: 0, endOffset: formula.length), ); // tap the more options button final moreOptionButton = find.findFlowyTooltip( LocaleKeys.document_toolbar_moreOptions.tr(), ); await tester.tapButton(moreOptionButton); // tap the inline math equation button final inlineMathEquationButton = find.text( LocaleKeys.document_toolbar_equation.tr(), ); await tester.tapButton(inlineMathEquationButton); // expect to see the math equation block final inlineMathEquation = find.byType(InlineMathEquation); expect(inlineMathEquation, findsOneWidget); // tap it and update the content await tester.tapButton(inlineMathEquation); final textFormField = find.descendant( of: find.byType(MathInputTextField), matching: find.byType(TextFormField), ); const newFormula = 'E = MC ^ 3'; await tester.enterText(textFormField, newFormula); await tester.tapButton( find.descendant( of: find.byType(MathInputTextField), matching: find.byType(FlowyButton), ), ); await tester.pumpAndSettle(); }); testWidgets('remove the inline math equation format', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'math equation', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a inline page const formula = 'E = MC ^ 2'; await tester.ime.insertText(formula); await tester.editor.updateSelection( Selection.single(path: [0], startOffset: 0, endOffset: formula.length), ); // tap the more options button final moreOptionButton = find.findFlowyTooltip( LocaleKeys.document_toolbar_moreOptions.tr(), ); await tester.tapButton(moreOptionButton); // tap the inline math equation button final inlineMathEquationButton = find.byFlowySvg(FlowySvgs.type_formula_m); await tester.tapButton(inlineMathEquationButton); // expect to see the math equation block var inlineMathEquation = find.byType(InlineMathEquation); expect(inlineMathEquation, findsOneWidget); // highlight the math equation block await tester.editor.updateSelection( Selection.single(path: [0], startOffset: 0, endOffset: 1), ); await tester.tapButton(moreOptionButton); // cancel the format await tester.tapButton(inlineMathEquationButton); // expect to see the math equation block is removed inlineMathEquation = find.byType(InlineMathEquation); expect(inlineMathEquation, findsNothing); tester.expectToSeeText(formula); }); testWidgets('insert a inline math equation and type something after it', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'math equation', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a inline page const formula = 'E = MC ^ 2'; await tester.ime.insertText(formula); await tester.editor.updateSelection( Selection.single(path: [0], startOffset: 0, endOffset: formula.length), ); // tap the more options button final moreOptionButton = find.findFlowyTooltip( LocaleKeys.document_toolbar_moreOptions.tr(), ); await tester.tapButton(moreOptionButton); // tap the inline math equation button final inlineMathEquationButton = find.byFlowySvg(FlowySvgs.type_formula_m); await tester.tapButton(inlineMathEquationButton); // expect to see the math equation block final inlineMathEquation = find.byType(InlineMathEquation); expect(inlineMathEquation, findsOneWidget); await tester.editor.tapLineOfEditorAt(0); const text = 'Hello World'; await tester.ime.insertText(text); final inlineText = find.textContaining(text, findRichText: true); expect(inlineText, findsOneWidget); // the text should be in the same line with the math equation final inlineMathEquationPosition = tester.getRect(inlineMathEquation); final textPosition = tester.getRect(inlineText); // allow 5px difference expect( (textPosition.top - inlineMathEquationPosition.top).abs(), lessThan(5), ); expect( (textPosition.bottom - inlineMathEquationPosition.bottom).abs(), lessThan(5), ); }); testWidgets('insert inline math equation by shortcut', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'insert inline math equation by shortcut', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a inline page const formula = 'E = MC ^ 2'; await tester.ime.insertText(formula); await tester.editor.updateSelection( Selection.single(path: [0], startOffset: 0, endOffset: formula.length), ); // mock key event await tester.simulateKeyEvent( LogicalKeyboardKey.keyE, isShiftPressed: true, isControlPressed: true, ); // expect to see the math equation block final inlineMathEquation = find.byType(InlineMathEquation); expect(inlineMathEquation, findsOneWidget); await tester.editor.tapLineOfEditorAt(0); const text = 'Hello World'; await tester.ime.insertText(text); final inlineText = find.textContaining(text, findRichText: true); expect(inlineText, findsOneWidget); // the text should be in the same line with the math equation final inlineMathEquationPosition = tester.getRect(inlineMathEquation); final textPosition = tester.getRect(inlineText); // allow 5px difference expect( (textPosition.top - inlineMathEquationPosition.top).abs(), lessThan(5), ); expect( (textPosition.bottom - inlineMathEquationPosition.bottom).abs(), lessThan(5), ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_page_test.dart ================================================ import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('inline page view in document', () { testWidgets('insert a inline page - grid', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertInlinePage(tester, ViewLayoutPB.Grid); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); await tester.tapButton(mentionBlock); }); testWidgets('insert a inline page - board', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertInlinePage(tester, ViewLayoutPB.Board); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); await tester.tapButton(mentionBlock); }); testWidgets('insert a inline page - calendar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertInlinePage(tester, ViewLayoutPB.Calendar); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); await tester.tapButton(mentionBlock); }); testWidgets('insert a inline page - document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertInlinePage(tester, ViewLayoutPB.Document); final mentionBlock = find.byType(MentionPageBlock); expect(mentionBlock, findsOneWidget); await tester.tapButton(mentionBlock); }); testWidgets('insert a inline page and rename it', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final pageName = await insertInlinePage(tester, ViewLayoutPB.Document); // rename const newName = 'RenameToNewPageName'; await tester.hoverOnPageName( pageName, onHover: () async => tester.renamePage(newName), ); final finder = find.descendant( of: find.byType(MentionPageBlock), matching: find.findTextInFlowyText(newName), ); expect(finder, findsOneWidget); }); testWidgets('insert a inline page and delete it', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final pageName = await insertInlinePage(tester, ViewLayoutPB.Grid); // rename await tester.hoverOnPageName( pageName, layout: ViewLayoutPB.Grid, onHover: () async => tester.tapDeletePageButton(), ); final finder = find.descendant( of: find.byType(MentionPageBlock), matching: find.findTextInFlowyText(pageName), ); expect(finder, findsOneWidget); await tester.tapButton(finder); expect(find.byType(GridPage), findsOneWidget); }); testWidgets('insert a inline page and type something after the page', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertInlinePage(tester, ViewLayoutPB.Grid); await tester.editor.tapLineOfEditorAt(0); const text = 'Hello World'; await tester.ime.insertText(text); expect(find.textContaining(text, findRichText: true), findsOneWidget); }); }); } /// Insert a referenced database of [layout] into the document Future insertInlinePage( WidgetTester tester, ViewLayoutPB layout, ) async { // create a new grid final id = uuid(); final name = '${layout.name}_$id'; await tester.createNewPageWithNameUnderParent( name: name, layout: layout, openAfterCreated: false, ); // create a new document await tester.createNewPageWithNameUnderParent( name: 'insert_a_inline_page_${layout.name}', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a inline page await tester.editor.showAtMenu(); await tester.editor.tapAtMenuItemWithName(name); return name; } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_link_test.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('test editing link in document', () { late MockUrlLauncher mock; setUp(() { mock = MockUrlLauncher(); UrlLauncherPlatform.instance = mock; }); testWidgets('insert/edit/open link', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a inline page const link = 'AppFlowy'; await tester.ime.insertText(link); await tester.editor.updateSelection( Selection.single(path: [0], startOffset: 0, endOffset: link.length), ); // tap the link button final linkButton = find.byTooltip( 'Link', ); await tester.tapButton(linkButton); expect(find.text('Add your link', findRichText: true), findsOneWidget); // input the link const url = 'https://appflowy.io'; final textField = find.byWidgetPredicate( (widget) => widget is TextField && widget.decoration!.hintText == 'URL', ); await tester.enterText(textField, url); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); // single-click the link menu to show the menu await tester.tapButton(find.text(link, findRichText: true)); expect(find.text('Open link', findRichText: true), findsOneWidget); expect(find.text('Copy link', findRichText: true), findsOneWidget); expect(find.text('Remove link', findRichText: true), findsOneWidget); // double-click the link menu to open the link mock ..setLaunchExpectations( url: url, useSafariVC: false, useWebView: false, universalLinksOnly: false, enableJavaScript: true, enableDomStorage: true, headers: {}, webOnlyWindowName: null, launchMode: PreferredLaunchMode.platformDefault, ) ..setResponse(true); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); await tester.doubleTapAt( tester.getTopLeft(find.text(link, findRichText: true)).translate(5, 5), ); expect(mock.canLaunchCalled, isTrue); expect(mock.launchCalled, isTrue); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart ================================================ import 'dart:io'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { setUp(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); }); group('multi image block in document', () { testWidgets('insert images from local and use interactive viewer', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'multi image block test', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_photoGallery.tr(), offset: 100, ); expect(find.byType(MultiImageBlockComponent), findsOneWidget); expect(find.byType(MultiImagePlaceholder), findsOneWidget); await tester.tap(find.byType(MultiImagePlaceholder)); await tester.pumpAndSettle(); expect(find.byType(UploadImageMenu), findsOneWidget); final firstImage = await rootBundle.load('assets/test/images/sample.jpeg'); final secondImage = await rootBundle.load('assets/test/images/sample.gif'); final tempDirectory = await getTemporaryDirectory(); final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); final firstFile = File(firstImagePath) ..writeAsBytesSync(firstImage.buffer.asUint8List()); final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); final secondFile = File(secondImagePath) ..writeAsBytesSync(secondImage.buffer.asUint8List()); mockPickFilePaths(paths: [firstImagePath, secondImagePath]); await getIt().set(KVKeys.kCloudType, '0'); await tester.tapButtonWithName( LocaleKeys.document_imageBlock_upload_placeholder.tr(), ); await tester.pumpAndSettle(); expect(find.byType(ImageBrowserLayout), findsOneWidget); final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; expect(node.type, MultiImageBlockKeys.type); final data = MultiImageData.fromJson( node.attributes[MultiImageBlockKeys.images], ); expect(data.images.length, 2); // Start using the interactive viewer to view the image(s) final imageFinder = find .byWidgetPredicate( (w) => w is Image && w.image is FileImage && (w.image as FileImage).file.path.endsWith('.jpeg'), ) .first; await tester.tap(imageFinder); await tester.pump(kDoubleTapMinTime); await tester.tap(imageFinder); await tester.pumpAndSettle(); final ivFinder = find.byType(InteractiveImageViewer); expect(ivFinder, findsOneWidget); // go to next image await tester.tap(find.byFlowySvg(FlowySvgs.arrow_right_s)); await tester.pumpAndSettle(); // Expect image to end with .gif final gifImageFinder = find.byWidgetPredicate( (w) => w is Image && w.image is FileImage && (w.image as FileImage).file.path.endsWith('.gif'), ); gifImageFinder.evaluate(); expect(gifImageFinder.found.length, 2); // go to previous image await tester.tap(find.byFlowySvg(FlowySvgs.arrow_left_s)); await tester.pumpAndSettle(); gifImageFinder.evaluate(); expect(gifImageFinder.found.length, 1); // remove the temp files await Future.wait([firstFile.delete(), secondFile.delete()]); }); testWidgets('insert and delete images from network', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent( name: 'multi image block test', ); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_photoGallery.tr(), offset: 100, ); expect(find.byType(MultiImageBlockComponent), findsOneWidget); expect(find.byType(MultiImagePlaceholder), findsOneWidget); await tester.tap(find.byType(MultiImagePlaceholder)); await tester.pumpAndSettle(); expect(find.byType(UploadImageMenu), findsOneWidget); await tester.tapButtonWithName( LocaleKeys.document_imageBlock_embedLink_label.tr(), ); const url = 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640'; await tester.enterText( find.descendant( of: find.byType(EmbedImageUrlWidget), matching: find.byType(TextField), ), url, ); await tester.pumpAndSettle(); await tester.tapButton( find.descendant( of: find.byType(EmbedImageUrlWidget), matching: find.text( LocaleKeys.document_imageBlock_embedLink_label.tr(), findRichText: true, ), ), ); await tester.pumpAndSettle(); expect(find.byType(ImageBrowserLayout), findsOneWidget); final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; expect(node.type, MultiImageBlockKeys.type); final data = MultiImageData.fromJson( node.attributes[MultiImageBlockKeys.images], ); expect(data.images.length, 1); final imageFinder = find .byWidgetPredicate( (w) => w is FlowyNetworkImage && w.url == url, ) .first; // Insert two images from network for (int i = 0; i < 2; i++) { // Hover on the image to show the image toolbar await tester.hoverOnWidget( imageFinder, onHover: () async { // Click on the add final addFinder = find.descendant( of: find.byType(MultiImageMenu), matching: find.byFlowySvg(FlowySvgs.add_s), ); expect(addFinder, findsOneWidget); await tester.tap(addFinder); await tester.pumpAndSettle(); await tester.tapButtonWithName( LocaleKeys.document_imageBlock_embedLink_label.tr(), ); await tester.enterText( find.descendant( of: find.byType(EmbedImageUrlWidget), matching: find.byType(TextField), ), url, ); await tester.pumpAndSettle(); await tester.tapButton( find.descendant( of: find.byType(EmbedImageUrlWidget), matching: find.text( LocaleKeys.document_imageBlock_embedLink_label.tr(), findRichText: true, ), ), ); await tester.pumpAndSettle(); }, ); } await tester.pumpAndSettle(); // There should be 4 images visible now, where 2 are thumbnails expect(find.byType(ThumbnailItem), findsNWidgets(3)); // And all three use ImageRender expect(find.byType(ImageRender), findsNWidgets(4)); // Hover on and delete the first thumbnail image await tester.hoverOnWidget(find.byType(ThumbnailItem).first); final deleteFinder = find .descendant( of: find.byType(ThumbnailItem), matching: find.byFlowySvg(FlowySvgs.delete_s), ) .first; expect(deleteFinder, findsOneWidget); await tester.tap(deleteFinder); await tester.pumpAndSettle(); expect(find.byType(ImageRender), findsNWidgets(3)); // Delete one from interactive viewer await tester.tap(imageFinder); await tester.pump(kDoubleTapMinTime); await tester.tap(imageFinder); await tester.pumpAndSettle(); final ivFinder = find.byType(InteractiveImageViewer); expect(ivFinder, findsOneWidget); await tester.tap( find.descendant( of: find.byType(InteractiveImageToolbar), matching: find.byFlowySvg(FlowySvgs.delete_s), ), ); await tester.pumpAndSettle(); expect(find.byType(InteractiveImageViewer), findsNothing); // There should be 1 image and the thumbnail for said image still visible expect(find.byType(ImageRender), findsNWidgets(2)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; const String heading1 = "Heading 1"; const String heading2 = "Heading 2"; const String heading3 = "Heading 3"; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('outline block test', () { testWidgets('insert an outline block', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'outline_test', ); await tester.editor.tapLineOfEditorAt(0); await insertOutlineInDocument(tester); // validate the outline is inserted expect(find.byType(OutlineBlockWidget), findsOneWidget); }); testWidgets('insert an outline block and check if headings are visible', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'outline_test', ); await insertHeadingComponent(tester); /* Results in: * # Heading 1 * ## Heading 2 * ### Heading 3 * > # Heading 1 * > ## Heading 2 * > ### Heading 3 */ await tester.editor.tapLineOfEditorAt(3); await insertOutlineInDocument(tester); expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading1), ), findsNWidgets(2), ); // Heading 2 is prefixed with a bullet expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading2), ), findsNWidgets(2), ); // Heading 3 is prefixed with a dash expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading3), ), findsNWidgets(2), ); // update the Heading 1 to Heading 1Hello world await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText('Hello world'); expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text('${heading1}Hello world'), ), findsOneWidget, ); }); testWidgets("control the depth of outline block", (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'outline_test', ); await insertHeadingComponent(tester); /* Results in: * # Heading 1 * ## Heading 2 * ### Heading 3 * > # Heading 1 * > ## Heading 2 * > ### Heading 3 */ await tester.editor.tapLineOfEditorAt(7); await insertOutlineInDocument(tester); // expect to find only the `heading1` widget under the [OutlineBlockWidget] await hoverAndClickDepthOptionAction(tester, [6], 1); expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading2), ), findsNothing, ); expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading3), ), findsNothing, ); ////// /// expect to find only the 'heading1' and 'heading2' under the [OutlineBlockWidget] await hoverAndClickDepthOptionAction(tester, [6], 2); expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading3), ), findsNothing, ); ////// // expect to find all the headings under the [OutlineBlockWidget] await hoverAndClickDepthOptionAction(tester, [6], 3); expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading1), ), findsNWidgets(2), ); expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading2), ), findsNWidgets(2), ); expect( find.descendant( of: find.byType(OutlineBlockWidget), matching: find.text(heading3), ), findsNWidgets(2), ); ////// }); }); } /// Inserts an outline block in the document Future insertOutlineInDocument(WidgetTester tester) async { // open the actions menu and insert the outline block await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_outline.tr(), offset: 180, ); await tester.pumpAndSettle(); } Future hoverAndClickDepthOptionAction( WidgetTester tester, List path, int level, ) async { await tester.editor.openDepthMenu(path); final type = OptionDepthType.fromLevel(level); await tester.tapButton(find.findTextInFlowyText(type.description)); await tester.pumpAndSettle(); } Future insertHeadingComponent(WidgetTester tester) async { await tester.editor.tapLineOfEditorAt(0); // # heading 1-3 await tester.ime.insertText('# $heading1\n'); await tester.ime.insertText('## $heading2\n'); await tester.ime.insertText('### $heading3\n'); // > # toggle heading 1-3 await tester.ime.insertText('> # $heading1\n'); await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); await tester.ime.insertText('> ## $heading2\n'); await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); await tester.ime.insertText('> ### $heading3\n'); await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_simple_table_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../shared/util.dart'; const String heading1 = "Heading 1"; const String heading2 = "Heading 2"; const String heading3 = "Heading 3"; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('simple table block test:', () { testWidgets('insert a simple table block', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); // validate the table is inserted expect(find.byType(SimpleTableBlockWidget), findsOneWidget); final editorState = tester.editor.getCurrentEditorState(); expect( editorState.selection, // table -> row -> cell -> paragraph Selection.collapsed(Position(path: [0, 0, 0, 0])), ); final firstCell = find.byType(SimpleTableCellBlockWidget).first; expect( tester .state(firstCell) .isEditingCellNotifier .value, isTrue, ); }); testWidgets('select all in table cell', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); const cell1Content = 'Cell 1'; await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText('New Table'); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); await tester.editor.tapLineOfEditorAt(1); await tester.insertTableInDocument(); await tester.ime.insertText(cell1Content); await tester.pumpAndSettle(); // Select all in the cell await tester.simulateKeyEvent( LogicalKeyboardKey.keyA, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, ); expect( tester.editor.getCurrentEditorState().selection, Selection( start: Position(path: [1, 0, 0, 0]), end: Position(path: [1, 0, 0, 0], offset: cell1Content.length), ), ); // Press select all again, the selection should be the entire document await tester.simulateKeyEvent( LogicalKeyboardKey.keyA, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, ); expect( tester.editor.getCurrentEditorState().selection, Selection( start: Position(path: [0]), end: Position(path: [1, 1, 1, 0]), ), ); }); testWidgets(''' 1. hover on the table 1.1 click the add row button 1.2 click the add column button 1.3 click the add row and column button 2. validate the table is updated 3. delete the last column 4. delete the last row 5. validate the table is updated ''', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); // add a new row final row = find.byWidgetPredicate((w) { return w is SimpleTableRowBlockWidget && w.node.rowIndex == 1; }); await tester.hoverOnWidget( row, onHover: () async { final addRowButton = find.byType(SimpleTableAddRowButton).first; await tester.tap(addRowButton); }, ); await tester.pumpAndSettle(); // add a new column final column = find.byWidgetPredicate((w) { return w is SimpleTableCellBlockWidget && w.node.columnIndex == 1; }).first; await tester.hoverOnWidget( column, onHover: () async { final addColumnButton = find.byType(SimpleTableAddColumnButton).first; await tester.tap(addColumnButton); }, ); await tester.pumpAndSettle(); // add a new row and a new column final row2 = find.byWidgetPredicate((w) { return w is SimpleTableCellBlockWidget && w.node.rowIndex == 2 && w.node.columnIndex == 2; }).first; await tester.hoverOnWidget( row2, onHover: () async { // click the add row and column button final addRowAndColumnButton = find.byType(SimpleTableAddColumnAndRowButton).first; await tester.tap(addRowAndColumnButton); }, ); await tester.pumpAndSettle(); final tableNode = tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; expect(tableNode.columnLength, 4); expect(tableNode.rowLength, 4); // delete the last row await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.row, index: tableNode.rowLength - 1, action: SimpleTableMoreAction.delete, ); await tester.pumpAndSettle(); expect(tableNode.rowLength, 3); expect(tableNode.columnLength, 4); // delete the last column await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.column, index: tableNode.columnLength - 1, action: SimpleTableMoreAction.delete, ); await tester.pumpAndSettle(); expect(tableNode.columnLength, 3); expect(tableNode.rowLength, 3); }); testWidgets('enable header column and header row', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); // enable the header row await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.row, index: 0, action: SimpleTableMoreAction.enableHeaderRow, ); await tester.pumpAndSettle(); // enable the header column await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.column, index: 0, action: SimpleTableMoreAction.enableHeaderColumn, ); await tester.pumpAndSettle(); final tableNode = tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; expect(tableNode.isHeaderColumnEnabled, isTrue); expect(tableNode.isHeaderRowEnabled, isTrue); // disable the header row await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.row, index: 0, action: SimpleTableMoreAction.enableHeaderRow, ); await tester.pumpAndSettle(); expect(tableNode.isHeaderColumnEnabled, isTrue); expect(tableNode.isHeaderRowEnabled, isFalse); // disable the header column await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.column, index: 0, action: SimpleTableMoreAction.enableHeaderColumn, ); await tester.pumpAndSettle(); expect(tableNode.isHeaderColumnEnabled, isFalse); expect(tableNode.isHeaderRowEnabled, isFalse); }); testWidgets('duplicate a column / row', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); // duplicate the row await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.row, index: 0, action: SimpleTableMoreAction.duplicate, ); await tester.pumpAndSettle(); // duplicate the column await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.column, index: 0, action: SimpleTableMoreAction.duplicate, ); await tester.pumpAndSettle(); final tableNode = tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; expect(tableNode.columnLength, 3); expect(tableNode.rowLength, 3); }); testWidgets('insert left / insert right', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); // insert left await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.column, index: 0, action: SimpleTableMoreAction.insertLeft, ); await tester.pumpAndSettle(); // insert right await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.column, index: 0, action: SimpleTableMoreAction.insertRight, ); await tester.pumpAndSettle(); final tableNode = tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; expect(tableNode.columnLength, 4); expect(tableNode.rowLength, 2); }); testWidgets('insert above / insert below', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); // insert above await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.row, index: 0, action: SimpleTableMoreAction.insertAbove, ); await tester.pumpAndSettle(); // insert below await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.row, index: 0, action: SimpleTableMoreAction.insertBelow, ); await tester.pumpAndSettle(); final tableNode = tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; expect(tableNode.rowLength, 4); expect(tableNode.columnLength, 2); }); }); testWidgets('set column width to page width (1)', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); final tableNode = tester.editor.getNodeAtPath([0]); final beforeWidth = tableNode.width; // set the column width to page width await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.column, index: 0, action: SimpleTableMoreAction.setToPageWidth, ); await tester.pumpAndSettle(); final afterWidth = tableNode.width; expect(afterWidth, greaterThan(beforeWidth)); }); testWidgets('set column width to page width (2)', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); final tableNode = tester.editor.getNodeAtPath([0]); final beforeWidth = tableNode.width; // set the column width to page width await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.row, index: 0, action: SimpleTableMoreAction.setToPageWidth, ); await tester.pumpAndSettle(); final afterWidth = tableNode.width; expect(afterWidth, greaterThan(beforeWidth)); }); testWidgets('distribute columns evenly (1)', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); final tableNode = tester.editor.getNodeAtPath([0]); final beforeWidth = tableNode.width; // set the column width to page width await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.row, index: 0, action: SimpleTableMoreAction.distributeColumnsEvenly, ); await tester.pumpAndSettle(); final afterWidth = tableNode.width; expect(afterWidth, equals(beforeWidth)); final distributeColumnWidthsEvenly = tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly]; expect(distributeColumnWidthsEvenly, isTrue); }); testWidgets('distribute columns evenly (2)', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); final tableNode = tester.editor.getNodeAtPath([0]); final beforeWidth = tableNode.width; // set the column width to page width await tester.clickMoreActionItemInTableMenu( type: SimpleTableMoreActionType.column, index: 0, action: SimpleTableMoreAction.distributeColumnsEvenly, ); await tester.pumpAndSettle(); final afterWidth = tableNode.width; expect(afterWidth, equals(beforeWidth)); final distributeColumnWidthsEvenly = tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly]; expect(distributeColumnWidthsEvenly, isTrue); }); testWidgets('using option menu to set column width', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); await tester.editor.hoverAndClickOptionMenuButton([0]); final editorState = tester.editor.getCurrentEditorState(); final beforeWidth = editorState.document.nodeAtPath([0])!.width; await tester.tapButton( find.text( LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth.tr(), ), ); await tester.pumpAndSettle(); final afterWidth = editorState.document.nodeAtPath([0])!.width; expect(afterWidth, greaterThan(beforeWidth)); await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButton( find.text( LocaleKeys .document_plugins_simpleTable_moreActions_distributeColumnsWidth .tr(), ), ); await tester.pumpAndSettle(); final afterWidth2 = editorState.document.nodeAtPath([0])!.width; expect(afterWidth2, equals(afterWidth)); }); testWidgets('insert a table and use select all the delete it', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); await tester.editor.tapLineOfEditorAt(1); await tester.ime.insertText('Hello World'); // select all await tester.simulateKeyEvent( LogicalKeyboardKey.keyA, isMetaPressed: UniversalPlatform.isMacOS, isControlPressed: !UniversalPlatform.isMacOS, ); await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); await tester.pumpAndSettle(); final editorState = tester.editor.getCurrentEditorState(); // only one paragraph left expect(editorState.document.root.children.length, 1); final paragraphNode = editorState.document.nodeAtPath([0])!; expect(paragraphNode.delta, isNull); }); testWidgets('use tab or shift+tab to navigate in table', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); await tester.simulateKeyEvent(LogicalKeyboardKey.tab); await tester.pumpAndSettle(); final editorState = tester.editor.getCurrentEditorState(); final selection = editorState.selection; expect(selection, isNotNull); expect(selection!.start.path, [0, 0, 1, 0]); expect(selection.end.path, [0, 0, 1, 0]); await tester.simulateKeyEvent( LogicalKeyboardKey.tab, isShiftPressed: true, ); await tester.pumpAndSettle(); final selection2 = editorState.selection; expect(selection2, isNotNull); expect(selection2!.start.path, [0, 0, 0, 0]); expect(selection2.end.path, [0, 0, 0, 0]); }); testWidgets('shift+enter to insert a new line in table', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); await tester.simulateKeyEvent( LogicalKeyboardKey.enter, isShiftPressed: true, ); await tester.pumpAndSettle(); final editorState = tester.editor.getCurrentEditorState(); final node = editorState.document.nodeAtPath([0, 0, 0])!; expect(node.children.length, 1); }); testWidgets('using option menu to set table align', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); await tester.editor.hoverAndClickOptionMenuButton([0]); final editorState = tester.editor.getCurrentEditorState(); final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign; expect(beforeAlign, TableAlign.left); await tester.tapButton( find.text( LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), ), ); await tester.pumpAndSettle(); await tester.tapButton( find.text( LocaleKeys.document_plugins_optionAction_center.tr(), ), ); await tester.pumpAndSettle(); final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign; expect(afterAlign, TableAlign.center); await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButton( find.text( LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), ), ); await tester.pumpAndSettle(); await tester.tapButton( find.text( LocaleKeys.document_plugins_optionAction_right.tr(), ), ); await tester.pumpAndSettle(); final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign; expect(afterAlign2, TableAlign.right); await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButton( find.text( LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), ), ); await tester.pumpAndSettle(); await tester.tapButton( find.text( LocaleKeys.document_plugins_optionAction_left.tr(), ), ); await tester.pumpAndSettle(); final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign; expect(afterAlign3, TableAlign.left); }); testWidgets('using option menu to set table align', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); await tester.editor.hoverAndClickOptionMenuButton([0]); final editorState = tester.editor.getCurrentEditorState(); final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign; expect(beforeAlign, TableAlign.left); await tester.tapButton( find.text( LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), ), ); await tester.pumpAndSettle(); await tester.tapButton( find.text( LocaleKeys.document_plugins_optionAction_center.tr(), ), ); await tester.pumpAndSettle(); final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign; expect(afterAlign, TableAlign.center); await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButton( find.text( LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), ), ); await tester.pumpAndSettle(); await tester.tapButton( find.text( LocaleKeys.document_plugins_optionAction_right.tr(), ), ); await tester.pumpAndSettle(); final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign; expect(afterAlign2, TableAlign.right); await tester.editor.hoverAndClickOptionMenuButton([0]); await tester.tapButton( find.text( LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), ), ); await tester.pumpAndSettle(); await tester.tapButton( find.text( LocaleKeys.document_plugins_optionAction_left.tr(), ), ); await tester.pumpAndSettle(); final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign; expect(afterAlign3, TableAlign.left); }); testWidgets('support slash menu in table', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'simple_table_test', ); final editorState = tester.editor.getCurrentEditorState(); await tester.editor.tapLineOfEditorAt(0); await tester.insertTableInDocument(); final path = [0, 0, 0, 0]; final selection = Selection.collapsed(Position(path: path)); editorState.selection = selection; await tester.editor.showSlashMenu(); await tester.pumpAndSettle(); final paragraphItem = find.byWidgetPredicate((w) { return w is SelectionMenuItemWidget && w.item.name == LocaleKeys.document_slashMenu_name_text.tr(); }); expect(paragraphItem, findsOneWidget); await tester.tap(paragraphItem); await tester.pumpAndSettle(); final paragraphNode = editorState.document.nodeAtPath(path)!; expect(paragraphNode.type, equals(ParagraphBlockKeys.type)); }); } extension on WidgetTester { /// Insert a table in the document Future insertTableInDocument() async { // open the actions menu and insert the outline block await editor.showSlashMenu(); await editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_table.tr(), ); await pumpAndSettle(); } Future clickMoreActionItemInTableMenu({ required SimpleTableMoreActionType type, required int index, required SimpleTableMoreAction action, }) async { if (type == SimpleTableMoreActionType.row) { final row = find.byWidgetPredicate((w) { return w is SimpleTableRowBlockWidget && w.node.rowIndex == index; }); await hoverOnWidget( row, onHover: () async { final moreActionButton = find.byWidgetPredicate((w) { return w is SimpleTableMoreActionMenu && w.type == SimpleTableMoreActionType.row && w.index == index; }); await tapButton(moreActionButton); await tapButton(find.text(action.name)); }, ); await pumpAndSettle(); } else if (type == SimpleTableMoreActionType.column) { final column = find.byWidgetPredicate((w) { return w is SimpleTableCellBlockWidget && w.node.columnIndex == index; }).first; await hoverOnWidget( column, onHover: () async { final moreActionButton = find.byWidgetPredicate((w) { return w is SimpleTableMoreActionMenu && w.type == SimpleTableMoreActionType.column && w.index == index; }); await tapButton(moreActionButton); await tapButton(find.text(action.name)); }, ); await pumpAndSettle(); } await tapAt(Offset.zero); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; const String _heading1 = 'Heading 1'; const String _heading2 = 'Heading 2'; const String _heading3 = 'Heading 3'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('toggle heading block test:', () { testWidgets('insert toggle heading 1 - 3 block', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'toggle heading block test', ); for (var i = 1; i <= 3; i++) { await tester.editor.tapLineOfEditorAt(0); await _insertToggleHeadingBlockInDocument(tester, i); await tester.pumpAndSettle(); expect( find.byWidgetPredicate( (widget) => widget is ToggleListBlockComponentWidget && widget.node.attributes[ToggleListBlockKeys.level] == i, ), findsOneWidget, ); } }); testWidgets('insert toggle heading 1 - 3 block by shortcuts', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'toggle heading block test', ); await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText('# > $_heading1\n'); await tester.ime.insertText('## > $_heading2\n'); await tester.ime.insertText('### > $_heading3\n'); await tester.ime.insertText('> # $_heading1\n'); await tester.ime.insertText('> ## $_heading2\n'); await tester.ime.insertText('> ### $_heading3\n'); await tester.pumpAndSettle(); expect( find.byType(ToggleListBlockComponentWidget), findsNWidgets(6), ); }); testWidgets('insert toggle heading and convert it to heading', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent( name: 'toggle heading block test', ); await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText('# > $_heading1\n'); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); await tester.ime.insertText('item 1'); await tester.pumpAndSettle(); await tester.editor.updateSelection( Selection( start: Position(path: [0]), end: Position(path: [0], offset: _heading1.length), ), ); await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_text_format_m)); // tap the H1 button await tester.tapButton(find.byFlowySvg(FlowySvgs.type_h1_m).at(0)); await tester.pumpAndSettle(); final editorState = tester.editor.getCurrentEditorState(); final node1 = editorState.document.nodeAtPath([0])!; expect(node1.type, HeadingBlockKeys.type); expect(node1.attributes[HeadingBlockKeys.level], 1); final node2 = editorState.document.nodeAtPath([1])!; expect(node2.type, ParagraphBlockKeys.type); expect(node2.delta!.toPlainText(), 'item 1'); }); }); } Future _insertToggleHeadingBlockInDocument( WidgetTester tester, int level, ) async { final name = switch (level) { 1 => LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), 2 => LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), 3 => LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), _ => throw Exception('Invalid level: $level'), }; await tester.editor.showSlashMenu(); await tester.editor.tapSlashMenuItemWithName( name, offset: 150, ); await tester.pumpAndSettle(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_list_test.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); group('toggle list in document', () { Finder findToggleListIcon({ required bool isExpanded, }) { final turns = isExpanded ? 0.25 : 0.0; return find.byWidgetPredicate( (widget) => widget is AnimatedRotation && widget.turns == turns, ); } void expectToggleListOpened() { expect(findToggleListIcon(isExpanded: true), findsOneWidget); expect(findToggleListIcon(isExpanded: false), findsNothing); } void expectToggleListClosed() { expect(findToggleListIcon(isExpanded: false), findsOneWidget); expect(findToggleListIcon(isExpanded: true), findsNothing); } testWidgets('convert > to toggle list, and click the icon to close it', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a toggle list const text = 'This is a toggle list sample'; await tester.ime.insertText('> $text'); final editorState = tester.editor.getCurrentEditorState(); final toggleList = editorState.document.nodeAtPath([0])!; expect( toggleList.type, ToggleListBlockKeys.type, ); expect( toggleList.attributes[ToggleListBlockKeys.collapsed], false, ); expect( toggleList.delta!.toPlainText(), text, ); // Simulate pressing enter key to move the cursor to the next line await tester.ime.insertCharacter('\n'); const text2 = 'This is a child node'; await tester.ime.insertText(text2); expect(find.text(text2, findRichText: true), findsOneWidget); // Click the toggle list icon to close it final toggleListIcon = find.byIcon(Icons.arrow_right); await tester.tapButton(toggleListIcon); // expect the toggle list to be closed expect(find.text(text2, findRichText: true), findsNothing); }); testWidgets('press enter key when the toggle list is closed', (tester) async { // if the toggle list is closed, press enter key will insert a new toggle list after it await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a toggle list const text = 'Hello AppFlowy'; await tester.ime.insertText('> $text'); // Click the toggle list icon to close it final toggleListIcon = find.byIcon(Icons.arrow_right); await tester.tapButton(toggleListIcon); // Press the enter key await tester.editor.updateSelection( Selection.collapsed( Position(path: [0], offset: 'Hello '.length), ), ); await tester.ime.insertCharacter('\n'); final editorState = tester.editor.getCurrentEditorState(); final node0 = editorState.getNodeAtPath([0])!; final node1 = editorState.getNodeAtPath([1])!; expect(node0.type, ToggleListBlockKeys.type); expect(node0.attributes[ToggleListBlockKeys.collapsed], true); expect(node0.delta!.toPlainText(), 'Hello '); expect(node1.type, ToggleListBlockKeys.type); expect(node1.delta!.toPlainText(), 'AppFlowy'); }); testWidgets('press enter key when the toggle list is open', (tester) async { // if the toggle list is open, press enter key will insert a new paragraph inside it await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a toggle list const text = 'Hello AppFlowy'; await tester.ime.insertText('> $text'); // Press the enter key await tester.editor.updateSelection( Selection.collapsed( Position(path: [0], offset: 'Hello '.length), ), ); await tester.ime.insertCharacter('\n'); final editorState = tester.editor.getCurrentEditorState(); final node0 = editorState.getNodeAtPath([0])!; final node00 = editorState.getNodeAtPath([0, 0])!; final node1 = editorState.getNodeAtPath([1]); expect(node0.type, ToggleListBlockKeys.type); expect(node0.attributes[ToggleListBlockKeys.collapsed], false); expect(node0.delta!.toPlainText(), 'Hello '); expect(node00.type, ParagraphBlockKeys.type); expect(node00.delta!.toPlainText(), 'AppFlowy'); expect(node1, isNull); }); testWidgets('clear the format if toggle list if empty', (tester) async { // if the toggle list is open, press enter key will insert a new paragraph inside it await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a toggle list await tester.ime.insertText('> '); // Press the enter key // Click the toggle list icon to close it final toggleListIcon = find.byIcon(Icons.arrow_right); await tester.tapButton(toggleListIcon); await tester.editor .updateSelection(Selection.collapsed(Position(path: [0]))); await tester.ime.insertCharacter('\n'); final editorState = tester.editor.getCurrentEditorState(); final node0 = editorState.getNodeAtPath([0])!; expect(node0.type, ParagraphBlockKeys.type); }); testWidgets('use cmd/ctrl + enter to open/close the toggle list', (tester) async { // if the toggle list is open, press enter key will insert a new paragraph inside it await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document await tester.createNewPageWithNameUnderParent(); // tap the first line of the document await tester.editor.tapLineOfEditorAt(0); // insert a toggle list await tester.ime.insertText('> Hello'); expectToggleListOpened(); await tester.editor.updateSelection( Selection.collapsed( Position(path: [0]), ), ); await tester.simulateKeyEvent( LogicalKeyboardKey.enter, isMetaPressed: Platform.isMacOS, isControlPressed: Platform.isLinux || Platform.isWindows, ); expectToggleListClosed(); await tester.simulateKeyEvent( LogicalKeyboardKey.enter, isMetaPressed: Platform.isMacOS, isControlPressed: Platform.isLinux || Platform.isWindows, ); expectToggleListOpened(); }); Future prepareToggleHeadingBlock( WidgetTester tester, String text, ) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); await tester.editor.tapLineOfEditorAt(0); await tester.ime.insertText(text); } testWidgets('> + # to toggle heading 1 block', (tester) async { await prepareToggleHeadingBlock(tester, '> # Hello'); final editorState = tester.editor.getCurrentEditorState(); final node = editorState.getNodeAtPath([0])!; expect(node.type, ToggleListBlockKeys.type); expect(node.attributes[ToggleListBlockKeys.level], 1); expect(node.delta!.toPlainText(), 'Hello'); }); testWidgets('> + ### to toggle heading 3 block', (tester) async { await prepareToggleHeadingBlock(tester, '> ### Hello'); final editorState = tester.editor.getCurrentEditorState(); final node = editorState.getNodeAtPath([0])!; expect(node.type, ToggleListBlockKeys.type); expect(node.attributes[ToggleListBlockKeys.level], 3); expect(node.delta!.toPlainText(), 'Hello'); }); testWidgets('# + > to toggle heading 1 block', (tester) async { await prepareToggleHeadingBlock(tester, '# > Hello'); final editorState = tester.editor.getCurrentEditorState(); final node = editorState.getNodeAtPath([0])!; expect(node.type, ToggleListBlockKeys.type); expect(node.attributes[ToggleListBlockKeys.level], 1); expect(node.delta!.toPlainText(), 'Hello'); }); testWidgets('### + > to toggle heading 3 block', (tester) async { await prepareToggleHeadingBlock(tester, '### > Hello'); final editorState = tester.editor.getCurrentEditorState(); final node = editorState.getNodeAtPath([0])!; expect(node.type, ToggleListBlockKeys.type); expect(node.attributes[ToggleListBlockKeys.level], 3); expect(node.delta!.toPlainText(), 'Hello'); }); testWidgets('click the toggle list to create a new paragraph', (tester) async { await prepareToggleHeadingBlock(tester, '> # Hello'); final emptyHintText = find.text( LocaleKeys.document_plugins_emptyToggleHeading.tr( args: ['1'], ), ); expect(emptyHintText, findsOneWidget); await tester.tapButton(emptyHintText); await tester.pumpAndSettle(); // check the new paragraph is created final editorState = tester.editor.getCurrentEditorState(); final node = editorState.getNodeAtPath([0, 0])!; expect(node.type, ParagraphBlockKeys.type); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.dart ================================================ import 'dart:io'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('edit document', () { testWidgets('redo & undo', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document called Sample const pageName = 'Sample'; await tester.createNewPageWithNameUnderParent(name: pageName); // focus on the editor await tester.editor.tapLineOfEditorAt(0); // insert 1. to trigger it to be a numbered list await tester.ime.insertText('1. '); expect(find.text('1.', findRichText: true), findsOneWidget); expect( tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, NumberedListBlockKeys.type, ); // undo // numbered list will be reverted to paragraph await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: Platform.isWindows || Platform.isLinux, isMetaPressed: Platform.isMacOS, ); expect( tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, ParagraphBlockKeys.type, ); // redo await tester.simulateKeyEvent( LogicalKeyboardKey.keyZ, isControlPressed: Platform.isWindows || Platform.isLinux, isMetaPressed: Platform.isMacOS, isShiftPressed: true, ); expect( tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, NumberedListBlockKeys.type, ); // switch to other page and switch back await tester.openPage(gettingStarted); await tester.openPage(pageName); // the numbered list should be kept expect( tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, NumberedListBlockKeys.type, ); }); testWidgets('write a readme document', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new document called Sample const pageName = 'Sample'; await tester.createNewPageWithNameUnderParent(name: pageName); // focus on the editor await tester.editor.tapLineOfEditorAt(0); // mock inputting the sample final lines = _sample.split('\n'); for (final line in lines) { await tester.ime.insertText(line); await tester.ime.insertCharacter('\n'); } // switch to other page and switch back await tester.openPage(gettingStarted); await tester.openPage(pageName); // this screenshots are different on different platform, so comment it out temporarily. // check the document // await expectLater( // find.byType(AppFlowyEditor), // matchesGoldenFile('document/edit_document_test.png'), // ); }); }); } const _sample = ''' # Heading 1 ## Heading 2 ### Heading 3 --- [] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~ [] Type followed by bullet or num to create a list. [x] Click `New Page` button at the bottom of your sidebar to add a new page. [] Click the plus sign next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`. --- * bulleted list 1 * bulleted list 2 * bulleted list 3 bulleted list 4 --- 1. numbered list 1 2. numbered list 2 3. numbered list 3 numbered list 4 --- " quote'''; ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/first_test/first_test.dart ================================================ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; // This test is meaningless, just for preventing the CI from failing. void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Empty', () { testWidgets('empty test', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.wait(500); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/grid/grid_calculations_test.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Grid Calculations', () { testWidgets('add calculation and update cell', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Change one Field to Number await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number); expect(find.text('Calculate'), findsOneWidget); await tester.changeCalculateAtIndex(1, CalculationType.Sum); // Enter values in cells await tester.editCell( rowIndex: 0, fieldType: FieldType.Number, input: '100', ); await tester.editCell( rowIndex: 1, fieldType: FieldType.Number, input: '100', ); // Dismiss edit cell await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(const Duration(seconds: 1)); expect(find.text('200'), findsOneWidget); }); testWidgets('add calculations and remove row', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); // Change two Fields to Number await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number); await tester.changeFieldTypeOfFieldWithName('Done', FieldType.Number); expect(find.text('Calculate'), findsNWidgets(2)); await tester.changeCalculateAtIndex(1, CalculationType.Sum); await tester.changeCalculateAtIndex(2, CalculationType.Min); // Enter values in cells await tester.editCell( rowIndex: 0, fieldType: FieldType.Number, input: '100', ); await tester.editCell( rowIndex: 1, fieldType: FieldType.Number, input: '150', ); await tester.editCell( rowIndex: 0, fieldType: FieldType.Number, input: '50', cellIndex: 1, ); await tester.editCell( rowIndex: 1, fieldType: FieldType.Number, input: '100', cellIndex: 1, ); await tester.pumpAndSettle(); // Dismiss edit cell await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); expect(find.text('250'), findsOneWidget); expect(find.text('50'), findsNWidgets(2)); // Delete 1st row await tester.hoverOnFirstRowOfGrid(); await tester.tapRowMenuButtonInGrid(); await tester.tapDeleteOnRowMenu(); await tester.pumpAndSettle(const Duration(seconds: 1)); expect(find.text('150'), findsNWidgets(2)); expect(find.text('100'), findsNWidgets(2)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/grid/grid_edit_row_test.dart ================================================ import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:time/time.dart'; import '../../shared/database_test_op.dart'; import 'grid_test_extensions.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('grid edit row test:', () { testWidgets('with sort configured', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final unsorted = tester.getGridRows(); // add a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); final sorted = [ unsorted[7], unsorted[8], unsorted[1], unsorted[9], unsorted[11], unsorted[10], unsorted[6], unsorted[12], unsorted[2], unsorted[0], unsorted[3], unsorted[5], unsorted[4], ]; List actual = tester.getGridRows(); expect(actual, orderedEquals(sorted)); await tester.editCell( rowIndex: 4, fieldType: FieldType.RichText, input: "x", ); await tester.pumpAndSettle(200.milliseconds); final reSorted = [ unsorted[7], unsorted[8], unsorted[1], unsorted[9], unsorted[10], unsorted[6], unsorted[12], unsorted[2], unsorted[0], unsorted[3], unsorted[5], unsorted[11], unsorted[4], ]; // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(reSorted)); // delete the sort await tester.tapSortMenuInSettingBar(); await tester.tapDeleteAllSortsButton(); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(unsorted)); }); testWidgets('with filter configured', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final original = tester.getGridRows(); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.Checkbox, 'Registration Complete', ); final filtered = [ original[1], original[3], original[5], original[6], original[7], original[9], original[12], ]; // verify grid data List actual = tester.getGridRows(); expect(actual, orderedEquals(filtered)); expect(actual.length, equals(7)); tester.assertNumberOfRowsInGridPage(7); await tester.tapCheckboxCellInGrid(rowIndex: 0); await tester.pumpAndSettle(200.milliseconds); // verify grid data actual = tester.getGridRows(); expect(actual.length, equals(6)); tester.assertNumberOfRowsInGridPage(6); final edited = [ original[3], original[5], original[6], original[7], original[9], original[12], ]; expect(actual, orderedEquals(edited)); // delete the filter await tester.tapFilterButtonInGrid('Registration Complete'); await tester .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); await tester.tapDeleteFilterButtonInGrid(); // verify grid data actual = tester.getGridRows(); expect(actual.length, equals(13)); tester.assertNumberOfRowsInGridPage(13); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/grid/grid_filter_and_sort_test.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import 'grid_test_extensions.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('grid simultaneous sort and filter test:', () { // testWidgets('delete filter with active sort', (tester) async { // await tester.openTestDatabase(v069GridFileName); // // get grid data // final original = tester.getGridRows(); // // add a filter // await tester.tapDatabaseFilterButton(); // await tester.tapCreateFilterByFieldType( // FieldType.Checkbox, // 'Registration Complete', // ); // // add a sort // await tester.tapDatabaseSortButton(); // await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); // final filteredAndSorted = [ // original[7], // original[1], // original[9], // original[6], // original[12], // original[3], // original[5], // ]; // // verify grid data // List actual = tester.getGridRows(); // expect(actual, orderedEquals(filteredAndSorted)); // // delete the filter // await tester.tapFilterButtonInGrid('Registration Complete'); // await tester // .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); // await tester.tapDeleteFilterButtonInGrid(); // final sorted = [ // original[7], // original[8], // original[1], // original[9], // original[11], // original[10], // original[6], // original[12], // original[2], // original[0], // original[3], // original[5], // original[4], // ]; // // verify grid data // actual = tester.getGridRows(); // expect(actual, orderedEquals(sorted)); // }); testWidgets('delete sort with active fiilter', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final original = tester.getGridRows(); // add a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.Checkbox, 'Registration Complete', ); // add a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); final filteredAndSorted = [ original[7], original[1], original[9], original[6], original[12], original[3], original[5], ]; // verify grid data List actual = tester.getGridRows(); expect(actual, orderedEquals(filteredAndSorted)); // delete the sort await tester.tapSortMenuInSettingBar(); await tester.tapDeleteAllSortsButton(); final filtered = [ original[1], original[3], original[5], original[6], original[7], original[9], original[12], ]; // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(filtered)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/grid/grid_reopen_test.dart ================================================ import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; import 'grid_test_extensions.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('grid reopen test:', () { testWidgets('base case', (tester) async { await tester.openTestDatabase(v069GridFileName); final expected = tester.getGridRows(); // go to another page and come back await tester.openPage('Getting started'); await tester.openPage('v069', layout: ViewLayoutPB.Grid); // verify grid data final actual = tester.getGridRows(); expect(actual, orderedEquals(expected)); }); testWidgets('with sort configured', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final unsorted = tester.getGridRows(); // add a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); final sorted = [ unsorted[7], unsorted[8], unsorted[1], unsorted[9], unsorted[11], unsorted[10], unsorted[6], unsorted[12], unsorted[2], unsorted[0], unsorted[3], unsorted[5], unsorted[4], ]; // verify grid data List actual = tester.getGridRows(); expect(actual, orderedEquals(sorted)); // go to another page and come back await tester.openPage('Getting started'); await tester.openPage('v069', layout: ViewLayoutPB.Grid); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(sorted)); // delete sorts // TODO(RS): Shouldn't the sort/filter list show automatically!? await tester.tapDatabaseSortButton(); await tester.tapSortMenuInSettingBar(); await tester.tapDeleteAllSortsButton(); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(unsorted)); // go to another page and come back await tester.openPage('Getting started'); await tester.openPage('v069', layout: ViewLayoutPB.Grid); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(unsorted)); }); testWidgets('with filter configured', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final unfiltered = tester.getGridRows(); // add a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.Checkbox, 'Registration Complete', ); final filtered = [ unfiltered[1], unfiltered[3], unfiltered[5], unfiltered[6], unfiltered[7], unfiltered[9], unfiltered[12], ]; // verify grid data List actual = tester.getGridRows(); expect(actual, orderedEquals(filtered)); // go to another page and come back await tester.openPage('Getting started'); await tester.openPage('v069', layout: ViewLayoutPB.Grid); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(filtered)); // delete the filter // TODO(RS): Shouldn't the sort/filter list show automatically!? await tester.tapDatabaseFilterButton(); await tester.tapFilterButtonInGrid('Registration Complete'); await tester .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); await tester.tapDeleteFilterButtonInGrid(); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(unfiltered)); // go to another page and come back await tester.openPage('Getting started'); await tester.openPage('v069', layout: ViewLayoutPB.Grid); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(unfiltered)); }); testWidgets('with both filter and sort configured', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final original = tester.getGridRows(); // add a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.Checkbox, 'Registration Complete', ); // add a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); final filteredAndSorted = [ original[7], original[1], original[9], original[6], original[12], original[3], original[5], ]; // verify grid data List actual = tester.getGridRows(); expect(actual, orderedEquals(filteredAndSorted)); // go to another page and come back await tester.openPage('Getting started'); await tester.openPage('v069', layout: ViewLayoutPB.Grid); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(filteredAndSorted)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/grid/grid_reorder_row_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; import 'grid_test_extensions.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('grid reorder row test:', () { testWidgets('base case', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final original = tester.getGridRows(); // reorder row await tester.reorderRow(original[4], original[1]); // verify grid data List reordered = [ original[0], original[4], original[1], original[2], original[3], original[5], original[6], original[7], original[8], original[9], original[10], original[11], original[12], ]; List actual = tester.getGridRows(); expect(actual, orderedEquals(reordered)); // reorder row await tester.reorderRow(reordered[1], reordered[3]); // verify grid data reordered = [ original[0], original[1], original[2], original[4], original[3], original[5], original[6], original[7], original[8], original[9], original[10], original[11], original[12], ]; actual = tester.getGridRows(); expect(actual, orderedEquals(reordered)); // reorder row await tester.reorderRow(reordered[2], reordered[0]); // verify grid data reordered = [ original[2], original[0], original[1], original[4], original[3], original[5], original[6], original[7], original[8], original[9], original[10], original[11], original[12], ]; actual = tester.getGridRows(); expect(actual, orderedEquals(reordered)); }); testWidgets('with active sort', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final original = tester.getGridRows(); // add a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); // verify grid data final sorted = [ original[7], original[8], original[1], original[9], original[11], original[10], original[6], original[12], original[2], original[0], original[3], original[5], original[4], ]; List actual = tester.getGridRows(); expect(actual, orderedEquals(sorted)); // reorder row await tester.reorderRow(original[4], original[1]); expect(find.byType(ConfirmPopup), findsOneWidget); await tester.tapButtonWithName(LocaleKeys.button_cancel.tr()); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(sorted)); }); testWidgets('with active filter', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final original = tester.getGridRows(); // add a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.Checkbox, 'Registration Complete', ); final filtered = [ original[1], original[3], original[5], original[6], original[7], original[9], original[12], ]; // verify grid data List actual = tester.getGridRows(); expect(actual, orderedEquals(filtered)); // reorder row await tester.reorderRow(filtered[3], filtered[1]); // verify grid data List reordered = [ original[1], original[6], original[3], original[5], original[7], original[9], original[12], ]; actual = tester.getGridRows(); expect(actual, orderedEquals(reordered)); // reorder row await tester.reorderRow(reordered[3], reordered[5]); // verify grid data reordered = [ original[1], original[6], original[3], original[7], original[9], original[5], original[12], ]; actual = tester.getGridRows(); expect(actual, orderedEquals(reordered)); // delete the filter await tester.tapFilterButtonInGrid('Registration Complete'); await tester .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); await tester.tapDeleteFilterButtonInGrid(); // verify grid data final expected = [ original[0], original[1], original[2], original[6], original[3], original[4], original[7], original[8], original[9], original[5], original[10], original[11], original[12], ]; actual = tester.getGridRows(); expect(actual, orderedEquals(expected)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/grid/grid_row_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/database_test_op.dart'; import '../../shared/util.dart'; import 'grid_test_extensions.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('grid row test:', () { testWidgets('create from the bottom', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); final expected = tester.getGridRows(); // create row await tester.tapCreateRowButtonInGrid(); final actual = tester.getGridRows(); expect(actual.slice(0, 3), orderedEquals(expected)); expect(actual.length, equals(4)); tester.assertNumberOfRowsInGridPage(4); }); testWidgets('create from a row\'s menu', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); final expected = tester.getGridRows(); // create row await tester.hoverOnFirstRowOfGrid(); await tester.tapCreateRowButtonAfterHoveringOnGridRow(); final actual = tester.getGridRows(); expect([actual[0], actual[2], actual[3]], orderedEquals(expected)); expect(actual.length, equals(4)); tester.assertNumberOfRowsInGridPage(4); }); testWidgets('create with sort configured', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final unsorted = tester.getGridRows(); // add a sort await tester.tapDatabaseSortButton(); await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); final sorted = [ unsorted[7], unsorted[8], unsorted[1], unsorted[9], unsorted[11], unsorted[10], unsorted[6], unsorted[12], unsorted[2], unsorted[0], unsorted[3], unsorted[5], unsorted[4], ]; List actual = tester.getGridRows(); expect(actual, orderedEquals(sorted)); // create row await tester.hoverOnFirstRowOfGrid(); await tester.tapCreateRowButtonAfterHoveringOnGridRow(); // cancel expect(find.byType(ConfirmPopup), findsOneWidget); await tester.tapButtonWithName(LocaleKeys.button_cancel.tr()); // verify grid data actual = tester.getGridRows(); expect(actual, orderedEquals(sorted)); // try again, but confirm this time await tester.hoverOnFirstRowOfGrid(); await tester.tapCreateRowButtonAfterHoveringOnGridRow(); expect(find.byType(ConfirmPopup), findsOneWidget); await tester.tapButtonWithName(LocaleKeys.button_remove.tr()); // verify grid data actual = tester.getGridRows(); expect(actual.length, equals(14)); tester.assertNumberOfRowsInGridPage(14); }); testWidgets('create with filter configured', (tester) async { await tester.openTestDatabase(v069GridFileName); // get grid data final original = tester.getGridRows(); // create a filter await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType( FieldType.Checkbox, 'Registration Complete', ); final filtered = [ original[1], original[3], original[5], original[6], original[7], original[9], original[12], ]; // verify grid data List actual = tester.getGridRows(); expect(actual, orderedEquals(filtered)); // create row (one before and after the first row, and one at the bottom) await tester.tapCreateRowButtonInGrid(); await tester.hoverOnFirstRowOfGrid(); await tester.tapCreateRowButtonAfterHoveringOnGridRow(); await tester.hoverOnFirstRowOfGrid(() async { await tester.tapRowMenuButtonInGrid(); await tester.tapCreateRowAboveButtonInRowMenu(); }); actual = tester.getGridRows(); expect(actual.length, equals(10)); tester.assertNumberOfRowsInGridPage(10); actual = [ actual[1], actual[3], actual[4], actual[5], actual[6], actual[7], actual[8], ]; expect(actual, orderedEquals(filtered)); // delete the filter await tester.tapFilterButtonInGrid('Registration Complete'); await tester .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); await tester.tapDeleteFilterButtonInGrid(); // verify grid data actual = tester.getGridRows(); expect(actual.length, equals(16)); tester.assertNumberOfRowsInGridPage(16); actual = [ actual[0], actual[2], actual[4], actual[5], actual[6], actual[7], actual[8], actual[9], actual[10], actual[11], actual[12], actual[13], actual[14], ]; expect(actual, orderedEquals(original)); }); testWidgets('delete row of the grid', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.hoverOnFirstRowOfGrid(() async { // Open the row menu and click the delete button await tester.tapRowMenuButtonInGrid(); await tester.tapDeleteOnRowMenu(); }); expect(find.byType(ConfirmPopup), findsOneWidget); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); tester.assertNumberOfRowsInGridPage(2); }); testWidgets('delete row in two views', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); await tester.renameLinkedView( tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Grid), 'grid 1', ); tester.assertNumberOfRowsInGridPage(3); await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); await tester.renameLinkedView( tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Grid).at(1), 'grid 2', ); tester.assertNumberOfRowsInGridPage(3); await tester.hoverOnFirstRowOfGrid(() async { // Open the row menu and click the delete button await tester.tapRowMenuButtonInGrid(); await tester.tapDeleteOnRowMenu(); }); expect(find.byType(ConfirmPopup), findsOneWidget); await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); // 3 initial rows - 1 deleted tester.assertNumberOfRowsInGridPage(2); await tester.tapTabBarLinkedViewByViewName('grid 1'); tester.assertNumberOfRowsInGridPage(2); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/grid/grid_test_extensions.dart ================================================ import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:flutter_test/flutter_test.dart'; extension GridTestExtensions on WidgetTester { List getGridRows() { final databaseController = widget(find.byType(GridPage)).databaseController; return [ ...databaseController.rowCache.rowInfos.map((e) => e.rowId), ]; } } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/grid/grid_test_runner_1.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'grid_edit_row_test.dart' as grid_edit_row_test_runner; import 'grid_filter_and_sort_test.dart' as grid_filter_and_sort_test_runner; import 'grid_reopen_test.dart' as grid_reopen_test_runner; import 'grid_reorder_row_test.dart' as grid_reorder_row_test_runner; import 'grid_row_test.dart' as grid_row_test_runner; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); grid_reopen_test_runner.main(); grid_row_test_runner.main(); grid_reorder_row_test_runner.main(); grid_filter_and_sort_test_runner.main(); grid_edit_row_test_runner.main(); // grid_calculations_test_runner.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/reminder/document_reminder_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/base.dart'; import '../../shared/common_operations.dart'; import '../../shared/document_test_operations.dart'; import '../../shared/expectation.dart'; import '../../shared/keyboard.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Reminder in Document', () { testWidgets('Add reminder for tomorrow, and include time', (tester) async { const time = "23:59"; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final dateTimeSettings = await UserSettingsBackendService().getDateTimeSettings(); await tester.editor.tapLineOfEditorAt(0); await tester.editor.getCurrentEditorState().insertNewLine(); await tester.pumpAndSettle(); // Trigger inline action menu and type 'remind tomorrow' final tomorrow = await _insertReminderTomorrow(tester); Node node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!; Map mentionAttr = node.delta!.first.attributes![MentionBlockKeys.mention]; expect(node.type, 'paragraph'); expect(mentionAttr['type'], MentionType.date.name); expect(mentionAttr['date'], tomorrow.toIso8601String()); await tester.tap( find.text(dateTimeSettings.dateFormat.formatDate(tomorrow, false)), ); await tester.pumpAndSettle(); await tester.tap(find.byType(Toggle)); await tester.pumpAndSettle(); await tester.enterText(find.byType(FlowyTextField), time); // Leave text field to submit await tester.tap(find.text(LocaleKeys.grid_field_includeTime.tr())); await tester.pumpAndSettle(); node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!; mentionAttr = node.delta!.first.attributes![MentionBlockKeys.mention]; final tomorrowWithTime = _dateWithTime(dateTimeSettings.timeFormat, tomorrow, time); expect(node.type, 'paragraph'); expect(mentionAttr['type'], MentionType.date.name); expect(mentionAttr['date'], tomorrowWithTime.toIso8601String()); }); testWidgets('Add reminder for tomorrow, and navigate to it', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.editor.tapLineOfEditorAt(0); await tester.editor.getCurrentEditorState().insertNewLine(); await tester.pumpAndSettle(); // Trigger inline action menu and type 'remind tomorrow' final tomorrow = await _insertReminderTomorrow(tester); final Node node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!; final Map mentionAttr = node.delta!.first.attributes![MentionBlockKeys.mention]; expect(node.type, 'paragraph'); expect(mentionAttr['type'], MentionType.date.name); expect(mentionAttr['date'], tomorrow.toIso8601String()); // Create and Navigate to a new document await tester.createNewPageWithNameUnderParent(); await tester.pumpAndSettle(); // Open "Upcoming" in Notification hub await tester.openNotificationHub(tabIndex: 1); // Expect 1 notification tester.expectNotificationItems(1); // Tap on the notification await tester.tap(find.byType(NotificationItem)); await tester.pumpAndSettle(); // Expect node at path 1 to be the date/reminder expect( tester.editor .getCurrentEditorState() .getNodeAtPath([1]) ?.delta ?.first .attributes?[MentionBlockKeys.mention]['type'], MentionType.date.name, ); }); }); } Future _insertReminderTomorrow(WidgetTester tester) async { await tester.editor.showAtMenu(); await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.keyR, LogicalKeyboardKey.keyE, LogicalKeyboardKey.keyM, LogicalKeyboardKey.keyI, LogicalKeyboardKey.keyN, LogicalKeyboardKey.keyD, LogicalKeyboardKey.space, LogicalKeyboardKey.keyT, LogicalKeyboardKey.keyO, LogicalKeyboardKey.keyM, LogicalKeyboardKey.keyO, LogicalKeyboardKey.keyR, LogicalKeyboardKey.keyR, LogicalKeyboardKey.keyO, LogicalKeyboardKey.keyW, ], tester: tester, ); await FlowyTestKeyboard.simulateKeyDownEvent( [LogicalKeyboardKey.enter], tester: tester, ); return DateTime.now().add(const Duration(days: 1)).withoutTime; } DateTime _dateWithTime(UserTimeFormatPB format, DateTime date, String time) { final t = format == UserTimeFormatPB.TwelveHour ? DateFormat.jm().parse(time) : DateFormat.Hm().parse(time); return DateTime.parse( '${date.year}${_padZeroLeft(date.month)}${_padZeroLeft(date.day)} ${_padZeroLeft(t.hour)}:${_padZeroLeft(t.minute)}', ); } String _padZeroLeft(int a) => a.toString().padLeft(2, '0'); ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart ================================================ import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('notification test', () { testWidgets('enable notification', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.openSettings(); await tester.openSettingsPage(SettingsPage.notifications); await tester.pumpAndSettle(); final toggleFinder = find.byType(Toggle).first; // Defaults to enabled Toggle toggleWidget = tester.widget(toggleFinder); expect(toggleWidget.value, true); // Disable await tester.tap(toggleFinder); await tester.pumpAndSettle(); toggleWidget = tester.widget(toggleFinder); expect(toggleWidget.value, false); // Enable again await tester.tap(toggleFinder); await tester.pumpAndSettle(); toggleWidget = tester.widget(toggleFinder); expect(toggleWidget.value, true); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/settings/settings_billing_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/auth_operation.dart'; import '../../shared/base.dart'; import '../../shared/expectation.dart'; import '../../shared/settings.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Settings Billing', () { testWidgets('Local auth cannot see plan+billing', (tester) async { await tester.initializeAppFlowy(); await tester.tapSignInAsGuest(); await tester.expectToSeeHomePageWithGetStartedPage(); await tester.openSettings(); await tester.pumpAndSettle(); // We check that another settings page is present to ensure // it's not a fluke expect( find.text( LocaleKeys.settings_workspacePage_menuLabel.tr(), skipOffstage: false, ), findsOneWidget, ); expect( find.text( LocaleKeys.settings_planPage_menuLabel.tr(), skipOffstage: false, ), findsNothing, ); expect( find.text( LocaleKeys.settings_billingPage_menuLabel.tr(), skipOffstage: false, ), findsNothing, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'notifications_settings_test.dart' as notifications_settings_test; import 'settings_billing_test.dart' as settings_billing_test; import 'shortcuts_settings_test.dart' as shortcuts_settings_test; import 'sign_in_page_settings_test.dart' as sign_in_page_settings_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); notifications_settings_test.main(); settings_billing_test.main(); shortcuts_settings_test.main(); sign_in_page_settings_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/settings/shortcuts_settings_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_shortcuts_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/keyboard.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('shortcuts:', () { testWidgets('change and overwrite shortcut', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.openSettings(); await tester.openSettingsPage(SettingsPage.shortcuts); await tester.pumpAndSettle(); final backspaceCmd = LocaleKeys.settings_shortcutsPage_keybindings_backspace.tr(); // Input "Delete" into the search field final inputField = find.descendant( of: find.byType(SettingsShortcutsView), matching: find.byType(TextField), ); await tester.enterText(inputField, backspaceCmd); await tester.pumpAndSettle(); await tester.hoverOnWidget( find .descendant( of: find.byType(ShortcutSettingTile), matching: find.text(backspaceCmd), ) .first, onHover: () async { await tester.tap(find.byFlowySvg(FlowySvgs.edit_s)); await tester.pumpAndSettle(); await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.delete, LogicalKeyboardKey.enter, ], tester: tester, ); await tester.pumpAndSettle(); }, ); // We expect to see conflict dialog expect( find.text( LocaleKeys.settings_shortcutsPage_conflictDialog_confirmLabel.tr(), ), findsOneWidget, ); // Press on confirm label await tester.tap( find.text( LocaleKeys.settings_shortcutsPage_conflictDialog_confirmLabel.tr(), ), ); await tester.pumpAndSettle(); // We expect the first ShortcutSettingTile to have one // [KeyBadge] with `delete` label final first = tester.widget(find.byType(ShortcutSettingTile).first) as ShortcutSettingTile; expect( first.command.command, 'delete', ); // And the second one which is `Delete left character` to have none // as it will have been overwritten final second = tester.widget(find.byType(ShortcutSettingTile).at(1)) as ShortcutSettingTile; expect( second.command.command, '', ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/settings/sign_in_page_settings_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); Finder findServerType(AuthenticatorType type) { return find .descendant( of: find.byType(SettingsServerDropdownMenu), matching: find.findTextInFlowyText( type.label, ), ) .last; } group('sign-in page settings:', () { testWidgets('change server type', (tester) async { await tester.initializeAppFlowy(); // reset the app to the default state await useAppFlowyBetaCloudWithURL( kAppflowyCloudUrl, AuthenticatorType.appflowyCloud, ); // open the settings page final settingsButton = find.byType(DesktopSignInSettingsButton); await tester.tapButton(settingsButton); expect(find.byType(SimpleSettingsDialog), findsOneWidget); // the default type should be appflowy cloud final appflowyCloudType = findServerType(AuthenticatorType.appflowyCloud); expect(appflowyCloudType, findsOneWidget); // change the server type to self-host await tester.tapButton(appflowyCloudType); final selfHostedButton = findServerType( AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapButton(selfHostedButton); // update server url const serverUrl = 'https://self-hosted.appflowy.cloud'; await tester.enterText( find.byKey(kSelfHostedTextInputFieldKey), serverUrl, ); await tester.pumpAndSettle(); // update the web url const webUrl = 'https://self-hosted.appflowy.com'; await tester.enterText( find.byKey(kSelfHostedWebTextInputFieldKey), webUrl, ); await tester.pumpAndSettle(); await tester.tapButton( find.findTextInFlowyText(LocaleKeys.button_save.tr()), ); // wait the app to restart, and the tooltip to disappear await tester.pumpUntilNotFound(find.byType(DesktopToast)); await tester.pumpAndSettle(const Duration(milliseconds: 250)); // open settings page to check the result await tester.tapButton(settingsButton); await tester.pumpAndSettle(const Duration(milliseconds: 250)); // check the server type expect( findServerType(AuthenticatorType.appflowyCloudSelfHost), findsOneWidget, ); // check the server url expect( find.text(serverUrl), findsOneWidget, ); // check the web url expect( find.text(webUrl), findsOneWidget, ); // reset to appflowy cloud await tester.tapButton( findServerType(AuthenticatorType.appflowyCloudSelfHost), ); // change the server type to appflowy cloud await tester.tapButton( findServerType(AuthenticatorType.appflowyCloud), ); // wait the app to restart, and the tooltip to disappear await tester.pumpUntilNotFound(find.byType(DesktopToast)); await tester.pumpAndSettle(const Duration(milliseconds: 250)); // check the server type await tester.tapButton(settingsButton); expect( findServerType(AuthenticatorType.appflowyCloud), findsOneWidget, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/sidebar/rename_current_item_test.dart ================================================ import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/keyboard.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Rename current view item', () { testWidgets('by F2 shortcut', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await FlowyTestKeyboard.simulateKeyDownEvent( [LogicalKeyboardKey.f2], tester: tester, ); await tester.pumpAndSettle(); expect(find.byType(RenameViewPopover), findsOneWidget); await tester.enterText( find.descendant( of: find.byType(RenameViewPopover), matching: find.byType(FlowyTextField), ), 'hello', ); await tester.pumpAndSettle(); // Dismiss rename popover await tester.tap(find.byType(AppFlowyEditor)); await tester.pumpAndSettle(); expect( find.descendant( of: find.byType(SingleInnerViewItem), matching: find.text('hello'), ), findsOneWidget, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('sidebar expand test', () { bool isExpanded({required FolderSpaceType type}) { if (type == FolderSpaceType.private) { return find .descendant( of: find.byType(PrivateSectionFolder), matching: find.byType(ViewItem), ) .evaluate() .isNotEmpty; } return false; } testWidgets('first time the personal folder is expanded', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // first time is expanded expect(isExpanded(type: FolderSpaceType.private), true); // collapse the personal folder await tester.tapButton( find.byTooltip(LocaleKeys.sideBar_clickToHidePrivate.tr()), ); expect(isExpanded(type: FolderSpaceType.private), false); // expand the personal folder await tester.tapButton( find.byTooltip(LocaleKeys.sideBar_clickToHidePrivate.tr()), ); expect(isExpanded(type: FolderSpaceType.private), true); }); testWidgets('Expanding with subpage', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const page1 = 'SubPageBloc', page2 = '$page1 2'; await tester.createNewPageWithNameUnderParent(name: page1); await tester.createNewPageWithNameUnderParent( name: page2, parentName: page1, ); await tester.expandOrCollapsePage( pageName: gettingStarted, layout: ViewLayoutPB.Document, ); await tester.tapNewPageButton(); await tester.editor.tapLineOfEditorAt(0); await tester.pumpAndSettle(); await tester.editor.showSlashMenu(); await tester.pumpAndSettle(); final slashMenu = find .ancestor( of: find.byType(SelectionMenuItemWidget), matching: find.byWidgetPredicate( (widget) => widget is Scrollable, ), ) .first; final slashMenuItem = find.text( LocaleKeys.document_slashMenu_name_linkedDoc.tr(), ); await tester.scrollUntilVisible( slashMenuItem, 100, scrollable: slashMenu, duration: const Duration(milliseconds: 250), ); final menuItemFinder = find.byWidgetPredicate( (w) => w is SelectionMenuItemWidget && w.item.name == LocaleKeys.document_slashMenu_name_linkedDoc.tr(), ); final menuItem = menuItemFinder.evaluate().first.widget as SelectionMenuItemWidget; /// tapSlashMenuItemWithName is not working, so invoke this function directly menuItem.item.handler( menuItem.editorState, menuItem.menuService, menuItemFinder.evaluate().first, ); await tester.pumpAndSettle(); final actionHandler = find.byType(InlineActionsHandler); final subPage = find.descendant( of: actionHandler, matching: find.text(page2, findRichText: true), ); await tester.tapButton(subPage); final subpageBlock = find.descendant( of: find.byType(AppFlowyEditor), matching: find.text(page2, findRichText: true), ); expect(find.text(page2, findRichText: true), findsOneWidget); await tester.tapButton(subpageBlock); /// one is in SectionFolder, another one is in CoverTitle /// the last one is in FlowyNavigation expect(find.text(page2, findRichText: true), findsNWidgets(3)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_favorites_test.dart ================================================ import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/base.dart'; import '../../shared/common_operations.dart'; import '../../shared/expectation.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Favorites', () { testWidgets( 'Toggle favorites for views creates / removes the favorite header along with favorite views', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // no favorite folder expect(find.byType(FavoriteFolder), findsNothing); // create the nested views final names = [ 1, 2, ].map((e) => 'document_$e').toList(); for (var i = 0; i < names.length; i++) { final parentName = i == 0 ? gettingStarted : names[i - 1]; await tester.createNewPageWithNameUnderParent( name: names[i], parentName: parentName, ); tester.expectToSeePageName(names[i], parentName: parentName); } await tester.favoriteViewByName(gettingStarted); expect( tester.findFavoritePageName(gettingStarted), findsOneWidget, ); await tester.favoriteViewByName(names[1]); expect( tester.findFavoritePageName(names[1]), findsNWidgets(1), ); await tester.unfavoriteViewByName(gettingStarted); expect( tester.findFavoritePageName(gettingStarted), findsNothing, ); expect( tester.findFavoritePageName( names[1], ), findsOneWidget, ); await tester.unfavoriteViewByName(names[1]); expect( tester.findFavoritePageName( names[1], ), findsNothing, ); }); testWidgets( 'renaming a favorite view updates name under favorite header', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const name = 'test'; await tester.favoriteViewByName(gettingStarted); await tester.hoverOnPageName( gettingStarted, onHover: () async { await tester.renamePage(name); await tester.pumpAndSettle(); }, ); expect( tester.findPageName(name), findsNWidgets(2), ); expect( tester.findFavoritePageName(name), findsOneWidget, ); }, ); testWidgets( 'deleting first level favorite view removes its instance from favorite header, deleting root level views leads to removal of all favorites that are its children', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final names = [1, 2].map((e) => 'document_$e').toList(); for (var i = 0; i < names.length; i++) { final parentName = i == 0 ? gettingStarted : names[i - 1]; await tester.createNewPageWithNameUnderParent( name: names[i], parentName: parentName, ); tester.expectToSeePageName(names[i], parentName: parentName); } await tester.favoriteViewByName(gettingStarted); await tester.favoriteViewByName(names[0]); await tester.favoriteViewByName(names[1]); expect( find.byWidgetPredicate( (widget) => widget is SingleInnerViewItem && widget.view.isFavorite && widget.spaceType == FolderSpaceType.favorite, ), findsNWidgets(3), ); await tester.hoverOnPageName( names[1], onHover: () async { await tester.tapDeletePageButton(); await tester.pumpAndSettle(); }, ); expect( tester.findAllFavoritePages(), findsNWidgets(2), ); await tester.hoverOnPageName( gettingStarted, onHover: () async { await tester.tapDeletePageButton(); await tester.pumpAndSettle(); }, ); expect( tester.findAllFavoritePages(), findsNothing, ); }, ); testWidgets( 'view selection is synced between favorites and personal folder', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); await tester.favoriteViewByName(gettingStarted); expect( find.byWidgetPredicate( (widget) => widget is FlowyHover && widget.isSelected != null && widget.isSelected!(), ), findsNWidgets(1), ); }, ); testWidgets( 'context menu opens up for favorites', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); await tester.favoriteViewByName(gettingStarted); await tester.hoverOnPageName( gettingStarted, useLast: false, onHover: () async { await tester.tapPageOptionButton(); await tester.pumpAndSettle(); expect( find.byType(PopoverContainer), findsOneWidget, ); }, ); await tester.pumpAndSettle(); }, ); testWidgets( 'reorder favorites', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); /// there are no favorite views final favorites = find.descendant( of: find.byType(FavoriteFolder), matching: find.byType(ViewItem), ); expect(favorites, findsNothing); /// create views and then favorite them const pageNames = ['001', '002', '003']; for (final name in pageNames) { await tester.createNewPageWithNameUnderParent(name: name); } for (final name in pageNames) { await tester.favoriteViewByName(name); } expect(favorites, findsNWidgets(pageNames.length)); final oldNames = favorites .evaluate() .map((e) => (e.widget as ViewItem).view.name) .toList(); expect(oldNames, pageNames); /// drag first to last await tester.reorderFavorite( fromName: '001', toName: '003', ); List newNames = favorites .evaluate() .map((e) => (e.widget as ViewItem).view.name) .toList(); expect(newNames, ['002', '003', '001']); /// drag first to second await tester.reorderFavorite( fromName: '002', toName: '003', ); newNames = favorites .evaluate() .map((e) => (e.widget as ViewItem).view.name) .toList(); expect(newNames, ['003', '002', '001']); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart ================================================ import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/base.dart'; import '../../shared/common_operations.dart'; import '../../shared/emoji.dart'; import '../../shared/expectation.dart'; void main() { final emoji = EmojiIconData.emoji('😁'); setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); testWidgets('Update page emoji in sidebar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { if (value == ViewLayoutPB.Chat) { continue; } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, layout: value, ); // update its emoji await tester.updatePageIconInSidebarByName( name: value.name, parentName: gettingStarted, layout: value, icon: emoji, ); tester.expectViewHasIcon( value.name, value, emoji, ); } }); testWidgets('Update page emoji in title bar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { if (value == ViewLayoutPB.Chat) { continue; } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, layout: value, ); // update its emoji await tester.updatePageIconInTitleBarByName( name: value.name, layout: value, icon: emoji, ); tester.expectViewHasIcon( value.name, value, emoji, ); tester.expectViewTitleHasIcon( value.name, value, emoji, ); } }); testWidgets('Emoji Search Bar Get Focus', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { if (value == ViewLayoutPB.Chat) { continue; } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, layout: value, ); await tester.openPage( value.name, layout: value, ); final title = find.descendant( of: find.byType(ViewTitleBar), matching: find.text(value.name), ); await tester.tapButton(title); await tester.tapButton(find.byType(EmojiPickerButton)); final emojiPicker = find.byType(FlowyEmojiPicker); expect(emojiPicker, findsOneWidget); final textField = find.descendant( of: emojiPicker, matching: find.byType(FlowyTextField), ); expect(textField, findsOneWidget); final textFieldWidget = textField.evaluate().first.widget as FlowyTextField; assert(textFieldWidget.focusNode!.hasFocus); await tester.tapEmoji(emoji.emoji); await tester.pumpAndSettle(); tester.expectViewHasIcon( value.name, value, emoji, ); } }); testWidgets('Update page icon in sidebar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final iconData = await tester.loadIcon(); // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { if (value == ViewLayoutPB.Chat) { continue; } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, layout: value, ); // update its icon await tester.updatePageIconInSidebarByName( name: value.name, parentName: gettingStarted, layout: value, icon: iconData, ); tester.expectViewHasIcon( value.name, value, iconData, ); } }); testWidgets('Update page icon in title bar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final iconData = await tester.loadIcon(); // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { if (value == ViewLayoutPB.Chat) { continue; } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, layout: value, ); // update its icon await tester.updatePageIconInTitleBarByName( name: value.name, layout: value, icon: iconData, ); tester.expectViewHasIcon( value.name, value, iconData, ); tester.expectViewTitleHasIcon( value.name, value, iconData, ); } }); testWidgets('Update page custom image icon in title bar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); /// prepare local image final iconData = await tester.prepareImageIcon(); // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { if (value == ViewLayoutPB.Chat) { continue; } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, layout: value, ); // update its icon await tester.updatePageIconInTitleBarByName( name: value.name, layout: value, icon: iconData, ); tester.expectViewHasIcon( value.name, value, iconData, ); tester.expectViewTitleHasIcon( value.name, value, iconData, ); } }); testWidgets('Update page custom svg icon in title bar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); /// prepare local image final iconData = await tester.prepareSvgIcon(); // create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { if (value == ViewLayoutPB.Chat) { continue; } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, layout: value, ); // update its icon await tester.updatePageIconInTitleBarByName( name: value.name, layout: value, icon: iconData, ); tester.expectViewHasIcon( value.name, value, iconData, ); tester.expectViewTitleHasIcon( value.name, value, iconData, ); } }); testWidgets('Update page custom svg icon in title bar by pasting a link', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); /// prepare local image const testIconLink = 'https://beta.appflowy.cloud/api/file_storage/008e6f23-516b-4d8d-b1fe-2b75c51eee26/v1/blob/6bdf8dff%2D0e54%2D4d35%2D9981%2Dcde68bef1141/BGpLnRtb3AGBNgSJsceu70j83zevYKrMLzqsTIJcBeI=.svg'; /// create document, board, grid and calendar views for (final value in ViewLayoutPB.values) { if (value == ViewLayoutPB.Chat) { continue; } await tester.createNewPageWithNameUnderParent( name: value.name, parentName: gettingStarted, layout: value, ); /// update its icon await tester.updatePageIconInTitleBarByPasteALink( name: value.name, layout: value, iconLink: testIconLink, ); /// check if there is a svg in page final pageName = tester.findPageName( value.name, layout: value, ); final imageInPage = find.descendant( of: pageName, matching: find.byType(SvgPicture), ); expect(imageInPage, findsOneWidget); /// check if there is a svg in title final imageInTitle = find.descendant( of: find.byType(ViewTitleBar), matching: find.byWidgetPredicate((w) { if (w is! SvgPicture) return false; final loader = w.bytesLoader; if (loader is! SvgFileLoader) return false; return loader.file.path.endsWith('.svg'); }), ); expect(imageInTitle, findsOneWidget); } }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_recent_icon_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../shared/base.dart'; import '../../shared/common_operations.dart'; import '../../shared/expectation.dart'; void main() { testWidgets('Skip the empty group name icon in recent icons', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); /// clear local data RecentIcons.clear(); await loadIconGroups(); final groups = kIconGroups!; final List localIcons = []; for (final e in groups) { localIcons.addAll(e.icons.map((e) => RecentIcon(e, e.name)).toList()); } await RecentIcons.putIcon(RecentIcon(localIcons.first.icon, '')); await tester.openPage(gettingStarted); final title = find.descendant( of: find.byType(ViewTitleBar), matching: find.text(gettingStarted), ); await tester.tapButton(title); /// tap emoji picker button await tester.tapButton(find.byType(EmojiPickerButton)); expect(find.byType(FlowyIconEmojiPicker), findsOneWidget); /// tap icon tab final pickTab = find.byType(PickerTab); final iconTab = find.descendant( of: pickTab, matching: find.text(PickerTabType.icon.tr), ); await tester.tapButton(iconTab); expect(find.byType(FlowyIconPicker), findsOneWidget); /// no recent icons final recentText = find.descendant( of: find.byType(FlowyIconPicker), matching: find.text('Recent'), ); expect(recentText, findsNothing); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart ================================================ import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('sidebar:', () { testWidgets('create a new page', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // create a new page await tester.tapNewPageButton(); // expect to see a new document tester.expectToSeePageName(''); // and with one paragraph block expect(find.byType(ParagraphBlockComponentWidget), findsOneWidget); }); testWidgets('create a new document, grid, board and calendar', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); for (final layout in ViewLayoutPB.values) { if (layout == ViewLayoutPB.Chat) { continue; } // create a new page final name = 'AppFlowy_$layout'; await tester.createNewPageWithNameUnderParent( name: name, layout: layout, ); // expect to see a new page tester.expectToSeePageName( name, layout: layout, ); switch (layout) { case ViewLayoutPB.Document: // and with one paragraph block expect(find.byType(ParagraphBlockComponentWidget), findsOneWidget); break; case ViewLayoutPB.Grid: expect(find.byType(GridPage), findsOneWidget); break; case ViewLayoutPB.Board: expect(find.byType(DesktopBoardPage), findsOneWidget); break; case ViewLayoutPB.Calendar: expect(find.byType(CalendarPage), findsOneWidget); break; case ViewLayoutPB.Chat: break; } await tester.openPage(gettingStarted); } }); testWidgets('create some nested pages, and move them', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final names = [1, 2, 3, 4].map((e) => 'document_$e').toList(); for (var i = 0; i < names.length; i++) { final parentName = i == 0 ? gettingStarted : names[i - 1]; await tester.createNewPageWithNameUnderParent( name: names[i], parentName: parentName, ); tester.expectToSeePageName(names[i], parentName: parentName); } // move the document_3 to the getting started page await tester.movePageToOtherPage( name: names[3], parentName: gettingStarted, layout: ViewLayoutPB.Document, parentLayout: ViewLayoutPB.Document, ); final fromId = tester .widget(tester.findPageName(names[3])) .view .parentViewId; final toId = tester .widget(tester.findPageName(gettingStarted)) .view .id; expect(fromId, toId); // move the document_2 before document_1 await tester.movePageToOtherPage( name: names[2], parentName: gettingStarted, layout: ViewLayoutPB.Document, parentLayout: ViewLayoutPB.Document, position: DraggableHoverPosition.bottom, ); final childViews = tester .widget(tester.findPageName(gettingStarted)) .view .childViews; expect( childViews[0].id, tester .widget(tester.findPageName(names[2])) .view .id, ); expect( childViews[1].id, tester .widget(tester.findPageName(names[0])) .view .id, ); expect( childViews[2].id, tester .widget(tester.findPageName(names[3])) .view .id, ); }); testWidgets('unable to move a document into a database', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const document = 'document'; await tester.createNewPageWithNameUnderParent( name: document, openAfterCreated: false, ); tester.expectToSeePageName(document); const grid = 'grid'; await tester.createNewPageWithNameUnderParent( name: grid, layout: ViewLayoutPB.Grid, openAfterCreated: false, ); tester.expectToSeePageName(grid, layout: ViewLayoutPB.Grid); // move the document to the grid page await tester.movePageToOtherPage( name: document, parentName: grid, layout: ViewLayoutPB.Document, parentLayout: ViewLayoutPB.Grid, ); // it should not be moved final childViews = tester .widget(tester.findPageName(gettingStarted)) .view .childViews; expect( childViews[0].name, document, ); expect( childViews[1].name, grid, ); }); testWidgets('unable to create a new database inside the existing one', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); const grid = 'grid'; await tester.createNewPageWithNameUnderParent( name: grid, layout: ViewLayoutPB.Grid, ); tester.expectToSeePageName(grid, layout: ViewLayoutPB.Grid); await tester.hoverOnPageName( grid, layout: ViewLayoutPB.Grid, onHover: () async { expect(find.byType(ViewAddButton), findsNothing); expect(find.byType(ViewMoreActionPopover), findsOneWidget); }, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test_runner.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'sidebar_favorites_test.dart' as sidebar_favorite_test; import 'sidebar_icon_test.dart' as sidebar_icon_test; import 'sidebar_recent_icon_test.dart' as sidebar_recent_icon_test; import 'sidebar_test.dart' as sidebar_test; import 'sidebar_view_item_test.dart' as sidebar_view_item_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Sidebar integration tests sidebar_test.main(); // sidebar_expanded_test.main(); sidebar_favorite_test.main(); sidebar_icon_test.main(); sidebar_view_item_test.main(); sidebar_recent_icon_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_view_item_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/emoji.dart'; import '../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); group('Sidebar view item tests', () { testWidgets('Access view item context menu by right click', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // Right click on the view item and change icon await tester.hoverOnWidget( find.byType(ViewItem), onHover: () async { await tester.tap(find.byType(ViewItem), buttons: kSecondaryButton); await tester.pumpAndSettle(); }, ); // Change icon final changeIconButton = find.text(LocaleKeys.document_plugins_cover_changeIcon.tr()); await tester.tapButton(changeIconButton); await tester.pumpUntilFound(find.byType(FlowyEmojiPicker)); const emoji = '😁'; await tester.tapEmoji(emoji); await tester.pumpAndSettle(); tester.expectViewHasIcon( gettingStarted, ViewLayoutPB.Document, EmojiIconData.emoji(emoji), ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/board_test.dart ================================================ import 'package:appflowy_board/appflowy_board.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; /// Integration tests for an empty board. The [TestWorkspaceService] will load /// a workspace from an empty board `assets/test/workspaces/board.zip` for all /// tests. /// /// To create another integration test with a preconfigured workspace. /// Use the following steps. /// 1. Create a new workspace from the AppFlowy launch screen. /// 2. Modify the workspace until it is suitable as the starting point for /// the integration test you need to land. /// 3. Use a zip utility program to zip the workspace folder that you created. /// 4. Add the zip file under `assets/test/workspaces/` /// 5. Add a new enumeration to [TestWorkspace] in `integration_test/utils/data.dart`. /// For example, if you added a workspace called `empty_calendar.zip`, /// then [TestWorkspace] should have the following value: /// ```dart /// enum TestWorkspace { /// board('board'), /// empty_calendar('empty_calendar'); /// /// /* code */ /// } /// ``` /// 6. Double check that the .zip file that you added is included as an asset in /// the pubspec.yaml file under appflowy_flutter. void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); const service = TestWorkspaceService(TestWorkspace.board); group('board', () { setUpAll(() async => service.setUpAll()); setUp(() async => service.setUp()); testWidgets('open the board with data structure in v0.2.0', (tester) async { await tester.initializeAppFlowy(); expect(find.byType(AppFlowyBoard), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/code_block_language_selector_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/base.dart'; import '../../shared/common_operations.dart'; import '../../shared/document_test_operations.dart'; import '../document/document_codeblock_paste_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('Code Block Language Selector Test', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); /// create a new document await tester.createNewPageWithNameUnderParent(); /// tap editor to get focus await tester.tapButton(find.byType(AppFlowyEditor)); expect(find.byType(CodeBlockLanguageSelector), findsNothing); await insertCodeBlockInDocument(tester); ///tap button await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget)); await tester .tapButtonWithName(LocaleKeys.document_codeBlock_language_auto.tr()); expect(find.byType(CodeBlockLanguageSelector), findsOneWidget); for (var i = 0; i < 3; ++i) { await onKey(tester, LogicalKeyboardKey.arrowDown); } for (var i = 0; i < 2; ++i) { await onKey(tester, LogicalKeyboardKey.arrowUp); } await onKey(tester, LogicalKeyboardKey.enter); final editorState = tester.editor.getCurrentEditorState(); String language = editorState .getNodeAtPath([0])! .attributes[CodeBlockKeys.language] .toString(); expect( language.toLowerCase(), defaultCodeBlockSupportedLanguages.first.toLowerCase(), ); await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget)); await tester.tapButtonWithName(language); await onKey(tester, LogicalKeyboardKey.arrowUp); await onKey(tester, LogicalKeyboardKey.enter); language = editorState .getNodeAtPath([0])! .attributes[CodeBlockKeys.language] .toString(); expect( language.toLowerCase(), defaultCodeBlockSupportedLanguages.last.toLowerCase(), ); await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget)); await tester.tapButtonWithName(language); tester.testTextInput.enterText("rust"); await onKey(tester, LogicalKeyboardKey.delete); await onKey(tester, LogicalKeyboardKey.delete); await onKey(tester, LogicalKeyboardKey.arrowDown); tester.testTextInput.enterText("st"); await onKey(tester, LogicalKeyboardKey.arrowDown); await onKey(tester, LogicalKeyboardKey.enter); language = editorState .getNodeAtPath([0])! .attributes[CodeBlockKeys.language] .toString(); expect(language.toLowerCase(), 'rust'); }); } Future onKey(WidgetTester tester, LogicalKeyboardKey key) async { await tester.simulateKeyEvent(key); await tester.pumpAndSettle(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/emoji/emoji_handler.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); Future prepare(WidgetTester tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.createNewPageWithNameUnderParent(); await tester.editor.tapLineOfEditorAt(0); } // May be better to move this to an existing test but unsure what it fits with group('Keyboard shortcuts related to emojis', () { testWidgets('cmd/ctrl+alt+e shortcut opens the emoji picker', (tester) async { await prepare(tester); expect(find.byType(EmojiHandler), findsNothing); await tester.simulateKeyEvent( LogicalKeyboardKey.keyE, isAltPressed: true, isMetaPressed: Platform.isMacOS, isControlPressed: !Platform.isMacOS, ); await tester.pumpAndSettle(Duration(seconds: 1)); expect(find.byType(EmojiHandler), findsOneWidget); /// press backspace to hide the emoji picker await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); expect(find.byType(EmojiHandler), findsNothing); }); testWidgets('insert emoji by slash menu', (tester) async { await prepare(tester); await tester.editor.showSlashMenu(); /// show emoji picler await tester.editor.tapSlashMenuItemWithName( LocaleKeys.document_slashMenu_name_emoji.tr(), offset: 100, ); await tester.pumpAndSettle(Duration(seconds: 1)); expect(find.byType(EmojiHandler), findsOneWidget); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); final firstNode = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; /// except the emoji is in document expect(firstNode.delta!.toPlainText().contains('😀'), true); }); }); group('insert emoji by colon', () { Future createNewDocumentAndShowEmojiList( WidgetTester tester, { String? search, }) async { await prepare(tester); await tester.ime.insertText(':${search ?? 'a'}'); await tester.pumpAndSettle(Duration(seconds: 1)); } testWidgets('insert with click', (tester) async { await createNewDocumentAndShowEmojiList(tester); /// emoji list is showing final emojiHandler = find.byType(EmojiHandler); expect(emojiHandler, findsOneWidget); final emojiButtons = find.descendant(of: emojiHandler, matching: find.byType(FlowyButton)); final firstTextFinder = find.descendant( of: emojiButtons.first, matching: find.byType(FlowyText), ); final emojiText = (firstTextFinder.evaluate().first.widget as FlowyText).text; /// click first emoji item await tester.tapButton(emojiButtons.first); final firstNode = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; /// except the emoji is in document expect(emojiText.contains(firstNode.delta!.toPlainText()), true); }); testWidgets('insert with arrow and enter', (tester) async { await createNewDocumentAndShowEmojiList(tester); /// emoji list is showing final emojiHandler = find.byType(EmojiHandler); expect(emojiHandler, findsOneWidget); final emojiButtons = find.descendant(of: emojiHandler, matching: find.byType(FlowyButton)); /// tap arrow down and arrow up await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp); await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown); final firstTextFinder = find.descendant( of: emojiButtons.first, matching: find.byType(FlowyText), ); final emojiText = (firstTextFinder.evaluate().first.widget as FlowyText).text; /// tap enter await tester.simulateKeyEvent(LogicalKeyboardKey.enter); final firstNode = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; /// except the emoji is in document expect(emojiText.contains(firstNode.delta!.toPlainText()), true); }); testWidgets('insert with searching', (tester) async { await createNewDocumentAndShowEmojiList(tester, search: 's'); /// search for `smiling eyes`, IME is not working, use keyboard input final searchText = [ LogicalKeyboardKey.keyM, LogicalKeyboardKey.keyI, LogicalKeyboardKey.keyL, LogicalKeyboardKey.keyI, LogicalKeyboardKey.keyN, LogicalKeyboardKey.keyG, LogicalKeyboardKey.space, LogicalKeyboardKey.keyE, LogicalKeyboardKey.keyY, LogicalKeyboardKey.keyE, LogicalKeyboardKey.keyS, ]; for (final key in searchText) { await tester.simulateKeyEvent(key); } /// tap enter await tester.simulateKeyEvent(LogicalKeyboardKey.enter); final firstNode = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; /// except the emoji is in document expect(firstNode.delta!.toPlainText().contains('😄'), true); }); testWidgets('start searching with sapce', (tester) async { await createNewDocumentAndShowEmojiList(tester, search: ' '); /// emoji list is showing final emojiHandler = find.byType(EmojiHandler); expect(emojiHandler, findsNothing); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_document_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/keyboard.dart'; import '../../shared/util.dart'; /// Integration tests for an empty document. The [TestWorkspaceService] will load a workspace from an empty document `assets/test/workspaces/empty_document.zip` for all tests. /// /// To create another integration test with a preconfigured workspace. Use the following steps: /// 1. Create a new workspace from the AppFlowy launch screen. /// 2. Modify the workspace until it is suitable as the starting point for the integration test you need to land. /// 3. Use a zip utility program to zip the workspace folder that you created. /// 4. Add the zip file under `assets/test/workspaces/` /// 5. Add a new enumeration to [TestWorkspace] in `integration_test/utils/data.dart`. For example, if you added a workspace called `empty_calendar.zip`, then [TestWorkspace] should have the following value: /// ```dart /// enum TestWorkspace { /// board('board'), /// empty_calendar('empty_calendar'); /// /// /* code */ /// } /// ``` /// 6. Double check that the .zip file that you added is included as an asset in the pubspec.yaml file under appflowy_flutter. void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); const service = TestWorkspaceService(TestWorkspace.emptyDocument); group('Tests on a workspace with only an empty document', () { setUpAll(() async => service.setUpAll()); setUp(() async => service.setUp()); testWidgets('/board shortcut creates a new board and view of the board', (tester) async { await tester.initializeAppFlowy(); // Needs tab to obtain focus for the app flowy editor. // by default the tap appears at the center of the widget. final Finder editor = find.byType(AppFlowyEditor); await tester.tap(editor); await tester.pumpAndSettle(); // tester.sendText() cannot be used since the editor // does not contain any EditableText widgets. // to interact with the app during an integration test, // simulate physical keyboard events. await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.slash, LogicalKeyboardKey.keyB, LogicalKeyboardKey.keyO, LogicalKeyboardKey.keyA, LogicalKeyboardKey.keyR, LogicalKeyboardKey.keyD, LogicalKeyboardKey.arrowDown, ], tester: tester, ); // Checks whether the options in the selection menu // for /board exist. expect(find.byType(SelectionMenuItemWidget), findsAtLeastNWidgets(2)); // Finalizes the slash command that creates the board. await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.enter, ], tester: tester, ); // Checks whether new board is referenced and properly on the page. expect(find.byType(BuiltInPageWidget), findsOneWidget); // Checks whether the new database was created const newBoardLabel = "Untitled"; expect(find.text(newBoardLabel), findsOneWidget); // Checks whether a view of the database was created const viewOfBoardLabel = "View of Untitled"; expect(find.text(viewOfBoardLabel), findsNWidgets(2)); }); testWidgets('/grid shortcut creates a new grid and view of the grid', (tester) async { await tester.initializeAppFlowy(); // Needs tab to obtain focus for the app flowy editor. // by default the tap appears at the center of the widget. final Finder editor = find.byType(AppFlowyEditor); await tester.tap(editor); await tester.pumpAndSettle(); // tester.sendText() cannot be used since the editor // does not contain any EditableText widgets. // to interact with the app during an integration test, // simulate physical keyboard events. await FlowyTestKeyboard.simulateKeyDownEvent( [ LogicalKeyboardKey.slash, LogicalKeyboardKey.keyG, LogicalKeyboardKey.keyR, LogicalKeyboardKey.keyI, LogicalKeyboardKey.keyD, LogicalKeyboardKey.arrowDown, ], tester: tester, ); // Checks whether the options in the selection menu // for /grid exist. expect(find.byType(SelectionMenuItemWidget), findsAtLeastNWidgets(2)); // Finalizes the slash command that creates the board. await simulateKeyDownEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); // Checks whether new board is referenced and properly on the page. expect(find.byType(BuiltInPageWidget), findsOneWidget); // Checks whether the new database was created const newTableLabel = "Untitled"; expect(find.text(newTableLabel), findsOneWidget); // Checks whether a view of the database was created const viewOfTableLabel = "View of Untitled"; expect(find.text(viewOfTableLabel), findsNWidgets(2)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/keyboard.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('hotkeys test', () { testWidgets('toggle theme mode', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); await tester.openSettings(); await tester.openSettingsPage(SettingsPage.workspace); await tester.pumpAndSettle(); final appFinder = find.byType(MaterialApp).first; ThemeMode? themeMode = tester.widget(appFinder).themeMode; expect(themeMode, ThemeMode.system); await tester.tapButton( find.bySemanticsLabel( LocaleKeys.settings_workspacePage_appearance_options_light.tr(), ), ); await tester.pumpAndSettle(const Duration(milliseconds: 250)); themeMode = tester.widget(appFinder).themeMode; expect(themeMode, ThemeMode.light); await tester.tapButton( find.bySemanticsLabel( LocaleKeys.settings_workspacePage_appearance_options_dark.tr(), ), ); await tester.pumpAndSettle(const Duration(milliseconds: 250)); themeMode = tester.widget(appFinder).themeMode; expect(themeMode, ThemeMode.dark); await tester.tap(find.byType(SettingsDialog)); await tester.pumpAndSettle(); await FlowyTestKeyboard.simulateKeyDownEvent( [ Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.keyL, ], tester: tester, ); await tester.pumpAndSettle(const Duration(milliseconds: 500)); // disable it temporarily. It works on macOS but not on Linux. // themeMode = tester.widget(appFinder).themeMode; // expect(themeMode, ThemeMode.light); }); testWidgets('show or hide home menu', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); await tester.pumpAndSettle(); expect(find.byType(HomeSideBar), findsOneWidget); await FlowyTestKeyboard.simulateKeyDownEvent( [ Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.backslash, ], tester: tester, ); await tester.pumpAndSettle(); expect(find.byType(HomeSideBar), findsNothing); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/import_files_test.dart ================================================ import 'dart:io'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('import files', () { testWidgets('import multiple markdown files', (tester) async { final context = await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // expect to see a getting started page tester.expectToSeePageName(gettingStarted); await tester.tapAddViewButton(); await tester.tapImportButton(); final testFileNames = ['test1.md', 'test2.md']; final paths = []; for (final fileName in testFileNames) { final str = await rootBundle.loadString( 'assets/test/workspaces/markdowns/$fileName', ); final path = p.join(context.applicationDataDirectory, fileName); paths.add(path); File(path).writeAsStringSync(str); } // mock get files mockPickFilePaths( paths: testFileNames .map((e) => p.join(context.applicationDataDirectory, e)) .toList(), ); await tester.tapTextAndMarkdownButton(); tester.expectToSeePageName('test1'); tester.expectToSeePageName('test2'); }); testWidgets('import markdown file with table', (tester) async { final context = await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // expect to see a getting started page tester.expectToSeePageName(gettingStarted); await tester.tapAddViewButton(); await tester.tapImportButton(); const testFileName = 'markdown_with_table.md'; final paths = []; final str = await rootBundle.loadString( 'assets/test/workspaces/markdowns/$testFileName', ); final path = p.join(context.applicationDataDirectory, testFileName); paths.add(path); File(path).writeAsStringSync(str); // mock get files mockPickFilePaths( paths: paths, ); await tester.tapTextAndMarkdownButton(); tester.expectToSeePageName('markdown_with_table'); // expect to see all content of markdown file along with table await tester.openPage('markdown_with_table'); final importedPageEditorState = tester.editor.getCurrentEditorState(); expect( importedPageEditorState.getNodeAtPath([0])!.type, HeadingBlockKeys.type, ); expect( importedPageEditorState.getNodeAtPath([1])!.type, HeadingBlockKeys.type, ); expect( importedPageEditorState.getNodeAtPath([2])!.type, SimpleTableBlockKeys.type, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart ================================================ import 'package:appflowy/user/presentation/screens/skip_log_in_screen.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document', () { testWidgets( 'change the language successfully when launching the app for the first time', (tester) async { await tester.initializeAppFlowy(); await tester.tapLanguageSelectorOnWelcomePage(); expect(find.byType(LanguageItemsListView), findsOneWidget); await tester.tapLanguageItem(languageCode: 'zh', countryCode: 'CN'); tester.expectToSeeText('开始'); await tester.tapLanguageItem(languageCode: 'en', scrollDelta: -100); tester.expectToSeeText('Quick Start'); await tester.tapLanguageItem(languageCode: 'it', countryCode: 'IT'); tester.expectToSeeText('Andiamo'); }); /// Make sure this test is executed after the test above. testWidgets('check the language after relaunching the app', (tester) async { await tester.initializeAppFlowy(); tester.expectToSeeText('Andiamo'); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/plugins/shared/share/share_button.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import '../../shared/mock/mock_file_picker.dart'; import '../../shared/util.dart'; import '../document/document_with_database_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('share markdown in document page', () { testWidgets('click the share button in document page', (tester) async { final context = await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // mock the file picker final path = await mockSaveFilePath( p.join(context.applicationDataDirectory, 'test.zip'), ); // click the share button and select markdown await tester.tapShareButton(); await tester.tapMarkdownButton(); // expect to see the success dialog tester.expectToExportSuccess(); final file = File(path); expect(file.existsSync(), true); final archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); for (final entry in archive) { if (entry.isFile && entry.name.endsWith('.md')) { final markdown = utf8.decode(entry.content); expect(markdown, expectedMarkdown); } } }); testWidgets( 'share the markdown after renaming the document name', (tester) async { final context = await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // expect to see a getting started page tester.expectToSeePageName(gettingStarted); // rename the document await tester.hoverOnPageName( gettingStarted, onHover: () async { await tester.renamePage('example'); }, ); final shareButton = find.byType(ShareButton); final shareButtonState = tester.widget(shareButton) as ShareButton; final path = await mockSaveFilePath( p.join( context.applicationDataDirectory, '${shareButtonState.view.name}.zip', ), ); // click the share button and select markdown await tester.tapShareButton(); await tester.tapMarkdownButton(); // expect to see the success dialog tester.expectToExportSuccess(); final file = File(path); expect(file.existsSync(), true); final archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); for (final entry in archive) { if (entry.isFile && entry.name.endsWith('.md')) { final markdown = utf8.decode(entry.content); expect(markdown, expectedMarkdown); } } }, ); testWidgets('share the markdown with database', (tester) async { final context = await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); await insertLinkedDatabase(tester, ViewLayoutPB.Grid); // mock the file picker final path = await mockSaveFilePath( p.join(context.applicationDataDirectory, 'test.zip'), ); // click the share button and select markdown await tester.tapShareButton(); await tester.tapMarkdownButton(); // expect to see the success dialog tester.expectToExportSuccess(); final file = File(path); expect(file.existsSync(), true); final archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); bool hasCsvFile = false; for (final entry in archive) { if (entry.isFile && entry.name.endsWith('.csv')) { hasCsvFile = true; } } expect(hasCsvFile, true); }); }); } const expectedMarkdown = ''' # Welcome to AppFlowy! ## Here are the basics - [ ] Click anywhere and just start typing. - [ ] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~ - [ ] As soon as you type `/` a menu will pop up. Select different types of content blocks you can add. - [ ] Type `/` followed by `/bullet` or `/num` to create a list. - [x] Click `+ New Page `button at the bottom of your sidebar to add a new page. - [ ] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`. --- ## Keyboard shortcuts, markdown, and code block 1. Keyboard shortcuts [guide](https://appflowy.gitbook.io/docs/essential-documentation/shortcuts) 1. Markdown [reference](https://appflowy.gitbook.io/docs/essential-documentation/markdown) 1. Type `/code` to insert a code block ```rust // This is the main function. fn main() { // Print text to the console. println!("Hello World!"); } ``` ## Have a question❓ > Click `?` at the bottom right for help and support. > 🥰 > > Like AppFlowy? Follow us: > [GitHub](https://github.com/AppFlowy-IO/AppFlowy) > [Twitter](https://twitter.com/appflowy): @appflowy > [Newsletter](https://blog-appflowy.ghost.io/) > '''; ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/switch_folder_test.dart ================================================ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('customize the folder path', () { if (Platform.isWindows) { return; } // testWidgets('switch to B from A, then switch to A again', (tester) async { // const userA = 'UserA'; // const userB = 'UserB'; // final initialPath = p.join(userA, appFlowyDataFolder); // final context = await tester.initializeAppFlowy( // pathExtension: initialPath, // ); // // remove the last extension // final rootPath = context.applicationDataDirectory.replaceFirst( // initialPath, // '', // ); // await tester.tapGoButton(); // await tester.expectToSeeHomePageWithGetStartedPage(); // // switch to user B // { // // set user name for userA // await tester.openSettings(); // await tester.openSettingsPage(SettingsPage.user); // await tester.enterUserName(userA); // await tester.openSettingsPage(SettingsPage.files); // await tester.pumpAndSettle(); // // mock the file_picker result // await mockGetDirectoryPath( // p.join(rootPath, userB), // ); // await tester.tapCustomLocationButton(); // await tester.pumpAndSettle(); // await tester.expectToSeeHomePageWithGetStartedPage(); // // set user name for userB // await tester.openSettings(); // await tester.openSettingsPage(SettingsPage.user); // await tester.enterUserName(userB); // } // // switch to the userA // { // await tester.openSettingsPage(SettingsPage.files); // await tester.pumpAndSettle(); // // mock the file_picker result // await mockGetDirectoryPath( // p.join(rootPath, userA), // ); // await tester.tapCustomLocationButton(); // await tester.pumpAndSettle(); // await tester.expectToSeeHomePageWithGetStartedPage(); // tester.expectToSeeUserName(userA); // } // // switch to the userB again // { // await tester.openSettings(); // await tester.openSettingsPage(SettingsPage.files); // await tester.pumpAndSettle(); // // mock the file_picker result // await mockGetDirectoryPath( // p.join(rootPath, userB), // ); // await tester.tapCustomLocationButton(); // await tester.pumpAndSettle(); // await tester.expectToSeeHomePageWithGetStartedPage(); // tester.expectToSeeUserName(userB); // } // }); // Disable this test because it failed after executing. // testWidgets('reset to default location', (tester) async { // await tester.initializeAppFlowy(); // await tester.tapAnonymousSignInButton(); // // home and readme document // await tester.expectToSeeHomePageWithGetStartedPage(); // // open settings and restore the location // await tester.openSettings(); // await tester.openSettingsPage(SettingsPage.manageData); // await tester.restoreLocation(); // expect( // await appFlowyApplicationDataDirectory().then((value) => value.path), // await getIt().getPath(), // ); // }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/keyboard.dart'; import '../../shared/util.dart'; const _documentName = 'First Doc'; const _documentTwoName = 'Second Doc'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Tabs', () { testWidgets('open/navigate/close tabs', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); // No tabs rendered yet expect(find.byType(FlowyTab), findsNothing); await tester.createNewPageWithNameUnderParent(name: _documentName); await tester.createNewPageWithNameUnderParent(name: _documentTwoName); /// Open second menu item in a new tab await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); /// Open third menu item in a new tab await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); expect( find.descendant( of: find.byType(TabsManager), matching: find.byType(FlowyTab), ), findsNWidgets(3), ); /// Navigate to the second tab await tester.tap( find.descendant( of: find.byType(FlowyTab), matching: find.text(gettingStarted), ), ); /// Close tab by shortcut await FlowyTestKeyboard.simulateKeyDownEvent( [ Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyW, ], tester: tester, ); expect( find.descendant( of: find.byType(TabsManager), matching: find.byType(FlowyTab), ), findsNWidgets(2), ); }); testWidgets('right click show tab menu, close others', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); expect( find.descendant( of: find.byType(TabsManager), matching: find.byType(TabBar), ), findsNothing, ); await tester.createNewPageWithNameUnderParent(name: _documentName); await tester.createNewPageWithNameUnderParent(name: _documentTwoName); /// Open second menu item in a new tab await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); /// Open third menu item in a new tab await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); expect( find.descendant( of: find.byType(TabsManager), matching: find.byType(FlowyTab), ), findsNWidgets(3), ); /// Right click on second tab await tester.tap( buttons: kSecondaryButton, find.descendant( of: find.byType(FlowyTab), matching: find.text(gettingStarted), ), ); await tester.pumpAndSettle(); expect(find.byType(TabMenu), findsOneWidget); final firstTabFinder = find.descendant( of: find.byType(FlowyTab), matching: find.text(_documentTwoName), ); final secondTabFinder = find.descendant( of: find.byType(FlowyTab), matching: find.text(gettingStarted), ); final thirdTabFinder = find.descendant( of: find.byType(FlowyTab), matching: find.text(_documentName), ); expect(firstTabFinder, findsOneWidget); expect(secondTabFinder, findsOneWidget); expect(thirdTabFinder, findsOneWidget); // Close other tabs than the second item await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr())); await tester.pumpAndSettle(); // We expect to not find any tabs expect(firstTabFinder, findsNothing); expect(secondTabFinder, findsNothing); expect(thirdTabFinder, findsNothing); // Expect second tab to be current page (current page has breadcrumb, cover title, // and in this case view name in sidebar) expect(find.text(gettingStarted), findsNWidgets(3)); }); testWidgets('cannot close pinned tabs', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); expect( find.descendant( of: find.byType(TabsManager), matching: find.byType(TabBar), ), findsNothing, ); await tester.createNewPageWithNameUnderParent(name: _documentName); await tester.createNewPageWithNameUnderParent(name: _documentTwoName); // Open second menu item in a new tab await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); // Open third menu item in a new tab await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); expect( find.descendant( of: find.byType(TabsManager), matching: find.byType(FlowyTab), ), findsNWidgets(3), ); const firstTab = _documentTwoName; const secondTab = gettingStarted; const thirdTab = _documentName; expect(tester.isTabAtIndex(firstTab, 0), isTrue); expect(tester.isTabAtIndex(secondTab, 1), isTrue); expect(tester.isTabAtIndex(thirdTab, 2), isTrue); expect(tester.isTabPinned(gettingStarted), isFalse); // Right click on second tab await tester.openTabMenu(gettingStarted); expect(find.byType(TabMenu), findsOneWidget); // Pin second tab await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr())); await tester.pumpAndSettle(); expect(tester.isTabPinned(gettingStarted), isTrue); /// Right click on first unpinned tab (second tab) await tester.openTabMenu(_documentTwoName); // Close others await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr())); await tester.pumpAndSettle(); // We expect to find 2 tabs, the first pinned tab and the second tab expect(find.byType(FlowyTab), findsNWidgets(2)); expect(tester.isTabAtIndex(gettingStarted, 0), isTrue); expect(tester.isTabAtIndex(_documentTwoName, 1), isTrue); }); testWidgets('pin/unpin tabs proper order', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); expect( find.descendant( of: find.byType(TabsManager), matching: find.byType(TabBar), ), findsNothing, ); await tester.createNewPageWithNameUnderParent(name: _documentName); await tester.createNewPageWithNameUnderParent(name: _documentTwoName); // Open second menu item in a new tab await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); // Open third menu item in a new tab await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); expect( find.descendant( of: find.byType(TabsManager), matching: find.byType(FlowyTab), ), findsNWidgets(3), ); const firstTabName = _documentTwoName; const secondTabName = gettingStarted; const thirdTabName = _documentName; // Expect correct order expect(tester.isTabAtIndex(firstTabName, 0), isTrue); expect(tester.isTabAtIndex(secondTabName, 1), isTrue); expect(tester.isTabAtIndex(thirdTabName, 2), isTrue); // Pin second tab await tester.openTabMenu(secondTabName); await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr())); await tester.pumpAndSettle(); expect(tester.isTabPinned(secondTabName), isTrue); // Expect correct order expect(tester.isTabAtIndex(secondTabName, 0), isTrue); expect(tester.isTabAtIndex(firstTabName, 1), isTrue); expect(tester.isTabAtIndex(thirdTabName, 2), isTrue); // Pin new second tab (first tab) await tester.openTabMenu(firstTabName); await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr())); await tester.pumpAndSettle(); expect(tester.isTabPinned(firstTabName), isTrue); expect(tester.isTabPinned(secondTabName), isTrue); expect(tester.isTabPinned(thirdTabName), isFalse); expect(tester.isTabAtIndex(secondTabName, 0), isTrue); expect(tester.isTabAtIndex(firstTabName, 1), isTrue); expect(tester.isTabAtIndex(thirdTabName, 2), isTrue); // Unpin second tab await tester.openTabMenu(secondTabName); await tester.tap(find.text(LocaleKeys.tabMenu_unpinTab.tr())); await tester.pumpAndSettle(); expect(tester.isTabPinned(firstTabName), isTrue); expect(tester.isTabPinned(secondTabName), isFalse); expect(tester.isTabPinned(thirdTabName), isFalse); expect(tester.isTabAtIndex(firstTabName, 0), isTrue); expect(tester.isTabAtIndex(secondTabName, 1), isTrue); expect(tester.isTabAtIndex(thirdTabName, 2), isTrue); }); testWidgets('displaying icons in tab', (tester) async { RecentIcons.enable = false; await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final icon = await tester.loadIcon(); // update emoji await tester.updatePageIconInSidebarByName( name: gettingStarted, parentName: gettingStarted, layout: ViewLayoutPB.Document, icon: icon, ); /// create new page await tester.createNewPageWithNameUnderParent(name: _documentName); /// open new tab for [gettingStarted] await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); final tabs = find.descendant( of: find.byType(TabsManager), matching: find.byType(FlowyTab), ); expect(tabs, findsNWidgets(2)); final svgInTab = find.descendant(of: tabs.last, matching: find.byType(FlowySvg)); final svgWidget = svgInTab.evaluate().first.widget as FlowySvg; final iconsData = IconsData.fromJson(jsonDecode(icon.emoji)); expect(svgWidget.svgString, iconsData.svgString); }); }); } extension _TabsTester on WidgetTester { bool isTabPinned(String tabName) { final tabFinder = find.ancestor( of: find.byWidgetPredicate( (w) => w is ViewTabBarItem && w.view.name == tabName, ), matching: find.byType(FlowyTab), ); final FlowyTab tabWidget = widget(tabFinder); return tabWidget.pageManager.isPinned; } bool isTabAtIndex(String tabName, int index) { final tabFinder = find.ancestor( of: find.byWidgetPredicate( (w) => w is ViewTabBarItem && w.view.name == tabName, ), matching: find.byType(FlowyTab), ); final pluginId = (widget(tabFinder) as FlowyTab).pageManager.plugin.id; final pluginIds = find .byType(FlowyTab) .evaluate() .map((e) => (e.widget as FlowyTab).pageManager.plugin.id); return pluginIds.elementAt(index) == pluginId; } Future openTabMenu(String tabName) async { await tap( buttons: kSecondaryButton, find.ancestor( of: find.byWidgetPredicate( (w) => w is ViewTabBarItem && w.view.name == tabName, ), matching: find.byType(FlowyTab), ), ); await pumpAndSettle(); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'emoji_shortcut_test.dart' as emoji_shortcut_test; import 'hotkeys_test.dart' as hotkeys_test; import 'import_files_test.dart' as import_files_test; import 'share_markdown_test.dart' as share_markdown_test; import 'zoom_in_out_test.dart' as zoom_in_out_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // This test must be run first, otherwise the CI will fail. hotkeys_test.main(); emoji_shortcut_test.main(); hotkeys_test.main(); share_markdown_test.main(); import_files_test.main(); zoom_in_out_test.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop/uncategorized/zoom_in_out_test.dart ================================================ import 'package:appflowy/startup/tasks/app_window_size_manager.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:integration_test/integration_test.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../shared/base.dart'; import '../../shared/common_operations.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Zoom in/out:', () { Future resetAppFlowyScaleFactor( WindowSizeManager windowSizeManager, ) async { appflowyScaleFactor = 1.0; await windowSizeManager.setScaleFactor(1.0); } testWidgets('Zoom in', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); double currentScaleFactor = 1.0; // this value can't be defined in the setUp method, because the windowSizeManager is not initialized yet. final windowSizeManager = WindowSizeManager(); await resetAppFlowyScaleFactor(windowSizeManager); // zoom in 2 times for (final keycode in zoomInKeyCodes) { if (UniversalPlatform.isLinux && keycode.logicalKey == LogicalKeyboardKey.add) { // Key LogicalKeyboardKey#79c9b(keyId: "0x0000002b", keyLabel: "+", debugName: "Add") not found in // linux keyCode map continue; } // test each keycode 2 times for (var i = 0; i < 2; i++) { await tester.simulateKeyEvent( keycode.logicalKey, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, // Register the physical key for the "Add" key, otherwise the test will fail and throw an error: // Physical key for LogicalKeyboardKey#79c9b(keyId: "0x0000002b", keyLabel: "+", debugName: "Add") // not found in known physical keys physicalKey: keycode.logicalKey == LogicalKeyboardKey.add ? PhysicalKeyboardKey.equal : null, ); await tester.pumpAndSettle(); currentScaleFactor += 0.1; currentScaleFactor = double.parse( currentScaleFactor.toStringAsFixed(2), ); final scaleFactor = await windowSizeManager.getScaleFactor(); expect(currentScaleFactor, appflowyScaleFactor); expect(currentScaleFactor, scaleFactor); } } }); testWidgets('Reset zoom', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); final windowSizeManager = WindowSizeManager(); for (final keycode in resetZoomKeyCodes) { await tester.simulateKeyEvent( keycode.logicalKey, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); final scaleFactor = await windowSizeManager.getScaleFactor(); expect(1.0, appflowyScaleFactor); expect(1.0, scaleFactor); } }); testWidgets('Zoom out', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); double currentScaleFactor = 1.0; final windowSizeManager = WindowSizeManager(); await resetAppFlowyScaleFactor(windowSizeManager); // zoom out 2 times for (final keycode in zoomOutKeyCodes) { if (UniversalPlatform.isLinux && keycode.logicalKey == LogicalKeyboardKey.numpadSubtract) { // Key LogicalKeyboardKey#2c39f(keyId: "0x20000022d", keyLabel: "Numpad Subtract", debugName: "Numpad // Subtract") not found in linux keyCode map continue; } // test each keycode 2 times for (var i = 0; i < 2; i++) { await tester.simulateKeyEvent( keycode.logicalKey, isControlPressed: !UniversalPlatform.isMacOS, isMetaPressed: UniversalPlatform.isMacOS, ); await tester.pumpAndSettle(); currentScaleFactor -= 0.1; final scaleFactor = await windowSizeManager.getScaleFactor(); expect(currentScaleFactor, appflowyScaleFactor); expect(currentScaleFactor, scaleFactor); } } }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop_runner_1.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'desktop/document/document_test_runner_1.dart' as document_test_runner_1; import 'desktop/uncategorized/switch_folder_test.dart' as switch_folder_test; Future main() async { await runIntegration1OnDesktop(); } Future runIntegration1OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); switch_folder_test.main(); document_test_runner_1.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop_runner_2.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'desktop/database/database_test_runner_1.dart' as database_test_runner_1; import 'desktop/first_test/first_test.dart' as first_test; Future main() async { await runIntegration2OnDesktop(); } Future runIntegration2OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); first_test.main(); database_test_runner_1.main(); // DON'T add more tests here. This is the second test runner for desktop. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop_runner_3.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'desktop/board/board_test_runner.dart' as board_test_runner; import 'desktop/first_test/first_test.dart' as first_test; import 'desktop/grid/grid_test_runner_1.dart' as grid_test_runner_1; Future main() async { await runIntegration3OnDesktop(); } Future runIntegration3OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); first_test.main(); board_test_runner.main(); grid_test_runner_1.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop_runner_4.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'desktop/document/document_test_runner_2.dart' as document_test_runner_2; import 'desktop/first_test/first_test.dart' as first_test; Future main() async { await runIntegration4OnDesktop(); } Future runIntegration4OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); first_test.main(); document_test_runner_2.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop_runner_5.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'desktop/database/database_test_runner_2.dart' as database_test_runner_2; import 'desktop/first_test/first_test.dart' as first_test; Future main() async { await runIntegration5OnDesktop(); } Future runIntegration5OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); first_test.main(); database_test_runner_2.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop_runner_6.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'desktop/first_test/first_test.dart' as first_test; import 'desktop/settings/settings_runner.dart' as settings_test_runner; import 'desktop/sidebar/sidebar_test_runner.dart' as sidebar_test_runner; import 'desktop/uncategorized/uncategorized_test_runner_1.dart' as uncategorized_test_runner_1; Future main() async { await runIntegration6OnDesktop(); } Future runIntegration6OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); first_test.main(); settings_test_runner.main(); sidebar_test_runner.main(); uncategorized_test_runner_1.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop_runner_7.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'desktop/document/document_test_runner_3.dart' as document_test_runner_3; import 'desktop/first_test/first_test.dart' as first_test; Future main() async { await runIntegration7OnDesktop(); } Future runIntegration7OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); first_test.main(); document_test_runner_3.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop_runner_8.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'desktop/document/document_test_runner_4.dart' as document_test_runner_4; import 'desktop/first_test/first_test.dart' as first_test; Future main() async { await runIntegration8OnDesktop(); } Future runIntegration8OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); first_test.main(); document_test_runner_4.main(); // DON'T add more tests here. } ================================================ FILE: frontend/appflowy_flutter/integration_test/desktop_runner_9.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'desktop/chat/chat_page_test.dart' as chat_page_test; import 'desktop/database/database_icon_test.dart' as database_icon_test; import 'desktop/first_test/first_test.dart' as first_test; import 'desktop/uncategorized/code_block_language_selector_test.dart' as code_language_selector; import 'desktop/uncategorized/tabs_test.dart' as tabs_test; Future main() async { await runIntegration9OnDesktop(); } Future runIntegration9OnDesktop() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); first_test.main(); tabs_test.main(); code_language_selector.main(); database_icon_test.main(); chat_page_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/cloud/cloud_runner.dart ================================================ import 'document/publish_test.dart' as publish_test; import 'document/share_link_test.dart' as share_link_test; import 'space/space_test.dart' as space_test; import 'workspace/workspace_operations_test.dart' as workspace_operations_test; Future main() async { workspace_operations_test.main(); share_link_test.main(); publish_test.main(); space_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/cloud/document/publish_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('publish:', () { testWidgets(''' 1. publish document 2. update path name 3. unpublish document ''', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); await tester.openPage(Constants.gettingStartedPageName); await tester.editor.openMoreActionMenuOnMobile(); // click the publish button await tester.editor.clickMoreActionItemOnMobile( LocaleKeys.shareAction_publish.tr(), ); // wait the notification dismiss final publishSuccessText = find.findTextInFlowyText( LocaleKeys.publish_publishSuccessfully.tr(), ); expect(publishSuccessText, findsOneWidget); await tester.pumpUntilNotFound(publishSuccessText); // open the menu again, to check the publish status await tester.editor.openMoreActionMenuOnMobile(); // expect to see the unpublish button and the visit site button expect( find.text(LocaleKeys.shareAction_unPublish.tr()), findsOneWidget, ); expect( find.text(LocaleKeys.shareAction_visitSite.tr()), findsOneWidget, ); // update the path name await tester.editor.clickMoreActionItemOnMobile( LocaleKeys.shareAction_updatePathName.tr(), ); const pathName1 = '???????????????'; const pathName2 = 'AppFlowy'; final textField = find.descendant( of: find.byType(EditWorkspaceNameBottomSheet), matching: find.byType(TextFormField), ); await tester.enterText(textField, pathName1); await tester.pumpAndSettle(); // wait 50ms to ensure the error message is shown await tester.wait(50); // click the confirm button final confirmButton = find.text(LocaleKeys.button_confirm.tr()); await tester.tapButton(confirmButton); // expect to see the update path name failed toast final updatePathFailedText = find.text( LocaleKeys.settings_sites_error_publishNameContainsInvalidCharacters .tr(), ); expect(updatePathFailedText, findsOneWidget); // input the valid path name await tester.enterText(textField, pathName2); await tester.pumpAndSettle(); // click the confirm button await tester.tapButton(confirmButton); // wait 50ms to ensure the error message is shown await tester.wait(50); // expect to see the update path name success toast final updatePathSuccessText = find.findTextInFlowyText( LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); expect(updatePathSuccessText, findsOneWidget); await tester.pumpUntilNotFound(updatePathSuccessText); // unpublish the document await tester.editor.clickMoreActionItemOnMobile( LocaleKeys.shareAction_unPublish.tr(), ); final unPublishSuccessText = find.findTextInFlowyText( LocaleKeys.publish_unpublishSuccessfully.tr(), ); expect(unPublishSuccessText, findsOneWidget); await tester.pumpUntilNotFound(unPublishSuccessText); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/cloud/document/share_link_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('share link:', () { testWidgets('copy share link and paste it on doc', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // open the getting started page and paste the link await tester.openPage(Constants.gettingStartedPageName); // open the more action menu await tester.editor.openMoreActionMenuOnMobile(); // click the share link item await tester.editor.clickMoreActionItemOnMobile( LocaleKeys.shareAction_copyLink.tr(), ); // check the clipboard final content = await Clipboard.getData(Clipboard.kTextPlain); expect( content?.text, matches(appflowySharePageLinkPattern), ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/cloud/space/space_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/space/manage_space_widget.dart'; import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart'; import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart'; import 'package:appflowy/mobile/presentation/home/space/space_menu_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/space/widgets.dart'; import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('space operations:', () { Future openSpaceMenu(WidgetTester tester) async { final spaceHeader = find.byType(MobileSpaceHeader); await tester.tapButton(spaceHeader); await tester.pumpUntilFound(find.byType(MobileSpaceMenu)); } Future openSpaceMenuMoreOptions( WidgetTester tester, ViewPB space, ) async { final spaceMenuItemTrailing = find.byWidgetPredicate( (w) => w is SpaceMenuItemTrailing && w.space.id == space.id, ); final moreOptions = find.descendant( of: spaceMenuItemTrailing, matching: find.byWidgetPredicate( (w) => w is FlowySvg && w.svg.path == FlowySvgs.workspace_three_dots_s.path, ), ); await tester.tapButton(moreOptions); await tester.pumpUntilFound(find.byType(SpaceMenuMoreOptions)); } // combine the tests together to reduce the CI time testWidgets(''' 1. create a new space 2. update the space name 3. update the space permission 4. update the space icon 5. delete the space ''', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // 1. create a new space // click the space menu await openSpaceMenu(tester); // click the create a new space button final createNewSpaceButton = find.text( LocaleKeys.space_createNewSpace.tr(), ); await tester.pumpUntilFound(createNewSpaceButton); await tester.tapButton(createNewSpaceButton); // input the new space name final inputField = find.descendant( of: find.byType(ManageSpaceWidget), matching: find.byType(TextField), ); const newSpaceName = 'AppFlowy'; await tester.enterText(inputField, newSpaceName); await tester.pumpAndSettle(); // change the space permission to private final permissionOption = find.byType(ManageSpacePermissionOption); await tester.tapButton(permissionOption); await tester.pumpAndSettle(); final privateOption = find.text(LocaleKeys.space_privatePermission.tr()); await tester.tapButton(privateOption); await tester.pumpAndSettle(); // change the space icon color final color = builtInSpaceColors[1]; final iconOption = find.descendant( of: find.byType(ManageSpaceIconOption), matching: find.byWidgetPredicate( (w) => w is SpaceColorItem && w.color == color, ), ); await tester.tapButton(iconOption); await tester.pumpAndSettle(); // change the space icon final icon = kIconGroups![0].icons[1]; final iconItem = find.descendant( of: find.byType(ManageSpaceIconOption), matching: find.byWidgetPredicate( (w) => w is SpaceIconItem && w.icon == icon, ), ); await tester.tapButton(iconItem); await tester.pumpAndSettle(); // click the done button final doneButton = find.descendant( of: find.byWidgetPredicate( (w) => w is BottomSheetHeader && w.title == LocaleKeys.space_createSpace.tr(), ), matching: find.text(LocaleKeys.button_done.tr()), ); await tester.tapButton(doneButton); await tester.pumpAndSettle(); // wait 100ms for the space to be created await tester.wait(100); // verify the space is created await openSpaceMenu(tester); final spaceItems = find.byType(MobileSpaceMenuItem); // expect to see 3 space items, 2 are built-in, 1 is the new space expect(spaceItems, findsNWidgets(3)); // convert the space item to a widget final spaceWidget = tester.widgetList(spaceItems).last; final space = spaceWidget.space; expect(space.name, newSpaceName); expect(space.spacePermission, SpacePermission.private); expect(space.spaceIcon, icon.iconPath); expect(space.spaceIconColor, color); // open the SpaceMenuMoreOptions menu await openSpaceMenuMoreOptions(tester, space); // 2. rename the space name final renameOption = find.text(LocaleKeys.button_rename.tr()); await tester.tapButton(renameOption); await tester.pumpUntilFound(find.byType(EditWorkspaceNameBottomSheet)); // input the new space name final renameInputField = find.descendant( of: find.byType(EditWorkspaceNameBottomSheet), matching: find.byType(TextField), ); const renameSpaceName = 'HelloWorld'; await tester.enterText(renameInputField, renameSpaceName); await tester.pumpAndSettle(); await tester.tapButton(find.text(LocaleKeys.button_confirm.tr())); // click the done button await tester.pumpAndSettle(); final renameSuccess = find.text( LocaleKeys.space_success_renameSpace.tr(), ); await tester.pumpUntilNotFound(renameSuccess); // check the space name is updated await openSpaceMenu(tester); final renameSpaceItem = find.descendant( of: find.byType(MobileSpaceMenuItem), matching: find.text(renameSpaceName), ); expect(renameSpaceItem, findsOneWidget); // 3. manage the space await openSpaceMenuMoreOptions(tester, space); final manageOption = find.text(LocaleKeys.space_manage.tr()); await tester.tapButton(manageOption); await tester.pumpUntilFound(find.byType(ManageSpaceWidget)); // 3.1 rename the space final textField = find.descendant( of: find.byType(ManageSpaceWidget), matching: find.byType(TextField), ); await tester.enterText(textField, 'AppFlowy'); await tester.pumpAndSettle(); // 3.2 change the permission final permissionOption2 = find.byType(ManageSpacePermissionOption); await tester.tapButton(permissionOption2); await tester.pumpAndSettle(); final publicOption = find.text(LocaleKeys.space_publicPermission.tr()); await tester.tapButton(publicOption); await tester.pumpAndSettle(); // 3.3 change the icon // change the space icon color final color2 = builtInSpaceColors[2]; final iconOption2 = find.descendant( of: find.byType(ManageSpaceIconOption), matching: find.byWidgetPredicate( (w) => w is SpaceColorItem && w.color == color2, ), ); await tester.tapButton(iconOption2); await tester.pumpAndSettle(); // change the space icon final icon2 = kIconGroups![0].icons[2]; final iconItem2 = find.descendant( of: find.byType(ManageSpaceIconOption), matching: find.byWidgetPredicate( (w) => w is SpaceIconItem && w.icon == icon2, ), ); await tester.tapButton(iconItem2); await tester.pumpAndSettle(); // click the done button final doneButton2 = find.descendant( of: find.byWidgetPredicate( (w) => w is BottomSheetHeader && w.title == LocaleKeys.space_manageSpace.tr(), ), matching: find.text(LocaleKeys.button_done.tr()), ); await tester.tapButton(doneButton2); await tester.pumpAndSettle(); // check the space is updated final spaceItems2 = find.byType(MobileSpaceMenuItem); final spaceWidget2 = tester.widgetList(spaceItems2).last; final space2 = spaceWidget2.space; expect(space2.name, 'AppFlowy'); expect(space2.spacePermission, SpacePermission.publicToAll); expect(space2.spaceIcon, icon2.iconPath); expect(space2.spaceIconColor, color2); final manageSuccess = find.text( LocaleKeys.space_success_updateSpace.tr(), ); await tester.pumpUntilNotFound(manageSuccess); // 4. duplicate the space await openSpaceMenuMoreOptions(tester, space); final duplicateOption = find.text(LocaleKeys.space_duplicate.tr()); await tester.tapButton(duplicateOption); final duplicateSuccess = find.text( LocaleKeys.space_success_duplicateSpace.tr(), ); await tester.pumpUntilNotFound(duplicateSuccess); // check the space is duplicated await openSpaceMenu(tester); final spaceItems3 = find.byType(MobileSpaceMenuItem); expect(spaceItems3, findsNWidgets(4)); // 5. delete the space await openSpaceMenuMoreOptions(tester, space); final deleteOption = find.text(LocaleKeys.button_delete.tr()); await tester.tapButton(deleteOption); final confirmDeleteButton = find.descendant( of: find.byType(CupertinoDialogAction), matching: find.text(LocaleKeys.button_delete.tr()), ); await tester.tapButton(confirmDeleteButton); final deleteSuccess = find.text( LocaleKeys.space_success_deleteSpace.tr(), ); await tester.pumpUntilNotFound(deleteSuccess); // check the space is deleted final spaceItems4 = find.byType(MobileSpaceMenuItem); expect(spaceItems4, findsNWidgets(3)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/cloud/workspace/workspace_operations_test.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../../shared/constants.dart'; import '../../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('workspace operations:', () { testWidgets('create a new workspace', (tester) async { await tester.initializeAppFlowy( cloudType: AuthenticatorType.appflowyCloudSelfHost, ); await tester.tapGoogleLoginInButton(); await tester.expectToSeeHomePageWithGetStartedPage(); // click the create a new workspace button await tester.tapButton(find.text(Constants.defaultWorkspaceName)); await tester.tapButton(find.text(LocaleKeys.workspace_create.tr())); // input the new workspace name final inputField = find.byType(TextFormField); const newWorkspaceName = 'AppFlowy'; await tester.enterText(inputField, newWorkspaceName); await tester.pumpAndSettle(); // wait for the workspace to be created await tester.pumpUntilFound( find.text(LocaleKeys.workspace_createSuccess.tr()), ); // expect to see the new workspace expect(find.text(newWorkspaceName), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/document/at_menu_test.dart ================================================ import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); const title = 'Test At Menu'; group('at menu', () { testWidgets('show at menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createPageAndShowAtMenu(title); final menuWidget = find.byType(MobileInlineActionsMenu); expect(menuWidget, findsOneWidget); }); testWidgets('search by at menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createPageAndShowAtMenu(title); const searchText = gettingStarted; await tester.ime.insertText(searchText); final actionWidgets = find.byType(MobileInlineActionsWidget); expect(actionWidgets, findsNWidgets(2)); }); testWidgets('tap at menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createPageAndShowAtMenu(title); const searchText = gettingStarted; await tester.ime.insertText(searchText); final actionWidgets = find.byType(MobileInlineActionsWidget); await tester.tap(actionWidgets.last); expect(find.byType(MentionPageBlock), findsOneWidget); }); testWidgets('create subpage with at menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createNewDocumentOnMobile(title); await tester.editor.tapLineOfEditorAt(0); const subpageName = 'Subpage'; await tester.ime.insertText('[[$subpageName'); await tester.pumpAndSettle(); final actionWidgets = find.byType(MobileInlineActionsWidget); await tester.tapButton(actionWidgets.first); final firstNode = tester.editor.getCurrentEditorState().getNodeAtPath([0]); assert(firstNode != null); expect(firstNode!.delta?.toPlainText().contains('['), false); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart ================================================ import 'package:integration_test/integration_test.dart'; import 'at_menu_test.dart' as at_menu; import 'at_menu_test.dart' as at_menu_test; import 'page_style_test.dart' as page_style_test; import 'plus_menu_test.dart' as plus_menu_test; import 'simple_table_test.dart' as simple_table_test; import 'slash_menu_test.dart' as slash_menu; import 'title_test.dart' as title_test; import 'toolbar_test.dart' as toolbar_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // Document integration tests title_test.main(); page_style_test.main(); plus_menu_test.main(); at_menu_test.main(); simple_table_test.main(); toolbar_test.main(); slash_menu.main(); at_menu.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/document/icon_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/emoji.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document title:', () { testWidgets('update page custom image icon in title bar', (tester) async { await tester.launchInAnonymousMode(); /// prepare local image final iconData = await tester.prepareImageIcon(); /// create an empty page await tester .tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey)); /// show Page style page await tester.tapButton(find.byType(MobileViewPageLayoutButton)); final pageStyleIcon = find.byType(PageStyleIcon); final iconInPageStyleIcon = find.descendant( of: pageStyleIcon, matching: find.byType(RawEmojiIconWidget), ); expect(iconInPageStyleIcon, findsNothing); /// show icon picker await tester.tapButton(pageStyleIcon); /// upload custom icon await tester.pickImage(iconData); /// check result final documentPage = find.byType(MobileDocumentScreen); final rawEmojiIconFinder = find .descendant( of: documentPage, matching: find.byType(RawEmojiIconWidget), ) .last; final rawEmojiIconWidget = rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget; final iconDataInWidget = rawEmojiIconWidget.emoji; expect(iconDataInWidget.type, FlowyIconType.custom); final imageFinder = find.descendant(of: rawEmojiIconFinder, matching: find.byType(Image)); expect(imageFinder, findsOneWidget); }); testWidgets('update page custom svg icon in title bar', (tester) async { await tester.launchInAnonymousMode(); /// prepare local image final iconData = await tester.prepareSvgIcon(); /// create an empty page await tester .tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey)); /// show Page style page await tester.tapButton(find.byType(MobileViewPageLayoutButton)); final pageStyleIcon = find.byType(PageStyleIcon); final iconInPageStyleIcon = find.descendant( of: pageStyleIcon, matching: find.byType(RawEmojiIconWidget), ); expect(iconInPageStyleIcon, findsNothing); /// show icon picker await tester.tapButton(pageStyleIcon); /// upload custom icon await tester.pickImage(iconData); /// check result final documentPage = find.byType(MobileDocumentScreen); final rawEmojiIconFinder = find .descendant( of: documentPage, matching: find.byType(RawEmojiIconWidget), ) .last; final rawEmojiIconWidget = rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget; final iconDataInWidget = rawEmojiIconWidget.emoji; expect(iconDataInWidget.type, FlowyIconType.custom); final svgFinder = find.descendant( of: rawEmojiIconFinder, matching: find.byType(SvgPicture), ); expect(svgFinder, findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/document/page_style_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/emoji.dart'; import '../../shared/util.dart'; void main() { setUpAll(() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); RecentIcons.enable = false; }); tearDownAll(() { RecentIcons.enable = true; }); group('document page style:', () { double getCurrentEditorFontSize() { final editorPage = find .byType(AppFlowyEditorPage) .evaluate() .single .widget as AppFlowyEditorPage; return editorPage.styleCustomizer .style() .textStyleConfiguration .text .fontSize!; } double getCurrentEditorLineHeight() { final editorPage = find .byType(AppFlowyEditorPage) .evaluate() .single .widget as AppFlowyEditorPage; return editorPage.styleCustomizer .style() .textStyleConfiguration .lineHeight; } testWidgets('change font size in page style settings', (tester) async { await tester.launchInAnonymousMode(); // click the getting start page await tester.openPage(gettingStarted); // click the layout button await tester.tapButton(find.byType(MobileViewPageLayoutButton)); expect(getCurrentEditorFontSize(), PageStyleFontLayout.normal.fontSize); // change font size from normal to large await tester.tapSvgButton(FlowySvgs.m_font_size_large_s); expect(getCurrentEditorFontSize(), PageStyleFontLayout.large.fontSize); // change font size from large to small await tester.tapSvgButton(FlowySvgs.m_font_size_small_s); expect(getCurrentEditorFontSize(), PageStyleFontLayout.small.fontSize); }); testWidgets('change line height in page style settings', (tester) async { await tester.launchInAnonymousMode(); // click the getting start page await tester.openPage(gettingStarted); // click the layout button await tester.tapButton(find.byType(MobileViewPageLayoutButton)); var lineHeight = getCurrentEditorLineHeight(); expect( lineHeight, PageStyleLineHeightLayout.normal.lineHeight, ); // change line height from normal to large await tester.tapSvgButton(FlowySvgs.m_layout_large_s); await tester.pumpAndSettle(); lineHeight = getCurrentEditorLineHeight(); expect( lineHeight, PageStyleLineHeightLayout.large.lineHeight, ); // change line height from large to small await tester.tapSvgButton(FlowySvgs.m_layout_small_s); lineHeight = getCurrentEditorLineHeight(); expect( lineHeight, PageStyleLineHeightLayout.small.lineHeight, ); }); testWidgets('use built-in image as cover', (tester) async { await tester.launchInAnonymousMode(); // click the getting start page await tester.openPage(gettingStarted); // click the layout button await tester.tapButton(find.byType(MobileViewPageLayoutButton)); // toggle the preset button await tester.tapSvgButton(FlowySvgs.m_page_style_presets_m); // select the first preset final firstBuiltInImage = find.byWidgetPredicate( (widget) => widget is Image && widget.image is AssetImage && (widget.image as AssetImage).assetName == PageStyleCoverImageType.builtInImagePath('1'), ); await tester.tap(firstBuiltInImage); // click done button to exit the page style settings await tester.tapButton(find.byType(BottomSheetDoneButton).first); // check the cover final builtInCover = find.descendant( of: find.byType(DocumentImmersiveCover), matching: firstBuiltInImage, ); expect(builtInCover, findsOneWidget); }); testWidgets('page style icon', (tester) async { await tester.launchInAnonymousMode(); final createPageButton = find.byKey(BottomNavigationBarItemType.add.valueKey); await tester.tapButton(createPageButton); /// toggle the preset button await tester.tapSvgButton(FlowySvgs.m_layout_s); /// select document plugins emoji final pageStyleIcon = find.byType(PageStyleIcon); /// there should be none of emoji final noneText = find.text(LocaleKeys.pageStyle_none.tr()); expect(noneText, findsOneWidget); await tester.tapButton(pageStyleIcon); /// select an emoji const emoji = '😄'; await tester.tapEmoji(emoji); await tester.tapSvgButton(FlowySvgs.m_layout_s); expect(noneText, findsNothing); expect( find.descendant( of: pageStyleIcon, matching: find.text(emoji), ), findsOneWidget, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document plus menu:', () { testWidgets('add the toggle heading blocks via plus menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createNewDocumentOnMobile('toggle heading blocks'); final editorState = tester.editor.getCurrentEditorState(); // focus on the editor unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [0])), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // open the plus menu and select the toggle heading block await tester.openPlusMenuAndClickButton( LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), ); // check the block is inserted final block1 = editorState.getNodeAtPath([0])!; expect(block1.type, equals(ToggleListBlockKeys.type)); expect(block1.attributes[ToggleListBlockKeys.level], equals(1)); // click the expand button won't cancel the selection await tester.tapButton(find.byIcon(Icons.arrow_right)); expect( editorState.selection, equals(Selection.collapsed(Position(path: [0]))), ); // focus on the next line unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [1])), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // open the plus menu and select the toggle heading block await tester.openPlusMenuAndClickButton( LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), ); // check the block is inserted final block2 = editorState.getNodeAtPath([1])!; expect(block2.type, equals(ToggleListBlockKeys.type)); expect(block2.attributes[ToggleListBlockKeys.level], equals(2)); // focus on the next line await tester.pumpAndSettle(); // open the plus menu and select the toggle heading block await tester.openPlusMenuAndClickButton( LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), ); // check the block is inserted final block3 = editorState.getNodeAtPath([2])!; expect(block3.type, equals(ToggleListBlockKeys.type)); expect(block3.attributes[ToggleListBlockKeys.level], equals(3)); // wait a few milliseconds to ensure the selection is updated await Future.delayed(const Duration(milliseconds: 100)); // check the selection is collapsed expect( editorState.selection, equals(Selection.collapsed(Position(path: [2]))), ); }); const title = 'Test Plus Menu'; testWidgets('show plus menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createPageAndShowPlusMenu(title); final menuWidget = find.byType(MobileInlineActionsMenu); expect(menuWidget, findsOneWidget); }); testWidgets('search by plus menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createPageAndShowPlusMenu(title); const searchText = gettingStarted; await tester.ime.insertText(searchText); final actionWidgets = find.byType(MobileInlineActionsWidget); expect(actionWidgets, findsNWidgets(2)); }); testWidgets('tap plus menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createPageAndShowPlusMenu(title); const searchText = gettingStarted; await tester.ime.insertText(searchText); final actionWidgets = find.byType(MobileInlineActionsWidget); await tester.tap(actionWidgets.last); expect(find.byType(MentionPageBlock), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/document/simple_table_test.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('simple table:', () { testWidgets(''' 1. insert a simple table via + menu 2. insert a row above the table 3. insert a row below the table 4. insert a column left to the table 5. insert a column right to the table 6. delete the first row 7. delete the first column ''', (tester) async { await tester.launchInAnonymousMode(); await tester.createNewDocumentOnMobile('simple table'); final editorState = tester.editor.getCurrentEditorState(); // focus on the editor unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [0])), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); final firstParagraphPath = [0, 0, 0, 0]; // open the plus menu and select the table block { await tester.openPlusMenuAndClickButton( LocaleKeys.document_slashMenu_name_table.tr(), ); // check the block is inserted final table = editorState.getNodeAtPath([0])!; expect(table.type, equals(SimpleTableBlockKeys.type)); expect(table.rowLength, equals(2)); expect(table.columnLength, equals(2)); // focus on the first cell final selection = editorState.selection!; expect(selection.isCollapsed, isTrue); expect(selection.start.path, equals(firstParagraphPath)); } // insert left and insert right { // click the column menu button await tester.clickColumnMenuButton(0); // insert left, insert right await tester.tapButton( find.findTextInFlowyText( LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(), ), ); await tester.tapButton( find.findTextInFlowyText( LocaleKeys.document_plugins_simpleTable_moreActions_insertRight .tr(), ), ); await tester.cancelTableActionMenu(); // check the table is updated final table = editorState.getNodeAtPath([0])!; expect(table.type, equals(SimpleTableBlockKeys.type)); expect(table.rowLength, equals(2)); expect(table.columnLength, equals(4)); } // insert above and insert below { // focus on the first cell unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: firstParagraphPath)), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // click the row menu button await tester.clickRowMenuButton(0); await tester.tapButton( find.findTextInFlowyText( LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove .tr(), ), ); await tester.tapButton( find.findTextInFlowyText( LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow .tr(), ), ); await tester.cancelTableActionMenu(); // check the table is updated final table = editorState.getNodeAtPath([0])!; expect(table.rowLength, equals(4)); expect(table.columnLength, equals(4)); } // delete the first row { // focus on the first cell unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: firstParagraphPath)), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // delete the first row await tester.clickRowMenuButton(0); await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete); await tester.cancelTableActionMenu(); // check the table is updated final table = editorState.getNodeAtPath([0])!; expect(table.rowLength, equals(3)); expect(table.columnLength, equals(4)); } // delete the first column { unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: firstParagraphPath)), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); await tester.clickColumnMenuButton(0); await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete); await tester.cancelTableActionMenu(); // check the table is updated final table = editorState.getNodeAtPath([0])!; expect(table.rowLength, equals(3)); expect(table.columnLength, equals(3)); } }); testWidgets(''' 1. insert a simple table via + menu 2. enable header column 3. enable header row 4. set to page width 5. distribute columns evenly ''', (tester) async { await tester.launchInAnonymousMode(); await tester.createNewDocumentOnMobile('simple table'); final editorState = tester.editor.getCurrentEditorState(); // focus on the editor unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [0])), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); final firstParagraphPath = [0, 0, 0, 0]; // open the plus menu and select the table block { await tester.openPlusMenuAndClickButton( LocaleKeys.document_slashMenu_name_table.tr(), ); // check the block is inserted final table = editorState.getNodeAtPath([0])!; expect(table.type, equals(SimpleTableBlockKeys.type)); expect(table.rowLength, equals(2)); expect(table.columnLength, equals(2)); // focus on the first cell final selection = editorState.selection!; expect(selection.isCollapsed, isTrue); expect(selection.start.path, equals(firstParagraphPath)); } // enable header column { // click the column menu button await tester.clickColumnMenuButton(0); // enable header column await tester.tapButton( find.findTextInFlowyText( LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn .tr(), ), ); } // enable header row { // focus on the first cell unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: firstParagraphPath)), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // click the row menu button await tester.clickRowMenuButton(0); // enable header column await tester.tapButton( find.findTextInFlowyText( LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(), ), ); } // check the table is updated final table = editorState.getNodeAtPath([0])!; expect(table.type, equals(SimpleTableBlockKeys.type)); expect(table.isHeaderColumnEnabled, isTrue); expect(table.isHeaderRowEnabled, isTrue); // disable header column { // focus on the first cell unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: firstParagraphPath)), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // click the row menu button await tester.clickColumnMenuButton(0); final toggleButton = find.descendant( of: find.byType(SimpleTableHeaderActionButton), matching: find.byType(CupertinoSwitch), ); await tester.tapButton(toggleButton); } // enable header row { // focus on the first cell unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: firstParagraphPath)), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // click the row menu button await tester.clickRowMenuButton(0); // enable header column final toggleButton = find.descendant( of: find.byType(SimpleTableHeaderActionButton), matching: find.byType(CupertinoSwitch), ); await tester.tapButton(toggleButton); } // check the table is updated expect(table.isHeaderColumnEnabled, isFalse); expect(table.isHeaderRowEnabled, isFalse); // set to page width { final table = editorState.getNodeAtPath([0])!; final beforeWidth = table.width; // focus on the first cell unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: firstParagraphPath)), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // click the row menu button await tester.clickRowMenuButton(0); // enable header column await tester.tapButton( find.findTextInFlowyText( LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth .tr(), ), ); // check the table is updated expect(table.width, greaterThan(beforeWidth)); } // distribute columns evenly { final table = editorState.getNodeAtPath([0])!; final beforeWidth = table.width; // focus on the first cell unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: firstParagraphPath)), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // click the column menu button await tester.clickColumnMenuButton(0); // distribute columns evenly await tester.tapButton( find.findTextInFlowyText( LocaleKeys .document_plugins_simpleTable_moreActions_distributeColumnsWidth .tr(), ), ); // check the table is updated expect(table.width, equals(beforeWidth)); } }); testWidgets(''' 1. insert a simple table via + menu 2. bold 3. clear content ''', (tester) async { await tester.launchInAnonymousMode(); await tester.createNewDocumentOnMobile('simple table'); final editorState = tester.editor.getCurrentEditorState(); // focus on the editor unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [0])), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); final firstParagraphPath = [0, 0, 0, 0]; // open the plus menu and select the table block { await tester.openPlusMenuAndClickButton( LocaleKeys.document_slashMenu_name_table.tr(), ); // check the block is inserted final table = editorState.getNodeAtPath([0])!; expect(table.type, equals(SimpleTableBlockKeys.type)); expect(table.rowLength, equals(2)); expect(table.columnLength, equals(2)); // focus on the first cell final selection = editorState.selection!; expect(selection.isCollapsed, isTrue); expect(selection.start.path, equals(firstParagraphPath)); } await tester.ime.insertText('Hello'); // enable bold { // click the column menu button await tester.clickColumnMenuButton(0); // enable bold await tester.clickSimpleTableBoldContentAction(); await tester.cancelTableActionMenu(); // check the first cell is bold final paragraph = editorState.getNodeAtPath(firstParagraphPath)!; expect(paragraph.isInBoldColumn, isTrue); } // clear content { // focus on the first cell unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: firstParagraphPath)), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); // click the column menu button await tester.clickColumnMenuButton(0); final clearContents = find.findTextInFlowyText( LocaleKeys.document_plugins_simpleTable_moreActions_clearContents .tr(), ); // clear content final scrollable = find.descendant( of: find.byType(SimpleTableBottomSheet), matching: find.byType(Scrollable), ); await tester.scrollUntilVisible( clearContents, 100, scrollable: scrollable, ); await tester.tapButton(clearContents); await tester.cancelTableActionMenu(); // check the first cell is empty final paragraph = editorState.getNodeAtPath(firstParagraphPath)!; expect(paragraph.delta!, isEmpty); } }); testWidgets(''' 1. insert a simple table via + menu 2. insert a heading block in table cell ''', (tester) async { await tester.launchInAnonymousMode(); await tester.createNewDocumentOnMobile('simple table'); final editorState = tester.editor.getCurrentEditorState(); // focus on the editor unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [0])), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); final firstParagraphPath = [0, 0, 0, 0]; // open the plus menu and select the table block { await tester.openPlusMenuAndClickButton( LocaleKeys.document_slashMenu_name_table.tr(), ); // check the block is inserted final table = editorState.getNodeAtPath([0])!; expect(table.type, equals(SimpleTableBlockKeys.type)); expect(table.rowLength, equals(2)); expect(table.columnLength, equals(2)); // focus on the first cell final selection = editorState.selection!; expect(selection.isCollapsed, isTrue); expect(selection.start.path, equals(firstParagraphPath)); } // open the plus menu and select the heading block { await tester.openPlusMenuAndClickButton( LocaleKeys.editor_heading1.tr(), ); // check the heading block is inserted final heading = editorState.getNodeAtPath([0, 0, 0, 0])!; expect(heading.type, equals(HeadingBlockKeys.type)); expect(heading.level, equals(1)); } }); testWidgets(''' 1. insert a simple table via + menu 2. resize column ''', (tester) async { await tester.launchInAnonymousMode(); await tester.createNewDocumentOnMobile('simple table'); final editorState = tester.editor.getCurrentEditorState(); // focus on the editor unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [0])), reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(); final beforeWidth = editorState.getNodeAtPath([0, 0, 0])!.columnWidth; // find the first cell { final resizeHandle = find.byType(SimpleTableColumnResizeHandle).first; final offset = tester.getCenter(resizeHandle); final gesture = await tester.startGesture(offset, pointer: 7); await tester.pumpAndSettle(); await gesture.moveBy(const Offset(100, 0)); await tester.pumpAndSettle(); await gesture.up(); await tester.pumpAndSettle(); } // check the table is updated final afterWidth1 = editorState.getNodeAtPath([0, 0, 0])!.columnWidth; expect(afterWidth1, greaterThan(beforeWidth)); // resize back to the original width { final resizeHandle = find.byType(SimpleTableColumnResizeHandle).first; final offset = tester.getCenter(resizeHandle); final gesture = await tester.startGesture(offset, pointer: 7); await tester.pumpAndSettle(); await gesture.moveBy(const Offset(-100, 0)); await tester.pumpAndSettle(); await gesture.up(); await tester.pumpAndSettle(); } // check the table is updated final afterWidth2 = editorState.getNodeAtPath([0, 0, 0])!.columnWidth; expect(afterWidth2, equals(beforeWidth)); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/document/slash_menu_test.dart ================================================ import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart'; import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); const title = 'Test Slash Menu'; group('slash menu', () { testWidgets('show slash menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createPageAndShowSlashMenu(title); final menuWidget = find.byType(MobileSelectionMenuWidget); expect(menuWidget, findsOneWidget); final items = (menuWidget.evaluate().first.widget as MobileSelectionMenuWidget) .items; int i = 0; for (final item in items) { final localItem = mobileItems[i]; expect(item.name, localItem.name); i++; } }); testWidgets('search by slash menu', (tester) async { await tester.launchInAnonymousMode(); await tester.createPageAndShowSlashMenu(title); const searchText = 'Heading'; await tester.ime.insertText(searchText); final itemWidgets = find.byType(MobileSelectionMenuItemWidget); int number = 0; for (final item in mobileItems) { if (item is MobileSelectionMenuItem) { for (final childItem in item.children) { if (childItem.name .toLowerCase() .contains(searchText.toLowerCase())) { number++; } } } else { if (item.name.toLowerCase().contains(searchText.toLowerCase())) { number++; } } } expect(itemWidgets, findsNWidgets(number)); }); testWidgets('tap to show submenu', (tester) async { await tester.launchInAnonymousMode(); await tester.createNewDocumentOnMobile(title); await tester.editor.tapLineOfEditorAt(0); final listview = find.descendant( of: find.byType(MobileSelectionMenuWidget), matching: find.byType(ListView), ); for (final item in mobileItems) { if (item is! MobileSelectionMenuItem) continue; await tester.editor.showSlashMenu(); await tester.scrollUntilVisible( find.text(item.name), 50, scrollable: listview, duration: const Duration(milliseconds: 250), ); await tester.tap(find.text(item.name)); final childrenLength = ((listview.evaluate().first.widget as ListView) .childrenDelegate as SliverChildListDelegate) .children .length; expect(childrenLength, item.children.length); } }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/document/title_test.dart ================================================ import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('document title:', () { testWidgets('create a new page, the title should be empty', (tester) async { await tester.launchInAnonymousMode(); final createPageButton = find.byKey( BottomNavigationBarItemType.add.valueKey, ); await tester.tapButton(createPageButton); expect(find.byType(MobileDocumentScreen), findsOneWidget); final title = tester.editor.findDocumentTitle(''); expect(title, findsOneWidget); final textField = tester.widget(title); expect(textField.focusNode!.hasFocus, isTrue); // input new name and press done button const name = 'test document'; await tester.enterText(title, name); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); final newTitle = tester.editor.findDocumentTitle(name); expect(newTitle, findsOneWidget); expect(textField.controller!.text, name); // the document should get focus final editor = tester.widget( find.byType(AppFlowyEditorPage), ); expect( editor.editorState.selection, Selection.collapsed(Position(path: [0])), ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/document/toolbar_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_edit_link_widget.dart'; import 'package:appflowy/mobile/presentation/editor/mobile_editor_screen.dart'; import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); Future createNeaPage(WidgetTester tester) async { final createPageButton = find.byKey(BottomNavigationBarItemType.add.valueKey); await tester.tapButton(createPageButton); expect(find.byType(MobileDocumentScreen), findsOneWidget); final editor = find.byType(AppFlowyEditor); expect(editor, findsOneWidget); } const testLink = 'https://appflowy.io/'; group('links', () { testWidgets('insert links', (tester) async { await tester.launchInAnonymousMode(); await createNeaPage(tester); await tester.editor.tapLineOfEditorAt(0); final editorState = tester.editor.getCurrentEditorState(); /// insert two lines of text const strFirst = 'FirstLine', strSecond = 'SecondLine'; await tester.ime.insertText(strFirst); await editorState.insertNewLine(); await tester.ime.insertText(strSecond); final firstLine = find.text(strFirst, findRichText: true), secondLine = find.text(strSecond, findRichText: true); expect(firstLine, findsOneWidget); expect(secondLine, findsOneWidget); /// select the first line await tester.doubleTapAt(tester.getCenter(firstLine)); await tester.pumpAndSettle(); /// find link button and tap it final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); await tester.tapButton(linkButton); /// input the link final textFormField = find.byType(TextFormField); expect(textFormField, findsNWidgets(2)); final linkField = textFormField.last; await tester.enterText(linkField, testLink); await tester.pumpAndSettle(); await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_earth_m)); /// apply the link await tester.tapButton(find.text(LocaleKeys.button_done.tr())); /// do it again /// select the second line await tester.doubleTapAt(tester.getCenter(secondLine)); await tester.pumpAndSettle(); await tester.tapButton(linkButton); await tester.enterText(linkField, testLink); await tester.pumpAndSettle(); await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_earth_m)); await tester.tapButton(find.text(LocaleKeys.button_done.tr())); final firstNode = editorState.getNodeAtPath([0]); final secondNode = editorState.getNodeAtPath([1]); Map commonDeltaJson(String insert) => { 'insert': insert, 'attributes': {'href': testLink, 'is_page_link': false}, }; expect( firstNode?.delta?.toJson(), [commonDeltaJson(strFirst)], ); expect( secondNode?.delta?.toJson(), [commonDeltaJson(strSecond)], ); }); testWidgets('change a link', (tester) async { await tester.launchInAnonymousMode(); await createNeaPage(tester); await tester.editor.tapLineOfEditorAt(0); final editorState = tester.editor.getCurrentEditorState(); const testText = 'TestText'; await tester.ime.insertText(testText); final textFinder = find.text(testText, findRichText: true); /// select the first line await tester.doubleTapAt(tester.getCenter(textFinder)); await tester.pumpAndSettle(); /// find link button and tap it final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m); await tester.tapButton(linkButton); /// input the link final textFormField = find.byType(TextFormField); expect(textFormField, findsNWidgets(2)); final linkField = textFormField.last; await tester.enterText(linkField, testLink); await tester.pumpAndSettle(); await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_earth_m)); /// apply the link await tester.tapButton(find.text(LocaleKeys.button_done.tr())); /// show edit link menu await tester.longPress(textFinder); await tester.pumpAndSettle(); final linkEditMenu = find.byType(MobileBottomSheetEditLinkWidget); expect(linkEditMenu, findsOneWidget); /// remove the link await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m)); final node = editorState.getNodeAtPath([0]); expect( node?.delta?.toJson(), [ {'insert': testText}, ], ); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/home_page/create_new_page_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('create new page in home page:', () { testWidgets('create document', (tester) async { await tester.launchInAnonymousMode(); // tap the create page button final createPageButton = find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg.path == FlowySvgs.m_home_add_m.path, ); await tester.tapButton(createPageButton); await tester.pumpAndSettle(); expect(find.byType(MobileDocumentScreen), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/search/search_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/home.dart'; import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; import 'package:appflowy/mobile/presentation/search/mobile_search_ask_ai_entrance.dart'; import 'package:appflowy/mobile/presentation/search/mobile_search_page.dart'; import 'package:appflowy/mobile/presentation/search/mobile_search_result.dart'; import 'package:appflowy/mobile/presentation/search/mobile_search_textfield.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Search test', () { testWidgets('tap to search page', (tester) async { await tester.launchInAnonymousMode(); final searchButton = find.byFlowySvg(FlowySvgs.m_home_search_icon_m); await tester.tapButton(searchButton); ///check for UI element expect(find.byType(MobileSearchAskAiEntrance), findsNothing); expect(find.byType(MobileSearchRecentList), findsOneWidget); expect(find.byType(MobileSearchResultList), findsNothing); /// search for something final searchTextField = find.descendant( of: find.byType(MobileSearchTextfield), matching: find.byType(TextFormField), ); final query = '$gettingStarted searching'; await tester.enterText(searchTextField, query); await tester.pumpAndSettle(); expect(find.byType(MobileSearchRecentList), findsNothing); expect(find.byType(MobileSearchResultList), findsOneWidget); expect( find.text(LocaleKeys.search_noResultForSearching.tr()), findsOneWidget, ); /// clear text final clearButton = find.byFlowySvg(FlowySvgs.clear_s); await tester.tapButton(clearButton); expect(find.byType(MobileSearchRecentList), findsOneWidget); expect(find.byType(MobileSearchResultList), findsNothing); /// tap cancel button final cancelButton = find.text(LocaleKeys.button_cancel.tr()); expect(cancelButton, findsNothing); await tester.enterText(searchTextField, query); await tester.pumpAndSettle(); expect(cancelButton, findsOneWidget); await tester.tapButton(cancelButton); expect(cancelButton, findsNothing); }); }); testWidgets('tap to search page and back to home', (tester) async { await tester.launchInAnonymousMode(); /// go to search page final searchButton = find.byFlowySvg(FlowySvgs.m_home_search_icon_m); expect(find.byType(MobileSearchScreen), findsNothing); await tester.tapButton(searchButton); expect(find.byType(MobileSearchScreen), findsOneWidget); /// back to home page final backButton = find.byFlowySvg(FlowySvgs.search_page_arrow_left_m); expect(backButton, findsOneWidget); expect(find.byType(MobileHomeScreen), findsNothing); await tester.tapButton(backButton); expect(find.byType(MobileHomeScreen), findsOneWidget); /// go to notification page final notificationButton = find.byFlowySvg(FlowySvgs.m_home_notification_m); expect(find.byType(MobileNotificationsScreenV2), findsNothing); await tester.tapButton(notificationButton); expect(find.byType(MobileNotificationsScreenV2), findsOneWidget); /// go to search page await tester.tapButton(searchButton); expect(find.byType(MobileNotificationsScreenV2), findsNothing); expect(find.byType(MobileSearchScreen), findsOneWidget); await tester.tapButton(backButton); expect(find.byType(MobileNotificationsScreenV2), findsOneWidget); expect(find.byType(MobileSearchScreen), findsNothing); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/settings/default_text_direction_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/home/setting/settings_popup_menu.dart'; import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('Change default text direction', (tester) async { await tester.launchInAnonymousMode(); /// tap [Setting] button await tester.tapButton(find.byType(HomePageSettingsPopupMenu)); await tester .tapButton(find.text(LocaleKeys.settings_popupMenuItem_settings.tr())); /// tap [Default Text Direction] await tester.tapButton( find.text(LocaleKeys.settings_appearance_textDirection_label.tr()), ); /// there are 3 items: LTR-RTL-AUTO final bottomSheet = find.ancestor( of: find.byType(FlowyOptionTile), matching: find.byType(SafeArea), ); final items = find.descendant( of: bottomSheet, matching: find.byType(FlowyOptionTile), ); expect(items, findsNWidgets(3)); /// select [Auto] await tester.tapButton(items.last); expect( find.text(LocaleKeys.settings_appearance_textDirection_auto.tr()), findsOneWidget, ); /// go back home await tester.tapButton(find.byType(AppBarImmersiveBackButton)); /// create new page final createPageButton = find.byKey(BottomNavigationBarItemType.add.valueKey); await tester.tapButton(createPageButton); final editorState = tester.editor.getCurrentEditorState(); // focus on the editor await tester.editor.tapLineOfEditorAt(0); const testEnglish = 'English', testArabic = 'إنجليزي'; /// insert [testEnglish] await editorState.insertTextAtCurrentSelection(testEnglish); await tester.pumpAndSettle(); await editorState.insertNewLine(position: editorState.selection!.end); await tester.pumpAndSettle(); /// insert [testArabic] await editorState.insertTextAtCurrentSelection(testArabic); await tester.pumpAndSettle(); final testEnglishFinder = find.text(testEnglish, findRichText: true), testArabicFinder = find.text(testArabic, findRichText: true); final testEnglishRenderBox = testEnglishFinder.evaluate().first.renderObject as RenderBox, testArabicRenderBox = testArabicFinder.evaluate().first.renderObject as RenderBox; final englishPosition = testEnglishRenderBox.localToGlobal(Offset.zero), arabicPosition = testArabicRenderBox.localToGlobal(Offset.zero); expect(englishPosition.dx > arabicPosition.dx, true); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/settings/scale_factor_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/setting/settings_popup_menu.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('test for change scale factor', (tester) async { await tester.launchInAnonymousMode(); /// tap [Setting] button await tester.tapButton(find.byType(HomePageSettingsPopupMenu)); await tester .tapButton(find.text(LocaleKeys.settings_popupMenuItem_settings.tr())); /// tap [Font Scale Factor] await tester.tapButton( find.text(LocaleKeys.settings_appearance_fontScaleFactor.tr()), ); /// drag slider final slider = find.descendant( of: find.byType(FontSizeStepper), matching: find.byType(Slider), ); await tester.slideToValue(slider, 0.8); expect(appflowyScaleFactor, 0.8); await tester.slideToValue(slider, 0.9); expect(appflowyScaleFactor, 0.9); await tester.slideToValue(slider, 1.0); expect(appflowyScaleFactor, 1.0); await tester.slideToValue(slider, 1.1); expect(appflowyScaleFactor, 1.1); await tester.slideToValue(slider, 1.2); expect(appflowyScaleFactor, 1.2); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile/sign_in/anonymous_sign_in_test.dart ================================================ import 'package:appflowy/mobile/presentation/home/home.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('anonymous sign in on mobile:', () { testWidgets('anon user and then sign in', (tester) async { await tester.launchInAnonymousMode(); // expect to see the home page expect(find.byType(MobileHomeScreen), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/integration_test/mobile_runner_1.dart ================================================ import 'package:appflowy_backend/log.dart'; import 'package:integration_test/integration_test.dart'; import 'mobile/document/document_test_runner.dart' as document_test_runner; import 'mobile/home_page/create_new_page_test.dart' as create_new_page_test; import 'mobile/settings/default_text_direction_test.dart' as default_text_direction_test; import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test; Future main() async { Log.shared.disableLog = true; await runIntegration1OnMobile(); } Future runIntegration1OnMobile() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); anonymous_sign_in_test.main(); create_new_page_test.main(); document_test_runner.main(); default_text_direction_test.main(); } ================================================ FILE: frontend/appflowy_flutter/integration_test/runner.dart ================================================ import 'dart:io'; import 'desktop_runner_1.dart'; import 'desktop_runner_2.dart'; import 'desktop_runner_3.dart'; import 'desktop_runner_4.dart'; import 'desktop_runner_5.dart'; import 'desktop_runner_6.dart'; import 'desktop_runner_7.dart'; import 'desktop_runner_8.dart'; import 'desktop_runner_9.dart'; import 'mobile_runner_1.dart'; /// The main task runner for all integration tests in AppFlowy. /// /// Having a single entrypoint for integration tests is necessary due to an /// [issue caused by switching files with integration testing](https://github.com/flutter/flutter/issues/101031). /// If flutter/flutter#101031 is resolved, this file can be removed completely. /// Once removed, the integration_test.yaml must be updated to exclude this as /// as the test target. Future main() async { if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { await runIntegration1OnDesktop(); await runIntegration2OnDesktop(); await runIntegration3OnDesktop(); await runIntegration4OnDesktop(); await runIntegration5OnDesktop(); await runIntegration6OnDesktop(); await runIntegration7OnDesktop(); await runIntegration8OnDesktop(); await runIntegration9OnDesktop(); } else if (Platform.isIOS || Platform.isAndroid) { await runIntegration1OnMobile(); } else { throw Exception('Unsupported platform'); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/ai_test_op.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../test/util.dart'; import 'util.dart'; extension AppFlowyAITest on WidgetTester { Future selectAIWriter(AiWriterCommand command) async { await tapButton(find.byType(AiWriterToolbarActionList)); await tapButton(find.text(command.i18n)); await pumpAndSettle(); } Future selectModel(String modelName) async { await tapButton(find.byType(SelectModelMenu)); await tapButton(find.text(modelName)); await pumpAndSettle(); } Future enterTextInPromptTextField(String text) async { // Wait for the text field to be visible await pumpAndSettle(); // Find the ExtendedTextField widget final textField = find.descendant( of: find.byType(PromptInputTextField), matching: find.byType(TextField), ); expect(textField, findsOneWidget, reason: 'ExtendedTextField not found'); final widget = element(textField).widget as TextField; expect(widget.enabled, isTrue, reason: 'TextField is not enabled'); await tap(textField); testTextInput.enterText(text); await pumpAndSettle(const Duration(milliseconds: 300)); } ChatBloc getCurrentChatBloc() { return element(find.byType(ChatContentPage)).read(); } Future loadDefaultMessages(List messages) async { final chatBloc = getCurrentChatBloc(); chatBloc.add(ChatEvent.didLoadLatestMessages(messages)); await blocResponseFuture(); } Future sendUserMessage(Message message) async { final chatBloc = getCurrentChatBloc(); // using received message to simulate the user message chatBloc.add(ChatEvent.receiveMessage(message)); await blocResponseFuture(); } Future receiveAIMessage(Message message) async { final chatBloc = getCurrentChatBloc(); chatBloc.add(ChatEvent.receiveMessage(message)); await blocResponseFuture(); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/auth_operation.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; extension AppFlowyAuthTest on WidgetTester { Future tapGoogleLoginInButton({bool pumpAndSettle = true}) async { await tapButton( find.byKey(signInWithGoogleButtonKey), pumpAndSettle: pumpAndSettle, ); } /// Requires being on the SettingsPage.account of the SettingsDialog Future logout() async { final scrollable = find.findSettingsScrollable(); await scrollUntilVisible( find.byType(AccountSignInOutButton), 100, scrollable: scrollable, ); await tapButton(find.byType(AccountSignInOutButton)); expectToSeeText(LocaleKeys.button_yes.tr()); await tapButtonWithName(LocaleKeys.button_yes.tr()); } Future tapSignInAsGuest() async { await tapButton(find.byType(SignInAnonymousButtonV2)); } void expectToSeeGoogleLoginButton() { expect(find.byKey(signInWithGoogleButtonKey), findsOneWidget); } void assertSwitchValue(Finder finder, bool value) { final Switch switchWidget = widget(finder); final isSwitched = switchWidget.value; assert(isSwitched == value); } void assertToggleValue(Finder finder, bool value) { final Toggle switchWidget = widget(finder); final isSwitched = switchWidget.value; assert(isSwitched == value); } void assertAppFlowyCloudEnableSyncSwitchValue(bool value) { assertToggleValue( find.descendant( of: find.byType(AppFlowyCloudEnableSync), matching: find.byWidgetPredicate((widget) => widget is Toggle), ), value, ); } Future toggleEnableSync(Type syncButton) async { final finder = find.descendant( of: find.byType(syncButton), matching: find.byWidgetPredicate((widget) => widget is Toggle), ); await tapButton(finder); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/base.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:appflowy/ai/service/appflowy_ai_service.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/env/cloud_env_test.dart'; import 'package:appflowy/startup/entry_point.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/presentation/presentation.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:universal_platform/universal_platform.dart'; import 'mock/mock_ai.dart'; class FlowyTestContext { FlowyTestContext({required this.applicationDataDirectory}); final String applicationDataDirectory; } extension AppFlowyTestBase on WidgetTester { Future initializeAppFlowy({ // use to append after the application data directory String? pathExtension, // use to specify the application data directory, if not specified, a temporary directory will be used. String? dataDirectory, Size windowSize = const Size(1600, 1200), String? email, AuthenticatorType? cloudType, AIRepository Function()? aiRepositoryBuilder, }) async { if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { await binding.setSurfaceSize(windowSize); } //cloudType = AuthenticatorType.appflowyCloudDevelop; mockHotKeyManagerHandlers(); final applicationDataDirectory = dataDirectory ?? await mockApplicationDataStorage( pathExtension: pathExtension, ); await FlowyRunner.run( AppFlowyApplication(), IntegrationMode.integrationTest, rustEnvsBuilder: () => _buildRustEnvs(cloudType), didInitGetItCallback: () => _initializeCloudServices( cloudType: cloudType, email: email, aiRepositoryBuilder: aiRepositoryBuilder, ), ); await waitUntilSignInPageShow(); return FlowyTestContext( applicationDataDirectory: applicationDataDirectory, ); } Map _buildRustEnvs(AuthenticatorType? cloudType) { final rustEnvs = {}; if (cloudType != null) { switch (cloudType) { case AuthenticatorType.local: break; case AuthenticatorType.appflowyCloudSelfHost: case AuthenticatorType.appflowyCloudDevelop: rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com"; rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password"; break; default: throw Exception("Unsupported cloud type: $cloudType"); } } return rustEnvs; } Future _initializeCloudServices({ required AuthenticatorType? cloudType, String? email, AIRepository Function()? aiRepositoryBuilder, }) async { if (cloudType == null) return; switch (cloudType) { case AuthenticatorType.local: await useLocalServer(); break; case AuthenticatorType.appflowyCloudSelfHost: await _setupAppFlowyCloud( useLocal: false, email: email, aiRepositoryBuilder: aiRepositoryBuilder, ); break; case AuthenticatorType.appflowyCloudDevelop: await _setupAppFlowyCloud( useLocal: integrationMode().isDevelop, email: email, aiRepositoryBuilder: aiRepositoryBuilder, ); break; default: throw Exception("Unsupported cloud type: $cloudType"); } } Future _setupAppFlowyCloud({ required bool useLocal, String? email, AIRepository Function()? aiRepositoryBuilder, }) async { if (useLocal) { await useAppFlowyCloudDevelop("http://localhost"); } else { await useSelfHostedAppFlowyCloud(TestEnv.afCloudUrl); } getIt.unregister(); getIt.unregister(); getIt.registerFactory( () => AppFlowyCloudMockAuthService(email: email), ); getIt.registerFactory( aiRepositoryBuilder ?? () => MockAIRepository(), ); } void mockHotKeyManagerHandlers() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(const MethodChannel('hotkey_manager'), (MethodCall methodCall) async { if (methodCall.method == 'unregisterAll') { // do nothing } return; }); } Future waitUntilSignInPageShow() async { if (isAuthEnabled || UniversalPlatform.isMobile) { final finder = find.byType(SignInAnonymousButtonV2); await pumpUntilFound(finder, timeout: const Duration(seconds: 30)); expect(finder, findsOneWidget); } else { final finder = find.byType(GoButton); await pumpUntilFound(finder); expect(finder, findsOneWidget); } } Future waitForSeconds(int seconds) async { await Future.delayed(Duration(seconds: seconds), () {}); } Future pumpUntilFound( Finder finder, { Duration timeout = const Duration(seconds: 10), Duration pumpInterval = const Duration( milliseconds: 50, ), // Interval between pumps }) async { bool timerDone = false; final timer = Timer(timeout, () => timerDone = true); while (!timerDone) { await pump(pumpInterval); // Pump with an interval if (any(finder)) { break; } } timer.cancel(); } Future pumpUntilNotFound( Finder finder, { Duration timeout = const Duration(seconds: 10), Duration pumpInterval = const Duration( milliseconds: 50, ), // Interval between pumps }) async { bool timerDone = false; final timer = Timer(timeout, () => timerDone = true); while (!timerDone) { await pump(pumpInterval); // Pump with an interval if (!any(finder)) { break; } } timer.cancel(); } Future tapButton( Finder finder, { int buttons = kPrimaryButton, bool warnIfMissed = false, int milliseconds = 500, bool pumpAndSettle = true, }) async { await tap(finder, buttons: buttons, warnIfMissed: warnIfMissed); if (pumpAndSettle) { await this.pumpAndSettle( Duration(milliseconds: milliseconds), EnginePhase.sendSemanticsUpdate, const Duration(seconds: 15), ); } } Future tapDown( Finder finder, { int? pointer, int buttons = kPrimaryButton, PointerDeviceKind kind = PointerDeviceKind.touch, bool pumpAndSettle = true, int milliseconds = 500, }) async { final location = getCenter(finder); final TestGesture gesture = await startGesture( location, pointer: pointer, buttons: buttons, kind: kind, ); await gesture.cancel(); await gesture.down(location); await gesture.cancel(); if (pumpAndSettle) { await this.pumpAndSettle( Duration(milliseconds: milliseconds), EnginePhase.sendSemanticsUpdate, const Duration(seconds: 15), ); } } Future tapButtonWithName( String tr, { int milliseconds = 500, bool pumpAndSettle = true, }) async { Finder button = find.text(tr, findRichText: true, skipOffstage: false); if (button.evaluate().isEmpty) { button = find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == tr, ); } await tapButton( button, milliseconds: milliseconds, pumpAndSettle: pumpAndSettle, ); } Future doubleTapAt( Offset location, { int? pointer, int buttons = kPrimaryButton, int milliseconds = 500, }) async { await tapAt(location, pointer: pointer, buttons: buttons); await pump(kDoubleTapMinTime); await tapAt(location, pointer: pointer, buttons: buttons); await pumpAndSettle(Duration(milliseconds: milliseconds)); } Future wait(int milliseconds) async { await pumpAndSettle(Duration(milliseconds: milliseconds)); } Future slideToValue( Finder slider, double value, { double paddingOffset = 24.0, }) async { final sliderWidget = slider.evaluate().first.widget as Slider; final range = sliderWidget.max - sliderWidget.min; final initialRate = (value - sliderWidget.min) / range; final totalWidth = getSize(slider).width - (2 * paddingOffset); final zeroPoint = getTopLeft(slider) + Offset( paddingOffset + initialRate * totalWidth, getSize(slider).height / 2, ); final calculatedOffset = value * (totalWidth / 100); await dragFrom(zeroPoint, Offset(calculatedOffset, 0)); await pumpAndSettle(); } } extension AppFlowyFinderTestBase on CommonFinders { Finder findTextInFlowyText(String text) { return find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == text, ); } Finder findFlowyTooltip(String richMessage, {bool skipOffstage = true}) { return byWidgetPredicate( (widget) => widget is FlowyTooltip && widget.richMessage != null && widget.richMessage!.toPlainText().contains(richMessage), skipOffstage: skipOffstage, ); } } Future mockApplicationDataStorage({ // use to append after the application data directory String? pathExtension, }) async { final dir = await getTemporaryDirectory(); // Use a random uuid to avoid conflict. String path = p.join(dir.path, 'appflowy_integration_test', uuid()); if (pathExtension != null && pathExtension.isNotEmpty) { path = '$path/$pathExtension'; } final directory = Directory(path); if (!directory.existsSync()) { await directory.create(recursive: true); } MockApplicationDataStorage.initialPath = directory.path; return directory.path; } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/common_operations.dart ================================================ import 'dart:io'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart'; import 'package:appflowy/plugins/shared/share/share_button.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/presentation/screens/screens.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:universal_platform/universal_platform.dart'; import 'emoji.dart'; import 'util.dart'; extension CommonOperations on WidgetTester { /// Tap the GetStart button on the launch page. Future tapAnonymousSignInButton() async { // local version final goButton = find.byType(GoButton); if (goButton.evaluate().isNotEmpty) { await tapButton(goButton); } else { // cloud version final anonymousButton = find.byType(SignInAnonymousButtonV2); await tapButton(anonymousButton, warnIfMissed: true); } await pumpAndSettle(const Duration(milliseconds: 200)); } Future tapContinousAnotherWay() async { // local version await tapButtonWithName(LocaleKeys.signIn_continueAnotherWay.tr()); if (Platform.isWindows) { await pumpAndSettle(const Duration(milliseconds: 200)); } } /// Tap the + button on the home page. Future tapAddViewButton({ String name = gettingStarted, ViewLayoutPB layout = ViewLayoutPB.Document, }) async { await hoverOnPageName( name, onHover: () async { final addButton = find.byType(ViewAddButton); await tapButton(addButton); }, ); } /// Tap the 'New Page' Button on the sidebar. Future tapNewPageButton() async { final newPageButton = find.byType(SidebarNewPageButton); await tapButton(newPageButton); } /// Tap the import button. /// /// Must call [tapAddViewButton] first. Future tapImportButton() async { await tapButtonWithName(LocaleKeys.moreAction_import.tr()); } /// Tap the import from text & markdown button. /// /// Must call [tapImportButton] first. Future tapTextAndMarkdownButton() async { await tapButtonWithName(LocaleKeys.importPanel_textAndMarkdown.tr()); } /// Tap the LanguageSelectorOnWelcomePage widget on the launch page. Future tapLanguageSelectorOnWelcomePage() async { final languageSelector = find.byType(LanguageSelectorOnWelcomePage); await tapButton(languageSelector); } /// Tap languageItem on LanguageItemsListView. /// /// [scrollDelta] is the distance to scroll the ListView. /// Default value is 100 /// /// If it is positive -> scroll down. /// /// If it is negative -> scroll up. Future tapLanguageItem({ required String languageCode, String? countryCode, double? scrollDelta, }) async { final languageItemsListView = find.descendant( of: find.byType(ListView), matching: find.byType(Scrollable), ); final languageItem = find.byWidgetPredicate( (widget) => widget is LanguageItem && widget.locale.languageCode == languageCode && widget.locale.countryCode == countryCode, ); // scroll the ListView until zHCNLanguageItem shows on the screen. await scrollUntilVisible( languageItem, scrollDelta ?? 100, scrollable: languageItemsListView, // maxHeight of LanguageItemsListView maxScrolls: 400, ); try { await tapButton(languageItem); } on FlutterError catch (e) { Log.warn('tapLanguageItem error: $e'); } } /// Hover on the widget. Future hoverOnWidget( Finder finder, { Offset? offset, Future Function()? onHover, bool removePointer = true, }) async { try { final gesture = await createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(location: offset ?? getCenter(finder)); await pumpAndSettle(); await onHover?.call(); await gesture.removePointer(); } catch (err) { Log.error('hoverOnWidget error: $err'); } } /// Hover on the page name. Future hoverOnPageName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, Future Function()? onHover, bool useLast = true, }) async { final pageNames = findPageName(name, layout: layout); if (useLast) { await hoverOnWidget(pageNames.last, onHover: onHover); } else { await hoverOnWidget(pageNames.first, onHover: onHover); } } /// Right click on the page name. Future rightClickOnPageName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, }) async { final page = findPageName(name, layout: layout); await hoverOnPageName( name, onHover: () async { await tap(page, buttons: kSecondaryMouseButton); await pumpAndSettle(); }, ); } /// open the page with given name. Future openPage( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, }) async { final finder = findPageName(name, layout: layout); expect(finder, findsOneWidget); await tapButton(finder); } /// Tap the ... button beside the page name. /// /// Must call [hoverOnPageName] first. Future tapPageOptionButton() async { final optionButton = find.descendant( of: find.byType(ViewMoreActionPopover), matching: find.byFlowySvg(FlowySvgs.workspace_three_dots_s), ); await tapButton(optionButton); } /// Tap the delete page button. Future tapDeletePageButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.delete.name); } /// Tap the rename page button. Future tapRenamePageButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.rename.name); } /// Tap the favorite page button Future tapFavoritePageButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.favorite.name); } /// Tap the unfavorite page button Future tapUnfavoritePageButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.unFavorite.name); } /// Tap the Open in a new tab button Future tapOpenInTabButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.openInNewTab.name); } /// Rename the page. Future renamePage(String name) async { await tapRenamePageButton(); await enterText(find.byType(AFTextField), name); await tapButton(find.text(LocaleKeys.button_confirm.tr())); } Future tapTrashButton() async { await tap(find.byType(SidebarTrashButton)); } Future tapOKButton() async { final okButton = find.byWidgetPredicate( (widget) => widget is PrimaryTextButton && widget.label == LocaleKeys.button_ok.tr(), ); await tapButton(okButton); } /// Expand or collapse the page. Future expandOrCollapsePage({ required String pageName, required ViewLayoutPB layout, }) async { final page = findPageName(pageName, layout: layout); await hoverOnWidget(page); final expandButton = find.descendant( of: page, matching: find.byType(ViewItemDefaultLeftIcon), ); await tapButton(expandButton.first); } /// Tap the restore button. /// /// the restore button will show after the current page is deleted. Future tapRestoreButton() async { final restoreButton = find.textContaining( LocaleKeys.deletePagePrompt_restore.tr(), ); await tapButton(restoreButton); } /// Tap the delete permanently button. /// /// the delete permanently button will show after the current page is deleted. Future tapDeletePermanentlyButton() async { final deleteButton = find.textContaining( LocaleKeys.deletePagePrompt_deletePermanent.tr(), ); await tapButton(deleteButton); await tap(find.text(LocaleKeys.button_delete.tr())); await pumpAndSettle(); } /// Tap the share button above the document page. Future tapShareButton() async { final shareButton = find.byWidgetPredicate( (widget) => widget is ShareButton, ); await tapButton(shareButton); } // open the share menu and then click the publish tab Future openPublishMenu() async { await tapShareButton(); final publishButton = find.textContaining( LocaleKeys.shareAction_publishTab.tr(), ); await tapButton(publishButton); } /// Tap the export markdown button /// /// Must call [tapShareButton] first. Future tapMarkdownButton() async { final markdownButton = find.textContaining( LocaleKeys.shareAction_markdown.tr(), ); await tapButton(markdownButton); } Future createNewPageWithNameUnderParent({ String? name, ViewLayoutPB layout = ViewLayoutPB.Document, String? parentName, bool openAfterCreated = true, }) async { // create a new page await tapAddViewButton(name: parentName ?? gettingStarted, layout: layout); await tapButtonWithName(layout.menuName); final settingsOrFailure = await getIt().getWithFormat( KVKeys.showRenameDialogWhenCreatingNewFile, (value) => bool.parse(value), ); final showRenameDialog = settingsOrFailure ?? false; if (showRenameDialog) { await tapButton(find.text(LocaleKeys.button_confirm.tr())); } await pumpAndSettle(); // hover on it and change it's name if (name != null) { await hoverOnPageName( layout.defaultName, layout: layout, onHover: () async { await renamePage(name); await pumpAndSettle(); }, ); await pumpAndSettle(); } // open the page after created if (openAfterCreated) { await openPage( // if the name is null, use the default name name ?? layout.defaultName, layout: layout, ); await pumpAndSettle(); } } Future createOpenRenameDocumentUnderParent({ required String name, String? parentName, }) async { // create a new page await tapAddViewButton(name: parentName ?? gettingStarted); await tapButtonWithName(ViewLayoutPB.Document.menuName); final settingsOrFailure = await getIt().getWithFormat( KVKeys.showRenameDialogWhenCreatingNewFile, (value) => bool.parse(value), ); final showRenameDialog = settingsOrFailure ?? false; if (showRenameDialog) { await tapOKButton(); } await pumpAndSettle(); // open the page after created await openPage(ViewLayoutPB.Document.defaultName); await pumpAndSettle(); // Enter new name in the document title await enterText(find.byType(TextFieldWithMetricLines), name); await pumpAndSettle(); } /// Create a new page in the space Future createNewPageInSpace({ required String spaceName, required ViewLayoutPB layout, bool openAfterCreated = true, String? pageName, }) async { final currentSpace = find.byWidgetPredicate( (widget) => widget is CurrentSpace && widget.space.name == spaceName, ); if (currentSpace.evaluate().isEmpty) { throw Exception('Current space not found'); } await hoverOnWidget( currentSpace, onHover: () async { // click the + button await clickAddPageButtonInSpaceHeader(); await tapButtonWithName(layout.menuName); }, ); await pumpAndSettle(); if (pageName != null) { // move the cursor to other place to disable to tooltips await tapAt(Offset.zero); // hover on new created page and change it's name await hoverOnPageName( '', layout: layout, onHover: () async { await renamePage(pageName); await pumpAndSettle(); }, ); await pumpAndSettle(); } // open the page after created if (openAfterCreated) { // if the name is null, use empty string await openPage(pageName ?? '', layout: layout); await pumpAndSettle(); } } /// Click the + button in the space header Future clickAddPageButtonInSpaceHeader() async { final addPageButton = find.descendant( of: find.byType(SidebarSpaceHeader), matching: find.byType(ViewAddButton), ); await tapButton(addPageButton); } /// Click the + button in the space header Future clickSpaceHeader() async { await tapButton(find.byType(SidebarSpaceHeader)); } Future openSpace(String spaceName) async { final space = find.descendant( of: find.byType(SidebarSpaceMenuItem), matching: find.text(spaceName), ); await tapButton(space); } /// Create a new page on the top level Future createNewPage({ ViewLayoutPB layout = ViewLayoutPB.Document, bool openAfterCreated = true, }) async { await tapButton(find.byType(SidebarNewPageButton)); } Future simulateKeyEvent( LogicalKeyboardKey key, { bool isControlPressed = false, bool isShiftPressed = false, bool isAltPressed = false, bool isMetaPressed = false, PhysicalKeyboardKey? physicalKey, }) async { if (isControlPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.control); } if (isShiftPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.shift); } if (isAltPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.alt); } if (isMetaPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.meta); } await simulateKeyDownEvent( key, physicalKey: physicalKey, ); await simulateKeyUpEvent( key, physicalKey: physicalKey, ); if (isControlPressed) { await simulateKeyUpEvent(LogicalKeyboardKey.control); } if (isShiftPressed) { await simulateKeyUpEvent(LogicalKeyboardKey.shift); } if (isAltPressed) { await simulateKeyUpEvent(LogicalKeyboardKey.alt); } if (isMetaPressed) { await simulateKeyUpEvent(LogicalKeyboardKey.meta); } await pumpAndSettle(); } Future openAppInNewTab(String name, ViewLayoutPB layout) async { await hoverOnPageName( name, onHover: () async { await tapOpenInTabButton(); await pumpAndSettle(); }, ); await pumpAndSettle(); } Future favoriteViewByName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, }) async { await hoverOnPageName( name, layout: layout, onHover: () async { await tapFavoritePageButton(); await pumpAndSettle(); }, ); } Future unfavoriteViewByName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, }) async { await hoverOnPageName( name, layout: layout, onHover: () async { await tapUnfavoritePageButton(); await pumpAndSettle(); }, ); } Future movePageToOtherPage({ required String name, required String parentName, required ViewLayoutPB layout, required ViewLayoutPB parentLayout, DraggableHoverPosition position = DraggableHoverPosition.center, }) async { final from = findPageName(name, layout: layout); final to = findPageName(parentName, layout: parentLayout); final gesture = await startGesture(getCenter(from)); Offset offset = Offset.zero; switch (position) { case DraggableHoverPosition.center: offset = getCenter(to); break; case DraggableHoverPosition.top: offset = getTopLeft(to); break; case DraggableHoverPosition.bottom: offset = getBottomLeft(to); break; default: } await gesture.moveTo(offset, timeStamp: const Duration(milliseconds: 400)); await gesture.up(); await pumpAndSettle(); } Future reorderFavorite({ required String fromName, required String toName, }) async { final from = find.descendant( of: find.byType(FavoriteFolder), matching: find.text(fromName), ), to = find.descendant( of: find.byType(FavoriteFolder), matching: find.text(toName), ); final distanceY = getCenter(to).dy - getCenter(from).dx; await drag(from, Offset(0, distanceY)); await pumpAndSettle(const Duration(seconds: 1)); } // tap the button with [FlowySvgData] Future tapButtonWithFlowySvgData(FlowySvgData svg) async { final button = find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg.path == svg.path, ); await tapButton(button); } // update the page icon in the sidebar Future updatePageIconInSidebarByName({ required String name, String? parentName, required ViewLayoutPB layout, required EmojiIconData icon, }) async { final iconButton = find.descendant( of: findPageName( name, layout: layout, parentName: parentName, ), matching: find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()), ); await tapButton(iconButton); if (icon.type == FlowyIconType.emoji) { await tapEmoji(icon.emoji); } else if (icon.type == FlowyIconType.icon) { await tapIcon(icon); } await pumpAndSettle(); } // update the page icon in the sidebar Future updatePageIconInTitleBarByName({ required String name, required ViewLayoutPB layout, required EmojiIconData icon, }) async { await openPage( name, layout: layout, ); final title = find.descendant( of: find.byType(ViewTitleBar), matching: find.text(name), ); await tapButton(title); await tapButton(find.byType(EmojiPickerButton)); if (icon.type == FlowyIconType.emoji) { await tapEmoji(icon.emoji); } else if (icon.type == FlowyIconType.icon) { await tapIcon(icon); } else if (icon.type == FlowyIconType.custom) { await pickImage(icon); } await pumpAndSettle(); } Future updatePageIconInTitleBarByPasteALink({ required String name, required ViewLayoutPB layout, required String iconLink, }) async { await openPage( name, layout: layout, ); final title = find.descendant( of: find.byType(ViewTitleBar), matching: find.text(name), ); await tapButton(title); await tapButton(find.byType(EmojiPickerButton)); await pasteImageLinkAsIcon(iconLink); await pumpAndSettle(); } Future openNotificationHub({int tabIndex = 0}) async { final finder = find.descendant( of: find.byType(NotificationButton), matching: find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.clock_alarm_s, ), ); await tap(finder); await pumpAndSettle(); if (tabIndex == 1) { final tabFinder = find.descendant( of: find.byType(NotificationTabBar), matching: find.byType(FlowyTabItem).at(1), ); await tap(tabFinder); await pumpAndSettle(); } } Future toggleCommandPalette() async { // Press CMD+P or CTRL+P to open the command palette await simulateKeyEvent( LogicalKeyboardKey.keyP, isControlPressed: !Platform.isMacOS, isMetaPressed: Platform.isMacOS, ); await pumpAndSettle(); } Future openCollaborativeWorkspaceMenu() async { if (!FeatureFlag.collaborativeWorkspace.isOn) { throw UnsupportedError('Collaborative workspace is not enabled'); } final workspace = find.byType(SidebarWorkspace); expect(workspace, findsOneWidget); await tapButton(workspace, milliseconds: 5000); } Future createCollaborativeWorkspace(String name) async { if (!FeatureFlag.collaborativeWorkspace.isOn) { throw UnsupportedError('Collaborative workspace is not enabled'); } await openCollaborativeWorkspaceMenu(); // expect to see the workspace list, and there should be only one workspace final workspacesMenu = find.byType(WorkspacesMenu); expect(workspacesMenu, findsOneWidget); // click the create button final createButton = find.byKey(createWorkspaceButtonKey); expect(createButton, findsOneWidget); await tapButton(createButton); // input the workspace name final workspaceNameInput = find.descendant( of: find.byType(AFTextFieldDialog), matching: find.byType(TextField), ); await enterText(workspaceNameInput, name); await pumpAndSettle(); await tapButton( find.text(LocaleKeys.button_confirm.tr()), milliseconds: 2000, ); } // For mobile platform to launch the app in anonymous mode Future launchInAnonymousMode() async { assert( [TargetPlatform.android, TargetPlatform.iOS] .contains(defaultTargetPlatform), 'This method is only supported on mobile platforms', ); await initializeAppFlowy(); final anonymousSignInButton = find.byType(SignInAnonymousButtonV2); expect(anonymousSignInButton, findsOneWidget); await tapButton(anonymousSignInButton); await pumpUntilFound(find.byType(MobileHomeScreen)); } Future tapSvgButton(FlowySvgData svg) async { final button = find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg.path == svg.path, ); await tapButton(button); } Future openMoreViewActions() async { final button = find.byType(MoreViewActions); await tapButton(button); } /// Presses on the Duplicate ViewAction in the [MoreViewActions] popup. /// /// [openMoreViewActions] must be called beforehand! /// Future duplicateByMoreViewActions() async { final button = find.byWidgetPredicate( (widget) => widget is ViewAction && widget.type == ViewMoreActionType.duplicate, ); await tap(button); await pump(); } /// Presses on the Delete ViewAction in the [MoreViewActions] popup. /// /// [openMoreViewActions] must be called beforehand! /// Future deleteByMoreViewActions() async { final button = find.descendant( of: find.byType(ListView), matching: find.byWidgetPredicate( (widget) => widget is ViewAction && widget.type == ViewMoreActionType.delete, ), ); await tap(button); await pump(); } Future tapFileUploadHint() async { final finder = find.byWidgetPredicate( (w) => w is RichText && w.text.toPlainText().contains( LocaleKeys.document_plugins_file_fileUploadHint.tr(), ), ); await tap(finder); await pumpAndSettle(const Duration(seconds: 2)); } /// Create a new document on mobile Future createNewDocumentOnMobile(String name) async { final createPageButton = find.byKey( BottomNavigationBarItemType.add.valueKey, ); await tapButton(createPageButton); expect(find.byType(MobileDocumentScreen), findsOneWidget); final title = editor.findDocumentTitle(''); expect(title, findsOneWidget); final textField = widget(title); expect(textField.focusNode!.hasFocus, isTrue); // input new name and press done button await enterText(title, name); await testTextInput.receiveAction(TextInputAction.done); await pumpAndSettle(); final newTitle = editor.findDocumentTitle(name); expect(newTitle, findsOneWidget); expect(textField.controller!.text, name); } /// Open the plus menu Future openPlusMenuAndClickButton(String buttonName) async { assert( UniversalPlatform.isMobile, 'This method is only supported on mobile platforms', ); final plusMenuButton = find.byKey(addBlockToolbarItemKey); final addMenuItem = find.byType(AddBlockMenu); await tapButton(plusMenuButton); await pumpUntilFound(addMenuItem); final toggleHeading1 = find.byWidgetPredicate( (widget) => widget is TypeOptionMenuItem && widget.value.text == buttonName, ); final scrollable = find.ancestor( of: find.byType(TypeOptionGridView), matching: find.byType(Scrollable), ); await scrollUntilVisible( toggleHeading1, 100, scrollable: scrollable, ); await tapButton(toggleHeading1); await pumpUntilNotFound(addMenuItem); } /// Click the column menu button in the simple table Future clickColumnMenuButton(int index) async { final columnMenuButton = find.byWidgetPredicate( (w) => w is SimpleTableMobileReorderButton && w.index == index && w.type == SimpleTableMoreActionType.column, ); await tapButton(columnMenuButton); await pumpUntilFound(find.byType(SimpleTableCellBottomSheet)); } /// Click the row menu button in the simple table Future clickRowMenuButton(int index) async { final rowMenuButton = find.byWidgetPredicate( (w) => w is SimpleTableMobileReorderButton && w.index == index && w.type == SimpleTableMoreActionType.row, ); await tapButton(rowMenuButton); await pumpUntilFound(find.byType(SimpleTableCellBottomSheet)); } /// Click the SimpleTableQuickAction Future clickSimpleTableQuickAction(SimpleTableMoreAction action) async { final button = find.byWidgetPredicate( (widget) => widget is SimpleTableQuickAction && widget.type == action, ); await tapButton(button); } /// Click the SimpleTableContentAction Future clickSimpleTableBoldContentAction() async { final button = find.byType(SimpleTableContentBoldAction); await tapButton(button); } /// Cancel the table action menu Future cancelTableActionMenu() async { final finder = find.byType(SimpleTableCellBottomSheet); if (finder.evaluate().isEmpty) { return; } await tapAt(Offset.zero); await pumpUntilNotFound(finder); } /// load icon list and return the first one Future loadIcon() async { await loadIconGroups(); final groups = kIconGroups!; final firstGroup = groups.first; final firstIcon = firstGroup.icons.first; return EmojiIconData.icon( IconsData( firstGroup.name, firstIcon.name, builtInSpaceColors.first, ), ); } Future prepareImageIcon() async { final imagePath = await rootBundle.load('assets/test/images/sample.jpeg'); final tempDirectory = await getTemporaryDirectory(); final localImagePath = p.join(tempDirectory.path, 'sample.jpeg'); final imageFile = File(localImagePath) ..writeAsBytesSync(imagePath.buffer.asUint8List()); return EmojiIconData.custom(imageFile.path); } Future prepareSvgIcon() async { final imagePath = await rootBundle.load('assets/test/images/sample.svg'); final tempDirectory = await getTemporaryDirectory(); final localImagePath = p.join(tempDirectory.path, 'sample.svg'); final imageFile = File(localImagePath) ..writeAsBytesSync(imagePath.buffer.asUint8List()); return EmojiIconData.custom(imageFile.path); } /// create new page and show slash menu Future createPageAndShowSlashMenu(String title) async { await createNewDocumentOnMobile(title); await editor.tapLineOfEditorAt(0); await editor.showSlashMenu(); } /// create new page and show at menu Future createPageAndShowAtMenu(String title) async { await createNewDocumentOnMobile(title); await editor.tapLineOfEditorAt(0); await editor.showAtMenu(); } /// create new page and show plus menu Future createPageAndShowPlusMenu(String title) async { await createNewDocumentOnMobile(title); await editor.tapLineOfEditorAt(0); await editor.showPlusMenu(); } } extension SettingsFinder on CommonFinders { Finder findSettingsScrollable() => find .descendant( of: find .descendant( of: find.byType(SettingsBody), matching: find.byType(SingleChildScrollView), ) .first, matching: find.byType(Scrollable), ) .first; Finder findSettingsMenuScrollable() => find .descendant( of: find .descendant( of: find.byType(SettingsMenu), matching: find.byType(SingleChildScrollView), ) .first, matching: find.byType(Scrollable), ) .first; } extension FlowySvgFinder on CommonFinders { Finder byFlowySvg(FlowySvgData svg) => _FlowySvgFinder(svg); } class _FlowySvgFinder extends MatchFinder { _FlowySvgFinder(this.svg); final FlowySvgData svg; @override String get description => 'flowy_svg "$svg"'; @override bool matches(Element candidate) { final Widget widget = candidate.widget; return widget is FlowySvg && widget.svg == svg; } } extension ViewLayoutPBTest on ViewLayoutPB { String get menuName { switch (this) { case ViewLayoutPB.Grid: return LocaleKeys.grid_menuName.tr(); case ViewLayoutPB.Board: return LocaleKeys.board_menuName.tr(); case ViewLayoutPB.Document: return LocaleKeys.document_menuName.tr(); case ViewLayoutPB.Calendar: return LocaleKeys.calendar_menuName.tr(); case ViewLayoutPB.Chat: return LocaleKeys.chat_newChat.tr(); default: throw UnsupportedError('Unsupported layout: $this'); } } String get referencedMenuName { switch (this) { case ViewLayoutPB.Grid: return LocaleKeys.document_plugins_referencedGrid.tr(); case ViewLayoutPB.Board: return LocaleKeys.document_plugins_referencedBoard.tr(); case ViewLayoutPB.Calendar: return LocaleKeys.document_plugins_referencedCalendar.tr(); default: throw UnsupportedError('Unsupported layout: $this'); } } String get slashMenuName { switch (this) { case ViewLayoutPB.Grid: return LocaleKeys.document_slashMenu_name_grid.tr(); case ViewLayoutPB.Board: return LocaleKeys.document_slashMenu_name_kanban.tr(); case ViewLayoutPB.Document: return LocaleKeys.document_slashMenu_name_doc.tr(); case ViewLayoutPB.Calendar: return LocaleKeys.document_slashMenu_name_calendar.tr(); default: throw UnsupportedError('Unsupported layout: $this'); } } String get slashMenuLinkedName { switch (this) { case ViewLayoutPB.Grid: return LocaleKeys.document_slashMenu_name_linkedGrid.tr(); case ViewLayoutPB.Board: return LocaleKeys.document_slashMenu_name_linkedKanban.tr(); case ViewLayoutPB.Document: return LocaleKeys.document_slashMenu_name_linkedDoc.tr(); case ViewLayoutPB.Calendar: return LocaleKeys.document_slashMenu_name_linkedCalendar.tr(); default: throw UnsupportedError('Unsupported layout: $this'); } } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/constants.dart ================================================ class Constants { // this page name is default page name in the new workspace static const gettingStartedPageName = 'Getting started'; static const toDosPageName = 'To-dos'; static const generalSpaceName = 'General'; static const defaultWorkspaceName = 'My Workspace'; } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/data.dart ================================================ import 'dart:io'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:archive/archive_io.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; enum TestWorkspace { board("board"), emptyDocument("empty_document"), aiWorkSpace("ai_workspace"), coverImage("cover_image"); const TestWorkspace(this._name); final String _name; Future get zip async { final Directory parent = await TestWorkspace._parent; final File out = File(p.join(parent.path, '$_name.zip')); if (await out.exists()) return out; await out.create(); final ByteData data = await rootBundle.load(_asset); await out.writeAsBytes(data.buffer.asUint8List()); return out; } Future get root async { final Directory parent = await TestWorkspace._parent; return Directory(p.join(parent.path, _name)); } static Future get _parent async { final Directory root = await getTemporaryDirectory(); if (await root.exists()) return root; await root.create(); return root; } String get _asset => 'assets/test/workspaces/$_name.zip'; } class TestWorkspaceService { const TestWorkspaceService(this.workspace); final TestWorkspace workspace; /// Instructs the application to read workspace data from the workspace found under this [TestWorkspace]'s path. Future setUpAll() async { final root = await workspace.root; final path = root.path; SharedPreferences.setMockInitialValues({KVKeys.pathLocation: path}); } /// Workspaces that are checked into source are compressed. [TestWorkspaceService.setUp()] decompresses the file into an ephemeral directory that will be ignored by source control. Future setUp() async { final inputStream = InputFileStream(await workspace.zip.then((value) => value.path)); final archive = ZipDecoder().decodeBuffer(inputStream); await extractArchiveToDisk( archive, await TestWorkspace._parent.then((value) => value.path), ); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/database_test_op.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_day.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_card.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_editor.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/row/row.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/order_panel.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_editor.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_menu.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/number.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/url.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/date_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_text_field.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; import 'package:appflowy/plugins/database/widgets/field/field_editor.dart'; import 'package:appflowy/plugins/database/widgets/field/field_type_list.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/number.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/row_action.dart'; import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/plugins/database/widgets/row/row_document.dart'; import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; import 'package:appflowy/plugins/database/widgets/setting/database_layout_selector.dart'; import 'package:appflowy/plugins/database/widgets/setting/database_setting_action.dart'; import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; // Non-exported member of the table_calendar library import 'package:table_calendar/src/widgets/cell_content.dart'; import 'package:table_calendar/table_calendar.dart'; import 'base.dart'; import 'common_operations.dart'; import 'expectation.dart'; import 'mock/mock_file_picker.dart'; const v020GridFileName = "v020.afdb"; const v069GridFileName = "v069.afdb"; extension AppFlowyDatabaseTest on WidgetTester { Future openTestDatabase(String fileName) async { final context = await initializeAppFlowy(); await tapAnonymousSignInButton(); // expect to see a readme page expectToSeePageName(gettingStarted); await tapAddViewButton(); await tapImportButton(); // Don't use the p.join to build the path that used in loadString. It // is not working on windows. final str = await rootBundle .loadString("assets/test/workspaces/database/$fileName"); // Write the content to the file. final path = p.join( context.applicationDataDirectory, fileName, ); final pageName = p.basenameWithoutExtension(path); File(path).writeAsStringSync(str); // mock get files mockPickFilePaths( paths: [path], ); await tapDatabaseRawDataButton(); await openPage(pageName, layout: ViewLayoutPB.Grid); } Future hoverOnFirstRowOfGrid([Future Function()? onHover]) async { final findRow = find.byType(GridRow); expect(findRow, findsWidgets); final firstRow = findRow.first; await hoverOnWidget(firstRow, onHover: onHover); } Future editCell({ required int rowIndex, required FieldType fieldType, required String input, int cellIndex = 0, }) async { final cell = cellFinder(rowIndex, fieldType, cellIndex: cellIndex); expect(cell, findsOneWidget); await enterText(cell, input); await testTextInput.receiveAction(TextInputAction.done); await pumpAndSettle(); } Finder cellFinder(int rowIndex, FieldType fieldType, {int cellIndex = 0}) { final findRow = find.byType(GridRow, skipOffstage: false); final findCell = finderForFieldType(fieldType); return find .descendant( of: findRow.at(rowIndex), matching: findCell, skipOffstage: false, ) .at(cellIndex); } Future tapCheckboxCellInGrid({ required int rowIndex, }) async { final cell = cellFinder(rowIndex, FieldType.Checkbox); final button = find.descendant( of: cell, matching: find.byType(FlowyIconButton), ); expect(cell, findsOneWidget); await tapButton(button); } Future assertCheckboxCell({ required int rowIndex, required bool isSelected, }) async { final cell = cellFinder(rowIndex, FieldType.Checkbox); final finder = isSelected ? find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.check_filled_s, ) : find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.uncheck_s, ); expect( find.descendant( of: cell, matching: finder, ), findsOneWidget, ); } Future tapCellInGrid({ required int rowIndex, required FieldType fieldType, }) async { final cell = cellFinder(rowIndex, fieldType); expect(cell, findsOneWidget); await tapButton(cell); } /// The [fieldName] must be unique in the grid. void assertCellContent({ required int rowIndex, required FieldType fieldType, required String content, int cellIndex = 0, }) { final findCell = cellFinder(rowIndex, fieldType, cellIndex: cellIndex); final findContent = find.descendant( of: findCell, matching: find.text(content), skipOffstage: false, ); expect(findContent, findsOneWidget); } Future assertSingleSelectOption({ required int rowIndex, required String content, }) async { final findCell = cellFinder(rowIndex, FieldType.SingleSelect); if (content.isNotEmpty) { final finder = find.descendant( of: findCell, matching: find.byWidgetPredicate( (widget) => widget is SelectOptionTag && (widget.name == content || widget.option?.name == content), ), ); expect(finder, findsOneWidget); } } void assertMultiSelectOption({ required int rowIndex, required List contents, }) { final findCell = cellFinder(rowIndex, FieldType.MultiSelect); for (final content in contents) { if (content.isNotEmpty) { final finder = find.descendant( of: findCell, matching: find.byWidgetPredicate( (widget) => widget is SelectOptionTag && (widget.name == content || widget.option?.name == content), ), ); expect(finder, findsOneWidget); } } } /// null percent means no progress bar should be found void assertChecklistCellInGrid({ required int rowIndex, required double? percent, }) { final findCell = cellFinder(rowIndex, FieldType.Checklist); if (percent == null) { final finder = find.descendant( of: findCell, matching: find.byType(ChecklistProgressBar), ); expect(finder, findsNothing); } else { final finder = find.descendant( of: findCell, matching: find.byWidgetPredicate( (widget) => widget is ChecklistProgressBar && widget.percent == percent, ), ); expect(finder, findsOneWidget); } } Future selectDay({ required int content, }) async { final findCalendar = find.byType(TableCalendar); final findDay = find.text(content.toString()); final finder = find.descendant( of: findCalendar, matching: findDay, ); // if the day is very near the beginning or the end of the month, // it may overlap with the same day in the next or previous month, // respectively because it was spilling over. This will lead to 2 // widgets being found and thus cannot be tapped correctly. if (content < 15) { // e.g., Jan 2 instead of Feb 2 await tapButton(finder.first); } else { // e.g. Jun 28 instead of May 28 await tapButton(finder.last); } } Future toggleIncludeTime() async { final findDateEditor = find.byType(IncludeTimeButton); final findToggle = find.byType(Toggle); final finder = find.descendant( of: findDateEditor, matching: findToggle, ); await tapButton(finder); } Future selectReminderOption(ReminderOption option) async { await tapButton(find.byType(ReminderSelector)); final finder = find.descendant( of: find.byType(FlowyButton), matching: find.textContaining(option.label), ); await tapButton(finder); } Future selectLastDateInPicker() async { final finder = find.byType(CellContent).last; final w = widget(finder) as CellContent; await tapButton(finder); return w.isToday; } Future tapChangeDateTimeFormatButton() async { await tapButton(find.byType(DateTypeOptionButton)); } Future changeDateFormat() async { final findDateFormatButton = find.byType(DateFormatButton); await tapButton(findDateFormatButton); final findNewDateFormat = find.text("Day/Month/Year"); await tapButton(findNewDateFormat); } Future changeTimeFormat() async { final findDateFormatButton = find.byType(TimeFormatButton); await tapButton(findDateFormatButton); final findNewDateFormat = find.text("12 hour"); await tapButton(findNewDateFormat); } Future clearDate() async { final findDateEditor = find.byType(DateCellEditor); final findClearButton = find.byType(ClearDateButton); final finder = find.descendant( of: findDateEditor, matching: findClearButton, ); await tapButton(finder); } Future tapSelectOptionCellInGrid({ required int rowIndex, required FieldType fieldType, }) async { assert( fieldType == FieldType.SingleSelect || fieldType == FieldType.MultiSelect, ); final findRow = find.byType(GridRow); final findCell = finderForFieldType(fieldType); final cell = find.descendant( of: findRow.at(rowIndex), matching: findCell, ); await tapButton(cell); } /// The [SelectOptionCellEditor] must be opened first. Future createOption({required String name}) async { final findEditor = find.byType(SelectOptionCellEditor); expect(findEditor, findsOneWidget); final findTextField = find.byType(SelectOptionTextField); expect(findTextField, findsOneWidget); await enterText(findTextField, name); await pump(); await testTextInput.receiveAction(TextInputAction.done); await pumpAndSettle(); } Future selectOption({required String name}) async { final option = find.descendant( of: find.byType(SelectOptionCellEditor), matching: find.byWidgetPredicate( (widget) => widget is SelectOptionTagCell && widget.option.name == name, ), ); await tapButton(option); } void findSelectOptionWithNameInGrid({ required int rowIndex, required String name, }) { final findRow = find.byType(GridRow); final option = find.byWidgetPredicate( (widget) => widget is SelectOptionTag && (widget.name == name || widget.option?.name == name), ); final cell = find.descendant(of: findRow.at(rowIndex), matching: option); expect(cell, findsOneWidget); } void assertNumberOfSelectedOptionsInGrid({ required int rowIndex, required Matcher matcher, }) { final findRow = find.byType(GridRow); final options = find.byWidgetPredicate( (widget) => widget is SelectOptionTag, ); final cell = find.descendant(of: findRow.at(rowIndex), matching: options); expect(cell, matcher); } Future tapChecklistCellInGrid({required int rowIndex}) async { final findRow = find.byType(GridRow); final findCell = finderForFieldType(FieldType.Checklist); final cell = find.descendant(of: findRow.at(rowIndex), matching: findCell); await tapButton(cell); } void assertChecklistEditorVisible({required bool visible}) { final editor = find.byType(ChecklistCellEditor); if (visible) { return expect(editor, findsOneWidget); } expect(editor, findsNothing); } Future createNewChecklistTask({ required String name, enter = false, button = false, }) async { assert(!(enter && button)); final textField = find.descendant( of: find.byType(NewTaskItem), matching: find.byType(TextField), ); await enterText(textField, name); await pumpAndSettle(); if (enter) { await testTextInput.receiveAction(TextInputAction.done); await pumpAndSettle(const Duration(milliseconds: 500)); } else { await tapButton( find.descendant( of: find.byType(NewTaskItem), matching: find.byType(FlowyTextButton), ), ); } } void assertChecklistTaskInEditor({ required int index, required String name, required bool isChecked, }) { final task = find.byType(ChecklistItem).at(index); final widget = this.widget(task); assert( widget.task.data.name == name && widget.task.isSelected == isChecked, ); } Future renameChecklistTask({ required int index, required String name, bool enter = true, }) async { final textField = find .descendant( of: find.byType(ChecklistItem), matching: find.byType(TextField), ) .at(index); await enterText(textField, name); if (enter) { await testTextInput.receiveAction(TextInputAction.done); } await pumpAndSettle(); } Future checkChecklistTask({required int index}) async { final button = find.descendant( of: find.byType(ChecklistItem).at(index), matching: find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.uncheck_s, ), ); await tapButton(button); } Future deleteChecklistTask({required int index}) async { final task = find.byType(ChecklistItem).at(index); await hoverOnWidget( task, onHover: () async { final button = find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s, ); await tapButton(button); }, ); } void assertPhantomChecklistItemAtIndex({required int index}) { final paddings = find.descendant( of: find.descendant( of: find.byType(ChecklistRowDetailCell), matching: find.byType(ReorderableListView), ), matching: find.byWidgetPredicate( (widget) => widget is Padding && (widget.child is ChecklistItem || widget.child is PhantomChecklistItem), ), ); final phantom = widget(paddings.at(index)).child!; expect(phantom is PhantomChecklistItem, true); } void assertPhantomChecklistItemContent(String content) { final phantom = find.byType(PhantomChecklistItem); final text = find.text(content); expect(find.descendant(of: phantom, matching: text), findsOneWidget); } Future openFirstRowDetailPage() async { await hoverOnFirstRowOfGrid(); final expandButton = find.byType(PrimaryCellAccessory); expect(expandButton, findsOneWidget); await tapButton(expandButton); } void assertRowDetailPageOpened() async { final findRowDetailPage = find.byType(RowDetailPage); expect(findRowDetailPage, findsOneWidget); } Future dismissRowDetailPage() async { // use tap empty area instead of clicking ESC to dismiss the row detail page // sometimes, the ESC key is not working. await simulateKeyEvent(LogicalKeyboardKey.escape); await pumpAndSettle(); final findRowDetailPage = find.byType(RowDetailPage); if (findRowDetailPage.evaluate().isNotEmpty) { await tapAt(const Offset(0, 0)); await pumpAndSettle(); } } Future hoverRowBanner() async { final banner = find.byType(RowBanner); expect(banner, findsOneWidget); await startGesture( getCenter(banner) + const Offset(0, -10), kind: PointerDeviceKind.mouse, ); await pumpAndSettle(); } /// Used to open the add cover popover, by pressing on "Add cover"-button. /// /// Should call [hoverRowBanner] first. /// Future tapAddCoverButton() async { await tapButtonWithName( LocaleKeys.document_plugins_cover_addCover.tr(), ); } Future openEmojiPicker() async => tapButton(find.text(LocaleKeys.document_plugins_cover_addIcon.tr())); Future tapDateCellInRowDetailPage() async { final findDateCell = find.byType(EditableDateCell); await tapButton(findDateCell); } Future tapGridFieldWithNameInRowDetailPage(String name) async { final fields = find.byWidgetPredicate( (widget) => widget is FieldCellButton && widget.field.name == name, ); final field = find.descendant( of: find.byType(RowDetailPage), matching: fields, ); await tapButton(field); await pumpAndSettle(); } Future hoverOnFieldInRowDetail({required int index}) async { final fieldButtons = find.byType(FieldCellButton); final button = find .descendant(of: find.byType(RowDetailPage), matching: fieldButtons) .at(index); return startGesture(getCenter(button), kind: PointerDeviceKind.mouse); } Future reorderFieldInRowDetail({required double offset}) async { final thumb = find .byWidgetPredicate( (widget) => widget is ReorderableDragStartListener && widget.enabled, ) .first; await drag(thumb, Offset(0, offset), kind: PointerDeviceKind.mouse); await pumpAndSettle(); } void assertToggleShowHiddenFieldsVisibility(bool shown) { final button = find.byType(ToggleHiddenFieldsVisibilityButton); if (shown) { expect(button, findsOneWidget); } else { expect(button, findsNothing); } } Future toggleShowHiddenFields() async { final button = find.byType(ToggleHiddenFieldsVisibilityButton); await tapButton(button); } Future tapDeletePropertyInFieldEditor() async { final deleteButton = find.byWidgetPredicate( (w) => w is FieldActionCell && w.action == FieldAction.delete, ); await tapButton(deleteButton); await tapButtonWithName(LocaleKeys.space_delete.tr()); } Future scrollRowDetailByOffset(Offset offset) async { await drag(find.byType(RowDetailPage), offset); await pumpAndSettle(); } Future scrollToRight(Finder find) async { final size = getSize(find); await drag(find, Offset(-size.width, 0), warnIfMissed: false); await pumpAndSettle(const Duration(milliseconds: 500)); } Future tapNewPropertyButton() async { await tapButtonWithName(LocaleKeys.grid_field_newProperty.tr()); await pumpAndSettle(); } Future tapGridFieldWithName(String name) async { final field = find.byWidgetPredicate( (widget) => widget is FieldCellButton && widget.field.name == name, ); await tapButton(field); await pumpAndSettle(); } Future changeFieldTypeOfFieldWithName( String name, FieldType type, { ViewLayoutPB layout = ViewLayoutPB.Grid, }) async { await tapGridFieldWithName(name); if (layout == ViewLayoutPB.Grid) { await tapEditFieldButton(); } await tapSwitchFieldTypeButton(); await selectFieldType(type); await dismissFieldEditor(); } Future changeFieldIcon(String icon) async { await tapButton(find.byType(FieldEditIconButton)); if (icon.isEmpty) { final button = find.descendant( of: find.byType(FlowyIconEmojiPicker), matching: find.text( LocaleKeys.button_remove.tr(), ), ); await tapButton(button); } else { final svgContent = kIconGroups?.findSvgContent(icon); await tapButton( find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svgString == svgContent, ), ); } } void assertFieldSvg(String name, FieldType fieldType) { final svgFinder = find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == fieldType.svgData, ); final fieldButton = find.byWidgetPredicate( (widget) => widget is FieldCellButton && widget.field.name == name, ); expect( find.descendant(of: fieldButton, matching: svgFinder), findsOneWidget, ); } void assertFieldCustomSvg(String name, String svg) { final svgContent = kIconGroups?.findSvgContent(svg); final svgFinder = find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svgString == svgContent, ); final fieldButton = find.byWidgetPredicate( (widget) => widget is FieldCellButton && widget.field.name == name, ); expect( find.descendant(of: fieldButton, matching: svgFinder), findsOneWidget, ); } Future changeCalculateAtIndex(int index, CalculationType type) async { await tap(find.byType(CalculateCell).at(index)); await pumpAndSettle(); await tap( find.descendant( of: find.byType(CalculationTypeItem), matching: find.text(type.label), ), ); await pumpAndSettle(); } /// Should call [tapGridFieldWithName] first. Future tapEditFieldButton() async { await tapButtonWithName(LocaleKeys.grid_field_editProperty.tr()); await pumpAndSettle(const Duration(milliseconds: 200)); } /// Should call [tapGridFieldWithName] first. Future tapDeletePropertyButton() async { final field = find.byWidgetPredicate( (w) => w is FieldActionCell && w.action == FieldAction.delete, ); await tapButton(field); } /// A SimpleDialog must be shown first, e.g. when deleting a field. Future tapDialogOkButton() async { final field = find.byWidgetPredicate( (w) => w is PrimaryTextButton && w.label == LocaleKeys.button_ok.tr(), ); await tapButton(field); } /// Should call [tapGridFieldWithName] first. Future tapDuplicatePropertyButton() async { final field = find.byWidgetPredicate( (w) => w is FieldActionCell && w.action == FieldAction.duplicate, ); await tapButton(field); } Future tapInsertFieldButton({ required bool left, required String name, }) async { final field = find.byWidgetPredicate( (widget) => widget is FieldActionCell && (left && widget.action == FieldAction.insertLeft || !left && widget.action == FieldAction.insertRight), ); await tapButton(field); await renameField(name); } /// Should call [tapGridFieldWithName] first. Future tapHidePropertyButton() async { final field = find.byWidgetPredicate( (w) => w is FieldActionCell && w.action == FieldAction.toggleVisibility, ); await tapButton(field); } Future tapHidePropertyButtonInFieldEditor() async { final button = find.byWidgetPredicate( (w) => w is FieldActionCell && w.action == FieldAction.toggleVisibility, ); await tapButton(button); } Future tapClearCellsButton() async { final field = find.byWidgetPredicate( (widget) => widget is FieldActionCell && widget.action == FieldAction.clearData, ); await tapButton(field); } Future tapRowDetailPageRowActionButton() async => tapButton(find.byType(RowActionButton)); Future tapRowDetailPageCreatePropertyButton() async => tapButton(find.byType(CreateRowFieldButton)); Future tapRowDetailPageDeleteRowButton() async => tapButton(find.byType(RowDetailPageDeleteButton)); Future tapRowDetailPageDuplicateRowButton() async => tapButton(find.byType(RowDetailPageDuplicateButton)); Future tapSwitchFieldTypeButton() async => tapButton(find.byType(SwitchFieldButton)); Future tapEscButton() async => sendKeyEvent(LogicalKeyboardKey.escape); /// Must call [tapSwitchFieldTypeButton] first. Future selectFieldType(FieldType fieldType) async { final fieldTypeCell = find.byType(FieldTypeCell); final fieldTypeButton = find.descendant( of: fieldTypeCell, matching: find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == fieldType.i18n, ), ); await tapButton(fieldTypeButton); } // Use in edit mode of FieldEditor void expectEmptyTypeOptionEditor() => expect( find.descendant( of: find.byType(FieldTypeOptionEditor), matching: find.byType(TypeOptionSeparator), ), findsNothing, ); /// Each field has its own cell, so we can find the corresponding cell by /// the field type after create a new field. void findCellByFieldType(FieldType fieldType) { final finder = finderForFieldType(fieldType); expect(finder, findsWidgets); } void assertNumberOfRowsInGridPage(int num) { expect( find.byType(GridRow, skipOffstage: false), findsNWidgets(num), ); } Future assertDocumentExistInRowDetailPage() async { expect(find.byType(RowDocument), findsOneWidget); } /// Check the field type of the [FieldCellButton] is the same as the name. Future assertFieldTypeWithFieldName(String name, FieldType type) async { final field = find.byWidgetPredicate( (widget) => widget is FieldCellButton && widget.field.fieldType == type && widget.field.name == name, ); expect(field, findsOneWidget); } void assertFirstFieldInRowDetailByType(FieldType fieldType) { final firstField = find .descendant( of: find.byType(RowDetailPage), matching: find.byType(FieldCellButton), ) .first; final widget = this.widget(firstField); expect(widget.field.fieldType, fieldType); } void findFieldWithName(String name) { final field = find.byWidgetPredicate( (widget) => widget is FieldCellButton && widget.field.name == name, ); expect(field, findsOneWidget); } void noFieldWithName(String name) { final field = find.byWidgetPredicate( (widget) => widget is FieldCellButton && widget.field.name == name, ); expect(field, findsNothing); } Future renameField(String newName) async { final textField = find.byType(FieldNameTextField); expect(textField, findsOneWidget); await enterText(textField, newName); await pumpAndSettle(); } Future dismissFieldEditor() async { await sendKeyEvent(LogicalKeyboardKey.escape); await pumpAndSettle(const Duration(milliseconds: 200)); } Future changeFieldWidth(String fieldName, double width) async { final field = find.byWidgetPredicate( (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName, ); await hoverOnWidget( field, onHover: () async { final dragHandle = find.descendant( of: field, matching: find.byType(DragToExpandLine), ); await drag(dragHandle, Offset(width - getSize(field).width, 0)); await pumpAndSettle(const Duration(milliseconds: 200)); }, ); } double getFieldWidth(String fieldName) { final field = find.byWidgetPredicate( (widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName, ); return getSize(field).width; } Future findDateEditor(dynamic matcher) async { final finder = find.byType(DateCellEditor); expect(finder, matcher); } Future findMediaCellEditor(dynamic matcher) async { final finder = find.byType(MediaCellEditor); expect(finder, matcher); } Future findSelectOptionEditor(dynamic matcher) async { final finder = find.byType(SelectOptionCellEditor); expect(finder, matcher); } Future dismissCellEditor() async { await sendKeyEvent(LogicalKeyboardKey.escape); await pumpAndSettle(); } Future tapCreateRowButtonInGrid() async { await tapButton(find.byType(GridAddRowButton)); } Future tapCreateRowButtonAfterHoveringOnGridRow() async { await tapButton(find.byType(InsertRowButton)); } Future tapRowMenuButtonInGrid() async { await tapButton(find.byType(RowMenuButton)); } /// Should call [tapRowMenuButtonInGrid] first. Future tapCreateRowAboveButtonInRowMenu() async { await tapButtonWithName(LocaleKeys.grid_row_insertRecordAbove.tr()); } /// Should call [tapRowMenuButtonInGrid] first. Future tapDeleteOnRowMenu() async { await tapButtonWithName(LocaleKeys.grid_row_delete.tr()); } Future reorderRow( String from, String to, ) async { final fromRow = find.byWidgetPredicate( (widget) => widget is GridRow && widget.rowId == from, ); final toRow = find.byWidgetPredicate( (widget) => widget is GridRow && widget.rowId == to, ); await hoverOnWidget( fromRow, onHover: () async { final dragElement = find.descendant( of: fromRow, matching: find.byType(ReorderableDragStartListener), ); await timedDrag( dragElement, getCenter(toRow) - getCenter(fromRow), const Duration(milliseconds: 200), ); await pumpAndSettle(); }, ); } Future createField( FieldType fieldType, { String? name, ViewLayoutPB layout = ViewLayoutPB.Grid, }) async { if (layout == ViewLayoutPB.Grid) { await scrollToRight(find.byType(GridPage)); } await tapNewPropertyButton(); if (name != null) { await renameField(name); } await tapSwitchFieldTypeButton(); await selectFieldType(fieldType); } Future tapDatabaseSettingButton() async { await tapButton(find.byType(SettingButton)); } Future tapDatabaseFilterButton() async { await tapButton(find.byType(FilterButton)); } Future tapDatabaseSortButton() async { await tapButton(find.byType(SortButton)); } Future tapCreateFilterByFieldType(FieldType type, String title) async { final findFilter = find.byWidgetPredicate( (widget) => widget is FilterableFieldButton && widget.fieldInfo.fieldType == type && widget.fieldInfo.name == title, ); await tapButton(findFilter); } Future tapFilterButtonInGrid(String name) async { final button = find.byWidgetPredicate( (widget) => widget is ChoiceChipButton && widget.fieldInfo.name == name, ); await tapButton(button); } Future tapCreateSortByFieldType(FieldType type, String title) async { final findSort = find.byWidgetPredicate( (widget) => widget is GridSortPropertyCell && widget.fieldInfo.fieldType == type && widget.fieldInfo.name == title, ); await tapButton(findSort); } // Must call [tapSortMenuInSettingBar] first. Future tapCreateSortByFieldTypeInSortMenu( FieldType fieldType, String title, ) async { await tapButton(find.byType(DatabaseAddSortButton)); final findSort = find.byWidgetPredicate( (widget) => widget is GridSortPropertyCell && widget.fieldInfo.fieldType == fieldType && widget.fieldInfo.name == title, ); await tapButton(findSort); await pumpAndSettle(); } Future tapSortMenuInSettingBar() async { await tapButton(find.byType(SortMenu)); await pumpAndSettle(); } /// Must call [tapSortMenuInSettingBar] first. Future tapEditSortConditionButtonByFieldName(String name) async { final sortItem = find.descendant( of: find.ancestor( of: find.text(name), matching: find.byType(DatabaseSortItem), ), matching: find.byType(SortConditionButton), ); await tapButton(sortItem); } /// Must call [tapSortMenuInSettingBar] first. Future reorderSort( (FieldType, String) from, (FieldType, String) to, ) async { final fromSortItem = find.ancestor( of: find.text(from.$2), matching: find.byType(DatabaseSortItem), ); final toSortItem = find.ancestor( of: find.text(to.$2), matching: find.byType(DatabaseSortItem), ); // final fromSortItem = find.byWidgetPredicate( // (widget) => // widget is DatabaseSortItem && // widget.sort.fieldInfo.fieldType == from.$1 && // widget.sort.fieldInfo.name == from.$2, // ); // final toSortItem = find.byWidgetPredicate( // (widget) => // widget is DatabaseSortItem && // widget.sort.fieldInfo.fieldType == to.$1 && // widget.sort.fieldInfo.name == to.$2, // ); final dragElement = find.descendant( of: fromSortItem, matching: find.byType(ReorderableDragStartListener), ); await drag(dragElement, getCenter(toSortItem) - getCenter(fromSortItem)); await pumpAndSettle(const Duration(milliseconds: 200)); } /// Must call [tapEditSortConditionButtonByFieldName] first. Future tapSortByDescending() async { await tapButton( find.byWidgetPredicate( (widget) => widget is OrderPanelItem && widget.condition == SortConditionPB.Descending, ), ); await sendKeyEvent(LogicalKeyboardKey.escape); await pumpAndSettle(); } /// Must call [tapSortMenuInSettingBar] first. Future tapDeleteAllSortsButton() async { await tapButton(find.byType(DeleteAllSortsButton)); } Future scrollOptionFilterListByOffset(Offset offset) async { await drag(find.byType(SelectOptionFilterEditor), offset); await pumpAndSettle(); } Future enterTextInTextFilter(String text) async { final findEditor = find.byType(TextFilterEditor); final findTextField = find.descendant( of: findEditor, matching: find.byType(FlowyTextField), ); await enterText(findTextField, text); await pumpAndSettle(const Duration(milliseconds: 300)); } Future tapDisclosureButtonInFinder(Finder finder) async { final findDisclosure = find.descendant( of: finder, matching: find.byType(DisclosureButton), ); await tapButton(findDisclosure); } /// must call [tapDisclosureButtonInFinder] first. Future tapDeleteFilterButtonInGrid() async { await tapButton(find.text(LocaleKeys.grid_settings_deleteFilter.tr())); } Future tapCheckboxFilterButtonInGrid() async { await tapButton(find.byType(CheckboxFilterConditionList)); } Future tapChecklistFilterButtonInGrid() async { await tapButton(find.byType(ChecklistFilterConditionList)); } /// The [SelectOptionFilterList] must show up first. Future tapOptionFilterWithName(String name) async { final findCell = find.descendant( of: find.byType(SelectOptionFilterList), matching: find.byWidgetPredicate( (widget) => widget is SelectOptionFilterCell && widget.option.name == name, skipOffstage: false, ), skipOffstage: false, ); expect(findCell, findsOneWidget); await tapButton(findCell); } Future tapUnCheckedButtonOnCheckboxFilter() async { final button = find.descendant( of: find.byType(HoverButton), matching: find.text(LocaleKeys.grid_checkboxFilter_isUnchecked.tr()), ); await tapButton(button); } Future tapCompletedButtonOnChecklistFilter() async { final button = find.descendant( of: find.byType(HoverButton), matching: find.text(LocaleKeys.grid_checklistFilter_isComplete.tr()), ); await tapButton(button); } Future changeTextFilterCondition( TextFilterConditionPB condition, ) async { await tapButton(find.byType(TextFilterConditionList)); final button = find.descendant( of: find.byType(HoverButton), matching: find.text( condition.filterName, ), ); await tapButton(button); } Future changeSelectFilterCondition( SelectOptionFilterConditionPB condition, ) async { await tapButton(find.byType(SelectOptionFilterConditionList)); final button = find.descendant( of: find.byType(HoverButton), matching: find.text(condition.i18n), ); await tapButton(button); } Future changeDateFilterCondition( DateTimeFilterCondition condition, ) async { await tapButton(find.byType(DateFilterConditionList)); final button = find.descendant( of: find.byType(HoverButton), matching: find.text(condition.filterName), ); await tapButton(button); } /// Should call [tapDatabaseSettingButton] first. Future tapViewPropertiesButton() async { final findSettingItem = find.byType(DatabaseSettingsList); final findLayoutButton = find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == DatabaseSettingAction.showProperties.title(), ); final button = find.descendant( of: findSettingItem, matching: findLayoutButton, ); await tapButton(button); } /// Should call [tapDatabaseSettingButton] first. Future tapDatabaseLayoutButton() async { final findSettingItem = find.byType(DatabaseSettingsList); final findLayoutButton = find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == DatabaseSettingAction.showLayout.title(), ); final button = find.descendant( of: findSettingItem, matching: findLayoutButton, ); await tapButton(button); } Future tapCalendarLayoutSettingButton() async { final findSettingItem = find.byType(DatabaseSettingsList); final findLayoutButton = find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == DatabaseSettingAction.showCalendarLayout.title(), ); final button = find.descendant( of: findSettingItem, matching: findLayoutButton, ); await tapButton(button); } Future tapFirstDayOfWeek() async => tapButton(find.byType(FirstDayOfWeek)); Future tapFirstDayOfWeekStartFromMonday() async { final finder = find.byWidgetPredicate( (widget) => widget is StartFromButton && widget.dayIndex == 1, ); await tapButton(finder); // Dismiss the popover overlay in cause of obscure the tapButton // in the next test case. await sendKeyEvent(LogicalKeyboardKey.escape); await pumpAndSettle(const Duration(milliseconds: 200)); } void assertFirstDayOfWeekStartFromMonday() { final finder = find.byWidgetPredicate( (w) => w is StartFromButton && w.dayIndex == 1 && w.isSelected == true, ); expect(finder, findsOneWidget); } void assertFirstDayOfWeekStartFromSunday() { final finder = find.byWidgetPredicate( (w) => w is StartFromButton && w.dayIndex == 0 && w.isSelected == true, ); expect(finder, findsOneWidget); } Future scrollToToday() async { final todayCell = find.byWidgetPredicate( (widget) => widget is CalendarDayCard && widget.isToday, ); final scrollable = find .descendant( of: find.byType(MonthView), matching: find.byWidgetPredicate( (widget) => widget is Scrollable && widget.axis == Axis.vertical, ), ) .first; await scrollUntilVisible(todayCell, 300, scrollable: scrollable); await pumpAndSettle(const Duration(milliseconds: 300)); } Future hoverOnTodayCalendarCell({ Future Function()? onHover, }) async { final todayCell = find.byWidgetPredicate( (widget) => widget is CalendarDayCard && widget.isToday, ); await hoverOnWidget(todayCell, onHover: onHover); } Future tapAddCalendarEventButton() async { final findFlowyButton = find.byType(FlowyIconButton); final findNewEventButton = find.byType(NewEventButton); final button = find.descendant( of: findNewEventButton, matching: findFlowyButton, ); await tapButton(button); } /// Checks for a certain number of events. Parameters [date] and [title] can /// also be provided to restrict the scope of the search void assertNumberOfEventsInCalendar(int number, {String? title}) { Finder findEvents = find.byType(EventCard); if (title != null) { findEvents = find.descendant(of: findEvents, matching: find.text(title)); } expect(findEvents, findsNWidgets(number)); } void assertNumberOfEventsOnSpecificDay( int number, DateTime date, { String? title, }) { final findDayCell = find.byWidgetPredicate( (widget) => widget is CalendarDayCard && isSameDay(widget.date, date), ); Finder findEvents = find.descendant( of: findDayCell, matching: find.byType(EventCard), ); if (title != null) { findEvents = find.descendant(of: findEvents, matching: find.text(title)); } expect(findEvents, findsNWidgets(number)); } Future doubleClickCalendarCell(DateTime date) async { final todayCell = find.byWidgetPredicate( (widget) => widget is CalendarDayCard && isSameDay(date, widget.date), ); final location = getTopLeft(todayCell).translate(10, 10); await doubleTapAt(location); } Future openCalendarEvent({required int index, DateTime? date}) async { final findDayCell = find.byWidgetPredicate( (widget) => widget is CalendarDayCard && isSameDay(widget.date, date ?? DateTime.now()), ); final cards = find.descendant( of: findDayCell, matching: find.byType(EventCard), ); await tapButton(cards.at(index), milliseconds: 1000); } void assertEventEditorOpen() => expect(find.byType(CalendarEventEditor), findsOneWidget); Future dismissEventEditor() async => simulateKeyEvent(LogicalKeyboardKey.escape); Future editEventTitle(String title) async { final textField = find.descendant( of: find.byType(CalendarEventEditor), matching: find.byType(FlowyTextField), ); await enterText(textField, title); await testTextInput.receiveAction(TextInputAction.done); await pumpAndSettle(const Duration(milliseconds: 300)); } Future openEventToRowDetailPage() async { final button = find.descendant( of: find.byType(CalendarEventEditor), matching: find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.full_view_s, ), ); await tapButton(button); } Future deleteEventFromEventEditor() async { final button = find.descendant( of: find.byType(CalendarEventEditor), matching: find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s, ), ); await tapButton(button); await tapButtonWithName(LocaleKeys.button_delete.tr()); } Future dragDropRescheduleCalendarEvent() async { final findEventCard = find.byType(EventCard); await drag(findEventCard.first, const Offset(0, 300)); await pumpAndSettle(const Duration(microseconds: 300)); } Future openUnscheduledEventsPopup() async { final button = find.byType(UnscheduledEventsButton); await tapButton(button); } void findUnscheduledPopup(Matcher matcher, int numUnscheduledEvents) { expect(find.byType(UnscheduleEventsList), matcher); if (matcher != findsNothing) { expect( find.byType(UnscheduledEventCell), findsNWidgets(numUnscheduledEvents), ); } } Future clickUnscheduledEvent() async { final unscheduledEvent = find.byType(UnscheduledEventCell); await tapButton(unscheduledEvent); } Future tapCreateLinkedDatabaseViewButton( DatabaseLayoutPB layoutType, ) async { final findAddButton = find.byType(AddDatabaseViewButton); await tapButton(findAddButton); final findCreateButton = find.byWidgetPredicate( (widget) => widget is TabBarAddButtonActionCell && widget.action == layoutType, ); await tapButton(findCreateButton); } void assertNumberOfGroups(int number) { final groups = find.byType(BoardColumnHeader, skipOffstage: false); expect(groups, findsNWidgets(number)); } Future scrollBoardToEnd() async { final scrollable = find .descendant( of: find.byType(AppFlowyBoard), matching: find.byWidgetPredicate( (widget) => widget is Scrollable && widget.axis == Axis.horizontal, ), ) .first; await scrollUntilVisible( find.byType(BoardTrailing), 300, scrollable: scrollable, ); } Future tapNewGroupButton() async { final button = find.descendant( of: find.byType(BoardTrailing), matching: find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.add_s, ), ); expect(button, findsOneWidget); await tapButton(button); } void assertNewGroupTextField(bool isVisible) { final textField = find.descendant( of: find.byType(BoardTrailing), matching: find.byType(TextField), ); if (isVisible) { return expect(textField, findsOneWidget); } expect(textField, findsNothing); } Future enterNewGroupName(String name, {required bool submit}) async { final textField = find.descendant( of: find.byType(BoardTrailing), matching: find.byType(TextField), ); await enterText(textField, name); await pumpAndSettle(); if (submit) { await testTextInput.receiveAction(TextInputAction.done); await pumpAndSettle(); } } Future clearNewGroupTextField() async { final textField = find.descendant( of: find.byType(BoardTrailing), matching: find.byType(TextField), ); await tapButton( find.descendant( of: textField, matching: find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.close_filled_s, ), ), ); final textFieldWidget = widget(textField); assert( textFieldWidget.controller != null && textFieldWidget.controller!.text.isEmpty, ); } Future tapTabBarLinkedViewByViewName(String name) async { final viewButton = findTabBarLinkViewByViewName(name); await tapButton(viewButton); } Finder findTabBarLinkViewByViewLayout(ViewLayoutPB layout) { return find.byWidgetPredicate( (widget) => widget is TabBarItemButton && widget.view.layout == layout, ); } Finder findTabBarLinkViewByViewName(String name) { return find.byWidgetPredicate( (widget) => widget is TabBarItemButton && widget.view.name == name, ); } Future renameLinkedView(Finder linkedView, String name) async { await tap(linkedView, buttons: kSecondaryButton); await pumpAndSettle(); await tapButton( find.byWidgetPredicate( (widget) => widget is ActionCellWidget && widget.action == TabBarViewAction.rename, ), ); await enterText( find.descendant( of: find.byType(AFTextFieldDialog), matching: find.byType(AFTextField), ), name, ); await tapButton(find.text(LocaleKeys.button_confirm.tr())); } Future deleteDatebaseView(Finder linkedView) async { await tap(linkedView, buttons: kSecondaryButton); await pumpAndSettle(); await tapButton( find.byWidgetPredicate( (widget) => widget is ActionCellWidget && widget.action == TabBarViewAction.delete, ), ); final okButton = find.byWidgetPredicate( (widget) => widget is PrimaryTextButton && widget.label == LocaleKeys.button_ok.tr(), ); await tapButton(okButton); } void assertCurrentDatabaseTagIs(DatabaseLayoutPB layout) => switch (layout) { DatabaseLayoutPB.Board => expect(find.byType(DesktopBoardPage), findsOneWidget), DatabaseLayoutPB.Calendar => expect(find.byType(CalendarPage), findsOneWidget), DatabaseLayoutPB.Grid => expect(find.byType(GridPage), findsOneWidget), _ => throw Exception('Unknown database layout type: $layout'), }; Future selectDatabaseLayoutType(DatabaseLayoutPB layout) async { final findLayoutCell = find.byType(DatabaseViewLayoutCell); final findText = find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == layout.layoutName, ); final button = find.descendant(of: findLayoutCell, matching: findText); await tapButton(button); } Future assertCurrentDatabaseLayoutType(DatabaseLayoutPB layout) async { expect(finderForDatabaseLayoutType(layout), findsOneWidget); } Future tapDatabaseRawDataButton() async { await tapButtonWithName(LocaleKeys.importPanel_database.tr()); } // Use in edit mode of FieldEditor Future changeNumberFieldFormat() async { final changeFormatButton = find.descendant( of: find.byType(FieldTypeOptionEditor), matching: find.text("Number"), ); await tapButton(changeFormatButton); await tapButton( find.byWidgetPredicate( (w) => w is NumberFormatCell && w.format == NumberFormatPB.USD, ), ); } // Use in edit mode of FieldEditor Future tapAddSelectOptionButton() async { await tapButtonWithName(LocaleKeys.grid_field_addSelectOption.tr()); } Future tapViewTogglePropertyVisibilityButtonByName( String fieldName, ) async { final field = find.byWidgetPredicate( (w) => w is DatabasePropertyCell && w.fieldInfo.name == fieldName, ); final toggleVisibilityButton = find.descendant(of: field, matching: find.byType(FlowyIconButton)); await tapButton(toggleVisibilityButton); } } Finder finderForDatabaseLayoutType(DatabaseLayoutPB layout) => switch (layout) { DatabaseLayoutPB.Board => find.byType(DesktopBoardPage), DatabaseLayoutPB.Calendar => find.byType(CalendarPage), DatabaseLayoutPB.Grid => find.byType(GridPage), _ => throw Exception('Unknown database layout type: $layout'), }; Finder finderForFieldType(FieldType fieldType) { switch (fieldType) { case FieldType.Checkbox: return find.byType(EditableCheckboxCell, skipOffstage: false); case FieldType.DateTime: return find.byType(EditableDateCell, skipOffstage: false); case FieldType.LastEditedTime: return find.byWidgetPredicate( (widget) => widget is EditableTimestampCell && widget.fieldType == FieldType.LastEditedTime, skipOffstage: false, ); case FieldType.CreatedTime: return find.byWidgetPredicate( (widget) => widget is EditableTimestampCell && widget.fieldType == FieldType.CreatedTime, skipOffstage: false, ); case FieldType.SingleSelect: return find.byWidgetPredicate( (widget) => widget is EditableSelectOptionCell && widget.fieldType == FieldType.SingleSelect, skipOffstage: false, ); case FieldType.MultiSelect: return find.byWidgetPredicate( (widget) => widget is EditableSelectOptionCell && widget.fieldType == FieldType.MultiSelect, skipOffstage: false, ); case FieldType.Checklist: return find.byType(EditableChecklistCell, skipOffstage: false); case FieldType.Number: return find.byType(EditableNumberCell, skipOffstage: false); case FieldType.RichText: return find.byType(EditableTextCell, skipOffstage: false); case FieldType.URL: return find.byType(EditableURLCell, skipOffstage: false); case FieldType.Media: return find.byType(EditableMediaCell, skipOffstage: false); default: throw Exception('Unknown field type: $fieldType'); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/dir.dart ================================================ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; Future deleteDirectoriesWithSameBaseNameAsPrefix( String path, ) async { final dir = Directory(path); final prefix = p.basename(dir.path); final parentDir = dir.parent; // Check if the directory exists if (!await parentDir.exists()) { // ignore: avoid_print print('Directory does not exist'); return; } // List all entities in the directory await for (final entity in parentDir.list()) { // Check if the entity is a directory and starts with the specified prefix if (entity is Directory && p.basename(entity.path).startsWith(prefix)) { try { await entity.delete(recursive: true); } catch (e) { // ignore: avoid_print print('Failed to delete directory: ${entity.path}, Error: $e'); } } } } Future unzipFile(File zipFile, Directory targetDirectory) async { // Read the Zip file from disk. final bytes = zipFile.readAsBytesSync(); // Decode the Zip file final archive = ZipDecoder().decodeBytes(bytes); // Extract the contents of the Zip archive to disk. for (final file in archive) { final filename = file.name; if (file.isFile) { final data = file.content as List; File(p.join(targetDirectory.path, filename)) ..createSync(recursive: true) ..writeAsBytesSync(data); } else { Directory(p.join(targetDirectory.path, filename)) .createSync(recursive: true); } } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart ================================================ import 'dart:async'; import 'dart:ui'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_title.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:universal_platform/universal_platform.dart'; import 'util.dart'; extension EditorWidgetTester on WidgetTester { EditorOperations get editor => EditorOperations(this); } class EditorOperations { const EditorOperations(this.tester); final WidgetTester tester; EditorState getCurrentEditorState() => tester.widget(find.byType(AppFlowyEditor)).editorState; Node getNodeAtPath(Path path) { final editorState = getCurrentEditorState(); return editorState.getNodeAtPath(path)!; } /// Tap the line of editor at [index] Future tapLineOfEditorAt(int index) async { final textBlocks = find.byType(AppFlowyRichText); index = index.clamp(0, textBlocks.evaluate().length - 1); final center = tester.getCenter(textBlocks.at(index)); final right = tester.getTopRight(textBlocks.at(index)); final centerRight = Offset(right.dx, center.dy); await tester.tapAt(centerRight); await tester.pumpAndSettle(); } /// Hover on cover plugin button above the document Future hoverOnCoverToolbar() async { final coverToolbar = find.byType(DocumentHeaderToolbar); await tester.startGesture( tester.getBottomLeft(coverToolbar).translate(5, -5), kind: PointerDeviceKind.mouse, ); await tester.pumpAndSettle(); } /// Taps on the 'Add Icon' button in the cover toolbar Future tapAddIconButton() async { await tester.tapButtonWithName( LocaleKeys.document_plugins_cover_addIcon.tr(), ); expect(find.byType(FlowyEmojiPicker), findsOneWidget); } Future paste() async { if (UniversalPlatform.isMacOS) { await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isMetaPressed: true, ); } else { await tester.simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: true, ); } } Future tapGettingStartedIcon() async { await tester.tapButton( find.descendant( of: find.byType(DocumentCoverWidget), matching: find.findTextInFlowyText('⭐️'), ), ); } /// Taps on the 'Skin tone' button /// /// Must call [tapAddIconButton] first. Future changeEmojiSkinTone(EmojiSkinTone skinTone) async { await tester.tapButton( find.byTooltip(LocaleKeys.emoji_selectSkinTone.tr()), ); final skinToneButton = find.byKey(emojiSkinToneKey(skinTone.icon)); await tester.tapButton(skinToneButton); } /// Taps the 'Remove Icon' button in the cover toolbar and the icon popover Future tapRemoveIconButton({bool isInPicker = false}) async { final Finder button = !isInPicker ? find.text(LocaleKeys.document_plugins_cover_removeIcon.tr()) : find.descendant( of: find.byType(FlowyIconEmojiPicker), matching: find.text(LocaleKeys.button_remove.tr()), ); await tester.tapButton(button); } /// Requires that the document must already have an icon. This opens the icon /// picker Future tapOnIconWidget() async { final iconWidget = find.byType(EmojiIconWidget); await tester.tapButton(iconWidget); } Future tapOnAddCover() async { await tester.tapButtonWithName( LocaleKeys.document_plugins_cover_addCover.tr(), ); } Future tapOnChangeCover() async { await tester.tapButtonWithName( LocaleKeys.document_plugins_cover_changeCover.tr(), ); } Future switchSolidColorBackground() async { final findPurpleButton = find.byWidgetPredicate( (widget) => widget is ColorItem && widget.option.name == 'Purple', ); await tester.tapButton(findPurpleButton); } Future addNetworkImageCover(String imageUrl) async { final embedLinkButton = find.findTextInFlowyText( LocaleKeys.document_imageBlock_embedLink_label.tr(), ); await tester.tapButton(embedLinkButton); final imageUrlTextField = find.descendant( of: find.byType(EmbedImageUrlWidget), matching: find.byType(TextField), ); await tester.enterText(imageUrlTextField, imageUrl); await tester.pumpAndSettle(); await tester.tapButton( find.descendant( of: find.byType(EmbedImageUrlWidget), matching: find.findTextInFlowyText( LocaleKeys.document_imageBlock_embedLink_label.tr(), ), ), ); } Future tapOnRemoveCover() async => tester.tapButton(find.byType(DeleteCoverButton)); /// A cover must be present in the document to function properly since this /// catches all cover types collectively Future hoverOnCover() async { final cover = find.byType(DocumentCover); await tester.startGesture( tester.getCenter(cover), kind: PointerDeviceKind.mouse, ); await tester.pumpAndSettle(); } Future dismissCoverPicker() async { await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); } /// trigger the slash command (selection menu) Future showSlashMenu() async { await tester.ime.insertCharacter('/'); } /// trigger the mention (@) command Future showAtMenu() async { await tester.ime.insertCharacter('@'); } /// trigger the plus action menu (+) command Future showPlusMenu() async { await tester.ime.insertCharacter('+'); } /// Tap the slash menu item with [name] /// /// Must call [showSlashMenu] first. Future tapSlashMenuItemWithName( String name, { double offset = 200, }) async { final slashMenu = find .ancestor( of: find.byType(SelectionMenuItemWidget), matching: find.byWidgetPredicate( (widget) => widget is Scrollable, ), ) .first; final slashMenuItem = find.text(name, findRichText: true); await tester.scrollUntilVisible( slashMenuItem, offset, scrollable: slashMenu, duration: const Duration(milliseconds: 250), ); assert(slashMenuItem.hasFound); await tester.tapButton(slashMenuItem); } /// Tap the at menu item with [name] /// /// Must call [showAtMenu] first. Future tapAtMenuItemWithName(String name) async { final atMenuItem = find.descendant( of: find.byType(InlineActionsHandler), matching: find.text(name, findRichText: true), ); await tester.tapButton(atMenuItem); } /// Update the editor's selection Future updateSelection(Selection? selection) async { final editorState = getCurrentEditorState(); unawaited( editorState.updateSelectionWithReason( selection, reason: SelectionUpdateReason.uiEvent, ), ); await tester.pumpAndSettle(const Duration(milliseconds: 200)); } /// hover and click on the + button beside the block component. Future hoverAndClickOptionAddButton( Path path, bool withModifiedKey, // alt on windows or linux, option on macos ) async { final optionAddButton = find.byWidgetPredicate( (widget) => widget is BlockComponentActionWrapper && widget.node.path.equals(path), ); await tester.hoverOnWidget( optionAddButton, onHover: () async { if (withModifiedKey) { await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); } await tester.tapButton( find.byWidgetPredicate( (widget) => widget is BlockAddButton && widget.blockComponentContext.node.path.equals(path), ), ); if (withModifiedKey) { await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); } }, ); } /// hover and click on the option menu button beside the block component. Future hoverAndClickOptionMenuButton(Path path) async { final optionMenuButton = find.byWidgetPredicate( (widget) => widget is BlockComponentActionWrapper && widget.node.path.equals(path), ); await tester.hoverOnWidget( optionMenuButton, onHover: () async { await tester.tapButton( find.byWidgetPredicate( (widget) => widget is BlockOptionButton && widget.blockComponentContext.node.path.equals(path), ), ); await tester.pumpUntilFound(find.byType(PopoverActionList)); }, ); } /// open the turn into menu Future openTurnIntoMenu(Path path) async { await hoverAndClickOptionMenuButton(path); await tester.tapButton( find .findTextInFlowyText( LocaleKeys.document_plugins_optionAction_turnInto.tr(), ) .first, ); await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu)); } /// copy link to block Future copyLinkToBlock(Path path) async { await hoverAndClickOptionMenuButton(path); await tester.tapButton( find.findTextInFlowyText( LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(), ), ); } Future openDepthMenu(Path path) async { await hoverAndClickOptionMenuButton(path); await tester.tapButton( find.findTextInFlowyText( LocaleKeys.document_plugins_optionAction_depth.tr(), ), ); await tester.pumpUntilFound(find.byType(DepthOptionMenu)); } /// Drag block /// /// [offset] is the offset to move the block. /// /// [path] is the path of the block to move. Future dragBlock( Path path, Offset offset, ) async { final dragToMoveAction = find.byWidgetPredicate( (widget) => widget is DraggableOptionButton && widget.blockComponentContext.node.path.equals(path), ); await tester.hoverOnWidget( dragToMoveAction, onHover: () async { final dragToMoveTooltip = find.findFlowyTooltip( LocaleKeys.blockActions_dragTooltip.tr(), ); await tester.pumpUntilFound(dragToMoveTooltip); final location = tester.getCenter(dragToMoveAction); final gesture = await tester.startGesture( location, pointer: 7, ); await tester.pump(); // divide the steps to small move to avoid the drag area not found error const steps = 5; final stepOffset = Offset(offset.dx / steps, offset.dy / steps); for (var i = 0; i < steps; i++) { await gesture.moveBy(stepOffset); await tester.pump(Durations.short1); } // check if the drag to move action is dragging expect( isDraggingAppFlowyEditorBlock.value, isTrue, ); await gesture.up(); await tester.pump(); }, ); await tester.pumpAndSettle(Durations.short1); } Finder findDocumentTitle(String? title) { final parent = UniversalPlatform.isDesktop ? find.byType(CoverTitle) : find.byType(DocumentImmersiveCover); return find.descendant( of: parent, matching: find.byWidgetPredicate( (widget) { if (widget is! TextField) { return false; } if (widget.controller?.text == title) { return true; } if (title == null) { return true; } if (title.isEmpty) { return widget.controller?.text.isEmpty ?? false; } return false; }, ), ); } /// open the more action menu on mobile Future openMoreActionMenuOnMobile() async { final moreActionButton = find.byType(MobileViewPageMoreButton); await tester.tapButton(moreActionButton); await tester.pumpAndSettle(); } /// click the more action item on mobile /// /// rename, add collaborator, publish, delete, etc. Future clickMoreActionItemOnMobile(String name) async { final moreActionItem = find.descendant( of: find.byType(MobileQuickActionButton), matching: find.findTextInFlowyText(name), ); await tester.tapButton(moreActionItem); await tester.pumpAndSettle(); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/emoji.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_uploader.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flowy_infra_ui/style_widget/primary_rounded_button.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:flutter_test/flutter_test.dart'; import 'base.dart'; import 'common_operations.dart'; extension EmojiTestExtension on WidgetTester { Future tapEmoji(String emoji) async { final emojiWidget = find.descendant( of: find.byType(EmojiPicker), matching: find.text(emoji), ); await tapButton(emojiWidget); } Future tapIcon(EmojiIconData icon, {bool enableColor = true}) async { final iconsData = IconsData.fromJson(jsonDecode(icon.emoji)); final pickTab = find.byType(PickerTab); expect(pickTab, findsOneWidget); await pumpAndSettle(); final iconTab = find.descendant( of: pickTab, matching: find.text(PickerTabType.icon.tr), ); expect(iconTab, findsOneWidget); await tapButton(iconTab); final selectedSvg = find.descendant( of: find.byType(FlowyIconPicker), matching: find.byWidgetPredicate( (w) => w is FlowySvg && w.svgString == iconsData.svgString, ), ); await tapButton(selectedSvg.first); if (enableColor) { final colorPicker = find.byType(IconColorPicker); expect(colorPicker, findsOneWidget); final selectedColor = find.descendant( of: colorPicker, matching: find.byWidgetPredicate((w) { if (w is Container) { final d = w.decoration; if (d is ShapeDecoration) { if (d.color == Color( int.parse(iconsData.color ?? builtInSpaceColors.first), )) { return true; } } } return false; }), ); await tapButton(selectedColor); } } Future pickImage(EmojiIconData icon) async { final pickTab = find.byType(PickerTab); expect(pickTab, findsOneWidget); await pumpAndSettle(); /// switch to custom tab final iconTab = find.descendant( of: pickTab, matching: find.text(PickerTabType.custom.tr), ); expect(iconTab, findsOneWidget); await tapButton(iconTab); /// mock for dragging image final dropTarget = find.descendant( of: find.byType(IconUploader), matching: find.byType(DropTarget), ); expect(dropTarget, findsOneWidget); final dropTargetWidget = dropTarget.evaluate().first.widget as DropTarget; dropTargetWidget.onDragDone?.call( DropDoneDetails( files: [DropItemFile(icon.emoji)], localPosition: Offset.zero, globalPosition: Offset.zero, ), ); await pumpAndSettle(const Duration(seconds: 3)); /// confirm to upload final confirmButton = find.descendant( of: find.byType(IconUploader), matching: find.byType(PrimaryRoundedButton), ); await tapButton(confirmButton); } Future pasteImageLinkAsIcon(String link) async { final pickTab = find.byType(PickerTab); expect(pickTab, findsOneWidget); await pumpAndSettle(); /// switch to custom tab final iconTab = find.descendant( of: pickTab, matching: find.text(PickerTabType.custom.tr), ); expect(iconTab, findsOneWidget); await tapButton(iconTab); // mock the clipboard await getIt() .setData(ClipboardServiceData(plainText: link)); // paste the link await simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, isMetaPressed: Platform.isMacOS, ); await pumpAndSettle(const Duration(seconds: 5)); /// confirm to upload final confirmButton = find.descendant( of: find.byType(IconUploader), matching: find.byType(PrimaryRoundedButton), ); await tapButton(confirmButton); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/expectation.dart ================================================ import 'dart:convert'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/appflowy_network_svg.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; import 'util.dart'; // const String readme = 'Read me'; const String gettingStarted = 'Getting started'; extension Expectation on WidgetTester { /// Expect to see the home page and with a default read me page. Future expectToSeeHomePageWithGetStartedPage() async { if (UniversalPlatform.isDesktopOrWeb) { final finder = find.byType(HomeStack); await pumpUntilFound(finder); expect(finder, findsOneWidget); } else if (UniversalPlatform.isMobile) { final finder = find.byType(MobileHomePage); await pumpUntilFound(finder); expect(finder, findsOneWidget); } final docFinder = find.textContaining(gettingStarted); await pumpUntilFound(docFinder); } Future expectToSeeHomePage() async { final finder = find.byType(HomeStack); await pumpUntilFound(finder); expect(finder, findsOneWidget); } /// Expect to see the page name on the home page. void expectToSeePageName( String name, { String? parentName, ViewLayoutPB layout = ViewLayoutPB.Document, ViewLayoutPB parentLayout = ViewLayoutPB.Document, }) { final pageName = findPageName( name, layout: layout, parentName: parentName, parentLayout: parentLayout, ); expect(pageName, findsOneWidget); } /// Expect not to see the page name on the home page. void expectNotToSeePageName( String name, { String? parentName, ViewLayoutPB layout = ViewLayoutPB.Document, ViewLayoutPB parentLayout = ViewLayoutPB.Document, }) { final pageName = findPageName( name, layout: layout, parentName: parentName, parentLayout: parentLayout, ); expect(pageName, findsNothing); } /// Expect to see the document banner. void expectToSeeDocumentBanner() { expect(find.byType(DocumentBanner), findsOneWidget); } /// Expect not to see the document banner. void expectNotToSeeDocumentBanner() { expect(find.byType(DocumentBanner), findsNothing); } /// Expect to the markdown file export success dialog. void expectToExportSuccess() { final exportSuccess = find.text( LocaleKeys.settings_files_exportFileSuccess.tr(), ); expect(exportSuccess, findsOneWidget); } /// Expect to see the document header toolbar empty void expectToSeeEmptyDocumentHeaderToolbar() { final addCover = find.textContaining( LocaleKeys.document_plugins_cover_addCover.tr(), ); final addIcon = find.textContaining( LocaleKeys.document_plugins_cover_addIcon.tr(), ); expect(addCover, findsNothing); expect(addIcon, findsNothing); } void expectToSeeDocumentIcon(String? emoji) { if (emoji == null) { final iconWidget = find.byType(EmojiIconWidget); expect(iconWidget, findsNothing); return; } final iconWidget = find.byWidgetPredicate( (widget) => widget is EmojiIconWidget && widget.emoji.emoji == emoji, ); expect(iconWidget, findsOneWidget); } void expectDocumentIconNotNull() { final iconWidget = find.byWidgetPredicate( (widget) => widget is EmojiIconWidget && widget.emoji.isNotEmpty, ); expect(iconWidget, findsOneWidget); } void expectToSeeDocumentCover(CoverType type) { final findCover = find.byWidgetPredicate( (widget) => widget is DocumentCover && widget.coverType == type, ); expect(findCover, findsOneWidget); } void expectToSeeNoDocumentCover() { final findCover = find.byType(DocumentCover); expect(findCover, findsNothing); } void expectChangeCoverAndDeleteButton() { final findChangeCover = find.text( LocaleKeys.document_plugins_cover_changeCover.tr(), ); final findRemoveIcon = find.byType(DeleteCoverButton); expect(findChangeCover, findsOneWidget); expect(findRemoveIcon, findsOneWidget); } /// Expect to see a text void expectToSeeText(String text) { Finder textWidget = find.textContaining(text, findRichText: true); if (textWidget.evaluate().isEmpty) { textWidget = find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == text, ); } expect(textWidget, findsOneWidget); } /// Find if the page is favorite Finder findFavoritePageName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, String? parentName, ViewLayoutPB parentLayout = ViewLayoutPB.Document, }) => find.byWidgetPredicate( (widget) => widget is SingleInnerViewItem && widget.view.isFavorite && widget.spaceType == FolderSpaceType.favorite && widget.view.name == name && widget.view.layout == layout, skipOffstage: false, ); Finder findAllFavoritePages() => find.byWidgetPredicate( (widget) => widget is SingleInnerViewItem && widget.view.isFavorite && widget.spaceType == FolderSpaceType.favorite, ); Finder findPageName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, String? parentName, ViewLayoutPB parentLayout = ViewLayoutPB.Document, }) { if (UniversalPlatform.isDesktop) { if (parentName == null) { return find.byWidgetPredicate( (widget) => widget is SingleInnerViewItem && widget.view.name == name && widget.view.layout == layout, skipOffstage: false, ); } return find.descendant( of: find.byWidgetPredicate( (widget) => widget is InnerViewItem && widget.view.name == parentName && widget.view.layout == parentLayout, skipOffstage: false, ), matching: findPageName(name, layout: layout), ); } return find.byWidgetPredicate( (widget) => widget is SingleMobileInnerViewItem && widget.view.name == name && widget.view.layout == layout, skipOffstage: false, ); } void expectViewHasIcon(String name, ViewLayoutPB layout, EmojiIconData data) { final pageName = findPageName( name, layout: layout, ); final type = data.type; if (type == FlowyIconType.emoji) { final icon = find.descendant( of: pageName, matching: find.text(data.emoji), ); expect(icon, findsOneWidget); } else if (type == FlowyIconType.icon) { final iconsData = IconsData.fromJson(jsonDecode(data.emoji)); final icon = find.descendant( of: pageName, matching: find.byWidgetPredicate( (w) => w is FlowySvg && w.svgString == iconsData.svgString, ), ); expect(icon, findsOneWidget); } else if (type == FlowyIconType.custom) { final isSvg = data.emoji.endsWith('.svg'); if (isURL(data.emoji)) { final image = find.descendant( of: pageName, matching: isSvg ? find.byType(FlowyNetworkSvg) : find.byType(FlowyNetworkImage), ); expect(image, findsOneWidget); } else { final image = find.descendant( of: pageName, matching: isSvg ? find.byType(SvgPicture) : find.byType(Image), ); expect(image, findsOneWidget); } } } void expectViewTitleHasIcon( String name, ViewLayoutPB layout, EmojiIconData data, ) { final type = data.type; if (type == FlowyIconType.emoji) { final icon = find.descendant( of: find.byType(ViewTitleBar), matching: find.text(data.emoji), ); expect(icon, findsOneWidget); } else if (type == FlowyIconType.icon) { final iconsData = IconsData.fromJson(jsonDecode(data.emoji)); final icon = find.descendant( of: find.byType(ViewTitleBar), matching: find.byWidgetPredicate( (w) => w is FlowySvg && w.svgString == iconsData.svgString, ), ); expect(icon, findsOneWidget); } else if (type == FlowyIconType.custom) { final isSvg = data.emoji.endsWith('.svg'); if (isURL(data.emoji)) { final image = find.descendant( of: find.byType(ViewTitleBar), matching: isSvg ? find.byType(FlowyNetworkSvg) : find.byType(FlowyNetworkImage), ); expect(image, findsOneWidget); } else { final image = find.descendant( of: find.byType(ViewTitleBar), matching: isSvg ? find.byWidgetPredicate((w) { if (w is! SvgPicture) return false; final loader = w.bytesLoader; if (loader is! SvgFileLoader) return false; return loader.file.path.endsWith('.svg'); }) : find.byType(Image), ); expect(image, findsOneWidget); } } } void expectSelectedReminder(ReminderOption option) { final findSelectedText = find.descendant( of: find.byType(ReminderSelector), matching: find.text(option.label), ); expect(findSelectedText, findsOneWidget); } void expectNotificationItems(int amount) { final findItems = find.byType(NotificationItem); expect(findItems, findsNWidgets(amount)); } void expectToSeeRowDetailsPageDialog() { expect( find.descendant( of: find.byType(RowDetailPage), matching: find.byType(SimpleDialog), ), findsOneWidget, ); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/ime.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; extension IME on WidgetTester { IMESimulator get ime => IMESimulator(this); } class IMESimulator { IMESimulator(this.tester) { client = findTextInputClient(); } final WidgetTester tester; late final TextInputClient client; Future insertText(String text) async { for (final c in text.characters) { await insertCharacter(c); } } Future insertCharacter(String character) async { final value = client.currentTextEditingValue; if (value == null) { assert(false); return; } final text = value.text .replaceRange(value.selection.start, value.selection.end, character); final textEditingValue = TextEditingValue( text: text, selection: TextSelection.collapsed( offset: value.selection.baseOffset + 1, ), ); client.updateEditingValue(textEditingValue); await tester.pumpAndSettle(); } TextInputClient findTextInputClient() { final finder = find.byType(KeyboardServiceWidget); final KeyboardServiceWidgetState state = tester.state(finder); return state.textInputService as TextInputClient; } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/keyboard.dart ================================================ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart' as flutter_test; class FlowyTestKeyboard { static Future simulateKeyDownEvent( List keys, { required flutter_test.WidgetTester tester, bool withKeyUp = false, }) async { for (final LogicalKeyboardKey key in keys) { await flutter_test.simulateKeyDownEvent(key); await tester.pumpAndSettle(); } if (withKeyUp) { for (final LogicalKeyboardKey key in keys) { await flutter_test.simulateKeyUpEvent(key); await tester.pumpAndSettle(); } } } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/mock/mock_ai.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/service/ai_entities.dart'; import 'package:appflowy/ai/service/appflowy_ai_service.dart'; import 'package:appflowy/ai/service/error.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart'; import 'package:mocktail/mocktail.dart'; final _mockAiMap = >>{ CompletionTypePB.ImproveWriting: { "I have an apple": [ "I", "have", "an", "apple", "and", "a", "banana", ], }, CompletionTypePB.SpellingAndGrammar: { "We didn’t had enough money": [ "We", "didn’t", "have", "enough", "money", ], }, CompletionTypePB.UserQuestion: { "Explain the concept of TPU": [ "TPU", "is", "a", "tensor", "processing", "unit", "that", "is", "designed", "to", "accelerate", "machine", ], "How about GPU?": [ "GPU", "is", "a", "graphics", "processing", "unit", "that", "is", "designed", "to", "accelerate", "machine", "learning", "tasks", ], }, }; abstract class StreamCompletionValidator { bool validate( String text, String? objectId, CompletionTypePB completionType, PredefinedFormat? format, List sourceIds, List history, ); } class MockCompletionStream extends Mock implements CompletionStream {} class MockAIRepository extends Mock implements AppFlowyAIService { MockAIRepository({this.validator}); StreamCompletionValidator? validator; @override Future<(String, CompletionStream)?> streamCompletion({ String? objectId, required String text, PredefinedFormat? format, String? promptId, List sourceIds = const [], List history = const [], required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, required void Function(LocalAIStreamingState state) onLocalAIStreamingStateChange, }) async { if (validator != null) { if (!validator!.validate( text, objectId, completionType, format, sourceIds, history, )) { throw Exception('Invalid completion'); } } final stream = MockCompletionStream(); unawaited( Future(() async { await onStart(); final lines = _mockAiMap[completionType]?[text.trim()]; if (lines == null) { throw Exception('No mock ai found for $text and $completionType'); } for (final line in lines) { await processMessage('$line '); } await onEnd(); }), ); return ('mock_id', stream); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/mock/mock_file_picker.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; class MockFilePicker implements FilePickerService { MockFilePicker({ this.mockPath = '', this.mockPaths = const [], }); final String mockPath; final List mockPaths; @override Future getDirectoryPath({String? title}) => Future.value(mockPath); @override Future saveFile({ String? dialogTitle, String? fileName, String? initialDirectory, FileType type = FileType.any, List? allowedExtensions, bool lockParentWindow = false, }) => Future.value(mockPath); @override Future pickFiles({ String? dialogTitle, String? initialDirectory, FileType type = FileType.any, List? allowedExtensions, Function(FilePickerStatus p1)? onFileLoading, bool allowCompression = true, bool allowMultiple = false, bool withData = false, bool withReadStream = false, bool lockParentWindow = false, }) { final platformFiles = mockPaths.map((e) => PlatformFile(path: e, name: '', size: 0)).toList(); return Future.value(FilePickerResult(platformFiles)); } } Future mockGetDirectoryPath(String path) async { getIt.unregister(); getIt.registerFactory( () => MockFilePicker(mockPath: path), ); } Future mockSaveFilePath(String path) async { getIt.unregister(); getIt.registerFactory( () => MockFilePicker(mockPath: path), ); return path; } List mockPickFilePaths({required List paths}) { getIt.unregister(); getIt.registerFactory( () => MockFilePicker(mockPaths: paths), ); return paths; } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/mock/mock_url_launcher.dart ================================================ // Copyright 2013 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; class MockUrlLauncher extends Fake with MockPlatformInterfaceMixin implements UrlLauncherPlatform { String? url; PreferredLaunchMode? launchMode; bool? useSafariVC; bool? useWebView; bool? enableJavaScript; bool? enableDomStorage; bool? universalLinksOnly; Map? headers; String? webOnlyWindowName; bool? response; bool closeWebViewCalled = false; bool canLaunchCalled = false; bool launchCalled = false; // ignore: use_setters_to_change_properties void setCanLaunchExpectations(String url) => this.url = url; void setLaunchExpectations({ required String url, PreferredLaunchMode? launchMode, bool? useSafariVC, bool? useWebView, required bool enableJavaScript, required bool enableDomStorage, required bool universalLinksOnly, required Map headers, required String? webOnlyWindowName, }) { this.url = url; this.launchMode = launchMode; this.useSafariVC = useSafariVC; this.useWebView = useWebView; this.enableJavaScript = enableJavaScript; this.enableDomStorage = enableDomStorage; this.universalLinksOnly = universalLinksOnly; this.headers = headers; this.webOnlyWindowName = webOnlyWindowName; } void setResponse(bool response) => this.response = response; @override LinkDelegate? get linkDelegate => null; @override Future canLaunch(String url) async { expect(url, this.url); canLaunchCalled = true; return response!; } @override Future launch( String url, { required bool useSafariVC, required bool useWebView, required bool enableJavaScript, required bool enableDomStorage, required bool universalLinksOnly, required Map headers, String? webOnlyWindowName, }) async { expect(url, this.url); expect(useSafariVC, this.useSafariVC); expect(useWebView, this.useWebView); expect(enableJavaScript, this.enableJavaScript); expect(enableDomStorage, this.enableDomStorage); expect(universalLinksOnly, this.universalLinksOnly); expect(headers, this.headers); expect(webOnlyWindowName, this.webOnlyWindowName); launchCalled = true; return response!; } @override Future launchUrl(String url, LaunchOptions options) async { expect(url, this.url); expect(options.mode, launchMode); expect(options.webViewConfiguration.enableJavaScript, enableJavaScript); expect(options.webViewConfiguration.enableDomStorage, enableDomStorage); expect(options.webViewConfiguration.headers, headers); expect(options.webOnlyWindowName, webOnlyWindowName); launchCalled = true; return response!; } @override Future closeWebView() async => closeWebViewCalled = true; } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/settings.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/account_user_profile.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'base.dart'; import 'common_operations.dart'; extension AppFlowySettings on WidgetTester { /// Open settings page Future openSettings() async { final settingsDialog = find.byType(SettingsDialog); // tap empty area to close the settings page while (settingsDialog.evaluate().isNotEmpty) { await tapAt(Offset.zero); await pumpAndSettle(); } final settingsButton = find.byType(UserSettingButton); expect(settingsButton, findsOneWidget); await tapButton(settingsButton); expect(settingsDialog, findsOneWidget); return; } /// Open the page that insides the settings page Future openSettingsPage(SettingsPage page) async { final button = find.byWidgetPredicate( (widget) => widget is SettingsMenuElement && widget.page == page, ); await scrollUntilVisible( button, 0, scrollable: find.findSettingsMenuScrollable(), ); await pump(); expect(button, findsOneWidget); await tapButton(button); return; } /// Restore the AppFlowy data storage location Future restoreLocation() async { final button = find.text(LocaleKeys.settings_common_reset.tr()); expect(button, findsOneWidget); await tapButton(button); await pumpAndSettle(); final confirmButton = find.text(LocaleKeys.button_confirm.tr()); expect(confirmButton, findsOneWidget); await tapButton(confirmButton); return; } Future tapCustomLocationButton() async { final button = find.byTooltip( LocaleKeys.settings_files_changeLocationTooltips.tr(), ); expect(button, findsOneWidget); await tapButton(button); return; } /// Enter user name Future enterUserName(String name) async { // Enable editing username final editUsernameFinder = find.descendant( of: find.byType(AccountUserProfile), matching: find.byFlowySvg(FlowySvgs.toolbar_link_edit_m), ); await tap(editUsernameFinder, warnIfMissed: false); await pumpAndSettle(); final userNameFinder = find.descendant( of: find.byType(AccountUserProfile), matching: find.byType(TextField), ); await enterText(userNameFinder, name); await pumpAndSettle(); await tap(find.text(LocaleKeys.button_save.tr())); await pumpAndSettle(); } // go to settings page and toggle enable RTL toolbar items Future toggleEnableRTLToolbarItems() async { await openSettings(); await openSettingsPage(SettingsPage.workspace); final scrollable = find.findSettingsScrollable(); await scrollUntilVisible( find.byType(EnableRTLItemsSwitcher), 0, scrollable: scrollable, ); final switcher = find.descendant( of: find.byType(EnableRTLItemsSwitcher), matching: find.byType(Toggle), ); await tap(switcher); // tap anywhere to close the settings page await tapAt(Offset.zero); await pumpAndSettle(); } Future updateNamespace(String namespace) async { final dialog = find.byType(DomainSettingsDialog); expect(dialog, findsOneWidget); // input the new namespace await enterText( find.descendant( of: dialog, matching: find.byType(TextField), ), namespace, ); await tapButton(find.text(LocaleKeys.button_save.tr())); await pumpAndSettle(); } } ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/util.dart ================================================ export 'auth_operation.dart'; export 'base.dart'; export 'common_operations.dart'; export 'data.dart'; export 'document_test_operations.dart'; export 'expectation.dart'; export 'ime.dart'; export 'mock/mock_url_launcher.dart'; export 'settings.dart'; ================================================ FILE: frontend/appflowy_flutter/integration_test/shared/workspace.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; extension AppFlowyWorkspace on WidgetTester { /// Open workspace menu Future openWorkspaceMenu() async { final workspaceWrapper = find.byType(SidebarSwitchWorkspaceButton); expect(workspaceWrapper, findsOneWidget); await tapButton(workspaceWrapper); final workspaceMenu = find.byType(WorkspacesMenu); expect(workspaceMenu, findsOneWidget); } /// Open a workspace Future openWorkspace(String name) async { final workspace = find.descendant( of: find.byType(WorkspaceMenuItem), matching: find.findTextInFlowyText(name), ); expect(workspace, findsOneWidget); await tapButton(workspace); } Future changeWorkspaceName(String name) async { final menuItem = find.byType(WorkspaceMenuItem); expect(menuItem, findsOneWidget); await hoverOnWidget( menuItem, onHover: () async { await tapButton( find.descendant( of: menuItem, matching: find.byType(WorkspaceMoreActionList), ), ); await tapButton(find.text(LocaleKeys.button_rename.tr())); final input = find.descendant( of: find.byType(AFTextFieldDialog), matching: find.byType(AFTextField), ); await enterText(input, name); await tapButton(find.text(LocaleKeys.button_confirm.tr())); }, ); } Future changeWorkspaceIcon(String icon) async { final iconButton = find.descendant( of: find.byType(WorkspaceMenuItem), matching: find.byType(WorkspaceIcon), ); expect(iconButton, findsOneWidget); await tapButton(iconButton); final iconPicker = find.byType(FlowyIconEmojiPicker); expect(iconPicker, findsOneWidget); await tapButton(find.findTextInFlowyText(icon)); } } ================================================ FILE: frontend/appflowy_flutter/ios/.gitignore ================================================ *.mode1v3 *.mode2v3 *.moved-aside *.pbxuser *.perspectivev3 **/*sync/ .sconsign.dblite .tags* **/.vagrant/ **/DerivedData/ Icon? **/Pods/ **/.symlinks/ profile xcuserdata **/.generated/ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !default.mode1v3 !default.mode2v3 !default.pbxuser !default.perspectivev3 ================================================ FILE: frontend/appflowy_flutter/ios/Flutter/AppFrameworkInfo.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable App CFBundleIdentifier io.flutter.flutter.app CFBundleInfoDictionaryVersion 6.0 CFBundleName App CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1.0 MinimumOSVersion 12.0 ================================================ FILE: frontend/appflowy_flutter/ios/Flutter/Debug.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/ios/Flutter/Release.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/ios/Podfile ================================================ # Uncomment this line to define a global platform for your project # platform :ios, '12.0' # 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', '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 Generated.xcconfig, then run flutter pub get" end require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) flutter_ios_podfile_setup target 'Runner' do use_frameworks! use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', # dart: PermissionGroup.photos 'PERMISSION_PHOTOS=1', ] end end installer.aggregate_targets.each do |target| target.xcconfigs.each do |variant, xcconfig| xcconfig_path = target.client_root + target.xcconfig_relative_path(variant) IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) end end installer.pods_project.targets.each do |target| target.build_configurations.each do |config| if config.base_configuration_reference.is_a? Xcodeproj::Project::Object::PBXFileReference xcconfig_path = config.base_configuration_reference.real_path IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) end end end end ================================================ FILE: frontend/appflowy_flutter/ios/Runner/AppDelegate.swift ================================================ import UIKit import Flutter @main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } ================================================ FILE: frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ {"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"}]} ================================================ FILE: frontend/appflowy_flutter/ios/Runner/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LaunchImage.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LaunchImage@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md ================================================ # Launch Screen Assets You can customize the launch screen with your own desired assets by replacing the image files in this directory. You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. ================================================ FILE: frontend/appflowy_flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: frontend/appflowy_flutter/ios/Runner/Base.lproj/Main.storyboard ================================================ ================================================ FILE: frontend/appflowy_flutter/ios/Runner/Info.plist ================================================ CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleLocalizations en CFBundleName AppFlowy CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleSignature ???? CFBundleURLTypes CFBundleURLName CFBundleURLSchemes appflowy-flutter CFBundleVersion $(FLUTTER_BUILD_NUMBER) FLTEnableImpeller LSRequiresIPhoneOS NSAppTransportSecurity NSAllowsArbitraryLoads NSPhotoLibraryUsageDescription AppFlowy needs access to your photos to let you add images to your documents NSPhotoLibraryAddUsageDescription AppFlowy needs access to your photos to let you add images to your photo library UIApplicationSupportsIndirectInputEvents NSCameraUsageDescription AppFlowy needs access to your camera to let you add images to your documents from camera UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UISupportedInterfaceOrientations~ipad UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UISupportsDocumentBrowser UIViewControllerBasedStatusBarAppearance ================================================ FILE: frontend/appflowy_flutter/ios/Runner/Runner-Bridging-Header.h ================================================ #import "GeneratedPluginRegistrant.h" ================================================ FILE: frontend/appflowy_flutter/ios/Runner/Runner.entitlements ================================================ aps-environment development com.apple.developer.applesignin Default com.apple.developer.associated-domains applinks:appflowy.com applinks:appflowy.io applinks:test.appflowy.com applinks:test.appflowy.io ================================================ FILE: frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; FB3C2A642AE0D57700490715 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 197F72694BED43249F1523E8 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 197F72694BED43249F1523E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 35DA03217F6DD4F7AC9356F9 /* 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 = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 4C2CB38DA64605A62D45B098 /* 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 = ""; }; 580A1ED8E012CA1552E5EFD3 /* 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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FBE00AD62AE8E46A006B563F /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( FB3C2A642AE0D57700490715 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 78844014EF958DCBB6F9B4EA /* Frameworks */ = { isa = PBXGroup; children = ( 197F72694BED43249F1523E8 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); name = Flutter; sourceTree = ""; }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 9EC83BEE9154F1BD11D24F8F /* Pods */, 78844014EF958DCBB6F9B4EA /* Frameworks */, ); sourceTree = ""; }; 97C146EF1CF9000F007C117D /* Products */ = { isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, ); name = Products; sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( FBE00AD62AE8E46A006B563F /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; 9EC83BEE9154F1BD11D24F8F /* Pods */ = { isa = PBXGroup; children = ( 35DA03217F6DD4F7AC9356F9 /* Pods-Runner.debug.xcconfig */, 580A1ED8E012CA1552E5EFD3 /* Pods-Runner.release.xcconfig */, 4C2CB38DA64605A62D45B098 /* Pods-Runner.profile.xcconfig */, ); path = Pods; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */, A548E58D5F4006A34D7DAA88 /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 08FAA63113168DEC7FB74204 /* [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; }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; A548E58D5F4006A34D7DAA88 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; E790B8FE5609053209ED85CB /* [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; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 97C146FB1CF9000F007C117D /* Base */, ); name = Main.storyboard; sourceTree = ""; }; 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 97C147001CF9000F007C117D /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Profile; }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = VHB67HRSZG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; STRIP_STYLE = "non-global"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; 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_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; 97C147061CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = VHB67HRSZG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; STRIP_STYLE = "non-global"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 97C147071CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = VHB67HRSZG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; STRIP_STYLE = "non-global"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147031CF9000F007C117D /* Debug */, 97C147041CF9000F007C117D /* Release */, 249021D3217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147061CF9000F007C117D /* Debug */, 97C147071CF9000F007C117D /* Release */, 249021D4217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } ================================================ FILE: frontend/appflowy_flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: frontend/appflowy_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: frontend/appflowy_flutter/ios/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: frontend/appflowy_flutter/lib/ai/ai.dart ================================================ export 'service/ai_entities.dart'; export 'service/ai_prompt_input_bloc.dart'; export 'service/ai_prompt_selector_cubit.dart'; export 'service/appflowy_ai_service.dart'; export 'service/error.dart'; export 'service/ai_model_state_notifier.dart'; export 'service/select_model_bloc.dart'; export 'service/view_selector_cubit.dart'; export 'widgets/loading_indicator.dart'; export 'widgets/view_selector.dart'; export 'widgets/prompt_input/action_buttons.dart'; export 'widgets/prompt_input/desktop_prompt_input.dart'; export 'widgets/prompt_input/file_attachment_list.dart'; export 'widgets/prompt_input/layout_define.dart'; export 'widgets/prompt_input/mention_page_bottom_sheet.dart'; export 'widgets/prompt_input/mention_page_menu.dart'; export 'widgets/prompt_input/prompt_input_text_controller.dart'; export 'widgets/prompt_input/predefined_format_buttons.dart'; export 'widgets/prompt_input/select_sources_bottom_sheet.dart'; export 'widgets/prompt_input/select_sources_menu.dart'; export 'widgets/prompt_input/select_model_menu.dart'; export 'widgets/prompt_input/send_button.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/ai/service/ai_entities.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/easy_localiation_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; part 'ai_entities.g.dart'; class AIStreamEventPrefix { static const data = 'data:'; static const error = 'error:'; static const metadata = 'metadata:'; static const start = 'start:'; static const finish = 'finish:'; static const comment = 'comment:'; static const aiResponseLimit = 'ai_response_limit:'; static const aiImageResponseLimit = 'ai_image_response_limit:'; static const aiMaxRequired = 'ai_max_required:'; static const localAINotReady = 'local_ai_not_ready:'; static const localAIDisabled = 'local_ai_disabled:'; static const aiFollowUp = 'ai_follow_up:'; } enum AiType { cloud, local; bool get isCloud => this == cloud; bool get isLocal => this == local; } class PredefinedFormat extends Equatable { const PredefinedFormat({ required this.imageFormat, required this.textFormat, }); final ImageFormat imageFormat; final TextFormat? textFormat; PredefinedFormatPB toPB() { return PredefinedFormatPB( imageFormat: switch (imageFormat) { ImageFormat.text => ResponseImageFormatPB.TextOnly, ImageFormat.image => ResponseImageFormatPB.ImageOnly, ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage, }, textFormat: switch (textFormat) { TextFormat.paragraph => ResponseTextFormatPB.Paragraph, TextFormat.bulletList => ResponseTextFormatPB.BulletedList, TextFormat.numberedList => ResponseTextFormatPB.NumberedList, TextFormat.table => ResponseTextFormatPB.Table, _ => null, }, ); } @override List get props => [imageFormat, textFormat]; } enum ImageFormat { text, image, textAndImage; bool get hasText => this == text || this == textAndImage; FlowySvgData get icon { return switch (this) { ImageFormat.text => FlowySvgs.ai_text_s, ImageFormat.image => FlowySvgs.ai_image_s, ImageFormat.textAndImage => FlowySvgs.ai_text_image_s, }; } String get i18n { return switch (this) { ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(), ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(), ImageFormat.textAndImage => LocaleKeys.chat_changeFormat_textAndImage.tr(), }; } } enum TextFormat { paragraph, bulletList, numberedList, table; FlowySvgData get icon { return switch (this) { TextFormat.paragraph => FlowySvgs.ai_paragraph_s, TextFormat.bulletList => FlowySvgs.ai_list_s, TextFormat.numberedList => FlowySvgs.ai_number_list_s, TextFormat.table => FlowySvgs.ai_table_s, }; } String get i18n { return switch (this) { TextFormat.paragraph => LocaleKeys.chat_changeFormat_text.tr(), TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(), TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(), TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(), }; } } enum AiPromptCategory { other, development, writing, healthAndFitness, business, marketing, travel, contentSeo, emailMarketing, paidAds, prCommunication, recruiting, sales, socialMedia, strategy, caseStudies, salesCopy, education, work, podcastProduction, copyWriting, customerSuccess; String get i18n => token.tr(); String get token { return switch (this) { other => LocaleKeys.ai_customPrompt_others, development => LocaleKeys.ai_customPrompt_development, writing => LocaleKeys.ai_customPrompt_writing, healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness, business => LocaleKeys.ai_customPrompt_business, marketing => LocaleKeys.ai_customPrompt_marketing, travel => LocaleKeys.ai_customPrompt_travel, contentSeo => LocaleKeys.ai_customPrompt_contentSeo, emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing, paidAds => LocaleKeys.ai_customPrompt_paidAds, prCommunication => LocaleKeys.ai_customPrompt_prCommunication, recruiting => LocaleKeys.ai_customPrompt_recruiting, sales => LocaleKeys.ai_customPrompt_sales, socialMedia => LocaleKeys.ai_customPrompt_socialMedia, strategy => LocaleKeys.ai_customPrompt_strategy, caseStudies => LocaleKeys.ai_customPrompt_caseStudies, salesCopy => LocaleKeys.ai_customPrompt_salesCopy, education => LocaleKeys.ai_customPrompt_education, work => LocaleKeys.ai_customPrompt_work, podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction, copyWriting => LocaleKeys.ai_customPrompt_copyWriting, customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess, }; } } @JsonSerializable() class AiPrompt extends Equatable { const AiPrompt({ required this.id, required this.name, required this.content, required this.category, required this.example, required this.isFeatured, required this.isCustom, }); factory AiPrompt.fromPB(CustomPromptPB pb) { final map = _buildCategoryNameMap(); final categories = pb.category .split(',') .map((categoryName) => categoryName.trim()) .map( (categoryName) { final entry = map.entries.firstWhereOrNull( (entry) => entry.value.$1 == categoryName || entry.value.$2 == categoryName, ); return entry?.key ?? AiPromptCategory.other; }, ) .toSet() .toList(); return AiPrompt( id: pb.id, name: pb.name, content: pb.content, category: categories, example: pb.example, isFeatured: false, isCustom: true, ); } factory AiPrompt.fromJson(Map json) => _$AiPromptFromJson(json); Map toJson() => _$AiPromptToJson(this); final String id; final String name; final String content; @JsonKey(fromJson: _categoryFromJson) final List category; @JsonKey(defaultValue: "") final String example; @JsonKey(defaultValue: false) final bool isFeatured; @JsonKey(defaultValue: false) final bool isCustom; @override List get props => [id, name, content, category, example, isFeatured, isCustom]; static Map _buildCategoryNameMap() { final service = getIt(); return { for (final category in AiPromptCategory.values) category: ( service.getFallbackTranslation(category.token), service.getFallbackTranslation(category.token), ), }; } static List _categoryFromJson(dynamic json) { if (json is String) { return json .split(',') .map((categoryName) => categoryName.trim()) .map( (categoryName) => $enumDecode( _aiPromptCategoryEnumMap, categoryName, unknownValue: AiPromptCategory.other, ), ) .toSet() .toList(); } return [AiPromptCategory.other]; } } const _aiPromptCategoryEnumMap = { AiPromptCategory.other: 'other', AiPromptCategory.development: 'development', AiPromptCategory.writing: 'writing', AiPromptCategory.healthAndFitness: 'healthAndFitness', AiPromptCategory.business: 'business', AiPromptCategory.marketing: 'marketing', AiPromptCategory.travel: 'travel', AiPromptCategory.contentSeo: 'contentSeo', AiPromptCategory.emailMarketing: 'emailMarketing', AiPromptCategory.paidAds: 'paidAds', AiPromptCategory.prCommunication: 'prCommunication', AiPromptCategory.recruiting: 'recruiting', AiPromptCategory.sales: 'sales', AiPromptCategory.socialMedia: 'socialMedia', AiPromptCategory.strategy: 'strategy', AiPromptCategory.caseStudies: 'caseStudies', AiPromptCategory.salesCopy: 'salesCopy', AiPromptCategory.education: 'education', AiPromptCategory.work: 'work', AiPromptCategory.podcastProduction: 'podcastProduction', AiPromptCategory.copyWriting: 'copyWriting', AiPromptCategory.customerSuccess: 'customerSuccess', }; class CustomPromptDatabaseConfig extends Equatable { const CustomPromptDatabaseConfig({ required this.view, required this.titleFieldId, required this.contentFieldId, required this.exampleFieldId, required this.categoryFieldId, }); factory CustomPromptDatabaseConfig.fromAiPB( CustomPromptDatabaseConfigurationPB pb, ViewPB view, ) { final config = CustomPromptDatabaseConfig( view: view, titleFieldId: pb.titleFieldId, contentFieldId: pb.contentFieldId, exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null, categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null, ); return config; } factory CustomPromptDatabaseConfig.fromDbPB( CustomPromptDatabaseConfigPB pb, ViewPB view, ) { final config = CustomPromptDatabaseConfig( view: view, titleFieldId: pb.titleFieldId, contentFieldId: pb.contentFieldId, exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null, categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null, ); return config; } final ViewPB view; final String titleFieldId; final String contentFieldId; final String? exampleFieldId; final String? categoryFieldId; @override List get props => [view.id, titleFieldId, contentFieldId, exampleFieldId, categoryFieldId]; CustomPromptDatabaseConfig copyWith({ ViewPB? view, String? titleFieldId, String? contentFieldId, String? exampleFieldId, String? categoryFieldId, }) { return CustomPromptDatabaseConfig( view: view ?? this.view, titleFieldId: titleFieldId ?? this.titleFieldId, contentFieldId: contentFieldId ?? this.contentFieldId, exampleFieldId: exampleFieldId ?? this.exampleFieldId, categoryFieldId: categoryFieldId ?? this.categoryFieldId, ); } CustomPromptDatabaseConfigurationPB toAiPB() { final payload = CustomPromptDatabaseConfigurationPB.create() ..viewId = view.id ..titleFieldId = titleFieldId ..contentFieldId = contentFieldId; if (exampleFieldId != null) { payload.exampleFieldId = exampleFieldId!; } if (categoryFieldId != null) { payload.categoryFieldId = categoryFieldId!; } return payload; } CustomPromptDatabaseConfigPB toDbPB() { final payload = CustomPromptDatabaseConfigPB.create() ..viewId = view.id ..titleFieldId = titleFieldId ..contentFieldId = contentFieldId; if (exampleFieldId != null) { payload.exampleFieldId = exampleFieldId!; } if (categoryFieldId != null) { payload.categoryFieldId = categoryFieldId!; } return payload; } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart'; import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:universal_platform/universal_platform.dart'; typedef OnModelStateChangedCallback = void Function(AIModelState state); typedef OnAvailableModelsChangedCallback = void Function( List, AIModelPB?, ); /// Represents the state of an AI model class AIModelState { const AIModelState({ required this.type, required this.hintText, required this.tooltip, required this.isEditable, required this.localAIEnabled, }); final AiType type; /// The text displayed as placeholder/hint in the input field /// Shows different messages based on AI state (enabled, initializing, disabled) final String hintText; /// Optional tooltip text that appears on hover /// Provides additional context about the current state of the AI /// Null when no tooltip should be shown final String? tooltip; final bool isEditable; final bool localAIEnabled; } class AIModelStateNotifier { AIModelStateNotifier({required this.objectId}) : _localAIListener = UniversalPlatform.isDesktop ? LocalAIStateListener() : null, _aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) { _startListening(); _init(); } final String objectId; final LocalAIStateListener? _localAIListener; final AIModelSwitchListener _aiModelSwitchListener; LocalAIPB? _localAIState; ModelSelectionPB? _modelSelection; AIModelState _currentState = _defaultState(); List _availableModels = []; AIModelPB? _selectedModel; final List _stateChangedCallbacks = []; final List _availableModelsChangedCallbacks = []; /// Starts platform-specific listeners void _startListening() { if (UniversalPlatform.isDesktop) { _localAIListener?.start( stateCallback: (state) async { _localAIState = state; _updateAll(); }, ); } _aiModelSwitchListener.start( onUpdateSelectedModel: (model) async { _selectedModel = model; _updateAll(); if (model.isLocal && UniversalPlatform.isDesktop) { await _loadLocalState(); _updateAll(); } }, ); } Future _init() async { await Future.wait([ if (UniversalPlatform.isDesktop) _loadLocalState(), _loadModelSelection(), ]); _updateAll(); } /// Register callbacks for state or available-models changes void addListener({ OnModelStateChangedCallback? onStateChanged, OnAvailableModelsChangedCallback? onAvailableModelsChanged, }) { if (onStateChanged != null) { _stateChangedCallbacks.add(onStateChanged); } if (onAvailableModelsChanged != null) { _availableModelsChangedCallbacks.add(onAvailableModelsChanged); } } /// Remove previously registered callbacks void removeListener({ OnModelStateChangedCallback? onStateChanged, OnAvailableModelsChangedCallback? onAvailableModelsChanged, }) { if (onStateChanged != null) { _stateChangedCallbacks.remove(onStateChanged); } if (onAvailableModelsChanged != null) { _availableModelsChangedCallbacks.remove(onAvailableModelsChanged); } } Future dispose() async { _stateChangedCallbacks.clear(); _availableModelsChangedCallbacks.clear(); await _localAIListener?.stop(); await _aiModelSwitchListener.stop(); } /// Returns current AIModelState AIModelState getState() => _currentState; /// Returns available models and the selected model (List, AIModelPB?) getModelSelection() => (_availableModels, _selectedModel); void _updateAll() { _currentState = _computeState(); for (final cb in _stateChangedCallbacks) { cb(_currentState); } for (final cb in _availableModelsChangedCallbacks) { cb(_availableModels, _selectedModel); } } Future _loadModelSelection() async { await AIEventGetSourceModelSelection( ModelSourcePB(source: objectId), ).send().fold( (ms) { _modelSelection = ms; _availableModels = ms.models; _selectedModel = ms.selectedModel; }, (e) => Log.error("Failed to fetch models: \$e"), ); } Future _loadLocalState() async { await AIEventGetLocalAIState().send().fold( (s) => _localAIState = s, (e) => Log.error("Failed to fetch local AI state: \$e"), ); } static AIModelState _defaultState() => AIModelState( type: AiType.cloud, hintText: LocaleKeys.chat_inputMessageHint.tr(), tooltip: null, isEditable: true, localAIEnabled: false, ); /// Core logic computing the state from local and selection data AIModelState _computeState() { if (UniversalPlatform.isMobile) return _defaultState(); if (_modelSelection == null || _localAIState == null) { return _defaultState(); } if (!_selectedModel!.isLocal) { return _defaultState(); } final enabled = _localAIState!.enabled; final running = _localAIState!.isReady; final hintKey = enabled ? (running ? LocaleKeys.chat_inputLocalAIMessageHint : LocaleKeys.settings_aiPage_keys_localAIInitializing) : LocaleKeys.settings_aiPage_keys_localAIDisabled; final tooltipKey = enabled ? (running ? null : LocaleKeys.settings_aiPage_keys_localAINotReadyTextFieldPrompt) : LocaleKeys.settings_aiPage_keys_localAIDisabledTextFieldPrompt; return AIModelState( type: AiType.local, hintText: hintKey.tr(), tooltip: tooltipKey?.tr(), isEditable: running, localAIEnabled: enabled, ); } } extension AIModelPBExtension on AIModelPB { bool get isDefault => name == 'Auto'; String get i18n => isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name; } ================================================ FILE: frontend/appflowy_flutter/lib/ai/service/ai_prompt_database_selector_cubit.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'ai_prompt_database_selector_cubit.freezed.dart'; class AiPromptDatabaseSelectorCubit extends Cubit { AiPromptDatabaseSelectorCubit({ required CustomPromptDatabaseConfig? configuration, }) : super(AiPromptDatabaseSelectorState.loading()) { _init(configuration); } void _init(CustomPromptDatabaseConfig? config) async { if (config == null) { emit(AiPromptDatabaseSelectorState.empty()); return; } final fields = await _getFields(config.view.id); if (fields == null) { emit(AiPromptDatabaseSelectorState.empty()); return; } emit( AiPromptDatabaseSelectorState.selected( config: config, fields: fields, ), ); } void selectDatabaseView(String viewId) async { final configuration = await _testDatabase(viewId); if (configuration == null) { final stateCopy = state; emit(AiPromptDatabaseSelectorState.invalidDatabase()); emit(stateCopy); return; } final databaseView = await AiPromptSelectorCubit.getDatabaseView(viewId); final fields = await _getFields(viewId); if (databaseView == null || fields == null) { final stateCopy = state; emit(AiPromptDatabaseSelectorState.invalidDatabase()); emit(stateCopy); return; } final config = CustomPromptDatabaseConfig.fromDbPB( configuration, databaseView, ); emit( AiPromptDatabaseSelectorState.selected( config: config, fields: fields, ), ); } void selectContentField(String fieldId) { final state = this.state; if (state is! _Selected) { return; } final config = state.config.copyWith( contentFieldId: fieldId, ); emit( AiPromptDatabaseSelectorState.selected( config: config, fields: state.fields, ), ); } void selectExampleField(String? fieldId) { final state = this.state; if (state is! _Selected) { return; } final config = CustomPromptDatabaseConfig( exampleFieldId: fieldId, view: state.config.view, titleFieldId: state.config.titleFieldId, contentFieldId: state.config.contentFieldId, categoryFieldId: state.config.categoryFieldId, ); emit( AiPromptDatabaseSelectorState.selected( config: config, fields: state.fields, ), ); } void selectCategoryField(String? fieldId) { final state = this.state; if (state is! _Selected) { return; } final config = CustomPromptDatabaseConfig( categoryFieldId: fieldId, view: state.config.view, titleFieldId: state.config.titleFieldId, contentFieldId: state.config.contentFieldId, exampleFieldId: state.config.exampleFieldId, ); emit( AiPromptDatabaseSelectorState.selected( config: config, fields: state.fields, ), ); } Future?> _getFields(String viewId) { return FieldBackendService.getFields(viewId: viewId).toNullable(); } Future _testDatabase( String viewId, ) { return DatabaseEventTestCustomPromptDatabaseConfiguration( DatabaseViewIdPB(value: viewId), ).send().toNullable(); } } @freezed class AiPromptDatabaseSelectorState with _$AiPromptDatabaseSelectorState { const factory AiPromptDatabaseSelectorState.loading() = _Loading; const factory AiPromptDatabaseSelectorState.empty() = _Empty; const factory AiPromptDatabaseSelectorState.selected({ required CustomPromptDatabaseConfig config, required List fields, }) = _Selected; const factory AiPromptDatabaseSelectorState.invalidDatabase() = _InvalidDatabase; } ================================================ FILE: frontend/appflowy_flutter/lib/ai/service/ai_prompt_input_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/service/ai_model_state_notifier.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'ai_entities.dart'; part 'ai_prompt_input_bloc.freezed.dart'; class AIPromptInputBloc extends Bloc { AIPromptInputBloc({ required String objectId, required PredefinedFormat? predefinedFormat, }) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId), super(AIPromptInputState.initial(predefinedFormat)) { _dispatch(); _startListening(); _init(); } final AIModelStateNotifier aiModelStateNotifier; String? promptId; @override Future close() async { await aiModelStateNotifier.dispose(); return super.close(); } void _dispatch() { on( (event, emit) { event.when( updateAIState: (modelState) { emit( state.copyWith( modelState: modelState, ), ); }, toggleShowPredefinedFormat: () { final showPredefinedFormats = !state.showPredefinedFormats; final predefinedFormat = showPredefinedFormats && state.predefinedFormat == null ? PredefinedFormat( imageFormat: ImageFormat.text, textFormat: TextFormat.paragraph, ) : null; emit( state.copyWith( showPredefinedFormats: showPredefinedFormats, predefinedFormat: predefinedFormat, ), ); }, updatePredefinedFormat: (format) { if (!state.showPredefinedFormats) { return; } emit(state.copyWith(predefinedFormat: format)); }, attachFile: (filePath, fileName) { final newFile = ChatFile.fromFilePath(filePath); if (newFile != null) { emit( state.copyWith( attachedFiles: [...state.attachedFiles, newFile], ), ); } }, removeFile: (file) { final files = [...state.attachedFiles]; files.remove(file); emit( state.copyWith( attachedFiles: files, ), ); }, updateMentionedViews: (views) { emit( state.copyWith( mentionedPages: views, ), ); }, updatePromptId: (promptId) { this.promptId = promptId; }, clearMetadata: () { promptId = null; emit( state.copyWith( attachedFiles: [], mentionedPages: [], ), ); }, ); }, ); } void _startListening() { aiModelStateNotifier.addListener( onStateChanged: (modelState) { add( AIPromptInputEvent.updateAIState(modelState), ); }, ); } void _init() { final modelState = aiModelStateNotifier.getState(); add( AIPromptInputEvent.updateAIState(modelState), ); } Map consumeMetadata() { final metadata = { for (final file in state.attachedFiles) file.filePath: file, for (final page in state.mentionedPages) page.id: page, }; if (metadata.isNotEmpty && !isClosed) { add(const AIPromptInputEvent.clearMetadata()); } return metadata; } } @freezed class AIPromptInputEvent with _$AIPromptInputEvent { const factory AIPromptInputEvent.updateAIState( AIModelState modelState, ) = _UpdateAIState; const factory AIPromptInputEvent.toggleShowPredefinedFormat() = _ToggleShowPredefinedFormat; const factory AIPromptInputEvent.updatePredefinedFormat( PredefinedFormat format, ) = _UpdatePredefinedFormat; const factory AIPromptInputEvent.attachFile( String filePath, String fileName, ) = _AttachFile; const factory AIPromptInputEvent.removeFile(ChatFile file) = _RemoveFile; const factory AIPromptInputEvent.updateMentionedViews(List views) = _UpdateMentionedViews; const factory AIPromptInputEvent.clearMetadata() = _ClearMetadata; const factory AIPromptInputEvent.updatePromptId(String promptId) = _UpdatePromptId; } @freezed class AIPromptInputState with _$AIPromptInputState { const factory AIPromptInputState({ required AIModelState modelState, required bool supportChatWithFile, required bool showPredefinedFormats, required PredefinedFormat? predefinedFormat, required List attachedFiles, required List mentionedPages, }) = _AIPromptInputState; factory AIPromptInputState.initial(PredefinedFormat? format) => AIPromptInputState( modelState: AIModelState( type: AiType.cloud, isEditable: true, hintText: '', localAIEnabled: false, tooltip: null, ), supportChatWithFile: false, showPredefinedFormats: format != null, predefinedFormat: format, attachedFiles: [], mentionedPages: [], ); } ================================================ FILE: frontend/appflowy_flutter/lib/ai/service/ai_prompt_selector_cubit.dart ================================================ import 'dart:async'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../plugins/trash/application/trash_service.dart'; import 'ai_entities.dart'; import 'appflowy_ai_service.dart'; part 'ai_prompt_selector_cubit.freezed.dart'; class AiPromptSelectorCubit extends Cubit { AiPromptSelectorCubit({ AppFlowyAIService? aiService, }) : _aiService = aiService ?? AppFlowyAIService(), super(AiPromptSelectorState.loading()) { filterTextController.addListener(_filterTextChanged); _init(); } final AppFlowyAIService _aiService; final filterTextController = TextEditingController(); final List availablePrompts = []; @override Future close() async { filterTextController.dispose(); await super.close(); } void _init() async { availablePrompts.addAll(await _aiService.getBuiltInPrompts()); final featuredPrompts = availablePrompts.where((prompt) => prompt.isFeatured); final visiblePrompts = _getFilteredPrompts(featuredPrompts); emit( AiPromptSelectorState.ready( visiblePrompts: visiblePrompts.toList(), isCustomPromptSectionSelected: false, isFeaturedSectionSelected: true, selectedPromptId: visiblePrompts.firstOrNull?.id, databaseConfig: null, isLoadingCustomPrompts: true, selectedCategory: null, favoritePrompts: [], ), ); loadCustomPrompts(); } void loadCustomPrompts() { state.maybeMap( ready: (readyState) async { emit( readyState.copyWith(isLoadingCustomPrompts: true), ); CustomPromptDatabaseConfig? configuration = readyState.databaseConfig; if (configuration == null) { final configResult = await AIEventGetCustomPromptDatabaseConfiguration() .send() .toNullable(); if (configResult != null) { final view = await getDatabaseView(configResult.viewId); if (view != null) { configuration = CustomPromptDatabaseConfig.fromAiPB( configResult, view, ); } } } else { final view = await getDatabaseView(configuration.view.id); if (view != null) { configuration = configuration.copyWith(view: view); } } if (configuration == null) { emit( readyState.copyWith(isLoadingCustomPrompts: false), ); return; } availablePrompts.removeWhere((prompt) => prompt.isCustom); final customPrompts = await _aiService.getDatabasePrompts(configuration.toDbPB()); if (customPrompts == null) { final prompts = availablePrompts.where((prompt) => prompt.isFeatured); final visiblePrompts = _getFilteredPrompts(prompts); final selectedPromptId = _getVisibleSelectedPrompt( visiblePrompts, readyState.selectedPromptId, ); emit( readyState.copyWith( visiblePrompts: visiblePrompts.toList(), selectedPromptId: selectedPromptId, databaseConfig: configuration, isLoadingCustomPrompts: false, isFeaturedSectionSelected: true, isCustomPromptSectionSelected: false, selectedCategory: null, ), ); } else { availablePrompts.addAll(customPrompts); final prompts = _getPromptsByCategory(readyState); final visiblePrompts = _getFilteredPrompts(prompts); final selectedPromptId = _getVisibleSelectedPrompt( visiblePrompts, readyState.selectedPromptId, ); emit( readyState.copyWith( visiblePrompts: visiblePrompts.toList(), databaseConfig: configuration, isLoadingCustomPrompts: false, selectedPromptId: selectedPromptId, ), ); } }, orElse: () {}, ); } void selectCustomSection() { state.maybeMap( ready: (readyState) { final prompts = availablePrompts.where((prompt) => prompt.isCustom); final visiblePrompts = _getFilteredPrompts(prompts); emit( readyState.copyWith( visiblePrompts: visiblePrompts.toList(), selectedPromptId: visiblePrompts.firstOrNull?.id, isCustomPromptSectionSelected: true, isFeaturedSectionSelected: false, selectedCategory: null, ), ); }, orElse: () {}, ); } void selectFeaturedSection() { state.maybeMap( ready: (readyState) { final prompts = availablePrompts.where((prompt) => prompt.isFeatured); final visiblePrompts = _getFilteredPrompts(prompts); emit( readyState.copyWith( visiblePrompts: visiblePrompts.toList(), selectedPromptId: visiblePrompts.firstOrNull?.id, isFeaturedSectionSelected: true, isCustomPromptSectionSelected: false, selectedCategory: null, ), ); }, orElse: () {}, ); } void selectCategory(AiPromptCategory? category) { state.maybeMap( ready: (readyState) { final prompts = category == null ? availablePrompts : availablePrompts .where((prompt) => prompt.category.contains(category)); final visiblePrompts = _getFilteredPrompts(prompts); final selectedPromptId = _getVisibleSelectedPrompt( visiblePrompts, readyState.selectedPromptId, ); emit( readyState.copyWith( visiblePrompts: visiblePrompts.toList(), selectedCategory: category, selectedPromptId: selectedPromptId, isFeaturedSectionSelected: false, isCustomPromptSectionSelected: false, ), ); }, orElse: () {}, ); } void selectPrompt(String promptId) { state.maybeMap( ready: (readyState) { final selectedPrompt = readyState.visiblePrompts .firstWhereOrNull((prompt) => prompt.id == promptId); if (selectedPrompt != null) { emit( readyState.copyWith(selectedPromptId: selectedPrompt.id), ); } }, orElse: () {}, ); } void toggleFavorite(String promptId) { state.maybeMap( ready: (readyState) { final favoritePrompts = [...readyState.favoritePrompts]; if (favoritePrompts.contains(promptId)) { favoritePrompts.remove(promptId); } else { favoritePrompts.add(promptId); } emit( readyState.copyWith(favoritePrompts: favoritePrompts), ); }, orElse: () {}, ); } void reset() { filterTextController.clear(); state.maybeMap( ready: (readyState) { emit( readyState.copyWith( visiblePrompts: availablePrompts, isCustomPromptSectionSelected: false, isFeaturedSectionSelected: true, selectedPromptId: availablePrompts.firstOrNull?.id, selectedCategory: null, ), ); }, orElse: () {}, ); } void updateCustomPromptDatabaseConfiguration( CustomPromptDatabaseConfig configuration, ) async { state.maybeMap( ready: (readyState) async { emit( readyState.copyWith(isLoadingCustomPrompts: true), ); final customPrompts = await _aiService.getDatabasePrompts(configuration.toDbPB()); if (customPrompts == null) { emit(AiPromptSelectorState.invalidDatabase()); emit(readyState); return; } availablePrompts ..removeWhere((prompt) => prompt.isCustom) ..addAll(customPrompts); await AIEventSetCustomPromptDatabaseConfiguration( configuration.toAiPB(), ).send().onFailure(Log.error); final prompts = _getPromptsByCategory(readyState); final visiblePrompts = _getFilteredPrompts(prompts); final selectedPromptId = _getVisibleSelectedPrompt( visiblePrompts, readyState.selectedPromptId, ); emit( readyState.copyWith( visiblePrompts: visiblePrompts.toList(), selectedPromptId: selectedPromptId, databaseConfig: configuration, isLoadingCustomPrompts: false, ), ); }, orElse: () => {}, ); } void _filterTextChanged() { state.maybeMap( ready: (readyState) { final prompts = _getPromptsByCategory(readyState); final visiblePrompts = _getFilteredPrompts(prompts); final selectedPromptId = _getVisibleSelectedPrompt( visiblePrompts, readyState.selectedPromptId, ); emit( readyState.copyWith( visiblePrompts: visiblePrompts.toList(), selectedPromptId: selectedPromptId, ), ); }, orElse: () {}, ); } Iterable _getFilteredPrompts(Iterable prompts) { final filterText = filterTextController.value.text.trim().toLowerCase(); return prompts.where((prompt) { final content = "${prompt.name} ${prompt.name}".toLowerCase(); return content.contains(filterText); }).toList(); } Iterable _getPromptsByCategory(_AiPromptSelectorReadyState state) { return availablePrompts.where((prompt) { if (state.selectedCategory != null) { return prompt.category.contains(state.selectedCategory); } if (state.isFeaturedSectionSelected) { return prompt.isFeatured; } if (state.isCustomPromptSectionSelected) { return prompt.isCustom; } return true; }); } String? _getVisibleSelectedPrompt( Iterable visiblePrompts, String? currentlySelectedPromptId, ) { if (visiblePrompts .any((prompt) => prompt.id == currentlySelectedPromptId)) { return currentlySelectedPromptId; } return visiblePrompts.firstOrNull?.id; } static Future getDatabaseView(String viewId) async { final view = await ViewBackendService.getView(viewId).toNullable(); if (view != null) { return view; } final trashViews = await TrashService().readTrash().toNullable(); final trashedItem = trashViews?.items.firstWhereOrNull((element) => element.id == viewId); if (trashedItem == null) { return null; } return ViewPB() ..id = trashedItem.id ..name = trashedItem.name; } } @freezed class AiPromptSelectorState with _$AiPromptSelectorState { const AiPromptSelectorState._(); const factory AiPromptSelectorState.loading() = _AiPromptSelectorLoadingState; const factory AiPromptSelectorState.invalidDatabase() = _AiPromptSelectorErrorState; const factory AiPromptSelectorState.ready({ required List visiblePrompts, required List favoritePrompts, required bool isCustomPromptSectionSelected, required bool isFeaturedSectionSelected, required AiPromptCategory? selectedCategory, required String? selectedPromptId, required bool isLoadingCustomPrompts, required CustomPromptDatabaseConfig? databaseConfig, }) = _AiPromptSelectorReadyState; bool get isLoading => this is _AiPromptSelectorLoadingState; bool get isReady => this is _AiPromptSelectorReadyState; AiPrompt? get selectedPrompt => maybeMap( ready: (state) => state.visiblePrompts .firstWhereOrNull((prompt) => prompt.id == state.selectedPromptId), orElse: () => null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; import 'dart:isolate'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart' hide CustomPromptDatabaseConfigurationPB; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart' as fixnum; import 'package:flutter/services.dart'; import 'ai_entities.dart'; import 'error.dart'; enum LocalAIStreamingState { notReady, disabled, } abstract class AIRepository { Future<(String, CompletionStream)?> streamCompletion({ String? objectId, required String text, PredefinedFormat? format, String? promptId, List sourceIds = const [], List history = const [], required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, required void Function(LocalAIStreamingState state) onLocalAIStreamingStateChange, }); Future> getBuiltInPrompts(); Future?> getDatabasePrompts( CustomPromptDatabaseConfigPB config, ); void updateFavoritePrompts(List promptIds); } class AppFlowyAIService implements AIRepository { @override Future<(String, CompletionStream)?> streamCompletion({ String? objectId, required String text, PredefinedFormat? format, String? promptId, List sourceIds = const [], List history = const [], required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, required void Function(LocalAIStreamingState state) onLocalAIStreamingStateChange, }) async { final stream = AppFlowyCompletionStream( onStart: onStart, processMessage: processMessage, processAssistMessage: processAssistMessage, processError: onError, onLocalAIStreamingStateChange: onLocalAIStreamingStateChange, onEnd: onEnd, ); final records = history.map((record) => record.toPB()).toList(); final payload = CompleteTextPB( text: text, completionType: completionType, format: format?.toPB(), promptId: promptId, streamPort: fixnum.Int64(stream.nativePort), objectId: objectId ?? '', ragIds: [ if (objectId != null) objectId, ...sourceIds, ].unique(), history: records, ); return AIEventCompleteText(payload).send().fold( (task) => (task.taskId, stream), (error) { Log.error(error); return null; }, ); } @override Future> getBuiltInPrompts() async { final prompts = []; try { final jsonString = await rootBundle.loadString('assets/built_in_prompts.json'); // final data = await rootBundle.load('assets/built_in_prompts.json'); // final jsonString = utf8.decode(data.buffer.asUint8List()); final jsonData = json.decode(jsonString) as Map; final promptJson = jsonData['prompts'] as List; prompts.addAll( promptJson .map((e) => AiPrompt.fromJson(e as Map)) .toList(), ); } catch (e) { Log.error(e); } return prompts; } @override Future?> getDatabasePrompts( CustomPromptDatabaseConfigPB config, ) async { return DatabaseEventGetDatabaseCustomPrompts(config).send().fold( (databasePromptsPB) => databasePromptsPB.items.map(AiPrompt.fromPB).toList(), (err) { Log.error(err); return null; }, ); } @override void updateFavoritePrompts(List promptIds) {} } abstract class CompletionStream { CompletionStream({ required this.onStart, required this.processMessage, required this.processAssistMessage, required this.processError, required this.onLocalAIStreamingStateChange, required this.onEnd, }); final Future Function() onStart; final Future Function(String text) processMessage; final Future Function(String text) processAssistMessage; final void Function(AIError error) processError; final void Function(LocalAIStreamingState state) onLocalAIStreamingStateChange; final Future Function() onEnd; } class AppFlowyCompletionStream extends CompletionStream { AppFlowyCompletionStream({ required super.onStart, required super.processMessage, required super.processAssistMessage, required super.processError, required super.onEnd, required super.onLocalAIStreamingStateChange, }) { _startListening(); } final RawReceivePort _port = RawReceivePort(); final StreamController _controller = StreamController.broadcast(); late StreamSubscription _subscription; int get nativePort => _port.sendPort.nativePort; void _startListening() { _port.handler = _controller.add; _subscription = _controller.stream.listen( (event) async { await _handleEvent(event); }, ); } Future dispose() async { await _controller.close(); await _subscription.cancel(); _port.close(); } Future _handleEvent(String event) async { // Check simple matches first if (event == AIStreamEventPrefix.aiResponseLimit) { processError( AIError( message: LocaleKeys.ai_textLimitReachedDescription.tr(), code: AIErrorCode.aiResponseLimitExceeded, ), ); return; } if (event == AIStreamEventPrefix.aiImageResponseLimit) { processError( AIError( message: LocaleKeys.ai_imageLimitReachedDescription.tr(), code: AIErrorCode.aiImageResponseLimitExceeded, ), ); return; } // Otherwise, parse out prefix:content if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) { processError( AIError( message: event.substring(AIStreamEventPrefix.aiMaxRequired.length), code: AIErrorCode.other, ), ); } else if (event.startsWith(AIStreamEventPrefix.start)) { await onStart(); } else if (event.startsWith(AIStreamEventPrefix.data)) { await processMessage( event.substring(AIStreamEventPrefix.data.length), ); } else if (event.startsWith(AIStreamEventPrefix.comment)) { await processAssistMessage( event.substring(AIStreamEventPrefix.comment.length), ); } else if (event.startsWith(AIStreamEventPrefix.finish)) { await onEnd(); } else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) { onLocalAIStreamingStateChange( LocalAIStreamingState.disabled, ); } else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) { onLocalAIStreamingStateChange( LocalAIStreamingState.notReady, ); } else if (event.startsWith(AIStreamEventPrefix.error)) { processError( AIError( message: event.substring(AIStreamEventPrefix.error.length), code: AIErrorCode.other, ), ); } else { Log.debug('Unknown AI event: $event'); } } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/service/error.dart ================================================ import 'package:freezed_annotation/freezed_annotation.dart'; part 'error.freezed.dart'; part 'error.g.dart'; @freezed class AIError with _$AIError { const factory AIError({ required String message, required AIErrorCode code, }) = _AIError; factory AIError.fromJson(Map json) => _$AIErrorFromJson(json); } enum AIErrorCode { @JsonValue('AIResponseLimitExceeded') aiResponseLimitExceeded, @JsonValue('AIImageResponseLimitExceeded') aiImageResponseLimitExceeded, @JsonValue('Other') other, } extension AIErrorExtension on AIError { bool get isLimitExceeded => code == AIErrorCode.aiResponseLimitExceeded; } ================================================ FILE: frontend/appflowy_flutter/lib/ai/service/select_model_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/service/ai_model_state_notifier.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'select_model_bloc.freezed.dart'; class SelectModelBloc extends Bloc { SelectModelBloc({ required AIModelStateNotifier aiModelStateNotifier, }) : _aiModelStateNotifier = aiModelStateNotifier, super(SelectModelState.initial(aiModelStateNotifier)) { on( (event, emit) { event.when( selectModel: (model) { AIEventUpdateSelectedModel( UpdateSelectedModelPB( source: _aiModelStateNotifier.objectId, selectedModel: model, ), ).send(); emit(state.copyWith(selectedModel: model)); }, didLoadModels: (models, selectedModel) { emit( SelectModelState( models: models, selectedModel: selectedModel, ), ); }, ); }, ); _aiModelStateNotifier.addListener( onAvailableModelsChanged: _onAvailableModelsChanged, ); } final AIModelStateNotifier _aiModelStateNotifier; @override Future close() async { _aiModelStateNotifier.removeListener( onAvailableModelsChanged: _onAvailableModelsChanged, ); await super.close(); } void _onAvailableModelsChanged( List models, AIModelPB? selectedModel, ) { if (!isClosed) { add(SelectModelEvent.didLoadModels(models, selectedModel)); } } } @freezed class SelectModelEvent with _$SelectModelEvent { const factory SelectModelEvent.selectModel( AIModelPB model, ) = _SelectModel; const factory SelectModelEvent.didLoadModels( List models, AIModelPB? selectedModel, ) = _DidLoadModels; } @freezed class SelectModelState with _$SelectModelState { const factory SelectModelState({ required List models, required AIModelPB? selectedModel, }) = _SelectModelState; factory SelectModelState.initial(AIModelStateNotifier notifier) { final (models, selectedModel) = notifier.getModelSelection(); return SelectModelState( models: models, selectedModel: selectedModel, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/service/view_selector_cubit.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'view_selector_cubit.freezed.dart'; enum ViewSelectedStatus { unselected, selected, partiallySelected; bool get isUnselected => this == unselected; bool get isSelected => this == selected; bool get isPartiallySelected => this == partiallySelected; } class ViewSelectorItem { ViewSelectorItem({ required this.view, required this.parentView, required this.children, required bool isExpanded, required ViewSelectedStatus selectedStatus, required bool isDisabled, }) : isExpandedNotifier = ValueNotifier(isExpanded), selectedStatusNotifier = ValueNotifier(selectedStatus), isDisabledNotifier = ValueNotifier(isDisabled); final ViewPB view; final ViewPB? parentView; final List children; final ValueNotifier isExpandedNotifier; final ValueNotifier isDisabledNotifier; final ValueNotifier selectedStatusNotifier; bool get isExpanded => isExpandedNotifier.value; ViewSelectedStatus get selectedStatus => selectedStatusNotifier.value; bool get isDisabled => isDisabledNotifier.value; void toggleIsExpanded() { isExpandedNotifier.value = !isExpandedNotifier.value; } ViewSelectorItem copy() { return ViewSelectorItem( view: view, parentView: parentView, children: children.map((child) => child.copy()).toList(), isDisabled: isDisabledNotifier.value, isExpanded: isExpandedNotifier.value, selectedStatus: selectedStatusNotifier.value, ); } ViewSelectorItem? findChildBySourceId(String sourceId) { if (view.id == sourceId) { return this; } for (final child in children) { final childResult = child.findChildBySourceId(sourceId); if (childResult != null) { return childResult; } } return null; } void setIsDisabledRecursive(bool Function(ViewSelectorItem) newIsDisabled) { isDisabledNotifier.value = newIsDisabled(this); for (final child in children) { child.setIsDisabledRecursive(newIsDisabled); } } void setIsSelectedStatusRecursive(ViewSelectedStatus selectedStatus) { selectedStatusNotifier.value = selectedStatus; for (final child in children) { child.setIsSelectedStatusRecursive(selectedStatus); } } void dispose() { for (final child in children) { child.dispose(); } isExpandedNotifier.dispose(); selectedStatusNotifier.dispose(); isDisabledNotifier.dispose(); } } class ViewSelectorCubit extends Cubit { ViewSelectorCubit({ required this.getIgnoreViewType, this.maxSelectedParentPageCount, }) : super(ViewSelectorState.initial()) { filterTextController.addListener(onFilterChanged); } final IgnoreViewType Function(ViewSelectorItem) getIgnoreViewType; final int? maxSelectedParentPageCount; final List selectedSourceIds = []; final List sources = []; final List selectedSources = []; final filterTextController = TextEditingController(); void updateSelectedSources(List newSelectedSourceIds) { selectedSourceIds.clear(); selectedSourceIds.addAll(newSelectedSourceIds); } Future refreshSources( List spaceViews, ViewPB? currentSpace, ) async { filterTextController.clear(); final newSources = await Future.wait( spaceViews.map((view) => _recursiveBuild(view, null)), ); _setIsDisabledAndHideIfNecessary(newSources); _restrictSelectionIfNecessary(newSources); if (currentSpace != null) { newSources .firstWhereOrNull((e) => e.view.id == currentSpace.id) ?.toggleIsExpanded(); } final selected = newSources .map((source) => _buildSelectedSources(source)) .flattened .toList(); emit( state.copyWith( selectedSources: selected, visibleSources: newSources, ), ); sources ..forEach((e) => e.dispose()) ..clear() ..addAll(newSources.map((e) => e.copy())); selectedSources ..forEach((e) => e.dispose()) ..clear() ..addAll(selected.map((e) => e.copy())); } Future _recursiveBuild( ViewPB view, ViewPB? parentView, ) async { ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected; final isThisSourceSelected = selectedSourceIds.contains(view.id); final List? childrenViews; if (integrationMode().isTest) { childrenViews = view.childViews; } else { childrenViews = await ViewBackendService.getChildViews(viewId: view.id).toNullable(); } int selectedCount = 0; final children = []; if (childrenViews != null) { for (final childView in childrenViews) { final childItem = await _recursiveBuild(childView, view); if (childItem.selectedStatus.isSelected) { selectedCount++; } children.add(childItem); } final areAllChildrenSelectedOrNoChildren = children.length == selectedCount; final isAnyChildNotUnselected = children.any((e) => !e.selectedStatus.isUnselected); if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) { selectedStatus = ViewSelectedStatus.selected; } else if (isThisSourceSelected || isAnyChildNotUnselected) { selectedStatus = ViewSelectedStatus.partiallySelected; } } else if (isThisSourceSelected) { selectedStatus = ViewSelectedStatus.selected; } return ViewSelectorItem( view: view, parentView: parentView, children: children, isDisabled: false, isExpanded: false, selectedStatus: selectedStatus, ); } void _setIsDisabledAndHideIfNecessary( List sources, ) { sources.retainWhere((source) { final ignoreViewType = getIgnoreViewType(source); return ignoreViewType != IgnoreViewType.hide; }); for (final source in sources) { source.isDisabledNotifier.value = getIgnoreViewType(source) == IgnoreViewType.disable; _setIsDisabledAndHideIfNecessary(source.children); } } void _restrictSelectionIfNecessary(List sources) { if (maxSelectedParentPageCount == null) { return; } for (final source in sources) { source.setIsDisabledRecursive((view) { return getIgnoreViewType(view) == IgnoreViewType.disable; }); } if (sources.where((e) => !e.selectedStatus.isUnselected).length >= maxSelectedParentPageCount!) { sources .where((e) => e.selectedStatus == ViewSelectedStatus.unselected) .forEach( (e) => e.setIsDisabledRecursive((_) => true), ); } } void onFilterChanged() { for (final source in state.visibleSources) { source.dispose(); } if (sources.isEmpty) { emit(ViewSelectorState.initial()); } else { final selected = selectedSources.map(_buildSearchResults).nonNulls.toList(); final visible = sources.map(_buildSearchResults).nonNulls.nonNulls.toList(); emit( state.copyWith( selectedSources: selected, visibleSources: visible, ), ); } } /// traverse tree to build up search query ViewSelectorItem? _buildSearchResults(ViewSelectorItem item) { final isVisible = item.view.nameOrDefault .toLowerCase() .contains(filterTextController.text.toLowerCase()); final childrenResults = []; for (final childSource in item.children) { final childResult = _buildSearchResults(childSource); if (childResult != null) { childrenResults.add(childResult); } } return isVisible || childrenResults.isNotEmpty ? ViewSelectorItem( view: item.view, parentView: item.parentView, children: childrenResults, isDisabled: item.isDisabled, isExpanded: item.isExpanded, selectedStatus: item.selectedStatus, ) : null; } /// traverse tree to build up selected sources Iterable _buildSelectedSources( ViewSelectorItem item, ) { final children = []; for (final childSource in item.children) { children.addAll(_buildSelectedSources(childSource)); } return selectedSourceIds.contains(item.view.id) ? [ ViewSelectorItem( view: item.view, parentView: item.parentView, children: children, isDisabled: item.isDisabled, selectedStatus: item.selectedStatus, isExpanded: true, ), ] : children; } void toggleSelectedStatus(ViewSelectorItem item, bool isSelectedSection) { if (item.view.isSpace) { return; } final allIds = _recursiveGetSourceIds(item); if (item.selectedStatus.isUnselected || item.selectedStatus.isPartiallySelected && !item.view.layout.isDocumentView) { for (final id in allIds) { if (!selectedSourceIds.contains(id)) { selectedSourceIds.add(id); } } } else { for (final id in allIds) { if (selectedSourceIds.contains(id)) { selectedSourceIds.remove(id); } } } if (isSelectedSection) { item.setIsSelectedStatusRecursive( item.selectedStatus.isUnselected || item.selectedStatus.isPartiallySelected ? ViewSelectedStatus.selected : ViewSelectedStatus.unselected, ); } updateSelectedStatus(); } List _recursiveGetSourceIds(ViewSelectorItem item) { return [ if (item.view.layout.isDocumentView) item.view.id, for (final childSource in item.children) ..._recursiveGetSourceIds(childSource), ]; } void updateSelectedStatus() { if (sources.isEmpty) { return; } for (final source in sources) { _recursiveUpdateSelectedStatus(source); } _restrictSelectionIfNecessary(sources); for (final visibleSource in state.visibleSources) { visibleSource.dispose(); } final visible = sources.map(_buildSearchResults).nonNulls.toList(); emit( state.copyWith( visibleSources: visible, ), ); } ViewSelectedStatus _recursiveUpdateSelectedStatus(ViewSelectorItem item) { ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected; int selectedCount = 0; for (final childSource in item.children) { final childStatus = _recursiveUpdateSelectedStatus(childSource); if (childStatus.isSelected) { selectedCount++; } } final isThisSourceSelected = selectedSourceIds.contains(item.view.id); final areAllChildrenSelectedOrNoChildren = item.children.length == selectedCount; final isAnyChildNotUnselected = item.children.any((e) => !e.selectedStatus.isUnselected); if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) { selectedStatus = ViewSelectedStatus.selected; } else if (isThisSourceSelected || isAnyChildNotUnselected) { selectedStatus = ViewSelectedStatus.partiallySelected; } item.selectedStatusNotifier.value = selectedStatus; return selectedStatus; } void toggleIsExpanded(ViewSelectorItem item, bool isSelectedSection) { item.toggleIsExpanded(); if (isSelectedSection) { for (final selectedSource in selectedSources) { selectedSource.findChildBySourceId(item.view.id)?.toggleIsExpanded(); } } else { for (final source in sources) { final child = source.findChildBySourceId(item.view.id); if (child != null) { child.toggleIsExpanded(); break; } } } } @override Future close() { for (final child in sources) { child.dispose(); } for (final child in selectedSources) { child.dispose(); } for (final child in state.selectedSources) { child.dispose(); } for (final child in state.visibleSources) { child.dispose(); } filterTextController.dispose(); return super.close(); } } @freezed class ViewSelectorState with _$ViewSelectorState { const factory ViewSelectorState({ required List visibleSources, required List selectedSources, }) = _ViewSelectorState; factory ViewSelectorState.initial() => const ViewSelectorState( visibleSources: [], selectedSources: [], ); } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/ai_prompt_modal/ai_prompt_category_list.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AiPromptCategoryList extends StatefulWidget { const AiPromptCategoryList({ super.key, }); @override State createState() => _AiPromptCategoryListState(); } class _AiPromptCategoryListState extends State { bool isSearching = false; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return TextFieldTapRegion( groupId: "ai_prompt_category_list", child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: EdgeInsets.only( right: theme.spacing.l, ), child: AiPromptFeaturedSection(), ), Padding( padding: EdgeInsets.only( right: theme.spacing.l, ), child: AiPromptCustomPromptSection(), ), Padding( padding: EdgeInsets.only( top: theme.spacing.s, right: theme.spacing.l, ), child: AFDivider(), ), Expanded( child: ListView( padding: EdgeInsets.only( top: theme.spacing.s, right: theme.spacing.l, ), children: [ _buildCategoryItem(context, null), ...sortedCategories.map( (category) => _buildCategoryItem( context, category, ), ), ], ), ), ], ), ); } static Iterable get sortedCategories { final categories = [...AiPromptCategory.values]; categories ..sort((a, b) => a.i18n.compareTo(b.i18n)) ..remove(AiPromptCategory.other) ..add(AiPromptCategory.other); return categories; } Widget _buildCategoryItem( BuildContext context, AiPromptCategory? category, ) { return AiPromptCategoryItem( category: category, onSelect: () { context.read().selectCategory(category); }, ); } } class AiPromptFeaturedSection extends StatelessWidget { const AiPromptFeaturedSection({ super.key, }); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final isSelected = context.watch().state.maybeMap( ready: (state) => state.isFeaturedSectionSelected, orElse: () => false, ); return AFBaseButton( onTap: () { if (!isSelected) { context.read().selectFeaturedSection(); } }, builder: (context, isHovering, disabled) { return Text( LocaleKeys.ai_customPrompt_featured.tr(), style: AppFlowyTheme.of(context).textStyle.body.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ); }, borderRadius: theme.borderRadius.m, padding: EdgeInsets.symmetric( vertical: theme.spacing.s, horizontal: theme.spacing.m, ), borderColor: (context, isHovering, disabled, isFocused) => Colors.transparent, backgroundColor: (context, isHovering, disabled) { if (isSelected) { return theme.fillColorScheme.themeSelect; } if (isHovering) { return theme.fillColorScheme.contentHover; } return Colors.transparent; }, ); } } class AiPromptCustomPromptSection extends StatelessWidget { const AiPromptCustomPromptSection({ super.key, }); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocBuilder( builder: (context, state) { return state.maybeMap( ready: (readyState) { final isSelected = readyState.isCustomPromptSectionSelected; return AFBaseButton( onTap: () { if (!isSelected) { context.read().selectCustomSection(); } }, builder: (context, isHovering, disabled) { return Text( LocaleKeys.ai_customPrompt_custom.tr(), style: AppFlowyTheme.of(context).textStyle.body.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ); }, borderRadius: theme.borderRadius.m, padding: EdgeInsets.symmetric( vertical: theme.spacing.s, horizontal: theme.spacing.m, ), borderColor: (context, isHovering, disabled, isFocused) => Colors.transparent, backgroundColor: (context, isHovering, disabled) { if (isSelected) { return theme.fillColorScheme.themeSelect; } if (isHovering) { return theme.fillColorScheme.contentHover; } return Colors.transparent; }, ); }, orElse: () => const SizedBox.shrink(), ); }, ); } } class AiPromptCategoryItem extends StatelessWidget { const AiPromptCategoryItem({ super.key, required this.category, required this.onSelect, }); final AiPromptCategory? category; final VoidCallback onSelect; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final theme = AppFlowyTheme.of(context); final isSelected = state.maybeMap( ready: (state) { return !state.isFeaturedSectionSelected && !state.isCustomPromptSectionSelected && state.selectedCategory == category; }, orElse: () => false, ); return AFBaseButton( onTap: onSelect, builder: (context, isHovering, disabled) { return Text( category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(), style: AppFlowyTheme.of(context).textStyle.body.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ); }, borderRadius: theme.borderRadius.m, padding: EdgeInsets.symmetric( vertical: theme.spacing.s, horizontal: theme.spacing.m, ), borderColor: (context, isHovering, disabled, isFocused) => Colors.transparent, backgroundColor: (context, isHovering, disabled) { if (isSelected) { return theme.fillColorScheme.themeSelect; } if (isHovering) { return theme.fillColorScheme.contentHover; } return Colors.transparent; }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/ai_prompt_modal/ai_prompt_database_modal.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/ai/service/ai_prompt_database_selector_cubit.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:equatable/equatable.dart'; import 'package:expandable/expandable.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; Future changeCustomPromptDatabaseConfig( BuildContext context, { CustomPromptDatabaseConfig? config, }) async { return showDialog( context: context, builder: (_) { return MultiBlocProvider( providers: [ BlocProvider.value( value: context.read(), ), BlocProvider( create: (context) => AiPromptDatabaseSelectorCubit( configuration: config, ), ), ], child: const AiPromptDatabaseModal(), ); }, ); } class AiPromptDatabaseModal extends StatefulWidget { const AiPromptDatabaseModal({ super.key, }); @override State createState() => _AiPromptDatabaseModalState(); } class _AiPromptDatabaseModalState extends State { final expandableController = ExpandableController(initialExpanded: false); @override void dispose() { expandableController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocListener( listener: (context, state) { state.maybeMap( invalidDatabase: (_) { showSimpleAFDialog( context: context, title: LocaleKeys.ai_customPrompt_invalidDatabase.tr(), content: LocaleKeys.ai_customPrompt_invalidDatabaseHelp.tr(), primaryAction: ( LocaleKeys.button_ok.tr(), (context) {}, ), ); }, empty: (_) => expandableController.expanded = false, selected: (_) => expandableController.expanded = true, orElse: () {}, ); }, child: AFModal( constraints: const BoxConstraints( maxWidth: 450, maxHeight: 400, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AFModalHeader( leading: Text( LocaleKeys.ai_customPrompt_configureDatabase.tr(), style: theme.textStyle.heading4.prominent( color: theme.textColorScheme.primary, ), ), trailing: [ AFGhostButton.normal( onTap: () => Navigator.of(context).pop(), padding: EdgeInsets.all(theme.spacing.xs), builder: (context, isHovering, disabled) { return Center( child: FlowySvg( FlowySvgs.toast_close_s, size: Size.square(20), ), ); }, ), ], ), Flexible( child: AFModalBody( child: ExpandablePanel( controller: expandableController, theme: ExpandableThemeData( tapBodyToCollapse: false, hasIcon: false, tapBodyToExpand: false, tapHeaderToExpand: false, ), header: const _Header(), collapsed: const SizedBox.shrink(), expanded: const _Expanded(), ), ), ), AFModalFooter( trailing: [ AFOutlinedButton.normal( onTap: () => Navigator.of(context).pop(), builder: (context, isHovering, disabled) { return Text( LocaleKeys.button_cancel.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ); }, ), AFFilledButton.primary( onTap: () { final config = context .read() .state .maybeMap( selected: (state) => state.config, orElse: () => null, ); Navigator.of(context).pop(config); }, builder: (context, isHovering, disabled) { return Text( LocaleKeys.button_done.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.onFill, ), ); }, ), ], ), ], ), ), ); } } class _Header extends StatefulWidget { const _Header(); @override State<_Header> createState() => _HeaderState(); } class _HeaderState extends State<_Header> { final popoverController = AFPopoverController(); @override void dispose() { popoverController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocBuilder( builder: (context, state) { bool showNothing = false; String? viewName; state.maybeMap( empty: (_) { showNothing = false; viewName = null; }, selected: (selectedState) { showNothing = false; viewName = selectedState.config.view.nameOrDefault; }, orElse: () { showNothing = true; viewName = null; }, ); if (showNothing) { return SizedBox.shrink(); } return Padding( padding: EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.xl, ), child: Row( spacing: theme.spacing.s, children: [ Expanded( child: Text( LocaleKeys.ai_customPrompt_selectDatabase.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ), ), ), Expanded( child: Center( child: ViewSelector( viewSelectorCubit: BlocProvider( create: (context) => ViewSelectorCubit( getIgnoreViewType: getIgnoreViewType, ), ), child: BlocSelector, ViewPB?)>( selector: (state) => (state.spaces, state.currentSpace), builder: (context, state) { return AFPopover( controller: popoverController, decoration: BoxDecoration( color: theme.surfaceColorScheme.primary, borderRadius: BorderRadius.circular(theme.borderRadius.l), border: Border.all( color: theme.borderColorScheme.primary, ), boxShadow: theme.shadow.medium, ), padding: EdgeInsets.zero, anchor: AFAnchor( childAlignment: Alignment.topCenter, overlayAlignment: Alignment.bottomCenter, offset: Offset(0, theme.spacing.xs), ), popover: (context) { return _PopoverContent( onSelectViewItem: (item) { context .read() .selectDatabaseView(item.view.id); popoverController.hide(); }, ); }, child: AFOutlinedButton.normal( onTap: () { context .read() .refreshSources(state.$1, state.$2); popoverController.toggle(); }, builder: (context, isHovering, disabled) { return Row( mainAxisSize: MainAxisSize.min, spacing: theme.spacing.s, children: [ Flexible( child: Text( viewName ?? LocaleKeys .ai_customPrompt_selectDatabase .tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), FlowySvg( FlowySvgs.toolbar_arrow_down_m, color: theme.iconColorScheme.primary, size: Size(12, 20), ), ], ); }, ), ); }, ), ), ), ), ], ), ); }, ); } IgnoreViewType getIgnoreViewType(ViewSelectorItem item) { final layout = item.view.layout; if (layout.isDatabaseView) { return IgnoreViewType.none; } if (layout.isDocumentView) { return hasDatabaseDescendent(item) ? IgnoreViewType.none : IgnoreViewType.hide; } return IgnoreViewType.hide; } bool hasDatabaseDescendent(ViewSelectorItem viewSelectorItem) { final layout = viewSelectorItem.view.layout; if (layout == ViewLayoutPB.Chat) { return false; } if (layout.isDatabaseView) { return true; } // document may have children return viewSelectorItem.children.any( (child) => hasDatabaseDescendent(child), ); } } class _Expanded extends StatelessWidget { const _Expanded(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocBuilder( builder: (context, state) { return state.maybeMap( orElse: () => SizedBox.shrink(), selected: (selectedState) { return Padding( padding: EdgeInsets.all(theme.spacing.m), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, spacing: theme.spacing.m, children: [ FieldSelector( title: LocaleKeys.ai_customPrompt_title.tr(), currentFieldId: selectedState.config.titleFieldId, isDisabled: true, fields: selectedState.fields, onSelect: (id) {}, ), FieldSelector( title: LocaleKeys.ai_customPrompt_content.tr(), currentFieldId: selectedState.config.contentFieldId, fields: selectedState.fields .where((f) => f.fieldType == FieldType.RichText) .toList(), onSelect: (id) { if (id != null) { context .read() .selectContentField(id); } }, ), FieldSelector( title: LocaleKeys.ai_customPrompt_example.tr(), currentFieldId: selectedState.config.exampleFieldId, isOptional: true, fields: selectedState.fields .where((f) => f.fieldType == FieldType.RichText) .toList(), onSelect: (id) { context .read() .selectExampleField(id); }, ), FieldSelector( title: LocaleKeys.ai_customPrompt_category.tr(), currentFieldId: selectedState.config.categoryFieldId, isOptional: true, fields: selectedState.fields .where( (f) => f.fieldType == FieldType.RichText || f.fieldType == FieldType.SingleSelect || f.fieldType == FieldType.MultiSelect, ) .toList(), onSelect: (id) { context .read() .selectCategoryField(id); }, ), ], ), ); }, ); }, ); } } class _PopoverContent extends StatefulWidget { const _PopoverContent({ required this.onSelectViewItem, }); final void Function(ViewSelectorItem item) onSelectViewItem; @override State<_PopoverContent> createState() => _PopoverContentState(); } class _PopoverContentState extends State<_PopoverContent> { final focusNode = FocusNode(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); }); } @override void dispose() { focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return ConstrainedBox( constraints: const BoxConstraints.tightFor( width: 300, height: 400, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ VSpace( theme.spacing.m, ), Padding( padding: EdgeInsets.symmetric( horizontal: theme.spacing.m, ), child: AFTextField( focusNode: focusNode, size: AFTextFieldSize.m, hintText: LocaleKeys.search_label.tr(), controller: context.read().filterTextController, ), ), VSpace( theme.spacing.m, ), AFDivider(), Expanded( child: BlocBuilder( builder: (context, state) { return ListView( shrinkWrap: true, padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), children: _buildVisibleSources(context, state).toList(), ); }, ), ), ], ), ); } Iterable _buildVisibleSources( BuildContext context, ViewSelectorState state, ) { return state.visibleSources.map( (e) => ViewSelectorTreeItem( key: ValueKey( 'custom_prompt_database_tree_item_${e.view.id}', ), viewSelectorItem: e, level: 0, isDescendentOfSpace: e.view.isSpace, isSelectedSection: false, showCheckbox: false, onSelected: (item) { if (item.view.isDocument || item.view.isSpace) { context.read().toggleIsExpanded(item, false); return; } widget.onSelectViewItem(item); }, height: 30.0, ), ); } } class _FieldPBWrapper extends Equatable with AFDropDownMenuMixin { const _FieldPBWrapper(this.field); final FieldPB field; @override String get label => field.name; @override List get props => [field.id]; } class FieldSelector extends StatelessWidget { const FieldSelector({ super.key, required this.title, required this.currentFieldId, this.isDisabled = false, this.isOptional = false, this.fields = const [], required this.onSelect, }); final String title; final String? currentFieldId; final bool isDisabled; final bool isOptional; final List fields; final void Function(String? id)? onSelect; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final selectedField = fields.firstWhereOrNull( (field) => field.id == currentFieldId, ); return Row( spacing: theme.spacing.s, children: [ Expanded( child: Text( title, style: theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ), ), ), Expanded( child: AFDropDownMenu<_FieldPBWrapper>( isDisabled: isDisabled, items: fields.map((field) => _FieldPBWrapper(field)).toList(), selectedItems: [ if (selectedField != null) _FieldPBWrapper(selectedField), ], clearIcon: selectedField == null || !fields.contains(selectedField) || !isOptional ? null : MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () { onSelect?.call(null); }, child: FlowySvg( FlowySvgs.search_clear_m, size: Size.square(16), color: theme.iconColorScheme.tertiary, ), ), ), onSelected: (value) { if (value == null) { return; } onSelect?.call(value.field.id); }, dropdownIcon: FlowySvg( FlowySvgs.toolbar_arrow_down_m, color: theme.iconColorScheme.primary, size: Size(12, 20), ), ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/ai_prompt_modal/ai_prompt_modal.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'ai_prompt_category_list.dart'; import 'ai_prompt_onboarding.dart'; import 'ai_prompt_preview.dart'; import 'ai_prompt_visible_list.dart'; Future showAiPromptModal( BuildContext context, { required AiPromptSelectorCubit aiPromptSelectorCubit, }) async { aiPromptSelectorCubit.loadCustomPrompts(); return showDialog( context: context, builder: (_) { return MultiBlocProvider( providers: [ BlocProvider.value( value: aiPromptSelectorCubit, ), BlocProvider.value( value: context.read(), ), ], child: const AiPromptModal(), ); }, ); } class AiPromptModal extends StatelessWidget { const AiPromptModal({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFModal( backgroundColor: theme.backgroundColorScheme.primary, constraints: const BoxConstraints( maxWidth: 1200, maxHeight: 800, ), child: BlocListener( listener: (context, state) { state.maybeMap( invalidDatabase: (_) { showLoadPromptFailedDialog(context); }, orElse: () {}, ); }, child: Column( children: [ AFModalHeader( leading: Text( LocaleKeys.ai_customPrompt_browsePrompts.tr(), style: theme.textStyle.heading4.prominent( color: theme.textColorScheme.primary, ), ), trailing: [ AFGhostButton.normal( onTap: () => Navigator.of(context).pop(), padding: EdgeInsets.all(theme.spacing.xs), builder: (context, isHovering, disabled) { return Center( child: FlowySvg( FlowySvgs.toast_close_s, size: Size.square(20), ), ); }, ), ], ), Expanded( child: AFModalBody( child: BlocBuilder( builder: (context, state) { return state.maybeMap( loading: (_) { return const Center( child: CircularProgressIndicator(), ); }, ready: (readyState) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Expanded( child: AiPromptCategoryList(), ), if (readyState.isCustomPromptSectionSelected && readyState.databaseConfig == null) const Expanded( flex: 5, child: Center( child: AiPromptOnboarding(), ), ) else ...[ const Expanded( flex: 2, child: AiPromptVisibleList(), ), Expanded( flex: 3, child: BlocBuilder( builder: (context, state) { final selectedPrompt = state.maybeMap( ready: (state) { return state.visiblePrompts .firstWhereOrNull( (prompt) => prompt.id == state.selectedPromptId, ); }, orElse: () => null, ); if (selectedPrompt == null) { return const SizedBox.shrink(); } return AiPromptPreview( prompt: selectedPrompt, ); }, ), ), ], ], ); }, orElse: () => const SizedBox.shrink(), ); }, ), ), ), ], ), ), ); } } void showLoadPromptFailedDialog( BuildContext context, ) { showSimpleAFDialog( context: context, title: LocaleKeys.ai_customPrompt_invalidDatabase.tr(), content: LocaleKeys.ai_customPrompt_invalidDatabaseHelp.tr(), primaryAction: ( LocaleKeys.button_ok.tr(), (context) {}, ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/ai_prompt_modal/ai_prompt_onboarding.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'ai_prompt_database_modal.dart'; class AiPromptOnboarding extends StatelessWidget { const AiPromptOnboarding({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( mainAxisSize: MainAxisSize.min, children: [ Text( LocaleKeys.ai_customPrompt_customPrompt.tr(), style: theme.textStyle.heading3.standard( color: theme.textColorScheme.primary, ), ), VSpace( theme.spacing.s, ), Text( LocaleKeys.ai_customPrompt_databasePrompts.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ), ), VSpace( theme.spacing.xxl, ), AFFilledButton.primary( onTap: () async { final config = await changeCustomPromptDatabaseConfig(context); if (config != null && context.mounted) { context .read() .updateCustomPromptDatabaseConfiguration(config); } }, builder: (context, isHovering, disabled) { return Text( LocaleKeys.ai_customPrompt_selectDatabase.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.onFill, ), ); }, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/ai_prompt_modal/ai_prompt_preview.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class AiPromptPreview extends StatelessWidget { const AiPromptPreview({ super.key, required this.prompt, }); final AiPrompt prompt; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return SelectionArea( child: Column( children: [ Padding( padding: EdgeInsets.symmetric( horizontal: theme.spacing.l, ), child: SelectionContainer.disabled( child: Row( children: [ Expanded( child: Text( prompt.name, style: theme.textStyle.headline.standard( color: theme.textColorScheme.primary, ), ), ), HSpace(theme.spacing.s), AFFilledTextButton.primary( text: LocaleKeys.ai_customPrompt_usePrompt.tr(), onTap: () { Navigator.of(context).pop(prompt); }, ), ], ), ), ), VSpace(theme.spacing.xs), Expanded( child: ListView( padding: EdgeInsets.all( theme.spacing.l, ), children: [ SelectionContainer.disabled( child: Text( LocaleKeys.ai_customPrompt_prompt.tr(), style: theme.textStyle.heading4.standard( color: theme.textColorScheme.primary, ), ), ), VSpace(theme.spacing.xs), _PromptContent( prompt: prompt, ), VSpace(theme.spacing.xl), if (prompt.example.isNotEmpty) ...[ SelectionContainer.disabled( child: Text( LocaleKeys.ai_customPrompt_promptExample.tr(), style: theme.textStyle.heading4.standard( color: theme.textColorScheme.primary, ), ), ), VSpace(theme.spacing.xs), _PromptExample( prompt: prompt, ), VSpace(theme.spacing.xl), ], ], ), ), ], ), ); } } class _PromptContent extends StatelessWidget { const _PromptContent({ required this.prompt, }); final AiPrompt prompt; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final textSpans = _buildTextSpans(context, prompt.content); return Container( padding: EdgeInsets.all(theme.spacing.l), decoration: BoxDecoration( color: theme.surfaceContainerColorScheme.layer01, borderRadius: BorderRadius.circular(theme.borderRadius.m), ), child: Text.rich( TextSpan( style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), children: textSpans, ), ), ); } List _buildTextSpans(BuildContext context, String text) { final theme = AppFlowyTheme.of(context); final spans = []; final parts = _splitPromptText(text); for (final part in parts) { if (part.startsWith('[') && part.endsWith(']')) { spans.add( TextSpan( text: part, style: TextStyle(color: theme.textColorScheme.featured), ), ); } else { spans.add(TextSpan(text: part)); } } return spans; } List _splitPromptText(String text) { final regex = RegExp(r'(\[[^\[\]]*?\])'); final result = []; text.splitMapJoin( regex, onMatch: (match) { result.add(match.group(0)!); return ''; }, onNonMatch: (nonMatch) { result.add(nonMatch); return ''; }, ); return result; } } class _PromptExample extends StatelessWidget { const _PromptExample({ required this.prompt, }); final AiPrompt prompt; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Container( padding: EdgeInsets.all(theme.spacing.l), decoration: BoxDecoration( color: theme.surfaceContainerColorScheme.layer01, borderRadius: BorderRadius.circular(theme.borderRadius.m), ), child: AIMarkdownText( markdown: prompt.example, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/ai_prompt_modal/ai_prompt_visible_list.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:diffutil_dart/diffutil.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'ai_prompt_database_modal.dart'; const Duration _listItemAnimationDuration = Duration(milliseconds: 150); class AiPromptVisibleList extends StatefulWidget { const AiPromptVisibleList({ super.key, }); @override State createState() => _AiPromptVisibleListState(); } class _AiPromptVisibleListState extends State { final listKey = GlobalKey(); final scrollController = ScrollController(); final List oldList = []; late AiPromptSelectorCubit cubit; late bool filterIsEmpty; @override void initState() { super.initState(); cubit = context.read(); final textController = cubit.filterTextController; filterIsEmpty = textController.text.isEmpty; textController.addListener(handleFilterTextChanged); final prompts = cubit.state.maybeMap( ready: (value) => value.visiblePrompts, orElse: () => [], ); oldList.addAll(prompts); } @override void dispose() { cubit.filterTextController.removeListener(handleFilterTextChanged); scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( children: [ BlocConsumer( listener: (context, state) { state.maybeMap( ready: (state) { handleVisiblePromptListChanged(state.visiblePrompts); }, orElse: () {}, ); }, buildWhen: (p, c) { return p.maybeMap( ready: (pr) => c.maybeMap( ready: (cr) => pr.databaseConfig?.view.id != cr.databaseConfig?.view.id || pr.isLoadingCustomPrompts != cr.isLoadingCustomPrompts || pr.isCustomPromptSectionSelected != cr.isCustomPromptSectionSelected, orElse: () => false, ), orElse: () => true, ); }, builder: (context, state) { return state.maybeMap( ready: (readyState) { if (!readyState.isCustomPromptSectionSelected) { return const SizedBox.shrink(); } return Container( margin: EdgeInsets.only( left: theme.spacing.l, right: theme.spacing.l, bottom: theme.spacing.l, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(theme.borderRadius.m), color: theme.surfaceContainerColorScheme.layer01, ), padding: EdgeInsets.all(theme.spacing.m), child: Row( children: [ Expanded( child: Text.rich( TextSpan( children: [ TextSpan( text: "${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: ", style: TextStyle(fontWeight: FontWeight.w500), ), TextSpan( text: readyState .databaseConfig?.view.nameOrDefault ?? "", ), ], ), style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ConstrainedBox( constraints: const BoxConstraints( maxWidth: 150, ), child: AFOutlinedButton.normal( builder: (context, isHovering, disabled) { return Row( spacing: theme.spacing.s, mainAxisSize: MainAxisSize.min, children: [ if (readyState.isLoadingCustomPrompts) buildLoadingIndicator(theme), Flexible( child: Text( readyState.isLoadingCustomPrompts ? LocaleKeys.ai_customPrompt_loading .tr() : LocaleKeys.button_change.tr(), maxLines: 1, style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ), ), ], ); }, onTap: () async { final newConfig = await changeCustomPromptDatabaseConfig( context, config: readyState.databaseConfig, ); if (newConfig != null && context.mounted) { context .read() .updateCustomPromptDatabaseConfiguration( newConfig, ); } }, ), ), ], ), ); }, orElse: () => const SizedBox.shrink(), ); }, ), Padding( padding: EdgeInsets.symmetric(horizontal: theme.spacing.l), child: buildSearchField(context), ), Expanded( child: TextFieldTapRegion( groupId: "ai_prompt_category_list", child: BlocBuilder( builder: (context, state) { return state.maybeMap( ready: (readyState) { if (readyState.visiblePrompts.isEmpty) { return buildEmptyPrompts(); } return buildPromptList(); }, orElse: () => const SizedBox.shrink(), ); }, ), ), ), ], ); } Widget buildSearchField(BuildContext context) { final theme = AppFlowyTheme.of(context); final iconSize = 20.0; return AFTextField( groupId: "ai_prompt_category_list", hintText: "Search", controller: context.read().filterTextController, autoFocus: true, suffixIconConstraints: BoxConstraints.tightFor( width: iconSize + theme.spacing.m, height: iconSize, ), suffixIconBuilder: filterIsEmpty ? null : (context, isObscured) => TextFieldTapRegion( groupId: "ai_prompt_category_list", child: Padding( padding: EdgeInsets.only(right: theme.spacing.m), child: GestureDetector( onTap: () => context .read() .filterTextController .clear(), child: MouseRegion( cursor: SystemMouseCursors.click, child: FlowySvg( FlowySvgs.search_clear_m, color: theme.iconColorScheme.tertiary, size: const Size.square(20), ), ), ), ), ), ); } Widget buildEmptyPrompts() { final theme = AppFlowyTheme.of(context); return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.m_home_search_icon_m, color: theme.iconColorScheme.secondary, size: Size.square(24), ), VSpace(theme.spacing.m), Text( LocaleKeys.ai_customPrompt_noResults.tr(), style: theme.textStyle.body .standard(color: theme.textColorScheme.secondary), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ); } Widget buildPromptList() { final theme = AppFlowyTheme.of(context); return AnimatedList( controller: scrollController, padding: EdgeInsets.all(theme.spacing.l), key: listKey, initialItemCount: oldList.length, itemBuilder: (context, index, animation) { return BlocBuilder( builder: (context, state) { return state.maybeMap( ready: (state) { final prompt = state.visiblePrompts[index]; return Padding( padding: EdgeInsets.only( top: index == 0 ? 0 : theme.spacing.s, bottom: index == state.visiblePrompts.length - 1 ? 0 : theme.spacing.s, ), child: _AiPromptListItem( animation: animation, prompt: prompt, isSelected: state.selectedPromptId == prompt.id, ), ); }, orElse: () => const SizedBox.shrink(), ); }, ); }, ); } Widget buildLoadingIndicator(AppFlowyThemeData theme) { return SizedBox.square( dimension: 20, child: Padding( padding: EdgeInsets.all(2.5), child: CircularProgressIndicator( color: theme.iconColorScheme.tertiary, strokeWidth: 2.0, ), ), ); } void handleVisiblePromptListChanged( List newList, ) { final updates = calculateListDiff(oldList, newList).getUpdatesWithData(); for (final update in updates) { update.when( insert: (pos, data) { listKey.currentState?.insertItem( pos, duration: _listItemAnimationDuration, ); }, remove: (pos, data) { listKey.currentState?.removeItem( pos, (context, animation) { final isSelected = context.read().state.maybeMap( ready: (state) => state.selectedPromptId == data.id, orElse: () => false, ); return _AiPromptListItem( animation: animation, prompt: data, isSelected: isSelected, ); }, duration: _listItemAnimationDuration, ); }, change: (pos, oldData, newData) {}, move: (from, to, data) {}, ); } oldList ..clear() ..addAll(newList); } void handleFilterTextChanged() { setState(() { filterIsEmpty = cubit.filterTextController.text.isEmpty; }); } } class _AiPromptListItem extends StatefulWidget { const _AiPromptListItem({ required this.animation, required this.prompt, required this.isSelected, }); final Animation animation; final AiPrompt prompt; final bool isSelected; @override State<_AiPromptListItem> createState() => _AiPromptListItemState(); } class _AiPromptListItemState extends State<_AiPromptListItem> { bool isHovering = false; Timer? timer; @override void dispose() { timer?.cancel(); timer = null; super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final cubit = context.read(); final curvedAnimation = CurvedAnimation( parent: widget.animation, curve: Curves.easeIn, ); final surfacePrimaryHover = Theme.of(context).isLightMode ? Color(0xFFF8FAFF) : Color(0xFF3C3F4E); return FadeTransition( opacity: curvedAnimation, child: SizeTransition( sizeFactor: curvedAnimation, child: MouseRegion( onEnter: (_) { setState(() { isHovering = true; timer = Timer(const Duration(milliseconds: 300), () { if (mounted) { cubit.selectPrompt(widget.prompt.id); } }); }); }, onExit: (_) { setState(() { isHovering = false; timer?.cancel(); }); }, child: GestureDetector( onTap: () { cubit.selectPrompt(widget.prompt.id); }, child: Stack( children: [ Container( padding: EdgeInsets.all(theme.spacing.m), decoration: BoxDecoration( borderRadius: BorderRadius.circular(theme.borderRadius.m), color: Colors.transparent, border: Border.all( color: widget.isSelected ? isHovering ? theme.borderColorScheme.themeThickHover : theme.borderColorScheme.themeThick : isHovering ? theme.borderColorScheme.primaryHover : theme.borderColorScheme.primary, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( widget.prompt.name, maxLines: 1, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, softWrap: true, ), ), ], ), Text( widget.prompt.content, maxLines: 2, style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), overflow: TextOverflow.ellipsis, softWrap: true, ), ], ), ), if (isHovering) Positioned( top: theme.spacing.s, right: theme.spacing.s, child: DecoratedBox( decoration: BoxDecoration(boxShadow: theme.shadow.small), child: AFBaseButton( onTap: () { Navigator.of(context).pop(widget.prompt); }, builder: (context, isHovering, disabled) { return Text( LocaleKeys.ai_customPrompt_usePrompt.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ); }, backgroundColor: (context, isHovering, disabled) { if (isHovering) { return surfacePrimaryHover; } return theme.surfaceColorScheme.primary; }, padding: EdgeInsets.symmetric( vertical: theme.spacing.s, horizontal: theme.spacing.m, ), borderRadius: theme.borderRadius.m, ), ), ), ], ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/loading_indicator.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; /// An animated generating indicator for an AI response class AILoadingIndicator extends StatelessWidget { const AILoadingIndicator({ super.key, this.text = "", this.duration = const Duration(seconds: 1), }); final String text; final Duration duration; @override Widget build(BuildContext context) { final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5); return SelectionContainer.disabled( child: SizedBox( height: 20, child: SeparatedRow( separatorBuilder: () => const HSpace(4), children: [ Padding( padding: const EdgeInsetsDirectional.only(end: 4.0), child: FlowyText( text, color: Theme.of(context).hintColor, ), ), buildDot(const Color(0xFF9327FF)) .animate(onPlay: (controller) => controller.repeat()) .slideY(duration: slice, begin: 0, end: -1) .then() .slideY(begin: -1, end: 1) .then() .slideY(begin: 1, end: 0) .then() .slideY(duration: slice * 2, begin: 0, end: 0), buildDot(const Color(0xFFFB006D)) .animate(onPlay: (controller) => controller.repeat()) .slideY(duration: slice, begin: 0, end: 0) .then() .slideY(begin: 0, end: -1) .then() .slideY(begin: -1, end: 1) .then() .slideY(begin: 1, end: 0) .then() .slideY(begin: 0, end: 0), buildDot(const Color(0xFFFFCE00)) .animate(onPlay: (controller) => controller.repeat()) .slideY(duration: slice * 2, begin: 0, end: 0) .then() .slideY(duration: slice, begin: 0, end: -1) .then() .slideY(begin: -1, end: 1) .then() .slideY(begin: 1, end: 0), ], ), ), ); } Widget buildDot(Color color) { return SizedBox.square( dimension: 4, child: DecoratedBox( decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(2), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/action_buttons.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'layout_define.dart'; class PromptInputAttachmentButton extends StatelessWidget { const PromptInputAttachmentButton({required this.onTap, super.key}); final VoidCallback onTap; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.chat_uploadFile.tr(), child: SizedBox.square( dimension: DesktopAIPromptSizes.actionBarButtonSize, child: FlowyIconButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, radius: BorderRadius.circular(8), icon: FlowySvg( FlowySvgs.ai_attachment_s, size: const Size.square(16), color: Theme.of(context).iconTheme.color, ), onPressed: onTap, ), ), ); } } class PromptInputMentionButton extends StatelessWidget { const PromptInputMentionButton({ super.key, required this.buttonSize, required this.iconSize, required this.onTap, }); final double buttonSize; final double iconSize; final VoidCallback onTap; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.chat_clickToMention.tr(), preferBelow: false, child: FlowyIconButton( width: buttonSize, hoverColor: AFThemeExtension.of(context).lightGreyHover, radius: BorderRadius.circular(8), icon: FlowySvg( FlowySvgs.chat_at_s, size: Size.square(iconSize), color: Theme.of(context).iconTheme.color, ), onPressed: onTap, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/browse_prompts_button.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../ai_prompt_modal/ai_prompt_modal.dart'; class BrowsePromptsButton extends StatelessWidget { const BrowsePromptsButton({ super.key, required this.onSelectPrompt, }); final void Function(AiPrompt) onSelectPrompt; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.ai_customPrompt_browsePrompts.tr(), child: BlocProvider( create: (context) => AiPromptSelectorCubit(), child: Builder( builder: (context) { return GestureDetector( onTap: () async { final prompt = await showAiPromptModal( context, aiPromptSelectorCubit: context.read(), ); if (context.mounted) { context.read().reset(); } if (prompt != null && context.mounted) { onSelectPrompt(prompt); } }, behavior: HitTestBehavior.opaque, child: SizedBox( height: DesktopAIPromptSizes.actionBarButtonSize, child: FlowyHover( style: const HoverStyle( borderRadius: BorderRadius.all(Radius.circular(8)), ), child: Padding( padding: const EdgeInsetsDirectional.all(4.0), child: Center( child: FlowyText( LocaleKeys.ai_customPrompt_browsePrompts.tr(), fontSize: 12, figmaLineHeight: 16, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ), ), ), ), ), ); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/desktop_prompt_input.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_user_cubit.dart'; import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'browse_prompts_button.dart'; typedef OnPromptInputSubmitted = void Function( String input, PredefinedFormat? predefinedFormat, Map metadata, String? promptId, ); class DesktopPromptInput extends StatefulWidget { const DesktopPromptInput({ super.key, required this.isStreaming, required this.textController, required this.onStopStreaming, required this.onSubmitted, required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, this.hideDecoration = false, this.hideFormats = false, this.extraBottomActionButton, }); final bool isStreaming; final AiPromptInputTextEditingController textController; final void Function() onStopStreaming; final OnPromptInputSubmitted onSubmitted; final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; final bool hideDecoration; final bool hideFormats; final Widget? extraBottomActionButton; @override State createState() => _DesktopPromptInputState(); } class _DesktopPromptInputState extends State { final textFieldKey = GlobalKey(); final layerLink = LayerLink(); final overlayController = OverlayPortalController(); final inputControlCubit = ChatInputControlCubit(); final chatUserCubit = ChatUserCubit(); final focusNode = FocusNode(); late SendButtonState sendButtonState; bool isComposing = false; @override void initState() { super.initState(); widget.textController.addListener(handleTextControllerChanged); focusNode ..addListener( () { if (!widget.hideDecoration) { setState(() {}); // refresh border color } if (!focusNode.hasFocus) { cancelMentionPage(); // hide menu when lost focus } }, ) ..onKeyEvent = handleKeyEvent; updateSendButtonState(); WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); checkForAskingAI(); }); } @override void didUpdateWidget(covariant oldWidget) { updateSendButtonState(); super.didUpdateWidget(oldWidget); } @override void dispose() { focusNode.dispose(); widget.textController.removeListener(handleTextControllerChanged); inputControlCubit.close(); chatUserCubit.close(); super.dispose(); } @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider.value(value: inputControlCubit), BlocProvider.value(value: chatUserCubit), ], child: BlocListener( listener: (context, state) { state.maybeWhen( updateSelectedViews: (selectedViews) { context .read() .add(AIPromptInputEvent.updateMentionedViews(selectedViews)); }, orElse: () {}, ); }, child: OverlayPortal( controller: overlayController, overlayChildBuilder: (context) { return PromptInputMentionPageMenu( anchor: PromptInputAnchor(textFieldKey, layerLink), textController: widget.textController, onPageSelected: handlePageSelected, ); }, child: DecoratedBox( decoration: decoration(context), child: Column( mainAxisSize: MainAxisSize.min, children: [ ConstrainedBox( constraints: BoxConstraints( maxHeight: DesktopAIPromptSizes.attachedFilesBarPadding.vertical + DesktopAIPromptSizes.attachedFilesPreviewHeight, ), child: TextFieldTapRegion( child: PromptInputFile( onDeleted: (file) => context .read() .add(AIPromptInputEvent.removeFile(file)), ), ), ), const VSpace(4.0), BlocBuilder( builder: (context, state) { return Stack( children: [ ConstrainedBox( constraints: getTextFieldConstraints( state.showPredefinedFormats && !widget.hideFormats, ), child: inputTextField(), ), if (state.showPredefinedFormats && !widget.hideFormats) Positioned.fill( bottom: null, child: TextFieldTapRegion( child: Padding( padding: const EdgeInsetsDirectional.only( start: 8.0, ), child: ChangeFormatBar( showImageFormats: state.modelState.type == AiType.cloud, predefinedFormat: state.predefinedFormat, spacing: 4.0, onSelectPredefinedFormat: (format) => context.read().add( AIPromptInputEvent .updatePredefinedFormat(format), ), ), ), ), ), Positioned.fill( top: null, child: TextFieldTapRegion( child: _PromptBottomActions( showPredefinedFormatBar: state.showPredefinedFormats, showPredefinedFormatButton: !widget.hideFormats, onTogglePredefinedFormatSection: () => context.read().add( AIPromptInputEvent .toggleShowPredefinedFormat(), ), onStartMention: startMentionPageFromButton, sendButtonState: sendButtonState, onSendPressed: handleSend, onStopStreaming: widget.onStopStreaming, selectedSourcesNotifier: widget.selectedSourcesNotifier, onUpdateSelectedSources: widget.onUpdateSelectedSources, onSelectPrompt: handleOnSelectPrompt, extraBottomActionButton: widget.extraBottomActionButton, ), ), ), ], ); }, ), ], ), ), ), ), ); } BoxDecoration decoration(BuildContext context) { if (widget.hideDecoration) { return BoxDecoration(); } return BoxDecoration( color: Theme.of(context).colorScheme.surface, border: Border.all( color: focusNode.hasFocus ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline, width: focusNode.hasFocus ? 1.5 : 1.0, ), borderRadius: const BorderRadius.all(Radius.circular(12.0)), ); } void checkForAskingAI() { final paletteBloc = context.read(), paletteState = paletteBloc?.state; if (paletteBloc == null || paletteState == null) return; final isAskingAI = paletteState.askAI; if (!isAskingAI) return; paletteBloc.add(CommandPaletteEvent.askedAI()); final query = paletteState.query ?? ''; if (query.isEmpty) return; final sources = (paletteState.askAISources ?? []).map((e) => e.id).toList(); final metadata = context.read()?.consumeMetadata() ?? {}; final promptBloc = context.read(); final promptId = promptBloc?.promptId; final promptState = promptBloc?.state; final predefinedFormat = promptState?.predefinedFormat; if (sources.isNotEmpty) { widget.onUpdateSelectedSources(sources); } widget.onSubmitted.call(query, predefinedFormat, metadata, promptId ?? ''); } void startMentionPageFromButton() { if (overlayController.isShowing) { return; } if (!focusNode.hasFocus) { focusNode.requestFocus(); } widget.textController.text += '@'; WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { context .read() .startSearching(widget.textController.value); overlayController.show(); } }); } void cancelMentionPage() { if (overlayController.isShowing) { inputControlCubit.reset(); overlayController.hide(); } } void updateSendButtonState() { if (widget.isStreaming) { sendButtonState = SendButtonState.streaming; } else if (widget.textController.text.trim().isEmpty) { sendButtonState = SendButtonState.disabled; } else { sendButtonState = SendButtonState.enabled; } } void handleSend() { if (widget.isStreaming) { return; } String userInput = widget.textController.text.trim(); userInput = inputControlCubit.formatIntputText(userInput); userInput = AiPromptInputTextEditingController.restore(userInput); widget.textController.clear(); if (userInput.isEmpty) { return; } // get the attached files and mentioned pages final metadata = context.read().consumeMetadata(); final bloc = context.read(); final showPredefinedFormats = bloc.state.showPredefinedFormats; final predefinedFormat = bloc.state.predefinedFormat; widget.onSubmitted( userInput, showPredefinedFormats ? predefinedFormat : null, metadata, bloc.promptId, ); } void handleTextControllerChanged() { setState(() { // update whether send button is clickable updateSendButtonState(); isComposing = !widget.textController.value.composing.isCollapsed; }); if (isComposing) { return; } // disable mention return; // handle text and selection changes ONLY when mentioning a page // ignore: dead_code if (!overlayController.isShowing || inputControlCubit.filterStartPosition == -1) { return; } // handle cases where mention a page is cancelled final textController = widget.textController; final textSelection = textController.value.selection; final isSelectingMultipleCharacters = !textSelection.isCollapsed; final isCaretBeforeStartOfRange = textSelection.baseOffset < inputControlCubit.filterStartPosition; final isCaretAfterEndOfRange = textSelection.baseOffset > inputControlCubit.filterEndPosition; final isTextSame = inputControlCubit.inputText == textController.text; if (isSelectingMultipleCharacters || isTextSame && (isCaretBeforeStartOfRange || isCaretAfterEndOfRange)) { cancelMentionPage(); return; } final previousLength = inputControlCubit.inputText.characters.length; final currentLength = textController.text.characters.length; // delete "@" if (previousLength != currentLength && isCaretBeforeStartOfRange) { cancelMentionPage(); return; } // handle cases where mention the filter is updated if (previousLength != currentLength) { final diff = currentLength - previousLength; final newEndPosition = inputControlCubit.filterEndPosition + diff; final newFilter = textController.text.substring( inputControlCubit.filterStartPosition, newEndPosition, ); inputControlCubit.updateFilter( textController.text, newFilter, newEndPosition: newEndPosition, ); } else if (!isTextSame) { final newFilter = textController.text.substring( inputControlCubit.filterStartPosition, inputControlCubit.filterEndPosition, ); inputControlCubit.updateFilter(textController.text, newFilter); } } KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { // if (event.character == '@') { // WidgetsBinding.instance.addPostFrameCallback((_) { // inputControlCubit.startSearching(widget.textController.value); // overlayController.show(); // }); // } if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { node.unfocus(); return KeyEventResult.handled; } return KeyEventResult.ignored; } void handlePageSelected(ViewPB view) { final newText = widget.textController.text.replaceRange( inputControlCubit.filterStartPosition, inputControlCubit.filterEndPosition, view.id, ); widget.textController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed( offset: inputControlCubit.filterStartPosition + view.id.length, affinity: TextAffinity.upstream, ), ); inputControlCubit.selectPage(view); overlayController.hide(); } Widget inputTextField() { return Shortcuts( shortcuts: buildShortcuts(), child: Actions( actions: buildActions(), child: CompositedTransformTarget( link: layerLink, child: BlocBuilder( builder: (context, state) { Widget textField = PromptInputTextField( key: textFieldKey, editable: state.modelState.isEditable, cubit: inputControlCubit, textController: widget.textController, textFieldFocusNode: focusNode, contentPadding: calculateContentPadding(state.showPredefinedFormats), hintText: state.modelState.hintText, ); if (state.modelState.tooltip != null) { textField = FlowyTooltip( message: state.modelState.tooltip!, child: textField, ); } return textField; }, ), ), ), ); } BoxConstraints getTextFieldConstraints(bool showPredefinedFormats) { double minHeight = DesktopAIPromptSizes.textFieldMinHeight + DesktopAIPromptSizes.actionBarSendButtonSize + DesktopAIChatSizes.inputActionBarMargin.vertical; double maxHeight = 300; if (showPredefinedFormats) { minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight; maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight; } return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight); } EdgeInsetsGeometry calculateContentPadding(bool showPredefinedFormats) { final top = showPredefinedFormats ? DesktopAIPromptSizes.predefinedFormatButtonHeight : 0.0; final bottom = DesktopAIPromptSizes.actionBarSendButtonSize + DesktopAIChatSizes.inputActionBarMargin.vertical; return DesktopAIPromptSizes.textFieldContentPadding .add(EdgeInsets.only(top: top, bottom: bottom)); } Map buildShortcuts() { if (isComposing) { return const {}; } return const { SingleActivator(LogicalKeyboardKey.arrowUp): _FocusPreviousItemIntent(), SingleActivator(LogicalKeyboardKey.arrowDown): _FocusNextItemIntent(), SingleActivator(LogicalKeyboardKey.escape): _CancelMentionPageIntent(), SingleActivator(LogicalKeyboardKey.enter): _SubmitOrMentionPageIntent(), }; } Map> buildActions() { return { _FocusPreviousItemIntent: CallbackAction<_FocusPreviousItemIntent>( onInvoke: (intent) { inputControlCubit.updateSelectionUp(); return; }, ), _FocusNextItemIntent: CallbackAction<_FocusNextItemIntent>( onInvoke: (intent) { inputControlCubit.updateSelectionDown(); return; }, ), _CancelMentionPageIntent: CallbackAction<_CancelMentionPageIntent>( onInvoke: (intent) { cancelMentionPage(); return; }, ), _SubmitOrMentionPageIntent: CallbackAction<_SubmitOrMentionPageIntent>( onInvoke: (intent) { if (overlayController.isShowing) { inputControlCubit.state.maybeWhen( ready: (visibleViews, focusedViewIndex) { if (focusedViewIndex != -1 && focusedViewIndex < visibleViews.length) { handlePageSelected(visibleViews[focusedViewIndex]); } }, orElse: () {}, ); } else { handleSend(); } return; }, ), }; } void handleOnSelectPrompt(AiPrompt prompt) { final bloc = context.read(); bloc ..add(AIPromptInputEvent.updateMentionedViews([])) ..add(AIPromptInputEvent.updatePromptId(prompt.id)); final content = AiPromptInputTextEditingController.replace(prompt.content); widget.textController.value = TextEditingValue( text: content, selection: TextSelection.collapsed( offset: content.length, ), ); if (bloc.state.showPredefinedFormats) { bloc.add( AIPromptInputEvent.toggleShowPredefinedFormat(), ); } } } class _SubmitOrMentionPageIntent extends Intent { const _SubmitOrMentionPageIntent(); } class _CancelMentionPageIntent extends Intent { const _CancelMentionPageIntent(); } class _FocusPreviousItemIntent extends Intent { const _FocusPreviousItemIntent(); } class _FocusNextItemIntent extends Intent { const _FocusNextItemIntent(); } class PromptInputTextField extends StatelessWidget { const PromptInputTextField({ super.key, required this.editable, required this.cubit, required this.textController, required this.textFieldFocusNode, required this.contentPadding, this.hintText = "", }); final ChatInputControlCubit cubit; final TextEditingController textController; final FocusNode textFieldFocusNode; final EdgeInsetsGeometry contentPadding; final bool editable; final String hintText; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return TextField( controller: textController, focusNode: textFieldFocusNode, readOnly: !editable, enabled: editable, decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: contentPadding, hintText: hintText, hintStyle: inputHintTextStyle(context), isCollapsed: true, isDense: true, ), keyboardType: TextInputType.multiline, textCapitalization: TextCapitalization.sentences, minLines: 1, maxLines: null, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ); } TextStyle? inputHintTextStyle(BuildContext context) { return AppFlowyTheme.of(context).textStyle.body.standard( color: Theme.of(context).isLightMode ? const Color(0xFFBDC2C8) : const Color(0xFF3C3E51), ); } } class _PromptBottomActions extends StatelessWidget { const _PromptBottomActions({ required this.sendButtonState, required this.showPredefinedFormatBar, required this.showPredefinedFormatButton, required this.onTogglePredefinedFormatSection, required this.onStartMention, required this.onSendPressed, required this.onStopStreaming, required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, required this.onSelectPrompt, this.extraBottomActionButton, }); final bool showPredefinedFormatBar; final bool showPredefinedFormatButton; final void Function() onTogglePredefinedFormatSection; final void Function() onStartMention; final SendButtonState sendButtonState; final void Function() onSendPressed; final void Function() onStopStreaming; final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; final void Function(AiPrompt) onSelectPrompt; final Widget? extraBottomActionButton; @override Widget build(BuildContext context) { return Container( height: DesktopAIPromptSizes.actionBarSendButtonSize, margin: DesktopAIChatSizes.inputActionBarMargin, child: BlocBuilder( builder: (context, state) { return Row( spacing: DesktopAIChatSizes.inputActionBarButtonSpacing, children: [ if (showPredefinedFormatButton) _predefinedFormatButton(), _selectModelButton(context), _buildBrowsePromptsButton(), const Spacer(), if (context.read().supportSelectSource()) _selectSourcesButton(), if (extraBottomActionButton != null) extraBottomActionButton!, // _mentionButton(context), if (state.supportChatWithFile) _attachmentButton(context), _sendButton(), ], ); }, ), ); } Widget _predefinedFormatButton() { return PromptInputDesktopToggleFormatButton( showFormatBar: showPredefinedFormatBar, onTap: onTogglePredefinedFormatSection, ); } Widget _selectSourcesButton() { return PromptInputDesktopSelectSourcesButton( onUpdateSelectedSources: onUpdateSelectedSources, selectedSourcesNotifier: selectedSourcesNotifier, ); } Widget _selectModelButton(BuildContext context) { return SelectModelMenu( aiModelStateNotifier: context.read().aiModelStateNotifier, ); } Widget _buildBrowsePromptsButton() { return BrowsePromptsButton( onSelectPrompt: onSelectPrompt, ); } // Widget _mentionButton(BuildContext context) { // return PromptInputMentionButton( // iconSize: DesktopAIPromptSizes.actionBarIconSize, // buttonSize: DesktopAIPromptSizes.actionBarButtonSize, // onTap: onStartMention, // ); // } Widget _attachmentButton(BuildContext context) { return PromptInputAttachmentButton( onTap: () async { final path = await getIt().pickFiles( dialogTitle: '', type: FileType.custom, allowedExtensions: ["pdf", "txt", "md"], ); if (path == null) { return; } for (final file in path.files) { if (file.path != null && context.mounted) { context .read() .add(AIPromptInputEvent.attachFile(file.path!, file.name)); } } }, ); } Widget _sendButton() { return PromptInputSendButton( state: sendButtonState, onSendPressed: onSendPressed, onStopStreaming: onStopStreaming, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/file_attachment_list.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/ai/service/ai_prompt_input_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_file_bloc.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:styled_widget/styled_widget.dart'; import 'layout_define.dart'; class PromptInputFile extends StatelessWidget { const PromptInputFile({ super.key, required this.onDeleted, }); final void Function(ChatFile) onDeleted; @override Widget build(BuildContext context) { return BlocSelector>( selector: (state) => state.attachedFiles, builder: (context, files) { if (files.isEmpty) { return const SizedBox.shrink(); } return ListView.separated( scrollDirection: Axis.horizontal, padding: DesktopAIPromptSizes.attachedFilesBarPadding - const EdgeInsets.only(top: 6), separatorBuilder: (context, index) => const HSpace( DesktopAIPromptSizes.attachedFilesPreviewSpacing - 6, ), itemCount: files.length, itemBuilder: (context, index) => ChatFilePreview( file: files[index], onDeleted: () => onDeleted(files[index]), ), ); }, ); } } class ChatFilePreview extends StatefulWidget { const ChatFilePreview({ required this.file, required this.onDeleted, super.key, }); final ChatFile file; final VoidCallback onDeleted; @override State createState() => _ChatFilePreviewState(); } class _ChatFilePreviewState extends State { bool isHover = false; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => ChatInputFileBloc(file: widget.file), child: BlocBuilder( builder: (context, state) { return MouseRegion( onEnter: (_) => setHover(true), onExit: (_) => setHover(false), child: Stack( children: [ Container( margin: const EdgeInsetsDirectional.only(top: 6, end: 6), constraints: const BoxConstraints(maxWidth: 240), decoration: BoxDecoration( border: Border.all( color: Theme.of(context).dividerColor, ), borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.all(8.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( decoration: BoxDecoration( color: AFThemeExtension.of(context).tint1, borderRadius: BorderRadius.circular(8), ), height: 32, width: 32, child: Center( child: FlowySvg( FlowySvgs.page_m, size: const Size.square(16), color: Theme.of(context).hintColor, ), ), ), const HSpace(8), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ FlowyText( widget.file.fileName, fontSize: 12.0, ), FlowyText( widget.file.fileType.name, color: Theme.of(context).hintColor, fontSize: 12.0, ), ], ), ), ], ), ), if (isHover) _CloseButton( onTap: widget.onDeleted, ).positioned(top: 0, right: 0), ], ), ); }, ), ); } void setHover(bool value) { if (value != isHover) { setState(() => isHover = value); } } } class _CloseButton extends StatelessWidget { const _CloseButton({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: onTap, child: FlowySvg( FlowySvgs.ai_close_filled_s, color: AFThemeExtension.of(context).greyHover, size: const Size.square(16), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/layout_define.dart ================================================ import 'package:flutter/widgets.dart'; class DesktopAIPromptSizes { const DesktopAIPromptSizes._(); static const attachedFilesBarPadding = EdgeInsets.only(left: 8.0, top: 8.0, right: 8.0); static const attachedFilesPreviewHeight = 48.0; static const attachedFilesPreviewSpacing = 12.0; static const predefinedFormatButtonHeight = 28.0; static const predefinedFormatIconHeight = 16.0; static const textFieldMinHeight = 36.0; static const textFieldContentPadding = EdgeInsetsDirectional.fromSTEB(14.0, 8.0, 14.0, 8.0); static const actionBarButtonSize = 28.0; static const actionBarIconSize = 16.0; static const actionBarSendButtonSize = 32.0; static const actionBarSendButtonIconSize = 24.0; } class MobileAIPromptSizes { const MobileAIPromptSizes._(); static const attachedFilesBarHeight = 68.0; static const attachedFilesBarPadding = EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0, bottom: 4.0); static const attachedFilesPreviewHeight = 56.0; static const attachedFilesPreviewSpacing = 8.0; static const predefinedFormatButtonHeight = 32.0; static const predefinedFormatIconHeight = 20.0; static const textFieldMinHeight = 32.0; static const textFieldContentPadding = EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0); static const mentionIconSize = 20.0; static const sendButtonSize = 32.0; } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_bottom_sheet.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'mention_page_menu.dart'; Future showPageSelectorSheet( BuildContext context, { required bool Function(ViewPB view) filter, }) async { return showMobileBottomSheet( context, backgroundColor: Theme.of(context).colorScheme.surface, maxChildSize: 0.98, enableDraggableScrollable: true, scrollableWidgetBuilder: (context, scrollController) { return Expanded( child: _MobilePageSelectorBody( filter: filter, scrollController: scrollController, ), ); }, builder: (context) => const SizedBox.shrink(), ); } class _MobilePageSelectorBody extends StatefulWidget { const _MobilePageSelectorBody({ this.filter, this.scrollController, }); final bool Function(ViewPB view)? filter; final ScrollController? scrollController; @override State<_MobilePageSelectorBody> createState() => _MobilePageSelectorBodyState(); } class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> { final textController = TextEditingController(); late final Future> _viewsFuture = _fetchViews(); @override void dispose() { textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return CustomScrollView( controller: widget.scrollController, shrinkWrap: true, slivers: [ SliverPersistentHeader( pinned: true, delegate: _Header( child: ColoredBox( color: Theme.of(context).cardColor, child: Column( mainAxisSize: MainAxisSize.min, children: [ const DragHandle(), SizedBox( height: 44.0, child: Center( child: FlowyText.medium( LocaleKeys.document_mobilePageSelector_title.tr(), fontSize: 16.0, ), ), ), Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0, ), child: SizedBox( height: 44.0, child: FlowySearchTextField( controller: textController, onChanged: (_) => setState(() {}), ), ), ), const Divider(height: 0.5, thickness: 0.5), ], ), ), ), ), FutureBuilder( future: _viewsFuture, builder: (_, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const SliverToBoxAdapter( child: CircularProgressIndicator.adaptive(), ); } if (snapshot.hasError || snapshot.data == null) { return SliverToBoxAdapter( child: FlowyText( LocaleKeys.document_mobilePageSelector_failedToLoad.tr(), ), ); } final views = snapshot.data! .where((v) => widget.filter?.call(v) ?? true) .toList(); final filtered = views.where( (v) => textController.text.isEmpty || v.name .toLowerCase() .contains(textController.text.toLowerCase()), ); if (filtered.isEmpty) { return SliverToBoxAdapter( child: FlowyText( LocaleKeys.document_mobilePageSelector_noPagesFound.tr(), ), ); } return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final view = filtered.elementAt(index); return InkWell( onTap: () => Navigator.of(context).pop(view), borderRadius: BorderRadius.circular(12), splashColor: Colors.transparent, child: Container( height: 44, padding: const EdgeInsets.all(4.0), child: Row( children: [ MentionViewIcon(view: view), const HSpace(8), Expanded( child: MentionViewTitleAndAncestors(view: view), ), ], ), ), ); }, childCount: filtered.length, ), ), ); }, ), ], ); } Future> _fetchViews() async => (await ViewBackendService.getAllViews()).toNullable()?.items ?? []; } class _Header extends SliverPersistentHeaderDelegate { const _Header({ required this.child, }); final Widget child; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent, ) { return child; } @override double get maxExtent => 120.5; @override double get minExtent => 120.5; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return false; } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mention_page_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view_title/view_title_bar_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; const double _itemHeight = 44.0; const double _noPageHeight = 20.0; const double _fixedWidth = 360.0; const double _maxHeight = 328.0; class PromptInputAnchor { PromptInputAnchor(this.anchorKey, this.layerLink); final GlobalKey> anchorKey; final LayerLink layerLink; } class PromptInputMentionPageMenu extends StatefulWidget { const PromptInputMentionPageMenu({ super.key, required this.anchor, required this.textController, required this.onPageSelected, }); final PromptInputAnchor anchor; final TextEditingController textController; final void Function(ViewPB view) onPageSelected; @override State createState() => _PromptInputMentionPageMenuState(); } class _PromptInputMentionPageMenuState extends State { @override void initState() { super.initState(); Future.delayed(Duration.zero, () { if (mounted) { context.read().refreshViews(); } }); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Stack( children: [ CompositedTransformFollower( link: widget.anchor.layerLink, showWhenUnlinked: false, offset: Offset(getPopupOffsetX(), 0.0), followerAnchor: Alignment.bottomLeft, child: Container( constraints: const BoxConstraints( minWidth: _fixedWidth, maxWidth: _fixedWidth, maxHeight: _maxHeight, ), decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(6.0), boxShadow: const [ BoxShadow( color: Color(0x0A1F2329), blurRadius: 24, offset: Offset(0, 8), spreadRadius: 8, ), BoxShadow( color: Color(0x0A1F2329), blurRadius: 12, offset: Offset(0, 6), ), BoxShadow( color: Color(0x0F1F2329), blurRadius: 8, offset: Offset(0, 4), spreadRadius: -8, ), ], ), child: TextFieldTapRegion( child: PromptInputMentionPageList( onPageSelected: widget.onPageSelected, ), ), ), ), ], ); }, ); } double getPopupOffsetX() { if (widget.anchor.anchorKey.currentContext == null) { return 0.0; } final cubit = context.read(); if (cubit.filterStartPosition == -1) { return 0.0; } final textPosition = TextPosition(offset: cubit.filterEndPosition); final renderBox = widget.anchor.anchorKey.currentContext?.findRenderObject() as RenderBox; final textPainter = TextPainter( text: TextSpan(text: cubit.formatIntputText(widget.textController.text)), textDirection: TextDirection.ltr, ); textPainter.layout( minWidth: renderBox.size.width, maxWidth: renderBox.size.width, ); final caretOffset = textPainter.getOffsetForCaret(textPosition, Rect.zero); final boxes = textPainter.getBoxesForSelection( TextSelection( baseOffset: textPosition.offset, extentOffset: textPosition.offset, ), ); if (boxes.isNotEmpty) { return boxes.last.right; } return caretOffset.dx; } } class PromptInputMentionPageList extends StatefulWidget { const PromptInputMentionPageList({ super.key, required this.onPageSelected, }); final void Function(ViewPB view) onPageSelected; @override State createState() => _PromptInputMentionPageListState(); } class _PromptInputMentionPageListState extends State { final autoScrollController = SimpleAutoScrollController( suggestedRowHeight: _itemHeight, beginGetter: (rect) => rect.top + 8.0, endGetter: (rect) => rect.bottom - 8.0, ); @override void dispose() { autoScrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocConsumer( listenWhen: (previous, current) { return previous.maybeWhen( ready: (_, pFocusedViewIndex) => current.maybeWhen( ready: (_, cFocusedViewIndex) => pFocusedViewIndex != cFocusedViewIndex, orElse: () => false, ), orElse: () => false, ); }, listener: (context, state) { state.maybeWhen( ready: (views, focusedViewIndex) { if (focusedViewIndex == -1 || !autoScrollController.hasClients) { return; } if (autoScrollController.isAutoScrolling) { autoScrollController.position .jumpTo(autoScrollController.position.pixels); } autoScrollController.scrollToIndex( focusedViewIndex, duration: const Duration(milliseconds: 200), preferPosition: AutoScrollPosition.begin, ); }, orElse: () {}, ); }, builder: (context, state) { return state.maybeWhen( loading: () { return const Padding( padding: EdgeInsets.all(8.0), child: SizedBox( height: _noPageHeight, child: Center( child: CircularProgressIndicator.adaptive(), ), ), ); }, ready: (views, focusedViewIndex) { if (views.isEmpty) { return Padding( padding: const EdgeInsets.all(8.0), child: SizedBox( height: _noPageHeight, child: Center( child: FlowyText( LocaleKeys.chat_inputActionNoPages.tr(), ), ), ), ); } return ListView.builder( shrinkWrap: true, controller: autoScrollController, padding: const EdgeInsets.all(8.0), itemCount: views.length, itemBuilder: (context, index) { final view = views[index]; return AutoScrollTag( key: ValueKey("chat_mention_page_item_${view.id}"), index: index, controller: autoScrollController, child: _ChatMentionPageItem( view: view, onTap: () => widget.onPageSelected(view), isSelected: focusedViewIndex == index, ), ); }, ); }, orElse: () => const SizedBox.shrink(), ); }, ); } } class _ChatMentionPageItem extends StatelessWidget { const _ChatMentionPageItem({ required this.view, required this.isSelected, required this.onTap, }); final ViewPB view; final bool isSelected; final VoidCallback onTap; @override Widget build(BuildContext context) { return FlowyTooltip( message: view.name, child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: FlowyHover( isSelected: () => isSelected, child: Container( height: _itemHeight, padding: const EdgeInsets.all(4.0), child: Row( children: [ MentionViewIcon(view: view), const HSpace(8.0), Expanded(child: MentionViewTitleAndAncestors(view: view)), ], ), ), ), ), ), ); } } class MentionViewIcon extends StatelessWidget { const MentionViewIcon({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { final spaceIcon = view.buildSpaceIconSvg(context); if (view.icon.value.isNotEmpty) { return SizedBox( width: 16.0, child: RawEmojiIconWidget( emoji: view.icon.toEmojiIconData(), emojiSize: 14, ), ); } if (view.isSpace == true && spaceIcon != null) { return SpaceIcon( dimension: 16.0, svgSize: 9.68, space: view, cornerRadius: 4, ); } return FlowySvg( view.layout.icon, size: const Size.square(16), color: Theme.of(context).hintColor, ); } } class MentionViewTitleAndAncestors extends StatelessWidget { const MentionViewTitleAndAncestors({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => ViewTitleBarBloc(view: view), child: BlocBuilder( builder: (context, state) { final nonEmptyName = view.name.isEmpty ? LocaleKeys.document_title_placeholder.tr() : view.name; final ancestorList = _getViewAncestorList(state.ancestors); if (state.ancestors.isEmpty || ancestorList.trim().isEmpty) { return FlowyText( nonEmptyName, fontSize: 14.0, overflow: TextOverflow.ellipsis, ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText( nonEmptyName, fontSize: 14.0, figmaLineHeight: 20.0, overflow: TextOverflow.ellipsis, ), FlowyText( ancestorList, fontSize: 12.0, figmaLineHeight: 16.0, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ), ], ); }, ), ); } /// see workspace/presentation/widgets/view_title_bar.dart, upon which this /// function was based. This version doesn't include the current view in the /// result, and returns a string rather than a list of widgets String _getViewAncestorList( List views, ) { const lowerBound = 2; final upperBound = views.length - 2; bool hasAddedEllipsis = false; String result = ""; if (views.length <= 1) { return ""; } // ignore the workspace name, use section name instead in the future // skip the workspace view for (var i = 1; i < views.length - 1; i++) { final view = views[i]; if (i >= lowerBound && i < upperBound) { if (!hasAddedEllipsis) { hasAddedEllipsis = true; result += "… / "; } continue; } final nonEmptyName = view.name.isEmpty ? LocaleKeys.document_title_placeholder.tr() : view.name; result += nonEmptyName; if (i != views.length - 2) { // if not the last one, add a divider result += " / "; } } return result; } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/mentioned_page_text_span.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:extended_text_library/extended_text_library.dart'; import 'package:flutter/material.dart'; class PromptInputTextSpanBuilder extends SpecialTextSpanBuilder { PromptInputTextSpanBuilder({ required this.inputControlCubit, this.mentionedPageTextStyle, }); final ChatInputControlCubit inputControlCubit; final TextStyle? mentionedPageTextStyle; @override SpecialText? createSpecialText( String flag, { TextStyle? textStyle, SpecialTextGestureTapCallback? onTap, int? index, }) { if (flag == '') { return null; } if (isStart(flag, MentionedPageText.flag)) { return MentionedPageText( inputControlCubit, mentionedPageTextStyle ?? textStyle, onTap, // scrubbing over text is kinda funky start: index! - (MentionedPageText.flag.length - 1), ); } return null; } } class MentionedPageText extends SpecialText { MentionedPageText( this.inputControlCubit, TextStyle? textStyle, SpecialTextGestureTapCallback? onTap, { this.start, }) : super(flag, '', textStyle, onTap: onTap); static const String flag = '@'; final int? start; final ChatInputControlCubit inputControlCubit; @override bool isEnd(String value) => inputControlCubit.selectedViewIds.contains(value); @override InlineSpan finishText() { final String actualText = toString(); final viewName = inputControlCubit.allViews .firstWhereOrNull((view) => view.id == actualText.substring(1)) ?.name ?? ""; final nonEmptyName = viewName.isEmpty ? LocaleKeys.document_title_placeholder.tr() : viewName; return SpecialTextSpan( text: "@$nonEmptyName", actualText: actualText, start: start!, style: textStyle, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/predefined_format_buttons.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../service/ai_entities.dart'; import 'layout_define.dart'; class PromptInputDesktopToggleFormatButton extends StatelessWidget { const PromptInputDesktopToggleFormatButton({ super.key, required this.showFormatBar, required this.onTap, }); final bool showFormatBar; final VoidCallback onTap; @override Widget build(BuildContext context) { return FlowyIconButton( tooltipText: showFormatBar ? LocaleKeys.chat_changeFormat_defaultDescription.tr() : LocaleKeys.chat_changeFormat_blankDescription.tr(), width: 28.0, onPressed: onTap, icon: showFormatBar ? const FlowySvg( FlowySvgs.m_aa_text_s, size: Size.square(16.0), color: Color(0xFF666D76), ) : const FlowySvg( FlowySvgs.ai_text_image_s, size: Size(21.0, 16.0), color: Color(0xFF666D76), ), ); } } class ChangeFormatBar extends StatelessWidget { const ChangeFormatBar({ super.key, required this.predefinedFormat, required this.spacing, required this.onSelectPredefinedFormat, this.showImageFormats = true, }); final PredefinedFormat? predefinedFormat; final double spacing; final void Function(PredefinedFormat) onSelectPredefinedFormat; final bool showImageFormats; @override Widget build(BuildContext context) { final showTextFormats = predefinedFormat?.imageFormat.hasText ?? true; return SizedBox( height: DesktopAIPromptSizes.predefinedFormatButtonHeight, child: SeparatedRow( mainAxisSize: MainAxisSize.min, separatorBuilder: () => HSpace(spacing), children: [ if (showImageFormats) ...[ _buildFormatButton(context, ImageFormat.text), _buildFormatButton(context, ImageFormat.textAndImage), _buildFormatButton(context, ImageFormat.image), ], if (showImageFormats && showTextFormats) _buildDivider(), if (showTextFormats) ...[ _buildTextFormatButton(context, TextFormat.paragraph), _buildTextFormatButton(context, TextFormat.bulletList), _buildTextFormatButton(context, TextFormat.numberedList), _buildTextFormatButton(context, TextFormat.table), ], ], ), ); } Widget _buildFormatButton(BuildContext context, ImageFormat format) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { if (predefinedFormat != null && format == predefinedFormat!.imageFormat) { return; } if (format.hasText) { final textFormat = predefinedFormat?.textFormat ?? TextFormat.paragraph; onSelectPredefinedFormat( PredefinedFormat(imageFormat: format, textFormat: textFormat), ); } else { onSelectPredefinedFormat( PredefinedFormat(imageFormat: format, textFormat: null), ); } }, child: FlowyTooltip( message: format.i18n, preferBelow: false, child: SizedBox.square( dimension: _buttonSize, child: FlowyHover( isSelected: () => format == predefinedFormat?.imageFormat, child: Center( child: FlowySvg( format.icon, size: format == ImageFormat.textAndImage ? Size(21.0 / 16.0 * _iconSize, _iconSize) : Size.square(_iconSize), ), ), ), ), ), ); } Widget _buildDivider() { return VerticalDivider( indent: 6.0, endIndent: 6.0, width: 1.0 + spacing * 2, ); } Widget _buildTextFormatButton( BuildContext context, TextFormat format, ) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { if (predefinedFormat != null && format == predefinedFormat!.textFormat) { return; } onSelectPredefinedFormat( PredefinedFormat( imageFormat: predefinedFormat?.imageFormat ?? ImageFormat.text, textFormat: format, ), ); }, child: FlowyTooltip( message: format.i18n, preferBelow: false, child: SizedBox.square( dimension: _buttonSize, child: FlowyHover( isSelected: () => format == predefinedFormat?.textFormat, child: Center( child: FlowySvg( format.icon, size: Size.square(_iconSize), ), ), ), ), ), ); } double get _buttonSize { return UniversalPlatform.isMobile ? MobileAIPromptSizes.predefinedFormatButtonHeight : DesktopAIPromptSizes.predefinedFormatButtonHeight; } double get _iconSize { return UniversalPlatform.isMobile ? MobileAIPromptSizes.predefinedFormatIconHeight : DesktopAIPromptSizes.predefinedFormatIconHeight; } } class PromptInputMobileToggleFormatButton extends StatelessWidget { const PromptInputMobileToggleFormatButton({ super.key, required this.showFormatBar, required this.onTap, }); final bool showFormatBar; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox.square( dimension: 32.0, child: FlowyButton( radius: const BorderRadius.all(Radius.circular(8.0)), margin: EdgeInsets.zero, expandText: false, text: showFormatBar ? const FlowySvg( FlowySvgs.m_aa_text_s, size: Size.square(20.0), ) : const FlowySvg( FlowySvgs.ai_text_image_s, size: Size(26.25, 20.0), ), onTap: onTap, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/prompt_input_text_controller.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/widgets.dart'; final openingBracketReplacement = String.fromCharCode(0xFFFE); final closingBracketReplacement = String.fromCharCode(0xFFFD); class AiPromptInputTextEditingController extends TextEditingController { AiPromptInputTextEditingController(); static String replace(String text) { return text .replaceAll('[', openingBracketReplacement) .replaceAll(']', closingBracketReplacement); } static String restore(String text) { return text .replaceAll(openingBracketReplacement, '[') .replaceAll(closingBracketReplacement, ']'); } void usePrompt(String content) { value = TextEditingValue( text: content, selection: TextSelection.collapsed( offset: content.length, ), ); } @override TextSpan buildTextSpan({ required BuildContext context, TextStyle? style, required bool withComposing, }) { return TextSpan( style: style, children: [...getTextSpans(context)], ); } Iterable getTextSpans(BuildContext context) { final open = openingBracketReplacement; final close = closingBracketReplacement; final regex = RegExp('($open[^$open$close]*?$close)'); final theme = AppFlowyTheme.of(context); final result = []; text.splitMapJoin( regex, onMatch: (match) { final string = match.group(0)!; result.add( TextSpan( text: restore(string), style: theme.textStyle.body.standard().copyWith( color: theme.textColorScheme.featured, backgroundColor: theme.fillColorScheme.featuredThick.withAlpha(51), ), ), ); return ''; }, onNonMatch: (nonMatch) { result.add( TextSpan( text: restore(nonMatch), ), ); return ''; }, ); return result; } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SelectModelMenu extends StatefulWidget { const SelectModelMenu({ super.key, required this.aiModelStateNotifier, }); final AIModelStateNotifier aiModelStateNotifier; @override State createState() => _SelectModelMenuState(); } class _SelectModelMenuState extends State { final popoverController = PopoverController(); @override Widget build(BuildContext context) { return BlocProvider( create: (context) => SelectModelBloc( aiModelStateNotifier: widget.aiModelStateNotifier, ), child: BlocBuilder( builder: (context, state) { return AppFlowyPopover( offset: Offset(-12.0, 0.0), constraints: BoxConstraints(maxWidth: 250, maxHeight: 600), direction: PopoverDirection.topWithLeftAligned, margin: EdgeInsets.zero, controller: popoverController, popupBuilder: (popoverContext) { return SelectModelPopoverContent( models: state.models, selectedModel: state.selectedModel, onSelectModel: (model) { if (model != state.selectedModel) { context .read() .add(SelectModelEvent.selectModel(model)); } popoverController.close(); }, ); }, child: _CurrentModelButton( model: state.selectedModel, onTap: () { if (state.selectedModel != null) { popoverController.show(); } }, ), ); }, ), ); } } class SelectModelPopoverContent extends StatelessWidget { const SelectModelPopoverContent({ super.key, required this.models, required this.selectedModel, this.onSelectModel, }); final List models; final AIModelPB? selectedModel; final void Function(AIModelPB)? onSelectModel; @override Widget build(BuildContext context) { if (models.isEmpty) { return const SizedBox.shrink(); } // separate models into local and cloud models final localModels = models.where((model) => model.isLocal).toList(); final cloudModels = models.where((model) => !model.isLocal).toList(); return Padding( padding: const EdgeInsets.all(8.0), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (localModels.isNotEmpty) ...[ _ModelSectionHeader( title: LocaleKeys.chat_switchModel_localModel.tr(), ), const VSpace(4.0), ], ...localModels.map( (model) => _ModelItem( model: model, isSelected: model == selectedModel, onTap: () => onSelectModel?.call(model), ), ), if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[ const VSpace(8.0), _ModelSectionHeader( title: LocaleKeys.chat_switchModel_cloudModel.tr(), ), const VSpace(4.0), ], ...cloudModels.map( (model) => _ModelItem( model: model, isSelected: model == selectedModel, onTap: () => onSelectModel?.call(model), ), ), ], ), ), ); } } class _ModelSectionHeader extends StatelessWidget { const _ModelSectionHeader({ required this.title, }); final String title; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 4, bottom: 2), child: FlowyText( title, fontSize: 12, figmaLineHeight: 16, color: Theme.of(context).hintColor, fontWeight: FontWeight.w500, ), ); } } class _ModelItem extends StatelessWidget { const _ModelItem({ required this.model, required this.isSelected, required this.onTap, }); final AIModelPB model; final bool isSelected; final VoidCallback onTap; @override Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints(minHeight: 32), child: FlowyButton( onTap: onTap, margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), text: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText( model.i18n, figmaLineHeight: 20, overflow: TextOverflow.ellipsis, ), if (model.desc.isNotEmpty) FlowyText( model.desc, fontSize: 12, figmaLineHeight: 16, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ), ], ), rightIcon: isSelected ? FlowySvg( FlowySvgs.check_s, size: const Size.square(20), color: Theme.of(context).colorScheme.primary, ) : null, ), ); } } class _CurrentModelButton extends StatelessWidget { const _CurrentModelButton({ required this.model, required this.onTap, }); final AIModelPB? model; final VoidCallback onTap; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.chat_switchModel_label.tr(), child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: SizedBox( height: DesktopAIPromptSizes.actionBarButtonSize, child: FlowyHover( style: const HoverStyle( borderRadius: BorderRadius.all(Radius.circular(8)), ), child: Padding( padding: const EdgeInsetsDirectional.all(4.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ Padding( // TODO: remove this after change icon to 20px padding: EdgeInsets.all(2), child: FlowySvg( FlowySvgs.ai_sparks_s, color: Theme.of(context).hintColor, size: Size.square(16), ), ), if (model != null && !model!.isDefault) Padding( padding: EdgeInsetsDirectional.only(end: 2.0), child: FlowyText( model!.i18n, fontSize: 12, figmaLineHeight: 16, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ), ), FlowySvg( FlowySvgs.ai_source_drop_down_s, color: Theme.of(context).hintColor, size: const Size.square(8), ), ], ), ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_bottom_sheet.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/service/view_selector_cubit.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'select_sources_menu.dart'; class PromptInputMobileSelectSourcesButton extends StatefulWidget { const PromptInputMobileSelectSourcesButton({ super.key, required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, }); final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; @override State createState() => _PromptInputMobileSelectSourcesButtonState(); } class _PromptInputMobileSelectSourcesButtonState extends State { late final cubit = ViewSelectorCubit( maxSelectedParentPageCount: 3, getIgnoreViewType: (item) { if (item.view.isSpace) { return IgnoreViewType.none; } if (item.view.layout != ViewLayoutPB.Document) { return IgnoreViewType.hide; } return IgnoreViewType.none; }, ); @override void initState() { super.initState(); widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged); WidgetsBinding.instance.addPostFrameCallback((_) { onSelectedSourcesChanged(); }); } @override void dispose() { widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged); cubit.close(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocBuilder( builder: (context, state) { final userProfile = context.read().state.userProfile; final workspaceId = state.currentWorkspace?.workspaceId ?? ''; return MultiBlocProvider( providers: [ BlocProvider( key: ValueKey(workspaceId), create: (context) => SpaceBloc( userProfile: userProfile, workspaceId: workspaceId, )..add(const SpaceEvent.initial(openFirstPage: false)), ), BlocProvider.value( value: cubit, ), ], child: BlocBuilder( builder: (context, state) { return FlowyButton( margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6), expandText: false, text: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.ai_page_s, color: Theme.of(context).iconTheme.color, size: const Size.square(20.0), ), const HSpace(2.0), ValueListenableBuilder( valueListenable: widget.selectedSourcesNotifier, builder: (context, selectedSourceIds, _) { final documentId = context.read()?.documentId; final label = documentId != null && selectedSourceIds.length == 1 && selectedSourceIds[0] == documentId ? LocaleKeys.chat_currentPage.tr() : selectedSourceIds.length.toString(); return FlowyText( label, fontSize: 14, figmaLineHeight: 20, color: Theme.of(context).hintColor, ); }, ), const HSpace(2.0), FlowySvg( FlowySvgs.ai_source_drop_down_s, color: Theme.of(context).hintColor, size: const Size.square(10), ), ], ), onTap: () async { unawaited( context .read() .refreshSources(state.spaces, state.currentSpace), ); await showMobileBottomSheet( context, backgroundColor: theme.surfaceColorScheme.primary, maxChildSize: 0.98, enableDraggableScrollable: true, scrollableWidgetBuilder: (_, scrollController) { return Expanded( child: BlocProvider.value( value: cubit, child: _MobileSelectSourcesSheetBody( scrollController: scrollController, ), ), ); }, builder: (context) => const SizedBox.shrink(), ); if (context.mounted) { widget.onUpdateSelectedSources(cubit.selectedSourceIds); } }, ); }, ), ); }, ); } void onSelectedSourcesChanged() { cubit ..updateSelectedSources(widget.selectedSourcesNotifier.value) ..updateSelectedStatus(); } } class _MobileSelectSourcesSheetBody extends StatelessWidget { const _MobileSelectSourcesSheetBody({ required this.scrollController, }); final ScrollController scrollController; @override Widget build(BuildContext context) { return CustomScrollView( controller: scrollController, shrinkWrap: true, slivers: [ SliverPersistentHeader( pinned: true, delegate: _Header( child: ColoredBox( color: AppFlowyTheme.of(context).surfaceColorScheme.primary, child: Column( mainAxisSize: MainAxisSize.min, children: [ const DragHandle(), SizedBox( height: 44.0, child: Center( child: FlowyText.medium( LocaleKeys.chat_selectSources.tr(), fontSize: 16.0, ), ), ), Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0, ), child: SizedBox( height: 44.0, child: FlowySearchTextField( controller: context .read() .filterTextController, ), ), ), const Divider(height: 0.5, thickness: 0.5), ], ), ), ), ), BlocBuilder( builder: (context, state) { return SliverList( delegate: SliverChildBuilderDelegate( childCount: state.selectedSources.length, (context, index) { final source = state.selectedSources.elementAt(index); return ViewSelectorTreeItem( key: ValueKey( 'selected_select_sources_tree_item_${source.view.id}', ), viewSelectorItem: source, level: 0, isDescendentOfSpace: source.view.isSpace, isSelectedSection: true, onSelected: (item) { context .read() .toggleSelectedStatus(item, true); }, height: 40.0, ); }, ), ); }, ), BlocBuilder( builder: (context, state) { if (state.selectedSources.isNotEmpty && state.visibleSources.isNotEmpty) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: AFDivider(), ), ); } return const SliverToBoxAdapter(); }, ), BlocBuilder( builder: (context, state) { return SliverList( delegate: SliverChildBuilderDelegate( childCount: state.visibleSources.length, (context, index) { final source = state.visibleSources.elementAt(index); return ViewSelectorTreeItem( key: ValueKey( 'visible_select_sources_tree_item_${source.view.id}', ), viewSelectorItem: source, level: 0, isDescendentOfSpace: source.view.isSpace, isSelectedSection: false, onSelected: (item) { context .read() .toggleSelectedStatus(item, false); }, height: 40.0, ); }, ), ); }, ), ], ); } } class _Header extends SliverPersistentHeaderDelegate { const _Header({ required this.child, }); final Widget child; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent, ) { return child; } @override double get maxExtent => 120.5; @override double get minExtent => 120.5; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return false; } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_sources_menu.dart ================================================ import 'dart:math'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../service/view_selector_cubit.dart'; import '../view_selector.dart'; import 'layout_define.dart'; import 'mention_page_menu.dart'; class PromptInputDesktopSelectSourcesButton extends StatefulWidget { const PromptInputDesktopSelectSourcesButton({ super.key, required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, }); final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; @override State createState() => _PromptInputDesktopSelectSourcesButtonState(); } class _PromptInputDesktopSelectSourcesButtonState extends State { late final cubit = ViewSelectorCubit( maxSelectedParentPageCount: 3, getIgnoreViewType: (item) { final view = item.view; if (view.isSpace) { return IgnoreViewType.none; } if (view.layout == ViewLayoutPB.Chat) { return IgnoreViewType.hide; } if (view.layout != ViewLayoutPB.Document) { return IgnoreViewType.disable; } return IgnoreViewType.none; }, ); final popoverController = PopoverController(); @override void initState() { super.initState(); widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged); WidgetsBinding.instance.addPostFrameCallback((_) { onSelectedSourcesChanged(); }); } @override void dispose() { widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged); cubit.close(); super.dispose(); } @override Widget build(BuildContext context) { return ViewSelector( viewSelectorCubit: BlocProvider.value( value: cubit, ), child: BlocBuilder( builder: (context, state) { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(320, 380)), offset: const Offset(0.0, -10.0), direction: PopoverDirection.topWithCenterAligned, margin: EdgeInsets.zero, controller: popoverController, onOpen: () { context .read() .refreshSources(state.spaces, state.currentSpace); }, onClose: () { widget.onUpdateSelectedSources(cubit.selectedSourceIds); context .read() .refreshSources(state.spaces, state.currentSpace); }, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: const _PopoverContent(), ); }, child: _IndicatorButton( selectedSourcesNotifier: widget.selectedSourcesNotifier, onTap: () => popoverController.show(), ), ); }, ), ); } void onSelectedSourcesChanged() { cubit ..updateSelectedSources(widget.selectedSourcesNotifier.value) ..updateSelectedStatus(); } } class _IndicatorButton extends StatelessWidget { const _IndicatorButton({ required this.selectedSourcesNotifier, required this.onTap, }); final ValueNotifier> selectedSourcesNotifier; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: SizedBox( height: DesktopAIPromptSizes.actionBarButtonSize, child: FlowyHover( style: const HoverStyle( borderRadius: BorderRadius.all(Radius.circular(8)), ), child: Padding( padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.ai_page_s, color: Theme.of(context).hintColor, ), const HSpace(2.0), ValueListenableBuilder( valueListenable: selectedSourcesNotifier, builder: (context, selectedSourceIds, _) { final documentId = context.read()?.documentId; final label = documentId != null && selectedSourceIds.length == 1 && selectedSourceIds[0] == documentId ? LocaleKeys.chat_currentPage.tr() : selectedSourceIds.length.toString(); return FlowyText( label, fontSize: 12, figmaLineHeight: 16, color: Theme.of(context).hintColor, ); }, ), const HSpace(2.0), FlowySvg( FlowySvgs.ai_source_drop_down_s, color: Theme.of(context).hintColor, size: const Size.square(8), ), ], ), ), ), ), ); } } class _PopoverContent extends StatelessWidget { const _PopoverContent(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final theme = AppFlowyTheme.of(context); return Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(8, 12, 8, 8), child: AFTextField( size: AFTextFieldSize.m, controller: context.read().filterTextController, hintText: LocaleKeys.search_label.tr(), ), ), AFDivider( startIndent: theme.spacing.l, endIndent: theme.spacing.l, ), Flexible( child: ListView( shrinkWrap: true, padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), children: [ ..._buildSelectedSources(context, state), if (state.selectedSources.isNotEmpty && state.visibleSources.isNotEmpty) AFDivider( spacing: 4.0, startIndent: theme.spacing.l, endIndent: theme.spacing.l, ), ..._buildVisibleSources(context, state), ], ), ), ], ); }, ); } Iterable _buildSelectedSources( BuildContext context, ViewSelectorState state, ) { return state.selectedSources.map( (e) => ViewSelectorTreeItem( key: ValueKey( 'selected_select_sources_tree_item_${e.view.id}', ), viewSelectorItem: e, level: 0, isDescendentOfSpace: e.view.isSpace, isSelectedSection: true, onSelected: (item) { context.read().toggleSelectedStatus(item, true); }, height: 30.0, ), ); } Iterable _buildVisibleSources( BuildContext context, ViewSelectorState state, ) { return state.visibleSources.map( (e) => ViewSelectorTreeItem( key: ValueKey( 'visible_select_sources_tree_item_${e.view.id}', ), viewSelectorItem: e, level: 0, isDescendentOfSpace: e.view.isSpace, isSelectedSection: false, onSelected: (item) { context.read().toggleSelectedStatus(item, false); }, height: 30.0, ), ); } } class ViewSelectorTreeItem extends StatefulWidget { const ViewSelectorTreeItem({ super.key, required this.viewSelectorItem, required this.level, required this.isDescendentOfSpace, required this.isSelectedSection, required this.onSelected, this.onAdd, required this.height, this.showSaveButton = false, this.showCheckbox = true, }); final ViewSelectorItem viewSelectorItem; /// nested level of the view item final int level; final bool isDescendentOfSpace; final bool isSelectedSection; final void Function(ViewSelectorItem viewSelectorItem) onSelected; final void Function(ViewSelectorItem viewSelectorItem)? onAdd; final bool showSaveButton; final double height; final bool showCheckbox; @override State createState() => _ViewSelectorTreeItemState(); } class _ViewSelectorTreeItemState extends State { @override Widget build(BuildContext context) { final child = SizedBox( height: widget.height, child: ViewSelectorTreeItemInner( viewSelectorItem: widget.viewSelectorItem, level: widget.level, isDescendentOfSpace: widget.isDescendentOfSpace, isSelectedSection: widget.isSelectedSection, showCheckbox: widget.showCheckbox, showSaveButton: widget.showSaveButton, onSelected: widget.onSelected, onAdd: widget.onAdd, ), ); final disabledEnabledChild = widget.viewSelectorItem.isDisabled ? FlowyTooltip( message: widget.showCheckbox ? switch (widget.viewSelectorItem.view.layout) { ViewLayoutPB.Document => LocaleKeys.chat_sourcesLimitReached.tr(), _ => LocaleKeys.chat_sourceUnsupported.tr(), } : "", child: Opacity( opacity: 0.5, child: MouseRegion( cursor: SystemMouseCursors.forbidden, child: IgnorePointer(child: child), ), ), ) : child; return ValueListenableBuilder( valueListenable: widget.viewSelectorItem.isExpandedNotifier, builder: (context, isExpanded, child) { // filter the child views that should be ignored final childViews = widget.viewSelectorItem.children; if (!isExpanded || childViews.isEmpty) { return disabledEnabledChild; } return Column( mainAxisSize: MainAxisSize.min, children: [ disabledEnabledChild, ...childViews.map( (childSource) => ViewSelectorTreeItem( key: ValueKey( 'select_sources_tree_item_${childSource.view.id}', ), viewSelectorItem: childSource, level: widget.level + 1, isDescendentOfSpace: widget.isDescendentOfSpace, isSelectedSection: widget.isSelectedSection, onSelected: widget.onSelected, height: widget.height, showCheckbox: widget.showCheckbox, showSaveButton: widget.showSaveButton, onAdd: widget.onAdd, ), ), ], ); }, ); } } class ViewSelectorTreeItemInner extends StatelessWidget { const ViewSelectorTreeItemInner({ super.key, required this.viewSelectorItem, required this.level, required this.isDescendentOfSpace, required this.isSelectedSection, required this.showCheckbox, required this.showSaveButton, this.onSelected, this.onAdd, }); final ViewSelectorItem viewSelectorItem; final int level; final bool isDescendentOfSpace; final bool isSelectedSection; final bool showCheckbox; final bool showSaveButton; final void Function(ViewSelectorItem)? onSelected; final void Function(ViewSelectorItem)? onAdd; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => onSelected?.call(viewSelectorItem), child: FlowyHover( style: HoverStyle( hoverColor: AFThemeExtension.of(context).lightGreyHover, ), builder: (context, onHover) { final theme = AppFlowyTheme.of(context); final isSaveButtonVisible = showSaveButton && !viewSelectorItem.view.isSpace; final isAddButtonVisible = onAdd != null; return Row( children: [ const HSpace(4.0), HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)), // builds the >, ^ or · button ToggleIsExpandedButton( viewSelectorItem: viewSelectorItem, isSelectedSection: isSelectedSection, ), const HSpace(2.0), // checkbox if (!viewSelectorItem.view.isSpace && showCheckbox) ...[ SourceSelectedStatusCheckbox( viewSelectorItem: viewSelectorItem, ), const HSpace(4.0), ], // icon MentionViewIcon( view: viewSelectorItem.view, ), const HSpace(6.0), // title Expanded( child: Text( viewSelectorItem.view.nameOrDefault, overflow: TextOverflow.ellipsis, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), ), if (onHover && (isSaveButtonVisible || isAddButtonVisible)) ...[ const HSpace(4.0), if (isSaveButtonVisible) FlowyIconButton( tooltipText: LocaleKeys.chat_addToPageButton.tr(), width: 24, icon: FlowySvg( FlowySvgs.ai_add_to_page_s, size: const Size.square(16), color: Theme.of(context).hintColor, ), onPressed: () => onSelected?.call(viewSelectorItem), ), if (isSaveButtonVisible && isAddButtonVisible) const HSpace(4.0), if (isAddButtonVisible) FlowyIconButton( tooltipText: LocaleKeys.chat_addToNewPage.tr(), width: 24, icon: FlowySvg( FlowySvgs.add_less_padding_s, size: const Size.square(16), color: Theme.of(context).hintColor, ), onPressed: () => onAdd?.call(viewSelectorItem), ), const HSpace(4.0), ], ], ); }, ), ); } } class ToggleIsExpandedButton extends StatelessWidget { const ToggleIsExpandedButton({ super.key, required this.viewSelectorItem, required this.isSelectedSection, }); final ViewSelectorItem viewSelectorItem; final bool isSelectedSection; @override Widget build(BuildContext context) { if (isReferencedDatabaseView( viewSelectorItem.view, viewSelectorItem.parentView, )) { return const _DotIconWidget(); } if (viewSelectorItem.children.isEmpty) { return const SizedBox.square(dimension: 16.0); } return FlowyHover( child: GestureDetector( child: ValueListenableBuilder( valueListenable: viewSelectorItem.isExpandedNotifier, builder: (context, value, _) => FlowySvg( value ? FlowySvgs.view_item_expand_s : FlowySvgs.view_item_unexpand_s, size: const Size.square(16.0), ), ), onTap: () => context .read() .toggleIsExpanded(viewSelectorItem, isSelectedSection), ), ); } } class _DotIconWidget extends StatelessWidget { const _DotIconWidget(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(6.0), child: Container( width: 4, height: 4, decoration: BoxDecoration( color: Theme.of(context).iconTheme.color, borderRadius: BorderRadius.circular(2), ), ), ); } } class SourceSelectedStatusCheckbox extends StatelessWidget { const SourceSelectedStatusCheckbox({ super.key, required this.viewSelectorItem, }); final ViewSelectorItem viewSelectorItem; @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: viewSelectorItem.selectedStatusNotifier, builder: (context, selectedStatus, _) => FlowySvg( switch (selectedStatus) { ViewSelectedStatus.unselected => FlowySvgs.uncheck_s, ViewSelectedStatus.selected => FlowySvgs.check_filled_s, ViewSelectedStatus.partiallySelected => FlowySvgs.check_partial_s, }, size: const Size.square(18.0), blendMode: null, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/prompt_input/send_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; import 'layout_define.dart'; enum SendButtonState { enabled, streaming, disabled } class PromptInputSendButton extends StatelessWidget { const PromptInputSendButton({ super.key, required this.state, required this.onSendPressed, required this.onStopStreaming, }); final SendButtonState state; final VoidCallback onSendPressed; final VoidCallback onStopStreaming; @override Widget build(BuildContext context) { return FlowyIconButton( width: _buttonSize, richTooltipText: switch (state) { SendButtonState.streaming => TextSpan( children: [ TextSpan( text: '${LocaleKeys.chat_stopTooltip.tr()} ', style: context.tooltipTextStyle(), ), TextSpan( text: 'ESC', style: context .tooltipTextStyle() ?.copyWith(color: Theme.of(context).hintColor), ), ], ), _ => null, }, icon: switch (state) { SendButtonState.enabled => FlowySvg( FlowySvgs.ai_send_filled_s, size: Size.square(_iconSize), color: Theme.of(context).colorScheme.primary, ), SendButtonState.disabled => FlowySvg( FlowySvgs.ai_send_filled_s, size: Size.square(_iconSize), color: Theme.of(context).disabledColor, ), SendButtonState.streaming => FlowySvg( FlowySvgs.ai_stop_filled_s, size: Size.square(_iconSize), color: Theme.of(context).colorScheme.primary, ), }, onPressed: () { switch (state) { case SendButtonState.enabled: onSendPressed(); break; case SendButtonState.streaming: onStopStreaming(); break; case SendButtonState.disabled: break; } }, hoverColor: Colors.transparent, ); } double get _buttonSize { return UniversalPlatform.isMobile ? MobileAIPromptSizes.sendButtonSize : DesktopAIPromptSizes.actionBarSendButtonSize; } double get _iconSize { return UniversalPlatform.isMobile ? MobileAIPromptSizes.sendButtonSize : DesktopAIPromptSizes.actionBarSendButtonIconSize; } } ================================================ FILE: frontend/appflowy_flutter/lib/ai/widgets/view_selector.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/single_child_widget.dart'; class ViewSelector extends StatelessWidget { const ViewSelector({ super.key, required this.viewSelectorCubit, required this.child, }); final SingleChildWidget viewSelectorCubit; final Widget child; @override Widget build(BuildContext context) { final userWorkspaceBloc = context.read(); final userProfile = userWorkspaceBloc.state.userProfile; final workspaceId = userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? ''; return MultiBlocProvider( providers: [ BlocProvider( create: (context) { return SpaceBloc( userProfile: userProfile, workspaceId: workspaceId, )..add(const SpaceEvent.initial(openFirstPage: false)); }, ), viewSelectorCubit, ], child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/core/config/kv.dart ================================================ import 'package:shared_preferences/shared_preferences.dart'; abstract class KeyValueStorage { Future set(String key, String value); Future get(String key); Future getWithFormat( String key, T Function(String value) formatter, ); Future remove(String key); Future clear(); } class DartKeyValue implements KeyValueStorage { SharedPreferences? _sharedPreferences; SharedPreferences get sharedPreferences => _sharedPreferences!; @override Future get(String key) async { await _initSharedPreferencesIfNeeded(); final value = sharedPreferences.getString(key); if (value != null) { return value; } return null; } @override Future getWithFormat( String key, T Function(String value) formatter, ) async { final value = await get(key); if (value == null) { return null; } return formatter(value); } @override Future remove(String key) async { await _initSharedPreferencesIfNeeded(); await sharedPreferences.remove(key); } @override Future set(String key, String value) async { await _initSharedPreferencesIfNeeded(); await sharedPreferences.setString(key, value); } @override Future clear() async { await _initSharedPreferencesIfNeeded(); await sharedPreferences.clear(); } Future _initSharedPreferencesIfNeeded() async { _sharedPreferences ??= await SharedPreferences.getInstance(); } } ================================================ FILE: frontend/appflowy_flutter/lib/core/config/kv_keys.dart ================================================ class KVKeys { const KVKeys._(); static const String prefix = 'io.appflowy.appflowy_flutter'; /// The key for the path location of the local data for the whole app. static const String pathLocation = '$prefix.path_location'; /// The key for saving the window size /// /// The value is a json string with the following format: /// {'height': 600.0, 'width': 800.0} static const String windowSize = 'windowSize'; /// The key for saving the window position /// /// The value is a json string with the following format: /// {'dx': 10.0, 'dy': 10.0} static const String windowPosition = 'windowPosition'; /// The key for saving the window status /// /// The value is a json string with the following format: /// { 'windowMaximized': true } /// static const String windowMaximized = 'windowMaximized'; static const String kDocumentAppearanceFontSize = 'kDocumentAppearanceFontSize'; static const String kDocumentAppearanceFontFamily = 'kDocumentAppearanceFontFamily'; static const String kDocumentAppearanceDefaultTextDirection = 'kDocumentAppearanceDefaultTextDirection'; static const String kDocumentAppearanceCursorColor = 'kDocumentAppearanceCursorColor'; static const String kDocumentAppearanceSelectionColor = 'kDocumentAppearanceSelectionColor'; static const String kDocumentAppearanceWidth = 'kDocumentAppearanceWidth'; /// The key for saving the expanded views /// /// The value is a json string with the following format: /// {'viewId': true, 'viewId2': false} static const String expandedViews = 'expandedViews'; /// The key for saving the expanded folder /// /// The value is a json string with the following format: /// {'SidebarFolderCategoryType.value': true} static const String expandedFolders = 'expandedFolders'; /// @deprecated in version 0.7.6 /// The key for saving if showing the rename dialog when creating a new file /// /// The value is a boolean string. static const String showRenameDialogWhenCreatingNewFile = 'showRenameDialogWhenCreatingNewFile'; static const String kCloudType = 'kCloudType'; static const String kAppflowyCloudBaseURL = 'kAppFlowyCloudBaseURL'; static const String kAppFlowyBaseShareDomain = 'kAppFlowyBaseShareDomain'; static const String kAppFlowyEnableSyncTrace = 'kAppFlowyEnableSyncTrace'; /// The key for saving the text scale factor. /// /// The value is a double string. /// The value range is from 0.8 to 1.0. If it's greater than 1.0, it will cause /// the text to be too large and not aligned with the icon static const String textScaleFactor = 'textScaleFactor'; /// The key for saving the feature flags /// /// The value is a json string with the following format: /// {'feature_flag_1': true, 'feature_flag_2': false} static const String featureFlag = 'featureFlag'; /// The key for saving show notification icon option /// /// The value is a boolean string static const String showNotificationIcon = 'showNotificationIcon'; /// The key for saving the last opened workspace id /// /// The workspace id is a string. @Deprecated('deprecated in version 0.5.5') static const String lastOpenedWorkspaceId = 'lastOpenedWorkspaceId'; /// The key for saving the scale factor /// /// The value is a double string. static const String scaleFactor = 'scaleFactor'; /// The key for saving the last opened tab (favorite, recent, space etc.) /// /// The value is a int string. static const String lastOpenedSpace = 'lastOpenedSpace'; /// The key for saving the space tab order /// /// The value is a json string with the following format: /// [0, 1, 2] static const String spaceOrder = 'spaceOrder'; /// The key for saving the last opened space id (space A, space B) /// /// The value is a string. static const String lastOpenedSpaceId = 'lastOpenedSpaceId'; /// The key for saving the upgrade space tag /// /// The value is a boolean string static const String hasUpgradedSpace = 'hasUpgradedSpace060'; /// The key for saving the recent icons /// /// The value is a json string of [RecentIcons] static const String recentIcons = 'kRecentIcons'; /// The key for saving compact mode ids for node or databse view /// /// The value is a json list of id static const String compactModeIds = 'compactModeIds'; /// v0.9.4: has the user clicked the upgrade to pro button /// The value is a boolean string static const String hasClickedUpgradeToProButton = 'hasClickedUpgradeToProButton'; } ================================================ FILE: frontend/appflowy_flutter/lib/core/frameless_window.dart ================================================ import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:universal_platform/universal_platform.dart'; class CocoaWindowChannel { CocoaWindowChannel._(); final MethodChannel _channel = const MethodChannel("flutter/cocoaWindow"); static final CocoaWindowChannel instance = CocoaWindowChannel._(); Future setWindowPosition(Offset offset) async { await _channel.invokeMethod("setWindowPosition", [offset.dx, offset.dy]); } Future> getWindowPosition() async { final raw = await _channel.invokeMethod("getWindowPosition"); final arr = raw as List; final List result = arr.map((s) => s as double).toList(); return result; } Future zoom() async { await _channel.invokeMethod("zoom"); } } class MoveWindowDetector extends StatefulWidget { const MoveWindowDetector({ super.key, this.child, }); final Widget? child; @override MoveWindowDetectorState createState() => MoveWindowDetectorState(); } class MoveWindowDetectorState extends State { double winX = 0; double winY = 0; @override Widget build(BuildContext context) { // the frameless window is only supported on macOS if (!UniversalPlatform.isMacOS) { return widget.child ?? const SizedBox.shrink(); } // For the macOS version 15 or higher, we can control the window position by using system APIs if (ApplicationInfo.macOSMajorVersion != null && ApplicationInfo.macOSMajorVersion! >= 15) { return widget.child ?? const SizedBox.shrink(); } return GestureDetector( // https://stackoverflow.com/questions/52965799/flutter-gesturedetector-not-working-with-containers-in-stack behavior: HitTestBehavior.translucent, onDoubleTap: () async => CocoaWindowChannel.instance.zoom(), onPanStart: (DragStartDetails details) { winX = details.globalPosition.dx; winY = details.globalPosition.dy; }, onPanUpdate: (DragUpdateDetails details) async { final windowPos = await CocoaWindowChannel.instance.getWindowPosition(); final double dx = windowPos[0]; final double dy = windowPos[1]; final deltaX = details.globalPosition.dx - winX; final deltaY = details.globalPosition.dy - winY; await CocoaWindowChannel.instance .setWindowPosition(Offset(dx + deltaX, dy - deltaY)); }, child: widget.child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/core/helpers/helpers.dart ================================================ export 'target_platform.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/core/helpers/target_platform.dart ================================================ import 'package:flutter/foundation.dart' show TargetPlatform, kIsWeb; extension TargetPlatformHelper on TargetPlatform { /// Convenience function to check if the app is running on a desktop computer. /// /// Easily check if on desktop by checking `defaultTargetPlatform.isDesktop`. bool get isDesktop => !kIsWeb && (this == TargetPlatform.linux || this == TargetPlatform.macOS || this == TargetPlatform.windows); } ================================================ FILE: frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:open_filex/open_filex.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; typedef OnFailureCallback = void Function(Uri uri); /// Launch the uri /// /// If the uri is a local file path, it will be opened with the OpenFilex. /// Otherwise, it will be launched with the url_launcher. Future afLaunchUri( Uri uri, { BuildContext? context, OnFailureCallback? onFailure, launcher.LaunchMode mode = launcher.LaunchMode.platformDefault, String? webOnlyWindowName, bool addingHttpSchemeWhenFailed = false, }) async { final url = uri.toString(); final decodedUrl = Uri.decodeComponent(url); // check if the uri is the local file path if (localPathRegex.hasMatch(decodedUrl)) { return _afLaunchLocalUri( uri, context: context, onFailure: onFailure, ); } // on Linux or Android or Windows, add http scheme to the url if it is not present if ((UniversalPlatform.isLinux || UniversalPlatform.isAndroid || UniversalPlatform.isWindows) && !isURL(url, {'require_protocol': true})) { uri = Uri.parse('https://$url'); } /// opening an incorrect link will cause a system error dialog to pop up on macOS /// only use [canLaunchUrl] on macOS /// and there is an known issue with url_launcher on Linux where it fails to launch /// see https://github.com/flutter/flutter/issues/88463 bool result = true; if (UniversalPlatform.isMacOS) { result = await launcher.canLaunchUrl(uri); } if (result) { try { // try to launch the uri directly result = await launcher.launchUrl( uri, mode: mode, webOnlyWindowName: webOnlyWindowName, ); } on PlatformException catch (e) { Log.error('Failed to open uri: $e'); return false; } } // if the uri is not a valid url, try to launch it with http scheme if (addingHttpSchemeWhenFailed && !result && !isURL(url, {'require_protocol': true})) { try { final uriWithScheme = Uri.parse('http://$url'); result = await launcher.launchUrl( uriWithScheme, mode: mode, webOnlyWindowName: webOnlyWindowName, ); } on PlatformException catch (e) { Log.error('Failed to open uri: $e'); if (context != null && context.mounted) { _errorHandler(uri, context: context, onFailure: onFailure, e: e); } } } return result; } /// Launch the url string /// /// See [afLaunchUri] for more details. Future afLaunchUrlString( String url, { bool addingHttpSchemeWhenFailed = false, BuildContext? context, OnFailureCallback? onFailure, }) async { final Uri uri; try { uri = Uri.parse(url); } on FormatException catch (e) { Log.error('Failed to parse url: $e'); return false; } // try to launch the uri directly return afLaunchUri( uri, addingHttpSchemeWhenFailed: addingHttpSchemeWhenFailed, context: context, onFailure: onFailure, ); } /// Launch the local uri /// /// See [afLaunchUri] for more details. Future _afLaunchLocalUri( Uri uri, { BuildContext? context, OnFailureCallback? onFailure, }) async { final decodedUrl = Uri.decodeComponent(uri.toString()); // open the file with the OpenfileX var result = await OpenFilex.open(decodedUrl); if (result.type != ResultType.done) { // For the file cant be opened, fallback to open the folder final parentFolder = Directory(decodedUrl).parent.path; result = await OpenFilex.open(parentFolder); } // show the toast if the file is not found final message = switch (result.type) { ResultType.done => LocaleKeys.openFileMessage_success.tr(), ResultType.fileNotFound => LocaleKeys.openFileMessage_fileNotFound.tr(), ResultType.noAppToOpen => LocaleKeys.openFileMessage_noAppToOpenFile.tr(), ResultType.permissionDenied => LocaleKeys.openFileMessage_permissionDenied.tr(), ResultType.error => LocaleKeys.failedToOpenUrl.tr(), }; if (context != null && context.mounted) { showToastNotification( message: message, type: result.type == ResultType.done ? ToastificationType.success : ToastificationType.error, ); } final openFileSuccess = result.type == ResultType.done; if (!openFileSuccess && onFailure != null) { onFailure(uri); Log.error('Failed to open file: $result.message'); } return openFileSuccess; } void _errorHandler( Uri uri, { BuildContext? context, OnFailureCallback? onFailure, PlatformException? e, }) { Log.error('Failed to open uri: $e'); if (onFailure != null) { onFailure(uri); } else { showMessageToast( LocaleKeys.failedToOpenUrl.tr(args: [e?.message ?? "PlatformException"]), context: context, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/core/network_monitor.dart ================================================ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/services.dart'; class NetworkListener { NetworkListener() { _connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); } final Connectivity _connectivity = Connectivity(); late StreamSubscription _connectivitySubscription; Future start() async { late ConnectivityResult result; // Platform messages may fail, so we use a try/catch PlatformException. try { result = await _connectivity.checkConnectivity(); } on PlatformException catch (e) { Log.error("Couldn't check connectivity status. $e"); return; } return _updateConnectionStatus(result); } Future stop() async { await _connectivitySubscription.cancel(); } Future _updateConnectionStatus(ConnectivityResult result) async { final networkType = () { switch (result) { case ConnectivityResult.wifi: return NetworkTypePB.Wifi; case ConnectivityResult.ethernet: return NetworkTypePB.Ethernet; case ConnectivityResult.mobile: return NetworkTypePB.Cell; case ConnectivityResult.bluetooth: return NetworkTypePB.Bluetooth; case ConnectivityResult.vpn: return NetworkTypePB.VPN; case ConnectivityResult.none: case ConnectivityResult.other: return NetworkTypePB.NetworkUnknown; } }(); final state = NetworkStatePB.create()..ty = networkType; return UserEventUpdateNetworkState(state).send().then((result) { result.fold((l) {}, (e) => Log.error(e)); }); } } ================================================ FILE: frontend/appflowy_flutter/lib/core/notification/document_notification.dart ================================================ import 'package:appflowy/core/notification/notification_helper.dart'; import 'package:appflowy_backend/protobuf/flowy-document/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; // This value should be the same as the DOCUMENT_OBSERVABLE_SOURCE value const String _source = 'Document'; class DocumentNotificationParser extends NotificationParser { DocumentNotificationParser({ super.id, required super.callback, }) : super( tyParser: (ty, source) => source == _source ? DocumentNotification.valueOf(ty) : null, errorParser: (bytes) => FlowyError.fromBuffer(bytes), ); } ================================================ FILE: frontend/appflowy_flutter/lib/core/notification/folder_notification.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'notification_helper.dart'; // This value should be the same as the FOLDER_OBSERVABLE_SOURCE value const String _source = 'Workspace'; class FolderNotificationParser extends NotificationParser { FolderNotificationParser({ super.id, required super.callback, }) : super( tyParser: (ty, source) => source == _source ? FolderNotification.valueOf(ty) : null, errorParser: (bytes) => FlowyError.fromBuffer(bytes), ); } typedef FolderNotificationHandler = Function( FolderNotification ty, FlowyResult result, ); class FolderNotificationListener { FolderNotificationListener({ required String objectId, required FolderNotificationHandler handler, }) : _parser = FolderNotificationParser( id: objectId, callback: handler, ) { _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); } FolderNotificationParser? _parser; StreamSubscription? _subscription; Future stop() async { _parser = null; await _subscription?.cancel(); } } ================================================ FILE: frontend/appflowy_flutter/lib/core/notification/grid_notification.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'notification_helper.dart'; // This value should be the same as the DATABASE_OBSERVABLE_SOURCE value const String _source = 'Database'; class DatabaseNotificationParser extends NotificationParser { DatabaseNotificationParser({ super.id, required super.callback, }) : super( tyParser: (ty, source) => source == _source ? DatabaseNotification.valueOf(ty) : null, errorParser: (bytes) => FlowyError.fromBuffer(bytes), ); } typedef DatabaseNotificationHandler = Function( DatabaseNotification ty, FlowyResult result, ); class DatabaseNotificationListener { DatabaseNotificationListener({ required String objectId, required DatabaseNotificationHandler handler, }) : _parser = DatabaseNotificationParser(id: objectId, callback: handler) { _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); } DatabaseNotificationParser? _parser; StreamSubscription? _subscription; Future stop() async { _parser = null; await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/core/notification/notification_helper.dart ================================================ import 'dart:typed_data'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class NotificationParser { NotificationParser({ this.id, required this.callback, required this.errorParser, required this.tyParser, }); String? id; void Function(T, FlowyResult) callback; E Function(Uint8List) errorParser; T? Function(int, String) tyParser; void parse(SubscribeObject subject) { if (id != null) { if (subject.id != id) { return; } } final ty = tyParser(subject.ty, subject.source); if (ty == null) { return; } if (subject.hasError()) { final bytes = Uint8List.fromList(subject.error); final error = errorParser(bytes); callback(ty, FlowyResult.failure(error)); } else { final bytes = Uint8List.fromList(subject.payload); callback(ty, FlowyResult.success(bytes)); } } } ================================================ FILE: frontend/appflowy_flutter/lib/core/notification/search_notification.dart ================================================ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-search/notification.pbenum.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'notification_helper.dart'; // This value must be identical to the value in the backend (SEARCH_OBSERVABLE_SOURCE) const _source = 'Search'; class SearchNotificationParser extends NotificationParser { SearchNotificationParser({ super.id, required super.callback, String? channel, }) : super( tyParser: (ty, source) => source == "$_source$channel" ? SearchNotification.valueOf(ty) : null, errorParser: (bytes) => FlowyError.fromBuffer(bytes), ); } typedef SearchNotificationHandler = Function( SearchNotification ty, FlowyResult result, ); class SearchNotificationListener { SearchNotificationListener({ required String objectId, required SearchNotificationHandler handler, String? channel, }) : _parser = SearchNotificationParser( id: objectId, callback: handler, channel: channel, ) { _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); } StreamSubscription? _subscription; SearchNotificationParser? _parser; Future stop() async { _parser = null; await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/core/notification/user_notification.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'notification_helper.dart'; // This value should be the same as the USER_OBSERVABLE_SOURCE value const String _source = 'User'; class UserNotificationParser extends NotificationParser { UserNotificationParser({ required String super.id, required super.callback, }) : super( tyParser: (ty, source) => source == _source ? UserNotification.valueOf(ty) : null, errorParser: (bytes) => FlowyError.fromBuffer(bytes), ); } ================================================ FILE: frontend/appflowy_flutter/lib/date/date_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-date/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class DateService { static Future> queryDate( String search, ) async { final query = DateQueryPB.create()..query = search; final result = await DateEventQueryDate(query).send(); return result.fold( (s) { final date = DateTime.tryParse(s.date); if (date != null) { return FlowyResult.success(date); } return FlowyResult.failure( FlowyError(msg: 'Could not parse Date (NLP) from String'), ); }, (e) => FlowyResult.failure(e), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/env/backend_env.dart ================================================ // ignore_for_file: non_constant_identifier_names import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:json_annotation/json_annotation.dart'; part 'backend_env.g.dart'; @JsonSerializable() class AppFlowyConfiguration { AppFlowyConfiguration({ required this.root, required this.app_version, required this.custom_app_path, required this.origin_app_path, required this.device_id, required this.platform, required this.authenticator_type, required this.appflowy_cloud_config, required this.envs, }); factory AppFlowyConfiguration.fromJson(Map json) => _$AppFlowyConfigurationFromJson(json); final String root; final String app_version; final String custom_app_path; final String origin_app_path; final String device_id; final String platform; final int authenticator_type; final AppFlowyCloudConfiguration appflowy_cloud_config; final Map envs; Map toJson() => _$AppFlowyConfigurationToJson(this); } @JsonSerializable() class AppFlowyCloudConfiguration { AppFlowyCloudConfiguration({ required this.base_url, required this.ws_base_url, required this.gotrue_url, required this.enable_sync_trace, required this.base_web_domain, }); factory AppFlowyCloudConfiguration.fromJson(Map json) => _$AppFlowyCloudConfigurationFromJson(json); final String base_url; final String ws_base_url; final String gotrue_url; final bool enable_sync_trace; /// The base domain is used in /// /// - Share URL /// - Publish URL /// - Copy Link To Block final String base_web_domain; Map toJson() => _$AppFlowyCloudConfigurationToJson(this); static AppFlowyCloudConfiguration defaultConfig() { return AppFlowyCloudConfiguration( base_url: '', ws_base_url: '', gotrue_url: '', enable_sync_trace: false, base_web_domain: ShareConstants.defaultBaseWebDomain, ); } bool get isValid { return base_url.isNotEmpty && ws_base_url.isNotEmpty && gotrue_url.isNotEmpty; } } ================================================ FILE: frontend/appflowy_flutter/lib/env/cloud_env.dart ================================================ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/env/backend_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; /// Sets the cloud type for the application. /// /// This method updates the cloud type setting in the key-value storage /// using the [KeyValueStorage] service. The cloud type is identified /// by the [AuthenticatorType] enum. /// /// [ty] - The type of cloud to be set. It must be one of the values from /// [AuthenticatorType] enum. The corresponding integer value of the enum is stored: /// - `CloudType.local` is stored as "0". /// - `CloudType.appflowyCloud` is stored as "2". /// /// The gap between [AuthenticatorType.local] and [AuthenticatorType.appflowyCloud] is /// due to previously supporting Supabase, this has been deprecated since and removed. /// To not cause conflicts with older clients, we keep the gap. /// Future _setAuthenticatorType(AuthenticatorType ty) async { switch (ty) { case AuthenticatorType.local: await getIt().set(KVKeys.kCloudType, 0.toString()); break; case AuthenticatorType.appflowyCloud: await getIt().set(KVKeys.kCloudType, 2.toString()); break; case AuthenticatorType.appflowyCloudSelfHost: await getIt().set(KVKeys.kCloudType, 3.toString()); break; case AuthenticatorType.appflowyCloudDevelop: await getIt().set(KVKeys.kCloudType, 4.toString()); break; } } const String kAppflowyCloudUrl = "https://beta.appflowy.cloud"; /// Retrieves the currently set cloud type. /// /// This method fetches the cloud type setting from the key-value storage /// using the [KeyValueStorage] service and returns the corresponding /// [AuthenticatorType] enum value. /// /// Returns: /// A Future that resolves to a [AuthenticatorType] enum value representing the /// currently set cloud type. The default return value is `CloudType.local` /// if no valid setting is found. /// Future getAuthenticatorType() async { final value = await getIt().get(KVKeys.kCloudType); if (value == null && !integrationMode().isUnitTest) { // if the cloud type is not set, then set it to AppFlowy Cloud as default. await useAppFlowyBetaCloudWithURL( kAppflowyCloudUrl, AuthenticatorType.appflowyCloud, ); return AuthenticatorType.appflowyCloud; } switch (value ?? "0") { case "0": return AuthenticatorType.local; case "2": return AuthenticatorType.appflowyCloud; case "3": return AuthenticatorType.appflowyCloudSelfHost; case "4": return AuthenticatorType.appflowyCloudDevelop; default: await useAppFlowyBetaCloudWithURL( kAppflowyCloudUrl, AuthenticatorType.appflowyCloud, ); return AuthenticatorType.appflowyCloud; } } /// Determines whether authentication is enabled. /// /// This getter evaluates if authentication should be enabled based on the /// current integration mode and cloud type settings. /// /// Returns: /// A boolean value indicating whether authentication is enabled. It returns /// `true` if the application is in release or develop mode, and the cloud type /// is not set to `CloudType.local`. Additionally, it checks if either the /// AppFlowy Cloud configuration is valid. /// Returns `false` otherwise. bool get isAuthEnabled { final env = getIt(); if (env.authenticatorType.isAppFlowyCloudEnabled) { return env.appflowyCloudConfig.isValid; } return false; } bool get isLocalAuthEnabled { return currentCloudType().isLocal; } /// Determines if AppFlowy Cloud is enabled. bool get isAppFlowyCloudEnabled { return currentCloudType().isAppFlowyCloudEnabled; } enum AuthenticatorType { local, appflowyCloud, appflowyCloudSelfHost, // The 'appflowyCloudDevelop' type is used for develop purposes only. appflowyCloudDevelop; bool get isLocal => this == AuthenticatorType.local; bool get isAppFlowyCloudEnabled => this == AuthenticatorType.appflowyCloudSelfHost || this == AuthenticatorType.appflowyCloudDevelop || this == AuthenticatorType.appflowyCloud; int get value { switch (this) { case AuthenticatorType.local: return 0; case AuthenticatorType.appflowyCloud: return 2; case AuthenticatorType.appflowyCloudSelfHost: return 3; case AuthenticatorType.appflowyCloudDevelop: return 4; } } static AuthenticatorType fromValue(int value) { switch (value) { case 0: return AuthenticatorType.local; case 2: return AuthenticatorType.appflowyCloud; case 3: return AuthenticatorType.appflowyCloudSelfHost; case 4: return AuthenticatorType.appflowyCloudDevelop; default: return AuthenticatorType.local; } } } AuthenticatorType currentCloudType() { return getIt().authenticatorType; } Future _setAppFlowyCloudUrl(String? url) async { await getIt().set(KVKeys.kAppflowyCloudBaseURL, url ?? ''); } Future useBaseWebDomain(String? url) async { await getIt().set( KVKeys.kAppFlowyBaseShareDomain, url ?? ShareConstants.defaultBaseWebDomain, ); } Future useSelfHostedAppFlowyCloud(String url) async { await _setAuthenticatorType(AuthenticatorType.appflowyCloudSelfHost); await _setAppFlowyCloudUrl(url); } Future useAppFlowyCloudDevelop(String url) async { await _setAuthenticatorType(AuthenticatorType.appflowyCloudDevelop); await _setAppFlowyCloudUrl(url); } Future useAppFlowyBetaCloudWithURL( String url, AuthenticatorType authenticatorType, ) async { await _setAuthenticatorType(authenticatorType); await _setAppFlowyCloudUrl(url); } Future useLocalServer() async { await _setAuthenticatorType(AuthenticatorType.local); } // Use getIt() to get the shared environment. class AppFlowyCloudSharedEnv { AppFlowyCloudSharedEnv({ required AuthenticatorType authenticatorType, required this.appflowyCloudConfig, }) : _authenticatorType = authenticatorType; final AuthenticatorType _authenticatorType; final AppFlowyCloudConfiguration appflowyCloudConfig; AuthenticatorType get authenticatorType => _authenticatorType; static Future fromEnv() async { // If [Env.enableCustomCloud] is true, then use the custom cloud configuration. if (Env.enableCustomCloud) { // Use the custom cloud configuration. var authenticatorType = await getAuthenticatorType(); final appflowyCloudConfig = authenticatorType.isAppFlowyCloudEnabled ? await getAppFlowyCloudConfig(authenticatorType) : AppFlowyCloudConfiguration.defaultConfig(); // In the backend, the value '2' represents the use of AppFlowy Cloud. However, in the frontend, // we distinguish between [AuthenticatorType.appflowyCloudSelfHost] and [AuthenticatorType.appflowyCloud]. // When the cloud type is [AuthenticatorType.appflowyCloudSelfHost] in the frontend, it should be // converted to [AuthenticatorType.appflowyCloud] to align with the backend representation, // where both types are indicated by the value '2'. if (authenticatorType.isAppFlowyCloudEnabled) { authenticatorType = AuthenticatorType.appflowyCloud; } return AppFlowyCloudSharedEnv( authenticatorType: authenticatorType, appflowyCloudConfig: appflowyCloudConfig, ); } else { // Using the cloud settings from the .env file. final appflowyCloudConfig = AppFlowyCloudConfiguration( base_url: Env.afCloudUrl, ws_base_url: await _getAppFlowyCloudWSUrl(Env.afCloudUrl), gotrue_url: await _getAppFlowyCloudGotrueUrl(Env.afCloudUrl), enable_sync_trace: false, base_web_domain: Env.baseWebDomain, ); return AppFlowyCloudSharedEnv( authenticatorType: AuthenticatorType.fromValue(Env.authenticatorType), appflowyCloudConfig: appflowyCloudConfig, ); } } @override String toString() { return 'authenticator: $_authenticatorType\n' 'appflowy: ${appflowyCloudConfig.toJson()}\n'; } } Future configurationFromUri( Uri baseUri, String baseUrl, AuthenticatorType authenticatorType, String baseShareDomain, ) async { // In development mode, the app is configured to access the AppFlowy cloud server directly through specific ports. // This setup bypasses the need for Nginx, meaning that the AppFlowy cloud should be running without an Nginx server // in the development environment. // If you modify following code, please update the corresponding documentation in the appflowy billing. if (authenticatorType == AuthenticatorType.appflowyCloudDevelop) { return AppFlowyCloudConfiguration( base_url: "$baseUrl:8000", ws_base_url: "ws://${baseUri.host}:8000/ws/v1", gotrue_url: "$baseUrl:9999", enable_sync_trace: true, base_web_domain: ShareConstants.testBaseWebDomain, ); } else { return AppFlowyCloudConfiguration( base_url: baseUrl, ws_base_url: await _getAppFlowyCloudWSUrl(baseUrl), gotrue_url: await _getAppFlowyCloudGotrueUrl(baseUrl), enable_sync_trace: await getSyncLogEnabled(), base_web_domain: authenticatorType == AuthenticatorType.appflowyCloud ? ShareConstants.defaultBaseWebDomain : baseShareDomain, ); } } Future getAppFlowyCloudConfig( AuthenticatorType authenticatorType, ) async { final baseURL = await getAppFlowyCloudUrl(); final baseShareDomain = await getAppFlowyShareDomain(); try { final uri = Uri.parse(baseURL); return await configurationFromUri( uri, baseURL, authenticatorType, baseShareDomain, ); } catch (e) { Log.error("Failed to parse AppFlowy Cloud URL: $e"); return AppFlowyCloudConfiguration.defaultConfig(); } } Future getAppFlowyCloudUrl() async { final result = await getIt().get(KVKeys.kAppflowyCloudBaseURL); return result ?? kAppflowyCloudUrl; } Future getAppFlowyShareDomain() async { final result = await getIt().get(KVKeys.kAppFlowyBaseShareDomain); return result ?? ShareConstants.defaultBaseWebDomain; } Future getSyncLogEnabled() async { final result = await getIt().get(KVKeys.kAppFlowyEnableSyncTrace); if (result == null) { return false; } return result.toLowerCase() == "true"; } Future setSyncLogEnabled(bool enable) async { await getIt().set( KVKeys.kAppFlowyEnableSyncTrace, enable.toString().toLowerCase(), ); } Future _getAppFlowyCloudWSUrl(String baseURL) async { try { final uri = Uri.parse(baseURL); // Construct the WebSocket URL directly from the parsed URI. final wsScheme = uri.isScheme('HTTPS') ? 'wss' : 'ws'; final wsUrl = Uri(scheme: wsScheme, host: uri.host, port: uri.port, path: '/ws/v1'); return wsUrl.toString(); } catch (e) { Log.error("Failed to get WebSocket URL: $e"); return ""; } } Future _getAppFlowyCloudGotrueUrl(String baseURL) async { return "$baseURL/gotrue"; } ================================================ FILE: frontend/appflowy_flutter/lib/env/cloud_env_test.dart ================================================ // lib/env/env.dart // ignore_for_file: prefer_const_declarations import 'package:envied/envied.dart'; part 'cloud_env_test.g.dart'; /// Follow the guide on https://supabase.com/docs/guides/auth/social-login/auth-google to setup the auth provider. /// @Envied(path: '.env.cloud.test') abstract class TestEnv { /// AppFlowy Cloud Configuration @EnviedField( obfuscate: false, varName: 'APPFLOWY_CLOUD_URL', defaultValue: 'http://localhost', ) static final String afCloudUrl = _TestEnv.afCloudUrl; // Supabase Configuration: @EnviedField( obfuscate: false, varName: 'SUPABASE_URL', defaultValue: '', ) static final String supabaseUrl = _TestEnv.supabaseUrl; @EnviedField( obfuscate: false, varName: 'SUPABASE_ANON_KEY', defaultValue: '', ) static final String supabaseAnonKey = _TestEnv.supabaseAnonKey; } ================================================ FILE: frontend/appflowy_flutter/lib/env/env.dart ================================================ // lib/env/env.dart import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:envied/envied.dart'; part 'env.g.dart'; @Envied(path: '.env') abstract class Env { // This flag is used to decide if users can dynamically configure cloud settings. It turns true when a .env file exists containing the APPFLOWY_CLOUD_URL variable. By default, this is set to false. static bool get enableCustomCloud { return Env.authenticatorType == AuthenticatorType.appflowyCloudSelfHost.value || Env.authenticatorType == AuthenticatorType.appflowyCloud.value || Env.authenticatorType == AuthenticatorType.appflowyCloudDevelop.value && _Env.afCloudUrl.isEmpty; } @EnviedField( obfuscate: false, varName: 'AUTHENTICATOR_TYPE', defaultValue: 2, ) static const int authenticatorType = _Env.authenticatorType; /// AppFlowy Cloud Configuration @EnviedField( obfuscate: false, varName: 'APPFLOWY_CLOUD_URL', defaultValue: '', ) static const String afCloudUrl = _Env.afCloudUrl; @EnviedField( obfuscate: false, varName: 'INTERNAL_BUILD', defaultValue: '', ) static const String internalBuild = _Env.internalBuild; @EnviedField( obfuscate: false, varName: 'SENTRY_DSN', defaultValue: '', ) static const String sentryDsn = _Env.sentryDsn; @EnviedField( obfuscate: false, varName: 'BASE_WEB_DOMAIN', defaultValue: ShareConstants.defaultBaseWebDomain, ) static const String baseWebDomain = _Env.baseWebDomain; } ================================================ FILE: frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/page_access_level_repository.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; /// Abstract repository for managing view lock status. /// /// For example, we're using rust events now, but we can still use the http api /// for the future. abstract class PageAccessLevelRepository { /// Gets the current view from the backend. Future> getView(String pageId); /// Locks the view. Future> lockView(String pageId); /// Unlocks the view. Future> unlockView(String pageId); /// Gets the access level of the current user. Future> getAccessLevel( String pageId, ); /// Gets the section type of the shared section. Future> getSectionType( String pageId, ); /// Get current workspace Future> getCurrentWorkspace(); } ================================================ FILE: frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/rust_page_access_level_repository_impl.dart ================================================ import 'package:appflowy/features/page_access_level/data/repositories/page_access_level_repository.dart'; import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/util/extensions.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart' hide AFRolePB; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { @override Future> getView(String pageId) async { final result = await ViewBackendService.getView(pageId); return result.fold( (view) { Log.debug('get view(${view.id}) success'); return FlowyResult.success(view); }, (error) { Log.error('failed to get view, error: $error'); return FlowyResult.failure(error); }, ); } @override Future> lockView(String pageId) async { final result = await ViewBackendService.lockView(pageId); return result.fold( (_) { Log.debug('lock view($pageId) success'); return FlowyResult.success(null); }, (error) { Log.error('failed to lock view, error: $error'); return FlowyResult.failure(error); }, ); } @override Future> unlockView(String pageId) async { final result = await ViewBackendService.unlockView(pageId); return result.fold( (_) { Log.debug('unlock view($pageId) success'); return FlowyResult.success(null); }, (error) { Log.error('failed to unlock view, error: $error'); return FlowyResult.failure(error); }, ); } /// 1. local users have full access /// 2. local workspace users have full access /// 3. page creator has full access /// 4. owner and members in public page have full access /// 5. check the shared users list @override Future> getAccessLevel( String pageId, ) async { final userResult = await UserBackendService.getCurrentUserProfile(); final user = userResult.fold( (s) => s, (_) => null, ); if (user == null) { return FlowyResult.failure( FlowyError( code: ErrorCode.Internal, msg: 'User not found', ), ); } if (user.userAuthType == AuthTypePB.Local) { // Local user can always have full access. return FlowyResult.success(ShareAccessLevel.fullAccess); } if (user.workspaceType == WorkspaceTypePB.LocalW) { // Local workspace can always have full access. return FlowyResult.success(ShareAccessLevel.fullAccess); } // If the user is the creator of the page, they can always have full access. final viewResult = await getView(pageId); final view = viewResult.fold( (s) => s, (_) => null, ); if (view?.createdBy == user.id) { return FlowyResult.success(ShareAccessLevel.fullAccess); } // If the page is public, the user can always have full access. final workspaceResult = await getCurrentWorkspace(); final workspace = workspaceResult.fold( (s) => s, (_) => null, ); if (workspace == null) { return FlowyResult.failure( FlowyError( code: ErrorCode.Internal, msg: 'Current workspace not found', ), ); } final sectionTypeResult = await getSectionType(pageId); final sectionType = sectionTypeResult.fold( (s) => s, (_) => null, ); if (sectionType == SharedSectionType.public && workspace.role != AFRolePB.Guest) { return FlowyResult.success(ShareAccessLevel.fullAccess); } final email = user.email; final request = GetSharedUsersPayloadPB( viewId: pageId, ); final result = await FolderEventGetSharedUsers(request).send(); return result.fold( (success) { final accessLevel = success.items .firstWhereOrNull( (item) => item.email == email, ) ?.accessLevel .shareAccessLevel ?? ShareAccessLevel.readOnly; Log.debug('current user access level: $accessLevel, in page: $pageId'); return FlowyResult.success(accessLevel); }, (failure) { Log.error( 'failed to get user access level: $failure, in page: $pageId', ); // return the read access level if the user is not found return FlowyResult.success(ShareAccessLevel.readOnly); }, ); } @override Future> getSectionType( String pageId, ) async { final request = ViewIdPB(value: pageId); final result = await FolderEventGetSharedViewSection(request).send(); return result.fold( (success) { final sectionType = success.section.sharedSectionType; Log.debug('shared section type: $sectionType, in page: $pageId'); return FlowyResult.success(sectionType); }, (failure) { Log.error( 'failed to get shared section type: $failure, in page: $pageId', ); return FlowyResult.failure(failure); }, ); } @override Future> getCurrentWorkspace() async { final result = await UserBackendService.getCurrentWorkspace(); final currentWorkspaceId = result.fold( (s) => s.id, (_) => null, ); if (currentWorkspaceId == null) { return FlowyResult.failure( FlowyError( code: ErrorCode.Internal, msg: 'Current workspace not found', ), ); } final workspaceResult = await UserBackendService.getWorkspaceById( currentWorkspaceId, ); return workspaceResult; } } ================================================ FILE: frontend/appflowy_flutter/lib/features/page_access_level/logic/page_access_level_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/features/page_access_level/data/repositories/page_access_level_repository.dart'; import 'package:appflowy/features/page_access_level/data/repositories/rust_page_access_level_repository_impl.dart'; import 'package:appflowy/features/page_access_level/logic/page_access_level_event.dart'; import 'package:appflowy/features/page_access_level/logic/page_access_level_state.dart'; import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:protobuf/protobuf.dart'; export 'page_access_level_event.dart'; export 'page_access_level_state.dart'; class PageAccessLevelBloc extends Bloc { PageAccessLevelBloc({ required this.view, this.ignorePageAccessLevel = false, PageAccessLevelRepository? repository, }) : repository = repository ?? RustPageAccessLevelRepositoryImpl(), listener = ViewListener(viewId: view.id), super(PageAccessLevelState.initial(view)) { on(_onInitial); on(_onLock); on(_onUnlock); on(_onUpdateLockStatus); on(_onUpdateSectionType); } final ViewPB view; // The repository to manage view lock status. // If you need to test this bloc, you can add your own repository implementation. final PageAccessLevelRepository repository; // Used to listen for view updates. late final ViewListener listener; // should ignore the page access level // in the row details page, we don't need to check the page access level final bool ignorePageAccessLevel; @override Future close() async { await listener.stop(); return super.close(); } Future _onInitial( PageAccessLevelInitialEvent event, Emitter emit, ) async { // lock status listener.start( onViewUpdated: (view) async { add(PageAccessLevelEvent.updateLockStatus(view.isLocked)); }, ); // section type final sectionTypeResult = await repository.getSectionType(view.id); final sectionType = sectionTypeResult.fold( (sectionType) => sectionType, (_) => SharedSectionType.public, ); if (!FeatureFlag.sharedSection.isOn || ignorePageAccessLevel) { emit( state.copyWith( view: view, isLocked: view.isLocked, isLoadingLockStatus: false, accessLevel: ShareAccessLevel.fullAccess, sectionType: sectionType, ), ); return; } final result = await repository.getView(view.id); final accessLevel = await repository.getAccessLevel(view.id); final latestView = result.fold( (view) => view, (_) => view, ); emit( state.copyWith( view: latestView, isLocked: latestView.isLocked, isLoadingLockStatus: false, accessLevel: accessLevel.fold( (accessLevel) => accessLevel, (_) => ShareAccessLevel.readOnly, ), sectionType: sectionType, ), ); } Future _onLock( PageAccessLevelLockEvent event, Emitter emit, ) async { final result = await repository.lockView(view.id); final isLocked = result.fold( (_) => true, (_) => false, ); add( PageAccessLevelEvent.updateLockStatus( isLocked, ), ); } Future _onUnlock( PageAccessLevelUnlockEvent event, Emitter emit, ) async { final result = await repository.unlockView(view.id); final isLocked = result.fold( (_) => false, (_) => true, ); add( PageAccessLevelEvent.updateLockStatus( isLocked, lockCounter: state.lockCounter + 1, ), ); } void _onUpdateLockStatus( PageAccessLevelUpdateLockStatusEvent event, Emitter emit, ) { state.view.freeze(); final updatedView = state.view.rebuild( (update) => update.isLocked = event.isLocked, ); emit( state.copyWith( view: updatedView, isLocked: event.isLocked, lockCounter: event.lockCounter ?? state.lockCounter, ), ); } void _onUpdateSectionType( PageAccessLevelUpdateSectionTypeEvent event, Emitter emit, ) { emit( state.copyWith( sectionType: event.sectionType, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/page_access_level/logic/page_access_level_event.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; /// Base class for all PageAccessLevel events sealed class PageAccessLevelEvent { const PageAccessLevelEvent(); /// Initialize the view lock status, it will create a view listener to listen for view updates. /// Also, it will fetch the current view lock status from the repository. const factory PageAccessLevelEvent.initial() = PageAccessLevelInitialEvent; /// Lock the view. const factory PageAccessLevelEvent.lock() = PageAccessLevelLockEvent; /// Unlock the view. const factory PageAccessLevelEvent.unlock() = PageAccessLevelUnlockEvent; /// Update the lock status in the state. const factory PageAccessLevelEvent.updateLockStatus( bool isLocked, { int? lockCounter, }) = PageAccessLevelUpdateLockStatusEvent; /// Update the section type in the state. const factory PageAccessLevelEvent.updateSectionType( SharedSectionType sectionType, ) = PageAccessLevelUpdateSectionTypeEvent; } class PageAccessLevelInitialEvent extends PageAccessLevelEvent { const PageAccessLevelInitialEvent(); } class PageAccessLevelLockEvent extends PageAccessLevelEvent { const PageAccessLevelLockEvent(); } class PageAccessLevelUnlockEvent extends PageAccessLevelEvent { const PageAccessLevelUnlockEvent(); } class PageAccessLevelUpdateLockStatusEvent extends PageAccessLevelEvent { const PageAccessLevelUpdateLockStatusEvent( this.isLocked, { this.lockCounter, }); final bool isLocked; final int? lockCounter; } class PageAccessLevelUpdateSectionTypeEvent extends PageAccessLevelEvent { const PageAccessLevelUpdateSectionTypeEvent(this.sectionType); final SharedSectionType sectionType; } ================================================ FILE: frontend/appflowy_flutter/lib/features/page_access_level/logic/page_access_level_state.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; class PageAccessLevelState { factory PageAccessLevelState.initial(ViewPB view) => PageAccessLevelState( view: view, isLocked: false, lockCounter: 0, sectionType: SharedSectionType.public, accessLevel: ShareAccessLevel.readOnly, ); const PageAccessLevelState({ required this.view, required this.isLocked, required this.lockCounter, required this.accessLevel, required this.sectionType, this.myRole, this.isLoadingLockStatus = true, }); final ViewPB view; final bool isLocked; final int lockCounter; final bool isLoadingLockStatus; final ShareAccessLevel accessLevel; final SharedSectionType sectionType; final ShareRole? myRole; bool get isPublic => sectionType == SharedSectionType.public; bool get isPrivate => sectionType == SharedSectionType.private; bool get isShared => sectionType == SharedSectionType.shared; bool get shouldHideSpace => myRole == ShareRole.guest; bool get isEditable { if (!FeatureFlag.sharedSection.isOn) { return !isLocked; } return accessLevel != ShareAccessLevel.readOnly && !isLocked; } bool get isReadOnly => accessLevel == ShareAccessLevel.readOnly; PageAccessLevelState copyWith({ ViewPB? view, bool? isLocked, int? lockCounter, bool? isLoadingLockStatus, ShareAccessLevel? accessLevel, SharedSectionType? sectionType, ShareRole? myRole, }) { return PageAccessLevelState( view: view ?? this.view, isLocked: isLocked ?? this.isLocked, lockCounter: lockCounter ?? this.lockCounter, isLoadingLockStatus: isLoadingLockStatus ?? this.isLoadingLockStatus, accessLevel: accessLevel ?? this.accessLevel, sectionType: sectionType ?? this.sectionType, myRole: myRole ?? this.myRole, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is PageAccessLevelState && other.view == view && other.isLocked == isLocked && other.lockCounter == lockCounter && other.isLoadingLockStatus == isLoadingLockStatus && other.accessLevel == accessLevel && other.sectionType == sectionType && other.myRole == myRole; } @override int get hashCode { return Object.hash( view, isLocked, lockCounter, isLoadingLockStatus, accessLevel, sectionType, myRole, ); } @override String toString() { return 'PageAccessLevelState(view: $view, isLocked: $isLocked, lockCounter: $lockCounter, isLoadingLockStatus: $isLoadingLockStatus, accessLevel: $accessLevel, sectionType: $sectionType, myRole: $myRole)'; } } ================================================ FILE: frontend/appflowy_flutter/lib/features/settings/data/models/user_data_location.dart ================================================ import 'package:equatable/equatable.dart'; class UserDataLocation extends Equatable { const UserDataLocation({ required this.path, required this.isCustom, }); final String path; final bool isCustom; @override List get props => [path, isCustom]; } ================================================ FILE: frontend/appflowy_flutter/lib/features/settings/data/repositories/rust_settings_repository_impl.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/rust_sdk.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import '../models/user_data_location.dart'; import 'settings_repository.dart'; class RustSettingsRepositoryImpl implements SettingsRepository { const RustSettingsRepositoryImpl(); final _userBackendService = const UserSettingsBackendService(); @override Future> getUserDataLocation() async { final defaultDirectory = (await appFlowyApplicationDataDirectory()).path; final result = await _userBackendService.getUserSetting(); return result.map( (settings) { final userDirectory = settings.userFolder; return UserDataLocation( path: userDirectory, isCustom: userDirectory.contains(defaultDirectory), ); }, ); } @override Future> resetUserDataLocation() async { final directory = await appFlowyApplicationDataDirectory(); await getIt().setPath(directory.path); return FlowyResult.success( UserDataLocation( path: directory.path, isCustom: false, ), ); } @override Future> setCustomLocation( String path, ) async { final defaultDirectory = (await appFlowyApplicationDataDirectory()).path; await getIt().setCustomPath(path); return FlowyResult.success( UserDataLocation( path: path, isCustom: path.contains(defaultDirectory), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/settings/data/repositories/settings_repository.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import '../models/user_data_location.dart'; abstract class SettingsRepository { Future> getUserDataLocation(); Future> resetUserDataLocation(); Future> setCustomLocation( String path, ); } ================================================ FILE: frontend/appflowy_flutter/lib/features/settings/logic/data_location_bloc.dart ================================================ import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../data/repositories/settings_repository.dart'; import 'data_location_event.dart'; import 'data_location_state.dart'; class DataLocationBloc extends Bloc { DataLocationBloc({ required SettingsRepository repository, }) : _repository = repository, super(DataLocationState.initial()) { on(_onStarted); on(_onResetToDefault); on(_onSetCustomPath); on(_onClearState); } final SettingsRepository _repository; Future _onStarted( DataLocationInitial event, Emitter emit, ) async { final userDataLocation = await _repository.getUserDataLocation().toNullable(); emit( DataLocationState( userDataLocation: userDataLocation, didResetToDefault: false, ), ); } Future _onResetToDefault( DataLocationResetToDefault event, Emitter emit, ) async { final defaultLocation = await _repository.resetUserDataLocation().toNullable(); emit( DataLocationState( userDataLocation: defaultLocation, didResetToDefault: true, ), ); } Future _onClearState( DataLocationClearState event, Emitter emit, ) async { emit( state.copyWith( didResetToDefault: false, ), ); } Future _onSetCustomPath( DataLocationSetCustomPath event, Emitter emit, ) async { final userDataLocation = await _repository.setCustomLocation(event.path).toNullable(); emit( state.copyWith( userDataLocation: userDataLocation, didResetToDefault: false, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/settings/logic/data_location_event.dart ================================================ sealed class DataLocationEvent { const DataLocationEvent(); factory DataLocationEvent.initial() = DataLocationInitial; factory DataLocationEvent.resetToDefault() = DataLocationResetToDefault; factory DataLocationEvent.setCustomPath(String path) = DataLocationSetCustomPath; factory DataLocationEvent.clearState() = DataLocationClearState; } class DataLocationInitial extends DataLocationEvent {} class DataLocationResetToDefault extends DataLocationEvent {} class DataLocationSetCustomPath extends DataLocationEvent { const DataLocationSetCustomPath(this.path); final String path; } class DataLocationClearState extends DataLocationEvent {} ================================================ FILE: frontend/appflowy_flutter/lib/features/settings/logic/data_location_state.dart ================================================ import 'package:equatable/equatable.dart'; import '../data/models/user_data_location.dart'; class DataLocationState extends Equatable { const DataLocationState({ required this.userDataLocation, required this.didResetToDefault, }); factory DataLocationState.initial() => const DataLocationState(userDataLocation: null, didResetToDefault: false); final UserDataLocation? userDataLocation; final bool didResetToDefault; @override List get props => [userDataLocation, didResetToDefault]; DataLocationState copyWith({ UserDataLocation? userDataLocation, bool? didResetToDefault, }) { return DataLocationState( userDataLocation: userDataLocation ?? this.userDataLocation, didResetToDefault: didResetToDefault ?? this.didResetToDefault, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/settings/settings.dart ================================================ export 'logic/data_location_bloc.dart'; export 'logic/data_location_event.dart'; export 'logic/data_location_state.dart'; export 'data/models/user_data_location.dart'; export 'data/repositories/settings_repository.dart'; export 'data/repositories/rust_settings_repository_impl.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/models/models.dart ================================================ export 'share_access_level.dart'; export 'share_popover_group_id.dart'; export 'share_role.dart'; export 'shared_user.dart'; export 'share_section_type.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/models/share_access_level.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; /// The access level a user can have on a shared page. enum ShareAccessLevel { /// Can view the page only. readOnly, /// Can read and comment on the page. readAndComment, /// Can read and write to the page. readAndWrite, /// Full access (edit, share, remove, etc.) and can add new users. fullAccess; String get title { switch (this) { case ShareAccessLevel.readOnly: return LocaleKeys.shareTab_accessLevel_view.tr(); case ShareAccessLevel.readAndComment: return LocaleKeys.shareTab_accessLevel_comment.tr(); case ShareAccessLevel.readAndWrite: return LocaleKeys.shareTab_accessLevel_edit.tr(); case ShareAccessLevel.fullAccess: return LocaleKeys.shareTab_accessLevel_fullAccess.tr(); } } String get subtitle { switch (this) { case ShareAccessLevel.readOnly: return LocaleKeys.shareTab_cantMakeChanges.tr(); case ShareAccessLevel.readAndComment: return LocaleKeys.shareTab_canMakeAnyChanges.tr(); case ShareAccessLevel.readAndWrite: return LocaleKeys.shareTab_canMakeAnyChanges.tr(); case ShareAccessLevel.fullAccess: return LocaleKeys.shareTab_canMakeAnyChanges.tr(); } } FlowySvgData get icon { switch (this) { case ShareAccessLevel.readOnly: return FlowySvgs.access_level_view_m; case ShareAccessLevel.readAndComment: return FlowySvgs.access_level_edit_m; case ShareAccessLevel.readAndWrite: return FlowySvgs.access_level_edit_m; case ShareAccessLevel.fullAccess: return FlowySvgs.access_level_edit_m; } } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/models/share_popover_group_id.dart ================================================ class SharePopoverGroupId {} ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/models/share_role.dart ================================================ enum ShareRole { /// The owner of the shared page. owner, /// A guest on the shared page. guest, /// A member of the shared page. member, } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/models/share_section_type.dart ================================================ /// The type of the shared section. /// /// - public: the shared section is public, anyone in the workspace can view/edit it. /// - shared: the shared section is shared, anyone in the shared section can view/edit it. /// - private: the shared section is private, only the users in the shared section can view/edit it. enum SharedSectionType { unknown, public, shared, private; } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/models/shared_group.dart ================================================ class SharedGroup { SharedGroup({ required this.id, required this.name, required this.icon, }); final String id; final String name; final String icon; SharedGroup copyWith({ String? id, String? name, String? icon, }) { return SharedGroup( id: id ?? this.id, name: name ?? this.name, icon: icon ?? this.icon, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/models/shared_user.dart ================================================ import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/share_tab/data/models/share_role.dart'; typedef SharedUsers = List; /// Represents a user with a role on a shared page. class SharedUser { SharedUser({ required this.email, required this.name, required this.role, required this.accessLevel, this.avatarUrl, }); final String email; /// The name of the user. final String name; /// The role of the user. final ShareRole role; /// The access level of the user. final ShareAccessLevel accessLevel; /// The avatar URL of the user. /// /// if the avatar is not set, it will be the first letter of the name. final String? avatarUrl; SharedUser copyWith({ String? email, String? name, ShareRole? role, ShareAccessLevel? accessLevel, String? avatarUrl, }) { return SharedUser( email: email ?? this.email, name: name ?? this.name, role: role ?? this.role, accessLevel: accessLevel ?? this.accessLevel, avatarUrl: avatarUrl ?? this.avatarUrl, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/repositories/local_share_with_user_repository_impl.dart ================================================ import 'dart:math'; import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'share_with_user_repository.dart'; // Move this file to test folder class LocalShareWithUserRepositoryImpl extends ShareWithUserRepository { LocalShareWithUserRepositoryImpl(); final SharedUsers _sharedUsers = [ // current user has full access SharedUser( email: 'lucas.xu@appflowy.io', name: 'Lucas Xu - Long long long long long name', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.guest, avatarUrl: 'https://avatar.iran.liara.run/public', ), // member user has read and write access SharedUser( email: 'vivian@appflowy.io', name: 'Vivian Wang', accessLevel: ShareAccessLevel.readAndWrite, role: ShareRole.member, avatarUrl: 'https://avatar.iran.liara.run/public/girl', ), // member user has read access SharedUser( email: 'shuheng@appflowy.io', name: 'Shuheng', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.member, avatarUrl: 'https://avatar.iran.liara.run/public/boy', ), // guest user has read access SharedUser( email: 'guest_user_1@appflowy.io', name: 'Read Only Guest', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.guest, avatarUrl: 'https://avatar.iran.liara.run/public/boy/10', ), // guest user has read and write access SharedUser( email: 'guest_user_2@appflowy.io', name: 'Read And Write Guest', accessLevel: ShareAccessLevel.readAndWrite, role: ShareRole.guest, avatarUrl: 'https://avatar.iran.liara.run/public/boy/11', ), // Others SharedUser( email: 'member_user_1@appflowy.io', name: 'Member User 1', accessLevel: ShareAccessLevel.readAndWrite, role: ShareRole.member, avatarUrl: 'https://avatar.iran.liara.run/public/boy/12', ), SharedUser( email: 'member_user_2@appflowy.io', name: 'Member User 2', accessLevel: ShareAccessLevel.readAndWrite, role: ShareRole.member, avatarUrl: 'https://avatar.iran.liara.run/public/boy/13', ), ]; final SharedUsers _availableSharedUsers = [ SharedUser( email: 'guest_email@appflowy.io', name: 'Guest', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.guest, avatarUrl: 'https://avatar.iran.liara.run/public/boy/28', ), SharedUser( email: 'richard@appflowy.io', name: 'Richard', accessLevel: ShareAccessLevel.readAndWrite, role: ShareRole.member, avatarUrl: 'https://avatar.iran.liara.run/public/boy/28', ), ]; @override Future> getSharedUsersInPage({ required String pageId, }) async { return FlowySuccess(_sharedUsers); } @override Future> removeSharedUserFromPage({ required String pageId, required List emails, }) async { for (final email in emails) { _sharedUsers.removeWhere((user) => user.email == email); } return FlowySuccess(null); } @override Future> sharePageWithUser({ required String pageId, required ShareAccessLevel accessLevel, required List emails, }) async { for (final email in emails) { final index = _sharedUsers.indexWhere((user) => user.email == email); if (index != -1) { // Update access level if user exists final user = _sharedUsers[index]; _sharedUsers[index] = SharedUser( name: user.name, email: user.email, accessLevel: accessLevel, role: user.role, avatarUrl: user.avatarUrl, ); } else { // Add new user _sharedUsers.add( SharedUser( name: email.split('@').first, email: email, accessLevel: accessLevel, role: ShareRole.guest, avatarUrl: 'https://avatar.iran.liara.run/public/${Random().nextInt(100)}', ), ); } } return FlowySuccess(null); } @override Future> getAvailableSharedUsers({ required String pageId, }) async { return FlowySuccess([ ..._sharedUsers, ..._availableSharedUsers, ]); } @override Future> changeRole({ required String workspaceId, required String email, required ShareRole role, }) async { final index = _sharedUsers.indexWhere((user) => user.email == email); if (index != -1) { _sharedUsers[index] = _sharedUsers[index].copyWith(role: role); } return FlowySuccess(null); } @override Future> getCurrentUserProfile() async { // Simulate fetching current user profile return FlowySuccess( UserProfilePB() ..email = 'lucas.xu@appflowy.io' ..name = 'Lucas Xu', ); } @override Future> getCurrentPageSectionType({ required String pageId, }) async { return FlowySuccess(SharedSectionType.private); } @override Future getUpgradeToProButtonClicked({ required String workspaceId, }) async { return false; } @override Future setUpgradeToProButtonClicked({ required String workspaceId, }) async { return; } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/repositories/rust_share_with_user_repository_impl.dart ================================================ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/util/extensions.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'share_with_user_repository.dart'; class RustShareWithUserRepositoryImpl extends ShareWithUserRepository { RustShareWithUserRepositoryImpl(); @override Future> getSharedUsersInPage({ required String pageId, }) async { final request = GetSharedUsersPayloadPB( viewId: pageId, ); final result = await FolderEventGetSharedUsers(request).send(); return result.fold( (success) { Log.debug('get shared users success: $success'); return FlowySuccess(success.sharedUsers); }, (failure) { Log.error('get shared users failed: $failure'); return FlowyFailure(failure); }, ); } @override Future> removeSharedUserFromPage({ required String pageId, required List emails, }) async { final request = RemoveUserFromSharedPagePayloadPB( viewId: pageId, emails: emails, ); final result = await FolderEventRemoveUserFromSharedPage(request).send(); return result.fold( (success) { Log.debug('remove users($emails) from shared page($pageId)'); return FlowySuccess(success); }, (failure) { Log.error('remove users($emails) from shared page($pageId): $failure'); return FlowyFailure(failure); }, ); } @override Future> sharePageWithUser({ required String pageId, required ShareAccessLevel accessLevel, required List emails, }) async { final request = SharePageWithUserPayloadPB( viewId: pageId, emails: emails, accessLevel: accessLevel.accessLevel, autoConfirm: true, ); final result = await FolderEventSharePageWithUser(request).send(); return result.fold( (success) { Log.debug( 'share page($pageId) with users($emails) with access level($accessLevel)', ); return FlowySuccess(success); }, (failure) { Log.error( 'share page($pageId) with users($emails) with access level($accessLevel): $failure', ); return FlowyFailure(failure); }, ); } @override Future> getAvailableSharedUsers({ required String pageId, }) async { return FlowySuccess([]); } @override Future> changeRole({ required String workspaceId, required String email, required ShareRole role, }) async { final request = UpdateWorkspaceMemberPB( workspaceId: workspaceId, email: email, role: role.userRole, ); final result = await UserEventUpdateWorkspaceMember(request).send(); return result.fold( (success) { Log.debug( 'change role($role) for user($email) in workspaceId($workspaceId)', ); return FlowySuccess(success); }, (failure) { Log.error( 'failed to change role($role) for user($email) in workspaceId($workspaceId)', failure, ); return FlowyFailure(failure); }, ); } @override Future> getCurrentUserProfile() async { final result = await UserEventGetUserProfile().send(); return result; } @override Future> getCurrentPageSectionType({ required String pageId, }) async { final request = ViewIdPB.create()..value = pageId; final result = await FolderEventGetViewAncestors(request).send(); final ancestors = result.fold( (s) => s.items, (f) => [], ); final space = ancestors.firstWhereOrNull((e) => e.isSpace); if (space == null) { return FlowySuccess(SharedSectionType.unknown); } final sectionType = switch (space.spacePermission) { SpacePermission.publicToAll => SharedSectionType.public, SpacePermission.private => SharedSectionType.private, }; return FlowySuccess(sectionType); } @override Future getUpgradeToProButtonClicked({ required String workspaceId, }) async { final result = await getIt().getWithFormat( '${KVKeys.hasClickedUpgradeToProButton}_$workspaceId', (value) => bool.parse(value), ); if (result == null) { return false; } return result; } @override Future setUpgradeToProButtonClicked({ required String workspaceId, }) async { await getIt().set( '${KVKeys.hasClickedUpgradeToProButton}_$workspaceId', 'true', ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/data/repositories/share_with_user_repository.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; /// Abstract repository for sharing with users. /// /// For example, we're using rust events now, but we can still use the http api /// for the future. abstract class ShareWithUserRepository { /// Gets the list of users and their roles for a shared page. Future> getSharedUsersInPage({ required String pageId, }); /// Gets the list of users that are available to be shared with. Future> getAvailableSharedUsers({ required String pageId, }); /// Removes a user from a shared page. Future> removeSharedUserFromPage({ required String pageId, required List emails, }); /// Shares a page with a user, assigning a role. /// /// If the user is already in the shared page, the access level will be updated. Future> sharePageWithUser({ required String pageId, required ShareAccessLevel accessLevel, required List emails, }); /// Change the role of a user in a shared page. Future> changeRole({ required String workspaceId, required String email, required ShareRole role, }); /// Get current user profile. Future> getCurrentUserProfile(); /// Get current page is in public section or private section. Future> getCurrentPageSectionType({ required String pageId, }); /// Get the upgrade to pro button has been clicked. Future getUpgradeToProButtonClicked({ required String workspaceId, }); /// Set the upgrade to pro button has been clicked. Future setUpgradeToProButtonClicked({ required String workspaceId, }); } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_bloc.dart ================================================ import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/data/repositories/share_with_user_repository.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_event.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_state.dart'; import 'package:appflowy/features/util/extensions.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; export 'share_tab_event.dart'; export 'share_tab_state.dart'; class ShareTabBloc extends Bloc { ShareTabBloc({ required this.repository, required this.pageId, required this.workspaceId, }) : super(ShareTabState.initial()) { on(_onInitial); on(_onGetSharedUsers); on(_onShare); on(_onRemove); on(_onUpdateAccessLevel); on(_onUpdateGeneralAccess); on(_onCopyLink); on(_onSearchAvailableUsers); on(_onTurnIntoMember); on(_onClearState); on(_onUpdateSharedUsers); on(_onUpgradeToProClicked); } final ShareWithUserRepository repository; final String workspaceId; final String pageId; // Used to listen for shared view updates. FolderNotificationListener? _folderNotificationListener; @override Future close() async { await _folderNotificationListener?.stop(); await super.close(); } Future _onInitial( ShareTabEventInitialize event, Emitter emit, ) async { if (!FeatureFlag.sharedSection.isOn) { emit( state.copyWith( errorMessage: 'Sharing is currently disabled.', users: [], isLoading: false, ), ); return; } _initFolderNotificationListener(); final result = await repository.getCurrentUserProfile(); final currentUser = result.fold( (user) => user, (error) => null, ); final sectionTypeResult = await repository.getCurrentPageSectionType( pageId: pageId, ); final sectionType = sectionTypeResult.fold( (type) => type, (error) => SharedSectionType.unknown, ); final shareLink = ShareConstants.buildShareUrl( workspaceId: workspaceId, viewId: pageId, ); final users = await _getSharedUsers(); final hasClickedUpgradeToPro = await repository.getUpgradeToProButtonClicked( workspaceId: workspaceId, ); emit( state.copyWith( currentUser: currentUser, shareLink: shareLink, users: users, sectionType: sectionType, hasClickedUpgradeToPro: hasClickedUpgradeToPro, ), ); } Future _onGetSharedUsers( ShareTabEventLoadSharedUsers event, Emitter emit, ) async { if (!FeatureFlag.sharedSection.isOn) { return; } emit( state.copyWith( errorMessage: '', ), ); final result = await repository.getSharedUsersInPage( pageId: pageId, ); result.fold( (users) => emit( state.copyWith( users: users, initialResult: FlowySuccess(null), ), ), (error) => emit( state.copyWith( errorMessage: error.msg, initialResult: FlowyFailure(error), ), ), ); } Future _onShare( ShareTabEventInviteUsers event, Emitter emit, ) async { emit( state.copyWith( errorMessage: '', ), ); final result = await repository.sharePageWithUser( pageId: pageId, accessLevel: event.accessLevel, emails: event.emails, ); await result.fold( (_) async { final users = await _getSharedUsers(); emit( state.copyWith( shareResult: FlowySuccess(null), users: users, ), ); }, (error) async { emit( state.copyWith( errorMessage: error.msg, shareResult: FlowyFailure(error), ), ); }, ); } Future _onRemove( ShareTabEventRemoveUsers event, Emitter emit, ) async { emit( state.copyWith( errorMessage: '', ), ); final result = await repository.removeSharedUserFromPage( pageId: pageId, emails: event.emails, ); await result.fold( (_) async { final users = await _getSharedUsers(); emit( state.copyWith( removeResult: FlowySuccess(null), users: users, ), ); }, (error) async { emit( state.copyWith( isLoading: false, removeResult: FlowyFailure(error), ), ); }, ); } Future _onUpdateAccessLevel( ShareTabEventUpdateUserAccessLevel event, Emitter emit, ) async { emit( state.copyWith(), ); final result = await repository.sharePageWithUser( pageId: pageId, accessLevel: event.accessLevel, emails: [event.email], ); await result.fold( (_) async { final users = await _getSharedUsers(); emit( state.copyWith( updateAccessLevelResult: FlowySuccess(null), users: users, ), ); }, (error) async { emit( state.copyWith( errorMessage: error.msg, isLoading: false, ), ); }, ); } void _onUpdateGeneralAccess( ShareTabEventUpdateGeneralAccessLevel event, Emitter emit, ) { emit( state.copyWith( generalAccessRole: event.accessLevel, ), ); } void _onCopyLink( ShareTabEventCopyShareLink event, Emitter emit, ) { getIt().setData( ClipboardServiceData( plainText: event.link, ), ); emit( state.copyWith( linkCopied: true, ), ); } Future _onSearchAvailableUsers( ShareTabEventSearchAvailableUsers event, Emitter emit, ) async { emit( state.copyWith( errorMessage: '', ), ); final result = await repository.getAvailableSharedUsers(pageId: pageId); result.fold( (users) { // filter by email and name final availableUsers = users.where((user) { final query = event.query.toLowerCase(); return user.name.toLowerCase().contains(query) || user.email.toLowerCase().contains(query); }).toList(); emit( state.copyWith( availableUsers: availableUsers, ), ); }, (error) => emit( state.copyWith( errorMessage: error.msg, availableUsers: [], ), ), ); } Future _onTurnIntoMember( ShareTabEventConvertToMember event, Emitter emit, ) async { emit( state.copyWith( errorMessage: '', ), ); final result = await repository.changeRole( workspaceId: workspaceId, email: event.email, role: ShareRole.member, ); await result.fold( (_) async { final users = await _getSharedUsers(); emit( state.copyWith( turnIntoMemberResult: FlowySuccess(null), users: users, ), ); }, (error) async { emit( state.copyWith( errorMessage: error.msg, turnIntoMemberResult: FlowyFailure(error), ), ); }, ); } Future _getSharedUsers() async { final shareResult = await repository.getSharedUsersInPage( pageId: pageId, ); return shareResult.fold( (users) => users, (error) => state.users, ); } void _onClearState( ShareTabEventClearState event, Emitter emit, ) { emit( state.copyWith( errorMessage: '', ), ); } void _onUpdateSharedUsers( ShareTabEventUpdateSharedUsers event, Emitter emit, ) { emit( state.copyWith( users: event.users, ), ); } Future _onUpgradeToProClicked( ShareTabEventUpgradeToProClicked event, Emitter emit, ) async { await repository.setUpgradeToProButtonClicked( workspaceId: workspaceId, ); emit( state.copyWith( hasClickedUpgradeToPro: true, ), ); } void _initFolderNotificationListener() { _folderNotificationListener = FolderNotificationListener( objectId: pageId, handler: (notification, result) { if (notification == FolderNotification.DidUpdateSharedUsers) { final response = result.fold( (payload) { final repeatedSharedUsers = RepeatedSharedUserPB.fromBuffer(payload); return repeatedSharedUsers; }, (error) => null, ); Log.debug('update shared users: $response'); if (response != null) { add( ShareTabEvent.updateSharedUsers( users: response.sharedUsers.reversed.toList(), ), ); } } }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_event.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; sealed class ShareTabEvent { const ShareTabEvent(); // Factory functions for creating events factory ShareTabEvent.initialize() => const ShareTabEventInitialize(); factory ShareTabEvent.loadSharedUsers() => const ShareTabEventLoadSharedUsers(); factory ShareTabEvent.inviteUsers({ required List emails, required ShareAccessLevel accessLevel, }) => ShareTabEventInviteUsers(emails: emails, accessLevel: accessLevel); factory ShareTabEvent.removeUsers({ required List emails, }) => ShareTabEventRemoveUsers(emails: emails); factory ShareTabEvent.updateUserAccessLevel({ required String email, required ShareAccessLevel accessLevel, }) => ShareTabEventUpdateUserAccessLevel( email: email, accessLevel: accessLevel, ); factory ShareTabEvent.updateGeneralAccessLevel({ required ShareAccessLevel accessLevel, }) => ShareTabEventUpdateGeneralAccessLevel(accessLevel: accessLevel); factory ShareTabEvent.copyShareLink({ required String link, }) => ShareTabEventCopyShareLink(link: link); factory ShareTabEvent.searchAvailableUsers({ required String query, }) => ShareTabEventSearchAvailableUsers(query: query); factory ShareTabEvent.convertToMember({ required String email, }) => ShareTabEventConvertToMember(email: email); factory ShareTabEvent.clearState() => const ShareTabEventClearState(); factory ShareTabEvent.updateSharedUsers({ required SharedUsers users, }) => ShareTabEventUpdateSharedUsers(users: users); factory ShareTabEvent.upgradeToProClicked() => const ShareTabEventUpgradeToProClicked(); } /// Initializes the share tab bloc. class ShareTabEventInitialize extends ShareTabEvent { const ShareTabEventInitialize(); } /// Loads the shared users for the current page. class ShareTabEventLoadSharedUsers extends ShareTabEvent { const ShareTabEventLoadSharedUsers(); } /// Invites users to the page with specified access level. class ShareTabEventInviteUsers extends ShareTabEvent { const ShareTabEventInviteUsers({ required this.emails, required this.accessLevel, }); final List emails; final ShareAccessLevel accessLevel; } /// Removes users from the shared page. class ShareTabEventRemoveUsers extends ShareTabEvent { const ShareTabEventRemoveUsers({ required this.emails, }); final List emails; } /// Updates the access level for a specific user. class ShareTabEventUpdateUserAccessLevel extends ShareTabEvent { const ShareTabEventUpdateUserAccessLevel({ required this.email, required this.accessLevel, }); final String email; final ShareAccessLevel accessLevel; } /// Updates the general access level for all users. class ShareTabEventUpdateGeneralAccessLevel extends ShareTabEvent { const ShareTabEventUpdateGeneralAccessLevel({ required this.accessLevel, }); final ShareAccessLevel accessLevel; } /// Copies the share link to the clipboard. class ShareTabEventCopyShareLink extends ShareTabEvent { const ShareTabEventCopyShareLink({ required this.link, }); final String link; } /// Searches for available users by name or email. class ShareTabEventSearchAvailableUsers extends ShareTabEvent { const ShareTabEventSearchAvailableUsers({ required this.query, }); final String query; } /// Converts a user into a member. class ShareTabEventConvertToMember extends ShareTabEvent { const ShareTabEventConvertToMember({ required this.email, }); final String email; } class ShareTabEventClearState extends ShareTabEvent { const ShareTabEventClearState(); } class ShareTabEventUpdateSharedUsers extends ShareTabEvent { const ShareTabEventUpdateSharedUsers({ required this.users, }); final SharedUsers users; } class ShareTabEventUpgradeToProClicked extends ShareTabEvent { const ShareTabEventUpgradeToProClicked(); } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_state.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class ShareTabState { factory ShareTabState.initial() => const ShareTabState(); const ShareTabState({ this.currentUser, this.users = const [], this.availableUsers = const [], this.isLoading = false, this.errorMessage = '', this.shareLink = '', this.generalAccessRole, this.linkCopied = false, this.sectionType = SharedSectionType.private, this.initialResult, this.shareResult, this.removeResult, this.updateAccessLevelResult, this.turnIntoMemberResult, this.hasClickedUpgradeToPro = false, }); final UserProfilePB? currentUser; final SharedUsers users; final SharedUsers availableUsers; final bool isLoading; final String errorMessage; final String shareLink; final ShareAccessLevel? generalAccessRole; final bool linkCopied; final SharedSectionType sectionType; final FlowyResult? initialResult; final FlowyResult? shareResult; final FlowyResult? removeResult; final FlowyResult? updateAccessLevelResult; final FlowyResult? turnIntoMemberResult; final bool hasClickedUpgradeToPro; ShareTabState copyWith({ UserProfilePB? currentUser, SharedUsers? users, SharedUsers? availableUsers, bool? isLoading, String? errorMessage, String? shareLink, ShareAccessLevel? generalAccessRole, bool? linkCopied, SharedSectionType? sectionType, FlowyResult? initialResult, FlowyResult? shareResult, FlowyResult? removeResult, FlowyResult? updateAccessLevelResult, FlowyResult? turnIntoMemberResult, bool? hasClickedUpgradeToPro, }) { return ShareTabState( currentUser: currentUser ?? this.currentUser, users: users ?? this.users, availableUsers: availableUsers ?? this.availableUsers, isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage ?? this.errorMessage, shareLink: shareLink ?? this.shareLink, generalAccessRole: generalAccessRole ?? this.generalAccessRole, linkCopied: linkCopied ?? this.linkCopied, sectionType: sectionType ?? this.sectionType, initialResult: initialResult, shareResult: shareResult, removeResult: removeResult, updateAccessLevelResult: updateAccessLevelResult, turnIntoMemberResult: turnIntoMemberResult, hasClickedUpgradeToPro: hasClickedUpgradeToPro ?? this.hasClickedUpgradeToPro, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is ShareTabState && other.currentUser == currentUser && other.users == users && other.availableUsers == availableUsers && other.isLoading == isLoading && other.errorMessage == errorMessage && other.shareLink == shareLink && other.generalAccessRole == generalAccessRole && other.linkCopied == linkCopied && other.sectionType == sectionType && other.initialResult == initialResult && other.shareResult == shareResult && other.removeResult == removeResult && other.updateAccessLevelResult == updateAccessLevelResult && other.turnIntoMemberResult == turnIntoMemberResult && other.hasClickedUpgradeToPro == hasClickedUpgradeToPro; } @override int get hashCode { return Object.hash( currentUser, users, availableUsers, isLoading, errorMessage, shareLink, generalAccessRole, linkCopied, sectionType, initialResult, shareResult, removeResult, updateAccessLevelResult, turnIntoMemberResult, hasClickedUpgradeToPro, ); } @override String toString() { return 'ShareTabState(currentUser: $currentUser, users: $users, availableUsers: $availableUsers, isLoading: $isLoading, errorMessage: $errorMessage, shareLink: $shareLink, generalAccessRole: $generalAccessRole, shareSectionType: $SharedSectionType, linkCopied: $linkCopied, initialResult: $initialResult, shareResult: $shareResult, removeResult: $removeResult, updateAccessLevelResult: $updateAccessLevelResult, turnIntoMemberResult: $turnIntoMemberResult, hasClickedUpgradeToPro: $hasClickedUpgradeToPro)'; } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/share_tab.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/data/models/shared_group.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/copy_link_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/general_access_section.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/people_with_access_section.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/share_with_user_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/upgrade_to_pro_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ShareTab extends StatefulWidget { const ShareTab({ super.key, required this.workspaceId, required this.pageId, required this.workspaceName, required this.workspaceIcon, required this.isInProPlan, required this.onUpgradeToPro, }); final String workspaceId; final String pageId; // these 2 values should be provided by the share tab bloc final String workspaceName; final String workspaceIcon; final bool isInProPlan; final VoidCallback onUpgradeToPro; @override State createState() => _ShareTabState(); } class _ShareTabState extends State { final TextEditingController controller = TextEditingController(); late final ShareTabBloc shareTabBloc; @override void initState() { super.initState(); shareTabBloc = context.read(); } @override void dispose() { controller.dispose(); shareTabBloc.add(ShareTabEvent.clearState()); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocConsumer( listener: (context, state) { _onListenShareWithUserState(context, state); }, builder: (context, state) { if (state.isLoading) { return const SizedBox.shrink(); } final currentUser = state.currentUser; final accessLevel = state.users .firstWhereOrNull( (user) => user.email == currentUser?.email, ) ?.accessLevel; final isFullAccess = accessLevel == ShareAccessLevel.fullAccess; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // share page with user by email // only user with full access can invite others VSpace(theme.spacing.l), ShareWithUserWidget( controller: controller, disabled: !isFullAccess, onInvite: (emails) => _onSharePageWithUser( context, emails: emails, accessLevel: ShareAccessLevel.readOnly, ), ), if (!widget.isInProPlan && !state.hasClickedUpgradeToPro) ...[ UpgradeToProWidget( onClose: () { context.read().add( ShareTabEvent.upgradeToProClicked(), ); }, onUpgrade: widget.onUpgradeToPro, ), ], // shared users if (state.users.isNotEmpty) ...[ VSpace(theme.spacing.l), PeopleWithAccessSection( isInPublicPage: state.sectionType == SharedSectionType.public, currentUserEmail: state.currentUser?.email ?? '', users: state.users, callbacks: _buildPeopleWithAccessSectionCallbacks(context), ), ], // general access if (state.sectionType == SharedSectionType.public) ...[ VSpace(theme.spacing.m), GeneralAccessSection( group: SharedGroup( id: widget.workspaceId, name: widget.workspaceName, icon: widget.workspaceIcon, ), ), ], // copy link VSpace(theme.spacing.xl), CopyLinkWidget(shareLink: state.shareLink), VSpace(theme.spacing.m), ], ); }, ); } void _onSharePageWithUser( BuildContext context, { required List emails, required ShareAccessLevel accessLevel, }) { context.read().add( ShareTabEvent.inviteUsers(emails: emails, accessLevel: accessLevel), ); } PeopleWithAccessSectionCallbacks _buildPeopleWithAccessSectionCallbacks( BuildContext context, ) { return PeopleWithAccessSectionCallbacks( onSelectAccessLevel: (user, accessLevel) { context.read().add( ShareTabEvent.updateUserAccessLevel( email: user.email, accessLevel: accessLevel, ), ); }, onTurnIntoMember: (user) { context.read().add( ShareTabEvent.convertToMember(email: user.email), ); }, onRemoveAccess: (user) { // show a dialog to confirm the action when removing self access final theme = AppFlowyTheme.of(context); final shareTabBloc = context.read(); final removingSelf = user.email == shareTabBloc.state.currentUser?.email; if (removingSelf) { showConfirmDialog( context: context, title: 'Remove your own access', titleStyle: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), description: '', style: ConfirmPopupStyle.cancelAndOk, confirmLabel: 'Remove', onConfirm: (_) { shareTabBloc.add( ShareTabEvent.removeUsers(emails: [user.email]), ); }, ); } else { shareTabBloc.add( ShareTabEvent.removeUsers(emails: [user.email]), ); } }, ); } void _onListenShareWithUserState( BuildContext context, ShareTabState state, ) { final shareResult = state.shareResult; if (shareResult != null) { shareResult.fold((success) { // clear the controller to avoid showing the previous emails controller.clear(); showToastNotification( message: LocaleKeys.shareTab_invitationSent.tr(), ); }, (error) { String message; switch (error.code) { case ErrorCode.InvalidGuest: message = LocaleKeys.shareTab_emailAlreadyInList.tr(); break; case ErrorCode.FreePlanGuestLimitExceeded: message = LocaleKeys.shareTab_upgradeToProToInviteGuests.tr(); break; case ErrorCode.PaidPlanGuestLimitExceeded: message = LocaleKeys.shareTab_maxGuestsReached.tr(); break; default: message = error.msg; } showToastNotification( message: message, type: ToastificationType.error, ); }); } final removeResult = state.removeResult; if (removeResult != null) { removeResult.fold((success) { showToastNotification( message: LocaleKeys.shareTab_removedGuestSuccessfully.tr(), ); }, (error) { showToastNotification( message: error.msg, type: ToastificationType.error, ); }); } final updateAccessLevelResult = state.updateAccessLevelResult; if (updateAccessLevelResult != null) { updateAccessLevelResult.fold((success) { showToastNotification( message: LocaleKeys.shareTab_updatedAccessLevelSuccessfully.tr(), ); }, (error) { showToastNotification( message: error.msg, type: ToastificationType.error, ); }); } final turnIntoMemberResult = state.turnIntoMemberResult; if (turnIntoMemberResult != null) { turnIntoMemberResult.fold((success) { showToastNotification( message: LocaleKeys.shareTab_turnedIntoMemberSuccessfully.tr(), ); }, (error) { showToastNotification( message: error.msg, type: ToastificationType.error, ); }); } } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/access_level_list_widget.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class AccessLevelListCallbacks { const AccessLevelListCallbacks({ required this.onSelectAccessLevel, required this.onTurnIntoMember, required this.onRemoveAccess, }); factory AccessLevelListCallbacks.none() { return AccessLevelListCallbacks( onSelectAccessLevel: (_) {}, onTurnIntoMember: () {}, onRemoveAccess: () {}, ); } /// Callback when an access level is selected final void Function(ShareAccessLevel accessLevel) onSelectAccessLevel; /// Callback when the "Turn into Member" option is selected final VoidCallback onTurnIntoMember; /// Callback when the "Remove access" option is selected final VoidCallback onRemoveAccess; /// Copy AccessLevelListCallbacks copyWith({ VoidCallback? onRemoveAccess, VoidCallback? onTurnIntoMember, void Function(ShareAccessLevel accessLevel)? onSelectAccessLevel, }) { return AccessLevelListCallbacks( onRemoveAccess: onRemoveAccess ?? this.onRemoveAccess, onTurnIntoMember: onTurnIntoMember ?? this.onTurnIntoMember, onSelectAccessLevel: onSelectAccessLevel ?? this.onSelectAccessLevel, ); } } enum AdditionalUserManagementOptions { turnIntoMember, removeAccess, } /// A widget that displays a list of access levels for sharing. /// /// This widget is used in a popover to allow users to select different access levels /// for shared content, as well as options to turn users into members or remove access. class AccessLevelListWidget extends StatelessWidget { const AccessLevelListWidget({ super.key, required this.selectedAccessLevel, required this.callbacks, required this.supportedAccessLevels, required this.additionalUserManagementOptions, }); /// The currently selected access level final ShareAccessLevel selectedAccessLevel; /// Callbacks final AccessLevelListCallbacks callbacks; /// Supported access levels final List supportedAccessLevels; /// Additional user management options final List additionalUserManagementOptions; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFMenu( width: supportedAccessLevels.isNotEmpty ? 260 : 160, children: [ // Display all available access level options if (supportedAccessLevels.isNotEmpty) ...[ ...supportedAccessLevels.map( (accessLevel) => _buildAccessLevelItem( context, accessLevel: accessLevel, onTap: () => callbacks.onSelectAccessLevel(accessLevel), ), ), AFDivider(spacing: theme.spacing.m), ], // Additional user management options if (additionalUserManagementOptions .contains(AdditionalUserManagementOptions.turnIntoMember)) AFTextMenuItem( title: LocaleKeys.shareTab_turnIntoMember.tr(), onTap: callbacks.onTurnIntoMember, ), if (additionalUserManagementOptions .contains(AdditionalUserManagementOptions.removeAccess)) AFTextMenuItem( title: LocaleKeys.shareTab_removeAccess.tr(), titleColor: theme.textColorScheme.error, onTap: callbacks.onRemoveAccess, ), ], ); } Widget _buildAccessLevelItem( BuildContext context, { required ShareAccessLevel accessLevel, required VoidCallback onTap, }) { return AFTextMenuItem( title: accessLevel.title, subtitle: accessLevel.subtitle, showSelectedBackground: false, selected: selectedAccessLevel == accessLevel, leading: FlowySvg( accessLevel.icon, ), // Show a checkmark icon for the currently selected access level trailing: selectedAccessLevel == accessLevel ? FlowySvg( FlowySvgs.m_blue_check_s, blendMode: null, ) : null, onTap: onTap, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/copy_link_widget.dart ================================================ import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CopyLinkWidget extends StatelessWidget { const CopyLinkWidget({ super.key, required this.shareLink, }); final String shareLink; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Container( padding: EdgeInsets.symmetric( vertical: theme.spacing.m, horizontal: theme.spacing.l, ), decoration: BoxDecoration( color: theme.surfaceContainerColorScheme.layer01, borderRadius: BorderRadius.circular(theme.spacing.m), border: Border.all( color: theme.borderColorScheme.primary, ), ), child: Row( children: [ FlowySvg( FlowySvgs.toolbar_link_m, ), HSpace(theme.spacing.m), Expanded( child: Text( LocaleKeys.shareTab_peopleAboveCanAccessWithTheLink.tr(), style: theme.textStyle.caption.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ), ), AFOutlinedTextButton.normal( text: LocaleKeys.shareTab_copyLink.tr(), size: AFButtonSize.l, padding: EdgeInsets.symmetric( horizontal: theme.spacing.l, vertical: theme.spacing.s, ), backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.content; } if (isHovering) { return theme.fillColorScheme.contentHover; } return theme.surfaceColorScheme.layer02; }, onTap: () { context.read().add( ShareTabEvent.copyShareLink(link: shareLink), ); if (FlowyRunner.currentMode.isUnitTest) { return; } showToastNotification( message: LocaleKeys.shareTab_copiedLinkToClipboard.tr(), ); }, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/edit_access_level_widget.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class EditAccessLevelWidget extends StatefulWidget { const EditAccessLevelWidget({ super.key, required this.callbacks, required this.selectedAccessLevel, required this.supportedAccessLevels, required this.additionalUserManagementOptions, this.disabled = false, }); /// Callbacks final AccessLevelListCallbacks callbacks; /// The currently selected access level final ShareAccessLevel selectedAccessLevel; /// Whether the widget is disabled final bool disabled; /// Supported access levels final List supportedAccessLevels; /// Additional user management options final List additionalUserManagementOptions; @override State createState() => _EditAccessLevelWidgetState(); } class _EditAccessLevelWidgetState extends State { final popoverController = AFPopoverController(); @override void dispose() { popoverController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFPopover( padding: EdgeInsets.zero, decoration: BoxDecoration(), // the access level widget has a border controller: popoverController, popover: (_) { return AccessLevelListWidget( selectedAccessLevel: widget.selectedAccessLevel, supportedAccessLevels: widget.supportedAccessLevels, additionalUserManagementOptions: widget.additionalUserManagementOptions, callbacks: widget.callbacks.copyWith( onSelectAccessLevel: (accessLevel) { widget.callbacks.onSelectAccessLevel(accessLevel); popoverController.hide(); }, onRemoveAccess: () { widget.callbacks.onRemoveAccess(); popoverController.hide(); }, ), ); }, child: AFGhostButton.normal( disabled: widget.disabled, onTap: () { popoverController.show(); }, padding: EdgeInsets.symmetric( vertical: theme.spacing.s, horizontal: theme.spacing.l, ), builder: (context, isHovering, disabled) { return Row( children: [ Text( widget.selectedAccessLevel.title, style: theme.textStyle.body.standard( color: disabled ? theme.textColorScheme.secondary : theme.textColorScheme.primary, ), ), HSpace(theme.spacing.xs), FlowySvg( FlowySvgs.arrow_down_s, color: theme.textColorScheme.secondary, ), ], ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/general_access_section.dart ================================================ import 'package:appflowy/features/share_tab/data/models/shared_group.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/shared_group_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class GeneralAccessSection extends StatelessWidget { const GeneralAccessSection({ super.key, required this.group, }); final SharedGroup group; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFMenuSection( title: LocaleKeys.shareTab_generalAccess.tr(), padding: EdgeInsets.symmetric( vertical: theme.spacing.xs, horizontal: theme.spacing.m, ), children: [ SharedGroupWidget( group: group, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/guest_tag.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class GuestTag extends StatelessWidget { const GuestTag({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Container( padding: EdgeInsets.only( left: theme.spacing.m, right: theme.spacing.m, bottom: 2, ), decoration: BoxDecoration( color: theme.fillColorScheme.warningLight, borderRadius: BorderRadius.circular(theme.spacing.s), ), child: Text( LocaleKeys.shareTab_guest.tr(), style: theme.textStyle.caption .standard( color: theme.textColorScheme.warning, ) .copyWith( height: 16.0 / 12.0, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/people_with_access_section.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/shared_user_widget.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; class PeopleWithAccessSectionCallbacks { const PeopleWithAccessSectionCallbacks({ required this.onRemoveAccess, required this.onTurnIntoMember, required this.onSelectAccessLevel, }); factory PeopleWithAccessSectionCallbacks.none() { return PeopleWithAccessSectionCallbacks( onSelectAccessLevel: (_, __) {}, onTurnIntoMember: (_) {}, onRemoveAccess: (_) {}, ); } /// Callback when an access level is selected final void Function(SharedUser user, ShareAccessLevel accessLevel) onSelectAccessLevel; /// Callback when the "Turn into Member" option is selected final void Function(SharedUser user) onTurnIntoMember; /// Callback when the "Remove access" option is selected final void Function(SharedUser user) onRemoveAccess; } class PeopleWithAccessSection extends StatelessWidget { const PeopleWithAccessSection({ super.key, required this.currentUserEmail, required this.users, required this.isInPublicPage, this.callbacks, }); final String currentUserEmail; final SharedUsers users; final bool isInPublicPage; final PeopleWithAccessSectionCallbacks? callbacks; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final currentUser = users.firstWhereOrNull( (user) => user.email == currentUserEmail, ); return AFMenuSection( title: 'People with access', constraints: BoxConstraints( maxHeight: 240, ), padding: EdgeInsets.symmetric( vertical: theme.spacing.xs, horizontal: theme.spacing.m, ), children: users.map((user) { if (currentUser == null) { return const SizedBox.shrink(); } return SharedUserWidget( user: user, currentUser: currentUser, isInPublicPage: isInPublicPage, callbacks: AccessLevelListCallbacks( onRemoveAccess: () => callbacks?.onRemoveAccess.call(user), onTurnIntoMember: () => callbacks?.onTurnIntoMember.call(user), onSelectAccessLevel: (accessLevel) => callbacks?.onSelectAccessLevel.call(user, accessLevel), ), ); }).toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/share_with_user_widget.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:string_validator/string_validator.dart'; class ShareWithUserWidget extends StatefulWidget { const ShareWithUserWidget({ super.key, required this.onInvite, this.controller, this.disabled = false, this.tooltip, }); final TextEditingController? controller; final void Function(List emails) onInvite; final bool disabled; final String? tooltip; @override State createState() => _ShareWithUserWidgetState(); } class _ShareWithUserWidgetState extends State { late final TextEditingController effectiveController; bool isButtonEnabled = false; @override void initState() { super.initState(); effectiveController = widget.controller ?? TextEditingController(); effectiveController.addListener(_onTextChanged); } @override void dispose() { if (widget.controller == null) { effectiveController.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final Widget child = Row( children: [ Expanded( child: AFTextField( controller: effectiveController, size: AFTextFieldSize.m, hintText: LocaleKeys.shareTab_inviteByEmail.tr(), ), ), HSpace(theme.spacing.s), AFFilledTextButton.primary( text: LocaleKeys.shareTab_invite.tr(), disabled: !isButtonEnabled, onTap: () { widget.onInvite(effectiveController.text.trim().split(',')); }, ), ], ); if (widget.disabled) { return FlowyTooltip( message: widget.tooltip ?? LocaleKeys.shareTab_onlyFullAccessCanInvite.tr(), child: IgnorePointer( child: child, ), ); } return child; } void _onTextChanged() { setState(() { final texts = effectiveController.text.trim().split(','); isButtonEnabled = texts.isNotEmpty && texts.every(isEmail); }); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/shared_group_widget.dart ================================================ import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/share_tab/data/models/shared_group.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class SharedGroupWidget extends StatelessWidget { const SharedGroupWidget({ super.key, required this.group, }); final SharedGroup group; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFMenuItem( padding: EdgeInsets.symmetric( vertical: theme.spacing.s, horizontal: theme.spacing.m, ), leading: _buildLeading(context), title: _buildTitle(context), subtitle: _buildSubtitle(context), trailing: _buildTrailing(context), onTap: () {}, ); } Widget _buildLeading(BuildContext context) { return WorkspaceIcon( isEditable: false, workspaceIcon: group.icon, workspaceName: group.name, iconSize: 32.0, emojiSize: 24.0, fontSize: 16.0, onSelected: (r) {}, borderRadius: 8.0, showBorder: false, figmaLineHeight: 24.0, ); } Widget _buildTitle(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( children: [ Flexible( child: Text( LocaleKeys.shareTab_anyoneAtWorkspace.tr( namedArgs: { 'workspace': group.name, }, ), style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ), ), // HSpace(theme.spacing.xs), // FlowySvg( // FlowySvgs.arrow_down_s, // color: theme.textColorScheme.secondary, // ), ], ); } Widget _buildSubtitle(BuildContext context) { final theme = AppFlowyTheme.of(context); return Text( LocaleKeys.shareTab_anyoneInGroupWithLinkCanEdit.tr(), textAlign: TextAlign.left, style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), ); } Widget _buildTrailing(BuildContext context) { return EditAccessLevelWidget( disabled: true, supportedAccessLevels: ShareAccessLevel.values, selectedAccessLevel: ShareAccessLevel.readAndWrite, callbacks: AccessLevelListCallbacks.none(), additionalUserManagementOptions: [], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/shared_user_widget.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/guest_tag.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/turn_into_member_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; /// Widget to display a single shared user row as per the UI design, using AFMenuItem. class SharedUserWidget extends StatelessWidget { const SharedUserWidget({ super.key, required this.user, required this.currentUser, required this.isInPublicPage, this.callbacks, }); final SharedUser user; final SharedUser currentUser; final AccessLevelListCallbacks? callbacks; final bool isInPublicPage; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFMenuItem( padding: EdgeInsets.symmetric( vertical: theme.spacing.s, horizontal: theme.spacing.m, ), leading: AFAvatar( name: user.name, url: user.avatarUrl, ), title: _buildTitle(context), subtitle: _buildSubtitle(context), trailing: _buildTrailing(context), onTap: () { // callbacks?.onSelectAccessLevel.call(user, user.accessLevel); }, ); } Widget _buildTitle( BuildContext context, ) { final theme = AppFlowyTheme.of(context); final isCurrentUser = user.email == currentUser.email; return Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( user.name, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ), ), // if the user is the current user, show '(You)' if (isCurrentUser) ...[ HSpace(theme.spacing.xs), Flexible( child: Text( LocaleKeys.shareTab_you.tr(), style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), overflow: TextOverflow.ellipsis, ), ), ], // if the user is a guest, show 'Guest' if (user.role == ShareRole.guest) ...[ HSpace(theme.spacing.m), const GuestTag(), ], ], ); } Widget _buildSubtitle( BuildContext context, ) { final theme = AppFlowyTheme.of(context); return Text( user.email, style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), ); } Widget _buildTrailing(BuildContext context) { final isCurrentUser = user.email == currentUser.email; final theme = AppFlowyTheme.of(context); final currentAccessLevel = currentUser.accessLevel; Widget disabledAccessButton() => AFGhostTextButton.disabled( text: user.accessLevel.title, textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ), ); Widget editAccessWidget(List supported) => EditAccessLevelWidget( selectedAccessLevel: user.accessLevel, supportedAccessLevels: supported, additionalUserManagementOptions: [ AdditionalUserManagementOptions.removeAccess, ], callbacks: callbacks ?? AccessLevelListCallbacks.none(), ); // In public page, member/owner permissions are fixed if (isInPublicPage && (user.role == ShareRole.member || user.role == ShareRole.owner)) { return disabledAccessButton(); } // Full access user can turn a guest into a member if (user.role == ShareRole.guest && currentAccessLevel == ShareAccessLevel.fullAccess) { return Row( children: [ TurnIntoMemberWidget( onTap: () => callbacks?.onTurnIntoMember.call(), ), editAccessWidget([ ShareAccessLevel.readOnly, ShareAccessLevel.readAndWrite, ]), ], ); } // Self-management if (isCurrentUser) { if (currentAccessLevel == ShareAccessLevel.readOnly || currentAccessLevel == ShareAccessLevel.readAndWrite) { // Can only remove self return editAccessWidget([]); } else if (currentAccessLevel == ShareAccessLevel.fullAccess) { // Full access user cannot change own access return disabledAccessButton(); } } // Managing others if (currentAccessLevel == ShareAccessLevel.readOnly || currentAccessLevel == ShareAccessLevel.readAndWrite) { // Cannot change others' access return disabledAccessButton(); } else { // Full access user can manage others final supportedAccessLevels = [ ShareAccessLevel.readOnly, ShareAccessLevel.readAndWrite, ]; return editAccessWidget(supportedAccessLevels); } } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/turn_into_member_widget.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; class TurnIntoMemberWidget extends StatelessWidget { const TurnIntoMemberWidget({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return FlowyTooltip( message: LocaleKeys.shareTab_turnIntoMember.tr(), child: AFGhostButton.normal( onTap: onTap, padding: EdgeInsets.all(theme.spacing.s), builder: (context, isHovering, disabled) { return FlowySvg(FlowySvgs.turn_into_member_m); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/upgrade_to_pro_widget.dart ================================================ import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class UpgradeToProWidget extends StatelessWidget { const UpgradeToProWidget({ super.key, required this.onUpgrade, required this.onClose, }); final VoidCallback onClose; final VoidCallback onUpgrade; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Container( decoration: BoxDecoration( color: Color(0x129327ff), borderRadius: BorderRadius.circular(theme.borderRadius.m), ), padding: EdgeInsets.symmetric( vertical: theme.spacing.m, horizontal: theme.spacing.l, ), margin: EdgeInsets.only( top: theme.spacing.l, ), child: Row( children: [ FlowySvg( FlowySvgs.upgrade_pro_crown_m, blendMode: null, ), HSpace( theme.spacing.m, ), RichText( text: TextSpan( children: [ TextSpan( text: LocaleKeys.shareTab_upgrade.tr(), style: theme.textStyle.caption.standard().copyWith( color: theme.textColorScheme.featured, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () { onUpgrade(); }, mouseCursor: SystemMouseCursors.click, ), TextSpan( text: LocaleKeys.shareTab_toProPlanToInviteGuests.tr(), style: theme.textStyle.caption.standard().copyWith( color: theme.textColorScheme.featured, ), ), ], ), ), const Spacer(), AFGhostButton.normal( size: AFButtonSize.s, padding: EdgeInsets.all(theme.spacing.xs), onTap: () { context .read() .add(ShareTabEvent.upgradeToProClicked()); onClose(); }, builder: (context, isHovering, disabled) => FlowySvg( FlowySvgs.upgrade_to_pro_close_m, size: const Size.square(20), ), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/data/repositories/local_shared_pages_repository_impl.dart ================================================ import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/shared_section/data/repositories/shared_pages_repository.dart'; import 'package:appflowy/features/shared_section/models/shared_page.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; // Move this file to test folder class LocalSharedPagesRepositoryImpl implements SharedPagesRepository { @override Future> getSharedPages() async { final pages = [ SharedPage( view: ViewPB() ..id = '1' ..name = 'Welcome Page', accessLevel: ShareAccessLevel.fullAccess, ), SharedPage( view: ViewPB() ..id = '2' ..name = 'Project Plan', accessLevel: ShareAccessLevel.readAndWrite, ), SharedPage( view: ViewPB() ..id = '3' ..name = 'Readme', accessLevel: ShareAccessLevel.readOnly, ), ]; return FlowyResult.success(pages); } @override Future> leaveSharedPage(String pageId) async { return FlowyResult.success(null); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart ================================================ import 'package:appflowy/features/shared_section/data/repositories/shared_pages_repository.dart'; import 'package:appflowy/features/shared_section/models/shared_page.dart'; import 'package:appflowy/features/util/extensions.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class RustSharePagesRepositoryImpl implements SharedPagesRepository { @override Future> getSharedPages() async { final result = await FolderEventGetSharedViews().send(); return result.fold( (success) { final sharedPages = success.sharedPages; Log.debug('get shared pages success, len: ${sharedPages.length}'); return FlowyResult.success(sharedPages); }, (error) { Log.error('failed to get shared pages, error: $error'); return FlowyResult.failure(error); }, ); } @override Future> leaveSharedPage(String pageId) async { final user = await UserEventGetUserProfile().send(); final userEmail = user.fold( (success) => success.email, (error) => null, ); if (userEmail == null) { return FlowyResult.failure(FlowyError(msg: 'User email is null')); } final request = RemoveUserFromSharedPagePayloadPB( viewId: pageId, emails: [userEmail], ); final result = await FolderEventRemoveUserFromSharedPage(request).send(); return result.fold( (success) { Log.debug('remove user($userEmail) from shared page($pageId)'); return FlowySuccess(success); }, (failure) { Log.error( 'remove user($userEmail) from shared page($pageId): $failure', ); return FlowyFailure(failure); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/data/repositories/shared_pages_repository.dart ================================================ import 'package:appflowy/features/shared_section/models/shared_page.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; /// Abstract repository for sharing pages with users. /// /// For example, we're using rust events now, but we can still use the http api /// for the future. abstract class SharedPagesRepository { /// Gets the list of users and their roles for a shared page. Future> getSharedPages(); /// Removes a shared page from the repository. Future> leaveSharedPage(String pageId); } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy/features/shared_section/data/repositories/shared_pages_repository.dart'; import 'package:appflowy/features/shared_section/logic/shared_section_event.dart'; import 'package:appflowy/features/shared_section/logic/shared_section_state.dart'; import 'package:appflowy/features/util/extensions.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:bloc/bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; export 'shared_section_event.dart'; export 'shared_section_state.dart'; class SharedSectionBloc extends Bloc { SharedSectionBloc({ required this.repository, required this.workspaceId, this.enablePolling = false, this.pollingIntervalSeconds = 30, }) : super(SharedSectionState.initial()) { on(_onInit); on(_onRefresh); on(_onUpdateSharedPages); on(_onToggleExpanded); on(_onLeaveSharedPage); } final String workspaceId; // The repository to fetch the shared views. // If you need to test this bloc, you can add your own repository implementation. final SharedPagesRepository repository; // Used to listen for shared view updates. late final FolderNotificationListener _folderNotificationListener; // Since the backend doesn't provide a way to listen for shared view updates (websocket with shared view updates is not implemented yet), // we need to poll the shared views periodically. final bool enablePolling; // The interval of polling the shared views. final int pollingIntervalSeconds; Timer? _pollingTimer; @override Future close() async { await _folderNotificationListener.stop(); _pollingTimer?.cancel(); await super.close(); } Future _onInit( SharedSectionInitEvent event, Emitter emit, ) async { _initFolderNotificationListener(); _startPollingIfNeeded(); emit( state.copyWith( isLoading: true, errorMessage: '', ), ); final result = await repository.getSharedPages(); result.fold( (pages) { emit( state.copyWith( sharedPages: pages, isLoading: false, ), ); }, (error) { emit( state.copyWith( errorMessage: error.msg, isLoading: false, ), ); }, ); } Future _onRefresh( SharedSectionRefreshEvent event, Emitter emit, ) async { final result = await repository.getSharedPages(); result.fold( (pages) { emit( state.copyWith( sharedPages: pages, ), ); }, (error) { emit( state.copyWith( errorMessage: error.msg, ), ); }, ); } void _onUpdateSharedPages( SharedSectionUpdateSharedPagesEvent event, Emitter emit, ) { emit( state.copyWith( sharedPages: event.sharedPages, ), ); } void _onToggleExpanded( SharedSectionToggleExpandedEvent event, Emitter emit, ) { emit( state.copyWith( isExpanded: !state.isExpanded, ), ); } void _initFolderNotificationListener() { _folderNotificationListener = FolderNotificationListener( objectId: workspaceId, handler: (notification, result) { if (notification == FolderNotification.DidUpdateSharedViews) { final response = result.fold( (payload) { final repeatedSharedViews = RepeatedSharedViewResponsePB.fromBuffer(payload); return repeatedSharedViews; }, (error) => null, ); if (response != null) { add( SharedSectionEvent.updateSharedPages( sharedPages: response.sharedPages, ), ); } } }, ); } void _onLeaveSharedPage( SharedSectionLeaveSharedPageEvent event, Emitter emit, ) async { final result = await repository.leaveSharedPage(event.pageId); result.fold( (success) { add( SharedSectionEvent.updateSharedPages( sharedPages: state.sharedPages ..removeWhere( (page) => page.view.id == event.pageId, ), ), ); }, (error) { emit(state.copyWith(errorMessage: error.msg)); }, ); } void _startPollingIfNeeded() { _pollingTimer?.cancel(); if (enablePolling && pollingIntervalSeconds > 0) { _pollingTimer = Timer.periodic( Duration(seconds: pollingIntervalSeconds), (_) { add(const SharedSectionEvent.refresh()); Log.debug('Polling shared views'); }, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_event.dart ================================================ import 'package:appflowy/features/shared_section/models/shared_page.dart'; /// Base class for all SharedSection events sealed class SharedSectionEvent { const SharedSectionEvent(); /// Initialize, it will create a folder notification listener to listen for shared view updates. /// Also, it will fetch the shared pages from the repository. const factory SharedSectionEvent.init() = SharedSectionInitEvent; /// Refresh, it will re-fetch the shared pages from the repository. const factory SharedSectionEvent.refresh() = SharedSectionRefreshEvent; /// Update the shared pages in the state. const factory SharedSectionEvent.updateSharedPages({ required SharedPages sharedPages, }) = SharedSectionUpdateSharedPagesEvent; /// Toggle the expanded status of the shared section. const factory SharedSectionEvent.toggleExpanded() = SharedSectionToggleExpandedEvent; /// Leave shared page. const factory SharedSectionEvent.leaveSharedPage({ required String pageId, }) = SharedSectionLeaveSharedPageEvent; } class SharedSectionInitEvent extends SharedSectionEvent { const SharedSectionInitEvent(); } class SharedSectionRefreshEvent extends SharedSectionEvent { const SharedSectionRefreshEvent(); } class SharedSectionUpdateSharedPagesEvent extends SharedSectionEvent { const SharedSectionUpdateSharedPagesEvent({ required this.sharedPages, }); final SharedPages sharedPages; } class SharedSectionToggleExpandedEvent extends SharedSectionEvent { const SharedSectionToggleExpandedEvent(); } class SharedSectionLeaveSharedPageEvent extends SharedSectionEvent { const SharedSectionLeaveSharedPageEvent({ required this.pageId, }); final String pageId; } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_state.dart ================================================ import 'package:appflowy/features/shared_section/models/shared_page.dart'; class SharedSectionState { factory SharedSectionState.initial() => const SharedSectionState(); const SharedSectionState({ this.sharedPages = const [], this.isLoading = false, this.errorMessage = '', this.isExpanded = true, }); final SharedPages sharedPages; final bool isLoading; final String errorMessage; final bool isExpanded; SharedSectionState copyWith({ SharedPages? sharedPages, bool? isLoading, String? errorMessage, bool? isExpanded, }) { return SharedSectionState( sharedPages: sharedPages ?? this.sharedPages, isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage ?? this.errorMessage, isExpanded: isExpanded ?? this.isExpanded, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is SharedSectionState && other.sharedPages == sharedPages && other.isLoading == isLoading && other.errorMessage == errorMessage && other.isExpanded == isExpanded; } @override int get hashCode { return Object.hash( sharedPages, isLoading, errorMessage, isExpanded, ); } @override String toString() { return 'SharedSectionState(sharedPages: $sharedPages, isLoading: $isLoading, errorMessage: $errorMessage, isExpanded: $isExpanded)'; } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/models/shared_page.dart ================================================ import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; typedef SharedPages = List; class SharedPage { SharedPage({ required this.view, required this.accessLevel, }); final ViewPB view; final ShareAccessLevel accessLevel; } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/m_shared_section.dart ================================================ import 'package:appflowy/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart'; import 'package:appflowy/features/shared_section/logic/shared_section_bloc.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/m_shared_page_list.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/m_shared_section_header.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/refresh_button.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_empty.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_error.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_loading.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MSharedSection extends StatelessWidget { const MSharedSection({ super.key, required this.workspaceId, }); final String workspaceId; @override Widget build(BuildContext context) { final repository = RustSharePagesRepositoryImpl(); return BlocProvider( create: (_) => SharedSectionBloc( workspaceId: workspaceId, repository: repository, enablePolling: true, )..add(const SharedSectionInitEvent()), child: BlocBuilder( builder: (context, state) { if (state.isLoading) { return const SharedSectionLoading(); } if (state.errorMessage.isNotEmpty) { return SharedSectionError(errorMessage: state.errorMessage); } // hide the shared section if there are no shared pages if (state.sharedPages.isEmpty) { return const SharedSectionEmpty(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(HomeSpaceViewSizes.mVerticalPadding), // Shared header MSharedSectionHeader(), Padding( padding: const EdgeInsets.only( left: HomeSpaceViewSizes.mHorizontalPadding, ), child: MSharedPageList( sharedPages: state.sharedPages, onSelected: (view) { context.pushView( view, tabs: [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ].map((e) => e.name).toList(), ); }, ), ), // Refresh button, for debugging only if (kDebugMode) RefreshSharedSectionButton( onTap: () { context.read().add( const SharedSectionEvent.refresh(), ); }, ), ], ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/shared_section.dart ================================================ import 'package:appflowy/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart'; import 'package:appflowy/features/shared_section/logic/shared_section_bloc.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/refresh_button.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_page_list.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_error.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_header.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_loading.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SharedSection extends StatelessWidget { const SharedSection({ super.key, required this.workspaceId, }); final String workspaceId; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final repository = RustSharePagesRepositoryImpl(); return BlocProvider( create: (_) => SharedSectionBloc( workspaceId: workspaceId, repository: repository, enablePolling: true, )..add(const SharedSectionInitEvent()), child: BlocBuilder( builder: (context, state) { if (state.isLoading) { return const SharedSectionLoading(); } if (state.errorMessage.isNotEmpty) { return SharedSectionError(errorMessage: state.errorMessage); } // hide the shared section if there are no shared pages if (state.sharedPages.isEmpty) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Shared header SharedSectionHeader( onTap: () { // expand or collapse the shared section context.read().add( const SharedSectionToggleExpandedEvent(), ); }, ), // Shared pages list if (state.isExpanded) SharedPageList( sharedPages: state.sharedPages, onSetEditing: (context, value) { context.read().add(ViewEvent.setIsEditing(value)); }, onAction: (action, view, data) async { switch (action) { case ViewMoreActionType.favorite: case ViewMoreActionType.unFavorite: context .read() .add(FavoriteEvent.toggle(view)); break; case ViewMoreActionType.openInNewTab: context.read().openTab(view); break; case ViewMoreActionType.rename: await showAFTextFieldDialog( context: context, title: LocaleKeys.disclosureAction_rename.tr(), initialValue: view.nameOrDefault, maxLength: 256, onConfirm: (newValue) { // can not use bloc here because it has been disposed. ViewBackendService.updateView( viewId: view.id, name: newValue, ); }, ); break; case ViewMoreActionType.leaveSharedPage: // show a dialog to confirm the action await showConfirmDialog( context: context, title: 'Remove your own access', titleStyle: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), description: '', style: ConfirmPopupStyle.cancelAndOk, confirmLabel: 'Remove', onConfirm: (_) { context.read().add( SharedSectionEvent.leaveSharedPage( pageId: view.id, ), ); }, ); break; default: // Other actions are not allowed for read-only access break; } }, onSelected: (context, view) { if (HardwareKeyboard.instance.isControlPressed) { context.read().openTab(view); } context.read().openPlugin(view); }, onTertiarySelected: (context, view) { context.read().openTab(view); }, ), // Refresh button, for debugging only if (kDebugMode) RefreshSharedSectionButton( onTap: () { context.read().add( const SharedSectionEvent.refresh(), ); }, ), const VSpace(16.0), ], ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/m_shared_page_list.dart ================================================ import 'package:appflowy/features/shared_section/models/shared_page.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:flutter/material.dart'; /// Shared pages on mobile class MSharedPageList extends StatelessWidget { const MSharedPageList({ super.key, required this.sharedPages, required this.onSelected, }); final SharedPages sharedPages; final ViewItemOnSelected onSelected; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: sharedPages.map((sharedPage) { final view = sharedPage.view; return MobileViewItem( key: ValueKey(view.id), spaceType: FolderSpaceType.public, isFirstChild: view.id == sharedPages.first.view.id, view: view, level: 0, isDraggable: false, // disable draggable for shared pages leftPadding: HomeSpaceViewSizes.leftPadding, isFeedback: false, onSelected: onSelected, ); }).toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/m_shared_section_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class MSharedSectionHeader extends StatelessWidget { const MSharedSectionHeader({ super.key, }); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return SizedBox( height: 48, child: Row( children: [ const HSpace(HomeSpaceViewSizes.mHorizontalPadding), FlowySvg( FlowySvgs.shared_with_me_m, color: theme.badgeColorScheme.color13Thick2, ), const HSpace(10.0), FlowyText.medium( LocaleKeys.shareSection_shared.tr(), lineHeight: 1.15, fontSize: 16.0, ), const HSpace(HomeSpaceViewSizes.mHorizontalPadding), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/refresh_button.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class RefreshSharedSectionButton extends StatelessWidget { const RefreshSharedSectionButton({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFTextMenuItem( leading: Icon( Icons.refresh, size: 20, color: theme.iconColorScheme.secondary, ), title: 'Refresh', onTap: onTap, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/shared_page_actions_button.dart ================================================ import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; typedef SharedPageActionsButtonCallback = void Function( ViewMoreActionType type, ViewPB view, dynamic data, ); typedef SharedPageActionsButtonSetEditingCallback = void Function( BuildContext context, bool value, ); class SharedPageActionsButton extends StatefulWidget { const SharedPageActionsButton({ super.key, required this.view, required this.accessLevel, required this.onAction, required this.buildChild, required this.onSetEditing, this.showAtCursor = false, }); final ViewPB view; final ShareAccessLevel accessLevel; final SharedPageActionsButtonCallback onAction; final SharedPageActionsButtonSetEditingCallback onSetEditing; final bool showAtCursor; final Widget Function(AFPopoverController) buildChild; @override State createState() => _SharedPageActionsButtonState(); } class _SharedPageActionsButtonState extends State { AFPopoverController controller = AFPopoverController(); @override void initState() { super.initState(); controller.addListener(() { if (!controller.isOpen) { widget.onSetEditing(context, false); } }); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AFPopover( controller: controller, padding: EdgeInsets.zero, decoration: BoxDecoration(), // the AFMenu has a border anchor: const AFAnchorAuto( offset: Offset(98, 8), ), popover: (context) => AFMenu( width: 240, backgroundColor: Theme.of(context).cardColor, // to compatible with the old design children: _buildMenuItems(context), ), child: widget.buildChild(controller), ); } List _buildMenuItems(BuildContext context) { final actionTypes = _buildActionTypes(); final menuItems = []; for (final actionType in actionTypes) { if (actionType == ViewMoreActionType.divider) { if (menuItems.isNotEmpty) { menuItems.add(const AFDivider(spacing: 4)); } } else { menuItems.add( AFTextMenuItem( leading: FlowySvg( actionType.leftIconSvg, size: const Size.square(16), color: actionType == ViewMoreActionType.delete ? Theme.of(context).colorScheme.error : null, ), title: actionType.name, titleColor: actionType == ViewMoreActionType.delete ? Theme.of(context).colorScheme.error : null, trailing: actionType.rightIcon, onTap: () { widget.onAction(actionType, widget.view, null); controller.hide(); }, ), ); } } return menuItems; } List _buildActionTypes() { final List actionTypes = []; // Always allow add to favorites and open in new tab actionTypes.add( widget.view.isFavorite ? ViewMoreActionType.unFavorite : ViewMoreActionType.favorite, ); actionTypes.add( ViewMoreActionType.leaveSharedPage, ); // Only show editable actions if access level allows it if (widget.accessLevel != ShareAccessLevel.readOnly) { actionTypes.addAll([ ViewMoreActionType.divider, ViewMoreActionType.rename, ]); // Chat doesn't change icon and duplicate if (widget.view.layout != ViewLayoutPB.Chat) { actionTypes.addAll([ ViewMoreActionType.changeIcon, ]); } if (widget.accessLevel == ShareAccessLevel.fullAccess) { actionTypes.addAll([ ViewMoreActionType.delete, ]); } } actionTypes.add(ViewMoreActionType.divider); actionTypes.add(ViewMoreActionType.openInNewTab); return actionTypes; } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/shared_page_list.dart ================================================ import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/shared_section/models/shared_page.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_page_actions_button.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; /// Shared pages on desktop class SharedPageList extends StatelessWidget { const SharedPageList({ super.key, required this.sharedPages, required this.onAction, required this.onSelected, required this.onTertiarySelected, required this.onSetEditing, }); final SharedPages sharedPages; final ViewItemOnSelected onSelected; final ViewItemOnSelected onTertiarySelected; final SharedPageActionsButtonCallback onAction; final SharedPageActionsButtonSetEditingCallback onSetEditing; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: sharedPages.map((sharedPage) { final view = sharedPage.view; final accessLevel = sharedPage.accessLevel; return ViewItem( key: ValueKey(view.id), spaceType: FolderSpaceType.public, isFirstChild: view.id == sharedPages.first.view.id, view: view, level: 0, isDraggable: false, // disable draggable for shared pages leftPadding: HomeSpaceViewSizes.leftPadding, isFeedback: false, onSelected: onSelected, onTertiarySelected: onTertiarySelected, rightIconsBuilder: (context, view) => [ IntrinsicWidth( child: _buildSharedPageMoreActionButton( context, view, accessLevel, ), ), const SizedBox(width: 4.0), ], ); }).toList(), ); } Widget _buildSharedPageMoreActionButton( BuildContext context, ViewPB view, ShareAccessLevel accessLevel, ) { return SharedPageActionsButton( view: view, accessLevel: accessLevel, onAction: onAction, onSetEditing: onSetEditing, buildChild: (controller) => FlowyIconButton( width: 24, icon: const FlowySvg(FlowySvgs.workspace_three_dots_s), onPressed: () { onSetEditing(context, true); controller.show(); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/shared_section_empty.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class SharedSectionEmpty extends StatelessWidget { const SharedSectionEmpty({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ FlowySvg( FlowySvgs.empty_shared_section_m, color: theme.iconColorScheme.tertiary, ), const VSpace(12), Text( 'Nothing shared with you', style: theme.textStyle.heading3.enhanced( color: theme.textColorScheme.secondary, ), textAlign: TextAlign.center, ), const VSpace(4), Text( 'Pages shared with you will show here', style: theme.textStyle.heading4.standard( color: theme.textColorScheme.tertiary, ), textAlign: TextAlign.center, ), const VSpace(kBottomNavigationBarHeight + 60.0), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/shared_section_error.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class SharedSectionError extends StatelessWidget { const SharedSectionError({ super.key, required this.errorMessage, }); final String errorMessage; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Padding( padding: const EdgeInsets.all(16.0), child: Text( errorMessage, style: theme.textStyle.body.enhanced( color: theme.textColorScheme.warning, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/shared_section_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class SharedSectionHeader extends StatelessWidget { const SharedSectionHeader({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFGhostIconTextButton.primary( text: LocaleKeys.shareSection_shared.tr(), mainAxisAlignment: MainAxisAlignment.start, size: AFButtonSize.l, onTap: onTap, // todo: ask the designer to provide the token. padding: EdgeInsets.symmetric( horizontal: 4, vertical: 6, ), borderRadius: theme.borderRadius.s, iconBuilder: (context, isHover, disabled) => FlowySvg( FlowySvgs.shared_with_me_m, color: theme.badgeColorScheme.color13Thick2, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/shared_section_loading.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class SharedSectionLoading extends StatelessWidget { const SharedSectionLoading({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Center( child: CircularProgressIndicator( color: theme.iconColorScheme.primary, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/util/extensions.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/shared_section/models/shared_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart' as folder; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart' as user; extension RepeatedSharedViewResponsePBExtension on RepeatedSharedViewResponsePB { SharedPages get sharedPages { return sharedViews.map((e) => e.sharedPage).toList(); } } extension SharedViewPBExtension on SharedViewPB { SharedPage get sharedPage { return SharedPage( view: view, accessLevel: accessLevel.shareAccessLevel, ); } } extension RepeatedSharedUserPBExtension on RepeatedSharedUserPB { SharedUsers get sharedUsers { return items.map((e) => e.sharedUser).toList(); } } extension SharedUserPBExtension on SharedUserPB { SharedUser get sharedUser { return SharedUser( email: email, name: name, accessLevel: accessLevel.shareAccessLevel, role: role.shareRole, avatarUrl: avatarUrl, ); } } extension AFAccessLevelPBExtension on AFAccessLevelPB { ShareAccessLevel get shareAccessLevel { switch (this) { case AFAccessLevelPB.ReadOnly: return ShareAccessLevel.readOnly; case AFAccessLevelPB.ReadAndComment: return ShareAccessLevel.readAndComment; case AFAccessLevelPB.ReadAndWrite: return ShareAccessLevel.readAndWrite; case AFAccessLevelPB.FullAccess: return ShareAccessLevel.fullAccess; default: throw Exception('Unknown share role: $this'); } } } extension ShareAccessLevelExtension on ShareAccessLevel { AFAccessLevelPB get accessLevel { switch (this) { case ShareAccessLevel.readOnly: return AFAccessLevelPB.ReadOnly; case ShareAccessLevel.readAndComment: return AFAccessLevelPB.ReadAndComment; case ShareAccessLevel.readAndWrite: return AFAccessLevelPB.ReadAndWrite; case ShareAccessLevel.fullAccess: return AFAccessLevelPB.FullAccess; } } } extension AFRolePBExtension on AFRolePB { ShareRole get shareRole { switch (this) { case AFRolePB.Guest: return ShareRole.guest; case AFRolePB.Member: return ShareRole.member; case AFRolePB.Owner: return ShareRole.owner; default: throw Exception('Unknown share role: $this'); } } } extension ShareRoleExtension on ShareRole { user.AFRolePB get userRole { switch (this) { case ShareRole.guest: return user.AFRolePB.Guest; case ShareRole.member: return user.AFRolePB.Member; case ShareRole.owner: return user.AFRolePB.Owner; } } folder.AFRolePB get folderRole { switch (this) { case ShareRole.guest: return folder.AFRolePB.Guest; case ShareRole.member: return folder.AFRolePB.Member; case ShareRole.owner: return folder.AFRolePB.Owner; } } } extension SharedSectionTypeExtension on folder.SharedViewSectionPB { SharedSectionType get sharedSectionType { switch (this) { case folder.SharedViewSectionPB.PublicSection: return SharedSectionType.public; case folder.SharedViewSectionPB.PrivateSection: return SharedSectionType.private; case folder.SharedViewSectionPB.SharedSection: return SharedSectionType.shared; default: throw Exception('Unknown shared section type: $this'); } } } ================================================ FILE: frontend/appflowy_flutter/lib/features/view_management/README.md ================================================ ================================================ FILE: frontend/appflowy_flutter/lib/features/view_management/logic/view_event.dart ================================================ ================================================ FILE: frontend/appflowy_flutter/lib/features/view_management/logic/view_management_bloc.dart ================================================ ================================================ FILE: frontend/appflowy_flutter/lib/features/view_management/logic/view_state.dart ================================================ ================================================ FILE: frontend/appflowy_flutter/lib/features/workspace/data/repositories/rust_workspace_repository_impl.dart ================================================ import 'package:appflowy/features/workspace/data/repositories/workspace_repository.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart' as billing_service; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; /// Implementation of WorkspaceRepository using UserBackendService. class RustWorkspaceRepositoryImpl implements WorkspaceRepository { RustWorkspaceRepositoryImpl({ required Int64 userId, }) : _userService = UserBackendService(userId: userId); final UserBackendService _userService; @override Future> getCurrentWorkspace() async { return UserBackendService.getCurrentWorkspace(); } @override Future, FlowyError>> getWorkspaces() async { return _userService.getWorkspaces(); } @override Future> createWorkspace({ required String name, required WorkspaceTypePB workspaceType, }) async { return _userService.createUserWorkspace(name, workspaceType); } @override Future> deleteWorkspace({ required String workspaceId, }) async { return _userService.deleteWorkspaceById(workspaceId); } @override Future> openWorkspace({ required String workspaceId, required WorkspaceTypePB workspaceType, }) async { return _userService.openWorkspace(workspaceId, workspaceType); } @override Future> renameWorkspace({ required String workspaceId, required String name, }) async { return _userService.renameWorkspace(workspaceId, name); } @override Future> updateWorkspaceIcon({ required String workspaceId, required String icon, }) async { return _userService.updateWorkspaceIcon(workspaceId, icon); } @override Future> leaveWorkspace({ required String workspaceId, }) async { return _userService.leaveWorkspace(workspaceId); } @override Future> getWorkspaceSubscriptionInfo({ required String workspaceId, }) async { return UserBackendService.getWorkspaceSubscriptionInfo(workspaceId); } @override Future isBillingEnabled() async { return billing_service.isBillingEnabled(); } } ================================================ FILE: frontend/appflowy_flutter/lib/features/workspace/data/repositories/workspace_repository.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; /// Abstract repository for workspace operations. /// /// This abstracts the data source for workspace operations, /// allowing for different implementations (e.g., REST API, gRPC, local storage). abstract class WorkspaceRepository { /// Gets the current workspace for the user. Future> getCurrentWorkspace(); /// Gets the list of workspaces for the current user. Future, FlowyError>> getWorkspaces(); /// Creates a new workspace. Future> createWorkspace({ required String name, required WorkspaceTypePB workspaceType, }); /// Deletes a workspace by ID. Future> deleteWorkspace({ required String workspaceId, }); /// Opens a workspace. Future> openWorkspace({ required String workspaceId, required WorkspaceTypePB workspaceType, }); /// Renames a workspace. Future> renameWorkspace({ required String workspaceId, required String name, }); /// Updates workspace icon. Future> updateWorkspaceIcon({ required String workspaceId, required String icon, }); /// Leaves a workspace. Future> leaveWorkspace({ required String workspaceId, }); /// Gets workspace subscription information. Future> getWorkspaceSubscriptionInfo({ required String workspaceId, }); /// Is billing enabled. Future isBillingEnabled(); } ================================================ FILE: frontend/appflowy_flutter/lib/features/workspace/logic/workspace_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/features/workspace/data/repositories/workspace_repository.dart'; import 'package:appflowy/features/workspace/logic/workspace_event.dart'; import 'package:appflowy/features/workspace/logic/workspace_state.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:protobuf/protobuf.dart'; export 'workspace_event.dart'; export 'workspace_state.dart'; class _WorkspaceFetchResult { const _WorkspaceFetchResult({ this.currentWorkspace, required this.workspaces, required this.shouldOpenWorkspace, }); final UserWorkspacePB? currentWorkspace; final List workspaces; final bool shouldOpenWorkspace; } class UserWorkspaceBloc extends Bloc { UserWorkspaceBloc({ required this.repository, required this.userProfile, this.initialWorkspaceId, }) : _listener = UserListener(userProfile: userProfile), super(UserWorkspaceState.initial(userProfile)) { on(_onInitialize); on(_onFetchWorkspaces); on(_onCreateWorkspace); on(_onDeleteWorkspace); on(_onOpenWorkspace); on(_onRenameWorkspace); on(_onUpdateWorkspaceIcon); on(_onLeaveWorkspace); on( _onFetchWorkspaceSubscriptionInfo, ); on( _onUpdateWorkspaceSubscriptionInfo, ); on(_onEmitWorkspaces); on(_onEmitUserProfile); on(_onEmitCurrentWorkspace); } final String? initialWorkspaceId; final WorkspaceRepository repository; final UserProfilePB userProfile; final UserListener _listener; @override Future close() { _listener.stop(); return super.close(); } Future _onInitialize( WorkspaceEventInitialize event, Emitter emit, ) async { await _setupListeners(); await _initializeWorkspaces(emit); } Future _onFetchWorkspaces( WorkspaceEventFetchWorkspaces event, Emitter emit, ) async { final result = await _fetchWorkspaces( initialWorkspaceId: event.initialWorkspaceId, ); final currentWorkspace = result.currentWorkspace; final workspaces = result.workspaces; Log.info( 'fetch workspaces: current workspace: ${currentWorkspace?.workspaceId}, workspaces: ${workspaces.map((e) => e.workspaceId)}', ); emit( state.copyWith( workspaces: workspaces, ), ); if (currentWorkspace != null && currentWorkspace.workspaceId != state.currentWorkspace?.workspaceId) { Log.info( 'fetch workspaces: try to open workspace: ${currentWorkspace.workspaceId}', ); add( UserWorkspaceEvent.openWorkspace( workspaceId: currentWorkspace.workspaceId, workspaceType: currentWorkspace.workspaceType, ), ); } } Future _onCreateWorkspace( WorkspaceEventCreateWorkspace event, Emitter emit, ) async { emit( state.copyWith( actionResult: const WorkspaceActionResult( actionType: WorkspaceActionType.create, isLoading: true, result: null, ), ), ); final result = await repository.createWorkspace( name: event.name, workspaceType: event.workspaceType, ); final workspaces = result.fold( (s) => [...state.workspaces, s], (e) => state.workspaces, ); emit( state.copyWith( workspaces: _sortWorkspaces(workspaces), actionResult: WorkspaceActionResult( actionType: WorkspaceActionType.create, isLoading: false, result: result.map((_) {}), ), ), ); result ..onSuccess((s) { Log.info('create workspace success: $s'); add( UserWorkspaceEvent.openWorkspace( workspaceId: s.workspaceId, workspaceType: s.workspaceType, ), ); }) ..onFailure((f) { Log.error('create workspace error: $f'); }); } Future _onDeleteWorkspace( WorkspaceEventDeleteWorkspace event, Emitter emit, ) async { Log.info('try to delete workspace: ${event.workspaceId}'); emit( state.copyWith( actionResult: const WorkspaceActionResult( actionType: WorkspaceActionType.delete, isLoading: true, result: null, ), ), ); final remoteWorkspaces = await _fetchWorkspaces().then( (value) => value.workspaces, ); if (state.workspaces.length <= 1 || remoteWorkspaces.length <= 1) { final result = FlowyResult.failure( FlowyError( code: ErrorCode.Internal, msg: LocaleKeys.workspace_cannotDeleteTheOnlyWorkspace.tr(), ), ); return emit( state.copyWith( actionResult: WorkspaceActionResult( actionType: WorkspaceActionType.delete, result: result, isLoading: false, ), ), ); } final result = await repository.deleteWorkspace( workspaceId: event.workspaceId, ); final workspacesResult = await _fetchWorkspaces(); final workspaces = workspacesResult.workspaces; final containsDeletedWorkspace = _findWorkspaceById(event.workspaceId, workspaces) != null; result ..onSuccess((_) { Log.info('delete workspace success: ${event.workspaceId}'); final firstWorkspace = workspaces.firstOrNull; assert( firstWorkspace != null, 'the first workspace must not be null', ); if (state.currentWorkspace?.workspaceId == event.workspaceId && firstWorkspace != null) { Log.info( 'delete workspace: open the first workspace: ${firstWorkspace.workspaceId}', ); add( UserWorkspaceEvent.openWorkspace( workspaceId: firstWorkspace.workspaceId, workspaceType: firstWorkspace.workspaceType, ), ); } }) ..onFailure((f) { Log.error('delete workspace error: $f'); if (!containsDeletedWorkspace && workspaces.isNotEmpty) { add( UserWorkspaceEvent.openWorkspace( workspaceId: workspaces.first.workspaceId, workspaceType: workspaces.first.workspaceType, ), ); } }); emit( state.copyWith( workspaces: workspaces, actionResult: WorkspaceActionResult( actionType: WorkspaceActionType.delete, result: result, isLoading: false, ), ), ); } Future _onOpenWorkspace( WorkspaceEventOpenWorkspace event, Emitter emit, ) async { emit( state.copyWith( actionResult: const WorkspaceActionResult( actionType: WorkspaceActionType.open, isLoading: true, result: null, ), ), ); final result = await repository.openWorkspace( workspaceId: event.workspaceId, workspaceType: event.workspaceType, ); final currentWorkspace = result.fold( (s) => _findWorkspaceById(event.workspaceId), (e) => state.currentWorkspace, ); result ..onSuccess((s) { add( UserWorkspaceEvent.fetchWorkspaceSubscriptionInfo( workspaceId: event.workspaceId, ), ); Log.info( 'open workspace success: ${event.workspaceId}, current workspace: ${currentWorkspace?.toProto3Json()}', ); }) ..onFailure((f) { Log.error('open workspace error: $f'); }); emit( state.copyWith( currentWorkspace: currentWorkspace, actionResult: WorkspaceActionResult( actionType: WorkspaceActionType.open, isLoading: false, result: result, ), ), ); getIt().add( ReminderEvent.started(), ); } Future _onRenameWorkspace( WorkspaceEventRenameWorkspace event, Emitter emit, ) async { final result = await repository.renameWorkspace( workspaceId: event.workspaceId, name: event.name, ); final workspaces = result.fold( (s) => _updateWorkspaceInList(event.workspaceId, (workspace) { workspace.freeze(); return workspace.rebuild((p0) { p0.name = event.name; }); }), (f) => state.workspaces, ); final currentWorkspace = _findWorkspaceById( state.currentWorkspace?.workspaceId ?? '', workspaces, ); Log.info('rename workspace: ${event.workspaceId}, name: ${event.name}'); result.onFailure((f) { Log.error('rename workspace error: $f'); }); emit( state.copyWith( workspaces: workspaces, currentWorkspace: currentWorkspace, actionResult: WorkspaceActionResult( actionType: WorkspaceActionType.rename, isLoading: false, result: result, ), ), ); } Future _onUpdateWorkspaceIcon( WorkspaceEventUpdateWorkspaceIcon event, Emitter emit, ) async { final workspace = _findWorkspaceById(event.workspaceId); if (workspace == null) { Log.error('workspace not found: ${event.workspaceId}'); return; } if (event.icon == workspace.icon) { Log.info('ignore same icon update'); return; } final result = await repository.updateWorkspaceIcon( workspaceId: event.workspaceId, icon: event.icon, ); final workspaces = result.fold( (s) => _updateWorkspaceInList(event.workspaceId, (workspace) { workspace.freeze(); return workspace.rebuild((p0) { p0.icon = event.icon; }); }), (f) => state.workspaces, ); final currentWorkspace = _findWorkspaceById( state.currentWorkspace?.workspaceId ?? '', workspaces, ); Log.info( 'update workspace icon: ${event.workspaceId}, icon: ${event.icon}', ); result.onFailure((f) { Log.error('update workspace icon error: $f'); }); emit( state.copyWith( workspaces: workspaces, currentWorkspace: currentWorkspace, actionResult: WorkspaceActionResult( actionType: WorkspaceActionType.updateIcon, isLoading: false, result: result, ), ), ); } Future _onLeaveWorkspace( WorkspaceEventLeaveWorkspace event, Emitter emit, ) async { final result = await repository.leaveWorkspace( workspaceId: event.workspaceId, ); final workspaces = result.fold( (s) => state.workspaces .where((e) => e.workspaceId != event.workspaceId) .toList(), (e) => state.workspaces, ); result ..onSuccess((_) { Log.info('leave workspace success: ${event.workspaceId}'); if (state.currentWorkspace?.workspaceId == event.workspaceId && workspaces.isNotEmpty) { add( UserWorkspaceEvent.openWorkspace( workspaceId: workspaces.first.workspaceId, workspaceType: workspaces.first.workspaceType, ), ); } }) ..onFailure((f) { Log.error('leave workspace error: $f'); }); emit( state.copyWith( workspaces: _sortWorkspaces(workspaces), actionResult: WorkspaceActionResult( actionType: WorkspaceActionType.leave, isLoading: false, result: result, ), ), ); } Future _onFetchWorkspaceSubscriptionInfo( WorkspaceEventFetchWorkspaceSubscriptionInfo event, Emitter emit, ) async { final enabled = await repository.isBillingEnabled(); // If billing is not enabled, we don't need to fetch the workspace subscription info if (!enabled) { return; } unawaited( repository .getWorkspaceSubscriptionInfo( workspaceId: event.workspaceId, ) .fold( (workspaceSubscriptionInfo) { if (isClosed) { return; } if (state.currentWorkspace?.workspaceId != event.workspaceId) { return; } Log.debug( 'fetch workspace subscription info: ${event.workspaceId}, $workspaceSubscriptionInfo', ); add( UserWorkspaceEvent.updateWorkspaceSubscriptionInfo( workspaceId: event.workspaceId, subscriptionInfo: workspaceSubscriptionInfo, ), ); }, (e) => Log.error('fetch workspace subscription info error: $e'), ), ); } Future _onUpdateWorkspaceSubscriptionInfo( WorkspaceEventUpdateWorkspaceSubscriptionInfo event, Emitter emit, ) async { emit( state.copyWith(workspaceSubscriptionInfo: event.subscriptionInfo), ); } Future _onEmitWorkspaces( WorkspaceEventEmitWorkspaces event, Emitter emit, ) async { emit( state.copyWith( workspaces: _sortWorkspaces(event.workspaces), ), ); } Future _onEmitUserProfile( WorkspaceEventEmitUserProfile event, Emitter emit, ) async { emit( state.copyWith(userProfile: event.userProfile), ); } Future _onEmitCurrentWorkspace( WorkspaceEventEmitCurrentWorkspace event, Emitter emit, ) async { emit( state.copyWith(currentWorkspace: event.workspace), ); } Future _setupListeners() async { _listener.start( onProfileUpdated: (result) { if (!isClosed) { result.fold( (newProfile) => add( UserWorkspaceEvent.emitUserProfile(userProfile: newProfile), ), (error) => Log.error("Failed to get user profile: $error"), ); } }, onUserWorkspaceListUpdated: (workspaces) { if (!isClosed) { add( UserWorkspaceEvent.emitWorkspaces( workspaces: _sortWorkspaces(workspaces.items), ), ); } }, onUserWorkspaceUpdated: (workspace) { if (!isClosed) { if (state.currentWorkspace?.workspaceId == workspace.workspaceId) { add(UserWorkspaceEvent.emitCurrentWorkspace(workspace: workspace)); } } }, ); } Future _initializeWorkspaces(Emitter emit) async { final result = await _fetchWorkspaces( initialWorkspaceId: initialWorkspaceId, ); final currentWorkspace = result.currentWorkspace; final workspaces = result.workspaces; final isCollabWorkspaceOn = state.userProfile.userAuthType == AuthTypePB.Server && FeatureFlag.collaborativeWorkspace.isOn; Log.info( 'init workspace, current workspace: ${currentWorkspace?.workspaceId}, ' 'workspaces: ${workspaces.map((e) => e.workspaceId)}, isCollabWorkspaceOn: $isCollabWorkspaceOn', ); if (currentWorkspace != null) { add( UserWorkspaceEvent.fetchWorkspaceSubscriptionInfo( workspaceId: currentWorkspace.workspaceId, ), ); } if (currentWorkspace != null && result.shouldOpenWorkspace == true) { Log.info('init open workspace: ${currentWorkspace.workspaceId}'); await repository.openWorkspace( workspaceId: currentWorkspace.workspaceId, workspaceType: currentWorkspace.workspaceType, ); } emit( state.copyWith( currentWorkspace: currentWorkspace, workspaces: workspaces, isCollabWorkspaceOn: isCollabWorkspaceOn, actionResult: const WorkspaceActionResult( actionType: WorkspaceActionType.none, isLoading: false, result: null, ), ), ); } // Helper methods List _sortWorkspaces(List workspaces) { final sorted = [...workspaces]; sorted.sort( (a, b) => a.createdAtTimestamp.compareTo(b.createdAtTimestamp), ); return sorted; } UserWorkspacePB? _findWorkspaceById( String id, [ List? workspacesList, ]) { final workspaces = workspacesList ?? state.workspaces; return workspaces.firstWhereOrNull((e) => e.workspaceId == id); } List _updateWorkspaceInList( String workspaceId, UserWorkspacePB Function(UserWorkspacePB workspace) updater, ) { final workspaces = [...state.workspaces]; final index = workspaces.indexWhere((e) => e.workspaceId == workspaceId); if (index != -1) { workspaces[index] = updater(workspaces[index]); } return workspaces; } Future<_WorkspaceFetchResult> _fetchWorkspaces({ String? initialWorkspaceId, }) async { try { final currentWorkspaceResult = await repository.getCurrentWorkspace(); final currentWorkspace = currentWorkspaceResult.fold( (s) => s, (e) => null, ); final currentWorkspaceId = initialWorkspaceId ?? currentWorkspace?.id; final workspacesResult = await repository.getWorkspaces(); final workspaces = workspacesResult.getOrThrow(); if (workspaces.isEmpty && currentWorkspace != null) { workspaces.add( _convertWorkspacePBToUserWorkspace(currentWorkspace), ); } final currentWorkspaceInList = _findWorkspaceById( currentWorkspaceId ?? '', workspaces, ) ?? workspaces.firstOrNull; final sortedWorkspaces = _sortWorkspaces(workspaces); Log.info( 'fetch workspaces: current workspace: ${currentWorkspaceInList?.workspaceId}, sorted workspaces: ${sortedWorkspaces.map((e) => '${e.name}: ${e.workspaceId}')}', ); return _WorkspaceFetchResult( currentWorkspace: currentWorkspaceInList, workspaces: sortedWorkspaces, shouldOpenWorkspace: currentWorkspaceInList?.workspaceId != currentWorkspaceId, ); } catch (e) { Log.error('fetch workspace error: $e'); return _WorkspaceFetchResult( currentWorkspace: state.currentWorkspace, workspaces: state.workspaces, shouldOpenWorkspace: false, ); } } UserWorkspacePB _convertWorkspacePBToUserWorkspace(WorkspacePB workspace) { return UserWorkspacePB.create() ..workspaceId = workspace.id ..name = workspace.name ..createdAtTimestamp = workspace.createTime; } } ================================================ FILE: frontend/appflowy_flutter/lib/features/workspace/logic/workspace_event.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; sealed class UserWorkspaceEvent { UserWorkspaceEvent(); // Factory functions for creating events factory UserWorkspaceEvent.initialize() => WorkspaceEventInitialize(); factory UserWorkspaceEvent.fetchWorkspaces({ String? initialWorkspaceId, }) => WorkspaceEventFetchWorkspaces(initialWorkspaceId: initialWorkspaceId); factory UserWorkspaceEvent.createWorkspace({ required String name, required WorkspaceTypePB workspaceType, }) => WorkspaceEventCreateWorkspace(name: name, workspaceType: workspaceType); factory UserWorkspaceEvent.deleteWorkspace({ required String workspaceId, }) => WorkspaceEventDeleteWorkspace(workspaceId: workspaceId); factory UserWorkspaceEvent.openWorkspace({ required String workspaceId, required WorkspaceTypePB workspaceType, }) => WorkspaceEventOpenWorkspace( workspaceId: workspaceId, workspaceType: workspaceType, ); factory UserWorkspaceEvent.renameWorkspace({ required String workspaceId, required String name, }) => WorkspaceEventRenameWorkspace(workspaceId: workspaceId, name: name); factory UserWorkspaceEvent.updateWorkspaceIcon({ required String workspaceId, required String icon, }) => WorkspaceEventUpdateWorkspaceIcon( workspaceId: workspaceId, icon: icon, ); factory UserWorkspaceEvent.leaveWorkspace({ required String workspaceId, }) => WorkspaceEventLeaveWorkspace(workspaceId: workspaceId); factory UserWorkspaceEvent.fetchWorkspaceSubscriptionInfo({ required String workspaceId, }) => WorkspaceEventFetchWorkspaceSubscriptionInfo(workspaceId: workspaceId); factory UserWorkspaceEvent.updateWorkspaceSubscriptionInfo({ required String workspaceId, required WorkspaceSubscriptionInfoPB subscriptionInfo, }) => WorkspaceEventUpdateWorkspaceSubscriptionInfo( workspaceId: workspaceId, subscriptionInfo: subscriptionInfo, ); factory UserWorkspaceEvent.emitWorkspaces({ required List workspaces, }) => WorkspaceEventEmitWorkspaces(workspaces: workspaces); factory UserWorkspaceEvent.emitUserProfile({ required UserProfilePB userProfile, }) => WorkspaceEventEmitUserProfile(userProfile: userProfile); factory UserWorkspaceEvent.emitCurrentWorkspace({ required UserWorkspacePB workspace, }) => WorkspaceEventEmitCurrentWorkspace(workspace: workspace); } /// Initializes the workspace bloc. class WorkspaceEventInitialize extends UserWorkspaceEvent { WorkspaceEventInitialize(); } /// Fetches the list of workspaces for the current user. class WorkspaceEventFetchWorkspaces extends UserWorkspaceEvent { WorkspaceEventFetchWorkspaces({ this.initialWorkspaceId, }); final String? initialWorkspaceId; } /// Creates a new workspace. class WorkspaceEventCreateWorkspace extends UserWorkspaceEvent { WorkspaceEventCreateWorkspace({ required this.name, required this.workspaceType, }); final String name; final WorkspaceTypePB workspaceType; } /// Deletes a workspace. class WorkspaceEventDeleteWorkspace extends UserWorkspaceEvent { WorkspaceEventDeleteWorkspace({ required this.workspaceId, }); final String workspaceId; } /// Opens a workspace. class WorkspaceEventOpenWorkspace extends UserWorkspaceEvent { WorkspaceEventOpenWorkspace({ required this.workspaceId, required this.workspaceType, }); final String workspaceId; final WorkspaceTypePB workspaceType; } /// Renames a workspace. class WorkspaceEventRenameWorkspace extends UserWorkspaceEvent { WorkspaceEventRenameWorkspace({ required this.workspaceId, required this.name, }); final String workspaceId; final String name; } /// Updates workspace icon. class WorkspaceEventUpdateWorkspaceIcon extends UserWorkspaceEvent { WorkspaceEventUpdateWorkspaceIcon({ required this.workspaceId, required this.icon, }); final String workspaceId; final String icon; } /// Leaves a workspace. class WorkspaceEventLeaveWorkspace extends UserWorkspaceEvent { WorkspaceEventLeaveWorkspace({ required this.workspaceId, }); final String workspaceId; } /// Fetches workspace subscription info. class WorkspaceEventFetchWorkspaceSubscriptionInfo extends UserWorkspaceEvent { WorkspaceEventFetchWorkspaceSubscriptionInfo({ required this.workspaceId, }); final String workspaceId; } /// Updates workspace subscription info. class WorkspaceEventUpdateWorkspaceSubscriptionInfo extends UserWorkspaceEvent { WorkspaceEventUpdateWorkspaceSubscriptionInfo({ required this.workspaceId, required this.subscriptionInfo, }); final String workspaceId; final WorkspaceSubscriptionInfoPB subscriptionInfo; } class WorkspaceEventEmitWorkspaces extends UserWorkspaceEvent { WorkspaceEventEmitWorkspaces({ required this.workspaces, }); final List workspaces; } class WorkspaceEventEmitUserProfile extends UserWorkspaceEvent { WorkspaceEventEmitUserProfile({ required this.userProfile, }); final UserProfilePB userProfile; } class WorkspaceEventEmitCurrentWorkspace extends UserWorkspaceEvent { WorkspaceEventEmitCurrentWorkspace({ required this.workspace, }); final UserWorkspacePB workspace; } ================================================ FILE: frontend/appflowy_flutter/lib/features/workspace/logic/workspace_state.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; enum WorkspaceActionType { none, create, delete, open, rename, updateIcon, fetchWorkspaces, leave, fetchSubscriptionInfo, } class WorkspaceActionResult { const WorkspaceActionResult({ required this.actionType, required this.isLoading, required this.result, }); final WorkspaceActionType actionType; final bool isLoading; final FlowyResult? result; @override String toString() { return 'WorkspaceActionResult(actionType: $actionType, isLoading: $isLoading, result: $result)'; } } class UserWorkspaceState { factory UserWorkspaceState.initial(UserProfilePB userProfile) => UserWorkspaceState( userProfile: userProfile, ); const UserWorkspaceState({ this.currentWorkspace, this.workspaces = const [], this.actionResult, this.isCollabWorkspaceOn = false, required this.userProfile, this.workspaceSubscriptionInfo, }); final UserWorkspacePB? currentWorkspace; final List workspaces; final WorkspaceActionResult? actionResult; final bool isCollabWorkspaceOn; final UserProfilePB userProfile; final WorkspaceSubscriptionInfoPB? workspaceSubscriptionInfo; UserWorkspaceState copyWith({ UserWorkspacePB? currentWorkspace, List? workspaces, WorkspaceActionResult? actionResult, bool? isCollabWorkspaceOn, UserProfilePB? userProfile, WorkspaceSubscriptionInfoPB? workspaceSubscriptionInfo, }) { return UserWorkspaceState( currentWorkspace: currentWorkspace ?? this.currentWorkspace, workspaces: workspaces ?? this.workspaces, actionResult: actionResult ?? this.actionResult, isCollabWorkspaceOn: isCollabWorkspaceOn ?? this.isCollabWorkspaceOn, userProfile: userProfile ?? this.userProfile, workspaceSubscriptionInfo: workspaceSubscriptionInfo ?? this.workspaceSubscriptionInfo, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is UserWorkspaceState && other.currentWorkspace == currentWorkspace && other.workspaces == workspaces && other.actionResult == actionResult && other.isCollabWorkspaceOn == isCollabWorkspaceOn && other.userProfile == userProfile && other.workspaceSubscriptionInfo == workspaceSubscriptionInfo; } @override int get hashCode { return Object.hash( currentWorkspace, workspaces, actionResult, isCollabWorkspaceOn, userProfile, workspaceSubscriptionInfo, ); } @override String toString() { return 'WorkspaceState(currentWorkspace: $currentWorkspace, workspaces: $workspaces, actionResult: $actionResult, isCollabWorkspaceOn: $isCollabWorkspaceOn, userProfile: $userProfile, workspaceSubscriptionInfo: $workspaceSubscriptionInfo)'; } } ================================================ FILE: frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart ================================================ // Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // TODO(Mathias): Make a PR in Flutter repository that enables customizing // the dropdown menu without having to copy the entire file. // This is a temporary solution! import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; const double _kMinimumWidth = 112.0; const double _kDefaultHorizontalPadding = 12.0; typedef CompareFunction = bool Function(T? left, T? right); // Navigation shortcuts to move the selected menu items up or down. final Map _kMenuTraversalShortcuts = { LogicalKeySet(LogicalKeyboardKey.arrowUp): const _ArrowUpIntent(), LogicalKeySet(LogicalKeyboardKey.arrowDown): const _ArrowDownIntent(), }; /// A dropdown menu that can be opened from a [TextField]. The selected /// menu item is displayed in that field. /// /// This widget is used to help people make a choice from a menu and put the /// selected item into the text input field. People can also filter the list based /// on the text input or search one item in the menu list. /// /// The menu is composed of a list of [DropdownMenuEntry]s. People can provide information, /// such as: label, leading icon or trailing icon for each entry. The [TextField] /// will be updated based on the selection from the menu entries. The text field /// will stay empty if the selected entry is disabled. /// /// The dropdown menu can be traversed by pressing the up or down key. During the /// process, the corresponding item will be highlighted and the text field will be updated. /// Disabled items will be skipped during traversal. /// /// The menu can be scrollable if not all items in the list are displayed at once. /// /// {@tool dartpad} /// This sample shows how to display outlined [AFDropdownMenu] and filled [AFDropdownMenu]. /// /// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart ** /// {@end-tool} /// /// See also: /// /// * [MenuAnchor], which is a widget used to mark the "anchor" for a set of submenus. /// The [AFDropdownMenu] uses a [TextField] as the "anchor". /// * [TextField], which is a text input widget that uses an [InputDecoration]. /// * [DropdownMenuEntry], which is used to build the [MenuItemButton] in the [AFDropdownMenu] list. class AFDropdownMenu extends StatefulWidget { /// Creates a const [AFDropdownMenu]. /// /// The leading and trailing icons in the text field can be customized by using /// [leadingIcon], [trailingIcon] and [selectedTrailingIcon] properties. They are /// passed down to the [InputDecoration] properties, and will override values /// in the [InputDecoration.prefixIcon] and [InputDecoration.suffixIcon]. /// /// Except leading and trailing icons, the text field can be configured by the /// [InputDecorationTheme] property. The menu can be configured by the [menuStyle]. const AFDropdownMenu({ super.key, this.enabled = true, this.width, this.menuHeight, this.leadingIcon, this.trailingIcon, this.label, this.hintText, this.helperText, this.errorText, this.selectedTrailingIcon, this.enableFilter = false, this.enableSearch = true, this.textStyle, this.inputDecorationTheme, this.menuStyle, this.controller, this.initialSelection, this.onSelected, this.requestFocusOnTap, this.expandedInsets, this.searchCallback, this.selectOptionCompare, required this.dropdownMenuEntries, }); /// Determine if the [AFDropdownMenu] is enabled. /// /// Defaults to true. final bool enabled; /// Determine the width of the [AFDropdownMenu]. /// /// If this is null, the width of the [AFDropdownMenu] will be the same as the width of the widest /// menu item plus the width of the leading/trailing icon. final double? width; /// Determine the height of the menu. /// /// If this is null, the menu will display as many items as possible on the screen. final double? menuHeight; /// An optional Icon at the front of the text input field. /// /// Defaults to null. If this is not null, the menu items will have extra paddings to be aligned /// with the text in the text field. final Widget? leadingIcon; /// An optional icon at the end of the text field. /// /// Defaults to an [Icon] with [Icons.arrow_drop_down]. final Widget? trailingIcon; /// Optional widget that describes the input field. /// /// When the input field is empty and unfocused, the label is displayed on /// top of the input field (i.e., at the same location on the screen where /// text may be entered in the input field). When the input field receives /// focus (or if the field is non-empty), the label moves above, either /// vertically adjacent to, or to the center of the input field. /// /// Defaults to null. final Widget? label; /// Text that suggests what sort of input the field accepts. /// /// Defaults to null; final String? hintText; /// Text that provides context about the [AFDropdownMenu]'s value, such /// as how the value will be used. /// /// If non-null, the text is displayed below the input field, in /// the same location as [errorText]. If a non-null [errorText] value is /// specified then the helper text is not shown. /// /// Defaults to null; /// /// See also: /// /// * [InputDecoration.helperText], which is the text that provides context about the [InputDecorator.child]'s value. final String? helperText; /// Text that appears below the input field and the border to show the error message. /// /// If non-null, the border's color animates to red and the [helperText] is not shown. /// /// Defaults to null; /// /// See also: /// /// * [InputDecoration.errorText], which is the text that appears below the [InputDecorator.child] and the border. final String? errorText; /// An optional icon at the end of the text field to indicate that the text /// field is pressed. /// /// Defaults to an [Icon] with [Icons.arrow_drop_up]. final Widget? selectedTrailingIcon; /// Determine if the menu list can be filtered by the text input. /// /// Defaults to false. final bool enableFilter; /// Determine if the first item that matches the text input can be highlighted. /// /// Defaults to true as the search function could be commonly used. final bool enableSearch; /// The text style for the [TextField] of the [AFDropdownMenu]; /// /// Defaults to the overall theme's [TextTheme.bodyLarge] /// if the dropdown menu theme's value is null. final TextStyle? textStyle; /// Defines the default appearance of [InputDecoration] to show around the text field. /// /// By default, shows a outlined text field. final InputDecorationTheme? inputDecorationTheme; /// The [MenuStyle] that defines the visual attributes of the menu. /// /// The default width of the menu is set to the width of the text field. final MenuStyle? menuStyle; /// Controls the text being edited or selected in the menu. /// /// If null, this widget will create its own [TextEditingController]. final TextEditingController? controller; /// The value used to for an initial selection. /// /// Defaults to null. final T? initialSelection; /// The callback is called when a selection is made. /// /// Defaults to null. If null, only the text field is updated. final ValueChanged? onSelected; /// Determine if the dropdown button requests focus and the on-screen virtual /// keyboard is shown in response to a touch event. /// /// By default, on mobile platforms, tapping on the text field and opening /// the menu will not cause a focus request and the virtual keyboard will not /// appear. The default behavior for desktop platforms is for the dropdown to /// take the focus. /// /// Defaults to null. Setting this field to true or false, rather than allowing /// the implementation to choose based on the platform, can be useful for /// applications that want to override the default behavior. final bool? requestFocusOnTap; /// Descriptions of the menu items in the [AFDropdownMenu]. /// /// This is a required parameter. It is recommended that at least one [DropdownMenuEntry] /// is provided. If this is an empty list, the menu will be empty and only /// contain space for padding. final List> dropdownMenuEntries; /// Defines the menu text field's width to be equal to its parent's width /// plus the horizontal width of the specified insets. /// /// If this property is null, the width of the text field will be determined /// by the width of menu items or [AFDropdownMenu.width]. If this property is not null, /// the text field's width will match the parent's width plus the specified insets. /// If the value of this property is [EdgeInsets.zero], the width of the text field will be the same /// as its parent's width. /// /// The [expandedInsets]' top and bottom are ignored, only its left and right /// properties are used. /// /// Defaults to null. final EdgeInsets? expandedInsets; /// When [AFDropdownMenu.enableSearch] is true, this callback is used to compute /// the index of the search result to be highlighted. /// /// {@tool snippet} /// /// In this example the `searchCallback` returns the index of the search result /// that exactly matches the query. /// /// ```dart /// DropdownMenu( /// searchCallback: (List> entries, String query) { /// if (query.isEmpty) { /// return null; /// } /// final int index = entries.indexWhere((DropdownMenuEntry entry) => entry.label == query); /// /// return index != -1 ? index : null; /// }, /// dropdownMenuEntries: const >[], /// ) /// ``` /// {@end-tool} /// /// Defaults to null. If this is null and [AFDropdownMenu.enableSearch] is true, /// the default function will return the index of the first matching result /// which contains the contents of the text input field. final SearchCallback? searchCallback; /// Defines the compare function for the menu items. /// /// Defaults to null. If this is null, the menu items will be sorted by the label. final CompareFunction? selectOptionCompare; @override State> createState() => _AFDropdownMenuState(); } class _AFDropdownMenuState extends State> { final GlobalKey _anchorKey = GlobalKey(); final GlobalKey _leadingKey = GlobalKey(); late List buttonItemKeys; final MenuController _controller = MenuController(); late bool _enableFilter; late List> filteredEntries; List? _initialMenu; int? currentHighlight; double? leadingPadding; bool _menuHasEnabledItem = false; TextEditingController? _localTextEditingController; TextEditingController get _textEditingController { return widget.controller ?? (_localTextEditingController ??= TextEditingController()); } @override void initState() { super.initState(); _enableFilter = widget.enableFilter; filteredEntries = widget.dropdownMenuEntries; buttonItemKeys = List.generate( filteredEntries.length, (int index) => GlobalKey(), ); _menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); final int index = filteredEntries.indexWhere( (DropdownMenuEntry entry) { if (widget.selectOptionCompare != null) { return widget.selectOptionCompare!( entry.value, widget.initialSelection, ); } else { return entry.value == widget.initialSelection; } }, ); if (index != -1) { _textEditingController.value = TextEditingValue( text: filteredEntries[index].label, selection: TextSelection.collapsed( offset: filteredEntries[index].label.length, ), ); } refreshLeadingPadding(); } @override void dispose() { _localTextEditingController?.dispose(); _localTextEditingController = null; super.dispose(); } @override void didUpdateWidget(AFDropdownMenu oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { if (widget.controller != null) { _localTextEditingController?.dispose(); _localTextEditingController = null; } } if (oldWidget.enableSearch != widget.enableSearch) { if (!widget.enableSearch) { currentHighlight = null; } } if (oldWidget.dropdownMenuEntries != widget.dropdownMenuEntries) { currentHighlight = null; filteredEntries = widget.dropdownMenuEntries; buttonItemKeys = List.generate( filteredEntries.length, (int index) => GlobalKey(), ); _menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); } if (oldWidget.leadingIcon != widget.leadingIcon) { refreshLeadingPadding(); } if (oldWidget.initialSelection != widget.initialSelection) { final int index = filteredEntries.indexWhere( (DropdownMenuEntry entry) => entry.value == widget.initialSelection, ); if (index != -1) { _textEditingController.value = TextEditingValue( text: filteredEntries[index].label, selection: TextSelection.collapsed( offset: filteredEntries[index].label.length, ), ); } } } bool canRequestFocus() { if (widget.requestFocusOnTap != null) { return widget.requestFocusOnTap!; } switch (Theme.of(context).platform) { case TargetPlatform.iOS: case TargetPlatform.android: case TargetPlatform.fuchsia: return false; case TargetPlatform.macOS: case TargetPlatform.linux: case TargetPlatform.windows: return true; } } void refreshLeadingPadding() { WidgetsBinding.instance.addPostFrameCallback( (_) { setState(() { leadingPadding = getWidth(_leadingKey); }); }, debugLabel: 'DropdownMenu.refreshLeadingPadding', ); } // Remove the code here, it will throw a FlutterError // Unless we upgrade to Flutter 3.24 https://github.com/flutter/flutter/issues/146764 void scrollToHighlight() { // WidgetsBinding.instance.addPostFrameCallback( // (_) { // // try { // final BuildContext? highlightContext = // buttonItemKeys[currentHighlight!].currentContext; // if (highlightContext != null) { // Scrollable.ensureVisible(highlightContext); // } // } catch (_) { // return; // } // }, // debugLabel: 'DropdownMenu.scrollToHighlight', // ); } double? getWidth(GlobalKey key) { final BuildContext? context = key.currentContext; if (context != null) { final RenderBox box = context.findRenderObject()! as RenderBox; return box.hasSize ? box.size.width : null; } return null; } List> filter( List> entries, TextEditingController textEditingController, ) { final String filterText = textEditingController.text.toLowerCase(); return entries .where( (DropdownMenuEntry entry) => entry.label.toLowerCase().contains(filterText), ) .toList(); } int? search( List> entries, TextEditingController textEditingController, ) { final String searchText = textEditingController.value.text.toLowerCase(); if (searchText.isEmpty) { return null; } final int index = entries.indexWhere( (DropdownMenuEntry entry) => entry.label.toLowerCase().contains(searchText), ); return index != -1 ? index : null; } List _buildButtons( List> filteredEntries, TextDirection textDirection, { int? focusedIndex, bool enableScrollToHighlight = true, }) { final List result = []; for (int i = 0; i < filteredEntries.length; i++) { final DropdownMenuEntry entry = filteredEntries[i]; // By default, when the text field has a leading icon but a menu entry doesn't // have one, the label of the entry should have extra padding to be aligned // with the text in the text input field. When both the text field and the // menu entry have leading icons, the menu entry should remove the extra // paddings so its leading icon will be aligned with the leading icon of // the text field. final double padding = entry.leadingIcon == null ? (leadingPadding ?? _kDefaultHorizontalPadding) : _kDefaultHorizontalPadding; final ButtonStyle defaultStyle; switch (textDirection) { case TextDirection.rtl: defaultStyle = MenuItemButton.styleFrom( padding: EdgeInsets.only( left: _kDefaultHorizontalPadding, right: padding, ), ); case TextDirection.ltr: defaultStyle = MenuItemButton.styleFrom( padding: EdgeInsets.only( left: padding, right: _kDefaultHorizontalPadding, ), ); } ButtonStyle effectiveStyle = entry.style ?? defaultStyle; final Color focusedBackgroundColor = effectiveStyle.foregroundColor ?.resolve({WidgetState.focused}) ?? Theme.of(context).colorScheme.onSurface; Widget label = entry.labelWidget ?? Text(entry.label); if (widget.width != null) { final double horizontalPadding = padding + _kDefaultHorizontalPadding; label = ConstrainedBox( constraints: BoxConstraints(maxWidth: widget.width! - horizontalPadding), child: label, ); } // Simulate the focused state because the text field should always be focused // during traversal. If the menu item has a custom foreground color, the "focused" // color will also change to foregroundColor.withValues(alpha: 0.12). effectiveStyle = entry.enabled && i == focusedIndex ? effectiveStyle.copyWith( backgroundColor: WidgetStatePropertyAll( focusedBackgroundColor.withValues(alpha: 0.12), ), ) : effectiveStyle; final Widget menuItemButton = Padding( padding: const EdgeInsets.only(bottom: 6), child: MenuItemButton( key: enableScrollToHighlight ? buttonItemKeys[i] : null, style: effectiveStyle, leadingIcon: entry.leadingIcon, trailingIcon: entry.trailingIcon, onPressed: entry.enabled ? () { _textEditingController.value = TextEditingValue( text: entry.label, selection: TextSelection.collapsed(offset: entry.label.length), ); currentHighlight = widget.enableSearch ? i : null; widget.onSelected?.call(entry.value); } : null, requestFocusOnHover: false, child: label, ), ); result.add(menuItemButton); } return result; } void handleUpKeyInvoke(_) { setState(() { if (!_menuHasEnabledItem || !_controller.isOpen) { return; } _enableFilter = false; currentHighlight ??= 0; currentHighlight = (currentHighlight! - 1) % filteredEntries.length; while (!filteredEntries[currentHighlight!].enabled) { currentHighlight = (currentHighlight! - 1) % filteredEntries.length; } final String currentLabel = filteredEntries[currentHighlight!].label; _textEditingController.value = TextEditingValue( text: currentLabel, selection: TextSelection.collapsed(offset: currentLabel.length), ); }); } void handleDownKeyInvoke(_) { setState(() { if (!_menuHasEnabledItem || !_controller.isOpen) { return; } _enableFilter = false; currentHighlight ??= -1; currentHighlight = (currentHighlight! + 1) % filteredEntries.length; while (!filteredEntries[currentHighlight!].enabled) { currentHighlight = (currentHighlight! + 1) % filteredEntries.length; } final String currentLabel = filteredEntries[currentHighlight!].label; _textEditingController.value = TextEditingValue( text: currentLabel, selection: TextSelection.collapsed(offset: currentLabel.length), ); }); } void handlePressed(MenuController controller) { if (controller.isOpen) { currentHighlight = null; controller.close(); } else { // close to open if (_textEditingController.text.isNotEmpty) { _enableFilter = false; } controller.open(); } setState(() {}); } @override Widget build(BuildContext context) { final TextDirection textDirection = Directionality.of(context); _initialMenu ??= _buildButtons( widget.dropdownMenuEntries, textDirection, enableScrollToHighlight: false, ); final DropdownMenuThemeData theme = DropdownMenuTheme.of(context); final DropdownMenuThemeData defaults = _DropdownMenuDefaultsM3(context); if (_enableFilter) { filteredEntries = filter(widget.dropdownMenuEntries, _textEditingController); } if (widget.enableSearch) { if (widget.searchCallback != null) { currentHighlight = widget.searchCallback! .call(filteredEntries, _textEditingController.text); } else { currentHighlight = search(filteredEntries, _textEditingController); } if (currentHighlight != null) { scrollToHighlight(); } } final List menu = _buildButtons( filteredEntries, textDirection, focusedIndex: currentHighlight, ); final TextStyle? effectiveTextStyle = widget.textStyle ?? theme.textStyle ?? defaults.textStyle; MenuStyle? effectiveMenuStyle = widget.menuStyle ?? theme.menuStyle ?? defaults.menuStyle!; final double? anchorWidth = getWidth(_anchorKey); if (widget.width != null) { effectiveMenuStyle = effectiveMenuStyle.copyWith( minimumSize: WidgetStatePropertyAll(Size(widget.width!, 0.0)), ); } else if (anchorWidth != null) { effectiveMenuStyle = effectiveMenuStyle.copyWith( minimumSize: WidgetStatePropertyAll(Size(anchorWidth, 0.0)), ); } if (widget.menuHeight != null) { effectiveMenuStyle = effectiveMenuStyle.copyWith( maximumSize: WidgetStatePropertyAll( Size(double.infinity, widget.menuHeight!), ), ); } final InputDecorationTheme effectiveInputDecorationTheme = widget.inputDecorationTheme ?? theme.inputDecorationTheme ?? defaults.inputDecorationTheme!; final MouseCursor effectiveMouseCursor = canRequestFocus() ? SystemMouseCursors.text : SystemMouseCursors.click; Widget menuAnchor = MenuAnchor( style: effectiveMenuStyle, controller: _controller, menuChildren: menu, crossAxisUnconstrained: false, builder: ( BuildContext context, MenuController controller, Widget? child, ) { assert(_initialMenu != null); final Widget trailingButton = Padding( padding: const EdgeInsets.all(4.0), child: IconButton( splashRadius: 1, isSelected: controller.isOpen, icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down), selectedIcon: widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up), onPressed: () { handlePressed(controller); }, ), ); final Widget leadingButton = Padding( padding: const EdgeInsets.all(8.0), child: widget.leadingIcon ?? const SizedBox(), ); final Widget textField = TextField( key: _anchorKey, mouseCursor: effectiveMouseCursor, canRequestFocus: canRequestFocus(), enableInteractiveSelection: canRequestFocus(), textAlignVertical: TextAlignVertical.center, style: effectiveTextStyle, controller: _textEditingController, onEditingComplete: () { if (currentHighlight != null) { final DropdownMenuEntry entry = filteredEntries[currentHighlight!]; if (entry.enabled) { _textEditingController.value = TextEditingValue( text: entry.label, selection: TextSelection.collapsed(offset: entry.label.length), ); widget.onSelected?.call(entry.value); } } else { widget.onSelected?.call(null); } if (!widget.enableSearch) { currentHighlight = null; } controller.close(); }, onTap: () { handlePressed(controller); }, onChanged: (String text) { controller.open(); setState(() { filteredEntries = widget.dropdownMenuEntries; _enableFilter = widget.enableFilter; }); }, decoration: InputDecoration( enabled: widget.enabled, label: widget.label, hintText: widget.hintText, helperText: widget.helperText, errorText: widget.errorText, prefixIcon: widget.leadingIcon != null ? Container(key: _leadingKey, child: widget.leadingIcon) : null, suffixIcon: trailingButton, ).applyDefaults(effectiveInputDecorationTheme), ); if (widget.expandedInsets != null) { // If [expandedInsets] is not null, the width of the text field should depend // on its parent width. So we don't need to use `_DropdownMenuBody` to // calculate the children's width. return textField; } return _DropdownMenuBody( width: widget.width, children: [ textField, for (final Widget item in _initialMenu!) item, trailingButton, leadingButton, ], ); }, ); if (widget.expandedInsets != null) { menuAnchor = Container( alignment: AlignmentDirectional.topStart, padding: widget.expandedInsets?.copyWith(top: 0.0, bottom: 0.0), child: menuAnchor, ); } return Shortcuts( shortcuts: _kMenuTraversalShortcuts, child: Actions( actions: >{ _ArrowUpIntent: CallbackAction<_ArrowUpIntent>( onInvoke: handleUpKeyInvoke, ), _ArrowDownIntent: CallbackAction<_ArrowDownIntent>( onInvoke: handleDownKeyInvoke, ), }, child: menuAnchor, ), ); } } class _ArrowUpIntent extends Intent { const _ArrowUpIntent(); } class _ArrowDownIntent extends Intent { const _ArrowDownIntent(); } class _DropdownMenuBody extends MultiChildRenderObjectWidget { const _DropdownMenuBody({ super.children, this.width, }); final double? width; @override _RenderDropdownMenuBody createRenderObject(BuildContext context) { return _RenderDropdownMenuBody( width: width, ); } @override void updateRenderObject( BuildContext context, _RenderDropdownMenuBody renderObject, ) { renderObject.width = width; } } class _DropdownMenuBodyParentData extends ContainerBoxParentData {} class _RenderDropdownMenuBody extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { _RenderDropdownMenuBody({ double? width, }) : _width = width; double? get width => _width; double? _width; set width(double? value) { if (_width == value) { return; } _width = value; markNeedsLayout(); } @override void setupParentData(RenderBox child) { if (child.parentData is! _DropdownMenuBodyParentData) { child.parentData = _DropdownMenuBodyParentData(); } } @override void performLayout() { final BoxConstraints constraints = this.constraints; double maxWidth = 0.0; double? maxHeight; RenderBox? child = firstChild; final BoxConstraints innerConstraints = BoxConstraints( maxWidth: width ?? computeMaxIntrinsicWidth(constraints.maxWidth), maxHeight: computeMaxIntrinsicHeight(constraints.maxHeight), ); while (child != null) { if (child == firstChild) { child.layout(innerConstraints, parentUsesSize: true); maxHeight ??= child.size.height; final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; assert(child.parentData == childParentData); child = childParentData.nextSibling; continue; } child.layout(innerConstraints, parentUsesSize: true); final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; childParentData.offset = Offset.zero; maxWidth = math.max(maxWidth, child.size.width); maxHeight ??= child.size.height; assert(child.parentData == childParentData); child = childParentData.nextSibling; } assert(maxHeight != null); maxWidth = math.max(_kMinimumWidth, maxWidth); size = constraints.constrain(Size(width ?? maxWidth, maxHeight!)); } @override void paint(PaintingContext context, Offset offset) { final RenderBox? child = firstChild; if (child != null) { final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; context.paintChild(child, offset + childParentData.offset); } } @override Size computeDryLayout(BoxConstraints constraints) { final BoxConstraints constraints = this.constraints; double maxWidth = 0.0; double? maxHeight; RenderBox? child = firstChild; final BoxConstraints innerConstraints = BoxConstraints( maxWidth: width ?? computeMaxIntrinsicWidth(constraints.maxWidth), maxHeight: computeMaxIntrinsicHeight(constraints.maxHeight), ); while (child != null) { if (child == firstChild) { final Size childSize = child.getDryLayout(innerConstraints); maxHeight ??= childSize.height; final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; assert(child.parentData == childParentData); child = childParentData.nextSibling; continue; } final Size childSize = child.getDryLayout(innerConstraints); final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; childParentData.offset = Offset.zero; maxWidth = math.max(maxWidth, childSize.width); maxHeight ??= childSize.height; assert(child.parentData == childParentData); child = childParentData.nextSibling; } assert(maxHeight != null); maxWidth = math.max(_kMinimumWidth, maxWidth); return constraints.constrain(Size(width ?? maxWidth, maxHeight!)); } @override double computeMinIntrinsicWidth(double height) { RenderBox? child = firstChild; double width = 0; while (child != null) { if (child == firstChild) { final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; child = childParentData.nextSibling; continue; } final double maxIntrinsicWidth = child.getMinIntrinsicWidth(height); if (child == lastChild) { width += maxIntrinsicWidth; } if (child == childBefore(lastChild!)) { width += maxIntrinsicWidth; } width = math.max(width, maxIntrinsicWidth); final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; child = childParentData.nextSibling; } return math.max(width, _kMinimumWidth); } @override double computeMaxIntrinsicWidth(double height) { RenderBox? child = firstChild; double width = 0; while (child != null) { if (child == firstChild) { final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; child = childParentData.nextSibling; continue; } final double maxIntrinsicWidth = child.getMaxIntrinsicWidth(height); // Add the width of leading Icon. if (child == lastChild) { width += maxIntrinsicWidth; } // Add the width of trailing Icon. if (child == childBefore(lastChild!)) { width += maxIntrinsicWidth; } width = math.max(width, maxIntrinsicWidth); final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; child = childParentData.nextSibling; } return math.max(width, _kMinimumWidth); } @override double computeMinIntrinsicHeight(double height) { final RenderBox? child = firstChild; double width = 0; if (child != null) { width = math.max(width, child.getMinIntrinsicHeight(height)); } return width; } @override double computeMaxIntrinsicHeight(double height) { final RenderBox? child = firstChild; double width = 0; if (child != null) { width = math.max(width, child.getMaxIntrinsicHeight(height)); } return width; } @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { final RenderBox? child = firstChild; if (child != null) { final _DropdownMenuBodyParentData childParentData = child.parentData! as _DropdownMenuBodyParentData; final bool isHit = result.addWithPaintOffset( offset: childParentData.offset, position: position, hitTest: (BoxHitTestResult result, Offset transformed) { assert(transformed == position - childParentData.offset); return child.hitTest(result, position: transformed); }, ); if (isHit) { return true; } } return false; } } // Hand coded defaults. These will be updated once we have tokens/spec. class _DropdownMenuDefaultsM3 extends DropdownMenuThemeData { _DropdownMenuDefaultsM3(this.context); final BuildContext context; late final ThemeData _theme = Theme.of(context); @override TextStyle? get textStyle => _theme.textTheme.bodyLarge; @override MenuStyle get menuStyle { return const MenuStyle( minimumSize: WidgetStatePropertyAll(Size(_kMinimumWidth, 0.0)), maximumSize: WidgetStatePropertyAll(Size.infinite), visualDensity: VisualDensity.standard, ); } @override InputDecorationTheme get inputDecorationTheme { return const InputDecorationTheme(border: OutlineInputBorder()); } } ================================================ FILE: frontend/appflowy_flutter/lib/main.dart ================================================ import 'package:scaled_app/scaled_app.dart'; import 'startup/startup.dart'; Future main() async { ScaledWidgetsFlutterBinding.ensureInitialized( scaleFactor: (_) => 1.0, ); await runAppFlowy(); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/application/base/mobile_view_page_bloc.dart ================================================ import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'mobile_view_page_bloc.freezed.dart'; class MobileViewPageBloc extends Bloc { MobileViewPageBloc({ required this.viewId, }) : _viewListener = ViewListener(viewId: viewId), super(MobileViewPageState.initial()) { on( (event, emit) async { await event.when( initial: () async { _registerListeners(); final userProfilePB = await UserBackendService.getCurrentUserProfile() .fold((s) => s, (f) => null); final result = await ViewBackendService.getView(viewId); final isImmersiveMode = _isImmersiveMode(result.fold((s) => s, (f) => null)); emit( state.copyWith( isLoading: false, result: result, isImmersiveMode: isImmersiveMode, userProfilePB: userProfilePB, ), ); }, updateImmersionMode: (isImmersiveMode) { emit( state.copyWith( isImmersiveMode: isImmersiveMode, ), ); }, ); }, ); } final String viewId; final ViewListener _viewListener; @override Future close() { _viewListener.stop(); return super.close(); } void _registerListeners() { _viewListener.start( onViewUpdated: (view) { final isImmersiveMode = _isImmersiveMode(view); add(MobileViewPageEvent.updateImmersionMode(isImmersiveMode)); }, ); } // only the document page supports immersive mode (version 0.5.6) bool _isImmersiveMode(ViewPB? view) { if (view == null) { return false; } final cover = view.cover; if (cover == null || cover.type == PageStyleCoverImageType.none) { return false; } else if (view.layout == ViewLayoutPB.Document && !cover.isPresets) { // only support immersive mode for document layout return true; } return false; } } @freezed class MobileViewPageEvent with _$MobileViewPageEvent { const factory MobileViewPageEvent.initial() = Initial; const factory MobileViewPageEvent.updateImmersionMode(bool isImmersiveMode) = UpdateImmersionMode; } @freezed class MobileViewPageState with _$MobileViewPageState { const factory MobileViewPageState({ @Default(true) bool isLoading, @Default(null) FlowyResult? result, @Default(false) bool isImmersiveMode, @Default(null) UserProfilePB? userProfilePB, }) = _MobileViewPageState; factory MobileViewPageState.initial() => const MobileViewPageState(); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart'; import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; extension MobileRouter on BuildContext { Future pushView( ViewPB view, { Map? arguments, bool addInRecent = true, bool showMoreButton = true, String? fixedTitle, String? blockId, List? tabs, }) async { // set the current view before pushing the new view getIt().latestOpenView = view; unawaited(getIt().updateRecentViews([view.id], true)); final queryParameters = view.queryParameters(arguments); if (view.layout == ViewLayoutPB.Document) { queryParameters[MobileDocumentScreen.viewShowMoreButton] = showMoreButton.toString(); if (fixedTitle != null) { queryParameters[MobileDocumentScreen.viewFixedTitle] = fixedTitle; } if (blockId != null) { queryParameters[MobileDocumentScreen.viewBlockId] = blockId; } } if (tabs != null) { queryParameters[MobileDocumentScreen.viewSelectTabs] = tabs.join('-'); } final uri = Uri( path: view.routeName, queryParameters: queryParameters, ).toString(); await push(uri); } } extension on ViewPB { String get routeName { switch (layout) { case ViewLayoutPB.Document: return MobileDocumentScreen.routeName; case ViewLayoutPB.Grid: return MobileGridScreen.routeName; case ViewLayoutPB.Calendar: return MobileCalendarScreen.routeName; case ViewLayoutPB.Board: return MobileBoardScreen.routeName; case ViewLayoutPB.Chat: return MobileChatScreen.routeName; default: throw UnimplementedError('routeName for $this is not implemented'); } } Map queryParameters([Map? arguments]) { switch (layout) { case ViewLayoutPB.Document: return { MobileDocumentScreen.viewId: id, MobileDocumentScreen.viewTitle: name, }; case ViewLayoutPB.Grid: return { MobileGridScreen.viewId: id, MobileGridScreen.viewTitle: name, MobileGridScreen.viewArgs: jsonEncode(arguments), }; case ViewLayoutPB.Calendar: return { MobileCalendarScreen.viewId: id, MobileCalendarScreen.viewTitle: name, }; case ViewLayoutPB.Board: return { MobileBoardScreen.viewId: id, MobileBoardScreen.viewTitle: name, }; case ViewLayoutPB.Chat: return { MobileChatScreen.viewId: id, MobileChatScreen.viewTitle: name, }; default: throw UnimplementedError( 'queryParameters for $this is not implemented', ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/application/notification/notification_reminder_bloc.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:time/time.dart'; part 'notification_reminder_bloc.freezed.dart'; class NotificationReminderBloc extends Bloc { NotificationReminderBloc() : super(NotificationReminderState.initial()) { on((event, emit) async { await event.when( initial: (reminder, dateFormat, timeFormat) async { this.reminder = reminder; this.dateFormat = dateFormat; this.timeFormat = timeFormat; add(const NotificationReminderEvent.reset()); }, reset: () async { final scheduledAt = await _getScheduledAt( reminder, dateFormat, timeFormat, ); final view = await _getView(reminder); if (view == null) { emit( NotificationReminderState( scheduledAt: scheduledAt, pageTitle: '', reminderContent: '', isLocked: false, status: NotificationReminderStatus.error, ), ); return; } final layout = view.layout; if (layout.isDocumentView) { final node = await _getContent(reminder); if (node != null) { emit( NotificationReminderState( scheduledAt: scheduledAt, pageTitle: view.nameOrDefault, isLocked: view.isLocked, view: view, reminderContent: node.delta?.toPlainText() ?? '', nodes: [node], status: NotificationReminderStatus.loaded, blockId: reminder.meta[ReminderMetaKeys.blockId], ), ); } } else if (layout.isDatabaseView) { emit( NotificationReminderState( scheduledAt: scheduledAt, pageTitle: view.nameOrDefault, isLocked: view.isLocked, view: view, reminderContent: reminder.message, status: NotificationReminderStatus.loaded, ), ); } }, ); }); } late final ReminderPB reminder; late final UserDateFormatPB dateFormat; late final UserTimeFormatPB timeFormat; Future _getScheduledAt( ReminderPB reminder, UserDateFormatPB dateFormat, UserTimeFormatPB timeFormat, ) async { return _formatTimestamp( reminder.scheduledAt.toInt() * 1000, timeFormat: timeFormat, dateFormate: dateFormat, ); } Future _getView(ReminderPB reminder) async { return ViewBackendService.getView(reminder.objectId) .fold((s) => s, (_) => null); } Future _getContent(ReminderPB reminder) async { final blockId = reminder.meta[ReminderMetaKeys.blockId]; if (blockId == null) { return null; } final document = await DocumentService() .openDocument( documentId: reminder.objectId, ) .fold((s) => s.toDocument(), (_) => null); if (document == null) { return null; } final node = _searchById(document.root, blockId); if (node == null) { return null; } return node; } Node? _searchById(Node current, String id) { if (current.id == id) { return current; } if (current.children.isNotEmpty) { for (final child in current.children) { final node = _searchById(child, id); if (node != null) { return node; } } } return null; } String _formatTimestamp( int timestamp, { required UserDateFormatPB dateFormate, required UserTimeFormatPB timeFormat, }) { final now = DateTime.now(); final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp); final difference = now.difference(dateTime); final String date; if (difference.inMinutes < 1) { date = LocaleKeys.sideBar_justNow.tr(); } else if (difference.inHours < 1 && dateTime.isToday) { // Less than 1 hour date = LocaleKeys.sideBar_minutesAgo .tr(namedArgs: {'count': difference.inMinutes.toString()}); } else if (difference.inHours >= 1 && dateTime.isToday) { // in same day date = timeFormat.formatTime(dateTime); } else { date = dateFormate.formatDate(dateTime, false); } return date; } } @freezed class NotificationReminderEvent with _$NotificationReminderEvent { const factory NotificationReminderEvent.initial( ReminderPB reminder, UserDateFormatPB dateFormat, UserTimeFormatPB timeFormat, ) = _Initial; const factory NotificationReminderEvent.reset() = _Reset; } enum NotificationReminderStatus { initial, loading, loaded, error, } @freezed class NotificationReminderState with _$NotificationReminderState { const NotificationReminderState._(); const factory NotificationReminderState({ required String scheduledAt, required String pageTitle, required String reminderContent, required bool isLocked, @Default(NotificationReminderStatus.initial) NotificationReminderStatus status, @Default([]) List nodes, String? blockId, ViewPB? view, }) = _NotificationReminderState; factory NotificationReminderState.initial() => const NotificationReminderState( scheduledAt: '', pageTitle: '', reminderContent: '', isLocked: false, ); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'document_page_style_bloc.freezed.dart'; class DocumentPageStyleBloc extends Bloc { DocumentPageStyleBloc({ required this.view, }) : super(DocumentPageStyleState.initial()) { on( (event, emit) async { await event.when( initial: () async { try { if (view.id.isEmpty) { return; } Map layoutObject = {}; final data = await ViewBackendService.getView(view.id); data.onSuccess((s) { if (s.extra.isNotEmpty) { layoutObject = jsonDecode(s.extra); } }); final fontLayout = _getSelectedFontLayout(layoutObject); final lineHeightLayout = _getSelectedLineHeightLayout( layoutObject, ); final fontFamily = _getSelectedFontFamily(layoutObject); final cover = _getSelectedCover(layoutObject); final coverType = cover.$1; final coverValue = cover.$2; emit( state.copyWith( fontLayout: fontLayout, fontFamily: fontFamily, lineHeightLayout: lineHeightLayout, coverImage: PageStyleCover( type: coverType, value: coverValue, ), iconPadding: calculateIconPadding( fontLayout, lineHeightLayout, ), ), ); } catch (e) { Log.error('Failed to decode layout object: $e'); } }, updateFont: (fontLayout) async { emit( state.copyWith( fontLayout: fontLayout, iconPadding: calculateIconPadding( fontLayout, state.lineHeightLayout, ), ), ); unawaited(updateLayoutObject()); }, updateLineHeight: (lineHeightLayout) async { emit( state.copyWith( lineHeightLayout: lineHeightLayout, iconPadding: calculateIconPadding( state.fontLayout, lineHeightLayout, ), ), ); unawaited(updateLayoutObject()); }, updateFontFamily: (fontFamily) async { emit( state.copyWith( fontFamily: fontFamily, ), ); unawaited(updateLayoutObject()); }, updateCoverImage: (coverImage) async { emit( state.copyWith( coverImage: coverImage, ), ); unawaited(updateLayoutObject()); }, ); }, ); } final ViewPB view; final ViewBackendService viewBackendService = ViewBackendService(); Future updateLayoutObject() async { final layoutObject = decodeLayoutObject(); if (layoutObject != null) { await ViewBackendService.updateView( viewId: view.id, extra: layoutObject, ); } } String? decodeLayoutObject() { Map oldValue = {}; try { final extra = view.extra; oldValue = jsonDecode(extra); } catch (e) { Log.error('Failed to decode layout object: $e'); } final newValue = { ViewExtKeys.fontLayoutKey: state.fontLayout.toString(), ViewExtKeys.lineHeightLayoutKey: state.lineHeightLayout.toString(), ViewExtKeys.coverKey: { ViewExtKeys.coverTypeKey: state.coverImage.type.toString(), ViewExtKeys.coverValueKey: state.coverImage.value, }, ViewExtKeys.fontKey: state.fontFamily, }; final merged = mergeMaps(oldValue, newValue); return jsonEncode(merged); } // because the line height can not be calculated accurately, // we need to adjust the icon padding manually. double calculateIconPadding( PageStyleFontLayout fontLayout, PageStyleLineHeightLayout lineHeightLayout, ) { double padding = switch (fontLayout) { PageStyleFontLayout.small => 1.0, PageStyleFontLayout.normal => 1.0, PageStyleFontLayout.large => 4.0, }; switch (lineHeightLayout) { case PageStyleLineHeightLayout.small: padding -= 1.0; break; case PageStyleLineHeightLayout.normal: break; case PageStyleLineHeightLayout.large: padding += 3.0; break; } return max(0, padding); } double calculateIconScale( PageStyleFontLayout fontLayout, ) { return switch (fontLayout) { PageStyleFontLayout.small => 0.8, PageStyleFontLayout.normal => 1.0, PageStyleFontLayout.large => 1.2, }; } PageStyleFontLayout _getSelectedFontLayout(Map layoutObject) { final fontLayout = layoutObject[ViewExtKeys.fontLayoutKey] ?? PageStyleFontLayout.normal.toString(); return PageStyleFontLayout.values.firstWhere( (e) => e.toString() == fontLayout, ); } PageStyleLineHeightLayout _getSelectedLineHeightLayout(Map layoutObject) { final lineHeightLayout = layoutObject[ViewExtKeys.lineHeightLayoutKey] ?? PageStyleLineHeightLayout.normal.toString(); return PageStyleLineHeightLayout.values.firstWhere( (e) => e.toString() == lineHeightLayout, ); } String? _getSelectedFontFamily(Map layoutObject) { return layoutObject[ViewExtKeys.fontKey]; } (PageStyleCoverImageType, String colorValue) _getSelectedCover( Map layoutObject, ) { final cover = layoutObject[ViewExtKeys.coverKey] ?? {}; final coverType = cover[ViewExtKeys.coverTypeKey] ?? PageStyleCoverImageType.none.toString(); final coverValue = cover[ViewExtKeys.coverValueKey] ?? ''; return ( PageStyleCoverImageType.values.firstWhere( (e) => e.toString() == coverType, ), coverValue, ); } } @freezed class DocumentPageStyleEvent with _$DocumentPageStyleEvent { const factory DocumentPageStyleEvent.initial() = Initial; const factory DocumentPageStyleEvent.updateFont( PageStyleFontLayout fontLayout, ) = UpdateFontSize; const factory DocumentPageStyleEvent.updateLineHeight( PageStyleLineHeightLayout lineHeightLayout, ) = UpdateLineHeight; const factory DocumentPageStyleEvent.updateFontFamily( String? fontFamily, ) = UpdateFontFamily; const factory DocumentPageStyleEvent.updateCoverImage( PageStyleCover coverImage, ) = UpdateCoverImage; } @freezed class DocumentPageStyleState with _$DocumentPageStyleState { const factory DocumentPageStyleState({ @Default(PageStyleFontLayout.normal) PageStyleFontLayout fontLayout, @Default(PageStyleLineHeightLayout.normal) PageStyleLineHeightLayout lineHeightLayout, // the default font family is null, which means the system font @Default(null) String? fontFamily, @Default(2.0) double iconPadding, required PageStyleCover coverImage, }) = _DocumentPageStyleState; factory DocumentPageStyleState.initial() => DocumentPageStyleState( coverImage: PageStyleCover.none(), ); } enum PageStyleFontLayout { small, normal, large; @override String toString() { switch (this) { case PageStyleFontLayout.small: return 'small'; case PageStyleFontLayout.normal: return 'normal'; case PageStyleFontLayout.large: return 'large'; } } static PageStyleFontLayout fromString(String value) { return PageStyleFontLayout.values.firstWhereOrNull( (e) => e.toString() == value, ) ?? PageStyleFontLayout.normal; } double get fontSize { switch (this) { case PageStyleFontLayout.small: return 14.0; case PageStyleFontLayout.normal: return 16.0; case PageStyleFontLayout.large: return 18.0; } } List get headingFontSizes { switch (this) { case PageStyleFontLayout.small: return [22.0, 18.0, 16.0, 16.0, 16.0, 16.0]; case PageStyleFontLayout.normal: return [24.0, 20.0, 18.0, 18.0, 18.0, 18.0]; case PageStyleFontLayout.large: return [26.0, 22.0, 20.0, 20.0, 20.0, 20.0]; } } double get factor { switch (this) { case PageStyleFontLayout.small: return PageStyleFontLayout.small.fontSize / PageStyleFontLayout.normal.fontSize; case PageStyleFontLayout.normal: return 1.0; case PageStyleFontLayout.large: return PageStyleFontLayout.large.fontSize / PageStyleFontLayout.normal.fontSize; } } } enum PageStyleLineHeightLayout { small, normal, large; @override String toString() { switch (this) { case PageStyleLineHeightLayout.small: return 'small'; case PageStyleLineHeightLayout.normal: return 'normal'; case PageStyleLineHeightLayout.large: return 'large'; } } static PageStyleLineHeightLayout fromString(String value) { return PageStyleLineHeightLayout.values.firstWhereOrNull( (e) => e.toString() == value, ) ?? PageStyleLineHeightLayout.normal; } double get lineHeight { switch (this) { case PageStyleLineHeightLayout.small: return 1.4; case PageStyleLineHeightLayout.normal: return 1.5; case PageStyleLineHeightLayout.large: return 1.75; } } double get padding { switch (this) { case PageStyleLineHeightLayout.small: return 6.0; case PageStyleLineHeightLayout.normal: return 8.0; case PageStyleLineHeightLayout.large: return 8.0; } } List get headingPaddings { switch (this) { case PageStyleLineHeightLayout.small: return [26.0, 22.0, 20.0, 20.0, 20.0, 20.0]; case PageStyleLineHeightLayout.normal: return [30.0, 24.0, 22.0, 22.0, 22.0, 22.0]; case PageStyleLineHeightLayout.large: return [34.0, 28.0, 26.0, 26.0, 26.0, 26.0]; } } } // for the version above 0.5.5 enum PageStyleCoverImageType { none, // normal color pureColor, // gradient color gradientColor, // built in images builtInImage, // custom images, uploaded by the user customImage, // local image localImage, // unsplash images unsplashImage; @override String toString() { switch (this) { case PageStyleCoverImageType.none: return 'none'; case PageStyleCoverImageType.pureColor: return 'color'; case PageStyleCoverImageType.gradientColor: return 'gradient'; case PageStyleCoverImageType.builtInImage: return 'built_in'; case PageStyleCoverImageType.customImage: return 'custom'; case PageStyleCoverImageType.localImage: return 'local'; case PageStyleCoverImageType.unsplashImage: return 'unsplash'; } } static PageStyleCoverImageType fromString(String value) { return PageStyleCoverImageType.values.firstWhereOrNull( (e) => e.toString() == value, ) ?? PageStyleCoverImageType.none; } static String builtInImagePath(String value) { return 'assets/images/built_in_cover_images/m_cover_image_$value.png'; } } class PageStyleCover { const PageStyleCover({ required this.type, required this.value, }); factory PageStyleCover.none() => const PageStyleCover( type: PageStyleCoverImageType.none, value: '', ); final PageStyleCoverImageType type; // there're 4 types of values: // 1. pure color: enum value // 2. gradient color: enum value // 3. built-in image: the image name, read from the assets // 4. custom image or unsplash image: the image url final String value; bool get isPresets => isPureColor || isGradient || isBuiltInImage; bool get isPhoto => isCustomImage || isLocalImage; bool get isNone => type == PageStyleCoverImageType.none; bool get isPureColor => type == PageStyleCoverImageType.pureColor; bool get isGradient => type == PageStyleCoverImageType.gradientColor; bool get isBuiltInImage => type == PageStyleCoverImageType.builtInImage; bool get isCustomImage => type == PageStyleCoverImageType.customImage; bool get isUnsplashImage => type == PageStyleCoverImageType.unsplashImage; bool get isLocalImage => type == PageStyleCoverImageType.localImage; @override bool operator ==(Object other) { if (other is! PageStyleCover) { return false; } return type == other.type && value == other.value; } @override int get hashCode => Object.hash(type, value); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart ================================================ import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_listener.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; part 'recent_view_bloc.freezed.dart'; class RecentViewBloc extends Bloc { RecentViewBloc({ required this.view, }) : _documentListener = DocumentListener(id: view.id), _viewListener = ViewListener(viewId: view.id), super(RecentViewState.initial()) { on( (event, emit) async { await event.when( initial: () async { _documentListener.start( onDocEventUpdate: (docEvent) async { if (state.coverTypeV2 != null) { return; } final (coverType, coverValue) = await getCoverV1(); add( RecentViewEvent.updateCover( coverType, null, coverValue, ), ); }, ); _viewListener.start( onViewUpdated: (view) { add( RecentViewEvent.updateNameOrIcon( view.name, view.icon.toEmojiIconData(), ), ); if (view.extra.isNotEmpty) { final cover = view.cover; add( RecentViewEvent.updateCover( CoverType.none, cover?.type, cover?.value, ), ); } }, ); // only document supports the cover if (view.layout != ViewLayoutPB.Document) { emit( state.copyWith( name: view.name, icon: view.icon.toEmojiIconData(), ), ); } final cover = getCoverV2(); if (cover != null) { emit( state.copyWith( name: view.name, icon: view.icon.toEmojiIconData(), coverTypeV2: cover.type, coverValue: cover.value, ), ); } else { final (coverTypeV1, coverValue) = await getCoverV1(); emit( state.copyWith( name: view.name, icon: view.icon.toEmojiIconData(), coverTypeV1: coverTypeV1, coverValue: coverValue, ), ); } }, updateNameOrIcon: (name, icon) { emit( state.copyWith( name: name, icon: icon, ), ); }, updateCover: (coverTypeV1, coverTypeV2, coverValue) { emit( state.copyWith( coverTypeV1: coverTypeV1, coverTypeV2: coverTypeV2, coverValue: coverValue, ), ); }, ); }, ); } final ViewPB view; final DocumentListener _documentListener; final ViewListener _viewListener; PageStyleCover? getCoverV2() { return view.cover; } // for the version under 0.5.5 Future<(CoverType, String?)> getCoverV1() async { return (CoverType.none, null); } @override Future close() async { await _documentListener.stop(); await _viewListener.stop(); return super.close(); } } @freezed class RecentViewEvent with _$RecentViewEvent { const factory RecentViewEvent.initial() = Initial; const factory RecentViewEvent.updateCover( CoverType coverTypeV1, // for the version under 0.5.5, including 0.5.5 PageStyleCoverImageType? coverTypeV2, // for the version above 0.5.5 String? coverValue, ) = UpdateCover; const factory RecentViewEvent.updateNameOrIcon( String name, EmojiIconData icon, ) = UpdateNameOrIcon; } @freezed class RecentViewState with _$RecentViewState { const factory RecentViewState({ required String name, required EmojiIconData icon, @Default(CoverType.none) CoverType coverTypeV1, PageStyleCoverImageType? coverTypeV2, @Default(null) String? coverValue, }) = _RecentViewState; factory RecentViewState.initial() => RecentViewState(name: '', icon: EmojiIconData.none()); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'user_profile_bloc.freezed.dart'; class UserProfileBloc extends Bloc { UserProfileBloc() : super(const _Initial()) { on((event, emit) async { await event.when( started: () async => _initialize(emit), ); }); } Future _initialize(Emitter emit) async { emit(const UserProfileState.loading()); final latestOrFailure = await FolderEventGetCurrentWorkspaceSetting().send(); final userOrFailure = await getIt().getUser(); final latest = latestOrFailure.fold( (latestPB) => latestPB, (error) => null, ); final userProfile = userOrFailure.fold( (userProfilePB) => userProfilePB, (error) => null, ); if (latest == null || userProfile == null) { return emit(const UserProfileState.workspaceFailure()); } emit( UserProfileState.success( workspaceSettings: latest, userProfile: userProfile, ), ); } } @freezed class UserProfileEvent with _$UserProfileEvent { const factory UserProfileEvent.started() = _Started; } @freezed class UserProfileState with _$UserProfileState { const factory UserProfileState.initial() = _Initial; const factory UserProfileState.loading() = _Loading; const factory UserProfileState.workspaceFailure() = _WorkspaceFailure; const factory UserProfileState.success({ required WorkspaceLatestPB workspaceSettings, required UserProfilePB userProfile, }) = _Success; } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/base/animated_gesture.dart ================================================ import 'package:appflowy/shared/feedback_gesture_detector.dart'; import 'package:flutter/material.dart'; class AnimatedGestureDetector extends StatefulWidget { const AnimatedGestureDetector({ super.key, this.scaleFactor = 0.98, this.feedback = true, this.duration = const Duration(milliseconds: 100), this.alignment = Alignment.center, this.behavior = HitTestBehavior.opaque, this.onTapUp, required this.child, }); final Widget child; final double scaleFactor; final Duration duration; final Alignment alignment; final bool feedback; final HitTestBehavior behavior; final VoidCallback? onTapUp; @override State createState() => _AnimatedGestureDetectorState(); } class _AnimatedGestureDetectorState extends State { double scale = 1.0; @override Widget build(BuildContext context) { return GestureDetector( behavior: widget.behavior, onTapUp: (details) { setState(() => scale = 1.0); HapticFeedbackType.light.call(); widget.onTapUp?.call(); }, onTapDown: (details) { setState(() => scale = widget.scaleFactor); }, child: AnimatedScale( scale: scale, alignment: widget.alignment, duration: widget.duration, child: widget.child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar.dart ================================================ import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; enum FlowyAppBarLeadingType { back, close, cancel; Widget getWidget(VoidCallback? onTap) { switch (this) { case FlowyAppBarLeadingType.back: return AppBarImmersiveBackButton(onTap: onTap); case FlowyAppBarLeadingType.close: return AppBarCloseButton(onTap: onTap); case FlowyAppBarLeadingType.cancel: return AppBarCancelButton(onTap: onTap); } } double? get width { switch (this) { case FlowyAppBarLeadingType.back: return 40.0; case FlowyAppBarLeadingType.close: return 40.0; case FlowyAppBarLeadingType.cancel: return 120; } } } class FlowyAppBar extends AppBar { FlowyAppBar({ super.key, super.actions, Widget? title, String? titleText, FlowyAppBarLeadingType leadingType = FlowyAppBarLeadingType.back, double? leadingWidth, Widget? leading, super.centerTitle, VoidCallback? onTapLeading, bool showDivider = true, super.backgroundColor, }) : super( title: title ?? FlowyText( titleText ?? '', fontSize: 15.0, fontWeight: FontWeight.w500, ), titleSpacing: 0, elevation: 0, leading: leading ?? leadingType.getWidget(onTapLeading), leadingWidth: leadingWidth ?? leadingType.width, toolbarHeight: 44.0, bottom: showDivider ? const PreferredSize( preferredSize: Size.fromHeight(0.5), child: Divider( height: 0.5, ), ) : null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar_actions.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class AppBarBackButton extends StatelessWidget { const AppBarBackButton({ super.key, this.onTap, this.padding, }); final VoidCallback? onTap; final EdgeInsetsGeometry? padding; @override Widget build(BuildContext context) { return AppBarButton( onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), padding: padding, child: const FlowySvg( FlowySvgs.m_app_bar_back_s, ), ); } } class AppBarImmersiveBackButton extends StatelessWidget { const AppBarImmersiveBackButton({ super.key, this.onTap, }); final VoidCallback? onTap; @override Widget build(BuildContext context) { return AppBarButton( onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), padding: const EdgeInsets.only( left: 12.0, top: 8.0, bottom: 8.0, right: 4.0, ), child: const FlowySvg( FlowySvgs.m_app_bar_back_s, ), ); } } class AppBarCloseButton extends StatelessWidget { const AppBarCloseButton({ super.key, this.onTap, }); final VoidCallback? onTap; @override Widget build(BuildContext context) { return AppBarButton( onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), child: const FlowySvg( FlowySvgs.m_app_bar_close_s, ), ); } } class AppBarCancelButton extends StatelessWidget { const AppBarCancelButton({ super.key, this.onTap, }); final VoidCallback? onTap; @override Widget build(BuildContext context) { return AppBarButton( onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), child: FlowyText( LocaleKeys.button_cancel.tr(), overflow: TextOverflow.ellipsis, ), ); } } class AppBarDoneButton extends StatelessWidget { const AppBarDoneButton({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { return AppBarButton( onTap: (_) => onTap(), padding: const EdgeInsets.all(12), child: FlowyText( LocaleKeys.button_done.tr(), color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w500, textAlign: TextAlign.right, ), ); } } class AppBarSaveButton extends StatelessWidget { const AppBarSaveButton({ super.key, required this.onTap, this.enable = true, this.padding = const EdgeInsets.all(12), }); final VoidCallback onTap; final bool enable; final EdgeInsetsGeometry padding; @override Widget build(BuildContext context) { return AppBarButton( onTap: (_) { if (enable) { onTap(); } }, padding: padding, child: FlowyText( LocaleKeys.button_save.tr(), color: enable ? Theme.of(context).colorScheme.primary : Theme.of(context).disabledColor, fontWeight: FontWeight.w500, textAlign: TextAlign.right, ), ); } } class AppBarFilledDoneButton extends StatelessWidget { const AppBarFilledDoneButton({super.key, required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), child: TextButton( style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), elevation: 0, visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap, enableFeedback: true, backgroundColor: Theme.of(context).primaryColor, ), onPressed: onTap, child: FlowyText.medium( LocaleKeys.button_done.tr(), fontSize: 16, color: Theme.of(context).colorScheme.onPrimary, overflow: TextOverflow.ellipsis, ), ), ); } } class AppBarMoreButton extends StatelessWidget { const AppBarMoreButton({ super.key, required this.onTap, }); final void Function(BuildContext context) onTap; @override Widget build(BuildContext context) { return AppBarButton( padding: const EdgeInsets.all(12), onTap: onTap, child: const FlowySvg(FlowySvgs.three_dots_s), ); } } class AppBarButton extends StatelessWidget { const AppBarButton({ super.key, required this.onTap, required this.child, this.padding, }); final void Function(BuildContext context) onTap; final Widget child; final EdgeInsetsGeometry? padding; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => onTap(context), child: Padding( padding: padding ?? const EdgeInsets.all(12), child: child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/base/flowy_search_text_field.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class FlowySearchTextField extends StatelessWidget { const FlowySearchTextField({ super.key, this.hintText, this.controller, this.onChanged, this.onSubmitted, }); final String? hintText; final TextEditingController? controller; final ValueChanged? onChanged; final ValueChanged? onSubmitted; @override Widget build(BuildContext context) { return SizedBox( height: 44.0, child: CupertinoSearchTextField( controller: controller, onChanged: onChanged, onSubmitted: onSubmitted, placeholder: hintText, prefixIcon: const FlowySvg(FlowySvgs.m_search_m), prefixInsets: const EdgeInsets.only(left: 16.0, right: 2.0), suffixIcon: const Icon(Icons.close), suffixInsets: const EdgeInsets.only(right: 16.0), placeholderStyle: Theme.of(context).textTheme.titleSmall?.copyWith( color: Theme.of(context).hintColor, fontWeight: FontWeight.w400, fontSize: 14.0, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/workspace/data/repositories/rust_workspace_repository_impl.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileViewPage extends StatefulWidget { const MobileViewPage({ super.key, required this.id, required this.viewLayout, this.title, this.arguments, this.fixedTitle, this.showMoreButton = true, this.blockId, this.bodyPaddingTop = 0.0, this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); /// view id final String id; final ViewLayoutPB viewLayout; final String? title; final Map? arguments; final bool showMoreButton; final String? blockId; final double bodyPaddingTop; final List tabs; // only used in row page final String? fixedTitle; @override State createState() => _MobileViewPageState(); } class _MobileViewPageState extends State { // used to determine if the user has scrolled down and show the app bar in immersive mode ScrollNotificationObserverState? _scrollNotificationObserver; // control the app bar opacity when in immersive mode final ValueNotifier _appBarOpacity = ValueNotifier(1.0); @override void initState() { super.initState(); getIt().add(const ReminderEvent.started()); } @override void dispose() { _appBarOpacity.dispose(); // there's no need to remove the listener, because the observer will be disposed when the widget is unmounted. // inside the observer, the listener will be removed automatically. // _scrollNotificationObserver?.removeListener(_onScrollNotification); _scrollNotificationObserver = null; super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (_) => MobileViewPageBloc(viewId: widget.id) ..add(const MobileViewPageEvent.initial()), child: BlocBuilder( builder: (context, state) { final view = state.result?.fold((s) => s, (f) => null); final body = _buildBody(context, state); if (view == null) { return SizedBox.shrink(); } return MultiBlocProvider( providers: [ BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), ), BlocProvider( create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), ), BlocProvider.value( value: getIt(), ), BlocProvider( create: (_) => ShareBloc(view: view)..add(const ShareEvent.initial()), ), if (state.userProfilePB != null) BlocProvider( create: (_) => UserWorkspaceBloc( userProfile: state.userProfilePB!, repository: RustWorkspaceRepositoryImpl( userId: state.userProfilePB!.id, ), )..add(UserWorkspaceEvent.initialize()), ), if (view.layout.isDocumentView) BlocProvider( create: (_) => DocumentPageStyleBloc(view: view) ..add(const DocumentPageStyleEvent.initial()), ), if (view.layout.isDocumentView || view.layout.isDatabaseView) BlocProvider( create: (_) => PageAccessLevelBloc(view: view) ..add(const PageAccessLevelEvent.initial()), ), ], child: Builder( builder: (context) { final view = context.watch().state.view; return _buildApp(context, view, body); }, ), ); }, ), ); } Widget _buildApp( BuildContext context, ViewPB? view, Widget child, ) { final isDocument = view?.layout.isDocumentView ?? false; final title = _buildTitle(context, view); final actions = _buildAppBarActions(context, view); final appBar = isDocument ? MobileViewPageImmersiveAppBar( preferredSize: Size( double.infinity, AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight, ), title: title, appBarOpacity: _appBarOpacity, actions: actions, view: view, ) : FlowyAppBar(title: title, actions: actions); final body = isDocument ? Builder( builder: (context) { _rebuildScrollNotificationObserver(context); return child; }, ) : SafeArea(child: child); return Scaffold( extendBodyBehindAppBar: isDocument, appBar: appBar, body: Padding( padding: EdgeInsets.only(top: widget.bodyPaddingTop), child: body, ), ); } Widget _buildBody(BuildContext context, MobileViewPageState state) { if (state.isLoading) { return const Center( child: CircularProgressIndicator(), ); } final result = state.result; if (result == null) { return FlowyMobileStateContainer.error( emoji: '😔', title: LocaleKeys.error_weAreSorry.tr(), description: LocaleKeys.error_loadingViewError.tr(), errorMsg: '', ); } return result.fold( (view) { final plugin = view.plugin(arguments: widget.arguments ?? const {}) ..init(); return plugin.widgetBuilder.buildWidget( shrinkWrap: false, context: PluginContext(userProfile: state.userProfilePB), data: { MobileDocumentScreen.viewFixedTitle: widget.fixedTitle, MobileDocumentScreen.viewBlockId: widget.blockId, MobileDocumentScreen.viewSelectTabs: widget.tabs, }, ); }, (error) { return FlowyMobileStateContainer.error( emoji: '😔', title: LocaleKeys.error_weAreSorry.tr(), description: LocaleKeys.error_loadingViewError.tr(), errorMsg: error.toString(), ); }, ); } // Document: // - [ collaborators, sync_indicator, layout_button, more_button] // Database: // - [ sync_indicator, more_button] List _buildAppBarActions(BuildContext context, ViewPB? view) { if (view == null) { return []; } final isImmersiveMode = context.read().state.isImmersiveMode; final isLocked = context.read()?.state.isLocked ?? false; final accessLevel = context.read().state.accessLevel; final actions = []; if (FeatureFlag.syncDocument.isOn) { // only document supports displaying collaborators. if (view.layout.isDocumentView) { actions.addAll([ DocumentCollaborators( width: 60, height: 44, fontSize: 14, padding: const EdgeInsets.symmetric(vertical: 8), view: view, ), const HSpace(12.0), ]); } } if (view.layout.isDocumentView && !isLocked) { actions.addAll([ MobileViewPageLayoutButton( view: view, isImmersiveMode: isImmersiveMode, appBarOpacity: _appBarOpacity, tabs: widget.tabs, ), ]); } if (widget.showMoreButton && accessLevel != ShareAccessLevel.readOnly) { actions.addAll([ MobileViewPageMoreButton( view: view, isImmersiveMode: isImmersiveMode, appBarOpacity: _appBarOpacity, ), ]); } else { actions.addAll([ const HSpace(18.0), ]); } return actions; } Widget _buildTitle(BuildContext context, ViewPB? view) { final icon = view?.icon; return ValueListenableBuilder( valueListenable: _appBarOpacity, builder: (_, value, child) { if (value < 0.99) { return Padding( padding: const EdgeInsets.only(left: 6.0), child: _buildLockStatus(context, view), ); } final name = widget.fixedTitle ?? view?.nameOrDefault ?? widget.title ?? ''; return Opacity( opacity: value, child: Row( children: [ if (icon != null && icon.value.isNotEmpty) ...[ RawEmojiIconWidget( emoji: icon.toEmojiIconData(), emojiSize: 15, ), const HSpace(4), ], Flexible( child: FlowyText.medium( name, fontSize: 15.0, overflow: TextOverflow.ellipsis, figmaLineHeight: 18.0, ), ), const HSpace(4.0), _buildLockStatusIcon(context, view), ], ), ); }, ); } Widget _buildLockStatus(BuildContext context, ViewPB? view) { if (view == null || view.layout == ViewLayoutPB.Chat) { return const SizedBox.shrink(); } return BlocConsumer( listenWhen: (previous, current) => previous.isLoadingLockStatus == current.isLoadingLockStatus && current.isLoadingLockStatus == false, listener: (context, state) { if (state.isLocked) { showToastNotification( message: LocaleKeys.lockPage_pageLockedToast.tr(), ); EditorNotification.exitEditing().post(); } }, builder: (context, state) { if (state.isLocked) { return LockedPageStatus(); } else if (!state.isLocked && state.lockCounter > 0) { return ReLockedPageStatus(); } return const SizedBox.shrink(); }, ); } Widget _buildLockStatusIcon(BuildContext context, ViewPB? view) { if (view == null || view.layout == ViewLayoutPB.Chat) { return const SizedBox.shrink(); } return BlocConsumer( listenWhen: (previous, current) => previous.isLoadingLockStatus == current.isLoadingLockStatus && current.isLoadingLockStatus == false, listener: (context, state) { if (state.isLocked) { showToastNotification( message: LocaleKeys.lockPage_pageLockedToast.tr(), ); } }, builder: (context, state) { if (state.isLocked) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { context.read().add( const PageAccessLevelEvent.unlock(), ); }, child: Padding( padding: const EdgeInsets.only( top: 4.0, right: 8, bottom: 4.0, ), child: FlowySvg( FlowySvgs.lock_page_fill_s, blendMode: null, ), ), ); } else if (!state.isLocked && state.lockCounter > 0) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { context.read().add( const PageAccessLevelEvent.lock(), ); }, child: Padding( padding: const EdgeInsets.only( top: 4.0, right: 8, bottom: 4.0, ), child: FlowySvg( FlowySvgs.unlock_page_s, color: Color(0xFF8F959E), blendMode: null, ), ), ); } return const SizedBox.shrink(); }, ); } void _rebuildScrollNotificationObserver(BuildContext context) { _scrollNotificationObserver?.removeListener(_onScrollNotification); _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); _scrollNotificationObserver?.addListener(_onScrollNotification); } // immersive mode related // auto show or hide the app bar based on the scroll position void _onScrollNotification(ScrollNotification notification) { if (_scrollNotificationObserver == null) { return; } if (notification is ScrollUpdateNotification && defaultScrollNotificationPredicate(notification)) { final ScrollMetrics metrics = notification.metrics; double height = MediaQuery.of(context).padding.top + widget.bodyPaddingTop; if (defaultTargetPlatform == TargetPlatform.android) { height += AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight; } final progress = (metrics.pixels / height).clamp(0.0, 1.0); // reduce the sensitivity of the app bar opacity change if ((progress - _appBarOpacity.value).abs() >= 0.1 || progress == 0 || progress == 1.0) { _appBarOpacity.value = progress; } } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/base/option_color_list.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; class OptionColorList extends StatelessWidget { const OptionColorList({ super.key, this.selectedColor, required this.onSelectedColor, }); final SelectOptionColorPB? selectedColor; final void Function(SelectOptionColorPB color) onSelectedColor; @override Widget build(BuildContext context) { return GridView.count( crossAxisCount: 6, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, children: SelectOptionColorPB.values.map( (colorPB) { final color = colorPB.toColor(context); final isSelected = selectedColor?.value == colorPB.value; return GestureDetector( onTap: () => onSelectedColor(colorPB), child: Container( margin: const EdgeInsets.all( 8.0, ), decoration: BoxDecoration( color: color, borderRadius: Corners.s12Border, border: Border.all( width: isSelected ? 2.0 : 1.0, color: isSelected ? const Color(0xff00C6F1) : Theme.of(context).dividerColor, ), ), alignment: Alignment.center, child: isSelected ? const FlowySvg( FlowySvgs.m_blue_check_s, size: Size.square(28.0), blendMode: null, ) : null, ), ); }, ).toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class TypeOptionMenuItemValue { const TypeOptionMenuItemValue({ required this.value, required this.icon, required this.text, required this.backgroundColor, required this.onTap, this.iconPadding, }); final T value; final FlowySvgData icon; final String text; final Color backgroundColor; final EdgeInsets? iconPadding; final void Function(BuildContext context, T value) onTap; } class TypeOptionMenu extends StatelessWidget { const TypeOptionMenu({ super.key, required this.values, this.width = 98, this.iconWidth = 72, this.scaleFactor = 1.0, this.maxAxisSpacing = 18, this.crossAxisCount = 3, }); final List> values; final double iconWidth; final double width; final double scaleFactor; final double maxAxisSpacing; final int crossAxisCount; @override Widget build(BuildContext context) { return TypeOptionGridView( crossAxisCount: crossAxisCount, mainAxisSpacing: maxAxisSpacing * scaleFactor, itemWidth: width * scaleFactor, children: values .map( (value) => TypeOptionMenuItem( value: value, width: width, iconWidth: iconWidth, scaleFactor: scaleFactor, iconPadding: value.iconPadding, ), ) .toList(), ); } } class TypeOptionMenuItem extends StatelessWidget { const TypeOptionMenuItem({ super.key, required this.value, this.width = 94, this.iconWidth = 72, this.scaleFactor = 1.0, this.iconPadding, }); final TypeOptionMenuItemValue value; final double iconWidth; final double width; final double scaleFactor; final EdgeInsets? iconPadding; double get scaledIconWidth => iconWidth * scaleFactor; double get scaledWidth => width * scaleFactor; @override Widget build(BuildContext context) { return GestureDetector( onTap: () => value.onTap(context, value.value), child: Column( children: [ Container( height: scaledIconWidth, width: scaledIconWidth, decoration: ShapeDecoration( color: value.backgroundColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24 * scaleFactor), ), ), padding: EdgeInsets.all(21 * scaleFactor) + (iconPadding ?? EdgeInsets.zero), child: FlowySvg( value.icon, ), ), const VSpace(6), ConstrainedBox( constraints: BoxConstraints( maxWidth: scaledWidth, ), child: FlowyText( value.text, fontSize: 14.0, maxLines: 2, lineHeight: 1.0, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), ), ], ), ); } } class TypeOptionGridView extends StatelessWidget { const TypeOptionGridView({ super.key, required this.children, required this.crossAxisCount, required this.mainAxisSpacing, required this.itemWidth, }); final List children; final int crossAxisCount; final double mainAxisSpacing; final double itemWidth; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < children.length; i += crossAxisCount) Padding( padding: EdgeInsets.only(bottom: mainAxisSpacing), child: Row( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ for (var j = 0; j < crossAxisCount; j++) i + j < children.length ? SizedBox( width: itemWidth, child: children[i + j], ) : HSpace(itemWidth), ], ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/base/view_page/more_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MobileViewPageImmersiveAppBar extends StatelessWidget implements PreferredSizeWidget { const MobileViewPageImmersiveAppBar({ super.key, required this.preferredSize, required this.appBarOpacity, required this.title, required this.actions, required this.view, }); final ValueListenable appBarOpacity; final Widget title; final List actions; final ViewPB? view; @override final Size preferredSize; @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: appBarOpacity, builder: (_, opacity, __) => FlowyAppBar( backgroundColor: AppBarTheme.of(context).backgroundColor?.withValues(alpha: opacity), showDivider: false, title: _buildTitle(context, opacity: opacity), leadingWidth: 44, leading: Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 12.0), child: _buildAppBarBackButton(context), ), actions: actions, ), ); } Widget _buildTitle( BuildContext context, { required double opacity, }) { return title; } Widget _buildAppBarBackButton(BuildContext context) { return AppBarButton( padding: EdgeInsets.zero, onTap: (context) => context.pop(), child: _ImmersiveAppBarButton( icon: FlowySvgs.m_app_bar_back_s, dimension: 30.0, iconPadding: 3.0, isImmersiveMode: context.read().state.isImmersiveMode, appBarOpacity: appBarOpacity, ), ); } } class MobileViewPageMoreButton extends StatelessWidget { const MobileViewPageMoreButton({ super.key, required this.view, required this.isImmersiveMode, required this.appBarOpacity, }); final ViewPB view; final bool isImmersiveMode; final ValueListenable appBarOpacity; @override Widget build(BuildContext context) { return AppBarButton( padding: const EdgeInsets.only(left: 8, right: 16), onTap: (context) { EditorNotification.exitEditing().post(); showMobileBottomSheet( context, showDragHandle: true, showDivider: false, backgroundColor: AFThemeExtension.of(context).background, builder: (_) => MultiBlocProvider( providers: [ BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), ], child: MobileViewPageMoreBottomSheet(view: view), ), ); }, child: _ImmersiveAppBarButton( icon: FlowySvgs.m_app_bar_more_s, dimension: 30.0, iconPadding: 3.0, isImmersiveMode: isImmersiveMode, appBarOpacity: appBarOpacity, ), ); } } class MobileViewPageLayoutButton extends StatelessWidget { const MobileViewPageLayoutButton({ super.key, required this.view, required this.isImmersiveMode, required this.appBarOpacity, required this.tabs, }); final ViewPB view; final List tabs; final bool isImmersiveMode; final ValueListenable appBarOpacity; @override Widget build(BuildContext context) { // only display the layout button if the view is a document if (view.layout != ViewLayoutPB.Document) { return const SizedBox.shrink(); } return AppBarButton( padding: const EdgeInsets.symmetric(vertical: 2.0), onTap: (context) { EditorNotification.exitEditing().post(); showMobileBottomSheet( context, showDragHandle: true, showDivider: false, showDoneButton: true, showHeader: true, title: LocaleKeys.pageStyle_title.tr(), backgroundColor: AFThemeExtension.of(context).background, builder: (_) => MultiBlocProvider( providers: [ BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), ], child: PageStyleBottomSheet( view: context.read().state.view, tabs: tabs, ), ), ); }, child: _ImmersiveAppBarButton( icon: FlowySvgs.m_layout_s, dimension: 30.0, iconPadding: 3.0, isImmersiveMode: isImmersiveMode, appBarOpacity: appBarOpacity, ), ); } } class _ImmersiveAppBarButton extends StatelessWidget { const _ImmersiveAppBarButton({ required this.icon, required this.dimension, required this.iconPadding, required this.isImmersiveMode, required this.appBarOpacity, }); final FlowySvgData icon; final double dimension; final double iconPadding; final bool isImmersiveMode; final ValueListenable appBarOpacity; @override Widget build(BuildContext context) { assert( dimension > 0.0 && dimension <= kToolbarHeight, 'dimension must be greater than 0, and less than or equal to kToolbarHeight', ); // if the immersive mode is on, the icon should be white and add a black background // also, the icon opacity will change based on the app bar opacity return UnconstrainedBox( child: SizedBox.square( dimension: dimension, child: ValueListenableBuilder( valueListenable: appBarOpacity, builder: (context, appBarOpacity, child) { Color? color; // if there's no cover or the cover is not immersive, // make sure the app bar is always visible if (!isImmersiveMode) { color = null; } else if (appBarOpacity < 0.99) { color = Colors.white; } Widget child = Container( margin: EdgeInsets.all(iconPadding), child: FlowySvg(icon, color: color), ); if (isImmersiveMode && appBarOpacity <= 0.99) { child = DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(dimension / 2.0), color: Colors.black.withValues(alpha: 0.2), ), child: child, ); } return child; }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart ================================================ import 'dart:async'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/plugins/shared/share/publish_name_generator.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/shared/error_code/error_code_map.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; class MobileViewPageMoreBottomSheet extends StatelessWidget { const MobileViewPageMoreBottomSheet({super.key, required this.view}); final ViewPB view; @override Widget build(BuildContext context) { return BlocListener( listener: (context, state) => _showToast(context, state), child: BlocListener( listener: (context, state) { if (state.successOrFailure.isSuccess && state.isDeleted) { context.go('/home'); } }, child: ViewPageBottomSheet( view: view, onAction: (action, {arguments}) async => _onAction( context, action, arguments, ), onRename: (name) { _onRename( context, name, ); context.pop(); }, ), ), ); } Future _onAction( BuildContext context, MobileViewBottomSheetBodyAction action, Map? arguments, ) async { switch (action) { case MobileViewBottomSheetBodyAction.duplicate: _duplicate(context); break; case MobileViewBottomSheetBodyAction.delete: context.read().add(const ViewEvent.delete()); Navigator.of(context).pop(); break; case MobileViewBottomSheetBodyAction.addToFavorites: _addFavorite(context); break; case MobileViewBottomSheetBodyAction.removeFromFavorites: _removeFavorite(context); break; case MobileViewBottomSheetBodyAction.undo: EditorNotification.undo().post(); context.pop(); break; case MobileViewBottomSheetBodyAction.redo: EditorNotification.redo().post(); context.pop(); break; case MobileViewBottomSheetBodyAction.helpCenter: // unimplemented context.pop(); break; case MobileViewBottomSheetBodyAction.publish: await _publish(context); if (context.mounted) { context.pop(); } break; case MobileViewBottomSheetBodyAction.unpublish: _unpublish(context); context.pop(); break; case MobileViewBottomSheetBodyAction.copyPublishLink: _copyPublishLink(context); context.pop(); break; case MobileViewBottomSheetBodyAction.visitSite: _visitPublishedSite(context); context.pop(); break; case MobileViewBottomSheetBodyAction.copyShareLink: _copyShareLink(context); context.pop(); break; case MobileViewBottomSheetBodyAction.updatePathName: _updatePathName(context); case MobileViewBottomSheetBodyAction.lockPage: final isLocked = arguments?[MobileViewBottomSheetBodyActionArguments.isLockedKey] ?? false; await _lockPage(context, isLocked: isLocked); // context.pop(); break; case MobileViewBottomSheetBodyAction.rename: // no need to implement, rename is handled by the onRename callback. throw UnimplementedError(); } } Future _lockPage( BuildContext context, { required bool isLocked, }) async { if (isLocked) { context .read() .add(const PageAccessLevelEvent.lock()); } else { context .read() .add(const PageAccessLevelEvent.unlock()); } } Future _publish(BuildContext context) async { final id = context.read().view.id; final lastPublishName = context.read().state.pathName; final publishName = lastPublishName.orDefault( await generatePublishName( id, view.name, ), ); if (context.mounted) { context.read().add( ShareEvent.publish( '', publishName, [view.id], ), ); } } void _duplicate(BuildContext context) { context.read().add(const ViewEvent.duplicate()); context.pop(); showToastNotification( message: LocaleKeys.button_duplicateSuccessfully.tr(), ); } void _addFavorite(BuildContext context) { _toggleFavorite(context); showToastNotification( message: LocaleKeys.button_favoriteSuccessfully.tr(), ); } void _removeFavorite(BuildContext context) { _toggleFavorite(context); showToastNotification( message: LocaleKeys.button_unfavoriteSuccessfully.tr(), ); } void _toggleFavorite(BuildContext context) { context.read().add(FavoriteEvent.toggle(view)); context.pop(); } void _unpublish(BuildContext context) { context.read().add(const ShareEvent.unPublish()); } void _copyPublishLink(BuildContext context) { final url = context.read().state.url; if (url.isNotEmpty) { unawaited( getIt().setData( ClipboardServiceData(plainText: url), ), ); showToastNotification( message: LocaleKeys.message_copy_success.tr(), ); } } void _visitPublishedSite(BuildContext context) { final url = context.read().state.url; if (url.isNotEmpty) { unawaited( afLaunchUri( Uri.parse(url), mode: LaunchMode.externalApplication, ), ); } } void _copyShareLink(BuildContext context) { final workspaceId = context.read().state.workspaceId; final viewId = context.read().state.viewId; final url = ShareConstants.buildShareUrl( workspaceId: workspaceId, viewId: viewId, ); if (url.isNotEmpty) { unawaited( getIt().setData( ClipboardServiceData(plainText: url), ), ); showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } else { showToastNotification( message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(), type: ToastificationType.error, ); } } void _onRename(BuildContext context, String name) { if (name != view.name) { context.read().add(ViewEvent.rename(name)); } } void _updatePathName(BuildContext context) async { final shareBloc = context.read(); final pathName = shareBloc.state.pathName; await showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.shareAction_updatePathName.tr(), showCloseButton: true, showDragHandle: true, showDivider: false, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (bottomSheetContext) { FlowyResult? previousUpdatePathNameResult; return EditWorkspaceNameBottomSheet( type: EditWorkspaceNameType.edit, workspaceName: pathName, hintText: '', validator: (value) => null, validatorBuilder: (context) { return BlocProvider.value( value: shareBloc, child: BlocBuilder( builder: (context, state) { final updatePathNameResult = state.updatePathNameResult; if (updatePathNameResult == null && previousUpdatePathNameResult == null) { return const SizedBox.shrink(); } if (updatePathNameResult != null) { previousUpdatePathNameResult = updatePathNameResult; } final widget = previousUpdatePathNameResult?.fold( (value) => const SizedBox.shrink(), (error) => FlowyText( error.code.publishErrorMessage.orDefault( LocaleKeys.settings_sites_error_updatePathNameFailed .tr(), ), maxLines: 3, fontSize: 12, textAlign: TextAlign.left, overflow: TextOverflow.ellipsis, color: Theme.of(context).colorScheme.error, ), ) ?? const SizedBox.shrink(); return widget; }, ), ); }, onSubmitted: (name) { // rename the path name Log.info('rename the path name, from: $pathName, to: $name'); shareBloc.add(ShareEvent.updatePathName(name)); }, ); }, ); shareBloc.add(const ShareEvent.clearPathNameResult()); } void _showToast(BuildContext context, ShareState state) { if (state.publishResult != null) { state.publishResult!.fold( (value) => showToastNotification( message: LocaleKeys.publish_publishSuccessfully.tr(), ), (error) => showToastNotification( message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', type: ToastificationType.error, ), ); } else if (state.unpublishResult != null) { state.unpublishResult!.fold( (value) => showToastNotification( message: LocaleKeys.publish_unpublishSuccessfully.tr(), ), (error) => showToastNotification( message: LocaleKeys.publish_unpublishFailed.tr(), description: error.msg, type: ToastificationType.error, ), ); } else if (state.updatePathNameResult != null) { state.updatePathNameResult!.onSuccess( (value) { showToastNotification( message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); context.pop(); }, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet.dart ================================================ export 'bottom_sheet_action_widget.dart'; export 'bottom_sheet_add_new_page.dart'; export 'bottom_sheet_drag_handler.dart'; export 'bottom_sheet_rename_widget.dart'; export 'bottom_sheet_view_item.dart'; export 'bottom_sheet_view_item_body.dart'; export 'bottom_sheet_view_page.dart'; export 'default_mobile_action_pane.dart'; export 'show_mobile_bottom_sheet.dart'; export 'show_transition_bottom_sheet.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class BottomSheetActionWidget extends StatelessWidget { const BottomSheetActionWidget({ super.key, this.svg, required this.text, required this.onTap, this.iconColor, }); final FlowySvgData? svg; final String text; final VoidCallback onTap; final Color? iconColor; @override Widget build(BuildContext context) { final iconColor = this.iconColor ?? AFThemeExtension.of(context).onBackground; if (svg == null) { return OutlinedButton( style: Theme.of(context) .outlinedButtonTheme .style ?.copyWith(alignment: Alignment.center), onPressed: onTap, child: FlowyText( text, textAlign: TextAlign.center, ), ); } return OutlinedButton.icon( icon: FlowySvg( svg!, size: const Size.square(22.0), color: iconColor, ), label: FlowyText( text, overflow: TextOverflow.ellipsis, ), style: Theme.of(context) .outlinedButtonTheme .style ?.copyWith(alignment: Alignment.centerLeft), onPressed: onTap, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class AddNewPageWidgetBottomSheet extends StatelessWidget { const AddNewPageWidgetBottomSheet({ super.key, required this.view, required this.onAction, }); final ViewPB view; final void Function(ViewLayoutPB layout) onAction; @override Widget build(BuildContext context) { return Column( children: [ FlowyOptionTile.text( text: LocaleKeys.document_menuName.tr(), height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_document_s, size: Size.square(20), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Document), ), FlowyOptionTile.text( text: LocaleKeys.grid_menuName.tr(), height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_grid_s, size: Size.square(20), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Grid), ), FlowyOptionTile.text( text: LocaleKeys.board_menuName.tr(), height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_board_s, size: Size.square(20), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Board), ), FlowyOptionTile.text( text: LocaleKeys.calendar_menuName.tr(), height: 52.0, leftIcon: const FlowySvg( FlowySvgs.icon_calendar_s, size: Size.square(20), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Calendar), ), FlowyOptionTile.text( text: LocaleKeys.chat_newChat.tr(), height: 52.0, leftIcon: const FlowySvg( FlowySvgs.chat_ai_page_s, size: Size.square(20), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction(ViewLayoutPB.Chat), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum BlockActionBottomSheetType { delete, duplicate, insertAbove, insertBelow, } // Only works on mobile. class BlockActionBottomSheet extends StatelessWidget { const BlockActionBottomSheet({ super.key, required this.onAction, this.extendActionWidgets = const [], }); final void Function(BlockActionBottomSheetType layout) onAction; final List extendActionWidgets; @override Widget build(BuildContext context) { return Column( children: [ // insert above, insert below FlowyOptionTile.text( text: LocaleKeys.button_insertAbove.tr(), leftIcon: const FlowySvg( FlowySvgs.arrow_up_s, size: Size.square(20), ), showTopBorder: false, onTap: () => onAction(BlockActionBottomSheetType.insertAbove), ), FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.button_insertBelow.tr(), leftIcon: const FlowySvg( FlowySvgs.arrow_down_s, size: Size.square(20), ), onTap: () => onAction(BlockActionBottomSheetType.insertBelow), ), // duplicate, delete FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.button_duplicate.tr(), leftIcon: const Padding( padding: EdgeInsets.all(2), child: FlowySvg( FlowySvgs.copy_s, size: Size.square(16), ), ), onTap: () => onAction(BlockActionBottomSheetType.duplicate), ), ...extendActionWidgets, FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.button_delete.tr(), leftIcon: FlowySvg( FlowySvgs.trash_s, size: const Size.square(18), color: Theme.of(context).colorScheme.error, ), textColor: Theme.of(context).colorScheme.error, onTap: () => onAction(BlockActionBottomSheetType.delete), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class BottomSheetCloseButton extends StatelessWidget { const BottomSheetCloseButton({ super.key, this.onTap, }); final VoidCallback? onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap ?? () => Navigator.pop(context), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: SizedBox( width: 18, height: 18, child: FlowySvg( FlowySvgs.m_bottom_sheet_close_m, ), ), ), ); } } class BottomSheetDoneButton extends StatelessWidget { const BottomSheetDoneButton({ super.key, this.onDone, }); final VoidCallback? onDone; @override Widget build(BuildContext context) { return GestureDetector( onTap: onDone ?? () => Navigator.pop(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12.0), child: FlowyText( LocaleKeys.button_done.tr(), color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w500, textAlign: TextAlign.right, ), ), ); } } class BottomSheetRemoveButton extends StatelessWidget { const BottomSheetRemoveButton({ super.key, required this.onRemove, }); final VoidCallback onRemove; @override Widget build(BuildContext context) { return GestureDetector( onTap: onRemove, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12.0), child: FlowyText( LocaleKeys.button_remove.tr(), color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w500, textAlign: TextAlign.right, ), ), ); } } class BottomSheetBackButton extends StatelessWidget { const BottomSheetBackButton({ super.key, this.onTap, }); final VoidCallback? onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap ?? () => Navigator.pop(context), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: SizedBox( width: 18, height: 18, child: FlowySvg( FlowySvgs.m_bottom_sheet_back_s, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart ================================================ import 'package:flutter/material.dart'; class MobileBottomSheetDragHandler extends StatelessWidget { const MobileBottomSheetDragHandler({super.key}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 10.0), child: Container( width: 60, height: 4, decoration: BoxDecoration( borderRadius: BorderRadius.circular(2.0), color: Theme.of(context).hintColor, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_edit_link_widget.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_header.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/util/link_util.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class MobileBottomSheetEditLinkWidget extends StatefulWidget { const MobileBottomSheetEditLinkWidget({ super.key, required this.linkInfo, required this.onApply, required this.onRemoveLink, required this.currentViewId, required this.onDispose, }); final LinkInfo linkInfo; final ValueChanged onApply; final ValueChanged onRemoveLink; final VoidCallback onDispose; final String currentViewId; @override State createState() => _MobileBottomSheetEditLinkWidgetState(); } class _MobileBottomSheetEditLinkWidgetState extends State { ValueChanged get onApply => widget.onApply; ValueChanged get onRemoveLink => widget.onRemoveLink; late TextEditingController linkNameController = TextEditingController(text: linkInfo.name); final textFocusNode = FocusNode(); late LinkInfo linkInfo = widget.linkInfo; late LinkSearchTextField searchTextField; bool isShowingSearchResult = false; ViewPB? currentView; bool showErrorText = false; bool showRemoveLink = false; String title = LocaleKeys.editor_editLink.tr(); AppFlowyThemeData get theme => AppFlowyTheme.of(context); @override void initState() { super.initState(); final isPageLink = linkInfo.isPage; if (isPageLink) getPageView(); searchTextField = LinkSearchTextField( initialSearchText: isPageLink ? '' : linkInfo.link, initialViewId: linkInfo.viewId, currentViewId: widget.currentViewId, onEnter: () {}, onEscape: () {}, onDataRefresh: () { if (mounted) setState(() {}); }, )..searchRecentViews(); if (linkInfo.link.isEmpty) { isShowingSearchResult = true; title = LocaleKeys.toolbar_addLink.tr(); } else { showRemoveLink = true; textFocusNode.requestFocus(); } textFocusNode.addListener(() { if (!mounted) return; if (textFocusNode.hasFocus) { setState(() { isShowingSearchResult = false; }); } }); } @override void dispose() { linkNameController.dispose(); textFocusNode.dispose(); searchTextField.dispose(); widget.onDispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: MediaQuery.of(context).size.height * 0.8, child: SingleChildScrollView( child: Column( children: [ BottomSheetHeader( title: title, onClose: () => context.pop(), confirmButton: FlowyTextButton( LocaleKeys.button_done.tr(), constraints: const BoxConstraints.tightFor(width: 62, height: 30), padding: const EdgeInsets.only(left: 12), fontColor: theme.textColorScheme.onFill, fillColor: Theme.of(context).primaryColor, onPressed: () { if (isShowingSearchResult) { onConfirm(); return; } if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) { setState(() { showErrorText = true; }); return; } widget.onApply.call(linkInfo); context.pop(); }, ), ), const VSpace(20.0), buildNameTextField(), const VSpace(16.0), buildLinkField(), const VSpace(20.0), buildRemoveLink(), ], ), ), ); } Widget buildNameTextField() { return SizedBox( height: 48, child: TextFormField( focusNode: textFocusNode, textAlign: TextAlign.left, controller: linkNameController, style: TextStyle( fontSize: 16, height: 20 / 16, fontWeight: FontWeight.w400, ), onChanged: (text) { linkInfo = LinkInfo( name: text, link: linkInfo.link, isPage: linkInfo.isPage, ); }, decoration: LinkStyle.buildLinkTextFieldInputDecoration( LocaleKeys.document_toolbar_linkNameHint.tr(), contentPadding: EdgeInsets.all(14), radius: 12, context, ), ), ); } Widget buildLinkField() { final width = MediaQuery.of(context).size.width; final showPageView = linkInfo.isPage && !isShowingSearchResult; Widget child; if (showPageView) { child = buildPageView(); } else if (!isShowingSearchResult) { child = buildLinkView(); } else { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ searchTextField.buildTextField( autofocus: true, context: context, contentPadding: EdgeInsets.all(14), textStyle: TextStyle( fontSize: 16, height: 20 / 16, fontWeight: FontWeight.w400, ), ), VSpace(6), searchTextField.buildResultContainer( context: context, onPageLinkSelected: onPageSelected, onLinkSelected: onLinkSelected, width: width - 32, ), ], ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ child, if (showErrorText) Padding( padding: const EdgeInsets.only(top: 4), child: FlowyText.regular( LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), color: theme.textColorScheme.error, fontSize: 12, figmaLineHeight: 16, ), ), ], ); } Widget buildPageView() { final height = 48.0; late Widget child; final view = currentView; if (view == null) { child = Center( child: SizedBox.fromSize( size: Size(10, 10), child: CircularProgressIndicator(), ), ); } else { final viewName = view.name; final displayName = viewName.isEmpty ? LocaleKeys.document_title_placeholder.tr() : viewName; child = GestureDetector( onTap: showSearchResult, child: Container( height: height, color: Colors.grey.withAlpha(1), padding: EdgeInsets.all(14), child: Row( children: [ searchTextField.buildIcon(view), HSpace(4), Flexible( child: FlowyText.regular( displayName, overflow: TextOverflow.ellipsis, figmaLineHeight: 20, fontSize: 14, ), ), ], ), ), ); } return Container( height: height, decoration: buildBorderDecoration(), child: child, ); } Widget buildLinkView() { return Container( height: 48, decoration: buildBorderDecoration(), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: showSearchResult, child: Padding( padding: EdgeInsets.all(12), child: Row( children: [ FlowySvg(FlowySvgs.toolbar_link_earth_m), HSpace(8), Flexible( child: FlowyText.regular( linkInfo.link, overflow: TextOverflow.ellipsis, figmaLineHeight: 20, ), ), ], ), ), ), ); } Widget buildRemoveLink() { if (!showRemoveLink) return SizedBox.shrink(); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { widget.onRemoveLink(linkInfo); context.pop(); }, child: SizedBox( height: 32, child: Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.mobile_icon_remove_link_m, color: theme.iconColorScheme.secondary, ), HSpace(8), FlowyText.regular( LocaleKeys.editor_removeLink.tr(), overflow: TextOverflow.ellipsis, figmaLineHeight: 20, color: theme.textColorScheme.secondary, ), ], ), ), ), ); } void onConfirm() { searchTextField.onSearchResult( onLink: onLinkSelected, onRecentViews: () => onPageSelected(searchTextField.currentRecentView), onSearchViews: () => onPageSelected(searchTextField.currentSearchedView), onEmpty: () { searchTextField.unfocus(); }, ); } Future onPageSelected(ViewPB view) async { currentView = view; final link = ShareConstants.buildShareUrl( workspaceId: await UserBackendService.getCurrentWorkspace().fold( (s) => s.id, (f) => '', ), viewId: view.id, ); linkInfo = LinkInfo( name: linkInfo.name, link: link, isPage: true, ); searchTextField.updateText(linkInfo.link); if (mounted) { setState(() { isShowingSearchResult = false; searchTextField.unfocus(); }); } } void onLinkSelected() { if (mounted) { linkInfo = LinkInfo( name: linkInfo.name, link: searchTextField.searchText, ); hideSearchResult(); } } void hideSearchResult() { setState(() { isShowingSearchResult = false; searchTextField.unfocus(); textFocusNode.unfocus(); }); } void showSearchResult() { setState(() { if (linkInfo.isPage) searchTextField.updateText(''); isShowingSearchResult = true; searchTextField.requestFocus(); }); } Future getPageView() async { if (!linkInfo.isPage) return; final (view, isInTrash, isDeleted) = await ViewBackendService.getMentionPageStatus(linkInfo.viewId); if (mounted) { setState(() { currentView = view; }); } } BoxDecoration buildCardDecoration() { return BoxDecoration( borderRadius: BorderRadius.circular(theme.borderRadius.l), boxShadow: theme.shadow.medium, ); } BoxDecoration buildBorderDecoration() { return BoxDecoration( borderRadius: BorderRadius.circular(theme.borderRadius.l), border: Border.all(color: theme.borderColorScheme.primary), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_header.dart ================================================ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class BottomSheetHeader extends StatelessWidget { const BottomSheetHeader({ super.key, this.title, this.onClose, this.onDone, this.confirmButton, }); final String? title; final VoidCallback? onClose; final VoidCallback? onDone; final Widget? confirmButton; @override Widget build(BuildContext context) { return Stack( alignment: Alignment.center, children: [ if (onClose != null) Positioned( left: 0, child: Align( alignment: Alignment.centerLeft, child: BottomSheetCloseButton( onTap: onClose, ), ), ), if (title != null) Align( child: FlowyText.medium( title!, fontSize: 16, ), ), if (onDone != null || confirmButton != null) Align( alignment: Alignment.centerRight, child: confirmButton ?? BottomSheetDoneButton(onDone: onDone), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart'; import 'package:appflowy/util/xfile_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pbenum.dart'; import 'package:cross_file/cross_file.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; class MobileMediaUploadSheetContent extends StatelessWidget { const MobileMediaUploadSheetContent({super.key, required this.dialogContext}); final BuildContext dialogContext; @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(top: 12), constraints: const BoxConstraints( maxHeight: 340, minHeight: 80, ), child: MobileFileUploadMenu( onInsertLocalFile: (files) async { dialogContext.pop(); await insertLocalFiles( context, files, userProfile: context.read().state.userProfile, documentId: context.read().rowId, onUploadSuccess: (file, path, isLocalMode) { final mediaCellBloc = context.read(); if (mediaCellBloc.isClosed) { return; } mediaCellBloc.add( MediaCellEvent.addFile( url: path, name: file.name, uploadType: isLocalMode ? FileUploadTypePB.LocalFile : FileUploadTypePB.CloudFile, fileType: file.fileType.toMediaFileTypePB(), ), ); }, ); }, onInsertNetworkFile: (url) async => _onInsertNetworkFile( url, dialogContext, context, ), ), ); } Future _onInsertNetworkFile( String url, BuildContext dialogContext, BuildContext context, ) async { dialogContext.pop(); if (url.isEmpty) return; final uri = Uri.tryParse(url); if (uri == null) { return; } final fakeFile = XFile(uri.path); MediaFileTypePB fileType = fakeFile.fileType.toMediaFileTypePB(); fileType = fileType == MediaFileTypePB.Other ? MediaFileTypePB.Link : fileType; String name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; if (name.isEmpty && uri.pathSegments.length > 1) { name = uri.pathSegments[uri.pathSegments.length - 2]; } else if (name.isEmpty) { name = uri.host; } context.read().add( MediaCellEvent.addFile( url: url, name: name, uploadType: FileUploadTypePB.NetworkFile, fileType: fileType, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileBottomSheetRenameWidget extends StatefulWidget { const MobileBottomSheetRenameWidget({ super.key, required this.name, required this.onRename, this.padding = const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), }); final String name; final void Function(String name) onRename; final EdgeInsets padding; @override State createState() => _MobileBottomSheetRenameWidgetState(); } class _MobileBottomSheetRenameWidgetState extends State { late final TextEditingController controller; @override void initState() { super.initState(); controller = TextEditingController(text: widget.name) ..selection = TextSelection( baseOffset: 0, extentOffset: widget.name.length, ); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Padding( padding: widget.padding, child: Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: SizedBox( height: 42.0, child: FlowyTextField( controller: controller, textStyle: Theme.of(context).textTheme.bodyMedium, keyboardType: TextInputType.text, onSubmitted: (text) => widget.onRename(text), ), ), ), const HSpace(12.0), FlowyTextButton( LocaleKeys.button_edit.tr(), constraints: const BoxConstraints.tightFor(height: 42), padding: const EdgeInsets.symmetric( horizontal: 16.0, ), fontColor: Colors.white, fillColor: Theme.of(context).primaryColor, onPressed: () { widget.onRename(controller.text); }, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; enum MobileBottomSheetType { view, rename, } class MobileViewItemBottomSheet extends StatefulWidget { const MobileViewItemBottomSheet({ super.key, required this.view, required this.actions, this.defaultType = MobileBottomSheetType.view, }); final ViewPB view; final MobileBottomSheetType defaultType; final List actions; @override State createState() => _MobileViewItemBottomSheetState(); } class _MobileViewItemBottomSheetState extends State { MobileBottomSheetType type = MobileBottomSheetType.view; final fToast = FToast(); @override void initState() { super.initState(); type = widget.defaultType; fToast.init(AppGlobals.context); } @override Widget build(BuildContext context) { switch (type) { case MobileBottomSheetType.view: return MobileViewItemBottomSheetBody( actions: widget.actions, isFavorite: widget.view.isFavorite, onAction: (action) { switch (action) { case MobileViewItemBottomSheetBodyAction.rename: setState(() { type = MobileBottomSheetType.rename; }); break; case MobileViewItemBottomSheetBodyAction.duplicate: Navigator.pop(context); context.read().add(const ViewEvent.duplicate()); showToastNotification( message: LocaleKeys.button_duplicateSuccessfully.tr(), ); break; case MobileViewItemBottomSheetBodyAction.share: // unimplemented Navigator.pop(context); break; case MobileViewItemBottomSheetBodyAction.delete: Navigator.pop(context); context.read().add(const ViewEvent.delete()); break; case MobileViewItemBottomSheetBodyAction.addToFavorites: case MobileViewItemBottomSheetBodyAction.removeFromFavorites: Navigator.pop(context); context .read() .add(FavoriteEvent.toggle(widget.view)); showToastNotification( message: !widget.view.isFavorite ? LocaleKeys.button_favoriteSuccessfully.tr() : LocaleKeys.button_unfavoriteSuccessfully.tr(), ); break; case MobileViewItemBottomSheetBodyAction.removeFromRecent: _removeFromRecent(context); break; case MobileViewItemBottomSheetBodyAction.divider: break; } }, ); case MobileBottomSheetType.rename: return MobileBottomSheetRenameWidget( name: widget.view.name, onRename: (name) { if (name != widget.view.name) { context.read().add(ViewEvent.rename(name)); } Navigator.pop(context); }, ); } } Future _removeFromRecent(BuildContext context) async { final viewId = context.read().view.id; final recentViewsBloc = context.read(); Navigator.pop(context); await _showConfirmDialog( onDelete: () { recentViewsBloc.add(RecentViewsEvent.removeRecentViews([viewId])); }, ); } Future _showConfirmDialog({required VoidCallback onDelete}) async { await showFlowyCupertinoConfirmDialog( title: LocaleKeys.sideBar_removePageFromRecent.tr(), leftButton: FlowyText( LocaleKeys.button_cancel.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w500, color: const Color(0xFF007AFF), ), rightButton: FlowyText( LocaleKeys.button_delete.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w400, color: const Color(0xFFFE0220), ), onRightButtonPressed: (context) { onDelete(); Navigator.pop(context); showToastNotification( message: LocaleKeys.sideBar_removeSuccess.tr(), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; enum MobileViewItemBottomSheetBodyAction { rename, duplicate, share, delete, addToFavorites, removeFromFavorites, divider, removeFromRecent, } class MobileViewItemBottomSheetBody extends StatelessWidget { const MobileViewItemBottomSheetBody({ super.key, this.isFavorite = false, required this.onAction, required this.actions, }); final bool isFavorite; final void Function(MobileViewItemBottomSheetBodyAction action) onAction; final List actions; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: actions.map((action) => _buildActionButton(context, action)).toList(), ); } Widget _buildActionButton( BuildContext context, MobileViewItemBottomSheetBodyAction action, ) { final isLocked = context.read()?.state.isLocked ?? false; switch (action) { case MobileViewItemBottomSheetBodyAction.rename: return FlowyOptionTile.text( text: LocaleKeys.button_rename.tr(), height: 52.0, leftIcon: const FlowySvg( FlowySvgs.view_item_rename_s, size: Size.square(18), ), enable: !isLocked, showTopBorder: false, showBottomBorder: false, onTap: () => onAction( MobileViewItemBottomSheetBodyAction.rename, ), ); case MobileViewItemBottomSheetBodyAction.duplicate: return FlowyOptionTile.text( text: LocaleKeys.button_duplicate.tr(), height: 52.0, leftIcon: const FlowySvg( FlowySvgs.duplicate_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( MobileViewItemBottomSheetBodyAction.duplicate, ), ); case MobileViewItemBottomSheetBodyAction.share: return FlowyOptionTile.text( text: LocaleKeys.button_share.tr(), height: 52.0, leftIcon: const FlowySvg( FlowySvgs.share_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( MobileViewItemBottomSheetBodyAction.share, ), ); case MobileViewItemBottomSheetBodyAction.delete: return FlowyOptionTile.text( text: LocaleKeys.button_delete.tr(), height: 52.0, textColor: Theme.of(context).colorScheme.error, leftIcon: FlowySvg( FlowySvgs.trash_s, size: const Size.square(18), color: Theme.of(context).colorScheme.error, ), enable: !isLocked, showTopBorder: false, showBottomBorder: false, onTap: () => onAction( MobileViewItemBottomSheetBodyAction.delete, ), ); case MobileViewItemBottomSheetBodyAction.addToFavorites: return FlowyOptionTile.text( height: 52.0, text: LocaleKeys.button_addToFavorites.tr(), leftIcon: const FlowySvg( FlowySvgs.favorite_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( MobileViewItemBottomSheetBodyAction.addToFavorites, ), ); case MobileViewItemBottomSheetBodyAction.removeFromFavorites: return FlowyOptionTile.text( height: 52.0, text: LocaleKeys.button_removeFromFavorites.tr(), leftIcon: const FlowySvg( FlowySvgs.favorite_section_remove_from_favorite_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( MobileViewItemBottomSheetBodyAction.removeFromFavorites, ), ); case MobileViewItemBottomSheetBodyAction.removeFromRecent: return FlowyOptionTile.text( height: 52.0, text: LocaleKeys.button_removeFromRecent.tr(), leftIcon: const FlowySvg( FlowySvgs.remove_from_recent_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( MobileViewItemBottomSheetBodyAction.removeFromRecent, ), ); case MobileViewItemBottomSheetBodyAction.divider: return const Padding( padding: EdgeInsets.symmetric(horizontal: 12.0), child: Divider(height: 0.5), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; enum MobileViewBottomSheetBodyAction { undo, redo, rename, duplicate, delete, addToFavorites, removeFromFavorites, helpCenter, publish, unpublish, copyPublishLink, visitSite, copyShareLink, updatePathName, lockPage; static const disableInLockedView = [ undo, redo, rename, delete, ]; } class MobileViewBottomSheetBodyActionArguments { static const isLockedKey = 'is_locked'; } typedef MobileViewBottomSheetBodyActionCallback = void Function( MobileViewBottomSheetBodyAction action, // for the [MobileViewBottomSheetBodyAction.lockPage] action, // it will pass the [isLocked] value to the callback. { Map? arguments, }); class ViewPageBottomSheet extends StatefulWidget { const ViewPageBottomSheet({ super.key, required this.view, required this.onAction, required this.onRename, }); final ViewPB view; final MobileViewBottomSheetBodyActionCallback onAction; final void Function(String name) onRename; @override State createState() => _ViewPageBottomSheetState(); } class _ViewPageBottomSheetState extends State { MobileBottomSheetType type = MobileBottomSheetType.view; @override Widget build(BuildContext context) { switch (type) { case MobileBottomSheetType.view: return MobileViewBottomSheetBody( view: widget.view, onAction: (action, {arguments}) { switch (action) { case MobileViewBottomSheetBodyAction.rename: setState(() { type = MobileBottomSheetType.rename; }); break; default: widget.onAction(action, arguments: arguments); } }, ); case MobileBottomSheetType.rename: return MobileBottomSheetRenameWidget( name: widget.view.name, onRename: (name) { widget.onRename(name); }, ); } } } class MobileViewBottomSheetBody extends StatelessWidget { const MobileViewBottomSheetBody({ super.key, required this.view, required this.onAction, }); final ViewPB view; final MobileViewBottomSheetBodyActionCallback onAction; @override Widget build(BuildContext context) { final isFavorite = view.isFavorite; final isEditable = context.watch()?.state.isEditable ?? false; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ MobileQuickActionButton( text: LocaleKeys.button_rename.tr(), icon: FlowySvgs.view_item_rename_s, iconSize: const Size.square(18), enable: isEditable, onTap: () => onAction( MobileViewBottomSheetBodyAction.rename, ), ), _divider(), MobileQuickActionButton( text: isFavorite ? LocaleKeys.button_removeFromFavorites.tr() : LocaleKeys.button_addToFavorites.tr(), icon: isFavorite ? FlowySvgs.unfavorite_s : FlowySvgs.favorite_s, iconSize: const Size.square(18), onTap: () => onAction( isFavorite ? MobileViewBottomSheetBodyAction.removeFromFavorites : MobileViewBottomSheetBodyAction.addToFavorites, ), ), _divider(), if (view.layout.isDatabaseView || view.layout.isDocumentView) ...[ MobileQuickActionButton( text: LocaleKeys.disclosureAction_lockPage.tr(), icon: FlowySvgs.lock_page_s, iconSize: const Size.square(18), rightIconBuilder: (context) => _LockPageRightIconBuilder( onAction: onAction, ), onTap: () { final isLocked = context.read()?.state.isLocked ?? false; onAction( MobileViewBottomSheetBodyAction.lockPage, arguments: { MobileViewBottomSheetBodyActionArguments.isLockedKey: isLocked, }, ); }, ), _divider(), ], MobileQuickActionButton( text: LocaleKeys.button_duplicate.tr(), icon: FlowySvgs.duplicate_s, iconSize: const Size.square(18), onTap: () => onAction( MobileViewBottomSheetBodyAction.duplicate, ), ), // copy link _divider(), MobileQuickActionButton( text: LocaleKeys.shareAction_copyLink.tr(), icon: FlowySvgs.m_copy_link_s, iconSize: const Size.square(18), onTap: () => onAction( MobileViewBottomSheetBodyAction.copyShareLink, ), ), _divider(), ..._buildPublishActions(context), MobileQuickActionButton( text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, icon: FlowySvgs.trash_s, iconColor: Theme.of(context).colorScheme.error, iconSize: const Size.square(18), enable: isEditable, onTap: () => onAction( MobileViewBottomSheetBodyAction.delete, ), ), _divider(), ], ); } List _buildPublishActions(BuildContext context) { final userProfile = context.read().state.userProfilePB; // the publish feature is only available for AppFlowy Cloud if (userProfile == null || userProfile.workspaceType != WorkspaceTypePB.ServerW) { return []; } final isPublished = context.watch().state.isPublished; if (isPublished) { return [ MobileQuickActionButton( text: LocaleKeys.shareAction_updatePathName.tr(), icon: FlowySvgs.view_item_rename_s, iconSize: const Size.square(18), onTap: () => onAction( MobileViewBottomSheetBodyAction.updatePathName, ), ), _divider(), MobileQuickActionButton( text: LocaleKeys.shareAction_visitSite.tr(), icon: FlowySvgs.m_visit_site_s, iconSize: const Size.square(18), onTap: () => onAction( MobileViewBottomSheetBodyAction.visitSite, ), ), _divider(), MobileQuickActionButton( text: LocaleKeys.shareAction_unPublish.tr(), icon: FlowySvgs.m_unpublish_s, iconSize: const Size.square(18), onTap: () => onAction( MobileViewBottomSheetBodyAction.unpublish, ), ), _divider(), ]; } else { return [ MobileQuickActionButton( text: LocaleKeys.shareAction_publish.tr(), icon: FlowySvgs.m_publish_s, onTap: () => onAction( MobileViewBottomSheetBodyAction.publish, ), ), _divider(), ]; } } Widget _divider() => const MobileQuickActionDivider(); } class _LockPageRightIconBuilder extends StatelessWidget { const _LockPageRightIconBuilder({ required this.onAction, }); final MobileViewBottomSheetBodyActionCallback onAction; @override Widget build(BuildContext context) { final isEditable = context.watch()?.state.isEditable ?? false; return SizedBox( width: 46, height: 30, child: FittedBox( fit: BoxFit.fill, child: CupertinoSwitch( value: isEditable, activeTrackColor: Theme.of(context).colorScheme.primary, onChanged: (value) { onAction( MobileViewBottomSheetBodyAction.lockPage, arguments: { MobileViewBottomSheetBodyActionArguments.isLockedKey: value, }, ); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; enum MobilePaneActionType { delete, addToFavorites, removeFromFavorites, more, add; MobileSlideActionButton actionButton( BuildContext context, { MobilePageCardType? cardType, FolderSpaceType? spaceType, }) { switch (this) { case MobilePaneActionType.delete: return MobileSlideActionButton( backgroundColor: Colors.red, svg: FlowySvgs.delete_s, size: 30.0, onPressed: (context) => context.read().add(const ViewEvent.delete()), ); case MobilePaneActionType.removeFromFavorites: return MobileSlideActionButton( backgroundColor: const Color(0xFFFA217F), svg: FlowySvgs.favorite_section_remove_from_favorite_s, size: 24.0, onPressed: (context) { showToastNotification( message: LocaleKeys.button_unfavoriteSuccessfully.tr(), ); context .read() .add(FavoriteEvent.toggle(context.read().view)); }, ); case MobilePaneActionType.addToFavorites: return MobileSlideActionButton( backgroundColor: const Color(0xFF00C8FF), svg: FlowySvgs.favorite_s, size: 24.0, onPressed: (context) { showToastNotification( message: LocaleKeys.button_favoriteSuccessfully.tr(), ); context .read() .add(FavoriteEvent.toggle(context.read().view)); }, ); case MobilePaneActionType.add: return MobileSlideActionButton( backgroundColor: const Color(0xFF00C8FF), svg: FlowySvgs.add_m, size: 28.0, onPressed: (context) { final viewBloc = context.read(); final view = viewBloc.state.view; final title = view.name; showMobileBottomSheet( context, showHeader: true, title: title, showDragHandle: true, showCloseButton: true, useRootNavigator: true, showDivider: false, backgroundColor: Theme.of(context).colorScheme.surface, builder: (sheetContext) { return AddNewPageWidgetBottomSheet( view: view, onAction: (layout) { Navigator.of(sheetContext).pop(); viewBloc.add( ViewEvent.createView( layout.defaultName, layout, section: spaceType!.toViewSectionPB, ), ); }, ); }, ); }, ); case MobilePaneActionType.more: return MobileSlideActionButton( backgroundColor: const Color(0xE5515563), svg: FlowySvgs.three_dots_s, size: 24.0, borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), bottomLeft: Radius.circular(10), ), onPressed: (context) { final viewBloc = context.read(); final favoriteBloc = context.read(); final recentViewsBloc = context.read(); showMobileBottomSheet( context, showDragHandle: true, showDivider: false, useRootNavigator: true, backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) { return MultiBlocProvider( providers: [ BlocProvider.value(value: viewBloc), BlocProvider.value(value: favoriteBloc), if (recentViewsBloc != null) BlocProvider.value(value: recentViewsBloc), BlocProvider( create: (_) => PageAccessLevelBloc(view: viewBloc.state.view) ..add(const PageAccessLevelEvent.initial()), ), ], child: BlocBuilder( builder: (context, state) { return MobileViewItemBottomSheet( view: viewBloc.state.view, actions: _buildActions(state.view, cardType: cardType), ); }, ), ); }, ); }, ); } } List _buildActions( ViewPB view, { MobilePageCardType? cardType, }) { final isFavorite = view.isFavorite; if (cardType != null) { switch (cardType) { case MobilePageCardType.recent: return [ isFavorite ? MobileViewItemBottomSheetBodyAction.removeFromFavorites : MobileViewItemBottomSheetBodyAction.addToFavorites, MobileViewItemBottomSheetBodyAction.divider, MobileViewItemBottomSheetBodyAction.divider, MobileViewItemBottomSheetBodyAction.removeFromRecent, ]; case MobilePageCardType.favorite: return [ isFavorite ? MobileViewItemBottomSheetBodyAction.removeFromFavorites : MobileViewItemBottomSheetBodyAction.addToFavorites, MobileViewItemBottomSheetBodyAction.divider, ]; } } return [ isFavorite ? MobileViewItemBottomSheetBodyAction.removeFromFavorites : MobileViewItemBottomSheetBodyAction.addToFavorites, MobileViewItemBottomSheetBodyAction.divider, MobileViewItemBottomSheetBodyAction.rename, if (view.layout != ViewLayoutPB.Chat) MobileViewItemBottomSheetBodyAction.duplicate, MobileViewItemBottomSheetBodyAction.divider, MobileViewItemBottomSheetBodyAction.delete, ]; } } ActionPane buildEndActionPane( BuildContext context, List actions, { bool needSpace = true, MobilePageCardType? cardType, FolderSpaceType? spaceType, required double spaceRatio, }) { return ActionPane( motion: const ScrollMotion(), extentRatio: actions.length / spaceRatio, children: [ if (needSpace) const HSpace(60), ...actions.map( (action) => action.actionButton( context, spaceType: spaceType, cardType: cardType, ), ), ], ); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart ================================================ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; extension BottomSheetPaddingExtension on BuildContext { /// Calculates the total amount of space that should be added to the bottom of /// a bottom sheet double bottomSheetPadding({ bool ignoreViewPadding = true, }) { final viewPadding = MediaQuery.viewPaddingOf(this); final viewInsets = MediaQuery.viewInsetsOf(this); double bottom = 0.0; if (!ignoreViewPadding) { bottom += viewPadding.bottom; } // for screens with 0 view padding, add some even more space bottom += viewPadding.bottom == 0 ? 28.0 : 16.0; bottom += viewInsets.bottom; return bottom; } } Future showMobileBottomSheet( BuildContext context, { required WidgetBuilder builder, bool useSafeArea = true, bool isDragEnabled = true, bool showDragHandle = false, bool showHeader = false, // this field is only used if showHeader is true bool showBackButton = false, bool showCloseButton = false, bool showRemoveButton = false, VoidCallback? onRemove, // this field is only used if showHeader is true String title = '', bool isScrollControlled = true, bool showDivider = true, bool useRootNavigator = false, ShapeBorder? shape, // the padding of the content, the padding of the header area is fixed EdgeInsets padding = EdgeInsets.zero, Color? backgroundColor, BoxConstraints? constraints, Color? barrierColor, double? elevation, bool showDoneButton = false, void Function(BuildContext context)? onDone, bool enableDraggableScrollable = false, bool enableScrollable = false, // this field is only used if showDragHandle is true Widget Function(BuildContext, ScrollController)? scrollableWidgetBuilder, // only used when enableDraggableScrollable is true double minChildSize = 0.5, double maxChildSize = 0.8, double initialChildSize = 0.51, double bottomSheetPadding = 0, bool enablePadding = true, WidgetBuilder? dragHandleBuilder, }) async { assert( showHeader || title.isEmpty && !showCloseButton && !showBackButton && !showDoneButton, ); assert(!(showCloseButton && showBackButton)); shape ??= const RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(16), ), ); backgroundColor ??= Theme.of(context).brightness == Brightness.light ? const Color(0xFFF7F8FB) : const Color(0xFF23262B); barrierColor ??= Colors.black.withValues(alpha: 0.3); return showModalBottomSheet( context: context, isScrollControlled: isScrollControlled, enableDrag: isDragEnabled, useSafeArea: true, clipBehavior: Clip.antiAlias, constraints: constraints, barrierColor: barrierColor, elevation: elevation, backgroundColor: backgroundColor, shape: shape, useRootNavigator: useRootNavigator, builder: (context) { final List children = []; final Widget child = builder(context); // if the children is only one, we don't need to wrap it with a column if (!showDragHandle && !showHeader && !showDivider) { return child; } // ----- header area ----- if (showDragHandle) { children.add( dragHandleBuilder?.call(context) ?? const DragHandle(), ); } if (showHeader) { children.add( BottomSheetHeader( showCloseButton: showCloseButton, showBackButton: showBackButton, showDoneButton: showDoneButton, showRemoveButton: showRemoveButton, title: title, onRemove: onRemove, onDone: onDone, ), ); if (showDivider) { children.add( const Divider(height: 0.5, thickness: 0.5), ); } } // ----- header area ----- if (enableDraggableScrollable) { final keyboardSize = context.bottomSheetPadding() / MediaQuery.of(context).size.height; return DraggableScrollableSheet( expand: false, snap: true, initialChildSize: (initialChildSize + keyboardSize).clamp(0, 1), minChildSize: (minChildSize + keyboardSize).clamp(0, 1.0), maxChildSize: (maxChildSize + keyboardSize).clamp(0, 1.0), builder: (context, scrollController) { return Column( children: [ ...children, scrollableWidgetBuilder?.call( context, scrollController, ) ?? Expanded( child: Scrollbar( controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: child, ), ), ), ], ); }, ); } else if (enableScrollable) { return Column( mainAxisSize: MainAxisSize.min, children: [ ...children, Flexible( child: SingleChildScrollView( child: child, ), ), VSpace(bottomSheetPadding), ], ); } // ----- content area ----- if (enablePadding) { // add content padding and extra bottom padding children.add( Padding( padding: padding + EdgeInsets.only(bottom: context.bottomSheetPadding()), child: child, ), ); } else { children.add(child); } // ----- content area ----- if (children.length == 1) { return children.first; } return useSafeArea ? SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: children, ), ) : Column( mainAxisSize: MainAxisSize.min, children: children, ); }, ); } class BottomSheetHeader extends StatelessWidget { const BottomSheetHeader({ super.key, required this.showBackButton, required this.showCloseButton, required this.showRemoveButton, required this.title, required this.showDoneButton, this.onRemove, this.onDone, this.onBack, this.onClose, }); final String title; final bool showBackButton; final bool showCloseButton; final bool showRemoveButton; final bool showDoneButton; final VoidCallback? onRemove; final VoidCallback? onBack; final VoidCallback? onClose; final void Function(BuildContext context)? onDone; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Padding( padding: const EdgeInsets.only(bottom: 4.0), child: SizedBox( height: 44.0, // the height of the header area is fixed child: Stack( children: [ if (showBackButton) Align( alignment: Alignment.centerLeft, child: BottomSheetBackButton( onTap: onBack, ), ), if (showCloseButton) Align( alignment: Alignment.centerLeft, child: BottomSheetCloseButton( onTap: onClose, ), ), if (showRemoveButton) Align( alignment: Alignment.centerLeft, child: BottomSheetRemoveButton( onRemove: () => onRemove?.call(), ), ), Align( child: Container( constraints: const BoxConstraints(maxWidth: 250), child: Text( title, style: theme.textStyle.heading4.prominent( color: theme.textColorScheme.primary, ), ), ), ), if (showDoneButton) Align( alignment: Alignment.centerRight, child: BottomSheetDoneButton( onDone: () { if (onDone != null) { onDone?.call(context); } else { Navigator.pop(context); } }, ), ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart ================================================ import 'dart:math' as math; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:sheet/route.dart'; import 'package:sheet/sheet.dart'; import 'show_mobile_bottom_sheet.dart'; Future showTransitionMobileBottomSheet( BuildContext context, { required WidgetBuilder builder, bool useRootNavigator = false, EdgeInsets contentPadding = EdgeInsets.zero, Color? backgroundColor, // drag handle bool showDragHandle = false, // header bool showHeader = false, String title = '', bool showBackButton = false, bool showCloseButton = false, bool showDoneButton = false, bool showDivider = true, // stops double initialStop = 1.0, List? stops, }) { assert( showHeader || title.isEmpty && !showCloseButton && !showBackButton && !showDoneButton && !showDivider, ); assert(!(showCloseButton && showBackButton)); backgroundColor ??= Theme.of(context).brightness == Brightness.light ? const Color(0xFFF7F8FB) : const Color(0xFF23262B); return Navigator.of( context, rootNavigator: useRootNavigator, ).push( TransitionSheetRoute( backgroundColor: backgroundColor, initialStop: initialStop, stops: stops, builder: (context) { final Widget child = builder(context); // if the children is only one, we don't need to wrap it with a column if (!showDragHandle && !showHeader) { return child; } return Column( mainAxisSize: MainAxisSize.min, children: [ if (showDragHandle) const DragHandle(), if (showHeader) ...[ BottomSheetHeader( showCloseButton: showCloseButton, showBackButton: showBackButton, showDoneButton: showDoneButton, showRemoveButton: false, title: title, ), if (showDivider) const Divider( height: 0.5, thickness: 0.5, ), ], Expanded( child: Padding( padding: contentPadding, child: child, ), ), ], ); }, ), ); } /// The top offset that will be displayed from the bottom route const double _kPreviousRouteVisibleOffset = 10.0; /// Minimal distance from the top of the screen to the top of the previous route /// It will be used ff the top safe area is less than this value. /// In iPhones the top SafeArea is more or equal to this distance. const double _kSheetMinimalOffset = 10; const Curve _kCupertinoSheetCurve = Curves.easeOutExpo; const Curve _kCupertinoTransitionCurve = Curves.linear; /// Wraps the child into a cupertino modal sheet appearance. This is used to /// create a [SheetRoute]. /// /// Clip the child widget to rectangle with top rounded corners and adds /// top padding and top safe area. class _CupertinoSheetDecorationBuilder extends StatelessWidget { const _CupertinoSheetDecorationBuilder({ required this.child, required this.topRadius, this.backgroundColor, }); /// The child contained by the modal sheet final Widget child; /// The color to paint behind the child final Color? backgroundColor; /// The top corners of this modal sheet are rounded by this Radius final Radius topRadius; @override Widget build(BuildContext context) { return Builder( builder: (BuildContext context) { return Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( borderRadius: BorderRadius.vertical(top: topRadius), color: backgroundColor, ), child: MediaQuery.removePadding( context: context, removeTop: true, child: child, ), ); }, ); } } /// Customized CupertinoSheetRoute from the sheets package /// /// A modal route that overlays a widget over the current route and animates /// it from the bottom with a cupertino modal sheet appearance /// /// Clip the child widget to rectangle with top rounded corners and adds /// top padding and top safe area. class TransitionSheetRoute extends SheetRoute { TransitionSheetRoute({ required WidgetBuilder builder, super.stops, double initialStop = 1.0, super.settings, Color? backgroundColor, super.maintainState = true, super.fit, }) : super( builder: (BuildContext context) { return _CupertinoSheetDecorationBuilder( backgroundColor: backgroundColor, topRadius: const Radius.circular(16), child: Builder(builder: builder), ); }, animationCurve: _kCupertinoSheetCurve, initialExtent: initialStop, ); @override bool get draggable => true; final SheetController _sheetController = SheetController(); @override SheetController createSheetController() => _sheetController; @override Color? get barrierColor => Colors.transparent; @override bool get barrierDismissible => true; @override Widget buildSheet(BuildContext context, Widget child) { final effectivePhysics = draggable ? BouncingSheetPhysics( parent: SnapSheetPhysics( stops: stops ?? [0, 1], parent: physics, ), ) : const NeverDraggableSheetPhysics(); final MediaQueryData mediaQuery = MediaQuery.of(context); final double topMargin = math.max(_kSheetMinimalOffset, mediaQuery.padding.top) + _kPreviousRouteVisibleOffset; return Sheet.raw( initialExtent: initialExtent, decorationBuilder: decorationBuilder, fit: fit, maxExtent: mediaQuery.size.height - topMargin, physics: effectivePhysics, controller: sheetController, child: child, ); } @override Widget buildTransitions( BuildContext context, Animation animation, Animation secondaryAnimation, Widget child, ) { final double topPadding = MediaQuery.of(context).padding.top; final double topOffset = math.max(_kSheetMinimalOffset, topPadding); return AnimatedBuilder( animation: secondaryAnimation, child: child, builder: (BuildContext context, Widget? child) { final double progress = secondaryAnimation.value; final double scale = 1 - progress / 10; final double distanceWithScale = (topOffset + _kPreviousRouteVisibleOffset) * 0.9; final Offset offset = Offset(0, progress * (topOffset - distanceWithScale)); return Transform.translate( offset: offset, child: Transform.scale( scale: scale, alignment: Alignment.topCenter, child: child, ), ); }, ); } @override bool canDriveSecondaryTransitionForPreviousRoute( Route previousRoute, ) => true; @override Widget buildSecondaryTransitionForPreviousRoute( BuildContext context, Animation secondaryAnimation, Widget child, ) { final Animation delayAnimation = CurvedAnimation( parent: _sheetController.animation, curve: Interval( initialExtent == 1 ? 0 : initialExtent, 1, ), ); final Animation secondaryAnimation = CurvedAnimation( parent: _sheetController.animation, curve: Interval( 0, initialExtent, ), ); return CupertinoSheetBottomRouteTransition( body: child, sheetAnimation: delayAnimation, secondaryAnimation: secondaryAnimation, ); } } /// Animation for previous route when a [TransitionSheetRoute] enters/exits @visibleForTesting class CupertinoSheetBottomRouteTransition extends StatelessWidget { const CupertinoSheetBottomRouteTransition({ super.key, required this.sheetAnimation, required this.secondaryAnimation, required this.body, }); final Widget body; final Animation sheetAnimation; final Animation secondaryAnimation; @override Widget build(BuildContext context) { final double topPadding = MediaQuery.of(context).padding.top; final double topOffset = math.max(_kSheetMinimalOffset, topPadding); final CurvedAnimation curvedAnimation = CurvedAnimation( parent: sheetAnimation, curve: _kCupertinoTransitionCurve, ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: AnimatedBuilder( animation: secondaryAnimation, child: body, builder: (BuildContext context, Widget? child) { final double progress = curvedAnimation.value; final double scale = 1 - progress / 10; return Stack( children: [ Container(color: Colors.black), Transform.translate( offset: Offset(0, progress * topOffset), child: Transform.scale( scale: scale, alignment: Alignment.topCenter, child: ClipRRect( borderRadius: BorderRadius.vertical( top: Radius.lerp( Radius.zero, const Radius.circular(16.0), progress, )!, ), child: ColorFiltered( colorFilter: ColorFilter.mode( (Theme.of(context).brightness == Brightness.dark ? Colors.grey : Colors.black) .withValues(alpha: secondaryAnimation.value * 0.1), BlendMode.srcOver, ), child: child, ), ), ), ), ], ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart ================================================ import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; class MobileChatScreen extends StatelessWidget { const MobileChatScreen({ super.key, required this.id, this.title, }); /// view id final String id; final String? title; static const routeName = '/chat'; static const viewId = 'id'; static const viewTitle = 'title'; @override Widget build(BuildContext context) { return MobileViewPage( id: id, title: title, viewLayout: ViewLayoutPB.Chat, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart ================================================ export 'mobile_board_screen.dart'; export 'mobile_board_page.dart'; export 'widgets/mobile_hidden_groups_column.dart'; export 'widgets/mobile_board_trailing.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/board/board.dart'; import 'package:appflowy/mobile/presentation/database/board/widgets/group_card_header.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MobileBoardPage extends StatefulWidget { const MobileBoardPage({ super.key, required this.view, required this.databaseController, this.onEditStateChanged, }); final ViewPB view; final DatabaseController databaseController; /// Called when edit state changed final VoidCallback? onEditStateChanged; @override State createState() => _MobileBoardPageState(); } class _MobileBoardPageState extends State { late final ValueNotifier _didCreateRow; @override void initState() { super.initState(); _didCreateRow = ValueNotifier(null)..addListener(_handleDidCreateRow); } @override void dispose() { _didCreateRow ..removeListener(_handleDidCreateRow) ..dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (_) => BoardBloc( databaseController: widget.databaseController, didCreateRow: _didCreateRow, )..add(const BoardEvent.initial()), child: BlocBuilder( builder: (context, state) => state.maybeMap( loading: (_) => const Center( child: CircularProgressIndicator.adaptive(), ), error: (err) => Center( child: AppFlowyErrorPage( error: err.error, ), ), ready: (data) => const _BoardContent(), orElse: () => const SizedBox.shrink(), ), ), ); } void _handleDidCreateRow() { if (_didCreateRow.value != null) { final result = _didCreateRow.value!; switch (result.action) { case DidCreateRowAction.openAsPage: context.push( MobileRowDetailPage.routeName, extra: { MobileRowDetailPage.argRowId: result.rowMeta.id, MobileRowDetailPage.argDatabaseController: widget.databaseController, }, ); break; default: break; } } } } class _BoardContent extends StatefulWidget { const _BoardContent(); @override State<_BoardContent> createState() => _BoardContentState(); } class _BoardContentState extends State<_BoardContent> { late final ScrollController scrollController; @override void initState() { super.initState(); scrollController = ScrollController(); } @override void dispose() { scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; final config = AppFlowyBoardConfig( groupCornerRadius: 8, groupBackgroundColor: Theme.of(context).colorScheme.secondary, groupMargin: const EdgeInsets.fromLTRB(4, 0, 4, 12), groupHeaderPadding: const EdgeInsets.all(8), groupBodyPadding: const EdgeInsets.all(4), groupFooterPadding: const EdgeInsets.all(8), cardMargin: const EdgeInsets.all(4), ); return BlocBuilder( builder: (context, state) { return state.maybeMap( orElse: () => const SizedBox.shrink(), ready: (state) { final isEditable = context.watch()?.state.isEditable ?? false; final showCreateGroupButton = context .read() .groupingFieldType ?.canCreateNewGroup ?? false; final showHiddenGroups = state.hiddenGroups.isNotEmpty; return AppFlowyBoard( scrollController: scrollController, controller: context.read().boardController, groupConstraints: BoxConstraints.tightFor(width: screenWidth * 0.7), config: config, leading: showHiddenGroups ? MobileHiddenGroupsColumn( padding: config.groupHeaderPadding, ) : const HSpace(16), trailing: showCreateGroupButton && isEditable ? const MobileBoardTrailing() : const HSpace(16), headerBuilder: (_, groupData) { return IgnorePointer( ignoring: !isEditable, child: GroupCardHeader( groupData: groupData, ), ); }, footerBuilder: _buildFooter, cardBuilder: (_, column, columnItem) => _buildCard( context: context, afGroupData: column, afGroupItem: columnItem, cardMargin: config.cardMargin, ), ); }, ); }, ); } Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) { final isEditable = context.read()?.state.isEditable ?? false; final style = Theme.of(context); return SizedBox( height: 42, width: double.infinity, child: IgnorePointer( ignoring: !isEditable, child: TextButton.icon( style: TextButton.styleFrom( padding: const EdgeInsets.only(left: 8), alignment: Alignment.centerLeft, ), icon: FlowySvg( FlowySvgs.add_m, color: style.colorScheme.onSurface, ), label: Text( LocaleKeys.board_column_createNewCard.tr(), style: style.textTheme.bodyMedium?.copyWith( color: style.colorScheme.onSurface, ), ), onPressed: () => context.read().add( BoardEvent.createRow( columnData.id, OrderObjectPositionTypePB.End, null, null, ), ), ), ), ); } Widget _buildCard({ required BuildContext context, required AppFlowyGroupData afGroupData, required AppFlowyGroupItem afGroupItem, required EdgeInsets cardMargin, }) { final boardBloc = context.read(); final groupItem = afGroupItem as GroupItem; final groupData = afGroupData.customData as GroupData; final rowMeta = groupItem.row; final cellBuilder = CardCellBuilder(databaseController: boardBloc.databaseController); final groupItemId = groupItem.row.id + groupData.group.groupId; final isLocked = context.read()?.state.isLocked ?? false; return Container( key: ValueKey(groupItemId), margin: cardMargin, decoration: _makeBoxDecoration(context), child: BlocProvider.value( value: boardBloc, child: IgnorePointer( ignoring: isLocked, child: RowCard( fieldController: boardBloc.fieldController, rowMeta: rowMeta, viewId: boardBloc.viewId, rowCache: boardBloc.rowCache, groupingFieldId: groupItem.fieldInfo.id, isEditing: false, cellBuilder: cellBuilder, onTap: (context) { context.push( MobileRowDetailPage.routeName, extra: { MobileRowDetailPage.argRowId: rowMeta.id, MobileRowDetailPage.argDatabaseController: context.read().databaseController, }, ); }, onStartEditing: () {}, onEndEditing: () {}, styleConfiguration: RowCardStyleConfiguration( cellStyleMap: mobileBoardCardCellStyleMap(context), showAccessory: false, ), userProfile: boardBloc.userProfile, ), ), ), ); } BoxDecoration _makeBoxDecoration(BuildContext context) { final themeMode = context.read().state.themeMode; return BoxDecoration( color: AFThemeExtension.of(context).background, borderRadius: const BorderRadius.all(Radius.circular(8)), border: themeMode == ThemeMode.light ? Border.fromBorderSide( BorderSide( color: Theme.of(context) .colorScheme .outline .withValues(alpha: 0.5), ), ) : null, boxShadow: themeMode == ThemeMode.light ? [ BoxShadow( color: Theme.of(context) .colorScheme .outline .withValues(alpha: 0.5), blurRadius: 4, offset: const Offset(0, 2), ), ] : null, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_screen.dart ================================================ import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; class MobileBoardScreen extends StatelessWidget { const MobileBoardScreen({ super.key, required this.id, this.title, }); /// view id final String id; final String? title; static const routeName = '/board'; static const viewId = 'id'; static const viewTitle = 'title'; @override Widget build(BuildContext context) { return MobileViewPage( id: id, title: title, viewLayout: ViewLayoutPB.Document, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; // similar to [BoardColumnHeader] in Desktop class GroupCardHeader extends StatefulWidget { const GroupCardHeader({ super.key, required this.groupData, }); final AppFlowyGroupData groupData; @override State createState() => _GroupCardHeaderState(); } class _GroupCardHeaderState extends State { late final TextEditingController _controller = TextEditingController.fromValue( TextEditingValue( selection: TextSelection.collapsed( offset: widget.groupData.headerData.groupName.length, ), text: widget.groupData.headerData.groupName, ), ); @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final boardCustomData = widget.groupData.customData as GroupData; final titleTextStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.w600, ); return BlocBuilder( builder: (context, state) { Widget title = Text( widget.groupData.headerData.groupName, style: titleTextStyle, overflow: TextOverflow.ellipsis, ); // header can be edited if it's not default group(no status) and the field type can be edited if (!boardCustomData.group.isDefault && boardCustomData.fieldType.canEditHeader) { title = GestureDetector( onTap: () => context .read() .add(BoardEvent.startEditingHeader(widget.groupData.id)), child: Text( widget.groupData.headerData.groupName, style: titleTextStyle, overflow: TextOverflow.ellipsis, ), ); } final isEditing = state.maybeMap( ready: (value) => value.editingHeaderId == widget.groupData.id, orElse: () => false, ); if (isEditing) { title = TextField( controller: _controller, autofocus: true, onEditingComplete: () => context.read().add( BoardEvent.endEditingHeader( widget.groupData.id, _controller.text, ), ), style: titleTextStyle, onTapOutside: (_) => context.read().add( // group header switch from TextField to Text // group name won't be changed BoardEvent.endEditingHeader(widget.groupData.id, null), ), ); } return Padding( padding: const EdgeInsets.only(left: 16), child: SizedBox( height: 42, child: Row( children: [ _buildHeaderIcon(boardCustomData), Expanded(child: title), IconButton( icon: Icon( Icons.more_horiz_rounded, color: Theme.of(context).colorScheme.onSurface, ), splashRadius: 5, onPressed: () => showMobileBottomSheet( context, showDragHandle: true, backgroundColor: Theme.of(context).colorScheme.surface, builder: (_) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ MobileQuickActionButton( text: LocaleKeys.board_column_renameColumn.tr(), icon: FlowySvgs.edit_s, onTap: () { context.read().add( BoardEvent.startEditingHeader( widget.groupData.id, ), ); context.pop(); }, ), const MobileQuickActionDivider(), MobileQuickActionButton( text: LocaleKeys.board_column_hideColumn.tr(), icon: FlowySvgs.hide_s, onTap: () { context.read().add( BoardEvent.setGroupVisibility( widget.groupData.customData.group as GroupPB, false, ), ); context.pop(); }, ), ], ), ), ), IconButton( icon: Icon( Icons.add, color: Theme.of(context).colorScheme.onSurface, ), splashRadius: 5, onPressed: () { context.read().add( BoardEvent.createRow( widget.groupData.id, OrderObjectPositionTypePB.Start, null, null, ), ); }, ), ], ), ), ); }, ); } Widget _buildHeaderIcon(GroupData customData) => switch (customData.fieldType) { FieldType.Checkbox => FlowySvg( customData.asCheckboxGroup()!.isCheck ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, blendMode: BlendMode.dst, ), _ => const SizedBox.shrink(), }; } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_board_trailing.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// Add new group class MobileBoardTrailing extends StatefulWidget { const MobileBoardTrailing({super.key}); @override State createState() => _MobileBoardTrailingState(); } class _MobileBoardTrailingState extends State { final TextEditingController _textController = TextEditingController(); bool isEditing = false; @override void dispose() { _textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; final style = Theme.of(context); return Container( margin: const EdgeInsets.symmetric(horizontal: 8), child: SizedBox( width: screenSize.width * 0.7, child: isEditing ? DecoratedBox( decoration: BoxDecoration( color: style.colorScheme.secondary, borderRadius: BorderRadius.circular(8), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _textController, autofocus: true, onChanged: (_) => setState(() {}), decoration: InputDecoration( suffixIcon: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _textController.text.isNotEmpty ? 1 : 0, child: Material( color: Colors.transparent, shape: const CircleBorder(), clipBehavior: Clip.antiAlias, child: IconButton( icon: Icon( Icons.close, color: style.colorScheme.onSurface, ), onPressed: () => setState(() => _textController.clear()), ), ), ), isDense: true, ), onEditingComplete: () { context.read().add( BoardEvent.createGroup( _textController.text, ), ); _textController.clear(); setState(() => isEditing = false); }, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton( child: Text( LocaleKeys.button_cancel.tr(), style: style.textTheme.titleSmall?.copyWith( color: style.colorScheme.onSurface, ), ), onPressed: () => setState(() => isEditing = false), ), TextButton( child: Text( LocaleKeys.button_add.tr(), style: style.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, color: style.colorScheme.onSurface, ), ), onPressed: () { context.read().add( BoardEvent.createGroup( _textController.text, ), ); _textController.clear(); setState(() => isEditing = false); }, ), ], ), ], ), ), ) : ElevatedButton.icon( style: ElevatedButton.styleFrom( foregroundColor: style.colorScheme.onSurface, backgroundColor: style.colorScheme.secondary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ).copyWith( overlayColor: WidgetStateProperty.all(Theme.of(context).hoverColor), ), icon: const Icon(Icons.add), label: Text( LocaleKeys.board_column_newGroup.tr(), style: style.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.w600, ), ), onPressed: () => setState(() => isEditing = true), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/board/group_ext.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MobileHiddenGroupsColumn extends StatelessWidget { const MobileHiddenGroupsColumn({super.key, required this.padding}); final EdgeInsets padding; @override Widget build(BuildContext context) { final databaseController = context.read().databaseController; return BlocSelector( selector: (state) => state.maybeMap( orElse: () => null, ready: (value) => value.layoutSettings, ), builder: (context, layoutSettings) { if (layoutSettings == null) { return const SizedBox.shrink(); } final isCollapsed = layoutSettings.collapseHiddenGroups; return Container( padding: padding, child: AnimatedSize( alignment: AlignmentDirectional.topStart, curve: Curves.easeOut, duration: const Duration(milliseconds: 150), child: isCollapsed ? SizedBox( height: 50, child: _collapseExpandIcon(context, isCollapsed), ) : SizedBox( width: 180, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Spacer(), _collapseExpandIcon(context, isCollapsed), ], ), Text( LocaleKeys.board_hiddenGroupSection_sectionTitle.tr(), style: Theme.of(context) .textTheme .bodyMedium ?.copyWith( color: Theme.of(context).colorScheme.tertiary, ), ), const VSpace(8), Expanded( child: MobileHiddenGroupList( databaseController: databaseController, ), ), ], ), ), ), ); }, ); } Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) { return CircleAvatar( radius: 20, backgroundColor: Theme.of(context).colorScheme.secondary, child: IconButton( icon: FlowySvg( isCollapsed ? FlowySvgs.hamburger_s_s : FlowySvgs.pull_left_outlined_s, size: isCollapsed ? const Size.square(12) : const Size.square(40), ), onPressed: () => context .read() .add(BoardEvent.toggleHiddenSectionVisibility(!isCollapsed)), ), ); } } class MobileHiddenGroupList extends StatelessWidget { const MobileHiddenGroupList({ super.key, required this.databaseController, }); final DatabaseController databaseController; @override Widget build(BuildContext context) { return BlocBuilder( builder: (_, state) { return state.maybeMap( orElse: () => const SizedBox.shrink(), ready: (state) { return ReorderableListView.builder( itemCount: state.hiddenGroups.length, itemBuilder: (_, index) => MobileHiddenGroup( key: ValueKey(state.hiddenGroups[index].groupId), group: state.hiddenGroups[index], index: index, ), proxyDecorator: (child, index, animation) => BlocProvider.value( value: context.read(), child: Material(color: Colors.transparent, child: child), ), physics: const ClampingScrollPhysics(), onReorder: (oldIndex, newIndex) { if (oldIndex < newIndex) { newIndex--; } final fromGroupId = state.hiddenGroups[oldIndex].groupId; final toGroupId = state.hiddenGroups[newIndex].groupId; context .read() .add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); }, ); }, ); }, ); } } class MobileHiddenGroup extends StatelessWidget { const MobileHiddenGroup({ super.key, required this.group, required this.index, }); final GroupPB group; final int index; @override Widget build(BuildContext context) { final databaseController = context.read().databaseController; final primaryField = databaseController.fieldController.fieldInfos .firstWhereOrNull((element) => element.isPrimary)!; final cells = group.rows.map( (item) { final cellContext = databaseController.rowCache.loadCells(item).firstWhere( (cellContext) => cellContext.fieldId == primaryField.id, ); return TextButton( style: TextButton.styleFrom( textStyle: Theme.of(context).textTheme.bodyMedium, foregroundColor: AFThemeExtension.of(context).onBackground, visualDensity: VisualDensity.compact, ), child: CardCellBuilder( databaseController: context.read().databaseController, ).build( cellContext: cellContext, styleMap: {FieldType.RichText: _titleCellStyle(context)}, hasNotes: !item.isDocumentEmpty, ), onPressed: () { context.push( MobileRowDetailPage.routeName, extra: { MobileRowDetailPage.argRowId: item.id, MobileRowDetailPage.argDatabaseController: context.read().databaseController, }, ); }, ); }, ).toList(); return ExpansionTile( tilePadding: EdgeInsets.zero, childrenPadding: EdgeInsets.zero, title: Row( children: [ Expanded( child: Text( group.generateGroupName(databaseController), style: Theme.of(context).textTheme.bodyMedium, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), GestureDetector( child: const Padding( padding: EdgeInsets.all(4), child: FlowySvg( FlowySvgs.hide_m, size: Size.square(20), ), ), onTap: () => showFlowyMobileConfirmDialog( context, title: FlowyText(LocaleKeys.board_mobile_showGroup.tr()), content: FlowyText( LocaleKeys.board_mobile_showGroupContent.tr(), ), actionButtonTitle: LocaleKeys.button_yes.tr(), actionButtonColor: Theme.of(context).colorScheme.primary, onActionButtonPressed: () => context .read() .add(BoardEvent.setGroupVisibility(group, true)), ), ), ], ), children: cells, ); } TextCardCellStyle _titleCellStyle(BuildContext context) { return TextCardCellStyle( padding: EdgeInsets.zero, textStyle: Theme.of(context).textTheme.bodyMedium!, maxLines: 2, titleTextStyle: Theme.of(context) .textTheme .bodyMedium! .copyWith(fontSize: 11, overflow: TextOverflow.ellipsis), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/widgets.dart ================================================ export 'group_card_header.dart'; export 'mobile_board_trailing.dart'; export 'mobile_hidden_groups_column.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/card/card.dart ================================================ export 'card_detail/mobile_card_detail_screen.dart'; export 'mobile_card_content.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_banner_bloc.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/application/row/mobile_row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/shared/af_image.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import 'widgets/mobile_create_field_button.dart'; import 'widgets/mobile_row_property_list.dart'; class MobileRowDetailPage extends StatefulWidget { const MobileRowDetailPage({ super.key, required this.databaseController, required this.rowId, }); static const routeName = '/MobileRowDetailPage'; static const argDatabaseController = 'databaseController'; static const argRowId = 'rowId'; final DatabaseController databaseController; final String rowId; @override State createState() => _MobileRowDetailPageState(); } class _MobileRowDetailPageState extends State { late final MobileRowDetailBloc _bloc; late final PageController _pageController; String get viewId => widget.databaseController.viewId; RowCache get rowCache => widget.databaseController.rowCache; FieldController get fieldController => widget.databaseController.fieldController; @override void initState() { super.initState(); _bloc = MobileRowDetailBloc( databaseController: widget.databaseController, )..add(MobileRowDetailEvent.initial(widget.rowId)); final initialPage = rowCache.rowInfos .indexWhere((rowInfo) => rowInfo.rowId == widget.rowId); _pageController = PageController(initialPage: initialPage == -1 ? 0 : initialPage); } @override void dispose() { _bloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: _bloc, child: Scaffold( appBar: FlowyAppBar( leadingType: FlowyAppBarLeadingType.close, showDivider: false, actions: [ AppBarMoreButton( onTap: (_) => _showCardActions(context), ), ], ), body: BlocBuilder( buildWhen: (previous, current) => previous.rowInfos.length != current.rowInfos.length, builder: (context, state) { if (state.isLoading) { return const SizedBox.shrink(); } return PageView.builder( controller: _pageController, onPageChanged: (page) { final rowId = _bloc.state.rowInfos[page].rowId; _bloc.add(MobileRowDetailEvent.changeRowId(rowId)); }, itemCount: state.rowInfos.length, itemBuilder: (context, index) { if (state.rowInfos.isEmpty || state.currentRowId == null) { return const SizedBox.shrink(); } return MobileRowDetailPageContent( databaseController: widget.databaseController, rowMeta: state.rowInfos[index].rowMeta, ); }, ); }, ), floatingActionButton: RowDetailFab( onTapPrevious: () => _pageController.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.ease, ), onTapNext: () => _pageController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.ease, ), ), ), ); } void _showCardActions(BuildContext context) { showMobileBottomSheet( context, backgroundColor: AFThemeExtension.of(context).background, showDragHandle: true, builder: (_) => Column( mainAxisSize: MainAxisSize.min, children: [ MobileQuickActionButton( onTap: () => _performAction(viewId, _bloc.state.currentRowId, false), icon: FlowySvgs.duplicate_s, text: LocaleKeys.button_duplicate.tr(), ), const MobileQuickActionDivider(), MobileQuickActionButton( onTap: () => showMobileBottomSheet( context, title: LocaleKeys.grid_media_addFileMobile.tr(), showHeader: true, showCloseButton: true, showDragHandle: true, builder: (dialogContext) => Container( margin: const EdgeInsets.only(top: 12), constraints: const BoxConstraints( maxHeight: 340, minHeight: 80, ), child: FileUploadMenu( onInsertLocalFile: (files) async { context ..pop() ..pop(); if (_bloc.state.currentRowId == null) { return; } await insertLocalFiles( context, files, userProfile: _bloc.userProfile, documentId: _bloc.state.currentRowId!, onUploadSuccess: (file, path, isLocalMode) { _bloc.add( MobileRowDetailEvent.addCover( RowCoverPB( data: path, uploadType: isLocalMode ? FileUploadTypePB.LocalFile : FileUploadTypePB.CloudFile, coverType: CoverTypePB.FileCover, ), ), ); }, ); }, onInsertNetworkFile: (url) async => _onInsertNetworkFile(url, context), ), ), ), icon: FlowySvgs.add_cover_s, text: 'Add cover', ), const MobileQuickActionDivider(), MobileQuickActionButton( onTap: () => _performAction(viewId, _bloc.state.currentRowId, true), text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, icon: FlowySvgs.trash_s, iconColor: Theme.of(context).colorScheme.error, ), ], ), ); } void _performAction(String viewId, String? rowId, bool deleteRow) { if (rowId == null) { return; } deleteRow ? RowBackendService.deleteRows(viewId, [rowId]) : RowBackendService.duplicateRow(viewId, rowId); context ..pop() ..pop(); Fluttertoast.showToast( msg: deleteRow ? LocaleKeys.board_cardDeleted.tr() : LocaleKeys.board_cardDuplicated.tr(), gravity: ToastGravity.BOTTOM, ); } Future _onInsertNetworkFile( String url, BuildContext context, ) async { context ..pop() ..pop(); if (url.isEmpty) return; final uri = Uri.tryParse(url); if (uri == null) { return; } String name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; if (name.isEmpty && uri.pathSegments.length > 1) { name = uri.pathSegments[uri.pathSegments.length - 2]; } else if (name.isEmpty) { name = uri.host; } _bloc.add( MobileRowDetailEvent.addCover( RowCoverPB( data: url, uploadType: FileUploadTypePB.NetworkFile, coverType: CoverTypePB.FileCover, ), ), ); } } class RowDetailFab extends StatelessWidget { const RowDetailFab({ super.key, required this.onTapPrevious, required this.onTapNext, }); final VoidCallback onTapPrevious; final VoidCallback onTapNext; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final rowCount = state.rowInfos.length; final rowIndex = state.rowInfos.indexWhere( (rowInfo) => rowInfo.rowId == state.currentRowId, ); if (rowIndex == -1 || rowCount == 0) { return const SizedBox.shrink(); } final previousDisabled = rowIndex == 0; final nextDisabled = rowIndex == rowCount - 1; return IntrinsicWidth( child: Container( height: 48, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(26), boxShadow: const [ BoxShadow( offset: Offset(0, 8), blurRadius: 20, color: Color(0x191F2329), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox.square( dimension: 48, child: Material( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(26), borderOnForeground: false, child: previousDisabled ? Icon( Icons.chevron_left_outlined, color: Theme.of(context).disabledColor, ) : InkWell( borderRadius: BorderRadius.circular(26), onTap: onTapPrevious, child: const Icon(Icons.chevron_left_outlined), ), ), ), FlowyText.medium( "${rowIndex + 1} / $rowCount", fontSize: 14, ), SizedBox.square( dimension: 48, child: Material( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(26), borderOnForeground: false, child: nextDisabled ? Icon( Icons.chevron_right_outlined, color: Theme.of(context).disabledColor, ) : InkWell( borderRadius: BorderRadius.circular(26), onTap: onTapNext, child: const Icon(Icons.chevron_right_outlined), ), ), ), ], ), ), ); }, ); } } class MobileRowDetailPageContent extends StatefulWidget { const MobileRowDetailPageContent({ super.key, required this.databaseController, required this.rowMeta, }); final DatabaseController databaseController; final RowMetaPB rowMeta; @override State createState() => MobileRowDetailPageContentState(); } class MobileRowDetailPageContentState extends State { late final RowController rowController; late final EditableCellBuilder cellBuilder; String get viewId => widget.databaseController.viewId; RowCache get rowCache => widget.databaseController.rowCache; FieldController get fieldController => widget.databaseController.fieldController; ValueNotifier primaryFieldId = ValueNotifier(''); @override void initState() { super.initState(); rowController = RowController( rowMeta: widget.rowMeta, viewId: viewId, rowCache: rowCache, ); rowController.initialize(); cellBuilder = EditableCellBuilder( databaseController: widget.databaseController, ); } @override Widget build(BuildContext context) { return BlocProvider( create: (_) => RowDetailBloc( fieldController: fieldController, rowController: rowController, ), child: BlocBuilder( builder: (context, rowDetailState) => Column( children: [ if (rowDetailState.rowMeta.cover.data.isNotEmpty) ...[ GestureDetector( onTap: () => showMobileBottomSheet( context, backgroundColor: AFThemeExtension.of(context).background, showDragHandle: true, builder: (_) => Column( mainAxisSize: MainAxisSize.min, children: [ MobileQuickActionButton( onTap: () { context ..pop() ..read() .add(const RowDetailEvent.removeCover()); }, text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, icon: FlowySvgs.trash_s, iconColor: Theme.of(context).colorScheme.error, ), ], ), ), child: SizedBox( height: 200, width: double.infinity, child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, ), child: AFImage( url: rowDetailState.rowMeta.cover.data, uploadType: widget.rowMeta.cover.uploadType, userProfile: context.read().userProfile, ), ), ), ), ], BlocProvider( create: (context) => RowBannerBloc( viewId: viewId, fieldController: fieldController, rowMeta: rowController.rowMeta, )..add(const RowBannerEvent.initial()), child: BlocConsumer( listener: (context, state) { if (state.primaryField == null) { return; } primaryFieldId.value = state.primaryField!.id; }, builder: (context, state) { if (state.primaryField == null) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: cellBuilder.buildCustom( CellContext( rowId: rowController.rowId, fieldId: state.primaryField!.id, ), skinMap: EditableCellSkinMap(textSkin: _TitleSkin()), ), ); }, ), ), Expanded( child: ListView( padding: const EdgeInsets.only(top: 9, bottom: 100), children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: MobileRowPropertyList( databaseController: widget.databaseController, cellBuilder: cellBuilder, ), ), Padding( padding: const EdgeInsets.fromLTRB(6, 6, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (rowDetailState.numHiddenFields != 0) ...[ const ToggleHiddenFieldsVisibilityButton(), ], const VSpace(8.0), ValueListenableBuilder( valueListenable: primaryFieldId, builder: (context, primaryFieldId, child) { if (primaryFieldId.isEmpty) { return const SizedBox.shrink(); } return OpenRowPageButton( databaseController: widget.databaseController, cellContext: CellContext( rowId: rowController.rowId, fieldId: primaryFieldId, ), documentId: rowController.rowMeta.documentId, ); }, ), MobileRowDetailCreateFieldButton( viewId: viewId, fieldController: fieldController, ), ], ), ), ], ), ), ], ), ), ); } } class _TitleSkin extends IEditableTextCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, focusNode: focusNode, maxLines: null, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 23, fontWeight: FontWeight.w500, ), onEditingComplete: () { bloc.add(TextCellEvent.updateText(textEditingController.text)); }, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(vertical: 9), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, hintText: LocaleKeys.grid_row_titlePlaceholder.tr(), isDense: true, isCollapsed: true, ), onTapOutside: (event) => focusNode.unfocus(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_create_field_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileRowDetailCreateFieldButton extends StatelessWidget { const MobileRowDetailCreateFieldButton({ super.key, required this.viewId, required this.fieldController, }); final String viewId; final FieldController fieldController; @override Widget build(BuildContext context) { return ConstrainedBox( constraints: BoxConstraints( minWidth: double.infinity, maxHeight: GridSize.headerHeight, ), child: TextButton.icon( style: Theme.of(context).textButtonTheme.style?.copyWith( shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), ), overlayColor: WidgetStateProperty.all( Theme.of(context).hoverColor, ), alignment: AlignmentDirectional.centerStart, splashFactory: NoSplash.splashFactory, padding: const WidgetStatePropertyAll( EdgeInsets.symmetric(horizontal: 6, vertical: 2), ), ), label: FlowyText.medium( LocaleKeys.grid_field_newProperty.tr(), fontSize: 15, ), onPressed: () => mobileCreateFieldWorkflow(context, viewId), icon: const FlowySvg(FlowySvgs.add_m), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileRowPropertyList extends StatelessWidget { const MobileRowPropertyList({ super.key, required this.databaseController, required this.cellBuilder, }); final DatabaseController databaseController; final EditableCellBuilder cellBuilder; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final List visibleCells = state.visibleCells.where((cell) => !_isCellPrimary(cell)).toList(); return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: visibleCells.length, padding: EdgeInsets.zero, itemBuilder: (context, index) => _PropertyCell( key: ValueKey('row_detail_${visibleCells[index].fieldId}'), cellContext: visibleCells[index], fieldController: databaseController.fieldController, cellBuilder: cellBuilder, ), separatorBuilder: (_, __) => const VSpace(22), ); }, ); } bool _isCellPrimary(CellContext cell) => databaseController.fieldController.getField(cell.fieldId)!.isPrimary; } class _PropertyCell extends StatefulWidget { const _PropertyCell({ super.key, required this.cellContext, required this.fieldController, required this.cellBuilder, }); final CellContext cellContext; final FieldController fieldController; final EditableCellBuilder cellBuilder; @override State createState() => _PropertyCellState(); } class _PropertyCellState extends State<_PropertyCell> { @override Widget build(BuildContext context) { final fieldInfo = widget.fieldController.getField(widget.cellContext.fieldId)!; final cell = widget.cellBuilder .buildStyled(widget.cellContext, EditableCellStyle.mobileRowDetail); return Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ FieldIcon( fieldInfo: fieldInfo, ), const HSpace(6), Expanded( child: FlowyText.regular( fieldInfo.name, overflow: TextOverflow.ellipsis, fontSize: 14, figmaLineHeight: 16.0, color: Theme.of(context).hintColor, ), ), ], ), const VSpace(6), cell, ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/option_text_field.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class OptionTextField extends StatelessWidget { const OptionTextField({ super.key, required this.controller, this.autoFocus = false, required this.isPrimary, required this.fieldType, required this.onTextChanged, required this.onFieldTypeChanged, }); final TextEditingController controller; final bool autoFocus; final bool isPrimary; final FieldType fieldType; final void Function(String value) onTextChanged; final void Function(FieldType value) onFieldTypeChanged; @override Widget build(BuildContext context) { return FlowyOptionTile.textField( controller: controller, autofocus: autoFocus, textFieldPadding: const EdgeInsets.symmetric(horizontal: 12.0), onTextChanged: onTextChanged, leftIcon: GestureDetector( onTap: () async { if (isPrimary) { return; } final fieldType = await showFieldTypeGridBottomSheet( context, title: LocaleKeys.grid_field_editProperty.tr(), ); if (fieldType != null) { onFieldTypeChanged(fieldType); } }, child: Container( height: 38, width: 38, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: Theme.of(context).brightness == Brightness.light ? fieldType.mobileIconBackgroundColor : fieldType.mobileIconBackgroundColorDark, ), child: Center( child: FlowySvg( fieldType.svgData, size: const Size.square(22), ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class OpenRowPageButton extends StatefulWidget { const OpenRowPageButton({ super.key, required this.documentId, required this.databaseController, required this.cellContext, }); final String documentId; final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _OpenRowPageButtonState(); } class _OpenRowPageButtonState extends State { late final cellBloc = TextCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); ViewPB? view; @override void initState() { super.initState(); _preloadView(context, createDocumentIfMissed: true); } @override Widget build(BuildContext context) { return BlocBuilder( bloc: cellBloc, builder: (context, state) { return ConstrainedBox( constraints: BoxConstraints( minWidth: double.infinity, maxHeight: GridSize.buttonHeight, ), child: TextButton.icon( style: Theme.of(context).textButtonTheme.style?.copyWith( shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), ), overlayColor: WidgetStateProperty.all( Theme.of(context).hoverColor, ), alignment: AlignmentDirectional.centerStart, splashFactory: NoSplash.splashFactory, padding: const WidgetStatePropertyAll( EdgeInsets.symmetric(horizontal: 6), ), ), label: FlowyText.medium( LocaleKeys.grid_field_openRowDocument.tr(), fontSize: 15, ), icon: const Padding( padding: EdgeInsets.all(4.0), child: FlowySvg( FlowySvgs.full_view_s, size: Size.square(16.0), ), ), onPressed: () { final name = state.content; _openRowPage(context, name ?? ""); }, ), ); }, ); } Future _openRowPage(BuildContext context, String fieldName) async { Log.info('Open row page(${widget.documentId})'); if (view == null) { showToastNotification(message: 'Failed to open row page'); // reload the view again unawaited(_preloadView(context)); Log.error('Failed to open row page(${widget.documentId})'); return; } if (context.mounted) { // the document in row is an orphan document, so we don't add it to recent await context.pushView( view!, addInRecent: false, showMoreButton: false, fixedTitle: fieldName, tabs: [PickerTabType.emoji.name], ); } } // preload view to reduce the time to open the view Future _preloadView( BuildContext context, { bool createDocumentIfMissed = false, }) async { Log.info('Preload row page(${widget.documentId})'); final result = await ViewBackendService.getView(widget.documentId); view = result.fold((s) => s, (f) => null); if (view == null && createDocumentIfMissed) { // create view if not exists Log.info('Create row page(${widget.documentId})'); final result = await ViewBackendService.createOrphanView( name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), viewId: widget.documentId, layoutType: ViewLayoutPB.Document, ); view = result.fold((s) => s, (f) => null); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/widgets.dart ================================================ export 'mobile_create_field_button.dart'; export 'mobile_row_property_list.dart'; export 'option_text_field.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; class MobileCardContent extends StatelessWidget { const MobileCardContent({ super.key, required this.rowMeta, required this.cellBuilder, required this.cells, required this.styleConfiguration, required this.userProfile, }); final RowMetaPB rowMeta; final CardCellBuilder cellBuilder; final List cells; final RowCardStyleConfiguration styleConfiguration; final UserProfilePB? userProfile; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ if (rowMeta.cover.data.isNotEmpty) ...[ CardCover(cover: rowMeta.cover, userProfile: userProfile), ], Padding( padding: styleConfiguration.cardPadding, child: Column( children: [ ...cells.map( (cellMeta) => cellBuilder.build( cellContext: cellMeta.cellContext(), styleMap: mobileBoardCardCellStyleMap(context), hasNotes: !rowMeta.isDocumentEmpty, ), ), ], ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/mobile_date_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileDateCellEditScreen extends StatefulWidget { const MobileDateCellEditScreen({ super.key, required this.controller, this.showAsFullScreen = true, }); final DateCellController controller; final bool showAsFullScreen; static const routeName = '/edit_date_cell'; // the type is DateCellController static const dateCellController = 'date_cell_controller'; // bool value, default is true static const fullScreen = 'full_screen'; @override State createState() => _MobileDateCellEditScreenState(); } class _MobileDateCellEditScreenState extends State { @override Widget build(BuildContext context) => widget.showAsFullScreen ? _buildFullScreen() : _buildNotFullScreen(); Widget _buildFullScreen() { return Scaffold( appBar: FlowyAppBar(titleText: LocaleKeys.titleBar_date.tr()), body: _buildDatePicker(), ); } Widget _buildNotFullScreen() { return DraggableScrollableSheet( expand: false, snap: true, initialChildSize: 0.7, minChildSize: 0.4, snapSizes: const [0.4, 0.7, 1.0], builder: (_, controller) => Material( color: Colors.transparent, child: ListView( controller: controller, children: [ ColoredBox( color: Theme.of(context).colorScheme.surface, child: const Center(child: DragHandle()), ), const MobileDateHeader(), _buildDatePicker(), ], ), ), ); } Widget _buildDatePicker() { return BlocProvider( create: (_) => DateCellEditorBloc( reminderBloc: getIt(), cellController: widget.controller, ), child: BlocBuilder( builder: (context, state) { final dateCellBloc = context.read(); return MobileAppFlowyDatePicker( dateTime: state.dateTime, endDateTime: state.endDateTime, isRange: state.isRange, includeTime: state.includeTime, dateFormat: state.dateTypeOptionPB.dateFormat, timeFormat: state.dateTypeOptionPB.timeFormat, reminderOption: state.reminderOption, onDaySelected: (selectedDay) { dateCellBloc.add(DateCellEditorEvent.updateDateTime(selectedDay)); }, onRangeSelected: (start, end) { dateCellBloc.add(DateCellEditorEvent.updateDateRange(start, end)); }, onIsRangeChanged: (value, dateTime, endDateTime) { dateCellBloc.add( DateCellEditorEvent.setIsRange(value, dateTime, endDateTime), ); }, onIncludeTimeChanged: (value, dateTime, endDateTime) { dateCellBloc.add( DateCellEditorEvent.setIncludeTime( value, dateTime, endDateTime, ), ); }, onClearDate: () { dateCellBloc.add(const DateCellEditorEvent.clearDate()); }, onReminderSelected: (option) { dateCellBloc.add(DateCellEditorEvent.setReminderOption(option)); }, ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_create_field_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class MobileNewPropertyScreen extends StatefulWidget { const MobileNewPropertyScreen({ super.key, required this.viewId, this.fieldType, }); final String viewId; final FieldType? fieldType; static const routeName = '/new_property'; static const argViewId = 'view_id'; static const argFieldTypeId = 'field_type_id'; @override State createState() => _MobileNewPropertyScreenState(); } class _MobileNewPropertyScreenState extends State { late FieldOptionValues optionValues; @override void initState() { super.initState(); final type = widget.fieldType ?? FieldType.RichText; optionValues = FieldOptionValues( type: type, icon: "", name: type.i18n, ); } @override Widget build(BuildContext context) { return Scaffold( appBar: FlowyAppBar( centerTitle: true, titleText: LocaleKeys.grid_field_newProperty.tr(), leadingType: FlowyAppBarLeadingType.cancel, actions: [ _SaveButton( onSave: () { context.pop(optionValues); }, ), ], ), body: MobileFieldEditor( mode: FieldOptionMode.add, defaultValues: optionValues, onOptionValuesChanged: (optionValues) { this.optionValues = optionValues; }, ), ); } } class _SaveButton extends StatelessWidget { const _SaveButton({ required this.onSave, }); final VoidCallback onSave; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 16.0), child: Align( child: GestureDetector( onTap: onSave, child: FlowyText.medium( LocaleKeys.button_save.tr(), color: const Color(0xFF00ADDC), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/domain/field_backend_service.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class MobileEditPropertyScreen extends StatefulWidget { const MobileEditPropertyScreen({ super.key, required this.viewId, required this.field, }); final String viewId; final FieldInfo field; static const routeName = '/edit_property'; static const argViewId = 'view_id'; static const argField = 'field'; @override State createState() => _MobileEditPropertyScreenState(); } class _MobileEditPropertyScreenState extends State { late final FieldBackendService fieldService; late FieldOptionValues _fieldOptionValues; @override void initState() { super.initState(); _fieldOptionValues = FieldOptionValues.fromField(field: widget.field.field); fieldService = FieldBackendService( viewId: widget.viewId, fieldId: widget.field.id, ); } @override Widget build(BuildContext context) { final viewId = widget.viewId; final fieldId = widget.field.id; return PopScope( onPopInvokedWithResult: (didPop, _) { if (!didPop) { context.pop(_fieldOptionValues); } }, child: Scaffold( appBar: FlowyAppBar( titleText: LocaleKeys.grid_field_editProperty.tr(), onTapLeading: () => context.pop(_fieldOptionValues), ), body: MobileFieldEditor( mode: FieldOptionMode.edit, isPrimary: widget.field.isPrimary, defaultValues: FieldOptionValues.fromField(field: widget.field.field), actions: [ widget.field.visibility?.isVisibleState() ?? true ? FieldOptionAction.hide : FieldOptionAction.show, FieldOptionAction.duplicate, FieldOptionAction.delete, ], onOptionValuesChanged: (fieldOptionValues) async { await fieldService.updateField(name: fieldOptionValues.name); await FieldBackendService.updateFieldType( viewId: widget.viewId, fieldId: widget.field.id, fieldType: fieldOptionValues.type, ); final data = fieldOptionValues.getTypeOptionData(); if (data != null) { await FieldBackendService.updateFieldTypeOption( viewId: widget.viewId, fieldId: widget.field.id, typeOptionData: data, ); } setState(() { _fieldOptionValues = fieldOptionValues; }); }, onAction: (action) { final service = FieldServices( viewId: viewId, fieldId: fieldId, ); switch (action) { case FieldOptionAction.delete: fieldService.delete(); context.pop(); return; case FieldOptionAction.duplicate: fieldService.duplicate(); break; case FieldOptionAction.hide: service.hide(); break; case FieldOptionAction.show: service.show(); break; } context.pop(_fieldOptionValues); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:go_router/go_router.dart'; import 'mobile_create_field_screen.dart'; import 'mobile_edit_field_screen.dart'; import 'mobile_field_picker_list.dart'; import 'mobile_full_field_editor.dart'; import 'mobile_quick_field_editor.dart'; const mobileSupportedFieldTypes = [ FieldType.RichText, FieldType.Number, FieldType.SingleSelect, FieldType.MultiSelect, FieldType.DateTime, FieldType.Media, FieldType.URL, FieldType.Checkbox, FieldType.Checklist, FieldType.LastEditedTime, FieldType.CreatedTime, // FieldType.Time, ]; Future showFieldTypeGridBottomSheet( BuildContext context, { required String title, }) { return showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showCloseButton: true, elevation: 20, title: title, backgroundColor: AFThemeExtension.of(context).background, enableDraggableScrollable: true, builder: (context) { final typeOptionMenuItemValue = mobileSupportedFieldTypes .map( (fieldType) => TypeOptionMenuItemValue( value: fieldType, backgroundColor: Theme.of(context).brightness == Brightness.light ? fieldType.mobileIconBackgroundColor : fieldType.mobileIconBackgroundColorDark, text: fieldType.i18n, icon: fieldType.svgData, onTap: (context, fieldType) => Navigator.of(context).pop(fieldType), ), ) .toList(); return Padding( padding: EdgeInsets.all(16 * context.scale), child: TypeOptionMenu( values: typeOptionMenuItemValue, scaleFactor: context.scale, ), ); }, ); } /// Shows the field type grid and upon selection, allow users to edit the /// field's properties and saving it when the user clicks save. void mobileCreateFieldWorkflow( BuildContext context, String viewId, { OrderObjectPositionPB? position, }) async { final fieldType = await showFieldTypeGridBottomSheet( context, title: LocaleKeys.grid_field_newProperty.tr(), ); if (fieldType == null || !context.mounted) { return; } final optionValues = await context.push( Uri( path: MobileNewPropertyScreen.routeName, queryParameters: { MobileNewPropertyScreen.argViewId: viewId, MobileNewPropertyScreen.argFieldTypeId: fieldType.value.toString(), }, ).toString(), ); if (optionValues != null) { await optionValues.create(viewId: viewId, position: position); } } /// Used to edit a field. Future showEditFieldScreen( BuildContext context, String viewId, FieldInfo field, ) { return context.push( MobileEditPropertyScreen.routeName, extra: { MobileEditPropertyScreen.argViewId: viewId, MobileEditPropertyScreen.argField: field, }, ); } /// Shows some quick field options in a bottom sheet. void showQuickEditField( BuildContext context, String viewId, FieldController fieldController, FieldInfo fieldInfo, ) { showMobileBottomSheet( context, showDragHandle: true, builder: (context) { return SingleChildScrollView( child: QuickEditField( viewId: viewId, fieldController: fieldController, fieldInfo: fieldInfo, ), ); }, ); } /// Display a list of fields in the current database that users can choose from. Future showFieldPicker( BuildContext context, String title, String? selectedFieldId, FieldController fieldController, bool Function(FieldInfo fieldInfo) filterBy, ) { return showMobileBottomSheet( context, showDivider: false, builder: (context) { return MobileFieldPickerList( title: title, selectedFieldId: selectedFieldId, fieldController: fieldController, filterBy: filterBy, ); }, ); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_picker_list.dart ================================================ import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class MobileFieldPickerList extends StatefulWidget { MobileFieldPickerList({ super.key, required this.title, required this.selectedFieldId, required FieldController fieldController, required bool Function(FieldInfo fieldInfo) filterBy, }) : fields = fieldController.fieldInfos.where(filterBy).toList(); final String title; final String? selectedFieldId; final List fields; @override State createState() => _MobileFieldPickerListState(); } class _MobileFieldPickerListState extends State { String? newFieldId; @override void initState() { super.initState(); newFieldId = widget.selectedFieldId; } @override Widget build(BuildContext context) { return DraggableScrollableSheet( expand: false, snap: true, initialChildSize: 0.98, minChildSize: 0.98, maxChildSize: 0.98, builder: (context, scrollController) { return Column( mainAxisSize: MainAxisSize.min, children: [ const DragHandle(), _Header( title: widget.title, onDone: (context) => context.pop(newFieldId), ), SingleChildScrollView( controller: scrollController, child: ListView.builder( shrinkWrap: true, itemCount: widget.fields.length, itemBuilder: (context, index) => _FieldButton( field: widget.fields[index], showTopBorder: index == 0, isSelected: widget.fields[index].id == newFieldId, onSelect: (fieldId) => setState(() => newFieldId = fieldId), ), ), ), ], ); }, ); } } /// Same header as the one in showMobileBottomSheet, but allows popping the /// sheet with a value. class _Header extends StatelessWidget { const _Header({ required this.title, required this.onDone, }); final String title; final void Function(BuildContext context) onDone; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 4.0), child: SizedBox( height: 44.0, child: Stack( children: [ const Align( alignment: Alignment.centerLeft, child: AppBarBackButton(), ), Align( child: FlowyText.medium( title, fontSize: 16.0, ), ), Align( alignment: Alignment.centerRight, child: AppBarDoneButton( onTap: () => onDone(context), ), ), ], ), ), ); } } class _FieldButton extends StatelessWidget { const _FieldButton({ required this.field, required this.isSelected, required this.onSelect, required this.showTopBorder, }); final FieldInfo field; final bool isSelected; final void Function(String fieldId) onSelect; final bool showTopBorder; @override Widget build(BuildContext context) { return FlowyOptionTile.checkbox( text: field.name, isSelected: isSelected, leftIcon: FieldIcon( fieldInfo: field, dimension: 20, ), showTopBorder: showTopBorder, onTap: () => onSelect(field.id), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart ================================================ import 'dart:math'; import 'dart:typed_data'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; import 'package:appflowy/mobile/presentation/base/option_color_list.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:protobuf/protobuf.dart'; import 'mobile_field_bottom_sheets.dart'; enum FieldOptionMode { add, edit, } class FieldOptionValues { FieldOptionValues({ required this.type, required this.name, required this.icon, this.dateFormat, this.timeFormat, this.includeTime, this.numberFormat, this.selectOption = const [], }); factory FieldOptionValues.fromField({required FieldPB field}) { final fieldType = field.fieldType; final buffer = field.typeOptionData; return FieldOptionValues( type: fieldType, name: field.name, icon: field.icon, numberFormat: fieldType == FieldType.Number ? NumberTypeOptionPB.fromBuffer(buffer).format : null, dateFormat: switch (fieldType) { FieldType.DateTime => DateTypeOptionPB.fromBuffer(buffer).dateFormat, FieldType.LastEditedTime || FieldType.CreatedTime => TimestampTypeOptionPB.fromBuffer(buffer).dateFormat, _ => null }, timeFormat: switch (fieldType) { FieldType.DateTime => DateTypeOptionPB.fromBuffer(buffer).timeFormat, FieldType.LastEditedTime || FieldType.CreatedTime => TimestampTypeOptionPB.fromBuffer(buffer).timeFormat, _ => null }, includeTime: switch (fieldType) { FieldType.LastEditedTime || FieldType.CreatedTime => TimestampTypeOptionPB.fromBuffer(buffer).includeTime, _ => null }, selectOption: switch (fieldType) { FieldType.SingleSelect => SingleSelectTypeOptionPB.fromBuffer(buffer).options, FieldType.MultiSelect => MultiSelectTypeOptionPB.fromBuffer(buffer).options, _ => [], }, ); } FieldType type; String name; String icon; // FieldType.DateTime // FieldType.LastEditedTime // FieldType.CreatedTime DateFormatPB? dateFormat; TimeFormatPB? timeFormat; // FieldType.LastEditedTime // FieldType.CreatedTime bool? includeTime; // FieldType.Number NumberFormatPB? numberFormat; // FieldType.Select // FieldType.MultiSelect List selectOption; Future create({ required String viewId, OrderObjectPositionPB? position, }) async { await FieldBackendService.createField( viewId: viewId, fieldType: type, fieldName: name, typeOptionData: getTypeOptionData(), position: position, ); } Uint8List? getTypeOptionData() { switch (type) { case FieldType.RichText: case FieldType.URL: case FieldType.Checkbox: case FieldType.Time: return null; case FieldType.Number: return NumberTypeOptionPB( format: numberFormat, ).writeToBuffer(); case FieldType.DateTime: return DateTypeOptionPB( dateFormat: dateFormat, timeFormat: timeFormat, ).writeToBuffer(); case FieldType.SingleSelect: return SingleSelectTypeOptionPB( options: selectOption, ).writeToBuffer(); case FieldType.MultiSelect: return MultiSelectTypeOptionPB( options: selectOption, ).writeToBuffer(); case FieldType.Checklist: return ChecklistTypeOptionPB().writeToBuffer(); case FieldType.LastEditedTime: case FieldType.CreatedTime: return TimestampTypeOptionPB( dateFormat: dateFormat, timeFormat: timeFormat, includeTime: includeTime, ).writeToBuffer(); case FieldType.Media: return MediaTypeOptionPB().writeToBuffer(); default: throw UnimplementedError(); } } } enum FieldOptionAction { hide, show, duplicate, delete, } class MobileFieldEditor extends StatefulWidget { const MobileFieldEditor({ super.key, required this.mode, required this.defaultValues, required this.onOptionValuesChanged, this.actions = const [], this.onAction, this.isPrimary = false, }); final FieldOptionMode mode; final FieldOptionValues defaultValues; final void Function(FieldOptionValues values) onOptionValuesChanged; // only used in edit mode final List actions; final void Function(FieldOptionAction action)? onAction; // the primary field can't be deleted, duplicated, and changed type final bool isPrimary; @override State createState() => _MobileFieldEditorState(); } class _MobileFieldEditorState extends State { final controller = TextEditingController(); bool isFieldNameChanged = false; late FieldOptionValues values; @override void initState() { super.initState(); values = widget.defaultValues; controller.text = values.name; } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final option = _buildOption(); return Container( color: Theme.of(context).brightness == Brightness.light ? const Color(0xFFF7F8FB) : const Color(0xFF23262B), height: MediaQuery.of(context).size.height, child: SingleChildScrollView( child: Column( children: [ const _Divider(), OptionTextField( controller: controller, autoFocus: widget.mode == FieldOptionMode.add, fieldType: values.type, isPrimary: widget.isPrimary, onTextChanged: (value) { isFieldNameChanged = true; _updateOptionValues(name: value); }, onFieldTypeChanged: (type) { setState( () { if (widget.mode == FieldOptionMode.add && !isFieldNameChanged) { controller.text = type.i18n; _updateOptionValues(name: type.i18n); } _updateOptionValues(type: type); }, ); }, ), const _Divider(), if (!widget.isPrimary) ...[ _PropertyType( type: values.type, onSelected: (type) { setState( () { if (widget.mode == FieldOptionMode.add && !isFieldNameChanged) { controller.text = type.i18n; _updateOptionValues(name: type.i18n); } _updateOptionValues(type: type); }, ); }, ), const _Divider(), if (option.isNotEmpty) ...[ ...option, const _Divider(), ], ], ..._buildOptionActions(), const _Divider(), VSpace(MediaQuery.viewPaddingOf(context).bottom == 0 ? 28.0 : 16.0), ], ), ), ); } List _buildOption() { switch (values.type) { case FieldType.Number: return [ _NumberOption( selectedFormat: values.numberFormat ?? NumberFormatPB.Num, onSelected: (format) => setState( () => _updateOptionValues( numberFormat: format, ), ), ), ]; case FieldType.DateTime: return [ _DateOption( selectedFormat: values.dateFormat ?? DateFormatPB.Local, onSelected: (format) => _updateOptionValues( dateFormat: format, ), ), const _Divider(), _TimeOption( selectedFormat: values.timeFormat ?? TimeFormatPB.TwelveHour, onSelected: (format) => _updateOptionValues( timeFormat: format, ), ), ]; case FieldType.LastEditedTime: case FieldType.CreatedTime: return [ _DateOption( selectedFormat: values.dateFormat ?? DateFormatPB.Local, onSelected: (format) => _updateOptionValues( dateFormat: format, ), ), const _Divider(), _TimeOption( selectedFormat: values.timeFormat ?? TimeFormatPB.TwelveHour, onSelected: (format) => _updateOptionValues( timeFormat: format, ), ), const _Divider(), _IncludeTimeOption( includeTime: values.includeTime ?? true, onToggle: (includeTime) => _updateOptionValues( includeTime: includeTime, ), ), ]; case FieldType.SingleSelect: case FieldType.MultiSelect: return [ _SelectOption( mode: widget.mode, selectOption: values.selectOption, onAddOptions: (options) { if (values.selectOption.lastOrNull?.name.isEmpty == true) { // ignore the add action if the last one doesn't have a name return; } setState(() { _updateOptionValues( selectOption: values.selectOption + options, ); }); }, onUpdateOptions: (options) { _updateOptionValues(selectOption: options); }, ), ]; default: return []; } } List _buildOptionActions() { if (widget.mode == FieldOptionMode.add || widget.actions.isEmpty) { return []; } return [ if (widget.actions.contains(FieldOptionAction.hide) && !widget.isPrimary) FlowyOptionTile.text( text: LocaleKeys.grid_field_hide.tr(), leftIcon: const FlowySvg(FlowySvgs.m_field_hide_s), onTap: () => widget.onAction?.call(FieldOptionAction.hide), ), if (widget.actions.contains(FieldOptionAction.show)) FlowyOptionTile.text( text: LocaleKeys.grid_field_show.tr(), leftIcon: const FlowySvg(FlowySvgs.show_m, size: Size.square(16)), onTap: () => widget.onAction?.call(FieldOptionAction.show), ), if (widget.actions.contains(FieldOptionAction.duplicate) && !widget.isPrimary) FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.button_duplicate.tr(), leftIcon: const FlowySvg(FlowySvgs.m_field_copy_s), onTap: () => widget.onAction?.call(FieldOptionAction.duplicate), ), if (widget.actions.contains(FieldOptionAction.delete) && !widget.isPrimary) FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, leftIcon: FlowySvg( FlowySvgs.m_delete_s, color: Theme.of(context).colorScheme.error, ), onTap: () => widget.onAction?.call(FieldOptionAction.delete), ), ]; } void _updateOptionValues({ FieldType? type, String? name, DateFormatPB? dateFormat, TimeFormatPB? timeFormat, bool? includeTime, NumberFormatPB? numberFormat, List? selectOption, }) { if (type != null) { values.type = type; } if (name != null) { values.name = name; } if (dateFormat != null) { values.dateFormat = dateFormat; } if (timeFormat != null) { values.timeFormat = timeFormat; } if (includeTime != null) { values.includeTime = includeTime; } if (numberFormat != null) { values.numberFormat = numberFormat; } if (selectOption != null) { values.selectOption = selectOption; } widget.onOptionValuesChanged(values); } } class _PropertyType extends StatelessWidget { const _PropertyType({ required this.type, required this.onSelected, }); final FieldType type; final void Function(FieldType type) onSelected; @override Widget build(BuildContext context) { return FlowyOptionTile.text( text: LocaleKeys.grid_field_propertyType.tr(), trailing: Row( children: [ FlowySvg( type.svgData, size: const Size.square(22), color: Theme.of(context).hintColor, ), const HSpace(6.0), FlowyText( type.i18n, color: Theme.of(context).hintColor, ), const HSpace(4.0), FlowySvg( FlowySvgs.arrow_right_s, color: Theme.of(context).hintColor, size: const Size.square(18.0), ), ], ), onTap: () async { final fieldType = await showFieldTypeGridBottomSheet( context, title: LocaleKeys.grid_field_editProperty.tr(), ); if (fieldType != null) { onSelected(fieldType); } }, ); } } class _Divider extends StatelessWidget { const _Divider(); @override Widget build(BuildContext context) { return const VSpace( 24.0, ); } } class _DateOption extends StatefulWidget { const _DateOption({ required this.selectedFormat, required this.onSelected, }); final DateFormatPB selectedFormat; final Function(DateFormatPB format) onSelected; @override State<_DateOption> createState() => _DateOptionState(); } class _DateOptionState extends State<_DateOption> { DateFormatPB selectedFormat = DateFormatPB.Local; @override void initState() { super.initState(); selectedFormat = widget.selectedFormat; } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 4.0), child: FlowyText( LocaleKeys.grid_field_dateFormat.tr().toUpperCase(), fontSize: 13, color: Theme.of(context).hintColor, ), ), ...DateFormatPB.values.mapIndexed((index, format) { return FlowyOptionTile.checkbox( text: format.title(), isSelected: selectedFormat == format, showTopBorder: index == 0, onTap: () { widget.onSelected(format); setState(() { selectedFormat = format; }); }, ); }), ], ); } } class _TimeOption extends StatefulWidget { const _TimeOption({ required this.selectedFormat, required this.onSelected, }); final TimeFormatPB selectedFormat; final Function(TimeFormatPB format) onSelected; @override State<_TimeOption> createState() => _TimeOptionState(); } class _TimeOptionState extends State<_TimeOption> { TimeFormatPB selectedFormat = TimeFormatPB.TwelveHour; @override void initState() { super.initState(); selectedFormat = widget.selectedFormat; } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 4.0), child: FlowyText( LocaleKeys.grid_field_timeFormat.tr().toUpperCase(), fontSize: 13, color: Theme.of(context).hintColor, ), ), ...TimeFormatPB.values.mapIndexed((index, format) { return FlowyOptionTile.checkbox( text: format.title(), isSelected: selectedFormat == format, showTopBorder: index == 0, onTap: () { widget.onSelected(format); setState(() { selectedFormat = format; }); }, ); }), ], ); } } class _IncludeTimeOption extends StatefulWidget { const _IncludeTimeOption({ required this.includeTime, required this.onToggle, }); final bool includeTime; final void Function(bool includeTime) onToggle; @override State<_IncludeTimeOption> createState() => _IncludeTimeOptionState(); } class _IncludeTimeOptionState extends State<_IncludeTimeOption> { late bool includeTime = widget.includeTime; @override Widget build(BuildContext context) { return FlowyOptionTile.toggle( text: LocaleKeys.grid_field_includeTime.tr(), isSelected: includeTime, onValueChanged: (value) { widget.onToggle(value); setState(() { includeTime = value; }); }, ); } } class _NumberOption extends StatelessWidget { const _NumberOption({ required this.selectedFormat, required this.onSelected, }); final NumberFormatPB selectedFormat; final void Function(NumberFormatPB format) onSelected; @override Widget build(BuildContext context) { return FlowyOptionTile.text( text: LocaleKeys.grid_field_numberFormat.tr(), trailing: Row( children: [ FlowyText( selectedFormat.title(), color: Theme.of(context).hintColor, ), const HSpace(4.0), FlowySvg( FlowySvgs.arrow_right_s, color: Theme.of(context).hintColor, size: const Size.square(18.0), ), ], ), onTap: () { showMobileBottomSheet( context, backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) { return DraggableScrollableSheet( expand: false, snap: true, minChildSize: 0.5, builder: (context, scrollController) => _NumberFormatList( scrollController: scrollController, selectedFormat: selectedFormat, onSelected: (type) { onSelected(type); context.pop(); }, ), ); }, ); }, ); } } class _NumberFormatList extends StatefulWidget { const _NumberFormatList({ this.scrollController, required this.selectedFormat, required this.onSelected, }); final NumberFormatPB selectedFormat; final ScrollController? scrollController; final void Function(NumberFormatPB format) onSelected; @override State<_NumberFormatList> createState() => _NumberFormatListState(); } class _NumberFormatListState extends State<_NumberFormatList> { List formats = NumberFormatPB.values; @override Widget build(BuildContext context) { return ListView( controller: widget.scrollController, children: [ const Center( child: DragHandle(), ), Container( margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), height: 44.0, child: FlowySearchTextField( onChanged: (String value) { setState(() { formats = NumberFormatPB.values .where( (element) => element .title() .toLowerCase() .contains(value.toLowerCase()), ) .toList(); }); }, ), ), ...formats.mapIndexed( (index, element) => FlowyOptionTile.checkbox( text: element.title(), content: Expanded( child: Row( children: [ Padding( padding: const EdgeInsets.fromLTRB( 4.0, 16.0, 12.0, 16.0, ), child: FlowyText( element.title(), fontSize: 16, ), ), FlowyText( element.iconSymbol(), fontSize: 16, color: Theme.of(context).hintColor, ), widget.selectedFormat != element ? const HSpace(30.0) : const HSpace(6.0), ], ), ), isSelected: widget.selectedFormat == element, showTopBorder: false, onTap: () => widget.onSelected(element), ), ), ], ); } } // single select or multi select class _SelectOption extends StatelessWidget { _SelectOption({ required this.mode, required this.selectOption, required this.onAddOptions, required this.onUpdateOptions, }); final List selectOption; final void Function(List options) onAddOptions; final void Function(List options) onUpdateOptions; final FieldOptionMode mode; final random = Random(); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 4.0), child: FlowyText( LocaleKeys.grid_field_optionTitle.tr().toUpperCase(), fontSize: 13, color: Theme.of(context).hintColor, ), ), _SelectOptionList( selectOptions: selectOption, onUpdateOptions: onUpdateOptions, ), FlowyOptionTile.text( text: LocaleKeys.grid_field_addOption.tr(), leftIcon: const FlowySvg( FlowySvgs.add_s, size: Size.square(20), ), onTap: () { onAddOptions([ SelectOptionPB( id: uuid(), name: '', color: SelectOptionColorPB.valueOf( random.nextInt(SelectOptionColorPB.values.length), ), ), ]); }, ), ], ); } } class _SelectOptionList extends StatefulWidget { const _SelectOptionList({ required this.selectOptions, required this.onUpdateOptions, }); final List selectOptions; final void Function(List options) onUpdateOptions; @override State<_SelectOptionList> createState() => _SelectOptionListState(); } class _SelectOptionListState extends State<_SelectOptionList> { late List options; @override void initState() { super.initState(); options = widget.selectOptions; } @override void didUpdateWidget(covariant _SelectOptionList oldWidget) { super.didUpdateWidget(oldWidget); options = widget.selectOptions; } @override void dispose() { super.dispose(); } @override Widget build(BuildContext context) { if (widget.selectOptions.isEmpty) { return const SizedBox.shrink(); } return ListView( shrinkWrap: true, padding: EdgeInsets.zero, // disable the inner scroll physics, so the outer ListView can scroll physics: const NeverScrollableScrollPhysics(), children: widget.selectOptions .mapIndexed( (index, option) => _SelectOptionTile( option: option, showTopBorder: index == 0, showBottomBorder: index != widget.selectOptions.length - 1, onUpdateOption: (option) { _updateOption(index, option); }, ), ) .toList(), ); } void _updateOption(int index, SelectOptionPB option) { final options = [...this.options]; options[index] = option; this.options = options; widget.onUpdateOptions(options); } } class _SelectOptionTile extends StatefulWidget { const _SelectOptionTile({ required this.option, required this.showTopBorder, required this.showBottomBorder, required this.onUpdateOption, }); final SelectOptionPB option; final bool showTopBorder; final bool showBottomBorder; final void Function(SelectOptionPB option) onUpdateOption; @override State<_SelectOptionTile> createState() => _SelectOptionTileState(); } class _SelectOptionTileState extends State<_SelectOptionTile> { final TextEditingController controller = TextEditingController(); late SelectOptionPB option; @override void initState() { super.initState(); controller.text = widget.option.name; option = widget.option; } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FlowyOptionTile.textField( controller: controller, textFieldHintText: LocaleKeys.grid_field_typeANewOption.tr(), showTopBorder: widget.showTopBorder, showBottomBorder: widget.showBottomBorder, trailing: _SelectOptionColor( color: option.color, onChanged: (color) { setState(() { option.freeze(); option = option.rebuild((p0) => p0.color = color); widget.onUpdateOption(option); }); context.pop(); }, ), onTextChanged: (name) { setState(() { option.freeze(); option = option.rebuild((p0) => p0.name = name); widget.onUpdateOption(option); }); }, ); } } class _SelectOptionColor extends StatelessWidget { const _SelectOptionColor({ required this.color, required this.onChanged, }); final SelectOptionColorPB color; final void Function(SelectOptionColorPB) onChanged; @override Widget build(BuildContext context) { return GestureDetector( onTap: () { showMobileBottomSheet( context, showHeader: true, showCloseButton: true, title: LocaleKeys.grid_selectOption_colorPanelTitle.tr(), padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), builder: (context) { return OptionColorList( selectedColor: color, onSelectedColor: onChanged, ); }, ); }, child: Container( decoration: BoxDecoration( color: color.toColor(context), borderRadius: Corners.s10Border, ), width: 32, height: 32, alignment: Alignment.center, child: const FlowySvg( FlowySvgs.arrow_down_s, size: Size.square(20), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/domain/field_backend_service.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class QuickEditField extends StatefulWidget { const QuickEditField({ super.key, required this.viewId, required this.fieldController, required this.fieldInfo, }); final String viewId; final FieldController fieldController; final FieldInfo fieldInfo; @override State createState() => _QuickEditFieldState(); } class _QuickEditFieldState extends State { final TextEditingController controller = TextEditingController(); late final FieldServices service = FieldServices( viewId: widget.viewId, fieldId: widget.fieldInfo.field.id, ); late FieldVisibility fieldVisibility; @override void initState() { super.initState(); fieldVisibility = widget.fieldInfo.visibility ?? FieldVisibility.AlwaysShown; controller.text = widget.fieldInfo.field.name; } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (_) => FieldEditorBloc( viewId: widget.viewId, fieldController: widget.fieldController, fieldInfo: widget.fieldInfo, isNew: false, ), child: BlocConsumer( listenWhen: (previous, current) => previous.field.name != current.field.name, listener: (context, state) => controller.text = state.field.name, builder: (context, state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(16), OptionTextField( controller: controller, isPrimary: state.field.isPrimary, fieldType: state.field.fieldType, onTextChanged: (text) { context .read() .add(FieldEditorEvent.renameField(text)); }, onFieldTypeChanged: (fieldType) { context .read() .add(FieldEditorEvent.switchFieldType(fieldType)); }, ), const _Divider(), FlowyOptionTile.text( text: LocaleKeys.grid_field_editProperty.tr(), leftIcon: const FlowySvg(FlowySvgs.m_field_edit_s), onTap: () { showEditFieldScreen( context, widget.viewId, state.field, ); context.pop(); }, ), if (!widget.fieldInfo.isPrimary) ...[ FlowyOptionTile.text( showTopBorder: false, text: fieldVisibility.isVisibleState() ? LocaleKeys.grid_field_hide.tr() : LocaleKeys.grid_field_show.tr(), leftIcon: const FlowySvg(FlowySvgs.m_field_hide_s), onTap: () async { context.pop(); if (fieldVisibility.isVisibleState()) { await service.hide(); } else { await service.hide(); } }, ), FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_field_insertLeft.tr(), leftIcon: const FlowySvg(FlowySvgs.m_filed_insert_left_s), onTap: () { context.pop(); mobileCreateFieldWorkflow( context, widget.viewId, position: OrderObjectPositionPB( position: OrderObjectPositionTypePB.Before, objectId: widget.fieldInfo.id, ), ); }, ), ], FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_field_insertRight.tr(), leftIcon: const FlowySvg(FlowySvgs.m_filed_insert_right_s), onTap: () { context.pop(); mobileCreateFieldWorkflow( context, widget.viewId, position: OrderObjectPositionPB( position: OrderObjectPositionTypePB.After, objectId: widget.fieldInfo.id, ), ); }, ), if (!widget.fieldInfo.isPrimary) ...[ FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.button_duplicate.tr(), leftIcon: const FlowySvg(FlowySvgs.m_field_copy_s), onTap: () { context.pop(); service.duplicate(); }, ), FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, leftIcon: FlowySvg( FlowySvgs.m_field_delete_s, color: Theme.of(context).colorScheme.error, ), onTap: () { context.pop(); service.delete(); }, ), ], ], ); }, ), ); } } class _Divider extends StatelessWidget { const _Divider(); @override Widget build(BuildContext context) { return const VSpace(20); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_empty.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; class MobileCalendarEventsEmpty extends StatelessWidget { const MobileCalendarEventsEmpty({super.key}); @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyText( LocaleKeys.calendar_mobileEventScreen_emptyTitle.tr(), fontWeight: FontWeight.w700, fontSize: 14, ), const VSpace(8), FlowyText.regular( LocaleKeys.calendar_mobileEventScreen_emptyBody.tr(), textAlign: TextAlign.center, maxLines: 2, ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_screen.dart ================================================ import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_empty.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_card.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileCalendarEventsScreen extends StatefulWidget { const MobileCalendarEventsScreen({ super.key, required this.calendarBloc, required this.date, required this.events, required this.rowCache, required this.viewId, }); final CalendarBloc calendarBloc; final DateTime date; final List events; final RowCache rowCache; final String viewId; static const routeName = '/calendar_events'; // GoRouter Arguments static const calendarBlocKey = 'calendar_bloc'; static const calendarDateKey = 'date'; static const calendarEventsKey = 'events'; static const calendarRowCacheKey = 'row_cache'; static const calendarViewIdKey = 'view_id'; @override State createState() => _MobileCalendarEventsScreenState(); } class _MobileCalendarEventsScreenState extends State { late final List _events = widget.events; @override Widget build(BuildContext context) { return Scaffold( floatingActionButton: FloatingActionButton( key: const Key('add_event_fab'), elevation: 6, backgroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Theme.of(context).colorScheme.onPrimary, onPressed: () => widget.calendarBloc.add(CalendarEvent.createEvent(widget.date)), child: const Text('+'), ), appBar: FlowyAppBar( titleText: DateFormat.yMMMMd(context.locale.toLanguageTag()) .format(widget.date), ), body: BlocProvider.value( value: widget.calendarBloc, child: BlocBuilder( buildWhen: (p, c) => p.newEvent != c.newEvent && c.newEvent?.date.withoutTime == widget.date, builder: (context, state) { if (state.newEvent?.event != null && _events .none((e) => e.eventId == state.newEvent!.event!.eventId) && state.newEvent!.date.withoutTime == widget.date) { _events.add(state.newEvent!.event!); } if (_events.isEmpty) { return const MobileCalendarEventsEmpty(); } return SingleChildScrollView( child: Column( children: [ const VSpace(10), ..._events.map((event) { return EventCard( databaseController: widget.calendarBloc.databaseController, event: event, constraints: const BoxConstraints.expand(), autoEdit: false, isDraggable: false, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 3, ), ); }), const VSpace(24), ], ), ); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_screen.dart ================================================ import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; class MobileCalendarScreen extends StatelessWidget { const MobileCalendarScreen({ super.key, required this.id, this.title, }); /// view id final String id; final String? title; static const routeName = '/calendar'; static const viewId = 'id'; static const viewTitle = 'title'; @override Widget build(BuildContext context) { return MobileViewPage( id: id, title: title, viewLayout: ViewLayoutPB.Document, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_grid_screen.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; class MobileGridScreen extends StatelessWidget { const MobileGridScreen({ super.key, required this.id, this.title, this.arguments, }); /// view id final String id; final String? title; final Map? arguments; static const routeName = '/grid'; static const viewId = 'id'; static const viewTitle = 'title'; static const viewArgs = 'arguments'; @override Widget build(BuildContext context) { return MobileViewPage( id: id, title: title, viewLayout: ViewLayoutPB.Grid, arguments: arguments, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_field_list.dart ================================================ import 'dart:ui'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/setting/property_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../field/mobile_field_bottom_sheets.dart'; class MobileDatabaseFieldList extends StatelessWidget { const MobileDatabaseFieldList({ super.key, required this.databaseController, required this.canCreate, }); final DatabaseController databaseController; final bool canCreate; @override Widget build(BuildContext context) { return _MobileDatabaseFieldListBody( databaseController: databaseController, viewId: context.read().state.view.id, canCreate: canCreate, ); } } class _MobileDatabaseFieldListBody extends StatelessWidget { const _MobileDatabaseFieldListBody({ required this.databaseController, required this.viewId, required this.canCreate, }); final DatabaseController databaseController; final String viewId; final bool canCreate; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => DatabasePropertyBloc( viewId: viewId, fieldController: databaseController.fieldController, )..add(const DatabasePropertyEvent.initial()), child: BlocBuilder( builder: (context, state) { if (state.fieldContexts.isEmpty) { return const SizedBox.shrink(); } final fields = [...state.fieldContexts]; final firstField = fields.removeAt(0); final firstCell = DatabaseFieldListTile( key: ValueKey(firstField.id), viewId: viewId, fieldController: databaseController.fieldController, fieldInfo: firstField, showTopBorder: false, ); final cells = fields .mapIndexed( (index, field) => DatabaseFieldListTile( key: ValueKey(field.id), viewId: viewId, fieldController: databaseController.fieldController, fieldInfo: field, showTopBorder: false, ), ) .toList(); return ReorderableListView.builder( proxyDecorator: (_, index, anim) { final field = fields[index]; return AnimatedBuilder( animation: anim, builder: (BuildContext context, Widget? child) { final double animValue = Curves.easeInOut.transform(anim.value); final double scale = lerpDouble(1, 1.05, animValue)!; return Transform.scale( scale: scale, child: Material( child: DatabaseFieldListTile( key: ValueKey(field.id), viewId: viewId, fieldController: databaseController.fieldController, fieldInfo: field, showTopBorder: true, ), ), ); }, ); }, shrinkWrap: true, onReorder: (from, to) { from++; to++; context .read() .add(DatabasePropertyEvent.moveField(from, to)); }, header: firstCell, footer: canCreate ? Padding( padding: const EdgeInsets.only(top: 20), child: _NewDatabaseFieldTile(viewId: viewId), ) : null, itemCount: cells.length, itemBuilder: (context, index) => cells[index], padding: EdgeInsets.only( bottom: context.bottomSheetPadding(ignoreViewPadding: false), ), ); }, ), ); } } class DatabaseFieldListTile extends StatelessWidget { const DatabaseFieldListTile({ super.key, required this.fieldInfo, required this.viewId, required this.fieldController, required this.showTopBorder, }); final FieldInfo fieldInfo; final String viewId; final FieldController fieldController; final bool showTopBorder; @override Widget build(BuildContext context) { if (fieldInfo.field.isPrimary) { return FlowyOptionTile.text( text: fieldInfo.name, leftIcon: FieldIcon( fieldInfo: fieldInfo, dimension: 20, ), onTap: () => showEditFieldScreen(context, viewId, fieldInfo), showTopBorder: showTopBorder, ); } else { return FlowyOptionTile.toggle( isSelected: fieldInfo.visibility?.isVisibleState() ?? false, text: fieldInfo.name, leftIcon: FieldIcon( fieldInfo: fieldInfo, dimension: 20, ), showTopBorder: showTopBorder, onTap: () => showEditFieldScreen(context, viewId, fieldInfo), onValueChanged: (value) { final newVisibility = fieldInfo.visibility!.toggle(); context.read().add( DatabasePropertyEvent.setFieldVisibility( fieldInfo.id, newVisibility, ), ); }, ); } } } class _NewDatabaseFieldTile extends StatelessWidget { const _NewDatabaseFieldTile({required this.viewId}); final String viewId; @override Widget build(BuildContext context) { return FlowyOptionTile.text( text: LocaleKeys.grid_field_newProperty.tr(), leftIcon: FlowySvg( FlowySvgs.add_s, size: const Size.square(20), color: Theme.of(context).hintColor, ), textColor: Theme.of(context).hintColor, onTap: () => mobileCreateFieldWorkflow(context, viewId), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_bottom_sheet.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/database/view/database_filter_condition_list.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/filter/select_option_loader.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart'; import 'package:appflowy/util/debounce.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:protobuf/protobuf.dart' hide FieldInfo; import 'package:time/time.dart'; import 'database_filter_bottom_sheet_cubit.dart'; class MobileFilterEditor extends StatefulWidget { const MobileFilterEditor({super.key}); @override State createState() => _MobileFilterEditorState(); } class _MobileFilterEditorState extends State { final pageController = PageController(); final scrollController = ScrollController(); @override void dispose() { pageController.dispose(); scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (context) => MobileFilterEditorCubit( pageController: pageController, ), child: Column( children: [ const _Header(), SizedBox( height: 400, child: PageView.builder( controller: pageController, itemCount: 2, physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { return switch (index) { 0 => _ActiveFilters(scrollController: scrollController), 1 => const _FilterDetail(), _ => const SizedBox.shrink(), }; }, ), ), ], ), ); } } class _Header extends StatelessWidget { const _Header(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SizedBox( height: 44.0, child: Stack( children: [ if (_isBackButtonShown(state)) Align( alignment: Alignment.centerLeft, child: AppBarBackButton( padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), onTap: () => context .read() .returnToOverview(), ), ), Align( child: FlowyText.medium( LocaleKeys.grid_settings_filter.tr(), fontSize: 16.0, ), ), if (_isSaveButtonShown(state)) Align( alignment: Alignment.centerRight, child: AppBarSaveButton( padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), enable: _isSaveButtonEnabled(state), onTap: () => _saveOnTapHandler(context, state), ), ), ], ), ); }, ); } bool _isBackButtonShown(MobileFilterEditorState state) { return state.maybeWhen( overview: (_) => false, orElse: () => true, ); } bool _isSaveButtonShown(MobileFilterEditorState state) { return state.maybeWhen( editCondition: (filterId, newFilter, showSave) => showSave, editContent: (_, __) => true, orElse: () => false, ); } bool _isSaveButtonEnabled(MobileFilterEditorState state) { return state.maybeWhen( editCondition: (_, __, enableSave) => enableSave, editContent: (_, __) => true, orElse: () => false, ); } void _saveOnTapHandler(BuildContext context, MobileFilterEditorState state) { state.maybeWhen( editCondition: (filterId, newFilter, _) { context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, editContent: (filterId, newFilter) { context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, orElse: () {}, ); context.read().returnToOverview(); } } class _ActiveFilters extends StatelessWidget { const _ActiveFilters({ required this.scrollController, }); final ScrollController scrollController; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom, ), child: Column( children: [ Expanded( child: state.filters.isEmpty ? _emptyBackground(context) : _filterList(context, state), ), const VSpace(12), const _CreateFilterButton(), ], ), ); }, ); } Widget _emptyBackground(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.filter_s, size: const Size.square(60), color: Theme.of(context).hintColor, ), FlowyText( LocaleKeys.grid_filter_empty.tr(), color: Theme.of(context).hintColor, ), ], ), ); } Widget _filterList(BuildContext context, FilterEditorState state) { WidgetsBinding.instance.addPostFrameCallback((_) { context.read().state.maybeWhen( overview: (scrollToBottom) { if (scrollToBottom && scrollController.hasClients) { scrollController .jumpTo(scrollController.position.maxScrollExtent); context.read().returnToOverview(); } }, orElse: () {}, ); }); return ListView.separated( controller: scrollController, padding: const EdgeInsets.symmetric( horizontal: 16, ), itemCount: state.filters.length, itemBuilder: (context, index) { final filter = state.filters[index]; final field = context .read() .state .fields .firstWhereOrNull((field) => field.id == filter.fieldId); return field == null ? const SizedBox.shrink() : _FilterItem(filter: filter, field: field); }, separatorBuilder: (context, index) => const VSpace(12.0), ); } } class _CreateFilterButton extends StatelessWidget { const _CreateFilterButton(); @override Widget build(BuildContext context) { return Container( height: 44, width: double.infinity, margin: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide( width: 0.5, color: Theme.of(context).dividerColor, ), ), borderRadius: Corners.s10Border, ), child: InkWell( onTap: () { if (context.read().state.fields.isEmpty) { Fluttertoast.showToast( msg: LocaleKeys.grid_filter_cannotFindCreatableField.tr(), gravity: ToastGravity.BOTTOM, ); } else { context.read().startCreatingFilter(); } }, borderRadius: Corners.s10Border, child: Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ const FlowySvg( FlowySvgs.add_s, size: Size.square(16), ), const HSpace(6.0), FlowyText( LocaleKeys.grid_filter_addFilter.tr(), fontSize: 15, ), ], ), ), ), ); } } class _FilterItem extends StatelessWidget { const _FilterItem({ required this.filter, required this.field, }); final DatabaseFilter filter; final FieldInfo field; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).hoverColor, borderRadius: BorderRadius.circular(12), ), child: Stack( children: [ Padding( padding: const EdgeInsets.symmetric( vertical: 14, horizontal: 8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: FlowyText.medium( LocaleKeys.grid_filter_where.tr(), fontSize: 15, ), ), const VSpace(10), Row( children: [ Expanded( child: FilterItemInnerButton( onTap: () => context .read() .startEditingFilterField(filter.filterId), icon: field.fieldType.svgData, child: FlowyText( field.name, overflow: TextOverflow.ellipsis, ), ), ), const HSpace(6), Expanded( child: FilterItemInnerButton( onTap: () => context .read() .startEditingFilterCondition( filter.filterId, filter, filter.fieldType == FieldType.DateTime, ), child: FlowyText( filter.conditionName, overflow: TextOverflow.ellipsis, ), ), ), ], ), if (filter.canAttachContent) ...[ const VSpace(6), filter.getMobileDescription( field, onExpand: () => context .read() .startEditingFilterContent(filter.filterId, filter), onUpdate: (newFilter) => context .read() .add(FilterEditorEvent.updateFilter(newFilter)), ), ], ], ), ), Positioned( right: 8, top: 6, child: _deleteButton(context), ), ], ), ); } Widget _deleteButton(BuildContext context) { return InkWell( onTap: () => context .read() .add(FilterEditorEvent.deleteFilter(filter.filterId)), // steal from the container LongClickReorderWidget thing onLongPress: () {}, borderRadius: BorderRadius.circular(10), child: SizedBox.square( dimension: 34, child: Center( child: FlowySvg( FlowySvgs.trash_m, size: const Size.square(18), color: Theme.of(context).hintColor, ), ), ), ); } } class FilterItemInnerButton extends StatelessWidget { const FilterItemInnerButton({ super.key, required this.onTap, required this.child, this.icon, }); final VoidCallback onTap; final FlowySvgData? icon; final Widget child; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( height: 44, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide( width: 0.5, color: Theme.of(context).dividerColor, ), ), borderRadius: Corners.s10Border, color: Theme.of(context).colorScheme.surface, ), padding: const EdgeInsets.symmetric(horizontal: 12), child: Center( child: SeparatedRow( separatorBuilder: () => const HSpace(6.0), children: [ if (icon != null) FlowySvg( icon!, size: const Size.square(16), ), Expanded(child: child), FlowySvg( FlowySvgs.icon_right_small_ccm_outlined_s, size: const Size.square(14), color: Theme.of(context).hintColor, ), ], ), ), ), ); } } class FilterItemInnerTextField extends StatefulWidget { const FilterItemInnerTextField({ super.key, required this.content, required this.enabled, required this.onSubmitted, }); final String content; final bool enabled; final void Function(String) onSubmitted; @override State createState() => _FilterItemInnerTextFieldState(); } class _FilterItemInnerTextFieldState extends State { late final TextEditingController textController = TextEditingController(text: widget.content); final FocusNode focusNode = FocusNode(); final Debounce debounce = Debounce(duration: 300.milliseconds); @override void dispose() { focusNode.dispose(); textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: 44, child: TextField( enabled: widget.enabled, focusNode: focusNode, controller: textController, onSubmitted: widget.onSubmitted, onChanged: (value) => debounce.call(() => widget.onSubmitted(value)), onTapOutside: (_) => focusNode.unfocus(), decoration: InputDecoration( filled: true, fillColor: widget.enabled ? Theme.of(context).colorScheme.surface : Theme.of(context).disabledColor, enabledBorder: _getBorder(Theme.of(context).dividerColor), border: _getBorder(Theme.of(context).dividerColor), focusedBorder: _getBorder(Theme.of(context).colorScheme.primary), contentPadding: const EdgeInsets.symmetric(horizontal: 12), ), ), ); } InputBorder _getBorder(Color color) { return OutlineInputBorder( borderSide: BorderSide( width: 0.5, color: color, ), borderRadius: Corners.s10Border, ); } } class _FilterDetail extends StatelessWidget { const _FilterDetail(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return state.maybeWhen( create: () { return _FilterableFieldList( onSelectField: (field) { context .read() .add(FilterEditorEvent.createFilter(field)); context.read().returnToOverview( scrollToBottom: true, ); }, ); }, editField: (filterId) { return _FilterableFieldList( onSelectField: (field) { final filter = context .read() .state .filters .firstWhereOrNull((filter) => filter.filterId == filterId); if (filter != null && field.id != filter.fieldId) { context.read().add( FilterEditorEvent.changeFilteringField(filterId, field), ); } context.read().returnToOverview(); }, ); }, editCondition: (filterId, newFilter, showSave) { return _FilterConditionList( filterId: filterId, onSelect: (newFilter) { context .read() .add(FilterEditorEvent.updateFilter(newFilter)); context.read().returnToOverview(); }, ); }, editContent: (filterId, filter) { return _FilterContentEditor( filter: filter, onUpdateFilter: (newFilter) { context.read().updateFilter(newFilter); }, ); }, orElse: () => const SizedBox.shrink(), ); }, ); } } class _FilterableFieldList extends StatelessWidget { const _FilterableFieldList({ required this.onSelectField, }); final void Function(FieldInfo field) onSelectField; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(4.0), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: FlowyText( LocaleKeys.grid_settings_filterBy.tr().toUpperCase(), fontSize: 13, color: Theme.of(context).hintColor, ), ), const VSpace(4.0), const Divider( height: 0.5, thickness: 0.5, ), Expanded( child: BlocBuilder( builder: (context, blocState) { return ListView.builder( itemCount: blocState.fields.length, itemBuilder: (context, index) { return FlowyOptionTile.text( text: blocState.fields[index].name, leftIcon: FieldIcon( fieldInfo: blocState.fields[index], ), showTopBorder: false, onTap: () => onSelectField(blocState.fields[index]), ); }, ); }, ), ), ], ); } } class _FilterConditionList extends StatelessWidget { const _FilterConditionList({ required this.filterId, required this.onSelect, }); final String filterId; final void Function(DatabaseFilter filter) onSelect; @override Widget build(BuildContext context) { return BlocSelector( selector: (state) => state.filters.firstWhereOrNull( (filter) => filter.filterId == filterId, ), builder: (context, filter) { if (filter == null) { return const SizedBox.shrink(); } if (filter is DateTimeFilter?) { return _DateTimeFilterConditionList( onSelect: (filter) { if (filter.fieldType == FieldType.DateTime) { context.read().updateFilter(filter); } else { onSelect(filter); } }, ); } final conditions = FilterCondition.fromFieldType(filter.fieldType).conditions; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(4.0), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: FlowyText( LocaleKeys.grid_filter_conditon.tr().toUpperCase(), fontSize: 13, color: Theme.of(context).hintColor, ), ), const VSpace(4.0), const Divider( height: 0.5, thickness: 0.5, ), Expanded( child: ListView.builder( itemCount: conditions.length, itemBuilder: (context, index) { return FlowyOptionTile.checkbox( text: conditions[index].$2, showTopBorder: false, isSelected: _isSelected(filter, conditions[index].$1), onTap: () { final newFilter = _updateCondition(filter, conditions[index].$1); onSelect(newFilter); }, ); }, ), ), ], ); }, ); } bool _isSelected(DatabaseFilter filter, ProtobufEnum condition) { return switch (filter.fieldType) { FieldType.RichText || FieldType.URL => (filter as TextFilter).condition == condition, FieldType.Number => (filter as NumberFilter).condition == condition, FieldType.SingleSelect || FieldType.MultiSelect => (filter as SelectOptionFilter).condition == condition, FieldType.Checkbox => (filter as CheckboxFilter).condition == condition, FieldType.Checklist => (filter as ChecklistFilter).condition == condition, _ => false, }; } DatabaseFilter _updateCondition( DatabaseFilter filter, ProtobufEnum condition, ) { return switch (filter.fieldType) { FieldType.RichText || FieldType.URL => (filter as TextFilter) .copyWith(condition: condition as TextFilterConditionPB), FieldType.Number => (filter as NumberFilter) .copyWith(condition: condition as NumberFilterConditionPB), FieldType.SingleSelect || FieldType.MultiSelect => (filter as SelectOptionFilter) .copyWith(condition: condition as SelectOptionFilterConditionPB), FieldType.Checkbox => (filter as CheckboxFilter) .copyWith(condition: condition as CheckboxFilterConditionPB), FieldType.Checklist => (filter as ChecklistFilter) .copyWith(condition: condition as ChecklistFilterConditionPB), _ => filter, }; } } class _DateTimeFilterConditionList extends StatelessWidget { const _DateTimeFilterConditionList({ required this.onSelect, }); final void Function(DatabaseFilter) onSelect; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return state.maybeWhen( orElse: () => const SizedBox.shrink(), editCondition: (filterId, newFilter, _) { final filter = newFilter as DateTimeFilter; final conditions = DateTimeFilterCondition.availableConditionsForFieldType( filter.fieldType, ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(4.0), if (filter.fieldType == FieldType.DateTime) _DateTimeFilterIsStartSelector( isStart: filter.condition.isStart, onSelect: (newValue) { final newFilter = filter.copyWithCondition( isStart: newValue, condition: filter.condition.toCondition(), ); onSelect(newFilter); }, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: FlowyText( LocaleKeys.grid_filter_conditon.tr().toUpperCase(), fontSize: 13, color: Theme.of(context).hintColor, ), ), const VSpace(4.0), const Divider( height: 0.5, thickness: 0.5, ), Expanded( child: ListView.builder( itemCount: conditions.length, itemBuilder: (context, index) { return FlowyOptionTile.checkbox( text: conditions[index].filterName, showTopBorder: false, isSelected: filter.condition.toCondition() == conditions[index], onTap: () { final newFilter = filter.copyWithCondition( isStart: filter.condition.isStart, condition: conditions[index], ); onSelect(newFilter); }, ); }, ), ), ], ); }, ); }, ); } } class _DateTimeFilterIsStartSelector extends StatelessWidget { const _DateTimeFilterIsStartSelector({ required this.isStart, required this.onSelect, }); final bool isStart; final void Function(bool isStart) onSelect; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 20), child: DefaultTabController( length: 2, initialIndex: isStart ? 0 : 1, child: Container( padding: const EdgeInsets.all(3.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), color: Theme.of(context).hoverColor, ), child: TabBar( indicatorSize: TabBarIndicatorSize.label, labelPadding: EdgeInsets.zero, padding: EdgeInsets.zero, indicatorWeight: 0, indicator: BoxDecoration( borderRadius: BorderRadius.circular(10), color: Theme.of(context).colorScheme.surface, ), splashFactory: NoSplash.splashFactory, overlayColor: const WidgetStatePropertyAll( Colors.transparent, ), onTap: (index) => onSelect(index == 0), tabs: [ _tab(LocaleKeys.grid_dateFilter_startDate.tr()), _tab(LocaleKeys.grid_dateFilter_endDate.tr()), ], ), ), ), ); } Tab _tab(String name) { return Tab( height: 34, child: Center( child: FlowyText( name, fontSize: 14, ), ), ); } } class _FilterContentEditor extends StatelessWidget { const _FilterContentEditor({ required this.filter, required this.onUpdateFilter, }); final DatabaseFilter filter; final void Function(DatabaseFilter) onUpdateFilter; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final field = state.fields .firstWhereOrNull((field) => field.id == filter.fieldId); if (field == null) return const SizedBox.shrink(); return switch (field.fieldType) { FieldType.SingleSelect || FieldType.MultiSelect => _SelectOptionFilterContentEditor( filter: filter as SelectOptionFilter, field: field, ), FieldType.CreatedTime || FieldType.LastEditedTime || FieldType.DateTime => _DateTimeFilterContentEditor(filter: filter as DateTimeFilter), _ => const SizedBox.shrink(), }; }, ); } } class _SelectOptionFilterContentEditor extends StatefulWidget { _SelectOptionFilterContentEditor({ required this.filter, required this.field, }) : delegate = filter.makeDelegate(field); final SelectOptionFilter filter; final FieldInfo field; final SelectOptionFilterDelegate delegate; @override State<_SelectOptionFilterContentEditor> createState() => _SelectOptionFilterContentEditorState(); } class _SelectOptionFilterContentEditorState extends State<_SelectOptionFilterContentEditor> { final TextEditingController textController = TextEditingController(); String filterText = ""; final List options = []; @override void initState() { super.initState(); options.addAll(widget.delegate.getOptions(widget.field)); } @override void dispose() { textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ const Divider( height: 0.5, thickness: 0.5, ), Padding( padding: const EdgeInsets.all(16.0), child: FlowyMobileSearchTextField( controller: textController, onChanged: (text) { if (textController.value.composing.isCollapsed) { setState(() { filterText = text; filterOptions(); }); } }, onSubmitted: (_) {}, hintText: LocaleKeys.grid_selectOption_searchOption.tr(), ), ), Expanded( child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 16.0), separatorBuilder: (context, index) => const VSpace(20), itemCount: options.length, itemBuilder: (context, index) { return MobileSelectOption( option: options[index], isSelected: widget.filter.optionIds.contains(options[index].id), onTap: (isSelected) { _onTapHandler( context, options, options[index], isSelected, ); }, indicator: MobileSelectedOptionIndicator.multi, showMoreOptionsButton: false, ); }, ), ), ], ); } void filterOptions() { options ..clear() ..addAll(widget.delegate.getOptions(widget.field)); if (filterText.isNotEmpty) { options.retainWhere((option) { final name = option.name.toLowerCase(); final lFilter = filterText.toLowerCase(); return name.contains(lFilter); }); } } void _onTapHandler( BuildContext context, List options, SelectOptionPB option, bool isSelected, ) { final selectedOptionIds = Set.from(widget.filter.optionIds); if (isSelected) { selectedOptionIds.remove(option.id); } else { selectedOptionIds.add(option.id); } _updateSelectOptions(context, options, selectedOptionIds); } void _updateSelectOptions( BuildContext context, List options, Set selectedOptionIds, ) { final optionIds = options.map((e) => e.id).where(selectedOptionIds.contains).toList(); final newFilter = widget.filter.copyWith(optionIds: optionIds); context.read().updateFilter(newFilter); } } class _DateTimeFilterContentEditor extends StatefulWidget { const _DateTimeFilterContentEditor({ required this.filter, }); final DateTimeFilter filter; @override State<_DateTimeFilterContentEditor> createState() => _DateTimeFilterContentEditorState(); } class _DateTimeFilterContentEditorState extends State<_DateTimeFilterContentEditor> { late DateTime focusedDay; bool get isRange => widget.filter.condition.isRange; @override void initState() { super.initState(); focusedDay = (isRange ? widget.filter.start : widget.filter.timestamp) ?? DateTime.now(); } @override Widget build(BuildContext context) { return MobileDatePicker( isRange: isRange, selectedDay: isRange ? widget.filter.start : widget.filter.timestamp, startDay: isRange ? widget.filter.start : null, endDay: isRange ? widget.filter.end : null, focusedDay: focusedDay, onDaySelected: (selectedDay) { final newFilter = isRange ? widget.filter.copyWithRange(start: selectedDay, end: null) : widget.filter.copyWithTimestamp(timestamp: selectedDay); context.read().updateFilter(newFilter); }, onRangeSelected: (start, end) { final newFilter = widget.filter.copyWithRange( start: start, end: end, ); context.read().updateFilter(newFilter); }, onPageChanged: (focusedDay) { setState(() => this.focusedDay = focusedDay); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_bottom_sheet_cubit.dart ================================================ import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'database_filter_bottom_sheet_cubit.freezed.dart'; class MobileFilterEditorCubit extends Cubit { MobileFilterEditorCubit({ required this.pageController, }) : super(MobileFilterEditorState.overview()); final PageController pageController; void returnToOverview({bool scrollToBottom = false}) { _animateToPage(0); emit(MobileFilterEditorState.overview(scrollToBottom: scrollToBottom)); } void startCreatingFilter() { _animateToPage(1); emit(MobileFilterEditorState.create()); } void startEditingFilterField(String filterId) { _animateToPage(1); emit(MobileFilterEditorState.editField(filterId: filterId)); } void updateFilter(DatabaseFilter filter) { emit( state.maybeWhen( editCondition: (filterId, newFilter, showSave) => MobileFilterEditorState.editCondition( filterId: filterId, newFilter: filter, showSave: showSave, ), editContent: (filterId, _) => MobileFilterEditorState.editContent( filterId: filterId, newFilter: filter, ), orElse: () => state, ), ); } void startEditingFilterCondition( String filterId, DatabaseFilter filter, bool showSave, ) { _animateToPage(1); emit( MobileFilterEditorState.editCondition( filterId: filterId, newFilter: filter, showSave: showSave, ), ); } void startEditingFilterContent(String filterId, DatabaseFilter filter) { _animateToPage(1); emit( MobileFilterEditorState.editContent( filterId: filterId, newFilter: filter, ), ); } Future _animateToPage(int page) async { return pageController.animateToPage( page, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, ); } } @freezed class MobileFilterEditorState with _$MobileFilterEditorState { factory MobileFilterEditorState.overview({ @Default(false) bool scrollToBottom, }) = _OverviewState; factory MobileFilterEditorState.create() = _CreateState; factory MobileFilterEditorState.editField({ required String filterId, }) = _EditFieldState; factory MobileFilterEditorState.editCondition({ required String filterId, required DatabaseFilter newFilter, required bool showSave, }) = _EditConditionState; factory MobileFilterEditorState.editContent({ required String filterId, required DatabaseFilter newFilter, }) = _EditContentState; } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_condition_list.dart ================================================ import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; abstract class FilterCondition { static FilterCondition fromFieldType(FieldType fieldType) { return switch (fieldType) { FieldType.RichText || FieldType.URL => TextFilterCondition().as(), FieldType.Number => NumberFilterCondition().as(), FieldType.Checkbox => CheckboxFilterCondition().as(), FieldType.Checklist => ChecklistFilterCondition().as(), FieldType.SingleSelect => SingleSelectOptionFilterCondition().as(), FieldType.MultiSelect => MultiSelectOptionFilterCondition().as(), _ => MultiSelectOptionFilterCondition().as(), }; } List<(C, String)> get conditions; } mixin _GenericCastHelper { FilterCondition as() => this as FilterCondition; } final class TextFilterCondition with _GenericCastHelper implements FilterCondition { @override List<(TextFilterConditionPB, String)> get conditions { return [ TextFilterConditionPB.TextContains, TextFilterConditionPB.TextDoesNotContain, TextFilterConditionPB.TextIs, TextFilterConditionPB.TextIsNot, TextFilterConditionPB.TextStartsWith, TextFilterConditionPB.TextEndsWith, TextFilterConditionPB.TextIsEmpty, TextFilterConditionPB.TextIsNotEmpty, ].map((e) => (e, e.filterName)).toList(); } } final class NumberFilterCondition with _GenericCastHelper implements FilterCondition { @override List<(NumberFilterConditionPB, String)> get conditions { return [ NumberFilterConditionPB.Equal, NumberFilterConditionPB.NotEqual, NumberFilterConditionPB.LessThan, NumberFilterConditionPB.LessThanOrEqualTo, NumberFilterConditionPB.GreaterThan, NumberFilterConditionPB.GreaterThanOrEqualTo, NumberFilterConditionPB.NumberIsEmpty, NumberFilterConditionPB.NumberIsNotEmpty, ].map((e) => (e, e.filterName)).toList(); } } final class CheckboxFilterCondition with _GenericCastHelper implements FilterCondition { @override List<(CheckboxFilterConditionPB, String)> get conditions { return [ CheckboxFilterConditionPB.IsChecked, CheckboxFilterConditionPB.IsUnChecked, ].map((e) => (e, e.filterName)).toList(); } } final class ChecklistFilterCondition with _GenericCastHelper implements FilterCondition { @override List<(ChecklistFilterConditionPB, String)> get conditions { return [ ChecklistFilterConditionPB.IsComplete, ChecklistFilterConditionPB.IsIncomplete, ].map((e) => (e, e.filterName)).toList(); } } final class SingleSelectOptionFilterCondition with _GenericCastHelper implements FilterCondition { @override List<(SelectOptionFilterConditionPB, String)> get conditions { return [ SelectOptionFilterConditionPB.OptionIs, SelectOptionFilterConditionPB.OptionIsNot, SelectOptionFilterConditionPB.OptionIsEmpty, SelectOptionFilterConditionPB.OptionIsNotEmpty, ].map((e) => (e, e.i18n)).toList(); } } final class MultiSelectOptionFilterCondition with _GenericCastHelper implements FilterCondition { @override List<(SelectOptionFilterConditionPB, String)> get conditions { return [ SelectOptionFilterConditionPB.OptionContains, SelectOptionFilterConditionPB.OptionDoesNotContain, SelectOptionFilterConditionPB.OptionIsEmpty, SelectOptionFilterConditionPB.OptionIsNotEmpty, ].map((e) => (e, e.i18n)).toList(); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'database_sort_bottom_sheet_cubit.dart'; class MobileSortEditor extends StatefulWidget { const MobileSortEditor({ super.key, }); @override State createState() => _MobileSortEditorState(); } class _MobileSortEditorState extends State { final PageController _pageController = PageController(); @override void dispose() { _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (context) => MobileSortEditorCubit( pageController: _pageController, ), child: Column( children: [ const _Header(), SizedBox( height: 400, //314, child: PageView.builder( controller: _pageController, itemCount: 2, physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { return index == 0 ? Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom, ), child: const _Overview(), ) : const _SortDetail(); }, ), ), ], ), ); } } class _Header extends StatelessWidget { const _Header(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SizedBox( height: 44.0, child: Stack( children: [ if (state.showBackButton) Align( alignment: Alignment.centerLeft, child: AppBarBackButton( padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), onTap: () => context .read() .returnToOverview(), ), ), Align( child: FlowyText.medium( LocaleKeys.grid_settings_sort.tr(), fontSize: 16.0, ), ), if (state.isCreatingNewSort) Align( alignment: Alignment.centerRight, child: AppBarSaveButton( padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), enable: state.newSortFieldId != null, onTap: () { _tryCreateSort(context, state); context.read().returnToOverview(); }, ), ), ], ), ); }, ); } void _tryCreateSort(BuildContext context, MobileSortEditorState state) { if (state.newSortFieldId != null && state.newSortCondition != null) { context.read().add( SortEditorEvent.createSort( fieldId: state.newSortFieldId!, condition: state.newSortCondition!, ), ); } } } class _Overview extends StatelessWidget { const _Overview(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Column( children: [ Expanded( child: state.sorts.isEmpty ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.sort_descending_s, size: const Size.square(60), color: Theme.of(context).hintColor, ), FlowyText( LocaleKeys.grid_sort_empty.tr(), color: Theme.of(context).hintColor, ), ], ), ) : ReorderableListView.builder( padding: const EdgeInsets.symmetric( horizontal: 16, ), proxyDecorator: (child, index, animation) => Material( color: Colors.transparent, child: child, ), onReorder: (oldIndex, newIndex) => context .read() .add(SortEditorEvent.reorderSort(oldIndex, newIndex)), itemCount: state.sorts.length, itemBuilder: (context, index) => _SortItem( key: ValueKey("sort_item_$index"), sort: state.sorts[index], ), ), ), Container( height: 44, width: double.infinity, margin: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide( width: 0.5, color: Theme.of(context).dividerColor, ), ), borderRadius: Corners.s10Border, ), child: InkWell( onTap: () { final firstField = context .read() .state .creatableFields .firstOrNull; if (firstField == null) { Fluttertoast.showToast( msg: LocaleKeys.grid_sort_cannotFindCreatableField.tr(), gravity: ToastGravity.BOTTOM, ); } else { context.read().startCreatingSort(); } }, borderRadius: Corners.s10Border, child: Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ const FlowySvg( FlowySvgs.add_s, size: Size.square(16), ), const HSpace(6.0), FlowyText( LocaleKeys.grid_sort_addSort.tr(), fontSize: 15, ), ], ), ), ), ), ], ); }, ); } } class _SortItem extends StatelessWidget { const _SortItem({super.key, required this.sort}); final DatabaseSort sort; @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.symmetric( vertical: 4.0, ), decoration: BoxDecoration( color: Theme.of(context).hoverColor, borderRadius: BorderRadius.circular(12), ), child: Stack( children: [ GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => context .read() .startEditingSort(sort.sortId), child: Padding( padding: const EdgeInsets.symmetric( vertical: 14, horizontal: 8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: FlowyText.medium( LocaleKeys.grid_sort_by.tr(), fontSize: 15, ), ), const VSpace(10), Row( children: [ Expanded( child: Container( height: 44, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide( width: 0.5, color: Theme.of(context).dividerColor, ), ), borderRadius: Corners.s10Border, color: Theme.of(context).colorScheme.surface, ), padding: const EdgeInsets.symmetric(horizontal: 12), child: Center( child: Row( children: [ Expanded( child: BlocSelector( selector: (state) => state.allFields.firstWhereOrNull( (field) => field.id == sort.fieldId, ), builder: (context, field) { return FlowyText( field?.name ?? "", overflow: TextOverflow.ellipsis, ); }, ), ), const HSpace(6.0), FlowySvg( FlowySvgs.icon_right_small_ccm_outlined_s, size: const Size.square(14), color: Theme.of(context).hintColor, ), ], ), ), ), ), const HSpace(6), Expanded( child: Container( height: 44, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide( width: 0.5, color: Theme.of(context).dividerColor, ), ), borderRadius: Corners.s10Border, color: Theme.of(context).colorScheme.surface, ), padding: const EdgeInsetsDirectional.only( start: 12, end: 10, ), child: Center( child: Row( children: [ Expanded( child: FlowyText( sort.condition.name, ), ), const HSpace(6.0), FlowySvg( FlowySvgs.icon_right_small_ccm_outlined_s, size: const Size.square(14), color: Theme.of(context).hintColor, ), ], ), ), ), ), ], ), ], ), ), ), Positioned( right: 8, top: 6, child: InkWell( onTap: () => context .read() .add(SortEditorEvent.deleteSort(sort.sortId)), // steal from the container LongClickReorderWidget thing onLongPress: () {}, borderRadius: BorderRadius.circular(10), child: SizedBox.square( dimension: 34, child: Center( child: FlowySvg( FlowySvgs.trash_m, size: const Size.square(18), color: Theme.of(context).hintColor, ), ), ), ), ), ], ), ); } } class _SortDetail extends StatelessWidget { const _SortDetail(); @override Widget build(BuildContext context) { final isCreatingNewSort = context.read().state.isCreatingNewSort; return isCreatingNewSort ? const _SortDetailContent() : BlocSelector( selector: (state) => state.sorts.firstWhere( (sort) => sort.sortId == context.read().state.editingSortId, ), builder: (context, sort) { return _SortDetailContent(sort: sort); }, ); } } class _SortDetailContent extends StatelessWidget { const _SortDetailContent({ this.sort, }); final DatabaseSort? sort; bool get isCreatingNewSort => sort == null; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(4), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: DefaultTabController( length: 2, initialIndex: isCreatingNewSort ? 0 : sort!.condition == SortConditionPB.Ascending ? 0 : 1, child: Container( padding: const EdgeInsets.all(3.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), color: Theme.of(context).hoverColor, ), child: TabBar( indicatorSize: TabBarIndicatorSize.label, labelPadding: EdgeInsets.zero, padding: EdgeInsets.zero, indicatorWeight: 0, indicator: BoxDecoration( borderRadius: BorderRadius.circular(10), color: Theme.of(context).colorScheme.surface, ), splashFactory: NoSplash.splashFactory, overlayColor: const WidgetStatePropertyAll( Colors.transparent, ), onTap: (index) { final newCondition = index == 0 ? SortConditionPB.Ascending : SortConditionPB.Descending; _changeCondition(context, newCondition); }, tabs: [ Tab( height: 34, child: Center( child: FlowyText( LocaleKeys.grid_sort_ascending.tr(), fontSize: 14, ), ), ), Tab( height: 34, child: Center( child: FlowyText( LocaleKeys.grid_sort_descending.tr(), fontSize: 14, ), ), ), ], ), ), ), ), const VSpace(20), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: FlowyText( LocaleKeys.grid_settings_sortBy.tr().toUpperCase(), fontSize: 13, color: Theme.of(context).hintColor, ), ), const VSpace(4.0), const Divider( height: 0.5, thickness: 0.5, ), Expanded( child: BlocBuilder( builder: (context, state) { final fields = state.allFields .where((field) => field.fieldType.canCreateSort) .toList(); return ListView.builder( itemCount: fields.length, itemBuilder: (context, index) { final fieldInfo = fields[index]; final isSelected = isCreatingNewSort ? context .watch() .state .newSortFieldId == fieldInfo.id : sort!.fieldId == fieldInfo.id; final canSort = fieldInfo.fieldType.canCreateSort && !fieldInfo.hasSort; final beingEdited = !isCreatingNewSort && sort!.fieldId == fieldInfo.id; final enabled = canSort || beingEdited; return FlowyOptionTile.checkbox( text: fieldInfo.field.name, leftIcon: FieldIcon( fieldInfo: fieldInfo, ), isSelected: isSelected, textColor: enabled ? null : Theme.of(context).disabledColor, showTopBorder: false, onTap: () { if (isSelected) { return; } if (enabled) { _changeFieldId(context, fieldInfo.id); } else { Fluttertoast.showToast( msg: LocaleKeys.grid_sort_fieldInUse.tr(), gravity: ToastGravity.BOTTOM, ); } }, ); }, ); }, ), ), ], ); } void _changeCondition(BuildContext context, SortConditionPB newCondition) { if (isCreatingNewSort) { context.read().changeSortCondition(newCondition); } else { context.read().add( SortEditorEvent.editSort( sortId: sort!.sortId, condition: newCondition, ), ); } } void _changeFieldId(BuildContext context, String newFieldId) { if (isCreatingNewSort) { context.read().changeFieldId(newFieldId); } else { context.read().add( SortEditorEvent.editSort( sortId: sort!.sortId, fieldId: newFieldId, ), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet_cubit.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'database_sort_bottom_sheet_cubit.freezed.dart'; class MobileSortEditorCubit extends Cubit { MobileSortEditorCubit({ required this.pageController, }) : super(MobileSortEditorState.initial()); final PageController pageController; void returnToOverview() { _animateToPage(0); emit(MobileSortEditorState.initial()); } void startCreatingSort() { _animateToPage(1); emit( state.copyWith( showBackButton: true, isCreatingNewSort: true, newSortCondition: SortConditionPB.Ascending, ), ); } void startEditingSort(String sortId) { _animateToPage(1); emit( state.copyWith( showBackButton: true, editingSortId: sortId, ), ); } /// only used when creating a new sort void changeFieldId(String fieldId) { emit(state.copyWith(newSortFieldId: fieldId)); } /// only used when creating a new sort void changeSortCondition(SortConditionPB condition) { emit(state.copyWith(newSortCondition: condition)); } Future _animateToPage(int page) async { return pageController.animateToPage( page, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, ); } } @freezed class MobileSortEditorState with _$MobileSortEditorState { factory MobileSortEditorState({ required bool showBackButton, required String? editingSortId, required bool isCreatingNewSort, required String? newSortFieldId, required SortConditionPB? newSortCondition, }) = _MobileSortEditorState; factory MobileSortEditorState.initial() => MobileSortEditorState( showBackButton: false, editingSortId: null, isCreatingNewSort: false, newSortFieldId: null, newSortCondition: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_layout.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_setting_bloc.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../field/mobile_field_bottom_sheets.dart'; /// [DatabaseViewLayoutPicker] is seen when changing the layout type of a /// database view or creating a new database view. class DatabaseViewLayoutPicker extends StatelessWidget { const DatabaseViewLayoutPicker({ super.key, required this.selectedLayout, required this.onSelect, }); final DatabaseLayoutPB selectedLayout; final void Function(DatabaseLayoutPB layout) onSelect; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildButton(DatabaseLayoutPB.Grid, true), _buildButton(DatabaseLayoutPB.Board, false), _buildButton(DatabaseLayoutPB.Calendar, false), ], ); } Widget _buildButton(DatabaseLayoutPB layout, bool showTopBorder) { return FlowyOptionTile.checkbox( text: layout.layoutName, leftIcon: FlowySvg(layout.icon, size: const Size.square(20)), isSelected: selectedLayout == layout, showTopBorder: showTopBorder, onTap: () { onSelect(layout); }, ); } } /// [MobileCalendarViewLayoutSettings] is used when the database layout is /// calendar. It allows changing the field being used to layout the events, /// and which day of the week the calendar starts on. class MobileCalendarViewLayoutSettings extends StatelessWidget { const MobileCalendarViewLayoutSettings({ super.key, required this.databaseController, }); final DatabaseController databaseController; @override Widget build(BuildContext context) { return BlocProvider( create: (context) { return CalendarSettingBloc( databaseController: databaseController, )..add(const CalendarSettingEvent.initial()); }, child: BlocBuilder( builder: (context, state) { if (state.layoutSetting == null) { return const SizedBox.shrink(); } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _CalendarLayoutField( context: context, databaseController: databaseController, selectedFieldId: state.layoutSetting?.fieldId, ), _divider(), ..._startWeek(context, state.layoutSetting?.firstDayOfWeek), ], ); }, ), ); } List _startWeek(BuildContext context, int? firstDayOfWeek) { final symbols = DateFormat.EEEE(context.locale.toLanguageTag()).dateSymbols; return [ Padding( padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 4.0), child: FlowyText( LocaleKeys.calendar_settings_firstDayOfWeek.tr().toUpperCase(), fontSize: 13, color: Theme.of(context).hintColor, ), ), FlowyOptionTile.checkbox( text: symbols.WEEKDAYS[0], isSelected: firstDayOfWeek! == 0, onTap: () { context.read().add( const CalendarSettingEvent.updateLayoutSetting( firstDayOfWeek: 0, ), ); }, ), FlowyOptionTile.checkbox( text: symbols.WEEKDAYS[1], isSelected: firstDayOfWeek == 1, showTopBorder: false, onTap: () { context.read().add( const CalendarSettingEvent.updateLayoutSetting( firstDayOfWeek: 1, ), ); }, ), ]; } Widget _divider() => const VSpace(20); } class _CalendarLayoutField extends StatelessWidget { const _CalendarLayoutField({ required this.context, required this.databaseController, required this.selectedFieldId, }); final BuildContext context; final DatabaseController databaseController; final String? selectedFieldId; @override Widget build(BuildContext context) { FieldInfo? selectedField; if (selectedFieldId != null) { selectedField = databaseController.fieldController.getField(selectedFieldId!); } return FlowyOptionTile.text( text: LocaleKeys.calendar_settings_layoutDateField.tr(), trailing: selectedFieldId == null ? null : Row( children: [ FlowyText( selectedField!.name, color: Theme.of(context).hintColor, ), const HSpace(8), const FlowySvg(FlowySvgs.arrow_right_s), ], ), onTap: () async { final newFieldId = await showFieldPicker( context, LocaleKeys.calendar_settings_changeLayoutDateField.tr(), selectedFieldId, databaseController.fieldController, (field) => field.fieldType == FieldType.DateTime, ); if (context.mounted && newFieldId != null && newFieldId != selectedFieldId) { context.read().add( CalendarSettingEvent.updateLayoutSetting( layoutFieldId: newFieldId, ), ); } }, ); } } class MobileBoardViewLayoutSettings extends StatelessWidget { const MobileBoardViewLayoutSettings({ super.key, required this.databaseController, }); final DatabaseController databaseController; @override Widget build(BuildContext context) { return FlowyOptionTile.text(text: LocaleKeys.board_groupBy.tr()); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'database_view_layout.dart'; import 'database_view_quick_actions.dart'; /// [MobileDatabaseViewList] shows a list of all the views in the database and /// adds a button to create a new database view. class MobileDatabaseViewList extends StatelessWidget { const MobileDatabaseViewList({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final views = [state.view, ...state.view.childViews]; return Column( children: [ _Header( title: LocaleKeys.grid_settings_viewList.plural( context.watch().state.tabBars.length, namedArgs: { 'count': '${context.watch().state.tabBars.length}', }, ), showBackButton: false, useFilledDoneButton: false, onDone: (context) => Navigator.pop(context), ), Expanded( child: ListView( shrinkWrap: true, padding: EdgeInsets.zero, children: [ ...views.mapIndexed( (index, view) => MobileDatabaseViewListButton( view: view, showTopBorder: index == 0, ), ), const VSpace(20), const MobileNewDatabaseViewButton(), VSpace( context.bottomSheetPadding(ignoreViewPadding: false), ), ], ), ), ], ); }, ); } } /// Same header as the one in showMobileBottomSheet, but allows popping the /// sheet with a value. class _Header extends StatelessWidget { const _Header({ required this.title, required this.showBackButton, required this.useFilledDoneButton, required this.onDone, }); final String title; final bool showBackButton; final bool useFilledDoneButton; final void Function(BuildContext context) onDone; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 4.0), child: SizedBox( height: 44.0, child: Stack( children: [ if (showBackButton) const Align( alignment: Alignment.centerLeft, child: AppBarBackButton(), ), Align( child: FlowyText.medium( title, fontSize: 16.0, ), ), useFilledDoneButton ? Align( alignment: Alignment.centerRight, child: AppBarFilledDoneButton( onTap: () => onDone(context), ), ) : Align( alignment: Alignment.centerRight, child: AppBarDoneButton( onTap: () => onDone(context), ), ), ], ), ), ); } } @visibleForTesting class MobileDatabaseViewListButton extends StatelessWidget { const MobileDatabaseViewListButton({ super.key, required this.view, required this.showTopBorder, }); final ViewPB view; final bool showTopBorder; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final index = state.tabBars.indexWhere((tabBar) => tabBar.viewId == view.id); final isSelected = index == state.selectedIndex; return FlowyOptionTile.text( text: view.name, onTap: () { context .read() .add(DatabaseTabBarEvent.selectView(view.id)); }, leftIcon: _buildViewIconButton(context, view), trailing: _trailing( context, state.tabBarControllerByViewId[view.id]!.controller, isSelected, ), showTopBorder: showTopBorder, ); }, ); } Widget _buildViewIconButton(BuildContext context, ViewPB view) { final iconData = view.icon.toEmojiIconData(); Widget icon; if (iconData.isEmpty || iconData.type != FlowyIconType.icon) { icon = view.defaultIcon(); } else { icon = RawEmojiIconWidget( emoji: iconData, emojiSize: 14.0, enableColor: false, ); } return SizedBox.square( dimension: 20.0, child: icon, ); } Widget _trailing( BuildContext context, DatabaseController databaseController, bool isSelected, ) { final more = FlowyIconButton( icon: FlowySvg( FlowySvgs.three_dots_s, size: const Size.square(20), color: Theme.of(context).hintColor, ), onPressed: () { showMobileBottomSheet( context, showDragHandle: true, backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return BlocProvider( create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), child: MobileDatabaseViewQuickActions( view: view, databaseController: databaseController, ), ); }, ); }, ); if (isSelected) { return Row( mainAxisSize: MainAxisSize.min, children: [ const FlowySvg( FlowySvgs.m_blue_check_s, size: Size.square(20), blendMode: BlendMode.dst, ), const HSpace(8), more, ], ); } else { return more; } } } class MobileNewDatabaseViewButton extends StatelessWidget { const MobileNewDatabaseViewButton({super.key}); @override Widget build(BuildContext context) { return FlowyOptionTile.text( text: LocaleKeys.grid_settings_createView.tr(), textColor: Theme.of(context).hintColor, leftIcon: FlowySvg( FlowySvgs.add_s, size: const Size.square(20), color: Theme.of(context).hintColor, ), onTap: () async { final result = await showMobileBottomSheet<(DatabaseLayoutPB, String)>( context, showDragHandle: true, builder: (_) { return const MobileCreateDatabaseView(); }, ); if (context.mounted && result != null) { context .read() .add(DatabaseTabBarEvent.createView(result.$1, result.$2)); } }, ); } } class MobileCreateDatabaseView extends StatefulWidget { const MobileCreateDatabaseView({super.key}); @override State createState() => _MobileCreateDatabaseViewState(); } class _MobileCreateDatabaseViewState extends State { late final TextEditingController controller; DatabaseLayoutPB layoutType = DatabaseLayoutPB.Grid; @override void initState() { super.initState(); controller = TextEditingController( text: LocaleKeys.grid_title_placeholder.tr(), ); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ _Header( title: LocaleKeys.grid_settings_createView.tr(), showBackButton: true, useFilledDoneButton: true, onDone: (context) => context.pop((layoutType, controller.text.trim())), ), FlowyOptionTile.textField( autofocus: true, controller: controller, ), const VSpace(20), DatabaseViewLayoutPicker( selectedLayout: layoutType, onSelect: (layout) { setState(() => layoutType = layout); }, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'edit_database_view_screen.dart'; /// [MobileDatabaseViewQuickActions] is gives users to quickly edit a database /// view from the [MobileDatabaseViewList] class MobileDatabaseViewQuickActions extends StatelessWidget { const MobileDatabaseViewQuickActions({ super.key, required this.view, required this.databaseController, }); final ViewPB view; final DatabaseController databaseController; @override Widget build(BuildContext context) { final isInline = view.childViews.isNotEmpty; return Column( mainAxisSize: MainAxisSize.min, children: [ _actionButton(context, _Action.edit, () async { final bloc = context.read(); await showTransitionMobileBottomSheet( context, showHeader: true, showDoneButton: true, title: LocaleKeys.grid_settings_editView.tr(), builder: (_) => BlocProvider.value( value: bloc, child: MobileEditDatabaseViewScreen( databaseController: databaseController, ), ), ); if (context.mounted) { context.pop(); } }), const MobileQuickActionDivider(), _actionButton( context, _Action.changeIcon, () { showMobileBottomSheet( context, showDragHandle: true, showDivider: false, showHeader: true, title: LocaleKeys.titleBar_pageIcon.tr(), backgroundColor: AFThemeExtension.of(context).background, enableDraggableScrollable: true, minChildSize: 0.6, initialChildSize: 0.61, scrollableWidgetBuilder: (_, controller) { return Expanded( child: FlowyIconEmojiPicker( tabs: const [PickerTabType.icon], enableBackgroundColorSelection: false, onSelectedEmoji: (r) { ViewBackendService.updateViewIcon( view: view, viewIcon: r.data, ); Navigator.pop(context); }, ), ); }, builder: (_) => const SizedBox.shrink(), ).then((_) { if (context.mounted) { Navigator.pop(context); } }); }, !isInline, ), const MobileQuickActionDivider(), _actionButton( context, _Action.duplicate, () { context.read().add(const ViewEvent.duplicate()); context.pop(); }, !isInline, ), const MobileQuickActionDivider(), _actionButton( context, _Action.delete, () { context.read().add(const ViewEvent.delete()); context.pop(); }, !isInline, ), ], ); } Widget _actionButton( BuildContext context, _Action action, VoidCallback onTap, [ bool enable = true, ]) { return MobileQuickActionButton( icon: action.icon, text: action.label, textColor: action.color(context), iconColor: action.color(context), onTap: onTap, enable: enable, ); } } enum _Action { edit, changeIcon, delete, duplicate; String get label { return switch (this) { edit => LocaleKeys.grid_settings_editView.tr(), duplicate => LocaleKeys.button_duplicate.tr(), delete => LocaleKeys.button_delete.tr(), changeIcon => LocaleKeys.disclosureAction_changeIcon.tr(), }; } FlowySvgData get icon { return switch (this) { edit => FlowySvgs.view_item_rename_s, duplicate => FlowySvgs.duplicate_s, delete => FlowySvgs.trash_s, changeIcon => FlowySvgs.change_icon_s, }; } Color? color(BuildContext context) { return switch (this) { delete => Theme.of(context).colorScheme.error, _ => null, }; } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/database/view/edit_database_view_screen.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/plugins/database/domain/layout_service.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'database_field_list.dart'; import 'database_view_layout.dart'; /// [MobileEditDatabaseViewScreen] is the main widget used to edit a database /// view. It contains multiple sub-pages, and the current page is managed by /// [MobileEditDatabaseViewCubit] class MobileEditDatabaseViewScreen extends StatelessWidget { const MobileEditDatabaseViewScreen({ super.key, required this.databaseController, }); final DatabaseController databaseController; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Column( children: [ _NameAndIcon(view: state.view), _divider(), DatabaseViewSettingTile( setting: DatabaseViewSettings.layout, databaseController: databaseController, view: state.view, showTopBorder: true, ), if (databaseController.databaseLayout == DatabaseLayoutPB.Calendar) DatabaseViewSettingTile( setting: DatabaseViewSettings.calendar, databaseController: databaseController, view: state.view, ), DatabaseViewSettingTile( setting: DatabaseViewSettings.fields, databaseController: databaseController, view: state.view, ), _divider(), ], ); }, ); } Widget _divider() => const VSpace(20); } class _NameAndIcon extends StatefulWidget { const _NameAndIcon({required this.view}); final ViewPB view; @override State<_NameAndIcon> createState() => _NameAndIconState(); } class _NameAndIconState extends State<_NameAndIcon> { final TextEditingController textEditingController = TextEditingController(); @override void initState() { super.initState(); textEditingController.text = widget.view.name; } @override void dispose() { textEditingController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Material( child: FlowyOptionTile.textField( autofocus: true, showTopBorder: false, controller: textEditingController, onTextChanged: (text) { context.read().add(ViewEvent.rename(text)); }, ), ); } } enum DatabaseViewSettings { layout, fields, filter, sort, board, calendar, duplicate, delete; String get label { return switch (this) { layout => LocaleKeys.grid_settings_databaseLayout.tr(), fields => LocaleKeys.grid_settings_properties.tr(), filter => LocaleKeys.grid_settings_filter.tr(), sort => LocaleKeys.grid_settings_sort.tr(), board => LocaleKeys.grid_settings_boardSettings.tr(), calendar => LocaleKeys.grid_settings_calendarSettings.tr(), duplicate => LocaleKeys.grid_settings_duplicateView.tr(), delete => LocaleKeys.grid_settings_deleteView.tr(), }; } FlowySvgData get icon { return switch (this) { layout => FlowySvgs.card_view_s, fields => FlowySvgs.disorder_list_s, filter => FlowySvgs.filter_s, sort => FlowySvgs.sort_ascending_s, board => FlowySvgs.board_s, calendar => FlowySvgs.calendar_s, duplicate => FlowySvgs.copy_s, delete => FlowySvgs.delete_s, }; } } class DatabaseViewSettingTile extends StatelessWidget { const DatabaseViewSettingTile({ super.key, required this.setting, required this.databaseController, required this.view, this.showTopBorder = false, }); final DatabaseViewSettings setting; final DatabaseController databaseController; final ViewPB view; final bool showTopBorder; @override Widget build(BuildContext context) { return FlowyOptionTile.text( text: setting.label, leftIcon: FlowySvg(setting.icon, size: const Size.square(20)), trailing: _trailing(context, setting, view, databaseController), showTopBorder: showTopBorder, onTap: () => _onTap(context), ); } Widget _trailing( BuildContext context, DatabaseViewSettings setting, ViewPB view, DatabaseController databaseController, ) { switch (setting) { case DatabaseViewSettings.layout: return Row( children: [ FlowyText( lineHeight: 1.0, databaseLayoutFromViewLayout(view.layout).layoutName, color: Theme.of(context).hintColor, ), const HSpace(8), const FlowySvg(FlowySvgs.arrow_right_s), ], ); case DatabaseViewSettings.fields: final numVisible = databaseController.fieldController.fieldInfos .where((field) => field.visibility != FieldVisibility.AlwaysHidden) .length; return Row( children: [ FlowyText( LocaleKeys.grid_settings_numberOfVisibleFields .tr(args: [numVisible.toString()]), color: Theme.of(context).hintColor, ), const HSpace(8), const FlowySvg(FlowySvgs.arrow_right_s), ], ); default: return const SizedBox.shrink(); } } void _onTap(BuildContext context) async { if (setting == DatabaseViewSettings.layout) { final databaseLayout = databaseLayoutFromViewLayout(view.layout); final newLayout = await showMobileBottomSheet( context, showDragHandle: true, showHeader: true, showDivider: false, title: LocaleKeys.grid_settings_layout.tr(), builder: (context) { return DatabaseViewLayoutPicker( selectedLayout: databaseLayout, onSelect: (layout) => Navigator.of(context).pop(layout), ); }, ); if (newLayout != null && newLayout != databaseLayout) { await DatabaseViewBackendService.updateLayout( viewId: databaseController.viewId, layout: newLayout, ); } return; } if (setting == DatabaseViewSettings.fields) { await showTransitionMobileBottomSheet( context, showHeader: true, showBackButton: true, title: LocaleKeys.grid_settings_properties.tr(), builder: (_) { return BlocProvider.value( value: context.read(), child: MobileDatabaseFieldList( databaseController: databaseController, canCreate: true, ), ); }, ); return; } if (setting == DatabaseViewSettings.board) { await showMobileBottomSheet( context, builder: (context) { return Padding( padding: const EdgeInsets.only(top: 24, bottom: 46), child: MobileBoardViewLayoutSettings( databaseController: databaseController, ), ); }, ); return; } if (setting == DatabaseViewSettings.calendar) { await showMobileBottomSheet( context, showDragHandle: true, showHeader: true, showDivider: false, title: LocaleKeys.calendar_settings_name.tr(), builder: (context) { return MobileCalendarViewLayoutSettings( databaseController: databaseController, ); }, ); return; } if (setting == DatabaseViewSettings.delete) { context.read().add(const ViewEvent.delete()); context.pop(true); return; } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart ================================================ import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; class MobileDocumentScreen extends StatelessWidget { const MobileDocumentScreen({ super.key, required this.id, this.title, this.showMoreButton = true, this.fixedTitle, this.blockId, this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); /// view id final String id; final String? title; final bool showMoreButton; final String? fixedTitle; final String? blockId; final List tabs; static const routeName = '/docs'; static const viewId = 'id'; static const viewTitle = 'title'; static const viewShowMoreButton = 'show_more_button'; static const viewFixedTitle = 'fixed_title'; static const viewBlockId = 'block_id'; static const viewSelectTabs = 'select_tabs'; @override Widget build(BuildContext context) { return MobileViewPage( id: id, title: title, viewLayout: ViewLayoutPB.Document, showMoreButton: showMoreButton, fixedTitle: fixedTitle, blockId: blockId, tabs: tabs, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; class MobileFavoritePageFolder extends StatelessWidget { const MobileFavoritePageFolder({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { final workspaceId = context.read().state.currentWorkspace?.workspaceId ?? ''; return MultiBlocProvider( providers: [ BlocProvider( create: (_) => SidebarSectionsBloc() ..add(SidebarSectionsEvent.initial(userProfile, workspaceId)), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), ), ], child: BlocListener( listener: (context, state) => context.read().add(const FavoriteEvent.initial()), child: MultiBlocListener( listeners: [ BlocListener( listenWhen: (p, c) => p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, listener: (context, state) => context.pushView(state.lastCreatedRootView!), ), ], child: Builder( builder: (context) { final favoriteState = context.watch().state; if (favoriteState.views.isEmpty) { return FlowyMobileStateContainer.info( emoji: '😁', title: LocaleKeys.favorite_noFavorite.tr(), description: LocaleKeys.favorite_noFavoriteHintText.tr(), ); } return Scrollbar( child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(8.0), child: SlidableAutoCloseBehavior( child: Column( children: [ MobileFavoriteFolder( showHeader: false, forceExpanded: true, views: favoriteState.views.map((e) => e.item).toList(), ), const VSpace(100.0), ], ), ), ), ), ); }, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart ================================================ import 'dart:io'; import 'package:appflowy/features/workspace/data/repositories/rust_workspace_repository_impl.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/mobile/presentation/favorite/mobile_favorite_folder.dart'; import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileFavoriteScreen extends StatelessWidget { const MobileFavoriteScreen({ super.key, }); static const routeName = '/favorite'; @override Widget build(BuildContext context) { return FutureBuilder( future: Future.wait([ FolderEventGetCurrentWorkspaceSetting().send(), getIt().getUser(), ]), builder: (context, snapshots) { if (!snapshots.hasData) { return const Center(child: CircularProgressIndicator.adaptive()); } final latest = snapshots.data?[0].fold( (latest) { return latest as WorkspaceLatestPB?; }, (error) => null, ); final userProfile = snapshots.data?[1].fold( (userProfilePB) { return userProfilePB as UserProfilePB?; }, (error) => null, ); // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. if (latest == null || userProfile == null) { return const WorkspaceFailedScreen(); } return Scaffold( body: SafeArea( child: BlocProvider( create: (_) => UserWorkspaceBloc( userProfile: userProfile, repository: RustWorkspaceRepositoryImpl( userId: userProfile.id, ), )..add( UserWorkspaceEvent.initialize(), ), child: BlocBuilder( buildWhen: (previous, current) => previous.currentWorkspace?.workspaceId != current.currentWorkspace?.workspaceId, builder: (context, state) { return MobileFavoritePage( userProfile: userProfile, ); }, ), ), ), ); }, ); } } class MobileFavoritePage extends StatelessWidget { const MobileFavoritePage({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { return Column( children: [ // Header Padding( padding: EdgeInsets.only( left: 16, right: 16, top: Platform.isAndroid ? 8.0 : 0.0, ), child: MobileHomePageHeader( userProfile: userProfile, ), ), const Divider(), // Folder Expanded( child: MobileFavoritePageFolder( userProfile: userProfile, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/home/shared/empty_placeholder.dart'; import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileFavoriteSpace extends StatefulWidget { const MobileFavoriteSpace({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override State createState() => _MobileFavoriteSpaceState(); } class _MobileFavoriteSpaceState extends State with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); final workspaceId = context.read().state.currentWorkspace?.workspaceId ?? ''; return MultiBlocProvider( providers: [ BlocProvider( create: (_) => SidebarSectionsBloc() ..add( SidebarSectionsEvent.initial(widget.userProfile, workspaceId), ), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), ), ], child: BlocListener( listener: (context, state) => context.read().add(const FavoriteEvent.initial()), child: MultiBlocListener( listeners: [ BlocListener( listenWhen: (p, c) => p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, listener: (context, state) => context.pushView(state.lastCreatedRootView!), ), ], child: Builder( builder: (context) { final favoriteState = context.watch().state; if (favoriteState.isLoading) { return const SizedBox.shrink(); } if (favoriteState.views.isEmpty) { return const EmptySpacePlaceholder( type: MobilePageCardType.favorite, ); } return _FavoriteViews( favoriteViews: favoriteState.views.reversed.toList(), ); }, ), ), ), ); } } class _FavoriteViews extends StatelessWidget { const _FavoriteViews({ required this.favoriteViews, }); final List favoriteViews; @override Widget build(BuildContext context) { final borderColor = Theme.of(context).isLightMode ? const Color(0xFFE9E9EC) : const Color(0x1AFFFFFF); return ListView.separated( key: const PageStorageKey('favorite_views_page_storage_key'), padding: EdgeInsets.only( bottom: HomeSpaceViewSizes.mVerticalPadding + MediaQuery.of(context).padding.bottom, ), itemBuilder: (context, index) { final view = favoriteViews[index]; return Container( padding: const EdgeInsets.symmetric(vertical: 24.0), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: borderColor, width: 0.5, ), ), ), child: MobileViewPage( key: ValueKey(view.item.id), view: view.item, timestamp: view.timestamp, type: MobilePageCardType.favorite, ), ); }, separatorBuilder: (context, index) => const HSpace(8), itemCount: favoriteViews.length, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart ================================================ import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart'; import 'package:appflowy/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder_header.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileFavoriteFolder extends StatelessWidget { const MobileFavoriteFolder({ super.key, required this.views, this.showHeader = true, this.forceExpanded = false, }); final bool showHeader; final bool forceExpanded; final List views; @override Widget build(BuildContext context) { if (views.isEmpty) { return const SizedBox.shrink(); } return BlocProvider( create: (context) => FolderBloc(type: FolderSpaceType.favorite) ..add( const FolderEvent.initial(), ), child: BlocBuilder( builder: (context, state) { return Column( children: [ if (showHeader) ...[ MobileFavoriteFolderHeader( isExpanded: context.read().state.isExpanded, onPressed: () => context .read() .add(const FolderEvent.expandOrUnExpand()), onAdded: () => context.read().add( const FolderEvent.expandOrUnExpand(isExpanded: true), ), ), const VSpace(8.0), const Divider( height: 1, ), ], if (forceExpanded || state.isExpanded) ...views.map( (view) => MobileViewItem( key: ValueKey( '${FolderSpaceType.favorite.name} ${view.id}', ), spaceType: FolderSpaceType.favorite, isDraggable: false, isFirstChild: view.id == views.first.id, isFeedback: false, view: view, level: 0, onSelected: context.pushView, endActionPane: (context) => buildEndActionPane( context, [ view.isFavorite ? MobilePaneActionType.removeFromFavorites : MobilePaneActionType.addToFavorites, MobilePaneActionType.more, ], spaceType: FolderSpaceType.favorite, spaceRatio: 5, ), ), ), ], ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder_header.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileFavoriteFolderHeader extends StatefulWidget { const MobileFavoriteFolderHeader({ super.key, required this.onPressed, required this.onAdded, required this.isExpanded, }); final VoidCallback onPressed; final VoidCallback onAdded; final bool isExpanded; @override State createState() => _MobileFavoriteFolderHeaderState(); } class _MobileFavoriteFolderHeaderState extends State { double _turns = 0; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: FlowyButton( text: FlowyText.semibold( LocaleKeys.sideBar_favorites.tr(), fontSize: 20.0, ), margin: const EdgeInsets.symmetric(vertical: 8), expandText: false, mainAxisAlignment: MainAxisAlignment.start, rightIcon: AnimatedRotation( duration: const Duration(milliseconds: 200), turns: _turns, child: const Icon( Icons.keyboard_arrow_down_rounded, color: Colors.grey, ), ), onTap: () { setState(() { _turns = widget.isExpanded ? -0.25 : 0; }); widget.onPressed(); }, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/home.dart ================================================ export 'mobile_home_page.dart'; export 'mobile_home_setting_page.dart'; export 'mobile_home_trash_page.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/mobile/presentation/home/mobile_folders.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileHomeSpace extends StatefulWidget { const MobileHomeSpace({super.key, required this.userProfile}); final UserProfilePB userProfile; @override State createState() => _MobileHomeSpaceState(); } class _MobileHomeSpaceState extends State with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); final workspaceId = context.read().state.currentWorkspace?.workspaceId ?? ''; return SingleChildScrollView( child: Padding( padding: EdgeInsets.only( top: HomeSpaceViewSizes.mVerticalPadding, bottom: HomeSpaceViewSizes.mVerticalPadding + MediaQuery.of(context).padding.bottom, ), child: MobileFolders( user: widget.userProfile, workspaceId: workspaceId, showFavorite: false, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart'; import 'package:appflowy/mobile/presentation/home/space/mobile_space.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; // Contains Public And Private Sections class MobileFolders extends StatelessWidget { const MobileFolders({ super.key, required this.user, required this.workspaceId, required this.showFavorite, }); final UserProfilePB user; final String workspaceId; final bool showFavorite; @override Widget build(BuildContext context) { final workspaceId = context.read().state.currentWorkspace?.workspaceId ?? ''; return BlocListener( listenWhen: (previous, current) => previous.currentWorkspace?.workspaceId != current.currentWorkspace?.workspaceId, listener: (context, state) { context.read().add( SidebarSectionsEvent.initial( user, state.currentWorkspace?.workspaceId ?? workspaceId, ), ); context.read().add( SpaceEvent.reset( user, state.currentWorkspace?.workspaceId ?? workspaceId, false, ), ); }, child: const _MobileFolder(), ); } } class _MobileFolder extends StatefulWidget { const _MobileFolder(); @override State<_MobileFolder> createState() => _MobileFolderState(); } class _MobileFolderState extends State<_MobileFolder> { @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SlidableAutoCloseBehavior( child: Column( children: [ ..._buildSpaceOrSection(context, state), const VSpace(80.0), ], ), ); }, ); } List _buildSpaceOrSection( BuildContext context, SidebarSectionsState state, ) { if (context.watch().state.spaces.isNotEmpty) { return [ const MobileSpace(), ]; } if (context.read().state.isCollabWorkspaceOn) { return [ MobileSectionFolder( title: LocaleKeys.sideBar_workspace.tr(), spaceType: FolderSpaceType.public, views: state.section.publicViews, ), const VSpace(8.0), MobileSectionFolder( title: LocaleKeys.sideBar_private.tr(), spaceType: FolderSpaceType.private, views: state.section.privateViews, ), ]; } return [ MobileSectionFolder( title: LocaleKeys.sideBar_personal.tr(), spaceType: FolderSpaceType.public, views: state.section.publicViews, ), ]; } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart ================================================ import 'package:appflowy/features/workspace/data/repositories/rust_workspace_repository_impl.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart'; import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; class MobileHomeScreen extends StatelessWidget { const MobileHomeScreen({super.key}); static const routeName = '/home'; @override Widget build(BuildContext context) { return FutureBuilder( future: Future.wait([ FolderEventGetCurrentWorkspaceSetting().send(), getIt().getUser(), ]), builder: (context, snapshots) { if (!snapshots.hasData) { return const Center(child: CircularProgressIndicator.adaptive()); } final workspaceLatest = snapshots.data?[0].fold( (workspaceLatestPB) { return workspaceLatestPB as WorkspaceLatestPB?; }, (error) => null, ); final userProfile = snapshots.data?[1].fold( (userProfilePB) { return userProfilePB as UserProfilePB?; }, (error) => null, ); // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. if (workspaceLatest == null || userProfile == null) { return const WorkspaceFailedScreen(); } return Scaffold( body: SafeArea( bottom: false, child: Provider.value( value: userProfile, child: MobileHomePage( userProfile: userProfile, workspaceLatest: workspaceLatest, ), ), ), ); }, ); } } final PropertyValueNotifier mCurrentWorkspace = PropertyValueNotifier(null); class MobileHomePage extends StatefulWidget { const MobileHomePage({ super.key, required this.userProfile, required this.workspaceLatest, }); final UserProfilePB userProfile; final WorkspaceLatestPB workspaceLatest; @override State createState() => _MobileHomePageState(); } class _MobileHomePageState extends State { Loading? loadingIndicator; @override void initState() { super.initState(); getIt().addLatestViewListener(_onLatestViewChange); getIt().add(const ReminderEvent.started()); } @override void dispose() { getIt().removeLatestViewListener(_onLatestViewChange); super.dispose(); } @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (_) => UserWorkspaceBloc( userProfile: widget.userProfile, repository: RustWorkspaceRepositoryImpl( userId: widget.userProfile.id, ), )..add(UserWorkspaceEvent.initialize()), ), BlocProvider( create: (context) => FavoriteBloc()..add(const FavoriteEvent.initial()), ), BlocProvider.value( value: getIt()..add(const ReminderEvent.started()), ), ], child: _HomePage(userProfile: widget.userProfile), ); } void _onLatestViewChange() async { final id = getIt().latestOpenView?.id; if (id == null || id.isEmpty) { return; } await FolderEventSetLatestView(ViewIdPB(value: id)).send(); } } class _HomePage extends StatefulWidget { const _HomePage({required this.userProfile}); final UserProfilePB userProfile; @override State<_HomePage> createState() => _HomePageState(); } class _HomePageState extends State<_HomePage> { Loading? loadingIndicator; @override Widget build(BuildContext context) { return BlocConsumer( buildWhen: (previous, current) => previous.currentWorkspace?.workspaceId != current.currentWorkspace?.workspaceId, listener: (context, state) { getIt().reset(); mCurrentWorkspace.value = state.currentWorkspace; if (FeatureFlag.search.isOn) { // Notify command palette that workspace has changed context.read().add( CommandPaletteEvent.workspaceChanged( workspaceId: state.currentWorkspace?.workspaceId, ), ); } Debounce.debounce( 'workspace_action_result', const Duration(milliseconds: 150), () { _showResultDialog(context, state); }, ); }, builder: (context, state) { if (state.currentWorkspace == null) { return const SizedBox.shrink(); } final workspaceId = state.currentWorkspace!.workspaceId; return Column( key: ValueKey('mobile_home_page_$workspaceId'), children: [ // Header Padding( padding: const EdgeInsets.only( left: HomeSpaceViewSizes.mHorizontalPadding, right: 8.0, ), child: MobileHomePageHeader( userProfile: widget.userProfile, ), ), Expanded( child: MultiBlocProvider( providers: [ BlocProvider( create: (_) => SpaceOrderBloc()..add(const SpaceOrderEvent.initial()), ), BlocProvider( create: (_) => SidebarSectionsBloc() ..add( SidebarSectionsEvent.initial( widget.userProfile, workspaceId, ), ), ), BlocProvider( create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), ), BlocProvider( create: (_) => SpaceBloc( userProfile: widget.userProfile, workspaceId: workspaceId, )..add( const SpaceEvent.initial( openFirstPage: false, ), ), ), ], child: MobileHomePageTab( userProfile: widget.userProfile, ), ), ), ], ); }, ); } void _showResultDialog(BuildContext context, UserWorkspaceState state) { final actionResult = state.actionResult; if (actionResult == null) { return; } Log.info('workspace action result: $actionResult'); final actionType = actionResult.actionType; final result = actionResult.result; final isLoading = actionResult.isLoading; if (isLoading) { loadingIndicator ??= Loading(context)..start(); return; } else { loadingIndicator?.stop(); loadingIndicator = null; } if (result == null) { return; } result.onFailure((f) { Log.error( '[Workspace] Failed to perform ${actionType.toString()} action: $f', ); }); final String? message; ToastificationType toastType = ToastificationType.success; switch (actionType) { case WorkspaceActionType.open: message = result.onFailure((e) { toastType = ToastificationType.error; return '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}'; }); break; case WorkspaceActionType.delete: message = result.fold( (s) { toastType = ToastificationType.success; return LocaleKeys.workspace_deleteSuccess.tr(); }, (e) { toastType = ToastificationType.error; return '${LocaleKeys.workspace_deleteFailed.tr()}: ${e.msg}'; }, ); break; case WorkspaceActionType.leave: message = result.fold( (s) { toastType = ToastificationType.success; return LocaleKeys .settings_workspacePage_leaveWorkspacePrompt_success .tr(); }, (e) { toastType = ToastificationType.error; return '${LocaleKeys.settings_workspacePage_leaveWorkspacePrompt_fail.tr()}: ${e.msg}'; }, ); break; case WorkspaceActionType.rename: message = result.fold( (s) { toastType = ToastificationType.success; return LocaleKeys.workspace_renameSuccess.tr(); }, (e) { toastType = ToastificationType.error; return '${LocaleKeys.workspace_renameFailed.tr()}: ${e.msg}'; }, ); break; default: message = null; toastType = ToastificationType.error; break; } if (message != null) { showToastNotification(message: message, type: toastType); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/built_in_svgs.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'setting/settings_popup_menu.dart'; class MobileHomePageHeader extends StatelessWidget { const MobileHomePageHeader({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => getIt(param1: userProfile) ..add(const SettingsUserEvent.initial()), child: BlocBuilder( builder: (context, state) { final isCollaborativeWorkspace = context.read().state.isCollabWorkspaceOn; return ConstrainedBox( constraints: const BoxConstraints(minHeight: 56), child: Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: isCollaborativeWorkspace ? _MobileWorkspace(userProfile: userProfile) : _MobileUser(userProfile: userProfile), ), HomePageSettingsPopupMenu( userProfile: userProfile, ), const HSpace(8.0), ], ), ); }, ), ); } } class _MobileUser extends StatelessWidget { const _MobileUser({ required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { final userIcon = userProfile.iconUrl; return Row( children: [ _UserIcon(userIcon: userIcon), const HSpace(12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const FlowyText.medium('AppFlowy', fontSize: 18), const VSpace(4), FlowyText.regular( userProfile.email.isNotEmpty ? userProfile.email : userProfile.name, fontSize: 12, color: Theme.of(context).colorScheme.onSurface, overflow: TextOverflow.ellipsis, ), ], ), ), ], ); } } class _MobileWorkspace extends StatelessWidget { const _MobileWorkspace({ required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final currentWorkspace = state.currentWorkspace; if (currentWorkspace == null) { return const SizedBox.shrink(); } return AnimatedGestureDetector( scaleFactor: 0.99, alignment: Alignment.centerLeft, onTapUp: () { context.read().add( UserWorkspaceEvent.fetchWorkspaces(), ); _showSwitchWorkspacesBottomSheet(context); }, child: Row( children: [ WorkspaceIcon( workspaceIcon: currentWorkspace.icon, workspaceName: currentWorkspace.name, iconSize: 36, fontSize: 18.0, isEditable: true, figmaLineHeight: 26.0, emojiSize: 24.0, borderRadius: 12.0, onSelected: (result) => context.read().add( UserWorkspaceEvent.updateWorkspaceIcon( workspaceId: currentWorkspace.workspaceId, icon: result.emoji, ), ), ), currentWorkspace.icon.isNotEmpty ? const HSpace(2) : const HSpace(8), Flexible( child: FlowyText.semibold( currentWorkspace.name, fontSize: 20.0, overflow: TextOverflow.ellipsis, ), ), ], ), ); }, ); } void _showSwitchWorkspacesBottomSheet( BuildContext context, ) { showMobileBottomSheet( context, showDivider: false, showHeader: true, showDragHandle: true, showCloseButton: true, useRootNavigator: true, enableScrollable: true, bottomSheetPadding: context.bottomSheetPadding(), title: LocaleKeys.workspace_menuTitle.tr(), backgroundColor: Theme.of(context).colorScheme.surface, builder: (sheetContext) { return BlocProvider.value( value: context.read(), child: BlocBuilder( builder: (context, state) { final currentWorkspace = state.currentWorkspace; final workspaces = state.workspaces; if (currentWorkspace == null || workspaces.isEmpty) { return const SizedBox.shrink(); } return MobileWorkspaceMenu( userProfile: userProfile, currentWorkspace: currentWorkspace, workspaces: workspaces, onWorkspaceSelected: (workspace) { Navigator.of(sheetContext).pop(); if (workspace == currentWorkspace) { return; } context.read().add( UserWorkspaceEvent.openWorkspace( workspaceId: workspace.workspaceId, workspaceType: workspace.workspaceType, ), ); }, ); }, ), ); }, ); } } class _UserIcon extends StatelessWidget { const _UserIcon({ required this.userIcon, }); final String userIcon; @override Widget build(BuildContext context) { return FlowyButton( useIntrinsicWidth: true, text: builtInSVGIcons.contains(userIcon) // to be compatible with old user icon ? FlowySvg( FlowySvgData('emoji/$userIcon'), size: const Size.square(32), blendMode: null, ) : FlowyText( userIcon.isNotEmpty ? userIcon : '🐻', fontSize: 26, ), onTap: () async { final icon = await context.push( Uri( path: MobileEmojiPickerScreen.routeName, queryParameters: { MobileEmojiPickerScreen.pageTitle: LocaleKeys.titleBar_userIcon.tr(), MobileEmojiPickerScreen.selectTabs: [PickerTabType.emoji.name], }, ).toString(), ); if (icon != null) { if (context.mounted) { context.read().add( SettingsUserEvent.updateUserIcon( iconUrl: icon.emoji, ), ); } } }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy/features/workspace/data/repositories/rust_workspace_repository_impl.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/ai/ai_settings_group.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/cloud_setting_group.dart'; import 'package:appflowy/mobile/presentation/setting/user_session_setting_group.dart'; import 'package:appflowy/mobile/presentation/setting/workspace/workspace_setting_group.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileHomeSettingPage extends StatefulWidget { const MobileHomeSettingPage({ super.key, }); static const routeName = '/settings'; @override State createState() => _MobileHomeSettingPageState(); } class _MobileHomeSettingPageState extends State { @override Widget build(BuildContext context) { return FutureBuilder( future: getIt().getUser(), builder: (context, snapshot) { String? errorMsg; if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator.adaptive()); } final userProfile = snapshot.data?.fold( (userProfile) { return userProfile; }, (error) { errorMsg = error.msg; return null; }, ); return Scaffold( appBar: FlowyAppBar( titleText: LocaleKeys.settings_title.tr(), ), body: userProfile == null ? _buildErrorWidget(errorMsg) : _buildSettingsWidget(userProfile), ); }, ); } Widget _buildErrorWidget(String? errorMsg) { return FlowyMobileStateContainer.error( emoji: '🛸', title: LocaleKeys.settings_mobile_userprofileError.tr(), description: LocaleKeys.settings_mobile_userprofileErrorDescription.tr(), errorMsg: errorMsg, ); } Widget _buildSettingsWidget(UserProfilePB userProfile) { return BlocProvider( create: (context) => UserWorkspaceBloc( userProfile: userProfile, repository: RustWorkspaceRepositoryImpl( userId: userProfile.id, ), )..add(UserWorkspaceEvent.initialize()), child: BlocBuilder( builder: (context, state) { final currentWorkspaceId = state.currentWorkspace?.workspaceId ?? ''; return SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ PersonalInfoSettingGroup( userProfile: userProfile, ), if (state.userProfile.userAuthType == AuthTypePB.Server) const WorkspaceSettingGroup(), const AppearanceSettingGroup(), const LanguageSettingGroup(), if (Env.enableCustomCloud) const CloudSettingGroup(), if (isAuthEnabled) AiSettingsGroup( key: ValueKey(currentWorkspaceId), userProfile: userProfile, workspaceId: currentWorkspaceId, ), const SupportSettingGroup(), const AboutSettingGroup(), UserSessionSettingGroup( userProfile: userProfile, showThirdPartyLogin: false, ), const VSpace(20), ], ), ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; class MobileHomeTrashPage extends StatelessWidget { const MobileHomeTrashPage({super.key}); static const routeName = '/trash'; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => getIt()..add(const TrashEvent.initial()), child: BlocBuilder( builder: (context, state) { return Scaffold( appBar: AppBar( title: Text(LocaleKeys.trash_text.tr()), actions: [ state.objects.isEmpty ? const SizedBox.shrink() : IconButton( splashRadius: 20, icon: const Icon(Icons.more_horiz), onPressed: () { final trashBloc = context.read(); showMobileBottomSheet( context, showHeader: true, showCloseButton: true, showDragHandle: true, padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), title: LocaleKeys.trash_mobile_actions.tr(), builder: (_) => Row( children: [ Expanded( child: _TrashActionAllButton( trashBloc: trashBloc, ), ), const SizedBox( width: 16, ), Expanded( child: _TrashActionAllButton( trashBloc: trashBloc, type: _TrashActionType.restoreAll, ), ), ], ), ); }, ), ], ), body: state.objects.isEmpty ? const _EmptyTrashBin() : _DeletedFilesListView(state), ); }, ), ); } } enum _TrashActionType { restoreAll, deleteAll, } class _EmptyTrashBin extends StatelessWidget { const _EmptyTrashBin(); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( FlowySvgs.m_empty_trash_xl, size: Size.square(46), ), const VSpace(16.0), FlowyText.medium( LocaleKeys.trash_mobile_empty.tr(), fontSize: 18.0, textAlign: TextAlign.center, ), const VSpace(8.0), FlowyText.regular( LocaleKeys.trash_mobile_emptyDescription.tr(), fontSize: 17.0, maxLines: 10, textAlign: TextAlign.center, lineHeight: 1.3, color: Theme.of(context).hintColor, ), const VSpace(kBottomNavigationBarHeight + 36.0), ], ), ); } } class _TrashActionAllButton extends StatelessWidget { /// Switch between 'delete all' and 'restore all' feature const _TrashActionAllButton({ this.type = _TrashActionType.deleteAll, required this.trashBloc, }); final _TrashActionType type; final TrashBloc trashBloc; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDeleteAll = type == _TrashActionType.deleteAll; return BlocProvider.value( value: trashBloc, child: BottomSheetActionWidget( svg: isDeleteAll ? FlowySvgs.m_delete_m : FlowySvgs.m_restore_m, text: isDeleteAll ? LocaleKeys.trash_deleteAll.tr() : LocaleKeys.trash_restoreAll.tr(), onTap: () { final trashList = trashBloc.state.objects; if (trashList.isNotEmpty) { context.pop(); showFlowyMobileConfirmDialog( context, title: FlowyText( isDeleteAll ? LocaleKeys.trash_confirmDeleteAll_title.tr() : LocaleKeys.trash_restoreAll.tr(), ), content: FlowyText( isDeleteAll ? LocaleKeys.trash_confirmDeleteAll_caption.tr() : LocaleKeys.trash_confirmRestoreAll_caption.tr(), ), actionButtonTitle: isDeleteAll ? LocaleKeys.trash_deleteAll.tr() : LocaleKeys.trash_restoreAll.tr(), actionButtonColor: isDeleteAll ? theme.colorScheme.error : theme.colorScheme.primary, onActionButtonPressed: () { if (isDeleteAll) { trashBloc.add( const TrashEvent.deleteAll(), ); } else { trashBloc.add( const TrashEvent.restoreAll(), ); } }, cancelButtonTitle: LocaleKeys.button_cancel.tr(), ); } else { // when there is no deleted files // show toast Fluttertoast.showToast( msg: LocaleKeys.trash_mobile_empty.tr(), gravity: ToastGravity.CENTER, ); } }, ), ); } } class _DeletedFilesListView extends StatelessWidget { const _DeletedFilesListView( this.state, ); final TrashState state; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: ListView.builder( itemBuilder: (context, index) { final deletedFile = state.objects[index]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile( // TODO: show different file type icon, implement this feature after TrashPB has file type field leading: FlowySvg( FlowySvgs.document_s, size: const Size.square(24), color: theme.colorScheme.onSurface, ), title: Text( deletedFile.name, style: theme.textTheme.labelMedium ?.copyWith(color: theme.colorScheme.onSurface), ), horizontalTitleGap: 0, tileColor: theme.colorScheme.onSurface.withValues(alpha: 0.1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( splashRadius: 20, icon: FlowySvg( FlowySvgs.m_restore_m, size: const Size.square(24), color: theme.colorScheme.onSurface, ), onPressed: () { context .read() .add(TrashEvent.putback(deletedFile.id)); Fluttertoast.showToast( msg: '${deletedFile.name} ${LocaleKeys.trash_mobile_isRestored.tr()}', gravity: ToastGravity.BOTTOM, ); }, ), IconButton( splashRadius: 20, icon: FlowySvg( FlowySvgs.m_delete_m, size: const Size.square(24), color: theme.colorScheme.onSurface, ), onPressed: () { context .read() .add(TrashEvent.delete(deletedFile)); Fluttertoast.showToast( msg: '${deletedFile.name} ${LocaleKeys.trash_mobile_isDeleted.tr()}', gravity: ToastGravity.BOTTOM, ); }, ), ], ), ), ); }, itemCount: state.objects.length, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/recent_folder/mobile_recent_view.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/workspace/application/recent/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MobileRecentFolder extends StatefulWidget { const MobileRecentFolder({super.key}); @override State createState() => _MobileRecentFolderState(); } class _MobileRecentFolderState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) => RecentViewsBloc()..add(const RecentViewsEvent.initial()), child: BlocListener( listenWhen: (previous, current) => current.currentWorkspace != null && previous.currentWorkspace?.workspaceId != current.currentWorkspace!.workspaceId, listener: (context, state) => context .read() .add(const RecentViewsEvent.resetRecentViews()), child: BlocBuilder( builder: (context, state) { final ids = {}; List recentViews = state.views.map((e) => e.item).toList(); recentViews.retainWhere((element) => ids.add(element.id)); // only keep the first 20 items. recentViews = recentViews.take(20).toList(); if (recentViews.isEmpty) { return const SizedBox.shrink(); } return Column( children: [ _RecentViews( key: ValueKey(recentViews), // the recent views are in reverse order recentViews: recentViews, ), const VSpace(12.0), ], ); }, ), ), ); } } class _RecentViews extends StatelessWidget { const _RecentViews({ super.key, required this.recentViews, }); final List recentViews; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: GestureDetector( child: FlowyText.semibold( LocaleKeys.sideBar_recent.tr(), fontSize: 20.0, ), onTap: () { showMobileBottomSheet( context, showDivider: false, showDragHandle: true, backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return Column( children: [ FlowyOptionTile.text( text: LocaleKeys.button_clear.tr(), leftIcon: FlowySvg( FlowySvgs.m_delete_s, color: Theme.of(context).colorScheme.error, ), textColor: Theme.of(context).colorScheme.error, onTap: () { context.read().add( RecentViewsEvent.removeRecentViews( recentViews.map((e) => e.id).toList(), ), ); context.pop(); }, ), ], ); }, ); }, ), ), SizedBox( height: 148, child: ListView.separated( key: const PageStorageKey('recent_views_page_storage_key'), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), scrollDirection: Axis.horizontal, itemBuilder: (context, index) { final view = recentViews[index]; return SizedBox.square( dimension: 148, child: MobileRecentView(view: view), ); }, separatorBuilder: (context, index) => const HSpace(8), itemCount: recentViews.length, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart ================================================ import 'dart:io'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:string_validator/string_validator.dart'; class MobileRecentView extends StatelessWidget { const MobileRecentView({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { final theme = Theme.of(context); return BlocProvider( create: (context) => RecentViewBloc(view: view) ..add( const RecentViewEvent.initial(), ), child: BlocBuilder( builder: (context, state) { return GestureDetector( onTap: () => context.pushView(view), child: Stack( children: [ DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: theme.colorScheme.outline), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(child: _buildCover(context, state)), Expanded(child: _buildTitle(context, state)), ], ), ), Align( alignment: Alignment.centerLeft, child: _buildIcon(context, state), ), ], ), ); }, ), ); } Widget _buildCover(BuildContext context, RecentViewState state) { return Padding( padding: const EdgeInsets.only(top: 1.0, left: 1.0, right: 1.0), child: ClipRRect( borderRadius: const BorderRadius.only( topLeft: Radius.circular(8), topRight: Radius.circular(8), ), child: _RecentCover( coverTypeV1: state.coverTypeV1, coverTypeV2: state.coverTypeV2, value: state.coverValue, ), ), ); } Widget _buildTitle(BuildContext context, RecentViewState state) { return Padding( padding: const EdgeInsets.fromLTRB(8, 18, 8, 2), // hack: minLines currently not supported in Text widget. // https://github.com/flutter/flutter/issues/31134 child: Stack( children: [ FlowyText.medium( view.name, fontSize: 16.0, maxLines: 2, overflow: TextOverflow.ellipsis, ), const FlowyText( "\n\n", maxLines: 2, ), ], ), ); } Widget _buildIcon(BuildContext context, RecentViewState state) { return Padding( padding: const EdgeInsets.only(left: 8.0), child: state.icon.isNotEmpty ? RawEmojiIconWidget(emoji: state.icon, emojiSize: 30) : SizedBox.square( dimension: 32.0, child: view.defaultIcon(), ), ); } } class _RecentCover extends StatelessWidget { const _RecentCover({ required this.coverTypeV1, this.coverTypeV2, this.value, }); final CoverType coverTypeV1; final PageStyleCoverImageType? coverTypeV2; final String? value; @override Widget build(BuildContext context) { final placeholder = Container( // random color, update it once we have a better placeholder color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.2), ); final value = this.value; if (value == null) { return placeholder; } if (coverTypeV2 != null) { return _buildCoverV2(context, value, placeholder); } return _buildCoverV1(context, value, placeholder); } Widget _buildCoverV2(BuildContext context, String value, Widget placeholder) { final type = coverTypeV2; if (type == null) { return placeholder; } if (type == PageStyleCoverImageType.customImage || type == PageStyleCoverImageType.unsplashImage) { final userProfilePB = Provider.of(context); return FlowyNetworkImage( url: value, userProfilePB: userProfilePB, ); } if (type == PageStyleCoverImageType.builtInImage) { return Image.asset( PageStyleCoverImageType.builtInImagePath(value), fit: BoxFit.cover, ); } if (type == PageStyleCoverImageType.pureColor) { final color = value.coverColor(context); if (color != null) { return ColoredBox( color: color, ); } } if (type == PageStyleCoverImageType.gradientColor) { return Container( decoration: BoxDecoration( gradient: FlowyGradientColor.fromId(value).linear, ), ); } if (type == PageStyleCoverImageType.localImage) { return Image.file( File(value), fit: BoxFit.cover, ); } return placeholder; } Widget _buildCoverV1(BuildContext context, String value, Widget placeholder) { switch (coverTypeV1) { case CoverType.file: if (isURL(value)) { final userProfilePB = Provider.of(context); return FlowyNetworkImage( url: value, userProfilePB: userProfilePB, ); } final imageFile = File(value); if (!imageFile.existsSync()) { return placeholder; } return Image.file( imageFile, ); case CoverType.asset: return Image.asset( value, fit: BoxFit.cover, ); case CoverType.color: final color = value.tryToColor() ?? Colors.white; return Container( color: color, ); case CoverType.none: return placeholder; } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart ================================================ import 'package:appflowy/mobile/presentation/home/shared/empty_placeholder.dart'; import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/recent/prelude.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; class MobileRecentSpace extends StatefulWidget { const MobileRecentSpace({super.key}); @override State createState() => _MobileRecentSpaceState(); } class _MobileRecentSpaceState extends State with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); return BlocProvider( create: (context) => RecentViewsBloc()..add(const RecentViewsEvent.initial()), child: BlocBuilder( builder: (context, state) { if (state.isLoading) { return const SizedBox.shrink(); } final recentViews = _filterRecentViews(state.views); if (recentViews.isEmpty) { return const Center( child: EmptySpacePlaceholder(type: MobilePageCardType.recent), ); } return _RecentViews(recentViews: recentViews); }, ), ); } List _filterRecentViews(List recentViews) { final ids = {}; final filteredRecentViews = recentViews.toList(); filteredRecentViews.retainWhere((e) => ids.add(e.item.id)); return filteredRecentViews; } } class _RecentViews extends StatelessWidget { const _RecentViews({ required this.recentViews, }); final List recentViews; @override Widget build(BuildContext context) { final borderColor = Theme.of(context).isLightMode ? const Color(0xFFE9E9EC) : const Color(0x1AFFFFFF); return SlidableAutoCloseBehavior( child: ListView.separated( key: const PageStorageKey('recent_views_page_storage_key'), padding: EdgeInsets.only( bottom: HomeSpaceViewSizes.mVerticalPadding + MediaQuery.of(context).padding.bottom, ), itemBuilder: (context, index) { final sectionView = recentViews[index]; return Container( padding: const EdgeInsets.symmetric(vertical: 24.0), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: borderColor, width: 0.5, ), ), ), child: MobileViewPage( key: ValueKey(sectionView.item.id), view: sectionView.item, timestamp: sectionView.timestamp, type: MobilePageCardType.recent, ), ); }, separatorBuilder: (context, index) => const HSpace(8), itemCount: recentViews.length, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileSectionFolder extends StatelessWidget { const MobileSectionFolder({ super.key, required this.title, required this.views, required this.spaceType, }); final String title; final List views; final FolderSpaceType spaceType; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => FolderBloc(type: spaceType) ..add( const FolderEvent.initial(), ), child: BlocBuilder( builder: (context, state) { return Column( children: [ SizedBox( height: HomeSpaceViewSizes.mViewHeight, child: MobileSectionFolderHeader( title: title, isExpanded: context.read().state.isExpanded, onPressed: () => context .read() .add(const FolderEvent.expandOrUnExpand()), onAdded: () => _createNewPage(context), ), ), if (state.isExpanded) Padding( padding: const EdgeInsets.only( left: HomeSpaceViewSizes.leftPadding, ), child: _Pages( views: views, spaceType: spaceType, ), ), ], ); }, ), ); } void _createNewPage(BuildContext context) { context.read().add( SidebarSectionsEvent.createRootViewInSection( name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), index: 0, viewSection: spaceType.toViewSectionPB, ), ); context.read().add( const FolderEvent.expandOrUnExpand(isExpanded: true), ); } } class _Pages extends StatelessWidget { const _Pages({ required this.views, required this.spaceType, }); final List views; final FolderSpaceType spaceType; @override Widget build(BuildContext context) { return Column( children: views .map( (view) => MobileViewItem( key: ValueKey( '${FolderSpaceType.private.name} ${view.id}', ), spaceType: spaceType, isFirstChild: view.id == views.first.id, view: view, level: 0, leftPadding: HomeSpaceViewSizes.leftPadding, isFeedback: false, onSelected: (v) => context.pushView( v, tabs: [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ].map((e) => e.name).toList(), ), endActionPane: (context) { final view = context.read().state.view; return buildEndActionPane( context, [ MobilePaneActionType.more, if (view.layout == ViewLayoutPB.Document) MobilePaneActionType.add, ], spaceType: spaceType, needSpace: false, spaceRatio: 5, ); }, ), ) .toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @visibleForTesting const Key mobileCreateNewPageButtonKey = Key('mobileCreateNewPageButtonKey'); class MobileSectionFolderHeader extends StatefulWidget { const MobileSectionFolderHeader({ super.key, required this.title, required this.onPressed, required this.onAdded, required this.isExpanded, }); final String title; final VoidCallback onPressed; final VoidCallback onAdded; final bool isExpanded; @override State createState() => _MobileSectionFolderHeaderState(); } class _MobileSectionFolderHeaderState extends State { double _turns = 0; @override Widget build(BuildContext context) { return Row( children: [ const HSpace(HomeSpaceViewSizes.mHorizontalPadding), Expanded( child: FlowyButton( text: FlowyText.medium( widget.title, fontSize: 16.0, ), margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 2.0), expandText: false, iconPadding: 2, mainAxisAlignment: MainAxisAlignment.start, rightIcon: AnimatedRotation( duration: const Duration(milliseconds: 200), turns: _turns, child: const FlowySvg( FlowySvgs.m_spaces_expand_s, ), ), onTap: () { setState(() { _turns = widget.isExpanded ? -0.25 : 0; }); widget.onPressed(); }, ), ), GestureDetector( behavior: HitTestBehavior.translucent, onTap: widget.onAdded, child: Container( // expand the touch area margin: const EdgeInsets.symmetric( horizontal: HomeSpaceViewSizes.mHorizontalPadding, vertical: 8.0, ), child: const FlowySvg( FlowySvgs.m_space_add_s, ), ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart' hide PopupMenuButton, PopupMenuDivider, PopupMenuItem, PopupMenuEntry; import 'package:go_router/go_router.dart'; enum _MobileSettingsPopupMenuItem { settings, members, trash, help, helpAndDocumentation, } class HomePageSettingsPopupMenu extends StatelessWidget { const HomePageSettingsPopupMenu({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { return PopupMenuButton<_MobileSettingsPopupMenuItem>( offset: const Offset(0, 36), padding: EdgeInsets.zero, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all( Radius.circular(12.0), ), ), shadowColor: const Color(0x68000000), elevation: 10, color: context.popupMenuBackgroundColor, itemBuilder: (BuildContext context) => >[ _buildItem( value: _MobileSettingsPopupMenuItem.settings, svg: FlowySvgs.m_notification_settings_s, text: LocaleKeys.settings_popupMenuItem_settings.tr(), ), // only show the member items in cloud mode if (userProfile.workspaceType == WorkspaceTypePB.ServerW) ...[ const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.members, svg: FlowySvgs.m_settings_member_s, text: LocaleKeys.settings_popupMenuItem_members.tr(), ), ], const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.trash, svg: FlowySvgs.trash_s, text: LocaleKeys.settings_popupMenuItem_trash.tr(), ), const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.helpAndDocumentation, svg: FlowySvgs.help_and_documentation_s, text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(), ), const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.help, svg: FlowySvgs.message_support_s, text: LocaleKeys.settings_popupMenuItem_getSupport.tr(), ), ], onSelected: (_MobileSettingsPopupMenuItem value) { switch (value) { case _MobileSettingsPopupMenuItem.members: _openMembersPage(context); break; case _MobileSettingsPopupMenuItem.trash: _openTrashPage(context); break; case _MobileSettingsPopupMenuItem.settings: _openSettingsPage(context); break; case _MobileSettingsPopupMenuItem.help: _openHelpPage(context); break; case _MobileSettingsPopupMenuItem.helpAndDocumentation: _openHelpAndDocumentationPage(context); break; } }, child: const Padding( padding: EdgeInsets.all(8.0), child: FlowySvg( FlowySvgs.m_settings_more_s, ), ), ); } PopupMenuItem _buildItem({ required T value, required FlowySvgData svg, required String text, }) { return PopupMenuItem( value: value, padding: EdgeInsets.zero, child: _PopupButton( svg: svg, text: text, ), ); } void _openMembersPage(BuildContext context) { context.push(InviteMembersScreen.routeName); } void _openTrashPage(BuildContext context) { context.push(MobileHomeTrashPage.routeName); } void _openHelpPage(BuildContext context) { afLaunchUrlString('https://discord.com/invite/9Q2xaN37tV'); } void _openSettingsPage(BuildContext context) { context.push(MobileHomeSettingPage.routeName); } void _openHelpAndDocumentationPage(BuildContext context) { afLaunchUrlString('https://appflowy.com/guide'); } } class _PopupButton extends StatelessWidget { const _PopupButton({ required this.svg, required this.text, }); final FlowySvgData svg; final String text; @override Widget build(BuildContext context) { return Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ FlowySvg(svg, size: const Size.square(20)), const HSpace(12), FlowyText.regular( text, fontSize: 16, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class EmptySpacePlaceholder extends StatelessWidget { const EmptySpacePlaceholder({ super.key, required this.type, }); final MobilePageCardType type; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 48.0), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( FlowySvgs.m_empty_page_xl, ), const VSpace(16.0), FlowyText.medium( _emptyPageText, fontSize: 18.0, textAlign: TextAlign.center, ), const VSpace(8.0), FlowyText.regular( _emptyPageSubText, fontSize: 17.0, maxLines: 10, textAlign: TextAlign.center, lineHeight: 1.3, color: Theme.of(context).hintColor, ), const VSpace(kBottomNavigationBarHeight + 36.0), ], ), ); } String get _emptyPageText => switch (type) { MobilePageCardType.recent => LocaleKeys.sideBar_emptyRecent.tr(), MobilePageCardType.favorite => LocaleKeys.sideBar_emptyFavorite.tr(), }; String get _emptyPageSubText => switch (type) { MobilePageCardType.recent => LocaleKeys.sideBar_emptyRecentDescription.tr(), MobilePageCardType.favorite => LocaleKeys.sideBar_emptyFavoriteDescription.tr(), }; } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:provider/provider.dart'; import 'package:string_validator/string_validator.dart'; import 'package:time/time.dart'; enum MobilePageCardType { recent, favorite; String get lastOperationHintText => switch (this) { MobilePageCardType.recent => LocaleKeys.sideBar_lastViewed.tr(), MobilePageCardType.favorite => LocaleKeys.sideBar_favoriteAt.tr(), }; } class MobileViewPage extends StatelessWidget { const MobileViewPage({ super.key, required this.view, this.timestamp, required this.type, }); final ViewPB view; final Int64? timestamp; final MobilePageCardType type; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (context) => ViewBloc(view: view, shouldLoadChildViews: false) ..add(const ViewEvent.initial()), ), BlocProvider( create: (context) => RecentViewBloc(view: view)..add(const RecentViewEvent.initial()), ), ], child: BlocBuilder( builder: (context, state) { return Slidable( endActionPane: buildEndActionPane( context, [ MobilePaneActionType.more, context.watch().state.view.isFavorite ? MobilePaneActionType.removeFromFavorites : MobilePaneActionType.addToFavorites, ], cardType: type, spaceRatio: 4, ), child: AnimatedGestureDetector( onTapUp: () => context.pushView( view, tabs: [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ].map((e) => e.name).toList(), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const HSpace(HomeSpaceViewSizes.mHorizontalPadding), Expanded(child: _buildDescription(context, state)), const HSpace(20.0), SizedBox( width: 84, height: 60, child: _buildCover(context, state), ), const HSpace(HomeSpaceViewSizes.mHorizontalPadding), ], ), ), ); }, ), ); } Widget _buildDescription(BuildContext context, RecentViewState state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ // page icon & page title _buildTitle(context, state), const VSpace(12.0), // author & last viewed _buildNameAndLastViewed(context, state), ], ); } Widget _buildNameAndLastViewed(BuildContext context, RecentViewState state) { final supportAvatar = isURL(state.icon.emoji); if (!supportAvatar) { return _buildLastViewed(context); } return Row( children: [ _buildAvatar(context, state), Flexible(child: _buildAuthor(context, state)), const Padding( padding: EdgeInsets.symmetric(horizontal: 3.0), child: FlowySvg(FlowySvgs.dot_s), ), _buildLastViewed(context), ], ); } Widget _buildAvatar(BuildContext context, RecentViewState state) { final userProfile = Provider.of(context); final iconUrl = userProfile?.iconUrl; if (iconUrl == null || iconUrl.isEmpty || view.createdBy != userProfile?.id || !isURL(iconUrl)) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(top: 2, bottom: 2, right: 8), child: ClipRRect( borderRadius: BorderRadius.circular(8.0), child: SizedBox.square( dimension: 16.0, child: FlowyNetworkImage( url: iconUrl, ), ), ), ); } Widget _buildCover(BuildContext context, RecentViewState state) { return ClipRRect( borderRadius: BorderRadius.circular(8), child: _ViewCover( layout: view.layout, coverTypeV1: state.coverTypeV1, coverTypeV2: state.coverTypeV2, value: state.coverValue, ), ); } Widget _buildTitle(BuildContext context, RecentViewState state) { final name = state.name; final icon = state.icon; return RichText( maxLines: 3, overflow: TextOverflow.ellipsis, text: TextSpan( children: [ if (icon.isNotEmpty) ...[ WidgetSpan( child: SizedBox( width: 20, child: RawEmojiIconWidget( emoji: icon, emojiSize: 18.0, ), ), ), const WidgetSpan(child: HSpace(8.0)), ], TextSpan( text: name.orDefault( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), ), style: Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: 16.0, fontWeight: FontWeight.w600, height: 1.3, ), ), ], ), ); } Widget _buildAuthor(BuildContext context, RecentViewState state) { return FlowyText.regular( // view.createdBy.toString(), '', fontSize: 12.0, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ); } Widget _buildLastViewed(BuildContext context) { final textColor = Theme.of(context).isLightMode ? const Color(0x7F171717) : Colors.white.withValues(alpha: 0.45); if (timestamp == null) { return const SizedBox.shrink(); } final date = _formatTimestamp( context, timestamp!.toInt() * 1000, ); return FlowyText.regular( date, fontSize: 13.0, color: textColor, ); } String _formatTimestamp(BuildContext context, int timestamp) { final now = DateTime.now(); final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp); final difference = now.difference(dateTime); final String date; final dateFormate = context.read().state.dateFormat; final timeFormate = context.read().state.timeFormat; if (difference.inMinutes < 1) { date = LocaleKeys.sideBar_justNow.tr(); } else if (difference.inHours < 1 && dateTime.isToday) { // Less than 1 hour date = LocaleKeys.sideBar_minutesAgo .tr(namedArgs: {'count': difference.inMinutes.toString()}); } else if (difference.inHours >= 1 && dateTime.isToday) { // in same day date = timeFormate.formatTime(dateTime); } else { date = dateFormate.formatDate(dateTime, false); } if (difference.inHours >= 1) { return '${type.lastOperationHintText} $date'; } return date; } } class _ViewCover extends StatelessWidget { const _ViewCover({ required this.layout, required this.coverTypeV1, this.coverTypeV2, this.value, }); final ViewLayoutPB layout; final CoverType coverTypeV1; final PageStyleCoverImageType? coverTypeV2; final String? value; @override Widget build(BuildContext context) { final placeholder = _buildPlaceholder(context); final value = this.value; if (value == null) { return placeholder; } if (coverTypeV2 != null) { return _buildCoverV2(context, value, placeholder); } return _buildCoverV1(context, value, placeholder); } Widget _buildPlaceholder(BuildContext context) { final isLightMode = Theme.of(context).isLightMode; final (svg, color) = switch (layout) { ViewLayoutPB.Document => ( FlowySvgs.m_document_thumbnail_m, isLightMode ? const Color(0xCCEDFBFF) : const Color(0x33658B90) ), ViewLayoutPB.Grid => ( FlowySvgs.m_grid_thumbnail_m, isLightMode ? const Color(0xFFF5F4FF) : const Color(0x338B80AD) ), ViewLayoutPB.Board => ( FlowySvgs.m_board_thumbnail_m, isLightMode ? const Color(0x7FE0FDD9) : const Color(0x3372936B), ), ViewLayoutPB.Calendar => ( FlowySvgs.m_calendar_thumbnail_m, isLightMode ? const Color(0xFFFFF7F0) : const Color(0x33A68B77) ), ViewLayoutPB.Chat => ( FlowySvgs.m_chat_thumbnail_m, isLightMode ? const Color(0x66FFE6FD) : const Color(0x33987195) ), _ => ( FlowySvgs.m_document_thumbnail_m, isLightMode ? Colors.black : Colors.white ) }; return ColoredBox( color: color, child: Center( child: FlowySvg( svg, blendMode: null, ), ), ); } Widget _buildCoverV2(BuildContext context, String value, Widget placeholder) { final type = coverTypeV2; if (type == null) { return placeholder; } if (type == PageStyleCoverImageType.customImage || type == PageStyleCoverImageType.unsplashImage) { final userProfilePB = Provider.of(context); return FlowyNetworkImage( url: value, userProfilePB: userProfilePB, ); } if (type == PageStyleCoverImageType.builtInImage) { return Image.asset( PageStyleCoverImageType.builtInImagePath(value), fit: BoxFit.cover, ); } if (type == PageStyleCoverImageType.pureColor) { final color = value.coverColor(context); if (color != null) { return ColoredBox( color: color, ); } } if (type == PageStyleCoverImageType.gradientColor) { return Container( decoration: BoxDecoration( gradient: FlowyGradientColor.fromId(value).linear, ), ); } if (type == PageStyleCoverImageType.localImage) { return Image.file( File(value), fit: BoxFit.cover, ); } return placeholder; } Widget _buildCoverV1(BuildContext context, String value, Widget placeholder) { switch (coverTypeV1) { case CoverType.file: if (isURL(value)) { final userProfilePB = Provider.of(context); return FlowyNetworkImage( url: value, userProfilePB: userProfilePB, ); } final imageFile = File(value); if (!imageFile.existsSync()) { return placeholder; } return Image.file( imageFile, ); case CoverType.asset: return Image.asset( value, fit: BoxFit.cover, ); case CoverType.color: final color = value.tryToColor() ?? Colors.white; return Container( color: color, ); case CoverType.none: return placeholder; } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/space/constants.dart ================================================ class SpaceUIConstants { static const itemHeight = 52.0; } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/space/manage_space_widget.dart ================================================ import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart' hide Icon; import 'widgets.dart'; enum ManageSpaceType { create, edit, } class ManageSpaceWidget extends StatelessWidget { const ManageSpaceWidget({ super.key, required this.controller, required this.permission, required this.selectedColor, required this.selectedIcon, required this.type, }); final TextEditingController controller; final ValueNotifier permission; final ValueNotifier selectedColor; final ValueNotifier selectedIcon; final ManageSpaceType type; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ ManageSpaceNameOption( controller: controller, type: type, ), ManageSpacePermissionOption(permission: permission), ConstrainedBox( constraints: const BoxConstraints( maxHeight: 560, ), child: ManageSpaceIconOption( selectedColor: selectedColor, selectedIcon: selectedIcon, ), ), const VSpace(60), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart'; import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileSpace extends StatelessWidget { const MobileSpace({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state.spaces.isEmpty) { return const SizedBox.shrink(); } final currentSpace = state.currentSpace ?? state.spaces.first; return Column( children: [ MobileSpaceHeader( isExpanded: state.isExpanded, space: currentSpace, onAdded: () => _showCreatePageMenu(context, currentSpace), onPressed: () => _showSpaceMenu(context), ), Padding( padding: const EdgeInsets.only( left: HomeSpaceViewSizes.mHorizontalPadding, ), child: _Pages( key: ValueKey(currentSpace.id), space: currentSpace, ), ), ], ); }, ); } void _showSpaceMenu(BuildContext context) { showMobileBottomSheet( context, showDivider: false, showHeader: true, showDragHandle: true, showCloseButton: true, showDoneButton: true, useRootNavigator: true, title: LocaleKeys.space_title.tr(), backgroundColor: Theme.of(context).colorScheme.surface, enableScrollable: true, bottomSheetPadding: context.bottomSheetPadding(), builder: (_) { return BlocProvider.value( value: context.read(), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 8.0), child: MobileSpaceMenu(), ), ); }, ); } void _showCreatePageMenu(BuildContext context, ViewPB space) { final title = space.name; showMobileBottomSheet( context, showHeader: true, title: title, showDragHandle: true, showCloseButton: true, useRootNavigator: true, showDivider: false, backgroundColor: Theme.of(context).colorScheme.surface, builder: (sheetContext) { return AddNewPageWidgetBottomSheet( view: space, onAction: (layout) { Navigator.of(sheetContext).pop(); context.read().add( SpaceEvent.createPage( name: '', layout: layout, index: 0, openAfterCreate: true, ), ); context.read().add( SpaceEvent.expand(space, true), ); }, ); }, ); } } class _Pages extends StatelessWidget { const _Pages({ super.key, required this.space, }); final ViewPB space; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => ViewBloc(view: space)..add(const ViewEvent.initial()), child: BlocBuilder( builder: (context, state) { final spaceType = space.spacePermission == SpacePermission.publicToAll ? FolderSpaceType.public : FolderSpaceType.private; final childViews = state.view.childViews.unique((view) => view.id); if (childViews.length != state.view.childViews.length) { final duplicatedViews = state.view.childViews .where((view) => childViews.contains(view)) .toList(); Log.error('some view id are duplicated: $duplicatedViews'); } return Column( children: childViews .map( (view) => MobileViewItem( key: ValueKey( '${space.id} ${view.id}', ), spaceType: spaceType, isFirstChild: view.id == state.view.childViews.first.id, view: view, level: 0, leftPadding: HomeSpaceViewSizes.leftPadding, isFeedback: false, onSelected: (v) => context.pushView( v, tabs: [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ].map((e) => e.name).toList(), ), endActionPane: (context) { final view = context.read().state.view; final actions = [ MobilePaneActionType.more, if (view.layout == ViewLayoutPB.Document) MobilePaneActionType.add, ]; return buildEndActionPane( context, actions, spaceType: spaceType, spaceRatio: actions.length == 1 ? 3 : 4, ); }, ), ) .toList(), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @visibleForTesting const Key mobileCreateNewPageButtonKey = Key('mobileCreateNewPageButtonKey'); class MobileSpaceHeader extends StatelessWidget { const MobileSpaceHeader({ super.key, required this.space, required this.onPressed, required this.onAdded, required this.isExpanded, }); final ViewPB space; final VoidCallback onPressed; final VoidCallback onAdded; final bool isExpanded; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: onPressed, child: SizedBox( height: 48, child: Row( children: [ const HSpace(HomeSpaceViewSizes.mHorizontalPadding), SpaceIcon( dimension: 24, space: space, svgSize: 14, textDimension: 18.0, cornerRadius: 6.0, ), const HSpace(8), FlowyText.medium( space.name, lineHeight: 1.15, fontSize: 16.0, ), const HSpace(4.0), const FlowySvg( FlowySvgs.workspace_drop_down_menu_show_s, ), const Spacer(), GestureDetector( behavior: HitTestBehavior.translucent, onTap: onAdded, child: Container( // expand the touch area margin: const EdgeInsets.symmetric( horizontal: HomeSpaceViewSizes.mHorizontalPadding, vertical: 8.0, ), child: const FlowySvg( FlowySvgs.m_space_add_s, ), ), ), ], ), ), ); } // Future _onAction(SpaceMoreActionType type, dynamic data) async { // switch (type) { // case SpaceMoreActionType.rename: // await _showRenameDialog(); // break; // case SpaceMoreActionType.changeIcon: // final (String icon, String iconColor) = data; // context.read().add(SpaceEvent.changeIcon(icon, iconColor)); // break; // case SpaceMoreActionType.manage: // _showManageSpaceDialog(context); // break; // case SpaceMoreActionType.addNewSpace: // break; // case SpaceMoreActionType.collapseAllPages: // break; // case SpaceMoreActionType.delete: // _showDeleteSpaceDialog(context); // break; // case SpaceMoreActionType.divider: // break; // } // } // Future _showRenameDialog() async { // await NavigatorTextFieldDialog( // title: LocaleKeys.space_rename.tr(), // value: space.name, // autoSelectAllText: true, // onConfirm: (name, _) { // context.read().add(SpaceEvent.rename(space, name)); // }, // ).show(context); // } // void _showManageSpaceDialog(BuildContext context) { // final spaceBloc = context.read(); // showDialog( // context: context, // builder: (_) { // return Dialog( // shape: RoundedRectangleBorder( // borderRadius: BorderRadius.circular(12.0), // ), // child: BlocProvider.value( // value: spaceBloc, // child: const ManageSpacePopup(), // ), // ); // }, // ); // } // void _showDeleteSpaceDialog(BuildContext context) { // final spaceBloc = context.read(); // showDialog( // context: context, // builder: (_) { // return Dialog( // shape: RoundedRectangleBorder( // borderRadius: BorderRadius.circular(12.0), // ), // child: BlocProvider.value( // value: spaceBloc, // child: const SizedBox(width: 440, child: DeleteSpacePopup()), // ), // ); // }, // ); // } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/space/space_menu_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/util/navigator_context_extension.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart' hide Icon; import 'package:flutter_bloc/flutter_bloc.dart'; import 'constants.dart'; import 'manage_space_widget.dart'; class MobileSpaceMenu extends StatelessWidget { const MobileSpaceMenu({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ const VSpace(4.0), for (final space in state.spaces) SizedBox( height: SpaceUIConstants.itemHeight, child: MobileSpaceMenuItem( space: space, isSelected: state.currentSpace?.id == space.id, ), ), const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), child: Divider( height: 0.5, ), ), const SizedBox( height: SpaceUIConstants.itemHeight, child: _CreateSpaceButton(), ), ], ), ); }, ); } } class MobileSpaceMenuItem extends StatelessWidget { const MobileSpaceMenuItem({ super.key, required this.space, required this.isSelected, }); final ViewPB space; final bool isSelected; @override Widget build(BuildContext context) { return FlowyButton( text: Row( children: [ FlowyText.medium( space.name, fontSize: 16.0, ), const HSpace(6.0), if (space.spacePermission == SpacePermission.private) const FlowySvg( FlowySvgs.space_lock_s, size: Size.square(12), ), ], ), margin: const EdgeInsets.symmetric(horizontal: 12.0), iconPadding: 10, leftIcon: SpaceIcon( dimension: 24, space: space, svgSize: 14, textDimension: 18.0, cornerRadius: 6.0, ), leftIconSize: const Size.square(24), rightIcon: SpaceMenuItemTrailing( key: ValueKey('${space.id}_space_menu_item_trailing'), space: space, currentSpace: context.read().state.currentSpace, ), onTap: () { context.read().add(SpaceEvent.open(space: space)); Navigator.of(context).pop(); }, ); } } class _CreateSpaceButton extends StatefulWidget { const _CreateSpaceButton(); @override State<_CreateSpaceButton> createState() => _CreateSpaceButtonState(); } class _CreateSpaceButtonState extends State<_CreateSpaceButton> { final controller = TextEditingController(); final permission = ValueNotifier( SpacePermission.publicToAll, ); final selectedColor = ValueNotifier( builtInSpaceColors.first, ); final selectedIcon = ValueNotifier( kIconGroups?.first.icons.first, ); @override void dispose() { controller.dispose(); permission.dispose(); selectedColor.dispose(); selectedIcon.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FlowyButton( text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()), iconPadding: 10, leftIcon: const Padding( padding: EdgeInsets.all(2.0), child: FlowySvg( FlowySvgs.space_add_s, ), ), margin: const EdgeInsets.symmetric(horizontal: 12.0), leftIconSize: const Size.square(24), onTap: () => _showCreateSpaceDialog(context), ); } Future _showCreateSpaceDialog(BuildContext context) async { await showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.space_createSpace.tr(), showCloseButton: true, showDivider: false, showDoneButton: true, enableScrollable: true, showDragHandle: true, bottomSheetPadding: context.bottomSheetPadding(), onDone: (bottomSheetContext) { final iconPath = selectedIcon.value?.iconPath ?? ''; context.read().add( SpaceEvent.create( name: controller.text.orDefault( LocaleKeys.space_defaultSpaceName.tr(), ), permission: permission.value, iconColor: selectedColor.value, icon: iconPath, createNewPageByDefault: true, openAfterCreate: false, ), ); Navigator.pop(bottomSheetContext); Navigator.pop(context); Log.info( 'create space on mobile, name: ${controller.text}, permission: ${permission.value}, color: ${selectedColor.value}, icon: $iconPath', ); }, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (bottomSheetContext) => ManageSpaceWidget( controller: controller, permission: permission, selectedColor: selectedColor, selectedIcon: selectedIcon, type: ManageSpaceType.create, ), ); _resetState(); } void _resetState() { controller.clear(); permission.value = SpacePermission.publicToAll; selectedColor.value = builtInSpaceColors.first; selectedIcon.value = kIconGroups?.first.icons.first; } } class SpaceMenuItemTrailing extends StatefulWidget { const SpaceMenuItemTrailing({ super.key, required this.space, this.currentSpace, }); final ViewPB space; final ViewPB? currentSpace; @override State createState() => _SpaceMenuItemTrailingState(); } class _SpaceMenuItemTrailingState extends State { final controller = TextEditingController(); final permission = ValueNotifier( SpacePermission.publicToAll, ); final selectedColor = ValueNotifier( builtInSpaceColors.first, ); final selectedIcon = ValueNotifier( kIconGroups?.first.icons.first, ); @override void dispose() { controller.dispose(); permission.dispose(); selectedColor.dispose(); selectedIcon.dispose(); super.dispose(); } @override Widget build(BuildContext context) { const iconSize = Size.square(20); return Row( children: [ const HSpace(12.0), // show the check icon if the space is the current space if (widget.space.id == widget.currentSpace?.id) const FlowySvg( FlowySvgs.m_blue_check_s, size: iconSize, blendMode: null, ), const HSpace(8.0), // more options button AnimatedGestureDetector( onTapUp: () => _showMoreOptions(context), child: const Padding( padding: EdgeInsets.all(8.0), child: FlowySvg( FlowySvgs.workspace_three_dots_s, size: iconSize, ), ), ), ], ); } void _showMoreOptions(BuildContext context) { final actions = [ SpaceMoreActionType.rename, SpaceMoreActionType.duplicate, SpaceMoreActionType.manage, SpaceMoreActionType.delete, ]; showMobileBottomSheet( context, showDragHandle: true, showDivider: false, useRootNavigator: true, backgroundColor: Theme.of(context).colorScheme.surface, builder: (bottomSheetContext) { return SpaceMenuMoreOptions( actions: actions, onAction: (action) => _onActions( context, bottomSheetContext, action, ), ); }, ); } void _onActions( BuildContext context, BuildContext bottomSheetContext, SpaceMoreActionType action, ) { Log.info('execute action in space menu bottom sheet: $action'); switch (action) { case SpaceMoreActionType.rename: _showRenameSpaceBottomSheet(context); break; case SpaceMoreActionType.duplicate: _duplicateSpace(context, bottomSheetContext); break; case SpaceMoreActionType.manage: _showManageSpaceBottomSheet(context); break; case SpaceMoreActionType.delete: _deleteSpace(context, bottomSheetContext); break; default: assert(false, 'Unsupported action: $action'); break; } } void _duplicateSpace(BuildContext context, BuildContext bottomSheetContext) { Log.info('duplicate the space: ${widget.space.name}'); context.read().add(const SpaceEvent.duplicate()); showToastNotification( message: LocaleKeys.space_success_duplicateSpace.tr(), ); Navigator.of(bottomSheetContext).pop(); Navigator.of(context).pop(); } void _showRenameSpaceBottomSheet(BuildContext context) { Navigator.of(context).pop(); showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.space_renameSpace.tr(), showCloseButton: true, showDragHandle: true, showDivider: false, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (bottomSheetContext) { return EditWorkspaceNameBottomSheet( type: EditWorkspaceNameType.edit, workspaceName: widget.space.name, hintText: LocaleKeys.space_spaceNamePlaceholder.tr(), validator: (value) => null, onSubmitted: (name) { // rename the workspace Log.info('rename the space, from: ${widget.space.name}, to: $name'); bottomSheetContext.popToHome(); context .read() .add(SpaceEvent.rename(space: widget.space, name: name)); showToastNotification( message: LocaleKeys.space_success_renameSpace.tr(), ); }, ); }, ); } Future _showManageSpaceBottomSheet(BuildContext context) async { controller.text = widget.space.name; permission.value = widget.space.spacePermission; selectedColor.value = widget.space.spaceIconColor ?? builtInSpaceColors.first; selectedIcon.value = widget.space.spaceIcon?.icon; await showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.space_manageSpace.tr(), showCloseButton: true, showDivider: false, showDoneButton: true, enableScrollable: true, showDragHandle: true, bottomSheetPadding: context.bottomSheetPadding(), onDone: (bottomSheetContext) { String iconName = ''; final icon = selectedIcon.value; final iconGroup = icon?.iconGroup; final iconId = icon?.name; if (icon != null && iconGroup != null) { iconName = '${iconGroup.name}/$iconId'; } Log.info( 'update space on mobile, name: ${controller.text}, permission: ${permission.value}, color: ${selectedColor.value}, icon: $iconName', ); context.read().add( SpaceEvent.update( space: widget.space, name: controller.text.orDefault( LocaleKeys.space_defaultSpaceName.tr(), ), permission: permission.value, iconColor: selectedColor.value, icon: iconName, ), ); showToastNotification( message: LocaleKeys.space_success_updateSpace.tr(), ); Navigator.pop(bottomSheetContext); Navigator.pop(context); }, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (bottomSheetContext) => ManageSpaceWidget( controller: controller, permission: permission, selectedColor: selectedColor, selectedIcon: selectedIcon, type: ManageSpaceType.edit, ), ); } void _deleteSpace( BuildContext context, BuildContext bottomSheetContext, ) { Navigator.of(bottomSheetContext).pop(); _showConfirmDialog( context, '${LocaleKeys.space_delete.tr()}: ${widget.space.name}', LocaleKeys.space_deleteConfirmationDescription.tr(), LocaleKeys.button_delete.tr(), (_) async { context.read().add(SpaceEvent.delete(widget.space)); showToastNotification( message: LocaleKeys.space_success_deleteSpace.tr(), ); Navigator.pop(context); }, ); } void _showConfirmDialog( BuildContext context, String title, String content, String rightButtonText, void Function(BuildContext context)? onRightButtonPressed, ) { showFlowyCupertinoConfirmDialog( title: title, content: FlowyText( content, fontSize: 14, maxLines: 10, ), leftButton: FlowyText( LocaleKeys.button_cancel.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w500, color: const Color(0xFF007AFF), ), rightButton: FlowyText( rightButtonText, fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w400, color: const Color(0xFFFE0220), ), onRightButtonPressed: onRightButtonPressed, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/space/space_menu_bottom_sheet.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'constants.dart'; class SpaceMenuMoreOptions extends StatelessWidget { const SpaceMenuMoreOptions({ super.key, required this.onAction, required this.actions, }); final void Function(SpaceMoreActionType action) onAction; final List actions; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: actions .map( (action) => _buildActionButton(context, action), ) .toList(), ); } Widget _buildActionButton( BuildContext context, SpaceMoreActionType action, ) { switch (action) { case SpaceMoreActionType.rename: return FlowyOptionTile.text( text: LocaleKeys.button_rename.tr(), height: SpaceUIConstants.itemHeight, leftIcon: const FlowySvg( FlowySvgs.view_item_rename_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( SpaceMoreActionType.rename, ), ); case SpaceMoreActionType.delete: return FlowyOptionTile.text( text: LocaleKeys.button_delete.tr(), height: SpaceUIConstants.itemHeight, textColor: Theme.of(context).colorScheme.error, leftIcon: FlowySvg( FlowySvgs.trash_s, size: const Size.square(18), color: Theme.of(context).colorScheme.error, ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( SpaceMoreActionType.delete, ), ); case SpaceMoreActionType.manage: return FlowyOptionTile.text( text: LocaleKeys.space_manage.tr(), height: SpaceUIConstants.itemHeight, leftIcon: const FlowySvg( FlowySvgs.settings_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( SpaceMoreActionType.manage, ), ); case SpaceMoreActionType.duplicate: return FlowyOptionTile.text( text: SpaceMoreActionType.duplicate.name, height: SpaceUIConstants.itemHeight, leftIcon: const FlowySvg( FlowySvgs.duplicate_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( SpaceMoreActionType.duplicate, ), ); default: return const SizedBox.shrink(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/space/space_permission_bottom_sheet.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class SpacePermissionBottomSheet extends StatelessWidget { const SpacePermissionBottomSheet({ super.key, required this.onAction, required this.permission, }); final SpacePermission permission; final void Function(SpacePermission action) onAction; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyOptionTile.text( text: LocaleKeys.space_publicPermission.tr(), leftIcon: const FlowySvg( FlowySvgs.space_permission_public_s, ), trailing: permission == SpacePermission.publicToAll ? const FlowySvg( FlowySvgs.m_blue_check_s, blendMode: null, ) : null, onTap: () => onAction(SpacePermission.publicToAll), ), FlowyOptionTile.text( text: LocaleKeys.space_privatePermission.tr(), showTopBorder: false, leftIcon: const FlowySvg( FlowySvgs.space_permission_private_s, ), trailing: permission == SpacePermission.private ? const FlowySvg( FlowySvgs.m_blue_check_s, blendMode: null, ) : null, onTap: () => onAction(SpacePermission.private), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/space/widgets.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/shared/icon_emoji_picker/colors.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart' hide Icon; import 'constants.dart'; import 'manage_space_widget.dart'; import 'space_permission_bottom_sheet.dart'; class ManageSpaceNameOption extends StatelessWidget { const ManageSpaceNameOption({ super.key, required this.controller, required this.type, }); final TextEditingController controller; final ManageSpaceType type; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 16, bottom: 4), child: FlowyText( LocaleKeys.space_spaceName.tr(), fontSize: 14, figmaLineHeight: 20.0, fontWeight: FontWeight.w400, color: Theme.of(context).hintColor, ), ), FlowyOptionTile.textField( controller: controller, autofocus: type == ManageSpaceType.create ? true : false, textFieldHintText: LocaleKeys.space_spaceNamePlaceholder.tr(), ), const VSpace(16), ], ); } } class ManageSpacePermissionOption extends StatelessWidget { const ManageSpacePermissionOption({ super.key, required this.permission, }); final ValueNotifier permission; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 16, bottom: 4), child: FlowyText( LocaleKeys.space_permission.tr(), fontSize: 14, figmaLineHeight: 20.0, fontWeight: FontWeight.w400, color: Theme.of(context).hintColor, ), ), ValueListenableBuilder( valueListenable: permission, builder: (context, value, child) => FlowyOptionTile.text( height: SpaceUIConstants.itemHeight, text: value.i18n, leftIcon: FlowySvg(value.icon), trailing: const FlowySvg( FlowySvgs.arrow_right_s, ), onTap: () { showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.space_permission.tr(), showCloseButton: true, showDivider: false, showDragHandle: true, builder: (context) => SpacePermissionBottomSheet( permission: value, onAction: (value) { permission.value = value; Navigator.pop(context); }, ), ); }, ), ), const VSpace(16), ], ); } } class ManageSpaceIconOption extends StatefulWidget { const ManageSpaceIconOption({ super.key, required this.selectedColor, required this.selectedIcon, }); final ValueNotifier selectedColor; final ValueNotifier selectedIcon; @override State createState() => _ManageSpaceIconOptionState(); } class _ManageSpaceIconOptionState extends State { @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ..._buildColorOption(context), ..._buildSpaceIconOption(context), ], ); } List _buildColorOption(BuildContext context) { return [ Padding( padding: const EdgeInsets.only(left: 16, bottom: 4), child: FlowyText( LocaleKeys.space_mSpaceIconColor.tr(), fontSize: 14, figmaLineHeight: 20.0, fontWeight: FontWeight.w400, color: Theme.of(context).hintColor, ), ), ValueListenableBuilder( valueListenable: widget.selectedColor, builder: (context, selectedColor, child) { return FlowyOptionDecorateBox( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: builtInSpaceColors.map((color) { return SpaceColorItem( color: color, selectedColor: selectedColor, onSelected: (color) => widget.selectedColor.value = color, ); }).toList(), ), ), ), ); }, ), const VSpace(16), ]; } List _buildSpaceIconOption(BuildContext context) { return [ Padding( padding: const EdgeInsets.only(left: 16, bottom: 4), child: FlowyText( LocaleKeys.space_mSpaceIcon.tr(), fontSize: 14, figmaLineHeight: 20.0, fontWeight: FontWeight.w400, color: Theme.of(context).hintColor, ), ), Expanded( child: SizedBox( width: double.infinity, child: ValueListenableBuilder( valueListenable: widget.selectedColor, builder: (context, selectedColor, child) { return ValueListenableBuilder( valueListenable: widget.selectedIcon, builder: (context, selectedIcon, child) { return FlowyOptionDecorateBox( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, ), child: _buildIconGroups( context, selectedColor, selectedIcon, ), ), ); }, ); }, ), ), ), const VSpace(16), ]; } Widget _buildIconGroups( BuildContext context, String selectedColor, Icon? selectedIcon, ) { final iconGroups = kIconGroups; if (iconGroups == null) { return const SizedBox.shrink(); } return ListView.builder( itemCount: iconGroups.length, itemBuilder: (context, index) { final iconGroup = iconGroups[index]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(12.0), FlowyText( iconGroup.displayName.capitalize(), fontSize: 12, figmaLineHeight: 18.0, color: context.pickerTextColor, ), const VSpace(4.0), Center( child: Wrap( spacing: 10.0, runSpacing: 8.0, children: iconGroup.icons.map((icon) { return SpaceIconItem( icon: icon, isSelected: selectedIcon?.name == icon.name, selectedColor: selectedColor, onSelectedIcon: (icon) => widget.selectedIcon.value = icon, ); }).toList(), ), ), const VSpace(12.0), if (index == iconGroups.length - 1) ...[ const StreamlinePermit(), ], ], ); }, ); } } class SpaceIconItem extends StatelessWidget { const SpaceIconItem({ super.key, required this.icon, required this.onSelectedIcon, required this.isSelected, required this.selectedColor, }); final Icon icon; final void Function(Icon icon) onSelectedIcon; final bool isSelected; final String selectedColor; @override Widget build(BuildContext context) { return AnimatedGestureDetector( onTapUp: () => onSelectedIcon(icon), child: Container( width: 36, height: 36, decoration: isSelected ? BoxDecoration( color: Color(int.parse(selectedColor)), borderRadius: BorderRadius.circular(8.0), ) : ShapeDecoration( color: Colors.transparent, shape: RoundedRectangleBorder( side: const BorderSide( width: 0.5, color: Color(0x661F2329), ), borderRadius: BorderRadius.circular(8), ), ), child: Center( child: FlowySvg.string( icon.content, size: const Size.square(18), color: isSelected ? Theme.of(context).colorScheme.surface : context.pickerIconColor, opacity: isSelected ? 1.0 : 0.7, ), ), ), ); } } class SpaceColorItem extends StatelessWidget { const SpaceColorItem({ super.key, required this.color, required this.selectedColor, required this.onSelected, }); final String color; final String selectedColor; final void Function(String color) onSelected; @override Widget build(BuildContext context) { final child = Center( child: Container( width: 28, height: 28, decoration: BoxDecoration( color: Color(int.parse(color)), borderRadius: BorderRadius.circular(14), ), ), ); final decoration = color != selectedColor ? null : ShapeDecoration( color: Colors.transparent, shape: RoundedRectangleBorder( side: BorderSide( width: 1.50, color: Theme.of(context).colorScheme.primary, ), borderRadius: BorderRadius.circular(21), ), ); return AnimatedGestureDetector( onTapUp: () => onSelected(color), child: Container( width: 36, height: 36, decoration: decoration, child: child, ), ); } } extension on SpacePermission { String get i18n { switch (this) { case SpacePermission.publicToAll: return LocaleKeys.space_publicPermission.tr(); case SpacePermission.private: return LocaleKeys.space_privatePermission.tr(); } } FlowySvgData get icon { switch (this) { case SpacePermission.publicToAll: return FlowySvgs.space_permission_public_s; case SpacePermission.private: return FlowySvgs.space_permission_private_s; } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_round_underline_tab_indicator.dart ================================================ import 'package:flutter/material.dart'; class RoundUnderlineTabIndicator extends Decoration { const RoundUnderlineTabIndicator({ this.borderRadius, this.borderSide = const BorderSide(width: 2.0, color: Colors.white), this.insets = EdgeInsets.zero, required this.width, }); final BorderRadius? borderRadius; final BorderSide borderSide; final EdgeInsetsGeometry insets; final double width; @override Decoration? lerpFrom(Decoration? a, double t) { if (a is UnderlineTabIndicator) { return UnderlineTabIndicator( borderSide: BorderSide.lerp(a.borderSide, borderSide, t), insets: EdgeInsetsGeometry.lerp(a.insets, insets, t)!, ); } return super.lerpFrom(a, t); } @override Decoration? lerpTo(Decoration? b, double t) { if (b is UnderlineTabIndicator) { return UnderlineTabIndicator( borderSide: BorderSide.lerp(borderSide, b.borderSide, t), insets: EdgeInsetsGeometry.lerp(insets, b.insets, t)!, ); } return super.lerpTo(b, t); } @override BoxPainter createBoxPainter([VoidCallback? onChanged]) { return _UnderlinePainter(this, borderRadius, onChanged); } Rect _indicatorRectFor(Rect rect, TextDirection textDirection) { final Rect indicator = insets.resolve(textDirection).deflateRect(rect); final center = indicator.center.dx; return Rect.fromLTWH( center - width / 2.0, indicator.bottom - borderSide.width, width, borderSide.width, ); } @override Path getClipPath(Rect rect, TextDirection textDirection) { if (borderRadius != null) { return Path() ..addRRect( borderRadius!.toRRect(_indicatorRectFor(rect, textDirection)), ); } return Path()..addRect(_indicatorRectFor(rect, textDirection)); } } class _UnderlinePainter extends BoxPainter { _UnderlinePainter( this.decoration, this.borderRadius, super.onChanged, ); final RoundUnderlineTabIndicator decoration; final BorderRadius? borderRadius; @override void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { assert(configuration.size != null); final Rect rect = offset & configuration.size!; final TextDirection textDirection = configuration.textDirection!; final Paint paint; if (borderRadius != null) { paint = Paint()..color = decoration.borderSide.color; final Rect indicator = decoration._indicatorRectFor(rect, textDirection); final RRect rrect = RRect.fromRectAndCorners( indicator, topLeft: borderRadius!.topLeft, topRight: borderRadius!.topRight, bottomRight: borderRadius!.bottomRight, bottomLeft: borderRadius!.bottomLeft, ); canvas.drawRRect(rrect, paint); } else { paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.round; final Rect indicator = decoration ._indicatorRectFor(rect, textDirection) .deflate(decoration.borderSide.width / 2.0); canvas.drawLine(indicator.bottomLeft, indicator.bottomRight, paint); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_tab_bar.dart ================================================ import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart'; import 'package:flutter/material.dart'; import 'package:reorderable_tabbar/reorderable_tabbar.dart'; class MobileSpaceTabBar extends StatelessWidget { const MobileSpaceTabBar({ super.key, this.height = 38.0, required this.tabController, required this.tabs, required this.onReorder, }); final double height; final List tabs; final TabController tabController; final OnReorder onReorder; @override Widget build(BuildContext context) { final baseStyle = Theme.of(context).textTheme.bodyMedium; final labelStyle = baseStyle?.copyWith( fontWeight: FontWeight.w500, fontSize: 16.0, height: 22.0 / 16.0, ); final unselectedLabelStyle = baseStyle?.copyWith( fontWeight: FontWeight.w400, fontSize: 15.0, height: 22.0 / 15.0, ); return Container( height: height, padding: const EdgeInsets.only(left: 8.0), child: ReorderableTabBar( controller: tabController, tabs: tabs.map((e) => Tab(text: e.tr)).toList(), indicatorSize: TabBarIndicatorSize.label, indicatorColor: Theme.of(context).primaryColor, isScrollable: true, labelStyle: labelStyle, labelColor: baseStyle?.color, labelPadding: const EdgeInsets.symmetric(horizontal: 12.0), unselectedLabelStyle: unselectedLabelStyle, overlayColor: WidgetStateProperty.all(Colors.transparent), indicator: RoundUnderlineTabIndicator( width: 28.0, borderSide: BorderSide( color: Theme.of(context).primaryColor, width: 3, ), ), onReorder: onReorder, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class FloatingAIEntry extends StatelessWidget { const FloatingAIEntry({super.key}); @override Widget build(BuildContext context) { return AnimatedGestureDetector( scaleFactor: 0.99, onTapUp: () => mobileCreateNewAIChatNotifier.value = mobileCreateNewAIChatNotifier.value + 1, child: Hero( tag: "ai_chat_prompt", child: DecoratedBox( decoration: _buildShadowDecoration(context), child: Container( decoration: _buildWrapperDecoration(context), height: 48, alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only(left: 18), child: _buildHintText(context), ), ), ), ), ); } BoxDecoration _buildShadowDecoration(BuildContext context) { return BoxDecoration( borderRadius: BorderRadius.circular(30), boxShadow: [ BoxShadow( blurRadius: 20, spreadRadius: 1, offset: const Offset(0, 4), color: Colors.black.withValues(alpha: 0.05), ), ], ); } BoxDecoration _buildWrapperDecoration(BuildContext context) { final outlineColor = Theme.of(context).colorScheme.outline; final borderColor = Theme.of(context).isLightMode ? outlineColor.withValues(alpha: 0.7) : outlineColor.withValues(alpha: 0.3); return BoxDecoration( borderRadius: BorderRadius.circular(30), color: Theme.of(context).colorScheme.surface, border: Border.fromBorderSide( BorderSide( color: borderColor, ), ), ); } Widget _buildHintText(BuildContext context) { return Row( children: [ FlowySvg( FlowySvgs.toolbar_item_ai_s, size: const Size.square(16.0), color: Theme.of(context).hintColor, opacity: 0.7, ), const HSpace(8), FlowyText( LocaleKeys.chat_inputMessageHint.tr(), color: Theme.of(context).hintColor, ), ], ); } } class FloatingAIEntryV2 extends StatelessWidget { const FloatingAIEntryV2({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return GestureDetector( onTap: () { mobileCreateNewAIChatNotifier.value = mobileCreateNewAIChatNotifier.value + 1; }, child: Container( width: 56, height: 56, decoration: BoxDecoration( shape: BoxShape.circle, color: theme.surfaceColorScheme.primary, boxShadow: theme.shadow.small, border: Border.all(color: theme.borderColorScheme.primary), ), child: Center( child: FlowySvg( FlowySvgs.m_home_ai_chat_icon_m, blendMode: null, size: Size(24, 24), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart ================================================ import 'package:appflowy/features/shared_section/presentation/m_shared_section.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/presentation/home/favorite_folder/favorite_space.dart'; import 'package:appflowy/mobile/presentation/home/home_space/home_space.dart'; import 'package:appflowy/mobile/presentation/home/recent_folder/recent_space.dart'; import 'package:appflowy/mobile/presentation/home/tab/_tab_bar.dart'; import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'ai_bubble_button.dart'; final ValueNotifier mobileCreateNewAIChatNotifier = ValueNotifier(0); class MobileHomePageTab extends StatefulWidget { const MobileHomePageTab({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override State createState() => _MobileHomePageTabState(); } class _MobileHomePageTabState extends State with SingleTickerProviderStateMixin { TabController? tabController; @override void initState() { super.initState(); mobileCreateNewPageNotifier.addListener(_createNewDocument); mobileCreateNewAIChatNotifier.addListener(_createNewAIChat); mobileLeaveWorkspaceNotifier.addListener(_leaveWorkspace); } @override void dispose() { tabController?.removeListener(_onTabChange); tabController?.dispose(); mobileCreateNewPageNotifier.removeListener(_createNewDocument); mobileCreateNewAIChatNotifier.removeListener(_createNewAIChat); mobileLeaveWorkspaceNotifier.removeListener(_leaveWorkspace); super.dispose(); } @override Widget build(BuildContext context) { return Provider.value( value: widget.userProfile, child: MultiBlocListener( listeners: [ BlocListener( listenWhen: (p, c) => p.lastCreatedPage?.id != c.lastCreatedPage?.id, listener: (context, state) { final lastCreatedPage = state.lastCreatedPage; if (lastCreatedPage != null) { context.pushView( lastCreatedPage, tabs: [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ].map((e) => e.name).toList(), ); } }, ), BlocListener( listenWhen: (p, c) => p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, listener: (context, state) { final lastCreatedPage = state.lastCreatedRootView; if (lastCreatedPage != null) { context.pushView( lastCreatedPage, tabs: [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ].map((e) => e.name).toList(), ); } }, ), ], child: BlocBuilder( builder: (context, state) { if (state.isLoading) { return const SizedBox.shrink(); } _initTabController(state); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MobileSpaceTabBar( tabController: tabController!, tabs: state.tabsOrder, onReorder: (from, to) { context.read().add( SpaceOrderEvent.reorder(from, to), ); }, ), const HSpace(12.0), Expanded( child: TabBarView( controller: tabController, children: _buildTabs(state), ), ), ], ); }, ), ), ); } void _initTabController(SpaceOrderState state) { if (tabController != null) { return; } tabController = TabController( length: state.tabsOrder.length, vsync: this, initialIndex: state.tabsOrder.indexOf(state.defaultTab), ); tabController?.addListener(_onTabChange); } void _onTabChange() { if (tabController == null) { return; } context .read() .add(SpaceOrderEvent.open(tabController!.index)); } List _buildTabs(SpaceOrderState state) { return state.tabsOrder.map((tab) { switch (tab) { case MobileSpaceTabType.recent: return const MobileRecentSpace(); case MobileSpaceTabType.spaces: final showAIFloatingButton = widget.userProfile.workspaceType == WorkspaceTypePB.ServerW; return Stack( children: [ MobileHomeSpace(userProfile: widget.userProfile), if (showAIFloatingButton) Positioned( right: 20, bottom: MediaQuery.of(context).padding.bottom + 16, child: FloatingAIEntryV2(), ), ], ); case MobileSpaceTabType.favorites: return MobileFavoriteSpace(userProfile: widget.userProfile); case MobileSpaceTabType.shared: final workspaceId = context .read() .state .currentWorkspace ?.workspaceId; if (workspaceId == null) { return const SizedBox.shrink(); } return MSharedSection( workspaceId: workspaceId, ); } }).toList(); } // quick create new page when clicking the add button in navigation bar void _createNewDocument() => _createNewPage(ViewLayoutPB.Document); void _createNewAIChat() => _createNewPage(ViewLayoutPB.Chat); void _createNewPage(ViewLayoutPB layout) { if (context.read().state.spaces.isNotEmpty) { context.read().add( SpaceEvent.createPage( name: '', layout: layout, openAfterCreate: true, ), ); } else if (layout == ViewLayoutPB.Document) { // only support create document in section context.read().add( SidebarSectionsEvent.createRootViewInSection( name: '', index: 0, viewSection: FolderSpaceType.public.toViewSectionPB, ), ); } } void _leaveWorkspace() { final workspaceId = context.read().state.currentWorkspace?.workspaceId; if (workspaceId == null) { return Log.error('Workspace ID is null'); } context .read() .add(UserWorkspaceEvent.leaveWorkspace(workspaceId: workspaceId)); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/tab/space_order_bloc.dart ================================================ import 'dart:convert'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:bloc/bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'space_order_bloc.freezed.dart'; enum MobileSpaceTabType { // DO NOT CHANGE THE ORDER spaces, recent, favorites, shared; String get tr { switch (this) { case MobileSpaceTabType.recent: return LocaleKeys.sideBar_RecentSpace.tr(); case MobileSpaceTabType.spaces: return LocaleKeys.sideBar_Spaces.tr(); case MobileSpaceTabType.favorites: return LocaleKeys.sideBar_favoriteSpace.tr(); case MobileSpaceTabType.shared: return 'Shared'; } } } class SpaceOrderBloc extends Bloc { SpaceOrderBloc() : super(const SpaceOrderState()) { on( (event, emit) async { await event.when( initial: () async { final tabsOrder = await _getTabsOrder(); final defaultTab = await _getDefaultTab(); emit( state.copyWith( tabsOrder: tabsOrder, defaultTab: defaultTab, isLoading: false, ), ); }, open: (index) async { final tab = state.tabsOrder[index]; await _setDefaultTab(tab); }, reorder: (from, to) async { final tabsOrder = List.of(state.tabsOrder); tabsOrder.insert(to, tabsOrder.removeAt(from)); await _setTabsOrder(tabsOrder); emit(state.copyWith(tabsOrder: tabsOrder)); }, ); }, ); } final _storage = getIt(); Future _getDefaultTab() async { try { return await _storage.getWithFormat( KVKeys.lastOpenedSpace, (value) { return MobileSpaceTabType.values[int.parse(value)]; }) ?? MobileSpaceTabType.spaces; } catch (e) { return MobileSpaceTabType.spaces; } } Future _setDefaultTab(MobileSpaceTabType tab) async { await _storage.set( KVKeys.lastOpenedSpace, tab.index.toString(), ); } Future> _getTabsOrder() async { try { return await _storage.getWithFormat>( KVKeys.spaceOrder, (value) { final order = jsonDecode(value).cast(); if (order.isEmpty) { return MobileSpaceTabType.values; } if (!order.contains(MobileSpaceTabType.shared.index)) { order.add(MobileSpaceTabType.shared.index); } return order .map((e) => MobileSpaceTabType.values[e]) .cast() .toList(); }) ?? MobileSpaceTabType.values; } catch (e) { return MobileSpaceTabType.values; } } Future _setTabsOrder(List tabsOrder) async { await _storage.set( KVKeys.spaceOrder, jsonEncode(tabsOrder.map((e) => e.index).toList()), ); } } @freezed class SpaceOrderEvent with _$SpaceOrderEvent { const factory SpaceOrderEvent.initial() = Initial; const factory SpaceOrderEvent.open(int index) = Open; const factory SpaceOrderEvent.reorder(int from, int to) = Reorder; } @freezed class SpaceOrderState with _$SpaceOrderState { const factory SpaceOrderState({ @Default(MobileSpaceTabType.spaces) MobileSpaceTabType defaultTab, @Default(MobileSpaceTabType.values) List tabsOrder, @Default(true) bool isLoading, }) = _SpaceOrderState; factory SpaceOrderState.initial() => const SpaceOrderState(); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/create_workspace_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; enum EditWorkspaceNameType { create, edit; String get title { switch (this) { case EditWorkspaceNameType.create: return LocaleKeys.workspace_create.tr(); case EditWorkspaceNameType.edit: return LocaleKeys.workspace_renameWorkspace.tr(); } } String get actionTitle { switch (this) { case EditWorkspaceNameType.create: return LocaleKeys.workspace_create.tr(); case EditWorkspaceNameType.edit: return LocaleKeys.button_confirm.tr(); } } } class EditWorkspaceNameBottomSheet extends StatefulWidget { const EditWorkspaceNameBottomSheet({ super.key, required this.type, required this.onSubmitted, required this.workspaceName, this.hintText, this.validator, this.validatorBuilder, }); final EditWorkspaceNameType type; final void Function(String) onSubmitted; // if the workspace name is not empty, it will be used as the initial value of the text field. final String? workspaceName; final String? hintText; final String? Function(String?)? validator; final WidgetBuilder? validatorBuilder; @override State createState() => _EditWorkspaceNameBottomSheetState(); } class _EditWorkspaceNameBottomSheetState extends State { late final TextEditingController _textFieldController; final _formKey = GlobalKey(); @override void initState() { super.initState(); _textFieldController = TextEditingController( text: widget.workspaceName, ); } @override void dispose() { _textFieldController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Form( key: _formKey, child: TextFormField( autofocus: true, controller: _textFieldController, keyboardType: TextInputType.text, decoration: InputDecoration( hintText: widget.hintText ?? LocaleKeys.workspace_defaultName.tr(), ), validator: widget.validator ?? (value) { if (value == null || value.isEmpty) { return LocaleKeys.workspace_workspaceNameCannotBeEmpty.tr(); } return null; }, onEditingComplete: _onSubmit, ), ), if (widget.validatorBuilder != null) ...[ const VSpace(4), widget.validatorBuilder!(context), const VSpace(4), ], const VSpace(16), SizedBox( width: double.infinity, child: PrimaryRoundedButton( text: widget.type.actionTitle, fontSize: 16, margin: const EdgeInsets.symmetric( vertical: 16, ), onTap: _onSubmit, ), ), ], ); } void _onSubmit() { if (_formKey.currentState!.validate()) { final value = _textFieldController.text; widget.onSubmitted.call(value); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/util/navigator_context_extension.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'create_workspace_menu.dart'; import 'workspace_more_options.dart'; // Only works on mobile. class MobileWorkspaceMenu extends StatelessWidget { const MobileWorkspaceMenu({ super.key, required this.userProfile, required this.currentWorkspace, required this.workspaces, required this.onWorkspaceSelected, }); final UserProfilePB userProfile; final UserWorkspacePB currentWorkspace; final List workspaces; final void Function(UserWorkspacePB workspace) onWorkspaceSelected; @override Widget build(BuildContext context) { // user profile final List children = [ _WorkspaceUserItem(userProfile: userProfile), _buildDivider(), ]; // workspace list for (var i = 0; i < workspaces.length; i++) { final workspace = workspaces[i]; children.add( _WorkspaceMenuItem( key: ValueKey(workspace.workspaceId), userProfile: userProfile, workspace: workspace, showTopBorder: false, currentWorkspace: currentWorkspace, onWorkspaceSelected: onWorkspaceSelected, ), ); } // create workspace button children.addAll([ _buildDivider(), const _CreateWorkspaceButton(), ]); return Column( mainAxisSize: MainAxisSize.min, children: children, ); } Widget _buildDivider() { return const Padding( padding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Divider(height: 0.5), ); } } class _CreateWorkspaceButton extends StatelessWidget { const _CreateWorkspaceButton(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: FlowyOptionTile.text( height: 60, showTopBorder: false, showBottomBorder: false, leftIcon: _buildLeftIcon(context), onTap: () => _showCreateWorkspaceBottomSheet(context), content: Expanded( child: Padding( padding: const EdgeInsets.only(left: 16.0), child: FlowyText.medium( LocaleKeys.workspace_create.tr(), fontSize: 14, ), ), ), ), ); } void _showCreateWorkspaceBottomSheet(BuildContext context) { showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.workspace_create.tr(), showCloseButton: true, showDragHandle: true, showDivider: false, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (bottomSheetContext) { return EditWorkspaceNameBottomSheet( type: EditWorkspaceNameType.create, workspaceName: LocaleKeys.workspace_defaultName.tr(), onSubmitted: (name) { // create a new workspace Log.info('create a new workspace: $name'); bottomSheetContext.popToHome(); context.read().add( UserWorkspaceEvent.createWorkspace( name: name, workspaceType: WorkspaceTypePB.ServerW, ), ); }, ); }, ); } Widget _buildLeftIcon(BuildContext context) { return Container( width: 36.0, height: 36.0, padding: const EdgeInsets.all(7.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0x01717171).withValues(alpha: 0.12), width: 0.8, ), ), child: const FlowySvg(FlowySvgs.add_workspace_s), ); } } class _WorkspaceUserItem extends StatelessWidget { const _WorkspaceUserItem({required this.userProfile}); final UserProfilePB userProfile; @override Widget build(BuildContext context) { final color = Theme.of(context).isLightMode ? const Color(0x99333333) : const Color(0x99CCCCCC); return FlowyOptionTile.text( height: 32, showTopBorder: false, showBottomBorder: false, content: Expanded( child: Padding( padding: const EdgeInsets.only(), child: FlowyText( userProfile.email, fontSize: 14, color: color, ), ), ), ); } } class _WorkspaceMenuItem extends StatelessWidget { const _WorkspaceMenuItem({ super.key, required this.userProfile, required this.workspace, required this.showTopBorder, required this.currentWorkspace, required this.onWorkspaceSelected, }); final UserProfilePB userProfile; final UserWorkspacePB workspace; final bool showTopBorder; final UserWorkspacePB currentWorkspace; final void Function(UserWorkspacePB workspace) onWorkspaceSelected; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => WorkspaceMemberBloc( userProfile: userProfile, workspace: workspace, )..add(const WorkspaceMemberEvent.initial()), child: BlocBuilder( builder: (context, state) { return FlowyOptionTile.text( height: 60, showTopBorder: showTopBorder, showBottomBorder: false, leftIcon: _WorkspaceMenuItemIcon(workspace: workspace), trailing: _WorkspaceMenuItemTrailing( workspace: workspace, currentWorkspace: currentWorkspace, ), onTap: () => onWorkspaceSelected(workspace), content: Expanded( child: _WorkspaceMenuItemContent( workspace: workspace, ), ), ); }, ), ); } } // - Workspace name // - Workspace member count class _WorkspaceMenuItemContent extends StatelessWidget { const _WorkspaceMenuItemContent({ required this.workspace, }); final UserWorkspacePB workspace; @override Widget build(BuildContext context) { final memberCount = workspace.memberCount.toInt(); return Padding( padding: const EdgeInsets.only(left: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ FlowyText( workspace.name, fontSize: 14, fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, ), FlowyText( memberCount == 0 ? '' : LocaleKeys.settings_appearance_members_membersCount.plural( memberCount, ), fontSize: 10.0, color: Theme.of(context).hintColor, ), ], ), ); } } class _WorkspaceMenuItemIcon extends StatelessWidget { const _WorkspaceMenuItemIcon({ required this.workspace, }); final UserWorkspacePB workspace; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: WorkspaceIcon( workspaceName: workspace.name, workspaceIcon: workspace.icon, isEditable: false, iconSize: 36, emojiSize: 24.0, fontSize: 18.0, figmaLineHeight: 26.0, borderRadius: 12.0, onSelected: (result) => context.read().add( UserWorkspaceEvent.updateWorkspaceIcon( workspaceId: workspace.workspaceId, icon: result.emoji, ), ), ), ); } } class _WorkspaceMenuItemTrailing extends StatelessWidget { const _WorkspaceMenuItemTrailing({ required this.workspace, required this.currentWorkspace, }); final UserWorkspacePB workspace; final UserWorkspacePB currentWorkspace; @override Widget build(BuildContext context) { const iconSize = Size.square(20); return Row( children: [ const HSpace(12.0), // show the check icon if the workspace is the current workspace if (workspace.workspaceId == currentWorkspace.workspaceId) const FlowySvg( FlowySvgs.m_blue_check_s, size: iconSize, blendMode: null, ), const HSpace(8.0), // more options button AnimatedGestureDetector( onTapUp: () => _showMoreOptions(context), child: const Padding( padding: EdgeInsets.all(8.0), child: FlowySvg( FlowySvgs.workspace_three_dots_s, size: iconSize, ), ), ), ], ); } void _showMoreOptions(BuildContext context) { final actions = context.read().state.myRole == AFRolePB.Owner ? [ // only the owner can update workspace properties WorkspaceMenuMoreOption.rename, WorkspaceMenuMoreOption.delete, ] : [ WorkspaceMenuMoreOption.leave, ]; showMobileBottomSheet( context, showDragHandle: true, showDivider: false, useRootNavigator: true, backgroundColor: Theme.of(context).colorScheme.surface, builder: (bottomSheetContext) { return WorkspaceMenuMoreOptions( actions: actions, onAction: (action) => _onActions(context, bottomSheetContext, action), ); }, ); } void _onActions( BuildContext context, BuildContext bottomSheetContext, WorkspaceMenuMoreOption action, ) { Log.info('execute action in workspace menu bottom sheet: $action'); switch (action) { case WorkspaceMenuMoreOption.rename: _showRenameWorkspaceBottomSheet(context); break; case WorkspaceMenuMoreOption.invite: _pushToInviteMembersPage(context); break; case WorkspaceMenuMoreOption.delete: _deleteWorkspace(context, bottomSheetContext); break; case WorkspaceMenuMoreOption.leave: _leaveWorkspace(context, bottomSheetContext); break; } } void _pushToInviteMembersPage(BuildContext context) { // empty implementation // we don't support invite members in workspace menu } void _showRenameWorkspaceBottomSheet(BuildContext context) { showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.workspace_renameWorkspace.tr(), showCloseButton: true, showDragHandle: true, showDivider: false, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (bottomSheetContext) { return EditWorkspaceNameBottomSheet( type: EditWorkspaceNameType.edit, workspaceName: workspace.name, onSubmitted: (name) { // rename the workspace Log.info('rename the workspace: $name'); bottomSheetContext.popToHome(); context.read().add( UserWorkspaceEvent.renameWorkspace( workspaceId: workspace.workspaceId, name: name, ), ); }, ); }, ); } void _deleteWorkspace(BuildContext context, BuildContext bottomSheetContext) { Navigator.of(bottomSheetContext).pop(); _showConfirmDialog( context, '${LocaleKeys.space_delete.tr()}: ${workspace.name}', LocaleKeys.workspace_deleteWorkspaceHintText.tr(), LocaleKeys.button_delete.tr(), (_) async { context.read().add( UserWorkspaceEvent.deleteWorkspace( workspaceId: workspace.workspaceId, ), ); context.popToHome(); }, ); } void _leaveWorkspace(BuildContext context, BuildContext bottomSheetContext) { Navigator.of(bottomSheetContext).pop(); _showConfirmDialog( context, '${LocaleKeys.settings_workspacePage_leaveWorkspacePrompt_title.tr()}: ${workspace.name}', LocaleKeys.settings_workspacePage_leaveWorkspacePrompt_content.tr(), LocaleKeys.button_confirm.tr(), (_) async { context.read().add( UserWorkspaceEvent.leaveWorkspace( workspaceId: workspace.workspaceId, ), ); context.popToHome(); }, ); } void _showConfirmDialog( BuildContext context, String title, String content, String rightButtonText, void Function(BuildContext context)? onRightButtonPressed, ) { showFlowyCupertinoConfirmDialog( title: title, content: FlowyText( content, fontSize: 14, color: Theme.of(context).hintColor, maxLines: 10, ), leftButton: FlowyText( LocaleKeys.button_cancel.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w500, color: const Color(0xFF007AFF), ), rightButton: FlowyText( rightButtonText, fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w400, color: const Color(0xFFFE0220), ), onRightButtonPressed: onRightButtonPressed, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_more_options.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum WorkspaceMenuMoreOption { rename, invite, delete, leave, } class WorkspaceMenuMoreOptions extends StatelessWidget { const WorkspaceMenuMoreOptions({ super.key, this.isFavorite = false, required this.onAction, required this.actions, }); final bool isFavorite; final void Function(WorkspaceMenuMoreOption action) onAction; final List actions; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: actions .map( (action) => _buildActionButton(context, action), ) .toList(), ); } Widget _buildActionButton( BuildContext context, WorkspaceMenuMoreOption action, ) { switch (action) { case WorkspaceMenuMoreOption.rename: return FlowyOptionTile.text( text: LocaleKeys.button_rename.tr(), height: 52.0, leftIcon: const FlowySvg( FlowySvgs.view_item_rename_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( WorkspaceMenuMoreOption.rename, ), ); case WorkspaceMenuMoreOption.delete: return FlowyOptionTile.text( text: LocaleKeys.button_delete.tr(), height: 52.0, textColor: Theme.of(context).colorScheme.error, leftIcon: FlowySvg( FlowySvgs.trash_s, size: const Size.square(18), color: Theme.of(context).colorScheme.error, ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( WorkspaceMenuMoreOption.delete, ), ); case WorkspaceMenuMoreOption.invite: return FlowyOptionTile.text( // i18n text: 'Invite', height: 52.0, leftIcon: const FlowySvg( FlowySvgs.workspace_add_member_s, size: Size.square(18), ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( WorkspaceMenuMoreOption.invite, ), ); case WorkspaceMenuMoreOption.leave: return FlowyOptionTile.text( text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(), height: 52.0, textColor: Theme.of(context).colorScheme.error, leftIcon: FlowySvg( FlowySvgs.leave_workspace_s, size: const Size.square(18), color: Theme.of(context).colorScheme.error, ), showTopBorder: false, showBottomBorder: false, onTap: () => onAction( WorkspaceMenuMoreOption.leave, ), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_handler.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'mobile_inline_actions_menu_group.dart'; extension _StartWithsSort on List { void sortByStartsWithKeyword(String search) => sort( (a, b) { final aCount = a.startsWithKeywords ?.where( (key) => search.toLowerCase().startsWith(key), ) .length ?? 0; final bCount = b.startsWithKeywords ?.where( (key) => search.toLowerCase().startsWith(key), ) .length ?? 0; if (aCount > bCount) { return -1; } else if (bCount > aCount) { return 1; } return 0; }, ); } const _invalidSearchesAmount = 10; class MobileInlineActionsHandler extends StatefulWidget { const MobileInlineActionsHandler({ super.key, required this.results, required this.editorState, required this.menuService, required this.onDismiss, required this.style, required this.service, this.startCharAmount = 1, this.startOffset = 0, this.cancelBySpaceHandler, }); final List results; final EditorState editorState; final InlineActionsMenuService menuService; final VoidCallback onDismiss; final InlineActionsMenuStyle style; final int startCharAmount; final InlineActionsService service; final bool Function()? cancelBySpaceHandler; final int startOffset; @override State createState() => _MobileInlineActionsHandlerState(); } class _MobileInlineActionsHandlerState extends State { final _focusNode = FocusNode(debugLabel: 'mobile_inline_actions_menu_handler'); late List results = widget.results; int invalidCounter = 0; late int startOffset; String _search = ''; set search(String search) { _search = search; _doSearch(); } Future _doSearch() async { final List newResults = []; for (final handler in widget.service.handlers) { final group = await handler.search(_search); if (group.results.isNotEmpty) { newResults.add(group); } } invalidCounter = results.every((group) => group.results.isEmpty) ? invalidCounter + 1 : 0; if (invalidCounter >= _invalidSearchesAmount) { widget.onDismiss(); // Workaround to bring focus back to editor await editorState.updateSelectionWithReason(editorState.selection); return; } _resetSelection(); newResults.sortByStartsWithKeyword(_search); setState(() => results = newResults); } void _resetSelection() { _selectedGroup = 0; _selectedIndex = 0; } int _selectedGroup = 0; int _selectedIndex = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback( (_) => _focusNode.requestFocus(), ); startOffset = editorState.selection?.endIndex ?? 0; keepEditorFocusNotifier.increase(); editorState.selectionNotifier.addListener(onSelectionChanged); } @override void dispose() { editorState.selectionNotifier.removeListener(onSelectionChanged); _focusNode.dispose(); keepEditorFocusNotifier.decrease(); super.dispose(); } @override Widget build(BuildContext context) { final width = editorState.renderBox!.size.width - 24 * 2; return Focus( focusNode: _focusNode, child: Container( constraints: BoxConstraints( maxHeight: 192, minWidth: width, maxWidth: width, ), margin: EdgeInsets.symmetric(horizontal: 24.0), decoration: BoxDecoration( color: widget.style.backgroundColor, borderRadius: BorderRadius.circular(6.0), boxShadow: [ BoxShadow( blurRadius: 5, spreadRadius: 1, color: Colors.black.withValues(alpha: 0.1), ), ], ), child: noResults ? SizedBox( width: 150, child: FlowyText.regular( LocaleKeys.inlineActions_noResults.tr(), ), ) : SingleChildScrollView( physics: const ClampingScrollPhysics(), child: Material( color: Colors.transparent, child: Padding( padding: EdgeInsets.all(6.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: results .where((g) => g.results.isNotEmpty) .mapIndexed( (index, group) => MobileInlineActionsGroup( result: group, editorState: editorState, menuService: menuService, style: widget.style, onSelected: widget.onDismiss, startOffset: startOffset - widget.startCharAmount, endOffset: _search.length + widget.startCharAmount, isLastGroup: index == results.length - 1, isGroupSelected: _selectedGroup == index, selectedIndex: _selectedIndex, onPreSelect: (int value) { setState(() { _selectedGroup = index; _selectedIndex = value; }); }, ), ) .toList(), ), ), ), ), ), ); } bool get noResults => results.isEmpty || results.every((e) => e.results.isEmpty); int get groupLength => results.length; int lengthOfGroup(int index) => results.length > index ? results[index].results.length : -1; InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) => results[groupIndex].results[handlerIndex]; EditorState get editorState => widget.editorState; InlineActionsMenuService get menuService => widget.menuService; void onSelectionChanged() { final selection = editorState.selection; if (selection == null) { menuService.dismiss(); return; } if (!selection.isCollapsed) { menuService.dismiss(); return; } final startOffset = widget.startOffset; final endOffset = selection.end.offset; if (endOffset < startOffset) { menuService.dismiss(); return; } final node = editorState.getNodeAtPath(selection.start.path); final text = node?.delta?.toPlainText() ?? ''; final search = text.substring(startOffset, endOffset); this.search = search; } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'mobile_inline_actions_handler.dart'; class MobileInlineActionsMenu extends InlineActionsMenuService { MobileInlineActionsMenu({ required this.context, required this.editorState, required this.initialResults, required this.style, required this.service, this.startCharAmount = 1, this.cancelBySpaceHandler, }); final BuildContext context; final EditorState editorState; final List initialResults; final bool Function()? cancelBySpaceHandler; final InlineActionsService service; @override final InlineActionsMenuStyle style; final int startCharAmount; OverlayEntry? _menuEntry; @override void dismiss() { if (_menuEntry != null) { editorState.service.keyboardService?.enable(); editorState.service.scrollService?.enable(); } _menuEntry?.remove(); _menuEntry = null; } @override Future show() { final completer = Completer(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _show(); completer.complete(); }); return completer.future; } void _show() { final selectionRects = editorState.selectionRects(); if (selectionRects.isEmpty) { return; } const double menuHeight = 192.0; const Offset menuOffset = Offset(0, 10); final Offset editorOffset = editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; final Size editorSize = editorState.renderBox!.size; // Default to opening the overlay below Alignment alignment = Alignment.topLeft; final firstRect = selectionRects.first; Offset offset = firstRect.bottomRight + menuOffset; // Show above if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) { offset = firstRect.topRight - menuOffset; alignment = Alignment.bottomLeft; offset = Offset( offset.dx, MediaQuery.of(context).size.height - offset.dy, ); } final (left, top, right, bottom) = _getPosition(alignment, offset); _menuEntry = OverlayEntry( builder: (context) => SizedBox( width: editorSize.width, height: editorSize.height, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: dismiss, child: Stack( children: [ Positioned( top: top, bottom: bottom, left: left, right: right, child: MobileInlineActionsHandler( service: service, results: initialResults, editorState: editorState, menuService: this, onDismiss: dismiss, style: style, startCharAmount: startCharAmount, cancelBySpaceHandler: cancelBySpaceHandler, startOffset: editorState.selection?.start.offset ?? 0, ), ), ], ), ), ), ); Overlay.of(context).insert(_menuEntry!); editorState.service.keyboardService?.disable(showCursor: true); editorState.service.scrollService?.disable(); } (double? left, double? top, double? right, double? bottom) _getPosition( Alignment alignment, Offset offset, ) { double? left, top, right, bottom; switch (alignment) { case Alignment.topLeft: left = 0; top = offset.dy; break; case Alignment.bottomLeft: left = 0; bottom = offset.dy; break; case Alignment.topRight: right = offset.dx; top = offset.dy; break; case Alignment.bottomRight: right = offset.dx; bottom = offset.dy; break; } return (left, top, right, bottom); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart ================================================ import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class MobileInlineActionsGroup extends StatelessWidget { const MobileInlineActionsGroup({ super.key, required this.result, required this.editorState, required this.menuService, required this.style, required this.onSelected, required this.startOffset, required this.endOffset, required this.onPreSelect, this.isLastGroup = false, this.isGroupSelected = false, this.selectedIndex = 0, }); final InlineActionsResult result; final EditorState editorState; final InlineActionsMenuService menuService; final InlineActionsMenuStyle style; final VoidCallback onSelected; final ValueChanged onPreSelect; final int startOffset; final int endOffset; final bool isLastGroup; final bool isGroupSelected; final int selectedIndex; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (result.title != null) ...[ SizedBox( height: 36, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Align( alignment: Alignment.centerLeft, child: FlowyText.medium( result.title!, color: style.groupTextColor, fontSize: 12, ), ), ), ), ], ...result.results.mapIndexed( (index, item) => GestureDetector( onTapDown: (e) { onPreSelect.call(index); }, child: MobileInlineActionsWidget( item: item, editorState: editorState, menuService: menuService, isSelected: isGroupSelected && index == selectedIndex, style: style, onSelected: onSelected, startOffset: startOffset, endOffset: endOffset, ), ), ), ], ); } } class MobileInlineActionsWidget extends StatelessWidget { const MobileInlineActionsWidget({ super.key, required this.item, required this.editorState, required this.menuService, required this.isSelected, required this.style, required this.onSelected, required this.startOffset, required this.endOffset, }); final InlineActionsMenuItem item; final EditorState editorState; final InlineActionsMenuService menuService; final bool isSelected; final InlineActionsMenuStyle style; final VoidCallback onSelected; final int startOffset; final int endOffset; @override Widget build(BuildContext context) { final hasIcon = item.iconBuilder != null; return Container( height: 36, decoration: BoxDecoration( color: isSelected ? style.menuItemSelectedColor : null, borderRadius: BorderRadius.circular(6.0), ), child: FlowyButton( expand: true, isSelected: isSelected, text: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Align( alignment: Alignment.centerLeft, child: Row( children: [ if (hasIcon) ...[ item.iconBuilder!.call(isSelected), SizedBox(width: 12), ], Flexible( child: FlowyText.regular( item.label, figmaLineHeight: 18, overflow: TextOverflow.ellipsis, fontSize: 16, color: style.menuItemSelectedTextColor, ), ), ], ), ), ), onTap: () => _onPressed(context), ), ); } void _onPressed(BuildContext context) { onSelected(); item.onSelected?.call( context, editorState, menuService, (startOffset, endOffset), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart ================================================ import 'dart:io'; import 'dart:ui'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; import 'package:appflowy/mobile/presentation/widgets/navigation_bar_button.dart'; import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/presentation/notifications/number_red_dot.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'home/mobile_home_page.dart'; import 'search/mobile_search_page.dart'; enum BottomNavigationBarActionType { home, notificationMultiSelect, } final PropertyValueNotifier mobileCreateNewPageNotifier = PropertyValueNotifier(null); final ValueNotifier bottomNavigationBarType = ValueNotifier(BottomNavigationBarActionType.home); final ValueNotifier bottomNavigationBarItemType = ValueNotifier(BottomNavigationBarItemType.home.label); enum BottomNavigationBarItemType { home, search, add, notification; String get label => name; String? get routeName { return switch (this) { home => MobileHomeScreen.routeName, search => MobileSearchScreen.routeName, notification => MobileNotificationsScreenV2.routeName, add => null, }; } ValueKey get valueKey { return ValueKey(label); } Widget get iconWidget { return switch (this) { home => const FlowySvg(FlowySvgs.m_home_unselected_m), search => const FlowySvg(FlowySvgs.m_home_search_icon_m), add => const FlowySvg(FlowySvgs.m_home_add_m), notification => const _NotificationNavigationBarItemIcon(), }; } Widget? get activeIcon { return switch (this) { home => const FlowySvg(FlowySvgs.m_home_selected_m, blendMode: null), search => const FlowySvg(FlowySvgs.m_home_search_icon_active_m, blendMode: null), add => null, notification => const _NotificationNavigationBarItemIcon(isActive: true), }; } BottomNavigationBarItem get navigationItem { return BottomNavigationBarItem( key: valueKey, label: label, icon: iconWidget, activeIcon: activeIcon, ); } } final _items = BottomNavigationBarItemType.values.map((e) => e.navigationItem).toList(); /// Builds the "shell" for the app by building a Scaffold with a /// BottomNavigationBar, where [child] is placed in the body of the Scaffold. class MobileBottomNavigationBar extends StatefulWidget { /// Constructs an [MobileBottomNavigationBar]. const MobileBottomNavigationBar({ required this.navigationShell, super.key, }); /// The navigation shell and container for the branch Navigators. final StatefulNavigationShell navigationShell; @override State createState() => _MobileBottomNavigationBarState(); } class _MobileBottomNavigationBarState extends State { Widget? _bottomNavigationBar; @override void initState() { super.initState(); bottomNavigationBarType.addListener(_animate); } @override void dispose() { bottomNavigationBarType.removeListener(_animate); super.dispose(); } @override Widget build(BuildContext context) { _bottomNavigationBar = switch (bottomNavigationBarType.value) { BottomNavigationBarActionType.home => _buildHomePageNavigationBar(context), BottomNavigationBarActionType.notificationMultiSelect => _buildNotificationNavigationBar(context), }; return Scaffold( body: widget.navigationShell, extendBody: true, bottomNavigationBar: AnimatedSwitcher( duration: const Duration(milliseconds: 250), switchInCurve: Curves.easeInOut, switchOutCurve: Curves.easeInOut, transitionBuilder: _transitionBuilder, child: _bottomNavigationBar, ), ); } Widget _buildHomePageNavigationBar(BuildContext context) { return _HomePageNavigationBar( navigationShell: widget.navigationShell, ); } Widget _buildNotificationNavigationBar(BuildContext context) { return const _NotificationNavigationBar(); } // widget A going down, widget B going up Widget _transitionBuilder( Widget child, Animation animation, ) { return SlideTransition( position: Tween( begin: const Offset(0, 1), end: Offset.zero, ).animate(animation), child: child, ); } void _animate() { setState(() {}); } } class _NotificationNavigationBarItemIcon extends StatelessWidget { const _NotificationNavigationBarItemIcon({ this.isActive = false, }); final bool isActive; @override Widget build(BuildContext context) { return BlocProvider.value( value: getIt(), child: BlocBuilder( builder: (context, state) { final hasUnreads = state.reminders.any( (reminder) => !reminder.isRead, ); return SizedBox( width: 40, height: 40, child: Stack( children: [ Center( child: isActive ? const FlowySvg( FlowySvgs.m_home_active_notification_m, blendMode: null, ) : const FlowySvg( FlowySvgs.m_home_notification_m, ), ), if (hasUnreads) const Align( alignment: Alignment.topRight, child: NumberedRedDot.mobile(), ), ], ), ); }, ), ); } } class _HomePageNavigationBar extends StatelessWidget { const _HomePageNavigationBar({ required this.navigationShell, }); final StatefulNavigationShell navigationShell; @override Widget build(BuildContext context) { return ClipRRect( child: BackdropFilter( filter: ImageFilter.blur( sigmaX: 3, sigmaY: 3, ), child: DecoratedBox( decoration: BoxDecoration( border: context.border, color: context.backgroundColor, ), child: Theme( data: _getThemeData(context), child: BottomNavigationBar( showSelectedLabels: false, showUnselectedLabels: false, enableFeedback: false, type: BottomNavigationBarType.fixed, elevation: 0, items: _items, backgroundColor: Colors.transparent, currentIndex: navigationShell.currentIndex, onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex), ), ), ), ), ); } ThemeData _getThemeData(BuildContext context) { if (Platform.isAndroid) { return Theme.of(context); } // hide the splash effect for iOS return Theme.of(context).copyWith( splashFactory: NoSplash.splashFactory, splashColor: Colors.transparent, highlightColor: Colors.transparent, ); } /// Navigate to the current location of the branch at the provided index when /// tapping an item in the BottomNavigationBar. void _onTap(BuildContext context, int bottomBarIndex) { // close the popup menu closePopupMenu(); final label = _items[bottomBarIndex].label; if (label == BottomNavigationBarItemType.add.label) { // show an add dialog mobileCreateNewPageNotifier.value = ViewLayoutPB.Document; return; } else if (label == BottomNavigationBarItemType.notification.label) { getIt().add(const ReminderEvent.refresh()); } bottomNavigationBarItemType.value = label; // When navigating to a new branch, it's recommended to use the goBranch // method, as doing so makes sure the last navigation state of the // Navigator for the branch is restored. navigationShell.goBranch( bottomBarIndex, // A common pattern when using bottom navigation bars is to support // navigating to the initial location when tapping the item that is // already active. This example demonstrates how to support this behavior, // using the initialLocation parameter of goBranch. initialLocation: bottomBarIndex == navigationShell.currentIndex, ); } } class _NotificationNavigationBar extends StatelessWidget { const _NotificationNavigationBar(); @override Widget build(BuildContext context) { return Container( // todo: use real height here. height: 90, decoration: BoxDecoration( border: context.border, color: context.backgroundColor, ), padding: const EdgeInsets.only(bottom: 20), child: ValueListenableBuilder( valueListenable: mSelectedNotificationIds, builder: (context, value, child) { if (value.isEmpty) { // not editable return IgnorePointer( child: Opacity( opacity: 0.3, child: child, ), ); } return child!; }, child: Row( children: [ const HSpace(20), Expanded( child: NavigationBarButton( icon: FlowySvgs.m_notification_action_mark_as_read_s, text: LocaleKeys.settings_notifications_action_markAsRead.tr(), onTap: () => _onMarkAsRead(context), ), ), const HSpace(16), Expanded( child: NavigationBarButton( icon: FlowySvgs.m_notification_action_archive_s, text: LocaleKeys.settings_notifications_action_archive.tr(), onTap: () => _onArchive(context), ), ), const HSpace(20), ], ), ), ); } void _onMarkAsRead(BuildContext context) { if (mSelectedNotificationIds.value.isEmpty) { return; } showToastNotification( message: LocaleKeys .settings_notifications_markAsReadNotifications_allSuccess .tr(), ); getIt() .add(ReminderEvent.markAsRead(mSelectedNotificationIds.value)); mSelectedNotificationIds.value = []; } void _onArchive(BuildContext context) { if (mSelectedNotificationIds.value.isEmpty) { return; } showToastNotification( message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess .tr(), ); getIt() .add(ReminderEvent.archive(mSelectedNotificationIds.value)); mSelectedNotificationIds.value = []; } } extension on BuildContext { Color get backgroundColor { return Theme.of(this).isLightMode ? Colors.white.withValues(alpha: 0.95) : const Color(0xFF23262B).withValues(alpha: 0.95); } Color get borderColor { return Theme.of(this).isLightMode ? const Color(0x141F2329) : const Color(0xFF23262B).withValues(alpha: 0.5); } Border? get border { return Theme.of(this).isLightMode ? Border(top: BorderSide(color: borderColor)) : null; } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart ================================================ import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileNotificationsMultiSelectScreen extends StatelessWidget { const MobileNotificationsMultiSelectScreen({super.key}); static const routeName = '/notifications_multi_select'; @override Widget build(BuildContext context) { return BlocProvider.value( value: getIt(), child: const MobileNotificationMultiSelect(), ); } } class MobileNotificationMultiSelect extends StatefulWidget { const MobileNotificationMultiSelect({ super.key, }); @override State createState() => _MobileNotificationMultiSelectState(); } class _MobileNotificationMultiSelectState extends State { @override void dispose() { mSelectedNotificationIds.value.clear(); super.dispose(); } @override Widget build(BuildContext context) { return const Scaffold( body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MobileNotificationMultiSelectPageHeader(), VSpace(12.0), Expanded( child: MultiSelectNotificationTab(), ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/user_profile/user_profile_bloc.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy/workspace/presentation/notifications/reminder_extension.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/inbox_action_bar.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_view.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileNotificationsScreen extends StatefulWidget { const MobileNotificationsScreen({super.key}); static const routeName = '/notifications'; @override State createState() => _MobileNotificationsScreenState(); } class _MobileNotificationsScreenState extends State with SingleTickerProviderStateMixin { final ReminderBloc reminderBloc = getIt(); late final TabController controller = TabController(length: 2, vsync: this); @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (context) => UserProfileBloc()..add(const UserProfileEvent.started()), ), BlocProvider.value(value: reminderBloc), BlocProvider( create: (_) => NotificationFilterBloc(), ), ], child: BlocBuilder( builder: (context, state) { return state.maybeWhen( orElse: () => const Center(child: CircularProgressIndicator.adaptive()), workspaceFailure: () => const WorkspaceFailedScreen(), success: (workspaceLatest, userProfile) => _NotificationScreenContent( workspaceLatest: workspaceLatest, userProfile: userProfile, controller: controller, reminderBloc: reminderBloc, ), ); }, ), ); } } class _NotificationScreenContent extends StatelessWidget { const _NotificationScreenContent({ required this.workspaceLatest, required this.userProfile, required this.controller, required this.reminderBloc, }); final WorkspaceLatestPB workspaceLatest; final UserProfilePB userProfile; final TabController controller; final ReminderBloc reminderBloc; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => SidebarSectionsBloc() ..add( SidebarSectionsEvent.initial( userProfile, workspaceLatest.workspaceId, ), ), child: BlocBuilder( builder: (context, sectionState) => BlocBuilder( builder: (context, filterState) => BlocBuilder( builder: (context, state) { // Workaround for rebuilding the Blocks by brightness Theme.of(context).brightness; final List pastReminders = state.pastReminders .where( (r) => filterState.showUnreadsOnly ? !r.isRead : true, ) .sortByScheduledAt(); final List upcomingReminders = state.upcomingReminders.sortByScheduledAt(); return Scaffold( appBar: AppBar( automaticallyImplyLeading: false, elevation: 0, title: Text(LocaleKeys.notificationHub_mobile_title.tr()), ), body: SafeArea( child: Column( children: [ MobileNotificationTabBar(controller: controller), Expanded( child: TabBarView( controller: controller, children: [ NotificationsView( shownReminders: pastReminders, reminderBloc: reminderBloc, views: sectionState.section.publicViews, onAction: _onAction, onReadChanged: _onReadChanged, actionBar: InboxActionBar( showUnreadsOnly: filterState.showUnreadsOnly, ), ), NotificationsView( shownReminders: upcomingReminders, reminderBloc: reminderBloc, views: sectionState.section.publicViews, isUpcoming: true, onAction: _onAction, ), ], ), ), ], ), ), ); }, ), ), ), ); } void _onAction(ReminderPB reminder, int? path, ViewPB? view) => reminderBloc.add( ReminderEvent.pressReminder( reminderId: reminder.id, path: path, view: view, ), ); void _onReadChanged(ReminderPB reminder, bool isRead) => reminderBloc.add( ReminderEvent.update(ReminderUpdate(id: reminder.id, isRead: isRead)), ); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart ================================================ import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'widgets/tab_bar.dart'; final PropertyValueNotifier> mSelectedNotificationIds = PropertyValueNotifier([]); class MobileNotificationsScreenV2 extends StatefulWidget { const MobileNotificationsScreenV2({super.key}); static const routeName = '/notifications'; @override State createState() => _MobileNotificationsScreenV2State(); } class _MobileNotificationsScreenV2State extends State with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override void initState() { super.initState(); mCurrentWorkspace.addListener(_onRefresh); } @override void dispose() { mCurrentWorkspace.removeListener(_onRefresh); super.dispose(); } @override Widget build(BuildContext context) { super.build(context); return BlocProvider.value( value: getIt(), child: ValueListenableBuilder( valueListenable: bottomNavigationBarType, builder: (_, value, __) { switch (value) { case BottomNavigationBarActionType.home: return const MobileNotificationsTab(); case BottomNavigationBarActionType.notificationMultiSelect: return const MobileNotificationMultiSelect(); } }, ), ); } void _onRefresh() => getIt().add(const ReminderEvent.refresh()); } class MobileNotificationsTab extends StatefulWidget { const MobileNotificationsTab({super.key}); @override State createState() => _MobileNotificationsTabState(); } class _MobileNotificationsTabState extends State with SingleTickerProviderStateMixin { late TabController tabController; final tabs = [ NotificationTabType.inbox, NotificationTabType.unread, NotificationTabType.archive, ]; @override void initState() { super.initState(); tabController = TabController(length: 3, vsync: this); } @override void dispose() { tabController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const MobileNotificationPageHeader(), MobileNotificationTabBar( tabController: tabController, tabs: tabs, ), const VSpace(12.0), Expanded( child: TabBarView( controller: tabController, children: tabs.map((e) => NotificationTab(tabType: e)).toList(), ), ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart ================================================ import 'package:appflowy/util/theme_extension.dart'; import 'package:flutter/material.dart'; extension NotificationItemColors on BuildContext { Color get notificationItemTextColor { if (Theme.of(this).isLightMode) { return const Color(0xFF171717); } return const Color(0xFFffffff).withValues(alpha: 0.8); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/empty.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class EmptyNotification extends StatelessWidget { const EmptyNotification({ super.key, required this.type, }); final NotificationTabType type; @override Widget build(BuildContext context) { final title = switch (type) { NotificationTabType.inbox => LocaleKeys.settings_notifications_emptyInbox_title.tr(), NotificationTabType.archive => LocaleKeys.settings_notifications_emptyArchived_title.tr(), NotificationTabType.unread => LocaleKeys.settings_notifications_emptyUnread_title.tr(), }; final desc = switch (type) { NotificationTabType.inbox => LocaleKeys.settings_notifications_emptyInbox_description.tr(), NotificationTabType.archive => LocaleKeys.settings_notifications_emptyArchived_description.tr(), NotificationTabType.unread => LocaleKeys.settings_notifications_emptyUnread_description.tr(), }; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg(FlowySvgs.m_empty_notification_xl), const VSpace(12.0), FlowyText( title, fontSize: 16.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w500, ), const VSpace(4.0), Opacity( opacity: 0.45, child: FlowyText( desc, fontSize: 15.0, figmaLineHeight: 22.0, fontWeight: FontWeight.w400, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/settings_popup_menu.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileNotificationPageHeader extends StatelessWidget { const MobileNotificationPageHeader({ super.key, }); @override Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints(minHeight: 56), child: Row( mainAxisSize: MainAxisSize.min, children: [ const HSpace(18.0), FlowyText( LocaleKeys.settings_notifications_titles_notifications.tr(), fontSize: 20, fontWeight: FontWeight.w600, ), const Spacer(), const NotificationSettingsPopupMenu(), const HSpace(16.0), ], ), ); } } class MobileNotificationMultiSelectPageHeader extends StatelessWidget { const MobileNotificationMultiSelectPageHeader({ super.key, }); @override Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints(minHeight: 56), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildCancelButton( isOpaque: false, padding: const EdgeInsets.symmetric(horizontal: 16), onTap: () => bottomNavigationBarType.value = BottomNavigationBarActionType.home, ), ValueListenableBuilder( valueListenable: mSelectedNotificationIds, builder: (_, value, __) { return FlowyText( // todo: i18n '${value.length} Selected', fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w500, ); }, ), // this button is used to align the text to the center _buildCancelButton( isOpaque: true, padding: const EdgeInsets.symmetric(horizontal: 16), ), ], ), ); } // Widget _buildCancelButton({ required bool isOpaque, required EdgeInsets padding, VoidCallback? onTap, }) { return GestureDetector( onTap: onTap, child: Padding( padding: padding, child: FlowyText( LocaleKeys.button_cancel.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w400, color: isOpaque ? Colors.transparent : null, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; class MobileNotificationTabBar extends StatefulWidget { const MobileNotificationTabBar({super.key, required this.controller}); final TabController controller; @override State createState() => _MobileNotificationTabBarState(); } class _MobileNotificationTabBarState extends State { @override void initState() { super.initState(); widget.controller.addListener(_updateState); } @override void dispose() { widget.controller.removeListener(_updateState); super.dispose(); } void _updateState() => setState(() {}); @override Widget build(BuildContext context) { final borderSide = BorderSide( color: AFThemeExtension.of(context).calloutBGColor, ); return DecoratedBox( decoration: BoxDecoration( border: Border( bottom: borderSide, top: borderSide, ), ), child: Row( children: [ Expanded( child: TabBar( controller: widget.controller, padding: const EdgeInsets.symmetric(horizontal: 8), labelPadding: EdgeInsets.zero, indicatorSize: TabBarIndicatorSize.label, indicator: UnderlineTabIndicator( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), ), isScrollable: true, tabs: [ FlowyTabItem( label: LocaleKeys.notificationHub_tabs_inbox.tr(), isSelected: widget.controller.index == 0, ), FlowyTabItem( label: LocaleKeys.notificationHub_tabs_upcoming.tr(), isSelected: widget.controller.index == 1, ), ], ), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart ================================================ import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MultiSelectNotificationItem extends StatelessWidget { const MultiSelectNotificationItem({ super.key, required this.reminder, }); final ReminderPB reminder; @override Widget build(BuildContext context) { final settings = context.read().state; final dateFormate = settings.dateFormat; final timeFormate = settings.timeFormat; return BlocProvider( create: (context) => NotificationReminderBloc() ..add( NotificationReminderEvent.initial( reminder, dateFormate, timeFormate, ), ), child: BlocBuilder( builder: (context, state) { if (state.status == NotificationReminderStatus.loading || state.status == NotificationReminderStatus.initial) { return const SizedBox.shrink(); } if (state.status == NotificationReminderStatus.error) { // error handle. return const SizedBox.shrink(); } final child = ValueListenableBuilder( valueListenable: mSelectedNotificationIds, builder: (_, selectedIds, child) { return Container( margin: const EdgeInsets.symmetric(horizontal: 12), decoration: selectedIds.contains(reminder.id) ? ShapeDecoration( color: const Color(0x1900BCF0), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ) : null, child: child, ); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: _InnerNotificationItem( reminder: reminder, ), ), ); return AnimatedGestureDetector( scaleFactor: 0.99, onTapUp: () { if (mSelectedNotificationIds.value.contains(reminder.id)) { mSelectedNotificationIds.value = mSelectedNotificationIds.value ..remove(reminder.id); } else { mSelectedNotificationIds.value = mSelectedNotificationIds.value ..add(reminder.id); } }, child: child, ); }, ), ); } } class _InnerNotificationItem extends StatelessWidget { const _InnerNotificationItem({ required this.reminder, }); final ReminderPB reminder; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const HSpace(10.0), NotificationCheckIcon( isSelected: mSelectedNotificationIds.value.contains(reminder.id), ), const HSpace(12.0), NotificationIcon(reminder: reminder), const HSpace(12.0), Expanded( child: NotificationContent(reminder: reminder), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart ================================================ import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; class NotificationItem extends StatelessWidget { const NotificationItem({ super.key, required this.tabType, required this.reminder, }); final NotificationTabType tabType; final ReminderPB reminder; @override Widget build(BuildContext context) { final settings = context.read().state; final dateFormate = settings.dateFormat; final timeFormate = settings.timeFormat; return BlocProvider( create: (context) => NotificationReminderBloc() ..add( NotificationReminderEvent.initial( reminder, dateFormate, timeFormate, ), ), child: BlocBuilder( builder: (context, state) { if (state.status == NotificationReminderStatus.loading || state.status == NotificationReminderStatus.initial) { return const SizedBox.shrink(); } if (state.status == NotificationReminderStatus.error) { // error handle. return const SizedBox.shrink(); } final child = GestureDetector( onLongPress: () { context.onMoreAction(); }, child: Padding( padding: const EdgeInsets.all(8), child: _SlidableNotificationItem( tabType: tabType, reminder: reminder, child: _InnerNotificationItem( tabType: tabType, reminder: reminder, ), ), ), ); return AnimatedGestureDetector( scaleFactor: 0.99, child: child, onTapUp: () async { final view = state.view; final blockId = state.blockId; if (view == null) { return; } await context.pushView( view, blockId: blockId, ); if (!reminder.isRead && context.mounted) { context.read().add( ReminderEvent.markAsRead([reminder.id]), ); } }, ); }, ), ); } } class _InnerNotificationItem extends StatelessWidget { const _InnerNotificationItem({ required this.reminder, required this.tabType, }); final NotificationTabType tabType; final ReminderPB reminder; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const HSpace(10.0), NotificationIcon(reminder: reminder), const HSpace(12.0), Expanded(child: NotificationContent(reminder: reminder)), const HSpace(6.0), ], ); } } class _SlidableNotificationItem extends StatelessWidget { const _SlidableNotificationItem({ required this.tabType, required this.reminder, required this.child, }); final NotificationTabType tabType; final ReminderPB reminder; final Widget child; @override Widget build(BuildContext context) { final List actions = switch (tabType) { NotificationTabType.inbox => [ NotificationPaneActionType.more, if (!reminder.isRead) NotificationPaneActionType.markAsRead, ], NotificationTabType.unread => [ NotificationPaneActionType.more, NotificationPaneActionType.markAsRead, ], NotificationTabType.archive => [ if (kDebugMode) NotificationPaneActionType.unArchive, ], }; if (actions.isEmpty) { return child; } final children = actions .map( (action) => action.actionButton( context, tabType: tabType, ), ) .toList(); final extentRatio = actions.length == 1 ? 1 / 5 : 1 / 3; return Slidable( endActionPane: ActionPane( motion: const ScrollMotion(), extentRatio: extentRatio, children: children, ), child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide PopupMenuButton, PopupMenuDivider, PopupMenuItem, PopupMenuEntry; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; enum _NotificationSettingsPopupMenuItem { settings, markAllAsRead, archiveAll, // only visible in debug mode unarchiveAll; } class NotificationSettingsPopupMenu extends StatelessWidget { const NotificationSettingsPopupMenu({super.key}); @override Widget build(BuildContext context) { return PopupMenuButton<_NotificationSettingsPopupMenuItem>( offset: const Offset(0, 36), padding: EdgeInsets.zero, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all( Radius.circular(12.0), ), ), // todo: replace it with shadows shadowColor: const Color(0x68000000), elevation: 10, color: context.popupMenuBackgroundColor, itemBuilder: (BuildContext context) => >[ _buildItem( value: _NotificationSettingsPopupMenuItem.settings, svg: FlowySvgs.m_notification_settings_s, text: LocaleKeys.settings_notifications_settings_settings.tr(), ), const PopupMenuDivider(height: 0.5), _buildItem( value: _NotificationSettingsPopupMenuItem.markAllAsRead, svg: FlowySvgs.m_notification_mark_as_read_s, text: LocaleKeys.settings_notifications_settings_markAllAsRead.tr(), ), const PopupMenuDivider(height: 0.5), _buildItem( value: _NotificationSettingsPopupMenuItem.archiveAll, svg: FlowySvgs.m_notification_archived_s, text: LocaleKeys.settings_notifications_settings_archiveAll.tr(), ), // only visible in debug mode if (kDebugMode) ...[ const PopupMenuDivider(height: 0.5), _buildItem( value: _NotificationSettingsPopupMenuItem.unarchiveAll, svg: FlowySvgs.m_notification_archived_s, text: 'Unarchive all (Debug Mode)', ), ], ], onSelected: (_NotificationSettingsPopupMenuItem value) { switch (value) { case _NotificationSettingsPopupMenuItem.markAllAsRead: _onMarkAllAsRead(context); break; case _NotificationSettingsPopupMenuItem.archiveAll: _onArchiveAll(context); break; case _NotificationSettingsPopupMenuItem.settings: context.push(MobileHomeSettingPage.routeName); break; case _NotificationSettingsPopupMenuItem.unarchiveAll: _onUnarchiveAll(context); break; } }, child: const Padding( padding: EdgeInsets.all(8.0), child: FlowySvg( FlowySvgs.m_settings_more_s, ), ), ); } PopupMenuItem _buildItem({ required T value, required FlowySvgData svg, required String text, }) { return PopupMenuItem( value: value, padding: EdgeInsets.zero, child: _PopupButton( svg: svg, text: text, ), ); } void _onMarkAllAsRead(BuildContext context) { showToastNotification( message: LocaleKeys .settings_notifications_markAsReadNotifications_allSuccess .tr(), ); context.read().add(const ReminderEvent.markAllRead()); } void _onArchiveAll(BuildContext context) { showToastNotification( message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess .tr(), ); context.read().add(const ReminderEvent.archiveAll()); } void _onUnarchiveAll(BuildContext context) { if (!kDebugMode) { return; } showToastNotification( message: 'Unarchive all success (Debug Mode)', ); context.read().add(const ReminderEvent.unarchiveAll()); } } class _PopupButton extends StatelessWidget { const _PopupButton({ required this.svg, required this.text, }); final FlowySvgData svg; final String text; @override Widget build(BuildContext context) { return Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ FlowySvg(svg), const HSpace(12), FlowyText.regular( text, fontSize: 16, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/color.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; const _kNotificationIconHeight = 36.0; class NotificationIcon extends StatelessWidget { const NotificationIcon({ super.key, required this.reminder, this.atSize = 12, }); final ReminderPB reminder; final double atSize; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return SizedBox( width: 42, height: 36, child: Stack( children: [ const FlowySvg( FlowySvgs.m_notification_reminder_s, size: Size.square(32), blendMode: null, ), Align( alignment: Alignment.bottomRight, child: Container( width: 20, height: 20, decoration: BoxDecoration( shape: BoxShape.circle, color: theme.fillColorScheme.primary, ), child: Center( child: FlowySvg( FlowySvgs.notification_icon_at_s, size: Size.square(atSize), color: theme.iconColorScheme.primary, ), ), ), ), ], ), ); } } class NotificationCheckIcon extends StatelessWidget { const NotificationCheckIcon({super.key, required this.isSelected}); final bool isSelected; @override Widget build(BuildContext context) { return SizedBox( height: _kNotificationIconHeight, child: Center( child: FlowySvg( isSelected ? FlowySvgs.m_notification_multi_select_s : FlowySvgs.m_notification_multi_unselect_s, blendMode: isSelected ? null : BlendMode.srcIn, ), ), ); } } class UnreadRedDot extends StatelessWidget { const UnreadRedDot({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return SizedBox( height: _kNotificationIconHeight, child: Center( child: SizedBox.square( dimension: 7.0, child: DecoratedBox( decoration: ShapeDecoration( color: theme.borderColorScheme.errorThick, shape: OvalBorder(), ), ), ), ), ); } } class NotificationContent extends StatefulWidget { const NotificationContent({ super.key, required this.reminder, }); final ReminderPB reminder; @override State createState() => _NotificationContentState(); } class _NotificationContentState extends State { AppFlowyThemeData get theme => AppFlowyTheme.of(context); @override void didUpdateWidget(covariant NotificationContent oldWidget) { super.didUpdateWidget(oldWidget); context.read().add( const NotificationReminderEvent.reset(), ); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final view = state.view; if (view == null) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // title & time _buildHeader(state.scheduledAt, !widget.reminder.isRead), // page name _buildPageName(context, state.isLocked, state.pageTitle), // content _buildContent(view, nodes: state.nodes), ], ); }, ); } Widget _buildContent(ViewPB view, {List? nodes}) { if (view.layout.isDocumentView && nodes != null) { return IntrinsicHeight( child: BlocProvider( create: (context) => DocumentPageStyleBloc(view: view), child: NotificationDocumentContent( reminder: widget.reminder, nodes: nodes, ), ), ); } else if (view.layout.isDatabaseView) { final opacity = widget.reminder.type == ReminderType.past ? 0.3 : 1.0; return Opacity( opacity: opacity, child: FlowyText( widget.reminder.message, fontSize: 14, figmaLineHeight: 22, color: context.notificationItemTextColor, maxLines: 3, overflow: TextOverflow.ellipsis, ), ); } return const SizedBox.shrink(); } Widget _buildHeader(String createAt, bool unread) { return SizedBox( height: 22, child: Row( children: [ FlowyText.semibold( LocaleKeys.settings_notifications_titles_reminder.tr(), fontSize: 14, figmaLineHeight: 20, color: theme.textColorScheme.primary, ), Spacer(), if (createAt.isNotEmpty) FlowyText.regular( createAt, fontSize: 12, figmaLineHeight: 18, color: theme.textColorScheme.secondary, ), if (unread) ...[ HSpace(4), const UnreadRedDot(), ], ], ), ); } Widget _buildPageName( BuildContext context, bool isLocked, String pageTitle, ) { return Opacity( opacity: 0.5, child: SizedBox( height: 18, child: Row( children: [ /// TODO: need to be replaced after reminder support more types FlowyText.regular( LocaleKeys.notificationHub_mentionedYou.tr(), fontSize: 12, figmaLineHeight: 18, color: theme.textColorScheme.secondary, ), const NotificationEllipse(), if (isLocked) Padding( padding: EdgeInsets.only(right: 5), child: FlowySvg( FlowySvgs.notification_lock_s, color: theme.iconColorScheme.secondary, ), ), Flexible( child: FlowyText.regular( pageTitle, fontSize: 12, figmaLineHeight: 18, color: theme.textColorScheme.secondary, overflow: TextOverflow.ellipsis, ), ), ], ), ), ); } } class NotificationEllipse extends StatelessWidget { const NotificationEllipse({super.key}); @override Widget build(BuildContext context) { return Container( width: 2.50, height: 2.50, margin: const EdgeInsets.symmetric(horizontal: 6.0), decoration: ShapeDecoration( color: context.notificationItemTextColor, shape: const OvalBorder(), ), ); } } class NotificationDocumentContent extends StatelessWidget { const NotificationDocumentContent({ super.key, required this.reminder, required this.nodes, }); final ReminderPB reminder; final List nodes; @override Widget build(BuildContext context) { final editorState = EditorState( document: Document( root: pageNode(children: nodes), ), ); final styleCustomizer = EditorStyleCustomizer( context: context, padding: EdgeInsets.zero, ); final editorStyle = styleCustomizer.style().copyWith( // hide the cursor cursorColor: Colors.transparent, cursorWidth: 0, textStyleConfiguration: TextStyleConfiguration( lineHeight: 22 / 14, applyHeightToFirstAscent: true, applyHeightToLastDescent: true, text: TextStyle( fontSize: 14, color: context.notificationItemTextColor, height: 22 / 14, fontWeight: FontWeight.w400, leadingDistribution: TextLeadingDistribution.even, ), ), ); final blockBuilders = buildBlockComponentBuilders( context: context, editorState: editorState, styleCustomizer: styleCustomizer, // the editor is not editable in the chat editable: false, customPadding: (node) => EdgeInsets.zero, ); final headingBuilder = blockBuilders[HeadingBlockKeys.type]; if (headingBuilder != null && headingBuilder is HeadingBlockComponentBuilder) { final newHeadingBuilder = HeadingBlockComponentBuilder( configuration: headingBuilder.configuration, textStyleBuilder: (v) { final fontFamily = context .read() .state .fontFamily .orDefault( context.read().state.font, ); return styleCustomizer.baseTextStyle(fontFamily); }, ); blockBuilders[HeadingBlockKeys.type] = newHeadingBuilder; } return IgnorePointer( child: Opacity( opacity: reminder.type == ReminderType.past ? 0.3 : 1, child: AppFlowyEditor( editorState: editorState, editorStyle: editorStyle, disableSelectionService: true, disableKeyboardService: true, disableScrollService: true, editable: false, shrinkWrap: true, blockComponentBuilders: blockBuilders, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; enum NotificationPaneActionType { more, markAsRead, // only used in the debug mode. unArchive; MobileSlideActionButton actionButton( BuildContext context, { required NotificationTabType tabType, }) { switch (this) { case NotificationPaneActionType.markAsRead: return MobileSlideActionButton( backgroundColor: const Color(0xFF00C8FF), svg: FlowySvgs.m_notification_action_mark_as_read_s, size: 24.0, onPressed: (context) { showToastNotification( message: LocaleKeys .settings_notifications_markAsReadNotifications_success .tr(), ); context.read().add( ReminderEvent.update( ReminderUpdate( id: context.read().reminder.id, isRead: true, ), ), ); }, ); // this action is only used in the debug mode. case NotificationPaneActionType.unArchive: return MobileSlideActionButton( backgroundColor: const Color(0xFF00C8FF), svg: FlowySvgs.m_notification_action_mark_as_read_s, size: 24.0, onPressed: (context) { showToastNotification( message: 'Unarchive notification success', ); context.read().add( ReminderEvent.update( ReminderUpdate( id: context.read().reminder.id, isArchived: false, ), ), ); }, ); case NotificationPaneActionType.more: return MobileSlideActionButton( backgroundColor: const Color(0xE5515563), svg: FlowySvgs.three_dots_s, size: 24.0, borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), bottomLeft: Radius.circular(10), ), onPressed: (context) => context.onMoreAction(), ); } } } extension NotificationPaneActionExtension on BuildContext { void onMoreAction() { final reminderBloc = read(); final notificationReminderBloc = read(); showMobileBottomSheet( this, showDragHandle: true, showDivider: false, useRootNavigator: true, backgroundColor: Theme.of(this).colorScheme.surface, builder: (_) { return MultiBlocProvider( providers: [ BlocProvider.value(value: reminderBloc), BlocProvider.value(value: notificationReminderBloc), ], child: _NotificationMoreActions( onClickMultipleChoice: () { Future.delayed(const Duration(milliseconds: 250), () { bottomNavigationBarType.value = BottomNavigationBarActionType.notificationMultiSelect; }); }, ), ); }, ); } } class _NotificationMoreActions extends StatelessWidget { const _NotificationMoreActions({ required this.onClickMultipleChoice, }); final VoidCallback onClickMultipleChoice; @override Widget build(BuildContext context) { final reminder = context.read().reminder; return Column( children: [ if (!reminder.isRead) FlowyOptionTile.text( height: 52.0, text: LocaleKeys.settings_notifications_action_markAsRead.tr(), leftIcon: const FlowySvg( FlowySvgs.m_notification_action_mark_as_read_s, size: Size.square(20), ), showTopBorder: false, showBottomBorder: false, onTap: () => _onMarkAsRead(context), ), FlowyOptionTile.text( height: 52.0, text: LocaleKeys.settings_notifications_action_multipleChoice.tr(), leftIcon: const FlowySvg( FlowySvgs.m_notification_action_multiple_choice_s, size: Size.square(20), ), showTopBorder: false, showBottomBorder: false, onTap: () => _onMultipleChoice(context), ), if (!reminder.isArchived) FlowyOptionTile.text( height: 52.0, text: LocaleKeys.settings_notifications_action_archive.tr(), leftIcon: const FlowySvg( FlowySvgs.m_notification_action_archive_s, size: Size.square(20), ), showTopBorder: false, showBottomBorder: false, onTap: () => _onArchive(context), ), ], ); } void _onMarkAsRead(BuildContext context) { Navigator.of(context).pop(); showToastNotification( message: LocaleKeys.settings_notifications_markAsReadNotifications_success .tr(), ); context.read().add( ReminderEvent.update( ReminderUpdate( id: context.read().reminder.id, isRead: true, ), ), ); } void _onMultipleChoice(BuildContext context) { Navigator.of(context).pop(); onClickMultipleChoice(); } void _onArchive(BuildContext context) { showToastNotification( message: LocaleKeys.settings_notifications_archiveNotifications_success .tr() .tr(), ); context.read().add( ReminderEvent.update( ReminderUpdate( id: context.read().reminder.id, isRead: true, isArchived: true, ), ), ); Navigator.of(context).pop(); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class NotificationTab extends StatefulWidget { const NotificationTab({ super.key, required this.tabType, }); final NotificationTabType tabType; @override State createState() => _NotificationTabState(); } class _NotificationTabState extends State with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); return BlocBuilder( builder: (context, state) { final reminders = _filterReminders(state.reminders); if (reminders.isEmpty) { // add refresh indicator to the empty notification. return EmptyNotification( type: widget.tabType, ); } final child = ListView.separated( itemCount: reminders.length, separatorBuilder: (context, index) => const VSpace(8.0), itemBuilder: (context, index) { final reminder = reminders[index]; return NotificationItem( key: ValueKey('${widget.tabType}_${reminder.id}'), tabType: widget.tabType, reminder: reminder, ); }, ); return RefreshIndicator.adaptive( onRefresh: () async => _onRefresh(context), child: child, ); }, ); } Future _onRefresh(BuildContext context) async { context.read().add(const ReminderEvent.refresh()); // at least 0.5 seconds to dismiss the refresh indicator. // otherwise, it will be dismissed immediately. await context.read().stream.firstOrNull; await Future.delayed(const Duration(milliseconds: 500)); if (context.mounted) { showToastNotification( message: LocaleKeys.settings_notifications_refreshSuccess.tr(), ); } } List _filterReminders(List reminders) { switch (widget.tabType) { case NotificationTabType.inbox: return reminders.reversed .where((reminder) => !reminder.isArchived) .toList() .unique((reminder) => reminder.id); case NotificationTabType.archive: return reminders.reversed .where((reminder) => reminder.isArchived) .toList() .unique((reminder) => reminder.id); case NotificationTabType.unread: return reminders.reversed .where((reminder) => !reminder.isRead) .toList() .unique((reminder) => reminder.id); } } } class MultiSelectNotificationTab extends StatelessWidget { const MultiSelectNotificationTab({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { // find the reminders that are not archived or read. final reminders = state.reminders.reversed .where((reminder) => !reminder.isArchived || !reminder.isRead) .toList(); if (reminders.isEmpty) { // add refresh indicator to the empty notification. return const SizedBox.shrink(); } return ListView.separated( itemCount: reminders.length, separatorBuilder: (context, index) => const VSpace(8.0), itemBuilder: (context, index) { final reminder = reminders[index]; return MultiSelectNotificationItem( key: ValueKey(reminder.id), reminder: reminder, ); }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab_bar.dart ================================================ import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:flutter/material.dart'; import 'package:reorderable_tabbar/reorderable_tabbar.dart'; class MobileNotificationTabBar extends StatelessWidget { const MobileNotificationTabBar({ super.key, this.height = 38.0, required this.tabController, required this.tabs, }); final double height; final List tabs; final TabController tabController; @override Widget build(BuildContext context) { final baseStyle = Theme.of(context).textTheme.bodyMedium; final labelStyle = baseStyle?.copyWith( fontWeight: FontWeight.w500, fontSize: 16.0, height: 22.0 / 16.0, ); final unselectedLabelStyle = baseStyle?.copyWith( fontWeight: FontWeight.w400, fontSize: 15.0, height: 22.0 / 15.0, ); return Container( height: height, padding: const EdgeInsets.only(left: 8.0), child: ReorderableTabBar( controller: tabController, tabs: tabs.map((e) => Tab(text: e.tr)).toList(), indicatorSize: TabBarIndicatorSize.label, isScrollable: true, labelStyle: labelStyle, labelColor: baseStyle?.color, labelPadding: const EdgeInsets.symmetric(horizontal: 12.0), unselectedLabelStyle: unselectedLabelStyle, overlayColor: WidgetStateProperty.all(Colors.transparent), indicator: const RoundUnderlineTabIndicator( width: 28.0, borderSide: BorderSide( color: Color(0xFF00C8FF), width: 3, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart ================================================ export 'empty.dart'; export 'header.dart'; export 'multi_select_notification_item.dart'; export 'notification_item.dart'; export 'settings_popup_menu.dart'; export 'shared.dart'; export 'slide_actions.dart'; export 'tab.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_slide_action_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; class MobileSlideActionButton extends StatelessWidget { const MobileSlideActionButton({ super.key, required this.svg, this.size = 32.0, this.backgroundColor = Colors.transparent, this.borderRadius = BorderRadius.zero, required this.onPressed, }); final FlowySvgData svg; final double size; final Color backgroundColor; final SlidableActionCallback onPressed; final BorderRadius borderRadius; @override Widget build(BuildContext context) { return CustomSlidableAction( borderRadius: borderRadius, backgroundColor: backgroundColor, onPressed: (context) { HapticFeedback.mediumImpact(); onPressed(context); }, padding: EdgeInsets.zero, child: FlowySvg( svg, size: Size.square(size), color: Colors.white, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; typedef ViewItemOnSelected = void Function(ViewPB); typedef ActionPaneBuilder = ActionPane Function(BuildContext context); class MobileViewItem extends StatelessWidget { const MobileViewItem({ super.key, required this.view, this.parentView, required this.spaceType, required this.level, this.leftPadding = 10, required this.onSelected, this.isFirstChild = false, this.isDraggable = true, required this.isFeedback, this.startActionPane, this.endActionPane, }); final ViewPB view; final ViewPB? parentView; final FolderSpaceType spaceType; // indicate the level of the view item // used to calculate the left padding final int level; // the left padding of the view item for each level // the left padding of the each level = level * leftPadding final double leftPadding; // Selected by normal conventions final ViewItemOnSelected onSelected; // used for indicating the first child of the parent view, so that we can // add top border to the first child final bool isFirstChild; // it should be false when it's rendered as feedback widget inside DraggableItem final bool isDraggable; // identify if the view item is rendered as feedback widget inside DraggableItem final bool isFeedback; // the actions of the view item, such as favorite, rename, delete, etc. final ActionPaneBuilder? startActionPane; final ActionPaneBuilder? endActionPane; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), child: BlocConsumer( listenWhen: (p, c) => c.lastCreatedView != null && p.lastCreatedView?.id != c.lastCreatedView!.id, listener: (context, state) => context.pushView(state.lastCreatedView!), builder: (context, state) { return InnerMobileViewItem( view: state.view, parentView: parentView, childViews: state.view.childViews, spaceType: spaceType, level: level, leftPadding: leftPadding, showActions: true, isExpanded: state.isExpanded, onSelected: onSelected, isFirstChild: isFirstChild, isDraggable: isDraggable, isFeedback: isFeedback, startActionPane: startActionPane, endActionPane: endActionPane, ); }, ), ); } } class InnerMobileViewItem extends StatelessWidget { const InnerMobileViewItem({ super.key, required this.view, required this.parentView, required this.childViews, required this.spaceType, this.isDraggable = true, this.isExpanded = true, required this.level, required this.leftPadding, required this.showActions, required this.onSelected, this.isFirstChild = false, required this.isFeedback, this.startActionPane, this.endActionPane, }); final ViewPB view; final ViewPB? parentView; final List childViews; final FolderSpaceType spaceType; final bool isDraggable; final bool isExpanded; final bool isFirstChild; // identify if the view item is rendered as feedback widget inside DraggableItem final bool isFeedback; final int level; final double leftPadding; final bool showActions; final ViewItemOnSelected onSelected; final ActionPaneBuilder? startActionPane; final ActionPaneBuilder? endActionPane; @override Widget build(BuildContext context) { Widget child = SingleMobileInnerViewItem( view: view, parentView: parentView, level: level, showActions: showActions, spaceType: spaceType, onSelected: onSelected, isExpanded: isExpanded, isDraggable: isDraggable, leftPadding: leftPadding, isFeedback: isFeedback, startActionPane: startActionPane, endActionPane: endActionPane, ); // if the view is expanded and has child views, render its child views if (isExpanded) { if (childViews.isNotEmpty) { final children = childViews.map((childView) { return MobileViewItem( key: ValueKey('${spaceType.name} ${childView.id}'), parentView: view, spaceType: spaceType, isFirstChild: childView.id == childViews.first.id, view: childView, level: level + 1, onSelected: onSelected, isDraggable: isDraggable, leftPadding: leftPadding, isFeedback: isFeedback, startActionPane: startActionPane, endActionPane: endActionPane, ); }).toList(); child = Column( mainAxisSize: MainAxisSize.min, children: [ child, ...children, ], ); } } // wrap the child with DraggableItem if isDraggable is true if (isDraggable && !isReferencedDatabaseView(view, parentView)) { child = DraggableViewItem( isFirstChild: isFirstChild, view: view, centerHighlightColor: Colors.blue.shade200, topHighlightColor: Colors.blue.shade200, bottomHighlightColor: Colors.blue.shade200, feedback: (context) { return MobileViewItem( view: view, parentView: parentView, spaceType: spaceType, level: level, onSelected: onSelected, isDraggable: false, leftPadding: leftPadding, isFeedback: true, startActionPane: startActionPane, endActionPane: endActionPane, ); }, child: child, ); } return child; } } class SingleMobileInnerViewItem extends StatefulWidget { const SingleMobileInnerViewItem({ super.key, required this.view, required this.parentView, required this.isExpanded, required this.level, required this.leftPadding, this.isDraggable = true, required this.spaceType, required this.showActions, required this.onSelected, required this.isFeedback, this.startActionPane, this.endActionPane, }); final ViewPB view; final ViewPB? parentView; final bool isExpanded; // identify if the view item is rendered as feedback widget inside DraggableItem final bool isFeedback; final int level; final double leftPadding; final bool isDraggable; final bool showActions; final ViewItemOnSelected onSelected; final FolderSpaceType spaceType; final ActionPaneBuilder? startActionPane; final ActionPaneBuilder? endActionPane; @override State createState() => _SingleMobileInnerViewItemState(); } class _SingleMobileInnerViewItemState extends State { @override Widget build(BuildContext context) { final children = [ // expand icon _buildLeftIcon(), // icon _buildViewIcon(), const HSpace(8), // title Expanded( child: FlowyText.regular( widget.view.nameOrDefault, fontSize: 16.0, figmaLineHeight: 20.0, overflow: TextOverflow.ellipsis, ), ), ]; Widget child = InkWell( borderRadius: BorderRadius.circular(4.0), onTap: () => widget.onSelected(widget.view), child: SizedBox( height: HomeSpaceViewSizes.mViewHeight, child: Padding( padding: EdgeInsets.only(left: widget.level * widget.leftPadding), child: Row( children: children, ), ), ), ); if (widget.startActionPane != null || widget.endActionPane != null) { child = Slidable( // Specify a key if the Slidable is dismissible. key: ValueKey(widget.view.hashCode), startActionPane: widget.startActionPane?.call(context), endActionPane: widget.endActionPane?.call(context), child: child, ); } return child; } Widget _buildViewIcon() { final iconData = widget.view.icon.toEmojiIconData(); final icon = iconData.isNotEmpty ? EmojiIconWidget( emoji: widget.view.icon.toEmojiIconData(), emojiSize: Platform.isAndroid ? 16.0 : 18.0, ) : Opacity( opacity: 0.7, child: widget.view.defaultIcon(size: const Size.square(18)), ); return SizedBox( width: 18.0, child: icon, ); } // > button or · button // show > if the view is expandable. // show · if the view can't contain child views. Widget _buildLeftIcon() { const rightPadding = 6.0; if (context.read().state.view.childViews.isEmpty) { return HSpace(widget.leftPadding + rightPadding); } return GestureDetector( behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.only(right: rightPadding, top: 6.0, bottom: 6.0), child: FlowySvg( widget.isExpanded ? FlowySvgs.m_expand_s : FlowySvgs.m_collapse_s, blendMode: null, ), ), onTap: () { context .read() .add(ViewEvent.setIsExpanded(!widget.isExpanded)); }, ); } } // workaround: we should use view.isEndPoint or something to check if the view can contain child views. But currently, we don't have that field. bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) { if (parentView == null) { return false; } return view.layout.isDatabaseView && parentView.layout.isDatabaseView; } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item_add_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileViewAddButton extends StatelessWidget { const MobileViewAddButton({ super.key, required this.onPressed, }); final VoidCallback onPressed; @override Widget build(BuildContext context) { return FlowyIconButton( width: HomeSpaceViewSizes.mViewButtonDimension, height: HomeSpaceViewSizes.mViewButtonDimension, icon: const FlowySvg( FlowySvgs.m_space_add_s, ), onPressed: onPressed, ); } } class MobileViewMoreButton extends StatelessWidget { const MobileViewMoreButton({ super.key, required this.onPressed, }); final VoidCallback onPressed; @override Widget build(BuildContext context) { return FlowyIconButton( width: HomeSpaceViewSizes.mViewButtonDimension, height: HomeSpaceViewSizes.mViewButtonDimension, icon: const FlowySvg( FlowySvgs.m_space_more_s, ), onPressed: onPressed, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/presentation.dart ================================================ export 'editor/mobile_editor_screen.dart'; export 'home/home.dart'; export 'mobile_bottom_navigation_bar.dart'; export 'root_placeholder_page.dart'; export 'setting/setting.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/root_placeholder_page.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; /// Widget for the root/initial pages in the bottom navigation bar. class RootPlaceholderScreen extends StatelessWidget { /// Creates a RootScreen const RootPlaceholderScreen({ required this.label, required this.detailsPath, this.secondDetailsPath, super.key, }); /// The label final String label; /// The path to the detail page final String detailsPath; /// The path to another detail page final String? secondDetailsPath; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( centerTitle: true, title: FlowyText.medium(label), ), body: const SizedBox.shrink(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_ask_ai_entrance.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'mobile_search_summary_cell.dart'; class MobileSearchAskAiEntrance extends StatelessWidget { const MobileSearchAskAiEntrance({super.key}); @override Widget build(BuildContext context) { final bloc = context.read(), state = bloc?.state; if (bloc == null || state == null) return _AskAIFor(); final generatingAIOverview = state.generatingAIOverview; if (generatingAIOverview) return _AISearching(); final hasMockSummary = _mockSummary?.isNotEmpty ?? false, hasSummaries = state.resultSummaries.isNotEmpty; if (hasMockSummary || hasSummaries) return _AIOverview(); return _AskAIFor(); } } class _AskAIFor extends StatelessWidget { const _AskAIFor(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context), spaceM = theme.spacing.m, spaceL = theme.spacing.l; return GestureDetector( onTap: () { context .read() ?.add(CommandPaletteEvent.goingToAskAI()); mobileCreateNewAIChatNotifier.value = mobileCreateNewAIChatNotifier.value + 1; }, behavior: HitTestBehavior.opaque, child: Container( margin: EdgeInsets.only(top: spaceM), padding: EdgeInsets.all(spaceL), child: Row( children: [ SizedBox.square( dimension: 24, child: Center( child: FlowySvg( FlowySvgs.m_home_ai_chat_icon_m, size: Size.square(20), blendMode: null, ), ), ), HSpace(8), buildText(context), ], ), ), ); } Widget buildText(BuildContext context) { final theme = AppFlowyTheme.of(context); final bloc = context.read(); final queryText = bloc?.state.query ?? ''; if (queryText.isEmpty) { return Text( LocaleKeys.search_askAIAnything.tr(), style: theme.textStyle.heading4 .standard(color: theme.textColorScheme.primary), ); } return Flexible( child: RichText( maxLines: 1, overflow: TextOverflow.ellipsis, text: TextSpan( children: [ TextSpan( text: LocaleKeys.search_askAIFor.tr(), style: theme.textStyle.heading4 .standard(color: theme.textColorScheme.primary), ), TextSpan( text: ' "$queryText"', style: theme.textStyle.heading4 .enhanced(color: theme.textColorScheme.primary), ), ], ), ), ); } } class _AISearching extends StatelessWidget { const _AISearching(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context), spaceM = theme.spacing.m, spaceL = theme.spacing.l; return Container( margin: EdgeInsets.only(top: spaceM), padding: EdgeInsets.all(spaceL), child: SizedBox( height: 24, child: Row( children: [ SizedBox.square( dimension: 24, child: Center( child: FlowySvg( FlowySvgs.m_home_ai_chat_icon_m, size: Size.square(20), blendMode: null, ), ), ), HSpace(8), Text( LocaleKeys.search_searching.tr(), style: theme.textStyle.heading4 .standard(color: theme.textColorScheme.secondary), ), ], ), ), ); } } class _AIOverview extends StatelessWidget { const _AIOverview(); @override Widget build(BuildContext context) { final bloc = context.read(), state = bloc?.state; final summaries = _mockSummary ?? state?.resultSummaries ?? []; if (summaries.isEmpty) { return const SizedBox.shrink(); } final theme = AppFlowyTheme.of(context), spaceM = theme.spacing.m, spaceL = theme.spacing.l; return Container( margin: EdgeInsets.only(top: spaceM), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ VSpace(spaceM), buildHeader(context), VSpace(spaceL), LayoutBuilder( builder: (context, constrains) { final summary = summaries.first; return MobileSearchSummaryCell( key: ValueKey(summary.content.trim()), summary: summary, maxWidth: constrains.maxWidth, theme: AppFlowyTheme.of(context), textStyle: theme.textStyle.heading4 .standard(color: theme.textColorScheme.primary) .copyWith(height: 22 / 16), ); }, ), ], ), ); } Widget buildHeader(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( children: [ FlowySvg( FlowySvgs.ai_searching_icon_m, size: Size.square(20), blendMode: null, ), HSpace(8), Text( LocaleKeys.commandPalette_aiOverview.tr(), style: theme.textStyle.heading4 .enhanced(color: theme.textColorScheme.primary), ), ], ); } } List? _mockSummary; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_icon.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'mobile_view_ancestors.dart'; class MobileSearchResultCell extends StatelessWidget { const MobileSearchResultCell({ super.key, required this.item, this.query, this.view, }); final SearchResultItem item; final ViewPB? view; final String? query; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final commandPaletteState = context.read().state; final displayName = item.displayName.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : item.displayName; final titleStyle = theme.textStyle.heading4 .standard(color: theme.textColorScheme.primary) .copyWith(height: 24 / 16); return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox.square( dimension: 24, child: Center( child: (view?.toSearchResultItem().icon ?? item.icon) .buildIcon(context), ), ), HSpace(12), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ RichText( maxLines: 1, overflow: TextOverflow.ellipsis, text: buildHighLightSpan( content: displayName, normal: titleStyle, highlight: titleStyle.copyWith( backgroundColor: theme.fillColorScheme.themeSelect, ), ), ), buildPath(commandPaletteState, theme), ...buildSummary(theme), ], ), ), ], ), ); } Widget buildPath(CommandPaletteState state, AppFlowyThemeData theme) { return BlocProvider( key: ValueKey(item.id), create: (context) => ViewAncestorBloc(item.id), child: BlocBuilder( builder: (context, state) { final ancestors = state.ancestor.ancestors; if(ancestors.isEmpty) return const SizedBox.shrink(); List displayPath = ancestors.map((e) => e.name).toList(); if (ancestors.length > 2) { displayPath = [ancestors.first.name, '...', ancestors.last.name]; } return Text( displayPath.join(' / '), maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textStyle.body .standard(color: theme.textColorScheme.tertiary), ); }, ), ); } List buildSummary(AppFlowyThemeData theme) { if (item.content.isEmpty) return []; return [ VSpace(theme.spacing.m), RichText( maxLines: 3, overflow: TextOverflow.ellipsis, text: buildHighLightSpan( content: item.content, normal: theme.textStyle.body .standard(color: theme.textColorScheme.secondary), highlight: theme.textStyle.body .standard(color: theme.textColorScheme.primary) .copyWith( backgroundColor: theme.fillColorScheme.themeSelect, ), ), ), ]; } TextSpan buildHighLightSpan({ required String content, required TextStyle normal, required TextStyle highlight, }) { final queryText = (query ?? '').trim(); if (queryText.isEmpty) { return TextSpan(text: content, style: normal); } final contents = content.splitIncludeSeparator(queryText); return TextSpan( children: List.generate(contents.length, (index) { final content = contents[index]; final isHighlight = content.toLowerCase() == queryText.toLowerCase(); return TextSpan( text: content, style: isHighlight ? highlight : normal, ); }), ); } } extension ViewPBToSearchResultItem on ViewPB { SearchResultItem toSearchResultItem() { final hasIcon = icon.value.isNotEmpty; return SearchResultItem( id: id, displayName: nameOrDefault, icon: ResultIconPB( ty: hasIcon ? ResultIconTypePB.valueOf(icon.ty.value) : ResultIconTypePB.Icon, value: hasIcon ? icon.value : '${layout.value}', ), content: '', ); } } extension StringSplitExtension on String { List splitIncludeSeparator(String separator) { final splits = split(RegExp(RegExp.escape(separator), caseSensitive: false)); final List contents = []; int charIndex = 0; final seperatorLength = separator.length; for (int i = 0; i < splits.length; i++) { contents.add(splits[i]); charIndex += splits[i].length; if (i != splits.length - 1) { contents.add(substring(charIndex, charIndex + seperatorLength)); charIndex += seperatorLength; } } return contents; } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_icon.dart ================================================ import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; extension MobileSearchIconItemExtension on ResultIconPB { Widget? buildIcon(BuildContext context) { final theme = AppFlowyTheme.of(context); if (ty == ResultIconTypePB.Emoji) { return SizedBox( width: 20, child: getIcon(size: 20) ?? SizedBox.shrink(), ); } else { return getIcon( size: 20, iconColor: theme.iconColorScheme.secondary, ) ?? SizedBox.shrink(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_page.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'mobile_search_ask_ai_entrance.dart'; import 'mobile_search_result.dart'; import 'mobile_search_textfield.dart'; class MobileSearchScreen extends StatelessWidget { const MobileSearchScreen({ super.key, }); static const routeName = '/search'; @override Widget build(BuildContext context) { return FutureBuilder( future: Future.wait([ FolderEventGetCurrentWorkspaceSetting().send(), getIt().getUser(), ]), builder: (context, snapshots) { if (!snapshots.hasData) { return const Center(child: CircularProgressIndicator.adaptive()); } final latest = snapshots.data?[0].fold( (latest) { return latest as WorkspaceLatestPB?; }, (error) => null, ); final userProfile = snapshots.data?[1].fold( (userProfilePB) { return userProfilePB as UserProfilePB?; }, (error) => null, ); // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. if (latest == null || userProfile == null) { return const WorkspaceFailedScreen(); } return Provider.value( value: userProfile, child: MobileSearchPage( userProfile: userProfile, workspaceLatestPB: latest, ), ); }, ); } } class MobileSearchPage extends StatefulWidget { const MobileSearchPage({ super.key, required this.userProfile, required this.workspaceLatestPB, }); final UserProfilePB userProfile; final WorkspaceLatestPB workspaceLatestPB; @override State createState() => _MobileSearchPageState(); } class _MobileSearchPageState extends State { bool get enableShowAISearch => widget.userProfile.workspaceType == WorkspaceTypePB.ServerW; final focusNode = FocusNode(); @override void dispose() { focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SafeArea( child: Scaffold( body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MobileSearchTextfield( focusNode: focusNode, hintText: enableShowAISearch ? LocaleKeys.search_searchOrAskAI.tr() : LocaleKeys.search_label.tr(), query: state.query ?? '', onChanged: (value) => context.read().add( CommandPaletteEvent.searchChanged(search: value), ), ), Flexible( child: NotificationListener( onNotification: (t) { if (t is ScrollUpdateNotification) { if (focusNode.hasFocus) { focusNode.unfocus(); } } return true; }, child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ if (enableShowAISearch) MobileSearchAskAiEntrance(), MobileSearchResult(), ], ), ), ), ), ), ], ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_reference_bottom_sheet.dart ================================================ import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; class SearchSourceReferenceBottomSheet extends StatelessWidget { const SearchSourceReferenceBottomSheet(this.sources, {super.key}); final List sources; @override Widget build(BuildContext context) { return PageReferenceList( sources: sources, onTap: (id) async { final theme = AppFlowyTheme.of(context); final view = (await ViewBackendService.getView(id)).toNullable(); if (view == null) { showToastNotification( message: LocaleKeys.search_somethingWentWrong.tr(), type: ToastificationType.error, ); return; } await showMobileBottomSheet( AppGlobals.rootNavKey.currentContext ?? context, showDragHandle: true, showDivider: false, enableDraggableScrollable: true, maxChildSize: 0.95, minChildSize: 0.95, initialChildSize: 0.95, backgroundColor: theme.surfaceColorScheme.primary, dragHandleBuilder: (_) => const _DragHandler(), builder: (_) => SizedBox( height: MediaQuery.of(context).size.height, child: MobileViewPage( id: id, viewLayout: view.layout, title: view.nameOrDefault, tabs: PickerTabType.values, bodyPaddingTop: AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight, ), ), ); }, ); } } class PageReferenceList extends StatelessWidget { const PageReferenceList({ super.key, required this.sources, required this.onTap, }); final List sources; final ValueChanged onTap; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 16, top: 8), child: Text( LocaleKeys.commandPalette_aiOverviewSource.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.secondary, ), ), ), const VSpace(6), ListView.builder( shrinkWrap: true, padding: EdgeInsets.fromLTRB(16, 0, 16, 8), physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { final source = sources[index]; final displayName = source.displayName.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : source.displayName; final sapceM = theme.spacing.m, spaceL = theme.spacing.l; return FlowyButton( onTap: () => onTap.call(source.id), margin: EdgeInsets.zero, text: Column( mainAxisSize: MainAxisSize.min, children: [ if (index != 0) AFDivider(), Padding( padding: EdgeInsets.symmetric( vertical: spaceL, horizontal: sapceM, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox.square( dimension: 20, child: Center(child: buildIcon(source.icon, theme)), ), HSpace(12), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( displayName, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textStyle.heading4.standard( color: theme.textColorScheme.primary, ), ), ], ), ), ], ), ), ], ), ); }, itemCount: sources.length, ), ], ); } Widget buildIcon(ResultIconPB icon, AppFlowyThemeData theme) { if (icon.ty == ResultIconTypePB.Emoji) { return icon.getIcon(size: 16, lineHeight: 20 / 16) ?? SizedBox.shrink(); } else { return icon.getIcon(size: 20, iconColor: theme.iconColorScheme.primary) ?? SizedBox.shrink(); } } } class _DragHandler extends StatelessWidget { const _DragHandler(); @override Widget build(BuildContext context) { return Container( height: 4, width: 40, margin: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( color: Colors.grey.shade400, borderRadius: BorderRadius.circular(2), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_result.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'mobile_search_cell.dart'; class MobileSearchResult extends StatelessWidget { const MobileSearchResult({super.key}); @override Widget build(BuildContext context) { final state = context.read().state; final query = (state.query ?? '').trim(); if (query.isEmpty) { return const MobileSearchRecentList(); } return MobileSearchResultList(); } } class MobileSearchRecentList extends StatelessWidget { const MobileSearchRecentList({super.key}); @override Widget build(BuildContext context) { final commandPaletteState = context.read().state; final theme = AppFlowyTheme.of(context); final trashIdSet = commandPaletteState.trash.map((e) => e.id).toSet(); return BlocProvider( create: (context) => RecentViewsBloc()..add(const RecentViewsEvent.initial()), child: BlocBuilder( builder: (context, state) { final List recentViews = state.views .map((e) => e.item) .where((e) => !trashIdSet.contains(e.id)) .toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const VSpace(16), Text( LocaleKeys.sideBar_recent.tr(), style: theme.textStyle.heading4 .enhanced(color: theme.textColorScheme.secondary) .copyWith( letterSpacing: 0.2, height: 24 / 16, ), ), const VSpace(4), Column( mainAxisSize: MainAxisSize.min, children: List.generate(recentViews.length, (index) { final view = recentViews[index]; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _goToView(context, view), child: MobileSearchResultCell( item: view.toSearchResultItem(), ), ); }), ), ], ); }, ), ); } } class MobileSearchResultList extends StatelessWidget { const MobileSearchResultList({super.key}); @override Widget build(BuildContext context) { final state = context.read().state, theme = AppFlowyTheme.of(context); final isSearching = state.searching || state.generatingAIOverview, cachedViews = state.cachedViews; List displayItems = state.combinedResponseItems.values.toList(); if (cachedViews.isNotEmpty) { displayItems = displayItems.where((item) => cachedViews[item.id] != null).toList(); } final hasData = displayItems.isNotEmpty; if (isSearching && !hasData) { return SizedBox( height: 400, child: Center(child: CircularProgressIndicator.adaptive()), ); } else if (!hasData) { return _NoResult(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const VSpace(16), Text( LocaleKeys.commandPalette_bestMatches.tr(), style: theme.textStyle.heading4 .enhanced(color: theme.textColorScheme.secondary) .copyWith( letterSpacing: 0.2, height: 24 / 16, ), ), const VSpace(4), ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { final item = displayItems[index]; final view = state.cachedViews[item.id]; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () async { if (view != null && context.mounted) { await _goToView(context, view); } else { showToastNotification( message: LocaleKeys.search_pageNotExist.tr(), type: ToastificationType.error, ); Log.error( 'tapping search result, view not found: ${item.id}', ); } }, child: MobileSearchResultCell( item: item, view: view, query: state.query, ), ); }, separatorBuilder: (context, index) => AFDivider(), itemCount: displayItems.length, ), ], ); } } class _NoResult extends StatelessWidget { const _NoResult(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final textColor = theme.textColorScheme.secondary; return Align( alignment: Alignment.topCenter, child: Column( children: [ const VSpace(48), FlowySvg( FlowySvgs.m_home_search_icon_m, color: theme.iconColorScheme.secondary, size: Size.square(24), ), const VSpace(12), Text( LocaleKeys.search_noResultForSearching.tr(), style: theme.textStyle.heading4.enhanced(color: textColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( textAlign: TextAlign.center, LocaleKeys.search_noResultForSearchingHint.tr(), style: theme.textStyle.body.standard(color: textColor), ), ], ), ); } } Future _goToView(BuildContext context, ViewPB view) async { await context.pushView( view, tabs: [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ].map((e) => e.name).toList(), ); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_summary_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; import 'package:appflowy/mobile/presentation/search/mobile_search_cell.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'mobile_search_reference_bottom_sheet.dart'; class MobileSearchSummaryCell extends StatefulWidget { const MobileSearchSummaryCell({ super.key, required this.summary, required this.maxWidth, required this.theme, required this.textStyle, }); final SearchSummaryPB summary; final double maxWidth; final AppFlowyThemeData theme; final TextStyle textStyle; @override State createState() => _MobileSearchSummaryCellState(); } class _MobileSearchSummaryCellState extends State { late TextPainter _painter; late _TextInfo _textInfo = _TextInfo.normal(summary.content); bool tappedShowMore = false; final maxLines = 4; SearchSummaryPB get summary => widget.summary; double get maxWidth => widget.maxWidth; AppFlowyThemeData get theme => widget.theme; TextStyle get textStyle => widget.textStyle; TextStyle get showMoreStyle => theme.textStyle.heading4.standard(color: theme.textColorScheme.secondary); @override void initState() { super.initState(); refreshTextPainter(); } @override void didUpdateWidget(MobileSearchSummaryCell oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.maxWidth != maxWidth) { refreshTextPainter(); } } @override void dispose() { _painter.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final showReference = summary.sources.isNotEmpty; final query = summary.highlights.isEmpty ? context.read()?.state.query : summary.highlights; return _textInfo.build( context: context, normal: textStyle, more: showMoreStyle, summury: summary, query: query ?? '', showMore: () { setState(() { tappedShowMore = true; _textInfo = _TextInfo.normal(summary.content); }); }, normalWidgetSpan: showReference ? WidgetSpan( alignment: PlaceholderAlignment.middle, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => showPageReferences(context), child: Padding( padding: const EdgeInsets.fromLTRB(4, 4, 15, 4), child: Container( width: 21, height: 15, decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, borderRadius: BorderRadius.circular(6), ), child: FlowySvg( FlowySvgs.toolbar_link_m, color: theme.iconColorScheme.primary, size: Size.square(10), ), ), ), ), ) : null, overflowFadeCover: buildFadeCover(), ); } Widget buildFadeCover() { final fillColor = Theme.of(context).scaffoldBackgroundColor; return Container( width: maxWidth, height: 40, decoration: BoxDecoration( gradient: LinearGradient( colors: [ fillColor.withValues(alpha: 0), fillColor.withValues(alpha: 0.6 * 255), fillColor, ], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), ), ); } void refreshTextPainter() { final content = summary.content; if (!tappedShowMore) { _painter = TextPainter( text: TextSpan(text: content, style: textStyle), textDirection: TextDirection.ltr, maxLines: maxLines, ); _painter.layout(maxWidth: maxWidth); if (_painter.didExceedMaxLines) { final lines = _painter.computeLineMetrics(); final lastLine = lines.last; final offset = Offset( lastLine.left + lastLine.width, lines.map((e) => e.height).reduce((a, b) => a + b), ); final range = _painter.getPositionForOffset(offset); final text = content.substring(0, range.offset); _textInfo = _TextInfo.overflow(text); } else { _textInfo = _TextInfo.normal(content); } } } void showPageReferences(BuildContext context) { final theme = AppFlowyTheme.of(context); showMobileBottomSheet( AppGlobals.rootNavKey.currentContext ?? context, showDragHandle: true, showDivider: false, enableDraggableScrollable: true, backgroundColor: theme.surfaceColorScheme.primary, builder: (_) => SearchSourceReferenceBottomSheet(summary.sources), ); } } class _TextInfo { _TextInfo({required this.text, required this.isOverflow}); _TextInfo.normal(this.text) : isOverflow = false; _TextInfo.overflow(this.text) : isOverflow = true; final String text; final bool isOverflow; Widget build({ required BuildContext context, required TextStyle normal, required TextStyle more, required VoidCallback showMore, required String query, required SearchSummaryPB summury, WidgetSpan? normalWidgetSpan, Widget? overflowFadeCover, }) { final theme = AppFlowyTheme.of(context); if (isOverflow) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: showMore, child: IgnorePointer( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Stack( children: [ SelectionArea( child: Text.rich( _buildHighLightSpan( content: text, normal: normal, query: query, highlight: normal.copyWith( backgroundColor: theme.fillColorScheme.themeSelect, ), ), ), ), if (overflowFadeCover != null) Positioned( bottom: 0, left: 0, child: overflowFadeCover, ), ], ), SizedBox( height: 34, child: Row( children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 7), child: FlowySvg( FlowySvgs.arrow_down_s, size: Size.square(20), color: theme.iconColorScheme.secondary, ), ), HSpace(8), Text(LocaleKeys.search_showMore.tr(), style: more), ], ), ), VSpace(16), ], ), ), ); } else { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SelectionArea( child: Text.rich( TextSpan( children: [ _buildHighLightSpan( content: text, normal: normal, query: query, highlight: normal.copyWith( backgroundColor: theme.fillColorScheme.themeSelect, ), ), if (normalWidgetSpan != null) normalWidgetSpan, ], ), ), ), VSpace(16), SizedBox( width: 156, height: 42, child: AFOutlinedButton.normal( borderRadius: 21, padding: EdgeInsets.zero, builder: (context, hovering, disabled) { return Center( child: SizedBox( height: 22, child: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.chat_ai_page_s, size: Size.square(20), color: theme.iconColorScheme.secondary, ), HSpace(8), Text( LocaleKeys.commandPalette_aiAskFollowUp.tr(), style: theme.textStyle.heading4.standard( color: theme.textColorScheme.primary, ), ), ], ), ), ); }, onTap: () { context.read()?.add( CommandPaletteEvent.goingToAskAI( sources: summury.sources, ), ); mobileCreateNewAIChatNotifier.value = mobileCreateNewAIChatNotifier.value + 1; }, ), ), VSpace(16), ], ); } } TextSpan _buildHighLightSpan({ required String content, required TextStyle normal, required TextStyle highlight, String? query, }) { final queryText = (query ?? '').trim(); if (queryText.isEmpty) { return TextSpan(text: content, style: normal); } final contents = content.splitIncludeSeparator(queryText); return TextSpan( children: List.generate(contents.length, (index) { final content = contents[index]; final isHighlight = content.toLowerCase() == queryText.toLowerCase(); return TextSpan( text: content, style: isHighlight ? highlight : normal, ); }), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_search_textfield.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart'; import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/af_navigator_observer.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'mobile_search_page.dart'; class MobileSearchTextfield extends StatefulWidget { const MobileSearchTextfield({ super.key, this.onChanged, required this.hintText, required this.query, required this.focusNode, }); final String hintText; final String query; final ValueChanged? onChanged; final FocusNode focusNode; @override State createState() => _MobileSearchTextfieldState(); } class _MobileSearchTextfieldState extends State with RouteAware { late final TextEditingController controller; FocusNode get focusNode => widget.focusNode; late String lastPage = bottomNavigationBarItemType.value ?? ''; String lastText = ''; @override void initState() { super.initState(); controller = TextEditingController(text: widget.query); controller.addListener(() { if (!mounted) return; if (lastText != controller.text) { widget.onChanged?.call(controller.text); lastText = controller.text; } }); bottomNavigationBarItemType.addListener(onBackOrLeave); makeSureHasFocus(); getIt().addListener(onRoute); } @override void dispose() { controller.dispose(); bottomNavigationBarItemType.removeListener(onBackOrLeave); getIt().removeListener(onRoute); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Container( height: 42, margin: EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.only(left: 4, right: 16), child: ValueListenableBuilder( valueListenable: controller, builder: (context, _, __) { return Row( children: [ GestureDetector( onTap: () { if (lastPage.isEmpty) return; // close the popup menu closePopupMenu(); try { BottomNavigationBarItemType label = BottomNavigationBarItemType.values.byName(lastPage); if (label == BottomNavigationBarItemType.search) { label = BottomNavigationBarItemType.home; } if (label == BottomNavigationBarItemType.notification) { getIt().add(const ReminderEvent.refresh()); } bottomNavigationBarItemType.value = label.label; final routeName = label.routeName; if (routeName != null) GoRouter.of(context).go(routeName); } on ArgumentError { Log.error( 'lastPage: [$lastPage] cannot be converted to BottomNavigationBarItemType', ); } }, child: SizedBox.square( dimension: 40, child: Center( child: FlowySvg( FlowySvgs.search_page_arrow_left_m, size: Size.square(20), color: theme.iconColorScheme.primary, ), ), ), ), Expanded( child: TextFormField( autovalidateMode: AutovalidateMode.onUserInteraction, focusNode: focusNode, textAlign: TextAlign.left, controller: controller, style: theme.textStyle.heading4.standard( color: theme.textColorScheme.primary, ), decoration: buildInputDecoration(context), ), ), ], ); }, ), ); } InputDecoration buildInputDecoration(BuildContext context) { final theme = AppFlowyTheme.of(context); final showCancelIcon = controller.text.isNotEmpty; final border = OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(10.0)), borderSide: BorderSide(color: theme.borderColorScheme.primary), ); final enableBorder = border.copyWith( borderSide: BorderSide(color: theme.borderColorScheme.themeThick), ); final hintStyle = theme.textStyle.heading4.standard( color: theme.textColorScheme.tertiary, ); return InputDecoration( hintText: widget.hintText, hintStyle: hintStyle, contentPadding: const EdgeInsets.fromLTRB(8, 10, 8, 10), isDense: true, border: border, enabledBorder: border, focusedBorder: enableBorder, prefixIconConstraints: BoxConstraints.loose(Size(38, 40)), prefixIcon: Padding( padding: const EdgeInsets.fromLTRB(8, 10, 8, 10), child: FlowySvg( FlowySvgs.m_home_search_icon_m, color: theme.iconColorScheme.secondary, size: Size.square(20), ), ), suffixIconConstraints: showCancelIcon ? BoxConstraints.loose(Size(34, 40)) : null, suffixIcon: showCancelIcon ? GestureDetector( onTap: () { controller.clear(); }, child: Padding( padding: const EdgeInsets.fromLTRB(4, 10, 8, 10), child: FlowySvg( FlowySvgs.search_clear_m, color: theme.iconColorScheme.tertiary, size: const Size.square(20), ), ), ) : null, ); } Future makeSureHasFocus() async { if (!mounted || focusNode.hasFocus) return; focusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { makeSureHasFocus(); }); } void onBackOrLeave() { final label = bottomNavigationBarItemType.value; if (label == BottomNavigationBarItemType.search.label) { focusNode.requestFocus(); } else { focusNode.unfocus(); controller.clear(); lastPage = label ?? ''; } } void onRoute(RouteInfo routeInfo) { final oldName = routeInfo.oldRoute?.settings.name; if (oldName != MobileSearchScreen.routeName) return; if (routeInfo is PushRouterInfo) { focusNode.unfocus(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/mobile_view_ancestors.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'view_ancestor_cache.dart'; part 'mobile_view_ancestors.freezed.dart'; class ViewAncestorBloc extends Bloc { ViewAncestorBloc(String viewId) : super(ViewAncestorState.initial(viewId)) { _cache = getIt(); _dispatch(); } late final ViewAncestorCache _cache; void _dispatch() { on( (event, emit) async { await event.map( initial: (e) async { emit(state.copyWith(isLoading: true)); final ancester = await _cache.getAncestor( state.viewId, onRefresh: (ancestor) { if (!emit.isDone) { emit(state.copyWith(ancestor: ancestor, isLoading: false)); } }, ); if (ancester != null) { emit(state.copyWith(ancestor: ancester, isLoading: false)); } }, ); }, ); add(const ViewAncestorEvent.initial()); } } @freezed class ViewAncestorEvent with _$ViewAncestorEvent { const factory ViewAncestorEvent.initial() = Initial; } @freezed class ViewAncestorState with _$ViewAncestorState { const factory ViewAncestorState({ required ViewAncestor ancestor, required String viewId, @Default(true) bool isLoading, }) = _ViewAncestorState; factory ViewAncestorState.initial(String viewId) => ViewAncestorState( viewId: viewId, ancestor: ViewAncestor.empty(), ); } extension ViewAncestorTextExtension on ViewAncestorState { Widget buildPath(BuildContext context, {TextStyle? style}) { final theme = AppFlowyTheme.of(context); final ancestors = ancestor.ancestors; final textStyle = style ?? theme.textStyle.caption.standard(color: theme.textColorScheme.tertiary); final textHeight = (textStyle.fontSize ?? 0.0) * (textStyle.height ?? 1.0); if (isLoading) return VSpace(textHeight); return LayoutBuilder( builder: (context, constrains) { final List displayPath = ancestors.map((e) => e.name).toList(); if (displayPath.isEmpty) return const SizedBox.shrink(); TextPainter textPainter = _buildTextPainter(displayPath.join(' / '), textStyle); textPainter.layout(maxWidth: constrains.maxWidth); if (textPainter.didExceedMaxLines && displayPath.length > 2) { displayPath.removeAt(displayPath.length - 2); displayPath.insert(displayPath.length - 1, '...'); } textPainter = _buildTextPainter(displayPath.join(' / '), textStyle); textPainter.layout(maxWidth: constrains.maxWidth); while (textPainter.didExceedMaxLines && displayPath.length > 3) { displayPath.removeAt(displayPath.length - 2); textPainter = _buildTextPainter(displayPath.join(' / '), textStyle); textPainter.layout(maxWidth: constrains.maxWidth); } return Text( displayPath.join(' / '), style: textStyle, maxLines: 2, overflow: TextOverflow.ellipsis, ); }, ); } TextPainter _buildTextPainter(String text, TextStyle style) => TextPainter( text: TextSpan(text: text, style: style), maxLines: 1, textDirection: TextDirection.ltr, ); Widget buildOnelinePath(BuildContext context) { final ancestors = ancestor.ancestors; List displayPath = ancestors.map((e) => e.name).toList(); if (ancestors.length > 2) { displayPath = [ancestors.first.name, '...', ancestors.last.name]; } final theme = AppFlowyTheme.of(context); final style = theme.textStyle.caption .standard(color: theme.textColorScheme.tertiary) .copyWith(letterSpacing: 0.1); return Row( mainAxisSize: MainAxisSize.min, children: [ HSpace(8), Text( '-', style: style.copyWith( color: theme.borderColorScheme.primaryHover, ), ), HSpace(8), Flexible( child: Text( displayPath.join(' / '), style: style, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/search/view_ancestor_cache.dart ================================================ import 'dart:async'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/material.dart'; class ViewAncestorCache { ViewAncestorCache(); final Map _ancestors = {}; Future getAncestor( String viewId, { ValueChanged? onRefresh, }) async { final cachedAncestor = _ancestors[viewId]; if (cachedAncestor != null) { unawaited(_getAncestor(viewId, onRefresh: onRefresh)); return cachedAncestor; } return _getAncestor(viewId); } Future _getAncestor( String viewId, { ValueChanged? onRefresh, }) async { final List? ancestors = await ViewBackendService.getViewAncestors(viewId).fold( (s) => s.items .where((e) => e.parentViewId.isNotEmpty && e.id != viewId) .toList(), (f) => null, ); if (ancestors != null) { final newAncestors = ViewAncestor( ancestors: ancestors.map((e) => ViewParent.fromViewPB(e)).toList(), ); _ancestors[viewId] = newAncestors; onRefresh?.call(newAncestors); return newAncestors; } return null; } } class ViewAncestor { const ViewAncestor({required this.ancestors}); const ViewAncestor.empty() : ancestors = const []; final List ancestors; } class ViewParent { ViewParent({required this.id, required this.name}); final String id; final String name; static ViewParent fromViewPB(ViewPB view) => ViewParent(id: view.id, name: view.nameOrDefault); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu.dart ================================================ import 'dart:async'; import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'mobile_selection_menu_item_widget.dart'; import 'mobile_selection_menu_widget.dart'; class MobileSelectionMenu extends SelectionMenuService { MobileSelectionMenu({ required this.context, required this.editorState, required this.selectionMenuItems, this.deleteSlashByDefault = false, this.deleteKeywordsByDefault = false, this.style = MobileSelectionMenuStyle.light, this.itemCountFilter = 0, this.startOffset = 0, this.singleColumn = false, }); final BuildContext context; final EditorState editorState; final List selectionMenuItems; final bool deleteSlashByDefault; final bool deleteKeywordsByDefault; final bool singleColumn; @override final MobileSelectionMenuStyle style; OverlayEntry? _selectionMenuEntry; Offset _offset = Offset.zero; Alignment _alignment = Alignment.topLeft; final int itemCountFilter; final int startOffset; ValueNotifier<_Position> _positionNotifier = ValueNotifier(_Position.zero); @override void dismiss() { if (_selectionMenuEntry != null) { editorState.service.keyboardService?.enable(); editorState.service.scrollService?.enable(); editorState .removeScrollViewScrolledListener(_checkPositionAfterScrolling); _positionNotifier.dispose(); } _selectionMenuEntry?.remove(); _selectionMenuEntry = null; } @override Future show() async { final completer = Completer(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _show(); editorState.addScrollViewScrolledListener(_checkPositionAfterScrolling); completer.complete(); }); return completer.future; } void _show() { final position = _getCurrentPosition(); if (position == null) return; final editorHeight = editorState.renderBox!.size.height; final editorWidth = editorState.renderBox!.size.width; _positionNotifier = ValueNotifier(position); final showAtTop = position.top != null; _selectionMenuEntry = OverlayEntry( builder: (context) { return SizedBox( width: editorWidth, height: editorHeight, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: dismiss, child: Stack( children: [ ValueListenableBuilder( valueListenable: _positionNotifier, builder: (context, value, _) { return Positioned( top: value.top, bottom: value.bottom, left: value.left, right: value.right, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: MobileSelectionMenuWidget( selectionMenuStyle: style, singleColumn: singleColumn, showAtTop: showAtTop, items: selectionMenuItems ..forEach((element) { if (element is MobileSelectionMenuItem) { element.deleteSlash = false; element.deleteKeywords = deleteKeywordsByDefault; for (final e in element.children) { e.deleteSlash = deleteSlashByDefault; e.deleteKeywords = deleteKeywordsByDefault; e.onSelected = () { dismiss(); }; } } else { element.deleteSlash = deleteSlashByDefault; element.deleteKeywords = deleteKeywordsByDefault; element.onSelected = () { dismiss(); }; } }), maxItemInRow: 5, editorState: editorState, itemCountFilter: itemCountFilter, startOffset: startOffset, menuService: this, onExit: () { dismiss(); }, deleteSlashByDefault: deleteSlashByDefault, ), ), ); }, ), ], ), ), ); }, ); Overlay.of(context, rootOverlay: true).insert(_selectionMenuEntry!); editorState.service.keyboardService?.disable(showCursor: true); editorState.service.scrollService?.disable(); } /// the workaround for: editor auto scrolling that will cause wrong position /// of slash menu void _checkPositionAfterScrolling() { final position = _getCurrentPosition(); if (position == null) return; if (position == _positionNotifier.value) { Future.delayed(const Duration(milliseconds: 100)).then((_) { final position = _getCurrentPosition(); if (position == null) return; if (position != _positionNotifier.value) { _positionNotifier.value = position; } }); } else { _positionNotifier.value = position; } } _Position? _getCurrentPosition() { final selectionRects = editorState.selectionRects(); if (selectionRects.isEmpty) { return null; } final screenSize = MediaQuery.of(context).size; calculateSelectionMenuOffset(selectionRects.first, screenSize); final (left, top, right, bottom) = getPosition(); return _Position(left, top, right, bottom); } @override Alignment get alignment { return _alignment; } @override Offset get offset { return _offset; } @override (double? left, double? top, double? right, double? bottom) getPosition() { double? left, top, right, bottom; switch (alignment) { case Alignment.topLeft: left = offset.dx; top = offset.dy; break; case Alignment.bottomLeft: left = offset.dx; bottom = offset.dy; break; case Alignment.topRight: right = offset.dx; top = offset.dy; break; case Alignment.bottomRight: right = offset.dx; bottom = offset.dy; break; } return (left, top, right, bottom); } void calculateSelectionMenuOffset(Rect rect, Size screenSize) { // Workaround: We can customize the padding through the [EditorStyle], // but the coordinates of overlay are not properly converted currently. // Just subtract the padding here as a result. const menuHeight = 192.0, menuWidth = 240.0; final editorOffset = editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; final editorHeight = editorState.renderBox!.size.height; final screenHeight = screenSize.height; final editorWidth = editorState.renderBox!.size.width; final rectHeight = rect.height; // show below default _alignment = Alignment.bottomRight; final bottomRight = rect.topLeft; final offset = bottomRight; final limitX = editorWidth + editorOffset.dx - menuWidth, limitY = screenHeight - editorHeight + editorOffset.dy - menuHeight - rectHeight; _offset = Offset( editorWidth - offset.dx - menuWidth, screenHeight - offset.dy - menuHeight - rectHeight, ); if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { /// show above if (offset.dy > menuHeight) { _offset = Offset( _offset.dx, offset.dy - menuHeight, ); _alignment = Alignment.topRight; } else { _offset = Offset( _offset.dx, limitY, ); } } if (offset.dx + menuWidth >= editorOffset.dx + editorWidth) { /// show left if (offset.dx > menuWidth) { _alignment = _alignment == Alignment.bottomRight ? Alignment.bottomLeft : Alignment.topLeft; _offset = Offset( offset.dx - menuWidth, _offset.dy, ); } else { _offset = Offset( limitX, _offset.dy, ); } } } } class _Position { const _Position(this.left, this.top, this.right, this.bottom); final double? left; final double? top; final double? right; final double? bottom; static const _Position zero = _Position(0, 0, 0, 0); @override bool operator ==(Object other) => identical(this, other) || other is _Position && runtimeType == other.runtimeType && left == other.left && top == other.top && right == other.right && bottom == other.bottom; @override int get hashCode => left.hashCode ^ top.hashCode ^ right.hashCode ^ bottom.hashCode; } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; class MobileSelectionMenuItem extends SelectionMenuItem { MobileSelectionMenuItem({ required super.getName, required super.icon, super.keywords = const [], required super.handler, this.children = const [], super.nameBuilder, super.deleteKeywords, super.deleteSlash, }); final List children; bool get isNotEmpty => children.isNotEmpty; } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'mobile_selection_menu_item.dart'; class MobileSelectionMenuItemWidget extends StatelessWidget { const MobileSelectionMenuItemWidget({ super.key, required this.editorState, required this.menuService, required this.item, required this.isSelected, required this.selectionMenuStyle, required this.onTap, }); final EditorState editorState; final SelectionMenuService menuService; final SelectionMenuItem item; final bool isSelected; final MobileSelectionMenuStyle selectionMenuStyle; final VoidCallback onTap; @override Widget build(BuildContext context) { final style = selectionMenuStyle; final showRightArrow = item is MobileSelectionMenuItem && (item as MobileSelectionMenuItem).isNotEmpty; return Container( padding: const EdgeInsets.symmetric(horizontal: 6), child: TextButton.icon( icon: item.icon( editorState, false, selectionMenuStyle, ), style: ButtonStyle( alignment: Alignment.centerLeft, overlayColor: WidgetStateProperty.all(Colors.transparent), backgroundColor: isSelected ? WidgetStateProperty.all( style.selectionMenuItemSelectedColor, ) : WidgetStateProperty.all(Colors.transparent), ), label: Row( children: [ item.nameBuilder?.call(item.name, style, false) ?? Text( item.name, textAlign: TextAlign.left, style: TextStyle( color: style.selectionMenuItemTextColor, fontSize: 16.0, ), ), if (showRightArrow) ...[ Spacer(), Icon( Icons.keyboard_arrow_right_rounded, color: style.selectionMenuItemRightIconColor, ), ], ], ), onPressed: () { onTap.call(); item.handler( editorState, menuService, context, ); }, ), ); } } class MobileSelectionMenuStyle extends SelectionMenuStyle { const MobileSelectionMenuStyle({ required super.selectionMenuBackgroundColor, required super.selectionMenuItemTextColor, required super.selectionMenuItemIconColor, required super.selectionMenuItemSelectedTextColor, required super.selectionMenuItemSelectedIconColor, required super.selectionMenuItemSelectedColor, required super.selectionMenuUnselectedLabelColor, required super.selectionMenuDividerColor, required super.selectionMenuLinkBorderColor, required super.selectionMenuInvalidLinkColor, required super.selectionMenuButtonColor, required super.selectionMenuButtonTextColor, required super.selectionMenuButtonIconColor, required super.selectionMenuButtonBorderColor, required super.selectionMenuTabIndicatorColor, required this.selectionMenuItemRightIconColor, }); final Color selectionMenuItemRightIconColor; static const MobileSelectionMenuStyle light = MobileSelectionMenuStyle( selectionMenuBackgroundColor: Color(0xFFFFFFFF), selectionMenuItemTextColor: Color(0xFF1F2225), selectionMenuItemIconColor: Color(0xFF333333), selectionMenuItemSelectedColor: Color(0xFFF2F5F7), selectionMenuItemRightIconColor: Color(0xB31E2022), selectionMenuItemSelectedTextColor: Color.fromARGB(255, 56, 91, 247), selectionMenuItemSelectedIconColor: Color.fromARGB(255, 56, 91, 247), selectionMenuUnselectedLabelColor: Color(0xFF333333), selectionMenuDividerColor: Color(0xFF00BCF0), selectionMenuLinkBorderColor: Color(0xFF00BCF0), selectionMenuInvalidLinkColor: Color(0xFFE53935), selectionMenuButtonColor: Color(0xFF00BCF0), selectionMenuButtonTextColor: Color(0xFF333333), selectionMenuButtonIconColor: Color(0xFF333333), selectionMenuButtonBorderColor: Color(0xFF00BCF0), selectionMenuTabIndicatorColor: Color(0xFF00BCF0), ); static const MobileSelectionMenuStyle dark = MobileSelectionMenuStyle( selectionMenuBackgroundColor: Color(0xFF424242), selectionMenuItemTextColor: Color(0xFFFFFFFF), selectionMenuItemIconColor: Color(0xFFFFFFFF), selectionMenuItemSelectedColor: Color(0xFF666666), selectionMenuItemRightIconColor: Color(0xB3FFFFFF), selectionMenuItemSelectedTextColor: Color(0xFF131720), selectionMenuItemSelectedIconColor: Color(0xFF131720), selectionMenuUnselectedLabelColor: Color(0xFFBBC3CD), selectionMenuDividerColor: Color(0xFF3A3F44), selectionMenuLinkBorderColor: Color(0xFF3A3F44), selectionMenuInvalidLinkColor: Color(0xFFE53935), selectionMenuButtonColor: Color(0xFF00BCF0), selectionMenuButtonTextColor: Color(0xFFFFFFFF), selectionMenuButtonIconColor: Color(0xFFFFFFFF), selectionMenuButtonBorderColor: Color(0xFF00BCF0), selectionMenuTabIndicatorColor: Color(0xFF00BCF0), ); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart ================================================ import 'dart:math'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'mobile_selection_menu_item.dart'; import 'mobile_selection_menu_item_widget.dart'; import 'slash_keyboard_service_interceptor.dart'; class MobileSelectionMenuWidget extends StatefulWidget { const MobileSelectionMenuWidget({ super.key, required this.items, required this.itemCountFilter, required this.maxItemInRow, required this.menuService, required this.editorState, required this.onExit, required this.selectionMenuStyle, required this.deleteSlashByDefault, required this.singleColumn, required this.startOffset, required this.showAtTop, this.nameBuilder, }); final List items; final int itemCountFilter; final int maxItemInRow; final SelectionMenuService menuService; final EditorState editorState; final VoidCallback onExit; final MobileSelectionMenuStyle selectionMenuStyle; final bool deleteSlashByDefault; final bool singleColumn; final bool showAtTop; final int startOffset; final SelectionMenuItemNameBuilder? nameBuilder; @override State createState() => _MobileSelectionMenuWidgetState(); } class _MobileSelectionMenuWidgetState extends State { final _focusNode = FocusNode(debugLabel: 'popup_list_widget'); List _showingItems = []; int _searchCounter = 0; EditorState get editorState => widget.editorState; SelectionMenuService get menuService => widget.menuService; String _keyword = ''; String get keyword => _keyword; int selectedIndex = 0; late AppFlowyKeyboardServiceInterceptor keyboardInterceptor; List get filterItems { final List items = []; for (final item in widget.items) { if (item is MobileSelectionMenuItem) { for (final childItem in item.children) { items.add(childItem); } } else { items.add(item); } } return items; } set keyword(String newKeyword) { _keyword = newKeyword; // Search items according to the keyword, and calculate the length of // the longest keyword, which is used to dismiss the selection_service. var maxKeywordLength = 0; final items = newKeyword.isEmpty ? widget.items : filterItems .where( (item) => item.allKeywords.any((keyword) { final value = keyword.contains(newKeyword.toLowerCase()); if (value) { maxKeywordLength = max(maxKeywordLength, keyword.length); } return value; }), ) .toList(growable: false); AppFlowyEditorLog.ui.debug('$items'); if (keyword.length >= maxKeywordLength + 2 && !(widget.deleteSlashByDefault && _searchCounter < 2)) { return widget.onExit(); } _showingItems = items; refreshSelectedIndex(); if (_showingItems.isEmpty) { _searchCounter++; } else { _searchCounter = 0; } } @override void initState() { super.initState(); _showingItems = buildInitialItems(); keepEditorFocusNotifier.increase(); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); keyboardInterceptor = SlashKeyboardServiceInterceptor( onDelete: () async { if (!mounted) return false; final hasItemsChanged = !isInitialItems(); if (keyword.isEmpty && hasItemsChanged) { _showingItems = buildInitialItems(); refreshSelectedIndex(); return true; } return false; }, onEnter: () { if (!mounted) return; if (_showingItems.isEmpty) return; final item = _showingItems[selectedIndex]; if (item is MobileSelectionMenuItem) { selectedIndex = 0; item.onSelected?.call(); } else { item.handler( editorState, menuService, context, ); } }, ); editorState.service.keyboardService ?.registerInterceptor(keyboardInterceptor); editorState.selectionNotifier.addListener(onSelectionChanged); } @override void dispose() { editorState.service.keyboardService ?.unregisterInterceptor(keyboardInterceptor); editorState.selectionNotifier.removeListener(onSelectionChanged); _focusNode.dispose(); keepEditorFocusNotifier.decrease(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: 192, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.showAtTop) Spacer(), Focus( focusNode: _focusNode, child: DecoratedBox( decoration: BoxDecoration( color: widget.selectionMenuStyle.selectionMenuBackgroundColor, boxShadow: [ BoxShadow( blurRadius: 5, spreadRadius: 1, color: Colors.black.withValues(alpha: 0.1), ), ], borderRadius: BorderRadius.circular(6.0), ), child: _showingItems.isEmpty ? _buildNoResultsWidget(context) : _buildResultsWidget( context, _showingItems, widget.itemCountFilter, ), ), ), if (!widget.showAtTop) Spacer(), ], ), ); } void onSelectionChanged() { final selection = editorState.selection; if (selection == null) { widget.onExit(); return; } if (!selection.isCollapsed) { widget.onExit(); return; } final startOffset = widget.startOffset; final endOffset = selection.end.offset; if (endOffset < startOffset) { widget.onExit(); return; } final node = editorState.getNodeAtPath(selection.start.path); final text = node?.delta?.toPlainText() ?? ''; final search = text.substring(startOffset, endOffset); keyword = search; } Widget _buildResultsWidget( BuildContext buildContext, List items, int itemCountFilter, ) { if (widget.singleColumn) { final List itemWidgets = []; for (var i = 0; i < items.length; i++) { final item = items[i]; itemWidgets.add( GestureDetector( onTapDown: (e) { setState(() { selectedIndex = i; }); }, child: MobileSelectionMenuItemWidget( item: item, isSelected: i == selectedIndex, editorState: editorState, menuService: menuService, selectionMenuStyle: widget.selectionMenuStyle, onTap: () { if (item is MobileSelectionMenuItem) refreshSelectedIndex(); }, ), ), ); } return ConstrainedBox( constraints: const BoxConstraints( maxHeight: 192, minWidth: 240, maxWidth: 240, ), child: ListView( shrinkWrap: true, padding: EdgeInsets.zero, children: itemWidgets, ), ); } else { final List columns = []; List itemWidgets = []; // apply item count filter if (itemCountFilter > 0) { items = items.take(itemCountFilter).toList(); } for (var i = 0; i < items.length; i++) { final item = items[i]; if (i != 0 && i % (widget.maxItemInRow) == 0) { columns.add( Column( crossAxisAlignment: CrossAxisAlignment.start, children: itemWidgets, ), ); itemWidgets = []; } itemWidgets.add( MobileSelectionMenuItemWidget( item: item, isSelected: false, editorState: editorState, menuService: menuService, selectionMenuStyle: widget.selectionMenuStyle, onTap: () { if (item is MobileSelectionMenuItem) refreshSelectedIndex(); }, ), ); } if (itemWidgets.isNotEmpty) { columns.add( Column( crossAxisAlignment: CrossAxisAlignment.start, children: itemWidgets, ), ); itemWidgets = []; } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: columns, ); } } void refreshSelectedIndex() { if (!mounted) return; setState(() { selectedIndex = 0; }); } Widget _buildNoResultsWidget(BuildContext context) { final theme = AppFlowyTheme.of(context); return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).cardColor, boxShadow: [ BoxShadow( blurRadius: 5, spreadRadius: 1, color: Colors.black.withValues(alpha: 0.1), ), ], borderRadius: BorderRadius.circular(12.0), ), child: SizedBox( width: 240, height: 48, child: Padding( padding: const EdgeInsets.all(6.0), child: Material( color: Colors.transparent, child: Center( child: Text( LocaleKeys.inlineActions_noResults.tr(), style: TextStyle( fontSize: 18.0, color: theme.textColorScheme.primary, ), textAlign: TextAlign.center, ), ), ), ), ), ); } List buildInitialItems() { final List items = []; for (final item in widget.items) { if (item is MobileSelectionMenuItem) { item.onSelected = () { if (mounted) { setState(() { _showingItems = item.children .map((e) => e..onSelected = widget.onExit) .toList(); }); } }; } items.add(item); } return items; } bool isInitialItems() { if (_showingItems.length != widget.items.length) return false; int i = 0; for (final item in _showingItems) { final widgetItem = widget.items[i]; if (widgetItem.name != item.name) return false; i++; } return true; } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/selection_menu/slash_keyboard_service_interceptor.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/keyboard_interceptor/keyboard_interceptor.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; class SlashKeyboardServiceInterceptor extends EditorKeyboardInterceptor { SlashKeyboardServiceInterceptor({ required this.onDelete, required this.onEnter, }); final AsyncValueGetter onDelete; final VoidCallback onEnter; @override Future interceptDelete( TextEditingDeltaDeletion deletion, EditorState editorState, ) async { final intercept = await onDelete.call(); if (intercept) { return true; } else { return super.interceptDelete(deletion, editorState); } } @override Future interceptInsert( TextEditingDeltaInsertion insertion, EditorState editorState, List characterShortcutEvents, ) async { final text = insertion.textInserted; if (text.contains('\n')) { onEnter.call(); return true; } return super .interceptInsert(insertion, editorState, characterShortcutEvents); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about.dart ================================================ export 'about_setting_group.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../widgets/widgets.dart'; class AboutSettingGroup extends StatelessWidget { const AboutSettingGroup({ super.key, }); @override Widget build(BuildContext context) { return MobileSettingGroup( groupTitle: LocaleKeys.settings_mobile_about.tr(), settingItemList: [ MobileSettingItem( name: LocaleKeys.settings_mobile_privacyPolicy.tr(), trailing: MobileSettingTrailing( text: '', ), onTap: () => afLaunchUrlString('https://appflowy.com/privacy'), ), MobileSettingItem( name: LocaleKeys.settings_mobile_termsAndConditions.tr(), trailing: MobileSettingTrailing( text: '', ), onTap: () => afLaunchUrlString('https://appflowy.com/terms'), ), if (kDebugMode) MobileSettingItem( name: 'Feature Flags', trailing: MobileSettingTrailing( text: '', ), onTap: () { context.push(FeatureFlagScreen.routeName); }, ), MobileSettingItem( name: LocaleKeys.settings_mobile_version.tr(), trailing: MobileSettingTrailing( text: '${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})', showArrow: false, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class AiSettingsGroup extends StatelessWidget { const AiSettingsGroup({ super.key, required this.userProfile, required this.workspaceId, }); final UserProfilePB userProfile; final String workspaceId; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => SettingsAIBloc( userProfile, workspaceId, )..add(const SettingsAIEvent.started()), child: BlocBuilder( builder: (context, state) { return MobileSettingGroup( groupTitle: LocaleKeys.settings_aiPage_title.tr(), settingItemList: [ MobileSettingItem( name: LocaleKeys.settings_aiPage_keys_llmModelType.tr(), trailing: MobileSettingTrailing( text: state.availableModels?.selectedModel.name ?? "", ), onTap: () => _onLLMModelTypeTap(context, state), ), // enable AI search if needed // MobileSettingItem( // name: LocaleKeys.settings_aiPage_keys_enableAISearchTitle.tr(), // trailing: const Icon( // Icons.chevron_right, // ), // onTap: () => context.push(AppFlowyCloudPage.routeName), // ), ], ); }, ), ); } void _onLLMModelTypeTap(BuildContext context, SettingsAIState state) { final availableModels = state.availableModels; showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showDivider: false, title: LocaleKeys.settings_aiPage_keys_llmModelType.tr(), builder: (_) { return Column( children: (availableModels?.models ?? []) .asMap() .entries .map( (entry) => FlowyOptionTile.checkbox( text: entry.value.name, showTopBorder: entry.key == 0, isSelected: availableModels?.selectedModel.name == entry.value.name, onTap: () { context .read() .add(SettingsAIEvent.selectModel(entry.value)); context.pop(); }, ), ) .toList(), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/appearance_setting_group.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/setting/appearance/rtl_setting.dart'; import 'package:appflowy/mobile/presentation/setting/appearance/text_scale_setting.dart'; import 'package:appflowy/mobile/presentation/setting/appearance/theme_setting.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../setting.dart'; class AppearanceSettingGroup extends StatelessWidget { const AppearanceSettingGroup({ super.key, }); @override Widget build(BuildContext context) { return MobileSettingGroup( groupTitle: LocaleKeys.settings_menu_appearance.tr(), settingItemList: const [ ThemeSetting(), FontSetting(), DisplaySizeSetting(), RTLSetting(), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../setting.dart'; class RTLSetting extends StatelessWidget { const RTLSetting({ super.key, }); @override Widget build(BuildContext context) { final textDirection = context.watch().state.textDirection; return MobileSettingItem( name: LocaleKeys.settings_appearance_textDirection_label.tr(), trailing: MobileSettingTrailing( text: _textDirectionLabelText(textDirection), ), onTap: () { showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showDivider: false, title: LocaleKeys.settings_appearance_textDirection_label.tr(), builder: (context) { return Column( children: [ FlowyOptionTile.checkbox( text: LocaleKeys.settings_appearance_textDirection_ltr.tr(), isSelected: textDirection == AppFlowyTextDirection.ltr, onTap: () => applyTextDirectionAndPop( context, AppFlowyTextDirection.ltr, ), ), FlowyOptionTile.checkbox( showTopBorder: false, text: LocaleKeys.settings_appearance_textDirection_rtl.tr(), isSelected: textDirection == AppFlowyTextDirection.rtl, onTap: () => applyTextDirectionAndPop( context, AppFlowyTextDirection.rtl, ), ), FlowyOptionTile.checkbox( showTopBorder: false, text: LocaleKeys.settings_appearance_textDirection_auto.tr(), isSelected: textDirection == AppFlowyTextDirection.auto, onTap: () => applyTextDirectionAndPop( context, AppFlowyTextDirection.auto, ), ), ], ); }, ); }, ); } String _textDirectionLabelText(AppFlowyTextDirection textDirection) { switch (textDirection) { case AppFlowyTextDirection.auto: return LocaleKeys.settings_appearance_textDirection_auto.tr(); case AppFlowyTextDirection.rtl: return LocaleKeys.settings_appearance_textDirection_rtl.tr(); case AppFlowyTextDirection.ltr: return LocaleKeys.settings_appearance_textDirection_ltr.tr(); } } void applyTextDirectionAndPop( BuildContext context, AppFlowyTextDirection textDirection, ) { context.read().setTextDirection(textDirection); context .read() .syncDefaultTextDirection(textDirection.name); Navigator.pop(context); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_window_size_manager.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:scaled_app/scaled_app.dart'; import '../setting.dart'; const int _divisions = 4; const double _minMobileScaleFactor = 0.8; const double _maxMobileScaleFactor = 1.2; class DisplaySizeSetting extends StatefulWidget { const DisplaySizeSetting({ super.key, }); @override State createState() => _DisplaySizeSettingState(); } class _DisplaySizeSettingState extends State { double scaleFactor = 1.0; final windowSizeManager = WindowSizeManager(); @override void initState() { super.initState(); windowSizeManager.getScaleFactor().then((v) { if (v != scaleFactor && mounted) { setState(() { scaleFactor = v; }); } }); } @override Widget build(BuildContext context) { return MobileSettingItem( name: LocaleKeys.settings_appearance_displaySize.tr(), trailing: MobileSettingTrailing( text: scaleFactor.toStringAsFixed(1), ), onTap: () { showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showDivider: false, title: LocaleKeys.settings_appearance_displaySize.tr(), builder: (context) { return FontSizeStepper( value: scaleFactor, minimumValue: _minMobileScaleFactor, maximumValue: _maxMobileScaleFactor, divisions: _divisions, onChanged: (newScaleFactor) async { await _setScale(newScaleFactor); }, ); }, ); }, ); } Future _setScale(double value) async { if (FlowyRunner.currentMode == IntegrationMode.integrationTest) { // The integration test will fail if we check the scale factor in the test. // #0 ScaledWidgetsFlutterBinding.Eval () // #1 ScaledWidgetsFlutterBinding.instance (package:scaled_app/scaled_app.dart:66:62) // ignore: invalid_use_of_visible_for_testing_member appflowyScaleFactor = value; } else { ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => value; } if (mounted) { setState(() { scaleFactor = value; }); } await windowSizeManager.setScaleFactor(value); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/util/theme_mode_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../setting.dart'; class ThemeSetting extends StatelessWidget { const ThemeSetting({ super.key, }); @override Widget build(BuildContext context) { final themeMode = context.watch().state.themeMode; return MobileSettingItem( name: LocaleKeys.settings_appearance_themeMode_label.tr(), trailing: MobileSettingTrailing( text: themeMode.labelText, ), onTap: () { showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showDivider: false, title: LocaleKeys.settings_appearance_themeMode_label.tr(), builder: (context) { final themeMode = context.read().state.themeMode; return Column( children: [ FlowyOptionTile.checkbox( text: LocaleKeys.settings_appearance_themeMode_system.tr(), leftIcon: const FlowySvg( FlowySvgs.m_theme_mode_system_s, ), isSelected: themeMode == ThemeMode.system, onTap: () { context .read() .setThemeMode(ThemeMode.system); Navigator.pop(context); }, ), FlowyOptionTile.checkbox( showTopBorder: false, text: LocaleKeys.settings_appearance_themeMode_light.tr(), leftIcon: const FlowySvg( FlowySvgs.m_theme_mode_light_s, ), isSelected: themeMode == ThemeMode.light, onTap: () { context .read() .setThemeMode(ThemeMode.light); Navigator.pop(context); }, ), FlowyOptionTile.checkbox( showTopBorder: false, text: LocaleKeys.settings_appearance_themeMode_dark.tr(), leftIcon: const FlowySvg( FlowySvgs.m_theme_mode_dark_s, ), isSelected: themeMode == ThemeMode.dark, onTap: () { context .read() .setThemeMode(ThemeMode.dark); Navigator.pop(context); }, ), ], ); }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class AppFlowyCloudPage extends StatelessWidget { const AppFlowyCloudPage({super.key}); static const routeName = '/AppFlowyCloudPage'; @override Widget build(BuildContext context) { return Scaffold( appBar: FlowyAppBar( titleText: LocaleKeys.settings_menu_cloudSettings.tr(), ), body: SettingCloud( restartAppFlowy: () async { await getIt().signOut(); await runAppFlowy(); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/cloud_setting_group.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class CloudSettingGroup extends StatelessWidget { const CloudSettingGroup({ super.key, }); @override Widget build(BuildContext context) { return FutureBuilder( future: getAuthenticatorType(), builder: (context, snapshot) { final cloudType = snapshot.data ?? AuthenticatorType.appflowyCloud; final name = titleFromCloudType(cloudType); return MobileSettingGroup( groupTitle: 'Cloud settings', settingItemList: [ MobileSettingItem( name: 'Cloud server', trailing: MobileSettingTrailing( text: name, ), onTap: () => context.push(AppFlowyCloudPage.routeName), ), ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/util/font_family_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; final List _availableFonts = [ defaultFontFamily, ...GoogleFonts.asMap().keys, ]; class FontPickerScreen extends StatelessWidget { const FontPickerScreen({super.key}); static const routeName = '/font_picker'; @override Widget build(BuildContext context) { return const LanguagePickerPage(); } } class LanguagePickerPage extends StatefulWidget { const LanguagePickerPage({super.key}); @override State createState() => _LanguagePickerPageState(); } class _LanguagePickerPageState extends State { late List availableFonts; @override void initState() { super.initState(); availableFonts = _availableFonts; } @override Widget build(BuildContext context) { final selectedFontFamilyName = context.watch().state.font; return Scaffold( appBar: FlowyAppBar( titleText: LocaleKeys.titleBar_font.tr(), ), body: SafeArea( child: Scrollbar( child: FontSelector( selectedFontFamilyName: selectedFontFamilyName, onFontFamilySelected: (fontFamilyName) => context.pop(fontFamilyName), ), ), ), ); } } class FontSelector extends StatefulWidget { const FontSelector({ super.key, this.scrollController, required this.selectedFontFamilyName, required this.onFontFamilySelected, }); final ScrollController? scrollController; final String selectedFontFamilyName; final void Function(String fontFamilyName) onFontFamilySelected; @override State createState() => _FontSelectorState(); } class _FontSelectorState extends State { late List availableFonts; @override void initState() { super.initState(); availableFonts = _availableFonts; } @override Widget build(BuildContext context) { return ListView.builder( controller: widget.scrollController, itemCount: availableFonts.length + 1, // with search bar itemBuilder: (context, index) { if (index == 0) { // search bar return _buildSearchBar(context); } final fontFamilyName = availableFonts[index - 1]; final usingDefaultFontFamily = fontFamilyName == defaultFontFamily; final fontFamily = !usingDefaultFontFamily ? getGoogleFontSafely(fontFamilyName).fontFamily : defaultFontFamily; return FlowyOptionTile.checkbox( text: fontFamilyName.fontFamilyDisplayName, isSelected: widget.selectedFontFamilyName == fontFamilyName, showTopBorder: false, onTap: () => widget.onFontFamilySelected(fontFamilyName), fontFamily: fontFamily, backgroundColor: Colors.transparent, ); }, ); } Widget _buildSearchBar(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 12.0, ), child: FlowyMobileSearchTextField( onChanged: (keyword) { setState(() { availableFonts = _availableFonts .where( (font) => font.isEmpty || // keep the default one always font .parseFontFamilyName() .toLowerCase() .contains(keyword.toLowerCase()), ) .toList(); }); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/util/font_family_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../setting.dart'; class FontSetting extends StatelessWidget { const FontSetting({ super.key, }); @override Widget build(BuildContext context) { final selectedFont = context.watch().state.font; final name = selectedFont.fontFamilyDisplayName; return MobileSettingItem( name: LocaleKeys.settings_appearance_fontFamily_label.tr(), trailing: MobileSettingTrailing( text: name, ), onTap: () async { final newFont = await context.push(FontPickerScreen.routeName); if (newFont != null && newFont != selectedFont) { if (context.mounted) { context.read().setFontFamily(newFont); unawaited( context.read().syncFontFamily(newFont), ); } } }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/language/language_picker_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/language.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class LanguagePickerScreen extends StatelessWidget { const LanguagePickerScreen({super.key}); static const routeName = '/language_picker'; @override Widget build(BuildContext context) => const LanguagePickerPage(); } class LanguagePickerPage extends StatefulWidget { const LanguagePickerPage({ super.key, }); @override State createState() => _LanguagePickerPageState(); } class _LanguagePickerPageState extends State { @override Widget build(BuildContext context) { final supportedLocales = EasyLocalization.of(context)!.supportedLocales; return Scaffold( appBar: FlowyAppBar( titleText: LocaleKeys.titleBar_language.tr(), ), body: SafeArea( child: ListView.builder( itemBuilder: (context, index) { final locale = supportedLocales[index]; return FlowyOptionTile.checkbox( text: languageFromLocale(locale), isSelected: EasyLocalization.of(context)!.locale == locale, showTopBorder: false, onTap: () => context.pop(locale), backgroundColor: Colors.transparent, ); }, itemCount: supportedLocales.length, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/language.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'setting.dart'; class LanguageSettingGroup extends StatefulWidget { const LanguageSettingGroup({ super.key, }); @override State createState() => _LanguageSettingGroupState(); } class _LanguageSettingGroupState extends State { @override Widget build(BuildContext context) { return BlocSelector( selector: (state) { return state.locale; }, builder: (context, locale) { return MobileSettingGroup( groupTitle: LocaleKeys.settings_menu_language.tr(), settingItemList: [ MobileSettingItem( name: LocaleKeys.settings_menu_language.tr(), trailing: MobileSettingTrailing( text: languageFromLocale(locale), ), onTap: () async { final newLocale = await context.push(LanguagePickerScreen.routeName); if (newLocale != null && newLocale != locale) { if (context.mounted) { context .read() .setLocale(context, newLocale); } } }, ), ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/launch_settings_page.dart ================================================ import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/self_host_setting_group.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileLaunchSettingsPage extends StatelessWidget { const MobileLaunchSettingsPage({ super.key, }); static const routeName = '/launch_settings'; @override Widget build(BuildContext context) { context.watch(); return Scaffold( appBar: FlowyAppBar( titleText: LocaleKeys.settings_title.tr(), ), body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ const LanguageSettingGroup(), if (Env.enableCustomCloud) const SelfHostSettingGroup(), const SupportSettingGroup(), ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/notifications_setting_group.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'widgets/widgets.dart'; class NotificationsSettingGroup extends StatefulWidget { const NotificationsSettingGroup({super.key}); @override State createState() => _NotificationsSettingGroupState(); } class _NotificationsSettingGroupState extends State { bool isPushNotificationOn = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); return MobileSettingGroup( groupTitle: LocaleKeys.notificationHub_title.tr(), settingItemList: [ MobileSettingItem( name: LocaleKeys.settings_mobile_pushNotifications.tr(), trailing: Switch.adaptive( activeColor: theme.colorScheme.primary, value: isPushNotificationOn, onChanged: (bool value) { setState(() { isPushNotificationOn = value; }); }, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/edit_username_bottom_sheet.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class EditUsernameBottomSheet extends StatefulWidget { const EditUsernameBottomSheet( this.context, { this.userName, required this.onSubmitted, super.key, }); final BuildContext context; final String? userName; final void Function(String) onSubmitted; @override State createState() => _EditUsernameBottomSheetState(); } class _EditUsernameBottomSheetState extends State { late TextEditingController _textFieldController; final _formKey = GlobalKey(); @override void initState() { super.initState(); _textFieldController = TextEditingController(text: widget.userName); } @override void dispose() { _textFieldController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { void submitUserName() { if (_formKey.currentState!.validate()) { final value = _textFieldController.text; widget.onSubmitted.call(value); widget.context.pop(); } } return Column( mainAxisSize: MainAxisSize.min, children: [ Form( key: _formKey, child: TextFormField( controller: _textFieldController, keyboardType: TextInputType.text, validator: (value) { if (value == null || value.isEmpty) { return LocaleKeys.settings_mobile_usernameEmptyError.tr(); } return null; }, onEditingComplete: submitUserName, ), ), const VSpace(16), AFFilledTextButton.primary( text: LocaleKeys.button_update.tr(), onTap: submitUserName, size: AFButtonSize.l, alignment: Alignment.center, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info.dart ================================================ export 'edit_username_bottom_sheet.dart'; export 'personal_info_setting_group.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/password/password_bloc.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/change_password.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/setup_password.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../widgets/widgets.dart'; import 'personal_info.dart'; class PersonalInfoSettingGroup extends StatelessWidget { const PersonalInfoSettingGroup({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (context) => getIt( param1: userProfile, )..add(const SettingsUserEvent.initial()), ), BlocProvider( create: (context) => PasswordBloc(userProfile) ..add(PasswordEvent.init()) ..add(PasswordEvent.checkHasPassword()), ), ], child: BlocSelector( selector: (state) => state.userProfile.name, builder: (context, userName) { return MobileSettingGroup( groupTitle: LocaleKeys.settings_accountPage_title.tr(), settingItemList: [ MobileSettingItem( name: 'User name', trailing: MobileSettingTrailing( text: userName, ), onTap: () { showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.settings_mobile_username.tr(), showCloseButton: true, showDragHandle: true, showDivider: false, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (_) { return EditUsernameBottomSheet( context, userName: userName, onSubmitted: (value) => context .read() .add(SettingsUserEvent.updateUserName(name: value)), ); }, ); }, ), ...userProfile.userAuthType == AuthTypePB.Server ? [ _buildEmailItem(context, userProfile), _buildPasswordItem(context, userProfile), ] : [ _buildLoginItem(context, userProfile), ], ], ); }, ), ); } Widget _buildEmailItem(BuildContext context, UserProfilePB userProfile) { final theme = AppFlowyTheme.of(context); return MobileSettingItem( name: LocaleKeys.settings_accountPage_email_title.tr(), trailing: Text( userProfile.email, style: theme.textStyle.heading4.standard( color: theme.textColorScheme.secondary, ), ), ); } Widget _buildPasswordItem(BuildContext context, UserProfilePB userProfile) { return BlocBuilder( builder: (context, state) { final hasPassword = state.hasPassword; final title = hasPassword ? LocaleKeys.newSettings_myAccount_password_changePassword.tr() : LocaleKeys.newSettings_myAccount_password_setupPassword.tr(); final passwordBloc = context.read(); return MobileSettingItem( name: LocaleKeys.newSettings_myAccount_password_title.tr(), trailing: MobileSettingTrailing( text: '', ), onTap: () { showMobileBottomSheet( context, showHeader: true, title: title, showCloseButton: true, showDragHandle: true, showDivider: false, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (_) { Widget child; if (hasPassword) { child = ChangePasswordDialogContent( userProfile: userProfile, showTitle: false, showCloseAndSaveButton: false, showSaveButton: true, padding: EdgeInsets.zero, ); } else { child = SetupPasswordDialogContent( userProfile: userProfile, showTitle: false, showCloseAndSaveButton: false, showSaveButton: true, padding: EdgeInsets.zero, ); } return BlocProvider.value( value: passwordBloc, child: child, ); }, ); }, ); }, ); } Widget _buildLoginItem(BuildContext context, UserProfilePB userProfile) { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( LocaleKeys.signIn_youAreInLocalMode.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ), ), VSpace(theme.spacing.m), AFOutlinedTextButton.normal( text: LocaleKeys.signIn_loginToAppFlowyCloud.tr(), size: AFButtonSize.l, alignment: Alignment.center, onTap: () async { // logout and restart the app await getIt().signOut(); await runAppFlowy(); }, ), VSpace(theme.spacing.m), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum SelfHostUrlBottomSheetType { shareDomain, cloudURL, } class SelfHostUrlBottomSheet extends StatefulWidget { const SelfHostUrlBottomSheet({ super.key, required this.url, required this.type, }); final String url; final SelfHostUrlBottomSheetType type; @override State createState() => _SelfHostUrlBottomSheetState(); } class _SelfHostUrlBottomSheetState extends State { final TextEditingController _textFieldController = TextEditingController(); final _formKey = GlobalKey(); @override void initState() { super.initState(); _textFieldController.text = widget.url; } @override void dispose() { _textFieldController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ Form( key: _formKey, child: TextFormField( controller: _textFieldController, keyboardType: TextInputType.text, validator: (value) { if (value == null || value.isEmpty || validateUrl(value).isFailure) { return LocaleKeys.settings_menu_invalidCloudURLScheme.tr(); } return null; }, onEditingComplete: _saveSelfHostUrl, ), ), const SizedBox( height: 16, ), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _saveSelfHostUrl, child: Text(LocaleKeys.settings_menu_restartApp.tr()), ), ), ], ); } void _saveSelfHostUrl() { if (_formKey.currentState!.validate()) { final value = _textFieldController.text; if (value.isNotEmpty) { validateUrl(value).fold( (url) async { switch (widget.type) { case SelfHostUrlBottomSheetType.shareDomain: await useBaseWebDomain(url); case SelfHostUrlBottomSheetType.cloudURL: await useSelfHostedAppFlowyCloud(url); } await runAppFlowy(); }, (err) => Log.error(err), ); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host_setting_group.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'setting.dart'; class SelfHostSettingGroup extends StatefulWidget { const SelfHostSettingGroup({ super.key, }); @override State createState() => _SelfHostSettingGroupState(); } class _SelfHostSettingGroupState extends State { final future = Future.wait([ getAppFlowyCloudUrl(), getAppFlowyShareDomain(), ]); @override Widget build(BuildContext context) { return FutureBuilder( future: future, builder: (context, snapshot) { final data = snapshot.data; if (!snapshot.hasData || data == null || data.length != 2) { return const SizedBox.shrink(); } final url = data[0]; final shareDomain = data[1]; return MobileSettingGroup( groupTitle: LocaleKeys.settings_menu_cloudAppFlowy.tr(), settingItemList: [ _buildSelfHostField(url), _buildShareDomainField(shareDomain), ], ); }, ); } Widget _buildSelfHostField(String url) { return MobileSettingItem( title: Padding( padding: const EdgeInsets.only(bottom: 4.0), child: FlowyText( LocaleKeys.settings_menu_cloudURL.tr(), fontSize: 12.0, color: Theme.of(context).hintColor, ), ), subtitle: FlowyText( url, ), trailing: const Icon( Icons.chevron_right, ), onTap: () { showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.editor_urlHint.tr(), showCloseButton: true, showDivider: false, padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0, ), builder: (_) { return SelfHostUrlBottomSheet( url: url, type: SelfHostUrlBottomSheetType.cloudURL, ); }, ); }, ); } Widget _buildShareDomainField(String shareDomain) { return MobileSettingItem( title: Padding( padding: const EdgeInsets.only(bottom: 4.0), child: FlowyText( LocaleKeys.settings_menu_webURL.tr(), fontSize: 12.0, color: Theme.of(context).hintColor, ), ), subtitle: FlowyText( shareDomain, ), trailing: const Icon( Icons.chevron_right, ), onTap: () { showMobileBottomSheet( context, showHeader: true, title: LocaleKeys.editor_urlHint.tr(), showCloseButton: true, showDivider: false, padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0, ), builder: (_) { return SelfHostUrlBottomSheet( url: shareDomain, type: SelfHostUrlBottomSheetType.shareDomain, ); }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/setting.dart ================================================ export 'about/about.dart'; export 'appearance/appearance_setting_group.dart'; export 'font/font_setting.dart'; export 'language_setting_group.dart'; export 'notifications_setting_group.dart'; export 'personal_info/personal_info.dart'; export 'support_setting_group.dart'; export 'widgets/widgets.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart ================================================ import 'dart:io'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/shared/appflowy_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/share_log_files.dart'; import 'package:appflowy/workspace/presentation/settings/pages/fix_data_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'widgets/widgets.dart'; class SupportSettingGroup extends StatelessWidget { const SupportSettingGroup({ super.key, }); @override Widget build(BuildContext context) { return FutureBuilder( future: PackageInfo.fromPlatform(), builder: (context, snapshot) => MobileSettingGroup( groupTitle: LocaleKeys.settings_mobile_support.tr(), settingItemList: [ MobileSettingItem( name: LocaleKeys.settings_mobile_joinDiscord.tr(), trailing: MobileSettingTrailing( text: '', ), onTap: () => afLaunchUrlString('https://discord.gg/JucBXeU2FE'), ), MobileSettingItem( name: LocaleKeys.workspace_errorActions_reportIssue.tr(), trailing: MobileSettingTrailing( text: '', ), onTap: () { showMobileBottomSheet( context, showDragHandle: true, showHeader: true, title: LocaleKeys.workspace_errorActions_reportIssue.tr(), backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) { return _ReportIssuesWidget( version: snapshot.data?.version ?? '', ); }, ); }, ), MobileSettingItem( name: LocaleKeys.settings_files_clearCache.tr(), trailing: MobileSettingTrailing( text: '', ), onTap: () async { await showFlowyMobileConfirmDialog( context, title: FlowyText( LocaleKeys.settings_files_areYouSureToClearCache.tr(), maxLines: 2, ), content: FlowyText( LocaleKeys.settings_files_clearCacheDesc.tr(), fontSize: 12, maxLines: 4, ), actionButtonTitle: LocaleKeys.button_yes.tr(), onActionButtonPressed: () async { await getIt().clearAllCache(); // check the workspace and space health await WorkspaceDataManager.checkViewHealth( dryRun: false, ); if (context.mounted) { showToastNotification( message: LocaleKeys.settings_files_clearCacheSuccess.tr(), ); } }, ); }, ), ], ), ); } } class _ReportIssuesWidget extends StatelessWidget { const _ReportIssuesWidget({ required this.version, }); final String version; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.workspace_errorActions_reportIssueOnGithub.tr(), onTap: () { final String os = Platform.operatingSystem; afLaunchUrlString( 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Mobile:%20&version=$version&os=$os', ); }, ), FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.workspace_errorActions_exportLogFiles.tr(), onTap: () => shareLogFiles(context), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class UserSessionSettingGroup extends StatelessWidget { const UserSessionSettingGroup({ super.key, required this.userProfile, required this.showThirdPartyLogin, }); final UserProfilePB userProfile; final bool showThirdPartyLogin; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( children: [ // third party sign in buttons if (showThirdPartyLogin) _buildThirdPartySignInButtons(context), VSpace(theme.spacing.xxl), // logout button MobileLogoutButton( text: LocaleKeys.settings_menu_logout.tr(), onPressed: () async => _showLogoutDialog(), ), // delete account button // only show the delete account button in cloud mode if (userProfile.userAuthType == AuthTypePB.Server) ...[ VSpace(theme.spacing.xxl), AFOutlinedTextButton.destructive( alignment: Alignment.center, text: LocaleKeys.button_deleteAccount.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.error, ), onTap: () => _showDeleteAccountDialog(context), size: AFButtonSize.l, ), ], VSpace(theme.spacing.xxl), ], ); } Widget _buildThirdPartySignInButtons(BuildContext context) { return BlocProvider( create: (context) => getIt(), child: BlocConsumer( listener: (context, state) { state.successOrFail?.fold( (result) => runAppFlowy(), (e) => Log.error(e), ); }, builder: (context, state) { return Column( children: [ const ContinueWithEmailAndPassword(), const VSpace(12.0), const ThirdPartySignInButtons( expanded: true, ), const VSpace(16.0), ], ); }, ), ); } Future _showDeleteAccountDialog(BuildContext context) async { return showMobileBottomSheet( context, useRootNavigator: true, backgroundColor: Theme.of(context).colorScheme.surface, builder: (_) => const _DeleteAccountBottomSheet(), ); } Future _showLogoutDialog() async { return showFlowyCupertinoConfirmDialog( title: LocaleKeys.settings_menu_logoutPrompt.tr(), leftButton: FlowyText( LocaleKeys.button_cancel.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w500, color: const Color(0xFF007AFF), ), rightButton: FlowyText( LocaleKeys.button_logout.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w400, color: const Color(0xFFFE0220), ), onRightButtonPressed: (context) async { Navigator.of(context).pop(); await getIt().signOut(); await runAppFlowy(); }, ); } } class _DeleteAccountBottomSheet extends StatefulWidget { const _DeleteAccountBottomSheet(); @override State<_DeleteAccountBottomSheet> createState() => _DeleteAccountBottomSheetState(); } class _DeleteAccountBottomSheetState extends State<_DeleteAccountBottomSheet> { final controller = TextEditingController(); final isChecked = ValueNotifier(false); @override void dispose() { controller.dispose(); isChecked.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ const VSpace(18.0), const FlowySvg( FlowySvgs.icon_warning_xl, blendMode: null, ), const VSpace(12.0), FlowyText( LocaleKeys.newSettings_myAccount_deleteAccount_title.tr(), fontSize: 20.0, fontWeight: FontWeight.w500, ), const VSpace(12.0), FlowyText( LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint1.tr(), fontSize: 14.0, fontWeight: FontWeight.w400, maxLines: 10, ), const VSpace(18.0), SizedBox( height: 36.0, child: FlowyTextField( controller: controller, textStyle: const TextStyle(fontSize: 14.0), hintStyle: const TextStyle(fontSize: 14.0), hintText: LocaleKeys .newSettings_myAccount_deleteAccount_confirmHint3 .tr(), ), ), const VSpace(18.0), _buildCheckbox(), const VSpace(18.0), MobileLogoutButton( text: LocaleKeys.button_deleteAccount.tr(), textColor: Theme.of(context).colorScheme.error, onPressed: () => deleteMyAccount( context, controller.text.trim(), isChecked.value, ), ), const VSpace(12.0), MobileLogoutButton( text: LocaleKeys.button_cancel.tr(), onPressed: () => Navigator.of(context).pop(), ), const VSpace(36.0), ], ), ); } Widget _buildCheckbox() { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => isChecked.value = !isChecked.value, child: ValueListenableBuilder( valueListenable: isChecked, builder: (context, isChecked, _) { return Padding( padding: const EdgeInsets.all(1.0), child: FlowySvg( isChecked ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, size: const Size.square(16.0), blendMode: isChecked ? null : BlendMode.srcIn, ), ); }, ), ), const HSpace(6.0), Expanded( child: FlowyText.regular( LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint2.tr(), fontSize: 14.0, figmaLineHeight: 18.0, maxLines: 3, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileSettingGroup extends StatelessWidget { const MobileSettingGroup({ required this.groupTitle, required this.settingItemList, this.showDivider = true, super.key, }); final String groupTitle; final List settingItemList; final bool showDivider; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ VSpace(theme.spacing.s), Text( groupTitle, style: theme.textStyle.heading4.enhanced( color: theme.textColorScheme.primary, ), ), VSpace(theme.spacing.s), ...settingItemList, showDivider ? AFDivider(spacing: theme.spacing.m) : const SizedBox.shrink(), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileSettingItem extends StatelessWidget { const MobileSettingItem({ super.key, this.name, this.padding = const EdgeInsets.only(bottom: 4), this.trailing, this.leadingIcon, this.title, this.subtitle, this.onTap, }); final String? name; final EdgeInsets padding; final Widget? trailing; final Widget? leadingIcon; final Widget? subtitle; final VoidCallback? onTap; final Widget? title; @override Widget build(BuildContext context) { return Padding( padding: padding, child: ListTile( title: title ?? _buildDefaultTitle(context, name), subtitle: subtitle, trailing: trailing, onTap: onTap, visualDensity: VisualDensity.compact, contentPadding: EdgeInsets.zero, ), ); } Widget _buildDefaultTitle(BuildContext context, String? name) { final theme = AppFlowyTheme.of(context); return Row( children: [ if (leadingIcon != null) ...[ leadingIcon!, const HSpace(8), ], Expanded( child: Text( name ?? '', style: theme.textStyle.heading4.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_trailing.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class MobileSettingTrailing extends StatelessWidget { const MobileSettingTrailing({ super.key, required this.text, this.showArrow = true, }); final String text; final bool showArrow; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( text, style: theme.textStyle.heading4.standard( color: theme.textColorScheme.secondary, ), overflow: TextOverflow.ellipsis, ), ), if (showArrow) ...[ const HSpace(8), FlowySvg( FlowySvgs.toolbar_arrow_right_m, size: Size.square(24), color: theme.iconColorScheme.tertiary, ), ], ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/widgets.dart ================================================ export 'mobile_setting_group_widget.dart'; export 'mobile_setting_item_widget.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/add_members_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_email.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'member_list.dart'; class AddMembersScreen extends StatelessWidget { const AddMembersScreen({ super.key, }); static const routeName = '/add_member'; @override Widget build(BuildContext context) { return Scaffold( appBar: FlowyAppBar( titleText: 'Add members', ), body: const _InviteMemberPage(), resizeToAvoidBottomInset: false, ); } } class _InviteMemberPage extends StatefulWidget { const _InviteMemberPage(); @override State<_InviteMemberPage> createState() => _InviteMemberPageState(); } class _InviteMemberPageState extends State<_InviteMemberPage> { final emailController = TextEditingController(); late final Future userProfile; bool exceededLimit = false; @override void initState() { super.initState(); userProfile = UserBackendService.getCurrentUserProfile().fold( (s) => s, (f) => null, ); } @override void dispose() { emailController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return FutureBuilder( future: userProfile, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const SizedBox.shrink(); } if (snapshot.hasError || snapshot.data == null) { return _buildError(context); } final userProfile = snapshot.data!; return BlocProvider( create: (context) => WorkspaceMemberBloc(userProfile: userProfile) ..add(const WorkspaceMemberEvent.initial()) ..add(const WorkspaceMemberEvent.getInviteCode()), child: BlocConsumer( listener: _onListener, builder: (context, state) { return Column( children: [ if (state.myRole.isOwner) ...[ Container( width: double.infinity, padding: EdgeInsets.all(theme.spacing.xl), child: const MInviteMemberByEmail(), ), VSpace(theme.spacing.m), ], if (state.members.isNotEmpty) ...[ const AFDivider(), VSpace(theme.spacing.xl), MobileMemberList( members: state.members, userProfile: userProfile, myRole: state.myRole, ), ], if (state.myRole.isMember) ...[ Spacer(), const _LeaveWorkspaceButton(), ], const VSpace(48), ], ); }, ), ); }, ); } Widget _buildError(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 48.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyText.medium( LocaleKeys.settings_appearance_members_workspaceMembersError.tr(), fontSize: 18.0, textAlign: TextAlign.center, ), const VSpace(8.0), FlowyText.regular( LocaleKeys .settings_appearance_members_workspaceMembersErrorDescription .tr(), fontSize: 17.0, maxLines: 10, textAlign: TextAlign.center, lineHeight: 1.3, color: Theme.of(context).hintColor, ), ], ), ), ); } void _onListener(BuildContext context, WorkspaceMemberState state) { final actionResult = state.actionResult; if (actionResult == null) { return; } final actionType = actionResult.actionType; final result = actionResult.result; // only show the result dialog when the action is WorkspaceMemberActionType.add if (actionType == WorkspaceMemberActionType.addByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), ); }, (f) { Log.error('add workspace member failed: $f'); final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded ? LocaleKeys .settings_appearance_members_inviteFailedMemberLimitMobile .tr() : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); setState(() { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( type: ToastificationType.error, message: message, ); }, ); } else if (actionType == WorkspaceMemberActionType.inviteByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), ); }, (f) { Log.error('invite workspace member failed: $f'); final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded ? LocaleKeys .settings_appearance_members_inviteFailedMemberLimitMobile .tr() : LocaleKeys.settings_appearance_members_failedToInviteMember .tr(); setState(() { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( type: ToastificationType.error, message: message, ); }, ); } else if (actionType == WorkspaceMemberActionType.removeByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys .settings_appearance_members_removeFromWorkspaceSuccess .tr(), ); }, (f) { showToastNotification( type: ToastificationType.error, message: LocaleKeys .settings_appearance_members_removeFromWorkspaceFailed .tr(), ); }, ); } else if (actionType == WorkspaceMemberActionType.generateInviteLink) { result.fold( (s) async { showToastNotification( message: LocaleKeys .settings_appearance_members_generatedLinkSuccessfully .tr(), ); // copy the invite link to the clipboard final inviteLink = state.inviteLink; if (inviteLink != null) { await getIt().setPlainText(inviteLink); showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } }, (f) { Log.error('generate invite link failed: $f'); showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_generatedLinkFailed.tr(), ); }, ); } else if (actionType == WorkspaceMemberActionType.resetInviteLink) { result.fold( (s) async { showToastNotification( message: LocaleKeys .settings_appearance_members_resetLinkSuccessfully .tr(), ); // copy the invite link to the clipboard final inviteLink = state.inviteLink; if (inviteLink != null) { await getIt().setPlainText(inviteLink); showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } }, (f) { Log.error('generate invite link failed: $f'); showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_resetLinkFailed.tr(), ); }, ); } } } class _LeaveWorkspaceButton extends StatelessWidget { const _LeaveWorkspaceButton(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: AFOutlinedTextButton.destructive( alignment: Alignment.center, text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(), onTap: () => _leaveWorkspace(context), size: AFButtonSize.l, ), ); } void _leaveWorkspace(BuildContext context) { showFlowyCupertinoConfirmDialog( title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), leftButton: FlowyText( LocaleKeys.button_cancel.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w500, color: const Color(0xFF007AFF), ), rightButton: FlowyText( LocaleKeys.button_confirm.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w400, color: const Color(0xFFFE0220), ), onRightButtonPressed: (buttonContext) async {}, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_member_by_link.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; import 'member_list.dart'; ValueNotifier mobileLeaveWorkspaceNotifier = ValueNotifier(0); class InviteMembersScreen extends StatelessWidget { const InviteMembersScreen({ super.key, }); static const routeName = '/invite_member'; @override Widget build(BuildContext context) { return Scaffold( appBar: FlowyAppBar( titleText: LocaleKeys.settings_appearance_members_label.tr(), ), body: const _InviteMemberPage(), resizeToAvoidBottomInset: false, ); } } class _InviteMemberPage extends StatefulWidget { const _InviteMemberPage(); @override State<_InviteMemberPage> createState() => _InviteMemberPageState(); } class _InviteMemberPageState extends State<_InviteMemberPage> { final emailController = TextEditingController(); late final Future userProfile; bool exceededLimit = false; @override void initState() { super.initState(); userProfile = UserBackendService.getCurrentUserProfile().fold( (s) => s, (f) => null, ); } @override void dispose() { emailController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return FutureBuilder( future: userProfile, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const SizedBox.shrink(); } if (snapshot.hasError || snapshot.data == null) { return _buildError(context); } final userProfile = snapshot.data!; return BlocProvider( create: (context) => WorkspaceMemberBloc(userProfile: userProfile) ..add(const WorkspaceMemberEvent.initial()) ..add(const WorkspaceMemberEvent.getInviteCode()), child: BlocConsumer( listener: _onListener, builder: (context, state) { return Column( children: [ Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (state.myRole.isOwner) ...[ Padding( padding: EdgeInsets.all(theme.spacing.xl), child: _buildInviteMemberArea(context), ), const VSpace(16), ], if (state.members.isNotEmpty) ...[ const AFDivider(), VSpace(theme.spacing.xl), MobileMemberList( members: state.members, userProfile: userProfile, myRole: state.myRole, ), ], ], ), ), if (state.myRole.isMember) const _LeaveWorkspaceButton(), const VSpace(48), ], ); }, ), ); }, ); } Widget _buildInviteMemberArea(BuildContext context) { return Column( children: [ TextFormField( autofocus: true, controller: emailController, keyboardType: TextInputType.text, decoration: InputDecoration( hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(), ), ), const VSpace(16), if (exceededLimit) ...[ FlowyText.regular( LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile .tr(), fontSize: 14.0, maxLines: 3, color: Theme.of(context).colorScheme.error, ), const VSpace(16), ], SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () => _inviteMember(context), child: Text( LocaleKeys.settings_appearance_members_sendInvite.tr(), ), ), ), ], ); } Widget _buildError(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 48.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyText.medium( LocaleKeys.settings_appearance_members_workspaceMembersError.tr(), fontSize: 18.0, textAlign: TextAlign.center, ), const VSpace(8.0), FlowyText.regular( LocaleKeys .settings_appearance_members_workspaceMembersErrorDescription .tr(), fontSize: 17.0, maxLines: 10, textAlign: TextAlign.center, lineHeight: 1.3, color: Theme.of(context).hintColor, ), ], ), ), ); } void _onListener(BuildContext context, WorkspaceMemberState state) { final actionResult = state.actionResult; if (actionResult == null) { return; } final actionType = actionResult.actionType; final result = actionResult.result; // get keyboard height final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; // only show the result dialog when the action is WorkspaceMemberActionType.add if (actionType == WorkspaceMemberActionType.addByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), bottomPadding: keyboardHeight, ); }, (f) { Log.error('add workspace member failed: $f'); final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded ? LocaleKeys .settings_appearance_members_inviteFailedMemberLimitMobile .tr() : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); setState(() { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( type: ToastificationType.error, bottomPadding: keyboardHeight, message: message, ); }, ); } else if (actionType == WorkspaceMemberActionType.inviteByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), bottomPadding: keyboardHeight, ); }, (f) { Log.error('invite workspace member failed: $f'); final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded ? LocaleKeys .settings_appearance_members_inviteFailedMemberLimitMobile .tr() : LocaleKeys.settings_appearance_members_failedToInviteMember .tr(); setState(() { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( type: ToastificationType.error, message: message, bottomPadding: keyboardHeight, ); }, ); } else if (actionType == WorkspaceMemberActionType.removeByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys .settings_appearance_members_removeFromWorkspaceSuccess .tr(), bottomPadding: keyboardHeight, ); }, (f) { showToastNotification( type: ToastificationType.error, message: LocaleKeys .settings_appearance_members_removeFromWorkspaceFailed .tr(), bottomPadding: keyboardHeight, ); }, ); } } void _inviteMember(BuildContext context) { final email = emailController.text; if (!isEmail(email)) { showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), ); return; } context .read() .add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email)); // clear the email field after inviting emailController.clear(); } } class _LeaveWorkspaceButton extends StatelessWidget { const _LeaveWorkspaceButton(); @override Widget build(BuildContext context) { return Container( width: double.infinity, margin: const EdgeInsets.symmetric(horizontal: 16), child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.transparent, foregroundColor: Theme.of(context).colorScheme.error, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), side: BorderSide( color: Theme.of(context).colorScheme.error, width: 0.5, ), ), ), onPressed: () => _leaveWorkspace(context), child: FlowyText( LocaleKeys.workspace_leaveCurrentWorkspace.tr(), fontSize: 14.0, color: Theme.of(context).colorScheme.error, fontWeight: FontWeight.w500, ), ), ); } void _leaveWorkspace(BuildContext context) { showFlowyCupertinoConfirmDialog( title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), leftButton: FlowyText( LocaleKeys.button_cancel.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w500, color: const Color(0xFF007AFF), ), rightButton: FlowyText( LocaleKeys.button_confirm.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w400, color: const Color(0xFFFE0220), ), onRightButtonPressed: (buttonContext) async { // try to use popUntil with a specific route name but failed // so use pop twice as a workaround Navigator.of(buttonContext).pop(); Navigator.of(context).pop(); Navigator.of(context).pop(); mobileLeaveWorkspaceNotifier.value = mobileLeaveWorkspaceNotifier.value + 1; }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/setting/workspace/add_members_screen.dart'; import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_link.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'member_list.dart'; ValueNotifier mobileLeaveWorkspaceNotifier = ValueNotifier(0); class InviteMembersScreen extends StatelessWidget { const InviteMembersScreen({ super.key, }); static const routeName = '/invite_member'; @override Widget build(BuildContext context) { return Scaffold( appBar: FlowyAppBar( titleText: LocaleKeys.settings_appearance_members_label.tr(), actions: [ _buildAddMemberButton(context), ], ), body: const _InviteMemberPage(), resizeToAvoidBottomInset: false, ); } Widget _buildAddMemberButton(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 20), child: GestureDetector( onTap: () { context.push(AddMembersScreen.routeName); }, child: FlowySvg(FlowySvgs.add_thin_s), ), ); } } class _InviteMemberPage extends StatefulWidget { const _InviteMemberPage(); @override State<_InviteMemberPage> createState() => _InviteMemberPageState(); } class _InviteMemberPageState extends State<_InviteMemberPage> { final emailController = TextEditingController(); late final Future userProfile; bool exceededLimit = false; @override void initState() { super.initState(); userProfile = UserBackendService.getCurrentUserProfile().fold( (s) => s, (f) => null, ); } @override void dispose() { emailController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return FutureBuilder( future: userProfile, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const SizedBox.shrink(); } if (snapshot.hasError || snapshot.data == null) { return _buildError(context); } final userProfile = snapshot.data!; return BlocProvider( create: (context) => WorkspaceMemberBloc(userProfile: userProfile) ..add(const WorkspaceMemberEvent.initial()) ..add(const WorkspaceMemberEvent.getInviteCode()), child: BlocConsumer( listener: _onListener, builder: (context, state) { return SingleChildScrollView( child: Column( children: [ if (state.myRole.isOwner) ...[ Container( width: double.infinity, padding: EdgeInsets.all(theme.spacing.xl), child: const MInviteMemberByLink(), ), VSpace(theme.spacing.m), ], if (state.members.isNotEmpty) ...[ const AFDivider(), VSpace(theme.spacing.xl), MobileMemberList( members: state.members, userProfile: userProfile, myRole: state.myRole, ), ], if (state.myRole.isMember) ...[ Spacer(), const _LeaveWorkspaceButton(), ], const VSpace(48), ], ), ); }, ), ); }, ); } Widget _buildError(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 48.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyText.medium( LocaleKeys.settings_appearance_members_workspaceMembersError.tr(), fontSize: 18.0, textAlign: TextAlign.center, ), const VSpace(8.0), FlowyText.regular( LocaleKeys .settings_appearance_members_workspaceMembersErrorDescription .tr(), fontSize: 17.0, maxLines: 10, textAlign: TextAlign.center, lineHeight: 1.3, color: Theme.of(context).hintColor, ), ], ), ), ); } void _onListener(BuildContext context, WorkspaceMemberState state) { final actionResult = state.actionResult; if (actionResult == null) { return; } final actionType = actionResult.actionType; final result = actionResult.result; // only show the result dialog when the action is WorkspaceMemberActionType.add if (actionType == WorkspaceMemberActionType.addByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), ); }, (f) { Log.error('add workspace member failed: $f'); final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded ? LocaleKeys .settings_appearance_members_inviteFailedMemberLimitMobile .tr() : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); setState(() { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( type: ToastificationType.error, message: message, ); }, ); } else if (actionType == WorkspaceMemberActionType.inviteByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), ); }, (f) { Log.error('invite workspace member failed: $f'); final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded ? LocaleKeys .settings_appearance_members_inviteFailedMemberLimitMobile .tr() : LocaleKeys.settings_appearance_members_failedToInviteMember .tr(); setState(() { exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; }); showToastNotification( type: ToastificationType.error, message: message, ); }, ); } else if (actionType == WorkspaceMemberActionType.removeByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys .settings_appearance_members_removeFromWorkspaceSuccess .tr(), ); }, (f) { showToastNotification( type: ToastificationType.error, message: LocaleKeys .settings_appearance_members_removeFromWorkspaceFailed .tr(), ); }, ); } else if (actionType == WorkspaceMemberActionType.generateInviteLink) { result.fold( (s) { showToastNotification( message: LocaleKeys .settings_appearance_members_generatedLinkSuccessfully .tr(), ); // copy the invite link to the clipboard final inviteLink = state.inviteLink; if (inviteLink != null) { getIt().setPlainText(inviteLink); showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } }, (f) { Log.error('generate invite link failed: $f'); showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_generatedLinkFailed.tr(), ); }, ); } else if (actionType == WorkspaceMemberActionType.resetInviteLink) { result.fold( (s) { showToastNotification( message: LocaleKeys .settings_appearance_members_resetLinkSuccessfully .tr(), ); // copy the invite link to the clipboard final inviteLink = state.inviteLink; if (inviteLink != null) { getIt().setPlainText(inviteLink); showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } }, (f) { Log.error('generate invite link failed: $f'); showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_resetLinkFailed.tr(), ); }, ); } } } class _LeaveWorkspaceButton extends StatelessWidget { const _LeaveWorkspaceButton(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: AFOutlinedTextButton.destructive( alignment: Alignment.center, text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(), onTap: () => _leaveWorkspace(context), size: AFButtonSize.l, ), ); } void _leaveWorkspace(BuildContext context) { showFlowyCupertinoConfirmDialog( title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), leftButton: FlowyText( LocaleKeys.button_cancel.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w500, color: const Color(0xFF007AFF), ), rightButton: FlowyText( LocaleKeys.button_confirm.tr(), fontSize: 17.0, figmaLineHeight: 24.0, fontWeight: FontWeight.w400, color: const Color(0xFFFE0220), ), onRightButtonPressed: (buttonContext) async { // try to use popUntil with a specific route name but failed // so use pop twice as a workaround Navigator.of(buttonContext).pop(); Navigator.of(context).pop(); Navigator.of(context).pop(); mobileLeaveWorkspaceNotifier.value = mobileLeaveWorkspaceNotifier.value + 1; }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:universal_platform/universal_platform.dart'; class MobileMemberList extends StatelessWidget { const MobileMemberList({ super.key, required this.members, required this.myRole, required this.userProfile, }); final List members; final AFRolePB myRole; final UserProfilePB userProfile; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return SingleChildScrollView( child: SlidableAutoCloseBehavior( child: SeparatedColumn( crossAxisAlignment: CrossAxisAlignment.start, separatorBuilder: () => SizedBox.shrink(), children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0, ), child: Text( 'Joined', style: theme.textStyle.heading4.enhanced( color: theme.textColorScheme.primary, ), ), ), ...members.map( (member) => _MemberItem( member: member, myRole: myRole, userProfile: userProfile, ), ), ], ), ), ); } } class _MemberItem extends StatelessWidget { const _MemberItem({ required this.member, required this.myRole, required this.userProfile, }); final WorkspaceMemberPB member; final AFRolePB myRole; final UserProfilePB userProfile; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final canDelete = myRole.canDelete && member.email != userProfile.email; Widget child; if (UniversalPlatform.isDesktop) { child = Row( children: [ Expanded( child: Text( member.name, style: theme.textStyle.heading4.standard( color: theme.textColorScheme.primary, ), ), ), Expanded( child: Text( member.role.description, style: theme.textStyle.heading4.standard( color: theme.textColorScheme.secondary, ), textAlign: TextAlign.end, ), ), ], ); } else { child = Row( children: [ Expanded( child: Text( member.name, style: theme.textStyle.heading4.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ), ), Text( member.role.description, style: theme.textStyle.heading4.standard( color: theme.textColorScheme.secondary, ), textAlign: TextAlign.end, ), ], ); } child = Container( padding: EdgeInsets.symmetric( horizontal: theme.spacing.xl, vertical: theme.spacing.l, ), child: child, ); if (canDelete) { child = Slidable( key: ValueKey(member.email), endActionPane: ActionPane( extentRatio: 1 / 6.0, motion: const ScrollMotion(), children: [ CustomSlidableAction( backgroundColor: const Color(0xE5515563), borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), bottomLeft: Radius.circular(10), ), onPressed: (context) { HapticFeedback.mediumImpact(); _showDeleteMenu(context); }, padding: EdgeInsets.zero, child: const FlowySvg( FlowySvgs.three_dots_s, size: Size.square(24), color: Colors.white, ), ), ], ), child: child, ); } return child; } void _showDeleteMenu(BuildContext context) { final workspaceMemberBloc = context.read(); showMobileBottomSheet( context, showDragHandle: true, showDivider: false, useRootNavigator: true, backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) { return FlowyOptionTile.text( text: LocaleKeys.settings_appearance_members_removeFromWorkspace.tr(), height: 52.0, textColor: Theme.of(context).colorScheme.error, leftIcon: FlowySvg( FlowySvgs.trash_s, size: const Size.square(18), color: Theme.of(context).colorScheme.error, ), showTopBorder: false, showBottomBorder: false, onTap: () { workspaceMemberBloc.add( WorkspaceMemberEvent.removeWorkspaceMemberByEmail( member.email, ), ); Navigator.of(context).pop(); }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_trailing.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../widgets/widgets.dart'; import 'invite_members_screen.dart'; class WorkspaceSettingGroup extends StatelessWidget { const WorkspaceSettingGroup({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final currentWorkspace = state.workspaces.firstWhereOrNull( (e) => e.workspaceId == state.currentWorkspace?.workspaceId, ); final memberCount = currentWorkspace?.memberCount; String memberCountText = ''; // if the member count is greater than 0, show the member count if (memberCount != null && memberCount > 0) { memberCountText = memberCount.toString(); } return MobileSettingGroup( groupTitle: LocaleKeys.settings_appearance_members_label.tr(), settingItemList: [ MobileSettingItem( name: LocaleKeys.settings_appearance_members_label.tr(), trailing: MobileSettingTrailing( text: memberCountText, ), onTap: () { context.push(InviteMembersScreen.routeName); }, ), ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_option_decorate_box.dart ================================================ import 'package:flutter/material.dart'; class FlowyOptionDecorateBox extends StatelessWidget { const FlowyOptionDecorateBox({ super.key, this.showTopBorder = true, this.showBottomBorder = true, this.color, required this.child, }); final bool showTopBorder; final bool showBottomBorder; final Widget child; final Color? color; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: color ?? Theme.of(context).colorScheme.surface, border: Border( top: showTopBorder ? BorderSide( color: Theme.of(context).dividerColor, width: 0.5, ) : BorderSide.none, bottom: showBottomBorder ? BorderSide( color: Theme.of(context).dividerColor, width: 0.5, ) : BorderSide.none, ), ), child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileQuickActionButton extends StatelessWidget { const MobileQuickActionButton({ super.key, required this.onTap, required this.icon, required this.text, this.textColor, this.iconColor, this.iconSize, this.enable = true, this.rightIconBuilder, }); final VoidCallback onTap; final FlowySvgData icon; final String text; final Color? textColor; final Color? iconColor; final Size? iconSize; final bool enable; final WidgetBuilder? rightIconBuilder; @override Widget build(BuildContext context) { final iconSize = this.iconSize ?? const Size.square(18); return Opacity( opacity: enable ? 1.0 : 0.5, child: InkWell( onTap: enable ? onTap : null, overlayColor: enable ? null : const WidgetStatePropertyAll(Colors.transparent), splashColor: Colors.transparent, child: Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ FlowySvg( icon, size: iconSize, color: iconColor, ), HSpace(30 - iconSize.width), Expanded( child: FlowyText.regular( text, fontSize: 16, color: textColor, ), ), if (rightIconBuilder != null) rightIconBuilder!(context), ], ), ), ), ); } } class MobileQuickActionDivider extends StatelessWidget { const MobileQuickActionDivider({super.key}); @override Widget build(BuildContext context) { return const Divider(height: 0.5, thickness: 0.5); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_search_text_field.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class FlowyMobileSearchTextField extends StatelessWidget { const FlowyMobileSearchTextField({ super.key, this.hintText, this.controller, this.onChanged, this.onSubmitted, }); final String? hintText; final TextEditingController? controller; final ValueChanged? onChanged; final ValueChanged? onSubmitted; @override Widget build(BuildContext context) { return SizedBox( height: 44.0, child: CupertinoSearchTextField( controller: controller, onChanged: onChanged, onSubmitted: onSubmitted, placeholder: hintText, prefixIcon: const FlowySvg(FlowySvgs.m_search_m), prefixInsets: const EdgeInsets.only(left: 16.0, right: 2.0), suffixIcon: const Icon(Icons.close), suffixInsets: const EdgeInsets.only(right: 16.0), placeholderStyle: Theme.of(context).textTheme.titleSmall?.copyWith( color: Theme.of(context).hintColor, fontWeight: FontWeight.w400, fontSize: 14.0, ), style: Theme.of(context).textTheme.titleSmall?.copyWith( color: Theme.of(context).textTheme.bodyMedium?.color, fontWeight: FontWeight.w400, fontSize: 14.0, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_state_container.dart ================================================ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:package_info_plus/package_info_plus.dart'; enum _FlowyMobileStateContainerType { info, error, } /// Used to display info(like empty state) or error state /// error state has two buttons to report issue with error message or reach out on discord class FlowyMobileStateContainer extends StatelessWidget { const FlowyMobileStateContainer.error({ this.emoji, required this.title, this.description, required this.errorMsg, super.key, }) : _stateType = _FlowyMobileStateContainerType.error; const FlowyMobileStateContainer.info({ this.emoji, required this.title, this.description, super.key, }) : errorMsg = null, _stateType = _FlowyMobileStateContainerType.info; final String? emoji; final String title; final String? description; final String? errorMsg; final _FlowyMobileStateContainerType _stateType; @override Widget build(BuildContext context) { final theme = Theme.of(context); return SizedBox.expand( child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( emoji ?? (_stateType == _FlowyMobileStateContainerType.error ? '🛸' : ''), style: const TextStyle(fontSize: 40), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( title, style: theme.textTheme.labelLarge, textAlign: TextAlign.center, ), const SizedBox(height: 4), Text( description ?? '', style: theme.textTheme.bodyMedium?.copyWith( color: theme.hintColor, ), textAlign: TextAlign.center, ), if (_stateType == _FlowyMobileStateContainerType.error) ...[ const SizedBox(height: 8), FutureBuilder( future: PackageInfo.fromPlatform(), builder: (context, snapshot) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ OutlinedButton( onPressed: () { final String? version = snapshot.data?.version; final String os = Platform.operatingSystem; afLaunchUrlString( 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Mobile:%20&version=$version&os=$os&context=Error%20log:%20$errorMsg', ); }, child: Text( LocaleKeys.workspace_errorActions_reportIssue.tr(), ), ), OutlinedButton( onPressed: () => afLaunchUrlString('https://discord.gg/JucBXeU2FE'), child: Text( LocaleKeys.workspace_errorActions_reachOut.tr(), ), ), ], ); }, ), ], ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; enum FlowyOptionTileType { text, textField, checkbox, toggle, } class FlowyOptionTile extends StatelessWidget { const FlowyOptionTile._({ super.key, required this.type, this.showTopBorder = true, this.showBottomBorder = true, this.text, this.textColor, this.controller, this.leading, this.onTap, this.trailing, this.textFieldPadding = const EdgeInsets.symmetric( horizontal: 12.0, vertical: 2.0, ), this.isSelected = false, this.onValueChanged, this.textFieldHintText, this.onTextChanged, this.onTextSubmitted, this.autofocus, this.content, this.backgroundColor, this.fontFamily, this.height, this.enable = true, }); factory FlowyOptionTile.text({ String? text, Widget? content, Color? textColor, bool showTopBorder = true, bool showBottomBorder = true, Widget? leftIcon, Widget? trailing, VoidCallback? onTap, double? height, bool enable = true, }) { return FlowyOptionTile._( type: FlowyOptionTileType.text, text: text, content: content, textColor: textColor, onTap: onTap, showTopBorder: showTopBorder, showBottomBorder: showBottomBorder, leading: leftIcon, trailing: trailing, height: height, enable: enable, ); } factory FlowyOptionTile.textField({ required TextEditingController controller, void Function(String value)? onTextChanged, void Function(String value)? onTextSubmitted, EdgeInsets textFieldPadding = const EdgeInsets.symmetric( vertical: 16.0, ), bool showTopBorder = true, bool showBottomBorder = true, Widget? leftIcon, Widget? trailing, String? textFieldHintText, bool autofocus = false, bool enable = true, }) { return FlowyOptionTile._( type: FlowyOptionTileType.textField, controller: controller, textFieldPadding: textFieldPadding, showTopBorder: showTopBorder, showBottomBorder: showBottomBorder, leading: leftIcon, trailing: trailing, textFieldHintText: textFieldHintText, onTextChanged: onTextChanged, onTextSubmitted: onTextSubmitted, autofocus: autofocus, enable: enable, ); } factory FlowyOptionTile.checkbox({ Key? key, required String text, required bool isSelected, required VoidCallback? onTap, Color? textColor, Widget? leftIcon, Widget? content, bool showTopBorder = true, bool showBottomBorder = true, String? fontFamily, Color? backgroundColor, bool enable = true, }) { return FlowyOptionTile._( key: key, type: FlowyOptionTileType.checkbox, isSelected: isSelected, text: text, textColor: textColor, content: content, onTap: onTap, fontFamily: fontFamily, backgroundColor: backgroundColor, showTopBorder: showTopBorder, showBottomBorder: showBottomBorder, leading: leftIcon, enable: enable, trailing: isSelected ? const FlowySvg( FlowySvgs.m_blue_check_s, blendMode: null, ) : null, ); } factory FlowyOptionTile.toggle({ required String text, required bool isSelected, required void Function(bool value) onValueChanged, void Function()? onTap, bool showTopBorder = true, bool showBottomBorder = true, Widget? leftIcon, bool enable = true, }) { return FlowyOptionTile._( type: FlowyOptionTileType.toggle, text: text, onTap: onTap ?? () => onValueChanged(!isSelected), onValueChanged: onValueChanged, showTopBorder: showTopBorder, showBottomBorder: showBottomBorder, leading: leftIcon, trailing: _Toggle(value: isSelected, onChanged: onValueChanged), enable: enable, ); } final bool showTopBorder; final bool showBottomBorder; final String? text; final Color? textColor; final TextEditingController? controller; final EdgeInsets textFieldPadding; final void Function()? onTap; final Widget? leading; final Widget? trailing; // customize the content widget final Widget? content; // only used in checkbox or switcher final bool isSelected; // only used in switcher final void Function(bool value)? onValueChanged; // only used in textfield final String? textFieldHintText; final void Function(String value)? onTextChanged; final void Function(String value)? onTextSubmitted; final bool? autofocus; final FlowyOptionTileType type; final Color? backgroundColor; final String? fontFamily; final double? height; final bool enable; @override Widget build(BuildContext context) { final leadingWidget = _buildLeading(); Widget child = FlowyOptionDecorateBox( color: backgroundColor, showTopBorder: showTopBorder, showBottomBorder: showBottomBorder, child: SizedBox( height: height, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ if (leadingWidget != null) leadingWidget, if (content != null) content!, if (content == null) _buildText(), if (content == null) _buildTextField(), if (trailing != null) trailing!, ], ), ), ), ); if (type == FlowyOptionTileType.checkbox || type == FlowyOptionTileType.toggle || type == FlowyOptionTileType.text) { child = GestureDetector( onTap: onTap, child: child, ); } if (!enable) { child = Opacity( opacity: 0.5, child: IgnorePointer( child: child, ), ); } return child; } Widget? _buildLeading() { if (leading != null) { return Center(child: leading); } else { return null; } } Widget _buildText() { if (text == null || type == FlowyOptionTileType.textField) { return const SizedBox.shrink(); } final padding = EdgeInsets.symmetric( horizontal: leading == null ? 0.0 : 12.0, vertical: 14.0, ); return Expanded( child: Padding( padding: padding, child: FlowyText( text!, fontSize: 16, color: textColor, fontFamily: fontFamily, ), ), ); } Widget _buildTextField() { if (controller == null) { return const SizedBox.shrink(); } return Expanded( child: Container( constraints: const BoxConstraints.tightFor( height: 54.0, ), alignment: Alignment.center, child: TextField( controller: controller, autofocus: autofocus ?? false, textInputAction: TextInputAction.done, decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: textFieldPadding, hintText: textFieldHintText, ), onChanged: onTextChanged, onSubmitted: onTextSubmitted, ), ), ); } } class _Toggle extends StatelessWidget { const _Toggle({ required this.value, required this.onChanged, }); final bool value; final void Function(bool value) onChanged; @override Widget build(BuildContext context) { // CupertinoSwitch adds a 8px margin all around. The original size of the // switch is 38 x 22. return SizedBox( width: 46, height: 30, child: FittedBox( fit: BoxFit.fill, child: CupertinoSwitch( value: value, activeTrackColor: Theme.of(context).colorScheme.primary, onChanged: onChanged, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class NavigationBarButton extends StatelessWidget { const NavigationBarButton({ super.key, required this.text, required this.icon, required this.onTap, this.enable = true, }); final String text; final FlowySvgData icon; final VoidCallback onTap; final bool enable; @override Widget build(BuildContext context) { return Opacity( opacity: enable ? 1.0 : 0.3, child: Container( height: 40, decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: const BorderSide(color: Color(0x3F1F2329)), borderRadius: BorderRadius.circular(10), ), ), child: FlowyButton( useIntrinsicWidth: true, expandText: false, iconPadding: 8, leftIcon: FlowySvg(icon), onTap: enable ? onTap : null, text: FlowyText( text, fontSize: 15.0, figmaLineHeight: 18.0, fontWeight: FontWeight.w400, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; enum ConfirmDialogActionAlignment { // The action buttons are aligned vertically // --------------------- // | Action Button | // | Cancel Button | vertical, // The action buttons are aligned horizontally // --------------------- // | Action Button | Cancel Button | horizontal, } /// show the dialog to confirm one single action /// [onActionButtonPressed] and [onCancelButtonPressed] end with close the dialog Future showFlowyMobileConfirmDialog( BuildContext context, { Widget? title, Widget? content, ConfirmDialogActionAlignment actionAlignment = ConfirmDialogActionAlignment.horizontal, required String actionButtonTitle, required VoidCallback? onActionButtonPressed, Color? actionButtonColor, String? cancelButtonTitle, Color? cancelButtonColor, VoidCallback? onCancelButtonPressed, }) async { return showDialog( context: context, builder: (dialogContext) { final foregroundColor = Theme.of(context).colorScheme.onSurface; final actionButton = TextButton( child: FlowyText( actionButtonTitle, color: actionButtonColor ?? foregroundColor, ), onPressed: () { onActionButtonPressed?.call(); // we cannot use dialogContext.pop() here because this is no GoRouter in dialogContext. Use Navigator instead to close the dialog. Navigator.of(dialogContext).pop(); }, ); final cancelButton = TextButton( child: FlowyText( cancelButtonTitle ?? LocaleKeys.button_cancel.tr(), color: cancelButtonColor ?? foregroundColor, ), onPressed: () { onCancelButtonPressed?.call(); Navigator.of(dialogContext).pop(); }, ); final actions = switch (actionAlignment) { ConfirmDialogActionAlignment.horizontal => [ actionButton, cancelButton, ], ConfirmDialogActionAlignment.vertical => [ Column( children: [ actionButton, const Divider(height: 1, color: Colors.grey), cancelButton, ], ), ], }; return AlertDialog.adaptive( title: title, content: content, contentPadding: const EdgeInsets.symmetric( horizontal: 24.0, vertical: 4.0, ), actionsAlignment: MainAxisAlignment.center, actions: actions, ); }, ); } Future showFlowyCupertinoConfirmDialog({ BuildContext? context, required String title, Widget? content, required Widget leftButton, required Widget rightButton, void Function(BuildContext context)? onLeftButtonPressed, void Function(BuildContext context)? onRightButtonPressed, }) { return showDialog( context: context ?? AppGlobals.context, barrierColor: Colors.black.withValues(alpha: 0.25), builder: (context) => CupertinoAlertDialog( title: FlowyText.medium( title, fontSize: 16, maxLines: 10, figmaLineHeight: 22.0, ), content: content, actions: [ CupertinoDialogAction( onPressed: () { if (onLeftButtonPressed != null) { onLeftButtonPressed(context); } else { Navigator.of(context).pop(); } }, child: leftButton, ), CupertinoDialogAction( onPressed: () { if (onRightButtonPressed != null) { onRightButtonPressed(context); } else { Navigator.of(context).pop(); } }, child: rightButton, ), ], ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/mobile/presentation/widgets/widgets.dart ================================================ export 'flowy_mobile_option_decorate_box.dart'; export 'flowy_mobile_state_container.dart'; export 'flowy_option_tile.dart'; export 'show_flowy_mobile_confirm_dialog.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_chat_prelude.dart ================================================ export 'ai_model_switch_listener.dart'; export 'chat_ai_message_bloc.dart'; export 'chat_bloc.dart'; export 'chat_edit_document_service.dart'; export 'chat_entity.dart'; export 'chat_input_control_cubit.dart'; export 'chat_input_file_bloc.dart'; export 'chat_member_bloc.dart'; export 'chat_message_listener.dart'; export 'chat_message_service.dart'; export 'chat_message_stream.dart'; export 'chat_notification.dart'; export 'chat_select_message_bloc.dart'; export 'chat_user_message_bloc.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_model_switch_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef OnUpdateSelectedModel = void Function(AIModelPB model); class AIModelSwitchListener { AIModelSwitchListener({required this.objectId}) { _parser = ChatNotificationParser(id: objectId, callback: _callback); _subscription = RustStreamReceiver.listen( (observable) => _parser?.parse(observable), ); } final String objectId; StreamSubscription? _subscription; ChatNotificationParser? _parser; void start({ OnUpdateSelectedModel? onUpdateSelectedModel, }) { this.onUpdateSelectedModel = onUpdateSelectedModel; } OnUpdateSelectedModel? onUpdateSelectedModel; void _callback( ChatNotification ty, FlowyResult result, ) { result.map((r) { switch (ty) { case ChatNotification.DidUpdateSelectedModel: onUpdateSelectedModel?.call(AIModelPB.fromBuffer(r)); break; default: break; } }); } Future stop() async { await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart ================================================ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'chat_message_service.dart'; part 'chat_ai_message_bloc.freezed.dart'; class ChatAIMessageBloc extends Bloc { ChatAIMessageBloc({ dynamic message, String? refSourceJsonString, required this.chatId, required this.questionId, }) : super( ChatAIMessageState.initial( message, parseMetadata(refSourceJsonString), ), ) { _registerEventHandlers(); _initializeStreamListener(); _checkInitialStreamState(); } final String chatId; final Int64? questionId; void _registerEventHandlers() { on<_UpdateText>((event, emit) { emit( state.copyWith( text: event.text, messageState: const MessageState.ready(), ), ); }); on<_ReceiveError>((event, emit) { emit(state.copyWith(messageState: MessageState.onError(event.error))); }); on<_Retry>((event, emit) async { if (questionId == null) { Log.error("Question id is not valid: $questionId"); return; } emit(state.copyWith(messageState: const MessageState.loading())); final payload = ChatMessageIdPB( chatId: chatId, messageId: questionId, ); final result = await AIEventGetAnswerForQuestion(payload).send(); if (!isClosed) { result.fold( (answer) => add(ChatAIMessageEvent.retryResult(answer.content)), (err) { Log.error("Failed to get answer: $err"); add(ChatAIMessageEvent.receiveError(err.toString())); }, ); } }); on<_RetryResult>((event, emit) { emit( state.copyWith( text: event.text, messageState: const MessageState.ready(), ), ); }); on<_OnAIResponseLimit>((event, emit) { emit( state.copyWith( messageState: const MessageState.onAIResponseLimit(), ), ); }); on<_OnAIImageResponseLimit>((event, emit) { emit( state.copyWith( messageState: const MessageState.onAIImageResponseLimit(), ), ); }); on<_OnAIMaxRquired>((event, emit) { emit( state.copyWith( messageState: MessageState.onAIMaxRequired(event.message), ), ); }); on<_OnLocalAIInitializing>((event, emit) { emit( state.copyWith( messageState: const MessageState.onInitializingLocalAI(), ), ); }); on<_ReceiveMetadata>((event, emit) { Log.debug("AI Steps: ${event.metadata.progress?.step}"); emit( state.copyWith( sources: event.metadata.sources, progress: event.metadata.progress, ), ); }); on<_OnAIFollowUp>((event, emit) { emit( state.copyWith( messageState: MessageState.aiFollowUp(event.followUpData), ), ); }); } void _initializeStreamListener() { if (state.stream != null) { state.stream!.listen( onData: (text) => _safeAdd(ChatAIMessageEvent.updateText(text)), onError: (error) => _safeAdd(ChatAIMessageEvent.receiveError(error.toString())), onAIResponseLimit: () => _safeAdd(const ChatAIMessageEvent.onAIResponseLimit()), onAIImageResponseLimit: () => _safeAdd(const ChatAIMessageEvent.onAIImageResponseLimit()), onMetadata: (metadata) => _safeAdd(ChatAIMessageEvent.receiveMetadata(metadata)), onAIMaxRequired: (message) { Log.info(message); _safeAdd(ChatAIMessageEvent.onAIMaxRequired(message)); }, onLocalAIInitializing: () => _safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()), onAIFollowUp: (data) { _safeAdd(ChatAIMessageEvent.onAIFollowUp(data)); }, ); } } void _checkInitialStreamState() { if (state.stream != null) { if (state.stream!.aiLimitReached) { add(const ChatAIMessageEvent.onAIResponseLimit()); } else if (state.stream!.error != null) { add(ChatAIMessageEvent.receiveError(state.stream!.error!)); } } } void _safeAdd(ChatAIMessageEvent event) { if (!isClosed) { add(event); } } } @freezed class ChatAIMessageEvent with _$ChatAIMessageEvent { const factory ChatAIMessageEvent.updateText(String text) = _UpdateText; const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError; const factory ChatAIMessageEvent.retry() = _Retry; const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult; const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit; const factory ChatAIMessageEvent.onAIImageResponseLimit() = _OnAIImageResponseLimit; const factory ChatAIMessageEvent.onAIMaxRequired(String message) = _OnAIMaxRquired; const factory ChatAIMessageEvent.onLocalAIInitializing() = _OnLocalAIInitializing; const factory ChatAIMessageEvent.receiveMetadata( MetadataCollection metadata, ) = _ReceiveMetadata; const factory ChatAIMessageEvent.onAIFollowUp( AIFollowUpData followUpData, ) = _OnAIFollowUp; } @freezed class ChatAIMessageState with _$ChatAIMessageState { const factory ChatAIMessageState({ AnswerStream? stream, required String text, required MessageState messageState, required List sources, required AIChatProgress? progress, }) = _ChatAIMessageState; factory ChatAIMessageState.initial( dynamic text, MetadataCollection metadata, ) { return ChatAIMessageState( text: text is String ? text : "", stream: text is AnswerStream ? text : null, messageState: const MessageState.ready(), sources: metadata.sources, progress: metadata.progress, ); } } @freezed class MessageState with _$MessageState { const factory MessageState.onError(String error) = _Error; const factory MessageState.onAIResponseLimit() = _AIResponseLimit; const factory MessageState.onAIImageResponseLimit() = _AIImageResponseLimit; const factory MessageState.onAIMaxRequired(String message) = _AIMaxRequired; const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing; const factory MessageState.ready() = _Ready; const factory MessageState.loading() = _Loading; const factory MessageState.aiFollowUp(AIFollowUpData followUpData) = _AIFollowUp; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'chat_entity.dart'; import 'chat_message_handler.dart'; import 'chat_message_listener.dart'; import 'chat_message_stream.dart'; import 'chat_settings_manager.dart'; import 'chat_stream_manager.dart'; part 'chat_bloc.freezed.dart'; /// Returns current Unix timestamp (seconds since epoch) int timestamp() { return DateTime.now().millisecondsSinceEpoch ~/ 1000; } class ChatBloc extends Bloc { ChatBloc({ required this.chatId, required this.userId, }) : chatController = InMemoryChatController(), listener = ChatMessageListener(chatId: chatId), super(ChatState.initial()) { // Initialize managers _messageHandler = ChatMessageHandler( chatId: chatId, userId: userId, chatController: chatController, ); _streamManager = ChatStreamManager(chatId); _settingsManager = ChatSettingsManager(chatId: chatId); _startListening(); _dispatch(); _loadMessages(); _loadSettings(); } final String chatId; final String userId; final ChatMessageListener listener; final ChatController chatController; // Managers late final ChatMessageHandler _messageHandler; late final ChatStreamManager _streamManager; late final ChatSettingsManager _settingsManager; ChatMessagePB? lastSentMessage; bool isLoadingPreviousMessages = false; bool hasMorePreviousMessages = true; bool isFetchingRelatedQuestions = false; bool shouldFetchRelatedQuestions = false; // Accessor for selected sources ValueNotifier> get selectedSourcesNotifier => _settingsManager.selectedSourcesNotifier; @override Future close() async { // Safely dispose all resources await _streamManager.dispose(); await listener.stop(); final request = ViewIdPB(value: chatId); unawaited(FolderEventCloseView(request).send()); _settingsManager.dispose(); chatController.dispose(); return super.close(); } void _dispatch() { on((event, emit) async { await event.when( // Chat settings didReceiveChatSettings: (settings) async => _handleChatSettings(settings), updateSelectedSources: (selectedSourcesIds) async => _handleUpdateSources(selectedSourcesIds), // Message loading didLoadLatestMessages: (messages) async => _handleLatestMessages(messages, emit), loadPreviousMessages: () async => _loadPreviousMessagesIfNeeded(), didLoadPreviousMessages: (messages, hasMore) async => _handlePreviousMessages(messages, hasMore), // Message handling receiveMessage: (message) async => _handleReceiveMessage(message), // Sending messages sendMessage: (message, format, metadata, promptId) async => _handleSendMessage(message, format, metadata, promptId, emit), finishSending: () async => emit( state.copyWith( promptResponseState: PromptResponseState.streamingAnswer, ), ), // Stream control stopStream: () async => _handleStopStream(emit), failedSending: () async => _handleFailedSending(emit), // Answer regeneration regenerateAnswer: (id, format, model) async => _handleRegenerateAnswer(id, format, model, emit), // Streaming completion didFinishAnswerStream: () async => emit( state.copyWith( promptResponseState: PromptResponseState.ready, ), ), // Related questions didReceiveRelatedQuestions: (questions) async => _handleRelatedQuestions( questions, emit, ), // Message management deleteMessage: (message) async => chatController.remove(message), // AI follow-up onAIFollowUp: (followUpData) async { shouldFetchRelatedQuestions = followUpData.shouldGenerateRelatedQuestion; }, ); }); } // Chat settings handlers void _handleChatSettings(ChatSettingsPB settings) { _settingsManager.selectedSourcesNotifier.value = settings.ragIds; } Future _handleUpdateSources(List selectedSourcesIds) async { await _settingsManager.updateSelectedSources(selectedSourcesIds); } // Message loading handlers Future _handleLatestMessages( List messages, Emitter emit, ) async { for (final message in messages) { await chatController.insert(message, index: 0); } // Check if emit is still valid after async operations if (emit.isDone) { return; } switch (state.loadingState) { case LoadChatMessageStatus.loading when chatController.messages.isEmpty: emit(state.copyWith(loadingState: LoadChatMessageStatus.loadingRemote)); break; case LoadChatMessageStatus.loading: case LoadChatMessageStatus.loadingRemote: emit(state.copyWith(loadingState: LoadChatMessageStatus.ready)); break; default: break; } } void _handlePreviousMessages(List messages, bool hasMore) { for (final message in messages) { chatController.insert(message, index: 0); } isLoadingPreviousMessages = false; hasMorePreviousMessages = hasMore; } // Message handling void _handleReceiveMessage(Message message) { final oldMessage = chatController.messages.firstWhereOrNull((m) => m.id == message.id); if (oldMessage == null) { chatController.insert(message); } else { chatController.update(oldMessage, message); } } // Message sending handlers void _handleSendMessage( String message, PredefinedFormat? format, Map? metadata, String? promptId, Emitter emit, ) { _messageHandler.clearErrorMessages(); emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages)); _messageHandler.clearRelatedQuestions(); _startStreamingMessage(message, format, metadata, promptId); lastSentMessage = null; isFetchingRelatedQuestions = false; shouldFetchRelatedQuestions = format == null || format.imageFormat.hasText; emit( state.copyWith( promptResponseState: PromptResponseState.sendingQuestion, ), ); } // Stream control handlers Future _handleStopStream(Emitter emit) async { await _streamManager.stopStream(); // Allow user input emit(state.copyWith(promptResponseState: PromptResponseState.ready)); // No need to remove old message if stream has started already if (_streamManager.hasAnswerStreamStarted) { return; } // Remove the non-started message from the list final message = chatController.messages.lastWhereOrNull( (e) => e.id == _messageHandler.answerStreamMessageId, ); if (message != null) { await chatController.remove(message); } await _streamManager.disposeAnswerStream(); } void _handleFailedSending(Emitter emit) { final lastMessage = chatController.messages.lastOrNull; if (lastMessage != null) { chatController.remove(lastMessage); } emit(state.copyWith(promptResponseState: PromptResponseState.ready)); } // Answer regeneration handler void _handleRegenerateAnswer( String id, PredefinedFormat? format, AIModelPB? model, Emitter emit, ) { _messageHandler.clearRelatedQuestions(); _regenerateAnswer(id, format, model); lastSentMessage = null; isFetchingRelatedQuestions = false; shouldFetchRelatedQuestions = false; emit( state.copyWith( promptResponseState: PromptResponseState.sendingQuestion, ), ); } // Related questions handler void _handleRelatedQuestions( List questions, Emitter emit, ) { if (questions.isEmpty) { return; } final metadata = { onetimeShotType: OnetimeShotType.relatedQuestion, 'questions': questions, }; final createdAt = DateTime.now(); final message = TextMessage( id: "related_question_$createdAt", text: '', metadata: metadata, author: const User(id: systemUserId), createdAt: createdAt, ); chatController.insert(message); emit( state.copyWith( promptResponseState: PromptResponseState.relatedQuestionsReady, ), ); } void _startListening() { listener.start( chatMessageCallback: (pb) { if (isClosed) { return; } _messageHandler.processReceivedMessage(pb); final message = _messageHandler.createTextMessage(pb); add(ChatEvent.receiveMessage(message)); }, chatErrorMessageCallback: (err) { if (!isClosed) { Log.error("chat error: ${err.errorMessage}"); add(const ChatEvent.didFinishAnswerStream()); } }, latestMessageCallback: (list) { if (!isClosed) { final messages = list.messages.map(_messageHandler.createTextMessage).toList(); add(ChatEvent.didLoadLatestMessages(messages)); } }, prevMessageCallback: (list) { if (!isClosed) { final messages = list.messages.map(_messageHandler.createTextMessage).toList(); add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore)); } }, finishStreamingCallback: () async { if (isClosed) { return; } add(const ChatEvent.didFinishAnswerStream()); unawaited(_fetchRelatedQuestionsIfNeeded()); }, ); } // Split method to handle related questions Future _fetchRelatedQuestionsIfNeeded() async { // Don't fetch related questions if conditions aren't met if (_streamManager.answerStream == null || lastSentMessage == null || !shouldFetchRelatedQuestions) { return; } final payload = ChatMessageIdPB( chatId: chatId, messageId: lastSentMessage!.messageId, ); isFetchingRelatedQuestions = true; await AIEventGetRelatedQuestion(payload).send().fold( (list) { // while fetching related questions, the user might enter a new // question or regenerate a previous response. In such cases, don't // display the relatedQuestions if (!isClosed && isFetchingRelatedQuestions) { add( ChatEvent.didReceiveRelatedQuestions( list.items.map((e) => e.content).toList(), ), ); isFetchingRelatedQuestions = false; } }, (err) => Log.error("Failed to get related questions: $err"), ); } void _loadSettings() async { final getChatSettingsPayload = AIEventGetChatSettings(ChatId(value: chatId)); await getChatSettingsPayload.send().fold( (settings) { if (!isClosed) { add(ChatEvent.didReceiveChatSettings(settings: settings)); } }, (err) => Log.error("Failed to load chat settings: $err"), ); } void _loadMessages() async { final loadMessagesPayload = LoadNextChatMessagePB( chatId: chatId, limit: Int64(10), ); await AIEventLoadNextMessage(loadMessagesPayload).send().fold( (list) { if (!isClosed) { final messages = list.messages.map(_messageHandler.createTextMessage).toList(); add(ChatEvent.didLoadLatestMessages(messages)); } }, (err) => Log.error("Failed to load messages: $err"), ); } void _loadPreviousMessagesIfNeeded() { if (isLoadingPreviousMessages) { return; } final oldestMessage = _messageHandler.getOldestMessage(); if (oldestMessage != null) { final oldestMessageId = Int64.tryParseInt(oldestMessage.id); if (oldestMessageId == null) { Log.error("Failed to parse message_id: ${oldestMessage.id}"); return; } isLoadingPreviousMessages = true; _loadPreviousMessages(oldestMessageId); } } void _loadPreviousMessages(Int64? beforeMessageId) { final payload = LoadPrevChatMessagePB( chatId: chatId, limit: Int64(10), beforeMessageId: beforeMessageId, ); AIEventLoadPrevMessage(payload).send(); } Future _startStreamingMessage( String message, PredefinedFormat? format, Map? metadata, String? promptId, ) async { // Prepare streams await _streamManager.prepareStreams(); // Create and add question message final questionStreamMessage = _messageHandler.createQuestionStreamMessage( _streamManager.questionStream!, metadata, ); add(ChatEvent.receiveMessage(questionStreamMessage)); // Send stream request await _streamManager.sendStreamRequest(message, format, promptId).fold( (question) { if (!isClosed) { // Create and add answer stream message final streamAnswer = _messageHandler.createAnswerStreamMessage( stream: _streamManager.answerStream!, questionMessageId: question.messageId, fakeQuestionMessageId: questionStreamMessage.id, ); lastSentMessage = question; add(const ChatEvent.finishSending()); add(ChatEvent.receiveMessage(streamAnswer)); } }, (err) { if (!isClosed) { Log.error("Failed to send message: ${err.msg}"); final metadata = { onetimeShotType: OnetimeShotType.error, if (err.code != ErrorCode.Internal) errorMessageTextKey: err.msg, }; final error = TextMessage( text: '', metadata: metadata, author: const User(id: systemUserId), id: systemUserId, createdAt: DateTime.now(), ); add(const ChatEvent.failedSending()); add(ChatEvent.receiveMessage(error)); } }, ); } // Refactored method to handle answer regeneration void _regenerateAnswer( String answerMessageIdString, PredefinedFormat? format, AIModelPB? model, ) async { final id = _messageHandler.getEffectiveMessageId(answerMessageIdString); final answerMessageId = Int64.tryParseInt(id); if (answerMessageId == null) { return; } await _streamManager.prepareStreams(); await _streamManager .sendRegenerateRequest( answerMessageId, format, model, ) .fold( (_) { if (!isClosed) { final streamAnswer = _messageHandler .createAnswerStreamMessage( stream: _streamManager.answerStream!, questionMessageId: answerMessageId - 1, ) .copyWith(id: answerMessageIdString); add(ChatEvent.receiveMessage(streamAnswer)); add(const ChatEvent.finishSending()); } }, (err) => Log.error("Failed to regenerate answer: ${err.msg}"), ); } } @freezed class ChatEvent with _$ChatEvent { // chat settings const factory ChatEvent.didReceiveChatSettings({ required ChatSettingsPB settings, }) = _DidReceiveChatSettings; const factory ChatEvent.updateSelectedSources({ required List selectedSourcesIds, }) = _UpdateSelectedSources; // send message const factory ChatEvent.sendMessage({ required String message, PredefinedFormat? format, Map? metadata, String? promptId, }) = _SendMessage; const factory ChatEvent.finishSending() = _FinishSendMessage; const factory ChatEvent.failedSending() = _FailSendMessage; // regenerate const factory ChatEvent.regenerateAnswer( String id, PredefinedFormat? format, AIModelPB? model, ) = _RegenerateAnswer; // streaming answer const factory ChatEvent.stopStream() = _StopStream; const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream; // receive message const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage; // loading messages const factory ChatEvent.didLoadLatestMessages(List messages) = _DidLoadMessages; const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages; const factory ChatEvent.didLoadPreviousMessages( List messages, bool hasMore, ) = _DidLoadPreviousMessages; // related questions const factory ChatEvent.didReceiveRelatedQuestions( List questions, ) = _DidReceiveRelatedQueston; const factory ChatEvent.deleteMessage(Message message) = _DeleteMessage; const factory ChatEvent.onAIFollowUp(AIFollowUpData followUpData) = _OnAIFollowUp; } @freezed class ChatState with _$ChatState { const factory ChatState({ required LoadChatMessageStatus loadingState, required PromptResponseState promptResponseState, required bool clearErrorMessages, }) = _ChatState; factory ChatState.initial() => const ChatState( loadingState: LoadChatMessageStatus.loading, promptResponseState: PromptResponseState.ready, clearErrorMessages: false, ); } bool isOtherUserMessage(Message message) { return message.author.id != aiResponseUserId && message.author.id != systemUserId && !message.author.id.startsWith("streamId:"); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_edit_document_service.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; class ChatEditDocumentService { const ChatEditDocumentService._(); static Future saveMessagesToNewPage( String chatPageName, String parentViewId, List messages, ) async { if (messages.isEmpty) { return null; } // Convert messages to markdown and trim the last empty newline. final completeMessage = messages.map((m) => m.text).join('\n').trimRight(); if (completeMessage.isEmpty) { return null; } final document = customMarkdownToDocument( completeMessage, tableWidth: 250.0, ); final initialBytes = DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); if (initialBytes == null) { Log.error('Failed to convert messages to document'); return null; } return ViewBackendService.createView( name: LocaleKeys.chat_addToNewPageName.tr(args: [chatPageName]), layoutType: ViewLayoutPB.Document, parentViewId: parentViewId, initialDataBytes: initialBytes, ).toNullable(); } static Future addMessagesToPage( String documentId, List messages, ) async { // Convert messages to markdown and trim the last empty newline. final completeMessage = messages.map((m) => m.text).join('\n').trimRight(); if (completeMessage.isEmpty) { return; } final bloc = DocumentBloc( documentId: documentId, saveToBlocMap: false, )..add(const DocumentEvent.initial()); if (bloc.state.editorState == null) { await bloc.stream.firstWhere((state) => state.editorState != null); } final editorState = bloc.state.editorState; if (editorState == null) { Log.error("Can't get EditorState of document"); return; } final messageDocument = customMarkdownToDocument( completeMessage, tableWidth: 250.0, ); if (messageDocument.isEmpty) { Log.error('Failed to convert message to document'); return; } final lastNodeOrNull = editorState.document.root.children.lastOrNull; final rootIsEmpty = lastNodeOrNull == null; final isLastLineEmpty = lastNodeOrNull?.children.isNotEmpty == false && lastNodeOrNull?.delta?.isNotEmpty == false; final nodes = [ if (rootIsEmpty || !isLastLineEmpty) paragraphNode(), ...messageDocument.root.children, paragraphNode(), ]; final insertPath = rootIsEmpty || listEquals(lastNodeOrNull.path, const [0]) && isLastLineEmpty ? const [0] : lastNodeOrNull.path.next; final transaction = editorState.transaction..insertNodes(insertPath, nodes); await editorState.apply(transaction); await bloc.close(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart ================================================ import 'dart:io'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:equatable/equatable.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:path/path.dart' as path; part 'chat_entity.g.dart'; part 'chat_entity.freezed.dart'; const errorMessageTextKey = "errorMessageText"; const systemUserId = "system"; const aiResponseUserId = "0"; /// `messageRefSourceJsonStringKey` is the key used for metadata that contains the reference source of a message. /// Each message may include this information. /// - When used in a sent message, it indicates that the message includes an attachment. /// - When used in a received message, it indicates the AI reference sources used to answer a question. const messageRefSourceJsonStringKey = "ref_source_json_string"; const messageChatFileListKey = "chat_files"; const messageQuestionIdKey = "question_id"; @JsonSerializable() class ChatMessageRefSource { ChatMessageRefSource({ required this.id, required this.name, required this.source, }); factory ChatMessageRefSource.fromJson(Map json) => _$ChatMessageRefSourceFromJson(json); final String id; final String name; final String source; Map toJson() => _$ChatMessageRefSourceToJson(this); } @JsonSerializable() class AIChatProgress { AIChatProgress({ required this.step, }); factory AIChatProgress.fromJson(Map json) => _$AIChatProgressFromJson(json); final String step; Map toJson() => _$AIChatProgressToJson(this); } enum PromptResponseState { ready, sendingQuestion, streamingAnswer, relatedQuestionsReady; bool get isReady => this == ready || this == relatedQuestionsReady; } class ChatFile extends Equatable { const ChatFile({ required this.filePath, required this.fileName, required this.fileType, }); static ChatFile? fromFilePath(String filePath) { final file = File(filePath); if (!file.existsSync()) { return null; } final fileName = path.basename(filePath); final extension = path.extension(filePath).toLowerCase(); ContextLoaderTypePB fileType; switch (extension) { case '.pdf': fileType = ContextLoaderTypePB.PDF; break; case '.txt': fileType = ContextLoaderTypePB.Txt; break; case '.md': fileType = ContextLoaderTypePB.Markdown; break; default: fileType = ContextLoaderTypePB.UnknownLoaderType; } return ChatFile( filePath: filePath, fileName: fileName, fileType: fileType, ); } final String filePath; final String fileName; final ContextLoaderTypePB fileType; @override List get props => [filePath]; } typedef ChatFileMap = Map; typedef ChatMentionedPageMap = Map; @freezed class ChatLoadingState with _$ChatLoadingState { const factory ChatLoadingState.loading() = _Loading; const factory ChatLoadingState.finish({FlowyError? error}) = _Finish; } extension ChatLoadingStateExtension on ChatLoadingState { bool get isLoading => this is _Loading; bool get isFinish => this is _Finish; } enum OnetimeShotType { sendingMessage, relatedQuestion, error, } const onetimeShotType = "OnetimeShotType"; OnetimeShotType? onetimeMessageTypeFromMeta(Map? metadata) { return metadata?[onetimeShotType]; } enum LoadChatMessageStatus { loading, loadingRemote, ready, } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_control_cubit.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'chat_input_control_cubit.freezed.dart'; class ChatInputControlCubit extends Cubit { ChatInputControlCubit() : super(const ChatInputControlState.loading()); final List allViews = []; final List selectedViewIds = []; /// used when mentioning a page /// /// the text position after the @ character int _filterStartPosition = -1; /// used when mentioning a page /// /// the text position after the @ character, at the end of the filter int _filterEndPosition = -1; /// used when mentioning a page /// /// the entire string input in the prompt String _inputText = ""; /// used when mentioning a page /// /// the current filtering text, after the @ characater String _filter = ""; String get inputText => _inputText; int get filterStartPosition => _filterStartPosition; int get filterEndPosition => _filterEndPosition; void refreshViews() async { final newViews = await ViewBackendService.getAllViews().fold( (result) { return result.items .where( (v) => !v.isSpace && v.layout.isDocumentView && v.parentViewId != v.id, ) .toList(); }, (err) { Log.error(err); return []; }, ); allViews ..clear() ..addAll(newViews); // update visible views newViews.retainWhere((v) => !selectedViewIds.contains(v.id)); if (_filter.isNotEmpty) { newViews.retainWhere( (v) { final nonEmptyName = v.name.isEmpty ? LocaleKeys.document_title_placeholder.tr() : v.name; return nonEmptyName.toLowerCase().contains(_filter); }, ); } final focusedViewIndex = newViews.isEmpty ? -1 : 0; emit( ChatInputControlState.ready( visibleViews: newViews, focusedViewIndex: focusedViewIndex, ), ); } void startSearching(TextEditingValue textEditingValue) { _filterStartPosition = _filterEndPosition = textEditingValue.selection.baseOffset; _filter = ""; _inputText = textEditingValue.text; state.maybeMap( ready: (readyState) { emit( readyState.copyWith( visibleViews: allViews, focusedViewIndex: allViews.isEmpty ? -1 : 0, ), ); }, orElse: () {}, ); } void reset() { _filterStartPosition = _filterEndPosition = -1; _filter = _inputText = ""; state.maybeMap( ready: (readyState) { emit( readyState.copyWith( visibleViews: allViews, focusedViewIndex: allViews.isEmpty ? -1 : 0, ), ); }, orElse: () {}, ); } void updateFilter( String newInputText, String newFilter, { int? newEndPosition, }) { updateInputText(newInputText); // filter the views _filter = newFilter.toLowerCase(); if (newEndPosition != null) { _filterEndPosition = newEndPosition; } final newVisibleViews = allViews.where((v) => !selectedViewIds.contains(v.id)).toList(); if (_filter.isNotEmpty) { newVisibleViews.retainWhere( (v) { final nonEmptyName = v.name.isEmpty ? LocaleKeys.document_title_placeholder.tr() : v.name; return nonEmptyName.toLowerCase().contains(_filter); }, ); } state.maybeWhen( ready: (_, oldFocusedIndex) { final newFocusedViewIndex = oldFocusedIndex < newVisibleViews.length ? oldFocusedIndex : (newVisibleViews.isEmpty ? -1 : 0); emit( ChatInputControlState.ready( visibleViews: newVisibleViews, focusedViewIndex: newFocusedViewIndex, ), ); }, orElse: () {}, ); } void updateInputText(String newInputText) { _inputText = newInputText; // input text is changed, see if there are any deletions selectedViewIds.retainWhere(_inputText.contains); _notifyUpdateSelectedViews(); } void updateSelectionUp() { state.maybeMap( ready: (readyState) { final newIndex = readyState.visibleViews.isEmpty ? -1 : (readyState.focusedViewIndex - 1) % readyState.visibleViews.length; emit( readyState.copyWith(focusedViewIndex: newIndex), ); }, orElse: () {}, ); } void updateSelectionDown() { state.maybeMap( ready: (readyState) { final newIndex = readyState.visibleViews.isEmpty ? -1 : (readyState.focusedViewIndex + 1) % readyState.visibleViews.length; emit( readyState.copyWith(focusedViewIndex: newIndex), ); }, orElse: () {}, ); } void selectPage(ViewPB view) { selectedViewIds.add(view.id); _notifyUpdateSelectedViews(); reset(); } String formatIntputText(final String input) { String result = input; for (final viewId in selectedViewIds) { if (!result.contains(viewId)) { continue; } final view = allViews.firstWhereOrNull((view) => view.id == viewId); if (view != null) { final nonEmptyName = view.name.isEmpty ? LocaleKeys.document_title_placeholder.tr() : view.name; result = result.replaceAll(RegExp(viewId), nonEmptyName); } } return result; } void _notifyUpdateSelectedViews() { final stateCopy = state; final selectedViews = allViews.where((view) => selectedViewIds.contains(view.id)).toList(); emit(ChatInputControlState.updateSelectedViews(selectedViews)); emit(stateCopy); } } @freezed class ChatInputControlState with _$ChatInputControlState { const factory ChatInputControlState.loading() = _Loading; const factory ChatInputControlState.ready({ required List visibleViews, required int focusedViewIndex, }) = _Ready; const factory ChatInputControlState.updateSelectedViews( List selectedViews, ) = _UpdateOneShot; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart ================================================ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'chat_input_file_bloc.freezed.dart'; class ChatInputFileBloc extends Bloc { ChatInputFileBloc({ required this.file, }) : super(const ChatInputFileState()) { on( (event, emit) async { event.when( updateUploadState: (UploadFileIndicator indicator) { emit(state.copyWith(uploadFileIndicator: indicator)); }, ); }, ); } final ChatFile file; } @freezed class ChatInputFileEvent with _$ChatInputFileEvent { const factory ChatInputFileEvent.updateUploadState( UploadFileIndicator indicator, ) = _UpdateUploadState; } @freezed class ChatInputFileState with _$ChatInputFileState { const factory ChatInputFileState({ UploadFileIndicator? uploadFileIndicator, }) = _ChatInputFileState; } @freezed class UploadFileIndicator with _$UploadFileIndicator { const factory UploadFileIndicator.finish() = _Finish; const factory UploadFileIndicator.uploading() = _Uploading; const factory UploadFileIndicator.error(String error) = _Error; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:equatable/equatable.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'chat_member_bloc.freezed.dart'; class ChatMemberBloc extends Bloc { ChatMemberBloc() : super(const ChatMemberState()) { on( (event, emit) async { await event.when( receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) { final members = Map.from(state.members); members[id] = ChatMember(info: memberInfo); emit(state.copyWith(members: members)); }, getMemberInfo: (String userId) async { if (state.members.containsKey(userId)) { // Member info already exists. Debouncing refresh member info from backend would be better. return; } final payload = WorkspaceMemberIdPB( uid: Int64.parseInt(userId), ); await UserEventGetMemberInfo(payload).send().then((result) { result.fold( (member) { if (!isClosed) { add(ChatMemberEvent.receiveMemberInfo(userId, member)); } }, (err) => Log.error("Error getting member info: $err"), ); }); }, ); }, ); } } @freezed class ChatMemberEvent with _$ChatMemberEvent { const factory ChatMemberEvent.getMemberInfo( String userId, ) = _GetMemberInfo; const factory ChatMemberEvent.receiveMemberInfo( String id, WorkspaceMemberPB memberInfo, ) = _ReceiveMemberInfo; } @freezed class ChatMemberState with _$ChatMemberState { const factory ChatMemberState({ @Default({}) Map members, }) = _ChatMemberState; } class ChatMember extends Equatable { ChatMember({ required this.info, }); final DateTime _date = DateTime.now(); final WorkspaceMemberPB info; @override List get props => [_date, info]; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_handler.dart ================================================ import 'dart:collection'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:collection/collection.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:nanoid/nanoid.dart'; import 'chat_entity.dart'; import 'chat_message_stream.dart'; /// Returns current Unix timestamp (seconds since epoch) int timestamp() { return DateTime.now().millisecondsSinceEpoch ~/ 1000; } /// Handles message creation and manipulation for the chat system class ChatMessageHandler { ChatMessageHandler({ required this.chatId, required this.userId, required this.chatController, }); final String chatId; final String userId; final ChatController chatController; /// Maps real message IDs to temporary streaming message IDs final HashMap _temporaryMessageIDMap = HashMap(); /// Gets the effective message ID from the temporary map String getEffectiveMessageId(String messageId) { return _temporaryMessageIDMap.entries .firstWhereOrNull((entry) => entry.value == messageId) ?.key ?? messageId; } String answerStreamMessageId = ''; String questionStreamMessageId = ''; /// Create a message from ChatMessagePB object Message createTextMessage(ChatMessagePB message) { String messageId = message.messageId.toString(); /// If the message id is in the temporary map, we will use the previous fake message id if (_temporaryMessageIDMap.containsKey(messageId)) { messageId = _temporaryMessageIDMap[messageId]!; } final metadata = message.metadata == 'null' ? '[]' : message.metadata; return TextMessage( author: User(id: message.authorId), id: messageId, text: message.content, createdAt: message.createdAt.toDateTime(), metadata: { messageRefSourceJsonStringKey: metadata, }, ); } /// Create a streaming answer message Message createAnswerStreamMessage({ required AnswerStream stream, required Int64 questionMessageId, String? fakeQuestionMessageId, }) { answerStreamMessageId = fakeQuestionMessageId == null ? (questionMessageId + 1).toString() : "${fakeQuestionMessageId}_ans"; return TextMessage( id: answerStreamMessageId, text: '', author: User(id: "streamId:${nanoid()}"), metadata: { "$AnswerStream": stream, messageQuestionIdKey: questionMessageId, "chatId": chatId, }, createdAt: DateTime.now(), ); } /// Create a streaming question message Message createQuestionStreamMessage( QuestionStream stream, Map? sentMetadata, ) { final now = DateTime.now(); questionStreamMessageId = timestamp().toString(); return TextMessage( author: User(id: userId), metadata: { "$QuestionStream": stream, "chatId": chatId, if (sentMetadata != null) messageChatFileListKey: sentMetadata[messageChatFileListKey], }, id: questionStreamMessageId, createdAt: now, text: '', ); } /// Clear error messages from the chat void clearErrorMessages() { final errorMessages = chatController.messages .where( (message) => onetimeMessageTypeFromMeta(message.metadata) == OnetimeShotType.error, ) .toList(); for (final message in errorMessages) { chatController.remove(message); } } /// Clear related questions from the chat void clearRelatedQuestions() { final relatedQuestionMessages = chatController.messages .where( (message) => onetimeMessageTypeFromMeta(message.metadata) == OnetimeShotType.relatedQuestion, ) .toList(); for (final message in relatedQuestionMessages) { chatController.remove(message); } } /// Checks if a message is a one-time message bool isOneTimeMessage(Message message) { return message.metadata != null && message.metadata!.containsKey(onetimeShotType); } /// Get the oldest message that is not a one-time message Message? getOldestMessage() { return chatController.messages .firstWhereOrNull((message) => !isOneTimeMessage(message)); } /// Add a message to the temporary ID map when receiving from server void processReceivedMessage(ChatMessagePB pb) { // 3 means message response from AI if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) { _temporaryMessageIDMap.putIfAbsent( pb.messageId.toString(), () => answerStreamMessageId, ); answerStreamMessageId = ''; } // 1 means message response from User if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) { _temporaryMessageIDMap.putIfAbsent( pb.messageId.toString(), () => questionStreamMessageId, ); questionStreamMessageId = ''; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_height_manager.dart ================================================ import 'dart:math'; import 'package:universal_platform/universal_platform.dart'; class MessageHeightConstants { static const String answerSuffix = '_ans'; static const String withoutMinHeightSuffix = '_without_min_height'; // This offset comes from the chat input box height + navigation bar height // It's used to calculate the minimum height for answer messages // // navigation bar height + last user message height // + last AI message height + chat input box height = screen height static const double defaultDesktopScreenOffset = 220.0; static const double defaultMobileScreenOffset = 304.0; static const double relatedQuestionOffset = 72.0; } class ChatMessageHeightManager { factory ChatMessageHeightManager() => _instance; ChatMessageHeightManager._(); static final ChatMessageHeightManager _instance = ChatMessageHeightManager._(); final Map _heightCache = {}; double get defaultScreenOffset { if (UniversalPlatform.isMobile) { return MessageHeightConstants.defaultMobileScreenOffset; } return MessageHeightConstants.defaultDesktopScreenOffset; } /// Cache a message height void cacheHeight({ required String messageId, required double height, }) { if (messageId.isEmpty || height <= 0) { assert(false, 'messageId or height is invalid'); return; } _heightCache[messageId] = height; } void cacheWithoutMinHeight({ required String messageId, required double height, }) { if (messageId.isEmpty || height <= 0) { assert(false, 'messageId or height is invalid'); return; } _heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix] = height; } double? getCachedHeight({ required String messageId, }) { if (messageId.isEmpty) return null; final height = _heightCache[messageId]; return height; } double? getCachedWithoutMinHeight({ required String messageId, }) { if (messageId.isEmpty) return null; final height = _heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix]; return height; } /// Calculate minimum height for AI answer messages /// /// For the user message, we don't need to calculate the minimum height double calculateMinHeight({ required String messageId, required double screenHeight, }) { if (!isAnswerMessage(messageId)) return 0.0; final originalMessageId = getOriginalMessageId( messageId: messageId, ); final cachedHeight = getCachedHeight( messageId: originalMessageId, ); if (cachedHeight == null) { return 0.0; } final calculatedHeight = screenHeight - cachedHeight - defaultScreenOffset; return max(calculatedHeight, 0.0); } /// Calculate minimum height for related question messages /// /// For the user message, we don't need to calculate the minimum height double calculateRelatedQuestionMinHeight({ required String messageId, }) { final cacheHeight = getCachedHeight( messageId: messageId, ); final cacheHeightWithoutMinHeight = getCachedWithoutMinHeight( messageId: messageId, ); double minHeight = 0; if (cacheHeight != null && cacheHeightWithoutMinHeight != null) { minHeight = cacheHeight - cacheHeightWithoutMinHeight - MessageHeightConstants.relatedQuestionOffset; } minHeight = max(minHeight, 0); return minHeight; } bool isAnswerMessage(String messageId) { return messageId.endsWith(MessageHeightConstants.answerSuffix); } /// Get the original message ID from an answer message ID /// /// Answer message ID is like: "message_id_ans" /// Original message ID is like: "message_id" String getOriginalMessageId({ required String messageId, }) { if (!isAnswerMessage(messageId)) { return messageId; } return messageId.replaceAll(MessageHeightConstants.answerSuffix, ''); } void removeFromCache({ required String messageId, }) { if (messageId.isEmpty) return; _heightCache.remove(messageId); final answerMessageId = messageId + MessageHeightConstants.answerSuffix; _heightCache.remove(answerMessageId); } void clearCache() { _heightCache.clear(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'chat_notification.dart'; typedef ChatMessageCallback = void Function(ChatMessagePB message); typedef ChatErrorMessageCallback = void Function(ChatMessageErrorPB message); typedef LatestMessageCallback = void Function(ChatMessageListPB list); typedef PrevMessageCallback = void Function(ChatMessageListPB list); class ChatMessageListener { ChatMessageListener({required this.chatId}) { _parser = ChatNotificationParser(id: chatId, callback: _callback); _subscription = RustStreamReceiver.listen( (observable) => _parser?.parse(observable), ); } final String chatId; StreamSubscription? _subscription; ChatNotificationParser? _parser; ChatMessageCallback? chatMessageCallback; ChatErrorMessageCallback? chatErrorMessageCallback; LatestMessageCallback? latestMessageCallback; PrevMessageCallback? prevMessageCallback; void Function()? finishStreamingCallback; void start({ ChatMessageCallback? chatMessageCallback, ChatErrorMessageCallback? chatErrorMessageCallback, LatestMessageCallback? latestMessageCallback, PrevMessageCallback? prevMessageCallback, void Function()? finishStreamingCallback, }) { this.chatMessageCallback = chatMessageCallback; this.chatErrorMessageCallback = chatErrorMessageCallback; this.latestMessageCallback = latestMessageCallback; this.prevMessageCallback = prevMessageCallback; this.finishStreamingCallback = finishStreamingCallback; } void _callback( ChatNotification ty, FlowyResult result, ) { result.map((r) { switch (ty) { case ChatNotification.DidReceiveChatMessage: chatMessageCallback?.call(ChatMessagePB.fromBuffer(r)); break; case ChatNotification.StreamChatMessageError: chatErrorMessageCallback?.call(ChatMessageErrorPB.fromBuffer(r)); break; case ChatNotification.DidLoadLatestChatMessage: latestMessageCallback?.call(ChatMessageListPB.fromBuffer(r)); break; case ChatNotification.DidLoadPrevChatMessage: prevMessageCallback?.call(ChatMessageListPB.fromBuffer(r)); break; case ChatNotification.FinishStreaming: finishStreamingCallback?.call(); break; default: break; } }); } Future stop() async { await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:nanoid/nanoid.dart'; /// Indicate file source from appflowy document const appflowySource = "appflowy"; List fileListFromMessageMetadata( Map? map, ) { final List metadata = []; if (map != null) { for (final entry in map.entries) { if (entry.value is ChatFile) { metadata.add(entry.value); } } } return metadata; } List chatFilesFromMetadataString(String? s) { if (s == null || s.isEmpty || s == "null") { return []; } final metadataJson = jsonDecode(s); if (metadataJson is Map) { final file = chatFileFromMap(metadataJson); if (file != null) { return [file]; } else { return []; } } else if (metadataJson is List) { return metadataJson .map((e) => e as Map) .map(chatFileFromMap) .where((file) => file != null) .cast() .toList(); } else { Log.error("Invalid metadata: $metadataJson"); return []; } } ChatFile? chatFileFromMap(Map? map) { if (map == null) return null; final filePath = map['source'] as String?; final fileName = map['name'] as String?; if (filePath == null || fileName == null) { return null; } return ChatFile.fromFilePath(filePath); } class MetadataCollection { MetadataCollection({ required this.sources, this.progress, }); final List sources; final AIChatProgress? progress; } MetadataCollection parseMetadata(String? s) { if (s == null || s.trim().isEmpty || s.toLowerCase() == "null") { return MetadataCollection(sources: []); } final List metadata = []; AIChatProgress? progress; try { final dynamic decodedJson = jsonDecode(s); if (decodedJson == null) { return MetadataCollection(sources: []); } void processMap(Map map) { if (map.containsKey("step") && map["step"] != null) { progress = AIChatProgress.fromJson(map); } else if (map.containsKey("id") && map["id"] != null) { metadata.add(ChatMessageRefSource.fromJson(map)); } else { Log.info("Unsupported metadata format: $map"); } } if (decodedJson is Map) { processMap(decodedJson); } else if (decodedJson is List) { for (final element in decodedJson) { if (element is Map) { processMap(element); } else { Log.error("Invalid metadata element: $element"); } } } else { Log.error("Invalid metadata format: $decodedJson"); } } catch (e, stacktrace) { Log.error("Failed to parse metadata: $e, input: $s"); Log.debug(stacktrace.toString()); } return MetadataCollection(sources: metadata, progress: progress); } Future> metadataPBFromMetadata( Map? map, ) async { if (map == null) return []; final List metadata = []; for (final value in map.values) { switch (value) { case ViewPB _ when value.layout.isDocumentView: final payload = OpenDocumentPayloadPB(documentId: value.id); await DocumentEventGetDocumentText(payload).send().fold( (pb) { metadata.add( ChatMessageMetaPB( id: value.id, name: value.name, data: pb.text, loaderType: ContextLoaderTypePB.Txt, source: appflowySource, ), ); }, (err) => Log.error('Failed to get document text: $err'), ); break; case ChatFile( filePath: final filePath, fileName: final fileName, fileType: final fileType, ): metadata.add( ChatMessageMetaPB( id: nanoid(8), name: fileName, data: filePath, loaderType: fileType, source: filePath, ), ); break; } } return metadata; } List chatFilesFromMessageMetadata( Map? map, ) { final List metadata = []; if (map != null) { for (final entry in map.entries) { if (entry.value is ChatFile) { metadata.add(entry.value); } } } return metadata; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; import 'dart:isolate'; import 'package:appflowy/ai/service/ai_entities.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:json_annotation/json_annotation.dart'; part 'chat_message_stream.g.dart'; /// A stream that receives answer events from an isolate or external process. /// It caches events that might occur before a listener is attached. class AnswerStream { AnswerStream() { _port.handler = _controller.add; _subscription = _controller.stream.listen( _handleEvent, onDone: _onDoneCallback, onError: _handleError, ); } final RawReceivePort _port = RawReceivePort(); final StreamController _controller = StreamController.broadcast(); late StreamSubscription _subscription; bool _hasStarted = false; bool _aiLimitReached = false; bool _aiImageLimitReached = false; String? _error; String _text = ""; // Callbacks void Function(String text)? _onData; void Function()? _onStart; void Function()? _onEnd; void Function(String error)? _onError; void Function()? _onLocalAIInitializing; void Function()? _onAIResponseLimit; void Function()? _onAIImageResponseLimit; void Function(String message)? _onAIMaxRequired; void Function(MetadataCollection metadata)? _onMetadata; void Function(AIFollowUpData)? _onAIFollowUp; // Caches for events that occur before listen() is called. final List _pendingAIMaxRequiredEvents = []; bool _pendingLocalAINotReady = false; int get nativePort => _port.sendPort.nativePort; bool get hasStarted => _hasStarted; bool get aiLimitReached => _aiLimitReached; bool get aiImageLimitReached => _aiImageLimitReached; String? get error => _error; String get text => _text; /// Releases the resources used by the AnswerStream. Future dispose() async { await _controller.close(); await _subscription.cancel(); _port.close(); } /// Handles incoming events from the underlying stream. void _handleEvent(String event) { if (event.startsWith(AIStreamEventPrefix.data)) { _hasStarted = true; final newText = event.substring(AIStreamEventPrefix.data.length); _text += newText; _onData?.call(_text); } else if (event.startsWith(AIStreamEventPrefix.error)) { _error = event.substring(AIStreamEventPrefix.error.length); _onError?.call(_error!); } else if (event.startsWith(AIStreamEventPrefix.metadata)) { final s = event.substring(AIStreamEventPrefix.metadata.length); _onMetadata?.call(parseMetadata(s)); } else if (event == AIStreamEventPrefix.aiResponseLimit) { _aiLimitReached = true; _onAIResponseLimit?.call(); } else if (event == AIStreamEventPrefix.aiImageResponseLimit) { _aiImageLimitReached = true; _onAIImageResponseLimit?.call(); } else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) { final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length); if (_onAIMaxRequired != null) { _onAIMaxRequired!(msg); } else { _pendingAIMaxRequiredEvents.add(msg); } } else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) { if (_onLocalAIInitializing != null) { _onLocalAIInitializing!(); } else { _pendingLocalAINotReady = true; } } else if (event.startsWith(AIStreamEventPrefix.aiFollowUp)) { final s = event.substring(AIStreamEventPrefix.aiFollowUp.length); try { final dynamic jsonData = jsonDecode(s); final data = AIFollowUpData.fromJson(jsonData); _onAIFollowUp?.call(data); } catch (e) { Log.error('Error deserializing AIFollowUp data: $e\nRaw JSON: $s'); } } } void _onDoneCallback() { _onEnd?.call(); } void _handleError(dynamic error) { _error = error.toString(); _onError?.call(_error!); } /// Registers listeners for various events. /// /// If certain events have already occurred (e.g. AI_MAX_REQUIRED or LOCAL_AI_NOT_READY), /// they will be flushed immediately. void listen({ void Function(String text)? onData, void Function()? onStart, void Function()? onEnd, void Function(String error)? onError, void Function()? onAIResponseLimit, void Function()? onAIImageResponseLimit, void Function(String message)? onAIMaxRequired, void Function(MetadataCollection metadata)? onMetadata, void Function()? onLocalAIInitializing, void Function(AIFollowUpData)? onAIFollowUp, }) { _onData = onData; _onStart = onStart; _onEnd = onEnd; _onError = onError; _onAIResponseLimit = onAIResponseLimit; _onAIImageResponseLimit = onAIImageResponseLimit; _onAIMaxRequired = onAIMaxRequired; _onMetadata = onMetadata; _onLocalAIInitializing = onLocalAIInitializing; _onAIFollowUp = onAIFollowUp; // Flush pending AI_MAX_REQUIRED events. if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) { for (final msg in _pendingAIMaxRequiredEvents) { _onAIMaxRequired!(msg); } _pendingAIMaxRequiredEvents.clear(); } // Flush pending LOCAL_AI_NOT_READY event. if (_pendingLocalAINotReady && _onLocalAIInitializing != null) { _onLocalAIInitializing!(); _pendingLocalAINotReady = false; } _onStart?.call(); } } class QuestionStream { QuestionStream() { _port.handler = _controller.add; _subscription = _controller.stream.listen( (event) { if (event.startsWith("data:")) { _hasStarted = true; final newText = event.substring(5); _text += newText; if (_onData != null) { _onData!(_text); } } else if (event.startsWith("message_id:")) { final messageId = event.substring(11); _onMessageId?.call(messageId); } else if (event.startsWith("start_index_file:")) { final indexName = event.substring(17); _onFileIndexStart?.call(indexName); } else if (event.startsWith("end_index_file:")) { final indexName = event.substring(10); _onFileIndexEnd?.call(indexName); } else if (event.startsWith("index_file_error:")) { final indexName = event.substring(16); _onFileIndexError?.call(indexName); } else if (event.startsWith("index_start:")) { _onIndexStart?.call(); } else if (event.startsWith("index_end:")) { _onIndexEnd?.call(); } else if (event.startsWith("done:")) { _onDone?.call(); } else if (event.startsWith("error:")) { _error = event.substring(5); if (_onError != null) { _onError!(_error!); } } }, onError: (error) { if (_onError != null) { _onError!(error.toString()); } }, ); } final RawReceivePort _port = RawReceivePort(); final StreamController _controller = StreamController.broadcast(); late StreamSubscription _subscription; bool _hasStarted = false; String? _error; String _text = ""; // Callbacks void Function(String text)? _onData; void Function(String error)? _onError; void Function(String messageId)? _onMessageId; void Function(String indexName)? _onFileIndexStart; void Function(String indexName)? _onFileIndexEnd; void Function(String indexName)? _onFileIndexError; void Function()? _onIndexStart; void Function()? _onIndexEnd; void Function()? _onDone; int get nativePort => _port.sendPort.nativePort; bool get hasStarted => _hasStarted; String? get error => _error; String get text => _text; Future dispose() async { await _controller.close(); await _subscription.cancel(); _port.close(); } void listen({ void Function(String text)? onData, void Function(String error)? onError, void Function(String messageId)? onMessageId, void Function(String indexName)? onFileIndexStart, void Function(String indexName)? onFileIndexEnd, void Function(String indexName)? onFileIndexFail, void Function()? onIndexStart, void Function()? onIndexEnd, void Function()? onDone, }) { _onData = onData; _onError = onError; _onMessageId = onMessageId; _onFileIndexStart = onFileIndexStart; _onFileIndexEnd = onFileIndexEnd; _onFileIndexError = onFileIndexFail; _onIndexStart = onIndexStart; _onIndexEnd = onIndexEnd; _onDone = onDone; } } @JsonSerializable() class AIFollowUpData { AIFollowUpData({ required this.shouldGenerateRelatedQuestion, }); factory AIFollowUpData.fromJson(Map json) => _$AIFollowUpDataFromJson(json); @JsonKey(name: 'should_generate_related_question') final bool shouldGenerateRelatedQuestion; Map toJson() => _$AIFollowUpDataToJson(this); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/notification_helper.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; class ChatNotificationParser extends NotificationParser { ChatNotificationParser({ super.id, required super.callback, }) : super( tyParser: (ty, source) => source == "Chat" ? ChatNotification.valueOf(ty) : null, errorParser: (bytes) => FlowyError.fromBuffer(bytes), ); } typedef ChatNotificationHandler = Function( ChatNotification ty, FlowyResult result, ); class ChatNotificationListener { ChatNotificationListener({ required String objectId, required ChatNotificationHandler handler, }) : _parser = ChatNotificationParser(id: objectId, callback: handler) { _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); } ChatNotificationParser? _parser; StreamSubscription? _subscription; Future stop() async { _parser = null; await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_message_bloc.dart ================================================ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'chat_select_message_bloc.freezed.dart'; class ChatSelectMessageBloc extends Bloc { ChatSelectMessageBloc({required this.viewNotifier}) : super(ChatSelectMessageState.initial()) { _dispatch(); } final ViewPluginNotifier viewNotifier; void _dispatch() { on( (event, emit) { event.when( enableStartSelectingMessages: () { emit(state.copyWith(enabled: true)); }, toggleSelectingMessages: () { if (state.isSelectingMessages) { emit( state.copyWith( isSelectingMessages: false, selectedMessages: [], ), ); } else { emit(state.copyWith(isSelectingMessages: true)); } }, toggleSelectMessage: (Message message) { if (state.selectedMessages.contains(message)) { emit( state.copyWith( selectedMessages: state.selectedMessages .where((m) => m != message) .toList(), ), ); } else { emit( state.copyWith( selectedMessages: [...state.selectedMessages, message], ), ); } }, selectAllMessages: (List messages) { final filtered = messages.where(isAIMessage).toList(); emit(state.copyWith(selectedMessages: filtered)); }, unselectAllMessages: () { emit(state.copyWith(selectedMessages: const [])); }, reset: () { emit( state.copyWith( isSelectingMessages: false, selectedMessages: [], ), ); }, ); }, ); } bool isMessageSelected(String messageId) => state.selectedMessages.any((m) => m.id == messageId); bool isAIMessage(Message message) { return message.author.id == aiResponseUserId || message.author.id == systemUserId || message.author.id.startsWith("streamId:"); } } @freezed class ChatSelectMessageEvent with _$ChatSelectMessageEvent { const factory ChatSelectMessageEvent.enableStartSelectingMessages() = _EnableStartSelectingMessages; const factory ChatSelectMessageEvent.toggleSelectingMessages() = _ToggleSelectingMessages; const factory ChatSelectMessageEvent.toggleSelectMessage(Message message) = _ToggleSelectMessage; const factory ChatSelectMessageEvent.selectAllMessages( List messages, ) = _SelectAllMessages; const factory ChatSelectMessageEvent.unselectAllMessages() = _UnselectAllMessages; const factory ChatSelectMessageEvent.reset() = _Reset; } @freezed class ChatSelectMessageState with _$ChatSelectMessageState { const factory ChatSelectMessageState({ required bool isSelectingMessages, required List selectedMessages, required bool enabled, }) = _ChatSelectMessageState; factory ChatSelectMessageState.initial() => const ChatSelectMessageState( enabled: false, isSelectingMessages: false, selectedMessages: [], ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_settings_manager.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; /// Manages settings for a chat class ChatSettingsManager { ChatSettingsManager({ required this.chatId, }) : selectedSourcesNotifier = ValueNotifier([]); final String chatId; /// Notifies listeners when selected sources change final ValueNotifier> selectedSourcesNotifier; /// Load settings from backend Future loadSettings() async { final getChatSettingsPayload = AIEventGetChatSettings(ChatId(value: chatId)); await getChatSettingsPayload.send().then((result) { result.fold( (settings) { selectedSourcesNotifier.value = settings.ragIds; }, (err) => Log.error("Failed to load chat settings: $err"), ); }); } /// Update selected sources Future updateSelectedSources(List selectedSourcesIds) async { selectedSourcesNotifier.value = [...selectedSourcesIds]; final payload = UpdateChatSettingsPB( chatId: ChatId(value: chatId), ragIds: selectedSourcesIds, ); await AIEventUpdateChatSettings(payload).send().onFailure(Log.error); } /// Clean up resources void dispose() { selectedSourcesNotifier.dispose(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_stream_manager.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; import 'chat_message_stream.dart'; /// Manages chat streaming operations class ChatStreamManager { ChatStreamManager(this.chatId); final String chatId; AnswerStream? answerStream; QuestionStream? questionStream; /// Dispose of all streams Future dispose() async { await answerStream?.dispose(); answerStream = null; await questionStream?.dispose(); questionStream = null; } /// Prepare streams for a new message Future prepareStreams() async { await dispose(); answerStream = AnswerStream(); questionStream = QuestionStream(); } /// Build the payload for a streaming message StreamChatPayloadPB buildStreamPayload( String message, PredefinedFormat? format, String? promptId, ) { final payload = StreamChatPayloadPB( chatId: chatId, message: message, messageType: ChatMessageTypePB.User, questionStreamPort: Int64(questionStream!.nativePort), answerStreamPort: Int64(answerStream!.nativePort), ); if (format != null) { payload.format = format.toPB(); } if (promptId != null) { payload.promptId = promptId; } return payload; } /// Send a streaming message request to the server Future> sendStreamRequest( String message, PredefinedFormat? format, String? promptId, ) async { final payload = buildStreamPayload(message, format, promptId); return AIEventStreamMessage(payload).send(); } /// Build the payload for regenerating a response RegenerateResponsePB buildRegeneratePayload( Int64 answerMessageId, PredefinedFormat? format, AIModelPB? model, ) { final payload = RegenerateResponsePB( chatId: chatId, answerMessageId: answerMessageId, answerStreamPort: Int64(answerStream!.nativePort), ); if (format != null) { payload.format = format.toPB(); } if (model != null) { payload.model = model; } return payload; } /// Send a request to regenerate a response Future> sendRegenerateRequest( Int64 answerMessageId, PredefinedFormat? format, AIModelPB? model, ) async { final payload = buildRegeneratePayload(answerMessageId, format, model); return AIEventRegenerateResponse(payload).send(); } /// Stop the current streaming message Future stopStream() async { if (answerStream == null) { return; } final payload = StopStreamPB(chatId: chatId); await AIEventStopStream(payload).send(); } /// Check if the answer stream has started bool get hasAnswerStreamStarted => answerStream != null && answerStream!.hasStarted; Future disposeAnswerStream() async { if (answerStream == null) { return; } await answerStream!.dispose(); answerStream = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_cubit.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// ChatUserCubit is responsible for fetching and storing the user profile class ChatUserCubit extends Cubit { ChatUserCubit() : super(ChatUserLoadingState()) { fetchUserProfile(); } /// Fetches the user profile from the AuthService Future fetchUserProfile() async { emit(ChatUserLoadingState()); final userOrFailure = await getIt().getUser(); userOrFailure.fold( (userProfile) => emit(ChatUserSuccessState(userProfile)), (error) => emit(ChatUserFailureState(error)), ); } bool supportSelectSource() { if (state is ChatUserSuccessState) { final userProfile = (state as ChatUserSuccessState).userProfile; if (userProfile.userAuthType == AuthTypePB.Server) { return true; } } return false; } bool isValueWorkspace() { if (state is ChatUserSuccessState) { final userProfile = (state as ChatUserSuccessState).userProfile; return userProfile.workspaceType == WorkspaceTypePB.LocalW && userProfile.userAuthType != AuthTypePB.Local; } return false; } /// Refreshes the user profile data Future refresh() async { await fetchUserProfile(); } } /// Base state class for ChatUserCubit abstract class ChatUserState extends Equatable { const ChatUserState(); @override List get props => []; } /// Loading state when fetching user profile class ChatUserLoadingState extends ChatUserState {} /// Success state when user profile is fetched successfully class ChatUserSuccessState extends ChatUserState { const ChatUserSuccessState(this.userProfile); final UserProfilePB userProfile; @override List get props => [userProfile]; } /// Failure state when fetching user profile fails class ChatUserFailureState extends ChatUserState { const ChatUserFailureState(this.error); final FlowyError error; @override List get props => [error]; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart ================================================ import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'chat_user_message_bloc.freezed.dart'; class ChatUserMessageBloc extends Bloc { ChatUserMessageBloc({ required this.questionStream, required String text, }) : super(ChatUserMessageState.initial(text)) { _dispatch(); _startListening(); } final QuestionStream? questionStream; void _dispatch() { on( (event, emit) { event.when( updateText: (String text) { emit(state.copyWith(text: text)); }, updateMessageId: (String messageId) { emit(state.copyWith(messageId: messageId)); }, receiveError: (String error) {}, updateQuestionState: (QuestionMessageState newState) { emit(state.copyWith(messageState: newState)); }, ); }, ); } void _startListening() { questionStream?.listen( onData: (text) { if (!isClosed) { add(ChatUserMessageEvent.updateText(text)); } }, onMessageId: (messageId) { if (!isClosed) { add(ChatUserMessageEvent.updateMessageId(messageId)); } }, onError: (error) { if (!isClosed) { add(ChatUserMessageEvent.receiveError(error.toString())); } }, onFileIndexStart: (indexName) { Log.debug("index start: $indexName"); }, onFileIndexEnd: (indexName) { Log.info("index end: $indexName"); }, onFileIndexFail: (indexName) { Log.debug("index fail: $indexName"); }, onIndexStart: () { if (!isClosed) { add( const ChatUserMessageEvent.updateQuestionState( QuestionMessageState.indexStart(), ), ); } }, onIndexEnd: () { if (!isClosed) { add( const ChatUserMessageEvent.updateQuestionState( QuestionMessageState.indexEnd(), ), ); } }, onDone: () { if (!isClosed) { add( const ChatUserMessageEvent.updateQuestionState( QuestionMessageState.finish(), ), ); } }, ); } } @freezed class ChatUserMessageEvent with _$ChatUserMessageEvent { const factory ChatUserMessageEvent.updateText(String text) = _UpdateText; const factory ChatUserMessageEvent.updateQuestionState( QuestionMessageState newState, ) = _UpdateQuestionState; const factory ChatUserMessageEvent.updateMessageId(String messageId) = _UpdateMessageId; const factory ChatUserMessageEvent.receiveError(String error) = _ReceiveError; } @freezed class ChatUserMessageState with _$ChatUserMessageState { const factory ChatUserMessageState({ required String text, required String? messageId, required QuestionMessageState messageState, }) = _ChatUserMessageState; factory ChatUserMessageState.initial(String message) => ChatUserMessageState( text: message, messageId: null, messageState: const QuestionMessageState.finish(), ); } @freezed class QuestionMessageState with _$QuestionMessageState { const factory QuestionMessageState.indexFileStart(String fileName) = _IndexFileStart; const factory QuestionMessageState.indexFileEnd(String fileName) = _IndexFileEnd; const factory QuestionMessageState.indexFileFail(String fileName) = _IndexFileFail; const factory QuestionMessageState.indexStart() = _IndexStart; const factory QuestionMessageState.indexEnd() = _IndexEnd; const factory QuestionMessageState.finish() = _Finish; } extension QuestionMessageStateX on QuestionMessageState { bool get isFinish => this is _Finish; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart'; import 'package:appflowy/plugins/ai_chat/chat_page.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AIChatPluginBuilder extends PluginBuilder { @override Plugin build(dynamic data) { if (data is ViewPB) { return AIChatPagePlugin(view: data); } throw FlowyPluginException.invalidData; } @override String get menuName => "AI Chat"; @override FlowySvgData get icon => FlowySvgs.chat_ai_page_s; @override PluginType get pluginType => PluginType.chat; @override ViewLayoutPB get layoutType => ViewLayoutPB.Chat; } class AIChatPluginConfig implements PluginConfig { @override bool get creatable => true; } class AIChatPagePlugin extends Plugin { AIChatPagePlugin({ required ViewPB view, }) : notifier = ViewPluginNotifier(view: view); late final ViewInfoBloc _viewInfoBloc; late final PageAccessLevelBloc _pageAccessLevelBloc; late final _chatMessageSelectorBloc = ChatSelectMessageBloc(viewNotifier: notifier); @override final ViewPluginNotifier notifier; @override PluginWidgetBuilder get widgetBuilder => AIChatPagePluginWidgetBuilder( viewInfoBloc: _viewInfoBloc, pageAccessLevelBloc: _pageAccessLevelBloc, chatMessageSelectorBloc: _chatMessageSelectorBloc, notifier: notifier, ); @override PluginId get id => notifier.view.id; @override PluginType get pluginType => PluginType.chat; @override void init() { _viewInfoBloc = ViewInfoBloc(view: notifier.view) ..add(const ViewInfoEvent.started()); _pageAccessLevelBloc = PageAccessLevelBloc(view: notifier.view) ..add(const PageAccessLevelEvent.initial()); } @override void dispose() { _viewInfoBloc.close(); _pageAccessLevelBloc.close(); _chatMessageSelectorBloc.close(); notifier.dispose(); } } class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder with NavigationItem { AIChatPagePluginWidgetBuilder({ required this.viewInfoBloc, required this.pageAccessLevelBloc, required this.chatMessageSelectorBloc, required this.notifier, }); final ViewInfoBloc viewInfoBloc; final PageAccessLevelBloc pageAccessLevelBloc; final ChatSelectMessageBloc chatMessageSelectorBloc; final ViewPluginNotifier notifier; int? deletedViewIndex; @override String? get viewName => notifier.view.nameOrDefault; @override Widget get leftBarItem { return BlocProvider.value( value: pageAccessLevelBloc, child: ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view), ); } @override Widget tabBarItem(String pluginId, [bool shortForm = false]) => ViewTabBarItem(view: notifier.view, shortForm: shortForm); @override Widget buildWidget({ required PluginContext context, required bool shrinkWrap, Map? data, }) { notifier.isDeleted.addListener(_onDeleted); if (context.userProfile == null) { Log.error("User profile is null when opening AI Chat plugin"); return const SizedBox(); } return MultiBlocProvider( providers: [ BlocProvider.value(value: chatMessageSelectorBloc), BlocProvider.value(value: viewInfoBloc), BlocProvider.value(value: pageAccessLevelBloc), ], child: AIChatPage( userProfile: context.userProfile!, key: ValueKey(notifier.view.id), view: notifier.view, onDeleted: () => context.onDeleted?.call(notifier.view, deletedViewIndex), ), ); } void _onDeleted() { final deletedView = notifier.isDeleted.value; if (deletedView != null && deletedView.hasIndex()) { deletedViewIndex = deletedView.index; } } @override List get navigationItems => [this]; @override EdgeInsets get contentPadding => EdgeInsets.zero; @override Widget? get rightBarItem => MultiBlocProvider( providers: [ BlocProvider.value(value: viewInfoBloc), BlocProvider.value(value: chatMessageSelectorBloc), ], child: BlocBuilder( builder: (context, state) { if (state.isSelectingMessages) { return const SizedBox.shrink(); } return Row( mainAxisSize: MainAxisSize.min, children: [ ViewFavoriteButton( key: ValueKey('favorite_button_${notifier.view.id}'), view: notifier.view, ), const HSpace(4), MoreViewActions( key: ValueKey(notifier.view.id), view: notifier.view, customActions: [ CustomViewAction( view: notifier.view, disabled: !state.enabled, leftIcon: FlowySvgs.ai_add_to_page_s, label: LocaleKeys.moreAction_saveAsNewPage.tr(), tooltipMessage: state.enabled ? null : LocaleKeys.moreAction_saveAsNewPageDisabled.tr(), onTap: () { chatMessageSelectorBloc.add( const ChatSelectMessageEvent .toggleSelectingMessages(), ); }, ), ViewAction( type: ViewMoreActionType.divider, view: notifier.view, ), ], ), ], ); }, ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'application/chat_bloc.dart'; import 'application/chat_member_bloc.dart'; class AIChatPage extends StatelessWidget { const AIChatPage({ super.key, required this.view, required this.onDeleted, required this.userProfile, }); final ViewPB view; final VoidCallback onDeleted; final UserProfilePB userProfile; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ /// [ChatBloc] is used to handle chat messages including send/receive message BlocProvider( create: (_) => ChatBloc( chatId: view.id, userId: userProfile.id.toString(), ), ), /// [AIPromptInputBloc] is used to handle the user prompt BlocProvider( create: (_) => AIPromptInputBloc( objectId: view.id, predefinedFormat: PredefinedFormat( imageFormat: ImageFormat.text, textFormat: TextFormat.bulletList, ), ), ), BlocProvider(create: (_) => ChatMemberBloc()), ], child: Builder( builder: (context) { return DropTarget( onDragDone: (DropDoneDetails detail) async { if (context.read().state.supportChatWithFile) { for (final file in detail.files) { context .read() .add(AIPromptInputEvent.attachFile(file.path, file.name)); } } }, child: FocusScope( onKeyEvent: (focusNode, event) { if (event is! KeyUpEvent) { return KeyEventResult.ignored; } if (event.logicalKey == LogicalKeyboardKey.escape || event.logicalKey == LogicalKeyboardKey.keyC && HardwareKeyboard.instance.isControlPressed) { final chatBloc = context.read(); if (!chatBloc.state.promptResponseState.isReady) { chatBloc.add(ChatEvent.stopStream()); return KeyEventResult.handled; } } return KeyEventResult.ignored; }, child: ChatContentPage( view: view, userProfile: userProfile, ), ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart ================================================ // ignore_for_file: implementation_imports import 'dart:async'; import 'package:appflowy/util/debounce.dart'; import 'package:appflowy_backend/log.dart'; import 'package:diffutil_dart/diffutil.dart' as diffutil; import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_chat_ui/src/scroll_to_bottom.dart'; import 'package:flutter_chat_ui/src/utils/message_list_diff.dart'; import 'package:provider/provider.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../application/chat_message_height_manager.dart'; import 'widgets/message_height_calculator.dart'; class ChatAnimatedList extends StatefulWidget { const ChatAnimatedList({ super.key, required this.scrollController, required this.itemBuilder, this.insertAnimationDuration = const Duration(milliseconds: 250), this.removeAnimationDuration = const Duration(milliseconds: 250), this.scrollToEndAnimationDuration = const Duration(milliseconds: 250), this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250), this.bottomPadding = 8, this.onLoadPreviousMessages, this.scrollBottomPadding = 440, }); final ScrollController scrollController; final ChatItem itemBuilder; final Duration insertAnimationDuration; final Duration removeAnimationDuration; final Duration scrollToEndAnimationDuration; final Duration scrollToBottomAppearanceDelay; final double? bottomPadding; final VoidCallback? onLoadPreviousMessages; final double scrollBottomPadding; @override State createState() => _ChatAnimatedListState(); } class _ChatAnimatedListState extends State with SingleTickerProviderStateMixin { late final ChatController chatController = Provider.of( context, listen: false, ); late List oldList; late StreamSubscription operationsSubscription; late final AnimationController scrollToBottomController; late final Animation scrollToBottomAnimation; Timer? scrollToBottomShowTimer; final ScrollOffsetController scrollOffsetController = ScrollOffsetController(); final ItemScrollController itemScrollController = ItemScrollController(); final ScrollOffsetListener scrollOffsetListener = ScrollOffsetListener.create(); final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create(); int lastUserMessageIndex = 0; bool isScrollingToBottom = false; final loadPreviousMessagesDebounce = Debounce( duration: const Duration(milliseconds: 200), ); int initialScrollIndex = 0; double initialAlignment = 1.0; List messages = []; final ChatMessageHeightManager heightManager = ChatMessageHeightManager(); @override void initState() { super.initState(); // TODO: Add assert for messages having same id oldList = List.from(chatController.messages); operationsSubscription = chatController.operationsStream.listen((event) { setState(() { messages = chatController.messages; }); switch (event.type) { case ChatOperationType.insert: assert( event.index != null, 'Index must be provided when inserting a message.', ); assert( event.message != null, 'Message must be provided when inserting a message.', ); _onInserted(event.index!, event.message!); oldList = List.from(chatController.messages); break; case ChatOperationType.remove: assert( event.index != null, 'Index must be provided when removing a message.', ); assert( event.message != null, 'Message must be provided when removing a message.', ); _onRemoved(event.index!, event.message!); oldList = List.from(chatController.messages); break; case ChatOperationType.set: final newList = chatController.messages; final updates = diffutil .calculateDiff( MessageListDiff(oldList, newList), ) .getUpdatesWithData(); for (var i = updates.length - 1; i >= 0; i--) { _onDiffUpdate(updates.elementAt(i)); } oldList = List.from(newList); break; default: break; } }); messages = chatController.messages; scrollToBottomController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); scrollToBottomAnimation = CurvedAnimation( parent: scrollToBottomController, curve: Curves.easeInOut, ); itemPositionsListener.itemPositions.addListener(() { _handleToggleScrollToBottom(); }); itemPositionsListener.itemPositions.addListener(() { _handleLoadPreviousMessages(); }); } @override void dispose() { scrollToBottomShowTimer?.cancel(); scrollToBottomController.dispose(); operationsSubscription.cancel(); _clearMessageHeightCache(); super.dispose(); } @override Widget build(BuildContext context) { final builders = context.watch(); // A trick to avoid the first message being scrolled to the top initialScrollIndex = messages.length; initialAlignment = 1.0; if (messages.length <= 2) { initialScrollIndex = 0; initialAlignment = 0.0; } final Widget child = Stack( children: [ ScrollablePositionedList.builder( scrollOffsetController: scrollOffsetController, itemScrollController: itemScrollController, initialScrollIndex: initialScrollIndex, initialAlignment: initialAlignment, scrollOffsetListener: scrollOffsetListener, itemPositionsListener: itemPositionsListener, physics: ClampingScrollPhysics(), shrinkWrap: true, // the extra item is a vertical padding. itemCount: messages.length + 1, itemBuilder: (context, index) { if (index < 0 || index > messages.length) { Log.error('[chat animation list] index out of range: $index'); return const SizedBox.shrink(); } if (index == messages.length) { return const SizedBox.shrink(); } final message = messages[index]; return MessageHeightCalculator( messageId: message.id, onHeightMeasured: _cacheMessageHeight, child: widget.itemBuilder( context, Tween(begin: 1, end: 1).animate( CurvedAnimation( parent: scrollToBottomController, curve: Curves.easeInOut, ), ), message, ), ); }, ), builders.scrollToBottomBuilder?.call( context, scrollToBottomAnimation, _handleScrollToBottom, ) ?? ScrollToBottom( animation: scrollToBottomAnimation, onPressed: _handleScrollToBottom, ), ], ); return child; } Future _scrollLastUserMessageToTop() async { final user = Provider.of(context, listen: false); final lastUserMessageIndex = messages.lastIndexWhere( (message) => message.author.id == user.id, ); // waiting for the ai answer message to be inserted if (lastUserMessageIndex == -1 || lastUserMessageIndex + 1 >= messages.length) { return; } if (this.lastUserMessageIndex != lastUserMessageIndex) { // scroll the current message to the top await itemScrollController.scrollTo( index: lastUserMessageIndex, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } this.lastUserMessageIndex = lastUserMessageIndex; } Future _handleScrollToBottom() async { isScrollingToBottom = true; scrollToBottomShowTimer?.cancel(); await scrollToBottomController.reverse(); await itemScrollController.scrollTo( index: messages.length + 1, alignment: 1.0, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); isScrollingToBottom = false; } void _handleToggleScrollToBottom() { if (isScrollingToBottom) { return; } // get the max item final sortedItems = itemPositionsListener.itemPositions.value.toList() ..sort((a, b) => a.index.compareTo(b.index)); final maxItem = sortedItems.lastOrNull; if (maxItem == null) { return; } if (maxItem.index > messages.length - 1 || (maxItem.index == messages.length - 1 && maxItem.itemTrailingEdge <= 1.01)) { scrollToBottomShowTimer?.cancel(); scrollToBottomController.reverse(); return; } scrollToBottomShowTimer?.cancel(); scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () { if (mounted) { scrollToBottomController.forward(); } }); } void _handleLoadPreviousMessages() { final sortedItems = itemPositionsListener.itemPositions.value.toList() ..sort((a, b) => a.index.compareTo(b.index)); final minItem = sortedItems.firstOrNull; if (minItem == null || minItem.index > 0 || minItem.itemLeadingEdge < 0) { return; } loadPreviousMessagesDebounce.call( () { widget.onLoadPreviousMessages?.call(); }, ); } void _cacheMessageHeight(String messageId, double height) { heightManager.cacheHeight(messageId: messageId, height: height); } void _clearMessageHeightCache() { heightManager.clearCache(); } Future _onInserted(final int position, final Message data) async { // scroll the last user message to the top if it's the last message if (position == oldList.length) { await _scrollLastUserMessageToTop(); } } void _onRemoved(final int position, final Message data) { // Clean up cached height for removed message heightManager.removeFromCache(messageId: data.id); } void _onDiffUpdate(diffutil.DataDiffUpdate update) { // do nothing } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list_reversed.dart ================================================ // ignore_for_file: implementation_imports import 'dart:async'; import 'dart:math'; import 'package:diffutil_dart/diffutil.dart' as diffutil; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_chat_ui/src/scroll_to_bottom.dart'; import 'package:flutter_chat_ui/src/utils/message_list_diff.dart'; import 'package:provider/provider.dart'; class ChatAnimatedListReversed extends StatefulWidget { const ChatAnimatedListReversed({ super.key, required this.scrollController, required this.itemBuilder, this.insertAnimationDuration = const Duration(milliseconds: 250), this.removeAnimationDuration = const Duration(milliseconds: 250), this.scrollToEndAnimationDuration = const Duration(milliseconds: 250), this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250), this.bottomPadding = 8, this.onLoadPreviousMessages, }); final ScrollController scrollController; final ChatItem itemBuilder; final Duration insertAnimationDuration; final Duration removeAnimationDuration; final Duration scrollToEndAnimationDuration; final Duration scrollToBottomAppearanceDelay; final double? bottomPadding; final VoidCallback? onLoadPreviousMessages; @override ChatAnimatedListReversedState createState() => ChatAnimatedListReversedState(); } class ChatAnimatedListReversedState extends State with SingleTickerProviderStateMixin { final GlobalKey _listKey = GlobalKey(); late final ChatController _chatController = Provider.of( context, listen: false, ); late List _oldList; late StreamSubscription _operationsSubscription; late final AnimationController _scrollToBottomController; late final Animation _scrollToBottomAnimation; Timer? _scrollToBottomShowTimer; bool _userHasScrolled = false; bool _isScrollingToBottom = false; String _lastInsertedMessageId = ''; @override void initState() { super.initState(); // TODO: Add assert for messages having same id _oldList = List.from(_chatController.messages); _operationsSubscription = _chatController.operationsStream.listen((event) { switch (event.type) { case ChatOperationType.insert: assert( event.index != null, 'Index must be provided when inserting a message.', ); assert( event.message != null, 'Message must be provided when inserting a message.', ); _onInserted(event.index!, event.message!); _oldList = List.from(_chatController.messages); break; case ChatOperationType.remove: assert( event.index != null, 'Index must be provided when removing a message.', ); assert( event.message != null, 'Message must be provided when removing a message.', ); _onRemoved(event.index!, event.message!); _oldList = List.from(_chatController.messages); break; case ChatOperationType.set: final newList = _chatController.messages; final updates = diffutil .calculateDiff( MessageListDiff(_oldList, newList), ) .getUpdatesWithData(); for (var i = updates.length - 1; i >= 0; i--) { _onDiffUpdate(updates.elementAt(i)); } _oldList = List.from(newList); break; default: break; } }); _scrollToBottomController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); _scrollToBottomAnimation = CurvedAnimation( parent: _scrollToBottomController, curve: Curves.easeInOut, ); widget.scrollController.addListener(_handleLoadPreviousMessages); WidgetsBinding.instance.addPostFrameCallback((_) { _handleLoadPreviousMessages(); }); } @override void dispose() { super.dispose(); _scrollToBottomShowTimer?.cancel(); _scrollToBottomController.dispose(); _operationsSubscription.cancel(); widget.scrollController.removeListener(_handleLoadPreviousMessages); } @override Widget build(BuildContext context) { final builders = context.watch(); return NotificationListener( onNotification: (notification) { if (notification is UserScrollNotification) { // When user scrolls up, save it to `_userHasScrolled` if (notification.direction == ScrollDirection.reverse) { _userHasScrolled = true; } else { // When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false if (notification.metrics.pixels == notification.metrics.minScrollExtent) { _userHasScrolled = false; } } } if (notification is ScrollUpdateNotification) { _handleToggleScrollToBottom(); } // Allow other listeners to get the notification return false; }, child: Stack( children: [ CustomScrollView( reverse: true, controller: widget.scrollController, slivers: [ SliverPadding( padding: EdgeInsets.only( top: widget.bottomPadding ?? 0, ), ), SliverAnimatedList( key: _listKey, initialItemCount: _chatController.messages.length, itemBuilder: ( BuildContext context, int index, Animation animation, ) { final message = _chatController.messages[ max(_chatController.messages.length - 1 - index, 0)]; return widget.itemBuilder( context, animation, message, ); }, ), ], ), builders.scrollToBottomBuilder?.call( context, _scrollToBottomAnimation, _handleScrollToBottom, ) ?? ScrollToBottom( animation: _scrollToBottomAnimation, onPressed: _handleScrollToBottom, ), ], ), ); } void _subsequentScrollToEnd(Message data) async { final user = Provider.of(context, listen: false); // We only want to scroll to the bottom if user has not scrolled up // or if the message is sent by the current user. if (data.id == _lastInsertedMessageId && widget.scrollController.offset > widget.scrollController.position.minScrollExtent && (user.id == data.author.id && _userHasScrolled)) { if (widget.scrollToEndAnimationDuration == Duration.zero) { widget.scrollController .jumpTo(widget.scrollController.position.minScrollExtent); } else { await widget.scrollController.animateTo( widget.scrollController.position.minScrollExtent, duration: widget.scrollToEndAnimationDuration, curve: Curves.linearToEaseOut, ); } if (!widget.scrollController.hasClients || !mounted) return; // Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768 // we need an additional jump to the end. Sometimes Flutter // will not scroll to the very end. Sometimes it will not scroll to the // very end even with this, so this is something that needs to be // addressed by the Flutter team. // // Additionally here we have a check for the message id, because // if new message arrives in the meantime it will trigger another // scroll to the end animation, making this logic redundant. if (data.id == _lastInsertedMessageId && widget.scrollController.offset > widget.scrollController.position.minScrollExtent && (user.id == data.author.id && _userHasScrolled)) { widget.scrollController .jumpTo(widget.scrollController.position.minScrollExtent); } } } void _scrollToEnd(Message data) { WidgetsBinding.instance.addPostFrameCallback( (_) { if (!widget.scrollController.hasClients || !mounted) return; _subsequentScrollToEnd(data); }, ); } void _handleScrollToBottom() { _isScrollingToBottom = true; _scrollToBottomController.reverse(); WidgetsBinding.instance.addPostFrameCallback((_) async { if (!widget.scrollController.hasClients || !mounted) return; if (widget.scrollToEndAnimationDuration == Duration.zero) { widget.scrollController .jumpTo(widget.scrollController.position.minScrollExtent); } else { await widget.scrollController.animateTo( widget.scrollController.position.minScrollExtent, duration: widget.scrollToEndAnimationDuration, curve: Curves.linearToEaseOut, ); } if (!widget.scrollController.hasClients || !mounted) return; if (widget.scrollController.offset < widget.scrollController.position.minScrollExtent) { widget.scrollController.jumpTo( widget.scrollController.position.minScrollExtent, ); } _isScrollingToBottom = false; }); } void _handleToggleScrollToBottom() { if (_isScrollingToBottom) { return; } _scrollToBottomShowTimer?.cancel(); if (widget.scrollController.offset > widget.scrollController.position.minScrollExtent) { _scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () { if (mounted) { _scrollToBottomController.forward(); } }); } else { if (_scrollToBottomController.status != AnimationStatus.completed) { _scrollToBottomController.stop(); } _scrollToBottomController.reverse(); } } void _onInserted(final int position, final Message data) { // There is a scroll notification listener the controls the // `_userHasScrolled` variable. // // If for some reason `_userHasScrolled` is true and the user is not at the // bottom of the list, set `_userHasScrolled` to false so that the scroll // animation is triggered. if (position == 0 && _userHasScrolled && widget.scrollController.offset > widget.scrollController.position.minScrollExtent) { _userHasScrolled = false; } _listKey.currentState!.insertItem( 0, duration: widget.insertAnimationDuration, ); // Used later to trigger scroll to end only for the last inserted message. _lastInsertedMessageId = data.id; if (position == _oldList.length) { _scrollToEnd(data); } } void _onRemoved(final int position, final Message data) { final visualPosition = max(_oldList.length - position - 1, 0); _listKey.currentState!.removeItem( visualPosition, (context, animation) => widget.itemBuilder( context, animation, data, isRemoved: true, ), duration: widget.removeAnimationDuration, ); } void _onChanged(int position, Message oldData, Message newData) { _onRemoved(position, oldData); _listKey.currentState!.insertItem( max(_oldList.length - position - 1, 0), duration: widget.insertAnimationDuration, ); } void _onDiffUpdate(diffutil.DataDiffUpdate update) { update.when( insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data), remove: (pos, data) => _onRemoved(pos, data), change: (pos, oldData, newData) => _onChanged(pos, oldData, newData), move: (_, __, ___) => throw UnimplementedError('unused'), ); } void _handleLoadPreviousMessages() { if (widget.scrollController.offset >= widget.scrollController.position.maxScrollExtent) { widget.onLoadPreviousMessages?.call(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/built_in_svgs.dart'; import 'package:appflowy/util/color_generator/color_generator.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:string_validator/string_validator.dart'; import 'layout_define.dart'; class ChatAIAvatar extends StatelessWidget { const ChatAIAvatar({super.key}); @override Widget build(BuildContext context) { return Container( width: DesktopAIChatSizes.avatarSize, height: DesktopAIChatSizes.avatarSize, clipBehavior: Clip.hardEdge, decoration: const BoxDecoration(shape: BoxShape.circle), foregroundDecoration: ShapeDecoration( shape: CircleBorder( side: BorderSide(color: Theme.of(context).colorScheme.outline), ), ), child: const CircleAvatar( backgroundColor: Colors.transparent, child: FlowySvg( FlowySvgs.app_logo_s, size: Size.square(16), blendMode: null, ), ), ); } } class ChatUserAvatar extends StatelessWidget { const ChatUserAvatar({ super.key, required this.iconUrl, required this.name, this.defaultName, }); final String iconUrl; final String name; final String? defaultName; @override Widget build(BuildContext context) { late final Widget child; if (iconUrl.isEmpty) { child = _buildEmptyAvatar(context); } else if (isURL(iconUrl)) { child = _buildUrlAvatar(context); } else { child = _buildEmojiAvatar(context); } return Container( width: DesktopAIChatSizes.avatarSize, height: DesktopAIChatSizes.avatarSize, clipBehavior: Clip.hardEdge, decoration: const BoxDecoration(shape: BoxShape.circle), foregroundDecoration: ShapeDecoration( shape: CircleBorder( side: BorderSide(color: Theme.of(context).colorScheme.outline), ), ), child: child, ); } Widget _buildEmptyAvatar(BuildContext context) { final String nameOrDefault = _userName(name, defaultName); final Color color = ColorGenerator(name).toColor(); const initialsCount = 2; // Taking the first letters of the name components and limiting to 2 elements final nameInitials = nameOrDefault .split(' ') .where((element) => element.isNotEmpty) .take(initialsCount) .map((element) => element[0].toUpperCase()) .join(); return ColoredBox( color: color, child: Center( child: FlowyText.regular( nameInitials, color: Colors.black, ), ), ); } Widget _buildUrlAvatar(BuildContext context) { return CircleAvatar( backgroundColor: Colors.transparent, radius: DesktopAIChatSizes.avatarSize / 2, child: Image.network( iconUrl, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => _buildEmptyAvatar(context), ), ); } Widget _buildEmojiAvatar(BuildContext context) { return CircleAvatar( backgroundColor: Colors.transparent, radius: DesktopAIChatSizes.avatarSize / 2, child: builtInSVGIcons.contains(iconUrl) ? FlowySvg( FlowySvgData('emoji/$iconUrl'), blendMode: null, ) : FlowyText.emoji( iconUrl, fontSize: 24, // cannot reduce optimizeEmojiAlign: true, ), ); } /// Return the user name. /// /// If the user name is empty, return the default user name. String _userName(String name, String? defaultName) => name.isEmpty ? (defaultName ?? LocaleKeys.defaultUsername.tr()) : name; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart ================================================ // ref appflowy_flutter/lib/plugins/document/presentation/editor_style.dart // diff: // - text style // - heading text style and padding builders // - don't listen to document appearance cubit // import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:universal_platform/universal_platform.dart'; class ChatEditorStyleCustomizer extends EditorStyleCustomizer { ChatEditorStyleCustomizer({ required super.context, required super.padding, super.width, }); @override EditorStyle desktop() { final theme = Theme.of(context); final afThemeExtension = AFThemeExtension.of(context); final appearanceFont = context.read().state.font; final appearance = context.read().state; const fontSize = 14.0; String fontFamily = appearance.fontFamily; if (fontFamily.isEmpty && appearanceFont.isNotEmpty) { fontFamily = appearanceFont; } return EditorStyle.desktop( padding: padding, maxWidth: width, cursorColor: appearance.cursorColor ?? DefaultAppearanceSettings.getDefaultCursorColor(context), selectionColor: appearance.selectionColor ?? DefaultAppearanceSettings.getDefaultSelectionColor(context), defaultTextDirection: appearance.defaultTextDirection, textStyleConfiguration: TextStyleConfiguration( lineHeight: 20 / 14, applyHeightToFirstAscent: true, applyHeightToLastDescent: true, text: baseTextStyle(fontFamily).copyWith( fontSize: fontSize, color: afThemeExtension.onBackground, ), bold: baseTextStyle(fontFamily, fontWeight: FontWeight.bold).copyWith( fontWeight: FontWeight.w600, ), italic: baseTextStyle(fontFamily).copyWith(fontStyle: FontStyle.italic), underline: baseTextStyle(fontFamily).copyWith( decoration: TextDecoration.underline, ), strikethrough: baseTextStyle(fontFamily).copyWith( decoration: TextDecoration.lineThrough, ), href: baseTextStyle(fontFamily).copyWith( color: theme.colorScheme.primary, decoration: TextDecoration.underline, ), code: GoogleFonts.robotoMono( textStyle: baseTextStyle(fontFamily).copyWith( fontSize: fontSize, fontWeight: FontWeight.normal, color: Colors.red, backgroundColor: theme.colorScheme.inverseSurface.withValues(alpha: 0.8), ), ), ), textSpanDecorator: customizeAttributeDecorator, textScaleFactor: context.watch().state.textScaleFactor, ); } @override TextStyle headingStyleBuilder(int level) { final String? fontFamily; final List fontSizes; const fontSize = 14.0; fontFamily = context.read().state.fontFamily; fontSizes = [ fontSize + 12, fontSize + 10, fontSize + 6, fontSize + 2, fontSize, ]; return baseTextStyle(fontFamily, fontWeight: FontWeight.w600).copyWith( fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize, ); } @override CodeBlockStyle codeBlockStyleBuilder() { final fontFamily = context.read().state.codeFontFamily; return CodeBlockStyle( textStyle: baseTextStyle(fontFamily).copyWith( height: 1.4, color: AFThemeExtension.of(context).onBackground, ), backgroundColor: AFThemeExtension.of(context).calloutBGColor, foregroundColor: AFThemeExtension.of(context).textColor.withAlpha(155), wrapLines: true, ); } @override TextStyle calloutBlockStyleBuilder() { if (UniversalPlatform.isMobile) { final afThemeExtension = AFThemeExtension.of(context); final pageStyle = context.read().state; final fontFamily = pageStyle.fontFamily ?? defaultFontFamily; final baseTextStyle = this.baseTextStyle(fontFamily); return baseTextStyle.copyWith( color: afThemeExtension.onBackground, ); } else { final fontSize = context.read().state.fontSize; return baseTextStyle(null).copyWith( fontSize: fontSize, height: 1.5, ); } } @override TextStyle outlineBlockPlaceholderStyleBuilder() { return TextStyle( fontFamily: defaultFontFamily, height: 1.5, color: AFThemeExtension.of(context).onBackground.withValues(alpha: 0.6), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/ai/widgets/prompt_input/mentioned_page_text_span.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:extended_text_field/extended_text_field.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; class MobileChatInput extends StatefulWidget { const MobileChatInput({ super.key, required this.isStreaming, required this.onStopStreaming, required this.onSubmitted, required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, }); final bool isStreaming; final void Function() onStopStreaming; final ValueNotifier> selectedSourcesNotifier; final void Function(String, PredefinedFormat?, Map) onSubmitted; final void Function(List) onUpdateSelectedSources; @override State createState() => _MobileChatInputState(); } class _MobileChatInputState extends State { final inputControlCubit = ChatInputControlCubit(); final focusNode = FocusNode(); final textController = TextEditingController(); late SendButtonState sendButtonState; @override void initState() { super.initState(); textController.addListener(handleTextControllerChanged); // focusNode.onKeyEvent = handleKeyEvent; WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); checkForAskingAI(); }); updateSendButtonState(); } @override void didUpdateWidget(covariant oldWidget) { updateSendButtonState(); super.didUpdateWidget(oldWidget); } @override void dispose() { focusNode.dispose(); textController.dispose(); inputControlCubit.close(); super.dispose(); } @override Widget build(BuildContext context) { return Hero( tag: "ai_chat_prompt", child: BlocProvider.value( value: inputControlCubit, child: BlocListener( listener: (context, state) { state.maybeWhen( updateSelectedViews: (selectedViews) { context.read().add( AIPromptInputEvent.updateMentionedViews(selectedViews), ); }, orElse: () {}, ); }, child: DecoratedBox( decoration: BoxDecoration( border: Border( top: BorderSide(color: Theme.of(context).colorScheme.outline), ), color: Theme.of(context).colorScheme.surface, boxShadow: const [ BoxShadow( blurRadius: 4.0, offset: Offset(0, -2), color: Color.fromRGBO(0, 0, 0, 0.05), ), ], borderRadius: const BorderRadius.vertical(top: Radius.circular(8.0)), ), child: BlocBuilder( builder: (context, state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ConstrainedBox( constraints: BoxConstraints( maxHeight: MobileAIPromptSizes .attachedFilesBarPadding.vertical + MobileAIPromptSizes.attachedFilesPreviewHeight, ), child: PromptInputFile( onDeleted: (file) => context .read() .add(AIPromptInputEvent.removeFile(file)), ), ), if (state.showPredefinedFormats) TextFieldTapRegion( child: Padding( padding: const EdgeInsets.all(8.0), child: ChangeFormatBar( predefinedFormat: state.predefinedFormat, spacing: 8.0, onSelectPredefinedFormat: (format) => context.read().add( AIPromptInputEvent.updatePredefinedFormat( format, ), ), ), ), ) else const VSpace(8.0), inputTextField(context), TextFieldTapRegion( child: Container( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( children: [ const HSpace(8.0), leadingButtons( context, state.showPredefinedFormats, ), const Spacer(), sendButton(), const HSpace(12.0), ], ), ), ), ], ); }, ), ), ), ), ); } void updateSendButtonState() { if (widget.isStreaming) { sendButtonState = SendButtonState.streaming; } else if (textController.text.trim().isEmpty) { sendButtonState = SendButtonState.disabled; } else { sendButtonState = SendButtonState.enabled; } } void handleSendPressed() { if (widget.isStreaming) { return; } final trimmedText = inputControlCubit.formatIntputText( textController.text.trim(), ); textController.clear(); if (trimmedText.isEmpty) { return; } onSubmitText(trimmedText); } void onSubmitText(String text) { // get the attached files and mentioned pages final metadata = context.read().consumeMetadata(); final bloc = context.read(); final showPredefinedFormats = bloc.state.showPredefinedFormats; final predefinedFormat = bloc.state.predefinedFormat; widget.onSubmitted( text, showPredefinedFormats ? predefinedFormat : null, metadata, ); } void checkForAskingAI() { if (!UniversalPlatform.isMobile) return; final paletteBloc = context.read(), paletteState = paletteBloc?.state; if (paletteBloc == null || paletteState == null) return; final isAskingAI = paletteState.askAI; if (!isAskingAI) return; paletteBloc.add(CommandPaletteEvent.askedAI()); final query = paletteState.query ?? ''; if (query.isEmpty) return; final sources = (paletteState.askAISources ?? []).map((e) => e.id).toList(); final metadata = context.read()?.consumeMetadata() ?? {}; final promptState = context.read()?.state; final predefinedFormat = promptState?.predefinedFormat; if (sources.isNotEmpty) { widget.onUpdateSelectedSources(sources); } widget.onSubmitted.call(query, predefinedFormat, metadata); } void handleTextControllerChanged() { if (textController.value.isComposingRangeValid) { return; } // inputControlCubit.updateInputText(textController.text); setState(() => updateSendButtonState()); } // KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { // if (event.character == '@') { // WidgetsBinding.instance.addPostFrameCallback((_) { // mentionPage(context); // }); // } // return KeyEventResult.ignored; // } Future mentionPage(BuildContext context) async { // if the focus node is on focus, unfocus it for better animation // otherwise, the page sheet animation will be blocked by the keyboard inputControlCubit.refreshViews(); inputControlCubit.startSearching(textController.value); if (focusNode.hasFocus) { focusNode.unfocus(); await Future.delayed(const Duration(milliseconds: 100)); } if (context.mounted) { final selectedView = await showPageSelectorSheet( context, filter: (view) => !view.isSpace && view.layout.isDocumentView && view.parentViewId != view.id && !inputControlCubit.selectedViewIds.contains(view.id), ); if (selectedView != null) { final newText = textController.text.replaceRange( inputControlCubit.filterStartPosition, inputControlCubit.filterStartPosition, selectedView.id, ); textController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed( offset: textController.selection.baseOffset + selectedView.id.length, affinity: TextAffinity.upstream, ), ); inputControlCubit.selectPage(selectedView); } focusNode.requestFocus(); inputControlCubit.reset(); } } Widget inputTextField(BuildContext context) { return BlocBuilder( builder: (context, state) { return ExtendedTextField( controller: textController, focusNode: focusNode, textAlignVertical: TextAlignVertical.center, decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: MobileAIPromptSizes.textFieldContentPadding, hintText: state.modelState.hintText, hintStyle: inputHintTextStyle(context), isCollapsed: true, isDense: true, ), keyboardType: TextInputType.multiline, textCapitalization: TextCapitalization.sentences, minLines: 1, maxLines: null, style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 20 / 14), specialTextSpanBuilder: PromptInputTextSpanBuilder( inputControlCubit: inputControlCubit, mentionedPageTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600, ), ), onTapOutside: (_) => focusNode.unfocus(), ); }, ); } TextStyle? inputHintTextStyle(BuildContext context) { return Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).isLightMode ? const Color(0xFFBDC2C8) : const Color(0xFF3C3E51), ); } Widget leadingButtons(BuildContext context, bool showPredefinedFormats) { return _LeadingActions( // onMention: () { // textController.text += '@'; // if (!focusNode.hasFocus) { // focusNode.requestFocus(); // } // WidgetsBinding.instance.addPostFrameCallback((_) { // mentionPage(context); // }); // }, showPredefinedFormats: showPredefinedFormats, onTogglePredefinedFormatSection: () { context .read() .add(AIPromptInputEvent.toggleShowPredefinedFormat()); }, selectedSourcesNotifier: widget.selectedSourcesNotifier, onUpdateSelectedSources: widget.onUpdateSelectedSources, ); } Widget sendButton() { return PromptInputSendButton( state: sendButtonState, onSendPressed: handleSendPressed, onStopStreaming: widget.onStopStreaming, ); } } class _LeadingActions extends StatelessWidget { const _LeadingActions({ required this.showPredefinedFormats, required this.onTogglePredefinedFormatSection, required this.selectedSourcesNotifier, required this.onUpdateSelectedSources, }); final bool showPredefinedFormats; final void Function() onTogglePredefinedFormatSection; final ValueNotifier> selectedSourcesNotifier; final void Function(List) onUpdateSelectedSources; @override Widget build(BuildContext context) { return Material( color: Theme.of(context).colorScheme.surface, child: SeparatedRow( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const HSpace(4.0), children: [ PromptInputMobileSelectSourcesButton( selectedSourcesNotifier: selectedSourcesNotifier, onUpdateSelectedSources: onUpdateSelectedSources, ), PromptInputMobileToggleFormatButton( showFormatBar: showPredefinedFormats, onTap: onTogglePredefinedFormatSection, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_message_selector_banner.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'message/ai_message_action_bar.dart'; import 'message/message_util.dart'; class ChatMessageSelectorBanner extends StatelessWidget { const ChatMessageSelectorBanner({ super.key, required this.view, this.allMessages = const [], }); final ViewPB view; final List allMessages; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (!state.isSelectingMessages) { return const SizedBox.shrink(); } final selectedAmount = state.selectedMessages.length; final totalAmount = allMessages.length; final allSelected = selectedAmount == totalAmount; return Container( height: 48, color: const Color(0xFF00BCF0), padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ GestureDetector( onTap: () { if (selectedAmount > 0) { _unselectAllMessages(context); } else { _selectAllMessages(context); } }, child: FlowySvg( allSelected ? FlowySvgs.checkbox_ai_selected_s : selectedAmount > 0 ? FlowySvgs.checkbox_ai_minus_s : FlowySvgs.checkbox_ai_empty_s, blendMode: BlendMode.dstIn, size: const Size.square(18), ), ), const HSpace(8), Expanded( child: FlowyText.semibold( allSelected ? LocaleKeys.chat_selectBanner_allSelected.tr() : selectedAmount > 0 ? LocaleKeys.chat_selectBanner_nSelected .tr(args: [selectedAmount.toString()]) : LocaleKeys.chat_selectBanner_selectMessages.tr(), figmaLineHeight: 16, color: Colors.white, ), ), SaveToPageButton( view: view, ), const HSpace(8), MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => context.read().add( const ChatSelectMessageEvent.toggleSelectingMessages(), ), child: const FlowySvg( FlowySvgs.close_m, color: Colors.white, size: Size.square(24), ), ), ), ], ), ); }, ); } void _selectAllMessages(BuildContext context) => context .read() .add(ChatSelectMessageEvent.selectAllMessages(allMessages)); void _unselectAllMessages(BuildContext context) => context .read() .add(const ChatSelectMessageEvent.unselectAllMessages()); } class SaveToPageButton extends StatefulWidget { const SaveToPageButton({ super.key, required this.view, }); final ViewPB view; @override State createState() => _SaveToPageButtonState(); } class _SaveToPageButtonState extends State { final popoverController = PopoverController(); @override Widget build(BuildContext context) { return ViewSelector( viewSelectorCubit: BlocProvider( create: (context) => ViewSelectorCubit( getIgnoreViewType: (item) { final view = item.view; if (view.isSpace) { return IgnoreViewType.none; } if (view.layout != ViewLayoutPB.Document) { return IgnoreViewType.hide; } return IgnoreViewType.none; }, ), ), child: BlocSelector( selector: (state) => state.currentSpace, builder: (context, spaceView) { return AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, margin: EdgeInsets.zero, offset: const Offset(0, 18), direction: PopoverDirection.bottomWithRightAligned, constraints: const BoxConstraints.tightFor(width: 300, height: 400), child: buildButton(context, spaceView), popupBuilder: (_) => buildPopover(context), ); }, ), ); } Widget buildButton(BuildContext context, ViewPB? spaceView) { return BlocBuilder( builder: (context, state) { final selectedAmount = state.selectedMessages.length; return Opacity( opacity: selectedAmount == 0 ? 0.5 : 1, child: FlowyTextButton( LocaleKeys.chat_selectBanner_saveButton.tr(), onPressed: selectedAmount == 0 ? null : () async { final documentId = getOpenedDocumentId(); if (documentId != null) { await onAddToExistingPage(context, documentId); await forceReload(documentId); await Future.delayed(const Duration(milliseconds: 500)); await updateSelection(documentId); } else { if (spaceView != null) { unawaited( context .read() .refreshSources([spaceView], spaceView), ); } popoverController.show(); } }, fontColor: Colors.white, borderColor: Colors.white, fillColor: Colors.transparent, padding: const EdgeInsets.symmetric( horizontal: 12.0, vertical: 6.0, ), ), ); }, ); } Widget buildPopover(BuildContext context) { return BlocProvider.value( value: context.read(), child: SaveToPagePopoverContent( onAddToNewPage: (parentViewId) async { await addMessageToNewPage(context, parentViewId); popoverController.close(); }, onAddToExistingPage: (documentId) async { final view = await onAddToExistingPage(context, documentId); if (context.mounted) { openPageFromMessage(context, view); } await Future.delayed(const Duration(milliseconds: 500)); await updateSelection(documentId); popoverController.close(); }, ), ); } Future onAddToExistingPage( BuildContext context, String documentId, ) async { final bloc = context.read(); final selectedMessages = [ ...bloc.state.selectedMessages.whereType(), ]..sort((a, b) => a.createdAt.compareTo(b.createdAt)); await ChatEditDocumentService.addMessagesToPage( documentId, selectedMessages, ); await Future.delayed(const Duration(milliseconds: 500)); final view = await ViewBackendService.getView(documentId).toNullable(); if (context.mounted) { showSaveMessageSuccessToast(context, view); } bloc.add(const ChatSelectMessageEvent.reset()); return view; } Future addMessageToNewPage( BuildContext context, String parentViewId, ) async { final bloc = context.read(); final selectedMessages = [ ...bloc.state.selectedMessages.whereType(), ]..sort((a, b) => a.createdAt.compareTo(b.createdAt)); final newView = await ChatEditDocumentService.saveMessagesToNewPage( widget.view.nameOrDefault, parentViewId, selectedMessages, ); if (context.mounted) { showSaveMessageSuccessToast(context, newView); openPageFromMessage(context, newView); } bloc.add(const ChatSelectMessageEvent.reset()); } Future forceReload(String documentId) async { final bloc = DocumentBloc.findOpen(documentId); if (bloc == null) { return; } await bloc.forceReloadDocumentState(); } Future updateSelection(String documentId) async { final bloc = DocumentBloc.findOpen(documentId); if (bloc == null) { return; } await bloc.forceReloadDocumentState(); final editorState = bloc.state.editorState; final lastNodePath = editorState?.getLastSelectable()?.$1.path; if (editorState == null || lastNodePath == null) { return; } unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: lastNodePath)), ), ); } String? getOpenedDocumentId() { final pageManager = getIt().state.currentPageManager; if (!pageManager.showSecondaryPluginNotifier.value) { return null; } return pageManager.secondaryNotifier.plugin.id; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart'; import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list.dart'; import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list_reversed.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_welcome_page.dart'; import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; @visibleForTesting bool skipAIChatWelcomePage = false; class ChatAnimationListWidget extends StatefulWidget { const ChatAnimationListWidget({ super.key, required this.userProfile, required this.scrollController, required this.itemBuilder, this.enableReversedList = false, }); final UserProfilePB userProfile; final ScrollController scrollController; final ChatItem itemBuilder; final bool enableReversedList; @override State createState() => _ChatAnimationListWidgetState(); } class _ChatAnimationListWidgetState extends State { @override Widget build(BuildContext context) { final bloc = context.read(); // this logic is quite weird, why don't we just get the message from the state? if (bloc.chatController.messages.isEmpty && !skipAIChatWelcomePage) { return ChatWelcomePage( userProfile: widget.userProfile, onSelectedQuestion: (question) { final aiPromptInputBloc = context.read(); final showPredefinedFormats = aiPromptInputBloc.state.showPredefinedFormats; final predefinedFormat = aiPromptInputBloc.state.predefinedFormat; bloc.add( ChatEvent.sendMessage( message: question, format: showPredefinedFormats ? predefinedFormat : null, ), ); }, ); } // don't call this in the build method context .read() .add(ChatSelectMessageEvent.enableStartSelectingMessages()); // final bool reversed = false; return BlocSelector( selector: (state) => state.isSelectingMessages, builder: (context, isSelectingMessages) { return widget.enableReversedList ? ChatAnimatedListReversed( scrollController: widget.scrollController, itemBuilder: widget.itemBuilder, bottomPadding: isSelectingMessages ? 48.0 + DesktopAIChatSizes.messageActionBarIconSize : 8.0, onLoadPreviousMessages: () { if (bloc.isClosed) { return; } bloc.add(const ChatEvent.loadPreviousMessages()); }, ) : ChatAnimatedList( scrollController: widget.scrollController, itemBuilder: widget.itemBuilder, bottomPadding: isSelectingMessages ? 48.0 + DesktopAIChatSizes.messageActionBarIconSize : 8.0, onLoadPreviousMessages: () { if (bloc.isClosed) { return; } bloc.add(const ChatEvent.loadPreviousMessages()); }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_page/chat_content_page.dart ================================================ import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_page/load_chat_message_status_ready.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ChatContentPage extends StatelessWidget { const ChatContentPage({ super.key, required this.view, required this.userProfile, }); final UserProfilePB userProfile; final ViewPB view; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return switch (state.loadingState) { LoadChatMessageStatus.ready => LoadChatMessageStatusReady( view: view, userProfile: userProfile, chatController: context.read().chatController, ), _ => const Center(child: CircularProgressIndicator.adaptive()), }; }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_page/chat_footer.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart'; import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; class ChatFooter extends StatefulWidget { const ChatFooter({ super.key, required this.view, }); final ViewPB view; @override State createState() => _ChatFooterState(); } class _ChatFooterState extends State { final textController = AiPromptInputTextEditingController(); @override void dispose() { textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocSelector( selector: (state) => state.isSelectingMessages, builder: (context, isSelectingMessages) { return AnimatedSwitcher( duration: const Duration(milliseconds: 150), transitionBuilder: (child, animation) { return NonClippingSizeTransition( sizeFactor: animation, axisAlignment: -1, child: child, ); }, child: isSelectingMessages ? const SizedBox.shrink() : Padding( padding: AIChatUILayout.safeAreaInsets(context), child: BlocSelector( selector: (state) { return state.promptResponseState.isReady; }, builder: (context, canSendMessage) { final chatBloc = context.read(); return UniversalPlatform.isDesktop ? _buildDesktopInput( context, chatBloc, canSendMessage, ) : _buildMobileInput( context, chatBloc, canSendMessage, ); }, ), ), ); }, ); } Widget _buildDesktopInput( BuildContext context, ChatBloc chatBloc, bool canSendMessage, ) { return DesktopPromptInput( isStreaming: !canSendMessage, textController: textController, onStopStreaming: () { chatBloc.add(const ChatEvent.stopStream()); }, onSubmitted: (text, format, metadata, promptId) { chatBloc.add( ChatEvent.sendMessage( message: text, format: format, metadata: metadata, promptId: promptId, ), ); }, selectedSourcesNotifier: chatBloc.selectedSourcesNotifier, onUpdateSelectedSources: (ids) { chatBloc.add( ChatEvent.updateSelectedSources( selectedSourcesIds: ids, ), ); }, ); } Widget _buildMobileInput( BuildContext context, ChatBloc chatBloc, bool canSendMessage, ) { return MobileChatInput( isStreaming: !canSendMessage, onStopStreaming: () { chatBloc.add(const ChatEvent.stopStream()); }, onSubmitted: (text, format, metadata) { chatBloc.add( ChatEvent.sendMessage( message: text, format: format, metadata: metadata, ), ); }, selectedSourcesNotifier: chatBloc.selectedSourcesNotifier, onUpdateSelectedSources: (ids) { chatBloc.add( ChatEvent.updateSelectedSources( selectedSourcesIds: ids, ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_page/chat_message_widget.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:provider/provider.dart'; class ChatMessage extends StatelessWidget { const ChatMessage({ super.key, required this.message, required this.child, this.sentMessageAlignment = AlignmentDirectional.centerEnd, this.receivedMessageAlignment = AlignmentDirectional.centerStart, this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 1), }); final Message message; final Widget child; final AlignmentGeometry sentMessageAlignment; final AlignmentGeometry receivedMessageAlignment; final EdgeInsetsGeometry padding; @override Widget build(BuildContext context) { final isSentByMe = context.watch().id == message.author.id; return Align( alignment: isSentByMe ? sentMessageAlignment : receivedMessageAlignment, child: Padding( padding: padding, child: child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_page/load_chat_message_status_ready.dart ================================================ import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_footer.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_message_widget.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_page/text_message_widget.dart'; import 'package:appflowy/plugins/ai_chat/presentation/scroll_to_bottom.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart' hide ChatMessage; import 'package:universal_platform/universal_platform.dart'; class LoadChatMessageStatusReady extends StatelessWidget { const LoadChatMessageStatusReady({ super.key, required this.view, required this.userProfile, required this.chatController, }); final ViewPB view; final UserProfilePB userProfile; final ChatController chatController; @override Widget build(BuildContext context) { return Column( children: [ // Chat header, banner _buildHeader(context), // Chat body, a list of messages _buildBody(context), // Chat footer, a text input field with toolbar, send button, etc. _buildFooter(context), ], ); } Widget _buildHeader(BuildContext context) { return ChatMessageSelectorBanner( view: view, allMessages: chatController.messages, ); } Widget _buildBody(BuildContext context) { final bool enableAnimation = true; return Expanded( child: Align( alignment: Alignment.topCenter, child: _wrapConstraints( SelectionArea( child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( scrollbars: false, ), child: Chat( chatController: chatController, user: User(id: userProfile.id.toString()), darkTheme: ChatTheme.fromThemeData(Theme.of(context)), theme: ChatTheme.fromThemeData(Theme.of(context)), builders: Builders( // we have a custom input builder, so we don't need the default one inputBuilder: (_) => const SizedBox.shrink(), textMessageBuilder: ( context, message, ) => TextMessageWidget( message: message, userProfile: userProfile, view: view, enableAnimation: enableAnimation, ), chatMessageBuilder: ( context, message, animation, child, ) => ChatMessage( message: message, padding: const EdgeInsets.symmetric(vertical: 18.0), child: child, ), scrollToBottomBuilder: ( context, animation, onPressed, ) => CustomScrollToBottom( animation: animation, onPressed: onPressed, ), chatAnimatedListBuilder: ( context, scrollController, itemBuilder, ) => ChatAnimationListWidget( userProfile: userProfile, scrollController: scrollController, itemBuilder: itemBuilder, enableReversedList: !enableAnimation, ), ), ), ), ), ), ), ); } Widget _buildFooter(BuildContext context) { return _wrapConstraints( ChatFooter(view: view), ); } Widget _wrapConstraints(Widget child) { return Container( constraints: const BoxConstraints(maxWidth: 784), margin: UniversalPlatform.isDesktop ? const EdgeInsets.symmetric(horizontal: 60.0) : null, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_page/text_message_widget.dart ================================================ import 'dart:io'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_height_manager.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/ai_text_message.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/error_text_message.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/message_util.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/user_text_message.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:string_validator/string_validator.dart'; import 'package:url_launcher/url_launcher.dart'; class TextMessageWidget extends StatelessWidget { const TextMessageWidget({ super.key, required this.message, required this.userProfile, required this.view, this.enableAnimation = true, }); final TextMessage message; final UserProfilePB userProfile; final ViewPB view; final bool enableAnimation; @override Widget build(BuildContext context) { final messageType = onetimeMessageTypeFromMeta( message.metadata, ); if (messageType == OnetimeShotType.error) { return ChatErrorMessageWidget( errorMessage: message.metadata?[errorMessageTextKey] ?? "", ); } if (messageType == OnetimeShotType.relatedQuestion) { final messages = context.read().chatController.messages; final lastAIMessage = messages.lastWhere( (e) => onetimeMessageTypeFromMeta(e.metadata) == null && (e.author.id == aiResponseUserId || e.author.id == systemUserId), ); final minHeight = ChatMessageHeightManager().calculateRelatedQuestionMinHeight( messageId: lastAIMessage.id, ); return Container( constraints: BoxConstraints( minHeight: minHeight, ), child: RelatedQuestionList( relatedQuestions: message.metadata!['questions'], onQuestionSelected: (question) { final bloc = context.read(); final showPredefinedFormats = bloc.state.showPredefinedFormats; final predefinedFormat = bloc.state.predefinedFormat; context.read().add( ChatEvent.sendMessage( message: question, format: showPredefinedFormats ? predefinedFormat : null, ), ); }, ), ); } if (message.author.id == userProfile.id.toString() || isOtherUserMessage(message)) { return ChatUserMessageWidget( user: message.author, message: message, ); } final stream = message.metadata?["$AnswerStream"]; final questionId = message.metadata?[messageQuestionIdKey]; final refSourceJsonString = message.metadata?[messageRefSourceJsonStringKey] as String?; return BlocSelector( selector: (state) => state.isSelectingMessages, builder: (context, isSelectingMessages) { return BlocBuilder( buildWhen: (previous, current) => previous.promptResponseState != current.promptResponseState, builder: (context, state) { final chatController = context.read().chatController; final messages = chatController.messages .where((e) => onetimeMessageTypeFromMeta(e.metadata) == null); final isLastMessage = messages.isEmpty ? false : messages.last.id == message.id; final hasRelatedQuestions = state.promptResponseState == PromptResponseState.relatedQuestionsReady; return ChatAIMessageWidget( user: message.author, messageUserId: message.id, message: message, stream: stream is AnswerStream ? stream : null, questionId: questionId, chatId: view.id, refSourceJsonString: refSourceJsonString, isStreaming: !state.promptResponseState.isReady, isLastMessage: isLastMessage, hasRelatedQuestions: hasRelatedQuestions, isSelectingMessages: isSelectingMessages, enableAnimation: enableAnimation, onSelectedMetadata: (metadata) => _onSelectMetadata(context, metadata), onRegenerate: () => context .read() .add(ChatEvent.regenerateAnswer(message.id, null, null)), onChangeFormat: (format) => context .read() .add(ChatEvent.regenerateAnswer(message.id, format, null)), onChangeModel: (model) => context .read() .add(ChatEvent.regenerateAnswer(message.id, null, model)), onStopStream: () => context.read().add( const ChatEvent.stopStream(), ), ); }, ); }, ); } void _onSelectMetadata( BuildContext context, ChatMessageRefSource metadata, ) async { // When the source of metatdata is appflowy, which means it is a appflowy page if (metadata.source == "appflowy") { final sidebarView = await ViewBackendService.getView(metadata.id).toNullable(); if (context.mounted) { openPageFromMessage(context, sidebarView); } return; } if (metadata.source == "web") { if (isURL(metadata.name)) { late Uri uri; try { uri = Uri.parse(metadata.name); // `Uri` identifies `localhost` as a scheme if (!uri.hasScheme || uri.scheme == 'localhost') { uri = Uri.parse("http://${metadata.name}"); await InternetAddress.lookup(uri.host); } await launchUrl(uri); } catch (err) { Log.error("failed to open url $err"); } } return; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; import 'layout_define.dart'; class RelatedQuestionList extends StatelessWidget { const RelatedQuestionList({ super.key, required this.onQuestionSelected, required this.relatedQuestions, }); final void Function(String) onQuestionSelected; final List relatedQuestions; @override Widget build(BuildContext context) { return SelectionContainer.disabled( child: ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: relatedQuestions.length + 1, padding: const EdgeInsets.only(left: 4.0, bottom: 8.0) + AIChatUILayout.messageMargin, separatorBuilder: (context, index) => const VSpace(4.0), itemBuilder: (context, index) { if (index == 0) { return Padding( padding: const EdgeInsets.only(bottom: 4.0), child: FlowyText( LocaleKeys.chat_relatedQuestion.tr(), color: Theme.of(context).hintColor, fontWeight: FontWeight.w600, ), ); } else { return Align( alignment: AlignmentDirectional.centerStart, child: RelatedQuestionItem( question: relatedQuestions[index - 1], onQuestionSelected: onQuestionSelected, ), ); } }, ), ); } } class RelatedQuestionItem extends StatelessWidget { const RelatedQuestionItem({ required this.question, required this.onQuestionSelected, super.key, }); final String question; final Function(String) onQuestionSelected; @override Widget build(BuildContext context) { return FlowyButton( mainAxisAlignment: MainAxisAlignment.start, text: Flexible( child: FlowyText( question, lineHeight: 1.4, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), expandText: false, margin: UniversalPlatform.isMobile ? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0) : const EdgeInsets.all(8.0), leftIcon: FlowySvg( FlowySvgs.ai_chat_outlined_s, color: Theme.of(context).colorScheme.primary, size: const Size.square(16.0), ), onTap: () => onQuestionSelected(question), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class ChatWelcomePage extends StatelessWidget { const ChatWelcomePage({ required this.userProfile, required this.onSelectedQuestion, super.key, }); final void Function(String) onSelectedQuestion; final UserProfilePB userProfile; static final List desktopItems = [ LocaleKeys.chat_question1.tr(), LocaleKeys.chat_question2.tr(), LocaleKeys.chat_question3.tr(), LocaleKeys.chat_question4.tr(), ]; static final List> mobileItems = [ [ LocaleKeys.chat_question1.tr(), LocaleKeys.chat_question2.tr(), ], [ LocaleKeys.chat_question3.tr(), LocaleKeys.chat_question4.tr(), ], [ LocaleKeys.chat_question5.tr(), LocaleKeys.chat_question6.tr(), ], ]; @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( FlowySvgs.app_logo_xl, size: Size.square(32), blendMode: null, ), const VSpace(16), FlowyText( fontSize: 15, LocaleKeys.chat_questionDetail.tr(args: [userProfile.name]), ), UniversalPlatform.isDesktop ? const VSpace(32 - 16) : const VSpace(24), ...UniversalPlatform.isDesktop ? buildDesktopSampleQuestions(context) : buildMobileSampleQuestions(context), ], ); } Iterable buildDesktopSampleQuestions(BuildContext context) { return desktopItems.map( (question) => Padding( padding: const EdgeInsets.only(top: 16.0), child: WelcomeSampleQuestion( question: question, onSelected: onSelectedQuestion, ), ), ); } Iterable buildMobileSampleQuestions(BuildContext context) { return [ _AutoScrollingSampleQuestions( key: const ValueKey('inf_scroll_1'), onSelected: onSelectedQuestion, questions: mobileItems[0], offset: 60.0, ), const VSpace(8), _AutoScrollingSampleQuestions( key: const ValueKey('inf_scroll_2'), onSelected: onSelectedQuestion, questions: mobileItems[1], offset: -50.0, reverse: true, ), const VSpace(8), _AutoScrollingSampleQuestions( key: const ValueKey('inf_scroll_3'), onSelected: onSelectedQuestion, questions: mobileItems[2], offset: 120.0, ), ]; } } class WelcomeSampleQuestion extends StatelessWidget { const WelcomeSampleQuestion({ required this.question, required this.onSelected, super.key, }); final void Function(String) onSelected; final String question; @override Widget build(BuildContext context) { final isLightMode = Theme.of(context).isLightMode; return DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( offset: const Offset(0, 1), blurRadius: 2, spreadRadius: -2, color: isLightMode ? const Color(0x051F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 4, color: isLightMode ? const Color(0x051F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 8, spreadRadius: 2, color: isLightMode ? const Color(0x051F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), ], ), child: TextButton( onPressed: () => onSelected(question), style: ButtonStyle( padding: WidgetStatePropertyAll( EdgeInsets.symmetric( horizontal: 16, vertical: UniversalPlatform.isDesktop ? 8 : 0, ), ), backgroundColor: WidgetStateProperty.resolveWith((state) { if (state.contains(WidgetState.hovered)) { return isLightMode ? const Color(0xFFF9FAFD) : AFThemeExtension.of(context).lightGreyHover; } return Theme.of(context).colorScheme.surface; }), overlayColor: WidgetStateColor.transparent, shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide(color: Theme.of(context).dividerColor), ), ), ), child: FlowyText( question, color: isLightMode ? Theme.of(context).hintColor : const Color(0xFF666D76), ), ), ); } } class _AutoScrollingSampleQuestions extends StatefulWidget { const _AutoScrollingSampleQuestions({ super.key, required this.questions, this.offset = 0.0, this.reverse = false, required this.onSelected, }); final List questions; final void Function(String) onSelected; final double offset; final bool reverse; @override State<_AutoScrollingSampleQuestions> createState() => _AutoScrollingSampleQuestionsState(); } class _AutoScrollingSampleQuestionsState extends State<_AutoScrollingSampleQuestions> { late final scrollController = ScrollController( initialScrollOffset: widget.offset, ); @override Widget build(BuildContext context) { return SizedBox( height: 36, child: InfiniteScrollView( scrollController: scrollController, centerKey: UniqueKey(), itemCount: widget.questions.length, itemBuilder: (context, index) { return WelcomeSampleQuestion( question: widget.questions[index], onSelected: widget.onSelected, ); }, separatorBuilder: (context, index) => const HSpace(8), ), ); } } class InfiniteScrollView extends StatelessWidget { const InfiniteScrollView({ super.key, required this.itemCount, required this.centerKey, required this.itemBuilder, required this.separatorBuilder, this.scrollController, }); final int itemCount; final Widget Function(BuildContext context, int index) itemBuilder; final Widget Function(BuildContext context, int index) separatorBuilder; final Key centerKey; final ScrollController? scrollController; @override Widget build(BuildContext context) { return CustomScrollView( scrollDirection: Axis.horizontal, controller: scrollController, center: centerKey, anchor: 0.5, slivers: [ _buildList(isForward: false), SliverToBoxAdapter( child: separatorBuilder.call(context, 0), ), SliverToBoxAdapter( key: centerKey, child: itemBuilder.call(context, 0), ), SliverToBoxAdapter( child: separatorBuilder.call(context, 0), ), _buildList(isForward: true), ], ); } Widget _buildList({required bool isForward}) { return SliverList.separated( itemBuilder: (context, index) { index = (index + 1) % itemCount; return itemBuilder(context, index); }, separatorBuilder: (context, index) { index = (index + 1) % itemCount; return separatorBuilder(context, index); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart ================================================ import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class AIChatUILayout { const AIChatUILayout._(); static EdgeInsets safeAreaInsets(BuildContext context) { final query = MediaQuery.of(context); return UniversalPlatform.isMobile ? EdgeInsets.fromLTRB( query.padding.left, 0, query.padding.right, query.viewInsets.bottom + query.padding.bottom, ) : const EdgeInsets.only(bottom: 24); } static EdgeInsets get messageMargin => UniversalPlatform.isMobile ? const EdgeInsets.symmetric(horizontal: 16) : EdgeInsets.zero; } class DesktopAIChatSizes { const DesktopAIChatSizes._(); static const avatarSize = 32.0; static const avatarAndChatBubbleSpacing = 12.0; static const messageActionBarIconSize = 28.0; static const messageHoverActionBarPadding = EdgeInsets.all(2.0); static const messageHoverActionBarRadius = BorderRadius.all(Radius.circular(8.0)); static const messageHoverActionBarIconRadius = BorderRadius.all(Radius.circular(6.0)); static const messageActionBarIconRadius = BorderRadius.all(Radius.circular(8.0)); static const inputActionBarMargin = EdgeInsetsDirectional.fromSTEB(8, 0, 8, 4); static const inputActionBarButtonSpacing = 4.0; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_format_bottom_sheet.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; Future showChangeFormatBottomSheet( BuildContext context, ) { return showMobileBottomSheet( context, showDragHandle: true, builder: (context) => const _ChangeFormatBottomSheetContent(), ); } class _ChangeFormatBottomSheetContent extends StatefulWidget { const _ChangeFormatBottomSheetContent(); @override State<_ChangeFormatBottomSheetContent> createState() => _ChangeFormatBottomSheetContentState(); } class _ChangeFormatBottomSheetContentState extends State<_ChangeFormatBottomSheetContent> { PredefinedFormat? predefinedFormat; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ _Header( onCancel: () => Navigator.of(context).pop(), onDone: () => Navigator.of(context).pop(predefinedFormat), ), const VSpace(4.0), _Body( predefinedFormat: predefinedFormat, onSelectPredefinedFormat: (format) { setState(() => predefinedFormat = format); }, ), const VSpace(16.0), ], ); } } class _Header extends StatelessWidget { const _Header({ required this.onCancel, required this.onDone, }); final VoidCallback onCancel; final VoidCallback onDone; @override Widget build(BuildContext context) { return SizedBox( height: 44.0, child: Stack( children: [ Align( alignment: Alignment.centerLeft, child: AppBarBackButton( padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), onTap: onCancel, ), ), Align( child: Container( constraints: const BoxConstraints(maxWidth: 250), child: FlowyText( LocaleKeys.chat_changeFormat_actionButton.tr(), fontSize: 17.0, fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, ), ), ), Align( alignment: Alignment.centerRight, child: AppBarDoneButton( onTap: onDone, ), ), ], ), ); } } class _Body extends StatelessWidget { const _Body({ required this.predefinedFormat, required this.onSelectPredefinedFormat, }); final PredefinedFormat? predefinedFormat; final void Function(PredefinedFormat) onSelectPredefinedFormat; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ _buildFormatButton(ImageFormat.text, true), _buildFormatButton(ImageFormat.textAndImage), _buildFormatButton(ImageFormat.image), const VSpace(32.0), Opacity( opacity: predefinedFormat?.imageFormat.hasText ?? true ? 1 : 0, child: Column( children: [ _buildTextFormatButton(TextFormat.paragraph, true), _buildTextFormatButton(TextFormat.bulletList), _buildTextFormatButton(TextFormat.numberedList), _buildTextFormatButton(TextFormat.table), ], ), ), ], ); } Widget _buildFormatButton( ImageFormat format, [ bool isFirst = false, ]) { return FlowyOptionTile.checkbox( text: format.i18n, isSelected: format == predefinedFormat?.imageFormat, showTopBorder: isFirst, leftIcon: FlowySvg( format.icon, size: format == ImageFormat.textAndImage ? const Size(21.0 / 16.0 * 20, 20) : const Size.square(20), ), onTap: () { if (predefinedFormat != null && format == predefinedFormat!.imageFormat) { return; } if (format.hasText) { final textFormat = predefinedFormat?.textFormat ?? TextFormat.paragraph; onSelectPredefinedFormat( PredefinedFormat(imageFormat: format, textFormat: textFormat), ); } else { onSelectPredefinedFormat( PredefinedFormat(imageFormat: format, textFormat: null), ); } }, ); } Widget _buildTextFormatButton( TextFormat format, [ bool isFirst = false, ]) { return FlowyOptionTile.checkbox( text: format.i18n, isSelected: format == predefinedFormat?.textFormat, showTopBorder: isFirst, leftIcon: FlowySvg( format.icon, size: const Size.square(20), ), onTap: () { if (predefinedFormat != null && format == predefinedFormat!.textFormat) { return; } onSelectPredefinedFormat( PredefinedFormat( imageFormat: predefinedFormat?.imageFormat ?? ImageFormat.text, textFormat: format, ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_change_model_bottom_sheet.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; Future showChangeModelBottomSheet( BuildContext context, List models, ) { return showMobileBottomSheet( context, showDragHandle: true, builder: (context) => _ChangeModelBottomSheetContent(models: models), ); } class _ChangeModelBottomSheetContent extends StatefulWidget { const _ChangeModelBottomSheetContent({ required this.models, }); final List models; @override State<_ChangeModelBottomSheetContent> createState() => _ChangeModelBottomSheetContentState(); } class _ChangeModelBottomSheetContentState extends State<_ChangeModelBottomSheetContent> { AIModelPB? model; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ _Header( onCancel: () => Navigator.of(context).pop(), onDone: () => Navigator.of(context).pop(model), ), const VSpace(4.0), _Body( models: widget.models, selectedModel: model, onSelectModel: (format) { setState(() => model = format); }, ), const VSpace(16.0), ], ); } } class _Header extends StatelessWidget { const _Header({ required this.onCancel, required this.onDone, }); final VoidCallback onCancel; final VoidCallback onDone; @override Widget build(BuildContext context) { return SizedBox( height: 44.0, child: Stack( children: [ Align( alignment: Alignment.centerLeft, child: AppBarBackButton( padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), onTap: onCancel, ), ), Align( child: Container( constraints: const BoxConstraints(maxWidth: 250), child: FlowyText( LocaleKeys.chat_switchModel_label.tr(), fontSize: 17.0, fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, ), ), ), Align( alignment: Alignment.centerRight, child: AppBarDoneButton( onTap: onDone, ), ), ], ), ); } } class _Body extends StatelessWidget { const _Body({ required this.models, required this.selectedModel, required this.onSelectModel, }); final List models; final AIModelPB? selectedModel; final void Function(AIModelPB) onSelectModel; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: models .mapIndexed( (index, model) => _buildModelButton(model, index == 0), ) .toList(), ); } Widget _buildModelButton( AIModelPB model, [ bool isFirst = false, ]) { return FlowyOptionTile.checkbox( text: model.name, isSelected: model == selectedModel, showTopBorder: isFirst, onTap: () { onSelectModel(model); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import '../chat_editor_style.dart'; // Wrap the appflowy_editor as a chat text message widget class AIMarkdownText extends StatelessWidget { const AIMarkdownText({ super.key, required this.markdown, this.withAnimation = false, }); final String markdown; final bool withAnimation; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DocumentPageStyleBloc(view: ViewPB()) ..add(const DocumentPageStyleEvent.initial()), child: _AppFlowyEditorMarkdown( markdown: markdown, withAnimation: withAnimation, ), ); } } class _AppFlowyEditorMarkdown extends StatefulWidget { const _AppFlowyEditorMarkdown({ required this.markdown, this.withAnimation = false, }); // the text should be the markdown format final String markdown; /// Whether to animate the text. final bool withAnimation; @override State<_AppFlowyEditorMarkdown> createState() => _AppFlowyEditorMarkdownState(); } class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> with TickerProviderStateMixin { late EditorState editorState; late EditorScrollController scrollController; late Timer markdownOutputTimer; int offset = 0; final Map)> _animations = {}; @override void initState() { super.initState(); editorState = _parseMarkdown(widget.markdown.trim()); scrollController = EditorScrollController( editorState: editorState, shrinkWrap: true, ); if (widget.withAnimation) { markdownOutputTimer = Timer.periodic(const Duration(milliseconds: 60), (timer) { if (offset >= widget.markdown.length || !widget.withAnimation) { return; } final markdown = widget.markdown.substring(0, offset); offset += 30; final editorState = _parseMarkdown( markdown, previousDocument: this.editorState.document, ); final lastCurrentNode = editorState.document.last; final lastPreviousNode = this.editorState.document.last; if (lastCurrentNode?.id != lastPreviousNode?.id || lastCurrentNode?.type != lastPreviousNode?.type || lastCurrentNode?.delta?.toPlainText() != lastPreviousNode?.delta?.toPlainText()) { setState(() { this.editorState.dispose(); this.editorState = editorState; scrollController.dispose(); scrollController = EditorScrollController( editorState: editorState, shrinkWrap: true, ); }); } }); } } @override void didUpdateWidget(covariant _AppFlowyEditorMarkdown oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.markdown != widget.markdown && !widget.withAnimation) { final editorState = _parseMarkdown( widget.markdown.trim(), previousDocument: this.editorState.document, ); this.editorState.dispose(); this.editorState = editorState; scrollController.dispose(); scrollController = EditorScrollController( editorState: editorState, shrinkWrap: true, ); } } @override void dispose() { scrollController.dispose(); editorState.dispose(); if (widget.withAnimation) { markdownOutputTimer.cancel(); for (final controller in _animations.values.map((e) => e.$1)) { controller.dispose(); } } super.dispose(); } @override Widget build(BuildContext context) { // don't lazy load the styleCustomizer and blockBuilders, // it needs the context to get the theme. final styleCustomizer = ChatEditorStyleCustomizer( context: context, padding: EdgeInsets.zero, ); final editorStyle = styleCustomizer.style().copyWith( // hide the cursor cursorColor: Colors.transparent, cursorWidth: 0, ); final blockBuilders = buildBlockComponentBuilders( context: context, editorState: editorState, styleCustomizer: styleCustomizer, // the editor is not editable in the chat editable: false, alwaysDistributeSimpleTableColumnWidths: UniversalPlatform.isDesktop, customPadding: (node) => EdgeInsets.zero, ); return IntrinsicHeight( child: AppFlowyEditor( shrinkWrap: true, // the editor is not editable in the chat editable: false, disableKeyboardService: UniversalPlatform.isMobile, disableSelectionService: UniversalPlatform.isMobile, editorStyle: editorStyle, editorScrollController: scrollController, blockComponentBuilders: blockBuilders, commandShortcutEvents: [customCopyCommand], disableAutoScroll: true, editorState: editorState, blockWrapper: ( context, { required Node node, required Widget child, }) { if (!widget.withAnimation) { return child; } if (!_animations.containsKey(node.id)) { final duration = UniversalPlatform.isMobile ? const Duration(milliseconds: 800) : const Duration(milliseconds: 1600); final controller = AnimationController( vsync: this, duration: duration, ); final fade = Tween( begin: 0, end: 1, ).animate(controller); _animations[node.id] = (controller, fade); controller.forward(); } final (controller, fade) = _animations[node.id]!; return _AnimatedWrapper( fade: fade, child: child, ); }, contextMenuItems: [ [ ContextMenuItem( getName: LocaleKeys.document_plugins_contextMenu_copy.tr, onPressed: (editorState) => customCopyCommand.execute(editorState), ), ] ], ), ); } EditorState _parseMarkdown( String markdown, { Document? previousDocument, }) { // merge the nodes from the previous document with the new document to keep the same node ids final document = customMarkdownToDocument(markdown); final documentIterator = NodeIterator( document: document, startNode: document.root, ); if (previousDocument != null) { final previousDocumentIterator = NodeIterator( document: previousDocument, startNode: previousDocument.root, ); while ( documentIterator.moveNext() && previousDocumentIterator.moveNext()) { final currentNode = documentIterator.current; final previousNode = previousDocumentIterator.current; if (currentNode.path.equals(previousNode.path)) { currentNode.id = previousNode.id; } } } final editorState = EditorState(document: document); return editorState; } } class _AnimatedWrapper extends StatelessWidget { const _AnimatedWrapper({ required this.fade, required this.child, }); final Animation fade; final Widget child; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: fade, builder: (context, childWidget) { return ShaderMask( shaderCallback: (Rect bounds) { return LinearGradient( stops: [fade.value, fade.value], colors: const [ Colors.white, Colors.transparent, ], ).createShader(bounds); }, blendMode: BlendMode.dstIn, child: Opacity( opacity: fade.value, child: childWidget, ), ); }, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import '../layout_define.dart'; import 'message_util.dart'; class AIMessageActionBar extends StatefulWidget { const AIMessageActionBar({ super.key, required this.message, required this.showDecoration, this.onRegenerate, this.onChangeFormat, this.onChangeModel, this.onOverrideVisibility, }); final Message message; final bool showDecoration; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; final void Function(AIModelPB)? onChangeModel; final void Function(bool)? onOverrideVisibility; @override State createState() => _AIMessageActionBarState(); } class _AIMessageActionBarState extends State { final popoverMutex = PopoverMutex(); @override Widget build(BuildContext context) { final isLightMode = Theme.of(context).isLightMode; final child = SeparatedRow( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const HSpace(8.0), children: _buildChildren(), ); return widget.showDecoration ? Container( padding: DesktopAIChatSizes.messageHoverActionBarPadding, decoration: BoxDecoration( borderRadius: DesktopAIChatSizes.messageHoverActionBarRadius, border: Border.all( color: isLightMode ? const Color(0x1F1F2329) : Theme.of(context).dividerColor, strokeAlign: BorderSide.strokeAlignOutside, ), color: Theme.of(context).cardColor, boxShadow: [ BoxShadow( offset: const Offset(0, 1), blurRadius: 2, spreadRadius: -2, color: isLightMode ? const Color(0x051F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 4, color: isLightMode ? const Color(0x051F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 8, spreadRadius: 2, color: isLightMode ? const Color(0x051F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), ], ), child: child, ) : child; } List _buildChildren() { return [ CopyButton( isInHoverBar: widget.showDecoration, textMessage: widget.message as TextMessage, ), RegenerateButton( isInHoverBar: widget.showDecoration, onTap: () => widget.onRegenerate?.call(), ), ChangeFormatButton( isInHoverBar: widget.showDecoration, onRegenerate: widget.onChangeFormat, popoverMutex: popoverMutex, onOverrideVisibility: widget.onOverrideVisibility, ), ChangeModelButton( isInHoverBar: widget.showDecoration, onRegenerate: widget.onChangeModel, popoverMutex: popoverMutex, onOverrideVisibility: widget.onOverrideVisibility, ), SaveToPageButton( textMessage: widget.message as TextMessage, isInHoverBar: widget.showDecoration, popoverMutex: popoverMutex, onOverrideVisibility: widget.onOverrideVisibility, ), ]; } } class CopyButton extends StatelessWidget { const CopyButton({ super.key, required this.isInHoverBar, required this.textMessage, }); final bool isInHoverBar; final TextMessage textMessage; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.settings_menu_clickToCopy.tr(), child: FlowyIconButton( width: DesktopAIChatSizes.messageActionBarIconSize, hoverColor: AFThemeExtension.of(context).lightGreyHover, radius: isInHoverBar ? DesktopAIChatSizes.messageHoverActionBarIconRadius : DesktopAIChatSizes.messageActionBarIconRadius, icon: FlowySvg( FlowySvgs.copy_s, color: Theme.of(context).hintColor, size: const Size.square(16), ), onPressed: () async { final messageText = textMessage.text.trim(); final document = customMarkdownToDocument( messageText, tableWidth: 250.0, ); await getIt().setData( ClipboardServiceData( plainText: _stripMarkdownIfNecessary(messageText), inAppJson: jsonEncode(document.toJson()), ), ); if (context.mounted) { showToastNotification( message: LocaleKeys.message_copy_success.tr(), ); } }, ), ); } String _stripMarkdownIfNecessary(String plainText) { // match and capture inner url as group final matches = singleLineMarkdownImageRegex.allMatches(plainText); if (matches.length != 1) { return plainText; } return matches.first[1] ?? plainText; } } class RegenerateButton extends StatelessWidget { const RegenerateButton({ super.key, required this.isInHoverBar, required this.onTap, }); final bool isInHoverBar; final void Function() onTap; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.chat_regenerate.tr(), child: FlowyIconButton( width: DesktopAIChatSizes.messageActionBarIconSize, hoverColor: AFThemeExtension.of(context).lightGreyHover, radius: isInHoverBar ? DesktopAIChatSizes.messageHoverActionBarIconRadius : DesktopAIChatSizes.messageActionBarIconRadius, icon: FlowySvg( FlowySvgs.ai_try_again_s, color: Theme.of(context).hintColor, size: const Size.square(16), ), onPressed: onTap, ), ); } } class ChangeFormatButton extends StatefulWidget { const ChangeFormatButton({ super.key, required this.isInHoverBar, this.popoverMutex, this.onRegenerate, this.onOverrideVisibility, }); final bool isInHoverBar; final PopoverMutex? popoverMutex; final void Function(PredefinedFormat)? onRegenerate; final void Function(bool)? onOverrideVisibility; @override State createState() => _ChangeFormatButtonState(); } class _ChangeFormatButtonState extends State { final popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, mutex: widget.popoverMutex, triggerActions: PopoverTriggerFlags.none, margin: EdgeInsets.zero, offset: Offset(0, widget.isInHoverBar ? 8 : 4), direction: PopoverDirection.bottomWithLeftAligned, constraints: const BoxConstraints(), onClose: () => widget.onOverrideVisibility?.call(false), child: buildButton(context), popupBuilder: (_) => BlocProvider.value( value: context.read(), child: _ChangeFormatPopoverContent( onRegenerate: widget.onRegenerate, ), ), ); } Widget buildButton(BuildContext context) { return FlowyTooltip( message: LocaleKeys.chat_changeFormat_actionButton.tr(), child: FlowyIconButton( width: 32.0, height: DesktopAIChatSizes.messageActionBarIconSize, hoverColor: AFThemeExtension.of(context).lightGreyHover, radius: widget.isInHoverBar ? DesktopAIChatSizes.messageHoverActionBarIconRadius : DesktopAIChatSizes.messageActionBarIconRadius, icon: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.ai_retry_font_s, color: Theme.of(context).hintColor, size: const Size.square(16), ), FlowySvg( FlowySvgs.ai_source_drop_down_s, color: Theme.of(context).hintColor, size: const Size.square(8), ), ], ), onPressed: () { widget.onOverrideVisibility?.call(true); popoverController.show(); }, ), ); } } class _ChangeFormatPopoverContent extends StatefulWidget { const _ChangeFormatPopoverContent({ this.onRegenerate, }); final void Function(PredefinedFormat)? onRegenerate; @override State<_ChangeFormatPopoverContent> createState() => _ChangeFormatPopoverContentState(); } class _ChangeFormatPopoverContentState extends State<_ChangeFormatPopoverContent> { PredefinedFormat? predefinedFormat; @override Widget build(BuildContext context) { final isLightMode = Theme.of(context).isLightMode; return Container( padding: const EdgeInsets.all(2.0), decoration: BoxDecoration( borderRadius: DesktopAIChatSizes.messageHoverActionBarRadius, border: Border.all( color: isLightMode ? const Color(0x1F1F2329) : Theme.of(context).dividerColor, strokeAlign: BorderSide.strokeAlignOutside, ), color: Theme.of(context).cardColor, boxShadow: [ BoxShadow( offset: const Offset(0, 1), blurRadius: 2, spreadRadius: -2, color: isLightMode ? const Color(0x051F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 4, color: isLightMode ? const Color(0x051F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 8, spreadRadius: 2, color: isLightMode ? const Color(0x051F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.02), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ BlocBuilder( builder: (context, state) { return ChangeFormatBar( spacing: 2.0, showImageFormats: state.modelState.type.isCloud, predefinedFormat: predefinedFormat, onSelectPredefinedFormat: (format) { setState(() => predefinedFormat = format); }, ); }, ), const HSpace(4.0), FlowyTooltip( message: LocaleKeys.chat_changeFormat_confirmButton.tr(), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { if (predefinedFormat != null) { widget.onRegenerate?.call(predefinedFormat!); } }, child: SizedBox.square( dimension: DesktopAIPromptSizes.predefinedFormatButtonHeight, child: Center( child: FlowySvg( FlowySvgs.ai_retry_filled_s, color: Theme.of(context).colorScheme.primary, size: const Size.square(20), ), ), ), ), ), ), ], ), ); } } class ChangeModelButton extends StatefulWidget { const ChangeModelButton({ super.key, required this.isInHoverBar, this.popoverMutex, this.onRegenerate, this.onOverrideVisibility, }); final bool isInHoverBar; final PopoverMutex? popoverMutex; final void Function(AIModelPB)? onRegenerate; final void Function(bool)? onOverrideVisibility; @override State createState() => _ChangeModelButtonState(); } class _ChangeModelButtonState extends State { final popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, mutex: widget.popoverMutex, triggerActions: PopoverTriggerFlags.none, margin: EdgeInsets.zero, offset: Offset(8, 0), direction: PopoverDirection.rightWithBottomAligned, constraints: BoxConstraints(maxWidth: 250, maxHeight: 600), onClose: () => widget.onOverrideVisibility?.call(false), child: buildButton(context), popupBuilder: (_) { final bloc = context.read(); final (models, _) = bloc.aiModelStateNotifier.getModelSelection(); return SelectModelPopoverContent( models: models, selectedModel: null, onSelectModel: widget.onRegenerate, ); }, ); } Widget buildButton(BuildContext context) { return FlowyTooltip( message: LocaleKeys.chat_switchModel_label.tr(), child: FlowyIconButton( width: 32.0, height: DesktopAIChatSizes.messageActionBarIconSize, hoverColor: AFThemeExtension.of(context).lightGreyHover, radius: widget.isInHoverBar ? DesktopAIChatSizes.messageHoverActionBarIconRadius : DesktopAIChatSizes.messageActionBarIconRadius, icon: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.ai_sparks_s, color: Theme.of(context).hintColor, size: const Size.square(16), ), FlowySvg( FlowySvgs.ai_source_drop_down_s, color: Theme.of(context).hintColor, size: const Size.square(8), ), ], ), onPressed: () { widget.onOverrideVisibility?.call(true); popoverController.show(); }, ), ); } } class SaveToPageButton extends StatefulWidget { const SaveToPageButton({ super.key, required this.textMessage, required this.isInHoverBar, this.popoverMutex, this.onOverrideVisibility, }); final TextMessage textMessage; final bool isInHoverBar; final PopoverMutex? popoverMutex; final void Function(bool)? onOverrideVisibility; @override State createState() => _SaveToPageButtonState(); } class _SaveToPageButtonState extends State { final popoverController = PopoverController(); @override Widget build(BuildContext context) { return ViewSelector( viewSelectorCubit: BlocProvider( create: (context) => ViewSelectorCubit( getIgnoreViewType: (item) { final view = item.view; if (view.isSpace) { return IgnoreViewType.none; } if (view.layout != ViewLayoutPB.Document) { return IgnoreViewType.hide; } return IgnoreViewType.none; }, ), ), child: BlocSelector( selector: (state) => state.currentSpace, builder: (context, spaceView) { return AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, margin: EdgeInsets.zero, mutex: widget.popoverMutex, offset: const Offset(8, 0), direction: PopoverDirection.rightWithBottomAligned, constraints: const BoxConstraints.tightFor(width: 300, height: 400), onClose: () { if (spaceView != null) { context .read() .refreshSources([spaceView], spaceView); } widget.onOverrideVisibility?.call(false); }, child: buildButton(context, spaceView), popupBuilder: (_) => buildPopover(context), ); }, ), ); } Widget buildButton(BuildContext context, ViewPB? spaceView) { return FlowyTooltip( message: LocaleKeys.chat_addToPageButton.tr(), child: FlowyIconButton( width: DesktopAIChatSizes.messageActionBarIconSize, hoverColor: AFThemeExtension.of(context).lightGreyHover, radius: widget.isInHoverBar ? DesktopAIChatSizes.messageHoverActionBarIconRadius : DesktopAIChatSizes.messageActionBarIconRadius, icon: FlowySvg( FlowySvgs.ai_add_to_page_s, color: Theme.of(context).hintColor, size: const Size.square(16), ), onPressed: () async { final documentId = getOpenedDocumentId(); if (documentId != null) { await onAddToExistingPage(context, documentId); await forceReload(documentId); await Future.delayed(const Duration(milliseconds: 500)); await updateSelection(documentId); } else { widget.onOverrideVisibility?.call(true); if (spaceView != null) { unawaited( context .read() .refreshSources([spaceView], spaceView), ); } popoverController.show(); } }, ), ); } Widget buildPopover(BuildContext context) { return BlocProvider.value( value: context.read(), child: SaveToPagePopoverContent( onAddToNewPage: (parentViewId) { addMessageToNewPage(context, parentViewId); popoverController.close(); }, onAddToExistingPage: (documentId) async { popoverController.close(); final view = await onAddToExistingPage(context, documentId); if (context.mounted) { openPageFromMessage(context, view); } await Future.delayed(const Duration(milliseconds: 500)); await updateSelection(documentId); }, ), ); } Future onAddToExistingPage( BuildContext context, String documentId, ) async { await ChatEditDocumentService.addMessagesToPage( documentId, [widget.textMessage], ); await Future.delayed(const Duration(milliseconds: 500)); final view = await ViewBackendService.getView(documentId).toNullable(); if (context.mounted) { showSaveMessageSuccessToast(context, view); } return view; } void addMessageToNewPage(BuildContext context, String parentViewId) async { final chatView = await ViewBackendService.getView( context.read().chatId, ).toNullable(); if (chatView != null) { final newView = await ChatEditDocumentService.saveMessagesToNewPage( chatView.nameOrDefault, parentViewId, [widget.textMessage], ); if (context.mounted) { showSaveMessageSuccessToast(context, newView); openPageFromMessage(context, newView); } } } Future forceReload(String documentId) async { final bloc = DocumentBloc.findOpen(documentId); if (bloc == null) { return; } await bloc.forceReloadDocumentState(); } Future updateSelection(String documentId) async { final bloc = DocumentBloc.findOpen(documentId); if (bloc == null) { return; } await bloc.forceReloadDocumentState(); final editorState = bloc.state.editorState; final lastNodePath = editorState?.getLastSelectable()?.$1.path; if (editorState == null || lastNodePath == null) { return; } unawaited( editorState.updateSelectionWithReason( Selection.collapsed(Position(path: lastNodePath)), ), ); } String? getOpenedDocumentId() { final pageManager = getIt().state.currentPageManager; if (!pageManager.showSecondaryPluginNotifier.value) { return null; } return pageManager.secondaryNotifier.plugin.id; } } class SaveToPagePopoverContent extends StatelessWidget { const SaveToPagePopoverContent({ super.key, required this.onAddToNewPage, required this.onAddToExistingPage, }); final void Function(String) onAddToNewPage; final void Function(String) onAddToExistingPage; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final theme = AppFlowyTheme.of(context); return Column( mainAxisSize: MainAxisSize.min, children: [ Container( height: 24, margin: const EdgeInsets.fromLTRB(12, 8, 12, 4), child: Align( alignment: AlignmentDirectional.centerStart, child: Text( LocaleKeys.chat_addToPageTitle.tr(), style: theme.textStyle.caption .standard(color: theme.textColorScheme.secondary), ), ), ), Padding( padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8), child: AFTextField( controller: context.read().filterTextController, hintText: LocaleKeys.search_label.tr(), size: AFTextFieldSize.m, ), ), AFDivider( startIndent: theme.spacing.l, endIndent: theme.spacing.l, ), Expanded( child: ListView( shrinkWrap: true, padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), children: _buildVisibleSources(context, state).toList(), ), ), ], ); }, ); } Iterable _buildVisibleSources( BuildContext context, ViewSelectorState state, ) { return state.visibleSources.map( (e) => ViewSelectorTreeItem( key: ValueKey( 'save_to_page_tree_item_${e.view.id}', ), viewSelectorItem: e, level: 0, isDescendentOfSpace: e.view.isSpace, isSelectedSection: false, showCheckbox: false, showSaveButton: true, onSelected: (source) { if (source.view.isSpace) { onAddToNewPage(source.view.id); } else { onAddToExistingPage(source.view.id); } }, onAdd: (source) { onAddToNewPage(source.view.id); }, height: 30.0, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart ================================================ import 'dart:convert'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; import '../layout_define.dart'; import 'ai_change_format_bottom_sheet.dart'; import 'ai_change_model_bottom_sheet.dart'; import 'ai_message_action_bar.dart'; import 'message_util.dart'; /// Wraps an AI response message with the avatar and actions. On desktop, /// the actions will be displayed below the response if the response is the /// last message in the chat. For the others, the actions will be shown on hover /// On mobile, the actions will be displayed in a bottom sheet on long press. class ChatAIMessageBubble extends StatelessWidget { const ChatAIMessageBubble({ super.key, required this.message, required this.child, required this.showActions, this.isLastMessage = false, this.isSelectingMessages = false, this.onRegenerate, this.onChangeFormat, this.onChangeModel, }); final Message message; final Widget child; final bool showActions; final bool isLastMessage; final bool isSelectingMessages; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; final void Function(AIModelPB)? onChangeModel; @override Widget build(BuildContext context) { final messageWidget = _WrapIsSelectingMessage( isSelectingMessages: isSelectingMessages, message: message, child: child, ); return !isSelectingMessages && showActions ? UniversalPlatform.isMobile ? _wrapPopMenu(messageWidget) : isLastMessage ? _wrapBottomActions(messageWidget) : _wrapHover(messageWidget) : messageWidget; } Widget _wrapBottomActions(Widget child) { return ChatAIBottomInlineActions( message: message, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, onChangeModel: onChangeModel, child: child, ); } Widget _wrapHover(Widget child) { return ChatAIMessageHover( message: message, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, onChangeModel: onChangeModel, child: child, ); } Widget _wrapPopMenu(Widget child) { return ChatAIMessagePopup( message: message, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, onChangeModel: onChangeModel, child: child, ); } } class ChatAIBottomInlineActions extends StatelessWidget { const ChatAIBottomInlineActions({ super.key, required this.child, required this.message, this.onRegenerate, this.onChangeFormat, this.onChangeModel, }); final Widget child; final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; final void Function(AIModelPB)? onChangeModel; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ child, const VSpace(16.0), AIMessageActionBar( message: message, showDecoration: false, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, onChangeModel: onChangeModel, ), const VSpace(16.0), ], ); } } class ChatAIMessageHover extends StatefulWidget { const ChatAIMessageHover({ super.key, required this.child, required this.message, this.onRegenerate, this.onChangeFormat, this.onChangeModel, }); final Widget child; final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; final void Function(AIModelPB)? onChangeModel; @override State createState() => _ChatAIMessageHoverState(); } class _ChatAIMessageHoverState extends State { final controller = OverlayPortalController(); final layerLink = LayerLink(); bool hoverBubble = false; bool hoverActionBar = false; bool overrideVisibility = false; ScrollPosition? scrollPosition; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { addScrollListener(); controller.show(); }); } @override Widget build(BuildContext context) { return MouseRegion( opaque: false, onEnter: (_) { if (!hoverBubble && isBottomOfWidgetVisible(context)) { setState(() => hoverBubble = true); } }, onHover: (_) { if (!hoverBubble && isBottomOfWidgetVisible(context)) { setState(() => hoverBubble = true); } }, onExit: (_) { if (hoverBubble) { setState(() => hoverBubble = false); } }, child: OverlayPortal( controller: controller, overlayChildBuilder: (_) { return CompositedTransformFollower( showWhenUnlinked: false, link: layerLink, targetAnchor: Alignment.bottomLeft, offset: const Offset(4.0, 0.0), child: Align( alignment: Alignment.topLeft, child: MouseRegion( opaque: false, onEnter: (_) { if (!hoverActionBar && isBottomOfWidgetVisible(context)) { setState(() => hoverActionBar = true); } }, onExit: (_) { if (hoverActionBar) { setState(() => hoverActionBar = false); } }, child: SizedBox( width: 784, height: DesktopAIChatSizes.messageActionBarIconSize + DesktopAIChatSizes.messageHoverActionBarPadding.vertical, child: hoverBubble || hoverActionBar || overrideVisibility ? Align( alignment: AlignmentDirectional.centerStart, child: AIMessageActionBar( message: widget.message, showDecoration: true, onRegenerate: widget.onRegenerate, onChangeFormat: widget.onChangeFormat, onChangeModel: widget.onChangeModel, onOverrideVisibility: (visibility) { overrideVisibility = visibility; }, ), ) : null, ), ), ), ); }, child: CompositedTransformTarget( link: layerLink, child: widget.child, ), ), ); } void addScrollListener() { if (!mounted) { return; } scrollPosition = Scrollable.maybeOf(context)?.position; scrollPosition?.addListener(handleScroll); } void handleScroll() { if (!mounted) { return; } if ((hoverActionBar || hoverBubble) && !isBottomOfWidgetVisible(context)) { setState(() { hoverBubble = false; hoverActionBar = false; }); } } bool isBottomOfWidgetVisible(BuildContext context) { if (Scrollable.maybeOf(context) == null) { return false; } final scrollableRenderBox = Scrollable.of(context).context.findRenderObject() as RenderBox; final scrollableHeight = scrollableRenderBox.size.height; final scrollableOffset = scrollableRenderBox.localToGlobal(Offset.zero); final messageRenderBox = context.findRenderObject() as RenderBox; final messageOffset = messageRenderBox.localToGlobal(Offset.zero); final messageHeight = messageRenderBox.size.height; return messageOffset.dy + messageHeight + DesktopAIChatSizes.messageActionBarIconSize + DesktopAIChatSizes.messageHoverActionBarPadding.vertical <= scrollableOffset.dy + scrollableHeight; } @override void dispose() { scrollPosition?.isScrollingNotifier.removeListener(handleScroll); super.dispose(); } } class ChatAIMessagePopup extends StatelessWidget { const ChatAIMessagePopup({ super.key, required this.child, required this.message, this.onRegenerate, this.onChangeFormat, this.onChangeModel, }); final Widget child; final Message message; final void Function()? onRegenerate; final void Function(PredefinedFormat)? onChangeFormat; final void Function(AIModelPB)? onChangeModel; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onLongPress: () { showMobileBottomSheet( context, showDragHandle: true, backgroundColor: AFThemeExtension.of(context).background, builder: (bottomSheetContext) { return Column( mainAxisSize: MainAxisSize.min, children: [ _copyButton(context, bottomSheetContext), _divider(), _regenerateButton(context), _divider(), _changeFormatButton(context), _divider(), _changeModelButton(context), _divider(), _saveToPageButton(context), ], ); }, ); }, child: child, ); } Widget _divider() => const MobileQuickActionDivider(); Widget _copyButton(BuildContext context, BuildContext bottomSheetContext) { return MobileQuickActionButton( onTap: () async { if (message is! TextMessage) { return; } final textMessage = message as TextMessage; final document = customMarkdownToDocument(textMessage.text); await getIt().setData( ClipboardServiceData( plainText: textMessage.text, inAppJson: jsonEncode(document.toJson()), ), ); if (bottomSheetContext.mounted) { Navigator.of(bottomSheetContext).pop(); } if (context.mounted) { showToastNotification( message: LocaleKeys.message_copy_success.tr(), ); } }, icon: FlowySvgs.copy_s, iconSize: const Size.square(20), text: LocaleKeys.button_copy.tr(), ); } Widget _regenerateButton(BuildContext context) { return MobileQuickActionButton( onTap: () { onRegenerate?.call(); Navigator.of(context).pop(); }, icon: FlowySvgs.ai_try_again_s, iconSize: const Size.square(20), text: LocaleKeys.chat_regenerate.tr(), ); } Widget _changeFormatButton(BuildContext context) { return MobileQuickActionButton( onTap: () async { final result = await showChangeFormatBottomSheet(context); if (result != null) { onChangeFormat?.call(result); if (context.mounted) { Navigator.of(context).pop(); } } }, icon: FlowySvgs.ai_retry_font_s, iconSize: const Size.square(20), text: LocaleKeys.chat_changeFormat_actionButton.tr(), ); } Widget _changeModelButton(BuildContext context) { return MobileQuickActionButton( onTap: () async { final bloc = context.read(); final (models, _) = bloc.aiModelStateNotifier.getModelSelection(); final result = await showChangeModelBottomSheet(context, models); if (result != null) { onChangeModel?.call(result); if (context.mounted) { Navigator.of(context).pop(); } } }, icon: FlowySvgs.ai_sparks_s, iconSize: const Size.square(20), text: LocaleKeys.chat_switchModel_label.tr(), ); } Widget _saveToPageButton(BuildContext context) { return MobileQuickActionButton( onTap: () async { final selectedView = await showPageSelectorSheet( context, filter: (view) => !view.isSpace && view.layout.isDocumentView && view.parentViewId != view.id, ); if (selectedView == null) { return; } await ChatEditDocumentService.addMessagesToPage( selectedView.id, [message as TextMessage], ); if (context.mounted) { context.pop(); openPageFromMessage(context, selectedView); } }, icon: FlowySvgs.ai_add_to_page_s, iconSize: const Size.square(20), text: LocaleKeys.chat_addToPageButton.tr(), ); } } class _WrapIsSelectingMessage extends StatelessWidget { const _WrapIsSelectingMessage({ required this.message, required this.child, this.isSelectingMessages = false, }); final Message message; final Widget child; final bool isSelectingMessages; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final isSelected = context.read().isMessageSelected(message.id); return GestureDetector( onTap: () { if (isSelectingMessages) { context .read() .add(ChatSelectMessageEvent.toggleSelectMessage(message)); } }, behavior: isSelectingMessages ? HitTestBehavior.opaque : null, child: DecoratedBox( decoration: BoxDecoration( color: isSelected ? Theme.of(context).colorScheme.tertiaryContainer : null, borderRadius: const BorderRadius.all(Radius.circular(8.0)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (isSelectingMessages) ...[ ChatSelectMessageIndicator(isSelected: isSelected), const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), ], Expanded( child: IgnorePointer( ignoring: isSelectingMessages, child: child, ), ), ], ), ), ); }, ); } } class ChatSelectMessageIndicator extends StatelessWidget { const ChatSelectMessageIndicator({ super.key, required this.isSelected, }); final bool isSelected; @override Widget build(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.click, child: SizedBox.square( dimension: 30.0, child: Center( child: FlowySvg( isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, blendMode: BlendMode.dst, size: const Size.square(20), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; import 'package:time/time.dart'; class AIMessageMetadata extends StatefulWidget { const AIMessageMetadata({ required this.sources, required this.onSelectedMetadata, super.key, }); final List sources; final void Function(ChatMessageRefSource metadata)? onSelectedMetadata; @override State createState() => _AIMessageMetadataState(); } class _AIMessageMetadataState extends State { bool isExpanded = true; @override Widget build(BuildContext context) { return AnimatedSize( duration: 150.milliseconds, alignment: AlignmentDirectional.topStart, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(8.0), ConstrainedBox( constraints: const BoxConstraints( maxHeight: 24, maxWidth: 240, ), child: FlowyButton( margin: const EdgeInsets.all(4.0), useIntrinsicWidth: true, hoverColor: Colors.transparent, radius: BorderRadius.circular(8.0), text: FlowyText( LocaleKeys.chat_referenceSource.plural( widget.sources.length, namedArgs: {'count': '${widget.sources.length}'}, ), fontSize: 12, color: Theme.of(context).hintColor, ), rightIcon: FlowySvg( isExpanded ? FlowySvgs.arrow_up_s : FlowySvgs.arrow_down_s, size: const Size.square(10), ), onTap: () { setState(() => isExpanded = !isExpanded); }, ), ), if (isExpanded) ...[ const VSpace(4.0), Wrap( spacing: 8.0, runSpacing: 4.0, children: widget.sources.map( (m) { if (isURL(m.id)) { return _MetadataButton( name: m.id, onTap: () => widget.onSelectedMetadata?.call(m), ); } else if (isUUID(m.id)) { return FutureBuilder( future: ViewBackendService.getView(m.id) .then((f) => f.toNullable()), builder: (context, snapshot) { final data = snapshot.data; if (!snapshot.hasData || snapshot.connectionState != ConnectionState.done || data == null) { return _MetadataButton( name: m.name, onTap: () => widget.onSelectedMetadata?.call(m), ); } return BlocProvider( create: (_) => ViewBloc(view: data), child: BlocBuilder( builder: (context, state) { return _MetadataButton( name: state.view.nameOrDefault, onTap: () => widget.onSelectedMetadata?.call(m), ); }, ), ); }, ); } else { return _MetadataButton( name: m.name, onTap: () => widget.onSelectedMetadata?.call(m), ); } }, ).toList(), ), ], ], ), ); } } class _MetadataButton extends StatelessWidget { const _MetadataButton({ this.name = "", this.onTap, }); final String name; final void Function()? onTap; @override Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints( maxHeight: 24, maxWidth: 240, ), child: FlowyButton( margin: const EdgeInsets.all(4.0), useIntrinsicWidth: true, radius: BorderRadius.circular(8.0), text: FlowyText( name, fontSize: 12, overflow: TextOverflow.ellipsis, ), leftIcon: FlowySvg( FlowySvgs.icon_document_s, size: const Size.square(16), color: Theme.of(context).hintColor, ), onTap: onTap, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_height_manager.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy/plugins/ai_chat/presentation/widgets/message_height_calculator.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import '../layout_define.dart'; import 'ai_markdown_text.dart'; import 'ai_message_bubble.dart'; import 'ai_metadata.dart'; import 'error_text_message.dart'; /// [ChatAIMessageWidget] includes both the text of the AI response as well as /// the avatar, decorations and hover effects that are also rendered. This is /// different from [ChatUserMessageWidget] which only contains the message and /// has to be separately wrapped with a bubble since the hover effects need to /// know the current streaming status of the message. class ChatAIMessageWidget extends StatelessWidget { const ChatAIMessageWidget({ super.key, required this.user, required this.messageUserId, required this.message, required this.stream, required this.questionId, required this.chatId, required this.refSourceJsonString, required this.onStopStream, this.onSelectedMetadata, this.onRegenerate, this.onChangeFormat, this.onChangeModel, this.isLastMessage = false, this.isStreaming = false, this.isSelectingMessages = false, this.enableAnimation = true, this.hasRelatedQuestions = false, }); final User user; final String messageUserId; final Message message; final AnswerStream? stream; final Int64? questionId; final String chatId; final String? refSourceJsonString; final void Function(ChatMessageRefSource metadata)? onSelectedMetadata; final void Function()? onRegenerate; final void Function() onStopStream; final void Function(PredefinedFormat)? onChangeFormat; final void Function(AIModelPB)? onChangeModel; final bool isStreaming; final bool isLastMessage; final bool isSelectingMessages; final bool enableAnimation; final bool hasRelatedQuestions; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => ChatAIMessageBloc( message: stream ?? (message as TextMessage).text, refSourceJsonString: refSourceJsonString, chatId: chatId, questionId: questionId, ), child: BlocConsumer( listenWhen: (previous, current) => previous.messageState != current.messageState, listener: (context, state) => _handleMessageState(state, context), builder: (context, blocState) { final loadingText = blocState.progress?.step ?? LocaleKeys.chat_generatingResponse.tr(); // Calculate minimum height only for the last AI answer message double minHeight = 0; if (isLastMessage && !hasRelatedQuestions) { final screenHeight = MediaQuery.of(context).size.height; minHeight = ChatMessageHeightManager().calculateMinHeight( messageId: message.id, screenHeight: screenHeight, ); } return Container( alignment: Alignment.topLeft, constraints: BoxConstraints( minHeight: minHeight, ), padding: AIChatUILayout.messageMargin, child: MessageHeightCalculator( messageId: message.id, onHeightMeasured: (messageId, height) { ChatMessageHeightManager().cacheWithoutMinHeight( messageId: messageId, height: height, ); }, child: blocState.messageState.when( loading: () => ChatAIMessageBubble( message: message, showActions: false, child: Padding( padding: const EdgeInsets.only(top: 8.0), child: AILoadingIndicator(text: loadingText), ), ), ready: () { return blocState.text.isEmpty ? _LoadingMessage( message: message, loadingText: loadingText, ) : _NonEmptyMessage( user: user, messageUserId: messageUserId, message: message, stream: stream, questionId: questionId, chatId: chatId, refSourceJsonString: refSourceJsonString, onStopStream: onStopStream, onSelectedMetadata: onSelectedMetadata, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, onChangeModel: onChangeModel, isLastMessage: isLastMessage, isStreaming: isStreaming, isSelectingMessages: isSelectingMessages, enableAnimation: enableAnimation, ); }, onError: (error) { return ChatErrorMessageWidget( errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(), ); }, onAIResponseLimit: () { return ChatErrorMessageWidget( errorMessage: LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(), ); }, onAIImageResponseLimit: () { return ChatErrorMessageWidget( errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(), ); }, onAIMaxRequired: (message) { return ChatErrorMessageWidget( errorMessage: message, ); }, onInitializingLocalAI: () { onStopStream(); return ChatErrorMessageWidget( errorMessage: LocaleKeys .settings_aiPage_keys_localAIInitializing .tr(), ); }, aiFollowUp: (followUpData) { return const SizedBox.shrink(); }, ), ), ); }, ), ); } void _handleMessageState(ChatAIMessageState state, BuildContext context) { if (state.stream?.error?.isEmpty != false) { state.messageState.maybeMap( aiFollowUp: (messageState) { context .read() .add(ChatEvent.onAIFollowUp(messageState.followUpData)); }, orElse: () { // do nothing }, ); return; } context.read().add(ChatEvent.deleteMessage(message)); } } class _LoadingMessage extends StatelessWidget { const _LoadingMessage({ required this.message, required this.loadingText, }); final Message message; final String loadingText; @override Widget build(BuildContext context) { return ChatAIMessageBubble( message: message, showActions: false, child: Padding( padding: EdgeInsetsDirectional.only(start: 4.0, top: 8.0), child: AILoadingIndicator(text: loadingText), ), ); } } class _NonEmptyMessage extends StatelessWidget { const _NonEmptyMessage({ required this.user, required this.messageUserId, required this.message, required this.stream, required this.questionId, required this.chatId, required this.refSourceJsonString, required this.onStopStream, this.onSelectedMetadata, this.onRegenerate, this.onChangeFormat, this.onChangeModel, this.isLastMessage = false, this.isStreaming = false, this.isSelectingMessages = false, this.enableAnimation = true, }); final User user; final String messageUserId; final Message message; final AnswerStream? stream; final Int64? questionId; final String chatId; final String? refSourceJsonString; final ValueChanged? onSelectedMetadata; final VoidCallback? onRegenerate; final VoidCallback onStopStream; final ValueChanged? onChangeFormat; final ValueChanged? onChangeModel; final bool isStreaming; final bool isLastMessage; final bool isSelectingMessages; final bool enableAnimation; @override Widget build(BuildContext context) { final state = context.read().state; final showActions = stream == null && state.text.isNotEmpty && !isStreaming; return ChatAIMessageBubble( message: message, isLastMessage: isLastMessage, showActions: showActions, isSelectingMessages: isSelectingMessages, onRegenerate: onRegenerate, onChangeFormat: onChangeFormat, onChangeModel: onChangeModel, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsetsDirectional.only(start: 4.0), child: AIMarkdownText( markdown: state.text, withAnimation: enableAnimation && stream != null, ), ), if (state.sources.isNotEmpty) SelectionContainer.disabled( child: AIMessageMetadata( sources: state.sources, onSelectedMetadata: onSelectedMetadata, ), ), if (state.sources.isNotEmpty && !isLastMessage) const VSpace(8.0), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/error_text_message.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class ChatErrorMessageWidget extends StatefulWidget { const ChatErrorMessageWidget({ super.key, required this.errorMessage, this.onRetry, }); final String errorMessage; final VoidCallback? onRetry; @override State createState() => _ChatErrorMessageWidgetState(); } class _ChatErrorMessageWidgetState extends State { late final TapGestureRecognizer recognizer; @override void initState() { super.initState(); recognizer = TapGestureRecognizer()..onTap = widget.onRetry; } @override void dispose() { recognizer.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Center( child: Container( margin: const EdgeInsets.only(top: 16.0, bottom: 24.0) + (UniversalPlatform.isMobile ? const EdgeInsets.symmetric(horizontal: 16) : EdgeInsets.zero), padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Theme.of(context).isLightMode ? const Color(0x80FFE7EE) : const Color(0x80591734), borderRadius: const BorderRadius.all(Radius.circular(8.0)), ), constraints: UniversalPlatform.isDesktop ? const BoxConstraints(maxWidth: 480) : null, child: Row( mainAxisSize: MainAxisSize.min, children: [ const FlowySvg( FlowySvgs.toast_error_filled_s, blendMode: null, ), const HSpace(8.0), Flexible( child: _buildText(), ), ], ), ), ); } Widget _buildText() { final errorMessage = widget.errorMessage; return widget.onRetry != null ? RichText( text: TextSpan( children: [ TextSpan( text: errorMessage, style: Theme.of(context).textTheme.bodyMedium, ), TextSpan( text: ' ', style: Theme.of(context).textTheme.bodyMedium, ), TextSpan( text: LocaleKeys.chat_retry.tr(), recognizer: recognizer, mouseCursor: SystemMouseCursors.click, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, decoration: TextDecoration.underline, ), ), ], ), ) : FlowyText( errorMessage, lineHeight: 1.4, maxLines: null, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; /// Opens a message in the right hand sidebar on desktop, and push the page /// on mobile void openPageFromMessage(BuildContext context, ViewPB? view) { if (view == null) { showToastNotification( message: LocaleKeys.chat_openPagePreviewFailedToast.tr(), type: ToastificationType.error, ); return; } if (UniversalPlatform.isDesktop) { getIt().add( TabsEvent.openSecondaryPlugin( plugin: view.plugin(), ), ); } else { context.pushView(view); } } void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) { if (view == null) { return; } showToastNotification( richMessage: TextSpan( children: [ TextSpan( text: LocaleKeys.chat_addToNewPageSuccessToast.tr(), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: const Color(0xFFFFFFFF), ), ), const TextSpan( text: ' ', ), TextSpan( text: view.nameOrDefault, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: const Color(0xFFFFFFFF), fontWeight: FontWeight.w700, ), ), ], ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_member_bloc.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import '../chat_avatar.dart'; import '../layout_define.dart'; class ChatUserMessageBubble extends StatelessWidget { const ChatUserMessageBubble({ super.key, required this.message, required this.child, this.files = const [], }); final Message message; final Widget child; final List files; @override Widget build(BuildContext context) { context .read() .add(ChatMemberEvent.getMemberInfo(message.author.id)); return Padding( padding: AIChatUILayout.messageMargin, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (files.isNotEmpty) ...[ Padding( padding: const EdgeInsets.only(right: 32), child: _MessageFileList(files: files), ), const VSpace(6), ], Row( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.end, children: [ const Spacer(), _buildBubble(context), const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing), _buildAvatar(), ], ), ], ), ); } Widget _buildAvatar() { return BlocBuilder( builder: (context, state) { final member = state.members[message.author.id]; return SelectionContainer.disabled( child: ChatUserAvatar( iconUrl: member?.info.avatarUrl ?? "", name: member?.info.name ?? "", ), ); }, ); } Widget _buildBubble(BuildContext context) { return Flexible( flex: 5, child: Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(16.0)), color: Theme.of(context).colorScheme.surfaceContainerHighest, ), padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0, ), child: child, ), ); } } class _MessageFileList extends StatelessWidget { const _MessageFileList({required this.files}); final List files; @override Widget build(BuildContext context) { final List children = files .map( (file) => _MessageFile( file: file, ), ) .toList(); return Wrap( direction: Axis.vertical, crossAxisAlignment: WrapCrossAlignment.end, spacing: 6, runSpacing: 6, children: children, ); } } class _MessageFile extends StatelessWidget { const _MessageFile({required this.file}); final ChatFile file; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Colors.transparent, borderRadius: BorderRadius.circular(10), border: Border.all( color: Theme.of(context).colorScheme.secondary, ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.page_m, size: const Size.square(16), color: Theme.of(context).hintColor, ), const HSpace(6), Flexible( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), child: FlowyText( file.fileName, fontSize: 12, maxLines: 6, ), ), ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart ================================================ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'user_message_bubble.dart'; class ChatUserMessageWidget extends StatelessWidget { const ChatUserMessageWidget({ super.key, required this.user, required this.message, }); final User user; final TextMessage message; @override Widget build(BuildContext context) { final stream = message.metadata?["$QuestionStream"]; final messageText = stream is QuestionStream ? stream.text : message.text; return BlocProvider( create: (context) => ChatUserMessageBloc( text: messageText, questionStream: stream, ), child: ChatUserMessageBubble( message: message, files: _getFiles(), child: BlocBuilder( builder: (context, state) { return Opacity( opacity: state.messageState.isFinish ? 1.0 : 0.8, child: TextMessageText( text: state.text, ), ); }, ), ), ); } List _getFiles() { if (message.metadata == null) { return const []; } final refSourceMetadata = message.metadata?[messageRefSourceJsonStringKey] as String?; if (refSourceMetadata != null) { return chatFilesFromMetadataString(refSourceMetadata); } final chatFileList = message.metadata![messageChatFileListKey] as List?; return chatFileList ?? []; } } /// Widget to reuse the markdown capabilities, e.g., for previews. class TextMessageText extends StatelessWidget { const TextMessageText({ super.key, required this.text, }); /// Text that is shown as markdown. final String text; @override Widget build(BuildContext context) { return FlowyText( text, lineHeight: 1.4, maxLines: null, color: AFThemeExtension.of(context).textColor, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; const BorderRadius _borderRadius = BorderRadius.all(Radius.circular(16)); class CustomScrollToBottom extends StatelessWidget { const CustomScrollToBottom({ super.key, required this.animation, required this.onPressed, }); final Animation animation; final VoidCallback onPressed; @override Widget build(BuildContext context) { final isLightMode = Theme.of(context).isLightMode; return Positioned( bottom: 24, left: 0, right: 0, child: Center( child: ScaleTransition( scale: animation, child: DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, border: Border.all( color: Theme.of(context).dividerColor, strokeAlign: BorderSide.strokeAlignOutside, ), borderRadius: _borderRadius, boxShadow: [ BoxShadow( offset: const Offset(0, 8), blurRadius: 16, spreadRadius: 8, color: isLightMode ? const Color(0x0F1F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.06), ), BoxShadow( offset: const Offset(0, 4), blurRadius: 8, color: isLightMode ? const Color(0x141F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.08), ), BoxShadow( offset: const Offset(0, 2), blurRadius: 4, color: isLightMode ? const Color(0x1F1F2329) : Theme.of(context).shadowColor.withValues(alpha: 0.12), ), ], ), child: Material( borderRadius: _borderRadius, color: Colors.transparent, borderOnForeground: false, child: InkWell( overlayColor: WidgetStateProperty.all( AFThemeExtension.of(context).lightGreyHover, ), borderRadius: _borderRadius, onTap: onPressed, child: const SizedBox.square( dimension: 32, child: Center( child: FlowySvg( FlowySvgs.ai_scroll_to_bottom_s, size: Size.square(20), ), ), ), ), ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/widgets/message_height_calculator.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; /// Callback type for height measurement typedef HeightMeasuredCallback = void Function(String messageId, double height); /// Widget that measures and caches message heights with proper lifecycle management class MessageHeightCalculator extends StatefulWidget { const MessageHeightCalculator({ super.key, required this.messageId, required this.child, this.onHeightMeasured, }); final String messageId; final Widget child; final HeightMeasuredCallback? onHeightMeasured; @override State createState() => _MessageHeightCalculatorState(); } class _MessageHeightCalculatorState extends State with WidgetsBindingObserver { final GlobalKey measureKey = GlobalKey(); double? lastMeasuredHeight; bool isMeasuring = false; int measurementAttempts = 0; static const int maxMeasurementAttempts = 3; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _scheduleMeasurement(); } @override void didUpdateWidget(MessageHeightCalculator oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.messageId != widget.messageId) { _resetMeasurement(); _scheduleMeasurement(); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeMetrics() { if (mounted) { _scheduleMeasurement(); } } @override Widget build(BuildContext context) { return KeyedSubtree( key: measureKey, child: widget.child, ); } void _resetMeasurement() { lastMeasuredHeight = null; isMeasuring = false; measurementAttempts = 0; } void _scheduleMeasurement() { if (isMeasuring || !mounted) return; isMeasuring = true; WidgetsBinding.instance.addPostFrameCallback((_) { _measureHeight(); }); } void _measureHeight() { if (!mounted || measurementAttempts >= maxMeasurementAttempts) { isMeasuring = false; return; } measurementAttempts++; try { final renderBox = measureKey.currentContext?.findRenderObject() as RenderBox?; if (renderBox == null || !renderBox.hasSize) { // Retry measurement in next frame if render box is not ready if (measurementAttempts < maxMeasurementAttempts) { SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) _measureHeight(); }); } else { isMeasuring = false; } return; } final height = renderBox.size.height; if (lastMeasuredHeight == null || (height - (lastMeasuredHeight ?? 0)).abs() > 1.0) { lastMeasuredHeight = height; widget.onHeightMeasured?.call(widget.messageId, height); } isMeasuring = false; } catch (e) { isMeasuring = false; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/base/color/color_picker.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class FlowyMobileColorPicker extends StatelessWidget { const FlowyMobileColorPicker({ super.key, required this.onSelectedColor, }); final void Function(FlowyColorOption? option) onSelectedColor; @override Widget build(BuildContext context) { const defaultColor = Colors.transparent; final colors = [ // reset to default background color FlowyColorOption( color: defaultColor, i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), id: optionActionColorDefaultColor, ), ...FlowyTint.values.map( (e) => FlowyColorOption( color: e.color(context), i18n: e.tintName(AppFlowyEditorL10n.current), id: e.id, ), ), ]; return ListView.separated( itemBuilder: (context, index) { final color = colors[index]; return SizedBox( height: 56, child: FlowyButton( useIntrinsicWidth: true, text: FlowyText( color.i18n, ), leftIcon: _ColorIcon( color: color.color, ), leftIconSize: const Size.square(36.0), iconPadding: 12.0, margin: const EdgeInsets.symmetric( horizontal: 12.0, vertical: 16.0, ), onTap: () => onSelectedColor(color), ), ); }, separatorBuilder: (_, __) => const Divider( height: 1, ), itemCount: colors.length, ); } } class _ColorIcon extends StatelessWidget { const _ColorIcon({required this.color}); final Color color; @override Widget build(BuildContext context) { return SizedBox.square( dimension: 24, child: DecoratedBox( decoration: BoxDecoration( color: color, shape: BoxShape.circle, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/base/color/color_picker_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/plugins/base/color/color_picker.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class MobileColorPickerScreen extends StatelessWidget { const MobileColorPickerScreen({super.key, this.title}); final String? title; static const routeName = '/color_picker'; static const pageTitle = 'title'; @override Widget build(BuildContext context) { return Scaffold( appBar: FlowyAppBar( titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(), ), body: SafeArea( child: FlowyMobileColorPicker( onSelectedColor: (option) => context.pop(option), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/base/drag_handler.dart ================================================ import 'package:flutter/material.dart'; class DragHandle extends StatelessWidget { const DragHandle({ super.key, }); @override Widget build(BuildContext context) { return Container( height: 4, width: 40, margin: const EdgeInsets.symmetric(vertical: 6), decoration: BoxDecoration( color: Colors.grey.shade400, borderRadius: BorderRadius.circular(2), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart ================================================ import 'dart:math'; import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart'; import 'package:appflowy/shared/icon_emoji_picker/emoji_search_bar.dart'; import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; // use a global value to store the selected emoji to prevent reloading every time. EmojiData? kCachedEmojiData; const _kRecentEmojiCategoryId = 'Recent'; class EmojiPickerResult { EmojiPickerResult({ required this.emojiId, required this.emoji, this.isRandom = false, }); final String emojiId; final String emoji; final bool isRandom; } class FlowyEmojiPicker extends StatefulWidget { const FlowyEmojiPicker({ super.key, required this.onEmojiSelected, this.emojiPerLine = 9, this.ensureFocus = false, }); final ValueChanged onEmojiSelected; final int emojiPerLine; final bool ensureFocus; @override State createState() => _FlowyEmojiPickerState(); } class _FlowyEmojiPickerState extends State { late EmojiData emojiData; bool loaded = false; @override void initState() { super.initState(); // load the emoji data from cache if it's available if (kCachedEmojiData != null) { loadEmojis(kCachedEmojiData!); } else { EmojiData.builtIn().then( (value) { kCachedEmojiData = value; loadEmojis(value); }, ); } } @override Widget build(BuildContext context) { if (!loaded) { return const Center( child: SizedBox.square( dimension: 24.0, child: CircularProgressIndicator( strokeWidth: 2.0, ), ), ); } return EmojiPicker( emojiData: emojiData, configuration: EmojiPickerConfiguration( showTabs: false, defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, ), onEmojiSelected: (id, emoji) { widget.onEmojiSelected.call( EmojiPickerResult(emojiId: id, emoji: emoji), ); RecentIcons.putEmoji(id); }, padding: const EdgeInsets.symmetric(horizontal: 16.0), headerBuilder: (_, category) => FlowyEmojiHeader(category: category), itemBuilder: (context, emojiId, emoji, callback) { final name = emojiData.emojis[emojiId]?.name ?? ''; return SizedBox.square( dimension: 36.0, child: FlowyButton( margin: EdgeInsets.zero, radius: Corners.s8Border, text: FlowyTooltip( message: name, preferBelow: false, child: FlowyText.emoji( emoji, fontSize: 24.0, ), ), onTap: () => callback(emojiId, emoji), ), ); }, searchBarBuilder: (context, keyword, skinTone) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: FlowyEmojiSearchBar( emojiData: emojiData, ensureFocus: widget.ensureFocus, onKeywordChanged: (value) { keyword.value = value; }, onSkinToneChanged: (value) { skinTone.value = value; }, onRandomEmojiSelected: (id, emoji) { widget.onEmojiSelected.call( EmojiPickerResult(emojiId: id, emoji: emoji, isRandom: true), ); RecentIcons.putEmoji(id); }, ), ); }, ); } void loadEmojis(EmojiData data) { RecentIcons.getEmojiIds().then((v) { if (v.isEmpty) { emojiData = data; if (mounted) setState(() => loaded = true); return; } final categories = List.of(data.categories); categories.insert( 0, Category( id: _kRecentEmojiCategoryId, emojiIds: v.sublist(0, min(widget.emojiPerLine, v.length)), ), ); emojiData = EmojiData(categories: categories, emojis: data.emojis); if (mounted) setState(() => loaded = true); }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_header.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:universal_platform/universal_platform.dart'; class FlowyEmojiHeader extends StatelessWidget { const FlowyEmojiHeader({ super.key, required this.category, }); final Category category; @override Widget build(BuildContext context) { if (UniversalPlatform.isDesktop) { return Container( height: 22, color: Theme.of(context).cardColor, child: Padding( padding: const EdgeInsets.only(bottom: 4.0), child: FlowyText.regular( category.id.capitalize(), color: Theme.of(context).hintColor, ), ), ); } else { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 40, width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 8.0), color: Theme.of(context).cardColor, child: Padding( padding: const EdgeInsets.only( top: 14.0, bottom: 4.0, ), child: FlowyText.regular(category.id), ), ), const Divider( height: 1, thickness: 1, ), ], ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_screen.dart ================================================ import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../generated/locale_keys.g.dart'; import '../../../mobile/presentation/base/app_bar/app_bar.dart'; import '../../../shared/icon_emoji_picker/tab.dart'; class MobileEmojiPickerScreen extends StatelessWidget { const MobileEmojiPickerScreen({ super.key, this.title, this.selectedType, this.documentId, this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); final PickerTabType? selectedType; final String? title; final String? documentId; final List tabs; static const routeName = '/emoji_picker'; static const pageTitle = 'title'; static const iconSelectedType = 'iconSelected_type'; static const selectTabs = 'tabs'; static const uploadDocumentId = 'document_id'; @override Widget build(BuildContext context) { return Scaffold( appBar: FlowyAppBar( titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(), ), body: SafeArea( child: FlowyIconEmojiPicker( tabs: tabs, documentId: documentId, initialType: selectedType, onSelectedEmoji: (r) { context.pop(r.data); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_text.dart ================================================ import 'dart:io'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; // used to prevent loading font from google fonts every time List? _cachedFallbackFontFamily; // Some emojis are not supported by the default font on Android or Linux, fallback to noto color emoji class EmojiText extends StatelessWidget { const EmojiText({ super.key, required this.emoji, required this.fontSize, this.textAlign, this.lineHeight, }); final String emoji; final double fontSize; final TextAlign? textAlign; final double? lineHeight; @override Widget build(BuildContext context) { _loadFallbackFontFamily(); return FlowyText( emoji, fontSize: fontSize, textAlign: textAlign, strutStyle: const StrutStyle(forceStrutHeight: true), fallbackFontFamily: _cachedFallbackFontFamily, lineHeight: lineHeight, isEmoji: true, ); } void _loadFallbackFontFamily() { if (Platform.isLinux) { final notoColorEmoji = GoogleFonts.notoColorEmoji().fontFamily; if (notoColorEmoji != null) { _cachedFallbackFontFamily = [notoColorEmoji]; } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/base/icon/icon_widget.dart ================================================ import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:flutter/material.dart'; import '../../../generated/flowy_svgs.g.dart'; class IconWidget extends StatelessWidget { const IconWidget({super.key, required this.size, required this.iconsData}); final IconsData iconsData; final double size; @override Widget build(BuildContext context) { final colorValue = int.tryParse(iconsData.color ?? ''); Color? color; if (colorValue != null) { color = Color(colorValue); } final svgString = iconsData.svgString; if (svgString == null) { return EmojiText( emoji: '❓', fontSize: size, textAlign: TextAlign.center, ); } return FlowySvg.string( svgString, size: Size.square(size), color: color, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/blank/blank.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class BlankPluginBuilder extends PluginBuilder { @override Plugin build(dynamic data) { return BlankPagePlugin(); } @override String get menuName => "Blank"; @override FlowySvgData get icon => const FlowySvgData(''); @override PluginType get pluginType => PluginType.blank; @override ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class BlankPluginConfig implements PluginConfig { @override bool get creatable => false; } class BlankPagePlugin extends Plugin { @override PluginWidgetBuilder get widgetBuilder => BlankPagePluginWidgetBuilder(); @override PluginId get id => ""; @override PluginType get pluginType => PluginType.blank; } class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder with NavigationItem { @override String? get viewName => LocaleKeys.blankPageTitle.tr(); @override Widget get leftBarItem => FlowyText.medium(LocaleKeys.blankPageTitle.tr()); @override Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem; @override Widget buildWidget({ required PluginContext context, required bool shrinkWrap, Map? data, }) => const BlankPage(); @override List get navigationItems => [this]; } class BlankPage extends StatefulWidget { const BlankPage({super.key}); @override State createState() => _BlankPageState(); } class _BlankPageState extends State { @override Widget build(BuildContext context) { return SizedBox.expand( child: Container( color: Theme.of(context).colorScheme.surface, child: const Padding( padding: EdgeInsets.all(10), child: SizedBox.shrink(), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculation_type_ext.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; extension CalcTypeLabel on CalculationType { String get label => switch (this) { CalculationType.Average => LocaleKeys.grid_calculationTypeLabel_average.tr(), CalculationType.Max => LocaleKeys.grid_calculationTypeLabel_max.tr(), CalculationType.Median => LocaleKeys.grid_calculationTypeLabel_median.tr(), CalculationType.Min => LocaleKeys.grid_calculationTypeLabel_min.tr(), CalculationType.Sum => LocaleKeys.grid_calculationTypeLabel_sum.tr(), CalculationType.Count => LocaleKeys.grid_calculationTypeLabel_count.tr(), CalculationType.CountEmpty => LocaleKeys.grid_calculationTypeLabel_countEmpty.tr(), CalculationType.CountNonEmpty => LocaleKeys.grid_calculationTypeLabel_countNonEmpty.tr(), _ => throw UnimplementedError( 'Label for $this has not been implemented', ), }; String get shortLabel => switch (this) { CalculationType.CountEmpty => LocaleKeys.grid_calculationTypeLabel_countEmptyShort.tr(), CalculationType.CountNonEmpty => LocaleKeys.grid_calculationTypeLabel_countNonEmptyShort.tr(), _ => label, }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef UpdateCalculationValue = FlowyResult; class CalculationsListener { CalculationsListener({required this.viewId}); final String viewId; PublishNotifier? _calculationNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({ required void Function(UpdateCalculationValue) onCalculationChanged, }) { _calculationNotifier?.addPublishListener(onCalculationChanged); _listener = DatabaseNotificationListener( objectId: viewId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateCalculation: _calculationNotifier?.value = result.fold( (payload) => FlowyResult.success( CalculationChangesetNotificationPB.fromBuffer(payload), ), (err) => FlowyResult.failure(err), ); default: break; } } Future stop() async { await _listener?.stop(); _calculationNotifier?.dispose(); _calculationNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class CalculationsBackendService { const CalculationsBackendService({required this.viewId}); final String viewId; // Get Calculations (initial fetch) Future> getCalculations() async { final payload = DatabaseViewIdPB()..value = viewId; return DatabaseEventGetAllCalculations(payload).send(); } Future updateCalculation( String fieldId, CalculationType type, { String? calculationId, }) async { final payload = UpdateCalculationChangesetPB() ..viewId = viewId ..fieldId = fieldId ..calculationType = type; if (calculationId != null) { payload.calculationId = calculationId; } await DatabaseEventUpdateCalculation(payload).send(); } Future removeCalculation( String fieldId, String calculationId, ) async { final payload = RemoveCalculationChangesetPB() ..viewId = viewId ..fieldId = fieldId ..calculationId = calculationId; await DatabaseEventRemoveCalculation(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'checkbox_cell_bloc.freezed.dart'; class CheckboxCellBloc extends Bloc { CheckboxCellBloc({ required this.cellController, }) : super(CheckboxCellState.initial(cellController)) { _dispatch(); } final CheckboxCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) { event.when( initial: () => _startListening(), didUpdateCell: (isSelected) { emit(state.copyWith(isSelected: isSelected)); }, didUpdateField: (fieldName) { emit(state.copyWith(fieldName: fieldName)); }, select: () { cellController.saveCellData(state.isSelected ? "No" : "Yes"); }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cellData) { if (!isClosed) { add(CheckboxCellEvent.didUpdateCell(_isSelected(cellData))); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(CheckboxCellEvent.didUpdateField(fieldInfo.name)); } } } @freezed class CheckboxCellEvent with _$CheckboxCellEvent { const factory CheckboxCellEvent.initial() = _Initial; const factory CheckboxCellEvent.select() = _Selected; const factory CheckboxCellEvent.didUpdateCell(bool isSelected) = _DidUpdateCell; const factory CheckboxCellEvent.didUpdateField(String fieldName) = _DidUpdateField; } @freezed class CheckboxCellState with _$CheckboxCellState { const factory CheckboxCellState({ required bool isSelected, required String fieldName, }) = _CheckboxCellState; factory CheckboxCellState.initial(CheckboxCellController cellController) { return CheckboxCellState( isSelected: _isSelected(cellController.getCellData()), fieldName: cellController.fieldInfo.field.name, ); } } bool _isSelected(CheckboxCellDataPB? cellData) { return cellData != null && cellData.isChecked; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/domain/checklist_cell_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'checklist_cell_bloc.freezed.dart'; class ChecklistSelectOption { ChecklistSelectOption({required this.isSelected, required this.data}); final bool isSelected; final SelectOptionPB data; } class ChecklistCellBloc extends Bloc { ChecklistCellBloc({required this.cellController}) : _checklistCellService = ChecklistCellBackendService( viewId: cellController.viewId, fieldId: cellController.fieldId, rowId: cellController.rowId, ), super(ChecklistCellState.initial(cellController)) { _dispatch(); _startListening(); } final ChecklistCellController cellController; final ChecklistCellBackendService _checklistCellService; void Function()? _onCellChangedFn; int? nextPhantomIndex; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener(onCellChanged: _onCellChangedFn!); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( didUpdateCell: (data) { if (data == null) { emit( const ChecklistCellState( tasks: [], percent: 0, showIncompleteOnly: false, phantomIndex: null, ), ); return; } final phantomIndex = state.phantomIndex != null ? nextPhantomIndex ?? state.phantomIndex : null; emit( state.copyWith( tasks: _makeChecklistSelectOptions(data), percent: data.percentage, phantomIndex: phantomIndex, ), ); nextPhantomIndex = null; }, updateTaskName: (option, name) { _updateOption(option, name); }, selectTask: (id) async { await _checklistCellService.select(optionId: id); }, createNewTask: (name, index) async { await _createTask(name, index); }, deleteTask: (id) async { await _deleteOption([id]); }, reorderTask: (fromIndex, toIndex) async { await _reorderTask(fromIndex, toIndex, emit); }, toggleShowIncompleteOnly: () { emit(state.copyWith(showIncompleteOnly: !state.showIncompleteOnly)); }, updatePhantomIndex: (index) { emit( ChecklistCellState( tasks: state.tasks, percent: state.percent, showIncompleteOnly: state.showIncompleteOnly, phantomIndex: index, ), ); }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (data) { if (!isClosed) { add(ChecklistCellEvent.didUpdateCell(data)); } }, ); } Future _createTask(String name, int? index) async { nextPhantomIndex = index == null ? state.tasks.length + 1 : index + 1; int? actualIndex = index; if (index != null && state.showIncompleteOnly) { int notSelectedTaskCount = 0; for (int i = 0; i < state.tasks.length; i++) { if (!state.tasks[i].isSelected) { notSelectedTaskCount++; } if (notSelectedTaskCount == index) { actualIndex = i + 1; break; } } } final result = await _checklistCellService.create( name: name, index: actualIndex, ); result.fold((l) {}, (err) => Log.error(err)); } void _updateOption(SelectOptionPB option, String name) async { final result = await _checklistCellService.updateName(option: option, name: name); result.fold((l) => null, (err) => Log.error(err)); } Future _deleteOption(List options) async { final result = await _checklistCellService.delete(optionIds: options); result.fold((l) => null, (err) => Log.error(err)); } Future _reorderTask( int fromIndex, int toIndex, Emitter emit, ) async { if (fromIndex < toIndex) { toIndex--; } final tasks = state.showIncompleteOnly ? state.tasks.where((task) => !task.isSelected).toList() : state.tasks; final fromId = tasks[fromIndex].data.id; final toId = tasks[toIndex].data.id; final newTasks = [...state.tasks]; newTasks.insert(toIndex, newTasks.removeAt(fromIndex)); emit(state.copyWith(tasks: newTasks)); final result = await _checklistCellService.reorder( fromTaskId: fromId, toTaskId: toId, ); result.fold((l) => null, (err) => Log.error(err)); } } @freezed class ChecklistCellEvent with _$ChecklistCellEvent { const factory ChecklistCellEvent.didUpdateCell( ChecklistCellDataPB? data, ) = _DidUpdateCell; const factory ChecklistCellEvent.updateTaskName( SelectOptionPB option, String name, ) = _UpdateTaskName; const factory ChecklistCellEvent.selectTask(String taskId) = _SelectTask; const factory ChecklistCellEvent.createNewTask( String description, { int? index, }) = _CreateNewTask; const factory ChecklistCellEvent.deleteTask(String taskId) = _DeleteTask; const factory ChecklistCellEvent.reorderTask(int fromIndex, int toIndex) = _ReorderTask; const factory ChecklistCellEvent.toggleShowIncompleteOnly() = _IncompleteOnly; const factory ChecklistCellEvent.updatePhantomIndex(int? index) = _UpdatePhantomIndex; } @freezed class ChecklistCellState with _$ChecklistCellState { const factory ChecklistCellState({ required List tasks, required double percent, required bool showIncompleteOnly, required int? phantomIndex, }) = _ChecklistCellState; factory ChecklistCellState.initial(ChecklistCellController cellController) { final cellData = cellController.getCellData(loadIfNotExist: true); return ChecklistCellState( tasks: _makeChecklistSelectOptions(cellData), percent: cellData?.percentage ?? 0, showIncompleteOnly: false, phantomIndex: null, ); } } List _makeChecklistSelectOptions( ChecklistCellDataPB? data, ) { if (data == null) { return []; } return data.options .map( (option) => ChecklistSelectOption( isSelected: data.selectedOptions.any( (selected) => selected.id == option.id, ), data: option, ), ) .toList(); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_bloc.dart ================================================ import 'dart:async'; import 'dart:ui'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'date_cell_editor_bloc.dart'; part 'date_cell_bloc.freezed.dart'; class DateCellBloc extends Bloc { DateCellBloc({required this.cellController}) : super(DateCellState.initial(cellController)) { _dispatch(); _startListening(); } final DateCellController cellController; VoidCallback? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { event.when( didReceiveCellUpdate: (DateCellDataPB? cellData) { final dateCellData = DateCellData.fromPB(cellData); emit( state.copyWith( cellData: dateCellData, ), ); }, didUpdateField: (fieldInfo) { emit( state.copyWith( fieldInfo: fieldInfo, ), ); }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (data) { if (!isClosed) { add(DateCellEvent.didReceiveCellUpdate(data)); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(DateCellEvent.didUpdateField(fieldInfo)); } } } @freezed class DateCellEvent with _$DateCellEvent { const factory DateCellEvent.didReceiveCellUpdate(DateCellDataPB? data) = _DidReceiveCellUpdate; const factory DateCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; } @freezed class DateCellState with _$DateCellState { const factory DateCellState({ required FieldInfo fieldInfo, required DateCellData cellData, }) = _DateCellState; factory DateCellState.initial(DateCellController cellController) { final cellData = DateCellData.fromPB(cellController.getCellData()); return DateCellState( fieldInfo: cellController.fieldInfo, cellData: cellData, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/domain/date_cell_service.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart' show StringTranslateExtension; import 'package:fixnum/fixnum.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:nanoid/non_secure.dart'; import 'package:protobuf/protobuf.dart' hide FieldInfo; part 'date_cell_editor_bloc.freezed.dart'; class DateCellEditorBloc extends Bloc { DateCellEditorBloc({ required this.cellController, required ReminderBloc reminderBloc, }) : _reminderBloc = reminderBloc, _dateCellBackendService = DateCellBackendService( viewId: cellController.viewId, fieldId: cellController.fieldId, rowId: cellController.rowId, ), super(DateCellEditorState.initial(cellController, reminderBloc)) { _dispatch(); _startListening(); } final DateCellBackendService _dateCellBackendService; final DateCellController cellController; final ReminderBloc _reminderBloc; void Function()? _onCellChangedFn; void _dispatch() { on( (event, emit) async { await event.when( didReceiveCellUpdate: (DateCellDataPB? cellData) { final dateCellData = DateCellData.fromPB(cellData); ReminderOption reminderOption = state.reminderOption; if (dateCellData.reminderId.isNotEmpty && dateCellData.dateTime != null && reminderOption != ReminderOption.none) { final reminder = _reminderBloc.state.reminders .firstWhereOrNull((r) => r.id == dateCellData.reminderId); if (reminder != null) { reminderOption = ReminderOption.fromDateDifference( dateCellData.dateTime!, reminder.scheduledAt.toDateTime(), ); } } emit( state.copyWith( dateTime: dateCellData.dateTime, endDateTime: dateCellData.endDateTime, includeTime: dateCellData.includeTime, isRange: dateCellData.isRange, reminderId: dateCellData.reminderId, reminderOption: reminderOption, ), ); }, didUpdateField: (field) { final typeOption = DateTypeOptionDataParser() .fromBuffer(field.field.typeOptionData); emit(state.copyWith(dateTypeOptionPB: typeOption)); }, updateDateTime: (date) async { if (state.isRange) { return; } await _updateDateData(date: date); }, updateDateRange: (DateTime start, DateTime end) async { if (!state.isRange) { return; } await _updateDateData(date: start, endDate: end); }, setIncludeTime: (includeTime, dateTime, endDateTime) async { await _updateIncludeTime(includeTime, dateTime, endDateTime); }, setIsRange: (isRange, dateTime, endDateTime) async { await _updateIsRange(isRange, dateTime, endDateTime); }, setDateFormat: (DateFormatPB dateFormat) async { await _updateTypeOption(emit, dateFormat: dateFormat); }, setTimeFormat: (TimeFormatPB timeFormat) async { await _updateTypeOption(emit, timeFormat: timeFormat); }, clearDate: () async { // Remove reminder if neccessary if (state.reminderId.isNotEmpty) { _reminderBloc.add( ReminderEvent.removeReminder(reminderId: state.reminderId), ); } await _clearDate(); }, setReminderOption: (ReminderOption option) async { emit(state.copyWith(reminderOption: option)); await _setReminderOption(option); }, ); }, ); } Future> _updateDateData({ DateTime? date, DateTime? endDate, bool updateReminderIfNecessary = true, }) async { final result = await _dateCellBackendService.update( date: date, endDate: endDate, ); if (updateReminderIfNecessary) { result.onSuccess((_) => _updateReminderIfNecessary(date)); } return result; } Future _updateIsRange( bool isRange, DateTime? dateTime, DateTime? endDateTime, ) { return _dateCellBackendService .update( date: dateTime, endDate: endDateTime, isRange: isRange, ) .fold((s) => _updateReminderIfNecessary(dateTime), Log.error); } Future _updateIncludeTime( bool includeTime, DateTime? dateTime, DateTime? endDateTime, ) { return _dateCellBackendService .update( date: dateTime, endDate: endDateTime, includeTime: includeTime, ) .fold((s) => _updateReminderIfNecessary(dateTime), Log.error); } Future _clearDate() async { final result = await _dateCellBackendService.clear(); result.onFailure(Log.error); } Future _setReminderOption(ReminderOption option) async { if (state.reminderId.isEmpty) { if (option == ReminderOption.none) { // do nothing return; } // if no date, fill it first final fillerDateTime = state.includeTime ? DateTime.now() : DateTime.now().withoutTime; if (state.dateTime == null) { final result = await _updateDateData( date: fillerDateTime, endDate: state.isRange ? fillerDateTime : null, updateReminderIfNecessary: false, ); // return if filling date is unsuccessful if (result.isFailure) { return; } } // create a reminder final reminderId = nanoid(); await _updateCellReminderId(reminderId); final dateTime = state.dateTime ?? fillerDateTime; _reminderBloc.add( ReminderEvent.addById( reminderId: reminderId, objectId: cellController.viewId, meta: { ReminderMetaKeys.includeTime: state.includeTime.toString(), ReminderMetaKeys.rowId: cellController.rowId, }, scheduledAt: Int64( option.getNotificationDateTime(dateTime).millisecondsSinceEpoch ~/ 1000, ), ), ); } else { if (option == ReminderOption.none) { // remove reminder from reminder bloc and cell data _reminderBloc .add(ReminderEvent.removeReminder(reminderId: state.reminderId)); await _updateCellReminderId(""); } else { // Update reminder final scheduledAt = option.getNotificationDateTime(state.dateTime!); _reminderBloc.add( ReminderEvent.update( ReminderUpdate( id: state.reminderId, scheduledAt: scheduledAt, includeTime: state.includeTime, ), ), ); } } } Future _updateCellReminderId( String reminderId, ) async { final result = await _dateCellBackendService.update( reminderId: reminderId, ); result.onFailure(Log.error); } void _updateReminderIfNecessary( DateTime? dateTime, ) { if (state.reminderId.isEmpty || dateTime == null) { return; } final scheduledAt = state.reminderOption.getNotificationDateTime(dateTime); // Update Reminder _reminderBloc.add( ReminderEvent.update( ReminderUpdate( id: state.reminderId, scheduledAt: scheduledAt, includeTime: state.includeTime, ), ), ); } String timeFormatPrompt(FlowyError error) { return switch (state.dateTypeOptionPB.timeFormat) { TimeFormatPB.TwelveHour => "${LocaleKeys.grid_field_invalidTimeFormat.tr()}. e.g. 01:00 PM", TimeFormatPB.TwentyFourHour => "${LocaleKeys.grid_field_invalidTimeFormat.tr()}. e.g. 13:00", _ => "", }; } @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } return super.close(); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cell) { if (!isClosed) { add(DateCellEditorEvent.didReceiveCellUpdate(cell)); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(DateCellEditorEvent.didUpdateField(fieldInfo)); } } Future _updateTypeOption( Emitter emit, { DateFormatPB? dateFormat, TimeFormatPB? timeFormat, }) async { state.dateTypeOptionPB.freeze(); final newDateTypeOption = state.dateTypeOptionPB.rebuild((typeOption) { if (dateFormat != null) { typeOption.dateFormat = dateFormat; } if (timeFormat != null) { typeOption.timeFormat = timeFormat; } }); final result = await FieldBackendService.updateFieldTypeOption( viewId: cellController.viewId, fieldId: cellController.fieldInfo.id, typeOptionData: newDateTypeOption.writeToBuffer(), ); result.onFailure(Log.error); } } @freezed class DateCellEditorEvent with _$DateCellEditorEvent { const factory DateCellEditorEvent.didUpdateField( FieldInfo fieldInfo, ) = _DidUpdateField; // notification that cell is updated in the backend const factory DateCellEditorEvent.didReceiveCellUpdate( DateCellDataPB? data, ) = _DidReceiveCellUpdate; const factory DateCellEditorEvent.updateDateTime(DateTime day) = _UpdateDateTime; const factory DateCellEditorEvent.updateDateRange( DateTime start, DateTime end, ) = _UpdateDateRange; const factory DateCellEditorEvent.setIncludeTime( bool includeTime, DateTime? dateTime, DateTime? endDateTime, ) = _IncludeTime; const factory DateCellEditorEvent.setIsRange( bool isRange, DateTime? dateTime, DateTime? endDateTime, ) = _SetIsRange; const factory DateCellEditorEvent.setReminderOption(ReminderOption option) = _SetReminderOption; // date field type options are modified const factory DateCellEditorEvent.setTimeFormat(TimeFormatPB timeFormat) = _SetTimeFormat; const factory DateCellEditorEvent.setDateFormat(DateFormatPB dateFormat) = _SetDateFormat; const factory DateCellEditorEvent.clearDate() = _ClearDate; } @freezed class DateCellEditorState with _$DateCellEditorState { const factory DateCellEditorState({ // the date field's type option required DateTypeOptionPB dateTypeOptionPB, // cell data from the backend required DateTime? dateTime, required DateTime? endDateTime, required bool includeTime, required bool isRange, required String reminderId, @Default(ReminderOption.none) ReminderOption reminderOption, }) = _DateCellEditorState; factory DateCellEditorState.initial( DateCellController controller, ReminderBloc reminderBloc, ) { final typeOption = controller.getTypeOption(DateTypeOptionDataParser()); final cellData = controller.getCellData(); final dateCellData = DateCellData.fromPB(cellData); ReminderOption reminderOption = ReminderOption.none; if (dateCellData.reminderId.isNotEmpty && dateCellData.dateTime != null) { final reminder = reminderBloc.state.allReminders .firstWhereOrNull((r) => r.id == dateCellData.reminderId); if (reminder != null) { final eventDate = dateCellData.includeTime ? dateCellData.dateTime! : dateCellData.dateTime!.withoutTime; reminderOption = ReminderOption.fromDateDifference( eventDate, reminder.scheduledAt.toDateTime(), ); } } return DateCellEditorState( dateTypeOptionPB: typeOption, dateTime: dateCellData.dateTime, endDateTime: dateCellData.endDateTime, includeTime: dateCellData.includeTime, isRange: dateCellData.isRange, reminderId: dateCellData.reminderId, reminderOption: reminderOption, ); } } /// Helper class to parse ProtoBuf payloads into DateCellEditorState class DateCellData { const DateCellData({ required this.dateTime, required this.endDateTime, required this.includeTime, required this.isRange, required this.reminderId, }); const DateCellData.empty() : dateTime = null, endDateTime = null, includeTime = false, isRange = false, reminderId = ""; factory DateCellData.fromPB(DateCellDataPB? cellData) { // a null DateCellDataPB may be returned, indicating that all the fields are // their default values: empty strings and false booleans if (cellData == null) { return const DateCellData.empty(); } final dateTime = cellData.hasTimestamp() ? cellData.timestamp.toDateTime() : null; final endDateTime = dateTime == null || !cellData.isRange ? null : cellData.hasEndTimestamp() ? cellData.endTimestamp.toDateTime() : null; return DateCellData( dateTime: dateTime, endDateTime: endDateTime, includeTime: cellData.includeTime, isRange: cellData.isRange, reminderId: cellData.reminderId, ); } final DateTime? dateTime; final DateTime? endDateTime; final bool includeTime; final bool isRange; final String reminderId; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'media_cell_bloc.freezed.dart'; class MediaCellBloc extends Bloc { MediaCellBloc({ required this.cellController, }) : super(MediaCellState.initial(cellController)) { _dispatch(); _startListening(); } late final RowBackendService _rowService = RowBackendService(viewId: cellController.viewId); final MediaCellController cellController; void Function()? _onCellChangedFn; String get databaseId => cellController.viewId; String get rowId => cellController.rowId; bool get wrapContent => cellController.fieldInfo.wrapCellContent ?? false; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { // Fetch user profile final userProfileResult = await UserBackendService.getCurrentUserProfile(); userProfileResult.fold( (userProfile) => emit(state.copyWith(userProfile: userProfile)), (l) => Log.error(l), ); }, didUpdateCell: (files) { emit(state.copyWith(files: files)); }, didUpdateField: (fieldName) { final typeOption = cellController.getTypeOption(MediaTypeOptionDataParser()); emit( state.copyWith( fieldName: fieldName, hideFileNames: typeOption.hideFileNames, ), ); }, addFile: (url, name, uploadType, fileType) async { final newFile = MediaFilePB( id: uuid(), url: url, name: name, uploadType: uploadType, fileType: fileType, ); final payload = MediaCellChangesetPB( viewId: cellController.viewId, cellId: CellIdPB( viewId: cellController.viewId, fieldId: cellController.fieldId, rowId: cellController.rowId, ), insertedFiles: [newFile], removedIds: [], ); final result = await DatabaseEventUpdateMediaCell(payload).send(); result.fold((l) => null, (err) => Log.error(err)); }, removeFile: (id) async { final payload = MediaCellChangesetPB( viewId: cellController.viewId, cellId: CellIdPB( viewId: cellController.viewId, fieldId: cellController.fieldId, rowId: cellController.rowId, ), insertedFiles: [], removedIds: [id], ); final result = await DatabaseEventUpdateMediaCell(payload).send(); result.fold((l) => null, (err) => Log.error(err)); }, reorderFiles: (from, to) async { final files = List.from(state.files); files.insert(to, files.removeAt(from)); // We emit the new state first to update the UI emit(state.copyWith(files: files)); final payload = MediaCellChangesetPB( viewId: cellController.viewId, cellId: CellIdPB( viewId: cellController.viewId, fieldId: cellController.fieldId, rowId: cellController.rowId, ), insertedFiles: files, // In the backend we remove all files by id before we do inserts. // So this will effectively reorder the files. removedIds: files.map((file) => file.id).toList(), ); final result = await DatabaseEventUpdateMediaCell(payload).send(); result.fold((l) => null, (err) => Log.error(err)); }, renameFile: (fileId, name) async { final payload = RenameMediaChangesetPB( viewId: cellController.viewId, cellId: CellIdPB( viewId: cellController.viewId, fieldId: cellController.fieldId, rowId: cellController.rowId, ), fileId: fileId, name: name, ); final result = await DatabaseEventRenameMediaFile(payload).send(); result.fold((l) => null, (err) => Log.error(err)); }, toggleShowAllFiles: () { emit(state.copyWith(showAllFiles: !state.showAllFiles)); }, setCover: (cover) => _rowService.updateMeta( rowId: cellController.rowId, cover: cover, ), ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cellData) { if (!isClosed) { add(MediaCellEvent.didUpdateCell(cellData?.files ?? const [])); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(MediaCellEvent.didUpdateField(fieldInfo.name)); } } void renameFile(String fileId, String name) => add(MediaCellEvent.renameFile(fileId: fileId, name: name)); void deleteFile(String fileId) => add(MediaCellEvent.removeFile(fileId: fileId)); } @freezed class MediaCellEvent with _$MediaCellEvent { const factory MediaCellEvent.initial() = _Initial; const factory MediaCellEvent.didUpdateCell(List files) = _DidUpdateCell; const factory MediaCellEvent.didUpdateField(String fieldName) = _DidUpdateField; const factory MediaCellEvent.addFile({ required String url, required String name, required FileUploadTypePB uploadType, required MediaFileTypePB fileType, }) = _AddFile; const factory MediaCellEvent.removeFile({ required String fileId, }) = _RemoveFile; const factory MediaCellEvent.reorderFiles({ required int from, required int to, }) = _ReorderFiles; const factory MediaCellEvent.renameFile({ required String fileId, required String name, }) = _RenameFile; const factory MediaCellEvent.toggleShowAllFiles() = _ToggleShowAllFiles; const factory MediaCellEvent.setCover(RowCoverPB cover) = _SetCover; } @freezed class MediaCellState with _$MediaCellState { const factory MediaCellState({ UserProfilePB? userProfile, required String fieldName, @Default([]) List files, @Default(false) showAllFiles, @Default(true) hideFileNames, }) = _MediaCellState; factory MediaCellState.initial(MediaCellController cellController) { final cellData = cellController.getCellData(); final typeOption = cellController.getTypeOption(MediaTypeOptionDataParser()); return MediaCellState( fieldName: cellController.fieldInfo.field.name, files: cellData?.files ?? const [], hideFileNames: typeOption.hideFileNames, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'number_cell_bloc.freezed.dart'; class NumberCellBloc extends Bloc { NumberCellBloc({ required this.cellController, }) : super(NumberCellState.initial(cellController)) { _dispatch(); _startListening(); } final NumberCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( didReceiveCellUpdate: (cellData) { emit(state.copyWith(content: cellData ?? "")); }, didUpdateField: (fieldInfo) { final wrap = fieldInfo.wrapCellContent; if (wrap != null) { emit(state.copyWith(wrap: wrap)); } }, updateCell: (text) async { if (state.content != text) { emit(state.copyWith(content: text)); await cellController.saveCellData(text); } }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cellContent) { if (!isClosed) { add(NumberCellEvent.didReceiveCellUpdate(cellContent)); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(NumberCellEvent.didUpdateField(fieldInfo)); } } } @freezed class NumberCellEvent with _$NumberCellEvent { const factory NumberCellEvent.didReceiveCellUpdate(String? cellContent) = _DidReceiveCellUpdate; const factory NumberCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; const factory NumberCellEvent.updateCell(String text) = _UpdateCell; } @freezed class NumberCellState with _$NumberCellState { const factory NumberCellState({ required String content, required bool wrap, }) = _NumberCellState; factory NumberCellState.initial(TextCellController cellController) { final wrap = cellController.fieldInfo.wrapCellContent; return NumberCellState( content: cellController.getCellData() ?? "", wrap: wrap ?? true, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'relation_cell_bloc.freezed.dart'; class RelationCellBloc extends Bloc { RelationCellBloc({required this.cellController}) : super(RelationCellState.initial(cellController)) { _dispatch(); _startListening(); _init(); } final RelationCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( didUpdateCell: (cellData) async { if (cellData == null || cellData.rowIds.isEmpty || state.relatedDatabaseMeta == null) { emit(state.copyWith(rows: const [])); return; } final payload = GetRelatedRowDataPB( databaseId: state.relatedDatabaseMeta!.databaseId, rowIds: cellData.rowIds, ); final result = await DatabaseEventGetRelatedRowDatas(payload).send(); final rows = result.fold( (data) => data.rows, (err) { Log.error(err); return const []; }, ); emit(state.copyWith(rows: rows)); }, didUpdateField: (FieldInfo fieldInfo) async { final wrap = fieldInfo.wrapCellContent; if (wrap != null) { emit(state.copyWith(wrap: wrap)); } final RelationTypeOptionPB typeOption = cellController.getTypeOption(RelationTypeOptionDataParser()); if (typeOption.databaseId.isEmpty) { return; } final meta = await _loadDatabaseMeta(typeOption.databaseId); emit(state.copyWith(relatedDatabaseMeta: meta)); _loadCellData(); }, selectDatabaseId: (databaseId) async { await _updateTypeOption(databaseId); }, selectRow: (rowId) async { await _handleSelectRow(rowId); }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (data) { if (!isClosed) { add(RelationCellEvent.didUpdateCell(data)); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(RelationCellEvent.didUpdateField(fieldInfo)); } } void _init() { add(RelationCellEvent.didUpdateField(cellController.fieldInfo)); } void _loadCellData() { final cellData = cellController.getCellData(); if (!isClosed && cellData != null) { add(RelationCellEvent.didUpdateCell(cellData)); } } Future _handleSelectRow(String rowId) async { final payload = RelationCellChangesetPB( viewId: cellController.viewId, cellId: CellIdPB( viewId: cellController.viewId, fieldId: cellController.fieldId, rowId: cellController.rowId, ), ); if (state.rows.any((row) => row.rowId == rowId)) { payload.removedRowIds.add(rowId); } else { payload.insertedRowIds.add(rowId); } final result = await DatabaseEventUpdateRelationCell(payload).send(); result.fold((l) => null, (err) => Log.error(err)); } Future _loadDatabaseMeta(String databaseId) async { final getDatabaseResult = await DatabaseEventGetDatabases().send(); final databaseMeta = getDatabaseResult.fold( (s) => s.items.firstWhereOrNull( (metaPB) => metaPB.databaseId == databaseId, ), (f) => null, ); if (databaseMeta != null) { final result = await ViewBackendService.getView(databaseMeta.viewId); return result.fold( (s) => DatabaseMeta( databaseId: databaseId, viewId: databaseMeta.viewId, databaseName: s.name, ), (f) => null, ); } return null; } Future _updateTypeOption(String databaseId) async { final newDateTypeOption = RelationTypeOptionPB( databaseId: databaseId, ); final result = await FieldBackendService.updateFieldTypeOption( viewId: cellController.viewId, fieldId: cellController.fieldInfo.id, typeOptionData: newDateTypeOption.writeToBuffer(), ); result.fold((s) => null, (err) => Log.error(err)); } } @freezed class RelationCellEvent with _$RelationCellEvent { const factory RelationCellEvent.didUpdateCell(RelationCellDataPB? data) = _DidUpdateCell; const factory RelationCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; const factory RelationCellEvent.selectDatabaseId( String databaseId, ) = _SelectDatabaseId; const factory RelationCellEvent.selectRow(String rowId) = _SelectRowId; } @freezed class RelationCellState with _$RelationCellState { const factory RelationCellState({ required DatabaseMeta? relatedDatabaseMeta, required List rows, required bool wrap, }) = _RelationCellState; factory RelationCellState.initial(RelationCellController cellController) { final wrap = cellController.fieldInfo.wrapCellContent; return RelationCellState( relatedDatabaseMeta: null, rows: [], wrap: wrap ?? true, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:bloc/bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'relation_row_search_bloc.freezed.dart'; class RelationRowSearchBloc extends Bloc { RelationRowSearchBloc({ required this.databaseId, }) : super(RelationRowSearchState.initial()) { _dispatch(); _init(); } final String databaseId; final List allRows = []; void _dispatch() { on( (event, emit) { event.when( didUpdateRowList: (List rowList) { allRows ..clear() ..addAll(rowList); emit( state.copyWith( filteredRows: allRows, focusedRowId: state.focusedRowId ?? allRows.firstOrNull?.rowId, ), ); }, updateFilter: (String filter) => _updateFilter(filter, emit), updateFocusedOption: (String rowId) { emit(state.copyWith(focusedRowId: rowId)); }, focusPreviousOption: () => _focusOption(true, emit), focusNextOption: () => _focusOption(false, emit), ); }, ); } Future _init() async { final payload = DatabaseIdPB(value: databaseId); final result = await DatabaseEventGetRelatedDatabaseRows(payload).send(); result.fold( (data) => add(RelationRowSearchEvent.didUpdateRowList(data.rows)), (err) => Log.error(err), ); } void _updateFilter(String filter, Emitter emit) { final rows = [...allRows]; if (filter.isNotEmpty) { rows.retainWhere( (row) => row.name.toLowerCase().contains(filter.toLowerCase()) || (row.name.isEmpty && LocaleKeys.grid_row_titlePlaceholder .tr() .toLowerCase() .contains(filter.toLowerCase())), ); } final focusedRowId = rows.isEmpty ? null : rows.any((row) => row.rowId == state.focusedRowId) ? state.focusedRowId : rows.first.rowId; emit( state.copyWith( filteredRows: rows, focusedRowId: focusedRowId, ), ); } void _focusOption(bool previous, Emitter emit) { if (state.filteredRows.isEmpty) { return; } final rowIds = state.filteredRows.map((e) => e.rowId).toList(); final currentIndex = state.focusedRowId == null ? -1 : rowIds.indexWhere((id) => id == state.focusedRowId); // If the current index is -1, it means that the focused row is not in the list of row ids. // In this case, we set the new index to the last index if previous is true, otherwise to 0. final newIndex = currentIndex == -1 ? (previous ? rowIds.length - 1 : 0) : (currentIndex + (previous ? -1 : 1)) % rowIds.length; emit(state.copyWith(focusedRowId: rowIds[newIndex])); } } @freezed class RelationRowSearchEvent with _$RelationRowSearchEvent { const factory RelationRowSearchEvent.didUpdateRowList( List rowList, ) = _DidUpdateRowList; const factory RelationRowSearchEvent.updateFilter(String filter) = _UpdateFilter; const factory RelationRowSearchEvent.updateFocusedOption( String rowId, ) = _UpdateFocusedOption; const factory RelationRowSearchEvent.focusPreviousOption() = _FocusPreviousOption; const factory RelationRowSearchEvent.focusNextOption() = _FocusNextOption; } @freezed class RelationRowSearchState with _$RelationRowSearchState { const factory RelationRowSearchState({ required List filteredRows, required String? focusedRowId, }) = _RelationRowSearchState; factory RelationRowSearchState.initial() => const RelationRowSearchState( filteredRows: [], focusedRowId: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'select_option_cell_bloc.freezed.dart'; class SelectOptionCellBloc extends Bloc { SelectOptionCellBloc({ required this.cellController, }) : super(SelectOptionCellState.initial(cellController)) { _dispatch(); _startListening(); } final SelectOptionCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) { event.when( didReceiveOptions: (List selectedOptions) { emit( state.copyWith( selectedOptions: selectedOptions, ), ); }, didUpdateField: (fieldInfo) { final wrap = fieldInfo.wrapCellContent; if (wrap != null) { emit(state.copyWith(wrap: wrap)); } }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (selectOptionCellData) { if (!isClosed) { add( SelectOptionCellEvent.didReceiveOptions( selectOptionCellData?.selectOptions ?? [], ), ); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(SelectOptionCellEvent.didUpdateField(fieldInfo)); } } } @freezed class SelectOptionCellEvent with _$SelectOptionCellEvent { const factory SelectOptionCellEvent.didReceiveOptions( List selectedOptions, ) = _DidReceiveOptions; const factory SelectOptionCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; } @freezed class SelectOptionCellState with _$SelectOptionCellState { const factory SelectOptionCellState({ required List selectedOptions, required bool wrap, }) = _SelectOptionCellState; factory SelectOptionCellState.initial( SelectOptionCellController cellController, ) { final data = cellController.getCellData(); final wrap = cellController.fieldInfo.wrapCellContent; return SelectOptionCellState( selectedOptions: data?.selectOptions ?? [], wrap: wrap ?? true, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart ================================================ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/domain/select_option_cell_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'select_option_cell_editor_bloc.freezed.dart'; const String createSelectOptionSuggestionId = "create_select_option_suggestion_id"; class SelectOptionCellEditorBloc extends Bloc { SelectOptionCellEditorBloc({ required this.cellController, }) : _selectOptionService = SelectOptionCellBackendService( viewId: cellController.viewId, fieldId: cellController.fieldId, rowId: cellController.rowId, ), _typeOptionAction = cellController.fieldType == FieldType.SingleSelect ? SingleSelectAction( viewId: cellController.viewId, fieldId: cellController.fieldId, onTypeOptionUpdated: (typeOptionData) => FieldBackendService.updateFieldTypeOption( viewId: cellController.viewId, fieldId: cellController.fieldId, typeOptionData: typeOptionData, ), ) : MultiSelectAction( viewId: cellController.viewId, fieldId: cellController.fieldId, onTypeOptionUpdated: (typeOptionData) => FieldBackendService.updateFieldTypeOption( viewId: cellController.viewId, fieldId: cellController.fieldId, typeOptionData: typeOptionData, ), ), super(SelectOptionCellEditorState.initial(cellController)) { _dispatch(); _startListening(); final loadedOptions = _loadAllOptions(cellController); add(SelectOptionCellEditorEvent.didUpdateOptions(loadedOptions)); } final SelectOptionCellBackendService _selectOptionService; final ISelectOptionAction _typeOptionAction; final SelectOptionCellController cellController; VoidCallback? _onCellChangedFn; final List allOptions = []; String filter = ""; void _dispatch() { on( (event, emit) async { await event.when( didUpdateCell: (selectedOptions) { emit(state.copyWith(selectedOptions: selectedOptions)); }, didUpdateOptions: (options) { allOptions ..clear() ..addAll(options); final result = _getVisibleOptions(options); emit( state.copyWith( options: result.options, createSelectOptionSuggestion: result.createSelectOptionSuggestion, ), ); }, createOption: () async { if (state.createSelectOptionSuggestion == null) { return; } filter = ""; await _createOption( name: state.createSelectOptionSuggestion!.name, color: state.createSelectOptionSuggestion!.color, ); emit(state.copyWith(clearFilter: true)); }, deleteOption: (option) async { await _deleteOption([option]); }, deleteAllOptions: () async { if (allOptions.isNotEmpty) { await _deleteOption(allOptions); } }, updateOption: (option) async { await _updateOption(option); }, selectOption: (optionId) async { await _selectOptionService.select(optionIds: [optionId]); }, unselectOption: (optionId) async { await _selectOptionService.unselect(optionIds: [optionId]); }, unselectLastOption: () async { if (state.selectedOptions.isEmpty) { return; } final lastSelectedOptionId = state.selectedOptions.last.id; await _selectOptionService .unselect(optionIds: [lastSelectedOptionId]); }, submitTextField: () { _submitTextFieldValue(emit); }, selectMultipleOptions: (optionNames, remainder) { if (optionNames.isNotEmpty) { _selectMultipleOptions(optionNames); } _filterOption(remainder, emit); }, reorderOption: (fromOptionId, toOptionId) { final options = _typeOptionAction.reorderOption( allOptions, fromOptionId, toOptionId, ); allOptions ..clear() ..addAll(options); final result = _getVisibleOptions(options); emit(state.copyWith(options: result.options)); }, filterOption: (filterText) { _filterOption(filterText, emit); }, focusPreviousOption: () { _focusOption(true, emit); }, focusNextOption: () { _focusOption(false, emit); }, updateFocusedOption: (optionId) { emit(state.copyWith(focusedOptionId: optionId)); }, resetClearFilterFlag: () { emit(state.copyWith(clearFilter: false)); }, ); }, ); } @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } return super.close(); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cellData) { if (!isClosed) { add( SelectOptionCellEditorEvent.didUpdateCell( cellData == null ? [] : cellData.selectOptions, ), ); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { final loadedOptions = _loadAllOptions(cellController); add(SelectOptionCellEditorEvent.didUpdateOptions(loadedOptions)); } } Future _createOption({ required String name, required SelectOptionColorPB color, }) async { final result = await _selectOptionService.create( name: name, color: color, ); result.fold((l) => {}, (err) => Log.error(err)); } Future _deleteOption(List options) async { final result = await _selectOptionService.delete(options: options); result.fold((l) => null, (err) => Log.error(err)); } Future _updateOption(SelectOptionPB option) async { final result = await _selectOptionService.update( option: option, ); result.fold((l) => null, (err) => Log.error(err)); } void _submitTextFieldValue(Emitter emit) { if (state.focusedOptionId == null) { return; } final focusedOptionId = state.focusedOptionId!; if (focusedOptionId == createSelectOptionSuggestionId) { filter = ""; _createOption( name: state.createSelectOptionSuggestion!.name, color: state.createSelectOptionSuggestion!.color, ); emit( state.copyWith( createSelectOptionSuggestion: null, clearFilter: true, ), ); } else if (!state.selectedOptions .any((option) => option.id == focusedOptionId)) { _selectOptionService.select(optionIds: [focusedOptionId]); emit( state.copyWith( clearFilter: true, ), ); } } void _selectMultipleOptions(List optionNames) { final optionIds = optionNames .map( (name) => allOptions.firstWhereOrNull( (option) => option.name.toLowerCase() == name.toLowerCase(), ), ) .nonNulls .map((option) => option.id) .toList(); _selectOptionService.select(optionIds: optionIds); } void _filterOption( String filterText, Emitter emit, ) { filter = filterText; final _MakeOptionResult result = _getVisibleOptions( allOptions, ); final focusedOptionId = result.options.isEmpty ? result.createSelectOptionSuggestion == null ? null : createSelectOptionSuggestionId : result.options.any((option) => option.id == state.focusedOptionId) ? state.focusedOptionId : result.options.first.id; emit( state.copyWith( options: result.options, createSelectOptionSuggestion: result.createSelectOptionSuggestion, focusedOptionId: focusedOptionId, ), ); } _MakeOptionResult _getVisibleOptions( List allOptions, ) { final List options = List.from(allOptions); String newOptionName = filter; if (filter.isNotEmpty) { options.retainWhere((option) { final name = option.name.toLowerCase(); final lFilter = filter.toLowerCase(); if (name == lFilter) { newOptionName = ""; } return name.contains(lFilter); }); } return _MakeOptionResult( options: options, createSelectOptionSuggestion: newOptionName.isEmpty ? null : CreateSelectOptionSuggestion( name: newOptionName, color: newSelectOptionColor(allOptions), ), ); } void _focusOption(bool previous, Emitter emit) { if (state.options.isEmpty && state.createSelectOptionSuggestion == null) { return; } final optionIds = [ ...state.options.map((e) => e.id), if (state.createSelectOptionSuggestion != null) createSelectOptionSuggestionId, ]; if (state.focusedOptionId == null) { emit( state.copyWith( focusedOptionId: previous ? optionIds.last : optionIds.first, ), ); return; } final currentIndex = optionIds.indexWhere((id) => id == state.focusedOptionId); final newIndex = currentIndex == -1 ? 0 : (currentIndex + (previous ? -1 : 1)) % optionIds.length; emit(state.copyWith(focusedOptionId: optionIds[newIndex])); } } @freezed class SelectOptionCellEditorEvent with _$SelectOptionCellEditorEvent { const factory SelectOptionCellEditorEvent.didUpdateCell( List selectedOptions, ) = _DidUpdateCell; const factory SelectOptionCellEditorEvent.didUpdateOptions( List options, ) = _DidUpdateOptions; const factory SelectOptionCellEditorEvent.createOption() = _CreateOption; const factory SelectOptionCellEditorEvent.selectOption(String optionId) = _SelectOption; const factory SelectOptionCellEditorEvent.unselectOption(String optionId) = _UnselectOption; const factory SelectOptionCellEditorEvent.unselectLastOption() = _UnselectLastOption; const factory SelectOptionCellEditorEvent.updateOption( SelectOptionPB option, ) = _UpdateOption; const factory SelectOptionCellEditorEvent.deleteOption( SelectOptionPB option, ) = _DeleteOption; const factory SelectOptionCellEditorEvent.deleteAllOptions() = _DeleteAllOptions; const factory SelectOptionCellEditorEvent.reorderOption( String fromOptionId, String toOptionId, ) = _ReorderOption; const factory SelectOptionCellEditorEvent.filterOption(String filterText) = _SelectOptionFilter; const factory SelectOptionCellEditorEvent.submitTextField() = _SubmitTextField; const factory SelectOptionCellEditorEvent.selectMultipleOptions( List optionNames, String remainder, ) = _SelectMultipleOptions; const factory SelectOptionCellEditorEvent.focusPreviousOption() = _FocusPreviousOption; const factory SelectOptionCellEditorEvent.focusNextOption() = _FocusNextOption; const factory SelectOptionCellEditorEvent.updateFocusedOption( String? optionId, ) = _UpdateFocusedOption; const factory SelectOptionCellEditorEvent.resetClearFilterFlag() = _ResetClearFilterFlag; } @freezed class SelectOptionCellEditorState with _$SelectOptionCellEditorState { const factory SelectOptionCellEditorState({ required List options, required List selectedOptions, required CreateSelectOptionSuggestion? createSelectOptionSuggestion, required String? focusedOptionId, required bool clearFilter, }) = _SelectOptionEditorState; factory SelectOptionCellEditorState.initial( SelectOptionCellController cellController, ) { final allOptions = _loadAllOptions(cellController); final data = cellController.getCellData(); return SelectOptionCellEditorState( options: allOptions, selectedOptions: data?.selectOptions ?? [], createSelectOptionSuggestion: null, focusedOptionId: null, clearFilter: false, ); } } class _MakeOptionResult { _MakeOptionResult({ required this.options, required this.createSelectOptionSuggestion, }); List options; CreateSelectOptionSuggestion? createSelectOptionSuggestion; } class CreateSelectOptionSuggestion { CreateSelectOptionSuggestion({ required this.name, required this.color, }); final String name; final SelectOptionColorPB color; } List _loadAllOptions( SelectOptionCellController cellController, ) { if (cellController.fieldType == FieldType.SingleSelect) { return cellController .getTypeOption( SingleSelectTypeOptionDataParser(), ) .options; } else { return cellController .getTypeOption( MultiSelectTypeOptionDataParser(), ) .options; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/summary_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'summary_cell_bloc.freezed.dart'; class SummaryCellBloc extends Bloc { SummaryCellBloc({ required this.cellController, }) : super(SummaryCellState.initial(cellController)) { _dispatch(); _startListening(); } final SummaryCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( didReceiveCellUpdate: (cellData) { emit( state.copyWith(content: cellData ?? ""), ); }, didUpdateField: (fieldInfo) { final wrap = fieldInfo.wrapCellContent; if (wrap != null) { emit(state.copyWith(wrap: wrap)); } }, updateCell: (text) async { if (state.content != text) { emit(state.copyWith(content: text)); await cellController.saveCellData(text); // If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string. // So for every cell data that will be formatted in the backend. // It needs to get the formatted data after saving. add( SummaryCellEvent.didReceiveCellUpdate( cellController.getCellData() ?? "", ), ); } }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cellContent) { if (!isClosed) { add( SummaryCellEvent.didReceiveCellUpdate(cellContent ?? ""), ); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(SummaryCellEvent.didUpdateField(fieldInfo)); } } } @freezed class SummaryCellEvent with _$SummaryCellEvent { const factory SummaryCellEvent.didReceiveCellUpdate(String? cellContent) = _DidReceiveCellUpdate; const factory SummaryCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; const factory SummaryCellEvent.updateCell(String text) = _UpdateCell; } @freezed class SummaryCellState with _$SummaryCellState { const factory SummaryCellState({ required String content, required bool wrap, }) = _SummaryCellState; factory SummaryCellState.initial(SummaryCellController cellController) { final wrap = cellController.fieldInfo.wrapCellContent; return SummaryCellState( content: cellController.getCellData() ?? "", wrap: wrap ?? true, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/summary_row_bloc.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'summary_row_bloc.freezed.dart'; class SummaryRowBloc extends Bloc { SummaryRowBloc({ required this.viewId, required this.rowId, required this.fieldId, }) : super(SummaryRowState.initial()) { _dispatch(); } final String viewId; final String rowId; final String fieldId; void _dispatch() { on( (event, emit) async { event.when( startSummary: () { final params = SummaryRowPB( viewId: viewId, rowId: rowId, fieldId: fieldId, ); emit( state.copyWith( loadingState: const LoadingState.loading(), error: null, ), ); DatabaseEventSummarizeRow(params).send().then( (result) => { if (!isClosed) add(SummaryRowEvent.finishSummary(result)), }, ); }, finishSummary: (result) { result.fold( (s) => { emit( state.copyWith( loadingState: const LoadingState.finish(), error: null, ), ), }, (err) => { emit( state.copyWith( loadingState: const LoadingState.finish(), error: err, ), ), }, ); }, ); }, ); } } @freezed class SummaryRowEvent with _$SummaryRowEvent { const factory SummaryRowEvent.startSummary() = _DidStartSummary; const factory SummaryRowEvent.finishSummary( FlowyResult result, ) = _DidFinishSummary; } @freezed class SummaryRowState with _$SummaryRowState { const factory SummaryRowState({ required LoadingState loadingState, required FlowyError? error, }) = _SummaryRowState; factory SummaryRowState.initial() { return const SummaryRowState( loadingState: LoadingState.finish(), error: null, ); } } @freezed class LoadingState with _$LoadingState { const factory LoadingState.loading() = _Loading; const factory LoadingState.finish() = _Finish; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/text_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'text_cell_bloc.freezed.dart'; class TextCellBloc extends Bloc { TextCellBloc({required this.cellController}) : super(TextCellState.initial(cellController)) { _dispatch(); _startListening(); } final TextCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) { event.when( didReceiveCellUpdate: (content) { emit(state.copyWith(content: content)); }, didUpdateField: (fieldInfo) { final wrap = fieldInfo.wrapCellContent; if (wrap != null) { emit(state.copyWith(wrap: wrap)); } }, updateText: (String text) { // If the content is null, it indicates that either the cell is empty (no data) // or the cell data is still being fetched from the backend and is not yet available. if (state.content != null && state.content != text) { cellController.saveCellData(text, debounce: true); } }, enableEdit: (bool enabled) { emit(state.copyWith(enableEdit: enabled)); }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cellContent) { if (!isClosed) { add(TextCellEvent.didReceiveCellUpdate(cellContent)); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(TextCellEvent.didUpdateField(fieldInfo)); } } } @freezed class TextCellEvent with _$TextCellEvent { const factory TextCellEvent.didReceiveCellUpdate(String? cellContent) = _DidReceiveCellUpdate; const factory TextCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; const factory TextCellEvent.updateText(String text) = _UpdateText; const factory TextCellEvent.enableEdit(bool enabled) = _EnableEdit; } @freezed class TextCellState with _$TextCellState { const factory TextCellState({ required String? content, required ValueNotifier? emoji, required ValueNotifier? hasDocument, required bool enableEdit, required bool wrap, }) = _TextCellState; factory TextCellState.initial(TextCellController cellController) { final cellData = cellController.getCellData(); final wrap = cellController.fieldInfo.wrapCellContent ?? true; ValueNotifier? emoji; ValueNotifier? hasDocument; if (cellController.fieldInfo.isPrimary) { emoji = cellController.icon; hasDocument = cellController.hasDocument; } return TextCellState( content: cellData, emoji: emoji, enableEdit: false, hasDocument: hasDocument, wrap: wrap, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/time_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/util/time.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; part 'time_cell_bloc.freezed.dart'; class TimeCellBloc extends Bloc { TimeCellBloc({ required this.cellController, }) : super(TimeCellState.initial(cellController)) { _dispatch(); _startListening(); } final TimeCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( didReceiveCellUpdate: (content) { emit( state.copyWith( content: content != null ? formatTime(content.time.toInt()) : "", ), ); }, didUpdateField: (fieldInfo) { final wrap = fieldInfo.wrapCellContent; if (wrap != null) { emit(state.copyWith(wrap: wrap)); } }, updateCell: (text) async { text = parseTime(text)?.toString() ?? text; if (state.content != text) { emit(state.copyWith(content: text)); await cellController.saveCellData(text); // If the input content is "abc" that can't parsered as number // then the data stored in the backend will be an empty string. // So for every cell data that will be formatted in the backend. // It needs to get the formatted data after saving. add( TimeCellEvent.didReceiveCellUpdate( cellController.getCellData(), ), ); } }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cellContent) { if (!isClosed) { add(TimeCellEvent.didReceiveCellUpdate(cellContent)); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(TimeCellEvent.didUpdateField(fieldInfo)); } } } @freezed class TimeCellEvent with _$TimeCellEvent { const factory TimeCellEvent.didReceiveCellUpdate(TimeCellDataPB? cell) = _DidReceiveCellUpdate; const factory TimeCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; const factory TimeCellEvent.updateCell(String text) = _UpdateCell; } @freezed class TimeCellState with _$TimeCellState { const factory TimeCellState({ required String content, required bool wrap, }) = _TimeCellState; factory TimeCellState.initial(TimeCellController cellController) { final wrap = cellController.fieldInfo.wrapCellContent; final cellData = cellController.getCellData(); return TimeCellState( content: cellData != null ? formatTime(cellData.time.toInt()) : "", wrap: wrap ?? true, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'timestamp_cell_bloc.freezed.dart'; class TimestampCellBloc extends Bloc { TimestampCellBloc({ required this.cellController, }) : super(TimestampCellState.initial(cellController)) { _dispatch(); _startListening(); } final TimestampCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { event.when( didReceiveCellUpdate: (TimestampCellDataPB? cellData) { emit( state.copyWith( data: cellData, dateStr: cellData?.dateTime ?? "", ), ); }, didUpdateField: (fieldInfo) { final wrap = fieldInfo.wrapCellContent; if (wrap != null) { emit(state.copyWith(wrap: wrap)); } }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (data) { if (!isClosed) { add(TimestampCellEvent.didReceiveCellUpdate(data)); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(TimestampCellEvent.didUpdateField(fieldInfo)); } } } @freezed class TimestampCellEvent with _$TimestampCellEvent { const factory TimestampCellEvent.didReceiveCellUpdate( TimestampCellDataPB? data, ) = _DidReceiveCellUpdate; const factory TimestampCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; } @freezed class TimestampCellState with _$TimestampCellState { const factory TimestampCellState({ required TimestampCellDataPB? data, required String dateStr, required FieldInfo fieldInfo, required bool wrap, }) = _TimestampCellState; factory TimestampCellState.initial(TimestampCellController cellController) { final cellData = cellController.getCellData(); final wrap = cellController.fieldInfo.wrapCellContent; return TimestampCellState( fieldInfo: cellController.fieldInfo, data: cellData, dateStr: cellData?.dateTime ?? "", wrap: wrap ?? true, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/translate_cell_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'translate_cell_bloc.freezed.dart'; class TranslateCellBloc extends Bloc { TranslateCellBloc({ required this.cellController, }) : super(TranslateCellState.initial(cellController)) { _dispatch(); _startListening(); } final TranslateCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( didReceiveCellUpdate: (cellData) { emit( state.copyWith(content: cellData ?? ""), ); }, didUpdateField: (fieldInfo) { final wrap = fieldInfo.wrapCellContent; if (wrap != null) { emit(state.copyWith(wrap: wrap)); } }, updateCell: (text) async { if (state.content != text) { emit(state.copyWith(content: text)); await cellController.saveCellData(text); // If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string. // So for every cell data that will be formatted in the backend. // It needs to get the formatted data after saving. add( TranslateCellEvent.didReceiveCellUpdate( cellController.getCellData() ?? "", ), ); } }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cellContent) { if (!isClosed) { add( TranslateCellEvent.didReceiveCellUpdate(cellContent ?? ""), ); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(TranslateCellEvent.didUpdateField(fieldInfo)); } } } @freezed class TranslateCellEvent with _$TranslateCellEvent { const factory TranslateCellEvent.didReceiveCellUpdate(String? cellContent) = _DidReceiveCellUpdate; const factory TranslateCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; const factory TranslateCellEvent.updateCell(String text) = _UpdateCell; } @freezed class TranslateCellState with _$TranslateCellState { const factory TranslateCellState({ required String content, required bool wrap, }) = _TranslateCellState; factory TranslateCellState.initial(TranslateCellController cellController) { final wrap = cellController.fieldInfo.wrapCellContent; return TranslateCellState( content: cellController.getCellData() ?? "", wrap: wrap ?? true, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/translate_row_bloc.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'translate_row_bloc.freezed.dart'; class TranslateRowBloc extends Bloc { TranslateRowBloc({ required this.viewId, required this.rowId, required this.fieldId, }) : super(TranslateRowState.initial()) { _dispatch(); } final String viewId; final String rowId; final String fieldId; void _dispatch() { on( (event, emit) async { event.when( startTranslate: () { final params = TranslateRowPB( viewId: viewId, rowId: rowId, fieldId: fieldId, ); emit( state.copyWith( loadingState: const LoadingState.loading(), error: null, ), ); DatabaseEventTranslateRow(params).send().then( (result) => { if (!isClosed) add(TranslateRowEvent.finishTranslate(result)), }, ); }, finishTranslate: (result) { result.fold( (s) => { emit( state.copyWith( loadingState: const LoadingState.finish(), error: null, ), ), }, (err) => { emit( state.copyWith( loadingState: const LoadingState.finish(), error: err, ), ), }, ); }, ); }, ); } } @freezed class TranslateRowEvent with _$TranslateRowEvent { const factory TranslateRowEvent.startTranslate() = _DidStartTranslate; const factory TranslateRowEvent.finishTranslate( FlowyResult result, ) = _DidFinishTranslate; } @freezed class TranslateRowState with _$TranslateRowState { const factory TranslateRowState({ required LoadingState loadingState, required FlowyError? error, }) = _TranslateRowState; factory TranslateRowState.initial() { return const TranslateRowState( loadingState: LoadingState.finish(), error: null, ); } } @freezed class LoadingState with _$LoadingState { const factory LoadingState.loading() = _Loading; const factory LoadingState.finish() = _Finish; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/url_cell_bloc.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'url_cell_bloc.freezed.dart'; class URLCellBloc extends Bloc { URLCellBloc({ required this.cellController, }) : super(URLCellState.initial(cellController)) { _dispatch(); _startListening(); } final URLCellController cellController; void Function()? _onCellChangedFn; @override Future close() async { if (_onCellChangedFn != null) { cellController.removeListener( onCellChanged: _onCellChangedFn!, onFieldChanged: _onFieldChangedListener, ); } await cellController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( didUpdateCell: (cellData) async { final content = cellData?.content ?? ""; final isValid = await _isUrlValid(content); emit( state.copyWith( content: content, isValid: isValid, ), ); }, didUpdateField: (fieldInfo) { final wrap = fieldInfo.wrapCellContent; if (wrap != null) { emit(state.copyWith(wrap: wrap)); } }, updateURL: (String url) { cellController.saveCellData(url, debounce: true); }, ); }, ); } void _startListening() { _onCellChangedFn = cellController.addListener( onCellChanged: (cellData) { if (!isClosed) { add(URLCellEvent.didUpdateCell(cellData)); } }, onFieldChanged: _onFieldChangedListener, ); } void _onFieldChangedListener(FieldInfo fieldInfo) { if (!isClosed) { add(URLCellEvent.didUpdateField(fieldInfo)); } } Future _isUrlValid(String content) async { if (content.isEmpty) { return true; } try { // check protocol is provided const linkPrefix = [ 'http://', 'https://', ]; final shouldAddScheme = !linkPrefix.any((pattern) => content.startsWith(pattern)); final url = shouldAddScheme ? 'http://$content' : content; // get hostname and check validity final uri = Uri.parse(url); final hostName = uri.host; await InternetAddress.lookup(hostName); } catch (_) { return false; } return true; } } @freezed class URLCellEvent with _$URLCellEvent { const factory URLCellEvent.updateURL(String url) = _UpdateURL; const factory URLCellEvent.didUpdateCell(URLCellDataPB? cell) = _DidUpdateCell; const factory URLCellEvent.didUpdateField(FieldInfo fieldInfo) = _DidUpdateField; } @freezed class URLCellState with _$URLCellState { const factory URLCellState({ required String content, required bool isValid, required bool wrap, }) = _URLCellState; factory URLCellState.initial(URLCellController cellController) { final cellData = cellController.getCellData(); final wrap = cellController.fieldInfo.wrapCellContent; return URLCellState( content: cellData?.content ?? "", isValid: true, wrap: wrap ?? true, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_cache.dart ================================================ import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'cell_controller.dart'; /// CellMemCache is used to cache cell data of each block. /// We use CellContext to index the cell in the cache. /// Read https://docs.appflowy.io/docs/documentation/software-contributions/architecture/frontend/frontend/grid /// for more information class CellMemCache { CellMemCache(); /// fieldId: {rowId: cellData} final Map> _cellByFieldId = {}; void removeCellWithFieldId(String fieldId) { _cellByFieldId.remove(fieldId); } void remove(CellContext context) { _cellByFieldId[context.fieldId]?.remove(context.rowId); } void insert(CellContext context, T data) { _cellByFieldId.putIfAbsent(context.fieldId, () => {}); _cellByFieldId[context.fieldId]![context.rowId] = data; } T? get(CellContext context) { final value = _cellByFieldId[context.fieldId]?[context.rowId]; return value is T ? value : null; } void dispose() { _cellByFieldId.clear(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/domain/cell_listener.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'cell_cache.dart'; import 'cell_data_loader.dart'; import 'cell_data_persistence.dart'; part 'cell_controller.freezed.dart'; @freezed class CellContext with _$CellContext { const factory CellContext({ required String fieldId, required RowId rowId, }) = _DatabaseCellContext; } /// [CellController] is used to manipulate the cell and receive notifications. /// The cell data is stored in the [RowCache]'s [CellMemCache]. /// /// * Read/write cell data /// * Listen on field/cell notifications. /// /// T represents the type of the cell data. /// D represents the type of data that will be saved to the disk. class CellController { CellController({ required this.viewId, required FieldController fieldController, required CellContext cellContext, required RowCache rowCache, required CellDataLoader cellDataLoader, required CellDataPersistence cellDataPersistence, }) : _fieldController = fieldController, _cellContext = cellContext, _rowCache = rowCache, _cellDataLoader = cellDataLoader, _cellDataPersistence = cellDataPersistence, _cellDataNotifier = CellDataNotifier(value: rowCache.cellCache.get(cellContext)) { _startListening(); } final String viewId; final FieldController _fieldController; final CellContext _cellContext; final RowCache _rowCache; final CellDataLoader _cellDataLoader; final CellDataPersistence _cellDataPersistence; CellListener? _cellListener; CellDataNotifier? _cellDataNotifier; Timer? _loadDataOperation; Timer? _saveDataOperation; Completer? _completer; RowId get rowId => _cellContext.rowId; String get fieldId => _cellContext.fieldId; FieldInfo get fieldInfo => _fieldController.getField(_cellContext.fieldId)!; FieldType get fieldType => _fieldController.getField(_cellContext.fieldId)!.fieldType; ValueNotifier? get icon => _rowCache.getRow(rowId)?.rowIconNotifier; ValueNotifier? get hasDocument => _rowCache.getRow(rowId)?.rowDocumentNotifier; CellMemCache get _cellCache => _rowCache.cellCache; /// casting method for painless type coersion CellController as() => this as CellController; /// Start listening to backend changes void _startListening() { _cellListener = CellListener( rowId: _cellContext.rowId, fieldId: _cellContext.fieldId, ); // 1. Listen on user edit event and load the new cell data if needed. // For example: // user input: 12 // cell display: $12 _cellListener?.start( onCellChanged: (result) { result.fold( (_) => _loadData(), (err) => Log.error(err), ); }, ); // 2. Listen on the field event and load the cell data if needed. _fieldController.addSingleFieldListener( fieldId, onFieldChanged: _onFieldChangedListener, ); } /// Add a new listener VoidCallback? addListener({ required void Function(T?) onCellChanged, void Function(FieldInfo fieldInfo)? onFieldChanged, }) { /// an adaptor for the onCellChanged listener void onCellChangedFn() => onCellChanged(_cellDataNotifier?.value); _cellDataNotifier?.addListener(onCellChangedFn); if (onFieldChanged != null) { _fieldController.addSingleFieldListener( fieldId, onFieldChanged: onFieldChanged, ); } // Return the function pointer that can be used when calling removeListener. return onCellChangedFn; } void removeListener({ required VoidCallback onCellChanged, void Function(FieldInfo fieldInfo)? onFieldChanged, VoidCallback? onRowMetaChanged, }) { _cellDataNotifier?.removeListener(onCellChanged); if (onFieldChanged != null) { _fieldController.removeSingleFieldListener( fieldId: fieldId, onFieldChanged: onFieldChanged, ); } } void _onFieldChangedListener(FieldInfo fieldInfo) { // reloadOnFieldChanged should be true if you want to reload the cell // data when the corresponding field is changed. // For example: // ¥12 -> $12 if (_cellDataLoader.reloadOnFieldChange) { _loadData(); } } /// Get the cell data. The cell data will be read from the cache first, /// and load from disk if it doesn't exist. You can set [loadIfNotExist] to /// false to disable this behavior. T? getCellData({bool loadIfNotExist = true}) { final T? data = _cellCache.get(_cellContext); if (data == null && loadIfNotExist) { _loadData(); } return data; } /// Return the TypeOptionPB that can be parsed into corresponding class using the [parser]. /// [PD] is the type that the parser return. PD getTypeOption(TypeOptionParser parser) { return parser.fromBuffer(fieldInfo.field.typeOptionData); } /// Saves the cell data to disk. You can set [debounce] to reduce the amount /// of save operations, which is useful when editing a [TextField]. Future saveCellData( D data, { bool debounce = false, void Function(FlowyError?)? onFinish, }) async { _loadDataOperation?.cancel(); if (debounce) { _saveDataOperation?.cancel(); _completer = Completer(); _saveDataOperation = Timer(const Duration(milliseconds: 300), () async { final result = await _cellDataPersistence.save( viewId: viewId, cellContext: _cellContext, data: data, ); onFinish?.call(result); _completer?.complete(); }); } else { final result = await _cellDataPersistence.save( viewId: viewId, cellContext: _cellContext, data: data, ); onFinish?.call(result); } } void _loadData() { _saveDataOperation?.cancel(); _loadDataOperation?.cancel(); _loadDataOperation = Timer(const Duration(milliseconds: 10), () { _cellDataLoader .loadData(viewId: viewId, cellContext: _cellContext) .then((data) { if (data != null) { _cellCache.insert(_cellContext, data); } else { _cellCache.remove(_cellContext); } _cellDataNotifier?.value = data; }); }); } Future dispose() async { await _cellListener?.stop(); _cellListener = null; _fieldController.removeSingleFieldListener( fieldId: fieldId, onFieldChanged: _onFieldChangedListener, ); _loadDataOperation?.cancel(); await _completer?.future; _saveDataOperation?.cancel(); _cellDataNotifier?.dispose(); _cellDataNotifier = null; } } class CellDataNotifier extends ChangeNotifier { CellDataNotifier({required T value, this.listenWhen}) : _value = value; T _value; bool Function(T? oldValue, T? newValue)? listenWhen; set value(T newValue) { if (listenWhen != null && !listenWhen!.call(_value, newValue)) { return; } _value = newValue; notifyListeners(); } T get value => _value; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'cell_controller.dart'; import 'cell_data_loader.dart'; import 'cell_data_persistence.dart'; typedef TextCellController = CellController; typedef CheckboxCellController = CellController; typedef NumberCellController = CellController; typedef SelectOptionCellController = CellController; typedef ChecklistCellController = CellController; typedef DateCellController = CellController; typedef TimestampCellController = CellController; typedef URLCellController = CellController; typedef RelationCellController = CellController; typedef SummaryCellController = CellController; typedef TimeCellController = CellController; typedef TranslateCellController = CellController; typedef MediaCellController = CellController; CellController makeCellController( DatabaseController databaseController, CellContext cellContext, ) { final DatabaseController(:viewId, :rowCache, :fieldController) = databaseController; final fieldType = fieldController.getField(cellContext.fieldId)!.fieldType; switch (fieldType) { case FieldType.Checkbox: return CheckboxCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: CheckboxCellDataParser(), ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.DateTime: return DateCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: DateCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.LastEditedTime: case FieldType.CreatedTime: return TimestampCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: TimestampCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.Number: return NumberCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: NumberCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.RichText: return TextCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: StringCellDataParser(), ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.MultiSelect: case FieldType.SingleSelect: return SelectOptionCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: SelectOptionCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.Checklist: return ChecklistCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: ChecklistCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.URL: return URLCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: URLCellDataParser(), ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.Relation: return RelationCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: RelationCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.Summary: return SummaryCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: StringCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.Time: return TimeCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: TimeCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.Translate: return TranslateCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: StringCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); case FieldType.Media: return MediaCellController( viewId: viewId, fieldController: fieldController, cellContext: cellContext, rowCache: rowCache, cellDataLoader: CellDataLoader( parser: MediaCellDataParser(), reloadOnFieldChange: true, ), cellDataPersistence: TextCellDataPersistence(), ); } throw UnimplementedError; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/database/domain/cell_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'cell_controller.dart'; abstract class CellDataParser { T? parserData(List data); } class CellDataLoader { CellDataLoader({ required this.parser, this.reloadOnFieldChange = false, }); final CellDataParser parser; /// Reload the cell data if the field is changed. final bool reloadOnFieldChange; Future loadData({ required String viewId, required CellContext cellContext, }) { return CellBackendService.getCell( viewId: viewId, cellContext: cellContext, ).then( (result) => result.fold( (CellPB cell) { try { return parser.parserData(cell.data); } catch (e, s) { Log.error('$parser parser cellData failed, $e'); Log.error('Stack trace \n $s'); return null; } }, (err) { Log.error(err); return null; }, ), ); } } class StringCellDataParser implements CellDataParser { @override String? parserData(List data) { try { final s = utf8.decode(data); return s; } catch (e) { Log.error("Failed to parse string data: $e"); return null; } } } class CheckboxCellDataParser implements CellDataParser { @override CheckboxCellDataPB? parserData(List data) { if (data.isEmpty) { return null; } try { return CheckboxCellDataPB.fromBuffer(data); } catch (e) { Log.error("Failed to parse checkbox data: $e"); return null; } } } class NumberCellDataParser implements CellDataParser { @override String? parserData(List data) { try { return utf8.decode(data); } catch (e) { Log.error("Failed to parse number data: $e"); return null; } } } class DateCellDataParser implements CellDataParser { @override DateCellDataPB? parserData(List data) { if (data.isEmpty) { return null; } try { return DateCellDataPB.fromBuffer(data); } catch (e) { Log.error("Failed to parse date data: $e"); return null; } } } class TimestampCellDataParser implements CellDataParser { @override TimestampCellDataPB? parserData(List data) { if (data.isEmpty) { return null; } try { return TimestampCellDataPB.fromBuffer(data); } catch (e) { Log.error("Failed to parse timestamp data: $e"); return null; } } } class SelectOptionCellDataParser implements CellDataParser { @override SelectOptionCellDataPB? parserData(List data) { if (data.isEmpty) { return null; } try { return SelectOptionCellDataPB.fromBuffer(data); } catch (e) { Log.error("Failed to parse select option data: $e"); return null; } } } class ChecklistCellDataParser implements CellDataParser { @override ChecklistCellDataPB? parserData(List data) { if (data.isEmpty) { return null; } try { return ChecklistCellDataPB.fromBuffer(data); } catch (e) { Log.error("Failed to parse checklist data: $e"); return null; } } } class URLCellDataParser implements CellDataParser { @override URLCellDataPB? parserData(List data) { if (data.isEmpty) { return null; } try { return URLCellDataPB.fromBuffer(data); } catch (e) { Log.error("Failed to parse url data: $e"); return null; } } } class RelationCellDataParser implements CellDataParser { @override RelationCellDataPB? parserData(List data) { if (data.isEmpty) { return null; } try { return RelationCellDataPB.fromBuffer(data); } catch (e) { Log.error("Failed to parse relation data: $e"); return null; } } } class TimeCellDataParser implements CellDataParser { @override TimeCellDataPB? parserData(List data) { if (data.isEmpty) { return null; } try { return TimeCellDataPB.fromBuffer(data); } catch (e) { Log.error("Failed to parse timer data: $e"); return null; } } } class MediaCellDataParser implements CellDataParser { @override MediaCellDataPB? parserData(List data) { if (data.isEmpty) { return null; } try { return MediaCellDataPB.fromBuffer(data); } catch (e) { Log.error("Failed to parse media cell data: $e"); return null; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_persistence.dart ================================================ import 'package:appflowy/plugins/database/domain/cell_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'cell_controller.dart'; /// Save the cell data to disk /// You can extend this class to do custom operations. abstract class CellDataPersistence { Future save({ required String viewId, required CellContext cellContext, required D data, }); } class TextCellDataPersistence implements CellDataPersistence { TextCellDataPersistence(); @override Future save({ required String viewId, required CellContext cellContext, required String data, }) async { final fut = CellBackendService.updateCell( viewId: viewId, cellContext: cellContext, data: data, ); return fut.then((result) { return result.fold( (l) => null, (err) => err, ); }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/view/view_cache.dart'; import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/plugins/database/domain/group_listener.dart'; import 'package:appflowy/plugins/database/domain/layout_service.dart'; import 'package:appflowy/plugins/database/domain/layout_setting_listener.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'defines.dart'; import 'row/row_cache.dart'; typedef OnGroupConfigurationChanged = void Function(List); typedef OnGroupByField = void Function(List); typedef OnUpdateGroup = void Function(List); typedef OnDeleteGroup = void Function(List); typedef OnInsertGroup = void Function(InsertedGroupPB); class GroupCallbacks { GroupCallbacks({ this.onGroupConfigurationChanged, this.onGroupByField, this.onUpdateGroup, this.onDeleteGroup, this.onInsertGroup, }); final OnGroupConfigurationChanged? onGroupConfigurationChanged; final OnGroupByField? onGroupByField; final OnUpdateGroup? onUpdateGroup; final OnDeleteGroup? onDeleteGroup; final OnInsertGroup? onInsertGroup; } class DatabaseLayoutSettingCallbacks { DatabaseLayoutSettingCallbacks({ required this.onLayoutSettingsChanged, }); final void Function(DatabaseLayoutSettingPB) onLayoutSettingsChanged; } class DatabaseCallbacks { DatabaseCallbacks({ this.onDatabaseChanged, this.onNumOfRowsChanged, this.onFieldsChanged, this.onFiltersChanged, this.onSortsChanged, this.onRowsUpdated, this.onRowsDeleted, this.onRowsCreated, }); OnDatabaseChanged? onDatabaseChanged; OnFieldsChanged? onFieldsChanged; OnFiltersChanged? onFiltersChanged; OnSortsChanged? onSortsChanged; OnNumOfRowsChanged? onNumOfRowsChanged; OnRowsDeleted? onRowsDeleted; OnRowsUpdated? onRowsUpdated; OnRowsCreated? onRowsCreated; } class DatabaseController { DatabaseController({required this.view}) : _databaseViewBackendSvc = DatabaseViewBackendService(viewId: view.id), fieldController = FieldController(viewId: view.id), _groupListener = DatabaseGroupListener(view.id), databaseLayout = databaseLayoutFromViewLayout(view.layout), _layoutListener = DatabaseLayoutSettingListener(view.id) { _viewCache = DatabaseViewCache( viewId: viewId, fieldController: fieldController, ); _listenOnRowsChanged(); _listenOnFieldsChanged(); _listenOnGroupChanged(); _listenOnLayoutChanged(); } final ViewPB view; final DatabaseViewBackendService _databaseViewBackendSvc; final FieldController fieldController; DatabaseLayoutPB databaseLayout; DatabaseLayoutSettingPB? databaseLayoutSetting; late DatabaseViewCache _viewCache; // Callbacks final List _databaseCallbacks = []; final List _groupCallbacks = []; final List _layoutCallbacks = []; final Set> _compactModeCallbacks = {}; // Getters RowCache get rowCache => _viewCache.rowCache; String get viewId => view.id; // Listener final DatabaseGroupListener _groupListener; final DatabaseLayoutSettingListener _layoutListener; final ValueNotifier _isLoading = ValueNotifier(true); final ValueNotifier _compactMode = ValueNotifier(true); void setIsLoading(bool isLoading) => _isLoading.value = isLoading; ValueNotifier get isLoading => _isLoading; void setCompactMode(bool compactMode) { _compactMode.value = compactMode; for (final callback in Set.of(_compactModeCallbacks)) { callback.call(compactMode); } } ValueNotifier get compactModeNotifier => _compactMode; void addListener({ DatabaseCallbacks? onDatabaseChanged, DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged, GroupCallbacks? onGroupChanged, ValueChanged? onCompactModeChanged, }) { if (onLayoutSettingsChanged != null) { _layoutCallbacks.add(onLayoutSettingsChanged); } if (onDatabaseChanged != null) { _databaseCallbacks.add(onDatabaseChanged); } if (onGroupChanged != null) { _groupCallbacks.add(onGroupChanged); } if (onCompactModeChanged != null) { _compactModeCallbacks.add(onCompactModeChanged); } } void removeListener({ DatabaseCallbacks? onDatabaseChanged, DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged, GroupCallbacks? onGroupChanged, ValueChanged? onCompactModeChanged, }) { if (onDatabaseChanged != null) { _databaseCallbacks.remove(onDatabaseChanged); } if (onLayoutSettingsChanged != null) { _layoutCallbacks.remove(onLayoutSettingsChanged); } if (onGroupChanged != null) { _groupCallbacks.remove(onGroupChanged); } if (onCompactModeChanged != null) { _compactModeCallbacks.remove(onCompactModeChanged); } } Future> open() async { return _databaseViewBackendSvc.openDatabase().then((result) { return result.fold( (DatabasePB database) async { databaseLayout = database.layoutType; // Load the actual database field data. final fieldsOrFail = await fieldController.loadFields( fieldIds: database.fields, ); return fieldsOrFail.fold( (fields) { // Notify the database is changed after the fields are loaded. // The database won't can't be used until the fields are loaded. for (final callback in _databaseCallbacks) { callback.onDatabaseChanged?.call(database); } _viewCache.rowCache.setInitialRows(database.rows); return Future(() async { await _loadGroups(); await _loadLayoutSetting(); return FlowyResult.success(fields); }); }, (err) { Log.error(err); return FlowyResult.failure(err); }, ); }, (err) => FlowyResult.failure(err), ); }); } Future> moveGroupRow({ required RowMetaPB fromRow, required String fromGroupId, required String toGroupId, RowMetaPB? toRow, }) { return _databaseViewBackendSvc.moveGroupRow( fromRowId: fromRow.id, fromGroupId: fromGroupId, toGroupId: toGroupId, toRowId: toRow?.id, ); } Future> moveRow({ required String fromRowId, required String toRowId, }) { return _databaseViewBackendSvc.moveRow( fromRowId: fromRowId, toRowId: toRowId, ); } Future> moveGroup({ required String fromGroupId, required String toGroupId, }) { return _databaseViewBackendSvc.moveGroup( fromGroupId: fromGroupId, toGroupId: toGroupId, ); } Future updateLayoutSetting({ BoardLayoutSettingPB? boardLayoutSetting, CalendarLayoutSettingPB? calendarLayoutSetting, }) async { await _databaseViewBackendSvc .updateLayoutSetting( boardLayoutSetting: boardLayoutSetting, calendarLayoutSetting: calendarLayoutSetting, layoutType: databaseLayout, ) .then((result) { result.fold((l) => null, (r) => Log.error(r)); }); } Future dispose() async { await _databaseViewBackendSvc.closeView(); await fieldController.dispose(); await _groupListener.stop(); await _viewCache.dispose(); _databaseCallbacks.clear(); _groupCallbacks.clear(); _layoutCallbacks.clear(); _compactModeCallbacks.clear(); _isLoading.dispose(); } Future _loadGroups() async { final groupsResult = await _databaseViewBackendSvc.loadGroups(); groupsResult.fold( (groups) { for (final callback in _groupCallbacks) { callback.onGroupByField?.call(groups.items); } }, (err) => Log.error(err), ); } Future _loadLayoutSetting() { return _databaseViewBackendSvc .getLayoutSetting(databaseLayout) .then((result) { result.fold( (newDatabaseLayoutSetting) { databaseLayoutSetting = newDatabaseLayoutSetting; for (final callback in _layoutCallbacks) { callback.onLayoutSettingsChanged(newDatabaseLayoutSetting); } }, (r) => Log.error(r), ); }); } void _listenOnRowsChanged() { final callbacks = DatabaseViewCallbacks( onNumOfRowsChanged: (rows, rowByRowId, reason) { for (final callback in _databaseCallbacks) { callback.onNumOfRowsChanged?.call(rows, rowByRowId, reason); } }, onRowsDeleted: (ids) { for (final callback in _databaseCallbacks) { callback.onRowsDeleted?.call(ids); } }, onRowsUpdated: (ids, reason) { for (final callback in _databaseCallbacks) { callback.onRowsUpdated?.call(ids, reason); } }, onRowsCreated: (ids) { for (final callback in _databaseCallbacks) { callback.onRowsCreated?.call(ids); } }, ); _viewCache.addListener(callbacks); } void _listenOnFieldsChanged() { fieldController.addListener( onReceiveFields: (fields) { for (final callback in _databaseCallbacks) { callback.onFieldsChanged?.call(UnmodifiableListView(fields)); } }, onSorts: (sorts) { for (final callback in _databaseCallbacks) { callback.onSortsChanged?.call(sorts); } }, onFilters: (filters) { for (final callback in _databaseCallbacks) { callback.onFiltersChanged?.call(filters); } }, ); } void _listenOnGroupChanged() { _groupListener.start( onNumOfGroupsChanged: (result) { result.fold( (changeset) { if (changeset.updateGroups.isNotEmpty) { for (final callback in _groupCallbacks) { callback.onUpdateGroup?.call(changeset.updateGroups); } } if (changeset.deletedGroups.isNotEmpty) { for (final callback in _groupCallbacks) { callback.onDeleteGroup?.call(changeset.deletedGroups); } } for (final insertedGroup in changeset.insertedGroups) { for (final callback in _groupCallbacks) { callback.onInsertGroup?.call(insertedGroup); } } }, (r) => Log.error(r), ); }, onGroupByNewField: (result) { result.fold( (groups) { for (final callback in _groupCallbacks) { callback.onGroupByField?.call(groups); } }, (r) => Log.error(r), ); }, ); } void _listenOnLayoutChanged() { _layoutListener.start( onLayoutChanged: (result) { result.fold( (newLayout) { databaseLayoutSetting = newLayout; databaseLayoutSetting?.freeze(); for (final callback in _layoutCallbacks) { callback.onLayoutSettingsChanged(newLayout); } }, (r) => Log.error(r), ); }, ); } void initCompactMode(bool enableCompactMode) { if (_compactMode.value != enableCompactMode) { _compactMode.value = enableCompactMode; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/defines.dart ================================================ import 'dart:collection'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'field/field_info.dart'; import 'field/sort_entities.dart'; import 'row/row_cache.dart'; import 'row/row_service.dart'; part 'defines.freezed.dart'; typedef OnFieldsChanged = void Function(UnmodifiableListView); typedef OnFiltersChanged = void Function(List); typedef OnSortsChanged = void Function(List); typedef OnDatabaseChanged = void Function(DatabasePB); typedef OnRowsCreated = void Function(List rows); typedef OnRowsUpdated = void Function( List rowIds, ChangedReason reason, ); typedef OnRowsDeleted = void Function(List rowIds); typedef OnNumOfRowsChanged = void Function( UnmodifiableListView rows, UnmodifiableMapView rowById, ChangedReason reason, ); typedef OnRowsVisibilityChanged = void Function( List<(RowId, bool)> rowVisibilityChanges, ); @freezed class LoadingState with _$LoadingState { const factory LoadingState.idle() = _Idle; const factory LoadingState.loading() = _Loading; const factory LoadingState.finish( FlowyResult successOrFail, ) = _Finish; const LoadingState._(); bool isLoading() => this is _Loading; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/field_cell_bloc.dart ================================================ import 'dart:math'; import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'field_info.dart'; part 'field_cell_bloc.freezed.dart'; class FieldCellBloc extends Bloc { FieldCellBloc({required String viewId, required FieldInfo fieldInfo}) : _fieldSettingsService = FieldSettingsBackendService(viewId: viewId), super(FieldCellState.initial(fieldInfo)) { _dispatch(); } final FieldSettingsBackendService _fieldSettingsService; void _dispatch() { on( (event, emit) async { event.when( onFieldChanged: (newFieldInfo) => emit(FieldCellState.initial(newFieldInfo)), onResizeStart: () => emit(state.copyWith(isResizing: true, resizeStart: state.width)), startUpdateWidth: (offset) { final width = max(offset + state.resizeStart, 50).toDouble(); emit(state.copyWith(width: width)); }, endUpdateWidth: () { if (state.width != state.fieldInfo.width) { _fieldSettingsService.updateFieldSettings( fieldId: state.fieldInfo.id, width: state.width, ); } emit(state.copyWith(isResizing: false, resizeStart: 0)); }, ); }, ); } } @freezed class FieldCellEvent with _$FieldCellEvent { const factory FieldCellEvent.onFieldChanged(FieldInfo newFieldInfo) = _OnFieldChanged; const factory FieldCellEvent.onResizeStart() = _OnResizeStart; const factory FieldCellEvent.startUpdateWidth(double offset) = _StartUpdateWidth; const factory FieldCellEvent.endUpdateWidth() = _EndUpdateWidth; } @freezed class FieldCellState with _$FieldCellState { factory FieldCellState.initial(FieldInfo fieldInfo) => FieldCellState( fieldInfo: fieldInfo, isResizing: false, width: fieldInfo.width!.toDouble(), resizeStart: 0, ); const factory FieldCellState({ required FieldInfo fieldInfo, required double width, required bool isResizing, required double resizeStart, }) = _FieldCellState; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart ================================================ import 'dart:collection'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/setting/setting_listener.dart'; import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/plugins/database/domain/field_listener.dart'; import 'package:appflowy/plugins/database/domain/field_settings_listener.dart'; import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy/plugins/database/domain/filter_listener.dart'; import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/domain/sort_listener.dart'; import 'package:appflowy/plugins/database/domain/sort_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import '../setting/setting_service.dart'; import 'field_info.dart'; import 'filter_entities.dart'; import 'sort_entities.dart'; class _GridFieldNotifier extends ChangeNotifier { List _fieldInfos = []; set fieldInfos(List fieldInfos) { _fieldInfos = fieldInfos; notifyListeners(); } void notify() { notifyListeners(); } UnmodifiableListView get fieldInfos => UnmodifiableListView(_fieldInfos); } class _GridFilterNotifier extends ChangeNotifier { List _filters = []; set filters(List filters) { _filters = filters; notifyListeners(); } void notify() { notifyListeners(); } List get filters => _filters; } class _GridSortNotifier extends ChangeNotifier { List _sorts = []; set sorts(List sorts) { _sorts = sorts; notifyListeners(); } void notify() { notifyListeners(); } List get sorts => _sorts; } typedef OnReceiveUpdateFields = void Function(List); typedef OnReceiveField = void Function(FieldInfo); typedef OnReceiveFields = void Function(List); typedef OnReceiveFilters = void Function(List); typedef OnReceiveSorts = void Function(List); class FieldController { FieldController({required this.viewId}) : _fieldListener = FieldsListener(viewId: viewId), _settingListener = DatabaseSettingListener(viewId: viewId), _filterBackendSvc = FilterBackendService(viewId: viewId), _filtersListener = FiltersListener(viewId: viewId), _databaseViewBackendSvc = DatabaseViewBackendService(viewId: viewId), _sortBackendSvc = SortBackendService(viewId: viewId), _sortsListener = SortsListener(viewId: viewId), _fieldSettingsListener = FieldSettingsListener(viewId: viewId), _fieldSettingsBackendSvc = FieldSettingsBackendService(viewId: viewId) { // Start listeners _listenOnFieldChanges(); _listenOnSettingChanges(); _listenOnFilterChanges(); _listenOnSortChanged(); _listenOnFieldSettingsChanged(); } final String viewId; // Listeners final FieldsListener _fieldListener; final DatabaseSettingListener _settingListener; final FiltersListener _filtersListener; final SortsListener _sortsListener; final FieldSettingsListener _fieldSettingsListener; // FFI services final DatabaseViewBackendService _databaseViewBackendSvc; final FilterBackendService _filterBackendSvc; final SortBackendService _sortBackendSvc; final FieldSettingsBackendService _fieldSettingsBackendSvc; bool _isDisposed = false; // Field callbacks final Map _fieldCallbacks = {}; final _GridFieldNotifier _fieldNotifier = _GridFieldNotifier(); // Field updated callbacks final Map)> _updatedFieldCallbacks = {}; // Filter callbacks final Map _filterCallbacks = {}; _GridFilterNotifier? _filterNotifier = _GridFilterNotifier(); // Sort callbacks final Map _sortCallbacks = {}; _GridSortNotifier? _sortNotifier = _GridSortNotifier(); // Database settings temporary storage final Map _groupConfigurationByFieldId = {}; final List _fieldSettings = []; // Getters List get fieldInfos => [..._fieldNotifier.fieldInfos]; List get filters => [..._filterNotifier?.filters ?? []]; List get sorts => [..._sortNotifier?.sorts ?? []]; List get groupSettings => _groupConfigurationByFieldId.entries.map((e) => e.value).toList(); FieldInfo? getField(String fieldId) { return _fieldNotifier.fieldInfos .firstWhereOrNull((element) => element.id == fieldId); } DatabaseFilter? getFilterByFilterId(String filterId) { return _filterNotifier?.filters .firstWhereOrNull((element) => element.filterId == filterId); } DatabaseFilter? getFilterByFieldId(String fieldId) { return _filterNotifier?.filters .firstWhereOrNull((element) => element.fieldId == fieldId); } DatabaseSort? getSortBySortId(String sortId) { return _sortNotifier?.sorts .firstWhereOrNull((element) => element.sortId == sortId); } DatabaseSort? getSortByFieldId(String fieldId) { return _sortNotifier?.sorts .firstWhereOrNull((element) => element.fieldId == fieldId); } /// Listen for filter changes in the backend. void _listenOnFilterChanges() { _filtersListener.start( onFilterChanged: (result) { if (_isDisposed) { return; } result.fold( (FilterChangesetNotificationPB changeset) { _filterNotifier?.filters = _filterListFromPBs(changeset.filters.items); _fieldNotifier.fieldInfos = _updateFieldInfos(_fieldNotifier.fieldInfos); }, (err) => Log.error(err), ); }, ); } /// Listen for sort changes in the backend. void _listenOnSortChanged() { void deleteSortFromChangeset( List newDatabaseSorts, SortChangesetNotificationPB changeset, ) { final deleteSortIds = changeset.deleteSorts.map((e) => e.id).toList(); if (deleteSortIds.isNotEmpty) { newDatabaseSorts.retainWhere( (element) => !deleteSortIds.contains(element.sortId), ); } } void insertSortFromChangeset( List newDatabaseSorts, SortChangesetNotificationPB changeset, ) { for (final newSortPB in changeset.insertSorts) { final sortIndex = newDatabaseSorts .indexWhere((element) => element.sortId == newSortPB.sort.id); if (sortIndex == -1) { newDatabaseSorts.insert( newSortPB.index, DatabaseSort.fromPB(newSortPB.sort), ); } } } void updateSortFromChangeset( List newDatabaseSorts, SortChangesetNotificationPB changeset, ) { for (final updatedSort in changeset.updateSorts) { final newDatabaseSort = DatabaseSort.fromPB(updatedSort); final sortIndex = newDatabaseSorts.indexWhere( (element) => element.sortId == updatedSort.id, ); if (sortIndex != -1) { newDatabaseSorts.removeAt(sortIndex); newDatabaseSorts.insert(sortIndex, newDatabaseSort); } else { newDatabaseSorts.add(newDatabaseSort); } } } void updateFieldInfos( List newDatabaseSorts, SortChangesetNotificationPB changeset, ) { final changedFieldIds = HashSet.from([ ...changeset.insertSorts.map((sort) => sort.sort.fieldId), ...changeset.updateSorts.map((sort) => sort.fieldId), ...changeset.deleteSorts.map((sort) => sort.fieldId), ...?_sortNotifier?.sorts.map((sort) => sort.fieldId), ]); final newFieldInfos = [...fieldInfos]; for (final fieldId in changedFieldIds) { final index = newFieldInfos.indexWhere((fieldInfo) => fieldInfo.id == fieldId); if (index == -1) { continue; } newFieldInfos[index] = newFieldInfos[index].copyWith( hasSort: newDatabaseSorts.any((sort) => sort.fieldId == fieldId), ); } _fieldNotifier.fieldInfos = newFieldInfos; } _sortsListener.start( onSortChanged: (result) { if (_isDisposed) { return; } result.fold( (SortChangesetNotificationPB changeset) { final List newDatabaseSorts = sorts; deleteSortFromChangeset(newDatabaseSorts, changeset); insertSortFromChangeset(newDatabaseSorts, changeset); updateSortFromChangeset(newDatabaseSorts, changeset); updateFieldInfos(newDatabaseSorts, changeset); _sortNotifier?.sorts = newDatabaseSorts; }, (err) => Log.error(err), ); }, ); } /// Listen for database setting changes in the backend. void _listenOnSettingChanges() { _settingListener.start( onSettingUpdated: (result) { if (_isDisposed) { return; } result.fold( (setting) => _updateSetting(setting), (r) => Log.error(r), ); }, ); } /// Listen for field changes in the backend. void _listenOnFieldChanges() { Future attachFieldSettings(FieldInfo fieldInfo) async { return _fieldSettingsBackendSvc .getFieldSettings(fieldInfo.id) .then((result) { final fieldSettings = result.fold( (fieldSettings) => fieldSettings, (err) => null, ); if (fieldSettings == null) { return fieldInfo; } final updatedFieldInfo = fieldInfo.copyWith(fieldSettings: fieldSettings); final index = _fieldSettings .indexWhere((element) => element.fieldId == fieldInfo.id); if (index != -1) { _fieldSettings.removeAt(index); } _fieldSettings.add(fieldSettings); return updatedFieldInfo; }); } List deleteFields(List deletedFields) { if (deletedFields.isEmpty) { return fieldInfos; } final List newFields = fieldInfos; final Map deletedFieldMap = { for (final fieldOrder in deletedFields) fieldOrder.fieldId: fieldOrder, }; newFields.retainWhere((field) => deletedFieldMap[field.id] == null); return newFields; } Future> insertFields( List insertedFields, List fieldInfos, ) async { if (insertedFields.isEmpty) { return fieldInfos; } final List newFieldInfos = fieldInfos; for (final indexField in insertedFields) { final initial = FieldInfo.initial(indexField.field_1); final fieldInfo = await attachFieldSettings(initial); if (newFieldInfos.length > indexField.index) { newFieldInfos.insert(indexField.index, fieldInfo); } else { newFieldInfos.add(fieldInfo); } } return newFieldInfos; } Future<(List, List)> updateFields( List updatedFieldPBs, List fieldInfos, ) async { if (updatedFieldPBs.isEmpty) { return ([], fieldInfos); } final List newFieldInfo = fieldInfos; final List updatedFields = []; for (final updatedFieldPB in updatedFieldPBs) { final index = newFieldInfo.indexWhere((field) => field.id == updatedFieldPB.id); if (index != -1) { newFieldInfo.removeAt(index); final initial = FieldInfo.initial(updatedFieldPB); final fieldInfo = await attachFieldSettings(initial); newFieldInfo.insert(index, fieldInfo); updatedFields.add(fieldInfo); } } return (updatedFields, newFieldInfo); } // Listen on field's changes _fieldListener.start( onFieldsChanged: (result) async { result.fold( (changeset) async { if (_isDisposed) { return; } List updatedFields; List fieldInfos = deleteFields(changeset.deletedFields); fieldInfos = await insertFields(changeset.insertedFields, fieldInfos); (updatedFields, fieldInfos) = await updateFields(changeset.updatedFields, fieldInfos); _fieldNotifier.fieldInfos = _updateFieldInfos(fieldInfos); for (final listener in _updatedFieldCallbacks.values) { listener(updatedFields); } }, (err) => Log.error(err), ); }, ); } /// Listen for field setting changes in the backend. void _listenOnFieldSettingsChanged() { FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) { final newFields = [...fieldInfos]; if (newFields.isEmpty) { return null; } final index = newFields .indexWhere((field) => field.id == updatedFieldSettings.fieldId); if (index != -1) { newFields[index] = newFields[index].copyWith(fieldSettings: updatedFieldSettings); _fieldNotifier.fieldInfos = newFields; _fieldSettings ..removeWhere( (field) => field.fieldId == updatedFieldSettings.fieldId, ) ..add(updatedFieldSettings); return newFields[index]; } return null; } _fieldSettingsListener.start( onFieldSettingsChanged: (result) { if (_isDisposed) { return; } result.fold( (fieldSettings) { final updatedFieldInfo = updateFieldSettings(fieldSettings); if (updatedFieldInfo == null) { return; } for (final listener in _updatedFieldCallbacks.values) { listener([updatedFieldInfo]); } }, (err) => Log.error(err), ); }, ); } /// Updates sort, filter, group and field info from `DatabaseViewSettingPB` void _updateSetting(DatabaseViewSettingPB setting) { _groupConfigurationByFieldId.clear(); for (final configuration in setting.groupSettings.items) { _groupConfigurationByFieldId[configuration.fieldId] = configuration; } _filterNotifier?.filters = _filterListFromPBs(setting.filters.items); _sortNotifier?.sorts = _sortListFromPBs(setting.sorts.items); _fieldSettings.clear(); _fieldSettings.addAll(setting.fieldSettings.items); _fieldNotifier.fieldInfos = _updateFieldInfos(_fieldNotifier.fieldInfos); } /// Attach sort, filter, group information and field settings to `FieldInfo` List _updateFieldInfos(List fieldInfos) { return fieldInfos .map( (field) => field.copyWith( fieldSettings: _fieldSettings .firstWhereOrNull((setting) => setting.fieldId == field.id), isGroupField: _groupConfigurationByFieldId[field.id] != null, hasFilter: getFilterByFieldId(field.id) != null, hasSort: getSortByFieldId(field.id) != null, ), ) .toList(); } /// Load all of the fields. This is required when opening the database Future> loadFields({ required List fieldIds, }) async { final result = await _databaseViewBackendSvc.getFields(fieldIds: fieldIds); return Future( () => result.fold( (newFields) async { if (_isDisposed) { return FlowyResult.success(null); } _fieldNotifier.fieldInfos = newFields.map((field) => FieldInfo.initial(field)).toList(); await Future.wait([ _loadFilters(), _loadSorts(), _loadAllFieldSettings(), _loadSettings(), ]); _fieldNotifier.fieldInfos = _updateFieldInfos(_fieldNotifier.fieldInfos); return FlowyResult.success(null); }, (err) => FlowyResult.failure(err), ), ); } /// Load all the filters from the backend. Required by `loadFields` Future> _loadFilters() async { return _filterBackendSvc.getAllFilters().then((result) { return result.fold( (filterPBs) { _filterNotifier?.filters = _filterListFromPBs(filterPBs); return FlowyResult.success(null); }, (err) => FlowyResult.failure(err), ); }); } /// Load all the sorts from the backend. Required by `loadFields` Future> _loadSorts() async { return _sortBackendSvc.getAllSorts().then((result) { return result.fold( (sortPBs) { _sortNotifier?.sorts = _sortListFromPBs(sortPBs); return FlowyResult.success(null); }, (err) => FlowyResult.failure(err), ); }); } /// Load all the field settings from the backend. Required by `loadFields` Future> _loadAllFieldSettings() async { return _fieldSettingsBackendSvc.getAllFieldSettings().then((result) { return result.fold( (fieldSettingsList) { _fieldSettings.clear(); _fieldSettings.addAll(fieldSettingsList); return FlowyResult.success(null); }, (err) => FlowyResult.failure(err), ); }); } Future> _loadSettings() async { return SettingBackendService(viewId: viewId).getSetting().then( (result) => result.fold( (setting) { _groupConfigurationByFieldId.clear(); for (final configuration in setting.groupSettings.items) { _groupConfigurationByFieldId[configuration.fieldId] = configuration; } return FlowyResult.success(null); }, (err) => FlowyResult.failure(err), ), ); } /// Attach corresponding `FieldInfo`s to the `FilterPB`s List _filterListFromPBs(List filterPBs) { return filterPBs.map(DatabaseFilter.fromPB).toList(); } /// Attach corresponding `FieldInfo`s to the `SortPB`s List _sortListFromPBs(List sortPBs) { return sortPBs.map(DatabaseSort.fromPB).toList(); } void addListener({ OnReceiveFields? onReceiveFields, OnReceiveUpdateFields? onFieldsChanged, OnReceiveFilters? onFilters, OnReceiveSorts? onSorts, bool Function()? listenWhen, }) { if (onFieldsChanged != null) { void callback(List updateFields) { if (listenWhen != null && listenWhen() == false) { return; } onFieldsChanged(updateFields); } _updatedFieldCallbacks[onFieldsChanged] = callback; } if (onReceiveFields != null) { void callback() { if (listenWhen != null && listenWhen() == false) { return; } onReceiveFields(fieldInfos); } _fieldCallbacks[onReceiveFields] = callback; _fieldNotifier.addListener(callback); } if (onFilters != null) { void callback() { if (listenWhen != null && listenWhen() == false) { return; } onFilters(filters); } _filterCallbacks[onFilters] = callback; _filterNotifier?.addListener(callback); } if (onSorts != null) { void callback() { if (listenWhen != null && listenWhen() == false) { return; } onSorts(sorts); } _sortCallbacks[onSorts] = callback; _sortNotifier?.addListener(callback); } } void addSingleFieldListener( String fieldId, { required OnReceiveField onFieldChanged, bool Function()? listenWhen, }) { void key(List fieldInfos) { final fieldInfo = fieldInfos.firstWhereOrNull( (fieldInfo) => fieldInfo.id == fieldId, ); if (fieldInfo != null) { onFieldChanged(fieldInfo); } } void callback() { if (listenWhen != null && listenWhen() == false) { return; } key(fieldInfos); } _fieldCallbacks[key] = callback; _fieldNotifier.addListener(callback); } void removeListener({ OnReceiveFields? onFieldsListener, OnReceiveSorts? onSortsListener, OnReceiveFilters? onFiltersListener, OnReceiveUpdateFields? onChangesetListener, }) { if (onFieldsListener != null) { final callback = _fieldCallbacks.remove(onFieldsListener); if (callback != null) { _fieldNotifier.removeListener(callback); } } if (onFiltersListener != null) { final callback = _filterCallbacks.remove(onFiltersListener); if (callback != null) { _filterNotifier?.removeListener(callback); } } if (onSortsListener != null) { final callback = _sortCallbacks.remove(onSortsListener); if (callback != null) { _sortNotifier?.removeListener(callback); } } } void removeSingleFieldListener({ required String fieldId, required OnReceiveField onFieldChanged, }) { void key(List fieldInfos) { final fieldInfo = fieldInfos.firstWhereOrNull( (fieldInfo) => fieldInfo.id == fieldId, ); if (fieldInfo != null) { onFieldChanged(fieldInfo); } } final callback = _fieldCallbacks.remove(key); if (callback != null) { _fieldNotifier.removeListener(callback); } } /// Stop listeners, dispose notifiers and clear listener callbacks Future dispose() async { if (_isDisposed) { Log.warn('FieldController is already disposed'); return; } _isDisposed = true; await _fieldListener.stop(); await _filtersListener.stop(); await _settingListener.stop(); await _sortsListener.stop(); await _fieldSettingsListener.stop(); for (final callback in _fieldCallbacks.values) { _fieldNotifier.removeListener(callback); } _fieldNotifier.dispose(); for (final callback in _filterCallbacks.values) { _filterNotifier?.removeListener(callback); } _filterNotifier?.dispose(); _filterNotifier = null; for (final callback in _sortCallbacks.values) { _sortNotifier?.removeListener(callback); } _sortNotifier?.dispose(); _sortNotifier = null; } } class RowCacheDependenciesImpl extends RowFieldsDelegate with RowLifeCycle { RowCacheDependenciesImpl(FieldController cache) : _fieldController = cache; final FieldController _fieldController; OnReceiveFields? _onFieldFn; @override UnmodifiableListView get fieldInfos => UnmodifiableListView(_fieldController.fieldInfos); @override void onFieldsChanged(void Function(List) callback) { if (_onFieldFn != null) { _fieldController.removeListener(onFieldsListener: _onFieldFn!); } _onFieldFn = (fieldInfos) => callback(fieldInfos); _fieldController.addListener(onReceiveFields: _onFieldFn); } @override void onRowDisposed() { if (_onFieldFn != null) { _fieldController.removeListener(onFieldsListener: _onFieldFn!); _onFieldFn = null; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'field_controller.dart'; import 'field_info.dart'; part 'field_editor_bloc.freezed.dart'; class FieldEditorBloc extends Bloc { FieldEditorBloc({ required this.viewId, required this.fieldInfo, required this.fieldController, this.onFieldInserted, required this.isNew, }) : _fieldService = FieldBackendService( viewId: viewId, fieldId: fieldInfo.id, ), fieldSettingsService = FieldSettingsBackendService(viewId: viewId), super(FieldEditorState(field: fieldInfo)) { _dispatch(); _startListening(); _init(); } final String viewId; final FieldInfo fieldInfo; final bool isNew; final FieldController fieldController; final FieldBackendService _fieldService; final FieldSettingsBackendService fieldSettingsService; final void Function(String newFieldId)? onFieldInserted; late final OnReceiveField _listener; String get fieldId => fieldInfo.id; @override Future close() { fieldController.removeSingleFieldListener( fieldId: fieldId, onFieldChanged: _listener, ); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( didUpdateField: (fieldInfo) { emit(state.copyWith(field: fieldInfo)); }, switchFieldType: (fieldType) async { String? fieldName; if (!state.wasRenameManually && isNew) { fieldName = fieldType.i18n; } await _fieldService.updateType( fieldType: fieldType, fieldName: fieldName, ); }, renameField: (newName) async { final result = await _fieldService.updateField(name: newName); _logIfError(result); emit(state.copyWith(wasRenameManually: true)); }, updateIcon: (icon) async { final result = await _fieldService.updateField(icon: icon); _logIfError(result); }, updateTypeOption: (typeOptionData) async { final result = await FieldBackendService.updateFieldTypeOption( viewId: viewId, fieldId: fieldId, typeOptionData: typeOptionData, ); _logIfError(result); }, insertLeft: () async { final result = await _fieldService.createBefore(); result.fold( (newField) => onFieldInserted?.call(newField.id), (err) => Log.error("Failed creating field $err"), ); }, insertRight: () async { final result = await _fieldService.createAfter(); result.fold( (newField) => onFieldInserted?.call(newField.id), (err) => Log.error("Failed creating field $err"), ); }, toggleFieldVisibility: () async { final currentVisibility = state.field.visibility ?? FieldVisibility.AlwaysShown; final newVisibility = currentVisibility == FieldVisibility.AlwaysHidden ? FieldVisibility.AlwaysShown : FieldVisibility.AlwaysHidden; final result = await fieldSettingsService.updateFieldSettings( fieldId: fieldId, fieldVisibility: newVisibility, ); _logIfError(result); }, toggleWrapCellContent: () async { final currentWrap = state.field.wrapCellContent ?? false; final result = await fieldSettingsService.updateFieldSettings( fieldId: state.field.id, wrapCellContent: !currentWrap, ); _logIfError(result); }, ); }, ); } void _startListening() { _listener = (field) { if (!isClosed) { add(FieldEditorEvent.didUpdateField(field)); } }; fieldController.addSingleFieldListener( fieldId, onFieldChanged: _listener, ); } void _init() async { await Future.delayed(const Duration(milliseconds: 50)); if (!isClosed) { final field = fieldController.getField(fieldId); if (field != null) { add(FieldEditorEvent.didUpdateField(field)); } } } void _logIfError(FlowyResult result) { result.fold( (l) => null, (err) => Log.error(err), ); } } @freezed class FieldEditorEvent with _$FieldEditorEvent { const factory FieldEditorEvent.didUpdateField(final FieldInfo fieldInfo) = _DidUpdateField; const factory FieldEditorEvent.switchFieldType(final FieldType fieldType) = _SwitchFieldType; const factory FieldEditorEvent.updateTypeOption( final Uint8List typeOptionData, ) = _UpdateTypeOption; const factory FieldEditorEvent.renameField(final String name) = _RenameField; const factory FieldEditorEvent.updateIcon(String icon) = _UpdateIcon; const factory FieldEditorEvent.insertLeft() = _InsertLeft; const factory FieldEditorEvent.insertRight() = _InsertRight; const factory FieldEditorEvent.toggleFieldVisibility() = _ToggleFieldVisiblity; const factory FieldEditorEvent.toggleWrapCellContent() = _ToggleWrapCellContent; } @freezed class FieldEditorState with _$FieldEditorState { const factory FieldEditorState({ required final FieldInfo field, @Default(false) bool wasRenameManually, }) = _FieldEditorState; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'field_info.freezed.dart'; @freezed class FieldInfo with _$FieldInfo { const FieldInfo._(); factory FieldInfo.initial(FieldPB field) => FieldInfo( field: field, fieldSettings: null, hasFilter: false, hasSort: false, isGroupField: false, ); const factory FieldInfo({ required FieldPB field, required FieldSettingsPB? fieldSettings, required bool isGroupField, required bool hasFilter, required bool hasSort, }) = _FieldInfo; String get id => field.id; FieldType get fieldType => field.fieldType; String get name => field.name; String get icon => field.icon; bool get isPrimary => field.isPrimary; double? get width => fieldSettings?.width.toDouble(); FieldVisibility? get visibility => fieldSettings?.visibility; bool? get wrapCellContent => fieldSettings?.wrapCellContent; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/filter_entities.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/view/database_filter_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/grid/application/filter/select_option_loader.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:equatable/equatable.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; abstract class DatabaseFilter extends Equatable { const DatabaseFilter({ required this.filterId, required this.fieldId, required this.fieldType, }); factory DatabaseFilter.fromPB(FilterPB filterPB) { final FilterDataPB(:fieldId, :fieldType) = filterPB.data; switch (fieldType) { case FieldType.RichText: case FieldType.URL: final data = TextFilterPB.fromBuffer(filterPB.data.data); return TextFilter( filterId: filterPB.id, fieldId: fieldId, fieldType: fieldType, condition: data.condition, content: data.content, ); case FieldType.Number: final data = NumberFilterPB.fromBuffer(filterPB.data.data); return NumberFilter( filterId: filterPB.id, fieldId: fieldId, fieldType: fieldType, condition: data.condition, content: data.content, ); case FieldType.Checkbox: final data = CheckboxFilterPB.fromBuffer(filterPB.data.data); return CheckboxFilter( filterId: filterPB.id, fieldId: fieldId, fieldType: fieldType, condition: data.condition, ); case FieldType.Checklist: final data = ChecklistFilterPB.fromBuffer(filterPB.data.data); return ChecklistFilter( filterId: filterPB.id, fieldId: fieldId, fieldType: fieldType, condition: data.condition, ); case FieldType.SingleSelect: case FieldType.MultiSelect: final data = SelectOptionFilterPB.fromBuffer(filterPB.data.data); return SelectOptionFilter( filterId: filterPB.id, fieldId: fieldId, fieldType: fieldType, condition: data.condition, optionIds: data.optionIds, ); case FieldType.LastEditedTime: case FieldType.CreatedTime: case FieldType.DateTime: final data = DateFilterPB.fromBuffer(filterPB.data.data); return DateTimeFilter( filterId: filterPB.id, fieldId: fieldId, fieldType: fieldType, condition: data.condition, timestamp: data.hasTimestamp() ? data.timestamp.toDateTime() : null, start: data.hasStart() ? data.start.toDateTime() : null, end: data.hasEnd() ? data.end.toDateTime() : null, ); default: throw ArgumentError(); } } final String filterId; final String fieldId; final FieldType fieldType; String get conditionName; bool get canAttachContent; String getContentDescription(FieldInfo field); Widget getMobileDescription( FieldInfo field, { required VoidCallback onExpand, required void Function(DatabaseFilter filter) onUpdate, }) => const SizedBox.shrink(); Uint8List writeToBuffer(); } final class TextFilter extends DatabaseFilter { TextFilter({ required super.filterId, required super.fieldId, required super.fieldType, required this.condition, required String content, }) { this.content = canAttachContent ? content : ""; } final TextFilterConditionPB condition; late final String content; @override String get conditionName => condition.filterName; @override bool get canAttachContent => condition != TextFilterConditionPB.TextIsEmpty && condition != TextFilterConditionPB.TextIsNotEmpty; @override String getContentDescription(FieldInfo field) { final filterDesc = condition.choicechipPrefix; if (condition == TextFilterConditionPB.TextIsEmpty || condition == TextFilterConditionPB.TextIsNotEmpty) { return filterDesc; } return content.isEmpty ? filterDesc : "$filterDesc $content"; } @override Widget getMobileDescription( FieldInfo field, { required VoidCallback onExpand, required void Function(DatabaseFilter filter) onUpdate, }) { return FilterItemInnerTextField( content: content, enabled: canAttachContent, onSubmitted: (content) { final newFilter = copyWith(content: content); onUpdate(newFilter); }, ); } @override Uint8List writeToBuffer() { final filterPB = TextFilterPB()..condition = condition; if (condition != TextFilterConditionPB.TextIsEmpty && condition != TextFilterConditionPB.TextIsNotEmpty) { filterPB.content = content; } return filterPB.writeToBuffer(); } TextFilter copyWith({ TextFilterConditionPB? condition, String? content, }) { return TextFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, condition: condition ?? this.condition, content: content ?? this.content, ); } @override List get props => [filterId, fieldId, condition, content]; } final class NumberFilter extends DatabaseFilter { NumberFilter({ required super.filterId, required super.fieldId, required super.fieldType, required this.condition, required String content, }) { this.content = canAttachContent ? content : ""; } final NumberFilterConditionPB condition; late final String content; @override String get conditionName => condition.filterName; @override bool get canAttachContent => condition != NumberFilterConditionPB.NumberIsEmpty && condition != NumberFilterConditionPB.NumberIsNotEmpty; @override String getContentDescription(FieldInfo field) { if (condition == NumberFilterConditionPB.NumberIsEmpty || condition == NumberFilterConditionPB.NumberIsNotEmpty) { return condition.shortName; } return "${condition.shortName} $content"; } @override Widget getMobileDescription( FieldInfo field, { required VoidCallback onExpand, required void Function(DatabaseFilter filter) onUpdate, }) { return FilterItemInnerTextField( content: content, enabled: canAttachContent, onSubmitted: (content) { final newFilter = copyWith(content: content); onUpdate(newFilter); }, ); } @override Uint8List writeToBuffer() { final filterPB = NumberFilterPB()..condition = condition; if (condition != NumberFilterConditionPB.NumberIsEmpty && condition != NumberFilterConditionPB.NumberIsNotEmpty) { filterPB.content = content; } return filterPB.writeToBuffer(); } NumberFilter copyWith({ NumberFilterConditionPB? condition, String? content, }) { return NumberFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, condition: condition ?? this.condition, content: content ?? this.content, ); } @override List get props => [filterId, fieldId, condition, content]; } final class CheckboxFilter extends DatabaseFilter { const CheckboxFilter({ required super.filterId, required super.fieldId, required super.fieldType, required this.condition, }); final CheckboxFilterConditionPB condition; @override String get conditionName => condition.filterName; @override bool get canAttachContent => false; @override String getContentDescription(FieldInfo field) => condition.filterName; @override Uint8List writeToBuffer() { return (CheckboxFilterPB()..condition = condition).writeToBuffer(); } CheckboxFilter copyWith({ CheckboxFilterConditionPB? condition, }) { return CheckboxFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, condition: condition ?? this.condition, ); } @override List get props => [filterId, fieldId, condition]; } final class ChecklistFilter extends DatabaseFilter { const ChecklistFilter({ required super.filterId, required super.fieldId, required super.fieldType, required this.condition, }); final ChecklistFilterConditionPB condition; @override String get conditionName => condition.filterName; @override bool get canAttachContent => false; @override String getContentDescription(FieldInfo field) => condition.filterName; @override Uint8List writeToBuffer() { return (ChecklistFilterPB()..condition = condition).writeToBuffer(); } ChecklistFilter copyWith({ ChecklistFilterConditionPB? condition, }) { return ChecklistFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, condition: condition ?? this.condition, ); } @override List get props => [filterId, fieldId, condition]; } final class SelectOptionFilter extends DatabaseFilter { SelectOptionFilter({ required super.filterId, required super.fieldId, required super.fieldType, required this.condition, required List optionIds, }) { if (canAttachContent) { this.optionIds.addAll(optionIds); } } final SelectOptionFilterConditionPB condition; final List optionIds = []; @override String get conditionName => condition.i18n; @override bool get canAttachContent => condition != SelectOptionFilterConditionPB.OptionIsEmpty && condition != SelectOptionFilterConditionPB.OptionIsNotEmpty; @override String getContentDescription(FieldInfo field) { if (!canAttachContent || optionIds.isEmpty) { return condition.i18n; } final delegate = makeDelegate(field); final options = delegate.getOptions(field); final optionNames = options .where((option) => optionIds.contains(option.id)) .map((option) => option.name) .join(', '); return "${condition.i18n} $optionNames"; } @override Widget getMobileDescription( FieldInfo field, { required VoidCallback onExpand, required void Function(DatabaseFilter filter) onUpdate, }) { final delegate = makeDelegate(field); final options = delegate .getOptions(field) .where((option) => optionIds.contains(option.id)) .toList(); return FilterItemInnerButton( onTap: onExpand, child: ListView.separated( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, separatorBuilder: (context, index) => const HSpace(8), itemCount: options.length, itemBuilder: (context, index) => SelectOptionTag( option: options[index], fontSize: 14, borderRadius: BorderRadius.circular(9), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), ), padding: const EdgeInsets.symmetric(vertical: 8), ), ); } @override Uint8List writeToBuffer() { final filterPB = SelectOptionFilterPB()..condition = condition; if (canAttachContent) { filterPB.optionIds.addAll(optionIds); } return filterPB.writeToBuffer(); } SelectOptionFilter copyWith({ SelectOptionFilterConditionPB? condition, List? optionIds, }) { return SelectOptionFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, condition: condition ?? this.condition, optionIds: optionIds ?? this.optionIds, ); } SelectOptionFilterDelegate makeDelegate(FieldInfo field) => field.fieldType == FieldType.SingleSelect ? const SingleSelectOptionFilterDelegateImpl() : const MultiSelectOptionFilterDelegateImpl(); @override List get props => [filterId, fieldId, condition, optionIds]; } enum DateTimeFilterCondition { on, before, after, onOrBefore, onOrAfter, between, isEmpty, isNotEmpty; DateFilterConditionPB toPB(bool isStart) { return isStart ? switch (this) { on => DateFilterConditionPB.DateStartsOn, before => DateFilterConditionPB.DateStartsBefore, after => DateFilterConditionPB.DateStartsAfter, onOrBefore => DateFilterConditionPB.DateStartsOnOrBefore, onOrAfter => DateFilterConditionPB.DateStartsOnOrAfter, between => DateFilterConditionPB.DateStartsBetween, isEmpty => DateFilterConditionPB.DateStartIsEmpty, isNotEmpty => DateFilterConditionPB.DateStartIsNotEmpty, } : switch (this) { on => DateFilterConditionPB.DateEndsOn, before => DateFilterConditionPB.DateEndsBefore, after => DateFilterConditionPB.DateEndsAfter, onOrBefore => DateFilterConditionPB.DateEndsOnOrBefore, onOrAfter => DateFilterConditionPB.DateEndsOnOrAfter, between => DateFilterConditionPB.DateEndsBetween, isEmpty => DateFilterConditionPB.DateEndIsEmpty, isNotEmpty => DateFilterConditionPB.DateEndIsNotEmpty, }; } String get choiceChipPrefix { return switch (this) { on => "", before => LocaleKeys.grid_dateFilter_choicechipPrefix_before.tr(), after => LocaleKeys.grid_dateFilter_choicechipPrefix_after.tr(), onOrBefore => LocaleKeys.grid_dateFilter_choicechipPrefix_onOrBefore.tr(), onOrAfter => LocaleKeys.grid_dateFilter_choicechipPrefix_onOrAfter.tr(), between => LocaleKeys.grid_dateFilter_choicechipPrefix_between.tr(), isEmpty => LocaleKeys.grid_dateFilter_choicechipPrefix_isEmpty.tr(), isNotEmpty => LocaleKeys.grid_dateFilter_choicechipPrefix_isNotEmpty.tr(), }; } String get filterName { return switch (this) { on => LocaleKeys.grid_dateFilter_is.tr(), before => LocaleKeys.grid_dateFilter_before.tr(), after => LocaleKeys.grid_dateFilter_after.tr(), onOrBefore => LocaleKeys.grid_dateFilter_onOrBefore.tr(), onOrAfter => LocaleKeys.grid_dateFilter_onOrAfter.tr(), between => LocaleKeys.grid_dateFilter_between.tr(), isEmpty => LocaleKeys.grid_dateFilter_empty.tr(), isNotEmpty => LocaleKeys.grid_dateFilter_notEmpty.tr(), }; } static List availableConditionsForFieldType( FieldType fieldType, ) { final result = [...values]; if (fieldType == FieldType.CreatedTime || fieldType == FieldType.LastEditedTime) { result.remove(isEmpty); result.remove(isNotEmpty); } return result; } } final class DateTimeFilter extends DatabaseFilter { DateTimeFilter({ required super.filterId, required super.fieldId, required super.fieldType, required this.condition, DateTime? timestamp, DateTime? start, DateTime? end, }) { if (canAttachContent) { if (condition == DateFilterConditionPB.DateStartsBetween || condition == DateFilterConditionPB.DateEndsBetween) { this.start = start; this.end = end; this.timestamp = null; } else { this.timestamp = timestamp; this.start = null; this.end = null; } } else { this.timestamp = null; this.start = null; this.end = null; } } final DateFilterConditionPB condition; late final DateTime? timestamp; late final DateTime? start; late final DateTime? end; @override String get conditionName => condition.toCondition().filterName; @override bool get canAttachContent => ![ DateFilterConditionPB.DateStartIsEmpty, DateFilterConditionPB.DateStartIsNotEmpty, DateFilterConditionPB.DateEndIsEmpty, DateFilterConditionPB.DateEndIsNotEmpty, ].contains(condition); @override String getContentDescription(FieldInfo field) { return switch (condition) { DateFilterConditionPB.DateStartIsEmpty || DateFilterConditionPB.DateStartIsNotEmpty || DateFilterConditionPB.DateEndIsEmpty || DateFilterConditionPB.DateEndIsNotEmpty => condition.toCondition().choiceChipPrefix, DateFilterConditionPB.DateStartsOn || DateFilterConditionPB.DateEndsOn => timestamp?.defaultFormat ?? "", DateFilterConditionPB.DateStartsBetween || DateFilterConditionPB.DateEndsBetween => "${condition.toCondition().choiceChipPrefix} ${start?.defaultFormat ?? ""} - ${end?.defaultFormat ?? ""}", _ => "${condition.toCondition().choiceChipPrefix} ${timestamp?.defaultFormat ?? ""}" }; } @override Widget getMobileDescription( FieldInfo field, { required VoidCallback onExpand, required void Function(DatabaseFilter filter) onUpdate, }) { String? text; if (condition.isRange) { text = "${start?.defaultFormat ?? ""} - ${end?.defaultFormat ?? ""}"; text = text == " - " ? null : text; } else { text = timestamp.defaultFormat; } return FilterItemInnerButton( onTap: onExpand, child: FlowyText( text ?? "", overflow: TextOverflow.ellipsis, ), ); } @override Uint8List writeToBuffer() { final filterPB = DateFilterPB()..condition = condition; Int64 dateTimeToInt(DateTime dateTime) { return Int64(dateTime.millisecondsSinceEpoch ~/ 1000); } switch (condition) { case DateFilterConditionPB.DateStartsOn: case DateFilterConditionPB.DateStartsBefore: case DateFilterConditionPB.DateStartsOnOrBefore: case DateFilterConditionPB.DateStartsAfter: case DateFilterConditionPB.DateStartsOnOrAfter: case DateFilterConditionPB.DateEndsOn: case DateFilterConditionPB.DateEndsBefore: case DateFilterConditionPB.DateEndsOnOrBefore: case DateFilterConditionPB.DateEndsAfter: case DateFilterConditionPB.DateEndsOnOrAfter: if (timestamp != null) { filterPB.timestamp = dateTimeToInt(timestamp!); } break; case DateFilterConditionPB.DateStartsBetween: case DateFilterConditionPB.DateEndsBetween: if (start != null) { filterPB.start = dateTimeToInt(start!); } if (end != null) { filterPB.end = dateTimeToInt(end!); } break; default: break; } return filterPB.writeToBuffer(); } DateTimeFilter copyWithCondition({ required bool isStart, required DateTimeFilterCondition condition, }) { return DateTimeFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, condition: condition.toPB(isStart), start: start, end: end, timestamp: timestamp, ); } DateTimeFilter copyWithTimestamp({ required DateTime timestamp, }) { return DateTimeFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, condition: condition, start: start, end: end, timestamp: timestamp, ); } DateTimeFilter copyWithRange({ required DateTime? start, required DateTime? end, }) { return DateTimeFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, condition: condition, start: start, end: end, timestamp: timestamp, ); } @override List get props => [filterId, fieldId, condition, timestamp, start, end]; } final class TimeFilter extends DatabaseFilter { const TimeFilter({ required super.filterId, required super.fieldId, required super.fieldType, required this.condition, required this.content, }); final NumberFilterConditionPB condition; final String content; @override String get conditionName => condition.filterName; @override bool get canAttachContent => condition != NumberFilterConditionPB.NumberIsEmpty && condition != NumberFilterConditionPB.NumberIsNotEmpty; @override String getContentDescription(FieldInfo field) { if (condition == NumberFilterConditionPB.NumberIsEmpty || condition == NumberFilterConditionPB.NumberIsNotEmpty) { return condition.shortName; } return "${condition.shortName} $content"; } @override Uint8List writeToBuffer() { return (NumberFilterPB() ..condition = condition ..content = content) .writeToBuffer(); } TimeFilter copyWith({NumberFilterConditionPB? condition, String? content}) { return TimeFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, condition: condition ?? this.condition, content: content ?? this.content, ); } @override List get props => [filterId, fieldId, condition, content]; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/sort_entities.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:equatable/equatable.dart'; final class DatabaseSort extends Equatable { const DatabaseSort({ required this.sortId, required this.fieldId, required this.condition, }); DatabaseSort.fromPB(SortPB sort) : sortId = sort.id, fieldId = sort.fieldId, condition = sort.condition; final String sortId; final String fieldId; final SortConditionPB condition; @override List get props => [sortId, fieldId, condition]; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/edit_select_option_bloc.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; part 'edit_select_option_bloc.freezed.dart'; class EditSelectOptionBloc extends Bloc { EditSelectOptionBloc({required SelectOptionPB option}) : super(EditSelectOptionState.initial(option)) { on( (event, emit) async { event.when( updateName: (name) { emit(state.copyWith(option: _updateName(name))); }, updateColor: (color) { emit(state.copyWith(option: _updateColor(color))); }, delete: () { emit(state.copyWith(deleted: true)); }, ); }, ); } SelectOptionPB _updateColor(SelectOptionColorPB color) { state.option.freeze(); return state.option.rebuild((option) { option.color = color; }); } SelectOptionPB _updateName(String name) { state.option.freeze(); return state.option.rebuild((option) { option.name = name; }); } } @freezed class EditSelectOptionEvent with _$EditSelectOptionEvent { const factory EditSelectOptionEvent.updateName(String name) = _UpdateName; const factory EditSelectOptionEvent.updateColor(SelectOptionColorPB color) = _UpdateColor; const factory EditSelectOptionEvent.delete() = _Delete; } @freezed class EditSelectOptionState with _$EditSelectOptionState { const factory EditSelectOptionState({ required SelectOptionPB option, required bool deleted, }) = _EditSelectOptionState; factory EditSelectOptionState.initial(SelectOptionPB option) => EditSelectOptionState( option: option, deleted: false, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/number_format_bloc.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pbenum.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'number_format_bloc.freezed.dart'; class NumberFormatBloc extends Bloc { NumberFormatBloc() : super(NumberFormatState.initial()) { on( (event, emit) async { event.map( setFilter: (_SetFilter value) { final List formats = List.from(NumberFormatPB.values); if (value.filter.isNotEmpty) { formats.retainWhere( (element) => element .title() .toLowerCase() .contains(value.filter.toLowerCase()), ); } emit(state.copyWith(formats: formats, filter: value.filter)); }, ); }, ); } } @freezed class NumberFormatEvent with _$NumberFormatEvent { const factory NumberFormatEvent.setFilter(String filter) = _SetFilter; } @freezed class NumberFormatState with _$NumberFormatState { const factory NumberFormatState({ required List formats, required String filter, }) = _NumberFormatState; factory NumberFormatState.initial() { return const NumberFormatState( formats: NumberFormatPB.values, filter: "", ); } } extension NumberFormatExtension on NumberFormatPB { String title() { switch (this) { case NumberFormatPB.ArgentinePeso: return "Argentine peso"; case NumberFormatPB.Baht: return "Baht"; case NumberFormatPB.CanadianDollar: return "Canadian dollar"; case NumberFormatPB.ChileanPeso: return "Chilean peso"; case NumberFormatPB.ColombianPeso: return "Colombian peso"; case NumberFormatPB.DanishKrone: return "Danish crown"; case NumberFormatPB.Dirham: return "Dirham"; case NumberFormatPB.EUR: return "Euro"; case NumberFormatPB.Forint: return "Forint"; case NumberFormatPB.Franc: return "Franc"; case NumberFormatPB.HongKongDollar: return "Hone Kong dollar"; case NumberFormatPB.Koruna: return "Koruna"; case NumberFormatPB.Krona: return "Krona"; case NumberFormatPB.Leu: return "Leu"; case NumberFormatPB.Lira: return "Lira"; case NumberFormatPB.MexicanPeso: return "Mexican peso"; case NumberFormatPB.NewTaiwanDollar: return "New Taiwan dollar"; case NumberFormatPB.NewZealandDollar: return "New Zealand dollar"; case NumberFormatPB.NorwegianKrone: return "Norwegian krone"; case NumberFormatPB.Num: return "Number"; case NumberFormatPB.Percent: return "Percent"; case NumberFormatPB.PhilippinePeso: return "Philippine peso"; case NumberFormatPB.Pound: return "Pound"; case NumberFormatPB.Rand: return "Rand"; case NumberFormatPB.Real: return "Real"; case NumberFormatPB.Ringgit: return "Ringgit"; case NumberFormatPB.Riyal: return "Riyal"; case NumberFormatPB.Ruble: return "Ruble"; case NumberFormatPB.Rupee: return "Rupee"; case NumberFormatPB.Rupiah: return "Rupiah"; case NumberFormatPB.Shekel: return "Skekel"; case NumberFormatPB.USD: return "US dollar"; case NumberFormatPB.UruguayanPeso: return "Uruguayan peso"; case NumberFormatPB.Won: return "Won"; case NumberFormatPB.Yen: return "Yen"; case NumberFormatPB.Yuan: return "Yuan"; default: throw UnimplementedError; } } String iconSymbol([bool defaultPrefixInc = true]) { switch (this) { case NumberFormatPB.ArgentinePeso: return "\$"; case NumberFormatPB.Baht: return "฿"; case NumberFormatPB.CanadianDollar: return "C\$"; case NumberFormatPB.ChileanPeso: return "\$"; case NumberFormatPB.ColombianPeso: return "\$"; case NumberFormatPB.DanishKrone: return "kr"; case NumberFormatPB.Dirham: return "د.إ"; case NumberFormatPB.EUR: return "€"; case NumberFormatPB.Forint: return "Ft"; case NumberFormatPB.Franc: return "Fr"; case NumberFormatPB.HongKongDollar: return "HK\$"; case NumberFormatPB.Koruna: return "Kč"; case NumberFormatPB.Krona: return "kr"; case NumberFormatPB.Leu: return "lei"; case NumberFormatPB.Lira: return "₺"; case NumberFormatPB.MexicanPeso: return "\$"; case NumberFormatPB.NewTaiwanDollar: return "NT\$"; case NumberFormatPB.NewZealandDollar: return "NZ\$"; case NumberFormatPB.NorwegianKrone: return "kr"; case NumberFormatPB.Num: return defaultPrefixInc ? "#" : ""; case NumberFormatPB.Percent: return "%"; case NumberFormatPB.PhilippinePeso: return "₱"; case NumberFormatPB.Pound: return "£"; case NumberFormatPB.Rand: return "R"; case NumberFormatPB.Real: return "R\$"; case NumberFormatPB.Ringgit: return "RM"; case NumberFormatPB.Riyal: return "ر.س"; case NumberFormatPB.Ruble: return "₽"; case NumberFormatPB.Rupee: return "₹"; case NumberFormatPB.Rupiah: return "Rp"; case NumberFormatPB.Shekel: return "₪"; case NumberFormatPB.USD: return "\$"; case NumberFormatPB.UruguayanPeso: return "\$U"; case NumberFormatPB.Won: return "₩"; case NumberFormatPB.Yen: return "JPY ¥"; case NumberFormatPB.Yuan: return "¥"; default: throw UnimplementedError; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart ================================================ import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'relation_type_option_cubit.freezed.dart'; class RelationDatabaseListCubit extends Cubit { RelationDatabaseListCubit() : super(RelationDatabaseListState.initial()) { _loadDatabaseMetas(); } void _loadDatabaseMetas() async { final metaPBs = await DatabaseEventGetDatabases() .send() .fold>((s) => s.items, (f) => []); final futures = metaPBs.map((meta) { return ViewBackendService.getView(meta.viewId).then( (result) => result.fold( (s) => DatabaseMeta( databaseId: meta.databaseId, viewId: meta.viewId, databaseName: s.name, ), (f) => null, ), ); }); final databaseMetas = await Future.wait(futures); emit( RelationDatabaseListState( databaseMetas: databaseMetas.nonNulls.toList(), ), ); } } @freezed class DatabaseMeta with _$DatabaseMeta { factory DatabaseMeta({ /// id of the database required String databaseId, /// id of the view required String viewId, /// name of the database required String databaseName, }) = _DatabaseMeta; } @freezed class RelationDatabaseListState with _$RelationDatabaseListState { factory RelationDatabaseListState({ required List databaseMetas, }) = _RelationDatabaseListState; factory RelationDatabaseListState.initial() => RelationDatabaseListState(databaseMetas: []); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'select_type_option_actions.dart'; part 'select_option_type_option_bloc.freezed.dart'; class SelectOptionTypeOptionBloc extends Bloc { SelectOptionTypeOptionBloc({ required List options, required this.typeOptionAction, }) : super(SelectOptionTypeOptionState.initial(options)) { _dispatch(); } final ISelectOptionAction typeOptionAction; void _dispatch() { on( (event, emit) async { event.when( createOption: (optionName) { final List options = typeOptionAction.insertOption(state.options, optionName); emit(state.copyWith(options: options)); }, addingOption: () { emit(state.copyWith(isEditingOption: true, newOptionName: null)); }, endAddingOption: () { emit(state.copyWith(isEditingOption: false, newOptionName: null)); }, updateOption: (option) { final options = typeOptionAction.updateOption(state.options, option); emit(state.copyWith(options: options)); }, deleteOption: (option) { final options = typeOptionAction.deleteOption(state.options, option); emit(state.copyWith(options: options)); }, reorderOption: (fromOptionId, toOptionId) { final options = typeOptionAction.reorderOption( state.options, fromOptionId, toOptionId, ); emit(state.copyWith(options: options)); }, ); }, ); } } @freezed class SelectOptionTypeOptionEvent with _$SelectOptionTypeOptionEvent { const factory SelectOptionTypeOptionEvent.createOption(String optionName) = _CreateOption; const factory SelectOptionTypeOptionEvent.addingOption() = _AddingOption; const factory SelectOptionTypeOptionEvent.endAddingOption() = _EndAddingOption; const factory SelectOptionTypeOptionEvent.updateOption( SelectOptionPB option, ) = _UpdateOption; const factory SelectOptionTypeOptionEvent.deleteOption( SelectOptionPB option, ) = _DeleteOption; const factory SelectOptionTypeOptionEvent.reorderOption( String fromOptionId, String toOptionId, ) = _ReorderOption; } @freezed class SelectOptionTypeOptionState with _$SelectOptionTypeOptionState { const factory SelectOptionTypeOptionState({ required List options, required bool isEditingOption, required String? newOptionName, }) = _SelectOptionTypeOptionState; factory SelectOptionTypeOptionState.initial(List options) => SelectOptionTypeOptionState( options: options, isEditingOption: false, newOptionName: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart ================================================ import 'package:appflowy/plugins/database/domain/type_option_service.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/builder.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:nanoid/nanoid.dart'; abstract class ISelectOptionAction { ISelectOptionAction({ required this.onTypeOptionUpdated, required String viewId, required String fieldId, }) : service = TypeOptionBackendService(viewId: viewId, fieldId: fieldId); final TypeOptionBackendService service; final TypeOptionDataCallback onTypeOptionUpdated; void updateTypeOption(List options) { final newTypeOption = MultiSelectTypeOptionPB()..options.addAll(options); onTypeOptionUpdated(newTypeOption.writeToBuffer()); } List insertOption( List options, String optionName, ) { if (options.any((element) => element.name == optionName)) { return options; } final newOptions = List.from(options); final newSelectOption = SelectOptionPB() ..id = nanoid(4) ..color = newSelectOptionColor(options) ..name = optionName; newOptions.insert(0, newSelectOption); updateTypeOption(newOptions); return newOptions; } List deleteOption( List options, SelectOptionPB deletedOption, ) { final newOptions = List.from(options); final index = newOptions.indexWhere((option) => option.id == deletedOption.id); if (index != -1) { newOptions.removeAt(index); } updateTypeOption(newOptions); return newOptions; } List updateOption( List options, SelectOptionPB option, ) { final newOptions = List.from(options); final index = newOptions.indexWhere((element) => element.id == option.id); if (index != -1) { newOptions[index] = option; } updateTypeOption(newOptions); return newOptions; } List reorderOption( List options, String fromOptionId, String toOptionId, ) { final newOptions = List.from(options); final fromIndex = newOptions.indexWhere((element) => element.id == fromOptionId); final toIndex = newOptions.indexWhere((element) => element.id == toOptionId); if (fromIndex != -1 && toIndex != -1) { newOptions.insert(toIndex, newOptions.removeAt(fromIndex)); } updateTypeOption(newOptions); return newOptions; } } class MultiSelectAction extends ISelectOptionAction { MultiSelectAction({ required super.viewId, required super.fieldId, required super.onTypeOptionUpdated, }); @override void updateTypeOption(List options) { final newTypeOption = MultiSelectTypeOptionPB()..options.addAll(options); onTypeOptionUpdated(newTypeOption.writeToBuffer()); } } class SingleSelectAction extends ISelectOptionAction { SingleSelectAction({ required super.viewId, required super.fieldId, required super.onTypeOptionUpdated, }); @override void updateTypeOption(List options) { final newTypeOption = SingleSelectTypeOptionPB()..options.addAll(options); onTypeOptionUpdated(newTypeOption.writeToBuffer()); } } SelectOptionColorPB newSelectOptionColor(List options) { final colorFrequency = List.filled(SelectOptionColorPB.values.length, 0); for (final option in options) { colorFrequency[option.color.value]++; } final minIndex = colorFrequency .asMap() .entries .reduce((a, b) => a.value <= b.value ? a : b) .key; return SelectOptionColorPB.valueOf(minIndex) ?? SelectOptionColorPB.Purple; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/translate_type_option_bloc.dart ================================================ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/translate_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; part 'translate_type_option_bloc.freezed.dart'; class TranslateTypeOptionBloc extends Bloc { TranslateTypeOptionBloc({required TranslateTypeOptionPB option}) : super(TranslateTypeOptionState.initial(option)) { on( (event, emit) async { event.when( selectLanguage: (languageType) { emit( state.copyWith( option: _updateLanguage(languageType), language: languageTypeToLanguage(languageType), ), ); }, ); }, ); } TranslateTypeOptionPB _updateLanguage(TranslateLanguagePB languageType) { state.option.freeze(); return state.option.rebuild((option) { option.language = languageType; }); } } @freezed class TranslateTypeOptionEvent with _$TranslateTypeOptionEvent { const factory TranslateTypeOptionEvent.selectLanguage( TranslateLanguagePB languageType, ) = _SelectLanguage; } @freezed class TranslateTypeOptionState with _$TranslateTypeOptionState { const factory TranslateTypeOptionState({ required TranslateTypeOptionPB option, required String language, }) = _TranslateTypeOptionState; factory TranslateTypeOptionState.initial(TranslateTypeOptionPB option) => TranslateTypeOptionState( option: option, language: languageTypeToLanguage(option.language), ); } String languageTypeToLanguage(TranslateLanguagePB langaugeType) { switch (langaugeType) { case TranslateLanguagePB.SimplifiedChinese: return 'Simplified Chinese'; case TranslateLanguagePB.TraditionalChinese: return 'Traditional Chinese'; case TranslateLanguagePB.English: return 'English'; case TranslateLanguagePB.French: return 'French'; case TranslateLanguagePB.German: return 'German'; case TranslateLanguagePB.Spanish: return 'Spanish'; case TranslateLanguagePB.Hindi: return 'Hindi'; case TranslateLanguagePB.Portuguese: return 'Portuguese'; case TranslateLanguagePB.StandardArabic: return 'Standard Arabic'; default: Log.error('Unknown language type: $langaugeType'); return 'English'; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; abstract class TypeOptionParser { T fromBuffer(List buffer); } class NumberTypeOptionDataParser extends TypeOptionParser { @override NumberTypeOptionPB fromBuffer(List buffer) { return NumberTypeOptionPB.fromBuffer(buffer); } } class DateTypeOptionDataParser extends TypeOptionParser { @override DateTypeOptionPB fromBuffer(List buffer) { return DateTypeOptionPB.fromBuffer(buffer); } } class TimestampTypeOptionDataParser extends TypeOptionParser { @override TimestampTypeOptionPB fromBuffer(List buffer) { return TimestampTypeOptionPB.fromBuffer(buffer); } } class SingleSelectTypeOptionDataParser extends TypeOptionParser { @override SingleSelectTypeOptionPB fromBuffer(List buffer) { return SingleSelectTypeOptionPB.fromBuffer(buffer); } } class MultiSelectTypeOptionDataParser extends TypeOptionParser { @override MultiSelectTypeOptionPB fromBuffer(List buffer) { return MultiSelectTypeOptionPB.fromBuffer(buffer); } } class RelationTypeOptionDataParser extends TypeOptionParser { @override RelationTypeOptionPB fromBuffer(List buffer) { return RelationTypeOptionPB.fromBuffer(buffer); } } class TranslateTypeOptionDataParser extends TypeOptionParser { @override TranslateTypeOptionPB fromBuffer(List buffer) { return TranslateTypeOptionPB.fromBuffer(buffer); } } class MediaTypeOptionDataParser extends TypeOptionParser { @override MediaTypeOptionPB fromBuffer(List buffer) { return MediaTypeOptionPB.fromBuffer(buffer); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart ================================================ import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'layout_bloc.freezed.dart'; class DatabaseLayoutBloc extends Bloc { DatabaseLayoutBloc({ required String viewId, required DatabaseLayoutPB databaseLayout, }) : super(DatabaseLayoutState.initial(viewId, databaseLayout)) { on( (event, emit) async { event.when( initial: () {}, updateLayout: (DatabaseLayoutPB layout) { DatabaseViewBackendService.updateLayout( viewId: viewId, layout: layout, ); emit(state.copyWith(databaseLayout: layout)); }, ); }, ); } } @freezed class DatabaseLayoutEvent with _$DatabaseLayoutEvent { const factory DatabaseLayoutEvent.initial() = _Initial; const factory DatabaseLayoutEvent.updateLayout(DatabaseLayoutPB layout) = _UpdateLayout; } @freezed class DatabaseLayoutState with _$DatabaseLayoutState { const factory DatabaseLayoutState({ required String viewId, required DatabaseLayoutPB databaseLayout, }) = _DatabaseLayoutState; factory DatabaseLayoutState.initial( String viewId, DatabaseLayoutPB databaseLayout, ) => DatabaseLayoutState( viewId: viewId, databaseLayout: databaseLayout, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart ================================================ import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../database_controller.dart'; import 'row_controller.dart'; part 'related_row_detail_bloc.freezed.dart'; class RelatedRowDetailPageBloc extends Bloc { RelatedRowDetailPageBloc({ required String databaseId, required String initialRowId, }) : super(const RelatedRowDetailPageState.loading()) { _dispatch(); _init(databaseId, initialRowId); } UserProfilePB? _userProfile; UserProfilePB? get userProfile => _userProfile; @override Future close() { state.whenOrNull( ready: (databaseController, rowController) async { await rowController.dispose(); await databaseController.dispose(); }, ); return super.close(); } void _dispatch() { on((event, emit) async { await event.when( didInitialize: (databaseController, rowController) async { final response = await UserEventGetUserProfile().send(); response.fold( (userProfile) => _userProfile = userProfile, (err) => Log.error(err), ); await rowController.initialize(); await state.maybeWhen( ready: (_, oldRowController) async { await oldRowController.dispose(); emit( RelatedRowDetailPageState.ready( databaseController: databaseController, rowController: rowController, ), ); }, orElse: () { emit( RelatedRowDetailPageState.ready( databaseController: databaseController, rowController: rowController, ), ); }, ); }, ); }); } void _init(String databaseId, String initialRowId) async { final viewId = await DatabaseEventGetDefaultDatabaseViewId( DatabaseIdPB(value: databaseId), ).send().fold( (pb) => pb.value, (error) => null, ); if (viewId == null) { return; } final databaseView = await ViewBackendService.getView(viewId) .fold((viewPB) => viewPB, (f) => null); if (databaseView == null) { return; } final databaseController = DatabaseController(view: databaseView); await databaseController.open().fold( (s) => databaseController.setIsLoading(false), (f) => null, ); final rowInfo = databaseController.rowCache.getRow(initialRowId); if (rowInfo == null) { return; } final rowController = RowController( rowMeta: rowInfo.rowMeta, viewId: databaseView.id, rowCache: databaseController.rowCache, ); add( RelatedRowDetailPageEvent.didInitialize( databaseController, rowController, ), ); } } @freezed class RelatedRowDetailPageEvent with _$RelatedRowDetailPageEvent { const factory RelatedRowDetailPageEvent.didInitialize( DatabaseController databaseController, RowController rowController, ) = _DidInitialize; } @freezed class RelatedRowDetailPageState with _$RelatedRowDetailPageState { const factory RelatedRowDetailPageState.loading() = _LoadingState; const factory RelatedRowDetailPageState.ready({ required DatabaseController databaseController, required RowController rowController, }) = _ReadyState; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/row/row_banner_bloc.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../domain/row_meta_listener.dart'; part 'row_banner_bloc.freezed.dart'; class RowBannerBloc extends Bloc { RowBannerBloc({ required this.viewId, required this.fieldController, required RowMetaPB rowMeta, }) : _rowBackendSvc = RowBackendService(viewId: viewId), _metaListener = RowMetaListener(rowMeta.id), super(RowBannerState.initial(rowMeta)) { _dispatch(); } final String viewId; final FieldController fieldController; final RowBackendService _rowBackendSvc; final RowMetaListener _metaListener; UserProfilePB? _userProfile; UserProfilePB? get userProfile => _userProfile; bool get hasCover => state.rowMeta.cover.data.isNotEmpty; @override Future close() async { await _metaListener.stop(); return super.close(); } void _dispatch() { on( (event, emit) { event.when( initial: () async { await _loadPrimaryField(); _listenRowMetaChanged(); final result = await UserEventGetUserProfile().send(); result.fold( (userProfile) => _userProfile = userProfile, (error) => Log.error(error), ); }, didReceiveRowMeta: (RowMetaPB rowMeta) { emit(state.copyWith(rowMeta: rowMeta)); }, setCover: (RowCoverPB cover) => _updateMeta(cover: cover), setIcon: (String iconURL) => _updateMeta(iconURL: iconURL), removeCover: () => _removeCover(), didReceiveFieldUpdate: (updatedField) { emit( state.copyWith( primaryField: updatedField, loadingState: const LoadingState.finish(), ), ); }, ); }, ); } Future _loadPrimaryField() async { final fieldOrError = await FieldBackendService.getPrimaryField(viewId: viewId); fieldOrError.fold( (primaryField) { if (!isClosed) { fieldController.addSingleFieldListener( primaryField.id, onFieldChanged: (updatedField) { if (!isClosed) { add(RowBannerEvent.didReceiveFieldUpdate(updatedField.field)); } }, ); add(RowBannerEvent.didReceiveFieldUpdate(primaryField)); } }, (r) => Log.error(r), ); } /// Listen the changes of the row meta and then update the banner void _listenRowMetaChanged() { _metaListener.start( callback: (rowMeta) { if (!isClosed) { add(RowBannerEvent.didReceiveRowMeta(rowMeta)); } }, ); } /// Update the meta of the row and the view Future _updateMeta({String? iconURL, RowCoverPB? cover}) async { final result = await _rowBackendSvc.updateMeta( iconURL: iconURL, cover: cover, rowId: state.rowMeta.id, ); result.fold((l) => null, (err) => Log.error(err)); } Future _removeCover() async { final result = await _rowBackendSvc.removeCover(state.rowMeta.id); result.fold((l) => null, (err) => Log.error(err)); } } @freezed class RowBannerEvent with _$RowBannerEvent { const factory RowBannerEvent.initial() = _Initial; const factory RowBannerEvent.didReceiveRowMeta(RowMetaPB rowMeta) = _DidReceiveRowMeta; const factory RowBannerEvent.didReceiveFieldUpdate(FieldPB field) = _DidReceiveFieldUpdate; const factory RowBannerEvent.setIcon(String iconURL) = _SetIcon; const factory RowBannerEvent.setCover(RowCoverPB cover) = _SetCover; const factory RowBannerEvent.removeCover() = _RemoveCover; } @freezed class RowBannerState extends Equatable with _$RowBannerState { const RowBannerState._(); const factory RowBannerState({ required FieldPB? primaryField, required RowMetaPB rowMeta, required LoadingState loadingState, }) = _RowBannerState; factory RowBannerState.initial(RowMetaPB rowMetaPB) => RowBannerState( primaryField: null, rowMeta: rowMetaPB, loadingState: const LoadingState.loading(), ); @override List get props => [ rowMeta.cover.data, rowMeta.icon, primaryField, loadingState, ]; } @freezed class LoadingState with _$LoadingState { const factory LoadingState.loading() = _Loading; const factory LoadingState.finish() = _Finish; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart ================================================ import 'dart:collection'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../cell/cell_cache.dart'; import '../cell/cell_controller.dart'; import 'row_list.dart'; import 'row_service.dart'; part 'row_cache.freezed.dart'; typedef RowUpdateCallback = void Function(); /// A delegate that provides the fields of the row. abstract class RowFieldsDelegate { UnmodifiableListView get fieldInfos; void onFieldsChanged(void Function(List) callback); } abstract mixin class RowLifeCycle { void onRowDisposed(); } /// Read https://docs.appflowy.io/docs/documentation/software-contributions/architecture/frontend/frontend/grid for more information. class RowCache { RowCache({ required this.viewId, required RowFieldsDelegate fieldsDelegate, required RowLifeCycle rowLifeCycle, }) : _cellMemCache = CellMemCache(), _changedNotifier = RowChangesetNotifier(), _rowLifeCycle = rowLifeCycle, _fieldDelegate = fieldsDelegate { // Listen to field changes. If a field is deleted, we can safely remove the // cells corresponding to that field from our cache. fieldsDelegate.onFieldsChanged((fieldInfos) { for (final fieldInfo in fieldInfos) { _cellMemCache.removeCellWithFieldId(fieldInfo.id); } _changedNotifier?.receive(const ChangedReason.fieldDidChange()); }); } final String viewId; final RowList _rowList = RowList(); final CellMemCache _cellMemCache; final RowLifeCycle _rowLifeCycle; final RowFieldsDelegate _fieldDelegate; RowChangesetNotifier? _changedNotifier; bool _isInitialRows = false; final List _pendingVisibilityChanges = []; /// Returns a unmodifiable list of RowInfo UnmodifiableListView get rowInfos { final visibleRows = [..._rowList.rows]; return UnmodifiableListView(visibleRows); } /// Returns a unmodifiable map of RowInfo UnmodifiableMapView get rowByRowId { return UnmodifiableMapView(_rowList.rowInfoByRowId); } CellMemCache get cellCache => _cellMemCache; ChangedReason get changeReason => _changedNotifier?.reason ?? const InitialListState(); RowInfo? getRow(RowId rowId) { return _rowList.get(rowId); } void setInitialRows(List rows) { for (final row in rows) { final rowInfo = buildGridRow(row); _rowList.add(rowInfo); } _isInitialRows = true; _changedNotifier?.receive(const ChangedReason.setInitialRows()); for (final changeset in _pendingVisibilityChanges) { applyRowsVisibility(changeset); } _pendingVisibilityChanges.clear(); } void setRowMeta(RowMetaPB rowMeta) { final rowInfo = _rowList.get(rowMeta.id); if (rowInfo != null) { rowInfo.updateRowMeta(rowMeta); } _changedNotifier?.receive(const ChangedReason.didFetchRow()); } void dispose() { _rowList.dispose(); _rowLifeCycle.onRowDisposed(); _changedNotifier?.dispose(); _changedNotifier = null; _cellMemCache.dispose(); } void applyRowsChanged(RowsChangePB changeset) { _deleteRows(changeset.deletedRows); _insertRows(changeset.insertedRows); _updateRows(changeset.updatedRows); } void applyRowsVisibility(RowsVisibilityChangePB changeset) { if (_isInitialRows) { _hideRows(changeset.invisibleRows); _showRows(changeset.visibleRows); _changedNotifier?.receive( ChangedReason.updateRowsVisibility(changeset), ); } else { _pendingVisibilityChanges.add(changeset); } } void reorderAllRows(List rowIds) { _rowList.reorderWithRowIds(rowIds); _changedNotifier?.receive(const ChangedReason.reorderRows()); } void reorderSingleRow(ReorderSingleRowPB reorderRow) { final rowInfo = _rowList.get(reorderRow.rowId); if (rowInfo != null) { _rowList.moveRow( reorderRow.rowId, reorderRow.oldIndex, reorderRow.newIndex, ); _changedNotifier?.receive( ChangedReason.reorderSingleRow( reorderRow, rowInfo, ), ); } } void _deleteRows(List deletedRowIds) { for (final rowId in deletedRowIds) { final deletedRow = _rowList.remove(rowId); if (deletedRow != null) { _changedNotifier?.receive(ChangedReason.delete(deletedRow)); } } } void _insertRows(List insertRows) { final InsertedIndexs insertedIndices = []; for (final insertedRow in insertRows) { if (insertedRow.hasIndex()) { final index = _rowList.insert( insertedRow.index, buildGridRow(insertedRow.rowMeta), ); if (index != null) { insertedIndices.add(index); } } } _changedNotifier?.receive(ChangedReason.insert(insertedIndices)); } void _updateRows(List updatedRows) { if (updatedRows.isEmpty) return; final List updatedList = []; for (final updatedRow in updatedRows) { for (final fieldId in updatedRow.fieldIds) { final key = CellContext( fieldId: fieldId, rowId: updatedRow.rowId, ); _cellMemCache.remove(key); } if (updatedRow.hasRowMeta()) { updatedList.add(updatedRow.rowMeta); } } final updatedIndexs = _rowList.updateRows( rowMetas: updatedList, builder: (rowId) => buildGridRow(rowId), ); if (updatedIndexs.isNotEmpty) { _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); } } void _hideRows(List invisibleRows) { for (final rowId in invisibleRows) { final deletedRow = _rowList.remove(rowId); if (deletedRow != null) { _changedNotifier?.receive(ChangedReason.delete(deletedRow)); } } } void _showRows(List visibleRows) { for (final insertedRow in visibleRows) { final insertedIndex = _rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta)); if (insertedIndex != null) { _changedNotifier?.receive(ChangedReason.insert([insertedIndex])); } } } void onRowsChanged(void Function(ChangedReason) onRowChanged) { _changedNotifier?.addListener(() { if (_changedNotifier != null) { onRowChanged(_changedNotifier!.reason); } }); } RowUpdateCallback addListener({ required RowId rowId, void Function(List, ChangedReason)? onRowChanged, }) { void listenerHandler() async { if (onRowChanged != null) { final rowInfo = _rowList.get(rowId); if (rowInfo != null) { final cellDataMap = _makeCells(rowInfo.rowMeta); if (_changedNotifier != null) { onRowChanged(cellDataMap, _changedNotifier!.reason); } } } } _changedNotifier?.addListener(listenerHandler); return listenerHandler; } void removeRowListener(VoidCallback callback) { _changedNotifier?.removeListener(callback); } List loadCells(RowMetaPB rowMeta) { final rowInfo = _rowList.get(rowMeta.id); if (rowInfo == null) { _loadRow(rowMeta.id); } final cells = _makeCells(rowMeta); return cells; } Future _loadRow(RowId rowId) async { final result = await RowBackendService.getRow(viewId: viewId, rowId: rowId); result.fold( (rowMetaPB) { final rowInfo = _rowList.get(rowMetaPB.id); final rowIndex = _rowList.indexOfRow(rowMetaPB.id); if (rowInfo != null && rowIndex != null) { rowInfo.rowMetaNotifier.value = rowMetaPB; final UpdatedIndexMap updatedIndexs = UpdatedIndexMap(); updatedIndexs[rowMetaPB.id] = UpdatedIndex( index: rowIndex, rowId: rowMetaPB.id, ); _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); } }, (err) => Log.error(err), ); } List _makeCells(RowMetaPB rowMeta) { return _fieldDelegate.fieldInfos .map( (fieldInfo) => CellContext( rowId: rowMeta.id, fieldId: fieldInfo.id, ), ) .toList(); } RowInfo buildGridRow(RowMetaPB rowMetaPB) { return RowInfo( fields: _fieldDelegate.fieldInfos, rowMeta: rowMetaPB, ); } } class RowChangesetNotifier extends ChangeNotifier { RowChangesetNotifier(); ChangedReason reason = const InitialListState(); void receive(ChangedReason newReason) { reason = newReason; reason.map( insert: (_) => notifyListeners(), delete: (_) => notifyListeners(), update: (_) => notifyListeners(), fieldDidChange: (_) => notifyListeners(), initial: (_) {}, reorderRows: (_) => notifyListeners(), reorderSingleRow: (_) => notifyListeners(), updateRowsVisibility: (_) => notifyListeners(), setInitialRows: (_) => notifyListeners(), didFetchRow: (_) => notifyListeners(), ); } } class RowInfo extends Equatable { RowInfo({ required this.fields, required RowMetaPB rowMeta, }) : rowMetaNotifier = ValueNotifier(rowMeta), rowIconNotifier = ValueNotifier(rowMeta.icon), rowDocumentNotifier = ValueNotifier( !(rowMeta.hasIsDocumentEmpty() ? rowMeta.isDocumentEmpty : true), ); final UnmodifiableListView fields; final ValueNotifier rowMetaNotifier; final ValueNotifier rowIconNotifier; final ValueNotifier rowDocumentNotifier; String get rowId => rowMetaNotifier.value.id; RowMetaPB get rowMeta => rowMetaNotifier.value; /// Updates the RowMeta and automatically updates the related notifiers. void updateRowMeta(RowMetaPB newMeta) { rowMetaNotifier.value = newMeta; rowIconNotifier.value = newMeta.icon; rowDocumentNotifier.value = !newMeta.isDocumentEmpty; } /// Dispose of the notifiers when they are no longer needed. void dispose() { rowMetaNotifier.dispose(); rowIconNotifier.dispose(); rowDocumentNotifier.dispose(); } @override List get props => [rowMeta]; } typedef InsertedIndexs = List; typedef DeletedIndexs = List; // key: id of the row // value: UpdatedIndex typedef UpdatedIndexMap = LinkedHashMap; @freezed class ChangedReason with _$ChangedReason { const factory ChangedReason.insert(InsertedIndexs items) = _Insert; const factory ChangedReason.delete(DeletedIndex item) = _Delete; const factory ChangedReason.update(UpdatedIndexMap indexs) = _Update; const factory ChangedReason.fieldDidChange() = _FieldDidChange; const factory ChangedReason.initial() = InitialListState; const factory ChangedReason.didFetchRow() = _DidFetchRow; const factory ChangedReason.reorderRows() = _ReorderRows; const factory ChangedReason.reorderSingleRow( ReorderSingleRowPB reorderRow, RowInfo rowInfo, ) = _ReorderSingleRow; const factory ChangedReason.updateRowsVisibility( RowsVisibilityChangePB changeset, ) = _UpdateRowsVisibility; const factory ChangedReason.setInitialRows() = _SetInitialRows; } class InsertedIndex { InsertedIndex({ required this.index, required this.rowId, }); final int index; final RowId rowId; } class DeletedIndex { DeletedIndex({ required this.index, required this.rowInfo, }); final int index; final RowInfo rowInfo; } class UpdatedIndex { UpdatedIndex({ required this.index, required this.rowId, }); final int index; final RowId rowId; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/domain/row_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import '../cell/cell_cache.dart'; import '../cell/cell_controller.dart'; import 'row_cache.dart'; typedef OnRowChanged = void Function(List, ChangedReason); class RowController { RowController({ required RowMetaPB rowMeta, required this.viewId, required RowCache rowCache, this.groupId, }) : _rowMeta = rowMeta, _rowCache = rowCache, _rowBackendSvc = RowBackendService(viewId: viewId), _rowListener = RowListener(rowMeta.id); RowMetaPB _rowMeta; final String? groupId; VoidCallback? _onRowMetaChanged; final String viewId; final List _onRowChangedListeners = []; final RowCache _rowCache; final RowListener _rowListener; final RowBackendService _rowBackendSvc; bool _isDisposed = false; String get rowId => _rowMeta.id; RowMetaPB get rowMeta => _rowMeta; CellMemCache get cellCache => _rowCache.cellCache; List loadCells() => _rowCache.loadCells(rowMeta); /// This method must be called to initialize the row controller; otherwise, the row will not sync between devices. /// When creating a row controller, calling [initialize] immediately may not be necessary. /// Only call [initialize] when the row becomes visible. This approach helps reduce unnecessary sync operations. Future initialize() async { await _rowBackendSvc.initRow(rowMeta.id); unawaited( _rowBackendSvc.getRowMeta(rowId).then( (result) { if (_isDisposed) { return; } result.fold( (rowMeta) { _rowMeta = rowMeta; _rowCache.setRowMeta(rowMeta); _onRowMetaChanged?.call(); }, (error) => debugPrint(error.toString()), ); }, ), ); _rowListener.start( onRowFetched: (DidFetchRowPB row) { _rowCache.setRowMeta(row.meta); }, onMetaChanged: (newRowMeta) { if (_isDisposed) { return; } _rowMeta = newRowMeta; _rowCache.setRowMeta(newRowMeta); _onRowMetaChanged?.call(); }, ); } void addListener({ OnRowChanged? onRowChanged, VoidCallback? onMetaChanged, }) { final fn = _rowCache.addListener( rowId: rowMeta.id, onRowChanged: (context, reasons) { if (_isDisposed) { return; } onRowChanged?.call(context, reasons); }, ); // Add the listener to the list so that we can remove it later. _onRowChangedListeners.add(fn); _onRowMetaChanged = onMetaChanged; } Future dispose() async { _isDisposed = true; await _rowListener.stop(); for (final fn in _onRowChangedListeners) { _rowCache.removeRowListener(fn); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart ================================================ import 'dart:collection'; import 'dart:math'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'row_cache.dart'; import 'row_service.dart'; class RowList { /// Use List to reverse the order of the row. List _rowInfos = []; List get rows => List.from(_rowInfos); /// Use Map for faster access the raw row data. final HashMap rowInfoByRowId = HashMap(); RowInfo? get(RowId rowId) { return rowInfoByRowId[rowId]; } int? indexOfRow(RowId rowId) { final rowInfo = rowInfoByRowId[rowId]; if (rowInfo != null) { return _rowInfos.indexOf(rowInfo); } return null; } void add(RowInfo rowInfo) { final rowId = rowInfo.rowId; if (contains(rowId)) { final index = _rowInfos.indexWhere((element) => element.rowId == rowId); _rowInfos.removeAt(index); _rowInfos.insert(index, rowInfo); } else { _rowInfos.add(rowInfo); } rowInfoByRowId[rowId] = rowInfo; } InsertedIndex? insert(int index, RowInfo rowInfo) { final rowId = rowInfo.rowId; var insertedIndex = index; if (_rowInfos.length <= insertedIndex) { insertedIndex = _rowInfos.length; } final oldRowInfo = get(rowId); if (oldRowInfo != null) { _rowInfos.insert(insertedIndex, rowInfo); _rowInfos.remove(oldRowInfo); rowInfoByRowId[rowId] = rowInfo; return null; } else { _rowInfos.insert(insertedIndex, rowInfo); rowInfoByRowId[rowId] = rowInfo; return InsertedIndex(index: insertedIndex, rowId: rowId); } } DeletedIndex? remove(RowId rowId) { final rowInfo = rowInfoByRowId[rowId]; if (rowInfo != null) { final index = _rowInfos.indexOf(rowInfo); if (index != -1) { rowInfoByRowId.remove(rowInfo.rowId); _rowInfos.remove(rowInfo); } return DeletedIndex(index: index, rowInfo: rowInfo); } else { return null; } } InsertedIndexs insertRows( List insertedRows, RowInfo Function(RowMetaPB) builder, ) { final InsertedIndexs insertIndexs = []; for (final insertRow in insertedRows) { final isContains = contains(insertRow.rowMeta.id); var index = insertRow.index; if (_rowInfos.length < index) { index = _rowInfos.length; } insert(index, builder(insertRow.rowMeta)); if (!isContains) { insertIndexs.add( InsertedIndex( index: index, rowId: insertRow.rowMeta.id, ), ); } } return insertIndexs; } DeletedIndexs removeRows(List rowIds) { final List newRows = []; final DeletedIndexs deletedIndex = []; final Map deletedRowByRowId = { for (final rowId in rowIds) rowId: rowId, }; _rowInfos.asMap().forEach((index, RowInfo rowInfo) { if (deletedRowByRowId[rowInfo.rowId] == null) { newRows.add(rowInfo); } else { rowInfoByRowId.remove(rowInfo.rowId); deletedIndex.add(DeletedIndex(index: index, rowInfo: rowInfo)); } }); _rowInfos = newRows; return deletedIndex; } UpdatedIndexMap updateRows({ required List rowMetas, required RowInfo Function(RowMetaPB) builder, }) { final UpdatedIndexMap updatedIndexs = UpdatedIndexMap(); for (final rowMeta in rowMetas) { final index = _rowInfos.indexWhere( (rowInfo) => rowInfo.rowId == rowMeta.id, ); if (index != -1) { rowInfoByRowId[rowMeta.id]?.updateRowMeta(rowMeta); } else { final insertIndex = max(index, _rowInfos.length); final rowInfo = builder(rowMeta); insert(insertIndex, rowInfo); updatedIndexs[rowMeta.id] = UpdatedIndex( index: insertIndex, rowId: rowMeta.id, ); } } return updatedIndexs; } void reorderWithRowIds(List rowIds) { _rowInfos.clear(); for (final rowId in rowIds) { final rowInfo = rowInfoByRowId[rowId]; if (rowInfo != null) { _rowInfos.add(rowInfo); } } } void moveRow(RowId rowId, int oldIndex, int newIndex) { final index = _rowInfos.indexWhere( (rowInfo) => rowInfo.rowId == rowId, ); if (index != -1) { final rowInfo = remove(rowId)!.rowInfo; insert(newIndex, rowInfo); } } bool contains(RowId rowId) { return rowInfoByRowId[rowId] != null; } void dispose() { for (final rowInfo in _rowInfos) { rowInfo.dispose(); } _rowInfos.clear(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import '../field/field_info.dart'; typedef RowId = String; class RowBackendService { RowBackendService({required this.viewId}); final String viewId; static Future> createRow({ required String viewId, String? groupId, void Function(RowDataBuilder builder)? withCells, OrderObjectPositionTypePB? position, String? targetRowId, }) { final payload = CreateRowPayloadPB( viewId: viewId, groupId: groupId, rowPosition: OrderObjectPositionPB( position: position, objectId: targetRowId, ), ); if (withCells != null) { final rowBuilder = RowDataBuilder(); withCells(rowBuilder); payload.data.addAll(rowBuilder.build()); } return DatabaseEventCreateRow(payload).send(); } Future> initRow(RowId rowId) async { final payload = DatabaseViewRowIdPB() ..viewId = viewId ..rowId = rowId; return DatabaseEventInitRow(payload).send(); } Future> createRowBefore(RowId rowId) { return createRow( viewId: viewId, position: OrderObjectPositionTypePB.Before, targetRowId: rowId, ); } Future> createRowAfter(RowId rowId) { return createRow( viewId: viewId, position: OrderObjectPositionTypePB.After, targetRowId: rowId, ); } static Future> getRow({ required String viewId, required String rowId, }) { final payload = DatabaseViewRowIdPB() ..viewId = viewId ..rowId = rowId; return DatabaseEventGetRowMeta(payload).send(); } Future> getRowMeta(RowId rowId) { final payload = DatabaseViewRowIdPB.create() ..viewId = viewId ..rowId = rowId; return DatabaseEventGetRowMeta(payload).send(); } Future> updateMeta({ required String rowId, String? iconURL, RowCoverPB? cover, bool? isDocumentEmpty, }) { final payload = UpdateRowMetaChangesetPB.create() ..viewId = viewId ..id = rowId; if (iconURL != null) { payload.iconUrl = iconURL; } if (cover != null) { payload.cover = cover; } if (isDocumentEmpty != null) { payload.isDocumentEmpty = isDocumentEmpty; } return DatabaseEventUpdateRowMeta(payload).send(); } Future> removeCover(String rowId) async { final payload = RemoveCoverPayloadPB.create() ..viewId = viewId ..rowId = rowId; return DatabaseEventRemoveCover(payload).send(); } static Future> deleteRows( String viewId, List rowIds, ) { final payload = RepeatedRowIdPB.create() ..viewId = viewId ..rowIds.addAll(rowIds); return DatabaseEventDeleteRows(payload).send(); } static Future> duplicateRow( String viewId, RowId rowId, ) { final payload = DatabaseViewRowIdPB( viewId: viewId, rowId: rowId, ); return DatabaseEventDuplicateRow(payload).send(); } } class RowDataBuilder { final _cellDataByFieldId = {}; void insertText(FieldInfo fieldInfo, String text) { assert(fieldInfo.fieldType == FieldType.RichText); _cellDataByFieldId[fieldInfo.field.id] = text; } void insertNumber(FieldInfo fieldInfo, int num) { assert(fieldInfo.fieldType == FieldType.Number); _cellDataByFieldId[fieldInfo.field.id] = num.toString(); } void insertDate(FieldInfo fieldInfo, DateTime date) { assert(fieldInfo.fieldType == FieldType.DateTime); final timestamp = date.millisecondsSinceEpoch ~/ 1000; _cellDataByFieldId[fieldInfo.field.id] = timestamp.toString(); } Map build() { return _cellDataByFieldId; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/domain/group_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/board_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'group_bloc.freezed.dart'; class DatabaseGroupBloc extends Bloc { DatabaseGroupBloc({ required String viewId, required DatabaseController databaseController, }) : _databaseController = databaseController, _groupBackendSvc = GroupBackendService(viewId), super( DatabaseGroupState.initial( viewId, databaseController.fieldController.fieldInfos, databaseController.databaseLayoutSetting!.board, databaseController.fieldController.groupSettings, ), ) { _dispatch(); } final DatabaseController _databaseController; final GroupBackendService _groupBackendSvc; Function(List)? _onFieldsFn; DatabaseLayoutSettingCallbacks? _layoutSettingCallbacks; @override Future close() async { if (_onFieldsFn != null) { _databaseController.fieldController .removeListener(onFieldsListener: _onFieldsFn!); _onFieldsFn = null; } _layoutSettingCallbacks = null; return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async => _startListening(), didReceiveFieldUpdate: (fieldInfos) { emit( state.copyWith( fieldInfos: fieldInfos, groupSettings: _databaseController.fieldController.groupSettings, ), ); }, setGroupByField: ( String fieldId, FieldType fieldType, [ List? settingContent, ]) async { final result = await _groupBackendSvc.groupByField( fieldId: fieldId, settingContent: settingContent ?? [], ); result.fold((l) => null, (err) => Log.error(err)); }, didUpdateLayoutSettings: (layoutSettings) { emit(state.copyWith(layoutSettings: layoutSettings)); }, ); }, ); } void _startListening() { _onFieldsFn = (fieldInfos) => add(DatabaseGroupEvent.didReceiveFieldUpdate(fieldInfos)); _databaseController.fieldController.addListener( onReceiveFields: _onFieldsFn, listenWhen: () => !isClosed, ); _layoutSettingCallbacks = DatabaseLayoutSettingCallbacks( onLayoutSettingsChanged: (layoutSettings) { if (isClosed || !layoutSettings.hasBoard()) { return; } add( DatabaseGroupEvent.didUpdateLayoutSettings(layoutSettings.board), ); }, ); _databaseController.addListener( onLayoutSettingsChanged: _layoutSettingCallbacks, ); } } @freezed class DatabaseGroupEvent with _$DatabaseGroupEvent { const factory DatabaseGroupEvent.initial() = _Initial; const factory DatabaseGroupEvent.setGroupByField( String fieldId, FieldType fieldType, [ @Default([]) List settingContent, ]) = _DatabaseGroupEvent; const factory DatabaseGroupEvent.didReceiveFieldUpdate( List fields, ) = _DidReceiveFieldUpdate; const factory DatabaseGroupEvent.didUpdateLayoutSettings( BoardLayoutSettingPB layoutSettings, ) = _DidUpdateLayoutSettings; } @freezed class DatabaseGroupState with _$DatabaseGroupState { const factory DatabaseGroupState({ required String viewId, required List fieldInfos, required BoardLayoutSettingPB layoutSettings, required List groupSettings, }) = _DatabaseGroupState; factory DatabaseGroupState.initial( String viewId, List fieldInfos, BoardLayoutSettingPB layoutSettings, List groupSettings, ) => DatabaseGroupState( viewId: viewId, fieldInfos: fieldInfos, layoutSettings: layoutSettings, groupSettings: groupSettings, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/setting/property_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'property_bloc.freezed.dart'; class DatabasePropertyBloc extends Bloc { DatabasePropertyBloc({ required String viewId, required FieldController fieldController, }) : _fieldController = fieldController, super( DatabasePropertyState.initial( viewId, fieldController.fieldInfos, ), ) { _dispatch(); } final FieldController _fieldController; Function(List)? _onFieldsFn; @override Future close() async { if (_onFieldsFn != null) { _fieldController.removeListener(onFieldsListener: _onFieldsFn!); _onFieldsFn = null; } return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () { _startListening(); }, setFieldVisibility: (fieldId, visibility) async { final fieldSettingsSvc = FieldSettingsBackendService(viewId: state.viewId); final result = await fieldSettingsSvc.updateFieldSettings( fieldId: fieldId, fieldVisibility: visibility, ); result.fold((l) => null, (err) => Log.error(err)); }, didReceiveFieldUpdate: (fields) { emit(state.copyWith(fieldContexts: fields)); }, moveField: (fromIndex, toIndex) async { if (fromIndex < toIndex) { toIndex--; } final fromId = state.fieldContexts[fromIndex].field.id; final toId = state.fieldContexts[toIndex].field.id; final fieldContexts = List.from(state.fieldContexts); fieldContexts.insert(toIndex, fieldContexts.removeAt(fromIndex)); emit(state.copyWith(fieldContexts: fieldContexts)); final result = await FieldBackendService.moveField( viewId: state.viewId, fromFieldId: fromId, toFieldId: toId, ); result.fold((l) => null, (r) => Log.error(r)); }, ); }, ); } void _startListening() { _onFieldsFn = (fields) => add(DatabasePropertyEvent.didReceiveFieldUpdate(fields)); _fieldController.addListener( onReceiveFields: _onFieldsFn, listenWhen: () => !isClosed, ); } } @freezed class DatabasePropertyEvent with _$DatabasePropertyEvent { const factory DatabasePropertyEvent.initial() = _Initial; const factory DatabasePropertyEvent.setFieldVisibility( String fieldId, FieldVisibility visibility, ) = _SetFieldVisibility; const factory DatabasePropertyEvent.didReceiveFieldUpdate( List fields, ) = _DidReceiveFieldUpdate; const factory DatabasePropertyEvent.moveField(int fromIndex, int toIndex) = _MoveField; } @freezed class DatabasePropertyState with _$DatabasePropertyState { const factory DatabasePropertyState({ required String viewId, required List fieldContexts, }) = _GridPropertyState; factory DatabasePropertyState.initial( String viewId, List fieldContexts, ) => DatabasePropertyState( viewId: viewId, fieldContexts: fieldContexts, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_listener.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef UpdateSettingNotifiedValue = FlowyResult; class DatabaseSettingListener { DatabaseSettingListener({required this.viewId}); final String viewId; DatabaseNotificationListener? _listener; PublishNotifier? _updateSettingNotifier = PublishNotifier(); void start({ required void Function(UpdateSettingNotifiedValue) onSettingUpdated, }) { _updateSettingNotifier?.addPublishListener(onSettingUpdated); _listener = DatabaseNotificationListener(objectId: viewId, handler: _handler); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateSettings: result.fold( (payload) => _updateSettingNotifier?.value = FlowyResult.success( DatabaseViewSettingPB.fromBuffer(payload), ), (error) => _updateSettingNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _updateSettingNotifier?.dispose(); _updateSettingNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class SettingBackendService { const SettingBackendService({required this.viewId}); final String viewId; Future> getSetting() { final payload = DatabaseViewIdPB.create()..value = viewId; return DatabaseEventGetDatabaseSetting(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/share_bloc.dart ================================================ import 'dart:io'; import 'package:appflowy/workspace/application/settings/share/export_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'share_bloc.freezed.dart'; class DatabaseShareBloc extends Bloc { DatabaseShareBloc({ required this.view, }) : super(const DatabaseShareState.initial()) { on(_onShareCSV); } final ViewPB view; Future _onShareCSV( ShareCSV event, Emitter emit, ) async { emit(const DatabaseShareState.loading()); final result = await BackendExportService.exportDatabaseAsCSV(view.id); result.fold( (l) => _saveCSVToPath(l.data, event.path), (r) => Log.error(r), ); emit( DatabaseShareState.finish( result.fold( (l) { _saveCSVToPath(l.data, event.path); return FlowyResult.success(null); }, (r) => FlowyResult.failure(r), ), ), ); } ExportDataPB _saveCSVToPath(String markdown, String path) { File(path).writeAsStringSync(markdown); return ExportDataPB() ..data = markdown ..exportType = ExportType.Markdown; } } @freezed class DatabaseShareEvent with _$DatabaseShareEvent { const factory DatabaseShareEvent.shareCSV(String path) = ShareCSV; } @freezed class DatabaseShareState with _$DatabaseShareState { const factory DatabaseShareState.initial() = _Initial; const factory DatabaseShareState.loading() = _Loading; const factory DatabaseShareState.finish( FlowyResult successOrFail, ) = _Finish; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/sync/database_sync_state_listener.dart'; import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'database_sync_bloc.freezed.dart'; class DatabaseSyncBloc extends Bloc { DatabaseSyncBloc({ required this.view, }) : super(DatabaseSyncBlocState.initial()) { on( (event, emit) async { await event.when( initial: () async { final userProfile = await getIt().getUser().then( (value) => value.fold((s) => s, (f) => null), ); final databaseId = await DatabaseViewBackendService(viewId: view.id) .getDatabaseId() .then((value) => value.fold((s) => s, (f) => null)); emit( state.copyWith( shouldShowIndicator: userProfile?.workspaceType == WorkspaceTypePB.ServerW && databaseId != null, ), ); if (databaseId != null) { _syncStateListener = DatabaseSyncStateListener(databaseId: databaseId) ..start( didReceiveSyncState: (syncState) { Log.info( 'database sync state changed, from ${state.syncState} to $syncState', ); add(DatabaseSyncEvent.syncStateChanged(syncState)); }, ); } final isNetworkConnected = await _connectivity .checkConnectivity() .then((value) => value != ConnectivityResult.none); emit(state.copyWith(isNetworkConnected: isNetworkConnected)); connectivityStream = _connectivity.onConnectivityChanged.listen((result) { add(DatabaseSyncEvent.networkStateChanged(result)); }); }, syncStateChanged: (syncState) { emit(state.copyWith(syncState: syncState.value)); }, networkStateChanged: (result) { emit( state.copyWith( isNetworkConnected: result != ConnectivityResult.none, ), ); }, ); }, ); } final ViewPB view; final _connectivity = Connectivity(); StreamSubscription? connectivityStream; DatabaseSyncStateListener? _syncStateListener; @override Future close() async { await connectivityStream?.cancel(); await _syncStateListener?.stop(); return super.close(); } } @freezed class DatabaseSyncEvent with _$DatabaseSyncEvent { const factory DatabaseSyncEvent.initial() = Initial; const factory DatabaseSyncEvent.syncStateChanged( DatabaseSyncStatePB syncState, ) = syncStateChanged; const factory DatabaseSyncEvent.networkStateChanged( ConnectivityResult result, ) = NetworkStateChanged; } @freezed class DatabaseSyncBlocState with _$DatabaseSyncBlocState { const factory DatabaseSyncBlocState({ required DatabaseSyncState syncState, @Default(true) bool isNetworkConnected, @Default(false) bool shouldShowIndicator, }) = _DatabaseSyncState; factory DatabaseSyncBlocState.initial() => const DatabaseSyncBlocState( syncState: DatabaseSyncState.Syncing, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_state_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef DatabaseSyncStateCallback = void Function( DatabaseSyncStatePB syncState, ); class DatabaseSyncStateListener { DatabaseSyncStateListener({ // NOTE: NOT the view id. required this.databaseId, }); final String databaseId; StreamSubscription? _subscription; DatabaseNotificationParser? _parser; DatabaseSyncStateCallback? didReceiveSyncState; void start({ DatabaseSyncStateCallback? didReceiveSyncState, }) { this.didReceiveSyncState = didReceiveSyncState; _parser = DatabaseNotificationParser( id: databaseId, callback: _callback, ); _subscription = RustStreamReceiver.listen( (observable) => _parser?.parse(observable), ); } void _callback( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateDatabaseSyncUpdate: result.map( (r) { final value = DatabaseSyncStatePB.fromBuffer(r); didReceiveSyncState?.call(value); }, ); break; default: break; } } Future stop() async { await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart ================================================ import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:universal_platform/universal_platform.dart'; import 'database_controller.dart'; part 'tab_bar_bloc.freezed.dart'; class DatabaseTabBarBloc extends Bloc { DatabaseTabBarBloc({ required ViewPB view, required String compactModeId, required bool enableCompactMode, }) : super( DatabaseTabBarState.initial( view, compactModeId, enableCompactMode, ), ) { on( (event, emit) async { await event.when( initial: () { _listenInlineViewChanged(); _loadChildView(); }, didLoadChildViews: (List childViews) { emit( state.copyWith( tabBars: [ ...state.tabBars, ...childViews.map( (newChildView) => DatabaseTabBar(view: newChildView), ), ], tabBarControllerByViewId: _extendsTabBarController(childViews), ), ); }, selectView: (String viewId) { final index = state.tabBars.indexWhere((element) => element.viewId == viewId); if (index != -1) { emit( state.copyWith(selectedIndex: index), ); } }, createView: (layout, name) { _createLinkedView(layout.layoutType, name ?? layout.layoutName); }, deleteView: (String viewId) async { final result = await ViewBackendService.deleteView(viewId: viewId); result.fold( (l) {}, (r) => Log.error(r), ); }, renameView: (String viewId, String newName) { ViewBackendService.updateView(viewId: viewId, name: newName); }, didUpdateChildViews: (updatePB) async { if (updatePB.createChildViews.isNotEmpty) { final allTabBars = [ ...state.tabBars, ...updatePB.createChildViews .map((e) => DatabaseTabBar(view: e)), ]; emit( state.copyWith( tabBars: allTabBars, selectedIndex: state.tabBars.length, tabBarControllerByViewId: _extendsTabBarController(updatePB.createChildViews), ), ); } if (updatePB.deleteChildViews.isNotEmpty) { final allTabBars = [...state.tabBars]; final tabBarControllerByViewId = { ...state.tabBarControllerByViewId, }; var newSelectedIndex = state.selectedIndex; for (final viewId in updatePB.deleteChildViews) { final index = allTabBars.indexWhere( (element) => element.viewId == viewId, ); if (index != -1) { final tabBar = allTabBars.removeAt(index); // Dispose the controller when the tab is removed. final controller = tabBarControllerByViewId.remove(tabBar.viewId); await controller?.dispose(); } if (index == state.selectedIndex) { if (index > 0 && allTabBars.isNotEmpty) { newSelectedIndex = index - 1; } } } emit( state.copyWith( tabBars: allTabBars, selectedIndex: newSelectedIndex, tabBarControllerByViewId: tabBarControllerByViewId, ), ); } }, viewDidUpdate: (ViewPB updatedView) { final index = state.tabBars.indexWhere( (element) => element.viewId == updatedView.id, ); if (index != -1) { final allTabBars = [...state.tabBars]; final updatedTabBar = DatabaseTabBar(view: updatedView); allTabBars[index] = updatedTabBar; emit(state.copyWith(tabBars: allTabBars)); } }, ); }, ); } @override Future close() async { for (final tabBar in state.tabBars) { await state.tabBarControllerByViewId[tabBar.viewId]?.dispose(); tabBar.dispose(); } return super.close(); } void _listenInlineViewChanged() { final controller = state.tabBarControllerByViewId[state.parentView.id]; controller?.onViewUpdated = (newView) { add(DatabaseTabBarEvent.viewDidUpdate(newView)); }; // Only listen the child view changes when the parent view is inline. controller?.onViewChildViewChanged = (update) { add(DatabaseTabBarEvent.didUpdateChildViews(update)); }; } /// Create tab bar controllers for the new views and return the updated map. Map _extendsTabBarController( List newViews, ) { final tabBarControllerByViewId = {...state.tabBarControllerByViewId}; for (final view in newViews) { final controller = DatabaseTabBarController( view: view, compactModeId: state.compactModeId, enableCompactMode: state.enableCompactMode, )..onViewUpdated = (newView) { add(DatabaseTabBarEvent.viewDidUpdate(newView)); }; tabBarControllerByViewId[view.id] = controller; } return tabBarControllerByViewId; } Future _createLinkedView(ViewLayoutPB layoutType, String name) async { final viewId = state.parentView.id; final databaseIdOrError = await DatabaseViewBackendService(viewId: viewId).getDatabaseId(); databaseIdOrError.fold( (databaseId) async { final linkedViewOrError = await ViewBackendService.createDatabaseLinkedView( parentViewId: viewId, databaseId: databaseId, layoutType: layoutType, name: name, ); linkedViewOrError.fold( (linkedView) {}, (err) => Log.error(err), ); }, (r) => Log.error(r), ); } void _loadChildView() async { final viewsOrFail = await ViewBackendService.getChildViews(viewId: state.parentView.id); viewsOrFail.fold( (views) { if (!isClosed) { add(DatabaseTabBarEvent.didLoadChildViews(views)); } }, (err) => Log.error(err), ); } } @freezed class DatabaseTabBarEvent with _$DatabaseTabBarEvent { const factory DatabaseTabBarEvent.initial() = _Initial; const factory DatabaseTabBarEvent.didLoadChildViews( List childViews, ) = _DidLoadChildViews; const factory DatabaseTabBarEvent.selectView(String viewId) = _DidSelectView; const factory DatabaseTabBarEvent.createView( DatabaseLayoutPB layout, String? name, ) = _CreateView; const factory DatabaseTabBarEvent.renameView(String viewId, String newName) = _RenameView; const factory DatabaseTabBarEvent.deleteView(String viewId) = _DeleteView; const factory DatabaseTabBarEvent.didUpdateChildViews( ChildViewUpdatePB updatePB, ) = _DidUpdateChildViews; const factory DatabaseTabBarEvent.viewDidUpdate(ViewPB view) = _ViewDidUpdate; } @freezed class DatabaseTabBarState with _$DatabaseTabBarState { const factory DatabaseTabBarState({ required ViewPB parentView, required int selectedIndex, required String compactModeId, required bool enableCompactMode, required List tabBars, required Map tabBarControllerByViewId, }) = _DatabaseTabBarState; factory DatabaseTabBarState.initial( ViewPB view, String compactModeId, bool enableCompactMode, ) { final tabBar = DatabaseTabBar(view: view); return DatabaseTabBarState( parentView: view, selectedIndex: 0, compactModeId: compactModeId, enableCompactMode: enableCompactMode, tabBars: [tabBar], tabBarControllerByViewId: { view.id: DatabaseTabBarController( view: view, compactModeId: compactModeId, enableCompactMode: enableCompactMode, ), }, ); } } class DatabaseTabBar extends Equatable { DatabaseTabBar({ required this.view, }) : _builder = UniversalPlatform.isMobile ? view.mobileTabBarItem() : view.tabBarItem(); final ViewPB view; final DatabaseTabBarItemBuilder _builder; String get viewId => view.id; DatabaseTabBarItemBuilder get builder => _builder; ViewLayoutPB get layout => view.layout; @override List get props => [view.hashCode]; void dispose() { _builder.dispose(); } } typedef OnViewUpdated = void Function(ViewPB newView); typedef OnViewChildViewChanged = void Function( ChildViewUpdatePB childViewUpdate, ); class DatabaseTabBarController { DatabaseTabBarController({ required this.view, required String compactModeId, required bool enableCompactMode, }) : controller = DatabaseController(view: view) ..initCompactMode(enableCompactMode) ..addListener( onCompactModeChanged: (v) async { compactModeEventBus .fire(CompactModeEvent(id: compactModeId, enable: v)); }, ), viewListener = ViewListener(viewId: view.id) { viewListener.start( onViewChildViewsUpdated: (update) => onViewChildViewChanged?.call(update), onViewUpdated: (newView) { view = newView; onViewUpdated?.call(newView); }, ); } ViewPB view; final DatabaseController controller; final ViewListener viewListener; OnViewUpdated? onViewUpdated; OnViewChildViewChanged? onViewChildViewChanged; Future dispose() async { await Future.wait([viewListener.stop(), controller.dispose()]); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart ================================================ import 'dart:async'; import 'dart:collection'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/log.dart'; import '../defines.dart'; import '../field/field_controller.dart'; import '../row/row_cache.dart'; import 'view_listener.dart'; class DatabaseViewCallbacks { const DatabaseViewCallbacks({ this.onNumOfRowsChanged, this.onRowsCreated, this.onRowsUpdated, this.onRowsDeleted, }); /// Will get called when number of rows were changed that includes /// update/delete/insert rows. The [onNumOfRowsChanged] will return all /// the rows of the current database final OnNumOfRowsChanged? onNumOfRowsChanged; // Will get called when creating new rows final OnRowsCreated? onRowsCreated; /// Will get called when rows were updated final OnRowsUpdated? onRowsUpdated; /// Will get called when number of rows were deleted final OnRowsDeleted? onRowsDeleted; } /// Read https://docs.appflowy.io/docs/documentation/software-contributions/architecture/frontend/frontend/grid for more information class DatabaseViewCache { DatabaseViewCache({ required this.viewId, required FieldController fieldController, }) : _databaseViewListener = DatabaseViewListener(viewId: viewId) { final depsImpl = RowCacheDependenciesImpl(fieldController); _rowCache = RowCache( viewId: viewId, fieldsDelegate: depsImpl, rowLifeCycle: depsImpl, ); _databaseViewListener.start( onRowsChanged: (result) => result.fold( (changeset) { // Update the cache _rowCache.applyRowsChanged(changeset); if (changeset.deletedRows.isNotEmpty) { for (final callback in _callbacks) { callback.onRowsDeleted?.call(changeset.deletedRows); } } if (changeset.updatedRows.isNotEmpty) { for (final callback in _callbacks) { callback.onRowsUpdated?.call( changeset.updatedRows.map((e) => e.rowId).toList(), _rowCache.changeReason, ); } } if (changeset.insertedRows.isNotEmpty) { for (final callback in _callbacks) { callback.onRowsCreated?.call(changeset.insertedRows); } } }, (err) => Log.error(err), ), onRowsVisibilityChanged: (result) => result.fold( (changeset) => _rowCache.applyRowsVisibility(changeset), (err) => Log.error(err), ), onReorderAllRows: (result) => result.fold( (rowIds) => _rowCache.reorderAllRows(rowIds), (err) => Log.error(err), ), onReorderSingleRow: (result) => result.fold( (reorderRow) => _rowCache.reorderSingleRow(reorderRow), (err) => Log.error(err), ), ); _rowCache.onRowsChanged( (reason) { for (final callback in _callbacks) { callback.onNumOfRowsChanged ?.call(rowInfos, _rowCache.rowByRowId, reason); } }, ); } final String viewId; late RowCache _rowCache; final DatabaseViewListener _databaseViewListener; final List _callbacks = []; UnmodifiableListView get rowInfos => _rowCache.rowInfos; RowCache get rowCache => _rowCache; RowInfo? getRow(RowId rowId) => _rowCache.getRow(rowId); Future dispose() async { await _databaseViewListener.stop(); _rowCache.dispose(); _callbacks.clear(); } void addListener(DatabaseViewCallbacks callbacks) { _callbacks.add(callbacks); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/view_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef RowsVisibilityCallback = void Function( FlowyResult, ); typedef NumberOfRowsCallback = void Function( FlowyResult, ); typedef ReorderAllRowsCallback = void Function( FlowyResult, FlowyError>, ); typedef SingleRowCallback = void Function( FlowyResult, ); class DatabaseViewListener { DatabaseViewListener({required this.viewId}); final String viewId; DatabaseNotificationListener? _listener; void start({ required NumberOfRowsCallback onRowsChanged, required ReorderAllRowsCallback onReorderAllRows, required SingleRowCallback onReorderSingleRow, required RowsVisibilityCallback onRowsVisibilityChanged, }) { // Stop any existing listener _listener?.stop(); // Initialize the notification listener _listener = DatabaseNotificationListener( objectId: viewId, handler: (ty, result) => _handler( ty, result, onRowsChanged, onReorderAllRows, onReorderSingleRow, onRowsVisibilityChanged, ), ); } void _handler( DatabaseNotification ty, FlowyResult result, NumberOfRowsCallback onRowsChanged, ReorderAllRowsCallback onReorderAllRows, SingleRowCallback onReorderSingleRow, RowsVisibilityCallback onRowsVisibilityChanged, ) { switch (ty) { case DatabaseNotification.DidUpdateViewRowsVisibility: result.fold( (payload) => onRowsVisibilityChanged( FlowyResult.success(RowsVisibilityChangePB.fromBuffer(payload)), ), (error) => onRowsVisibilityChanged(FlowyResult.failure(error)), ); break; case DatabaseNotification.DidUpdateRow: result.fold( (payload) => onRowsChanged( FlowyResult.success(RowsChangePB.fromBuffer(payload)), ), (error) => onRowsChanged(FlowyResult.failure(error)), ); break; case DatabaseNotification.DidReorderRows: result.fold( (payload) => onReorderAllRows( FlowyResult.success(ReorderAllRowsPB.fromBuffer(payload).rowOrders), ), (error) => onReorderAllRows(FlowyResult.failure(error)), ); break; case DatabaseNotification.DidReorderSingleRow: result.fold( (payload) => onReorderSingleRow( FlowyResult.success(ReorderSingleRowPB.fromBuffer(payload)), ), (error) => onReorderSingleRow(FlowyResult.failure(error)), ); break; default: break; } } Future stop() async { await _listener?.stop(); _listener = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/application/board_actions_bloc.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'board_actions_bloc.freezed.dart'; class BoardActionsCubit extends Cubit { BoardActionsCubit({ required this.databaseController, }) : super(const BoardActionsState.initial()); final DatabaseController databaseController; void startEditingRow(GroupedRowId groupedRowId) { emit(BoardActionsState.startEditingRow(groupedRowId: groupedRowId)); emit(const BoardActionsState.initial()); } void endEditing(GroupedRowId groupedRowId) { emit(const BoardActionsState.endEditingRow()); emit(BoardActionsState.setFocus(groupedRowIds: [groupedRowId])); emit(const BoardActionsState.initial()); } void openCard(RowMetaPB rowMeta) { emit(BoardActionsState.openCard(rowMeta: rowMeta)); emit(const BoardActionsState.initial()); } void openCardWithRowId(rowId) { final rowMeta = databaseController.rowCache.getRow(rowId)!.rowMeta; openCard(rowMeta); } void setFocus(List groupedRowIds) { emit(BoardActionsState.setFocus(groupedRowIds: groupedRowIds)); emit(const BoardActionsState.initial()); } void startCreateBottomRow(String groupId) { emit(const BoardActionsState.setFocus(groupedRowIds: [])); emit(BoardActionsState.startCreateBottomRow(groupId: groupId)); emit(const BoardActionsState.initial()); } void createRow( GroupedRowId? groupedRowId, CreateBoardCardRelativePosition relativePosition, ) { emit( BoardActionsState.createRow( groupedRowId: groupedRowId, position: relativePosition, ), ); emit(const BoardActionsState.initial()); } } @freezed class BoardActionsState with _$BoardActionsState { const factory BoardActionsState.initial() = _BoardActionsInitialState; const factory BoardActionsState.openCard({ required RowMetaPB rowMeta, }) = _BoardActionsOpenCardState; const factory BoardActionsState.startEditingRow({ required GroupedRowId groupedRowId, }) = _BoardActionsStartEditingRowState; const factory BoardActionsState.endEditingRow() = _BoardActionsEndEditingRowState; const factory BoardActionsState.setFocus({ required List groupedRowIds, }) = _BoardActionsSetFocusState; const factory BoardActionsState.startCreateBottomRow({ required String groupId, }) = _BoardActionsStartCreateBottomRowState; const factory BoardActionsState.createRow({ required GroupedRowId? groupedRowId, required CreateBoardCardRelativePosition position, }) = _BoardActionCreateRowState; } enum CreateBoardCardRelativePosition { before, after, } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart ================================================ import 'dart:async'; import 'dart:collection'; import 'package:appflowy/plugins/database/application/defines.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/board/group_ext.dart'; import 'package:appflowy/plugins/database/domain/group_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart' hide FieldInfo; import 'package:universal_platform/universal_platform.dart'; import '../../application/database_controller.dart'; import '../../application/field/field_controller.dart'; import '../../application/row/row_cache.dart'; import 'group_controller.dart'; part 'board_bloc.freezed.dart'; class BoardBloc extends Bloc { BoardBloc({ required this.databaseController, this.didCreateRow, AppFlowyBoardController? boardController, }) : super(const BoardState.loading()) { groupBackendSvc = GroupBackendService(viewId); _initBoardController(boardController); _dispatch(); } final DatabaseController databaseController; late final AppFlowyBoardController boardController; final LinkedHashMap groupControllers = LinkedHashMap(); final List groupList = []; final ValueNotifier? didCreateRow; late final GroupBackendService groupBackendSvc; UserProfilePB? _userProfile; UserProfilePB? get userProfile => _userProfile; FieldController get fieldController => databaseController.fieldController; String get viewId => databaseController.viewId; DatabaseCallbacks? _databaseCallbacks; DatabaseLayoutSettingCallbacks? _layoutSettingsCallback; GroupCallbacks? _groupCallbacks; void _initBoardController(AppFlowyBoardController? controller) { boardController = controller ?? AppFlowyBoardController( onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) => databaseController.moveGroup( fromGroupId: fromGroupId, toGroupId: toGroupId, ), onMoveGroupItem: (groupId, fromIndex, toIndex) { final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex); final toRow = groupControllers[groupId]?.rowAtIndex(toIndex); if (fromRow != null) { databaseController.moveGroupRow( fromRow: fromRow, toRow: toRow, fromGroupId: groupId, toGroupId: groupId, ); } }, onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { final fromRow = groupControllers[fromGroupId]?.rowAtIndex(fromIndex); final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex); if (fromRow != null) { databaseController.moveGroupRow( fromRow: fromRow, toRow: toRow, fromGroupId: fromGroupId, toGroupId: toGroupId, ); } }, ); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { emit(BoardState.initial(viewId)); _startListening(); await _openDatabase(emit); final result = await UserEventGetUserProfile().send(); result.fold( (profile) => _userProfile = profile, (err) => Log.error('Failed to fetch user profile: ${err.msg}'), ); }, createRow: (groupId, position, title, targetRowId) async { final primaryField = databaseController.fieldController.fieldInfos .firstWhereOrNull((element) => element.isPrimary)!; final void Function(RowDataBuilder)? cellBuilder = title == null ? null : (builder) => builder.insertText(primaryField, title); final result = await RowBackendService.createRow( viewId: databaseController.viewId, groupId: groupId, position: position, targetRowId: targetRowId, withCells: cellBuilder, ); final startEditing = position != OrderObjectPositionTypePB.End; final action = UniversalPlatform.isMobile ? DidCreateRowAction.openAsPage : startEditing ? DidCreateRowAction.startEditing : DidCreateRowAction.none; result.fold( (rowMeta) { state.maybeMap( ready: (value) { didCreateRow?.value = DidCreateRowResult( action: action, rowMeta: rowMeta, groupId: groupId, ); }, orElse: () {}, ); }, (err) => Log.error(err), ); }, createGroup: (name) async { final result = await groupBackendSvc.createGroup(name: name); result.onFailure(Log.error); }, deleteGroup: (groupId) async { final result = await groupBackendSvc.deleteGroup(groupId: groupId); result.onFailure(Log.error); }, renameGroup: (groupId, name) async { final result = await groupBackendSvc.updateGroup( groupId: groupId, name: name, ); result.onFailure(Log.error); }, didReceiveError: (error) { emit(BoardState.error(error: error)); }, didReceiveGroups: (List groups) { state.maybeMap( ready: (state) { emit( state.copyWith( hiddenGroups: _filterHiddenGroups(hideUngrouped, groups), groupIds: groups.map((group) => group.groupId).toList(), ), ); }, orElse: () {}, ); }, didUpdateLayoutSettings: (layoutSettings) { state.maybeMap( ready: (state) { emit( state.copyWith( layoutSettings: layoutSettings, hiddenGroups: _filterHiddenGroups(hideUngrouped, groupList), ), ); }, orElse: () {}, ); }, setGroupVisibility: (GroupPB group, bool isVisible) async { await _setGroupVisibility(group, isVisible); }, toggleHiddenSectionVisibility: (isVisible) async { await state.maybeMap( ready: (state) async { final newLayoutSettings = state.layoutSettings!; newLayoutSettings.freeze(); final newLayoutSetting = newLayoutSettings.rebuild( (message) => message.collapseHiddenGroups = isVisible, ); await databaseController.updateLayoutSetting( boardLayoutSetting: newLayoutSetting, ); }, orElse: () {}, ); }, reorderGroup: (fromGroupId, toGroupId) async { _reorderGroup(fromGroupId, toGroupId, emit); }, startEditingHeader: (String groupId) { state.maybeMap( ready: (state) => emit(state.copyWith(editingHeaderId: groupId)), orElse: () {}, ); }, endEditingHeader: (String groupId, String? groupName) async { final group = groupControllers[groupId]?.group; if (group != null) { final currentName = group.generateGroupName(databaseController); if (currentName != groupName) { await groupBackendSvc.updateGroup( groupId: groupId, name: groupName, ); } } state.maybeMap( ready: (state) => emit(state.copyWith(editingHeaderId: null)), orElse: () {}, ); }, deleteCards: (groupedRowIds) async { final rowIds = groupedRowIds.map((e) => e.rowId).toList(); await RowBackendService.deleteRows(viewId, rowIds); }, moveGroupToAdjacentGroup: (groupedRowId, toPrevious) async { final fromRow = databaseController.rowCache.getRow(groupedRowId.rowId)?.rowMeta; final currentGroupIndex = boardController.groupIds.indexOf(groupedRowId.groupId); final toGroupIndex = toPrevious ? currentGroupIndex - 1 : currentGroupIndex + 1; if (fromRow != null && toGroupIndex > -1 && toGroupIndex < boardController.groupIds.length) { final toGroupId = boardController.groupDatas[toGroupIndex].id; final result = await databaseController.moveGroupRow( fromRow: fromRow, fromGroupId: groupedRowId.groupId, toGroupId: toGroupId, ); result.fold( (s) { final previousState = state; emit( BoardState.setFocus( groupedRowIds: [ GroupedRowId( groupId: toGroupId, rowId: groupedRowId.rowId, ), ], ), ); emit(previousState); }, (f) {}, ); } }, openRowDetail: (rowMeta) { final copyState = state; emit(BoardState.openRowDetail(rowMeta: rowMeta)); emit(copyState); }, ); }, ); } Future _setGroupVisibility(GroupPB group, bool isVisible) async { if (group.isDefault) { await state.maybeMap( ready: (state) async { final newLayoutSettings = state.layoutSettings!; newLayoutSettings.freeze(); final newLayoutSetting = newLayoutSettings.rebuild( (message) => message.hideUngroupedColumn = !isVisible, ); await databaseController.updateLayoutSetting( boardLayoutSetting: newLayoutSetting, ); }, orElse: () {}, ); } else { await groupBackendSvc.updateGroup( groupId: group.groupId, visible: isVisible, ); } } void _reorderGroup( String fromGroupId, String toGroupId, Emitter emit, ) async { final fromIndex = groupList.indexWhere((g) => g.groupId == fromGroupId); final toIndex = groupList.indexWhere((g) => g.groupId == toGroupId); final group = groupList.removeAt(fromIndex); groupList.insert(toIndex, group); add(BoardEvent.didReceiveGroups(groupList)); final result = await databaseController.moveGroup( fromGroupId: fromGroupId, toGroupId: toGroupId, ); result.fold((l) => {}, (err) => Log.error(err)); } @override Future close() async { for (final controller in groupControllers.values) { await controller.dispose(); } databaseController.removeListener( onDatabaseChanged: _databaseCallbacks, onLayoutSettingsChanged: _layoutSettingsCallback, onGroupChanged: _groupCallbacks, ); _databaseCallbacks = null; _layoutSettingsCallback = null; _groupCallbacks = null; boardController.dispose(); return super.close(); } bool get hideUngrouped => databaseController.databaseLayoutSetting?.board.hideUngroupedColumn ?? false; FieldType? get groupingFieldType { if (groupList.isEmpty) { return null; } return databaseController.fieldController .getField(groupList.first.fieldId) ?.fieldType; } void initializeGroups(List groups) { for (final controller in groupControllers.values) { controller.dispose(); } groupControllers.clear(); boardController.clear(); groupList.clear(); groupList.addAll(groups); boardController.addGroups( groups .where((group) { final field = fieldController.getField(group.fieldId); return field != null && (!group.isDefault && group.isVisible || group.isDefault && !hideUngrouped && field.fieldType != FieldType.Checkbox); }) .map((group) => _initializeGroupData(group)) .toList(), ); for (final group in groups) { final controller = _initializeGroupController(group); groupControllers[controller.group.groupId] = controller; } } RowCache get rowCache => databaseController.rowCache; void _startListening() { _layoutSettingsCallback = DatabaseLayoutSettingCallbacks( onLayoutSettingsChanged: (layoutSettings) { if (isClosed) { return; } final index = groupList.indexWhere((element) => element.isDefault); if (index != -1) { if (layoutSettings.board.hideUngroupedColumn) { boardController.removeGroup(groupList[index].fieldId); } else { final newGroup = _initializeGroupData(groupList[index]); final visibleGroups = [...groupList] ..retainWhere((g) => g.isVisible || g.isDefault); final indexInVisibleGroups = visibleGroups.indexWhere((g) => g.isDefault); if (indexInVisibleGroups != -1) { boardController.insertGroup(indexInVisibleGroups, newGroup); } } } add(BoardEvent.didUpdateLayoutSettings(layoutSettings.board)); }, ); _groupCallbacks = GroupCallbacks( onGroupByField: (groups) { if (isClosed) { return; } initializeGroups(groups); add(BoardEvent.didReceiveGroups(groups)); }, onDeleteGroup: (groupIds) { if (isClosed) { return; } boardController.removeGroups(groupIds); groupList.removeWhere((group) => groupIds.contains(group.groupId)); add(BoardEvent.didReceiveGroups(groupList)); }, onInsertGroup: (insertGroups) { if (isClosed) { return; } final group = insertGroups.group; final newGroup = _initializeGroupData(group); final controller = _initializeGroupController(group); groupControllers[controller.group.groupId] = controller; boardController.addGroup(newGroup); groupList.insert(insertGroups.index, group); add(BoardEvent.didReceiveGroups(groupList)); }, onUpdateGroup: (updatedGroups) async { if (isClosed) { return; } // workaround: update group most of the time gets called before fields in // field controller are updated. For single and multi-select group // renames, this is required before generating the new group name. await Future.delayed(const Duration(milliseconds: 50)); for (final group in updatedGroups) { // see if the column is already in the board final index = groupList.indexWhere((g) => g.groupId == group.groupId); if (index == -1) { continue; } final columnController = boardController.getGroupController(group.groupId); if (columnController != null) { // remove the group or update its name columnController.updateGroupName( group.generateGroupName(databaseController), ); if (!group.isVisible) { boardController.removeGroup(group.groupId); } } else { final newGroup = _initializeGroupData(group); final visibleGroups = [...groupList]..retainWhere( (g) => (g.isVisible && !g.isDefault) || g.isDefault && !hideUngrouped || g.groupId == group.groupId, ); final indexInVisibleGroups = visibleGroups.indexWhere((g) => g.groupId == group.groupId); if (indexInVisibleGroups != -1) { boardController.insertGroup(indexInVisibleGroups, newGroup); } } groupList.removeAt(index); groupList.insert(index, group); } add(BoardEvent.didReceiveGroups(groupList)); }, ); _databaseCallbacks = DatabaseCallbacks( onRowsCreated: (rows) { for (final row in rows) { if (!isClosed && row.isHiddenInView) { add(BoardEvent.openRowDetail(row.rowMeta)); } } }, ); databaseController.addListener( onDatabaseChanged: _databaseCallbacks, onLayoutSettingsChanged: _layoutSettingsCallback, onGroupChanged: _groupCallbacks, ); } List _buildGroupItems(GroupPB group) { final items = group.rows.map((row) { final fieldInfo = fieldController.getField(group.fieldId); return GroupItem( row: row, fieldInfo: fieldInfo!, ); }).toList(); return [...items]; } Future _openDatabase(Emitter emit) { return databaseController.open().fold( (datbasePB) => databaseController.setIsLoading(false), (err) => emit(BoardState.error(error: err)), ); } GroupController _initializeGroupController(GroupPB group) { group.freeze(); final delegate = GroupControllerDelegateImpl( controller: boardController, fieldController: fieldController, onNewColumnItem: (groupId, row, index) {}, ); final controller = GroupController( group: group, delegate: delegate, onGroupChanged: (newGroup) { if (isClosed) return; final index = groupList.indexWhere((g) => g.groupId == newGroup.groupId); if (index != -1) { groupList.removeAt(index); groupList.insert(index, newGroup); add(BoardEvent.didReceiveGroups(groupList)); } }, ); return controller..startListening(); } AppFlowyGroupData _initializeGroupData(GroupPB group) { return AppFlowyGroupData( id: group.groupId, name: group.generateGroupName(databaseController), items: _buildGroupItems(group), customData: GroupData( group: group, fieldInfo: fieldController.getField(group.fieldId)!, ), ); } } @freezed class BoardEvent with _$BoardEvent { const factory BoardEvent.initial() = _InitialBoard; const factory BoardEvent.createRow( String groupId, OrderObjectPositionTypePB position, String? title, String? targetRowId, ) = _CreateRow; const factory BoardEvent.createGroup(String name) = _CreateGroup; const factory BoardEvent.startEditingHeader(String groupId) = _StartEditingHeader; const factory BoardEvent.endEditingHeader(String groupId, String? groupName) = _EndEditingHeader; const factory BoardEvent.setGroupVisibility( GroupPB group, bool isVisible, ) = _SetGroupVisibility; const factory BoardEvent.toggleHiddenSectionVisibility(bool isVisible) = _ToggleHiddenSectionVisibility; const factory BoardEvent.renameGroup(String groupId, String name) = _RenameGroup; const factory BoardEvent.deleteGroup(String groupId) = _DeleteGroup; const factory BoardEvent.reorderGroup(String fromGroupId, String toGroupId) = _ReorderGroup; const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError; const factory BoardEvent.didReceiveGroups(List groups) = _DidReceiveGroups; const factory BoardEvent.didUpdateLayoutSettings( BoardLayoutSettingPB layoutSettings, ) = _DidUpdateLayoutSettings; const factory BoardEvent.deleteCards(List groupedRowIds) = _DeleteCards; const factory BoardEvent.moveGroupToAdjacentGroup( GroupedRowId groupedRowId, bool toPrevious, ) = _MoveGroupToAdjacentGroup; const factory BoardEvent.openRowDetail(RowMetaPB rowMeta) = _OpenRowDetail; } @freezed class BoardState with _$BoardState { const BoardState._(); const factory BoardState.loading() = _BoardLoadingState; const factory BoardState.error({ required FlowyError error, }) = _BoardErrorState; const factory BoardState.ready({ required String viewId, required List groupIds, required LoadingState loadingState, required FlowyError? noneOrError, required BoardLayoutSettingPB? layoutSettings, required List hiddenGroups, String? editingHeaderId, }) = _BoardReadyState; const factory BoardState.setFocus({ required List groupedRowIds, }) = _BoardSetFocusState; const factory BoardState.openRowDetail({ required RowMetaPB rowMeta, }) = _BoardOpenRowDetailState; factory BoardState.initial(String viewId) => BoardState.ready( viewId: viewId, groupIds: [], noneOrError: null, loadingState: const LoadingState.loading(), layoutSettings: null, hiddenGroups: [], ); bool get isLoading => maybeMap(loading: (_) => true, orElse: () => false); bool get isError => maybeMap(error: (_) => true, orElse: () => false); bool get isReady => maybeMap(ready: (_) => true, orElse: () => false); bool get isSetFocus => maybeMap(setFocus: (_) => true, orElse: () => false); } List _filterHiddenGroups(bool hideUngrouped, List groups) { return [...groups]..retainWhere( (group) => !group.isVisible || group.isDefault && hideUngrouped, ); } class GroupItem extends AppFlowyGroupItem { GroupItem({ required this.row, required this.fieldInfo, }); final RowMetaPB row; final FieldInfo fieldInfo; @override String get id => row.id.toString(); } /// Identifies a card in a database view that has grouping. To support cases /// in which a card can belong to more than one group at the same time (e.g. /// FieldType.Multiselect), we include the card's group id as well. /// class GroupedRowId extends Equatable { const GroupedRowId({ required this.rowId, required this.groupId, }); final String rowId; final String groupId; @override List get props => [rowId, groupId]; } class GroupControllerDelegateImpl extends GroupControllerDelegate { GroupControllerDelegateImpl({ required this.controller, required this.fieldController, required this.onNewColumnItem, }); final FieldController fieldController; final AppFlowyBoardController controller; final void Function(String, RowMetaPB, int?) onNewColumnItem; @override bool hasGroup(String groupId) => controller.groupIds.contains(groupId); @override void insertRow(GroupPB group, RowMetaPB row, int? index) { final fieldInfo = fieldController.getField(group.fieldId); if (fieldInfo == null) { return Log.warn("fieldInfo should not be null"); } if (index != null) { final item = GroupItem( row: row, fieldInfo: fieldInfo, ); controller.insertGroupItem(group.groupId, index, item); } else { final item = GroupItem( row: row, fieldInfo: fieldInfo, ); controller.addGroupItem(group.groupId, item); } } @override void removeRow(GroupPB group, RowId rowId) => controller.removeGroupItem(group.groupId, rowId.toString()); @override void updateRow(GroupPB group, RowMetaPB row) { final fieldInfo = fieldController.getField(group.fieldId); if (fieldInfo == null) { return Log.warn("fieldInfo should not be null"); } controller.updateGroupItem( group.groupId, GroupItem( row: row, fieldInfo: fieldInfo, ), ); } @override void addNewRow(GroupPB group, RowMetaPB row, int? index) { final fieldInfo = fieldController.getField(group.fieldId); if (fieldInfo == null) { return Log.warn("fieldInfo should not be null"); } final item = GroupItem(row: row, fieldInfo: fieldInfo); if (index != null) { controller.insertGroupItem(group.groupId, index, item); } else { controller.addGroupItem(group.groupId, item); } onNewColumnItem(group.groupId, row, index); } } class GroupData { const GroupData({ required this.group, required this.fieldInfo, }); final GroupPB group; final FieldInfo fieldInfo; CheckboxGroup? asCheckboxGroup() => fieldType == FieldType.Checkbox ? CheckboxGroup(group) : null; FieldType get fieldType => fieldInfo.fieldType; } class CheckboxGroup { const CheckboxGroup(this.group); final GroupPB group; // Hardcode value: "Yes" that equal to the value defined in Rust // pub const CHECK: &str = "Yes"; bool get isCheck => group.groupId == "Yes"; } enum DidCreateRowAction { none, openAsPage, startEditing, } class DidCreateRowResult { DidCreateRowResult({ required this.action, required this.rowMeta, required this.groupId, }); final DidCreateRowAction action; final RowMetaPB rowMeta; final String groupId; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; import 'package:protobuf/protobuf.dart'; abstract class GroupControllerDelegate { bool hasGroup(String groupId); void removeRow(GroupPB group, RowId rowId); void insertRow(GroupPB group, RowMetaPB row, int? index); void updateRow(GroupPB group, RowMetaPB row); void addNewRow(GroupPB group, RowMetaPB row, int? index); } class GroupController { GroupController({ required this.group, required this.delegate, required this.onGroupChanged, }) : _listener = SingleGroupListener(group); GroupPB group; final SingleGroupListener _listener; final GroupControllerDelegate delegate; final void Function(GroupPB group) onGroupChanged; RowMetaPB? rowAtIndex(int index) => group.rows.elementAtOrNull(index); RowMetaPB? firstRow() => group.rows.firstOrNull; RowMetaPB? lastRow() => group.rows.lastOrNull; void startListening() { _listener.start( onGroupChanged: (result) { result.fold( (GroupRowsNotificationPB changeset) { final newItems = [...group.rows]; final isGroupExist = delegate.hasGroup(group.groupId); for (final deletedRow in changeset.deletedRows) { newItems.removeWhere((rowPB) => rowPB.id == deletedRow); if (isGroupExist) { delegate.removeRow(group, deletedRow); } } for (final insertedRow in changeset.insertedRows) { if (newItems.any((rowPB) => rowPB.id == insertedRow.rowMeta.id)) { continue; } final index = insertedRow.hasIndex() ? insertedRow.index : null; if (insertedRow.hasIndex() && newItems.length > insertedRow.index) { newItems.insert(insertedRow.index, insertedRow.rowMeta); } else { newItems.add(insertedRow.rowMeta); } if (isGroupExist) { if (insertedRow.isNew) { delegate.addNewRow(group, insertedRow.rowMeta, index); } else { delegate.insertRow(group, insertedRow.rowMeta, index); } } } for (final updatedRow in changeset.updatedRows) { final index = newItems.indexWhere( (rowPB) => rowPB.id == updatedRow.id, ); if (index != -1) { newItems[index] = updatedRow; if (isGroupExist) { delegate.updateRow(group, updatedRow); } } } group = group.rebuild((group) { group.rows.clear(); group.rows.addAll(newItems); }); group.freeze(); Log.debug( "Build GroupPB:${group.groupId}: items: ${group.rows.length}", ); onGroupChanged(group); }, (err) => Log.error(err), ); }, ); } Future dispose() async { await _listener.stop(); } } typedef UpdateGroupNotifiedValue = FlowyResult; class SingleGroupListener { SingleGroupListener(this.group); final GroupPB group; PublishNotifier? _groupNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({ required void Function(UpdateGroupNotifiedValue) onGroupChanged, }) { _groupNotifier?.addPublishListener(onGroupChanged); _listener = DatabaseNotificationListener( objectId: group.groupId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateGroupRow: result.fold( (payload) => _groupNotifier?.value = FlowyResult.success(GroupRowsNotificationPB.fromBuffer(payload)), (error) => _groupNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _groupNotifier?.dispose(); _groupNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/board.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; class BoardPluginBuilder implements PluginBuilder { @override Plugin build(dynamic data) { if (data is ViewPB) { return DatabaseTabBarViewPlugin(pluginType: pluginType, view: data); } else { throw FlowyPluginException.invalidData; } } @override String get menuName => LocaleKeys.board_menuName.tr(); @override FlowySvgData get icon => FlowySvgs.icon_board_s; @override PluginType get pluginType => PluginType.board; @override ViewLayoutPB get layoutType => ViewLayoutPB.Board; } class BoardPluginConfig implements PluginConfig { @override bool get creatable => true; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; extension GroupName on GroupPB { String generateGroupName(DatabaseController databaseController) { final fieldController = databaseController.fieldController; final field = fieldController.getField(fieldId); if (field == null) { return ""; } // if the group is the default group, then if (isDefault) { return "No ${field.name}"; } final groupSettings = databaseController.fieldController.groupSettings .firstWhereOrNull((gs) => gs.fieldId == field.id); switch (field.fieldType) { case FieldType.SingleSelect: final options = SingleSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) .options; final option = options.firstWhereOrNull((option) => option.id == groupId); return option == null ? "" : option.name; case FieldType.MultiSelect: final options = MultiSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) .options; final option = options.firstWhereOrNull((option) => option.id == groupId); return option == null ? "" : option.name; case FieldType.Checkbox: return groupId; case FieldType.URL: return groupId; case FieldType.DateTime: final config = groupSettings?.content != null ? DateGroupConfigurationPB.fromBuffer(groupSettings!.content) : DateGroupConfigurationPB(); final dateFormat = DateFormat("y/MM/dd"); try { final targetDateTime = dateFormat.parseLoose(groupId); switch (config.condition) { case DateConditionPB.Day: return DateFormat("MMM dd, y").format(targetDateTime); case DateConditionPB.Week: final beginningOfWeek = targetDateTime .subtract(Duration(days: targetDateTime.weekday - 1)); final endOfWeek = targetDateTime.add( Duration(days: DateTime.daysPerWeek - targetDateTime.weekday), ); final beginningOfWeekFormat = beginningOfWeek.year != endOfWeek.year ? "MMM dd y" : "MMM dd"; final endOfWeekFormat = beginningOfWeek.month != endOfWeek.month ? "MMM dd y" : "dd y"; return LocaleKeys.board_dateCondition_weekOf.tr( args: [ DateFormat(beginningOfWeekFormat).format(beginningOfWeek), DateFormat(endOfWeekFormat).format(endOfWeek), ], ); case DateConditionPB.Month: return DateFormat("MMM y").format(targetDateTime); case DateConditionPB.Year: return DateFormat("y").format(targetDateTime); case DateConditionPB.Relative: final targetDateTimeDay = DateTime( targetDateTime.year, targetDateTime.month, targetDateTime.day, ); final nowDay = DateTime.now().withoutTime; final diff = targetDateTimeDay.difference(nowDay).inDays; return switch (diff) { 0 => LocaleKeys.board_dateCondition_today.tr(), -1 => LocaleKeys.board_dateCondition_yesterday.tr(), 1 => LocaleKeys.board_dateCondition_tomorrow.tr(), -7 => LocaleKeys.board_dateCondition_lastSevenDays.tr(), 2 => LocaleKeys.board_dateCondition_nextSevenDays.tr(), -30 => LocaleKeys.board_dateCondition_lastThirtyDays.tr(), 8 => LocaleKeys.board_dateCondition_nextThirtyDays.tr(), _ => DateFormat("MMM y").format(targetDateTimeDay) }; default: return ""; } } on FormatException { return ""; } default: return ""; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart ================================================ import 'dart:io'; import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/board/mobile_board_page.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_actions_bloc.dart'; import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/shared/conditional_listenable_builder.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart' hide Card; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../widgets/card/card.dart'; import '../../widgets/cell/card_cell_builder.dart'; import '../application/board_bloc.dart'; import 'toolbar/board_setting_bar.dart'; import 'widgets/board_focus_scope.dart'; import 'widgets/board_hidden_groups.dart'; import 'widgets/board_shortcut_container.dart'; class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { final _toggleExtension = ToggleExtensionNotifier(); @override Widget content( BuildContext context, ViewPB view, DatabaseController controller, bool shrinkWrap, String? initialRowId, ) => UniversalPlatform.isDesktop ? DesktopBoardPage( key: _makeValueKey(controller), view: view, databaseController: controller, shrinkWrap: shrinkWrap, ) : MobileBoardPage( key: _makeValueKey(controller), view: view, databaseController: controller, ); @override Widget settingBar(BuildContext context, DatabaseController controller) => BoardSettingBar( key: _makeValueKey(controller), databaseController: controller, toggleExtension: _toggleExtension, ); @override Widget settingBarExtension( BuildContext context, DatabaseController controller, ) { return DatabaseViewSettingExtension( key: _makeValueKey(controller), viewId: controller.viewId, databaseController: controller, toggleExtension: _toggleExtension, ); } @override void dispose() { _toggleExtension.dispose(); super.dispose(); } ValueKey _makeValueKey(DatabaseController controller) => ValueKey(controller.viewId); } class DesktopBoardPage extends StatefulWidget { const DesktopBoardPage({ super.key, required this.view, required this.databaseController, this.onEditStateChanged, this.shrinkWrap = false, }); final ViewPB view; final DatabaseController databaseController; /// Called when edit state changed final VoidCallback? onEditStateChanged; /// If true, the board will shrink wrap its content final bool shrinkWrap; @override State createState() => _DesktopBoardPageState(); } class _DesktopBoardPageState extends State { late final AppFlowyBoardController _boardController = AppFlowyBoardController( onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) => widget.databaseController.moveGroup( fromGroupId: fromGroupId, toGroupId: toGroupId, ), onMoveGroupItem: (groupId, fromIndex, toIndex) { final groupControllers = _boardBloc.groupControllers; final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex); final toRow = groupControllers[groupId]?.rowAtIndex(toIndex); if (fromRow != null) { widget.databaseController.moveGroupRow( fromRow: fromRow, toRow: toRow, fromGroupId: groupId, toGroupId: groupId, ); } }, onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { final groupControllers = _boardBloc.groupControllers; final fromRow = groupControllers[fromGroupId]?.rowAtIndex(fromIndex); final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex); if (fromRow != null) { widget.databaseController.moveGroupRow( fromRow: fromRow, toRow: toRow, fromGroupId: fromGroupId, toGroupId: toGroupId, ); } }, onStartDraggingCard: (groupId, index) { final groupControllers = _boardBloc.groupControllers; final toRow = groupControllers[groupId]?.rowAtIndex(index); if (toRow != null) { _focusScope.clear(); } }, ); late final _focusScope = BoardFocusScope( boardController: _boardController, ); late final BoardBloc _boardBloc; late final BoardActionsCubit _boardActionsCubit; late final ValueNotifier _didCreateRow; @override void initState() { super.initState(); _didCreateRow = ValueNotifier(null)..addListener(_handleDidCreateRow); _boardBloc = BoardBloc( databaseController: widget.databaseController, didCreateRow: _didCreateRow, boardController: _boardController, )..add(const BoardEvent.initial()); _boardActionsCubit = BoardActionsCubit( databaseController: widget.databaseController, ); } @override void dispose() { _focusScope.dispose(); _boardBloc.close(); _boardActionsCubit.close(); _didCreateRow.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider.value(value: _boardBloc), BlocProvider.value(value: _boardActionsCubit), ], child: BlocBuilder( builder: (context, state) => state.maybeMap( loading: (_) => const Center( child: CircularProgressIndicator.adaptive(), ), error: (err) => Center(child: AppFlowyErrorPage(error: err.error)), orElse: () => _BoardContent( shrinkWrap: widget.shrinkWrap, onEditStateChanged: widget.onEditStateChanged, focusScope: _focusScope, boardController: _boardController, view: widget.view, ), ), ), ); } void _handleDidCreateRow() async { // work around: wait for the new card to be inserted into the board before enabling edit await Future.delayed(const Duration(milliseconds: 50)); if (_didCreateRow.value != null) { final result = _didCreateRow.value!; switch (result.action) { case DidCreateRowAction.openAsPage: _boardActionsCubit.openCard(result.rowMeta); break; case DidCreateRowAction.startEditing: _boardActionsCubit.startEditingRow( GroupedRowId( groupId: result.groupId, rowId: result.rowMeta.id, ), ); break; default: break; } } } } class _BoardContent extends StatefulWidget { const _BoardContent({ required this.boardController, required this.focusScope, required this.view, this.onEditStateChanged, this.shrinkWrap = false, }); final AppFlowyBoardController boardController; final BoardFocusScope focusScope; final VoidCallback? onEditStateChanged; final bool shrinkWrap; final ViewPB view; @override State<_BoardContent> createState() => _BoardContentState(); } class _BoardContentState extends State<_BoardContent> { final ScrollController scrollController = ScrollController(); final AppFlowyBoardScrollController scrollManager = AppFlowyBoardScrollController(); final config = const AppFlowyBoardConfig( groupMargin: EdgeInsets.symmetric(horizontal: 4), groupBodyPadding: EdgeInsets.symmetric(horizontal: 4), groupFooterPadding: EdgeInsets.fromLTRB(8, 14, 8, 4), groupHeaderPadding: EdgeInsets.symmetric(horizontal: 8), cardMargin: EdgeInsets.symmetric(horizontal: 4, vertical: 3), stretchGroupHeight: false, ); late final cellBuilder = CardCellBuilder( databaseController: databaseController, ); DatabaseController get databaseController => context.read().databaseController; @override void dispose() { scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final horizontalPadding = context.read()?.horizontalPadding ?? 0.0; return MultiBlocListener( listeners: [ BlocListener( listener: (context, state) { state.maybeMap( ready: (value) { widget.onEditStateChanged?.call(); }, openRowDetail: (value) { _openCard( context: context, databaseController: context.read().databaseController, rowMeta: value.rowMeta, ); }, orElse: () {}, ); }, ), BlocListener( listener: (context, state) { state.maybeMap( openCard: (value) { _openCard( context: context, databaseController: context.read().databaseController, rowMeta: value.rowMeta, ); }, setFocus: (value) { widget.focusScope.focusedGroupedRows = value.groupedRowIds; }, startEditingRow: (value) { widget.boardController.enableGroupDragging(false); widget.focusScope.clear(); }, endEditingRow: (value) { widget.boardController.enableGroupDragging(true); }, orElse: () {}, ); }, ), ], child: FocusScope( autofocus: true, child: BoardShortcutContainer( focusScope: widget.focusScope, child: Padding( padding: const EdgeInsets.only(top: 8.0), child: ValueListenableBuilder( valueListenable: databaseController.compactModeNotifier, builder: (context, compactMode, _) { return AppFlowyBoard( boardScrollController: scrollManager, scrollController: scrollController, shrinkWrap: widget.shrinkWrap, controller: context.read().boardController, groupConstraints: BoxConstraints.tightFor(width: compactMode ? 196 : 256), config: config, leading: HiddenGroupsColumn( shrinkWrap: widget.shrinkWrap, margin: config.groupHeaderPadding + EdgeInsets.only( left: widget.shrinkWrap ? horizontalPadding : 0.0, ), ), trailing: context .read() .groupingFieldType ?.canCreateNewGroup ?? false ? BoardTrailing(scrollController: scrollController) : const HSpace(40), headerBuilder: (_, groupData) => BlocProvider.value( value: context.read(), child: BoardColumnHeader( databaseController: databaseController, groupData: groupData, margin: config.groupHeaderPadding, ), ), footerBuilder: (_, groupData) => MultiBlocProvider( providers: [ BlocProvider.value(value: context.read()), BlocProvider.value( value: context.read(), ), ], child: BoardColumnFooter( columnData: groupData, boardConfig: config, scrollManager: scrollManager, ), ), cardBuilder: (cardContext, column, columnItem) => MultiBlocProvider( key: ValueKey("board_card_${column.id}_${columnItem.id}"), providers: [ BlocProvider.value( value: cardContext.read(), ), BlocProvider.value( value: cardContext.read(), ), BlocProvider( create: (_) => PageAccessLevelBloc( view: widget.view, ignorePageAccessLevel: true, )..add(PageAccessLevelEvent.initial()), ), ], child: BlocBuilder( builder: (lockStatusContext, state) { return IgnorePointer( ignoring: !state.isEditable, child: _BoardCard( afGroupData: column, groupItem: columnItem as GroupItem, boardConfig: config, notifier: widget.focusScope, cellBuilder: cellBuilder, compactMode: compactMode, onOpenCard: (rowMeta) => _openCard( context: context, databaseController: lockStatusContext .read() .databaseController, rowMeta: rowMeta, ), ), ); }, ), ), ); }, ), ), ), ), ); } } @visibleForTesting class BoardColumnFooter extends StatefulWidget { const BoardColumnFooter({ super.key, required this.columnData, required this.boardConfig, required this.scrollManager, }); final AppFlowyGroupData columnData; final AppFlowyBoardConfig boardConfig; final AppFlowyBoardScrollController scrollManager; @override State createState() => _BoardColumnFooterState(); } class _BoardColumnFooterState extends State { final TextEditingController _textController = TextEditingController(); late final FocusNode _focusNode; bool _isCreating = false; @override void initState() { super.initState(); _focusNode = FocusNode( onKeyEvent: (node, event) { if (_focusNode.hasFocus && event.logicalKey == LogicalKeyboardKey.escape) { _focusNode.unfocus(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, )..addListener(() { if (!_focusNode.hasFocus) { setState(() => _isCreating = false); } }); } @override void dispose() { _textController.dispose(); _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_isCreating) { _focusNode.requestFocus(); } }); return Padding( padding: widget.boardConfig.groupFooterPadding, child: AnimatedSwitcher( duration: const Duration(milliseconds: 150), child: _isCreating ? _createCardsTextField() : _startCreatingCardsButton(), ), ); } Widget _createCardsTextField() { const nada = DoNothingAndStopPropagationIntent(); return Shortcuts( shortcuts: { const SingleActivator(LogicalKeyboardKey.arrowUp): nada, const SingleActivator(LogicalKeyboardKey.arrowDown): nada, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): nada, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): nada, const SingleActivator(LogicalKeyboardKey.keyE): nada, const SingleActivator(LogicalKeyboardKey.keyN): nada, const SingleActivator(LogicalKeyboardKey.delete): nada, // const SingleActivator(LogicalKeyboardKey.backspace): nada, const SingleActivator(LogicalKeyboardKey.enter): nada, const SingleActivator(LogicalKeyboardKey.numpadEnter): nada, const SingleActivator(LogicalKeyboardKey.comma): nada, const SingleActivator(LogicalKeyboardKey.period): nada, SingleActivator( LogicalKeyboardKey.arrowUp, shift: true, meta: Platform.isMacOS, control: !Platform.isMacOS, ): nada, }, child: FlowyTextField( hintTextConstraints: const BoxConstraints(maxHeight: 36), controller: _textController, focusNode: _focusNode, onSubmitted: (name) { context.read().add( BoardEvent.createRow( widget.columnData.id, OrderObjectPositionTypePB.End, name, null, ), ); widget.scrollManager.scrollToBottom(widget.columnData.id); _textController.clear(); _focusNode.requestFocus(); }, ), ); } Widget _startCreatingCardsButton() { return BlocListener( listener: (context, state) { state.maybeWhen( startCreateBottomRow: (groupId) { if (groupId == widget.columnData.id) { setState(() => _isCreating = true); } }, orElse: () {}, ); }, child: FlowyTooltip( message: LocaleKeys.board_column_addToColumnBottomTooltip.tr(), child: SizedBox( height: 36, child: FlowyButton( leftIcon: FlowySvg( FlowySvgs.add_s, color: Theme.of(context).hintColor, ), text: FlowyText( LocaleKeys.board_column_createNewCard.tr(), color: Theme.of(context).hintColor, ), onTap: () { context .read() .startCreateBottomRow(widget.columnData.id); }, ), ), ), ); } } class _BoardCard extends StatefulWidget { const _BoardCard({ required this.afGroupData, required this.groupItem, required this.boardConfig, required this.cellBuilder, required this.notifier, required this.compactMode, required this.onOpenCard, }); final AppFlowyGroupData afGroupData; final GroupItem groupItem; final AppFlowyBoardConfig boardConfig; final CardCellBuilder cellBuilder; final BoardFocusScope notifier; final bool compactMode; final void Function(RowMetaPB) onOpenCard; @override State<_BoardCard> createState() => _BoardCardState(); } class _BoardCardState extends State<_BoardCard> { bool _isEditing = false; @override Widget build(BuildContext context) { final boardBloc = context.read(); final groupData = widget.afGroupData.customData as GroupData; final rowCache = boardBloc.rowCache; final databaseController = boardBloc.databaseController; final rowMeta = rowCache.getRow(widget.groupItem.id)?.rowMeta ?? widget.groupItem.row; const nada = DoNothingAndStopPropagationIntent(); return BlocListener( listener: (context, state) { state.maybeMap( startEditingRow: (value) { if (value.groupedRowId.rowId == widget.groupItem.id && value.groupedRowId.groupId == groupData.group.groupId) { setState(() => _isEditing = true); } }, endEditingRow: (_) { if (_isEditing) { setState(() => _isEditing = false); } }, createRow: (value) { if ((_isEditing && value.groupedRowId == null) || (value.groupedRowId?.rowId == widget.groupItem.id && value.groupedRowId?.groupId == groupData.group.groupId)) { context.read().add( BoardEvent.createRow( groupData.group.groupId, value.position == CreateBoardCardRelativePosition.before ? OrderObjectPositionTypePB.Before : OrderObjectPositionTypePB.After, null, widget.groupItem.row.id, ), ); } }, orElse: () {}, ); }, child: Shortcuts( shortcuts: { const SingleActivator(LogicalKeyboardKey.arrowUp): nada, const SingleActivator(LogicalKeyboardKey.arrowDown): nada, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): nada, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): nada, const SingleActivator(LogicalKeyboardKey.keyE): nada, const SingleActivator(LogicalKeyboardKey.keyN): nada, const SingleActivator(LogicalKeyboardKey.delete): nada, // const SingleActivator(LogicalKeyboardKey.backspace): nada, const SingleActivator(LogicalKeyboardKey.enter): nada, const SingleActivator(LogicalKeyboardKey.numpadEnter): nada, const SingleActivator(LogicalKeyboardKey.comma): nada, const SingleActivator(LogicalKeyboardKey.period): nada, SingleActivator( LogicalKeyboardKey.arrowUp, shift: true, meta: Platform.isMacOS, control: !Platform.isMacOS, ): nada, }, child: ConditionalListenableBuilder>( valueListenable: widget.notifier, buildWhen: (previous, current) { final focusItem = GroupedRowId( groupId: groupData.group.groupId, rowId: rowMeta.id, ); final previousContainsFocus = previous.contains(focusItem); final currentContainsFocus = current.contains(focusItem); return previousContainsFocus != currentContainsFocus; }, builder: (context, focusedItems, child) { final cardMargin = widget.boardConfig.cardMargin; final margin = widget.compactMode ? cardMargin - EdgeInsets.symmetric(horizontal: 2) : cardMargin; return Container( margin: margin, decoration: _makeBoxDecoration( context, groupData.group.groupId, widget.groupItem.id, ), child: child, ); }, child: RowCard( fieldController: databaseController.fieldController, rowMeta: rowMeta, viewId: boardBloc.viewId, rowCache: rowCache, groupingFieldId: widget.groupItem.fieldInfo.id, isEditing: _isEditing, cellBuilder: widget.cellBuilder, onTap: (context) => widget.onOpenCard( context.read().rowController.rowMeta, ), onShiftTap: (_) { Focus.of(context).requestFocus(); widget.notifier.toggle( GroupedRowId( rowId: widget.groupItem.row.id, groupId: groupData.group.groupId, ), ); }, styleConfiguration: RowCardStyleConfiguration( cellStyleMap: desktopBoardCardCellStyleMap(context), hoverStyle: HoverStyle( hoverColor: Theme.of(context).brightness == Brightness.light ? const Color(0x0F1F2329) : const Color(0x0FEFF4FB), foregroundColorOnHover: AFThemeExtension.of(context).onBackground, ), ), onStartEditing: () => context.read().startEditingRow( GroupedRowId( groupId: groupData.group.groupId, rowId: rowMeta.id, ), ), onEndEditing: () => context.read().endEditing( GroupedRowId( groupId: groupData.group.groupId, rowId: rowMeta.id, ), ), userProfile: context.read().userProfile, ), ), ), ); } BoxDecoration _makeBoxDecoration( BuildContext context, String groupId, String rowId, ) { return BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(6)), border: Border.fromBorderSide( BorderSide( color: widget.notifier .isFocused(GroupedRowId(rowId: rowId, groupId: groupId)) ? Theme.of(context).colorScheme.primary : Theme.of(context).brightness == Brightness.light ? const Color(0xFF1F2329).withValues(alpha: 0.12) : const Color(0xFF59647A), ), ), boxShadow: [ BoxShadow( blurRadius: 4, color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), BoxShadow( blurRadius: 4, spreadRadius: -2, color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), ], ); } } class BoardTrailing extends StatefulWidget { const BoardTrailing({super.key, required this.scrollController}); final ScrollController scrollController; @override State createState() => _BoardTrailingState(); } class _BoardTrailingState extends State { final TextEditingController _textController = TextEditingController(); late final FocusNode _focusNode; bool isEditing = false; void _cancelAddNewGroup() { _textController.clear(); setState(() => isEditing = false); } @override void initState() { super.initState(); _focusNode = FocusNode( onKeyEvent: (node, event) { if (_focusNode.hasFocus && event.logicalKey == LogicalKeyboardKey.escape) { _cancelAddNewGroup(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, )..addListener(_onFocusChanged); } @override void dispose() { _focusNode.removeListener(_onFocusChanged); _focusNode.dispose(); _textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // call after every setState WidgetsBinding.instance.addPostFrameCallback((_) { if (isEditing) { _focusNode.requestFocus(); widget.scrollController.jumpTo( widget.scrollController.position.maxScrollExtent, ); } }); return Container( padding: const EdgeInsets.only(left: 8.0, top: 12, right: 40), alignment: AlignmentDirectional.topStart, child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: isEditing ? SizedBox( width: 256, child: Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: _textController, focusNode: _focusNode, decoration: InputDecoration( suffixIcon: Padding( padding: const EdgeInsets.only(left: 4, bottom: 8.0), child: FlowyIconButton( icon: const FlowySvg(FlowySvgs.close_filled_s), hoverColor: Colors.transparent, onPressed: () => _textController.clear(), ), ), suffixIconConstraints: BoxConstraints.loose(const Size(20, 24)), border: const UnderlineInputBorder(), contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 8), isDense: true, ), style: Theme.of(context).textTheme.bodySmall, onSubmitted: (groupName) => context .read() .add(BoardEvent.createGroup(groupName)), ), ), ) : FlowyTooltip( message: LocaleKeys.board_column_createNewColumn.tr(), child: FlowyIconButton( width: 26, icon: const FlowySvg(FlowySvgs.add_s), iconColorOnHover: Theme.of(context).colorScheme.onSurface, onPressed: () => setState(() => isEditing = true), ), ), ), ); } void _onFocusChanged() { if (!_focusNode.hasFocus) { _cancelAddNewGroup(); } } } void _openCard({ required BuildContext context, required DatabaseController databaseController, required RowMetaPB rowMeta, }) { final rowController = RowController( rowMeta: rowMeta, viewId: databaseController.viewId, rowCache: databaseController.rowCache, ); FlowyOverlay.show( context: context, builder: (_) => BlocProvider.value( value: context.read(), child: RowDetailPage( databaseController: databaseController, rowController: rowController, userProfile: context.read().userProfile, ), ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; class BoardSettingBar extends StatelessWidget { const BoardSettingBar({ super.key, required this.databaseController, required this.toggleExtension, }); final DatabaseController databaseController; final ToggleExtensionNotifier toggleExtension; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => FilterEditorBloc( viewId: databaseController.viewId, fieldController: databaseController.fieldController, ), child: ValueListenableBuilder( valueListenable: databaseController.isLoading, builder: (context, value, child) { if (value) { return const SizedBox.shrink(); } final isReference = Provider.of(context)?.isReference ?? false; return SizedBox( height: 20, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ FilterButton( toggleExtension: toggleExtension, ), if (isReference) ...[ const HSpace(2), ViewDatabaseButton(view: databaseController.view), ], const HSpace(2), SettingButton( databaseController: databaseController, ), ], ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_checkbox_column_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/board/group_ext.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'board_column_header.dart'; class CheckboxColumnHeader extends StatelessWidget { const CheckboxColumnHeader({ super.key, required this.databaseController, required this.groupData, }); final DatabaseController databaseController; final AppFlowyGroupData groupData; @override Widget build(BuildContext context) { final customData = groupData.customData as GroupData; final groupName = customData.group.generateGroupName(databaseController); return Row( children: [ FlowySvg( customData.asCheckboxGroup()!.isCheck ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, blendMode: BlendMode.dst, size: const Size.square(18), ), const HSpace(6), Expanded( child: Align( alignment: AlignmentDirectional.centerStart, child: FlowyTooltip( message: groupName, child: FlowyText.medium( groupName, overflow: TextOverflow.ellipsis, ), ), ), ), const HSpace(6), GroupOptionsButton( groupData: groupData, ), const HSpace(4), CreateCardFromTopButton( groupId: groupData.id, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/board/group_ext.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'board_checkbox_column_header.dart'; import 'board_editable_column_header.dart'; class BoardColumnHeader extends StatefulWidget { const BoardColumnHeader({ super.key, required this.databaseController, required this.groupData, required this.margin, }); final DatabaseController databaseController; final AppFlowyGroupData groupData; final EdgeInsets margin; @override State createState() => _BoardColumnHeaderState(); } class _BoardColumnHeaderState extends State { final ValueNotifier isEditing = ValueNotifier(false); GroupData get customData => widget.groupData.customData; @override void dispose() { isEditing.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final Widget child = switch (customData.fieldType) { FieldType.MultiSelect || FieldType.SingleSelect when !customData.group.isDefault => EditableColumnHeader( databaseController: widget.databaseController, groupData: widget.groupData, isEditing: isEditing, onSubmitted: (columnName) { context .read() .add(BoardEvent.renameGroup(widget.groupData.id, columnName)); }, ), FieldType.Checkbox => CheckboxColumnHeader( databaseController: widget.databaseController, groupData: widget.groupData, ), _ => _DefaultColumnHeaderContent( databaseController: widget.databaseController, groupData: widget.groupData, ), }; return Container( padding: widget.margin, height: 50, child: child, ); } } class GroupOptionsButton extends StatelessWidget { const GroupOptionsButton({ super.key, required this.groupData, this.isEditing, }); final AppFlowyGroupData groupData; final ValueNotifier? isEditing; @override Widget build(BuildContext context) { return AppFlowyPopover( clickHandler: PopoverClickHandler.gestureDetector, margin: const EdgeInsets.all(8), constraints: BoxConstraints.loose(const Size(168, 300)), direction: PopoverDirection.bottomWithLeftAligned, child: FlowyIconButton( width: 20, icon: const FlowySvg(FlowySvgs.details_horizontal_s), iconColorOnHover: Theme.of(context).colorScheme.onSurface, ), popupBuilder: (popoverContext) { final customGroupData = groupData.customData as GroupData; final isDefault = customGroupData.group.isDefault; final menuItems = GroupOption.values.toList(); if (!customGroupData.fieldType.canEditHeader || isDefault) { menuItems.remove(GroupOption.rename); } if (!customGroupData.fieldType.canDeleteGroup || isDefault) { menuItems.remove(GroupOption.delete); } return SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(4), children: [ ...menuItems.map( (action) => SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( leftIcon: FlowySvg(action.icon), text: FlowyText( action.text, lineHeight: 1.0, overflow: TextOverflow.ellipsis, ), onTap: () { run(context, action, customGroupData.group); PopoverContainer.of(popoverContext).close(); }, ), ), ), ], ); }, ); } void run(BuildContext context, GroupOption option, GroupPB group) { switch (option) { case GroupOption.rename: isEditing?.value = true; break; case GroupOption.hide: context .read() .add(BoardEvent.setGroupVisibility(group, false)); break; case GroupOption.delete: showConfirmDeletionDialog( context: context, name: LocaleKeys.board_column_label.tr(), description: LocaleKeys.board_column_deleteColumnConfirmation.tr(), onConfirm: () { context .read() .add(BoardEvent.deleteGroup(group.groupId)); }, ); break; } } } class CreateCardFromTopButton extends StatelessWidget { const CreateCardFromTopButton({ super.key, required this.groupId, }); final String groupId; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.board_column_addToColumnTopTooltip.tr(), preferBelow: false, child: FlowyIconButton( width: 20, icon: const FlowySvg(FlowySvgs.add_s), iconColorOnHover: Theme.of(context).colorScheme.onSurface, onPressed: () => context.read().add( BoardEvent.createRow( groupId, OrderObjectPositionTypePB.Start, null, null, ), ), ), ); } } class _DefaultColumnHeaderContent extends StatelessWidget { const _DefaultColumnHeaderContent({ required this.databaseController, required this.groupData, }); final DatabaseController databaseController; final AppFlowyGroupData groupData; @override Widget build(BuildContext context) { final customData = groupData.customData as GroupData; final groupName = customData.group.generateGroupName(databaseController); return Row( children: [ Expanded( child: Align( alignment: AlignmentDirectional.centerStart, child: FlowyTooltip( message: groupName, child: FlowyText.medium( groupName, overflow: TextOverflow.ellipsis, ), ), ), ), const HSpace(6), GroupOptionsButton( groupData: groupData, ), const HSpace(4), CreateCardFromTopButton( groupId: groupData.id, ), ], ); } } enum GroupOption { rename, hide, delete; FlowySvgData get icon => switch (this) { rename => FlowySvgs.edit_s, hide => FlowySvgs.hide_s, delete => FlowySvgs.delete_s, }; String get text => switch (this) { rename => LocaleKeys.board_column_renameColumn.tr(), hide => LocaleKeys.board_column_hideColumn.tr(), delete => LocaleKeys.board_column_deleteColumn.tr(), }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_editable_column_header.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/board/group_ext.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'board_column_header.dart'; /// This column header is used for the MultiSelect and SingleSelect field types. class EditableColumnHeader extends StatefulWidget { const EditableColumnHeader({ super.key, required this.databaseController, required this.groupData, required this.isEditing, required this.onSubmitted, }); final DatabaseController databaseController; final AppFlowyGroupData groupData; final ValueNotifier isEditing; final void Function(String columnName) onSubmitted; @override State createState() => _EditableColumnHeaderState(); } class _EditableColumnHeaderState extends State { late final FocusNode focusNode; late final TextEditingController textController = TextEditingController( text: _generateGroupName(), ); GroupData get customData => widget.groupData.customData; @override void initState() { super.initState(); focusNode = FocusNode( onKeyEvent: (node, event) { if (event.logicalKey == LogicalKeyboardKey.escape && event is KeyUpEvent) { focusNode.unfocus(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, )..addListener(onFocusChanged); } @override void didUpdateWidget(covariant oldWidget) { if (oldWidget.groupData.customData != widget.groupData.customData) { textController.text = _generateGroupName(); } super.didUpdateWidget(oldWidget); } @override void dispose() { focusNode ..removeListener(onFocusChanged) ..dispose(); textController.dispose(); super.dispose(); } void onFocusChanged() { if (!focusNode.hasFocus) { widget.isEditing.value = false; widget.onSubmitted(textController.text); } else { textController.selection = TextSelection( baseOffset: 0, extentOffset: textController.text.length, ); } } @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: ValueListenableBuilder( valueListenable: widget.isEditing, builder: (context, isEditing, _) { if (isEditing) { focusNode.requestFocus(); } return isEditing ? _buildTextField() : _buildTitle(); }, ), ), const HSpace(6), GroupOptionsButton( groupData: widget.groupData, isEditing: widget.isEditing, ), const HSpace(4), CreateCardFromTopButton( groupId: widget.groupData.id, ), ], ); } Widget _buildTitle() { final (backgroundColor, dotColor) = _generateGroupColor(); final groupName = _generateGroupName(); return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { widget.isEditing.value = true; }, child: Align( alignment: AlignmentDirectional.centerStart, child: FlowyTooltip( message: groupName, child: Container( height: 20, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( height: 6, width: 6, decoration: BoxDecoration( color: dotColor, borderRadius: BorderRadius.circular(3), ), ), ), const HSpace(4.0), Flexible( child: FlowyText.medium( groupName, overflow: TextOverflow.ellipsis, lineHeight: 1.0, ), ), ], ), ), ), ), ), ); } Widget _buildTextField() { return TextField( controller: textController, focusNode: focusNode, onEditingComplete: () { widget.isEditing.value = false; }, onSubmitted: widget.onSubmitted, style: Theme.of(context).textTheme.bodyMedium, decoration: InputDecoration( filled: true, fillColor: Theme.of(context).colorScheme.surface, hoverColor: Colors.transparent, contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), focusedBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), ), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), ), border: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), ), isDense: true, ), ); } String _generateGroupName() { return customData.group.generateGroupName(widget.databaseController); } (Color? backgroundColor, Color? dotColor) _generateGroupColor() { Color? backgroundColor; Color? dotColor; final groupId = widget.groupData.id; final fieldId = customData.fieldInfo.id; final field = widget.databaseController.fieldController.getField(fieldId); if (field != null) { final selectOptions = switch (field.fieldType) { FieldType.MultiSelect => MultiSelectTypeOptionDataParser() .fromBuffer(field.field.typeOptionData) .options, FieldType.SingleSelect => SingleSelectTypeOptionDataParser() .fromBuffer(field.field.typeOptionData) .options, _ => [], }; final colorPB = selectOptions.firstWhereOrNull((e) => e.id == groupId)?.color; if (colorPB != null) { backgroundColor = colorPB.toColor(context); dotColor = getColorOfDot(colorPB); } } return (backgroundColor, dotColor); } // move to theme file and allow theme customization once palette is finalized Color getColorOfDot(SelectOptionColorPB color) { return switch (Theme.of(context).brightness) { Brightness.light => switch (color) { SelectOptionColorPB.Purple => const Color(0xFFAB8DFF), SelectOptionColorPB.Pink => const Color(0xFFFF8EF5), SelectOptionColorPB.LightPink => const Color(0xFFFF85A9), SelectOptionColorPB.Orange => const Color(0xFFFFBC7E), SelectOptionColorPB.Yellow => const Color(0xFFFCD86F), SelectOptionColorPB.Lime => const Color(0xFFC6EC41), SelectOptionColorPB.Green => const Color(0xFF74F37D), SelectOptionColorPB.Aqua => const Color(0xFF40F0D1), SelectOptionColorPB.Blue => const Color(0xFF00C8FF), _ => throw ArgumentError, }, Brightness.dark => switch (color) { SelectOptionColorPB.Purple => const Color(0xFF502FD6), SelectOptionColorPB.Pink => const Color(0xFFBF1CC0), SelectOptionColorPB.LightPink => const Color(0xFFC42A53), SelectOptionColorPB.Orange => const Color(0xFFD77922), SelectOptionColorPB.Yellow => const Color(0xFFC59A1A), SelectOptionColorPB.Lime => const Color(0xFFA4C824), SelectOptionColorPB.Green => const Color(0xFF23CA2E), SelectOptionColorPB.Aqua => const Color(0xFF19CCAC), SelectOptionColorPB.Blue => const Color(0xFF04A9D7), _ => throw ArgumentError, } }; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_focus_scope.dart ================================================ import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy_board/appflowy_board.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; class BoardFocusScope extends ChangeNotifier implements ValueListenable> { BoardFocusScope({ required this.boardController, }); final AppFlowyBoardController boardController; List _focusedCards = []; @override List get value => _focusedCards; UnmodifiableListView get focusedGroupedRows => UnmodifiableListView(_focusedCards); set focusedGroupedRows(List focusedGroupedRows) { _deepCopy(); _focusedCards ..clear() ..addAll(focusedGroupedRows); notifyListeners(); } bool isFocused(GroupedRowId groupedRowId) => _focusedCards.contains(groupedRowId); void toggle(GroupedRowId groupedRowId) { _deepCopy(); if (_focusedCards.contains(groupedRowId)) { _focusedCards.remove(groupedRowId); } else { _focusedCards.add(groupedRowId); } notifyListeners(); } bool focusNext() { _deepCopy(); // if no card is focused, focus on the first card in the board if (_focusedCards.isEmpty) { _focusFirstCard(); notifyListeners(); return true; } final lastFocusedCard = _focusedCards.last; final groupController = boardController.controller(lastFocusedCard.groupId); final iterable = groupController?.items .skipWhile((item) => item.id != lastFocusedCard.rowId); // if the last-focused card's group cannot be found, or if the last-focused card cannot be found in the group, focus on the first card in the board if (iterable == null || iterable.isEmpty) { _focusFirstCard(); notifyListeners(); return true; } if (iterable.length == 1) { // focus on the first card in the next group final group = boardController.groupDatas .skipWhile((item) => item.id != lastFocusedCard.groupId) .skip(1) .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); if (group != null) { _focusedCards ..clear() ..add( GroupedRowId( rowId: group.items.first.id, groupId: group.id, ), ); } } else { // focus on the next card in the same group _focusedCards ..clear() ..add( GroupedRowId( rowId: iterable.elementAt(1).id, groupId: lastFocusedCard.groupId, ), ); } notifyListeners(); return true; } bool focusPrevious() { _deepCopy(); // if no card is focused, focus on the last card in the board if (_focusedCards.isEmpty) { _focusLastCard(); notifyListeners(); return true; } final lastFocusedCard = _focusedCards.last; final groupController = boardController.controller(lastFocusedCard.groupId); final iterable = groupController?.items.reversed .skipWhile((item) => item.id != lastFocusedCard.rowId); // if the last-focused card's group cannot be found or if the last-focused card cannot be found in the group, focus on the last card in the board if (iterable == null || iterable.isEmpty) { _focusLastCard(); notifyListeners(); return true; } if (iterable.length == 1) { // focus on the last card in the previous group final group = boardController.groupDatas.reversed .skipWhile((item) => item.id != lastFocusedCard.groupId) .skip(1) .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); if (group != null) { _focusedCards ..clear() ..add( GroupedRowId( rowId: group.items.last.id, groupId: group.id, ), ); } } else { // focus on the next card in the same group _focusedCards ..clear() ..add( GroupedRowId( rowId: iterable.elementAt(1).id, groupId: lastFocusedCard.groupId, ), ); } notifyListeners(); return true; } bool adjustRangeDown() { _deepCopy(); // if no card is focused, focus on the first card in the board if (_focusedCards.isEmpty) { _focusFirstCard(); notifyListeners(); return true; } final firstFocusedCard = _focusedCards.first; final lastFocusedCard = _focusedCards.last; // determine whether to shrink or expand the selection bool isExpand = false; if (_focusedCards.length == 1) { isExpand = true; } else { final firstGroupIndex = boardController.groupDatas .indexWhere((element) => element.id == firstFocusedCard.groupId); final lastGroupIndex = boardController.groupDatas .indexWhere((element) => element.id == lastFocusedCard.groupId); if (firstGroupIndex == -1 || lastGroupIndex == -1) { _focusFirstCard(); notifyListeners(); return true; } if (firstGroupIndex < lastGroupIndex) { isExpand = true; } else if (firstGroupIndex > lastGroupIndex) { isExpand = false; } else { final groupItems = boardController.groupDatas.elementAt(firstGroupIndex).items; final firstCardIndex = groupItems.indexWhere((item) => item.id == firstFocusedCard.rowId); final lastCardIndex = groupItems.indexWhere((item) => item.id == lastFocusedCard.rowId); if (firstCardIndex == -1 || lastCardIndex == -1) { _focusFirstCard(); notifyListeners(); return true; } isExpand = firstCardIndex < lastCardIndex; } } if (isExpand) { final groupController = boardController.controller(lastFocusedCard.groupId); if (groupController == null) { _focusFirstCard(); notifyListeners(); return true; } final iterable = groupController.items .skipWhile((item) => item.id != lastFocusedCard.rowId); if (iterable.length == 1) { // focus on the first card in the next group final group = boardController.groupDatas .skipWhile((item) => item.id != lastFocusedCard.groupId) .skip(1) .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); if (group != null) { _focusedCards.add( GroupedRowId( rowId: group.items.first.id, groupId: group.id, ), ); } } else { _focusedCards.add( GroupedRowId( rowId: iterable.elementAt(1).id, groupId: lastFocusedCard.groupId, ), ); } } else { _focusedCards.removeLast(); } notifyListeners(); return true; } bool adjustRangeUp() { _deepCopy(); // if no card is focused, focus on the first card in the board if (_focusedCards.isEmpty) { _focusLastCard(); notifyListeners(); return true; } final firstFocusedCard = _focusedCards.first; final lastFocusedCard = _focusedCards.last; // determine whether to shrink or expand the selection bool isExpand = false; if (_focusedCards.length == 1) { isExpand = true; } else { final firstGroupIndex = boardController.groupDatas .indexWhere((element) => element.id == firstFocusedCard.groupId); final lastGroupIndex = boardController.groupDatas .indexWhere((element) => element.id == lastFocusedCard.groupId); if (firstGroupIndex == -1 || lastGroupIndex == -1) { _focusLastCard(); notifyListeners(); return true; } if (firstGroupIndex < lastGroupIndex) { isExpand = false; } else if (firstGroupIndex > lastGroupIndex) { isExpand = true; } else { final groupItems = boardController.groupDatas.elementAt(firstGroupIndex).items; final firstCardIndex = groupItems.indexWhere((item) => item.id == firstFocusedCard.rowId); final lastCardIndex = groupItems.indexWhere((item) => item.id == lastFocusedCard.rowId); if (firstCardIndex == -1 || lastCardIndex == -1) { _focusLastCard(); notifyListeners(); return true; } isExpand = firstCardIndex > lastCardIndex; } } if (isExpand) { final groupController = boardController.controller(lastFocusedCard.groupId); if (groupController == null) { _focusLastCard(); notifyListeners(); return true; } final iterable = groupController.items.reversed .skipWhile((item) => item.id != lastFocusedCard.rowId); if (iterable.length == 1) { // focus on the last card in the previous group final group = boardController.groupDatas.reversed .skipWhile((item) => item.id != lastFocusedCard.groupId) .skip(1) .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); if (group != null) { _focusedCards.add( GroupedRowId( rowId: group.items.last.id, groupId: group.id, ), ); } } else { _focusedCards.add( GroupedRowId( rowId: iterable.elementAt(1).id, groupId: lastFocusedCard.groupId, ), ); } } else { _focusedCards.removeLast(); } notifyListeners(); return true; } bool clear() { _deepCopy(); _focusedCards.clear(); notifyListeners(); return true; } void _focusFirstCard() { _focusedCards.clear(); final firstGroup = boardController.groupDatas .firstWhereOrNull((group) => group.items.isNotEmpty); final firstCard = firstGroup?.items.firstOrNull; if (firstCard != null) { _focusedCards .add(GroupedRowId(rowId: firstCard.id, groupId: firstGroup!.id)); } } void _focusLastCard() { _focusedCards.clear(); final lastGroup = boardController.groupDatas .lastWhereOrNull((group) => group.items.isNotEmpty); final lastCard = lastGroup?.items.lastOrNull; if (lastCard != null) { _focusedCards .add(GroupedRowId(rowId: lastCard.id, groupId: lastGroup!.id)); } } void _deepCopy() { _focusedCards = [..._focusedCards]; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart ================================================ import 'dart:io'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/board/group_ext.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class HiddenGroupsColumn extends StatelessWidget { const HiddenGroupsColumn({ super.key, required this.margin, required this.shrinkWrap, }); final EdgeInsets margin; final bool shrinkWrap; @override Widget build(BuildContext context) { final databaseController = context.read().databaseController; return BlocSelector( selector: (state) => state.maybeMap( orElse: () => null, ready: (value) => value.layoutSettings, ), builder: (context, layoutSettings) { if (layoutSettings == null) { return const SizedBox.shrink(); } final isCollapsed = layoutSettings.collapseHiddenGroups; final leftPadding = margin.left + context.read().paddingLeft; final leftPaddingWithMaxDocWidth = context .read() .paddingLeftWithMaxDocumentWidth; return AnimatedSize( alignment: AlignmentDirectional.topStart, curve: Curves.easeOut, duration: const Duration(milliseconds: 150), child: isCollapsed ? SizedBox( height: 50, child: Padding( padding: EdgeInsets.only( left: 80 + leftPaddingWithMaxDocWidth, right: 8, ), child: Center( child: _collapseExpandIcon(context, isCollapsed), ), ), ) : Container( width: 274 + leftPaddingWithMaxDocWidth, padding: EdgeInsets.only( left: leftPadding, right: margin.right + 4, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 50, child: Row( children: [ Expanded( child: FlowyText.medium( LocaleKeys.board_hiddenGroupSection_sectionTitle .tr(), overflow: TextOverflow.ellipsis, color: Theme.of(context).hintColor, ), ), _collapseExpandIcon(context, isCollapsed), ], ), ), _hiddenGroupList(databaseController), ], ), ), ); }, ); } Widget _hiddenGroupList(DatabaseController databaseController) { final hiddenGroupList = HiddenGroupList( shrinkWrap: shrinkWrap, databaseController: databaseController, ); return shrinkWrap ? hiddenGroupList : Expanded(child: hiddenGroupList); } Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) { return FlowyTooltip( message: isCollapsed ? LocaleKeys.board_hiddenGroupSection_expandTooltip.tr() : LocaleKeys.board_hiddenGroupSection_collapseTooltip.tr(), preferBelow: false, child: FlowyIconButton( width: 20, height: 20, iconColorOnHover: Theme.of(context).colorScheme.onSurface, onPressed: () => context .read() .add(BoardEvent.toggleHiddenSectionVisibility(!isCollapsed)), icon: FlowySvg( isCollapsed ? FlowySvgs.hamburger_s_s : FlowySvgs.pull_left_outlined_s, ), ), ); } } class HiddenGroupList extends StatelessWidget { const HiddenGroupList({ super.key, required this.databaseController, required this.shrinkWrap, }); final DatabaseController databaseController; final bool shrinkWrap; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return state.maybeMap( orElse: () => const SizedBox.shrink(), ready: (state) => ReorderableListView.builder( proxyDecorator: (child, index, animation) => Material( color: Colors.transparent, child: Stack( children: [ child, MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grabbing, child: const SizedBox.expand(), ), ], ), ), shrinkWrap: shrinkWrap, buildDefaultDragHandles: false, itemCount: state.hiddenGroups.length, itemBuilder: (_, index) => Padding( padding: const EdgeInsets.only(bottom: 4), key: ValueKey("hiddenGroup${state.hiddenGroups[index].groupId}"), child: HiddenGroupCard( group: state.hiddenGroups[index], index: index, bloc: context.read(), ), ), onReorder: (oldIndex, newIndex) { if (oldIndex < newIndex) { newIndex--; } final fromGroupId = state.hiddenGroups[oldIndex].groupId; final toGroupId = state.hiddenGroups[newIndex].groupId; context .read() .add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); }, ), ); }, ); } } class HiddenGroupCard extends StatefulWidget { const HiddenGroupCard({ super.key, required this.group, required this.index, required this.bloc, }); final GroupPB group; final BoardBloc bloc; final int index; @override State createState() => _HiddenGroupCardState(); } class _HiddenGroupCardState extends State { final PopoverController _popoverController = PopoverController(); @override Widget build(BuildContext context) { final databaseController = widget.bloc.databaseController; final primaryField = databaseController.fieldController.fieldInfos .firstWhereOrNull((element) => element.isPrimary)!; return AppFlowyPopover( controller: _popoverController, direction: PopoverDirection.bottomWithCenterAligned, triggerActions: PopoverTriggerFlags.none, constraints: const BoxConstraints(maxWidth: 234, maxHeight: 300), popupBuilder: (popoverContext) { return BlocProvider.value( value: context.read(), child: HiddenGroupPopupItemList( viewId: databaseController.viewId, groupId: widget.group.groupId, primaryFieldId: primaryField.id, rowCache: databaseController.rowCache, ), ); }, child: HiddenGroupButtonContent( popoverController: _popoverController, groupId: widget.group.groupId, index: widget.index, bloc: widget.bloc, ), ); } } class HiddenGroupButtonContent extends StatelessWidget { const HiddenGroupButtonContent({ super.key, required this.popoverController, required this.groupId, required this.index, required this.bloc, }); final PopoverController popoverController; final String groupId; final int index; final BoardBloc bloc; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 8.0), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: popoverController.show, child: FlowyHover( builder: (context, isHovering) { return BlocProvider.value( value: bloc, child: BlocBuilder( builder: (context, state) { return state.maybeMap( orElse: () => const SizedBox.shrink(), ready: (state) { final group = state.hiddenGroups.firstWhereOrNull( (g) => g.groupId == groupId, ); if (group == null) { return const SizedBox.shrink(); } return SizedBox( height: 32, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 3, ), child: Row( children: [ HiddenGroupCardActions( isVisible: isHovering, index: index, ), const HSpace(4), Expanded( child: Row( children: [ Flexible( child: FlowyText( group.generateGroupName( bloc.databaseController, ), overflow: TextOverflow.ellipsis, ), ), const HSpace(6), FlowyText( group.rows.length.toString(), overflow: TextOverflow.ellipsis, color: Theme.of(context).hintColor, ), ], ), ), if (isHovering) ...[ const HSpace(6), FlowyIconButton( width: 20, icon: const FlowySvg( FlowySvgs.show_m, size: Size.square(16), ), onPressed: () => context.read().add( BoardEvent.setGroupVisibility( group, true, ), ), ), ], ], ), ), ); }, ); }, ), ); }, ), ), ); } } class HiddenGroupCardActions extends StatelessWidget { const HiddenGroupCardActions({ super.key, required this.isVisible, required this.index, }); final bool isVisible; final int index; @override Widget build(BuildContext context) { return ReorderableDragStartListener( index: index, enabled: isVisible, child: MouseRegion( cursor: SystemMouseCursors.grab, child: SizedBox( height: 14, width: 14, child: isVisible ? FlowySvg( FlowySvgs.drag_element_s, color: Theme.of(context).hintColor, ) : const SizedBox.shrink(), ), ), ); } } class HiddenGroupPopupItemList extends StatelessWidget { const HiddenGroupPopupItemList({ super.key, required this.groupId, required this.viewId, required this.primaryFieldId, required this.rowCache, }); final String groupId; final String viewId; final String primaryFieldId; final RowCache rowCache; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return state.maybeMap( orElse: () => const SizedBox.shrink(), ready: (state) { final group = state.hiddenGroups.firstWhereOrNull( (g) => g.groupId == groupId, ); if (group == null) { return const SizedBox.shrink(); } final bloc = context.read(); final cells = [ Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: FlowyText( group.generateGroupName(bloc.databaseController), fontSize: 10, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ), ), ...group.rows.map( (item) { final rowController = RowController( rowMeta: item, viewId: viewId, rowCache: rowCache, ); rowController.initialize(); final databaseController = context.read().databaseController; return HiddenGroupPopupItem( cellContext: rowCache.loadCells(item).firstWhere( (cellContext) => cellContext.fieldId == primaryFieldId, ), rowController: rowController, rowMeta: item, cellBuilder: CardCellBuilder( databaseController: databaseController, ), onPressed: () { FlowyOverlay.show( context: context, builder: (_) => BlocProvider.value( value: context.read(), child: RowDetailPage( databaseController: databaseController, rowController: rowController, userProfile: context.read().userProfile, ), ), ); PopoverContainer.of(context).close(); }, ); }, ), ]; return ListView.separated( itemBuilder: (context, index) => cells[index], itemCount: cells.length, separatorBuilder: (context, index) => VSpace(GridSize.typeOptionSeparatorHeight), shrinkWrap: true, ); }, ); }, ); } } class HiddenGroupPopupItem extends StatelessWidget { const HiddenGroupPopupItem({ super.key, required this.rowMeta, required this.cellContext, required this.onPressed, required this.cellBuilder, required this.rowController, }); final RowMetaPB rowMeta; final CellContext cellContext; final RowController rowController; final CardCellBuilder cellBuilder; final VoidCallback onPressed; @override Widget build(BuildContext context) { return SizedBox( height: 26, child: FlowyButton( margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), text: cellBuilder.build( cellContext: cellContext, styleMap: {FieldType.RichText: _titleCellStyle(context)}, hasNotes: false, ), onTap: onPressed, ), ); } TextCardCellStyle _titleCellStyle(BuildContext context) { return TextCardCellStyle( padding: EdgeInsets.zero, textStyle: Theme.of(context).textTheme.bodyMedium!, titleTextStyle: Theme.of(context) .textTheme .bodyMedium! .copyWith(fontSize: 11, overflow: TextOverflow.ellipsis), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_shortcut_container.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/board/application/board_actions_bloc.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/shared/callback_shortcuts.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'board_focus_scope.dart'; class BoardShortcutContainer extends StatelessWidget { const BoardShortcutContainer({ super.key, required this.focusScope, required this.child, }); final BoardFocusScope focusScope; final Widget child; @override Widget build(BuildContext context) { return AFCallbackShortcuts( bindings: _shortcutBindings(context), child: FocusScope( child: Focus( child: Builder( builder: (context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { final focusNode = Focus.of(context); focusNode.requestFocus(); focusScope.clear(); }, child: child, ); }, ), ), ), ); } Map _shortcutBindings( BuildContext context, ) { return { const SingleActivator(LogicalKeyboardKey.arrowUp): focusScope.focusPrevious, const SingleActivator(LogicalKeyboardKey.arrowDown): focusScope.focusNext, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): focusScope.adjustRangeUp, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): focusScope.adjustRangeDown, const SingleActivator(LogicalKeyboardKey.escape): focusScope.clear, const SingleActivator(LogicalKeyboardKey.delete): () => _removeHandler(context), const SingleActivator(LogicalKeyboardKey.backspace): () => _removeHandler(context), SingleActivator( LogicalKeyboardKey.arrowUp, shift: true, meta: Platform.isMacOS, control: !Platform.isMacOS, ): () => _shiftCmdUpHandler(context), const SingleActivator(LogicalKeyboardKey.enter): () => _enterHandler(context), const SingleActivator(LogicalKeyboardKey.numpadEnter): () => _enterHandler(context), const SingleActivator(LogicalKeyboardKey.enter, shift: true): () => _shiftEnterHandler(context), const SingleActivator(LogicalKeyboardKey.comma): () => _moveGroupToAdjacentGroup(context, true), const SingleActivator(LogicalKeyboardKey.period): () => _moveGroupToAdjacentGroup(context, false), const SingleActivator(LogicalKeyboardKey.keyE): () => _keyEHandler(context), const SingleActivator(LogicalKeyboardKey.keyN): () => _keyNHandler(context), }; } bool _keyEHandler(BuildContext context) { if (focusScope.value.length != 1) { return false; } context.read().startEditingRow(focusScope.value.first); return true; } bool _keyNHandler(BuildContext context) { if (focusScope.value.length != 1) { return false; } context .read() .startCreateBottomRow(focusScope.value.first.groupId); focusScope.clear(); return true; } bool _enterHandler(BuildContext context) { if (focusScope.value.length != 1) { return false; } context .read() .openCardWithRowId(focusScope.value.first.rowId); return true; } bool _shiftEnterHandler(BuildContext context) { if (focusScope.value.isEmpty) { context .read() .createRow(null, CreateBoardCardRelativePosition.after); } else if (focusScope.value.length == 1) { context.read().createRow( focusScope.value.first, CreateBoardCardRelativePosition.after, ); } else { return false; } return true; } bool _shiftCmdUpHandler(BuildContext context) { if (focusScope.value.isEmpty) { context .read() .createRow(null, CreateBoardCardRelativePosition.before); } else if (focusScope.value.length == 1) { context.read().createRow( focusScope.value.first, CreateBoardCardRelativePosition.before, ); } else { return false; } return true; } bool _removeHandler(BuildContext context) { if (focusScope.value.length != 1) { return false; } NavigatorOkCancelDialog( message: LocaleKeys.grid_row_deleteCardPrompt.tr(), onOkPressed: () { context.read().add(BoardEvent.deleteCards(focusScope.value)); }, ).show(context); return true; } bool _moveGroupToAdjacentGroup(BuildContext context, bool toPrevious) { if (focusScope.value.length != 1) { return false; } context.read().add( BoardEvent.moveGroupToAdjacentGroup( focusScope.value.first, toPrevious, ), ); focusScope.clear(); return true; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/board/tests/integrate_test/card_test.dart ================================================ // import 'package:flutter_test/flutter_test.dart'; // import 'package:integration_test/integration_test.dart'; // import 'package:appflowy/main.dart' as app; // void main() { // IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // group('end-to-end test', () { // testWidgets('tap on the floating action button, verify counter', // (tester) async { // app.main(); // await tester.pumpAndSettle(); // }); // }); // } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_cache.dart'; import 'package:appflowy/plugins/database/application/defines.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../application/database_controller.dart'; import '../../application/row/row_cache.dart'; part 'calendar_bloc.freezed.dart'; class CalendarBloc extends Bloc { CalendarBloc({required this.databaseController}) : super(CalendarState.initial()) { _dispatch(); } final DatabaseController databaseController; Map fieldInfoByFieldId = {}; // Getters String get viewId => databaseController.viewId; FieldController get fieldController => databaseController.fieldController; CellMemCache get cellCache => databaseController.rowCache.cellCache; RowCache get rowCache => databaseController.rowCache; UserProfilePB? _userProfile; UserProfilePB? get userProfile => _userProfile; DatabaseCallbacks? _databaseCallbacks; DatabaseLayoutSettingCallbacks? _layoutSettingCallbacks; @override Future close() async { databaseController.removeListener( onDatabaseChanged: _databaseCallbacks, onLayoutSettingsChanged: _layoutSettingCallbacks, ); _databaseCallbacks = null; _layoutSettingCallbacks = null; await super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { final result = await UserEventGetUserProfile().send(); result.fold( (profile) => _userProfile = profile, (err) => Log.error('Failed to get user profile: $err'), ); _startListening(); await _openDatabase(emit); _loadAllEvents(); }, didReceiveCalendarSettings: (CalendarLayoutSettingPB settings) { // If the field id changed, reload all events if (state.settings?.fieldId != settings.fieldId) { _loadAllEvents(); } emit(state.copyWith(settings: settings)); }, didReceiveDatabaseUpdate: (DatabasePB database) { emit(state.copyWith(database: database)); }, didLoadAllEvents: (events) { final calenderEvents = _calendarEventDataFromEventPBs(events); emit( state.copyWith( initialEvents: calenderEvents, allEvents: calenderEvents, ), ); }, createEvent: (DateTime date) async { await _createEvent(date); }, duplicateEvent: (String viewId, String rowId) async { final result = await RowBackendService.duplicateRow(viewId, rowId); result.fold( (_) => null, (e) => Log.error('Failed to duplicate event: $e', e), ); }, deleteEvent: (String viewId, String rowId) async { final result = await RowBackendService.deleteRows(viewId, [rowId]); result.fold( (_) => null, (e) => Log.error('Failed to delete event: $e', e), ); }, newEventPopupDisplayed: () { emit(state.copyWith(editingEvent: null)); }, moveEvent: (CalendarDayEvent event, DateTime date) async { await _moveEvent(event, date); }, didCreateEvent: (CalendarEventData event) { emit(state.copyWith(editingEvent: event)); }, updateCalendarLayoutSetting: (CalendarLayoutSettingPB layoutSetting) async { await _updateCalendarLayoutSetting(layoutSetting); }, didUpdateEvent: (CalendarEventData eventData) { final allEvents = [...state.allEvents]; final index = allEvents.indexWhere( (element) => element.event!.eventId == eventData.event!.eventId, ); if (index != -1) { allEvents[index] = eventData; } emit(state.copyWith(allEvents: allEvents, updateEvent: eventData)); }, didDeleteEvents: (List deletedRowIds) { final events = [...state.allEvents]; events.retainWhere( (element) => !deletedRowIds.contains(element.event!.eventId), ); emit( state.copyWith( allEvents: events, deleteEventIds: deletedRowIds, ), ); emit(state.copyWith(deleteEventIds: const [])); }, didReceiveEvent: (CalendarEventData event) { emit( state.copyWith( allEvents: [...state.allEvents, event], newEvent: event, ), ); emit(state.copyWith(newEvent: null)); }, openRowDetail: (row) { emit(state.copyWith(openRow: row)); emit(state.copyWith(openRow: null)); }, ); }, ); } FieldInfo? _getCalendarFieldInfo(String fieldId) { final fieldInfos = databaseController.fieldController.fieldInfos; final index = fieldInfos.indexWhere( (element) => element.field.id == fieldId, ); if (index != -1) { return fieldInfos[index]; } else { return null; } } Future _openDatabase(Emitter emit) async { final result = await databaseController.open(); result.fold( (database) { databaseController.setIsLoading(false); emit( state.copyWith( loadingState: LoadingState.finish(FlowyResult.success(null)), ), ); }, (err) => emit( state.copyWith( loadingState: LoadingState.finish(FlowyResult.failure(err)), ), ), ); } Future _createEvent(DateTime date) async { final settings = state.settings; if (settings == null) { Log.warn('Calendar settings not found'); return; } final dateField = _getCalendarFieldInfo(settings.fieldId); if (dateField != null) { final newRow = await RowBackendService.createRow( viewId: viewId, withCells: (builder) => builder.insertDate(dateField, date), ).then( (result) => result.fold( (newRow) => newRow, (err) { Log.error(err); return null; }, ), ); if (newRow != null) { final event = await _loadEvent(newRow.id); if (event != null && !isClosed) { add(CalendarEvent.didCreateEvent(event)); } } } } Future _moveEvent(CalendarDayEvent event, DateTime date) async { final timestamp = _eventTimestamp(event, date); final payload = MoveCalendarEventPB( cellPath: CellIdPB( viewId: viewId, rowId: event.eventId, fieldId: event.dateFieldId, ), timestamp: timestamp, ); return DatabaseEventMoveCalendarEvent(payload).send().then((result) { return result.fold( (_) async { final modifiedEvent = await _loadEvent(event.eventId); add(CalendarEvent.didUpdateEvent(modifiedEvent!)); }, (err) { Log.error(err); return null; }, ); }); } Future _updateCalendarLayoutSetting( CalendarLayoutSettingPB layoutSetting, ) async { return databaseController.updateLayoutSetting( calendarLayoutSetting: layoutSetting, ); } Future?> _loadEvent(RowId rowId) async { final payload = DatabaseViewRowIdPB(viewId: viewId, rowId: rowId); return DatabaseEventGetCalendarEvent(payload).send().fold( (eventPB) => _calendarEventDataFromEventPB(eventPB), (r) { Log.error(r); return null; }, ); } void _loadAllEvents() async { final payload = CalendarEventRequestPB.create()..viewId = viewId; final result = await DatabaseEventGetAllCalendarEvents(payload).send(); result.fold( (events) { if (!isClosed) { add(CalendarEvent.didLoadAllEvents(events.items)); } }, (r) => Log.error(r), ); } List> _calendarEventDataFromEventPBs( List eventPBs, ) { final calendarEvents = >[]; for (final eventPB in eventPBs) { final event = _calendarEventDataFromEventPB(eventPB); if (event != null) { calendarEvents.add(event); } } return calendarEvents; } CalendarEventData? _calendarEventDataFromEventPB( CalendarEventPB eventPB, ) { final fieldInfo = fieldInfoByFieldId[eventPB.dateFieldId]; if (fieldInfo == null) { return null; } // timestamp is stored as seconds, but constructor requires milliseconds final date = DateTime.fromMillisecondsSinceEpoch( eventPB.timestamp.toInt() * 1000, ); final eventData = CalendarDayEvent( event: eventPB, eventId: eventPB.rowMeta.id, dateFieldId: eventPB.dateFieldId, date: date, ); return CalendarEventData( title: eventPB.title, date: date, event: eventData, ); } void _startListening() { _databaseCallbacks = DatabaseCallbacks( onDatabaseChanged: (database) { if (isClosed) return; }, onFieldsChanged: (fieldInfos) { if (isClosed) { return; } fieldInfoByFieldId = { for (final fieldInfo in fieldInfos) fieldInfo.field.id: fieldInfo, }; }, onRowsCreated: (rows) async { if (isClosed) { return; } for (final row in rows) { if (row.isHiddenInView) { add(CalendarEvent.openRowDetail(row.rowMeta)); } else { final event = await _loadEvent(row.rowMeta.id); if (event != null) { add(CalendarEvent.didReceiveEvent(event)); } } } }, onRowsDeleted: (rowIds) { if (isClosed) { return; } add(CalendarEvent.didDeleteEvents(rowIds)); }, onRowsUpdated: (rowIds, reason) async { if (isClosed) { return; } for (final id in rowIds) { final event = await _loadEvent(id); if (event != null) { if (isEventDayChanged(event)) { add(CalendarEvent.didDeleteEvents([id])); add(CalendarEvent.didReceiveEvent(event)); } else { add(CalendarEvent.didUpdateEvent(event)); } } } }, onNumOfRowsChanged: (rows, rowById, reason) { reason.maybeWhen( updateRowsVisibility: (changeset) async { if (isClosed) { return; } for (final id in changeset.invisibleRows) { if (_containsEvent(id)) { add(CalendarEvent.didDeleteEvents([id])); } } for (final row in changeset.visibleRows) { final id = row.rowMeta.id; if (!_containsEvent(id)) { final event = await _loadEvent(id); if (event != null) { add(CalendarEvent.didReceiveEvent(event)); } } } }, orElse: () {}, ); }, ); _layoutSettingCallbacks = DatabaseLayoutSettingCallbacks( onLayoutSettingsChanged: _didReceiveLayoutSetting, ); databaseController.addListener( onDatabaseChanged: _databaseCallbacks, onLayoutSettingsChanged: _layoutSettingCallbacks, ); } void _didReceiveLayoutSetting(DatabaseLayoutSettingPB layoutSetting) { if (layoutSetting.hasCalendar()) { if (isClosed) { return; } add(CalendarEvent.didReceiveCalendarSettings(layoutSetting.calendar)); } } bool isEventDayChanged(CalendarEventData event) { final index = state.allEvents.indexWhere( (element) => element.event!.eventId == event.event!.eventId, ); if (index == -1) { return false; } return state.allEvents[index].date.day != event.date.day; } bool _containsEvent(String rowId) { return state.allEvents.any((element) => element.event!.eventId == rowId); } Int64 _eventTimestamp(CalendarDayEvent event, DateTime date) { final time = event.date.hour * 3600 + event.date.minute * 60 + event.date.second; return Int64(date.millisecondsSinceEpoch ~/ 1000 + time); } } typedef Events = List>; @freezed class CalendarEvent with _$CalendarEvent { const factory CalendarEvent.initial() = _InitialCalendar; // Called after loading the calendar layout setting from the backend const factory CalendarEvent.didReceiveCalendarSettings( CalendarLayoutSettingPB settings, ) = _ReceiveCalendarSettings; // Called after loading all the current evnets const factory CalendarEvent.didLoadAllEvents(List events) = _ReceiveCalendarEvents; // Called when specific event was updated const factory CalendarEvent.didUpdateEvent( CalendarEventData event, ) = _DidUpdateEvent; // Called after creating a new event const factory CalendarEvent.didCreateEvent( CalendarEventData event, ) = _DidReceiveNewEvent; // Called after creating a new event const factory CalendarEvent.newEventPopupDisplayed() = _NewEventPopupDisplayed; // Called when receive a new event const factory CalendarEvent.didReceiveEvent( CalendarEventData event, ) = _DidReceiveEvent; // Called when deleting events const factory CalendarEvent.didDeleteEvents(List rowIds) = _DidDeleteEvents; // Called when creating a new event const factory CalendarEvent.createEvent(DateTime date) = _CreateEvent; // Called when moving an event const factory CalendarEvent.moveEvent(CalendarDayEvent event, DateTime date) = _MoveEvent; // Called when updating the calendar's layout settings const factory CalendarEvent.updateCalendarLayoutSetting( CalendarLayoutSettingPB layoutSetting, ) = _UpdateCalendarLayoutSetting; const factory CalendarEvent.didReceiveDatabaseUpdate(DatabasePB database) = _ReceiveDatabaseUpdate; const factory CalendarEvent.duplicateEvent(String viewId, String rowId) = _DuplicateEvent; const factory CalendarEvent.deleteEvent(String viewId, String rowId) = _DeleteEvent; const factory CalendarEvent.openRowDetail(RowMetaPB row) = _OpenRowDetail; } @freezed class CalendarState with _$CalendarState { const factory CalendarState({ required DatabasePB? database, // events by row id required Events allEvents, required Events initialEvents, CalendarEventData? editingEvent, CalendarEventData? newEvent, CalendarEventData? updateEvent, required List deleteEventIds, required CalendarLayoutSettingPB? settings, required RowMetaPB? openRow, required LoadingState loadingState, required FlowyError? noneOrError, }) = _CalendarState; factory CalendarState.initial() => const CalendarState( database: null, allEvents: [], initialEvents: [], deleteEventIds: [], settings: null, openRow: null, noneOrError: null, loadingState: LoadingState.loading(), ); } @freezed class CalendarDayEvent with _$CalendarDayEvent { const factory CalendarDayEvent({ required CalendarEventPB event, required String dateFieldId, required String eventId, required DateTime date, }) = _CalendarDayEvent; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'calendar_event_editor_bloc.freezed.dart'; class CalendarEventEditorBloc extends Bloc { CalendarEventEditorBloc({ required this.fieldController, required this.rowController, required this.layoutSettings, }) : super(CalendarEventEditorState.initial()) { _dispatch(); } final FieldController fieldController; final RowController rowController; final CalendarLayoutSettingPB layoutSettings; void _dispatch() { on( (event, emit) async { await event.when( initial: () { rowController.initialize(); _startListening(); final primaryFieldId = fieldController.fieldInfos .firstWhere((fieldInfo) => fieldInfo.isPrimary) .id; final cells = rowController .loadCells() .where( (cellContext) => _filterCellContext(cellContext, primaryFieldId), ) .toList(); add(CalendarEventEditorEvent.didReceiveCellDatas(cells)); }, didReceiveCellDatas: (cells) { emit(state.copyWith(cells: cells)); }, delete: () async { final result = await RowBackendService.deleteRows( rowController.viewId, [rowController.rowId], ); result.fold((l) => null, (err) => Log.error(err)); }, ); }, ); } void _startListening() { rowController.addListener( onRowChanged: (cells, reason) { if (isClosed) { return; } final primaryFieldId = fieldController.fieldInfos .firstWhere((fieldInfo) => fieldInfo.isPrimary) .id; final cellData = cells .where( (cellContext) => _filterCellContext(cellContext, primaryFieldId), ) .toList(); add(CalendarEventEditorEvent.didReceiveCellDatas(cellData)); }, ); } bool _filterCellContext(CellContext cellContext, String primaryFieldId) { return fieldController .getField(cellContext.fieldId)! .fieldSettings! .visibility .isVisibleState() || cellContext.fieldId == layoutSettings.fieldId || cellContext.fieldId == primaryFieldId; } @override Future close() async { await rowController.dispose(); return super.close(); } } @freezed class CalendarEventEditorEvent with _$CalendarEventEditorEvent { const factory CalendarEventEditorEvent.initial() = _Initial; const factory CalendarEventEditorEvent.didReceiveCellDatas( List cells, ) = _DidReceiveCellDatas; const factory CalendarEventEditorEvent.delete() = _Delete; } @freezed class CalendarEventEditorState with _$CalendarEventEditorState { const factory CalendarEventEditorState({ required List cells, }) = _CalendarEventEditorState; factory CalendarEventEditorState.initial() => const CalendarEventEditorState(cells: []); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_setting_bloc.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/domain/layout_setting_listener.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; part 'calendar_setting_bloc.freezed.dart'; class CalendarSettingBloc extends Bloc { CalendarSettingBloc({required DatabaseController databaseController}) : _databaseController = databaseController, _listener = DatabaseLayoutSettingListener(databaseController.viewId), super( CalendarSettingState.initial( databaseController.databaseLayoutSetting?.calendar, ), ) { _dispatch(); } final DatabaseController _databaseController; final DatabaseLayoutSettingListener _listener; @override Future close() async { await _listener.stop(); return super.close(); } void _dispatch() { on((event, emit) { event.when( initial: () { _startListening(); }, didUpdateLayoutSetting: (CalendarLayoutSettingPB setting) { emit(state.copyWith(layoutSetting: layoutSetting)); }, updateLayoutSetting: ( bool? showWeekends, bool? showWeekNumbers, int? firstDayOfWeek, String? layoutFieldId, ) { _updateLayoutSettings( showWeekends, showWeekNumbers, firstDayOfWeek, layoutFieldId, emit, ); }, ); }); } void _updateLayoutSettings( bool? showWeekends, bool? showWeekNumbers, int? firstDayOfWeek, String? layoutFieldId, Emitter emit, ) { final currentSetting = state.layoutSetting; if (currentSetting == null) { return; } currentSetting.freeze(); final newSetting = currentSetting.rebuild((setting) { if (showWeekends != null) { setting.showWeekends = !showWeekends; } if (showWeekNumbers != null) { setting.showWeekNumbers = !showWeekNumbers; } if (firstDayOfWeek != null) { setting.firstDayOfWeek = firstDayOfWeek; } if (layoutFieldId != null) { setting.fieldId = layoutFieldId; } }); _databaseController.updateLayoutSetting( calendarLayoutSetting: newSetting, ); emit(state.copyWith(layoutSetting: newSetting)); } CalendarLayoutSettingPB? get layoutSetting => _databaseController.databaseLayoutSetting?.calendar; void _startListening() { _listener.start( onLayoutChanged: (result) { if (isClosed) { return; } result.fold( (setting) => add( CalendarSettingEvent.didUpdateLayoutSetting(setting.calendar), ), (r) => Log.error(r), ); }, ); } } @freezed class CalendarSettingState with _$CalendarSettingState { const factory CalendarSettingState({ required CalendarLayoutSettingPB? layoutSetting, }) = _CalendarSettingState; factory CalendarSettingState.initial( CalendarLayoutSettingPB? layoutSettings, ) { return CalendarSettingState(layoutSetting: layoutSettings); } } @freezed class CalendarSettingEvent with _$CalendarSettingEvent { const factory CalendarSettingEvent.initial() = _Initial; const factory CalendarSettingEvent.didUpdateLayoutSetting( CalendarLayoutSettingPB setting, ) = _DidUpdateLayoutSetting; const factory CalendarSettingEvent.updateLayoutSetting({ bool? showWeekends, bool? showWeekNumbers, int? firstDayOfWeek, String? layoutFieldId, }) = _UpdateLayoutSetting; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_cache.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../application/database_controller.dart'; import '../../application/row/row_cache.dart'; part 'unschedule_event_bloc.freezed.dart'; class UnscheduleEventsBloc extends Bloc { UnscheduleEventsBloc({required this.databaseController}) : super(UnscheduleEventsState.initial()) { _dispatch(); } final DatabaseController databaseController; Map fieldInfoByFieldId = {}; // Getters String get viewId => databaseController.viewId; FieldController get fieldController => databaseController.fieldController; CellMemCache get cellCache => databaseController.rowCache.cellCache; RowCache get rowCache => databaseController.rowCache; DatabaseCallbacks? _databaseCallbacks; @override Future close() async { databaseController.removeListener(onDatabaseChanged: _databaseCallbacks); _databaseCallbacks = null; await super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { _startListening(); _loadAllEvents(); }, didLoadAllEvents: (events) { emit( state.copyWith( allEvents: events, unscheduleEvents: events.where((element) => !element.hasTimestamp()).toList(), ), ); }, didDeleteEvents: (List deletedRowIds) { final events = [...state.allEvents]; events.retainWhere( (element) => !deletedRowIds.contains(element.rowMeta.id), ); emit( state.copyWith( allEvents: events, unscheduleEvents: events.where((element) => !element.hasTimestamp()).toList(), ), ); }, didReceiveEvent: (CalendarEventPB event) { final events = [...state.allEvents, event]; emit( state.copyWith( allEvents: events, unscheduleEvents: events.where((element) => !element.hasTimestamp()).toList(), ), ); }, ); }, ); } Future _loadEvent( RowId rowId, ) async { final payload = DatabaseViewRowIdPB(viewId: viewId, rowId: rowId); return DatabaseEventGetCalendarEvent(payload).send().then( (result) => result.fold( (eventPB) => eventPB, (r) { Log.error(r); return null; }, ), ); } void _loadAllEvents() async { final payload = CalendarEventRequestPB.create()..viewId = viewId; final result = await DatabaseEventGetAllCalendarEvents(payload).send(); result.fold( (events) { if (!isClosed) { add(UnscheduleEventsEvent.didLoadAllEvents(events.items)); } }, (r) => Log.error(r), ); } void _startListening() { _databaseCallbacks = DatabaseCallbacks( onRowsCreated: (rows) async { if (isClosed) { return; } for (final row in rows) { final event = await _loadEvent(row.rowMeta.id); if (event != null && !isClosed) { add(UnscheduleEventsEvent.didReceiveEvent(event)); } } }, onRowsDeleted: (rowIds) { if (isClosed) { return; } add(UnscheduleEventsEvent.didDeleteEvents(rowIds)); }, onRowsUpdated: (rowIds, reason) async { if (isClosed) { return; } for (final id in rowIds) { final event = await _loadEvent(id); if (event != null) { add(UnscheduleEventsEvent.didDeleteEvents([id])); add(UnscheduleEventsEvent.didReceiveEvent(event)); } } }, ); databaseController.addListener(onDatabaseChanged: _databaseCallbacks); } } @freezed class UnscheduleEventsEvent with _$UnscheduleEventsEvent { const factory UnscheduleEventsEvent.initial() = _InitialCalendar; // Called after loading all the current evnets const factory UnscheduleEventsEvent.didLoadAllEvents( List events, ) = _ReceiveUnscheduleEventsEvents; const factory UnscheduleEventsEvent.didDeleteEvents(List rowIds) = _DidDeleteEvents; const factory UnscheduleEventsEvent.didReceiveEvent( CalendarEventPB event, ) = _DidReceiveEvent; } @freezed class UnscheduleEventsState with _$UnscheduleEventsState { const factory UnscheduleEventsState({ required DatabasePB? database, required List allEvents, required List unscheduleEvents, }) = _UnscheduleEventsState; factory UnscheduleEventsState.initial() => const UnscheduleEventsState( database: null, allEvents: [], unscheduleEvents: [], ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; class CalendarPluginBuilder extends PluginBuilder { @override Plugin build(dynamic data) { if (data is ViewPB) { return DatabaseTabBarViewPlugin(pluginType: pluginType, view: data); } else { throw FlowyPluginException.invalidData; } } @override String get menuName => LocaleKeys.calendar_menuName.tr(); @override FlowySvgData get icon => FlowySvgs.icon_calendar_s; @override PluginType get pluginType => PluginType.calendar; @override ViewLayoutPB get layoutType => ViewLayoutPB.Calendar; } class CalendarPluginConfig implements PluginConfig { @override bool get creatable => true; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_screen.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/time/duration.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../grid/presentation/layout/sizes.dart'; import '../application/calendar_bloc.dart'; import 'calendar_event_card.dart'; class CalendarDayCard extends StatelessWidget { const CalendarDayCard({ super.key, required this.viewId, required this.isToday, required this.isInMonth, required this.date, required this.rowCache, required this.events, required this.onCreateEvent, required this.position, }); final String viewId; final bool isToday; final bool isInMonth; final DateTime date; final RowCache rowCache; final List events; final void Function(DateTime) onCreateEvent; final CellPosition position; @override Widget build(BuildContext context) { final hoverBackgroundColor = Theme.of(context).brightness == Brightness.light ? Theme.of(context).colorScheme.secondaryContainer : Colors.transparent; return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return ChangeNotifierProvider( create: (_) => _CardEnterNotifier(), builder: (context, child) { final child = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ _Header( date: date, isInMonth: isInMonth, isToday: isToday, ), // Add a separator between the header and the content. const VSpace(6.0), // List of cards or empty space if (events.isNotEmpty && !UniversalPlatform.isMobile) ...[ _EventList( events: events, viewId: viewId, rowCache: rowCache, constraints: constraints, ), ] else if (events.isNotEmpty && UniversalPlatform.isMobile) ...[ const _EventIndicator(), ], ], ); return Stack( children: [ GestureDetector( onDoubleTap: () => onCreateEvent(date), onTap: UniversalPlatform.isMobile ? () => _mobileOnTap(context) : null, child: Container( decoration: BoxDecoration( color: date.isWeekend ? AFThemeExtension.of(context).calendarWeekendBGColor : Colors.transparent, border: _borderFromPosition(context, position), ), ), ), DragTarget( builder: (context, candidate, __) { return Stack( children: [ Container( width: double.infinity, height: double.infinity, color: candidate.isEmpty ? null : hoverBackgroundColor, padding: const EdgeInsets.only(top: 5.0), child: child, ), if (candidate.isEmpty && !UniversalPlatform.isMobile) NewEventButton( onCreate: () => onCreateEvent(date), ), ], ); }, onAcceptWithDetails: (details) { final event = details.data; if (event.date != date) { context .read() .add(CalendarEvent.moveEvent(event, date)); } }, ), MouseRegion( onEnter: (p) => notifyEnter(context, true), onExit: (p) => notifyEnter(context, false), opaque: false, hitTestBehavior: HitTestBehavior.translucent, ), ], ); }, ); }, ); } void _mobileOnTap(BuildContext context) { context.push( MobileCalendarEventsScreen.routeName, extra: { MobileCalendarEventsScreen.calendarBlocKey: context.read(), MobileCalendarEventsScreen.calendarDateKey: date, MobileCalendarEventsScreen.calendarEventsKey: events, MobileCalendarEventsScreen.calendarRowCacheKey: rowCache, MobileCalendarEventsScreen.calendarViewIdKey: viewId, }, ); } bool notifyEnter(BuildContext context, bool isEnter) => Provider.of<_CardEnterNotifier>(context, listen: false).onEnter = isEnter; Border _borderFromPosition(BuildContext context, CellPosition position) { final BorderSide borderSide = BorderSide(color: Theme.of(context).dividerColor); return Border( top: borderSide, left: borderSide, bottom: [ CellPosition.bottom, CellPosition.bottomLeft, CellPosition.bottomRight, ].contains(position) ? borderSide : BorderSide.none, right: [ CellPosition.topRight, CellPosition.bottomRight, CellPosition.right, ].contains(position) ? borderSide : BorderSide.none, ); } } class _EventIndicator extends StatelessWidget { const _EventIndicator(); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 7, height: 7, decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of(context).hintColor, ), ), ], ); } } class _Header extends StatelessWidget { const _Header({ required this.isToday, required this.isInMonth, required this.date, }); final bool isToday; final bool isInMonth; final DateTime date; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0), child: _DayBadge(isToday: isToday, isInMonth: isInMonth, date: date), ); } } @visibleForTesting class NewEventButton extends StatelessWidget { const NewEventButton({super.key, required this.onCreate}); final VoidCallback onCreate; @override Widget build(BuildContext context) { return Consumer<_CardEnterNotifier>( builder: (context, notifier, _) { if (!notifier.onEnter) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.all(4.0), child: FlowyIconButton( onPressed: onCreate, icon: const FlowySvg(FlowySvgs.add_s), fillColor: Theme.of(context).colorScheme.surface, hoverColor: AFThemeExtension.of(context).lightGreyHover, width: 22, tooltipText: LocaleKeys.calendar_newEventButtonTooltip.tr(), radius: Corners.s6Border, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide( color: Theme.of(context).brightness == Brightness.light ? const Color(0xffd0d3d6) : const Color(0xff59647a), width: 0.5, ), ), borderRadius: Corners.s6Border, boxShadow: [ BoxShadow( spreadRadius: -2, color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 2, ), BoxShadow( color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 4, ), BoxShadow( spreadRadius: 2, color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 8, ), ], ), ), ); }, ); } } class _DayBadge extends StatelessWidget { const _DayBadge({ required this.isToday, required this.isInMonth, required this.date, }); final bool isToday; final bool isInMonth; final DateTime date; @override Widget build(BuildContext context) { Color dayTextColor = AFThemeExtension.of(context).onBackground; Color monthTextColor = AFThemeExtension.of(context).onBackground; final String monthString = DateFormat("MMM ", context.locale.toLanguageTag()).format(date); final String dayString = date.day.toString(); if (!isInMonth) { dayTextColor = Theme.of(context).disabledColor; monthTextColor = Theme.of(context).disabledColor; } if (isToday) { dayTextColor = Theme.of(context).colorScheme.onPrimary; } final double size = UniversalPlatform.isMobile ? 20 : 18; return SizedBox( height: size, child: Row( mainAxisAlignment: UniversalPlatform.isMobile ? MainAxisAlignment.center : MainAxisAlignment.end, children: [ if (date.day == 1 && !UniversalPlatform.isMobile) FlowyText.medium( monthString, fontSize: 11, color: monthTextColor, ), Container( decoration: BoxDecoration( color: isToday ? Theme.of(context).colorScheme.primary : null, borderRadius: BorderRadius.circular(10), ), width: isToday ? size : null, height: isToday ? size : null, child: Center( child: FlowyText( dayString, fontSize: UniversalPlatform.isMobile ? 12 : 11, color: dayTextColor, ), ), ), ], ), ); } } class _EventList extends StatelessWidget { const _EventList({ required this.events, required this.viewId, required this.rowCache, required this.constraints, }); final List events; final String viewId; final RowCache rowCache; final BoxConstraints constraints; @override Widget build(BuildContext context) { final editingEvent = context.watch().state.editingEvent; return Flexible( child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: true), child: ListView.separated( itemBuilder: (BuildContext context, int index) { final autoEdit = editingEvent?.event?.eventId == events[index].eventId; return EventCard( databaseController: context.read().databaseController, event: events[index], constraints: constraints, autoEdit: autoEdit, ); }, itemCount: events.length, padding: const EdgeInsets.fromLTRB(4.0, 0, 4.0, 4.0), separatorBuilder: (_, __) => VSpace(GridSize.typeOptionSeparatorHeight), shrinkWrap: true, ), ), ); } } class _CardEnterNotifier extends ChangeNotifier { _CardEnterNotifier(); bool _onEnter = false; set onEnter(bool value) { if (_onEnter != value) { _onEnter = value; notifyListeners(); } } bool get onEnter => _onEnter; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; import '../application/calendar_bloc.dart'; import 'calendar_event_editor.dart'; class EventCard extends StatefulWidget { const EventCard({ super.key, required this.databaseController, required this.event, required this.constraints, required this.autoEdit, this.isDraggable = true, this.padding = EdgeInsets.zero, }); final DatabaseController databaseController; final CalendarDayEvent event; final BoxConstraints constraints; final bool autoEdit; final bool isDraggable; final EdgeInsets padding; @override State createState() => _EventCardState(); } class _EventCardState extends State { final PopoverController _popoverController = PopoverController(); String get viewId => widget.databaseController.viewId; RowCache get rowCache => widget.databaseController.rowCache; @override void initState() { super.initState(); if (widget.autoEdit) { WidgetsBinding.instance.addPostFrameCallback((_) { _popoverController.show(); context .read() .add(const CalendarEvent.newEventPopupDisplayed()); }); } } @override Widget build(BuildContext context) { final rowInfo = rowCache.getRow(widget.event.eventId); if (rowInfo == null) { return const SizedBox.shrink(); } final cellBuilder = CardCellBuilder( databaseController: widget.databaseController, ); Widget card = RowCard( // Add the key here to make sure the card is rebuilt when the cells // in this row are updated. key: ValueKey(widget.event.eventId), fieldController: widget.databaseController.fieldController, rowMeta: rowInfo.rowMeta, viewId: viewId, rowCache: rowCache, isEditing: false, cellBuilder: cellBuilder, isCompact: true, onTap: (context) { if (UniversalPlatform.isMobile) { context.push( MobileRowDetailPage.routeName, extra: { MobileRowDetailPage.argRowId: rowInfo.rowId, MobileRowDetailPage.argDatabaseController: widget.databaseController, }, ); } else { _popoverController.show(); } }, styleConfiguration: RowCardStyleConfiguration( cellStyleMap: desktopCalendarCardCellStyleMap(context), showAccessory: false, cardPadding: const EdgeInsets.all(6), hoverStyle: HoverStyle( hoverColor: Theme.of(context).brightness == Brightness.light ? const Color(0x0F1F2329) : const Color(0x0FEFF4FB), foregroundColorOnHover: AFThemeExtension.of(context).onBackground, ), ), onStartEditing: () {}, onEndEditing: () {}, userProfile: context.read().userProfile, ); final decoration = BoxDecoration( color: Theme.of(context).colorScheme.surface, border: Border.fromBorderSide( BorderSide( color: Theme.of(context).brightness == Brightness.light ? const Color(0xffd0d3d6) : const Color(0xff59647a), width: 0.5, ), ), borderRadius: Corners.s6Border, boxShadow: [ BoxShadow( spreadRadius: -2, color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 2, ), BoxShadow( color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 4, ), BoxShadow( spreadRadius: 2, color: const Color(0xFF1F2329).withValues(alpha: 0.02), blurRadius: 8, ), ], ); card = AppFlowyPopover( triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.rightWithCenterAligned, controller: _popoverController, constraints: const BoxConstraints(maxWidth: 360, maxHeight: 348), asBarrier: true, margin: EdgeInsets.zero, offset: const Offset(10.0, 0), popupBuilder: (_) { final settings = context.watch().state.settings; if (settings == null) { return const SizedBox.shrink(); } return MultiBlocProvider( providers: [ BlocProvider.value( value: context.read(), ), BlocProvider.value( value: context.read(), ), ], child: CalendarEventEditor( databaseController: widget.databaseController, rowMeta: widget.event.event.rowMeta, layoutSettings: settings, onExpand: () { final rowController = RowController( rowMeta: widget.event.event.rowMeta, viewId: widget.databaseController.viewId, rowCache: widget.databaseController.rowCache, ); FlowyOverlay.show( context: context, builder: (_) => BlocProvider.value( value: context.read(), child: RowDetailPage( databaseController: widget.databaseController, rowController: rowController, userProfile: context.read().userProfile, ), ), ); }, ), ); }, child: Padding( padding: widget.padding, child: Material( color: Colors.transparent, child: DecoratedBox( decoration: decoration, child: card, ), ), ), ); if (widget.isDraggable) { return Draggable( data: widget.event, feedback: Container( constraints: BoxConstraints( maxWidth: widget.constraints.maxWidth - 8.0, ), decoration: decoration, child: Opacity( opacity: 0.6, child: card, ), ), child: card, ); } return card; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_event_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CalendarEventEditor extends StatelessWidget { CalendarEventEditor({ super.key, required RowMetaPB rowMeta, required this.layoutSettings, required this.databaseController, required this.onExpand, }) : rowController = RowController( rowMeta: rowMeta, viewId: databaseController.viewId, rowCache: databaseController.rowCache, ), cellBuilder = EditableCellBuilder(databaseController: databaseController); final CalendarLayoutSettingPB layoutSettings; final DatabaseController databaseController; final RowController rowController; final EditableCellBuilder cellBuilder; final VoidCallback onExpand; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => CalendarEventEditorBloc( fieldController: databaseController.fieldController, rowController: rowController, layoutSettings: layoutSettings, )..add(const CalendarEventEditorEvent.initial()), child: Column( mainAxisSize: MainAxisSize.min, children: [ EventEditorControls( rowController: rowController, databaseController: databaseController, onExpand: onExpand, ), Flexible( child: EventPropertyList( fieldController: databaseController.fieldController, dateFieldId: layoutSettings.fieldId, cellBuilder: cellBuilder, ), ), ], ), ); } } class EventEditorControls extends StatelessWidget { const EventEditorControls({ super.key, required this.rowController, required this.databaseController, required this.onExpand, }); final RowController rowController; final DatabaseController databaseController; final VoidCallback onExpand; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(12.0, 8.0, 12.0, 0), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ FlowyTooltip( message: LocaleKeys.calendar_duplicateEvent.tr(), child: FlowyIconButton( width: 20, icon: FlowySvg( FlowySvgs.m_duplicate_s, size: const Size.square(16), color: Theme.of(context).iconTheme.color, ), onPressed: () { context.read().add( CalendarEvent.duplicateEvent( rowController.viewId, rowController.rowId, ), ); PopoverContainer.of(context).close(); }, ), ), const HSpace(8.0), FlowyIconButton( width: 20, icon: FlowySvg( FlowySvgs.delete_s, size: const Size.square(16), color: Theme.of(context).iconTheme.color, ), onPressed: () { showConfirmDeletionDialog( context: context, name: LocaleKeys.grid_row_label.tr(), description: LocaleKeys.grid_row_deleteRowPrompt.tr(), onConfirm: () { context.read().add( CalendarEvent.deleteEvent( rowController.viewId, rowController.rowId, ), ); PopoverContainer.of(context).close(); }, ); }, ), const HSpace(8.0), FlowyIconButton( width: 20, icon: FlowySvg( FlowySvgs.full_view_s, size: const Size.square(16), color: Theme.of(context).iconTheme.color, ), onPressed: () { PopoverContainer.of(context).close(); onExpand.call(); }, ), ], ), ); } } class EventPropertyList extends StatelessWidget { const EventPropertyList({ super.key, required this.fieldController, required this.dateFieldId, required this.cellBuilder, }); final FieldController fieldController; final String dateFieldId; final EditableCellBuilder cellBuilder; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final primaryFieldId = fieldController.fieldInfos .firstWhereOrNull((fieldInfo) => fieldInfo.isPrimary)! .id; final reorderedList = List.from(state.cells) ..retainWhere((cell) => cell.fieldId != primaryFieldId); final primaryCellContext = state.cells .firstWhereOrNull((cell) => cell.fieldId == primaryFieldId); final dateFieldIndex = reorderedList.indexWhere((cell) => cell.fieldId == dateFieldId); if (primaryCellContext == null || dateFieldIndex == -1) { return const SizedBox.shrink(); } reorderedList.insert(0, reorderedList.removeAt(dateFieldIndex)); final children = [ Padding( padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0), child: cellBuilder.buildCustom( primaryCellContext, skinMap: EditableCellSkinMap(textSkin: _TitleTextCellSkin()), ), ), ...reorderedList.map( (cellContext) => PropertyCell( fieldController: fieldController, cellContext: cellContext, cellBuilder: cellBuilder, ), ), ]; return ListView( shrinkWrap: true, padding: const EdgeInsets.only(bottom: 16.0), children: children, ); }, ); } } class PropertyCell extends StatefulWidget { const PropertyCell({ super.key, required this.fieldController, required this.cellContext, required this.cellBuilder, }); final FieldController fieldController; final CellContext cellContext; final EditableCellBuilder cellBuilder; @override State createState() => _PropertyCellState(); } class _PropertyCellState extends State { @override Widget build(BuildContext context) { final fieldInfo = widget.fieldController.getField(widget.cellContext.fieldId)!; final cell = widget.cellBuilder .buildStyled(widget.cellContext, EditableCellStyle.desktopRowDetail); final gesture = GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => cell.requestFocus.notify(), child: AccessoryHover( fieldType: fieldInfo.fieldType, child: cell, ), ); return Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), constraints: const BoxConstraints(minHeight: 28), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 88, height: 28, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), child: Row( children: [ FieldIcon( fieldInfo: fieldInfo, dimension: 14, ), const HSpace(4.0), Expanded( child: FlowyText.regular( fieldInfo.name, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, fontSize: 11, ), ), ], ), ), ), const HSpace(8), Expanded(child: gesture), ], ), ); } } class _TitleTextCellSkin extends IEditableTextCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return FlowyTextField( controller: textEditingController, textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14), focusNode: focusNode, hintText: LocaleKeys.calendar_defaultNewCalendarTitle.tr(), onEditingComplete: () { bloc.add(TextCellEvent.updateText(textEditingController.text)); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; import 'package:appflowy/plugins/database/calendar/application/unschedule_event_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../application/row/row_controller.dart'; import '../../widgets/row/row_detail.dart'; import 'calendar_day.dart'; import 'layout/sizes.dart'; import 'toolbar/calendar_setting_bar.dart'; class CalendarPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { final _toggleExtension = ToggleExtensionNotifier(); @override Widget content( BuildContext context, ViewPB view, DatabaseController controller, bool shrinkWrap, String? initialRowId, ) { return CalendarPage( key: _makeValueKey(controller), view: view, databaseController: controller, shrinkWrap: shrinkWrap, ); } @override Widget settingBar(BuildContext context, DatabaseController controller) { return CalendarSettingBar( key: _makeValueKey(controller), databaseController: controller, toggleExtension: _toggleExtension, ); } @override Widget settingBarExtension( BuildContext context, DatabaseController controller, ) { return DatabaseViewSettingExtension( key: _makeValueKey(controller), viewId: controller.viewId, databaseController: controller, toggleExtension: _toggleExtension, ); } @override void dispose() { _toggleExtension.dispose(); super.dispose(); } ValueKey _makeValueKey(DatabaseController controller) { return ValueKey(controller.viewId); } } class CalendarPage extends StatefulWidget { const CalendarPage({ super.key, required this.view, required this.databaseController, this.shrinkWrap = false, }); final ViewPB view; final DatabaseController databaseController; final bool shrinkWrap; @override State createState() => _CalendarPageState(); } class _CalendarPageState extends State { final _eventController = EventController(); late final CalendarBloc _calendarBloc; GlobalKey? _calendarState; @override void initState() { super.initState(); _calendarState = GlobalKey(); _calendarBloc = CalendarBloc( databaseController: widget.databaseController, )..add(const CalendarEvent.initial()); } @override void dispose() { _calendarBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return CalendarControllerProvider( controller: _eventController, child: MultiBlocProvider( providers: [ BlocProvider.value( value: _calendarBloc, ), BlocProvider( create: (context) => PageAccessLevelBloc(view: widget.view) ..add( PageAccessLevelEvent.initial(), ), ), ], child: MultiBlocListener( listeners: [ BlocListener( listenWhen: (p, c) => p.initialEvents != c.initialEvents, listener: (context, state) { _eventController.removeWhere((_) => true); _eventController.addAll(state.initialEvents); }, ), BlocListener( listenWhen: (p, c) => p.deleteEventIds != c.deleteEventIds, listener: (context, state) { _eventController.removeWhere( (element) => state.deleteEventIds.contains(element.event!.eventId), ); }, ), BlocListener( // Event create by click the + button or double click on the // calendar listenWhen: (p, c) => p.newEvent != c.newEvent, listener: (context, state) { if (state.newEvent != null) { _eventController.add(state.newEvent!); } }, ), BlocListener( // When an event is rescheduled listenWhen: (p, c) => p.updateEvent != c.updateEvent, listener: (context, state) { if (state.updateEvent != null) { _eventController.removeWhere( (element) => element.event!.eventId == state.updateEvent!.event!.eventId, ); _eventController.add(state.updateEvent!); } }, ), BlocListener( listenWhen: (p, c) => p.openRow != c.openRow, listener: (context, state) { if (state.openRow != null) { showEventDetails( context: context, databaseController: _calendarBloc.databaseController, rowMeta: state.openRow!, ); } }, ), ], child: BlocBuilder( builder: (context, state) { return ValueListenableBuilder( valueListenable: widget.databaseController.isLoading, builder: (_, value, ___) { if (value) { return const Center( child: CircularProgressIndicator.adaptive(), ); } return _buildCalendar( context, _eventController, state.settings?.firstDayOfWeek ?? 0, ); }, ); }, ), ), ), ); } Widget _buildCalendar( BuildContext context, EventController eventController, int firstDayOfWeek, ) { return LayoutBuilder( // must specify MonthView width for useAvailableVerticalSpace to work properly builder: (context, constraints) { final paddingLeft = context.read().paddingLeft; EdgeInsets padding = UniversalPlatform.isMobile ? CalendarSize.contentInsetsMobile : CalendarSize.contentInsets + const EdgeInsets.symmetric(horizontal: 40); final double horizontalPadding = context.read().horizontalPadding; if (horizontalPadding == 0) { padding = padding.copyWith(left: 0, right: 0); } padding = padding.copyWith(left: paddingLeft + padding.left); return Padding( padding: padding, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: MonthView( key: _calendarState, controller: _eventController, width: constraints.maxWidth, cellAspectRatio: UniversalPlatform.isMobile ? 0.9 : 0.6, startDay: _weekdayFromInt(firstDayOfWeek), showBorder: false, headerBuilder: _headerNavigatorBuilder, weekDayBuilder: _headerWeekDayBuilder, cellBuilder: ( date, calenderEvents, isToday, isInMonth, position, ) => _calendarDayBuilder( context, date, calenderEvents, isToday, isInMonth, position, ), useAvailableVerticalSpace: widget.shrinkWrap, ), ), ); }, ); } Widget _headerNavigatorBuilder(DateTime currentMonth) { return SizedBox( height: 24, child: Row( children: [ GestureDetector( onTap: UniversalPlatform.isMobile ? () => showMobileBottomSheet( context, title: LocaleKeys.calendar_quickJumpYear.tr(), showHeader: true, showCloseButton: true, builder: (_) => SizedBox( height: 200, child: YearPicker( firstDate: CalendarConstants.epochDate.withoutTime, lastDate: CalendarConstants.maxDate.withoutTime, selectedDate: currentMonth, currentDate: DateTime.now(), onChanged: (newDate) { _calendarState?.currentState?.jumpToMonth(newDate); context.pop(); }, ), ), ) : null, child: Row( children: [ FlowyText.medium( DateFormat('MMMM y', context.locale.toLanguageTag()) .format(currentMonth), ), if (UniversalPlatform.isMobile) ...[ const HSpace(6), const FlowySvg(FlowySvgs.arrow_down_s), ], ], ), ), const Spacer(), FlowyIconButton( width: CalendarSize.navigatorButtonWidth, height: CalendarSize.navigatorButtonHeight, icon: const FlowySvg(FlowySvgs.arrow_left_s), tooltipText: LocaleKeys.calendar_navigation_previousMonth.tr(), hoverColor: AFThemeExtension.of(context).lightGreyHover, onPressed: () => _calendarState?.currentState?.previousPage(), ), FlowyTextButton( LocaleKeys.calendar_navigation_today.tr(), fillColor: Colors.transparent, fontWeight: FontWeight.w400, fontSize: 10, fontColor: AFThemeExtension.of(context).textColor, tooltip: LocaleKeys.calendar_navigation_jumpToday.tr(), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), hoverColor: AFThemeExtension.of(context).lightGreyHover, onPressed: () => _calendarState?.currentState?.animateToMonth(DateTime.now()), ), FlowyIconButton( width: CalendarSize.navigatorButtonWidth, height: CalendarSize.navigatorButtonHeight, icon: const FlowySvg(FlowySvgs.arrow_right_s), tooltipText: LocaleKeys.calendar_navigation_nextMonth.tr(), hoverColor: AFThemeExtension.of(context).lightGreyHover, onPressed: () => _calendarState?.currentState?.nextPage(), ), const HSpace(6.0), UnscheduledEventsButton( databaseController: widget.databaseController, ), ], ), ); } Widget _headerWeekDayBuilder(day) { // incoming day starts from Monday, the symbols start from Sunday final symbols = DateFormat.EEEE(context.locale.toLanguageTag()).dateSymbols; String weekDayString = symbols.WEEKDAYS[(day + 1) % 7]; if (UniversalPlatform.isMobile) { weekDayString = weekDayString.substring(0, 3); } return Center( child: Padding( padding: CalendarSize.daysOfWeekInsets, child: FlowyText.regular( weekDayString, fontSize: 9, color: Theme.of(context).hintColor, ), ), ); } Widget _calendarDayBuilder( BuildContext context, DateTime date, List> calenderEvents, isToday, isInMonth, position, ) { // Sort the events by timestamp. Because the database view is not // reserving the order of the events. Reserving the order of the rows/events // is implemnted in the develop branch(WIP). Will be replaced with that. final events = calenderEvents.map((value) => value.event!).toList() ..sort((a, b) => a.event.timestamp.compareTo(b.event.timestamp)); final isEditable = context.watch()?.state.isEditable ?? false; return IgnorePointer( ignoring: !isEditable, child: CalendarDayCard( viewId: widget.view.id, isToday: isToday, isInMonth: isInMonth, events: events, date: date, rowCache: _calendarBloc.rowCache, onCreateEvent: (date) => _calendarBloc.add(CalendarEvent.createEvent(date)), position: position, ), ); } WeekDays _weekdayFromInt(int dayOfWeek) { // dayOfWeek starts from Sunday, WeekDays starts from Monday return WeekDays.values[(dayOfWeek - 1) % 7]; } } void showEventDetails({ required BuildContext context, required DatabaseController databaseController, required RowMetaPB rowMeta, }) { final rowController = RowController( rowMeta: rowMeta, viewId: databaseController.viewId, rowCache: databaseController.rowCache, ); FlowyOverlay.show( context: context, builder: (BuildContext overlayContext) { return BlocProvider.value( value: context.read(), child: RowDetailPage( rowController: rowController, databaseController: databaseController, userProfile: context.read().userProfile, ), ); }, ); } class UnscheduledEventsButton extends StatefulWidget { const UnscheduledEventsButton({super.key, required this.databaseController}); final DatabaseController databaseController; @override State createState() => _UnscheduledEventsButtonState(); } class _UnscheduledEventsButtonState extends State { final PopoverController _popoverController = PopoverController(); @override Widget build(BuildContext context) { return BlocProvider( create: (_) => UnscheduleEventsBloc(databaseController: widget.databaseController) ..add(const UnscheduleEventsEvent.initial()), child: BlocBuilder( builder: (context, state) { return AppFlowyPopover( direction: PopoverDirection.bottomWithCenterAligned, triggerActions: PopoverTriggerFlags.none, controller: _popoverController, offset: const Offset(0, 8), constraints: const BoxConstraints(maxWidth: 282, maxHeight: 600), child: OutlinedButton( style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder( side: BorderSide(color: Theme.of(context).dividerColor), borderRadius: Corners.s6Border, ), side: BorderSide(color: Theme.of(context).dividerColor), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), ), onPressed: () { if (state.unscheduleEvents.isNotEmpty) { if (UniversalPlatform.isMobile) { _showUnscheduledEventsMobile(state.unscheduleEvents); } else { _popoverController.show(); } } }, child: FlowyTooltip( message: LocaleKeys.calendar_settings_noDateHint.plural( state.unscheduleEvents.length, namedArgs: {'count': '${state.unscheduleEvents.length}'}, ), child: FlowyText.regular( "${LocaleKeys.calendar_settings_noDateTitle.tr()} (${state.unscheduleEvents.length})", fontSize: 10, ), ), ), popupBuilder: (_) => MultiBlocProvider( providers: [ BlocProvider.value( value: context.read(), ), BlocProvider.value( value: context.read(), ), ], child: UnscheduleEventsList( databaseController: widget.databaseController, unscheduleEvents: state.unscheduleEvents, ), ), ); }, ), ); } void _showUnscheduledEventsMobile(List events) => showMobileBottomSheet( context, builder: (_) { return Column( children: [ FlowyText( LocaleKeys.calendar_settings_unscheduledEventsTitle.tr(), ), UnscheduleEventsList( databaseController: widget.databaseController, unscheduleEvents: events, ), ], ); }, ); } class UnscheduleEventsList extends StatelessWidget { const UnscheduleEventsList({ super.key, required this.unscheduleEvents, required this.databaseController, }); final List unscheduleEvents; final DatabaseController databaseController; @override Widget build(BuildContext context) { final cells = [ if (!UniversalPlatform.isMobile) Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: FlowyText( LocaleKeys.calendar_settings_clickToAdd.tr(), fontSize: 10, color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ), ), ...unscheduleEvents.map( (event) => UnscheduledEventCell( event: event, onPressed: () { if (UniversalPlatform.isMobile) { context.push( MobileRowDetailPage.routeName, extra: { MobileRowDetailPage.argRowId: event.rowMeta.id, MobileRowDetailPage.argDatabaseController: databaseController, }, ); context.pop(); } else { showEventDetails( context: context, rowMeta: event.rowMeta, databaseController: databaseController, ); PopoverContainer.of(context).close(); } }, ), ), ]; final child = ListView.separated( itemBuilder: (context, index) => cells[index], itemCount: cells.length, separatorBuilder: (context, index) => VSpace(GridSize.typeOptionSeparatorHeight), shrinkWrap: true, ); if (UniversalPlatform.isMobile) { return Flexible(child: child); } return child; } } class UnscheduledEventCell extends StatelessWidget { const UnscheduledEventCell({ super.key, required this.event, required this.onPressed, }); final CalendarEventPB event; final VoidCallback onPressed; @override Widget build(BuildContext context) { return UniversalPlatform.isMobile ? MobileUnscheduledEventTile(event: event, onPressed: onPressed) : DesktopUnscheduledEventTile(event: event, onPressed: onPressed); } } class DesktopUnscheduledEventTile extends StatelessWidget { const DesktopUnscheduledEventTile({ super.key, required this.event, required this.onPressed, }); final CalendarEventPB event; final VoidCallback onPressed; @override Widget build(BuildContext context) { return SizedBox( height: 26, child: FlowyButton( margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), text: FlowyText( event.title.isEmpty ? LocaleKeys.calendar_defaultNewCalendarTitle.tr() : event.title, fontSize: 11, ), onTap: onPressed, ), ); } } class MobileUnscheduledEventTile extends StatelessWidget { const MobileUnscheduledEventTile({ super.key, required this.event, required this.onPressed, }); final CalendarEventPB event; final VoidCallback onPressed; @override Widget build(BuildContext context) { return MobileSettingItem( name: event.title.isEmpty ? LocaleKeys.calendar_defaultNewCalendarTitle.tr() : event.title, onTap: onPressed, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/layout/sizes.dart ================================================ import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:flutter/widgets.dart'; class CalendarSize { static double scale = 1; static double get headerContainerPadding => 12 * scale; static EdgeInsets get contentInsets => EdgeInsets.fromLTRB( GridSize.horizontalHeaderPadding, CalendarSize.headerContainerPadding, GridSize.horizontalHeaderPadding, CalendarSize.headerContainerPadding, ); static EdgeInsets get contentInsetsMobile => EdgeInsets.fromLTRB( GridSize.horizontalHeaderPadding / 2, 0, GridSize.horizontalHeaderPadding / 2, 0, ); static double get scrollBarSize => 8 * scale; static double get navigatorButtonWidth => 20 * scale; static double get navigatorButtonHeight => 24 * scale; static EdgeInsets get daysOfWeekInsets => EdgeInsets.only(top: 12.0 * scale, bottom: 5.0 * scale); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/setting/property_bloc.dart'; import 'package:appflowy/plugins/database/calendar/application/calendar_setting_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// Widget that displays a list of settings that alters the appearance of the /// calendar class CalendarLayoutSetting extends StatefulWidget { const CalendarLayoutSetting({ super.key, required this.databaseController, }); final DatabaseController databaseController; @override State createState() => _CalendarLayoutSettingState(); } class _CalendarLayoutSettingState extends State { final PopoverMutex popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (context) { return CalendarSettingBloc( databaseController: widget.databaseController, )..add(const CalendarSettingEvent.initial()); }, child: BlocBuilder( builder: (context, state) { final CalendarLayoutSettingPB? settings = state.layoutSetting; if (settings == null) { return const CircularProgressIndicator(); } final availableSettings = _availableCalendarSettings(settings); final bloc = context.read(); final items = availableSettings.map((setting) { switch (setting) { case CalendarLayoutSettingAction.showWeekNumber: return ShowWeekNumber( showWeekNumbers: settings.showWeekNumbers, onUpdated: (showWeekNumbers) => bloc.add( CalendarSettingEvent.updateLayoutSetting( showWeekNumbers: showWeekNumbers, ), ), ); case CalendarLayoutSettingAction.showWeekends: return ShowWeekends( showWeekends: settings.showWeekends, onUpdated: (showWeekends) => bloc.add( CalendarSettingEvent.updateLayoutSetting( showWeekends: showWeekends, ), ), ); case CalendarLayoutSettingAction.firstDayOfWeek: return FirstDayOfWeek( firstDayOfWeek: settings.firstDayOfWeek, popoverMutex: popoverMutex, onUpdated: (firstDayOfWeek) => bloc.add( CalendarSettingEvent.updateLayoutSetting( firstDayOfWeek: firstDayOfWeek, ), ), ); case CalendarLayoutSettingAction.layoutField: return LayoutDateField( databaseController: widget.databaseController, fieldId: settings.fieldId, popoverMutex: popoverMutex, onUpdated: (fieldId) => bloc.add( CalendarSettingEvent.updateLayoutSetting( layoutFieldId: fieldId, ), ), ); default: return const SizedBox.shrink(); } }).toList(); return SizedBox( width: 200, child: ListView.separated( shrinkWrap: true, itemCount: items.length, separatorBuilder: (_, __) => VSpace(GridSize.typeOptionSeparatorHeight), physics: StyledScrollPhysics(), itemBuilder: (_, int index) => items[index], padding: const EdgeInsets.all(6.0), ), ); }, ), ); } List _availableCalendarSettings( CalendarLayoutSettingPB layoutSettings, ) { final List settings = [ CalendarLayoutSettingAction.layoutField, ]; switch (layoutSettings.layoutTy) { case CalendarLayoutPB.DayLayout: break; case CalendarLayoutPB.MonthLayout: case CalendarLayoutPB.WeekLayout: settings.add(CalendarLayoutSettingAction.firstDayOfWeek); break; } return settings; } } class LayoutDateField extends StatelessWidget { const LayoutDateField({ super.key, required this.databaseController, required this.fieldId, required this.popoverMutex, required this.onUpdated, }); final DatabaseController databaseController; final String fieldId; final PopoverMutex popoverMutex; final Function(String fieldId) onUpdated; @override Widget build(BuildContext context) { return AppFlowyPopover( direction: PopoverDirection.leftWithTopAligned, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, constraints: BoxConstraints.loose(const Size(300, 400)), mutex: popoverMutex, offset: const Offset(-14, 0), popupBuilder: (context) { return BlocProvider( create: (context) => DatabasePropertyBloc( viewId: databaseController.viewId, fieldController: databaseController.fieldController, )..add(const DatabasePropertyEvent.initial()), child: BlocBuilder( builder: (context, state) { final items = state.fieldContexts .where((field) => field.fieldType == FieldType.DateTime) .map( (fieldInfo) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( fieldInfo.name, lineHeight: 1.0, ), onTap: () { onUpdated(fieldInfo.id); popoverMutex.close(); }, leftIcon: const FlowySvg(FlowySvgs.date_s), rightIcon: fieldInfo.id == fieldId ? const FlowySvg(FlowySvgs.check_s) : null, ), ); }, ).toList(); return SizedBox( width: 200, child: ListView.separated( shrinkWrap: true, itemBuilder: (_, index) => items[index], separatorBuilder: (_, __) => VSpace(GridSize.typeOptionSeparatorHeight), itemCount: items.length, ), ); }, ), ); }, child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), text: FlowyText( lineHeight: 1.0, LocaleKeys.calendar_settings_layoutDateField.tr(), ), ), ), ); } } class ShowWeekNumber extends StatelessWidget { const ShowWeekNumber({ super.key, required this.showWeekNumbers, required this.onUpdated, }); final bool showWeekNumbers; final Function(bool showWeekNumbers) onUpdated; @override Widget build(BuildContext context) { return _toggleItem( onToggle: (showWeekNumbers) => onUpdated(!showWeekNumbers), value: showWeekNumbers, text: LocaleKeys.calendar_settings_showWeekNumbers.tr(), ); } } class ShowWeekends extends StatelessWidget { const ShowWeekends({ super.key, required this.showWeekends, required this.onUpdated, }); final bool showWeekends; final Function(bool showWeekends) onUpdated; @override Widget build(BuildContext context) { return _toggleItem( onToggle: (showWeekends) => onUpdated(!showWeekends), value: showWeekends, text: LocaleKeys.calendar_settings_showWeekends.tr(), ); } } class FirstDayOfWeek extends StatelessWidget { const FirstDayOfWeek({ super.key, required this.firstDayOfWeek, required this.popoverMutex, required this.onUpdated, }); final int firstDayOfWeek; final PopoverMutex popoverMutex; final Function(int firstDayOfWeek) onUpdated; @override Widget build(BuildContext context) { return AppFlowyPopover( direction: PopoverDirection.leftWithTopAligned, constraints: BoxConstraints.loose(const Size(300, 400)), triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, mutex: popoverMutex, offset: const Offset(-14, 0), popupBuilder: (context) { final symbols = DateFormat.EEEE(context.locale.toLanguageTag()).dateSymbols; // starts from sunday const len = 2; final items = symbols.WEEKDAYS.take(len).indexed.map((entry) { return StartFromButton( title: entry.$2, dayIndex: entry.$1, isSelected: firstDayOfWeek == entry.$1, onTap: (index) { onUpdated(index); popoverMutex.close(); }, ); }).toList(); return SizedBox( width: 100, child: ListView.separated( shrinkWrap: true, itemBuilder: (_, index) => items[index], separatorBuilder: (_, __) => VSpace(GridSize.typeOptionSeparatorHeight), itemCount: len, ), ); }, child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), text: FlowyText( lineHeight: 1.0, LocaleKeys.calendar_settings_firstDayOfWeek.tr(), ), ), ), ); } } Widget _toggleItem({ required String text, required bool value, required void Function(bool) onToggle, }) { return SizedBox( height: GridSize.popoverItemHeight, child: Padding( padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), child: Row( children: [ FlowyText(text), const Spacer(), Toggle( value: value, onChanged: (value) => onToggle(value), padding: EdgeInsets.zero, ), ], ), ), ); } enum CalendarLayoutSettingAction { layoutField, layoutType, showWeekends, firstDayOfWeek, showWeekNumber, showTimeLine, } class StartFromButton extends StatelessWidget { const StartFromButton({ super.key, required this.title, required this.dayIndex, required this.onTap, required this.isSelected, }); final String title; final int dayIndex; final void Function(int) onTap; final bool isSelected; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( title, lineHeight: 1.0, ), onTap: () => onTap(dayIndex), rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_setting_bar.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; class CalendarSettingBar extends StatelessWidget { const CalendarSettingBar({ super.key, required this.databaseController, required this.toggleExtension, }); final DatabaseController databaseController; final ToggleExtensionNotifier toggleExtension; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => FilterEditorBloc( viewId: databaseController.viewId, fieldController: databaseController.fieldController, ), child: ValueListenableBuilder( valueListenable: databaseController.isLoading, builder: (context, value, child) { if (value) { return const SizedBox.shrink(); } final isReference = Provider.of(context)?.isReference ?? false; return SizedBox( height: 20, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ FilterButton( toggleExtension: toggleExtension, ), if (isReference) ...[ const HSpace(2), ViewDatabaseButton(view: databaseController.view), ], const HSpace(2), SettingButton( databaseController: databaseController, ), ], ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/cell_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; import '../application/row/row_service.dart'; typedef UpdateFieldNotifiedValue = FlowyResult; class CellListener { CellListener({required this.rowId, required this.fieldId}); final RowId rowId; final String fieldId; PublishNotifier? _updateCellNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({required void Function(UpdateFieldNotifiedValue) onCellChanged}) { _updateCellNotifier?.addPublishListener(onCellChanged); _listener = DatabaseNotificationListener( objectId: "$rowId:$fieldId", handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateCell: result.fold( (payload) => _updateCellNotifier?.value = FlowyResult.success(null), (error) => _updateCellNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _updateCellNotifier?.dispose(); _updateCellNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/cell_service.dart ================================================ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import '../application/cell/cell_controller.dart'; class CellBackendService { CellBackendService(); static Future> updateCell({ required String viewId, required CellContext cellContext, required String data, }) { final payload = CellChangesetPB() ..viewId = viewId ..fieldId = cellContext.fieldId ..rowId = cellContext.rowId ..cellChangeset = data; return DatabaseEventUpdateCell(payload).send(); } static Future> getCell({ required String viewId, required CellContext cellContext, }) { final payload = CellIdPB() ..viewId = viewId ..fieldId = cellContext.fieldId ..rowId = cellContext.rowId; return DatabaseEventGetCell(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/checklist_cell_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:protobuf/protobuf.dart'; class ChecklistCellBackendService { ChecklistCellBackendService({ required this.viewId, required this.fieldId, required this.rowId, }); final String viewId; final String fieldId; final String rowId; Future> create({ required String name, int? index, }) { final insert = ChecklistCellInsertPB()..name = name; if (index != null) { insert.index = index; } final payload = ChecklistCellDataChangesetPB() ..cellId = _makdeCellId() ..insertTask.add(insert); return DatabaseEventUpdateChecklistCell(payload).send(); } Future> delete({ required List optionIds, }) { final payload = ChecklistCellDataChangesetPB() ..cellId = _makdeCellId() ..deleteTasks.addAll(optionIds); return DatabaseEventUpdateChecklistCell(payload).send(); } Future> select({ required String optionId, }) { final payload = ChecklistCellDataChangesetPB() ..cellId = _makdeCellId() ..completedTasks.add(optionId); return DatabaseEventUpdateChecklistCell(payload).send(); } Future> updateName({ required SelectOptionPB option, required name, }) { option.freeze(); final newOption = option.rebuild((option) { option.name = name; }); final payload = ChecklistCellDataChangesetPB() ..cellId = _makdeCellId() ..updateTasks.add(newOption); return DatabaseEventUpdateChecklistCell(payload).send(); } Future> reorder({ required fromTaskId, required toTaskId, }) { final payload = ChecklistCellDataChangesetPB() ..cellId = _makdeCellId() ..reorder = "$fromTaskId $toTaskId"; return DatabaseEventUpdateChecklistCell(payload).send(); } CellIdPB _makdeCellId() { return CellIdPB() ..viewId = viewId ..fieldId = fieldId ..rowId = rowId; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/database_view_service.dart ================================================ import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'layout_service.dart'; class DatabaseViewBackendService { DatabaseViewBackendService({required this.viewId}); final String viewId; /// Returns the database id associated with the view. Future> getDatabaseId() async { final payload = DatabaseViewIdPB(value: viewId); return DatabaseEventGetDatabaseId(payload) .send() .then((value) => value.map((l) => l.value)); } static Future> updateLayout({ required String viewId, required DatabaseLayoutPB layout, }) { final payload = UpdateViewPayloadPB.create() ..viewId = viewId ..layout = viewLayoutFromDatabaseLayout(layout); return FolderEventUpdateView(payload).send(); } Future> openDatabase() async { final payload = DatabaseViewIdPB(value: viewId); return DatabaseEventGetDatabase(payload).send(); } Future> moveGroupRow({ required RowId fromRowId, required String fromGroupId, required String toGroupId, RowId? toRowId, }) { final payload = MoveGroupRowPayloadPB.create() ..viewId = viewId ..fromRowId = fromRowId ..fromGroupId = fromGroupId ..toGroupId = toGroupId; if (toRowId != null) { payload.toRowId = toRowId; } return DatabaseEventMoveGroupRow(payload).send(); } Future> moveRow({ required String fromRowId, required String toRowId, }) { final payload = MoveRowPayloadPB.create() ..viewId = viewId ..fromRowId = fromRowId ..toRowId = toRowId; return DatabaseEventMoveRow(payload).send(); } Future> moveGroup({ required String fromGroupId, required String toGroupId, }) { final payload = MoveGroupPayloadPB.create() ..viewId = viewId ..fromGroupId = fromGroupId ..toGroupId = toGroupId; return DatabaseEventMoveGroup(payload).send(); } Future, FlowyError>> getFields({ List? fieldIds, }) { final payload = GetFieldPayloadPB.create()..viewId = viewId; if (fieldIds != null) { payload.fieldIds = RepeatedFieldIdPB(items: fieldIds); } return DatabaseEventGetFields(payload).send().then((result) { return result.fold( (l) => FlowyResult.success(l.items), (r) => FlowyResult.failure(r), ); }); } Future> getLayoutSetting( DatabaseLayoutPB layoutType, ) { final payload = DatabaseLayoutMetaPB.create() ..viewId = viewId ..layout = layoutType; return DatabaseEventGetLayoutSetting(payload).send(); } Future> updateLayoutSetting({ required DatabaseLayoutPB layoutType, BoardLayoutSettingPB? boardLayoutSetting, CalendarLayoutSettingPB? calendarLayoutSetting, }) { final payload = LayoutSettingChangesetPB.create() ..viewId = viewId ..layoutType = layoutType; if (boardLayoutSetting != null) { payload.board = boardLayoutSetting; } if (calendarLayoutSetting != null) { payload.calendar = calendarLayoutSetting; } return DatabaseEventSetLayoutSetting(payload).send(); } Future> closeView() { final request = ViewIdPB(value: viewId); return FolderEventCloseView(request).send(); } Future> loadGroups() { final payload = DatabaseViewIdPB(value: viewId); return DatabaseEventGetGroups(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; final class DateCellBackendService { DateCellBackendService({ required String viewId, required String fieldId, required String rowId, }) : cellId = CellIdPB() ..viewId = viewId ..fieldId = fieldId ..rowId = rowId; final CellIdPB cellId; Future> update({ bool? includeTime, bool? isRange, DateTime? date, DateTime? endDate, String? reminderId, }) { final payload = DateCellChangesetPB()..cellId = cellId; if (includeTime != null) { payload.includeTime = includeTime; } if (isRange != null) { payload.isRange = isRange; } if (date != null) { final dateTimestamp = date.millisecondsSinceEpoch ~/ 1000; payload.timestamp = Int64(dateTimestamp); } if (endDate != null) { final dateTimestamp = endDate.millisecondsSinceEpoch ~/ 1000; payload.endTimestamp = Int64(dateTimestamp); } if (reminderId != null) { payload.reminderId = reminderId; } return DatabaseEventUpdateDateCell(payload).send(); } Future> clear() { final payload = DateCellChangesetPB() ..cellId = cellId ..clearFlag = true; return DatabaseEventUpdateDateCell(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/field_backend_service.dart ================================================ import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; // This class is used for combining the // 1. FieldBackendService // 2. FieldSettingsBackendService // 3. TypeOptionBackendService // // including, // hide, delete, duplicated, // insertLeft, insertRight, // updateName class FieldServices { FieldServices({ required this.viewId, required this.fieldId, }) : fieldBackendService = FieldBackendService( viewId: viewId, fieldId: fieldId, ), fieldSettingsService = FieldSettingsBackendService( viewId: viewId, ); final String viewId; final String fieldId; final FieldBackendService fieldBackendService; final FieldSettingsBackendService fieldSettingsService; Future hide() async { await fieldSettingsService.updateFieldSettings( fieldId: fieldId, fieldVisibility: FieldVisibility.AlwaysHidden, ); } Future show() async { await fieldSettingsService.updateFieldSettings( fieldId: fieldId, fieldVisibility: FieldVisibility.AlwaysShown, ); } Future delete() async { await fieldBackendService.delete(); } Future duplicate() async { await fieldBackendService.duplicate(); } Future insertLeft() async { await FieldBackendService.createField( viewId: viewId, position: OrderObjectPositionPB( position: OrderObjectPositionTypePB.Before, objectId: fieldId, ), ); } Future insertRight() async { await FieldBackendService.createField( viewId: viewId, position: OrderObjectPositionPB( position: OrderObjectPositionTypePB.After, objectId: fieldId, ), ); } Future updateName(String name) async { await fieldBackendService.updateField( name: name, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/field_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef UpdateFieldsNotifiedValue = FlowyResult; class FieldsListener { FieldsListener({required this.viewId}); final String viewId; PublishNotifier? updateFieldsNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({ required void Function(UpdateFieldsNotifiedValue) onFieldsChanged, }) { updateFieldsNotifier?.addPublishListener(onFieldsChanged); _listener = DatabaseNotificationListener( objectId: viewId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateFields: result.fold( (payload) => updateFieldsNotifier?.value = FlowyResult.success(DatabaseFieldChangesetPB.fromBuffer(payload)), (error) => updateFieldsNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); updateFieldsNotifier?.dispose(); updateFieldsNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart ================================================ import 'dart:typed_data'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; /// FieldService provides many field-related interfaces event functions. Check out /// `rust-lib/flowy-database/event_map.rs` for a list of events and their /// implementations. class FieldBackendService { FieldBackendService({required this.viewId, required this.fieldId}); final String viewId; final String fieldId; /// Create a field in a database view. The position will only be applicable /// in this view; for other views it will be appended to the end static Future> createField({ required String viewId, FieldType fieldType = FieldType.RichText, String? fieldName, String? icon, Uint8List? typeOptionData, OrderObjectPositionPB? position, }) { final payload = CreateFieldPayloadPB( viewId: viewId, fieldType: fieldType, fieldName: fieldName, typeOptionData: typeOptionData, fieldPosition: position, ); return DatabaseEventCreateField(payload).send(); } /// Reorder a field within a database view static Future> moveField({ required String viewId, required String fromFieldId, required String toFieldId, }) { final payload = MoveFieldPayloadPB( viewId: viewId, fromFieldId: fromFieldId, toFieldId: toFieldId, ); return DatabaseEventMoveField(payload).send(); } /// Delete a field static Future> deleteField({ required String viewId, required String fieldId, }) { final payload = DeleteFieldPayloadPB( viewId: viewId, fieldId: fieldId, ); return DatabaseEventDeleteField(payload).send(); } // Clear all data of all cells in a Field static Future> clearField({ required String viewId, required String fieldId, }) { final payload = ClearFieldPayloadPB( viewId: viewId, fieldId: fieldId, ); return DatabaseEventClearField(payload).send(); } /// Duplicate a field static Future> duplicateField({ required String viewId, required String fieldId, }) { final payload = DuplicateFieldPayloadPB(viewId: viewId, fieldId: fieldId); return DatabaseEventDuplicateField(payload).send(); } /// Update a field's properties Future> updateField({ String? name, String? icon, bool? frozen, }) { final payload = FieldChangesetPB.create() ..viewId = viewId ..fieldId = fieldId; if (name != null) { payload.name = name; } if (icon != null) { payload.icon = icon; } if (frozen != null) { payload.frozen = frozen; } return DatabaseEventUpdateField(payload).send(); } /// Change a field's type static Future> updateFieldType({ required String viewId, required String fieldId, required FieldType fieldType, String? fieldName, }) { final payload = UpdateFieldTypePayloadPB() ..viewId = viewId ..fieldId = fieldId ..fieldType = fieldType; // Only set if fieldName is not null if (fieldName != null) { payload.fieldName = fieldName; } return DatabaseEventUpdateFieldType(payload).send(); } /// Update a field's type option data static Future> updateFieldTypeOption({ required String viewId, required String fieldId, required List typeOptionData, }) { final payload = TypeOptionChangesetPB.create() ..viewId = viewId ..fieldId = fieldId ..typeOptionData = typeOptionData; return DatabaseEventUpdateFieldTypeOption(payload).send(); } static Future, FlowyError>> getFields({ required String viewId, }) { final payload = GetFieldPayloadPB.create()..viewId = viewId; return DatabaseEventGetFields(payload).send().fold( (repeated) => FlowySuccess(repeated.items), (error) => FlowyFailure(error), ); } /// Returns the primary field of the view. static Future> getPrimaryField({ required String viewId, }) { final payload = DatabaseViewIdPB.create()..value = viewId; return DatabaseEventGetPrimaryField(payload).send(); } Future> createBefore({ FieldType fieldType = FieldType.RichText, String? fieldName, Uint8List? typeOptionData, }) { return createField( viewId: viewId, fieldType: fieldType, fieldName: fieldName, typeOptionData: typeOptionData, position: OrderObjectPositionPB( position: OrderObjectPositionTypePB.Before, objectId: fieldId, ), ); } Future> createAfter({ FieldType fieldType = FieldType.RichText, String? fieldName, Uint8List? typeOptionData, }) { return createField( viewId: viewId, fieldType: fieldType, fieldName: fieldName, typeOptionData: typeOptionData, position: OrderObjectPositionPB( position: OrderObjectPositionTypePB.After, objectId: fieldId, ), ); } Future> updateType({ required FieldType fieldType, String? fieldName, }) => updateFieldType( viewId: viewId, fieldId: fieldId, fieldType: fieldType, fieldName: fieldName, ); Future> delete() => deleteField(viewId: viewId, fieldId: fieldId); Future> duplicate() => duplicateField(viewId: viewId, fieldId: fieldId); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_listener.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef FieldSettingsValue = FlowyResult; class FieldSettingsListener { FieldSettingsListener({required this.viewId}); final String viewId; PublishNotifier? _fieldSettingsNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({ required void Function(FieldSettingsValue) onFieldSettingsChanged, }) { _fieldSettingsNotifier?.addPublishListener(onFieldSettingsChanged); _listener = DatabaseNotificationListener( objectId: viewId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateFieldSettings: result.fold( (payload) => _fieldSettingsNotifier?.value = FlowyResult.success(FieldSettingsPB.fromBuffer(payload)), (error) => _fieldSettingsNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _fieldSettingsNotifier?.dispose(); _fieldSettingsNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class FieldSettingsBackendService { FieldSettingsBackendService({required this.viewId}); final String viewId; Future> getFieldSettings( String fieldId, ) { final id = FieldIdPB(fieldId: fieldId); final ids = RepeatedFieldIdPB()..items.add(id); final payload = FieldIdsPB() ..viewId = viewId ..fieldIds = ids; return DatabaseEventGetFieldSettings(payload).send().then((result) { return result.fold( (repeatedFieldSettings) { final fieldSetting = repeatedFieldSettings.items.first; if (!fieldSetting.hasVisibility()) { fieldSetting.visibility = FieldVisibility.AlwaysShown; } return FlowyResult.success(fieldSetting); }, (r) => FlowyResult.failure(r), ); }); } Future, FlowyError>> getAllFieldSettings() { final payload = DatabaseViewIdPB()..value = viewId; return DatabaseEventGetAllFieldSettings(payload).send().then((result) { return result.fold( (repeatedFieldSettings) { final fieldSettings = []; for (final fieldSetting in repeatedFieldSettings.items) { if (!fieldSetting.hasVisibility()) { fieldSetting.visibility = FieldVisibility.AlwaysShown; } fieldSettings.add(fieldSetting); } return FlowyResult.success(fieldSettings); }, (r) => FlowyResult.failure(r), ); }); } Future> updateFieldSettings({ required String fieldId, FieldVisibility? fieldVisibility, double? width, bool? wrapCellContent, }) { final FieldSettingsChangesetPB payload = FieldSettingsChangesetPB.create() ..viewId = viewId ..fieldId = fieldId; if (fieldVisibility != null) { payload.visibility = fieldVisibility; } if (width != null) { payload.width = width.round(); } if (wrapCellContent != null) { payload.wrapCellContent = wrapCellContent; } return DatabaseEventUpdateFieldSettings(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/filter_listener.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/filter_changeset.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef UpdateFilterNotifiedValue = FlowyResult; class FiltersListener { FiltersListener({required this.viewId}); final String viewId; PublishNotifier? _filterNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({ required void Function(UpdateFilterNotifiedValue) onFilterChanged, }) { _filterNotifier?.addPublishListener(onFilterChanged); _listener = DatabaseNotificationListener( objectId: viewId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateFilter: result.fold( (payload) => _filterNotifier?.value = FlowyResult.success( FilterChangesetNotificationPB.fromBuffer(payload), ), (error) => _filterNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _filterNotifier?.dispose(); _filterNotifier = null; } } class FilterListener { FilterListener({required this.viewId, required this.filterId}); final String viewId; final String filterId; PublishNotifier? _onUpdateNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({void Function(FilterPB)? onUpdated}) { _onUpdateNotifier?.addPublishListener((filter) { onUpdated?.call(filter); }); _listener = DatabaseNotificationListener( objectId: viewId, handler: _handler, ); } void handleChangeset(FilterChangesetNotificationPB changeset) { final filters = changeset.filters.items; final updatedIndex = filters.indexWhere( (filter) => filter.id == filterId, ); if (updatedIndex != -1) { _onUpdateNotifier?.value = filters[updatedIndex]; } } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateFilter: result.fold( (payload) => handleChangeset( FilterChangesetNotificationPB.fromBuffer(payload), ), (error) {}, ); break; default: break; } } Future stop() async { await _listener?.stop(); _onUpdateNotifier?.dispose(); _onUpdateNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart' as $fixnum; class FilterBackendService { const FilterBackendService({required this.viewId}); final String viewId; Future, FlowyError>> getAllFilters() { final payload = DatabaseViewIdPB()..value = viewId; return DatabaseEventGetAllFilters(payload).send().then((result) { return result.fold( (repeated) => FlowyResult.success(repeated.items), (r) => FlowyResult.failure(r), ); }); } Future> insertTextFilter({ required String fieldId, String? filterId, required TextFilterConditionPB condition, required String content, }) { final filter = TextFilterPB() ..condition = condition ..content = content; return filterId == null ? insertFilter( fieldId: fieldId, fieldType: FieldType.RichText, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, fieldType: FieldType.RichText, data: filter.writeToBuffer(), ); } Future> insertCheckboxFilter({ required String fieldId, String? filterId, required CheckboxFilterConditionPB condition, }) { final filter = CheckboxFilterPB()..condition = condition; return filterId == null ? insertFilter( fieldId: fieldId, fieldType: FieldType.Checkbox, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, fieldType: FieldType.Checkbox, data: filter.writeToBuffer(), ); } Future> insertNumberFilter({ required String fieldId, String? filterId, required NumberFilterConditionPB condition, String content = "", }) { final filter = NumberFilterPB() ..condition = condition ..content = content; return filterId == null ? insertFilter( fieldId: fieldId, fieldType: FieldType.Number, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, fieldType: FieldType.Number, data: filter.writeToBuffer(), ); } Future> insertDateFilter({ required String fieldId, required FieldType fieldType, String? filterId, required DateFilterConditionPB condition, int? start, int? end, int? timestamp, }) { final filter = DateFilterPB()..condition = condition; if (timestamp != null) { filter.timestamp = $fixnum.Int64(timestamp); } if (start != null) { filter.start = $fixnum.Int64(start); } if (end != null) { filter.end = $fixnum.Int64(end); } return filterId == null ? insertFilter( fieldId: fieldId, fieldType: fieldType, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, data: filter.writeToBuffer(), ); } Future> insertURLFilter({ required String fieldId, String? filterId, required TextFilterConditionPB condition, String content = "", }) { final filter = TextFilterPB() ..condition = condition ..content = content; return filterId == null ? insertFilter( fieldId: fieldId, fieldType: FieldType.URL, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, fieldType: FieldType.URL, data: filter.writeToBuffer(), ); } Future> insertSelectOptionFilter({ required String fieldId, required FieldType fieldType, required SelectOptionFilterConditionPB condition, String? filterId, List optionIds = const [], }) { final filter = SelectOptionFilterPB() ..condition = condition ..optionIds.addAll(optionIds); return filterId == null ? insertFilter( fieldId: fieldId, fieldType: fieldType, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, fieldType: fieldType, data: filter.writeToBuffer(), ); } Future> insertChecklistFilter({ required String fieldId, required ChecklistFilterConditionPB condition, String? filterId, List optionIds = const [], }) { final filter = ChecklistFilterPB()..condition = condition; return filterId == null ? insertFilter( fieldId: fieldId, fieldType: FieldType.Checklist, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, fieldType: FieldType.Checklist, data: filter.writeToBuffer(), ); } Future> insertTimeFilter({ required String fieldId, String? filterId, required NumberFilterConditionPB condition, String content = "", }) { final filter = TimeFilterPB() ..condition = condition ..content = content; return filterId == null ? insertFilter( fieldId: fieldId, fieldType: FieldType.Time, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, fieldType: FieldType.Time, data: filter.writeToBuffer(), ); } Future> insertFilter({ required String fieldId, required FieldType fieldType, required List data, }) async { final filterData = FilterDataPB() ..fieldId = fieldId ..fieldType = fieldType ..data = data; final insertFilterPayload = InsertFilterPB()..data = filterData; final payload = DatabaseSettingChangesetPB() ..viewId = viewId ..insertFilter = insertFilterPayload; final result = await DatabaseEventUpdateDatabaseSetting(payload).send(); return result.fold( (l) => FlowyResult.success(l), (err) { Log.error(err); return FlowyResult.failure(err); }, ); } Future> updateFilter({ required String filterId, required String fieldId, required FieldType fieldType, required List data, }) async { final filterData = FilterDataPB() ..fieldId = fieldId ..fieldType = fieldType ..data = data; final updateFilterPayload = UpdateFilterDataPB() ..filterId = filterId ..data = filterData; final payload = DatabaseSettingChangesetPB() ..viewId = viewId ..updateFilterData = updateFilterPayload; final result = await DatabaseEventUpdateDatabaseSetting(payload).send(); return result.fold( (l) => FlowyResult.success(l), (err) { Log.error(err); return FlowyResult.failure(err); }, ); } Future> insertMediaFilter({ required String fieldId, String? filterId, required MediaFilterConditionPB condition, String content = "", }) { final filter = MediaFilterPB() ..condition = condition ..content = content; return filterId == null ? insertFilter( fieldId: fieldId, fieldType: FieldType.Media, data: filter.writeToBuffer(), ) : updateFilter( filterId: filterId, fieldId: fieldId, fieldType: FieldType.Media, data: filter.writeToBuffer(), ); } Future> deleteFilter({ required String filterId, }) async { final deleteFilterPayload = DeleteFilterPB()..filterId = filterId; final payload = DatabaseSettingChangesetPB() ..viewId = viewId ..deleteFilter = deleteFilterPayload; final result = await DatabaseEventUpdateDatabaseSetting(payload).send(); return result.fold( (l) => FlowyResult.success(l), (err) { Log.error(err); return FlowyResult.failure(err); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/group_listener.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/group_changeset.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef GroupUpdateValue = FlowyResult; typedef GroupByNewFieldValue = FlowyResult, FlowyError>; class DatabaseGroupListener { DatabaseGroupListener(this.viewId); final String viewId; PublishNotifier? _numOfGroupsNotifier = PublishNotifier(); PublishNotifier? _groupByFieldNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({ required void Function(GroupUpdateValue) onNumOfGroupsChanged, required void Function(GroupByNewFieldValue) onGroupByNewField, }) { _numOfGroupsNotifier?.addPublishListener(onNumOfGroupsChanged); _groupByFieldNotifier?.addPublishListener(onGroupByNewField); _listener = DatabaseNotificationListener( objectId: viewId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateNumOfGroups: result.fold( (payload) => _numOfGroupsNotifier?.value = FlowyResult.success(GroupChangesPB.fromBuffer(payload)), (error) => _numOfGroupsNotifier?.value = FlowyResult.failure(error), ); break; case DatabaseNotification.DidGroupByField: result.fold( (payload) => _groupByFieldNotifier?.value = FlowyResult.success( GroupChangesPB.fromBuffer(payload).initialGroups, ), (error) => _groupByFieldNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _numOfGroupsNotifier?.dispose(); _numOfGroupsNotifier = null; _groupByFieldNotifier?.dispose(); _groupByFieldNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class GroupBackendService { GroupBackendService(this.viewId); final String viewId; Future> groupByField({ required String fieldId, required List settingContent, }) { final payload = GroupByFieldPayloadPB.create() ..viewId = viewId ..fieldId = fieldId ..settingContent = settingContent; return DatabaseEventSetGroupByField(payload).send(); } Future> updateGroup({ required String groupId, String? name, bool? visible, }) { final payload = UpdateGroupPB.create() ..viewId = viewId ..groupId = groupId; if (name != null) { payload.name = name; } if (visible != null) { payload.visible = visible; } return DatabaseEventUpdateGroup(payload).send(); } Future> createGroup({ required String name, String groupConfigId = "", }) { final payload = CreateGroupPayloadPB.create() ..viewId = viewId ..name = name; return DatabaseEventCreateGroup(payload).send(); } Future> deleteGroup({ required String groupId, }) { final payload = DeleteGroupPayloadPB.create() ..viewId = viewId ..groupId = groupId; return DatabaseEventDeleteGroup(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/layout_service.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; ViewLayoutPB viewLayoutFromDatabaseLayout(DatabaseLayoutPB databaseLayout) { switch (databaseLayout) { case DatabaseLayoutPB.Board: return ViewLayoutPB.Board; case DatabaseLayoutPB.Calendar: return ViewLayoutPB.Calendar; case DatabaseLayoutPB.Grid: return ViewLayoutPB.Grid; default: throw UnimplementedError; } } DatabaseLayoutPB databaseLayoutFromViewLayout(ViewLayoutPB viewLayout) { switch (viewLayout) { case ViewLayoutPB.Board: return DatabaseLayoutPB.Board; case ViewLayoutPB.Calendar: return DatabaseLayoutPB.Calendar; case ViewLayoutPB.Grid: return DatabaseLayoutPB.Grid; default: throw UnimplementedError; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/layout_setting_listener.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef LayoutSettingsValue = FlowyResult; class DatabaseLayoutSettingListener { DatabaseLayoutSettingListener(this.viewId); final String viewId; PublishNotifier>? _settingNotifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({ required void Function(LayoutSettingsValue) onLayoutChanged, }) { _settingNotifier?.addPublishListener(onLayoutChanged); _listener = DatabaseNotificationListener( objectId: viewId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateLayoutSettings: result.fold( (payload) => _settingNotifier?.value = FlowyResult.success(DatabaseLayoutSettingPB.fromBuffer(payload)), (error) => _settingNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _settingNotifier?.dispose(); _settingNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/row_listener.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef DidFetchRowCallback = void Function(DidFetchRowPB); typedef RowMetaCallback = void Function(RowMetaPB); class RowListener { RowListener(this.rowId); final String rowId; DidFetchRowCallback? _onRowFetchedCallback; RowMetaCallback? _onMetaChangedCallback; DatabaseNotificationListener? _listener; /// OnMetaChanged will be called when the row meta is changed. /// OnRowFetched will be called when the row is fetched from remote storage void start({ RowMetaCallback? onMetaChanged, DidFetchRowCallback? onRowFetched, }) { _onMetaChangedCallback = onMetaChanged; _onRowFetchedCallback = onRowFetched; _listener = DatabaseNotificationListener( objectId: rowId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateRowMeta: result.fold( (payload) { if (_onMetaChangedCallback != null) { _onMetaChangedCallback!(RowMetaPB.fromBuffer(payload)); } }, (error) => Log.error(error), ); break; case DatabaseNotification.DidFetchRow: result.fold( (payload) { if (_onRowFetchedCallback != null) { _onRowFetchedCallback!(DidFetchRowPB.fromBuffer(payload)); } }, (error) => Log.error(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _onMetaChangedCallback = null; _onRowFetchedCallback = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/row_meta_listener.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef RowMetaCallback = void Function(RowMetaPB); class RowMetaListener { RowMetaListener(this.rowId); final String rowId; RowMetaCallback? _callback; DatabaseNotificationListener? _listener; void start({required RowMetaCallback callback}) { _callback = callback; _listener = DatabaseNotificationListener( objectId: rowId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateRowMeta: result.fold( (payload) { if (_callback != null) { _callback!(RowMetaPB.fromBuffer(payload)); } }, (error) => Log.error(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _callback = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:nanoid/nanoid.dart'; class SelectOptionCellBackendService { SelectOptionCellBackendService({ required this.viewId, required this.fieldId, required this.rowId, }); final String viewId; final String fieldId; final String rowId; Future> create({ required String name, SelectOptionColorPB? color, bool isSelected = true, }) { final option = SelectOptionPB() ..id = nanoid(4) ..name = name; if (color != null) { option.color = color; } final payload = RepeatedSelectOptionPayload() ..viewId = viewId ..fieldId = fieldId ..rowId = rowId ..items.add(option); return DatabaseEventInsertOrUpdateSelectOption(payload).send(); } Future> update({ required SelectOptionPB option, }) { final payload = RepeatedSelectOptionPayload() ..items.add(option) ..viewId = viewId ..fieldId = fieldId ..rowId = rowId; return DatabaseEventInsertOrUpdateSelectOption(payload).send(); } Future> delete({ required Iterable options, }) { final payload = RepeatedSelectOptionPayload() ..items.addAll(options) ..viewId = viewId ..fieldId = fieldId ..rowId = rowId; return DatabaseEventDeleteSelectOption(payload).send(); } Future> select({ required Iterable optionIds, }) { final payload = SelectOptionCellChangesetPB() ..cellIdentifier = _cellIdentifier() ..insertOptionIds.addAll(optionIds); return DatabaseEventUpdateSelectOptionCell(payload).send(); } Future> unselect({ required Iterable optionIds, }) { final payload = SelectOptionCellChangesetPB() ..cellIdentifier = _cellIdentifier() ..deleteOptionIds.addAll(optionIds); return DatabaseEventUpdateSelectOptionCell(payload).send(); } CellIdPB _cellIdentifier() { return CellIdPB() ..viewId = viewId ..fieldId = fieldId ..rowId = rowId; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/sort_listener.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/core/notification/grid_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef SortNotifiedValue = FlowyResult; class SortsListener { SortsListener({required this.viewId}); final String viewId; PublishNotifier? _notifier = PublishNotifier(); DatabaseNotificationListener? _listener; void start({ required void Function(SortNotifiedValue) onSortChanged, }) { _notifier?.addPublishListener(onSortChanged); _listener = DatabaseNotificationListener( objectId: viewId, handler: _handler, ); } void _handler( DatabaseNotification ty, FlowyResult result, ) { switch (ty) { case DatabaseNotification.DidUpdateSort: result.fold( (payload) => _notifier?.value = FlowyResult.success( SortChangesetNotificationPB.fromBuffer(payload), ), (error) => _notifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _notifier?.dispose(); _notifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class SortBackendService { SortBackendService({required this.viewId}); final String viewId; Future, FlowyError>> getAllSorts() { final payload = DatabaseViewIdPB()..value = viewId; return DatabaseEventGetAllSorts(payload).send().then((result) { return result.fold( (repeated) => FlowyResult.success(repeated.items), (r) => FlowyResult.failure(r), ); }); } Future> updateSort({ required String sortId, required String fieldId, required SortConditionPB condition, }) { final insertSortPayload = UpdateSortPayloadPB.create() ..viewId = viewId ..sortId = sortId ..fieldId = fieldId ..condition = condition; final payload = DatabaseSettingChangesetPB.create() ..viewId = viewId ..updateSort = insertSortPayload; return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { return result.fold( (l) => FlowyResult.success(l), (err) { Log.error(err); return FlowyResult.failure(err); }, ); }); } Future> insertSort({ required String fieldId, required SortConditionPB condition, }) { final insertSortPayload = UpdateSortPayloadPB.create() ..fieldId = fieldId ..viewId = viewId ..condition = condition; final payload = DatabaseSettingChangesetPB.create() ..viewId = viewId ..updateSort = insertSortPayload; return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { return result.fold( (l) => FlowyResult.success(l), (err) { Log.error(err); return FlowyResult.failure(err); }, ); }); } Future> reorderSort({ required String fromSortId, required String toSortId, }) { final payload = DatabaseSettingChangesetPB() ..viewId = viewId ..reorderSort = (ReorderSortPayloadPB() ..viewId = viewId ..fromSortId = fromSortId ..toSortId = toSortId); return DatabaseEventUpdateDatabaseSetting(payload).send(); } Future> deleteSort({ required String sortId, }) { final deleteSortPayload = DeleteSortPayloadPB.create() ..sortId = sortId ..viewId = viewId; final payload = DatabaseSettingChangesetPB.create() ..viewId = viewId ..deleteSort = deleteSortPayload; return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { return result.fold( (l) => FlowyResult.success(l), (err) { Log.error(err); return FlowyResult.failure(err); }, ); }); } Future> deleteAllSorts() { final payload = DatabaseViewIdPB(value: viewId); return DatabaseEventDeleteAllSorts(payload).send().then((result) { return result.fold( (l) => FlowyResult.success(l), (err) { Log.error(err); return FlowyResult.failure(err); }, ); }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/domain/type_option_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class TypeOptionBackendService { TypeOptionBackendService({ required this.viewId, required this.fieldId, }); final String viewId; final String fieldId; Future> newOption({ required String name, }) { final payload = CreateSelectOptionPayloadPB.create() ..optionName = name ..viewId = viewId ..fieldId = fieldId; return DatabaseEventCreateSelectOption(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/calculations_bloc.dart ================================================ import 'package:appflowy/plugins/database/application/calculations/calculations_listener.dart'; import 'package:appflowy/plugins/database/application/calculations/calculations_service.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calculation_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pbenum.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'calculations_bloc.freezed.dart'; class CalculationsBloc extends Bloc { CalculationsBloc({ required this.viewId, required FieldController fieldController, }) : _fieldController = fieldController, _calculationsListener = CalculationsListener(viewId: viewId), _calculationsService = CalculationsBackendService(viewId: viewId), super(CalculationsState.initial()) { _dispatch(); } final String viewId; final FieldController _fieldController; final CalculationsListener _calculationsListener; late final CalculationsBackendService _calculationsService; @override Future close() async { _fieldController.removeListener(onFieldsListener: _onReceiveFields); await _calculationsListener.stop(); await super.close(); } void _dispatch() { on((event, emit) async { await event.when( started: () async { _startListening(); await _getAllCalculations(); if (!isClosed) { add( CalculationsEvent.didReceiveFieldUpdate( _fieldController.fieldInfos, ), ); } }, didReceiveFieldUpdate: (fields) async { emit( state.copyWith( fields: fields .where( (e) => e.visibility != null && e.visibility != FieldVisibility.AlwaysHidden, ) .toList(), ), ); }, didReceiveCalculationsUpdate: (calculationsMap) async { emit( state.copyWith( calculationsByFieldId: calculationsMap, ), ); }, updateCalculationType: (fieldId, type, calculationId) async { await _calculationsService.updateCalculation( fieldId, type, calculationId: calculationId, ); }, removeCalculation: (fieldId, calculationId) async { await _calculationsService.removeCalculation(fieldId, calculationId); }, ); }); } void _startListening() { _fieldController.addListener( listenWhen: () => !isClosed, onReceiveFields: _onReceiveFields, ); _calculationsListener.start( onCalculationChanged: (changesetOrFailure) { if (isClosed) { return; } changesetOrFailure.fold( (changeset) { final calculationsMap = {...state.calculationsByFieldId}; if (changeset.insertCalculations.isNotEmpty) { for (final insert in changeset.insertCalculations) { calculationsMap[insert.fieldId] = insert; } } if (changeset.updateCalculations.isNotEmpty) { for (final update in changeset.updateCalculations) { calculationsMap.removeWhere((key, _) => key == update.fieldId); calculationsMap.addAll({update.fieldId: update}); } } if (changeset.deleteCalculations.isNotEmpty) { for (final delete in changeset.deleteCalculations) { calculationsMap.removeWhere((key, _) => key == delete.fieldId); } } add( CalculationsEvent.didReceiveCalculationsUpdate( calculationsMap, ), ); }, (_) => null, ); }, ); } void _onReceiveFields(List fields) => add(CalculationsEvent.didReceiveFieldUpdate(fields)); Future _getAllCalculations() async { final calculationsOrFailure = await _calculationsService.getCalculations(); if (isClosed) { return; } final RepeatedCalculationsPB? calculations = calculationsOrFailure.fold((s) => s, (e) => null); if (calculations != null) { final calculationMap = {}; for (final calculation in calculations.items) { calculationMap[calculation.fieldId] = calculation; } add(CalculationsEvent.didReceiveCalculationsUpdate(calculationMap)); } } } @freezed class CalculationsEvent with _$CalculationsEvent { const factory CalculationsEvent.started() = _Started; const factory CalculationsEvent.didReceiveFieldUpdate( List fields, ) = _DidReceiveFieldUpdate; const factory CalculationsEvent.didReceiveCalculationsUpdate( Map calculationsByFieldId, ) = _DidReceiveCalculationsUpdate; const factory CalculationsEvent.updateCalculationType( String fieldId, CalculationType type, { @Default(null) String? calculationId, }) = _UpdateCalculationType; const factory CalculationsEvent.removeCalculation( String fieldId, String calculationId, ) = _RemoveCalculation; } @freezed class CalculationsState with _$CalculationsState { const factory CalculationsState({ required List fields, required Map calculationsByFieldId, }) = _CalculationsState; factory CalculationsState.initial() => const CalculationsState( fields: [], calculationsByFieldId: {}, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; extension AvailableCalculations on FieldType { List calculationsForFieldType() { final calculationTypes = [ CalculationType.Count, ]; // These FieldTypes cannot be empty, or might hold secondary // data causing them to be seen as not empty when in fact they // are empty. if (![ FieldType.URL, FieldType.Checkbox, FieldType.LastEditedTime, FieldType.CreatedTime, ].contains(this)) { calculationTypes.addAll([ CalculationType.CountEmpty, CalculationType.CountNonEmpty, ]); } switch (this) { case FieldType.Number: calculationTypes.addAll([ CalculationType.Sum, CalculationType.Average, CalculationType.Min, CalculationType.Max, CalculationType.Median, ]); break; default: break; } return calculationTypes; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_editor_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'filter_editor_bloc.freezed.dart'; class FilterEditorBloc extends Bloc { FilterEditorBloc({required this.viewId, required this.fieldController}) : _filterBackendSvc = FilterBackendService(viewId: viewId), super( FilterEditorState.initial( viewId, fieldController.filters, _getCreatableFilter(fieldController.fieldInfos), ), ) { _dispatch(); _startListening(); } final String viewId; final FieldController fieldController; final FilterBackendService _filterBackendSvc; void Function(List)? _onFilterFn; void Function(List)? _onFieldFn; void _dispatch() { on( (event, emit) async { await event.when( didReceiveFilters: (filters) { emit(state.copyWith(filters: filters)); }, didReceiveFields: (List fields) { emit( state.copyWith( fields: _getCreatableFilter(fields), ), ); }, createFilter: (field) { return _createDefaultFilter(null, field); }, changeFilteringField: (filterId, field) { return _createDefaultFilter(filterId, field); }, updateFilter: (filter) { return _filterBackendSvc.updateFilter( filterId: filter.filterId, fieldId: filter.fieldId, fieldType: filter.fieldType, data: filter.writeToBuffer(), ); }, deleteFilter: (filterId) async { return _filterBackendSvc.deleteFilter(filterId: filterId); }, ); }, ); } void _startListening() { _onFilterFn = (filters) { add(FilterEditorEvent.didReceiveFilters(filters)); }; _onFieldFn = (fields) { add(FilterEditorEvent.didReceiveFields(fields)); }; fieldController.addListener( onFilters: _onFilterFn, onReceiveFields: _onFieldFn, ); } @override Future close() async { if (_onFilterFn != null) { fieldController.removeListener(onFiltersListener: _onFilterFn!); _onFilterFn = null; } if (_onFieldFn != null) { fieldController.removeListener(onFieldsListener: _onFieldFn!); _onFieldFn = null; } return super.close(); } Future> _createDefaultFilter( String? filterId, FieldInfo field, ) async { final fieldId = field.id; switch (field.fieldType) { case FieldType.Checkbox: return _filterBackendSvc.insertCheckboxFilter( filterId: filterId, fieldId: fieldId, condition: CheckboxFilterConditionPB.IsChecked, ); case FieldType.DateTime: case FieldType.LastEditedTime: case FieldType.CreatedTime: final now = DateTime.now(); final timestamp = DateTime(now.year, now.month, now.day).millisecondsSinceEpoch ~/ 1000; return _filterBackendSvc.insertDateFilter( filterId: filterId, fieldId: fieldId, fieldType: field.fieldType, condition: DateFilterConditionPB.DateStartsOn, timestamp: timestamp, ); case FieldType.MultiSelect: return _filterBackendSvc.insertSelectOptionFilter( filterId: filterId, fieldId: fieldId, condition: SelectOptionFilterConditionPB.OptionContains, fieldType: FieldType.MultiSelect, ); case FieldType.Checklist: return _filterBackendSvc.insertChecklistFilter( filterId: filterId, fieldId: fieldId, condition: ChecklistFilterConditionPB.IsIncomplete, ); case FieldType.Number: return _filterBackendSvc.insertNumberFilter( filterId: filterId, fieldId: fieldId, condition: NumberFilterConditionPB.Equal, ); case FieldType.Time: return _filterBackendSvc.insertTimeFilter( filterId: filterId, fieldId: fieldId, condition: NumberFilterConditionPB.Equal, ); case FieldType.RichText: return _filterBackendSvc.insertTextFilter( filterId: filterId, fieldId: fieldId, condition: TextFilterConditionPB.TextContains, content: '', ); case FieldType.SingleSelect: return _filterBackendSvc.insertSelectOptionFilter( filterId: filterId, fieldId: fieldId, condition: SelectOptionFilterConditionPB.OptionIs, fieldType: FieldType.SingleSelect, ); case FieldType.URL: return _filterBackendSvc.insertURLFilter( filterId: filterId, fieldId: fieldId, condition: TextFilterConditionPB.TextContains, ); case FieldType.Media: return _filterBackendSvc.insertMediaFilter( filterId: filterId, fieldId: fieldId, condition: MediaFilterConditionPB.MediaIsNotEmpty, ); default: throw UnimplementedError(); } } } @freezed class FilterEditorEvent with _$FilterEditorEvent { const factory FilterEditorEvent.didReceiveFilters( List filters, ) = _DidReceiveFilters; const factory FilterEditorEvent.didReceiveFields(List fields) = _DidReceiveFields; const factory FilterEditorEvent.createFilter(FieldInfo field) = _CreateFilter; const factory FilterEditorEvent.updateFilter(DatabaseFilter filter) = _UpdateFilter; const factory FilterEditorEvent.changeFilteringField( String filterId, FieldInfo field, ) = _ChangeFilteringField; const factory FilterEditorEvent.deleteFilter(String filterId) = _DeleteFilter; } @freezed class FilterEditorState with _$FilterEditorState { const factory FilterEditorState({ required String viewId, required List filters, required List fields, }) = _FilterEditorState; factory FilterEditorState.initial( String viewId, List filterInfos, List fields, ) => FilterEditorState( viewId: viewId, filters: filterInfos, fields: fields, ); } List _getCreatableFilter(List fieldInfos) { final List creatableFields = List.from(fieldInfos); creatableFields.retainWhere( (field) => field.fieldType.canCreateFilter && !field.isGroupField, ); return creatableFields; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_loader.dart ================================================ import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; abstract class SelectOptionFilterDelegate { const SelectOptionFilterDelegate(); List getOptions(FieldInfo fieldInfo); } class SingleSelectOptionFilterDelegateImpl implements SelectOptionFilterDelegate { const SingleSelectOptionFilterDelegateImpl(); @override List getOptions(FieldInfo fieldInfo) { final parser = SingleSelectTypeOptionDataParser(); return parser.fromBuffer(fieldInfo.field.typeOptionData).options; } } class MultiSelectOptionFilterDelegateImpl implements SelectOptionFilterDelegate { const MultiSelectOptionFilterDelegateImpl(); @override List getOptions(FieldInfo fieldInfo) { return MultiSelectTypeOptionDataParser() .fromBuffer(fieldInfo.field.typeOptionData) .options; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_accessory_bloc.dart ================================================ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'grid_accessory_bloc.freezed.dart'; class DatabaseViewSettingExtensionBloc extends Bloc< DatabaseViewSettingExtensionEvent, DatabaseViewSettingExtensionState> { DatabaseViewSettingExtensionBloc({required this.viewId}) : super(DatabaseViewSettingExtensionState.initial(viewId)) { on( (event, emit) async { event.when( initial: () {}, toggleMenu: () { emit(state.copyWith(isVisible: !state.isVisible)); }, ); }, ); } final String viewId; } @freezed class DatabaseViewSettingExtensionEvent with _$DatabaseViewSettingExtensionEvent { const factory DatabaseViewSettingExtensionEvent.initial() = _Initial; const factory DatabaseViewSettingExtensionEvent.toggleMenu() = _MenuVisibleChange; } @freezed class DatabaseViewSettingExtensionState with _$DatabaseViewSettingExtensionState { const factory DatabaseViewSettingExtensionState({ required String viewId, required bool isVisible, }) = _DatabaseViewSettingExtensionState; factory DatabaseViewSettingExtensionState.initial( String viewId, ) => DatabaseViewSettingExtensionState( viewId: viewId, isVisible: false, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/defines.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../application/database_controller.dart'; part 'grid_bloc.freezed.dart'; class GridBloc extends Bloc { GridBloc({ required ViewPB view, required this.databaseController, this.shrinkWrapped = false, }) : super(GridState.initial(view.id)) { _dispatch(); } final DatabaseController databaseController; /// When true will emit the count of visible rows to show /// final bool shrinkWrapped; String get viewId => databaseController.viewId; UserProfilePB? _userProfile; UserProfilePB? get userProfile => _userProfile; DatabaseCallbacks? _databaseCallbacks; @override Future close() async { databaseController.removeListener(onDatabaseChanged: _databaseCallbacks); _databaseCallbacks = null; await super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { final response = await UserEventGetUserProfile().send(); response.fold( (userProfile) => _userProfile = userProfile, (err) => Log.error(err), ); _startListening(); await _openGrid(emit); }, openRowDetail: (row) { emit( state.copyWith( createdRow: row, openRowDetail: true, ), ); }, createRow: (openRowDetail) async { final lastVisibleRowId = shrinkWrapped ? state.lastVisibleRow?.rowId : null; final result = await RowBackendService.createRow( viewId: viewId, position: lastVisibleRowId != null ? OrderObjectPositionTypePB.After : null, targetRowId: lastVisibleRowId, ); result.fold( (createdRow) => emit( state.copyWith( createdRow: createdRow, openRowDetail: openRowDetail ?? false, visibleRows: state.visibleRows + 1, ), ), (err) => Log.error(err), ); }, resetCreatedRow: () { emit(state.copyWith(createdRow: null, openRowDetail: false)); }, deleteRow: (rowInfo) async { await RowBackendService.deleteRows(viewId, [rowInfo.rowId]); }, moveRow: (int from, int to) { final List rows = [...state.rowInfos]; final fromRow = rows[from].rowId; final toRow = rows[to].rowId; rows.insert(to, rows.removeAt(from)); emit(state.copyWith(rowInfos: rows)); databaseController.moveRow(fromRowId: fromRow, toRowId: toRow); }, didReceiveFieldUpdate: (fields) { emit(state.copyWith(fields: fields)); }, didLoadRows: (newRowInfos, reason) { emit( state.copyWith( rowInfos: newRowInfos, rowCount: newRowInfos.length, reason: reason, ), ); }, didReceveFilters: (filters) { emit(state.copyWith(filters: filters)); }, didReceveSorts: (sorts) { emit(state.copyWith(reorderable: sorts.isEmpty, sorts: sorts)); }, loadMoreRows: () { emit(state.copyWith(visibleRows: state.visibleRows + 25)); }, ); }, ); } RowCache get rowCache => databaseController.rowCache; void _startListening() { _databaseCallbacks = DatabaseCallbacks( onNumOfRowsChanged: (rowInfos, _, reason) { if (!isClosed) { add(GridEvent.didLoadRows(rowInfos, reason)); } }, onRowsCreated: (rows) { for (final row in rows) { if (!isClosed && row.isHiddenInView) { add(GridEvent.openRowDetail(row.rowMeta)); } } }, onRowsUpdated: (rows, reason) { // TODO(nathan): separate different reasons if (!isClosed) { add( GridEvent.didLoadRows(databaseController.rowCache.rowInfos, reason), ); } }, onFieldsChanged: (fields) { if (!isClosed) { add(GridEvent.didReceiveFieldUpdate(fields)); } }, onFiltersChanged: (filters) { if (!isClosed) { add(GridEvent.didReceveFilters(filters)); } }, onSortsChanged: (sorts) { if (!isClosed) { add(GridEvent.didReceveSorts(sorts)); } }, ); databaseController.addListener(onDatabaseChanged: _databaseCallbacks); } Future _openGrid(Emitter emit) async { final result = await databaseController.open(); result.fold( (grid) { databaseController.setIsLoading(false); emit( state.copyWith( loadingState: LoadingState.finish(FlowyResult.success(null)), ), ); }, (err) => emit( state.copyWith( loadingState: LoadingState.finish(FlowyResult.failure(err)), ), ), ); } } @freezed class GridEvent with _$GridEvent { const factory GridEvent.initial() = InitialGrid; const factory GridEvent.openRowDetail(RowMetaPB row) = _OpenRowDetail; const factory GridEvent.createRow({bool? openRowDetail}) = _CreateRow; const factory GridEvent.resetCreatedRow() = _ResetCreatedRow; const factory GridEvent.deleteRow(RowInfo rowInfo) = _DeleteRow; const factory GridEvent.moveRow(int from, int to) = _MoveRow; const factory GridEvent.didLoadRows( List rows, ChangedReason reason, ) = _DidReceiveRowUpdate; const factory GridEvent.didReceiveFieldUpdate( List fields, ) = _DidReceiveFieldUpdate; const factory GridEvent.didReceveFilters(List filters) = _DidReceiveFilters; const factory GridEvent.didReceveSorts(List sorts) = _DidReceiveSorts; const factory GridEvent.loadMoreRows() = _LoadMoreRows; } @freezed class GridState with _$GridState { const factory GridState({ required String viewId, required List fields, required List rowInfos, required int rowCount, required RowMetaPB? createdRow, required LoadingState loadingState, required bool reorderable, required ChangedReason reason, required List sorts, required List filters, required bool openRowDetail, @Default(0) int visibleRows, }) = _GridState; factory GridState.initial(String viewId) => GridState( fields: [], rowInfos: [], rowCount: 0, createdRow: null, viewId: viewId, reorderable: true, loadingState: const LoadingState.loading(), reason: const InitialListState(), filters: [], sorts: [], openRowDetail: false, visibleRows: 25, ); } extension _LastVisibleRow on GridState { /// Returns the last visible [RowInfo] in the list of [rowInfos]. /// Only returns if the visibleRows is less than the rowCount, otherwise returns null. /// RowInfo? get lastVisibleRow { if (visibleRows < rowCount) { return rowInfos[visibleRows - 1]; } return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_header_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../domain/field_service.dart'; part 'grid_header_bloc.freezed.dart'; class GridHeaderBloc extends Bloc { GridHeaderBloc({required this.viewId, required this.fieldController}) : super(GridHeaderState.initial()) { _dispatch(); } final String viewId; final FieldController fieldController; @override Future close() async { fieldController.removeListener(onFieldsListener: _onReceiveFields); await super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () { _startListening(); add( GridHeaderEvent.didReceiveFieldUpdate(fieldController.fieldInfos), ); }, didReceiveFieldUpdate: (List fields) { emit( state.copyWith( fields: fields .where( (element) => element.visibility != null && element.visibility != FieldVisibility.AlwaysHidden, ) .toList(), ), ); }, startEditingField: (fieldId) { emit(state.copyWith(editingFieldId: fieldId)); }, startEditingNewField: (fieldId) { emit(state.copyWith(editingFieldId: fieldId, newFieldId: fieldId)); }, endEditingField: () { emit(state.copyWith(editingFieldId: null, newFieldId: null)); }, moveField: (fromIndex, toIndex) async { await _moveField(fromIndex, toIndex, emit); }, ); }, ); } Future _moveField( int fromIndex, int toIndex, Emitter emit, ) async { final fromId = state.fields[fromIndex].id; final toId = state.fields[toIndex].id; final fields = List.from(state.fields); fields.insert(toIndex, fields.removeAt(fromIndex)); emit(state.copyWith(fields: fields)); final result = await FieldBackendService.moveField( viewId: viewId, fromFieldId: fromId, toFieldId: toId, ); result.fold((l) {}, (err) => Log.error(err)); } void _startListening() { fieldController.addListener( onReceiveFields: _onReceiveFields, listenWhen: () => !isClosed, ); } void _onReceiveFields(List fields) => add(GridHeaderEvent.didReceiveFieldUpdate(fields)); } @freezed class GridHeaderEvent with _$GridHeaderEvent { const factory GridHeaderEvent.initial() = _InitialHeader; const factory GridHeaderEvent.didReceiveFieldUpdate(List fields) = _DidReceiveFieldUpdate; const factory GridHeaderEvent.startEditingField(String fieldId) = _StartEditingField; const factory GridHeaderEvent.startEditingNewField(String fieldId) = _StartEditingNewField; const factory GridHeaderEvent.endEditingField() = _EndEditingField; const factory GridHeaderEvent.moveField( int fromIndex, int toIndex, ) = _MoveField; } @freezed class GridHeaderState with _$GridHeaderState { const factory GridHeaderState({ required List fields, required String? editingFieldId, required String? newFieldId, }) = _GridHeaderState; factory GridHeaderState.initial() => const GridHeaderState(fields: [], editingFieldId: null, newFieldId: null); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/row/mobile_row_detail_bloc.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'mobile_row_detail_bloc.freezed.dart'; class MobileRowDetailBloc extends Bloc { MobileRowDetailBloc({required this.databaseController}) : super(MobileRowDetailState.initial()) { rowBackendService = RowBackendService(viewId: databaseController.viewId); _dispatch(); } final DatabaseController databaseController; late final RowBackendService rowBackendService; UserProfilePB? _userProfile; UserProfilePB? get userProfile => _userProfile; DatabaseCallbacks? _databaseCallbacks; @override Future close() async { databaseController.removeListener(onDatabaseChanged: _databaseCallbacks); _databaseCallbacks = null; await super.close(); } void _dispatch() { on( (event, emit) { event.when( initial: (rowId) async { _startListening(); emit( state.copyWith( isLoading: false, currentRowId: rowId, rowInfos: databaseController.rowCache.rowInfos, ), ); final result = await UserEventGetUserProfile().send(); result.fold( (profile) => _userProfile = profile, (error) => Log.error(error), ); }, didLoadRows: (rows) { emit(state.copyWith(rowInfos: rows)); }, changeRowId: (rowId) { emit(state.copyWith(currentRowId: rowId)); }, addCover: (rowCover) async { if (state.currentRowId == null) { return; } await rowBackendService.updateMeta( rowId: state.currentRowId!, cover: rowCover, ); }, ); }, ); } void _startListening() { _databaseCallbacks = DatabaseCallbacks( onNumOfRowsChanged: (rowInfos, _, reason) { if (!isClosed) { add(MobileRowDetailEvent.didLoadRows(rowInfos)); } }, onRowsUpdated: (rows, reason) { if (!isClosed) { add( MobileRowDetailEvent.didLoadRows( databaseController.rowCache.rowInfos, ), ); } }, ); databaseController.addListener(onDatabaseChanged: _databaseCallbacks); } } @freezed class MobileRowDetailEvent with _$MobileRowDetailEvent { const factory MobileRowDetailEvent.initial(String rowId) = _Initial; const factory MobileRowDetailEvent.didLoadRows(List rows) = _DidLoadRows; const factory MobileRowDetailEvent.changeRowId(String rowId) = _ChangeRowId; const factory MobileRowDetailEvent.addCover(RowCoverPB cover) = _AddCover; } @freezed class MobileRowDetailState with _$MobileRowDetailState { const factory MobileRowDetailState({ required bool isLoading, required String? currentRowId, required List rowInfos, }) = _MobileRowDetailState; factory MobileRowDetailState.initial() { return const MobileRowDetailState( isLoading: true, rowInfos: [], currentRowId: null, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_bloc.dart ================================================ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../application/row/row_cache.dart'; import '../../../application/row/row_controller.dart'; import '../../../application/row/row_service.dart'; part 'row_bloc.freezed.dart'; class RowBloc extends Bloc { RowBloc({ required this.fieldController, required this.rowId, required this.viewId, required RowController rowController, }) : _rowBackendSvc = RowBackendService(viewId: viewId), _rowController = rowController, super(RowState.initial()) { _dispatch(); _startListening(); _init(); rowController.initialize(); } final FieldController fieldController; final RowBackendService _rowBackendSvc; final RowController _rowController; final String viewId; final String rowId; @override Future close() async { await _rowController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { event.when( createRow: () { _rowBackendSvc.createRowAfter(rowId); }, didReceiveCells: (List cellContexts, reason) { final visibleCellContexts = cellContexts .where( (cellContext) => fieldController .getField(cellContext.fieldId)! .fieldSettings! .visibility .isVisibleState(), ) .toList(); emit( state.copyWith( cellContexts: visibleCellContexts, changeReason: reason, ), ); }, ); }, ); } void _startListening() => _rowController.addListener(onRowChanged: _onRowChanged); void _onRowChanged(List cells, ChangedReason reason) { if (!isClosed) { add(RowEvent.didReceiveCells(cells, reason)); } } void _init() { add( RowEvent.didReceiveCells( _rowController.loadCells(), const ChangedReason.setInitialRows(), ), ); } } @freezed class RowEvent with _$RowEvent { const factory RowEvent.createRow() = _CreateRow; const factory RowEvent.didReceiveCells( List cellsByFieldId, ChangedReason reason, ) = _DidReceiveCells; } @freezed class RowState with _$RowState { const factory RowState({ required List cellContexts, ChangedReason? changeReason, }) = _RowState; factory RowState.initial() => const RowState(cellContexts: []); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy/plugins/database/domain/row_meta_listener.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'row_detail_bloc.freezed.dart'; class RowDetailBloc extends Bloc { RowDetailBloc({ required this.fieldController, required this.rowController, }) : _metaListener = RowMetaListener(rowController.rowId), _rowService = RowBackendService(viewId: rowController.viewId), super(RowDetailState.initial(rowController.rowMeta)) { _dispatch(); _startListening(); _init(); rowController.initialize(); } final FieldController fieldController; final RowController rowController; final RowMetaListener _metaListener; final RowBackendService _rowService; final List allCells = []; @override Future close() async { await rowController.dispose(); await _metaListener.stop(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( didReceiveCellDatas: (visibleCells, numHiddenFields) { emit( state.copyWith( visibleCells: visibleCells, numHiddenFields: numHiddenFields, ), ); }, didUpdateFields: (fields) { emit(state.copyWith(fields: fields)); }, deleteField: (fieldId) async { final result = await FieldBackendService.deleteField( viewId: rowController.viewId, fieldId: fieldId, ); result.fold((l) {}, (err) => Log.error(err)); }, toggleFieldVisibility: (fieldId) async { await _toggleFieldVisibility(fieldId, emit); }, reorderField: (fromIndex, toIndex) async { await _reorderField(fromIndex, toIndex, emit); }, toggleHiddenFieldVisibility: () { final showHiddenFields = !state.showHiddenFields; final visibleCells = List.from( allCells.where((cellContext) { final fieldInfo = fieldController.getField(cellContext.fieldId); return fieldInfo != null && !fieldInfo.isPrimary && (fieldInfo.visibility!.isVisibleState() || showHiddenFields); }), ); emit( state.copyWith( showHiddenFields: showHiddenFields, visibleCells: visibleCells, ), ); }, startEditingField: (fieldId) { emit(state.copyWith(editingFieldId: fieldId)); }, startEditingNewField: (fieldId) { emit(state.copyWith(editingFieldId: fieldId, newFieldId: fieldId)); }, endEditingField: () { emit(state.copyWith(editingFieldId: "", newFieldId: "")); }, removeCover: () => _rowService.removeCover(rowController.rowId), setCover: (cover) => _rowService.updateMeta(rowId: rowController.rowId, cover: cover), didReceiveRowMeta: (rowMeta) { emit(state.copyWith(rowMeta: rowMeta)); }, ); }, ); } void _startListening() { _metaListener.start( callback: (rowMeta) { if (!isClosed) { add(RowDetailEvent.didReceiveRowMeta(rowMeta)); } }, ); rowController.addListener( onRowChanged: (cellMap, reason) { if (isClosed) { return; } allCells.clear(); allCells.addAll(cellMap); int numHiddenFields = 0; final visibleCells = []; for (final cellContext in allCells) { final fieldInfo = fieldController.getField(cellContext.fieldId); if (fieldInfo == null || fieldInfo.isPrimary) { continue; } final isHidden = !fieldInfo.visibility!.isVisibleState(); if (!isHidden || state.showHiddenFields) { visibleCells.add(cellContext); } if (isHidden) { numHiddenFields++; } } add( RowDetailEvent.didReceiveCellDatas( visibleCells, numHiddenFields, ), ); }, ); fieldController.addListener( onReceiveFields: (fields) => add(RowDetailEvent.didUpdateFields(fields)), listenWhen: () => !isClosed, ); } void _init() { allCells.addAll(rowController.loadCells()); int numHiddenFields = 0; final visibleCells = []; for (final cell in allCells) { final fieldInfo = fieldController.getField(cell.fieldId); if (fieldInfo == null || fieldInfo.isPrimary) { continue; } final isHidden = !fieldInfo.visibility!.isVisibleState(); if (!isHidden) { visibleCells.add(cell); } else { numHiddenFields++; } } add( RowDetailEvent.didReceiveCellDatas( visibleCells, numHiddenFields, ), ); add(RowDetailEvent.didUpdateFields(fieldController.fieldInfos)); } Future _toggleFieldVisibility( String fieldId, Emitter emit, ) async { final fieldInfo = fieldController.getField(fieldId)!; final fieldVisibility = fieldInfo.visibility == FieldVisibility.AlwaysShown ? FieldVisibility.AlwaysHidden : FieldVisibility.AlwaysShown; final result = await FieldSettingsBackendService(viewId: rowController.viewId) .updateFieldSettings( fieldId: fieldId, fieldVisibility: fieldVisibility, ); result.fold((l) {}, (err) => Log.error(err)); } Future _reorderField( int fromIndex, int toIndex, Emitter emit, ) async { if (fromIndex < toIndex) { toIndex--; } final fromId = state.visibleCells[fromIndex].fieldId; final toId = state.visibleCells[toIndex].fieldId; final cells = List.from(state.visibleCells); cells.insert(toIndex, cells.removeAt(fromIndex)); emit(state.copyWith(visibleCells: cells)); final result = await FieldBackendService.moveField( viewId: rowController.viewId, fromFieldId: fromId, toFieldId: toId, ); result.fold((l) {}, (err) => Log.error(err)); } } @freezed class RowDetailEvent with _$RowDetailEvent { const factory RowDetailEvent.didUpdateFields(List fields) = _DidUpdateFields; /// Triggered by listeners to update row data const factory RowDetailEvent.didReceiveCellDatas( List visibleCells, int numHiddenFields, ) = _DidReceiveCellDatas; /// Used to delete a field const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField; /// Used to show/hide a field const factory RowDetailEvent.toggleFieldVisibility(String fieldId) = _ToggleFieldVisibility; /// Used to reorder a field const factory RowDetailEvent.reorderField( int fromIndex, int toIndex, ) = _ReorderField; /// Used to hide/show the hidden fields in the row detail page const factory RowDetailEvent.toggleHiddenFieldVisibility() = _ToggleHiddenFieldVisibility; /// Begin editing an event; const factory RowDetailEvent.startEditingField(String fieldId) = _StartEditingField; const factory RowDetailEvent.startEditingNewField(String fieldId) = _StartEditingNewField; /// End editing an event const factory RowDetailEvent.endEditingField() = _EndEditingField; const factory RowDetailEvent.removeCover() = _RemoveCover; const factory RowDetailEvent.setCover(RowCoverPB cover) = _SetCover; const factory RowDetailEvent.didReceiveRowMeta(RowMetaPB rowMeta) = _DidReceiveRowMeta; } @freezed class RowDetailState with _$RowDetailState { const factory RowDetailState({ required List fields, required List visibleCells, required bool showHiddenFields, required int numHiddenFields, required String editingFieldId, required String newFieldId, required RowMetaPB rowMeta, }) = _RowDetailState; factory RowDetailState.initial(RowMetaPB rowMeta) => RowDetailState( fields: [], visibleCells: [], showHiddenFields: false, numHiddenFields: 0, editingFieldId: "", newFieldId: "", rowMeta: rowMeta, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_document_bloc.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../application/row/row_service.dart'; part 'row_document_bloc.freezed.dart'; class RowDocumentBloc extends Bloc { RowDocumentBloc({ required this.rowId, required String viewId, }) : _rowBackendSvc = RowBackendService(viewId: viewId), super(RowDocumentState.initial()) { _dispatch(); } final String rowId; final RowBackendService _rowBackendSvc; void _dispatch() { on( (event, emit) async { await event.when( initial: () { _getRowDocumentView(); }, didReceiveRowDocument: (view) { emit( state.copyWith( viewPB: view, loadingState: const LoadingState.finish(), ), ); }, didReceiveError: (FlowyError error) { emit( state.copyWith( loadingState: LoadingState.error(error), ), ); }, updateIsEmpty: (isEmpty) async { final unitOrFailure = await _rowBackendSvc.updateMeta( rowId: rowId, isDocumentEmpty: isEmpty, ); unitOrFailure.fold((l) => null, (err) => Log.error(err)); }, ); }, ); } Future _getRowDocumentView() async { final rowDetailOrError = await _rowBackendSvc.getRowMeta(rowId); rowDetailOrError.fold( (RowMetaPB rowMeta) async { final viewsOrError = await ViewBackendService.getView(rowMeta.documentId); if (isClosed) { return; } viewsOrError.fold( (view) => add(RowDocumentEvent.didReceiveRowDocument(view)), (error) async { if (error.code == ErrorCode.RecordNotFound) { // By default, the document of the row is not exist. So creating a // new document for the given document id of the row. final documentView = await _createRowDocumentView(rowMeta.documentId); if (documentView != null && !isClosed) { add(RowDocumentEvent.didReceiveRowDocument(documentView)); } } else { add(RowDocumentEvent.didReceiveError(error)); } }, ); }, (err) => Log.error('Failed to get row detail: $err'), ); } Future _createRowDocumentView(String viewId) async { final result = await ViewBackendService.createOrphanView( viewId: viewId, name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), desc: '', layoutType: ViewLayoutPB.Document, ); return result.fold( (view) => view, (error) { Log.error(error); return null; }, ); } } @freezed class RowDocumentEvent with _$RowDocumentEvent { const factory RowDocumentEvent.initial() = _InitialRow; const factory RowDocumentEvent.didReceiveRowDocument(ViewPB view) = _DidReceiveRowDocument; const factory RowDocumentEvent.didReceiveError(FlowyError error) = _DidReceiveError; const factory RowDocumentEvent.updateIsEmpty(bool isDocumentEmpty) = _UpdateIsEmpty; } @freezed class RowDocumentState with _$RowDocumentState { const factory RowDocumentState({ ViewPB? viewPB, required LoadingState loadingState, }) = _RowDocumentState; factory RowDocumentState.initial() => const RowDocumentState( loadingState: LoadingState.loading(), ); } @freezed class LoadingState with _$LoadingState { const factory LoadingState.loading() = _Loading; const factory LoadingState.error(FlowyError error) = _Error; const factory LoadingState.finish() = _Finish; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/simple_text_filter_bloc.dart ================================================ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'simple_text_filter_bloc.freezed.dart'; class SimpleTextFilterBloc extends Bloc, SimpleTextFilterState> { SimpleTextFilterBloc({ required this.values, required this.comparator, this.filterText = "", }) : super(SimpleTextFilterState(values: values)) { _dispatch(); } final String Function(T) comparator; final List values; String filterText; void _dispatch() { on>((event, emit) async { event.when( updateFilter: (String filter) { filterText = filter.toLowerCase(); _filter(emit); }, receiveNewValues: (List newValues) { values ..clear() ..addAll(newValues); _filter(emit); }, ); }); } void _filter(Emitter> emit) { final List result = [...values]; result.retainWhere((value) { if (filterText.isNotEmpty) { return comparator(value).toLowerCase().contains(filterText); } return true; }); emit(SimpleTextFilterState(values: result)); } } @freezed class SimpleTextFilterEvent with _$SimpleTextFilterEvent { const factory SimpleTextFilterEvent.updateFilter(String filter) = _UpdateFilter; const factory SimpleTextFilterEvent.receiveNewValues(List newValues) = _ReceiveNewValues; } @freezed class SimpleTextFilterState with _$SimpleTextFilterState { const factory SimpleTextFilterState({ required List values, }) = _SimpleTextFilterState; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; import 'package:appflowy/plugins/database/domain/sort_service.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'sort_editor_bloc.freezed.dart'; class SortEditorBloc extends Bloc { SortEditorBloc({ required this.viewId, required this.fieldController, }) : _sortBackendSvc = SortBackendService(viewId: viewId), super( SortEditorState.initial( fieldController.sorts, fieldController.fieldInfos, ), ) { _dispatch(); _startListening(); } final String viewId; final SortBackendService _sortBackendSvc; final FieldController fieldController; void Function(List)? _onFieldFn; void Function(List)? _onSortsFn; void _dispatch() { on( (event, emit) async { await event.when( didReceiveFields: (List fields) { emit( state.copyWith( allFields: fields, creatableFields: _getCreatableSorts(fields), ), ); }, createSort: ( String fieldId, SortConditionPB? condition, ) async { final result = await _sortBackendSvc.insertSort( fieldId: fieldId, condition: condition ?? SortConditionPB.Ascending, ); result.fold((l) => {}, (err) => Log.error(err)); }, editSort: ( String sortId, String? fieldId, SortConditionPB? condition, ) async { final sort = state.sorts .firstWhereOrNull((element) => element.sortId == sortId); if (sort == null) { return; } final result = await _sortBackendSvc.updateSort( sortId: sortId, fieldId: fieldId ?? sort.fieldId, condition: condition ?? sort.condition, ); result.fold((l) => {}, (err) => Log.error(err)); }, deleteAllSorts: () async { final result = await _sortBackendSvc.deleteAllSorts(); result.fold((l) => {}, (err) => Log.error(err)); }, didReceiveSorts: (sorts) { emit(state.copyWith(sorts: sorts)); }, deleteSort: (sortId) async { final result = await _sortBackendSvc.deleteSort( sortId: sortId, ); result.fold((l) => null, (err) => Log.error(err)); }, reorderSort: (fromIndex, toIndex) async { if (fromIndex < toIndex) { toIndex--; } final fromId = state.sorts[fromIndex].sortId; final toId = state.sorts[toIndex].sortId; final newSorts = [...state.sorts]; newSorts.insert(toIndex, newSorts.removeAt(fromIndex)); emit(state.copyWith(sorts: newSorts)); final result = await _sortBackendSvc.reorderSort( fromSortId: fromId, toSortId: toId, ); result.fold((l) => null, (err) => Log.error(err)); }, ); }, ); } void _startListening() { _onFieldFn = (fields) { add(SortEditorEvent.didReceiveFields(List.from(fields))); }; _onSortsFn = (sorts) { add(SortEditorEvent.didReceiveSorts(sorts)); }; fieldController.addListener( listenWhen: () => !isClosed, onReceiveFields: _onFieldFn, onSorts: _onSortsFn, ); } @override Future close() async { fieldController.removeListener( onFieldsListener: _onFieldFn, onSortsListener: _onSortsFn, ); _onFieldFn = null; _onSortsFn = null; return super.close(); } } @freezed class SortEditorEvent with _$SortEditorEvent { const factory SortEditorEvent.didReceiveFields(List fieldInfos) = _DidReceiveFields; const factory SortEditorEvent.didReceiveSorts(List sorts) = _DidReceiveSorts; const factory SortEditorEvent.createSort({ required String fieldId, SortConditionPB? condition, }) = _CreateSort; const factory SortEditorEvent.editSort({ required String sortId, String? fieldId, SortConditionPB? condition, }) = _EditSort; const factory SortEditorEvent.reorderSort(int oldIndex, int newIndex) = _ReorderSort; const factory SortEditorEvent.deleteSort(String sortId) = _DeleteSort; const factory SortEditorEvent.deleteAllSorts() = _DeleteAllSorts; } @freezed class SortEditorState with _$SortEditorState { const factory SortEditorState({ required List sorts, required List allFields, required List creatableFields, }) = _SortEditorState; factory SortEditorState.initial( List sorts, List fields, ) { return SortEditorState( sorts: sorts, allFields: fields, creatableFields: _getCreatableSorts(fields), ); } } List _getCreatableSorts(List fieldInfos) { final List creatableFields = List.from(fieldInfos); creatableFields.retainWhere( (field) => field.fieldType.canCreateSort && !field.hasSort, ); return creatableFields; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; class GridPluginBuilder implements PluginBuilder { @override Plugin build(dynamic data) { if (data is ViewPB) { return DatabaseTabBarViewPlugin(pluginType: pluginType, view: data); } else { throw FlowyPluginException.invalidData; } } @override String get menuName => LocaleKeys.grid_menuName.tr(); @override FlowySvgData get icon => FlowySvgs.icon_grid_s; @override PluginType get pluginType => PluginType.grid; @override ViewLayoutPB get layoutType => ViewLayoutPB.Grid; } class GridPluginConfig implements PluginConfig { @override bool get creatable => true; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart ================================================ import 'dart:async'; import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/database/domain/sort_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; import 'package:provider/provider.dart'; import '../../application/database_controller.dart'; import '../../application/row/row_controller.dart'; import '../../tab_bar/tab_bar_view.dart'; import '../../widgets/row/row_detail.dart'; import '../application/grid_bloc.dart'; import 'grid_scroll.dart'; import 'layout/layout.dart'; import 'layout/sizes.dart'; import 'widgets/footer/grid_footer.dart'; import 'widgets/header/grid_header.dart'; import 'widgets/row/row.dart'; import 'widgets/shortcuts.dart'; class ToggleExtensionNotifier extends ChangeNotifier { bool _isToggled = false; bool get isToggled => _isToggled; void toggle() { _isToggled = !_isToggled; notifyListeners(); } } class DesktopGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder { final _toggleExtension = ToggleExtensionNotifier(); @override Widget content( BuildContext context, ViewPB view, DatabaseController controller, bool shrinkWrap, String? initialRowId, ) { return GridPage( key: _makeValueKey(controller), view: view, databaseController: controller, initialRowId: initialRowId, shrinkWrap: shrinkWrap, ); } @override Widget settingBar(BuildContext context, DatabaseController controller) { return GridSettingBar( key: _makeValueKey(controller), controller: controller, toggleExtension: _toggleExtension, ); } @override Widget settingBarExtension( BuildContext context, DatabaseController controller, ) { return DatabaseViewSettingExtension( key: _makeValueKey(controller), viewId: controller.viewId, databaseController: controller, toggleExtension: _toggleExtension, ); } @override void dispose() { _toggleExtension.dispose(); super.dispose(); } ValueKey _makeValueKey(DatabaseController controller) { return ValueKey(controller.viewId); } } class GridPage extends StatefulWidget { const GridPage({ super.key, required this.view, required this.databaseController, this.onDeleted, this.initialRowId, this.shrinkWrap = false, }); final ViewPB view; final DatabaseController databaseController; final VoidCallback? onDeleted; final String? initialRowId; final bool shrinkWrap; @override State createState() => _GridPageState(); } class _GridPageState extends State { bool _didOpenInitialRow = false; late final GridBloc gridBloc = GridBloc( view: widget.view, databaseController: widget.databaseController, shrinkWrapped: widget.shrinkWrap, )..add(const GridEvent.initial()); @override void dispose() { gridBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (_) => gridBloc, ), BlocProvider( create: (context) => PageAccessLevelBloc(view: widget.view) ..add(PageAccessLevelEvent.initial()), ), ], child: BlocListener( listener: (context, state) { final action = state.action; if (action?.type == ActionType.openRow && action?.objectId == widget.view.id) { final rowId = action!.arguments?[ActionArgumentKeys.rowId]; if (rowId != null) { // If Reminder in existing database is pressed // then open the row _openRow(context, rowId); } } }, child: BlocConsumer( listener: listener, builder: (context, state) => state.loadingState.map( idle: (_) => const SizedBox.shrink(), loading: (_) => const Center( child: CircularProgressIndicator.adaptive(), ), finish: (result) => result.successOrFail.fold( (_) => GridShortcuts( child: GridPageContent( key: ValueKey(widget.view.id), view: widget.view, shrinkWrap: widget.shrinkWrap, ), ), (err) => Center(child: AppFlowyErrorPage(error: err)), ), ), ), ), ); } void _openRow(BuildContext context, String rowId) { WidgetsBinding.instance.addPostFrameCallback((_) { final gridBloc = context.read(); final rowCache = gridBloc.rowCache; final rowMeta = rowCache.getRow(rowId)?.rowMeta; if (rowMeta == null) { return; } final rowController = RowController( viewId: widget.view.id, rowMeta: rowMeta, rowCache: rowCache, ); FlowyOverlay.show( context: context, builder: (_) => BlocProvider.value( value: context.read(), child: RowDetailPage( databaseController: context.read().databaseController, rowController: rowController, userProfile: context.read().userProfile, ), ), ); }); } void listener(BuildContext context, GridState state) { state.loadingState.whenOrNull( // If initial row id is defined, open row details overlay finish: (_) async { if (widget.initialRowId != null && !_didOpenInitialRow) { _didOpenInitialRow = true; _openRow(context, widget.initialRowId!); return; } final bloc = context.read(); final isCurrentView = bloc.state.tabBars[bloc.state.selectedIndex].viewId == widget.view.id; if (state.openRowDetail && state.createdRow != null && isCurrentView) { final rowController = RowController( viewId: widget.view.id, rowMeta: state.createdRow!, rowCache: context.read().rowCache, ); unawaited( FlowyOverlay.show( context: context, builder: (_) => BlocProvider.value( value: context.read(), child: RowDetailPage( databaseController: context.read().databaseController, rowController: rowController, userProfile: context.read().userProfile, ), ), ), ); context.read().add(const GridEvent.resetCreatedRow()); } }, ); } } class GridPageContent extends StatefulWidget { const GridPageContent({ super.key, required this.view, this.shrinkWrap = false, }); final ViewPB view; final bool shrinkWrap; @override State createState() => _GridPageContentState(); } class _GridPageContentState extends State { final _scrollController = GridScrollController( scrollGroupController: LinkedScrollControllerGroup(), ); late final ScrollController headerScrollController; @override void initState() { super.initState(); headerScrollController = _scrollController.linkHorizontalController(); } @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _GridHeader( headerScrollController: headerScrollController, editable: context.read().state.isEditable, shrinkWrap: widget.shrinkWrap, ), _GridRows( viewId: widget.view.id, scrollController: _scrollController, shrinkWrap: widget.shrinkWrap, ), ], ); } } class _GridHeader extends StatelessWidget { const _GridHeader({ required this.headerScrollController, required this.editable, required this.shrinkWrap, }); final ScrollController headerScrollController; final bool editable; final bool shrinkWrap; @override Widget build(BuildContext context) { Widget child = BlocBuilder( builder: (_, state) => GridHeaderSliverAdaptor( viewId: state.viewId, anchorScrollController: headerScrollController, shrinkWrap: shrinkWrap, ), ); if (!editable) { child = IgnorePointer( child: child, ); } return child; } } class _GridRows extends StatefulWidget { const _GridRows({ required this.viewId, required this.scrollController, this.shrinkWrap = false, }); final String viewId; final GridScrollController scrollController; /// When [shrinkWrap] is active, the Grid will show items according to /// GridState.visibleRows and will not have a vertical scroll area. /// final bool shrinkWrap; @override State<_GridRows> createState() => _GridRowsState(); } class _GridRowsState extends State<_GridRows> { bool showFloatingCalculations = false; bool isAtBottom = false; @override void initState() { super.initState(); if (!widget.shrinkWrap) { _evaluateFloatingCalculations(); widget.scrollController.verticalController.addListener(_onScrollChanged); } } void _onScrollChanged() { final controller = widget.scrollController.verticalController; final isAtBottom = controller.position.atEdge && controller.offset > 0 || controller.offset >= controller.position.maxScrollExtent - 1; if (isAtBottom != this.isAtBottom) { setState(() => this.isAtBottom = isAtBottom); } } @override void dispose() { if (!widget.shrinkWrap) { widget.scrollController.verticalController .removeListener(_onScrollChanged); } super.dispose(); } void _evaluateFloatingCalculations() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !widget.shrinkWrap) { setState(() { final verticalController = widget.scrollController.verticalController; // maxScrollExtent is 0.0 if scrolling is not possible showFloatingCalculations = verticalController.position.maxScrollExtent > 0; isAtBottom = verticalController.position.atEdge && verticalController.offset > 0; }); } }); } @override Widget build(BuildContext context) { final paddingLeft = context .read() ?.paddingLeftWithMaxDocumentWidth ?? 0.0; Widget child; if (widget.shrinkWrap) { child = Scrollbar( controller: widget.scrollController.horizontalController, child: SingleChildScrollView( scrollDirection: Axis.horizontal, controller: widget.scrollController.horizontalController, child: ConstrainedBox( constraints: BoxConstraints( maxWidth: GridLayout.headerWidth( context .read() .horizontalPadding * 3 + paddingLeft, context.read().state.fields, ), ), child: _shrinkWrapRenderList(context), ), ), ); } else { child = _WrapScrollView( scrollController: widget.scrollController, contentWidth: GridLayout.headerWidth( context.read().horizontalPadding, context.read().state.fields, ), child: BlocListener( listenWhen: (previous, current) => previous.rowCount != current.rowCount, listener: (context, state) => _evaluateFloatingCalculations(), child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: _renderList(context), ), ), ); } if (widget.shrinkWrap) { return child; } return Flexible(child: child); } Widget _shrinkWrapRenderList(BuildContext context) { final state = context.read().state; final databaseSize = context.read(); return ListView( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB( databaseSize?.paddingLeft ?? 0.0, 0, databaseSize?.horizontalPadding ?? 0.0, 0, ), children: [ widget.shrinkWrap ? _reorderableListView(state) : Expanded(child: _reorderableListView(state)), if (showFloatingCalculations && !widget.shrinkWrap) ...[ _PositionedCalculationsRow( viewId: widget.viewId, isAtBottom: isAtBottom, ), ], ], ); } Widget _renderList(BuildContext context) { final state = context.read().state; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ widget.shrinkWrap ? _reorderableListView(state) : Expanded(child: _reorderableListView(state)), if (showFloatingCalculations && !widget.shrinkWrap) ...[ _PositionedCalculationsRow( viewId: widget.viewId, isAtBottom: isAtBottom, ), ], ], ); } Widget _reorderableListView(GridState state) { final List footer = [ const GridRowBottomBar(), if (widget.shrinkWrap && state.visibleRows < state.rowInfos.length) const GridRowLoadMoreButton(), if (!showFloatingCalculations) GridCalculationsRow(viewId: widget.viewId), ]; // If we are using shrinkWrap, we need to show at most // state.visibleRows + 1 items. The visibleRows can be larger // than the actual rowInfos length. final itemCount = widget.shrinkWrap ? (state.visibleRows + 1).clamp(0, state.rowInfos.length + 1) : state.rowInfos.length + 1; return ReorderableListView.builder( cacheExtent: 500, scrollController: widget.scrollController.verticalController, physics: const ClampingScrollPhysics(), buildDefaultDragHandles: false, shrinkWrap: widget.shrinkWrap, proxyDecorator: (child, _, __) => Provider.value( value: context.read(), child: Material( color: Colors.white.withValues(alpha: .1), child: Opacity(opacity: .5, child: child), ), ), onReorder: (fromIndex, newIndex) { final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; if (state.sorts.isNotEmpty) { showCancelAndDeleteDialog( context: context, title: LocaleKeys.grid_sort_sortsActive.tr( namedArgs: { 'intention': LocaleKeys.grid_row_reorderRowDescription.tr(), }, ), description: LocaleKeys.grid_sort_removeSorting.tr(), confirmLabel: LocaleKeys.button_remove.tr(), closeOnAction: true, onDelete: () { SortBackendService(viewId: widget.viewId).deleteAllSorts(); moveRow(fromIndex, toIndex); }, ); } else { moveRow(fromIndex, toIndex); } }, itemCount: itemCount, itemBuilder: (context, index) { if (index == itemCount - 1) { final child = Column( key: const Key('grid_footer'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: footer, ); if (!context.read().state.isEditable) { return IgnorePointer( key: const Key('grid_footer'), child: child, ); } return child; } return _renderRow( context, state.rowInfos[index].rowId, index: index, ); }, ); } Widget _renderRow( BuildContext context, RowId rowId, { required int index, Animation? animation, }) { final databaseController = context.read().databaseController; final DatabaseController(:viewId, :rowCache) = databaseController; final rowMeta = rowCache.getRow(rowId)?.rowMeta; /// Return placeholder widget if the rowMeta is null. if (rowMeta == null) { Log.warn('RowMeta is null for rowId: $rowId'); return const SizedBox.shrink(); } final child = GridRow( key: ValueKey("grid_row_$rowId"), shrinkWrap: widget.shrinkWrap, fieldController: databaseController.fieldController, rowId: rowId, viewId: viewId, index: index, editable: context.watch().state.isEditable, rowController: RowController( viewId: viewId, rowMeta: rowMeta, rowCache: rowCache, ), cellBuilder: EditableCellBuilder(databaseController: databaseController), openDetailPage: (rowDetailContext) => FlowyOverlay.show( context: rowDetailContext, builder: (_) { final rowMeta = rowCache.getRow(rowId)?.rowMeta; if (rowMeta == null) { return const SizedBox.shrink(); } return BlocProvider.value( value: context.read(), child: RowDetailPage( rowController: RowController( viewId: viewId, rowMeta: rowMeta, rowCache: rowCache, ), databaseController: databaseController, userProfile: context.read().userProfile, ), ); }, ), ); if (animation != null) { return SizeTransition(sizeFactor: animation, child: child); } return child; } void moveRow(int from, int to) { if (from != to) { context.read().add(GridEvent.moveRow(from, to)); } } } class _WrapScrollView extends StatelessWidget { const _WrapScrollView({ required this.contentWidth, required this.scrollController, required this.child, }); final GridScrollController scrollController; final double contentWidth; final Widget child; @override Widget build(BuildContext context) { return ScrollbarListStack( includeInsets: false, axis: Axis.vertical, controller: scrollController.verticalController, barSize: GridSize.scrollBarSize, autoHideScrollbar: false, child: StyledSingleChildScrollView( autoHideScrollbar: false, includeInsets: false, controller: scrollController.horizontalController, axis: Axis.horizontal, child: SizedBox( width: contentWidth, child: child, ), ), ); } } /// This Widget is used to show the Calculations Row at the bottom of the Grids ScrollView /// when the ScrollView is scrollable. /// class _PositionedCalculationsRow extends StatefulWidget { const _PositionedCalculationsRow({ required this.viewId, this.isAtBottom = false, }); final String viewId; /// We don't need to show the top border if the scroll offset /// is at the bottom of the ScrollView. /// final bool isAtBottom; @override State<_PositionedCalculationsRow> createState() => _PositionedCalculationsRowState(); } class _PositionedCalculationsRowState extends State<_PositionedCalculationsRow> { @override Widget build(BuildContext context) { return Container( margin: EdgeInsets.only( left: context.read().horizontalPadding, ), padding: const EdgeInsets.only(bottom: 10), decoration: BoxDecoration( color: Theme.of(context).canvasColor, border: widget.isAtBottom ? null : Border( top: BorderSide( color: AFThemeExtension.of(context).borderColor, ), ), ), child: SizedBox( height: 36, width: double.infinity, child: GridCalculationsRow( key: const Key('floating_grid_calculations'), viewId: widget.viewId, includeDefaultInsets: false, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_scroll.dart ================================================ import 'package:flutter/material.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; class GridScrollController { GridScrollController({ required LinkedScrollControllerGroup scrollGroupController, }) : _scrollGroupController = scrollGroupController, verticalController = ScrollController(), horizontalController = scrollGroupController.addAndGet(); final LinkedScrollControllerGroup _scrollGroupController; final ScrollController verticalController; final ScrollController horizontalController; final List _linkHorizontalControllers = []; ScrollController linkHorizontalController() { final controller = _scrollGroupController.addAndGet(); _linkHorizontalControllers.add(controller); return controller; } void dispose() { for (final controller in _linkHorizontalControllers) { controller.dispose(); } verticalController.dispose(); horizontalController.dispose(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart ================================================ import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pbenum.dart'; import 'sizes.dart'; class GridLayout { static double headerWidth(double padding, List fields) { if (fields.isEmpty) return 0; final fieldsWidth = fields .where( (element) => element.visibility != null && element.visibility != FieldVisibility.AlwaysHidden, ) .map((fieldInfo) => fieldInfo.width!.toDouble()) .reduce((value, element) => value + element); return fieldsWidth + padding + GridSize.newPropertyButtonWidth; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:universal_platform/universal_platform.dart'; class GridSize { static double scale = 1; static double get scrollBarSize => 8 * scale; static double get headerHeight => 36 * scale; static double get buttonHeight => 38 * scale; static double get footerHeight => 36 * scale; static double get horizontalHeaderPadding => UniversalPlatform.isDesktop ? 40 * scale : 16 * scale; static double get cellHPadding => 10 * scale; static double get cellVPadding => 8 * scale; static double get popoverItemHeight => 26 * scale; static double get typeOptionSeparatorHeight => 4 * scale; static double get newPropertyButtonWidth => 140 * scale; static double get mobileNewPropertyButtonWidth => 200 * scale; static EdgeInsets get cellContentInsets => EdgeInsets.symmetric( horizontal: GridSize.cellHPadding, vertical: GridSize.cellVPadding, ); static EdgeInsets get compactCellContentInsets => cellContentInsets - EdgeInsets.symmetric(vertical: 2); static EdgeInsets get typeOptionContentInsets => const EdgeInsets.all(4); static EdgeInsets get toolbarSettingButtonInsets => const EdgeInsets.symmetric(horizontal: 6, vertical: 2); static EdgeInsets get footerContentInsets => EdgeInsets.fromLTRB( GridSize.horizontalHeaderPadding, 0, UniversalPlatform.isMobile ? GridSize.horizontalHeaderPadding : 0, UniversalPlatform.isMobile ? 100 : 0, ); static EdgeInsets get contentInsets => EdgeInsets.symmetric( horizontal: GridSize.horizontalHeaderPadding, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; import 'grid_scroll.dart'; import 'layout/sizes.dart'; import 'widgets/header/mobile_grid_header.dart'; import 'widgets/mobile_fab.dart'; import 'widgets/row/mobile_row.dart'; class MobileGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder { @override Widget content( BuildContext context, ViewPB view, DatabaseController controller, bool shrinkWrap, String? initialRowId, ) { return MobileGridPage( key: _makeValueKey(controller), view: view, databaseController: controller, initialRowId: initialRowId, shrinkWrap: shrinkWrap, ); } @override Widget settingBar(BuildContext context, DatabaseController controller) => const SizedBox.shrink(); @override Widget settingBarExtension( BuildContext context, DatabaseController controller, ) => const SizedBox.shrink(); ValueKey _makeValueKey(DatabaseController controller) { return ValueKey(controller.viewId); } } class MobileGridPage extends StatefulWidget { const MobileGridPage({ super.key, required this.view, required this.databaseController, this.onDeleted, this.initialRowId, this.shrinkWrap = false, }); final ViewPB view; final DatabaseController databaseController; final VoidCallback? onDeleted; final String? initialRowId; final bool shrinkWrap; @override State createState() => _MobileGridPageState(); } class _MobileGridPageState extends State { bool _didOpenInitialRow = false; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider.value( value: getIt(), ), BlocProvider( create: (context) => GridBloc( view: widget.view, databaseController: widget.databaseController, )..add(const GridEvent.initial()), ), ], child: BlocBuilder( builder: (context, state) { return state.loadingState.map( loading: (_) => const Center(child: CircularProgressIndicator.adaptive()), finish: (result) { _openRow(context, widget.initialRowId, true); return result.successOrFail.fold( (_) => GridPageContent( view: widget.view, shrinkWrap: widget.shrinkWrap, ), (err) => Center( child: AppFlowyErrorPage( error: err, ), ), ); }, idle: (_) => const SizedBox.shrink(), ); }, ), ); } void _openRow( BuildContext context, String? rowId, [ bool initialRow = false, ]) { if (rowId != null && (!initialRow || (initialRow && !_didOpenInitialRow))) { _didOpenInitialRow = initialRow; WidgetsBinding.instance.addPostFrameCallback((_) { context.push( MobileRowDetailPage.routeName, extra: { MobileRowDetailPage.argRowId: rowId, MobileRowDetailPage.argDatabaseController: widget.databaseController, }, ); }); } } } class GridPageContent extends StatefulWidget { const GridPageContent({ super.key, required this.view, this.shrinkWrap = false, }); final ViewPB view; final bool shrinkWrap; @override State createState() => _GridPageContentState(); } class _GridPageContentState extends State { final _scrollController = GridScrollController( scrollGroupController: LinkedScrollControllerGroup(), ); late final ScrollController contentScrollController; late final ScrollController reorderableController; @override void initState() { super.initState(); contentScrollController = _scrollController.linkHorizontalController(); reorderableController = _scrollController.linkHorizontalController(); } @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final isEditable = context.read()?.state.isEditable ?? false; return BlocListener( listenWhen: (previous, current) => previous.createdRow != current.createdRow, listener: (context, state) { if (state.createdRow == null || !state.openRowDetail) { return; } final bloc = context.read(); context.push( MobileRowDetailPage.routeName, extra: { MobileRowDetailPage.argRowId: state.createdRow!.id, MobileRowDetailPage.argDatabaseController: bloc.databaseController, }, ); bloc.add(const GridEvent.resetCreatedRow()); }, child: Stack( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _GridHeader( contentScrollController: contentScrollController, reorderableController: reorderableController, ), _GridRows( viewId: widget.view.id, scrollController: _scrollController, ), ], ), if (!widget.shrinkWrap && isEditable) Positioned( bottom: 16, right: 16, child: getGridFabs(context), ), ], ), ); } } class _GridHeader extends StatelessWidget { const _GridHeader({ required this.contentScrollController, required this.reorderableController, }); final ScrollController contentScrollController; final ScrollController reorderableController; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return MobileGridHeader( viewId: state.viewId, contentScrollController: contentScrollController, reorderableController: reorderableController, ); }, ); } } class _GridRows extends StatelessWidget { const _GridRows({ required this.viewId, required this.scrollController, }); final String viewId; final GridScrollController scrollController; @override Widget build(BuildContext context) { return BlocBuilder( buildWhen: (previous, current) => previous.fields != current.fields, builder: (context, state) { final double contentWidth = getMobileGridContentWidth(state.fields); return Flexible( child: _WrapScrollView( scrollController: scrollController, contentWidth: contentWidth, child: BlocBuilder( buildWhen: (previous, current) => current.reason.maybeWhen( reorderRows: () => true, reorderSingleRow: (reorderRow, rowInfo) => true, delete: (item) => true, insert: (item) => true, orElse: () => false, ), builder: (context, state) { final behavior = ScrollConfiguration.of(context).copyWith( scrollbars: false, physics: const ClampingScrollPhysics(), ); return ScrollConfiguration( behavior: behavior, child: _renderList(context, state), ); }, ), ), ); }, ); } Widget _renderList( BuildContext context, GridState state, ) { final children = state.rowInfos.mapIndexed((index, rowInfo) { return ReorderableDelayedDragStartListener( key: ValueKey(rowInfo.rowMeta.id), index: index, child: _renderRow( context, rowInfo.rowId, isDraggable: state.reorderable, index: index, ), ); }).toList(); return ReorderableListView.builder( scrollController: scrollController.verticalController, buildDefaultDragHandles: false, shrinkWrap: true, proxyDecorator: (child, index, animation) => Material( color: Colors.transparent, child: child, ), onReorder: (fromIndex, newIndex) { final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; if (fromIndex == toIndex) { return; } context.read().add(GridEvent.moveRow(fromIndex, toIndex)); }, itemCount: state.rowInfos.length, itemBuilder: (context, index) => children[index], footer: Padding( padding: GridSize.footerContentInsets, child: _AddRowButton(), ), ); } Widget _renderRow( BuildContext context, RowId rowId, { int? index, required bool isDraggable, Animation? animation, }) { final rowMeta = context .read() .databaseController .rowCache .getRow(rowId) ?.rowMeta; if (rowMeta == null) { Log.warn('RowMeta is null for rowId: $rowId'); return const SizedBox.shrink(); } final databaseController = context.read().databaseController; Widget child = MobileGridRow( key: ValueKey(rowMeta.id), rowId: rowId, isDraggable: isDraggable, databaseController: databaseController, openDetailPage: (context) { context.push( MobileRowDetailPage.routeName, extra: { MobileRowDetailPage.argRowId: rowId, MobileRowDetailPage.argDatabaseController: databaseController, }, ); }, ); if (animation != null) { child = SizeTransition( sizeFactor: animation, child: child, ); } final isEditable = context.read()?.state.isEditable ?? false; if (!isEditable) { child = IgnorePointer( child: child, ); } return child; } } class _WrapScrollView extends StatelessWidget { const _WrapScrollView({ required this.contentWidth, required this.scrollController, required this.child, }); final GridScrollController scrollController; final double contentWidth; final Widget child; @override Widget build(BuildContext context) { return SingleChildScrollView( controller: scrollController.horizontalController, scrollDirection: Axis.horizontal, child: SizedBox( width: contentWidth, child: child, ), ); } } class _AddRowButton extends StatelessWidget { @override Widget build(BuildContext context) { final borderSide = BorderSide( color: Theme.of(context).dividerColor, ); const radius = BorderRadius.only( bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24), ); final decoration = BoxDecoration( borderRadius: radius, border: BorderDirectional( start: borderSide, end: borderSide, bottom: borderSide, ), ); return Container( height: 54, decoration: decoration, child: FlowyButton( text: FlowyText( LocaleKeys.grid_row_newRow.tr(), fontSize: 15, color: Theme.of(context).hintColor, ), margin: const EdgeInsets.symmetric(horizontal: 20.0), radius: radius, hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: () => context.read().add(const GridEvent.createRow()), leftIcon: FlowySvg( FlowySvgs.add_s, color: Theme.of(context).hintColor, size: const Size.square(18), ), leftIconSize: const Size.square(18), ), ); } } double getMobileGridContentWidth(List fields) { final visibleFields = fields.where( (field) => field.visibility != FieldVisibility.AlwaysHidden, ); return (visibleFields.length + 1) * 200 + GridSize.horizontalHeaderPadding * 2; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart ================================================ import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/calculations/calculations_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/calculations/field_type_calc_ext.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/remove_calculation_button.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calculation_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pb.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CalculateCell extends StatefulWidget { const CalculateCell({ super.key, required this.fieldInfo, required this.width, this.calculation, }); final FieldInfo fieldInfo; final double width; final CalculationPB? calculation; @override State createState() => _CalculateCellState(); } class _CalculateCellState extends State { final _cellScrollController = ScrollController(); bool isSelected = false; bool isScrollable = false; @override void initState() { super.initState(); _checkScrollable(); } @override void didUpdateWidget(covariant CalculateCell oldWidget) { _checkScrollable(); super.didUpdateWidget(oldWidget); } void _checkScrollable() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_cellScrollController.hasClients) { setState( () => isScrollable = _cellScrollController.position.maxScrollExtent > 0, ); } }); } @override void dispose() { _cellScrollController.dispose(); super.dispose(); } void setIsSelected(bool selected) => setState(() => isSelected = selected); @override Widget build(BuildContext context) { final prefix = _prefixFromFieldType(widget.fieldInfo.fieldType); return SizedBox( height: 35, width: widget.width, child: AppFlowyPopover( constraints: BoxConstraints.loose(const Size(150, 200)), direction: PopoverDirection.bottomWithCenterAligned, onClose: () => setIsSelected(false), popupBuilder: (_) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setIsSelected(true); } }); return SingleChildScrollView( child: Column( children: [ if (widget.calculation != null) RemoveCalculationButton( onTap: () => context.read().add( CalculationsEvent.removeCalculation( widget.fieldInfo.id, widget.calculation!.id, ), ), ), ...widget.fieldInfo.fieldType.calculationsForFieldType().map( (type) => CalculationTypeItem( type: type, onTap: () { if (type != widget.calculation?.calculationType) { context.read().add( CalculationsEvent.updateCalculationType( widget.fieldInfo.id, type, calculationId: widget.calculation?.id, ), ); } }, ), ), ], ), ); }, child: widget.calculation != null ? _showCalculateValue(context, prefix) : CalculationSelector(isSelected: isSelected), ), ); } Widget _showCalculateValue(BuildContext context, String? prefix) { prefix = prefix != null ? '$prefix ' : ''; final calculateValue = '$prefix${_withoutTrailingZeros(widget.calculation!.value)}'; return FlowyTooltip( message: !isScrollable ? "" : null, richMessage: !isScrollable ? null : TextSpan( children: [ TextSpan( text: widget.calculation!.calculationType.shortLabel .toUpperCase(), style: context.tooltipTextStyle(), ), const TextSpan(text: ' '), TextSpan( text: calculateValue, style: context .tooltipTextStyle() ?.copyWith(fontWeight: FontWeight.w500), ), ], ), child: FlowyButton( radius: BorderRadius.zero, hoverColor: AFThemeExtension.of(context).lightGreyHover, text: Row( children: [ Expanded( child: SingleChildScrollView( controller: _cellScrollController, key: ValueKey(widget.calculation!.id), reverse: true, physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ FlowyText( lineHeight: 1.0, widget.calculation!.calculationType.shortLabel .toUpperCase(), color: Theme.of(context).hintColor, fontSize: 10, ), if (widget.calculation!.value.isNotEmpty) ...[ const HSpace(8), FlowyText( lineHeight: 1.0, calculateValue, color: AFThemeExtension.of(context).textColor, overflow: TextOverflow.ellipsis, ), ], ], ), ), ), ], ), ), ); } String _withoutTrailingZeros(String value) { if (trailingZerosRegex.hasMatch(value)) { final match = trailingZerosRegex.firstMatch(value)!; return match.group(1)!; } return value; } String? _prefixFromFieldType(FieldType fieldType) => switch (fieldType) { FieldType.Number => NumberTypeOptionPB.fromBuffer(widget.fieldInfo.field.typeOptionData) .format .iconSymbol(false), _ => null, }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; class CalculationSelector extends StatefulWidget { const CalculationSelector({ super.key, required this.isSelected, }); final bool isSelected; @override State createState() => _CalculationSelectorState(); } class _CalculationSelectorState extends State { bool _isHovering = false; void _setHovering(bool isHovering) => setState(() => _isHovering = isHovering); @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => _setHovering(true), onExit: (_) => _setHovering(false), child: AnimatedOpacity( duration: const Duration(milliseconds: 100), opacity: widget.isSelected || _isHovering ? 1 : 0, child: FlowyButton( radius: BorderRadius.zero, hoverColor: AFThemeExtension.of(context).lightGreyHover, text: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Flexible( child: FlowyText( LocaleKeys.grid_calculate.tr(), color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ), ), const HSpace(8), FlowySvg( FlowySvgs.arrow_down_s, color: Theme.of(context).hintColor, ), ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calculation_entities.pbenum.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; class CalculationTypeItem extends StatelessWidget { const CalculationTypeItem({ super.key, required this.type, required this.onTap, }); final CalculationType type; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( type.label, overflow: TextOverflow.ellipsis, lineHeight: 1.0, ), onTap: () { onTap(); PopoverContainer.of(context).close(); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart ================================================ import 'package:appflowy/plugins/database/grid/application/calculations/calculations_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class GridCalculationsRow extends StatelessWidget { const GridCalculationsRow({ super.key, required this.viewId, this.includeDefaultInsets = true, }); final String viewId; final bool includeDefaultInsets; @override Widget build(BuildContext context) { final gridBloc = context.read(); return BlocProvider( create: (context) => CalculationsBloc( viewId: gridBloc.databaseController.viewId, fieldController: gridBloc.databaseController.fieldController, )..add(const CalculationsEvent.started()), child: BlocBuilder( builder: (context, state) { final padding = context.read().horizontalPadding; return Padding( padding: includeDefaultInsets ? EdgeInsets.symmetric(horizontal: padding) : EdgeInsets.zero, child: Row( children: [ ...state.fields.map( (field) => CalculateCell( key: Key( '${field.id}-${state.calculationsByFieldId[field.id]?.id}', ), width: field.width!.toDouble(), fieldInfo: field, calculation: state.calculationsByFieldId[field.id], ), ), ], ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/remove_calculation_button.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; class RemoveCalculationButton extends StatelessWidget { const RemoveCalculationButton({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( LocaleKeys.grid_calculationTypeLabel_none.tr(), overflow: TextOverflow.ellipsis, ), onTap: () { onTap(); PopoverContainer.of(context).close(); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/common/type_option_separator.dart ================================================ import 'package:flutter/material.dart'; class TypeOptionSeparator extends StatelessWidget { const TypeOptionSeparator({this.spacing = 6.0, super.key}); final double spacing; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.symmetric(vertical: spacing), child: Container( color: Theme.of(context).dividerColor, height: 1.0, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../condition_button.dart'; import '../disclosure_button.dart'; import 'choicechip.dart'; class CheckboxFilterChoicechip extends StatelessWidget { const CheckboxFilterChoicechip({ super.key, required this.filterId, }); final String filterId; @override Widget build(BuildContext context) { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(200, 76)), direction: PopoverDirection.bottomWithCenterAligned, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: CheckboxFilterEditor( filterId: filterId, ), ); }, child: SingleFilterBlocSelector( filterId: filterId, builder: (context, filter, field) { return ChoiceChipButton( fieldInfo: field, filterDesc: filter.condition.filterName, ); }, ), ); } } class CheckboxFilterEditor extends StatefulWidget { const CheckboxFilterEditor({ super.key, required this.filterId, }); final String filterId; @override State createState() => _CheckboxFilterEditorState(); } class _CheckboxFilterEditorState extends State { final popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SingleFilterBlocSelector( filterId: widget.filterId, builder: (context, filter, field) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), child: IntrinsicHeight( child: Row( children: [ Expanded( child: FlowyText( field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), CheckboxFilterConditionList( filter: filter, popoverMutex: popoverMutex, onCondition: (condition) { final newFilter = filter.copyWith(condition: condition); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ), DisclosureButton( popoverMutex: popoverMutex, onAction: (action) { switch (action) { case FilterDisclosureAction.delete: context.read().add( FilterEditorEvent.deleteFilter( filter.filterId, ), ); break; } }, ), ], ), ), ); }, ); } } class CheckboxFilterConditionList extends StatelessWidget { const CheckboxFilterConditionList({ super.key, required this.filter, required this.popoverMutex, required this.onCondition, }); final CheckboxFilter filter; final PopoverMutex popoverMutex; final void Function(CheckboxFilterConditionPB) onCondition; @override Widget build(BuildContext context) { return PopoverActionList( asBarrier: true, mutex: popoverMutex, direction: PopoverDirection.bottomWithCenterAligned, actions: CheckboxFilterConditionPB.values .map( (action) => ConditionWrapper( action, filter.condition == action, ), ) .toList(), buildChild: (controller) { return ConditionButton( conditionName: filter.conditionName, onTap: () => controller.show(), ); }, onSelected: (action, controller) { onCondition(action.inner); controller.close(); }, ); } } class ConditionWrapper extends ActionCell { ConditionWrapper(this.inner, this.isSelected); final CheckboxFilterConditionPB inner; final bool isSelected; @override Widget? rightIcon(Color iconColor) => isSelected ? const FlowySvg(FlowySvgs.check_s) : null; @override String get name => inner.filterName; } extension TextFilterConditionPBExtension on CheckboxFilterConditionPB { String get filterName { switch (this) { case CheckboxFilterConditionPB.IsChecked: return LocaleKeys.grid_checkboxFilter_isChecked.tr(); case CheckboxFilterConditionPB.IsUnChecked: return LocaleKeys.grid_checkboxFilter_isUnchecked.tr(); default: return ""; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../condition_button.dart'; import '../disclosure_button.dart'; import 'choicechip.dart'; class ChecklistFilterChoicechip extends StatelessWidget { const ChecklistFilterChoicechip({ super.key, required this.filterId, }); final String filterId; @override Widget build(BuildContext context) { return AppFlowyPopover( controller: PopoverController(), constraints: BoxConstraints.loose(const Size(200, 160)), direction: PopoverDirection.bottomWithCenterAligned, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: ChecklistFilterEditor(filterId: filterId), ); }, child: SingleFilterBlocSelector( filterId: filterId, builder: (context, filter, field) { return ChoiceChipButton( fieldInfo: field, filterDesc: filter.getContentDescription(field), ); }, ), ); } } class ChecklistFilterEditor extends StatefulWidget { const ChecklistFilterEditor({ super.key, required this.filterId, }); final String filterId; @override ChecklistState createState() => ChecklistState(); } class ChecklistState extends State { final PopoverMutex popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SingleFilterBlocSelector( filterId: widget.filterId, builder: (context, filter, field) { return SizedBox( height: 20, child: Row( children: [ Expanded( child: FlowyText( field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), ChecklistFilterConditionList( filter: filter, popoverMutex: popoverMutex, onCondition: (condition) { final newFilter = filter.copyWith(condition: condition); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ), DisclosureButton( popoverMutex: popoverMutex, onAction: (action) { switch (action) { case FilterDisclosureAction.delete: context .read() .add(FilterEditorEvent.deleteFilter(filter.filterId)); break; } }, ), ], ), ); }, ); } } class ChecklistFilterConditionList extends StatelessWidget { const ChecklistFilterConditionList({ super.key, required this.filter, required this.popoverMutex, required this.onCondition, }); final ChecklistFilter filter; final PopoverMutex popoverMutex; final void Function(ChecklistFilterConditionPB) onCondition; @override Widget build(BuildContext context) { return PopoverActionList( asBarrier: true, direction: PopoverDirection.bottomWithCenterAligned, mutex: popoverMutex, actions: ChecklistFilterConditionPB.values .map((action) => ConditionWrapper(action)) .toList(), buildChild: (controller) { return ConditionButton( conditionName: filter.condition.filterName, onTap: () => controller.show(), ); }, onSelected: (action, controller) { onCondition(action.inner); controller.close(); }, ); } } class ConditionWrapper extends ActionCell { ConditionWrapper(this.inner); final ChecklistFilterConditionPB inner; @override String get name => inner.filterName; } extension ChecklistFilterConditionPBExtension on ChecklistFilterConditionPB { String get filterName { switch (this) { case ChecklistFilterConditionPB.IsComplete: return LocaleKeys.grid_checklistFilter_isComplete.tr(); case ChecklistFilterConditionPB.IsIncomplete: return LocaleKeys.grid_checklistFilter_isIncomplted.tr(); default: return ""; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart ================================================ import 'dart:math' as math; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ChoiceChipButton extends StatelessWidget { const ChoiceChipButton({ super.key, required this.fieldInfo, this.filterDesc = '', this.onTap, }); final FieldInfo fieldInfo; final String filterDesc; final VoidCallback? onTap; @override Widget build(BuildContext context) { final buttonText = filterDesc.isEmpty ? fieldInfo.name : "${fieldInfo.name}: $filterDesc"; return SizedBox( height: 28, child: FlowyButton( decoration: BoxDecoration( color: Colors.transparent, border: Border.fromBorderSide( BorderSide( color: AFThemeExtension.of(context).toggleOffFill, ), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), useIntrinsicWidth: true, text: FlowyText( buttonText, lineHeight: 1.0, color: AFThemeExtension.of(context).textColor, ), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), radius: const BorderRadius.all(Radius.circular(14)), leftIcon: FieldIcon( fieldInfo: fieldInfo, ), rightIcon: const _ChoicechipDownArrow(), hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: onTap, ), ); } } class _ChoicechipDownArrow extends StatelessWidget { const _ChoicechipDownArrow(); @override Widget build(BuildContext context) { return Transform.rotate( angle: -math.pi / 2, child: FlowySvg( FlowySvgs.arrow_left_s, color: AFThemeExtension.of(context).textColor, ), ); } } class SingleFilterBlocSelector extends StatelessWidget { const SingleFilterBlocSelector({ super.key, required this.filterId, required this.builder, }); final String filterId; final Widget Function(BuildContext, T, FieldInfo) builder; @override Widget build(BuildContext context) { return BlocSelector( selector: (state) { final filter = state.filters .firstWhereOrNull((filter) => filter.filterId == filterId) as T?; if (filter == null) { return null; } final field = state.fields .firstWhereOrNull((field) => field.id == filter.fieldId); if (field == null) { return null; } return (filter, field); }, builder: (context, selection) { if (selection == null) { return const SizedBox.shrink(); } return builder(context, selection.$1, selection.$2); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart ================================================ import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../condition_button.dart'; import '../disclosure_button.dart'; import 'choicechip.dart'; class DateFilterChoicechip extends StatelessWidget { const DateFilterChoicechip({ super.key, required this.filterId, }); final String filterId; @override Widget build(BuildContext context) { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(275, 120)), direction: PopoverDirection.bottomWithLeftAligned, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: DateFilterEditor(filterId: filterId), ); }, child: SingleFilterBlocSelector( filterId: filterId, builder: (context, filter, field) { return ChoiceChipButton( fieldInfo: field, filterDesc: filter.getContentDescription(field), ); }, ), ); } } class DateFilterEditor extends StatefulWidget { const DateFilterEditor({ super.key, required this.filterId, }); final String filterId; @override State createState() => _DateFilterEditorState(); } class _DateFilterEditorState extends State { final popoverMutex = PopoverMutex(); final popooverController = PopoverController(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SingleFilterBlocSelector( filterId: widget.filterId, builder: (context, filter, field) { final List children = [ _buildFilterPanel(filter, field), if (![ DateFilterConditionPB.DateStartIsEmpty, DateFilterConditionPB.DateStartIsNotEmpty, DateFilterConditionPB.DateEndIsEmpty, DateFilterConditionPB.DateStartIsNotEmpty, ].contains(filter.condition)) ...[ const VSpace(4), _buildFilterContentField(filter), ], ]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), child: IntrinsicHeight(child: Column(children: children)), ); }, ); } Widget _buildFilterPanel( DateTimeFilter filter, FieldInfo field, ) { return SizedBox( height: 20, child: Row( children: [ Expanded( child: field.fieldType == FieldType.DateTime ? DateFilterIsStartList( filter: filter, popoverMutex: popoverMutex, onChangeIsStart: (isStart) { final newFilter = filter.copyWithCondition( isStart: isStart, condition: filter.condition.toCondition(), ); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ) : FlowyText( field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), Expanded( child: DateFilterConditionList( filter: filter, popoverMutex: popoverMutex, onCondition: (condition) { final newFilter = filter.copyWithCondition( isStart: filter.condition.isStart, condition: condition, ); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ), ), const HSpace(4), DisclosureButton( popoverMutex: popoverMutex, onAction: (action) { switch (action) { case FilterDisclosureAction.delete: context .read() .add(FilterEditorEvent.deleteFilter(filter.filterId)); break; } }, ), ], ), ); } Widget _buildFilterContentField(DateTimeFilter filter) { final isRange = filter.condition.isRange; String? text; if (isRange) { text = "${filter.start?.defaultFormat ?? ""} - ${filter.end?.defaultFormat ?? ""}"; text = text == " - " ? null : text; } else { text = filter.timestamp.defaultFormat; } return AppFlowyPopover( controller: popooverController, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(260, 620)), offset: const Offset(0, 4), margin: EdgeInsets.zero, mutex: popoverMutex, child: FlowyButton( decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: Corners.s6Border, ), onTap: popooverController.show, text: FlowyText( text ?? "", overflow: TextOverflow.ellipsis, ), ), popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: SingleFilterBlocSelector( filterId: widget.filterId, builder: (context, filter, field) { return DesktopAppFlowyDatePicker( isRange: isRange, includeTime: false, dateFormat: DateFormatPB.Friendly, timeFormat: TimeFormatPB.TwentyFourHour, dateTime: isRange ? filter.start : filter.timestamp, endDateTime: isRange ? filter.end : null, onDaySelected: (selectedDay) { final newFilter = isRange ? filter.copyWithRange(start: selectedDay, end: null) : filter.copyWithTimestamp(timestamp: selectedDay); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); if (isRange) { popooverController.close(); } }, onRangeSelected: (start, end) { final newFilter = filter.copyWithRange( start: start, end: end, ); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ); }, ), ); }, ); } } class DateFilterIsStartList extends StatelessWidget { const DateFilterIsStartList({ super.key, required this.filter, required this.popoverMutex, required this.onChangeIsStart, }); final DateTimeFilter filter; final PopoverMutex popoverMutex; final Function(bool isStart) onChangeIsStart; @override Widget build(BuildContext context) { return PopoverActionList<_IsStartWrapper>( asBarrier: true, mutex: popoverMutex, direction: PopoverDirection.bottomWithCenterAligned, actions: [ _IsStartWrapper( true, filter.condition.isStart, ), _IsStartWrapper( false, !filter.condition.isStart, ), ], buildChild: (controller) { return ConditionButton( conditionName: filter.condition.isStart ? LocaleKeys.grid_dateFilter_startDate.tr() : LocaleKeys.grid_dateFilter_endDate.tr(), onTap: () => controller.show(), ); }, onSelected: (action, controller) { onChangeIsStart(action.inner); controller.close(); }, ); } } class _IsStartWrapper extends ActionCell { _IsStartWrapper(this.inner, this.isSelected); final bool inner; final bool isSelected; @override Widget? rightIcon(Color iconColor) => isSelected ? const FlowySvg(FlowySvgs.check_s) : null; @override String get name => inner ? LocaleKeys.grid_dateFilter_startDate.tr() : LocaleKeys.grid_dateFilter_endDate.tr(); } class DateFilterConditionList extends StatelessWidget { const DateFilterConditionList({ super.key, required this.filter, required this.popoverMutex, required this.onCondition, }); final DateTimeFilter filter; final PopoverMutex popoverMutex; final Function(DateTimeFilterCondition) onCondition; @override Widget build(BuildContext context) { final conditions = DateTimeFilterCondition.availableConditionsForFieldType( filter.fieldType, ); return PopoverActionList( asBarrier: true, mutex: popoverMutex, direction: PopoverDirection.bottomWithCenterAligned, actions: conditions .map( (action) => ConditionWrapper( action, filter.condition.toCondition() == action, ), ) .toList(), buildChild: (controller) { return ConditionButton( conditionName: filter.condition.toCondition().filterName, onTap: () => controller.show(), ); }, onSelected: (action, controller) { onCondition(action.inner); controller.close(); }, ); } } class ConditionWrapper extends ActionCell { ConditionWrapper(this.inner, this.isSelected); final DateTimeFilterCondition inner; final bool isSelected; @override Widget? rightIcon(Color iconColor) => isSelected ? const FlowySvg(FlowySvgs.check_s) : null; @override String get name => inner.filterName; } extension DateFilterConditionPBExtension on DateFilterConditionPB { bool get isStart { return switch (this) { DateFilterConditionPB.DateStartsOn || DateFilterConditionPB.DateStartsBefore || DateFilterConditionPB.DateStartsAfter || DateFilterConditionPB.DateStartsOnOrBefore || DateFilterConditionPB.DateStartsOnOrAfter || DateFilterConditionPB.DateStartsBetween || DateFilterConditionPB.DateStartIsEmpty || DateFilterConditionPB.DateStartIsNotEmpty => true, _ => false }; } bool get isRange { return switch (this) { DateFilterConditionPB.DateStartsBetween || DateFilterConditionPB.DateEndsBetween => true, _ => false, }; } DateTimeFilterCondition toCondition() { return switch (this) { DateFilterConditionPB.DateStartsOn || DateFilterConditionPB.DateEndsOn => DateTimeFilterCondition.on, DateFilterConditionPB.DateStartsBefore || DateFilterConditionPB.DateEndsBefore => DateTimeFilterCondition.before, DateFilterConditionPB.DateStartsAfter || DateFilterConditionPB.DateEndsAfter => DateTimeFilterCondition.after, DateFilterConditionPB.DateStartsOnOrBefore || DateFilterConditionPB.DateEndsOnOrBefore => DateTimeFilterCondition.onOrBefore, DateFilterConditionPB.DateStartsOnOrAfter || DateFilterConditionPB.DateEndsOnOrAfter => DateTimeFilterCondition.onOrAfter, DateFilterConditionPB.DateStartsBetween || DateFilterConditionPB.DateEndsBetween => DateTimeFilterCondition.between, DateFilterConditionPB.DateStartIsEmpty || DateFilterConditionPB.DateEndIsEmpty => DateTimeFilterCondition.isEmpty, DateFilterConditionPB.DateStartIsNotEmpty || DateFilterConditionPB.DateEndIsNotEmpty => DateTimeFilterCondition.isNotEmpty, _ => throw ArgumentError(), }; } } extension DateTimeChoicechipExtension on DateTime { DateTime get considerLocal { return DateTime(year, month, day); } } extension DateTimeDefaultFormatExtension on DateTime? { String? get defaultFormat { return this != null ? DateFormat('dd/MM/yyyy').format(this!) : null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../condition_button.dart'; import '../disclosure_button.dart'; import 'choicechip.dart'; class NumberFilterChoiceChip extends StatelessWidget { const NumberFilterChoiceChip({ super.key, required this.filterId, }); final String filterId; @override Widget build(BuildContext context) { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(200, 100)), direction: PopoverDirection.bottomWithCenterAligned, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: NumberFilterEditor(filterId: filterId), ); }, child: SingleFilterBlocSelector( filterId: filterId, builder: (context, filter, field) { return ChoiceChipButton( fieldInfo: field, filterDesc: filter.getContentDescription(field), ); }, ), ); } } class NumberFilterEditor extends StatefulWidget { const NumberFilterEditor({ super.key, required this.filterId, }); final String filterId; @override State createState() => _NumberFilterEditorState(); } class _NumberFilterEditorState extends State { final popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SingleFilterBlocSelector( filterId: widget.filterId, builder: (context, filter, field) { final List children = [ _buildFilterPanel(filter, field), if (filter.condition != NumberFilterConditionPB.NumberIsEmpty && filter.condition != NumberFilterConditionPB.NumberIsNotEmpty) ...[ const VSpace(4), _buildFilterNumberField(filter), ], ]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), child: IntrinsicHeight(child: Column(children: children)), ); }, ); } Widget _buildFilterPanel( NumberFilter filter, FieldInfo field, ) { return SizedBox( height: 20, child: Row( children: [ Expanded( child: FlowyText( field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), Expanded( child: NumberFilterConditionList( filter: filter, popoverMutex: popoverMutex, onCondition: (condition) { final newFilter = filter.copyWith(condition: condition); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ), ), const HSpace(4), DisclosureButton( popoverMutex: popoverMutex, onAction: (action) { switch (action) { case FilterDisclosureAction.delete: context.read().add( FilterEditorEvent.deleteFilter( filter.filterId, ), ); break; } }, ), ], ), ); } Widget _buildFilterNumberField( NumberFilter filter, ) { return FlowyTextField( text: filter.content, hintText: LocaleKeys.grid_settings_typeAValue.tr(), debounceDuration: const Duration(milliseconds: 300), autoFocus: false, onChanged: (text) { final newFilter = filter.copyWith(content: text); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ); } } class NumberFilterConditionList extends StatelessWidget { const NumberFilterConditionList({ super.key, required this.filter, required this.popoverMutex, required this.onCondition, }); final NumberFilter filter; final PopoverMutex popoverMutex; final void Function(NumberFilterConditionPB) onCondition; @override Widget build(BuildContext context) { return PopoverActionList( asBarrier: true, mutex: popoverMutex, direction: PopoverDirection.bottomWithCenterAligned, actions: NumberFilterConditionPB.values .map( (action) => ConditionWrapper( action, filter.condition == action, ), ) .toList(), buildChild: (controller) { return ConditionButton( conditionName: filter.condition.filterName, onTap: () => controller.show(), ); }, onSelected: (action, controller) { onCondition(action.inner); controller.close(); }, ); } } class ConditionWrapper extends ActionCell { ConditionWrapper(this.inner, this.isSelected); final NumberFilterConditionPB inner; final bool isSelected; @override Widget? rightIcon(Color iconColor) => isSelected ? const FlowySvg(FlowySvgs.check_s) : null; @override String get name => inner.filterName; } extension NumberFilterConditionPBExtension on NumberFilterConditionPB { String get shortName { return switch (this) { NumberFilterConditionPB.Equal => "=", NumberFilterConditionPB.NotEqual => "≠", NumberFilterConditionPB.LessThan => "<", NumberFilterConditionPB.LessThanOrEqualTo => "≤", NumberFilterConditionPB.GreaterThan => ">", NumberFilterConditionPB.GreaterThanOrEqualTo => "≥", NumberFilterConditionPB.NumberIsEmpty => LocaleKeys.grid_numberFilter_isEmpty.tr(), NumberFilterConditionPB.NumberIsNotEmpty => LocaleKeys.grid_numberFilter_isNotEmpty.tr(), _ => "", }; } String get filterName { return switch (this) { NumberFilterConditionPB.Equal => LocaleKeys.grid_numberFilter_equal.tr(), NumberFilterConditionPB.NotEqual => LocaleKeys.grid_numberFilter_notEqual.tr(), NumberFilterConditionPB.LessThan => LocaleKeys.grid_numberFilter_lessThan.tr(), NumberFilterConditionPB.LessThanOrEqualTo => LocaleKeys.grid_numberFilter_lessThanOrEqualTo.tr(), NumberFilterConditionPB.GreaterThan => LocaleKeys.grid_numberFilter_greaterThan.tr(), NumberFilterConditionPB.GreaterThanOrEqualTo => LocaleKeys.grid_numberFilter_greaterThanOrEqualTo.tr(), NumberFilterConditionPB.NumberIsEmpty => LocaleKeys.grid_numberFilter_isEmpty.tr(), NumberFilterConditionPB.NumberIsNotEmpty => LocaleKeys.grid_numberFilter_isNotEmpty.tr(), _ => "", }; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/view/database_filter_condition_list.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/condition_button.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; class SelectOptionFilterConditionList extends StatelessWidget { const SelectOptionFilterConditionList({ super.key, required this.filter, required this.fieldType, required this.popoverMutex, required this.onCondition, }); final SelectOptionFilter filter; final FieldType fieldType; final PopoverMutex popoverMutex; final void Function(SelectOptionFilterConditionPB) onCondition; @override Widget build(BuildContext context) { final conditions = (fieldType == FieldType.SingleSelect ? SingleSelectOptionFilterCondition().conditions : MultiSelectOptionFilterCondition().conditions); return PopoverActionList( asBarrier: true, mutex: popoverMutex, direction: PopoverDirection.bottomWithCenterAligned, actions: conditions .map( (action) => ConditionWrapper( action.$1, filter.condition == action.$1, ), ) .toList(), buildChild: (controller) { return ConditionButton( conditionName: filter.condition.i18n, onTap: () => controller.show(), ); }, onSelected: (action, controller) async { onCondition(action.inner); controller.close(); }, ); } } class ConditionWrapper extends ActionCell { ConditionWrapper(this.inner, this.isSelected); final SelectOptionFilterConditionPB inner; final bool isSelected; @override Widget? rightIcon(Color iconColor) { return isSelected ? const FlowySvg(FlowySvgs.check_s) : null; } @override String get name => inner.i18n; } extension SelectOptionFilterConditionPBExtension on SelectOptionFilterConditionPB { String get i18n { return switch (this) { SelectOptionFilterConditionPB.OptionIs => LocaleKeys.grid_selectOptionFilter_is.tr(), SelectOptionFilterConditionPB.OptionIsNot => LocaleKeys.grid_selectOptionFilter_isNot.tr(), SelectOptionFilterConditionPB.OptionContains => LocaleKeys.grid_selectOptionFilter_contains.tr(), SelectOptionFilterConditionPB.OptionDoesNotContain => LocaleKeys.grid_selectOptionFilter_doesNotContain.tr(), SelectOptionFilterConditionPB.OptionIsEmpty => LocaleKeys.grid_selectOptionFilter_isEmpty.tr(), SelectOptionFilterConditionPB.OptionIsNotEmpty => LocaleKeys.grid_selectOptionFilter_isNotEmpty.tr(), _ => "", }; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SelectOptionFilterList extends StatelessWidget { const SelectOptionFilterList({ super.key, required this.filter, required this.field, required this.options, required this.onTap, }); final SelectOptionFilter filter; final FieldInfo field; final List options; final VoidCallback onTap; @override Widget build(BuildContext context) { return ListView.separated( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, itemCount: options.length, separatorBuilder: (context, index) => VSpace(GridSize.typeOptionSeparatorHeight), itemBuilder: (context, index) { final option = options[index]; final isSelected = filter.optionIds.contains(option.id); return SelectOptionFilterCell( option: option, isSelected: isSelected, onTap: () => _onTapHandler(context, option, isSelected), ); }, ); } void _onTapHandler( BuildContext context, SelectOptionPB option, bool isSelected, ) { final selectedOptionIds = Set.from(filter.optionIds); if (isSelected) { selectedOptionIds.remove(option.id); } else { selectedOptionIds.add(option.id); } _updateSelectOptions(context, filter, selectedOptionIds); onTap(); } void _updateSelectOptions( BuildContext context, SelectOptionFilter filter, Set selectedOptionIds, ) { final optionIds = options.map((e) => e.id).where(selectedOptionIds.contains).toList(); final newFilter = filter.copyWith(optionIds: optionIds); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); } } class SelectOptionFilterCell extends StatelessWidget { const SelectOptionFilterCell({ super.key, required this.option, required this.isSelected, required this.onTap, }); final SelectOptionPB option; final bool isSelected; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyHover( resetHoverOnRebuild: false, style: HoverStyle( hoverColor: AFThemeExtension.of(context).lightGreyHover, ), child: SelectOptionTagCell( option: option, onSelected: onTap, children: [ if (isSelected) const Padding( padding: EdgeInsets.only(right: 6), child: FlowySvg(FlowySvgs.check_s), ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart ================================================ import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../disclosure_button.dart'; import '../choicechip.dart'; import 'condition_list.dart'; import 'option_list.dart'; class SelectOptionFilterChoicechip extends StatelessWidget { const SelectOptionFilterChoicechip({ super.key, required this.filterId, }); final String filterId; @override Widget build(BuildContext context) { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(240, 160)), direction: PopoverDirection.bottomWithCenterAligned, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: SelectOptionFilterEditor(filterId: filterId), ); }, child: SingleFilterBlocSelector( filterId: filterId, builder: (context, filter, field) { return ChoiceChipButton( fieldInfo: field, filterDesc: filter.getContentDescription(field), ); }, ), ); } } class SelectOptionFilterEditor extends StatefulWidget { const SelectOptionFilterEditor({ super.key, required this.filterId, }); final String filterId; @override State createState() => _SelectOptionFilterEditorState(); } class _SelectOptionFilterEditorState extends State { final popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SingleFilterBlocSelector( filterId: widget.filterId, builder: (context, filter, field) { final List slivers = [ SliverToBoxAdapter(child: _buildFilterPanel(filter, field)), ]; if (filter.canAttachContent) { slivers ..add(const SliverToBoxAdapter(child: VSpace(4))) ..add( SliverToBoxAdapter( child: SelectOptionFilterList( filter: filter, field: field, options: filter.makeDelegate(field).getOptions(field), onTap: () => popoverMutex.close(), ), ), ); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), child: CustomScrollView( shrinkWrap: true, slivers: slivers, physics: StyledScrollPhysics(), ), ); }, ); } Widget _buildFilterPanel( SelectOptionFilter filter, FieldInfo field, ) { return SizedBox( height: 20, child: Row( children: [ Expanded( child: FlowyText( field.field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), SelectOptionFilterConditionList( filter: filter, fieldType: field.fieldType, popoverMutex: popoverMutex, onCondition: (condition) { final newFilter = filter.copyWith(condition: condition); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ), DisclosureButton( popoverMutex: popoverMutex, onAction: (action) { switch (action) { case FilterDisclosureAction.delete: context .read() .add(FilterEditorEvent.deleteFilter(filter.filterId)); break; } }, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../condition_button.dart'; import '../disclosure_button.dart'; import 'choicechip.dart'; class TextFilterChoicechip extends StatelessWidget { const TextFilterChoicechip({ super.key, required this.filterId, }); final String filterId; @override Widget build(BuildContext context) { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(200, 76)), direction: PopoverDirection.bottomWithCenterAligned, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: TextFilterEditor(filterId: filterId), ); }, child: SingleFilterBlocSelector( filterId: filterId, builder: (context, filter, field) { return ChoiceChipButton( fieldInfo: field, filterDesc: filter.getContentDescription(field), ); }, ), ); } } class TextFilterEditor extends StatefulWidget { const TextFilterEditor({ super.key, required this.filterId, }); final String filterId; @override State createState() => _TextFilterEditorState(); } class _TextFilterEditorState extends State { final popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SingleFilterBlocSelector( filterId: widget.filterId, builder: (context, filter, field) { final List children = [ _buildFilterPanel(filter, field), ]; if (filter.condition != TextFilterConditionPB.TextIsEmpty && filter.condition != TextFilterConditionPB.TextIsNotEmpty) { children.add(const VSpace(4)); children.add(_buildFilterTextField(filter, field)); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), child: IntrinsicHeight(child: Column(children: children)), ); }, ); } Widget _buildFilterPanel(TextFilter filter, FieldInfo field) { return SizedBox( height: 20, child: Row( children: [ Expanded( child: FlowyText( field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), Expanded( child: TextFilterConditionList( filter: filter, popoverMutex: popoverMutex, onCondition: (condition) { final newFilter = filter.copyWith(condition: condition); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ), ), const HSpace(4), DisclosureButton( popoverMutex: popoverMutex, onAction: (action) { switch (action) { case FilterDisclosureAction.delete: context .read() .add(FilterEditorEvent.deleteFilter(filter.filterId)); break; } }, ), ], ), ); } Widget _buildFilterTextField(TextFilter filter, FieldInfo field) { return FlowyTextField( text: filter.content, hintText: LocaleKeys.grid_settings_typeAValue.tr(), debounceDuration: const Duration(milliseconds: 300), autoFocus: false, onChanged: (text) { final newFilter = filter.copyWith(content: text); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ); } } class TextFilterConditionList extends StatelessWidget { const TextFilterConditionList({ super.key, required this.filter, required this.popoverMutex, required this.onCondition, }); final TextFilter filter; final PopoverMutex popoverMutex; final void Function(TextFilterConditionPB) onCondition; @override Widget build(BuildContext context) { return PopoverActionList( asBarrier: true, mutex: popoverMutex, direction: PopoverDirection.bottomWithCenterAligned, actions: TextFilterConditionPB.values .map( (action) => ConditionWrapper( action, filter.condition == action, ), ) .toList(), buildChild: (controller) { return ConditionButton( conditionName: filter.condition.filterName, onTap: () => controller.show(), ); }, onSelected: (action, controller) async { onCondition(action.inner); controller.close(); }, ); } } class ConditionWrapper extends ActionCell { ConditionWrapper(this.inner, this.isSelected); final TextFilterConditionPB inner; final bool isSelected; @override Widget? rightIcon(Color iconColor) { if (isSelected) { return const FlowySvg(FlowySvgs.check_s); } else { return null; } } @override String get name => inner.filterName; } extension TextFilterConditionPBExtension on TextFilterConditionPB { String get filterName { switch (this) { case TextFilterConditionPB.TextContains: return LocaleKeys.grid_textFilter_contains.tr(); case TextFilterConditionPB.TextDoesNotContain: return LocaleKeys.grid_textFilter_doesNotContain.tr(); case TextFilterConditionPB.TextEndsWith: return LocaleKeys.grid_textFilter_endsWith.tr(); case TextFilterConditionPB.TextIs: return LocaleKeys.grid_textFilter_is.tr(); case TextFilterConditionPB.TextIsNot: return LocaleKeys.grid_textFilter_isNot.tr(); case TextFilterConditionPB.TextStartsWith: return LocaleKeys.grid_textFilter_startWith.tr(); case TextFilterConditionPB.TextIsEmpty: return LocaleKeys.grid_textFilter_isEmpty.tr(); case TextFilterConditionPB.TextIsNotEmpty: return LocaleKeys.grid_textFilter_isNotEmpty.tr(); default: return ""; } } String get choicechipPrefix { switch (this) { case TextFilterConditionPB.TextDoesNotContain: return LocaleKeys.grid_textFilter_choicechipPrefix_isNot.tr(); case TextFilterConditionPB.TextEndsWith: return LocaleKeys.grid_textFilter_choicechipPrefix_endWith.tr(); case TextFilterConditionPB.TextIsNot: return LocaleKeys.grid_textFilter_choicechipPrefix_isNot.tr(); case TextFilterConditionPB.TextStartsWith: return LocaleKeys.grid_textFilter_choicechipPrefix_startWith.tr(); case TextFilterConditionPB.TextIsEmpty: return LocaleKeys.grid_textFilter_choicechipPrefix_isEmpty.tr(); case TextFilterConditionPB.TextIsNotEmpty: return LocaleKeys.grid_textFilter_choicechipPrefix_isNotEmpty.tr(); default: return ""; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/time.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../condition_button.dart'; import '../disclosure_button.dart'; import 'choicechip.dart'; class TimeFilterChoiceChip extends StatelessWidget { const TimeFilterChoiceChip({ super.key, required this.filterId, }); final String filterId; @override Widget build(BuildContext context) { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(200, 100)), direction: PopoverDirection.bottomWithCenterAligned, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: TimeFilterEditor(filterId: filterId), ); }, child: SingleFilterBlocSelector( filterId: filterId, builder: (context, filter, field) { return ChoiceChipButton( fieldInfo: field, ); }, ), ); } } class TimeFilterEditor extends StatefulWidget { const TimeFilterEditor({ super.key, required this.filterId, }); final String filterId; @override State createState() => _TimeFilterEditorState(); } class _TimeFilterEditorState extends State { final popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SingleFilterBlocSelector( filterId: widget.filterId, builder: (context, filter, field) { final List children = [ _buildFilterPanel(filter, field), if (filter.condition != NumberFilterConditionPB.NumberIsEmpty && filter.condition != NumberFilterConditionPB.NumberIsNotEmpty) ...[ const VSpace(4), _buildFilterTimeField(filter, field), ], ]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), child: IntrinsicHeight(child: Column(children: children)), ); }, ); } Widget _buildFilterPanel( TimeFilter filter, FieldInfo field, ) { return SizedBox( height: 20, child: Row( children: [ Expanded( child: FlowyText( field.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(4), Expanded( child: TimeFilterConditionList( filter: filter, popoverMutex: popoverMutex, onCondition: (condition) { final newFilter = filter.copyWith(condition: condition); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ), ), const HSpace(4), DisclosureButton( popoverMutex: popoverMutex, onAction: (action) { switch (action) { case FilterDisclosureAction.delete: context .read() .add(FilterEditorEvent.deleteFilter(filter.filterId)); break; } }, ), ], ), ); } Widget _buildFilterTimeField( TimeFilter filter, FieldInfo field, ) { return FlowyTextField( text: filter.content, hintText: LocaleKeys.grid_settings_typeAValue.tr(), debounceDuration: const Duration(milliseconds: 300), autoFocus: false, onChanged: (text) { final newFilter = filter.copyWith(content: text); context .read() .add(FilterEditorEvent.updateFilter(newFilter)); }, ); } } class TimeFilterConditionList extends StatelessWidget { const TimeFilterConditionList({ super.key, required this.filter, required this.popoverMutex, required this.onCondition, }); final TimeFilter filter; final PopoverMutex popoverMutex; final void Function(NumberFilterConditionPB) onCondition; @override Widget build(BuildContext context) { return PopoverActionList( asBarrier: true, mutex: popoverMutex, direction: PopoverDirection.bottomWithCenterAligned, actions: NumberFilterConditionPB.values .map( (action) => ConditionWrapper( action, filter.condition == action, ), ) .toList(), buildChild: (controller) { return ConditionButton( conditionName: filter.condition.filterName, onTap: () => controller.show(), ); }, onSelected: (action, controller) { onCondition(action.inner); controller.close(); }, ); } } class ConditionWrapper extends ActionCell { ConditionWrapper(this.inner, this.isSelected); final NumberFilterConditionPB inner; final bool isSelected; @override Widget? rightIcon(Color iconColor) => isSelected ? const FlowySvg(FlowySvgs.check_s) : null; @override String get name => inner.filterName; } extension TimeFilterConditionPBExtension on NumberFilterConditionPB { String get filterName { return switch (this) { NumberFilterConditionPB.Equal => LocaleKeys.grid_numberFilter_equal.tr(), NumberFilterConditionPB.NotEqual => LocaleKeys.grid_numberFilter_notEqual.tr(), NumberFilterConditionPB.LessThan => LocaleKeys.grid_numberFilter_lessThan.tr(), NumberFilterConditionPB.LessThanOrEqualTo => LocaleKeys.grid_numberFilter_lessThanOrEqualTo.tr(), NumberFilterConditionPB.GreaterThan => LocaleKeys.grid_numberFilter_greaterThan.tr(), NumberFilterConditionPB.GreaterThanOrEqualTo => LocaleKeys.grid_numberFilter_greaterThanOrEqualTo.tr(), NumberFilterConditionPB.NumberIsEmpty => LocaleKeys.grid_numberFilter_isEmpty.tr(), NumberFilterConditionPB.NumberIsNotEmpty => LocaleKeys.grid_numberFilter_isNotEmpty.tr(), _ => "", }; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/url.dart ================================================ import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'choicechip.dart'; class URLFilterChoicechip extends StatelessWidget { const URLFilterChoicechip({ super.key, required this.filterId, }); final String filterId; @override Widget build(BuildContext context) { return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(200, 76)), direction: PopoverDirection.bottomWithCenterAligned, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: TextFilterEditor(filterId: filterId), ); }, child: SingleFilterBlocSelector( filterId: filterId, builder: (context, filter, field) { return ChoiceChipButton( fieldInfo: field, filterDesc: filter.getContentDescription(field), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/condition_button.dart ================================================ import 'dart:math' as math; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class ConditionButton extends StatelessWidget { const ConditionButton({ super.key, required this.conditionName, required this.onTap, }); final String conditionName; final VoidCallback onTap; @override Widget build(BuildContext context) { final arrow = Transform.rotate( angle: -math.pi / 2, child: FlowySvg( FlowySvgs.arrow_left_s, color: AFThemeExtension.of(context).textColor, ), ); return SizedBox( height: 20, child: FlowyButton( useIntrinsicWidth: true, text: FlowyText( lineHeight: 1.0, conditionName, fontSize: 10, color: AFThemeExtension.of(context).textColor, overflow: TextOverflow.ellipsis, ), margin: const EdgeInsets.symmetric(horizontal: 4), radius: Corners.s6Border, rightIcon: arrow, hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: onTap, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/simple_text_filter_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CreateDatabaseViewFilterList extends StatelessWidget { const CreateDatabaseViewFilterList({ super.key, this.onTap, }); final VoidCallback? onTap; @override Widget build(BuildContext context) { final filterBloc = context.read(); return BlocProvider( create: (_) => SimpleTextFilterBloc( values: List.from(filterBloc.state.fields), comparator: (val) => val.name, ), child: BlocListener( listenWhen: (previous, current) => previous.fields != current.fields, listener: (context, state) { context .read>() .add(SimpleTextFilterEvent.receiveNewValues(state.fields)); }, child: BlocBuilder, SimpleTextFilterState>( builder: (context, state) { final cells = state.values.map((fieldInfo) { return SizedBox( height: GridSize.popoverItemHeight, child: FilterableFieldButton( fieldInfo: fieldInfo, onTap: () { context .read() .add(FilterEditorEvent.createFilter(fieldInfo)); onTap?.call(); }, ), ); }).toList(); final List slivers = [ SliverPersistentHeader( pinned: true, delegate: _FilterTextFieldDelegate(), ), SliverToBoxAdapter( child: ListView.separated( shrinkWrap: true, itemCount: cells.length, itemBuilder: (_, int index) => cells[index], separatorBuilder: (_, __) => VSpace(GridSize.typeOptionSeparatorHeight), ), ), ]; return CustomScrollView( shrinkWrap: true, slivers: slivers, physics: StyledScrollPhysics(), ); }, ), ), ); } } class _FilterTextFieldDelegate extends SliverPersistentHeaderDelegate { _FilterTextFieldDelegate(); double fixHeight = 36; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent, ) { return Container( padding: const EdgeInsets.only(bottom: 4), color: Theme.of(context).cardColor, height: fixHeight, child: FlowyTextField( hintText: LocaleKeys.grid_settings_filterBy.tr(), onChanged: (text) { context .read>() .add(SimpleTextFilterEvent.updateFilter(text)); }, ), ); } @override double get maxExtent => fixHeight; @override double get minExtent => fixHeight; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return false; } } class FilterableFieldButton extends StatelessWidget { const FilterableFieldButton({ super.key, required this.fieldInfo, required this.onTap, }); final FieldInfo fieldInfo; final VoidCallback onTap; @override Widget build(BuildContext context) { return FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText( lineHeight: 1.0, fieldInfo.field.name, color: AFThemeExtension.of(context).textColor, ), onTap: onTap, leftIcon: FieldIcon( fieldInfo: fieldInfo, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; class DisclosureButton extends StatefulWidget { const DisclosureButton({ super.key, required this.popoverMutex, required this.onAction, }); final PopoverMutex popoverMutex; final Function(FilterDisclosureAction) onAction; @override State createState() => _DisclosureButtonState(); } class _DisclosureButtonState extends State { @override Widget build(BuildContext context) { return PopoverActionList( asBarrier: true, mutex: widget.popoverMutex, actions: FilterDisclosureAction.values .map((action) => FilterDisclosureActionWrapper(action)) .toList(), buildChild: (controller) { return FlowyIconButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, width: 20, icon: FlowySvg( FlowySvgs.details_s, color: Theme.of(context).iconTheme.color, ), onPressed: () => controller.show(), ); }, onSelected: (action, controller) async { widget.onAction(action.inner); controller.close(); }, ); } } enum FilterDisclosureAction { delete, } class FilterDisclosureActionWrapper extends ActionCell { FilterDisclosureActionWrapper(this.inner); final FilterDisclosureAction inner; @override Widget? leftIcon(Color iconColor) => null; @override String get name => inner.name; } extension FilterDisclosureActionExtension on FilterDisclosureAction { String get name { switch (this) { case FilterDisclosureAction.delete: return LocaleKeys.grid_settings_deleteFilter.tr(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'create_filter_list.dart'; import 'filter_menu_item.dart'; class FilterMenu extends StatelessWidget { const FilterMenu({ super.key, required this.fieldController, }); final FieldController fieldController; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => FilterEditorBloc( viewId: fieldController.viewId, fieldController: fieldController, ), child: BlocBuilder( buildWhen: (previous, current) { final previousIds = previous.filters.map((e) => e.filterId).toList(); final currentIds = current.filters.map((e) => e.filterId).toList(); return !listEquals(previousIds, currentIds); }, builder: (context, state) { final List children = []; children.addAll( state.filters .map( (filter) => FilterMenuItem( key: ValueKey(filter.filterId), filterId: filter.filterId, fieldType: state.fields .firstWhere( (element) => element.id == filter.fieldId, ) .fieldType, ), ) .toList(), ); if (state.fields.isNotEmpty) { children.add( AddFilterButton( viewId: state.viewId, ), ); } return Wrap( spacing: 6, runSpacing: 4, children: children, ); }, ), ); } } class AddFilterButton extends StatefulWidget { const AddFilterButton({required this.viewId, super.key}); final String viewId; @override State createState() => _AddFilterButtonState(); } class _AddFilterButtonState extends State { final PopoverController popoverController = PopoverController(); @override Widget build(BuildContext context) { return wrapPopover( SizedBox( height: 28, child: FlowyButton( text: FlowyText( lineHeight: 1.0, LocaleKeys.grid_settings_addFilter.tr(), color: AFThemeExtension.of(context).textColor, ), useIntrinsicWidth: true, hoverColor: AFThemeExtension.of(context).lightGreyHover, leftIcon: FlowySvg( FlowySvgs.add_s, color: Theme.of(context).iconTheme.color, ), onTap: () => popoverController.show(), ), ), ); } Widget wrapPopover(Widget child) { return AppFlowyPopover( controller: popoverController, constraints: BoxConstraints.loose(const Size(200, 300)), triggerActions: PopoverTriggerFlags.none, child: child, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: CreateDatabaseViewFilterList( onTap: () => popoverController.close(), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import 'choicechip/checkbox.dart'; import 'choicechip/checklist.dart'; import 'choicechip/date.dart'; import 'choicechip/number.dart'; import 'choicechip/select_option/select_option.dart'; import 'choicechip/text.dart'; import 'choicechip/url.dart'; class FilterMenuItem extends StatelessWidget { const FilterMenuItem({ super.key, required this.fieldType, required this.filterId, }); final FieldType fieldType; final String filterId; @override Widget build(BuildContext context) { return switch (fieldType) { FieldType.RichText => TextFilterChoicechip(filterId: filterId), FieldType.Number => NumberFilterChoiceChip(filterId: filterId), FieldType.URL => URLFilterChoicechip(filterId: filterId), FieldType.Checkbox => CheckboxFilterChoicechip(filterId: filterId), FieldType.Checklist => ChecklistFilterChoicechip(filterId: filterId), FieldType.DateTime || FieldType.LastEditedTime || FieldType.CreatedTime => DateFilterChoicechip(filterId: filterId), FieldType.SingleSelect || FieldType.MultiSelect => SelectOptionFilterChoicechip(filterId: filterId), // FieldType.Time => // TimeFilterChoiceChip(filterInfo: filterInfo), _ => const SizedBox.shrink(), }; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/footer/grid_footer.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class GridAddRowButton extends StatelessWidget { const GridAddRowButton({super.key}); @override Widget build(BuildContext context) { final color = Theme.of(context).brightness == Brightness.light ? const Color(0xFF171717).withValues(alpha: 0.4) : const Color(0xFFFFFFFF).withValues(alpha: 0.4); return FlowyButton( radius: BorderRadius.zero, decoration: BoxDecoration( border: Border( bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), ), ), text: FlowyText( lineHeight: 1.0, LocaleKeys.grid_row_newRow.tr(), color: color, ), margin: const EdgeInsets.symmetric(horizontal: 12), hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: () => context.read().add(const GridEvent.createRow()), leftIcon: FlowySvg( FlowySvgs.add_less_padding_s, color: color, ), ); } } class GridRowBottomBar extends StatelessWidget { const GridRowBottomBar({super.key}); @override Widget build(BuildContext context) { final padding = context.read().horizontalPadding; return Container( padding: GridSize.footerContentInsets.copyWith(left: 0) + EdgeInsets.only(left: padding), height: GridSize.footerHeight, child: const GridAddRowButton(), ); } } class GridRowLoadMoreButton extends StatelessWidget { const GridRowLoadMoreButton({super.key}); @override Widget build(BuildContext context) { final padding = context.read().horizontalPadding; final color = Theme.of(context).brightness == Brightness.light ? const Color(0xFF171717).withValues(alpha: 0.4) : const Color(0xFFFFFFFF).withValues(alpha: 0.4); return Container( padding: GridSize.footerContentInsets.copyWith(left: 0) + EdgeInsets.only(left: padding), height: GridSize.footerHeight, child: FlowyButton( radius: BorderRadius.zero, decoration: BoxDecoration( border: Border( bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), ), ), text: FlowyText( lineHeight: 1.0, LocaleKeys.grid_row_loadMore.tr(), color: color, ), margin: const EdgeInsets.symmetric(horizontal: 12), hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: () => context.read().add( const GridEvent.loadMoreRows(), ), leftIcon: FlowySvg( FlowySvgs.load_more_s, color: color, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/field/field_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/widgets/field/field_editor.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../layout/sizes.dart'; class GridFieldCell extends StatefulWidget { const GridFieldCell({ super.key, required this.viewId, required this.fieldController, required this.fieldInfo, required this.onTap, required this.onEditorOpened, required this.onFieldInsertedOnEitherSide, required this.isEditing, required this.isNew, }); final String viewId; final FieldController fieldController; final FieldInfo fieldInfo; final VoidCallback onTap; final VoidCallback onEditorOpened; final void Function(String fieldId) onFieldInsertedOnEitherSide; final bool isEditing; final bool isNew; @override State createState() => _GridFieldCellState(); } class _GridFieldCellState extends State { final PopoverController popoverController = PopoverController(); late final FieldCellBloc _bloc; @override void initState() { super.initState(); _bloc = FieldCellBloc(viewId: widget.viewId, fieldInfo: widget.fieldInfo); if (widget.isEditing) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { popoverController.show(); }); } } @override void didUpdateWidget(covariant oldWidget) { if (widget.fieldInfo != oldWidget.fieldInfo && !_bloc.isClosed) { _bloc.add(FieldCellEvent.onFieldChanged(widget.fieldInfo)); } if (widget.isEditing) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { popoverController.show(); }); } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { return BlocProvider.value( value: _bloc, child: BlocBuilder( builder: (context, state) { final button = AppFlowyPopover( triggerActions: PopoverTriggerFlags.none, constraints: const BoxConstraints(), margin: EdgeInsets.zero, direction: PopoverDirection.bottomWithLeftAligned, controller: popoverController, popupBuilder: (BuildContext context) { widget.onEditorOpened(); return FieldEditor( viewId: widget.viewId, fieldController: widget.fieldController, fieldInfo: widget.fieldInfo, isNewField: widget.isNew, initialPage: widget.isNew ? FieldEditorPage.details : FieldEditorPage.general, onFieldInserted: widget.onFieldInsertedOnEitherSide, ); }, child: FlowyTooltip( message: widget.fieldInfo.name, preferBelow: false, child: SizedBox( height: GridSize.headerHeight, child: FieldCellButton( field: widget.fieldInfo.field, onTap: widget.onTap, margin: const EdgeInsetsDirectional.fromSTEB(12, 9, 10, 9), ), ), ), ); const line = Positioned( top: 0, bottom: 0, right: 0, child: DragToExpandLine(), ); return _GridHeaderCellContainer( width: state.width, child: Stack( alignment: Alignment.centerRight, children: [button, line], ), ); }, ), ); } @override void dispose() { _bloc.close(); super.dispose(); } } class _GridHeaderCellContainer extends StatelessWidget { const _GridHeaderCellContainer({ required this.child, required this.width, }); final Widget child; final double width; @override Widget build(BuildContext context) { final borderSide = BorderSide(color: AFThemeExtension.of(context).borderColor); final decoration = BoxDecoration( border: Border( right: borderSide, bottom: borderSide, ), ); return Container( width: width, decoration: decoration, child: child, ); } } @visibleForTesting class DragToExpandLine extends StatelessWidget { const DragToExpandLine({ super.key, }); @override Widget build(BuildContext context) { return InkWell( onTap: () {}, child: GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragStart: (details) { context .read() .add(const FieldCellEvent.onResizeStart()); }, onHorizontalDragUpdate: (value) { context .read() .add(FieldCellEvent.startUpdateWidth(value.localPosition.dx)); }, onHorizontalDragEnd: (end) { context .read() .add(const FieldCellEvent.endUpdateWidth()); }, child: FlowyHover( cursor: SystemMouseCursors.resizeLeftRight, style: HoverStyle( hoverColor: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.zero, contentMargin: const EdgeInsets.only(left: 6), ), child: const SizedBox(width: 4), ), ), ); } } class FieldCellButton extends StatelessWidget { const FieldCellButton({ super.key, required this.field, required this.onTap, this.maxLines = 1, this.radius = BorderRadius.zero, this.margin, }); final FieldPB field; final VoidCallback onTap; final int? maxLines; final BorderRadius? radius; final EdgeInsetsGeometry? margin; @override Widget build(BuildContext context) { return FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: onTap, leftIcon: FieldIcon( fieldInfo: FieldInfo.initial(field), ), rightIcon: field.fieldType.rightIcon != null ? FlowySvg( field.fieldType.rightIcon!, blendMode: null, size: const Size.square(18), ) : null, radius: radius, text: FlowyText( field.name, lineHeight: 1.0, maxLines: maxLines, overflow: TextOverflow.ellipsis, color: AFThemeExtension.of(context).textColor, ), margin: margin ?? GridSize.cellContentInsets, ); } } class FieldIcon extends StatelessWidget { const FieldIcon({ super.key, required this.fieldInfo, this.dimension = 16.0, }); final FieldInfo fieldInfo; final double dimension; @override Widget build(BuildContext context) { final svgContent = kIconGroups?.findSvgContent( fieldInfo.icon, ); final color = Theme.of(context).isLightMode ? const Color(0xFF171717) : Colors.white; return svgContent == null ? FlowySvg( fieldInfo.fieldType.svgData, color: color.withValues(alpha: 0.6), size: Size.square(dimension), ) : SizedBox.square( dimension: dimension, child: Center( child: FlowySvg.string( svgContent, color: color.withValues(alpha: 0.45), size: Size.square(dimension - 2), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; extension RowDetailAccessoryExtension on FieldType { bool get showRowDetailAccessory => switch (this) { FieldType.Media => false, _ => true, }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reorderables/reorderables.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../layout/sizes.dart'; import 'desktop_field_cell.dart'; class GridHeaderSliverAdaptor extends StatefulWidget { const GridHeaderSliverAdaptor({ super.key, required this.viewId, required this.shrinkWrap, required this.anchorScrollController, }); final String viewId; final ScrollController anchorScrollController; final bool shrinkWrap; @override State createState() => _GridHeaderSliverAdaptorState(); } class _GridHeaderSliverAdaptorState extends State { @override Widget build(BuildContext context) { final fieldController = context.read().databaseController.fieldController; final databaseSize = context.read(); final horizontalPadding = databaseSize?.horizontalPadding ?? 0.0; final paddingLeft = databaseSize?.paddingLeftWithMaxDocumentWidth ?? 0.0; return BlocProvider( create: (context) { return GridHeaderBloc( viewId: widget.viewId, fieldController: fieldController, )..add(const GridHeaderEvent.initial()); }, child: SingleChildScrollView( scrollDirection: Axis.horizontal, controller: widget.anchorScrollController, child: Padding( padding: widget.shrinkWrap ? EdgeInsets.fromLTRB( horizontalPadding + paddingLeft, 0, horizontalPadding, 0, ) : EdgeInsets.zero, child: _GridHeader( viewId: widget.viewId, fieldController: fieldController, ), ), ), ); } } class _GridHeader extends StatefulWidget { const _GridHeader({required this.viewId, required this.fieldController}); final String viewId; final FieldController fieldController; @override State<_GridHeader> createState() => _GridHeaderState(); } class _GridHeaderState extends State<_GridHeader> { final Map> _gridMap = {}; final _scrollController = ScrollController(); @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final cells = state.fields .map( (fieldInfo) => GridFieldCell( key: _getKeyById(fieldInfo.id), viewId: widget.viewId, fieldInfo: fieldInfo, fieldController: widget.fieldController, onTap: () => context .read() .add(GridHeaderEvent.startEditingField(fieldInfo.id)), onFieldInsertedOnEitherSide: (fieldId) => context .read() .add(GridHeaderEvent.startEditingNewField(fieldId)), onEditorOpened: () => context .read() .add(const GridHeaderEvent.endEditingField()), isEditing: state.editingFieldId == fieldInfo.id, isNew: state.newFieldId == fieldInfo.id, ), ) .toList(); return RepaintBoundary( child: ReorderableRow( scrollController: _scrollController, buildDraggableFeedback: (context, constraints, child) => Material( color: Colors.transparent, child: child, ), draggingWidgetOpacity: 0, header: _cellLeading(), needsLongPressDraggable: UniversalPlatform.isMobile, footer: _CellTrailing(viewId: widget.viewId), onReorder: (int oldIndex, int newIndex) { context .read() .add(GridHeaderEvent.moveField(oldIndex, newIndex)); }, children: cells, ), ); }, ); } /// This is a workaround for [ReorderableRow]. /// [ReorderableRow] warps the child's key with a [GlobalKey]. /// It will trigger the child's widget's to recreate. /// The state will lose. ValueKey? _getKeyById(String id) { if (_gridMap.containsKey(id)) { return _gridMap[id]; } final newKey = ValueKey(id); _gridMap[id] = newKey; return newKey; } Widget _cellLeading() { return SizedBox( width: context.read().horizontalPadding, ); } } class _CellTrailing extends StatelessWidget { const _CellTrailing({required this.viewId}); final String viewId; @override Widget build(BuildContext context) { return Container( width: GridSize.newPropertyButtonWidth, height: GridSize.headerHeight, decoration: BoxDecoration( border: Border( bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), ), ), child: CreateFieldButton( viewId: viewId, onFieldCreated: (fieldId) => context .read() .add(GridHeaderEvent.startEditingNewField(fieldId)), ), ); } } class CreateFieldButton extends StatelessWidget { const CreateFieldButton({ super.key, required this.viewId, required this.onFieldCreated, }); final String viewId; final void Function(String fieldId) onFieldCreated; @override Widget build(BuildContext context) { return FlowyButton( margin: GridSize.cellContentInsets, radius: BorderRadius.zero, text: FlowyText( lineHeight: 1.0, LocaleKeys.grid_field_newProperty.tr(), overflow: TextOverflow.ellipsis, ), hoverColor: AFThemeExtension.of(context).greyHover, onTap: () async { final result = await FieldBackendService.createField( viewId: viewId, ); result.fold( (field) => onFieldCreated(field.id), (err) => Log.error("Failed to create field type option: $err"), ); }, leftIcon: const FlowySvg( FlowySvgs.add_less_padding_s, size: Size.square(16), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_field_button.dart ================================================ import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileFieldButton extends StatelessWidget { const MobileFieldButton.first({ super.key, required this.viewId, required this.fieldController, required this.fieldInfo, }) : radius = const BorderRadius.only(topLeft: Radius.circular(24)), margin = const EdgeInsets.symmetric(vertical: 14, horizontal: 18), index = null; const MobileFieldButton({ super.key, required this.viewId, required this.fieldController, required this.fieldInfo, required this.index, }) : radius = BorderRadius.zero, margin = const EdgeInsets.symmetric(vertical: 14, horizontal: 12); final String viewId; final int? index; final FieldController fieldController; final FieldInfo fieldInfo; final BorderRadius? radius; final EdgeInsets? margin; @override Widget build(BuildContext context) { Widget child = Container( width: 200, decoration: _getDecoration(context), child: FlowyButton( onTap: () => showQuickEditField(context, viewId, fieldController, fieldInfo), radius: radius, margin: margin, leftIconSize: const Size.square(18), leftIcon: FieldIcon( fieldInfo: fieldInfo, dimension: 18, ), text: FlowyText( fieldInfo.name, fontSize: 15, overflow: TextOverflow.ellipsis, ), ), ); if (index != null) { child = ReorderableDelayedDragStartListener(index: index!, child: child); } return child; } BoxDecoration? _getDecoration(BuildContext context) { final borderSide = BorderSide( color: Theme.of(context).dividerColor, ); if (index == null) { return BoxDecoration( borderRadius: const BorderRadiusDirectional.only( topStart: Radius.circular(24), ), border: BorderDirectional( top: borderSide, start: borderSide, ), ); } else { return null; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../layout/sizes.dart'; import '../../mobile_grid_page.dart'; import 'mobile_field_button.dart'; const double _kGridHeaderHeight = 50.0; class MobileGridHeader extends StatefulWidget { const MobileGridHeader({ super.key, required this.viewId, required this.contentScrollController, required this.reorderableController, }); final String viewId; final ScrollController contentScrollController; final ScrollController reorderableController; @override State createState() => _MobileGridHeaderState(); } class _MobileGridHeaderState extends State { @override Widget build(BuildContext context) { final fieldController = context.read().databaseController.fieldController; final isEditable = context.read()?.state.isEditable ?? false; return BlocProvider( create: (context) { return GridHeaderBloc( viewId: widget.viewId, fieldController: fieldController, )..add(const GridHeaderEvent.initial()); }, child: Stack( children: [ BlocBuilder( builder: (context, state) { return SingleChildScrollView( scrollDirection: Axis.horizontal, controller: widget.contentScrollController, child: Stack( children: [ Positioned( top: 0, left: GridSize.horizontalHeaderPadding + 24, right: GridSize.horizontalHeaderPadding + 24, child: _divider(), ), Positioned( bottom: 0, left: GridSize.horizontalHeaderPadding, right: GridSize.horizontalHeaderPadding, child: _divider(), ), SizedBox( height: _kGridHeaderHeight, width: getMobileGridContentWidth(state.fields), ), ], ), ); }, ), IgnorePointer( ignoring: !isEditable, child: SizedBox( height: _kGridHeaderHeight, child: _GridHeader( viewId: widget.viewId, fieldController: fieldController, scrollController: widget.reorderableController, ), ), ), ], ), ); } Widget _divider() { return Divider( height: 1, thickness: 1, color: Theme.of(context).dividerColor, ); } } class _GridHeader extends StatefulWidget { const _GridHeader({ required this.viewId, required this.fieldController, required this.scrollController, }); final String viewId; final FieldController fieldController; final ScrollController scrollController; @override State<_GridHeader> createState() => _GridHeaderState(); } class _GridHeaderState extends State<_GridHeader> { @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final fields = [...state.fields]; FieldInfo? firstField; if (fields.isNotEmpty) { firstField = fields.removeAt(0); } final cells = fields .mapIndexed( (index, fieldInfo) => MobileFieldButton( key: ValueKey(fieldInfo.id), index: index, viewId: widget.viewId, fieldController: widget.fieldController, fieldInfo: fieldInfo, ), ) .toList(); return ReorderableListView.builder( scrollController: widget.scrollController, shrinkWrap: true, scrollDirection: Axis.horizontal, proxyDecorator: (child, index, anim) => Material( color: Colors.transparent, child: child, ), padding: EdgeInsets.symmetric( horizontal: GridSize.horizontalHeaderPadding, ), header: firstField != null ? MobileFieldButton.first( viewId: widget.viewId, fieldController: widget.fieldController, fieldInfo: firstField, ) : null, footer: CreateFieldButton( viewId: widget.viewId, onFieldCreated: (fieldId) => context .read() .add(GridHeaderEvent.startEditingNewField(fieldId)), ), onReorder: (int oldIndex, int newIndex) { if (oldIndex < newIndex) { newIndex--; } oldIndex++; newIndex++; context .read() .add(GridHeaderEvent.moveField(oldIndex, newIndex)); }, itemCount: cells.length, itemBuilder: (context, index) => cells[index], ); }, ); } } class CreateFieldButton extends StatelessWidget { const CreateFieldButton({ super.key, required this.viewId, required this.onFieldCreated, }); final String viewId; final void Function(String fieldId) onFieldCreated; @override Widget build(BuildContext context) { return Container( constraints: BoxConstraints( maxWidth: GridSize.mobileNewPropertyButtonWidth, minHeight: GridSize.headerHeight, ), decoration: _getDecoration(context), child: FlowyButton( margin: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), radius: const BorderRadius.only(topRight: Radius.circular(24)), text: FlowyText( LocaleKeys.grid_field_newProperty.tr(), fontSize: 15, overflow: TextOverflow.ellipsis, color: Theme.of(context).hintColor, ), hoverColor: AFThemeExtension.of(context).greyHover, onTap: () => mobileCreateFieldWorkflow(context, viewId), leftIconSize: const Size.square(18), leftIcon: FlowySvg( FlowySvgs.add_s, size: const Size.square(18), color: Theme.of(context).hintColor, ), ), ); } BoxDecoration? _getDecoration(BuildContext context) { final borderSide = BorderSide( color: Theme.of(context).dividerColor, ); return BoxDecoration( borderRadius: const BorderRadiusDirectional.only( topEnd: Radius.circular(24), ), border: BorderDirectional( top: borderSide, end: borderSide, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/mobile_fab.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; Widget getGridFabs(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ MobileGridFab( backgroundColor: Theme.of(context).colorScheme.surface, foregroundColor: Theme.of(context).primaryColor, onTap: () { final bloc = context.read(); if (bloc.state.rowInfos.isNotEmpty) { context.push( MobileRowDetailPage.routeName, extra: { MobileRowDetailPage.argRowId: bloc.state.rowInfos.first.rowId, MobileRowDetailPage.argDatabaseController: bloc.databaseController, }, ); } }, boxShadow: const BoxShadow( offset: Offset(0, 8), color: Color(0x145D7D8B), blurRadius: 20, ), icon: FlowySvgs.properties_s, iconSize: const Size.square(24), ), const HSpace(16), MobileGridFab( backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, onTap: () { context .read() .add(const GridEvent.createRow(openRowDetail: true)); }, overlayColor: const WidgetStatePropertyAll(Color(0xFF009FD1)), boxShadow: const BoxShadow( offset: Offset(0, 8), color: Color(0x6612BFEF), blurRadius: 18, spreadRadius: -5, ), icon: FlowySvgs.add_s, iconSize: const Size.square(24), ), ], ); } class MobileGridFab extends StatelessWidget { const MobileGridFab({ super.key, required this.backgroundColor, required this.foregroundColor, required this.boxShadow, required this.onTap, required this.icon, required this.iconSize, this.overlayColor, }); final Color backgroundColor; final Color foregroundColor; final BoxShadow boxShadow; final VoidCallback onTap; final FlowySvgData icon; final Size iconSize; final WidgetStateProperty? overlayColor; @override Widget build(BuildContext context) { final radius = BorderRadius.circular(20); return DecoratedBox( decoration: BoxDecoration( color: backgroundColor, border: const Border.fromBorderSide( BorderSide(width: 0.5, color: Color(0xFFE4EDF0)), ), borderRadius: radius, boxShadow: [boxShadow], ), child: Material( borderOnForeground: false, color: Colors.transparent, borderRadius: radius, child: InkWell( borderRadius: radius, overlayColor: overlayColor, onTap: onTap, child: SizedBox.square( dimension: 56, child: Center( child: FlowySvg( icon, color: foregroundColor, size: iconSize, ), ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/domain/sort_service.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class RowActionMenu extends StatelessWidget { const RowActionMenu({ super.key, required this.viewId, required this.rowId, this.actions = RowAction.values, this.groupId, }); const RowActionMenu.board({ super.key, required this.viewId, required this.rowId, required this.groupId, }) : actions = const [RowAction.duplicate, RowAction.delete]; final String viewId; final RowId rowId; final List actions; final String? groupId; @override Widget build(BuildContext context) { final cells = actions.map((action) => _actionCell(context, action)).toList(); return SeparatedColumn( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, separatorBuilder: () => VSpace(GridSize.typeOptionSeparatorHeight), children: cells, ); } Widget _actionCell(BuildContext context, RowAction action) { Widget icon = FlowySvg(action.icon); if (action == RowAction.insertAbove) { icon = RotatedBox(quarterTurns: 1, child: icon); } return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( action.text, overflow: TextOverflow.ellipsis, lineHeight: 1.0, ), onTap: () { action.performAction(context, viewId, rowId); PopoverContainer.of(context).close(); }, leftIcon: icon, ), ); } } enum RowAction { insertAbove, insertBelow, duplicate, delete; FlowySvgData get icon { return switch (this) { insertAbove => FlowySvgs.arrow_s, insertBelow => FlowySvgs.add_s, duplicate => FlowySvgs.duplicate_s, delete => FlowySvgs.delete_s, }; } String get text { return switch (this) { insertAbove => LocaleKeys.grid_row_insertRecordAbove.tr(), insertBelow => LocaleKeys.grid_row_insertRecordBelow.tr(), duplicate => LocaleKeys.grid_row_duplicate.tr(), delete => LocaleKeys.grid_row_delete.tr(), }; } void performAction(BuildContext context, String viewId, String rowId) { switch (this) { case insertAbove: case insertBelow: final position = this == insertAbove ? OrderObjectPositionTypePB.Before : OrderObjectPositionTypePB.After; final intention = this == insertAbove ? LocaleKeys.grid_row_createRowAboveDescription.tr() : LocaleKeys.grid_row_createRowBelowDescription.tr(); if (context.read().state.sorts.isNotEmpty) { showCancelAndDeleteDialog( context: context, title: LocaleKeys.grid_sort_sortsActive.tr( namedArgs: {'intention': intention}, ), description: LocaleKeys.grid_sort_removeSorting.tr(), confirmLabel: LocaleKeys.button_remove.tr(), closeOnAction: true, onDelete: () { SortBackendService(viewId: viewId).deleteAllSorts(); RowBackendService.createRow( viewId: viewId, position: position, targetRowId: rowId, ); }, ); } else { RowBackendService.createRow( viewId: viewId, position: position, targetRowId: rowId, ); } break; case duplicate: RowBackendService.duplicateRow(viewId, rowId); break; case delete: showConfirmDeletionDialog( context: context, name: LocaleKeys.grid_row_label.tr(), description: LocaleKeys.grid_row_deleteRowPrompt.tr(), onConfirm: () => RowBackendService.deleteRows(viewId, [rowId]), ); break; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/mobile_cell_container.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../layout/sizes.dart'; class MobileGridRow extends StatefulWidget { const MobileGridRow({ super.key, required this.rowId, required this.databaseController, required this.openDetailPage, this.isDraggable = false, }); final RowId rowId; final DatabaseController databaseController; final void Function(BuildContext context) openDetailPage; final bool isDraggable; @override State createState() => _MobileGridRowState(); } class _MobileGridRowState extends State { late final RowController _rowController; late final EditableCellBuilder _cellBuilder; String get viewId => widget.databaseController.viewId; RowCache get rowCache => widget.databaseController.rowCache; @override void initState() { super.initState(); _rowController = RowController( rowMeta: rowCache.getRow(widget.rowId)!.rowMeta, viewId: viewId, rowCache: rowCache, ); _cellBuilder = EditableCellBuilder( databaseController: widget.databaseController, ); } @override Widget build(BuildContext context) { return BlocProvider( create: (context) => RowBloc( fieldController: widget.databaseController.fieldController, rowId: widget.rowId, rowController: _rowController, viewId: viewId, ), child: BlocBuilder( builder: (context, state) { return Row( children: [ SizedBox(width: GridSize.horizontalHeaderPadding), Expanded( child: RowContent( fieldController: widget.databaseController.fieldController, builder: _cellBuilder, onExpand: () => widget.openDetailPage(context), ), ), ], ); }, ), ); } @override Future dispose() async { super.dispose(); await _rowController.dispose(); } } class RowContent extends StatelessWidget { const RowContent({ super.key, required this.fieldController, required this.onExpand, required this.builder, }); final FieldController fieldController; final VoidCallback onExpand; final EditableCellBuilder builder; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SizedBox( height: 52, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ..._makeCells(context, state.cellContexts), _finalCellDecoration(context), ], ), ); }, ); } List _makeCells( BuildContext context, List cellContexts, ) { return cellContexts.map( (cellContext) { final fieldInfo = fieldController.getField(cellContext.fieldId)!; final EditableCellWidget child = builder.buildStyled( cellContext, EditableCellStyle.mobileGrid, ); return MobileCellContainer( isPrimary: fieldInfo.field.isPrimary, onPrimaryFieldCellTap: onExpand, child: child, ); }, ).toList(); } Widget _finalCellDecoration(BuildContext context) { return Container( width: 200, constraints: const BoxConstraints(minHeight: 46), decoration: BoxDecoration( border: Border( bottom: BorderSide(color: Theme.of(context).dividerColor), right: BorderSide(color: Theme.of(context).dividerColor), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import "package:appflowy/generated/locale_keys.g.dart"; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/domain/sort_service.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import '../../../../widgets/row/accessory/cell_accessory.dart'; import '../../../../widgets/row/cells/cell_container.dart'; import '../../layout/sizes.dart'; import 'action.dart'; class GridRow extends StatelessWidget { const GridRow({ super.key, required this.fieldController, required this.viewId, required this.rowId, required this.rowController, required this.cellBuilder, required this.openDetailPage, required this.index, this.shrinkWrap = false, required this.editable, }); final FieldController fieldController; final String viewId; final RowId rowId; final RowController rowController; final EditableCellBuilder cellBuilder; final void Function(BuildContext context) openDetailPage; final int index; final bool shrinkWrap; final bool editable; @override Widget build(BuildContext context) { Widget rowContent = RowContent( fieldController: fieldController, cellBuilder: cellBuilder, onExpand: () => openDetailPage(context), ); if (!shrinkWrap) { rowContent = Expanded(child: rowContent); } rowContent = BlocProvider( create: (_) => RowBloc( fieldController: fieldController, rowId: rowId, rowController: rowController, viewId: viewId, ), child: _RowEnterRegion( child: Row( children: [ _RowLeading(viewId: viewId, index: index), rowContent, ], ), ), ); if (!editable) { rowContent = IgnorePointer( child: rowContent, ); } return rowContent; } } class _RowLeading extends StatefulWidget { const _RowLeading({ required this.viewId, required this.index, }); final String viewId; final int index; @override State<_RowLeading> createState() => _RowLeadingState(); } class _RowLeadingState extends State<_RowLeading> { final PopoverController popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, constraints: BoxConstraints.loose(const Size(200, 200)), direction: PopoverDirection.rightWithCenterAligned, margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), popupBuilder: (_) { final bloc = context.read(); return BlocProvider.value( value: context.read(), child: RowActionMenu( viewId: bloc.viewId, rowId: bloc.rowId, ), ); }, child: Consumer( builder: (context, state, _) { return SizedBox( width: context .read() .horizontalPadding, child: state.onEnter ? _activeWidget() : null, ); }, ), ); } Widget _activeWidget() { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ InsertRowButton(viewId: widget.viewId), ReorderableDragStartListener( index: widget.index, child: RowMenuButton( openMenu: popoverController.show, ), ), ], ); } } class InsertRowButton extends StatelessWidget { const InsertRowButton({ super.key, required this.viewId, }); final String viewId; @override Widget build(BuildContext context) { return FlowyIconButton( tooltipText: LocaleKeys.tooltip_addNewRow.tr(), hoverColor: AFThemeExtension.of(context).lightGreyHover, width: 20, height: 30, onPressed: () { final rowBloc = context.read(); if (context.read().state.sorts.isNotEmpty) { showCancelAndDeleteDialog( context: context, title: LocaleKeys.grid_sort_sortsActive.tr( namedArgs: { 'intention': LocaleKeys.grid_row_createRowBelowDescription.tr(), }, ), description: LocaleKeys.grid_sort_removeSorting.tr(), confirmLabel: LocaleKeys.button_remove.tr(), closeOnAction: true, onDelete: () { SortBackendService(viewId: viewId).deleteAllSorts(); rowBloc.add(const RowEvent.createRow()); }, ); } else { rowBloc.add(const RowEvent.createRow()); } }, iconPadding: const EdgeInsets.all(3), icon: FlowySvg( FlowySvgs.add_s, color: Theme.of(context).colorScheme.tertiary, ), ); } } class RowMenuButton extends StatefulWidget { const RowMenuButton({ super.key, required this.openMenu, }); final VoidCallback openMenu; @override State createState() => _RowMenuButtonState(); } class _RowMenuButtonState extends State { @override Widget build(BuildContext context) { return FlowyIconButton( richTooltipText: TextSpan( children: [ TextSpan( text: '${LocaleKeys.tooltip_dragRow.tr()}\n', style: context.tooltipTextStyle(), ), TextSpan( text: LocaleKeys.tooltip_openMenu.tr(), style: context.tooltipTextStyle(), ), ], ), hoverColor: AFThemeExtension.of(context).lightGreyHover, width: 20, height: 30, onPressed: () => widget.openMenu(), iconPadding: const EdgeInsets.all(3), icon: FlowySvg( FlowySvgs.drag_element_s, color: Theme.of(context).colorScheme.tertiary, ), ); } } class RowContent extends StatelessWidget { const RowContent({ super.key, required this.fieldController, required this.cellBuilder, required this.onExpand, }); final FieldController fieldController; final VoidCallback onExpand; final EditableCellBuilder cellBuilder; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ..._makeCells(context, state.cellContexts), _finalCellDecoration(context), ], ), ); }, ); } List _makeCells( BuildContext context, List cellContexts, ) { return cellContexts.map( (cellContext) { final fieldInfo = fieldController.getField(cellContext.fieldId)!; final EditableCellWidget child = cellBuilder.buildStyled( cellContext, EditableCellStyle.desktopGrid, ); return CellContainer( width: fieldInfo.width!.toDouble(), isPrimary: fieldInfo.field.isPrimary, accessoryBuilder: (buildContext) { final builder = child.accessoryBuilder; final List accessories = []; if (fieldInfo.field.isPrimary) { accessories.add( GridCellAccessoryBuilder( builder: (key) => PrimaryCellAccessory( key: key, onTap: onExpand, isCellEditing: buildContext.isCellEditing, ), ), ); } if (builder != null) { accessories.addAll(builder(buildContext)); } return accessories; }, child: child, ); }, ).toList(); } Widget _finalCellDecoration(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.basic, child: ValueListenableBuilder( valueListenable: cellBuilder.databaseController.compactModeNotifier, builder: (context, compactMode, _) { return Container( width: GridSize.newPropertyButtonWidth, constraints: BoxConstraints(minHeight: compactMode ? 32 : 36), decoration: BoxDecoration( border: Border( bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), ), ), ); }, ), ); } } class RegionStateNotifier extends ChangeNotifier { bool _onEnter = false; set onEnter(bool value) { if (_onEnter != value) { _onEnter = value; notifyListeners(); } } bool get onEnter => _onEnter; } class _RowEnterRegion extends StatefulWidget { const _RowEnterRegion({required this.child}); final Widget child; @override State<_RowEnterRegion> createState() => _RowEnterRegionState(); } class _RowEnterRegionState extends State<_RowEnterRegion> { late final RegionStateNotifier _rowStateNotifier; @override void initState() { super.initState(); _rowStateNotifier = RegionStateNotifier(); } @override Future dispose() async { _rowStateNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: _rowStateNotifier, child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (p) => _rowStateNotifier.onEnter = true, onExit: (p) => _rowStateNotifier.onEnter = false, child: widget.child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/shortcuts.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class GridShortcuts extends StatelessWidget { const GridShortcuts({required this.child, super.key}); final Widget child; @override Widget build(BuildContext context) { return Shortcuts( shortcuts: bindKeys([]), child: Actions( dispatcher: LoggingActionDispatcher(), actions: const {}, child: child, ), ); } } Map bindKeys(List keys) { return {for (final key in keys) LogicalKeySet(key): KeyboardKeyIdent(key)}; } class KeyboardKeyIdent extends Intent { const KeyboardKeyIdent(this.key); final KeyboardKey key; } class LoggingActionDispatcher extends ActionDispatcher { @override Object? invokeAction( covariant Action action, covariant Intent intent, [ BuildContext? context, ]) { // print('Action invoked: $action($intent) from $context'); super.invokeAction(action, intent, context); return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/grid/application/simple_text_filter_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CreateDatabaseViewSortList extends StatelessWidget { const CreateDatabaseViewSortList({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { final sortBloc = context.read(); return BlocProvider( create: (_) => SimpleTextFilterBloc( values: List.from(sortBloc.state.creatableFields), comparator: (val) => val.name, ), child: BlocListener( listenWhen: (previous, current) => previous.creatableFields != current.creatableFields, listener: (context, state) { context.read>().add( SimpleTextFilterEvent.receiveNewValues(state.creatableFields), ); }, child: BlocBuilder, SimpleTextFilterState>( builder: (context, state) { final cells = state.values.map((fieldInfo) { return GridSortPropertyCell( fieldInfo: fieldInfo, onTap: () { context .read() .add(SortEditorEvent.createSort(fieldId: fieldInfo.id)); onTap.call(); }, ); }).toList(); final List slivers = [ SliverPersistentHeader( pinned: true, delegate: _SortTextFieldDelegate(), ), SliverToBoxAdapter( child: ListView.separated( shrinkWrap: true, itemCount: cells.length, itemBuilder: (_, index) => cells[index], separatorBuilder: (_, __) => VSpace(GridSize.typeOptionSeparatorHeight), ), ), ]; return CustomScrollView( shrinkWrap: true, slivers: slivers, physics: StyledScrollPhysics(), ); }, ), ), ); } } class _SortTextFieldDelegate extends SliverPersistentHeaderDelegate { _SortTextFieldDelegate(); double fixHeight = 36; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent, ) { return Container( padding: const EdgeInsets.only(bottom: 4), color: Theme.of(context).cardColor, height: fixHeight, child: FlowyTextField( hintText: LocaleKeys.grid_settings_sortBy.tr(), onChanged: (text) { context .read>() .add(SimpleTextFilterEvent.updateFilter(text)); }, ), ); } @override double get maxExtent => fixHeight; @override double get minExtent => fixHeight; @override bool shouldRebuild(covariant oldDelegate) => false; } class GridSortPropertyCell extends StatelessWidget { const GridSortPropertyCell({ super.key, required this.fieldInfo, required this.onTap, }); final FieldInfo fieldInfo; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText( fieldInfo.name, lineHeight: 1.0, color: AFThemeExtension.of(context).textColor, ), onTap: onTap, leftIcon: FieldIcon( fieldInfo: fieldInfo, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/order_panel.dart ================================================ import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_editor.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbenum.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class OrderPanel extends StatelessWidget { const OrderPanel({required this.onCondition, super.key}); final Function(SortConditionPB) onCondition; @override Widget build(BuildContext context) { final List children = SortConditionPB.values.map((condition) { return OrderPanelItem( condition: condition, onCondition: onCondition, ); }).toList(); return ConstrainedBox( constraints: const BoxConstraints(minWidth: 160), child: IntrinsicWidth( child: IntrinsicHeight( child: Column( children: children, ), ), ), ); } } class OrderPanelItem extends StatelessWidget { const OrderPanelItem({ super.key, required this.condition, required this.onCondition, }); final SortConditionPB condition; final Function(SortConditionPB) onCondition; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText(condition.title), onTap: () => onCondition(condition), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_choice_button.dart ================================================ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class SortChoiceButton extends StatelessWidget { const SortChoiceButton({ super.key, required this.text, this.onTap, this.radius = const Radius.circular(14), this.leftIcon, this.rightIcon, this.editable = true, }); final String text; final VoidCallback? onTap; final Radius radius; final Widget? leftIcon; final Widget? rightIcon; final bool editable; @override Widget build(BuildContext context) { return FlowyButton( decoration: BoxDecoration( color: Colors.transparent, border: Border.fromBorderSide( BorderSide(color: Theme.of(context).dividerColor), ), borderRadius: BorderRadius.all(radius), ), useIntrinsicWidth: true, text: FlowyText( text, lineHeight: 1.0, color: AFThemeExtension.of(context).textColor, overflow: TextOverflow.ellipsis, ), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), radius: BorderRadius.all(radius), leftIcon: leftIcon, rightIcon: rightIcon, hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: onTap, disable: !editable, disableOpacity: 1.0, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart ================================================ import 'dart:io'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'create_sort_list.dart'; import 'order_panel.dart'; import 'sort_choice_button.dart'; class SortEditor extends StatefulWidget { const SortEditor({super.key}); @override State createState() => _SortEditorState(); } class _SortEditorState extends State { final popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return ReorderableListView.builder( onReorder: (oldIndex, newIndex) => context .read() .add(SortEditorEvent.reorderSort(oldIndex, newIndex)), itemCount: state.sorts.length, itemBuilder: (context, index) => DatabaseSortItem( key: ValueKey(state.sorts[index].sortId), index: index, sort: state.sorts[index], popoverMutex: popoverMutex, ), proxyDecorator: (child, index, animation) => Material( color: Colors.transparent, child: Stack( children: [ BlocProvider.value( value: context.read(), child: child, ), MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grabbing, child: const SizedBox.expand(), ), ], ), ), shrinkWrap: true, buildDefaultDragHandles: false, footer: Row( children: [ Flexible( child: DatabaseAddSortButton( disable: state.creatableFields.isEmpty, popoverMutex: popoverMutex, ), ), const HSpace(6), Flexible( child: DeleteAllSortsButton( popoverMutex: popoverMutex, ), ), ], ), ); }, ); } } class DatabaseSortItem extends StatelessWidget { const DatabaseSortItem({ super.key, required this.index, required this.popoverMutex, required this.sort, }); final int index; final PopoverMutex popoverMutex; final DatabaseSort sort; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(vertical: 6), color: Theme.of(context).cardColor, child: Row( children: [ ReorderableDragStartListener( index: index, child: MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grab, child: SizedBox( width: 14 + 12, height: 14, child: FlowySvg( FlowySvgs.drag_element_s, size: const Size.square(14), color: Theme.of(context).iconTheme.color, ), ), ), ), Flexible( fit: FlexFit.tight, child: SizedBox( height: 26, child: BlocSelector( selector: (state) => state.allFields.firstWhereOrNull( (field) => field.id == sort.fieldId, ), builder: (context, field) { return SortChoiceButton( text: field?.name ?? "", editable: false, ); }, ), ), ), const HSpace(6), Flexible( fit: FlexFit.tight, child: SizedBox( height: 26, child: SortConditionButton( sort: sort, popoverMutex: popoverMutex, ), ), ), const HSpace(6), FlowyIconButton( width: 26, onPressed: () { context .read() .add(SortEditorEvent.deleteSort(sort.sortId)); PopoverContainer.of(context).close(); }, hoverColor: AFThemeExtension.of(context).lightGreyHover, icon: FlowySvg( FlowySvgs.trash_m, color: Theme.of(context).iconTheme.color, size: const Size.square(16), ), ), ], ), ); } } extension SortConditionExtension on SortConditionPB { String get title { return switch (this) { SortConditionPB.Ascending => LocaleKeys.grid_sort_ascending.tr(), SortConditionPB.Descending => LocaleKeys.grid_sort_descending.tr(), _ => throw UnimplementedError(), }; } } class DatabaseAddSortButton extends StatefulWidget { const DatabaseAddSortButton({ super.key, required this.disable, required this.popoverMutex, }); final bool disable; final PopoverMutex popoverMutex; @override State createState() => _DatabaseAddSortButtonState(); } class _DatabaseAddSortButtonState extends State { final _popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( controller: _popoverController, mutex: widget.popoverMutex, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(200, 300)), offset: const Offset(-6, 8), triggerActions: PopoverTriggerFlags.none, asBarrier: true, popupBuilder: (popoverContext) { return BlocProvider.value( value: context.read(), child: CreateDatabaseViewSortList( onTap: () => _popoverController.close(), ), ); }, child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).greyHover, disable: widget.disable, text: FlowyText(LocaleKeys.grid_sort_addSort.tr()), onTap: () => _popoverController.show(), leftIcon: const FlowySvg(FlowySvgs.add_s), ), ), ); } } class DeleteAllSortsButton extends StatelessWidget { const DeleteAllSortsButton({super.key, required this.popoverMutex}); final PopoverMutex popoverMutex; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText(LocaleKeys.grid_sort_deleteAllSorts.tr()), onTap: () { context .read() .add(const SortEditorEvent.deleteAllSorts()); PopoverContainer.of(context).close(); }, leftIcon: const FlowySvg(FlowySvgs.delete_s), ), ); }, ); } } class SortConditionButton extends StatefulWidget { const SortConditionButton({ super.key, required this.popoverMutex, required this.sort, }); final PopoverMutex popoverMutex; final DatabaseSort sort; @override State createState() => _SortConditionButtonState(); } class _SortConditionButtonState extends State { final PopoverController popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, mutex: widget.popoverMutex, constraints: BoxConstraints.loose(const Size(340, 200)), direction: PopoverDirection.bottomWithLeftAligned, triggerActions: PopoverTriggerFlags.none, popupBuilder: (BuildContext popoverContext) { return OrderPanel( onCondition: (condition) { context.read().add( SortEditorEvent.editSort( sortId: widget.sort.sortId, condition: condition, ), ); popoverController.close(); }, ); }, child: SortChoiceButton( text: widget.sort.condition.title, rightIcon: FlowySvg( FlowySvgs.arrow_down_s, color: Theme.of(context).iconTheme.color, ), onTap: () => popoverController.show(), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_menu.dart ================================================ import 'dart:math' as math; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'sort_choice_button.dart'; import 'sort_editor.dart'; class SortMenu extends StatelessWidget { const SortMenu({ super.key, required this.fieldController, }); final FieldController fieldController; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => SortEditorBloc( viewId: fieldController.viewId, fieldController: fieldController, ), child: BlocBuilder( builder: (context, state) { if (state.sorts.isEmpty) { return const SizedBox.shrink(); } return AppFlowyPopover( controller: PopoverController(), constraints: BoxConstraints.loose(const Size(320, 200)), direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 5), margin: const EdgeInsets.fromLTRB(6.0, 0.0, 6.0, 6.0), popupBuilder: (BuildContext popoverContext) { return BlocProvider.value( value: context.read(), child: const SortEditor(), ); }, child: SortChoiceChip(sorts: state.sorts), ); }, ), ); } } class SortChoiceChip extends StatelessWidget { const SortChoiceChip({ super.key, required this.sorts, this.onTap, }); final List sorts; final VoidCallback? onTap; @override Widget build(BuildContext context) { final arrow = Transform.rotate( angle: -math.pi / 2, child: FlowySvg( FlowySvgs.arrow_left_s, color: Theme.of(context).iconTheme.color, ), ); final text = LocaleKeys.grid_settings_sort.tr(); final leftIcon = FlowySvg( FlowySvgs.sort_ascending_s, color: Theme.of(context).iconTheme.color, ); return SizedBox( height: 28, child: SortChoiceButton( text: text, leftIcon: leftIcon, rightIcon: arrow, onTap: onTap, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../filter/create_filter_list.dart'; class FilterButton extends StatefulWidget { const FilterButton({ super.key, required this.toggleExtension, }); final ToggleExtensionNotifier toggleExtension; @override State createState() => _FilterButtonState(); } class _FilterButtonState extends State { final _popoverController = PopoverController(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return _wrapPopover( MouseRegion( cursor: SystemMouseCursors.click, child: FlowyIconButton( tooltipText: LocaleKeys.grid_settings_filter.tr(), width: 24, height: 24, iconPadding: const EdgeInsets.all(3), hoverColor: AFThemeExtension.of(context).lightGreyHover, icon: const FlowySvg(FlowySvgs.database_filter_s), onPressed: () { final bloc = context.read(); if (bloc.state.filters.isEmpty) { _popoverController.show(); } else { widget.toggleExtension.toggle(); } }, ), ), ); }, ); } Widget _wrapPopover(Widget child) { return AppFlowyPopover( controller: _popoverController, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(200, 300)), offset: const Offset(0, 8), triggerActions: PopoverTriggerFlags.none, child: child, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: CreateDatabaseViewFilterList( onTap: () { if (!widget.toggleExtension.isToggled) { widget.toggleExtension.toggle(); } _popoverController.close(); }, ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'filter_button.dart'; import 'sort_button.dart'; import 'view_database_button.dart'; class GridSettingBar extends StatelessWidget { const GridSettingBar({ super.key, required this.controller, required this.toggleExtension, }); final DatabaseController controller; final ToggleExtensionNotifier toggleExtension; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (context) => FilterEditorBloc( viewId: controller.viewId, fieldController: controller.fieldController, ), ), BlocProvider( create: (context) => SortEditorBloc( viewId: controller.viewId, fieldController: controller.fieldController, ), ), ], child: ValueListenableBuilder( valueListenable: controller.isLoading, builder: (context, isLoading, child) { if (isLoading) { return const SizedBox.shrink(); } final isReference = Provider.of(context)?.isReference ?? false; return SizedBox( height: 20, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ FilterButton(toggleExtension: toggleExtension), const HSpace(2), SortButton(toggleExtension: toggleExtension), if (isReference) ...[ const HSpace(2), ViewDatabaseButton(view: controller.view), ], const HSpace(2), SettingButton(databaseController: controller), ], ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../sort/create_sort_list.dart'; class SortButton extends StatefulWidget { const SortButton({super.key, required this.toggleExtension}); final ToggleExtensionNotifier toggleExtension; @override State createState() => _SortButtonState(); } class _SortButtonState extends State { final _popoverController = PopoverController(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return wrapPopover( MouseRegion( cursor: SystemMouseCursors.click, child: FlowyIconButton( tooltipText: LocaleKeys.grid_settings_sort.tr(), width: 24, height: 24, iconPadding: const EdgeInsets.all(3), hoverColor: AFThemeExtension.of(context).lightGreyHover, icon: const FlowySvg(FlowySvgs.database_sort_s), onPressed: () { if (state.sorts.isEmpty) { _popoverController.show(); } else { widget.toggleExtension.toggle(); } }, ), ), ); }, ); } Widget wrapPopover(Widget child) { return AppFlowyPopover( controller: _popoverController, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(200, 300)), offset: const Offset(0, 8), triggerActions: PopoverTriggerFlags.none, popupBuilder: (popoverContext) { return BlocProvider.value( value: context.read(), child: CreateDatabaseViewSortList( onTap: () { if (!widget.toggleExtension.isToggled) { widget.toggleExtension.toggle(); } _popoverController.close(); }, ), ); }, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; class ViewDatabaseButton extends StatelessWidget { const ViewDatabaseButton({super.key, required this.view}); final ViewPB view; @override Widget build(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.click, child: FlowyIconButton( tooltipText: LocaleKeys.grid_rowPage_viewDatabase.tr(), width: 24, height: 24, iconPadding: const EdgeInsets.all(3), icon: const FlowySvg(FlowySvgs.database_fullscreen_s), onPressed: () { getIt().add( TabsEvent.openPlugin( plugin: view.plugin(), view: view, ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/grid/application/grid_accessory_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_menu.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_menu.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; class DatabaseViewSettingExtension extends StatelessWidget { const DatabaseViewSettingExtension({ super.key, required this.viewId, required this.databaseController, required this.toggleExtension, }); final String viewId; final DatabaseController databaseController; final ToggleExtensionNotifier toggleExtension; @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: toggleExtension, child: Consumer( builder: (context, value, child) { if (value.isToggled) { return BlocProvider( create: (context) => DatabaseViewSettingExtensionBloc(viewId: viewId), child: _DatabaseViewSettingContent( fieldController: databaseController.fieldController, ), ); } else { return const SizedBox.shrink(); } }, ), ); } } class _DatabaseViewSettingContent extends StatelessWidget { const _DatabaseViewSettingContent({required this.fieldController}); final FieldController fieldController; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return DecoratedBox( decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Theme.of(context).dividerColor, ), ), ), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( children: [ SortMenu(fieldController: fieldController), const HSpace(6), Expanded( child: FilterMenu(fieldController: fieldController), ), ], ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_add_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/extension.dart'; import 'package:flutter/material.dart'; class AddDatabaseViewButton extends StatefulWidget { const AddDatabaseViewButton({super.key, required this.onTap}); final Function(DatabaseLayoutPB) onTap; @override State createState() => _AddDatabaseViewButtonState(); } class _AddDatabaseViewButtonState extends State { final popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, constraints: BoxConstraints.loose(const Size(200, 400)), direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 8), margin: EdgeInsets.zero, triggerActions: PopoverTriggerFlags.none, child: Padding( padding: const EdgeInsetsDirectional.only( top: 2.0, bottom: 7.0, start: 6.0, ), child: FlowyIconButton( width: 26, hoverColor: AFThemeExtension.of(context).greyHover, onPressed: () => popoverController.show(), radius: Corners.s4Border, icon: FlowySvg( FlowySvgs.add_s, color: Theme.of(context).hintColor, ), iconColorOnHover: Theme.of(context).colorScheme.onSurface, ), ), popupBuilder: (BuildContext context) { return TabBarAddButtonAction( onTap: (action) { popoverController.close(); widget.onTap(action); }, ); }, ); } } class TabBarAddButtonAction extends StatelessWidget { const TabBarAddButtonAction({super.key, required this.onTap}); final Function(DatabaseLayoutPB) onTap; @override Widget build(BuildContext context) { final cells = DatabaseLayoutPB.values.map((layout) { return TabBarAddButtonActionCell( action: layout, onTap: onTap, ); }).toList(); return ListView.separated( shrinkWrap: true, itemCount: cells.length, itemBuilder: (BuildContext context, int index) => cells[index], separatorBuilder: (BuildContext context, int index) => VSpace(GridSize.typeOptionSeparatorHeight), padding: const EdgeInsets.symmetric(vertical: 4.0), ); } } class TabBarAddButtonActionCell extends StatelessWidget { const TabBarAddButtonActionCell({ super.key, required this.action, required this.onTap, }); final DatabaseLayoutPB action; final void Function(DatabaseLayoutPB) onTap; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText( '${LocaleKeys.grid_createView.tr()} ${action.layoutName}', color: AFThemeExtension.of(context).textColor, ), leftIcon: FlowySvg( action.icon, color: Theme.of(context).iconTheme.color, ), onTap: () => onTap(action), ).padding(horizontal: 6.0), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'tab_bar_add_button.dart'; class TabBarHeader extends StatelessWidget { const TabBarHeader({ super.key, }); @override Widget build(BuildContext context) { return SizedBox( height: 35, child: Stack( children: [ Positioned( bottom: 0, left: 0, right: 0, child: Divider( color: AFThemeExtension.of(context).borderColor, height: 1, thickness: 1, ), ), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Expanded( child: DatabaseTabBar(), ), Flexible( child: BlocBuilder( builder: (context, state) { return Padding( padding: const EdgeInsets.only(top: 6.0), child: pageSettingBarFromState(context, state), ); }, ), ), ], ), ], ), ); } Widget pageSettingBarFromState( BuildContext context, DatabaseTabBarState state, ) { if (state.tabBars.length < state.selectedIndex) { return const SizedBox.shrink(); } final tabBar = state.tabBars[state.selectedIndex]; final controller = state.tabBarControllerByViewId[tabBar.viewId]!.controller; return tabBar.builder.settingBar(context, controller); } } class DatabaseTabBar extends StatefulWidget { const DatabaseTabBar({super.key}); @override State createState() => _DatabaseTabBarState(); } class _DatabaseTabBarState extends State { final _scrollController = ScrollController(); @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return ListView.separated( controller: _scrollController, scrollDirection: Axis.horizontal, shrinkWrap: true, itemCount: state.tabBars.length + 1, itemBuilder: (context, index) => index == state.tabBars.length ? AddDatabaseViewButton( onTap: (layoutType) { context .read() .add(DatabaseTabBarEvent.createView(layoutType, null)); }, ) : DatabaseTabBarItem( key: ValueKey(state.tabBars[index].viewId), view: state.tabBars[index].view, isSelected: state.selectedIndex == index, onTap: (selectedView) { context .read() .add(DatabaseTabBarEvent.selectView(selectedView.id)); }, ), separatorBuilder: (context, index) => VerticalDivider( width: 1.0, thickness: 1.0, indent: 8, endIndent: 13, color: Theme.of(context).dividerColor, ), ); }, ); } } class DatabaseTabBarItem extends StatelessWidget { const DatabaseTabBarItem({ super.key, required this.view, required this.isSelected, required this.onTap, }); final ViewPB view; final bool isSelected; final Function(ViewPB) onTap; @override Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 160), child: Stack( children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: SizedBox( height: 26, child: TabBarItemButton( view: view, isSelected: isSelected, onTap: () => onTap(view), ), ), ), if (isSelected) Positioned( bottom: 0, left: 0, right: 0, child: Divider( height: 2, thickness: 2, color: Theme.of(context).colorScheme.primary, ), ), ], ), ); } } class TabBarItemButton extends StatefulWidget { const TabBarItemButton({ super.key, required this.view, required this.isSelected, required this.onTap, }); final ViewPB view; final bool isSelected; final VoidCallback onTap; @override State createState() => _TabBarItemButtonState(); } class _TabBarItemButtonState extends State { final menuController = PopoverController(); final iconController = PopoverController(); @override Widget build(BuildContext context) { Color? color; if (!widget.isSelected) { color = Theme.of(context).hintColor; } if (Theme.of(context).brightness == Brightness.dark) { color = null; } return AppFlowyPopover( controller: menuController, constraints: const BoxConstraints( minWidth: 120, maxWidth: 460, maxHeight: 300, ), direction: PopoverDirection.bottomWithCenterAligned, clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (_) { return IntrinsicHeight( child: IntrinsicWidth( child: Column( children: [ ActionCellWidget( action: TabBarViewAction.rename, itemHeight: ActionListSizes.itemHeight, onSelected: (action) { showAFTextFieldDialog( context: context, title: LocaleKeys.menuAppHeader_renameDialog.tr(), initialValue: widget.view.nameOrDefault, onConfirm: (newValue) { context.read().add( DatabaseTabBarEvent.renameView( widget.view.id, newValue, ), ); }, ); menuController.close(); }, ), AppFlowyPopover( controller: iconController, direction: PopoverDirection.rightWithCenterAligned, constraints: BoxConstraints.loose(const Size(364, 356)), margin: const EdgeInsets.all(0), child: ActionCellWidget( action: TabBarViewAction.changeIcon, itemHeight: ActionListSizes.itemHeight, onSelected: (action) { iconController.show(); }, ), popupBuilder: (context) { return FlowyIconEmojiPicker( tabs: const [PickerTabType.icon], enableBackgroundColorSelection: false, onSelectedEmoji: (r) { ViewBackendService.updateViewIcon( view: widget.view, viewIcon: r.data, ); if (!r.keepOpen) { iconController.close(); menuController.close(); } }, ); }, ), ActionCellWidget( action: TabBarViewAction.delete, itemHeight: ActionListSizes.itemHeight, onSelected: (action) { NavigatorAlertDialog( title: LocaleKeys.grid_deleteView.tr(), confirm: () { context.read().add( DatabaseTabBarEvent.deleteView(widget.view.id), ); }, ).show(context); menuController.close(); }, ), ], ), ), ); }, child: IntrinsicWidth( child: FlowyTooltip( message: widget.view.nameOrDefault, preferBelow: false, child: FlowyButton( radius: Corners.s6Border, hoverColor: AFThemeExtension.of(context).greyHover, onTap: () { if (widget.isSelected) menuController.show(); widget.onTap.call(); }, margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), onSecondaryTap: () { menuController.show(); }, leftIcon: _buildViewIcon(), text: FlowyText( widget.view.nameOrDefault, lineHeight: 1.0, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, color: color, fontWeight: widget.isSelected ? FontWeight.w500 : FontWeight.w400, ), ), ), ), ); } Widget _buildViewIcon() { final iconData = widget.view.icon.toEmojiIconData(); Widget icon; if (iconData.isEmpty || iconData.type != FlowyIconType.icon) { icon = widget.view.defaultIcon(); } else { icon = RawEmojiIconWidget( emoji: iconData, emojiSize: 14.0, enableColor: false, ); } final isReference = Provider.of(context)?.isReference ?? false; final iconWidget = Opacity(opacity: 0.6, child: icon); return isReference ? Stack( children: [ iconWidget, const Positioned( right: 0, bottom: 0, child: FlowySvg( FlowySvgs.referenced_page_s, blendMode: BlendMode.dstIn, ), ), ], ) : iconWidget; } } enum TabBarViewAction implements ActionCell { rename, changeIcon, delete; @override String get name { switch (this) { case TabBarViewAction.rename: return LocaleKeys.disclosureAction_rename.tr(); case TabBarViewAction.changeIcon: return LocaleKeys.disclosureAction_changeIcon.tr(); case TabBarViewAction.delete: return LocaleKeys.disclosureAction_delete.tr(); } } Widget icon(Color iconColor) { switch (this) { case TabBarViewAction.rename: return const FlowySvg(FlowySvgs.edit_s); case TabBarViewAction.changeIcon: return const FlowySvg(FlowySvgs.change_icon_s); case TabBarViewAction.delete: return const FlowySvg(FlowySvgs.delete_s); } } @override Widget? leftIcon(Color iconColor) => icon(iconColor); @override Widget? rightIcon(Color iconColor) => null; @override Color? textColor(BuildContext context) { return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/tab_bar/mobile/mobile_tab_bar_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/view/database_view_list.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/setting/mobile_database_controls.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileTabBarHeader extends StatelessWidget { const MobileTabBarHeader({super.key}); @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( left: GridSize.horizontalHeaderPadding, top: 14.0, right: GridSize.horizontalHeaderPadding - 5.0, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const _DatabaseViewSelectorButton(), const Spacer(), BlocBuilder( builder: (context, state) { final currentView = state.tabBars.firstWhereIndexedOrNull( (index, tabBar) => index == state.selectedIndex, ); if (currentView == null) { return const SizedBox.shrink(); } return MobileDatabaseControls( controller: state .tabBarControllerByViewId[currentView.viewId]!.controller, features: switch (currentView.layout) { ViewLayoutPB.Board || ViewLayoutPB.Calendar => [ MobileDatabaseControlFeatures.filter, ], ViewLayoutPB.Grid => [ MobileDatabaseControlFeatures.sort, MobileDatabaseControlFeatures.filter, ], _ => [], }, ); }, ), ], ), ); } } class _DatabaseViewSelectorButton extends StatelessWidget { const _DatabaseViewSelectorButton(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final tabBar = state.tabBars.firstWhereIndexedOrNull( (index, tabBar) => index == state.selectedIndex, ); if (tabBar == null) { return const SizedBox.shrink(); } return TextButton( style: ButtonStyle( padding: const WidgetStatePropertyAll( EdgeInsets.fromLTRB(12, 8, 8, 8), ), maximumSize: const WidgetStatePropertyAll(Size(200, 48)), minimumSize: const WidgetStatePropertyAll(Size(48, 0)), shape: const WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(12)), ), ), backgroundColor: WidgetStatePropertyAll( Theme.of(context).brightness == Brightness.light ? const Color(0x0F212729) : const Color(0x0FFFFFFF), ), overlayColor: WidgetStatePropertyAll( Theme.of(context).colorScheme.secondary, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ _buildViewIconButton(context, tabBar.view), const HSpace(6), Flexible( child: FlowyText.medium( tabBar.view.nameOrDefault, fontSize: 14, overflow: TextOverflow.ellipsis, ), ), const HSpace(8), const FlowySvg( FlowySvgs.arrow_tight_s, size: Size.square(10), ), ], ), onPressed: () { showTransitionMobileBottomSheet( context, showDivider: false, builder: (_) { return MultiBlocProvider( providers: [ BlocProvider.value( value: context.read(), ), BlocProvider.value( value: context.read(), ), ], child: const MobileDatabaseViewList(), ); }, ); }, ); }, ); } Widget _buildViewIconButton(BuildContext context, ViewPB view) { final iconData = view.icon.toEmojiIconData(); if (iconData.isEmpty || iconData.type != FlowyIconType.icon) { return SizedBox.square( dimension: 16.0, child: view.defaultIcon(), ); } return RawEmojiIconWidget( emoji: iconData, emojiSize: 16, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart'; import 'package:appflowy/plugins/shared/share/share_button.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; import 'desktop/tab_bar_header.dart'; import 'mobile/mobile_tab_bar_header.dart'; abstract class DatabaseTabBarItemBuilder { const DatabaseTabBarItemBuilder(); /// Returns the content of the tab bar item. The content is shown when the tab /// bar item is selected. It can be any kind of database view. Widget content( BuildContext context, ViewPB view, DatabaseController controller, bool shrinkWrap, String? initialRowId, ); /// Returns the setting bar of the tab bar item. The setting bar is shown on the /// top right conner when the tab bar item is selected. Widget settingBar( BuildContext context, DatabaseController controller, ); Widget settingBarExtension( BuildContext context, DatabaseController controller, ); /// Should be called in case a builder has resources it /// needs to dispose of. /// // If we add any logic in this method, add @mustCallSuper ! void dispose() {} } class DatabaseTabBarView extends StatefulWidget { const DatabaseTabBarView({ super.key, required this.view, required this.shrinkWrap, required this.showActions, this.initialRowId, this.actionBuilder, this.node, }); final ViewPB view; final bool shrinkWrap; final BlockComponentActionBuilder? actionBuilder; final bool showActions; final Node? node; /// Used to open a Row on plugin load /// final String? initialRowId; @override State createState() => _DatabaseTabBarViewState(); } class _DatabaseTabBarViewState extends State { bool enableCompactMode = false; bool initialed = false; StreamSubscription? compactModeSubscription; String get compactModeId => widget.node?.id ?? widget.view.id; @override void initState() { super.initState(); if (widget.node != null) { enableCompactMode = widget.node!.attributes[DatabaseBlockKeys.enableCompactMode] ?? false; setState(() { initialed = true; }); } else { fetchLocalCompactMode(compactModeId).then((v) { if (mounted) { setState(() { enableCompactMode = v; initialed = true; }); } }); compactModeSubscription = compactModeEventBus.on().listen((event) { if (event.id != widget.view.id) return; updateLocalCompactMode(event.enable); }); } } @override void dispose() { super.dispose(); compactModeSubscription?.cancel(); } @override Widget build(BuildContext context) { if (!initialed) return Center(child: CircularProgressIndicator()); return LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth; final editorState = context.read(); final maxDocWidth = editorState?.editorStyle.maxWidth ?? maxWidth; final paddingLeft = max(0, maxWidth - maxDocWidth) / 2; return MultiBlocProvider( providers: [ BlocProvider( create: (_) => DatabaseTabBarBloc( view: widget.view, compactModeId: compactModeId, enableCompactMode: enableCompactMode, )..add(const DatabaseTabBarEvent.initial()), ), BlocProvider( create: (_) => ViewBloc(view: widget.view) ..add( const ViewEvent.initial(), ), ), ], child: BlocBuilder( builder: (innerContext, state) { final layout = state.tabBars[state.selectedIndex].layout; final isCalendar = layout == ViewLayoutPB.Calendar; final databseBuilderSize = context.read(); final horizontalPadding = databseBuilderSize.horizontalPadding; final showActionWrapper = widget.showActions && widget.actionBuilder != null && widget.node != null; final Widget child = Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (UniversalPlatform.isMobile) const VSpace(12), ValueListenableBuilder( valueListenable: state .tabBarControllerByViewId[state.parentView.id]! .controller .isLoading, builder: (_, value, ___) { if (value) { return const SizedBox.shrink(); } Widget child = UniversalPlatform.isDesktop ? const TabBarHeader() : const MobileTabBarHeader(); if (innerContext.watch().state.view.isLocked) { child = IgnorePointer( child: child, ); } if (showActionWrapper) { child = BlockComponentActionWrapper( node: widget.node!, actionBuilder: widget.actionBuilder!, child: Padding( padding: EdgeInsets.only(right: horizontalPadding), child: child, ), ); } if (UniversalPlatform.isDesktop) { child = Container( padding: EdgeInsets.fromLTRB( horizontalPadding + paddingLeft, 0, horizontalPadding, 0, ), child: child, ); } return child; }, ), pageSettingBarExtensionFromState(context, state), wrapContent( layout: layout, child: Padding( padding: (isCalendar && widget.shrinkWrap || showActionWrapper) ? EdgeInsets.only(left: 42 - horizontalPadding) : EdgeInsets.zero, child: Provider( create: (_) => DatabasePluginWidgetBuilderSize( horizontalPadding: horizontalPadding, paddingLeftWithMaxDocumentWidth: paddingLeft, verticalPadding: databseBuilderSize.verticalPadding, ), child: pageContentFromState(context, state), ), ), ), ], ); return child; }, ), ); }, ); } Future fetchLocalCompactMode(String compactModeId) async { Set compactModeIds = {}; try { final localIds = await getIt().get( KVKeys.compactModeIds, ); final List decodedList = jsonDecode(localIds ?? ''); compactModeIds = Set.from(decodedList.map((item) => item as String)); } catch (e) { Log.warn('fetch local compact mode from id :$compactModeId failed', e); } return compactModeIds.contains(compactModeId); } Future updateLocalCompactMode(bool enableCompactMode) async { Set compactModeIds = {}; try { final localIds = await getIt().get( KVKeys.compactModeIds, ); final List decodedList = jsonDecode(localIds ?? ''); compactModeIds = Set.from(decodedList.map((item) => item as String)); } catch (e) { Log.warn('get compact mode ids failed', e); } if (enableCompactMode) { compactModeIds.add(compactModeId); } else { compactModeIds.remove(compactModeId); } await getIt().set( KVKeys.compactModeIds, jsonEncode(compactModeIds.toList()), ); } Widget wrapContent({required ViewLayoutPB layout, required Widget child}) { if (widget.shrinkWrap) { if (layout.shrinkWrappable) { return child; } return SizedBox( height: layout.pluginHeight, child: child, ); } return Expanded(child: child); } Widget pageContentFromState(BuildContext context, DatabaseTabBarState state) { final tab = state.tabBars[state.selectedIndex]; final controller = state.tabBarControllerByViewId[tab.viewId]!.controller; return tab.builder.content( context, tab.view, controller, widget.shrinkWrap, widget.initialRowId, ); } Widget pageSettingBarExtensionFromState( BuildContext context, DatabaseTabBarState state, ) { if (state.tabBars.length < state.selectedIndex) { return const SizedBox.shrink(); } final tabBar = state.tabBars[state.selectedIndex]; final controller = state.tabBarControllerByViewId[tabBar.viewId]!.controller; return Padding( padding: EdgeInsets.symmetric( horizontal: context.read().horizontalPadding, ), child: tabBar.builder.settingBarExtension( context, controller, ), ); } } class DatabaseTabBarViewPlugin extends Plugin { DatabaseTabBarViewPlugin({ required ViewPB view, required PluginType pluginType, this.initialRowId, }) : _pluginType = pluginType, notifier = ViewPluginNotifier(view: view); @override final ViewPluginNotifier notifier; final PluginType _pluginType; late final ViewInfoBloc _viewInfoBloc; late final PageAccessLevelBloc _pageAccessLevelBloc; /// Used to open a Row on plugin load /// final String? initialRowId; @override PluginWidgetBuilder get widgetBuilder => DatabasePluginWidgetBuilder( bloc: _viewInfoBloc, pageAccessLevelBloc: _pageAccessLevelBloc, notifier: notifier, initialRowId: initialRowId, ); @override PluginId get id => notifier.view.id; @override PluginType get pluginType => _pluginType; @override void init() { _viewInfoBloc = ViewInfoBloc(view: notifier.view) ..add(const ViewInfoEvent.started()); _pageAccessLevelBloc = PageAccessLevelBloc(view: notifier.view) ..add(const PageAccessLevelEvent.initial()); } @override void dispose() { _viewInfoBloc.close(); _pageAccessLevelBloc.close(); notifier.dispose(); } } const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding'; const kDatabasePluginWidgetBuilderShowActions = 'show_actions'; const kDatabasePluginWidgetBuilderActionBuilder = 'action_builder'; const kDatabasePluginWidgetBuilderNode = 'node'; class DatabasePluginWidgetBuilderSize { const DatabasePluginWidgetBuilderSize({ required this.horizontalPadding, this.verticalPadding = 16.0, this.paddingLeftWithMaxDocumentWidth = 0.0, }); final double horizontalPadding; final double verticalPadding; final double paddingLeftWithMaxDocumentWidth; double get paddingLeft => paddingLeftWithMaxDocumentWidth + horizontalPadding; } class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { DatabasePluginWidgetBuilder({ required this.bloc, required this.pageAccessLevelBloc, required this.notifier, this.initialRowId, }); final ViewInfoBloc bloc; final PageAccessLevelBloc pageAccessLevelBloc; final ViewPluginNotifier notifier; /// Used to open a Row on plugin load /// final String? initialRowId; @override String? get viewName => notifier.view.nameOrDefault; @override Widget get leftBarItem { return BlocProvider.value( value: pageAccessLevelBloc, child: ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view), ); } @override Widget tabBarItem(String pluginId, [bool shortForm = false]) => ViewTabBarItem(view: notifier.view, shortForm: shortForm); @override Widget buildWidget({ required PluginContext context, required bool shrinkWrap, Map? data, }) { notifier.isDeleted.addListener(() { final deletedView = notifier.isDeleted.value; if (deletedView != null && deletedView.hasIndex()) { context.onDeleted?.call(notifier.view, deletedView.index); } }); final horizontalPadding = data?[kDatabasePluginWidgetBuilderHorizontalPadding] as double? ?? GridSize.horizontalHeaderPadding + 40; final BlockComponentActionBuilder? actionBuilder = data?[kDatabasePluginWidgetBuilderActionBuilder]; final bool showActions = data?[kDatabasePluginWidgetBuilderShowActions] ?? false; final Node? node = data?[kDatabasePluginWidgetBuilderNode]; return Provider( create: (context) => DatabasePluginWidgetBuilderSize( horizontalPadding: horizontalPadding, ), child: DatabaseTabBarView( key: ValueKey(notifier.view.id), view: notifier.view, shrinkWrap: shrinkWrap, initialRowId: initialRowId, actionBuilder: actionBuilder, showActions: showActions, node: node, ), ); } @override List get navigationItems => [this]; @override Widget? get rightBarItem { final view = notifier.view; return MultiBlocProvider( providers: [ BlocProvider.value( value: bloc, ), BlocProvider.value( value: pageAccessLevelBloc, ), ], child: Row( children: [ ShareButton(key: ValueKey(view.id), view: view), const HSpace(10), ViewFavoriteButton(view: view), const HSpace(4), MoreViewActions(view: view), ], ), ); } @override EdgeInsets get contentPadding => const EdgeInsets.only(top: 28); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/row/action.dart'; import 'package:appflowy/shared/af_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import '../cell/card_cell_builder.dart'; import '../cell/card_cell_skeleton/card_cell.dart'; import 'card_bloc.dart'; import 'container/accessory.dart'; import 'container/card_container.dart'; /// Edit a database row with card style widget class RowCard extends StatefulWidget { const RowCard({ super.key, required this.fieldController, required this.rowMeta, required this.viewId, required this.isEditing, required this.rowCache, required this.cellBuilder, required this.onTap, required this.onStartEditing, required this.onEndEditing, required this.styleConfiguration, this.onShiftTap, this.groupingFieldId, this.groupId, required this.userProfile, this.isCompact = false, }); final FieldController fieldController; final RowMetaPB rowMeta; final String viewId; final String? groupingFieldId; final String? groupId; final bool isEditing; final RowCache rowCache; /// The [CardCellBuilder] is used to build the card cells. final CardCellBuilder cellBuilder; /// Called when the user taps on the card. final void Function(BuildContext context) onTap; final void Function(BuildContext context)? onShiftTap; /// Called when the user starts editing the card. final VoidCallback onStartEditing; /// Called when the user ends editing the card. final VoidCallback onEndEditing; final RowCardStyleConfiguration styleConfiguration; /// Specifically the token is used to handle requests to retrieve images /// from cloud storage, such as the card cover. final UserProfilePB? userProfile; /// Whether the card is in a narrow space. /// This is used to determine eg. the Cover height. final bool isCompact; @override State createState() => _RowCardState(); } class _RowCardState extends State { final popoverController = PopoverController(); late final CardBloc _cardBloc; @override void initState() { super.initState(); final rowController = RowController( viewId: widget.viewId, rowMeta: widget.rowMeta, rowCache: widget.rowCache, ); _cardBloc = CardBloc( fieldController: widget.fieldController, viewId: widget.viewId, groupFieldId: widget.groupingFieldId, isEditing: widget.isEditing, rowController: rowController, )..add(const CardEvent.initial()); } @override void didUpdateWidget(covariant oldWidget) { if (widget.isEditing != _cardBloc.state.isEditing) { _cardBloc.add(CardEvent.setIsEditing(widget.isEditing)); } super.didUpdateWidget(oldWidget); } @override void dispose() { _cardBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: _cardBloc, child: BlocListener( listenWhen: (previous, current) => previous.isEditing != current.isEditing, listener: (context, state) { if (!state.isEditing) { widget.onEndEditing(); } }, child: UniversalPlatform.isMobile ? _mobile() : _desktop(), ), ); } Widget _mobile() { return BlocBuilder( builder: (context, state) { return GestureDetector( onTap: () => widget.onTap(context), behavior: HitTestBehavior.opaque, child: MobileCardContent( userProfile: widget.userProfile, rowMeta: state.rowMeta, cellBuilder: widget.cellBuilder, styleConfiguration: widget.styleConfiguration, cells: state.cells, ), ); }, ); } Widget _desktop() { final accessories = widget.styleConfiguration.showAccessory ? const [ EditCardAccessory(), MoreCardOptionsAccessory(), ] : null; return AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, constraints: BoxConstraints.loose(const Size(140, 200)), direction: PopoverDirection.rightWithCenterAligned, popupBuilder: (_) => RowActionMenu.board( viewId: _cardBloc.viewId, rowId: _cardBloc.rowController.rowId, groupId: widget.groupId, ), child: Builder( builder: (context) { return RowCardContainer( buildAccessoryWhen: () => !context.watch().state.isEditing, accessories: accessories ?? [], openAccessory: _handleOpenAccessory, onTap: widget.onTap, onShiftTap: widget.onShiftTap, child: BlocBuilder( builder: (context, state) { return _CardContent( rowMeta: state.rowMeta, cellBuilder: widget.cellBuilder, styleConfiguration: widget.styleConfiguration, cells: state.cells, userProfile: widget.userProfile, isCompact: widget.isCompact, ); }, ), ); }, ), ); } void _handleOpenAccessory(AccessoryType newAccessoryType) { switch (newAccessoryType) { case AccessoryType.edit: widget.onStartEditing(); break; case AccessoryType.more: popoverController.show(); break; } } } class _CardContent extends StatelessWidget { const _CardContent({ required this.rowMeta, required this.cellBuilder, required this.cells, required this.styleConfiguration, this.userProfile, this.isCompact = false, }); final RowMetaPB rowMeta; final CardCellBuilder cellBuilder; final List cells; final RowCardStyleConfiguration styleConfiguration; final UserProfilePB? userProfile; final bool isCompact; @override Widget build(BuildContext context) { final child = Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ CardCover( cover: rowMeta.cover, userProfile: userProfile, isCompact: isCompact, ), Padding( padding: styleConfiguration.cardPadding, child: Column( children: _makeCells(context, rowMeta, cells), ), ), ], ); return styleConfiguration.hoverStyle == null ? child : FlowyHover( style: styleConfiguration.hoverStyle, buildWhenOnHover: () => !context.read().state.isEditing, child: child, ); } List _makeCells( BuildContext context, RowMetaPB rowMeta, List cells, ) { return cells .mapIndexed( (int index, CellMeta cellMeta) => _CardContentCell( cellBuilder: cellBuilder, cellMeta: cellMeta, rowMeta: rowMeta, isTitle: index == 0, styleMap: styleConfiguration.cellStyleMap, ), ) .toList(); } } class _CardContentCell extends StatefulWidget { const _CardContentCell({ required this.cellBuilder, required this.cellMeta, required this.rowMeta, required this.isTitle, required this.styleMap, }); final CellMeta cellMeta; final RowMetaPB rowMeta; final CardCellBuilder cellBuilder; final CardCellStyleMap styleMap; final bool isTitle; @override State<_CardContentCell> createState() => _CardContentCellState(); } class _CardContentCellState extends State<_CardContentCell> { late final EditableCardNotifier? cellNotifier; @override void initState() { super.initState(); cellNotifier = widget.isTitle ? EditableCardNotifier() : null; cellNotifier?.isCellEditing.addListener(listener); } void listener() { final isEditing = cellNotifier!.isCellEditing.value; context.read().add(CardEvent.setIsEditing(isEditing)); } @override void dispose() { cellNotifier?.isCellEditing.removeListener(listener); cellNotifier?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocListener( listenWhen: (previous, current) => previous.isEditing != current.isEditing, listener: (context, state) { cellNotifier?.isCellEditing.value = state.isEditing; }, child: widget.cellBuilder.build( cellContext: widget.cellMeta.cellContext(), styleMap: widget.styleMap, cellNotifier: cellNotifier, hasNotes: !widget.rowMeta.isDocumentEmpty, ), ); } } class CardCover extends StatelessWidget { const CardCover({ super.key, this.cover, this.userProfile, this.isCompact = false, }); final RowCoverPB? cover; final UserProfilePB? userProfile; final bool isCompact; @override Widget build(BuildContext context) { if (cover == null || cover!.data.isEmpty || cover!.uploadType == FileUploadTypePB.CloudFile && userProfile == null) { return const SizedBox.shrink(); } return Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: const BorderRadius.only( topLeft: Radius.circular(4), topRight: Radius.circular(4), ), color: Theme.of(context).cardColor, ), child: Row( children: [ Expanded(child: _renderCover(context, cover!)), ], ), ); } Widget _renderCover(BuildContext context, RowCoverPB cover) { final height = isCompact ? 50.0 : 100.0; if (cover.coverType == CoverTypePB.FileCover) { return SizedBox( height: height, width: double.infinity, child: AFImage( url: cover.data, uploadType: cover.uploadType, userProfile: userProfile, ), ); } if (cover.coverType == CoverTypePB.AssetCover) { return SizedBox( height: height, width: double.infinity, child: Image.asset( PageStyleCoverImageType.builtInImagePath(cover.data), fit: BoxFit.cover, ), ); } if (cover.coverType == CoverTypePB.ColorCover) { final color = FlowyTint.fromId(cover.data)?.color(context) ?? cover.data.tryToColor(); return Container( height: height, width: double.infinity, color: color, ); } if (cover.coverType == CoverTypePB.GradientCover) { return Container( height: height, width: double.infinity, decoration: BoxDecoration( gradient: FlowyGradientColor.fromId(cover.data).linear, ), ); } return const SizedBox.shrink(); } } class EditCardAccessory extends StatelessWidget with CardAccessory { const EditCardAccessory({super.key}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(3.0), child: FlowySvg( FlowySvgs.edit_s, color: Theme.of(context).hintColor, ), ); } @override AccessoryType get type => AccessoryType.edit; } class MoreCardOptionsAccessory extends StatelessWidget with CardAccessory { const MoreCardOptionsAccessory({super.key}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(3.0), child: FlowySvg( FlowySvgs.three_dots_s, color: Theme.of(context).hintColor, ), ); } @override AccessoryType get type => AccessoryType.more; } class RowCardStyleConfiguration { const RowCardStyleConfiguration({ required this.cellStyleMap, this.showAccessory = true, this.cardPadding = const EdgeInsets.all(4), this.hoverStyle, }); final CardCellStyleMap cellStyleMap; final bool showAccessory; final EdgeInsets cardPadding; final HoverStyle? hoverStyle; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/foundation.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'card_bloc.freezed.dart'; class CardBloc extends Bloc { CardBloc({ required this.fieldController, required this.groupFieldId, required this.viewId, required bool isEditing, required this.rowController, }) : super( CardState.initial( _makeCells( fieldController, groupFieldId, rowController, ), isEditing, rowController.rowMeta, ), ) { rowController.initialize(); _dispatch(); } final FieldController fieldController; final String? groupFieldId; final String viewId; final RowController rowController; VoidCallback? _rowCallback; @override Future close() async { if (_rowCallback != null) { _rowCallback = null; } await rowController.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { await _startListening(); }, didReceiveCells: (cells, reason) async { emit( state.copyWith( cells: cells, changeReason: reason, ), ); }, setIsEditing: (bool isEditing) { if (isEditing != state.isEditing) { emit(state.copyWith(isEditing: isEditing)); } }, didUpdateRowMeta: (rowMeta) { emit(state.copyWith(rowMeta: rowMeta)); }, ); }, ); } Future _startListening() async { rowController.addListener( onRowChanged: (cellMap, reason) { if (!isClosed) { final cells = _makeCells(fieldController, groupFieldId, rowController); add(CardEvent.didReceiveCells(cells, reason)); } }, onMetaChanged: () { if (!isClosed) { add(CardEvent.didUpdateRowMeta(rowController.rowMeta)); } }, ); } } List _makeCells( FieldController fieldController, String? groupFieldId, RowController rowController, ) { // Only show the non-hidden cells and cells that aren't of the grouping field final cellContext = rowController.loadCells(); cellContext.removeWhere((cellContext) { final fieldInfo = fieldController.getField(cellContext.fieldId); return fieldInfo == null || !(fieldInfo.visibility?.isVisibleState() ?? false) || (groupFieldId != null && cellContext.fieldId == groupFieldId); }); return cellContext .map( (cellCtx) => CellMeta( fieldId: cellCtx.fieldId, rowId: cellCtx.rowId, fieldType: fieldController.getField(cellCtx.fieldId)!.fieldType, ), ) .toList(); } @freezed class CardEvent with _$CardEvent { const factory CardEvent.initial() = _InitialRow; const factory CardEvent.setIsEditing(bool isEditing) = _IsEditing; const factory CardEvent.didReceiveCells( List cells, ChangedReason reason, ) = _DidReceiveCells; const factory CardEvent.didUpdateRowMeta(RowMetaPB rowMeta) = _DidUpdateRowMeta; } @freezed class CellMeta with _$CellMeta { const CellMeta._(); const factory CellMeta({ required String fieldId, required RowId rowId, required FieldType fieldType, }) = _DatabaseCellMeta; CellContext cellContext() => CellContext(fieldId: fieldId, rowId: rowId); } @freezed class CardState with _$CardState { const factory CardState({ required List cells, required bool isEditing, required RowMetaPB rowMeta, ChangedReason? changeReason, }) = _RowCardState; factory CardState.initial( List cells, bool isEditing, RowMetaPB rowMeta, ) => CardState( cells: cells, isEditing: isEditing, rowMeta: rowMeta, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart ================================================ import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; enum AccessoryType { edit, more, } abstract mixin class CardAccessory implements Widget { AccessoryType get type; void onTap(BuildContext context) {} } class CardAccessoryContainer extends StatelessWidget { const CardAccessoryContainer({ super.key, required this.accessories, required this.onTapAccessory, }); final List accessories; final void Function(AccessoryType) onTapAccessory; @override Widget build(BuildContext context) { if (accessories.isEmpty) { return const SizedBox.shrink(); } final children = accessories.map((accessory) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { accessory.onTap(context); onTapAccessory(accessory.type); }, child: _wrapHover(context, accessory), ); }).toList(); children.insert( 1, VerticalDivider( width: 1, thickness: 1, color: Theme.of(context).brightness == Brightness.light ? const Color(0xFF1F2329).withValues(alpha: 0.12) : const Color(0xff59647a), ), ); return _wrapDecoration( context, IntrinsicHeight(child: Row(children: children)), ); } Widget _wrapHover(BuildContext context, CardAccessory accessory) { return SizedBox( width: 24, height: 22, child: FlowyHover( style: HoverStyle( backgroundColor: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.zero, ), child: accessory, ), ); } Widget _wrapDecoration(BuildContext context, Widget child) { final decoration = BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(4)), border: Border.fromBorderSide( BorderSide( color: Theme.of(context).brightness == Brightness.light ? const Color(0xFF1F2329).withValues(alpha: 0.12) : const Color(0xff59647a), ), ), boxShadow: [ BoxShadow( blurRadius: 4, color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), BoxShadow( blurRadius: 4, spreadRadius: -2, color: const Color(0xFF1F2329).withValues(alpha: 0.02), ), ], ); return Container( clipBehavior: Clip.hardEdge, decoration: decoration, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), child: child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'accessory.dart'; class RowCardContainer extends StatelessWidget { const RowCardContainer({ super.key, required this.child, required this.onTap, required this.openAccessory, required this.accessories, this.buildAccessoryWhen, this.onShiftTap, }); final Widget child; final void Function(BuildContext) onTap; final void Function(BuildContext)? onShiftTap; final void Function(AccessoryType) openAccessory; final List accessories; final bool Function()? buildAccessoryWhen; @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => _CardContainerNotifier(), child: Consumer<_CardContainerNotifier>( builder: (context, notifier, _) { final shouldBuildAccessory = buildAccessoryWhen?.call() ?? true; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { if (HardwareKeyboard.instance.isShiftPressed) { onShiftTap?.call(context); } else { onTap(context); } }, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 36), child: _CardEnterRegion( shouldBuildAccessory: shouldBuildAccessory, accessories: accessories, onTapAccessory: openAccessory, child: child, ), ), ); }, ), ); } } class _CardEnterRegion extends StatelessWidget { const _CardEnterRegion({ required this.shouldBuildAccessory, required this.child, required this.accessories, required this.onTapAccessory, }); final bool shouldBuildAccessory; final Widget child; final List accessories; final void Function(AccessoryType) onTapAccessory; @override Widget build(BuildContext context) { return Selector<_CardContainerNotifier, bool>( selector: (context, notifier) => notifier.onEnter, builder: (context, onEnter, _) { final List children = [ child, if (onEnter && shouldBuildAccessory) Positioned( top: 7.0, right: 7.0, child: CardAccessoryContainer( accessories: accessories, onTapAccessory: onTapAccessory, ), ), ]; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (p) => Provider.of<_CardContainerNotifier>(context, listen: false) .onEnter = true, onExit: (p) => Provider.of<_CardContainerNotifier>(context, listen: false) .onEnter = false, child: IntrinsicHeight( child: Stack( alignment: AlignmentDirectional.topEnd, fit: StackFit.expand, children: children, ), ), ); }, ); } } class _CardContainerNotifier extends ChangeNotifier { _CardContainerNotifier(); bool _onEnter = false; set onEnter(bool value) { if (_onEnter != value) { _onEnter = value; notifyListeners(); } } bool get onEnter => _onEnter; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'card_cell_skeleton/card_cell.dart'; import 'card_cell_skeleton/checkbox_card_cell.dart'; import 'card_cell_skeleton/checklist_card_cell.dart'; import 'card_cell_skeleton/date_card_cell.dart'; import 'card_cell_skeleton/media_card_cell.dart'; import 'card_cell_skeleton/number_card_cell.dart'; import 'card_cell_skeleton/relation_card_cell.dart'; import 'card_cell_skeleton/select_option_card_cell.dart'; import 'card_cell_skeleton/summary_card_cell.dart'; import 'card_cell_skeleton/text_card_cell.dart'; import 'card_cell_skeleton/time_card_cell.dart'; import 'card_cell_skeleton/timestamp_card_cell.dart'; import 'card_cell_skeleton/translate_card_cell.dart'; import 'card_cell_skeleton/url_card_cell.dart'; typedef CardCellStyleMap = Map; class CardCellBuilder { CardCellBuilder({required this.databaseController}); final DatabaseController databaseController; Widget build({ required CellContext cellContext, required CardCellStyleMap styleMap, EditableCardNotifier? cellNotifier, required bool hasNotes, }) { final fieldType = databaseController.fieldController .getField(cellContext.fieldId)! .fieldType; final key = ValueKey( "${databaseController.viewId}${cellContext.fieldId}${cellContext.rowId}", ); final style = styleMap[fieldType]; return switch (fieldType) { FieldType.Checkbox => CheckboxCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.Checklist => ChecklistCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.DateTime => DateCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.LastEditedTime || FieldType.CreatedTime => TimestampCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.SingleSelect || FieldType.MultiSelect => SelectOptionCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.Number => NumberCardCell( style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, key: key, ), FieldType.RichText => TextCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, editableNotifier: cellNotifier, showNotes: hasNotes, ), FieldType.URL => URLCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.Relation => RelationCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.Summary => SummaryCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.Time => TimeCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.Translate => TranslateCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), FieldType.Media => MediaCardCell( key: key, style: isStyleOrNull(style), databaseController: databaseController, cellContext: cellContext, ), _ => throw UnimplementedError, }; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart ================================================ import 'package:flutter/material.dart'; abstract class CardCell extends StatefulWidget { const CardCell({super.key, required this.style}); final T style; } abstract class CardCellStyle { const CardCellStyle({required this.padding}); final EdgeInsetsGeometry padding; } S? isStyleOrNull(CardCellStyle? style) { if (style is S) { return style as S; } else { return null; } } class EditableCardNotifier { EditableCardNotifier({bool isEditing = false}) : isCellEditing = ValueNotifier(isEditing); final ValueNotifier isCellEditing; void dispose() { isCellEditing.dispose(); } } abstract mixin class EditableCell { // Each cell notifier will be bind to the [EditableRowNotifier], which enable // the row notifier receive its cells event. For example: begin editing the // cell or end editing the cell. // EditableCardNotifier? get editableNotifier; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checkbox_card_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'card_cell.dart'; class CheckboxCardCellStyle extends CardCellStyle { CheckboxCardCellStyle({ required super.padding, required this.iconSize, required this.showFieldName, this.textStyle, }) : assert(!showFieldName || showFieldName && textStyle != null); final Size iconSize; final bool showFieldName; final TextStyle? textStyle; } class CheckboxCardCell extends CardCell { const CheckboxCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _CheckboxCellState(); } class _CheckboxCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (_) { return CheckboxCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), )..add(const CheckboxCellEvent.initial()); }, child: BlocBuilder( builder: (context, state) { return Container( alignment: AlignmentDirectional.centerStart, padding: widget.style.padding, child: Row( children: [ FlowyIconButton( icon: FlowySvg( state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, blendMode: BlendMode.dst, size: widget.style.iconSize, ), width: 20, onPressed: () => context .read() .add(const CheckboxCellEvent.select()), ), if (widget.style.showFieldName) ...[ const HSpace(6.0), Text( state.fieldName, style: widget.style.textStyle, ), ], ], ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'card_cell.dart'; class ChecklistCardCellStyle extends CardCellStyle { ChecklistCardCellStyle({ required super.padding, required this.textStyle, }); final TextStyle textStyle; } class ChecklistCardCell extends CardCell { const ChecklistCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _ChecklistCellState(); } class _ChecklistCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (_) { return ChecklistCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( builder: (context, state) { if (state.tasks.isEmpty) { return const SizedBox.shrink(); } return Padding( padding: widget.style.padding, child: ChecklistProgressBar( tasks: state.tasks, percent: state.percent, textStyle: widget.style.textStyle, ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/date_card_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/date.dart'; import 'card_cell.dart'; class DateCardCellStyle extends CardCellStyle { DateCardCellStyle({ required super.padding, required this.textStyle, }); final TextStyle textStyle; } class DateCardCell extends CardCell { const DateCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _DateCellState(); } class _DateCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) { return DateCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( builder: (context, state) { final dateStr = getDateCellStrFromCellData( state.fieldInfo, state.cellData, ); if (dateStr.isEmpty) { return const SizedBox.shrink(); } return Container( alignment: Alignment.centerLeft, padding: widget.style.padding, child: Row( children: [ Flexible( child: Text( dateStr, style: widget.style.textStyle, overflow: TextOverflow.ellipsis, ), ), if (state.cellData.reminderId.isNotEmpty) ...[ const HSpace(4), const FlowySvg(FlowySvgs.clock_alarm_s), ], ], ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MediaCardCellStyle extends CardCellStyle { const MediaCardCellStyle({ required super.padding, required this.textStyle, }); final TextStyle textStyle; } class MediaCardCell extends CardCell { const MediaCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _MediaCellState(); } class _MediaCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (_) => MediaCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), )..add(const MediaCellEvent.initial()), child: BlocBuilder( builder: (context, state) { if (state.files.isEmpty) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(left: 4), child: Row( children: [ const FlowySvg( FlowySvgs.media_s, size: Size.square(12), ), const HSpace(6), Flexible( child: FlowyText.regular( LocaleKeys.grid_media_attachmentsHint .tr(args: ['${state.files.length}']), fontSize: 12, color: AFThemeExtension.of(context).secondaryTextColor, overflow: TextOverflow.ellipsis, ), ), ], ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/number_card_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'card_cell.dart'; class NumberCardCellStyle extends CardCellStyle { const NumberCardCellStyle({ required super.padding, required this.textStyle, }); final TextStyle textStyle; } class NumberCardCell extends CardCell { const NumberCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _NumberCellState(); } class _NumberCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) { return NumberCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( buildWhen: (previous, current) => previous.content != current.content, builder: (context, state) { if (state.content.isEmpty) { return const SizedBox.shrink(); } return Container( alignment: AlignmentDirectional.centerStart, padding: widget.style.padding, child: Text(state.content, style: widget.style.textStyle), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'card_cell.dart'; class RelationCardCellStyle extends CardCellStyle { RelationCardCellStyle({ required super.padding, required this.textStyle, required this.wrap, }); final TextStyle textStyle; final bool wrap; } class RelationCardCell extends CardCell { const RelationCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _RelationCellState(); } class _RelationCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (_) { return RelationCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( builder: (context, state) { if (state.rows.isEmpty) { return const SizedBox.shrink(); } final children = state.rows.map( (row) { final isEmpty = row.name.isEmpty; return Text( isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, style: widget.style.textStyle.copyWith( color: isEmpty ? Theme.of(context).hintColor : null, decoration: TextDecoration.underline, ), overflow: TextOverflow.ellipsis, ); }, ).toList(); return Container( alignment: AlignmentDirectional.topStart, padding: widget.style.padding, child: widget.style.wrap ? Wrap(spacing: 4, runSpacing: 4, children: children) : SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, children: children, ), ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/select_option_card_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'card_cell.dart'; class SelectOptionCardCellStyle extends CardCellStyle { SelectOptionCardCellStyle({ required super.padding, required this.tagFontSize, required this.wrap, required this.tagPadding, }); final double tagFontSize; final bool wrap; final EdgeInsets tagPadding; } class SelectOptionCardCell extends CardCell { const SelectOptionCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _SelectOptionCellState(); } class _SelectOptionCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (_) { return SelectOptionCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( buildWhen: (previous, current) { return previous.selectedOptions != current.selectedOptions; }, builder: (context, state) { if (state.selectedOptions.isEmpty) { return const SizedBox.shrink(); } final children = state.selectedOptions .map( (option) => SelectOptionTag( option: option, fontSize: widget.style.tagFontSize, padding: widget.style.tagPadding, ), ) .toList(); return Container( alignment: AlignmentDirectional.topStart, padding: widget.style.padding, child: widget.style.wrap ? Wrap(spacing: 4, runSpacing: 4, children: children) : SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, children: children, ), ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'card_cell.dart'; class SummaryCardCellStyle extends CardCellStyle { const SummaryCardCellStyle({ required super.padding, required this.textStyle, }); final TextStyle textStyle; } class SummaryCardCell extends CardCell { const SummaryCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _SummaryCellState(); } class _SummaryCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) { return SummaryCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( buildWhen: (previous, current) => previous.content != current.content, builder: (context, state) { if (state.content.isEmpty) { return const SizedBox.shrink(); } return Container( alignment: AlignmentDirectional.centerStart, padding: widget.style.padding, child: Text(state.content, style: widget.style.textStyle), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_builder.dart'; import 'card_cell.dart'; class TextCardCellStyle extends CardCellStyle { TextCardCellStyle({ required super.padding, required this.textStyle, required this.titleTextStyle, this.maxLines = 1, }); final TextStyle textStyle; final TextStyle titleTextStyle; final int? maxLines; } class TextCardCell extends CardCell with EditableCell { const TextCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, this.showNotes = false, this.editableNotifier, }); final DatabaseController databaseController; final CellContext cellContext; final bool showNotes; @override final EditableCardNotifier? editableNotifier; @override State createState() => _TextCellState(); } class _TextCellState extends State { late final cellBloc = TextCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); late final TextEditingController _textEditingController; final focusNode = SingleListenerFocusNode(); @override void initState() { super.initState(); _textEditingController = TextEditingController(text: cellBloc.state.content); if (widget.editableNotifier?.isCellEditing.value ?? false) { WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); cellBloc.add(const TextCellEvent.enableEdit(true)); }); } // If the focusNode lost its focus, the widget's editableNotifier will // set to false, which will cause the [EditableRowNotifier] to receive // end edit event. focusNode.addListener(_onFocusChanged); _bindEditableNotifier(); } void _onFocusChanged() { if (!focusNode.hasFocus) { widget.editableNotifier?.isCellEditing.value = false; cellBloc.add(const TextCellEvent.enableEdit(false)); cellBloc.add(TextCellEvent.updateText(_textEditingController.text)); } } void _bindEditableNotifier() { widget.editableNotifier?.isCellEditing.addListener(() { if (!mounted) { return; } final isEditing = widget.editableNotifier?.isCellEditing.value ?? false; if (isEditing) { WidgetsBinding.instance .addPostFrameCallback((_) => focusNode.requestFocus()); } cellBloc.add(TextCellEvent.enableEdit(isEditing)); }); } @override void didUpdateWidget(covariant oldWidget) { if (oldWidget.editableNotifier != widget.editableNotifier) { _bindEditableNotifier(); } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { final isTitle = cellBloc.cellController.fieldInfo.isPrimary; return BlocProvider.value( value: cellBloc, child: BlocListener( listenWhen: (previous, current) => previous.content != current.content, listener: (context, state) { _textEditingController.text = state.content ?? ""; }, child: isTitle ? _buildTitle() : _buildText(), ), ); } @override void dispose() { _textEditingController.dispose(); widget.editableNotifier?.isCellEditing .removeListener(_bindEditableNotifier); focusNode.dispose(); cellBloc.close(); super.dispose(); } Widget? _buildIcon(TextCellState state) { if (state.emoji?.value.isNotEmpty ?? false) { return FlowyText.emoji( optimizeEmojiAlign: true, state.emoji?.value ?? '', ); } if (widget.showNotes) { return FlowyTooltip( message: LocaleKeys.board_notesTooltip.tr(), child: Padding( padding: const EdgeInsets.all(1.0), child: FlowySvg( FlowySvgs.notes_s, color: Theme.of(context).hintColor, ), ), ); } return null; } Widget _buildText() { return BlocBuilder( builder: (context, state) { final content = state.content ?? ""; return content.isEmpty ? const SizedBox.shrink() : Container( padding: widget.style.padding, alignment: AlignmentDirectional.centerStart, child: Text( content, style: widget.style.textStyle, maxLines: widget.style.maxLines, ), ); }, ); } Widget _buildTitle() { final textField = _buildTextField(); return BlocBuilder( builder: (context, state) { final icon = _buildIcon(state); if (icon == null) { return textField; } final resolved = widget.style.padding.resolve(Directionality.of(context)); final padding = EdgeInsetsDirectional.only( start: resolved.left, top: resolved.top, bottom: resolved.bottom, ); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: padding, child: icon, ), Expanded(child: textField), ], ); }, ); } Widget _buildTextField() { return BlocSelector( selector: (state) => state.enableEdit, builder: (context, isEditing) { return IgnorePointer( ignoring: !isEditing, child: CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): () => focusNode.unfocus(), const SimpleActivator(LogicalKeyboardKey.enter): () => focusNode.unfocus(), }, child: TextField( controller: _textEditingController, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), maxLines: null, minLines: 1, textInputAction: TextInputAction.done, readOnly: !isEditing, enableInteractiveSelection: isEditing, style: widget.style.titleTextStyle, decoration: InputDecoration( contentPadding: widget.style.padding, border: InputBorder.none, enabledBorder: InputBorder.none, isDense: true, isCollapsed: true, hintText: LocaleKeys.grid_row_titlePlaceholder.tr(), hintStyle: widget.style.titleTextStyle.copyWith( color: Theme.of(context).hintColor, ), ), onTapOutside: (_) {}, ), ), ); }, ); } } class SimpleActivator with Diagnosticable implements ShortcutActivator { const SimpleActivator( this.trigger, { this.includeRepeats = true, }); final LogicalKeyboardKey trigger; final bool includeRepeats; @override bool accepts(KeyEvent event, HardwareKeyboard state) { return (event is KeyDownEvent || (includeRepeats && event is KeyRepeatEvent)) && trigger == event.logicalKey; } @override String debugDescribeKeys() => kDebugMode ? trigger.debugName ?? trigger.toStringShort() : ''; @override Iterable? get triggers => [trigger]; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/time_card_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; import 'card_cell.dart'; class TimeCardCellStyle extends CardCellStyle { const TimeCardCellStyle({ required super.padding, required this.textStyle, }); final TextStyle textStyle; } class TimeCardCell extends CardCell { const TimeCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _TimeCellState(); } class _TimeCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) { return TimeCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( buildWhen: (previous, current) => previous.content != current.content, builder: (context, state) { if (state.content.isEmpty) { return const SizedBox.shrink(); } return Container( alignment: AlignmentDirectional.centerStart, padding: widget.style.padding, child: Text(state.content, style: widget.style.textStyle), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'card_cell.dart'; class TimestampCardCellStyle extends CardCellStyle { TimestampCardCellStyle({ required super.padding, required this.textStyle, }); final TextStyle textStyle; } class TimestampCardCell extends CardCell { const TimestampCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _TimestampCellState(); } class _TimestampCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (_) { return TimestampCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( buildWhen: (previous, current) => previous.dateStr != current.dateStr, builder: (context, state) { if (state.dateStr.isEmpty) { return const SizedBox.shrink(); } return Container( alignment: AlignmentDirectional.centerStart, padding: widget.style.padding, child: Text( state.dateStr, style: widget.style.textStyle, ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'card_cell.dart'; class TranslateCardCellStyle extends CardCellStyle { const TranslateCardCellStyle({ required super.padding, required this.textStyle, }); final TextStyle textStyle; } class TranslateCardCell extends CardCell { const TranslateCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _TranslateCellState(); } class _TranslateCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) { return TranslateCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( buildWhen: (previous, current) => previous.content != current.content, builder: (context, state) { if (state.content.isEmpty) { return const SizedBox.shrink(); } return Container( alignment: AlignmentDirectional.centerStart, padding: widget.style.padding, child: Text(state.content, style: widget.style.textStyle), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/url_card_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'card_cell.dart'; class URLCardCellStyle extends CardCellStyle { URLCardCellStyle({ required super.padding, required this.textStyle, }); final TextStyle textStyle; } class URLCardCell extends CardCell { const URLCardCell({ super.key, required super.style, required this.databaseController, required this.cellContext, }); final DatabaseController databaseController; final CellContext cellContext; @override State createState() => _URLCellState(); } class _URLCellState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) { return URLCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); }, child: BlocBuilder( buildWhen: (previous, current) => previous.content != current.content, builder: (context, state) { if (state.content.isEmpty) { return const SizedBox.shrink(); } return Container( alignment: AlignmentDirectional.centerStart, padding: widget.style.padding, child: Text( state.content, style: widget.style.textStyle, ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../card_cell_builder.dart'; import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; import '../card_cell_skeleton/media_card_cell.dart'; import '../card_cell_skeleton/number_card_cell.dart'; import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; import '../card_cell_skeleton/summary_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; import '../card_cell_skeleton/translate_card_cell.dart'; import '../card_cell_skeleton/url_card_cell.dart'; CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) { const EdgeInsetsGeometry padding = EdgeInsets.symmetric(vertical: 2); final TextStyle textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: 10, overflow: TextOverflow.ellipsis, fontWeight: FontWeight.w400, ); return { FieldType.Checkbox: CheckboxCardCellStyle( padding: padding, iconSize: const Size.square(16), showFieldName: true, textStyle: textStyle, ), FieldType.Checklist: ChecklistCardCellStyle( padding: padding, textStyle: textStyle.copyWith(color: Theme.of(context).hintColor), ), FieldType.CreatedTime: TimestampCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.DateTime: DateCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.LastEditedTime: TimestampCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.MultiSelect: SelectOptionCardCellStyle( padding: padding, tagFontSize: 9, wrap: true, tagPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), ), FieldType.Number: NumberCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.RichText: TextCardCellStyle( padding: padding, textStyle: textStyle, titleTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: 11, overflow: TextOverflow.ellipsis, ), ), FieldType.SingleSelect: SelectOptionCardCellStyle( padding: padding, tagFontSize: 9, wrap: true, tagPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), ), FieldType.URL: URLCardCellStyle( padding: padding, textStyle: textStyle.copyWith( color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), ), FieldType.Relation: RelationCardCellStyle( padding: padding, wrap: true, textStyle: textStyle, ), FieldType.Summary: SummaryCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.Translate: TranslateCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.Media: MediaCardCellStyle( padding: padding, textStyle: textStyle, ), }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../card_cell_builder.dart'; import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; import '../card_cell_skeleton/media_card_cell.dart'; import '../card_cell_skeleton/number_card_cell.dart'; import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; import '../card_cell_skeleton/summary_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; import '../card_cell_skeleton/time_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; import '../card_cell_skeleton/translate_card_cell.dart'; import '../card_cell_skeleton/url_card_cell.dart'; CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { const EdgeInsetsGeometry padding = EdgeInsets.all(4); final TextStyle textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: 11, overflow: TextOverflow.ellipsis, ); return { FieldType.Checkbox: CheckboxCardCellStyle( padding: padding, iconSize: const Size.square(16), showFieldName: true, textStyle: textStyle, ), FieldType.Checklist: ChecklistCardCellStyle( padding: padding, textStyle: textStyle.copyWith(color: Theme.of(context).hintColor), ), FieldType.CreatedTime: TimestampCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.DateTime: DateCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.LastEditedTime: TimestampCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.MultiSelect: SelectOptionCardCellStyle( padding: padding, tagFontSize: 11, wrap: true, tagPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), ), FieldType.Number: NumberCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.RichText: TextCardCellStyle( padding: padding, textStyle: textStyle, maxLines: 2, titleTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( overflow: TextOverflow.ellipsis, ), ), FieldType.SingleSelect: SelectOptionCardCellStyle( padding: padding, tagFontSize: 11, wrap: true, tagPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), ), FieldType.URL: URLCardCellStyle( padding: padding, textStyle: textStyle.copyWith( color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), ), FieldType.Relation: RelationCardCellStyle( padding: padding, wrap: true, textStyle: textStyle, ), FieldType.Summary: SummaryCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.Time: TimeCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.Translate: TranslateCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.Media: MediaCardCellStyle( padding: padding, textStyle: textStyle, ), }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../card_cell_builder.dart'; import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; import '../card_cell_skeleton/number_card_cell.dart'; import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; import '../card_cell_skeleton/time_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; import '../card_cell_skeleton/url_card_cell.dart'; CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) { const EdgeInsetsGeometry padding = EdgeInsets.all(4); final TextStyle textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: 14, overflow: TextOverflow.ellipsis, fontWeight: FontWeight.w400, ); return { FieldType.Checkbox: CheckboxCardCellStyle( padding: padding, iconSize: const Size.square(24), showFieldName: true, textStyle: textStyle, ), FieldType.Checklist: ChecklistCardCellStyle( padding: padding, textStyle: textStyle.copyWith(color: Theme.of(context).hintColor), ), FieldType.CreatedTime: TimestampCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.DateTime: DateCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.LastEditedTime: TimestampCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.MultiSelect: SelectOptionCardCellStyle( padding: padding, tagFontSize: 12, wrap: true, tagPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), ), FieldType.Number: NumberCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.RichText: TextCardCellStyle( padding: padding, textStyle: textStyle, titleTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( overflow: TextOverflow.ellipsis, ), ), FieldType.SingleSelect: SelectOptionCardCellStyle( padding: padding, tagFontSize: 12, wrap: true, tagPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), ), FieldType.URL: URLCardCellStyle( padding: padding, textStyle: textStyle.copyWith( color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), ), FieldType.Relation: RelationCardCellStyle( padding: padding, textStyle: textStyle, wrap: true, ), FieldType.Summary: SummaryCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.Time: TimeCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.Translate: TranslateCardCellStyle( padding: padding, textStyle: textStyle, ), FieldType.Media: MediaCardCellStyle( padding: padding, textStyle: textStyle, ), }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checkbox_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/checkbox.dart'; class DesktopGridCheckboxCellSkin extends IEditableCheckboxCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { return ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return Container( alignment: AlignmentDirectional.centerStart, padding: padding, child: FlowyIconButton( hoverColor: Colors.transparent, onPressed: () => bloc.add(const CheckboxCellEvent.select()), icon: FlowySvg( state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, blendMode: BlendMode.dst, size: const Size.square(20), ), width: 20, ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/checklist.dart'; class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ) { return AppFlowyPopover( margin: EdgeInsets.zero, controller: popoverController, constraints: BoxConstraints.loose(const Size(360, 400)), direction: PopoverDirection.bottomWithLeftAligned, triggerActions: PopoverTriggerFlags.none, skipTraversal: true, popupBuilder: (popoverContext) { WidgetsBinding.instance.addPostFrameCallback((_) { cellContainerNotifier.isFocus = true; }); return BlocProvider.value( value: bloc, child: ChecklistCellEditor( cellController: bloc.cellController, ), ); }, onClose: () => cellContainerNotifier.isFocus = false, child: BlocBuilder( builder: (context, state) { return ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return Container( alignment: AlignmentDirectional.centerStart, padding: padding, child: state.tasks.isEmpty ? const SizedBox.shrink() : ChecklistProgressBar( tasks: state.tasks, percent: state.percent, ), ); }, ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_date_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/date_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; import '../editable_cell_skeleton/date.dart'; class DesktopGridDateCellSkin extends IEditableDateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, ) { return AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(260, 620)), margin: EdgeInsets.zero, child: Align( alignment: AlignmentDirectional.centerStart, child: state.fieldInfo.wrapCellContent ?? false ? _buildCellContent(state, compactModeNotifier) : SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, child: _buildCellContent(state, compactModeNotifier), ), ), popupBuilder: (BuildContext popoverContent) { return DateCellEditor( cellController: bloc.cellController, onDismissed: () => cellContainerNotifier.isFocus = false, ); }, onClose: () { cellContainerNotifier.isFocus = false; }, ); } Widget _buildCellContent( DateCellState state, ValueNotifier compactModeNotifier, ) { final wrap = state.fieldInfo.wrapCellContent ?? false; final dateStr = getDateCellStrFromCellData( state.fieldInfo, state.cellData, ); return ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return Padding( padding: padding, child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: FlowyText( dateStr, overflow: wrap ? null : TextOverflow.ellipsis, maxLines: wrap ? null : 1, ), ), if (state.cellData.reminderId.isNotEmpty) ...[ const HSpace(4), FlowyTooltip( message: LocaleKeys.grid_field_reminderOnDateTooltip.tr(), child: const FlowySvg(FlowySvgs.clock_alarm_s), ), ], ], ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/af_image.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; class GridMediaCellSkin extends IEditableMediaCellSkin { const GridMediaCellSkin({this.isMobileRowDetail = false}); final bool isMobileRowDetail; @override void dispose() {} @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, PopoverController popoverController, MediaCellBloc bloc, ) { final isMobile = UniversalPlatform.isMobile; Widget child = BlocBuilder( builder: (context, state) { final wrapContent = context.read().wrapContent; final List children = state.files .map( (file) => GestureDetector( onTap: () => _openOrExpandFile(context, file, state.files), child: Padding( padding: wrapContent ? const EdgeInsets.only(right: 4) : EdgeInsets.zero, child: _FilePreviewRender(file: file), ), ), ) .toList(); if (isMobileRowDetail && state.files.isEmpty) { children.add( Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text( LocaleKeys.grid_row_textPlaceholder.tr(), style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 16, color: Theme.of(context).hintColor, ), ), ), ); } if (!isMobile && wrapContent) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: SizedBox( width: double.infinity, child: Wrap( runSpacing: 4, children: children, ), ), ); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: SizedBox( width: double.infinity, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: SeparatedRow( separatorBuilder: () => const HSpace(4), children: children..add(const SizedBox(width: 16)), ), ), ), ); }, ); if (!isMobile) { child = AppFlowyPopover( controller: popoverController, constraints: const BoxConstraints( minWidth: 250, maxWidth: 250, maxHeight: 400, ), margin: EdgeInsets.zero, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithCenterAligned, popupBuilder: (_) => BlocProvider.value( value: context.read(), child: const MediaCellEditor(), ), onClose: () => cellContainerNotifier.isFocus = false, child: child, ); } else { child = Align( alignment: AlignmentDirectional.centerStart, child: child, ); if (isMobileRowDetail) { child = Container( decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), alignment: AlignmentDirectional.centerStart, child: child, ); } child = InkWell( borderRadius: isMobileRowDetail ? BorderRadius.circular(12) : BorderRadius.zero, onTap: () => _tapCellMobile(context), hoverColor: Colors.transparent, child: child, ); } return BlocProvider.value( value: bloc, child: Builder(builder: (context) => child), ); } void _openOrExpandFile( BuildContext context, MediaFilePB file, List files, ) { if (file.fileType != MediaFileTypePB.Image) { afLaunchUrlString(file.url, context: context); return; } final images = files.where((f) => f.fileType == MediaFileTypePB.Image).toList(); final index = images.indexOf(file); showDialog( context: context, builder: (_) => InteractiveImageViewer( userProfile: context.read().state.userProfile, imageProvider: AFBlockImageProvider( initialIndex: index, images: images .map( (e) => ImageBlockData( url: e.url, type: e.uploadType.toCustomImageType(), ), ) .toList(), onDeleteImage: (index) { final deleteFile = images[index]; context.read().deleteFile(deleteFile.id); }, ), ), ); } void _tapCellMobile(BuildContext context) { final files = context.read().state.files; if (files.isEmpty) { showMobileBottomSheet( context, title: LocaleKeys.grid_media_addFileMobile.tr(), showHeader: true, showCloseButton: true, showDragHandle: true, builder: (dContext) => BlocProvider.value( value: context.read(), child: MobileMediaUploadSheetContent( dialogContext: dContext, ), ), ); return; } showMobileBottomSheet( context, builder: (_) => BlocProvider.value( value: context.read(), child: const MobileMediaCellEditor(), ), ); } } class _FilePreviewRender extends StatelessWidget { const _FilePreviewRender({required this.file}); final MediaFilePB file; @override Widget build(BuildContext context) { if (file.fileType != MediaFileTypePB.Image) { return FlowyTooltip( message: file.name, child: Container( height: 28, width: 28, clipBehavior: Clip.antiAlias, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: AFThemeExtension.of(context).greyHover, borderRadius: BorderRadius.circular(4), ), child: FlowySvg( file.fileType.icon, size: const Size.square(12), color: AFThemeExtension.of(context).textColor, ), ), ); } return FlowyTooltip( message: file.name, child: Container( height: 28, width: 28, clipBehavior: Clip.antiAlias, decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)), child: AFImage( url: file.url, uploadType: file.uploadType, userProfile: context.read().state.userProfile, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_number_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/number.dart'; class DesktopGridNumberCellSkin extends IEditableNumberCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return TextField( controller: textEditingController, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), maxLines: context.watch().state.wrap ? null : 1, style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, decoration: InputDecoration( contentPadding: padding, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/relation.dart'; class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, ) { final userWorkspaceBloc = context.read(); return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400), margin: EdgeInsets.zero, onClose: () => cellContainerNotifier.isFocus = false, popupBuilder: (context) { return MultiBlocProvider( providers: [ BlocProvider.value(value: userWorkspaceBloc), BlocProvider.value(value: bloc), ], child: const RelationCellEditor(), ); }, child: Align( alignment: AlignmentDirectional.centerStart, child: ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { return state.wrap ? _buildWrapRows(context, state.rows, compactMode) : _buildNoWrapRows(context, state.rows, compactMode); }, ), ), ); } Widget _buildWrapRows( BuildContext context, List rows, bool compactMode, ) { return Padding( padding: compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets, child: Wrap( runSpacing: 4, spacing: 4.0, children: rows.map( (row) { final isEmpty = row.name.isEmpty; return FlowyText( isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, color: isEmpty ? Theme.of(context).hintColor : null, decoration: TextDecoration.underline, overflow: TextOverflow.ellipsis, ); }, ).toList(), ), ); } Widget _buildNoWrapRows( BuildContext context, List rows, bool compactMode, ) { return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, child: Padding( padding: GridSize.cellContentInsets, child: SeparatedRow( separatorBuilder: () => const HSpace(4.0), mainAxisSize: MainAxisSize.min, children: rows.map( (row) { final isEmpty = row.name.isEmpty; return FlowyText( isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, color: isEmpty ? Theme.of(context).hintColor : null, decoration: TextDecoration.underline, overflow: TextOverflow.ellipsis, ); }, ).toList(), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/select_option.dart'; class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { return AppFlowyPopover( controller: popoverController, constraints: const BoxConstraints.tightFor(width: 300), margin: EdgeInsets.zero, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, popupBuilder: (BuildContext popoverContext) { return SelectOptionCellEditor( cellController: bloc.cellController, ); }, onClose: () => cellContainerNotifier.isFocus = false, child: BlocBuilder( builder: (context, state) { return Align( alignment: AlignmentDirectional.centerStart, child: state.wrap ? _buildWrapOptions( context, state.selectedOptions, compactModeNotifier, ) : _buildNoWrapOptions( context, state.selectedOptions, compactModeNotifier, ), ); }, ), ); } Widget _buildWrapOptions( BuildContext context, List options, ValueNotifier compactModeNotifier, ) { return ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return Padding( padding: padding, child: Wrap( runSpacing: 4, children: options.map( (option) { return Padding( padding: const EdgeInsets.only(right: 4), child: SelectOptionTag( option: option, padding: EdgeInsets.symmetric( vertical: compactMode ? 2 : 4, horizontal: 8, ), ), ); }, ).toList(), ), ); }, ); } Widget _buildNoWrapOptions( BuildContext context, List options, ValueNotifier compactModeNotifier, ) { return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, child: ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return Padding( padding: padding, child: Row( mainAxisSize: MainAxisSize.min, children: options.map( (option) { return Padding( padding: const EdgeInsets.only(right: 4), child: SelectOptionTag( option: option, padding: const EdgeInsets.symmetric( vertical: 1, horizontal: 8, ), ), ); }, ).toList(), ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; class DesktopGridSummaryCellSkin extends IEditableSummaryCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return ChangeNotifierProvider( create: (_) => SummaryMouseNotifier(), builder: (context, child) { return MouseRegion( cursor: SystemMouseCursors.click, opaque: false, onEnter: (p) => Provider.of(context, listen: false) .onEnter = true, onExit: (p) => Provider.of(context, listen: false) .onEnter = false, child: ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return ConstrainedBox( constraints: BoxConstraints( minHeight: compactMode ? GridSize.headerHeight - 4 : GridSize.headerHeight, ), child: Stack( fit: StackFit.expand, children: [ Center( child: TextField( controller: textEditingController, enabled: false, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), maxLines: null, style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, decoration: InputDecoration( contentPadding: padding, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ), ), Padding( padding: EdgeInsets.symmetric( horizontal: GridSize.cellVPadding, ), child: Consumer( builder: ( BuildContext context, SummaryMouseNotifier notifier, Widget? child, ) { if (notifier.onEnter) { return SummaryCellAccessory( viewId: bloc.cellController.viewId, fieldId: bloc.cellController.fieldId, rowId: bloc.cellController.rowId, ); } else { return const SizedBox.shrink(); } }, ), ).positioned(right: 0, bottom: compactMode ? 4 : 8), ], ), ); }, ), ); }, ); } } class SummaryMouseNotifier extends ChangeNotifier { SummaryMouseNotifier(); bool _onEnter = false; set onEnter(bool value) { if (_onEnter != value) { _onEnter = value; notifyListeners(); } } bool get onEnter => _onEnter; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_text_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/text.dart'; class DesktopGridTextCellSkin extends IEditableTextCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, data) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return Padding( padding: padding, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const _IconOrEmoji(), Expanded( child: TextField( controller: textEditingController, focusNode: focusNode, maxLines: context.watch().state.wrap ? null : 1, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: context .read() .cellController .fieldInfo .isPrimary ? FontWeight.w500 : null, ), decoration: const InputDecoration( border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, isCollapsed: true, ), ), ), ], ), ); }, ); } } class _IconOrEmoji extends StatelessWidget { const _IconOrEmoji(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { // if not a title cell, return empty widget if (state.emoji == null || state.hasDocument == null) { return const SizedBox.shrink(); } return ValueListenableBuilder( valueListenable: state.emoji!, builder: (context, emoji, _) { return emoji.isNotEmpty ? Padding( padding: const EdgeInsetsDirectional.only(end: 6.0), child: FlowyText.emoji( optimizeEmojiAlign: true, emoji, ), ) : ValueListenableBuilder( valueListenable: state.hasDocument!, builder: (context, hasDocument, _) { return hasDocument ? Padding( padding: const EdgeInsetsDirectional.only(end: 6.0) .add(const EdgeInsets.all(1)), child: FlowySvg( FlowySvgs.notes_s, color: Theme.of(context).hintColor, ), ) : const SizedBox.shrink(); }, ); }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_time_cell.dart ================================================ import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/time.dart'; class DesktopGridTimeCellSkin extends IEditableTimeCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, TimeCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), maxLines: context.watch().state.wrap ? null : 1, style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, decoration: InputDecoration( contentPadding: GridSize.cellContentInsets, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_timestamp_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; import '../editable_cell_skeleton/timestamp.dart'; class DesktopGridTimestampCellSkin extends IEditableTimestampCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { return Container( alignment: AlignmentDirectional.centerStart, child: state.wrap ? _buildCellContent(state, compactModeNotifier) : SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, child: _buildCellContent(state, compactModeNotifier), ), ); } Widget _buildCellContent( TimestampCellState state, ValueNotifier compactModeNotifier, ) { return ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return Padding( padding: padding, child: FlowyText( state.dateStr, overflow: state.wrap ? null : TextOverflow.ellipsis, maxLines: state.wrap ? null : 1, ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; class DesktopGridTranslateCellSkin extends IEditableTranslateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return ChangeNotifierProvider( create: (_) => TranslateMouseNotifier(), builder: (context, child) { return ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return MouseRegion( cursor: SystemMouseCursors.click, opaque: false, onEnter: (p) => Provider.of(context, listen: false) .onEnter = true, onExit: (p) => Provider.of(context, listen: false) .onEnter = false, child: ConstrainedBox( constraints: BoxConstraints( minHeight: compactMode ? GridSize.headerHeight - 4 : GridSize.headerHeight, ), child: Stack( fit: StackFit.expand, children: [ Center( child: TextField( controller: textEditingController, readOnly: true, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), maxLines: null, style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, decoration: InputDecoration( contentPadding: padding, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ), ), Padding( padding: EdgeInsets.symmetric( horizontal: GridSize.cellVPadding, ), child: Consumer( builder: ( BuildContext context, TranslateMouseNotifier notifier, Widget? child, ) { if (notifier.onEnter) { return TranslateCellAccessory( viewId: bloc.cellController.viewId, fieldId: bloc.cellController.fieldId, rowId: bloc.cellController.rowId, ); } else { return const SizedBox.shrink(); } }, ), ).positioned(right: 0, bottom: compactMode ? 4 : 8), ], ), ), ); }, ); }, ); } } class TranslateMouseNotifier extends ChangeNotifier { TranslateMouseNotifier(); bool _onEnter = false; set onEnter(bool value) { if (_onEnter != value) { _onEnter = value; notifyListeners(); } } bool get onEnter => _onEnter; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/url.dart'; class DesktopGridURLSkin extends IEditableURLCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, URLCellDataNotifier cellDataNotifier, ) { return BlocSelector( selector: (state) => state.wrap, builder: (context, wrap) => ValueListenableBuilder( valueListenable: compactModeNotifier, builder: (context, compactMode, _) { final padding = compactMode ? GridSize.compactCellContentInsets : GridSize.cellContentInsets; return TextField( controller: textEditingController, focusNode: focusNode, maxLines: wrap ? null : 1, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), decoration: InputDecoration( contentPadding: padding, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, hintStyle: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: Theme.of(context).hintColor), isDense: true, ), onTapOutside: (_) => focusNode.unfocus(), ); }, ), ); } @override List accessoryBuilder( GridCellAccessoryBuildContext context, URLCellDataNotifier cellDataNotifier, ) { return [ accessoryFromType( GridURLCellAccessoryType.visitURL, cellDataNotifier, ), accessoryFromType( GridURLCellAccessoryType.copyURL, cellDataNotifier, ), ]; } } GridCellAccessoryBuilder accessoryFromType( GridURLCellAccessoryType ty, URLCellDataNotifier cellDataNotifier, ) { switch (ty) { case GridURLCellAccessoryType.visitURL: return VisitURLCellAccessoryBuilder( builder: (Key key) => _VisitURLAccessory( key: key, cellDataNotifier: cellDataNotifier, ), ); case GridURLCellAccessoryType.copyURL: return CopyURLCellAccessoryBuilder( builder: (Key key) => _CopyURLAccessory( key: key, cellDataNotifier: cellDataNotifier, ), ); } } enum GridURLCellAccessoryType { copyURL, visitURL, } typedef CopyURLCellAccessoryBuilder = GridCellAccessoryBuilder>; class _CopyURLAccessory extends StatefulWidget { const _CopyURLAccessory({ super.key, required this.cellDataNotifier, }); final URLCellDataNotifier cellDataNotifier; @override State<_CopyURLAccessory> createState() => _CopyURLAccessoryState(); } class _CopyURLAccessoryState extends State<_CopyURLAccessory> with GridCellAccessoryState { @override Widget build(BuildContext context) { if (widget.cellDataNotifier.value.isNotEmpty) { return FlowyTooltip( message: LocaleKeys.grid_url_copy.tr(), preferBelow: false, child: _URLAccessoryIconContainer( child: FlowySvg( FlowySvgs.copy_s, color: AFThemeExtension.of(context).textColor, ), ), ); } else { return const SizedBox.shrink(); } } @override void onTap() { final content = widget.cellDataNotifier.value; if (content.isEmpty) { return; } Clipboard.setData(ClipboardData(text: content)); showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); } } typedef VisitURLCellAccessoryBuilder = GridCellAccessoryBuilder>; class _VisitURLAccessory extends StatefulWidget { const _VisitURLAccessory({ super.key, required this.cellDataNotifier, }); final URLCellDataNotifier cellDataNotifier; @override State<_VisitURLAccessory> createState() => _VisitURLAccessoryState(); } class _VisitURLAccessoryState extends State<_VisitURLAccessory> with GridCellAccessoryState { @override Widget build(BuildContext context) { if (widget.cellDataNotifier.value.isNotEmpty) { return FlowyTooltip( message: LocaleKeys.grid_url_launch.tr(), preferBelow: false, child: _URLAccessoryIconContainer( child: FlowySvg( FlowySvgs.url_s, color: AFThemeExtension.of(context).textColor, ), ), ); } else { return const SizedBox.shrink(); } } @override bool enable() => widget.cellDataNotifier.value.isNotEmpty; @override void onTap() => openUrlCellLink(widget.cellDataNotifier.value); } class _URLAccessoryIconContainer extends StatelessWidget { const _URLAccessoryIconContainer({required this.child}); final Widget child; @override Widget build(BuildContext context) { return Container( width: 26, height: 26, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).dividerColor), ), borderRadius: Corners.s6Border, ), child: FlowyHover( style: HoverStyle( backgroundColor: AFThemeExtension.of(context).background, hoverColor: AFThemeExtension.of(context).lightGreyHover, ), child: Center( child: child, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checkbox_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/checkbox.dart'; class DesktopRowDetailCheckboxCellSkin extends IEditableCheckboxCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { return Container( alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), child: FlowyIconButton( hoverColor: Colors.transparent, onPressed: () => bloc.add(const CheckboxCellEvent.select()), icon: FlowySvg( state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, blendMode: BlendMode.dst, size: const Size.square(20), ), width: 20, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; import '../editable_cell_skeleton/checklist.dart'; class DesktopRowDetailChecklistCellSkin extends IEditableChecklistCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ) { return ChecklistRowDetailCell( context: context, cellContainerNotifier: cellContainerNotifier, bloc: bloc, popoverController: popoverController, ); } } class ChecklistRowDetailCell extends StatefulWidget { const ChecklistRowDetailCell({ super.key, required this.context, required this.cellContainerNotifier, required this.bloc, required this.popoverController, }); final BuildContext context; final CellContainerNotifier cellContainerNotifier; final ChecklistCellBloc bloc; final PopoverController popoverController; @override State createState() => _ChecklistRowDetailCellState(); } class _ChecklistRowDetailCellState extends State { final phantomTextController = TextEditingController(); @override void dispose() { phantomTextController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Align( alignment: AlignmentDirectional.centerStart, child: Column( mainAxisSize: MainAxisSize.min, children: [ ProgressAndHideCompleteButton( onToggleHideComplete: () => context .read() .add(const ChecklistCellEvent.toggleShowIncompleteOnly()), ), const VSpace(2.0), _ChecklistItems( phantomTextController: phantomTextController, onStartCreatingTaskAfter: (index) { context .read() .add(ChecklistCellEvent.updatePhantomIndex(index + 1)); }, ), ChecklistItemControl( cellNotifer: widget.cellContainerNotifier, onTap: () { final bloc = context.read(); if (bloc.state.phantomIndex == null) { bloc.add( ChecklistCellEvent.updatePhantomIndex( bloc.state.showIncompleteOnly ? bloc.state.tasks .where((task) => !task.isSelected) .length : bloc.state.tasks.length, ), ); } else { bloc.add( ChecklistCellEvent.createNewTask( phantomTextController.text, index: bloc.state.phantomIndex, ), ); } phantomTextController.clear(); }, ), ], ), ); } } @visibleForTesting class ProgressAndHideCompleteButton extends StatelessWidget { const ProgressAndHideCompleteButton({ super.key, required this.onToggleHideComplete, }); final VoidCallback onToggleHideComplete; @override Widget build(BuildContext context) { return BlocBuilder( buildWhen: (previous, current) => previous.showIncompleteOnly != current.showIncompleteOnly, builder: (context, state) { return Padding( padding: const EdgeInsets.only(left: 8.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: BlocBuilder( builder: (context, state) { return ChecklistProgressBar( tasks: state.tasks, percent: state.percent, ); }, ), ), const HSpace(6.0), FlowyIconButton( tooltipText: state.showIncompleteOnly ? LocaleKeys.grid_checklist_showComplete.tr() : LocaleKeys.grid_checklist_hideComplete.tr(), width: 32, iconColorOnHover: Theme.of(context).colorScheme.onSurface, icon: FlowySvg( state.showIncompleteOnly ? FlowySvgs.show_m : FlowySvgs.hide_m, size: const Size.square(16), ), onPressed: onToggleHideComplete, ), ], ), ); }, ); } } class _ChecklistItems extends StatelessWidget { const _ChecklistItems({ required this.phantomTextController, required this.onStartCreatingTaskAfter, }); final TextEditingController phantomTextController; final void Function(int index) onStartCreatingTaskAfter; @override Widget build(BuildContext context) { return Actions( actions: { _CancelCreatingFromPhantomIntent: CallbackAction<_CancelCreatingFromPhantomIntent>( onInvoke: (_CancelCreatingFromPhantomIntent intent) { phantomTextController.clear(); context .read() .add(const ChecklistCellEvent.updatePhantomIndex(null)); return; }, ), }, child: BlocBuilder( builder: (context, state) { final children = _makeChildren(context, state); return ReorderableListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), proxyDecorator: (child, index, _) => Material( color: Colors.transparent, child: MouseRegion( cursor: UniversalPlatform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grabbing, child: IgnorePointer( child: BlocProvider.value( value: context.read(), child: child, ), ), ), ), buildDefaultDragHandles: false, itemCount: children.length, itemBuilder: (_, index) => children[index], onReorder: (from, to) { context .read() .add(ChecklistCellEvent.reorderTask(from, to)); }, ); }, ), ); } List _makeChildren(BuildContext context, ChecklistCellState state) { final children = []; final tasks = [...state.tasks]; if (state.showIncompleteOnly) { tasks.removeWhere((task) => task.isSelected); } children.addAll( tasks.mapIndexed( (index, task) => Padding( key: ValueKey('checklist_row_detail_cell_task_${task.data.id}'), padding: const EdgeInsets.symmetric(vertical: 2.0), child: ChecklistItem( task: task, index: index, onSubmitted: () { onStartCreatingTaskAfter(index); }, ), ), ), ); if (state.phantomIndex != null) { children.insert( state.phantomIndex!, Padding( key: const ValueKey('new_checklist_cell_task'), padding: const EdgeInsets.symmetric(vertical: 2.0), child: PhantomChecklistItem( index: state.phantomIndex!, textController: phantomTextController, ), ), ); } return children; } } class _CancelCreatingFromPhantomIntent extends Intent { const _CancelCreatingFromPhantomIntent(); } class _SubmitPhantomTaskIntent extends Intent { const _SubmitPhantomTaskIntent({ required this.taskDescription, required this.index, }); final String taskDescription; final int index; } @visibleForTesting class PhantomChecklistItem extends StatefulWidget { const PhantomChecklistItem({ super.key, required this.index, required this.textController, }); final int index; final TextEditingController textController; @override State createState() => _PhantomChecklistItemState(); } class _PhantomChecklistItemState extends State { final focusNode = FocusNode(); bool isComposing = false; @override void initState() { super.initState(); widget.textController.addListener(_onTextChanged); focusNode.addListener(_onFocusChanged); WidgetsBinding.instance .addPostFrameCallback((_) => focusNode.requestFocus()); } void _onTextChanged() => setState( () => isComposing = !widget.textController.value.composing.isCollapsed, ); void _onFocusChanged() { if (!focusNode.hasFocus) { widget.textController.clear(); Actions.maybeInvoke( context, const _CancelCreatingFromPhantomIntent(), ); } } @override void dispose() { widget.textController.removeListener(_onTextChanged); focusNode.removeListener(_onFocusChanged); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Actions( actions: { _SubmitPhantomTaskIntent: CallbackAction<_SubmitPhantomTaskIntent>( onInvoke: (_SubmitPhantomTaskIntent intent) { context.read().add( ChecklistCellEvent.createNewTask( intent.taskDescription, index: intent.index, ), ); widget.textController.clear(); return; }, ), }, child: Shortcuts( shortcuts: _buildShortcuts(), child: Container( constraints: const BoxConstraints(minHeight: 32), decoration: BoxDecoration( color: AFThemeExtension.of(context).lightGreyHover, borderRadius: Corners.s6Border, ), child: Center( child: ChecklistCellTextfield( textController: widget.textController, focusNode: focusNode, contentPadding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), ), ), ), ), ); } Map _buildShortcuts() { return isComposing ? const {} : { const SingleActivator(LogicalKeyboardKey.enter): _SubmitPhantomTaskIntent( taskDescription: widget.textController.text, index: widget.index, ), const SingleActivator(LogicalKeyboardKey.escape): const _CancelCreatingFromPhantomIntent(), }; } } @visibleForTesting class ChecklistItemControl extends StatelessWidget { const ChecklistItemControl({ super.key, required this.cellNotifer, required this.onTap, }); final CellContainerNotifier cellNotifer; final VoidCallback onTap; @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: cellNotifer, child: Consumer( builder: (buildContext, notifier, _) => TextFieldTapRegion( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( margin: const EdgeInsets.fromLTRB(8.0, 2.0, 8.0, 0), height: 12, child: AnimatedSwitcher( duration: const Duration(milliseconds: 150), child: notifier.isHover ? FlowyTooltip( message: LocaleKeys.grid_checklist_addNew.tr(), child: Row( children: [ const Flexible(child: Center(child: Divider())), const HSpace(12.0), FilledButton( style: FilledButton.styleFrom( minimumSize: const Size.square(12), maximumSize: const Size.square(12), padding: EdgeInsets.zero, ), onPressed: onTap, child: FlowySvg( FlowySvgs.add_s, color: Theme.of(context).colorScheme.onPrimary, ), ), const HSpace(12.0), const Flexible(child: Center(child: Divider())), ], ), ) : const SizedBox.expand(), ), ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/date_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, ) { final dateStr = getDateCellStrFromCellData( state.fieldInfo, state.cellData, ); final text = dateStr.isEmpty ? LocaleKeys.grid_row_textPlaceholder.tr() : dateStr; final color = dateStr.isEmpty ? Theme.of(context).hintColor : null; return AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(260, 620)), margin: EdgeInsets.zero, asBarrier: true, child: Container( alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: FlowyText( text, color: color, overflow: TextOverflow.ellipsis, ), ), if (state.cellData.reminderId.isNotEmpty) ...[ const HSpace(4), FlowyTooltip( message: LocaleKeys.grid_field_reminderOnDateTooltip.tr(), child: const FlowySvg(FlowySvgs.clock_alarm_s), ), ], ], ), ), popupBuilder: (BuildContext popoverContent) { return DateCellEditor( cellController: bloc.cellController, onDismissed: () => cellContainerNotifier.isFocus = false, ); }, onClose: () { cellContainerNotifier.isFocus = false; }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/af_image.dart'; import 'package:appflowy/util/xfile_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reorderables/reorderables.dart'; const _dropFileKey = 'files_media'; const _itemWidth = 86.4; class DekstopRowDetailMediaCellSkin extends IEditableMediaCellSkin { final mutex = PopoverMutex(); @override void dispose() { mutex.dispose(); } @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, PopoverController popoverController, MediaCellBloc bloc, ) { return BlocProvider.value( value: bloc, child: BlocBuilder( builder: (context, state) => LayoutBuilder( builder: (context, constraints) { if (state.files.isEmpty) { return _AddFileButton( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, mutex: mutex, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 6, ), child: FlowyText( LocaleKeys.grid_row_textPlaceholder.tr(), color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ), ), ); } int itemsToShow = state.showAllFiles ? state.files.length : 0; if (!state.showAllFiles) { // The row width is surrounded by 8px padding on each side final rowWidth = constraints.maxWidth - 16; // Each item needs 94.4 px to render, 86.4px width + 8px runSpacing final itemsPerRow = rowWidth ~/ (_itemWidth + 8); // We show at most 2 rows itemsToShow = itemsPerRow * 2; } final filesToDisplay = state.showAllFiles || itemsToShow >= state.files.length ? state.files : state.files.take(itemsToShow - 1).toList(); final extraCount = state.files.length - itemsToShow; final images = state.files .where((f) => f.fileType == MediaFileTypePB.Image) .toList(); final size = constraints.maxWidth / 2 - 6; return _AddFileButton( controller: popoverController, mutex: mutex, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(8.0), child: ReorderableWrap( needsLongPressDraggable: false, runSpacing: 8, spacing: 8, onReorder: (from, to) => context .read() .add(MediaCellEvent.reorderFiles(from: from, to: to)), footer: extraCount > 0 && !state.showAllFiles ? GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _toggleShowAllFiles(context), child: _FilePreviewRender( key: ValueKey(state.files[itemsToShow - 1].id), file: state.files[itemsToShow - 1], index: 9, images: images, size: size, mutex: mutex, hideFileNames: state.hideFileNames, foregroundText: LocaleKeys.grid_media_extraCount .tr(args: [extraCount.toString()]), ), ) : null, buildDraggableFeedback: (_, __, child) => BlocProvider.value( value: context.read(), child: _FilePreviewFeedback(child: child), ), children: filesToDisplay .mapIndexed( (index, file) => _FilePreviewRender( key: ValueKey(file.id), file: file, index: index, images: images, size: size, mutex: mutex, hideFileNames: state.hideFileNames, ), ) .toList(), ), ), Padding( padding: const EdgeInsets.all(4), child: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.add_thin_s, size: const Size.square(12), color: Theme.of(context).hintColor, ), const HSpace(6), FlowyText.medium( LocaleKeys.grid_media_addFileOrImage.tr(), fontSize: 12, color: Theme.of(context).hintColor, figmaLineHeight: 18, ), ], ), ), ], ), ); }, ), ), ); } void _toggleShowAllFiles(BuildContext context) { context .read() .add(const MediaCellEvent.toggleShowAllFiles()); } } class _FilePreviewFeedback extends StatelessWidget { const _FilePreviewFeedback({required this.child}); final Widget child; @override Widget build(BuildContext context) { return DecoratedBox( position: DecorationPosition.foreground, decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), border: Border.all( width: 2, color: const Color(0xFF00BCF0), ), ), child: DecoratedBox( decoration: BoxDecoration( boxShadow: [ BoxShadow( color: const Color(0xFF1F2329).withValues(alpha: .2), blurRadius: 6, offset: const Offset(0, 3), ), ], ), child: BlocProvider.value( value: context.read(), child: Material( type: MaterialType.transparency, child: child, ), ), ), ); } } const _menuWidth = 350.0; class _AddFileButton extends StatefulWidget { const _AddFileButton({ this.mutex, required this.controller, this.direction = PopoverDirection.bottomWithCenterAligned, required this.child, }); final PopoverController controller; final PopoverMutex? mutex; final PopoverDirection direction; final Widget child; @override State<_AddFileButton> createState() => _AddFileButtonState(); } class _AddFileButtonState extends State<_AddFileButton> { Offset? position; @override Widget build(BuildContext context) { return AppFlowyPopover( triggerActions: PopoverTriggerFlags.none, controller: widget.controller, mutex: widget.mutex, offset: const Offset(0, 10), direction: widget.direction, constraints: const BoxConstraints(maxWidth: _menuWidth), margin: EdgeInsets.zero, asBarrier: true, onClose: () => context.read().remove(_dropFileKey), popupBuilder: (_) { WidgetsBinding.instance.addPostFrameCallback((_) { context.read().add(_dropFileKey); }); return FileUploadMenu( allowMultipleFiles: true, onInsertLocalFile: (files) => insertLocalFiles( context, files, userProfile: context.read().state.userProfile, documentId: context.read().rowId, onUploadSuccess: (file, path, isLocalMode) { final mediaCellBloc = context.read(); if (mediaCellBloc.isClosed) { return; } mediaCellBloc.add( MediaCellEvent.addFile( url: path, name: file.name, uploadType: isLocalMode ? FileUploadTypePB.LocalFile : FileUploadTypePB.CloudFile, fileType: file.fileType.toMediaFileTypePB(), ), ); widget.controller.close(); }, ), onInsertNetworkFile: (url) { if (url.isEmpty) return; final uri = Uri.tryParse(url); if (uri == null) { return; } final fakeFile = XFile(uri.path); MediaFileTypePB fileType = fakeFile.fileType.toMediaFileTypePB(); fileType = fileType == MediaFileTypePB.Other ? MediaFileTypePB.Link : fileType; String name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; if (name.isEmpty && uri.pathSegments.length > 1) { name = uri.pathSegments[uri.pathSegments.length - 2]; } else if (name.isEmpty) { name = uri.host; } context.read().add( MediaCellEvent.addFile( url: url, name: name, uploadType: FileUploadTypePB.NetworkFile, fileType: fileType, ), ); widget.controller.close(); }, ); }, child: MouseRegion( onEnter: (event) => position = event.position, onExit: (_) => position = null, onHover: (event) => position = event.position, child: GestureDetector( onTap: () { if (position != null) { widget.controller.showAt( position! - const Offset(_menuWidth / 2, 0), ); } }, behavior: HitTestBehavior.translucent, child: FlowyHover(resetHoverOnRebuild: false, child: widget.child), ), ), ); } } class _FilePreviewRender extends StatefulWidget { const _FilePreviewRender({ super.key, required this.file, required this.images, required this.index, required this.size, required this.mutex, this.hideFileNames = false, this.foregroundText, }); final MediaFilePB file; final List images; final int index; final double size; final PopoverMutex mutex; final bool hideFileNames; final String? foregroundText; @override State<_FilePreviewRender> createState() => _FilePreviewRenderState(); } class _FilePreviewRenderState extends State<_FilePreviewRender> { final nameController = TextEditingController(); final controller = PopoverController(); bool isHovering = false; bool isSelected = false; late int thisIndex; MediaFilePB get file => widget.file; @override void initState() { super.initState(); thisIndex = widget.images.indexOf(file); } @override void dispose() { nameController.dispose(); controller.close(); super.dispose(); } @override void didUpdateWidget(covariant _FilePreviewRender oldWidget) { thisIndex = widget.images.indexOf(file); super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { Widget child; if (file.fileType == MediaFileTypePB.Image) { child = AFImage( url: file.url, uploadType: file.uploadType, userProfile: context.read().state.userProfile, width: _itemWidth, borderRadius: BorderRadius.only( topLeft: Corners.s5Radius, topRight: Corners.s5Radius, bottomLeft: widget.hideFileNames ? Corners.s5Radius : Radius.zero, bottomRight: widget.hideFileNames ? Corners.s5Radius : Radius.zero, ), ); } else { child = DecoratedBox( decoration: BoxDecoration(color: file.fileType.color), child: Center( child: Padding( padding: const EdgeInsets.all(8), child: FlowySvg( file.fileType.icon, color: const Color(0xFF666D76), ), ), ), ); } if (widget.foregroundText != null) { child = Stack( children: [ Positioned.fill( child: DecoratedBox( position: DecorationPosition.foreground, decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5)), child: child, ), ), Positioned.fill( child: Center( child: FlowyText.semibold( widget.foregroundText!, color: Colors.white, fontSize: 14, ), ), ), ], ); } return MouseRegion( onEnter: (_) => setState(() => isHovering = true), onExit: (_) => setState(() => isHovering = false), child: FlowyTooltip( message: file.name, child: AppFlowyPopover( controller: controller, constraints: const BoxConstraints(maxWidth: 240), offset: const Offset(0, 5), triggerActions: PopoverTriggerFlags.none, onClose: () => setState(() => isSelected = false), asBarrier: true, popupBuilder: (popoverContext) => MultiBlocProvider( providers: [ BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), ], child: _FileMenu( parentContext: context, index: thisIndex, file: file, images: widget.images, controller: controller, nameController: nameController, ), ), child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: widget.foregroundText != null ? null : () { if (file.uploadType == FileUploadTypePB.LocalFile) { afLaunchUrlString(file.url); return; } if (file.fileType != MediaFileTypePB.Image) { afLaunchUrlString(widget.file.url); return; } openInteractiveViewerFromFiles( context, widget.images, userProfile: context.read().state.userProfile, initialIndex: thisIndex, onDeleteImage: (index) { final deleteFile = widget.images[index]; context.read().deleteFile(deleteFile.id); }, ); }, child: Container( width: _itemWidth, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Corners.s6Radius), border: Border.all(color: Theme.of(context).dividerColor), color: Theme.of(context).cardColor, ), child: Stack( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: 68, child: child), if (!widget.hideFileNames) Row( children: [ Expanded( child: Padding( padding: const EdgeInsets.all(4), child: FlowyText( file.name, fontSize: 10, overflow: TextOverflow.ellipsis, figmaLineHeight: 16, ), ), ), ], ), ], ), if (widget.foregroundText == null && (isHovering || isSelected)) Positioned( top: 3, right: 3, child: FlowyIconButton( onPressed: () { setState(() => isSelected = true); controller.show(); }, fillColor: Colors.black.withValues(alpha: 0.4), width: 18, radius: BorderRadius.circular(4), icon: const FlowySvg( FlowySvgs.three_dots_s, color: Colors.white, size: Size.square(16), ), ), ), ], ), ), ), ), ), ); } } class _FileMenu extends StatefulWidget { const _FileMenu({ required this.parentContext, required this.index, required this.file, required this.images, required this.controller, required this.nameController, }); /// Parent [BuildContext] used to retrieve the [MediaCellBloc] final BuildContext parentContext; /// Index of this file in [widget.images] final int index; /// The current [MediaFilePB] being previewed final MediaFilePB file; /// All images in the field, excluding non-image files- final List images; /// The [PopoverController] to close the popover final PopoverController controller; /// The [TextEditingController] for renaming the file final TextEditingController nameController; @override State<_FileMenu> createState() => _FileMenuState(); } class _FileMenuState extends State<_FileMenu> { final errorMessage = ValueNotifier(null); @override void dispose() { errorMessage.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SeparatedColumn( separatorBuilder: () => const VSpace(8), mainAxisSize: MainAxisSize.min, children: [ if (widget.file.fileType == MediaFileTypePB.Image) ...[ MediaMenuItem( onTap: () { widget.controller.close(); _showInteractiveViewer(context); }, icon: FlowySvgs.full_view_s, label: LocaleKeys.grid_media_expand.tr(), ), MediaMenuItem( onTap: () { widget.controller.close(); _setCover(context); }, icon: FlowySvgs.cover_s, label: LocaleKeys.grid_media_setAsCover.tr(), ), ], MediaMenuItem( onTap: () { widget.controller.close(); afLaunchUrlString(widget.file.url); }, icon: FlowySvgs.open_in_browser_s, label: LocaleKeys.grid_media_openInBrowser.tr(), ), MediaMenuItem( onTap: () { widget.controller.close(); widget.nameController.text = widget.file.name; widget.nameController.selection = TextSelection( baseOffset: 0, extentOffset: widget.nameController.text.length, ); _showRenameConfirmDialog(); }, icon: FlowySvgs.rename_s, label: LocaleKeys.grid_media_rename.tr(), ), if (widget.file.uploadType == FileUploadTypePB.CloudFile) ...[ MediaMenuItem( onTap: () async => downloadMediaFile( context, widget.file, userProfile: context.read().state.userProfile, ), icon: FlowySvgs.save_as_s, label: LocaleKeys.button_download.tr(), ), ], MediaMenuItem( onTap: () { widget.controller.close(); showConfirmDeletionDialog( context: context, name: widget.file.name, description: LocaleKeys.grid_media_deleteFileDescription.tr(), onConfirm: () => widget.parentContext .read() .add(MediaCellEvent.removeFile(fileId: widget.file.id)), ); }, icon: FlowySvgs.trash_s, label: LocaleKeys.button_delete.tr(), ), ], ); } void _saveName(BuildContext context) { final newName = widget.nameController.text.trim(); if (newName.isEmpty) { return; } context .read() .add(MediaCellEvent.renameFile(fileId: widget.file.id, name: newName)); Navigator.of(context).pop(); } void _showRenameConfirmDialog() { showCustomConfirmDialog( context: widget.parentContext, title: LocaleKeys.document_plugins_file_renameFile_title.tr(), description: LocaleKeys.document_plugins_file_renameFile_description.tr(), closeOnConfirm: false, builder: (builderContext) => FileRenameTextField( nameController: widget.nameController, errorMessage: errorMessage, onSubmitted: () => _saveName(widget.parentContext), disposeController: false, ), style: ConfirmPopupStyle.cancelAndOk, confirmLabel: LocaleKeys.button_save.tr(), onConfirm: () => _saveName(widget.parentContext), onCancel: Navigator.of(widget.parentContext).pop, ); } void _setCover(BuildContext context) => context.read().add( RowDetailEvent.setCover( RowCoverPB( data: widget.file.url, uploadType: widget.file.uploadType, coverType: CoverTypePB.FileCover, ), ), ); void _showInteractiveViewer(BuildContext context) => showDialog( context: context, builder: (_) => InteractiveImageViewer( userProfile: widget.parentContext.read().state.userProfile, imageProvider: AFBlockImageProvider( initialIndex: widget.index, images: widget.images .map( (e) => ImageBlockData( url: e.url, type: e.uploadType.toCustomImageType(), ), ) .toList(), onDeleteImage: (index) { final deleteFile = widget.images[index]; widget.parentContext .read() .deleteFile(deleteFile.id); }, ), ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_number_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/number.dart'; class DesktopRowDetailNumberCellSkin extends IEditableNumberCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, hintText: LocaleKeys.grid_row_textPlaceholder.tr(), hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, ), isDense: true, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/relation.dart'; class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, ) { final userWorkspaceBloc = context.read(); return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400), margin: EdgeInsets.zero, asBarrier: true, onClose: () => cellContainerNotifier.isFocus = false, popupBuilder: (context) { return MultiBlocProvider( providers: [ BlocProvider.value(value: userWorkspaceBloc), BlocProvider.value(value: bloc), ], child: const RelationCellEditor(), ); }, child: Container( alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: state.rows.isEmpty ? _buildPlaceholder(context) : _buildRows(context, state.rows), ), ); } Widget _buildPlaceholder(BuildContext context) { return FlowyText( LocaleKeys.grid_row_textPlaceholder.tr(), color: Theme.of(context).hintColor, ); } Widget _buildRows(BuildContext context, List rows) { return Wrap( runSpacing: 4.0, spacing: 4.0, children: rows.map( (row) { final isEmpty = row.name.isEmpty; return FlowyText( isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, color: isEmpty ? Theme.of(context).hintColor : null, decoration: TextDecoration.underline, overflow: TextOverflow.ellipsis, ); }, ).toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/select_option.dart'; class DesktopRowDetailSelectOptionCellSkin extends IEditableSelectOptionCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { return AppFlowyPopover( controller: popoverController, constraints: const BoxConstraints.tightFor(width: 300), margin: EdgeInsets.zero, asBarrier: true, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, onClose: () => cellContainerNotifier.isFocus = false, onOpen: () => cellContainerNotifier.isFocus = true, popupBuilder: (_) => SelectOptionCellEditor( cellController: bloc.cellController, ), child: BlocBuilder( builder: (context, state) { return Container( alignment: AlignmentDirectional.centerStart, padding: state.selectedOptions.isEmpty ? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0) : const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), child: state.selectedOptions.isEmpty ? _buildPlaceholder(context) : _buildOptions(context, state.selectedOptions), ); }, ), ); } Widget _buildPlaceholder(BuildContext context) { return FlowyText( LocaleKeys.grid_row_textPlaceholder.tr(), color: Theme.of(context).hintColor, ); } Widget _buildOptions(BuildContext context, List options) { return Wrap( runSpacing: 4, spacing: 4, children: options.map( (option) { return SelectOptionTag( option: option, padding: const EdgeInsets.symmetric( vertical: 4, horizontal: 8, ), ); }, ).toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class DesktopRowDetailSummaryCellSkin extends IEditableSummaryCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return Padding( padding: const EdgeInsets.all(8.0), child: Stack( children: [ TextField( controller: textEditingController, readOnly: true, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, maxLines: null, minLines: 1, decoration: InputDecoration( contentPadding: GridSize.cellContentInsets, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ), ChangeNotifierProvider.value( value: cellContainerNotifier, child: Selector( selector: (_, notifier) => notifier.isHover, builder: (context, isHover, child) { return Visibility( visible: isHover, child: Row( children: [ const Spacer(), SummaryCellAccessory( viewId: bloc.cellController.viewId, fieldId: bloc.cellController.fieldId, rowId: bloc.cellController.rowId, ), ], ), ); }, ), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_text_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/text.dart'; class DesktopRowDetailTextCellSkin extends IEditableTextCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, focusNode: focusNode, maxLines: null, style: Theme.of(context).textTheme.bodyMedium, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, hintText: LocaleKeys.grid_row_textPlaceholder.tr(), hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, ), isDense: true, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_time_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/time.dart'; class DesktopRowDetailTimeCellSkin extends IEditableTimeCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, TimeCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, hintText: LocaleKeys.grid_row_textPlaceholder.tr(), hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, ), isDense: true, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_timestamp_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; import '../editable_cell_skeleton/timestamp.dart'; class DesktopRowDetailTimestampCellSkin extends IEditableTimestampCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { return Container( alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6.0), child: FlowyText( state.dateStr, maxLines: null, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_url_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../editable_cell_skeleton/url.dart'; class DesktopRowDetailURLSkin extends IEditableURLCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, URLCellDataNotifier cellDataNotifier, ) { return LinkTextField( controller: textEditingController, focusNode: focusNode, ); } @override List accessoryBuilder( GridCellAccessoryBuildContext context, URLCellDataNotifier cellDataNotifier, ) { return [ accessoryFromType( GridURLCellAccessoryType.visitURL, cellDataNotifier, ), ]; } } class LinkTextField extends StatefulWidget { const LinkTextField({ super.key, required this.controller, required this.focusNode, }); final TextEditingController controller; final FocusNode focusNode; @override State createState() => _LinkTextFieldState(); } class _LinkTextFieldState extends State { bool isLinkClickable = false; @override void initState() { super.initState(); HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent); } @override void dispose() { HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); super.dispose(); } bool _handleGlobalKeyEvent(KeyEvent event) { final keyboard = HardwareKeyboard.instance; final canOpenLink = event is KeyDownEvent && (keyboard.isControlPressed || keyboard.isMetaPressed); if (canOpenLink != isLinkClickable) { setState(() => isLinkClickable = canOpenLink); } return false; } @override Widget build(BuildContext context) { return TextField( mouseCursor: isLinkClickable ? SystemMouseCursors.click : SystemMouseCursors.text, controller: widget.controller, focusNode: widget.focusNode, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), onTap: () { if (isLinkClickable) { openUrlCellLink(widget.controller.text); } }, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, hintText: LocaleKeys.grid_row_textPlaceholder.tr(), hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, ), isDense: true, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class DesktopRowDetailTranslateCellSkin extends IEditableTranslateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return Padding( padding: const EdgeInsets.all(8.0), child: Stack( children: [ TextField( controller: textEditingController, focusNode: focusNode, readOnly: true, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, maxLines: null, minLines: 1, decoration: InputDecoration( contentPadding: GridSize.cellContentInsets, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ), ChangeNotifierProvider.value( value: cellContainerNotifier, child: Selector( selector: (_, notifier) => notifier.isHover, builder: (context, isHover, child) { return Visibility( visible: isHover, child: Row( children: [ const Spacer(), TranslateCellAccessory( viewId: bloc.cellController.viewId, fieldId: bloc.cellController.fieldId, rowId: bloc.cellController.rowId, ), ], ), ); }, ), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../row/accessory/cell_accessory.dart'; import '../row/accessory/cell_shortcuts.dart'; import '../row/cells/cell_container.dart'; import 'editable_cell_skeleton/checkbox.dart'; import 'editable_cell_skeleton/checklist.dart'; import 'editable_cell_skeleton/date.dart'; import 'editable_cell_skeleton/number.dart'; import 'editable_cell_skeleton/relation.dart'; import 'editable_cell_skeleton/select_option.dart'; import 'editable_cell_skeleton/summary.dart'; import 'editable_cell_skeleton/text.dart'; import 'editable_cell_skeleton/time.dart'; import 'editable_cell_skeleton/timestamp.dart'; import 'editable_cell_skeleton/url.dart'; enum EditableCellStyle { desktopGrid, desktopRowDetail, mobileGrid, mobileRowDetail, } /// Build an editable cell widget class EditableCellBuilder { EditableCellBuilder({required this.databaseController}); final DatabaseController databaseController; EditableCellWidget buildStyled( CellContext cellContext, EditableCellStyle style, ) { final fieldType = databaseController.fieldController .getField(cellContext.fieldId)! .fieldType; final key = ValueKey( "${databaseController.viewId}${cellContext.fieldId}${cellContext.rowId}", ); return switch (fieldType) { FieldType.Checkbox => EditableCheckboxCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableCheckboxCellSkin.fromStyle(style), key: key, ), FieldType.Checklist => EditableChecklistCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableChecklistCellSkin.fromStyle(style), key: key, ), FieldType.CreatedTime => EditableTimestampCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableTimestampCellSkin.fromStyle(style), key: key, fieldType: FieldType.CreatedTime, ), FieldType.DateTime => EditableDateCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableDateCellSkin.fromStyle(style), key: key, ), FieldType.LastEditedTime => EditableTimestampCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableTimestampCellSkin.fromStyle(style), key: key, fieldType: FieldType.LastEditedTime, ), FieldType.MultiSelect => EditableSelectOptionCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableSelectOptionCellSkin.fromStyle(style), key: key, fieldType: FieldType.MultiSelect, ), FieldType.Number => EditableNumberCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableNumberCellSkin.fromStyle(style), key: key, ), FieldType.RichText => EditableTextCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableTextCellSkin.fromStyle(style), key: key, ), FieldType.SingleSelect => EditableSelectOptionCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableSelectOptionCellSkin.fromStyle(style), key: key, fieldType: FieldType.SingleSelect, ), FieldType.URL => EditableURLCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableURLCellSkin.fromStyle(style), key: key, ), FieldType.Relation => EditableRelationCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableRelationCellSkin.fromStyle(style), key: key, ), FieldType.Summary => EditableSummaryCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableSummaryCellSkin.fromStyle(style), key: key, ), FieldType.Time => EditableTimeCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableTimeCellSkin.fromStyle(style), key: key, ), FieldType.Translate => EditableTranslateCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableTranslateCellSkin.fromStyle(style), key: key, ), FieldType.Media => EditableMediaCell( databaseController: databaseController, cellContext: cellContext, skin: IEditableMediaCellSkin.fromStyle(style), style: style, key: key, ), _ => throw UnimplementedError(), }; } EditableCellWidget buildCustom( CellContext cellContext, { required EditableCellSkinMap skinMap, }) { final DatabaseController(:fieldController) = databaseController; final fieldType = fieldController.getField(cellContext.fieldId)!.fieldType; final key = ValueKey( "${databaseController.viewId}${cellContext.fieldId}${cellContext.rowId}", ); assert(skinMap.has(fieldType)); return switch (fieldType) { FieldType.Checkbox => EditableCheckboxCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.checkboxSkin!, key: key, ), FieldType.Checklist => EditableChecklistCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.checklistSkin!, key: key, ), FieldType.CreatedTime => EditableTimestampCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.timestampSkin!, key: key, fieldType: FieldType.CreatedTime, ), FieldType.DateTime => EditableDateCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.dateSkin!, key: key, ), FieldType.LastEditedTime => EditableTimestampCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.timestampSkin!, key: key, fieldType: FieldType.LastEditedTime, ), FieldType.MultiSelect => EditableSelectOptionCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.selectOptionSkin!, key: key, fieldType: FieldType.MultiSelect, ), FieldType.Number => EditableNumberCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.numberSkin!, key: key, ), FieldType.RichText => EditableTextCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.textSkin!, key: key, ), FieldType.SingleSelect => EditableSelectOptionCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.selectOptionSkin!, key: key, fieldType: FieldType.SingleSelect, ), FieldType.URL => EditableURLCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.urlSkin!, key: key, ), FieldType.Relation => EditableRelationCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.relationSkin!, key: key, ), FieldType.Time => EditableTimeCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.timeSkin!, key: key, ), FieldType.Media => EditableMediaCell( databaseController: databaseController, cellContext: cellContext, skin: skinMap.mediaSkin!, style: EditableCellStyle.desktopGrid, key: key, ), _ => throw UnimplementedError(), }; } } abstract class CellEditable { SingleListenerChangeNotifier get requestFocus; CellContainerNotifier get cellContainerNotifier; } typedef AccessoryBuilder = List Function( GridCellAccessoryBuildContext buildContext, ); abstract class CellAccessory extends Widget { const CellAccessory({super.key}); AccessoryBuilder? get accessoryBuilder; } abstract class EditableCellWidget extends StatefulWidget implements CellAccessory, CellEditable, CellShortcuts { EditableCellWidget({super.key}); @override final CellContainerNotifier cellContainerNotifier = CellContainerNotifier(); @override AccessoryBuilder? get accessoryBuilder => null; @override final requestFocus = SingleListenerChangeNotifier(); @override final Map shortcutHandlers = {}; } abstract class GridCellState extends State { @override void initState() { super.initState(); widget.requestFocus.addListener(onRequestFocus); } @override void didUpdateWidget(covariant T oldWidget) { if (oldWidget != this) { oldWidget.requestFocus.removeListener(onRequestFocus); widget.requestFocus.addListener(onRequestFocus); } super.didUpdateWidget(oldWidget); } @override void dispose() { widget.requestFocus.removeListener(onRequestFocus); widget.requestFocus.dispose(); super.dispose(); } /// Subclass can override this method to request focus. void onRequestFocus(); String? onCopy() => null; } abstract class GridEditableTextCell extends GridCellState { SingleListenerFocusNode get focusNode; @override void initState() { super.initState(); widget.shortcutHandlers[CellKeyboardKey.onEnter] = () => focusNode.unfocus(); _listenOnFocusNodeChanged(); } @override void dispose() { widget.shortcutHandlers.clear(); focusNode.removeAllListener(); focusNode.dispose(); super.dispose(); } @override void onRequestFocus() { if (!focusNode.hasFocus && focusNode.canRequestFocus) { FocusScope.of(context).requestFocus(focusNode); } } void _listenOnFocusNodeChanged() { widget.cellContainerNotifier.isFocus = focusNode.hasFocus; focusNode.setListener(() { widget.cellContainerNotifier.isFocus = focusNode.hasFocus; focusChanged(); }); } Future focusChanged() async {} } class SingleListenerChangeNotifier extends ChangeNotifier { VoidCallback? _listener; @override void addListener(VoidCallback listener) { if (_listener != null) { removeListener(_listener!); } _listener = listener; super.addListener(listener); } @override void dispose() { _listener = null; super.dispose(); } void notify() => notifyListeners(); } class SingleListenerFocusNode extends FocusNode { VoidCallback? _listener; void setListener(VoidCallback listener) { if (_listener != null) { removeListener(_listener!); } _listener = listener; super.addListener(listener); } void removeAllListener() { if (_listener != null) { removeListener(_listener!); } } @override void dispose() { removeAllListener(); super.dispose(); } } class EditableCellSkinMap { EditableCellSkinMap({ this.checkboxSkin, this.checklistSkin, this.timestampSkin, this.dateSkin, this.selectOptionSkin, this.numberSkin, this.textSkin, this.urlSkin, this.relationSkin, this.timeSkin, this.mediaSkin, }); final IEditableCheckboxCellSkin? checkboxSkin; final IEditableChecklistCellSkin? checklistSkin; final IEditableTimestampCellSkin? timestampSkin; final IEditableDateCellSkin? dateSkin; final IEditableSelectOptionCellSkin? selectOptionSkin; final IEditableNumberCellSkin? numberSkin; final IEditableTextCellSkin? textSkin; final IEditableURLCellSkin? urlSkin; final IEditableRelationCellSkin? relationSkin; final IEditableTimeCellSkin? timeSkin; final IEditableMediaCellSkin? mediaSkin; bool has(FieldType fieldType) { return switch (fieldType) { FieldType.Checkbox => checkboxSkin != null, FieldType.Checklist => checklistSkin != null, FieldType.CreatedTime || FieldType.LastEditedTime => timestampSkin != null, FieldType.DateTime => dateSkin != null, FieldType.MultiSelect || FieldType.SingleSelect => selectOptionSkin != null, FieldType.Number => numberSkin != null, FieldType.RichText => textSkin != null, FieldType.URL => urlSkin != null, FieldType.Time => timeSkin != null, FieldType.Media => mediaSkin != null, _ => throw UnimplementedError(), }; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_checkbox_cell.dart'; import '../desktop_row_detail/desktop_row_detail_checkbox_cell.dart'; import '../mobile_grid/mobile_grid_checkbox_cell.dart'; import '../mobile_row_detail/mobile_row_detail_checkbox_cell.dart'; abstract class IEditableCheckboxCellSkin { const IEditableCheckboxCellSkin(); factory IEditableCheckboxCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridCheckboxCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailCheckboxCellSkin(), EditableCellStyle.mobileGrid => MobileGridCheckboxCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailCheckboxCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ); } class EditableCheckboxCell extends EditableCellWidget { EditableCheckboxCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableCheckboxCellSkin skin; @override GridCellState createState() => _CheckboxCellState(); } class _CheckboxCellState extends GridCellState { late final cellBloc = CheckboxCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), )..add(const CheckboxCellEvent.initial()); @override void dispose() { cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocBuilder( builder: (context, state) { return widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, state, ); }, ), ); } @override void onRequestFocus() => cellBloc.add(const CheckboxCellEvent.select()); @override String? onCopy() { if (cellBloc.state.isSelected) { return "Yes"; } else { return "No"; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_checklist_cell.dart'; import '../desktop_row_detail/desktop_row_detail_checklist_cell.dart'; import '../mobile_grid/mobile_grid_checklist_cell.dart'; import '../mobile_row_detail/mobile_row_detail_checklist_cell.dart'; abstract class IEditableChecklistCellSkin { const IEditableChecklistCellSkin(); factory IEditableChecklistCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridChecklistCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailChecklistCellSkin(), EditableCellStyle.mobileGrid => MobileGridChecklistCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailChecklistCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ); } class EditableChecklistCell extends EditableCellWidget { EditableChecklistCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableChecklistCellSkin skin; @override GridCellState createState() => GridChecklistCellState(); } class GridChecklistCellState extends GridCellState { final PopoverController _popover = PopoverController(); late final cellBloc = ChecklistCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void dispose() { cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, _popover, ), ); } @override void onRequestFocus() { if (widget.skin is DesktopGridChecklistCellSkin) { _popover.show(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/date.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import '../desktop_grid/desktop_grid_date_cell.dart'; import '../desktop_row_detail/desktop_row_detail_date_cell.dart'; import '../mobile_grid/mobile_grid_date_cell.dart'; import '../mobile_row_detail/mobile_row_detail_date_cell.dart'; abstract class IEditableDateCellSkin { const IEditableDateCellSkin(); factory IEditableDateCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridDateCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailDateCellSkin(), EditableCellStyle.mobileGrid => MobileGridDateCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailDateCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, ); } class EditableDateCell extends EditableCellWidget { EditableDateCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableDateCellSkin skin; @override GridCellState createState() => _DateCellState(); } class _DateCellState extends GridCellState { final PopoverController _popover = PopoverController(); late final cellBloc = DateCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void dispose() { cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocBuilder( builder: (context, state) { return widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, state, _popover, ); }, ), ); } @override void onRequestFocus() { _popover.show(); widget.cellContainerNotifier.isFocus = true; } @override String? onCopy() => getDateCellStrFromCellData( cellBloc.state.fieldInfo, cellBloc.state.cellData, ); } String getDateCellStrFromCellData(FieldInfo field, DateCellData cellData) { if (cellData.dateTime == null) { return ""; } final DateTypeOptionPB(:dateFormat, :timeFormat) = DateTypeOptionDataParser().fromBuffer(field.field.typeOptionData); final format = cellData.includeTime ? DateFormat("${dateFormat.pattern} ${timeFormat.pattern}") : DateFormat(dateFormat.pattern); if (cellData.isRange) { return "${format.format(cellData.dateTime!)} → ${format.format(cellData.endDateTime!)}"; } else { return format.format(cellData.dateTime!); } } extension GetDateFormatExtension on DateFormatPB { String get pattern => switch (this) { DateFormatPB.Local => 'MM/dd/y', DateFormatPB.US => 'y/MM/dd', DateFormatPB.ISO => 'y-MM-dd', DateFormatPB.Friendly => 'MMM dd, y', DateFormatPB.DayMonthYear => 'dd/MM/y', _ => 'MMM dd, y', }; } extension GetTimeFormatExtension on TimeFormatPB { String get pattern => switch (this) { TimeFormatPB.TwelveHour => 'hh:mm a', _ => 'HH:mm', }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../application/cell/cell_controller_builder.dart'; abstract class IEditableMediaCellSkin { const IEditableMediaCellSkin(); factory IEditableMediaCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => const GridMediaCellSkin(), EditableCellStyle.desktopRowDetail => DekstopRowDetailMediaCellSkin(), EditableCellStyle.mobileGrid => const GridMediaCellSkin(), EditableCellStyle.mobileRowDetail => const GridMediaCellSkin(isMobileRowDetail: true), }; } bool autoShowPopover(EditableCellStyle style) => switch (style) { EditableCellStyle.desktopGrid => true, EditableCellStyle.desktopRowDetail => false, EditableCellStyle.mobileGrid => false, EditableCellStyle.mobileRowDetail => false, }; Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, PopoverController popoverController, MediaCellBloc bloc, ); void dispose(); } class EditableMediaCell extends EditableCellWidget { EditableMediaCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, required this.style, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableMediaCellSkin skin; final EditableCellStyle style; @override GridEditableTextCell createState() => _EditableMediaCellState(); } class _EditableMediaCellState extends GridEditableTextCell { final PopoverController popoverController = PopoverController(); late final cellBloc = MediaCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void dispose() { cellBloc.close(); widget.skin.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc..add(const MediaCellEvent.initial()), child: Builder( builder: (context) => widget.skin.build( context, widget.cellContainerNotifier, popoverController, cellBloc, ), ), ); } @override SingleListenerFocusNode focusNode = SingleListenerFocusNode(); @override void onRequestFocus() => widget.skin.autoShowPopover(widget.style) ? popoverController.show() : null; @override String? onCopy() => null; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_number_cell.dart'; import '../desktop_row_detail/desktop_row_detail_number_cell.dart'; import '../mobile_grid/mobile_grid_number_cell.dart'; import '../mobile_row_detail/mobile_row_detail_number_cell.dart'; abstract class IEditableNumberCellSkin { const IEditableNumberCellSkin(); factory IEditableNumberCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridNumberCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailNumberCellSkin(), EditableCellStyle.mobileGrid => MobileGridNumberCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailNumberCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ); } class EditableNumberCell extends EditableCellWidget { EditableNumberCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableNumberCellSkin skin; @override GridEditableTextCell createState() => _NumberCellState(); } class _NumberCellState extends GridEditableTextCell { late final TextEditingController _textEditingController; late final cellBloc = NumberCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void initState() { super.initState(); _textEditingController = TextEditingController(text: cellBloc.state.content); } @override void dispose() { _textEditingController.dispose(); cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocListener( listener: (context, state) { if (!focusNode.hasFocus) { _textEditingController.text = state.content; } }, child: Builder( builder: (context) { return widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, ); }, ), ), ); } @override SingleListenerFocusNode focusNode = SingleListenerFocusNode(); @override void onRequestFocus() { focusNode.requestFocus(); } @override String? onCopy() => cellBloc.state.content; @override Future focusChanged() async { if (mounted && !cellBloc.isClosed && cellBloc.state.content != _textEditingController.text.trim()) { cellBloc .add(NumberCellEvent.updateCell(_textEditingController.text.trim())); } return super.focusChanged(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_relation_cell.dart'; import '../desktop_row_detail/desktop_row_detail_relation_cell.dart'; import '../mobile_grid/mobile_grid_relation_cell.dart'; import '../mobile_row_detail/mobile_row_detail_relation_cell.dart'; abstract class IEditableRelationCellSkin { factory IEditableRelationCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridRelationCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailRelationCellSkin(), EditableCellStyle.mobileGrid => MobileGridRelationCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailRelationCellSkin(), }; } const IEditableRelationCellSkin(); Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, ); } class EditableRelationCell extends EditableCellWidget { EditableRelationCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableRelationCellSkin skin; @override GridCellState createState() => _RelationCellState(); } class _RelationCellState extends GridCellState { final PopoverController _popover = PopoverController(); late final cellBloc = RelationCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void dispose() { cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocBuilder( builder: (context, state) { return widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, state, _popover, ); }, ), ); } @override void onRequestFocus() { _popover.show(); widget.cellContainerNotifier.isFocus = true; } @override String? onCopy() => ""; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_select_option_cell.dart'; import '../desktop_row_detail/desktop_row_detail_select_option_cell.dart'; import '../mobile_grid/mobile_grid_select_option_cell.dart'; import '../mobile_row_detail/mobile_row_detail_select_cell_option.dart'; abstract class IEditableSelectOptionCellSkin { const IEditableSelectOptionCellSkin(); factory IEditableSelectOptionCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridSelectOptionCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailSelectOptionCellSkin(), EditableCellStyle.mobileGrid => MobileGridSelectOptionCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailSelectOptionCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ); } class EditableSelectOptionCell extends EditableCellWidget { EditableSelectOptionCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, required this.fieldType, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableSelectOptionCellSkin skin; final FieldType fieldType; @override GridCellState createState() => _SelectOptionCellState(); } class _SelectOptionCellState extends GridCellState { final PopoverController _popover = PopoverController(); late final cellBloc = SelectOptionCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void dispose() { cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, _popover, ), ); } @override void onRequestFocus() => _popover.show(); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/summary_row_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/error.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; abstract class IEditableSummaryCellSkin { const IEditableSummaryCellSkin(); factory IEditableSummaryCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridSummaryCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailSummaryCellSkin(), EditableCellStyle.mobileGrid => MobileGridSummaryCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailSummaryCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ); } class EditableSummaryCell extends EditableCellWidget { EditableSummaryCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableSummaryCellSkin skin; @override GridEditableTextCell createState() => _SummaryCellState(); } class _SummaryCellState extends GridEditableTextCell { late final TextEditingController _textEditingController; late final cellBloc = SummaryCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void initState() { super.initState(); _textEditingController = TextEditingController(text: cellBloc.state.content); } @override void dispose() { _textEditingController.dispose(); cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocListener( listener: (context, state) { _textEditingController.text = state.content; }, child: Builder( builder: (context) { return widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, ); }, ), ), ); } @override SingleListenerFocusNode focusNode = SingleListenerFocusNode(); @override void onRequestFocus() { focusNode.requestFocus(); } @override String? onCopy() => cellBloc.state.content; @override Future focusChanged() { if (mounted && !cellBloc.isClosed && cellBloc.state.content != _textEditingController.text.trim()) { cellBloc .add(SummaryCellEvent.updateCell(_textEditingController.text.trim())); } return super.focusChanged(); } } class SummaryCellAccessory extends StatelessWidget { const SummaryCellAccessory({ required this.viewId, required this.rowId, required this.fieldId, super.key, }); final String viewId; final String rowId; final String fieldId; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => SummaryRowBloc( viewId: viewId, rowId: rowId, fieldId: fieldId, ), child: BlocConsumer( listenWhen: (previous, current) { return previous.error != current.error; }, listener: (context, state) { if (state.error != null) { if (state.error!.isAIResponseLimitExceeded) { showSnackBarMessage( context, LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), ); } else { showSnackBarMessage(context, state.error!.msg); } } }, builder: (context, state) { return const Row( children: [SummaryButton(), HSpace(6), CopyButton()], ); }, ), ); } } class SummaryButton extends StatelessWidget { const SummaryButton({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return state.loadingState.when( loading: () { return const Center( child: CircularProgressIndicator.adaptive(), ); }, finish: () { return FlowyTooltip( message: LocaleKeys.tooltip_aiGenerate.tr(), child: Container( width: 26, height: 26, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).dividerColor), ), borderRadius: Corners.s6Border, ), child: FlowyIconButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, fillColor: Theme.of(context).cardColor, icon: FlowySvg( FlowySvgs.ai_summary_generate_s, color: Theme.of(context).colorScheme.primary, ), onPressed: () { context .read() .add(const SummaryRowEvent.startSummary()); }, ), ), ); }, ); }, ); } } class CopyButton extends StatelessWidget { const CopyButton({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (blocContext, state) { return FlowyTooltip( message: LocaleKeys.settings_menu_clickToCopy.tr(), child: Container( width: 26, height: 26, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).dividerColor), ), borderRadius: Corners.s6Border, ), child: FlowyIconButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, fillColor: Theme.of(context).cardColor, icon: FlowySvg( FlowySvgs.ai_copy_s, color: Theme.of(context).colorScheme.primary, ), onPressed: () { Clipboard.setData(ClipboardData(text: state.content)); showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); }, ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_text_cell.dart'; import '../desktop_row_detail/desktop_row_detail_text_cell.dart'; import '../mobile_grid/mobile_grid_text_cell.dart'; import '../mobile_row_detail/mobile_row_detail_text_cell.dart'; abstract class IEditableTextCellSkin { const IEditableTextCellSkin(); factory IEditableTextCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridTextCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailTextCellSkin(), EditableCellStyle.mobileGrid => MobileGridTextCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailTextCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ); } class EditableTextCell extends EditableCellWidget { EditableTextCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableTextCellSkin skin; @override GridEditableTextCell createState() => _TextCellState(); } class _TextCellState extends GridEditableTextCell { late final TextEditingController _textEditingController; late final cellBloc = TextCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void initState() { super.initState(); _textEditingController = TextEditingController(text: cellBloc.state.content); } @override void dispose() { _textEditingController.dispose(); cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocListener( listenWhen: (previous, current) => previous.content != current.content, listener: (context, state) { // It's essential to set the new content to the textEditingController. // If you don't, the old value in textEditingController will persist and // overwrite the correct value, leading to inconsistencies between the // displayed text and the actual data. _textEditingController.text = state.content ?? ""; }, child: Builder( builder: (context) { return widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, ); }, ), ), ); } @override SingleListenerFocusNode focusNode = SingleListenerFocusNode(); @override void onRequestFocus() { focusNode.requestFocus(); } @override String? onCopy() => cellBloc.state.content; @override Future focusChanged() { if (mounted && !cellBloc.isClosed && cellBloc.state.content != _textEditingController.text.trim()) { cellBloc .add(TextCellEvent.updateText(_textEditingController.text.trim())); } return super.focusChanged(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/time.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_time_cell.dart'; import '../desktop_row_detail/desktop_row_detail_time_cell.dart'; import '../mobile_grid/mobile_grid_time_cell.dart'; import '../mobile_row_detail/mobile_row_detail_time_cell.dart'; abstract class IEditableTimeCellSkin { const IEditableTimeCellSkin(); factory IEditableTimeCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridTimeCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailTimeCellSkin(), EditableCellStyle.mobileGrid => MobileGridTimeCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailTimeCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, TimeCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ); } class EditableTimeCell extends EditableCellWidget { EditableTimeCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableTimeCellSkin skin; @override GridEditableTextCell createState() => _TimeCellState(); } class _TimeCellState extends GridEditableTextCell { late final TextEditingController _textEditingController; late final cellBloc = TimeCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void initState() { super.initState(); _textEditingController = TextEditingController(text: cellBloc.state.content); } @override void dispose() { _textEditingController.dispose(); cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocListener( listener: (context, state) => _textEditingController.text = state.content, child: Builder( builder: (context) { return widget.skin.build( context, widget.cellContainerNotifier, cellBloc, focusNode, _textEditingController, ); }, ), ), ); } @override SingleListenerFocusNode focusNode = SingleListenerFocusNode(); @override void onRequestFocus() { focusNode.requestFocus(); } @override String? onCopy() => cellBloc.state.content; @override Future focusChanged() async { if (mounted && !cellBloc.isClosed && cellBloc.state.content != _textEditingController.text.trim()) { cellBloc .add(TimeCellEvent.updateCell(_textEditingController.text.trim())); } return super.focusChanged(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../desktop_grid/desktop_grid_timestamp_cell.dart'; import '../desktop_row_detail/desktop_row_detail_timestamp_cell.dart'; import '../mobile_grid/mobile_grid_timestamp_cell.dart'; import '../mobile_row_detail/mobile_row_detail_timestamp_cell.dart'; abstract class IEditableTimestampCellSkin { const IEditableTimestampCellSkin(); factory IEditableTimestampCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridTimestampCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailTimestampCellSkin(), EditableCellStyle.mobileGrid => MobileGridTimestampCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailTimestampCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ); } class EditableTimestampCell extends EditableCellWidget { EditableTimestampCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, required this.fieldType, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableTimestampCellSkin skin; final FieldType fieldType; @override GridCellState createState() => _TimestampCellState(); } class _TimestampCellState extends GridCellState { late final cellBloc = TimestampCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void dispose() { cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocBuilder( builder: (context, state) { return widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, state, ); }, ), ); } @override void onRequestFocus() { widget.cellContainerNotifier.isFocus = true; } @override String? onCopy() => cellBloc.state.dateStr; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/translate_row_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/error.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; abstract class IEditableTranslateCellSkin { const IEditableTranslateCellSkin(); factory IEditableTranslateCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridTranslateCellSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailTranslateCellSkin(), EditableCellStyle.mobileGrid => MobileGridTranslateCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailTranslateCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ); } class EditableTranslateCell extends EditableCellWidget { EditableTranslateCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }); final DatabaseController databaseController; final CellContext cellContext; final IEditableTranslateCellSkin skin; @override GridEditableTextCell createState() => _TranslateCellState(); } class _TranslateCellState extends GridEditableTextCell { late final TextEditingController _textEditingController; late final cellBloc = TranslateCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override void initState() { super.initState(); _textEditingController = TextEditingController(text: cellBloc.state.content); } @override void dispose() { _textEditingController.dispose(); cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocListener( listener: (context, state) { _textEditingController.text = state.content; }, child: Builder( builder: (context) { return widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, ); }, ), ), ); } @override SingleListenerFocusNode focusNode = SingleListenerFocusNode(); @override void onRequestFocus() { focusNode.requestFocus(); } @override String? onCopy() => cellBloc.state.content; @override Future focusChanged() { if (mounted && !cellBloc.isClosed && cellBloc.state.content != _textEditingController.text.trim()) { cellBloc.add( TranslateCellEvent.updateCell(_textEditingController.text.trim()), ); } return super.focusChanged(); } } class TranslateCellAccessory extends StatelessWidget { const TranslateCellAccessory({ required this.viewId, required this.rowId, required this.fieldId, super.key, }); final String viewId; final String rowId; final String fieldId; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => TranslateRowBloc( viewId: viewId, rowId: rowId, fieldId: fieldId, ), child: BlocConsumer( listenWhen: (previous, current) { return previous.error != current.error; }, listener: (context, state) { if (state.error != null) { if (state.error!.isAIResponseLimitExceeded) { showSnackBarMessage( context, LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), ); } else { showSnackBarMessage(context, state.error!.msg); } } }, builder: (context, state) { return const Row( children: [TranslateButton(), HSpace(6), CopyButton()], ); }, ), ); } } class TranslateButton extends StatelessWidget { const TranslateButton({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return state.loadingState.map( loading: (_) { return const Center( child: CircularProgressIndicator.adaptive(), ); }, finish: (_) { return FlowyTooltip( message: LocaleKeys.tooltip_aiGenerate.tr(), child: Container( width: 26, height: 26, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).dividerColor), ), borderRadius: Corners.s6Border, ), child: FlowyIconButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, fillColor: Theme.of(context).cardColor, icon: FlowySvg( FlowySvgs.ai_summary_generate_s, color: Theme.of(context).colorScheme.primary, ), onPressed: () { context .read() .add(const TranslateRowEvent.startTranslate()); }, ), ), ); }, ); }, ); } } class CopyButton extends StatelessWidget { const CopyButton({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (blocContext, state) { return FlowyTooltip( message: LocaleKeys.settings_menu_clickToCopy.tr(), child: Container( width: 26, height: 26, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).dividerColor), ), borderRadius: Corners.s6Border, ), child: FlowyIconButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, fillColor: Theme.of(context).cardColor, icon: FlowySvg( FlowySvgs.ai_copy_s, color: Theme.of(context).colorScheme.primary, ), onPressed: () { Clipboard.setData(ClipboardData(text: state.content)); showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); }, ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import '../desktop_grid/desktop_grid_url_cell.dart'; import '../desktop_row_detail/desktop_row_detail_url_cell.dart'; import '../mobile_grid/mobile_grid_url_cell.dart'; import '../mobile_row_detail/mobile_row_detail_url_cell.dart'; abstract class IEditableURLCellSkin { const IEditableURLCellSkin(); factory IEditableURLCellSkin.fromStyle(EditableCellStyle style) { return switch (style) { EditableCellStyle.desktopGrid => DesktopGridURLSkin(), EditableCellStyle.desktopRowDetail => DesktopRowDetailURLSkin(), EditableCellStyle.mobileGrid => MobileGridURLCellSkin(), EditableCellStyle.mobileRowDetail => MobileRowDetailURLCellSkin(), }; } Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, URLCellDataNotifier cellDataNotifier, ); List accessoryBuilder( GridCellAccessoryBuildContext context, URLCellDataNotifier cellDataNotifier, ); } typedef URLCellDataNotifier = CellDataNotifier; class EditableURLCell extends EditableCellWidget { EditableURLCell({ super.key, required this.databaseController, required this.cellContext, required this.skin, }) : _cellDataNotifier = CellDataNotifier(value: ''); final DatabaseController databaseController; final CellContext cellContext; final IEditableURLCellSkin skin; final URLCellDataNotifier _cellDataNotifier; @override List Function( GridCellAccessoryBuildContext buildContext, ) get accessoryBuilder => (context) { return skin.accessoryBuilder(context, _cellDataNotifier); }; @override GridCellState createState() => _GridURLCellState(); } class _GridURLCellState extends GridEditableTextCell { late final TextEditingController _textEditingController; late final cellBloc = URLCellBloc( cellController: makeCellController( widget.databaseController, widget.cellContext, ).as(), ); @override SingleListenerFocusNode focusNode = SingleListenerFocusNode(); @override void initState() { super.initState(); _textEditingController = TextEditingController(text: cellBloc.state.content); } @override void dispose() { widget._cellDataNotifier.dispose(); _textEditingController.dispose(); cellBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: cellBloc, child: BlocListener( listenWhen: (previous, current) => previous.content != current.content, listener: (context, state) { if (!focusNode.hasFocus) { _textEditingController.value = _textEditingController.value.copyWith(text: state.content); } widget._cellDataNotifier.value = state.content; }, child: widget.skin.build( context, widget.cellContainerNotifier, widget.databaseController.compactModeNotifier, cellBloc, focusNode, _textEditingController, widget._cellDataNotifier, ), ), ); } @override Future focusChanged() async { if (mounted && !cellBloc.isClosed && cellBloc.state.content != _textEditingController.text) { cellBloc.add(URLCellEvent.updateURL(_textEditingController.text)); } return super.focusChanged(); } @override String? onCopy() => cellBloc.state.content; } class MobileURLEditor extends StatelessWidget { const MobileURLEditor({ super.key, required this.textEditingController, }); final TextEditingController textEditingController; @override Widget build(BuildContext context) { return Column( children: [ const VSpace(4.0), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: FlowyTextField( controller: textEditingController, hintStyle: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: Theme.of(context).hintColor), hintText: LocaleKeys.grid_url_textFieldHint.tr(), textStyle: Theme.of(context).textTheme.bodyMedium, keyboardType: TextInputType.url, hintTextConstraints: const BoxConstraints(maxHeight: 52), error: context.watch().state.isValid ? null : const SizedBox.shrink(), onChanged: (_) { if (textEditingController.value.composing.isCollapsed) { context .read() .add(URLCellEvent.updateURL(textEditingController.text)); } }, onSubmitted: (text) => context.read().add(URLCellEvent.updateURL(text)), ), ), const VSpace(8.0), MobileQuickActionButton( enable: context.watch().state.content.isNotEmpty, onTap: () { openUrlCellLink(textEditingController.text); context.pop(); }, icon: FlowySvgs.url_s, text: LocaleKeys.grid_url_launch.tr(), ), const MobileQuickActionDivider(), MobileQuickActionButton( enable: context.watch().state.content.isNotEmpty, onTap: () { Clipboard.setData( ClipboardData(text: textEditingController.text), ); Fluttertoast.showToast( msg: LocaleKeys.message_copy_success.tr(), gravity: ToastGravity.BOTTOM, ); context.pop(); }, icon: FlowySvgs.copy_s, text: LocaleKeys.grid_url_copy.tr(), ), ], ); } } void openUrlCellLink(String content) async { late Uri uri; try { uri = Uri.parse(content); // `Uri` identifies `localhost` as a scheme if (!uri.hasScheme || uri.scheme == 'localhost') { uri = Uri.parse("http://$content"); await InternetAddress.lookup(uri.host); } } catch (_) { uri = Uri.parse( "https://www.google.com/search?q=${Uri.encodeComponent(content)}", ); } finally { await launchUrl(uri); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checkbox_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/checkbox.dart'; class MobileGridCheckboxCellSkin extends IEditableCheckboxCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { return Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), child: FlowySvg( state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, blendMode: BlendMode.dst, size: const Size.square(24), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart ================================================ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/checklist.dart'; class MobileGridChecklistCellSkin extends IEditableChecklistCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ) { return BlocBuilder( builder: (context, state) { return FlowyButton( radius: BorderRadius.zero, hoverColor: Colors.transparent, text: Container( alignment: Alignment.centerLeft, padding: GridSize.cellContentInsets, child: state.tasks.isEmpty ? const SizedBox.shrink() : ChecklistProgressBar( tasks: state.tasks, percent: state.percent, textStyle: Theme.of(context) .textTheme .bodyMedium ?.copyWith(fontSize: 15), ), ), onTap: () => showMobileBottomSheet( context, builder: (context) { return BlocProvider.value( value: bloc, child: const MobileChecklistCellEditScreen(), ); }, ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileGridDateCellSkin extends IEditableDateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, ) { final dateStr = getDateCellStrFromCellData( state.fieldInfo, state.cellData, ); return FlowyButton( radius: BorderRadius.zero, hoverColor: Colors.transparent, margin: EdgeInsets.zero, text: Align( alignment: AlignmentDirectional.centerStart, child: SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ if (state.cellData.reminderId.isNotEmpty) ...[ const FlowySvg(FlowySvgs.clock_alarm_s), const HSpace(6), ], FlowyText( dateStr, fontSize: 15, ), ], ), ), ), onTap: () { showMobileBottomSheet( context, builder: (context) { return MobileDateCellEditScreen( controller: bloc.cellController, showAsFullScreen: false, ); }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_number_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/number.dart'; class MobileGridNumberCellSkin extends IEditableNumberCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, focusNode: focusNode, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15), decoration: const InputDecoration( enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), isCollapsed: true, ), onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart ================================================ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/relation.dart'; class MobileGridRelationCellSkin extends IEditableRelationCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, ) { return FlowyButton( radius: BorderRadius.zero, hoverColor: Colors.transparent, margin: EdgeInsets.zero, text: Align( alignment: AlignmentDirectional.centerStart, child: SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( mainAxisSize: MainAxisSize.min, children: state.rows .map( (row) => FlowyText( row.name, fontSize: 15, decoration: TextDecoration.underline, ), ) .toList(), ), ), ), onTap: () { showMobileBottomSheet( context, backgroundColor: Theme.of(context).colorScheme.secondaryContainer, builder: (context) { return const FlowyText("Coming soon"); }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart ================================================ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/select_option.dart'; class MobileGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { return BlocBuilder( builder: (context, state) { return FlowyButton( hoverColor: Colors.transparent, radius: BorderRadius.zero, margin: EdgeInsets.zero, text: Align( alignment: AlignmentDirectional.centerStart, child: state.selectedOptions.isEmpty ? const SizedBox.shrink() : _buildOptions(context, state.selectedOptions), ), onTap: () { showMobileBottomSheet( context, builder: (context) { return MobileSelectOptionEditor( cellController: bloc.cellController, ); }, ); }, ); }, ); } Widget _buildOptions(BuildContext context, List options) { final children = options .mapIndexed( (index, option) => SelectOptionTag( option: option, fontSize: 14, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), ), ) .toList(); return ListView.separated( scrollDirection: Axis.horizontal, separatorBuilder: (context, index) => const HSpace(8), itemCount: children.length, itemBuilder: (context, index) => children[index], padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; class MobileGridSummaryCellSkin extends IEditableSummaryCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return ChangeNotifierProvider( create: (_) => SummaryMouseNotifier(), builder: (context, child) { return MouseRegion( cursor: SystemMouseCursors.click, opaque: false, onEnter: (p) => Provider.of(context, listen: false) .onEnter = true, onExit: (p) => Provider.of(context, listen: false) .onEnter = false, child: Stack( children: [ TextField( controller: textEditingController, readOnly: true, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, decoration: InputDecoration( contentPadding: GridSize.cellContentInsets, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ), Padding( padding: EdgeInsets.symmetric( horizontal: GridSize.cellVPadding, ), child: Consumer( builder: ( BuildContext context, SummaryMouseNotifier notifier, Widget? child, ) { if (notifier.onEnter) { return SummaryCellAccessory( viewId: bloc.cellController.viewId, fieldId: bloc.cellController.fieldId, rowId: bloc.cellController.rowId, ); } else { return const SizedBox.shrink(); } }, ), ).positioned(right: 0, bottom: 0), ], ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_text_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/text.dart'; class MobileGridTextCellSkin extends IEditableTextCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return Row( children: [ const HSpace(10), BlocBuilder( buildWhen: (p, c) => p.emoji != c.emoji, builder: (context, state) => Center( child: FlowyText.emoji( state.emoji?.value ?? "", fontSize: 15, optimizeEmojiAlign: true, ), ), ), Expanded( child: TextField( controller: textEditingController, focusNode: focusNode, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 15, ), decoration: const InputDecoration( enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: EdgeInsets.symmetric(horizontal: 4), isCollapsed: true, ), onTapOutside: (event) => focusNode.unfocus(), ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_time_cell.dart ================================================ import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/time.dart'; class MobileGridTimeCellSkin extends IEditableTimeCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, TimeCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, focusNode: focusNode, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15), decoration: const InputDecoration( enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), isCollapsed: true, ), onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_timestamp_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/timestamp.dart'; class MobileGridTimestampCellSkin extends IEditableTimestampCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { return Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), child: FlowyText( state.dateStr, fontSize: 15, overflow: TextOverflow.ellipsis, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; class MobileGridTranslateCellSkin extends IEditableTranslateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return ChangeNotifierProvider( create: (_) => TranslateMouseNotifier(), builder: (context, child) { return MouseRegion( cursor: SystemMouseCursors.click, opaque: false, onEnter: (p) => Provider.of(context, listen: false) .onEnter = true, onExit: (p) => Provider.of(context, listen: false) .onEnter = false, child: Stack( children: [ TextField( controller: textEditingController, readOnly: true, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, decoration: InputDecoration( contentPadding: GridSize.cellContentInsets, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ), Padding( padding: EdgeInsets.symmetric( horizontal: GridSize.cellVPadding, ), child: Consumer( builder: ( BuildContext context, TranslateMouseNotifier notifier, Widget? child, ) { if (notifier.onEnter) { return TranslateCellAccessory( viewId: bloc.cellController.viewId, fieldId: bloc.cellController.fieldId, rowId: bloc.cellController.rowId, ); } else { return const SizedBox.shrink(); } }, ), ).positioned(right: 0, bottom: 0), ], ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart ================================================ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/url.dart'; class MobileGridURLCellSkin extends IEditableURLCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, URLCellDataNotifier cellDataNotifier, ) { return BlocSelector( selector: (state) => state.content, builder: (context, content) { return GestureDetector( onTap: () => _showURLEditor(context, bloc, textEditingController), behavior: HitTestBehavior.opaque, child: Container( alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Text( content, maxLines: 1, style: Theme.of(context).textTheme.titleMedium?.copyWith( decoration: TextDecoration.underline, color: Theme.of(context).colorScheme.primary, ), ), ), ), ); }, ); } void _showURLEditor( BuildContext context, URLCellBloc bloc, TextEditingController textEditingController, ) { showMobileBottomSheet( context, showDragHandle: true, backgroundColor: AFThemeExtension.of(context).background, builder: (context) => BlocProvider.value( value: bloc, child: MobileURLEditor( textEditingController: textEditingController, ), ), ); } @override List>> accessoryBuilder( GridCellAccessoryBuildContext context, URLCellDataNotifier cellDataNotifier, ) => const []; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/checkbox.dart'; class MobileRowDetailCheckboxCellSkin extends IEditableCheckboxCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, CheckboxCellBloc bloc, CheckboxCellState state, ) { return InkWell( onTap: () => bloc.add(const CheckboxCellEvent.select()), borderRadius: const BorderRadius.all(Radius.circular(14)), child: Container( constraints: const BoxConstraints( minHeight: 48, minWidth: double.infinity, ), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), alignment: AlignmentDirectional.centerStart, child: FlowySvg( state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, color: AFThemeExtension.of(context).onBackground, blendMode: BlendMode.dst, size: const Size.square(24), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checklist_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/checklist.dart'; class MobileRowDetailChecklistCellSkin extends IEditableChecklistCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, ChecklistCellBloc bloc, PopoverController popoverController, ) { return BlocBuilder( builder: (context, state) { return InkWell( borderRadius: const BorderRadius.all(Radius.circular(14)), onTap: () => showMobileBottomSheet( context, backgroundColor: AFThemeExtension.of(context).background, builder: (context) { return BlocProvider.value( value: bloc, child: const MobileChecklistCellEditScreen(), ); }, ), child: Container( constraints: const BoxConstraints( minHeight: 48, minWidth: double.infinity, ), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), alignment: AlignmentDirectional.centerStart, child: state.tasks.isEmpty ? FlowyText( LocaleKeys.grid_row_textPlaceholder.tr(), fontSize: 15, color: Theme.of(context).hintColor, ) : ChecklistProgressBar( tasks: state.tasks, percent: state.percent, textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 15, color: Theme.of(context).hintColor, ), ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_date_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileRowDetailDateCellSkin extends IEditableDateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, DateCellBloc bloc, DateCellState state, PopoverController popoverController, ) { final dateStr = getDateCellStrFromCellData( state.fieldInfo, state.cellData, ); final text = dateStr.isEmpty ? LocaleKeys.grid_row_textPlaceholder.tr() : dateStr; final color = dateStr.isEmpty ? Theme.of(context).hintColor : null; return InkWell( borderRadius: const BorderRadius.all(Radius.circular(14)), onTap: () => showMobileBottomSheet( context, builder: (context) { return MobileDateCellEditScreen( controller: bloc.cellController, showAsFullScreen: false, ); }, ), child: Container( constraints: const BoxConstraints( minHeight: 48, minWidth: double.infinity, ), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), child: Row( children: [ if (state.cellData.reminderId.isNotEmpty) ...[ const FlowySvg(FlowySvgs.clock_alarm_s), const HSpace(6), ], FlowyText.regular( text, fontSize: 16, color: color, maxLines: null, ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_number_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/number.dart'; class MobileRowDetailNumberCellSkin extends IEditableNumberCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, NumberCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, keyboardType: const TextInputType.numberWithOptions( signed: true, decimal: true, ), focusNode: focusNode, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 16), decoration: InputDecoration( enabledBorder: _getInputBorder(color: Theme.of(context).colorScheme.outline), focusedBorder: _getInputBorder(color: Theme.of(context).colorScheme.primary), hintText: LocaleKeys.grid_row_textPlaceholder.tr(), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), isCollapsed: true, isDense: true, constraints: const BoxConstraints(), ), // close keyboard when tapping outside of the text field onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), ); } InputBorder _getInputBorder({Color? color}) { return OutlineInputBorder( borderSide: BorderSide(color: color!), borderRadius: const BorderRadius.all(Radius.circular(14)), gapPadding: 0, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart ================================================ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileRowDetailRelationCellSkin extends IEditableRelationCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, RelationCellBloc bloc, RelationCellState state, PopoverController popoverController, ) { return InkWell( borderRadius: const BorderRadius.all(Radius.circular(14)), onTap: () => showMobileBottomSheet( context, builder: (context) { return const FlowyText("Coming soon"); }, ), child: Container( constraints: const BoxConstraints( minHeight: 48, minWidth: double.infinity, ), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), child: Wrap( runSpacing: 4.0, spacing: 4.0, children: state.rows .map( (row) => FlowyText( row.name, fontSize: 16, decoration: TextDecoration.underline, overflow: TextOverflow.ellipsis, ), ) .toList(), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/select_option.dart'; class MobileRowDetailSelectOptionCellSkin extends IEditableSelectOptionCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SelectOptionCellBloc bloc, PopoverController popoverController, ) { return BlocBuilder( builder: (context, state) { return InkWell( borderRadius: const BorderRadius.all(Radius.circular(14)), onTap: () => showMobileBottomSheet( context, builder: (context) { return MobileSelectOptionEditor( cellController: bloc.cellController, ); }, ), child: Container( constraints: const BoxConstraints( minHeight: 48, minWidth: double.infinity, ), padding: EdgeInsets.symmetric( horizontal: 12, vertical: state.selectedOptions.isEmpty ? 13 : 10, ), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), child: Row( children: [ Expanded( child: state.selectedOptions.isEmpty ? _buildPlaceholder(context) : _buildOptions(context, state.selectedOptions), ), const HSpace(6), RotatedBox( quarterTurns: 3, child: Icon( Icons.chevron_left, color: Theme.of(context).hintColor, ), ), const HSpace(2), ], ), ), ); }, ); } Widget _buildPlaceholder(BuildContext context) { return Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(vertical: 1), child: FlowyText( LocaleKeys.grid_row_textPlaceholder.tr(), color: Theme.of(context).hintColor, ), ); } Widget _buildOptions(BuildContext context, List options) { final children = options.mapIndexed( (index, option) { return Padding( padding: EdgeInsets.only(left: index == 0 ? 0 : 4), child: SelectOptionTag( option: option, fontSize: 14, padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5), ), ); }, ).toList(); return Align( alignment: AlignmentDirectional.centerStart, child: Wrap( runSpacing: 4, children: children, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; class MobileRowDetailSummaryCellSkin extends IEditableSummaryCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, SummaryCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return Container( decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2, ), child: Column( children: [ TextField( controller: textEditingController, readOnly: true, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, maxLines: null, minLines: 1, decoration: InputDecoration( contentPadding: GridSize.cellContentInsets, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ), Row( children: [ const Spacer(), Padding( padding: const EdgeInsets.all(8.0), child: SummaryCellAccessory( viewId: bloc.cellController.viewId, fieldId: bloc.cellController.fieldId, rowId: bloc.cellController.rowId, ), ), ], ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_text_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/text.dart'; class MobileRowDetailTextCellSkin extends IEditableTextCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, focusNode: focusNode, maxLines: null, decoration: InputDecoration( enabledBorder: _getInputBorder(color: Theme.of(context).colorScheme.outline), focusedBorder: _getInputBorder(color: Theme.of(context).colorScheme.primary), hintText: LocaleKeys.grid_row_textPlaceholder.tr(), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), isCollapsed: true, isDense: true, constraints: const BoxConstraints(minHeight: 48), hintStyle: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: Theme.of(context).hintColor), ), onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), ); } InputBorder _getInputBorder({Color? color}) { return OutlineInputBorder( borderSide: BorderSide(color: color!), borderRadius: const BorderRadius.all(Radius.circular(14)), gapPadding: 0, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_time_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/time.dart'; class MobileRowDetailTimeCellSkin extends IEditableTimeCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, TimeCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return TextField( controller: textEditingController, focusNode: focusNode, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 16), decoration: InputDecoration( enabledBorder: _getInputBorder(color: Theme.of(context).colorScheme.outline), focusedBorder: _getInputBorder(color: Theme.of(context).colorScheme.primary), hintText: LocaleKeys.grid_row_textPlaceholder.tr(), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), isCollapsed: true, isDense: true, constraints: const BoxConstraints(), ), // close keyboard when tapping outside of the text field onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), ); } InputBorder _getInputBorder({Color? color}) { return OutlineInputBorder( borderSide: BorderSide(color: color!), borderRadius: const BorderRadius.all(Radius.circular(14)), gapPadding: 0, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_timestamp_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import '../editable_cell_skeleton/timestamp.dart'; class MobileRowDetailTimestampCellSkin extends IEditableTimestampCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TimestampCellBloc bloc, TimestampCellState state, ) { return Container( constraints: const BoxConstraints( minHeight: 48, minWidth: double.infinity, ), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), child: FlowyText( state.dateStr.isEmpty ? LocaleKeys.grid_row_textPlaceholder.tr() : state.dateStr, fontSize: 16, color: state.dateStr.isEmpty ? Theme.of(context).hintColor : null, maxLines: null, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flutter/material.dart'; class MobileRowDetailTranslateCellSkin extends IEditableTranslateCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TranslateCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return Container( decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2, ), child: Column( children: [ TextField( readOnly: true, controller: textEditingController, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), onSubmitted: (_) => focusNode.unfocus(), style: Theme.of(context).textTheme.bodyMedium, textInputAction: TextInputAction.done, maxLines: null, minLines: 1, decoration: InputDecoration( contentPadding: GridSize.cellContentInsets, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, isDense: true, ), ), Row( children: [ const Spacer(), Padding( padding: const EdgeInsets.all(8.0), child: TranslateCellAccessory( viewId: bloc.cellController.viewId, fieldId: bloc.cellController.fieldId, rowId: bloc.cellController.rowId, ), ), ], ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_skeleton/url.dart'; class MobileRowDetailURLCellSkin extends IEditableURLCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, URLCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, URLCellDataNotifier cellDataNotifier, ) { return BlocSelector( selector: (state) => state.content, builder: (context, content) { return InkWell( borderRadius: const BorderRadius.all(Radius.circular(14)), onTap: () => showMobileBottomSheet( context, showDragHandle: true, backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return BlocProvider.value( value: bloc, child: MobileURLEditor( textEditingController: textEditingController, ), ); }, ), child: Container( constraints: const BoxConstraints( minHeight: 48, minWidth: double.infinity, ), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).colorScheme.outline), ), borderRadius: const BorderRadius.all(Radius.circular(14)), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), child: Text( content.isEmpty ? LocaleKeys.grid_row_textPlaceholder.tr() : content, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 16, decoration: content.isEmpty ? null : TextDecoration.underline, color: content.isEmpty ? Theme.of(context).hintColor : Theme.of(context).colorScheme.primary, ), ), ), ), ); }, ); } @override List>> accessoryBuilder( GridCellAccessoryBuildContext context, URLCellDataNotifier cellDataNotifier, ) => const []; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; import 'package:appflowy/util/debounce.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../application/cell/bloc/checklist_cell_bloc.dart'; import 'checklist_cell_textfield.dart'; import 'checklist_progress_bar.dart'; class ChecklistCellEditor extends StatefulWidget { const ChecklistCellEditor({required this.cellController, super.key}); final ChecklistCellController cellController; @override State createState() => _ChecklistCellEditorState(); } class _ChecklistCellEditorState extends State { /// Focus node for the new task text field late final FocusNode newTaskFocusNode; @override void initState() { super.initState(); newTaskFocusNode = FocusNode( onKeyEvent: (node, event) { if (event.logicalKey == LogicalKeyboardKey.escape) { node.unfocus(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, ); } @override Widget build(BuildContext context) { return BlocConsumer( listener: (context, state) { if (state.tasks.isEmpty) { newTaskFocusNode.requestFocus(); } }, builder: (context, state) { return Column( mainAxisSize: MainAxisSize.min, children: [ if (state.tasks.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), child: ChecklistProgressBar( tasks: state.tasks, percent: state.percent, ), ), ChecklistItemList( options: state.tasks, onUpdateTask: () => newTaskFocusNode.requestFocus(), ), if (state.tasks.isNotEmpty) const TypeOptionSeparator(spacing: 0.0), Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: NewTaskItem(focusNode: newTaskFocusNode), ), ], ); }, ); } @override void dispose() { newTaskFocusNode.dispose(); super.dispose(); } } /// Displays the a list of all the existing tasks and an input field to create /// a new task if `isAddingNewTask` is true class ChecklistItemList extends StatelessWidget { const ChecklistItemList({ super.key, required this.options, required this.onUpdateTask, }); final List options; final VoidCallback onUpdateTask; @override Widget build(BuildContext context) { if (options.isEmpty) { return const SizedBox.shrink(); } final itemList = options .mapIndexed( (index, option) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), key: ValueKey(option.data.id), child: ChecklistItem( task: option, index: index, onSubmitted: index == options.length - 1 ? onUpdateTask : null, ), ), ) .toList(); return Flexible( child: ReorderableListView.builder( shrinkWrap: true, proxyDecorator: (child, index, _) => Material( color: Colors.transparent, child: MouseRegion( cursor: UniversalPlatform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grabbing, child: IgnorePointer( child: BlocProvider.value( value: context.read(), child: child, ), ), ), ), buildDefaultDragHandles: false, itemBuilder: (context, index) => itemList[index], itemCount: itemList.length, padding: const EdgeInsets.symmetric(vertical: 6.0), onReorder: (from, to) { context .read() .add(ChecklistCellEvent.reorderTask(from, to)); }, ), ); } } class _SelectTaskIntent extends Intent { const _SelectTaskIntent(); } class _EndEditingTaskIntent extends Intent { const _EndEditingTaskIntent(); } class _UpdateTaskDescriptionIntent extends Intent { const _UpdateTaskDescriptionIntent(); } class ChecklistItem extends StatefulWidget { const ChecklistItem({ super.key, required this.task, required this.index, this.onSubmitted, this.autofocus = false, }); final ChecklistSelectOption task; final int index; final VoidCallback? onSubmitted; final bool autofocus; @override State createState() => _ChecklistItemState(); } class _ChecklistItemState extends State { TextEditingController textController = TextEditingController(); final textFieldFocusNode = FocusNode(); final focusNode = FocusNode(skipTraversal: true); bool isHovered = false; bool isFocused = false; bool isComposing = false; final _debounceOnChanged = Debounce( duration: const Duration(milliseconds: 300), ); @override void initState() { super.initState(); textController.text = widget.task.data.name; textController.addListener(_onTextChanged); if (widget.autofocus) { WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); textFieldFocusNode.requestFocus(); }); } } void _onTextChanged() => setState(() => isComposing = !textController.value.composing.isCollapsed); @override void didUpdateWidget(covariant oldWidget) { if (!focusNode.hasFocus && oldWidget.task.data.name != widget.task.data.name) { textController.text = widget.task.data.name; } super.didUpdateWidget(oldWidget); } @override void dispose() { _debounceOnChanged.dispose(); textController.removeListener(_onTextChanged); textController.dispose(); focusNode.dispose(); textFieldFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final isFocusedOrHovered = isHovered || isFocused; final color = isFocusedOrHovered || textFieldFocusNode.hasFocus ? AFThemeExtension.of(context).lightGreyHover : Colors.transparent; return FocusableActionDetector( focusNode: focusNode, onShowHoverHighlight: (value) => setState(() => isHovered = value), onFocusChange: (value) => setState(() => isFocused = value), actions: _buildActions(), shortcuts: _buildShortcuts(), child: Container( constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight), decoration: BoxDecoration(color: color, borderRadius: Corners.s6Border), child: _buildChild(isFocusedOrHovered && !textFieldFocusNode.hasFocus), ), ); } Widget _buildChild(bool showTrash) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ReorderableDragStartListener( index: widget.index, child: MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grab, child: SizedBox( width: 20, height: 32, child: Align( alignment: AlignmentDirectional.centerEnd, child: FlowySvg( FlowySvgs.drag_element_s, size: const Size.square(14), color: AFThemeExtension.of(context).onBackground, ), ), ), ), ), ChecklistCellCheckIcon(task: widget.task), Expanded( child: ChecklistCellTextfield( textController: textController, focusNode: textFieldFocusNode, lineHeight: Platform.isWindows ? 1.2 : 1.1, onChanged: () { _debounceOnChanged.call(() { if (!isComposing) { _submitUpdateTaskDescription(textController.text); } }); }, onSubmitted: () { _submitUpdateTaskDescription(textController.text); if (widget.onSubmitted != null) { widget.onSubmitted?.call(); } else { Actions.invoke(context, const NextFocusIntent()); } }, ), ), if (showTrash) ChecklistCellDeleteButton( onPressed: () => context .read() .add(ChecklistCellEvent.deleteTask(widget.task.data.id)), ), ], ); } Map _buildShortcuts() { return { SingleActivator( LogicalKeyboardKey.enter, meta: Platform.isMacOS, control: !Platform.isMacOS, ): const _SelectTaskIntent(), if (!isComposing) const SingleActivator(LogicalKeyboardKey.enter): const _UpdateTaskDescriptionIntent(), if (!isComposing) const SingleActivator(LogicalKeyboardKey.escape): const _EndEditingTaskIntent(), }; } Map> _buildActions() { return { _SelectTaskIntent: CallbackAction<_SelectTaskIntent>( onInvoke: (_SelectTaskIntent intent) { context .read() .add(ChecklistCellEvent.selectTask(widget.task.data.id)); return; }, ), _UpdateTaskDescriptionIntent: CallbackAction<_UpdateTaskDescriptionIntent>( onInvoke: (_UpdateTaskDescriptionIntent intent) { textFieldFocusNode.unfocus(); widget.onSubmitted?.call(); return; }, ), _EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>( onInvoke: (_EndEditingTaskIntent intent) { textFieldFocusNode.unfocus(); return; }, ), }; } void _submitUpdateTaskDescription(String description) => context .read() .add(ChecklistCellEvent.updateTaskName(widget.task.data, description)); } /// Creates a new task after entering the description and pressing enter. /// This can be cancelled by pressing escape @visibleForTesting class NewTaskItem extends StatefulWidget { const NewTaskItem({super.key, required this.focusNode}); final FocusNode focusNode; @override State createState() => _NewTaskItemState(); } class _NewTaskItemState extends State { final textController = TextEditingController(); bool isCreateButtonEnabled = false; bool isComposing = false; @override void initState() { super.initState(); textController.addListener(_onTextChanged); if (widget.focusNode.canRequestFocus) { widget.focusNode.requestFocus(); } } void _onTextChanged() => setState(() => isComposing = !textController.value.composing.isCollapsed); @override void dispose() { textController.removeListener(_onTextChanged); textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8), constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight), child: Row( children: [ const HSpace(8), Expanded( child: CallbackShortcuts( bindings: isComposing ? const {} : { const SingleActivator(LogicalKeyboardKey.enter): () => _createNewTask(context), }, child: TextField( focusNode: widget.focusNode, controller: textController, style: Theme.of(context).textTheme.bodyMedium, maxLines: null, decoration: InputDecoration( border: InputBorder.none, isCollapsed: true, contentPadding: const EdgeInsets.symmetric( vertical: 6.0, horizontal: 2.0, ), hintText: LocaleKeys.grid_checklist_addNew.tr(), ), onSubmitted: (_) => _createNewTask(context), onChanged: (_) => setState( () => isCreateButtonEnabled = textController.text.isNotEmpty, ), ), ), ), FlowyTextButton( LocaleKeys.grid_checklist_submitNewTask.tr(), fontSize: 11, fillColor: isCreateButtonEnabled ? Theme.of(context).colorScheme.primary : Theme.of(context).disabledColor, hoverColor: isCreateButtonEnabled ? Theme.of(context).colorScheme.primaryContainer : Theme.of(context).disabledColor, fontColor: Theme.of(context).colorScheme.onPrimary, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), onPressed: isCreateButtonEnabled ? () { context.read().add( ChecklistCellEvent.createNewTask(textController.text), ); widget.focusNode.requestFocus(); textController.clear(); } : null, ), ], ), ); } void _createNewTask(BuildContext context) { final taskDescription = textController.text; if (taskDescription.isNotEmpty) { context .read() .add(ChecklistCellEvent.createNewTask(taskDescription)); textController.clear(); } widget.focusNode.requestFocus(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../application/cell/bloc/checklist_cell_bloc.dart'; class ChecklistCellCheckIcon extends StatelessWidget { const ChecklistCellCheckIcon({ super.key, required this.task, }); final ChecklistSelectOption task; @override Widget build(BuildContext context) { return ExcludeFocus( child: FlowyIconButton( width: 32, icon: FlowySvg( task.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, blendMode: BlendMode.dst, ), hoverColor: Colors.transparent, onPressed: () => context.read().add( ChecklistCellEvent.selectTask(task.data.id), ), ), ); } } class ChecklistCellTextfield extends StatelessWidget { const ChecklistCellTextfield({ super.key, required this.textController, required this.focusNode, this.onChanged, this.contentPadding = const EdgeInsets.symmetric( vertical: 8, horizontal: 2, ), this.onSubmitted, this.lineHeight, }); final TextEditingController textController; final FocusNode focusNode; final EdgeInsetsGeometry contentPadding; final VoidCallback? onSubmitted; final VoidCallback? onChanged; final double? lineHeight; @override Widget build(BuildContext context) { final textStyle = Theme.of(context).textTheme.bodyMedium; return TextField( controller: textController, focusNode: focusNode, style: textStyle?.copyWith( height: lineHeight, ), maxLines: null, decoration: InputDecoration( border: InputBorder.none, isCollapsed: true, isDense: true, contentPadding: contentPadding, hintText: LocaleKeys.grid_checklist_taskHint.tr(), ), textInputAction: onSubmitted == null ? TextInputAction.next : null, onChanged: (_) => onChanged?.call(), onSubmitted: (_) => onSubmitted?.call(), ); } } class ChecklistCellDeleteButton extends StatefulWidget { const ChecklistCellDeleteButton({ super.key, required this.onPressed, }); final VoidCallback onPressed; @override State createState() => _ChecklistCellDeleteButtonState(); } class _ChecklistCellDeleteButtonState extends State { final _materialStatesController = WidgetStatesController(); @override void dispose() { _materialStatesController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return TextButton( onPressed: widget.onPressed, onHover: (_) => setState(() {}), onFocusChange: (_) => setState(() {}), style: ButtonStyle( fixedSize: const WidgetStatePropertyAll(Size.square(32)), minimumSize: const WidgetStatePropertyAll(Size.square(32)), maximumSize: const WidgetStatePropertyAll(Size.square(32)), overlayColor: WidgetStateProperty.resolveWith((state) { if (state.contains(WidgetState.focused)) { return AFThemeExtension.of(context).greyHover; } return Colors.transparent; }), shape: const WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: Corners.s6Border), ), ), statesController: _materialStatesController, child: FlowySvg( FlowySvgs.delete_s, color: _materialStatesController.value.contains(WidgetState.hovered) || _materialStatesController.value.contains(WidgetState.focused) ? Theme.of(context).colorScheme.error : null, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_progress_bar.dart ================================================ import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:percent_indicator/percent_indicator.dart'; import '../../application/cell/bloc/checklist_cell_bloc.dart'; class ChecklistProgressBar extends StatefulWidget { const ChecklistProgressBar({ super.key, required this.tasks, required this.percent, this.textStyle, }); final List tasks; final double percent; final TextStyle? textStyle; final int segmentLimit = 5; @override State createState() => _ChecklistProgressBarState(); } class _ChecklistProgressBarState extends State { @override Widget build(BuildContext context) { final numFinishedTasks = widget.tasks.where((e) => e.isSelected).length; final completedTaskColor = numFinishedTasks == widget.tasks.length ? AFThemeExtension.of(context).success : Theme.of(context).colorScheme.primary; return Row( children: [ Expanded( child: widget.tasks.isNotEmpty && widget.tasks.length <= widget.segmentLimit ? Row( children: [ ...List.generate( widget.tasks.length, (index) => Flexible( child: Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(2)), color: index < numFinishedTasks ? completedTaskColor : AFThemeExtension.of(context) .progressBarBGColor, ), margin: const EdgeInsets.symmetric(horizontal: 1), height: 4.0, ), ), ), ], ) : LinearPercentIndicator( lineHeight: 4.0, percent: widget.percent, padding: EdgeInsets.zero, progressColor: completedTaskColor, backgroundColor: AFThemeExtension.of(context).progressBarBGColor, barRadius: const Radius.circular(2), ), ), SizedBox( width: 45, child: Align( alignment: AlignmentDirectional.centerEnd, child: Text( "${(widget.percent * 100).round()}%", style: widget.textStyle, ), ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/date_cell_editor.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../application/cell/bloc/date_cell_editor_bloc.dart'; class DateCellEditor extends StatefulWidget { const DateCellEditor({ super.key, required this.onDismissed, required this.cellController, }); final VoidCallback onDismissed; final DateCellController cellController; @override State createState() => _DateCellEditor(); } class _DateCellEditor extends State { final PopoverMutex popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DateCellEditorBloc( reminderBloc: getIt(), cellController: widget.cellController, ), child: BlocBuilder( builder: (context, state) { final dateCellBloc = context.read(); return DesktopAppFlowyDatePicker( dateTime: state.dateTime, endDateTime: state.endDateTime, dateFormat: state.dateTypeOptionPB.dateFormat, timeFormat: state.dateTypeOptionPB.timeFormat, includeTime: state.includeTime, isRange: state.isRange, reminderOption: state.reminderOption, popoverMutex: popoverMutex, options: [ OptionGroup( options: [ DateTypeOptionButton( popoverMutex: popoverMutex, dateFormat: state.dateTypeOptionPB.dateFormat, timeFormat: state.dateTypeOptionPB.timeFormat, onDateFormatChanged: (format) { dateCellBloc .add(DateCellEditorEvent.setDateFormat(format)); }, onTimeFormatChanged: (format) { dateCellBloc .add(DateCellEditorEvent.setTimeFormat(format)); }, ), ClearDateButton( onClearDate: () { dateCellBloc.add(const DateCellEditorEvent.clearDate()); }, ), ], ), ], onIncludeTimeChanged: (value, dateTime, endDateTime) { dateCellBloc.add( DateCellEditorEvent.setIncludeTime( value, dateTime, endDateTime, ), ); }, onIsRangeChanged: (value, dateTime, endDateTime) { dateCellBloc.add( DateCellEditorEvent.setIsRange(value, dateTime, endDateTime), ); }, onDaySelected: (selectedDay) { dateCellBloc.add(DateCellEditorEvent.updateDateTime(selectedDay)); }, onRangeSelected: (start, end) { dateCellBloc.add(DateCellEditorEvent.updateDateRange(start, end)); }, onReminderSelected: (option) { dateCellBloc.add(DateCellEditorEvent.setReminderOption(option)); }, ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; extension SelectOptionColorExtension on SelectOptionColorPB { Color toColor(BuildContext context) { switch (this) { case SelectOptionColorPB.Purple: return AFThemeExtension.of(context).tint1; case SelectOptionColorPB.Pink: return AFThemeExtension.of(context).tint2; case SelectOptionColorPB.LightPink: return AFThemeExtension.of(context).tint3; case SelectOptionColorPB.Orange: return AFThemeExtension.of(context).tint4; case SelectOptionColorPB.Yellow: return AFThemeExtension.of(context).tint5; case SelectOptionColorPB.Lime: return AFThemeExtension.of(context).tint6; case SelectOptionColorPB.Green: return AFThemeExtension.of(context).tint7; case SelectOptionColorPB.Aqua: return AFThemeExtension.of(context).tint8; case SelectOptionColorPB.Blue: return AFThemeExtension.of(context).tint9; default: throw ArgumentError; } } String colorName() { switch (this) { case SelectOptionColorPB.Purple: return LocaleKeys.grid_selectOption_purpleColor.tr(); case SelectOptionColorPB.Pink: return LocaleKeys.grid_selectOption_pinkColor.tr(); case SelectOptionColorPB.LightPink: return LocaleKeys.grid_selectOption_lightPinkColor.tr(); case SelectOptionColorPB.Orange: return LocaleKeys.grid_selectOption_orangeColor.tr(); case SelectOptionColorPB.Yellow: return LocaleKeys.grid_selectOption_yellowColor.tr(); case SelectOptionColorPB.Lime: return LocaleKeys.grid_selectOption_limeColor.tr(); case SelectOptionColorPB.Green: return LocaleKeys.grid_selectOption_greenColor.tr(); case SelectOptionColorPB.Aqua: return LocaleKeys.grid_selectOption_aquaColor.tr(); case SelectOptionColorPB.Blue: return LocaleKeys.grid_selectOption_blueColor.tr(); default: throw ArgumentError; } } } class SelectOptionTag extends StatelessWidget { const SelectOptionTag({ super.key, this.option, this.name, this.fontSize, this.color, this.textStyle, this.onRemove, this.textAlign, this.isExpanded = false, this.borderRadius, required this.padding, }) : assert(option != null || name != null && color != null); final SelectOptionPB? option; final String? name; final double? fontSize; final Color? color; final TextStyle? textStyle; final void Function(String)? onRemove; final EdgeInsets padding; final BorderRadius? borderRadius; final TextAlign? textAlign; final bool isExpanded; @override Widget build(BuildContext context) { final optionName = option?.name ?? name!; final optionColor = option?.color.toColor(context) ?? color!; final text = FlowyText( optionName, fontSize: fontSize, overflow: TextOverflow.ellipsis, color: AFThemeExtension.of(context).textColor, textAlign: textAlign, ); return Container( padding: onRemove == null ? padding : padding.copyWith(right: 2.0), decoration: BoxDecoration( color: optionColor, borderRadius: borderRadius ?? BorderRadius.circular(UniversalPlatform.isDesktopOrWeb ? 6 : 11), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ isExpanded ? Expanded(child: text) : Flexible(child: text), if (onRemove != null) ...[ const HSpace(4), FlowyIconButton( width: 16.0, onPressed: () => onRemove?.call(optionName), hoverColor: Colors.transparent, icon: const FlowySvg(FlowySvgs.close_s), ), ], ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/af_image.dart'; import 'package:appflowy/util/xfile_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MediaCellEditor extends StatefulWidget { const MediaCellEditor({super.key}); @override State createState() => _MediaCellEditorState(); } class _MediaCellEditorState extends State { final addFilePopoverController = PopoverController(); final itemMutex = PopoverMutex(); @override void dispose() { addFilePopoverController.close(); itemMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final images = state.files .where((file) => file.fileType == MediaFileTypePB.Image) .toList(); return Column( mainAxisSize: MainAxisSize.min, children: [ if (state.files.isNotEmpty) ...[ Flexible( child: ReorderableListView.builder( padding: const EdgeInsets.all(6), physics: const ClampingScrollPhysics(), shrinkWrap: true, buildDefaultDragHandles: false, itemBuilder: (_, index) => BlocProvider.value( key: Key(state.files[index].id), value: context.read(), child: RenderMedia( file: state.files[index], images: images, index: index, enableReordering: state.files.length > 1, mutex: itemMutex, ), ), itemCount: state.files.length, onReorder: (from, to) { if (from < to) { to--; } context .read() .add(MediaCellEvent.reorderFiles(from: from, to: to)); }, proxyDecorator: (child, index, animation) => Material( color: Colors.transparent, child: SizeTransition( sizeFactor: animation, child: child, ), ), ), ), ], _AddButton(addFilePopoverController: addFilePopoverController), ], ); }, ); } } class _AddButton extends StatelessWidget { const _AddButton({required this.addFilePopoverController}); final PopoverController addFilePopoverController; @override Widget build(BuildContext context) { return AppFlowyPopover( controller: addFilePopoverController, direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 10), constraints: const BoxConstraints(maxWidth: 350), triggerActions: PopoverTriggerFlags.none, popupBuilder: (popoverContext) => FileUploadMenu( allowMultipleFiles: true, onInsertLocalFile: (files) async => insertLocalFiles( context, files, userProfile: context.read().state.userProfile, documentId: context.read().rowId, onUploadSuccess: (file, path, isLocalMode) { final mediaCellBloc = context.read(); if (mediaCellBloc.isClosed) { return; } mediaCellBloc.add( MediaCellEvent.addFile( url: path, name: file.name, uploadType: isLocalMode ? FileUploadTypePB.LocalFile : FileUploadTypePB.CloudFile, fileType: file.fileType.toMediaFileTypePB(), ), ); addFilePopoverController.close(); }, ), onInsertNetworkFile: (url) { if (url.isEmpty) return; final uri = Uri.tryParse(url); if (uri == null) { return; } final fakeFile = XFile(uri.path); MediaFileTypePB fileType = fakeFile.fileType.toMediaFileTypePB(); fileType = fileType == MediaFileTypePB.Other ? MediaFileTypePB.Link : fileType; String name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; if (name.isEmpty && uri.pathSegments.length > 1) { name = uri.pathSegments[uri.pathSegments.length - 2]; } else if (name.isEmpty) { name = uri.host; } context.read().add( MediaCellEvent.addFile( url: url, name: name, uploadType: FileUploadTypePB.NetworkFile, fileType: fileType, ), ); addFilePopoverController.close(); }, ), child: DecoratedBox( decoration: BoxDecoration( border: Border( top: BorderSide( color: Theme.of(context).dividerColor, ), ), ), child: Padding( padding: const EdgeInsets.all(8.0), child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: addFilePopoverController.show, child: FlowyHover( resetHoverOnRebuild: false, child: Padding( padding: const EdgeInsets.all(4.0), child: Row( children: [ FlowySvg( FlowySvgs.add_thin_s, size: const Size.square(14), color: AFThemeExtension.of(context).lightIconColor, ), const HSpace(8), FlowyText.regular( LocaleKeys.grid_media_addFileOrImage.tr(), figmaLineHeight: 20, fontSize: 14, ), ], ), ), ), ), ), ), ); } } extension ToCustomImageType on FileUploadTypePB { CustomImageType toCustomImageType() => switch (this) { FileUploadTypePB.NetworkFile => CustomImageType.external, FileUploadTypePB.CloudFile => CustomImageType.internal, _ => CustomImageType.local, }; } @visibleForTesting class RenderMedia extends StatefulWidget { const RenderMedia({ super.key, required this.index, required this.file, required this.images, required this.enableReordering, required this.mutex, }); final int index; final MediaFilePB file; final List images; final bool enableReordering; final PopoverMutex mutex; @override State createState() => _RenderMediaState(); } class _RenderMediaState extends State { bool isHovering = false; int? imageIndex; MediaFilePB get file => widget.file; late final controller = PopoverController(); @override void initState() { super.initState(); imageIndex = widget.images.indexOf(file); } @override void didUpdateWidget(covariant RenderMedia oldWidget) { imageIndex = widget.images.indexOf(file); super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 4), child: MouseRegion( onEnter: (_) => setState(() => isHovering = true), onExit: (_) => setState(() => isHovering = false), cursor: SystemMouseCursors.click, child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), color: isHovering ? AFThemeExtension.of(context).greyHover : Colors.transparent, ), child: Row( crossAxisAlignment: widget.file.fileType == MediaFileTypePB.Image ? CrossAxisAlignment.start : CrossAxisAlignment.center, children: [ ReorderableDragStartListener( index: widget.index, enabled: widget.enableReordering, child: Padding( padding: const EdgeInsets.all(4), child: FlowySvg( FlowySvgs.drag_element_s, color: AFThemeExtension.of(context).lightIconColor, ), ), ), const HSpace(4), if (widget.file.fileType == MediaFileTypePB.Image) ...[ Flexible( child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: _openInteractiveViewer( context, files: widget.images, index: imageIndex!, child: AFImage( url: widget.file.url, uploadType: widget.file.uploadType, userProfile: context.read().state.userProfile, ), ), ), ), ] else ...[ Expanded( child: GestureDetector( onTap: () => afLaunchUrlString(file.url), child: Row( children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: FlowySvg( file.fileType.icon, color: AFThemeExtension.of(context).strongText, size: const Size.square(12), ), ), const HSpace(8), Flexible( child: Padding( padding: const EdgeInsets.only(bottom: 1), child: FlowyText( file.name, overflow: TextOverflow.ellipsis, fontSize: 14, ), ), ), ], ), ), ), ], const HSpace(4), AppFlowyPopover( controller: controller, mutex: widget.mutex, asBarrier: true, offset: const Offset(0, 4), constraints: const BoxConstraints(maxWidth: 240), direction: PopoverDirection.bottomWithLeftAligned, popupBuilder: (popoverContext) => BlocProvider.value( value: context.read(), child: MediaItemMenu( file: file, images: widget.images, index: imageIndex ?? -1, closeContext: popoverContext, onAction: () => controller.close(), ), ), child: FlowyIconButton( hoverColor: Colors.transparent, width: 24, icon: FlowySvg( FlowySvgs.three_dots_s, size: const Size.square(16), color: AFThemeExtension.of(context).lightIconColor, ), ), ), ], ), ), ), ); } Widget _openInteractiveViewer( BuildContext context, { required List files, required int index, required Widget child, }) => GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => openInteractiveViewerFromFiles( context, files, onDeleteImage: (index) { final deleteFile = files[index]; context.read().deleteFile(deleteFile.id); }, userProfile: context.read().state.userProfile, initialIndex: index, ), child: child, ); } class MediaItemMenu extends StatefulWidget { const MediaItemMenu({ super.key, required this.file, required this.images, required this.index, this.closeContext, this.onAction, }); /// The [MediaFilePB] this menu concerns final MediaFilePB file; /// The list of [MediaFilePB] which are images /// This is used to show the [InteractiveImageViewer] final List images; /// The index of the [MediaFilePB] in the [images] list final int index; /// The [BuildContext] used to show the [InteractiveImageViewer] final BuildContext? closeContext; /// Callback to be called when an action is performed final VoidCallback? onAction; @override State createState() => _MediaItemMenuState(); } class _MediaItemMenuState extends State { late final nameController = TextEditingController(text: widget.file.name); final errorMessage = ValueNotifier(null); BuildContext? renameContext; @override void dispose() { nameController.dispose(); errorMessage.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SeparatedColumn( separatorBuilder: () => const VSpace(8), mainAxisSize: MainAxisSize.min, children: [ if (widget.file.fileType == MediaFileTypePB.Image) ...[ MediaMenuItem( onTap: () { widget.onAction?.call(); _showInteractiveViewer(); }, icon: FlowySvgs.full_view_s, label: LocaleKeys.grid_media_expand.tr(), ), MediaMenuItem( onTap: () { context.read().add( MediaCellEvent.setCover( RowCoverPB( data: widget.file.url, uploadType: widget.file.uploadType, coverType: CoverTypePB.FileCover, ), ), ); widget.onAction?.call(); }, icon: FlowySvgs.cover_s, label: LocaleKeys.grid_media_setAsCover.tr(), ), ], MediaMenuItem( onTap: () { widget.onAction?.call(); afLaunchUrlString(widget.file.url); }, icon: FlowySvgs.open_in_browser_s, label: LocaleKeys.grid_media_openInBrowser.tr(), ), MediaMenuItem( onTap: () async { await _showRenameDialog(); widget.onAction?.call(); }, icon: FlowySvgs.rename_s, label: LocaleKeys.grid_media_rename.tr(), ), if (widget.file.uploadType == FileUploadTypePB.CloudFile) ...[ MediaMenuItem( onTap: () async { await downloadMediaFile( context, widget.file, userProfile: context.read().state.userProfile, ); widget.onAction?.call(); }, icon: FlowySvgs.save_as_s, label: LocaleKeys.button_download.tr(), ), ], MediaMenuItem( onTap: () async { await showConfirmDeletionDialog( context: context, name: widget.file.name, description: LocaleKeys.grid_media_deleteFileDescription.tr(), onConfirm: () => context .read() .add(MediaCellEvent.removeFile(fileId: widget.file.id)), ); widget.onAction?.call(); }, icon: FlowySvgs.trash_s, label: LocaleKeys.button_delete.tr(), ), ], ); } Future _showRenameDialog() async { nameController.selection = TextSelection( baseOffset: 0, extentOffset: nameController.text.length, ); await showCustomConfirmDialog( context: context, title: LocaleKeys.document_plugins_file_renameFile_title.tr(), description: LocaleKeys.document_plugins_file_renameFile_description.tr(), closeOnConfirm: false, builder: (dialogContext) { renameContext = dialogContext; return FileRenameTextField( nameController: nameController, errorMessage: errorMessage, onSubmitted: () => _saveName(context), disposeController: false, ); }, confirmLabel: LocaleKeys.button_save.tr(), onConfirm: () => _saveName(context), ); } void _saveName(BuildContext context) { if (nameController.text.isEmpty) { errorMessage.value = LocaleKeys.document_plugins_file_renameFile_nameEmptyError.tr(); return; } context.read().add( MediaCellEvent.renameFile( fileId: widget.file.id, name: nameController.text, ), ); if (renameContext != null) { Navigator.of(renameContext!).pop(); } } void _showInteractiveViewer() { showDialog( context: widget.closeContext ?? context, builder: (_) => InteractiveImageViewer( userProfile: context.read().state.userProfile, imageProvider: AFBlockImageProvider( initialIndex: widget.index, images: widget.images .map( (e) => ImageBlockData( url: e.url, type: e.uploadType.toCustomImageType(), ), ) .toList(), onDeleteImage: (index) { final deleteFile = widget.images[index]; context.read().deleteFile(deleteFile.id); }, ), ), ); } } class MediaMenuItem extends StatelessWidget { const MediaMenuItem({ super.key, required this.onTap, required this.icon, required this.label, }); final VoidCallback onTap; final FlowySvgData icon; final String label; @override Widget build(BuildContext context) { return FlowyButton( onTap: onTap, leftIcon: FlowySvg(icon), text: Padding( padding: const EdgeInsets.only(left: 4, top: 1, bottom: 1), child: FlowyText.regular( label, figmaLineHeight: 20, ), ), hoverColor: AFThemeExtension.of(context).lightGreyHover, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart ================================================ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MobileChecklistCellEditScreen extends StatefulWidget { const MobileChecklistCellEditScreen({super.key}); @override State createState() => _MobileChecklistCellEditScreenState(); } class _MobileChecklistCellEditScreenState extends State { @override Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints.tightFor(height: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ const DragHandle(), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: _buildHeader(context), ), const Divider(), const Expanded(child: _TaskList()), ], ), ); } Widget _buildHeader(BuildContext context) { return Stack( children: [ SizedBox( height: 44.0, child: Align( child: FlowyText.medium( LocaleKeys.grid_field_checklistFieldName.tr(), fontSize: 18, ), ), ), ], ); } } class _TaskList extends StatelessWidget { const _TaskList(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final cells = []; cells.addAll( state.tasks .mapIndexed( (index, task) => _ChecklistItem( key: ValueKey('mobile_checklist_task_${task.data.id}'), task: task, index: index, autofocus: state.phantomIndex != null && index == state.tasks.length - 1, onAutofocus: () { context .read() .add(const ChecklistCellEvent.updatePhantomIndex(null)); }, ), ) .toList(), ); cells.add( const _NewTaskButton(key: ValueKey('mobile_checklist_new_task')), ); return ReorderableListView.builder( shrinkWrap: true, proxyDecorator: (child, index, _) => Material( color: Colors.transparent, child: BlocProvider.value( value: context.read(), child: child, ), ), buildDefaultDragHandles: false, itemCount: cells.length, itemBuilder: (_, index) => cells[index], padding: const EdgeInsets.only(bottom: 12.0), onReorder: (from, to) { context .read() .add(ChecklistCellEvent.reorderTask(from, to)); }, ); }, ); } } class _ChecklistItem extends StatefulWidget { const _ChecklistItem({ super.key, required this.task, required this.index, required this.autofocus, this.onAutofocus, }); final ChecklistSelectOption task; final int index; final bool autofocus; final VoidCallback? onAutofocus; @override State<_ChecklistItem> createState() => _ChecklistItemState(); } class _ChecklistItemState extends State<_ChecklistItem> { late final TextEditingController textController; final FocusNode focusNode = FocusNode(); Timer? _debounceOnChanged; @override void initState() { super.initState(); textController = TextEditingController(text: widget.task.data.name); if (widget.autofocus) { WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); widget.onAutofocus?.call(); }); } } @override void didUpdateWidget(covariant oldWidget) { super.didUpdateWidget(oldWidget); if (widget.task.data.name != oldWidget.task.data.name && !focusNode.hasFocus) { textController.text = widget.task.data.name; } } @override void dispose() { textController.dispose(); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4), constraints: const BoxConstraints(minHeight: 44), child: Row( children: [ ReorderableDelayedDragStartListener( index: widget.index, child: InkWell( borderRadius: BorderRadius.circular(22), onTap: () => context .read() .add(ChecklistCellEvent.selectTask(widget.task.data.id)), child: SizedBox.square( dimension: 44, child: Center( child: FlowySvg( widget.task.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, size: const Size.square(20.0), blendMode: BlendMode.dst, ), ), ), ), ), Expanded( child: TextField( controller: textController, focusNode: focusNode, style: Theme.of(context).textTheme.bodyMedium, keyboardType: TextInputType.multiline, maxLines: null, decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, isCollapsed: true, isDense: true, contentPadding: const EdgeInsets.symmetric(vertical: 12), hintText: LocaleKeys.grid_checklist_taskHint.tr(), ), onChanged: _debounceOnChangedText, onSubmitted: (description) { _submitUpdateTaskDescription(description); }, ), ), InkWell( borderRadius: BorderRadius.circular(22), onTap: _showDeleteTaskBottomSheet, child: SizedBox.square( dimension: 44, child: Center( child: FlowySvg( FlowySvgs.three_dots_s, color: Theme.of(context).hintColor, ), ), ), ), ], ), ); } void _debounceOnChangedText(String text) { _debounceOnChanged?.cancel(); _debounceOnChanged = Timer(const Duration(milliseconds: 300), () { _submitUpdateTaskDescription(text); }); } void _submitUpdateTaskDescription(String description) { context.read().add( ChecklistCellEvent.updateTaskName( widget.task.data, description.trim(), ), ); } void _showDeleteTaskBottomSheet() { showMobileBottomSheet( context, showDragHandle: true, builder: (_) => Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: InkWell( onTap: () { context.read().add( ChecklistCellEvent.deleteTask(widget.task.data.id), ); context.pop(); }, borderRadius: BorderRadius.circular(12), child: Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ FlowySvg( FlowySvgs.m_delete_m, size: const Size.square(20), color: Theme.of(context).colorScheme.error, ), const HSpace(8), FlowyText( LocaleKeys.button_delete.tr(), fontSize: 15, color: Theme.of(context).colorScheme.error, ), ], ), ), ), ), const Divider(height: 9), ], ), ); } } class _NewTaskButton extends StatelessWidget { const _NewTaskButton({super.key}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () { context .read() .add(const ChecklistCellEvent.updatePhantomIndex(-1)); context .read() .add(const ChecklistCellEvent.createNewTask("")); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13), child: Row( children: [ const FlowySvg(FlowySvgs.add_s, size: Size.square(20)), const HSpace(11), FlowyText(LocaleKeys.grid_checklist_addNew.tr(), fontSize: 15), ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MobileMediaCellEditor extends StatelessWidget { const MobileMediaCellEditor({super.key}); @override Widget build(BuildContext context) { return Container( constraints: const BoxConstraints.tightFor(height: 420), child: BlocProvider.value( value: context.read(), child: BlocBuilder( builder: (context, state) => Column( mainAxisSize: MainAxisSize.min, children: [ const DragHandle(), SizedBox( height: 46.0, child: Stack( children: [ Align( child: FlowyText.medium( LocaleKeys.grid_field_mediaFieldName.tr(), fontSize: 18, ), ), Positioned( top: 8, right: 18, child: GestureDetector( onTap: () => showMobileBottomSheet( context, title: LocaleKeys.grid_media_addFileMobile.tr(), showHeader: true, showCloseButton: true, showDragHandle: true, builder: (dContext) => BlocProvider.value( value: context.read(), child: MobileMediaUploadSheetContent( dialogContext: dContext, ), ), ), child: const FlowySvg( FlowySvgs.add_m, size: Size.square(28), ), ), ), ], ), ), const Divider(height: 0.5), Expanded( child: SingleChildScrollView( child: Column( children: [ if (state.files.isNotEmpty) const Divider(height: .5), ...state.files.map( (file) => Padding( padding: const EdgeInsets.only(bottom: 2), child: _FileItem(key: Key(file.id), file: file), ), ), ], ), ), ), ], ), ), ), ); } } class _FileItem extends StatelessWidget { const _FileItem({super.key, required this.file}); final MediaFilePB file; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).scaffoldBackgroundColor, ), child: ListTile( contentPadding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), title: Row( crossAxisAlignment: file.fileType == MediaFileTypePB.Image ? CrossAxisAlignment.start : CrossAxisAlignment.center, children: [ if (file.fileType != MediaFileTypePB.Image) ...[ Flexible( child: GestureDetector( onTap: () => afLaunchUrlString(file.url), child: Row( children: [ FlowySvg(file.fileType.icon, size: const Size.square(24)), const HSpace(12), Expanded( child: FlowyText( file.name, overflow: TextOverflow.ellipsis, ), ), ], ), ), ), ] else ...[ Expanded( child: Container( alignment: Alignment.centerLeft, constraints: const BoxConstraints(maxHeight: 96), child: GestureDetector( onTap: () => openInteractiveViewer(context), child: ImageRender( userProfile: context.read().state.userProfile, fit: BoxFit.fitHeight, borderRadius: BorderRadius.zero, image: ImageBlockData( url: file.url, type: file.uploadType.toCustomImageType(), ), ), ), ), ), ], FlowyIconButton( width: 20, icon: const FlowySvg( FlowySvgs.three_dots_s, size: Size.square(20), ), onPressed: () => showMobileBottomSheet( context, backgroundColor: Theme.of(context).colorScheme.surface, showDragHandle: true, builder: (_) => BlocProvider.value( value: context.read(), child: _EditFileSheet(file: file), ), ), ), const HSpace(6), ], ), ), ); } void openInteractiveViewer(BuildContext context) => openInteractiveViewerFromFile( context, file, onDeleteImage: (_) => context.read().deleteFile(file.id), userProfile: context.read().state.userProfile, ); } class _EditFileSheet extends StatefulWidget { const _EditFileSheet({required this.file}); final MediaFilePB file; @override State<_EditFileSheet> createState() => _EditFileSheetState(); } class _EditFileSheetState extends State<_EditFileSheet> { late final controller = TextEditingController(text: widget.file.name); Loading? loader; MediaFilePB get file => widget.file; @override void dispose() { controller.dispose(); loader?.stop(); super.dispose(); } @override Widget build(BuildContext context) { return SingleChildScrollView( child: Column( children: [ const VSpace(16), if (file.fileType == MediaFileTypePB.Image) ...[ FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_media_expand.tr(), leftIcon: const FlowySvg( FlowySvgs.full_view_s, size: Size.square(20), ), onTap: () => openInteractiveViewer(context), ), FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_media_setAsCover.tr(), leftIcon: const FlowySvg( FlowySvgs.cover_s, size: Size.square(20), ), onTap: () => context.read().add( MediaCellEvent.setCover( RowCoverPB( data: file.url, uploadType: file.uploadType, coverType: CoverTypePB.FileCover, ), ), ), ), ], FlowyOptionTile.text( showTopBorder: file.fileType == MediaFileTypePB.Image, text: LocaleKeys.grid_media_openInBrowser.tr(), leftIcon: const FlowySvg( FlowySvgs.open_in_browser_s, size: Size.square(20), ), onTap: () => afLaunchUrlString(file.url), ), // TODO(Mathias): Rename interaction need design // FlowyOptionTile.text( // text: LocaleKeys.grid_media_rename.tr(), // leftIcon: const FlowySvg( // FlowySvgs.rename_s, // size: Size.square(20), // ), // onTap: () {}, // ), if (widget.file.uploadType == FileUploadTypePB.CloudFile) ...[ FlowyOptionTile.text( onTap: () async => downloadMediaFile( context, file, userProfile: context.read().state.userProfile, onDownloadBegin: () { loader?.stop(); loader = Loading(context); loader?.start(); }, onDownloadEnd: () => loader?.stop(), ), text: LocaleKeys.button_download.tr(), leftIcon: const FlowySvg( FlowySvgs.save_as_s, size: Size.square(20), ), ), ], FlowyOptionTile.text( text: LocaleKeys.grid_media_delete.tr(), textColor: Theme.of(context).colorScheme.error, leftIcon: FlowySvg( FlowySvgs.trash_s, size: const Size.square(20), color: Theme.of(context).colorScheme.error, ), onTap: () { context.pop(); context.read().deleteFile(file.id); }, ), ], ), ); } void openInteractiveViewer(BuildContext context) => openInteractiveViewerFromFile( context, file, onDeleteImage: (_) => context.read().deleteFile(file.id), userProfile: context.read().state.userProfile, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/base/option_color_list.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:protobuf/protobuf.dart'; // include single select and multiple select class MobileSelectOptionEditor extends StatefulWidget { const MobileSelectOptionEditor({ super.key, required this.cellController, }); final SelectOptionCellController cellController; @override State createState() => _MobileSelectOptionEditorState(); } class _MobileSelectOptionEditorState extends State { final searchController = TextEditingController(); final renameController = TextEditingController(); String typingOption = ''; FieldType get fieldType => widget.cellController.fieldType; bool showMoreOptions = false; SelectOptionPB? option; @override void dispose() { searchController.dispose(); renameController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints.tightFor(height: 420), child: BlocProvider( create: (context) => SelectOptionCellEditorBloc( cellController: widget.cellController, ), child: BlocBuilder( builder: (context, state) { return Column( mainAxisSize: MainAxisSize.min, children: [ const DragHandle(), _buildHeader(context), const Divider(height: 0.5), Expanded( child: Padding( padding: EdgeInsets.symmetric( horizontal: showMoreOptions ? 0.0 : 16.0, ), child: _buildBody(context), ), ), ], ); }, ), ), ); } Widget _buildHeader(BuildContext context) { const height = 44.0; return Stack( children: [ if (showMoreOptions) Align( alignment: Alignment.centerLeft, child: AppBarBackButton(onTap: _popOrBack), ), SizedBox( height: 44.0, child: Align( child: FlowyText.medium( _headerTitle(), fontSize: 18, ), ), ), ].map((e) => SizedBox(height: height, child: e)).toList(), ); } Widget _buildBody(BuildContext context) { if (showMoreOptions && option != null) { return _MoreOptions( initialOption: option!, controller: renameController, onDelete: () { context .read() .add(SelectOptionCellEditorEvent.deleteOption(option!)); _popOrBack(); }, onUpdate: (name, color) { final option = this.option; if (option == null) { return; } option.freeze(); context.read().add( SelectOptionCellEditorEvent.updateOption( option.rebuild((p0) { if (name != null) { p0.name = name; } if (color != null) { p0.color = color; } }), ), ); }, ); } return SingleChildScrollView( child: Column( children: [ const VSpace(16), _SearchField( controller: searchController, hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(), onSubmitted: (_) { context .read() .add(const SelectOptionCellEditorEvent.submitTextField()); searchController.clear(); }, onChanged: (value) { typingOption = value; context.read().add( SelectOptionCellEditorEvent.selectMultipleOptions( [], value, ), ); }, ), const VSpace(22), _OptionList( fieldType: widget.cellController.fieldType, onCreateOption: (optionName) { context .read() .add(const SelectOptionCellEditorEvent.createOption()); searchController.clear(); }, onCheck: (option, isSelected) { if (isSelected) { context .read() .add(SelectOptionCellEditorEvent.unselectOption(option.id)); } else { context .read() .add(SelectOptionCellEditorEvent.selectOption(option.id)); } }, onMoreOptions: (option) { setState(() { this.option = option; renameController.text = option.name; showMoreOptions = true; }); }, ), ], ), ); } String _headerTitle() { switch (fieldType) { case FieldType.SingleSelect: return LocaleKeys.grid_field_singleSelectFieldName.tr(); case FieldType.MultiSelect: return LocaleKeys.grid_field_multiSelectFieldName.tr(); default: throw UnimplementedError(); } } void _popOrBack() { if (showMoreOptions) { setState(() { showMoreOptions = false; option = null; }); } else { context.pop(); } } } class _SearchField extends StatelessWidget { const _SearchField({ this.hintText, required this.onChanged, required this.onSubmitted, required this.controller, }); final String? hintText; final void Function(String value) onChanged; final void Function(String value) onSubmitted; final TextEditingController controller; @override Widget build(BuildContext context) { return FlowyMobileSearchTextField( controller: controller, onChanged: onChanged, onSubmitted: onSubmitted, hintText: hintText, ); } } class _OptionList extends StatelessWidget { const _OptionList({ required this.fieldType, required this.onCreateOption, required this.onCheck, required this.onMoreOptions, }); final FieldType fieldType; final void Function(String optionName) onCreateOption; final void Function(SelectOptionPB option, bool value) onCheck; final void Function(SelectOptionPB option) onMoreOptions; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { // existing options final List cells = []; // create an option cell if (state.createSelectOptionSuggestion != null) { cells.add( _CreateOptionCell( name: state.createSelectOptionSuggestion!.name, color: state.createSelectOptionSuggestion!.color, onTap: () => onCreateOption( state.createSelectOptionSuggestion!.name, ), ), ); } cells.addAll( state.options.map( (option) => MobileSelectOption( indicator: fieldType == FieldType.MultiSelect ? MobileSelectedOptionIndicator.multi : MobileSelectedOptionIndicator.single, option: option, isSelected: state.selectedOptions.contains(option), onTap: (value) => onCheck(option, value), onMoreOptions: () => onMoreOptions(option), ), ), ); return ListView.separated( shrinkWrap: true, itemCount: cells.length, separatorBuilder: (_, __) => const VSpace(20), physics: const NeverScrollableScrollPhysics(), itemBuilder: (_, int index) => cells[index], padding: const EdgeInsets.only(bottom: 12.0), ); }, ); } } class MobileSelectOption extends StatelessWidget { const MobileSelectOption({ super.key, required this.indicator, required this.option, required this.isSelected, required this.onTap, this.showMoreOptionsButton = true, this.onMoreOptions, }); final MobileSelectedOptionIndicator indicator; final SelectOptionPB option; final bool isSelected; final void Function(bool value) onTap; final bool showMoreOptionsButton; final VoidCallback? onMoreOptions; @override Widget build(BuildContext context) { return SizedBox( height: 40, child: GestureDetector( // no need to add click effect, so using gesture detector behavior: HitTestBehavior.translucent, onTap: () => onTap(isSelected), child: Row( children: [ // checked or selected icon SizedBox( height: 20, width: 20, child: _IsSelectedIndicator( indicator: indicator, isSelected: isSelected, ), ), // padding const HSpace(12), // option tag Expanded( child: Align( alignment: AlignmentDirectional.centerStart, child: SelectOptionTag( option: option, padding: const EdgeInsets.symmetric( vertical: 10, horizontal: 14, ), textAlign: TextAlign.center, fontSize: 15.0, ), ), ), if (showMoreOptionsButton) ...[ const HSpace(24), // more options FlowyIconButton( icon: const FlowySvg( FlowySvgs.m_field_more_s, ), onPressed: onMoreOptions, ), ], ], ), ), ); } } class _CreateOptionCell extends StatelessWidget { const _CreateOptionCell({ required this.name, required this.color, required this.onTap, }); final String name; final SelectOptionColorPB color; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( height: 44, child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: onTap, child: Row( children: [ FlowyText( LocaleKeys.grid_selectOption_create.tr(), color: Theme.of(context).hintColor, ), const HSpace(8), Expanded( child: Align( alignment: AlignmentDirectional.centerStart, child: SelectOptionTag( name: name, color: color.toColor(context), textAlign: TextAlign.center, padding: const EdgeInsets.symmetric( vertical: 10, horizontal: 14, ), ), ), ), ], ), ), ); } } class _MoreOptions extends StatefulWidget { const _MoreOptions({ required this.initialOption, required this.onDelete, required this.onUpdate, required this.controller, }); final SelectOptionPB initialOption; final VoidCallback onDelete; final void Function(String? name, SelectOptionColorPB? color) onUpdate; final TextEditingController controller; @override State<_MoreOptions> createState() => _MoreOptionsState(); } class _MoreOptionsState extends State<_MoreOptions> { late SelectOptionPB option = widget.initialOption; @override Widget build(BuildContext context) { return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildRenameTextField(context), const VSpace(16.0), _buildDeleteButton(context), const VSpace(16.0), Padding( padding: const EdgeInsets.only(left: 12.0), child: FlowyText( LocaleKeys.grid_selectOption_colorPanelTitle.tr().toUpperCase(), color: Theme.of(context).hintColor, fontSize: 13, ), ), const VSpace(4.0), FlowyOptionDecorateBox( child: Padding( padding: const EdgeInsets.symmetric( vertical: 12.0, horizontal: 6.0, ), child: OptionColorList( selectedColor: option.color, onSelectedColor: (color) { widget.onUpdate(null, color); setState(() { option.freeze(); option = option.rebuild((option) => option.color = color); }); }, ), ), ), ], ), ); } Widget _buildRenameTextField(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints.tightFor(height: 52.0), child: FlowyOptionTile.textField( showTopBorder: false, onTextChanged: (name) => widget.onUpdate(name, null), controller: widget.controller, ), ); } Widget _buildDeleteButton(BuildContext context) { return FlowyOptionTile.text( text: LocaleKeys.button_delete.tr(), textColor: Theme.of(context).colorScheme.error, leftIcon: FlowySvg( FlowySvgs.m_delete_s, color: Theme.of(context).colorScheme.error, ), onTap: widget.onDelete, ); } } enum MobileSelectedOptionIndicator { single, multi } class _IsSelectedIndicator extends StatelessWidget { const _IsSelectedIndicator({ required this.indicator, required this.isSelected, }); final MobileSelectedOptionIndicator indicator; final bool isSelected; @override Widget build(BuildContext context) { return isSelected ? DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of(context).colorScheme.primary, ), child: Center( child: indicator == MobileSelectedOptionIndicator.multi ? FlowySvg( FlowySvgs.checkmark_tiny_s, color: Theme.of(context).colorScheme.onPrimary, ) : Container( width: 7.5, height: 7.5, decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of(context).colorScheme.onPrimary, ), ), ), ) : DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, border: Border.fromBorderSide( BorderSide( color: Theme.of(context).dividerColor, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/database/widgets/row/relation_row_detail.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../application/cell/bloc/relation_cell_bloc.dart'; import '../../application/cell/bloc/relation_row_search_bloc.dart'; class RelationCellEditor extends StatelessWidget { const RelationCellEditor({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, cellState) { return cellState.relatedDatabaseMeta == null ? const _RelationCellEditorDatabasePicker() : _RelationCellEditorContent( relatedDatabaseMeta: cellState.relatedDatabaseMeta!, selectedRowIds: cellState.rows.map((e) => e.rowId).toList(), ); }, ); } } class _RelationCellEditorContent extends StatefulWidget { const _RelationCellEditorContent({ required this.relatedDatabaseMeta, required this.selectedRowIds, }); final DatabaseMeta relatedDatabaseMeta; final List selectedRowIds; @override State<_RelationCellEditorContent> createState() => _RelationCellEditorContentState(); } class _RelationCellEditorContentState extends State<_RelationCellEditorContent> { final textEditingController = TextEditingController(); late final FocusNode focusNode; late final bloc = RelationRowSearchBloc( databaseId: widget.relatedDatabaseMeta.databaseId, ); @override void initState() { super.initState(); focusNode = FocusNode( onKeyEvent: (node, event) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowUp when event is! KeyUpEvent: if (textEditingController.value.composing.isCollapsed) { bloc.add(const RelationRowSearchEvent.focusPreviousOption()); return KeyEventResult.handled; } break; case LogicalKeyboardKey.arrowDown when event is! KeyUpEvent: if (textEditingController.value.composing.isCollapsed) { bloc.add(const RelationRowSearchEvent.focusNextOption()); return KeyEventResult.handled; } break; case LogicalKeyboardKey.escape when event is! KeyUpEvent: if (!textEditingController.value.composing.isCollapsed) { final end = textEditingController.value.composing.end; final text = textEditingController.text; textEditingController.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: end), ); return KeyEventResult.handled; } break; } return KeyEventResult.ignored; }, ); } @override void dispose() { textEditingController.dispose(); focusNode.dispose(); bloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider.value(value: bloc), BlocProvider.value(value: context.read()), ], child: BlocBuilder( buildWhen: (previous, current) => !listEquals(previous.filteredRows, current.filteredRows), builder: (context, state) { final selected = []; final unselected = []; for (final row in state.filteredRows) { if (widget.selectedRowIds.contains(row.rowId)) { selected.add(row); } else { unselected.add(row); } } return TextFieldTapRegion( child: CustomScrollView( shrinkWrap: true, slivers: [ _CellEditorTitle( databaseMeta: widget.relatedDatabaseMeta, ), _SearchField( focusNode: focusNode, textEditingController: textEditingController, ), const SliverToBoxAdapter( child: TypeOptionSeparator(spacing: 0.0), ), if (state.filteredRows.isEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(6.0) + GridSize.typeOptionContentInsets, child: FlowyText.regular( LocaleKeys.grid_relation_emptySearchResult.tr(), color: Theme.of(context).hintColor, ), ), ), if (selected.isNotEmpty) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0) + GridSize.typeOptionContentInsets, child: FlowyText.regular( LocaleKeys.grid_relation_linkedRowListLabel.plural( selected.length, namedArgs: {'count': '${selected.length}'}, ), fontSize: 11, overflow: TextOverflow.ellipsis, color: Theme.of(context).hintColor, ), ), ), _RowList( databaseId: widget.relatedDatabaseMeta.databaseId, rows: selected, isSelected: true, ), const SliverToBoxAdapter( child: VSpace(4.0), ), ], if (unselected.isNotEmpty) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0) + GridSize.typeOptionContentInsets, child: FlowyText.regular( LocaleKeys.grid_relation_unlinkedRowListLabel.tr(), fontSize: 11, overflow: TextOverflow.ellipsis, color: Theme.of(context).hintColor, ), ), ), _RowList( databaseId: widget.relatedDatabaseMeta.databaseId, rows: unselected, isSelected: false, ), const SliverToBoxAdapter( child: VSpace(4.0), ), ], ], ), ); }, ), ); } } class _CellEditorTitle extends StatelessWidget { const _CellEditorTitle({ required this.databaseMeta, }); final DatabaseMeta databaseMeta; @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0) + GridSize.typeOptionContentInsets, child: Row( mainAxisSize: MainAxisSize.min, children: [ FlowyText.regular( LocaleKeys.grid_relation_inRelatedDatabase.tr(), fontSize: 11, color: Theme.of(context).hintColor, ), MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => _openRelatedDatbase(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: FlowyText.regular( databaseMeta.databaseName, fontSize: 11, overflow: TextOverflow.ellipsis, decoration: TextDecoration.underline, ), ), ), ), ], ), ), ); } void _openRelatedDatbase(BuildContext context) { FolderEventGetView(ViewIdPB(value: databaseMeta.viewId)) .send() .then((result) { result.fold( (view) { PopoverContainer.of(context).closeAll(); Navigator.of(context).maybePop(); getIt().add( TabsEvent.openPlugin( plugin: DatabaseTabBarViewPlugin( view: view, pluginType: view.pluginType, ), ), ); }, (err) => Log.error(err), ); }); } } class _SearchField extends StatelessWidget { const _SearchField({ required this.focusNode, required this.textEditingController, }); final FocusNode focusNode; final TextEditingController textEditingController; @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 6.0, bottom: 6.0, right: 6.0), child: FlowyTextField( focusNode: focusNode, controller: textEditingController, hintText: LocaleKeys.grid_relation_rowSearchTextFieldPlaceholder.tr(), hintStyle: Theme.of(context) .textTheme .bodySmall ?.copyWith(color: Theme.of(context).hintColor), onChanged: (text) { if (textEditingController.value.composing.isCollapsed) { context .read() .add(RelationRowSearchEvent.updateFilter(text)); } }, onSubmitted: (_) { final focusedRowId = context.read().state.focusedRowId; if (focusedRowId != null) { final row = context .read() .state .rows .firstWhereOrNull((e) => e.rowId == focusedRowId); if (row != null) { FlowyOverlay.show( context: context, builder: (BuildContext overlayContext) { return BlocProvider.value( value: context.read(), child: RelatedRowDetailPage( databaseId: context .read() .state .relatedDatabaseMeta! .databaseId, rowId: row.rowId, ), ); }, ); PopoverContainer.of(context).close(); } else { context .read() .add(RelationCellEvent.selectRow(focusedRowId)); } } focusNode.requestFocus(); }, ), ), ); } } class _RowList extends StatelessWidget { const _RowList({ required this.databaseId, required this.rows, required this.isSelected, }); final String databaseId; final List rows; final bool isSelected; @override Widget build(BuildContext context) { return SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _RowListItem( row: rows[index], databaseId: databaseId, isSelected: isSelected, ), childCount: rows.length, ), ); } } class _RowListItem extends StatelessWidget { const _RowListItem({ required this.row, required this.isSelected, required this.databaseId, }); final RelatedRowDataPB row; final String databaseId; final bool isSelected; @override Widget build(BuildContext context) { final isHovered = context.watch().state.focusedRowId == row.rowId; return Container( height: 28, margin: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 2.0), decoration: BoxDecoration( color: isHovered ? AFThemeExtension.of(context).lightGreyHover : null, borderRadius: const BorderRadius.all(Radius.circular(6)), ), child: GestureDetector( onTap: () { final userWorkspaceBloc = context.read(); if (isSelected) { FlowyOverlay.show( context: context, builder: (BuildContext overlayContext) { return BlocProvider.value( value: userWorkspaceBloc, child: RelatedRowDetailPage( databaseId: databaseId, rowId: row.rowId, ), ); }, ); PopoverContainer.of(context).close(); } else { context .read() .add(RelationCellEvent.selectRow(row.rowId)); } }, child: MouseRegion( cursor: SystemMouseCursors.click, onHover: (_) => context .read() .add(RelationRowSearchEvent.updateFocusedOption(row.rowId)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4.0), child: Row( children: [ Expanded( child: FlowyText( row.name.trim().isEmpty ? LocaleKeys.grid_title_placeholder.tr() : row.name, color: row.name.trim().isEmpty ? Theme.of(context).hintColor : null, overflow: TextOverflow.ellipsis, ), ), if (isSelected && isHovered) _UnselectRowButton( onPressed: () => context .read() .add(RelationCellEvent.selectRow(row.rowId)), ), ], ), ), ), ), ); } } class _UnselectRowButton extends StatefulWidget { const _UnselectRowButton({ required this.onPressed, }); final VoidCallback onPressed; @override State<_UnselectRowButton> createState() => _UnselectRowButtonState(); } class _UnselectRowButtonState extends State<_UnselectRowButton> { final _materialStatesController = WidgetStatesController(); @override void dispose() { _materialStatesController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return TextButton( onPressed: widget.onPressed, onHover: (_) => setState(() {}), onFocusChange: (_) => setState(() {}), style: ButtonStyle( fixedSize: const WidgetStatePropertyAll(Size.square(32)), minimumSize: const WidgetStatePropertyAll(Size.square(32)), maximumSize: const WidgetStatePropertyAll(Size.square(32)), overlayColor: WidgetStateProperty.resolveWith((state) { if (state.contains(WidgetState.focused)) { return AFThemeExtension.of(context).greyHover; } return Colors.transparent; }), shape: const WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: Corners.s6Border), ), ), statesController: _materialStatesController, child: Container( color: _materialStatesController.value.contains(WidgetState.hovered) || _materialStatesController.value.contains(WidgetState.focused) ? Theme.of(context).colorScheme.primary : AFThemeExtension.of(context).onBackground, width: 12, height: 1, ), ); } } class _RelationCellEditorDatabasePicker extends StatelessWidget { const _RelationCellEditorDatabasePicker(); @override Widget build(BuildContext context) { return BlocProvider( create: (_) => RelationDatabaseListCubit(), child: BlocBuilder( builder: (context, state) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(6, 6, 6, 0), child: FlowyText( LocaleKeys.grid_relation_noDatabaseSelected.tr(), maxLines: null, fontSize: 10, color: Theme.of(context).hintColor, ), ), Flexible( child: ListView.separated( padding: const EdgeInsets.all(6), separatorBuilder: (context, index) => VSpace(GridSize.typeOptionSeparatorHeight), itemCount: state.databaseMetas.length, shrinkWrap: true, itemBuilder: (context, index) { final databaseMeta = state.databaseMetas[index]; return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( onTap: () => context.read().add( RelationCellEvent.selectDatabaseId( databaseMeta.databaseId, ), ), text: FlowyText( databaseMeta.databaseName, overflow: TextOverflow.ellipsis, ), ), ); }, ), ), ], ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart ================================================ import 'dart:collection'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../grid/presentation/widgets/common/type_option_separator.dart'; import '../field/type_option_editor/select/select_option_editor.dart'; import 'extension.dart'; import 'select_option_text_field.dart'; const double _editorPanelWidth = 300; class SelectOptionCellEditor extends StatefulWidget { const SelectOptionCellEditor({super.key, required this.cellController}); final SelectOptionCellController cellController; @override State createState() => _SelectOptionCellEditorState(); } class _SelectOptionCellEditorState extends State { final textEditingController = TextEditingController(); final scrollController = ScrollController(); final popoverMutex = PopoverMutex(); late final bloc = SelectOptionCellEditorBloc( cellController: widget.cellController, ); late final FocusNode focusNode; @override void initState() { super.initState(); focusNode = FocusNode( onKeyEvent: (node, event) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowUp when event is! KeyUpEvent: if (textEditingController.value.composing.isCollapsed) { bloc.add(const SelectOptionCellEditorEvent.focusPreviousOption()); return KeyEventResult.handled; } break; case LogicalKeyboardKey.arrowDown when event is! KeyUpEvent: if (textEditingController.value.composing.isCollapsed) { bloc.add(const SelectOptionCellEditorEvent.focusNextOption()); return KeyEventResult.handled; } break; case LogicalKeyboardKey.escape when event is! KeyUpEvent: if (!textEditingController.value.composing.isCollapsed) { final end = textEditingController.value.composing.end; final text = textEditingController.text; textEditingController.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: end), ); return KeyEventResult.handled; } break; case LogicalKeyboardKey.backspace when event is KeyUpEvent: if (!textEditingController.text.isNotEmpty) { bloc.add(const SelectOptionCellEditorEvent.unselectLastOption()); return KeyEventResult.handled; } break; } return KeyEventResult.ignored; }, ); } @override void dispose() { popoverMutex.dispose(); textEditingController.dispose(); scrollController.dispose(); bloc.close(); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: bloc, child: TextFieldTapRegion( child: Column( mainAxisSize: MainAxisSize.min, children: [ _TextField( textEditingController: textEditingController, scrollController: scrollController, focusNode: focusNode, popoverMutex: popoverMutex, ), const TypeOptionSeparator(spacing: 0.0), Flexible( child: Focus( descendantsAreFocusable: false, child: _OptionList( textEditingController: textEditingController, popoverMutex: popoverMutex, ), ), ), ], ), ), ); } } class _OptionList extends StatelessWidget { const _OptionList({ required this.textEditingController, required this.popoverMutex, }); final TextEditingController textEditingController; final PopoverMutex popoverMutex; @override Widget build(BuildContext context) { return BlocConsumer( listenWhen: (prev, curr) => prev.clearFilter != curr.clearFilter, listener: (context, state) { if (state.clearFilter) { textEditingController.clear(); context .read() .add(const SelectOptionCellEditorEvent.resetClearFilterFlag()); } }, buildWhen: (previous, current) => !listEquals(previous.options, current.options) || previous.createSelectOptionSuggestion != current.createSelectOptionSuggestion, builder: (context, state) => ReorderableListView.builder( shrinkWrap: true, proxyDecorator: (child, index, _) => Material( color: Colors.transparent, child: Stack( children: [ BlocProvider.value( value: context.read(), child: child, ), MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grabbing, child: const SizedBox.expand(), ), ], ), ), buildDefaultDragHandles: false, itemCount: state.options.length, onReorderStart: (_) => popoverMutex.close(), itemBuilder: (_, int index) { final option = state.options[index]; return _SelectOptionCell( key: ValueKey("select_cell_option_list_${option.id}"), index: index, option: option, popoverMutex: popoverMutex, ); }, onReorder: (oldIndex, newIndex) { if (oldIndex < newIndex) { newIndex--; } final fromOptionId = state.options[oldIndex].id; final toOptionId = state.options[newIndex].id; context.read().add( SelectOptionCellEditorEvent.reorderOption( fromOptionId, toOptionId, ), ); }, header: Padding( padding: EdgeInsets.only( bottom: state.createSelectOptionSuggestion != null || state.options.isNotEmpty ? 12 : 0, ), child: const _Title(), ), footer: state.createSelectOptionSuggestion != null ? _CreateOptionCell( suggestion: state.createSelectOptionSuggestion!, ) : null, padding: const EdgeInsets.symmetric(vertical: 8), ), ); } } class _TextField extends StatelessWidget { const _TextField({ required this.textEditingController, required this.scrollController, required this.focusNode, required this.popoverMutex, }); final TextEditingController textEditingController; final ScrollController scrollController; final FocusNode focusNode; final PopoverMutex popoverMutex; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final optionMap = LinkedHashMap.fromIterable( state.selectedOptions, key: (option) => option.name, value: (option) => option, ); return Material( color: Colors.transparent, child: Padding( padding: const EdgeInsets.all(12.0), child: SelectOptionTextField( options: state.options, focusNode: focusNode, selectedOptionMap: optionMap, distanceToText: _editorPanelWidth * 0.7, textController: textEditingController, scrollController: scrollController, textSeparators: const [','], onClick: () => popoverMutex.close(), newText: (text) => context .read() .add(SelectOptionCellEditorEvent.filterOption(text)), onSubmitted: () { context .read() .add(const SelectOptionCellEditorEvent.submitTextField()); focusNode.requestFocus(); }, onPaste: (tagNames, remainder) { context.read().add( SelectOptionCellEditorEvent.selectMultipleOptions( tagNames, remainder, ), ); }, onRemove: (name) => context.read().add( SelectOptionCellEditorEvent.unselectOption( optionMap[name]!.id, ), ), ), ), ); }, ); } } class _Title extends StatelessWidget { const _Title(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: FlowyText.regular( LocaleKeys.grid_selectOption_panelTitle.tr(), color: Theme.of(context).hintColor, ), ); } } class _SelectOptionCell extends StatefulWidget { const _SelectOptionCell({ super.key, required this.option, required this.index, required this.popoverMutex, }); final SelectOptionPB option; final int index; final PopoverMutex popoverMutex; @override State<_SelectOptionCell> createState() => _SelectOptionCellState(); } class _SelectOptionCellState extends State<_SelectOptionCell> { final _popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( controller: _popoverController, offset: const Offset(8, 0), margin: EdgeInsets.zero, asBarrier: true, constraints: BoxConstraints.loose(const Size(200, 470)), mutex: widget.popoverMutex, clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (popoverContext) => SelectOptionEditor( key: ValueKey(widget.option.id), option: widget.option, onDeleted: () { context .read() .add(SelectOptionCellEditorEvent.deleteOption(widget.option)); PopoverContainer.of(popoverContext).close(); }, onUpdated: (updatedOption) => context .read() .add(SelectOptionCellEditorEvent.updateOption(updatedOption)), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), child: MouseRegion( onEnter: (_) => context.read().add( SelectOptionCellEditorEvent.updateFocusedOption( widget.option.id, ), ), child: Container( height: 28, decoration: BoxDecoration( color: context .watch() .state .focusedOptionId == widget.option.id ? AFThemeExtension.of(context).lightGreyHover : null, borderRadius: const BorderRadius.all(Radius.circular(6)), ), child: SelectOptionTagCell( option: widget.option, index: widget.index, onSelected: _onTap, children: [ if (context .watch() .state .selectedOptions .contains(widget.option)) FlowyIconButton( width: 20, hoverColor: Colors.transparent, onPressed: _onTap, icon: FlowySvg( FlowySvgs.check_s, color: Theme.of(context).iconTheme.color, ), ), FlowyIconButton( onPressed: () => _popoverController.show(), iconPadding: const EdgeInsets.symmetric(horizontal: 6.0), hoverColor: Colors.transparent, icon: FlowySvg( FlowySvgs.three_dots_s, size: const Size.square(16), color: AFThemeExtension.of(context).onBackground, ), ), ], ), ), ), ), ); } void _onTap() { widget.popoverMutex.close(); final bloc = context.read(); if (bloc.state.selectedOptions.contains(widget.option)) { bloc.add(SelectOptionCellEditorEvent.unselectOption(widget.option.id)); } else { bloc.add(SelectOptionCellEditorEvent.selectOption(widget.option.id)); } } } class SelectOptionTagCell extends StatelessWidget { const SelectOptionTagCell({ super.key, required this.option, required this.onSelected, this.children = const [], this.index, }); final SelectOptionPB option; final VoidCallback onSelected; final List children; final int? index; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (index != null) ReorderableDragStartListener( index: index!, child: MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grab, child: GestureDetector( onTap: onSelected, child: SizedBox( width: 26, child: Center( child: FlowySvg( FlowySvgs.drag_element_s, size: const Size.square(14), color: AFThemeExtension.of(context).onBackground, ), ), ), ), ), ), Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onSelected, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 6.0), child: SelectOptionTag( fontSize: 14, option: option, padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), ), ), ), ), ), ...children, ], ); } } class _CreateOptionCell extends StatelessWidget { const _CreateOptionCell({required this.suggestion}); final CreateSelectOptionSuggestion suggestion; @override Widget build(BuildContext context) { return Container( height: 32, margin: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: context.watch().state.focusedOptionId == createSelectOptionSuggestionId ? AFThemeExtension.of(context).lightGreyHover : null, borderRadius: const BorderRadius.all(Radius.circular(6)), ), child: GestureDetector( onTap: () => context .read() .add(const SelectOptionCellEditorEvent.createOption()), child: MouseRegion( onEnter: (_) { context.read().add( const SelectOptionCellEditorEvent.updateFocusedOption( createSelectOptionSuggestionId, ), ); }, child: Row( children: [ FlowyText( LocaleKeys.grid_selectOption_create.tr(), color: Theme.of(context).hintColor, ), const HSpace(10), Expanded( child: Align( alignment: Alignment.centerLeft, child: SelectOptionTag( name: suggestion.name, color: suggestion.color.toColor(context), fontSize: 14, padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), ), ), ), ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart ================================================ import 'dart:collection'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:flowy_infra/size.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'extension.dart'; class SelectOptionTextField extends StatefulWidget { const SelectOptionTextField({ super.key, required this.options, required this.selectedOptionMap, required this.distanceToText, required this.textSeparators, required this.textController, required this.focusNode, required this.onSubmitted, required this.newText, required this.onPaste, required this.onRemove, this.scrollController, this.onClick, }); final List options; final LinkedHashMap selectedOptionMap; final double distanceToText; final List textSeparators; final TextEditingController textController; final ScrollController? scrollController; final FocusNode focusNode; final Function() onSubmitted; final Function(String) newText; final Function(List, String) onPaste; final Function(String) onRemove; final VoidCallback? onClick; @override State createState() => _SelectOptionTextFieldState(); } class _SelectOptionTextFieldState extends State { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { widget.focusNode.requestFocus(); _scrollToEnd(); }); widget.textController.addListener(_onChanged); } @override void didUpdateWidget(covariant oldWidget) { if (oldWidget.selectedOptionMap.length < widget.selectedOptionMap.length) { WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToEnd(); }); } if (oldWidget.textController != widget.textController) { oldWidget.textController.removeListener(_onChanged); widget.textController.addListener(_onChanged); } super.didUpdateWidget(oldWidget); } @override void dispose() { widget.textController.removeListener(_onChanged); super.dispose(); } @override Widget build(BuildContext context) { return TextField( controller: widget.textController, focusNode: widget.focusNode, onTap: widget.onClick, onSubmitted: (_) => widget.onSubmitted(), style: Theme.of(context).textTheme.bodyMedium, decoration: InputDecoration( enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: Theme.of(context).colorScheme.outline), borderRadius: Corners.s10Border, ), isDense: true, prefixIcon: _renderTags(context), prefixIconConstraints: BoxConstraints(maxWidth: widget.distanceToText), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), borderRadius: Corners.s10Border, ), ), ); } void _scrollToEnd() { if (widget.scrollController?.hasClients ?? false) { widget.scrollController?.animateTo( widget.scrollController!.position.maxScrollExtent, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, ); } } void _onChanged() { if (!widget.textController.value.composing.isCollapsed) { return; } // split input final (submitted, remainder) = splitInput( widget.textController.text.trimLeft(), widget.textSeparators, ); if (submitted.isNotEmpty) { widget.textController.text = remainder; widget.textController.selection = TextSelection.collapsed(offset: widget.textController.text.length); } widget.onPaste(submitted, remainder); } Widget? _renderTags(BuildContext context) { if (widget.selectedOptionMap.isEmpty) { return null; } final children = widget.selectedOptionMap.values .map( (option) => SelectOptionTag( option: option, onRemove: (option) => widget.onRemove(option), padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), ), ) .toList(); return Focus( descendantsAreFocusable: false, child: MouseRegion( cursor: SystemMouseCursors.basic, child: Padding( padding: const EdgeInsets.all(8.0), child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( dragDevices: { PointerDeviceKind.mouse, PointerDeviceKind.touch, PointerDeviceKind.trackpad, PointerDeviceKind.stylus, PointerDeviceKind.invertedStylus, }, ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, controller: widget.scrollController, child: Wrap(spacing: 4, children: children), ), ), ), ), ); } } @visibleForTesting (List, String) splitInput(String input, List textSeparators) { final List splits = []; String currentString = ''; // split the string into tokens for (final char in input.split('')) { if (textSeparators.contains(char)) { if (currentString.trim().isNotEmpty) { splits.add(currentString.trim()); } currentString = ''; continue; } currentString += char; } // add the remainder (might be '') splits.add(currentString); final submittedOptions = splits.sublist(0, splits.length - 1).toList(); final remainder = splits.elementAt(splits.length - 1).trimLeft(); return (submittedOptions, remainder); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/database_layout_ext.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; extension DatabaseLayoutExtension on DatabaseLayoutPB { String get layoutName { return switch (this) { DatabaseLayoutPB.Board => LocaleKeys.board_menuName.tr(), DatabaseLayoutPB.Calendar => LocaleKeys.calendar_menuName.tr(), DatabaseLayoutPB.Grid => LocaleKeys.grid_menuName.tr(), _ => "", }; } ViewLayoutPB get layoutType { return switch (this) { DatabaseLayoutPB.Board => ViewLayoutPB.Board, DatabaseLayoutPB.Calendar => ViewLayoutPB.Calendar, DatabaseLayoutPB.Grid => ViewLayoutPB.Grid, _ => throw UnimplementedError(), }; } FlowySvgData get icon { return switch (this) { DatabaseLayoutPB.Board => FlowySvgs.board_s, DatabaseLayoutPB.Calendar => FlowySvgs.calendar_s, DatabaseLayoutPB.Grid => FlowySvgs.grid_s, _ => throw UnimplementedError(), }; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart ================================================ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class DatabaseViewWidget extends StatefulWidget { const DatabaseViewWidget({ super.key, required this.view, this.shrinkWrap = true, required this.showActions, required this.node, this.actionBuilder, }); final ViewPB view; final bool shrinkWrap; final BlockComponentActionBuilder? actionBuilder; final bool showActions; final Node node; @override State createState() => _DatabaseViewWidgetState(); } class _DatabaseViewWidgetState extends State { /// Listens to the view updates. late final ViewListener _listener; /// Notifies the view layout type changes. When the layout type changes, /// the widget of the view will be updated. late final ValueNotifier _layoutTypeChangeNotifier; /// The view will be updated by the [ViewListener]. late ViewPB view; late Plugin viewPlugin; @override void initState() { super.initState(); view = widget.view; viewPlugin = view.plugin()..init(); _listenOnViewUpdated(); } @override void dispose() { _layoutTypeChangeNotifier.dispose(); _listener.stop(); viewPlugin.dispose(); super.dispose(); } @override Widget build(BuildContext context) { double? horizontalPadding = 0.0; final databasePluginWidgetBuilderSize = Provider.of(context); if (view.layout == ViewLayoutPB.Grid || view.layout == ViewLayoutPB.Board) { horizontalPadding = 40.0; } if (databasePluginWidgetBuilderSize != null) { horizontalPadding = databasePluginWidgetBuilderSize.horizontalPadding; } return ValueListenableBuilder( valueListenable: _layoutTypeChangeNotifier, builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget( shrinkWrap: widget.shrinkWrap, context: PluginContext(), data: { kDatabasePluginWidgetBuilderHorizontalPadding: horizontalPadding, kDatabasePluginWidgetBuilderActionBuilder: widget.actionBuilder, kDatabasePluginWidgetBuilderShowActions: widget.showActions, kDatabasePluginWidgetBuilderNode: widget.node, }, ), ); } void _listenOnViewUpdated() { _listener = ViewListener(viewId: widget.view.id) ..start( onViewUpdated: (updatedView) { if (mounted) { view = updatedView; _layoutTypeChangeNotifier.value = view.layout; } }, ); _layoutTypeChangeNotifier = ValueNotifier(widget.view.layout); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart ================================================ import 'dart:convert'; import 'dart:typed_data'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../shared/icon_emoji_picker/icon_picker.dart'; import 'field_type_list.dart'; import 'type_option_editor/builder.dart'; enum FieldEditorPage { general, details, } class FieldEditor extends StatefulWidget { const FieldEditor({ super.key, required this.viewId, required this.fieldInfo, required this.fieldController, required this.isNewField, this.initialPage = FieldEditorPage.details, this.onFieldInserted, }); final String viewId; final FieldInfo fieldInfo; final FieldController fieldController; final FieldEditorPage initialPage; final void Function(String fieldId)? onFieldInserted; final bool isNewField; @override State createState() => _FieldEditorState(); } class _FieldEditorState extends State { final PopoverMutex popoverMutex = PopoverMutex(); late FieldEditorPage _currentPage; late final TextEditingController textController = TextEditingController(text: widget.fieldInfo.name); @override void initState() { super.initState(); _currentPage = widget.initialPage; } @override void dispose() { popoverMutex.dispose(); textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (_) => FieldEditorBloc( viewId: widget.viewId, fieldInfo: widget.fieldInfo, fieldController: widget.fieldController, onFieldInserted: widget.onFieldInserted, isNew: widget.isNewField, ), child: _currentPage == FieldEditorPage.general ? _fieldGeneral() : _fieldDetails(), ); } Widget _fieldGeneral() { return SizedBox( width: 240, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _NameAndIcon( popoverMutex: popoverMutex, textController: textController, padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), ), VSpace(GridSize.typeOptionSeparatorHeight), _EditFieldButton( padding: const EdgeInsets.symmetric(horizontal: 8.0), onTap: () { setState(() => _currentPage = FieldEditorPage.details); }, ), VSpace(GridSize.typeOptionSeparatorHeight), _actionCell(FieldAction.insertLeft), VSpace(GridSize.typeOptionSeparatorHeight), _actionCell(FieldAction.insertRight), VSpace(GridSize.typeOptionSeparatorHeight), _actionCell(FieldAction.toggleVisibility), VSpace(GridSize.typeOptionSeparatorHeight), _actionCell(FieldAction.duplicate), VSpace(GridSize.typeOptionSeparatorHeight), _actionCell(FieldAction.clearData), VSpace(GridSize.typeOptionSeparatorHeight), _actionCell(FieldAction.delete), const TypeOptionSeparator(spacing: 8.0), _actionCell(FieldAction.wrap), const VSpace(8.0), ], ), ); } Widget _actionCell(FieldAction action) { return BlocBuilder( builder: (context, state) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: FieldActionCell( viewId: widget.viewId, fieldInfo: state.field, action: action, ), ); }, ); } Widget _fieldDetails() { return SizedBox( width: 260, child: FieldDetailsEditor( viewId: widget.viewId, textEditingController: textController, ), ); } } class _EditFieldButton extends StatelessWidget { const _EditFieldButton({ required this.padding, this.onTap, }); final EdgeInsetsGeometry padding; final void Function()? onTap; @override Widget build(BuildContext context) { return Container( height: GridSize.popoverItemHeight, padding: padding, child: FlowyButton( leftIcon: const FlowySvg(FlowySvgs.edit_s), text: FlowyText( lineHeight: 1.0, LocaleKeys.grid_field_editProperty.tr(), ), onTap: onTap, ), ); } } class FieldActionCell extends StatelessWidget { const FieldActionCell({ super.key, required this.viewId, required this.fieldInfo, required this.action, this.popoverMutex, }); final String viewId; final FieldInfo fieldInfo; final FieldAction action; final PopoverMutex? popoverMutex; @override Widget build(BuildContext context) { bool enable = true; // If the field is primary, delete and duplicate are disabled. if (fieldInfo.isPrimary && (action == FieldAction.duplicate || action == FieldAction.delete)) { enable = false; } return FlowyIconTextButton( resetHoverOnRebuild: false, disable: !enable, onHover: (_) => popoverMutex?.close(), onTap: () => action.run(context, viewId, fieldInfo), // show the error color when delete is hovered textBuilder: (onHover) => FlowyText( action.title(fieldInfo), lineHeight: 1.0, color: enable ? action == FieldAction.delete && onHover ? Theme.of(context).colorScheme.error : null : Theme.of(context).disabledColor, ), leftIconBuilder: (onHover) => action.leading( fieldInfo, enable ? action == FieldAction.delete && onHover ? Theme.of(context).colorScheme.error : null : Theme.of(context).disabledColor, ), rightIconBuilder: (_) => action.trailing(context, fieldInfo), ); } } enum FieldAction { insertLeft, insertRight, toggleVisibility, duplicate, clearData, delete, wrap; Widget? leading(FieldInfo fieldInfo, Color? color) { FlowySvgData? svgData; switch (this) { case FieldAction.insertLeft: svgData = FlowySvgs.arrow_s; case FieldAction.insertRight: svgData = FlowySvgs.arrow_s; case FieldAction.toggleVisibility: if (fieldInfo.visibility != null && fieldInfo.visibility == FieldVisibility.AlwaysHidden) { svgData = FlowySvgs.show_m; } else { svgData = FlowySvgs.hide_s; } case FieldAction.duplicate: svgData = FlowySvgs.copy_s; case FieldAction.clearData: svgData = FlowySvgs.reload_s; case FieldAction.delete: svgData = FlowySvgs.delete_s; default: } if (svgData == null) { return null; } final icon = FlowySvg( svgData, size: const Size.square(16), color: color, ); return this == FieldAction.insertRight ? Transform.flip(flipX: true, child: icon) : icon; } Widget? trailing(BuildContext context, FieldInfo fieldInfo) { if (this == FieldAction.wrap) { return Toggle( value: fieldInfo.wrapCellContent ?? false, onChanged: (_) => context .read() .add(const FieldEditorEvent.toggleWrapCellContent()), padding: EdgeInsets.zero, ); } return null; } String title(FieldInfo fieldInfo) { switch (this) { case FieldAction.insertLeft: return LocaleKeys.grid_field_insertLeft.tr(); case FieldAction.insertRight: return LocaleKeys.grid_field_insertRight.tr(); case FieldAction.toggleVisibility: if (fieldInfo.visibility != null && fieldInfo.visibility == FieldVisibility.AlwaysHidden) { return LocaleKeys.grid_field_show.tr(); } else { return LocaleKeys.grid_field_hide.tr(); } case FieldAction.duplicate: return LocaleKeys.grid_field_duplicate.tr(); case FieldAction.clearData: return LocaleKeys.grid_field_clear.tr(); case FieldAction.delete: return LocaleKeys.grid_field_delete.tr(); case FieldAction.wrap: return LocaleKeys.grid_field_wrapCellContent.tr(); } } void run(BuildContext context, String viewId, FieldInfo fieldInfo) { switch (this) { case FieldAction.insertLeft: PopoverContainer.of(context).close(); context .read() .add(const FieldEditorEvent.insertLeft()); break; case FieldAction.insertRight: PopoverContainer.of(context).close(); context .read() .add(const FieldEditorEvent.insertRight()); break; case FieldAction.toggleVisibility: PopoverContainer.of(context).close(); context .read() .add(const FieldEditorEvent.toggleFieldVisibility()); break; case FieldAction.duplicate: PopoverContainer.of(context).close(); FieldBackendService.duplicateField( viewId: viewId, fieldId: fieldInfo.id, ); break; case FieldAction.clearData: PopoverContainer.of(context).closeAll(); showCancelAndConfirmDialog( context: context, title: LocaleKeys.grid_field_label.tr(), description: LocaleKeys.grid_field_clearFieldPromptMessage.tr(), confirmLabel: LocaleKeys.button_confirm.tr(), onConfirm: (_) { FieldBackendService.clearField( viewId: viewId, fieldId: fieldInfo.id, ); }, ); break; case FieldAction.delete: PopoverContainer.of(context).closeAll(); showConfirmDeletionDialog( context: context, name: LocaleKeys.grid_field_label.tr(), description: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), onConfirm: () { FieldBackendService.deleteField( viewId: viewId, fieldId: fieldInfo.id, ); }, ); break; case FieldAction.wrap: context .read() .add(const FieldEditorEvent.toggleWrapCellContent()); break; } } } class FieldDetailsEditor extends StatefulWidget { const FieldDetailsEditor({ super.key, required this.viewId, required this.textEditingController, this.onAction, }); final String viewId; final TextEditingController textEditingController; final Function()? onAction; @override State createState() => _FieldDetailsEditorState(); } class _FieldDetailsEditorState extends State { final PopoverMutex popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final List children = [ _NameAndIcon( popoverMutex: popoverMutex, textController: widget.textEditingController, padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), ), const VSpace(8.0), SwitchFieldButton(popoverMutex: popoverMutex), const TypeOptionSeparator(spacing: 8.0), Flexible( child: FieldTypeOptionEditor( viewId: widget.viewId, popoverMutex: popoverMutex, ), ), _addFieldVisibilityToggleButton(), _addDuplicateFieldButton(), _addDeleteFieldButton(), ]; return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Column( mainAxisSize: MainAxisSize.min, children: children, ), ); } Widget _addFieldVisibilityToggleButton() { return BlocBuilder( builder: (context, state) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: FieldActionCell( viewId: widget.viewId, fieldInfo: state.field, action: FieldAction.toggleVisibility, popoverMutex: popoverMutex, ), ); }, ); } Widget _addDeleteFieldButton() { return BlocBuilder( builder: (context, state) { return Padding( padding: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0), child: FieldActionCell( viewId: widget.viewId, fieldInfo: state.field, action: FieldAction.delete, popoverMutex: popoverMutex, ), ); }, ); } Widget _addDuplicateFieldButton() { return BlocBuilder( builder: (context, state) { return Padding( padding: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0), child: FieldActionCell( viewId: widget.viewId, fieldInfo: state.field, action: FieldAction.duplicate, popoverMutex: popoverMutex, ), ); }, ); } } class FieldTypeOptionEditor extends StatelessWidget { const FieldTypeOptionEditor({ super.key, required this.viewId, required this.popoverMutex, }); final String viewId; final PopoverMutex popoverMutex; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state.field.isPrimary) { return const SizedBox.shrink(); } final typeOptionEditor = makeTypeOptionEditor( context: context, viewId: viewId, field: state.field.field, popoverMutex: popoverMutex, onTypeOptionUpdated: (Uint8List typeOptionData) { context .read() .add(FieldEditorEvent.updateTypeOption(typeOptionData)); }, ); if (typeOptionEditor == null) { return const SizedBox.shrink(); } return Column( mainAxisSize: MainAxisSize.min, children: [ Flexible(child: typeOptionEditor), const TypeOptionSeparator(spacing: 8.0), ], ); }, ); } } class _NameAndIcon extends StatelessWidget { const _NameAndIcon({ required this.textController, this.padding = EdgeInsets.zero, this.popoverMutex, }); final TextEditingController textController; final PopoverMutex? popoverMutex; final EdgeInsetsGeometry padding; @override Widget build(BuildContext context) { return Padding( padding: padding, child: Row( children: [ FieldEditIconButton( popoverMutex: popoverMutex, ), const HSpace(6), Expanded( child: FieldNameTextField( textController: textController, popoverMutex: popoverMutex, ), ), ], ), ); } } class FieldEditIconButton extends StatefulWidget { const FieldEditIconButton({ super.key, this.popoverMutex, }); final PopoverMutex? popoverMutex; @override State createState() => _FieldEditIconButtonState(); } class _FieldEditIconButtonState extends State { final PopoverController popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( offset: const Offset(0, 4), constraints: BoxConstraints.loose(const Size(360, 432)), margin: EdgeInsets.zero, direction: PopoverDirection.bottomWithLeftAligned, controller: popoverController, mutex: widget.popoverMutex, child: FlowyIconButton( decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide( color: Theme.of(context).colorScheme.outline, ), ), borderRadius: Corners.s8Border, ), icon: BlocBuilder( builder: (context, state) { return FieldIcon(fieldInfo: state.field); }, ), width: 32, onPressed: () => popoverController.show(), ), popupBuilder: (popoverContext) { return FlowyIconEmojiPicker( enableBackgroundColorSelection: false, tabs: const [PickerTabType.icon], onSelectedEmoji: (r) { if (r.type == FlowyIconType.icon) { try { final iconsData = IconsData.fromJson(jsonDecode(r.emoji)); context.read().add( FieldEditorEvent.updateIcon( '${iconsData.groupName}/${iconsData.iconName}', ), ); } on FormatException catch (e) { Log.warn('FieldEditIconButton onSelectedEmoji error:$e'); context .read() .add(const FieldEditorEvent.updateIcon('')); } } PopoverContainer.of(popoverContext).close(); }, ); }, ); } } class FieldNameTextField extends StatefulWidget { const FieldNameTextField({ super.key, required this.textController, this.popoverMutex, }); final TextEditingController textController; final PopoverMutex? popoverMutex; @override State createState() => _FieldNameTextFieldState(); } class _FieldNameTextFieldState extends State { final focusNode = FocusNode(); @override void initState() { super.initState(); focusNode.addListener(_onFocusChanged); widget.popoverMutex?.addPopoverListener(_onPopoverChanged); } @override void dispose() { widget.popoverMutex?.removePopoverListener(_onPopoverChanged); focusNode.removeListener(_onFocusChanged); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FlowyTextField( focusNode: focusNode, controller: widget.textController, onSubmitted: (_) => PopoverContainer.of(context).close(), onChanged: (newName) { context .read() .add(FieldEditorEvent.renameField(newName)); }, ); } void _onFocusChanged() { if (focusNode.hasFocus) { widget.popoverMutex?.close(); } } void _onPopoverChanged() { if (focusNode.hasFocus) { focusNode.unfocus(); } } } class SwitchFieldButton extends StatefulWidget { const SwitchFieldButton({ super.key, required this.popoverMutex, }); final PopoverMutex popoverMutex; @override State createState() => _SwitchFieldButtonState(); } class _SwitchFieldButtonState extends State { final PopoverController _popoverController = PopoverController(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state.field.isPrimary) { return SizedBox( height: GridSize.popoverItemHeight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: FlowyTooltip( message: LocaleKeys.grid_field_switchPrimaryFieldTooltip.tr(), child: FlowyButton( text: FlowyText( state.field.fieldType.i18n, lineHeight: 1.0, color: Theme.of(context).disabledColor, ), leftIcon: FlowySvg( state.field.fieldType.svgData, color: Theme.of(context).disabledColor, ), rightIcon: FlowySvg( FlowySvgs.more_s, color: Theme.of(context).disabledColor, ), ), ), ), ); } return SizedBox( height: GridSize.popoverItemHeight, child: AppFlowyPopover( constraints: BoxConstraints.loose(const Size(460, 540)), triggerActions: PopoverTriggerFlags.hover, mutex: widget.popoverMutex, controller: _popoverController, offset: const Offset(8, 0), margin: const EdgeInsets.all(8), popupBuilder: (BuildContext popoverContext) { return FieldTypeList( onSelectField: (newFieldType) { context .read() .add(FieldEditorEvent.switchFieldType(newFieldType)); }, ); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: FlowyButton( onTap: () => _popoverController.show(), text: FlowyText( state.field.fieldType.i18n, lineHeight: 1.0, ), leftIcon: FlowySvg( state.field.fieldType.svgData, ), rightIcon: const FlowySvg( FlowySvgs.more_s, ), ), ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; typedef SelectFieldCallback = void Function(FieldType); const List _supportedFieldTypes = [ FieldType.RichText, FieldType.Number, FieldType.SingleSelect, FieldType.MultiSelect, FieldType.DateTime, FieldType.Media, FieldType.URL, FieldType.Checkbox, FieldType.Checklist, FieldType.LastEditedTime, FieldType.CreatedTime, FieldType.Relation, FieldType.Summary, FieldType.Translate, // FieldType.Time, ]; class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { const FieldTypeList({required this.onSelectField, super.key}); final SelectFieldCallback onSelectField; @override Widget build(BuildContext context) { final cells = _supportedFieldTypes.map((fieldType) { return FieldTypeCell( fieldType: fieldType, onSelectField: (fieldType) { onSelectField(fieldType); PopoverContainer.of(context).closeAll(); }, ); }).toList(); return SizedBox( width: 140, child: ListView.separated( shrinkWrap: true, itemCount: cells.length, separatorBuilder: (context, index) { return VSpace(GridSize.typeOptionSeparatorHeight); }, physics: StyledScrollPhysics(), itemBuilder: (BuildContext context, int index) { return cells[index]; }, ), ); } } class FieldTypeCell extends StatelessWidget { const FieldTypeCell({ super.key, required this.fieldType, required this.onSelectField, }); final FieldType fieldType; final SelectFieldCallback onSelectField; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText(fieldType.i18n, lineHeight: 1.0), onTap: () => onSelectField(fieldType), leftIcon: FlowySvg( fieldType.svgData, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart ================================================ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/media.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/translate.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'checkbox.dart'; import 'checklist.dart'; import 'date.dart'; import 'multi_select.dart'; import 'number.dart'; import 'relation.dart'; import 'rich_text.dart'; import 'single_select.dart'; import 'summary.dart'; import 'time.dart'; import 'timestamp.dart'; import 'url.dart'; typedef TypeOptionDataCallback = void Function(Uint8List typeOptionData); abstract class TypeOptionEditorFactory { factory TypeOptionEditorFactory.makeBuilder(FieldType fieldType) { return switch (fieldType) { FieldType.RichText => const RichTextTypeOptionEditorFactory(), FieldType.Number => const NumberTypeOptionEditorFactory(), FieldType.URL => const URLTypeOptionEditorFactory(), FieldType.DateTime => const DateTypeOptionEditorFactory(), FieldType.LastEditedTime => const TimestampTypeOptionEditorFactory(), FieldType.CreatedTime => const TimestampTypeOptionEditorFactory(), FieldType.SingleSelect => const SingleSelectTypeOptionEditorFactory(), FieldType.MultiSelect => const MultiSelectTypeOptionEditorFactory(), FieldType.Checkbox => const CheckboxTypeOptionEditorFactory(), FieldType.Checklist => const ChecklistTypeOptionEditorFactory(), FieldType.Relation => const RelationTypeOptionEditorFactory(), FieldType.Summary => const SummaryTypeOptionEditorFactory(), FieldType.Time => const TimeTypeOptionEditorFactory(), FieldType.Translate => const TranslateTypeOptionEditorFactory(), FieldType.Media => const MediaTypeOptionEditorFactory(), _ => throw UnimplementedError(), }; } Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }); } Widget? makeTypeOptionEditor({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) { final editorBuilder = TypeOptionEditorFactory.makeBuilder(field.fieldType); return editorBuilder.build( context: context, viewId: viewId, field: field, onTypeOptionUpdated: onTypeOptionUpdated, popoverMutex: popoverMutex, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/checkbox.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'builder.dart'; class CheckboxTypeOptionEditorFactory implements TypeOptionEditorFactory { const CheckboxTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) => null; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/checklist.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'builder.dart'; class ChecklistTypeOptionEditorFactory implements TypeOptionEditorFactory { const ChecklistTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) => null; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; class DateFormatButton extends StatelessWidget { const DateFormatButton({ super.key, this.onTap, this.onHover, }); final VoidCallback? onTap; final void Function(bool)? onHover; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( LocaleKeys.grid_field_dateFormat.tr(), lineHeight: 1.0, ), onTap: onTap, onHover: onHover, rightIcon: const FlowySvg(FlowySvgs.more_s), ), ); } } class TimeFormatButton extends StatelessWidget { const TimeFormatButton({ super.key, this.onTap, this.onHover, }); final VoidCallback? onTap; final void Function(bool)? onHover; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( LocaleKeys.grid_field_timeFormat.tr(), lineHeight: 1.0, ), onTap: onTap, onHover: onHover, rightIcon: const FlowySvg(FlowySvgs.more_s), ), ); } } class DateFormatList extends StatelessWidget { const DateFormatList({ super.key, required this.selectedFormat, required this.onSelected, }); final DateFormatPB selectedFormat; final Function(DateFormatPB format) onSelected; @override Widget build(BuildContext context) { final cells = DateFormatPB.values .where((value) => value != DateFormatPB.FriendlyFull) .map((format) { return DateFormatCell( dateFormat: format, onSelected: onSelected, isSelected: selectedFormat == format, ); }).toList(); return SizedBox( width: 180, child: ListView.separated( shrinkWrap: true, separatorBuilder: (context, index) { return VSpace(GridSize.typeOptionSeparatorHeight); }, itemCount: cells.length, itemBuilder: (BuildContext context, int index) { return cells[index]; }, ), ); } } class DateFormatCell extends StatelessWidget { const DateFormatCell({ super.key, required this.dateFormat, required this.onSelected, required this.isSelected, }); final DateFormatPB dateFormat; final Function(DateFormatPB format) onSelected; final bool isSelected; @override Widget build(BuildContext context) { Widget? checkmark; if (isSelected) { checkmark = const FlowySvg(FlowySvgs.check_s); } return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( dateFormat.title(), lineHeight: 1.0, ), rightIcon: checkmark, onTap: () => onSelected(dateFormat), ), ); } } extension DateFormatExtension on DateFormatPB { String title() { switch (this) { case DateFormatPB.Friendly: return LocaleKeys.grid_field_dateFormatFriendly.tr(); case DateFormatPB.ISO: return LocaleKeys.grid_field_dateFormatISO.tr(); case DateFormatPB.Local: return LocaleKeys.grid_field_dateFormatLocal.tr(); case DateFormatPB.US: return LocaleKeys.grid_field_dateFormatUS.tr(); case DateFormatPB.DayMonthYear: return LocaleKeys.grid_field_dateFormatDayMonthYear.tr(); case DateFormatPB.FriendlyFull: return LocaleKeys.grid_field_dateFormatFriendly.tr(); default: throw UnimplementedError; } } } class TimeFormatList extends StatelessWidget { const TimeFormatList({ super.key, required this.selectedFormat, required this.onSelected, }); final TimeFormatPB selectedFormat; final Function(TimeFormatPB format) onSelected; @override Widget build(BuildContext context) { final cells = TimeFormatPB.values.map((format) { return TimeFormatCell( isSelected: format == selectedFormat, timeFormat: format, onSelected: onSelected, ); }).toList(); return SizedBox( width: 120, child: ListView.separated( shrinkWrap: true, separatorBuilder: (context, index) { return VSpace(GridSize.typeOptionSeparatorHeight); }, itemCount: cells.length, itemBuilder: (BuildContext context, int index) { return cells[index]; }, ), ); } } class TimeFormatCell extends StatelessWidget { const TimeFormatCell({ super.key, required this.timeFormat, required this.onSelected, required this.isSelected, }); final TimeFormatPB timeFormat; final bool isSelected; final Function(TimeFormatPB format) onSelected; @override Widget build(BuildContext context) { Widget? checkmark; if (isSelected) { checkmark = const FlowySvg(FlowySvgs.check_s); } return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( timeFormat.title(), lineHeight: 1.0, ), rightIcon: checkmark, onTap: () => onSelected(timeFormat), ), ); } } extension TimeFormatExtension on TimeFormatPB { String title() { switch (this) { case TimeFormatPB.TwelveHour: return LocaleKeys.grid_field_timeFormatTwelveHour.tr(); case TimeFormatPB.TwentyFourHour: return LocaleKeys.grid_field_timeFormatTwentyFourHour.tr(); default: throw UnimplementedError; } } } class IncludeTimeButton extends StatelessWidget { const IncludeTimeButton({ super.key, required this.onChanged, required this.includeTime, this.showIcon = true, }); final Function(bool value) onChanged; final bool includeTime; final bool showIcon; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: Padding( padding: GridSize.typeOptionContentInsets, child: Row( children: [ if (showIcon) ...[ FlowySvg( FlowySvgs.clock_alarm_s, color: Theme.of(context).iconTheme.color, ), const HSpace(4), ], const HSpace(2), FlowyText(LocaleKeys.grid_field_includeTime.tr()), const Spacer(), Toggle( value: includeTime, onChanged: onChanged, padding: EdgeInsets.zero, ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/date.dart ================================================ import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:protobuf/protobuf.dart'; import '../../../grid/presentation/layout/sizes.dart'; import 'builder.dart'; import 'date/date_time_format.dart'; class DateTypeOptionEditorFactory implements TypeOptionEditorFactory { const DateTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) { final typeOption = _parseTypeOptionData(field.typeOptionData); return Column( mainAxisSize: MainAxisSize.min, children: [ _renderDateFormatButton( typeOption, popoverMutex, onTypeOptionUpdated, ), VSpace(GridSize.typeOptionSeparatorHeight), _renderTimeFormatButton( typeOption, popoverMutex, onTypeOptionUpdated, ), ], ); } Widget _renderDateFormatButton( DateTypeOptionPB typeOption, PopoverMutex popoverMutex, TypeOptionDataCallback onTypeOptionUpdated, ) { return AppFlowyPopover( mutex: popoverMutex, asBarrier: true, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(8, 0), constraints: BoxConstraints.loose(const Size(460, 440)), popupBuilder: (popoverContext) { return DateFormatList( selectedFormat: typeOption.dateFormat, onSelected: (format) { final newTypeOption = _updateTypeOption(typeOption: typeOption, dateFormat: format); onTypeOptionUpdated(newTypeOption.writeToBuffer()); PopoverContainer.of(popoverContext).close(); }, ); }, child: const Padding( padding: EdgeInsets.symmetric(horizontal: 12.0), child: DateFormatButton(), ), ); } Widget _renderTimeFormatButton( DateTypeOptionPB typeOption, PopoverMutex popoverMutex, TypeOptionDataCallback onTypeOptionUpdated, ) { return AppFlowyPopover( mutex: popoverMutex, asBarrier: true, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(8, 0), constraints: BoxConstraints.loose(const Size(460, 440)), popupBuilder: (BuildContext popoverContext) { return TimeFormatList( selectedFormat: typeOption.timeFormat, onSelected: (format) { final newTypeOption = _updateTypeOption(typeOption: typeOption, timeFormat: format); onTypeOptionUpdated(newTypeOption.writeToBuffer()); PopoverContainer.of(popoverContext).close(); }, ); }, child: const Padding( padding: EdgeInsets.symmetric(horizontal: 12.0), child: TimeFormatButton(), ), ); } DateTypeOptionPB _parseTypeOptionData(List data) { return DateTypeOptionDataParser().fromBuffer(data); } DateTypeOptionPB _updateTypeOption({ required DateTypeOptionPB typeOption, DateFormatPB? dateFormat, TimeFormatPB? timeFormat, }) { typeOption.freeze(); return typeOption.rebuild((typeOption) { if (dateFormat != null) { typeOption.dateFormat = dateFormat; } if (timeFormat != null) { typeOption.timeFormat = timeFormat; } }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/builder.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:protobuf/protobuf.dart'; class MediaTypeOptionEditorFactory implements TypeOptionEditorFactory { const MediaTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) { final typeOption = _parseTypeOptionData(field.typeOptionData); return Container( padding: const EdgeInsets.symmetric(horizontal: 8), height: GridSize.popoverItemHeight, alignment: Alignment.centerLeft, child: FlowyButton( resetHoverOnRebuild: false, text: FlowyText( LocaleKeys.grid_media_showFileNames.tr(), lineHeight: 1.0, ), onHover: (_) => popoverMutex.close(), rightIcon: Toggle( value: !typeOption.hideFileNames, onChanged: (val) => onTypeOptionUpdated( _toggleHideFiles(typeOption, !val).writeToBuffer(), ), padding: EdgeInsets.zero, ), ), ); } MediaTypeOptionPB _parseTypeOptionData(List data) { return MediaTypeOptionDataParser().fromBuffer(data); } MediaTypeOptionPB _toggleHideFiles( MediaTypeOptionPB typeOption, bool hideFileNames, ) { typeOption.freeze(); return typeOption.rebuild((to) => to.hideFileNames = hideFileNames); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/multi_select.dart ================================================ import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'builder.dart'; import 'select/select_option.dart'; class MultiSelectTypeOptionEditorFactory implements TypeOptionEditorFactory { const MultiSelectTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) { final typeOption = _parseTypeOptionData(field.typeOptionData); return SelectOptionTypeOptionWidget( options: typeOption.options, beginEdit: () => PopoverContainer.of(context).closeAll(), popoverMutex: popoverMutex, typeOptionAction: MultiSelectAction( viewId: viewId, fieldId: field.id, onTypeOptionUpdated: onTypeOptionUpdated, ), ); } MultiSelectTypeOptionPB _parseTypeOptionData(List data) { return MultiSelectTypeOptionDataParser().fromBuffer(data); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/number.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:protobuf/protobuf.dart'; import '../../../grid/presentation/layout/sizes.dart'; import '../../../grid/presentation/widgets/common/type_option_separator.dart'; import 'builder.dart'; class NumberTypeOptionEditorFactory implements TypeOptionEditorFactory { const NumberTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) { final typeOption = _parseTypeOptionData(field.typeOptionData); final selectNumUnitButton = SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( rightIcon: const FlowySvg(FlowySvgs.more_s), text: FlowyText( lineHeight: 1.0, typeOption.format.title(), ), ), ); final numFormatTitle = Container( padding: const EdgeInsets.only(left: 6), height: GridSize.popoverItemHeight, alignment: Alignment.centerLeft, child: FlowyText.regular( LocaleKeys.grid_field_numberFormat.tr(), color: Theme.of(context).hintColor, fontSize: 11, ), ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ numFormatTitle, AppFlowyPopover( mutex: popoverMutex, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(16, 0), constraints: BoxConstraints.loose(const Size(460, 440)), margin: EdgeInsets.zero, child: selectNumUnitButton, popupBuilder: (BuildContext popoverContext) { return NumberFormatList( selectedFormat: typeOption.format, onSelected: (format) { final newTypeOption = _updateNumberFormat(typeOption, format); onTypeOptionUpdated(newTypeOption.writeToBuffer()); PopoverContainer.of(popoverContext).close(); }, ); }, ), ], ), ); } NumberTypeOptionPB _parseTypeOptionData(List data) { return NumberTypeOptionDataParser().fromBuffer(data); } NumberTypeOptionPB _updateNumberFormat( NumberTypeOptionPB typeOption, NumberFormatPB format, ) { typeOption.freeze(); return typeOption.rebuild((typeOption) => typeOption.format = format); } } typedef SelectNumberFormatCallback = void Function(NumberFormatPB format); class NumberFormatList extends StatelessWidget { const NumberFormatList({ super.key, required this.selectedFormat, required this.onSelected, }); final NumberFormatPB selectedFormat; final SelectNumberFormatCallback onSelected; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => NumberFormatBloc(), child: SizedBox( width: 180, child: Column( mainAxisSize: MainAxisSize.min, children: [ const _FilterTextField(), const TypeOptionSeparator(spacing: 0.0), BlocBuilder( builder: (context, state) { final cells = state.formats.map((format) { return NumberFormatCell( isSelected: format == selectedFormat, format: format, onSelected: (format) { onSelected(format); }, ); }).toList(); final list = ListView.separated( shrinkWrap: true, separatorBuilder: (context, index) { return VSpace(GridSize.typeOptionSeparatorHeight); }, itemCount: cells.length, itemBuilder: (BuildContext context, int index) { return cells[index]; }, padding: const EdgeInsets.all(6.0), ); return Flexible(child: list); }, ), ], ), ), ); } } class NumberFormatCell extends StatelessWidget { const NumberFormatCell({ super.key, required this.format, required this.isSelected, required this.onSelected, }); final NumberFormatPB format; final bool isSelected; final SelectNumberFormatCallback onSelected; @override Widget build(BuildContext context) { final checkmark = isSelected ? const FlowySvg(FlowySvgs.check_s) : null; return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( format.title(), lineHeight: 1.0, ), onTap: () => onSelected(format), rightIcon: checkmark, ), ); } } class _FilterTextField extends StatelessWidget { const _FilterTextField(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(6.0), child: FlowyTextField( onChanged: (text) => context .read() .add(NumberFormatEvent.setFilter(text)), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/relation.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:protobuf/protobuf.dart'; import 'builder.dart'; class RelationTypeOptionEditorFactory implements TypeOptionEditorFactory { const RelationTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) { final typeOption = _parseTypeOptionData(field.typeOptionData); return BlocProvider( create: (_) => RelationDatabaseListCubit(), child: Builder( builder: (context) { return Column( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.only(left: 14, right: 8), height: GridSize.popoverItemHeight, alignment: Alignment.centerLeft, child: FlowyText.regular( LocaleKeys.grid_relation_relatedDatabasePlaceLabel.tr(), color: Theme.of(context).hintColor, fontSize: 11, ), ), AppFlowyPopover( mutex: popoverMutex, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(6, 0), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8), height: GridSize.popoverItemHeight, child: FlowyButton( text: BlocBuilder( builder: (context, state) { final databaseMeta = state.databaseMetas.firstWhereOrNull( (meta) => meta.databaseId == typeOption.databaseId, ); return FlowyText( lineHeight: 1.0, databaseMeta == null ? LocaleKeys .grid_relation_relatedDatabasePlaceholder .tr() : databaseMeta.databaseName, color: databaseMeta == null ? Theme.of(context).hintColor : null, overflow: TextOverflow.ellipsis, ); }, ), rightIcon: const FlowySvg(FlowySvgs.more_s), ), ), popupBuilder: (popoverContext) { return BlocProvider.value( value: context.read(), child: _DatabaseList( onSelectDatabase: (newDatabaseId) { final newTypeOption = _updateTypeOption( typeOption: typeOption, databaseId: newDatabaseId, ); onTypeOptionUpdated(newTypeOption.writeToBuffer()); PopoverContainer.of(context).close(); }, currentDatabaseId: typeOption.databaseId, ), ); }, ), ], ); }, ), ); } RelationTypeOptionPB _parseTypeOptionData(List data) { return RelationTypeOptionDataParser().fromBuffer(data); } RelationTypeOptionPB _updateTypeOption({ required RelationTypeOptionPB typeOption, required String databaseId, }) { typeOption.freeze(); return typeOption.rebuild((typeOption) { typeOption.databaseId = databaseId; }); } } class _DatabaseList extends StatelessWidget { const _DatabaseList({ required this.onSelectDatabase, required this.currentDatabaseId, }); final String currentDatabaseId; final void Function(String databaseId) onSelectDatabase; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final children = state.databaseMetas.map((meta) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( onTap: () => onSelectDatabase(meta.databaseId), text: FlowyText( lineHeight: 1.0, meta.databaseName, overflow: TextOverflow.ellipsis, ), rightIcon: meta.databaseId == currentDatabaseId ? const FlowySvg( FlowySvgs.check_s, ) : null, ), ); }).toList(); return ListView.separated( shrinkWrap: true, padding: EdgeInsets.zero, separatorBuilder: (_, __) => VSpace(GridSize.typeOptionSeparatorHeight), itemCount: children.length, itemBuilder: (context, index) => children[index], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/rich_text.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'builder.dart'; class RichTextTypeOptionEditorFactory implements TypeOptionEditorFactory { const RichTextTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) => null; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/select_option_type_option_bloc.dart'; import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'select_option_editor.dart'; class SelectOptionTypeOptionWidget extends StatelessWidget { const SelectOptionTypeOptionWidget({ super.key, required this.options, required this.beginEdit, required this.typeOptionAction, this.popoverMutex, }); final List options; final VoidCallback beginEdit; final ISelectOptionAction typeOptionAction; final PopoverMutex? popoverMutex; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => SelectOptionTypeOptionBloc( options: options, typeOptionAction: typeOptionAction, ), child: BlocBuilder( builder: (context, state) { final List children = [ const _OptionTitle(), const VSpace(4), if (state.isEditingOption) ...[ CreateOptionTextField(popoverMutex: popoverMutex), const VSpace(4), ] else const _AddOptionButton(), const VSpace(4), Flexible( child: _OptionList( popoverMutex: popoverMutex, ), ), ]; return Column( mainAxisSize: MainAxisSize.min, children: children, ); }, ), ); } } class _OptionTitle extends StatelessWidget { const _OptionTitle(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Align( alignment: AlignmentDirectional.centerStart, child: FlowyText.regular( LocaleKeys.grid_field_optionTitle.tr(), fontSize: 11, color: Theme.of(context).hintColor, ), ), ); }, ); } } class _OptionCell extends StatefulWidget { const _OptionCell({ super.key, required this.option, required this.index, this.popoverMutex, }); final SelectOptionPB option; final int index; final PopoverMutex? popoverMutex; @override State<_OptionCell> createState() => _OptionCellState(); } class _OptionCellState extends State<_OptionCell> { final PopoverController _popoverController = PopoverController(); @override Widget build(BuildContext context) { final child = SizedBox( height: 28, child: SelectOptionTagCell( option: widget.option, index: widget.index, onSelected: () => _popoverController.show(), children: [ FlowyIconButton( onPressed: () => _popoverController.show(), iconPadding: const EdgeInsets.symmetric(horizontal: 6.0), hoverColor: Colors.transparent, icon: FlowySvg( FlowySvgs.three_dots_s, color: Theme.of(context).iconTheme.color, size: const Size.square(16), ), ), ], ), ); return AppFlowyPopover( controller: _popoverController, mutex: widget.popoverMutex, offset: const Offset(8, 0), margin: EdgeInsets.zero, asBarrier: true, constraints: BoxConstraints.loose(const Size(460, 470)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: FlowyHover( resetHoverOnRebuild: false, style: HoverStyle( hoverColor: AFThemeExtension.of(context).lightGreyHover, ), child: child, ), ), popupBuilder: (BuildContext popoverContext) { return SelectOptionEditor( option: widget.option, onDeleted: () { context .read() .add(SelectOptionTypeOptionEvent.deleteOption(widget.option)); PopoverContainer.of(popoverContext).close(); }, onUpdated: (updatedOption) { context .read() .add(SelectOptionTypeOptionEvent.updateOption(updatedOption)); PopoverContainer.of(popoverContext).close(); }, key: ValueKey(widget.option.id), ); }, ); } } class _AddOptionButton extends StatelessWidget { const _AddOptionButton(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( lineHeight: 1.0, LocaleKeys.grid_field_addSelectOption.tr(), ), onTap: () { context .read() .add(const SelectOptionTypeOptionEvent.addingOption()); }, leftIcon: const FlowySvg(FlowySvgs.add_s), ), ), ); } } class CreateOptionTextField extends StatefulWidget { const CreateOptionTextField({super.key, this.popoverMutex}); final PopoverMutex? popoverMutex; @override State createState() => _CreateOptionTextFieldState(); } class _CreateOptionTextFieldState extends State { final focusNode = FocusNode(); @override void initState() { super.initState(); focusNode.addListener(_onFocusChanged); widget.popoverMutex?.addPopoverListener(_onPopoverChanged); } @override void dispose() { widget.popoverMutex?.removePopoverListener(_onPopoverChanged); focusNode.removeListener(_onFocusChanged); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final text = state.newOptionName ?? ''; return Padding( padding: const EdgeInsets.symmetric(horizontal: 14.0), child: FlowyTextField( autoClearWhenDone: true, text: text, focusNode: focusNode, onCanceled: () { context .read() .add(const SelectOptionTypeOptionEvent.endAddingOption()); }, onEditingComplete: () {}, onSubmitted: (optionName) { context .read() .add(SelectOptionTypeOptionEvent.createOption(optionName)); }, ), ); }, ); } void _onFocusChanged() { if (focusNode.hasFocus) { widget.popoverMutex?.close(); } } void _onPopoverChanged() { if (focusNode.hasFocus) { focusNode.unfocus(); } } } class _OptionList extends StatelessWidget { const _OptionList({ this.popoverMutex, }); final PopoverMutex? popoverMutex; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return ReorderableListView.builder( shrinkWrap: true, onReorderStart: (_) => popoverMutex?.close(), proxyDecorator: (child, index, _) => Material( color: Colors.transparent, child: Stack( children: [ BlocProvider.value( value: context.read(), child: child, ), MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grabbing, child: const SizedBox.expand(), ), ], ), ), buildDefaultDragHandles: false, itemBuilder: (context, index) => _OptionCell( key: ValueKey("select_type_option_list_${state.options[index].id}"), index: index, option: state.options[index], popoverMutex: popoverMutex, ), itemCount: state.options.length, onReorder: (oldIndex, newIndex) { if (oldIndex < newIndex) { newIndex--; } final fromOptionId = state.options[oldIndex].id; final toOptionId = state.options[newIndex].id; context.read().add( SelectOptionTypeOptionEvent.reorderOption( fromOptionId, toOptionId, ), ); }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/edit_select_option_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../grid/presentation/layout/sizes.dart'; import '../../../../grid/presentation/widgets/common/type_option_separator.dart'; class SelectOptionEditor extends StatelessWidget { const SelectOptionEditor({ super.key, required this.option, required this.onDeleted, required this.onUpdated, this.showOptions = true, this.autoFocus = true, }); final SelectOptionPB option; final VoidCallback onDeleted; final Function(SelectOptionPB) onUpdated; final bool showOptions; final bool autoFocus; static String get identifier => (SelectOptionEditor).toString(); @override Widget build(BuildContext context) { return BlocProvider( create: (context) => EditSelectOptionBloc(option: option), child: MultiBlocListener( listeners: [ BlocListener( listenWhen: (p, c) => p.deleted != c.deleted, listener: (context, state) { if (state.deleted) { onDeleted(); } }, ), BlocListener( listenWhen: (p, c) => p.option != c.option, listener: (context, state) { onUpdated(state.option); }, ), ], child: BlocBuilder( builder: (context, state) { final List cells = [ _OptionNameTextField( name: state.option.name, autoFocus: autoFocus, ), const VSpace(10), const _DeleteTag(), const TypeOptionSeparator(), SelectOptionColorList( selectedColor: state.option.color, onSelectedColor: (color) => context .read() .add(EditSelectOptionEvent.updateColor(color)), ), ]; return SizedBox( width: 180, child: ListView.builder( shrinkWrap: true, physics: StyledScrollPhysics(), itemCount: cells.length, itemBuilder: (context, index) { if (cells[index] is TypeOptionSeparator) { return cells[index]; } else { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0), child: cells[index], ); } }, padding: const EdgeInsets.symmetric(vertical: 6.0), ), ); }, ), ), ); } } class _DeleteTag extends StatelessWidget { const _DeleteTag(); @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( lineHeight: 1.0, LocaleKeys.grid_selectOption_deleteTag.tr(), ), leftIcon: const FlowySvg(FlowySvgs.delete_s), onTap: () { context .read() .add(const EditSelectOptionEvent.delete()); }, ), ); } } class _OptionNameTextField extends StatelessWidget { const _OptionNameTextField({ required this.name, required this.autoFocus, }); final String name; final bool autoFocus; @override Widget build(BuildContext context) { return FlowyTextField( autoFocus: autoFocus, text: name, submitOnLeave: true, onSubmitted: (newName) { if (name != newName) { context .read() .add(EditSelectOptionEvent.updateName(newName)); } }, ); } } class SelectOptionColorList extends StatelessWidget { const SelectOptionColorList({ super.key, this.selectedColor, required this.onSelectedColor, }); final SelectOptionColorPB? selectedColor; final void Function(SelectOptionColorPB color) onSelectedColor; @override Widget build(BuildContext context) { final cells = SelectOptionColorPB.values.map((color) { return _SelectOptionColorCell( color: color, isSelected: selectedColor != null ? selectedColor == color : false, onSelectedColor: onSelectedColor, ); }).toList(); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: GridSize.typeOptionContentInsets, child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyText( LocaleKeys.grid_selectOption_colorPanelTitle.tr(), textAlign: TextAlign.left, color: Theme.of(context).hintColor, ), ), ), ListView.separated( shrinkWrap: true, separatorBuilder: (context, index) { return VSpace(GridSize.typeOptionSeparatorHeight); }, itemCount: cells.length, physics: StyledScrollPhysics(), itemBuilder: (BuildContext context, int index) { return cells[index]; }, ), ], ); } } class _SelectOptionColorCell extends StatelessWidget { const _SelectOptionColorCell({ required this.color, required this.isSelected, required this.onSelectedColor, }); final SelectOptionColorPB color; final bool isSelected; final void Function(SelectOptionColorPB color) onSelectedColor; @override Widget build(BuildContext context) { Widget? checkmark; if (isSelected) { checkmark = const FlowySvg(FlowySvgs.check_s); } final colorIcon = SizedBox.square( dimension: 16, child: DecoratedBox( decoration: BoxDecoration( color: color.toColor(context), shape: BoxShape.circle, ), ), ); return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText( lineHeight: 1.0, color.colorName(), color: AFThemeExtension.of(context).textColor, ), leftIcon: colorIcon, rightIcon: checkmark, onTap: () => onSelectedColor(color), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/single_select.dart ================================================ import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'builder.dart'; import 'select/select_option.dart'; class SingleSelectTypeOptionEditorFactory implements TypeOptionEditorFactory { const SingleSelectTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) { final typeOption = _parseTypeOptionData(field.typeOptionData); return SelectOptionTypeOptionWidget( options: typeOption.options, beginEdit: () => PopoverContainer.of(context).closeAll(), popoverMutex: popoverMutex, typeOptionAction: SingleSelectAction( viewId: viewId, fieldId: field.id, onTypeOptionUpdated: onTypeOptionUpdated, ), ); } SingleSelectTypeOptionPB _parseTypeOptionData(List data) { return SingleSelectTypeOptionDataParser().fromBuffer(data); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/summary.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'builder.dart'; class SummaryTypeOptionEditorFactory implements TypeOptionEditorFactory { const SummaryTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) => null; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/time.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'builder.dart'; class TimeTypeOptionEditorFactory implements TypeOptionEditorFactory { const TimeTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) => null; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/timestamp.dart ================================================ import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:protobuf/protobuf.dart'; import 'builder.dart'; import 'date/date_time_format.dart'; class TimestampTypeOptionEditorFactory implements TypeOptionEditorFactory { const TimestampTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) { final typeOption = _parseTypeOptionData(field.typeOptionData); return SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => VSpace(GridSize.typeOptionSeparatorHeight), children: [ _renderDateFormatButton(typeOption, popoverMutex, onTypeOptionUpdated), _renderTimeFormatButton(typeOption, popoverMutex, onTypeOptionUpdated), Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: IncludeTimeButton( includeTime: typeOption.includeTime, showIcon: false, onChanged: (value) { final newTypeOption = _updateTypeOption( typeOption: typeOption, includeTime: value, ); onTypeOptionUpdated(newTypeOption.writeToBuffer()); }, ), ), ], ); } Widget _renderDateFormatButton( TimestampTypeOptionPB typeOption, PopoverMutex popoverMutex, TypeOptionDataCallback onTypeOptionUpdated, ) { return AppFlowyPopover( mutex: popoverMutex, asBarrier: true, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(8, 0), constraints: BoxConstraints.loose(const Size(460, 440)), popupBuilder: (popoverContext) { return DateFormatList( selectedFormat: typeOption.dateFormat, onSelected: (format) { final newTypeOption = _updateTypeOption(typeOption: typeOption, dateFormat: format); onTypeOptionUpdated(newTypeOption.writeToBuffer()); PopoverContainer.of(popoverContext).close(); }, ); }, child: const Padding( padding: EdgeInsets.symmetric(horizontal: 12.0), child: DateFormatButton(), ), ); } Widget _renderTimeFormatButton( TimestampTypeOptionPB typeOption, PopoverMutex popoverMutex, TypeOptionDataCallback onTypeOptionUpdated, ) { return AppFlowyPopover( mutex: popoverMutex, asBarrier: true, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(8, 0), constraints: BoxConstraints.loose(const Size(460, 440)), popupBuilder: (BuildContext popoverContext) { return TimeFormatList( selectedFormat: typeOption.timeFormat, onSelected: (format) { final newTypeOption = _updateTypeOption(typeOption: typeOption, timeFormat: format); onTypeOptionUpdated(newTypeOption.writeToBuffer()); PopoverContainer.of(popoverContext).close(); }, ); }, child: const Padding( padding: EdgeInsets.symmetric(horizontal: 12.0), child: TimeFormatButton(), ), ); } TimestampTypeOptionPB _parseTypeOptionData(List data) { return TimestampTypeOptionDataParser().fromBuffer(data); } TimestampTypeOptionPB _updateTypeOption({ required TimestampTypeOptionPB typeOption, DateFormatPB? dateFormat, TimeFormatPB? timeFormat, bool? includeTime, }) { typeOption.freeze(); return typeOption.rebuild((typeOption) { if (dateFormat != null) { typeOption.dateFormat = dateFormat; } if (timeFormat != null) { typeOption.timeFormat = timeFormat; } if (includeTime != null) { typeOption.includeTime = includeTime; } }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/translate.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/field/type_option/translate_type_option_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import './builder.dart'; class TranslateTypeOptionEditorFactory implements TypeOptionEditorFactory { const TranslateTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) { final typeOption = TranslateTypeOptionPB.fromBuffer(field.typeOptionData); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText( LocaleKeys.grid_field_translateTo.tr(), ), const HSpace(6), Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: BlocProvider( create: (context) => TranslateTypeOptionBloc(option: typeOption), child: BlocConsumer( listenWhen: (previous, current) => previous.option != current.option, listener: (context, state) { onTypeOptionUpdated(state.option.writeToBuffer()); }, builder: (context, state) { return _wrapLanguageListPopover( context, state, popoverMutex, SelectLanguageButton( language: state.language, ), ); }, ), ), ), ], ), ); } Widget _wrapLanguageListPopover( BuildContext blocContext, TranslateTypeOptionState state, PopoverMutex popoverMutex, Widget child, ) { return AppFlowyPopover( mutex: popoverMutex, asBarrier: true, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(8, 0), constraints: BoxConstraints.loose(const Size(460, 440)), popupBuilder: (popoverContext) { return LanguageList( onSelected: (language) { blocContext .read() .add(TranslateTypeOptionEvent.selectLanguage(language)); PopoverContainer.of(popoverContext).close(); }, selectedLanguage: state.option.language, ); }, child: child, ); } } class SelectLanguageButton extends StatelessWidget { const SelectLanguageButton({required this.language, super.key}); final String language; @override Widget build(BuildContext context) { return SizedBox( height: 30, child: FlowyButton( text: FlowyText( language, lineHeight: 1.0, ), ), ); } } class LanguageList extends StatelessWidget { const LanguageList({ super.key, required this.onSelected, required this.selectedLanguage, }); final Function(TranslateLanguagePB) onSelected; final TranslateLanguagePB selectedLanguage; @override Widget build(BuildContext context) { final cells = TranslateLanguagePB.values.map((languageType) { return LanguageCell( languageType: languageType, onSelected: onSelected, isSelected: languageType == selectedLanguage, ); }).toList(); return SizedBox( width: 180, child: ListView.separated( shrinkWrap: true, separatorBuilder: (context, index) { return VSpace(GridSize.typeOptionSeparatorHeight); }, itemCount: cells.length, itemBuilder: (BuildContext context, int index) { return cells[index]; }, ), ); } } class LanguageCell extends StatelessWidget { const LanguageCell({ required this.languageType, required this.onSelected, required this.isSelected, super.key, }); final Function(TranslateLanguagePB) onSelected; final TranslateLanguagePB languageType; final bool isSelected; @override Widget build(BuildContext context) { Widget? checkmark; if (isSelected) { checkmark = const FlowySvg(FlowySvgs.check_s); } return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText( languageTypeToLanguage(languageType), lineHeight: 1.0, ), rightIcon: checkmark, onTap: () => onSelected(languageType), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/url.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'builder.dart'; class URLTypeOptionEditorFactory implements TypeOptionEditorFactory { const URLTypeOptionEditorFactory(); @override Widget? build({ required BuildContext context, required String viewId, required FieldPB field, required PopoverMutex popoverMutex, required TypeOptionDataCallback onTypeOptionUpdated, }) => null; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/setting/group_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:protobuf/protobuf.dart' hide FieldInfo; class DatabaseGroupList extends StatelessWidget { const DatabaseGroupList({ super.key, required this.viewId, required this.databaseController, required this.onDismissed, }); final String viewId; final DatabaseController databaseController; final VoidCallback onDismissed; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DatabaseGroupBloc( viewId: viewId, databaseController: databaseController, )..add(const DatabaseGroupEvent.initial()), child: BlocBuilder( builder: (context, state) { final field = state.fieldInfos.firstWhereOrNull( (field) => field.fieldType.canBeGroup && field.isGroupField, ); final showHideUngroupedToggle = field?.fieldType != FieldType.Checkbox; DateGroupConfigurationPB? config; if (field != null) { final gs = state.groupSettings .firstWhereOrNull((gs) => gs.fieldId == field.id); config = gs != null ? DateGroupConfigurationPB.fromBuffer(gs.content) : null; } final children = [ if (showHideUngroupedToggle) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( resetHoverOnRebuild: false, text: FlowyText( LocaleKeys.board_showUngrouped.tr(), lineHeight: 1.0, ), onTap: () { _updateLayoutSettings( state.layoutSettings, !state.layoutSettings.hideUngroupedColumn, ); }, rightIcon: Toggle( value: !state.layoutSettings.hideUngroupedColumn, onChanged: (value) => _updateLayoutSettings(state.layoutSettings, !value), padding: EdgeInsets.zero, ), ), ), ), const TypeOptionSeparator(spacing: 0), ], SizedBox( height: GridSize.popoverItemHeight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: FlowyText( LocaleKeys.board_groupBy.tr(), textAlign: TextAlign.left, color: Theme.of(context).hintColor, ), ), ), ...state.fieldInfos .where((fieldInfo) => fieldInfo.fieldType.canBeGroup) .map( (fieldInfo) => _GridGroupCell( fieldInfo: fieldInfo, name: fieldInfo.name, checked: fieldInfo.isGroupField, onSelected: onDismissed, key: ValueKey(fieldInfo.id), ), ), if (field?.fieldType.groupConditions.isNotEmpty ?? false) ...[ const TypeOptionSeparator(spacing: 0), SizedBox( height: GridSize.popoverItemHeight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: FlowyText( LocaleKeys.board_groupCondition.tr(), textAlign: TextAlign.left, color: Theme.of(context).hintColor, ), ), ), ...field!.fieldType.groupConditions.map( (condition) => _GridGroupCell( fieldInfo: field, name: condition.name, condition: condition.value, onSelected: onDismissed, checked: config?.condition == condition, ), ), ], ]; return ListView.separated( shrinkWrap: true, itemCount: children.length, itemBuilder: (BuildContext context, int index) => children[index], separatorBuilder: (BuildContext context, int index) => VSpace(GridSize.typeOptionSeparatorHeight), padding: const EdgeInsets.symmetric(vertical: 6.0), ); }, ), ); } Future _updateLayoutSettings( BoardLayoutSettingPB layoutSettings, bool hideUngrouped, ) { layoutSettings.freeze(); final newLayoutSetting = layoutSettings.rebuild((message) { message.hideUngroupedColumn = hideUngrouped; }); return databaseController.updateLayoutSetting( boardLayoutSetting: newLayoutSetting, ); } } class _GridGroupCell extends StatelessWidget { const _GridGroupCell({ super.key, required this.fieldInfo, required this.onSelected, required this.checked, required this.name, this.condition = 0, }); final FieldInfo fieldInfo; final VoidCallback onSelected; final bool checked; final int condition; final String name; @override Widget build(BuildContext context) { Widget? rightIcon; if (checked) { rightIcon = const Padding( padding: EdgeInsets.all(2.0), child: FlowySvg(FlowySvgs.check_s), ); } return SizedBox( height: GridSize.popoverItemHeight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0), child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText( name, color: AFThemeExtension.of(context).textColor, lineHeight: 1.0, ), leftIcon: FieldIcon(fieldInfo: fieldInfo), rightIcon: rightIcon, onTap: () { List settingContent = []; switch (fieldInfo.fieldType) { case FieldType.DateTime: final config = DateGroupConfigurationPB() ..condition = DateConditionPB.values[condition]; settingContent = config.writeToBuffer(); break; default: } context.read().add( DatabaseGroupEvent.setGroupByField( fieldInfo.id, fieldInfo.fieldType, settingContent, ), ); onSelected(); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; extension FileTypeDisplay on MediaFileTypePB { FlowySvgData get icon => switch (this) { MediaFileTypePB.Image => FlowySvgs.image_s, MediaFileTypePB.Link => FlowySvgs.ft_link_s, MediaFileTypePB.Document => FlowySvgs.icon_document_s, MediaFileTypePB.Archive => FlowySvgs.ft_archive_s, MediaFileTypePB.Video => FlowySvgs.ft_video_s, MediaFileTypePB.Audio => FlowySvgs.ft_audio_s, MediaFileTypePB.Text => FlowySvgs.ft_text_s, _ => FlowySvgs.icon_document_s, }; Color get color => switch (this) { MediaFileTypePB.Image => const Color(0xFF5465A1), MediaFileTypePB.Link => const Color(0xFFEBE4FF), MediaFileTypePB.Audio => const Color(0xFFE4FFDE), MediaFileTypePB.Video => const Color(0xFFE0F8FF), MediaFileTypePB.Archive => const Color(0xFFFFE7EE), MediaFileTypePB.Text || MediaFileTypePB.Document || MediaFileTypePB.Other => const Color(0xFFF5FFDC), _ => const Color(0xFF87B3A8), }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:styled_widget/styled_widget.dart'; import '../../cell/editable_cell_builder.dart'; class GridCellAccessoryBuildContext { GridCellAccessoryBuildContext({ required this.anchorContext, required this.isCellEditing, }); final BuildContext anchorContext; final bool isCellEditing; } class GridCellAccessoryBuilder> { GridCellAccessoryBuilder({required Widget Function(Key key) builder}) : _builder = builder; final GlobalKey _key = GlobalKey(); final Widget Function(Key key) _builder; Widget build() => _builder(_key); void onTap() { (_key.currentState as GridCellAccessoryState).onTap(); } bool enable() { if (_key.currentState == null) { return true; } return (_key.currentState as GridCellAccessoryState).enable(); } } abstract mixin class GridCellAccessoryState { void onTap(); // The accessory will be hidden if enable() return false; bool enable() => true; } class PrimaryCellAccessory extends StatefulWidget { const PrimaryCellAccessory({ super.key, required this.onTap, required this.isCellEditing, }); final VoidCallback onTap; final bool isCellEditing; @override State createState() => _PrimaryCellAccessoryState(); } class _PrimaryCellAccessoryState extends State with GridCellAccessoryState { @override Widget build(BuildContext context) { return FlowyHover( style: HoverStyle( hoverColor: AFThemeExtension.of(context).lightGreyHover, backgroundColor: Theme.of(context).cardColor, ), builder: (_, onHover) { return FlowyTooltip( message: LocaleKeys.tooltip_openAsPage.tr(), child: Container( width: 26, height: 26, decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide(color: Theme.of(context).dividerColor), ), borderRadius: Corners.s6Border, ), child: Center( child: FlowySvg( FlowySvgs.full_view_s, color: Theme.of(context).colorScheme.primary, ), ), ), ); }, ); } @override void onTap() => widget.onTap(); @override bool enable() => !widget.isCellEditing; } class AccessoryHover extends StatefulWidget { const AccessoryHover({ super.key, required this.child, required this.fieldType, }); final CellAccessory child; final FieldType fieldType; @override State createState() => _AccessoryHoverState(); } class _AccessoryHoverState extends State { bool _isHover = false; @override Widget build(BuildContext context) { // Some FieldType has built-in handling for more gestures // and granular control, so we don't need to show the accessory. if (!widget.fieldType.showRowDetailAccessory) { return widget.child; } final List children = [ DecoratedBox( decoration: BoxDecoration( color: _isHover && widget.fieldType != FieldType.Checklist ? AFThemeExtension.of(context).lightGreyHover : Colors.transparent, borderRadius: Corners.s6Border, ), child: widget.child, ), ]; final accessoryBuilder = widget.child.accessoryBuilder; if (accessoryBuilder != null && _isHover) { final accessories = accessoryBuilder( GridCellAccessoryBuildContext( anchorContext: context, isCellEditing: false, ), ); children.add( Padding( padding: const EdgeInsets.only(right: 6), child: CellAccessoryContainer(accessories: accessories), ).positioned(right: 0), ); } return MouseRegion( cursor: SystemMouseCursors.click, opaque: false, onEnter: (p) => setState(() => _isHover = true), onExit: (p) => setState(() => _isHover = false), child: Stack( alignment: AlignmentDirectional.center, children: children, ), ); } } class CellAccessoryContainer extends StatelessWidget { const CellAccessoryContainer({required this.accessories, super.key}); final List accessories; @override Widget build(BuildContext context) { final children = accessories.where((accessory) => accessory.enable()).map((accessory) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => accessory.onTap(), child: accessory.build(), ); }).toList(); return SeparatedRow( separatorBuilder: () => const HSpace(6), children: children, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_shortcuts.dart ================================================ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; typedef CellKeyboardAction = dynamic Function(); enum CellKeyboardKey { onEnter, onCopy, onInsert, } abstract class CellShortcuts extends Widget { const CellShortcuts({super.key}); Map get shortcutHandlers; } class GridCellShortcuts extends StatelessWidget { const GridCellShortcuts({required this.child, super.key}); final CellShortcuts child; @override Widget build(BuildContext context) { return Shortcuts( shortcuts: shortcuts, child: Actions( actions: actions, child: child, ), ); } Map get shortcuts => { if (shouldAddKeyboardKey(CellKeyboardKey.onEnter)) LogicalKeySet(LogicalKeyboardKey.enter): const GridCellEnterIdent(), if (shouldAddKeyboardKey(CellKeyboardKey.onCopy)) LogicalKeySet( Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyC, ): const GridCellCopyIntent(), }; Map> get actions => { if (shouldAddKeyboardKey(CellKeyboardKey.onEnter)) GridCellEnterIdent: GridCellEnterAction(child: child), if (shouldAddKeyboardKey(CellKeyboardKey.onCopy)) GridCellCopyIntent: GridCellCopyAction(child: child), }; bool shouldAddKeyboardKey(CellKeyboardKey key) => child.shortcutHandlers.containsKey(key); } class GridCellEnterIdent extends Intent { const GridCellEnterIdent(); } class GridCellEnterAction extends Action { GridCellEnterAction({required this.child}); final CellShortcuts child; @override void invoke(covariant GridCellEnterIdent intent) { final callback = child.shortcutHandlers[CellKeyboardKey.onEnter]; if (callback != null) { callback(); } } } class GridCellCopyIntent extends Intent { const GridCellCopyIntent(); } class GridCellCopyAction extends Action { GridCellCopyAction({required this.child}); final CellShortcuts child; @override void invoke(covariant GridCellCopyIntent intent) { final callback = child.shortcutHandlers[CellKeyboardKey.onCopy]; if (callback == null) { return; } final s = callback(); if (s is String) { Clipboard.setData(ClipboardData(text: s)); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart ================================================ import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import '../../../grid/presentation/layout/sizes.dart'; import '../../../grid/presentation/widgets/row/row.dart'; import '../../cell/editable_cell_builder.dart'; import '../accessory/cell_accessory.dart'; import '../accessory/cell_shortcuts.dart'; class CellContainer extends StatelessWidget { const CellContainer({ super.key, required this.child, required this.width, required this.isPrimary, this.accessoryBuilder, }); final EditableCellWidget child; final AccessoryBuilder? accessoryBuilder; final double width; final bool isPrimary; @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: child.cellContainerNotifier, child: Selector( selector: (context, notifier) => notifier.isFocus, builder: (providerContext, isFocus, _) { Widget container = Center(child: GridCellShortcuts(child: child)); if (accessoryBuilder != null) { final accessories = accessoryBuilder!.call( GridCellAccessoryBuildContext( anchorContext: context, isCellEditing: isFocus, ), ); if (accessories.isNotEmpty) { container = _GridCellEnterRegion( accessories: accessories, isPrimary: isPrimary, child: container, ); } } return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { if (!isFocus) { child.requestFocus.notify(); } }, child: Container( constraints: BoxConstraints(maxWidth: width, minHeight: 32), decoration: _makeBoxDecoration(context, isFocus), child: container, ), ); }, ), ); } BoxDecoration _makeBoxDecoration(BuildContext context, bool isFocus) { if (isFocus) { final borderSide = BorderSide( color: Theme.of(context).colorScheme.primary, ); return BoxDecoration(border: Border.fromBorderSide(borderSide)); } final borderSide = BorderSide(color: AFThemeExtension.of(context).borderColor); return BoxDecoration( border: Border(right: borderSide, bottom: borderSide), ); } } class _GridCellEnterRegion extends StatelessWidget { const _GridCellEnterRegion({ required this.child, required this.accessories, required this.isPrimary, }); final Widget child; final List accessories; final bool isPrimary; @override Widget build(BuildContext context) { return Selector2( selector: (context, regionNotifier, cellNotifier) => !cellNotifier.isFocus && (cellNotifier.isHover || regionNotifier.onEnter && isPrimary), builder: (context, showAccessory, _) { final List children = [child]; if (showAccessory) { children.add( CellAccessoryContainer(accessories: accessories).positioned( right: GridSize.cellContentInsets.right, ), ); } return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (p) => CellContainerNotifier.of(context, listen: false).isHover = true, onExit: (p) => CellContainerNotifier.of(context, listen: false).isHover = false, child: Stack( alignment: Alignment.center, fit: StackFit.expand, children: children, ), ); }, ); } } class CellContainerNotifier extends ChangeNotifier { bool _isFocus = false; bool _onEnter = false; set isFocus(bool value) { if (_isFocus != value) { _isFocus = value; notifyListeners(); } } set isHover(bool value) { if (_onEnter != value) { _onEnter = value; notifyListeners(); } } bool get isFocus => _isFocus; bool get isHover => _onEnter; static CellContainerNotifier of(BuildContext context, {bool listen = true}) { return Provider.of(context, listen: listen); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/mobile_cell_container.dart ================================================ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../cell/editable_cell_builder.dart'; import 'cell_container.dart'; class MobileCellContainer extends StatelessWidget { const MobileCellContainer({ super.key, required this.child, required this.isPrimary, this.onPrimaryFieldCellTap, }); final EditableCellWidget child; final bool isPrimary; final VoidCallback? onPrimaryFieldCellTap; @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: child.cellContainerNotifier, child: Selector( selector: (context, notifier) => notifier.isFocus, builder: (providerContext, isFocus, _) { Widget container = Center(child: child); if (isPrimary) { container = IgnorePointer(child: container); } return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { if (isPrimary) { onPrimaryFieldCellTap?.call(); return; } if (!isFocus) { child.requestFocus.notify(); } }, child: Container( constraints: const BoxConstraints(maxWidth: 200, minHeight: 46), decoration: _makeBoxDecoration(context, isPrimary, isFocus), child: container, ), ); }, ), ); } BoxDecoration _makeBoxDecoration( BuildContext context, bool isPrimary, bool isFocus, ) { if (isFocus) { return BoxDecoration( border: Border.fromBorderSide( BorderSide( color: Theme.of(context).colorScheme.primary, ), ), ); } final borderSide = BorderSide(color: Theme.of(context).dividerColor); return BoxDecoration( border: Border( left: isPrimary ? borderSide : BorderSide.none, right: borderSide, bottom: borderSide, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'row_detail.dart'; class RelatedRowDetailPage extends StatelessWidget { const RelatedRowDetailPage({ super.key, required this.databaseId, required this.rowId, }); final String databaseId; final String rowId; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => RelatedRowDetailPageBloc( databaseId: databaseId, initialRowId: rowId, ), child: BlocBuilder( builder: (_, state) { return state.when( loading: () => const SizedBox.shrink(), ready: (databaseController, rowController) { return BlocProvider.value( value: context.read(), child: RowDetailPage( databaseController: databaseController, rowController: rowController, allowOpenAsFullPage: false, ), ); }, ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class RowActionList extends StatelessWidget { const RowActionList({super.key, required this.rowController}); final RowController rowController; @override Widget build(BuildContext context) { return IntrinsicWidth( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ RowDetailPageDuplicateButton( viewId: rowController.viewId, rowId: rowController.rowId, ), const VSpace(4.0), RowDetailPageDeleteButton( viewId: rowController.viewId, rowId: rowController.rowId, ), ], ), ); } } class RowDetailPageDeleteButton extends StatelessWidget { const RowDetailPageDeleteButton({ super.key, required this.viewId, required this.rowId, }); final String viewId; final String rowId; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText.regular( LocaleKeys.grid_row_delete.tr(), lineHeight: 1.0, ), leftIcon: const FlowySvg(FlowySvgs.trash_m), onTap: () { RowBackendService.deleteRows(viewId, [rowId]); FlowyOverlay.pop(context); }, ), ); } } class RowDetailPageDuplicateButton extends StatelessWidget { const RowDetailPageDuplicateButton({ super.key, required this.viewId, required this.rowId, }); final String viewId; final String rowId; @override Widget build(BuildContext context) { return SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText.regular( LocaleKeys.grid_row_duplicate.tr(), lineHeight: 1.0, ), leftIcon: const FlowySvg(FlowySvgs.copy_s), onTap: () { RowBackendService.duplicateRow(viewId, rowId); FlowyOverlay.pop(context); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_banner_bloc.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/widgets/row/row_action.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; import 'package:appflowy/plugins/shared/cover_type_ext.dart'; import 'package:appflowy/shared/af_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../../../shared/icon_emoji_picker/tab.dart'; import '../../../document/presentation/editor_plugins/plugins.dart'; /// We have the cover height as public as it is used in the row_detail.dart file /// Used to determine the position of the row actions depending on if there is a cover or not. /// const rowCoverHeight = 250.0; const _iconHeight = 60.0; const _toolbarHeight = 40.0; class RowBanner extends StatefulWidget { const RowBanner({ super.key, required this.databaseController, required this.rowController, required this.cellBuilder, this.allowOpenAsFullPage = true, this.userProfile, }); final DatabaseController databaseController; final RowController rowController; final EditableCellBuilder cellBuilder; final bool allowOpenAsFullPage; final UserProfilePB? userProfile; @override State createState() => _RowBannerState(); } class _RowBannerState extends State { final _isHovering = ValueNotifier(false); late final isLocalMode = (widget.userProfile?.workspaceType ?? WorkspaceTypePB.LocalW) == WorkspaceTypePB.LocalW; @override void dispose() { _isHovering.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (context) => RowBannerBloc( viewId: widget.rowController.viewId, fieldController: widget.databaseController.fieldController, rowMeta: widget.rowController.rowMeta, )..add(const RowBannerEvent.initial()), child: BlocBuilder( builder: (context, state) { final hasCover = state.rowMeta.cover.data.isNotEmpty; final hasIcon = state.rowMeta.icon.isNotEmpty; return Column( children: [ LayoutBuilder( builder: (context, constraints) { return Stack( children: [ SizedBox( height: _calculateOverallHeight(hasIcon, hasCover), width: constraints.maxWidth, child: RowHeaderToolbar( offset: GridSize.horizontalHeaderPadding + 20, hasIcon: hasIcon, hasCover: hasCover, onIconChanged: (icon) { if (icon != null) { context .read() .add(RowBannerEvent.setIcon(icon)); } }, onCoverChanged: (cover) { if (cover != null) { context .read() .add(RowBannerEvent.setCover(cover)); } }, ), ), if (hasCover) RowCover( rowId: widget.rowController.rowId, cover: state.rowMeta.cover, userProfile: widget.userProfile, onCoverChanged: (type, details, uploadType) { if (details != null) { context.read().add( RowBannerEvent.setCover( RowCoverPB( data: details, uploadType: uploadType, coverType: type.into(), ), ), ); } else { context .read() .add(const RowBannerEvent.removeCover()); } }, isLocalMode: isLocalMode, ), if (hasIcon) Positioned( left: GridSize.horizontalHeaderPadding + 20, bottom: hasCover ? _toolbarHeight - _iconHeight / 2 : _toolbarHeight, child: RowIcon( ///TODO: avoid hardcoding for [FlowyIconType] icon: EmojiIconData( FlowyIconType.emoji, state.rowMeta.icon, ), onIconChanged: (icon) { if (icon == null || icon.isEmpty) { context .read() .add(const RowBannerEvent.setIcon("")); } else { context .read() .add(RowBannerEvent.setIcon(icon)); } }, ), ), ], ); }, ), const VSpace(8), _BannerTitle( cellBuilder: widget.cellBuilder, rowController: widget.rowController, ), ], ); }, ), ); } double _calculateOverallHeight(bool hasIcon, bool hasCover) { switch ((hasIcon, hasCover)) { case (true, true): return rowCoverHeight + _toolbarHeight; case (true, false): return 50 + _iconHeight + _toolbarHeight; case (false, true): return rowCoverHeight + _toolbarHeight; case (false, false): return _toolbarHeight; } } } class RowCover extends StatefulWidget { const RowCover({ super.key, required this.rowId, required this.cover, this.userProfile, required this.onCoverChanged, this.isLocalMode = true, }); final String rowId; final RowCoverPB cover; final UserProfilePB? userProfile; final void Function( CoverType type, String? details, FileUploadTypePB? uploadType, ) onCoverChanged; final bool isLocalMode; @override State createState() => _RowCoverState(); } class _RowCoverState extends State { final popoverController = PopoverController(); bool isOverlayButtonsHidden = true; bool isPopoverOpen = false; @override Widget build(BuildContext context) { return SizedBox( height: rowCoverHeight, child: MouseRegion( onEnter: (_) => setState(() => isOverlayButtonsHidden = false), onExit: (_) => setState(() => isOverlayButtonsHidden = true), child: Stack( children: [ SizedBox( width: double.infinity, child: DesktopRowCover( cover: widget.cover, userProfile: widget.userProfile, ), ), if (!isOverlayButtonsHidden || isPopoverOpen) _buildCoverOverlayButtons(context), ], ), ), ); } Widget _buildCoverOverlayButtons(BuildContext context) { return Positioned( bottom: 20, right: 50, child: Row( mainAxisSize: MainAxisSize.min, children: [ AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, offset: const Offset(0, 8), direction: PopoverDirection.bottomWithCenterAligned, constraints: const BoxConstraints( maxWidth: 540, maxHeight: 360, minHeight: 80, ), margin: EdgeInsets.zero, onClose: () => setState(() => isPopoverOpen = false), child: IntrinsicWidth( child: RoundedTextButton( height: 28.0, onPressed: () => popoverController.show(), hoverColor: Theme.of(context).colorScheme.surface, textColor: Theme.of(context).colorScheme.tertiary, fillColor: Theme.of(context) .colorScheme .surface .withValues(alpha: 0.5), title: LocaleKeys.document_plugins_cover_changeCover.tr(), ), ), popupBuilder: (BuildContext popoverContext) { isPopoverOpen = true; return UploadImageMenu( limitMaximumImageSize: !widget.isLocalMode, supportTypes: const [ UploadImageType.color, UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, ], onSelectedAIImage: (_) => throw UnimplementedError(), onSelectedLocalImages: (files) { popoverController.close(); if (files.isEmpty) { return; } final item = files.map((file) => file.path).first; onCoverChanged( CoverType.file, item, widget.isLocalMode ? FileUploadTypePB.LocalFile : FileUploadTypePB.CloudFile, ); }, onSelectedNetworkImage: (url) { popoverController.close(); onCoverChanged( CoverType.file, url, FileUploadTypePB.NetworkFile, ); }, onSelectedColor: (color) { popoverController.close(); onCoverChanged( CoverType.color, color, FileUploadTypePB.LocalFile, ); }, ); }, ), const HSpace(10), DeleteCoverButton( onTap: () => widget.onCoverChanged(CoverType.none, null, null), ), ], ), ); } Future onCoverChanged( CoverType type, String? details, FileUploadTypePB? uploadType, ) async { if (type == CoverType.file && details != null && !isURL(details)) { if (widget.isLocalMode) { details = await saveImageToLocalStorage(details); } else { // else we should save the image to cloud storage (details, _) = await saveImageToCloudStorage(details, widget.rowId); } } widget.onCoverChanged(type, details, uploadType); } } class DesktopRowCover extends StatefulWidget { const DesktopRowCover({super.key, required this.cover, this.userProfile}); final RowCoverPB cover; final UserProfilePB? userProfile; @override State createState() => _DesktopRowCoverState(); } class _DesktopRowCoverState extends State { RowCoverPB get cover => widget.cover; @override Widget build(BuildContext context) { if (cover.coverType == CoverTypePB.FileCover) { return SizedBox( height: rowCoverHeight, width: double.infinity, child: AFImage( url: cover.data, uploadType: cover.uploadType, userProfile: widget.userProfile, ), ); } if (cover.coverType == CoverTypePB.AssetCover) { return SizedBox( height: rowCoverHeight, width: double.infinity, child: Image.asset( PageStyleCoverImageType.builtInImagePath(cover.data), fit: BoxFit.cover, ), ); } if (cover.coverType == CoverTypePB.ColorCover) { final color = FlowyTint.fromId(cover.data)?.color(context) ?? cover.data.tryToColor(); return Container( height: rowCoverHeight, width: double.infinity, color: color, ); } if (cover.coverType == CoverTypePB.GradientCover) { return Container( height: rowCoverHeight, width: double.infinity, decoration: BoxDecoration( gradient: FlowyGradientColor.fromId(cover.data).linear, ), ); } return const SizedBox.shrink(); } } class RowHeaderToolbar extends StatefulWidget { const RowHeaderToolbar({ super.key, required this.offset, required this.hasIcon, required this.hasCover, required this.onIconChanged, required this.onCoverChanged, }); final double offset; final bool hasIcon; final bool hasCover; /// Returns null if the icon is removed. /// final void Function(String? icon) onIconChanged; /// Returns null if the cover is removed. /// final void Function(RowCoverPB? cover) onCoverChanged; @override State createState() => _RowHeaderToolbarState(); } class _RowHeaderToolbarState extends State { final popoverController = PopoverController(); final bool isDesktop = UniversalPlatform.isDesktopOrWeb; bool isHidden = UniversalPlatform.isDesktopOrWeb; bool isPopoverOpen = false; @override Widget build(BuildContext context) { if (!isDesktop) { return const SizedBox.shrink(); } return MouseRegion( opaque: false, onEnter: (_) => setState(() => isHidden = false), onExit: isPopoverOpen ? null : (_) => setState(() => isHidden = true), child: Container( alignment: Alignment.bottomLeft, width: double.infinity, padding: EdgeInsets.symmetric(horizontal: widget.offset), child: SizedBox( height: 28, child: Visibility( visible: !isHidden || isPopoverOpen, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!widget.hasCover) FlowyButton( resetHoverOnRebuild: false, useIntrinsicWidth: true, leftIconSize: const Size.square(18), leftIcon: const FlowySvg(FlowySvgs.add_cover_s), text: FlowyText.small( LocaleKeys.document_plugins_cover_addCover.tr(), ), onTap: () => widget.onCoverChanged( RowCoverPB( data: isDesktop ? '1' : '0xffe8e0ff', uploadType: FileUploadTypePB.LocalFile, coverType: isDesktop ? CoverTypePB.AssetCover : CoverTypePB.ColorCover, ), ), ), if (!widget.hasIcon) AppFlowyPopover( controller: popoverController, onClose: () => setState(() => isPopoverOpen = false), offset: const Offset(0, 8), direction: PopoverDirection.bottomWithCenterAligned, constraints: BoxConstraints.loose(const Size(360, 380)), margin: EdgeInsets.zero, triggerActions: PopoverTriggerFlags.none, popupBuilder: (_) { isPopoverOpen = true; return FlowyIconEmojiPicker( tabs: const [PickerTabType.emoji], onSelectedEmoji: (result) { widget.onIconChanged(result.emoji); popoverController.close(); }, ); }, child: FlowyButton( useIntrinsicWidth: true, leftIconSize: const Size.square(18), leftIcon: const FlowySvg(FlowySvgs.add_icon_s), text: FlowyText.small( widget.hasIcon ? LocaleKeys.document_plugins_cover_removeIcon.tr() : LocaleKeys.document_plugins_cover_addIcon.tr(), ), onTap: () async { if (!isDesktop) { final result = await context.push( MobileEmojiPickerScreen.routeName, ); if (result != null) { widget.onIconChanged(result.emoji); } } else { popoverController.show(); } }, ), ), ], ), ), ), ), ); } } class RowIcon extends StatefulWidget { const RowIcon({ super.key, required this.icon, required this.onIconChanged, }); final EmojiIconData icon; final void Function(String?) onIconChanged; @override State createState() => _RowIconState(); } class _RowIconState extends State { final controller = PopoverController(); @override Widget build(BuildContext context) { if (widget.icon.isEmpty) { return const SizedBox.shrink(); } return AppFlowyPopover( controller: controller, offset: const Offset(0, 8), direction: PopoverDirection.bottomWithCenterAligned, constraints: BoxConstraints.loose(const Size(360, 380)), margin: EdgeInsets.zero, popupBuilder: (_) => FlowyIconEmojiPicker( tabs: const [PickerTabType.emoji], onSelectedEmoji: (result) { controller.close(); widget.onIconChanged(result.emoji); }, ), child: EmojiIconWidget(emoji: widget.icon), ); } } class _BannerTitle extends StatelessWidget { const _BannerTitle({ required this.cellBuilder, required this.rowController, }); final EditableCellBuilder cellBuilder; final RowController rowController; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final children = [ if (state.primaryField != null) Expanded( child: cellBuilder.buildCustom( CellContext( fieldId: state.primaryField!.id, rowId: rowController.rowId, ), skinMap: EditableCellSkinMap(textSkin: _TitleSkin()), ), ), ]; return Padding( padding: const EdgeInsets.only(left: 60), child: Row(children: children), ); }, ); } } class _TitleSkin extends IEditableTextCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): () => focusNode.unfocus(), const SimpleActivator(LogicalKeyboardKey.enter): () => focusNode.unfocus(), }, child: TextField( controller: textEditingController, focusNode: focusNode, autofocus: true, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 28), maxLines: null, decoration: InputDecoration( contentPadding: EdgeInsets.zero, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, hintText: LocaleKeys.grid_row_titlePlaceholder.tr(), isDense: true, isCollapsed: true, ), onEditingComplete: () { bloc.add(TextCellEvent.updateText(textEditingController.text)); }, ), ); } } class RowActionButton extends StatelessWidget { const RowActionButton({super.key, required this.rowController}); final RowController rowController; @override Widget build(BuildContext context) { return AppFlowyPopover( direction: PopoverDirection.bottomWithLeftAligned, popupBuilder: (context) => RowActionList(rowController: rowController), child: FlowyTooltip( message: LocaleKeys.grid_rowPage_moreRowActions.tr(), child: FlowyIconButton( width: 20, height: 20, icon: const FlowySvg(FlowySvgs.details_horizontal_s), iconColorOnHover: Theme.of(context).colorScheme.onSurface, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/row_document.dart'; import 'package:appflowy/plugins/database_document/database_document_plugin.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import '../cell/editable_cell_builder.dart'; import 'row_banner.dart'; import 'row_property.dart'; class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate { const RowDetailPage({ super.key, required this.rowController, required this.databaseController, this.allowOpenAsFullPage = true, this.userProfile, }); final RowController rowController; final DatabaseController databaseController; final bool allowOpenAsFullPage; final UserProfilePB? userProfile; @override State createState() => _RowDetailPageState(); } class _RowDetailPageState extends State { // To allow blocking drop target in RowDocument from Field dialogs final dropManagerState = EditorDropManagerState(); late final cellBuilder = EditableCellBuilder( databaseController: widget.databaseController, ); late final ScrollController scrollController; double scrollOffset = 0; @override void initState() { super.initState(); scrollController = ScrollController(onAttach: (_) => attachScrollListener()); } void attachScrollListener() => scrollController.addListener(onScrollChanged); @override void dispose() { scrollController.removeListener(onScrollChanged); scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FlowyDialog( child: ChangeNotifierProvider.value( value: dropManagerState, child: MultiBlocProvider( providers: [ BlocProvider( create: (_) => RowDetailBloc( fieldController: widget.databaseController.fieldController, rowController: widget.rowController, ), ), BlocProvider.value(value: getIt()), ], child: BlocBuilder( builder: (context, state) => Stack( fit: StackFit.expand, children: [ Positioned.fill( child: NestedScrollView( controller: scrollController, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ SliverToBoxAdapter( child: Column( children: [ RowBanner( databaseController: widget.databaseController, rowController: widget.rowController, cellBuilder: cellBuilder, allowOpenAsFullPage: widget.allowOpenAsFullPage, userProfile: widget.userProfile, ), const VSpace(16), Padding( padding: const EdgeInsets.only(left: 40, right: 60), child: RowPropertyList( cellBuilder: cellBuilder, viewId: widget.databaseController.viewId, fieldController: widget.databaseController.fieldController, ), ), const VSpace(20), const Padding( padding: EdgeInsets.symmetric(horizontal: 60), child: Divider(height: 1.0), ), const VSpace(20), ], ), ), ]; }, body: RowDocument( viewId: widget.rowController.viewId, rowId: widget.rowController.rowId, ), ), ), Positioned( top: calculateActionsOffset( state.rowMeta.cover.data.isNotEmpty, ), right: 12, child: Row(children: actions(context)), ), ], ), ), ), ), ); } void onScrollChanged() { if (scrollOffset != scrollController.offset) { setState(() => scrollOffset = scrollController.offset); } } double calculateActionsOffset(bool hasCover) { if (!hasCover) { return 12; } final offsetByScroll = clampDouble( rowCoverHeight - scrollOffset, 0, rowCoverHeight, ); return 12 + offsetByScroll; } List actions(BuildContext context) { return [ if (widget.allowOpenAsFullPage) ...[ FlowyTooltip( message: LocaleKeys.grid_rowPage_openAsFullPage.tr(), child: FlowyIconButton( width: 20, height: 20, icon: const FlowySvg(FlowySvgs.full_view_s), iconColorOnHover: Theme.of(context).colorScheme.onSurface, onPressed: () async { Navigator.of(context).pop(); final databaseId = await DatabaseViewBackendService( viewId: widget.databaseController.viewId, ) .getDatabaseId() .then((value) => value.fold((s) => s, (f) => null)); final documentId = widget.rowController.rowMeta.documentId; if (databaseId != null) { getIt().add( TabsEvent.openPlugin( plugin: DatabaseDocumentPlugin( data: DatabaseDocumentContext( view: widget.databaseController.view, databaseId: databaseId, rowId: widget.rowController.rowId, documentId: documentId, ), pluginType: PluginType.databaseDocument, ), setLatest: false, ), ); } }, ), ), const HSpace(4), ], RowActionButton(rowController: widget.rowController), ]; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_document_bloc.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; class RowDocument extends StatelessWidget { const RowDocument({ super.key, required this.viewId, required this.rowId, }); final String viewId; final String rowId; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => RowDocumentBloc(viewId: viewId, rowId: rowId) ..add(const RowDocumentEvent.initial()), child: BlocConsumer( listener: (_, state) => state.loadingState.maybeWhen( error: (error) => Log.error('RowDocument error: $error'), orElse: () => null, ), builder: (context, state) { return state.loadingState.when( loading: () => const Center( child: CircularProgressIndicator.adaptive(), ), error: (error) => Center( child: AppFlowyErrorPage( error: error, ), ), finish: () => _RowEditor( view: state.viewPB!, onIsEmptyChanged: (isEmpty) => context .read() .add(RowDocumentEvent.updateIsEmpty(isEmpty)), ), ); }, ), ); } } class _RowEditor extends StatelessWidget { const _RowEditor({ required this.view, this.onIsEmptyChanged, }); final ViewPB view; final void Function(bool)? onIsEmptyChanged; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (_) => DocumentBloc(documentId: view.id) ..add(const DocumentEvent.initial()), ), BlocProvider( create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), ), ], child: BlocConsumer( listenWhen: (previous, current) => previous.isDocumentEmpty != current.isDocumentEmpty, listener: (_, state) { if (state.isDocumentEmpty != null) { onIsEmptyChanged?.call(state.isDocumentEmpty!); } if (state.error != null) { Log.error('RowEditor error: ${state.error}'); } if (state.editorState == null) { Log.error('RowEditor unable to get editorState'); } }, builder: (context, state) { if (state.isLoading) { return const Center(child: CircularProgressIndicator.adaptive()); } final editorState = state.editorState; final error = state.error; if (error != null || editorState == null) { return Center( child: AppFlowyErrorPage(error: error), ); } return BlocProvider( create: (context) => ViewInfoBloc(view: view), child: Container( constraints: const BoxConstraints(minHeight: 300), child: Provider( create: (_) { final context = SharedEditorContext(); context.isInDatabaseRowPage = true; return context; }, dispose: (_, editorContext) => editorContext.dispose(), child: AiWriterScrollWrapper( viewId: view.id, editorState: editorState, child: EditorDropHandler( viewId: view.id, editorState: editorState, isLocalMode: context.read().isLocalMode, dropManagerState: context.read(), child: EditorTransactionService( viewId: view.id, editorState: editorState, child: Provider( create: (context) => DatabasePluginWidgetBuilderSize( horizontalPadding: 0, ), child: AppFlowyEditorPage( shrinkWrap: true, autoFocus: false, editorState: editorState, styleCustomizer: EditorStyleCustomizer( context: context, padding: const EdgeInsets.only(left: 16, right: 54), ), showParagraphPlaceholder: (editorState, _) => editorState.document.isEmpty, placeholderText: (_) => LocaleKeys.cardDetails_notesPlaceholder.tr(), ), ), ), ), ), ), ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/plugins/database/widgets/field/field_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; import 'package:appflowy_backend/log.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import '../cell/editable_cell_builder.dart'; import 'accessory/cell_accessory.dart'; /// Display the row properties in a list. Only used in [RowDetailPage]. class RowPropertyList extends StatelessWidget { const RowPropertyList({ super.key, required this.viewId, required this.fieldController, required this.cellBuilder, }); final String viewId; final FieldController fieldController; final EditableCellBuilder cellBuilder; @override Widget build(BuildContext context) { return BlocBuilder( buildWhen: (previous, current) => previous.showHiddenFields != current.showHiddenFields || !listEquals(previous.visibleCells, current.visibleCells), builder: (context, state) { final children = state.visibleCells .mapIndexed( (index, cell) => _PropertyCell( key: ValueKey('row_detail_${cell.fieldId}'), cellContext: cell, cellBuilder: cellBuilder, fieldController: fieldController, index: index, ), ) .toList(); return ReorderableListView( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), onReorder: (from, to) => context .read() .add(RowDetailEvent.reorderField(from, to)), buildDefaultDragHandles: false, proxyDecorator: (child, index, animation) => Material( color: Colors.transparent, child: Stack( children: [ BlocProvider.value( value: context.read(), child: child, ), MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grabbing, child: const SizedBox( width: 16, height: 30, child: FlowySvg(FlowySvgs.drag_element_s), ), ), ], ), ), footer: Padding( padding: const EdgeInsets.only(left: 20), child: Column( children: [ if (context.watch().state.numHiddenFields != 0) const Padding( padding: EdgeInsets.only(bottom: 4.0), child: ToggleHiddenFieldsVisibilityButton(), ), CreateRowFieldButton( viewId: viewId, fieldController: fieldController, ), ], ), ), children: children, ); }, ); } } class _PropertyCell extends StatefulWidget { const _PropertyCell({ super.key, required this.cellContext, required this.cellBuilder, required this.fieldController, required this.index, }); final CellContext cellContext; final EditableCellBuilder cellBuilder; final FieldController fieldController; final int index; @override State createState() => _PropertyCellState(); } class _PropertyCellState extends State<_PropertyCell> { final PopoverController _popoverController = PopoverController(); final ValueNotifier _isFieldHover = ValueNotifier(false); @override Widget build(BuildContext context) { final cell = widget.cellBuilder.buildStyled( widget.cellContext, EditableCellStyle.desktopRowDetail, ); final gesture = GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => cell.requestFocus.notify(), child: AccessoryHover( fieldType: widget.fieldController .getField(widget.cellContext.fieldId)! .fieldType, child: cell, ), ); return Container( margin: const EdgeInsets.only(bottom: 8), constraints: const BoxConstraints(minHeight: 30), child: MouseRegion( onEnter: (event) { _isFieldHover.value = true; cell.cellContainerNotifier.isHover = true; }, onExit: (event) { _isFieldHover.value = false; cell.cellContainerNotifier.isHover = false; }, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ValueListenableBuilder( valueListenable: _isFieldHover, builder: (context, value, _) { return ReorderableDragStartListener( index: widget.index, enabled: value, child: _buildDragHandle(context), ); }, ), const HSpace(4), _buildFieldButton(context), const HSpace(8), Expanded(child: gesture), ], ), ), ); } Widget _buildDragHandle(BuildContext context) { return MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grab, child: SizedBox( width: 16, height: 30, child: BlocListener( listenWhen: (previous, current) => previous.editingFieldId != current.editingFieldId, listener: (context, state) { if (state.editingFieldId == widget.cellContext.fieldId) { WidgetsBinding.instance.addPostFrameCallback((_) { _popoverController.show(); }); } }, child: ValueListenableBuilder( valueListenable: _isFieldHover, builder: (_, isHovering, child) => isHovering ? child! : const SizedBox.shrink(), child: BlockActionButton( onTap: () => context.read().add( RowDetailEvent.startEditingField( widget.cellContext.fieldId, ), ), svg: FlowySvgs.drag_element_s, richMessage: TextSpan( text: LocaleKeys.grid_rowPage_fieldDragElementTooltip.tr(), style: context.tooltipTextStyle(), ), ), ), ), ), ); } Widget _buildFieldButton(BuildContext context) { return BlocSelector( selector: (state) => state.fields.firstWhereOrNull( (fieldInfo) => fieldInfo.field.id == widget.cellContext.fieldId, ), builder: (context, fieldInfo) { if (fieldInfo == null) { return const SizedBox.shrink(); } return AppFlowyPopover( controller: _popoverController, constraints: BoxConstraints.loose(const Size(240, 600)), margin: EdgeInsets.zero, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, onClose: () => context .read() .add(const RowDetailEvent.endEditingField()), popupBuilder: (popoverContext) => FieldEditor( viewId: widget.fieldController.viewId, fieldInfo: fieldInfo, fieldController: widget.fieldController, isNewField: context.watch().state.newFieldId == widget.cellContext.fieldId, ), child: SizedBox( width: 160, height: 30, child: Tooltip( waitDuration: const Duration(seconds: 1), preferBelow: false, verticalOffset: 15, message: fieldInfo.name, child: FieldCellButton( field: fieldInfo.field, onTap: () => context.read().add( RowDetailEvent.startEditingField( widget.cellContext.fieldId, ), ), radius: BorderRadius.circular(6), margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), ), ), ), ); }, ); } } class ToggleHiddenFieldsVisibilityButton extends StatelessWidget { const ToggleHiddenFieldsVisibilityButton({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( buildWhen: (previous, current) => previous.showHiddenFields != current.showHiddenFields || previous.numHiddenFields != current.numHiddenFields, builder: (context, state) { final text = state.showHiddenFields ? LocaleKeys.grid_rowPage_hideHiddenFields.plural( state.numHiddenFields, namedArgs: {'count': '${state.numHiddenFields}'}, ) : LocaleKeys.grid_rowPage_showHiddenFields.plural( state.numHiddenFields, namedArgs: {'count': '${state.numHiddenFields}'}, ); final quarterTurns = state.showHiddenFields ? 1 : 3; return UniversalPlatform.isDesktopOrWeb ? _desktop(context, text, quarterTurns) : _mobile(context, text, quarterTurns); }, ); } Widget _desktop(BuildContext context, String text, int quarterTurns) { return SizedBox( height: 30, child: FlowyButton( text: FlowyText( text, lineHeight: 1.0, color: Theme.of(context).hintColor, ), hoverColor: AFThemeExtension.of(context).lightGreyHover, leftIcon: RotatedBox( quarterTurns: quarterTurns, child: FlowySvg( FlowySvgs.arrow_left_s, color: Theme.of(context).hintColor, ), ), margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), onTap: () => context.read().add( const RowDetailEvent.toggleHiddenFieldVisibility(), ), ), ); } Widget _mobile(BuildContext context, String text, int quarterTurns) { return ConstrainedBox( constraints: const BoxConstraints(minWidth: double.infinity), child: TextButton.icon( style: Theme.of(context).textButtonTheme.style?.copyWith( shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), ), overlayColor: WidgetStateProperty.all( Theme.of(context).hoverColor, ), alignment: AlignmentDirectional.centerStart, splashFactory: NoSplash.splashFactory, padding: const WidgetStatePropertyAll( EdgeInsets.symmetric(vertical: 14, horizontal: 6), ), ), label: FlowyText( text, fontSize: 15, color: Theme.of(context).hintColor, ), onPressed: () => context .read() .add(const RowDetailEvent.toggleHiddenFieldVisibility()), icon: RotatedBox( quarterTurns: quarterTurns, child: FlowySvg( FlowySvgs.arrow_left_s, color: Theme.of(context).hintColor, ), ), ), ); } } class CreateRowFieldButton extends StatelessWidget { const CreateRowFieldButton({ super.key, required this.viewId, required this.fieldController, }); final String viewId; final FieldController fieldController; @override Widget build(BuildContext context) { return SizedBox( height: 30, child: FlowyButton( margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), text: FlowyText( lineHeight: 1.0, LocaleKeys.grid_field_newProperty.tr(), color: Theme.of(context).hintColor, ), hoverColor: AFThemeExtension.of(context).lightGreyHover, onTap: () async { final result = await FieldBackendService.createField( viewId: viewId, ); await Future.delayed(const Duration(milliseconds: 50)); result.fold( (field) => context .read() .add(RowDetailEvent.startEditingNewField(field.id)), (err) => Log.error("Failed to create field type option: $err"), ); }, leftIcon: FlowySvg( FlowySvgs.add_m, color: Theme.of(context).hintColor, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_layout_selector.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/layout/layout_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DatabaseLayoutSelector extends StatelessWidget { const DatabaseLayoutSelector({ super.key, required this.viewId, required this.databaseController, }); final String viewId; final DatabaseController databaseController; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DatabaseLayoutBloc( viewId: viewId, databaseLayout: databaseController.databaseLayout, )..add(const DatabaseLayoutEvent.initial()), child: BlocBuilder( builder: (context, state) { final cells = DatabaseLayoutPB.values .map( (layout) => DatabaseViewLayoutCell( databaseLayout: layout, isSelected: state.databaseLayout == layout, onTap: (selectedLayout) => context .read() .add(DatabaseLayoutEvent.updateLayout(selectedLayout)), ), ) .toList(); return Padding( padding: const EdgeInsets.symmetric(vertical: 6.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ ListView.separated( shrinkWrap: true, itemCount: cells.length, padding: EdgeInsets.zero, itemBuilder: (_, int index) => cells[index], separatorBuilder: (_, __) => VSpace(GridSize.typeOptionSeparatorHeight), ), Container( height: 1, margin: EdgeInsets.fromLTRB(8, 4, 8, 0), color: AFThemeExtension.of(context).borderColor, ), Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 2), child: SizedBox( height: 30, child: FlowyButton( resetHoverOnRebuild: false, text: FlowyText( LocaleKeys.grid_settings_compactMode.tr(), lineHeight: 1.0, ), onTap: () { databaseController.setCompactMode( !databaseController.compactModeNotifier.value, ); }, rightIcon: ValueListenableBuilder( valueListenable: databaseController.compactModeNotifier, builder: (context, compactMode, child) { return Toggle( value: compactMode, duration: Duration.zero, onChanged: (value) => databaseController.setCompactMode(value), padding: EdgeInsets.zero, ); }, ), ), ), ), ], ), ); }, ), ); } } class DatabaseViewLayoutCell extends StatelessWidget { const DatabaseViewLayoutCell({ super.key, required this.isSelected, required this.databaseLayout, required this.onTap, }); final bool isSelected; final DatabaseLayoutPB databaseLayout; final void Function(DatabaseLayoutPB) onTap; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: SizedBox( height: 30, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText( lineHeight: 1.0, databaseLayout.layoutName, color: AFThemeExtension.of(context).textColor, ), leftIcon: FlowySvg( databaseLayout.icon, color: Theme.of(context).iconTheme.color, ), rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null, onTap: () => onTap(databaseLayout), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_setting_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/group/database_group.dart'; import 'package:appflowy/plugins/database/widgets/setting/database_layout_selector.dart'; import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; enum DatabaseSettingAction { showProperties, showLayout, showGroup, showCalendarLayout, } extension DatabaseSettingActionExtension on DatabaseSettingAction { FlowySvgData iconData() { switch (this) { case DatabaseSettingAction.showProperties: return FlowySvgs.multiselect_s; case DatabaseSettingAction.showLayout: return FlowySvgs.database_layout_s; case DatabaseSettingAction.showGroup: return FlowySvgs.group_s; case DatabaseSettingAction.showCalendarLayout: return FlowySvgs.calendar_layout_s; } } String title() { switch (this) { case DatabaseSettingAction.showProperties: return LocaleKeys.grid_settings_properties.tr(); case DatabaseSettingAction.showLayout: return LocaleKeys.grid_settings_databaseLayout.tr(); case DatabaseSettingAction.showGroup: return LocaleKeys.grid_settings_group.tr(); case DatabaseSettingAction.showCalendarLayout: return LocaleKeys.calendar_settings_name.tr(); } } Widget build( BuildContext context, DatabaseController databaseController, PopoverMutex popoverMutex, ) { final popover = switch (this) { DatabaseSettingAction.showLayout => DatabaseLayoutSelector( viewId: databaseController.viewId, databaseController: databaseController, ), DatabaseSettingAction.showGroup => DatabaseGroupList( viewId: databaseController.viewId, databaseController: databaseController, onDismissed: () {}, ), DatabaseSettingAction.showProperties => DatabasePropertyList( viewId: databaseController.viewId, fieldController: databaseController.fieldController, ), DatabaseSettingAction.showCalendarLayout => CalendarLayoutSetting( databaseController: databaseController, ), }; return AppFlowyPopover( triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, direction: PopoverDirection.leftWithTopAligned, mutex: popoverMutex, margin: EdgeInsets.zero, offset: const Offset(-14, 0), child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText( title(), lineHeight: 1.0, color: AFThemeExtension.of(context).textColor, ), leftIcon: FlowySvg( iconData(), color: Theme.of(context).iconTheme.color, ), rightIcon: FlowySvg(FlowySvgs.database_settings_arrow_right_s), ), ), popupBuilder: (context) => popover, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_settings_list.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/widgets/setting/database_setting_action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/widgets.dart'; import 'package:universal_platform/universal_platform.dart'; class DatabaseSettingsList extends StatefulWidget { const DatabaseSettingsList({ super.key, required this.databaseController, }); final DatabaseController databaseController; @override State createState() => _DatabaseSettingsListState(); } class _DatabaseSettingsListState extends State { final PopoverMutex popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cells = actionsForDatabaseLayout(widget.databaseController.databaseLayout) .map( (action) => action.build( context, widget.databaseController, popoverMutex, ), ) .toList(); return ListView.separated( shrinkWrap: true, padding: EdgeInsets.zero, itemCount: cells.length, separatorBuilder: (context, index) => VSpace(GridSize.typeOptionSeparatorHeight), physics: StyledScrollPhysics(), itemBuilder: (BuildContext context, int index) => cells[index], ); } } /// Returns the list of actions that should be shown for the given database layout. List actionsForDatabaseLayout(DatabaseLayoutPB? layout) { switch (layout) { case DatabaseLayoutPB.Board: return [ DatabaseSettingAction.showProperties, DatabaseSettingAction.showLayout, if (!UniversalPlatform.isMobile) DatabaseSettingAction.showGroup, ]; case DatabaseLayoutPB.Calendar: return [ DatabaseSettingAction.showProperties, DatabaseSettingAction.showLayout, DatabaseSettingAction.showCalendarLayout, ]; case DatabaseLayoutPB.Grid: return [ DatabaseSettingAction.showProperties, DatabaseSettingAction.showLayout, ]; default: return []; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/setting/field_visibility_extension.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; extension ToggleVisibility on FieldVisibility { FieldVisibility toggle() => switch (this) { FieldVisibility.AlwaysShown => FieldVisibility.AlwaysHidden, FieldVisibility.AlwaysHidden => FieldVisibility.AlwaysShown, _ => FieldVisibility.AlwaysHidden, }; bool isVisibleState() => switch (this) { FieldVisibility.AlwaysShown => true, FieldVisibility.HideWhenEmpty => true, FieldVisibility.AlwaysHidden => false, _ => false, }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/view/database_field_list.dart'; import 'package:appflowy/mobile/presentation/database/view/database_filter_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/view/database_sort_bottom_sheet.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; enum MobileDatabaseControlFeatures { sort, filter } class MobileDatabaseControls extends StatelessWidget { const MobileDatabaseControls({ super.key, required this.controller, required this.features, }); final DatabaseController controller; final List features; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (context) => FilterEditorBloc( viewId: controller.viewId, fieldController: controller.fieldController, ), ), BlocProvider( create: (context) => SortEditorBloc( viewId: controller.viewId, fieldController: controller.fieldController, ), ), ], child: ValueListenableBuilder( valueListenable: controller.isLoading, builder: (context, isLoading, child) { if (isLoading) { return const SizedBox.shrink(); } return SeparatedRow( separatorBuilder: () => const HSpace(8.0), children: [ if (features.contains(MobileDatabaseControlFeatures.sort)) _DatabaseControlButton( icon: FlowySvgs.sort_ascending_s, count: context.watch().state.sorts.length, onTap: () => _showEditSortPanelFromToolbar( context, controller, ), ), if (features.contains(MobileDatabaseControlFeatures.filter)) _DatabaseControlButton( icon: FlowySvgs.filter_s, count: context.watch().state.filters.length, onTap: () => _showEditFilterPanelFromToolbar( context, controller, ), ), _DatabaseControlButton( icon: FlowySvgs.m_field_hide_s, onTap: () => _showDatabaseFieldListFromToolbar( context, controller, ), ), ], ); }, ), ); } } class _DatabaseControlButton extends StatelessWidget { const _DatabaseControlButton({ required this.onTap, required this.icon, this.count = 0, }); final VoidCallback onTap; final FlowySvgData icon; final int count; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(10), child: Padding( padding: const EdgeInsets.all(5.0), child: count == 0 ? FlowySvg( icon, size: const Size.square(20), ) : Row( children: [ FlowySvg( icon, size: const Size.square(20), color: Theme.of(context).colorScheme.primary, ), const HSpace(2.0), FlowyText.medium( count.toString(), color: Theme.of(context).colorScheme.primary, ), ], ), ), ); } } void _showDatabaseFieldListFromToolbar( BuildContext context, DatabaseController databaseController, ) { showTransitionMobileBottomSheet( context, showHeader: true, showBackButton: true, title: LocaleKeys.grid_settings_properties.tr(), builder: (_) { return BlocProvider.value( value: context.read(), child: MobileDatabaseFieldList( databaseController: databaseController, canCreate: false, ), ); }, ); } void _showEditSortPanelFromToolbar( BuildContext context, DatabaseController databaseController, ) { showMobileBottomSheet( context, showDragHandle: true, showDivider: false, useSafeArea: false, backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return BlocProvider.value( value: context.read(), child: const MobileSortEditor(), ); }, ); } void _showEditFilterPanelFromToolbar( BuildContext context, DatabaseController databaseController, ) { showMobileBottomSheet( context, showDragHandle: true, showDivider: false, useSafeArea: false, backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return BlocProvider.value( value: context.read(), child: const MobileFilterEditor(), ); }, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SettingButton extends StatefulWidget { const SettingButton({super.key, required this.databaseController}); final DatabaseController databaseController; @override State createState() => _SettingButtonState(); } class _SettingButtonState extends State { final PopoverController _popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( controller: _popoverController, constraints: BoxConstraints.loose(const Size(200, 400)), direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 8), triggerActions: PopoverTriggerFlags.none, child: MouseRegion( cursor: SystemMouseCursors.click, child: FlowyIconButton( tooltipText: LocaleKeys.settings_title.tr(), width: 24, height: 24, iconPadding: const EdgeInsets.all(3), hoverColor: AFThemeExtension.of(context).lightGreyHover, icon: const FlowySvg(FlowySvgs.settings_s), onPressed: _popoverController.show, ), ), popupBuilder: (_) => DatabaseSettingsList(databaseController: widget.databaseController), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_property_list.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/setting/property_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; import 'package:appflowy/plugins/database/widgets/field/field_editor.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DatabasePropertyList extends StatefulWidget { const DatabasePropertyList({ super.key, required this.viewId, required this.fieldController, }); final String viewId; final FieldController fieldController; @override State createState() => _DatabasePropertyListState(); } class _DatabasePropertyListState extends State { final PopoverMutex _popoverMutex = PopoverMutex(); late final DatabasePropertyBloc _bloc; @override void initState() { super.initState(); _bloc = DatabasePropertyBloc( viewId: widget.viewId, fieldController: widget.fieldController, )..add(const DatabasePropertyEvent.initial()); } @override void dispose() { _popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: _bloc, child: BlocBuilder( builder: (context, state) { final cells = state.fieldContexts .mapIndexed( (index, field) => DatabasePropertyCell( key: ValueKey(field.id), viewId: widget.viewId, fieldController: widget.fieldController, fieldInfo: field, popoverMutex: _popoverMutex, index: index, ), ) .toList(); return ReorderableListView( proxyDecorator: (child, index, _) => Material( color: Colors.transparent, child: Stack( children: [ child, MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grabbing, child: const SizedBox.expand(), ), ], ), ), buildDefaultDragHandles: false, shrinkWrap: true, onReorder: (from, to) { context .read() .add(DatabasePropertyEvent.moveField(from, to)); }, onReorderStart: (_) => _popoverMutex.close(), padding: const EdgeInsets.symmetric(vertical: 4.0), children: cells, ); }, ), ); } } @visibleForTesting class DatabasePropertyCell extends StatefulWidget { const DatabasePropertyCell({ super.key, required this.fieldInfo, required this.viewId, required this.popoverMutex, required this.index, required this.fieldController, }); final FieldInfo fieldInfo; final String viewId; final PopoverMutex popoverMutex; final int index; final FieldController fieldController; @override State createState() => _DatabasePropertyCellState(); } class _DatabasePropertyCellState extends State { final PopoverController _popoverController = PopoverController(); @override Widget build(BuildContext context) { final visiblity = widget.fieldInfo.visibility; final visibleIcon = FlowySvg( visiblity != null && visiblity != FieldVisibility.AlwaysHidden ? FlowySvgs.show_m : FlowySvgs.hide_m, size: const Size.square(16), color: Theme.of(context).iconTheme.color, ); return AppFlowyPopover( mutex: widget.popoverMutex, controller: _popoverController, offset: const Offset(-8, 0), direction: PopoverDirection.leftWithTopAligned, constraints: BoxConstraints.loose(const Size(240, 400)), triggerActions: PopoverTriggerFlags.none, margin: EdgeInsets.zero, child: Container( height: GridSize.popoverItemHeight, margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 6), child: FlowyButton( hoverColor: AFThemeExtension.of(context).lightGreyHover, text: FlowyText( lineHeight: 1.0, widget.fieldInfo.name, color: AFThemeExtension.of(context).textColor, ), leftIconSize: const Size(36, 18), leftIcon: Row( children: [ ReorderableDragStartListener( index: widget.index, child: MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grab, child: SizedBox( width: 14, height: 14, child: FlowySvg( FlowySvgs.drag_element_s, color: Theme.of(context).iconTheme.color, ), ), ), ), const HSpace(6.0), FieldIcon( fieldInfo: widget.fieldInfo, ), ], ), rightIcon: FlowyIconButton( hoverColor: Colors.transparent, onPressed: () { if (widget.fieldInfo.fieldSettings == null) { return; } final newVisiblity = widget.fieldInfo.visibility!.toggle(); context.read().add( DatabasePropertyEvent.setFieldVisibility( widget.fieldInfo.id, newVisiblity, ), ); }, icon: visibleIcon, ), onTap: () => _popoverController.show(), ), ), popupBuilder: (BuildContext context) { return FieldEditor( viewId: widget.viewId, fieldInfo: widget.fieldInfo, fieldController: widget.fieldController, isNewField: false, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/share_bloc.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DatabaseShareButton extends StatelessWidget { const DatabaseShareButton({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DatabaseShareBloc(view: view), child: BlocListener( listener: (context, state) { state.mapOrNull( finish: (state) { state.successOrFail.fold( (data) => _handleExportData(context), _handleExportError, ); }, ); }, child: BlocBuilder( builder: (context, state) => IntrinsicWidth( child: DatabaseShareActionList(view: view), ), ), ), ); } void _handleExportData(BuildContext context) { showSnackBarMessage( context, LocaleKeys.settings_files_exportFileSuccess.tr(), ); } void _handleExportError(FlowyError error) { showMessageToast(error.msg); } } class DatabaseShareActionList extends StatefulWidget { const DatabaseShareActionList({ super.key, required this.view, }); final ViewPB view; @override State createState() => DatabaseShareActionListState(); } @visibleForTesting class DatabaseShareActionListState extends State { late String name; late final ViewListener viewListener = ViewListener(viewId: widget.view.id); @override void initState() { super.initState(); listenOnViewUpdated(); } @override void dispose() { viewListener.stop(); super.dispose(); } @override Widget build(BuildContext context) { final databaseShareBloc = context.read(); return PopoverActionList( direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 8), actions: ShareAction.values .map((action) => ShareActionWrapper(action)) .toList(), buildChild: (controller) => Listener( onPointerDown: (_) => controller.show(), child: RoundedTextButton( title: LocaleKeys.shareAction_buttonText.tr(), padding: const EdgeInsets.symmetric(horizontal: 12.0), fontSize: 14.0, textColor: Theme.of(context).colorScheme.onPrimary, onPressed: () {}, ), ), onSelected: (action, controller) async { switch (action.inner) { case ShareAction.csv: final exportPath = await getIt().saveFile( dialogTitle: '', fileName: '${name.toFileName()}.csv', ); if (exportPath != null) { databaseShareBloc.add(DatabaseShareEvent.shareCSV(exportPath)); } break; } controller.close(); }, ); } void listenOnViewUpdated() { name = widget.view.name; viewListener.start( onViewUpdated: (view) { name = view.name; }, ); } } enum ShareAction { csv, } class ShareActionWrapper extends ActionCell { ShareActionWrapper(this.inner); final ShareAction inner; Widget? icon(Color iconColor) => null; @override String get name { switch (inner) { case ShareAction.csv: return LocaleKeys.shareAction_csv.tr(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import '../../workspace/application/view/view_bloc.dart'; // This widget is largely copied from `plugins/document/document_page.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department. class DatabaseDocumentPage extends StatefulWidget { const DatabaseDocumentPage({ super.key, required this.view, required this.databaseId, required this.rowId, required this.documentId, this.initialSelection, }); final ViewPB view; final String databaseId; final String rowId; final String documentId; final Selection? initialSelection; @override State createState() => _DatabaseDocumentPageState(); } class _DatabaseDocumentPageState extends State { EditorState? editorState; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider.value( value: getIt(), ), BlocProvider( create: (_) => DocumentBloc( databaseViewId: widget.databaseId, rowId: widget.rowId, documentId: widget.documentId, )..add(const DocumentEvent.initial()), ), BlocProvider( create: (_) => ViewBloc(view: widget.view)..add(const ViewEvent.initial()), ), ], child: BlocBuilder( builder: (context, state) { if (state.isLoading) { return const Center(child: CircularProgressIndicator.adaptive()); } final editorState = state.editorState; this.editorState = editorState; final error = state.error; if (error != null || editorState == null) { Log.error(error); return Center( child: AppFlowyErrorPage( error: error, ), ); } if (state.forceClose) { return const SizedBox.shrink(); } return BlocListener( listener: _onNotificationAction, listenWhen: (_, curr) => curr.action != null, child: AiWriterScrollWrapper( viewId: widget.view.id, editorState: editorState, child: _buildEditorPage(context, state), ), ); }, ), ); } Widget _buildEditorPage(BuildContext context, DocumentState state) { final appflowyEditorPage = EditorDropHandler( viewId: widget.view.id, editorState: state.editorState!, isLocalMode: context.read().isLocalMode, child: AppFlowyEditorPage( editorState: state.editorState!, styleCustomizer: EditorStyleCustomizer( context: context, padding: EditorStyleCustomizer.documentPadding, editorState: state.editorState!, ), header: _buildDatabaseDataContent(context, state.editorState!), initialSelection: widget.initialSelection, useViewInfoBloc: false, placeholderText: (node) => node.type == ParagraphBlockKeys.type && !node.isInTable ? LocaleKeys.editor_slashPlaceHolder.tr() : '', ), ); return Provider( create: (_) { final context = SharedEditorContext(); context.isInDatabaseRowPage = true; return context; }, dispose: (_, editorContext) => editorContext.dispose(), child: EditorTransactionService( viewId: widget.view.id, editorState: state.editorState!, child: Column( children: [ if (state.isDeleted) _buildBanner(context), Expanded(child: appflowyEditorPage), ], ), ), ); } Widget _buildDatabaseDataContent( BuildContext context, EditorState editorState, ) { return BlocProvider( create: (context) => RelatedRowDetailPageBloc( databaseId: widget.databaseId, initialRowId: widget.rowId, ), child: BlocBuilder( builder: (context, state) { return state.when( loading: () => const SizedBox.shrink(), ready: (databaseController, rowController) { final padding = EditorStyleCustomizer.documentPadding; return BlocProvider( create: (context) => RowDetailBloc( fieldController: databaseController.fieldController, rowController: rowController, ), child: Column( children: [ RowBanner( databaseController: databaseController, rowController: rowController, cellBuilder: EditableCellBuilder( databaseController: databaseController, ), userProfile: context.read().userProfile, ), Padding( padding: EdgeInsets.only( top: 24, left: padding.left, right: padding.right, ), child: RowPropertyList( viewId: databaseController.viewId, fieldController: databaseController.fieldController, cellBuilder: EditableCellBuilder( databaseController: databaseController, ), ), ), const TypeOptionSeparator(spacing: 24.0), ], ), ); }, ); }, ), ); } Widget _buildBanner(BuildContext context) { return DocumentBanner( viewName: widget.view.name, onRestore: () => context.read().add( const DocumentEvent.restorePage(), ), onDelete: () => context.read().add( const DocumentEvent.deletePermanently(), ), ); } void _onNotificationAction( BuildContext context, ActionNavigationState state, ) { if (state.action != null && state.action!.type == ActionType.jumpToBlock) { final path = state.action?.arguments?[ActionArgumentKeys.nodePath]; final editorState = context.read().state.editorState; if (editorState != null && widget.documentId == state.action?.objectId) { editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [path])), ); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart ================================================ library; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'database_document_page.dart'; import 'presentation/database_document_title.dart'; // This widget is largely copied from `plugins/document/document_plugin.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department. class DatabaseDocumentContext { DatabaseDocumentContext({ required this.view, required this.databaseId, required this.rowId, required this.documentId, }); final ViewPB view; final String databaseId; final String rowId; final String documentId; } class DatabaseDocumentPluginBuilder extends PluginBuilder { @override Plugin build(dynamic data) { if (data is DatabaseDocumentContext) { return DatabaseDocumentPlugin(pluginType: pluginType, data: data); } throw FlowyPluginException.invalidData; } @override String get menuName => LocaleKeys.document_menuName.tr(); @override FlowySvgData get icon => FlowySvgs.icon_document_s; @override PluginType get pluginType => PluginType.databaseDocument; @override ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class DatabaseDocumentPlugin extends Plugin { DatabaseDocumentPlugin({ required this.data, required PluginType pluginType, this.initialSelection, }) : _pluginType = pluginType; final DatabaseDocumentContext data; final PluginType _pluginType; final Selection? initialSelection; @override PluginWidgetBuilder get widgetBuilder => DatabaseDocumentPluginWidgetBuilder( view: data.view, databaseId: data.databaseId, rowId: data.rowId, documentId: data.documentId, initialSelection: initialSelection, ); @override PluginType get pluginType => _pluginType; @override PluginId get id => data.rowId; } class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder with NavigationItem { DatabaseDocumentPluginWidgetBuilder({ required this.view, required this.databaseId, required this.rowId, required this.documentId, this.initialSelection, }); final ViewPB view; final String databaseId; final String rowId; final String documentId; final Selection? initialSelection; @override String? get viewName => view.nameOrDefault; @override EdgeInsets get contentPadding => EdgeInsets.zero; @override Widget buildWidget({ required PluginContext context, required bool shrinkWrap, Map? data, }) { return BlocBuilder( builder: (_, state) => DatabaseDocumentPage( key: ValueKey(documentId), view: view, databaseId: databaseId, documentId: documentId, rowId: rowId, initialSelection: initialSelection, ), ); } @override Widget get leftBarItem => ViewTitleBarWithRow(view: view, databaseId: databaseId, rowId: rowId); @override Widget tabBarItem(String pluginId, [bool shortForm = false]) => const SizedBox.shrink(); @override Widget? get rightBarItem => const SizedBox.shrink(); @override List get navigationItems => [this]; } class DatabaseDocumentPluginConfig implements PluginConfig { @override bool get creatable => false; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'database_document_title_bloc.dart'; // This widget is largely copied from `workspace/presentation/widgets/view_title_bar.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department. // workspaces / ... / database view name / row name class ViewTitleBarWithRow extends StatelessWidget { const ViewTitleBarWithRow({ super.key, required this.view, required this.databaseId, required this.rowId, }); final ViewPB view; final String databaseId; final String rowId; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DatabaseDocumentTitleBloc( view: view, rowId: rowId, ), child: BlocBuilder( builder: (context, state) { if (state.ancestors.isEmpty) { return const SizedBox.shrink(); } return SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox( height: 24, child: Row( // refresh the view title bar when the ancestors changed key: ValueKey(state.ancestors.hashCode), children: _buildViewTitles(state.ancestors), ), ), ); }, ), ); } List _buildViewTitles(List views) { // if the level is too deep, only show the root view, the database view and the row return views.length > 2 ? [ _buildViewButton(views[1]), const FlowySvg(FlowySvgs.title_bar_divider_s), const FlowyText.regular(' ... '), const FlowySvg(FlowySvgs.title_bar_divider_s), _buildViewButton(views.last), const FlowySvg(FlowySvgs.title_bar_divider_s), _buildRowName(), ] : [ ...views .map( (e) => [ _buildViewButton(e), const FlowySvg(FlowySvgs.title_bar_divider_s), ], ) .flattened, _buildRowName(), ]; } Widget _buildViewButton(ViewPB view) { return FlowyTooltip( message: view.name, child: ViewTitle( view: view, behavior: ViewTitleBehavior.uneditable, onUpdated: () {}, ), ); } Widget _buildRowName() { return _RowName( rowId: rowId, ); } } class _RowName extends StatelessWidget { const _RowName({ required this.rowId, }); final String rowId; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state.databaseController == null) { return const SizedBox.shrink(); } final cellBuilder = EditableCellBuilder( databaseController: state.databaseController!, ); return cellBuilder.buildCustom( CellContext( fieldId: state.fieldId!, rowId: rowId, ), skinMap: EditableCellSkinMap(textSkin: _TitleSkin()), ); }, ); } } class _TitleSkin extends IEditableTextCellSkin { @override Widget build( BuildContext context, CellContainerNotifier cellContainerNotifier, ValueNotifier compactModeNotifier, TextCellBloc bloc, FocusNode focusNode, TextEditingController textEditingController, ) { return BlocSelector( selector: (state) => state.content ?? "", builder: (context, content) { final name = content.isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : content; return BlocBuilder( builder: (context, state) { return FlowyTooltip( message: name, child: AppFlowyPopover( constraints: const BoxConstraints( maxWidth: 300, maxHeight: 44, ), direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 18), popupBuilder: (_) { return RenameRowPopover( textController: textEditingController, icon: state.icon ?? EmojiIconData.none(), onUpdateIcon: (icon) { context .read() .add(DatabaseDocumentTitleEvent.updateIcon(icon)); }, onUpdateName: (text) => bloc.add(TextCellEvent.updateText(text)), tabs: const [PickerTabType.emoji], ); }, child: FlowyButton( useIntrinsicWidth: true, onTap: () {}, margin: const EdgeInsets.symmetric(horizontal: 6), text: Row( children: [ if (state.icon != null) ...[ RawEmojiIconWidget(emoji: state.icon!, emojiSize: 14), const HSpace(4.0), ], ConstrainedBox( constraints: const BoxConstraints(maxWidth: 180), child: FlowyText.regular( name, overflow: TextOverflow.ellipsis, fontSize: 14.0, figmaLineHeight: 18.0, ), ), ], ), ), ), ); }, ); }, ); } } class RenameRowPopover extends StatefulWidget { const RenameRowPopover({ super.key, required this.textController, required this.onUpdateName, required this.onUpdateIcon, required this.icon, this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); final TextEditingController textController; final EmojiIconData icon; final ValueChanged onUpdateName; final ValueChanged onUpdateIcon; final List tabs; @override State createState() => _RenameRowPopoverState(); } class _RenameRowPopoverState extends State { @override void initState() { super.initState(); widget.textController.selection = TextSelection( baseOffset: 0, extentOffset: widget.textController.value.text.characters.length, ); } @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ EmojiPickerButton( emoji: widget.icon, direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 18), defaultIcon: const FlowySvg(FlowySvgs.document_s), onSubmitted: (r, _) { widget.onUpdateIcon(r.data); if (!r.keepOpen) PopoverContainer.of(context).close(); }, tabs: widget.tabs, ), const HSpace(6), SizedBox( height: 36.0, width: 220, child: FlowyTextField( controller: widget.textController, maxLength: 256, onSubmitted: (text) { widget.onUpdateName(text); PopoverContainer.of(context).close(); }, onCanceled: () => widget.onUpdateName(widget.textController.text), showCounter: false, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/domain/row_meta_listener.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; part 'database_document_title_bloc.freezed.dart'; class DatabaseDocumentTitleBloc extends Bloc { DatabaseDocumentTitleBloc({ required this.view, required this.rowId, }) : _metaListener = RowMetaListener(rowId), super(DatabaseDocumentTitleState.initial()) { _dispatch(); _startListening(); _init(); } final ViewPB view; final String rowId; final RowMetaListener _metaListener; void _dispatch() { on((event, emit) async { event.when( didUpdateAncestors: (ancestors) { emit( state.copyWith( ancestors: ancestors, ), ); }, didUpdateRowTitleInfo: (databaseController, rowController, fieldId) { emit( state.copyWith( databaseController: databaseController, rowController: rowController, fieldId: fieldId, ), ); }, didUpdateRowIcon: (icon) { emit( state.copyWith( icon: icon, ), ); }, updateIcon: (icon) { _updateMeta(icon.emoji); }, ); }); } void _startListening() { _metaListener.start( callback: (rowMeta) { if (!isClosed) { add( DatabaseDocumentTitleEvent.didUpdateRowIcon( EmojiIconData.emoji(rowMeta.icon), ), ); } }, ); } void _init() async { // get the database controller, row controller and primary field id final databaseController = DatabaseController(view: view); await databaseController.open().fold( (s) => databaseController.setIsLoading(false), (f) => null, ); final rowInfo = databaseController.rowCache.getRow(rowId); if (rowInfo == null) { return; } final rowController = RowController( rowMeta: rowInfo.rowMeta, viewId: view.id, rowCache: databaseController.rowCache, ); unawaited(rowController.initialize()); final primaryFieldId = await FieldBackendService.getPrimaryField(viewId: view.id).fold( (primaryField) => primaryField.id, (r) { Log.error(r); return null; }, ); if (primaryFieldId != null) { add( DatabaseDocumentTitleEvent.didUpdateRowTitleInfo( databaseController, rowController, primaryFieldId, ), ); } // load ancestors final ancestors = await ViewBackendService.getViewAncestors(view.id) .fold((s) => s.items, (f) => []); add(DatabaseDocumentTitleEvent.didUpdateAncestors(ancestors)); // initialize icon if (rowInfo.rowMeta.icon.isNotEmpty) { add( DatabaseDocumentTitleEvent.didUpdateRowIcon( EmojiIconData.emoji(rowInfo.rowMeta.icon), ), ); } } /// Update the meta of the row and the view void _updateMeta(String iconURL) { RowBackendService(viewId: view.id) .updateMeta( iconURL: iconURL, rowId: rowId, ) .fold((l) => null, (err) => Log.error(err)); } } @freezed class DatabaseDocumentTitleEvent with _$DatabaseDocumentTitleEvent { const factory DatabaseDocumentTitleEvent.didUpdateAncestors( List ancestors, ) = _DidUpdateAncestors; const factory DatabaseDocumentTitleEvent.didUpdateRowTitleInfo( DatabaseController databaseController, RowController rowController, String fieldId, ) = _DidUpdateRowTitleInfo; const factory DatabaseDocumentTitleEvent.didUpdateRowIcon( EmojiIconData icon, ) = _DidUpdateRowIcon; const factory DatabaseDocumentTitleEvent.updateIcon( EmojiIconData icon, ) = _UpdateIcon; } @freezed class DatabaseDocumentTitleState with _$DatabaseDocumentTitleState { const factory DatabaseDocumentTitleState({ required List ancestors, required DatabaseController? databaseController, required RowController? rowController, required String? fieldId, required EmojiIconData? icon, }) = _DatabaseDocumentTitleState; factory DatabaseDocumentTitleState.initial() => const DatabaseDocumentTitleState( ancestors: [], databaseController: null, rowController: null, fieldId: null, icon: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_state_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/document_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef DocumentSyncStateCallback = void Function( DocumentSyncStatePB syncState, ); class DocumentSyncStateListener { DocumentSyncStateListener({ required this.id, }); final String id; StreamSubscription? _subscription; DocumentNotificationParser? _parser; DocumentSyncStateCallback? didReceiveSyncState; void start({ DocumentSyncStateCallback? didReceiveSyncState, }) { this.didReceiveSyncState = didReceiveSyncState; _parser = DocumentNotificationParser( id: id, callback: _callback, ); _subscription = RustStreamReceiver.listen( (observable) => _parser?.parse(observable), ); } void _callback( DocumentNotification ty, FlowyResult result, ) { switch (ty) { case DocumentNotification.DidUpdateDocumentSyncState: result.map( (r) { final value = DocumentSyncStatePB.fromBuffer(r); didReceiveSyncState?.call(value); }, ); break; default: break; } } Future stop() async { await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart ================================================ import 'dart:async'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:universal_platform/universal_platform.dart'; class DocumentAppearance { const DocumentAppearance({ required this.fontSize, required this.fontFamily, required this.codeFontFamily, required this.width, this.cursorColor, this.selectionColor, this.defaultTextDirection, }); final double fontSize; final String fontFamily; final String codeFontFamily; final Color? cursorColor; final Color? selectionColor; final String? defaultTextDirection; final double width; /// For nullable fields (like `cursorColor`), /// use the corresponding `isNull` flag (like `cursorColorIsNull`) to explicitly set the field to `null`. /// /// This is necessary because simply passing `null` as the value does not distinguish between wanting to /// set the field to `null` and not wanting to update the field at all. DocumentAppearance copyWith({ double? fontSize, String? fontFamily, String? codeFontFamily, Color? cursorColor, Color? selectionColor, String? defaultTextDirection, bool cursorColorIsNull = false, bool selectionColorIsNull = false, bool textDirectionIsNull = false, double? width, }) { return DocumentAppearance( fontSize: fontSize ?? this.fontSize, fontFamily: fontFamily ?? this.fontFamily, codeFontFamily: codeFontFamily ?? this.codeFontFamily, cursorColor: cursorColorIsNull ? null : cursorColor ?? this.cursorColor, selectionColor: selectionColorIsNull ? null : selectionColor ?? this.selectionColor, defaultTextDirection: textDirectionIsNull ? null : defaultTextDirection ?? this.defaultTextDirection, width: width ?? this.width, ); } } class DocumentAppearanceCubit extends Cubit { DocumentAppearanceCubit() : super( DocumentAppearance( fontSize: 16.0, fontFamily: defaultFontFamily, codeFontFamily: builtInCodeFontFamily, width: UniversalPlatform.isMobile ? double.infinity : EditorStyleCustomizer.maxDocumentWidth, ), ); Future fetch() async { final prefs = await SharedPreferences.getInstance(); final fontSize = prefs.getDouble(KVKeys.kDocumentAppearanceFontSize) ?? 16.0; final fontFamily = prefs.getString(KVKeys.kDocumentAppearanceFontFamily) ?? defaultFontFamily; final defaultTextDirection = prefs.getString(KVKeys.kDocumentAppearanceDefaultTextDirection); final cursorColorString = prefs.getString(KVKeys.kDocumentAppearanceCursorColor); final selectionColorString = prefs.getString(KVKeys.kDocumentAppearanceSelectionColor); final cursorColor = cursorColorString != null ? Color(int.parse(cursorColorString)) : null; final selectionColor = selectionColorString != null ? Color(int.parse(selectionColorString)) : null; final double? width = prefs.getDouble(KVKeys.kDocumentAppearanceWidth); final textScaleFactor = double.parse(prefs.getString(KVKeys.textScaleFactor) ?? '1.0'); if (isClosed) { return; } emit( state.copyWith( fontSize: fontSize * textScaleFactor, fontFamily: fontFamily, cursorColor: cursorColor, selectionColor: selectionColor, defaultTextDirection: defaultTextDirection, cursorColorIsNull: cursorColor == null, selectionColorIsNull: selectionColor == null, textDirectionIsNull: defaultTextDirection == null, width: width, ), ); } Future syncFontSize(double fontSize) async { final prefs = await SharedPreferences.getInstance(); await prefs.setDouble(KVKeys.kDocumentAppearanceFontSize, fontSize); if (!isClosed) { emit(state.copyWith(fontSize: fontSize)); } } Future syncFontFamily(String fontFamily) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(KVKeys.kDocumentAppearanceFontFamily, fontFamily); if (!isClosed) { emit(state.copyWith(fontFamily: fontFamily)); } } Future syncDefaultTextDirection(String? direction) async { final prefs = await SharedPreferences.getInstance(); if (direction == null) { await prefs.remove(KVKeys.kDocumentAppearanceDefaultTextDirection); } else { await prefs.setString( KVKeys.kDocumentAppearanceDefaultTextDirection, direction, ); } if (!isClosed) { emit( state.copyWith( defaultTextDirection: direction, textDirectionIsNull: direction == null, ), ); } } Future syncCursorColor(Color? cursorColor) async { final prefs = await SharedPreferences.getInstance(); if (cursorColor == null) { await prefs.remove(KVKeys.kDocumentAppearanceCursorColor); } else { await prefs.setString( KVKeys.kDocumentAppearanceCursorColor, cursorColor.toHexString(), ); } if (!isClosed) { emit( state.copyWith( cursorColor: cursorColor, cursorColorIsNull: cursorColor == null, ), ); } } Future syncSelectionColor(Color? selectionColor) async { final prefs = await SharedPreferences.getInstance(); if (selectionColor == null) { await prefs.remove(KVKeys.kDocumentAppearanceSelectionColor); } else { await prefs.setString( KVKeys.kDocumentAppearanceSelectionColor, selectionColor.toHexString(), ); } if (!isClosed) { emit( state.copyWith( selectionColor: selectionColor, selectionColorIsNull: selectionColor == null, ), ); } } Future syncWidth(double? width) async { final prefs = await SharedPreferences.getInstance(); width ??= UniversalPlatform.isMobile ? double.infinity : EditorStyleCustomizer.maxDocumentWidth; width = width.clamp( EditorStyleCustomizer.minDocumentWidth, EditorStyleCustomizer.maxDocumentWidth, ); await prefs.setDouble(KVKeys.kDocumentAppearanceWidth, width); if (!isClosed) { emit(state.copyWith(width: width)); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_awareness_metadata.dart ================================================ // This file is "main.dart" import 'package:freezed_annotation/freezed_annotation.dart'; part 'document_awareness_metadata.freezed.dart'; part 'document_awareness_metadata.g.dart'; @freezed class DocumentAwarenessMetadata with _$DocumentAwarenessMetadata { const factory DocumentAwarenessMetadata({ // ignore: invalid_annotation_target @JsonKey(name: 'cursor_color') required String cursorColor, // ignore: invalid_annotation_target @JsonKey(name: 'selection_color') required String selectionColor, // ignore: invalid_annotation_target @JsonKey(name: 'user_name') required String userName, // ignore: invalid_annotation_target @JsonKey(name: 'user_avatar') required String userAvatar, }) = _DocumentAwarenessMetadata; factory DocumentAwarenessMetadata.fromJson(Map json) => _$DocumentAwarenessMetadataFromJson(json); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart'; import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; import 'package:appflowy/plugins/document/application/document_collab_adapter.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_listener.dart'; import 'package:appflowy/plugins/document/application/document_rules.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/util/color_generator/color_generator.dart'; import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/util/debounce.dart'; import 'package:appflowy/util/throttle.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' show AppFlowyEditorLogLevel, EditorState, TransactionTime; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'document_bloc.freezed.dart'; /// Enable this flag to enable the internal log for /// - document diff /// - document integrity check /// - document sync state /// - document awareness states bool enableDocumentInternalLog = false; final Map _documentBlocMap = {}; class DocumentBloc extends Bloc { DocumentBloc({ required this.documentId, this.databaseViewId, this.rowId, bool saveToBlocMap = true, }) : _saveToBlocMap = saveToBlocMap, _documentListener = DocumentListener(id: documentId), _syncStateListener = DocumentSyncStateListener(id: documentId), super(DocumentState.initial()) { _viewListener = databaseViewId == null && rowId == null ? ViewListener(viewId: documentId) : null; on(_onDocumentEvent); } static DocumentBloc? findOpen(String documentId) => _documentBlocMap[documentId]; /// For a normal document, the document id is the same as the view id final String documentId; final String? databaseViewId; final String? rowId; final bool _saveToBlocMap; final DocumentListener _documentListener; final DocumentSyncStateListener _syncStateListener; late final ViewListener? _viewListener; final DocumentService _documentService = DocumentService(); final TrashService _trashService = TrashService(); late DocumentCollabAdapter _documentCollabAdapter; late final TransactionAdapter _transactionAdapter = TransactionAdapter( documentId: documentId, documentService: _documentService, ); late final DocumentRules _documentRules; StreamSubscription? _transactionSubscription; bool isClosing = false; static const _syncDuration = Duration(milliseconds: 250); final _updateSelectionDebounce = Debounce(duration: _syncDuration); final _syncThrottle = Throttler(duration: _syncDuration); // The conflict handle logic is not fully implemented yet // use the syncTimer to force to reload the document state when the conflict happens. Timer? _syncTimer; bool get isLocalMode { final userProfilePB = state.userProfilePB; final type = userProfilePB?.workspaceType ?? WorkspaceTypePB.LocalW; return type == WorkspaceTypePB.LocalW; } @override Future close() async { isClosing = true; if (_saveToBlocMap) { _documentBlocMap.remove(documentId); } await checkDocumentIntegrity(); await _cancelSubscriptions(); _clearEditorState(); return super.close(); } Future _cancelSubscriptions() async { await _documentService.syncAwarenessStates(documentId: documentId); await _documentListener.stop(); await _syncStateListener.stop(); await _viewListener?.stop(); await _transactionSubscription?.cancel(); await _documentService.closeDocument(viewId: documentId); } void _clearEditorState() { _updateSelectionDebounce.dispose(); _syncThrottle.dispose(); _syncTimer?.cancel(); _syncTimer = null; state.editorState?.selectionNotifier .removeListener(_debounceOnSelectionUpdate); state.editorState?.service.keyboardService?.closeKeyboard(); state.editorState?.dispose(); } Future _onDocumentEvent( DocumentEvent event, Emitter emit, ) async { await event.when( initial: () async { if (_saveToBlocMap) { _documentBlocMap[documentId] = this; } final result = await _fetchDocumentState(); _onViewChanged(); _onDocumentChanged(); final newState = await result.fold( (s) async { final userProfilePB = await getIt().getUser().toNullable(); return state.copyWith( error: null, editorState: s, isLoading: false, userProfilePB: userProfilePB, ); }, (f) async => state.copyWith( error: f, editorState: null, isLoading: false, ), ); emit(newState); if (newState.userProfilePB != null) { await _updateCollaborator(); } }, moveToTrash: () async { emit(state.copyWith(isDeleted: true)); }, restore: () async { emit(state.copyWith(isDeleted: false)); }, deletePermanently: () async { if (databaseViewId == null && rowId == null) { final result = await _trashService.deleteViews([documentId]); final forceClose = result.fold((l) => true, (r) => false); emit(state.copyWith(forceClose: forceClose)); } }, restorePage: () async { if (databaseViewId == null && rowId == null) { final result = await TrashService.putback(documentId); final isDeleted = result.fold((l) => false, (r) => true); emit(state.copyWith(isDeleted: isDeleted)); } }, syncStateChanged: (syncState) { emit(state.copyWith(syncState: syncState.value)); }, clearAwarenessStates: () async { // sync a null selection and a null meta to clear the awareness states await _documentService.syncAwarenessStates( documentId: documentId, ); }, syncAwarenessStates: () async { await _updateCollaborator(); }, ); } /// subscribe to the view(document page) change void _onViewChanged() { _viewListener?.start( onViewMoveToTrash: (r) { r.map((r) => add(const DocumentEvent.moveToTrash())); }, onViewDeleted: (r) { r.map((r) => add(const DocumentEvent.moveToTrash())); }, onViewRestored: (r) => r.map((r) => add(const DocumentEvent.restore())), ); } /// subscribe to the document content change void _onDocumentChanged() { _documentListener.start( onDocEventUpdate: _throttleSyncDoc, onDocAwarenessUpdate: _onAwarenessStatesUpdate, ); _syncStateListener.start( didReceiveSyncState: (syncState) { if (!isClosed) { add(DocumentEvent.syncStateChanged(syncState)); } }, ); } /// Fetch document Future> _fetchDocumentState() async { final result = await _documentService.openDocument(documentId: documentId); return result.fold( (s) async => FlowyResult.success(await _initAppFlowyEditorState(s)), (e) => FlowyResult.failure(e), ); } Future _initAppFlowyEditorState(DocumentDataPB data) async { if (enableDocumentInternalLog) { Log.info('document data: ${data.toProto3Json()}'); } final document = data.toDocument(); if (document == null) { assert(false, 'document is null'); return null; } final editorState = EditorState(document: document); _documentCollabAdapter = DocumentCollabAdapter(editorState, documentId); _documentRules = DocumentRules(editorState: editorState); // subscribe to the document change from the editor _transactionSubscription = editorState.transactionStream.listen( (value) async { final time = value.$1; final transaction = value.$2; final options = value.$3; if (time != TransactionTime.before) { return; } if (options.inMemoryUpdate) { if (enableDocumentInternalLog) { Log.trace('skip transaction for in-memory update'); } return; } if (enableDocumentInternalLog) { Log.trace( '[TransactionAdapter] 1. transaction before apply: ${transaction.hashCode}', ); } // apply transaction to backend await _transactionAdapter.apply(transaction, editorState); // check if the document is empty. await _documentRules.applyRules(value: value); if (enableDocumentInternalLog) { Log.trace( '[TransactionAdapter] 4. transaction after apply: ${transaction.hashCode}', ); } if (!isClosed) { // ignore: invalid_use_of_visible_for_testing_member emit(state.copyWith(isDocumentEmpty: editorState.document.isEmpty)); } }, ); editorState.selectionNotifier.addListener(_debounceOnSelectionUpdate); // output the log from the editor when debug mode if (kDebugMode) { editorState.logConfiguration ..level = AppFlowyEditorLogLevel.all ..handler = (log) { if (enableDocumentInternalLog) { // Log.info(log); } }; } return editorState; } Future _onDocumentStateUpdate(DocEventPB docEvent) async { if (!docEvent.isRemote || !FeatureFlag.syncDocument.isOn) { return; } unawaited(_documentCollabAdapter.syncV3(docEvent: docEvent)); } Future _onAwarenessStatesUpdate( DocumentAwarenessStatesPB awarenessStates, ) async { if (!FeatureFlag.syncDocument.isOn) { return; } final userId = state.userProfilePB?.id; if (userId != null) { await _documentCollabAdapter.updateRemoteSelection( userId.toString(), awarenessStates, ); } } void _debounceOnSelectionUpdate() { _updateSelectionDebounce.call(_onSelectionUpdate); } void _throttleSyncDoc(DocEventPB docEvent) { if (enableDocumentInternalLog) { Log.info('[DocumentBloc] throttle sync doc: ${docEvent.toProto3Json()}'); } _syncThrottle.call(() { _onDocumentStateUpdate(docEvent); }); } Future _onSelectionUpdate() async { if (isClosing) { return; } final user = state.userProfilePB; final deviceId = ApplicationInfo.deviceId; if (!FeatureFlag.syncDocument.isOn || user == null) { return; } final editorState = state.editorState; if (editorState == null) { return; } final selection = editorState.selection; // sync the selection final id = user.id.toString() + deviceId; final basicColor = ColorGenerator(id.toString()).toColor(); final metadata = DocumentAwarenessMetadata( cursorColor: basicColor.toHexString(), selectionColor: basicColor.withValues(alpha: 0.6).toHexString(), userName: user.name, userAvatar: user.iconUrl, ); await _documentService.syncAwarenessStates( documentId: documentId, selection: selection, metadata: jsonEncode(metadata.toJson()), ); } Future _updateCollaborator() async { final user = state.userProfilePB; final deviceId = ApplicationInfo.deviceId; if (!FeatureFlag.syncDocument.isOn || user == null) { return; } // sync the selection final id = user.id.toString() + deviceId; final basicColor = ColorGenerator(id.toString()).toColor(); final metadata = DocumentAwarenessMetadata( cursorColor: basicColor.toHexString(), selectionColor: basicColor.withValues(alpha: 0.6).toHexString(), userName: user.name, userAvatar: user.iconUrl, ); await _documentService.syncAwarenessStates( documentId: documentId, metadata: jsonEncode(metadata.toJson()), ); } Future forceReloadDocumentState() { return _documentCollabAdapter.syncV3(); } // this is only used for debug mode Future checkDocumentIntegrity() async { if (!enableDocumentInternalLog) { return; } final cloudDocResult = await _documentService.getDocument(documentId: documentId); final cloudDoc = cloudDocResult.fold((s) => s, (f) => null)?.toDocument(); final localDoc = state.editorState?.document; if (cloudDoc == null || localDoc == null) { return; } final cloudJson = cloudDoc.toJson(); final localJson = localDoc.toJson(); final deepEqual = const DeepCollectionEquality().equals( cloudJson, localJson, ); if (!deepEqual) { Log.error('document integrity check failed'); // Enable it to debug the document integrity check failed Log.error('cloud doc: $cloudJson'); Log.error('local doc: $localJson'); final context = AppGlobals.rootNavKey.currentContext; if (context != null && context.mounted) { showToastNotification( message: 'document integrity check failed', type: ToastificationType.error, ); } } } } @freezed class DocumentEvent with _$DocumentEvent { const factory DocumentEvent.initial() = Initial; const factory DocumentEvent.moveToTrash() = MoveToTrash; const factory DocumentEvent.restore() = Restore; const factory DocumentEvent.restorePage() = RestorePage; const factory DocumentEvent.deletePermanently() = DeletePermanently; const factory DocumentEvent.syncStateChanged( final DocumentSyncStatePB syncState, ) = syncStateChanged; const factory DocumentEvent.syncAwarenessStates() = SyncAwarenessStates; const factory DocumentEvent.clearAwarenessStates() = ClearAwarenessStates; } @freezed class DocumentState with _$DocumentState { const factory DocumentState({ required final bool isDeleted, required final bool forceClose, required final bool isLoading, required final DocumentSyncState syncState, bool? isDocumentEmpty, UserProfilePB? userProfilePB, EditorState? editorState, FlowyError? error, @Default(null) DocumentAwarenessStatesPB? awarenessStates, }) = _DocumentState; factory DocumentState.initial() => const DocumentState( isDeleted: false, forceClose: false, isLoading: true, syncState: DocumentSyncState.Syncing, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_diff.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy/util/color_generator/color_generator.dart'; import 'package:appflowy/util/json_print.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class DocumentCollabAdapter { DocumentCollabAdapter( this.editorState, this.docId, ); final EditorState editorState; final String docId; final DocumentDiff diff = const DocumentDiff(); final _service = DocumentService(); /// Sync version 1 /// /// Force to reload the document /// /// Only use in development Future syncV1() async { final result = await _service.getDocument(documentId: docId); final document = result.fold((s) => s.toDocument(), (f) => null); if (document == null) { return null; } return EditorState(document: document); } /// Sync version 2 /// /// Translate the [docEvent] from yrs to [Operation]s and apply it to the [editorState] /// /// Not fully implemented yet Future syncV2(DocEventPB docEvent) async { prettyPrintJson(docEvent.toProto3Json()); final transaction = editorState.transaction; for (final event in docEvent.events) { for (final blockEvent in event.event) { switch (blockEvent.command) { case DeltaTypePB.Inserted: break; case DeltaTypePB.Updated: await _syncUpdated(blockEvent, transaction); break; case DeltaTypePB.Removed: break; default: } } } await editorState.apply(transaction, isRemote: true); } /// Sync version 3 /// /// Diff the local document with the remote document and apply the changes Future syncV3({DocEventPB? docEvent}) async { final result = await _service.getDocument(documentId: docId); final document = result.fold((s) => s.toDocument(), (f) => null); if (document == null) { return; } final ops = diff.diffDocument(editorState.document, document); if (ops.isEmpty) { return; } if (enableDocumentInternalLog) { prettyPrintJson(ops.map((op) => op.toJson()).toList()); } final transaction = editorState.transaction; for (final op in ops) { transaction.add(op); } await editorState.apply(transaction, isRemote: true); if (enableDocumentInternalLog) { assert(() { final local = editorState.document.root.toJson(); final remote = document.root.toJson(); if (!const DeepCollectionEquality().equals(local, remote)) { Log.error('Invalid diff status'); Log.error('Local: $local'); Log.error('Remote: $remote'); return false; } return true; }()); } } Future forceReload() async { final result = await _service.getDocument(documentId: docId); final document = result.fold((s) => s.toDocument(), (f) => null); if (document == null) { return; } final beforeSelection = editorState.selection; final clear = editorState.transaction; clear.deleteNodes(editorState.document.root.children); await editorState.apply(clear, isRemote: true); final insert = editorState.transaction; insert.insertNodes([0], document.root.children); await editorState.apply(insert, isRemote: true); editorState.selection = beforeSelection; } Future _syncUpdated( BlockEventPayloadPB payload, Transaction transaction, ) async { assert(payload.command == DeltaTypePB.Updated); final path = payload.path; final id = payload.id; final value = jsonDecode(payload.value); final nodes = NodeIterator( document: editorState.document, startNode: editorState.document.root, ).toList(); // 1. meta -> text_map = text delta change if (path.isTextDeltaChangeset) { // find the 'text' block and apply the delta // ⚠️ not completed yet. final target = nodes.singleWhereOrNull((n) => n.id == id); if (target != null) { try { final delta = Delta.fromJson(jsonDecode(value)); transaction.insertTextDelta(target, 0, delta); } catch (e) { Log.error('Failed to apply delta: $value, error: $e'); } } } else if (path.isBlockChangeset) { final target = nodes.singleWhereOrNull((n) => n.id == id); if (target != null) { try { final delta = jsonDecode(value['data'])['delta']; transaction.updateNode(target, { 'delta': Delta.fromJson(delta).toJson(), }); } catch (e) { Log.error('Failed to update $value, error: $e'); } } } } Future updateRemoteSelection( String userId, DocumentAwarenessStatesPB states, ) async { final List remoteSelections = []; final deviceId = ApplicationInfo.deviceId; // the values may be duplicated, sort by the timestamp and then filter the duplicated values final values = states.value.values .sorted( (a, b) => b.timestamp.compareTo(a.timestamp), ) // in descending order .unique( (e) => Object.hashAll([e.user.uid, e.user.deviceId]), ); for (final state in values) { // the following code is only for version 1 if (state.version != 1 || state.metadata.isEmpty) { continue; } final uid = state.user.uid.toString(); final did = state.user.deviceId; DocumentAwarenessMetadata metadata; try { metadata = DocumentAwarenessMetadata.fromJson( jsonDecode(state.metadata), ); } catch (e) { Log.error('Failed to parse metadata: $e, ${state.metadata}'); continue; } final selectionColor = metadata.selectionColor.tryToColor(); final cursorColor = metadata.cursorColor.tryToColor(); if ((uid == userId && did == deviceId) || (cursorColor == null || selectionColor == null)) { continue; } final start = state.selection.start; final end = state.selection.end; final selection = Selection( start: Position( path: start.path.toIntList(), offset: start.offset.toInt(), ), end: Position( path: end.path.toIntList(), offset: end.offset.toInt(), ), ); final color = ColorGenerator(uid + did).toColor(); final remoteSelection = RemoteSelection( id: uid, selection: selection, selectionColor: selectionColor, cursorColor: cursorColor, builder: (_, __, rect) { return Positioned( top: rect.top - 14, left: selection.isCollapsed ? rect.right : rect.left, child: ColoredBox( color: color, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 2.0, vertical: 1.0, ), child: FlowyText( metadata.userName, color: Colors.black, fontSize: 12.0, ), ), ), ); }, ); remoteSelections.add(remoteSelection); } editorState.remoteSelections.value = remoteSelections; } } extension on List { List toIntList() { return map((e) => e.toInt()).toList(); } } extension on List { bool get isTextDeltaChangeset { return length == 3 && this[0] == 'meta' && this[1] == 'text_map'; } bool get isBlockChangeset { return length == 2 && this[0] == 'blocks'; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; import 'package:appflowy/plugins/document/application/document_listener.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'document_collaborators_bloc.freezed.dart'; bool _filterCurrentUser = false; class DocumentCollaboratorsBloc extends Bloc { DocumentCollaboratorsBloc({ required this.view, }) : _listener = DocumentListener(id: view.id), super(DocumentCollaboratorsState.initial()) { on( (event, emit) async { await event.when( initial: () async { final result = await getIt().getUser(); final userProfile = result.fold((s) => s, (f) => null); emit( state.copyWith( shouldShowIndicator: userProfile?.workspaceType == WorkspaceTypePB.ServerW, ), ); final deviceId = ApplicationInfo.deviceId; if (userProfile != null) { _listener.start( onDocAwarenessUpdate: (states) { if (isClosed) { return; } add( DocumentCollaboratorsEvent.update( userProfile, deviceId, states, ), ); }, ); } }, update: (userProfile, deviceId, states) { final collaborators = _buildCollaborators( userProfile, deviceId, states, ); emit(state.copyWith(collaborators: collaborators)); }, ); }, ); } final ViewPB view; final DocumentListener _listener; @override Future close() async { await _listener.stop(); return super.close(); } List _buildCollaborators( UserProfilePB userProfile, String deviceId, DocumentAwarenessStatesPB states, ) { final result = []; final ids = {}; final sorted = states.value.values.toList() ..sort((a, b) => b.timestamp.compareTo(a.timestamp)) // filter the duplicate users ..retainWhere((e) => ids.add(e.user.uid.toString() + e.user.deviceId)) // only keep version 1 and metadata is not empty ..retainWhere((e) => e.version == 1) ..retainWhere((e) => e.metadata.isNotEmpty); for (final state in sorted) { if (state.version != 1) { continue; } // filter current user if (_filterCurrentUser && userProfile.id == state.user.uid && deviceId == state.user.deviceId) { continue; } try { final metadata = DocumentAwarenessMetadata.fromJson( jsonDecode(state.metadata), ); result.add(metadata); } catch (e) { Log.error('Failed to parse metadata: $e'); } } return result; } } @freezed class DocumentCollaboratorsEvent with _$DocumentCollaboratorsEvent { const factory DocumentCollaboratorsEvent.initial() = Initial; const factory DocumentCollaboratorsEvent.update( UserProfilePB userProfile, String deviceId, DocumentAwarenessStatesPB states, ) = Update; } @freezed class DocumentCollaboratorsState with _$DocumentCollaboratorsState { const factory DocumentCollaboratorsState({ @Default([]) List collaborators, @Default(false) bool shouldShowIndicator, }) = _DocumentCollaboratorsState; factory DocumentCollaboratorsState.initial() => const DocumentCollaboratorsState(); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' show Document, Node, Attributes, Delta, ParagraphBlockKeys, NodeIterator, NodeExternalValues, HeadingBlockKeys, NumberedListBlockKeys, BulletedListBlockKeys, blockComponentDelta; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:nanoid/nanoid.dart'; class ExternalValues extends NodeExternalValues { const ExternalValues({ required this.externalId, required this.externalType, }); final String externalId; final String externalType; } extension DocumentDataPBFromTo on DocumentDataPB { static DocumentDataPB? fromDocument(Document document) { final startNode = document.first; final endNode = document.last; if (startNode == null || endNode == null) { return null; } final pageId = document.root.id; // generate the block final blocks = {}; final nodes = NodeIterator( document: document, startNode: startNode, endNode: endNode, ).toList(); for (final node in nodes) { if (blocks.containsKey(node.id)) { assert(false, 'duplicate node id: ${node.id}'); } final parentId = node.parent?.id; final childrenId = nanoid(10); blocks[node.id] = node.toBlock( parentId: parentId, childrenId: childrenId, ); } // root blocks[pageId] = document.root.toBlock( parentId: '', childrenId: pageId, ); // generate the meta final childrenMap = {}; blocks.values.where((e) => e.parentId.isNotEmpty).forEach((value) { final childrenId = blocks[value.parentId]?.childrenId; if (childrenId != null) { childrenMap[childrenId] ??= ChildrenPB.create(); childrenMap[childrenId]!.children.add(value.id); } }); final meta = MetaPB(childrenMap: childrenMap); return DocumentDataPB( blocks: blocks, pageId: pageId, meta: meta, ); } Document? toDocument() { final rootId = pageId; try { final root = buildNode(rootId); if (root != null) { return Document(root: root); } return null; } catch (e) { Log.error('create document error: $e'); return null; } } Node? buildNode(String id) { final block = blocks[id]; final childrenId = block?.childrenId; final childrenIds = meta.childrenMap[childrenId]?.children; final children = []; if (childrenIds != null && childrenIds.isNotEmpty) { children.addAll(childrenIds.map((e) => buildNode(e)).nonNulls); } final node = block?.toNode( children: children, meta: meta, ); for (final element in children) { element.parent = node; } return node; } } extension BlockToNode on BlockPB { Node toNode({ Iterable? children, required MetaPB meta, }) { final node = Node( id: id, type: ty, attributes: _dataAdapter(ty, data, meta), children: children ?? [], ); node.externalValues = ExternalValues( externalId: externalId, externalType: externalType, ); return node; } Attributes _dataAdapter(String ty, String data, MetaPB meta) { final map = Attributes.from(jsonDecode(data)); // it used in the delta case now. final externalType = this.externalType; final externalId = this.externalId; if (externalType.isNotEmpty && externalId.isNotEmpty) { // the 'text' type is the only type that is supported now. if (externalType == 'text') { final deltaString = meta.textMap[externalId]; if (deltaString != null) { final delta = jsonDecode(deltaString); map[blockComponentDelta] = delta; } } } Attributes adapterCallback(Attributes map) => map ..putIfAbsent( blockComponentDelta, () => Delta().toJson(), ); final adapter = { ParagraphBlockKeys.type: adapterCallback, HeadingBlockKeys.type: adapterCallback, CodeBlockKeys.type: adapterCallback, QuoteBlockKeys.type: adapterCallback, NumberedListBlockKeys.type: adapterCallback, BulletedListBlockKeys.type: adapterCallback, ToggleListBlockKeys.type: adapterCallback, }; return adapter[ty]?.call(map) ?? map; } } extension NodeToBlock on Node { BlockPB toBlock({ String? parentId, String? childrenId, Attributes? attributes, String? externalId, String? externalType, }) { assert(id.isNotEmpty); final block = BlockPB.create() ..id = id ..ty = type ..data = _dataAdapter(type, attributes ?? this.attributes); if (childrenId != null && childrenId.isNotEmpty) { block.childrenId = childrenId; } if (parentId != null && parentId.isNotEmpty) { block.parentId = parentId; } if (externalId != null && externalId.isNotEmpty) { block.externalId = externalId; } if (externalType != null && externalType.isNotEmpty) { block.externalType = externalType; } return block; } String _dataAdapter(String type, Attributes attributes) { try { return jsonEncode( attributes, toEncodable: (value) { if (value is Map) { return jsonEncode(value); } return value; }, ); } catch (e) { Log.error('encode attributes error: $e'); return '{}'; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_diff.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; /// DocumentDiff compares two document states and generates operations needed /// to transform one state into another. class DocumentDiff { const DocumentDiff({ this.enableDebugLog = false, }); final bool enableDebugLog; // Using DeepCollectionEquality for deep comparison of collections static const _equality = DeepCollectionEquality(); /// Generates operations needed to transform oldDocument into newDocument. /// Returns a list of operations (Insert, Delete, Update) that can be applied sequentially. List diffDocument(Document oldDocument, Document newDocument) { return diffNode(oldDocument.root, newDocument.root); } /// Compares two nodes and their children recursively to generate transformation operations. /// Returns a list of operations that will transform oldNode into newNode. List diffNode(Node oldNode, Node newNode) { final operations = []; // Compare and update node attributes if they're different. // Using DeepCollectionEquality instead of == for deep comparison of collections if (!_equality.equals(oldNode.attributes, newNode.attributes)) { operations.add( UpdateOperation(oldNode.path, newNode.attributes, oldNode.attributes), ); } final oldChildrenById = Map.fromEntries( oldNode.children.map((child) => MapEntry(child.id, child)), ); final newChildrenById = Map.fromEntries( newNode.children.map((child) => MapEntry(child.id, child)), ); // Insertion or Update for (final newChild in newNode.children) { final oldChild = oldChildrenById[newChild.id]; if (oldChild == null) { // If the node doesn't exist in the old document, it's a new node. operations.add(InsertOperation(newChild.path, [newChild])); } else { // If the node exists in the old document, recursively compare its children operations.addAll(diffNode(oldChild, newChild)); } } // Deletion for (final id in oldChildrenById.keys) { // If the node doesn't exist in the new document, it's a deletion. if (!newChildrenById.containsKey(id)) { final oldChild = oldChildrenById[id]!; operations.add(DeleteOperation(oldChild.path, [oldChild])); } } // Optimize operations by merging consecutive inserts and deletes return _optimizeOperations(operations); } /// Optimizes the list of operations by merging consecutive operations where possible. /// This reduces the total number of operations that need to be applied. List _optimizeOperations(List operations) { // Optimize the insert operations first, then the delete operations final optimizedOps = mergeDeleteOperations( mergeInsertOperations( operations, ), ); return optimizedOps; } /// Merges consecutive insert operations to reduce the number of operations. /// Operations are merged if they target consecutive paths in the document. List mergeInsertOperations(List operations) { if (enableDebugLog) { _logOperations('mergeInsertOperations[before]', operations); } final copy = [...operations]; final insertOperations = operations .whereType() .sorted(_descendingCompareTo) .toList(); _mergeConsecutiveOperations( insertOperations, (prev, current) => InsertOperation( prev.path, [...prev.nodes, ...current.nodes], ), ); if (insertOperations.isNotEmpty) { copy ..removeWhere((op) => op is InsertOperation) ..insertAll(0, insertOperations); // Insert ops must be at the start } if (enableDebugLog) { _logOperations('mergeInsertOperations[after]', copy); } return copy; } /// Merges consecutive delete operations to reduce the number of operations. /// Operations are merged if they target consecutive paths in the document. List mergeDeleteOperations(List operations) { if (enableDebugLog) { _logOperations('mergeDeleteOperations[before]', operations); } final copy = [...operations]; final deleteOperations = operations .whereType() .sorted(_descendingCompareTo) .toList(); _mergeConsecutiveOperations( deleteOperations, (prev, current) => DeleteOperation( prev.path, [...prev.nodes, ...current.nodes], ), ); if (deleteOperations.isNotEmpty) { copy ..removeWhere((op) => op is DeleteOperation) ..addAll(deleteOperations); // Delete ops must be at the end } if (enableDebugLog) { _logOperations('mergeDeleteOperations[after]', copy); } return copy; } /// Merge consecutive operations of the same type void _mergeConsecutiveOperations( List operations, T Function(T prev, T current) merge, ) { for (var i = operations.length - 1; i > 0; i--) { final op = operations[i]; final previousOp = operations[i - 1]; if (op.path.equals(previousOp.path.next)) { operations ..removeAt(i) ..[i - 1] = merge(previousOp, op); } } } void _logOperations(String prefix, List operations) { debugPrint('$prefix: ${operations.map((op) => op.toJson()).toList()}'); } int _descendingCompareTo(Operation a, Operation b) { return a.path > b.path ? 1 : -1; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/document_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef OnDocumentEventUpdate = void Function(DocEventPB docEvent); typedef OnDocumentAwarenessStateUpdate = void Function( DocumentAwarenessStatesPB awarenessStates, ); class DocumentListener { DocumentListener({ required this.id, }); final String id; StreamSubscription? _subscription; DocumentNotificationParser? _parser; OnDocumentEventUpdate? _onDocEventUpdate; OnDocumentAwarenessStateUpdate? _onDocAwarenessUpdate; void start({ OnDocumentEventUpdate? onDocEventUpdate, OnDocumentAwarenessStateUpdate? onDocAwarenessUpdate, }) { _onDocEventUpdate = onDocEventUpdate; _onDocAwarenessUpdate = onDocAwarenessUpdate; _parser = DocumentNotificationParser( id: id, callback: _callback, ); _subscription = RustStreamReceiver.listen( (observable) => _parser?.parse(observable), ); } void _callback( DocumentNotification ty, FlowyResult result, ) { switch (ty) { case DocumentNotification.DidReceiveUpdate: result.map( (s) => _onDocEventUpdate?.call(DocEventPB.fromBuffer(s)), ); break; case DocumentNotification.DidUpdateDocumentAwarenessState: result.map( (s) => _onDocAwarenessUpdate?.call( DocumentAwarenessStatesPB.fromBuffer(s), ), ); break; default: break; } } Future stop() async { _onDocAwarenessUpdate = null; _onDocEventUpdate = null; await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_rules.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; /// Apply rules to the document /// /// 1. ensure there is at least one paragraph in the document, otherwise the user will be blocked from typing /// 2. remove columns block if its children are empty class DocumentRules { DocumentRules({ required this.editorState, }); final EditorState editorState; Future applyRules({ required EditorTransactionValue value, }) async { await Future.wait([ _ensureAtLeastOneParagraphExists(value: value), _removeColumnIfItIsEmpty(value: value), ]); } Future _ensureAtLeastOneParagraphExists({ required EditorTransactionValue value, }) async { final document = editorState.document; if (document.root.children.isEmpty) { final transaction = editorState.transaction; transaction ..insertNode([0], paragraphNode()) ..afterSelection = Selection.collapsed( Position(path: [0]), ); await editorState.apply(transaction); } } Future _removeColumnIfItIsEmpty({ required EditorTransactionValue value, }) async { final transaction = value.$2; final options = value.$3; if (options.inMemoryUpdate) { return; } for (final operation in transaction.operations) { final deleteColumnsTransaction = editorState.transaction; if (operation is DeleteOperation) { final path = operation.path; final column = editorState.document.nodeAtPath(path.parent); if (column != null && column.type == SimpleColumnBlockKeys.type) { // check if the column is empty final children = column.children; if (children.isEmpty) { // delete the column or the columns final columns = column.parent; if (columns != null && columns.type == SimpleColumnsBlockKeys.type) { final nonEmptyColumnCount = columns.children.fold( 0, (p, c) => c.children.isEmpty ? p : p + 1, ); // Example: // columns // - column 1 // - paragraph 1-1 // - paragraph 1-2 // - column 2 // - paragraph 2 // - column 3 // - paragraph 3 // // case 1: delete the paragraph 3 from column 3. // because there is only one child in column 3, we should delete the column 3 as well. // the result should be: // columns // - column 1 // - paragraph 1-1 // - paragraph 1-2 // - column 2 // - paragraph 2 // // case 2: delete the paragraph 3 from column 3 and delete the paragraph 2 from column 2. // in this case, there will be only one column left, so we should delete the columns block and flatten the children. // the result should be: // paragraph 1-1 // paragraph 1-2 // if there is only one empty column left, delete the columns block and flatten the children if (nonEmptyColumnCount <= 1) { // move the children in columns out of the column final children = columns.children .map((e) => e.children) .expand((e) => e) .map((e) => e.deepCopy()) .toList(); deleteColumnsTransaction.insertNodes(columns.path, children); deleteColumnsTransaction.deleteNode(columns); } else { // otherwise, delete the column deleteColumnsTransaction.deleteNode(column); final deletedColumnRatio = column.attributes[SimpleColumnBlockKeys.ratio]; if (deletedColumnRatio != null) { // update the ratio of the columns final columnsNode = column.columnsParent; if (columnsNode != null) { final length = columnsNode.children.length; for (final columnNode in columnsNode.children) { final ratio = columnNode.attributes[SimpleColumnBlockKeys.ratio] ?? 1.0 / length; if (ratio != null) { deleteColumnsTransaction.updateNode(columnNode, { ...columnNode.attributes, SimpleColumnBlockKeys.ratio: ratio + deletedColumnRatio / (length - 1), }); } } } } } } } } } if (deleteColumnsTransaction.operations.isNotEmpty) { await editorState.apply(deleteColumnsTransaction); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart ================================================ import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; class DocumentService { // unused now. Future> createDocument({ required ViewPB view, }) async { final canOpen = await openDocument(documentId: view.id); if (canOpen.isSuccess) { return FlowyResult.success(null); } final payload = CreateDocumentPayloadPB()..documentId = view.id; final result = await DocumentEventCreateDocument(payload).send(); return result; } Future> openDocument({ required String documentId, }) async { final payload = OpenDocumentPayloadPB()..documentId = documentId; final result = await DocumentEventOpenDocument(payload).send(); return result; } Future> getDocument({ required String documentId, }) async { final payload = OpenDocumentPayloadPB()..documentId = documentId; final result = await DocumentEventGetDocumentData(payload).send(); return result; } Future> getDocumentNode({ required String documentId, required String blockId, }) async { final documentResult = await getDocument(documentId: documentId); final document = documentResult.fold((l) => l, (f) => null); if (document == null) { Log.error('unable to get the document for page $documentId'); return FlowyResult.failure(FlowyError(msg: 'Document not found')); } final blockResult = await getBlockFromDocument( document: document, blockId: blockId, ); final block = blockResult.fold((l) => l, (f) => null); if (block == null) { Log.error( 'unable to get the block $blockId from the document $documentId', ); return FlowyResult.failure(FlowyError(msg: 'Block not found')); } final node = document.buildNode(blockId); if (node == null) { Log.error( 'unable to get the node for block $blockId in document $documentId', ); return FlowyResult.failure(FlowyError(msg: 'Node not found')); } return FlowyResult.success((document, block, node)); } Future> getBlockFromDocument({ required DocumentDataPB document, required String blockId, }) async { final block = document.blocks[blockId]; if (block != null) { return FlowyResult.success(block); } return FlowyResult.failure( FlowyError( msg: 'Block($blockId) not found in Document(${document.pageId})', ), ); } Future> closeDocument({ required String viewId, }) async { final payload = ViewIdPB()..value = viewId; final result = await FolderEventCloseView(payload).send(); return result; } Future> applyAction({ required String documentId, required Iterable actions, }) async { final payload = ApplyActionPayloadPB( documentId: documentId, actions: actions, ); final result = await DocumentEventApplyAction(payload).send(); return result; } /// Creates a new external text. /// /// Normally, it's used to the block that needs sync long text. /// /// the delta parameter is the json representation of the delta. Future> createExternalText({ required String documentId, required String textId, String? delta, }) async { final payload = TextDeltaPayloadPB( documentId: documentId, textId: textId, delta: delta, ); final result = await DocumentEventCreateText(payload).send(); return result; } /// Updates the external text. /// /// this function is compatible with the [createExternalText] function. /// /// the delta parameter is the json representation of the delta too. Future> updateExternalText({ required String documentId, required String textId, String? delta, }) async { final payload = TextDeltaPayloadPB( documentId: documentId, textId: textId, delta: delta, ); final result = await DocumentEventApplyTextDeltaEvent(payload).send(); return result; } /// Upload a file to the cloud storage. Future> uploadFile({ required String localFilePath, required String documentId, }) async { final workspace = await FolderEventReadCurrentWorkspace().send(); return workspace.fold( (l) async { final payload = UploadFileParamsPB( workspaceId: l.id, localFilePath: localFilePath, documentId: documentId, ); return DocumentEventUploadFile(payload).send(); }, (r) async { return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); }, ); } /// Download a file from the cloud storage. Future> downloadFile({ required String url, }) async { final workspace = await FolderEventReadCurrentWorkspace().send(); return workspace.fold((l) async { final payload = DownloadFilePB( url: url, ); final result = await DocumentEventDownloadFile(payload).send(); return result; }, (r) async { return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); }); } /// Sync the awareness states /// For example, the cursor position, selection, who is viewing the document. Future> syncAwarenessStates({ required String documentId, Selection? selection, String? metadata, }) async { final payload = UpdateDocumentAwarenessStatePB( documentId: documentId, selection: convertSelectionToAwarenessSelection(selection), metadata: metadata, ); final result = await DocumentEventSetAwarenessState(payload).send(); return result; } DocumentAwarenessSelectionPB? convertSelectionToAwarenessSelection( Selection? selection, ) { if (selection == null) { return null; } return DocumentAwarenessSelectionPB( start: DocumentAwarenessPositionPB( offset: Int64(selection.startIndex), path: selection.start.path.map((e) => Int64(e)), ), end: DocumentAwarenessPositionPB( offset: Int64(selection.endIndex), path: selection.end.path.map((e) => Int64(e)), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'document_sync_bloc.freezed.dart'; class DocumentSyncBloc extends Bloc { DocumentSyncBloc({ required this.view, }) : _syncStateListener = DocumentSyncStateListener(id: view.id), super(DocumentSyncBlocState.initial()) { on( (event, emit) async { await event.when( initial: () async { final userProfile = await getIt().getUser().then( (result) => result.fold( (l) => l, (r) => null, ), ); emit( state.copyWith( shouldShowIndicator: userProfile?.workspaceType == WorkspaceTypePB.ServerW, ), ); _syncStateListener.start( didReceiveSyncState: (syncState) { add(DocumentSyncEvent.syncStateChanged(syncState)); }, ); final isNetworkConnected = await _connectivity .checkConnectivity() .then((value) => value != ConnectivityResult.none); emit(state.copyWith(isNetworkConnected: isNetworkConnected)); connectivityStream = _connectivity.onConnectivityChanged.listen((result) { add(DocumentSyncEvent.networkStateChanged(result)); }); }, syncStateChanged: (syncState) { emit(state.copyWith(syncState: syncState.value)); }, networkStateChanged: (result) { emit( state.copyWith( isNetworkConnected: result != ConnectivityResult.none, ), ); }, ); }, ); } final ViewPB view; final DocumentSyncStateListener _syncStateListener; final _connectivity = Connectivity(); StreamSubscription? connectivityStream; @override Future close() async { await connectivityStream?.cancel(); await _syncStateListener.stop(); return super.close(); } } @freezed class DocumentSyncEvent with _$DocumentSyncEvent { const factory DocumentSyncEvent.initial() = Initial; const factory DocumentSyncEvent.syncStateChanged( DocumentSyncStatePB syncState, ) = syncStateChanged; const factory DocumentSyncEvent.networkStateChanged( ConnectivityResult result, ) = NetworkStateChanged; } @freezed class DocumentSyncBlocState with _$DocumentSyncBlocState { const factory DocumentSyncBlocState({ required DocumentSyncState syncState, @Default(true) bool isNetworkConnected, @Default(false) bool shouldShowIndicator, }) = _DocumentSyncState; factory DocumentSyncBlocState.initial() => const DocumentSyncBlocState( syncState: DocumentSyncState.Syncing, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/document_validator.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; class DocumentValidator { const DocumentValidator({ required this.editorState, required this.rules, }); final EditorState editorState; final List rules; Future validate(Transaction transaction) async { // deep copy the document final root = this.editorState.document.root.deepCopy(); final dummyDocument = Document(root: root); if (dummyDocument.isEmpty) { return true; } final editorState = EditorState(document: dummyDocument); await editorState.apply(transaction); final iterator = NodeIterator( document: editorState.document, startNode: editorState.document.root, ); for (final rule in rules) { while (iterator.moveNext()) { if (!rule.validate(iterator.current)) { return false; } } } return true; } Future applyTransactionInDummyDocument(Transaction transaction) async { // deep copy the document final root = this.editorState.document.root.deepCopy(); final dummyDocument = Document(root: root); if (dummyDocument.isEmpty) { return true; } final editorState = EditorState(document: dummyDocument); await editorState.apply(transaction); final iterator = NodeIterator( document: editorState.document, startNode: editorState.document.root, ); for (final rule in rules) { while (iterator.moveNext()) { if (!rule.validate(iterator.current)) { return false; } } } return true; } } class DocumentRule { const DocumentRule({ required this.type, }); final String type; bool validate(Node node) { return true; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:nanoid/nanoid.dart'; const kExternalTextType = 'text'; /// Uses to adjust the data structure between the editor and the backend. /// /// The editor uses a tree structure to represent the document, while the backend uses a flat structure. /// This adapter is used to convert the editor's transaction to the backend's transaction. class TransactionAdapter { TransactionAdapter({ required this.documentId, required this.documentService, }); final DocumentService documentService; final String documentId; Future apply(Transaction transaction, EditorState editorState) async { if (enableDocumentInternalLog) { Log.info( '[TransactionAdapter] 2. apply transaction begin ${transaction.hashCode} in $hashCode', ); } await _applyInternal(transaction, editorState); if (enableDocumentInternalLog) { Log.info( '[TransactionAdapter] 3. apply transaction end ${transaction.hashCode} in $hashCode', ); } } Future _applyInternal( Transaction transaction, EditorState editorState, ) async { final stopwatch = Stopwatch()..start(); if (enableDocumentInternalLog) { Log.info('transaction => ${transaction.toJson()}'); } final actions = transactionToBlockActions(transaction, editorState); final textActions = filterTextDeltaActions(actions); final actionCostTime = stopwatch.elapsedMilliseconds; for (final textAction in textActions) { final payload = textAction.textDeltaPayloadPB!; final type = textAction.textDeltaType; if (type == TextDeltaType.create) { await documentService.createExternalText( documentId: payload.documentId, textId: payload.textId, delta: payload.delta, ); if (enableDocumentInternalLog) { Log.info( '[editor_transaction_adapter] create external text: id: ${payload.textId} delta: ${payload.delta}', ); } } else if (type == TextDeltaType.update) { await documentService.updateExternalText( documentId: payload.documentId, textId: payload.textId, delta: payload.delta, ); if (enableDocumentInternalLog) { Log.info( '[editor_transaction_adapter] update external text: id: ${payload.textId} delta: ${payload.delta}', ); } } } final blockActions = filterBlockActions(actions); for (final action in blockActions) { if (enableDocumentInternalLog) { Log.info( '[editor_transaction_adapter] action => ${action.toProto3Json()}', ); } } await documentService.applyAction( documentId: documentId, actions: blockActions, ); final elapsed = stopwatch.elapsedMilliseconds; stopwatch.stop(); if (enableDocumentInternalLog) { Log.info( '[editor_transaction_adapter] apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms', ); } } List transactionToBlockActions( Transaction transaction, EditorState editorState, ) { return transaction.operations .map((op) => op.toBlockAction(editorState, documentId)) .nonNulls .expand((element) => element) .toList(growable: false); // avoid lazy evaluation } List filterTextDeltaActions( List actions, ) { return actions .where( (e) => e.textDeltaType != TextDeltaType.none && e.textDeltaPayloadPB != null, ) .toList(growable: false); } List filterBlockActions( List actions, ) { return actions.map((e) => e.blockActionPB).toList(growable: false); } } extension BlockAction on Operation { List toBlockAction( EditorState editorState, String documentId, ) { final op = this; if (op is InsertOperation) { return op.toBlockAction(editorState, documentId); } else if (op is UpdateOperation) { return op.toBlockAction(editorState, documentId); } else if (op is DeleteOperation) { return op.toBlockAction(editorState); } throw UnimplementedError(); } } extension on InsertOperation { List toBlockAction( EditorState editorState, String documentId, { Node? previousNode, }) { Path currentPath = path; final List actions = []; for (final node in nodes) { if (node.type == AiWriterBlockKeys.type) { continue; } final parentId = node.parent?.id ?? editorState.getNodeAtPath(currentPath.parent)?.id ?? ''; assert(parentId.isNotEmpty); String prevId = ''; // if the node is the first child of the parent, then its prevId should be empty. final isFirstChild = currentPath.previous.equals(currentPath); if (!isFirstChild) { prevId = previousNode?.id ?? editorState.getNodeAtPath(currentPath.previous)?.id ?? ''; assert(prevId.isNotEmpty && prevId != node.id); } // create the external text if the node contains the delta in its data. final delta = node.delta; TextDeltaPayloadPB? textDeltaPayloadPB; String? textId; if (delta != null) { textId = nanoid(6); textDeltaPayloadPB = TextDeltaPayloadPB( documentId: documentId, textId: textId, delta: jsonEncode(node.delta!.toJson()), ); // sync the text id to the node node.externalValues = ExternalValues( externalId: textId, externalType: kExternalTextType, ); } // remove the delta from the data when the incremental update is stable. final payload = BlockActionPayloadPB() ..block = node.toBlock( childrenId: nanoid(6), externalId: textId, externalType: textId != null ? kExternalTextType : null, attributes: {...node.attributes}..remove(blockComponentDelta), ) ..parentId = parentId ..prevId = prevId; // pass the external text id to the payload. if (textDeltaPayloadPB != null) { payload.textId = textDeltaPayloadPB.textId; } assert(payload.block.childrenId.isNotEmpty); final blockActionPB = BlockActionPB() ..action = BlockActionTypePB.Insert ..payload = payload; actions.add( BlockActionWrapper( blockActionPB: blockActionPB, textDeltaPayloadPB: textDeltaPayloadPB, textDeltaType: TextDeltaType.create, ), ); if (node.children.isNotEmpty) { Node? prevChild; for (final child in node.children) { actions.addAll( InsertOperation(currentPath + child.path, [child]).toBlockAction( editorState, documentId, previousNode: prevChild, ), ); prevChild = child; } } previousNode = node; currentPath = currentPath.next; } return actions; } } extension on UpdateOperation { List toBlockAction( EditorState editorState, String documentId, ) { final List actions = []; // if the attributes are both empty, we don't need to update if (const DeepCollectionEquality().equals(attributes, oldAttributes)) { return actions; } final node = editorState.getNodeAtPath(path); if (node == null) { assert(false, 'node not found at path: $path'); return actions; } final parentId = node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? ''; assert(parentId.isNotEmpty); // create the external text if the node contains the delta in its data. final prevDelta = oldAttributes[blockComponentDelta]; final delta = attributes[blockComponentDelta]; final composedAttributes = composeAttributes(oldAttributes, attributes); final composedDelta = composedAttributes?[blockComponentDelta]; composedAttributes?.remove(blockComponentDelta); final payload = BlockActionPayloadPB() ..block = node.toBlock( parentId: parentId, attributes: composedAttributes, ) ..parentId = parentId; final blockActionPB = BlockActionPB() ..action = BlockActionTypePB.Update ..payload = payload; final textId = (node.externalValues as ExternalValues?)?.externalId; if (textId == null || textId.isEmpty) { // to be compatible with the old version, we create a new text id if the text id is empty. final textId = nanoid(6); final textDelta = composedDelta ?? delta ?? prevDelta; final correctedTextDelta = textDelta != null ? _correctAttributes(textDelta) : null; final textDeltaPayloadPB = correctedTextDelta == null ? null : TextDeltaPayloadPB( documentId: documentId, textId: textId, delta: jsonEncode(correctedTextDelta), ); node.externalValues = ExternalValues( externalId: textId, externalType: kExternalTextType, ); if (enableDocumentInternalLog) { Log.info('create text delta: $textDeltaPayloadPB'); } // update the external text id and external type to the block blockActionPB.payload.block ..externalId = textId ..externalType = kExternalTextType; actions.add( BlockActionWrapper( blockActionPB: blockActionPB, textDeltaPayloadPB: textDeltaPayloadPB, textDeltaType: TextDeltaType.create, ), ); } else { final diff = prevDelta != null && delta != null ? Delta.fromJson(prevDelta).diff( Delta.fromJson(delta), ) : null; final correctedDiff = diff != null ? _correctDelta(diff) : null; final textDeltaPayloadPB = correctedDiff == null ? null : TextDeltaPayloadPB( documentId: documentId, textId: textId, delta: jsonEncode(correctedDiff), ); if (enableDocumentInternalLog) { Log.info('update text delta: $textDeltaPayloadPB'); } // update the external text id and external type to the block blockActionPB.payload.block ..externalId = textId ..externalType = kExternalTextType; actions.add( BlockActionWrapper( blockActionPB: blockActionPB, textDeltaPayloadPB: textDeltaPayloadPB, textDeltaType: TextDeltaType.update, ), ); } return actions; } // if the value in Delta's attributes is false, we should set the value to null instead. // because on Yjs, canceling format must use the null value. If we use false, the update will be rejected. List? _correctDelta(Delta delta) { // if the value in diff's attributes is false, we should set the value to null instead. // because on Yjs, canceling format must use the null value. If we use false, the update will be rejected. final correctedOps = delta.map((op) { final attributes = op.attributes?.map( (key, value) => MapEntry( key, // if the value is false, we should set the value to null instead. value == false ? null : value, ), ); if (attributes != null) { if (op is TextRetain) { return TextRetain(op.length, attributes: attributes); } else if (op is TextInsert) { return TextInsert(op.text, attributes: attributes); } // ignore the other operations that do not contain attributes. } return op; }); return correctedOps.toList(growable: false); } // Refer to [_correctDelta] for more details. List> _correctAttributes( List> attributes, ) { final correctedAttributes = attributes.map((attribute) { return attribute.map((key, value) { if (value is bool) { return MapEntry(key, value == false ? null : value); } else if (value is Map) { return MapEntry( key, value.map((key, value) { return MapEntry(key, value == false ? null : value); }), ); } return MapEntry(key, value); }); }).toList(growable: false); return correctedAttributes; } } extension on DeleteOperation { List toBlockAction(EditorState editorState) { final List actions = []; for (final node in nodes) { final parentId = node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? ''; final payload = BlockActionPayloadPB() ..block = node.toBlock( parentId: parentId, ) ..parentId = parentId; assert(parentId.isNotEmpty); actions.add( BlockActionPB() ..action = BlockActionTypePB.Delete ..payload = payload, ); } return actions .map((e) => BlockActionWrapper(blockActionPB: e)) .toList(growable: false); } } enum TextDeltaType { none, create, update, } class BlockActionWrapper { BlockActionWrapper({ required this.blockActionPB, this.textDeltaType = TextDeltaType.none, this.textDeltaPayloadPB, }); final BlockActionPB blockActionPB; final TextDeltaPayloadPB? textDeltaPayloadPB; final TextDeltaType textDeltaType; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/application/prelude.dart ================================================ export '../../shared/share/share_bloc.dart'; export 'document_bloc.dart'; export 'document_service.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/document.dart ================================================ library; import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/document_page.dart'; import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; import 'package:appflowy/plugins/shared/share/share_button.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DocumentPluginBuilder extends PluginBuilder { @override Plugin build(dynamic data) { if (data is ViewPB) { return DocumentPlugin(pluginType: pluginType, view: data); } throw FlowyPluginException.invalidData; } @override String get menuName => LocaleKeys.document_menuName.tr(); @override FlowySvgData get icon => FlowySvgs.icon_document_s; @override PluginType get pluginType => PluginType.document; @override ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class DocumentPlugin extends Plugin { DocumentPlugin({ required ViewPB view, required PluginType pluginType, this.initialSelection, this.initialBlockId, }) : notifier = ViewPluginNotifier(view: view) { _pluginType = pluginType; } late PluginType _pluginType; late final ViewInfoBloc _viewInfoBloc; late final PageAccessLevelBloc _pageAccessLevelBloc; @override final ViewPluginNotifier notifier; // the initial selection of the document final Selection? initialSelection; // the initial block id of the document final String? initialBlockId; @override PluginWidgetBuilder get widgetBuilder => DocumentPluginWidgetBuilder( bloc: _viewInfoBloc, pageAccessLevelBloc: _pageAccessLevelBloc, notifier: notifier, initialSelection: initialSelection, initialBlockId: initialBlockId, ); @override PluginType get pluginType => _pluginType; @override PluginId get id => notifier.view.id; @override void init() { _viewInfoBloc = ViewInfoBloc(view: notifier.view) ..add(const ViewInfoEvent.started()); _pageAccessLevelBloc = PageAccessLevelBloc(view: notifier.view) ..add(const PageAccessLevelEvent.initial()); } @override void dispose() { _viewInfoBloc.close(); _pageAccessLevelBloc.close(); notifier.dispose(); } } class DocumentPluginWidgetBuilder extends PluginWidgetBuilder with NavigationItem { DocumentPluginWidgetBuilder({ required this.bloc, required this.notifier, this.initialSelection, this.initialBlockId, required this.pageAccessLevelBloc, }); final ViewInfoBloc bloc; final ViewPluginNotifier notifier; final PageAccessLevelBloc pageAccessLevelBloc; ViewPB get view => notifier.view; int? deletedViewIndex; final Selection? initialSelection; final String? initialBlockId; @override EdgeInsets get contentPadding => EdgeInsets.zero; @override Widget buildWidget({ required PluginContext context, required bool shrinkWrap, Map? data, }) { notifier.isDeleted.addListener(() { final deletedView = notifier.isDeleted.value; if (deletedView != null && deletedView.hasIndex()) { deletedViewIndex = deletedView.index; } }); final fixedTitle = data?[MobileDocumentScreen.viewFixedTitle]; final blockId = initialBlockId ?? data?[MobileDocumentScreen.viewBlockId]; final tabs = data?[MobileDocumentScreen.viewSelectTabs] ?? const [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ]; return MultiBlocProvider( providers: [ BlocProvider.value( value: bloc, ), BlocProvider.value( value: pageAccessLevelBloc, ), ], child: BlocBuilder( builder: (_, state) => DocumentPage( key: ValueKey(view.id), view: view, onDeleted: () => context.onDeleted?.call(view, deletedViewIndex), initialSelection: initialSelection, initialBlockId: blockId, fixedTitle: fixedTitle, tabs: tabs, ), ), ); } @override String? get viewName => notifier.view.nameOrDefault; @override Widget get leftBarItem { return BlocProvider.value( value: pageAccessLevelBloc, child: ViewTitleBar(key: ValueKey(view.id), view: view), ); } @override Widget tabBarItem(String pluginId, [bool shortForm = false]) => ViewTabBarItem(view: notifier.view, shortForm: shortForm); @override Widget? get rightBarItem { return MultiBlocProvider( providers: [ BlocProvider.value( value: bloc, ), BlocProvider.value( value: pageAccessLevelBloc, ), ], child: Row( mainAxisSize: MainAxisSize.min, children: [ ...FeatureFlag.syncDocument.isOn ? [ DocumentCollaborators( key: ValueKey('collaborators_${view.id}'), width: 120, height: 32, view: view, ), const HSpace(16), ] : [const HSpace(8)], ShareButton( key: ValueKey('share_button_${view.id}'), view: view, ), const HSpace(10), ViewFavoriteButton( key: ValueKey('favorite_button_${view.id}'), view: view, ), const HSpace(4), MoreViewActions(view: view), ], ), ); } @override List get navigationItems => [this]; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/document_page.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class DocumentPage extends StatefulWidget { const DocumentPage({ super.key, required this.view, required this.onDeleted, required this.tabs, this.initialSelection, this.initialBlockId, this.fixedTitle, }); final ViewPB view; final VoidCallback onDeleted; final Selection? initialSelection; final String? initialBlockId; final String? fixedTitle; final List tabs; @override State createState() => _DocumentPageState(); } class _DocumentPageState extends State with WidgetsBindingObserver { EditorState? editorState; Selection? initialSelection; late final documentBloc = DocumentBloc(documentId: widget.view.id) ..add(const DocumentEvent.initial()); @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); documentBloc.close(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { documentBloc.add(const DocumentEvent.clearAwarenessStates()); } else if (state == AppLifecycleState.resumed) { documentBloc.add(const DocumentEvent.syncAwarenessStates()); } } @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider.value(value: getIt()), BlocProvider.value(value: documentBloc), BlocProvider( create: (context) => ViewBloc(view: widget.view)..add(const ViewEvent.initial()), lazy: false, ), ], child: BlocConsumer( listenWhen: (prev, curr) => curr.isLocked != prev.isLocked || curr.accessLevel != prev.accessLevel || curr.isLoadingLockStatus != prev.isLoadingLockStatus, listener: (context, pageAccessLevelState) { if (pageAccessLevelState.isLoadingLockStatus) { return; } editorState?.editable = pageAccessLevelState.isEditable; }, builder: (context, pageAccessLevelState) { return BlocBuilder( buildWhen: shouldRebuildDocument, builder: (context, state) { if (state.isLoading) { return const Center( child: CircularProgressIndicator.adaptive(), ); } final editorState = state.editorState; this.editorState = editorState; final error = state.error; if (error != null || editorState == null) { Log.error(error); return Center(child: AppFlowyErrorPage(error: error)); } if (state.forceClose) { widget.onDeleted(); return const SizedBox.shrink(); } return MultiBlocListener( listeners: [ BlocListener( listener: (context, state) { editorState.editable = state.isEditable; }, ), BlocListener( listenWhen: (_, curr) => curr.action != null, listener: onNotificationAction, ), ], child: AiWriterScrollWrapper( viewId: widget.view.id, editorState: editorState, child: buildEditorPage(context, state), ), ); }, ); }, ), ); } Widget buildEditorPage( BuildContext context, DocumentState state, ) { final editorState = state.editorState; if (editorState == null) { return const SizedBox.shrink(); } final width = context.read().state.width; // avoid the initial selection calculation change when the editorState is not changed initialSelection ??= _calculateInitialSelection(editorState); final Widget child; if (UniversalPlatform.isMobile) { child = BlocBuilder( builder: (context, styleState) => AppFlowyEditorPage( editorState: editorState, // if the view's name is empty, focus on the title autoFocus: widget.view.name.isEmpty ? false : null, styleCustomizer: EditorStyleCustomizer( context: context, width: width, padding: EditorStyleCustomizer.documentPadding, editorState: editorState, ), header: buildCoverAndIcon(context, state), initialSelection: initialSelection, ), ); } else { child = EditorDropHandler( viewId: widget.view.id, editorState: editorState, isLocalMode: context.read().isLocalMode, child: AppFlowyEditorPage( editorState: editorState, // if the view's name is empty, focus on the title autoFocus: widget.view.name.isEmpty ? false : null, styleCustomizer: EditorStyleCustomizer( context: context, width: width, padding: EditorStyleCustomizer.documentPadding, editorState: editorState, ), header: buildCoverAndIcon(context, state), initialSelection: initialSelection, placeholderText: (node) => node.type == ParagraphBlockKeys.type && !node.isInTable ? LocaleKeys.editor_slashPlaceHolder.tr() : '', ), ); } return Provider( create: (_) { final context = SharedEditorContext(); final children = editorState.document.root.children; final firstDelta = children.firstOrNull?.delta; final isEmptyDocument = children.length == 1 && (firstDelta == null || firstDelta.isEmpty); if (widget.view.name.isEmpty && isEmptyDocument) { context.requestCoverTitleFocus = true; } return context; }, dispose: (buildContext, editorContext) => editorContext.dispose(), child: EditorTransactionService( viewId: widget.view.id, editorState: state.editorState!, child: Column( children: [ // the banner only shows on desktop if (state.isDeleted && UniversalPlatform.isDesktop) buildBanner(context), Expanded(child: child), ], ), ), ); } Widget buildBanner(BuildContext context) { return DocumentBanner( viewName: widget.view.nameOrDefault, onRestore: () => context.read().add(const DocumentEvent.restorePage()), onDelete: () => context .read() .add(const DocumentEvent.deletePermanently()), ); } Widget buildCoverAndIcon(BuildContext context, DocumentState state) { final editorState = state.editorState; final userProfilePB = state.userProfilePB; if (editorState == null || userProfilePB == null) { return const SizedBox.shrink(); } if (UniversalPlatform.isMobile) { return DocumentImmersiveCover( fixedTitle: widget.fixedTitle, view: widget.view, tabs: widget.tabs, userProfilePB: userProfilePB, ); } final page = editorState.document.root; return DocumentCoverWidget( node: page, tabs: widget.tabs, editorState: editorState, view: widget.view, onIconChanged: (icon) async => ViewBackendService.updateViewIcon( view: widget.view, viewIcon: icon, ), ); } void onNotificationAction( BuildContext context, ActionNavigationState state, ) { final action = state.action; if (action == null || action.type != ActionType.jumpToBlock || action.objectId != widget.view.id) { return; } final editorState = context.read().state.editorState; if (editorState == null) { return; } final Path? path = _getPathFromAction(action, editorState); if (path != null) { editorState.updateSelectionWithReason( Selection.collapsed(Position(path: path)), ); } } Path? _getPathFromAction(NavigationAction action, EditorState editorState) { final path = action.arguments?[ActionArgumentKeys.nodePath]; if (path is int) { return [path]; } else if (path is List?) { if (path == null || path.isEmpty) { final blockId = action.arguments?[ActionArgumentKeys.blockId]; if (blockId != null) { return _findNodePathByBlockId(editorState, blockId); } } } return path; } Path? _findNodePathByBlockId(EditorState editorState, String blockId) { final document = editorState.document; final startNode = document.root.children.firstOrNull; if (startNode == null) { return null; } final nodeIterator = NodeIterator(document: document, startNode: startNode); while (nodeIterator.moveNext()) { final node = nodeIterator.current; if (node.id == blockId) { return node.path; } } return null; } bool shouldRebuildDocument(DocumentState previous, DocumentState current) { // only rebuild the document page when the below fields are changed // this is to prevent unnecessary rebuilds // // If you confirm the newly added fields should be rebuilt, please update // this function. if (previous.editorState != current.editorState) { return true; } if (previous.forceClose != current.forceClose || previous.isDeleted != current.isDeleted) { return true; } if (previous.userProfilePB != current.userProfilePB) { return true; } if (previous.isLoading != current.isLoading || previous.error != current.error) { return true; } return false; } Selection? _calculateInitialSelection(EditorState editorState) { if (widget.initialSelection != null) { return widget.initialSelection; } if (widget.initialBlockId != null) { final path = _findNodePathByBlockId(editorState, widget.initialBlockId!); if (path != null) { editorState.selectionType = SelectionType.block; editorState.selectionExtraInfo = { selectionExtraInfoDoNotAttachTextService: true, }; return Selection.collapsed( Position( path: path, ), ); } } return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/banner.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/buttons/base_styled_button.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class DocumentBanner extends StatelessWidget { const DocumentBanner({ super.key, required this.viewName, required this.onRestore, required this.onDelete, }); final String viewName; final void Function() onRestore; final void Function() onDelete; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return ConstrainedBox( constraints: const BoxConstraints(minHeight: 60), child: Container( width: double.infinity, color: colorScheme.surfaceContainerHighest, child: FittedBox( fit: BoxFit.scaleDown, child: Row( children: [ FlowyText.medium( LocaleKeys.deletePagePrompt_text.tr(), color: colorScheme.tertiary, fontSize: 14, ), const HSpace(20), BaseStyledButton( minWidth: 160, minHeight: 40, contentPadding: EdgeInsets.zero, bgColor: Colors.transparent, highlightColor: Theme.of(context).colorScheme.onErrorContainer, outlineColor: colorScheme.tertiaryContainer, borderRadius: Corners.s8Border, onPressed: onRestore, child: FlowyText.medium( LocaleKeys.deletePagePrompt_restore.tr(), color: colorScheme.tertiary, fontSize: 13, ), ), const HSpace(20), BaseStyledButton( minWidth: 220, minHeight: 40, contentPadding: EdgeInsets.zero, bgColor: Colors.transparent, highlightColor: Theme.of(context).colorScheme.error, outlineColor: colorScheme.tertiaryContainer, borderRadius: Corners.s8Border, onPressed: () => showConfirmDeletionDialog( context: context, name: viewName.trim().isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : viewName, description: LocaleKeys .deletePagePrompt_deletePermanentDescription .tr(), onConfirm: onDelete, ), child: FlowyText.medium( LocaleKeys.deletePagePrompt_deletePermanent.tr(), color: colorScheme.tertiary, fontSize: 13, ), ), ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avatar_stack.dart ================================================ import 'package:avatar_stack/avatar_stack.dart'; import 'package:avatar_stack/positions.dart'; import 'package:flutter/material.dart'; class CollaboratorAvatarStack extends StatelessWidget { const CollaboratorAvatarStack({ super.key, required this.avatars, this.settings, this.infoWidgetBuilder, this.width, this.height, this.borderWidth, this.borderColor, this.backgroundColor, required this.plusWidgetBuilder, }); final List avatars; final Positions? settings; final InfoWidgetBuilder? infoWidgetBuilder; final double? width; final double? height; final double? borderWidth; final Color? borderColor; final Color? backgroundColor; final Widget Function(int value, BorderSide border) plusWidgetBuilder; @override Widget build(BuildContext context) { final settings = this.settings ?? RestrictedPositions( maxCoverage: 0.4, minCoverage: 0.3, align: StackAlign.right, laying: StackLaying.first, ); final border = BorderSide( color: borderColor ?? Theme.of(context).dividerColor, width: borderWidth ?? 2.0, ); return SizedBox( height: height, width: width, child: WidgetStack( positions: settings, buildInfoWidget: (value, _) => plusWidgetBuilder(value, border), stackedWidgets: avatars .map( (avatar) => CircleAvatar( backgroundColor: border.color, child: Padding( padding: EdgeInsets.all(border.width), child: avatar, ), ), ) .toList(), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/compact_mode_event.dart ================================================ import 'package:event_bus/event_bus.dart'; EventBus compactModeEventBus = EventBus(); class CompactModeEvent { CompactModeEvent({ required this.id, required this.enable, }); final String id; final bool enable; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart ================================================ import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; import 'package:appflowy/plugins/document/application/document_collaborators_bloc.dart'; import 'package:appflowy/plugins/document/presentation/collaborator_avatar_stack.dart'; import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:avatar_stack/avatar_stack.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DocumentCollaborators extends StatelessWidget { const DocumentCollaborators({ super.key, required this.height, required this.width, required this.view, this.padding, this.fontSize, }); final ViewPB view; final double height; final double width; final EdgeInsets? padding; final double? fontSize; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DocumentCollaboratorsBloc(view: view) ..add(const DocumentCollaboratorsEvent.initial()), child: BlocBuilder( builder: (context, state) { final collaborators = state.collaborators; if (!state.shouldShowIndicator || collaborators.isEmpty) { return const SizedBox.shrink(); } return Padding( padding: padding ?? EdgeInsets.zero, child: CollaboratorAvatarStack( height: height, width: width, borderWidth: 1.0, plusWidgetBuilder: (value, border) { final lastXCollaborators = collaborators.sublist( collaborators.length - value, ); return BorderedCircleAvatar( border: border, backgroundColor: Theme.of(context).hoverColor, child: FittedBox( child: Padding( padding: const EdgeInsets.all(8.0), child: FlowyTooltip( message: lastXCollaborators .map((e) => e.userName) .join('\n'), child: FlowyText( '+$value', fontSize: fontSize, color: Colors.black, ), ), ), ), ); }, avatars: [ ...collaborators.map( (c) => _UserAvatar(fontSize: fontSize, user: c, width: width), ), ], ), ); }, ), ); } } class _UserAvatar extends StatelessWidget { const _UserAvatar({ this.fontSize, required this.user, required this.width, }); final DocumentAwarenessMetadata user; final double? fontSize; final double width; @override Widget build(BuildContext context) { return FlowyTooltip( message: user.userName, child: IgnorePointer( child: UserAvatar( iconUrl: user.userAvatar, name: user.userName, size: AFAvatarSize.m, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import 'editor_plugins/link_preview/custom_link_preview_block_component.dart'; import 'editor_plugins/page_block/custom_page_block_component.dart'; /// A global configuration for the editor. class EditorGlobalConfiguration { /// Whether to enable the drag menu in the editor. /// /// Case 1, resizing the columns block in the desktop, then the drag menu will be disabled. static ValueNotifier enableDragMenu = ValueNotifier(true); } /// The node types that support slash menu. final Set supportSlashMenuNodeTypes = { ParagraphBlockKeys.type, HeadingBlockKeys.type, // Lists TodoListBlockKeys.type, BulletedListBlockKeys.type, NumberedListBlockKeys.type, QuoteBlockKeys.type, ToggleListBlockKeys.type, CalloutBlockKeys.type, // Simple table SimpleTableBlockKeys.type, SimpleTableRowBlockKeys.type, SimpleTableCellBlockKeys.type, // Columns SimpleColumnsBlockKeys.type, SimpleColumnBlockKeys.type, }; /// Build the block component builders. /// /// Every block type should have a corresponding builder in the map. /// Otherwise, the errorBlockComponentBuilder will be rendered. /// /// Additional, you can define the block render options in the builder /// - customize the block option actions. (... button and + button) /// - customize the block component configuration. (padding, placeholder, etc.) /// - customize the block icon. (bulleted list, numbered list, todo list) /// - customize the hover menu. (show the menu at the top-right corner of the block) Map buildBlockComponentBuilders({ required BuildContext context, required EditorState editorState, required EditorStyleCustomizer styleCustomizer, SlashMenuItemsBuilder? slashMenuItemsBuilder, bool editable = true, ShowPlaceholder? showParagraphPlaceholder, String Function(Node)? placeholderText, EdgeInsets Function(Node node)? customPadding, bool alwaysDistributeSimpleTableColumnWidths = false, }) { final configuration = _buildDefaultConfiguration( context, padding: customPadding, ); final builders = _buildBlockComponentBuilderMap( context, configuration: configuration, editorState: editorState, styleCustomizer: styleCustomizer, showParagraphPlaceholder: showParagraphPlaceholder, placeholderText: placeholderText, alwaysDistributeSimpleTableColumnWidths: alwaysDistributeSimpleTableColumnWidths, customHeadingPadding: customPadding, ); // customize the action builder. actually, we can customize them in their own builder. Put them here just for convenience. if (editable) { _customBlockOptionActions( context, builders: builders, editorState: editorState, styleCustomizer: styleCustomizer, slashMenuItemsBuilder: slashMenuItemsBuilder, ); } return builders; } BlockComponentConfiguration _buildDefaultConfiguration( BuildContext context, { EdgeInsets Function(Node node)? padding, }) { final configuration = BlockComponentConfiguration( padding: (node) { if (UniversalPlatform.isMobile) { final pageStyle = context.read().state; final factor = pageStyle.fontLayout.factor; final top = pageStyle.lineHeightLayout.padding * factor; EdgeInsets edgeInsets = EdgeInsets.only(top: top); // only add padding for the top level node, otherwise the nested node will have extra padding if (node.path.length == 1) { if (node.type != SimpleTableBlockKeys.type) { // do not add padding for the simple table to allow it overflow edgeInsets = edgeInsets.copyWith( left: EditorStyleCustomizer.nodeHorizontalPadding, ); } edgeInsets = edgeInsets.copyWith( right: EditorStyleCustomizer.nodeHorizontalPadding, ); } return padding?.call(node) ?? edgeInsets; } return const EdgeInsets.symmetric(vertical: 5.0); }, indentPadding: (node, textDirection) { double padding = 26.0; // only add indent padding for the top level node to align the children if (UniversalPlatform.isMobile && node.level == 1) { padding += EditorStyleCustomizer.nodeHorizontalPadding - 4; } // in the quote block, we reduce the indent padding for the first level block. // So we have to add more padding for the second level to avoid the drag menu overlay the quote icon. if (node.parent?.type == QuoteBlockKeys.type && UniversalPlatform.isDesktop) { padding += 22; } return textDirection == TextDirection.ltr ? EdgeInsets.only(left: padding) : EdgeInsets.only(right: padding); }, ); return configuration; } /// Build the option actions for the block component. /// /// Notes: different block type may have different option actions. /// All the block types have the delete and duplicate options. List _buildOptionActions(BuildContext context, String type) { final standardActions = [ OptionAction.delete, OptionAction.duplicate, ]; // filter out the copy link to block option if in local mode if (context.read()?.isLocalMode != true) { standardActions.add(OptionAction.copyLinkToBlock); } standardActions.add(OptionAction.turnInto); if (SimpleTableBlockKeys.type == type) { standardActions.addAll([ OptionAction.divider, OptionAction.setToPageWidth, OptionAction.distributeColumnsEvenly, ]); } if (EditorOptionActionType.color.supportTypes.contains(type)) { standardActions.addAll([OptionAction.divider, OptionAction.color]); } if (EditorOptionActionType.align.supportTypes.contains(type)) { standardActions.addAll([OptionAction.divider, OptionAction.align]); } if (EditorOptionActionType.depth.supportTypes.contains(type)) { standardActions.addAll([OptionAction.divider, OptionAction.depth]); } return standardActions; } void _customBlockOptionActions( BuildContext context, { required Map builders, required EditorState editorState, required EditorStyleCustomizer styleCustomizer, SlashMenuItemsBuilder? slashMenuItemsBuilder, }) { for (final entry in builders.entries) { if (entry.key == PageBlockKeys.type) { continue; } final builder = entry.value; final actions = _buildOptionActions(context, entry.key); if (UniversalPlatform.isDesktop) { builder.showActions = (node) { final parentTableNode = node.parentTableNode; // disable the option action button in table cell to avoid the misalignment issue if (node.type != SimpleTableBlockKeys.type && parentTableNode != null) { return false; } return true; }; builder.configuration = builder.configuration.copyWith( blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric( vertical: 1, ), ); builder.actionTrailingBuilder = (context, state) { if (context.node.parent?.type == QuoteBlockKeys.type) { return const SizedBox( width: 24, height: 24, ); } return const SizedBox.shrink(); }; builder.actionBuilder = (context, state) { double top = builder.configuration.padding(context.node).top; final type = context.node.type; final level = context.node.attributes[HeadingBlockKeys.level] ?? 0; if ((type == HeadingBlockKeys.type || type == ToggleListBlockKeys.type) && level > 0) { final offset = [13.0, 11.0, 8.0, 6.0, 4.0, 2.0]; top += offset[level - 1]; } else if (type == SimpleTableBlockKeys.type) { top += 8.0; } else { top += 2.0; } if (overflowTypes.contains(type)) { top = top / 2; } return ValueListenableBuilder( valueListenable: EditorGlobalConfiguration.enableDragMenu, builder: (_, enableDragMenu, child) { return ValueListenableBuilder( valueListenable: editorState.editableNotifier, builder: (_, editable, child) { return IgnorePointer( ignoring: !editable, child: Opacity( opacity: editable && enableDragMenu ? 1.0 : 0.0, child: Padding( padding: EdgeInsets.only(top: top), child: BlockActionList( blockComponentContext: context, blockComponentState: state, editorState: editorState, blockComponentBuilder: builders, actions: actions, showSlashMenu: slashMenuItemsBuilder != null ? () => customAppFlowySlashCommand( itemsBuilder: slashMenuItemsBuilder, shouldInsertSlash: false, deleteKeywordsByDefault: true, style: styleCustomizer .selectionMenuStyleBuilder(), supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, ).handler.call(editorState) : () {}, ), ), ), ); }, ); }, ); }; } } } Map _buildBlockComponentBuilderMap( BuildContext context, { required BlockComponentConfiguration configuration, required EditorState editorState, required EditorStyleCustomizer styleCustomizer, ShowPlaceholder? showParagraphPlaceholder, String Function(Node)? placeholderText, EdgeInsets Function(Node)? customHeadingPadding, bool alwaysDistributeSimpleTableColumnWidths = false, }) { final customBlockComponentBuilderMap = { PageBlockKeys.type: CustomPageBlockComponentBuilder(), ParagraphBlockKeys.type: _buildParagraphBlockComponentBuilder( context, configuration, showParagraphPlaceholder, placeholderText, ), TodoListBlockKeys.type: _buildTodoListBlockComponentBuilder( context, configuration, ), BulletedListBlockKeys.type: _buildBulletedListBlockComponentBuilder( context, configuration, ), NumberedListBlockKeys.type: _buildNumberedListBlockComponentBuilder( context, configuration, ), QuoteBlockKeys.type: _buildQuoteBlockComponentBuilder( context, configuration, ), HeadingBlockKeys.type: _buildHeadingBlockComponentBuilder( context, configuration, styleCustomizer, customHeadingPadding, ), ImageBlockKeys.type: _buildCustomImageBlockComponentBuilder( context, configuration, ), MultiImageBlockKeys.type: _buildMultiImageBlockComponentBuilder( context, configuration, ), TableBlockKeys.type: _buildTableBlockComponentBuilder( context, configuration, ), TableCellBlockKeys.type: _buildTableCellBlockComponentBuilder( context, configuration, ), DatabaseBlockKeys.gridType: _buildDatabaseViewBlockComponentBuilder( context, configuration, ), DatabaseBlockKeys.boardType: _buildDatabaseViewBlockComponentBuilder( context, configuration, ), DatabaseBlockKeys.calendarType: _buildDatabaseViewBlockComponentBuilder( context, configuration, ), CalloutBlockKeys.type: _buildCalloutBlockComponentBuilder( context, configuration, ), DividerBlockKeys.type: _buildDividerBlockComponentBuilder( context, configuration, editorState, ), MathEquationBlockKeys.type: _buildMathEquationBlockComponentBuilder( context, configuration, ), CodeBlockKeys.type: _buildCodeBlockComponentBuilder( context, configuration, styleCustomizer, ), AiWriterBlockKeys.type: _buildAIWriterBlockComponentBuilder( context, configuration, ), ToggleListBlockKeys.type: _buildToggleListBlockComponentBuilder( context, configuration, styleCustomizer, customHeadingPadding, ), OutlineBlockKeys.type: _buildOutlineBlockComponentBuilder( context, configuration, styleCustomizer, ), LinkPreviewBlockKeys.type: _buildLinkPreviewBlockComponentBuilder( context, configuration, ), // Flutter doesn't support the video widget, so we forward the video block to the link preview block VideoBlockKeys.type: _buildLinkPreviewBlockComponentBuilder( context, configuration, ), FileBlockKeys.type: _buildFileBlockComponentBuilder( context, configuration, ), SubPageBlockKeys.type: _buildSubPageBlockComponentBuilder( context, configuration, styleCustomizer: styleCustomizer, ), errorBlockComponentBuilderKey: ErrorBlockComponentBuilder( configuration: configuration, ), SimpleTableBlockKeys.type: _buildSimpleTableBlockComponentBuilder( context, configuration, alwaysDistributeColumnWidths: alwaysDistributeSimpleTableColumnWidths, ), SimpleTableRowBlockKeys.type: _buildSimpleTableRowBlockComponentBuilder( context, configuration, alwaysDistributeColumnWidths: alwaysDistributeSimpleTableColumnWidths, ), SimpleTableCellBlockKeys.type: _buildSimpleTableCellBlockComponentBuilder( context, configuration, alwaysDistributeColumnWidths: alwaysDistributeSimpleTableColumnWidths, ), SimpleColumnsBlockKeys.type: _buildSimpleColumnsBlockComponentBuilder( context, configuration, ), SimpleColumnBlockKeys.type: _buildSimpleColumnBlockComponentBuilder( context, configuration, ), }; final builders = { ...standardBlockComponentBuilderMap, ...customBlockComponentBuilderMap, }; return builders; } SimpleTableBlockComponentBuilder _buildSimpleTableBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, { bool alwaysDistributeColumnWidths = false, }) { final copiedConfiguration = configuration.copyWith( padding: (node) { final padding = configuration.padding(node); if (UniversalPlatform.isDesktop) { return padding; } else { return padding; } }, ); return SimpleTableBlockComponentBuilder( configuration: copiedConfiguration, alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, ); } SimpleTableRowBlockComponentBuilder _buildSimpleTableRowBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, { bool alwaysDistributeColumnWidths = false, }) { return SimpleTableRowBlockComponentBuilder( configuration: configuration, alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, ); } SimpleTableCellBlockComponentBuilder _buildSimpleTableCellBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, { bool alwaysDistributeColumnWidths = false, }) { return SimpleTableCellBlockComponentBuilder( configuration: configuration, alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, ); } ParagraphBlockComponentBuilder _buildParagraphBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ShowPlaceholder? showParagraphPlaceholder, String Function(Node)? placeholderText, ) { return ParagraphBlockComponentBuilder( configuration: configuration.copyWith( placeholderText: placeholderText, textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell( context, node: node, configuration: configuration, textSpan: textSpan, ), textAlign: (node) => _buildTextAlignInTableCell( context, node: node, configuration: configuration, ), ), showPlaceholder: showParagraphPlaceholder, ); } TodoListBlockComponentBuilder _buildTodoListBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return TodoListBlockComponentBuilder( configuration: configuration.copyWith( placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(), textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell( context, node: node, configuration: configuration, textSpan: textSpan, ), textAlign: (node) => _buildTextAlignInTableCell( context, node: node, configuration: configuration, ), ), iconBuilder: (_, node, onCheck) => TodoListIcon( node: node, onCheck: onCheck, ), toggleChildrenTriggers: [ LogicalKeyboardKey.shift, LogicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftRight, ], ); } BulletedListBlockComponentBuilder _buildBulletedListBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return BulletedListBlockComponentBuilder( configuration: configuration.copyWith( placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(), textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell( context, node: node, configuration: configuration, textSpan: textSpan, ), textAlign: (node) => _buildTextAlignInTableCell( context, node: node, configuration: configuration, ), ), iconBuilder: (_, node) => BulletedListIcon(node: node), ); } NumberedListBlockComponentBuilder _buildNumberedListBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return NumberedListBlockComponentBuilder( configuration: configuration.copyWith( placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(), textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell( context, node: node, configuration: configuration, textSpan: textSpan, ), textAlign: (node) => _buildTextAlignInTableCell( context, node: node, configuration: configuration, ), ), iconBuilder: (_, node, textDirection) { TextStyle? textStyle; if (node.isInHeaderColumn || node.isInHeaderRow) { textStyle = configuration.textStyle(node).copyWith( fontWeight: FontWeight.bold, ); } return NumberedListIcon( node: node, textDirection: textDirection, textStyle: textStyle, ); }, ); } QuoteBlockComponentBuilder _buildQuoteBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return QuoteBlockComponentBuilder( configuration: configuration.copyWith( placeholderText: (_) => LocaleKeys.blockPlaceholders_quote.tr(), textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell( context, node: node, configuration: configuration, textSpan: textSpan, ), textAlign: (node) => _buildTextAlignInTableCell( context, node: node, configuration: configuration, ), indentPadding: (node, textDirection) { if (UniversalPlatform.isMobile) { return configuration.indentPadding(node, textDirection); } if (node.isInTable) { return textDirection == TextDirection.ltr ? EdgeInsets.only(left: 24) : EdgeInsets.only(right: 24); } return EdgeInsets.zero; }, ), ); } HeadingBlockComponentBuilder _buildHeadingBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, EditorStyleCustomizer styleCustomizer, EdgeInsets Function(Node)? customHeadingPadding, ) { return HeadingBlockComponentBuilder( configuration: configuration.copyWith( textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell( context, node: node, configuration: configuration, textSpan: textSpan, ), padding: (node) { if (customHeadingPadding != null) { return customHeadingPadding.call(node); } if (UniversalPlatform.isMobile) { final pageStyle = context.read().state; final factor = pageStyle.fontLayout.factor; final headingPaddings = pageStyle.lineHeightLayout.headingPaddings.map((e) => e * factor); final level = (node.attributes[HeadingBlockKeys.level] ?? 6).clamp(1, 6); final top = headingPaddings.elementAt(level - 1); EdgeInsets edgeInsets = EdgeInsets.only(top: top); if (node.path.length == 1) { edgeInsets = edgeInsets.copyWith( left: EditorStyleCustomizer.nodeHorizontalPadding, right: EditorStyleCustomizer.nodeHorizontalPadding, ); } return edgeInsets; } return const EdgeInsets.only(top: 12.0, bottom: 4.0); }, placeholderText: (node) { int level = node.attributes[HeadingBlockKeys.level] ?? 6; level = level.clamp(1, 6); return LocaleKeys.blockPlaceholders_heading.tr( args: [level.toString()], ); }, textAlign: (node) => _buildTextAlignInTableCell( context, node: node, configuration: configuration, ), ), textStyleBuilder: (level) { return styleCustomizer.headingStyleBuilder(level); }, ); } CustomImageBlockComponentBuilder _buildCustomImageBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return CustomImageBlockComponentBuilder( configuration: configuration, showMenu: true, menuBuilder: (node, state, imageStateNotifier) => Positioned( top: 10, right: 10, child: ImageMenu( node: node, state: state, imageStateNotifier: imageStateNotifier, ), ), ); } MultiImageBlockComponentBuilder _buildMultiImageBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return MultiImageBlockComponentBuilder( configuration: configuration, showMenu: true, menuBuilder: ( Node node, MultiImageBlockComponentState state, ValueNotifier indexNotifier, VoidCallback onImageDeleted, ) => Positioned( top: 10, right: 10, child: MultiImageMenu( node: node, state: state, indexNotifier: indexNotifier, isLocalMode: context.read().isLocalMode, onImageDeleted: onImageDeleted, ), ), ); } TableBlockComponentBuilder _buildTableBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return TableBlockComponentBuilder( menuBuilder: ( node, editorState, position, dir, onBuild, onClose, ) => TableMenu( node: node, editorState: editorState, position: position, dir: dir, onBuild: onBuild, onClose: onClose, ), ); } TableCellBlockComponentBuilder _buildTableCellBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return TableCellBlockComponentBuilder( colorBuilder: (context, node) { final String colorString = node.attributes[TableCellBlockKeys.colBackgroundColor] ?? node.attributes[TableCellBlockKeys.rowBackgroundColor] ?? ''; if (colorString.isEmpty) { return null; } return buildEditorCustomizedColor(context, node, colorString); }, menuBuilder: ( node, editorState, position, dir, onBuild, onClose, ) => TableMenu( node: node, editorState: editorState, position: position, dir: dir, onBuild: onBuild, onClose: onClose, ), ); } DatabaseViewBlockComponentBuilder _buildDatabaseViewBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return DatabaseViewBlockComponentBuilder( configuration: configuration.copyWith( padding: (node) { if (UniversalPlatform.isMobile) { return configuration.padding(node); } return const EdgeInsets.symmetric(vertical: 10); }, ), ); } CalloutBlockComponentBuilder _buildCalloutBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { final calloutBGColor = AFThemeExtension.of(context).calloutBGColor; return CalloutBlockComponentBuilder( configuration: configuration.copyWith( padding: (node) { if (UniversalPlatform.isMobile) { return configuration.padding(node); } return const EdgeInsets.symmetric(vertical: 10); }, textAlign: (node) => _buildTextAlignInTableCell( context, node: node, configuration: configuration, ), textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell( context, node: node, configuration: configuration, textSpan: textSpan, ), indentPadding: (node, _) => EdgeInsets.only(left: 42), ), inlinePadding: (node) { if (node.children.isEmpty) { return const EdgeInsets.symmetric(vertical: 8.0); } return EdgeInsets.only(top: 8.0, bottom: 2.0); }, defaultColor: calloutBGColor, ); } DividerBlockComponentBuilder _buildDividerBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, EditorState editorState, ) { return DividerBlockComponentBuilder( configuration: configuration, height: 28.0, wrapper: (_, node, child) => MobileBlockActionButtons( showThreeDots: false, node: node, editorState: editorState, child: child, ), ); } MathEquationBlockComponentBuilder _buildMathEquationBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return MathEquationBlockComponentBuilder( configuration: configuration, ); } CodeBlockComponentBuilder _buildCodeBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, EditorStyleCustomizer styleCustomizer, ) { return CodeBlockComponentBuilder( styleBuilder: styleCustomizer.codeBlockStyleBuilder, configuration: configuration, padding: const EdgeInsets.only(left: 20, right: 30, bottom: 34), languagePickerBuilder: codeBlockLanguagePickerBuilder, copyButtonBuilder: codeBlockCopyBuilder, ); } AIWriterBlockComponentBuilder _buildAIWriterBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return AIWriterBlockComponentBuilder(); } ToggleListBlockComponentBuilder _buildToggleListBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, EditorStyleCustomizer styleCustomizer, EdgeInsets Function(Node)? customHeadingPadding, ) { return ToggleListBlockComponentBuilder( configuration: configuration.copyWith( padding: (node) { if (customHeadingPadding != null) { return customHeadingPadding(node); } if (UniversalPlatform.isMobile) { final pageStyle = context.read().state; final factor = pageStyle.fontLayout.factor; final headingPaddings = pageStyle.lineHeightLayout.headingPaddings.map((e) => e * factor); final level = (node.attributes[HeadingBlockKeys.level] ?? 6).clamp(1, 6); final top = headingPaddings.elementAt(level - 1); return configuration.padding(node).copyWith(top: top); } return const EdgeInsets.only(top: 12.0, bottom: 4.0); }, textStyle: (node, {TextSpan? textSpan}) { final textStyle = _buildTextStyleInTableCell( context, node: node, configuration: configuration, textSpan: textSpan, ); final level = node.attributes[ToggleListBlockKeys.level] as int?; if (level == null) { return textStyle; } return textStyle.merge(styleCustomizer.headingStyleBuilder(level)); }, textAlign: (node) => _buildTextAlignInTableCell( context, node: node, configuration: configuration, ), placeholderText: (node) { int? level = node.attributes[ToggleListBlockKeys.level]; if (level == null) { return configuration.placeholderText(node); } level = level.clamp(1, 6); return LocaleKeys.blockPlaceholders_heading.tr( args: [level.toString()], ); }, ), textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level), ); } OutlineBlockComponentBuilder _buildOutlineBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, EditorStyleCustomizer styleCustomizer, ) { return OutlineBlockComponentBuilder( configuration: configuration.copyWith( placeholderTextStyle: (node, {TextSpan? textSpan}) => styleCustomizer.outlineBlockPlaceholderStyleBuilder(), padding: (node) { if (UniversalPlatform.isMobile) { return configuration.padding(node); } return const EdgeInsets.only(top: 12.0, bottom: 4.0); }, ), ); } CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return CustomLinkPreviewBlockComponentBuilder( configuration: configuration.copyWith( padding: (node) { if (UniversalPlatform.isMobile) { return configuration.padding(node); } return const EdgeInsets.symmetric(vertical: 10); }, ), ); } FileBlockComponentBuilder _buildFileBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return FileBlockComponentBuilder( configuration: configuration, ); } SubPageBlockComponentBuilder _buildSubPageBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, { required EditorStyleCustomizer styleCustomizer, }) { return SubPageBlockComponentBuilder( configuration: configuration.copyWith( textStyle: (node, {TextSpan? textSpan}) => styleCustomizer.subPageBlockTextStyleBuilder(), padding: (node) { if (UniversalPlatform.isMobile) { return const EdgeInsets.symmetric(horizontal: 18); } return configuration.padding(node); }, ), ); } SimpleColumnsBlockComponentBuilder _buildSimpleColumnsBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return SimpleColumnsBlockComponentBuilder( configuration: configuration.copyWith( padding: (node) { if (UniversalPlatform.isMobile) { return configuration.padding(node); } return EdgeInsets.zero; }, ), ); } SimpleColumnBlockComponentBuilder _buildSimpleColumnBlockComponentBuilder( BuildContext context, BlockComponentConfiguration configuration, ) { return SimpleColumnBlockComponentBuilder( configuration: configuration.copyWith( padding: (_) => EdgeInsets.zero, ), ); } TextStyle _buildTextStyleInTableCell( BuildContext context, { required Node node, required BlockComponentConfiguration configuration, required TextSpan? textSpan, }) { TextStyle textStyle = configuration.textStyle(node, textSpan: textSpan); textStyle = textStyle.copyWith( fontFamily: textSpan?.style?.fontFamily, fontSize: textSpan?.style?.fontSize, ); if (node.isInHeaderColumn || node.isInHeaderRow || node.isInBoldColumn || node.isInBoldRow) { textStyle = textStyle.copyWith( fontWeight: FontWeight.bold, ); } final cellTextColor = node.textColorInColumn ?? node.textColorInRow; // enable it if we need to support the text color of the text span // final isTextSpanColorNull = textSpan?.style?.color == null; // final isTextSpanChildrenColorNull = // textSpan?.children?.every((e) => e.style?.color == null) ?? true; if (cellTextColor != null) { textStyle = textStyle.copyWith( color: buildEditorCustomizedColor( context, node, cellTextColor, ), ); } return textStyle; } TextAlign _buildTextAlignInTableCell( BuildContext context, { required Node node, required BlockComponentConfiguration configuration, }) { final isInTable = node.isInTable; if (!isInTable) { return configuration.textAlign(node); } return node.tableAlign.textAlign; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_handler.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; const _excludeFromDropTarget = [ ImageBlockKeys.type, CustomImageBlockKeys.type, MultiImageBlockKeys.type, FileBlockKeys.type, SimpleTableBlockKeys.type, SimpleTableCellBlockKeys.type, SimpleTableRowBlockKeys.type, ]; class EditorDropHandler extends StatelessWidget { const EditorDropHandler({ super.key, required this.viewId, required this.editorState, required this.isLocalMode, required this.child, this.dropManagerState, }); final String viewId; final EditorState editorState; final bool isLocalMode; final Widget child; final EditorDropManagerState? dropManagerState; @override Widget build(BuildContext context) { final childWidget = Consumer( builder: (context, dropState, _) => DragTarget( onLeave: (_) { editorState.selectionService.removeDropTarget(); disableAutoScrollWhenDragging = false; }, onMove: (details) { disableAutoScrollWhenDragging = true; if (details.data.id == viewId) { return; } _onDragUpdated(details.offset); }, onWillAcceptWithDetails: (details) { if (!dropState.isDropEnabled) { return false; } if (details.data.id == viewId) { return false; } return true; }, onAcceptWithDetails: _onDragViewDone, builder: (context, _, __) => ValueListenableBuilder( valueListenable: enableDocumentDragNotifier, builder: (context, value, _) { final enableDocumentDrag = value; return DropTarget( enable: dropState.isDropEnabled && enableDocumentDrag, onDragExited: (_) => editorState.selectionService.removeDropTarget(), onDragUpdated: (details) => _onDragUpdated(details.globalPosition), onDragDone: _onDragDone, child: child, ); }, ), ), ); // Due to how DropTarget works, there is no way to differentiate if an overlay is // blocking the target visibly, so when we have an overlay with a drop target, // we should disable the drop target for the Editor, until it is closed. // // See FileBlockComponent for sample use. // // Relates to: // - https://github.com/MixinNetwork/flutter-plugins/issues/2 // - https://github.com/MixinNetwork/flutter-plugins/issues/331 if (dropManagerState != null) { return ChangeNotifierProvider.value( value: dropManagerState!, child: childWidget, ); } return ChangeNotifierProvider( create: (_) => EditorDropManagerState(), child: childWidget, ); } void _onDragUpdated(Offset position) { final data = editorState.selectionService.getDropTargetRenderData(position); if (dropManagerState?.isDropEnabled == false) { return editorState.selectionService.removeDropTarget(); } if (data != null && data.dropPath != null && // We implement custom Drop logic for image blocks, this is // how we can exclude them from the Drop Target !_excludeFromDropTarget.contains(data.cursorNode?.type)) { // Render the drop target editorState.selectionService.renderDropTargetForOffset(position); } else { editorState.selectionService.removeDropTarget(); } } Future _onDragDone(DropDoneDetails details) async { editorState.selectionService.removeDropTarget(); final data = editorState.selectionService .getDropTargetRenderData(details.globalPosition); if (data != null) { final cursorNode = data.cursorNode; final dropPath = data.dropPath; if (cursorNode != null && dropPath != null) { if (_excludeFromDropTarget.contains(cursorNode.type)) { return; } for (final file in details.files) { final fileName = file.name.toLowerCase(); if (file.mimeType?.startsWith('image/') ?? false || imgExtensionRegex.hasMatch(fileName)) { await editorState.dropImages(dropPath, [file], viewId, isLocalMode); } else { await editorState.dropFiles(dropPath, [file], viewId, isLocalMode); } } } } } void _onDragViewDone(DragTargetDetails details) { editorState.selectionService.removeDropTarget(); final data = editorState.selectionService.getDropTargetRenderData(details.offset); if (data != null) { final cursorNode = data.cursorNode; final dropPath = data.dropPath; if (cursorNode != null && dropPath != null) { if (_excludeFromDropTarget.contains(cursorNode.type)) { return; } final view = details.data; final node = pageMentionNode(view.id); final t = editorState.transaction..insertNode(dropPath, node); editorState.apply(t); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart ================================================ import 'package:flutter/widgets.dart'; class EditorDropManagerState extends ChangeNotifier { final Set _draggedTypes = {}; void add(String type) { _draggedTypes.add(type); notifyListeners(); } void remove(String type) { _draggedTypes.remove(type); notifyListeners(); } bool get isDropEnabled => _draggedTypes.isEmpty; bool contains(String type) => _draggedTypes.contains(type); } final enableDocumentDragNotifier = ValueNotifier(true); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; enum EditorNotificationType { none, undo, redo, exitEditing, paste, dragStart, dragEnd, turnInto, } class EditorNotification { const EditorNotification({required this.type}); EditorNotification.undo() : type = EditorNotificationType.undo; EditorNotification.redo() : type = EditorNotificationType.redo; EditorNotification.exitEditing() : type = EditorNotificationType.exitEditing; EditorNotification.paste() : type = EditorNotificationType.paste; EditorNotification.dragStart() : type = EditorNotificationType.dragStart; EditorNotification.dragEnd() : type = EditorNotificationType.dragEnd; EditorNotification.turnInto() : type = EditorNotificationType.turnInto; static final PropertyValueNotifier _notifier = PropertyValueNotifier(EditorNotificationType.none); final EditorNotificationType type; void post() => _notifier.value = type; static void addListener(ValueChanged listener) { _notifier.addListener(() => listener(_notifier.value)); } static void removeListener(ValueChanged listener) { _notifier.removeListener(() => listener(_notifier.value)); } static void dispose() => _notifier.dispose(); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart ================================================ import 'dart:ui' as ui; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/inline_actions/handlers/child_page.dart'; import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart'; import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; import 'package:appflowy/plugins/inline_actions/handlers/reminder_reference.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import 'editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'editor_plugins/toolbar_item/custom_format_toolbar_items.dart'; import 'editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart'; import 'editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart'; import 'editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart'; import 'editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart'; import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart'; import 'editor_plugins/toolbar_item/text_heading_toolbar_item.dart'; import 'editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; /// Wrapper for the appflowy editor. class AppFlowyEditorPage extends StatefulWidget { const AppFlowyEditorPage({ super.key, required this.editorState, this.header, this.shrinkWrap = false, this.scrollController, this.autoFocus, required this.styleCustomizer, this.showParagraphPlaceholder, this.placeholderText, this.initialSelection, this.useViewInfoBloc = true, }); final Widget? header; final EditorState editorState; final ScrollController? scrollController; final bool shrinkWrap; final bool? autoFocus; final EditorStyleCustomizer styleCustomizer; final ShowPlaceholder? showParagraphPlaceholder; final String Function(Node)? placeholderText; /// Used to provide an initial selection on Page-load final Selection? initialSelection; final bool useViewInfoBloc; @override State createState() => _AppFlowyEditorPageState(); } class _AppFlowyEditorPageState extends State with WidgetsBindingObserver { late final ScrollController effectiveScrollController; late final InlineActionsService inlineActionsService = InlineActionsService( context: context, handlers: [ if (FeatureFlag.inlineSubPageMention.isOn) InlineChildPageService(currentViewId: documentBloc.documentId), InlinePageReferenceService(currentViewId: documentBloc.documentId), DateReferenceService(context), ReminderReferenceService(context), ], ); late final List commandShortcuts = [ ...commandShortcutEvents, ..._buildFindAndReplaceCommands(), ]; final List toolbarItems = [ improveWritingItem, group0PaddingItem, aiWriterItem, customTextHeadingItem, buildPaddingPlaceholderItem( 1, isActive: onlyShowInSingleTextTypeSelectionAndExcludeTable, ), ...customMarkdownFormatItems, group1PaddingItem, customTextColorItem, group1PaddingItem, customHighlightColorItem, customInlineCodeItem, suggestionsItem, customLinkItem, group4PaddingItem, customTextAlignItem, moreOptionItem, ]; List get characterShortcutEvents { return buildCharacterShortcutEvents( context, documentBloc, styleCustomizer, inlineActionsService, (editorState, node) => _customSlashMenuItems( editorState: editorState, node: node, ), ); } EditorStyleCustomizer get styleCustomizer => widget.styleCustomizer; DocumentBloc get documentBloc => context.read(); late final EditorScrollController editorScrollController; late final ViewInfoBloc viewInfoBloc = context.read(); final editorKeyboardInterceptor = EditorKeyboardInterceptor(); Future showSlashMenu(editorState) async => customSlashCommand( _customSlashMenuItems(), shouldInsertSlash: false, style: styleCustomizer.selectionMenuStyleBuilder(), supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, ).handler(editorState); AFFocusManager? focusManager; AppLifecycleState? lifecycleState = WidgetsBinding.instance.lifecycleState; List previousSelections = []; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); if (widget.useViewInfoBloc) { viewInfoBloc.add( ViewInfoEvent.registerEditorState(editorState: widget.editorState), ); } _initEditorL10n(); _initializeShortcuts(); AppFlowyRichTextKeys.partialSliced.addAll([ MentionBlockKeys.mention, InlineMathEquationKeys.formula, ]); indentableBlockTypes.addAll([ ToggleListBlockKeys.type, CalloutBlockKeys.type, QuoteBlockKeys.type, ]); convertibleBlockTypes.addAll([ ToggleListBlockKeys.type, CalloutBlockKeys.type, QuoteBlockKeys.type, ]); editorLaunchUrl = (url) { if (url != null) { afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); } return Future.value(true); }; effectiveScrollController = widget.scrollController ?? ScrollController(); // disable the color parse in the HTML decoder. DocumentHTMLDecoder.enableColorParse = false; editorScrollController = EditorScrollController( editorState: widget.editorState, shrinkWrap: widget.shrinkWrap, scrollController: effectiveScrollController, ); toolbarItemWhiteList.addAll([ ToggleListBlockKeys.type, CalloutBlockKeys.type, TableBlockKeys.type, SimpleTableBlockKeys.type, SimpleTableCellBlockKeys.type, SimpleTableRowBlockKeys.type, ]); AppFlowyRichTextKeys.supportSliced.add(AppFlowyRichTextKeys.fontFamily); // customize the dynamic theme color _customizeBlockComponentBackgroundColorDecorator(); widget.editorState.selectionNotifier.addListener(onSelectionChanged); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } focusManager = AFFocusManager.maybeOf(context); focusManager?.loseFocusNotifier.addListener(_loseFocus); _scrollToSelectionIfNeeded(); widget.editorState.service.keyboardService?.registerInterceptor( editorKeyboardInterceptor, ); }); } void _scrollToSelectionIfNeeded() { final initialSelection = widget.initialSelection; final path = initialSelection?.start.path; if (path == null) { return; } // on desktop, using jumpTo to scroll to the selection. // on mobile, using scrollTo to scroll to the selection, because using jumpTo will break the scroll notification metrics. if (UniversalPlatform.isDesktop) { editorScrollController.itemScrollController.jumpTo( index: path.first, alignment: 0.5, ); widget.editorState.updateSelectionWithReason( initialSelection, ); } else { const delayDuration = Duration(milliseconds: 250); const animationDuration = Duration(milliseconds: 400); Future.delayed(delayDuration, () { editorScrollController.itemScrollController.scrollTo( index: path.first, duration: animationDuration, curve: Curves.easeInOut, ); widget.editorState.updateSelectionWithReason( initialSelection, extraInfo: { selectionExtraInfoDoNotAttachTextService: true, selectionExtraInfoDisableMobileToolbarKey: true, }, ); }).then((_) { Future.delayed(animationDuration, () { widget.editorState.selectionType = SelectionType.inline; widget.editorState.selectionExtraInfo = null; }); }); } } void onSelectionChanged() { if (widget.editorState.isDisposed) { return; } previousSelections.add(widget.editorState.selection); if (previousSelections.length > 2) { previousSelections.removeAt(0); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); lifecycleState = state; if (widget.editorState.isDisposed) { return; } if (previousSelections.length == 2 && state == AppLifecycleState.resumed && widget.editorState.selection == null) { widget.editorState.selection = previousSelections.first; } } @override void didChangeDependencies() { final currFocusManager = AFFocusManager.maybeOf(context); if (focusManager != currFocusManager) { focusManager?.loseFocusNotifier.removeListener(_loseFocus); focusManager = currFocusManager; focusManager?.loseFocusNotifier.addListener(_loseFocus); } super.didChangeDependencies(); } @override void dispose() { widget.editorState.selectionNotifier.removeListener(onSelectionChanged); widget.editorState.service.keyboardService?.unregisterInterceptor( editorKeyboardInterceptor, ); focusManager?.loseFocusNotifier.removeListener(_loseFocus); if (widget.useViewInfoBloc && !viewInfoBloc.isClosed) { viewInfoBloc.add(const ViewInfoEvent.unregisterEditorState()); } SystemChannels.textInput.invokeMethod('TextInput.hide'); if (widget.scrollController == null) { effectiveScrollController.dispose(); } inlineActionsService.dispose(); editorScrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final (bool autoFocus, Selection? selection) = _computeAutoFocusParameters(); final isRTL = context.read().state.layoutDirection == LayoutDirection.rtlLayout; final textDirection = isRTL ? ui.TextDirection.rtl : ui.TextDirection.ltr; _setRTLToolbarItems( context.read().state.enableRtlToolbarItems, ); final isViewDeleted = context.read().state.isDeleted; final isEditable = context.read()?.state.isEditable ?? true; final editor = Directionality( textDirection: textDirection, child: AppFlowyEditor( editorState: widget.editorState, editable: !isViewDeleted && isEditable, disableSelectionService: UniversalPlatform.isMobile && !isEditable, disableKeyboardService: UniversalPlatform.isMobile && !isEditable, editorScrollController: editorScrollController, // setup the auto focus parameters autoFocus: widget.autoFocus ?? autoFocus, focusedSelection: selection, // setup the theme editorStyle: styleCustomizer.style(), // customize the block builders blockComponentBuilders: buildBlockComponentBuilders( slashMenuItemsBuilder: (editorState, node) => _customSlashMenuItems( editorState: editorState, node: node, ), context: context, editorState: widget.editorState, styleCustomizer: widget.styleCustomizer, showParagraphPlaceholder: widget.showParagraphPlaceholder, placeholderText: widget.placeholderText, ), // customize the shortcuts characterShortcutEvents: characterShortcutEvents, commandShortcutEvents: commandShortcuts, // customize the context menu items contextMenuItems: customContextMenuItems, // customize the header and footer. header: widget.header, autoScrollEdgeOffset: UniversalPlatform.isDesktopOrWeb ? 250 : appFlowyEditorAutoScrollEdgeOffset, footer: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () async { // if the last one isn't a empty node, insert a new empty node. await _focusOnLastEmptyParagraph(); }, child: SizedBox( width: double.infinity, height: UniversalPlatform.isDesktopOrWeb ? 600 : 400, ), ), dropTargetStyle: AppFlowyDropTargetStyle( color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.8), margin: const EdgeInsets.only(left: 44), ), ), ); if (isViewDeleted) { return editor; } final editorState = widget.editorState; if (UniversalPlatform.isMobile) { return AppFlowyMobileToolbar( toolbarHeight: 42.0, editorState: editorState, toolbarItemsBuilder: (sel) => buildMobileToolbarItems(editorState, sel), child: MobileFloatingToolbar( editorState: editorState, editorScrollController: editorScrollController, toolbarBuilder: (_, anchor, closeToolbar) => CustomMobileFloatingToolbar( editorState: editorState, anchor: anchor, closeToolbar: closeToolbar, ), floatingToolbarHeight: 32, child: editor, ), ); } final appTheme = AppFlowyTheme.of(context); return Center( child: BlocProvider.value( value: context.read(), child: FloatingToolbar( floatingToolbarHeight: 40, padding: EdgeInsets.symmetric(horizontal: 6), style: FloatingToolbarStyle( backgroundColor: Theme.of(context).cardColor, toolbarActiveColor: Color(0xffe0f8fd), ), items: toolbarItems, decoration: BoxDecoration( borderRadius: BorderRadius.circular(appTheme.borderRadius.l), color: appTheme.surfaceColorScheme.primary, boxShadow: appTheme.shadow.small, ), toolbarBuilder: (_, child, onDismiss, isMetricsChanged) => BlocProvider.value( value: context.read(), child: DesktopFloatingToolbar( editorState: editorState, onDismiss: onDismiss, enableAnimation: !isMetricsChanged, child: child, ), ), placeHolderBuilder: (_) => customPlaceholderItem, editorState: editorState, editorScrollController: editorScrollController, textDirection: textDirection, tooltipBuilder: (context, id, message, child) => widget.styleCustomizer.buildToolbarItemTooltip( context, id, message, child, ), child: editor, ), ), ); } List _customSlashMenuItems({ EditorState? editorState, Node? node, }) { final documentBloc = context.read(); final isLocalMode = documentBloc.isLocalMode; final view = context.read().state.view; return slashMenuItemsBuilder( editorState: editorState, node: node, isLocalMode: isLocalMode, documentBloc: documentBloc, view: view, ); } (bool, Selection?) _computeAutoFocusParameters() { if (widget.editorState.document.isEmpty) { return (true, Selection.collapsed(Position(path: [0]))); } return const (false, null); } Future _initializeShortcuts() async { defaultCommandShortcutEvents; final settingsShortcutService = SettingsShortcutService(); final customizeShortcuts = await settingsShortcutService.getCustomizeShortcuts(); await settingsShortcutService.updateCommandShortcuts( commandShortcuts, customizeShortcuts, ); } void _setRTLToolbarItems(bool enableRtlToolbarItems) { final textDirectionItemIds = textDirectionItems.map((e) => e.id); // clear all the text direction items toolbarItems.removeWhere((item) => textDirectionItemIds.contains(item.id)); // only show the rtl item when the layout direction is ltr. if (enableRtlToolbarItems) { toolbarItems.addAll(textDirectionItems); } } List _buildFindAndReplaceCommands() { return findAndReplaceCommands( context: context, style: FindReplaceStyle( findMenuBuilder: ( context, editorState, localizations, style, showReplaceMenu, onDismiss, ) => Material( child: DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: FindAndReplaceMenuWidget( showReplaceMenu: showReplaceMenu, editorState: editorState, onDismiss: onDismiss, ), ), ), ), ); } void _customizeBlockComponentBackgroundColorDecorator() { blockComponentBackgroundColorDecorator = (Node node, String colorString) { if (mounted && context.mounted) { return buildEditorCustomizedColor(context, node, colorString); } return null; }; } void _initEditorL10n() => AppFlowyEditorL10n.current = EditorI18n(); Future _focusOnLastEmptyParagraph() async { final editorState = widget.editorState; final root = editorState.document.root; final lastNode = root.children.lastOrNull; final transaction = editorState.transaction; if (lastNode == null || lastNode.delta?.isEmpty == false || lastNode.type != ParagraphBlockKeys.type) { transaction.insertNode([root.children.length], paragraphNode()); transaction.afterSelection = Selection.collapsed( Position(path: [root.children.length]), ); } else { transaction.afterSelection = Selection.collapsed( Position(path: lastNode.path), ); } transaction.customSelectionType = SelectionType.inline; transaction.reason = SelectionUpdateReason.uiEvent; await editorState.apply(transaction); } void _loseFocus() { if (!widget.editorState.isDisposed) { widget.editorState.selection = null; } } } Color? buildEditorCustomizedColor( BuildContext context, Node node, String colorString, ) { if (!context.mounted) { return null; } // the color string is from FlowyTint. final tintColor = FlowyTint.values.firstWhereOrNull( (e) => e.id == colorString, ); if (tintColor != null) { return tintColor.color(context); } final themeColor = themeBackgroundColors[colorString]; if (themeColor != null) { return themeColor.color(context); } if (colorString == optionActionColorDefaultColor) { final defaultColor = node.type == CalloutBlockKeys.type ? AFThemeExtension.of(context).calloutBGColor : Colors.transparent; return defaultColor; } if (colorString == tableCellDefaultColor) { return AFThemeExtension.of(context).tableCellBGColor; } try { return colorString.tryToColor(); } catch (e) { return null; } } bool showInAnyTextType(EditorState editorState) { final selection = editorState.selection; if (selection == null) { return false; } final nodes = editorState.getNodesInSelection(selection); return nodes.any((node) => toolbarItemWhiteList.contains(node.type)); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class BlockAddButton extends StatelessWidget { const BlockAddButton({ super.key, required this.blockComponentContext, required this.blockComponentState, required this.editorState, required this.showSlashMenu, }); final BlockComponentContext blockComponentContext; final BlockComponentActionState blockComponentState; final EditorState editorState; final VoidCallback showSlashMenu; @override Widget build(BuildContext context) { return BlockActionButton( svg: FlowySvgs.add_s, richMessage: TextSpan( children: [ TextSpan( text: LocaleKeys.blockActions_addBelowTooltip.tr(), style: context.tooltipTextStyle(), ), const TextSpan(text: '\n'), TextSpan( text: Platform.isMacOS ? LocaleKeys.blockActions_addAboveMacCmd.tr() : LocaleKeys.blockActions_addAboveCmd.tr(), style: context.tooltipTextStyle(), ), const TextSpan(text: ' '), TextSpan( text: LocaleKeys.blockActions_addAboveTooltip.tr(), style: context.tooltipTextStyle(), ), ], ), onTap: () { final isAltPressed = HardwareKeyboard.instance.isAltPressed; final transaction = editorState.transaction; // If the current block is not an empty paragraph block, // then insert a new block above/below the current block. final node = blockComponentContext.node; if (node.type != ParagraphBlockKeys.type || (node.delta?.isNotEmpty ?? true)) { final path = isAltPressed ? node.path : node.path.next; transaction.insertNode(path, paragraphNode()); transaction.afterSelection = Selection.collapsed( Position(path: path), ); } else { transaction.afterSelection = Selection.collapsed( Position(path: node.path), ); } // show the slash menu. editorState.apply(transaction).then( (_) => WidgetsBinding.instance.addPostFrameCallback( (_) => showSlashMenu(), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class BlockActionButton extends StatelessWidget { const BlockActionButton({ super.key, required this.svg, required this.richMessage, required this.onTap, this.showTooltip = true, this.onPointerDown, }); final FlowySvgData svg; final bool showTooltip; final InlineSpan richMessage; final VoidCallback onTap; final VoidCallback? onPointerDown; @override Widget build(BuildContext context) { return FlowyTooltip( richMessage: showTooltip ? richMessage : null, child: FlowyIconButton( width: 18.0, hoverColor: Colors.transparent, iconColorOnHover: Theme.of(context).iconTheme.color, onPressed: onTap, icon: MouseRegion( cursor: Platform.isWindows ? SystemMouseCursors.click : SystemMouseCursors.grab, child: FlowySvg( svg, size: const Size.square(18.0), color: Theme.of(context).iconTheme.color, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class BlockActionList extends StatelessWidget { const BlockActionList({ super.key, required this.blockComponentContext, required this.blockComponentState, required this.editorState, required this.actions, required this.showSlashMenu, required this.blockComponentBuilder, }); final BlockComponentContext blockComponentContext; final BlockComponentActionState blockComponentState; final List actions; final VoidCallback showSlashMenu; final EditorState editorState; final Map blockComponentBuilder; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ BlockAddButton( blockComponentContext: blockComponentContext, blockComponentState: blockComponentState, editorState: editorState, showSlashMenu: showSlashMenu, ), const HSpace(2.0), BlockOptionButton( blockComponentContext: blockComponentContext, blockComponentState: blockComponentState, actions: actions, editorState: editorState, blockComponentBuilder: blockComponentBuilder, ), const HSpace(5.0), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'drag_to_reorder/draggable_option_button.dart'; class BlockOptionButton extends StatefulWidget { const BlockOptionButton({ super.key, required this.blockComponentContext, required this.blockComponentState, required this.actions, required this.editorState, required this.blockComponentBuilder, }); final BlockComponentContext blockComponentContext; final BlockComponentActionState blockComponentState; final List actions; final EditorState editorState; final Map blockComponentBuilder; @override State createState() => _BlockOptionButtonState(); } class _BlockOptionButtonState extends State { // the mutex is used to ensure that only one popover is open at a time // for example, when the user is selecting the color, the turn into option // should not be shown. final mutex = PopoverMutex(); @override Widget build(BuildContext context) { final direction = context.read().state.layoutDirection == LayoutDirection.rtlLayout ? PopoverDirection.rightWithCenterAligned : PopoverDirection.leftWithCenterAligned; return BlocProvider( create: (context) => BlockActionOptionCubit( editorState: widget.editorState, blockComponentBuilder: widget.blockComponentBuilder, ), child: BlocBuilder( builder: (context, _) => PopoverActionList( actions: _buildPopoverActions(context), animationDuration: Durations.short3, slideDistance: 5, beginScaleFactor: 1.0, beginOpacity: 0.8, direction: direction, onPopupBuilder: _onPopoverBuilder, onClosed: () => _onPopoverClosed(context), onSelected: (action, controller) => _onActionSelected( context, action, controller, ), buildChild: (controller) => DraggableOptionButton( controller: controller, editorState: widget.editorState, blockComponentContext: widget.blockComponentContext, blockComponentBuilder: widget.blockComponentBuilder, ), ), ), ); } @override void dispose() { mutex.dispose(); super.dispose(); } List _buildPopoverActions(BuildContext context) { return widget.actions.map((e) { switch (e) { case OptionAction.divider: return DividerOptionAction(); case OptionAction.color: return ColorOptionAction( editorState: widget.editorState, mutex: mutex, ); case OptionAction.align: return AlignOptionAction(editorState: widget.editorState); case OptionAction.depth: return DepthOptionAction(editorState: widget.editorState); case OptionAction.turnInto: return TurnIntoOptionAction( editorState: widget.editorState, blockComponentBuilder: widget.blockComponentBuilder, mutex: mutex, ); default: return OptionActionWrapper(e); } }).toList(); } void _onPopoverBuilder() { keepEditorFocusNotifier.increase(); widget.blockComponentState.alwaysShowActions = true; } void _onPopoverClosed(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { widget.editorState.selectionType = null; widget.editorState.selection = null; widget.blockComponentState.alwaysShowActions = false; }); PopoverContainer.maybeOf(context)?.closeAll(); } void _onActionSelected( BuildContext context, PopoverAction action, PopoverController controller, ) { if (action is! OptionActionWrapper) { return; } context.read().handleAction( action.inner, widget.blockComponentContext.node, ); controller.close(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart ================================================ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys, quoteNode; import 'package:flutter_bloc/flutter_bloc.dart'; class BlockActionOptionState {} class BlockActionOptionCubit extends Cubit { BlockActionOptionCubit({ required this.editorState, required this.blockComponentBuilder, }) : super(BlockActionOptionState()); final EditorState editorState; final Map blockComponentBuilder; Future handleAction(OptionAction action, Node node) async { final transaction = editorState.transaction; switch (action) { case OptionAction.delete: _deleteBlocks(transaction, node); break; case OptionAction.duplicate: await _duplicateBlock(transaction, node); EditorNotification.paste().post(); break; case OptionAction.moveUp: transaction.moveNode(node.path.previous, node); break; case OptionAction.moveDown: transaction.moveNode(node.path.next.next, node); break; case OptionAction.copyLinkToBlock: await _copyLinkToBlock(node); break; case OptionAction.setToPageWidth: await _setToPageWidth(node); break; case OptionAction.distributeColumnsEvenly: await _distributeColumnsEvenly(node); break; case OptionAction.align: case OptionAction.color: case OptionAction.divider: case OptionAction.depth: case OptionAction.turnInto: throw UnimplementedError(); } await editorState.apply(transaction); } /// If the selection is a block selection, delete the selected blocks. /// Otherwise, delete the selected block. void _deleteBlocks(Transaction transaction, Node selectedNode) { final selection = editorState.selection; final selectionType = editorState.selectionType; if (selectionType == SelectionType.block && selection != null) { final nodes = editorState.getNodesInSelection(selection.normalized); transaction.deleteNodes(nodes); } else { transaction.deleteNode(selectedNode); } } Future _duplicateBlock(Transaction transaction, Node node) async { final selection = editorState.selection; final selectionType = editorState.selectionType; if (selectionType == SelectionType.block && selection != null) { final nodes = editorState.getNodesInSelection(selection.normalized); for (final node in nodes) { _validateNode(node); } transaction.insertNodes( selection.normalized.end.path.next, nodes.map((e) => _copyBlock(e)).toList(), ); } else { _validateNode(node); transaction.insertNode(node.path.next, _copyBlock(node)); } } void _validateNode(Node node) { final type = node.type; final builder = blockComponentBuilder[type]; if (builder == null) { Log.error('Block type $type is not supported'); return; } final valid = builder.validate(node); if (!valid) { Log.error('Block type $type is not valid'); } } Node _copyBlock(Node node) { Node copiedNode = node.deepCopy(); final type = node.type; final builder = blockComponentBuilder[type]; if (builder == null) { Log.error('Block type $type is not supported'); } else { final valid = builder.validate(node); if (!valid) { Log.error('Block type $type is not valid'); if (node.type == TableBlockKeys.type) { copiedNode = _fixTableBlock(node); copiedNode = _convertTableToSimpleTable(copiedNode); } } else { if (node.type == TableBlockKeys.type) { copiedNode = _convertTableToSimpleTable(node); } } } return copiedNode; } Node _fixTableBlock(Node node) { if (node.type != TableBlockKeys.type) { return node; } // the table node should contains colsLen and rowsLen final colsLen = node.attributes[TableBlockKeys.colsLen]; final rowsLen = node.attributes[TableBlockKeys.rowsLen]; if (colsLen == null || rowsLen == null) { return node; } final newChildren = []; final children = node.children; // based on the colsLen and rowsLen, iterate the children and fix the data for (var i = 0; i < rowsLen; i++) { for (var j = 0; j < colsLen; j++) { final cell = children .where( (n) => n.attributes[TableCellBlockKeys.rowPosition] == i && n.attributes[TableCellBlockKeys.colPosition] == j, ) .firstOrNull; if (cell != null) { newChildren.add(cell.deepCopy()); } else { newChildren.add( tableCellNode('', i, j), ); } } } return node.copyWith( children: newChildren, attributes: { ...node.attributes, TableBlockKeys.colsLen: colsLen, TableBlockKeys.rowsLen: rowsLen, }, ); } Node _convertTableToSimpleTable(Node node) { if (node.type != TableBlockKeys.type) { return node; } // the table node should contains colsLen and rowsLen final colsLen = node.attributes[TableBlockKeys.colsLen]; final rowsLen = node.attributes[TableBlockKeys.rowsLen]; if (colsLen == null || rowsLen == null) { return node; } final rows = >[]; final children = node.children; for (var i = 0; i < rowsLen; i++) { final row = []; for (var j = 0; j < colsLen; j++) { final cell = children .where( (n) => n.attributes[TableCellBlockKeys.rowPosition] == i && n.attributes[TableCellBlockKeys.colPosition] == j, ) .firstOrNull; row.add( simpleTableCellBlockNode( children: [cell?.children.first.deepCopy() ?? paragraphNode()], ), ); } rows.add(row); } return simpleTableBlockNode( children: rows.map((e) => simpleTableRowBlockNode(children: e)).toList(), ); } Future _copyLinkToBlock(Node node) async { List nodes = [node]; final selection = editorState.selection; final selectionType = editorState.selectionType; if (selectionType == SelectionType.block && selection != null) { nodes = editorState.getNodesInSelection(selection.normalized); } final context = editorState.document.root.context; final viewId = context?.read().documentId; if (viewId == null) { return; } final workspace = await FolderEventReadCurrentWorkspace().send(); final workspaceId = workspace.fold( (l) => l.id, (r) => '', ); if (workspaceId.isEmpty || viewId.isEmpty) { Log.error('Failed to get workspace id: $workspaceId or view id: $viewId'); emit(BlockActionOptionState()); // Emit a new state to trigger UI update return; } final blockIds = nodes.map((e) => e.id); final links = blockIds.map( (e) => ShareConstants.buildShareUrl( workspaceId: workspaceId, viewId: viewId, blockId: e, ), ); await getIt().setData( ClipboardServiceData(plainText: links.join('\n')), ); emit(BlockActionOptionState()); // Emit a new state to trigger UI update } static Future turnIntoBlock( String type, Node node, EditorState editorState, { int? level, String? currentViewId, bool keepSelection = false, }) async { final selection = editorState.selection; if (selection == null) { return false; } // Notify the transaction service that the next apply is from turn into action EditorNotification.turnInto().post(); final toType = type; // only handle the node in the same depth final selectedNodes = editorState .getNodesInSelection(selection.normalized) .where((e) => e.path.length == node.path.length) .toList(); Log.info('turnIntoBlock selectedNodes $selectedNodes'); // try to turn into a single toggle heading block if (await turnIntoSingleToggleHeading( type: toType, selectedNodes: selectedNodes, level: level, editorState: editorState, afterSelection: keepSelection ? selection : null, )) { return true; } // try to turn into a page block if (currentViewId != null && await turnIntoPage( type: toType, selectedNodes: selectedNodes, selection: selection, currentViewId: currentViewId, editorState: editorState, )) { return true; } final insertedNode = []; for (final node in selectedNodes) { Log.info('Turn into block: from ${node.type} to $type'); Node afterNode = node.copyWith( type: type, attributes: { if (toType == HeadingBlockKeys.type) HeadingBlockKeys.level: level, if (toType == ToggleListBlockKeys.type) ToggleListBlockKeys.level: level, if (toType == TodoListBlockKeys.type) TodoListBlockKeys.checked: false, blockComponentBackgroundColor: node.attributes[blockComponentBackgroundColor], blockComponentTextDirection: node.attributes[blockComponentTextDirection], blockComponentDelta: (node.delta ?? Delta()).toJson(), }, ); // heading block should not have children if ([HeadingBlockKeys.type].contains(toType)) { afterNode = afterNode.copyWith(children: []); afterNode = await _handleSubPageNode(afterNode, node); insertedNode.add(afterNode); insertedNode.addAll(node.children.map((e) => e.deepCopy())); } else if (!EditorOptionActionType.turnInto.supportTypes .contains(node.type)) { afterNode = node.deepCopy(); insertedNode.add(afterNode); } else { afterNode = await _handleSubPageNode(afterNode, node); insertedNode.add(afterNode); } } final transaction = editorState.transaction; transaction.insertNodes( node.path, insertedNode, ); transaction.deleteNodes(selectedNodes); if (keepSelection) transaction.afterSelection = selection; await editorState.apply(transaction); return true; } /// Takes the new [Node] and the Node which is a SubPageBlock. /// /// Returns the altered [Node] with the delta as the Views' name. /// static Future _handleSubPageNode(Node node, Node subPageNode) async { if (subPageNode.type != SubPageBlockKeys.type) { return node; } final delta = await _deltaFromSubPageNode(subPageNode); return node.copyWith( attributes: { ...node.attributes, blockComponentDelta: (delta ?? Delta()).toJson(), }, ); } /// Returns the [Delta] from a SubPage [Node], where the /// [Delta] is the views' name. /// static Future _deltaFromSubPageNode(Node node) async { if (node.type != SubPageBlockKeys.type) { return null; } final viewId = node.attributes[SubPageBlockKeys.viewId]; final viewOrFailure = await ViewBackendService.getView(viewId); final view = viewOrFailure.toNullable(); if (view != null) { return Delta(operations: [TextInsert(view.name)]); } Log.error("Failed to get view by id($viewId)"); return null; } // turn a single node into toggle heading block // 1. find the sibling nodes after the selected node until // meet the first node that contains level and its value is greater or equal to the level // 2. move the found nodes in the selected node // // example: // Toggle Heading 1 <- selected node // - bulleted item 1 // - bulleted item 2 // - bulleted item 3 // Heading 1 // - paragraph 1 // - paragraph 2 // when turning "Toggle Heading 1" into toggle heading, the bulleted items will be moved into the toggle heading static Future turnIntoSingleToggleHeading({ required String type, required List selectedNodes, required EditorState editorState, int? level, Delta? delta, Selection? afterSelection, }) async { // only support turn a single node into toggle heading block if (type != ToggleListBlockKeys.type || selectedNodes.length != 1 || level == null) { return false; } // find the sibling nodes after the selected node until final insertedNodes = []; final node = selectedNodes.first; Path path = node.path.next; Node? nextNode = editorState.getNodeAtPath(path); while (nextNode != null) { if (nextNode.type == HeadingBlockKeys.type && nextNode.attributes[HeadingBlockKeys.level] != null && nextNode.attributes[HeadingBlockKeys.level]! <= level) { break; } if (nextNode.type == ToggleListBlockKeys.type && nextNode.attributes[ToggleListBlockKeys.level] != null && nextNode.attributes[ToggleListBlockKeys.level]! <= level) { break; } insertedNodes.add(nextNode); path = path.next; nextNode = editorState.getNodeAtPath(path); } Log.info('insertedNodes $insertedNodes'); Log.info( 'Turn into block: from ${node.type} to $type', ); Delta newDelta = delta ?? (node.delta ?? Delta()); if (delta == null && node.type == SubPageBlockKeys.type) { newDelta = await _deltaFromSubPageNode(node) ?? Delta(); } final afterNode = node.copyWith( type: type, attributes: { ToggleListBlockKeys.level: level, ToggleListBlockKeys.collapsed: node.attributes[ToggleListBlockKeys.collapsed] ?? false, blockComponentBackgroundColor: node.attributes[blockComponentBackgroundColor], blockComponentTextDirection: node.attributes[blockComponentTextDirection], blockComponentDelta: newDelta.toJson(), }, children: [ ...node.children.map((e) => e.deepCopy()), ...insertedNodes.map((e) => e.deepCopy()), ], ); final transaction = editorState.transaction; transaction.insertNode( node.path, afterNode, ); transaction.deleteNodes([ node, ...insertedNodes, ]); if (afterSelection != null) { transaction.afterSelection = afterSelection; } else if (insertedNodes.isNotEmpty) { // select the blocks transaction.afterSelection = Selection( start: Position(path: node.path.child(0)), end: Position(path: node.path.child(insertedNodes.length - 1)), ); } else { transaction.afterSelection = transaction.beforeSelection; } await editorState.apply(transaction); return true; } static Future turnIntoPage({ required String type, required List selectedNodes, required Selection selection, required String currentViewId, required EditorState editorState, }) async { if (type != SubPageBlockKeys.type || selectedNodes.isEmpty) { return false; } if (selectedNodes.length == 1 && selectedNodes.first.type == SubPageBlockKeys.type) { return true; } Log.info('Turn into page'); final insertedNodes = selectedNodes.map((n) => n.deepCopy()).toList(); final document = Document.blank()..insert([0], insertedNodes); final name = await _extractNameFromNodes(selectedNodes); final viewResult = await ViewBackendService.createView( layoutType: ViewLayoutPB.Document, name: name, parentViewId: currentViewId, initialDataBytes: DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(), ); await viewResult.fold( (view) async { final node = subPageNode(viewId: view.id); final transaction = editorState.transaction; transaction ..insertNode(selection.normalized.start.path.next, node) ..deleteNodes(selectedNodes) ..afterSelection = Selection.collapsed(selection.normalized.start); editorState.selectionType = SelectionType.inline; await editorState.apply(transaction); // We move views after applying transaction to avoid performing side-effects on the views final viewIdsToMove = _extractChildViewIds(selectedNodes); for (final viewId in viewIdsToMove) { // Attempt to put back from trash if necessary await TrashService.putback(viewId); await ViewBackendService.moveViewV2( viewId: viewId, newParentId: view.id, prevViewId: null, ); } }, (err) async => Log.error(err), ); return true; } static Future _extractNameFromNodes(List? nodes) async { if (nodes == null || nodes.isEmpty) { return ''; } String name = ''; for (final node in nodes) { if (name.length > 30) { return name.substring(0, name.length > 30 ? 30 : name.length); } if (node.delta != null) { // "ABC [Hello world]" -> ABC Hello world final textInserts = node.delta!.whereType(); for (final ti in textInserts) { if (ti.attributes?[MentionBlockKeys.mention] != null) { // fetch the view name final pageId = ti.attributes![MentionBlockKeys.mention] [MentionBlockKeys.pageId]; final viewOrFailure = await ViewBackendService.getView(pageId); final view = viewOrFailure.toNullable(); if (view == null) { Log.error('Failed to fetch view with id: $pageId'); continue; } name += view.name; } else { name += ti.data!.toString(); } } if (name.isNotEmpty) { break; } } if (node.children.isNotEmpty) { final n = await _extractNameFromNodes(node.children); if (n.isNotEmpty) { name = n; break; } } } return name.substring(0, name.length > 30 ? 30 : name.length); } static List _extractChildViewIds(List nodes) { final List viewIds = []; for (final node in nodes) { if (node.type == SubPageBlockKeys.type) { final viewId = node.attributes[SubPageBlockKeys.viewId]; viewIds.add(viewId); } if (node.children.isNotEmpty) { viewIds.addAll(_extractChildViewIds(node.children)); } if (node.delta == null || node.delta!.isEmpty) { continue; } final textInserts = node.delta!.whereType(); for (final ti in textInserts) { final Map? mention = ti.attributes?[MentionBlockKeys.mention]; if (mention != null && mention[MentionBlockKeys.type] == MentionType.childPage.name) { final String? viewId = mention[MentionBlockKeys.pageId]; if (viewId != null) { viewIds.add(viewId); } } } } return viewIds; } Selection? calculateTurnIntoSelection( Node selectedNode, Selection? beforeSelection, ) { final path = selectedNode.path; final selection = Selection.collapsed(Position(path: path)); // if the previous selection is null or the start path is not in the same level as the current block path, // then update the selection with the current block path // for example,'|' means the selection, // case 1: collapsed selection // - bulleted item 1 // - bulleted |item 2 // when clicking the bulleted item 1, the bulleted item 1 path should be selected // case 2: not collapsed selection // - bulleted item 1 // - bulleted |item 2 // - bulleted |item 3 // when clicking the bulleted item 1, the bulleted item 1 path should be selected if (beforeSelection == null || beforeSelection.start.path.length != path.length || !path.inSelection(beforeSelection)) { return selection; } // if the beforeSelection start with the current block, // then updating the selection with the beforeSelection that may contains multiple blocks return beforeSelection; } Future _setToPageWidth(Node node) async { if (node.type != SimpleTableBlockKeys.type) { return; } await editorState.setColumnWidthToPageWidth(tableNode: node); } Future _distributeColumnsEvenly(Node node) async { if (node.type != SimpleTableBlockKeys.type) { return; } await editorState.distributeColumnWidthToPageWidth(tableNode: node); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'draggable_option_button_feedback.dart'; import 'option_button.dart'; // this flag is used to disable the tooltip of the block when it is dragged ValueNotifier isDraggingAppFlowyEditorBlock = ValueNotifier(false); class DraggableOptionButton extends StatefulWidget { const DraggableOptionButton({ super.key, required this.controller, required this.editorState, required this.blockComponentContext, required this.blockComponentBuilder, }); final PopoverController controller; final EditorState editorState; final BlockComponentContext blockComponentContext; final Map blockComponentBuilder; @override State createState() => _DraggableOptionButtonState(); } class _DraggableOptionButtonState extends State { late Node node; late BlockComponentContext blockComponentContext; Offset? globalPosition; @override void initState() { super.initState(); // copy the node to avoid the node in document being updated node = widget.blockComponentContext.node.deepCopy(); } @override void dispose() { node.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Draggable( data: node, onDragStarted: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, feedback: DraggleOptionButtonFeedback( controller: widget.controller, editorState: widget.editorState, blockComponentContext: widget.blockComponentContext, blockComponentBuilder: widget.blockComponentBuilder, ), child: OptionButton( isDragging: isDraggingAppFlowyEditorBlock, controller: widget.controller, editorState: widget.editorState, blockComponentContext: widget.blockComponentContext, ), ); } void _onDragStart() { EditorNotification.dragStart().post(); isDraggingAppFlowyEditorBlock.value = true; widget.editorState.selectionService.removeDropTarget(); } void _onDragUpdate(DragUpdateDetails details) { isDraggingAppFlowyEditorBlock.value = true; final offset = details.globalPosition; widget.editorState.selectionService.renderDropTargetForOffset( offset, interceptor: (context, targetNode) { // if the cursor node is in a columns block or a column block, // we will return the node's parent instead to support dragging a node to the inside of a columns block or a column block. final parentColumnNode = targetNode.columnParent; if (parentColumnNode != null) { final position = getDragAreaPosition( context, targetNode, offset, ); if (position != null && position.$2 == HorizontalPosition.right) { return parentColumnNode; } if (position != null && position.$2 == HorizontalPosition.left && position.$1 == VerticalPosition.middle) { return parentColumnNode; } } // return simple table block if the target node is in a simple table block final parentSimpleTableNode = targetNode.parentTableNode; if (parentSimpleTableNode != null) { return parentSimpleTableNode; } return targetNode; }, builder: (context, data) { return VisualDragArea( editorState: widget.editorState, data: data, dragNode: widget.blockComponentContext.node, ); }, ); globalPosition = details.globalPosition; // auto scroll the page when the drag position is at the edge of the screen widget.editorState.scrollService?.startAutoScroll( details.localPosition, ); } void _onDragEnd(DraggableDetails details) { isDraggingAppFlowyEditorBlock.value = false; widget.editorState.selectionService.removeDropTarget(); if (globalPosition == null) { return; } final data = widget.editorState.selectionService.getDropTargetRenderData( globalPosition!, interceptor: (context, targetNode) { // if the cursor node is in a columns block or a column block, // we will return the node's parent instead to support dragging a node to the inside of a columns block or a column block. final parentColumnNode = targetNode.columnParent; if (parentColumnNode != null) { final position = getDragAreaPosition( context, targetNode, globalPosition!, ); if (position != null && position.$2 == HorizontalPosition.right) { return parentColumnNode; } if (position != null && position.$2 == HorizontalPosition.left && position.$1 == VerticalPosition.middle) { return parentColumnNode; } } // return simple table block if the target node is in a simple table block final parentSimpleTableNode = targetNode.parentTableNode; if (parentSimpleTableNode != null) { return parentSimpleTableNode; } return targetNode; }, ); dragToMoveNode( context, node: widget.blockComponentContext.node, acceptedPath: data?.cursorNode?.path, dragOffset: globalPosition!, ).then((_) { EditorNotification.dragEnd().post(); }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button_feedback.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class DraggleOptionButtonFeedback extends StatefulWidget { const DraggleOptionButtonFeedback({ super.key, required this.controller, required this.editorState, required this.blockComponentContext, required this.blockComponentBuilder, }); final PopoverController controller; final EditorState editorState; final BlockComponentContext blockComponentContext; final Map blockComponentBuilder; @override State createState() => _DraggleOptionButtonFeedbackState(); } class _DraggleOptionButtonFeedbackState extends State { late Node node; late BlockComponentContext blockComponentContext; @override void initState() { super.initState(); _setupLockComponentContext(); widget.blockComponentContext.node.addListener(_updateBlockComponentContext); } @override void dispose() { widget.blockComponentContext.node .removeListener(_updateBlockComponentContext); super.dispose(); } @override Widget build(BuildContext context) { final maxWidth = (widget.editorState.renderBox?.size.width ?? MediaQuery.of(context).size.width) * 0.8; return Opacity( opacity: 0.7, child: Material( color: Colors.transparent, child: Container( constraints: BoxConstraints( maxWidth: maxWidth, ), child: IntrinsicHeight( child: Provider.value( value: widget.editorState, child: _buildBlock(), ), ), ), ), ); } Widget _buildBlock() { final node = widget.blockComponentContext.node; final builder = widget.blockComponentBuilder[node.type]; if (builder == null) { return const SizedBox.shrink(); } const unsupportedRenderBlockTypes = [ TableBlockKeys.type, CustomImageBlockKeys.type, MultiImageBlockKeys.type, FileBlockKeys.type, DatabaseBlockKeys.boardType, DatabaseBlockKeys.calendarType, DatabaseBlockKeys.gridType, ]; if (unsupportedRenderBlockTypes.contains(node.type)) { // unable to render table block without provider/context // render a placeholder instead return Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(8), ), child: FlowyText(node.type.replaceAll('_', ' ').capitalize()), ); } return IntrinsicHeight( child: MultiProvider( providers: [ Provider.value(value: widget.editorState), Provider.value(value: getIt()), ], child: builder.build(blockComponentContext), ), ); } void _updateBlockComponentContext() { setState(() => _setupLockComponentContext()); } void _setupLockComponentContext() { node = widget.blockComponentContext.node.deepCopy(); blockComponentContext = BlockComponentContext( widget.blockComponentContext.buildContext, node, ); } } class _OptionButton extends StatefulWidget { const _OptionButton({ required this.controller, required this.editorState, required this.blockComponentContext, required this.isDragging, }); final PopoverController controller; final EditorState editorState; final BlockComponentContext blockComponentContext; final ValueNotifier isDragging; @override State<_OptionButton> createState() => _OptionButtonState(); } const _interceptorKey = 'document_option_button_interceptor'; class _OptionButtonState extends State<_OptionButton> { late final gestureInterceptor = SelectionGestureInterceptor( key: _interceptorKey, canTap: (details) => !_isTapInBounds(details.globalPosition), ); // the selection will be cleared when tap the option button // so we need to restore the selection after tap the option button Selection? beforeSelection; RenderBox? get renderBox => context.findRenderObject() as RenderBox?; @override void initState() { super.initState(); widget.editorState.service.selectionService.registerGestureInterceptor( gestureInterceptor, ); } @override void dispose() { widget.editorState.service.selectionService.unregisterGestureInterceptor( _interceptorKey, ); super.dispose(); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: widget.isDragging, builder: (context, isDragging, child) { return BlockActionButton( svg: FlowySvgs.drag_element_s, showTooltip: !isDragging, richMessage: TextSpan( children: [ TextSpan( text: LocaleKeys.document_plugins_optionAction_drag.tr(), style: context.tooltipTextStyle(), ), TextSpan( text: LocaleKeys.document_plugins_optionAction_toMove.tr(), style: context.tooltipTextStyle(), ), const TextSpan(text: '\n'), TextSpan( text: LocaleKeys.document_plugins_optionAction_click.tr(), style: context.tooltipTextStyle(), ), TextSpan( text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), style: context.tooltipTextStyle(), ), ], ), onPointerDown: () { if (widget.editorState.selection != null) { beforeSelection = widget.editorState.selection; } }, onTap: () { if (widget.editorState.selection != null) { beforeSelection = widget.editorState.selection; } widget.controller.show(); // update selection _updateBlockSelection(); }, ); }, ); } void _updateBlockSelection() { if (beforeSelection == null) { final path = widget.blockComponentContext.node.path; final selection = Selection.collapsed( Position(path: path), ); widget.editorState.updateSelectionWithReason( selection, customSelectionType: SelectionType.block, ); } else { widget.editorState.updateSelectionWithReason( beforeSelection!, customSelectionType: SelectionType.block, ); } } bool _isTapInBounds(Offset offset) { if (renderBox == null) { return false; } final localPosition = renderBox!.globalToLocal(offset); final result = renderBox!.paintBounds.contains(localPosition); if (result) { beforeSelection = widget.editorState.selection; } else { beforeSelection = null; } return result; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/option_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; const _interceptorKey = 'document_option_button_interceptor'; class OptionButton extends StatefulWidget { const OptionButton({ super.key, required this.controller, required this.editorState, required this.blockComponentContext, required this.isDragging, }); final PopoverController controller; final EditorState editorState; final BlockComponentContext blockComponentContext; final ValueNotifier isDragging; @override State createState() => _OptionButtonState(); } class _OptionButtonState extends State { late final registerKey = _interceptorKey + widget.blockComponentContext.node.id; late final gestureInterceptor = SelectionGestureInterceptor( key: registerKey, canTap: (details) => !_isTapInBounds(details.globalPosition), ); // the selection will be cleared when tap the option button // so we need to restore the selection after tap the option button Selection? beforeSelection; RenderBox? get renderBox => context.findRenderObject() as RenderBox?; @override void initState() { super.initState(); widget.editorState.service.selectionService.registerGestureInterceptor( gestureInterceptor, ); } @override void dispose() { widget.editorState.service.selectionService.unregisterGestureInterceptor( registerKey, ); super.dispose(); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: widget.isDragging, builder: (context, isDragging, child) { return BlockActionButton( svg: FlowySvgs.drag_element_s, showTooltip: !isDragging, richMessage: TextSpan( children: [ TextSpan( text: LocaleKeys.document_plugins_optionAction_drag.tr(), style: context.tooltipTextStyle(), ), TextSpan( text: LocaleKeys.document_plugins_optionAction_toMove.tr(), style: context.tooltipTextStyle(), ), const TextSpan(text: '\n'), TextSpan( text: LocaleKeys.document_plugins_optionAction_click.tr(), style: context.tooltipTextStyle(), ), TextSpan( text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), style: context.tooltipTextStyle(), ), ], ), onTap: () { final selection = widget.editorState.selection; if (selection != null) { beforeSelection = selection.normalized; } widget.controller.show(); // update selection _updateBlockSelection(context); }, ); }, ); } void _updateBlockSelection(BuildContext context) { final cubit = context.read(); final selection = cubit.calculateTurnIntoSelection( widget.blockComponentContext.node, beforeSelection, ); widget.editorState.updateSelectionWithReason( selection, customSelectionType: SelectionType.block, ); } bool _isTapInBounds(Offset offset) { final renderBox = this.renderBox; if (renderBox == null) { return false; } final localPosition = renderBox.globalToLocal(offset); final result = renderBox.paintBounds.contains(localPosition); if (result) { beforeSelection = widget.editorState.selection?.normalized; } else { beforeSelection = null; } return result; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys, quoteNode; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; enum HorizontalPosition { left, center, right } enum VerticalPosition { top, middle, bottom } List nodeTypesThatCanContainChildNode = [ ParagraphBlockKeys.type, BulletedListBlockKeys.type, NumberedListBlockKeys.type, QuoteBlockKeys.type, TodoListBlockKeys.type, ToggleListBlockKeys.type, ]; Future dragToMoveNode( BuildContext context, { required Node node, required Offset dragOffset, Path? acceptedPath, }) async { if (acceptedPath == null) { Log.info('acceptedPath is null'); return; } final editorState = context.read(); final targetNode = editorState.getNodeAtPath(acceptedPath); if (targetNode == null) { Log.info('targetNode is null'); return; } if (shouldIgnoreDragTarget( editorState: editorState, dragNode: node, targetPath: acceptedPath, )) { Log.info('Drop ignored: node($node, ${node.path}), path($acceptedPath)'); return; } final position = getDragAreaPosition(context, targetNode, dragOffset); if (position == null) { Log.info('position is null'); return; } final (verticalPosition, horizontalPosition, _) = position; Path newPath = targetNode.path; // if the horizontal position is right, creating a column block to contain the target node and the drag node if (horizontalPosition == HorizontalPosition.right) { // 1. if the targetNode is a column block, it means we should create a column block to contain the node and insert the column node to the target node's parent // 2. if the targetNode is not a column block, it means we should create a columns block to contain the target node and the drag node final transaction = editorState.transaction; final targetNodeParent = targetNode.columnsParent; if (targetNodeParent != null) { final length = targetNodeParent.children.length; final ratios = targetNodeParent.children .map( (e) => e.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ?? 1.0 / length, ) .map((e) => e * length / (length + 1)) .toList(); final columnNode = simpleColumnNode( children: [node.deepCopy()], ratio: 1.0 / (length + 1), ); for (final (index, column) in targetNodeParent.children.indexed) { transaction.updateNode(column, { ...column.attributes, SimpleColumnBlockKeys.ratio: ratios[index], }); } transaction.insertNode(targetNode.path.next, columnNode); transaction.deleteNode(node); } else { final columnsNode = simpleColumnsNode( children: [ simpleColumnNode(children: [targetNode.deepCopy()], ratio: 0.5), simpleColumnNode(children: [node.deepCopy()], ratio: 0.5), ], ); transaction.insertNode(newPath, columnsNode); transaction.deleteNode(targetNode); transaction.deleteNode(node); } if (transaction.operations.isNotEmpty) { await editorState.apply(transaction); } return; } else if (horizontalPosition == HorizontalPosition.left && verticalPosition == VerticalPosition.middle) { // 1. if the target node is a column block, we should create a column block to contain the node and insert the column node to the target node's parent // 2. if the target node is not a column block, we should create a columns block to contain the target node and the drag node final transaction = editorState.transaction; final targetNodeParent = targetNode.columnsParent; if (targetNodeParent != null) { // find the previous sibling node of the target node final length = targetNodeParent.children.length; final ratios = targetNodeParent.children .map( (e) => e.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ?? 1.0 / length, ) .map((e) => e * length / (length + 1)) .toList(); final columnNode = simpleColumnNode( children: [node.deepCopy()], ratio: 1.0 / (length + 1), ); for (final (index, column) in targetNodeParent.children.indexed) { transaction.updateNode(column, { ...column.attributes, SimpleColumnBlockKeys.ratio: ratios[index], }); } transaction.insertNode(targetNode.path.previous, columnNode); transaction.deleteNode(node); } else { final columnsNode = simpleColumnsNode( children: [ simpleColumnNode(children: [node.deepCopy()], ratio: 0.5), simpleColumnNode(children: [targetNode.deepCopy()], ratio: 0.5), ], ); transaction.insertNode(newPath, columnsNode); transaction.deleteNode(targetNode); transaction.deleteNode(node); } if (transaction.operations.isNotEmpty) { await editorState.apply(transaction); } return; } // Determine the new path based on drop position // For VerticalPosition.top, we keep the target node's path if (verticalPosition == VerticalPosition.bottom) { if (horizontalPosition == HorizontalPosition.left) { newPath = newPath.next; } else if (horizontalPosition == HorizontalPosition.center && nodeTypesThatCanContainChildNode.contains(targetNode.type)) { // check if the target node can contain a child node newPath = newPath.child(0); } } // Check if the drop should be ignored if (shouldIgnoreDragTarget( editorState: editorState, dragNode: node, targetPath: newPath, )) { Log.info( 'Drop ignored: node($node, ${node.path}), path($acceptedPath)', ); return; } Log.info('Moving node($node, ${node.path}) to path($newPath)'); final transaction = editorState.transaction; transaction.insertNode(newPath, node.deepCopy()); transaction.deleteNode(node); await editorState.apply(transaction); } (VerticalPosition, HorizontalPosition, Rect)? getDragAreaPosition( BuildContext context, Node dragTargetNode, Offset dragOffset, ) { final selectable = dragTargetNode.selectable; final renderBox = selectable?.context.findRenderObject() as RenderBox?; if (selectable == null || renderBox == null) { return null; } // disable the table cell block if (dragTargetNode.parent?.type == TableCellBlockKeys.type) { return null; } final globalBlockOffset = renderBox.localToGlobal(Offset.zero); final globalBlockRect = globalBlockOffset & renderBox.size; // Check if the dragOffset is within the globalBlockRect final isInside = globalBlockRect.contains(dragOffset); if (!isInside) { Log.info( 'the drag offset is not inside the block, dragOffset($dragOffset), globalBlockRect($globalBlockRect)', ); return null; } // Determine the relative position HorizontalPosition horizontalPosition = HorizontalPosition.left; VerticalPosition verticalPosition; // | ----------------------------- block ----------------------------- | // | 1. -- 88px --| 2. ---------------------------- | 3. ---- 1/5 ---- | // 1. drag the node under the block as a sibling node // 2. drag the node inside the block as a child node // 3. create a column block to contain the node and the drag node // Horizontal position, please refer to the diagram above // 88px is a hardcoded value, it can be changed based on the project's design if (dragOffset.dx < globalBlockRect.left + 88) { horizontalPosition = HorizontalPosition.left; } else if (dragOffset.dx > globalBlockRect.right * 4.0 / 5.0) { horizontalPosition = HorizontalPosition.right; } else if (nodeTypesThatCanContainChildNode.contains(dragTargetNode.type)) { horizontalPosition = HorizontalPosition.center; } // | ----------------------------------------------------------------- | <- if the drag position is in this area, the vertical position is top // | ----------------------------- block ----------------------------- | <- if the drag position is in this area, the vertical position is middle // | ----------------------------------------------------------------- | <- if the drag position is in this area, the vertical position is bottom // Vertical position final heightThird = globalBlockRect.height / 3; if (dragOffset.dy < globalBlockRect.top + heightThird) { verticalPosition = VerticalPosition.top; } else if (dragOffset.dy < globalBlockRect.top + heightThird * 2) { verticalPosition = VerticalPosition.middle; } else { verticalPosition = VerticalPosition.bottom; } return (verticalPosition, horizontalPosition, globalBlockRect); } bool shouldIgnoreDragTarget({ required EditorState editorState, required Node dragNode, required Path? targetPath, }) { if (targetPath == null) { return true; } if (dragNode.path.equals(targetPath)) { return true; } if (dragNode.path.isAncestorOf(targetPath)) { return true; } final targetNode = editorState.getNodeAtPath(targetPath); if (targetNode != null && targetNode.isInTable && targetNode.type != SimpleTableBlockKeys.type) { return true; } return false; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart ================================================ import 'dart:math'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'util.dart'; class VisualDragArea extends StatelessWidget { const VisualDragArea({ super.key, required this.data, required this.dragNode, required this.editorState, }); final DragAreaBuilderData data; final Node dragNode; final EditorState editorState; @override Widget build(BuildContext context) { final targetNode = data.targetNode; final ignore = shouldIgnoreDragTarget( editorState: editorState, dragNode: dragNode, targetPath: targetNode.path, ); if (ignore) { return const SizedBox.shrink(); } final selectable = targetNode.selectable; final renderBox = selectable?.context.findRenderObject() as RenderBox?; if (selectable == null || renderBox == null) { return const SizedBox.shrink(); } final position = getDragAreaPosition( context, targetNode, data.dragOffset, ); if (position == null) { return const SizedBox.shrink(); } final (verticalPosition, horizontalPosition, globalBlockRect) = position; // 44 is the width of the drag indicator const indicatorWidth = 44.0; final width = globalBlockRect.width - indicatorWidth; Widget child = Container( height: 2, width: max(width, 0.0), color: Theme.of(context).colorScheme.primary, ); // if the horizontal position is right, we need to show the indicator on the right side of the target node // which represent moving the target node and drag node inside the column block. if (horizontalPosition == HorizontalPosition.left && verticalPosition == VerticalPosition.middle) { return Positioned( top: globalBlockRect.top, height: globalBlockRect.height, left: globalBlockRect.left + indicatorWidth, child: Container( width: 2, color: Theme.of(context).colorScheme.primary, ), ); } if (horizontalPosition == HorizontalPosition.right) { return Positioned( top: globalBlockRect.top, height: globalBlockRect.height, left: globalBlockRect.right - 2, child: Container( width: 2, color: Theme.of(context).colorScheme.primary, ), ); } // If the horizontal position is center, we need to show two indicators //which represent moving the block as the child of the target node. if (horizontalPosition == HorizontalPosition.center) { const breakWidth = 22.0; const padding = 8.0; child = Row( mainAxisSize: MainAxisSize.min, children: [ Container( height: 2, width: breakWidth, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: padding), Container( height: 2, width: width - breakWidth - padding, color: Theme.of(context).colorScheme.primary, ), ], ); } return Positioned( top: verticalPosition == VerticalPosition.top ? globalBlockRect.top : globalBlockRect.bottom, // 44 is the width of the drag indicator left: globalBlockRect.left + 44, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; /// The ... button shows on the top right corner of a block. /// /// Default actions are: /// - delete /// - duplicate /// - insert above /// - insert below /// /// Only works on mobile. class MobileBlockActionButtons extends StatelessWidget { const MobileBlockActionButtons({ super.key, this.extendActionWidgets = const [], this.showThreeDots = true, required this.node, required this.editorState, required this.child, }); final Node node; final EditorState editorState; final List extendActionWidgets; final Widget child; final bool showThreeDots; @override Widget build(BuildContext context) { if (!UniversalPlatform.isMobile) { return child; } if (!showThreeDots) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _showBottomSheet(context), child: child, ); } const padding = 10.0; return Stack( children: [ child, Positioned( top: padding, right: padding, child: FlowyIconButton( icon: const FlowySvg( FlowySvgs.three_dots_s, ), width: 20.0, onPressed: () => _showBottomSheet(context), ), ), ], ); } void _showBottomSheet(BuildContext context) { // close the keyboard editorState.updateSelectionWithReason(null, extraInfo: {}); showMobileBottomSheet( context, showHeader: true, showCloseButton: true, showDragHandle: true, title: LocaleKeys.document_plugins_action.tr(), builder: (context) { return BlockActionBottomSheet( extendActionWidgets: extendActionWidgets, onAction: (action) async { context.pop(); final transaction = editorState.transaction; switch (action) { case BlockActionBottomSheetType.delete: transaction.deleteNode(node); break; case BlockActionBottomSheetType.duplicate: transaction.insertNode( node.path.next, node.deepCopy(), ); break; case BlockActionBottomSheetType.insertAbove: case BlockActionBottomSheetType.insertBelow: final path = action == BlockActionBottomSheetType.insertAbove ? node.path : node.path.next; transaction ..insertNode( path, paragraphNode(), ) ..afterSelection = Selection.collapsed( Position( path: path, ), ); break; } if (transaction.operations.isNotEmpty) { await editorState.apply(transaction); } }, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/align_option_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum OptionAlignType { left, center, right; static OptionAlignType fromString(String? value) { switch (value) { case 'left': return OptionAlignType.left; case 'center': return OptionAlignType.center; case 'right': return OptionAlignType.right; default: return OptionAlignType.center; } } FlowySvgData get svg { switch (this) { case OptionAlignType.left: return FlowySvgs.table_align_left_s; case OptionAlignType.center: return FlowySvgs.table_align_center_s; case OptionAlignType.right: return FlowySvgs.table_align_right_s; } } String get description { switch (this) { case OptionAlignType.left: return LocaleKeys.document_plugins_optionAction_left.tr(); case OptionAlignType.center: return LocaleKeys.document_plugins_optionAction_center.tr(); case OptionAlignType.right: return LocaleKeys.document_plugins_optionAction_right.tr(); } } } class AlignOptionAction extends PopoverActionCell { AlignOptionAction({ required this.editorState, }); final EditorState editorState; @override Widget? leftIcon(Color iconColor) { return FlowySvg( align.svg, size: const Size.square(18), ); } @override String get name { return LocaleKeys.document_plugins_optionAction_align.tr(); } @override PopoverActionCellBuilder get builder => (context, parentController, controller) { final selection = editorState.selection?.normalized; if (selection == null) { return const SizedBox.shrink(); } final node = editorState.getNodeAtPath(selection.start.path); if (node == null) { return const SizedBox.shrink(); } final children = buildAlignOptions(context, (align) async { await onAlignChanged(align); controller.close(); parentController.close(); }); return IntrinsicHeight( child: IntrinsicWidth( child: Column( children: children, ), ), ); }; List buildAlignOptions( BuildContext context, void Function(OptionAlignType) onTap, ) { return OptionAlignType.values.map((e) => OptionAlignWrapper(e)).map((e) { final leftIcon = e.leftIcon(Theme.of(context).colorScheme.onSurface); final rightIcon = e.rightIcon(Theme.of(context).colorScheme.onSurface); return HoverButton( onTap: () => onTap(e.inner), itemHeight: ActionListSizes.itemHeight, leftIcon: SizedBox( width: 16, height: 16, child: leftIcon, ), name: e.name, rightIcon: rightIcon, ); }).toList(); } OptionAlignType get align { final selection = editorState.selection; if (selection == null) { return OptionAlignType.center; } final node = editorState.getNodeAtPath(selection.start.path); final align = node?.type == SimpleTableBlockKeys.type ? node?.tableAlign.key : node?.attributes[blockComponentAlign]; return OptionAlignType.fromString(align); } Future onAlignChanged(OptionAlignType align) async { if (align == this.align) { return; } final selection = editorState.selection; if (selection == null) { return; } final node = editorState.getNodeAtPath(selection.start.path); if (node == null) { return; } // the align attribute for simple table is not same as the align type, // so we need to convert the align type to the align attribute if (node.type == SimpleTableBlockKeys.type) { await editorState.updateTableAlign( tableNode: node, align: TableAlign.fromString(align.name), ); } else { final transaction = editorState.transaction; transaction.updateNode(node, { blockComponentAlign: align.name, }); await editorState.apply(transaction); } } } class OptionAlignWrapper extends ActionCell { OptionAlignWrapper(this.inner); final OptionAlignType inner; @override Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); @override String get name => inner.description; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/color_option_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; const optionActionColorDefaultColor = 'appflowy_theme_default_color'; class ColorOptionAction extends CustomActionCell { ColorOptionAction({ required this.editorState, required this.mutex, }); final EditorState editorState; final PopoverController innerController = PopoverController(); final PopoverMutex mutex; @override Widget buildWithContext( BuildContext context, PopoverController controller, PopoverMutex? mutex, ) { return ColorOptionButton( editorState: editorState, mutex: this.mutex, controller: controller, ); } } class ColorOptionButton extends StatefulWidget { const ColorOptionButton({ super.key, required this.editorState, required this.mutex, required this.controller, }); final EditorState editorState; final PopoverMutex mutex; final PopoverController controller; @override State createState() => _ColorOptionButtonState(); } class _ColorOptionButtonState extends State { final PopoverController innerController = PopoverController(); bool isOpen = false; @override Widget build(BuildContext context) { return AppFlowyPopover( asBarrier: true, controller: innerController, mutex: widget.mutex, popupBuilder: (context) { isOpen = true; return _buildColorOptionMenu( context, widget.controller, ); }, onClose: () => isOpen = false, direction: PopoverDirection.rightWithCenterAligned, animationDuration: Durations.short3, beginScaleFactor: 1.0, beginOpacity: 0.8, child: HoverButton( itemHeight: ActionListSizes.itemHeight, leftIcon: const FlowySvg( FlowySvgs.color_format_m, size: Size.square(15), ), name: LocaleKeys.document_plugins_optionAction_color.tr(), onTap: () { if (!isOpen) { innerController.show(); } }, ), ); } Widget _buildColorOptionMenu( BuildContext context, PopoverController controller, ) { final selection = widget.editorState.selection?.normalized; if (selection == null) { return const SizedBox.shrink(); } final node = widget.editorState.getNodeAtPath(selection.start.path); if (node == null) { return const SizedBox.shrink(); } return _buildColorOptions(context, node, controller); } Widget _buildColorOptions( BuildContext context, Node node, PopoverController controller, ) { final selection = widget.editorState.selection?.normalized; if (selection == null) { return const SizedBox.shrink(); } final node = widget.editorState.getNodeAtPath(selection.start.path); if (node == null) { return const SizedBox.shrink(); } final bgColor = node.attributes[blockComponentBackgroundColor] as String?; final selectedColor = bgColor?.tryToColor(); // get default background color for callout block from themeExtension final defaultColor = node.type == CalloutBlockKeys.type ? AFThemeExtension.of(context).calloutBGColor : Colors.transparent; final colors = [ // reset to default background color FlowyColorOption( color: defaultColor, i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), id: optionActionColorDefaultColor, ), ...FlowyTint.values.map( (e) => FlowyColorOption( color: e.color(context), i18n: e.tintName(AppFlowyEditorL10n.current), id: e.id, ), ), ]; return FlowyColorPicker( colors: colors, selected: selectedColor, border: Border.all( color: AFThemeExtension.of(context).onBackground, ), onTap: (option, index) async { final editorState = widget.editorState; final transaction = editorState.transaction; final selectionType = editorState.selectionType; final selection = editorState.selection; // In multiple selection, we need to update all the nodes in the selection if (selectionType == SelectionType.block && selection != null) { final nodes = editorState.getNodesInSelection(selection.normalized); for (final node in nodes) { transaction.updateNode(node, { blockComponentBackgroundColor: option.id, }); } } else { transaction.updateNode(node, { blockComponentBackgroundColor: option.id, }); } await widget.editorState.apply(transaction); innerController.close(); controller.close(); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum OptionDepthType { h1(1, 'H1'), h2(2, 'H2'), h3(3, 'H3'), h4(4, 'H4'), h5(5, 'H5'), h6(6, 'H6'); const OptionDepthType(this.level, this.description); final String description; final int level; static OptionDepthType fromLevel(int? level) { switch (level) { case 1: return OptionDepthType.h1; case 2: return OptionDepthType.h2; case 3: default: return OptionDepthType.h3; } } } class DepthOptionAction extends PopoverActionCell { DepthOptionAction({ required this.editorState, }); final EditorState editorState; @override Widget? leftIcon(Color iconColor) { return FlowySvg( OptionAction.depth.svg, size: const Size.square(16), ); } @override String get name => LocaleKeys.document_plugins_optionAction_depth.tr(); @override PopoverActionCellBuilder get builder => (context, parentController, controller) { return DepthOptionMenu( onTap: (depth) async { await onDepthChanged(depth); parentController.close(); parentController.close(); }, ); }; OptionDepthType depth(Node node) { final level = node.attributes[OutlineBlockKeys.depth]; return OptionDepthType.fromLevel(level); } Future onDepthChanged(OptionDepthType depth) async { final selection = editorState.selection; final node = selection != null ? editorState.getNodeAtPath(selection.start.path) : null; if (node == null || depth == this.depth(node)) return; final transaction = editorState.transaction; transaction.updateNode( node, {OutlineBlockKeys.depth: depth.level}, ); await editorState.apply(transaction); } } class DepthOptionMenu extends StatelessWidget { const DepthOptionMenu({ super.key, required this.onTap, }); final Future Function(OptionDepthType) onTap; @override Widget build(BuildContext context) { return SizedBox( width: 42, child: Column( mainAxisSize: MainAxisSize.min, children: buildDepthOptions(context, onTap), ), ); } List buildDepthOptions( BuildContext context, Future Function(OptionDepthType) onTap, ) { return OptionDepthType.values .map((e) => OptionDepthWrapper(e)) .map( (e) => HoverButton( onTap: () => onTap(e.inner), itemHeight: ActionListSizes.itemHeight, name: e.name, ), ) .toList(); } } class OptionDepthWrapper extends ActionCell { OptionDepthWrapper(this.inner); final OptionDepthType inner; @override String get name => inner.description; } class OptionActionWrapper extends ActionCell { OptionActionWrapper(this.inner); final OptionAction inner; @override Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); @override String get name => inner.description; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/divider_option_action.dart ================================================ import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; class DividerOptionAction extends CustomActionCell { @override Widget buildWithContext( BuildContext context, PopoverController controller, PopoverMutex? mutex, ) { return const Padding( padding: EdgeInsets.symmetric(vertical: 4.0), child: Divider( height: 1.0, thickness: 1.0, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys, quoteNode; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:easy_localization/easy_localization.dart'; export 'align_option_action.dart'; export 'color_option_action.dart'; export 'depth_option_action.dart'; export 'divider_option_action.dart'; export 'turn_into_option_action.dart'; enum EditorOptionActionType { turnInto, color, align, depth; Set get supportTypes { switch (this) { case EditorOptionActionType.turnInto: return { ParagraphBlockKeys.type, HeadingBlockKeys.type, QuoteBlockKeys.type, CalloutBlockKeys.type, BulletedListBlockKeys.type, NumberedListBlockKeys.type, TodoListBlockKeys.type, ToggleListBlockKeys.type, SubPageBlockKeys.type, }; case EditorOptionActionType.color: return { ParagraphBlockKeys.type, HeadingBlockKeys.type, BulletedListBlockKeys.type, NumberedListBlockKeys.type, QuoteBlockKeys.type, TodoListBlockKeys.type, CalloutBlockKeys.type, OutlineBlockKeys.type, ToggleListBlockKeys.type, }; case EditorOptionActionType.align: return { ImageBlockKeys.type, SimpleTableBlockKeys.type, }; case EditorOptionActionType.depth: return { OutlineBlockKeys.type, }; } } } enum OptionAction { delete, duplicate, turnInto, moveUp, moveDown, copyLinkToBlock, /// callout background color color, divider, align, // Outline block depth, // Simple table setToPageWidth, distributeColumnsEvenly; FlowySvgData get svg { switch (this) { case OptionAction.delete: return FlowySvgs.trash_s; case OptionAction.duplicate: return FlowySvgs.copy_s; case OptionAction.turnInto: return FlowySvgs.turninto_s; case OptionAction.moveUp: return const FlowySvgData('editor/move_up'); case OptionAction.moveDown: return const FlowySvgData('editor/move_down'); case OptionAction.color: return const FlowySvgData('editor/color'); case OptionAction.divider: return const FlowySvgData('editor/divider'); case OptionAction.align: return FlowySvgs.m_aa_bulleted_list_s; case OptionAction.depth: return FlowySvgs.tag_s; case OptionAction.copyLinkToBlock: return FlowySvgs.share_tab_copy_s; case OptionAction.setToPageWidth: return FlowySvgs.table_set_to_page_width_s; case OptionAction.distributeColumnsEvenly: return FlowySvgs.table_distribute_columns_evenly_s; } } String get description { switch (this) { case OptionAction.delete: return LocaleKeys.document_plugins_optionAction_delete.tr(); case OptionAction.duplicate: return LocaleKeys.document_plugins_optionAction_duplicate.tr(); case OptionAction.turnInto: return LocaleKeys.document_plugins_optionAction_turnInto.tr(); case OptionAction.moveUp: return LocaleKeys.document_plugins_optionAction_moveUp.tr(); case OptionAction.moveDown: return LocaleKeys.document_plugins_optionAction_moveDown.tr(); case OptionAction.color: return LocaleKeys.document_plugins_optionAction_color.tr(); case OptionAction.align: return LocaleKeys.document_plugins_optionAction_align.tr(); case OptionAction.depth: return LocaleKeys.document_plugins_optionAction_depth.tr(); case OptionAction.copyLinkToBlock: return LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(); case OptionAction.divider: throw UnsupportedError('Divider does not have description'); case OptionAction.setToPageWidth: return LocaleKeys .document_plugins_simpleTable_moreActions_setToPageWidth .tr(); case OptionAction.distributeColumnsEvenly: return LocaleKeys .document_plugins_simpleTable_moreActions_distributeColumnsWidth .tr(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys, quoteNode; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class TurnIntoOptionAction extends CustomActionCell { TurnIntoOptionAction({ required this.editorState, required this.blockComponentBuilder, required this.mutex, }); final EditorState editorState; final Map blockComponentBuilder; final PopoverController innerController = PopoverController(); final PopoverMutex mutex; @override Widget buildWithContext( BuildContext context, PopoverController controller, PopoverMutex? mutex, ) { return TurnInfoButton( editorState: editorState, blockComponentBuilder: blockComponentBuilder, mutex: this.mutex, ); } } class TurnInfoButton extends StatefulWidget { const TurnInfoButton({ super.key, required this.editorState, required this.blockComponentBuilder, required this.mutex, }); final EditorState editorState; final Map blockComponentBuilder; final PopoverMutex mutex; @override State createState() => _TurnInfoButtonState(); } class _TurnInfoButtonState extends State { final PopoverController innerController = PopoverController(); bool isOpen = false; @override Widget build(BuildContext context) { return AppFlowyPopover( asBarrier: true, controller: innerController, mutex: widget.mutex, popupBuilder: (context) { isOpen = true; return BlocProvider( create: (context) => BlockActionOptionCubit( editorState: widget.editorState, blockComponentBuilder: widget.blockComponentBuilder, ), child: BlocBuilder( builder: (context, _) => _buildTurnIntoOptionMenu(context), ), ); }, onClose: () => isOpen = false, direction: PopoverDirection.rightWithCenterAligned, animationDuration: Durations.short3, beginScaleFactor: 1.0, beginOpacity: 0.8, child: HoverButton( itemHeight: ActionListSizes.itemHeight, // todo(lucas): replace the svg with the correct one leftIcon: const FlowySvg(FlowySvgs.turninto_s), name: LocaleKeys.document_plugins_optionAction_turnInto.tr(), onTap: () { if (!isOpen) { innerController.show(); } }, ), ); } Widget _buildTurnIntoOptionMenu(BuildContext context) { final selection = widget.editorState.selection?.normalized; // the selection may not be collapsed, for example, if a block contains some children, // the selection will be the start from the current block and end at the last child block. // we should take care of this case: // converting a block that contains children to a heading block, // we should move all the children under the heading block. if (selection == null) { return const SizedBox.shrink(); } final node = widget.editorState.getNodeAtPath(selection.start.path); if (node == null) { return const SizedBox.shrink(); } return TurnIntoOptionMenu( node: node, hasNonSupportedTypes: _hasNonSupportedTypes(selection), ); } bool _hasNonSupportedTypes(Selection selection) { final nodes = widget.editorState.getNodesInSelection(selection); if (nodes.isEmpty) { return false; } for (final node in nodes) { if (!EditorOptionActionType.turnInto.supportTypes.contains(node.type)) { return true; } } return false; } } class TurnIntoOptionMenu extends StatelessWidget { const TurnIntoOptionMenu({ super.key, required this.node, required this.hasNonSupportedTypes, }); final Node node; /// Signifies whether the selection contains [Node]s that are not supported, /// these often do not have a [Delta], example could be [FileBlockComponent]. /// final bool hasNonSupportedTypes; @override Widget build(BuildContext context) { if (hasNonSupportedTypes) { return buildItem( pateItem, textSuggestionItem, context.read().editorState, ); } return _buildTurnIntoOptions(context, node); } Widget _buildTurnIntoOptions(BuildContext context, Node node) { final editorState = context.read().editorState; SuggestionItem currentSuggestionItem = textSuggestionItem; final List suggestionItems = suggestions.sublist(0, 4); final List turnIntoItems = suggestions.sublist(4, suggestions.length); final textColor = Color(0xff99A1A8); void refreshSuggestions() { final selection = editorState.selection; if (selection == null || !selection.isSingle) return; final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.delta == null) return; final nodeType = node.type; SuggestionType? suggestionType; if (nodeType == HeadingBlockKeys.type) { final level = node.attributes[HeadingBlockKeys.level] ?? 1; if (level == 1) { suggestionType = SuggestionType.h1; } else if (level == 2) { suggestionType = SuggestionType.h2; } else if (level == 3) { suggestionType = SuggestionType.h3; } } else if (nodeType == ToggleListBlockKeys.type) { final level = node.attributes[ToggleListBlockKeys.level]; if (level == null) { suggestionType = SuggestionType.toggle; } else if (level == 1) { suggestionType = SuggestionType.toggleH1; } else if (level == 2) { suggestionType = SuggestionType.toggleH2; } else if (level == 3) { suggestionType = SuggestionType.toggleH3; } } else { suggestionType = nodeType2SuggestionType[nodeType]; } if (suggestionType == null) return; suggestionItems.clear(); turnIntoItems.clear(); for (final item in suggestions) { if (item.type.group == suggestionType.group && item.type != suggestionType) { suggestionItems.add(item); } else { turnIntoItems.add(item); } } currentSuggestionItem = suggestions.where((item) => item.type == suggestionType).first; } refreshSuggestions(); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ buildSubTitle( LocaleKeys.document_toolbar_suggestions.tr(), textColor, ), ...List.generate(suggestionItems.length, (index) { return buildItem( suggestionItems[index], currentSuggestionItem, editorState, ); }), buildSubTitle(LocaleKeys.document_toolbar_turnInto.tr(), textColor), ...List.generate(turnIntoItems.length, (index) { return buildItem( turnIntoItems[index], currentSuggestionItem, editorState, ); }), ], ); } Widget buildSubTitle(String text, Color color) { return Container( height: 32, margin: EdgeInsets.symmetric(horizontal: 8), child: Align( alignment: Alignment.centerLeft, child: FlowyText.semibold( text, color: color, figmaLineHeight: 16, ), ), ); } Widget buildItem( SuggestionItem item, SuggestionItem currentSuggestionItem, EditorState state, ) { final isSelected = item.type == currentSuggestionItem.type; return SizedBox( height: 36, child: FlowyButton( leftIconSize: const Size.square(20), leftIcon: FlowySvg(item.svg), iconPadding: 12, text: FlowyText( item.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () => item.onTap.call(state, false), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import 'operations/ai_writer_cubit.dart'; import 'operations/ai_writer_entities.dart'; import 'operations/ai_writer_node_extension.dart'; import 'widgets/ai_writer_suggestion_actions.dart'; import 'widgets/ai_writer_prompt_input_more_button.dart'; class AiWriterBlockKeys { const AiWriterBlockKeys._(); static const String type = 'ai_writer'; static const String isInitialized = 'is_initialized'; static const String selection = 'selection'; static const String command = 'command'; /// Sample usage: /// /// `attributes: { /// 'ai_writer_delta_suggestion': 'original' /// }` static const String suggestion = 'ai_writer_delta_suggestion'; static const String suggestionOriginal = 'original'; static const String suggestionReplacement = 'replacement'; } Node aiWriterNode({ required Selection? selection, required AiWriterCommand command, }) { return Node( type: AiWriterBlockKeys.type, attributes: { AiWriterBlockKeys.isInitialized: false, AiWriterBlockKeys.selection: selection?.toJson(), AiWriterBlockKeys.command: command.index, }, ); } class AIWriterBlockComponentBuilder extends BlockComponentBuilder { AIWriterBlockComponentBuilder(); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return AiWriterBlockComponent( key: node.key, node: node, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (node) => node.children.isEmpty && node.attributes[AiWriterBlockKeys.isInitialized] is bool && node.attributes[AiWriterBlockKeys.selection] is Map? && node.attributes[AiWriterBlockKeys.command] is int; } class AiWriterBlockComponent extends BlockComponentStatefulWidget { const AiWriterBlockComponent({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @override State createState() => _AIWriterBlockComponentState(); } class _AIWriterBlockComponentState extends State { final textController = AiPromptInputTextEditingController(); final overlayController = OverlayPortalController(); final layerLink = LayerLink(); final focusNode = FocusNode(); late final editorState = context.read(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { overlayController.show(); context.read().register(widget.node); }); } @override void dispose() { textController.dispose(); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (UniversalPlatform.isMobile) { return const SizedBox.shrink(); } final documentId = context.read()?.documentId; return BlocProvider( create: (_) => AIPromptInputBloc( predefinedFormat: null, objectId: documentId ?? editorState.document.root.id, ), child: LayoutBuilder( builder: (context, constraints) { return OverlayPortal( controller: overlayController, overlayChildBuilder: (context) { return Center( child: CompositedTransformFollower( link: layerLink, showWhenUnlinked: false, child: Container( padding: const EdgeInsets.only( left: 40.0, bottom: 16.0, ), width: constraints.maxWidth, child: Focus( focusNode: focusNode, child: OverlayContent( editorState: editorState, node: widget.node, textController: textController, ), ), ), ), ); }, child: CompositedTransformTarget( link: layerLink, child: BlocBuilder( builder: (context, state) { return SizedBox( width: double.infinity, height: 1.0, ); }, ), ), ); }, ), ); } } class OverlayContent extends StatefulWidget { const OverlayContent({ super.key, required this.editorState, required this.node, required this.textController, }); final EditorState editorState; final Node node; final AiPromptInputTextEditingController textController; @override State createState() => _OverlayContentState(); } class _OverlayContentState extends State { final showCommandsToggle = ValueNotifier(false); @override void dispose() { showCommandsToggle.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state is IdleAiWriterState || state is DocumentContentEmptyAiWriterState) { return const SizedBox.shrink(); } final command = (state as RegisteredAiWriter).command; final selection = widget.node.aiWriterSelection; final hasSelection = selection != null && !selection.isCollapsed; final markdownText = switch (state) { final ReadyAiWriterState ready => ready.markdownText, final GeneratingAiWriterState generating => generating.markdownText, _ => '', }; final showSuggestedActions = state is ReadyAiWriterState && !state.isFirstRun; final isInitialReadyState = state is ReadyAiWriterState && state.isFirstRun; final showSuggestedActionsPopup = showSuggestedActions && markdownText.isEmpty || (markdownText.isNotEmpty && command != AiWriterCommand.explain); final showSuggestedActionsWithin = showSuggestedActions && markdownText.isNotEmpty && command == AiWriterCommand.explain; final borderColor = Theme.of(context).isLightMode ? Color(0x1F1F2329) : Color(0xFF505469); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showSuggestedActionsPopup) ...[ Container( padding: EdgeInsets.all(4.0), decoration: _getModalDecoration( context, color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.all(Radius.circular(8.0)), borderColor: borderColor, ), child: SuggestionActionBar( currentCommand: command, hasSelection: hasSelection, onTap: (action) { _onSelectSuggestionAction(context, action); }, ), ), const VSpace(4.0 + 1.0), ], Container( decoration: _getModalDecoration( context, color: null, borderColor: borderColor, borderRadius: BorderRadius.all(Radius.circular(12.0)), ), constraints: BoxConstraints(maxHeight: 400), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (markdownText.isNotEmpty) ...[ Flexible( child: DecoratedBox( decoration: _secondaryContentDecoration(context), child: SecondaryContentArea( markdownText: markdownText, onSelectSuggestionAction: (action) { _onSelectSuggestionAction(context, action); }, command: command, showSuggestionActions: showSuggestedActionsWithin, hasSelection: hasSelection, ), ), ), Divider(height: 1.0), ], DecoratedBox( decoration: markdownText.isNotEmpty ? _mainContentDecoration(context) : _getSingleChildDeocoration(context), child: MainContentArea( textController: widget.textController, isDocumentEmpty: _isDocumentEmpty(), isInitialReadyState: isInitialReadyState, showCommandsToggle: showCommandsToggle, ), ), ], ), ), ValueListenableBuilder( valueListenable: showCommandsToggle, builder: (context, value, child) { if (!value || !isInitialReadyState) { return const SizedBox.shrink(); } return Align( alignment: AlignmentDirectional.centerEnd, child: MoreAiWriterCommands( hasSelection: hasSelection, editorState: widget.editorState, onSelectCommand: (command) { final bloc = context.read(); final promptId = bloc.promptId; final state = bloc.state; final showPredefinedFormats = state.showPredefinedFormats; final predefinedFormat = state.predefinedFormat; final text = widget.textController.text; context.read().runCommand( command, text, showPredefinedFormats ? predefinedFormat : null, promptId, ); }, ), ); }, ), ], ); }, ); } BoxDecoration _getModalDecoration( BuildContext context, { required Color? color, required Color borderColor, required BorderRadius borderRadius, }) { return BoxDecoration( color: color, border: Border.all( color: borderColor, strokeAlign: BorderSide.strokeAlignOutside, ), borderRadius: borderRadius, boxShadow: Theme.of(context).isLightMode ? ShadowConstants.lightSmall : ShadowConstants.darkSmall, ); } BoxDecoration _getSingleChildDeocoration(BuildContext context) { return BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.all(Radius.circular(12.0)), ); } BoxDecoration _secondaryContentDecoration(BuildContext context) { return BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.vertical(top: Radius.circular(12.0)), ); } BoxDecoration _mainContentDecoration(BuildContext context) { return BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.vertical(bottom: Radius.circular(12.0)), ); } void _onSelectSuggestionAction( BuildContext context, SuggestionAction action, ) { final predefinedFormat = context.read().state.predefinedFormat; context.read().runResponseAction( action, predefinedFormat, ); } bool _isDocumentEmpty() { if (widget.editorState.isEmptyForContinueWriting()) { final documentContext = widget.editorState.document.root.context; if (documentContext == null) { return true; } final view = documentContext.read().state.view; if (view.name.isEmpty) { return true; } } return false; } } class SecondaryContentArea extends StatelessWidget { const SecondaryContentArea({ super.key, required this.command, required this.markdownText, required this.showSuggestionActions, required this.hasSelection, required this.onSelectSuggestionAction, }); final AiWriterCommand command; final String markdownText; final bool showSuggestionActions; final bool hasSelection; final void Function(SuggestionAction) onSelectSuggestionAction; @override Widget build(BuildContext context) { return SizedBox( width: double.infinity, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(8.0), Container( height: 24.0, padding: EdgeInsets.symmetric(horizontal: 14.0), alignment: AlignmentDirectional.centerStart, child: FlowyText( command.i18n, fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF666D76), ), ), const VSpace(4.0), Flexible( child: SingleChildScrollView( physics: ClampingScrollPhysics(), padding: EdgeInsets.symmetric(horizontal: 14.0), child: AIMarkdownText( markdown: markdownText, ), ), ), if (showSuggestionActions) ...[ const VSpace(4.0), Padding( padding: EdgeInsets.symmetric(horizontal: 8.0), child: SuggestionActionBar( currentCommand: command, hasSelection: hasSelection, onTap: onSelectSuggestionAction, ), ), ], const VSpace(8.0), ], ), ); } } class MainContentArea extends StatelessWidget { const MainContentArea({ super.key, required this.textController, required this.isInitialReadyState, required this.isDocumentEmpty, required this.showCommandsToggle, }); final AiPromptInputTextEditingController textController; final bool isInitialReadyState; final bool isDocumentEmpty; final ValueNotifier showCommandsToggle; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final cubit = context.read(); if (state is ReadyAiWriterState) { return DesktopPromptInput( isStreaming: false, hideDecoration: true, hideFormats: [ AiWriterCommand.fixSpellingAndGrammar, AiWriterCommand.improveWriting, AiWriterCommand.makeLonger, AiWriterCommand.makeShorter, ].contains(state.command), textController: textController, onSubmitted: (message, format, _, promptId) { cubit.runCommand(state.command, message, format, promptId); }, onStopStreaming: () => cubit.stopStream(), selectedSourcesNotifier: cubit.selectedSourcesNotifier, onUpdateSelectedSources: (sources) { cubit.selectedSourcesNotifier.value = [ ...sources, ]; }, extraBottomActionButton: isInitialReadyState ? ValueListenableBuilder( valueListenable: showCommandsToggle, builder: (context, value, _) { return AiWriterPromptMoreButton( isEnabled: !isDocumentEmpty, isSelected: value, onTap: () => showCommandsToggle.value = !value, ); }, ) : null, ); } if (state is GeneratingAiWriterState) { return Padding( padding: const EdgeInsets.all(8.0), child: Row( children: [ const HSpace(6.0), Expanded( child: AILoadingIndicator( text: state.command == AiWriterCommand.explain ? LocaleKeys.ai_analyzing.tr() : LocaleKeys.ai_editing.tr(), ), ), const HSpace(8.0), PromptInputSendButton( state: SendButtonState.streaming, onSendPressed: () {}, onStopStreaming: () => cubit.stopStream(), ), ], ), ); } if (state is ErrorAiWriterState) { return Padding( padding: EdgeInsets.all(8.0), child: Row( children: [ const FlowySvg( FlowySvgs.toast_error_filled_s, blendMode: null, ), const HSpace(8.0), Expanded( child: FlowyText( state.error.message, maxLines: null, ), ), const HSpace(8.0), FlowyIconButton( width: 32, hoverColor: Colors.transparent, icon: FlowySvg( FlowySvgs.toast_close_s, size: Size.square(20), ), onPressed: () => cubit.exit(), ), ], ), ); } if (state is LocalAIStreamingAiWriterState) { final text = switch (state.state) { LocalAIStreamingState.notReady => LocaleKeys.settings_aiPage_keys_localAINotReadyRetryLater.tr(), LocalAIStreamingState.disabled => LocaleKeys.settings_aiPage_keys_localAIDisabled.tr(), }; return Padding( padding: EdgeInsets.all(8.0), child: Row( children: [ const HSpace(8.0), Opacity( opacity: 0.5, child: FlowyText(text), ), ], ), ); } return const SizedBox.shrink(); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'operations/ai_writer_entities.dart'; const _improveWritingToolbarItemId = 'appflowy.editor.ai_improve_writing'; const _aiWriterToolbarItemId = 'appflowy.editor.ai_writer'; final ToolbarItem improveWritingItem = ToolbarItem( id: _improveWritingToolbarItemId, group: 0, isActive: onlyShowInTextTypeAndExcludeTable, builder: (context, editorState, _, __, tooltipBuilder) => ImproveWritingButton( editorState: editorState, tooltipBuilder: tooltipBuilder, ), ); final ToolbarItem aiWriterItem = ToolbarItem( id: _aiWriterToolbarItemId, group: 0, isActive: onlyShowInTextTypeAndExcludeTable, builder: (context, editorState, _, __, tooltipBuilder) => AiWriterToolbarActionList( editorState: editorState, tooltipBuilder: tooltipBuilder, ), ); class AiWriterToolbarActionList extends StatefulWidget { const AiWriterToolbarActionList({ super.key, required this.editorState, this.tooltipBuilder, }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; @override State createState() => _AiWriterToolbarActionListState(); } class _AiWriterToolbarActionListState extends State { final popoverController = PopoverController(); bool isSelected = false; @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 2.0), onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { setState(() { isSelected = false; }); keepEditorFocusNotifier.decrease(); }, popupBuilder: (context) => buildPopoverContent(), child: buildChild(context), ); } Widget buildPopoverContent() { return SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(4.0), children: [ actionWrapper(AiWriterCommand.improveWriting), actionWrapper(AiWriterCommand.userQuestion), actionWrapper(AiWriterCommand.fixSpellingAndGrammar), // actionWrapper(AiWriterCommand.summarize), actionWrapper(AiWriterCommand.explain), divider(), actionWrapper(AiWriterCommand.makeLonger), actionWrapper(AiWriterCommand.makeShorter), ], ); } Widget actionWrapper(AiWriterCommand command) { return SizedBox( height: 36, child: FlowyButton( leftIconSize: const Size.square(20), leftIcon: FlowySvg(command.icon), iconPadding: 12, text: FlowyText( command.i18n, figmaLineHeight: 20, ), onTap: () { popoverController.close(); _insertAiNode(widget.editorState, command); }, ), ); } Widget divider() { return const Divider( thickness: 1.0, height: 1.0, ); } Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; final child = FlowyIconButton( width: 48, height: 32, isSelected: isSelected, hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), icon: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.toolbar_ai_writer_m, size: Size.square(20), color: iconScheme.primary, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), color: iconScheme.primary, ), ], ), onPressed: () { if (_isAIWriterEnabled(widget.editorState)) { keepEditorFocusNotifier.increase(); popoverController.show(); setState(() { isSelected = true; }); } else { showToastNotification( message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), ); } }, ); return widget.tooltipBuilder?.call( context, _aiWriterToolbarItemId, _isAIWriterEnabled(widget.editorState) ? LocaleKeys.document_plugins_aiWriter_userQuestion.tr() : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), child, ) ?? child; } } class ImproveWritingButton extends StatelessWidget { const ImproveWritingButton({ super.key, required this.editorState, this.tooltipBuilder, }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final child = FlowyIconButton( width: 36, height: 32, hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), icon: FlowySvg( FlowySvgs.toolbar_ai_improve_writing_m, size: Size.square(20.0), color: theme.iconColorScheme.primary, ), onPressed: () { if (_isAIWriterEnabled(editorState)) { keepEditorFocusNotifier.increase(); _insertAiNode(editorState, AiWriterCommand.improveWriting); } else { showToastNotification( message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), ); } }, ); return tooltipBuilder?.call( context, _aiWriterToolbarItemId, _isAIWriterEnabled(editorState) ? LocaleKeys.document_plugins_aiWriter_improveWriting.tr() : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), child, ) ?? child; } } void _insertAiNode(EditorState editorState, AiWriterCommand command) async { final selection = editorState.selection?.normalized; if (selection == null) { return; } final transaction = editorState.transaction ..insertNode( selection.end.path.next, aiWriterNode( selection: selection, command: command, ), ) ..selectionExtraInfo = {selectionExtraInfoDisableToolbar: true}; await editorState.apply( transaction, options: const ApplyOptions( recordUndo: false, inMemoryUpdate: true, ), withUpdateSelection: false, ); } bool _isAIWriterEnabled(EditorState editorState) { return true; } bool onlyShowInTextTypeAndExcludeTable( EditorState editorState, ) { return onlyShowInTextType(editorState) && notShowInTable(editorState); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_block_operations.dart ================================================ import 'dart:async'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import '../ai_writer_block_component.dart'; import 'ai_writer_entities.dart'; import 'ai_writer_node_extension.dart'; Future setAiWriterNodeIsInitialized( EditorState editorState, Node node, ) async { final transaction = editorState.transaction ..updateNode(node, { AiWriterBlockKeys.isInitialized: true, }); await editorState.apply( transaction, options: const ApplyOptions( recordUndo: false, inMemoryUpdate: true, ), withUpdateSelection: false, ); final selection = node.aiWriterSelection; if (selection != null && !selection.isCollapsed) { unawaited( editorState.updateSelectionWithReason( selection, extraInfo: {selectionExtraInfoDisableToolbar: true}, ), ); } } Future removeAiWriterNode( EditorState editorState, Node node, ) async { final transaction = editorState.transaction..deleteNode(node); await editorState.apply( transaction, options: const ApplyOptions(recordUndo: false), withUpdateSelection: false, ); } Future formatSelection( EditorState editorState, Selection selection, ApplySuggestionFormatType formatType, ) async { final nodes = editorState.getNodesInSelection(selection).toList(); if (nodes.isEmpty) { return; } final transaction = editorState.transaction; if (nodes.length == 1) { final node = nodes.removeAt(0); if (node.delta != null) { final delta = Delta() ..retain(selection.start.offset) ..retain( selection.length, attributes: formatType.attributes, ); transaction.addDeltaToComposeMap(node, delta); } } else { final firstNode = nodes.removeAt(0); final lastNode = nodes.removeLast(); if (firstNode.delta != null) { final text = firstNode.delta!.toPlainText(); final remainderLength = text.length - selection.start.offset; final delta = Delta() ..retain(selection.start.offset) ..retain(remainderLength, attributes: formatType.attributes); transaction.addDeltaToComposeMap(firstNode, delta); } if (lastNode.delta != null) { final delta = Delta() ..retain(selection.end.offset, attributes: formatType.attributes); transaction.addDeltaToComposeMap(lastNode, delta); } for (final node in nodes) { if (node.delta == null) { continue; } final length = node.delta!.length; if (length != 0) { final delta = Delta() ..retain(length, attributes: formatType.attributes); transaction.addDeltaToComposeMap(node, delta); } } } transaction.compose(); await editorState.apply( transaction, options: ApplyOptions( inMemoryUpdate: true, recordUndo: false, ), withUpdateSelection: false, ); } Future ensurePreviousNodeIsEmptyParagraph( EditorState editorState, Node aiWriterNode, ) async { final previous = aiWriterNode.previous; final needsEmptyParagraphNode = previous == null || previous.type != ParagraphBlockKeys.type || (previous.delta?.toPlainText().isNotEmpty ?? false); final Position position; final transaction = editorState.transaction; if (needsEmptyParagraphNode) { position = Position(path: aiWriterNode.path); transaction.insertNode(aiWriterNode.path, paragraphNode()); } else { position = Position(path: previous.path); } transaction.afterSelection = Selection.collapsed(position); await editorState.apply( transaction, options: ApplyOptions( inMemoryUpdate: true, recordUndo: false, ), ); return position; } extension SaveAIResponseExtension on EditorState { Future insertBelow({ required Node node, required String markdownText, }) async { final selection = this.selection?.normalized; if (selection == null) { return; } final nodes = customMarkdownToDocument( markdownText, tableWidth: 250.0, ).root.children.map((e) => e.deepCopy()).toList(); if (nodes.isEmpty) { return; } final insertedPath = selection.end.path.next; final lastDeltaLength = nodes.lastOrNull?.delta?.length ?? 0; final transaction = this.transaction ..insertNodes(insertedPath, nodes) ..afterSelection = Selection( start: Position(path: insertedPath), end: Position( path: insertedPath.nextNPath(nodes.length - 1), offset: lastDeltaLength, ), ); await apply(transaction); } Future replace({ required Selection selection, required String text, }) async { final trimmedText = text.trim(); if (trimmedText.isEmpty) { return; } await switch (kdefaultReplacementType) { AskAIReplacementType.markdown => _replaceWithMarkdown(selection, trimmedText), AskAIReplacementType.plainText => _replaceWithPlainText(selection, trimmedText), }; } Future _replaceWithMarkdown( Selection selection, String markdownText, ) async { final nodes = customMarkdownToDocument(markdownText) .root .children .map((e) => e.deepCopy()) .toList(); if (nodes.isEmpty) { return; } final nodesInSelection = getNodesInSelection(selection); final newSelection = Selection( start: selection.start, end: Position( path: selection.start.path.nextNPath(nodes.length - 1), offset: nodes.lastOrNull?.delta?.length ?? 0, ), ); final transaction = this.transaction ..insertNodes(selection.start.path, nodes) ..deleteNodes(nodesInSelection) ..afterSelection = newSelection; await apply(transaction); } Future _replaceWithPlainText( Selection selection, String plainText, ) async { final nodes = getNodesInSelection(selection); if (nodes.isEmpty || nodes.any((element) => element.delta == null)) { return; } final replaceTexts = plainText.split('\n') ..removeWhere((element) => element.isEmpty); final transaction = this.transaction ..replaceTexts( nodes, selection, replaceTexts, ); await apply(transaction); int endOffset = replaceTexts.last.length; if (replaceTexts.length == 1) { endOffset += selection.start.offset; } final end = Position( path: [selection.start.path.first + replaceTexts.length - 1], offset: endOffset, ); this.selection = Selection( start: selection.start, end: end, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import '../../base/markdown_text_robot.dart'; import 'ai_writer_block_operations.dart'; import 'ai_writer_entities.dart'; import 'ai_writer_node_extension.dart'; /// Enable the debug log for the AiWriterCubit. /// /// This is useful for debugging the AI writer cubit. const _aiWriterCubitDebugLog = true; class AiWriterCubit extends Cubit { AiWriterCubit({ required this.documentId, required this.editorState, this.onCreateNode, this.onRemoveNode, this.onAppendToDocument, }) : _aiService = getIt(), _textRobot = MarkdownTextRobot(editorState: editorState), selectedSourcesNotifier = ValueNotifier([documentId]), super(IdleAiWriterState()); final String documentId; final EditorState editorState; final AIRepository _aiService; final MarkdownTextRobot _textRobot; final void Function()? onCreateNode; final void Function()? onRemoveNode; final void Function()? onAppendToDocument; Node? aiWriterNode; final List records = []; final ValueNotifier> selectedSourcesNotifier; @override Future close() async { selectedSourcesNotifier.dispose(); await super.close(); } Future exit({ bool withDiscard = true, bool withUnformat = true, }) async { if (aiWriterNode == null) { return; } if (withDiscard) { await _textRobot.discard( afterSelection: aiWriterNode!.aiWriterSelection, ); } _textRobot.clear(); _textRobot.reset(); onRemoveNode?.call(); records.clear(); selectedSourcesNotifier.value = [documentId]; emit(IdleAiWriterState()); if (withUnformat) { final selection = aiWriterNode!.aiWriterSelection; if (selection == null) { return; } await formatSelection( editorState, selection, ApplySuggestionFormatType.clear, ); } if (aiWriterNode != null) { await removeAiWriterNode(editorState, aiWriterNode!); aiWriterNode = null; } } void register(Node node) async { if (node.isAiWriterInitialized) { return; } if (aiWriterNode != null && node.id != aiWriterNode!.id) { await removeAiWriterNode(editorState, node); return; } aiWriterNode = node; onCreateNode?.call(); await setAiWriterNodeIsInitialized(editorState, node); final command = node.aiWriterCommand; final (run, prompt) = await _addSelectionTextToRecords(command); _aiWriterCubitLog( 'command: $command, run: $run, prompt: $prompt', ); if (!run) { await exit(); return; } runCommand(command, prompt, null, null); } void runCommand( AiWriterCommand command, String prompt, PredefinedFormat? predefinedFormat, String? promptId, ) async { if (aiWriterNode == null) { return; } await _textRobot.discard(); _textRobot.clear(); switch (command) { case AiWriterCommand.continueWriting: await _startContinueWriting( command, predefinedFormat, promptId, ); break; case AiWriterCommand.fixSpellingAndGrammar: case AiWriterCommand.improveWriting: case AiWriterCommand.makeLonger: case AiWriterCommand.makeShorter: await _startSuggestingEdits( command, prompt, predefinedFormat, promptId, ); break; case AiWriterCommand.explain: await _startInforming(command, prompt, predefinedFormat, promptId); break; case AiWriterCommand.userQuestion when prompt.isNotEmpty: _startAskingQuestion( prompt, predefinedFormat, promptId, ); break; case AiWriterCommand.userQuestion: emit( ReadyAiWriterState(AiWriterCommand.userQuestion, isFirstRun: true), ); break; } } void _retry({ required PredefinedFormat? predefinedFormat, }) async { final lastQuestion = records.lastWhereOrNull((record) => record.role == AiRole.user); if (lastQuestion != null && state is RegisteredAiWriter) { runCommand( (state as RegisteredAiWriter).command, lastQuestion.content, lastQuestion.format, null, ); } } Future stopStream() async { if (aiWriterNode == null) { return; } if (state is GeneratingAiWriterState) { final generatingState = state as GeneratingAiWriterState; await _textRobot.stop( attributes: ApplySuggestionFormatType.replace.attributes, ); if (_textRobot.hasAnyResult) { records.add(AiWriterRecord.ai(content: _textRobot.markdownText)); } await AIEventStopCompleteText( CompleteTextTaskPB( taskId: generatingState.taskId, ), ).send(); emit( ReadyAiWriterState( generatingState.command, isFirstRun: false, markdownText: generatingState.markdownText, ), ); } } void runResponseAction( SuggestionAction action, [ PredefinedFormat? predefinedFormat, ]) async { if (aiWriterNode == null) { return; } if (action case SuggestionAction.rewrite || SuggestionAction.tryAgain) { _retry(predefinedFormat: predefinedFormat); return; } if (action case SuggestionAction.discard || SuggestionAction.close) { await exit(); return; } final selection = aiWriterNode?.aiWriterSelection; if (selection == null) { return; } // Accept // // If the user clicks accept, we need to replace the selection with the AI's response if (action case SuggestionAction.accept) { // trim the markdown text to avoid extra new lines final trimmedMarkdownText = _textRobot.markdownText.trim(); _aiWriterCubitLog( 'trigger accept action, markdown text: $trimmedMarkdownText', ); await formatSelection( editorState, selection, ApplySuggestionFormatType.clear, ); await _textRobot.deleteAINodes(); await _textRobot.replace( selection: selection, markdownText: trimmedMarkdownText, ); await exit(withDiscard: false, withUnformat: false); return; } if (action case SuggestionAction.keep) { await _textRobot.persist(); await exit(withDiscard: false); return; } if (action case SuggestionAction.insertBelow) { if (state is! ReadyAiWriterState) { return; } final command = (state as ReadyAiWriterState).command; final markdownText = (state as ReadyAiWriterState).markdownText; if (command == AiWriterCommand.explain && markdownText.isNotEmpty) { final position = await ensurePreviousNodeIsEmptyParagraph( editorState, aiWriterNode!, ); _textRobot.start(position: position); await _textRobot.persist(markdownText: markdownText); } else if (_textRobot.hasAnyResult) { await _textRobot.persist(); } await formatSelection( editorState, selection, ApplySuggestionFormatType.clear, ); await exit(withDiscard: false); } } bool hasUnusedResponse() { return switch (state) { ReadyAiWriterState( isFirstRun: final isInitial, markdownText: final markdownText, ) => !isInitial && (markdownText.isNotEmpty || _textRobot.hasAnyResult), GeneratingAiWriterState() => true, _ => false, }; } Future<(bool, String)> _addSelectionTextToRecords( AiWriterCommand command, ) async { final node = aiWriterNode; // check the node is registered if (node == null) { Log.warn('[AI writer] Node is null'); return (false, ''); } // check the selection is valid final selection = node.aiWriterSelection?.normalized; if (selection == null) { Log.warn('[AI writer]Selection is null'); return (false, ''); } // if the command is continue writing, we don't need to get the selection text if (command == AiWriterCommand.continueWriting) { return (true, ''); } // if the selection is collapsed, we don't need to get the selection text if (selection.isCollapsed) { return (true, ''); } final selectionText = await editorState.getMarkdownInSelection(selection); if (command == AiWriterCommand.userQuestion) { records.add( AiWriterRecord.user(content: selectionText, format: null), ); return (true, ''); } else { return (true, selectionText); } } Future _getDocumentContentFromTopToPosition(Position position) async { final beginningToCursorSelection = Selection( start: Position(path: [0]), end: position, ).normalized; final documentText = (await editorState.getMarkdownInSelection(beginningToCursorSelection)) .trim(); final view = await ViewBackendService.getView(documentId).toNullable(); final viewName = view?.name ?? ''; return "$viewName\n$documentText".trim(); } void _startAskingQuestion( String prompt, PredefinedFormat? format, String? promptId, ) async { if (aiWriterNode == null) { return; } final command = AiWriterCommand.userQuestion; final stream = await _aiService.streamCompletion( objectId: documentId, text: prompt, format: format, promptId: promptId, history: records, sourceIds: selectedSourcesNotifier.value, completionType: command.toCompletionType(), onStart: () async { final position = await ensurePreviousNodeIsEmptyParagraph( editorState, aiWriterNode!, ); _textRobot.start(position: position); records.add( AiWriterRecord.user( content: prompt, format: format, ), ); }, processMessage: (text) async { await _textRobot.appendMarkdownText( text, updateSelection: false, attributes: ApplySuggestionFormatType.replace.attributes, ); onAppendToDocument?.call(); }, processAssistMessage: (text) async { if (state case final GeneratingAiWriterState generatingState) { emit( GeneratingAiWriterState( command, taskId: generatingState.taskId, markdownText: generatingState.markdownText + text, ), ); } }, onEnd: () async { if (state case final GeneratingAiWriterState generatingState) { await _textRobot.stop( attributes: ApplySuggestionFormatType.replace.attributes, ); emit( ReadyAiWriterState( command, isFirstRun: false, markdownText: generatingState.markdownText, ), ); records.add( AiWriterRecord.ai(content: _textRobot.markdownText), ); } }, onError: (error) async { emit(ErrorAiWriterState(command, error: error)); records.add( AiWriterRecord.ai(content: _textRobot.markdownText), ); }, onLocalAIStreamingStateChange: (state) { emit(LocalAIStreamingAiWriterState(command, state: state)); }, ); if (stream != null) { emit( GeneratingAiWriterState( command, taskId: stream.$1, ), ); } } Future _startContinueWriting( AiWriterCommand command, PredefinedFormat? predefinedFormat, String? promptId, ) async { final position = aiWriterNode?.aiWriterSelection?.start; if (position == null) { return; } final text = await _getDocumentContentFromTopToPosition(position); if (text.isEmpty) { final stateCopy = state; emit(DocumentContentEmptyAiWriterState(command, onConfirm: exit)); emit(stateCopy); return; } final stream = await _aiService.streamCompletion( objectId: documentId, text: text, completionType: command.toCompletionType(), history: records, sourceIds: selectedSourcesNotifier.value, format: predefinedFormat, promptId: promptId, onStart: () async { final position = await ensurePreviousNodeIsEmptyParagraph( editorState, aiWriterNode!, ); _textRobot.start(position: position); records.add( AiWriterRecord.user( content: text, format: predefinedFormat, ), ); }, processMessage: (text) async { await _textRobot.appendMarkdownText( text, updateSelection: false, attributes: ApplySuggestionFormatType.replace.attributes, ); onAppendToDocument?.call(); }, processAssistMessage: (text) async { if (state case final GeneratingAiWriterState generatingState) { emit( GeneratingAiWriterState( command, taskId: generatingState.taskId, markdownText: generatingState.markdownText + text, ), ); } }, onEnd: () async { if (state case final GeneratingAiWriterState generatingState) { await _textRobot.stop( attributes: ApplySuggestionFormatType.replace.attributes, ); emit( ReadyAiWriterState( command, isFirstRun: false, markdownText: generatingState.markdownText, ), ); } records.add( AiWriterRecord.ai(content: _textRobot.markdownText), ); }, onError: (error) async { emit(ErrorAiWriterState(command, error: error)); records.add( AiWriterRecord.ai(content: _textRobot.markdownText), ); }, onLocalAIStreamingStateChange: (state) { emit(LocalAIStreamingAiWriterState(command, state: state)); }, ); if (stream != null) { emit( GeneratingAiWriterState(command, taskId: stream.$1), ); } } Future _startSuggestingEdits( AiWriterCommand command, String prompt, PredefinedFormat? predefinedFormat, String? promptId, ) async { final selection = aiWriterNode?.aiWriterSelection; if (selection == null) { return; } if (prompt.isEmpty) { prompt = records.removeAt(0).content; } final stream = await _aiService.streamCompletion( objectId: documentId, text: prompt, format: predefinedFormat, promptId: promptId, completionType: command.toCompletionType(), history: records, sourceIds: selectedSourcesNotifier.value, onStart: () async { await formatSelection( editorState, selection, ApplySuggestionFormatType.original, ); final position = await ensurePreviousNodeIsEmptyParagraph( editorState, aiWriterNode!, ); _textRobot.start(position: position, previousSelection: selection); records.add( AiWriterRecord.user( content: prompt, format: predefinedFormat, ), ); }, processMessage: (text) async { await _textRobot.appendMarkdownText( text, updateSelection: false, attributes: ApplySuggestionFormatType.replace.attributes, ); onAppendToDocument?.call(); _aiWriterCubitLog( 'received message: $text', ); }, processAssistMessage: (text) async { if (state case final GeneratingAiWriterState generatingState) { emit( GeneratingAiWriterState( command, taskId: generatingState.taskId, markdownText: generatingState.markdownText + text, ), ); } _aiWriterCubitLog( 'received assist message: $text', ); }, onEnd: () async { if (state case final GeneratingAiWriterState generatingState) { await _textRobot.stop( attributes: ApplySuggestionFormatType.replace.attributes, ); emit( ReadyAiWriterState( command, isFirstRun: false, markdownText: generatingState.markdownText, ), ); records.add( AiWriterRecord.ai(content: _textRobot.markdownText), ); _aiWriterCubitLog( 'returned response: ${_textRobot.markdownText}', ); } }, onError: (error) async { emit(ErrorAiWriterState(command, error: error)); records.add( AiWriterRecord.ai(content: _textRobot.markdownText), ); }, onLocalAIStreamingStateChange: (state) { emit(LocalAIStreamingAiWriterState(command, state: state)); }, ); if (stream != null) { emit( GeneratingAiWriterState(command, taskId: stream.$1), ); } } Future _startInforming( AiWriterCommand command, String prompt, PredefinedFormat? predefinedFormat, String? promptId, ) async { final selection = aiWriterNode?.aiWriterSelection; if (selection == null) { return; } if (prompt.isEmpty) { prompt = records.removeAt(0).content; } final stream = await _aiService.streamCompletion( objectId: documentId, text: prompt, completionType: command.toCompletionType(), history: records, sourceIds: selectedSourcesNotifier.value, format: predefinedFormat, promptId: promptId, onStart: () async { records.add( AiWriterRecord.user( content: prompt, format: predefinedFormat, ), ); }, processMessage: (text) async { if (state case final GeneratingAiWriterState generatingState) { emit( GeneratingAiWriterState( command, taskId: generatingState.taskId, markdownText: generatingState.markdownText + text, ), ); } }, processAssistMessage: (_) async {}, onEnd: () async { if (state case final GeneratingAiWriterState generatingState) { emit( ReadyAiWriterState( command, isFirstRun: false, markdownText: generatingState.markdownText, ), ); records.add( AiWriterRecord.ai(content: generatingState.markdownText), ); } }, onError: (error) async { if (state case final GeneratingAiWriterState generatingState) { records.add( AiWriterRecord.ai(content: generatingState.markdownText), ); } emit(ErrorAiWriterState(command, error: error)); }, onLocalAIStreamingStateChange: (state) { emit(LocalAIStreamingAiWriterState(command, state: state)); }, ); if (stream != null) { emit( GeneratingAiWriterState(command, taskId: stream.$1), ); } } void _aiWriterCubitLog(String message) { if (_aiWriterCubitDebugLog) { Log.debug('[AiWriterCubit] $message'); } } } mixin RegisteredAiWriter { AiWriterCommand get command; } sealed class AiWriterState { const AiWriterState(); } class IdleAiWriterState extends AiWriterState { const IdleAiWriterState(); } class ReadyAiWriterState extends AiWriterState with RegisteredAiWriter { const ReadyAiWriterState( this.command, { required this.isFirstRun, this.markdownText = '', }); @override final AiWriterCommand command; final bool isFirstRun; final String markdownText; } class GeneratingAiWriterState extends AiWriterState with RegisteredAiWriter { const GeneratingAiWriterState( this.command, { required this.taskId, this.progress = '', this.markdownText = '', }); @override final AiWriterCommand command; final String taskId; final String progress; final String markdownText; } class ErrorAiWriterState extends AiWriterState with RegisteredAiWriter { const ErrorAiWriterState( this.command, { required this.error, }); @override final AiWriterCommand command; final AIError error; } class DocumentContentEmptyAiWriterState extends AiWriterState with RegisteredAiWriter { const DocumentContentEmptyAiWriterState( this.command, { required this.onConfirm, }); @override final AiWriterCommand command; final void Function() onConfirm; } class LocalAIStreamingAiWriterState extends AiWriterState with RegisteredAiWriter { const LocalAIStreamingAiWriterState( this.command, { required this.state, }); @override final AiWriterCommand command; final LocalAIStreamingState state; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import '../ai_writer_block_component.dart'; const kdefaultReplacementType = AskAIReplacementType.markdown; enum AskAIReplacementType { markdown, plainText, } enum SuggestionAction { accept, discard, close, tryAgain, rewrite, keep, insertBelow; String get i18n => switch (this) { accept => LocaleKeys.suggestion_accept.tr(), discard => LocaleKeys.suggestion_discard.tr(), close => LocaleKeys.suggestion_close.tr(), tryAgain => LocaleKeys.suggestion_tryAgain.tr(), rewrite => LocaleKeys.suggestion_rewrite.tr(), keep => LocaleKeys.suggestion_keep.tr(), insertBelow => LocaleKeys.suggestion_insertBelow.tr(), }; FlowySvg buildIcon(BuildContext context) { final icon = switch (this) { accept || keep => FlowySvgs.ai_fix_spelling_grammar_s, discard || close => FlowySvgs.toast_close_s, tryAgain || rewrite => FlowySvgs.ai_try_again_s, insertBelow => FlowySvgs.suggestion_insert_below_s, }; return FlowySvg( icon, size: Size.square(16.0), color: switch (this) { accept || keep => Color(0xFF278E42), discard || close => Color(0xFFC40055), _ => Theme.of(context).iconTheme.color, }, ); } } enum AiWriterCommand { userQuestion, explain, // summarize, continueWriting, fixSpellingAndGrammar, improveWriting, makeShorter, makeLonger; String defaultPrompt(String input) => switch (this) { userQuestion => input, explain => "Explain this phrase in a concise manner:\n\n$input", // summarize => '$input\n\nTl;dr', continueWriting => 'Continue writing based on this existing text:\n\n$input', fixSpellingAndGrammar => 'Correct this to standard English:\n\n$input', improveWriting => 'Rewrite this in your own words:\n\n$input', makeShorter => 'Make this text shorter:\n\n$input', makeLonger => 'Make this text longer:\n\n$input', }; String get i18n => switch (this) { userQuestion => LocaleKeys.document_plugins_aiWriter_userQuestion.tr(), explain => LocaleKeys.document_plugins_aiWriter_explain.tr(), // summarize => LocaleKeys.document_plugins_aiWriter_summarize.tr(), continueWriting => LocaleKeys.document_plugins_aiWriter_continueWriting.tr(), fixSpellingAndGrammar => LocaleKeys.document_plugins_aiWriter_fixSpelling.tr(), improveWriting => LocaleKeys.document_plugins_smartEditImproveWriting.tr(), makeShorter => LocaleKeys.document_plugins_aiWriter_makeShorter.tr(), makeLonger => LocaleKeys.document_plugins_aiWriter_makeLonger.tr(), }; FlowySvgData get icon => switch (this) { userQuestion => FlowySvgs.toolbar_ai_ask_anything_m, explain => FlowySvgs.toolbar_ai_explain_m, // summarize => FlowySvgs.ai_summarize_s, continueWriting || improveWriting => FlowySvgs.toolbar_ai_improve_writing_m, fixSpellingAndGrammar => FlowySvgs.toolbar_ai_fix_spelling_grammar_m, makeShorter => FlowySvgs.toolbar_ai_make_shorter_m, makeLonger => FlowySvgs.toolbar_ai_make_longer_m, }; CompletionTypePB toCompletionType() => switch (this) { userQuestion => CompletionTypePB.UserQuestion, explain => CompletionTypePB.ExplainSelected, // summarize => CompletionTypePB.Summarize, continueWriting => CompletionTypePB.ContinueWriting, fixSpellingAndGrammar => CompletionTypePB.SpellingAndGrammar, improveWriting => CompletionTypePB.ImproveWriting, makeShorter => CompletionTypePB.MakeShorter, makeLonger => CompletionTypePB.MakeLonger, }; } enum ApplySuggestionFormatType { original(AiWriterBlockKeys.suggestionOriginal), replace(AiWriterBlockKeys.suggestionReplacement), clear(null); const ApplySuggestionFormatType(this.value); final String? value; Map get attributes => {AiWriterBlockKeys.suggestion: value}; } enum AiRole { user, system, ai, } class AiWriterRecord extends Equatable { const AiWriterRecord.user({ required this.content, required this.format, }) : role = AiRole.user; const AiWriterRecord.ai({ required this.content, }) : role = AiRole.ai, format = null; final AiRole role; final String content; final PredefinedFormat? format; @override List get props => [role, content, format]; CompletionRecordPB toPB() { return CompletionRecordPB( content: content, role: switch (role) { AiRole.user => ChatMessageTypePB.User, AiRole.system || AiRole.ai => ChatMessageTypePB.System, }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import '../ai_writer_block_component.dart'; import 'ai_writer_entities.dart'; extension AiWriterExtension on Node { bool get isAiWriterInitialized { return attributes[AiWriterBlockKeys.isInitialized]; } Selection? get aiWriterSelection { final selection = attributes[AiWriterBlockKeys.selection]; if (selection == null) { return null; } return Selection.fromJson(selection); } AiWriterCommand get aiWriterCommand { final index = attributes[AiWriterBlockKeys.command]; return AiWriterCommand.values[index]; } } extension AiWriterNodeExtension on EditorState { Future getMarkdownInSelection(Selection? selection) async { selection ??= this.selection?.normalized; if (selection == null || selection.isCollapsed) { return ''; } // if the selected nodes are not entirely selected, slice the nodes final slicedNodes = []; final List flattenNodes = getNodesInSelection(selection); final List nodes = []; for (final node in flattenNodes) { if (nodes.any((element) => element.isParentOf(node))) { continue; } nodes.add(node); } for (final node in nodes) { final delta = node.delta; if (delta == null) { continue; } final slicedDelta = delta.slice( node == nodes.first ? selection.startIndex : 0, node == nodes.last ? selection.endIndex : delta.length, ); final copiedNode = node.copyWith( attributes: { ...node.attributes, blockComponentDelta: slicedDelta.toJson(), }, ); slicedNodes.add(copiedNode); } for (final (i, node) in slicedNodes.indexed) { final childNodesShouldBeDeleted = []; for (final child in node.children) { if (!child.path.inSelection(selection)) { childNodesShouldBeDeleted.add(child); } } for (final child in childNodesShouldBeDeleted) { slicedNodes[i] = node.copyWith( children: node.children.where((e) => e.id != child.id).toList(), type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type, ); } } // use \n\n as line break to improve the ai response // using \n will cause the ai response treat the text as a single line final markdown = await customDocumentToMarkdown( Document.blank()..insert([0], slicedNodes), lineBreak: '\n', ); // trim the last \n if it exists return markdown.trimRight(); } List getPlainTextInSelection(Selection? selection) { selection ??= this.selection?.normalized; if (selection == null || selection.isCollapsed) { return []; } final res = []; if (selection.isCollapsed) { return res; } final nodes = getNodesInSelection(selection); for (final node in nodes) { final delta = node.delta; if (delta == null) { continue; } final startIndex = node == nodes.first ? selection.startIndex : 0; final endIndex = node == nodes.last ? selection.endIndex : delta.length; res.add(delta.slice(startIndex, endIndex).toPlainText()); } return res; } /// Determines whether the document is empty up to the selection /// /// If empty and the title is also empty, the continue writing option will be disabled. bool isEmptyForContinueWriting({ Selection? selection, }) { if (selection != null && !selection.isCollapsed) { return false; } final effectiveSelection = Selection( start: Position(path: [0]), end: selection?.normalized.end ?? this.selection?.normalized.end ?? Position(path: getLastSelectable()?.$1.path ?? [0]), ); // if the selected nodes are not entirely selected, slice the nodes final slicedNodes = []; final nodes = getNodesInSelection(effectiveSelection); for (final node in nodes) { final delta = node.delta; if (delta == null) { continue; } final slicedDelta = delta.slice( node == nodes.first ? effectiveSelection.startIndex : 0, node == nodes.last ? effectiveSelection.endIndex : delta.length, ); final copiedNode = node.copyWith( attributes: { ...node.attributes, blockComponentDelta: slicedDelta.toJson(), }, ); slicedNodes.add(copiedNode); } // using less custom parsers to avoid futures final markdown = documentToMarkdown( Document.blank()..insert([0], slicedNodes), customParsers: [ const MathEquationNodeParser(), const CalloutNodeParser(), const ToggleListNodeParser(), const CustomParagraphNodeParser(), const SubPageNodeParser(), const SimpleTableNodeParser(), const LinkPreviewNodeParser(), const FileBlockNodeParser(), ], ); return markdown.trim().isEmpty; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_gesture_detector.dart ================================================ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; class AiWriterGestureDetector extends StatelessWidget { const AiWriterGestureDetector({ super.key, required this.behavior, required this.onPointerEvent, this.child, }); final HitTestBehavior behavior; final void Function() onPointerEvent; final Widget? child; @override Widget build(BuildContext context) { return RawGestureDetector( behavior: behavior, gestures: { TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(), (instance) => instance ..onTapDown = ((_) => onPointerEvent()) ..onSecondaryTapDown = ((_) => onPointerEvent()) ..onTertiaryTapDown = ((_) => onPointerEvent()), ), ImmediateMultiDragGestureRecognizer: GestureRecognizerFactoryWithHandlers< ImmediateMultiDragGestureRecognizer>( () => ImmediateMultiDragGestureRecognizer(), (instance) => instance.onStart = (offset) => null, ), }, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_prompt_input_more_button.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import '../operations/ai_writer_entities.dart'; class AiWriterPromptMoreButton extends StatelessWidget { const AiWriterPromptMoreButton({ super.key, required this.isEnabled, required this.isSelected, required this.onTap, }); final bool isEnabled; final bool isSelected; final void Function() onTap; @override Widget build(BuildContext context) { return IgnorePointer( ignoring: !isEnabled, child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: SizedBox( height: DesktopAIPromptSizes.actionBarButtonSize, child: FlowyHover( style: const HoverStyle( borderRadius: BorderRadius.all(Radius.circular(8)), ), isSelected: () => isSelected, child: Padding( padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ FlowyText( LocaleKeys.ai_more.tr(), fontSize: 12, figmaLineHeight: 16, color: isEnabled ? Theme.of(context).hintColor : Theme.of(context).disabledColor, ), const HSpace(2.0), FlowySvg( FlowySvgs.ai_source_drop_down_s, color: Theme.of(context).hintColor, size: const Size.square(8), ), ], ), ), ), ), ), ); } } class MoreAiWriterCommands extends StatelessWidget { const MoreAiWriterCommands({ super.key, required this.hasSelection, required this.editorState, required this.onSelectCommand, }); final EditorState editorState; final bool hasSelection; final void Function(AiWriterCommand) onSelectCommand; @override Widget build(BuildContext context) { return Container( // add one here to take into account the border of the main message box. // It is configured to be on the outside to hide some graphical // artifacts. margin: EdgeInsets.only(top: 4.0 + 1.0), padding: EdgeInsets.all(8.0), constraints: BoxConstraints(minWidth: 240.0), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, border: Border.all( color: Theme.of(context).brightness == Brightness.light ? ColorSchemeConstants.lightBorderColor : ColorSchemeConstants.darkBorderColor, strokeAlign: BorderSide.strokeAlignOutside, ), borderRadius: BorderRadius.all(Radius.circular(8.0)), boxShadow: Theme.of(context).isLightMode ? ShadowConstants.lightSmall : ShadowConstants.darkSmall, ), child: IntrinsicWidth( child: Column( spacing: 4.0, crossAxisAlignment: CrossAxisAlignment.start, children: _getCommands( hasSelection: hasSelection, ), ), ), ); } List _getCommands({required bool hasSelection}) { if (hasSelection) { return [ _bottomButton(AiWriterCommand.improveWriting), _bottomButton(AiWriterCommand.fixSpellingAndGrammar), _bottomButton(AiWriterCommand.explain), const Divider(height: 1.0, thickness: 1.0), _bottomButton(AiWriterCommand.makeLonger), _bottomButton(AiWriterCommand.makeShorter), ]; } else { return [ _bottomButton(AiWriterCommand.continueWriting), ]; } } Widget _bottomButton(AiWriterCommand command) { return Builder( builder: (context) { return FlowyButton( leftIcon: FlowySvg( command.icon, color: Theme.of(context).iconTheme.color, ), leftIconSize: const Size.square(20), margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), text: FlowyText( command.i18n, figmaLineHeight: 20, ), onTap: () => onSelectCommand(command), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/throttle.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../operations/ai_writer_cubit.dart'; import 'ai_writer_gesture_detector.dart'; class AiWriterScrollWrapper extends StatefulWidget { const AiWriterScrollWrapper({ super.key, required this.viewId, required this.editorState, required this.child, }); final String viewId; final EditorState editorState; final Widget child; @override State createState() => _AiWriterScrollWrapperState(); } class _AiWriterScrollWrapperState extends State { final overlayController = OverlayPortalController(); late final throttler = Throttler(); late final aiWriterCubit = AiWriterCubit( documentId: widget.viewId, editorState: widget.editorState, onCreateNode: () { aiWriterRegistered = true; widget.editorState.service.keyboardService?.disableShortcuts(); }, onRemoveNode: () { aiWriterRegistered = false; widget.editorState.service.keyboardService?.enableShortcuts(); widget.editorState.service.keyboardService?.enable(); }, onAppendToDocument: onAppendToDocument, ); bool userHasScrolled = false; bool aiWriterRegistered = false; bool dialogShown = false; @override void initState() { super.initState(); overlayController.show(); } @override void dispose() { aiWriterCubit.close(); throttler.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: aiWriterCubit, child: NotificationListener( onNotification: handleScrollNotification, child: Focus( autofocus: true, onKeyEvent: handleKeyEvent, child: MultiBlocListener( listeners: [ BlocListener( listener: (context, state) { if (state is DocumentContentEmptyAiWriterState) { showConfirmDialog( context: context, title: LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(), description: LocaleKeys .ai_continueWritingEmptyDocumentDescription .tr(), onConfirm: (_) => state.onConfirm(), ); } }, ), BlocListener( listenWhen: (previous, current) => previous is GeneratingAiWriterState && current is ReadyAiWriterState, listener: (context, state) { widget.editorState.updateSelectionWithReason(null); }, ), ], child: OverlayPortal( controller: overlayController, overlayChildBuilder: (context) { return BlocBuilder( builder: (context, state) { return AiWriterGestureDetector( behavior: state is RegisteredAiWriter ? HitTestBehavior.translucent : HitTestBehavior.deferToChild, onPointerEvent: () => onTapOutside(context), ); }, ); }, child: widget.child, ), ), ), ), ); } bool handleScrollNotification(ScrollNotification notification) { if (!aiWriterRegistered) { return false; } if (notification is UserScrollNotification) { debounceResetUserHasScrolled(); userHasScrolled = true; throttler.cancel(); } return false; } void debounceResetUserHasScrolled() { Debounce.debounce( 'user_has_scrolled', const Duration(seconds: 3), () => userHasScrolled = false, ); } void onTapOutside(BuildContext context) { final aiWriterCubit = context.read(); if (aiWriterCubit.hasUnusedResponse()) { showConfirmDialog( context: context, title: LocaleKeys.button_discard.tr(), description: LocaleKeys.document_plugins_discardResponse.tr(), confirmLabel: LocaleKeys.button_discard.tr(), style: ConfirmPopupStyle.cancelAndOk, onConfirm: (_) => stopAndExit(), onCancel: () {}, ); } else { stopAndExit(); } } KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { if (!aiWriterRegistered) { return KeyEventResult.ignored; } if (dialogShown) { return KeyEventResult.handled; } if (event is! KeyDownEvent) { return KeyEventResult.ignored; } switch (event.logicalKey) { case LogicalKeyboardKey.escape: if (aiWriterCubit.state case GeneratingAiWriterState _) { aiWriterCubit.stopStream(); } else if (aiWriterCubit.hasUnusedResponse()) { dialogShown = true; showConfirmDialog( context: context, title: LocaleKeys.button_discard.tr(), description: LocaleKeys.document_plugins_discardResponse.tr(), confirmLabel: LocaleKeys.button_discard.tr(), style: ConfirmPopupStyle.cancelAndOk, onConfirm: (_) => stopAndExit(), onCancel: () {}, ).then((_) => dialogShown = false); } else { stopAndExit(); } return KeyEventResult.handled; case LogicalKeyboardKey.keyC when HardwareKeyboard.instance.isControlPressed: if (aiWriterCubit.state case GeneratingAiWriterState _) { aiWriterCubit.stopStream(); } return KeyEventResult.handled; default: break; } return KeyEventResult.ignored; } void onAppendToDocument() { if (!aiWriterRegistered || userHasScrolled) { return; } throttler.call(() { if (aiWriterCubit.aiWriterNode != null) { final path = aiWriterCubit.aiWriterNode!.path; if (path.isEmpty) { return; } if (path.previous.isNotEmpty) { final node = widget.editorState.getNodeAtPath(path.previous); if (node != null && node.delta != null && node.delta!.isNotEmpty) { widget.editorState.updateSelectionWithReason( Selection.collapsed( Position(path: path, offset: node.delta!.length), ), ); return; } } widget.editorState.updateSelectionWithReason( Selection.collapsed(Position(path: path)), ); } }); } void stopAndExit() { Future(() async { await aiWriterCubit.stopStream(); await aiWriterCubit.exit(); }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_suggestion_actions.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import '../operations/ai_writer_entities.dart'; class SuggestionActionBar extends StatelessWidget { const SuggestionActionBar({ super.key, required this.currentCommand, required this.hasSelection, required this.onTap, }); final AiWriterCommand currentCommand; final bool hasSelection; final void Function(SuggestionAction) onTap; @override Widget build(BuildContext context) { return SeparatedRow( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const HSpace(4.0), children: _getSuggestedActions() .map( (action) => SuggestionActionButton( action: action, onTap: () => onTap(action), ), ) .toList(), ); } List _getSuggestedActions() { if (hasSelection) { return switch (currentCommand) { AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ SuggestionAction.keep, SuggestionAction.discard, SuggestionAction.rewrite, ], AiWriterCommand.explain => [ SuggestionAction.insertBelow, SuggestionAction.tryAgain, SuggestionAction.close, ], AiWriterCommand.fixSpellingAndGrammar || AiWriterCommand.improveWriting || AiWriterCommand.makeShorter || AiWriterCommand.makeLonger => [ SuggestionAction.accept, SuggestionAction.discard, SuggestionAction.insertBelow, SuggestionAction.rewrite, ], }; } else { return switch (currentCommand) { AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [ SuggestionAction.keep, SuggestionAction.discard, SuggestionAction.rewrite, ], AiWriterCommand.explain => [ SuggestionAction.insertBelow, SuggestionAction.tryAgain, SuggestionAction.close, ], _ => [ SuggestionAction.keep, SuggestionAction.discard, SuggestionAction.rewrite, ], }; } } } class SuggestionActionButton extends StatelessWidget { const SuggestionActionButton({ super.key, required this.action, required this.onTap, }); final SuggestionAction action; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( height: 28, child: FlowyButton( text: FlowyText( action.i18n, figmaLineHeight: 20, ), leftIcon: action.buildIcon(context), iconPadding: 4.0, margin: const EdgeInsets.symmetric( horizontal: 6.0, vertical: 4.0, ), onTap: onTap, useIntrinsicWidth: true, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; const String leftAlignmentKey = 'left'; const String centerAlignmentKey = 'center'; const String rightAlignmentKey = 'right'; const String kAlignToolbarItemId = 'editor.align'; final alignToolbarItem = ToolbarItem( id: kAlignToolbarItemId, group: 4, isActive: onlyShowInTextType, builder: (context, editorState, highlightColor, _, tooltipBuilder) { final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); bool isSatisfyCondition(bool Function(Object? value) test) { return nodes.every( (n) => test(n.attributes[blockComponentAlign]), ); } bool isHighlight = false; FlowySvgData data = FlowySvgs.toolbar_align_left_s; if (isSatisfyCondition((value) => value == leftAlignmentKey)) { isHighlight = true; data = FlowySvgs.toolbar_align_left_s; } else if (isSatisfyCondition((value) => value == centerAlignmentKey)) { isHighlight = true; data = FlowySvgs.toolbar_align_center_s; } else if (isSatisfyCondition((value) => value == rightAlignmentKey)) { isHighlight = true; data = FlowySvgs.toolbar_align_right_s; } Widget child = FlowySvg( data, size: const Size.square(16), color: isHighlight ? highlightColor : Colors.white, ); child = _AlignmentButtons( child: child, onAlignChanged: (align) async { await editorState.updateNode( selection, (node) => node.copyWith( attributes: { ...node.attributes, blockComponentAlign: align, }, ), ); }, ); if (tooltipBuilder != null) { child = tooltipBuilder( context, kAlignToolbarItemId, LocaleKeys.document_plugins_optionAction_align.tr(), child, ); } return child; }, ); class _AlignmentButtons extends StatefulWidget { const _AlignmentButtons({ required this.child, required this.onAlignChanged, }); final Widget child; final Function(String align) onAlignChanged; @override State<_AlignmentButtons> createState() => _AlignmentButtonsState(); } class _AlignmentButtonsState extends State<_AlignmentButtons> { final controller = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( windowPadding: const EdgeInsets.all(0), margin: const EdgeInsets.symmetric(vertical: 2.0), direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 10), decorationColor: Theme.of(context).colorScheme.onTertiary, borderRadius: BorderRadius.circular(6.0), popupBuilder: (_) { keepEditorFocusNotifier.increase(); return _AlignButtons(onAlignChanged: widget.onAlignChanged); }, onClose: () { keepEditorFocusNotifier.decrease(); }, child: FlowyButton( useIntrinsicWidth: true, text: widget.child, hoverColor: Colors.grey.withValues(alpha: 0.3), onTap: () => controller.show(), ), ); } } class _AlignButtons extends StatelessWidget { const _AlignButtons({ required this.onAlignChanged, }); final Function(String align) onAlignChanged; @override Widget build(BuildContext context) { return SizedBox( height: 28, child: Row( mainAxisSize: MainAxisSize.min, children: [ const HSpace(4), _AlignButton( icon: FlowySvgs.toolbar_align_left_s, tooltips: LocaleKeys.document_plugins_optionAction_left.tr(), onTap: () => onAlignChanged(leftAlignmentKey), ), const _Divider(), _AlignButton( icon: FlowySvgs.toolbar_align_center_s, tooltips: LocaleKeys.document_plugins_optionAction_center.tr(), onTap: () => onAlignChanged(centerAlignmentKey), ), const _Divider(), _AlignButton( icon: FlowySvgs.toolbar_align_right_s, tooltips: LocaleKeys.document_plugins_optionAction_right.tr(), onTap: () => onAlignChanged(rightAlignmentKey), ), const HSpace(4), ], ), ); } } class _AlignButton extends StatelessWidget { const _AlignButton({ required this.icon, required this.tooltips, required this.onTap, }); final FlowySvgData icon; final String tooltips; final VoidCallback onTap; @override Widget build(BuildContext context) { return FlowyButton( useIntrinsicWidth: true, hoverColor: Colors.grey.withValues(alpha: 0.3), onTap: onTap, text: FlowyTooltip( message: tooltips, child: FlowySvg( icon, size: const Size.square(16), color: Colors.white, ), ), ); } } class _Divider extends StatelessWidget { const _Divider(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(4), child: Container( width: 1, color: Colors.grey, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; final List customTextAlignCommands = [ customTextLeftAlignCommand, customTextCenterAlignCommand, customTextRightAlignCommand, ]; /// Windows / Linux : ctrl + shift + l /// macOS : ctrl + shift + l /// Allows the user to align text to the left /// /// - support /// - desktop /// - web /// final CommandShortcutEvent customTextLeftAlignCommand = CommandShortcutEvent( key: 'Align text to the left', command: 'ctrl+shift+l', getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignLeft.tr, handler: (editorState) => _textAlignHandler(editorState, leftAlignmentKey), ); /// Windows / Linux : ctrl + shift + c /// macOS : ctrl + shift + c /// Allows the user to align text to the center /// /// - support /// - desktop /// - web /// final CommandShortcutEvent customTextCenterAlignCommand = CommandShortcutEvent( key: 'Align text to the center', command: 'ctrl+shift+c', getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignCenter.tr, handler: (editorState) => _textAlignHandler(editorState, centerAlignmentKey), ); /// Windows / Linux : ctrl + shift + r /// macOS : ctrl + shift + r /// Allows the user to align text to the right /// /// - support /// - desktop /// - web /// final CommandShortcutEvent customTextRightAlignCommand = CommandShortcutEvent( key: 'Align text to the right', command: 'ctrl+shift+r', getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignRight.tr, handler: (editorState) => _textAlignHandler(editorState, rightAlignmentKey), ); KeyEventResult _textAlignHandler(EditorState editorState, String align) { final Selection? selection = editorState.selection; if (selection == null) { return KeyEventResult.ignored; } editorState.updateNode( selection, (node) => node.copyWith( attributes: { ...node.attributes, blockComponentAlign: align, }, ), ); return KeyEventResult.handled; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart ================================================ import 'package:flowy_infra/theme_extension.dart'; // DON'T MODIFY THIS KEY BECAUSE IT'S SAVED IN THE DATABASE! // Used for the block component background color const themeBackgroundColors = { 'appflowy_them_color_tint1': FlowyTint.tint1, 'appflowy_them_color_tint2': FlowyTint.tint2, 'appflowy_them_color_tint3': FlowyTint.tint3, 'appflowy_them_color_tint4': FlowyTint.tint4, 'appflowy_them_color_tint5': FlowyTint.tint5, 'appflowy_them_color_tint6': FlowyTint.tint6, 'appflowy_them_color_tint7': FlowyTint.tint7, 'appflowy_them_color_tint8': FlowyTint.tint8, 'appflowy_them_color_tint9': FlowyTint.tint9, }; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/backtick_character_command.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; /// ``` to code block /// /// - support /// - desktop /// - mobile /// - web /// final CharacterShortcutEvent formatBacktickToCodeBlock = CharacterShortcutEvent( key: '``` to code block', character: '`', handler: (editorState) async => _convertBacktickToCodeBlock( editorState: editorState, ), ); Future _convertBacktickToCodeBlock({ required EditorState editorState, }) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return false; } final node = editorState.getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null || delta.isEmpty) { return false; } // only active when the backtick is at the beginning of the line final keyword = '``'; final plainText = delta.toPlainText(); if (!plainText.startsWith(keyword)) { return false; } final transaction = editorState.transaction; final deltaWithoutKeyword = delta.compose(Delta()..delete(keyword.length)); transaction.insertNodes( selection.end.path, [ codeBlockNode( delta: deltaWithoutKeyword, ), if (node.children.isNotEmpty) ...node.children.map((e) => e.copyWith()), ], ); transaction.deleteNode(node); transaction.afterSelection = Selection.collapsed( Position(path: selection.start.path), ); await editorState.apply(transaction); return true; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/build_context_extension.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; extension BuildContextExtension on BuildContext { /// returns a boolean value indicating whether the given offset is contained within the bounds of the specified RenderBox or not. bool isOffsetInside(Offset offset) { final box = findRenderObject() as RenderBox?; if (box == null) { return false; } final result = BoxHitTestResult(); box.hitTest(result, position: box.globalToLocal(offset)); return result.path.any((entry) => entry.target == box); } double get appBarHeight => AppBarTheme.of(this).toolbarHeight ?? kToolbarHeight; double get statusBarHeight => statusBarAndAppBarHeight - appBarHeight; double get statusBarAndAppBarHeight => MediaQuery.of(this).padding.top; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart ================================================ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class BuiltInPageWidget extends StatefulWidget { const BuiltInPageWidget({ super.key, required this.node, required this.editorState, required this.builder, }); final Node node; final EditorState editorState; final Widget Function(ViewPB viewPB) builder; @override State createState() => _BuiltInPageWidgetState(); } class _BuiltInPageWidgetState extends State { late Future> future; final focusNode = FocusNode(); String get parentViewId => widget.node.attributes[DatabaseBlockKeys.parentID]; String get childViewId => widget.node.attributes[DatabaseBlockKeys.viewID]; @override void initState() { super.initState(); future = ViewBackendService().getChildView( parentViewId: parentViewId, childViewId: childViewId, ); } @override void dispose() { focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FutureBuilder>( builder: (context, snapshot) { final page = snapshot.data?.toNullable(); if (snapshot.hasData && page != null) { return _build(context, page); } if (snapshot.connectionState == ConnectionState.done) { // Delete the page if not found WidgetsBinding.instance.addPostFrameCallback((_) => _deletePage()); return const Center(child: FlowyText('Cannot load the page')); } return const Center(child: CircularProgressIndicator()); }, future: future, ); } Widget _build(BuildContext context, ViewPB viewPB) { return MouseRegion( onEnter: (_) => widget.editorState.service.scrollService?.disable(), onExit: (_) => widget.editorState.service.scrollService?.enable(), child: _buildPage(context, viewPB), ); } Widget _buildPage(BuildContext context, ViewPB view) { final verticalPadding = context.read()?.verticalPadding ?? 0.0; return Focus( focusNode: focusNode, onFocusChange: (value) { if (value) { widget.editorState.service.selectionService.clearSelection(); } }, child: Padding( padding: EdgeInsets.symmetric(vertical: verticalPadding), child: widget.builder(view), ), ); } Future _deletePage() async { final transaction = widget.editorState.transaction; transaction.deleteNode(widget.node); await widget.editorState.apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/cover_title_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// Press the backspace at the first position of first line to go to the title /// /// - support /// - desktop /// - web /// final CommandShortcutEvent backspaceToTitle = CommandShortcutEvent( key: 'backspace to title', command: 'backspace', getDescription: () => 'backspace to title', handler: (editorState) => _backspaceToTitle( editorState: editorState, ), ); KeyEventResult _backspaceToTitle({ required EditorState editorState, }) { final coverTitleFocusNode = editorState.document.root.context ?.read() ?.coverTitleFocusNode; if (coverTitleFocusNode == null) { return KeyEventResult.ignored; } final selection = editorState.selection; // only active when the backspace is at the first position of first line if (selection == null || !selection.isCollapsed || !selection.start.path.equals([0]) || selection.start.offset != 0) { return KeyEventResult.ignored; } final node = editorState.getNodeAtPath(selection.end.path); if (node == null || node.type != ParagraphBlockKeys.type) { return KeyEventResult.ignored; } // delete the first line () async { // only delete the first line if it is empty if (node.delta == null || node.delta!.isEmpty) { final transaction = editorState.transaction; transaction.deleteNode(node); transaction.afterSelection = null; await editorState.apply(transaction); } editorState.selection = null; coverTitleFocusNode.requestFocus(); }(); return KeyEventResult.handled; } /// Press the arrow left at the first position of first line to go to the title /// /// - support /// - desktop /// - web /// final CommandShortcutEvent arrowLeftToTitle = CommandShortcutEvent( key: 'arrow left to title', command: 'arrow left', getDescription: () => 'arrow left to title', handler: (editorState) => _arrowKeyToTitle( editorState: editorState, checkSelection: (selection) { if (!selection.isCollapsed || !selection.start.path.equals([0]) || selection.start.offset != 0) { return false; } return true; }, ), ); /// Press the arrow up at the first line to go to the title /// /// - support /// - desktop /// - web /// final CommandShortcutEvent arrowUpToTitle = CommandShortcutEvent( key: 'arrow up to title', command: 'arrow up', getDescription: () => 'arrow up to title', handler: (editorState) => _arrowKeyToTitle( editorState: editorState, checkSelection: (selection) { if (!selection.isCollapsed || !selection.start.path.equals([0])) { return false; } return true; }, ), ); KeyEventResult _arrowKeyToTitle({ required EditorState editorState, required bool Function(Selection selection) checkSelection, }) { final coverTitleFocusNode = editorState.document.root.context ?.read() ?.coverTitleFocusNode; if (coverTitleFocusNode == null) { return KeyEventResult.ignored; } final selection = editorState.selection; // only active when the arrow up is at the first line if (selection == null || !checkSelection(selection)) { return KeyEventResult.ignored; } final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return KeyEventResult.ignored; } editorState.selection = null; coverTitleFocusNode.requestFocus(); return KeyEventResult.handled; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart ================================================ import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; class EmojiPickerButton extends StatelessWidget { EmojiPickerButton({ super.key, required this.emoji, required this.onSubmitted, this.emojiPickerSize = const Size(360, 380), this.emojiSize = 18.0, this.defaultIcon, this.offset, this.direction, this.title, this.showBorder = true, this.enable = true, this.margin, this.buttonSize, this.documentId, this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); final EmojiIconData emoji; final double emojiSize; final Size emojiPickerSize; final void Function( SelectedEmojiIconResult result, PopoverController? controller, ) onSubmitted; final PopoverController popoverController = PopoverController(); final Widget? defaultIcon; final Offset? offset; final PopoverDirection? direction; final String? title; final bool showBorder; final bool enable; final EdgeInsets? margin; final Size? buttonSize; final String? documentId; final List tabs; @override Widget build(BuildContext context) { if (UniversalPlatform.isDesktopOrWeb) { return _DesktopEmojiPickerButton( emoji: emoji, onSubmitted: onSubmitted, emojiPickerSize: emojiPickerSize, emojiSize: emojiSize, defaultIcon: defaultIcon, offset: offset, direction: direction, title: title, showBorder: showBorder, enable: enable, buttonSize: buttonSize, tabs: tabs, documentId: documentId, ); } return _MobileEmojiPickerButton( emoji: emoji, onSubmitted: onSubmitted, emojiSize: emojiSize, enable: enable, title: title, margin: margin, tabs: tabs, documentId: documentId, ); } } class _DesktopEmojiPickerButton extends StatelessWidget { _DesktopEmojiPickerButton({ required this.emoji, required this.onSubmitted, this.emojiPickerSize = const Size(360, 380), this.emojiSize = 18.0, this.defaultIcon, this.offset, this.direction, this.title, this.showBorder = true, this.enable = true, this.buttonSize, this.documentId, this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); final EmojiIconData emoji; final double emojiSize; final Size emojiPickerSize; final void Function( SelectedEmojiIconResult result, PopoverController? controller, ) onSubmitted; final PopoverController popoverController = PopoverController(); final Widget? defaultIcon; final Offset? offset; final PopoverDirection? direction; final String? title; final bool showBorder; final bool enable; final Size? buttonSize; final String? documentId; final List tabs; @override Widget build(BuildContext context) { final showDefault = emoji.isEmpty && defaultIcon != null; return AppFlowyPopover( controller: popoverController, constraints: BoxConstraints.expand( width: emojiPickerSize.width, height: emojiPickerSize.height, ), offset: offset, margin: EdgeInsets.zero, direction: direction ?? PopoverDirection.rightWithTopAligned, popupBuilder: (_) => Container( width: emojiPickerSize.width, height: emojiPickerSize.height, padding: const EdgeInsets.all(4.0), child: FlowyIconEmojiPicker( initialType: emoji.type.toPickerTabType(), tabs: tabs, documentId: documentId, onSelectedEmoji: (r) { onSubmitted(r, popoverController); }, ), ), child: Container( width: buttonSize?.width ?? 30.0, height: buttonSize?.height ?? 30.0, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: showBorder ? Border.all( color: Theme.of(context).dividerColor, ) : null, ), child: FlowyButton( margin: emoji.isEmpty && defaultIcon != null ? EdgeInsets.zero : const EdgeInsets.only(left: 2.0), expandText: false, text: showDefault ? defaultIcon! : RawEmojiIconWidget(emoji: emoji, emojiSize: emojiSize), onTap: enable ? popoverController.show : null, ), ), ); } } class _MobileEmojiPickerButton extends StatelessWidget { const _MobileEmojiPickerButton({ required this.emoji, required this.onSubmitted, this.emojiSize = 18.0, this.enable = true, this.title, this.margin, this.documentId, this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); final EmojiIconData emoji; final double emojiSize; final void Function( SelectedEmojiIconResult result, PopoverController? controller, ) onSubmitted; final String? title; final bool enable; final EdgeInsets? margin; final String? documentId; final List tabs; @override Widget build(BuildContext context) { return FlowyButton( useIntrinsicWidth: true, margin: margin ?? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), text: RawEmojiIconWidget( emoji: emoji, emojiSize: emojiSize, ), onTap: enable ? () async { final result = await context.push( Uri( path: MobileEmojiPickerScreen.routeName, queryParameters: { MobileEmojiPickerScreen.pageTitle: title, MobileEmojiPickerScreen.iconSelectedType: emoji.type.name, MobileEmojiPickerScreen.uploadDocumentId: documentId, MobileEmojiPickerScreen.selectTabs: tabs.map((e) => e.name).toList().join('-'), }, ).toString(), ); if (result != null) { onSubmitted(result.toSelectedResult(), null); } } : null, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/font_colors.dart ================================================ import 'package:flutter/material.dart'; class EditorFontColors { static final lightColors = [ const Color(0x00FFFFFF), const Color(0xFFE8E0FF), const Color(0xFFFFE6FD), const Color(0xFFFFDAE6), const Color(0xFFFFEFE3), const Color(0xFFF5FFDC), const Color(0xFFDDFFD6), const Color(0xFFDEFFF1), const Color(0xFFE1FBFF), const Color(0xFFFFADAD), const Color(0xFFFFE088), const Color(0xFFA7DF4A), const Color(0xFFD4C0FF), const Color(0xFFFDB2FE), const Color(0xFFFFD18B), const Color(0xFF65E7F0), const Color(0xFF71E6B4), const Color(0xFF80F1FF), ]; static final darkColors = [ const Color(0x00FFFFFF), const Color(0xFF8B80AD), const Color(0xFF987195), const Color(0xFF906D78), const Color(0xFFA68B77), const Color(0xFF88936D), const Color(0xFF72936B), const Color(0xFF6B9483), const Color(0xFF658B90), const Color(0xFF95405A), const Color(0xFFA6784D), const Color(0xFF6E9234), const Color(0xFF6455A2), const Color(0xFF924F83), const Color(0xFFA48F34), const Color(0xFF29A3AC), const Color(0xFF2E9F84), const Color(0xFF405EA6), ]; // if the input color doesn't exist in the list, return the input color itself. static Color? fromBuiltInColors(BuildContext context, Color? color) { if (color == null) { return null; } final brightness = Theme.of(context).brightness; // if the dark mode color using light mode, return it's corresponding light color. Same for light mode. if (brightness == Brightness.light) { if (darkColors.contains(color)) { return lightColors[darkColors.indexOf(color)]; } } else { if (lightColors.contains(color)) { return darkColors[lightColors.indexOf(color)]; } } return color; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; const _greater = '>'; const _dash = '-'; const _equals = '='; const _equalGreater = '⇒'; const _dashGreater = '→'; const _hyphen = '-'; const _emDash = '—'; // This is an em dash — not a single dash - !! /// format '=' + '>' into an ⇒ /// /// - support /// - desktop /// - mobile /// - web /// final CharacterShortcutEvent customFormatGreaterEqual = CharacterShortcutEvent( key: 'format = + > into ⇒', character: _greater, handler: (editorState) async => _handleDoubleCharacterReplacement( editorState: editorState, character: _greater, replacement: _equalGreater, prefixCharacter: _equals, ), ); /// format '-' + '>' into ⇒ /// /// - support /// - desktop /// - mobile /// - web /// final CharacterShortcutEvent customFormatDashGreater = CharacterShortcutEvent( key: 'format - + > into ->', character: _greater, handler: (editorState) async => _handleDoubleCharacterReplacement( editorState: editorState, character: _greater, replacement: _dashGreater, prefixCharacter: _dash, ), ); /// format two hyphens into an em dash /// /// - support /// - desktop /// - mobile /// - web /// final CharacterShortcutEvent customFormatDoubleHyphenEmDash = CharacterShortcutEvent( key: 'format double hyphen into an em dash', character: _hyphen, handler: (editorState) async => _handleDoubleCharacterReplacement( editorState: editorState, character: _hyphen, replacement: _emDash, ), ); /// If [prefixCharacter] is null or empty, [character] is used Future _handleDoubleCharacterReplacement({ required EditorState editorState, required String character, required String replacement, String? prefixCharacter, }) async { assert(character.length == 1); final selection = editorState.selection; if (selection == null) { return false; } if (!selection.isCollapsed) { await editorState.deleteSelection(selection); } final node = editorState.getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null || delta.isEmpty || node.type == CodeBlockKeys.type) { return false; } if (selection.end.offset > 0) { final plain = delta.toPlainText(); final expectedPrevious = prefixCharacter?.isEmpty ?? true ? character : prefixCharacter; final previousCharacter = plain[selection.end.offset - 1]; if (previousCharacter != expectedPrevious) { return false; } // insert the greater character first and convert it to the replacement character to support undo final insert = editorState.transaction ..insertText( node, selection.end.offset, character, ); await editorState.apply( insert, skipHistoryDebounce: true, ); final afterSelection = editorState.selection; if (afterSelection == null) { return false; } final replace = editorState.transaction ..replaceText( node, afterSelection.end.offset - 2, 2, replacement, ); await editorState.apply(replace); return true; } return false; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/domain/database_view_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; extension InsertDatabase on EditorState { Future insertInlinePage(String parentViewId, ViewPB childView) async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { return; } final node = getNodeAtPath(selection.end.path); if (node == null) { return; } final transaction = this.transaction; transaction.insertNode( selection.end.path, Node( type: _convertPageType(childView), attributes: { DatabaseBlockKeys.parentID: parentViewId, DatabaseBlockKeys.viewID: childView.id, }, ), ); await apply(transaction); } Future insertReferencePage( ViewPB childView, ViewLayoutPB viewType, ) async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { throw FlowyError( msg: "Could not insert the reference page because the current selection was null or collapsed.", ); } final node = getNodeAtPath(selection.end.path); if (node == null) { throw FlowyError( msg: "Could not insert the reference page because the current node at the selection does not exist.", ); } final Transaction transaction = viewType == ViewLayoutPB.Document ? await _insertDocumentReference(childView, selection, node) : await _insertDatabaseReference(childView, selection.end.path); await apply(transaction); } Future _insertDocumentReference( ViewPB view, Selection selection, Node node, ) async { return transaction ..replaceText( node, selection.end.offset, 0, MentionBlockKeys.mentionChar, attributes: MentionBlockKeys.buildMentionPageAttributes( mentionType: MentionType.page, pageId: view.id, blockId: null, ), ); } Future _insertDatabaseReference( ViewPB view, List path, ) async { // get the database id that the view is associated with final databaseId = await DatabaseViewBackendService(viewId: view.id) .getDatabaseId() .then((value) => value.toNullable()); if (databaseId == null) { throw StateError( 'The database associated with ${view.id} could not be found while attempting to create a referenced ${view.layout.name}.', ); } final prefix = _referencedDatabasePrefix(view.layout); final ref = await ViewBackendService.createDatabaseLinkedView( parentViewId: view.id, name: "$prefix ${view.nameOrDefault}", layoutType: view.layout, databaseId: databaseId, ).then((value) => value.toNullable()); if (ref == null) { throw FlowyError( msg: "The `ViewBackendService` failed to create a database reference view", ); } return transaction ..insertNode( path, Node( type: _convertPageType(view), attributes: { DatabaseBlockKeys.parentID: view.id, DatabaseBlockKeys.viewID: ref.id, }, ), ); } String _referencedDatabasePrefix(ViewLayoutPB layout) { switch (layout) { case ViewLayoutPB.Grid: return LocaleKeys.grid_referencedGridPrefix.tr(); case ViewLayoutPB.Board: return LocaleKeys.board_referencedBoardPrefix.tr(); case ViewLayoutPB.Calendar: return LocaleKeys.calendar_referencedCalendarPrefix.tr(); default: throw UnimplementedError(); } } String _convertPageType(ViewPB viewPB) { switch (viewPB.layout) { case ViewLayoutPB.Grid: return DatabaseBlockKeys.gridType; case ViewLayoutPB.Board: return DatabaseBlockKeys.boardType; case ViewLayoutPB.Calendar: return DatabaseBlockKeys.calendarType; default: throw Exception('Unknown layout type'); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; InlineActionsMenuService? _actionsMenuService; Future showLinkToPageMenu( EditorState editorState, SelectionMenuService menuService, { ViewLayoutPB? pageType, bool? insertPage, }) async { keepEditorFocusNotifier.increase(); menuService.dismiss(); _actionsMenuService?.dismiss(); final rootContext = editorState.document.root.context; if (rootContext == null) { return; } final service = InlineActionsService( context: rootContext, handlers: [ InlinePageReferenceService( currentViewId: '', viewLayout: pageType, customTitle: titleFromPageType(pageType), insertPage: insertPage ?? pageType != ViewLayoutPB.Document, limitResults: 15, ), ], ); final List initialResults = []; for (final handler in service.handlers) { final group = await handler.search(null); if (group.results.isNotEmpty) { initialResults.add(group); } } if (rootContext.mounted) { _actionsMenuService = InlineActionsMenu( context: rootContext, editorState: editorState, service: service, initialResults: initialResults, style: Theme.of(editorState.document.root.context!).brightness == Brightness.light ? const InlineActionsMenuStyle.light() : const InlineActionsMenuStyle.dark(), startCharAmount: 0, ); await _actionsMenuService?.show(); } } String titleFromPageType(ViewLayoutPB? layout) => switch (layout) { ViewLayoutPB.Grid => LocaleKeys.inlineActions_gridReference.tr(), ViewLayoutPB.Document => LocaleKeys.inlineActions_docReference.tr(), ViewLayoutPB.Board => LocaleKeys.inlineActions_boardReference.tr(), ViewLayoutPB.Calendar => LocaleKeys.inlineActions_calReference.tr(), _ => LocaleKeys.inlineActions_pageReference.tr(), }; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:synchronized/synchronized.dart'; const _enableDebug = false; class MarkdownTextRobot { MarkdownTextRobot({ required this.editorState, }); final EditorState editorState; final Lock _lock = Lock(); /// The text position where new nodes will be inserted Position? _insertPosition; /// The markdown text to be inserted String _markdownText = ''; /// The nodes inserted in the previous refresh. Iterable _insertedNodes = []; /// Only for debug via [_enableDebug]. final List _debugMarkdownTexts = []; /// Selection before the refresh. Selection? _previousSelection; bool get hasAnyResult => _markdownText.isNotEmpty; String get markdownText => _markdownText; Selection? getInsertedSelection() { final position = _insertPosition; if (position == null) { Log.error("Expected non-null insert markdown text position"); return null; } if (_insertedNodes.isEmpty) { return Selection.collapsed(position); } return Selection( start: position, end: Position( path: position.path.nextNPath(_insertedNodes.length - 1), ), ); } List getInsertedNodes() { final selection = getInsertedSelection(); return selection == null ? [] : editorState.getNodesInSelection(selection); } void start({ Selection? previousSelection, Position? position, }) { _insertPosition = position ?? editorState.selection?.start; _previousSelection = previousSelection ?? editorState.selection; if (_enableDebug) { Log.info( 'MarkdownTextRobot start with insert text position: $_insertPosition', ); } } /// The text will be inserted into the document but only in memory Future appendMarkdownText( String text, { bool updateSelection = true, Map? attributes, }) async { _markdownText += text; await _lock.synchronized(() async { await _refresh( inMemoryUpdate: true, updateSelection: updateSelection, attributes: attributes, ); }); if (_enableDebug) { _debugMarkdownTexts.add(text); Log.info( 'MarkdownTextRobot receive markdown: ${jsonEncode(_debugMarkdownTexts)}', ); } } Future stop({ Map? attributes, }) async { await _lock.synchronized(() async { await _refresh( inMemoryUpdate: true, attributes: attributes, ); }); } /// Persist the text into the document Future persist({ String? markdownText, }) async { if (markdownText != null) { _markdownText = markdownText; } await _lock.synchronized(() async { await _refresh(inMemoryUpdate: false, updateSelection: true); }); if (_enableDebug) { Log.info('MarkdownTextRobot stop'); _debugMarkdownTexts.clear(); } } /// Replace the selected content with the AI's response Future replace({ required Selection selection, required String markdownText, }) async { if (selection.isSingle) { await _replaceInSameLine( selection: selection, markdownText: markdownText, ); } else { await _replaceInMultiLines( selection: selection, markdownText: markdownText, ); } } /// Delete the temporary inserted AI nodes Future deleteAINodes() async { final nodes = getInsertedNodes(); final transaction = editorState.transaction..deleteNodes(nodes); await editorState.apply( transaction, options: const ApplyOptions(recordUndo: false), ); } /// Discard the inserted content Future discard({ Selection? afterSelection, }) async { final start = _insertPosition; if (start == null) { return; } if (_insertedNodes.isEmpty) { return; } afterSelection ??= Selection.collapsed(start); // fallback to the calculated position if the selection is null. final end = Position( path: start.path.nextNPath(_insertedNodes.length - 1), ); final deletedNodes = editorState.getNodesInSelection( Selection(start: start, end: end), ); final transaction = editorState.transaction ..deleteNodes(deletedNodes) ..afterSelection = afterSelection; await editorState.apply( transaction, options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true), ); if (_enableDebug) { Log.info('MarkdownTextRobot discard'); } } void clear() { _markdownText = ''; _insertedNodes = []; } void reset() { _insertPosition = null; } Future _refresh({ required bool inMemoryUpdate, bool updateSelection = false, Map? attributes, }) async { final position = _insertPosition; if (position == null) { Log.error("Expected non-null insert markdown text position"); return; } // Convert markdown and deep copy the nodes, prevent ing the linked // entities from being changed final documentNodes = customMarkdownToDocument( _markdownText, tableWidth: 250.0, ).root.children; // check if the first selected node before the refresh is a numbered list node final previousSelection = _previousSelection; final previousSelectedNode = previousSelection == null ? null : editorState.getNodeAtPath(previousSelection.start.path); final firstNodeIsNumberedList = previousSelectedNode != null && previousSelectedNode.type == NumberedListBlockKeys.type; final newNodes = attributes == null ? documentNodes : documentNodes.mapIndexed((index, node) { final n = _styleDelta(node: node, attributes: attributes); n.externalValues = AINodeExternalValues( isAINode: true, ); if (index == 0 && n.type == NumberedListBlockKeys.type) { if (firstNodeIsNumberedList) { final builder = NumberedListIndexBuilder( editorState: editorState, node: previousSelectedNode, ); final firstIndex = builder.indexInSameLevel; n.updateAttributes({ NumberedListBlockKeys.number: firstIndex, }); } n.externalValues = AINodeExternalValues( isAINode: true, isFirstNumberedListNode: true, ); } return n; }).toList(); if (newNodes.isEmpty) { return; } final deleteTransaction = editorState.transaction ..deleteNodes(getInsertedNodes()); await editorState.apply( deleteTransaction, options: ApplyOptions( inMemoryUpdate: inMemoryUpdate, recordUndo: false, ), ); final insertTransaction = editorState.transaction ..insertNodes(position.path, newNodes); final lastDelta = newNodes.lastOrNull?.delta; if (lastDelta != null) { insertTransaction.afterSelection = Selection.collapsed( Position( path: position.path.nextNPath(newNodes.length - 1), offset: lastDelta.length, ), ); } await editorState.apply( insertTransaction, options: ApplyOptions( inMemoryUpdate: inMemoryUpdate, recordUndo: !inMemoryUpdate, ), withUpdateSelection: updateSelection, ); _insertedNodes = newNodes; } Node _styleDelta({ required Node node, required Map attributes, }) { if (node.delta != null) { final delta = node.delta!; final attributeDelta = Delta() ..retain(delta.length, attributes: attributes); final newDelta = delta.compose(attributeDelta); final newAttributes = node.attributes; newAttributes['delta'] = newDelta.toJson(); node.updateAttributes(newAttributes); } List? children; if (node.children.isNotEmpty) { children = node.children .map((child) => _styleDelta(node: child, attributes: attributes)) .toList(); } return node.copyWith( children: children, ); } /// If the selected content is in the same line, /// keep the selected node and replace the delta. Future _replaceInSameLine({ required Selection selection, required String markdownText, }) async { if (markdownText.isEmpty) { assert(false, 'Expected non-empty markdown text'); Log.error('Expected non-empty markdown text'); return; } selection = selection.normalized; // If the selection is not a single node, do nothing. if (!selection.isSingle) { assert(false, 'Expected single node selection'); Log.error('Expected single node selection'); return; } final startIndex = selection.startIndex; final endIndex = selection.endIndex; final length = endIndex - startIndex; // Get the selected node. final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || delta == null) { assert(false, 'Expected non-null node and delta'); Log.error('Expected non-null node and delta'); return; } // Convert the markdown text to delta. // Question: Why we need to convert the markdown to document first? // Answer: Because the markdown text may contain the list item, // if we convert the markdown to delta directly, the list item will be // treated as a normal text node, and the delta will be incorrect. // For example, the markdown text is: // ``` // 1. item1 // ``` // if we convert the markdown to delta directly, the delta will be: // ``` // [ // { // "insert": "1. item1" // } // ] // ``` // if we convert the markdown to document first, the document will be: // ``` // [ // { // "type": "numbered_list", // "children": [ // { // "insert": "item1" // } // ] // } // ] final document = customMarkdownToDocument(markdownText); final nodes = document.root.children; final decoder = DeltaMarkdownDecoder(); final markdownDelta = nodes.firstOrNull?.delta ?? decoder.convert(markdownText); if (markdownDelta.isEmpty) { assert(false, 'Expected non-empty markdown delta'); Log.error('Expected non-empty markdown delta'); return; } // Replace the delta of the selected node. final transaction = editorState.transaction; // it means the user selected the entire sentence, we just replace the node if (startIndex == 0 && length == node.delta?.length) { if (nodes.isNotEmpty && node.children.isNotEmpty) { // merge the children of the selected node and the first node of the ai response nodes[0] = nodes[0].copyWith( children: [ ...node.children.map((e) => e.deepCopy()), ...nodes[0].children, ], ); } transaction ..insertNodes(node.path.next, nodes) ..deleteNode(node); } else { // it means the user selected a part of the sentence, we need to delete the // selected part and insert the new delta. transaction ..deleteText(node, startIndex, length) ..insertTextDelta(node, startIndex, markdownDelta); // Add the remaining nodes to the document. final remainingNodes = nodes.skip(1); if (remainingNodes.isNotEmpty) { transaction.insertNodes( node.path.next, remainingNodes, ); } } await editorState.apply(transaction); } /// If the selected content is in multiple lines Future _replaceInMultiLines({ required Selection selection, required String markdownText, }) async { selection = selection.normalized; // If the selection is a single node, do nothing. if (selection.isSingle) { assert(false, 'Expected multi-line selection'); Log.error('Expected multi-line selection'); return; } final markdownNodes = customMarkdownToDocument( markdownText, tableWidth: 250.0, ).root.children; // Get the selected nodes. final flattenNodes = editorState.getNodesInSelection(selection); final nodes = []; for (final node in flattenNodes) { if (nodes.any((element) => element.isParentOf(node))) { continue; } nodes.add(node); } // Note: Don't change its order, otherwise the delta will be incorrect. // step 1. merge the first selected node and the first node from the ai response // step 2. merge the last selected node and the last node from the ai response // step 3. insert the middle nodes from the ai response // step 4. delete the middle nodes final transaction = editorState.transaction; // step 1 final firstNode = nodes.firstOrNull; final delta = firstNode?.delta; final firstMarkdownNode = markdownNodes.firstOrNull; final firstMarkdownDelta = firstMarkdownNode?.delta; if (firstNode != null && delta != null && firstMarkdownNode != null && firstMarkdownDelta != null) { final startIndex = selection.startIndex; final length = delta.length - startIndex; transaction ..deleteText(firstNode, startIndex, length) ..insertTextDelta(firstNode, startIndex, firstMarkdownDelta); // if the first markdown node has children, we need to insert the children // and delete the children of the first node that are in the selection. if (firstMarkdownNode.children.isNotEmpty) { transaction.insertNodes( firstNode.path.child(0), firstMarkdownNode.children.map((e) => e.deepCopy()), ); } final nodesToDelete = firstNode.children.where((e) => e.path.inSelection(selection)); transaction.deleteNodes(nodesToDelete); } // step 2 bool handledLastNode = false; final lastNode = nodes.lastOrNull; final lastDelta = lastNode?.delta; final lastMarkdownNode = markdownNodes.lastOrNull; final lastMarkdownDelta = lastMarkdownNode?.delta; if (lastNode != null && lastDelta != null && lastMarkdownNode != null && lastMarkdownDelta != null && firstNode?.id != lastNode.id) { handledLastNode = true; final endIndex = selection.endIndex; transaction.deleteText(lastNode, 0, endIndex); // if the last node is same as the first node, it means we have replaced the // selected text in the first node. if (lastMarkdownNode.id != firstMarkdownNode?.id) { transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta); if (lastMarkdownNode.children.isNotEmpty) { transaction ..insertNodes( lastNode.path.child(0), lastMarkdownNode.children.map((e) => e.deepCopy()), ) ..deleteNodes( lastNode.children.where((e) => e.path.inSelection(selection)), ); } } } // step 3 final insertedPath = selection.start.path.nextNPath(1); final insertLength = handledLastNode ? 2 : 1; if (markdownNodes.length > insertLength) { transaction.insertNodes( insertedPath, markdownNodes .skip(1) .take(markdownNodes.length - insertLength) .toList(), ); } // step 4 final length = nodes.length - 2; if (length > 0) { final middleNodes = nodes.skip(1).take(length).toList(); transaction.deleteNodes(middleNodes); } await editorState.apply(transaction); } } class AINodeExternalValues extends NodeExternalValues { const AINodeExternalValues({ this.isAINode = false, this.isFirstNumberedListNode = false, }); final bool isAINode; final bool isFirstNumberedListNode; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart ================================================ import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/handlers/child_page.dart'; import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; const _bracketChar = '['; const _plusChar = '+'; CharacterShortcutEvent pageReferenceShortcutBrackets( BuildContext context, String viewId, InlineActionsMenuStyle style, ) => CharacterShortcutEvent( key: 'show the inline page reference menu by [', character: _bracketChar, handler: (editorState) => inlinePageReferenceCommandHandler( _bracketChar, context, viewId, editorState, style, previousChar: _bracketChar, ), ); CharacterShortcutEvent pageReferenceShortcutPlusSign( BuildContext context, String viewId, InlineActionsMenuStyle style, ) => CharacterShortcutEvent( key: 'show the inline page reference menu by +', character: _plusChar, handler: (editorState) => inlinePageReferenceCommandHandler( _plusChar, context, viewId, editorState, style, ), ); InlineActionsMenuService? selectionMenuService; Future inlinePageReferenceCommandHandler( String character, BuildContext context, String currentViewId, EditorState editorState, InlineActionsMenuStyle style, { String? previousChar, }) async { final selection = editorState.selection; if (selection == null) { return false; } if (!selection.isCollapsed) { await editorState.deleteSelection(selection); } // Check for previous character if (previousChar != null) { final node = editorState.getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null || delta.isEmpty) { return false; } if (selection.end.offset > 0) { final plain = delta.toPlainText(); final previousCharacter = plain[selection.end.offset - 1]; if (previousCharacter != _bracketChar) { return false; } } } if (!context.mounted) { return false; } final service = InlineActionsService( context: context, handlers: [ if (FeatureFlag.inlineSubPageMention.isOn) InlineChildPageService(currentViewId: currentViewId), InlinePageReferenceService( currentViewId: currentViewId, limitResults: 10, ), ], ); await editorState.insertTextAtPosition(character, position: selection.start); final List initialResults = []; for (final handler in service.handlers) { final group = await handler.search(null); if (group.results.isNotEmpty) { initialResults.add(group); } } if (context.mounted) { keepEditorFocusNotifier.increase(); selectionMenuService?.dismiss(); selectionMenuService = UniversalPlatform.isMobile ? MobileInlineActionsMenu( context: service.context!, editorState: editorState, service: service, initialResults: initialResults, startCharAmount: previousChar != null ? 2 : 1, style: style, ) : InlineActionsMenu( context: service.context!, editorState: editorState, service: service, initialResults: initialResults, style: style, startCharAmount: previousChar != null ? 2 : 1, cancelBySpaceHandler: () { if (character == _plusChar) { final currentSelection = editorState.selection; if (currentSelection == null) { return false; } // check if the space is after the character if (currentSelection.isCollapsed && currentSelection.start.offset == selection.start.offset + character.length) { _cancelInlinePageReferenceMenu(editorState); return true; } } return false; }, ); // disable the keyboard service editorState.service.keyboardService?.disable(); await selectionMenuService?.show(); // enable the keyboard service editorState.service.keyboardService?.enable(); } return true; } void _cancelInlinePageReferenceMenu(EditorState editorState) { selectionMenuService?.dismiss(); selectionMenuService = null; // re-focus the selection final selection = editorState.selection; if (selection != null) { editorState.updateSelectionWithReason( selection, reason: SelectionUpdateReason.uiEvent, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart ================================================ import 'dart:math'; // ignore: implementation_imports import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SelectableItemListMenu extends StatelessWidget { const SelectableItemListMenu({ super.key, required this.items, required this.selectedIndex, required this.onSelected, this.shrinkWrap = false, this.controller, }); final List items; final int selectedIndex; final void Function(int) onSelected; final ItemScrollController? controller; /// shrinkWrapping is useful in cases where you have a list of /// limited amount of items. It will make the list take the minimum /// amount of space required to show all the items. /// final bool shrinkWrap; @override Widget build(BuildContext context) { return ScrollablePositionedList.builder( physics: const ClampingScrollPhysics(), shrinkWrap: shrinkWrap, itemCount: items.length, itemScrollController: controller, initialScrollIndex: max(0, selectedIndex), itemBuilder: (context, index) => SelectableItem( isSelected: index == selectedIndex, item: items[index], onTap: () => onSelected(index), ), ); } } class SelectableItem extends StatelessWidget { const SelectableItem({ super.key, required this.isSelected, required this.item, required this.onTap, }); final bool isSelected; final String item; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( height: 32, child: FlowyButton( text: FlowyText.medium( item, lineHeight: 1.0, ), isSelected: isSelected, onTap: onTap, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; class SelectableSvgWidget extends StatelessWidget { const SelectableSvgWidget({ super.key, required this.data, required this.isSelected, required this.style, this.size, this.padding, }); final FlowySvgData data; final bool isSelected; final SelectionMenuStyle style; final Size? size; final EdgeInsets? padding; @override Widget build(BuildContext context) { final child = FlowySvg( data, size: size ?? const Size.square(16.0), color: isSelected ? style.selectionMenuItemSelectedIconColor : style.selectionMenuItemIconColor, ); if (padding != null) { return Padding(padding: padding!, child: child); } else { return child; } } } class SelectableIconWidget extends StatelessWidget { const SelectableIconWidget({ super.key, required this.icon, required this.isSelected, required this.style, }); final IconData icon; final bool isSelected; final SelectionMenuStyle style; @override Widget build(BuildContext context) { return Icon( icon, size: 18.0, color: isSelected ? style.selectionMenuItemSelectedIconColor : style.selectionMenuItemIconColor, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/string_extension.dart ================================================ extension Capitalize on String { String capitalize() { if (isEmpty) return this; return "${this[0].toUpperCase()}${substring(1)}"; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/text_robot.dart ================================================ import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:synchronized/synchronized.dart'; enum TextRobotInputType { character, word, sentence, } class TextRobot { TextRobot({ required this.editorState, }); final EditorState editorState; final Lock lock = Lock(); /// This function is used to insert text in a synchronized way /// /// It is suitable for inserting text in a loop. Future autoInsertTextSync( String text, { TextRobotInputType inputType = TextRobotInputType.word, Duration delay = const Duration(milliseconds: 10), String separator = '\n', }) async { await lock.synchronized(() async { await autoInsertText( text, inputType: inputType, delay: delay, separator: separator, ); }); } /// This function is used to insert text in an asynchronous way /// /// It is suitable for inserting a long paragraph or a long sentence. Future autoInsertText( String text, { TextRobotInputType inputType = TextRobotInputType.word, Duration delay = const Duration(milliseconds: 10), String separator = '\n', }) async { if (text == separator) { await insertNewParagraph(delay); return; } final lines = _splitText(text, separator); for (final line in lines) { if (line.isEmpty) { await insertNewParagraph(delay); continue; } switch (inputType) { case TextRobotInputType.character: await insertCharacter(line, delay); break; case TextRobotInputType.word: await insertWord(line, delay); break; case TextRobotInputType.sentence: await insertSentence(line, delay); break; } } } Future insertCharacter(String line, Duration delay) async { final iterator = line.runes.iterator; while (iterator.moveNext()) { await insertText(iterator.currentAsString, delay); } } Future insertWord(String line, Duration delay) async { final words = line.split(' '); if (words.length == 1 || (words.length == 2 && (words.first.isEmpty || words.last.isEmpty))) { await insertText(line, delay); } else { for (final word in words.map((e) => '$e ')) { await insertText(word, delay); } } await Future.delayed(delay); } Future insertSentence(String line, Duration delay) async { await insertText(line, delay); } Future insertNewParagraph(Duration delay) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final next = selection.end.path.next; final transaction = editorState.transaction; transaction.insertNode( next, paragraphNode(), ); transaction.afterSelection = Selection.collapsed( Position(path: next), ); await editorState.apply(transaction); await Future.delayed(const Duration(milliseconds: 10)); } Future insertText(String text, Duration delay) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return; } final transaction = editorState.transaction; transaction.insertText(node, selection.endIndex, text); await editorState.apply(transaction); await Future.delayed(delay); } } List _splitText(String text, String separator) { final parts = text.split(RegExp(separator)); final result = []; for (int i = 0; i < parts.length; i++) { result.add(parts[i]); // Only add empty string if it's not the last part and the next part is not empty if (i < parts.length - 1 && parts[i + 1].isNotEmpty) { result.add(''); } } return result; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart ================================================ import 'dart:ui'; import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; bool _isTableType(String type) { return [TableBlockKeys.type, SimpleTableBlockKeys.type].contains(type); } bool notShowInTable(EditorState editorState) { final selection = editorState.selection; if (selection == null) { return false; } final nodes = editorState.getNodesInSelection(selection); return nodes.every((element) { if (_isTableType(element.type)) { return false; } var parent = element.parent; while (parent != null) { if (_isTableType(parent.type)) { return false; } parent = parent.parent; } return true; }); } bool onlyShowInSingleTextTypeSelectionAndExcludeTable( EditorState editorState, ) { return onlyShowInSingleSelectionAndTextType(editorState) && notShowInTable(editorState); } bool enableSuggestions(EditorState editorState) { final selection = editorState.selection; if (selection == null || !selection.isSingle) { return false; } final node = editorState.getNodeAtPath(selection.start.path); if (node == null) { return false; } if (isNarrowWindow(editorState)) return false; return (node.delta != null && suggestionsItemTypes.contains(node.type)) && notShowInTable(editorState); } bool isNarrowWindow(EditorState editorState) { final editorSize = editorState.renderBox?.size ?? Size.zero; if (editorSize.width < 650) return true; return false; } final Set suggestionsItemTypes = { ...toolbarItemWhiteList, ToggleListBlockKeys.type, TodoListBlockKeys.type, CalloutBlockKeys.type, }; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MenuBlockButton extends StatelessWidget { const MenuBlockButton({ super.key, required this.tooltip, required this.iconData, this.onTap, }); final VoidCallback? onTap; final String tooltip; final FlowySvgData iconData; @override Widget build(BuildContext context) { return FlowyButton( useIntrinsicWidth: true, onTap: onTap, text: FlowyTooltip( message: tooltip, child: FlowySvg( iconData, size: const Size.square(16), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; /// A handler for transactions that involve a Block Component. /// abstract class BlockTransactionHandler { const BlockTransactionHandler({required this.blockType}); /// The type of the block that this handler is built for. /// It's used to determine whether to call any of the handlers on certain transactions. /// final String blockType; Future onTransaction( BuildContext context, EditorState editorState, List added, List removed, { bool isUndoRedo = false, bool isPaste = false, bool isDraggingNode = false, String? parentViewId, }); void onUndo( BuildContext context, EditorState editorState, List before, List after, ); void onRedo( BuildContext context, EditorState editorState, List before, List after, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class BulletedListIcon extends StatefulWidget { const BulletedListIcon({ super.key, required this.node, }); final Node node; static final bulletedListIcons = [ FlowySvgs.bulleted_list_icon_1_s, FlowySvgs.bulleted_list_icon_2_s, FlowySvgs.bulleted_list_icon_3_s, ]; @override State createState() => _BulletedListIconState(); } class _BulletedListIconState extends State { int index = 0; double size = 0.0; @override void initState() { super.initState(); final textStyle = context.read().editorStyle.textStyleConfiguration; final fontSize = textStyle.text.fontSize ?? 16.0; final height = textStyle.text.height ?? textStyle.lineHeight; index = level % BulletedListIcon.bulletedListIcons.length; size = fontSize * height; } @override Widget build(BuildContext context) { final icon = FlowySvg( BulletedListIcon.bulletedListIcons[index], size: Size.square(size * 0.8), ); return Container( width: size, height: size, margin: const EdgeInsets.only(right: 8.0), alignment: Alignment.center, child: icon, ); } int get level { var level = 0; var parent = widget.node.parent; while (parent != null) { if (parent.type == BulletedListBlockKeys.type) { level++; } parent = parent.parent; } return level; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart' show LocaleKeys; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart' show StringTranslateExtension; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; import '../base/emoji_picker_button.dart'; // defining the keys of the callout block's attributes for easy access class CalloutBlockKeys { const CalloutBlockKeys._(); static const String type = 'callout'; /// The content of a code block. /// /// The value is a String. static const String delta = 'delta'; /// The background color of a callout block. /// /// The value is a String. static const String backgroundColor = blockComponentBackgroundColor; /// The emoji icon of a callout block. /// /// The value is a String. static const String icon = 'icon'; /// the type of [FlowyIconType] static const String iconType = 'icon_type'; } // The one is inserted through selection menu Node calloutNode({ Delta? delta, EmojiIconData? emoji, Color? defaultColor, }) { final defaultEmoji = emoji ?? EmojiIconData.emoji('📌'); final attributes = { CalloutBlockKeys.delta: (delta ?? Delta()).toJson(), CalloutBlockKeys.icon: defaultEmoji.emoji, CalloutBlockKeys.iconType: defaultEmoji.type, CalloutBlockKeys.backgroundColor: defaultColor?.toHex(), }; return Node( type: CalloutBlockKeys.type, attributes: attributes, ); } // defining the callout block menu item in selection menu SelectionMenuItem calloutItem = SelectionMenuItem.node( getName: LocaleKeys.document_plugins_callout.tr, iconData: Icons.note, keywords: [CalloutBlockKeys.type], nodeBuilder: (editorState, context) => calloutNode(defaultColor: Colors.transparent), replace: (_, node) => node.delta?.isEmpty ?? false, updateSelection: (_, path, __, ___) { return Selection.single(path: path, startOffset: 0); }, ); // building the callout block widget class CalloutBlockComponentBuilder extends BlockComponentBuilder { CalloutBlockComponentBuilder({ super.configuration, required this.defaultColor, required this.inlinePadding, }); final Color defaultColor; final EdgeInsets Function(Node node) inlinePadding; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return CalloutBlockComponentWidget( key: node.key, node: node, defaultColor: defaultColor, inlinePadding: inlinePadding, configuration: configuration, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (node) => node.delta != null; } // the main widget for rendering the callout block class CalloutBlockComponentWidget extends BlockComponentStatefulWidget { const CalloutBlockComponentWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), required this.defaultColor, required this.inlinePadding, }); final Color defaultColor; final EdgeInsets Function(Node node) inlinePadding; @override State createState() => _CalloutBlockComponentWidgetState(); } class _CalloutBlockComponentWidgetState extends State with SelectableMixin, DefaultSelectableMixin, BlockComponentConfigurable, BlockComponentTextDirectionMixin, BlockComponentAlignMixin, BlockComponentBackgroundColorMixin, NestedBlockComponentStatefulWidgetMixin { // the key used to forward focus to the richtext child @override final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); // the key used to identify this component @override GlobalKey> get containerKey => widget.node.key; @override GlobalKey> blockComponentKey = GlobalKey( debugLabel: CalloutBlockKeys.type, ); @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; @override Color get backgroundColor { final color = super.backgroundColor; if (color == Colors.transparent) { return AFThemeExtension.of(context).calloutBGColor; } return color; } // get the emoji of the note block from the node's attributes or default to '📌' EmojiIconData get emoji { final icon = node.attributes[CalloutBlockKeys.icon]; final type = node.attributes[CalloutBlockKeys.iconType] ?? FlowyIconType.emoji; EmojiIconData result = EmojiIconData.emoji('📌'); try { result = EmojiIconData(FlowyIconType.values.byName(type), icon); } catch (_) {} return result; } @override Widget build(BuildContext context) { Widget child = node.children.isEmpty ? buildComponent(context) : buildComponentWithChildren(context); if (UniversalPlatform.isDesktop) { child = Padding( padding: EdgeInsets.symmetric(vertical: 2.0), child: child, ); } return child; } @override Widget buildComponentWithChildren(BuildContext context) { Widget child = Stack( children: [ Positioned.fill( left: UniversalPlatform.isMobile ? 0 : cachedLeft, child: Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(6.0)), color: backgroundColor, ), ), ), NestedListWidget( indentPadding: indentPadding.copyWith(bottom: 8), child: buildComponent(context, withBackgroundColor: false), children: editorState.renderer.buildList( context, widget.node.children, ), ), ], ); if (UniversalPlatform.isMobile) { child = Padding( padding: padding, child: child, ); } return child; } // build the callout block widget @override Widget buildComponent( BuildContext context, { bool withBackgroundColor = true, }) { final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); final (emojiSize, emojiButtonSize) = calculateEmojiSize(); final documentId = context.read()?.documentId; Widget child = Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(6.0)), color: withBackgroundColor ? backgroundColor : null, ), padding: widget.inlinePadding(widget.node), width: double.infinity, alignment: alignment, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, textDirection: textDirection, children: [ const HSpace(6.0), // the emoji picker button for the note EmojiPickerButton( // force to refresh the popover state key: ValueKey(widget.node.id + emoji.emoji), enable: editorState.editable, title: '', margin: UniversalPlatform.isMobile ? const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0) : EdgeInsets.zero, emoji: emoji, emojiSize: emojiSize, showBorder: false, buttonSize: emojiButtonSize, documentId: documentId, tabs: const [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ], onSubmitted: (r, controller) { setEmojiIconData(r.data); if (!r.keepOpen) controller?.close(); }, ), if (UniversalPlatform.isDesktopOrWeb) const HSpace(6.0), Flexible( child: Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: buildCalloutBlockComponent(context, textDirection), ), ), const HSpace(8.0), ], ), ); if (UniversalPlatform.isMobile && node.children.isEmpty) { child = Padding( key: blockComponentKey, padding: padding, child: child, ); } else { child = Container( key: blockComponentKey, padding: EdgeInsets.zero, child: child, ); } child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, selectionAboveBlock: true, supportTypes: const [ BlockSelectionType.block, ], child: child, ); if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: widget.node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } return child; } // build the richtext child Widget buildCalloutBlockComponent( BuildContext context, TextDirection textDirection, ) { return AppFlowyRichText( key: forwardKey, delegate: this, node: widget.node, editorState: editorState, placeholderText: placeholderText, textAlign: alignment?.toTextAlign ?? textAlign, textSpanDecorator: (textSpan) => textSpan.updateTextStyle( textStyleWithTextSpan(textSpan: textSpan), ), placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle( placeholderTextStyleWithTextSpan(textSpan: textSpan), ), textDirection: textDirection, cursorColor: editorState.editorStyle.cursorColor, selectionColor: editorState.editorStyle.selectionColor, ); } // set the emoji of the note block Future setEmojiIconData(EmojiIconData data) async { final transaction = editorState.transaction ..updateNode(node, { CalloutBlockKeys.icon: data.emoji, CalloutBlockKeys.iconType: data.type.name, }) ..afterSelection = Selection.collapsed( Position(path: node.path, offset: node.delta?.length ?? 0), ); await editorState.apply(transaction); } (double, Size) calculateEmojiSize() { const double defaultEmojiSize = 16.0; const Size defaultEmojiButtonSize = Size(30.0, 30.0); final double emojiSize = editorState.editorStyle.textStyleConfiguration.text.fontSize ?? defaultEmojiSize; final emojiButtonSize = defaultEmojiButtonSize * emojiSize / defaultEmojiSize; return (emojiSize, emojiButtonSize); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; /// Pressing Enter in a callout block will insert a newline (\n) within the callout, /// while pressing Shift+Enter in a callout will insert a new paragraph next to the callout. /// /// - support /// - desktop /// - mobile /// - web /// final CharacterShortcutEvent insertNewLineInCalloutBlock = CharacterShortcutEvent( key: 'insert a new line in callout block', character: '\n', handler: _insertNewLineHandler, ); CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async { final selection = editorState.selection?.normalized; if (selection == null) { return false; } final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.type != CalloutBlockKeys.type) { return false; } // delete the selection await editorState.deleteSelection(selection); if (HardwareKeyboard.instance.isShiftPressed) { // ignore the shift+enter event, fallback to the default behavior return false; } else if (node.children.isEmpty) { // insert a new paragraph within the callout block final path = node.path.child(0); final transaction = editorState.transaction; transaction.insertNode( path, paragraphNode(), ); transaction.afterSelection = Selection.collapsed( Position( path: path, ), ); await editorState.apply(transaction); } return true; }; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart ================================================ import 'dart:convert'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; CodeBlockCopyBuilder codeBlockCopyBuilder = (_, node) => _CopyButton(node: node); class _CopyButton extends StatelessWidget { const _CopyButton({required this.node}); final Node node; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(4), child: FlowyTooltip( message: LocaleKeys.document_codeBlock_copyTooltip.tr(), child: FlowyIconButton( onPressed: () async { final delta = node.delta; if (delta == null) { return; } final document = Document.blank() ..insert([0], [node.deepCopy()]) ..toJson(); await getIt().setData( ClipboardServiceData( plainText: delta.toPlainText(), inAppJson: jsonEncode(document.toJson()), ), ); if (context.mounted) { showToastNotification( message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(), ); } }, hoverColor: Theme.of(context).colorScheme.secondaryContainer, icon: FlowySvg( FlowySvgs.copy_s, color: AFThemeExtension.of(context).textColor, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; CodeBlockLanguagePickerBuilder codeBlockLanguagePickerBuilder = ( editorState, supportedLanguages, onLanguageSelected, { selectedLanguage, onMenuClose, onMenuOpen, }) => CodeBlockLanguageSelector( editorState: editorState, language: selectedLanguage, supportedLanguages: supportedLanguages, onLanguageSelected: onLanguageSelected, onMenuClose: onMenuClose, onMenuOpen: onMenuOpen, ); class CodeBlockLanguageSelector extends StatefulWidget { const CodeBlockLanguageSelector({ super.key, required this.editorState, required this.supportedLanguages, this.language, required this.onLanguageSelected, this.onMenuOpen, this.onMenuClose, }); final EditorState editorState; final List supportedLanguages; final String? language; final void Function(String) onLanguageSelected; final VoidCallback? onMenuOpen; final VoidCallback? onMenuClose; @override State createState() => _CodeBlockLanguageSelectorState(); } class _CodeBlockLanguageSelectorState extends State { final controller = PopoverController(); @override void dispose() { controller.close(); super.dispose(); } @override Widget build(BuildContext context) { Widget child = Row( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0), child: FlowyTextButton( widget.language?.capitalize() ?? LocaleKeys.document_codeBlock_language_auto.tr(), constraints: const BoxConstraints(minWidth: 50), fontColor: AFThemeExtension.of(context).onBackground, padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4), fillColor: Colors.transparent, hoverColor: Theme.of(context).colorScheme.secondaryContainer, onPressed: () async { if (UniversalPlatform.isMobile) { final language = await context .push(MobileCodeLanguagePickerScreen.routeName); if (language != null) { widget.onLanguageSelected(language); } } }, ), ), ], ); if (UniversalPlatform.isDesktopOrWeb) { child = AppFlowyPopover( controller: controller, direction: PopoverDirection.bottomWithLeftAligned, onOpen: widget.onMenuOpen, constraints: const BoxConstraints(maxHeight: 300, maxWidth: 200), onClose: widget.onMenuClose, popupBuilder: (_) => _LanguageSelectionPopover( editorState: widget.editorState, language: widget.language, supportedLanguages: widget.supportedLanguages, onLanguageSelected: (language) { widget.onLanguageSelected(language); controller.close(); }, ), child: child, ); } return child; } } class _LanguageSelectionPopover extends StatefulWidget { const _LanguageSelectionPopover({ required this.editorState, required this.language, required this.supportedLanguages, required this.onLanguageSelected, }); final EditorState editorState; final String? language; final List supportedLanguages; final void Function(String) onLanguageSelected; @override State<_LanguageSelectionPopover> createState() => _LanguageSelectionPopoverState(); } class _LanguageSelectionPopoverState extends State<_LanguageSelectionPopover> { final searchController = TextEditingController(); final focusNode = FocusNode(); late List filteredLanguages = widget.supportedLanguages.map((e) => e.capitalize()).toList(); late int selectedIndex = widget.supportedLanguages.indexOf(widget.language?.toLowerCase() ?? ''); final ItemScrollController languageListController = ItemScrollController(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback( // This is a workaround because longer taps might break the // focus, this might be an issue with the Flutter framework. (_) => Future.delayed( const Duration(milliseconds: 100), () => focusNode.requestFocus(), ), ); } @override void dispose() { focusNode.dispose(); searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Shortcuts( shortcuts: const { SingleActivator(LogicalKeyboardKey.arrowUp): _DirectionIntent(AxisDirection.up), SingleActivator(LogicalKeyboardKey.arrowDown): _DirectionIntent(AxisDirection.down), SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), }, child: Actions( actions: { _DirectionIntent: CallbackAction<_DirectionIntent>( onInvoke: (intent) => onArrowKey(intent.direction), ), ActivateIntent: CallbackAction( onInvoke: (intent) { if (selectedIndex < 0) return; selectLanguage(selectedIndex); return null; }, ), }, child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyTextField( focusNode: focusNode, autoFocus: false, controller: searchController, hintText: LocaleKeys.document_codeBlock_searchLanguageHint.tr(), onChanged: (_) => setState(() { filteredLanguages = widget.supportedLanguages .where( (e) => e.contains(searchController.text.toLowerCase()), ) .map((e) => e.capitalize()) .toList(); selectedIndex = widget.supportedLanguages.indexOf(widget.language ?? ''); }), ), const VSpace(8), Flexible( child: SelectableItemListMenu( controller: languageListController, shrinkWrap: true, items: filteredLanguages, selectedIndex: selectedIndex, onSelected: selectLanguage, ), ), ], ), ), ); } void onArrowKey(AxisDirection direction) { if (filteredLanguages.isEmpty) return; final isUp = direction == AxisDirection.up; if (selectedIndex < 0) { selectedIndex = isUp ? 0 : -1; } final length = filteredLanguages.length; setState(() { if (isUp) { selectedIndex = selectedIndex == 0 ? length - 1 : selectedIndex - 1; } else { selectedIndex = selectedIndex == length - 1 ? 0 : selectedIndex + 1; } }); languageListController.scrollTo( index: selectedIndex, alignment: 0.5, duration: const Duration(milliseconds: 300), ); } void selectLanguage(int index) { widget.onLanguageSelected(filteredLanguages[index]); } } /// [ScrollIntent] is not working, so using this custom Intent class _DirectionIntent extends Intent { const _DirectionIntent(this.direction); final AxisDirection direction; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_menu_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; final codeBlockSelectionMenuItem = SelectionMenuItem.node( getName: () => LocaleKeys.document_selectionMenu_codeBlock.tr(), iconBuilder: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.icon_code_block_s, isSelected: onSelected, style: style, ), keywords: ['code', 'codeblock'], nodeBuilder: (_, __) => codeBlockNode(), replace: (_, node) => node.delta?.isEmpty ?? false, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:go_router/go_router.dart'; class MobileCodeLanguagePickerScreen extends StatelessWidget { const MobileCodeLanguagePickerScreen({super.key}); static const routeName = '/code_language_picker'; @override Widget build(BuildContext context) { return Scaffold( appBar: FlowyAppBar(titleText: LocaleKeys.titleBar_language.tr()), body: SafeArea( child: ListView.separated( separatorBuilder: (_, __) => const Divider(), itemCount: defaultCodeBlockSupportedLanguages.length, itemBuilder: (context, index) { final language = defaultCodeBlockSupportedLanguages[index]; return SizedBox( height: 48, child: FlowyTextButton( language.capitalize(), padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 4.0, ), onPressed: () => context.pop(language), ), ); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_component.dart ================================================ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; Node simpleColumnNode({ List? children, double? ratio, }) { return Node( type: SimpleColumnBlockKeys.type, children: children ?? [paragraphNode()], attributes: { SimpleColumnBlockKeys.ratio: ratio, }, ); } extension SimpleColumnBlockAttributes on Node { // get the next column node of the current column node // if the current column node is the last column node, return null Node? get nextColumn { final index = path.last; final parent = this.parent; if (parent == null || index == parent.children.length - 1) { return null; } return parent.children[index + 1]; } // get the previous column node of the current column node // if the current column node is the first column node, return null Node? get previousColumn { final index = path.last; final parent = this.parent; if (parent == null || index == 0) { return null; } return parent.children[index - 1]; } } class SimpleColumnBlockKeys { const SimpleColumnBlockKeys._(); static const String type = 'simple_column'; /// @Deprecated Use [SimpleColumnBlockKeys.ratio] instead. /// /// This field is no longer used since v0.6.9 @Deprecated('Use [SimpleColumnBlockKeys.ratio] instead.') static const String width = 'width'; /// The ratio of the column width. /// /// The value is a double number between 0 and 1. static const String ratio = 'ratio'; } class SimpleColumnBlockComponentBuilder extends BlockComponentBuilder { SimpleColumnBlockComponentBuilder({ super.configuration, }); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return SimpleColumnBlockComponent( key: node.key, node: node, showActions: showActions(node), configuration: configuration, actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), ); } @override BlockComponentValidate get validate => (node) => node.children.isNotEmpty; } class SimpleColumnBlockComponent extends BlockComponentStatefulWidget { const SimpleColumnBlockComponent({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @override State createState() => SimpleColumnBlockComponentState(); } class SimpleColumnBlockComponentState extends State with SelectableMixin, BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; final columnKey = GlobalKey(); late final EditorState editorState = context.read(); @override Widget build(BuildContext context) { Widget child = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: node.children.map( (e) { Widget child = Provider( create: (_) => DatabasePluginWidgetBuilderSize( verticalPadding: 0, horizontalPadding: 0, ), child: editorState.renderer.build(context, e), ); if (SimpleColumnsBlockConstants.enableDebugBorder) { child = DecoratedBox( decoration: BoxDecoration( border: Border.all( color: Colors.blue, ), ), child: child, ); } return child; }, ).toList(), ); child = Padding( key: columnKey, padding: padding, child: child, ); if (SimpleColumnsBlockConstants.enableDebugBorder) { child = Container( color: Colors.green.withValues( alpha: 0.3, ), child: child, ); } // the column block does not support the block actions and selection // because the column block is a layout wrapper, it does not have a content return child; } @override Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); @override Position getPositionInOffset(Offset start) => end(); @override bool get shouldCursorBlink => false; @override CursorStyle get cursorStyle => CursorStyle.cover; @override Rect getBlockRect({ bool shiftWithBaseOffset = false, }) { return getRectsInSelection(Selection.invalid()).first; } @override Rect? getCursorRectInPosition( Position position, { bool shiftWithBaseOffset = false, }) { final rects = getRectsInSelection( Selection.collapsed(position), shiftWithBaseOffset: shiftWithBaseOffset, ); return rects.firstOrNull; } @override List getRectsInSelection( Selection selection, { bool shiftWithBaseOffset = false, }) { if (_renderBox == null) { return []; } final parentBox = context.findRenderObject(); final renderBox = columnKey.currentContext?.findRenderObject(); if (parentBox is RenderBox && renderBox is RenderBox) { return [ renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & renderBox.size, ]; } return [Offset.zero & _renderBox!.size]; } @override Selection getSelectionInRange(Offset start, Offset end) => Selection.single(path: widget.node.path, startOffset: 0, endOffset: 1); @override Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => _renderBox!.localToGlobal(offset); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_block_width_resizer.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class SimpleColumnBlockWidthResizer extends StatefulWidget { const SimpleColumnBlockWidthResizer({ super.key, required this.columnNode, required this.editorState, this.height, }); final Node columnNode; final EditorState editorState; final double? height; @override State createState() => _SimpleColumnBlockWidthResizerState(); } class _SimpleColumnBlockWidthResizerState extends State { bool isDragging = false; ValueNotifier isHovering = ValueNotifier(false); @override void dispose() { isHovering.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => isHovering.value = true, onExit: (_) { // delay the hover state change to avoid flickering Future.delayed(const Duration(milliseconds: 100), () { if (!isDragging) { isHovering.value = false; } }); }, child: GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragStart: _onHorizontalDragStart, onHorizontalDragUpdate: _onHorizontalDragUpdate, onHorizontalDragEnd: _onHorizontalDragEnd, onHorizontalDragCancel: _onHorizontalDragCancel, child: ValueListenableBuilder( valueListenable: isHovering, builder: (context, isHovering, child) { if (UniversalPlatform.isMobile) { return const SizedBox.shrink(); } final hide = isDraggingAppFlowyEditorBlock.value || !isHovering; return MouseRegion( cursor: SystemMouseCursors.resizeLeftRight, child: Container( width: 2, height: widget.height ?? 20, margin: EdgeInsets.symmetric(horizontal: 2), color: !hide ? Theme.of(context).colorScheme.primary : Colors.transparent, ), ); }, ), ), ); } void _onHorizontalDragStart(DragStartDetails details) { isDragging = true; EditorGlobalConfiguration.enableDragMenu.value = false; } void _onHorizontalDragUpdate(DragUpdateDetails details) { if (!isDragging) { return; } // update the column width in memory final columnNode = widget.columnNode; final columnsNode = columnNode.columnsParent; if (columnsNode == null) { return; } final editorWidth = columnsNode.rect.width; final rect = columnNode.rect; final width = rect.width; final originalRatio = columnNode.attributes[SimpleColumnBlockKeys.ratio]; final newWidth = width + details.delta.dx; final transaction = widget.editorState.transaction; final newRatio = newWidth / editorWidth; transaction.updateNode(columnNode, { ...columnNode.attributes, SimpleColumnBlockKeys.ratio: newRatio, }); if (newRatio < 0.1 && newRatio < originalRatio) { return; } final nextColumn = columnNode.nextColumn; if (nextColumn != null) { final nextColumnRect = nextColumn.rect; final nextColumnWidth = nextColumnRect.width; final newNextColumnWidth = nextColumnWidth - details.delta.dx; final newNextColumnRatio = newNextColumnWidth / editorWidth; if (newNextColumnRatio < 0.1) { return; } transaction.updateNode(nextColumn, { ...nextColumn.attributes, SimpleColumnBlockKeys.ratio: newNextColumnRatio, }); } transaction.updateNode(columnsNode, { ...columnsNode.attributes, ColumnsBlockKeys.columnCount: columnsNode.children.length, }); widget.editorState.apply( transaction, options: ApplyOptions(inMemoryUpdate: true), ); } void _onHorizontalDragEnd(DragEndDetails details) { isHovering.value = false; EditorGlobalConfiguration.enableDragMenu.value = true; if (!isDragging) { return; } // apply the transaction again to make sure the width is updated final transaction = widget.editorState.transaction; final columnsNode = widget.columnNode.columnsParent; if (columnsNode == null) { return; } for (final columnNode in columnsNode.children) { transaction.updateNode(columnNode, { ...columnNode.attributes, }); } widget.editorState.apply(transaction); isDragging = false; } void _onHorizontalDragCancel() { isDragging = false; isHovering.value = false; EditorGlobalConfiguration.enableDragMenu.value = true; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_column_node_extension.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension SimpleColumnNodeExtension on Node { /// Returns the parent [Node] of the current node if it is a [SimpleColumnsBlock]. Node? get columnsParent { Node? currentNode = parent; while (currentNode != null) { if (currentNode.type == SimpleColumnsBlockKeys.type) { return currentNode; } currentNode = currentNode.parent; } return null; } /// Returns the parent [Node] of the current node if it is a [SimpleColumnBlock]. Node? get columnParent { Node? currentNode = parent; while (currentNode != null) { if (currentNode.type == SimpleColumnBlockKeys.type) { return currentNode; } currentNode = currentNode.parent; } return null; } /// Returns whether the current node is in a [SimpleColumnsBlock]. bool get isInColumnsBlock => columnsParent != null; /// Returns whether the current node is in a [SimpleColumnBlock]. bool get isInColumnBlock => columnParent != null; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_component.dart ================================================ import 'dart:math'; import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; // if the children is not provided, it will create two columns by default. // if the columnCount is provided, it will create the specified number of columns. Node simpleColumnsNode({ List? children, int? columnCount, double? ratio, }) { columnCount ??= 2; children ??= List.generate( columnCount, (index) => simpleColumnNode( ratio: ratio, children: [paragraphNode()], ), ); // check the type of children for (final child in children) { if (child.type != SimpleColumnBlockKeys.type) { Log.error('the type of children must be column, but got ${child.type}'); } } return Node( type: SimpleColumnsBlockKeys.type, children: children, ); } class SimpleColumnsBlockKeys { const SimpleColumnsBlockKeys._(); static const String type = 'simple_columns'; } class SimpleColumnsBlockComponentBuilder extends BlockComponentBuilder { SimpleColumnsBlockComponentBuilder({super.configuration}); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return ColumnsBlockComponent( key: node.key, node: node, showActions: showActions(node), configuration: configuration, actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), ); } @override BlockComponentValidate get validate => (node) => node.children.isNotEmpty; } class ColumnsBlockComponent extends BlockComponentStatefulWidget { const ColumnsBlockComponent({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @override State createState() => ColumnsBlockComponentState(); } class ColumnsBlockComponentState extends State with SelectableMixin, BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; final columnsKey = GlobalKey(); late final EditorState editorState = context.read(); final ScrollController scrollController = ScrollController(); final ValueNotifier heightValueNotifier = ValueNotifier(null); @override void initState() { super.initState(); _updateColumnsBlock(); } @override void dispose() { scrollController.dispose(); heightValueNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { Widget child = Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: _buildChildren(), ); child = Align( alignment: Alignment.topLeft, child: child, ); child = Padding( key: columnsKey, padding: padding, child: child, ); if (SimpleColumnsBlockConstants.enableDebugBorder) { child = DecoratedBox( decoration: BoxDecoration( border: Border.all( color: Colors.red, width: 3.0, ), ), child: child, ); } // the columns block does not support the block actions and selection // because the columns block is a layout wrapper, it does not have a content return NotificationListener( onNotification: (v) => updateHeightValueNotifier(v), child: SizeChangedLayoutNotifier(child: child), ); } List _buildChildren() { final length = node.children.length; final children = []; for (var i = 0; i < length; i++) { final childNode = node.children[i]; final double ratio = childNode.attributes[SimpleColumnBlockKeys.ratio]?.toDouble() ?? 1.0 / length; Widget child = editorState.renderer.build(context, childNode); child = Expanded( flex: (max(ratio, 0.1) * 10000).toInt(), child: child, ); children.add(child); if (i != length - 1) { children.add( ValueListenableBuilder( valueListenable: heightValueNotifier, builder: (context, height, child) { return SimpleColumnBlockWidthResizer( columnNode: childNode, editorState: editorState, height: height, ); }, ), ); } } return children; } // Update the existing columns block data // if the column ratio is not existing, it will be set to 1.0 / columnCount void _updateColumnsBlock() { final transaction = editorState.transaction; final length = node.children.length; for (int i = 0; i < length; i++) { final childNode = node.children[i]; final ratio = childNode.attributes[SimpleColumnBlockKeys.ratio]; if (ratio == null) { transaction.updateNode(childNode, { ...childNode.attributes, SimpleColumnBlockKeys.ratio: 1.0 / length, }); } } if (transaction.operations.isNotEmpty) { editorState.apply(transaction); } } bool updateHeightValueNotifier(SizeChangedLayoutNotification notification) { if (!mounted) return true; final height = _renderBox?.size.height; if (heightValueNotifier.value == height) return true; WidgetsBinding.instance.addPostFrameCallback((_) { heightValueNotifier.value = height; }); return true; } @override Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); @override Position getPositionInOffset(Offset start) => end(); @override bool get shouldCursorBlink => false; @override CursorStyle get cursorStyle => CursorStyle.cover; @override Rect getBlockRect({ bool shiftWithBaseOffset = false, }) { return getRectsInSelection(Selection.invalid()).first; } @override Rect? getCursorRectInPosition( Position position, { bool shiftWithBaseOffset = false, }) { final rects = getRectsInSelection( Selection.collapsed(position), shiftWithBaseOffset: shiftWithBaseOffset, ); return rects.firstOrNull; } @override List getRectsInSelection( Selection selection, { bool shiftWithBaseOffset = false, }) { if (_renderBox == null) { return []; } final parentBox = context.findRenderObject(); final renderBox = columnsKey.currentContext?.findRenderObject(); if (parentBox is RenderBox && renderBox is RenderBox) { return [ renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & renderBox.size, ]; } return [Offset.zero & _renderBox!.size]; } @override Selection getSelectionInRange(Offset start, Offset end) => Selection.single(path: widget.node.path, startOffset: 0, endOffset: 1); @override Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => _renderBox!.localToGlobal(offset); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart ================================================ class SimpleColumnsBlockConstants { const SimpleColumnsBlockConstants._(); static const double minimumColumnWidth = 128.0; static const bool enableDebugBorder = false; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/context_menu/custom_context_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; final List> customContextMenuItems = [ [ ContextMenuItem( getName: LocaleKeys.document_plugins_contextMenu_copy.tr, onPressed: (editorState) => customCopyCommand.execute(editorState), ), ContextMenuItem( getName: LocaleKeys.document_plugins_contextMenu_paste.tr, onPressed: (editorState) => customPasteCommand.execute(editorState), ), ContextMenuItem( getName: LocaleKeys.document_plugins_contextMenu_pasteAsPlainText.tr, onPressed: (editorState) => customPastePlainTextCommand.execute(editorState), ), ContextMenuItem( getName: LocaleKeys.document_plugins_contextMenu_cut.tr, onPressed: (editorState) => customCutCommand.execute(editorState), ), ], ]; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:appflowy_backend/log.dart'; import 'package:flutter/foundation.dart'; import 'package:super_clipboard/super_clipboard.dart'; /// Used for in-app copy and paste without losing the format. /// /// It's a Json string representing the copied editor nodes. const inAppJsonFormat = CustomValueFormat( applicationId: 'io.appflowy.InAppJsonType', onDecode: _defaultDecode, onEncode: _defaultEncode, ); /// Used for table nodes when coping a row or a column. const tableJsonFormat = CustomValueFormat( applicationId: 'io.appflowy.TableJsonType', onDecode: _defaultDecode, onEncode: _defaultEncode, ); class ClipboardServiceData { const ClipboardServiceData({ this.plainText, this.html, this.image, this.inAppJson, this.tableJson, }); /// The [plainText] is the plain text string. /// /// It should be used for pasting the plain text from the clipboard. final String? plainText; /// The [html] is the html string. /// /// It should be used for pasting the html from the clipboard. /// For example, copy the content in the browser, and paste it in the editor. final String? html; /// The [image] is the image data. /// /// It should be used for pasting the image from the clipboard. /// For example, copy the image in the browser or other apps, and paste it in the editor. final (String, Uint8List?)? image; /// The [inAppJson] is the json string of the editor nodes. /// /// It should be used for pasting the content in-app. /// For example, pasting the content from document A to document B. final String? inAppJson; /// The [tableJson] is the json string of the table nodes. /// /// It only works for the table nodes when coping a row or a column. /// Don't use it for another scenario. final String? tableJson; } class ClipboardService { static ClipboardServiceData? _mockData; @visibleForTesting static void mockSetData(ClipboardServiceData? data) { _mockData = data; } Future setData(ClipboardServiceData data) async { final plainText = data.plainText; final html = data.html; final inAppJson = data.inAppJson; final image = data.image; final tableJson = data.tableJson; final item = DataWriterItem(); if (plainText != null) { item.add(Formats.plainText(plainText)); } if (html != null) { item.add(Formats.htmlText(html)); } if (inAppJson != null) { item.add(inAppJsonFormat(inAppJson)); } if (tableJson != null) { item.add(tableJsonFormat(tableJson)); } if (image != null && image.$2?.isNotEmpty == true) { switch (image.$1) { case 'png': item.add(Formats.png(image.$2!)); break; case 'jpeg': item.add(Formats.jpeg(image.$2!)); break; case 'gif': item.add(Formats.gif(image.$2!)); break; default: throw Exception('unsupported image format: ${image.$1}'); } } await SystemClipboard.instance?.write([item]); } Future setPlainText(String text) async { await SystemClipboard.instance?.write([ DataWriterItem()..add(Formats.plainText(text)), ]); } Future getData() async { if (_mockData != null) { return _mockData!; } final reader = await SystemClipboard.instance?.read(); if (reader == null) { return const ClipboardServiceData(); } for (final item in reader.items) { final availableFormats = await item.rawReader!.getAvailableFormats(); Log.info('availableFormats: $availableFormats'); } final plainText = await reader.readValue(Formats.plainText); final html = await reader.readValue(Formats.htmlText); final inAppJson = await reader.readValue(inAppJsonFormat); final tableJson = await reader.readValue(tableJsonFormat); final uri = await reader.readValue(Formats.uri); (String, Uint8List?)? image; if (reader.canProvide(Formats.png)) { image = ('png', await reader.readFile(Formats.png)); } else if (reader.canProvide(Formats.jpeg)) { image = ('jpeg', await reader.readFile(Formats.jpeg)); } else if (reader.canProvide(Formats.gif)) { image = ('gif', await reader.readFile(Formats.gif)); } else if (reader.canProvide(Formats.webp)) { image = ('webp', await reader.readFile(Formats.webp)); } return ClipboardServiceData( plainText: plainText ?? uri?.uri.toString(), html: html, image: image, inAppJson: inAppJson, tableJson: tableJson, ); } } extension on DataReader { Future? readFile(FileFormat format) { final c = Completer(); final progress = getFile( format, (file) async { try { final all = await file.readAll(); c.complete(all); } catch (e) { c.completeError(e); } }, onError: (e) { c.completeError(e); }, ); if (progress == null) { c.complete(null); } return c.future; } } /// The default decode function for the clipboard service. Future _defaultDecode(Object value, String platformType) async { if (value is PlatformDataProvider) { final data = await value.getData(platformType); if (data is List) { return utf8.decode(data, allowMalformed: true); } if (data is String) { return Uri.decodeFull(data); } } return null; } /// The default encode function for the clipboard service. Future _defaultEncode(String value, String platformType) async { return utf8.encode(value); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; /// Copy. /// /// - support /// - desktop /// - web /// - mobile /// final CommandShortcutEvent customCopyCommand = CommandShortcutEvent( key: 'copy the selected content', getDescription: () => AppFlowyEditorL10n.current.cmdCopySelection, command: 'ctrl+c', macOSCommand: 'cmd+c', handler: _copyCommandHandler, ); CommandShortcutEventHandler _copyCommandHandler = (editorState) => handleCopyCommand(editorState); KeyEventResult handleCopyCommand( EditorState editorState, { bool isCut = false, }) { final selection = editorState.selection?.normalized; if (selection == null) { return KeyEventResult.ignored; } String? text; String? html; String? inAppJson; if (selection.isCollapsed) { // if the selection is collapsed, we will copy the text of the current line. final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return KeyEventResult.ignored; } // plain text. text = node.delta?.toPlainText(); // in app json final document = Document.blank() ..insert([0], [_handleNode(node.deepCopy(), isCut)]); inAppJson = jsonEncode(document.toJson()); // html html = documentToHTML(document); } else { // plain text. text = editorState.getTextInSelection(selection).join('\n'); final document = _buildCopiedDocument( editorState, selection, isCut: isCut, ); inAppJson = jsonEncode(document.toJson()); // html html = documentToHTML(document); } () async { await getIt().setData( ClipboardServiceData( plainText: text, html: html, inAppJson: inAppJson, ), ); }(); return KeyEventResult.handled; } Document _buildCopiedDocument( EditorState editorState, Selection selection, { bool isCut = false, }) { // filter the table nodes final filteredNodes = []; final selectedNodes = editorState.getSelectedNodes(selection: selection); final nodes = _handleSubPageNodes(selectedNodes, isCut); for (final node in nodes) { if (node.type == SimpleTableCellBlockKeys.type) { // if the node is a table cell, we will fetch its children instead. filteredNodes.addAll(node.children); } else if (node.type == SimpleTableRowBlockKeys.type) { // if the node is a table row, we will fetch its children's children instead. filteredNodes.addAll(node.children.expand((e) => e.children)); } else if (node.type == SimpleColumnBlockKeys.type) { // if the node is a column block, we will fetch its children instead. filteredNodes.addAll(node.children); } else if (node.type == SimpleColumnsBlockKeys.type) { // if the node is a columns block, we will fetch its children's children instead. filteredNodes.addAll(node.children.expand((e) => e.children)); } else { filteredNodes.add(node); } } final document = Document.blank() ..insert( [0], filteredNodes.map((e) => e.deepCopy()), ); return document; } List _handleSubPageNodes(List nodes, [bool isCut = false]) { final handled = []; for (final node in nodes) { handled.add(_handleNode(node, isCut)); } return handled; } Node _handleNode(Node node, [bool isCut = false]) { if (!isCut) { return node.deepCopy(); } final newChildren = node.children.map(_handleNode).toList(); if (node.type == SubPageBlockKeys.type) { return node.copyWith( attributes: { ...node.attributes, SubPageBlockKeys.wasCopied: !isCut, SubPageBlockKeys.wasCut: isCut, }, children: newChildren, ); } return node.copyWith(children: newChildren); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_cut_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; /// cut. /// /// - support /// - desktop /// - web /// - mobile /// final CommandShortcutEvent customCutCommand = CommandShortcutEvent( key: 'cut the selected content', getDescription: () => AppFlowyEditorL10n.current.cmdCutSelection, command: 'ctrl+x', macOSCommand: 'cmd+x', handler: _cutCommandHandler, ); CommandShortcutEventHandler _cutCommandHandler = (editorState) { final selection = editorState.selection; if (selection == null) { return KeyEventResult.ignored; } final context = editorState.document.root.context; if (context == null || !context.mounted) { return KeyEventResult.ignored; } context.read().didCut(); handleCopyCommand(editorState, isCut: true); if (!selection.isCollapsed) { editorState.deleteSelectionIfNeeded(); } else { final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return KeyEventResult.handled; } // prevent to cut the node that is selecting the table. if (node.parentTableNode != null) { return KeyEventResult.skipRemainingHandlers; } final transaction = editorState.transaction; transaction.deleteNode(node); final nextNode = node.next; if (nextNode != null && nextNode.delta != null) { transaction.afterSelection = Selection.collapsed( Position(path: node.path, offset: nextNode.delta?.length ?? 0), ); } editorState.apply(transaction); } return KeyEventResult.handled; }; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart ================================================ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart'; import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/default_extensions.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:http/http.dart' as http; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; /// - support /// - desktop /// - web /// - mobile /// final CommandShortcutEvent customPasteCommand = CommandShortcutEvent( key: 'paste the content', getDescription: () => AppFlowyEditorL10n.current.cmdPasteContent, command: 'ctrl+v', macOSCommand: 'cmd+v', handler: _pasteCommandHandler, ); final CommandShortcutEvent customPastePlainTextCommand = CommandShortcutEvent( key: 'paste the plain content', getDescription: () => AppFlowyEditorL10n.current.cmdPasteContent, command: 'ctrl+shift+v', macOSCommand: 'cmd+shift+v', handler: _pastePlainCommandHandler, ); CommandShortcutEventHandler _pasteCommandHandler = (editorState) { final selection = editorState.selection; if (selection == null) { return KeyEventResult.ignored; } doPaste(editorState).then((_) { final context = editorState.document.root.context; if (context != null && context.mounted) { context.read().didPaste(); } }); return KeyEventResult.handled; }; CommandShortcutEventHandler _pastePlainCommandHandler = (editorState) { final selection = editorState.selection; if (selection == null) { return KeyEventResult.ignored; } doPlainPaste(editorState).then((_) { final context = editorState.document.root.context; if (context != null && context.mounted) { context.read().didPaste(); } }); return KeyEventResult.handled; }; Future doPaste(EditorState editorState) async { final selection = editorState.selection; if (selection == null) { return; } EditorNotification.paste().post(); // dispatch the paste event final data = await getIt().getData(); final inAppJson = data.inAppJson; final html = data.html; final plainText = data.plainText; final image = data.image; // dump the length of the data here, don't log the data itself for privacy concerns Log.info('paste command: inAppJson: ${inAppJson?.length}'); Log.info('paste command: html: ${html?.length}'); Log.info('paste command: plainText: ${plainText?.length}'); Log.info('paste command: image: ${image?.$2?.length}'); if (await editorState.pasteAppFlowySharePageLink(plainText)) { return Log.info('Pasted block link'); } // paste as link preview if (await _pasteAsLinkPreview(editorState, plainText)) { return Log.info('Pasted as link preview'); } // Order: // 1. in app json format // 2. html // 3. image // 4. plain text // try to paste the content in order, if any of them is failed, then try the next one if (inAppJson != null && inAppJson.isNotEmpty) { if (await editorState.pasteInAppJson(inAppJson)) { return Log.info('Pasted in app json'); } } // if the image data is not null, we should handle it first // because the image URL in the HTML may not be reachable due to permission issues // For example, when pasting an image from Slack, the image URL provided is not public. if (image != null && image.$2?.isNotEmpty == true) { final documentBloc = editorState.document.root.context?.read(); final documentId = documentBloc?.documentId; if (documentId == null || documentId.isEmpty) { return; } await editorState.deleteSelectionIfNeeded(); final result = await editorState.pasteImage( image.$1, image.$2!, documentId, selection: selection, ); if (result) { return Log.info('Pasted image'); } } if (html != null && html.isNotEmpty) { await editorState.deleteSelectionIfNeeded(); if (await editorState.pasteHtml(html)) { return Log.info('Pasted html'); } } if (plainText != null && plainText.isNotEmpty) { final currentSelection = editorState.selection; if (currentSelection == null) { await editorState.updateSelectionWithReason( selection, reason: SelectionUpdateReason.uiEvent, ); } await editorState.pasteText(plainText); return Log.info('Pasted plain text'); } return Log.info('unable to parse the clipboard content'); } Future _pasteAsLinkPreview( EditorState editorState, String? text, ) async { final isMobile = UniversalPlatform.isMobile; // the url should contain a protocol if (text == null || !isURL(text, {'require_protocol': true})) { return false; } final selection = editorState.selection; // Apply the update only when the selection is collapsed // and at the start of the current line if (selection == null || !selection.isCollapsed || selection.startIndex != 0) { return false; } final node = editorState.getNodeAtPath(selection.start.path); // Apply the update only when the current node is a paragraph // and the paragraph is empty if (node == null || node.type != ParagraphBlockKeys.type || node.delta?.toPlainText().isNotEmpty == true) { return false; } if (!isMobile) return false; final bool isImageUrl; try { isImageUrl = await _isImageUrl(text); } catch (e) { Log.info('unable to get content header'); return false; } if (!isImageUrl) return false; // insert the text with link format final textTransaction = editorState.transaction ..insertText( node, 0, text, attributes: {AppFlowyRichTextKeys.href: text}, ); await editorState.apply( textTransaction, skipHistoryDebounce: true, ); // convert it to image or link preview node final replacementInsertedNodes = [ isImageUrl ? imageNode(url: text) : linkPreviewNode(url: text), // if the next node is null, insert a empty paragraph node if (node.next == null) paragraphNode(), ]; final replacementTransaction = editorState.transaction ..insertNodes( selection.start.path, replacementInsertedNodes, ) ..deleteNode(node) ..afterSelection = Selection.collapsed( Position(path: node.path.next), ); await editorState.apply(replacementTransaction); return true; } Future doPlainPaste(EditorState editorState) async { final selection = editorState.selection; if (selection == null) { return; } EditorNotification.paste().post(); // dispatch the paste event final data = await getIt().getData(); final plainText = data.plainText; if (plainText != null && plainText.isNotEmpty) { await editorState.pastePlainText(plainText); Log.info('Pasted plain text'); return; } Log.info('unable to parse the clipboard content'); return; } Future _isImageUrl(String text) async { if (isNotImageUrl(text)) return false; final response = await http.head(Uri.parse(text)); if (response.statusCode == 200) { final contentType = response.headers['content-type']; if (contentType != null) { return contentType.startsWith('image/') && defaultImageExtensions.any(contentType.contains); } } throw 'bad status code'; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension PasteFromBlockLink on EditorState { Future pasteAppFlowySharePageLink(String? sharePageLink) async { if (sharePageLink == null || sharePageLink.isEmpty) { return false; } // Check if the link matches the appflowy block link format final match = appflowySharePageLinkRegex.firstMatch(sharePageLink); if (match == null) { return false; } final workspaceId = match.group(1); final pageId = match.group(2); final blockId = match.group(3); if (workspaceId == null || pageId == null) { Log.error( 'Failed to extract information from block link: $sharePageLink', ); return false; } final selection = this.selection; if (selection == null) { return false; } final node = getNodesInSelection(selection).firstOrNull; if (node == null) { return false; } // todo: if the current link is not from current workspace. final transaction = this.transaction; transaction.insertText( node, selection.startIndex, MentionBlockKeys.mentionChar, attributes: MentionBlockKeys.buildMentionPageAttributes( mentionType: MentionType.page, pageId: pageId, blockId: blockId, ), ); await apply(transaction); return true; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:cross_file/cross_file.dart'; extension PasteFromFile on EditorState { Future dropFiles( List dropPath, List files, String documentId, bool isLocalMode, ) async { for (final file in files) { String? path; FileUrlType? type; if (isLocalMode) { path = await saveFileToLocalStorage(file.path); type = FileUrlType.local; } else { (path, _) = await saveFileToCloudStorage(file.path, documentId); type = FileUrlType.cloud; } if (path == null) { continue; } final t = transaction ..insertNode( dropPath, fileNode( url: path, type: type, name: file.name, ), ); await apply(t); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:html2md/html2md.dart' as html2md; extension PasteFromHtml on EditorState { Future pasteHtml(String html) async { final nodes = convertHtmlToNodes(html); // if there's no nodes being converted successfully, return false if (nodes.isEmpty) { return false; } if (nodes.length == 1) { await pasteSingleLineNode(nodes.first); checkToShowPasteAsMenu(nodes.first); } else { await pasteMultiLineNodes(nodes.toList()); } return true; } // Convert the html to document nodes. // For the google docs table, it will be fallback to the markdown parser. List convertHtmlToNodes(String html) { List nodes = htmlToDocument(html).root.children.toList(); // 1. remove the front and back empty line while (nodes.isNotEmpty && nodes.first.delta?.isEmpty == true) { nodes.removeAt(0); } while (nodes.isNotEmpty && nodes.last.delta?.isEmpty == true) { nodes.removeLast(); } // 2. replace the legacy table nodes with the new simple table nodes for (int i = 0; i < nodes.length; i++) { final node = nodes[i]; if (node.type == TableBlockKeys.type) { nodes[i] = _convertTableToSimpleTable(node); } } // 3. verify the nodes is empty or contains google table flag // The table from Google Docs will contain the flag 'Google Table' const googleDocsFlag = 'docs-internal-guid-'; final isPasteFromGoogleDocs = html.contains(googleDocsFlag); final isPasteFromAppleNotes = appleNotesRegex.hasMatch(html); final containsTable = nodes.any( (node) => [TableBlockKeys.type, SimpleTableBlockKeys.type].contains(node.type), ); if ((nodes.isEmpty || isPasteFromGoogleDocs || containsTable) && !isPasteFromAppleNotes) { // fallback to the markdown parser final markdown = html2md.convert(html); nodes = customMarkdownToDocument(markdown, tableWidth: 200) .root .children .toList(); } // 4. check if the first node and the last node is bold, because google docs will wrap the table with bold tags if (isPasteFromGoogleDocs) { if (nodes.isNotEmpty && nodes.first.delta?.toPlainText() == '**') { nodes.removeAt(0); } if (nodes.isNotEmpty && nodes.last.delta?.toPlainText() == '**') { nodes.removeLast(); } } return nodes; } // convert the legacy table node to the new simple table node // from type 'table' to type 'simple_table' Node _convertTableToSimpleTable(Node node) { if (node.type != TableBlockKeys.type) { return node; } // the table node should contains colsLen and rowsLen final colsLen = node.attributes[TableBlockKeys.colsLen]; final rowsLen = node.attributes[TableBlockKeys.rowsLen]; if (colsLen == null || rowsLen == null) { return node; } final rows = >[]; final children = node.children; for (var i = 0; i < rowsLen; i++) { final row = []; for (var j = 0; j < colsLen; j++) { final cell = children .where( (n) => n.attributes[TableCellBlockKeys.rowPosition] == i && n.attributes[TableCellBlockKeys.colPosition] == j, ) .firstOrNull; row.add( simpleTableCellBlockNode( children: cell?.children.map((e) => e.deepCopy()).toList() ?? [paragraphNode()], ), ); } rows.add(row); } return simpleTableBlockNode( children: rows.map((e) => simpleTableRowBlockNode(children: e)).toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart ================================================ import 'dart:io'; import 'dart:typed_data'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/default_extensions.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; extension PasteFromImage on EditorState { Future dropImages( List dropPath, List files, String documentId, bool isLocalMode, ) async { final imageFiles = files.where( (file) => file.mimeType?.startsWith('image/') ?? false || imgExtensionRegex.hasMatch(file.name.toLowerCase()), ); for (final file in imageFiles) { String? path; CustomImageType? type; if (isLocalMode) { path = await saveImageToLocalStorage(file.path); type = CustomImageType.local; } else { (path, _) = await saveImageToCloudStorage(file.path, documentId); type = CustomImageType.internal; } if (path == null) { continue; } final t = transaction ..insertNode( dropPath, customImageNode(url: path, type: type), ); await apply(t); } } Future pasteImage( String format, Uint8List imageBytes, String documentId, { Selection? selection, }) async { final context = document.root.context; if (context == null) { return false; } if (!defaultImageExtensions.contains(format)) { Log.info('unsupported format: $format'); if (UniversalPlatform.isMobile) { showToastNotification( message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(), ); } return false; } final isLocalMode = context.read().isLocalMode; final path = await getIt().getPath(); final imagePath = p.join( path, 'images', ); try { // create the directory if not exists final directory = Directory(imagePath); if (!directory.existsSync()) { await directory.create(recursive: true); } final copyToPath = p.join( imagePath, 'tmp_${uuid()}.$format', ); await File(copyToPath).writeAsBytes(imageBytes); final String? path; CustomImageType type; if (isLocalMode) { path = await saveImageToLocalStorage(copyToPath); type = CustomImageType.local; } else { final result = await saveImageToCloudStorage(copyToPath, documentId); final errorMessage = result.$2; if (errorMessage != null && context.mounted) { showToastNotification( message: errorMessage, ); return false; } path = result.$1; type = CustomImageType.internal; } if (path != null) { await insertImageNode(path, selection: selection, type: type); } return true; } catch (e) { Log.error('cannot copy image file', e); if (context.mounted) { showToastNotification( message: LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } } return false; } Future insertImageNode( String src, { Selection? selection, required CustomImageType type, }) async { selection ??= this.selection; if (selection == null || !selection.isCollapsed) { return; } final node = getNodeAtPath(selection.end.path); if (node == null) { return; } final transaction = this.transaction; // if the current node is empty paragraph, replace it with image node if (node.type == ParagraphBlockKeys.type && (node.delta?.isEmpty ?? false)) { transaction ..insertNode( node.path, customImageNode( url: src, type: type, ), ) ..deleteNode(node); } else { transaction.insertNode( node.path.next, customImageNode( url: src, type: type, ), ); } transaction.afterSelection = Selection.collapsed( Position( path: node.path.next, ), ); return apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension PasteFromInAppJson on EditorState { Future pasteInAppJson(String inAppJson) async { try { final nodes = Document.fromJson(jsonDecode(inAppJson)).root.children; // skip pasting a table block to another table block final containsTable = nodes.any((node) => node.type == SimpleTableBlockKeys.type); if (containsTable) { final selectedNodes = getSelectedNodes(withCopy: false); if (selectedNodes.any((node) => node.parentTableNode != null)) { return false; } } if (nodes.isEmpty) { Log.info('pasteInAppJson: nodes is empty'); return false; } if (nodes.length == 1) { Log.info('pasteInAppJson: single line node'); await pasteSingleLineNode(nodes.first); } else { Log.info('pasteInAppJson: multi line nodes'); await pasteMultiLineNodes(nodes.toList()); } return true; } catch (e) { Log.error( 'Failed to paste in app json: $inAppJson, error: $e', ); } return false; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:universal_platform/universal_platform.dart'; extension PasteFromPlainText on EditorState { Future pastePlainText(String plainText) async { await deleteSelectionIfNeeded(); final nodes = plainText .split('\n') .map( (e) => e ..replaceAll(r'\r', '') ..trimRight(), ) .map((e) => Delta()..insert(e)) .map((e) => paragraphNode(delta: e)) .toList(); if (nodes.isEmpty) { return; } if (nodes.length == 1) { await pasteSingleLineNode(nodes.first); } else { await pasteMultiLineNodes(nodes.toList()); } } Future pasteText(String plainText) async { if (await pasteHtmlIfAvailable(plainText)) { return; } await deleteSelectionIfNeeded(); /// try to parse the plain text as markdown final nodes = customMarkdownToDocument(plainText).root.children; if (nodes.isEmpty) { /// if the markdown parser failed, fallback to the plain text parser await pastePlainText(plainText); return; } if (nodes.length == 1) { await pasteSingleLineNode(nodes.first); checkToShowPasteAsMenu(nodes.first); } else { await pasteMultiLineNodes(nodes.toList()); } } Future pasteHtmlIfAvailable(String plainText) async { final selection = this.selection; if (selection == null || !selection.isSingle || selection.isCollapsed || !hrefRegex.hasMatch(plainText)) { return false; } final node = getNodeAtPath(selection.start.path); if (node == null) { return false; } final transaction = this.transaction; transaction.formatText(node, selection.startIndex, selection.length, { AppFlowyRichTextKeys.href: plainText, }); await apply(transaction); checkToShowPasteAsMenu(node); return true; } void checkToShowPasteAsMenu(Node node) { if (selection == null || !selection!.isCollapsed) return; if (UniversalPlatform.isMobile) return; final href = _getLinkFromNode(node); if (href != null) { final context = document.root.context; if (context != null && context.mounted) { PasteAsMenuService(context: context, editorState: this).show(href); } } } String? _getLinkFromNode(Node node) { final delta = node.delta; if (delta == null) return null; final inserts = delta.whereType(); if (inserts.isEmpty || inserts.length > 1) return null; final link = inserts.first.attributes?.href; if (link != null) return inserts.first.text; return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:auto_size_text_field/auto_size_text_field.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; double kDocumentCoverHeight = 98.0; double kDocumentTitlePadding = 20.0; class DocumentImmersiveCover extends StatefulWidget { const DocumentImmersiveCover({ super.key, required this.view, required this.userProfilePB, required this.tabs, this.fixedTitle, }); final ViewPB view; final UserProfilePB userProfilePB; final String? fixedTitle; final List tabs; @override State createState() => _DocumentImmersiveCoverState(); } class _DocumentImmersiveCoverState extends State { final textEditingController = TextEditingController(); final scrollController = ScrollController(); final focusNode = FocusNode(); late PropertyValueNotifier? selectionNotifier = context.read().state.editorState?.selectionNotifier; @override void initState() { super.initState(); selectionNotifier?.addListener(_unfocus); if (widget.view.name.isEmpty) { focusNode.requestFocus(); } } @override void dispose() { textEditingController.dispose(); scrollController.dispose(); selectionNotifier?.removeListener(_unfocus); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return IgnoreParentGestureWidget( child: BlocProvider( create: (context) => DocumentImmersiveCoverBloc(view: widget.view) ..add(const DocumentImmersiveCoverEvent.initial()), child: BlocConsumer( listener: (context, state) { if (textEditingController.text != state.name) { textEditingController.text = state.name; } }, builder: (_, state) { final iconAndTitle = _buildIconAndTitle(context, state); if (state.cover.type == PageStyleCoverImageType.none) { return Padding( padding: EdgeInsets.only( top: context.statusBarAndAppBarHeight + kDocumentTitlePadding, ), child: iconAndTitle, ); } return Padding( padding: const EdgeInsets.only(bottom: 16), child: Stack( children: [ _buildCover(context, state), Positioned( left: 0, right: 0, bottom: 0, child: Padding( padding: const EdgeInsets.symmetric(vertical: 24.0), child: iconAndTitle, ), ), ], ), ); }, ), ), ); } Widget _buildIconAndTitle( BuildContext context, DocumentImmersiveCoverState state, ) { final icon = state.icon; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: Row( children: [ if (icon != null && icon.isNotEmpty) ...[ _buildIcon(context, icon), const HSpace(8.0), ], Expanded(child: _buildTitle(context, state)), ], ), ); } Widget _buildTitle( BuildContext context, DocumentImmersiveCoverState state, ) { String? fontFamily = defaultFontFamily; final documentFontFamily = context.read().state.fontFamily; if (documentFontFamily != null && fontFamily != documentFontFamily) { fontFamily = getGoogleFontSafely(documentFontFamily).fontFamily; } if (widget.fixedTitle != null) { return FlowyText( widget.fixedTitle!, fontSize: 28.0, fontWeight: FontWeight.w700, fontFamily: fontFamily, color: state.cover.isNone || state.cover.isPresets ? null : Colors.white, overflow: TextOverflow.ellipsis, ); } return AutoSizeTextField( controller: textEditingController, focusNode: focusNode, minFontSize: 18.0, decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, disabledBorder: InputBorder.none, focusedBorder: InputBorder.none, hintText: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), contentPadding: EdgeInsets.zero, ), scrollController: scrollController, keyboardType: TextInputType.text, textInputAction: TextInputAction.next, style: TextStyle( fontSize: 28.0, fontWeight: FontWeight.w700, fontFamily: fontFamily, color: state.cover.isNone || state.cover.isPresets ? null : Colors.white, overflow: TextOverflow.ellipsis, ), onChanged: (name) => Debounce.debounce( 'rename', const Duration(milliseconds: 300), () => _rename(name), ), onSubmitted: (name) { // focus on the document _createNewLine(); Debounce.debounce( 'rename', const Duration(milliseconds: 300), () => _rename(name), ); }, ); } Widget _buildIcon(BuildContext context, EmojiIconData icon) { return GestureDetector( child: ConstrainedBox( constraints: const BoxConstraints.tightFor(width: 34.0), child: EmojiIconWidget( emoji: icon, emojiSize: 26, ), ), onTap: () async { final pageStyleIconBloc = PageStyleIconBloc(view: widget.view) ..add(const PageStyleIconEvent.initial()); await showMobileBottomSheet( context, showDragHandle: true, showDivider: false, showHeader: true, title: LocaleKeys.titleBar_pageIcon.tr(), backgroundColor: AFThemeExtension.of(context).background, enableDraggableScrollable: true, minChildSize: 0.6, initialChildSize: 0.61, scrollableWidgetBuilder: (_, controller) { return BlocProvider.value( value: pageStyleIconBloc, child: Expanded( child: FlowyIconEmojiPicker( initialType: icon.type.toPickerTabType(), tabs: widget.tabs, documentId: widget.view.id, onSelectedEmoji: (r) { pageStyleIconBloc.add( PageStyleIconEvent.updateIcon(r.data, true), ); if (!r.keepOpen) Navigator.pop(context); }, ), ), ); }, builder: (_) => const SizedBox.shrink(), ); }, ); } Widget _buildCover(BuildContext context, DocumentImmersiveCoverState state) { final cover = state.cover; final type = cover.type; final naviBarHeight = MediaQuery.of(context).padding.top; final height = naviBarHeight + kDocumentCoverHeight; if (type == PageStyleCoverImageType.customImage || type == PageStyleCoverImageType.unsplashImage) { return SizedBox( height: height, width: double.infinity, child: FlowyNetworkImage( url: cover.value, userProfilePB: widget.userProfilePB, ), ); } if (type == PageStyleCoverImageType.builtInImage) { return SizedBox( height: height, width: double.infinity, child: Image.asset( PageStyleCoverImageType.builtInImagePath(cover.value), fit: BoxFit.cover, ), ); } if (type == PageStyleCoverImageType.pureColor) { return Container( height: height, width: double.infinity, color: cover.value.coverColor(context), ); } if (type == PageStyleCoverImageType.gradientColor) { return Container( height: height, width: double.infinity, decoration: BoxDecoration( gradient: FlowyGradientColor.fromId(cover.value).linear, ), ); } if (type == PageStyleCoverImageType.localImage) { return SizedBox( height: height, width: double.infinity, child: Image.file( File(cover.value), fit: BoxFit.cover, ), ); } return SizedBox( height: naviBarHeight, width: double.infinity, ); } void _unfocus() { final selection = selectionNotifier?.value; if (selection != null) { focusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); } } void _rename(String name) { scrollController.position.jumpTo(0); context.read().add(ViewEvent.rename(name)); } Future _createNewLine() async { focusNode.unfocus(); final selection = textEditingController.selection; final text = textEditingController.text; // split the text into two lines based on the cursor position final parts = [ text.substring(0, selection.baseOffset), text.substring(selection.baseOffset), ]; textEditingController.text = parts[0]; final editorState = context.read().state.editorState; if (editorState == null) { Log.info('editorState is null when creating new line'); return; } final transaction = editorState.transaction; transaction.insertNode([0], paragraphNode(text: parts[1])); await editorState.apply(transaction); // update selection instead of using afterSelection in transaction, // because it will cause the cursor to jump await editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [0])), // trigger the keyboard service. reason: SelectionUpdateReason.uiEvent, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart ================================================ import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; part 'document_immersive_cover_bloc.freezed.dart'; class DocumentImmersiveCoverBloc extends Bloc { DocumentImmersiveCoverBloc({ required this.view, }) : _viewListener = ViewListener(viewId: view.id), super(DocumentImmersiveCoverState.initial()) { on( (event, emit) async { await event.when( initial: () async { final latestView = await ViewBackendService.getView(view.id); if (isClosed) return; add( DocumentImmersiveCoverEvent.updateCoverAndIcon( latestView.fold( (s) => s.cover, (e) => view.cover, ), EmojiIconData.fromViewIconPB(view.icon), view.name, ), ); _viewListener?.start( onViewUpdated: (view) { add( DocumentImmersiveCoverEvent.updateCoverAndIcon( view.cover, EmojiIconData.fromViewIconPB(view.icon), view.name, ), ); }, ); }, updateCoverAndIcon: (cover, icon, name) { emit( state.copyWith( icon: icon, cover: cover ?? state.cover, name: name ?? state.name, ), ); }, ); }, ); } final ViewPB view; final ViewListener? _viewListener; @override Future close() { _viewListener?.stop(); return super.close(); } } @freezed class DocumentImmersiveCoverEvent with _$DocumentImmersiveCoverEvent { const factory DocumentImmersiveCoverEvent.initial() = Initial; const factory DocumentImmersiveCoverEvent.updateCoverAndIcon( PageStyleCover? cover, EmojiIconData? icon, String? name, ) = UpdateCoverAndIcon; } @freezed class DocumentImmersiveCoverState with _$DocumentImmersiveCoverState { const factory DocumentImmersiveCoverState({ @Default(null) EmojiIconData? icon, required PageStyleCover cover, @Default('') String name, }) = _DocumentImmersiveCoverState; factory DocumentImmersiveCoverState.initial() => DocumentImmersiveCoverState( cover: PageStyleCover.none(), ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/database/widgets/database_view_widget.dart'; import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class DatabaseBlockKeys { const DatabaseBlockKeys._(); static const String gridType = 'grid'; static const String boardType = 'board'; static const String calendarType = 'calendar'; static const String parentID = 'parent_id'; static const String viewID = 'view_id'; static const String enableCompactMode = 'enable_compact_mode'; } const overflowTypes = { DatabaseBlockKeys.gridType, DatabaseBlockKeys.boardType, }; class DatabaseViewBlockComponentBuilder extends BlockComponentBuilder { DatabaseViewBlockComponentBuilder({ super.configuration, }); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return DatabaseBlockComponentWidget( key: node.key, node: node, configuration: configuration, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (node) => node.children.isEmpty && node.attributes[DatabaseBlockKeys.parentID] is String && node.attributes[DatabaseBlockKeys.viewID] is String; } class DatabaseBlockComponentWidget extends BlockComponentStatefulWidget { const DatabaseBlockComponentWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @override State createState() => _DatabaseBlockComponentWidgetState(); } class _DatabaseBlockComponentWidgetState extends State with BlockComponentConfigurable { @override Node get node => widget.node; @override BlockComponentConfiguration get configuration => widget.configuration; late StreamSubscription compactModeSubscription; EditorState? editorState; @override void initState() { super.initState(); compactModeSubscription = compactModeEventBus.on().listen((event) { if (event.id != node.id) return; final newAttributes = { ...node.attributes, DatabaseBlockKeys.enableCompactMode: event.enable, }; final theEditorState = editorState; if (theEditorState == null) return; final transaction = theEditorState.transaction; transaction.updateNode(node, newAttributes); theEditorState.apply(transaction); }); } @override void dispose() { super.dispose(); compactModeSubscription.cancel(); editorState = null; } @override Widget build(BuildContext context) { final editorState = Provider.of(context, listen: false); this.editorState = editorState; Widget child = BuiltInPageWidget( node: widget.node, editorState: editorState, builder: (view) => Provider.value( value: ReferenceState(true), child: DatabaseViewWidget( key: ValueKey(view.id), view: view, actionBuilder: widget.actionBuilder, showActions: widget.showActions, node: widget.node, ), ), ); child = FocusScope( skipTraversal: true, onFocusChange: (value) { if (value && keepEditorFocusNotifier.value == 0) { context.read().selection = null; } }, child: child, ); if (!editorState.editable) { child = IgnorePointer( child: child, ); } return child; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/inline_database_menu_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; SelectionMenuItem inlineGridMenuItem(DocumentBloc documentBloc) => SelectionMenuItem( getName: LocaleKeys.document_slashMenu_grid_createANewGrid.tr, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.grid_s, isSelected: onSelected, style: style, ), keywords: ['grid', 'database'], handler: (editorState, menuService, context) async { // create the view inside current page final parentViewId = documentBloc.documentId; final value = await ViewBackendService.createView( parentViewId: parentViewId, name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layoutType: ViewLayoutPB.Grid, ); value.map((r) => editorState.insertInlinePage(parentViewId, r)); }, ); SelectionMenuItem inlineBoardMenuItem(DocumentBloc documentBloc) => SelectionMenuItem( getName: LocaleKeys.document_slashMenu_board_createANewBoard.tr, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.board_s, isSelected: onSelected, style: style, ), keywords: ['board', 'kanban', 'database'], handler: (editorState, menuService, context) async { // create the view inside current page final parentViewId = documentBloc.documentId; final value = await ViewBackendService.createView( parentViewId: parentViewId, name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layoutType: ViewLayoutPB.Board, ); value.map((r) => editorState.insertInlinePage(parentViewId, r)); }, ); SelectionMenuItem inlineCalendarMenuItem(DocumentBloc documentBloc) => SelectionMenuItem( getName: LocaleKeys.document_slashMenu_calendar_createANewCalendar.tr, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.date_s, isSelected: onSelected, style: style, ), keywords: ['calendar', 'database'], handler: (editorState, menuService, context) async { // create the view inside current page final parentViewId = documentBloc.documentId; final value = await ViewBackendService.createView( parentViewId: parentViewId, name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layoutType: ViewLayoutPB.Calendar, ); value.map((r) => editorState.insertInlinePage(parentViewId, r)); }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; // Document Reference SelectionMenuItem referencedDocumentMenuItem = SelectionMenuItem( getName: LocaleKeys.document_plugins_referencedDocument.tr, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.icon_document_s, isSelected: onSelected, style: style, ), keywords: ['page', 'notes', 'referenced page', 'referenced document'], handler: (editorState, menuService, context) => showLinkToPageMenu( editorState, menuService, pageType: ViewLayoutPB.Document, ), ); // Database References SelectionMenuItem referencedGridMenuItem = SelectionMenuItem( getName: LocaleKeys.document_plugins_referencedGrid.tr, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.grid_s, isSelected: onSelected, style: style, ), keywords: ['referenced', 'grid', 'database'], handler: (editorState, menuService, context) => showLinkToPageMenu( editorState, menuService, pageType: ViewLayoutPB.Grid, ), ); SelectionMenuItem referencedBoardMenuItem = SelectionMenuItem( getName: LocaleKeys.document_plugins_referencedBoard.tr, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.board_s, isSelected: onSelected, style: style, ), keywords: ['referenced', 'board', 'kanban'], handler: (editorState, menuService, context) => showLinkToPageMenu( editorState, menuService, pageType: ViewLayoutPB.Board, ), ); SelectionMenuItem referencedCalendarMenuItem = SelectionMenuItem( getName: LocaleKeys.document_plugins_referencedCalendar.tr, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.date_s, isSelected: onSelected, style: style, ), keywords: ['referenced', 'calendar', 'database'], handler: (editorState, menuService, context) => showLinkToPageMenu( editorState, menuService, pageType: ViewLayoutPB.Calendar, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; typedef MentionPageNameGetter = Future Function(String pageId); extension TextDeltaExtension on Delta { /// Convert the delta to a text string. /// /// Unlike the [toPlainText], this method will keep the mention text /// such as mentioned page name, mentioned block content. /// /// If the mentioned page or mentioned block not found, it will downgrade to /// the default plain text. Future toText({ required MentionPageNameGetter getMentionPageName, }) async { final defaultPlainText = toPlainText(); String text = ''; final ops = iterator; while (ops.moveNext()) { final op = ops.current; final attributes = op.attributes; if (op is TextInsert) { // if the text is '\$', it means the block text is empty, // the real data is in the attributes if (op.text == MentionBlockKeys.mentionChar) { final mention = attributes?[MentionBlockKeys.mention]; final mentionPageId = mention?[MentionBlockKeys.pageId]; final mentionType = mention?[MentionBlockKeys.type]; if (mentionPageId != null) { text += await getMentionPageName(mentionPageId); continue; } else if (mentionType == MentionType.externalLink.name) { final url = mention?[MentionBlockKeys.url] ?? ''; final info = await LinkInfoCache.get(url); text += info?.title ?? url; continue; } } text += op.text; } else { // if the delta contains other types of operations, // return the default plain text return defaultPlainText; } } return text; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/overlay_util.dart'; import 'package:flutter/material.dart'; class ColorPicker extends StatefulWidget { const ColorPicker({ super.key, required this.title, required this.selectedColorHex, required this.onSubmittedColorHex, required this.colorOptions, this.resetText, this.customColorHex, this.resetIconName, this.showClearButton = false, }); final String title; final String? selectedColorHex; final String? customColorHex; final void Function(String? color, bool isCustomColor) onSubmittedColorHex; final String? resetText; final String? resetIconName; final bool showClearButton; final List colorOptions; @override State createState() => _ColorPickerState(); } class _ColorPickerState extends State { final TextEditingController _colorHexController = TextEditingController(); final TextEditingController _colorOpacityController = TextEditingController(); @override void initState() { super.initState(); final selectedColorHex = widget.selectedColorHex, customColorHex = widget.customColorHex; _colorHexController.text = _extractColorHex(customColorHex ?? selectedColorHex) ?? 'FFFFFF'; _colorOpacityController.text = _convertHexToOpacity(customColorHex ?? selectedColorHex) ?? '100'; } @override Widget build(BuildContext context) { return basicOverlay( context, width: 300, height: 250, children: [ EditorOverlayTitle(text: widget.title), const SizedBox(height: 6), widget.showClearButton && widget.resetText != null && widget.resetIconName != null ? ResetColorButton( resetText: widget.resetText!, resetIconName: widget.resetIconName!, onPressed: (color) => widget.onSubmittedColorHex.call(color, false), ) : const SizedBox.shrink(), CustomColorItem( colorController: _colorHexController, opacityController: _colorOpacityController, onSubmittedColorHex: (color) => widget.onSubmittedColorHex.call(color, true), ), _buildColorItems( widget.colorOptions, widget.selectedColorHex, ), ], ); } Widget _buildColorItems( List options, String? selectedColor, ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: options .map((e) => _buildColorItem(e, e.colorHex == selectedColor)) .toList(), ); } Widget _buildColorItem(ColorOption option, bool isChecked) { return SizedBox( height: 36, child: TextButton.icon( onPressed: () { widget.onSubmittedColorHex(option.colorHex, false); }, icon: SizedBox.square( dimension: 12, child: Container( decoration: BoxDecoration( color: option.colorHex.tryToColor(), shape: BoxShape.circle, ), ), ), style: buildOverlayButtonStyle(context), label: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( option.name, softWrap: false, maxLines: 1, overflow: TextOverflow.fade, style: TextStyle( color: Theme.of(context).textTheme.labelLarge?.color, ), ), ), // checkbox if (isChecked) const FlowySvg(FlowySvgs.toolbar_check_m), ], ), ), ); } String? _convertHexToOpacity(String? colorHex) { if (colorHex == null) return null; final opacityHex = colorHex.substring(2, 4); final opacity = int.parse(opacityHex, radix: 16) / 2.55; return opacity.toStringAsFixed(0); } String? _extractColorHex(String? colorHex) { if (colorHex == null) return null; return colorHex.substring(4); } } class ResetColorButton extends StatelessWidget { const ResetColorButton({ super.key, required this.resetText, required this.resetIconName, required this.onPressed, }); final Function(String? color) onPressed; final String resetText; final String resetIconName; @override Widget build(BuildContext context) { return SizedBox( width: double.infinity, height: 32, child: TextButton.icon( onPressed: () => onPressed(null), icon: EditorSvg( name: resetIconName, width: 13, height: 13, color: Theme.of(context).iconTheme.color, ), label: Text( resetText, style: TextStyle( color: Theme.of(context).hintColor, ), textAlign: TextAlign.left, ), style: ButtonStyle( backgroundColor: WidgetStateProperty.resolveWith( (Set states) { if (states.contains(WidgetState.hovered)) { return Theme.of(context).hoverColor; } return Colors.transparent; }, ), alignment: Alignment.centerLeft, ), ), ); } } class CustomColorItem extends StatefulWidget { const CustomColorItem({ super.key, required this.colorController, required this.opacityController, required this.onSubmittedColorHex, }); final TextEditingController colorController; final TextEditingController opacityController; final void Function(String color) onSubmittedColorHex; @override State createState() => _CustomColorItemState(); } class _CustomColorItemState extends State { @override Widget build(BuildContext context) { return ExpansionTile( tilePadding: const EdgeInsets.only(left: 8), shape: Border.all( color: Colors.transparent, ), // remove the default border when it is expanded title: Row( children: [ // color sample box SizedBox.square( dimension: 12, child: Container( decoration: BoxDecoration( color: Color( int.tryParse( _combineColorHexAndOpacity( widget.colorController.text, widget.opacityController.text, ), ) ?? 0xFFFFFFFF, ), shape: BoxShape.circle, ), ), ), const SizedBox(width: 8), Expanded( child: Text( AppFlowyEditorL10n.current.customColor, style: Theme.of(context).textTheme.labelLarge, // same style as TextButton.icon ), ), ], ), children: [ const SizedBox(height: 6), _customColorDetailsTextField( labelText: AppFlowyEditorL10n.current.hexValue, controller: widget.colorController, // update the color sample box when the text changes onChanged: (_) => setState(() {}), onSubmitted: _submitCustomColorHex, ), const SizedBox(height: 10), _customColorDetailsTextField( labelText: AppFlowyEditorL10n.current.opacity, controller: widget.opacityController, // update the color sample box when the text changes onChanged: (_) => setState(() {}), onSubmitted: _submitCustomColorHex, ), const SizedBox(height: 6), ], ); } Widget _customColorDetailsTextField({ required String labelText, required TextEditingController controller, Function(String)? onChanged, Function(String)? onSubmitted, }) { return Padding( padding: const EdgeInsets.only(right: 3), child: TextField( controller: controller, decoration: InputDecoration( labelText: labelText, border: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.outline, ), ), ), style: Theme.of(context).textTheme.bodyMedium, onChanged: onChanged, onSubmitted: onSubmitted, ), ); } String _combineColorHexAndOpacity(String colorHex, String opacity) { colorHex = _fixColorHex(colorHex); opacity = _fixOpacity(opacity); final opacityHex = (int.parse(opacity) * 2.55).round().toRadixString(16); return '0x$opacityHex$colorHex'; } String _fixColorHex(String colorHex) { if (colorHex.length > 6) { colorHex = colorHex.substring(0, 6); } if (int.tryParse(colorHex, radix: 16) == null) { colorHex = 'FFFFFF'; } return colorHex; } String _fixOpacity(String opacity) { // if opacity is 0 - 99, return it // otherwise return 100 final RegExp regex = RegExp('^(0|[1-9][0-9]?)'); if (regex.hasMatch(opacity)) { return opacity; } else { return '100'; } } void _submitCustomColorHex(String value) { final String color = _combineColorHexAndOpacity( widget.colorController.text, widget.opacityController.text, ); widget.onSubmittedColorHex(color); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'toolbar_animation.dart'; class DesktopFloatingToolbar extends StatefulWidget { const DesktopFloatingToolbar({ super.key, required this.editorState, required this.child, required this.onDismiss, this.enableAnimation = true, }); final EditorState editorState; final Widget child; final VoidCallback onDismiss; final bool enableAnimation; @override State createState() => _DesktopFloatingToolbarState(); } class _DesktopFloatingToolbarState extends State { EditorState get editorState => widget.editorState; _Position? position; final toolbarController = getIt(); @override void initState() { super.initState(); final selection = editorState.selection; if (selection == null || selection.isCollapsed) { return; } final selectionRect = editorState.selectionRects(); if (selectionRect.isEmpty) return; position = calculateSelectionMenuOffset(selectionRect.first); toolbarController._addCallback(dismiss); } @override void dispose() { toolbarController._removeCallback(dismiss); super.dispose(); } @override Widget build(BuildContext context) { if (position == null) return Container(); return Positioned( left: position!.left, top: position!.top, right: position!.right, child: widget.enableAnimation ? ToolbarAnimationWidget(child: widget.child) : widget.child, ); } void dismiss() { widget.onDismiss.call(); } _Position calculateSelectionMenuOffset( Rect rect, ) { const toolbarHeight = 40, topLimit = toolbarHeight + 8; final bool isLongMenu = onlyShowInSingleSelectionAndTextType(editorState); final editorOffset = editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; final editorSize = editorState.renderBox?.size ?? Size.zero; final menuWidth = isLongMenu ? (isNarrowWindow(editorState) ? 490.0 : 660.0) : 420.0; final editorRect = editorOffset & editorSize; final left = rect.left, leftStart = 50; final top = rect.top < topLimit ? rect.bottom + topLimit : rect.top - topLimit; if (left + menuWidth > editorRect.right) { return _Position( editorRect.right - menuWidth, top, null, ); } else if (rect.left - leftStart > 0) { return _Position(rect.left - leftStart, top, null); } else { return _Position(rect.left, top, null); } } } class _Position { _Position(this.left, this.top, this.right); final double? left; final double? top; final double? right; } class FloatingToolbarController { final Set _dismissCallbacks = {}; final Set _displayListeners = {}; void _addCallback(VoidCallback callback) { _dismissCallbacks.add(callback); for (final listener in Set.of(_displayListeners)) { listener.call(); } } void _removeCallback(VoidCallback callback) => _dismissCallbacks.remove(callback); bool get isToolbarShowing => _dismissCallbacks.isNotEmpty; void addDisplayListener(VoidCallback listener) => _displayListeners.add(listener); void removeDisplayListener(VoidCallback listener) => _displayListeners.remove(listener); void hideToolbar() { if (_dismissCallbacks.isEmpty) return; for (final callback in _dismissCallbacks) { callback.call(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'link_search_text_field.dart'; class LinkCreateMenu extends StatefulWidget { const LinkCreateMenu({ super.key, required this.editorState, required this.onSubmitted, required this.onDismiss, required this.alignment, required this.currentViewId, required this.initialText, }); final EditorState editorState; final void Function(String link, bool isPage) onSubmitted; final VoidCallback onDismiss; final String currentViewId; final String initialText; final LinkMenuAlignment alignment; @override State createState() => _LinkCreateMenuState(); } class _LinkCreateMenuState extends State { late LinkSearchTextField searchTextField = LinkSearchTextField( currentViewId: widget.currentViewId, initialSearchText: widget.initialText, onEnter: () { searchTextField.onSearchResult( onLink: () => onSubmittedLink(), onRecentViews: () => onSubmittedPageLink(searchTextField.currentRecentView), onSearchViews: () => onSubmittedPageLink(searchTextField.currentSearchedView), onEmpty: () {}, ); }, onEscape: widget.onDismiss, onDataRefresh: () { if (mounted) setState(() {}); }, ); bool get isTextfieldEnable => searchTextField.isTextfieldEnable; String get searchText => searchTextField.searchText; bool get showAtTop => widget.alignment.isTop; bool showErrorText = false; @override void initState() { super.initState(); searchTextField.requestFocus(); searchTextField.searchRecentViews(); final focusNode = searchTextField.focusNode; bool hasFocus = focusNode.hasFocus; focusNode.addListener(() { if (hasFocus != focusNode.hasFocus && mounted) { setState(() { hasFocus = focusNode.hasFocus; }); } }); } @override void dispose() { searchTextField.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( width: 320, child: Column( children: showAtTop ? [ searchTextField.buildResultContainer( margin: EdgeInsets.only(bottom: 2), context: context, onLinkSelected: onSubmittedLink, onPageLinkSelected: onSubmittedPageLink, ), buildSearchContainer(), ] : [ buildSearchContainer(), searchTextField.buildResultContainer( margin: EdgeInsets.only(top: 2), context: context, onLinkSelected: onSubmittedLink, onPageLinkSelected: onSubmittedPageLink, ), ], ), ); } Widget buildSearchContainer() { final theme = AppFlowyTheme.maybeOf(context); return Container( width: 320, decoration: buildToolbarLinkDecoration(context), padding: EdgeInsets.all(8), child: ValueListenableBuilder( valueListenable: searchTextField.textEditingController, builder: (context, _, __) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: searchTextField.buildTextField(context: context), ), HSpace(8), FlowyTextButton( LocaleKeys.document_toolbar_insert.tr(), mainAxisAlignment: MainAxisAlignment.center, padding: EdgeInsets.zero, constraints: BoxConstraints(maxWidth: 72, minHeight: 32), fontSize: 14, fontColor: Colors.white, fillColor: theme?.fillColorScheme.themeThick, hoverColor: theme?.fillColorScheme.themeThick.withAlpha(200), lineHeight: 20 / 14, fontWeight: FontWeight.w600, onPressed: onSubmittedLink, ), ], ), if (showErrorText) Padding( padding: const EdgeInsets.only(top: 4), child: FlowyText.regular( LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), color: theme?.textColorScheme.error, fontSize: 12, figmaLineHeight: 16, ), ), ], ); }, ), ); } void onSubmittedLink() { if (!isTextfieldEnable) { setState(() { showErrorText = true; }); return; } widget.onSubmitted(searchText, false); } void onSubmittedPageLink(ViewPB view) async { final workspaceId = context .read() ?.state .currentWorkspace ?.workspaceId ?? ''; final link = ShareConstants.buildShareUrl( workspaceId: workspaceId, viewId: view.id, ); widget.onSubmitted(link, true); } } void showLinkCreateMenu( BuildContext context, EditorState editorState, Selection selection, String currentViewId, ) { if (!context.mounted) return; final (left, top, right, bottom, alignment) = _getPosition(editorState); final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return; } final selectedText = editorState.getTextInSelection(selection).join(); OverlayEntry? overlay; void dismissOverlay() { keepEditorFocusNotifier.decrease(); overlay?.remove(); overlay = null; } keepEditorFocusNotifier.increase(); overlay = FullScreenOverlayEntry( top: top, bottom: bottom, left: left, right: right, dismissCallback: () => keepEditorFocusNotifier.decrease(), builder: (context) { return LinkCreateMenu( alignment: alignment, initialText: selectedText, currentViewId: currentViewId, editorState: editorState, onSubmitted: (link, isPage) async { await editorState.formatDelta(selection, { BuiltInAttributeKey.href: link, kIsPageLink: isPage, }); await editorState.updateSelectionWithReason( null, reason: SelectionUpdateReason.uiEvent, ); dismissOverlay(); }, onDismiss: dismissOverlay, ); }, ).build(); Overlay.of(context, rootOverlay: true).insert(overlay!); } // get a proper position for link menu ( double? left, double? top, double? right, double? bottom, LinkMenuAlignment alignment, ) _getPosition( EditorState editorState, ) { final rect = editorState.selectionRects().first; const menuHeight = 222.0, menuWidth = 320.0; double? left, right, top, bottom; LinkMenuAlignment alignment = LinkMenuAlignment.topLeft; final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero), editorSize = editorState.renderBox!.size; final editorBottom = editorSize.height + editorOffset.dy, editorRight = editorSize.width + editorOffset.dx; final overflowBottom = rect.bottom + menuHeight > editorBottom, overflowTop = rect.top - menuHeight < 0, overflowLeft = rect.left - menuWidth < 0, overflowRight = rect.right + menuWidth > editorRight; if (overflowTop && !overflowBottom) { /// show at bottom top = rect.bottom; } else if (overflowBottom && !overflowTop) { /// show at top bottom = editorBottom - rect.top; } else if (!overflowTop && !overflowBottom) { /// show at bottom top = rect.bottom; } else { top = 0; } if (overflowLeft && !overflowRight) { /// show at right left = rect.left; } else if (overflowRight && !overflowLeft) { /// show at left right = editorRight - rect.right; } else if (!overflowLeft && !overflowRight) { /// show at right left = rect.left; } else { left = 0; } if (left != null && top != null) { alignment = LinkMenuAlignment.bottomRight; } else if (left != null && bottom != null) { alignment = LinkMenuAlignment.topRight; } else if (right != null && top != null) { alignment = LinkMenuAlignment.bottomLeft; } else if (right != null && bottom != null) { alignment = LinkMenuAlignment.topLeft; } return (left, top, right, bottom, alignment); } ShapeDecoration buildToolbarLinkDecoration( BuildContext context, { double radius = 12.0, }) { final theme = AppFlowyTheme.of(context); return ShapeDecoration( color: theme.surfaceColorScheme.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radius), ), shadows: theme.shadow.small, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/util/link_util.dart'; import 'package:flutter/services.dart'; import 'link_create_menu.dart'; import 'link_search_text_field.dart'; import 'link_styles.dart'; class LinkEditMenu extends StatefulWidget { const LinkEditMenu({ super.key, required this.linkInfo, required this.onDismiss, required this.onApply, required this.onRemoveLink, required this.currentViewId, }); final LinkInfo linkInfo; final ValueChanged onApply; final ValueChanged onRemoveLink; final VoidCallback onDismiss; final String currentViewId; @override State createState() => _LinkEditMenuState(); } class _LinkEditMenuState extends State { ValueChanged get onRemoveLink => widget.onRemoveLink; VoidCallback get onDismiss => widget.onDismiss; late TextEditingController linkNameController = TextEditingController(text: linkInfo.name); late FocusNode textFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent); late FocusNode menuFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent); late LinkInfo linkInfo = widget.linkInfo; late LinkSearchTextField searchTextField; bool isShowingSearchResult = false; ViewPB? currentView; bool showErrorText = false; AppFlowyThemeData? get theme => AppFlowyTheme.maybeOf(context); @override void initState() { super.initState(); final isPageLink = linkInfo.isPage; if (isPageLink) getPageView(); searchTextField = LinkSearchTextField( initialSearchText: isPageLink ? '' : linkInfo.link, initialViewId: linkInfo.viewId, currentViewId: widget.currentViewId, onEnter: onConfirm, onEscape: () { if (isShowingSearchResult) { hideSearchResult(); } else { onDismiss(); } }, onDataRefresh: () { if (mounted) setState(() {}); }, )..searchRecentViews(); makeSureHasFocus(); } @override void dispose() { linkNameController.dispose(); textFocusNode.dispose(); menuFocusNode.dispose(); searchTextField.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final showingRecent = searchTextField.showingRecent && isShowingSearchResult; final errorHeight = showErrorText ? 20.0 : 0.0; final theme = AppFlowyTheme.of(context); return GestureDetector( onTap: onDismiss, child: Focus( focusNode: menuFocusNode, child: Container( width: 400, height: 250 + (showingRecent ? 32 : 0), color: Colors.white.withAlpha(1), child: Stack( children: [ GestureDetector( onTap: hideSearchResult, child: Container( width: 400, height: 192 + errorHeight, decoration: buildToolbarLinkDecoration(context), ), ), Positioned( top: 16, left: 20, child: FlowyText.semibold( LocaleKeys.document_toolbar_pageOrURL.tr(), color: theme.textColorScheme.tertiary, fontSize: 12, figmaLineHeight: 16, ), ), Positioned( top: 80 + errorHeight, left: 20, child: FlowyText.semibold( LocaleKeys.document_toolbar_linkName.tr(), color: theme.textColorScheme.tertiary, fontSize: 12, figmaLineHeight: 16, ), ), Positioned( top: 144 + errorHeight, left: 20, child: buildButtons(), ), Positioned( top: 100 + errorHeight, left: 20, child: buildNameTextField(), ), Positioned( top: 36, left: 20, child: buildLinkField(), ), ], ), ), ), ); } Widget buildLinkField() { final showPageView = linkInfo.isPage && !isShowingSearchResult; Widget child; if (showPageView) { child = buildPageView(); } else if (!isShowingSearchResult) { child = buildLinkView(); } else { return SizedBox( width: 360, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 360, height: 32, child: searchTextField.buildTextField( autofocus: true, context: context, ), ), VSpace(6), searchTextField.buildResultContainer( context: context, width: 360, onPageLinkSelected: onPageSelected, onLinkSelected: onLinkSelected, ), ], ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ child, if (showErrorText) Padding( padding: const EdgeInsets.only(top: 4), child: FlowyText.regular( LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), color: theme?.textColorScheme.error, fontSize: 12, figmaLineHeight: 16, ), ), ], ); } Widget buildButtons() { return GestureDetector( onTap: hideSearchResult, child: SizedBox( width: 360, height: 32, child: Row( children: [ FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), width: 32, height: 32, tooltipText: LocaleKeys.editor_removeLink.tr(), preferBelow: false, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(8)), border: Border.all(color: LinkStyle.borderColor(context)), ), onPressed: () => onRemoveLink.call(linkInfo), ), Spacer(), DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(8)), border: Border.all(color: LinkStyle.borderColor(context)), ), child: FlowyTextButton( LocaleKeys.button_cancel.tr(), padding: EdgeInsets.zero, mainAxisAlignment: MainAxisAlignment.center, constraints: BoxConstraints(maxWidth: 78, minHeight: 32), fontSize: 14, lineHeight: 20 / 14, fontColor: theme?.textColorScheme.primary, fillColor: Colors.transparent, fontWeight: FontWeight.w400, onPressed: onDismiss, ), ), HSpace(12), ValueListenableBuilder( valueListenable: linkNameController, builder: (context, _, __) { return FlowyTextButton( LocaleKeys.settings_appearance_documentSettings_apply.tr(), padding: EdgeInsets.zero, mainAxisAlignment: MainAxisAlignment.center, constraints: BoxConstraints(maxWidth: 78, minHeight: 32), fontSize: 14, lineHeight: 20 / 14, hoverColor: theme?.fillColorScheme.themeThick.withAlpha(200), fontColor: Colors.white, fillColor: theme?.fillColorScheme.themeThick, fontWeight: FontWeight.w400, onPressed: onApply, ); }, ), ], ), ), ); } Widget buildNameTextField() { return SizedBox( width: 360, height: 32, child: TextFormField( autovalidateMode: AutovalidateMode.onUserInteraction, focusNode: textFocusNode, autofocus: true, textAlign: TextAlign.left, controller: linkNameController, style: TextStyle( fontSize: 14, height: 20 / 14, fontWeight: FontWeight.w400, ), onChanged: (text) { linkInfo = LinkInfo( name: text, link: linkInfo.link, isPage: linkInfo.isPage, ); }, decoration: LinkStyle.buildLinkTextFieldInputDecoration( LocaleKeys.document_toolbar_linkNameHint.tr(), context, ), ), ); } Widget buildPageView() { late Widget child; final view = currentView; if (view == null) { child = Center( child: SizedBox.fromSize( size: Size(10, 10), child: CircularProgressIndicator(), ), ); } else { final viewName = view.name; final displayName = viewName.isEmpty ? LocaleKeys.document_title_placeholder.tr() : viewName; child = GestureDetector( onTap: showSearchResult, child: MouseRegion( cursor: SystemMouseCursors.click, child: FlowyTooltip( preferBelow: false, message: displayName, child: Container( height: 32, padding: EdgeInsets.fromLTRB(8, 0, 8, 0), child: Row( children: [ searchTextField.buildIcon(view), HSpace(4), Flexible( child: FlowyText.regular( displayName, overflow: TextOverflow.ellipsis, figmaLineHeight: 20, fontSize: 14, ), ), ], ), ), ), ), ); } return Container( width: 360, height: 32, decoration: buildDecoration(), child: child, ); } Widget buildLinkView() { return Container( width: 360, height: 32, decoration: buildDecoration(), child: FlowyTooltip( preferBelow: false, message: linkInfo.link, child: GestureDetector( onTap: showSearchResult, child: MouseRegion( cursor: SystemMouseCursors.click, child: Padding( padding: EdgeInsets.fromLTRB(8, 6, 8, 6), child: Row( children: [ FlowySvg(FlowySvgs.toolbar_link_earth_m), HSpace(8), Flexible( child: FlowyText.regular( linkInfo.link, overflow: TextOverflow.ellipsis, figmaLineHeight: 20, ), ), ], ), ), ), ), ), ); } KeyEventResult onFocusKeyEvent(FocusNode node, KeyEvent key) { if (key is! KeyDownEvent) return KeyEventResult.ignored; if (key.logicalKey == LogicalKeyboardKey.enter) { onApply(); return KeyEventResult.handled; } else if (key.logicalKey == LogicalKeyboardKey.escape) { onDismiss(); return KeyEventResult.handled; } return KeyEventResult.ignored; } Future makeSureHasFocus() async { final focusNode = textFocusNode; if (!mounted || focusNode.hasFocus) return; focusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { makeSureHasFocus(); }); } void onApply() { if (isShowingSearchResult) { onConfirm(); return; } if (linkInfo.link.isEmpty) { widget.onRemoveLink(linkInfo); return; } if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) { setState(() { showErrorText = true; }); return; } widget.onApply.call(linkInfo); onDismiss(); } void onConfirm() { searchTextField.onSearchResult( onLink: onLinkSelected, onRecentViews: () => onPageSelected(searchTextField.currentRecentView), onSearchViews: () => onPageSelected(searchTextField.currentSearchedView), onEmpty: () { searchTextField.unfocus(); }, ); menuFocusNode.requestFocus(); } Future getPageView() async { if (!linkInfo.isPage) return; final (view, isInTrash, isDeleted) = await ViewBackendService.getMentionPageStatus(linkInfo.viewId); if (mounted) { setState(() { currentView = view; }); } } void showSearchResult() { setState(() { if (linkInfo.isPage) searchTextField.updateText(''); isShowingSearchResult = true; searchTextField.requestFocus(); }); } void hideSearchResult() { setState(() { isShowingSearchResult = false; searchTextField.unfocus(); textFocusNode.unfocus(); }); } void onLinkSelected() { if (mounted) { linkInfo = LinkInfo( name: linkInfo.name, link: searchTextField.searchText, ); hideSearchResult(); } } Future onPageSelected(ViewPB view) async { currentView = view; final link = ShareConstants.buildShareUrl( workspaceId: await UserBackendService.getCurrentWorkspace().fold( (s) => s.id, (f) => '', ), viewId: view.id, ); linkInfo = LinkInfo( name: linkInfo.name, link: link, isPage: true, ); searchTextField.updateText(linkInfo.link); if (mounted) { setState(() { isShowingSearchResult = false; searchTextField.unfocus(); }); } } BoxDecoration buildDecoration() => BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: LinkStyle.borderColor(context)), ); } class LinkInfo { LinkInfo({this.isPage = false, required this.name, required this.link}); final bool isPage; final String name; final String link; Attributes toAttribute() => {AppFlowyRichTextKeys.href: link, kIsPageLink: isPage}; String get viewId => isPage ? link.split('/').lastOrNull ?? '' : ''; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_extension.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension LinkExtension on EditorState { void removeLink(Selection selection) { final node = getNodeAtPath(selection.end.path); if (node == null) { return; } final index = selection.normalized.startIndex; final length = selection.length; final transaction = this.transaction ..formatText( node, index, length, { BuiltInAttributeKey.href: null, kIsPageLink: null, }, ); apply(transaction); } void applyLink(Selection selection, LinkInfo info) { final node = getNodeAtPath(selection.start.path); if (node == null) return; final transaction = this.transaction; final linkName = info.name.isEmpty ? info.link : info.name; transaction.replaceText( node, selection.startIndex, selection.length, linkName, attributes: info.toAttribute(), ); apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart ================================================ import 'dart:math'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/link_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import 'link_create_menu.dart'; import 'link_edit_menu.dart'; import 'link_extension.dart'; class LinkHoverTrigger extends StatefulWidget { const LinkHoverTrigger({ super.key, required this.editorState, required this.selection, required this.node, required this.attribute, required this.size, this.delayToShow = const Duration(milliseconds: 50), this.delayToHide = const Duration(milliseconds: 300), }); final EditorState editorState; final Selection selection; final Node node; final Attributes attribute; final Size size; final Duration delayToShow; final Duration delayToHide; @override State createState() => _LinkHoverTriggerState(); } class _LinkHoverTriggerState extends State { final hoverMenuController = PopoverController(); final editMenuController = PopoverController(); final toolbarController = getIt(); bool isHoverMenuShowing = false; bool isHoverMenuHovering = false; bool isHoverTriggerHovering = false; Size get size => widget.size; EditorState get editorState => widget.editorState; Selection get selection => widget.selection; Attributes get attribute => widget.attribute; late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection); @override void initState() { super.initState(); getIt()._add(triggerKey, showLinkHoverMenu); toolbarController.addDisplayListener(onToolbarShow); } @override void dispose() { hoverMenuController.close(); editMenuController.close(); getIt()._remove(triggerKey, showLinkHoverMenu); toolbarController.removeDisplayListener(onToolbarShow); super.dispose(); } @override Widget build(BuildContext context) { final placeHolder = Container( color: Colors.black.withAlpha(1), width: size.width, height: size.height, ); if (UniversalPlatform.isMobile) { return GestureDetector( onTap: openLink, onLongPress: () async { await showEditLinkBottomSheet(context, selection, editorState); }, child: placeHolder, ); } return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (v) { isHoverTriggerHovering = true; Future.delayed(widget.delayToShow, () { if (isHoverTriggerHovering && !isHoverMenuShowing) { showLinkHoverMenu(); } }); }, onExit: (v) { isHoverTriggerHovering = false; tryToDismissLinkHoverMenu(); }, child: buildHoverPopover(buildEditPopover(placeHolder)), ); } Widget buildHoverPopover(Widget child) { return AppFlowyPopover( controller: hoverMenuController, direction: PopoverDirection.topWithLeftAligned, offset: Offset(0, size.height), onOpen: () { keepEditorFocusNotifier.increase(); isHoverMenuShowing = true; }, onClose: () { keepEditorFocusNotifier.decrease(); isHoverMenuShowing = false; }, margin: EdgeInsets.zero, constraints: BoxConstraints( maxWidth: max(320, size.width), maxHeight: 48 + size.height, ), decorationColor: Colors.transparent, popoverDecoration: BoxDecoration(), popupBuilder: (context) => LinkHoverMenu( attribute: widget.attribute, triggerSize: size, editable: editorState.editable, onEnter: (_) { isHoverMenuHovering = true; }, onExit: (_) { isHoverMenuHovering = false; tryToDismissLinkHoverMenu(); }, onConvertTo: (type) => convertLinkTo(editorState, selection, type), onOpenLink: openLink, onCopyLink: () => copyLink(context), onEditLink: showLinkEditMenu, onRemoveLink: () => editorState.removeLink(selection), ), child: child, ); } Widget buildEditPopover(Widget child) { final href = attribute.href ?? '', isPage = attribute.isPage, title = editorState.getTextInSelection(selection).join(); final currentViewId = context.read()?.documentId ?? ''; return AppFlowyPopover( controller: editMenuController, direction: PopoverDirection.bottomWithLeftAligned, offset: Offset(0, 0), onOpen: () => keepEditorFocusNotifier.increase(), onClose: () => keepEditorFocusNotifier.decrease(), margin: EdgeInsets.zero, asBarrier: true, decorationColor: Colors.transparent, popoverDecoration: BoxDecoration(), constraints: BoxConstraints( maxWidth: 400, minHeight: 282, ), popupBuilder: (context) => LinkEditMenu( currentViewId: currentViewId, linkInfo: LinkInfo(name: title, link: href, isPage: isPage), onDismiss: () => editMenuController.close(), onApply: (info) => editorState.applyLink(selection, info), onRemoveLink: (linkinfo) { final replaceText = linkinfo.name.isEmpty ? linkinfo.link : linkinfo.name; onRemoveAndReplaceLink(editorState, selection, replaceText); }, ), child: child, ); } void onToolbarShow() => hoverMenuController.close(); void showLinkHoverMenu() { if (UniversalPlatform.isMobile || isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) { return; } keepEditorFocusNotifier.increase(); hoverMenuController.show(); } void showLinkEditMenu() { if (UniversalPlatform.isMobile) return; keepEditorFocusNotifier.increase(); hoverMenuController.close(); editMenuController.show(); } void tryToDismissLinkHoverMenu() { Future.delayed(widget.delayToHide, () { if (isHoverMenuHovering || isHoverTriggerHovering) { return; } hoverMenuController.close(); }); } Future openLink() async { final href = widget.attribute.href ?? '', isPage = widget.attribute.isPage; if (isPage) { final viewId = href.split('/').lastOrNull ?? ''; if (viewId.isEmpty) { await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true); } else { final (view, isInTrash, isDeleted) = await ViewBackendService.getMentionPageStatus(viewId); if (view != null) { await handleMentionBlockTap(context, widget.editorState, view); } } } else { await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true); } } Future copyLink(BuildContext context) async { final href = widget.attribute.href ?? ''; await context.copyLink(href); hoverMenuController.close(); } Future convertLinkTo( EditorState editorState, Selection selection, LinkConvertMenuCommand type, ) async { final url = widget.attribute.href ?? ''; if (type == LinkConvertMenuCommand.toBookmark) { await convertUrlToLinkPreview(editorState, selection, url); } else if (type == LinkConvertMenuCommand.toMention) { await convertUrlToMention(editorState, selection); } else if (type == LinkConvertMenuCommand.toEmbed) { await convertUrlToLinkPreview( editorState, selection, url, previewType: LinkEmbedKeys.embed, ); } } void onRemoveAndReplaceLink( EditorState editorState, Selection selection, String text, ) { final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return; } final index = selection.normalized.startIndex; final length = selection.length; final transaction = editorState.transaction ..replaceText( node, index, length, text, attributes: { BuiltInAttributeKey.href: null, kIsPageLink: null, }, ); editorState.apply(transaction); } } class LinkHoverMenu extends StatefulWidget { const LinkHoverMenu({ super.key, required this.attribute, required this.onEnter, required this.onExit, required this.editable, required this.triggerSize, required this.onCopyLink, required this.onOpenLink, required this.onEditLink, required this.onRemoveLink, required this.onConvertTo, }); final Attributes attribute; final PointerEnterEventListener onEnter; final PointerExitEventListener onExit; final Size triggerSize; final VoidCallback onCopyLink; final VoidCallback onOpenLink; final VoidCallback onEditLink; final VoidCallback onRemoveLink; final bool editable; final ValueChanged onConvertTo; @override State createState() => _LinkHoverMenuState(); } class _LinkHoverMenuState extends State { ViewPB? currentView; late bool isPage = widget.attribute.isPage; late String href = widget.attribute.href ?? ''; final popoverController = PopoverController(); bool isConvertButtonSelected = false; bool get editable => widget.editable; @override void initState() { super.initState(); if (isPage) getPageView(); } @override void dispose() { super.dispose(); popoverController.close(); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MouseRegion( onEnter: widget.onEnter, onExit: widget.onExit, child: SizedBox( width: max(320, widget.triggerSize.width), height: 48, child: Align( alignment: Alignment.centerLeft, child: Container( width: 320, height: 48, decoration: buildToolbarLinkDecoration(context), padding: EdgeInsets.fromLTRB(12, 8, 8, 8), child: Row( children: [ Expanded(child: buildLinkWidget()), Container( height: 20, width: 1, color: Color(0xffE8ECF3) .withAlpha(Theme.of(context).isLightMode ? 255 : 40), margin: EdgeInsets.symmetric(horizontal: 6), ), FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_m), tooltipText: LocaleKeys.editor_copyLink.tr(), preferBelow: false, width: 36, height: 32, onPressed: widget.onCopyLink, ), FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_edit_m), tooltipText: LocaleKeys.editor_editLink.tr(), hoverColor: hoverColor, preferBelow: false, width: 36, height: 32, onPressed: getTapCallback(widget.onEditLink), ), buildConvertButton(), FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m), tooltipText: LocaleKeys.editor_removeLink.tr(), hoverColor: hoverColor, preferBelow: false, width: 36, height: 32, onPressed: getTapCallback(widget.onRemoveLink), ), ], ), ), ), ), ), MouseRegion( cursor: SystemMouseCursors.click, onEnter: widget.onEnter, onExit: widget.onExit, child: GestureDetector( onTap: widget.onOpenLink, child: Container( width: widget.triggerSize.width, height: widget.triggerSize.height, color: Colors.black.withAlpha(1), ), ), ), ], ); } Future getPageView() async { final viewId = href.split('/').lastOrNull ?? ''; final (view, isInTrash, isDeleted) = await ViewBackendService.getMentionPageStatus(viewId); if (mounted) { setState(() { currentView = view; }); } } Widget buildLinkWidget() { final view = currentView; if (isPage && view == null) { return SizedBox.square( dimension: 20, child: CircularProgressIndicator(), ); } String text = ''; if (isPage && view != null) { text = view.name; if (text.isEmpty) { text = LocaleKeys.document_title_placeholder.tr(); } } else { text = href; } return FlowyTooltip( message: text, preferBelow: false, child: FlowyText.regular( text, overflow: TextOverflow.ellipsis, figmaLineHeight: 20, fontSize: 14, ), ); } Widget buildConvertButton() { final button = FlowyIconButton( icon: FlowySvg(FlowySvgs.turninto_m), isSelected: isConvertButtonSelected, tooltipText: LocaleKeys.editor_convertTo.tr(), preferBelow: false, hoverColor: hoverColor, width: 36, height: 32, onPressed: getTapCallback(() { setState(() { isConvertButtonSelected = true; }); showConvertMenu(); }), ); if (!editable) return button; return AppFlowyPopover( offset: Offset(44, 10.0), direction: PopoverDirection.bottomWithRightAligned, margin: EdgeInsets.zero, controller: popoverController, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () => keepEditorFocusNotifier.decrease(), popupBuilder: (context) => buildConvertMenu(), child: button, ); } Widget buildConvertMenu() { return MouseRegion( onEnter: widget.onEnter, onExit: widget.onExit, child: Padding( padding: const EdgeInsets.all(8.0), child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(0.0), children: List.generate(LinkConvertMenuCommand.values.length, (index) { final command = LinkConvertMenuCommand.values[index]; return SizedBox( height: 36, child: FlowyButton( text: FlowyText( command.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), onTap: () { widget.onConvertTo(command); closeConvertMenu(); }, ), ); }), ), ), ); } Color? get hoverColor => editable ? null : Colors.transparent; VoidCallback? getTapCallback(VoidCallback callback) { if (editable) return callback; return null; } void showConvertMenu() { keepEditorFocusNotifier.increase(); popoverController.show(); } void closeConvertMenu() { popoverController.close(); } } class HoverTriggerKey { HoverTriggerKey(this.nodeId, this.selection); final String nodeId; final Selection selection; @override bool operator ==(Object other) => identical(this, other) || other is HoverTriggerKey && runtimeType == other.runtimeType && nodeId == other.nodeId && isSelectionSame(other.selection); bool isSelectionSame(Selection other) => (selection.start == other.start && selection.end == other.end) || (selection.start == other.end && selection.end == other.start); @override int get hashCode => nodeId.hashCode ^ selection.hashCode; } class LinkHoverTriggers { final Map> _map = {}; void _add(HoverTriggerKey key, VoidCallback callback) { final callbacks = _map[key] ?? {}; callbacks.add(callback); _map[key] = callbacks; } void _remove(HoverTriggerKey key, VoidCallback callback) { final callbacks = _map[key] ?? {}; callbacks.remove(callback); _map[key] = callbacks; } void call(HoverTriggerKey key) { final callbacks = _map[key] ?? {}; if (callbacks.isEmpty) return; callbacks.first.call(); } } enum LinkConvertMenuCommand { toMention, toBookmark, toEmbed; String get title { switch (this) { case toMention: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion .tr(); case toBookmark: return LocaleKeys .document_plugins_linkPreview_linkPreviewMenu_toBookmark .tr(); case toEmbed: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed .tr(); } } String get type { switch (this) { case toMention: return MentionBlockKeys.type; case toBookmark: return LinkPreviewBlockKeys.type; case toEmbed: return LinkPreviewBlockKeys.type; } } } extension LinkExtension on BuildContext { Future copyLink(String link) async { if (link.isEmpty) return; await getIt() .setData(ClipboardServiceData(plainText: link)); if (mounted) { showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/util/link_util.dart'; import 'package:flutter/services.dart'; import 'link_create_menu.dart'; import 'link_styles.dart'; void showReplaceMenu({ required BuildContext context, required EditorState editorState, required Node node, String? url, required LTRB ltrb, required ValueChanged onReplace, }) { OverlayEntry? overlay; void dismissOverlay() { keepEditorFocusNotifier.decrease(); overlay?.remove(); overlay = null; } keepEditorFocusNotifier.increase(); overlay = FullScreenOverlayEntry( top: ltrb.top, bottom: ltrb.bottom, left: ltrb.left, right: ltrb.right, dismissCallback: () => keepEditorFocusNotifier.decrease(), builder: (context) { return LinkReplaceMenu( link: url ?? '', onSubmitted: (link) async { onReplace.call(link); dismissOverlay(); }, onDismiss: dismissOverlay, ); }, ).build(); Overlay.of(context, rootOverlay: true).insert(overlay!); } class LinkReplaceMenu extends StatefulWidget { const LinkReplaceMenu({ super.key, required this.onSubmitted, required this.link, required this.onDismiss, }); final ValueChanged onSubmitted; final VoidCallback onDismiss; final String link; @override State createState() => _LinkReplaceMenuState(); } class _LinkReplaceMenuState extends State { bool showErrorText = false; late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent); late TextEditingController textEditingController = TextEditingController(text: widget.link); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); }); } @override void dispose() { focusNode.dispose(); textEditingController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( width: 330, padding: EdgeInsets.all(8), decoration: buildToolbarLinkDecoration(context), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: buildLinkField()), HSpace(8), buildReplaceButton(), ], ), ); } Widget buildLinkField() { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 32, child: TextFormField( autovalidateMode: AutovalidateMode.onUserInteraction, autofocus: true, focusNode: focusNode, textAlign: TextAlign.left, controller: textEditingController, style: TextStyle( fontSize: 14, height: 20 / 14, fontWeight: FontWeight.w400, ), decoration: LinkStyle.buildLinkTextFieldInputDecoration( LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_pasteHint .tr(), context, showErrorBorder: showErrorText, ), ), ), if (showErrorText) Padding( padding: const EdgeInsets.only(top: 4), child: FlowyText.regular( LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), color: AppFlowyTheme.maybeOf(context)?.textColorScheme.error, fontSize: 12, figmaLineHeight: 16, ), ), ], ); } Widget buildReplaceButton() { final fillTheme = AppFlowyTheme.maybeOf(context)?.fillColorScheme; return FlowyTextButton( LocaleKeys.button_replace.tr(), padding: EdgeInsets.zero, mainAxisAlignment: MainAxisAlignment.center, constraints: BoxConstraints(maxWidth: 78, minHeight: 32), fontSize: 14, lineHeight: 20 / 14, hoverColor: fillTheme?.themeThick.withAlpha(200), fontColor: Colors.white, fillColor: fillTheme?.themeThick, fontWeight: FontWeight.w400, onPressed: onSubmit, ); } void onSubmit() { final link = textEditingController.text.trim(); if (link.isEmpty || !isUri(link)) { setState(() { showErrorText = true; }); return; } widget.onSubmitted.call(link); } KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) { if (key is! KeyDownEvent) return KeyEventResult.ignored; if (key.logicalKey == LogicalKeyboardKey.escape) { widget.onDismiss.call(); return KeyEventResult.handled; } else if (key.logicalKey == LogicalKeyboardKey.enter) { onSubmit(); return KeyEventResult.handled; } return KeyEventResult.ignored; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart ================================================ import 'dart:math'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/util/link_util.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'link_create_menu.dart'; import 'link_styles.dart'; class LinkSearchTextField { LinkSearchTextField({ this.onEscape, this.onEnter, this.onDataRefresh, this.initialViewId = '', required this.currentViewId, String? initialSearchText, }) : textEditingController = TextEditingController( text: isUri(initialSearchText ?? '') ? initialSearchText : '', ); final TextEditingController textEditingController; final String initialViewId; final String currentViewId; final ItemScrollController searchController = ItemScrollController(); late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent); final List searchedViews = []; final List recentViews = []; int selectedIndex = 0; final VoidCallback? onEscape; final VoidCallback? onEnter; final VoidCallback? onDataRefresh; String get searchText => textEditingController.text; bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText); bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty; ViewPB get currentSearchedView => searchedViews[selectedIndex]; ViewPB get currentRecentView => recentViews[selectedIndex]; void dispose() { textEditingController.dispose(); focusNode.dispose(); searchedViews.clear(); recentViews.clear(); } Widget buildTextField({ bool autofocus = false, bool showError = false, required BuildContext context, EdgeInsets contentPadding = const EdgeInsets.fromLTRB(8, 6, 8, 6), TextStyle? textStyle, }) { return TextFormField( autovalidateMode: AutovalidateMode.onUserInteraction, autofocus: autofocus, focusNode: focusNode, textAlign: TextAlign.left, controller: textEditingController, style: textStyle ?? TextStyle( fontSize: 14, height: 20 / 14, fontWeight: FontWeight.w400, ), onChanged: (text) { if (text.isEmpty) { searchedViews.clear(); selectedIndex = 0; onDataRefresh?.call(); } else { searchViews(text); } }, decoration: LinkStyle.buildLinkTextFieldInputDecoration( LocaleKeys.document_toolbar_linkInputHint.tr(), context, showErrorBorder: showError, contentPadding: contentPadding, ), ); } Widget buildResultContainer({ EdgeInsetsGeometry? margin, required BuildContext context, VoidCallback? onLinkSelected, ValueChanged? onPageLinkSelected, double width = 320.0, }) { return onSearchResult( onEmpty: () => SizedBox.shrink(), onLink: () => Container( height: 48, width: width, padding: EdgeInsets.all(8), margin: margin, decoration: buildToolbarLinkDecoration(context), child: FlowyButton( leftIcon: FlowySvg(FlowySvgs.toolbar_link_earth_m), isSelected: true, text: FlowyText.regular( searchText, overflow: TextOverflow.ellipsis, fontSize: 14, figmaLineHeight: 20, ), onTap: onLinkSelected, ), ), onRecentViews: () => Container( width: width, height: recentViews.length.clamp(1, 5) * 32.0 + 48, margin: margin, padding: EdgeInsets.all(8), decoration: buildToolbarLinkDecoration(context), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 32, padding: EdgeInsets.all(8), child: FlowyText.semibold( LocaleKeys.inlineActions_recentPages.tr(), color: AppFlowyTheme.of(context).textColorScheme.tertiary, fontSize: 12, figmaLineHeight: 16, ), ), Flexible( child: ListView.builder( itemBuilder: (context, index) { final currentView = recentViews[index]; return buildPageItem( currentView, index == selectedIndex, onPageLinkSelected, ); }, itemCount: recentViews.length, ), ), ], ), ), onSearchViews: () => Container( width: width, height: searchedViews.length.clamp(1, 5) * 32.0 + 16, margin: margin, decoration: buildToolbarLinkDecoration(context), child: ScrollablePositionedList.builder( padding: EdgeInsets.all(8), physics: const ClampingScrollPhysics(), shrinkWrap: true, itemCount: searchedViews.length, itemScrollController: searchController, initialScrollIndex: max(0, selectedIndex), itemBuilder: (context, index) { final currentView = searchedViews[index]; return buildPageItem( currentView, index == selectedIndex, onPageLinkSelected, ); }, ), ), ); } Widget buildPageItem( ViewPB view, bool isSelected, ValueChanged? onSubmittedPageLink, ) { final viewName = view.name; final displayName = viewName.isEmpty ? LocaleKeys.document_title_placeholder.tr() : viewName; final isCurrent = initialViewId == view.id; return SizedBox( height: 32, child: FlowyButton( isSelected: isSelected, leftIcon: buildIcon(view, padding: EdgeInsets.zero), text: FlowyText.regular( displayName, overflow: TextOverflow.ellipsis, fontSize: 14, figmaLineHeight: 20, ), rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () => onSubmittedPageLink?.call(view), ), ); } Widget buildIcon( ViewPB view, { EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4), }) { if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20)); final iconData = view.icon.toEmojiIconData(); return Padding( padding: padding, child: RawEmojiIconWidget( emoji: iconData, emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20, lineHeight: 1, ), ); } void requestFocus() => focusNode.requestFocus(); void unfocus() => focusNode.unfocus(); void updateText(String text) => textEditingController.text = text; T onSearchResult({ required ValueGetter onLink, required ValueGetter onRecentViews, required ValueGetter onSearchViews, required ValueGetter onEmpty, }) { if (searchedViews.isEmpty && recentViews.isEmpty && searchText.isEmpty) { return onEmpty.call(); } if (searchedViews.isEmpty && searchText.isNotEmpty) { return onLink.call(); } if (searchedViews.isEmpty) return onRecentViews.call(); return onSearchViews.call(); } KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) { if (key is! KeyDownEvent) return KeyEventResult.ignored; int index = selectedIndex; if (key.logicalKey == LogicalKeyboardKey.escape) { onEscape?.call(); return KeyEventResult.handled; } else if (key.logicalKey == LogicalKeyboardKey.arrowUp) { index = onSearchResult( onLink: () => 0, onRecentViews: () { int result = index - 1; if (result < 0) result = recentViews.length - 1; return result; }, onSearchViews: () { int result = index - 1; if (result < 0) result = searchedViews.length - 1; searchController.scrollTo( index: result, alignment: 0.5, duration: const Duration(milliseconds: 300), ); return result; }, onEmpty: () => 0, ); refreshIndex(index); return KeyEventResult.handled; } else if (key.logicalKey == LogicalKeyboardKey.arrowDown) { index = onSearchResult( onLink: () => 0, onRecentViews: () { int result = index + 1; if (result >= recentViews.length) result = 0; return result; }, onSearchViews: () { int result = index + 1; if (result >= searchedViews.length) result = 0; searchController.scrollTo( index: result, alignment: 0.5, duration: const Duration(milliseconds: 300), ); return result; }, onEmpty: () => 0, ); refreshIndex(index); return KeyEventResult.handled; } else if (key.logicalKey == LogicalKeyboardKey.enter) { onEnter?.call(); return KeyEventResult.handled; } return KeyEventResult.ignored; } Future searchRecentViews() async { final recentService = getIt(); final sectionViews = await recentService.recentViews(); final views = sectionViews .unique((e) => e.item.id) .map((e) => e.item) .where((e) => e.id != currentViewId) .take(5) .toList(); recentViews.clear(); recentViews.addAll(views); selectedIndex = 0; onDataRefresh?.call(); } Future searchViews(String search) async { final viewResult = await ViewBackendService.getAllViews(); final allViews = viewResult .toNullable() ?.items .where( (view) => (view.id != currentViewId) && (view.name.toLowerCase().contains(search.toLowerCase()) || (view.name.isEmpty && search.isEmpty) || (view.name.isEmpty && LocaleKeys.menuAppHeader_defaultNewPageName .tr() .toLowerCase() .contains(search.toLowerCase()))), ) .take(10) .toList(); searchedViews.clear(); searchedViews.addAll(allViews ?? []); selectedIndex = 0; onDataRefresh?.call(); } void refreshIndex(int index) { selectedIndex = index; onDataRefresh?.call(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart ================================================ import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class LinkStyle { static Color borderColor(BuildContext context) => Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0x64BDBDBD); static InputDecoration buildLinkTextFieldInputDecoration( String hintText, BuildContext context, { bool showErrorBorder = false, EdgeInsets? contentPadding, double? radius, }) { final theme = AppFlowyTheme.of(context); final border = OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(radius ?? 8.0)), borderSide: BorderSide(color: borderColor(context)), ); final enableBorder = border.copyWith( borderSide: BorderSide( color: showErrorBorder ? theme.textColorScheme.error : theme.fillColorScheme.themeThick, ), ); final hintStyle = TextStyle( fontSize: 14, height: 20 / 14, fontWeight: FontWeight.w400, color: theme.textColorScheme.tertiary, ); return InputDecoration( hintText: hintText, hintStyle: hintStyle, contentPadding: contentPadding ?? const EdgeInsets.fromLTRB(8, 6, 8, 6), isDense: true, border: border, enabledBorder: border, focusedBorder: enableBorder, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_animation.dart ================================================ import 'package:flutter/material.dart'; class ToolbarAnimationWidget extends StatefulWidget { const ToolbarAnimationWidget({ super.key, required this.child, this.duration = const Duration(milliseconds: 150), this.beginOpacity = 0.0, this.endOpacity = 1.0, this.beginScaleFactor = 0.95, this.endScaleFactor = 1.0, }); final Widget child; final Duration duration; final double beginScaleFactor; final double endScaleFactor; final double beginOpacity; final double endOpacity; @override State createState() => _ToolbarAnimationWidgetState(); } class _ToolbarAnimationWidgetState extends State with SingleTickerProviderStateMixin { late AnimationController controller; late Animation fadeAnimation; late Animation scaleAnimation; @override void initState() { super.initState(); controller = AnimationController( vsync: this, duration: widget.duration, ); fadeAnimation = _buildFadeAnimation(); scaleAnimation = _buildScaleAnimation(); controller.forward(); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: controller, builder: (_, child) => Opacity( opacity: fadeAnimation.value, child: Transform.scale( scale: scaleAnimation.value, child: child, ), ), child: widget.child, ); } Animation _buildFadeAnimation() { return Tween( begin: widget.beginOpacity, end: widget.endOpacity, ).animate( CurvedAnimation( parent: controller, curve: Curves.easeInOut, ), ); } Animation _buildScaleAnimation() { return Tween( begin: widget.beginScaleFactor, end: widget.endScaleFactor, ).animate( CurvedAnimation( parent: controller, curve: Curves.easeInOut, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart ================================================ import 'dart:convert'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; class ErrorBlockComponentBuilder extends BlockComponentBuilder { ErrorBlockComponentBuilder({ super.configuration, }); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return ErrorBlockComponentWidget( key: node.key, node: node, configuration: configuration, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (_) => true; } class ErrorBlockComponentWidget extends BlockComponentStatefulWidget { const ErrorBlockComponentWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @override State createState() => _ErrorBlockComponentWidgetState(); } class _ErrorBlockComponentWidgetState extends State with BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; @override Widget build(BuildContext context) { Widget child = Container( width: double.infinity, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: UniversalPlatform.isDesktopOrWeb ? _buildDesktopErrorBlock(context) : _buildMobileErrorBlock(context), ); child = Padding( padding: padding, child: child, ); if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } if (UniversalPlatform.isMobile) { child = MobileBlockActionButtons( node: node, editorState: context.read(), child: child, ); } return child; } Widget _buildDesktopErrorBlock(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Row( children: [ const HSpace(12), FlowyText.regular( LocaleKeys.document_errorBlock_parseError.tr(args: [node.type]), ), const Spacer(), OutlinedRoundedButton( text: LocaleKeys.document_errorBlock_copyBlockContent.tr(), onTap: _copyBlockContent, ), const HSpace(12), ], ), ); } Widget _buildMobileErrorBlock(BuildContext context) { return AnimatedGestureDetector( onTapUp: _copyBlockContent, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 4.0, right: 24.0), child: FlowyText.regular( LocaleKeys.document_errorBlock_parseError.tr(args: [node.type]), maxLines: 3, ), ), const VSpace(6), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: FlowyText.regular( '(${LocaleKeys.document_errorBlock_clickToCopyTheBlockContent.tr()})', color: Theme.of(context).hintColor, fontSize: 12.0, ), ), ], ), ), ); } void _copyBlockContent() { showToastNotification( message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(), ); getIt().setData( ClipboardServiceData(plainText: jsonEncode(node.toJson())), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/extensions/flowy_tint_extension.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; extension FlowyTintExtension on FlowyTint { String tintName( AppFlowyEditorLocalizations l10n, { ThemeMode? themeMode, String? theme, }) { switch (this) { case FlowyTint.tint1: return l10n.lightLightTint1; case FlowyTint.tint2: return l10n.lightLightTint2; case FlowyTint.tint3: return l10n.lightLightTint3; case FlowyTint.tint4: return l10n.lightLightTint4; case FlowyTint.tint5: return l10n.lightLightTint5; case FlowyTint.tint6: return l10n.lightLightTint6; case FlowyTint.tint7: return l10n.lightLightTint7; case FlowyTint.tint8: return l10n.lightLightTint8; case FlowyTint.tint9: return l10n.lightLightTint9; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart ================================================ export './file_block_component.dart'; export './file_selection_menu.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; import 'file_block_menu.dart'; import 'file_upload_menu.dart'; class FileBlockKeys { const FileBlockKeys._(); static const String type = 'file'; /// The src of the file. /// /// The value is a String. /// It can be a url for a network file or a local file path. /// static const String url = 'url'; /// The name of the file. /// /// The value is a String. /// static const String name = 'name'; /// The type of the url. /// /// The value is a FileUrlType enum. /// static const String urlType = 'url_type'; /// The date of the file upload. /// /// The value is a timestamp in ms. /// static const String uploadedAt = 'uploaded_at'; /// The user who uploaded the file. /// /// The value is a String, in form of user id. /// static const String uploadedBy = 'uploaded_by'; /// The GlobalKey of the FileBlockComponentState. /// /// **Note: This value is used in extraInfos of the Node, not in the attributes.** static const String globalKey = 'global_key'; } enum FileUrlType { local, network, cloud; static FileUrlType fromIntValue(int value) { switch (value) { case 0: return FileUrlType.local; case 1: return FileUrlType.network; case 2: return FileUrlType.cloud; default: throw UnimplementedError(); } } int toIntValue() { switch (this) { case FileUrlType.local: return 0; case FileUrlType.network: return 1; case FileUrlType.cloud: return 2; } } FileUploadTypePB toFileUploadTypePB() { switch (this) { case FileUrlType.local: return FileUploadTypePB.LocalFile; case FileUrlType.network: return FileUploadTypePB.NetworkFile; case FileUrlType.cloud: return FileUploadTypePB.CloudFile; } } } Node fileNode({ required String url, FileUrlType type = FileUrlType.local, String? name, }) { return Node( type: FileBlockKeys.type, attributes: { FileBlockKeys.url: url, FileBlockKeys.urlType: type.toIntValue(), FileBlockKeys.name: name, FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, }, ); } class FileBlockComponentBuilder extends BlockComponentBuilder { FileBlockComponentBuilder({super.configuration}); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; final extraInfos = node.extraInfos; final key = extraInfos?[FileBlockKeys.globalKey] as GlobalKey?; return FileBlockComponent( key: key ?? node.key, node: node, showActions: showActions(node), configuration: configuration, actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), ); } @override BlockComponentValidate get validate => (node) => node.children.isEmpty; } class FileBlockComponent extends BlockComponentStatefulWidget { const FileBlockComponent({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); static const uploadDragKey = 'FileUploadMenu'; @override State createState() => FileBlockComponentState(); } class FileBlockComponentState extends State with SelectableMixin, BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; late EditorDropManagerState? dropManagerState = UniversalPlatform.isMobile ? null : context.read(); final fileKey = GlobalKey(); final showActionsNotifier = ValueNotifier(false); final controller = PopoverController(); final menuController = PopoverController(); late final editorState = Provider.of(context, listen: false); bool alwaysShowMenu = false; bool isDragging = false; bool isHovering = false; @override void didChangeDependencies() { if (!UniversalPlatform.isMobile) { dropManagerState = context.read(); } super.didChangeDependencies(); } @override Widget build(BuildContext context) { final url = node.attributes[FileBlockKeys.url]; final FileUrlType urlType = FileUrlType.fromIntValue(node.attributes[FileBlockKeys.urlType] ?? 0); Widget child = MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) { setState(() => isHovering = true); showActionsNotifier.value = true; }, onExit: (_) { setState(() => isHovering = false); if (!alwaysShowMenu) { showActionsNotifier.value = false; } }, opaque: false, child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: url != null && url.isNotEmpty ? () async => _openFile(context, urlType, url) : _openMenu, child: DecoratedBox( decoration: BoxDecoration( color: isHovering ? Theme.of(context).colorScheme.secondary : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), border: isDragging ? Border.all( color: Theme.of(context).colorScheme.primary, width: 2, ) : null, ), child: SizedBox( height: 52, child: Row( children: [ const HSpace(10), FlowySvg( FlowySvgs.slash_menu_icon_file_s, color: Theme.of(context).hintColor, size: const Size.square(24), ), const HSpace(10), ..._buildTrailing(context), ], ), ), ), ), ); if (UniversalPlatform.isDesktopOrWeb) { if (url == null || url.isEmpty) { child = DropTarget( enable: dropManagerState?.isDropEnabled == true || dropManagerState?.contains(FileBlockKeys.type) == true, onDragEntered: (_) { if (dropManagerState?.isDropEnabled == true) { dropManagerState?.add(FileBlockKeys.type); setState(() => isDragging = true); } }, onDragExited: (_) { if (dropManagerState?.contains(FileBlockKeys.type) == true) { dropManagerState?.remove(FileBlockKeys.type); setState(() => isDragging = false); } }, onDragDone: (details) { dropManagerState?.remove(FileBlockKeys.type); insertFileFromLocal(details.files); }, child: AppFlowyPopover( controller: controller, direction: PopoverDirection.bottomWithCenterAligned, constraints: const BoxConstraints( maxWidth: 480, maxHeight: 340, minHeight: 80, ), clickHandler: PopoverClickHandler.gestureDetector, onOpen: () => dropManagerState?.add( FileBlockComponent.uploadDragKey, ), onClose: () => dropManagerState?.remove( FileBlockComponent.uploadDragKey, ), popupBuilder: (_) => FileUploadMenu( onInsertLocalFile: insertFileFromLocal, onInsertNetworkFile: insertNetworkFile, ), child: child, ), ); } child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, supportTypes: const [BlockSelectionType.block], child: Padding( key: fileKey, padding: padding, child: child, ), ); } else { return Padding( key: fileKey, padding: padding, child: MobileBlockActionButtons( node: widget.node, editorState: editorState, child: child, ), ); } if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } if (!UniversalPlatform.isDesktopOrWeb) { // show a fixed menu on mobile child = MobileBlockActionButtons( node: node, editorState: editorState, extendActionWidgets: _buildExtendActionWidgets(context), child: child, ); } return child; } Future _openFile( BuildContext context, FileUrlType urlType, String url, ) async { await afLaunchUrlString(url, context: context); } void _openMenu() { if (UniversalPlatform.isDesktopOrWeb) { controller.show(); dropManagerState?.add(FileBlockComponent.uploadDragKey); } else { editorState.updateSelectionWithReason(null, extraInfo: {}); showUploadFileMobileMenu(); } } List _buildTrailing(BuildContext context) { if (node.attributes[FileBlockKeys.url]?.isNotEmpty == true) { final name = node.attributes[FileBlockKeys.name] as String; return [ Expanded( child: FlowyText( name, overflow: TextOverflow.ellipsis, ), ), const HSpace(8), if (UniversalPlatform.isDesktopOrWeb) ...[ ValueListenableBuilder( valueListenable: showActionsNotifier, builder: (_, value, __) { final url = node.attributes[FileBlockKeys.url]; if (!value || url == null || url.isEmpty) { return const SizedBox.shrink(); } return GestureDetector( behavior: HitTestBehavior.translucent, onTap: menuController.show, child: AppFlowyPopover( controller: menuController, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithRightAligned, onClose: () { setState( () { alwaysShowMenu = false; showActionsNotifier.value = false; }, ); }, popupBuilder: (_) { alwaysShowMenu = true; return FileBlockMenu( controller: menuController, node: node, editorState: editorState, ); }, child: const FileMenuTrigger(), ), ); }, ), const HSpace(8), ], if (UniversalPlatform.isMobile) ...[ const HSpace(36), ], ]; } else { return [ Flexible( child: FlowyText( isDragging ? LocaleKeys.document_plugins_file_placeholderDragging.tr() : LocaleKeys.document_plugins_file_placeholderText.tr(), overflow: TextOverflow.ellipsis, color: Theme.of(context).hintColor, ), ), ]; } } // only used on mobile platform List _buildExtendActionWidgets(BuildContext context) { final String? url = widget.node.attributes[FileBlockKeys.url]; if (url == null || url.isEmpty) { return []; } final urlType = FileUrlType.fromIntValue( widget.node.attributes[FileBlockKeys.urlType] ?? 0, ); if (urlType != FileUrlType.network) { return []; } return [ FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.editor_copyLink.tr(), leftIcon: const FlowySvg( FlowySvgs.m_field_copy_s, ), onTap: () async { context.pop(); showSnackBarMessage( context, LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), ); await getIt().setPlainText(url); }, ), ]; } void showUploadFileMobileMenu() { showMobileBottomSheet( context, title: LocaleKeys.document_plugins_file_name.tr(), showHeader: true, showCloseButton: true, showDragHandle: true, builder: (context) { return Container( margin: const EdgeInsets.only(top: 12.0), constraints: const BoxConstraints( maxHeight: 340, minHeight: 80, ), child: FileUploadMenu( onInsertLocalFile: (file) async { context.pop(); await insertFileFromLocal(file); }, onInsertNetworkFile: (url) async { context.pop(); await insertNetworkFile(url); }, ), ); }, ); } Future insertFileFromLocal(List files) async { if (files.isEmpty) return; final file = files.first; final path = file.path; final documentBloc = context.read(); final isLocalMode = documentBloc.isLocalMode; final urlType = isLocalMode ? FileUrlType.local : FileUrlType.cloud; String? url; String? errorMsg; if (isLocalMode) { url = await saveFileToLocalStorage(path); } else { final result = await saveFileToCloudStorage(path, documentBloc.documentId); url = result.$1; errorMsg = result.$2; } if (errorMsg != null && mounted) { return showSnackBarMessage(context, errorMsg); } // Remove the file block from the drop state manager dropManagerState?.remove(FileBlockKeys.type); final transaction = editorState.transaction; transaction.updateNode(widget.node, { FileBlockKeys.url: url, FileBlockKeys.urlType: urlType.toIntValue(), FileBlockKeys.name: file.name, FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, }); await editorState.apply(transaction); } Future insertNetworkFile(String url) async { if (url.isEmpty || !isURL(url)) { // show error return showSnackBarMessage( context, LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), ); } // Remove the file block from the drop state manager dropManagerState?.remove(FileBlockKeys.type); final uri = Uri.tryParse(url); if (uri == null) { return; } String name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; if (name.isEmpty && uri.pathSegments.length > 1) { name = uri.pathSegments[uri.pathSegments.length - 2]; } else if (name.isEmpty) { name = uri.host; } final transaction = editorState.transaction; transaction.updateNode(widget.node, { FileBlockKeys.url: url, FileBlockKeys.urlType: FileUrlType.network.toIntValue(), FileBlockKeys.name: name, FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, }); await editorState.apply(transaction); } @override Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); @override Position getPositionInOffset(Offset start) => end(); @override bool get shouldCursorBlink => false; @override CursorStyle get cursorStyle => CursorStyle.cover; @override Rect getBlockRect({bool shiftWithBaseOffset = false}) { final renderBox = fileKey.currentContext?.findRenderObject(); if (renderBox is RenderBox) { return padding.topLeft & renderBox.size; } return Rect.zero; } @override Rect? getCursorRectInPosition( Position position, { bool shiftWithBaseOffset = false, }) { final rects = getRectsInSelection(Selection.collapsed(position)); return rects.firstOrNull; } @override List getRectsInSelection( Selection selection, { bool shiftWithBaseOffset = false, }) { if (_renderBox == null) { return []; } final parentBox = context.findRenderObject(); final renderBox = fileKey.currentContext?.findRenderObject(); if (parentBox is RenderBox && renderBox is RenderBox) { return [ renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & renderBox.size, ]; } return [Offset.zero & _renderBox!.size]; } @override Selection getSelectionInRange(Offset start, Offset end) => Selection.single( path: widget.node.path, startOffset: 0, endOffset: 1, ); @override Offset localToGlobal( Offset offset, { bool shiftWithBaseOffset = false, }) => _renderBox!.localToGlobal(offset); } @visibleForTesting class FileMenuTrigger extends StatelessWidget { const FileMenuTrigger({super.key}); @override Widget build(BuildContext context) { return const FlowyHover( resetHoverOnRebuild: false, child: Padding( padding: EdgeInsets.all(4), child: FlowySvg( FlowySvgs.three_dots_s, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class FileBlockMenu extends StatefulWidget { const FileBlockMenu({ super.key, required this.controller, required this.node, required this.editorState, }); final PopoverController controller; final Node node; final EditorState editorState; @override State createState() => _FileBlockMenuState(); } class _FileBlockMenuState extends State { final nameController = TextEditingController(); final errorMessage = ValueNotifier(null); BuildContext? renameContext; @override void initState() { super.initState(); nameController.text = widget.node.attributes[FileBlockKeys.name] ?? ''; nameController.selection = TextSelection( baseOffset: 0, extentOffset: nameController.text.length, ); } @override void dispose() { errorMessage.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final uploadedAtInMS = widget.node.attributes[FileBlockKeys.uploadedAt] as int?; final uploadedAt = uploadedAtInMS != null ? DateTime.fromMillisecondsSinceEpoch(uploadedAtInMS) : null; final dateFormat = context.read().state.dateFormat; final urlType = FileUrlType.fromIntValue(widget.node.attributes[FileBlockKeys.urlType]); final fileUploadType = urlType.toFileUploadTypePB(); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ HoverButton( itemHeight: 20, leftIcon: const FlowySvg(FlowySvgs.download_s), name: LocaleKeys.button_download.tr(), onTap: () { final userProfile = widget.editorState.document.root.context ?.read() .state .userProfilePB; final url = widget.node.attributes[FileBlockKeys.url]; final name = widget.node.attributes[FileBlockKeys.name]; if (url != null && name != null) { final filePB = MediaFilePB( url: url, name: name, uploadType: fileUploadType, ); downloadMediaFile( context, filePB, userProfile: userProfile, ); } }, ), const VSpace(4), HoverButton( itemHeight: 20, leftIcon: const FlowySvg(FlowySvgs.edit_s), name: LocaleKeys.document_plugins_file_renameFile_title.tr(), onTap: () { widget.controller.close(); showCustomConfirmDialog( context: context, title: LocaleKeys.document_plugins_file_renameFile_title.tr(), description: LocaleKeys.document_plugins_file_renameFile_description.tr(), closeOnConfirm: false, builder: (context) { renameContext = context; return FileRenameTextField( nameController: nameController, errorMessage: errorMessage, onSubmitted: _saveName, ); }, confirmLabel: LocaleKeys.button_save.tr(), onConfirm: _saveName, ); }, ), const VSpace(4), HoverButton( itemHeight: 20, leftIcon: const FlowySvg(FlowySvgs.delete_s), name: LocaleKeys.button_delete.tr(), onTap: () { final transaction = widget.editorState.transaction ..deleteNode(widget.node); widget.editorState.apply(transaction); widget.controller.close(); }, ), if (uploadedAt != null) ...[ const Divider(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: FlowyText.regular( [FileUrlType.cloud, FileUrlType.local].contains(urlType) ? LocaleKeys.document_plugins_file_uploadedAt.tr( args: [dateFormat.formatDate(uploadedAt, false)], ) : LocaleKeys.document_plugins_file_linkedAt.tr( args: [dateFormat.formatDate(uploadedAt, false)], ), fontSize: 14, maxLines: 2, color: Theme.of(context).hintColor, ), ), const VSpace(2), ], ], ); } void _saveName() { if (nameController.text.isEmpty) { errorMessage.value = LocaleKeys.document_plugins_file_renameFile_nameEmptyError.tr(); return; } final attributes = widget.node.attributes; attributes[FileBlockKeys.name] = nameController.text; final transaction = widget.editorState.transaction ..updateNode(widget.node, attributes); widget.editorState.apply(transaction); if (renameContext != null) { Navigator.of(renameContext!).pop(); } } } class FileRenameTextField extends StatefulWidget { const FileRenameTextField({ super.key, required this.nameController, required this.errorMessage, required this.onSubmitted, this.disposeController = true, }); final TextEditingController nameController; final ValueNotifier errorMessage; final VoidCallback onSubmitted; final bool disposeController; @override State createState() => _FileRenameTextFieldState(); } class _FileRenameTextFieldState extends State { @override void initState() { super.initState(); widget.errorMessage.addListener(_setState); } @override void dispose() { widget.errorMessage.removeListener(_setState); if (widget.disposeController) { widget.nameController.dispose(); } super.dispose(); } void _setState() { if (mounted) { setState(() {}); } } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyTextField( controller: widget.nameController, onSubmitted: (_) => widget.onSubmitted(), ), if (widget.errorMessage.value != null) Padding( padding: const EdgeInsets.only(top: 8), child: FlowyText( widget.errorMessage.value!, color: Theme.of(context).colorScheme.error, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; extension InsertFile on EditorState { Future insertEmptyFileBlock(GlobalKey key) async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { return; } final path = selection.end.path; final node = getNodeAtPath(path); final delta = node?.delta; if (node == null || delta == null) { return; } final file = fileNode(url: '')..extraInfos = {'global_key': key}; final transaction = this.transaction; // if current node is a paragraph and empty, replace it with the file block if (delta.isEmpty && node.type == ParagraphBlockKeys.type) { final insertedPath = path; transaction.insertNode(insertedPath, file); transaction.deleteNode(node); } else { // otherwise, insert the file block after the current node final insertedPath = path.next; transaction.insertNode(insertedPath, file); } return apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:dotted_border/dotted_border.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class FileUploadMenu extends StatefulWidget { const FileUploadMenu({ super.key, required this.onInsertLocalFile, required this.onInsertNetworkFile, this.allowMultipleFiles = false, }); final void Function(List files) onInsertLocalFile; final void Function(String url) onInsertNetworkFile; final bool allowMultipleFiles; @override State createState() => _FileUploadMenuState(); } class _FileUploadMenuState extends State { int currentTab = 0; @override Widget build(BuildContext context) { // ClipRRect is used to clip the tab indicator, so the animation doesn't overflow the dialog return ClipRRect( child: DefaultTabController( length: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ TabBar( onTap: (value) => setState(() => currentTab = value), isScrollable: true, indicatorWeight: 3, tabAlignment: TabAlignment.start, indicatorSize: TabBarIndicatorSize.label, labelPadding: EdgeInsets.zero, padding: EdgeInsets.zero, overlayColor: WidgetStatePropertyAll( UniversalPlatform.isDesktop ? Theme.of(context).colorScheme.secondary : Colors.transparent, ), tabs: [ _Tab( title: LocaleKeys.document_plugins_file_uploadTab.tr(), isSelected: currentTab == 0, ), _Tab( title: LocaleKeys.document_plugins_file_networkTab.tr(), isSelected: currentTab == 1, ), ], ), const Divider(height: 0), if (currentTab == 0) ...[ _FileUploadLocal( allowMultipleFiles: widget.allowMultipleFiles, onFilesPicked: (files) { if (files.isNotEmpty) { widget.onInsertLocalFile(files); } }, ), ] else ...[ _FileUploadNetwork(onSubmit: widget.onInsertNetworkFile), ], ], ), ), ); } } class _Tab extends StatelessWidget { const _Tab({required this.title, this.isSelected = false}); final String title; final bool isSelected; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( left: 12.0, right: 12.0, bottom: 8.0, top: UniversalPlatform.isMobile ? 0 : 8.0, ), child: FlowyText.semibold( title, color: isSelected ? AFThemeExtension.of(context).strongText : Theme.of(context).hintColor, ), ); } } class _FileUploadLocal extends StatefulWidget { const _FileUploadLocal({ required this.onFilesPicked, this.allowMultipleFiles = false, }); final void Function(List) onFilesPicked; final bool allowMultipleFiles; @override State<_FileUploadLocal> createState() => _FileUploadLocalState(); } class _FileUploadLocalState extends State<_FileUploadLocal> { bool isDragging = false; @override Widget build(BuildContext context) { final constraints = UniversalPlatform.isMobile ? const BoxConstraints(minHeight: 92) : null; if (UniversalPlatform.isMobile) { return Padding( padding: const EdgeInsets.all(12), child: SizedBox( height: 32, child: FlowyButton( backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), showDefaultBoxDecorationOnMobile: true, margin: const EdgeInsets.all(5), text: FlowyText( LocaleKeys.document_plugins_file_uploadMobile.tr(), textAlign: TextAlign.center, color: Theme.of(context).colorScheme.onPrimary, ), onTap: () => _uploadFile(context), ), ), ); } return Padding( padding: const EdgeInsets.all(16), child: DropTarget( onDragEntered: (_) => setState(() => isDragging = true), onDragExited: (_) => setState(() => isDragging = false), onDragDone: (details) => widget.onFilesPicked(details.files), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => _uploadFile(context), child: FlowyHover( resetHoverOnRebuild: false, isSelected: () => isDragging, style: HoverStyle( borderRadius: BorderRadius.circular(10), hoverColor: isDragging ? AFThemeExtension.of(context).tint9 : null, ), child: Container( height: 172, constraints: constraints, child: DottedBorder( dashPattern: const [3, 3], radius: const Radius.circular(8), borderType: BorderType.RRect, color: isDragging ? Theme.of(context).colorScheme.primary : Theme.of(context).hintColor, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ if (isDragging) ...[ FlowyText( LocaleKeys.document_plugins_file_dropFileToUpload .tr(), fontSize: 14, fontWeight: FontWeight.w500, color: Theme.of(context).hintColor, ), ] else ...[ RichText( text: TextSpan( children: [ TextSpan( text: LocaleKeys .document_plugins_file_fileUploadHint .tr(), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Theme.of(context).hintColor, ), ), TextSpan( text: LocaleKeys .document_plugins_file_fileUploadHintSuffix .tr(), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.primary, ), ), ], ), ), ], ], ), ), ), ), ), ), ), ), ); } Future _uploadFile(BuildContext context) async { final result = await getIt().pickFiles( dialogTitle: '', allowMultiple: widget.allowMultipleFiles, ); final List files = result?.files.isNotEmpty ?? false ? result!.files.map((f) => f.xFile).toList() : const []; widget.onFilesPicked(files); } } class _FileUploadNetwork extends StatefulWidget { const _FileUploadNetwork({required this.onSubmit}); final void Function(String url) onSubmit; @override State<_FileUploadNetwork> createState() => _FileUploadNetworkState(); } class _FileUploadNetworkState extends State<_FileUploadNetwork> { bool isUrlValid = true; String inputText = ''; @override Widget build(BuildContext context) { final constraints = UniversalPlatform.isMobile ? const BoxConstraints(minHeight: 92) : null; return Container( padding: const EdgeInsets.all(16), constraints: constraints, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyTextField( hintText: LocaleKeys.document_plugins_file_networkHint.tr(), onChanged: (value) => inputText = value, onEditingComplete: submit, ), if (!isUrlValid) ...[ const VSpace(4), FlowyText( LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), color: Theme.of(context).colorScheme.error, maxLines: 3, textAlign: TextAlign.start, ), ], const VSpace(16), SizedBox( height: 32, child: FlowyButton( backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), showDefaultBoxDecorationOnMobile: true, margin: const EdgeInsets.all(5), text: FlowyText( LocaleKeys.document_plugins_file_networkAction.tr(), textAlign: TextAlign.center, color: Theme.of(context).colorScheme.onPrimary, ), onTap: submit, ), ), ], ), ); } void submit() { if (checkUrlValidity(inputText)) { return widget.onSubmit(inputText); } setState(() => isUrlValid = false); } bool checkUrlValidity(String url) => hrefRegex.hasMatch(url); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/xfile_ext.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:universal_platform/universal_platform.dart'; Future saveFileToLocalStorage(String localFilePath) async { final path = await getIt().getPath(); final filePath = p.join(path, 'files'); try { // create the directory if not exists final directory = Directory(filePath); if (!directory.existsSync()) { await directory.create(recursive: true); } final copyToPath = p.join( filePath, '${uuid()}${p.extension(localFilePath)}', ); await File(localFilePath).copy( copyToPath, ); return copyToPath; } catch (e) { Log.error('cannot save file', e); return null; } } Future<(String? path, String? errorMessage)> saveFileToCloudStorage( String localFilePath, String documentId, [ bool isImage = false, ]) async { final documentService = DocumentService(); Log.debug("Uploading file from local path: $localFilePath"); final result = await documentService.uploadFile( localFilePath: localFilePath, documentId: documentId, ); return result.fold( (s) async { if (isImage) { await CustomImageCacheManager().putFile( s.url, File(localFilePath).readAsBytesSync(), ); } return (s.url, null); }, (err) { final message = Platform.isIOS ? LocaleKeys.sideBar_storageLimitDialogTitleIOS.tr() : LocaleKeys.sideBar_storageLimitDialogTitle.tr(); if (err.isStorageLimitExceeded) { return (null, message); } return (null, err.msg); }, ); } /// Downloads a MediaFilePB /// /// On Mobile the file is fetched first using HTTP, and then saved using FilePicker. /// On Desktop the files location is picked first using FilePicker, and then the file is saved. /// Future downloadMediaFile( BuildContext context, MediaFilePB file, { VoidCallback? onDownloadBegin, VoidCallback? onDownloadEnd, UserProfilePB? userProfile, }) async { if ([ FileUploadTypePB.NetworkFile, FileUploadTypePB.LocalFile, ].contains(file.uploadType)) { /// When the file is a network file or a local file, we can directly open the file. await afLaunchUrlString(file.url); } else { if (userProfile == null) { showToastNotification( message: LocaleKeys.grid_media_downloadFailedToken.tr(), ); return; } final uri = Uri.parse(file.url); final token = jsonDecode(userProfile.token)['access_token']; if (UniversalPlatform.isMobile) { onDownloadBegin?.call(); final response = await http.get(uri, headers: {'Authorization': 'Bearer $token'}); if (response.statusCode == 200) { final tempFile = File(uri.pathSegments.last); final result = await FilePicker().saveFile( fileName: p.basename(tempFile.path), bytes: response.bodyBytes, ); if (result != null && context.mounted) { showToastNotification( type: ToastificationType.error, message: LocaleKeys.grid_media_downloadSuccess.tr(), ); } } else if (context.mounted) { showToastNotification( type: ToastificationType.error, message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), ); } onDownloadEnd?.call(); } else { final savePath = await FilePicker().saveFile(fileName: file.name); if (savePath == null) { return; } onDownloadBegin?.call(); final response = await http.get(uri, headers: {'Authorization': 'Bearer $token'}); if (response.statusCode == 200) { final imgFile = File(savePath); await imgFile.writeAsBytes(response.bodyBytes); if (context.mounted) { showToastNotification( message: LocaleKeys.grid_media_downloadSuccess.tr(), ); } } else if (context.mounted) { showToastNotification( type: ToastificationType.error, message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), ); } onDownloadEnd?.call(); } } } Future insertLocalFile( BuildContext context, XFile file, { required String documentId, UserProfilePB? userProfile, void Function(String, bool)? onUploadSuccess, }) async { if (file.path.isEmpty) return; final fileType = file.fileType.toMediaFileTypePB(); // Check upload type final isLocalMode = (userProfile?.workspaceType ?? WorkspaceTypePB.LocalW) == WorkspaceTypePB.LocalW; String? path; String? errorMsg; if (isLocalMode) { path = await saveFileToLocalStorage(file.path); } else { (path, errorMsg) = await saveFileToCloudStorage( file.path, documentId, fileType == MediaFileTypePB.Image, ); } if (errorMsg != null) { return showSnackBarMessage(context, errorMsg); } if (path == null) { return; } onUploadSuccess?.call(path, isLocalMode); } /// [onUploadSuccess] Callback to be called when the upload is successful. /// /// The callback is called for each file that is successfully uploaded. /// In case of an error, the error message will be shown on a per-file basis. /// Future insertLocalFiles( BuildContext context, List files, { required String documentId, UserProfilePB? userProfile, void Function( XFile file, String path, bool isLocalMode, )? onUploadSuccess, }) async { if (files.every((f) => f.path.isEmpty)) return; // Check upload type final isLocalMode = (userProfile?.workspaceType ?? WorkspaceTypePB.LocalW) == WorkspaceTypePB.LocalW; for (final file in files) { final fileType = file.fileType.toMediaFileTypePB(); String? path; String? errorMsg; if (isLocalMode) { path = await saveFileToLocalStorage(file.path); } else { (path, errorMsg) = await saveFileToCloudStorage( file.path, documentId, fileType == MediaFileTypePB.Image, ); } if (errorMsg != null) { showSnackBarMessage(context, errorMsg); continue; } if (path == null) { continue; } onUploadSuccess?.call(file, path, isLocalMode); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:universal_platform/universal_platform.dart'; class MobileFileUploadMenu extends StatefulWidget { const MobileFileUploadMenu({ super.key, required this.onInsertLocalFile, required this.onInsertNetworkFile, this.allowMultipleFiles = false, }); final void Function(List files) onInsertLocalFile; final void Function(String url) onInsertNetworkFile; final bool allowMultipleFiles; @override State createState() => _MobileFileUploadMenuState(); } class _MobileFileUploadMenuState extends State { int currentTab = 0; @override Widget build(BuildContext context) { // ClipRRect is used to clip the tab indicator, so the animation doesn't overflow the dialog return ClipRRect( child: DefaultTabController( length: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ TabBar( onTap: (value) => setState(() => currentTab = value), indicatorWeight: 3, labelPadding: EdgeInsets.zero, padding: EdgeInsets.zero, overlayColor: WidgetStatePropertyAll( UniversalPlatform.isDesktop ? Theme.of(context).colorScheme.secondary : Colors.transparent, ), tabs: [ _Tab( title: LocaleKeys.document_plugins_file_uploadTab.tr(), isSelected: currentTab == 0, ), _Tab( title: LocaleKeys.document_plugins_file_networkTab.tr(), isSelected: currentTab == 1, ), ], ), const Divider(height: 0), if (currentTab == 0) ...[ _FileUploadLocal( allowMultipleFiles: widget.allowMultipleFiles, onFilesPicked: (files) { if (files.isNotEmpty) { widget.onInsertLocalFile(files); } }, ), ] else ...[ _FileUploadNetwork(onSubmit: widget.onInsertNetworkFile), ], ], ), ), ); } } class _Tab extends StatelessWidget { const _Tab({required this.title, this.isSelected = false}); final String title; final bool isSelected; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( left: 12.0, right: 12.0, bottom: 8.0, top: UniversalPlatform.isMobile ? 0 : 8.0, ), child: FlowyText.semibold( title, color: isSelected ? AFThemeExtension.of(context).strongText : Theme.of(context).hintColor, ), ); } } class _FileUploadLocal extends StatefulWidget { const _FileUploadLocal({ required this.onFilesPicked, this.allowMultipleFiles = false, }); final void Function(List) onFilesPicked; final bool allowMultipleFiles; @override State<_FileUploadLocal> createState() => _FileUploadLocalState(); } class _FileUploadLocalState extends State<_FileUploadLocal> { bool isDragging = false; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Column( children: [ SizedBox( height: 36, child: FlowyButton( radius: Corners.s8Border, backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), margin: const EdgeInsets.all(5), text: FlowyText( LocaleKeys.document_plugins_file_uploadMobileGallery.tr(), textAlign: TextAlign.center, color: Theme.of(context).colorScheme.onPrimary, ), onTap: () => _uploadFileFromGallery(context), ), ), const VSpace(16), SizedBox( height: 36, child: FlowyButton( radius: Corners.s8Border, backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), margin: const EdgeInsets.all(5), text: FlowyText( LocaleKeys.document_plugins_file_uploadMobile.tr(), textAlign: TextAlign.center, color: Theme.of(context).colorScheme.onPrimary, ), onTap: () => _uploadFile(context), ), ), ], ), ); } Future _uploadFileFromGallery(BuildContext context) async { final photoPermission = await PermissionChecker.checkPhotoPermission(context); if (!photoPermission) { Log.error('Has no permission to access the photo library'); return; } // on mobile, the users can pick a image file from camera or image library final files = await ImagePicker().pickMultiImage(); widget.onFilesPicked(files); } Future _uploadFile(BuildContext context) async { final result = await getIt().pickFiles( dialogTitle: '', allowMultiple: widget.allowMultipleFiles, ); final List files = result?.files.isNotEmpty ?? false ? result!.files.map((f) => f.xFile).toList() : const []; widget.onFilesPicked(files); } } class _FileUploadNetwork extends StatefulWidget { const _FileUploadNetwork({required this.onSubmit}); final void Function(String url) onSubmit; @override State<_FileUploadNetwork> createState() => _FileUploadNetworkState(); } class _FileUploadNetworkState extends State<_FileUploadNetwork> { bool isUrlValid = true; String inputText = ''; @override Widget build(BuildContext context) { final constraints = UniversalPlatform.isMobile ? const BoxConstraints(minHeight: 92) : null; return Container( padding: const EdgeInsets.all(16), constraints: constraints, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyTextField( hintText: LocaleKeys.document_plugins_file_networkHint.tr(), onChanged: (value) => inputText = value, onEditingComplete: submit, ), if (!isUrlValid) ...[ const VSpace(4), FlowyText( LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), color: Theme.of(context).colorScheme.error, maxLines: 3, textAlign: TextAlign.start, ), ], const VSpace(16), SizedBox( height: 36, child: FlowyButton( backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), radius: Corners.s8Border, margin: const EdgeInsets.all(5), text: FlowyText( LocaleKeys.grid_media_embedLink.tr(), textAlign: TextAlign.center, color: Theme.of(context).colorScheme.onPrimary, ), onTap: submit, ), ), ], ), ); } void submit() { if (checkUrlValidity(inputText)) { return widget.onSubmit(inputText); } setState(() => isUrlValid = false); } bool checkUrlValidity(String url) => hrefRegex.hasMatch(url); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class FindAndReplaceMenuWidget extends StatefulWidget { const FindAndReplaceMenuWidget({ super.key, required this.onDismiss, required this.editorState, required this.showReplaceMenu, }); final EditorState editorState; final VoidCallback onDismiss; /// Whether to show the replace menu initially final bool showReplaceMenu; @override State createState() => _FindAndReplaceMenuWidgetState(); } class _FindAndReplaceMenuWidgetState extends State { late bool showReplaceMenu = widget.showReplaceMenu; final findFocusNode = FocusNode(); final replaceFocusNode = FocusNode(); late SearchServiceV3 searchService = SearchServiceV3( editorState: widget.editorState, ); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (widget.showReplaceMenu) { replaceFocusNode.requestFocus(); } else { findFocusNode.requestFocus(); } }); } @override void dispose() { findFocusNode.dispose(); replaceFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Shortcuts( shortcuts: const { SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), }, child: Actions( actions: { DismissIntent: CallbackAction( onInvoke: (t) => widget.onDismiss.call(), ), }, child: TextFieldTapRegion( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: FindMenu( onDismiss: widget.onDismiss, editorState: widget.editorState, searchService: searchService, focusNode: findFocusNode, showReplaceMenu: showReplaceMenu, onToggleShowReplace: () => setState(() { showReplaceMenu = !showReplaceMenu; }), ), ), if (showReplaceMenu) Padding( padding: const EdgeInsets.only( bottom: 8.0, ), child: ReplaceMenu( editorState: widget.editorState, searchService: searchService, focusNode: replaceFocusNode, ), ), ], ), ), ), ); } } class FindMenu extends StatefulWidget { const FindMenu({ super.key, required this.editorState, required this.searchService, required this.showReplaceMenu, required this.focusNode, required this.onDismiss, required this.onToggleShowReplace, }); final EditorState editorState; final SearchServiceV3 searchService; final bool showReplaceMenu; final FocusNode focusNode; final VoidCallback onDismiss; final void Function() onToggleShowReplace; @override State createState() => _FindMenuState(); } class _FindMenuState extends State { final textController = TextEditingController(); bool caseSensitive = false; @override void initState() { super.initState(); widget.searchService.matchWrappers.addListener(_setState); widget.searchService.currentSelectedIndex.addListener(_setState); textController.addListener(_searchPattern); } @override void dispose() { widget.searchService.matchWrappers.removeListener(_setState); widget.searchService.currentSelectedIndex.removeListener(_setState); widget.searchService.dispose(); textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // the selectedIndex from searchService is 0-based final selectedIndex = widget.searchService.selectedIndex + 1; final matches = widget.searchService.matchWrappers.value; return Row( children: [ const HSpace(4.0), // expand/collapse button _FindAndReplaceIcon( icon: widget.showReplaceMenu ? FlowySvgs.drop_menu_show_s : FlowySvgs.drop_menu_hide_s, tooltipText: '', onPressed: widget.onToggleShowReplace, ), const HSpace(4.0), // find text input SizedBox( width: 200, height: 30, child: TextField( key: const Key('findTextField'), focusNode: widget.focusNode, controller: textController, style: Theme.of(context).textTheme.bodyMedium, onSubmitted: (_) { widget.searchService.navigateToMatch(); // after update selection or navigate to match, the editor // will request focus, here's a workaround to request the // focus back to the text field Future.delayed( const Duration(milliseconds: 50), () => widget.focusNode.requestFocus(), ); }, decoration: _buildInputDecoration( LocaleKeys.findAndReplace_find.tr(), ), ), ), // the count of matches Container( constraints: const BoxConstraints(minWidth: 80), padding: const EdgeInsets.symmetric(horizontal: 8.0), alignment: Alignment.centerLeft, child: FlowyText( matches.isEmpty ? LocaleKeys.findAndReplace_noResult.tr() : '$selectedIndex of ${matches.length}', ), ), const HSpace(4.0), // case sensitive button _FindAndReplaceIcon( icon: FlowySvgs.text_s, tooltipText: LocaleKeys.findAndReplace_caseSensitive.tr(), onPressed: () => setState(() { caseSensitive = !caseSensitive; widget.searchService.caseSensitive = caseSensitive; }), isSelected: caseSensitive, ), const HSpace(4.0), // previous match button _FindAndReplaceIcon( onPressed: () => widget.searchService.navigateToMatch(moveUp: true), icon: FlowySvgs.arrow_up_s, tooltipText: LocaleKeys.findAndReplace_previousMatch.tr(), ), const HSpace(4.0), // next match button _FindAndReplaceIcon( onPressed: () => widget.searchService.navigateToMatch(), icon: FlowySvgs.arrow_down_s, tooltipText: LocaleKeys.findAndReplace_nextMatch.tr(), ), const HSpace(4.0), _FindAndReplaceIcon( onPressed: widget.onDismiss, icon: FlowySvgs.close_s, tooltipText: LocaleKeys.findAndReplace_close.tr(), ), const HSpace(4.0), ], ); } void _searchPattern() { widget.searchService.findAndHighlight(textController.text); _setState(); } void _setState() { setState(() {}); } } class ReplaceMenu extends StatefulWidget { const ReplaceMenu({ super.key, required this.editorState, required this.searchService, required this.focusNode, }); final EditorState editorState; final SearchServiceV3 searchService; final FocusNode focusNode; @override State createState() => _ReplaceMenuState(); } class _ReplaceMenuState extends State { final textController = TextEditingController(); @override void dispose() { textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Row( children: [ // placeholder for aligning the replace menu const HSpace(30), SizedBox( width: 200, height: 30, child: TextField( key: const Key('replaceTextField'), focusNode: widget.focusNode, controller: textController, style: Theme.of(context).textTheme.bodyMedium, onSubmitted: (_) { _replaceSelectedWord(); Future.delayed( const Duration(milliseconds: 50), () => widget.focusNode.requestFocus(), ); }, decoration: _buildInputDecoration( LocaleKeys.findAndReplace_replace.tr(), ), ), ), _FindAndReplaceIcon( onPressed: _replaceSelectedWord, iconBuilder: (_) => const Icon( Icons.find_replace_outlined, size: 16, ), tooltipText: LocaleKeys.findAndReplace_replace.tr(), ), const HSpace(4.0), _FindAndReplaceIcon( iconBuilder: (_) => const Icon( Icons.change_circle_outlined, size: 16, ), tooltipText: LocaleKeys.findAndReplace_replaceAll.tr(), onPressed: () => widget.searchService.replaceAllMatches( textController.text, ), ), ], ); } void _replaceSelectedWord() { widget.searchService.replaceSelectedWord(textController.text); } } class _FindAndReplaceIcon extends StatelessWidget { const _FindAndReplaceIcon({ required this.onPressed, required this.tooltipText, this.icon, this.iconBuilder, this.isSelected, }); final VoidCallback onPressed; final FlowySvgData? icon; final WidgetBuilder? iconBuilder; final String tooltipText; final bool? isSelected; @override Widget build(BuildContext context) { return FlowyIconButton( width: 24, height: 24, onPressed: onPressed, icon: iconBuilder?.call(context) ?? (icon != null ? FlowySvg(icon!, color: Theme.of(context).iconTheme.color) : const Placeholder()), tooltipText: tooltipText, isSelected: isSelected, iconColorOnHover: Theme.of(context).colorScheme.onSecondary, ); } } InputDecoration _buildInputDecoration(String hintText) { return InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), border: const UnderlineInputBorder(), hintText: hintText, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/util/font_family_extension.dart'; import 'package:appflowy/util/levenshtein.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_value_dropdown.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; class ThemeFontFamilySetting extends StatefulWidget { const ThemeFontFamilySetting({ super.key, required this.currentFontFamily, }); final String currentFontFamily; static Key textFieldKey = const Key('FontFamilyTextField'); static Key resetButtonKey = const Key('FontFamilyResetButton'); static Key popoverKey = const Key('FontFamilyPopover'); @override State createState() => _ThemeFontFamilySettingState(); } class _ThemeFontFamilySettingState extends State { @override Widget build(BuildContext context) { return SettingListTile( label: LocaleKeys.settings_appearance_fontFamily_label.tr(), resetButtonKey: ThemeFontFamilySetting.resetButtonKey, onResetRequested: () { context.read().resetFontFamily(); context .read() .syncFontFamily(DefaultAppearanceSettings.kDefaultFontFamily); }, trailing: [ FontFamilyDropDown(currentFontFamily: widget.currentFontFamily), ], ); } } class FontFamilyDropDown extends StatefulWidget { const FontFamilyDropDown({ super.key, required this.currentFontFamily, this.onOpen, this.onClose, this.onFontFamilyChanged, this.child, this.popoverController, this.offset, this.onResetFont, }); final String currentFontFamily; final VoidCallback? onOpen; final VoidCallback? onClose; final void Function(String fontFamily)? onFontFamilyChanged; final Widget? child; final PopoverController? popoverController; final Offset? offset; final VoidCallback? onResetFont; @override State createState() => _FontFamilyDropDownState(); } class _FontFamilyDropDownState extends State { final List availableFonts = [ defaultFontFamily, ...GoogleFonts.asMap().keys, ]; final ValueNotifier query = ValueNotifier(''); @override void dispose() { query.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final currentValue = widget.currentFontFamily.fontFamilyDisplayName; return SettingValueDropDown( popoverKey: ThemeFontFamilySetting.popoverKey, popoverController: widget.popoverController, currentValue: currentValue, margin: EdgeInsets.zero, boxConstraints: const BoxConstraints( maxWidth: 240, maxHeight: 420, ), onClose: () { query.value = ''; widget.onClose?.call(); }, offset: widget.offset, child: widget.child, popupBuilder: (_) { widget.onOpen?.call(); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.all(8.0), child: FlowyTextField( key: ThemeFontFamilySetting.textFieldKey, hintText: LocaleKeys.settings_appearance_fontFamily_search.tr(), autoFocus: true, debounceDuration: const Duration(milliseconds: 300), onChanged: (value) { setState(() { query.value = value; }); }, ), ), Container(height: 1, color: Theme.of(context).dividerColor), ValueListenableBuilder( valueListenable: query, builder: (context, value, child) { var displayed = availableFonts; if (value.isNotEmpty) { displayed = availableFonts .where( (font) => font .toLowerCase() .contains(value.toLowerCase().toString()), ) .sorted((a, b) => levenshtein(a, b)) .toList(); } return displayed.length >= 10 ? Flexible( child: ListView.builder( padding: const EdgeInsets.all(8.0), itemBuilder: (context, index) => _fontFamilyItemButton( context, getGoogleFontSafely(displayed[index]), ), itemCount: displayed.length, ), ) : Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: List.generate( displayed.length, (index) => _fontFamilyItemButton( context, getGoogleFontSafely(displayed[index]), ), ), ), ); }, ), ], ); }, ); } Widget _fontFamilyItemButton( BuildContext context, TextStyle style, ) { final buttonFontFamily = style.fontFamily?.parseFontFamilyName() ?? defaultFontFamily; return Tooltip( message: buttonFontFamily, waitDuration: const Duration(milliseconds: 150), child: SizedBox( key: ValueKey(buttonFontFamily), height: 36, child: FlowyButton( onHover: (_) => FocusScope.of(context).unfocus(), text: FlowyText( buttonFontFamily.fontFamilyDisplayName, fontFamily: buttonFontFamily, figmaLineHeight: 20, fontWeight: FontWeight.w400, ), rightIcon: buttonFontFamily == widget.currentFontFamily.parseFontFamilyName() ? const FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () { if (widget.onFontFamilyChanged != null) { widget.onFontFamilyChanged!(buttonFontFamily); } else { if (widget.currentFontFamily.parseFontFamilyName() != buttonFontFamily) { context .read() .setFontFamily(buttonFontFamily); context .read() .syncFontFamily(buttonFontFamily); } } PopoverContainer.of(context).close(); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart ================================================ import 'dart:ui'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; const String kLocalImagesKey = 'local_images'; List get builtInAssetImages => [ 'assets/images/built_in_cover_images/m_cover_image_1.jpg', 'assets/images/built_in_cover_images/m_cover_image_2.jpg', 'assets/images/built_in_cover_images/m_cover_image_3.jpg', 'assets/images/built_in_cover_images/m_cover_image_4.jpg', 'assets/images/built_in_cover_images/m_cover_image_5.jpg', 'assets/images/built_in_cover_images/m_cover_image_6.jpg', ]; class ColorOption { const ColorOption({ required this.colorHex, required this.name, }); final String colorHex; final String name; } class CoverColorPicker extends StatefulWidget { const CoverColorPicker({ super.key, this.selectedBackgroundColorHex, required this.pickerBackgroundColor, required this.backgroundColorOptions, required this.pickerItemHoverColor, required this.onSubmittedBackgroundColorHex, }); final String? selectedBackgroundColorHex; final Color pickerBackgroundColor; final List backgroundColorOptions; final Color pickerItemHoverColor; final void Function(String color) onSubmittedBackgroundColorHex; @override State createState() => _CoverColorPickerState(); } class _CoverColorPickerState extends State { final scrollController = ScrollController(); @override void dispose() { scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( height: 30, alignment: Alignment.center, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( dragDevices: { PointerDeviceKind.touch, PointerDeviceKind.mouse, }, platform: TargetPlatform.windows, ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: _buildColorItems( widget.backgroundColorOptions, widget.selectedBackgroundColorHex, ), ), ), ); } Widget _buildColorItems(List options, String? selectedColor) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: options .map( (e) => ColorItem( option: e, isChecked: e.colorHex == selectedColor, hoverColor: widget.pickerItemHoverColor, onTap: widget.onSubmittedBackgroundColorHex, ), ) .toList(), ); } } @visibleForTesting class ColorItem extends StatelessWidget { const ColorItem({ super.key, required this.option, required this.isChecked, required this.hoverColor, required this.onTap, }); final ColorOption option; final bool isChecked; final Color hoverColor; final void Function(String) onTap; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 10.0), child: InkWell( customBorder: const CircleBorder(), hoverColor: hoverColor, onTap: () => onTap(option.colorHex), child: SizedBox.square( dimension: 25, child: DecoratedBox( decoration: BoxDecoration( color: option.colorHex.tryToColor(), shape: BoxShape.circle, ), child: isChecked ? SizedBox.square( child: Container( margin: const EdgeInsets.all(1), decoration: BoxDecoration( border: Border.all( color: Theme.of(context).cardColor, width: 3.0, ), color: option.colorHex.tryToColor(), shape: BoxShape.circle, ), ), ) : null, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; part 'cover_editor_bloc.freezed.dart'; class ChangeCoverPopoverBloc extends Bloc { ChangeCoverPopoverBloc({required this.editorState, required this.node}) : super(const ChangeCoverPopoverState.initial()) { SharedPreferences.getInstance().then((prefs) { _prefs = prefs; _initCompleter.complete(); }); _dispatch(); } final EditorState editorState; final Node node; final _initCompleter = Completer(); late final SharedPreferences _prefs; void _dispatch() { on((event, emit) async { await event.map( fetchPickedImagePaths: (fetchPickedImagePaths) async { final imageNames = await _getPreviouslyPickedImagePaths(); emit( ChangeCoverPopoverState.loaded( imageNames, selectLatestImage: fetchPickedImagePaths.selectLatestImage, ), ); }, deleteImage: (deleteImage) async { final currentState = state; final currentlySelectedImage = node.attributes[DocumentHeaderBlockKeys.coverDetails]; if (currentState is _Loaded) { await _deleteImageInStorage(deleteImage.path); if (currentlySelectedImage == deleteImage.path) { _removeCoverImageFromNode(); } final updateImageList = currentState.imageNames .where((path) => path != deleteImage.path) .toList(); _updateImagePathsInStorage(updateImageList); emit(ChangeCoverPopoverState.loaded(updateImageList)); } }, clearAllImages: (clearAllImages) async { final currentState = state; final currentlySelectedImage = node.attributes[DocumentHeaderBlockKeys.coverDetails]; if (currentState is _Loaded) { for (final image in currentState.imageNames) { await _deleteImageInStorage(image); if (currentlySelectedImage == image) { _removeCoverImageFromNode(); } } _updateImagePathsInStorage([]); emit(const ChangeCoverPopoverState.loaded([])); } }, ); }); } Future> _getPreviouslyPickedImagePaths() async { await _initCompleter.future; final imageNames = _prefs.getStringList(kLocalImagesKey) ?? []; if (imageNames.isEmpty) { return imageNames; } imageNames.removeWhere((name) => !File(name).existsSync()); unawaited(_prefs.setStringList(kLocalImagesKey, imageNames)); return imageNames; } void _updateImagePathsInStorage(List imagePaths) async { await _initCompleter.future; await _prefs.setStringList(kLocalImagesKey, imagePaths); } Future _deleteImageInStorage(String path) async { final imageFile = File(path); await imageFile.delete(); } void _removeCoverImageFromNode() { final transaction = editorState.transaction; transaction.updateNode(node, { DocumentHeaderBlockKeys.coverType: CoverType.none.toString(), DocumentHeaderBlockKeys.icon: node.attributes[DocumentHeaderBlockKeys.icon], }); editorState.apply(transaction); } } @freezed class ChangeCoverPopoverEvent with _$ChangeCoverPopoverEvent { const factory ChangeCoverPopoverEvent.fetchPickedImagePaths({ @Default(false) bool selectLatestImage, }) = _FetchPickedImagePaths; const factory ChangeCoverPopoverEvent.deleteImage(String path) = _DeleteImage; const factory ChangeCoverPopoverEvent.clearAllImages() = _ClearAllImages; } @freezed class ChangeCoverPopoverState with _$ChangeCoverPopoverState { const factory ChangeCoverPopoverState.initial() = _Initial; const factory ChangeCoverPopoverState.loading() = _Loading; const factory ChangeCoverPopoverState.loaded( List imageNames, { @Default(false) selectLatestImage, }) = _Loaded; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; class CoverTitle extends StatelessWidget { const CoverTitle({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => ViewBloc(view: view)..add(const ViewEvent.initial()), child: _InnerCoverTitle( view: view, ), ); } } class _InnerCoverTitle extends StatefulWidget { const _InnerCoverTitle({ required this.view, }); final ViewPB view; @override State<_InnerCoverTitle> createState() => _InnerCoverTitleState(); } class _InnerCoverTitleState extends State<_InnerCoverTitle> { final titleTextController = TextEditingController(); late final editorContext = context.read(); late final editorState = context.read(); late final titleFocusNode = editorContext.coverTitleFocusNode; int lineCount = 1; bool updatingViewName = false; @override void initState() { super.initState(); titleTextController.text = widget.view.name; titleTextController.addListener(_onViewNameChanged); titleFocusNode ..onKeyEvent = _onKeyEvent ..addListener(_onFocusChanged); editorState.selectionNotifier.addListener(_onSelectionChanged); _requestInitialFocus(); } @override void dispose() { titleFocusNode ..onKeyEvent = null ..removeListener(_onFocusChanged); titleTextController.dispose(); editorState.selectionNotifier.removeListener(_onSelectionChanged); super.dispose(); } @override Widget build(BuildContext context) { final fontStyle = Theme.of(context) .textTheme .bodyMedium! .copyWith(fontSize: 40.0, fontWeight: FontWeight.w700); final width = context.read().state.width; return BlocConsumer( listenWhen: (previous, current) => previous.view.name != current.view.name && !updatingViewName, listener: _onListen, builder: (context, state) { final appearance = context.read().state; return Container( constraints: BoxConstraints(maxWidth: width), child: Theme( data: Theme.of(context).copyWith( textSelectionTheme: TextSelectionThemeData( cursorColor: appearance.selectionColor, selectionColor: appearance.selectionColor ?? DefaultAppearanceSettings.getDefaultSelectionColor(context), ), ), child: TextFieldWithMetricLines( controller: titleTextController, enabled: editorState.editable, focusNode: titleFocusNode, style: fontStyle, onLineCountChange: (count) => lineCount = count, decoration: InputDecoration( border: InputBorder.none, hintText: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), hintStyle: fontStyle.copyWith( color: Theme.of(context).hintColor, ), ), ), ), ); }, ); } void _requestInitialFocus() { if (editorContext.requestCoverTitleFocus) { void requestFocus() { titleFocusNode.canRequestFocus = true; titleFocusNode.requestFocus(); editorContext.requestCoverTitleFocus = false; } // on macOS, if we gain focus immediately, the focus won't work. // It's a workaround to delay the focus request. if (UniversalPlatform.isMacOS) { Future.delayed(Durations.short4, () { requestFocus(); }); } else { WidgetsBinding.instance.addPostFrameCallback((_) { requestFocus(); }); } } } void _onSelectionChanged() { // if title is focused and the selection is not null, clear the selection if (editorState.selection != null && titleFocusNode.hasFocus) { Log.info('title is focused, clear the editor selection'); editorState.selection = null; } } void _onListen(BuildContext context, ViewState state) { _requestFocusIfNeeded(widget.view, state); if (state.view.name != titleTextController.text) { titleTextController.text = state.view.name; } } bool _shouldFocus(ViewPB view, ViewState? state) { final name = state?.view.name ?? view.name; if (editorState.document.root.children.isNotEmpty) { return false; } // if the view's name is empty, focus on the title if (name.isEmpty) { return true; } return false; } void _requestFocusIfNeeded(ViewPB view, ViewState? state) { final shouldFocus = _shouldFocus(view, state); if (shouldFocus) { titleFocusNode.requestFocus(); } } void _onFocusChanged() { if (titleFocusNode.hasFocus) { // if the document is empty, disable the keyboard service final children = editorState.document.root.children; final firstDelta = children.firstOrNull?.delta; final isEmptyDocument = children.length == 1 && (firstDelta == null || firstDelta.isEmpty); if (!isEmptyDocument) { return; } if (editorState.selection != null) { Log.info('cover title got focus, clear the editor selection'); editorState.selection = null; } Log.info('cover title got focus, disable keyboard service'); editorState.service.keyboardService?.disable(); } else { Log.info('cover title lost focus, enable keyboard service'); editorState.service.keyboardService?.enable(); } } void _onViewNameChanged() { updatingViewName = true; Debounce.debounce( 'update view name', const Duration(milliseconds: 250), () { if (!mounted) { return; } if (context.read().state.view.name != titleTextController.text) { context .read() .add(ViewEvent.rename(titleTextController.text)); } context .read() ?.add(ViewInfoEvent.titleChanged(titleTextController.text)); updatingViewName = false; }, ); } KeyEventResult _onKeyEvent(FocusNode focusNode, KeyEvent event) { if (event is KeyUpEvent) { return KeyEventResult.ignored; } if (event.logicalKey == LogicalKeyboardKey.enter) { // if enter is pressed, jump the first line of editor. _createNewLine(); return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { return _moveCursorToNextLine(event.logicalKey); } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { return _moveCursorToNextLine(event.logicalKey); } else if (event.logicalKey == LogicalKeyboardKey.escape) { return _exitEditing(); } else if (event.logicalKey == LogicalKeyboardKey.tab) { return KeyEventResult.handled; } return KeyEventResult.ignored; } KeyEventResult _exitEditing() { titleFocusNode.unfocus(); return KeyEventResult.handled; } Future _createNewLine() async { titleFocusNode.unfocus(); final selection = titleTextController.selection; final text = titleTextController.text; // split the text into two lines based on the cursor position final parts = [ text.substring(0, selection.baseOffset), text.substring(selection.baseOffset), ]; titleTextController.text = parts[0]; final transaction = editorState.transaction; transaction.insertNode([0], paragraphNode(text: parts[1])); await editorState.apply(transaction); // update selection instead of using afterSelection in transaction, // because it will cause the cursor to jump await editorState.updateSelectionWithReason( Selection.collapsed(Position(path: [0])), // trigger the keyboard service. reason: SelectionUpdateReason.uiEvent, ); } KeyEventResult _moveCursorToNextLine(LogicalKeyboardKey key) { final selection = titleTextController.selection; final text = titleTextController.text; // if the cursor is not at the end of the text, ignore the event if ((key == LogicalKeyboardKey.arrowRight || lineCount != 1) && (!selection.isCollapsed || text.length != selection.extentOffset)) { return KeyEventResult.ignored; } final node = editorState.getNodeAtPath([0]); if (node == null) { _createNewLine(); return KeyEventResult.handled; } titleFocusNode.unfocus(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { // delay the update selection to wait for the title to unfocus int offset = 0; if (key == LogicalKeyboardKey.arrowDown) { offset = node.delta?.length ?? 0; } else if (key == LogicalKeyboardKey.arrowRight) { offset = 0; } editorState.updateSelectionWithReason( Selection.collapsed( Position(path: [0], offset: offset), ), // trigger the keyboard service. reason: SelectionUpdateReason.uiEvent, ); }); return KeyEventResult.handled; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CoverImagePicker extends StatefulWidget { const CoverImagePicker({ super.key, required this.onBackPressed, required this.onFileSubmit, }); final VoidCallback onBackPressed; final Function(List paths) onFileSubmit; @override State createState() => _CoverImagePickerState(); } class _CoverImagePickerState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (context) => CoverImagePickerBloc() ..add(const CoverImagePickerEvent.initialEvent()), child: BlocListener( listener: (context, state) { state.maybeWhen( networkImage: (successOrFail) { successOrFail.fold( (s) {}, (e) => showSnapBar( context, LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), ), ); }, done: (successOrFail) { successOrFail.fold( (l) => widget.onFileSubmit(l), (r) => showSnapBar( context, LocaleKeys.document_plugins_cover_failedToAddImageToGallery .tr(), ), ); }, orElse: () {}, ); }, child: BlocBuilder( builder: (context, state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ state.maybeMap( loading: (_) => const SizedBox( height: 180, child: Center( child: CircularProgressIndicator(), ), ), orElse: () => CoverImagePreviewWidget(state: state), ), const VSpace(10), NetworkImageUrlInput( onAdd: (url) { context .read() .add(CoverImagePickerEvent.urlSubmit(url)); }, ), const VSpace(10), ImagePickerActionButtons( onBackPressed: () { widget.onBackPressed(); }, onSave: () { context .read() .add(CoverImagePickerEvent.saveToGallery(state)); }, ), ], ); }, ), ), ); } } class NetworkImageUrlInput extends StatefulWidget { const NetworkImageUrlInput({super.key, required this.onAdd}); final void Function(String color) onAdd; @override State createState() => _NetworkImageUrlInputState(); } class _NetworkImageUrlInputState extends State { TextEditingController urlController = TextEditingController(); bool get buttonDisabled => urlController.text.isEmpty; @override void initState() { super.initState(); urlController.addListener(_updateState); } void _updateState() => setState(() {}); @override void dispose() { urlController.removeListener(_updateState); urlController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Row( children: [ Expanded( flex: 4, child: FlowyTextField( controller: urlController, hintText: LocaleKeys.document_plugins_cover_enterImageUrl.tr(), ), ), const SizedBox( width: 5, ), Expanded( child: RoundedTextButton( onPressed: () { urlController.text.isNotEmpty ? widget.onAdd(urlController.text) : null; }, hoverColor: Colors.transparent, fillColor: buttonDisabled ? Theme.of(context).disabledColor : Theme.of(context).colorScheme.primary, height: 36, title: LocaleKeys.document_plugins_cover_add.tr(), borderRadius: Corners.s8Border, ), ), ], ); } } class ImagePickerActionButtons extends StatelessWidget { const ImagePickerActionButtons({ super.key, required this.onBackPressed, required this.onSave, }); final VoidCallback onBackPressed; final VoidCallback onSave; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ FlowyTextButton( LocaleKeys.document_plugins_cover_back.tr(), hoverColor: Theme.of(context).colorScheme.secondaryContainer, fillColor: Colors.transparent, mainAxisAlignment: MainAxisAlignment.end, onPressed: () => onBackPressed(), ), FlowyTextButton( LocaleKeys.document_plugins_cover_saveToGallery.tr(), onPressed: () => onSave(), hoverColor: Theme.of(context).colorScheme.secondaryContainer, fillColor: Colors.transparent, mainAxisAlignment: MainAxisAlignment.end, fontColor: Theme.of(context).colorScheme.primary, ), ], ); } } class CoverImagePreviewWidget extends StatefulWidget { const CoverImagePreviewWidget({super.key, required this.state}); final CoverImagePickerState state; @override State createState() => _CoverImagePreviewWidgetState(); } class _CoverImagePreviewWidgetState extends State { DecoratedBox _buildFilePickerWidget(BuildContext ctx) { return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: Corners.s6Border, border: Border.fromBorderSide( BorderSide( color: Theme.of(context).colorScheme.primary, ), ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( FlowySvgs.add_s, size: Size(20, 20), ), const SizedBox( width: 3, ), FlowyText( LocaleKeys.document_plugins_cover_pasteImageUrl.tr(), ), ], ), const VSpace(10), FlowyText( LocaleKeys.document_plugins_cover_or.tr(), fontWeight: FontWeight.w300, ), const VSpace(10), FlowyButton( hoverColor: Theme.of(context).hoverColor, onTap: () { ctx .read() .add(const CoverImagePickerEvent.pickFileImage()); }, useIntrinsicWidth: true, leftIcon: const FlowySvg( FlowySvgs.document_s, size: Size(20, 20), ), text: FlowyText( lineHeight: 1.0, LocaleKeys.document_plugins_cover_pickFromFiles.tr(), ), ), ], ), ); } Positioned _buildImageDeleteButton(BuildContext ctx) { return Positioned( right: 10, top: 10, child: InkWell( onTap: () { ctx .read() .add(const CoverImagePickerEvent.deleteImage()); }, child: DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of(context).colorScheme.onPrimary, ), child: const FlowySvg( FlowySvgs.close_s, size: Size(20, 20), ), ), ), ); } @override Widget build(BuildContext context) { return Stack( children: [ Container( height: 180, alignment: Alignment.center, decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, borderRadius: Corners.s6Border, image: widget.state.whenOrNull( networkImage: (successOrFail) { return successOrFail.fold( (path) => DecorationImage( image: NetworkImage(path), fit: BoxFit.cover, ), (r) => null, ); }, fileImage: (path) { return DecorationImage( image: FileImage(File(path)), fit: BoxFit.cover, ); }, ), ), child: widget.state.whenOrNull( initial: () => _buildFilePickerWidget(context), networkImage: (successOrFail) => successOrFail.fold( (l) => null, (r) => _buildFilePickerWidget( context, ), ), ), ), widget.state.maybeWhen( fileImage: (_) => _buildImageDeleteButton(context), networkImage: (successOrFail) => successOrFail.fold( (l) => _buildImageDeleteButton(context), (r) => const SizedBox.shrink(), ), orElse: () => const SizedBox.shrink(), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/default_extensions.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; import 'cover_editor.dart'; part 'custom_cover_picker_bloc.freezed.dart'; class CoverImagePickerBloc extends Bloc { CoverImagePickerBloc() : super(const CoverImagePickerState.initial()) { _dispatch(); } void _dispatch() { on( (event, emit) async { await event.map( initialEvent: (initialEvent) { emit(const CoverImagePickerState.initial()); }, urlSubmit: (urlSubmit) async { emit(const CoverImagePickerState.loading()); final validateImage = await _validateURL(urlSubmit.path); if (validateImage) { emit( CoverImagePickerState.networkImage( FlowyResult.success(urlSubmit.path), ), ); } else { emit( CoverImagePickerState.networkImage( FlowyResult.failure( FlowyError( msg: LocaleKeys.document_plugins_cover_couldNotFetchImage .tr(), ), ), ), ); } }, pickFileImage: (pickFileImage) async { final imagePickerResults = await _pickImages(); if (imagePickerResults != null) { emit(CoverImagePickerState.fileImage(imagePickerResults)); } else { emit(const CoverImagePickerState.initial()); } }, deleteImage: (deleteImage) { emit(const CoverImagePickerState.initial()); }, saveToGallery: (saveToGallery) async { emit(const CoverImagePickerState.loading()); final saveImage = await _saveToGallery(saveToGallery.previousState); if (saveImage != null) { emit(CoverImagePickerState.done(FlowyResult.success(saveImage))); } else { emit( CoverImagePickerState.done( FlowyResult.failure( FlowyError( msg: LocaleKeys.document_plugins_cover_imageSavingFailed .tr(), ), ), ), ); emit(const CoverImagePickerState.initial()); } }, ); }, ); } Future?>? _saveToGallery(CoverImagePickerState state) async { final SharedPreferences prefs = await SharedPreferences.getInstance(); final List imagePaths = prefs.getStringList(kLocalImagesKey) ?? []; final directory = await _coverPath(); if (state is _FileImagePicked) { try { final path = state.path; final newPath = p.join(directory, p.split(path).last); final newFile = await File(path).copy(newPath); imagePaths.add(newFile.path); } catch (e) { return null; } } else if (state is _NetworkImagePicked) { try { final url = state.successOrFail.fold((path) => path, (r) => null); if (url != null) { final response = await http.get(Uri.parse(url)); final newPath = p.join(directory, _networkImageName(url)); final imageFile = File(newPath); await imageFile.create(); await imageFile.writeAsBytes(response.bodyBytes); imagePaths.add(imageFile.absolute.path); } else { return null; } } catch (e) { return null; } } await prefs.setStringList(kLocalImagesKey, imagePaths); return imagePaths; } Future _pickImages() async { final result = await getIt().pickFiles( dialogTitle: LocaleKeys.document_plugins_cover_addLocalImage.tr(), type: FileType.image, allowedExtensions: defaultImageExtensions, ); if (result != null && result.files.isNotEmpty) { return result.files.first.path; } return null; } Future _coverPath() async { final directory = await getIt().getPath(); return Directory(p.join(directory, 'covers')) .create(recursive: true) .then((value) => value.path); } String _networkImageName(String url) { return 'IMG_${DateTime.now().millisecondsSinceEpoch.toString()}.${_getExtension( url, fromNetwork: true, )}'; } String? _getExtension( String path, { bool fromNetwork = false, }) { String? ext; if (!fromNetwork) { final extension = p.extension(path); if (extension.isEmpty) { return null; } ext = extension; } else { final uri = Uri.parse(path); final parameters = uri.queryParameters; if (path.contains('unsplash')) { final dl = parameters['dl']; if (dl != null) { ext = p.extension(dl); } } else { ext = p.extension(path); } } if (ext != null && ext.isNotEmpty) { ext = ext.substring(1); } if (defaultImageExtensions.contains(ext)) { return ext; } return null; } Future _validateURL(String path) async { final extension = _getExtension(path, fromNetwork: true); if (extension == null) { return false; } try { final response = await http.head(Uri.parse(path)); return response.statusCode == 200; } catch (e) { return false; } } } @freezed class CoverImagePickerEvent with _$CoverImagePickerEvent { const factory CoverImagePickerEvent.urlSubmit(String path) = _UrlSubmit; const factory CoverImagePickerEvent.pickFileImage() = _PickFileImage; const factory CoverImagePickerEvent.deleteImage() = _DeleteImage; const factory CoverImagePickerEvent.saveToGallery( CoverImagePickerState previousState, ) = _SaveToGallery; const factory CoverImagePickerEvent.initialEvent() = _InitialEvent; } @freezed class CoverImagePickerState with _$CoverImagePickerState { const factory CoverImagePickerState.initial() = _Initial; const factory CoverImagePickerState.loading() = _Loading; const factory CoverImagePickerState.networkImage( FlowyResult successOrFail, ) = _NetworkImagePicked; const factory CoverImagePickerState.fileImage(String path) = _FileImagePicked; const factory CoverImagePickerState.done( FlowyResult, FlowyError> successOrFail, ) = _Done; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart ================================================ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; /// This is a transitional component that can be removed once the desktop /// supports immersive widgets, allowing for the exclusive use of the DocumentImmersiveCover component. class DesktopCover extends StatefulWidget { const DesktopCover({ super.key, required this.view, required this.editorState, required this.node, required this.coverType, this.coverDetails, }); final ViewPB view; final Node node; final EditorState editorState; final CoverType coverType; final String? coverDetails; @override State createState() => _DesktopCoverState(); } class _DesktopCoverState extends State { CoverType get coverType => CoverType.fromString( widget.node.attributes[DocumentHeaderBlockKeys.coverType], ); String? get coverDetails => widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; @override Widget build(BuildContext context) { if (widget.view.extra.isEmpty) { return _buildCoverImageV1(); } return _buildCoverImageV2(); } // version > 0.5.5 Widget _buildCoverImageV2() { return BlocProvider( create: (context) => DocumentImmersiveCoverBloc(view: widget.view) ..add(const DocumentImmersiveCoverEvent.initial()), child: BlocBuilder( builder: (context, state) { final cover = state.cover; final type = state.cover.type; const height = kCoverHeight; if (type == PageStyleCoverImageType.customImage || type == PageStyleCoverImageType.unsplashImage) { final userProfilePB = context.read().state.userProfilePB; return SizedBox( height: height, width: double.infinity, child: FlowyNetworkImage( url: cover.value, userProfilePB: userProfilePB, ), ); } if (type == PageStyleCoverImageType.builtInImage) { return SizedBox( height: height, width: double.infinity, child: Image.asset( PageStyleCoverImageType.builtInImagePath(cover.value), fit: BoxFit.cover, ), ); } if (type == PageStyleCoverImageType.pureColor) { // try to parse the color from the tint id, // if it fails, try to parse the color as a hex string final color = FlowyTint.fromId(cover.value)?.color(context) ?? cover.value.tryToColor(); return Container( height: height, width: double.infinity, color: color, ); } if (type == PageStyleCoverImageType.gradientColor) { return Container( height: height, width: double.infinity, decoration: BoxDecoration( gradient: FlowyGradientColor.fromId(cover.value).linear, ), ); } if (type == PageStyleCoverImageType.localImage) { return SizedBox( height: height, width: double.infinity, child: Image.file( File(cover.value), fit: BoxFit.cover, ), ); } return const SizedBox.shrink(); }, ), ); } // version <= 0.5.5 Widget _buildCoverImageV1() { final detail = coverDetails; if (detail == null) { return const SizedBox.shrink(); } switch (widget.coverType) { case CoverType.file: if (isURL(detail)) { final userProfilePB = context.read().state.userProfilePB; return FlowyNetworkImage( url: detail, userProfilePB: userProfilePB, errorWidgetBuilder: (context, url, error) => const SizedBox.shrink(), ); } final imageFile = File(detail); if (!imageFile.existsSync()) { return const SizedBox.shrink(); } return Image.file( imageFile, fit: BoxFit.cover, ); case CoverType.asset: return Image.asset( PageStyleCoverImageType.builtInImagePath(detail), fit: BoxFit.cover, ); case CoverType.color: final color = widget.coverDetails?.tryToColor() ?? Colors.white; return Container(color: color); case CoverType.none: return const SizedBox.shrink(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart ================================================ import 'dart:io'; import 'dart:math'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/desktop_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; import 'cover_title.dart'; const double kCoverHeight = 280.0; const double kIconHeight = 60.0; const double kToolbarHeight = 40.0; // with padding to the top // Remove this widget if the desktop support immersive cover. class DocumentHeaderBlockKeys { const DocumentHeaderBlockKeys._(); static const String coverType = 'cover_selection_type'; static const String coverDetails = 'cover_selection'; static const String icon = 'selected_icon'; } // for the version under 0.5.5, including 0.5.5 enum CoverType { none, color, file, asset; static CoverType fromString(String? value) { if (value == null) { return CoverType.none; } return CoverType.values.firstWhere( (e) => e.toString() == value, orElse: () => CoverType.none, ); } } // This key is used to intercept the selection event in the document cover widget. const _interceptorKey = 'document_cover_widget_interceptor'; class DocumentCoverWidget extends StatefulWidget { const DocumentCoverWidget({ super.key, required this.node, required this.editorState, required this.onIconChanged, required this.view, required this.tabs, }); final Node node; final EditorState editorState; final ValueChanged onIconChanged; final ViewPB view; final List tabs; @override State createState() => _DocumentCoverWidgetState(); } class _DocumentCoverWidgetState extends State { CoverType get coverType => CoverType.fromString( widget.node.attributes[DocumentHeaderBlockKeys.coverType], ); String? get coverDetails => widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; String? get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon]; bool get hasIcon => viewIcon.emoji.isNotEmpty; bool get hasCover => coverType != CoverType.none || (cover != null && cover?.type != PageStyleCoverImageType.none); RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; EmojiIconData viewIcon = EmojiIconData.none(); PageStyleCover? cover; late ViewPB view; late final ViewListener viewListener; int retryCount = 0; final isCoverTitleHovered = ValueNotifier(false); late final gestureInterceptor = SelectionGestureInterceptor( key: _interceptorKey, canTap: (details) => !_isTapInBounds(details.globalPosition), canPanStart: (details) => !_isDragInBounds(details.globalPosition), ); @override void initState() { super.initState(); final icon = widget.view.icon; viewIcon = EmojiIconData.fromViewIconPB(icon); cover = widget.view.cover; view = widget.view; widget.node.addListener(_reload); widget.editorState.service.selectionService .registerGestureInterceptor(gestureInterceptor); viewListener = ViewListener(viewId: widget.view.id) ..start( onViewUpdated: (view) { setState(() { viewIcon = EmojiIconData.fromViewIconPB(view.icon); cover = view.cover; view = view; }); }, ); } @override void dispose() { viewListener.stop(); widget.node.removeListener(_reload); isCoverTitleHovered.dispose(); widget.editorState.service.selectionService .unregisterGestureInterceptor(_interceptorKey); super.dispose(); } @override Widget build(BuildContext context) { return IgnorePointer( ignoring: !widget.editorState.editable, child: LayoutBuilder( builder: (context, constraints) { final offset = _calculateIconLeft(context, constraints); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Stack( children: [ SizedBox( height: _calculateOverallHeight(), child: DocumentHeaderToolbar( onIconOrCoverChanged: _saveIconOrCover, node: widget.node, editorState: widget.editorState, hasCover: hasCover, hasIcon: hasIcon, offset: offset, isCoverTitleHovered: isCoverTitleHovered, documentId: view.id, tabs: widget.tabs, ), ), if (hasCover) DocumentCover( view: view, editorState: widget.editorState, node: widget.node, coverType: coverType, coverDetails: coverDetails, onChangeCover: (type, details) => _saveIconOrCover(cover: (type, details)), ), _buildAlignedCoverIcon(context), ], ), _buildAlignedTitle(context), ], ); }, ), ); } Widget _buildAlignedTitle(BuildContext context) { return Center( child: Container( constraints: BoxConstraints( maxWidth: widget.editorState.editorStyle.maxWidth ?? double.infinity, ), padding: widget.editorState.editorStyle.padding + const EdgeInsets.symmetric(horizontal: 44), child: MouseRegion( onEnter: (event) => isCoverTitleHovered.value = true, onExit: (event) => isCoverTitleHovered.value = false, child: CoverTitle( view: widget.view, ), ), ), ); } Widget _buildAlignedCoverIcon(BuildContext context) { if (!hasIcon) { return const SizedBox.shrink(); } return Positioned( bottom: hasCover ? kToolbarHeight - kIconHeight / 2 : kToolbarHeight, left: 0, right: 0, child: Center( child: Container( constraints: BoxConstraints( maxWidth: widget.editorState.editorStyle.maxWidth ?? double.infinity, ), padding: widget.editorState.editorStyle.padding + const EdgeInsets.symmetric(horizontal: 44), child: Row( children: [ DocumentIcon( editorState: widget.editorState, node: widget.node, icon: viewIcon, documentId: view.id, onChangeIcon: (icon) => _saveIconOrCover(icon: icon), ), Spacer(), ], ), ), ), ); } void _reload() => setState(() {}); double _calculateIconLeft(BuildContext context, BoxConstraints constraints) { final editorState = context.read(); final appearanceCubit = context.read(); final renderBox = editorState.renderBox; if (renderBox == null || !renderBox.hasSize) {} var renderBoxWidth = 0.0; if (renderBox != null && renderBox.hasSize) { renderBoxWidth = renderBox.size.width; } else if (retryCount <= 3) { retryCount++; // this is a workaround for the issue that the renderBox is not initialized WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _reload(); }); return 0; } // if the renderBox width equals to 0, it means the editor is not initialized final editorWidth = renderBoxWidth != 0 ? min(renderBoxWidth, appearanceCubit.state.width) : appearanceCubit.state.width; // left padding + editor width + right padding = the width of the editor final leftOffset = (constraints.maxWidth - editorWidth) / 2.0 + EditorStyleCustomizer.documentPadding.right; // ensure the offset is not negative return max(0, leftOffset); } double _calculateOverallHeight() { final height = switch ((hasIcon, hasCover)) { (true, true) => kCoverHeight + kToolbarHeight, (true, false) => 50 + kIconHeight + kToolbarHeight, (false, true) => kCoverHeight + kToolbarHeight, (false, false) => kToolbarHeight, }; return height; } void _saveIconOrCover({ (CoverType, String?)? cover, EmojiIconData? icon, }) async { if (!widget.editorState.editable) { return; } final transaction = widget.editorState.transaction; final coverType = widget.node.attributes[DocumentHeaderBlockKeys.coverType]; final coverDetails = widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; final Map attributes = { DocumentHeaderBlockKeys.coverType: coverType, DocumentHeaderBlockKeys.coverDetails: coverDetails, DocumentHeaderBlockKeys.icon: widget.node.attributes[DocumentHeaderBlockKeys.icon], CustomImageBlockKeys.imageType: '1', }; if (cover != null) { attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString(); attributes[DocumentHeaderBlockKeys.coverDetails] = cover.$2; } if (icon != null) { attributes[DocumentHeaderBlockKeys.icon] = icon.emoji; widget.onIconChanged(icon); } // compatible with version <= 0.5.5. transaction.updateNode(widget.node, attributes); await widget.editorState.apply(transaction); // compatible with version > 0.5.5. EditorMigration.migrateCoverIfNeeded( widget.view, attributes, overwrite: true, ); } bool _isTapInBounds(Offset offset) { if (_renderBox == null) { return false; } final localPosition = _renderBox!.globalToLocal(offset); return _renderBox!.paintBounds.contains(localPosition); } bool _isDragInBounds(Offset offset) { if (_renderBox == null) { return false; } final localPosition = _renderBox!.globalToLocal(offset); return _renderBox!.paintBounds.contains(localPosition); } } @visibleForTesting class DocumentHeaderToolbar extends StatefulWidget { const DocumentHeaderToolbar({ super.key, required this.node, required this.editorState, required this.hasCover, required this.hasIcon, required this.onIconOrCoverChanged, required this.offset, this.documentId, required this.isCoverTitleHovered, required this.tabs, }); final Node node; final EditorState editorState; final bool hasCover; final bool hasIcon; final void Function({(CoverType, String?)? cover, EmojiIconData? icon}) onIconOrCoverChanged; final double offset; final String? documentId; final ValueNotifier isCoverTitleHovered; final List tabs; @override State createState() => _DocumentHeaderToolbarState(); } class _DocumentHeaderToolbarState extends State { final _popoverController = PopoverController(); bool isHidden = UniversalPlatform.isDesktopOrWeb; bool isPopoverOpen = false; @override Widget build(BuildContext context) { Widget child = Container( alignment: Alignment.bottomLeft, width: double.infinity, padding: EdgeInsets.symmetric(horizontal: widget.offset), child: SizedBox( height: 28, child: ValueListenableBuilder( valueListenable: widget.isCoverTitleHovered, builder: (context, isHovered, child) { return Visibility( visible: !isHidden || isPopoverOpen || isHovered, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: buildRowChildren(), ), ); }, ), ), ); if (UniversalPlatform.isDesktopOrWeb) { child = MouseRegion( opaque: false, onEnter: (event) => setHidden(false), onExit: isPopoverOpen ? null : (_) => setHidden(true), child: child, ); } return child; } List buildRowChildren() { if (widget.hasCover && widget.hasIcon) { return []; } final List children = []; if (!widget.hasCover) { children.add( FlowyButton( leftIconSize: const Size.square(18), onTap: () => widget.onIconOrCoverChanged( cover: UniversalPlatform.isDesktopOrWeb ? (CoverType.asset, '1') : (CoverType.color, '0xffe8e0ff'), ), useIntrinsicWidth: true, leftIcon: const FlowySvg(FlowySvgs.add_cover_s), text: FlowyText.small( LocaleKeys.document_plugins_cover_addCover.tr(), color: Theme.of(context).hintColor, ), ), ); } if (widget.hasIcon) { children.add( FlowyButton( onTap: () => widget.onIconOrCoverChanged(icon: EmojiIconData.none()), useIntrinsicWidth: true, leftIcon: const FlowySvg(FlowySvgs.add_icon_s), iconPadding: 4.0, text: FlowyText.small( LocaleKeys.document_plugins_cover_removeIcon.tr(), color: Theme.of(context).hintColor, ), ), ); } else { Widget child = FlowyButton( useIntrinsicWidth: true, leftIcon: const FlowySvg(FlowySvgs.add_icon_s), iconPadding: 4.0, text: FlowyText.small( LocaleKeys.document_plugins_cover_addIcon.tr(), color: Theme.of(context).hintColor, ), onTap: UniversalPlatform.isDesktop ? null : () async { final result = await context.push( MobileEmojiPickerScreen.routeName, ); if (result != null) { widget.onIconOrCoverChanged(icon: result); } }, ); if (UniversalPlatform.isDesktop) { child = AppFlowyPopover( onClose: () => setState(() => isPopoverOpen = false), controller: _popoverController, offset: const Offset(0, 8), direction: PopoverDirection.bottomWithCenterAligned, constraints: BoxConstraints.loose(const Size(360, 380)), margin: EdgeInsets.zero, child: child, popupBuilder: (BuildContext popoverContext) { isPopoverOpen = true; return FlowyIconEmojiPicker( tabs: widget.tabs, documentId: widget.documentId, onSelectedEmoji: (r) { widget.onIconOrCoverChanged(icon: r.data); if (!r.keepOpen) _popoverController.close(); }, ); }, ); } children.add(child); } return children; } void setHidden(bool value) { if (isHidden == value) return; setState(() { isHidden = value; }); } } @visibleForTesting class DocumentCover extends StatefulWidget { const DocumentCover({ super.key, required this.view, required this.node, required this.editorState, required this.coverType, this.coverDetails, required this.onChangeCover, }); final ViewPB view; final Node node; final EditorState editorState; final CoverType coverType; final String? coverDetails; final void Function(CoverType type, String? details) onChangeCover; @override State createState() => DocumentCoverState(); } class DocumentCoverState extends State { final popoverController = PopoverController(); bool isOverlayButtonsHidden = true; bool isPopoverOpen = false; @override Widget build(BuildContext context) { return UniversalPlatform.isDesktopOrWeb ? _buildDesktopCover() : _buildMobileCover(); } Widget _buildDesktopCover() { return SizedBox( height: kCoverHeight, child: MouseRegion( onEnter: (event) => setOverlayButtonsHidden(false), onExit: (event) => setOverlayButtonsHidden(isPopoverOpen ? false : true), child: Stack( children: [ SizedBox( height: double.infinity, width: double.infinity, child: DesktopCover( view: widget.view, editorState: widget.editorState, node: widget.node, coverType: widget.coverType, coverDetails: widget.coverDetails, ), ), if (!isOverlayButtonsHidden) _buildCoverOverlayButtons(context), ], ), ), ); } Widget _buildMobileCover() { return SizedBox( height: kCoverHeight, child: Stack( children: [ SizedBox( height: double.infinity, width: double.infinity, child: _buildCoverImage(), ), Positioned( bottom: 8, right: 12, child: Row( children: [ IntrinsicWidth( child: RoundedTextButton( fontSize: 14, onPressed: () { showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showCloseButton: true, title: LocaleKeys.document_plugins_cover_changeCover.tr(), builder: (context) { return Padding( padding: const EdgeInsets.only(top: 8.0), child: ConstrainedBox( constraints: const BoxConstraints( maxHeight: 340, minHeight: 80, ), child: UploadImageMenu( limitMaximumImageSize: !_isLocalMode(), supportTypes: const [ UploadImageType.color, UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, ], onSelectedLocalImages: (files) async { context.pop(); if (files.isEmpty) { return; } widget.onChangeCover( CoverType.file, files.first.path, ); }, onSelectedAIImage: (_) { throw UnimplementedError(); }, onSelectedNetworkImage: (url) async { context.pop(); widget.onChangeCover(CoverType.file, url); }, onSelectedColor: (color) { context.pop(); widget.onChangeCover(CoverType.color, color); }, ), ), ); }, ); }, fillColor: Theme.of(context) .colorScheme .onSurfaceVariant .withValues(alpha: 0.5), height: 32, title: LocaleKeys.document_plugins_cover_changeCover.tr(), ), ), const HSpace(8.0), SizedBox.square( dimension: 32.0, child: DeleteCoverButton( onTap: () => widget.onChangeCover(CoverType.none, null), ), ), ], ), ), ], ), ); } Widget _buildCoverImage() { final detail = widget.coverDetails; if (detail == null) { return const SizedBox.shrink(); } switch (widget.coverType) { case CoverType.file: if (isURL(detail)) { final userProfilePB = context.read().state.userProfilePB; return FlowyNetworkImage( url: detail, userProfilePB: userProfilePB, errorWidgetBuilder: (context, url, error) => const SizedBox.shrink(), ); } final imageFile = File(detail); if (!imageFile.existsSync()) { WidgetsBinding.instance.addPostFrameCallback((_) { widget.onChangeCover(CoverType.none, null); }); return const SizedBox.shrink(); } return Image.file( imageFile, fit: BoxFit.cover, ); case CoverType.asset: return Image.asset( widget.coverDetails!, fit: BoxFit.cover, ); case CoverType.color: final color = widget.coverDetails?.tryToColor() ?? Colors.white; return Container(color: color); case CoverType.none: return const SizedBox.shrink(); } } Widget _buildCoverOverlayButtons(BuildContext context) { return Positioned( bottom: 20, right: 50, child: Row( mainAxisSize: MainAxisSize.min, children: [ AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, offset: const Offset(0, 8), direction: PopoverDirection.bottomWithCenterAligned, constraints: const BoxConstraints( maxWidth: 540, maxHeight: 360, minHeight: 80, ), margin: EdgeInsets.zero, onClose: () => isPopoverOpen = false, child: IntrinsicWidth( child: RoundedTextButton( height: 28.0, onPressed: () => popoverController.show(), hoverColor: Theme.of(context).colorScheme.surface, textColor: Theme.of(context).colorScheme.tertiary, fillColor: Theme.of(context) .colorScheme .surface .withValues(alpha: 0.5), title: LocaleKeys.document_plugins_cover_changeCover.tr(), ), ), popupBuilder: (BuildContext popoverContext) { isPopoverOpen = true; return UploadImageMenu( limitMaximumImageSize: !_isLocalMode(), supportTypes: const [ UploadImageType.color, UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, ], onSelectedLocalImages: (files) { popoverController.close(); if (files.isEmpty) { return; } final item = files.map((file) => file.path).first; onCoverChanged(CoverType.file, item); }, onSelectedAIImage: (_) { throw UnimplementedError(); }, onSelectedNetworkImage: (url) { popoverController.close(); onCoverChanged(CoverType.file, url); }, onSelectedColor: (color) { popoverController.close(); onCoverChanged(CoverType.color, color); }, ); }, ), const HSpace(10), DeleteCoverButton( onTap: () => onCoverChanged(CoverType.none, null), ), ], ), ); } Future onCoverChanged(CoverType type, String? details) async { final previousType = CoverType.fromString( widget.node.attributes[DocumentHeaderBlockKeys.coverType], ); final previousDetails = widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; bool isFileType(CoverType type, String? details) => type == CoverType.file && details != null && !isURL(details); if (isFileType(type, details)) { if (_isLocalMode()) { details = await saveImageToLocalStorage(details!); } else { // else we should save the image to cloud storage (details, _) = await saveImageToCloudStorage(details!, widget.view.id); } } widget.onChangeCover(type, details); // After cover change,delete from localstorage if previous cover was image type if (isFileType(previousType, previousDetails) && _isLocalMode()) { await deleteImageFromLocalStorage(previousDetails); } } void setOverlayButtonsHidden(bool value) { if (isOverlayButtonsHidden == value) return; setState(() { isOverlayButtonsHidden = value; }); } bool _isLocalMode() { return context.read().isLocalMode; } } @visibleForTesting class DeleteCoverButton extends StatelessWidget { const DeleteCoverButton({required this.onTap, super.key}); final VoidCallback onTap; @override Widget build(BuildContext context) { final fillColor = UniversalPlatform.isDesktopOrWeb ? Theme.of(context).colorScheme.surface.withValues(alpha: 0.5) : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5); final svgColor = UniversalPlatform.isDesktopOrWeb ? Theme.of(context).colorScheme.tertiary : Theme.of(context).colorScheme.onPrimary; return FlowyIconButton( hoverColor: Theme.of(context).colorScheme.surface, fillColor: fillColor, iconPadding: const EdgeInsets.all(5), width: 28, icon: FlowySvg( FlowySvgs.delete_s, color: svgColor, ), onPressed: onTap, ); } } @visibleForTesting class DocumentIcon extends StatefulWidget { const DocumentIcon({ super.key, required this.node, required this.editorState, required this.icon, required this.onChangeIcon, this.documentId, }); final Node node; final EditorState editorState; final EmojiIconData icon; final String? documentId; final ValueChanged onChangeIcon; @override State createState() => _DocumentIconState(); } class _DocumentIconState extends State { final PopoverController _popoverController = PopoverController(); @override Widget build(BuildContext context) { Widget child = EmojiIconWidget(emoji: widget.icon); if (UniversalPlatform.isDesktopOrWeb) { child = AppFlowyPopover( direction: PopoverDirection.bottomWithCenterAligned, controller: _popoverController, offset: const Offset(0, 8), constraints: BoxConstraints.loose(const Size(360, 380)), margin: EdgeInsets.zero, child: child, popupBuilder: (BuildContext popoverContext) { return FlowyIconEmojiPicker( initialType: widget.icon.type.toPickerTabType(), tabs: const [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ], documentId: widget.documentId, onSelectedEmoji: (r) { widget.onChangeIcon(r.data); if (!r.keepOpen) _popoverController.close(); }, ); }, ); } else { child = GestureDetector( child: child, onTap: () async { final result = await context.push( Uri( path: MobileEmojiPickerScreen.routeName, queryParameters: { MobileEmojiPickerScreen.iconSelectedType: widget.icon.type.name, }, ).toString(), ); if (result != null) { widget.onChangeIcon(result); } }, ); } return child; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/appflowy_network_svg.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; import 'package:string_validator/string_validator.dart'; import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import '../../../../base/icon/icon_widget.dart'; class EmojiIconWidget extends StatefulWidget { const EmojiIconWidget({ super.key, required this.emoji, this.emojiSize = 60, }); final EmojiIconData emoji; final double emojiSize; @override State createState() => _EmojiIconWidgetState(); } class _EmojiIconWidgetState extends State { bool hover = true; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setHidden(false), onExit: (_) => setHidden(true), cursor: SystemMouseCursors.click, child: Container( decoration: BoxDecoration( color: !hover ? Theme.of(context) .colorScheme .inverseSurface .withValues(alpha: 0.5) : Colors.transparent, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: RawEmojiIconWidget( emoji: widget.emoji, emojiSize: widget.emojiSize, ), ), ); } void setHidden(bool value) { if (hover == value) return; setState(() { hover = value; }); } } class RawEmojiIconWidget extends StatefulWidget { const RawEmojiIconWidget({ super.key, required this.emoji, required this.emojiSize, this.enableColor = true, this.lineHeight, }); final EmojiIconData emoji; final double emojiSize; final bool enableColor; final double? lineHeight; @override State createState() => _RawEmojiIconWidgetState(); } class _RawEmojiIconWidgetState extends State { UserProfilePB? userProfile; EmojiIconData get emoji => widget.emoji; @override void initState() { super.initState(); loadUserProfile(); } @override void didUpdateWidget(RawEmojiIconWidget oldWidget) { super.didUpdateWidget(oldWidget); loadUserProfile(); } @override Widget build(BuildContext context) { final defaultEmoji = SizedBox( width: widget.emojiSize, child: EmojiText( emoji: '❓', fontSize: widget.emojiSize, textAlign: TextAlign.center, ), ); try { switch (widget.emoji.type) { case FlowyIconType.emoji: return FlowyText.emoji( widget.emoji.emoji, fontSize: widget.emojiSize, textAlign: TextAlign.justify, lineHeight: widget.lineHeight, ); case FlowyIconType.icon: IconsData iconData = IconsData.fromJson( jsonDecode(widget.emoji.emoji), ); if (!widget.enableColor) { iconData = iconData.noColor(); } final iconSize = widget.emojiSize; return IconWidget( iconsData: iconData, size: iconSize, ); case FlowyIconType.custom: final url = widget.emoji.emoji; final isSvg = url.endsWith('.svg'); final hasUserProfile = userProfile != null; if (isURL(url)) { Widget child = const SizedBox.shrink(); if (isSvg) { child = FlowyNetworkSvg( url, headers: hasUserProfile ? _buildRequestHeader(userProfile!) : {}, width: widget.emojiSize, height: widget.emojiSize, ); } else if (hasUserProfile) { child = FlowyNetworkImage( url: url, width: widget.emojiSize, height: widget.emojiSize, userProfilePB: userProfile, errorWidgetBuilder: (context, url, error) { return const SizedBox.shrink(); }, ); } return SizedBox.square( dimension: widget.emojiSize, child: child, ); } final imageFile = File(url); if (!imageFile.existsSync()) { throw PathNotFoundException(url, const OSError()); } return SizedBox.square( dimension: widget.emojiSize, child: isSvg ? SvgPicture.file( File(url), width: widget.emojiSize, height: widget.emojiSize, ) : Image.file( imageFile, fit: BoxFit.cover, width: widget.emojiSize, height: widget.emojiSize, ), ); } } catch (e) { Log.error("Display widget error: $e"); return defaultEmoji; } } Map _buildRequestHeader(UserProfilePB userProfilePB) { final header = {}; final token = userProfilePB.token; try { final decodedToken = jsonDecode(token); header['Authorization'] = 'Bearer ${decodedToken['access_token']}'; } catch (e) { Log.error('Unable to decode token: $e'); } return header; } Future loadUserProfile() async { if (userProfile != null) return; if (emoji.type == FlowyIconType.custom) { final userProfile = (await UserBackendService.getCurrentUserProfile()).fold( (userProfile) => userProfile, (l) => null, ); if (mounted) { setState(() { this.userProfile = userProfile; }); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; final _headingData = [ (FlowySvgs.h1_s, LocaleKeys.editor_heading1.tr()), (FlowySvgs.h2_s, LocaleKeys.editor_heading2.tr()), (FlowySvgs.h3_s, LocaleKeys.editor_heading3.tr()), ]; final headingsToolbarItem = ToolbarItem( id: 'editor.headings', group: 1, isActive: onlyShowInTextType, builder: (context, editorState, highlightColor, _, __) { final selection = editorState.selection!; final node = editorState.getNodeAtPath(selection.start.path)!; final delta = (node.delta ?? Delta()).toJson(); int level = node.attributes[HeadingBlockKeys.level] ?? 1; final originLevel = level; final isHighlight = node.type == HeadingBlockKeys.type && (level >= 1 && level <= 3); // only supports the level 1 - 3 in the toolbar, ignore the other levels level = level.clamp(1, 3); final svg = _headingData[level - 1].$1; final message = _headingData[level - 1].$2; final child = FlowyTooltip( message: message, preferBelow: false, child: Row( children: [ FlowySvg( svg, size: const Size.square(18), color: isHighlight ? highlightColor : Colors.white, ), const HSpace(2.0), const FlowySvg( FlowySvgs.arrow_down_s, size: Size.square(12), color: Colors.grey, ), ], ), ); return HeadingPopup( currentLevel: isHighlight ? level : -1, highlightColor: highlightColor, child: child, onLevelChanged: (newLevel) async { // same level means cancel the heading final type = newLevel == originLevel && node.type == HeadingBlockKeys.type ? ParagraphBlockKeys.type : HeadingBlockKeys.type; if (type == HeadingBlockKeys.type) { // from paragraph to heading final newNode = node.copyWith( type: type, attributes: { HeadingBlockKeys.level: newLevel, blockComponentBackgroundColor: node.attributes[blockComponentBackgroundColor], blockComponentTextDirection: node.attributes[blockComponentTextDirection], blockComponentDelta: delta, }, ); final children = node.children.map((child) => child.deepCopy()); final transaction = editorState.transaction; transaction.insertNodes( selection.start.path.next, [newNode, ...children], ); transaction.deleteNode(node); await editorState.apply(transaction); } else { // from heading to paragraph await editorState.formatNode( selection, (node) => node.copyWith( type: type, attributes: { HeadingBlockKeys.level: newLevel, blockComponentBackgroundColor: node.attributes[blockComponentBackgroundColor], blockComponentTextDirection: node.attributes[blockComponentTextDirection], blockComponentDelta: delta, }, ), ); } }, ); }, ); class HeadingPopup extends StatelessWidget { const HeadingPopup({ super.key, required this.currentLevel, required this.highlightColor, required this.onLevelChanged, required this.child, }); final int currentLevel; final Color highlightColor; final Function(int level) onLevelChanged; final Widget child; @override Widget build(BuildContext context) { return AppFlowyPopover( windowPadding: const EdgeInsets.all(0), margin: const EdgeInsets.symmetric(vertical: 2.0), direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 10), decorationColor: Theme.of(context).colorScheme.onTertiary, borderRadius: BorderRadius.circular(6.0), popupBuilder: (_) { keepEditorFocusNotifier.increase(); return _HeadingButtons( currentLevel: currentLevel, highlightColor: highlightColor, onLevelChanged: onLevelChanged, ); }, onClose: () { keepEditorFocusNotifier.decrease(); }, child: FlowyButton( useIntrinsicWidth: true, hoverColor: Colors.grey.withValues(alpha: 0.3), text: child, ), ); } } class _HeadingButtons extends StatelessWidget { const _HeadingButtons({ required this.highlightColor, required this.currentLevel, required this.onLevelChanged, }); final int currentLevel; final Color highlightColor; final Function(int level) onLevelChanged; @override Widget build(BuildContext context) { return SizedBox( height: 28, child: Row( mainAxisSize: MainAxisSize.min, children: [ const HSpace(4), ..._headingData.mapIndexed((index, data) { final svg = data.$1; final message = data.$2; return [ HeadingButton( icon: svg, tooltip: message, onTap: () => onLevelChanged(index + 1), isHighlight: index + 1 == currentLevel, highlightColor: highlightColor, ), index != _headingData.length - 1 ? const _Divider() : const SizedBox.shrink(), ]; }).flattened, const HSpace(4), ], ), ); } } class HeadingButton extends StatelessWidget { const HeadingButton({ super.key, required this.icon, required this.tooltip, required this.onTap, required this.highlightColor, required this.isHighlight, }); final Color highlightColor; final FlowySvgData icon; final String tooltip; final VoidCallback onTap; final bool isHighlight; @override Widget build(BuildContext context) { return FlowyButton( useIntrinsicWidth: true, hoverColor: Colors.grey.withValues(alpha: 0.3), onTap: onTap, text: FlowyTooltip( message: tooltip, preferBelow: true, child: FlowySvg( icon, size: const Size.square(18), color: isHighlight ? highlightColor : Colors.white, ), ), ); } } class _Divider extends StatelessWidget { const _Divider(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(4), child: Container( width: 1, color: Colors.grey, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; class EditorI18n extends AppFlowyEditorL10n { // static AppFlowyEditorLocalizations current = EditorI18n(); EditorI18n(); @override String get bold { return LocaleKeys.editor_bold.tr(); } /// `Bulleted List` @override String get bulletedList { return LocaleKeys.editor_bulletedList.tr(); } /// `Checkbox` @override String get checkbox { return LocaleKeys.editor_checkbox.tr(); } /// `Embed Code` @override String get embedCode { return LocaleKeys.editor_embedCode.tr(); } /// `H1` @override String get heading1 { return LocaleKeys.editor_heading1.tr(); } /// `H2` @override String get heading2 { return LocaleKeys.editor_heading2.tr(); } /// `H3` @override String get heading3 { return LocaleKeys.editor_heading3.tr(); } /// `Highlight` @override String get highlight { return LocaleKeys.editor_highlight.tr(); } /// `Color` @override String get color { return LocaleKeys.editor_color.tr(); } /// `Image` @override String get image { return LocaleKeys.editor_image.tr(); } /// `Italic` @override String get italic { return LocaleKeys.editor_italic.tr(); } /// `Link` @override String get link { return LocaleKeys.editor_link.tr(); } /// `Numbered List` @override String get numberedList { return LocaleKeys.editor_numberedList.tr(); } /// `Quote` @override String get quote { return LocaleKeys.editor_quote.tr(); } /// `Strikethrough` @override String get strikethrough { return LocaleKeys.editor_strikethrough.tr(); } /// `Text` @override String get text { return LocaleKeys.editor_text.tr(); } /// `Underline` @override String get underline { return LocaleKeys.editor_underline.tr(); } /// `Default` @override String get fontColorDefault { return LocaleKeys.editor_fontColorDefault.tr(); } /// `Gray` @override String get fontColorGray { return LocaleKeys.editor_fontColorGray.tr(); } /// `Brown` @override String get fontColorBrown { return LocaleKeys.editor_fontColorBrown.tr(); } /// `Orange` @override String get fontColorOrange { return LocaleKeys.editor_fontColorOrange.tr(); } /// `Yellow` @override String get fontColorYellow { return LocaleKeys.editor_fontColorYellow.tr(); } /// `Green` @override String get fontColorGreen { return LocaleKeys.editor_fontColorGreen.tr(); } /// `Blue` @override String get fontColorBlue { return LocaleKeys.editor_fontColorBlue.tr(); } /// `Purple` @override String get fontColorPurple { return LocaleKeys.editor_fontColorPurple.tr(); } /// `Pink` @override String get fontColorPink { return LocaleKeys.editor_fontColorPink.tr(); } /// `Red` @override String get fontColorRed { return LocaleKeys.editor_fontColorRed.tr(); } /// `Default background` @override String get backgroundColorDefault { return LocaleKeys.editor_backgroundColorDefault.tr(); } /// `Gray background` @override String get backgroundColorGray { return LocaleKeys.editor_backgroundColorGray.tr(); } /// `Brown background` @override String get backgroundColorBrown { return LocaleKeys.editor_backgroundColorBrown.tr(); } /// `Orange background` @override String get backgroundColorOrange { return LocaleKeys.editor_backgroundColorOrange.tr(); } /// `Yellow background` @override String get backgroundColorYellow { return LocaleKeys.editor_backgroundColorYellow.tr(); } /// `Green background` @override String get backgroundColorGreen { return LocaleKeys.editor_backgroundColorGreen.tr(); } /// `Blue background` @override String get backgroundColorBlue { return LocaleKeys.editor_backgroundColorBlue.tr(); } /// `Purple background` @override String get backgroundColorPurple { return LocaleKeys.editor_backgroundColorPurple.tr(); } /// `Pink background` @override String get backgroundColorPink { return LocaleKeys.editor_backgroundColorPink.tr(); } /// `Red background` @override String get backgroundColorRed { return LocaleKeys.editor_backgroundColorRed.tr(); } /// `Done` @override String get done { return LocaleKeys.editor_done.tr(); } /// `Cancel` @override String get cancel { return LocaleKeys.editor_cancel.tr(); } /// `Tint 1` @override String get tint1 { return LocaleKeys.editor_tint1.tr(); } /// `Tint 2` @override String get tint2 { return LocaleKeys.editor_tint2.tr(); } /// `Tint 3` @override String get tint3 { return LocaleKeys.editor_tint3.tr(); } /// `Tint 4` @override String get tint4 { return LocaleKeys.editor_tint4.tr(); } /// `Tint 5` @override String get tint5 { return LocaleKeys.editor_tint5.tr(); } /// `Tint 6` @override String get tint6 { return LocaleKeys.editor_tint6.tr(); } /// `Tint 7` @override String get tint7 { return LocaleKeys.editor_tint7.tr(); } /// `Tint 8` @override String get tint8 { return LocaleKeys.editor_tint8.tr(); } /// `Tint 9` @override String get tint9 { return LocaleKeys.editor_tint9.tr(); } /// `Purple` @override String get lightLightTint1 { return LocaleKeys.editor_lightLightTint1.tr(); } /// `Pink` @override String get lightLightTint2 { return LocaleKeys.editor_lightLightTint2.tr(); } /// `Light Pink` @override String get lightLightTint3 { return LocaleKeys.editor_lightLightTint3.tr(); } /// `Orange` @override String get lightLightTint4 { return LocaleKeys.editor_lightLightTint4.tr(); } /// `Yellow` @override String get lightLightTint5 { return LocaleKeys.editor_lightLightTint5.tr(); } /// `Lime` @override String get lightLightTint6 { return LocaleKeys.editor_lightLightTint6.tr(); } /// `Green` @override String get lightLightTint7 { return LocaleKeys.editor_lightLightTint7.tr(); } /// `Aqua` @override String get lightLightTint8 { return LocaleKeys.editor_lightLightTint8.tr(); } /// `Blue` @override String get lightLightTint9 { return LocaleKeys.editor_lightLightTint9.tr(); } /// `URL` @override String get urlHint { return LocaleKeys.editor_urlHint.tr(); } /// `Heading 1` @override String get mobileHeading1 { return LocaleKeys.editor_mobileHeading1.tr(); } /// `Heading 2` @override String get mobileHeading2 { return LocaleKeys.editor_mobileHeading2.tr(); } /// `Heading 3` @override String get mobileHeading3 { return LocaleKeys.editor_mobileHeading3.tr(); } /// `Text Color` @override String get textColor { return LocaleKeys.editor_textColor.tr(); } /// `Background Color` @override String get backgroundColor { return LocaleKeys.editor_backgroundColor.tr(); } /// `Add your link` @override String get addYourLink { return LocaleKeys.editor_addYourLink.tr(); } /// `Open link` @override String get openLink { return LocaleKeys.editor_openLink.tr(); } /// `Copy link` @override String get copyLink { return LocaleKeys.editor_copyLink.tr(); } /// `Remove link` @override String get removeLink { return LocaleKeys.editor_removeLink.tr(); } /// `Edit link` @override String get editLink { return LocaleKeys.editor_editLink.tr(); } /// `Text` @override String get linkText { return LocaleKeys.editor_linkText.tr(); } /// `Please enter text` @override String get linkTextHint { return LocaleKeys.editor_linkTextHint.tr(); } /// `Please enter URL` @override String get linkAddressHint { return LocaleKeys.editor_linkAddressHint.tr(); } /// `Highlight color` @override String get highlightColor { return LocaleKeys.editor_highlightColor.tr(); } /// `Clear highlight color` @override String get clearHighlightColor { return LocaleKeys.editor_clearHighlightColor.tr(); } /// `Custom color` @override String get customColor { return LocaleKeys.editor_customColor.tr(); } /// `Hex value` @override String get hexValue { return LocaleKeys.editor_hexValue.tr(); } /// `Opacity` @override String get opacity { return LocaleKeys.editor_opacity.tr(); } /// `Reset to default color` @override String get resetToDefaultColor { return LocaleKeys.editor_resetToDefaultColor.tr(); } /// `LTR` @override String get ltr { return LocaleKeys.editor_ltr.tr(); } /// `RTL` @override String get rtl { return LocaleKeys.editor_rtl.tr(); } /// `Auto` @override String get auto { return LocaleKeys.editor_auto.tr(); } /// `Cut` @override String get cut { return LocaleKeys.editor_cut.tr(); } /// `Copy` @override String get copy { return LocaleKeys.editor_copy.tr(); } /// `Paste` @override String get paste { return LocaleKeys.editor_paste.tr(); } /// `Find` @override String get find { return LocaleKeys.editor_find.tr(); } /// `Previous match` @override String get previousMatch { return LocaleKeys.editor_previousMatch.tr(); } /// `Next match` @override String get nextMatch { return LocaleKeys.editor_nextMatch.tr(); } /// `Close` @override String get closeFind { return LocaleKeys.editor_closeFind.tr(); } /// `Replace` @override String get replace { return LocaleKeys.editor_replace.tr(); } /// `Replace all` @override String get replaceAll { return LocaleKeys.editor_replaceAll.tr(); } /// `Regex` @override String get regex { return LocaleKeys.editor_regex.tr(); } /// `Case sensitive` @override String get caseSensitive { return LocaleKeys.editor_caseSensitive.tr(); } /// `Upload Image` @override String get uploadImage { return LocaleKeys.editor_uploadImage.tr(); } /// `URL Image` @override String get urlImage { return LocaleKeys.editor_urlImage.tr(); } /// `Incorrect Link` @override String get incorrectLink { return LocaleKeys.editor_incorrectLink.tr(); } /// `Upload` @override String get upload { return LocaleKeys.editor_upload.tr(); } /// `Choose an image` @override String get chooseImage { return LocaleKeys.editor_chooseImage.tr(); } /// `Loading` @override String get loading { return LocaleKeys.editor_loading.tr(); } /// `Could not load the image` @override String get imageLoadFailed { return LocaleKeys.editor_imageLoadFailed.tr(); } /// `Divider` @override String get divider { return LocaleKeys.editor_divider.tr(); } /// `Table` @override String get table { return LocaleKeys.editor_table.tr(); } /// `Add before` @override String get colAddBefore { return LocaleKeys.editor_colAddBefore.tr(); } /// `Add before` @override String get rowAddBefore { return LocaleKeys.editor_rowAddBefore.tr(); } /// `Add after` @override String get colAddAfter { return LocaleKeys.editor_colAddAfter.tr(); } /// `Add after` @override String get rowAddAfter { return LocaleKeys.editor_rowAddAfter.tr(); } /// `Remove` @override String get colRemove { return LocaleKeys.editor_colRemove.tr(); } /// `Remove` @override String get rowRemove { return LocaleKeys.editor_rowRemove.tr(); } /// `Duplicate` @override String get colDuplicate { return LocaleKeys.editor_colDuplicate.tr(); } /// `Duplicate` @override String get rowDuplicate { return LocaleKeys.editor_rowDuplicate.tr(); } /// `Clear Content` @override String get colClear { return LocaleKeys.editor_colClear.tr(); } /// `Clear Content` @override String get rowClear { return LocaleKeys.editor_rowClear.tr(); } /// `Enter a / to insert a block, or start typing` @override String get slashPlaceHolder { return LocaleKeys.editor_slashPlaceHolder.tr(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart ================================================ import 'dart:io'; import 'package:flutter/widgets.dart'; enum CustomImageType { local, internal, // the images saved in self-host cloud external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg static CustomImageType fromIntValue(int value) { switch (value) { case 0: return CustomImageType.local; case 1: return CustomImageType.internal; case 2: return CustomImageType.external; default: throw UnimplementedError(); } } int toIntValue() { switch (this) { case CustomImageType.local: return 0; case CustomImageType.internal: return 1; case CustomImageType.external: return 2; } } } class ImageBlockData { factory ImageBlockData.fromJson(Map json) { return ImageBlockData( url: json['url'] as String? ?? '', type: CustomImageType.fromIntValue(json['type'] as int), ); } ImageBlockData({required this.url, required this.type}); final String url; final CustomImageType type; bool get isLocal => type == CustomImageType.local; bool get isNotInternal => type != CustomImageType.internal; Map toJson() { return {'url': url, 'type': type.toIntValue()}; } ImageProvider toImageProvider() { switch (type) { case CustomImageType.internal: case CustomImageType.external: return NetworkImage(url); case CustomImageType.local: return FileImage(File(url)); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:saver_gallery/saver_gallery.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; import '../common.dart'; const kImagePlaceholderKey = 'imagePlaceholderKey'; class CustomImageBlockKeys { const CustomImageBlockKeys._(); static const String type = 'image'; /// The align data of a image block. /// /// The value is a String. /// left, center, right static const String align = 'align'; /// The image src of a image block. /// /// The value is a String. /// It can be a url or a base64 string(web). static const String url = 'url'; /// The height of a image block. /// /// The value is a double. static const String width = 'width'; /// The width of a image block. /// /// The value is a double. static const String height = 'height'; /// The image type of a image block. /// /// The value is a CustomImageType enum. static const String imageType = 'image_type'; } Node customImageNode({ required String url, String align = 'center', double? height, double? width, CustomImageType type = CustomImageType.local, }) { return Node( type: CustomImageBlockKeys.type, attributes: { CustomImageBlockKeys.url: url, CustomImageBlockKeys.align: align, CustomImageBlockKeys.height: height, CustomImageBlockKeys.width: width, CustomImageBlockKeys.imageType: type.toIntValue(), }, ); } typedef CustomImageBlockComponentMenuBuilder = Widget Function( Node node, CustomImageBlockComponentState state, ValueNotifier imageStateNotifier, ); class CustomImageBlockComponentBuilder extends BlockComponentBuilder { CustomImageBlockComponentBuilder({ super.configuration, this.showMenu = false, this.menuBuilder, }); /// Whether to show the menu of this block component. final bool showMenu; /// final CustomImageBlockComponentMenuBuilder? menuBuilder; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return CustomImageBlockComponent( key: node.key, node: node, showActions: showActions(node), configuration: configuration, actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), showMenu: showMenu, menuBuilder: menuBuilder, ); } @override BlockComponentValidate get validate => (node) => node.children.isEmpty; } class CustomImageBlockComponent extends BlockComponentStatefulWidget { const CustomImageBlockComponent({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), this.showMenu = false, this.menuBuilder, }); /// Whether to show the menu of this block component. final bool showMenu; final CustomImageBlockComponentMenuBuilder? menuBuilder; @override State createState() => CustomImageBlockComponentState(); } class CustomImageBlockComponentState extends State with SelectableMixin, BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; final imageKey = GlobalKey(); RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; late final editorState = Provider.of(context, listen: false); final showActionsNotifier = ValueNotifier(false); final imageStateNotifier = ValueNotifier(ResizableImageState.loading); bool alwaysShowMenu = false; @override Widget build(BuildContext context) { final node = widget.node; final attributes = node.attributes; final src = attributes[CustomImageBlockKeys.url]; final alignment = AlignmentExtension.fromString( attributes[CustomImageBlockKeys.align] ?? 'center', ); final width = attributes[CustomImageBlockKeys.width]?.toDouble() ?? MediaQuery.of(context).size.width; final height = attributes[CustomImageBlockKeys.height]?.toDouble(); final rawImageType = attributes[CustomImageBlockKeys.imageType] ?? 0; final imageType = CustomImageType.fromIntValue(rawImageType); final imagePlaceholderKey = node.extraInfos?[kImagePlaceholderKey]; Widget child; if (src.isEmpty) { child = ImagePlaceholder( key: imagePlaceholderKey is GlobalKey ? imagePlaceholderKey : null, node: node, ); } else if (imageType != CustomImageType.internal && !_checkIfURLIsValid(src)) { child = const UnsupportedImageWidget(); } else { child = ResizableImage( src: src, width: width, height: height, editable: editorState.editable, alignment: alignment, type: imageType, onStateChange: (state) => imageStateNotifier.value = state, onDoubleTap: () => showDialog( context: context, builder: (_) => InteractiveImageViewer( userProfile: context.read().state.userProfilePB, imageProvider: AFBlockImageProvider( images: [ImageBlockData(url: src, type: imageType)], onDeleteImage: (_) async { final transaction = editorState.transaction..deleteNode(node); await editorState.apply(transaction); }, ), ), ), onResize: (width) { final transaction = editorState.transaction ..updateNode(node, {CustomImageBlockKeys.width: width}); editorState.apply(transaction); }, ); } child = Padding( padding: padding, child: RepaintBoundary( key: imageKey, child: child, ), ); if (UniversalPlatform.isDesktopOrWeb) { child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, selectionAboveBlock: true, supportTypes: const [BlockSelectionType.block], child: child, ); } if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } // show a hover menu on desktop or web if (UniversalPlatform.isDesktopOrWeb) { if (widget.showMenu && widget.menuBuilder != null) { child = MouseRegion( onEnter: (_) => showActionsNotifier.value = true, onExit: (_) { if (!alwaysShowMenu) { showActionsNotifier.value = false; } }, hitTestBehavior: HitTestBehavior.opaque, opaque: false, child: ValueListenableBuilder( valueListenable: showActionsNotifier, builder: (_, value, child) { return Stack( children: [ editorState.editable ? BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, cursorColor: editorState.editorStyle.cursorColor, selectionColor: editorState.editorStyle.selectionColor, child: child!, ) : child!, if (value) widget.menuBuilder!(widget.node, this, imageStateNotifier), ], ); }, child: child, ), ); } } else { // show a fixed menu on mobile child = MobileBlockActionButtons( showThreeDots: false, node: node, editorState: editorState, extendActionWidgets: _buildExtendActionWidgets(context), child: child, ); } return child; } @override Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); @override Position getPositionInOffset(Offset start) => end(); @override bool get shouldCursorBlink => false; @override CursorStyle get cursorStyle => CursorStyle.cover; @override Rect getBlockRect({ bool shiftWithBaseOffset = false, }) { final imageBox = imageKey.currentContext?.findRenderObject(); if (imageBox is RenderBox) { return padding.topLeft & imageBox.size; } return Rect.zero; } @override Rect? getCursorRectInPosition( Position position, { bool shiftWithBaseOffset = false, }) { final rects = getRectsInSelection(Selection.collapsed(position)); return rects.firstOrNull; } @override List getRectsInSelection( Selection selection, { bool shiftWithBaseOffset = false, }) { if (_renderBox == null) { return []; } final parentBox = context.findRenderObject(); final imageBox = imageKey.currentContext?.findRenderObject(); if (parentBox is RenderBox && imageBox is RenderBox) { return [ imageBox.localToGlobal(Offset.zero, ancestor: parentBox) & imageBox.size, ]; } return [Offset.zero & _renderBox!.size]; } @override Selection getSelectionInRange(Offset start, Offset end) => Selection.single( path: widget.node.path, startOffset: 0, endOffset: 1, ); @override Offset localToGlobal( Offset offset, { bool shiftWithBaseOffset = false, }) => _renderBox!.localToGlobal(offset); // only used on mobile platform List _buildExtendActionWidgets(BuildContext context) { final String url = widget.node.attributes[CustomImageBlockKeys.url]; if (!_checkIfURLIsValid(url)) { return []; } return [ FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.editor_copy.tr(), leftIcon: const FlowySvg( FlowySvgs.m_field_copy_s, ), onTap: () async { context.pop(); showToastNotification( message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), ); await getIt().setPlainText(url); }, ), FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.document_imageBlock_saveImageToGallery.tr(), leftIcon: const FlowySvg( FlowySvgs.image_placeholder_s, size: Size.square(20), ), onTap: () async { context.pop(); // save the image to the photo library await _saveImageToGallery(url); }, ), ]; } bool _checkIfURLIsValid(dynamic url) { if (url is! String) { return false; } if (url.isEmpty) { return false; } if (!isURL(url) && !File(url).existsSync()) { return false; } return true; } Future _saveImageToGallery(String url) async { final permission = await PermissionChecker.checkPhotoPermission(context); if (!permission) { return; } final imageFile = await CustomImageCacheManager().getSingleFile(url); if (imageFile.existsSync()) { final result = await SaverGallery.saveImage( imageFile.readAsBytesSync(), fileName: imageFile.basename, skipIfExists: false, ); if (mounted) { showToastNotification( message: result.isSuccess ? LocaleKeys.document_imageBlock_successToAddImageToGallery.tr() : LocaleKeys.document_imageBlock_failedToAddImageToGallery.tr(), ); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart ================================================ import 'dart:ui'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; class ImageMenu extends StatefulWidget { const ImageMenu({ super.key, required this.node, required this.state, required this.imageStateNotifier, }); final Node node; final CustomImageBlockComponentState state; final ValueNotifier imageStateNotifier; @override State createState() => _ImageMenuState(); } class _ImageMenuState extends State { late final String? url = widget.node.attributes[CustomImageBlockKeys.url]; @override Widget build(BuildContext context) { final isPlaceholder = url == null || url!.isEmpty; final theme = Theme.of(context); return ValueListenableBuilder( valueListenable: widget.imageStateNotifier, builder: (_, state, child) { if (state == ResizableImageState.loading && !isPlaceholder) { return const SizedBox.shrink(); } return Container( height: 32, decoration: BoxDecoration( color: theme.cardColor, boxShadow: [ BoxShadow( blurRadius: 5, spreadRadius: 1, color: Colors.black.withValues(alpha: 0.1), ), ], borderRadius: BorderRadius.circular(4.0), ), child: Row( children: [ const HSpace(4), if (!isPlaceholder) ...[ MenuBlockButton( tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), iconData: FlowySvgs.full_view_s, onTap: openFullScreen, ), const HSpace(4), MenuBlockButton( tooltip: LocaleKeys.editor_copy.tr(), iconData: FlowySvgs.copy_s, onTap: copyImageLink, ), const HSpace(4), ], if (widget.state.editorState.editable) ...[ if (!isPlaceholder) ...[ _ImageAlignButton(node: widget.node, state: widget.state), const _Divider(), ], MenuBlockButton( tooltip: LocaleKeys.button_delete.tr(), iconData: FlowySvgs.trash_s, onTap: deleteImage, ), const HSpace(4), ], ], ), ); }, ); } Future copyImageLink() async { if (url != null) { // paste the image url and the image data final imageData = await captureImage(); try { // /image await getIt().setData( ClipboardServiceData( plainText: url!, image: ('png', imageData), ), ); if (mounted) { showToastNotification( message: LocaleKeys.message_copy_success.tr(), ); } } catch (e) { if (mounted) { showToastNotification( message: LocaleKeys.message_copy_fail.tr(), type: ToastificationType.error, ); } } } } Future deleteImage() async { final node = widget.node; final editorState = context.read(); final transaction = editorState.transaction; transaction.deleteNode(node); transaction.afterSelection = null; await editorState.apply(transaction); } void openFullScreen() { showDialog( context: context, builder: (_) => InteractiveImageViewer( userProfile: context.read()?.state.userProfile ?? context.read().state.userProfilePB, imageProvider: AFBlockImageProvider( images: [ ImageBlockData( url: url!, type: CustomImageType.fromIntValue( widget.node.attributes[CustomImageBlockKeys.imageType] ?? 2, ), ), ], onDeleteImage: widget.state.editorState.editable ? (_) async { final transaction = widget.state.editorState.transaction; transaction.deleteNode(widget.node); await widget.state.editorState.apply(transaction); } : null, ), ), ); } Future captureImage() async { final boundary = widget.state.imageKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; final image = await boundary?.toImage(); final byteData = await image?.toByteData(format: ImageByteFormat.png); if (byteData == null) { return Uint8List(0); } return byteData.buffer.asUint8List(); } } class _ImageAlignButton extends StatefulWidget { const _ImageAlignButton({required this.node, required this.state}); final Node node; final CustomImageBlockComponentState state; @override State<_ImageAlignButton> createState() => _ImageAlignButtonState(); } const _interceptorKey = 'image-align'; class _ImageAlignButtonState extends State<_ImageAlignButton> { final gestureInterceptor = SelectionGestureInterceptor( key: _interceptorKey, canTap: (details) => false, ); String get align => widget.node.attributes[CustomImageBlockKeys.align] ?? centerAlignmentKey; final popoverController = PopoverController(); late final EditorState editorState; @override void initState() { super.initState(); editorState = context.read(); } @override void dispose() { allowMenuClose(); super.dispose(); } @override Widget build(BuildContext context) { return IgnoreParentGestureWidget( child: AppFlowyPopover( onClose: allowMenuClose, controller: popoverController, windowPadding: const EdgeInsets.all(0), margin: const EdgeInsets.all(0), direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 10), child: MenuBlockButton( tooltip: LocaleKeys.document_plugins_optionAction_align.tr(), iconData: iconFor(align), ), popupBuilder: (_) { preventMenuClose(); return _AlignButtons(onAlignChanged: onAlignChanged); }, ), ); } void onAlignChanged(String align) { popoverController.close(); final transaction = editorState.transaction; transaction.updateNode(widget.node, {CustomImageBlockKeys.align: align}); editorState.apply(transaction); allowMenuClose(); } void preventMenuClose() { widget.state.alwaysShowMenu = true; editorState.service.selectionService.registerGestureInterceptor( gestureInterceptor, ); } void allowMenuClose() { widget.state.alwaysShowMenu = false; editorState.service.selectionService.unregisterGestureInterceptor( _interceptorKey, ); } FlowySvgData iconFor(String alignment) { switch (alignment) { case rightAlignmentKey: return FlowySvgs.align_right_s; case centerAlignmentKey: return FlowySvgs.align_center_s; case leftAlignmentKey: default: return FlowySvgs.align_left_s; } } } class _AlignButtons extends StatelessWidget { const _AlignButtons({required this.onAlignChanged}); final Function(String align) onAlignChanged; @override Widget build(BuildContext context) { return SizedBox( height: 32, child: Row( mainAxisSize: MainAxisSize.min, children: [ const HSpace(4), MenuBlockButton( tooltip: LocaleKeys.document_plugins_optionAction_left, iconData: FlowySvgs.align_left_s, onTap: () => onAlignChanged(leftAlignmentKey), ), const _Divider(), MenuBlockButton( tooltip: LocaleKeys.document_plugins_optionAction_center, iconData: FlowySvgs.align_center_s, onTap: () => onAlignChanged(centerAlignmentKey), ), const _Divider(), MenuBlockButton( tooltip: LocaleKeys.document_plugins_optionAction_right, iconData: FlowySvgs.align_right_s, onTap: () => onAlignChanged(rightAlignmentKey), ), const HSpace(4), ], ), ); } } class _Divider extends StatelessWidget { const _Divider(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(8), child: Container(width: 1, color: Colors.grey), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; class UnsupportedImageWidget extends StatelessWidget { const UnsupportedImageWidget({super.key}); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: FlowyHover( style: HoverStyle(borderRadius: BorderRadius.circular(4)), child: SizedBox( height: 52, child: Row( children: [ const HSpace(10), const FlowySvg( FlowySvgs.image_placeholder_s, size: Size.square(24), ), const HSpace(10), FlowyText(LocaleKeys.document_imageBlock_unableToLoadImage.tr()), ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; class MobileImagePickerScreen extends StatelessWidget { const MobileImagePickerScreen({super.key}); static const routeName = '/image_picker'; @override Widget build(BuildContext context) => const ImagePickerPage(); } class ImagePickerPage extends StatelessWidget { const ImagePickerPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( titleSpacing: 0, title: FlowyText.semibold( LocaleKeys.titleBar_pageIcon.tr(), fontSize: 14.0, ), leading: const AppBarBackButton(), ), body: SafeArea( child: UploadImageMenu( onSubmitted: (_) {}, onUpload: (_) {}, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart ================================================ import 'dart:io'; import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart'; import 'package:path/path.dart' as p; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; class ImagePlaceholder extends StatefulWidget { const ImagePlaceholder({super.key, required this.node}); final Node node; @override State createState() => ImagePlaceholderState(); } class ImagePlaceholderState extends State { final controller = PopoverController(); final documentService = DocumentService(); late final editorState = context.read(); late EditorDropManagerState? dropManagerState = UniversalPlatform.isMobile ? null : context.read(); bool get isDragEnabled => dropManagerState?.isDropEnabled == true || dropManagerState?.contains(CustomImageBlockKeys.type) == true; bool showLoading = false; String? errorMessage; bool isDraggingFiles = false; @override void didChangeDependencies() { if (UniversalPlatform.isMobile) { dropManagerState = context.read(); } super.didChangeDependencies(); } @override Widget build(BuildContext context) { final Widget child = DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), border: isDraggingFiles ? Border.all( color: Theme.of(context).colorScheme.primary, width: 2, ) : null, ), child: FlowyHover( style: HoverStyle( borderRadius: BorderRadius.circular(4), ), child: SizedBox( height: 52, child: Row( children: [ const HSpace(10), FlowySvg( FlowySvgs.slash_menu_icon_image_s, size: const Size.square(24), color: Theme.of(context).hintColor, ), const HSpace(10), ..._buildTrailing(context), ], ), ), ), ); if (UniversalPlatform.isDesktopOrWeb) { return AppFlowyPopover( controller: controller, direction: PopoverDirection.bottomWithCenterAligned, constraints: const BoxConstraints( maxWidth: 540, maxHeight: 360, minHeight: 80, ), clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (context) { return UploadImageMenu( allowMultipleImages: true, limitMaximumImageSize: !_isLocalMode(), supportTypes: const [ UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, ], onSelectedLocalImages: (files) { controller.close(); WidgetsBinding.instance.addPostFrameCallback((_) async { final List items = List.from( files .where((file) => file.path.isNotEmpty) .map((file) => file.path), ); if (items.isNotEmpty) { await insertMultipleLocalImages(items); } }); }, onSelectedAIImage: (url) { controller.close(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { await insertAIImage(url); }); }, onSelectedNetworkImage: (url) { controller.close(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { await insertNetworkImage(url); }); }, ); }, child: DropTarget( enable: isDragEnabled, onDragEntered: (_) { if (isDragEnabled) { setState(() => isDraggingFiles = true); } }, onDragExited: (_) { setState(() => isDraggingFiles = false); }, onDragDone: (details) { // Only accept files where the mimetype is an image, // otherwise we assume it's a file we cannot display. final imageFiles = details.files .where( (file) => file.mimeType?.startsWith('image/') ?? false || imgExtensionRegex.hasMatch(file.name), ) .toList(); final paths = imageFiles.map((file) => file.path).toList(); WidgetsBinding.instance.addPostFrameCallback( (_) async => insertMultipleLocalImages(paths), ); }, child: child, ), ); } else { return MobileBlockActionButtons( node: widget.node, editorState: editorState, child: GestureDetector( onTap: () { editorState.updateSelectionWithReason(null, extraInfo: {}); showUploadImageMenu(); }, child: child, ), ); } } List _buildTrailing(BuildContext context) { if (errorMessage != null) { return [ Flexible( child: FlowyText( '${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}', maxLines: 3, ), ), ]; } else if (showLoading) { return [ FlowyText( LocaleKeys.document_imageBlock_imageIsUploading.tr(), ), const HSpace(8), const CircularProgressIndicator.adaptive(), ]; } else { return [ Flexible( child: FlowyText( UniversalPlatform.isDesktop ? isDraggingFiles ? LocaleKeys.document_plugins_image_dropImageToInsert.tr() : LocaleKeys.document_plugins_image_addAnImageDesktop.tr() : LocaleKeys.document_plugins_image_addAnImageMobile.tr(), color: Theme.of(context).hintColor, ), ), ]; } } void showUploadImageMenu() { if (UniversalPlatform.isDesktopOrWeb) { controller.show(); } else { final isLocalMode = _isLocalMode(); showMobileBottomSheet( context, title: LocaleKeys.editor_image.tr(), showHeader: true, showCloseButton: true, showDragHandle: true, builder: (context) { return Container( margin: const EdgeInsets.only(top: 12.0), constraints: const BoxConstraints( maxHeight: 340, minHeight: 80, ), child: UploadImageMenu( limitMaximumImageSize: !isLocalMode, supportTypes: const [ UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, ], onSelectedLocalImages: (files) async { context.pop(); final items = files .where((file) => file.path.isNotEmpty) .map((file) => file.path) .toList(); await insertMultipleLocalImages(items); }, onSelectedAIImage: (url) async { context.pop(); await insertAIImage(url); }, onSelectedNetworkImage: (url) async { context.pop(); await insertNetworkImage(url); }, ), ); }, ); } } Future insertMultipleLocalImages(List urls) async { controller.close(); if (urls.isEmpty) { return; } setState(() { showLoading = true; errorMessage = null; }); bool hasError = false; if (_isLocalMode()) { final first = urls.removeAt(0); final firstPath = await saveImageToLocalStorage(first); final transaction = editorState.transaction; transaction.updateNode(widget.node, { CustomImageBlockKeys.url: firstPath, CustomImageBlockKeys.imageType: CustomImageType.local.toIntValue(), }); if (urls.isNotEmpty) { // Create new nodes for the rest of the images: final paths = await Future.wait(urls.map(saveImageToLocalStorage)); paths.removeWhere((url) => url == null || url.isEmpty); transaction.insertNodes( widget.node.path.next, paths.map((url) => customImageNode(url: url!)).toList(), ); } await editorState.apply(transaction); } else { final transaction = editorState.transaction; bool isFirst = true; for (final url in urls) { // Upload to cloud final (path, error) = await saveImageToCloudStorage( url, context.read().documentId, ); if (error != null) { hasError = true; if (isFirst) { setState(() => errorMessage = error); } continue; } if (path != null) { if (isFirst) { isFirst = false; transaction.updateNode(widget.node, { CustomImageBlockKeys.url: path, CustomImageBlockKeys.imageType: CustomImageType.internal.toIntValue(), }); } else { transaction.insertNode( widget.node.path.next, customImageNode( url: path, type: CustomImageType.internal, ), ); } } } await editorState.apply(transaction); } setState(() => showLoading = false); if (hasError && mounted) { showSnapBar( context, LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(), ); } } Future insertAIImage(String url) async { if (url.isEmpty || !isURL(url)) { // show error return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } final path = await getIt().getPath(); final imagePath = p.join(path, 'images'); try { // create the directory if not exists final directory = Directory(imagePath); if (!directory.existsSync()) { await directory.create(recursive: true); } final uri = Uri.parse(url); final copyToPath = p.join( imagePath, '${uuid()}${p.extension(uri.path)}', ); final response = await get(uri); await File(copyToPath).writeAsBytes(response.bodyBytes); await insertMultipleLocalImages([copyToPath]); await File(copyToPath).delete(); } catch (e) { Log.error('cannot save image file', e); } } Future insertNetworkImage(String url) async { if (url.isEmpty || !isURL(url)) { // show error return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } final transaction = editorState.transaction; transaction.updateNode(widget.node, { CustomImageBlockKeys.url: url, CustomImageBlockKeys.imageType: CustomImageType.external.toIntValue(), }); await editorState.apply(transaction); } bool _isLocalMode() { return context.read().isLocalMode; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; final customImageMenuItem = SelectionMenuItem( getName: () => AppFlowyEditorL10n.current.image, icon: (_, isSelected, style) => SelectionMenuIconWidget( name: 'image', isSelected: isSelected, style: style, ), keywords: ['image', 'picture', 'img', 'photo'], handler: (editorState, _, __) async { // use the key to retrieve the state of the image block to show the popover automatically final imagePlaceholderKey = GlobalKey(); await editorState.insertEmptyImageBlock(imagePlaceholderKey); WidgetsBinding.instance.addPostFrameCallback((_) { imagePlaceholderKey.currentState?.controller.show(); }); }, ); final multiImageMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_plugins_photoGallery_name.tr(), icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.image_s, size: const Size.square(16.0), isSelected: isSelected, style: style, ), keywords: [ LocaleKeys.document_plugins_photoGallery_imageKeyword.tr(), LocaleKeys.document_plugins_photoGallery_imageGalleryKeyword.tr(), LocaleKeys.document_plugins_photoGallery_photoKeyword.tr(), LocaleKeys.document_plugins_photoGallery_photoBrowserKeyword.tr(), LocaleKeys.document_plugins_photoGallery_galleryKeyword.tr(), ], handler: (editorState, _, __) async { final imagePlaceholderKey = GlobalKey(); await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey); WidgetsBinding.instance.addPostFrameCallback( (_) => imagePlaceholderKey.currentState?.controller.show(), ); }, ); extension InsertImage on EditorState { Future insertEmptyImageBlock(GlobalKey key) async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { return; } final path = selection.end.path; final node = getNodeAtPath(path); final delta = node?.delta; if (node == null || delta == null) { return; } final emptyImage = imageNode(url: '') ..extraInfos = {kImagePlaceholderKey: key}; final insertedPath = delta.isEmpty ? path : path.next; final transaction = this.transaction ..insertNode(insertedPath, emptyImage) ..insertNode(insertedPath, paragraphNode()) ..afterSelection = Selection.collapsed(Position(path: insertedPath.next)); return apply(transaction); } Future insertEmptyMultiImageBlock(GlobalKey key) async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { return; } final path = selection.end.path; final node = getNodeAtPath(path); final delta = node?.delta; if (node == null || delta == null) { return; } final emptyBlock = multiImageNode() ..extraInfos = {kMultiImagePlaceholderKey: key}; final insertedPath = delta.isEmpty ? path : path.next; final transaction = this.transaction ..insertNode(insertedPath, emptyBlock) ..insertNode(insertedPath, paragraphNode()) ..afterSelection = Selection.collapsed(Position(path: insertedPath.next)); return apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:path/path.dart' as p; Future saveImageToLocalStorage(String localImagePath) async { final path = await getIt().getPath(); final imagePath = p.join( path, 'images', ); try { // create the directory if not exists final directory = Directory(imagePath); if (!directory.existsSync()) { await directory.create(recursive: true); } final copyToPath = p.join( imagePath, '${uuid()}${p.extension(localImagePath)}', ); await File(localImagePath).copy( copyToPath, ); return copyToPath; } catch (e) { Log.error('cannot save image file', e); return null; } } Future<(String? path, String? errorMessage)> saveImageToCloudStorage( String localImagePath, String documentId, ) async { final documentService = DocumentService(); Log.debug("Uploading image local path: $localImagePath"); final result = await documentService.uploadFile( localFilePath: localImagePath, documentId: documentId, ); return result.fold( (s) async { await CustomImageCacheManager().putFile( s.url, File(localImagePath).readAsBytesSync(), ); return (s.url, null); }, (err) { final message = Platform.isIOS ? LocaleKeys.sideBar_storageLimitDialogTitleIOS.tr() : LocaleKeys.sideBar_storageLimitDialogTitle.tr(); if (err.isStorageLimitExceeded) { return (null, message); } else { return (null, err.msg); } }, ); } Future> extractAndUploadImages( BuildContext context, List urls, bool isLocalMode, ) async { final List images = []; bool hasError = false; for (final url in urls) { if (url == null || url.isEmpty) { continue; } String? path; String? errorMsg; CustomImageType imageType = CustomImageType.local; // If the user is using local authenticator, we save the image to local storage if (isLocalMode) { path = await saveImageToLocalStorage(url); } else { // Else we save the image to cloud storage (path, errorMsg) = await saveImageToCloudStorage( url, context.read().documentId, ); imageType = CustomImageType.internal; } if (path != null && errorMsg == null) { images.add(ImageBlockData(url: path, type: imageType)); } else { hasError = true; } } if (context.mounted && hasError) { showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(), ); } return images; } @visibleForTesting int deleteImageTestCounter = 0; Future deleteImageFromLocalStorage(String localImagePath) async { try { await File(localImagePath) .delete() .whenComplete(() => deleteImageTestCounter++); } catch (e) { Log.error('cannot delete image file', e); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; final imageMobileToolbarItem = MobileToolbarItem.action( itemIconBuilder: (_, __, ___) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg), actionHandler: (_, editorState) async { final imagePlaceholderKey = GlobalKey(); await editorState.insertEmptyImageBlock(imagePlaceholderKey); WidgetsBinding.instance.addPostFrameCallback((_) { imagePlaceholderKey.currentState?.showUploadImageMenu(); }); }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart ================================================ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flowy_infra/size.dart'; @visibleForTesting class ImageRender extends StatelessWidget { const ImageRender({ super.key, required this.image, this.userProfile, this.fit = BoxFit.cover, this.borderRadius = Corners.s6Border, }); final ImageBlockData image; final UserProfilePB? userProfile; final BoxFit fit; final BorderRadius? borderRadius; @override Widget build(BuildContext context) { final child = switch (image.type) { CustomImageType.internal || CustomImageType.external => FlowyNetworkImage( url: image.url, userProfilePB: userProfile, fit: fit, ), CustomImageType.local => Image.file(File(image.url), fit: fit), }; return Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration(borderRadius: borderRadius), child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart ================================================ import 'dart:io'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:collection/collection.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../image_render.dart'; const _thumbnailItemSize = 100.0, _imageHeight = 400.0; class ImageBrowserLayout extends ImageBlockMultiLayout { const ImageBrowserLayout({ super.key, required super.node, required super.editorState, required super.images, required super.indexNotifier, required super.isLocalMode, required this.onIndexChanged, }); final void Function(int) onIndexChanged; @override State createState() => _ImageBrowserLayoutState(); } class _ImageBrowserLayoutState extends State { UserProfilePB? _userProfile; bool isDraggingFiles = false; @override void initState() { super.initState(); _userProfile = context.read()?.state.userProfile ?? context.read().state.userProfilePB; } @override Widget build(BuildContext context) { final gallery = Stack( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: _imageHeight, width: MediaQuery.of(context).size.width, child: GestureDetector( onDoubleTap: () => _openInteractiveViewer(context), child: ImageRender( image: widget.images[widget.indexNotifier.value], userProfile: _userProfile, fit: BoxFit.contain, ), ), ), const VSpace(8), LayoutBuilder( builder: (context, constraints) { final maxItems = (constraints.maxWidth / (_thumbnailItemSize + 4)).floor(); final items = widget.images.take(maxItems).toList(); return Center( child: Wrap( children: items.mapIndexed((index, image) { final isLast = items.last == image; final amountLeft = widget.images.length - items.length; if (isLast && amountLeft > 0) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => _openInteractiveViewer( context, maxItems - 1, ), child: Container( width: _thumbnailItemSize, height: _thumbnailItemSize, padding: const EdgeInsets.all(2), margin: const EdgeInsets.all(2), decoration: BoxDecoration( borderRadius: Corners.s8Border, border: Border.all( width: 2, color: Theme.of(context).dividerColor, ), ), child: DecoratedBox( decoration: BoxDecoration( borderRadius: Corners.s6Border, image: image.type == CustomImageType.local ? DecorationImage( image: FileImage(File(image.url)), fit: BoxFit.cover, opacity: 0.5, ) : null, ), child: Stack( children: [ if (image.type != CustomImageType.local) Positioned.fill( child: Container( clipBehavior: Clip.antiAlias, decoration: const BoxDecoration( borderRadius: Corners.s6Border, ), child: FlowyNetworkImage( url: image.url, userProfilePB: _userProfile, ), ), ), DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.5), ), child: Center( child: FlowyText( '+$amountLeft', color: AFThemeExtension.of(context) .strongText, fontSize: 24, fontWeight: FontWeight.bold, ), ), ), ], ), ), ), ), ); } return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => widget.onIndexChanged(index), child: ThumbnailItem( images: widget.images, index: index, selectedIndex: widget.indexNotifier.value, userProfile: _userProfile, onDeleted: () async { final transaction = widget.editorState.transaction; final images = widget.images.toList(); images.removeAt(index); transaction.updateNode( widget.node, { MultiImageBlockKeys.images: images.map((e) => e.toJson()).toList(), MultiImageBlockKeys.layout: widget.node .attributes[MultiImageBlockKeys.layout], }, ); await widget.editorState.apply(transaction); widget.onIndexChanged( widget.indexNotifier.value > 0 ? widget.indexNotifier.value - 1 : 0, ); }, ), ), ); }).toList(), ), ); }, ), ], ), Positioned.fill( child: DropTarget( onDragEntered: (_) => setState(() => isDraggingFiles = true), onDragExited: (_) => setState(() => isDraggingFiles = false), onDragDone: (details) { setState(() => isDraggingFiles = false); // Only accept files where the mimetype is an image, // or the file extension is a known image format, // otherwise we assume it's a file we cannot display. final imageFiles = details.files .where( (file) => file.mimeType?.startsWith('image/') ?? false || imgExtensionRegex.hasMatch(file.name), ) .toList(); final paths = imageFiles.map((file) => file.path).toList(); WidgetsBinding.instance.addPostFrameCallback( (_) async => insertLocalImages(paths), ); }, child: !isDraggingFiles ? const SizedBox.shrink() : SizedBox.expand( child: DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.5), ), child: Center( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( FlowySvgs.download_s, size: Size.square(28), ), const HSpace(12), Flexible( child: FlowyText( LocaleKeys .document_plugins_image_dropImageToInsert .tr(), color: AFThemeExtension.of(context).strongText, fontSize: 22, fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, ), ), ], ), ), ), ), ), ), ], ); return SizedBox( height: _imageHeight + _thumbnailItemSize + 20, child: gallery, ); } void _openInteractiveViewer(BuildContext context, [int? index]) => showDialog( context: context, builder: (_) => InteractiveImageViewer( userProfile: _userProfile, imageProvider: AFBlockImageProvider( images: widget.images, initialIndex: index ?? widget.indexNotifier.value, onDeleteImage: (index) async { final transaction = widget.editorState.transaction; final newImages = widget.images.toList(); newImages.removeAt(index); widget.onIndexChanged( widget.indexNotifier.value > 0 ? widget.indexNotifier.value - 1 : 0, ); if (newImages.isNotEmpty) { transaction.updateNode( widget.node, { MultiImageBlockKeys.images: newImages.map((e) => e.toJson()).toList(), MultiImageBlockKeys.layout: widget.node.attributes[MultiImageBlockKeys.layout], }, ); } else { transaction.deleteNode(widget.node); } await widget.editorState.apply(transaction); }, ), ), ); Future insertLocalImages(List urls) async { if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) { return; } final isLocalMode = context.read().isLocalMode; final transaction = widget.editorState.transaction; final images = await extractAndUploadImages(context, urls, isLocalMode); if (images.isEmpty) { return; } final newImages = [...widget.images, ...images]; final imagesJson = newImages.map((image) => image.toJson()).toList(); transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, MultiImageBlockKeys.layout: widget.node.attributes[MultiImageBlockKeys.layout], }); await widget.editorState.apply(transaction); } } @visibleForTesting class ThumbnailItem extends StatefulWidget { const ThumbnailItem({ super.key, required this.images, required this.index, required this.selectedIndex, required this.onDeleted, this.userProfile, }); final List images; final int index; final int selectedIndex; final VoidCallback onDeleted; final UserProfilePB? userProfile; @override State createState() => _ThumbnailItemState(); } class _ThumbnailItemState extends State { bool isHovering = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => isHovering = true), onExit: (_) => setState(() => isHovering = false), child: Container( width: _thumbnailItemSize, height: _thumbnailItemSize, padding: const EdgeInsets.all(2), margin: const EdgeInsets.all(2), decoration: BoxDecoration( borderRadius: Corners.s8Border, border: Border.all( width: 2, color: widget.index == widget.selectedIndex ? Theme.of(context).colorScheme.primary : Theme.of(context).dividerColor, ), ), child: Stack( children: [ Positioned.fill( child: ImageRender( image: widget.images[widget.index], userProfile: widget.userProfile, ), ), Positioned( top: 4, right: 4, child: AnimatedOpacity( opacity: isHovering ? 1 : 0, duration: const Duration(milliseconds: 100), child: FlowyTooltip( message: LocaleKeys.button_delete.tr(), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.onDeleted, child: FlowyHover( resetHoverOnRebuild: false, style: HoverStyle( backgroundColor: Colors.black.withValues(alpha: 0.6), hoverColor: Colors.black.withValues(alpha: 0.9), ), child: const Padding( padding: EdgeInsets.all(4), child: FlowySvg( FlowySvgs.delete_s, color: Colors.white, ), ), ), ), ), ), ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:collection/collection.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:provider/provider.dart'; class ImageGridLayout extends ImageBlockMultiLayout { const ImageGridLayout({ super.key, required super.node, required super.editorState, required super.images, required super.indexNotifier, required super.isLocalMode, }); @override State createState() => _ImageGridLayoutState(); } class _ImageGridLayoutState extends State { @override Widget build(BuildContext context) { return StaggeredGridBuilder( images: widget.images, onImageDoubleTapped: (index) { _openInteractiveViewer(context, index); }, ); } void _openInteractiveViewer(BuildContext context, int index) => showDialog( context: context, builder: (_) => InteractiveImageViewer( userProfile: context.read().state.userProfilePB, imageProvider: AFBlockImageProvider( images: widget.images, initialIndex: index, onDeleteImage: (index) async { final transaction = widget.editorState.transaction; final newImages = widget.images.toList(); newImages.removeAt(index); if (newImages.isNotEmpty) { transaction.updateNode( widget.node, { MultiImageBlockKeys.images: newImages.map((e) => e.toJson()).toList(), MultiImageBlockKeys.layout: widget.node.attributes[MultiImageBlockKeys.layout], }, ); } else { transaction.deleteNode(widget.node); } await widget.editorState.apply(transaction); }, ), ), ); } /// Draws a staggered grid of images, where the pattern is based /// on the amount of images to fill the grid at all times. /// /// They will be alternating depending on the current index of the images, such that /// the layout is reversed in odd segments. /// /// If there are 4 images in the last segment, this layout will be used: /// ┌─────┐┌─┐┌─┐ /// │ │└─┘└─┘ /// │ │┌────┐ /// └─────┘└────┘ /// /// If there are 3 images in the last segment, this layout will be used: /// ┌─────┐┌────┐ /// │ │└────┘ /// │ │┌────┐ /// └─────┘└────┘ /// /// If there are 2 images in the last segment, this layout will be used: /// ┌─────┐┌─────┐ /// │ ││ │ /// └─────┘└─────┘ /// /// If there is 1 image in the last segment, this layout will be used: /// ┌──────────┐ /// │ │ /// └──────────┘ class StaggeredGridBuilder extends StatefulWidget { const StaggeredGridBuilder({ super.key, required this.images, required this.onImageDoubleTapped, }); final List images; final void Function(int) onImageDoubleTapped; @override State createState() => _StaggeredGridBuilderState(); } class _StaggeredGridBuilderState extends State { late final UserProfilePB? _userProfile; final List> _splitImages = []; @override void initState() { super.initState(); _userProfile = context.read().state.userProfilePB; for (int i = 0; i < widget.images.length; i += 4) { final end = (i + 4 < widget.images.length) ? i + 4 : widget.images.length; _splitImages.add(widget.images.sublist(i, end)); } } @override void didUpdateWidget(covariant StaggeredGridBuilder oldWidget) { if (widget.images.length != oldWidget.images.length) { _splitImages.clear(); for (int i = 0; i < widget.images.length; i += 4) { final end = (i + 4 < widget.images.length) ? i + 4 : widget.images.length; _splitImages.add(widget.images.sublist(i, end)); } } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { return StaggeredGrid.count( crossAxisCount: 4, mainAxisSpacing: 6, crossAxisSpacing: 6, children: _splitImages.indexed.map(_buildTilesForImages).flattened.toList(), ); } List _buildTilesForImages((int, List) data) { final index = data.$1; final images = data.$2; final isReversed = index.isOdd; if (images.length == 4) { return [ StaggeredGridTile.count( crossAxisCellCount: isReversed ? 1 : 2, mainAxisCellCount: isReversed ? 1 : 2, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[0], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), StaggeredGridTile.count( crossAxisCellCount: 1, mainAxisCellCount: 1, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4 + 1; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[1], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), StaggeredGridTile.count( crossAxisCellCount: isReversed ? 2 : 1, mainAxisCellCount: isReversed ? 2 : 1, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4 + 2; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[2], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), StaggeredGridTile.count( crossAxisCellCount: 2, mainAxisCellCount: 1, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4 + 3; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[3], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), ]; } else if (images.length == 3) { return [ StaggeredGridTile.count( crossAxisCellCount: 2, mainAxisCellCount: isReversed ? 1 : 2, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[0], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), StaggeredGridTile.count( crossAxisCellCount: 2, mainAxisCellCount: isReversed ? 2 : 1, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4 + 1; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[1], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), StaggeredGridTile.count( crossAxisCellCount: 2, mainAxisCellCount: 1, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4 + 2; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[2], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), ]; } else if (images.length == 2) { return [ StaggeredGridTile.count( crossAxisCellCount: 2, mainAxisCellCount: 2, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[0], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), StaggeredGridTile.count( crossAxisCellCount: 2, mainAxisCellCount: 2, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4 + 1; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[1], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), ]; } else { return [ StaggeredGridTile.count( crossAxisCellCount: 4, mainAxisCellCount: 2, child: GestureDetector( onDoubleTap: () { final imageIndex = index * 4; widget.onImageDoubleTapped(imageIndex); }, child: ImageRender( image: images[0], userProfile: _userProfile, borderRadius: BorderRadius.zero, ), ), ), ]; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; import 'package:flutter/material.dart'; abstract class ImageBlockMultiLayout extends StatefulWidget { const ImageBlockMultiLayout({ super.key, required this.node, required this.editorState, required this.images, required this.indexNotifier, required this.isLocalMode, }); final Node node; final EditorState editorState; final List images; final ValueNotifier indexNotifier; final bool isLocalMode; } class ImageLayoutRender extends StatelessWidget { const ImageLayoutRender({ super.key, required this.node, required this.editorState, required this.images, required this.indexNotifier, required this.isLocalMode, required this.onIndexChanged, }); final Node node; final EditorState editorState; final List images; final ValueNotifier indexNotifier; final bool isLocalMode; final void Function(int) onIndexChanged; @override Widget build(BuildContext context) { final layout = _getLayout(); return _buildLayout(layout); } MultiImageLayout _getLayout() { return MultiImageLayout.fromIntValue( node.attributes[MultiImageBlockKeys.layout] ?? 0, ); } Widget _buildLayout(MultiImageLayout layout) { switch (layout) { case MultiImageLayout.grid: return ImageGridLayout( node: node, editorState: editorState, images: images, indexNotifier: indexNotifier, isLocalMode: isLocalMode, ); case MultiImageLayout.browser: return ImageBrowserLayout( node: node, editorState: editorState, images: images, indexNotifier: indexNotifier, isLocalMode: isLocalMode, onIndexChanged: onIndexChanged, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey'; Node multiImageNode({List? images}) => Node( type: MultiImageBlockKeys.type, attributes: { MultiImageBlockKeys.images: MultiImageData(images: images ?? []).toJson(), MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), }, ); class MultiImageBlockKeys { const MultiImageBlockKeys._(); static const String type = 'multi_image'; /// The image data for the block, stored as a JSON encoded list of [ImageBlockData]. /// static const String images = 'images'; /// The layout of the images. /// /// The value is a MultiImageLayout enum. /// static const String layout = 'layout'; } typedef MultiImageBlockComponentMenuBuilder = Widget Function( Node node, MultiImageBlockComponentState state, ValueNotifier indexNotifier, VoidCallback onImageDeleted, ); class MultiImageBlockComponentBuilder extends BlockComponentBuilder { MultiImageBlockComponentBuilder({ super.configuration, this.showMenu = false, this.menuBuilder, }); final bool showMenu; final MultiImageBlockComponentMenuBuilder? menuBuilder; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return MultiImageBlockComponent( key: node.key, node: node, showActions: showActions(node), configuration: configuration, actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), showMenu: showMenu, menuBuilder: menuBuilder, ); } @override BlockComponentValidate get validate => (node) => node.children.isEmpty; } class MultiImageBlockComponent extends BlockComponentStatefulWidget { const MultiImageBlockComponent({ super.key, required super.node, super.showActions, this.showMenu = false, this.menuBuilder, super.configuration = const BlockComponentConfiguration(), super.actionBuilder, super.actionTrailingBuilder, }); final bool showMenu; final MultiImageBlockComponentMenuBuilder? menuBuilder; @override State createState() => MultiImageBlockComponentState(); } class MultiImageBlockComponentState extends State with SelectableMixin, BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; final multiImageKey = GlobalKey(); RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; late final editorState = Provider.of(context, listen: false); final showActionsNotifier = ValueNotifier(false); ValueNotifier indexNotifier = ValueNotifier(0); bool alwaysShowMenu = false; static const _interceptorKey = 'multi-image-block-interceptor'; late final interceptor = SelectionGestureInterceptor( key: _interceptorKey, canTap: (details) => _isTapInBounds(details.globalPosition), canPanStart: (details) => _isTapInBounds(details.globalPosition), ); @override void initState() { super.initState(); editorState.selectionService.registerGestureInterceptor(interceptor); } @override void dispose() { editorState.selectionService.unregisterGestureInterceptor(_interceptorKey); super.dispose(); } bool _isTapInBounds(Offset offset) { if (_renderBox == null) { // We shouldn't block any actions if the render box is not available. // This has the potential to break taps on the editor completely if we // accidentally return false here. return true; } final localPosition = _renderBox!.globalToLocal(offset); return !_renderBox!.paintBounds.contains(localPosition); } @override Widget build(BuildContext context) { final data = MultiImageData.fromJson( node.attributes[MultiImageBlockKeys.images], ); Widget child; if (data.images.isEmpty) { final multiImagePlaceholderKey = node.extraInfos?[kMultiImagePlaceholderKey]; child = MultiImagePlaceholder( key: multiImagePlaceholderKey is GlobalKey ? multiImagePlaceholderKey : null, node: node, ); } else { child = ImageLayoutRender( node: node, images: data.images, editorState: editorState, indexNotifier: indexNotifier, isLocalMode: context.read().isLocalMode, onIndexChanged: (index) => setState(() => indexNotifier.value = index), ); } if (UniversalPlatform.isDesktopOrWeb) { child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, supportTypes: const [BlockSelectionType.block], child: Padding(key: multiImageKey, padding: padding, child: child), ); } else { child = Padding(key: multiImageKey, padding: padding, child: child); } if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } if (UniversalPlatform.isDesktopOrWeb) { if (widget.showMenu && widget.menuBuilder != null) { child = MouseRegion( onEnter: (_) => showActionsNotifier.value = true, onExit: (_) { if (!alwaysShowMenu) { showActionsNotifier.value = false; } }, hitTestBehavior: HitTestBehavior.opaque, opaque: false, child: ValueListenableBuilder( valueListenable: showActionsNotifier, builder: (context, value, child) { return Stack( children: [ BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, cursorColor: editorState.editorStyle.cursorColor, selectionColor: editorState.editorStyle.selectionColor, child: child!, ), if (value && data.images.isNotEmpty) widget.menuBuilder!( widget.node, this, indexNotifier, () => setState( () => indexNotifier.value = indexNotifier.value > 0 ? indexNotifier.value - 1 : 0, ), ), ], ); }, child: child, ), ); } } else { // show a fixed menu on mobile child = MobileBlockActionButtons( showThreeDots: false, node: node, editorState: editorState, child: child, ); } return child; } @override Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); @override Position getPositionInOffset(Offset start) => end(); @override bool get shouldCursorBlink => false; @override CursorStyle get cursorStyle => CursorStyle.cover; @override Rect getBlockRect({ bool shiftWithBaseOffset = false, }) { final imageBox = multiImageKey.currentContext?.findRenderObject(); if (imageBox is RenderBox) { return Offset.zero & imageBox.size; } return Rect.zero; } @override Rect? getCursorRectInPosition( Position position, { bool shiftWithBaseOffset = false, }) { final rects = getRectsInSelection(Selection.collapsed(position)); return rects.firstOrNull; } @override List getRectsInSelection( Selection selection, { bool shiftWithBaseOffset = false, }) { if (_renderBox == null) { return []; } final parentBox = context.findRenderObject(); final imageBox = multiImageKey.currentContext?.findRenderObject(); if (parentBox is RenderBox && imageBox is RenderBox) { return [ imageBox.localToGlobal(Offset.zero, ancestor: parentBox) & imageBox.size, ]; } return [Offset.zero & _renderBox!.size]; } @override Selection getSelectionInRange(Offset start, Offset end) => Selection.single( path: widget.node.path, startOffset: 0, endOffset: 1, ); @override Offset localToGlobal( Offset offset, { bool shiftWithBaseOffset = false, }) => _renderBox!.localToGlobal(offset); } /// The data for a multi-image block, primarily used for /// serializing and deserializing the block's images. /// class MultiImageData { factory MultiImageData.fromJson(List json) { final images = json .map((e) => ImageBlockData.fromJson(e as Map)) .toList(); return MultiImageData(images: images); } MultiImageData({required this.images}); final List images; List toJson() => images.map((e) => e.toJson()).toList(); } enum MultiImageLayout { browser, grid; int toIntValue() { switch (this) { case MultiImageLayout.browser: return 0; case MultiImageLayout.grid: return 1; } } static MultiImageLayout fromIntValue(int value) { switch (value) { case 0: return MultiImageLayout.browser; case 1: return MultiImageLayout.grid; default: throw UnimplementedError(); } } String get label => switch (this) { browser => LocaleKeys.document_plugins_photoGallery_browserLayout.tr(), grid => LocaleKeys.document_plugins_photoGallery_gridLayout.tr(), }; FlowySvgData get icon => switch (this) { browser => FlowySvgs.photo_layout_browser_s, grid => FlowySvgs.photo_layout_grid_s, }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:string_validator/string_validator.dart'; const _interceptorKey = 'add-image'; class MultiImageMenu extends StatefulWidget { const MultiImageMenu({ super.key, required this.node, required this.state, required this.indexNotifier, this.isLocalMode = true, required this.onImageDeleted, }); final Node node; final MultiImageBlockComponentState state; final ValueNotifier indexNotifier; final bool isLocalMode; final VoidCallback onImageDeleted; @override State createState() => _MultiImageMenuState(); } class _MultiImageMenuState extends State { final gestureInterceptor = SelectionGestureInterceptor( key: _interceptorKey, canTap: (details) => false, ); final PopoverController controller = PopoverController(); final PopoverController layoutController = PopoverController(); late List images; late final EditorState editorState; @override void initState() { super.initState(); editorState = context.read(); images = MultiImageData.fromJson( widget.node.attributes[MultiImageBlockKeys.images] ?? {}, ).images; } @override void dispose() { allowMenuClose(); controller.close(); layoutController.close(); super.dispose(); } @override void didUpdateWidget(covariant MultiImageMenu oldWidget) { images = MultiImageData.fromJson( widget.node.attributes[MultiImageBlockKeys.images] ?? {}, ).images; super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final layout = MultiImageLayout.fromIntValue( widget.node.attributes[MultiImageBlockKeys.layout] ?? 0, ); return Container( height: 32, decoration: BoxDecoration( color: theme.cardColor, boxShadow: [ BoxShadow( blurRadius: 5, spreadRadius: 1, color: Colors.black.withValues(alpha: 0.1), ), ], borderRadius: BorderRadius.circular(4.0), ), child: Row( children: [ const HSpace(4), AppFlowyPopover( controller: controller, direction: PopoverDirection.bottomWithRightAligned, onClose: allowMenuClose, constraints: const BoxConstraints( maxWidth: 540, maxHeight: 360, minHeight: 80, ), offset: const Offset(0, 10), popupBuilder: (context) { preventMenuClose(); return UploadImageMenu( allowMultipleImages: true, supportTypes: const [ UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, ], onSelectedLocalImages: insertLocalImages, onSelectedAIImage: insertAIImage, onSelectedNetworkImage: insertNetworkImage, ); }, child: MenuBlockButton( tooltip: LocaleKeys.document_plugins_photoGallery_addImageTooltip.tr(), iconData: FlowySvgs.add_s, onTap: () {}, ), ), const HSpace(4), AppFlowyPopover( controller: layoutController, onClose: allowMenuClose, direction: PopoverDirection.bottomWithRightAligned, offset: const Offset(0, 10), constraints: const BoxConstraints( maxHeight: 300, maxWidth: 300, ), popupBuilder: (context) { preventMenuClose(); return Column( mainAxisSize: MainAxisSize.min, children: [ _LayoutSelector( selectedLayout: layout, onSelected: (layout) { allowMenuClose(); layoutController.close(); final transaction = editorState.transaction; transaction.updateNode(widget.node, { MultiImageBlockKeys.images: widget.node.attributes[MultiImageBlockKeys.images], MultiImageBlockKeys.layout: layout.toIntValue(), }); editorState.apply(transaction); }, ), ], ); }, child: MenuBlockButton( tooltip: LocaleKeys .document_plugins_photoGallery_changeLayoutTooltip .tr(), iconData: FlowySvgs.edit_layout_s, onTap: () {}, ), ), const HSpace(4), MenuBlockButton( tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), iconData: FlowySvgs.full_view_s, onTap: openFullScreen, ), // disable the copy link button if the image is hosted on appflowy cloud // because the url needs the verification token to be accessible if (layout == MultiImageLayout.browser && !images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[ const HSpace(4), MenuBlockButton( tooltip: LocaleKeys.editor_copyLink.tr(), iconData: FlowySvgs.copy_s, onTap: copyImageLink, ), ], const _Divider(), MenuBlockButton( tooltip: LocaleKeys.document_plugins_photoGallery_deleteBlockTooltip .tr(), iconData: FlowySvgs.delete_s, onTap: deleteImage, ), const HSpace(4), ], ), ); } void copyImageLink() { Clipboard.setData( ClipboardData(text: images[widget.indexNotifier.value].url), ); showToastNotification( message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), ); } Future deleteImage() async { final node = widget.node; final editorState = context.read(); final transaction = editorState.transaction; transaction.deleteNode(node); transaction.afterSelection = null; await editorState.apply(transaction); } void openFullScreen() { showDialog( context: context, builder: (_) => InteractiveImageViewer( userProfile: context.read().state.userProfilePB, imageProvider: AFBlockImageProvider( images: images, initialIndex: widget.indexNotifier.value, onDeleteImage: (index) async { final transaction = editorState.transaction; final newImages = List.from(images); newImages.removeAt(index); images = newImages; widget.onImageDeleted(); final imagesJson = newImages.map((image) => image.toJson()).toList(); transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, MultiImageBlockKeys.layout: widget.node.attributes[MultiImageBlockKeys.layout], }); await editorState.apply(transaction); }, ), ), ); } void preventMenuClose() { widget.state.alwaysShowMenu = true; editorState.service.selectionService.registerGestureInterceptor( gestureInterceptor, ); } void allowMenuClose() { widget.state.alwaysShowMenu = false; editorState.service.selectionService.unregisterGestureInterceptor( _interceptorKey, ); } Future insertLocalImages(List files) async { controller.close(); WidgetsBinding.instance.addPostFrameCallback((_) async { final urls = files .map((file) => file.path) .where((path) => path.isNotEmpty) .toList(); if (urls.isEmpty || urls.every((url) => url.isEmpty)) { return; } final transaction = editorState.transaction; final newImages = await extractAndUploadImages(context, urls, widget.isLocalMode); if (newImages.isEmpty) { return; } final imagesJson = [...images, ...newImages].map((i) => i.toJson()).toList(); transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, MultiImageBlockKeys.layout: widget.node.attributes[MultiImageBlockKeys.layout], }); await editorState.apply(transaction); setState(() => images = newImages); }); } Future insertAIImage(String url) async { controller.close(); if (url.isEmpty || !isURL(url)) { // show error return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } final path = await getIt().getPath(); final imagePath = p.join(path, 'images'); try { // create the directory if not exists final directory = Directory(imagePath); if (!directory.existsSync()) { await directory.create(recursive: true); } final uri = Uri.parse(url); final copyToPath = p.join( imagePath, '${uuid()}${p.extension(uri.path)}', ); final response = await get(uri); await File(copyToPath).writeAsBytes(response.bodyBytes); await insertLocalImages([XFile(copyToPath)]); await File(copyToPath).delete(); } catch (e) { Log.error('cannot save image file', e); } } Future insertNetworkImage(String url) async { controller.close(); if (url.isEmpty || !isURL(url)) { // show error return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } final transaction = editorState.transaction; final newImages = [ ...images, ImageBlockData(url: url, type: CustomImageType.external), ]; final imagesJson = newImages.map((image) => image.toJson()).toList(); transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, MultiImageBlockKeys.layout: widget.node.attributes[MultiImageBlockKeys.layout], }); await editorState.apply(transaction); setState(() => images = newImages); } } class _Divider extends StatelessWidget { const _Divider(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(8), child: Container(width: 1, color: Colors.grey), ); } } class _LayoutSelector extends StatelessWidget { const _LayoutSelector({ required this.selectedLayout, required this.onSelected, }); final MultiImageLayout selectedLayout; final Function(MultiImageLayout) onSelected; @override Widget build(BuildContext context) { return SeparatedRow( separatorBuilder: () => const HSpace(6), mainAxisSize: MainAxisSize.min, children: MultiImageLayout.values .map( (layout) => MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => onSelected(layout), child: Container( height: 80, width: 80, padding: const EdgeInsets.all(8), decoration: BoxDecoration( border: Border.all( width: 2, color: selectedLayout == layout ? Theme.of(context).colorScheme.primary : Theme.of(context).dividerColor, ), borderRadius: Corners.s8Border, ), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ FlowySvg( layout.icon, color: AFThemeExtension.of(context).strongText, size: const Size.square(24), ), const VSpace(6), FlowyText(layout.label), ], ), ), ), ), ) .toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; class MultiImagePlaceholder extends StatefulWidget { const MultiImagePlaceholder({super.key, required this.node}); final Node node; @override State createState() => MultiImagePlaceholderState(); } class MultiImagePlaceholderState extends State { final controller = PopoverController(); final documentService = DocumentService(); late final editorState = context.read(); bool isDraggingFiles = false; @override Widget build(BuildContext context) { final child = DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), border: isDraggingFiles ? Border.all( color: Theme.of(context).colorScheme.primary, width: 2, ) : null, ), child: FlowyHover( style: HoverStyle( borderRadius: BorderRadius.circular(4), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), child: Row( children: [ FlowySvg( FlowySvgs.slash_menu_icon_photo_gallery_s, color: Theme.of(context).hintColor, size: const Size.square(24), ), const HSpace(10), FlowyText( UniversalPlatform.isDesktop ? isDraggingFiles ? LocaleKeys.document_plugins_image_dropImageToInsert .tr() : LocaleKeys.document_plugins_image_addAnImageDesktop .tr() : LocaleKeys.document_plugins_image_addAnImageMobile.tr(), color: Theme.of(context).hintColor, ), ], ), ), ), ); if (UniversalPlatform.isDesktopOrWeb) { return AppFlowyPopover( controller: controller, direction: PopoverDirection.bottomWithCenterAligned, constraints: const BoxConstraints( maxWidth: 540, maxHeight: 360, minHeight: 80, ), clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (_) { return UploadImageMenu( allowMultipleImages: true, limitMaximumImageSize: !_isLocalMode(), supportTypes: const [ UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, ], onSelectedLocalImages: (files) { controller.close(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { final paths = files.map((file) => file.path).toList(); await insertLocalImages(paths); }); }, onSelectedAIImage: (url) { controller.close(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { await insertAIImage(url); }); }, onSelectedNetworkImage: (url) { controller.close(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { await insertNetworkImage(url); }); }, ); }, child: DropTarget( onDragEntered: (_) => setState(() => isDraggingFiles = true), onDragExited: (_) => setState(() => isDraggingFiles = false), onDragDone: (details) { // Only accept files where the mimetype is an image, // or the file extension is a known image format, // otherwise we assume it's a file we cannot display. final imageFiles = details.files .where( (file) => file.mimeType?.startsWith('image/') ?? false || imgExtensionRegex.hasMatch(file.name), ) .toList(); final paths = imageFiles.map((file) => file.path).toList(); WidgetsBinding.instance.addPostFrameCallback( (_) async => insertLocalImages(paths), ); }, child: child, ), ); } else { return MobileBlockActionButtons( node: widget.node, editorState: editorState, child: GestureDetector( onTap: () { editorState.updateSelectionWithReason(null, extraInfo: {}); showUploadImageMenu(); }, child: child, ), ); } } void showUploadImageMenu() { if (UniversalPlatform.isDesktopOrWeb) { controller.show(); } else { final isLocalMode = _isLocalMode(); showMobileBottomSheet( context, title: LocaleKeys.editor_image.tr(), showHeader: true, showCloseButton: true, showDragHandle: true, builder: (context) { return Container( margin: const EdgeInsets.only(top: 12.0), constraints: const BoxConstraints( maxHeight: 340, minHeight: 80, ), child: UploadImageMenu( limitMaximumImageSize: !isLocalMode, allowMultipleImages: true, supportTypes: const [ UploadImageType.local, UploadImageType.url, UploadImageType.unsplash, ], onSelectedLocalImages: (files) async { context.pop(); final items = files.map((file) => file.path).toList(); await insertLocalImages(items); }, onSelectedAIImage: (url) async { context.pop(); await insertAIImage(url); }, onSelectedNetworkImage: (url) async { context.pop(); await insertNetworkImage(url); }, ), ); }, ); } } Future insertLocalImages(List urls) async { controller.close(); if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) { return; } final transaction = editorState.transaction; final images = await extractAndUploadImages(context, urls, _isLocalMode()); if (images.isEmpty) { return; } final imagesJson = images.map((image) => image.toJson()).toList(); transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, MultiImageBlockKeys.layout: widget.node.attributes[MultiImageBlockKeys.layout] ?? MultiImageLayout.browser.toIntValue(), }); await editorState.apply(transaction); } Future insertAIImage(String url) async { if (url.isEmpty || !isURL(url)) { // show error return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } final path = await getIt().getPath(); final imagePath = p.join(path, 'images'); try { // create the directory if not exists final directory = Directory(imagePath); if (!directory.existsSync()) { await directory.create(recursive: true); } final uri = Uri.parse(url); final copyToPath = p.join( imagePath, '${uuid()}${p.extension(uri.path)}', ); final response = await get(uri); await File(copyToPath).writeAsBytes(response.bodyBytes); await insertLocalImages([copyToPath]); await File(copyToPath).delete(); } catch (e) { Log.error('cannot save image file', e); } } Future insertNetworkImage(String url) async { if (url.isEmpty || !isURL(url)) { // show error return showSnackBarMessage( context, LocaleKeys.document_imageBlock_error_invalidImage.tr(), ); } final transaction = editorState.transaction; final images = [ ImageBlockData( url: url, type: CustomImageType.external, ), ]; transaction.updateNode(widget.node, { MultiImageBlockKeys.images: images.map((image) => image.toJson()).toList(), MultiImageBlockKeys.layout: widget.node.attributes[MultiImageBlockKeys.layout] ?? MultiImageLayout.browser.toIntValue(), }); await editorState.apply(transaction); } bool _isLocalMode() { return context.read().isLocalMode; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart ================================================ import 'dart:io'; import 'dart:math'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:string_validator/string_validator.dart'; enum ResizableImageState { loading, loaded, failed, } class ResizableImage extends StatefulWidget { const ResizableImage({ super.key, required this.type, required this.alignment, required this.editable, required this.onResize, required this.width, required this.src, this.height, this.onDoubleTap, this.onStateChange, }); final String src; final CustomImageType type; final double width; final double? height; final Alignment alignment; final bool editable; final VoidCallback? onDoubleTap; final ValueChanged? onStateChange; final void Function(double width) onResize; @override State createState() => _ResizableImageState(); } const _kImageBlockComponentMinWidth = 30.0; class _ResizableImageState extends State { final documentService = DocumentService(); double initialOffset = 0; double moveDistance = 0; Widget? _cacheImage; late double imageWidth; @visibleForTesting bool onFocus = false; UserProfilePB? _userProfilePB; @override void initState() { super.initState(); imageWidth = widget.width; _userProfilePB = context.read()?.state.userProfile ?? context.read().state.userProfilePB; } @override Widget build(BuildContext context) { return Align( alignment: widget.alignment, child: SizedBox( width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance), height: widget.height, child: MouseRegion( onEnter: (_) => setState(() => onFocus = true), onExit: (_) => setState(() => onFocus = false), child: GestureDetector( onDoubleTap: widget.onDoubleTap, child: _buildResizableImage(context), ), ), ), ); } Widget _buildResizableImage(BuildContext context) { Widget child; final src = widget.src; if (isURL(src)) { _cacheImage = FlowyNetworkImage( url: widget.src, width: imageWidth - moveDistance, userProfilePB: _userProfilePB, onImageLoaded: (isImageInCache) { if (isImageInCache) { widget.onStateChange?.call(ResizableImageState.loaded); } }, progressIndicatorBuilder: (context, _, progress) { if (progress.totalSize != null) { if (progress.progress == 1) { widget.onStateChange?.call(ResizableImageState.loaded); } else { widget.onStateChange?.call(ResizableImageState.loading); } } return _buildLoading(context); }, errorWidgetBuilder: (_, __, error) { widget.onStateChange?.call(ResizableImageState.failed); return _ImageLoadFailedWidget( width: imageWidth, error: error, onRetry: () { setState(() { final retryCounter = FlowyNetworkRetryCounter(); retryCounter.clear(tag: src, url: src); }); }, ); }, ); child = _cacheImage!; } else { // load local file _cacheImage ??= Image.file(File(src)); child = _cacheImage!; } return Stack( children: [ child, if (widget.editable) ...[ _buildEdgeGesture( context, top: 0, left: 5, bottom: 0, width: 5, onUpdate: (distance) => setState(() => moveDistance = distance), ), _buildEdgeGesture( context, top: 0, right: 5, bottom: 0, width: 5, onUpdate: (distance) => setState(() => moveDistance = -distance), ), ], ], ); } Widget _buildLoading(BuildContext context) { return SizedBox( height: 150, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox.fromSize( size: const Size(18, 18), child: const CircularProgressIndicator(), ), SizedBox.fromSize(size: const Size(10, 10)), Text(AppFlowyEditorL10n.current.loading), ], ), ); } Widget _buildEdgeGesture( BuildContext context, { double? top, double? left, double? right, double? bottom, double? width, void Function(double distance)? onUpdate, }) { return Positioned( top: top, left: left, right: right, bottom: bottom, width: width, child: GestureDetector( onHorizontalDragStart: (details) { initialOffset = details.globalPosition.dx; }, onHorizontalDragUpdate: (details) { if (onUpdate != null) { double offset = details.globalPosition.dx - initialOffset; if (widget.alignment == Alignment.center) { offset *= 2.0; } onUpdate(offset); } }, onHorizontalDragEnd: (details) { imageWidth = max(_kImageBlockComponentMinWidth, imageWidth - moveDistance); initialOffset = 0; moveDistance = 0; widget.onResize(imageWidth); }, child: MouseRegion( cursor: SystemMouseCursors.resizeLeftRight, child: onFocus ? Center( child: Container( height: 40, decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.5), borderRadius: const BorderRadius.all( Radius.circular(5.0), ), border: Border.all(color: Colors.white), ), ), ) : null, ), ), ); } } class _ImageLoadFailedWidget extends StatelessWidget { const _ImageLoadFailedWidget({ required this.width, required this.error, required this.onRetry, }); final double width; final Object error; final VoidCallback onRetry; @override Widget build(BuildContext context) { final error = _getErrorMessage(); return Container( height: 160, width: width, alignment: Alignment.center, padding: const EdgeInsets.symmetric(vertical: 8.0), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4.0)), border: Border.all(color: Colors.grey.withValues(alpha: 0.6)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const FlowySvg( FlowySvgs.broken_image_xl, size: Size.square(36), ), FlowyText( AppFlowyEditorL10n.current.imageLoadFailed, fontSize: 14, ), const VSpace(4), if (error != null) FlowyText( error, textAlign: TextAlign.center, color: Theme.of(context).hintColor.withValues(alpha: 0.6), fontSize: 10, maxLines: 2, ), const VSpace(12), Listener( onPointerDown: (event) { onRetry(); }, child: OutlinedRoundedButton( text: LocaleKeys.chat_retry.tr(), onTap: () {}, ), ), ], ), ); } String? _getErrorMessage() { if (error is HttpExceptionWithStatus) { return 'Error ${(error as HttpExceptionWithStatus).statusCode}'; } return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart ================================================ import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:unsplash_client/unsplash_client.dart'; const _accessKeyA = 'YyD-LbW5bVolHWZBq5fWRM_'; const _accessKeyB = '3ezkG2XchRFjhNTnK9TE'; const _secretKeyA = '5z4EnxaXjWjWMnuBhc0Ku0u'; const _secretKeyB = 'YW2bsYCZlO-REZaqmV6A'; enum UnsplashImageType { // the creator name is under the image halfScreen, // the creator name is on the image fullScreen, } typedef OnSelectUnsplashImage = void Function(String url); class UnsplashImageWidget extends StatefulWidget { const UnsplashImageWidget({ super.key, this.type = UnsplashImageType.halfScreen, required this.onSelectUnsplashImage, }); final UnsplashImageType type; final OnSelectUnsplashImage onSelectUnsplashImage; @override State createState() => _UnsplashImageWidgetState(); } class _UnsplashImageWidgetState extends State { final unsplash = UnsplashClient( settings: const ClientSettings( credentials: AppCredentials( accessKey: _accessKeyA + _accessKeyB, secretKey: _secretKeyA + _secretKeyB, ), ), ); late Future> randomPhotos; String query = ''; @override void initState() { super.initState(); randomPhotos = unsplash.photos .random(count: 18, orientation: PhotoOrientation.landscape) .goAndGet(); } @override void dispose() { unsplash.close(); super.dispose(); } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 44, child: FlowyMobileSearchTextField( onChanged: (keyword) => query = keyword, onSubmitted: (_) => _search(), ), ), const VSpace(12.0), Expanded( child: FutureBuilder( future: randomPhotos, builder: (context, value) { final data = value.data; if (!value.hasData || value.connectionState != ConnectionState.done || data == null || data.isEmpty) { return const Center( child: CircularProgressIndicator.adaptive(), ); } return _UnsplashImages( type: widget.type, photos: data, onSelectUnsplashImage: widget.onSelectUnsplashImage, ); }, ), ), ], ); } void _search() { setState(() { randomPhotos = unsplash.photos .random( count: 18, orientation: PhotoOrientation.landscape, query: query, ) .goAndGet(); }); } } class _UnsplashImages extends StatefulWidget { const _UnsplashImages({ required this.type, required this.photos, required this.onSelectUnsplashImage, }); final UnsplashImageType type; final List photos; final OnSelectUnsplashImage onSelectUnsplashImage; @override State<_UnsplashImages> createState() => _UnsplashImagesState(); } class _UnsplashImagesState extends State<_UnsplashImages> { int _selectedPhotoIndex = -1; @override Widget build(BuildContext context) { const mainAxisSpacing = 16.0; final crossAxisCount = switch (widget.type) { UnsplashImageType.halfScreen => 3, UnsplashImageType.fullScreen => 2, }; final crossAxisSpacing = switch (widget.type) { UnsplashImageType.halfScreen => 10.0, UnsplashImageType.fullScreen => 16.0, }; return GridView.count( crossAxisCount: crossAxisCount, mainAxisSpacing: mainAxisSpacing, crossAxisSpacing: crossAxisSpacing, childAspectRatio: 4 / 3, children: widget.photos.asMap().entries.map((entry) { final index = entry.key; final photo = entry.value; return _UnsplashImage( type: widget.type, photo: photo, isSelected: index == _selectedPhotoIndex, onTap: () { widget.onSelectUnsplashImage(photo.urls.full.toString()); setState(() => _selectedPhotoIndex = index); }, ); }).toList(), ); } } class _UnsplashImage extends StatelessWidget { const _UnsplashImage({ required this.type, required this.photo, required this.onTap, required this.isSelected, }); final UnsplashImageType type; final Photo photo; final VoidCallback onTap; final bool isSelected; @override Widget build(BuildContext context) { final child = switch (type) { UnsplashImageType.halfScreen => _buildHalfScreenImage(context), UnsplashImageType.fullScreen => _buildFullScreenImage(context), }; return GestureDetector( onTap: onTap, child: isSelected ? Container( clipBehavior: Clip.antiAlias, decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: const BorderSide(width: 1.50, color: Color(0xFF00BCF0)), borderRadius: BorderRadius.circular(8.0), ), ), padding: const EdgeInsets.all(2.0), child: child, ) : child, ); } Widget _buildHalfScreenImage(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: Image.network( photo.urls.thumb.toString(), fit: BoxFit.cover, ), ), const HSpace(2.0), FlowyText('by ${photo.name}', fontSize: 10.0), ], ); } Widget _buildFullScreenImage(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(8.0), child: Stack( children: [ LayoutBuilder( builder: (_, constraints) => Image.network( photo.urls.thumb.toString(), fit: BoxFit.cover, width: constraints.maxWidth, height: constraints.maxHeight, ), ), Positioned( bottom: 9, left: 10, child: FlowyText.medium( photo.name, fontSize: 13.0, color: Colors.white, ), ), ], ), ); } } extension on Photo { String get name { if (user.username.isNotEmpty) { return user.username; } else if (user.name.isNotEmpty) { return user.name; } else if (user.email?.isNotEmpty == true) { return user.email!; } return user.id; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption; import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; import 'widgets/embed_image_url_widget.dart'; enum UploadImageType { local, url, unsplash, color; String get description => switch (this) { UploadImageType.local => LocaleKeys.document_imageBlock_upload_label.tr(), UploadImageType.url => LocaleKeys.document_imageBlock_embedLink_label.tr(), UploadImageType.unsplash => LocaleKeys.document_imageBlock_unsplash_label.tr(), UploadImageType.color => LocaleKeys.document_plugins_cover_colors.tr(), }; } class UploadImageMenu extends StatefulWidget { const UploadImageMenu({ super.key, required this.onSelectedLocalImages, required this.onSelectedAIImage, required this.onSelectedNetworkImage, this.onSelectedColor, this.supportTypes = UploadImageType.values, this.limitMaximumImageSize = false, this.allowMultipleImages = false, }); final void Function(List) onSelectedLocalImages; final void Function(String url) onSelectedAIImage; final void Function(String url) onSelectedNetworkImage; final void Function(String color)? onSelectedColor; final List supportTypes; final bool limitMaximumImageSize; final bool allowMultipleImages; @override State createState() => _UploadImageMenuState(); } class _UploadImageMenuState extends State { late final List values; int currentTabIndex = 0; @override void initState() { super.initState(); values = widget.supportTypes; } @override Widget build(BuildContext context) { return DefaultTabController( length: values.length, child: Column( mainAxisSize: MainAxisSize.min, children: [ TabBar( onTap: (value) => setState(() { currentTabIndex = value; }), indicatorSize: TabBarIndicatorSize.label, isScrollable: true, overlayColor: WidgetStatePropertyAll( UniversalPlatform.isDesktop ? Theme.of(context).colorScheme.secondary : Colors.transparent, ), padding: EdgeInsets.zero, tabs: values.map( (e) { final child = Padding( padding: EdgeInsets.only( left: 12.0, right: 12.0, bottom: 8.0, top: UniversalPlatform.isMobile ? 0 : 8.0, ), child: FlowyText(e.description), ); if (UniversalPlatform.isDesktop) { return FlowyHover( style: const HoverStyle(borderRadius: BorderRadius.zero), child: child, ); } return child; }, ).toList(), ), const Divider(height: 2), _buildTab(), ], ), ); } Widget _buildTab() { final constraints = UniversalPlatform.isMobile ? const BoxConstraints(minHeight: 92) : null; final type = values[currentTabIndex]; switch (type) { case UploadImageType.local: Widget child = UploadImageFileWidget( allowMultipleImages: widget.allowMultipleImages, onPickFiles: widget.onSelectedLocalImages, ); if (UniversalPlatform.isDesktop) { child = Padding( padding: const EdgeInsets.all(8.0), child: Container( alignment: Alignment.center, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all( color: Theme.of(context).colorScheme.outline, ), ), constraints: constraints, child: child, ), ); } else { child = Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0, vertical: 12.0, ), child: child, ); } return child; case UploadImageType.url: return Container( padding: const EdgeInsets.all(8.0), constraints: constraints, child: EmbedImageUrlWidget( onSubmit: widget.onSelectedNetworkImage, ), ); case UploadImageType.unsplash: return Expanded( child: Padding( padding: const EdgeInsets.all(8.0), child: UnsplashImageWidget( onSelectUnsplashImage: widget.onSelectedNetworkImage, ), ), ); case UploadImageType.color: final theme = Theme.of(context); final padding = UniversalPlatform.isMobile ? const EdgeInsets.all(16.0) : const EdgeInsets.all(8.0); return Container( constraints: constraints, padding: padding, alignment: Alignment.center, child: CoverColorPicker( pickerBackgroundColor: theme.cardColor, pickerItemHoverColor: theme.hoverColor, backgroundColorOptions: FlowyTint.values .map( (t) => ColorOption( colorHex: t.color(context).toHex(), name: t.tintName(AppFlowyEditorL10n.current), ), ) .toList(), onSubmittedBackgroundColorHex: (color) { widget.onSelectedColor?.call(color); }, ), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class EmbedImageUrlWidget extends StatefulWidget { const EmbedImageUrlWidget({ super.key, required this.onSubmit, }); final void Function(String url) onSubmit; @override State createState() => _EmbedImageUrlWidgetState(); } class _EmbedImageUrlWidgetState extends State { bool isUrlValid = true; String inputText = ''; @override Widget build(BuildContext context) { final textField = FlowyTextField( hintText: LocaleKeys.document_imageBlock_embedLink_placeholder.tr(), onChanged: (value) => inputText = value, onEditingComplete: submit, textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 14, ), hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, fontSize: 14, ), ); return Column( children: [ const VSpace(12), UniversalPlatform.isDesktop ? textField : SizedBox( height: 42, child: textField, ), if (!isUrlValid) ...[ const VSpace(12), FlowyText( LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), color: Theme.of(context).colorScheme.error, ), ], const VSpace(20), SizedBox( height: UniversalPlatform.isMobile ? 36 : 32, width: 300, child: FlowyButton( backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), showDefaultBoxDecorationOnMobile: true, radius: UniversalPlatform.isMobile ? BorderRadius.circular(8) : null, margin: const EdgeInsets.all(5), text: FlowyText( LocaleKeys.document_imageBlock_embedLink_label.tr(), lineHeight: 1, textAlign: TextAlign.center, color: UniversalPlatform.isMobile ? null : Theme.of(context).colorScheme.onPrimary, fontSize: UniversalPlatform.isMobile ? 14 : null, ), onTap: submit, ), ), const VSpace(8), ], ); } void submit() { if (checkUrlValidity(inputText)) { return widget.onSubmit(inputText); } setState(() => isUrlValid = false); } bool checkUrlValidity(String url) => imgUrlRegex.hasMatch(url); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/default_extensions.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:universal_platform/universal_platform.dart'; class UploadImageFileWidget extends StatelessWidget { const UploadImageFileWidget({ super.key, required this.onPickFiles, this.allowedExtensions = defaultImageExtensions, this.allowMultipleImages = false, }); final void Function(List) onPickFiles; final List allowedExtensions; final bool allowMultipleImages; @override Widget build(BuildContext context) { Widget child = FlowyButton( showDefaultBoxDecorationOnMobile: true, radius: UniversalPlatform.isMobile ? BorderRadius.circular(8.0) : null, text: Container( margin: const EdgeInsets.all(4.0), alignment: Alignment.center, child: FlowyText( LocaleKeys.document_imageBlock_upload_placeholder.tr(), ), ), onTap: () => _uploadImage(context), ); if (UniversalPlatform.isDesktopOrWeb) { child = FlowyHover(child: child); } else { child = Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: child, ); } return child; } Future _uploadImage(BuildContext context) async { if (UniversalPlatform.isDesktopOrWeb) { // on desktop, the users can pick a image file from folder final result = await getIt().pickFiles( dialogTitle: '', type: FileType.custom, allowedExtensions: allowedExtensions, allowMultiple: allowMultipleImages, ); onPickFiles(result?.files.map((f) => f.xFile).toList() ?? const []); } else { final photoPermission = await PermissionChecker.checkPhotoPermission(context); if (!photoPermission) { Log.error('Has no permission to access the photo library'); return; } // on mobile, the users can pick a image file from camera or image library final result = await ImagePicker().pickMultiImage(); onPickFiles(result); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/text_input.dart'; import 'package:flutter/material.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:provider/provider.dart'; class InlineMathEquationKeys { const InlineMathEquationKeys._(); static const formula = 'formula'; } class InlineMathEquation extends StatefulWidget { const InlineMathEquation({ super.key, required this.formula, required this.node, required this.index, this.textStyle, }); final Node node; final int index; final String formula; final TextStyle? textStyle; @override State createState() => _InlineMathEquationState(); } class _InlineMathEquationState extends State { final popoverController = PopoverController(); @override Widget build(BuildContext context) { return _IgnoreParentPointer( child: AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, popupBuilder: (_) { return MathInputTextField( initialText: widget.formula, onSubmit: (value) async { popoverController.close(); if (value == widget.formula) { return; } final editorState = context.read(); final transaction = editorState.transaction ..formatText(widget.node, widget.index, 1, { InlineMathEquationKeys.formula: value, }); await editorState.apply(transaction); }, ); }, offset: const Offset(0, 10), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2.0), child: MouseRegion( cursor: SystemMouseCursors.click, child: _buildMathEquation(context), ), ), ), ); } Widget _buildMathEquation(BuildContext context) { final theme = Theme.of(context); final longEq = Math.tex( widget.formula, textStyle: widget.textStyle, mathStyle: MathStyle.text, options: MathOptions( style: MathStyle.text, mathFontOptions: const FontOptions( fontShape: FontStyle.italic, ), fontSize: widget.textStyle?.fontSize ?? 14.0, color: widget.textStyle?.color ?? theme.colorScheme.onSurface, ), onErrorFallback: (errmsg) { return FlowyText( errmsg.message, fontSize: widget.textStyle?.fontSize ?? 14.0, color: widget.textStyle?.color ?? theme.colorScheme.onSurface, ); }, ); return longEq; } } class MathInputTextField extends StatefulWidget { const MathInputTextField({ super.key, required this.initialText, required this.onSubmit, }); final String initialText; final void Function(String value) onSubmit; @override State createState() => _MathInputTextFieldState(); } class _MathInputTextFieldState extends State { late final TextEditingController textEditingController; @override void initState() { super.initState(); textEditingController = TextEditingController( text: widget.initialText, ); textEditingController.selection = TextSelection( baseOffset: 0, extentOffset: widget.initialText.length, ); } @override void dispose() { textEditingController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( width: 240, child: Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: FlowyFormTextInput( autoFocus: true, textAlign: TextAlign.left, controller: textEditingController, contentPadding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 4.0, ), onEditingComplete: () => widget.onSubmit(textEditingController.text), ), ), const HSpace(4.0), FlowyButton( text: FlowyText(LocaleKeys.button_done.tr()), useIntrinsicWidth: true, onTap: () => widget.onSubmit(textEditingController.text), ), ], ), ); } } class _IgnoreParentPointer extends StatelessWidget { const _IgnoreParentPointer({ required this.child, }); final Widget child; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () {}, onTapDown: (_) {}, onDoubleTap: () {}, onLongPress: () {}, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; const _kInlineMathEquationToolbarItemId = 'editor.inline_math_equation'; final ToolbarItem inlineMathEquationItem = ToolbarItem( id: _kInlineMathEquationToolbarItemId, group: 4, isActive: onlyShowInSingleSelectionAndTextType, builder: (context, editorState, highlightColor, _, tooltipBuilder) { final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); final isHighlight = nodes.allSatisfyInSelection(selection, (delta) { return delta.everyAttributes( (attributes) => attributes[InlineMathEquationKeys.formula] != null, ); }); final child = SVGIconItemWidget( iconBuilder: (_) => FlowySvg( FlowySvgs.math_lg, size: const Size.square(16), color: isHighlight ? highlightColor : Colors.white, ), isHighlight: isHighlight, highlightColor: highlightColor, onPressed: () async { final selection = editorState.selection; if (selection == null || selection.isCollapsed) { return; } final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || delta == null) { return; } final transaction = editorState.transaction; if (isHighlight) { final formula = delta .slice(selection.startIndex, selection.endIndex) .whereType() .firstOrNull ?.attributes?[InlineMathEquationKeys.formula]; assert(formula != null); if (formula == null) { return; } // clear the format transaction.replaceText( node, selection.startIndex, selection.length, formula, attributes: {}, ); } else { final text = editorState.getTextInSelection(selection).join(); transaction.replaceText( node, selection.startIndex, selection.length, MentionBlockKeys.mentionChar, attributes: { InlineMathEquationKeys.formula: text, }, ); } await editorState.apply(transaction); }, ); if (tooltipBuilder != null) { return tooltipBuilder( context, _kInlineMathEquationToolbarItemId, LocaleKeys.document_plugins_createInlineMathEquation.tr(), child, ); } return child; }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/keyboard_interceptor/keyboard_interceptor.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:universal_platform/universal_platform.dart'; class EditorKeyboardInterceptor extends AppFlowyKeyboardServiceInterceptor { @override Future interceptInsert( TextEditingDeltaInsertion insertion, EditorState editorState, List characterShortcutEvents, ) async { // Only check on the mobile platform: check if the inserted text is a link, if so, try to paste it as a link preview final text = insertion.textInserted; if (UniversalPlatform.isMobile && hrefRegex.hasMatch(text)) { final result = customPasteCommand.execute(editorState); return result == KeyEventResult.handled; } return false; } @override Future interceptReplace( TextEditingDeltaReplacement replacement, EditorState editorState, List characterShortcutEvents, ) async { // Only check on the mobile platform: check if the replaced text is a link, if so, try to paste it as a link preview final text = replacement.replacementText; if (UniversalPlatform.isMobile && hrefRegex.hasMatch(text)) { final result = customPasteCommand.execute(editorState); return result == KeyEventResult.handled; } return false; } @override Future interceptNonTextUpdate( TextEditingDeltaNonTextUpdate nonTextUpdate, EditorState editorState, List characterShortcutEvents, ) async { return _checkIfBacktickPressed( editorState, nonTextUpdate, ); } @override Future interceptDelete( TextEditingDeltaDeletion deletion, EditorState editorState, ) async { // check if the current selection is in a code block final (isInTableCell, selection, tableCellNode, node) = editorState.isCurrentSelectionInTableCell(); if (!isInTableCell || selection == null || tableCellNode == null || node == null) { return false; } final onlyContainsOneChild = tableCellNode.children.length == 1; final isParagraphNode = tableCellNode.children.first.type == ParagraphBlockKeys.type; if (onlyContainsOneChild && selection.isCollapsed && selection.end.offset == 0 && isParagraphNode) { return true; } return false; } /// Check if the backtick pressed event should be handled Future _checkIfBacktickPressed( EditorState editorState, TextEditingDeltaNonTextUpdate nonTextUpdate, ) async { // if the composing range is not empty, it means the user is typing a text, // so we don't need to handle the backtick pressed event if (!nonTextUpdate.composing.isCollapsed || !nonTextUpdate.selection.isCollapsed) { return false; } final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { AppFlowyEditorLog.input.debug('selection is null or not collapsed'); return false; } final node = editorState.getNodesInSelection(selection).firstOrNull; if (node == null) { AppFlowyEditorLog.input.debug('node is null'); return false; } // get last character of the node final plainText = node.delta?.toPlainText(); // three backticks to code block if (plainText != '```') { return false; } final transaction = editorState.transaction; transaction.insertNode( selection.end.path, codeBlockNode(), ); transaction.deleteNode(node); transaction.afterSelection = Selection.collapsed( Position(path: selection.start.path), ); await editorState.apply(transaction); return true; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import 'link_embed_menu.dart'; class LinkEmbedKeys { const LinkEmbedKeys._(); static const String previewType = 'preview_type'; static const String embed = 'embed'; static const String align = 'align'; } Node linkEmbedNode({required String url}) => Node( type: LinkPreviewBlockKeys.type, attributes: { LinkPreviewBlockKeys.url: url, LinkEmbedKeys.previewType: LinkEmbedKeys.embed, }, ); class LinkEmbedBlockComponent extends BlockComponentStatefulWidget { const LinkEmbedBlockComponent({ super.key, super.showActions, super.actionBuilder, super.configuration = const BlockComponentConfiguration(), required super.node, }); @override DefaultSelectableMixinState createState() => LinkEmbedBlockComponentState(); } class LinkEmbedBlockComponentState extends DefaultSelectableMixinState with BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; String get url => widget.node.attributes[LinkPreviewBlockKeys.url] ?? ''; LinkLoadingStatus status = LinkLoadingStatus.loading; final parser = LinkParser(); late LinkInfo linkInfo = LinkInfo(url: url); final showActionsNotifier = ValueNotifier(false); bool isMenuShowing = false, isHovering = false; @override void initState() { super.initState(); parser.addLinkInfoListener((v) { final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); if (mounted) { setState(() { if (hasNewInfo) { linkInfo = v; status = LinkLoadingStatus.idle; } else if (!hasOldInfo) { status = LinkLoadingStatus.error; } }); } }); parser.start(url); } @override void dispose() { parser.dispose(); super.dispose(); } @override Widget build(BuildContext context) { Widget result = MouseRegion( onEnter: (_) { isHovering = true; showActionsNotifier.value = true; }, onExit: (_) { isHovering = false; Future.delayed(const Duration(milliseconds: 200), () { if (isMenuShowing || isHovering) return; if (mounted) showActionsNotifier.value = false; }); }, child: buildChild(context), ); final parent = node.parent; EdgeInsets newPadding = padding; if (parent?.type == CalloutBlockKeys.type) { newPadding = padding.copyWith(right: padding.right + 10); } result = Padding(padding: newPadding, child: result); if (widget.showActions && widget.actionBuilder != null) { result = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, child: result, ); } return result; } Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), fillScheme = theme.fillColorScheme, borderScheme = theme.borderColorScheme; Widget child; final isIdle = status == LinkLoadingStatus.idle; if (isIdle) { child = buildContent(context); } else { child = buildErrorLoadingWidget(context); } return Container( height: 450, key: widgetKey, decoration: BoxDecoration( color: fillScheme.content, borderRadius: BorderRadius.all(Radius.circular(16)), border: Border.all(color: borderScheme.primary), ), child: Stack( children: [ child, buildMenu(context), ], ), ); } Widget buildMenu(BuildContext context) { return Positioned( top: 12, right: 12, child: ValueListenableBuilder( valueListenable: showActionsNotifier, builder: (context, showActions, child) { if (!showActions || UniversalPlatform.isMobile) { return SizedBox.shrink(); } return LinkEmbedMenu( editorState: context.read(), node: node, onReload: () { setState(() { status = LinkLoadingStatus.loading; }); Future.delayed(const Duration(milliseconds: 200), () { if (mounted) parser.start(url); }); }, onMenuShowed: () { isMenuShowing = true; }, onMenuHided: () { isMenuShowing = false; if (!isHovering && mounted) { showActionsNotifier.value = false; } }, ); }, ), ); } Widget buildContent(BuildContext context) { final theme = AppFlowyTheme.of(context), textScheme = theme.textColorScheme; final hasSiteName = linkInfo.siteName?.isNotEmpty ?? false; return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => afLaunchUrlString(url, addingHttpSchemeWhenFailed: true), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), child: FlowyNetworkImage( url: linkInfo.imageUrl ?? '', width: MediaQuery.of(context).size.width, ), ), ), Container( height: 64, padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), child: Row( children: [ SizedBox.square( dimension: 40, child: Center( child: linkInfo.buildIconWidget(size: Size.square(32)), ), ), HSpace(12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (hasSiteName) ...[ FlowyText( linkInfo.siteName ?? '', color: textScheme.primary, fontSize: 14, figmaLineHeight: 20, fontWeight: FontWeight.w600, overflow: TextOverflow.ellipsis, ), VSpace(4), ], FlowyText.regular( url, color: textScheme.secondary, fontSize: 12, figmaLineHeight: 16, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ), ], ), ), ); } Widget buildErrorLoadingWidget(BuildContext context) { final theme = AppFlowyTheme.of(context), textScheme = theme.textColorScheme; final isLoading = status == LinkLoadingStatus.loading; return isLoading ? Center( child: SizedBox.square( dimension: 64, child: CircularProgressIndicator.adaptive(), ), ) : GestureDetector( behavior: HitTestBehavior.opaque, onTap: !UniversalPlatform.isMobile ? null : () => afLaunchUrlString(url, addingHttpSchemeWhenFailed: true), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ SvgPicture.asset( FlowySvgs.embed_error_xl.path, ), VSpace(4), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: RichText( maxLines: 1, overflow: TextOverflow.ellipsis, text: TextSpan( children: [ TextSpan( text: '$url ', style: TextStyle( color: textScheme.secondary, fontSize: 14, height: 20 / 14, fontWeight: FontWeight.w700, ), ), TextSpan( text: LocaleKeys .document_plugins_linkPreview_linkPreviewMenu_unableToDisplay .tr(), style: TextStyle( color: textScheme.secondary, fontSize: 14, height: 20 / 14, fontWeight: FontWeight.w400, ), ), ], ), ), ), ], ), ), ); } @override Node get currentNode => node; @override EdgeInsets get boxPadding => padding; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; import 'link_embed_block_component.dart'; class LinkEmbedMenu extends StatefulWidget { const LinkEmbedMenu({ super.key, required this.node, required this.editorState, required this.onMenuShowed, required this.onMenuHided, required this.onReload, }); final Node node; final EditorState editorState; final VoidCallback onMenuShowed; final VoidCallback onMenuHided; final VoidCallback onReload; @override State createState() => _LinkEmbedMenuState(); } class _LinkEmbedMenuState extends State { final turnIntoController = PopoverController(); final moreOptionController = PopoverController(); int turnIntoMenuNum = 0, moreOptionNum = 0, alignMenuNum = 0; final moreOptionButtonKey = GlobalKey(); bool get isTurnIntoShowing => turnIntoMenuNum > 0; bool get isMoreOptionShowing => moreOptionNum > 0; bool get isAlignMenuShowing => alignMenuNum > 0; Node get node => widget.node; EditorState get editorState => widget.editorState; bool get editable => editorState.editable; String get url => node.attributes[LinkPreviewBlockKeys.url] ?? ''; @override void dispose() { super.dispose(); turnIntoController.close(); moreOptionController.close(); widget.onMenuHided.call(); } @override Widget build(BuildContext context) { return buildChild(); } Widget buildChild() { final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme, surfaceColorScheme = theme.surfaceColorScheme; return Container( padding: EdgeInsets.all(4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: surfaceColorScheme.inverse, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ // FlowyIconButton( // icon: FlowySvg( // FlowySvgs.embed_fullscreen_m, // color: iconScheme.tertiary, // ), // tooltipText: LocaleKeys.document_imageBlock_openFullScreen.tr(), // preferBelow: false, // onPressed: () {}, // ), FlowyIconButton( icon: FlowySvg( FlowySvgs.toolbar_link_m, color: iconScheme.tertiary, ), radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), tooltipText: LocaleKeys.editor_copyLink.tr(), preferBelow: false, onPressed: () => copyLink(context), ), buildConvertButton(), buildMoreOptionButton(), ], ), ); } Widget buildConvertButton() { final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; final button = FlowyIconButton( icon: FlowySvg( FlowySvgs.turninto_m, color: iconScheme.tertiary, ), radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), tooltipText: LocaleKeys.editor_convertTo.tr(), preferBelow: false, onPressed: getTapCallback(showTurnIntoMenu), ); if (!editable) return button; return AppFlowyPopover( offset: Offset(0, 6), direction: PopoverDirection.bottomWithRightAligned, margin: EdgeInsets.zero, controller: turnIntoController, onOpen: () { keepEditorFocusNotifier.increase(); turnIntoMenuNum++; }, onClose: () { keepEditorFocusNotifier.decrease(); turnIntoMenuNum--; checkToHideMenu(); }, popupBuilder: (context) => buildConvertMenu(), child: button, ); } Widget buildConvertMenu() { final types = LinkEmbedConvertCommand.values; return Padding( padding: const EdgeInsets.all(8.0), child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(0.0), children: List.generate(types.length, (index) { final command = types[index]; return SizedBox( height: 36, child: FlowyButton( text: FlowyText( command.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), onTap: () { if (command == LinkEmbedConvertCommand.toBookmark) { final transaction = editorState.transaction; transaction.updateNode(node, { LinkPreviewBlockKeys.url: url, LinkEmbedKeys.previewType: '', }); editorState.apply(transaction); } else if (command == LinkEmbedConvertCommand.toMention) { convertUrlPreviewNodeToMention(editorState, node); } else if (command == LinkEmbedConvertCommand.toURL) { convertUrlPreviewNodeToLink(editorState, node); } }, ), ); }), ), ); } Widget buildMoreOptionButton() { final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorScheme; final button = FlowyIconButton( key: moreOptionButtonKey, icon: FlowySvg( FlowySvgs.toolbar_more_m, color: iconScheme.tertiary, ), radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)), tooltipText: LocaleKeys.document_toolbar_moreOptions.tr(), preferBelow: false, onPressed: getTapCallback(showMoreOptionMenu), ); if (!editable) return button; return AppFlowyPopover( offset: Offset(0, 6), direction: PopoverDirection.bottomWithRightAligned, margin: EdgeInsets.zero, controller: moreOptionController, onOpen: () { keepEditorFocusNotifier.increase(); moreOptionNum++; }, onClose: () { keepEditorFocusNotifier.decrease(); moreOptionNum--; checkToHideMenu(); }, popupBuilder: (context) => buildMoreOptionMenu(), child: button, ); } Widget buildMoreOptionMenu() { final types = LinkEmbedMenuCommand.values; return Padding( padding: const EdgeInsets.all(8.0), child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(0.0), children: List.generate(types.length, (index) { final command = types[index]; return SizedBox( height: 36, child: FlowyButton( text: FlowyText( command.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), onTap: () => onEmbedMenuCommand(command), ), ); }), ), ); } void showTurnIntoMenu() { keepEditorFocusNotifier.increase(); turnIntoController.show(); checkToShowMenu(); turnIntoMenuNum++; if (isMoreOptionShowing) closeMoreOptionMenu(); } void closeTurnIntoMenu() { turnIntoController.close(); checkToHideMenu(); } void showMoreOptionMenu() { keepEditorFocusNotifier.increase(); moreOptionController.show(); checkToShowMenu(); moreOptionNum++; if (isTurnIntoShowing) closeTurnIntoMenu(); } void closeMoreOptionMenu() { moreOptionController.close(); checkToHideMenu(); } void checkToHideMenu() { Future.delayed(Duration(milliseconds: 200), () { if (!mounted) return; if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) { widget.onMenuHided.call(); } }); } void checkToShowMenu() { if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) { widget.onMenuShowed.call(); } } Future copyLink(BuildContext context) async { await context.copyLink(url); widget.onMenuHided.call(); } void onEmbedMenuCommand(LinkEmbedMenuCommand command) { switch (command) { case LinkEmbedMenuCommand.openLink: afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); break; case LinkEmbedMenuCommand.replace: final box = moreOptionButtonKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; final p = box.localToGlobal(Offset.zero); showReplaceMenu( context: context, editorState: editorState, node: node, url: url, ltrb: LTRB(left: p.dx - 330, top: p.dy), onReplace: (url) async { await convertLinkBlockToOtherLinkBlock( editorState, node, node.type, url: url, ); }, ); break; case LinkEmbedMenuCommand.reload: widget.onReload.call(); break; case LinkEmbedMenuCommand.removeLink: removeUrlPreviewLink(editorState, node); break; } closeMoreOptionMenu(); } VoidCallback? getTapCallback(VoidCallback callback) { if (editable) return callback; return null; } } enum LinkEmbedMenuCommand { openLink, replace, reload, removeLink; String get title { switch (this) { case openLink: return LocaleKeys.editor_openLink.tr(); case replace: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace .tr(); case reload: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload .tr(); case removeLink: return LocaleKeys .document_plugins_linkPreview_linkPreviewMenu_removeLink .tr(); } } } enum LinkEmbedConvertCommand { toMention, toURL, toBookmark; String get title { switch (this) { case toMention: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion .tr(); case toURL: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl .tr(); case toBookmark: return LocaleKeys .document_plugins_linkPreview_linkPreviewMenu_toBookmark .tr(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart ================================================ import 'dart:convert'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/appflowy_network_svg.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; import 'link_parsers/default_parser.dart'; import 'link_parsers/youtube_parser.dart'; class LinkParser { final Set> _listeners = >{}; static final Map _hostToParsers = { 'www.youtube.com': YoutubeParser(), 'youtube.com': YoutubeParser(), 'youtu.be': YoutubeParser(), }; Future start(String url, {LinkInfoParser? parser}) async { final uri = Uri.tryParse(LinkInfoParser.formatUrl(url)) ?? Uri.parse(url); final data = await LinkInfoCache.get(uri); if (data != null) { refreshLinkInfo(data); } final host = uri.host; final currentParser = parser ?? _hostToParsers[host] ?? DefaultParser(); await _getLinkInfo(uri, currentParser); } Future _getLinkInfo(Uri uri, LinkInfoParser parser) async { try { final linkInfo = await parser.parse(uri) ?? LinkInfo(url: '$uri'); if (!linkInfo.isEmpty()) await LinkInfoCache.set(uri, linkInfo); refreshLinkInfo(linkInfo); return linkInfo; } catch (e, s) { Log.error('get link info error: ', e, s); refreshLinkInfo(LinkInfo(url: '$uri')); return null; } } void refreshLinkInfo(LinkInfo info) { for (final listener in _listeners) { listener(info); } } void addLinkInfoListener(ValueChanged listener) { _listeners.add(listener); } void dispose() { _listeners.clear(); } } class LinkInfo { factory LinkInfo.fromJson(Map json) => LinkInfo( siteName: json['siteName'], url: json['url'] ?? '', title: json['title'], description: json['description'], imageUrl: json['imageUrl'], faviconUrl: json['faviconUrl'], ); LinkInfo({ required this.url, this.siteName, this.title, this.description, this.imageUrl, this.faviconUrl, }); final String url; final String? siteName; final String? title; final String? description; final String? imageUrl; final String? faviconUrl; Map toJson() => { 'url': url, 'siteName': siteName, 'title': title, 'description': description, 'imageUrl': imageUrl, 'faviconUrl': faviconUrl, }; @override String toString() { return 'LinkInfo{url: $url, siteName: $siteName, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl}'; } bool isEmpty() { return title == null; } Widget buildIconWidget({Size size = const Size.square(20.0)}) { final iconUrl = faviconUrl; if (iconUrl == null) { return FlowySvg(FlowySvgs.toolbar_link_earth_m, size: size); } if (iconUrl.endsWith('.svg')) { return FlowyNetworkSvg( iconUrl, height: size.height, width: size.width, errorWidget: const FlowySvg(FlowySvgs.toolbar_link_earth_m), ); } return FlowyNetworkImage( url: iconUrl, fit: BoxFit.contain, height: size.height, width: size.width, errorWidgetBuilder: (context, error, stackTrace) => const FlowySvg(FlowySvgs.toolbar_link_earth_m), ); } } class LinkInfoCache { static const _linkInfoPrefix = 'link_info'; static Future get(Uri uri) async { final option = await getIt().getWithFormat( '$_linkInfoPrefix$uri', (value) => LinkInfo.fromJson(jsonDecode(value)), ); return option; } static Future set(Uri uri, LinkInfo data) async { await getIt().set( '$_linkInfoPrefix$uri', jsonEncode(data.toJson()), ); } } enum LinkLoadingStatus { loading, idle, error, } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; import 'custom_link_parser.dart'; class CustomLinkPreviewWidget extends StatelessWidget { const CustomLinkPreviewWidget({ super.key, required this.node, required this.url, this.title, this.description, this.imageUrl, this.isHovering = false, this.status = LinkLoadingStatus.loading, }); final Node node; final String? title; final String? description; final String? imageUrl; final String url; final bool isHovering; final LinkLoadingStatus status; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context), borderScheme = theme.borderColorScheme, textScheme = theme.textColorScheme; final documentFontSize = context .read() .editorStyle .textStyleConfiguration .text .fontSize ?? 16.0; final isInDarkCallout = node.parent?.type == CalloutBlockKeys.type && !Theme.of(context).isLightMode; final (fontSize, width) = UniversalPlatform.isDesktopOrWeb ? (documentFontSize, 160.0) : (documentFontSize - 2, 120.0); final Widget child = Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( border: Border.all( color: isHovering || isInDarkCallout ? borderScheme.primaryHover : borderScheme.primary, ), borderRadius: BorderRadius.circular(16.0), ), child: SizedBox( height: 96, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ buildImage(context), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(20, 12, 58, 12), child: status != LinkLoadingStatus.idle ? buildLoadingOrErrorWidget() : Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (title != null) Padding( padding: const EdgeInsets.only(bottom: 4.0), child: FlowyText.medium( title!, overflow: TextOverflow.ellipsis, fontSize: fontSize, color: textScheme.primary, figmaLineHeight: 20, ), ), if (description != null) Padding( padding: const EdgeInsets.only(bottom: 16.0), child: FlowyText( description!, overflow: TextOverflow.ellipsis, fontSize: fontSize - 4, figmaLineHeight: 16, color: textScheme.primary, ), ), FlowyText( url.toString(), overflow: TextOverflow.ellipsis, color: textScheme.secondary, fontSize: fontSize - 4, figmaLineHeight: 16, ), ], ), ), ), ], ), ), ); if (UniversalPlatform.isDesktopOrWeb) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => afLaunchUrlString(url, addingHttpSchemeWhenFailed: true), child: child, ), ); } return MobileBlockActionButtons( node: node, editorState: context.read(), extendActionWidgets: _buildExtendActionWidgets(context), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => afLaunchUrlString(url, addingHttpSchemeWhenFailed: true), child: child, ), ); } // only used on mobile platform List _buildExtendActionWidgets(BuildContext context) { return [ FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), leftIcon: const FlowySvg( FlowySvgs.m_toolbar_link_m, size: Size.square(18), ), onTap: () { context.pop(); convertUrlPreviewNodeToLink( context.read(), node, ); }, ), ]; } Widget buildImage(BuildContext context) { if (imageUrl?.isEmpty ?? true) { return SizedBox.shrink(); } final theme = AppFlowyTheme.of(context), fillScheme = theme.fillColorScheme, iconScheme = theme.iconColorScheme; final width = UniversalPlatform.isDesktopOrWeb ? 160.0 : 120.0; return ClipRRect( borderRadius: const BorderRadius.only( topLeft: Radius.circular(16.0), bottomLeft: Radius.circular(16.0), ), child: Container( width: width, color: fillScheme.quaternary, child: FlowyNetworkImage( url: imageUrl!, width: width, errorWidgetBuilder: (_, __, ___) => Center( child: FlowySvg( FlowySvgs.toolbar_link_earth_m, color: iconScheme.secondary, size: Size.square(30), ), ), ), ), ); } Widget buildLoadingOrErrorWidget() { if (status == LinkLoadingStatus.loading) { return const Center( child: SizedBox( height: 16, width: 16, child: CircularProgressIndicator.adaptive(), ), ); } else if (status == LinkLoadingStatus.error) { return const Center( child: SizedBox( height: 16, width: 16, child: Icon( Icons.error_outline, color: Colors.red, ), ), ); } return SizedBox.shrink(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; import 'custom_link_preview.dart'; import 'default_selectable_mixin.dart'; import 'link_preview_menu.dart'; class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder { CustomLinkPreviewBlockComponentBuilder({ super.configuration, }); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; final isEmbed = node.attributes[LinkEmbedKeys.previewType] == LinkEmbedKeys.embed; if (isEmbed) { return LinkEmbedBlockComponent( key: node.key, node: node, configuration: configuration, showActions: showActions(node), actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), ); } return CustomLinkPreviewBlockComponent( key: node.key, node: node, configuration: configuration, showActions: showActions(node), actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), ); } @override BlockComponentValidate get validate => (node) => node.attributes[LinkPreviewBlockKeys.url]!.isNotEmpty; } class CustomLinkPreviewBlockComponent extends BlockComponentStatefulWidget { const CustomLinkPreviewBlockComponent({ super.key, required super.node, super.showActions, super.actionBuilder, super.configuration = const BlockComponentConfiguration(), }); @override DefaultSelectableMixinState createState() => CustomLinkPreviewBlockComponentState(); } class CustomLinkPreviewBlockComponentState extends DefaultSelectableMixinState with BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!; final parser = LinkParser(); LinkLoadingStatus status = LinkLoadingStatus.loading; late LinkInfo linkInfo = LinkInfo(url: url); final showActionsNotifier = ValueNotifier(false); bool isMenuShowing = false, isHovering = false; @override void initState() { super.initState(); parser.addLinkInfoListener((v) { final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); if (mounted) { setState(() { if (hasNewInfo) { linkInfo = v; status = LinkLoadingStatus.idle; } else if (!hasOldInfo) { status = LinkLoadingStatus.error; } }); } }); parser.start(url); } @override void dispose() { parser.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) { isHovering = true; showActionsNotifier.value = true; }, onExit: (_) { isHovering = false; Future.delayed(const Duration(milliseconds: 200), () { if (isMenuShowing || isHovering) return; if (mounted) showActionsNotifier.value = false; }); }, hitTestBehavior: HitTestBehavior.opaque, opaque: false, child: ValueListenableBuilder( valueListenable: showActionsNotifier, builder: (context, showActions, child) { return buildPreview(showActions); }, ), ); } Widget buildPreview(bool showActions) { Widget child = CustomLinkPreviewWidget( key: widgetKey, node: node, url: url, isHovering: showActions, title: linkInfo.siteName, description: linkInfo.description, imageUrl: linkInfo.imageUrl, status: status, ); if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, child: child, ); } child = Stack( children: [ child, if (showActions && UniversalPlatform.isDesktopOrWeb) Positioned( top: 12, right: 12, child: CustomLinkPreviewMenu( onMenuShowed: () { isMenuShowing = true; }, onMenuHided: () { isMenuShowing = false; if (!isHovering && mounted) { showActionsNotifier.value = false; } }, onReload: () { setState(() { status = LinkLoadingStatus.loading; }); Future.delayed(const Duration(milliseconds: 200), () { if (mounted) parser.start(url); }); }, node: node, ), ), ], ); final parent = node.parent; EdgeInsets newPadding = padding; if (parent?.type == CalloutBlockKeys.type) { newPadding = padding.copyWith(right: padding.right + 10); } child = Padding(padding: newPadding, child: child); return child; } @override Node get currentNode => node; @override EdgeInsets get boxPadding => padding; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/widgets.dart'; abstract class DefaultSelectableMixinState extends State with SelectableMixin { final widgetKey = GlobalKey(); RenderBox? get _renderBox => widgetKey.currentContext?.findRenderObject() as RenderBox?; Node get currentNode; EdgeInsets get boxPadding => EdgeInsets.zero; @override Position start() => Position(path: currentNode.path); @override Position end() => Position(path: currentNode.path, offset: 1); @override Position getPositionInOffset(Offset start) => end(); @override bool get shouldCursorBlink => false; @override CursorStyle get cursorStyle => CursorStyle.cover; @override Rect getBlockRect({ bool shiftWithBaseOffset = false, }) { final box = _renderBox; if (box is RenderBox) { return boxPadding.topLeft & box.size; } return Rect.zero; } @override Rect? getCursorRectInPosition( Position position, { bool shiftWithBaseOffset = false, }) { final rects = getRectsInSelection(Selection.collapsed(position)); return rects.firstOrNull; } @override List getRectsInSelection( Selection selection, { bool shiftWithBaseOffset = false, }) { if (_renderBox == null) { return []; } final parentBox = context.findRenderObject(); final box = widgetKey.currentContext?.findRenderObject(); if (parentBox is RenderBox && box is RenderBox) { return [ box.localToGlobal(Offset.zero, ancestor: parentBox) & box.size, ]; } return [Offset.zero & _renderBox!.size]; } @override Selection getSelectionInRange(Offset start, Offset end) => Selection.single( path: currentNode.path, startOffset: 0, endOffset: 1, ); @override Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => _renderBox!.localToGlobal(offset); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy_backend/log.dart'; // ignore: depend_on_referenced_packages import 'package:html/parser.dart' as html_parser; import 'package:http/http.dart' as http; import 'dart:convert'; abstract class LinkInfoParser { Future parse( Uri link, { Duration timeout = const Duration(seconds: 8), Map? headers, }); static String formatUrl(String url) { Uri? uri = Uri.tryParse(url); if (uri == null) return url; if (!uri.hasScheme) uri = Uri.tryParse('http://$url'); if (uri == null) return url; final isHome = (uri.hasEmptyPath || uri.path == '/') && !uri.hasQuery; final homeUrl = '${uri.scheme}://${uri.host}/'; if (isHome) return homeUrl; return '$uri'; } } class DefaultParser implements LinkInfoParser { @override Future parse( Uri link, { Duration timeout = const Duration(seconds: 8), Map? headers, }) async { try { final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery; final http.Response response = await http.get(link, headers: headers).timeout(timeout); final code = response.statusCode; if (code != 200 && isHome) { throw Exception('Http request error: $code'); } final contentType = response.headers['content-type']; final charset = contentType?.split('charset=').lastOrNull; String body = ''; if (charset == null || charset.toLowerCase() == 'latin-1' || charset.toLowerCase() == 'iso-8859-1') { body = latin1.decode(response.bodyBytes); } else { body = utf8.decode(response.bodyBytes, allowMalformed: true); } final document = html_parser.parse(body); final siteName = document .querySelector('meta[property="og:site_name"]') ?.attributes['content']; String? title = document .querySelector('meta[property="og:title"]') ?.attributes['content']; title ??= document.querySelector('title')?.text; String? description = document .querySelector('meta[property="og:description"]') ?.attributes['content']; description ??= document .querySelector('meta[name="description"]') ?.attributes['content']; String? imageUrl = document .querySelector('meta[property="og:image"]') ?.attributes['content']; if (imageUrl != null && !imageUrl.startsWith('http')) { imageUrl = link.resolve(imageUrl).toString(); } final favicon = 'https://www.faviconextractor.com/favicon/${link.host}?larger=true'; return LinkInfo( url: '$link', siteName: siteName, title: title, description: description, imageUrl: imageUrl, faviconUrl: favicon, ); } catch (e) { Log.error('Parse link $link error: $e'); return null; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy_backend/log.dart'; import 'package:http/http.dart' as http; import 'default_parser.dart'; class YoutubeParser implements LinkInfoParser { @override Future parse( Uri link, { Duration timeout = const Duration(seconds: 8), Map? headers, }) async { try { final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery; if (isHome) { return DefaultParser().parse( link, timeout: timeout, headers: headers, ); } final requestLink = 'https://www.youtube.com/oembed?url=$link&format=json'; final http.Response response = await http .get(Uri.parse(requestLink), headers: headers) .timeout(timeout); final code = response.statusCode; if (code != 200) { throw Exception('Http request error: $code'); } final youtubeInfo = YoutubeInfo.fromJson(jsonDecode(response.body)); final favicon = 'https://www.google.com/s2/favicons?sz=64&domain=${link.host}'; return LinkInfo( url: '$link', title: youtubeInfo.title, siteName: youtubeInfo.authorName, imageUrl: youtubeInfo.thumbnailUrl, faviconUrl: favicon, ); } catch (e) { Log.error('Parse link $link error: $e'); return null; } } } class YoutubeInfo { YoutubeInfo({ this.title, this.authorName, this.version, this.providerName, this.providerUrl, this.thumbnailUrl, }); YoutubeInfo.fromJson(Map json) { title = json['title']; authorName = json['author_name']; version = json['version']; providerName = json['provider_name']; providerUrl = json['provider_url']; thumbnailUrl = json['thumbnail_url']; } String? title; String? authorName; String? version; String? providerName; String? providerUrl; String? thumbnailUrl; Map toJson() => { 'title': title, 'author_name': authorName, 'version': version, 'provider_name': providerName, 'provider_url': providerUrl, 'thumbnail_url': thumbnailUrl, }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class CustomLinkPreviewMenu extends StatefulWidget { const CustomLinkPreviewMenu({ super.key, required this.onMenuShowed, required this.onMenuHided, required this.onReload, required this.node, }); final VoidCallback onMenuShowed; final VoidCallback onMenuHided; final VoidCallback onReload; final Node node; @override State createState() => _CustomLinkPreviewMenuState(); } class _CustomLinkPreviewMenuState extends State { final popoverController = PopoverController(); final buttonKey = GlobalKey(); bool closed = false; bool selected = false; @override void dispose() { super.dispose(); popoverController.close(); widget.onMenuHided.call(); } @override Widget build(BuildContext context) { return AppFlowyPopover( offset: Offset(0, 0.0), direction: PopoverDirection.bottomWithRightAligned, margin: EdgeInsets.zero, controller: popoverController, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { keepEditorFocusNotifier.decrease(); if (!closed) { closed = true; return; } else { closed = false; widget.onMenuHided.call(); } setState(() { selected = false; }); }, popupBuilder: (context) => buildMenu(), child: FlowyIconButton( key: buttonKey, isSelected: selected, icon: FlowySvg(FlowySvgs.toolbar_more_m), onPressed: showPopover, ), ); } Widget buildMenu() { final editorState = context.read(), editable = editorState.editable; return MouseRegion( child: Padding( padding: const EdgeInsets.all(8.0), child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(0.0), children: List.generate(LinkPreviewMenuCommand.values.length, (index) { final command = LinkPreviewMenuCommand.values[index]; final isCopyCommand = command == LinkPreviewMenuCommand.copyLink; final enableButton = editable || (!editable && isCopyCommand); return SizedBox( height: 36, child: FlowyButton( hoverColor: enableButton ? null : Colors.transparent, text: FlowyText( command.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), onTap: enableButton ? () => onTap(command) : null, ), ); }), ), ), ); } Future onTap(LinkPreviewMenuCommand command) async { final editorState = context.read(); final node = widget.node; final url = node.attributes[LinkPreviewBlockKeys.url]; switch (command) { case LinkPreviewMenuCommand.convertToMention: await convertUrlPreviewNodeToMention(editorState, node); break; case LinkPreviewMenuCommand.convertToUrl: await convertUrlPreviewNodeToLink(editorState, node); break; case LinkPreviewMenuCommand.convertToEmbed: final transaction = editorState.transaction; transaction.updateNode(node, { LinkPreviewBlockKeys.url: url, LinkEmbedKeys.previewType: LinkEmbedKeys.embed, }); await editorState.apply(transaction); break; case LinkPreviewMenuCommand.copyLink: if (url != null) { await context.copyLink(url); } break; case LinkPreviewMenuCommand.replace: final box = buttonKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; final p = box.localToGlobal(Offset.zero); showReplaceMenu( context: context, editorState: editorState, node: node, url: url, ltrb: LTRB(left: p.dx - 330, top: p.dy), onReplace: (url) async { await convertLinkBlockToOtherLinkBlock( editorState, node, node.type, url: url, ); }, ); break; case LinkPreviewMenuCommand.reload: widget.onReload.call(); break; case LinkPreviewMenuCommand.removeLink: await removeUrlPreviewLink(editorState, node); break; } closePopover(); } void showPopover() { widget.onMenuShowed.call(); keepEditorFocusNotifier.increase(); popoverController.show(); setState(() { selected = true; }); } void closePopover() { popoverController.close(); widget.onMenuHided.call(); } } enum LinkPreviewMenuCommand { convertToMention, convertToUrl, convertToEmbed, copyLink, replace, reload, removeLink; String get title { switch (this) { case convertToMention: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion .tr(); case LinkPreviewMenuCommand.convertToUrl: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl .tr(); case LinkPreviewMenuCommand.convertToEmbed: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed .tr(); case LinkPreviewMenuCommand.copyLink: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink .tr(); case LinkPreviewMenuCommand.replace: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace .tr(); case LinkPreviewMenuCommand.reload: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload .tr(); case LinkPreviewMenuCommand.removeLink: return LocaleKeys .document_plugins_linkPreview_linkPreviewMenu_removeLink .tr(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; const _menuHeighgt = 188.0, _menuWidth = 288.0; class PasteAsMenuService { PasteAsMenuService({ required this.context, required this.editorState, }); final BuildContext context; final EditorState editorState; OverlayEntry? _menuEntry; void show(String href) { WidgetsBinding.instance.addPostFrameCallback((_) => _show(href)); } void dismiss() { if (_menuEntry != null) { keepEditorFocusNotifier.decrease(); // editorState.service.scrollService?.enable(); // editorState.service.keyboardService?.enable(); } _menuEntry?.remove(); _menuEntry = null; } void _show(String href) { final Size editorSize = editorState.renderBox?.size ?? Size.zero; if (editorSize == Size.zero) return; final menuPosition = editorState.calculateMenuOffset( menuWidth: _menuWidth, menuHeight: _menuHeighgt, ); if (menuPosition == null) return; final ltrb = menuPosition.ltrb; _menuEntry = OverlayEntry( builder: (context) => SizedBox( height: editorSize.height, width: editorSize.width, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: dismiss, child: Stack( children: [ ltrb.buildPositioned( child: PasteAsMenu( editorState: editorState, onSelect: (t) { final selection = editorState.selection; if (selection == null) return; final end = selection.end; final urlSelection = Selection( start: end.copyWith(offset: end.offset - href.length), end: end, ); if (t == PasteMenuType.bookmark) { convertUrlToLinkPreview(editorState, urlSelection, href); } else if (t == PasteMenuType.mention) { convertUrlToMention(editorState, urlSelection); } else if (t == PasteMenuType.embed) { convertUrlToLinkPreview( editorState, urlSelection, href, previewType: LinkEmbedKeys.embed, ); } dismiss(); }, onDismiss: dismiss, ), ), ], ), ), ), ); Overlay.of(context).insert(_menuEntry!); keepEditorFocusNotifier.increase(); // editorState.service.keyboardService?.disable(showCursor: true); // editorState.service.scrollService?.disable(); } } class PasteAsMenu extends StatefulWidget { const PasteAsMenu({ super.key, required this.onSelect, required this.onDismiss, required this.editorState, }); final ValueChanged onSelect; final VoidCallback onDismiss; final EditorState editorState; @override State createState() => _PasteAsMenuState(); } class _PasteAsMenuState extends State { final focusNode = FocusNode(debugLabel: 'paste_as_menu'); final ValueNotifier selectedIndexNotifier = ValueNotifier(0); EditorState get editorState => widget.editorState; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback( (_) => focusNode.requestFocus(), ); editorState.selectionNotifier.addListener(dismiss); } @override void dispose() { focusNode.dispose(); selectedIndexNotifier.dispose(); editorState.selectionNotifier.removeListener(dismiss); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Focus( focusNode: focusNode, onKeyEvent: onKeyEvent, child: Container( width: _menuWidth, height: _menuHeighgt, padding: EdgeInsets.all(6), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: theme.surfaceColorScheme.primary, boxShadow: theme.shadow.medium, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 32, padding: EdgeInsets.all(8), child: FlowyText.semibold( color: theme.textColorScheme.primary, LocaleKeys.document_plugins_linkPreview_typeSelection_pasteAs .tr(), ), ), ...List.generate( PasteMenuType.values.length, (i) => buildItem(PasteMenuType.values[i], i), ), ], ), ), ); } Widget buildItem(PasteMenuType type, int i) { return ValueListenableBuilder( valueListenable: selectedIndexNotifier, builder: (context, value, child) { final isSelected = i == value; return SizedBox( height: 36, child: FlowyButton( isSelected: isSelected, text: FlowyText( type.title, ), onTap: () => onSelect(type), ), ); }, ); } void changeIndex(int index) => selectedIndexNotifier.value = index; KeyEventResult onKeyEvent(focus, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } int index = selectedIndexNotifier.value, length = PasteMenuType.values.length; if (event.logicalKey == LogicalKeyboardKey.enter) { onSelect(PasteMenuType.values[index]); return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.escape) { dismiss(); } else if (event.logicalKey == LogicalKeyboardKey.backspace) { dismiss(); } else if ([LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowLeft] .contains(event.logicalKey)) { if (index == 0) { index = length - 1; } else { index--; } changeIndex(index); return KeyEventResult.handled; } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowRight] .contains(event.logicalKey)) { if (index == length - 1) { index = 0; } else { index++; } changeIndex(index); return KeyEventResult.handled; } return KeyEventResult.ignored; } void onSelect(PasteMenuType type) => widget.onSelect.call(type); void dismiss() => widget.onDismiss.call(); } enum PasteMenuType { mention, url, bookmark, embed, } extension PasteMenuTypeExtension on PasteMenuType { String get title { switch (this) { case PasteMenuType.mention: return LocaleKeys.document_plugins_linkPreview_typeSelection_mention .tr(); case PasteMenuType.url: return LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr(); case PasteMenuType.bookmark: return LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark .tr(); case PasteMenuType.embed: return LocaleKeys.document_plugins_linkPreview_typeSelection_embed.tr(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; Future convertUrlPreviewNodeToLink( EditorState editorState, Node node, ) async { if (node.type != LinkPreviewBlockKeys.type) { return; } final url = node.attributes[LinkPreviewBlockKeys.url]; final delta = Delta() ..insert( url, attributes: { AppFlowyRichTextKeys.href: url, }, ); final transaction = editorState.transaction; transaction ..insertNode(node.path, paragraphNode(delta: delta)) ..deleteNode(node); transaction.afterSelection = Selection.collapsed( Position( path: node.path, offset: url.length, ), ); return editorState.apply(transaction); } Future convertUrlPreviewNodeToMention( EditorState editorState, Node node, ) async { if (node.type != LinkPreviewBlockKeys.type) { return; } final url = node.attributes[LinkPreviewBlockKeys.url]; final delta = Delta() ..insert( MentionBlockKeys.mentionChar, attributes: { MentionBlockKeys.mention: { MentionBlockKeys.type: MentionType.externalLink.name, MentionBlockKeys.url: url, }, }, ); final transaction = editorState.transaction; transaction ..insertNode(node.path, paragraphNode(delta: delta)) ..deleteNode(node); transaction.afterSelection = Selection.collapsed( Position( path: node.path, offset: url.length, ), ); return editorState.apply(transaction); } Future removeUrlPreviewLink( EditorState editorState, Node node, ) async { if (node.type != LinkPreviewBlockKeys.type) { return; } final url = node.attributes[LinkPreviewBlockKeys.url]; final delta = Delta()..insert(url); final transaction = editorState.transaction; transaction ..insertNode(node.path, paragraphNode(delta: delta)) ..deleteNode(node); transaction.afterSelection = Selection.collapsed( Position( path: node.path, offset: url.length, ), ); return editorState.apply(transaction); } Future convertUrlToLinkPreview( EditorState editorState, Selection selection, String url, { String? previewType, }) async { final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return; } final delta = node.delta; if (delta == null) return; final List beforeOperations = [], afterOperations = []; int index = 0; for (final insert in delta.whereType()) { if (index < selection.startIndex) { beforeOperations.add(insert); } else if (index >= selection.endIndex) { afterOperations.add(insert); } index += insert.length; } final transaction = editorState.transaction; transaction ..deleteNode(node) ..insertNodes(node.path.next, [ if (beforeOperations.isNotEmpty) paragraphNode(delta: Delta(operations: beforeOperations)), if (previewType == LinkEmbedKeys.embed) linkEmbedNode(url: url) else linkPreviewNode(url: url), if (afterOperations.isNotEmpty) paragraphNode(delta: Delta(operations: afterOperations)), ]); await editorState.apply(transaction); } Future convertUrlToMention( EditorState editorState, Selection selection, ) async { final node = editorState.getNodeAtPath(selection.end.path); if (node == null) { return; } final delta = node.delta; if (delta == null) return; String url = ''; int index = 0; for (final insert in delta.whereType()) { if (index >= selection.startIndex && index < selection.endIndex) { final href = insert.attributes?.href ?? ''; if (href.isNotEmpty) { url = href; break; } } index += insert.length; } final transaction = editorState.transaction; transaction.replaceText( node, selection.startIndex, selection.length, MentionBlockKeys.mentionChar, attributes: { MentionBlockKeys.mention: { MentionBlockKeys.type: MentionType.externalLink.name, MentionBlockKeys.url: url, }, }, ); await editorState.apply(transaction); } Future convertLinkBlockToOtherLinkBlock( EditorState editorState, Node node, String toType, { String? url, }) async { final nodeType = node.type; if (nodeType != LinkPreviewBlockKeys.type || (nodeType == toType && url == null)) { return; } final insertedNode = []; final afterUrl = url ?? node.attributes[LinkPreviewBlockKeys.url] ?? ''; final previewType = node.attributes[LinkEmbedKeys.previewType]; Node afterNode = node.copyWith( type: toType, attributes: { LinkPreviewBlockKeys.url: afterUrl, LinkEmbedKeys.previewType: previewType, blockComponentBackgroundColor: node.attributes[blockComponentBackgroundColor], blockComponentTextDirection: node.attributes[blockComponentTextDirection], blockComponentDelta: (node.delta ?? Delta()).toJson(), }, ); afterNode = afterNode.copyWith(children: []); insertedNode.add(afterNode); insertedNode.addAll(node.children.map((e) => e.deepCopy())); final transaction = editorState.transaction; transaction.insertNodes( node.path, insertedNode, ); transaction.deleteNodes([node]); await editorState.apply(transaction); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class MathEquationBlockKeys { const MathEquationBlockKeys._(); static const String type = 'math_equation'; /// The content of a math equation block. /// /// The value is a String. static const String formula = 'formula'; } Node mathEquationNode({ String formula = '', }) { final attributes = { MathEquationBlockKeys.formula: formula, }; return Node( type: MathEquationBlockKeys.type, attributes: attributes, ); } // defining the callout block menu item for selection SelectionMenuItem mathEquationItem = SelectionMenuItem.node( getName: LocaleKeys.document_plugins_mathEquation_name.tr, iconBuilder: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.icon_math_eq_s, isSelected: onSelected, style: style, ), keywords: ['tex, latex, katex', 'math equation', 'formula'], nodeBuilder: (editorState, _) => mathEquationNode(), replace: (_, node) => node.delta?.isEmpty ?? false, updateSelection: (editorState, path, __, ___) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { final mathEquationState = editorState.getNodeAtPath(path)?.key.currentState; if (mathEquationState != null && mathEquationState is MathEquationBlockComponentWidgetState) { mathEquationState.showEditingDialog(); } }); return null; }, ); class MathEquationBlockComponentBuilder extends BlockComponentBuilder { MathEquationBlockComponentBuilder({ super.configuration, }); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return MathEquationBlockComponentWidget( key: node.key, node: node, configuration: configuration, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (node) => node.children.isEmpty && node.attributes[MathEquationBlockKeys.formula] is String; } class MathEquationBlockComponentWidget extends BlockComponentStatefulWidget { const MathEquationBlockComponentWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @override State createState() => MathEquationBlockComponentWidgetState(); } class MathEquationBlockComponentWidgetState extends State with BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; String get formula => widget.node.attributes[MathEquationBlockKeys.formula] as String; late final editorState = context.read(); final ValueNotifier isHover = ValueNotifier(false); late final controller = TextEditingController(text: formula); @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return InkWell( onHover: (value) => isHover.value = value, onTap: showEditingDialog, child: _build(context), ); } Widget _build(BuildContext context) { Widget child = Container( constraints: const BoxConstraints(minHeight: 52), decoration: BoxDecoration( color: formula.isNotEmpty ? Colors.transparent : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: FlowyHover( style: HoverStyle( borderRadius: BorderRadius.circular(4), ), child: formula.isEmpty ? _buildPlaceholderWidget(context) : _buildMathEquation(context), ), ); if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } if (UniversalPlatform.isMobile) { child = MobileBlockActionButtons( node: node, editorState: editorState, child: child, ); } child = Padding( padding: padding, child: child, ); if (UniversalPlatform.isDesktopOrWeb) { child = Stack( children: [ child, Positioned( right: 6, top: 12, child: ValueListenableBuilder( valueListenable: isHover, builder: (_, value, __) => value ? _buildDeleteButton(context) : const SizedBox.shrink(), ), ), ], ); } return child; } Widget _buildPlaceholderWidget(BuildContext context) { return SizedBox( height: 52, child: Row( children: [ const HSpace(10), FlowySvg( FlowySvgs.slash_menu_icon_math_equation_s, color: Theme.of(context).hintColor, size: const Size.square(24), ), const HSpace(10), FlowyText( LocaleKeys.document_plugins_mathEquation_addMathEquation.tr(), color: Theme.of(context).hintColor, ), ], ), ); } Widget _buildMathEquation(BuildContext context) { return Center( child: Math.tex( formula, textStyle: const TextStyle(fontSize: 20), ), ); } Widget _buildDeleteButton(BuildContext context) { return MenuBlockButton( tooltip: LocaleKeys.button_delete.tr(), iconData: FlowySvgs.trash_s, onTap: () { final transaction = editorState.transaction..deleteNode(widget.node); editorState.apply(transaction); }, ); } void showEditingDialog() { showDialog( context: context, builder: (context) { return AlertDialog( backgroundColor: Theme.of(context).canvasColor, title: Text( LocaleKeys.document_plugins_mathEquation_editMathEquation.tr(), ), content: KeyboardListener( focusNode: FocusNode(), onKeyEvent: (key) { if (key.logicalKey == LogicalKeyboardKey.enter && !HardwareKeyboard.instance.isShiftPressed) { updateMathEquation(controller.text, context); } else if (key.logicalKey == LogicalKeyboardKey.escape) { dismiss(context); } }, child: SizedBox( width: MediaQuery.of(context).size.width * 0.3, child: TextField( autofocus: true, controller: controller, maxLines: null, decoration: const InputDecoration( border: OutlineInputBorder(), hintText: 'E = MC^2', ), ), ), ), actions: [ SecondaryTextButton( LocaleKeys.button_cancel.tr(), mode: TextButtonMode.big, onPressed: () => dismiss(context), ), PrimaryTextButton( LocaleKeys.button_done.tr(), onPressed: () => updateMathEquation(controller.text, context), ), ], actionsPadding: const EdgeInsets.only(bottom: 20), actionsAlignment: MainAxisAlignment.spaceAround, ); }, ); } void updateMathEquation(String mathEquation, BuildContext context) { if (mathEquation == formula) { dismiss(context); return; } final transaction = editorState.transaction ..updateNode( widget.node, { MathEquationBlockKeys.formula: mathEquation, }, ); editorState.apply(transaction); dismiss(context); } void dismiss(BuildContext context) { Navigator.of(context).pop(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; /// Windows / Linux : ctrl + shift + e /// macOS : cmd + shift + e /// Allows the user to insert math equation by shortcut /// /// - support /// - desktop /// - web /// final CommandShortcutEvent insertInlineMathEquationCommand = CommandShortcutEvent( key: 'Insert inline math equation', command: 'ctrl+shift+e', macOSCommand: 'cmd+shift+e', getDescription: LocaleKeys.document_plugins_mathEquation_name.tr, handler: (editorState) { final selection = editorState.selection; if (selection == null || selection.isCollapsed || !selection.isSingle) { return KeyEventResult.ignored; } final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || delta == null) { return KeyEventResult.ignored; } if (node.delta == null || !toolbarItemWhiteList.contains(node.type)) { return KeyEventResult.ignored; } final transaction = editorState.transaction; final nodes = editorState.getNodesInSelection(selection); final isHighlight = nodes.allSatisfyInSelection(selection, (delta) { return delta.everyAttributes( (attributes) => attributes[InlineMathEquationKeys.formula] != null, ); }); if (isHighlight) { final formula = delta .slice(selection.startIndex, selection.endIndex) .whereType() .firstOrNull ?.attributes?[InlineMathEquationKeys.formula]; assert(formula != null); if (formula == null) { return KeyEventResult.ignored; } // clear the format transaction.replaceText( node, selection.startIndex, selection.length, formula, attributes: {}, ); } else { final text = editorState.getTextInSelection(selection).join(); transaction.replaceText( node, selection.startIndex, selection.length, MentionBlockKeys.mentionChar, attributes: { InlineMathEquationKeys.formula: text, }, ); } editorState.apply(transaction); return KeyEventResult.handled; }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/mobile_math_equation_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; final mathEquationMobileToolbarItem = MobileToolbarItem.action( itemIconBuilder: (_, __, ___) => const SizedBox( width: 22, child: FlowySvg(FlowySvgs.math_lg), ), actionHandler: (_, editorState) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final path = selection.start.path; final node = editorState.getNodeAtPath(path); final delta = node?.delta; if (node == null || delta == null) { return; } final transaction = editorState.transaction; final insertedNode = mathEquationNode(); if (delta.isEmpty) { transaction ..insertNode(path, insertedNode) ..deleteNode(node); } else { transaction.insertNode( path.next, insertedNode, ); } await editorState.apply(transaction); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { final mathEquationState = editorState.getNodeAtPath(path)?.key.currentState; if (mathEquationState != null && mathEquationState is MathEquationBlockComponentWidgetState) { mathEquationState.showEditingDialog(); } }); }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../transaction_handler/mention_transaction_handler.dart'; const _pasteIdentifier = 'child_page_transaction'; class ChildPageTransactionHandler extends MentionTransactionHandler { ChildPageTransactionHandler(); @override Future onTransaction( BuildContext context, String viewId, EditorState editorState, List added, List removed, { bool isCut = false, bool isUndoRedo = false, bool isPaste = false, bool isDraggingNode = false, bool isTurnInto = false, String? parentViewId, }) async { if (isDraggingNode || isTurnInto) { return; } // Remove the mentions that were both added and removed in the same transaction. // These were just moved around. final moved = []; for (final mention in added) { if (removed.any((r) => r.$2 == mention.$2)) { moved.add(mention); } } for (final mention in removed) { if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { return; } if (mention.$2[MentionBlockKeys.type] != MentionType.childPage.name) { continue; } await _handleDeletion(context, mention); } if (isPaste || isUndoRedo) { if (context.mounted) { context.read().startHandlingPaste(_pasteIdentifier); } for (final mention in added) { if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { return; } if (mention.$2[MentionBlockKeys.type] != MentionType.childPage.name) { continue; } await _handleAddition( context, editorState, mention, isPaste, parentViewId, isCut, ); } if (context.mounted) { context.read().endHandlingPaste(_pasteIdentifier); } } } Future _handleDeletion( BuildContext context, MentionBlockData data, ) async { final viewId = data.$2[MentionBlockKeys.pageId]; final result = await ViewBackendService.deleteView(viewId: viewId); result.fold( (_) {}, (error) { Log.error(error); if (context.mounted) { showToastNotification( message: LocaleKeys.document_plugins_subPage_errors_failedDeletePage .tr(), ); } }, ); } Future _handleAddition( BuildContext context, EditorState editorState, MentionBlockData data, bool isPaste, String? parentViewId, bool isCut, ) async { if (parentViewId == null) { return; } final viewId = data.$2[MentionBlockKeys.pageId]; if (isPaste && !isCut) { _handlePasteFromCopy( context, editorState, data.$1, data.$3, viewId, parentViewId, ); } else { _handlePasteFromCut(viewId, parentViewId); } } void _handlePasteFromCut(String viewId, String parentViewId) async { // Attempt to restore from Trash just in case await TrashService.putback(viewId); final view = (await ViewBackendService.getView(viewId)).toNullable(); if (view == null) { return Log.error('View not found: $viewId'); } if (view.parentViewId == parentViewId) { return; } await ViewBackendService.moveViewV2( viewId: viewId, newParentId: parentViewId, prevViewId: null, ); } void _handlePasteFromCopy( BuildContext context, EditorState editorState, Node node, int index, String viewId, String parentViewId, ) async { final view = (await ViewBackendService.getView(viewId)).toNullable(); if (view == null) { return Log.error('View not found: $viewId'); } final duplicatedViewOrFailure = await ViewBackendService.duplicate( view: view, openAfterDuplicate: false, includeChildren: true, syncAfterDuplicate: true, parentViewId: parentViewId, ); await duplicatedViewOrFailure.fold( (newView) async { // The index is the index of the delta, to get the index of the mention character // in all the text, we need to calculate it based on the deltas before the current delta. int mentionIndex = 0; for (final (i, delta) in node.delta!.indexed) { if (i >= index) { break; } mentionIndex += delta.length; } final transaction = editorState.transaction; transaction.formatText( node, mentionIndex, MentionBlockKeys.mentionChar.length, MentionBlockKeys.buildMentionPageAttributes( mentionType: MentionType.childPage, pageId: newView.id, blockId: null, ), ); await editorState.apply( transaction, options: const ApplyOptions(recordUndo: false), ); }, (error) { Log.error(error); if (context.mounted) { showSnapBar( context, LocaleKeys.document_plugins_subPage_errors_failedDuplicatePage.tr(), ); } }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart ================================================ import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/material.dart'; import 'package:nanoid/nanoid.dart'; import 'package:provider/provider.dart'; import '../plugins.dart'; import '../transaction_handler/mention_transaction_handler.dart'; const _pasteIdentifier = 'date_transaction'; class DateTransactionHandler extends MentionTransactionHandler { DateTransactionHandler(); @override Future onTransaction( BuildContext context, String viewId, EditorState editorState, List added, List removed, { bool isCut = false, bool isUndoRedo = false, bool isPaste = false, bool isDraggingNode = false, bool isTurnInto = false, String? parentViewId, }) async { if (isDraggingNode || isTurnInto) { return; } // Remove the mentions that were both added and removed in the same transaction. // These were just moved around. final moved = []; for (final mention in added) { if (removed.any((r) => r.$2 == mention.$2)) { moved.add(mention); } } for (final mention in removed) { if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { return; } if (mention.$2[MentionBlockKeys.type] != MentionType.date.name) { continue; } _handleDeletion(context, mention); } if (isPaste || isUndoRedo) { if (context.mounted) { context.read().startHandlingPaste(_pasteIdentifier); } for (final mention in added) { if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { return; } if (mention.$2[MentionBlockKeys.type] != MentionType.date.name) { continue; } _handleAddition( context, viewId, editorState, mention, isPaste, isCut, ); } if (context.mounted) { context.read().endHandlingPaste(_pasteIdentifier); } } } void _handleDeletion( BuildContext context, MentionBlockData data, ) { final reminderId = data.$2[MentionBlockKeys.reminderId]; if (reminderId case String _ when reminderId.isNotEmpty) { getIt() .add(ReminderEvent.removeReminder(reminderId: reminderId)); } } void _handleAddition( BuildContext context, String viewId, EditorState editorState, MentionBlockData data, bool isPaste, bool isCut, ) { final dateData = _MentionDateBlockData.fromData(data.$2); if (dateData.dateString.isEmpty) { Log.error("mention date block doesn't have a valid date string"); return; } if (isPaste && !isCut) { _handlePasteFromCopy( context, viewId, editorState, data.$1, data.$3, dateData, ); } else { _handlePasteFromCut(viewId, data.$1, dateData); } } void _handlePasteFromCut( String viewId, Node node, _MentionDateBlockData data, ) { final dateTime = DateTime.tryParse(data.dateString); if (data.reminderId == null || dateTime == null) { return; } getIt().add( ReminderEvent.addById( reminderId: data.reminderId!, objectId: viewId, scheduledAt: Int64( data.reminderOption .getNotificationDateTime(dateTime) .millisecondsSinceEpoch ~/ 1000, ), meta: { ReminderMetaKeys.includeTime: data.includeTime.toString(), ReminderMetaKeys.blockId: node.id, }, ), ); } void _handlePasteFromCopy( BuildContext context, String viewId, EditorState editorState, Node node, int index, _MentionDateBlockData data, ) async { final dateTime = DateTime.tryParse(data.dateString); if (node.delta == null) { return; } if (data.reminderId == null || dateTime == null) { return; } final reminderId = nanoid(); getIt().add( ReminderEvent.addById( reminderId: reminderId, objectId: viewId, scheduledAt: Int64( data.reminderOption .getNotificationDateTime(dateTime) .millisecondsSinceEpoch ~/ 1000, ), meta: { ReminderMetaKeys.includeTime: data.includeTime.toString(), ReminderMetaKeys.blockId: node.id, }, ), ); final newMentionAttributes = MentionBlockKeys.buildMentionDateAttributes( date: dateTime.toIso8601String(), reminderId: reminderId, reminderOption: data.reminderOption.name, includeTime: data.includeTime, ); // The index is the index of the delta, to get the index of the mention character // in all the text, we need to calculate it based on the deltas before the current delta. int mentionIndex = 0; for (final (i, delta) in node.delta!.indexed) { if (i >= index) { break; } mentionIndex += delta.length; } // Required to prevent editing the same spot at the same time await Future.delayed(const Duration(milliseconds: 100)); final transaction = editorState.transaction ..formatText( node, mentionIndex, MentionBlockKeys.mentionChar.length, newMentionAttributes, ); await editorState.apply( transaction, options: const ApplyOptions(recordUndo: false), ); } } /// A helper class to parse and store the mention date block data class _MentionDateBlockData { _MentionDateBlockData.fromData(Map data) { dateString = switch (data[MentionBlockKeys.date]) { final String string when DateTime.tryParse(string) != null => string, _ => "", }; includeTime = switch (data[MentionBlockKeys.includeTime]) { final bool flag => flag, _ => false, }; reminderOption = switch (data[MentionBlockKeys.reminderOption]) { final String name => ReminderOption.values.firstWhereOrNull((o) => o.name == name) ?? ReminderOption.none, _ => ReminderOption.none, }; reminderId = switch (data[MentionBlockKeys.reminderId]) { final String id when id.isNotEmpty && reminderOption != ReminderOption.none => id, _ => null, }; } late final String dateString; late final bool includeTime; late final String? reminderId; late final ReminderOption reminderOption; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'mention_link_block.dart'; enum MentionType { page, date, externalLink, childPage; static MentionType fromString(String value) => switch (value) { 'page' => page, 'date' => date, 'externalLink' => externalLink, 'childPage' => childPage, // Backwards compatibility 'reminder' => date, _ => throw UnimplementedError(), }; } Node dateMentionNode() { return paragraphNode( delta: Delta( operations: [ TextInsert( MentionBlockKeys.mentionChar, attributes: MentionBlockKeys.buildMentionDateAttributes( date: DateTime.now().toIso8601String(), reminderId: null, reminderOption: null, includeTime: false, ), ), ], ), ); } class MentionBlockKeys { const MentionBlockKeys._(); static const mention = 'mention'; static const type = 'type'; // MentionType, String static const pageId = 'page_id'; static const blockId = 'block_id'; static const url = 'url'; // Related to Reminder and Date blocks static const date = 'date'; // Start Date static const includeTime = 'include_time'; static const reminderId = 'reminder_id'; // ReminderID static const reminderOption = 'reminder_option'; static const mentionChar = '\$'; static Map buildMentionPageAttributes({ required MentionType mentionType, required String pageId, required String? blockId, }) { return { MentionBlockKeys.mention: { MentionBlockKeys.type: mentionType.name, MentionBlockKeys.pageId: pageId, if (blockId != null) MentionBlockKeys.blockId: blockId, }, }; } static Map buildMentionDateAttributes({ required String date, required String? reminderId, required String? reminderOption, required bool includeTime, }) { return { MentionBlockKeys.mention: { MentionBlockKeys.type: MentionType.date.name, MentionBlockKeys.date: date, MentionBlockKeys.includeTime: includeTime, if (reminderId != null) MentionBlockKeys.reminderId: reminderId, if (reminderOption != null) MentionBlockKeys.reminderOption: reminderOption, }, }; } } class MentionBlock extends StatelessWidget { const MentionBlock({ super.key, required this.mention, required this.node, required this.index, required this.textStyle, }); final Map mention; final Node node; final int index; final TextStyle? textStyle; @override Widget build(BuildContext context) { final type = MentionType.fromString(mention[MentionBlockKeys.type]); final editorState = context.read(); switch (type) { case MentionType.page: final String? pageId = mention[MentionBlockKeys.pageId] as String?; if (pageId == null) { return const SizedBox.shrink(); } final String? blockId = mention[MentionBlockKeys.blockId] as String?; return MentionPageBlock( key: ValueKey(pageId), editorState: editorState, pageId: pageId, blockId: blockId, node: node, textStyle: textStyle, index: index, ); case MentionType.childPage: final String? pageId = mention[MentionBlockKeys.pageId] as String?; if (pageId == null) { return const SizedBox.shrink(); } return MentionSubPageBlock( key: ValueKey(pageId), editorState: editorState, pageId: pageId, node: node, textStyle: textStyle, index: index, ); case MentionType.date: final String date = mention[MentionBlockKeys.date]; final reminderOption = ReminderOption.values.firstWhereOrNull( (o) => o.name == mention[MentionBlockKeys.reminderOption], ); return MentionDateBlock( key: ValueKey(date), editorState: editorState, date: date, node: node, textStyle: textStyle, index: index, reminderId: mention[MentionBlockKeys.reminderId], reminderOption: reminderOption ?? ReminderOption.none, includeTime: mention[MentionBlockKeys.includeTime] ?? false, ); case MentionType.externalLink: final String? url = mention[MentionBlockKeys.url] as String?; if (url == null) { return const SizedBox.shrink(); } return MentionLinkBlock( url: url, editorState: editorState, node: node, index: index, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/mobile_date_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nanoid/non_secure.dart'; import 'package:universal_platform/universal_platform.dart'; class MentionDateBlock extends StatefulWidget { const MentionDateBlock({ super.key, required this.editorState, required this.date, required this.index, required this.node, this.textStyle, this.reminderId, this.reminderOption = ReminderOption.none, this.includeTime = false, }); final EditorState editorState; final String date; final int index; final Node node; /// If [isReminder] is true, then this must not be /// null or empty final String? reminderId; final ReminderOption reminderOption; final bool includeTime; final TextStyle? textStyle; @override State createState() => _MentionDateBlockState(); } class _MentionDateBlockState extends State { late bool _includeTime = widget.includeTime; late DateTime? parsedDate = DateTime.tryParse(widget.date); late String? _reminderId = widget.reminderId; late ReminderOption _reminderOption = widget.reminderOption; ReminderPB? getReminder(BuildContext context) { if (!context.mounted || _reminderId == null) return null; final reminderBloc = context.read(); return reminderBloc?.state.allReminders .firstWhereOrNull((r) => r.id == _reminderId); } @override void didUpdateWidget(covariant oldWidget) { parsedDate = DateTime.tryParse(widget.date); if (widget.reminderId != oldWidget.reminderId) { _reminderId = widget.reminderId; } if (widget.includeTime != oldWidget.includeTime) { _includeTime = widget.includeTime; } if (widget.date != oldWidget.date) { parsedDate = DateTime.tryParse(widget.date); } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { if (parsedDate == null) { return const SizedBox.shrink(); } final appearance = context.read(); final reminder = context.read(); if (appearance == null || reminder == null) { return const SizedBox.shrink(); } return BlocBuilder( buildWhen: (previous, current) => previous.dateFormat != current.dateFormat || previous.timeFormat != current.timeFormat, builder: (context, appearance) => BlocBuilder( builder: (context, state) { final formattedDate = appearance.dateFormat .formatDate(parsedDate!, _includeTime, appearance.timeFormat); final options = DatePickerOptions( focusedDay: parsedDate, selectedDay: parsedDate, includeTime: _includeTime, dateFormat: appearance.dateFormat, timeFormat: appearance.timeFormat, selectedReminderOption: _reminderOption, onIncludeTimeChanged: (includeTime, dateTime, _) { _includeTime = includeTime; if (_reminderOption != ReminderOption.none) { _updateReminder( widget.reminderOption, context, includeTime, ); } else if (dateTime != null) { parsedDate = dateTime; _updateBlock( dateTime, includeTime: includeTime, ); } }, onDaySelected: (selectedDay) { parsedDate = selectedDay; if (_reminderOption != ReminderOption.none) { _updateReminder( _reminderOption, context, _includeTime, ); } else { final rootContext = widget.editorState.document.root.context; if (rootContext != null && _reminderId != null) { rootContext.read()?.add( ReminderEvent.removeReminder(reminderId: _reminderId!), ); } _updateBlock(selectedDay, includeTime: _includeTime); } }, onReminderSelected: (reminderOption) { _reminderOption = reminderOption; _updateReminder(reminderOption, context, _includeTime); }, ); Color? color; final reminder = getReminder(context); if (reminder != null) { if (reminder.type == ReminderType.today) { color = Theme.of(context).isLightMode ? const Color(0xFFFE0299) : Theme.of(context).colorScheme.error; } } final textStyle = widget.textStyle?.copyWith( color: color, leadingDistribution: TextLeadingDistribution.even, ); // when font size equals 14, the icon size is 16.0. // scale the icon size based on the font size. final iconSize = (widget.textStyle?.fontSize ?? 14.0) / 14.0 * 16.0; return GestureDetector( onTapDown: (details) { _showDatePicker( context: context, offset: details.globalPosition, options: options, ); }, child: MouseRegion( cursor: SystemMouseCursors.click, child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( '@$formattedDate', style: textStyle, strutStyle: textStyle != null ? StrutStyle.fromTextStyle(textStyle) : null, ), const HSpace(4), FlowySvg( _reminderId != null ? FlowySvgs.reminder_clock_s : FlowySvgs.date_s, size: Size.square(iconSize), color: textStyle?.color, ), ], ), ), ); }, ), ); } void _updateBlock( DateTime date, { required bool includeTime, String? reminderId, ReminderOption? reminderOption, }) { final rId = reminderId ?? (reminderOption == ReminderOption.none ? null : _reminderId); final transaction = widget.editorState.transaction ..formatText( widget.node, widget.index, 1, MentionBlockKeys.buildMentionDateAttributes( date: date.toIso8601String(), reminderId: rId, includeTime: includeTime, reminderOption: reminderOption?.name ?? widget.reminderOption.name, ), ); widget.editorState.apply(transaction, withUpdateSelection: false); // Length of rendered block changes, this synchronizes // the cursor with the new block render widget.editorState.updateSelectionWithReason( widget.editorState.selection, ); } void _updateReminder( ReminderOption reminderOption, BuildContext context, [ bool includeTime = false, ]) { final rootContext = widget.editorState.document.root.context; if (parsedDate == null || rootContext == null) { return; } final reminder = getReminder(rootContext); if (reminder != null) { _updateBlock( parsedDate!, includeTime: includeTime, reminderOption: reminderOption, ); if (ReminderOption.none == reminderOption) { // Delete existing reminder return rootContext .read() .add(ReminderEvent.removeReminder(reminderId: reminder.id)); } // Update existing reminder return rootContext.read().add( ReminderEvent.update( ReminderUpdate( id: reminder.id, scheduledAt: reminderOption.getNotificationDateTime(parsedDate!), date: parsedDate!, ), ), ); } _reminderId ??= nanoid(); final reminderId = _reminderId; _updateBlock( parsedDate!, includeTime: includeTime, reminderId: reminderId, reminderOption: reminderOption, ); // Add new reminder final viewId = rootContext.read().documentId; return rootContext.read().add( ReminderEvent.add( reminder: ReminderPB( id: reminderId, objectId: viewId, title: LocaleKeys.reminderNotification_title.tr(), message: LocaleKeys.reminderNotification_message.tr(), meta: { ReminderMetaKeys.includeTime: false.toString(), ReminderMetaKeys.blockId: widget.node.id, ReminderMetaKeys.createdAt: DateTime.now().millisecondsSinceEpoch.toString(), }, scheduledAt: Int64(parsedDate!.millisecondsSinceEpoch ~/ 1000), isAck: parsedDate!.isBefore(DateTime.now()), ), ), ); } void _showDatePicker({ required BuildContext context, required DatePickerOptions options, required Offset offset, }) { if (!widget.editorState.editable) { return; } if (UniversalPlatform.isMobile) { SystemChannels.textInput.invokeMethod('TextInput.hide'); showMobileBottomSheet( context, builder: (_) => DraggableScrollableSheet( expand: false, snap: true, initialChildSize: 0.7, minChildSize: 0.4, snapSizes: const [0.4, 0.7, 1.0], builder: (_, controller) => _DatePickerBottomSheet( controller: controller, parsedDate: parsedDate, options: options, includeTime: _includeTime, reminderOption: widget.reminderOption, onReminderSelected: (option) => _updateReminder(option, context), ), ), ); } else { DatePickerMenu( context: context, editorState: widget.editorState, ).show(offset, options: options); } } } class _DatePickerBottomSheet extends StatelessWidget { const _DatePickerBottomSheet({ required this.controller, required this.parsedDate, required this.options, required this.includeTime, required this.reminderOption, required this.onReminderSelected, }); final ScrollController controller; final DateTime? parsedDate; final DatePickerOptions options; final bool includeTime; final ReminderOption reminderOption; final void Function(ReminderOption) onReminderSelected; @override Widget build(BuildContext context) { return Material( color: Theme.of(context).colorScheme.secondaryContainer, child: ListView( controller: controller, children: [ ColoredBox( color: Theme.of(context).colorScheme.surface, child: const Center(child: DragHandle()), ), const MobileDateHeader(), MobileAppFlowyDatePicker( dateTime: parsedDate, includeTime: includeTime, isRange: options.isRange, dateFormat: options.dateFormat.simplified, timeFormat: options.timeFormat.simplified, reminderOption: reminderOption, onDaySelected: options.onDaySelected, onIncludeTimeChanged: options.onIncludeTimeChanged, onReminderSelected: onReminderSelected, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart ================================================ import 'dart:async'; import 'dart:math'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:universal_platform/universal_platform.dart'; import 'mention_link_error_preview.dart'; import 'mention_link_preview.dart'; class MentionLinkBlock extends StatefulWidget { const MentionLinkBlock({ super.key, required this.url, required this.editorState, required this.node, required this.index, this.delayToShow = const Duration(milliseconds: 50), this.delayToHide = const Duration(milliseconds: 300), }); final String url; final Duration delayToShow; final Duration delayToHide; final EditorState editorState; final Node node; final int index; @override State createState() => _MentionLinkBlockState(); } class _MentionLinkBlockState extends State { final parser = LinkParser(); _LoadingStatus status = _LoadingStatus.loading; late LinkInfo linkInfo = LinkInfo(url: url); final previewController = PopoverController(); bool isHovering = false; int previewFocusNum = 0; bool isPreviewHovering = false; bool showAtBottom = false; final key = GlobalKey(); bool get isPreviewShowing => previewFocusNum > 0; String get url => widget.url; EditorState get editorState => widget.editorState; bool get editable => editorState.editable; Node get node => widget.node; int get index => widget.index; bool get readyForPreview => status == _LoadingStatus.idle && !linkInfo.isEmpty(); @override void initState() { super.initState(); parser.addLinkInfoListener((v) { final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty(); if (mounted) { setState(() { if (hasNewInfo) { linkInfo = v; status = _LoadingStatus.idle; } else if (!hasOldInfo) { status = _LoadingStatus.error; } }); } }); parser.start(url); } @override void dispose() { super.dispose(); parser.dispose(); previewController.close(); } @override Widget build(BuildContext context) { final child = buildIconWithTitle(context); if (UniversalPlatform.isMobile) return child; return AppFlowyPopover( key: ValueKey(showAtBottom), controller: previewController, direction: showAtBottom ? PopoverDirection.bottomWithLeftAligned : PopoverDirection.topWithLeftAligned, offset: Offset(0, showAtBottom ? -20 : 20), onOpen: () { keepEditorFocusNotifier.increase(); previewFocusNum++; }, onClose: () { keepEditorFocusNotifier.decrease(); previewFocusNum--; }, decorationColor: Colors.transparent, popoverDecoration: BoxDecoration(), margin: EdgeInsets.zero, constraints: getConstraints(), borderRadius: BorderRadius.circular(16), popupBuilder: (context) => readyForPreview ? MentionLinkPreview( linkInfo: linkInfo, editable: editable, showAtBottom: showAtBottom, triggerSize: getSizeFromKey(), onEnter: (e) { isPreviewHovering = true; }, onExit: (e) { isPreviewHovering = false; tryToDismissPreview(); }, onCopyLink: () => copyLink(context), onConvertTo: (s) => convertTo(s), onRemoveLink: removeLink, onOpenLink: openLink, ) : MentionLinkErrorPreview( url: url, editable: editable, triggerSize: getSizeFromKey(), onEnter: (e) { isPreviewHovering = true; }, onExit: (e) { isPreviewHovering = false; tryToDismissPreview(); }, onCopyLink: () => copyLink(context), onConvertTo: (s) => convertTo(s), onRemoveLink: removeLink, onOpenLink: openLink, ), child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: onEnter, onExit: onExit, child: child, ), ); } Widget buildIconWithTitle(BuildContext context) { final theme = AppFlowyTheme.of(context); final siteName = linkInfo.siteName, linkTitle = linkInfo.title ?? url; return GestureDetector( onTap: () async { await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); }, child: FlowyHoverContainer( style: HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), applyStyle: isHovering, child: Row( mainAxisSize: MainAxisSize.min, key: key, children: [ HSpace(2), buildIcon(), HSpace(4), Flexible( child: RichText( overflow: TextOverflow.ellipsis, text: TextSpan( children: [ if (siteName != null) ...[ TextSpan( text: siteName, style: theme.textStyle.body .standard(color: theme.textColorScheme.secondary), ), WidgetSpan(child: HSpace(2)), ], TextSpan( text: linkTitle, style: theme.textStyle.body .standard(color: theme.textColorScheme.primary), ), ], ), ), ), HSpace(2), ], ), ), ); } Widget buildIcon() { const defaultWidget = FlowySvg(FlowySvgs.toolbar_link_earth_m); Widget icon = defaultWidget; if (status == _LoadingStatus.loading) { icon = Padding( padding: const EdgeInsets.all(2.0), child: const CircularProgressIndicator(strokeWidth: 1), ); } else { icon = linkInfo.buildIconWidget(); } return SizedBox( height: 20, width: 20, child: icon, ); } RenderBox? get box => key.currentContext?.findRenderObject() as RenderBox?; Size getSizeFromKey() => box?.size ?? Size.zero; Future copyLink(BuildContext context) async { await context.copyLink(url); previewController.close(); } Future openLink() async { await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true); } Future removeLink() async { final transaction = editorState.transaction ..replaceText(widget.node, widget.index, 1, url, attributes: {}); await editorState.apply(transaction); } Future convertTo(PasteMenuType type) async { if (type == PasteMenuType.url) { await toUrl(); } else if (type == PasteMenuType.bookmark) { await toLinkPreview(); } else if (type == PasteMenuType.embed) { await toLinkPreview(previewType: LinkEmbedKeys.embed); } } Future toUrl() async { final transaction = editorState.transaction ..replaceText( widget.node, widget.index, 1, url, attributes: { AppFlowyRichTextKeys.href: url, }, ); await editorState.apply(transaction); } Future toLinkPreview({String? previewType}) async { final selection = Selection( start: Position(path: node.path, offset: index), end: Position(path: node.path, offset: index + 1), ); await convertUrlToLinkPreview( editorState, selection, url, previewType: previewType, ); } void changeHovering(bool hovering) { if (isHovering == hovering) return; if (mounted) { setState(() { isHovering = hovering; }); } } void changeShowAtBottom(bool bottom) { if (showAtBottom == bottom) return; if (mounted) { setState(() { showAtBottom = bottom; }); } } void tryToDismissPreview() { Future.delayed(widget.delayToHide, () { if (isHovering || isPreviewHovering) { return; } previewController.close(); }); } void onEnter(PointerEnterEvent e) { changeHovering(true); final location = box?.localToGlobal(Offset.zero) ?? Offset.zero; if (readyForPreview) { if (location.dy < 300) { changeShowAtBottom(true); } else { changeShowAtBottom(false); } } Future.delayed(widget.delayToShow, () { if (isHovering && !isPreviewShowing && status != _LoadingStatus.loading) { showPreview(); } }); } void onExit(PointerExitEvent e) { changeHovering(false); tryToDismissPreview(); } void showPreview() { if (!mounted) return; keepEditorFocusNotifier.increase(); previewController.show(); previewFocusNum++; } BoxConstraints getConstraints() { final size = getSizeFromKey(); if (!readyForPreview) { return BoxConstraints( maxWidth: max(320, size.width), maxHeight: 48 + size.height, ); } final hasImage = linkInfo.imageUrl?.isNotEmpty ?? false; return BoxConstraints( maxWidth: max(300, size.width), maxHeight: hasImage ? 300 : 180, ); } } enum _LoadingStatus { loading, idle, error, } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart ================================================ import 'dart:math'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class MentionLinkErrorPreview extends StatefulWidget { const MentionLinkErrorPreview({ super.key, required this.url, required this.onEnter, required this.onExit, required this.onCopyLink, required this.onRemoveLink, required this.onConvertTo, required this.onOpenLink, required this.triggerSize, required this.editable, }); final String url; final PointerEnterEventListener onEnter; final PointerExitEventListener onExit; final VoidCallback onCopyLink; final VoidCallback onRemoveLink; final VoidCallback onOpenLink; final ValueChanged onConvertTo; final Size triggerSize; final bool editable; @override State createState() => _MentionLinkErrorPreviewState(); } class _MentionLinkErrorPreviewState extends State { final menuController = PopoverController(); bool isConvertButtonSelected = false; @override void dispose() { super.dispose(); menuController.close(); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MouseRegion( onEnter: widget.onEnter, onExit: widget.onExit, child: SizedBox( width: max(320, widget.triggerSize.width), height: 48, child: Align( alignment: Alignment.centerLeft, child: Container( width: 320, height: 48, decoration: buildToolbarLinkDecoration(context), padding: EdgeInsets.fromLTRB(12, 8, 8, 8), child: Row( children: [ Expanded(child: buildLinkWidget()), Container( height: 20, width: 1, color: Color(0xffE8ECF3) .withAlpha(Theme.of(context).isLightMode ? 255 : 40), margin: EdgeInsets.symmetric(horizontal: 6), ), FlowyIconButton( icon: FlowySvg(FlowySvgs.toolbar_link_m), tooltipText: LocaleKeys.editor_copyLink.tr(), preferBelow: false, width: 36, height: 32, onPressed: widget.onCopyLink, ), buildConvertButton(), ], ), ), ), ), ), MouseRegion( cursor: SystemMouseCursors.click, onEnter: widget.onEnter, onExit: widget.onExit, child: GestureDetector( onTap: widget.onOpenLink, child: Container( width: widget.triggerSize.width, height: widget.triggerSize.height, color: Colors.black.withAlpha(1), ), ), ), ], ); } Widget buildLinkWidget() { final url = widget.url; return FlowyTooltip( message: url, preferBelow: false, child: FlowyText.regular( url, overflow: TextOverflow.ellipsis, figmaLineHeight: 20, fontSize: 14, ), ); } Widget buildConvertButton() { return AppFlowyPopover( offset: Offset(8, 10), direction: PopoverDirection.bottomWithRightAligned, margin: EdgeInsets.zero, controller: menuController, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () => keepEditorFocusNotifier.decrease(), popupBuilder: (context) => buildConvertMenu(), child: FlowyIconButton( icon: FlowySvg(FlowySvgs.turninto_m), isSelected: isConvertButtonSelected, tooltipText: LocaleKeys.editor_convertTo.tr(), preferBelow: false, width: 36, height: 32, onPressed: () { setState(() { isConvertButtonSelected = true; }); showPopover(); }, ), ); } Widget buildConvertMenu() { return MouseRegion( onEnter: widget.onEnter, onExit: widget.onExit, child: Padding( padding: const EdgeInsets.all(8.0), child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(0.0), children: List.generate(MentionLinktErrorMenuCommand.values.length, (index) { final command = MentionLinktErrorMenuCommand.values[index]; return SizedBox( height: 36, child: FlowyButton( text: FlowyText( command.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), onTap: () => onTap(command), ), ); }), ), ), ); } void showPopover() { keepEditorFocusNotifier.increase(); menuController.show(); } void closePopover() { menuController.close(); } void onTap(MentionLinktErrorMenuCommand command) { switch (command) { case MentionLinktErrorMenuCommand.toURL: widget.onConvertTo(PasteMenuType.url); break; case MentionLinktErrorMenuCommand.toBookmark: widget.onConvertTo(PasteMenuType.bookmark); break; case MentionLinktErrorMenuCommand.toEmbed: widget.onConvertTo(PasteMenuType.embed); break; case MentionLinktErrorMenuCommand.removeLink: widget.onRemoveLink(); break; } closePopover(); } } enum MentionLinktErrorMenuCommand { toURL, toBookmark, toEmbed, removeLink; String get title { switch (this) { case toURL: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl .tr(); case toBookmark: return LocaleKeys .document_plugins_linkPreview_linkPreviewMenu_toBookmark .tr(); case toEmbed: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed .tr(); case removeLink: return LocaleKeys .document_plugins_linkPreview_linkPreviewMenu_removeLink .tr(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class MentionLinkPreview extends StatefulWidget { const MentionLinkPreview({ super.key, required this.linkInfo, required this.onEnter, required this.onExit, required this.onCopyLink, required this.onRemoveLink, required this.onConvertTo, required this.onOpenLink, required this.triggerSize, required this.showAtBottom, required this.editable, }); final LinkInfo linkInfo; final PointerEnterEventListener onEnter; final PointerExitEventListener onExit; final VoidCallback onCopyLink; final VoidCallback onRemoveLink; final VoidCallback onOpenLink; final ValueChanged onConvertTo; final Size triggerSize; final bool showAtBottom; final bool editable; @override State createState() => _MentionLinkPreviewState(); } class _MentionLinkPreviewState extends State { final menuController = PopoverController(); bool isSelected = false; LinkInfo get linkInfo => widget.linkInfo; bool get editable => widget.editable; @override void dispose() { super.dispose(); menuController.close(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context), textColorScheme = theme.textColorScheme; final imageUrl = linkInfo.imageUrl ?? '', description = linkInfo.description ?? ''; final imageHeight = 120.0; final card = MouseRegion( onEnter: widget.onEnter, onExit: widget.onExit, child: Container( decoration: buildToolbarLinkDecoration(context, radius: 16), width: 280, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (imageUrl.isNotEmpty) ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), child: FlowyNetworkImage( url: linkInfo.imageUrl ?? '', width: 280, height: imageHeight, ), ), VSpace(12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: FlowyText.semibold( linkInfo.title ?? linkInfo.siteName ?? '', fontSize: 14, figmaLineHeight: 20, color: textColorScheme.primary, overflow: TextOverflow.ellipsis, ), ), VSpace(4), if (description.isNotEmpty) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: FlowyText( description, fontSize: 12, figmaLineHeight: 16, color: textColorScheme.secondary, maxLines: 3, overflow: TextOverflow.ellipsis, ), ), VSpace(36), ], Container( margin: const EdgeInsets.symmetric(horizontal: 16), height: 28, child: Row( children: [ linkInfo.buildIconWidget(size: Size.square(16)), HSpace(6), Expanded( child: FlowyText( linkInfo.siteName ?? linkInfo.url, fontSize: 12, figmaLineHeight: 16, color: textColorScheme.primary, overflow: TextOverflow.ellipsis, fontWeight: FontWeight.w700, ), ), buildMoreOptionButton(), ], ), ), VSpace(12), ], ), ), ); final clickPlaceHolder = MouseRegion( cursor: SystemMouseCursors.click, onEnter: widget.onEnter, onExit: widget.onExit, child: GestureDetector( child: Container( height: 20, width: widget.triggerSize.width, color: Colors.white.withAlpha(1), ), onTap: () { widget.onOpenLink.call(); closePopover(); }, ), ); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: widget.showAtBottom ? [clickPlaceHolder, card] : [card, clickPlaceHolder], ); } Widget buildMoreOptionButton() { return AppFlowyPopover( controller: menuController, direction: PopoverDirection.topWithLeftAligned, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () => keepEditorFocusNotifier.decrease(), margin: EdgeInsets.zero, borderRadius: BorderRadius.circular(12), popupBuilder: (context) => buildConvertMenu(), child: FlowyIconButton( width: 28, height: 28, isSelected: isSelected, hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), icon: FlowySvg( FlowySvgs.toolbar_more_m, size: Size.square(20), ), onPressed: () { setState(() { isSelected = true; }); showPopover(); }, ), ); } Widget buildConvertMenu() { return MouseRegion( onEnter: widget.onEnter, onExit: widget.onExit, child: Padding( padding: const EdgeInsets.all(8.0), child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(0.0), children: List.generate(MentionLinktMenuCommand.values.length, (index) { final command = MentionLinktMenuCommand.values[index]; final isCopyCommand = command == MentionLinktMenuCommand.copyLink; final enableButton = editable || (!editable && isCopyCommand); return SizedBox( height: 36, child: FlowyButton( hoverColor: enableButton ? null : Colors.transparent, text: FlowyText( command.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), onTap: enableButton ? () => onTap(command) : null, ), ); }), ), ), ); } void showPopover() { keepEditorFocusNotifier.increase(); menuController.show(); } void closePopover() { menuController.close(); } void onTap(MentionLinktMenuCommand command) { switch (command) { case MentionLinktMenuCommand.toURL: widget.onConvertTo(PasteMenuType.url); break; case MentionLinktMenuCommand.toBookmark: widget.onConvertTo(PasteMenuType.bookmark); break; case MentionLinktMenuCommand.toEmbed: widget.onConvertTo(PasteMenuType.embed); break; case MentionLinktMenuCommand.copyLink: widget.onCopyLink(); break; case MentionLinktMenuCommand.removeLink: widget.onRemoveLink(); break; } closePopover(); } } enum MentionLinktMenuCommand { toURL, toBookmark, toEmbed, copyLink, removeLink; String get title { switch (this) { case toURL: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl .tr(); case toBookmark: return LocaleKeys .document_plugins_linkPreview_linkPreviewMenu_toBookmark .tr(); case toEmbed: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed .tr(); case copyLink: return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink .tr(); case removeLink: return LocaleKeys .document_plugins_linkPreview_linkPreviewMenu_removeLink .tr(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_listener.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'mention_page_bloc.freezed.dart'; typedef MentionPageStatus = (ViewPB? view, bool isInTrash, bool isDeleted); class MentionPageBloc extends Bloc { MentionPageBloc({ required this.pageId, this.blockId, bool isSubPage = false, }) : _isSubPage = isSubPage, super(MentionPageState.initial()) { on( (event, emit) async { await event.when( initial: () async { final (view, isInTrash, isDeleted) = await ViewBackendService.getMentionPageStatus(pageId); String? blockContent; if (!_isSubPage) { blockContent = await _getBlockContent(); } emit( state.copyWith( view: view, isLoading: false, isInTrash: isInTrash, isDeleted: isDeleted, blockContent: blockContent ?? '', ), ); if (view != null) { _startListeningView(); _startListeningTrash(); if (!_isSubPage) { _startListeningDocument(); } } }, didUpdateViewStatus: (view, isDeleted) async { emit( state.copyWith( view: view, isDeleted: isDeleted ?? state.isDeleted, ), ); }, didUpdateTrashStatus: (isInTrash) async => emit(state.copyWith(isInTrash: isInTrash)), didUpdateBlockContent: (content) { emit( state.copyWith( blockContent: content, ), ); }, ); }, ); } @override Future close() { _viewListener?.stop(); _trashListener?.close(); _documentListener?.stop(); return super.close(); } final _documentService = DocumentService(); final String pageId; final String? blockId; final bool _isSubPage; ViewListener? _viewListener; TrashListener? _trashListener; DocumentListener? _documentListener; BlockPB? _block; String? _blockTextId; Delta? _initialDelta; void _startListeningView() { _viewListener = ViewListener(viewId: pageId) ..start( onViewUpdated: (view) => add( MentionPageEvent.didUpdateViewStatus(view: view, isDeleted: false), ), onViewDeleted: (_) => add(const MentionPageEvent.didUpdateViewStatus(isDeleted: true)), ); } void _startListeningTrash() { _trashListener = TrashListener() ..start( trashUpdated: (trashOrFailed) { final trash = trashOrFailed.toNullable(); if (trash != null) { final isInTrash = trash.any((t) => t.id == pageId); if (!isClosed) { add(MentionPageEvent.didUpdateTrashStatus(isInTrash: isInTrash)); } } }, ); } Future _convertDeltaToText(Delta? delta) async { if (delta == null) { return _initialDelta?.toPlainText() ?? ''; } return delta.toText( getMentionPageName: (mentionedPageId) async { if (mentionedPageId == pageId) { // if the mention page is the current page, return the view name return state.view?.name ?? ''; } else { // if the mention page is not the current page, return the mention page name final viewResult = await ViewBackendService.getView(mentionedPageId); final name = viewResult.fold((l) => l.name, (f) => ''); return name; } }, ); } Future _getBlockContent() async { if (blockId == null) { return null; } final documentNodeResult = await _documentService.getDocumentNode( documentId: pageId, blockId: blockId!, ); final documentNode = documentNodeResult.fold((l) => l, (f) => null); if (documentNode == null) { Log.error( 'unable to get the document node for block $blockId in page $pageId', ); return null; } final block = documentNode.$2; final node = documentNode.$3; _blockTextId = (node.externalValues as ExternalValues?)?.externalId; _initialDelta = node.delta; _block = block; return _convertDeltaToText(_initialDelta); } void _startListeningDocument() { // only observe the block content if the block id is not null if (blockId == null || _blockTextId == null || _initialDelta == null || _block == null) { return; } _documentListener = DocumentListener(id: pageId) ..start( onDocEventUpdate: (docEvent) { for (final block in docEvent.events) { for (final event in block.event) { if (event.id == _blockTextId) { if (event.command == DeltaTypePB.Updated) { _updateBlockContent(event.value); } else if (event.command == DeltaTypePB.Removed) { add(const MentionPageEvent.didUpdateBlockContent('')); } } } } }, ); } Future _updateBlockContent(String deltaJson) async { if (_initialDelta == null || _block == null) { return; } try { final incremental = Delta.fromJson(jsonDecode(deltaJson)); final delta = _initialDelta!.compose(incremental); final content = await _convertDeltaToText(delta); add(MentionPageEvent.didUpdateBlockContent(content)); _initialDelta = delta; } catch (e) { Log.error('failed to update block content: $e'); } } } @freezed class MentionPageEvent with _$MentionPageEvent { const factory MentionPageEvent.initial() = _Initial; const factory MentionPageEvent.didUpdateViewStatus({ @Default(null) ViewPB? view, @Default(null) bool? isDeleted, }) = _DidUpdateViewStatus; const factory MentionPageEvent.didUpdateTrashStatus({ required bool isInTrash, }) = _DidUpdateTrashStatus; const factory MentionPageEvent.didUpdateBlockContent( String content, ) = _DidUpdateBlockContent; } @freezed class MentionPageState with _$MentionPageState { const factory MentionPageState({ required bool isLoading, required bool isInTrash, required bool isDeleted, // non-null case: // - page is found // - page is in trash // null case: // - page is deleted required ViewPB? view, // the plain text content of the block // it doesn't contain any formatting required String blockContent, }) = _MentionSubPageState; factory MentionPageState.initial() => const MentionPageState( isLoading: true, isInTrash: false, isDeleted: false, view: null, blockContent: '', ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' show ApplyOptions, Delta, EditorState, Node, NodeIterator, Path, Position, Selection, SelectionType, TextInsert, TextTransaction, paragraphNode; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; final pageMemorizer = {}; Node pageMentionNode(String viewId) { return paragraphNode( delta: Delta( operations: [ TextInsert( MentionBlockKeys.mentionChar, attributes: MentionBlockKeys.buildMentionPageAttributes( mentionType: MentionType.page, pageId: viewId, blockId: null, ), ), ], ), ); } class ReferenceState { ReferenceState(this.isReference); final bool isReference; } class MentionPageBlock extends StatefulWidget { const MentionPageBlock({ super.key, required this.editorState, required this.pageId, required this.blockId, required this.node, required this.textStyle, required this.index, }); final EditorState editorState; final String pageId; final String? blockId; final Node node; final TextStyle? textStyle; // Used to update the block final int index; @override State createState() => _MentionPageBlockState(); } class _MentionPageBlockState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (_) => MentionPageBloc( pageId: widget.pageId, blockId: widget.blockId, )..add(const MentionPageEvent.initial()), child: BlocBuilder( builder: (context, state) { final view = state.view; if (state.isLoading) { return const SizedBox.shrink(); } if (state.isDeleted || view == null) { return _NoAccessMentionPageBlock( textStyle: widget.textStyle, ); } if (UniversalPlatform.isMobile) { return _MobileMentionPageBlock( view: view, content: state.blockContent, textStyle: widget.textStyle, handleTap: () => handleMentionBlockTap( context, widget.editorState, view, blockId: widget.blockId, ), handleDoubleTap: () => _handleDoubleTap( context, widget.editorState, view.id, widget.node, widget.index, ), ); } else { return _DesktopMentionPageBlock( view: view, content: state.blockContent, textStyle: widget.textStyle, showTrashHint: state.isInTrash, handleTap: () => handleMentionBlockTap( context, widget.editorState, view, blockId: widget.blockId, ), ); } }, ), ); } void updateSelection() { WidgetsBinding.instance.addPostFrameCallback( (_) => widget.editorState .updateSelectionWithReason(widget.editorState.selection), ); } } class MentionSubPageBlock extends StatefulWidget { const MentionSubPageBlock({ super.key, required this.editorState, required this.pageId, required this.node, required this.textStyle, required this.index, }); final EditorState editorState; final String pageId; final Node node; final TextStyle? textStyle; // Used to update the block final int index; @override State createState() => _MentionSubPageBlockState(); } class _MentionSubPageBlockState extends State { late bool isHandlingPaste = context.read().isHandlingPaste; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => MentionPageBloc(pageId: widget.pageId, isSubPage: true) ..add(const MentionPageEvent.initial()), child: BlocConsumer( listener: (context, state) async { if (state.view != null) { final currentViewId = getIt().latestOpenView?.id; if (currentViewId == null) { return; } if (state.view!.parentViewId != currentViewId) { SchedulerBinding.instance.addPostFrameCallback((_) { if (context.mounted) { turnIntoPageRef(); } }); } } }, builder: (context, state) { final view = state.view; if (state.isLoading || isHandlingPaste) { return const SizedBox.shrink(); } if (state.isDeleted || view == null) { return _DeletedPageBlock(textStyle: widget.textStyle); } if (UniversalPlatform.isMobile) { return _MobileMentionPageBlock( view: view, showTrashHint: state.isInTrash, textStyle: widget.textStyle, handleTap: () => handleMentionBlockTap(context, widget.editorState, view), isChildPage: true, content: '', handleDoubleTap: () => _handleDoubleTap( context, widget.editorState, view.id, widget.node, widget.index, ), ); } else { return _DesktopMentionPageBlock( view: view, showTrashHint: state.isInTrash, content: null, textStyle: widget.textStyle, isChildPage: true, handleTap: () => handleMentionBlockTap(context, widget.editorState, view), ); } }, ), ); } Future fetchView(String pageId) async { final view = await ViewBackendService.getView(pageId).then( (value) => value.toNullable(), ); if (view == null) { // try to fetch from trash final trashViews = await TrashService().readTrash(); final trash = trashViews.fold( (l) => l.items.firstWhereOrNull((element) => element.id == pageId), (r) => null, ); if (trash != null) { return ViewPB() ..id = trash.id ..name = trash.name; } } return view; } void updateSelection() { WidgetsBinding.instance.addPostFrameCallback( (_) => widget.editorState .updateSelectionWithReason(widget.editorState.selection), ); } void turnIntoPageRef() { final transaction = widget.editorState.transaction ..formatText( widget.node, widget.index, MentionBlockKeys.mentionChar.length, MentionBlockKeys.buildMentionPageAttributes( mentionType: MentionType.page, pageId: widget.pageId, blockId: null, ), ); widget.editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions( recordUndo: false, ), ); } } Path? _findNodePathByBlockId(EditorState editorState, String blockId) { final document = editorState.document; final startNode = document.root.children.firstOrNull; if (startNode == null) { return null; } final nodeIterator = NodeIterator( document: document, startNode: startNode, ); while (nodeIterator.moveNext()) { final node = nodeIterator.current; if (node.id == blockId) { return node.path; } } return null; } Future handleMentionBlockTap( BuildContext context, EditorState editorState, ViewPB view, { String? blockId, }) async { final currentViewId = context.read().documentId; if (currentViewId == view.id && blockId != null) { // same page final path = _findNodePathByBlockId(editorState, blockId); if (path != null) { editorState.scrollService?.jumpTo(path.first); await editorState.updateSelectionWithReason( Selection.collapsed(Position(path: path)), customSelectionType: SelectionType.block, ); } return; } if (UniversalPlatform.isMobile) { if (context.mounted && currentViewId != view.id) { await context.pushView( view, blockId: blockId, tabs: [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ].map((e) => e.name).toList(), ); } } else { final action = NavigationAction( objectId: view.id, arguments: { ActionArgumentKeys.view: view, ActionArgumentKeys.blockId: blockId, }, ); getIt().add( ActionNavigationEvent.performAction( action: action, ), ); } } Future _handleDoubleTap( BuildContext context, EditorState editorState, String viewId, Node node, int index, ) async { if (!UniversalPlatform.isMobile) { return; } final currentViewId = context.read().documentId; final newView = await showPageSelectorSheet( context, currentViewId: currentViewId, selectedViewId: viewId, ); if (newView != null) { // Update this nodes pageId final transaction = editorState.transaction ..formatText( node, index, 1, MentionBlockKeys.buildMentionPageAttributes( mentionType: MentionType.page, pageId: newView.id, blockId: null, ), ); await editorState.apply(transaction, withUpdateSelection: false); } } class _MentionPageBlockContent extends StatelessWidget { const _MentionPageBlockContent({ required this.view, required this.textStyle, this.content, this.showTrashHint = false, this.isChildPage = false, }); final ViewPB view; final TextStyle? textStyle; final String? content; final bool showTrashHint; final bool isChildPage; @override Widget build(BuildContext context) { final text = _getDisplayText(context, view, content); return Row( mainAxisSize: MainAxisSize.min, children: [ ..._buildPrefixIcons(context, view, content, isChildPage), const HSpace(4), Flexible( child: FlowyText( text, decoration: TextDecoration.underline, fontSize: textStyle?.fontSize, fontWeight: textStyle?.fontWeight, lineHeight: textStyle?.height, overflow: TextOverflow.ellipsis, ), ), if (showTrashHint) ...[ FlowyText( LocaleKeys.document_mention_trashHint.tr(), fontSize: textStyle?.fontSize, fontWeight: textStyle?.fontWeight, lineHeight: textStyle?.height, color: Theme.of(context).disabledColor, decoration: TextDecoration.underline, decorationColor: AFThemeExtension.of(context).textColor, ), ], const HSpace(4), ], ); } List _buildPrefixIcons( BuildContext context, ViewPB view, String? content, bool isChildPage, ) { final isSameDocument = _isSameDocument(context, view.id); final shouldDisplayViewName = _shouldDisplayViewName( context, view.id, content, ); final isBlockContentEmpty = content == null || content.isEmpty; final emojiSize = textStyle?.fontSize ?? 12.0; final iconSize = textStyle?.fontSize ?? 16.0; // if the block is from the same doc, display the paragraph mark icon '¶' if (isSameDocument && !isBlockContentEmpty) { return [ const HSpace(2), FlowySvg( FlowySvgs.paragraph_mark_s, size: Size.square(iconSize - 2.0), color: Theme.of(context).hintColor, ), ]; } else if (shouldDisplayViewName) { return [ const HSpace(4), Stack( children: [ view.icon.value.isNotEmpty ? EmojiIconWidget( emoji: view.icon.toEmojiIconData(), emojiSize: emojiSize, ) : view.defaultIcon(size: Size.square(iconSize + 2.0)), if (!isChildPage) ...[ const Positioned( right: 0, bottom: 0, child: FlowySvg( FlowySvgs.referenced_page_s, blendMode: BlendMode.dstIn, ), ), ], ], ), ]; } return []; } String _getDisplayText( BuildContext context, ViewPB view, String? blockContent, ) { final shouldDisplayViewName = _shouldDisplayViewName( context, view.id, blockContent, ); if (blockContent == null || blockContent.isEmpty) { return shouldDisplayViewName ? view.name .orDefault(LocaleKeys.menuAppHeader_defaultNewPageName.tr()) : ''; } return shouldDisplayViewName ? '${view.name} - $blockContent' : blockContent; } // display the view name or not // if the block is from the same doc, // 1. block content is not empty, display the **block content only**. // 2. block content is empty, display the **view name**. // if the block is from another doc, // 1. block content is not empty, display the **view name and block content**. // 2. block content is empty, display the **view name**. bool _shouldDisplayViewName( BuildContext context, String viewId, String? blockContent, ) { if (_isSameDocument(context, viewId)) { return blockContent == null || blockContent.isEmpty; } return true; } bool _isSameDocument(BuildContext context, String viewId) { final currentViewId = context.read()?.documentId; return viewId == currentViewId; } } class _NoAccessMentionPageBlock extends StatelessWidget { const _NoAccessMentionPageBlock({required this.textStyle}); final TextStyle? textStyle; @override Widget build(BuildContext context) { return FlowyHover( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: FlowyText( LocaleKeys.document_mention_noAccess.tr(), color: Theme.of(context).disabledColor, decoration: TextDecoration.underline, fontSize: textStyle?.fontSize, fontWeight: textStyle?.fontWeight, ), ), ); } } class _DeletedPageBlock extends StatelessWidget { const _DeletedPageBlock({required this.textStyle}); final TextStyle? textStyle; @override Widget build(BuildContext context) { return FlowyHover( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: FlowyText( LocaleKeys.document_mention_deletedPage.tr(), color: Theme.of(context).disabledColor, decoration: TextDecoration.underline, fontSize: textStyle?.fontSize, fontWeight: textStyle?.fontWeight, ), ), ); } } class _MobileMentionPageBlock extends StatelessWidget { const _MobileMentionPageBlock({ required this.view, required this.content, required this.textStyle, required this.handleTap, required this.handleDoubleTap, this.showTrashHint = false, this.isChildPage = false, }); final TextStyle? textStyle; final ViewPB view; final String content; final VoidCallback handleTap; final VoidCallback handleDoubleTap; final bool showTrashHint; final bool isChildPage; @override Widget build(BuildContext context) { return GestureDetector( onTap: handleTap, onDoubleTap: handleDoubleTap, behavior: HitTestBehavior.opaque, child: _MentionPageBlockContent( view: view, content: content, textStyle: textStyle, showTrashHint: showTrashHint, isChildPage: isChildPage, ), ); } } class _DesktopMentionPageBlock extends StatelessWidget { const _DesktopMentionPageBlock({ required this.view, required this.textStyle, required this.handleTap, required this.content, this.showTrashHint = false, this.isChildPage = false, }); final TextStyle? textStyle; final ViewPB view; final String? content; final VoidCallback handleTap; final bool showTrashHint; final bool isChildPage; @override Widget build(BuildContext context) { return GestureDetector( onTap: handleTap, behavior: HitTestBehavior.opaque, child: FlowyHover( cursor: SystemMouseCursors.click, child: _MentionPageBlockContent( view: view, content: content, textStyle: textStyle, showTrashHint: showTrashHint, isChildPage: isChildPage, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; Future showPageSelectorSheet( BuildContext context, { String? currentViewId, String? selectedViewId, bool Function(ViewPB view)? filter, }) async { filter ??= (v) => !v.isSpace && v.parentViewId.isNotEmpty; return showMobileBottomSheet( context, title: LocaleKeys.document_mobilePageSelector_title.tr(), showHeader: true, showCloseButton: true, showDragHandle: true, useSafeArea: false, backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) => ConstrainedBox( constraints: const BoxConstraints( maxHeight: 340, minHeight: 80, ), child: _MobilePageSelectorBody( currentViewId: currentViewId, selectedViewId: selectedViewId, filter: filter, ), ), ); } class _MobilePageSelectorBody extends StatefulWidget { const _MobilePageSelectorBody({ this.currentViewId, this.selectedViewId, this.filter, }); final String? currentViewId; final String? selectedViewId; final bool Function(ViewPB view)? filter; @override State<_MobilePageSelectorBody> createState() => _MobilePageSelectorBodyState(); } class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> { final searchController = TextEditingController(); late final Future> _viewsFuture = _fetchViews(); @override Widget build(BuildContext context) { return Column( children: [ Container( margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), height: 44.0, child: FlowySearchTextField( controller: searchController, onChanged: (_) => setState(() {}), ), ), FutureBuilder( future: _viewsFuture, builder: (_, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator.adaptive()); } if (snapshot.hasError || snapshot.data == null) { return Center( child: FlowyText( LocaleKeys.document_mobilePageSelector_failedToLoad.tr(), ), ); } final views = snapshot.data! .where((v) => widget.filter?.call(v) ?? true) .toList(); if (widget.currentViewId != null) { views.removeWhere((v) => v.id == widget.currentViewId); } final filtered = views.where( (v) => searchController.text.isEmpty || v.name .toLowerCase() .contains(searchController.text.toLowerCase()), ); if (filtered.isEmpty) { return Center( child: FlowyText( LocaleKeys.document_mobilePageSelector_noPagesFound.tr(), ), ); } return Flexible( child: ListView.builder( itemCount: filtered.length, itemBuilder: (context, index) { final view = filtered.elementAt(index); return FlowyOptionTile.checkbox( leftIcon: view.icon.value.isNotEmpty ? RawEmojiIconWidget( emoji: view.icon.toEmojiIconData(), emojiSize: 18, ) : FlowySvg( view.layout.icon, size: const Size.square(20), ), text: view.name, showTopBorder: index != 0, showBottomBorder: false, isSelected: view.id == widget.selectedViewId, onTap: () => Navigator.of(context).pop(view), ); }, ), ); }, ), ], ); } Future> _fetchViews() async => (await ViewBackendService.getAllViews()).toNullable()?.items ?? []; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/menu/menu_extension.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; extension MenuExtension on EditorState { MenuPosition? calculateMenuOffset({ Rect? rect, required double menuWidth, required double menuHeight, Offset menuOffset = const Offset(0, 10), }) { final selectionService = service.selectionService; final selectionRects = selectionService.selectionRects; late Rect startRect; if (rect != null) { startRect = rect; } else { if (selectionRects.isEmpty) return null; startRect = selectionRects.first; } final editorOffset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; final editorHeight = renderBox!.size.height; final editorWidth = renderBox!.size.width; // show below default Alignment alignment = Alignment.topLeft; final bottomRight = startRect.bottomRight; final topRight = startRect.topRight; var startOffset = bottomRight + menuOffset; Offset offset = Offset( startOffset.dx, startOffset.dy, ); // show above if (startOffset.dy + menuHeight >= editorOffset.dy + editorHeight) { startOffset = topRight - menuOffset; alignment = Alignment.bottomLeft; offset = Offset( startOffset.dx, editorHeight + editorOffset.dy - startOffset.dy, ); } // show on right if (offset.dx + menuWidth < editorOffset.dx + editorWidth) { offset = Offset( offset.dx, offset.dy, ); } else if (startOffset.dx - editorOffset.dx > menuWidth) { // show on left alignment = alignment == Alignment.topLeft ? Alignment.topRight : Alignment.bottomRight; offset = Offset( editorWidth - offset.dx + editorOffset.dx, offset.dy, ); } return MenuPosition(align: alignment, offset: offset); } } class MenuPosition { MenuPosition({ required this.align, required this.offset, }); final Alignment align; final Offset offset; LTRB get ltrb { double? left, top, right, bottom; switch (align) { case Alignment.topLeft: left = offset.dx; top = offset.dy; break; case Alignment.bottomLeft: left = offset.dx; bottom = offset.dy; break; case Alignment.topRight: right = offset.dx; top = offset.dy; break; case Alignment.bottomRight: right = offset.dx; bottom = offset.dy; break; } return LTRB(left: left, top: top, right: right, bottom: bottom); } } class LTRB { LTRB({this.left, this.top, this.right, this.bottom}); final double? left; final double? top; final double? right; final double? bottom; Positioned buildPositioned({required Widget child}) => Positioned( left: left, top: top, right: right, bottom: bottom, child: child, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart ================================================ import 'dart:convert'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:collection/collection.dart'; import 'package:string_validator/string_validator.dart'; class EditorMigration { // AppFlowy 0.1.x -> 0.2 // // The cover node has been deprecated, and use page/attributes/cover instead. // cover node -> page/attributes/cover // // mark the textNode deprecated. use paragraph node instead. // text node -> paragraph node // delta -> attributes/delta // // mark the subtype deprecated. use type instead. // for example, text/checkbox -> checkbox_list // // some attribute keys. // ... static Document migrateDocument(String json) { final map = jsonDecode(json); assert(map['document'] != null); final documentV0 = Map.from(map['document'] as Map); final rootV0 = NodeV0.fromJson(documentV0); final root = migrateNode(rootV0); return Document(root: root); } static Node migrateNode(NodeV0 nodeV0) { Node? node; final children = nodeV0.children.map((e) => migrateNode(e)).toList(); final id = nodeV0.id; if (id == 'editor') { final coverNode = children.firstWhereOrNull( (element) => element.id == 'cover', ); if (coverNode != null) { node = pageNode( children: children, attributes: coverNode.attributes, ); } else { node = pageNode(children: children); } } else if (id == 'callout') { final icon = nodeV0.attributes[CalloutBlockKeys.icon] ?? '📌'; final iconType = nodeV0.attributes[CalloutBlockKeys.iconType] ?? FlowyIconType.emoji.name; final delta = nodeV0.children.whereType().fold(Delta(), (p, e) { final delta = migrateDelta(e.delta); final textInserts = delta.whereType(); for (final element in textInserts) { p.add(element); } return p..insert('\n'); }); EmojiIconData? emojiIconData; try { emojiIconData = EmojiIconData(FlowyIconType.values.byName(iconType), icon); } catch (e) { Log.error( 'migrateNode get EmojiIconData error with :${nodeV0.attributes}', e, ); } node = calloutNode( emoji: emojiIconData, delta: delta, ); } else if (id == 'divider') { // divider -> divider node = dividerNode(); } else if (id == 'math_equation') { // math_equation -> math_equation final formula = nodeV0.attributes['math_equation'] ?? ''; node = mathEquationNode(formula: formula); } else if (nodeV0 is TextNodeV0) { final delta = migrateDelta(nodeV0.delta); final deltaJson = delta.toJson(); final attributes = {'delta': deltaJson}; if (id == 'text') { // text -> paragraph node = paragraphNode( attributes: attributes, children: children, ); } else if (nodeV0.id == 'text/heading') { // text/heading -> heading final heading = nodeV0.attributes.heading?.replaceAll('h', ''); final level = int.tryParse(heading ?? '') ?? 1; node = headingNode( level: level, attributes: attributes, ); } else if (id == 'text/checkbox') { // text/checkbox -> todo_list final checked = nodeV0.attributes.check; node = todoListNode( checked: checked, attributes: attributes, children: children, ); } else if (id == 'text/quote') { // text/quote -> quote node = quoteNode(attributes: attributes); } else if (id == 'text/number-list') { // text/number-list -> numbered_list node = numberedListNode( attributes: attributes, children: children, ); } else if (id == 'text/bulleted-list') { // text/bulleted-list -> bulleted_list node = bulletedListNode( attributes: attributes, children: children, ); } else if (id == 'text/code_block') { // text/code_block -> code final language = nodeV0.attributes['language']; node = codeBlockNode(delta: delta, language: language); } } else if (id == 'cover') { node = paragraphNode(); } return node ?? paragraphNode(text: jsonEncode(nodeV0.toJson())); } // migrate the attributes. // backgroundColor -> highlightColor // color -> textColor static Delta migrateDelta(Delta delta) { final textInserts = delta .whereType() .map( (e) => TextInsert( e.text, attributes: migrateAttributes(e.attributes), ), ) .toList(growable: false); return Delta(operations: textInserts.toList()); } static Attributes? migrateAttributes(Attributes? attributes) { if (attributes == null) { return null; } const backgroundColor = 'backgroundColor'; if (attributes.containsKey(backgroundColor)) { attributes[AppFlowyRichTextKeys.backgroundColor] = attributes[backgroundColor]; attributes.remove(backgroundColor); } const color = 'color'; if (attributes.containsKey(color)) { attributes[AppFlowyRichTextKeys.textColor] = attributes[color]; attributes.remove(color); } return attributes; } // Before version 0.5.5, the cover is stored in the document root. // Now, the cover is stored in the view.ext. static void migrateCoverIfNeeded( ViewPB view, Attributes attributes, { bool overwrite = false, }) async { if (view.extra.isNotEmpty && !overwrite) { return; } final coverType = CoverType.fromString( attributes[DocumentHeaderBlockKeys.coverType], ); final coverDetails = attributes[DocumentHeaderBlockKeys.coverDetails]; Map extra = {}; if (coverType == CoverType.none || coverDetails == null || coverDetails is! String) { extra = { ViewExtKeys.coverKey: { ViewExtKeys.coverTypeKey: PageStyleCoverImageType.none.toString(), ViewExtKeys.coverValueKey: '', }, }; } else { switch (coverType) { case CoverType.asset: extra = { ViewExtKeys.coverKey: { ViewExtKeys.coverTypeKey: PageStyleCoverImageType.builtInImage.toString(), ViewExtKeys.coverValueKey: coverDetails, }, }; break; case CoverType.color: extra = { ViewExtKeys.coverKey: { ViewExtKeys.coverTypeKey: PageStyleCoverImageType.pureColor.toString(), ViewExtKeys.coverValueKey: coverDetails, }, }; break; case CoverType.file: if (isURL(coverDetails)) { if (coverDetails.contains('unsplash')) { extra = { ViewExtKeys.coverKey: { ViewExtKeys.coverTypeKey: PageStyleCoverImageType.unsplashImage.toString(), ViewExtKeys.coverValueKey: coverDetails, }, }; } else { extra = { ViewExtKeys.coverKey: { ViewExtKeys.coverTypeKey: PageStyleCoverImageType.customImage.toString(), ViewExtKeys.coverValueKey: coverDetails, }, }; } } else { extra = { ViewExtKeys.coverKey: { ViewExtKeys.coverTypeKey: PageStyleCoverImageType.localImage.toString(), ViewExtKeys.coverValueKey: coverDetails, }, }; } break; default: } } if (extra.isEmpty) { return; } try { final current = view.extra.isNotEmpty ? jsonDecode(view.extra) : {}; final merged = mergeMaps(current, extra); await ViewBackendService.updateView( viewId: view.id, extra: jsonEncode(merged), ); } catch (e) { Log.error('Failed to migrating cover: $e'); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_floating_toolbar/custom_mobile_floating_toolbar.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; List buildMobileFloatingToolbarItems( EditorState editorState, Offset offset, Function closeToolbar, ) { // copy, paste, select, select all, cut final selection = editorState.selection; if (selection == null) { return []; } final toolbarItems = []; if (!selection.isCollapsed) { toolbarItems.add( ContextMenuButtonItem( label: LocaleKeys.editor_copy.tr(), onPressed: () { customCopyCommand.execute(editorState); closeToolbar(); }, ), ); } toolbarItems.add( ContextMenuButtonItem( label: LocaleKeys.editor_paste.tr(), onPressed: () { customPasteCommand.execute(editorState); closeToolbar(); }, ), ); if (!selection.isCollapsed) { toolbarItems.add( ContextMenuButtonItem( label: LocaleKeys.editor_cut.tr(), onPressed: () { cutCommand.execute(editorState); closeToolbar(); }, ), ); } toolbarItems.add( ContextMenuButtonItem( label: LocaleKeys.editor_select.tr(), onPressed: () { editorState.selectWord(offset); closeToolbar(); }, ), ); toolbarItems.add( ContextMenuButtonItem( label: LocaleKeys.editor_selectAll.tr(), onPressed: () { selectAllCommand.execute(editorState); closeToolbar(); }, ), ); return toolbarItems; } extension on EditorState { void selectWord(Offset offset) { final node = service.selectionService.getNodeInOffset(offset); final selection = node?.selectable?.getWordBoundaryInOffset(offset); if (selection == null) { return; } updateSelectionWithReason(selection); } } class CustomMobileFloatingToolbar extends StatelessWidget { const CustomMobileFloatingToolbar({ super.key, required this.editorState, required this.anchor, required this.closeToolbar, }); final EditorState editorState; final Offset anchor; final VoidCallback closeToolbar; @override Widget build(BuildContext context) { return Animate( autoPlay: true, effects: _getEffects(context), child: AdaptiveTextSelectionToolbar.buttonItems( buttonItems: buildMobileFloatingToolbarItems( editorState, anchor, closeToolbar, ), anchors: TextSelectionToolbarAnchors( primaryAnchor: anchor, ), ), ); } List _getEffects(BuildContext context) { if (Platform.isIOS) { final Size(:width, :height) = MediaQuery.of(context).size; final alignmentX = (anchor.dx - width / 2) / (width / 2); final alignmentY = (anchor.dy - height / 2) / (height / 2); return [ ScaleEffect( curve: Curves.easeInOut, alignment: Alignment(alignmentX, alignmentY), duration: 250.milliseconds, ), ]; } else if (Platform.isAndroid) { return [ const FadeEffect( duration: SelectionOverlay.fadeDuration, ), MoveEffect( curve: Curves.easeOutCubic, begin: const Offset(0, 16), duration: 100.milliseconds, ), ]; } else { return []; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_add_block_toolbar_item.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension EditorStateAddBlock on EditorState { Future insertMathEquation( Selection selection, ) async { final path = selection.start.path; final node = getNodeAtPath(path); final delta = node?.delta; if (node == null || delta == null) { return; } final transaction = this.transaction; final insertedNode = mathEquationNode(); if (delta.isEmpty) { transaction ..insertNode(path, insertedNode) ..deleteNode(node); } else { transaction.insertNode( path.next, insertedNode, ); } await apply(transaction); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { final mathEquationState = getNodeAtPath(path)?.key.currentState; if (mathEquationState != null && mathEquationState is MathEquationBlockComponentWidgetState) { mathEquationState.showEditingDialog(); } }); } Future insertDivider(Selection selection) async { // same as the [handler] of [dividerMenuItem] in Desktop final path = selection.end.path; final node = getNodeAtPath(path); final delta = node?.delta; if (node == null || delta == null) { return; } final insertedPath = delta.isEmpty ? path : path.next; final transaction = this.transaction; transaction.insertNode(insertedPath, dividerNode()); // only insert a new paragraph node when the next node is not a paragraph node // and its delta is not empty. final next = node.next; if (next == null || next.type != ParagraphBlockKeys.type || next.delta?.isNotEmpty == true) { transaction.insertNode( insertedPath, paragraphNode(), ); } transaction.selectionExtraInfo = {}; transaction.afterSelection = Selection.collapsed( Position(path: insertedPath.next), ); await apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; enum MobileBlockActionType { delete, duplicate, insertAbove, insertBelow, color; static List get standard => [ MobileBlockActionType.delete, MobileBlockActionType.duplicate, MobileBlockActionType.insertAbove, MobileBlockActionType.insertBelow, ]; static MobileBlockActionType fromActionString(String actionString) { return MobileBlockActionType.values.firstWhere( (e) => e.actionString == actionString, orElse: () => throw Exception('Unknown action string: $actionString'), ); } String get actionString => toString(); FlowySvgData get icon { return switch (this) { MobileBlockActionType.delete => FlowySvgs.m_delete_m, MobileBlockActionType.duplicate => FlowySvgs.m_duplicate_m, MobileBlockActionType.insertAbove => FlowySvgs.arrow_up_s, MobileBlockActionType.insertBelow => FlowySvgs.arrow_down_s, MobileBlockActionType.color => FlowySvgs.m_color_m, }; } String get i18n { return switch (this) { MobileBlockActionType.delete => LocaleKeys.button_delete.tr(), MobileBlockActionType.duplicate => LocaleKeys.button_duplicate.tr(), MobileBlockActionType.insertAbove => LocaleKeys.button_insertAbove.tr(), MobileBlockActionType.insertBelow => LocaleKeys.button_insertBelow.tr(), MobileBlockActionType.color => LocaleKeys.document_plugins_optionAction_color.tr(), }; } } class MobileBlockSettingsScreen extends StatelessWidget { const MobileBlockSettingsScreen({super.key, required this.actions}); final List actions; static const routeName = '/block_settings'; // the action string comes from the enum MobileBlockActionType // example: MobileBlockActionType.delete.actionString, MobileBlockActionType.duplicate.actionString, etc. static const supportedActions = 'actions'; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( titleSpacing: 0, title: FlowyText.semibold( LocaleKeys.titleBar_actions.tr(), fontSize: 14.0, ), leading: const AppBarBackButton(), ), body: SafeArea( child: ListView.separated( itemCount: actions.length, itemBuilder: (context, index) { final action = actions[index]; return FlowyButton( text: Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0, vertical: 18.0, ), child: FlowyText(action.i18n), ), leftIcon: FlowySvg(action.icon), leftIconSize: const Size.square(24), onTap: () {}, ); }, separatorBuilder: (context, index) => const Divider( height: 1.0, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; extension SelectionColor on EditorState { String? getSelectionColor(String key) { final selection = this.selection; if (selection == null) { return null; } String? color = toggledStyle[key]; if (color == null) { if (selection.isCollapsed && selection.startIndex != 0) { color = getDeltaAttributeValueInSelection( key, selection.copyWith( start: selection.start.copyWith( offset: selection.startIndex - 1, ), ), ); } else { color = getDeltaAttributeValueInSelection( key, ); } } return color; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; const _left = 'left'; const _center = 'center'; const _right = 'right'; class AlignItems extends StatelessWidget { AlignItems({ super.key, required this.editorState, }); final EditorState editorState; final List<(String, FlowySvgData)> _alignMenuItems = [ (_left, FlowySvgs.m_aa_align_left_m), (_center, FlowySvgs.m_aa_align_center_m), (_right, FlowySvgs.m_aa_align_right_m), ]; @override Widget build(BuildContext context) { final currentAlignItem = _getCurrentAlignItem(); final theme = ToolbarColorExtension.of(context); return PopupMenu( itemLength: _alignMenuItems.length, onSelected: (index) { editorState.alignBlock( _alignMenuItems[index].$1, selectionExtraInfo: { selectionExtraInfoDoNotAttachTextService: true, selectionExtraInfoDisableFloatingToolbar: true, }, ); }, menuBuilder: (context, keys, currentIndex) { final children = _alignMenuItems .mapIndexed( (index, e) => [ PopupMenuItemWrapper( key: keys[index], isSelected: currentIndex == index, icon: e.$2, ), if (index != 0 && index != _alignMenuItems.length - 1) const HSpace(12), ], ) .flattened .toList(); return PopupMenuWrapper( child: Row( mainAxisSize: MainAxisSize.min, children: children, ), ); }, builder: (context, key) => MobileToolbarMenuItemWrapper( key: key, size: const Size(82, 52), onTap: () async { await editorState.alignBlock( currentAlignItem.$1, selectionExtraInfo: { selectionExtraInfoDoNotAttachTextService: true, selectionExtraInfoDisableFloatingToolbar: true, }, ); }, icon: currentAlignItem.$2, isSelected: false, iconPadding: const EdgeInsets.symmetric( vertical: 14.0, ), showDownArrow: true, backgroundColor: theme.toolbarMenuItemBackgroundColor, ), ); } (String, FlowySvgData) _getCurrentAlignItem() { final align = _getCurrentBlockAlign(); if (align == _center) { return (_right, FlowySvgs.m_aa_align_right_s); } else if (align == _right) { return (_left, FlowySvgs.m_aa_align_left_s); } else { return (_center, FlowySvgs.m_aa_align_center_s); } } String _getCurrentBlockAlign() { final selection = editorState.selection; if (selection == null) { return _left; } final nodes = editorState.getNodesInSelection(selection); String? alignString; for (final node in nodes) { final align = node.attributes[blockComponentAlign]; if (alignString == null) { alignString = align; } else if (alignString != align) { return _left; } } return alignString ?? _left; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; class BIUSItems extends StatelessWidget { BIUSItems({ super.key, required this.editorState, }); final EditorState editorState; final List<(FlowySvgData, String)> _bius = [ (FlowySvgs.m_toolbar_bold_m, AppFlowyRichTextKeys.bold), (FlowySvgs.m_toolbar_italic_m, AppFlowyRichTextKeys.italic), (FlowySvgs.m_toolbar_underline_m, AppFlowyRichTextKeys.underline), (FlowySvgs.m_toolbar_strike_m, AppFlowyRichTextKeys.strikethrough), ]; @override Widget build(BuildContext context) { return IntrinsicHeight( child: Row( mainAxisSize: MainAxisSize.min, children: _bius .mapIndexed( (index, e) => [ _buildBIUSItem( context, index, e.$1, e.$2, ), if (index != 0 || index != _bius.length - 1) const ScaledVerticalDivider(), ], ) .flattened .toList(), ), ); } Widget _buildBIUSItem( BuildContext context, int index, FlowySvgData icon, String richTextKey, ) { final theme = ToolbarColorExtension.of(context); return StatefulBuilder( builder: (_, setState) => MobileToolbarMenuItemWrapper( size: const Size(62, 52), enableTopLeftRadius: index == 0, enableBottomLeftRadius: index == 0, enableTopRightRadius: index == _bius.length - 1, enableBottomRightRadius: index == _bius.length - 1, backgroundColor: theme.toolbarMenuItemBackgroundColor, onTap: () async { await editorState.toggleAttribute( richTextKey, selectionExtraInfo: { selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDoNotAttachTextService: true, }, ); // refresh the status setState(() {}); }, icon: icon, isSelected: editorState.isTextDecorationSelected(richTextKey) && editorState.toggledStyle[richTextKey] != false, iconPadding: const EdgeInsets.symmetric( vertical: 14.0, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/link_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys, quoteNode; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class BlockItems extends StatelessWidget { BlockItems({ super.key, required this.service, required this.editorState, }); final EditorState editorState; final AppFlowyMobileToolbarWidgetService service; final List<(FlowySvgData, String)> _blockItems = [ (FlowySvgs.m_toolbar_bulleted_list_m, BulletedListBlockKeys.type), (FlowySvgs.m_toolbar_numbered_list_m, NumberedListBlockKeys.type), (FlowySvgs.m_aa_quote_m, QuoteBlockKeys.type), ]; @override Widget build(BuildContext context) { return IntrinsicHeight( child: Row( mainAxisSize: MainAxisSize.min, children: [ ..._blockItems .mapIndexed( (index, e) => [ _buildBlockItem( context, index, e.$1, e.$2, ), if (index != 0) const ScaledVerticalDivider(), ], ) .flattened, // this item is a special case, use link item here instead of block item _buildLinkItem(context), ], ), ); } Widget _buildBlockItem( BuildContext context, int index, FlowySvgData icon, String blockType, ) { final theme = ToolbarColorExtension.of(context); return MobileToolbarMenuItemWrapper( size: const Size(62, 54), enableTopLeftRadius: index == 0, enableBottomLeftRadius: index == 0, enableTopRightRadius: false, enableBottomRightRadius: false, onTap: () async { await _convert(blockType); }, backgroundColor: theme.toolbarMenuItemBackgroundColor, icon: icon, isSelected: editorState.isBlockTypeSelected(blockType), iconPadding: const EdgeInsets.symmetric( vertical: 14.0, ), ); } Widget _buildLinkItem(BuildContext context) { final theme = ToolbarColorExtension.of(context); final items = [ (AppFlowyRichTextKeys.code, FlowySvgs.m_aa_code_m), // (InlineMathEquationKeys.formula, FlowySvgs.m_aa_math_s), ]; return PopupMenu( itemLength: items.length, onSelected: (index) async { await editorState.toggleAttribute(items[index].$1); }, menuBuilder: (context, keys, currentIndex) { final children = items .mapIndexed( (index, e) => [ PopupMenuItemWrapper( key: keys[index], isSelected: currentIndex == index, icon: e.$2, ), if (index != 0 || index != items.length - 1) const HSpace(12), ], ) .flattened .toList(); return PopupMenuWrapper( child: Row( mainAxisSize: MainAxisSize.min, children: children, ), ); }, builder: (context, key) => MobileToolbarMenuItemWrapper( key: key, size: const Size(62, 54), enableTopLeftRadius: false, enableBottomLeftRadius: false, showDownArrow: true, onTap: _onLinkItemTap, backgroundColor: theme.toolbarMenuItemBackgroundColor, icon: FlowySvgs.m_toolbar_link_m, isSelected: false, iconPadding: const EdgeInsets.symmetric( vertical: 14.0, ), ), ); } void _onLinkItemTap() async { final selection = editorState.selection; if (selection == null) { return; } final nodes = editorState.getNodesInSelection(selection); // show edit link bottom sheet final context = nodes.firstOrNull?.context; if (context != null) { _closeKeyboard(selection); // keep the selection unawaited( editorState.updateSelectionWithReason( selection, extraInfo: { selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDoNotAttachTextService: true, selectionExtraInfoDisableFloatingToolbar: true, }, ), ); keepEditorFocusNotifier.increase(); await showEditLinkBottomSheet(context, selection, editorState); } } void _closeKeyboard(Selection selection) { editorState.updateSelectionWithReason( selection, extraInfo: { selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDoNotAttachTextService: true, }, ); editorState.service.keyboardService?.closeKeyboard(); } Future _convert(String blockType) async { await editorState.convertBlockType( blockType, selectionExtraInfo: { selectionExtraInfoDoNotAttachTextService: true, selectionExtraInfoDisableFloatingToolbar: true, }, ); unawaited( editorState.updateSelectionWithReason( editorState.selection, extraInfo: { selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDoNotAttachTextService: true, }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class CloseKeyboardOrMenuButton extends StatelessWidget { const CloseKeyboardOrMenuButton({ super.key, required this.onPressed, }); final VoidCallback onPressed; @override Widget build(BuildContext context) { return SizedBox( width: 62, height: 42, child: FlowyButton( text: const FlowySvg( FlowySvgs.m_toolbar_keyboard_m, ), onTap: onPressed, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/font_colors.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; class ColorItem extends StatelessWidget { const ColorItem({ super.key, required this.editorState, required this.service, }); final EditorState editorState; final AppFlowyMobileToolbarWidgetService service; @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); final String? selectedTextColor = editorState.getSelectionColor(AppFlowyRichTextKeys.textColor); final String? selectedBackgroundColor = editorState.getSelectionColor(AppFlowyRichTextKeys.backgroundColor); final backgroundColor = EditorFontColors.fromBuiltInColors( context, selectedBackgroundColor?.tryToColor(), ); return MobileToolbarMenuItemWrapper( size: const Size(82, 52), onTap: () async { service.closeKeyboard(); unawaited( editorState.updateSelectionWithReason( editorState.selection, extraInfo: { selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDoNotAttachTextService: true, }, ), ); keepEditorFocusNotifier.increase(); await showTextColorAndBackgroundColorPicker( context, editorState: editorState, selection: editorState.selection!, ); }, icon: FlowySvgs.m_aa_font_color_m, iconColor: EditorFontColors.fromBuiltInColors( context, selectedTextColor?.tryToColor(), ), backgroundColor: backgroundColor ?? theme.toolbarMenuItemBackgroundColor, selectedBackgroundColor: backgroundColor, isSelected: selectedBackgroundColor != null, showRightArrow: true, iconPadding: const EdgeInsets.only( top: 14.0, bottom: 14.0, right: 28.0, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/font_colors.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; const _count = 6; Future showTextColorAndBackgroundColorPicker( BuildContext context, { required EditorState editorState, required Selection selection, }) async { final theme = ToolbarColorExtension.of(context); await showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showDoneButton: true, barrierColor: Colors.transparent, backgroundColor: theme.toolbarMenuBackgroundColor, elevation: 20, title: LocaleKeys.grid_selectOption_colorPanelTitle.tr(), padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), builder: (context) { return _TextColorAndBackgroundColor( editorState: editorState, selection: selection, ); }, ); Future.delayed(const Duration(milliseconds: 100), () { // highlight the selected text again. editorState.updateSelectionWithReason( selection, extraInfo: { selectionExtraInfoDisableFloatingToolbar: true, }, ); }); } class _TextColorAndBackgroundColor extends StatefulWidget { const _TextColorAndBackgroundColor({ required this.editorState, required this.selection, }); final EditorState editorState; final Selection selection; @override State<_TextColorAndBackgroundColor> createState() => _TextColorAndBackgroundColorState(); } class _TextColorAndBackgroundColorState extends State<_TextColorAndBackgroundColor> { @override Widget build(BuildContext context) { final String? selectedTextColor = widget.editorState.getSelectionColor(AppFlowyRichTextKeys.textColor); final String? selectedBackgroundColor = widget.editorState .getSelectionColor(AppFlowyRichTextKeys.backgroundColor); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only( top: 20, left: 6.0, ), child: FlowyText( LocaleKeys.editor_textColor.tr(), fontSize: 14.0, ), ), const VSpace(6.0), EditorTextColorWidget( selectedColor: selectedTextColor?.tryToColor(), onSelectedColor: (textColor) async { final hex = textColor.a == 0 ? null : textColor.toHex(); final selection = widget.selection; if (selection.isCollapsed) { widget.editorState.updateToggledStyle( AppFlowyRichTextKeys.textColor, hex ?? '', ); } else { await widget.editorState.formatDelta( widget.selection, { AppFlowyRichTextKeys.textColor: hex, }, selectionExtraInfo: { selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDoNotAttachTextService: true, }, ); } setState(() {}); }, ), Padding( padding: const EdgeInsets.only( top: 18.0, left: 6.0, ), child: FlowyText( LocaleKeys.editor_backgroundColor.tr(), fontSize: 14.0, ), ), const VSpace(6.0), EditorBackgroundColors( selectedColor: selectedBackgroundColor?.tryToColor(), onSelectedColor: (backgroundColor) async { final hex = backgroundColor.a == 0 ? null : backgroundColor.toHex(); final selection = widget.selection; if (selection.isCollapsed) { widget.editorState.updateToggledStyle( AppFlowyRichTextKeys.backgroundColor, hex ?? '', ); } else { await widget.editorState.formatDelta( widget.selection, { AppFlowyRichTextKeys.backgroundColor: hex, }, selectionExtraInfo: { selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDoNotAttachTextService: true, }, ); } setState(() {}); }, ), ], ); } } class EditorBackgroundColors extends StatelessWidget { const EditorBackgroundColors({ super.key, this.selectedColor, required this.onSelectedColor, }); final Color? selectedColor; final void Function(Color color) onSelectedColor; @override Widget build(BuildContext context) { final colors = Theme.of(context).brightness == Brightness.light ? EditorFontColors.lightColors : EditorFontColors.darkColors; return GridView.count( crossAxisCount: _count, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), children: colors.mapIndexed( (index, color) { return _BackgroundColorItem( color: color, isSelected: selectedColor == null ? index == 0 : selectedColor == color, onTap: () => onSelectedColor(color), ); }, ).toList(), ); } } class _BackgroundColorItem extends StatelessWidget { const _BackgroundColorItem({ required this.color, required this.isSelected, required this.onTap, }); final VoidCallback onTap; final Color color; final bool isSelected; @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); return GestureDetector( onTap: onTap, child: Container( margin: const EdgeInsets.all(6.0), decoration: BoxDecoration( color: color, borderRadius: Corners.s12Border, border: Border.all( width: isSelected ? 2.0 : 1.0, color: isSelected ? theme.toolbarMenuItemSelectedBackgroundColor : Theme.of(context).dividerColor, ), ), alignment: Alignment.center, child: isSelected ? const FlowySvg( FlowySvgs.m_blue_check_s, size: Size.square(28.0), blendMode: null, ) : null, ), ); } } class EditorTextColorWidget extends StatelessWidget { EditorTextColorWidget({ super.key, this.selectedColor, required this.onSelectedColor, }); final Color? selectedColor; final void Function(Color color) onSelectedColor; final colors = [ const Color(0x00FFFFFF), const Color(0xFFDB3636), const Color(0xFFEA8F06), const Color(0xFF18A166), const Color(0xFF205EEE), const Color(0xFFC619C9), ]; @override Widget build(BuildContext context) { return GridView.count( crossAxisCount: _count, shrinkWrap: true, padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), children: colors.mapIndexed( (index, color) { return _TextColorItem( color: color, isSelected: selectedColor == null ? index == 0 : selectedColor == color, onTap: () => onSelectedColor(color), ); }, ).toList(), ); } } class _TextColorItem extends StatelessWidget { const _TextColorItem({ required this.color, required this.isSelected, required this.onTap, }); final VoidCallback onTap; final Color color; final bool isSelected; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( margin: const EdgeInsets.all(6.0), decoration: BoxDecoration( borderRadius: Corners.s12Border, border: Border.all( width: isSelected ? 2.0 : 1.0, color: isSelected ? const Color(0xff00C6F1) : Theme.of(context).dividerColor, ), ), alignment: Alignment.center, child: FlowyText( 'A', fontSize: 24, color: color.a == 0 ? null : color, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart ================================================ import 'dart:async'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/util/font_family_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; class FontFamilyItem extends StatelessWidget { const FontFamilyItem({ super.key, required this.editorState, }); final EditorState editorState; @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); final fontFamily = _getCurrentSelectedFontFamilyName(); final systemFonFamily = context.read().state.fontFamily; return MobileToolbarMenuItemWrapper( size: const Size(144, 52), onTap: () async { final selection = editorState.selection; if (selection == null) { return; } // disable the floating toolbar unawaited( editorState.updateSelectionWithReason( selection, extraInfo: { selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDisableMobileToolbarKey: true, }, ), ); final newFont = await context .read() .push(FontPickerScreen.routeName); // if the selection is not collapsed, apply the font to the selection. if (newFont != null && !selection.isCollapsed) { if (newFont != fontFamily) { await editorState.formatDelta(selection, { AppFlowyRichTextKeys.fontFamily: newFont, }); } } // wait for the font picker screen to be dismissed. Future.delayed(const Duration(milliseconds: 250), () { // highlight the selected text again. editorState.updateSelectionWithReason( selection, extraInfo: { selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDisableMobileToolbarKey: false, }, ); // if the selection is collapsed, save the font for the next typing. if (newFont != null && selection.isCollapsed) { editorState.updateToggledStyle( AppFlowyRichTextKeys.fontFamily, getGoogleFontSafely(newFont).fontFamily, ); } }); }, text: (fontFamily ?? systemFonFamily).fontFamilyDisplayName, fontFamily: fontFamily ?? systemFonFamily, backgroundColor: theme.toolbarMenuItemBackgroundColor, isSelected: false, enable: true, showRightArrow: true, iconPadding: const EdgeInsets.only( top: 14.0, bottom: 14.0, left: 14.0, right: 12.0, ), textPadding: const EdgeInsets.only( right: 16.0, ), ); } String? _getCurrentSelectedFontFamilyName() { final toggleFontFamily = editorState.toggledStyle[AppFlowyRichTextKeys.fontFamily]; if (toggleFontFamily is String && toggleFontFamily.isNotEmpty) { return toggleFontFamily; } final selection = editorState.selection; if (selection != null && selection.isCollapsed && selection.startIndex != 0) { return editorState.getDeltaAttributeValueInSelection( AppFlowyRichTextKeys.fontFamily, selection.copyWith( start: selection.start.copyWith( offset: selection.startIndex - 1, ), ), ); } return editorState.getDeltaAttributeValueInSelection( AppFlowyRichTextKeys.fontFamily, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; class HeadingsAndTextItems extends StatelessWidget { const HeadingsAndTextItems({ super.key, required this.editorState, }); final EditorState editorState; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _HeadingOrTextItem( icon: FlowySvgs.m_aa_h1_m, blockType: HeadingBlockKeys.type, editorState: editorState, level: 1, ), _HeadingOrTextItem( icon: FlowySvgs.m_aa_h2_m, blockType: HeadingBlockKeys.type, editorState: editorState, level: 2, ), _HeadingOrTextItem( icon: FlowySvgs.m_aa_h3_m, blockType: HeadingBlockKeys.type, editorState: editorState, level: 3, ), _HeadingOrTextItem( icon: FlowySvgs.m_aa_paragraph_m, blockType: ParagraphBlockKeys.type, editorState: editorState, ), ], ); } } class _HeadingOrTextItem extends StatelessWidget { const _HeadingOrTextItem({ required this.icon, required this.blockType, required this.editorState, this.level, }); final FlowySvgData icon; final String blockType; final EditorState editorState; final int? level; @override Widget build(BuildContext context) { final isSelected = editorState.isBlockTypeSelected( blockType, level: level, ); final padding = level != null ? EdgeInsets.symmetric( vertical: 14.0 - (3 - level!) * 3.0, ) : const EdgeInsets.symmetric( vertical: 16.0, ); return MobileToolbarMenuItemWrapper( size: const Size(76, 52), onTap: () async => _convert(isSelected), icon: icon, isSelected: isSelected, iconPadding: padding, ); } Future _convert(bool isSelected) async { await editorState.convertBlockType( blockType, isSelected: isSelected, extraAttributes: level != null ? { HeadingBlockKeys.level: level!, } : null, selectionExtraInfo: { selectionExtraInfoDoNotAttachTextService: true, selectionExtraInfoDisableFloatingToolbar: true, }, ); unawaited( editorState.updateSelectionWithReason( editorState.selection, extraInfo: { selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDoNotAttachTextService: true, }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; class IndentAndOutdentItems extends StatelessWidget { const IndentAndOutdentItems({ super.key, required this.service, required this.editorState, }); final EditorState editorState; final AppFlowyMobileToolbarWidgetService service; @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); return IntrinsicHeight( child: Row( children: [ MobileToolbarMenuItemWrapper( size: const Size(95, 52), icon: FlowySvgs.m_aa_outdent_m, enable: isOutdentable(editorState), isSelected: false, enableTopRightRadius: false, enableBottomRightRadius: false, iconPadding: const EdgeInsets.symmetric(vertical: 14.0), backgroundColor: theme.toolbarMenuItemBackgroundColor, onTap: () { service.closeItemMenu(); outdentCommand.execute(editorState); }, ), const ScaledVerticalDivider(), MobileToolbarMenuItemWrapper( size: const Size(95, 52), icon: FlowySvgs.m_aa_indent_m, enable: isIndentable(editorState), isSelected: false, enableTopLeftRadius: false, enableBottomLeftRadius: false, iconPadding: const EdgeInsets.symmetric(vertical: 14.0), backgroundColor: theme.toolbarMenuItemBackgroundColor, onTap: () { service.closeItemMenu(); indentCommand.execute(editorState); }, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; class PopupMenuItemWrapper extends StatelessWidget { const PopupMenuItemWrapper({ super.key, required this.isSelected, required this.icon, }); final bool isSelected; final FlowySvgData icon; @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); return Container( width: 62, height: 44, decoration: ShapeDecoration( color: isSelected ? theme.toolbarMenuItemSelectedBackgroundColor : null, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9), child: FlowySvg( icon, color: isSelected ? theme.toolbarMenuIconSelectedColor : theme.toolbarMenuIconColor, ), ); } } class PopupMenuWrapper extends StatelessWidget { const PopupMenuWrapper({ super.key, required this.child, }); final Widget child; @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); return Container( height: 64, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), decoration: ShapeDecoration( color: theme.toolbarMenuBackgroundColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), shadows: [ BoxShadow( color: theme.toolbarShadowColor, blurRadius: 20, offset: const Offset(0, 10), ), ], ), child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class PopupMenu extends StatefulWidget { const PopupMenu({ super.key, required this.onSelected, required this.itemLength, required this.menuBuilder, required this.builder, }); final Widget Function(BuildContext context, Key key) builder; final int itemLength; final Widget Function( BuildContext context, List keys, int currentIndex, ) menuBuilder; final void Function(int index) onSelected; @override State createState() => _PopupMenuState(); } class _PopupMenuState extends State { final key = GlobalKey(); final indexNotifier = ValueNotifier(-1); late List itemKeys; OverlayEntry? popupMenuOverlayEntry; Rect get rect { final RenderBox renderBox = key.currentContext!.findRenderObject() as RenderBox; final size = renderBox.size; final offset = renderBox.localToGlobal(Offset.zero); return Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height); } @override void initState() { super.initState(); indexNotifier.value = widget.itemLength - 1; itemKeys = List.generate( widget.itemLength, (_) => GlobalKey(), ); indexNotifier.addListener(HapticFeedback.mediumImpact); } @override void dispose() { indexNotifier.removeListener(HapticFeedback.mediumImpact); indexNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onLongPressStart: (details) { _showMenu(context); }, onLongPressMoveUpdate: (details) { _updateSelection(details); }, onLongPressCancel: () { _hideMenu(); }, onLongPressUp: () { if (indexNotifier.value != -1) { widget.onSelected(indexNotifier.value); } _hideMenu(); }, child: widget.builder(context, key), ); } void _showMenu(BuildContext context) { final theme = ToolbarColorExtension.of(context); _hideMenu(); indexNotifier.value = widget.itemLength - 1; popupMenuOverlayEntry ??= OverlayEntry( builder: (context) { final screenSize = MediaQuery.of(context).size; final right = screenSize.width - rect.right; final bottom = screenSize.height - rect.top + 16; return Positioned( right: right, bottom: bottom, child: ColoredBox( color: theme.toolbarMenuBackgroundColor, child: ValueListenableBuilder( valueListenable: indexNotifier, builder: (context, value, _) => widget.menuBuilder( context, itemKeys, value, ), ), ), ); }, ); Overlay.of(context).insert(popupMenuOverlayEntry!); } void _hideMenu() { indexNotifier.value = -1; popupMenuOverlayEntry?.remove(); popupMenuOverlayEntry = null; } void _updateSelection(LongPressMoveUpdateDetails details) { final dx = details.globalPosition.dx; for (var i = 0; i < itemKeys.length; i++) { final key = itemKeys[i]; final RenderBox renderBox = key.currentContext!.findRenderObject() as RenderBox; final size = renderBox.size; final offset = renderBox.localToGlobal(Offset.zero); final rect = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height); // ignore the position overflow if ((i == 0 && dx < rect.left) || (i == itemKeys.length - 1 && dx > rect.right)) { indexNotifier.value = -1; break; } if (rect.left <= dx && dx <= rect.right) { indexNotifier.value = itemKeys.indexOf(key); break; } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart ================================================ // workaround for toolbar theme color. import 'package:flutter/material.dart'; class ToolbarColorExtension extends ThemeExtension { factory ToolbarColorExtension.light() => const ToolbarColorExtension( toolbarBackgroundColor: Color(0xFFFFFFFF), toolbarItemIconColor: Color(0xFF1F2329), toolbarItemIconDisabledColor: Color(0xFF999BA0), toolbarItemIconSelectedColor: Color(0x1F232914), toolbarItemSelectedBackgroundColor: Color(0xFFF2F2F2), toolbarMenuBackgroundColor: Color(0xFFFFFFFF), toolbarMenuItemBackgroundColor: Color(0xFFF2F2F7), toolbarMenuItemSelectedBackgroundColor: Color(0xFF00BCF0), toolbarMenuIconColor: Color(0xFF1F2329), toolbarMenuIconDisabledColor: Color(0xFF999BA0), toolbarMenuIconSelectedColor: Color(0xFFFFFFFF), toolbarShadowColor: Color(0x2D000000), ); factory ToolbarColorExtension.dark() => const ToolbarColorExtension( toolbarBackgroundColor: Color(0xFF1F2329), toolbarItemIconColor: Color(0xFFF3F3F8), toolbarItemIconDisabledColor: Color(0xFF55565B), toolbarItemIconSelectedColor: Color(0xFF00BCF0), toolbarItemSelectedBackgroundColor: Color(0xFF3A3D43), toolbarMenuBackgroundColor: Color(0xFF23262B), toolbarMenuItemBackgroundColor: Color(0xFF2D3036), toolbarMenuItemSelectedBackgroundColor: Color(0xFF00BCF0), toolbarMenuIconColor: Color(0xFFF3F3F8), toolbarMenuIconDisabledColor: Color(0xFF55565B), toolbarMenuIconSelectedColor: Color(0xFF1F2329), toolbarShadowColor: Color.fromARGB(80, 112, 112, 112), ); factory ToolbarColorExtension.fromBrightness(Brightness brightness) => brightness == Brightness.light ? ToolbarColorExtension.light() : ToolbarColorExtension.dark(); const ToolbarColorExtension({ required this.toolbarBackgroundColor, required this.toolbarItemIconColor, required this.toolbarItemIconDisabledColor, required this.toolbarItemIconSelectedColor, required this.toolbarMenuBackgroundColor, required this.toolbarMenuItemBackgroundColor, required this.toolbarMenuItemSelectedBackgroundColor, required this.toolbarItemSelectedBackgroundColor, required this.toolbarMenuIconColor, required this.toolbarMenuIconDisabledColor, required this.toolbarMenuIconSelectedColor, required this.toolbarShadowColor, }); final Color toolbarBackgroundColor; final Color toolbarItemIconColor; final Color toolbarItemIconDisabledColor; final Color toolbarItemIconSelectedColor; final Color toolbarItemSelectedBackgroundColor; final Color toolbarMenuBackgroundColor; final Color toolbarMenuItemBackgroundColor; final Color toolbarMenuItemSelectedBackgroundColor; final Color toolbarMenuIconColor; final Color toolbarMenuIconDisabledColor; final Color toolbarMenuIconSelectedColor; final Color toolbarShadowColor; static ToolbarColorExtension of(BuildContext context) { return Theme.of(context).extension()!; } @override ToolbarColorExtension copyWith({ Color? toolbarBackgroundColor, Color? toolbarItemIconColor, Color? toolbarItemIconDisabledColor, Color? toolbarItemIconSelectedColor, Color? toolbarMenuBackgroundColor, Color? toolbarItemSelectedBackgroundColor, Color? toolbarMenuItemBackgroundColor, Color? toolbarMenuItemSelectedBackgroundColor, Color? toolbarMenuIconColor, Color? toolbarMenuIconDisabledColor, Color? toolbarMenuIconSelectedColor, Color? toolbarShadowColor, }) { return ToolbarColorExtension( toolbarBackgroundColor: toolbarBackgroundColor ?? this.toolbarBackgroundColor, toolbarItemIconColor: toolbarItemIconColor ?? this.toolbarItemIconColor, toolbarItemIconDisabledColor: toolbarItemIconDisabledColor ?? this.toolbarItemIconDisabledColor, toolbarItemIconSelectedColor: toolbarItemIconSelectedColor ?? this.toolbarItemIconSelectedColor, toolbarItemSelectedBackgroundColor: toolbarItemSelectedBackgroundColor ?? this.toolbarItemSelectedBackgroundColor, toolbarMenuBackgroundColor: toolbarMenuBackgroundColor ?? this.toolbarMenuBackgroundColor, toolbarMenuItemBackgroundColor: toolbarMenuItemBackgroundColor ?? this.toolbarMenuItemBackgroundColor, toolbarMenuItemSelectedBackgroundColor: toolbarMenuItemSelectedBackgroundColor ?? this.toolbarMenuItemSelectedBackgroundColor, toolbarMenuIconColor: toolbarMenuIconColor ?? this.toolbarMenuIconColor, toolbarMenuIconDisabledColor: toolbarMenuIconDisabledColor ?? this.toolbarMenuIconDisabledColor, toolbarMenuIconSelectedColor: toolbarMenuIconSelectedColor ?? this.toolbarMenuIconSelectedColor, toolbarShadowColor: toolbarShadowColor ?? this.toolbarShadowColor, ); } @override ToolbarColorExtension lerp(ToolbarColorExtension? other, double t) { if (other is! ToolbarColorExtension) { return this; } return ToolbarColorExtension( toolbarBackgroundColor: Color.lerp(toolbarBackgroundColor, other.toolbarBackgroundColor, t)!, toolbarItemIconColor: Color.lerp(toolbarItemIconColor, other.toolbarItemIconColor, t)!, toolbarItemIconDisabledColor: Color.lerp( toolbarItemIconDisabledColor, other.toolbarItemIconDisabledColor, t, )!, toolbarItemIconSelectedColor: Color.lerp( toolbarItemIconSelectedColor, other.toolbarItemIconSelectedColor, t, )!, toolbarItemSelectedBackgroundColor: Color.lerp( toolbarItemSelectedBackgroundColor, other.toolbarItemSelectedBackgroundColor, t, )!, toolbarMenuBackgroundColor: Color.lerp( toolbarMenuBackgroundColor, other.toolbarMenuBackgroundColor, t, )!, toolbarMenuItemBackgroundColor: Color.lerp( toolbarMenuItemBackgroundColor, other.toolbarMenuItemBackgroundColor, t, )!, toolbarMenuItemSelectedBackgroundColor: Color.lerp( toolbarMenuItemSelectedBackgroundColor, other.toolbarMenuItemSelectedBackgroundColor, t, )!, toolbarMenuIconColor: Color.lerp(toolbarMenuIconColor, other.toolbarMenuIconColor, t)!, toolbarMenuIconDisabledColor: Color.lerp( toolbarMenuIconDisabledColor, other.toolbarMenuIconDisabledColor, t, )!, toolbarMenuIconSelectedColor: Color.lerp( toolbarMenuIconSelectedColor, other.toolbarMenuIconSelectedColor, t, )!, toolbarShadowColor: Color.lerp(toolbarShadowColor, other.toolbarShadowColor, t)!, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; final aaToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, service, onMenu, _) { return AppFlowyMobileToolbarIconItem( editorState: editorState, isSelected: () => service.showMenuNotifier.value, keepSelectedStatus: true, icon: FlowySvgs.m_toolbar_aa_m, onTap: () => onMenu?.call(), ); }, menuBuilder: (context, editorState, service) { final selection = editorState.selection; if (selection == null) { return const SizedBox.shrink(); } return _TextDecorationMenu( editorState, selection, service, ); }, ); class _TextDecorationMenu extends StatefulWidget { const _TextDecorationMenu( this.editorState, this.selection, this.service, ); final EditorState editorState; final Selection selection; final AppFlowyMobileToolbarWidgetService service; @override State<_TextDecorationMenu> createState() => _TextDecorationMenuState(); } class _TextDecorationMenuState extends State<_TextDecorationMenu> { EditorState get editorState => widget.editorState; @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); return ColoredBox( color: theme.toolbarMenuBackgroundColor, child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.only( top: 16, bottom: 20, left: 12, right: 12, ) * context.scale, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ HeadingsAndTextItems( editorState: editorState, ), const ScaledVSpace(), Row( children: [ BIUSItems( editorState: editorState, ), const Spacer(), ColorItem( editorState: editorState, service: widget.service, ), ], ), const ScaledVSpace(), Row( children: [ BlockItems( service: widget.service, editorState: editorState, ), const Spacer(), AlignItems( editorState: editorState, ), ], ), const ScaledVSpace(), Row( children: [ FontFamilyItem( editorState: editorState, ), const Spacer(), IndentAndOutdentItems( service: widget.service, editorState: editorState, ), ], ), ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_attachment_item.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; @visibleForTesting const addAttachmentToolbarItemKey = ValueKey('add_attachment_toolbar_item'); final addAttachmentItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, service, _, onAction) { return AppFlowyMobileToolbarIconItem( key: addAttachmentToolbarItemKey, editorState: editorState, icon: FlowySvgs.media_s, onTap: () { final documentId = context.read().documentId; final isLocalMode = context.read().isLocalMode; final selection = editorState.selection; service.closeKeyboard(); // delay to wait the keyboard closed. Future.delayed(const Duration(milliseconds: 100), () async { unawaited( editorState.updateSelectionWithReason( selection, extraInfo: { selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDoNotAttachTextService: true, }, ), ); keepEditorFocusNotifier.increase(); final didAddAttachment = await showAddAttachmentMenu( AppGlobals.rootNavKey.currentContext!, documentId: documentId, isLocalMode: isLocalMode, editorState: editorState, selection: selection!, ); if (didAddAttachment != true) { WidgetsBinding.instance.addPostFrameCallback((_) { editorState.updateSelectionWithReason(selection); }); } }); }, ); }, ); Future showAddAttachmentMenu( BuildContext context, { required String documentId, required bool isLocalMode, required EditorState editorState, required Selection selection, }) async => showMobileBottomSheet( context, showDragHandle: true, barrierColor: Colors.transparent, backgroundColor: ToolbarColorExtension.of(context).toolbarMenuBackgroundColor, elevation: 20, isScrollControlled: false, enableDraggableScrollable: true, builder: (_) => _AddAttachmentMenu( documentId: documentId, isLocalMode: isLocalMode, editorState: editorState, selection: selection, ), ); class _AddAttachmentMenu extends StatelessWidget { const _AddAttachmentMenu({ required this.documentId, required this.isLocalMode, required this.editorState, required this.selection, }); final String documentId; final bool isLocalMode; final EditorState editorState; final Selection selection; @override Widget build(BuildContext context) { return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ MobileQuickActionButton( text: LocaleKeys.document_attachmentMenu_choosePhoto.tr(), icon: FlowySvgs.image_rounded_s, iconSize: const Size.square(20), onTap: () async => selectPhoto(context), ), const MobileQuickActionDivider(), MobileQuickActionButton( text: LocaleKeys.document_attachmentMenu_takePicture.tr(), icon: FlowySvgs.camera_s, iconSize: const Size.square(20), onTap: () async => selectCamera(context), ), const MobileQuickActionDivider(), MobileQuickActionButton( text: LocaleKeys.document_attachmentMenu_chooseFile.tr(), icon: FlowySvgs.file_s, iconSize: const Size.square(20), onTap: () async => selectFile(context), ), ], ), ); } Future _insertNode(Node node) async { Future.delayed( const Duration(milliseconds: 100), () async { // if current selected block is a empty paragraph block, replace it with the new block. if (selection.isCollapsed) { final path = selection.end.path; final currentNode = editorState.getNodeAtPath(path); final text = currentNode?.delta?.toPlainText(); if (currentNode != null && currentNode.type == ParagraphBlockKeys.type && text != null && text.isEmpty) { final transaction = editorState.transaction; transaction.insertNode(path.next, node); transaction.deleteNode(currentNode); transaction.afterSelection = Selection.collapsed(Position(path: path)); transaction.selectionExtraInfo = {}; return editorState.apply(transaction); } } await editorState.insertBlockAfterCurrentSelection(selection, node); }, ); } Future insertImage(BuildContext context, XFile image) async { CustomImageType type = CustomImageType.local; String? path; if (isLocalMode) { path = await saveImageToLocalStorage(image.path); } else { (path, _) = await saveImageToCloudStorage(image.path, documentId); type = CustomImageType.internal; } if (path != null) { final node = customImageNode(url: path, type: type); await _insertNode(node); } } Future selectPhoto(BuildContext context) async { final image = await ImagePicker().pickImage(source: ImageSource.gallery); if (image != null && context.mounted) { await insertImage(context, image); } else { // refocus the editor WidgetsBinding.instance.addPostFrameCallback((_) { editorState.updateSelectionWithReason(selection); }); } if (context.mounted) { Navigator.pop(context); } } Future selectCamera(BuildContext context) async { final cameraPermission = await PermissionChecker.checkCameraPermission(context); if (!cameraPermission) { Log.error('Has no permission to access the camera'); return; } final image = await ImagePicker().pickImage(source: ImageSource.camera); if (image != null && context.mounted) { await insertImage(context, image); } if (context.mounted) { Navigator.pop(context); } } Future selectFile(BuildContext context) async { final result = await getIt().pickFiles(); final file = result?.files.first.xFile; if (file != null) { FileUrlType type = FileUrlType.local; String? path; if (isLocalMode) { path = await saveFileToLocalStorage(file.path); } else { (path, _) = await saveFileToCloudStorage(file.path, documentId); type = FileUrlType.cloud; } if (path != null) { final node = fileNode(url: path, type: type, name: file.name); await _insertNode(node); } } if (context.mounted) { Navigator.pop(context); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_menu_item_builder.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_add_block_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class AddBlockMenuItemBuilder { AddBlockMenuItemBuilder({ required this.editorState, required this.selection, }); final EditorState editorState; final Selection selection; List> buildTypeOptionMenuItemValues( BuildContext context, ) { if (selection.isCollapsed) { final node = editorState.getNodeAtPath(selection.end.path); if (node?.parentTableCellNode != null) { return _buildTableTypeOptionMenuItemValues(context); } } return _buildDefaultTypeOptionMenuItemValues(context); } /// Build the default type option menu item values. List> _buildDefaultTypeOptionMenuItemValues( BuildContext context, ) { final colorMap = _colorMap(context); return [ ..._buildHeadingMenuItems(colorMap), ..._buildParagraphMenuItems(colorMap), ..._buildTodoListMenuItems(colorMap), ..._buildTableMenuItems(colorMap), ..._buildQuoteMenuItems(colorMap), ..._buildListMenuItems(colorMap), ..._buildToggleHeadingMenuItems(colorMap), ..._buildImageMenuItems(colorMap), ..._buildPhotoGalleryMenuItems(colorMap), ..._buildFileMenuItems(colorMap), ..._buildMentionMenuItems(context, colorMap), ..._buildDividerMenuItems(colorMap), ..._buildCalloutMenuItems(colorMap), ..._buildCodeMenuItems(colorMap), ..._buildMathEquationMenuItems(colorMap), ]; } /// Build the table type option menu item values. List> _buildTableTypeOptionMenuItemValues( BuildContext context, ) { final colorMap = _colorMap(context); return [ ..._buildHeadingMenuItems(colorMap), ..._buildParagraphMenuItems(colorMap), ..._buildTodoListMenuItems(colorMap), ..._buildQuoteMenuItems(colorMap), ..._buildListMenuItems(colorMap), ..._buildToggleHeadingMenuItems(colorMap), ..._buildImageMenuItems(colorMap), ..._buildFileMenuItems(colorMap), ..._buildMentionMenuItems(context, colorMap), ..._buildDividerMenuItems(colorMap), ..._buildCalloutMenuItems(colorMap), ..._buildCodeMenuItems(colorMap), ..._buildMathEquationMenuItems(colorMap), ]; } List> _buildHeadingMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: HeadingBlockKeys.type, backgroundColor: colorMap[HeadingBlockKeys.type]!, text: LocaleKeys.editor_heading1.tr(), icon: FlowySvgs.m_add_block_h1_s, onTap: (_, __) => _insertBlock(headingNode(level: 1)), ), TypeOptionMenuItemValue( value: HeadingBlockKeys.type, backgroundColor: colorMap[HeadingBlockKeys.type]!, text: LocaleKeys.editor_heading2.tr(), icon: FlowySvgs.m_add_block_h2_s, onTap: (_, __) => _insertBlock(headingNode(level: 2)), ), TypeOptionMenuItemValue( value: HeadingBlockKeys.type, backgroundColor: colorMap[HeadingBlockKeys.type]!, text: LocaleKeys.editor_heading3.tr(), icon: FlowySvgs.m_add_block_h3_s, onTap: (_, __) => _insertBlock(headingNode(level: 3)), ), ]; } List> _buildParagraphMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: ParagraphBlockKeys.type, backgroundColor: colorMap[ParagraphBlockKeys.type]!, text: LocaleKeys.editor_text.tr(), icon: FlowySvgs.m_add_block_paragraph_s, onTap: (_, __) => _insertBlock(paragraphNode()), ), ]; } List> _buildTodoListMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: TodoListBlockKeys.type, backgroundColor: colorMap[TodoListBlockKeys.type]!, text: LocaleKeys.editor_checkbox.tr(), icon: FlowySvgs.m_add_block_checkbox_s, onTap: (_, __) => _insertBlock(todoListNode(checked: false)), ), ]; } List> _buildTableMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: SimpleTableBlockKeys.type, backgroundColor: colorMap[SimpleTableBlockKeys.type]!, text: LocaleKeys.editor_table.tr(), icon: FlowySvgs.slash_menu_icon_simple_table_s, onTap: (_, __) => _insertBlock( createSimpleTableBlockNode(columnCount: 2, rowCount: 2), ), ), ]; } List> _buildQuoteMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: QuoteBlockKeys.type, backgroundColor: colorMap[QuoteBlockKeys.type]!, text: LocaleKeys.editor_quote.tr(), icon: FlowySvgs.m_add_block_quote_s, onTap: (_, __) => _insertBlock(quoteNode()), ), ]; } List> _buildListMenuItems( Map colorMap, ) { return [ // bulleted list, numbered list, toggle list TypeOptionMenuItemValue( value: BulletedListBlockKeys.type, backgroundColor: colorMap[BulletedListBlockKeys.type]!, text: LocaleKeys.editor_bulletedListShortForm.tr(), icon: FlowySvgs.m_add_block_bulleted_list_s, onTap: (_, __) => _insertBlock(bulletedListNode()), ), TypeOptionMenuItemValue( value: NumberedListBlockKeys.type, backgroundColor: colorMap[NumberedListBlockKeys.type]!, text: LocaleKeys.editor_numberedListShortForm.tr(), icon: FlowySvgs.m_add_block_numbered_list_s, onTap: (_, __) => _insertBlock(numberedListNode()), ), TypeOptionMenuItemValue( value: ToggleListBlockKeys.type, backgroundColor: colorMap[ToggleListBlockKeys.type]!, text: LocaleKeys.editor_toggleListShortForm.tr(), icon: FlowySvgs.m_add_block_toggle_s, onTap: (_, __) => _insertBlock(toggleListBlockNode()), ), ]; } List> _buildToggleHeadingMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: ToggleListBlockKeys.type, backgroundColor: colorMap[ToggleListBlockKeys.type]!, text: LocaleKeys.editor_toggleHeading1ShortForm.tr(), icon: FlowySvgs.toggle_heading1_s, iconPadding: const EdgeInsets.all(3), onTap: (_, __) => _insertBlock(toggleHeadingNode()), ), TypeOptionMenuItemValue( value: ToggleListBlockKeys.type, backgroundColor: colorMap[ToggleListBlockKeys.type]!, text: LocaleKeys.editor_toggleHeading2ShortForm.tr(), icon: FlowySvgs.toggle_heading2_s, iconPadding: const EdgeInsets.all(3), onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 2)), ), TypeOptionMenuItemValue( value: ToggleListBlockKeys.type, backgroundColor: colorMap[ToggleListBlockKeys.type]!, text: LocaleKeys.editor_toggleHeading3ShortForm.tr(), icon: FlowySvgs.toggle_heading3_s, iconPadding: const EdgeInsets.all(3), onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 3)), ), ]; } List> _buildImageMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: ImageBlockKeys.type, backgroundColor: colorMap[ImageBlockKeys.type]!, text: LocaleKeys.editor_image.tr(), icon: FlowySvgs.m_add_block_image_s, onTap: (_, __) async { AppGlobals.rootNavKey.currentContext?.pop(true); Future.delayed(const Duration(milliseconds: 400), () async { final imagePlaceholderKey = GlobalKey(); await editorState.insertEmptyImageBlock(imagePlaceholderKey); }); }, ), ]; } List> _buildPhotoGalleryMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: MultiImageBlockKeys.type, backgroundColor: colorMap[ImageBlockKeys.type]!, text: LocaleKeys.document_plugins_photoGallery_name.tr(), icon: FlowySvgs.m_add_block_photo_gallery_s, onTap: (_, __) async { AppGlobals.rootNavKey.currentContext?.pop(true); Future.delayed(const Duration(milliseconds: 400), () async { final imagePlaceholderKey = GlobalKey(); await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey); }); }, ), ]; } List> _buildFileMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: FileBlockKeys.type, backgroundColor: colorMap[ImageBlockKeys.type]!, text: LocaleKeys.document_plugins_file_name.tr(), icon: FlowySvgs.media_s, onTap: (_, __) async { AppGlobals.rootNavKey.currentContext?.pop(true); Future.delayed(const Duration(milliseconds: 400), () async { final fileGlobalKey = GlobalKey(); await editorState.insertEmptyFileBlock(fileGlobalKey); }); }, ), ]; } List> _buildMentionMenuItems( BuildContext context, Map colorMap, ) { return [ TypeOptionMenuItemValue( value: ParagraphBlockKeys.type, backgroundColor: colorMap[MentionBlockKeys.type]!, text: LocaleKeys.editor_date.tr(), icon: FlowySvgs.m_add_block_date_s, onTap: (_, __) => _insertBlock(dateMentionNode()), ), TypeOptionMenuItemValue( value: ParagraphBlockKeys.type, backgroundColor: colorMap[MentionBlockKeys.type]!, text: LocaleKeys.editor_page.tr(), icon: FlowySvgs.icon_document_s, onTap: (_, __) async { AppGlobals.rootNavKey.currentContext?.pop(true); final currentViewId = getIt().latestOpenView?.id; final view = await showPageSelectorSheet( context, currentViewId: currentViewId, ); if (view != null) { Future.delayed(const Duration(milliseconds: 100), () { editorState.insertBlockAfterCurrentSelection( selection, pageMentionNode(view.id), ); }); } }, ), ]; } List> _buildDividerMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: DividerBlockKeys.type, backgroundColor: colorMap[DividerBlockKeys.type]!, text: LocaleKeys.editor_divider.tr(), icon: FlowySvgs.m_add_block_divider_s, onTap: (_, __) { AppGlobals.rootNavKey.currentContext?.pop(true); Future.delayed(const Duration(milliseconds: 100), () { editorState.insertDivider(selection); }); }, ), ]; } // callout, code, math equation List> _buildCalloutMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: CalloutBlockKeys.type, backgroundColor: colorMap[CalloutBlockKeys.type]!, text: LocaleKeys.document_plugins_callout.tr(), icon: FlowySvgs.m_add_block_callout_s, onTap: (_, __) => _insertBlock(calloutNode()), ), ]; } List> _buildCodeMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: CodeBlockKeys.type, backgroundColor: colorMap[CodeBlockKeys.type]!, text: LocaleKeys.editor_codeBlockShortForm.tr(), icon: FlowySvgs.m_add_block_code_s, onTap: (_, __) => _insertBlock(codeBlockNode()), ), ]; } List> _buildMathEquationMenuItems( Map colorMap, ) { return [ TypeOptionMenuItemValue( value: MathEquationBlockKeys.type, backgroundColor: colorMap[MathEquationBlockKeys.type]!, text: LocaleKeys.editor_mathEquationShortForm.tr(), icon: FlowySvgs.m_add_block_formula_s, onTap: (_, __) { AppGlobals.rootNavKey.currentContext?.pop(true); Future.delayed(const Duration(milliseconds: 100), () { editorState.insertMathEquation(selection); }); }, ), ]; } Map _colorMap(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; if (isDarkMode) { return { HeadingBlockKeys.type: const Color(0xFF5465A1), ParagraphBlockKeys.type: const Color(0xFF5465A1), TodoListBlockKeys.type: const Color(0xFF4BB299), SimpleTableBlockKeys.type: const Color(0xFF4BB299), QuoteBlockKeys.type: const Color(0xFFBAAC74), BulletedListBlockKeys.type: const Color(0xFFA35F94), NumberedListBlockKeys.type: const Color(0xFFA35F94), ToggleListBlockKeys.type: const Color(0xFFA35F94), ImageBlockKeys.type: const Color(0xFFBAAC74), MentionBlockKeys.type: const Color(0xFF40AAB8), DividerBlockKeys.type: const Color(0xFF4BB299), CalloutBlockKeys.type: const Color(0xFF66599B), CodeBlockKeys.type: const Color(0xFF66599B), MathEquationBlockKeys.type: const Color(0xFF66599B), }; } return { HeadingBlockKeys.type: const Color(0xFFBECCFF), ParagraphBlockKeys.type: const Color(0xFFBECCFF), TodoListBlockKeys.type: const Color(0xFF98F4CD), SimpleTableBlockKeys.type: const Color(0xFF98F4CD), QuoteBlockKeys.type: const Color(0xFFFDEDA7), BulletedListBlockKeys.type: const Color(0xFFFFB9EF), NumberedListBlockKeys.type: const Color(0xFFFFB9EF), ToggleListBlockKeys.type: const Color(0xFFFFB9EF), ImageBlockKeys.type: const Color(0xFFFDEDA7), MentionBlockKeys.type: const Color(0xFF91EAF5), DividerBlockKeys.type: const Color(0xFF98F4CD), CalloutBlockKeys.type: const Color(0xFFCABDFF), CodeBlockKeys.type: const Color(0xFFCABDFF), MathEquationBlockKeys.type: const Color(0xFFCABDFF), }; } Future _insertBlock(Node node) async { AppGlobals.rootNavKey.currentContext?.pop(true); Future.delayed( const Duration(milliseconds: 100), () async { // if current selected block is a empty paragraph block, replace it with the new block. if (selection.isCollapsed) { final currentNode = editorState.getNodeAtPath(selection.end.path); final text = currentNode?.delta?.toPlainText(); if (currentNode != null && currentNode.type == ParagraphBlockKeys.type && text != null && text.isEmpty) { final transaction = editorState.transaction; transaction.insertNode( selection.end.path.next, node, ); transaction.deleteNode(currentNode); if (node.type == SimpleTableBlockKeys.type) { transaction.afterSelection = Selection.collapsed( Position( // table -> row -> cell -> paragraph path: selection.end.path + [0, 0, 0], ), ); } else { transaction.afterSelection = Selection.collapsed( Position(path: selection.end.path), ); } transaction.selectionExtraInfo = {}; await editorState.apply(transaction); return; } } await editorState.insertBlockAfterCurrentSelection(selection, node); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'add_block_menu_item_builder.dart'; @visibleForTesting const addBlockToolbarItemKey = ValueKey('add_block_toolbar_item'); final addBlockToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, service, __, onAction) { return AppFlowyMobileToolbarIconItem( key: addBlockToolbarItemKey, editorState: editorState, icon: FlowySvgs.m_toolbar_add_m, onTap: () { final selection = editorState.selection; service.closeKeyboard(); // delay to wait the keyboard closed. Future.delayed(const Duration(milliseconds: 100), () async { unawaited( editorState.updateSelectionWithReason( selection, extraInfo: { selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDoNotAttachTextService: true, }, ), ); keepEditorFocusNotifier.increase(); final didAddBlock = await showAddBlockMenu( AppGlobals.rootNavKey.currentContext!, editorState: editorState, selection: selection!, ); if (didAddBlock != true) { unawaited(editorState.updateSelectionWithReason(selection)); } }); }, ); }, ); Future showAddBlockMenu( BuildContext context, { required EditorState editorState, required Selection selection, }) async => showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showCloseButton: true, title: LocaleKeys.button_add.tr(), barrierColor: Colors.transparent, backgroundColor: ToolbarColorExtension.of(context).toolbarMenuBackgroundColor, elevation: 20, enableDraggableScrollable: true, builder: (_) => Padding( padding: EdgeInsets.all(16 * context.scale), child: AddBlockMenu(selection: selection, editorState: editorState), ), ); class AddBlockMenu extends StatelessWidget { const AddBlockMenu({ super.key, required this.selection, required this.editorState, }); final Selection selection; final EditorState editorState; @override Widget build(BuildContext context) { final builder = AddBlockMenuItemBuilder( editorState: editorState, selection: selection, ); return TypeOptionMenu( values: builder.buildTypeOptionMenuItemValues(context), scaleFactor: context.scale, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart ================================================ import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; abstract class AppFlowyMobileToolbarWidgetService { void closeItemMenu(); void closeKeyboard(); PropertyValueNotifier get showMenuNotifier; } class AppFlowyMobileToolbar extends StatefulWidget { const AppFlowyMobileToolbar({ super.key, this.toolbarHeight = 50.0, required this.editorState, required this.toolbarItemsBuilder, required this.child, }); final EditorState editorState; final double toolbarHeight; final List Function( Selection? selection, ) toolbarItemsBuilder; final Widget child; @override State createState() => _AppFlowyMobileToolbarState(); } class _AppFlowyMobileToolbarState extends State { OverlayEntry? toolbarOverlay; final isKeyboardShow = ValueNotifier(false); @override void initState() { super.initState(); _insertKeyboardToolbar(); KeyboardHeightObserver.instance.addListener(_onKeyboardHeightChanged); } @override void dispose() { _removeKeyboardToolbar(); KeyboardHeightObserver.instance.removeListener(_onKeyboardHeightChanged); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ Expanded(child: widget.child), // add a bottom offset to make sure the toolbar is above the keyboard ValueListenableBuilder( valueListenable: isKeyboardShow, builder: (context, isKeyboardShow, __) { return SizedBox( // only adding padding when the keyboard is triggered by editor height: isKeyboardShow && widget.editorState.selection != null ? widget.toolbarHeight : 0, ); }, ), ], ); } void _onKeyboardHeightChanged(double height) { isKeyboardShow.value = height > 0; } void _removeKeyboardToolbar() { toolbarOverlay?.remove(); toolbarOverlay?.dispose(); toolbarOverlay = null; } void _insertKeyboardToolbar() { _removeKeyboardToolbar(); Widget child = ValueListenableBuilder( valueListenable: widget.editorState.selectionNotifier, builder: (_, Selection? selection, __) { // if the selection is null, hide the toolbar if (selection == null || widget.editorState.selectionExtraInfo?[ selectionExtraInfoDisableMobileToolbarKey] == true) { return const SizedBox.shrink(); } return RepaintBoundary( child: BlocProvider.value( value: context.read(), child: _MobileToolbar( editorState: widget.editorState, toolbarItems: widget.toolbarItemsBuilder(selection), toolbarHeight: widget.toolbarHeight, ), ), ); }, ); child = Stack( children: [ Positioned( left: 0, right: 0, bottom: 0, child: Material( child: child, ), ), ], ); final router = GoRouter.of(context); toolbarOverlay = OverlayEntry( builder: (context) { return Provider.value( value: router, child: child, ); }, ); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { Overlay.of(context, rootOverlay: true).insert(toolbarOverlay!); }); } } class _MobileToolbar extends StatefulWidget { const _MobileToolbar({ required this.editorState, required this.toolbarItems, required this.toolbarHeight, }); final EditorState editorState; final List toolbarItems; final double toolbarHeight; @override State<_MobileToolbar> createState() => _MobileToolbarState(); } class _MobileToolbarState extends State<_MobileToolbar> implements AppFlowyMobileToolbarWidgetService { // used to control the toolbar menu items @override PropertyValueNotifier showMenuNotifier = PropertyValueNotifier(false); // when the users click the menu item, the keyboard will be hidden, // but in this case, we don't want to update the cached keyboard height. // This is because we want to keep the same height when the menu is shown. bool canUpdateCachedKeyboardHeight = true; /// when the [_MobileToolbar] disposed before the keyboard height can be updated in time, /// there will be an issue with the height being 0 /// this is used to globally record the height. static double _globalCachedKeyboardHeight = 0.0; ValueNotifier cachedKeyboardHeight = ValueNotifier(_globalCachedKeyboardHeight); // used to check if click the same item again int? selectedMenuIndex; Selection? currentSelection; bool closeKeyboardInitiative = false; final ScrollOffsetListener offsetListener = ScrollOffsetListener.create(); late final StreamSubscription offsetSubscription; ValueNotifier toolbarOffset = ValueNotifier(0.0); @override void initState() { super.initState(); currentSelection = widget.editorState.selection; KeyboardHeightObserver.instance.addListener(_onKeyboardHeightChanged); offsetSubscription = offsetListener.changes.listen((event) { toolbarOffset.value += event; }); } @override void didUpdateWidget(covariant _MobileToolbar oldWidget) { super.didUpdateWidget(oldWidget); if (currentSelection != widget.editorState.selection) { currentSelection = widget.editorState.selection; closeItemMenu(); if (currentSelection != null) { _showKeyboard(); } } } @override void dispose() { showMenuNotifier.dispose(); cachedKeyboardHeight.dispose(); KeyboardHeightObserver.instance.removeListener(_onKeyboardHeightChanged); offsetSubscription.cancel(); toolbarOffset.dispose(); super.dispose(); } @override void reassemble() { super.reassemble(); canUpdateCachedKeyboardHeight = true; closeItemMenu(); _closeKeyboard(); } @override Widget build(BuildContext context) { // toolbar // - if the menu is shown, the toolbar will be pushed up by the height of the menu // - otherwise, add a spacer to push the toolbar up when the keyboard is shown return Column( children: [ const Divider( height: 0.5, color: Color(0x7FEDEDED), ), _buildToolbar(context), const Divider( height: 0.5, color: Color(0x7FEDEDED), ), _buildMenuOrSpacer(context), ], ); } @override void closeItemMenu() { showMenuNotifier.value = false; } @override void closeKeyboard() { _closeKeyboard(); } void showItemMenu() { showMenuNotifier.value = true; } void _onKeyboardHeightChanged(double height) { // if the keyboard is not closed initiative, we need to close the menu at same time if (!closeKeyboardInitiative && cachedKeyboardHeight.value != 0 && height == 0) { if (!widget.editorState.isDisposed) { widget.editorState.selection = null; } } // if the menu is shown and the height is not 0, we need to close the menu if (showMenuNotifier.value && height != 0) { closeItemMenu(); } if (canUpdateCachedKeyboardHeight) { cachedKeyboardHeight.value = height; if (defaultTargetPlatform == TargetPlatform.android) { // cache the keyboard height with the view padding in Android if (cachedKeyboardHeight.value != 0) { cachedKeyboardHeight.value += MediaQuery.of(context).viewPadding.bottom; } } } if (height == 0) { closeKeyboardInitiative = false; } } // toolbar list view and close keyboard/menu button Widget _buildToolbar(BuildContext context) { final theme = ToolbarColorExtension.of(context); return Container( height: widget.toolbarHeight, width: MediaQuery.of(context).size.width, decoration: BoxDecoration( color: theme.toolbarBackgroundColor, boxShadow: const [ BoxShadow( color: Color(0x0F181818), blurRadius: 40, offset: Offset(0, -4), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ // toolbar list view Expanded( child: _ToolbarItemListView( offsetListener: offsetListener, toolbarItems: widget.toolbarItems, editorState: widget.editorState, toolbarWidgetService: this, itemWithActionOnPressed: (_) { if (showMenuNotifier.value) { closeItemMenu(); _showKeyboard(); // update the cached keyboard height after the keyboard is shown Debounce.debounce('canUpdateCachedKeyboardHeight', const Duration(milliseconds: 500), () { canUpdateCachedKeyboardHeight = true; }); } }, itemWithMenuOnPressed: (index) { // click the same one if (selectedMenuIndex == index && showMenuNotifier.value) { // if the menu is shown, close it and show the keyboard closeItemMenu(); _showKeyboard(); // update the cached keyboard height after the keyboard is shown Debounce.debounce('canUpdateCachedKeyboardHeight', const Duration(milliseconds: 500), () { canUpdateCachedKeyboardHeight = true; }); } else { canUpdateCachedKeyboardHeight = false; selectedMenuIndex = index; showItemMenu(); closeKeyboardInitiative = true; _closeKeyboard(); } }, ), ), const Padding( padding: EdgeInsets.symmetric(vertical: 13.0), child: VerticalDivider( width: 1.0, thickness: 1.0, color: Color(0xFFD9D9D9), ), ), // close menu or close keyboard button CloseKeyboardOrMenuButton( onPressed: () { closeKeyboardInitiative = true; // close the keyboard and clear the selection // if the selection is null, the keyboard and the toolbar will be hidden automatically widget.editorState.selection = null; // sometimes, the keyboard is not closed after the selection is cleared if (Platform.isAndroid) { SystemChannels.textInput.invokeMethod('TextInput.hide'); } }, ), const HSpace(4.0), ], ), ); } // if there's no menu, we need to add a spacer to push the toolbar up when the keyboard is shown Widget _buildMenuOrSpacer(BuildContext context) { return ValueListenableBuilder( valueListenable: cachedKeyboardHeight, builder: (_, height, ___) { return ValueListenableBuilder( valueListenable: showMenuNotifier, builder: (_, showingMenu, __) { var keyboardHeight = height; if (defaultTargetPlatform == TargetPlatform.android) { if (!showingMenu) { // take the max value of the keyboard height and the view padding // to make sure the toolbar is above the keyboard keyboardHeight = max( keyboardHeight, MediaQuery.of(context).viewInsets.bottom, ); } } if (keyboardHeight > 0) { _globalCachedKeyboardHeight = keyboardHeight; } return SizedBox( height: keyboardHeight, child: (showingMenu && selectedMenuIndex != null) ? widget.toolbarItems[selectedMenuIndex!].menuBuilder?.call( context, widget.editorState, this, ) ?? const SizedBox.shrink() : const SizedBox.shrink(), ); }, ); }, ); } void _showKeyboard() { final selection = widget.editorState.selection; if (selection != null) { widget.editorState.service.keyboardService?.enableKeyBoard(selection); } } void _closeKeyboard() { widget.editorState.service.keyboardService?.closeKeyboard(); } } class _ToolbarItemListView extends StatefulWidget { const _ToolbarItemListView({ required this.offsetListener, required this.toolbarItems, required this.editorState, required this.toolbarWidgetService, required this.itemWithMenuOnPressed, required this.itemWithActionOnPressed, }); final Function(int index) itemWithMenuOnPressed; final Function(int index) itemWithActionOnPressed; final List toolbarItems; final EditorState editorState; final AppFlowyMobileToolbarWidgetService toolbarWidgetService; final ScrollOffsetListener offsetListener; @override State<_ToolbarItemListView> createState() => _ToolbarItemListViewState(); } class _ToolbarItemListViewState extends State<_ToolbarItemListView> { final scrollController = ItemScrollController(); Selection? previousSelection; @override void initState() { super.initState(); widget.editorState.selectionNotifier .addListener(_debounceUpdatePilotPosition); previousSelection = widget.editorState.selection; } @override void dispose() { widget.editorState.selectionNotifier .removeListener(_debounceUpdatePilotPosition); super.dispose(); } @override Widget build(BuildContext context) { const left = 8.0; const right = 4.0; // 68.0 is the width of the close keyboard/menu button final padding = _calculatePadding(left + right + 68.0); final children = [ const HSpace(left), ...widget.toolbarItems .mapIndexed( (index, element) => element.itemBuilder.call( context, widget.editorState, widget.toolbarWidgetService, element.menuBuilder != null ? () { widget.itemWithMenuOnPressed(index); } : null, element.menuBuilder == null ? () { widget.itemWithActionOnPressed(index); } : null, ), ) .map((e) => [e, HSpace(padding)]) .flattened, const HSpace(right), ]; return PageStorage( bucket: PageStorageBucket(), child: ScrollablePositionedList.builder( physics: const ClampingScrollPhysics(), scrollOffsetListener: widget.offsetListener, itemScrollController: scrollController, scrollDirection: Axis.horizontal, itemBuilder: (context, index) => children[index], itemCount: children.length, ), ); } double _calculatePadding(double extent) { final screenWidth = MediaQuery.of(context).size.width; final width = screenWidth - extent; final int count; if (screenWidth <= 340) { count = 5; } else if (screenWidth <= 384) { count = 6; } else if (screenWidth <= 430) { count = 7; } else { count = 8; } // left + item count * width + item count * padding + right + close button width = screenWidth return (width - count * 40.0) / count; } void _debounceUpdatePilotPosition() { Debounce.debounce( 'updatePilotPosition', const Duration(milliseconds: 250), _updatePilotPosition, ); } void _updatePilotPosition() { final selection = widget.editorState.selection; if (selection == null) { return; } if (previousSelection != null && previousSelection!.isCollapsed == selection.isCollapsed) { return; } final toolbarItems = widget.toolbarItems; // use -0.4 to make sure the pilot is in the front of the toolbar item final alignment = selection.isCollapsed ? 0.0 : -0.4; final index = toolbarItems.indexWhere( (element) => selection.isCollapsed ? element.pilotAtCollapsedSelection : element.pilotAtExpandedSelection, ); if (index != -1) { scrollController.scrollTo( alignment: alignment, index: index, duration: const Duration( milliseconds: 250, ), ); } previousSelection = selection; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; // build the toolbar item, like Aa, +, image ... typedef AppFlowyMobileToolbarItemBuilder = Widget Function( BuildContext context, EditorState editorState, AppFlowyMobileToolbarWidgetService service, VoidCallback? onMenuCallback, VoidCallback? onActionCallback, ); // build the menu after clicking the toolbar item typedef AppFlowyMobileToolbarItemMenuBuilder = Widget Function( BuildContext context, EditorState editorState, AppFlowyMobileToolbarWidgetService service, ); class AppFlowyMobileToolbarItem { /// Tool bar item that implements attribute directly(without opening menu) const AppFlowyMobileToolbarItem({ required this.itemBuilder, this.menuBuilder, this.pilotAtCollapsedSelection = false, this.pilotAtExpandedSelection = false, }); final AppFlowyMobileToolbarItemBuilder itemBuilder; final AppFlowyMobileToolbarItemMenuBuilder? menuBuilder; final bool pilotAtCollapsedSelection; final bool pilotAtExpandedSelection; } class AppFlowyMobileToolbarIconItem extends StatefulWidget { const AppFlowyMobileToolbarIconItem({ super.key, this.icon, this.keepSelectedStatus = false, this.iconBuilder, this.isSelected, this.shouldListenToToggledStyle = false, this.enable, required this.onTap, required this.editorState, }); final FlowySvgData? icon; final bool keepSelectedStatus; final VoidCallback onTap; final WidgetBuilder? iconBuilder; final bool Function()? isSelected; final bool shouldListenToToggledStyle; final EditorState editorState; final bool Function()? enable; @override State createState() => _AppFlowyMobileToolbarIconItemState(); } class _AppFlowyMobileToolbarIconItemState extends State { bool isSelected = false; StreamSubscription? _subscription; @override void initState() { super.initState(); isSelected = widget.isSelected?.call() ?? false; if (widget.shouldListenToToggledStyle) { widget.editorState.toggledStyleNotifier.addListener(_rebuild); _subscription = widget.editorState.transactionStream.listen((_) { _rebuild(); }); } } @override void dispose() { if (widget.shouldListenToToggledStyle) { widget.editorState.toggledStyleNotifier.removeListener(_rebuild); _subscription?.cancel(); } super.dispose(); } @override void didUpdateWidget(covariant AppFlowyMobileToolbarIconItem oldWidget) { super.didUpdateWidget(oldWidget); if (widget.isSelected != null) { isSelected = widget.isSelected!.call(); } } @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); final enable = widget.enable?.call() ?? true; return Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: AnimatedGestureDetector( scaleFactor: 0.95, onTapUp: () { widget.onTap(); _rebuild(); }, child: widget.iconBuilder?.call(context) ?? Container( width: 40, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(9), color: isSelected ? theme.toolbarItemSelectedBackgroundColor : null, ), child: FlowySvg( widget.icon!, color: enable ? theme.toolbarItemIconColor : theme.toolbarItemIconDisabledColor, ), ), ), ); } void _rebuild() { if (!mounted) { return; } setState(() { isSelected = (widget.keepSelectedStatus && widget.isSelected == null) ? !isSelected : widget.isSelected?.call() ?? false; }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/basic_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/font_colors.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/widgets.dart'; import 'link_toolbar_item.dart'; final boldToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, isSelected: () => editorState.isTextDecorationSelected( AppFlowyRichTextKeys.bold, ) && editorState.toggledStyle[AppFlowyRichTextKeys.bold] != false, icon: FlowySvgs.m_toolbar_bold_m, onTap: () async => editorState.toggleAttribute( AppFlowyRichTextKeys.bold, selectionExtraInfo: { selectionExtraInfoDisableFloatingToolbar: true, }, ), ); }, ); final italicToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, isSelected: () => editorState.isTextDecorationSelected( AppFlowyRichTextKeys.italic, ), icon: FlowySvgs.m_toolbar_italic_m, onTap: () async => editorState.toggleAttribute( AppFlowyRichTextKeys.italic, selectionExtraInfo: { selectionExtraInfoDisableFloatingToolbar: true, }, ), ); }, ); final underlineToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, isSelected: () => editorState.isTextDecorationSelected( AppFlowyRichTextKeys.underline, ), icon: FlowySvgs.m_toolbar_underline_m, onTap: () async => editorState.toggleAttribute( AppFlowyRichTextKeys.underline, selectionExtraInfo: { selectionExtraInfoDisableFloatingToolbar: true, }, ), ); }, ); final strikethroughToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, isSelected: () => editorState.isTextDecorationSelected( AppFlowyRichTextKeys.strikethrough, ), icon: FlowySvgs.m_toolbar_strike_m, onTap: () async => editorState.toggleAttribute( AppFlowyRichTextKeys.strikethrough, selectionExtraInfo: { selectionExtraInfoDisableFloatingToolbar: true, }, ), ); }, ); final colorToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, service, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, icon: FlowySvgs.m_aa_font_color_m, iconBuilder: (context) { String? getColor(String key) { final selection = editorState.selection; if (selection == null) { return null; } String? color = editorState.toggledStyle[key]; if (color == null) { if (selection.isCollapsed && selection.startIndex != 0) { color = editorState.getDeltaAttributeValueInSelection( key, selection.copyWith( start: selection.start.copyWith( offset: selection.startIndex - 1, ), ), ); } else { color = editorState.getDeltaAttributeValueInSelection( key, ); } } return color; } final textColor = getColor(AppFlowyRichTextKeys.textColor); final backgroundColor = getColor(AppFlowyRichTextKeys.backgroundColor); return Container( width: 40, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(9), color: EditorFontColors.fromBuiltInColors( context, backgroundColor?.tryToColor(), ), ), child: FlowySvg( FlowySvgs.m_aa_font_color_m, color: EditorFontColors.fromBuiltInColors( context, textColor?.tryToColor(), ), ), ); }, onTap: () { service.closeKeyboard(); editorState.updateSelectionWithReason( editorState.selection, extraInfo: { selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDisableFloatingToolbar: true, selectionExtraInfoDoNotAttachTextService: true, }, ); keepEditorFocusNotifier.increase(); showTextColorAndBackgroundColorPicker( context, editorState: editorState, selection: editorState.selection!, ); }, ); }, ); final linkToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, service, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, icon: FlowySvgs.m_toolbar_link_m, onTap: () { onMobileLinkButtonTap(editorState); }, ); }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/indent_outdent_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; final indentToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, keepSelectedStatus: true, isSelected: () => false, enable: () => isIndentable(editorState), icon: FlowySvgs.m_aa_indent_m, onTap: () async { indentCommand.execute(editorState); }, ); }, ); final outdentToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, keepSelectedStatus: true, isSelected: () => false, enable: () => isOutdentable(editorState), icon: FlowySvgs.m_aa_outdent_m, onTap: () async { outdentCommand.execute(editorState); }, ); }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart ================================================ import 'dart:io'; import 'package:keyboard_height_plugin/keyboard_height_plugin.dart'; typedef KeyboardHeightCallback = void Function(double height); // the KeyboardHeightPlugin only accepts one listener, so we need to create a // singleton class to manage the multiple listeners. class KeyboardHeightObserver { KeyboardHeightObserver._() { _keyboardHeightPlugin.onKeyboardHeightChanged((height) { notify(height); }); } static final KeyboardHeightObserver instance = KeyboardHeightObserver._(); static double currentKeyboardHeight = 0; final List _listeners = []; final KeyboardHeightPlugin _keyboardHeightPlugin = KeyboardHeightPlugin(); void addListener(KeyboardHeightCallback listener) { _listeners.add(listener); } void removeListener(KeyboardHeightCallback listener) { _listeners.remove(listener); } void dispose() { _listeners.clear(); _keyboardHeightPlugin.dispose(); } void notify(double height) { // the keyboard height will notify twice with the same value on Android if (Platform.isAndroid && height == currentKeyboardHeight) { return; } for (final listener in _listeners) { listener(height); } currentKeyboardHeight = height; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/link_toolbar_item.dart ================================================ import 'dart:async'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_edit_link_widget.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; Future onMobileLinkButtonTap(EditorState editorState) async { final selection = editorState.selection; if (selection == null) { return; } final nodes = editorState.getNodesInSelection(selection); // show edit link bottom sheet final context = nodes.firstOrNull?.context; if (context != null) { // keep the selection unawaited( editorState.updateSelectionWithReason( selection, extraInfo: { selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDoNotAttachTextService: true, selectionExtraInfoDisableFloatingToolbar: true, }, ), ); keepEditorFocusNotifier.increase(); await showEditLinkBottomSheet(context, selection, editorState); } } Future showEditLinkBottomSheet( BuildContext context, Selection selection, EditorState editorState, ) async { final currentViewId = context.read()?.documentId ?? ''; final text = editorState.getTextInSelection(selection).join(); final href = editorState.getDeltaAttributeValueInSelection( AppFlowyRichTextKeys.href, selection, ); final isPage = editorState.getDeltaAttributeValueInSelection( kIsPageLink, selection, ); final linkInfo = LinkInfo(name: text, link: href ?? '', isPage: isPage ?? false); return showMobileBottomSheet( context, showDragHandle: true, padding: const EdgeInsets.symmetric(horizontal: 16), builder: (context) { return MobileBottomSheetEditLinkWidget( currentViewId: currentViewId, linkInfo: linkInfo, onApply: (info) => editorState.applyLink(selection, info), onRemoveLink: (_) => editorState.removeLink(selection), onDispose: () { editorState.service.keyboardService?.closeKeyboard(); }, ); }, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/list_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; final todoListToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, keepSelectedStatus: true, isSelected: () => false, icon: FlowySvgs.m_toolbar_checkbox_m, onTap: () async { await editorState.convertBlockType( TodoListBlockKeys.type, extraAttributes: { TodoListBlockKeys.checked: false, }, ); }, ); }, ); final numberedListToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { final isSelected = editorState.isBlockTypeSelected(NumberedListBlockKeys.type); return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, keepSelectedStatus: true, isSelected: () => isSelected, icon: FlowySvgs.m_toolbar_numbered_list_m, onTap: () async { await editorState.convertBlockType( NumberedListBlockKeys.type, ); }, ); }, ); final bulletedListToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { final isSelected = editorState.isBlockTypeSelected(BulletedListBlockKeys.type); return AppFlowyMobileToolbarIconItem( editorState: editorState, shouldListenToToggledStyle: true, keepSelectedStatus: true, isSelected: () => isSelected, icon: FlowySvgs.m_toolbar_bulleted_list_m, onTap: () async { await editorState.convertBlockType( BulletedListBlockKeys.type, ); }, ); }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/more_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; final moreToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { return AppFlowyMobileToolbarIconItem( editorState: editorState, icon: FlowySvgs.m_toolbar_more_s, onTap: () {}, ); }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/toolbar_item_builder.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; final _listBlockTypes = [ BulletedListBlockKeys.type, NumberedListBlockKeys.type, TodoListBlockKeys.type, ]; final _defaultToolbarItems = [ addBlockToolbarItem, aaToolbarItem, todoListToolbarItem, bulletedListToolbarItem, addAttachmentItem, numberedListToolbarItem, boldToolbarItem, italicToolbarItem, underlineToolbarItem, strikethroughToolbarItem, colorToolbarItem, undoToolbarItem, redoToolbarItem, ]; final _listToolbarItems = [ addBlockToolbarItem, aaToolbarItem, outdentToolbarItem, indentToolbarItem, todoListToolbarItem, bulletedListToolbarItem, numberedListToolbarItem, boldToolbarItem, italicToolbarItem, underlineToolbarItem, strikethroughToolbarItem, colorToolbarItem, addAttachmentItem, undoToolbarItem, redoToolbarItem, ]; final _textToolbarItems = [ aaToolbarItem, boldToolbarItem, italicToolbarItem, underlineToolbarItem, strikethroughToolbarItem, colorToolbarItem, ]; /// Calculate the toolbar items based on the current selection. /// /// Default: /// Add, Aa, Todo List, Image, Bulleted List, Numbered List, B, I, U, S, Color, Undo, Redo /// /// Selecting text: /// Aa, B, I, U, S, Color /// /// Selecting a list: /// Add, Aa, Indent, Outdent, Bulleted List, Numbered List, Todo List B, I, U, S List buildMobileToolbarItems( EditorState editorState, Selection? selection, ) { if (selection == null) { return []; } if (!selection.isCollapsed) { final items = List.of(_textToolbarItems); if (onlyShowInSingleSelectionAndTextType(editorState)) { items.add(linkToolbarItem); } return items; } final allSelectedAreListType = editorState .getSelectedNodes(selection: selection) .every((node) => _listBlockTypes.contains(node.type)); if (allSelectedAreListType) { return _listToolbarItems; } return _defaultToolbarItems; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/undo_redo_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/widgets.dart'; final undoToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { final theme = ToolbarColorExtension.of(context); return AppFlowyMobileToolbarIconItem( editorState: editorState, iconBuilder: (context) { final canUndo = editorState.undoManager.undoStack.isNonEmpty; return Container( width: 40, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(9), ), child: FlowySvg( FlowySvgs.m_toolbar_undo_m, color: canUndo ? theme.toolbarItemIconColor : theme.toolbarItemIconDisabledColor, ), ); }, onTap: () => undoCommand.execute(editorState), ); }, ); final redoToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, _, __, onAction) { final theme = ToolbarColorExtension.of(context); return AppFlowyMobileToolbarIconItem( editorState: editorState, iconBuilder: (context) { final canRedo = editorState.undoManager.redoStack.isNonEmpty; return Container( width: 40, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(9), ), child: FlowySvg( FlowySvgs.m_toolbar_redo_m, color: canRedo ? theme.toolbarItemIconColor : theme.toolbarItemIconDisabledColor, ), ); }, onTap: () => redoCommand.execute(editorState), ); }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class MobileToolbarMenuItemWrapper extends StatelessWidget { const MobileToolbarMenuItemWrapper({ super.key, required this.size, this.icon, this.text, this.backgroundColor, this.selectedBackgroundColor, this.enable, this.fontFamily, required this.isSelected, required this.iconPadding, this.enableBottomLeftRadius = true, this.enableBottomRightRadius = true, this.enableTopLeftRadius = true, this.enableTopRightRadius = true, this.showDownArrow = false, this.showRightArrow = false, this.textPadding = EdgeInsets.zero, required this.onTap, this.iconColor, }); final Size size; final VoidCallback onTap; final FlowySvgData? icon; final String? text; final bool? enable; final String? fontFamily; final bool isSelected; final EdgeInsets iconPadding; final bool enableTopLeftRadius; final bool enableTopRightRadius; final bool enableBottomRightRadius; final bool enableBottomLeftRadius; final bool showDownArrow; final bool showRightArrow; final Color? backgroundColor; final Color? selectedBackgroundColor; final EdgeInsets textPadding; final Color? iconColor; @override Widget build(BuildContext context) { final theme = ToolbarColorExtension.of(context); Color? iconColor = this.iconColor; if (iconColor == null) { if (enable != null) { iconColor = enable! ? null : theme.toolbarMenuIconDisabledColor; } else { iconColor = isSelected ? theme.toolbarMenuIconSelectedColor : theme.toolbarMenuIconColor; } } final textColor = enable == false ? theme.toolbarMenuIconDisabledColor : null; // the ui design is based on 375.0 width final scale = context.scale; final radius = Radius.circular(12 * scale); final Widget child; if (icon != null) { child = FlowySvg(icon!, color: iconColor); } else if (text != null) { child = Padding( padding: textPadding * scale, child: FlowyText( text!, fontSize: 16.0, color: textColor, fontFamily: fontFamily, overflow: TextOverflow.ellipsis, ), ); } else { throw ArgumentError('icon and text cannot be null at the same time'); } return GestureDetector( behavior: HitTestBehavior.opaque, onTap: enable == false ? null : onTap, child: Stack( children: [ Container( height: size.height * scale, width: size.width * scale, alignment: text != null ? Alignment.centerLeft : Alignment.center, decoration: BoxDecoration( color: isSelected ? (selectedBackgroundColor ?? theme.toolbarMenuItemSelectedBackgroundColor) : backgroundColor, borderRadius: BorderRadius.only( topLeft: enableTopLeftRadius ? radius : Radius.zero, topRight: enableTopRightRadius ? radius : Radius.zero, bottomRight: enableBottomRightRadius ? radius : Radius.zero, bottomLeft: enableBottomLeftRadius ? radius : Radius.zero, ), ), padding: iconPadding * scale, child: child, ), if (showDownArrow) Positioned( right: 9.0 * scale, bottom: 9.0 * scale, child: const FlowySvg(FlowySvgs.m_aa_down_arrow_s), ), if (showRightArrow) Positioned.fill( right: 12.0 * scale, child: Align( alignment: Alignment.centerRight, child: FlowySvg( FlowySvgs.m_aa_arrow_right_s, color: iconColor, ), ), ), ], ), ); } } class ScaledVerticalDivider extends StatelessWidget { const ScaledVerticalDivider({super.key}); @override Widget build(BuildContext context) { return HSpace(1.5 * context.scale); } } class ScaledVSpace extends StatelessWidget { const ScaledVSpace({super.key}); @override Widget build(BuildContext context) { return VSpace(12.0 * context.scale); } } extension MobileToolbarBuildContext on BuildContext { double get scale => MediaQuery.of(this).size.width / 375.0; } final _blocksCanContainChildren = [ ParagraphBlockKeys.type, BulletedListBlockKeys.type, NumberedListBlockKeys.type, TodoListBlockKeys.type, ]; extension MobileToolbarEditorState on EditorState { bool isBlockTypeSelected(String blockType, {int? level}) { final selection = this.selection; if (selection == null) { return false; } final node = getNodeAtPath(selection.start.path); final type = node?.type; if (node == null || type == null) { return false; } if (level != null && blockType == HeadingBlockKeys.type) { return type == blockType && node.attributes[HeadingBlockKeys.level] == level; } return type == blockType; } bool isTextDecorationSelected(String richTextKey) { final selection = this.selection; if (selection == null) { return false; } final nodes = getNodesInSelection(selection); bool isSelected = false; if (selection.isCollapsed) { if (toggledStyle.containsKey(richTextKey)) { isSelected = toggledStyle[richTextKey] as bool; } else { if (selection.startIndex != 0) { // get previous index text style isSelected = nodes.allSatisfyInSelection( selection.copyWith( start: selection.start.copyWith( offset: selection.startIndex - 1, ), ), (delta) => delta.everyAttributes( (attributes) => attributes[richTextKey] == true, ), ); } } } else { isSelected = nodes.allSatisfyInSelection(selection, (delta) { return delta.everyAttributes((attr) => attr[richTextKey] == true); }); } return isSelected; } Future convertBlockType( String newBlockType, { Selection? selection, Attributes? extraAttributes, bool? isSelected, Map? selectionExtraInfo, }) async { selection = selection ?? this.selection; if (selection == null) { return; } final node = getNodeAtPath(selection.start.path); final type = node?.type; if (node == null || type == null) { assert(false, 'node or type is null'); return; } final selected = isSelected ?? type == newBlockType; // if the new block type can't contain children, we need to move all the children to the parent bool needToDeleteChildren = false; if (!selected && node.children.isNotEmpty && !_blocksCanContainChildren.contains(newBlockType)) { final transaction = this.transaction; needToDeleteChildren = true; transaction.insertNodes( selection.end.path.next, node.children.map((e) => e.deepCopy()), ); await apply(transaction); } await formatNode( selection, (node) { final attributes = { ParagraphBlockKeys.delta: (node.delta ?? Delta()).toJson(), // for some block types, they have extra attributes, like todo list has checked attribute, callout has icon attribute, etc. if (!selected && extraAttributes != null) ...extraAttributes, }; return node.copyWith( type: selected ? ParagraphBlockKeys.type : newBlockType, attributes: attributes, children: needToDeleteChildren ? [] : null, ); }, selectionExtraInfo: selectionExtraInfo, ); } Future alignBlock( String alignment, { Selection? selection, Map? selectionExtraInfo, }) async { await updateNode( selection, (node) => node.copyWith( attributes: { ...node.attributes, blockComponentAlign: alignment, }, ), selectionExtraInfo: selectionExtraInfo, ); } Future updateTextAndHref( String? prevText, String? prevHref, String? text, String? href, { Selection? selection, }) async { if (prevText == null && text == null) { return; } selection ??= this.selection; // doesn't support multiple selection now if (selection == null || !selection.isSingle) { return; } final node = getNodeAtPath(selection.start.path); if (node == null) { return; } final transaction = this.transaction; // insert a new link if (prevText == null && text != null && text.isNotEmpty && selection.isCollapsed) { final attributes = href != null && href.isNotEmpty ? {AppFlowyRichTextKeys.href: href} : null; transaction.insertText( node, selection.startIndex, text, attributes: attributes, ); } else if (text != null && prevText != text) { // update text transaction.replaceText( node, selection.startIndex, selection.length, text, ); } // if the text is empty, it means the user wants to remove the text if (text != null && text.isNotEmpty && prevHref != href) { // update href transaction.formatText( node, selection.startIndex, text.length, {AppFlowyRichTextKeys.href: href?.isEmpty == true ? null : href}, ); } await apply(transaction); } Future insertBlockAfterCurrentSelection( Selection selection, Node node, ) async { final path = selection.end.path.next; final transaction = this.transaction; transaction.insertNode( path, node, ); transaction.afterSelection = Selection.collapsed( Position(path: path), ); transaction.selectionExtraInfo = {}; await apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:numerus/roman/roman.dart'; class NumberedListIcon extends StatelessWidget { const NumberedListIcon({ super.key, required this.node, required this.textDirection, this.textStyle, }); final Node node; final TextDirection textDirection; final TextStyle? textStyle; @override Widget build(BuildContext context) { final textStyleConfiguration = context.read().editorStyle.textStyleConfiguration; final height = textStyleConfiguration.text.height ?? textStyleConfiguration.lineHeight; final combinedTextStyle = textStyle?.combine(textStyleConfiguration.text) ?? textStyleConfiguration.text; final adjustedTextStyle = combinedTextStyle.copyWith( height: height, fontFeatures: [const FontFeature.tabularFigures()], ); return Padding( padding: const EdgeInsets.only(left: 6.0, right: 10.0), child: Text( node.buildLevelString(context), style: adjustedTextStyle, strutStyle: StrutStyle.fromTextStyle(combinedTextStyle), textHeightBehavior: TextHeightBehavior( applyHeightToFirstAscent: textStyleConfiguration.applyHeightToFirstAscent, applyHeightToLastDescent: textStyleConfiguration.applyHeightToLastDescent, leadingDistribution: textStyleConfiguration.leadingDistribution, ), textDirection: textDirection, ), ); } } extension NumberedListNodeIndex on Node { String buildLevelString(BuildContext context) { final builder = NumberedListIndexBuilder( editorState: context.read(), node: this, ); final indexInRootLevel = builder.indexInRootLevel; final indexInSameLevel = builder.indexInSameLevel; final level = indexInRootLevel % 3; final levelString = switch (level) { 1 => indexInSameLevel.latin, 2 => indexInSameLevel.roman, _ => '$indexInSameLevel', }; return '$levelString.'; } } class NumberedListIndexBuilder { NumberedListIndexBuilder({ required this.editorState, required this.node, }); final EditorState editorState; final Node node; // the level of the current node int get indexInRootLevel { var level = 0; var parent = node.parent; while (parent != null) { if (parent.type == NumberedListBlockKeys.type) { level++; } parent = parent.parent; } return level; } // the index of the current level int get indexInSameLevel { int level = 1; Node? previous = node.previous; // if the previous one is not a numbered list, then it is the first one final aiNodeExternalValues = node.externalValues?.unwrapOrNull(); if (previous == null || previous.type != NumberedListBlockKeys.type || (aiNodeExternalValues != null && aiNodeExternalValues.isFirstNumberedListNode)) { return node.attributes[NumberedListBlockKeys.number] ?? level; } int? startNumber; while (previous != null && previous.type == NumberedListBlockKeys.type) { startNumber = previous.attributes[NumberedListBlockKeys.number] as int?; level++; previous = previous.previous; // break the loop if the start number is found when the current node is an AI node if (aiNodeExternalValues != null && startNumber != null) { return startNumber + level - 1; } } if (startNumber != null) { level = startNumber + level - 1; } return level; } } extension on int { String get latin { String result = ''; int number = this; while (number > 0) { final int remainder = (number - 1) % 26; result = String.fromCharCode(remainder + 65) + result; number = (number - 1) ~/ 26; } return result.toLowerCase(); } String get roman { return toRomanNumeralString() ?? '$this'; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class OutlineBlockKeys { const OutlineBlockKeys._(); static const String type = 'outline'; static const String backgroundColor = blockComponentBackgroundColor; static const String depth = 'depth'; } Node outlineBlockNode() { return Node( type: OutlineBlockKeys.type, ); } enum _OutlineBlockStatus { noHeadings, noMatchHeadings, success; } final _availableBlockTypes = [ HeadingBlockKeys.type, ToggleListBlockKeys.type, ]; class OutlineBlockComponentBuilder extends BlockComponentBuilder { OutlineBlockComponentBuilder({ super.configuration, }); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return OutlineBlockWidget( key: node.key, node: node, configuration: configuration, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (node) => node.children.isEmpty; } class OutlineBlockWidget extends BlockComponentStatefulWidget { const OutlineBlockWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @override State createState() => _OutlineBlockWidgetState(); } class _OutlineBlockWidgetState extends State with BlockComponentConfigurable, BlockComponentTextDirectionMixin, BlockComponentBackgroundColorMixin, DefaultSelectableMixin, SelectableMixin { // Change the value if the heading block type supports heading levels greater than '3' static const maxVisibleDepth = 6; @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; @override late EditorState editorState = context.read(); late Stream stream = editorState.transactionStream; @override GlobalKey> blockComponentKey = GlobalKey( debugLabel: OutlineBlockKeys.type, ); @override GlobalKey> get containerKey => widget.node.key; @override GlobalKey> get forwardKey => widget.node.key; @override Widget build(BuildContext context) { return StreamBuilder( stream: stream, builder: (context, snapshot) { Widget child = _buildOutlineBlock(); child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, remoteSelection: editorState.remoteSelections, blockColor: editorState.editorStyle.selectionColor, selectionAboveBlock: true, supportTypes: const [ BlockSelectionType.block, ], child: child, ); if (UniversalPlatform.isDesktopOrWeb) { if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: widget.node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } } else { child = Padding( padding: padding, child: MobileBlockActionButtons( node: node, editorState: editorState, child: child, ), ); } return child; }, ); } Widget _buildOutlineBlock() { final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); final (status, headings) = getHeadingNodes(); Widget child; switch (status) { case _OutlineBlockStatus.noHeadings: child = Align( alignment: Alignment.centerLeft, child: Text( LocaleKeys.document_plugins_outline_addHeadingToCreateOutline.tr(), style: configuration.placeholderTextStyle(node), ), ); case _OutlineBlockStatus.noMatchHeadings: child = Align( alignment: Alignment.centerLeft, child: Text( LocaleKeys.document_plugins_outline_noMatchHeadings.tr(), style: configuration.placeholderTextStyle(node), ), ); case _OutlineBlockStatus.success: final children = headings .map( (e) => Container( padding: const EdgeInsets.only( bottom: 4.0, ), width: double.infinity, child: OutlineItemWidget( node: e, textDirection: textDirection, ), ), ) .toList(); child = Padding( padding: const EdgeInsets.only(left: 15.0), child: Column( children: children, ), ); } return Container( key: blockComponentKey, constraints: const BoxConstraints( minHeight: 40.0, ), padding: UniversalPlatform.isMobile ? EdgeInsets.zero : padding, child: Container( padding: const EdgeInsets.symmetric( vertical: 2.0, horizontal: 5.0, ), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(8.0)), color: backgroundColor, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, textDirection: textDirection, children: [ Text( LocaleKeys.document_outlineBlock_placeholder.tr(), style: Theme.of(context).textTheme.titleLarge, ), const VSpace(8.0), child, ], ), ), ); } (_OutlineBlockStatus, Iterable) getHeadingNodes() { final nodes = NodeIterator( document: editorState.document, startNode: editorState.document.root, ).toList(); final level = node.attributes[OutlineBlockKeys.depth] ?? maxVisibleDepth; var headings = nodes.where( (e) => _isHeadingNode(e), ); if (headings.isEmpty) { return (_OutlineBlockStatus.noHeadings, []); } headings = headings.where( (e) => (e.type == HeadingBlockKeys.type && e.attributes[HeadingBlockKeys.level] <= level) || (e.type == ToggleListBlockKeys.type && e.attributes[ToggleListBlockKeys.level] <= level), ); if (headings.isEmpty) { return (_OutlineBlockStatus.noMatchHeadings, []); } return (_OutlineBlockStatus.success, headings); } bool _isHeadingNode(Node node) { if (node.type == HeadingBlockKeys.type && node.delta?.isNotEmpty == true) { return true; } if (node.type == ToggleListBlockKeys.type && node.delta?.isNotEmpty == true && node.attributes[ToggleListBlockKeys.level] != null) { return true; } return false; } } class OutlineItemWidget extends StatelessWidget { OutlineItemWidget({ super.key, required this.node, required this.textDirection, }) { assert(_availableBlockTypes.contains(node.type)); } final Node node; final TextDirection textDirection; @override Widget build(BuildContext context) { return FlowyButton( onTap: () => scrollToBlock(context), text: Row( textDirection: textDirection, children: [ HSpace(node.leftIndent), Flexible( child: buildOutlineItemWidget(context), ), ], ), ); } void scrollToBlock(BuildContext context) { final editorState = context.read(); final editorScrollController = context.read(); editorScrollController.itemScrollController.jumpTo( index: node.path.first, alignment: 0.5, ); editorState.selection = Selection.collapsed( Position(path: node.path, offset: node.delta?.length ?? 0), ); } Widget buildOutlineItemWidget(BuildContext context) { final editorState = context.read(); final textStyle = editorState.editorStyle.textStyleConfiguration; final style = textStyle.href.combine(textStyle.text); final textInserted = node.delta?.whereType(); if (textInserted == null) { return const SizedBox.shrink(); } final children = []; var i = 0; for (final e in textInserted) { final mentionAttribute = e.attributes?[MentionBlockKeys.mention]; final mention = mentionAttribute is Map ? mentionAttribute : null; final text = e.text; if (mention != null) { final type = mention[MentionBlockKeys.type]; children.add( WidgetSpan( alignment: PlaceholderAlignment.middle, child: MentionBlock( key: ValueKey(type), node: node, index: i, mention: mention, textStyle: style, ), ), ); } else { children.add( TextSpan( text: text, style: style, ), ); } i += text.length; } return IgnorePointer( child: Text.rich( TextSpan( children: children, style: style, ), ), ); } } extension on Node { double get leftIndent { assert(_availableBlockTypes.contains(type)); if (!_availableBlockTypes.contains(type)) { return 0.0; } final level = attributes[HeadingBlockKeys.level] ?? attributes[ToggleListBlockKeys.level]; if (level != null) { final indent = (level - 1) * 15.0; return indent; } return 0.0; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_block/custom_page_block_component.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/block_component/base_component/widget/ignore_parent_gesture.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class CustomPageBlockComponentBuilder extends BlockComponentBuilder { @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { return CustomPageBlockComponent( key: blockComponentContext.node.key, node: blockComponentContext.node, header: blockComponentContext.header, footer: blockComponentContext.footer, wrapper: blockComponentContext.wrapper, ); } } class CustomPageBlockComponent extends BlockComponentStatelessWidget { const CustomPageBlockComponent({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), this.header, this.footer, this.wrapper, }); final Widget? header; final Widget? footer; final BlockComponentWrapper? wrapper; @override Widget build(BuildContext context) { final editorState = context.read(); final scrollController = context.read(); final items = node.children; if (scrollController == null || scrollController.shrinkWrap) { return SingleChildScrollView( child: Builder( builder: (context) { final scroller = Scrollable.maybeOf(context); if (scroller != null) { editorState.updateAutoScroller(scroller); } return Column( children: [ if (header != null) header!, ...items.map( (e) { Widget child = editorState.renderer.build(context, e); if (wrapper != null) { child = wrapper!(context, node: e, child: child); } return Container( constraints: BoxConstraints( maxWidth: editorState.editorStyle.maxWidth ?? double.infinity, ), padding: editorState.editorStyle.padding, child: child, ); }, ), if (footer != null) footer!, ], ); }, ), ); } else { int extentCount = 0; if (header != null) extentCount++; if (footer != null) extentCount++; return ScrollablePositionedList.builder( shrinkWrap: scrollController.shrinkWrap, itemCount: items.length + extentCount, itemBuilder: (context, index) { editorState.updateAutoScroller(Scrollable.of(context)); if (header != null && index == 0) { return IgnoreEditorSelectionGesture( child: header!, ); } if (footer != null && index == (items.length - 1) + extentCount) { return IgnoreEditorSelectionGesture( child: footer!, ); } final childNode = items[index - (header != null ? 1 : 0)]; final isOverflowType = overflowTypes.contains(childNode.type); Widget child = editorState.renderer.build(context, childNode); if (wrapper != null) { child = wrapper!(context, node: childNode, child: child); } final item = Container( constraints: BoxConstraints( maxWidth: editorState.editorStyle.maxWidth ?? double.infinity, ), padding: isOverflowType ? EdgeInsets.zero : editorState.editorStyle.padding, child: child, ); return isOverflowType ? item : Center(child: item); }, itemScrollController: scrollController.itemScrollController, scrollOffsetController: scrollController.scrollOffsetController, itemPositionsListener: scrollController.itemPositionsListener, scrollOffsetListener: scrollController.scrollOffsetListener, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; import 'package:appflowy/shared/feedback_gesture_detector.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class PageCoverBottomSheet extends StatelessWidget { const PageCoverBottomSheet({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const VSpace(8.0), // pure colors FlowyText( LocaleKeys.pageStyle_colors.tr(), color: context.pageStyleTextColor, fontSize: 14.0, ), const VSpace(8.0), _buildPureColors(context, state), const VSpace(20.0), // gradient colors FlowyText( LocaleKeys.pageStyle_gradient.tr(), color: context.pageStyleTextColor, fontSize: 14.0, ), const VSpace(8.0), _buildGradientColors(context, state), const VSpace(20.0), // built-in images FlowyText( LocaleKeys.pageStyle_backgroundImage.tr(), color: context.pageStyleTextColor, fontSize: 14.0, ), const VSpace(8.0), _buildBuiltImages(context, state), ], ), ); }, ); } Widget _buildPureColors( BuildContext context, DocumentPageStyleState state, ) { return SizedBox( height: 42.0, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: FlowyTint.values.length, separatorBuilder: (context, index) => const HSpace(12.0), itemBuilder: (context, index) => _buildColorButton( context, state, FlowyTint.values[index], ), ), ); } Widget _buildGradientColors( BuildContext context, DocumentPageStyleState state, ) { return SizedBox( height: 42.0, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: FlowyGradientColor.values.length, separatorBuilder: (context, index) => const HSpace(12.0), itemBuilder: (context, index) => _buildGradientButton( context, state, FlowyGradientColor.values[index], ), ), ); } Widget _buildColorButton( BuildContext context, DocumentPageStyleState state, FlowyTint tint, ) { final isSelected = state.coverImage.isPureColor && state.coverImage.value == tint.id; final child = !isSelected ? Container( width: 42, height: 42, decoration: ShapeDecoration( color: tint.color(context), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(21), ), ), ) : Container( width: 42, height: 42, decoration: ShapeDecoration( color: Colors.transparent, shape: RoundedRectangleBorder( side: BorderSide( width: 1.50, color: Theme.of(context).colorScheme.primary, ), borderRadius: BorderRadius.circular(21), ), ), alignment: Alignment.center, child: Container( width: 34, height: 34, decoration: ShapeDecoration( color: tint.color(context), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(17), ), ), ), ); return FeedbackGestureDetector( onTap: () { context.read().add( DocumentPageStyleEvent.updateCoverImage( PageStyleCover( type: PageStyleCoverImageType.pureColor, value: tint.id, ), ), ); }, child: child, ); } Widget _buildGradientButton( BuildContext context, DocumentPageStyleState state, FlowyGradientColor gradientColor, ) { final isSelected = state.coverImage.isGradient && state.coverImage.value == gradientColor.id; final child = !isSelected ? Container( width: 42, height: 42, decoration: ShapeDecoration( gradient: gradientColor.linear, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(21), ), ), ) : Container( width: 42, height: 42, decoration: ShapeDecoration( color: Colors.transparent, shape: RoundedRectangleBorder( side: BorderSide( width: 1.50, color: Theme.of(context).colorScheme.primary, ), borderRadius: BorderRadius.circular(21), ), ), alignment: Alignment.center, child: Container( width: 34, height: 34, decoration: ShapeDecoration( gradient: gradientColor.linear, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(17), ), ), ), ); return FeedbackGestureDetector( onTap: () { context.read().add( DocumentPageStyleEvent.updateCoverImage( PageStyleCover( type: PageStyleCoverImageType.gradientColor, value: gradientColor.id, ), ), ); }, child: child, ); } Widget _buildBuiltImages( BuildContext context, DocumentPageStyleState state, ) { final imageNames = ['1', '2', '3', '4', '5', '6']; return GridView.builder( shrinkWrap: true, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 12, mainAxisSpacing: 12, childAspectRatio: 16.0 / 9.0, ), itemCount: imageNames.length, itemBuilder: (context, index) => _buildBuiltInImage( context, state, imageNames[index], ), ); } Widget _buildBuiltInImage( BuildContext context, DocumentPageStyleState state, String imageName, ) { final asset = PageStyleCoverImageType.builtInImagePath(imageName); final isSelected = state.coverImage.isBuiltInImage && state.coverImage.value == imageName; final image = ClipRRect( borderRadius: BorderRadius.circular(4), child: Image.asset( asset, fit: BoxFit.cover, ), ); final child = !isSelected ? image : Container( clipBehavior: Clip.antiAlias, decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: const BorderSide(width: 1.50, color: Color(0xFF00BCF0)), borderRadius: BorderRadius.circular(6), ), ), padding: const EdgeInsets.all(2.0), child: image, ); return FeedbackGestureDetector( onTap: () { context.read().add( DocumentPageStyleEvent.updateCoverImage( PageStyleCover( type: PageStyleCoverImageType.builtInImage, value: imageName, ), ), ); }, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/feedback_gesture_detector.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; class PageStyleCoverImage extends StatelessWidget { PageStyleCoverImage({ super.key, required this.documentId, }); final String documentId; late final ImagePicker _imagePicker = ImagePicker(); @override Widget build(BuildContext context) { final backgroundColor = context.pageStyleBackgroundColor; return BlocBuilder( builder: (context, state) { return Column( children: [ _buildOptionGroup(context, backgroundColor, state), const VSpace(16.0), _buildPreview(context, state), ], ); }, ); } Widget _buildOptionGroup( BuildContext context, Color backgroundColor, DocumentPageStyleState state, ) { return Container( decoration: BoxDecoration( color: backgroundColor, borderRadius: const BorderRadius.horizontal( left: Radius.circular(12), right: Radius.circular(12), ), ), padding: const EdgeInsets.all(4.0), child: Row( children: [ _CoverOptionButton( showLeftCorner: true, showRightCorner: false, selected: state.coverImage.isPresets, onTap: () => _showPresets(context), child: const _PresetCover(), ), _CoverOptionButton( showLeftCorner: false, showRightCorner: false, selected: state.coverImage.isPhoto, onTap: () => _pickImage(context), child: const _PhotoCover(), ), _CoverOptionButton( showLeftCorner: false, showRightCorner: true, selected: state.coverImage.isUnsplashImage, onTap: () => _showUnsplash(context), child: const _UnsplashCover(), ), ], ), ); } Widget _buildPreview( BuildContext context, DocumentPageStyleState state, ) { final cover = state.coverImage; if (cover.isNone) { return const SizedBox.shrink(); } final value = cover.value; final type = cover.type; Widget preview = const SizedBox.shrink(); if (type == PageStyleCoverImageType.customImage || type == PageStyleCoverImageType.unsplashImage) { final userProfilePB = context.read().state.userProfilePB; preview = FlowyNetworkImage( url: value, userProfilePB: userProfilePB, ); } if (type == PageStyleCoverImageType.builtInImage) { preview = Image.asset( PageStyleCoverImageType.builtInImagePath(value), fit: BoxFit.cover, ); } if (type == PageStyleCoverImageType.pureColor) { final color = value.coverColor(context); if (color != null) { preview = ColoredBox( color: color, ); } } if (type == PageStyleCoverImageType.gradientColor) { preview = Container( decoration: BoxDecoration( gradient: FlowyGradientColor.fromId(value).linear, ), ); } if (type == PageStyleCoverImageType.localImage) { preview = Image.file( File(value), fit: BoxFit.cover, ); } return Row( children: [ FlowyText(LocaleKeys.pageStyle_image.tr()), const Spacer(), Container( width: 40, height: 28, clipBehavior: Clip.hardEdge, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(6.0)), border: Border.all(color: const Color(0x1F222533)), ), child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(5.0)), child: preview, ), ), ], ); } void _showPresets(BuildContext context) { final pageStyleBloc = context.read(); context.pop(); showMobileBottomSheet( context, showDragHandle: true, showDivider: false, showDoneButton: true, showHeader: true, showRemoveButton: true, onRemove: () { pageStyleBloc.add( DocumentPageStyleEvent.updateCoverImage( PageStyleCover.none(), ), ); }, title: LocaleKeys.pageStyle_presets.tr(), backgroundColor: AFThemeExtension.of(context).background, builder: (_) { return BlocProvider.value( value: pageStyleBloc, child: const PageCoverBottomSheet(), ); }, ); } Future _pickImage(BuildContext context) async { final photoPermission = await PermissionChecker.checkPhotoPermission(context); if (!photoPermission) { Log.error('Has no permission to access the photo library'); return; } XFile? result; try { result = await _imagePicker.pickImage(source: ImageSource.gallery); } catch (e) { Log.error('Error while picking image: $e'); return; } final path = result?.path; if (path != null && context.mounted) { final String? result; final userProfile = await UserBackendService.getCurrentUserProfile().fold( (s) => s, (f) => null, ); final isAppFlowyCloud = userProfile?.workspaceType == WorkspaceTypePB.ServerW; final PageStyleCoverImageType type; if (!isAppFlowyCloud) { result = await saveImageToLocalStorage(path); type = PageStyleCoverImageType.localImage; } else { // else we should save the image to cloud storage (result, _) = await saveImageToCloudStorage(path, documentId); type = PageStyleCoverImageType.customImage; } if (!context.mounted) { return; } if (result == null) { return showSnapBar( context, LocaleKeys.document_plugins_image_imageUploadFailed.tr(), ); } context.read().add( DocumentPageStyleEvent.updateCoverImage( PageStyleCover(type: type, value: result), ), ); } } void _showUnsplash(BuildContext context) { final pageStyleBloc = context.read(); final backgroundColor = AFThemeExtension.of(context).background; final maxHeight = MediaQuery.of(context).size.height * 0.6; context.pop(); showMobileBottomSheet( context, showDragHandle: true, showDivider: false, showDoneButton: true, showHeader: true, showRemoveButton: true, title: LocaleKeys.pageStyle_unsplash.tr(), backgroundColor: backgroundColor, onRemove: () { pageStyleBloc.add( DocumentPageStyleEvent.updateCoverImage( PageStyleCover.none(), ), ); }, builder: (_) { return ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight, minHeight: 80), child: BlocProvider.value( value: pageStyleBloc, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: UnsplashImageWidget( type: UnsplashImageType.fullScreen, onSelectUnsplashImage: (url) { pageStyleBloc.add( DocumentPageStyleEvent.updateCoverImage( PageStyleCover( type: PageStyleCoverImageType.unsplashImage, value: url, ), ), ); }, ), ), ), ); }, ); } } class _UnsplashCover extends StatelessWidget { const _UnsplashCover(); @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg(FlowySvgs.m_page_style_unsplash_m), const VSpace(4.0), FlowyText( LocaleKeys.pageStyle_unsplash.tr(), fontSize: 12.0, ), ], ); } } class _PhotoCover extends StatelessWidget { const _PhotoCover(); @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg(FlowySvgs.m_page_style_photo_m), const VSpace(4.0), FlowyText( LocaleKeys.pageStyle_photo.tr(), fontSize: 12.0, ), ], ); } } class _PresetCover extends StatelessWidget { const _PresetCover(); @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( FlowySvgs.m_page_style_presets_m, blendMode: null, ), const VSpace(4.0), FlowyText( LocaleKeys.pageStyle_presets.tr(), fontSize: 12.0, ), ], ); } } class _CoverOptionButton extends StatelessWidget { const _CoverOptionButton({ required this.showLeftCorner, required this.showRightCorner, required this.child, required this.onTap, required this.selected, }); final Widget child; final bool showLeftCorner; final bool showRightCorner; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return Expanded( child: FeedbackGestureDetector( feedbackType: HapticFeedbackType.medium, onTap: onTap, child: AnimatedContainer( height: 64, duration: Durations.medium1, decoration: selected ? ShapeDecoration( color: const Color(0x141AC3F2), shape: RoundedRectangleBorder( side: const BorderSide( width: 1.50, color: Color(0xFF1AC3F2), ), borderRadius: BorderRadius.circular(12), ), ) : null, alignment: Alignment.center, child: child, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; class PageStyleIcon extends StatefulWidget { const PageStyleIcon({ super.key, required this.view, required this.tabs, }); final ViewPB view; final List tabs; @override State createState() => _PageStyleIconState(); } class _PageStyleIconState extends State { @override Widget build(BuildContext context) { return BlocProvider( create: (_) => PageStyleIconBloc(view: widget.view) ..add(const PageStyleIconEvent.initial()), child: BlocBuilder( builder: (context, state) { final icon = state.icon; return GestureDetector( onTap: () => icon == null ? null : _showIconSelector(context, icon), behavior: HitTestBehavior.opaque, child: Container( height: 52, decoration: BoxDecoration( color: context.pageStyleBackgroundColor, borderRadius: BorderRadius.circular(12.0), ), child: Row( children: [ const HSpace(16.0), FlowyText(LocaleKeys.document_plugins_emoji.tr()), const Spacer(), (icon?.isEmpty ?? true) ? FlowyText( LocaleKeys.pageStyle_none.tr(), fontSize: 16.0, ) : RawEmojiIconWidget( emoji: icon!, emojiSize: 16.0, ), const HSpace(6.0), const FlowySvg(FlowySvgs.m_page_style_arrow_right_s), const HSpace(12.0), ], ), ), ); }, ), ); } void _showIconSelector(BuildContext context, EmojiIconData icon) { Navigator.pop(context); final pageStyleIconBloc = PageStyleIconBloc(view: widget.view) ..add(const PageStyleIconEvent.initial()); showMobileBottomSheet( context, showDragHandle: true, showDivider: false, showHeader: true, title: LocaleKeys.titleBar_pageIcon.tr(), backgroundColor: AFThemeExtension.of(context).background, enableDraggableScrollable: true, minChildSize: 0.6, initialChildSize: 0.61, scrollableWidgetBuilder: (ctx, controller) { return BlocProvider.value( value: pageStyleIconBloc, child: Expanded( child: FlowyIconEmojiPicker( initialType: icon.type.toPickerTabType(), documentId: widget.view.id, tabs: widget.tabs, onSelectedEmoji: (r) { pageStyleIconBloc.add( PageStyleIconEvent.updateIcon(r.data, true), ); if (!r.keepOpen) Navigator.pop(ctx); }, ), ), ); }, builder: (_) => const SizedBox.shrink(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart ================================================ import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part '_page_style_icon_bloc.freezed.dart'; class PageStyleIconBloc extends Bloc { PageStyleIconBloc({ required this.view, }) : _viewListener = ViewListener(viewId: view.id), super(PageStyleIconState.initial()) { on( (event, emit) async { await event.when( initial: () async { add( PageStyleIconEvent.updateIcon( view.icon.toEmojiIconData(), false, ), ); _viewListener?.start( onViewUpdated: (view) { add( PageStyleIconEvent.updateIcon( view.icon.toEmojiIconData(), false, ), ); }, ); }, updateIcon: (icon, shouldUpdateRemote) async { emit(state.copyWith(icon: icon)); if (shouldUpdateRemote && icon != null) { await ViewBackendService.updateViewIcon( view: view, viewIcon: icon, ); } }, ); }, ); } final ViewPB view; final ViewListener? _viewListener; @override Future close() { _viewListener?.stop(); return super.close(); } } @freezed class PageStyleIconEvent with _$PageStyleIconEvent { const factory PageStyleIconEvent.initial() = Initial; const factory PageStyleIconEvent.updateIcon( EmojiIconData? icon, bool shouldUpdateRemote, ) = UpdateIconInner; } @freezed class PageStyleIconState with _$PageStyleIconState { const factory PageStyleIconState({ @Default(null) EmojiIconData? icon, }) = _PageStyleIconState; factory PageStyleIconState.initial() => const PageStyleIconState(); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; import 'package:appflowy/shared/feedback_gesture_detector.dart'; import 'package:appflowy/util/font_family_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; const kPageStyleLayoutHeight = 52.0; class PageStyleLayout extends StatelessWidget { const PageStyleLayout({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Column( children: [ Row( children: [ _OptionGroup( options: const [ PageStyleFontLayout.small, PageStyleFontLayout.normal, PageStyleFontLayout.large, ], selectedOption: state.fontLayout, onTap: (option) => context .read() .add(DocumentPageStyleEvent.updateFont(option)), ), const HSpace(14), _OptionGroup( options: const [ PageStyleLineHeightLayout.small, PageStyleLineHeightLayout.normal, PageStyleLineHeightLayout.large, ], selectedOption: state.lineHeightLayout, onTap: (option) => context .read() .add(DocumentPageStyleEvent.updateLineHeight(option)), ), ], ), const VSpace(12.0), const _FontButton(), ], ); }, ); } } class _OptionGroup extends StatelessWidget { const _OptionGroup({ required this.options, required this.selectedOption, required this.onTap, }); final List options; final T selectedOption; final void Function(T option) onTap; @override Widget build(BuildContext context) { return Expanded( child: DecoratedBox( decoration: BoxDecoration( color: context.pageStyleBackgroundColor, borderRadius: const BorderRadius.horizontal( left: Radius.circular(12), right: Radius.circular(12), ), ), child: Row( children: options.map((option) { final child = _buildSvg(option); final showLeftCorner = option == options.first; final showRightCorner = option == options.last; return _buildOptionButton( child, showLeftCorner, showRightCorner, selectedOption == option, () => onTap(option), ); }).toList(), ), ), ); } Widget _buildOptionButton( Widget child, bool showLeftCorner, bool showRightCorner, bool selected, VoidCallback onTap, ) { return Expanded( child: FeedbackGestureDetector( feedbackType: HapticFeedbackType.medium, onTap: onTap, child: AnimatedContainer( height: kPageStyleLayoutHeight, duration: Durations.medium1, decoration: selected ? ShapeDecoration( color: const Color(0x141AC3F2), shape: RoundedRectangleBorder( side: const BorderSide( width: 1.50, color: Color(0xFF1AC3F2), ), borderRadius: BorderRadius.circular(12), ), ) : null, alignment: Alignment.center, child: child, ), ), ); } Widget _buildSvg(dynamic option) { if (option is PageStyleFontLayout) { return switch (option) { PageStyleFontLayout.small => const FlowySvg(FlowySvgs.m_font_size_small_s), PageStyleFontLayout.normal => const FlowySvg(FlowySvgs.m_font_size_normal_s), PageStyleFontLayout.large => const FlowySvg(FlowySvgs.m_font_size_large_s), }; } else if (option is PageStyleLineHeightLayout) { return switch (option) { PageStyleLineHeightLayout.small => const FlowySvg(FlowySvgs.m_layout_small_s), PageStyleLineHeightLayout.normal => const FlowySvg(FlowySvgs.m_layout_normal_s), PageStyleLineHeightLayout.large => const FlowySvg(FlowySvgs.m_layout_large_s), }; } throw ArgumentError('Invalid option type'); } } class _FontButton extends StatelessWidget { const _FontButton(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final fontFamilyDisplayName = (state.fontFamily ?? defaultFontFamily).fontFamilyDisplayName; return GestureDetector( onTap: () => _showFontSelector(context), behavior: HitTestBehavior.opaque, child: Container( height: kPageStyleLayoutHeight, decoration: BoxDecoration( color: context.pageStyleBackgroundColor, borderRadius: BorderRadius.circular(12.0), ), child: Row( children: [ const HSpace(16.0), FlowyText(LocaleKeys.titleBar_font.tr()), const Spacer(), FlowyText( fontFamilyDisplayName, color: context.pageStyleTextColor, ), const HSpace(6.0), const FlowySvg(FlowySvgs.m_page_style_arrow_right_s), const HSpace(12.0), ], ), ), ); }, ); } void _showFontSelector(BuildContext context) { final pageStyleBloc = context.read(); context.pop(); showMobileBottomSheet( context, showDragHandle: true, showDivider: false, showDoneButton: true, showHeader: true, title: LocaleKeys.titleBar_font.tr(), backgroundColor: AFThemeExtension.of(context).background, enableDraggableScrollable: true, minChildSize: 0.6, initialChildSize: 0.61, scrollableWidgetBuilder: (_, controller) { return BlocProvider.value( value: pageStyleBloc, child: BlocBuilder( builder: (context, state) { return Expanded( child: Scrollbar( controller: controller, child: FontSelector( scrollController: controller, selectedFontFamilyName: state.fontFamily ?? defaultFontFamily, onFontFamilySelected: (fontFamilyName) { pageStyleBloc.add( DocumentPageStyleEvent.updateFontFamily( fontFamilyName, ), ); }, ), ), ); }, ), ); }, builder: (_) => const SizedBox.shrink(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart ================================================ import 'package:flutter/material.dart'; extension PageStyleUtil on BuildContext { Color get pageStyleBackgroundColor { final themeMode = Theme.of(this).brightness; return themeMode == Brightness.light ? const Color(0xFFF5F5F8) : const Color(0xFF303030); } Color get pageStyleTextColor { final themeMode = Theme.of(this).brightness; return themeMode == Brightness.light ? const Color(0x7F1F2225) : Colors.white54; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class PageStyleBottomSheet extends StatelessWidget { const PageStyleBottomSheet({ super.key, required this.view, required this.tabs, }); final ViewPB view; final List tabs; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // cover image FlowyText( LocaleKeys.pageStyle_coverImage.tr(), color: context.pageStyleTextColor, fontSize: 14.0, ), const VSpace(8.0), PageStyleCoverImage(documentId: view.id), const VSpace(20.0), // layout: font size, line height and font family. FlowyText( LocaleKeys.pageStyle_layout.tr(), color: context.pageStyleTextColor, fontSize: 14.0, ), const VSpace(8.0), const PageStyleLayout(), const VSpace(20.0), // icon FlowyText( LocaleKeys.pageStyle_pageIcon.tr(), color: context.pageStyleTextColor, fontSize: 14.0, ), const VSpace(8.0), PageStyleIcon( view: view, tabs: tabs, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/callout_node_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; class CalloutNodeParser extends NodeParser { const CalloutNodeParser(); @override String get id => CalloutBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { final delta = node.delta ?? Delta() ..insert(''); final String markdown = DeltaMarkdownEncoder() .convert(delta) .split('\n') .map((e) => '> $e') .join('\n'); final type = node.attributes[CalloutBlockKeys.iconType]; final icon = type == FlowyIconType.emoji.name || type == null || type == "" ? node.attributes[CalloutBlockKeys.icon] : null; final content = icon == null ? markdown : "> $icon\n$markdown"; return ''' $content '''; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart ================================================ import 'dart:io'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; import '../image/custom_image_block_component/custom_image_block_component.dart'; class CustomImageNodeParser extends NodeParser { const CustomImageNodeParser(); @override String get id => ImageBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { assert(node.children.isEmpty); final url = node.attributes[CustomImageBlockKeys.url]; assert(url != null); return '![]($url)\n'; } } class CustomImageNodeFileParser extends NodeParser { const CustomImageNodeFileParser(this.files, this.dirPath); final List> files; final String dirPath; @override String get id => ImageBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { assert(node.children.isEmpty); final url = node.attributes[CustomImageBlockKeys.url]; final hasFile = File(url).existsSync(); if (hasFile) { final bytes = File(url).readAsBytesSync(); files.add( Future.value( ArchiveFile(p.join(dirPath, p.basename(url)), bytes.length, bytes), ), ); return '![](${p.join(dirPath, p.basename(url))})\n'; } assert(url != null); return '![]($url)\n'; } } class CustomMultiImageNodeFileParser extends NodeParser { const CustomMultiImageNodeFileParser(this.files, this.dirPath); final List> files; final String dirPath; @override String get id => MultiImageBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { assert(node.children.isEmpty); final images = node.attributes[MultiImageBlockKeys.images] as List; final List markdownImages = []; for (final image in images) { final String url = image['url'] ?? ''; if (url.isEmpty) continue; final hasFile = File(url).existsSync(); if (hasFile) { final bytes = File(url).readAsBytesSync(); final filePath = p.join(dirPath, p.basename(url)); files.add( Future.value(ArchiveFile(filePath, bytes.length, bytes)), ); markdownImages.add('![]($filePath)'); } else { markdownImages.add('![]($url)'); } } return markdownImages.join('\n'); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; class CustomParagraphNodeParser extends NodeParser { const CustomParagraphNodeParser(); @override String get id => ParagraphBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { final delta = node.delta; if (delta != null) { for (final o in delta) { final attribute = o.attributes ?? {}; final Map? mention = attribute[MentionBlockKeys.mention] ?? {}; if (mention == null) continue; /// filter date reminder node, and return it final String date = mention[MentionBlockKeys.date] ?? ''; if (date.isNotEmpty) { final dateTime = DateTime.tryParse(date); if (dateTime == null) continue; return '${DateFormat.yMMMd().format(dateTime)}\n'; } /// filter reference page final String pageId = mention[MentionBlockKeys.pageId] ?? ''; if (pageId.isNotEmpty) { return '[]($pageId)\n'; } } } return const TextNodeParser().transform(node, encoder); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/database_node_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart'; import 'package:appflowy/workspace/application/settings/share/export_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; abstract class DatabaseNodeParser extends NodeParser { DatabaseNodeParser(this.files, this.dirPath); final List> files; final String dirPath; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { final String viewId = node.attributes[DatabaseBlockKeys.viewID] ?? ''; if (viewId.isEmpty) return ''; files.add(_convertDatabaseToCSV(viewId)); return '[](${p.join(dirPath, '$viewId.csv')})\n'; } Future _convertDatabaseToCSV(String viewId) async { final result = await BackendExportService.exportDatabaseAsCSV(viewId); final filePath = p.join(dirPath, '$viewId.csv'); ArchiveFile file = ArchiveFile.string(filePath, ''); result.fold( (s) => file = ArchiveFile.string(filePath, s.data), (f) => Log.error('convertDatabaseToCSV error with $viewId, error: $f'), ); return file; } } class GridNodeParser extends DatabaseNodeParser { GridNodeParser(super.files, super.dirPath); @override String get id => DatabaseBlockKeys.gridType; } class BoardNodeParser extends DatabaseNodeParser { BoardNodeParser(super.files, super.dirPath); @override String get id => DatabaseBlockKeys.boardType; } class CalendarNodeParser extends DatabaseNodeParser { CalendarNodeParser(super.files, super.dirPath); @override String get id => DatabaseBlockKeys.calendarType; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart ================================================ export 'callout_node_parser.dart'; export 'custom_image_node_parser.dart'; export 'custom_paragraph_node_parser.dart'; export 'database_node_parser.dart'; export 'file_block_node_parser.dart'; export 'link_preview_node_parser.dart'; export 'math_equation_node_parser.dart'; export 'simple_table_node_parser.dart'; export 'toggle_list_node_parser.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/file_block_node_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; class FileBlockNodeParser extends NodeParser { const FileBlockNodeParser(); @override String get id => FileBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { final name = node.attributes[FileBlockKeys.name]; final url = node.attributes[FileBlockKeys.url]; if (name == null || url == null) { return ''; } return '[$name]($url)\n'; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/link_preview_node_parser.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; class LinkPreviewNodeParser extends NodeParser { const LinkPreviewNodeParser(); @override String get id => LinkPreviewBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { final href = node.attributes[LinkPreviewBlockKeys.url]; if (href == null) { return ''; } return '[$href]($href)\n'; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_code_parser.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:markdown/markdown.dart' as md; class MarkdownCodeBlockParser extends CustomMarkdownParser { const MarkdownCodeBlockParser(); @override List transform( md.Node element, List parsers, { MarkdownListType listType = MarkdownListType.unknown, int? startNumber, }) { if (element is! md.Element) { return []; } if (element.tag != 'pre') { return []; } final ec = element.children; if (ec == null || ec.isEmpty) { return []; } final code = ec.first; if (code is! md.Element || code.tag != 'code') { return []; } String? language; if (code.attributes.containsKey('class')) { final classes = code.attributes['class']!.split(' '); final languageClass = classes.firstWhere( (c) => c.startsWith('language-'), orElse: () => '', ); language = languageClass.substring('language-'.length); } return [ codeBlockNode( language: language, delta: Delta()..insert(code.textContent.trimRight()), ), ]; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_parsers.dart ================================================ export 'markdown_code_parser.dart'; export 'markdown_simple_table_parser.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_simple_table_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:markdown/markdown.dart' as md; import 'package:universal_platform/universal_platform.dart'; class MarkdownSimpleTableParser extends CustomMarkdownParser { const MarkdownSimpleTableParser({ this.tableWidth, }); final double? tableWidth; @override List transform( md.Node element, List parsers, { MarkdownListType listType = MarkdownListType.unknown, int? startNumber, }) { if (element is! md.Element) { return []; } if (element.tag != 'table') { return []; } final ec = element.children; if (ec == null || ec.isEmpty) { return []; } final th = ec .whereType() .where((e) => e.tag == 'thead') .firstOrNull ?.children ?.whereType() .where((e) => e.tag == 'tr') .expand((e) => e.children?.whereType().toList() ?? []) .where((e) => e.tag == 'th') .toList(); final tr = ec .whereType() .where((e) => e.tag == 'tbody') .firstOrNull ?.children ?.whereType() .where((e) => e.tag == 'tr') .toList(); if (th == null || tr == null || th.isEmpty || tr.isEmpty) { return []; } final rows = []; // Add header cells rows.add( simpleTableRowBlockNode( children: th .map( (e) => simpleTableCellBlockNode( children: [ paragraphNode( delta: DeltaMarkdownDecoder().convertNodes(e.children), ), ], ), ) .toList(), ), ); // Add body cells for (var i = 0; i < tr.length; i++) { final td = tr[i] .children ?.whereType() .where((e) => e.tag == 'td') .toList(); if (td == null || td.isEmpty) { continue; } rows.add( simpleTableRowBlockNode( children: td .map( (e) => simpleTableCellBlockNode( children: [ paragraphNode( delta: DeltaMarkdownDecoder().convertNodes(e.children), ), ], ), ) .toList(), ), ); } return [ simpleTableBlockNode( children: rows, enableHeaderRow: true, columnWidths: UniversalPlatform.isMobile || tableWidth == null ? null : {for (var i = 0; i < th.length; i++) i.toString(): tableWidth!}, ), ]; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/math_equation_node_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; class MathEquationNodeParser extends NodeParser { const MathEquationNodeParser(); @override String get id => MathEquationBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { return '\$\$${node.attributes[MathEquationBlockKeys.formula]}\$\$'; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/simple_table_node_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; /// Parser for converting SimpleTable nodes to markdown format class SimpleTableNodeParser extends NodeParser { const SimpleTableNodeParser(); @override String get id => SimpleTableBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { try { final tableData = _extractTableData(node, encoder); if (tableData.isEmpty) { return ''; } return _buildMarkdownTable(tableData); } catch (e) { return ''; } } /// Extracts table data from the node structure into a 2D list of strings /// Each inner list represents a row, and each string represents a cell's content List> _extractTableData( Node node, DocumentMarkdownEncoder? encoder, ) { final tableData = >[]; final rows = node.children; for (final row in rows) { final rowData = _extractRowData(row, encoder); tableData.add(rowData); } return tableData; } /// Extracts data from a single table row List _extractRowData(Node row, DocumentMarkdownEncoder? encoder) { final rowData = []; final cells = row.children; for (final cell in cells) { final content = _extractCellContent(cell, encoder); rowData.add(content); } return rowData; } /// Extracts and formats content from a single table cell String _extractCellContent(Node cell, DocumentMarkdownEncoder? encoder) { final contentBuffer = StringBuffer(); for (final child in cell.children) { final delta = child.delta; // if the node doesn't contain delta, fallback to the encoder final content = delta != null ? DeltaMarkdownEncoder().convert(delta) : encoder?.convertNodes([child]).trim() ?? ''; // Escape pipe characters to prevent breaking markdown table structure contentBuffer.write(content.replaceAll('|', '\\|')); } return contentBuffer.toString(); } /// Builds a markdown table string from the extracted table data /// First row is treated as header, followed by separator row and data rows String _buildMarkdownTable(List> tableData) { final markdown = StringBuffer(); final columnCount = tableData[0].length; // Add header row markdown.writeln('|${tableData[0].join('|')}|'); // Add separator row markdown.writeln('|${List.filled(columnCount, '---').join('|')}|'); // Add data rows (skip header row) for (int i = 1; i < tableData.length; i++) { markdown.writeln('|${tableData[i].join('|')}|'); } return markdown.toString(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; class SubPageNodeParser extends NodeParser { const SubPageNodeParser(); @override String get id => SubPageBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { final String viewId = node.attributes[SubPageBlockKeys.viewId] ?? ''; if (viewId.isNotEmpty) { final view = pageMemorizer[viewId]; return '[$viewId](${view?.name ?? ''})\n'; } return ''; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/toggle_list_node_parser.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; enum ToggleListExportStyle { github, markdown, } class ToggleListNodeParser extends NodeParser { const ToggleListNodeParser({ this.exportStyle = ToggleListExportStyle.markdown, }); final ToggleListExportStyle exportStyle; @override String get id => ToggleListBlockKeys.type; @override String transform(Node node, DocumentMarkdownEncoder? encoder) { final delta = node.delta ?? Delta() ..insert(''); String markdown = DeltaMarkdownEncoder().convert(delta); final details = encoder?.convertNodes( node.children, withIndent: true, ); switch (exportStyle) { case ToggleListExportStyle.github: return '''
$markdown $details
'''; case ToggleListExportStyle.markdown: markdown = '- $markdown\n'; if (details != null && details.isNotEmpty) { markdown += details; } return markdown; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart ================================================ export 'actions/block_action_list.dart'; export 'actions/option/option_actions.dart'; export 'ai/ai_writer_block_component.dart'; export 'ai/ai_writer_toolbar_item.dart'; export 'align_toolbar_item/align_toolbar_item.dart'; export 'base/backtick_character_command.dart'; export 'base/cover_title_command.dart'; export 'base/toolbar_extension.dart'; export 'bulleted_list/bulleted_list_icon.dart'; export 'callout/callout_block_component.dart'; export 'code_block/code_block_language_selector.dart'; export 'code_block/code_block_menu_item.dart'; export 'columns/simple_column_block_component.dart'; export 'columns/simple_column_block_width_resizer.dart'; export 'columns/simple_column_node_extension.dart'; export 'columns/simple_columns_block_component.dart'; export 'context_menu/custom_context_menu.dart'; export 'copy_and_paste/custom_copy_command.dart'; export 'copy_and_paste/custom_cut_command.dart'; export 'copy_and_paste/custom_paste_command.dart'; export 'database/database_view_block_component.dart'; export 'database/inline_database_menu_item.dart'; export 'database/referenced_database_menu_item.dart'; export 'error/error_block_component_builder.dart'; export 'extensions/flowy_tint_extension.dart'; export 'file/file_block.dart'; export 'find_and_replace/find_and_replace_menu.dart'; export 'font/customize_font_toolbar_item.dart'; export 'header/cover_editor_bloc.dart'; export 'header/custom_cover_picker.dart'; export 'header/document_cover_widget.dart'; export 'heading/heading_toolbar_item.dart'; export 'image/custom_image_block_component/custom_image_block_component.dart'; export 'image/custom_image_block_component/image_menu.dart'; export 'image/image_selection_menu.dart'; export 'image/mobile_image_toolbar_item.dart'; export 'image/multi_image_block_component/multi_image_block_component.dart'; export 'image/multi_image_block_component/multi_image_menu.dart'; export 'inline_math_equation/inline_math_equation.dart'; export 'inline_math_equation/inline_math_equation_toolbar_item.dart'; export 'keyboard_interceptor/keyboard_interceptor.dart'; export 'link_preview/custom_link_preview.dart'; export 'link_preview/link_preview_menu.dart'; export 'math_equation/math_equation_block_component.dart'; export 'math_equation/mobile_math_equation_toolbar_item.dart'; export 'mention/mention_block.dart'; export 'mobile_floating_toolbar/custom_mobile_floating_toolbar.dart'; export 'mobile_toolbar_v3/aa_toolbar_item.dart'; export 'mobile_toolbar_v3/add_attachment_item.dart'; export 'mobile_toolbar_v3/add_block_toolbar_item.dart'; export 'mobile_toolbar_v3/appflowy_mobile_toolbar.dart'; export 'mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart'; export 'mobile_toolbar_v3/basic_toolbar_item.dart'; export 'mobile_toolbar_v3/indent_outdent_toolbar_item.dart'; export 'mobile_toolbar_v3/list_toolbar_item.dart'; export 'mobile_toolbar_v3/more_toolbar_item.dart'; export 'mobile_toolbar_v3/toolbar_item_builder.dart'; export 'mobile_toolbar_v3/undo_redo_toolbar_item.dart'; export 'mobile_toolbar_v3/util.dart'; export 'numbered_list/numbered_list_icon.dart'; export 'outline/outline_block_component.dart'; export 'parsers/document_markdown_parsers.dart'; export 'parsers/markdown_parsers.dart'; export 'parsers/markdown_simple_table_parser.dart'; export 'quote/quote_block_component.dart'; export 'quote/quote_block_shortcuts.dart'; export 'shortcuts/character_shortcuts.dart'; export 'shortcuts/command_shortcuts.dart'; export 'simple_table/simple_table.dart'; export 'slash_menu/slash_command.dart'; export 'slash_menu/slash_menu_items_builder.dart'; export 'sub_page/sub_page_block_component.dart'; export 'table/table_menu.dart'; export 'table/table_option_action.dart'; export 'todo_list/todo_list_icon.dart'; export 'toggle/toggle_block_component.dart'; export 'toggle/toggle_block_shortcuts.dart'; export 'video/video_block_component.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_component.dart ================================================ import 'dart:async'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; /// In memory cache of the quote block height to avoid flashing when the quote block is updated. Map _quoteBlockHeightCache = {}; typedef QuoteBlockIconBuilder = Widget Function( BuildContext context, Node node, ); class QuoteBlockKeys { const QuoteBlockKeys._(); static const String type = 'quote'; static const String delta = blockComponentDelta; static const String backgroundColor = blockComponentBackgroundColor; static const String textDirection = blockComponentTextDirection; } Node quoteNode({ Delta? delta, String? textDirection, Attributes? attributes, Iterable? children, }) { attributes ??= {'delta': (delta ?? Delta()).toJson()}; return Node( type: QuoteBlockKeys.type, attributes: { ...attributes, if (textDirection != null) QuoteBlockKeys.textDirection: textDirection, }, children: children ?? [], ); } class QuoteBlockComponentBuilder extends BlockComponentBuilder { QuoteBlockComponentBuilder({ super.configuration, this.iconBuilder, }); final QuoteBlockIconBuilder? iconBuilder; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return QuoteBlockComponentWidget( key: node.key, node: node, configuration: configuration, iconBuilder: iconBuilder, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (node) => node.delta != null; } class QuoteBlockComponentWidget extends BlockComponentStatefulWidget { const QuoteBlockComponentWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), this.iconBuilder, }); final QuoteBlockIconBuilder? iconBuilder; @override State createState() => _QuoteBlockComponentWidgetState(); } class _QuoteBlockComponentWidgetState extends State with SelectableMixin, DefaultSelectableMixin, BlockComponentConfigurable, BlockComponentBackgroundColorMixin, BlockComponentTextDirectionMixin, BlockComponentAlignMixin, NestedBlockComponentStatefulWidgetMixin { @override final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); @override GlobalKey> get containerKey => widget.node.key; @override GlobalKey> blockComponentKey = GlobalKey( debugLabel: QuoteBlockKeys.type, ); @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; late ValueNotifier quoteBlockHeightNotifier = ValueNotifier( _quoteBlockHeightCache[node.id] ?? 0, ); StreamSubscription? _transactionSubscription; final GlobalKey layoutBuilderKey = GlobalKey(); @override void initState() { super.initState(); _updateQuoteBlockHeight(); } @override void dispose() { _transactionSubscription?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return NotificationListener( key: layoutBuilderKey, onNotification: (notification) { _updateQuoteBlockHeight(); return true; }, child: SizeChangedLayoutNotifier( child: node.children.isEmpty ? buildComponent(context) : buildComponentWithChildren(context), ), ); } @override Widget buildComponentWithChildren(BuildContext context) { final Widget child = Stack( children: [ Positioned.fill( left: UniversalPlatform.isMobile ? padding.left : cachedLeft, right: UniversalPlatform.isMobile ? padding.right : 0, child: Container( color: backgroundColor, ), ), NestedListWidget( indentPadding: indentPadding, child: buildComponent(context, withBackgroundColor: false), children: editorState.renderer.buildList( context, widget.node.children, ), ), ], ); return child; } @override Widget buildComponent( BuildContext context, { bool withBackgroundColor = true, }) { final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); Widget child = AppFlowyRichText( key: forwardKey, delegate: this, node: widget.node, editorState: editorState, textAlign: alignment?.toTextAlign ?? textAlign, placeholderText: placeholderText, textSpanDecorator: (textSpan) => textSpan.updateTextStyle( textStyleWithTextSpan(textSpan: textSpan), ), placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle( placeholderTextStyleWithTextSpan(textSpan: textSpan), ), textDirection: textDirection, cursorColor: editorState.editorStyle.cursorColor, selectionColor: editorState.editorStyle.selectionColor, cursorWidth: editorState.editorStyle.cursorWidth, ); child = Container( width: double.infinity, alignment: alignment, child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, textDirection: textDirection, children: [ widget.iconBuilder != null ? widget.iconBuilder!(context, node) : ValueListenableBuilder( valueListenable: quoteBlockHeightNotifier, builder: (context, height, child) { return QuoteIcon(height: height); }, ), Flexible( child: child, ), ], ), ), ); child = Container( color: withBackgroundColor ? backgroundColor : null, child: Padding( key: blockComponentKey, padding: padding, child: child, ), ); child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, remoteSelection: editorState.remoteSelections, blockColor: editorState.editorStyle.selectionColor, selectionAboveBlock: true, supportTypes: const [ BlockSelectionType.block, ], child: child, ); if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } return child; } void _updateQuoteBlockHeight() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { final renderObject = layoutBuilderKey.currentContext?.findRenderObject(); double height = _quoteBlockHeightCache[node.id] ?? 0; if (renderObject != null && renderObject is RenderBox) { if (UniversalPlatform.isMobile) { height = renderObject.size.height - padding.top; } else { height = renderObject.size.height - padding.top * 2; } } else { height = 0; } quoteBlockHeightNotifier.value = height; _quoteBlockHeightCache[node.id] = height; }); } } class QuoteIcon extends StatelessWidget { const QuoteIcon({ super.key, this.height = 0, }); final double height; @override Widget build(BuildContext context) { final textScaleFactor = context.read().editorStyle.textScaleFactor; return Container( alignment: Alignment.center, constraints: const BoxConstraints(minWidth: 22, minHeight: 22, maxHeight: 22) * textScaleFactor, padding: const EdgeInsets.only(right: 6.0), child: SizedBox( width: 3 * textScaleFactor, // use overflow box to ensure the container can overflow the height so that the children of the quote block can have the quote child: OverflowBox( alignment: Alignment.topCenter, maxHeight: height, child: Container( width: 3 * textScaleFactor, height: height, color: Theme.of(context).colorScheme.primary, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; /// Pressing Enter in a quote block will insert a newline (\n) within the quote, /// while pressing Shift+Enter in a quote will insert a new paragraph next to the quote. /// /// - support /// - desktop /// - mobile /// - web /// final CharacterShortcutEvent insertNewLineInQuoteBlock = CharacterShortcutEvent( key: 'insert a new line in quote block', character: '\n', handler: _insertNewLineHandler, ); CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async { final selection = editorState.selection?.normalized; if (selection == null) { return false; } final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.type != QuoteBlockKeys.type) { return false; } // delete the selection await editorState.deleteSelection(selection); if (HardwareKeyboard.instance.isShiftPressed) { // ignore the shift+enter event, fallback to the default behavior return false; } else if (node.children.isEmpty && selection.endIndex == node.delta?.length) { // insert a new paragraph within the callout block final path = node.path.child(0); final transaction = editorState.transaction; transaction.insertNode( path, paragraphNode(), ); transaction.afterSelection = Selection.collapsed( Position( path: path, ), ); await editorState.apply(transaction); return true; } return false; }; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart ================================================ import 'package:flutter/widgets.dart'; /// Shared context for the editor plugins. /// /// For example, the backspace command requires the focus node of the cover title. /// so we need to use the shared context to get the focus node. /// class SharedEditorContext { SharedEditorContext() : _coverTitleFocusNode = FocusNode(); // The focus node of the cover title. final FocusNode _coverTitleFocusNode; bool requestCoverTitleFocus = false; bool isInDatabaseRowPage = false; FocusNode get coverTitleFocusNode => _coverTitleFocusNode; void dispose() { _coverTitleFocusNode.dispose(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart ================================================ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/emoji/emoji_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; List buildCharacterShortcutEvents( BuildContext context, DocumentBloc documentBloc, EditorStyleCustomizer styleCustomizer, InlineActionsService inlineActionsService, SlashMenuItemsBuilder slashMenuItemsBuilder, ) { return [ // code block formatBacktickToCodeBlock, ...codeBlockCharacterEvents, // callout block insertNewLineInCalloutBlock, // quote block insertNewLineInQuoteBlock, // toggle list formatGreaterToToggleList, insertChildNodeInsideToggleList, // customize the slash menu command customAppFlowySlashCommand( itemsBuilder: slashMenuItemsBuilder, style: styleCustomizer.selectionMenuStyleBuilder(), supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, ), customFormatGreaterEqual, customFormatDashGreater, customFormatDoubleHyphenEmDash, customFormatNumberToNumberedList, customFormatSignToHeading, ...standardCharacterShortcutEvents ..removeWhere( (shortcut) => [ slashCommand, // Remove default slash command formatGreaterEqual, // Overridden by customFormatGreaterEqual formatNumberToNumberedList, // Overridden by customFormatNumberToNumberedList formatSignToHeading, // Overridden by customFormatSignToHeading formatDoubleHyphenEmDash, // Overridden by customFormatDoubleHyphenEmDash ].contains(shortcut), ), /// Inline Actions /// - Reminder /// - Inline-page reference inlineActionsCommand( inlineActionsService, style: styleCustomizer.inlineActionsMenuStyleBuilder(), ), /// Inline page menu /// - Using `[[` pageReferenceShortcutBrackets( context, documentBloc.documentId, styleCustomizer.inlineActionsMenuStyleBuilder(), ), /// - Using `+` pageReferenceShortcutPlusSign( context, documentBloc.documentId, styleCustomizer.inlineActionsMenuStyleBuilder(), ), /// show emoji list /// - Using `:` emojiCommand(context), ]; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/custom_delete_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'exit_edit_mode_command.dart'; final List defaultCommandShortcutEvents = [ ...commandShortcutEvents.map((e) => e.copyWith()), ]; // Command shortcuts are order-sensitive. Verify order when modifying. List commandShortcutEvents = [ ...simpleTableCommands, customExitEditingCommand, backspaceToTitle, removeToggleHeadingStyle, arrowUpToTitle, arrowLeftToTitle, toggleToggleListCommand, ...localizedCodeBlockCommands, customCopyCommand, customPasteCommand, customPastePlainTextCommand, customCutCommand, customUndoCommand, customRedoCommand, ...customTextAlignCommands, customDeleteCommand, insertInlineMathEquationCommand, // remove standard shortcuts for copy, cut, paste, todo ...standardCommandShortcutEvents ..removeWhere( (shortcut) => [ copyCommand, cutCommand, pasteCommand, pasteTextWithoutFormattingCommand, toggleTodoListCommand, undoCommand, redoCommand, exitEditingCommand, ...tableCommands, deleteCommand, ].contains(shortcut), ), emojiShortcutEvent, ]; final _codeBlockLocalization = CodeBlockLocalizations( codeBlockNewParagraph: LocaleKeys.settings_shortcutsPage_commands_codeBlockNewParagraph.tr(), codeBlockIndentLines: LocaleKeys.settings_shortcutsPage_commands_codeBlockIndentLines.tr(), codeBlockOutdentLines: LocaleKeys.settings_shortcutsPage_commands_codeBlockOutdentLines.tr(), codeBlockSelectAll: LocaleKeys.settings_shortcutsPage_commands_codeBlockSelectAll.tr(), codeBlockPasteText: LocaleKeys.settings_shortcutsPage_commands_codeBlockPasteText.tr(), codeBlockAddTwoSpaces: LocaleKeys.settings_shortcutsPage_commands_codeBlockAddTwoSpaces.tr(), ); final localizedCodeBlockCommands = codeBlockCommands( localizations: _codeBlockLocalization, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/custom_delete_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; /// Delete key event. /// /// - support /// - desktop /// - web /// final CommandShortcutEvent customDeleteCommand = CommandShortcutEvent( key: 'Delete Key', getDescription: () => AppFlowyEditorL10n.current.cmdDeleteRight, command: 'delete, shift+delete', handler: _deleteCommandHandler, ); CommandShortcutEventHandler _deleteCommandHandler = (editorState) { final selection = editorState.selection; final selectionType = editorState.selectionType; if (selection == null) { return KeyEventResult.ignored; } if (selectionType == SelectionType.block) { return _deleteInBlockSelection(editorState); } else if (selection.isCollapsed) { return _deleteInCollapsedSelection(editorState); } else { return _deleteInNotCollapsedSelection(editorState); } }; /// Handle delete key event when selection is collapsed. CommandShortcutEventHandler _deleteInCollapsedSelection = (editorState) { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return KeyEventResult.ignored; } final position = selection.start; final node = editorState.getNodeAtPath(position.path); final delta = node?.delta; if (node == null || delta == null) { return KeyEventResult.ignored; } final transaction = editorState.transaction; if (position.offset == delta.length) { final Node? tableParent = node.findParent((element) => element.type == SimpleTableBlockKeys.type); Node? nextTableParent; final next = node.findDownward((element) { nextTableParent = element .findParent((element) => element.type == SimpleTableBlockKeys.type); // break if only one is in a table or they're in different tables return tableParent != nextTableParent || // merge the next node with delta element.delta != null; }); // table nodes should be deleted using the table menu // in-table paragraphs should only be deleted inside the table if (next != null && tableParent == nextTableParent) { if (next.children.isNotEmpty) { final path = node.path + [node.children.length]; transaction.insertNodes(path, next.children); } transaction ..deleteNode(next) ..mergeText( node, next, ); editorState.apply(transaction); return KeyEventResult.handled; } } else { final nextIndex = delta.nextRunePosition(position.offset); if (nextIndex <= delta.length) { transaction.deleteText( node, position.offset, nextIndex - position.offset, ); editorState.apply(transaction); return KeyEventResult.handled; } } return KeyEventResult.ignored; }; /// Handle delete key event when selection is not collapsed. CommandShortcutEventHandler _deleteInNotCollapsedSelection = (editorState) { final selection = editorState.selection; if (selection == null || selection.isCollapsed) { return KeyEventResult.ignored; } editorState.deleteSelection( selection, ignoreNodeTypes: [ SimpleTableCellBlockKeys.type, TableCellBlockKeys.type, ], ); return KeyEventResult.handled; }; CommandShortcutEventHandler _deleteInBlockSelection = (editorState) { final selection = editorState.selection; if (selection == null || editorState.selectionType != SelectionType.block) { return KeyEventResult.ignored; } final transaction = editorState.transaction; transaction.deleteNodesAtPath(selection.start.path); editorState .apply(transaction) .then((value) => editorState.selectionType = null); return KeyEventResult.handled; }; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/exit_edit_mode_command.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; /// End key event. /// /// - support /// - desktop /// - web /// final CommandShortcutEvent customExitEditingCommand = CommandShortcutEvent( key: 'exit the editing mode', getDescription: () => AppFlowyEditorL10n.current.cmdExitEditing, command: 'escape', handler: _exitEditingCommandHandler, ); CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { if (editorState.selection == null) { return KeyEventResult.ignored; } editorState.selection = null; editorState.service.keyboardService?.closeKeyboard(); return KeyEventResult.handled; }; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; /// Convert '# ' to bulleted list /// /// - support /// - desktop /// - mobile /// - web /// CharacterShortcutEvent customFormatSignToHeading = CharacterShortcutEvent( key: 'format sign to heading list', character: ' ', handler: (editorState) async => formatMarkdownSymbol( editorState, (node) => true, (_, text, selection) { final characters = text.split(''); // only supports h1 to h6 levels // if the characters is empty, the every function will return true directly return characters.isNotEmpty && characters.every((element) => element == '#') && characters.length < 7; }, (text, node, delta) { final numberOfSign = text.split('').length; final type = node.type; // if current node is toggle block, try to convert it to toggle heading block. if (type == ToggleListBlockKeys.type) { final collapsed = node.attributes[ToggleListBlockKeys.collapsed] as bool?; return [ toggleHeadingNode( level: numberOfSign, delta: delta.compose(Delta()..delete(numberOfSign)), collapsed: collapsed ?? false, children: node.children.map((child) => child.deepCopy()), ), ]; } return [ headingNode( level: numberOfSign, delta: delta.compose(Delta()..delete(numberOfSign)), ), if (node.children.isNotEmpty) ...node.children, ]; }, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; /// Convert 'num. ' to bulleted list /// /// - support /// - desktop /// - mobile /// /// In heading block and toggle heading block, this shortcut will be ignored. CharacterShortcutEvent customFormatNumberToNumberedList = CharacterShortcutEvent( key: 'format number to numbered list', character: ' ', handler: (editorState) async => formatMarkdownSymbol( editorState, (node) => node.type != NumberedListBlockKeys.type, (node, text, selection) { final shouldBeIgnored = _shouldBeIgnored(node); if (shouldBeIgnored) { return false; } final match = numberedListRegex.firstMatch(text); if (match == null) { return false; } final matchText = match.group(0); final numberText = match.group(1); if (matchText == null || numberText == null) { return false; } // if the previous one is numbered list, // we should check the current number is the next number of the previous one Node? previous = node.previous; int level = 0; int? startNumber; while (previous != null && previous.type == NumberedListBlockKeys.type) { startNumber = previous.attributes[NumberedListBlockKeys.number] as int?; level++; previous = previous.previous; } if (startNumber != null) { final currentNumber = int.tryParse(numberText); if (currentNumber == null || currentNumber != startNumber + level) { return false; } } return selection.endIndex == matchText.length; }, (text, node, delta) { final match = numberedListRegex.firstMatch(text); final matchText = match?.group(0); if (matchText == null) { return [node]; } final number = matchText.substring(0, matchText.length - 1); final composedDelta = delta.compose( Delta()..delete(matchText.length), ); return [ node.copyWith( type: NumberedListBlockKeys.type, attributes: { NumberedListBlockKeys.delta: composedDelta.toJson(), NumberedListBlockKeys.number: int.tryParse(number), }, ), ]; }, ), ); bool _shouldBeIgnored(Node node) { final type = node.type; // ignore heading block if (type == HeadingBlockKeys.type) { return true; } // ignore toggle heading block final level = node.attributes[ToggleListBlockKeys.level] as int?; if (type == ToggleListBlockKeys.type && level != null) { return true; } return false; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart ================================================ export 'simple_table_block_component.dart'; export 'simple_table_cell_block_component.dart'; export 'simple_table_constants.dart'; export 'simple_table_more_action.dart'; export 'simple_table_operations/simple_table_operations.dart'; export 'simple_table_row_block_component.dart'; export 'simple_table_shortcuts/simple_table_commands.dart'; export 'simple_table_widgets/widgets.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; typedef SimpleTableColumnWidthMap = Map; typedef SimpleTableRowAlignMap = Map; typedef SimpleTableColumnAlignMap = Map; typedef SimpleTableColorMap = Map; typedef SimpleTableAttributeMap = Map; class SimpleTableBlockKeys { const SimpleTableBlockKeys._(); static const String type = 'simple_table'; /// enable header row /// it's a bool value, default is false static const String enableHeaderRow = 'enable_header_row'; /// enable column header /// it's a bool value, default is false static const String enableHeaderColumn = 'enable_header_column'; /// column colors /// it's a [SimpleTableColorMap] value, {column_index: color, ...} /// the number of colors should be the same as the number of columns static const String columnColors = 'column_colors'; /// row colors /// it's a [SimpleTableColorMap] value, {row_index: color, ...} /// the number of colors should be the same as the number of rows static const String rowColors = 'row_colors'; /// column alignments /// it's a [SimpleTableColumnAlignMap] value, {column_index: align, ...} /// the value should be one of the following: 'left', 'center', 'right' static const String columnAligns = 'column_aligns'; /// row alignments /// it's a [SimpleTableRowAlignMap] value, {row_index: align, ...} /// the value should be one of the following: 'top', 'center', 'bottom' static const String rowAligns = 'row_aligns'; /// column bold attributes /// it's a [SimpleTableAttributeMap] value, {column_index: attribute, ...} /// the attribute should be one of the following: true, false static const String columnBoldAttributes = 'column_bold_attributes'; /// row bold attributes /// it's a [SimpleTableAttributeMap] value, {row_index: true, ...} /// the attribute should be one of the following: true, false static const String rowBoldAttributes = 'row_bold_attributes'; /// column text color attributes /// it's a [SimpleTableColorMap] value, {column_index: color_hex_code, ...} /// the attribute should be the color hex color or appflowy_theme_color static const String columnTextColors = 'column_text_colors'; /// row text color attributes /// it's a [SimpleTableColorMap] value, {row_index: color_hex_code, ...} /// the attribute should be the color hex color or appflowy_theme_color static const String rowTextColors = 'row_text_colors'; /// column widths /// it's a [SimpleTableColumnWidthMap] value, {column_index: width, ...} static const String columnWidths = 'column_widths'; /// distribute column widths evenly /// if the user distributed the column widths evenly before, the value should be true, /// and for the newly added column, using the width of the previous column. /// it's a bool value, default is false static const String distributeColumnWidthsEvenly = 'distribute_column_widths_evenly'; } Node simpleTableBlockNode({ bool enableHeaderRow = false, bool enableHeaderColumn = false, SimpleTableColorMap? columnColors, SimpleTableColorMap? rowColors, SimpleTableColumnAlignMap? columnAligns, SimpleTableRowAlignMap? rowAligns, SimpleTableColumnWidthMap? columnWidths, required List children, }) { assert(children.every((e) => e.type == SimpleTableRowBlockKeys.type)); return Node( type: SimpleTableBlockKeys.type, attributes: { SimpleTableBlockKeys.enableHeaderRow: enableHeaderRow, SimpleTableBlockKeys.enableHeaderColumn: enableHeaderColumn, SimpleTableBlockKeys.columnColors: columnColors, SimpleTableBlockKeys.rowColors: rowColors, SimpleTableBlockKeys.columnAligns: columnAligns, SimpleTableBlockKeys.rowAligns: rowAligns, SimpleTableBlockKeys.columnWidths: columnWidths, }, children: children, ); } /// Create a simple table block node with the given column and row count. /// /// The table will have cells filled with paragraph nodes. /// /// For example, if you want to create a table with 2 columns and 3 rows, you can use: /// ```dart /// final table = createSimpleTableBlockNode(columnCount: 2, rowCount: 3); /// ``` /// /// | cell 1 | cell 2 | /// | cell 3 | cell 4 | /// | cell 5 | cell 6 | Node createSimpleTableBlockNode({ required int columnCount, required int rowCount, String? defaultContent, String Function(int rowIndex, int columnIndex)? contentBuilder, }) { final rows = List.generate(rowCount, (rowIndex) { final cells = List.generate( columnCount, (columnIndex) => simpleTableCellBlockNode( children: [ paragraphNode( text: defaultContent ?? contentBuilder?.call(rowIndex, columnIndex), ), ], ), ); return simpleTableRowBlockNode(children: cells); }); return simpleTableBlockNode(children: rows); } class SimpleTableBlockComponentBuilder extends BlockComponentBuilder { SimpleTableBlockComponentBuilder({ super.configuration, this.alwaysDistributeColumnWidths = false, }); final bool alwaysDistributeColumnWidths; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return SimpleTableBlockWidget( key: node.key, node: node, configuration: configuration, alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (node) => node.children.isNotEmpty; } class SimpleTableBlockWidget extends BlockComponentStatefulWidget { const SimpleTableBlockWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), required this.alwaysDistributeColumnWidths, }); final bool alwaysDistributeColumnWidths; @override State createState() => _SimpleTableBlockWidgetState(); } class _SimpleTableBlockWidgetState extends State with SelectableMixin, BlockComponentConfigurable, BlockComponentTextDirectionMixin, BlockComponentBackgroundColorMixin { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; @override late EditorState editorState = context.read(); final tableKey = GlobalKey(); final simpleTableContext = SimpleTableContext(); final scrollController = ScrollController(); @override void initState() { super.initState(); editorState.selectionNotifier.addListener(_onSelectionChanged); } @override void dispose() { simpleTableContext.dispose(); editorState.selectionNotifier.removeListener(_onSelectionChanged); scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { Widget child = SimpleTableWidget( node: node, simpleTableContext: simpleTableContext, alwaysDistributeColumnWidths: widget.alwaysDistributeColumnWidths, ); if (UniversalPlatform.isDesktop) { child = Transform.translate( offset: Offset( -SimpleTableConstants.tableLeftPadding, 0, ), child: child, ); } child = Container( alignment: Alignment.topLeft, padding: padding, child: child, ); if (UniversalPlatform.isDesktop) { child = Provider.value( value: simpleTableContext, child: MouseRegion( onEnter: (event) => simpleTableContext.isHoveringOnTableBlock.value = true, onExit: (event) { simpleTableContext.isHoveringOnTableBlock.value = false; }, child: child, ), ); } if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } return child; } void _onSelectionChanged() { final selection = editorState.selectionNotifier.value; final selectionType = editorState.selectionType; if (selectionType == SelectionType.block && widget.node.path.inSelection(selection)) { simpleTableContext.isSelectingTable.value = true; } else { simpleTableContext.isSelectingTable.value = false; } } RenderBox get _renderBox => context.findRenderObject() as RenderBox; @override Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); @override Position getPositionInOffset(Offset start) => end(); @override List getRectsInSelection( Selection selection, { bool shiftWithBaseOffset = false, }) { final parentBox = context.findRenderObject(); final tableBox = tableKey.currentContext?.findRenderObject(); if (parentBox is RenderBox && tableBox is RenderBox) { return [ (shiftWithBaseOffset ? tableBox.localToGlobal(Offset.zero, ancestor: parentBox) : Offset.zero) & tableBox.size, ]; } return [Offset.zero & _renderBox.size]; } @override Selection getSelectionInRange(Offset start, Offset end) => Selection.single( path: widget.node.path, startOffset: 0, endOffset: 1, ); @override bool get shouldCursorBlink => false; @override CursorStyle get cursorStyle => CursorStyle.cover; @override Offset localToGlobal( Offset offset, { bool shiftWithBaseOffset = false, }) => _renderBox.localToGlobal(offset); @override Rect getBlockRect({ bool shiftWithBaseOffset = false, }) { return getRectsInSelection(Selection.invalid()).first; } @override Rect? getCursorRectInPosition( Position position, { bool shiftWithBaseOffset = false, }) { final size = _renderBox.size; return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class SimpleTableCellBlockKeys { const SimpleTableCellBlockKeys._(); static const String type = 'simple_table_cell'; } Node simpleTableCellBlockNode({ List? children, }) { // Default children is a paragraph node. children ??= [ paragraphNode(), ]; return Node( type: SimpleTableCellBlockKeys.type, children: children, ); } class SimpleTableCellBlockComponentBuilder extends BlockComponentBuilder { SimpleTableCellBlockComponentBuilder({ super.configuration, this.alwaysDistributeColumnWidths = false, }); final bool alwaysDistributeColumnWidths; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return SimpleTableCellBlockWidget( key: node.key, node: node, configuration: configuration, alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (node) => true; } class SimpleTableCellBlockWidget extends BlockComponentStatefulWidget { const SimpleTableCellBlockWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), required this.alwaysDistributeColumnWidths, }); final bool alwaysDistributeColumnWidths; @override State createState() => SimpleTableCellBlockWidgetState(); } @visibleForTesting class SimpleTableCellBlockWidgetState extends State with BlockComponentConfigurable, BlockComponentTextDirectionMixin, BlockComponentBackgroundColorMixin { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; @override late EditorState editorState = context.read(); late SimpleTableContext? simpleTableContext = context.read(); late final borderBuilder = SimpleTableBorderBuilder( context: context, simpleTableContext: simpleTableContext!, node: node, ); /// Notify if the cell is editing. ValueNotifier isEditingCellNotifier = ValueNotifier(false); /// Notify if the cell is hit by the reordering offset. /// /// This value is only available on mobile. ValueNotifier isReorderingHitCellNotifier = ValueNotifier(false); @override void initState() { super.initState(); simpleTableContext?.isSelectingTable.addListener(_onSelectingTableChanged); simpleTableContext?.reorderingOffset .addListener(_onReorderingOffsetChanged); node.parentTableNode?.addListener(_onSelectingTableChanged); editorState.selectionNotifier.addListener(_onSelectionChanged); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _onSelectionChanged(); }); } @override void dispose() { simpleTableContext?.isSelectingTable.removeListener( _onSelectingTableChanged, ); simpleTableContext?.reorderingOffset.removeListener( _onReorderingOffsetChanged, ); node.parentTableNode?.removeListener(_onSelectingTableChanged); editorState.selectionNotifier.removeListener(_onSelectionChanged); isEditingCellNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (simpleTableContext == null) { return const SizedBox.shrink(); } Widget child = Stack( clipBehavior: Clip.none, children: [ _buildCell(), if (editorState.editable) ...[ if (node.columnIndex == 0) Positioned( // if the cell is in the first row, add padding to the top of the cell // to make the row action button clickable. top: node.rowIndex == 0 ? SimpleTableConstants.tableHitTestTopPadding : 0, bottom: 0, left: -SimpleTableConstants.tableLeftPadding, child: _buildRowMoreActionButton(), ), if (node.rowIndex == 0) Positioned( left: node.columnIndex == 0 ? SimpleTableConstants.tableHitTestLeftPadding : 0, right: 0, child: _buildColumnMoreActionButton(), ), if (node.columnIndex == 0 && node.rowIndex == 0) Positioned( left: 2, top: 2, child: _buildTableActionMenu(), ), Positioned( right: 0, top: node.rowIndex == 0 ? SimpleTableConstants.tableHitTestTopPadding : 0, bottom: 0, child: SimpleTableColumnResizeHandle( node: node, ), ), ], ], ); if (UniversalPlatform.isDesktop) { child = MouseRegion( hitTestBehavior: HitTestBehavior.opaque, onEnter: (event) => simpleTableContext!.hoveringTableCell.value = node, child: child, ); } return child; } Widget _buildCell() { if (simpleTableContext == null) { return const SizedBox.shrink(); } return UniversalPlatform.isDesktop ? _buildDesktopCell() : _buildMobileCell(); } Widget _buildDesktopCell() { return Padding( // add padding to the top of the cell if it is the first row, otherwise the // column action button is not clickable. // issue: https://github.com/flutter/flutter/issues/75747 padding: EdgeInsets.only( top: node.rowIndex == 0 ? SimpleTableConstants.tableHitTestTopPadding : 0, left: node.columnIndex == 0 ? SimpleTableConstants.tableHitTestLeftPadding : 0, ), // TODO(Lucas): find a better way to handle the multiple value listenable builder // There's flutter pub can do that. child: ValueListenableBuilder( valueListenable: isEditingCellNotifier, builder: (context, isEditingCell, child) { return ValueListenableBuilder( valueListenable: simpleTableContext!.selectingColumn, builder: (context, selectingColumn, _) { return ValueListenableBuilder( valueListenable: simpleTableContext!.selectingRow, builder: (context, selectingRow, _) { return ValueListenableBuilder( valueListenable: simpleTableContext!.hoveringTableCell, builder: (context, hoveringTableCell, _) { return DecoratedBox( decoration: _buildDecoration(), child: child!, ); }, ); }, ); }, ); }, child: Container( padding: SimpleTableConstants.cellEdgePadding, constraints: const BoxConstraints( minWidth: SimpleTableConstants.minimumColumnWidth, ), width: widget.alwaysDistributeColumnWidths ? null : node.columnWidth, child: node.children.isEmpty ? Column( children: [ // expand the cell to make the empty cell content clickable Expanded( child: _buildEmptyCellContent(), ), ], ) : Column( children: [ ...node.children.map(_buildCellContent), _buildEmptyCellContent(height: 12), ], ), ), ), ); } Widget _buildMobileCell() { return Padding( padding: EdgeInsets.only( top: node.rowIndex == 0 ? SimpleTableConstants.tableHitTestTopPadding : 0, left: node.columnIndex == 0 ? SimpleTableConstants.tableHitTestLeftPadding : 0, ), child: ValueListenableBuilder( valueListenable: isEditingCellNotifier, builder: (context, isEditingCell, child) { return ValueListenableBuilder( valueListenable: simpleTableContext!.selectingColumn, builder: (context, selectingColumn, _) { return ValueListenableBuilder( valueListenable: simpleTableContext!.selectingRow, builder: (context, selectingRow, _) { return ValueListenableBuilder( valueListenable: isReorderingHitCellNotifier, builder: (context, isReorderingHitCellNotifier, _) { final previousCell = node.getPreviousCellInSameRow(); return Stack( children: [ DecoratedBox( decoration: _buildDecoration(), child: child!, ), Positioned( right: 0, top: 0, bottom: 0, child: SimpleTableColumnResizeHandle( node: node, ), ), if (node.columnIndex != 0 && previousCell != null) Positioned( left: 0, top: 0, bottom: 0, // pass the previous node to the resize handle // to make the resize handle work correctly child: SimpleTableColumnResizeHandle( node: previousCell, isPreviousCell: true, ), ), ], ); }, ); }, ); }, ); }, child: Container( padding: SimpleTableConstants.cellEdgePadding, constraints: const BoxConstraints( minWidth: SimpleTableConstants.minimumColumnWidth, ), width: node.columnWidth, child: node.children.isEmpty ? _buildEmptyCellContent() : Column( children: node.children.map(_buildCellContent).toList(), ), ), ), ); } Widget _buildCellContent(Node childNode) { final alignment = _buildAlignment(); Widget child = IntrinsicWidth( child: editorState.renderer.build(context, childNode), ); final notSupportAlignmentBlocks = [ DividerBlockKeys.type, CalloutBlockKeys.type, MathEquationBlockKeys.type, CodeBlockKeys.type, SubPageBlockKeys.type, FileBlockKeys.type, CustomImageBlockKeys.type, ]; if (notSupportAlignmentBlocks.contains(childNode.type)) { child = SizedBox( width: double.infinity, child: child, ); } else { child = Align( alignment: alignment, child: child, ); } return child; } Widget _buildEmptyCellContent({ double? height, }) { // if the table cell is empty, we should allow the user to tap on it to create a new paragraph. final lastChild = node.children.lastOrNull; if (lastChild != null && lastChild.delta?.isEmpty != null) { return const SizedBox.shrink(); } Widget child = GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { final transaction = editorState.transaction; final length = node.children.length; final path = node.path.child(length); transaction ..insertNode( path, paragraphNode(), ) ..afterSelection = Selection.collapsed(Position(path: path)); editorState.apply(transaction); }, ); if (height != null) { child = SizedBox( height: height, child: child, ); } return child; } Widget _buildRowMoreActionButton() { final rowIndex = node.rowIndex; return SimpleTableMoreActionMenu( tableCellNode: node, index: rowIndex, type: SimpleTableMoreActionType.row, ); } Widget _buildColumnMoreActionButton() { final columnIndex = node.columnIndex; return SimpleTableMoreActionMenu( tableCellNode: node, index: columnIndex, type: SimpleTableMoreActionType.column, ); } Widget _buildTableActionMenu() { final tableNode = node.parentTableNode; // the table action menu is only available on mobile platform. if (tableNode == null || UniversalPlatform.isDesktop) { return const SizedBox.shrink(); } return SimpleTableActionMenu( tableNode: tableNode, editorState: editorState, ); } Alignment _buildAlignment() { Alignment alignment = Alignment.topLeft; if (node.columnAlign != TableAlign.left) { alignment = node.columnAlign.alignment; } else if (node.rowAlign != TableAlign.left) { alignment = node.rowAlign.alignment; } return alignment; } Decoration _buildDecoration() { final backgroundColor = _buildBackgroundColor(); final border = borderBuilder.buildBorder( isEditingCell: isEditingCellNotifier.value, ); return BoxDecoration( border: border, color: backgroundColor, ); } Color? _buildBackgroundColor() { // Priority: highlight color > column color > row color > header color > default color final isSelectingTable = simpleTableContext?.isSelectingTable.value ?? false; if (isSelectingTable) { return Theme.of(context).colorScheme.primary.withValues(alpha: 0.1); } final columnColor = node.buildColumnColor(context); if (columnColor != null && columnColor != Colors.transparent) { return columnColor; } final rowColor = node.buildRowColor(context); if (rowColor != null && rowColor != Colors.transparent) { return rowColor; } // Check if the cell is in the header. // If the cell is in the header, set the background color to the default header color. // Otherwise, set the background color to null. if (_isInHeader()) { return context.simpleTableDefaultHeaderColor; } return Colors.transparent; } bool _isInHeader() { final isHeaderColumnEnabled = node.isHeaderColumnEnabled; final isHeaderRowEnabled = node.isHeaderRowEnabled; final cellPosition = node.cellPosition; final isFirstColumn = cellPosition.$1 == 0; final isFirstRow = cellPosition.$2 == 0; return isHeaderColumnEnabled && isFirstRow || isHeaderRowEnabled && isFirstColumn; } void _onSelectingTableChanged() { if (mounted) { setState(() {}); } } void _onSelectionChanged() { final selection = editorState.selection; // check if the selection is in the cell if (selection != null && node.path.isAncestorOf(selection.start.path) && node.path.isAncestorOf(selection.end.path)) { isEditingCellNotifier.value = true; simpleTableContext?.isEditingCell.value = node; } else { isEditingCellNotifier.value = false; } // if the selection is null or the selection is collapsed, set the isEditingCell to null. if (selection == null) { simpleTableContext?.isEditingCell.value = null; } else if (selection.isCollapsed) { // if the selection is collapsed, check if the selection is in the cell. final selectedNode = editorState.getNodesInSelection(selection).firstOrNull; if (selectedNode != null) { final tableNode = selectedNode.parentTableNode; if (tableNode == null || tableNode.id != node.parentTableNode?.id) { simpleTableContext?.isEditingCell.value = null; } } else { simpleTableContext?.isEditingCell.value = null; } } } /// Calculate if the cell is hit by the reordering offset. /// If the cell is hit, set the isReorderingCell to true. void _onReorderingOffsetChanged() { final simpleTableContext = this.simpleTableContext; if (UniversalPlatform.isDesktop || simpleTableContext == null) { return; } final isReordering = simpleTableContext.isReordering; if (!isReordering) { return; } final isReorderingColumn = simpleTableContext.isReorderingColumn.value.$1; final isReorderingRow = simpleTableContext.isReorderingRow.value.$1; if (!isReorderingColumn && !isReorderingRow) { return; } final reorderingOffset = simpleTableContext.reorderingOffset.value; final renderBox = node.renderBox; if (renderBox == null) { return; } final cellRect = renderBox.localToGlobal(Offset.zero) & renderBox.size; bool isHitCurrentCell = false; if (isReorderingColumn) { isHitCurrentCell = cellRect.left < reorderingOffset.dx && cellRect.right > reorderingOffset.dx; } else if (isReorderingRow) { isHitCurrentCell = cellRect.top < reorderingOffset.dy && cellRect.bottom > reorderingOffset.dy; } isReorderingHitCellNotifier.value = isHitCurrentCell; if (isHitCurrentCell) { if (isReorderingColumn) { if (simpleTableContext.isReorderingHitIndex.value != node.columnIndex) { HapticFeedback.lightImpact(); simpleTableContext.isReorderingHitIndex.value = node.columnIndex; } } else if (isReorderingRow) { if (simpleTableContext.isReorderingHitIndex.value != node.rowIndex) { HapticFeedback.lightImpact(); simpleTableContext.isReorderingHitIndex.value = node.rowIndex; } } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; const _enableTableDebugLog = false; class SimpleTableContext { SimpleTableContext() { if (_enableTableDebugLog) { isHoveringOnColumnsAndRows.addListener( _onHoveringOnColumnsAndRowsChanged, ); isHoveringOnTableArea.addListener( _onHoveringOnTableAreaChanged, ); hoveringTableCell.addListener(_onHoveringTableNodeChanged); selectingColumn.addListener(_onSelectingColumnChanged); selectingRow.addListener(_onSelectingRowChanged); isSelectingTable.addListener(_onSelectingTableChanged); isHoveringOnTableBlock.addListener(_onHoveringOnTableBlockChanged); isReorderingColumn.addListener(_onDraggingColumnChanged); isReorderingRow.addListener(_onDraggingRowChanged); } } /// the area only contains the columns and rows, /// the add row button, add column button, and add column and row button are not part of the table area final ValueNotifier isHoveringOnColumnsAndRows = ValueNotifier(false); /// the table area contains the columns and rows, /// the add row button, add column button, and add column and row button are not part of the table area, /// not including the selection area and padding final ValueNotifier isHoveringOnTableArea = ValueNotifier(false); /// the table block area contains the table area and the add row button, add column button, and add column and row button /// also, the table block area contains the selection area and padding final ValueNotifier isHoveringOnTableBlock = ValueNotifier(false); /// the hovering table cell is the cell that the mouse is hovering on final ValueNotifier hoveringTableCell = ValueNotifier(null); /// the hovering on resize handle is the resize handle that the mouse is hovering on final ValueNotifier hoveringOnResizeHandle = ValueNotifier(null); /// the selecting column is the column that the user is selecting final ValueNotifier selectingColumn = ValueNotifier(null); /// the selecting row is the row that the user is selecting final ValueNotifier selectingRow = ValueNotifier(null); /// the is selecting table is the table that the user is selecting final ValueNotifier isSelectingTable = ValueNotifier(false); /// isReorderingColumn is a tuple of (isReordering, columnIndex) final ValueNotifier<(bool, int)> isReorderingColumn = ValueNotifier((false, -1)); /// isReorderingRow is a tuple of (isReordering, rowIndex) final ValueNotifier<(bool, int)> isReorderingRow = ValueNotifier((false, -1)); /// reorderingOffset is the offset of the reordering // /// This value is only available when isReordering is true final ValueNotifier reorderingOffset = ValueNotifier(Offset.zero); /// isDraggingRow to expand the rows of the table bool isDraggingRow = false; /// isDraggingColumn to expand the columns of the table bool isDraggingColumn = false; bool get isReordering => isReorderingColumn.value.$1 || isReorderingRow.value.$1; /// isEditingCell is the cell that the user is editing /// /// This value is available on mobile only final ValueNotifier isEditingCell = ValueNotifier(null); /// isReorderingHitCell is the cell that the user is reordering /// /// This value is available on mobile only final ValueNotifier isReorderingHitIndex = ValueNotifier(null); /// resizingCell is the cell that the user is resizing /// /// This value is available on mobile only final ValueNotifier resizingCell = ValueNotifier(null); /// Scroll controller for the table ScrollController? horizontalScrollController; void _onHoveringOnColumnsAndRowsChanged() { if (!_enableTableDebugLog) { return; } Log.debug('isHoveringOnTable: ${isHoveringOnColumnsAndRows.value}'); } void _onHoveringTableNodeChanged() { if (!_enableTableDebugLog) { return; } final node = hoveringTableCell.value; if (node == null) { return; } Log.debug('hoveringTableNode: $node, ${node.cellPosition}'); } void _onSelectingColumnChanged() { if (!_enableTableDebugLog) { return; } Log.debug('selectingColumn: ${selectingColumn.value}'); } void _onSelectingRowChanged() { if (!_enableTableDebugLog) { return; } Log.debug('selectingRow: ${selectingRow.value}'); } void _onSelectingTableChanged() { if (!_enableTableDebugLog) { return; } Log.debug('isSelectingTable: ${isSelectingTable.value}'); } void _onHoveringOnTableBlockChanged() { if (!_enableTableDebugLog) { return; } Log.debug('isHoveringOnTableBlock: ${isHoveringOnTableBlock.value}'); } void _onHoveringOnTableAreaChanged() { if (!_enableTableDebugLog) { return; } Log.debug('isHoveringOnTableArea: ${isHoveringOnTableArea.value}'); } void _onDraggingColumnChanged() { if (!_enableTableDebugLog) { return; } Log.debug('isDraggingColumn: ${isReorderingColumn.value}'); } void _onDraggingRowChanged() { if (!_enableTableDebugLog) { return; } Log.debug('isDraggingRow: ${isReorderingRow.value}'); } void dispose() { isHoveringOnColumnsAndRows.dispose(); isHoveringOnTableBlock.dispose(); isHoveringOnTableArea.dispose(); hoveringTableCell.dispose(); hoveringOnResizeHandle.dispose(); selectingColumn.dispose(); selectingRow.dispose(); isSelectingTable.dispose(); isReorderingColumn.dispose(); isReorderingRow.dispose(); reorderingOffset.dispose(); isEditingCell.dispose(); isReorderingHitIndex.dispose(); resizingCell.dispose(); } } class SimpleTableConstants { /// Table static const defaultColumnWidth = 160.0; static const minimumColumnWidth = 36.0; static const defaultRowHeight = 36.0; static double get tableHitTestTopPadding => UniversalPlatform.isDesktop ? 8.0 : 24.0; static double get tableHitTestLeftPadding => UniversalPlatform.isDesktop ? 0.0 : 24.0; static double get tableLeftPadding => UniversalPlatform.isDesktop ? 8.0 : 0.0; static const tableBottomPadding = addRowButtonHeight + 3 * addRowButtonPadding; static const tableRightPadding = addColumnButtonWidth + 2 * SimpleTableConstants.addColumnButtonPadding; static EdgeInsets get tablePadding => EdgeInsets.only( // don't add padding to the top of the table, the first row will have padding // to make the column action button clickable. bottom: tableBottomPadding, left: tableLeftPadding, right: tableRightPadding, ); static double get tablePageOffset => UniversalPlatform.isMobile ? EditorStyleCustomizer.optionMenuWidth + EditorStyleCustomizer.nodeHorizontalPadding * 2 : EditorStyleCustomizer.optionMenuWidth + 12; // Add row button static const addRowButtonHeight = 16.0; static const addRowButtonPadding = 4.0; static const addRowButtonRadius = 4.0; static const addRowButtonRightPadding = addColumnButtonWidth + addColumnButtonPadding * 2; // Add column button static const addColumnButtonWidth = 16.0; static const addColumnButtonPadding = 2.0; static const addColumnButtonRadius = 4.0; static const addColumnButtonBottomPadding = addRowButtonHeight + 3 * addRowButtonPadding; // Add column and row button static const addColumnAndRowButtonWidth = addColumnButtonWidth; static const addColumnAndRowButtonHeight = addRowButtonHeight; static const addColumnAndRowButtonCornerRadius = addColumnButtonWidth / 2.0; static const addColumnAndRowButtonBottomPadding = 2.5 * addRowButtonPadding; // Table cell static EdgeInsets get cellEdgePadding => UniversalPlatform.isDesktop ? const EdgeInsets.symmetric( horizontal: 9.0, vertical: 2.0, ) : const EdgeInsets.only( left: 8.0, right: 8.0, bottom: 6.0, ); static const cellBorderWidth = 1.0; static const resizeHandleWidth = 3.0; static const borderType = SimpleTableBorderRenderType.cell; // Table more action static const moreActionHeight = 34.0; static const moreActionPadding = EdgeInsets.symmetric(vertical: 2.0); static const moreActionHorizontalMargin = EdgeInsets.symmetric(horizontal: 6.0); /// Only displaying the add row / add column / add column and row button /// when hovering on the last row / last column / last cell. static const enableHoveringLogicV2 = true; /// Enable the drag to expand the table static const enableDragToExpandTable = false; /// Action sheet hit test area on Mobile static const rowActionSheetHitTestAreaWidth = 24.0; static const columnActionSheetHitTestAreaHeight = 24.0; static const actionSheetQuickActionSectionHeight = 44.0; static const actionSheetInsertSectionHeight = 52.0; static const actionSheetContentSectionHeight = 44.0; static const actionSheetNormalActionSectionHeight = 48.0; static const actionSheetButtonRadius = 12.0; static const actionSheetBottomSheetHeight = 320.0; } enum SimpleTableBorderRenderType { cell, table, } extension SimpleTableColors on BuildContext { Color get simpleTableBorderColor => Theme.of(this).isLightMode ? const Color(0xFFE4E5E5) : const Color(0xFF3A3F49); Color get simpleTableDividerColor => Theme.of(this).isLightMode ? const Color(0x141F2329) : const Color(0xFF23262B).withValues(alpha: 0.5); Color get simpleTableMoreActionBackgroundColor => Theme.of(this).isLightMode ? const Color(0xFFF2F3F5) : const Color(0xFF2D3036); Color get simpleTableMoreActionBorderColor => Theme.of(this).isLightMode ? const Color(0xFFCFD3D9) : const Color(0xFF44484E); Color get simpleTableMoreActionHoverColor => Theme.of(this).isLightMode ? const Color(0xFF00C8FF) : const Color(0xFF00C8FF); Color get simpleTableDefaultHeaderColor => Theme.of(this).isLightMode ? const Color(0xFFF2F2F2) : const Color(0x08FFFFFF); Color get simpleTableActionButtonBackgroundColor => Theme.of(this).isLightMode ? const Color(0xFFFFFFFF) : const Color(0xFF2D3036); Color get simpleTableInsertActionBackgroundColor => Theme.of(this).isLightMode ? const Color(0xFFF2F2F7) : const Color(0xFF2D3036); Color? get simpleTableQuickActionBackgroundColor => Theme.of(this).isLightMode ? null : const Color(0xFFBBC3CD); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; enum SimpleTableMoreActionType { column, row; List buildDesktopActions({ required int index, required int columnLength, required int rowLength, }) { // there're two special cases: // 1. if the table only contains one row or one column, remove the delete action // 2. if the index is 0, add the enable header action switch (this) { case SimpleTableMoreActionType.row: return [ SimpleTableMoreAction.insertAbove, SimpleTableMoreAction.insertBelow, SimpleTableMoreAction.divider, if (index == 0) SimpleTableMoreAction.enableHeaderRow, SimpleTableMoreAction.backgroundColor, SimpleTableMoreAction.align, SimpleTableMoreAction.divider, SimpleTableMoreAction.setToPageWidth, SimpleTableMoreAction.distributeColumnsEvenly, SimpleTableMoreAction.divider, SimpleTableMoreAction.duplicate, SimpleTableMoreAction.clearContents, if (rowLength > 1) SimpleTableMoreAction.delete, ]; case SimpleTableMoreActionType.column: return [ SimpleTableMoreAction.insertLeft, SimpleTableMoreAction.insertRight, SimpleTableMoreAction.divider, if (index == 0) SimpleTableMoreAction.enableHeaderColumn, SimpleTableMoreAction.backgroundColor, SimpleTableMoreAction.align, SimpleTableMoreAction.divider, SimpleTableMoreAction.setToPageWidth, SimpleTableMoreAction.distributeColumnsEvenly, SimpleTableMoreAction.divider, SimpleTableMoreAction.duplicate, SimpleTableMoreAction.clearContents, if (columnLength > 1) SimpleTableMoreAction.delete, ]; } } List> buildMobileActions({ required int index, required int columnLength, required int rowLength, }) { // the actions on mobile are not the same as the desktop ones // the mobile actions are grouped into different sections switch (this) { case SimpleTableMoreActionType.row: return [ if (index == 0) [SimpleTableMoreAction.enableHeaderRow], [ SimpleTableMoreAction.setToPageWidth, SimpleTableMoreAction.distributeColumnsEvenly, ], [ SimpleTableMoreAction.duplicateRow, SimpleTableMoreAction.clearContents, ], ]; case SimpleTableMoreActionType.column: return [ if (index == 0) [SimpleTableMoreAction.enableHeaderColumn], [ SimpleTableMoreAction.setToPageWidth, SimpleTableMoreAction.distributeColumnsEvenly, ], [ SimpleTableMoreAction.duplicateColumn, SimpleTableMoreAction.clearContents, ], ]; } } FlowySvgData get reorderIconSvg { switch (this) { case SimpleTableMoreActionType.column: return FlowySvgs.table_reorder_column_s; case SimpleTableMoreActionType.row: return FlowySvgs.table_reorder_row_s; } } @override String toString() { return switch (this) { SimpleTableMoreActionType.column => 'column', SimpleTableMoreActionType.row => 'row', }; } } enum SimpleTableMoreAction { insertLeft, insertRight, insertAbove, insertBelow, duplicate, clearContents, delete, align, backgroundColor, enableHeaderColumn, enableHeaderRow, setToPageWidth, distributeColumnsEvenly, divider, // these actions are only available on mobile duplicateRow, duplicateColumn, cut, copy, paste, bold, textColor, textBackgroundColor, duplicateTable, copyLinkToBlock; String get name { return switch (this) { SimpleTableMoreAction.align => LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), SimpleTableMoreAction.backgroundColor => LocaleKeys.document_plugins_simpleTable_moreActions_color.tr(), SimpleTableMoreAction.enableHeaderColumn => LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn.tr(), SimpleTableMoreAction.enableHeaderRow => LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(), SimpleTableMoreAction.insertLeft => LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(), SimpleTableMoreAction.insertRight => LocaleKeys.document_plugins_simpleTable_moreActions_insertRight.tr(), SimpleTableMoreAction.insertBelow => LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow.tr(), SimpleTableMoreAction.insertAbove => LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove.tr(), SimpleTableMoreAction.clearContents => LocaleKeys.document_plugins_simpleTable_moreActions_clearContents.tr(), SimpleTableMoreAction.delete => LocaleKeys.document_plugins_simpleTable_moreActions_delete.tr(), SimpleTableMoreAction.duplicate => LocaleKeys.document_plugins_simpleTable_moreActions_duplicate.tr(), SimpleTableMoreAction.setToPageWidth => LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth.tr(), SimpleTableMoreAction.distributeColumnsEvenly => LocaleKeys .document_plugins_simpleTable_moreActions_distributeColumnsWidth .tr(), SimpleTableMoreAction.duplicateRow => LocaleKeys.document_plugins_simpleTable_moreActions_duplicateRow.tr(), SimpleTableMoreAction.duplicateColumn => LocaleKeys .document_plugins_simpleTable_moreActions_duplicateColumn .tr(), SimpleTableMoreAction.duplicateTable => LocaleKeys.document_plugins_simpleTable_moreActions_duplicateTable.tr(), SimpleTableMoreAction.copyLinkToBlock => LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(), SimpleTableMoreAction.bold || SimpleTableMoreAction.textColor || SimpleTableMoreAction.textBackgroundColor || SimpleTableMoreAction.cut || SimpleTableMoreAction.copy || SimpleTableMoreAction.paste => throw UnimplementedError(), SimpleTableMoreAction.divider => throw UnimplementedError(), }; } FlowySvgData get leftIconSvg { return switch (this) { SimpleTableMoreAction.insertLeft => FlowySvgs.table_insert_left_s, SimpleTableMoreAction.insertRight => FlowySvgs.table_insert_right_s, SimpleTableMoreAction.insertAbove => FlowySvgs.table_insert_above_s, SimpleTableMoreAction.insertBelow => FlowySvgs.table_insert_below_s, SimpleTableMoreAction.duplicate => FlowySvgs.duplicate_s, SimpleTableMoreAction.clearContents => FlowySvgs.table_clear_content_s, SimpleTableMoreAction.delete => FlowySvgs.trash_s, SimpleTableMoreAction.setToPageWidth => FlowySvgs.table_set_to_page_width_s, SimpleTableMoreAction.distributeColumnsEvenly => FlowySvgs.table_distribute_columns_evenly_s, SimpleTableMoreAction.enableHeaderColumn => FlowySvgs.table_header_column_s, SimpleTableMoreAction.enableHeaderRow => FlowySvgs.table_header_row_s, SimpleTableMoreAction.duplicateRow => FlowySvgs.m_table_duplicate_s, SimpleTableMoreAction.duplicateColumn => FlowySvgs.m_table_duplicate_s, SimpleTableMoreAction.cut => FlowySvgs.m_table_quick_action_cut_s, SimpleTableMoreAction.copy => FlowySvgs.m_table_quick_action_copy_s, SimpleTableMoreAction.paste => FlowySvgs.m_table_quick_action_paste_s, SimpleTableMoreAction.bold => FlowySvgs.m_aa_bold_s, SimpleTableMoreAction.duplicateTable => FlowySvgs.m_table_duplicate_s, SimpleTableMoreAction.copyLinkToBlock => FlowySvgs.m_copy_link_s, SimpleTableMoreAction.align => FlowySvgs.m_aa_align_left_s, SimpleTableMoreAction.textColor => throw UnsupportedError('text color icon is not supported'), SimpleTableMoreAction.textBackgroundColor => throw UnsupportedError('text background color icon is not supported'), SimpleTableMoreAction.divider => throw UnsupportedError('divider icon is not supported'), SimpleTableMoreAction.backgroundColor => throw UnsupportedError('background color icon is not supported'), }; } } class SimpleTableMoreActionMenu extends StatefulWidget { const SimpleTableMoreActionMenu({ super.key, required this.index, required this.type, required this.tableCellNode, }); final int index; final SimpleTableMoreActionType type; final Node tableCellNode; @override State createState() => _SimpleTableMoreActionMenuState(); } class _SimpleTableMoreActionMenuState extends State { ValueNotifier isShowingMenu = ValueNotifier(false); ValueNotifier isEditingCellNotifier = ValueNotifier(false); late final editorState = context.read(); late final simpleTableContext = context.read(); @override void initState() { super.initState(); editorState.selectionNotifier.addListener(_onSelectionChanged); } @override void dispose() { isShowingMenu.dispose(); isEditingCellNotifier.dispose(); editorState.selectionNotifier.removeListener(_onSelectionChanged); super.dispose(); } @override Widget build(BuildContext context) { return Align( alignment: widget.type == SimpleTableMoreActionType.row ? UniversalPlatform.isDesktop ? Alignment.centerLeft : Alignment.centerRight : Alignment.topCenter, child: UniversalPlatform.isDesktop ? _buildDesktopMenu() : _buildMobileMenu(), ); } // On desktop, the menu is a popup and only shows when hovering. Widget _buildDesktopMenu() { return ValueListenableBuilder( valueListenable: isShowingMenu, builder: (context, isShowingMenu, child) { return ValueListenableBuilder( valueListenable: simpleTableContext.hoveringTableCell, builder: (context, hoveringTableNode, child) { final reorderingIndex = switch (widget.type) { SimpleTableMoreActionType.column => simpleTableContext.isReorderingColumn.value.$2, SimpleTableMoreActionType.row => simpleTableContext.isReorderingRow.value.$2, }; final isReordering = simpleTableContext.isReordering; if (isReordering) { // when reordering, hide the menu for another column or row that is not the current dragging one. if (reorderingIndex != widget.index) { return const SizedBox.shrink(); } else { return child!; } } final hoveringIndex = widget.type == SimpleTableMoreActionType.column ? hoveringTableNode?.columnIndex : hoveringTableNode?.rowIndex; if (hoveringIndex != widget.index && !isShowingMenu) { return const SizedBox.shrink(); } return child!; }, child: SimpleTableMoreActionPopup( index: widget.index, isShowingMenu: this.isShowingMenu, type: widget.type, ), ); }, ); } // On mobile, the menu is a action sheet and always shows. Widget _buildMobileMenu() { return ValueListenableBuilder( valueListenable: isShowingMenu, builder: (context, isShowingMenu, child) { return ValueListenableBuilder( valueListenable: simpleTableContext.isEditingCell, builder: (context, isEditingCell, child) { if (isShowingMenu) { return child!; } if (isEditingCell == null) { return const SizedBox.shrink(); } final columnIndex = isEditingCell.columnIndex; final rowIndex = isEditingCell.rowIndex; switch (widget.type) { case SimpleTableMoreActionType.column: if (columnIndex != widget.index) { return const SizedBox.shrink(); } case SimpleTableMoreActionType.row: if (rowIndex != widget.index) { return const SizedBox.shrink(); } } return child!; }, child: SimpleTableMobileDraggableReorderButton( index: widget.index, type: widget.type, cellNode: widget.tableCellNode, isShowingMenu: this.isShowingMenu, editorState: editorState, simpleTableContext: simpleTableContext, ), ); }, ); } void _onSelectionChanged() { final selection = editorState.selection; // check if the selection is in the cell if (selection != null && widget.tableCellNode.path.isAncestorOf(selection.start.path) && widget.tableCellNode.path.isAncestorOf(selection.end.path)) { isEditingCellNotifier.value = true; } else { isEditingCellNotifier.value = false; } } } /// This widget is only used on mobile class SimpleTableActionMenu extends StatelessWidget { const SimpleTableActionMenu({ super.key, required this.tableNode, required this.editorState, }); final Node tableNode; final EditorState editorState; @override Widget build(BuildContext context) { final simpleTableContext = context.read(); return ValueListenableBuilder( valueListenable: simpleTableContext.isEditingCell, builder: (context, isEditingCell, child) { if (isEditingCell == null) { return const SizedBox.shrink(); } return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { editorState.service.keyboardService?.closeKeyboard(); // delay the bottom sheet show to make sure the keyboard is closed Future.delayed(Durations.short3, () { if (context.mounted) { _showTableActionBottomSheet(context); } }); }, child: Container( width: 20, height: 20, alignment: Alignment.center, child: const FlowySvg( FlowySvgs.drag_element_s, size: Size.square(18.0), ), ), ); }, ); } Future _showTableActionBottomSheet(BuildContext context) async { // check if the table node is a simple table assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { Log.error('The table node is not a simple table'); return; } final beforeSelection = editorState.selection; // increase the keep editor focus notifier to prevent the editor from losing focus keepEditorFocusNotifier.increase(); unawaited( editorState.updateSelectionWithReason( Selection.collapsed( Position( path: tableNode.path, ), ), customSelectionType: SelectionType.block, extraInfo: { selectionExtraInfoDisableMobileToolbarKey: true, selectionExtraInfoDoNotAttachTextService: true, }, ), ); if (!context.mounted) { return; } final simpleTableContext = context.read(); simpleTableContext.isSelectingTable.value = true; // show the bottom sheet await showMobileBottomSheet( context, showDragHandle: true, showDivider: false, useSafeArea: false, enablePadding: false, builder: (context) => Provider.value( value: simpleTableContext, child: SimpleTableBottomSheet( tableNode: tableNode, editorState: editorState, ), ), ); simpleTableContext.isSelectingTable.value = false; keepEditorFocusNotifier.decrease(); // remove the extra info if (beforeSelection != null) { await editorState.updateSelectionWithReason( beforeSelection, customSelectionType: SelectionType.inline, reason: SelectionUpdateReason.uiEvent, extraInfo: {}, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_content_operation.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension TableContentOperation on EditorState { /// Clear the content of the column at the given index. /// /// Before: /// Given column index: 0 /// Row 1: | 0 | 1 | ← The content of these cells will be cleared /// Row 2: | 2 | 3 | /// /// Call this function with column index 0 will clear the first column of the table. /// /// After: /// Row 1: | | | /// Row 2: | 2 | 3 | Future clearContentAtRowIndex({ required Node tableNode, required int rowIndex, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } if (rowIndex < 0 || rowIndex >= tableNode.rowLength) { Log.warn('clear content in row: index out of range: $rowIndex'); return; } Log.info('clear content in row: $rowIndex in table ${tableNode.id}'); final transaction = this.transaction; final row = tableNode.children[rowIndex]; for (var i = 0; i < row.children.length; i++) { final cell = row.children[i]; transaction.insertNode(cell.path.next, simpleTableCellBlockNode()); transaction.deleteNode(cell); } await apply(transaction); } /// Clear the content of the row at the given index. /// /// Before: /// Given row index: 1 /// ↓ The content of these cells will be cleared /// Row 1: | 0 | 1 | /// Row 2: | 2 | 3 | /// /// Call this function with row index 1 will clear the second row of the table. /// /// After: /// Row 1: | 0 | | /// Row 2: | 2 | | Future clearContentAtColumnIndex({ required Node tableNode, required int columnIndex, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } if (columnIndex < 0 || columnIndex >= tableNode.columnLength) { Log.warn('clear content in column: index out of range: $columnIndex'); return; } Log.info('clear content in column: $columnIndex in table ${tableNode.id}'); final transaction = this.transaction; for (var i = 0; i < tableNode.rowLength; i++) { final row = tableNode.children[i]; final cell = columnIndex >= row.children.length ? row.children.last : row.children[columnIndex]; transaction.insertNode(cell.path.next, simpleTableCellBlockNode()); transaction.deleteNode(cell); } await apply(transaction); } /// Clear the content of the table. Future clearAllContent({ required Node tableNode, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } for (var i = 0; i < tableNode.rowLength; i++) { await clearContentAtRowIndex(tableNode: tableNode, rowIndex: i); } } /// Copy the selected column to the clipboard. /// /// If the [clearContent] is true, the content of the column will be cleared after /// copying. Future copyColumn({ required Node tableNode, required int columnIndex, bool clearContent = false, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return null; } if (columnIndex < 0 || columnIndex >= tableNode.columnLength) { Log.warn('copy column: index out of range: $columnIndex'); return null; } // the plain text content of the column final List content = []; // the cells of the column final List cells = []; for (var i = 0; i < tableNode.rowLength; i++) { final row = tableNode.children[i]; final cell = columnIndex >= row.children.length ? row.children.last : row.children[columnIndex]; final startNode = cell.getFirstFocusableChild(); final endNode = cell.getLastFocusableChild(); if (startNode == null || endNode == null) { continue; } final plainText = getTextInSelection( Selection( start: Position(path: startNode.path), end: Position( path: endNode.path, offset: endNode.delta?.length ?? 0, ), ), ); content.add(plainText.join('\n')); cells.add(cell.deepCopy()); } final plainText = content.join('\n'); final document = Document.blank()..insert([0], cells); if (clearContent) { await clearContentAtColumnIndex( tableNode: tableNode, columnIndex: columnIndex, ); } return ClipboardServiceData( plainText: plainText, tableJson: jsonEncode(document.toJson()), ); } /// Copy the selected row to the clipboard. /// /// If the [clearContent] is true, the content of the row will be cleared after /// copying. Future copyRow({ required Node tableNode, required int rowIndex, bool clearContent = false, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return null; } if (rowIndex < 0 || rowIndex >= tableNode.rowLength) { Log.warn('copy row: index out of range: $rowIndex'); return null; } // the plain text content of the row final List content = []; // the cells of the row final List cells = []; final row = tableNode.children[rowIndex]; for (var i = 0; i < row.children.length; i++) { final cell = row.children[i]; final startNode = cell.getFirstFocusableChild(); final endNode = cell.getLastFocusableChild(); if (startNode == null || endNode == null) { continue; } final plainText = getTextInSelection( Selection( start: Position(path: startNode.path), end: Position( path: endNode.path, offset: endNode.delta?.length ?? 0, ), ), ); content.add(plainText.join('\n')); cells.add(cell.deepCopy()); } final plainText = content.join('\n'); final document = Document.blank()..insert([0], cells); if (clearContent) { await clearContentAtRowIndex( tableNode: tableNode, rowIndex: rowIndex, ); } return ClipboardServiceData( plainText: plainText, tableJson: jsonEncode(document.toJson()), ); } /// Copy the selected table to the clipboard. Future copyTable({ required Node tableNode, bool clearContent = false, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return null; } // the plain text content of the table final List content = []; // the cells of the table final List cells = []; for (var i = 0; i < tableNode.rowLength; i++) { final row = tableNode.children[i]; for (var j = 0; j < row.children.length; j++) { final cell = row.children[j]; final startNode = cell.getFirstFocusableChild(); final endNode = cell.getLastFocusableChild(); if (startNode == null || endNode == null) { continue; } final plainText = getTextInSelection( Selection( start: Position(path: startNode.path), end: Position( path: endNode.path, offset: endNode.delta?.length ?? 0, ), ), ); content.add(plainText.join('\n')); cells.add(cell.deepCopy()); } } final plainText = content.join('\n'); final document = Document.blank()..insert([0], cells); if (clearContent) { await clearAllContent(tableNode: tableNode); } return ClipboardServiceData( plainText: plainText, tableJson: jsonEncode(document.toJson()), ); } /// Paste the clipboard content to the table column. Future pasteColumn({ required Node tableNode, required int columnIndex, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } if (columnIndex < 0 || columnIndex >= tableNode.columnLength) { Log.warn('paste column: index out of range: $columnIndex'); return; } final clipboardData = await getIt().getData(); final tableJson = clipboardData.tableJson; if (tableJson == null) { return; } try { final document = Document.fromJson(jsonDecode(tableJson)); final cells = document.root.children; final transaction = this.transaction; for (var i = 0; i < tableNode.rowLength; i++) { final nodes = i < cells.length ? cells[i].children : []; final row = tableNode.children[i]; final cell = columnIndex >= row.children.length ? row.children.last : row.children[columnIndex]; if (nodes.isNotEmpty) { transaction.insertNodes( cell.path.child(0), nodes, ); transaction.deleteNodes(cell.children); } } await apply(transaction); } catch (e) { Log.error('paste column: failed to paste: $e'); } } /// Paste the clipboard content to the table row. Future pasteRow({ required Node tableNode, required int rowIndex, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } if (rowIndex < 0 || rowIndex >= tableNode.rowLength) { Log.warn('paste row: index out of range: $rowIndex'); return; } final clipboardData = await getIt().getData(); final tableJson = clipboardData.tableJson; if (tableJson == null) { return; } try { final document = Document.fromJson(jsonDecode(tableJson)); final cells = document.root.children; final transaction = this.transaction; final row = tableNode.children[rowIndex]; for (var i = 0; i < row.children.length; i++) { final nodes = i < cells.length ? cells[i].children : []; final cell = row.children[i]; if (nodes.isNotEmpty) { transaction.insertNodes( cell.path.child(0), nodes, ); transaction.deleteNodes(cell.children); } } await apply(transaction); } catch (e) { Log.error('paste row: failed to paste: $e'); } } /// Paste the clipboard content to the table. Future pasteTable({ required Node tableNode, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } final clipboardData = await getIt().getData(); final tableJson = clipboardData.tableJson; if (tableJson == null) { return; } try { final document = Document.fromJson(jsonDecode(tableJson)); final cells = document.root.children; final transaction = this.transaction; for (var i = 0; i < tableNode.rowLength; i++) { final row = tableNode.children[i]; for (var j = 0; j < row.children.length; j++) { final cell = row.children[j]; final node = i + j < cells.length ? cells[i + j] : null; if (node != null && node.children.isNotEmpty) { transaction.insertNodes( cell.path.child(0), node.children, ); transaction.deleteNodes(cell.children); } } } await apply(transaction); } catch (e) { Log.error('paste row: failed to paste: $e'); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_delete_operation.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension TableDeletionOperations on EditorState { /// Delete a row at the given index. /// /// Before: /// Given index: 0 /// Row 1: | | | | ← This row will be deleted /// Row 2: | | | | /// /// Call this function with index 0 will delete the first row of the table. /// /// After: /// Row 1: | | | | Future deleteRowInTable( Node tableNode, int rowIndex, { bool inMemoryUpdate = false, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } final rowLength = tableNode.rowLength; if (rowIndex < 0 || rowIndex >= rowLength) { Log.warn( 'delete row: index out of range: $rowIndex, row length: $rowLength', ); return; } Log.info('delete row: $rowIndex in table ${tableNode.id}'); final attributes = tableNode.mapTableAttributes( tableNode, type: TableMapOperationType.deleteRow, index: rowIndex, ); final row = tableNode.children[rowIndex]; final transaction = this.transaction; transaction.deleteNode(row); if (attributes != null) { transaction.updateNode(tableNode, attributes); } await apply( transaction, options: ApplyOptions( inMemoryUpdate: inMemoryUpdate, ), ); } /// Delete a column at the given index. /// /// Before: /// Given index: 2 /// ↓ This column will be deleted /// Row 1: | 0 | 1 | 2 | /// Row 2: | | | | /// /// Call this function with index 2 will delete the third column of the table. /// /// After: /// Row 1: | 0 | 1 | /// Row 2: | | | Future deleteColumnInTable( Node tableNode, int columnIndex, { bool inMemoryUpdate = false, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } final rowLength = tableNode.rowLength; final columnLength = tableNode.columnLength; if (columnIndex < 0 || columnIndex >= columnLength) { Log.warn( 'delete column: index out of range: $columnIndex, column length: $columnLength', ); return; } Log.info('delete column: $columnIndex in table ${tableNode.id}'); final attributes = tableNode.mapTableAttributes( tableNode, type: TableMapOperationType.deleteColumn, index: columnIndex, ); final transaction = this.transaction; for (var i = 0; i < rowLength; i++) { final row = tableNode.children[i]; transaction.deleteNode(row.children[columnIndex]); } if (attributes != null) { transaction.updateNode(tableNode, attributes); } await apply( transaction, options: ApplyOptions( inMemoryUpdate: inMemoryUpdate, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_duplicate_operation.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension TableDuplicationOperations on EditorState { /// Duplicate a row at the given index. /// /// Before: /// | 0 | 1 | 2 | /// | 3 | 4 | 5 | ← This row will be duplicated /// /// Call this function with index 1 will duplicate the second row of the table. /// /// After: /// | 0 | 1 | 2 | /// | 3 | 4 | 5 | /// | 3 | 4 | 5 | ← New row Future duplicateRowInTable(Node node, int index) async { assert(node.type == SimpleTableBlockKeys.type); if (node.type != SimpleTableBlockKeys.type) { return; } final columnLength = node.columnLength; final rowLength = node.rowLength; if (index < 0 || index >= rowLength) { Log.warn( 'duplicate row: index out of range: $index, row length: $rowLength', ); return; } Log.info( 'duplicate row in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength', ); final attributes = node.mapTableAttributes( node, type: TableMapOperationType.duplicateRow, index: index, ); final newRow = node.children[index].deepCopy(); final transaction = this.transaction; final path = index >= columnLength ? node.children.last.path.next : node.children[index].path; transaction.insertNode(path, newRow); if (attributes != null) { transaction.updateNode(node, attributes); } await apply(transaction); } Future duplicateColumnInTable(Node node, int index) async { assert(node.type == SimpleTableBlockKeys.type); if (node.type != SimpleTableBlockKeys.type) { return; } final columnLength = node.columnLength; final rowLength = node.rowLength; if (index < 0 || index >= columnLength) { Log.warn( 'duplicate column: index out of range: $index, column length: $columnLength', ); return; } Log.info( 'duplicate column in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength', ); final attributes = node.mapTableAttributes( node, type: TableMapOperationType.duplicateColumn, index: index, ); final transaction = this.transaction; for (var i = 0; i < rowLength; i++) { final row = node.children[i]; final path = index >= rowLength ? row.children.last.path.next : row.children[index].path; final newCell = row.children[index].deepCopy(); transaction.insertNode( path, newCell, ); } if (attributes != null) { transaction.updateNode(node, attributes); } await apply(transaction); } /// Duplicate the table. /// /// This function will duplicate the table and insert it after the original table. Future duplicateTable({ required Node tableNode, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } final transaction = this.transaction; final newTable = tableNode.deepCopy(); transaction.insertNode(tableNode.path.next, newTable); await apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_header_operation.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension TableHeaderOperation on EditorState { /// Toggle the enable header column of the table. Future toggleEnableHeaderColumn({ required Node tableNode, required bool enable, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } Log.info( 'toggle enable header column: $enable in table ${tableNode.id}', ); final columnColors = tableNode.columnColors; final transaction = this.transaction; transaction.updateNode(tableNode, { SimpleTableBlockKeys.enableHeaderColumn: enable, // remove the previous background color if the header column is enable again if (enable) SimpleTableBlockKeys.columnColors: columnColors ..removeWhere((key, _) => key == '0'), }); await apply(transaction); } /// Toggle the enable header row of the table. Future toggleEnableHeaderRow({ required Node tableNode, required bool enable, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } Log.info('toggle enable header row: $enable in table ${tableNode.id}'); final rowColors = tableNode.rowColors; final transaction = this.transaction; transaction.updateNode(tableNode, { SimpleTableBlockKeys.enableHeaderRow: enable, // remove the previous background color if the header row is enable again if (enable) SimpleTableBlockKeys.rowColors: rowColors ..removeWhere((key, _) => key == '0'), }); await apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_insert_operation.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension TableInsertionOperations on EditorState { /// Add a row at the end of the table. /// /// Before: /// Row 1: | | | | /// Row 2: | | | | /// /// Call this function will add a row at the end of the table. /// /// After: /// Row 1: | | | | /// Row 2: | | | | /// Row 3: | | | | ← New row /// Future addRowInTable(Node tableNode) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { Log.warn('node is not a table node: ${tableNode.type}'); return; } await insertRowInTable(tableNode, tableNode.rowLength); } /// Add a column at the end of the table. /// /// Before: /// Row 1: | | | | /// Row 2: | | | | /// /// Call this function will add a column at the end of the table. /// /// After: /// ↓ New column /// Row 1: | | | | | /// Row 2: | | | | | Future addColumnInTable(Node node) async { assert(node.type == SimpleTableBlockKeys.type); if (node.type != SimpleTableBlockKeys.type) { Log.warn('node is not a table node: ${node.type}'); return; } await insertColumnInTable(node, node.columnLength); } /// Add a column and a row at the end of the table. /// /// Before: /// Row 1: | | | | /// Row 2: | | | | /// /// Call this function will add a column and a row at the end of the table. /// /// After: /// ↓ New column /// Row 1: | | | | | /// Row 2: | | | | | /// Row 3: | | | | | ← New row Future addColumnAndRowInTable(Node node) async { assert(node.type == SimpleTableBlockKeys.type); if (node.type != SimpleTableBlockKeys.type) { return; } await addColumnInTable(node); await addRowInTable(node); } /// Add a column at the given index. /// /// Before: /// Given index: 1 /// Row 1: | 0 | 1 | /// Row 2: | | | /// /// Call this function with index 1 will add a column at the second position of the table. /// /// After: ↓ New column /// Row 1: | 0 | | 1 | /// Row 2: | | | | Future insertColumnInTable( Node node, int index, { bool inMemoryUpdate = false, }) async { assert(node.type == SimpleTableBlockKeys.type); if (node.type != SimpleTableBlockKeys.type) { Log.warn('node is not a table node: ${node.type}'); return; } final columnLength = node.rowLength; final rowLength = node.columnLength; Log.info( 'add column in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength', ); if (index < 0) { Log.warn( 'insert column: index out of range: $index, column length: $columnLength', ); return; } final attributes = node.mapTableAttributes( node, type: TableMapOperationType.insertColumn, index: index, ); final transaction = this.transaction; for (var i = 0; i < columnLength; i++) { final row = node.children[i]; // if the index is greater than the row length, we add the new column at the end of the row. final path = index >= rowLength ? row.children.last.path.next : row.children[index].path; transaction.insertNode( path, simpleTableCellBlockNode(), ); } if (attributes != null) { transaction.updateNode(node, attributes); } await apply( transaction, options: ApplyOptions( inMemoryUpdate: inMemoryUpdate, ), ); } /// Add a row at the given index. /// /// Before: /// Given index: 1 /// Row 1: | | | /// Row 2: | | | /// /// Call this function with index 1 will add a row at the second position of the table. /// /// After: /// Row 1: | | | /// Row 2: | | | /// Row 3: | | | ← New row Future insertRowInTable( Node node, int index, { bool inMemoryUpdate = false, }) async { assert(node.type == SimpleTableBlockKeys.type); if (node.type != SimpleTableBlockKeys.type) { return; } if (index < 0) { Log.warn( 'insert row: index out of range: $index', ); return; } final columnLength = node.rowLength; final rowLength = node.columnLength; Log.info( 'insert row in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength', ); final newRow = simpleTableRowBlockNode( children: [ for (var i = 0; i < rowLength; i++) simpleTableCellBlockNode(), ], ); final attributes = node.mapTableAttributes( node, type: TableMapOperationType.insertRow, index: index, ); final transaction = this.transaction; final path = index >= columnLength ? node.children.last.path.next : node.children[index].path; transaction.insertNode(path, newRow); if (attributes != null) { transaction.updateNode(node, attributes); } await apply( transaction, options: ApplyOptions( inMemoryUpdate: inMemoryUpdate, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; enum TableMapOperationType { insertRow, deleteRow, insertColumn, deleteColumn, duplicateRow, duplicateColumn, reorderColumn, reorderRow, } extension TableMapOperation on Node { Attributes? mapTableAttributes( Node node, { required TableMapOperationType type, required int index, // Only used for reorder column operation int? toIndex, }) { assert(this.type == SimpleTableBlockKeys.type); if (this.type != SimpleTableBlockKeys.type) { return null; } Attributes? attributes; switch (type) { case TableMapOperationType.insertRow: attributes = _mapRowInsertionAttributes(index); case TableMapOperationType.insertColumn: attributes = _mapColumnInsertionAttributes(index); case TableMapOperationType.duplicateRow: attributes = _mapRowDuplicationAttributes(index); case TableMapOperationType.duplicateColumn: attributes = _mapColumnDuplicationAttributes(index); case TableMapOperationType.deleteRow: attributes = _mapRowDeletionAttributes(index); case TableMapOperationType.deleteColumn: attributes = _mapColumnDeletionAttributes(index); case TableMapOperationType.reorderColumn: if (toIndex != null) { attributes = _mapColumnReorderingAttributes(index, toIndex); } case TableMapOperationType.reorderRow: if (toIndex != null) { attributes = _mapRowReorderingAttributes(index, toIndex); } } // clear the attributes that are null attributes?.removeWhere( (key, value) => value == null, ); return attributes; } /// Map the attributes of a row insertion operation. /// /// When inserting a row, the attributes of the table after the index should be updated /// For example: /// Before: /// | 0 | 1 | 2 | /// | 3 | 4 | 5 | ← insert a new row here /// /// The original attributes of the table: /// { /// "rowColors": { /// 0: "#FF0000", /// 1: "#00FF00", /// } /// } /// /// Insert a row at index 1: /// | 0 | 1 | 2 | /// | | | | ← new row /// | 3 | 4 | 5 | /// /// The new attributes of the table: /// { /// "rowColors": { /// 0: "#FF0000", /// 2: "#00FF00", ← The attributes of the original second row /// } /// } Attributes? _mapRowInsertionAttributes(int index) { final attributes = this.attributes; try { final rowColors = _remapSource( this.rowColors, index, comparator: (iKey, index) => iKey >= index, ); final rowAligns = _remapSource( this.rowAligns, index, comparator: (iKey, index) => iKey >= index, ); final rowBoldAttributes = _remapSource( this.rowBoldAttributes, index, comparator: (iKey, index) => iKey >= index, ); final rowTextColors = _remapSource( this.rowTextColors, index, comparator: (iKey, index) => iKey >= index, ); return attributes .mergeValues( SimpleTableBlockKeys.rowColors, rowColors, ) .mergeValues( SimpleTableBlockKeys.rowAligns, rowAligns, ) .mergeValues( SimpleTableBlockKeys.rowBoldAttributes, rowBoldAttributes, ) .mergeValues( SimpleTableBlockKeys.rowTextColors, rowTextColors, ); } catch (e) { Log.warn('Failed to map row insertion attributes: $e'); return attributes; } } /// Map the attributes of a column insertion operation. /// /// When inserting a column, the attributes of the table after the index should be updated /// For example: /// Before: /// | 0 | 1 | /// | 2 | 3 | /// /// The original attributes of the table: /// { /// "columnColors": { /// 0: "#FF0000", /// 1: "#00FF00", /// } /// } /// /// Insert a column at index 1: /// | 0 | | 1 | /// | 2 | | 3 | /// /// The new attributes of the table: /// { /// "columnColors": { /// 0: "#FF0000", /// 2: "#00FF00", ← The attributes of the original second column /// } /// } Attributes? _mapColumnInsertionAttributes(int index) { final attributes = this.attributes; try { final columnColors = _remapSource( this.columnColors, index, comparator: (iKey, index) => iKey >= index, ); final columnAligns = _remapSource( this.columnAligns, index, comparator: (iKey, index) => iKey >= index, ); final columnWidths = _remapSource( this.columnWidths, index, comparator: (iKey, index) => iKey >= index, ); final columnBoldAttributes = _remapSource( this.columnBoldAttributes, index, comparator: (iKey, index) => iKey >= index, ); final columnTextColors = _remapSource( this.columnTextColors, index, comparator: (iKey, index) => iKey >= index, ); final bool distributeColumnWidthsEvenly = attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly] ?? false; if (distributeColumnWidthsEvenly) { // if the distribute column widths evenly flag is true, // we should distribute the column widths evenly columnWidths[index.toString()] = columnWidths.values.firstOrNull; } return attributes .mergeValues( SimpleTableBlockKeys.columnColors, columnColors, ) .mergeValues( SimpleTableBlockKeys.columnAligns, columnAligns, ) .mergeValues( SimpleTableBlockKeys.columnWidths, columnWidths, ) .mergeValues( SimpleTableBlockKeys.columnBoldAttributes, columnBoldAttributes, ) .mergeValues( SimpleTableBlockKeys.columnTextColors, columnTextColors, ); } catch (e) { Log.warn('Failed to map row insertion attributes: $e'); return attributes; } } /// Map the attributes of a row duplication operation. /// /// When duplicating a row, the attributes of the table after the index should be updated /// For example: /// Before: /// | 0 | 1 | 2 | /// | 3 | 4 | 5 | /// /// The original attributes of the table: /// { /// "rowColors": { /// 0: "#FF0000", /// 1: "#00FF00", /// } /// } /// /// Duplicate the row at index 1: /// | 0 | 1 | 2 | /// | 3 | 4 | 5 | /// | 3 | 4 | 5 | ← duplicated row /// /// The new attributes of the table: /// { /// "rowColors": { /// 0: "#FF0000", /// 1: "#00FF00", /// 2: "#00FF00", ← The attributes of the original second row /// } /// } Attributes? _mapRowDuplicationAttributes(int index) { final attributes = this.attributes; try { final (rowColors, duplicatedRowColor) = _findDuplicatedEntryAndRemap( this.rowColors, index, ); final (rowAligns, duplicatedRowAlign) = _findDuplicatedEntryAndRemap( this.rowAligns, index, ); final (rowBoldAttributes, duplicatedRowBoldAttribute) = _findDuplicatedEntryAndRemap( this.rowBoldAttributes, index, ); final (rowTextColors, duplicatedRowTextColor) = _findDuplicatedEntryAndRemap( this.rowTextColors, index, ); return attributes .mergeValues( SimpleTableBlockKeys.rowColors, rowColors, duplicatedEntry: duplicatedRowColor, ) .mergeValues( SimpleTableBlockKeys.rowAligns, rowAligns, duplicatedEntry: duplicatedRowAlign, ) .mergeValues( SimpleTableBlockKeys.rowBoldAttributes, rowBoldAttributes, duplicatedEntry: duplicatedRowBoldAttribute, ) .mergeValues( SimpleTableBlockKeys.rowTextColors, rowTextColors, duplicatedEntry: duplicatedRowTextColor, ); } catch (e) { Log.warn('Failed to map row insertion attributes: $e'); return attributes; } } /// Map the attributes of a column duplication operation. /// /// When duplicating a column, the attributes of the table after the index should be updated /// For example: /// Before: /// | 0 | 1 | /// | 2 | 3 | /// /// The original attributes of the table: /// { /// "columnColors": { /// 0: "#FF0000", /// 1: "#00FF00", /// } /// } /// /// Duplicate the column at index 1: /// | 0 | 1 | 1 | ← duplicated column /// | 2 | 3 | 2 | ← duplicated column /// /// The new attributes of the table: /// { /// "columnColors": { /// 0: "#FF0000", /// 1: "#00FF00", /// 2: "#00FF00", ← The attributes of the original second column /// } /// } Attributes? _mapColumnDuplicationAttributes(int index) { final attributes = this.attributes; try { final (columnColors, duplicatedColumnColor) = _findDuplicatedEntryAndRemap( this.columnColors, index, ); final (columnAligns, duplicatedColumnAlign) = _findDuplicatedEntryAndRemap( this.columnAligns, index, ); final (columnWidths, duplicatedColumnWidth) = _findDuplicatedEntryAndRemap( this.columnWidths, index, ); final (columnBoldAttributes, duplicatedColumnBoldAttribute) = _findDuplicatedEntryAndRemap( this.columnBoldAttributes, index, ); final (columnTextColors, duplicatedColumnTextColor) = _findDuplicatedEntryAndRemap( this.columnTextColors, index, ); return attributes .mergeValues( SimpleTableBlockKeys.columnColors, columnColors, duplicatedEntry: duplicatedColumnColor, ) .mergeValues( SimpleTableBlockKeys.columnAligns, columnAligns, duplicatedEntry: duplicatedColumnAlign, ) .mergeValues( SimpleTableBlockKeys.columnWidths, columnWidths, duplicatedEntry: duplicatedColumnWidth, ) .mergeValues( SimpleTableBlockKeys.columnBoldAttributes, columnBoldAttributes, duplicatedEntry: duplicatedColumnBoldAttribute, ) .mergeValues( SimpleTableBlockKeys.columnTextColors, columnTextColors, duplicatedEntry: duplicatedColumnTextColor, ); } catch (e) { Log.warn('Failed to map column duplication attributes: $e'); return attributes; } } /// Map the attributes of a column deletion operation. /// /// When deleting a column, the attributes of the table after the index should be updated /// /// For example: /// Before: /// | 0 | 1 | 2 | /// | 3 | 4 | 5 | /// /// The original attributes of the table: /// { /// "columnColors": { /// 0: "#FF0000", /// 2: "#00FF00", /// } /// } /// /// Delete the column at index 1: /// | 0 | 2 | /// | 3 | 5 | /// /// The new attributes of the table: /// { /// "columnColors": { /// 0: "#FF0000", /// 1: "#00FF00", ← The attributes of the original second column /// } /// } Attributes? _mapColumnDeletionAttributes(int index) { final attributes = this.attributes; try { final columnColors = _remapSource( this.columnColors, index, increment: false, comparator: (iKey, index) => iKey > index, filterIndex: index, ); final columnAligns = _remapSource( this.columnAligns, index, increment: false, comparator: (iKey, index) => iKey > index, filterIndex: index, ); final columnWidths = _remapSource( this.columnWidths, index, increment: false, comparator: (iKey, index) => iKey > index, filterIndex: index, ); final columnBoldAttributes = _remapSource( this.columnBoldAttributes, index, increment: false, comparator: (iKey, index) => iKey > index, filterIndex: index, ); final columnTextColors = _remapSource( this.columnTextColors, index, increment: false, comparator: (iKey, index) => iKey > index, filterIndex: index, ); return attributes .mergeValues( SimpleTableBlockKeys.columnColors, columnColors, ) .mergeValues( SimpleTableBlockKeys.columnAligns, columnAligns, ) .mergeValues( SimpleTableBlockKeys.columnWidths, columnWidths, ) .mergeValues( SimpleTableBlockKeys.columnBoldAttributes, columnBoldAttributes, ) .mergeValues( SimpleTableBlockKeys.columnTextColors, columnTextColors, ); } catch (e) { Log.warn('Failed to map column deletion attributes: $e'); return attributes; } } /// Map the attributes of a row deletion operation. /// /// When deleting a row, the attributes of the table after the index should be updated /// /// For example: /// Before: /// | 0 | 1 | 2 | ← delete this row /// | 3 | 4 | 5 | /// /// The original attributes of the table: /// { /// "rowColors": { /// 0: "#FF0000", /// 1: "#00FF00", /// } /// } /// /// Delete the row at index 0: /// | 3 | 4 | 5 | /// /// The new attributes of the table: /// { /// "rowColors": { /// 0: "#00FF00", /// } /// } Attributes? _mapRowDeletionAttributes(int index) { final attributes = this.attributes; try { final rowColors = _remapSource( this.rowColors, index, increment: false, comparator: (iKey, index) => iKey > index, filterIndex: index, ); final rowAligns = _remapSource( this.rowAligns, index, increment: false, comparator: (iKey, index) => iKey > index, filterIndex: index, ); final rowBoldAttributes = _remapSource( this.rowBoldAttributes, index, increment: false, comparator: (iKey, index) => iKey > index, filterIndex: index, ); final rowTextColors = _remapSource( this.rowTextColors, index, increment: false, comparator: (iKey, index) => iKey > index, filterIndex: index, ); return attributes .mergeValues( SimpleTableBlockKeys.rowColors, rowColors, ) .mergeValues( SimpleTableBlockKeys.rowAligns, rowAligns, ) .mergeValues( SimpleTableBlockKeys.rowBoldAttributes, rowBoldAttributes, ) .mergeValues( SimpleTableBlockKeys.rowTextColors, rowTextColors, ); } catch (e) { Log.warn('Failed to map row deletion attributes: $e'); return attributes; } } /// Map the attributes of a column reordering operation. /// /// /// Examples: /// Case 1: /// /// When reordering a column, if the from index is greater than the to index, /// the attributes of the table before the from index should be updated. /// /// Before: /// ↓ reorder this column from index 1 to index 0 /// | 0 | 1 | 2 | /// | 3 | 4 | 5 | /// /// The original attributes of the table: /// { /// "rowColors": { /// 0: "#FF0000", /// 1: "#00FF00", /// 2: "#0000FF", /// } /// } /// /// After reordering: /// | 1 | 0 | 2 | /// | 4 | 3 | 5 | /// /// The new attributes of the table: /// { /// "rowColors": { /// 0: "#00FF00", ← The attributes of the original second column /// 1: "#FF0000", ← The attributes of the original first column /// 2: "#0000FF", /// } /// } /// /// Case 2: /// /// When reordering a column, if the from index is less than the to index, /// the attributes of the table after the from index should be updated. /// /// Before: /// ↓ reorder this column from index 1 to index 2 /// | 0 | 1 | 2 | /// | 3 | 4 | 5 | /// /// The original attributes of the table: /// { /// "columnColors": { /// 0: "#FF0000", /// 1: "#00FF00", /// 2: "#0000FF", /// } /// } /// /// After reordering: /// | 0 | 2 | 1 | /// | 3 | 5 | 4 | /// /// The new attributes of the table: /// { /// "columnColors": { /// 0: "#FF0000", /// 1: "#0000FF", ← The attributes of the original third column /// 2: "#00FF00", ← The attributes of the original second column /// } /// } Attributes? _mapColumnReorderingAttributes(int fromIndex, int toIndex) { final attributes = this.attributes; try { final duplicatedColumnColor = this.columnColors[fromIndex.toString()]; final duplicatedColumnAlign = this.columnAligns[fromIndex.toString()]; final duplicatedColumnWidth = this.columnWidths[fromIndex.toString()]; final duplicatedColumnBoldAttribute = this.columnBoldAttributes[fromIndex.toString()]; final duplicatedColumnTextColor = this.columnTextColors[fromIndex.toString()]; /// Case 1: fromIndex > toIndex /// Before: /// Row 0: | 0 | 1 | 2 | /// Row 1: | 3 | 4 | 5 | /// Row 2: | 6 | 7 | 8 | /// /// columnColors = { /// "0": "#FF0000", /// "1": "#00FF00", /// "2": "#0000FF" ← Move this column (index 2) /// } /// /// Move column 2 to index 0: /// Row 0: | 2 | 0 | 1 | /// Row 1: | 5 | 3 | 4 | /// Row 2: | 8 | 6 | 7 | /// /// columnColors = { /// "0": "#0000FF", ← Moved here /// "1": "#FF0000", /// "2": "#00FF00" /// } /// /// Case 2: fromIndex < toIndex /// Before: /// Row 0: | 0 | 1 | 2 | /// Row 1: | 3 | 4 | 5 | /// Row 2: | 6 | 7 | 8 | /// /// columnColors = { /// "0": "#FF0000" ← Move this column (index 0) /// "1": "#00FF00", /// "2": "#0000FF" /// } /// /// Move column 0 to index 2: /// Row 0: | 1 | 2 | 0 | /// Row 1: | 4 | 5 | 3 | /// Row 2: | 7 | 8 | 6 | /// /// columnColors = { /// "0": "#00FF00", /// "1": "#0000FF", /// "2": "#FF0000" ← Moved here /// } final columnColors = _remapSource( this.columnColors, fromIndex, increment: fromIndex > toIndex, comparator: (iKey, index) { if (fromIndex > toIndex) { return iKey < fromIndex && iKey >= toIndex; } else { return iKey > fromIndex && iKey <= toIndex; } }, filterIndex: fromIndex, ); final columnAligns = _remapSource( this.columnAligns, fromIndex, increment: fromIndex > toIndex, comparator: (iKey, index) { if (fromIndex > toIndex) { return iKey < fromIndex && iKey >= toIndex; } else { return iKey > fromIndex && iKey <= toIndex; } }, filterIndex: fromIndex, ); final columnWidths = _remapSource( this.columnWidths, fromIndex, increment: fromIndex > toIndex, comparator: (iKey, index) { if (fromIndex > toIndex) { return iKey < fromIndex && iKey >= toIndex; } else { return iKey > fromIndex && iKey <= toIndex; } }, filterIndex: fromIndex, ); final columnBoldAttributes = _remapSource( this.columnBoldAttributes, fromIndex, increment: fromIndex > toIndex, comparator: (iKey, index) { if (fromIndex > toIndex) { return iKey < fromIndex && iKey >= toIndex; } else { return iKey > fromIndex && iKey <= toIndex; } }, filterIndex: fromIndex, ); final columnTextColors = _remapSource( this.columnTextColors, fromIndex, increment: fromIndex > toIndex, comparator: (iKey, index) { if (fromIndex > toIndex) { return iKey < fromIndex && iKey >= toIndex; } else { return iKey > fromIndex && iKey <= toIndex; } }, filterIndex: fromIndex, ); return attributes .mergeValues( SimpleTableBlockKeys.columnColors, columnColors, duplicatedEntry: duplicatedColumnColor != null ? MapEntry( toIndex.toString(), duplicatedColumnColor, ) : null, removeNullValue: true, ) .mergeValues( SimpleTableBlockKeys.columnAligns, columnAligns, duplicatedEntry: duplicatedColumnAlign != null ? MapEntry( toIndex.toString(), duplicatedColumnAlign, ) : null, removeNullValue: true, ) .mergeValues( SimpleTableBlockKeys.columnWidths, columnWidths, duplicatedEntry: duplicatedColumnWidth != null ? MapEntry( toIndex.toString(), duplicatedColumnWidth, ) : null, removeNullValue: true, ) .mergeValues( SimpleTableBlockKeys.columnBoldAttributes, columnBoldAttributes, duplicatedEntry: duplicatedColumnBoldAttribute != null ? MapEntry( toIndex.toString(), duplicatedColumnBoldAttribute, ) : null, removeNullValue: true, ) .mergeValues( SimpleTableBlockKeys.columnTextColors, columnTextColors, duplicatedEntry: duplicatedColumnTextColor != null ? MapEntry( toIndex.toString(), duplicatedColumnTextColor, ) : null, removeNullValue: true, ); } catch (e) { Log.warn('Failed to map column deletion attributes: $e'); return attributes; } } /// Map the attributes of a row reordering operation. /// /// See [_mapColumnReorderingAttributes] for more details. Attributes? _mapRowReorderingAttributes(int fromIndex, int toIndex) { final attributes = this.attributes; try { final duplicatedRowColor = this.rowColors[fromIndex.toString()]; final duplicatedRowAlign = this.rowAligns[fromIndex.toString()]; final duplicatedRowBoldAttribute = this.rowBoldAttributes[fromIndex.toString()]; final duplicatedRowTextColor = this.rowTextColors[fromIndex.toString()]; /// Example: /// Case 1: fromIndex > toIndex /// Before: /// Row 0: | 0 | 1 | 2 | /// Row 1: | 3 | 4 | 5 | ← Move this row (index 1) /// Row 2: | 6 | 7 | 8 | /// /// rowColors = { /// "0": "#FF0000", /// "1": "#00FF00", ← This will be moved /// "2": "#0000FF" /// } /// /// Move row 1 to index 0: /// Row 0: | 3 | 4 | 5 | ← Moved here /// Row 1: | 0 | 1 | 2 | /// Row 2: | 6 | 7 | 8 | /// /// rowColors = { /// "0": "#00FF00", ← Moved here /// "1": "#FF0000", /// "2": "#0000FF" /// } /// /// Case 2: fromIndex < toIndex /// Before: /// Row 0: | 0 | 1 | 2 | /// Row 1: | 3 | 4 | 5 | ← Move this row (index 1) /// Row 2: | 6 | 7 | 8 | /// /// rowColors = { /// "0": "#FF0000", /// "1": "#00FF00", ← This will be moved /// "2": "#0000FF" /// } /// /// Move row 1 to index 2: /// Row 0: | 0 | 1 | 2 | /// Row 1: | 3 | 4 | 5 | /// Row 2: | 6 | 7 | 8 | ← Moved here /// /// rowColors = { /// "0": "#FF0000", /// "1": "#0000FF", /// "2": "#00FF00" ← Moved here /// } final rowColors = _remapSource( this.rowColors, fromIndex, increment: fromIndex > toIndex, comparator: (iKey, index) { if (fromIndex > toIndex) { return iKey < fromIndex && iKey >= toIndex; } else { return iKey > fromIndex && iKey <= toIndex; } }, filterIndex: fromIndex, ); final rowAligns = _remapSource( this.rowAligns, fromIndex, increment: fromIndex > toIndex, comparator: (iKey, index) { if (fromIndex > toIndex) { return iKey < fromIndex && iKey >= toIndex; } else { return iKey > fromIndex && iKey <= toIndex; } }, filterIndex: fromIndex, ); final rowBoldAttributes = _remapSource( this.rowBoldAttributes, fromIndex, increment: fromIndex > toIndex, comparator: (iKey, index) { if (fromIndex > toIndex) { return iKey < fromIndex && iKey >= toIndex; } else { return iKey > fromIndex && iKey <= toIndex; } }, filterIndex: fromIndex, ); final rowTextColors = _remapSource( this.rowTextColors, fromIndex, increment: fromIndex > toIndex, comparator: (iKey, index) { if (fromIndex > toIndex) { return iKey < fromIndex && iKey >= toIndex; } else { return iKey > fromIndex && iKey <= toIndex; } }, filterIndex: fromIndex, ); return attributes .mergeValues( SimpleTableBlockKeys.rowColors, rowColors, duplicatedEntry: duplicatedRowColor != null ? MapEntry( toIndex.toString(), duplicatedRowColor, ) : null, removeNullValue: true, ) .mergeValues( SimpleTableBlockKeys.rowAligns, rowAligns, duplicatedEntry: duplicatedRowAlign != null ? MapEntry( toIndex.toString(), duplicatedRowAlign, ) : null, removeNullValue: true, ) .mergeValues( SimpleTableBlockKeys.rowBoldAttributes, rowBoldAttributes, duplicatedEntry: duplicatedRowBoldAttribute != null ? MapEntry( toIndex.toString(), duplicatedRowBoldAttribute, ) : null, removeNullValue: true, ) .mergeValues( SimpleTableBlockKeys.rowTextColors, rowTextColors, duplicatedEntry: duplicatedRowTextColor != null ? MapEntry( toIndex.toString(), duplicatedRowTextColor, ) : null, removeNullValue: true, ); } catch (e) { Log.warn('Failed to map row reordering attributes: $e'); return attributes; } } } /// Find the duplicated entry and remap the source. /// /// All the entries after the index will be remapped to the new index. (Map newSource, MapEntry? duplicatedEntry) _findDuplicatedEntryAndRemap( Map source, int index, { bool increment = true, }) { MapEntry? duplicatedEntry; final newSource = source.map((key, value) { final iKey = int.parse(key); if (iKey == index) { duplicatedEntry = MapEntry(key, value); } if (iKey >= index) { return MapEntry((iKey + (increment ? 1 : -1)).toString(), value); } return MapEntry(key, value); }); return (newSource, duplicatedEntry); } /// Remap the source to the new index. /// /// All the entries after the index will be remapped to the new index. Map _remapSource( Map source, int index, { bool increment = true, required bool Function(int iKey, int index) comparator, int? filterIndex, }) { var newSource = {...source}; if (filterIndex != null) { newSource.remove(filterIndex.toString()); } newSource = newSource.map((key, value) { final iKey = int.parse(key); if (comparator(iKey, index)) { return MapEntry((iKey + (increment ? 1 : -1)).toString(), value); } return MapEntry(key, value); }); return newSource; } extension TableMapOperationAttributes on Attributes { Attributes mergeValues( String key, Map newSource, { MapEntry? duplicatedEntry, bool removeNullValue = false, }) { final result = {...this}; if (duplicatedEntry != null) { newSource[duplicatedEntry.key] = duplicatedEntry.value; } if (removeNullValue) { // remove the null value newSource.removeWhere((key, value) => value == null); } result[key] = newSource; return result; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; typedef TableCellPosition = (int, int); enum TableAlign { left, center, right; static TableAlign fromString(String align) { return TableAlign.values.firstWhere( (e) => e.key.toLowerCase() == align.toLowerCase(), orElse: () => TableAlign.left, ); } String get name => switch (this) { TableAlign.left => 'Left', TableAlign.center => 'Center', TableAlign.right => 'Right', }; // The key used in the attributes of the table node. // // Example: // // attributes[SimpleTableBlockKeys.columnAligns] = {0: 'left', 1: 'center', 2: 'right'} String get key => switch (this) { TableAlign.left => 'left', TableAlign.center => 'center', TableAlign.right => 'right', }; FlowySvgData get leftIconSvg => switch (this) { TableAlign.left => FlowySvgs.table_align_left_s, TableAlign.center => FlowySvgs.table_align_center_s, TableAlign.right => FlowySvgs.table_align_right_s, }; Alignment get alignment => switch (this) { TableAlign.left => Alignment.topLeft, TableAlign.center => Alignment.topCenter, TableAlign.right => Alignment.topRight, }; TextAlign get textAlign => switch (this) { TableAlign.left => TextAlign.left, TableAlign.center => TextAlign.center, TableAlign.right => TextAlign.right, }; } extension TableNodeExtension on Node { /// The number of rows in the table. /// /// The acceptable node is a table node, table row node or table cell node. /// /// Example: /// /// Row 1: | | | | /// Row 2: | | | | /// /// The row length is 2. int get rowLength { final parentTableNode = this.parentTableNode; if (parentTableNode == null || parentTableNode.type != SimpleTableBlockKeys.type) { return -1; } return parentTableNode.children.length; } /// The number of rows in the table. /// /// The acceptable node is a table node, table row node or table cell node. /// /// Example: /// /// Row 1: | | | | /// Row 2: | | | | /// /// The column length is 3. int get columnLength { final parentTableNode = this.parentTableNode; if (parentTableNode == null || parentTableNode.type != SimpleTableBlockKeys.type) { return -1; } return parentTableNode.children.firstOrNull?.children.length ?? 0; } TableCellPosition get cellPosition { assert(type == SimpleTableCellBlockKeys.type); return (rowIndex, columnIndex); } int get rowIndex { if (type == SimpleTableCellBlockKeys.type) { if (path.parent.isEmpty) { return -1; } return path.parent.last; } else if (type == SimpleTableRowBlockKeys.type) { return path.last; } return -1; } int get columnIndex { assert(type == SimpleTableCellBlockKeys.type); if (path.isEmpty) { return -1; } return path.last; } bool get isHeaderColumnEnabled { try { return parentTableNode ?.attributes[SimpleTableBlockKeys.enableHeaderColumn] ?? false; } catch (e) { Log.warn('get is header column enabled: $e'); return false; } } bool get isHeaderRowEnabled { try { return parentTableNode ?.attributes[SimpleTableBlockKeys.enableHeaderRow] ?? false; } catch (e) { Log.warn('get is header row enabled: $e'); return false; } } TableAlign get rowAlign { final parentTableNode = this.parentTableNode; if (parentTableNode == null) { return TableAlign.left; } try { final rowAligns = parentTableNode.attributes[SimpleTableBlockKeys.rowAligns]; final align = rowAligns?[rowIndex.toString()]; return TableAlign.values.firstWhere( (e) => e.key == align, orElse: () => TableAlign.left, ); } catch (e) { Log.warn('get row align: $e'); return TableAlign.left; } } TableAlign get columnAlign { final parentTableNode = this.parentTableNode; if (parentTableNode == null) { return TableAlign.left; } try { final columnAligns = parentTableNode.attributes[SimpleTableBlockKeys.columnAligns]; final align = columnAligns?[columnIndex.toString()]; return TableAlign.values.firstWhere( (e) => e.key == align, orElse: () => TableAlign.left, ); } catch (e) { Log.warn('get column align: $e'); return TableAlign.left; } } Node? get parentTableNode { Node? tableNode; if (type == SimpleTableBlockKeys.type) { tableNode = this; } else if (type == SimpleTableRowBlockKeys.type) { tableNode = parent; } else if (type == SimpleTableCellBlockKeys.type) { tableNode = parent?.parent; } else { return parent?.parentTableNode; } if (tableNode == null || tableNode.type != SimpleTableBlockKeys.type) { return null; } return tableNode; } Node? get parentTableCellNode { Node? tableCellNode; if (type == SimpleTableCellBlockKeys.type) { tableCellNode = this; } else { return parent?.parentTableCellNode; } return tableCellNode; } /// Whether the current node is in a table. bool get isInTable { return parentTableNode != null; } double get columnWidth { final parentTableNode = this.parentTableNode; if (parentTableNode == null) { return SimpleTableConstants.defaultColumnWidth; } try { final columnWidths = parentTableNode.attributes[SimpleTableBlockKeys.columnWidths]; final width = columnWidths?[columnIndex.toString()] as Object?; if (width == null) { return SimpleTableConstants.defaultColumnWidth; } return width.toDouble( defaultValue: SimpleTableConstants.defaultColumnWidth, ); } catch (e) { Log.warn('get column width: $e'); return SimpleTableConstants.defaultColumnWidth; } } /// Build the row color. /// /// Default is null. Color? buildRowColor(BuildContext context) { try { final rawRowColors = parentTableNode?.attributes[SimpleTableBlockKeys.rowColors]; if (rawRowColors == null) { return null; } final color = rawRowColors[rowIndex.toString()]; if (color == null) { return null; } return buildEditorCustomizedColor(context, this, color); } catch (e) { Log.warn('get row color: $e'); return null; } } /// Build the column color. /// /// Default is null. Color? buildColumnColor(BuildContext context) { try { final columnColors = parentTableNode?.attributes[SimpleTableBlockKeys.columnColors]; if (columnColors == null) { return null; } final color = columnColors[columnIndex.toString()]; if (color == null) { return null; } return buildEditorCustomizedColor(context, this, color); } catch (e) { Log.warn('get column color: $e'); return null; } } /// Whether the current node is in the header column. /// /// Default is false. bool get isInHeaderColumn { final parentTableNode = parent?.parentTableNode; if (parentTableNode == null || parentTableNode.type != SimpleTableBlockKeys.type) { return false; } return parentTableNode.isHeaderColumnEnabled && parentTableCellNode?.columnIndex == 0; } /// Whether the current cell is bold in the column. /// /// Default is false. bool get isInBoldColumn { final parentTableCellNode = this.parentTableCellNode; final parentTableNode = this.parentTableNode; if (parentTableCellNode == null || parentTableNode == null || parentTableNode.type != SimpleTableBlockKeys.type) { return false; } final columnIndex = parentTableCellNode.columnIndex; final columnBoldAttributes = parentTableNode.columnBoldAttributes; return columnBoldAttributes[columnIndex.toString()] ?? false; } /// Whether the current cell is bold in the row. /// /// Default is false. bool get isInBoldRow { final parentTableCellNode = this.parentTableCellNode; final parentTableNode = this.parentTableNode; if (parentTableCellNode == null || parentTableNode == null || parentTableNode.type != SimpleTableBlockKeys.type) { return false; } final rowIndex = parentTableCellNode.rowIndex; final rowBoldAttributes = parentTableNode.rowBoldAttributes; return rowBoldAttributes[rowIndex.toString()] ?? false; } /// Get the text color of the current cell in the column. /// /// Default is null. String? get textColorInColumn { final parentTableCellNode = this.parentTableCellNode; final parentTableNode = this.parentTableNode; if (parentTableCellNode == null || parentTableNode == null || parentTableNode.type != SimpleTableBlockKeys.type) { return null; } final columnIndex = parentTableCellNode.columnIndex; return parentTableNode.columnTextColors[columnIndex.toString()]; } /// Get the text color of the current cell in the row. /// /// Default is null. String? get textColorInRow { final parentTableCellNode = this.parentTableCellNode; final parentTableNode = this.parentTableNode; if (parentTableCellNode == null || parentTableNode == null || parentTableNode.type != SimpleTableBlockKeys.type) { return null; } final rowIndex = parentTableCellNode.rowIndex; return parentTableNode.rowTextColors[rowIndex.toString()]; } /// Whether the current node is in the header row. /// /// Default is false. bool get isInHeaderRow { final parentTableNode = parent?.parentTableNode; if (parentTableNode == null || parentTableNode.type != SimpleTableBlockKeys.type) { return false; } return parentTableNode.isHeaderRowEnabled && parentTableCellNode?.rowIndex == 0; } /// Get the row aligns. SimpleTableRowAlignMap get rowAligns { final rawRowAligns = parentTableNode?.attributes[SimpleTableBlockKeys.rowAligns]; if (rawRowAligns == null) { return SimpleTableRowAlignMap(); } try { return SimpleTableRowAlignMap.from(rawRowAligns); } catch (e) { Log.warn('get row aligns: $e'); return SimpleTableRowAlignMap(); } } /// Get the row colors. SimpleTableColorMap get rowColors { final rawRowColors = parentTableNode?.attributes[SimpleTableBlockKeys.rowColors]; if (rawRowColors == null) { return SimpleTableColorMap(); } try { return SimpleTableColorMap.from(rawRowColors); } catch (e) { Log.warn('get row colors: $e'); return SimpleTableColorMap(); } } /// Get the column colors. SimpleTableColorMap get columnColors { final rawColumnColors = parentTableNode?.attributes[SimpleTableBlockKeys.columnColors]; if (rawColumnColors == null) { return SimpleTableColorMap(); } try { return SimpleTableColorMap.from(rawColumnColors); } catch (e) { Log.warn('get column colors: $e'); return SimpleTableColorMap(); } } /// Get the column aligns. SimpleTableColumnAlignMap get columnAligns { final rawColumnAligns = parentTableNode?.attributes[SimpleTableBlockKeys.columnAligns]; if (rawColumnAligns == null) { return SimpleTableRowAlignMap(); } try { return SimpleTableRowAlignMap.from(rawColumnAligns); } catch (e) { Log.warn('get column aligns: $e'); return SimpleTableRowAlignMap(); } } /// Get the column widths. SimpleTableColumnWidthMap get columnWidths { final rawColumnWidths = parentTableNode?.attributes[SimpleTableBlockKeys.columnWidths]; if (rawColumnWidths == null) { return SimpleTableColumnWidthMap(); } try { return SimpleTableColumnWidthMap.from(rawColumnWidths); } catch (e) { Log.warn('get column widths: $e'); return SimpleTableColumnWidthMap(); } } /// Get the column text colors SimpleTableColorMap get columnTextColors { final rawColumnTextColors = parentTableNode?.attributes[SimpleTableBlockKeys.columnTextColors]; if (rawColumnTextColors == null) { return SimpleTableColorMap(); } try { return SimpleTableColorMap.from(rawColumnTextColors); } catch (e) { Log.warn('get column text colors: $e'); return SimpleTableColorMap(); } } /// Get the row text colors SimpleTableColorMap get rowTextColors { final rawRowTextColors = parentTableNode?.attributes[SimpleTableBlockKeys.rowTextColors]; if (rawRowTextColors == null) { return SimpleTableColorMap(); } try { return SimpleTableColorMap.from(rawRowTextColors); } catch (e) { Log.warn('get row text colors: $e'); return SimpleTableColorMap(); } } /// Get the column bold attributes SimpleTableAttributeMap get columnBoldAttributes { final rawColumnBoldAttributes = parentTableNode?.attributes[SimpleTableBlockKeys.columnBoldAttributes]; if (rawColumnBoldAttributes == null) { return SimpleTableAttributeMap(); } try { return SimpleTableAttributeMap.from(rawColumnBoldAttributes); } catch (e) { Log.warn('get column bold attributes: $e'); return SimpleTableAttributeMap(); } } /// Get the row bold attributes SimpleTableAttributeMap get rowBoldAttributes { final rawRowBoldAttributes = parentTableNode?.attributes[SimpleTableBlockKeys.rowBoldAttributes]; if (rawRowBoldAttributes == null) { return SimpleTableAttributeMap(); } try { return SimpleTableAttributeMap.from(rawRowBoldAttributes); } catch (e) { Log.warn('get row bold attributes: $e'); return SimpleTableAttributeMap(); } } /// Get the width of the table. double get width { double currentColumnWidth = 0; for (var i = 0; i < columnLength; i++) { final columnWidth = columnWidths[i.toString()] ?? SimpleTableConstants.defaultColumnWidth; currentColumnWidth += columnWidth; } return currentColumnWidth; } /// Get the previous cell in the same column. If the row index is 0, it will return the same cell. Node? getPreviousCellInSameColumn() { assert(type == SimpleTableCellBlockKeys.type); final parentTableNode = this.parentTableNode; if (parentTableNode == null) { return null; } final columnIndex = this.columnIndex; final rowIndex = this.rowIndex; if (rowIndex == 0) { return this; } final previousColumn = parentTableNode.children[rowIndex - 1]; final previousCell = previousColumn.children[columnIndex]; return previousCell; } /// Get the next cell in the same column. If the row index is the last row, it will return the same cell. Node? getNextCellInSameColumn() { assert(type == SimpleTableCellBlockKeys.type); final parentTableNode = this.parentTableNode; if (parentTableNode == null) { return null; } final columnIndex = this.columnIndex; final rowIndex = this.rowIndex; if (rowIndex == parentTableNode.rowLength - 1) { return this; } final nextColumn = parentTableNode.children[rowIndex + 1]; final nextCell = nextColumn.children[columnIndex]; return nextCell; } /// Get the right cell in the same row. If the column index is the last column, it will return the same cell. Node? getNextCellInSameRow() { assert(type == SimpleTableCellBlockKeys.type); final parentTableNode = this.parentTableNode; if (parentTableNode == null) { return null; } final columnIndex = this.columnIndex; final rowIndex = this.rowIndex; // the last cell if (columnIndex == parentTableNode.columnLength - 1 && rowIndex == parentTableNode.rowLength - 1) { return this; } if (columnIndex == parentTableNode.columnLength - 1) { final nextRow = parentTableNode.children[rowIndex + 1]; final nextCell = nextRow.children.first; return nextCell; } final nextColumn = parentTableNode.children[rowIndex]; final nextCell = nextColumn.children[columnIndex + 1]; return nextCell; } /// Get the previous cell in the same row. If the column index is 0, it will return the same cell. Node? getPreviousCellInSameRow() { assert(type == SimpleTableCellBlockKeys.type); final parentTableNode = this.parentTableNode; if (parentTableNode == null) { return null; } final columnIndex = this.columnIndex; final rowIndex = this.rowIndex; if (columnIndex == 0 && rowIndex == 0) { return this; } if (columnIndex == 0) { final previousRow = parentTableNode.children[rowIndex - 1]; final previousCell = previousRow.children.last; return previousCell; } final previousColumn = parentTableNode.children[rowIndex]; final previousCell = previousColumn.children[columnIndex - 1]; return previousCell; } /// Get the previous focusable sibling. /// /// If the current node is the first child of its parent, it will return itself. Node? getPreviousFocusableSibling() { final parent = this.parent; if (parent == null) { return null; } final parentTableNode = this.parentTableNode; if (parentTableNode == null) { return null; } if (parentTableNode.path == [0]) { return this; } final previous = parentTableNode.previous; if (previous == null) { return null; } var children = previous.children; if (children.isEmpty) { return previous; } while (children.isNotEmpty) { children = children.last.children; } return children.lastWhere((c) => c.delta != null); } /// Get the next focusable sibling. /// /// If the current node is the last child of its parent, it will return itself. Node? getNextFocusableSibling() { final next = this.next; if (next == null) { return null; } return next; } /// Is the last cell in the table. bool get isLastCellInTable { return columnIndex + 1 == parentTableNode?.columnLength && rowIndex + 1 == parentTableNode?.rowLength; } /// Is the first cell in the table. bool get isFirstCellInTable { return columnIndex == 0 && rowIndex == 0; } /// Get the table cell node by the row index and column index. /// /// If the current node is not a table cell node, it will return null. /// Or if the row index or column index is out of range, it will return null. Node? getTableCellNode({ required int rowIndex, required int columnIndex, }) { assert(type == SimpleTableBlockKeys.type); if (type != SimpleTableBlockKeys.type) { return null; } if (rowIndex < 0 || rowIndex >= rowLength) { return null; } if (columnIndex < 0 || columnIndex >= columnLength) { return null; } return children[rowIndex].children[columnIndex]; } String? getTableCellContent({ required int rowIndex, required int columnIndex, }) { final cell = getTableCellNode(rowIndex: rowIndex, columnIndex: columnIndex); if (cell == null) { return null; } final content = cell.children .map((e) => e.delta?.toPlainText()) .where((e) => e != null) .join('\n'); return content; } /// Return the first empty row in the table from bottom to top. /// /// Example: /// /// | A | B | C | /// | | | | /// | E | F | G | /// | H | I | J | /// | | | | <--- The first empty row is the row at index 3. /// | | | | /// /// The first empty row is the row at index 3. (int, Node)? getFirstEmptyRowFromBottom() { assert(type == SimpleTableBlockKeys.type); if (type != SimpleTableBlockKeys.type) { return null; } (int, Node)? result; for (var i = children.length - 1; i >= 0; i--) { final row = children[i]; // Check if all cells in this row are empty final hasContent = row.children.any((cell) { final content = getTableCellContent( rowIndex: i, columnIndex: row.children.indexOf(cell), ); return content != null && content.isNotEmpty; }); if (!hasContent) { if (result != null) { final (index, _) = result; if (i <= index) { result = (i, row); } } else { result = (i, row); } } } return result; } /// Return the first empty column in the table from right to left. /// /// Example: /// ↓ The first empty column is the column at index 3. /// | A | C | | E | | | /// | B | D | | F | | | /// /// The first empty column is the column at index 3. int? getFirstEmptyColumnFromRight() { assert(type == SimpleTableBlockKeys.type); if (type != SimpleTableBlockKeys.type) { return null; } int? result; for (var i = columnLength - 1; i >= 0; i--) { bool hasContent = false; for (var j = 0; j < rowLength; j++) { final content = getTableCellContent( rowIndex: j, columnIndex: i, ); if (content != null && content.isNotEmpty) { hasContent = true; } } if (!hasContent) { if (result != null) { final index = result; if (i <= index) { result = i; } } else { result = i; } } } return result; } /// Get first focusable child in the table cell. /// /// If the current node is not a table cell node, it will return null. Node? getFirstFocusableChild() { if (children.isEmpty) { return this; } return children.first.getFirstFocusableChild(); } /// Get last focusable child in the table cell. /// /// If the current node is not a table cell node, it will return null. Node? getLastFocusableChild() { if (children.isEmpty) { return this; } return children.last.getLastFocusableChild(); } /// Get table align of column /// /// If one of the align is not same as the others, it will return TableAlign.left. TableAlign get allColumnAlign { final alignSet = columnAligns.values.toSet(); if (alignSet.length == 1) { return TableAlign.fromString(alignSet.first); } return TableAlign.left; } /// Get table align of row /// /// If one of the align is not same as the others, it will return TableAlign.left. TableAlign get allRowAlign { final alignSet = rowAligns.values.toSet(); if (alignSet.length == 1) { return TableAlign.fromString(alignSet.first); } return TableAlign.left; } /// Get table align of the table. /// /// If one of the align is not same as the others, it will return TableAlign.left. TableAlign get tableAlign { if (allColumnAlign != TableAlign.left) { return allColumnAlign; } else if (allRowAlign != TableAlign.left) { return allRowAlign; } return TableAlign.left; } } extension on Object { double toDouble({double defaultValue = 0}) { if (this is double) { return this as double; } if (this is String) { return double.tryParse(this as String) ?? defaultValue; } if (this is int) { return (this as int).toDouble(); } return defaultValue; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart ================================================ export 'simple_table_content_operation.dart'; export 'simple_table_delete_operation.dart'; export 'simple_table_duplicate_operation.dart'; export 'simple_table_header_operation.dart'; export 'simple_table_insert_operation.dart'; export 'simple_table_map_operation.dart'; export 'simple_table_node_extension.dart'; export 'simple_table_reorder_operation.dart'; export 'simple_table_style_operation.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_reorder_operation.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension SimpleTableReorderOperation on EditorState { /// Reorder the column of the table. /// /// If the from index is equal to the to index, do nothing. /// The node's type can be [SimpleTableCellBlockKeys.type] or [SimpleTableRowBlockKeys.type] or [SimpleTableBlockKeys.type]. Future reorderColumn( Node node, { required int fromIndex, required int toIndex, }) async { if (fromIndex == toIndex) { return; } final tableNode = node.parentTableNode; if (tableNode == null) { assert(tableNode == null); return; } final columnLength = tableNode.columnLength; final rowLength = tableNode.rowLength; if (fromIndex < 0 || fromIndex >= columnLength || toIndex < 0 || toIndex >= columnLength) { Log.warn( 'reorder column: index out of range: fromIndex: $fromIndex, toIndex: $toIndex, column length: $columnLength', ); return; } Log.info( 'reorder column in table ${node.id} at fromIndex: $fromIndex, toIndex: $toIndex, column length: $columnLength, row length: $rowLength', ); final attributes = tableNode.mapTableAttributes( tableNode, type: TableMapOperationType.reorderColumn, index: fromIndex, toIndex: toIndex, ); final transaction = this.transaction; for (var i = 0; i < rowLength; i++) { final row = tableNode.children[i]; final from = row.children[fromIndex]; final to = row.children[toIndex]; final path = fromIndex < toIndex ? to.path.next : to.path; transaction.insertNode(path, from.deepCopy()); transaction.deleteNode(from); } if (attributes != null) { transaction.updateNode(tableNode, attributes); } await apply(transaction); } /// Reorder the row of the table. /// /// If the from index is equal to the to index, do nothing. /// The node's type can be [SimpleTableCellBlockKeys.type] or [SimpleTableRowBlockKeys.type] or [SimpleTableBlockKeys.type]. Future reorderRow( Node node, { required int fromIndex, required int toIndex, }) async { if (fromIndex == toIndex) { return; } final tableNode = node.parentTableNode; if (tableNode == null) { assert(tableNode == null); return; } final columnLength = tableNode.columnLength; final rowLength = tableNode.rowLength; if (fromIndex < 0 || fromIndex >= rowLength || toIndex < 0 || toIndex >= rowLength) { Log.warn( 'reorder row: index out of range: fromIndex: $fromIndex, toIndex: $toIndex, row length: $rowLength', ); return; } Log.info( 'reorder row in table ${node.id} at fromIndex: $fromIndex, toIndex: $toIndex, column length: $columnLength, row length: $rowLength', ); final attributes = tableNode.mapTableAttributes( tableNode, type: TableMapOperationType.reorderRow, index: fromIndex, toIndex: toIndex, ); final transaction = this.transaction; final from = tableNode.children[fromIndex]; final to = tableNode.children[toIndex]; final path = fromIndex < toIndex ? to.path.next : to.path; transaction.insertNode(path, from.deepCopy()); transaction.deleteNode(from); if (attributes != null) { transaction.updateNode(tableNode, attributes); } await apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_style_operation.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:universal_platform/universal_platform.dart'; extension TableOptionOperation on EditorState { /// Update the column width of the table in memory. Call this function when dragging the table column. /// /// The deltaX is the change of the column width. Future updateColumnWidthInMemory({ required Node tableCellNode, required double deltaX, }) async { assert(tableCellNode.type == SimpleTableCellBlockKeys.type); if (tableCellNode.type != SimpleTableCellBlockKeys.type) { return; } // when dragging the table column, we need to update the column width in memory. // so that the table can render the column with the new width. // but don't need to persist to the database immediately. // only persist to the database when the drag is completed. final columnIndex = tableCellNode.columnIndex; final parentTableNode = tableCellNode.parentTableNode; if (parentTableNode == null) { Log.warn('parent table node is null'); return; } final width = tableCellNode.columnWidth + deltaX; try { final columnWidths = parentTableNode.attributes[SimpleTableBlockKeys.columnWidths] ?? SimpleTableColumnWidthMap(); final newAttributes = { ...parentTableNode.attributes, SimpleTableBlockKeys.columnWidths: { ...columnWidths, columnIndex.toString(): width.clamp( SimpleTableConstants.minimumColumnWidth, double.infinity, ), }, }; parentTableNode.updateAttributes(newAttributes); } catch (e) { Log.warn('update column width in memory: $e'); } } /// Update the column width of the table. Call this function after the drag is completed. Future updateColumnWidth({ required Node tableCellNode, required double width, }) async { assert(tableCellNode.type == SimpleTableCellBlockKeys.type); if (tableCellNode.type != SimpleTableCellBlockKeys.type) { return; } final columnIndex = tableCellNode.columnIndex; final parentTableNode = tableCellNode.parentTableNode; if (parentTableNode == null) { Log.warn('parent table node is null'); return; } final transaction = this.transaction; final columnWidths = parentTableNode.attributes[SimpleTableBlockKeys.columnWidths] ?? SimpleTableColumnWidthMap(); transaction.updateNode(parentTableNode, { SimpleTableBlockKeys.columnWidths: { ...columnWidths, columnIndex.toString(): width.clamp( SimpleTableConstants.minimumColumnWidth, double.infinity, ), }, // reset the distribute column widths evenly flag SimpleTableBlockKeys.distributeColumnWidthsEvenly: false, }); await apply(transaction); } /// Update the align of the column at the index where the table cell node is located. /// /// Before: /// Given table cell node: /// Row 1: | 0 | 1 | /// Row 2: |2 |3 | ← This column will be updated /// /// Call this function will update the align of the column where the table cell node is located. /// /// After: /// Row 1: | 0 | 1 | /// Row 2: | 2 | 3 | ← This column is updated, texts are aligned to the center Future updateColumnAlign({ required Node tableCellNode, required TableAlign align, }) async { await clearColumnTextAlign(tableCellNode: tableCellNode); final columnIndex = tableCellNode.columnIndex; await _updateTableAttributes( tableCellNode: tableCellNode, attributeKey: SimpleTableBlockKeys.columnAligns, source: tableCellNode.columnAligns, duplicatedEntry: MapEntry(columnIndex.toString(), align.key), ); } /// Update the align of the row at the index where the table cell node is located. /// /// Before: /// Given table cell node: /// ↓ This row will be updated /// Row 1: | 0 |1 | /// Row 2: | 2 |3 | /// /// Call this function will update the align of the row where the table cell node is located. /// /// After: /// ↓ This row is updated, texts are aligned to the center /// Row 1: | 0 | 1 | /// Row 2: | 2 | 3 | Future updateRowAlign({ required Node tableCellNode, required TableAlign align, }) async { await clearRowTextAlign(tableCellNode: tableCellNode); final rowIndex = tableCellNode.rowIndex; await _updateTableAttributes( tableCellNode: tableCellNode, attributeKey: SimpleTableBlockKeys.rowAligns, source: tableCellNode.rowAligns, duplicatedEntry: MapEntry(rowIndex.toString(), align.key), ); } /// Update the align of the table. /// /// This function will update the align of the table. /// /// The align is the align to be updated. Future updateTableAlign({ required Node tableNode, required TableAlign align, }) async { assert(tableNode.type == SimpleTableBlockKeys.type); if (tableNode.type != SimpleTableBlockKeys.type) { return; } final transaction = this.transaction; Attributes attributes = tableNode.attributes; for (var i = 0; i < tableNode.columnLength; i++) { attributes = attributes.mergeValues( SimpleTableBlockKeys.columnAligns, attributes[SimpleTableBlockKeys.columnAligns] ?? SimpleTableColumnAlignMap(), duplicatedEntry: MapEntry(i.toString(), align.key), ); } transaction.updateNode(tableNode, attributes); await apply(transaction); } /// Update the background color of the column at the index where the table cell node is located. Future updateColumnBackgroundColor({ required Node tableCellNode, required String color, }) async { final columnIndex = tableCellNode.columnIndex; await _updateTableAttributes( tableCellNode: tableCellNode, attributeKey: SimpleTableBlockKeys.columnColors, source: tableCellNode.columnColors, duplicatedEntry: MapEntry(columnIndex.toString(), color), ); } /// Update the background color of the row at the index where the table cell node is located. Future updateRowBackgroundColor({ required Node tableCellNode, required String color, }) async { final rowIndex = tableCellNode.rowIndex; await _updateTableAttributes( tableCellNode: tableCellNode, attributeKey: SimpleTableBlockKeys.rowColors, source: tableCellNode.rowColors, duplicatedEntry: MapEntry(rowIndex.toString(), color), ); } /// Set the column width of the table to the page width. /// /// Example: /// /// Before: /// | 0 | 1 | /// | 3 | 4 | /// /// After: /// | 0 | 1 | <- the column's width will be expanded based on the percentage of the page width /// | 3 | 4 | /// /// This function will update the table width. Future setColumnWidthToPageWidth({ required Node tableNode, }) async { final columnLength = tableNode.columnLength; double? pageWidth = tableNode.renderBox?.size.width; if (pageWidth == null) { Log.warn('table node render box is null'); return; } pageWidth -= SimpleTableConstants.tablePageOffset; final transaction = this.transaction; final columnWidths = tableNode.columnWidths; final ratio = pageWidth / tableNode.width; for (var i = 0; i < columnLength; i++) { final columnWidth = columnWidths[i.toString()] ?? SimpleTableConstants.defaultColumnWidth; columnWidths[i.toString()] = (columnWidth * ratio).clamp( SimpleTableConstants.minimumColumnWidth, double.infinity, ); } transaction.updateNode(tableNode, { SimpleTableBlockKeys.columnWidths: columnWidths, SimpleTableBlockKeys.distributeColumnWidthsEvenly: false, }); await apply(transaction); } /// Distribute the column width of the table to the page width. /// /// Example: /// /// Before: /// Before: /// | 0 | 1 | /// | 3 | 4 | /// /// After: /// | 0 | 1 | <- the column's width will be expanded based on the percentage of the page width /// | 3 | 4 | /// /// This function will not update table width. Future distributeColumnWidthToPageWidth({ required Node tableNode, }) async { // Disable in mobile if (UniversalPlatform.isMobile) { return; } final columnLength = tableNode.columnLength; final tableWidth = tableNode.width; final columnWidth = (tableWidth / columnLength).clamp( SimpleTableConstants.minimumColumnWidth, double.infinity, ); final transaction = this.transaction; final columnWidths = tableNode.columnWidths; for (var i = 0; i < columnLength; i++) { columnWidths[i.toString()] = columnWidth; } transaction.updateNode(tableNode, { SimpleTableBlockKeys.columnWidths: columnWidths, SimpleTableBlockKeys.distributeColumnWidthsEvenly: true, }); await apply(transaction); } /// Update the bold attribute of the column Future toggleColumnBoldAttribute({ required Node tableCellNode, required bool isBold, }) async { final columnIndex = tableCellNode.columnIndex; await _updateTableAttributes( tableCellNode: tableCellNode, attributeKey: SimpleTableBlockKeys.columnBoldAttributes, source: tableCellNode.columnBoldAttributes, duplicatedEntry: MapEntry(columnIndex.toString(), isBold), ); } /// Update the bold attribute of the row Future toggleRowBoldAttribute({ required Node tableCellNode, required bool isBold, }) async { final rowIndex = tableCellNode.rowIndex; await _updateTableAttributes( tableCellNode: tableCellNode, attributeKey: SimpleTableBlockKeys.rowBoldAttributes, source: tableCellNode.rowBoldAttributes, duplicatedEntry: MapEntry(rowIndex.toString(), isBold), ); } /// Update the text color of the column Future updateColumnTextColor({ required Node tableCellNode, required String color, }) async { final columnIndex = tableCellNode.columnIndex; await _updateTableAttributes( tableCellNode: tableCellNode, attributeKey: SimpleTableBlockKeys.columnTextColors, source: tableCellNode.columnTextColors, duplicatedEntry: MapEntry(columnIndex.toString(), color), ); } /// Update the text color of the row Future updateRowTextColor({ required Node tableCellNode, required String color, }) async { final rowIndex = tableCellNode.rowIndex; await _updateTableAttributes( tableCellNode: tableCellNode, attributeKey: SimpleTableBlockKeys.rowTextColors, source: tableCellNode.rowTextColors, duplicatedEntry: MapEntry(rowIndex.toString(), color), ); } /// Update the attributes of the table. /// /// This function is used to update the attributes of the table. /// /// The attribute key is the key of the attribute to be updated. /// The source is the original value of the attribute. /// The duplicatedEntry is the entry of the attribute to be updated. Future _updateTableAttributes({ required Node tableCellNode, required String attributeKey, required Map source, MapEntry? duplicatedEntry, }) async { assert(tableCellNode.type == SimpleTableCellBlockKeys.type); final parentTableNode = tableCellNode.parentTableNode; if (parentTableNode == null) { Log.warn('parent table node is null'); return; } final columnIndex = tableCellNode.columnIndex; Log.info( 'update $attributeKey: $source at column $columnIndex in table ${parentTableNode.id}', ); final transaction = this.transaction; final attributes = parentTableNode.attributes.mergeValues( attributeKey, source, duplicatedEntry: duplicatedEntry, ); transaction.updateNode(parentTableNode, attributes); await apply(transaction); } /// Clear the text align of the column at the index where the table cell node is located. Future clearColumnTextAlign({ required Node tableCellNode, }) async { final parentTableNode = tableCellNode.parentTableNode; if (parentTableNode == null) { Log.warn('parent table node is null'); return; } final columnIndex = tableCellNode.columnIndex; final transaction = this.transaction; for (var i = 0; i < parentTableNode.rowLength; i++) { final cell = parentTableNode.getTableCellNode( rowIndex: i, columnIndex: columnIndex, ); if (cell == null) { continue; } for (final child in cell.children) { transaction.updateNode(child, { blockComponentAlign: null, }); } } if (transaction.operations.isNotEmpty) { await apply(transaction); } } /// Clear the text align of the row at the index where the table cell node is located. Future clearRowTextAlign({ required Node tableCellNode, }) async { final parentTableNode = tableCellNode.parentTableNode; if (parentTableNode == null) { Log.warn('parent table node is null'); return; } final rowIndex = tableCellNode.rowIndex; final transaction = this.transaction; for (var i = 0; i < parentTableNode.columnLength; i++) { final cell = parentTableNode.getTableCellNode( rowIndex: rowIndex, columnIndex: i, ); if (cell == null) { continue; } for (final child in cell.children) { transaction.updateNode( child, { blockComponentAlign: null, }, ); } } if (transaction.operations.isNotEmpty) { await apply(transaction); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class SimpleTableRowBlockKeys { const SimpleTableRowBlockKeys._(); static const String type = 'simple_table_row'; } Node simpleTableRowBlockNode({ List children = const [], }) { return Node( type: SimpleTableRowBlockKeys.type, children: children, ); } class SimpleTableRowBlockComponentBuilder extends BlockComponentBuilder { SimpleTableRowBlockComponentBuilder({ super.configuration, this.alwaysDistributeColumnWidths = false, }); final bool alwaysDistributeColumnWidths; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return SimpleTableRowBlockWidget( key: node.key, node: node, configuration: configuration, alwaysDistributeColumnWidths: alwaysDistributeColumnWidths, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (_) => true; } class SimpleTableRowBlockWidget extends BlockComponentStatefulWidget { const SimpleTableRowBlockWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), required this.alwaysDistributeColumnWidths, }); final bool alwaysDistributeColumnWidths; @override State createState() => _SimpleTableRowBlockWidgetState(); } class _SimpleTableRowBlockWidgetState extends State with BlockComponentConfigurable, BlockComponentTextDirectionMixin, BlockComponentBackgroundColorMixin { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; @override late EditorState editorState = context.read(); @override Widget build(BuildContext context) { if (node.children.isEmpty) { return const SizedBox.shrink(); } return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildCells(), ), ); } List _buildCells() { final List cells = []; for (var i = 0; i < node.children.length; i++) { // border if (i == 0 && SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) { cells.add(const SimpleTableRowDivider()); } final child = editorState.renderer.build(context, node.children[i]); cells.add( widget.alwaysDistributeColumnWidths ? Flexible(child: child) : child, ); // border if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) { cells.add(const SimpleTableRowDivider()); } } return cells; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_down_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_navigation_command.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; final CommandShortcutEvent arrowDownInTableCell = CommandShortcutEvent( key: 'Press arrow down in table cell', getDescription: () => AppFlowyEditorL10n.current.cmdTableMoveToDownCellAtSameOffset, command: 'arrow down', handler: _arrowDownInTableCellHandler, ); /// Move the selection to the next cell in the same column. /// /// Only handle the case when the selection is in the first line of the cell. KeyEventResult _arrowDownInTableCellHandler(EditorState editorState) { final (isInTableCell, selection, tableCellNode, node) = editorState.isCurrentSelectionInTableCell(); if (!isInTableCell || selection == null || tableCellNode == null || node == null) { return KeyEventResult.ignored; } final isInLastLine = node.path.last + 1 == node.parent?.children.length; if (!isInLastLine) { return KeyEventResult.ignored; } Selection? newSelection = editorState.selection; final rowIndex = tableCellNode.rowIndex; final parentTableNode = tableCellNode.parentTableNode; if (parentTableNode == null) { return KeyEventResult.ignored; } if (rowIndex == parentTableNode.rowLength - 1) { // focus on the next block final nextNode = tableCellNode.next; final nextBlock = tableCellNode.parentTableNode?.next; if (nextNode != null) { final nextFocusableSibling = parentTableNode.getNextFocusableSibling(); if (nextFocusableSibling != null) { final length = nextFocusableSibling.delta?.length ?? 0; newSelection = Selection.collapsed( Position( path: nextFocusableSibling.path, offset: length, ), ); } } else if (nextBlock != null) { if (nextBlock.type != SimpleTableBlockKeys.type) { newSelection = Selection.collapsed( Position( path: nextBlock.path, ), ); } else { return tableNavigationArrowDownCommand.handler(editorState); } } } else { // focus on next cell in the same column final nextCell = tableCellNode.getNextCellInSameColumn(); if (nextCell != null) { final offset = selection.end.offset; // get the first children of the next cell final firstChild = nextCell.children.firstWhereOrNull( (c) => c.delta != null, ); if (firstChild != null) { final length = firstChild.delta?.length ?? 0; newSelection = Selection.collapsed( Position( path: firstChild.path, offset: offset.clamp(0, length), ), ); } } } if (newSelection != null) { editorState.updateSelectionWithReason(newSelection); } return KeyEventResult.handled; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_left_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; final CommandShortcutEvent arrowLeftInTableCell = CommandShortcutEvent( key: 'Press arrow left in table cell', getDescription: () => AppFlowyEditorL10n .current.cmdTableMoveToRightCellIfItsAtTheEndOfCurrentCell, command: 'arrow left', handler: (editorState) => editorState.moveToPreviousCell( editorState, (result) { // only handle the case when the selection is at the beginning of the cell if (0 != result.$2?.end.offset) { return false; } return true; }, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_right_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; final CommandShortcutEvent arrowRightInTableCell = CommandShortcutEvent( key: 'Press arrow right in table cell', getDescription: () => AppFlowyEditorL10n .current.cmdTableMoveToRightCellIfItsAtTheEndOfCurrentCell, command: 'arrow right', handler: (editorState) => editorState.moveToNextCell( editorState, (result) { // only handle the case when the selection is at the end of the cell final node = result.$4; final length = node?.delta?.length ?? 0; final selection = result.$2; return selection?.end.offset == length; }, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_up_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; final CommandShortcutEvent arrowUpInTableCell = CommandShortcutEvent( key: 'Press arrow up in table cell', getDescription: () => AppFlowyEditorL10n.current.cmdTableMoveToUpCellAtSameOffset, command: 'arrow up', handler: _arrowUpInTableCellHandler, ); /// Move the selection to the previous cell in the same column. /// /// Only handle the case when the selection is in the first line of the cell. KeyEventResult _arrowUpInTableCellHandler(EditorState editorState) { final (isInTableCell, selection, tableCellNode, node) = editorState.isCurrentSelectionInTableCell(); if (!isInTableCell || selection == null || tableCellNode == null || node == null) { return KeyEventResult.ignored; } final isInFirstLine = node.path.last == 0; if (!isInFirstLine) { return KeyEventResult.ignored; } Selection? newSelection = editorState.selection; final rowIndex = tableCellNode.rowIndex; if (rowIndex == 0) { // focus on the previous block final previousNode = tableCellNode.parentTableNode; if (previousNode != null) { final previousFocusableSibling = previousNode.getPreviousFocusableSibling(); if (previousFocusableSibling != null) { final length = previousFocusableSibling.delta?.length ?? 0; newSelection = Selection.collapsed( Position( path: previousFocusableSibling.path, offset: length, ), ); } } } else { // focus on previous cell in the same column final previousCell = tableCellNode.getPreviousCellInSameColumn(); if (previousCell != null) { final offset = selection.end.offset; // get the last children of the previous cell final lastChild = previousCell.children.lastWhereOrNull( (c) => c.delta != null, ); if (lastChild != null) { final length = lastChild.delta?.length ?? 0; newSelection = Selection.collapsed( Position( path: lastChild.path, offset: offset.clamp(0, length), ), ); } } } if (newSelection != null) { editorState.updateSelectionWithReason(newSelection); } return KeyEventResult.handled; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_backspace_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; final CommandShortcutEvent backspaceInTableCell = CommandShortcutEvent( key: 'Press backspace in table cell', getDescription: () => 'Ignore the backspace key in table cell', command: 'backspace', handler: _backspaceInTableCellHandler, ); KeyEventResult _backspaceInTableCellHandler(EditorState editorState) { final (isInTableCell, selection, tableCellNode, node) = editorState.isCurrentSelectionInTableCell(); if (!isInTableCell || selection == null || tableCellNode == null || node == null) { return KeyEventResult.ignored; } final onlyContainsOneChild = tableCellNode.children.length == 1; final firstChild = tableCellNode.children.first; final isParagraphNode = firstChild.type == ParagraphBlockKeys.type; final isCodeBlock = firstChild.type == CodeBlockKeys.type; if (onlyContainsOneChild && selection.isCollapsed && selection.end.offset == 0) { if (isParagraphNode && firstChild.children.isEmpty) { return KeyEventResult.skipRemainingHandlers; } else if (isCodeBlock) { // replace the codeblock with a paragraph final transaction = editorState.transaction; transaction.insertNode(node.path, paragraphNode()); transaction.deleteNode(node); transaction.afterSelection = Selection.collapsed( Position( path: node.path, ), ); editorState.apply(transaction); return KeyEventResult.handled; } } return KeyEventResult.ignored; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; typedef IsInTableCellResult = ( bool isInTableCell, Selection? selection, Node? tableCellNode, Node? node, ); extension TableCommandExtension on EditorState { /// Return a tuple, the first element is a boolean indicating whether the current selection is in a table cell, /// the second element is the node that is the parent of the table cell if the current selection is in a table cell, /// otherwise it is null. /// The third element is the node that is the current selection. IsInTableCellResult isCurrentSelectionInTableCell() { final selection = this.selection; if (selection == null) { return (false, null, null, null); } if (selection.isCollapsed) { // if the selection is collapsed, check if the node is in a table cell final node = document.nodeAtPath(selection.end.path); final tableCellParent = node?.findParent( (node) => node.type == SimpleTableCellBlockKeys.type, ); final isInTableCell = tableCellParent != null; return (isInTableCell, selection, tableCellParent, node); } else { // if the selection is not collapsed, check if the start and end nodes are in a table cell final startNode = document.nodeAtPath(selection.start.path); final endNode = document.nodeAtPath(selection.end.path); final startNodeInTableCell = startNode?.findParent( (node) => node.type == SimpleTableCellBlockKeys.type, ); final endNodeInTableCell = endNode?.findParent( (node) => node.type == SimpleTableCellBlockKeys.type, ); final isInSameTableCell = startNodeInTableCell != null && endNodeInTableCell != null && startNodeInTableCell.path.equals(endNodeInTableCell.path); return (isInSameTableCell, selection, startNodeInTableCell, endNode); } } /// Move the selection to the previous cell KeyEventResult moveToPreviousCell( EditorState editorState, bool Function(IsInTableCellResult result) shouldHandle, ) { final (isInTableCell, selection, tableCellNode, node) = editorState.isCurrentSelectionInTableCell(); if (!isInTableCell || selection == null || tableCellNode == null || node == null) { return KeyEventResult.ignored; } if (!shouldHandle((isInTableCell, selection, tableCellNode, node))) { return KeyEventResult.ignored; } if (isOutdentable(editorState)) { return outdentCommand.execute(editorState); } Selection? newSelection; final previousCell = tableCellNode.getPreviousCellInSameRow(); if (previousCell != null && !previousCell.path.equals(tableCellNode.path)) { // get the last children of the previous cell final lastChild = previousCell.children.lastWhereOrNull( (c) => c.delta != null, ); if (lastChild != null) { newSelection = Selection.collapsed( Position( path: lastChild.path, offset: lastChild.delta?.length ?? 0, ), ); } } else { // focus on the previous block final previousNode = tableCellNode.parentTableNode; if (previousNode != null) { final previousFocusableSibling = previousNode.getPreviousFocusableSibling(); if (previousFocusableSibling != null) { final length = previousFocusableSibling.delta?.length ?? 0; newSelection = Selection.collapsed( Position( path: previousFocusableSibling.path, offset: length, ), ); } } } if (newSelection != null) { editorState.updateSelectionWithReason(newSelection); } return KeyEventResult.handled; } /// Move the selection to the next cell KeyEventResult moveToNextCell( EditorState editorState, bool Function(IsInTableCellResult result) shouldHandle, ) { final (isInTableCell, selection, tableCellNode, node) = editorState.isCurrentSelectionInTableCell(); if (!isInTableCell || selection == null || tableCellNode == null || node == null) { return KeyEventResult.ignored; } if (!shouldHandle((isInTableCell, selection, tableCellNode, node))) { return KeyEventResult.ignored; } Selection? newSelection; if (isIndentable(editorState)) { return indentCommand.execute(editorState); } final nextCell = tableCellNode.getNextCellInSameRow(); if (nextCell != null && !nextCell.path.equals(tableCellNode.path)) { // get the first children of the next cell final firstChild = nextCell.children.firstWhereOrNull( (c) => c.delta != null, ); if (firstChild != null) { newSelection = Selection.collapsed( Position( path: firstChild.path, ), ); } } else { // focus on the previous block final nextNode = tableCellNode.parentTableNode; if (nextNode != null) { final nextFocusableSibling = nextNode.getNextFocusableSibling(); nextNode.getNextFocusableSibling(); if (nextFocusableSibling != null) { newSelection = Selection.collapsed( Position( path: nextFocusableSibling.path, ), ); } } } if (newSelection != null) { editorState.updateSelectionWithReason(newSelection); } return KeyEventResult.handled; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_commands.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_down_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_left_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_right_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_up_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_backspace_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_navigation_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_select_all_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_tab_command.dart'; final simpleTableCommands = [ tableNavigationArrowDownCommand, arrowUpInTableCell, arrowDownInTableCell, arrowLeftInTableCell, arrowRightInTableCell, tabInTableCell, shiftTabInTableCell, backspaceInTableCell, selectAllInTableCellCommand, enterInTableCell, ]; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; final CommandShortcutEvent enterInTableCell = CommandShortcutEvent( key: 'Press enter in table cell', getDescription: () => 'Press the enter key in table cell', command: 'enter', handler: _enterInTableCellHandler, ); KeyEventResult _enterInTableCellHandler(EditorState editorState) { final (isInTableCell, selection, tableCellNode, node) = editorState.isCurrentSelectionInTableCell(); if (!isInTableCell || selection == null || tableCellNode == null || node == null || !selection.isCollapsed) { return KeyEventResult.ignored; } // check if the shift key is pressed, if so, we should return false to let the system handle it. final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; if (isShiftPressed) { return KeyEventResult.ignored; } final delta = node.delta; if (!indentableBlockTypes.contains(node.type) || delta == null) { return KeyEventResult.ignored; } if (selection.startIndex == 0 && delta.isEmpty) { // clear the style if (node.parent?.type != SimpleTableCellBlockKeys.type) { if (outdentCommand.execute(editorState) == KeyEventResult.handled) { return KeyEventResult.handled; } } if (node.type != CalloutBlockKeys.type) { return convertToParagraphCommand.execute(editorState); } } return KeyEventResult.ignored; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_navigation_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; final CommandShortcutEvent tableNavigationArrowDownCommand = CommandShortcutEvent( key: 'table navigation', getDescription: () => 'table navigation', command: 'arrow down', handler: _tableNavigationArrowDownHandler, ); KeyEventResult _tableNavigationArrowDownHandler(EditorState editorState) { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return KeyEventResult.ignored; } final nextNode = editorState.getNodeAtPath(selection.start.path.next); if (nextNode == null) { return KeyEventResult.ignored; } if (nextNode.type == SimpleTableBlockKeys.type) { final firstCell = nextNode.getTableCellNode(rowIndex: 0, columnIndex: 0); if (firstCell != null) { final firstFocusableChild = firstCell.getFirstFocusableChild(); if (firstFocusableChild != null) { editorState.updateSelectionWithReason( Selection.collapsed( Position(path: firstFocusableChild.path), ), ); return KeyEventResult.handled; } } } return KeyEventResult.ignored; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_select_all_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; final CommandShortcutEvent selectAllInTableCellCommand = CommandShortcutEvent( key: 'Select all contents in table cell', getDescription: () => 'Select all contents in table cell', command: 'ctrl+a', macOSCommand: 'cmd+a', handler: _selectAllInTableCellHandler, ); KeyEventResult _selectAllInTableCellHandler(EditorState editorState) { final (isInTableCell, selection, tableCellNode, _) = editorState.isCurrentSelectionInTableCell(); if (!isInTableCell || selection == null || tableCellNode == null) { return KeyEventResult.ignored; } final firstFocusableChild = tableCellNode.children.firstWhereOrNull( (e) => e.delta != null, ); final lastFocusableChild = tableCellNode.lastChildWhere( (e) => e.delta != null, ); if (firstFocusableChild == null || lastFocusableChild == null) { return KeyEventResult.ignored; } final afterSelection = Selection( start: Position(path: firstFocusableChild.path), end: Position( path: lastFocusableChild.path, offset: lastFocusableChild.delta?.length ?? 0, ), ); if (afterSelection == editorState.selection) { // Focus on the cell already return KeyEventResult.ignored; } else { editorState.selection = afterSelection; return KeyEventResult.handled; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_tab_command.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; final CommandShortcutEvent tabInTableCell = CommandShortcutEvent( key: 'Press tab in table cell', getDescription: () => 'Move the selection to the next cell', command: 'tab', handler: (editorState) => editorState.moveToNextCell( editorState, (result) { final tableCellNode = result.$3; if (tableCellNode?.isLastCellInTable ?? false) { return false; } return true; }, ), ); final CommandShortcutEvent shiftTabInTableCell = CommandShortcutEvent( key: 'Press shift + tab in table cell', getDescription: () => 'Move the selection to the previous cell', command: 'shift+tab', handler: (editorState) => editorState.moveToPreviousCell( editorState, (result) { final tableCellNode = result.$3; if (tableCellNode?.isFirstCellInTable ?? false) { return false; } return true; }, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_desktop_simple_table_widget.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class DesktopSimpleTableWidget extends StatefulWidget { const DesktopSimpleTableWidget({ super.key, required this.simpleTableContext, required this.node, this.enableAddColumnButton = true, this.enableAddRowButton = true, this.enableAddColumnAndRowButton = true, this.enableHoverEffect = true, this.isFeedback = false, this.alwaysDistributeColumnWidths = false, }); /// Refer to [SimpleTableWidget.node]. final Node node; /// Refer to [SimpleTableWidget.simpleTableContext]. final SimpleTableContext simpleTableContext; /// Refer to [SimpleTableWidget.enableAddColumnButton]. final bool enableAddColumnButton; /// Refer to [SimpleTableWidget.enableAddRowButton]. final bool enableAddRowButton; /// Refer to [SimpleTableWidget.enableAddColumnAndRowButton]. final bool enableAddColumnAndRowButton; /// Refer to [SimpleTableWidget.enableHoverEffect]. final bool enableHoverEffect; /// Refer to [SimpleTableWidget.isFeedback]. final bool isFeedback; /// Refer to [SimpleTableWidget.alwaysDistributeColumnWidths]. final bool alwaysDistributeColumnWidths; @override State createState() => _DesktopSimpleTableWidgetState(); } class _DesktopSimpleTableWidgetState extends State { SimpleTableContext get simpleTableContext => widget.simpleTableContext; final scrollController = ScrollController(); late final editorState = context.read(); @override void initState() { super.initState(); simpleTableContext.horizontalScrollController = scrollController; } @override void dispose() { simpleTableContext.horizontalScrollController = null; scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return widget.isFeedback ? _buildFeedbackTable() : _buildDesktopTable(); } Widget _buildFeedbackTable() { return Provider.value( value: simpleTableContext, child: IntrinsicWidth( child: IntrinsicHeight( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: _buildRows(), ), ), ), ); } Widget _buildDesktopTable() { // table content // IntrinsicHeight is used to make the table size fit the content. Widget child = IntrinsicHeight( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildRows(), ), ); if (widget.alwaysDistributeColumnWidths) { child = Padding( padding: SimpleTableConstants.tablePadding, child: child, ); } else { child = Scrollbar( controller: scrollController, child: SingleChildScrollView( controller: scrollController, scrollDirection: Axis.horizontal, child: Padding( padding: SimpleTableConstants.tablePadding, // IntrinsicWidth is used to make the table size fit the content. child: IntrinsicWidth(child: child), ), ), ); } if (widget.enableHoverEffect) { child = MouseRegion( onEnter: (event) => simpleTableContext.isHoveringOnTableArea.value = true, onExit: (event) { simpleTableContext.isHoveringOnTableArea.value = false; }, child: Provider.value( value: simpleTableContext, child: Stack( children: [ MouseRegion( hitTestBehavior: HitTestBehavior.opaque, onEnter: (event) => simpleTableContext.isHoveringOnColumnsAndRows.value = true, onExit: (event) { simpleTableContext.isHoveringOnColumnsAndRows.value = false; simpleTableContext.hoveringTableCell.value = null; }, child: child, ), if (editorState.editable) ...[ if (widget.enableAddColumnButton) SimpleTableAddColumnHoverButton( editorState: editorState, tableNode: widget.node, ), if (widget.enableAddRowButton) SimpleTableAddRowHoverButton( editorState: editorState, tableNode: widget.node, ), if (widget.enableAddColumnAndRowButton) SimpleTableAddColumnAndRowHoverButton( editorState: editorState, node: widget.node, ), ], ], ), ), ); } return child; } List _buildRows() { final List rows = []; if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) { rows.add(const SimpleTableColumnDivider()); } for (final child in widget.node.children) { rows.add(editorState.renderer.build(context, child)); if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) { rows.add(const SimpleTableColumnDivider()); } } return rows; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_mobile_simple_table_widget.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class MobileSimpleTableWidget extends StatefulWidget { const MobileSimpleTableWidget({ super.key, required this.simpleTableContext, required this.node, this.enableAddColumnButton = true, this.enableAddRowButton = true, this.enableAddColumnAndRowButton = true, this.enableHoverEffect = true, this.isFeedback = false, this.alwaysDistributeColumnWidths = false, }); /// Refer to [SimpleTableWidget.node]. final Node node; /// Refer to [SimpleTableWidget.simpleTableContext]. final SimpleTableContext simpleTableContext; /// Refer to [SimpleTableWidget.enableAddColumnButton]. final bool enableAddColumnButton; /// Refer to [SimpleTableWidget.enableAddRowButton]. final bool enableAddRowButton; /// Refer to [SimpleTableWidget.enableAddColumnAndRowButton]. final bool enableAddColumnAndRowButton; /// Refer to [SimpleTableWidget.enableHoverEffect]. final bool enableHoverEffect; /// Refer to [SimpleTableWidget.isFeedback]. final bool isFeedback; /// Refer to [SimpleTableWidget.alwaysDistributeColumnWidths]. final bool alwaysDistributeColumnWidths; @override State createState() => _MobileSimpleTableWidgetState(); } class _MobileSimpleTableWidgetState extends State { SimpleTableContext get simpleTableContext => widget.simpleTableContext; final scrollController = ScrollController(); late final editorState = context.read(); @override void initState() { super.initState(); simpleTableContext.horizontalScrollController = scrollController; } @override void dispose() { simpleTableContext.horizontalScrollController = null; scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return widget.isFeedback ? _buildFeedbackTable() : _buildMobileTable(); } Widget _buildFeedbackTable() { return Provider.value( value: simpleTableContext, child: IntrinsicWidth( child: IntrinsicHeight( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: _buildRows(), ), ), ), ); } Widget _buildMobileTable() { return Provider.value( value: simpleTableContext, child: SingleChildScrollView( controller: scrollController, scrollDirection: Axis.horizontal, child: IntrinsicWidth( child: IntrinsicHeight( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: _buildRows(), ), ), ), ), ); } List _buildRows() { final List rows = []; if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) { rows.add(const SimpleTableColumnDivider()); } for (final child in widget.node.children) { rows.add(editorState.renderer.build(context, child)); if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) { rows.add(const SimpleTableColumnDivider()); } } return rows; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; /// Base class for all simple table bottom sheet actions abstract class ISimpleTableBottomSheetActions extends StatelessWidget { const ISimpleTableBottomSheetActions({ super.key, required this.type, required this.cellNode, required this.editorState, }); final SimpleTableMoreActionType type; final Node cellNode; final EditorState editorState; } /// Quick actions for the table cell /// /// - Copy /// - Paste /// - Cut /// - Delete class SimpleTableCellQuickActions extends ISimpleTableBottomSheetActions { const SimpleTableCellQuickActions({ super.key, required super.type, required super.cellNode, required super.editorState, }); @override Widget build(BuildContext context) { return SizedBox( height: SimpleTableConstants.actionSheetQuickActionSectionHeight, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ SimpleTableQuickAction( type: SimpleTableMoreAction.cut, onTap: () => _onActionTap( context, SimpleTableMoreAction.cut, ), ), SimpleTableQuickAction( type: SimpleTableMoreAction.copy, onTap: () => _onActionTap( context, SimpleTableMoreAction.copy, ), ), SimpleTableQuickAction( type: SimpleTableMoreAction.paste, onTap: () => _onActionTap( context, SimpleTableMoreAction.paste, ), ), SimpleTableQuickAction( type: SimpleTableMoreAction.delete, onTap: () => _onActionTap( context, SimpleTableMoreAction.delete, ), ), ], ), ); } void _onActionTap(BuildContext context, SimpleTableMoreAction action) { final tableNode = cellNode.parentTableNode; if (tableNode == null) { Log.error('unable to find table node when performing action: $action'); return; } switch (action) { case SimpleTableMoreAction.cut: _onCut(tableNode); case SimpleTableMoreAction.copy: _onCopy(tableNode); case SimpleTableMoreAction.paste: _onPaste(tableNode); case SimpleTableMoreAction.delete: _onDelete(tableNode); default: assert(false, 'Unsupported action: $type'); } // close the action menu Navigator.of(context).pop(); } Future _onCut(Node tableNode) async { ClipboardServiceData? data; switch (type) { case SimpleTableMoreActionType.column: data = await editorState.copyColumn( tableNode: tableNode, columnIndex: cellNode.columnIndex, clearContent: true, ); case SimpleTableMoreActionType.row: data = await editorState.copyRow( tableNode: tableNode, rowIndex: cellNode.rowIndex, clearContent: true, ); } if (data != null) { await getIt().setData(data); } } Future _onCopy( Node tableNode, ) async { ClipboardServiceData? data; switch (type) { case SimpleTableMoreActionType.column: data = await editorState.copyColumn( tableNode: tableNode, columnIndex: cellNode.columnIndex, ); case SimpleTableMoreActionType.row: data = await editorState.copyRow( tableNode: tableNode, rowIndex: cellNode.rowIndex, ); } if (data != null) { await getIt().setData(data); } } void _onPaste(Node tableNode) { switch (type) { case SimpleTableMoreActionType.column: editorState.pasteColumn( tableNode: tableNode, columnIndex: cellNode.columnIndex, ); case SimpleTableMoreActionType.row: editorState.pasteRow( tableNode: tableNode, rowIndex: cellNode.rowIndex, ); } } void _onDelete(Node tableNode) { switch (type) { case SimpleTableMoreActionType.column: editorState.deleteColumnInTable( tableNode, cellNode.columnIndex, ); case SimpleTableMoreActionType.row: editorState.deleteRowInTable( tableNode, cellNode.rowIndex, ); } } } class SimpleTableQuickAction extends StatelessWidget { const SimpleTableQuickAction({ super.key, required this.type, required this.onTap, this.isEnabled = true, }); final SimpleTableMoreAction type; final VoidCallback onTap; final bool isEnabled; @override Widget build(BuildContext context) { return Opacity( opacity: isEnabled ? 1.0 : 0.5, child: AnimatedGestureDetector( onTapUp: isEnabled ? onTap : null, child: FlowySvg( type.leftIconSvg, blendMode: type == SimpleTableMoreAction.delete ? null : BlendMode.srcIn, size: const Size.square(24), color: context.simpleTableQuickActionBackgroundColor, ), ), ); } } /// Insert actions /// /// - Column: Insert left or insert right /// - Row: Insert above or insert below class SimpleTableInsertActions extends ISimpleTableBottomSheetActions { const SimpleTableInsertActions({ super.key, required super.type, required super.cellNode, required super.editorState, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16), height: SimpleTableConstants.actionSheetInsertSectionHeight, child: _buildAction(context), ); } Widget _buildAction(BuildContext context) { return switch (type) { SimpleTableMoreActionType.row => Row( children: [ SimpleTableInsertAction( type: SimpleTableMoreAction.insertAbove, enableLeftBorder: true, onTap: (increaseCounter) async => _onActionTap( context, type: SimpleTableMoreAction.insertAbove, increaseCounter: increaseCounter, ), ), const HSpace(2), SimpleTableInsertAction( type: SimpleTableMoreAction.insertBelow, enableRightBorder: true, onTap: (increaseCounter) async => _onActionTap( context, type: SimpleTableMoreAction.insertBelow, increaseCounter: increaseCounter, ), ), ], ), SimpleTableMoreActionType.column => Row( children: [ SimpleTableInsertAction( type: SimpleTableMoreAction.insertLeft, enableLeftBorder: true, onTap: (increaseCounter) async => _onActionTap( context, type: SimpleTableMoreAction.insertLeft, increaseCounter: increaseCounter, ), ), const HSpace(2), SimpleTableInsertAction( type: SimpleTableMoreAction.insertRight, enableRightBorder: true, onTap: (increaseCounter) async => _onActionTap( context, type: SimpleTableMoreAction.insertRight, increaseCounter: increaseCounter, ), ), ], ), }; } Future _onActionTap( BuildContext context, { required SimpleTableMoreAction type, required int increaseCounter, }) async { final simpleTableContext = context.read(); final tableNode = cellNode.parentTableNode; if (tableNode == null) { Log.error('unable to find table node when performing action: $type'); return; } switch (type) { case SimpleTableMoreAction.insertAbove: // update the highlight status for the selecting row simpleTableContext.selectingRow.value = cellNode.rowIndex + 1; await editorState.insertRowInTable( tableNode, cellNode.rowIndex, ); case SimpleTableMoreAction.insertBelow: await editorState.insertRowInTable( tableNode, cellNode.rowIndex + 1, ); // scroll to the next cell position editorState.scrollService?.scrollTo( SimpleTableConstants.defaultRowHeight, duration: Durations.short3, ); case SimpleTableMoreAction.insertLeft: // update the highlight status for the selecting column simpleTableContext.selectingColumn.value = cellNode.columnIndex + 1; await editorState.insertColumnInTable( tableNode, cellNode.columnIndex, ); case SimpleTableMoreAction.insertRight: await editorState.insertColumnInTable( tableNode, cellNode.columnIndex + 1, ); final horizontalScrollController = simpleTableContext.horizontalScrollController; if (horizontalScrollController != null) { final previousWidth = horizontalScrollController.offset; horizontalScrollController.jumpTo( previousWidth + SimpleTableConstants.defaultColumnWidth, ); } default: assert(false, 'Unsupported action: $type'); } } } class SimpleTableInsertAction extends StatefulWidget { const SimpleTableInsertAction({ super.key, required this.type, this.enableLeftBorder = false, this.enableRightBorder = false, required this.onTap, }); final SimpleTableMoreAction type; final bool enableLeftBorder; final bool enableRightBorder; final ValueChanged onTap; @override State createState() => _SimpleTableInsertActionState(); } class _SimpleTableInsertActionState extends State { // used to count how many times the action is tapped int increaseCounter = 0; @override Widget build(BuildContext context) { return Expanded( child: DecoratedBox( decoration: ShapeDecoration( color: context.simpleTableInsertActionBackgroundColor, shape: _buildBorder(), ), child: AnimatedGestureDetector( onTapUp: () => widget.onTap(increaseCounter++), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( padding: const EdgeInsets.all(1), child: FlowySvg( widget.type.leftIconSvg, size: const Size.square(22), ), ), FlowyText( widget.type.name, fontSize: 12, figmaLineHeight: 16, ), ], ), ), ), ); } RoundedRectangleBorder _buildBorder() { const radius = Radius.circular( SimpleTableConstants.actionSheetButtonRadius, ); return RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: widget.enableLeftBorder ? radius : Radius.zero, bottomLeft: widget.enableLeftBorder ? radius : Radius.zero, topRight: widget.enableRightBorder ? radius : Radius.zero, bottomRight: widget.enableRightBorder ? radius : Radius.zero, ), ); } } /// Cell Action buttons /// /// - Distribute columns evenly /// - Set to page width /// - Duplicate row /// - Duplicate column /// - Clear contents class SimpleTableCellActionButtons extends ISimpleTableBottomSheetActions { const SimpleTableCellActionButtons({ super.key, required super.type, required super.cellNode, required super.editorState, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: _buildActions(context), ), ); } List _buildActions(BuildContext context) { // the actions are grouped into different sections // we need to get the index of the table cell node // and the length of the columns and rows final (index, columnLength, rowLength) = switch (type) { SimpleTableMoreActionType.row => ( cellNode.rowIndex, cellNode.columnLength, cellNode.rowLength, ), SimpleTableMoreActionType.column => ( cellNode.columnIndex, cellNode.rowLength, cellNode.columnLength, ), }; final actionGroups = type.buildMobileActions( index: index, columnLength: columnLength, rowLength: rowLength, ); final List widgets = []; for (final (actionGroupIndex, actionGroup) in actionGroups.indexed) { for (final (index, action) in actionGroup.indexed) { widgets.add( // enable the corner border if the cell is the first or last in the group switch (action) { SimpleTableMoreAction.enableHeaderColumn => SimpleTableHeaderActionButton( type: action, isEnabled: cellNode.isHeaderColumnEnabled, onTap: (value) => _onActionTap( context, action: action, toggleHeaderValue: value, ), ), SimpleTableMoreAction.enableHeaderRow => SimpleTableHeaderActionButton( type: action, isEnabled: cellNode.isHeaderRowEnabled, onTap: (value) => _onActionTap( context, action: action, toggleHeaderValue: value, ), ), _ => SimpleTableActionButton( type: action, enableTopBorder: index == 0, enableBottomBorder: index == actionGroup.length - 1, onTap: () => _onActionTap( context, action: action, ), ), }, ); // if the action is not the first or last in the group, add a divider if (index != actionGroup.length - 1) { widgets.add(const FlowyDivider()); } } // add padding to separate the action groups if (actionGroupIndex != actionGroups.length - 1) { widgets.add(const VSpace(16)); } } return widgets; } void _onActionTap( BuildContext context, { required SimpleTableMoreAction action, bool toggleHeaderValue = false, }) { final tableNode = cellNode.parentTableNode; if (tableNode == null) { Log.error('unable to find table node when performing action: $action'); return; } switch (action) { case SimpleTableMoreAction.enableHeaderColumn: editorState.toggleEnableHeaderColumn( tableNode: tableNode, enable: toggleHeaderValue, ); case SimpleTableMoreAction.enableHeaderRow: editorState.toggleEnableHeaderRow( tableNode: tableNode, enable: toggleHeaderValue, ); case SimpleTableMoreAction.distributeColumnsEvenly: editorState.distributeColumnWidthToPageWidth(tableNode: tableNode); case SimpleTableMoreAction.setToPageWidth: editorState.setColumnWidthToPageWidth(tableNode: tableNode); case SimpleTableMoreAction.duplicateRow: editorState.duplicateRowInTable( tableNode, cellNode.rowIndex, ); case SimpleTableMoreAction.duplicateColumn: editorState.duplicateColumnInTable( tableNode, cellNode.columnIndex, ); case SimpleTableMoreAction.clearContents: switch (type) { case SimpleTableMoreActionType.column: editorState.clearContentAtColumnIndex( tableNode: tableNode, columnIndex: cellNode.columnIndex, ); case SimpleTableMoreActionType.row: editorState.clearContentAtRowIndex( tableNode: tableNode, rowIndex: cellNode.rowIndex, ); } default: assert(false, 'Unsupported action: $action'); break; } // close the action menu // keep the action menu open if the action is enable header row or enable header column if (action != SimpleTableMoreAction.enableHeaderRow && action != SimpleTableMoreAction.enableHeaderColumn) { Navigator.of(context).pop(); } } } /// Header action button /// /// - Enable header column /// - Enable header row /// /// Notes: These actions are only available for the first column or first row class SimpleTableHeaderActionButton extends StatefulWidget { const SimpleTableHeaderActionButton({ super.key, required this.isEnabled, required this.type, this.onTap, }); final bool isEnabled; final SimpleTableMoreAction type; final void Function(bool value)? onTap; @override State createState() => _SimpleTableHeaderActionButtonState(); } class _SimpleTableHeaderActionButtonState extends State { bool value = false; @override void initState() { super.initState(); value = widget.isEnabled; } @override Widget build(BuildContext context) { return SimpleTableActionButton( type: widget.type, enableTopBorder: true, enableBottomBorder: true, onTap: _toggle, rightIconBuilder: (context) { return Container( width: 36, height: 24, margin: const EdgeInsets.only(right: 16), child: FittedBox( fit: BoxFit.fill, child: CupertinoSwitch( value: value, activeTrackColor: Theme.of(context).colorScheme.primary, onChanged: (_) => _toggle(), ), ), ); }, ); } void _toggle() { setState(() { value = !value; }); widget.onTap?.call(value); } } /// Align text action button /// /// - Align text to left /// - Align text to center /// - Align text to right /// /// Notes: These actions are only available for the table class SimpleTableAlignActionButton extends StatefulWidget { const SimpleTableAlignActionButton({ super.key, required this.onTap, }); final VoidCallback onTap; @override State createState() => _SimpleTableAlignActionButtonState(); } class _SimpleTableAlignActionButtonState extends State { @override Widget build(BuildContext context) { return SimpleTableActionButton( type: SimpleTableMoreAction.align, enableTopBorder: true, enableBottomBorder: true, onTap: widget.onTap, rightIconBuilder: (context) { return const Padding( padding: EdgeInsets.only(right: 16), child: FlowySvg(FlowySvgs.m_aa_arrow_right_s), ); }, ); } } class SimpleTableActionButton extends StatelessWidget { const SimpleTableActionButton({ super.key, required this.type, this.enableTopBorder = false, this.enableBottomBorder = false, this.rightIconBuilder, this.onTap, }); final SimpleTableMoreAction type; final bool enableTopBorder; final bool enableBottomBorder; final WidgetBuilder? rightIconBuilder; final void Function()? onTap; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( height: SimpleTableConstants.actionSheetNormalActionSectionHeight, decoration: ShapeDecoration( color: context.simpleTableActionButtonBackgroundColor, shape: _buildBorder(), ), child: Row( children: [ const HSpace(16), Padding( padding: const EdgeInsets.all(1.0), child: FlowySvg( type.leftIconSvg, size: const Size.square(20), ), ), const HSpace(12), FlowyText( type.name, fontSize: 14, figmaLineHeight: 20, ), const Spacer(), rightIconBuilder?.call(context) ?? const SizedBox.shrink(), ], ), ), ); } RoundedRectangleBorder _buildBorder() { const radius = Radius.circular( SimpleTableConstants.actionSheetButtonRadius, ); return RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: enableTopBorder ? radius : Radius.zero, topRight: enableTopBorder ? radius : Radius.zero, bottomLeft: enableBottomBorder ? radius : Radius.zero, bottomRight: enableBottomBorder ? radius : Radius.zero, ), ); } } class SimpleTableContentActions extends ISimpleTableBottomSheetActions { const SimpleTableContentActions({ super.key, required super.type, required super.cellNode, required super.editorState, required this.onTextColorSelected, required this.onCellBackgroundColorSelected, required this.onAlignTap, this.selectedTextColor, this.selectedCellBackgroundColor, this.selectedAlign, }); final VoidCallback onTextColorSelected; final VoidCallback onCellBackgroundColorSelected; final ValueChanged onAlignTap; final Color? selectedTextColor; final Color? selectedCellBackgroundColor; final TableAlign? selectedAlign; @override Widget build(BuildContext context) { return Container( height: SimpleTableConstants.actionSheetContentSectionHeight, padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ SimpleTableContentBoldAction( isBold: type == SimpleTableMoreActionType.column ? cellNode.isInBoldColumn : cellNode.isInBoldRow, toggleBold: _toggleBold, ), const HSpace(2), SimpleTableContentTextColorAction( onTap: onTextColorSelected, selectedTextColor: selectedTextColor, ), const HSpace(2), SimpleTableContentCellBackgroundColorAction( onTap: onCellBackgroundColorSelected, selectedCellBackgroundColor: selectedCellBackgroundColor, ), const HSpace(16), SimpleTableContentAlignmentAction( align: selectedAlign ?? TableAlign.left, onTap: onAlignTap, ), ], ), ); } void _toggleBold(bool isBold) { switch (type) { case SimpleTableMoreActionType.column: editorState.toggleColumnBoldAttribute( tableCellNode: cellNode, isBold: isBold, ); case SimpleTableMoreActionType.row: editorState.toggleRowBoldAttribute( tableCellNode: cellNode, isBold: isBold, ); } } } class SimpleTableContentBoldAction extends StatefulWidget { const SimpleTableContentBoldAction({ super.key, required this.toggleBold, required this.isBold, }); final ValueChanged toggleBold; final bool isBold; @override State createState() => _SimpleTableContentBoldActionState(); } class _SimpleTableContentBoldActionState extends State { bool isBold = false; @override void initState() { super.initState(); isBold = widget.isBold; } @override Widget build(BuildContext context) { return Expanded( child: SimpleTableContentActionDecorator( backgroundColor: isBold ? Theme.of(context).colorScheme.primary : null, enableLeftBorder: true, child: AnimatedGestureDetector( onTapUp: () { setState(() { isBold = !isBold; }); widget.toggleBold.call(isBold); }, child: FlowySvg( FlowySvgs.m_aa_bold_s, size: const Size.square(24), color: isBold ? Theme.of(context).colorScheme.onPrimary : null, ), ), ), ); } } class SimpleTableContentTextColorAction extends StatelessWidget { const SimpleTableContentTextColorAction({ super.key, required this.onTap, this.selectedTextColor, }); final VoidCallback onTap; final Color? selectedTextColor; @override Widget build(BuildContext context) { return Expanded( child: SimpleTableContentActionDecorator( child: AnimatedGestureDetector( onTapUp: onTap, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ FlowySvg( FlowySvgs.m_table_text_color_m, color: selectedTextColor, ), const HSpace(10), const FlowySvg( FlowySvgs.m_aa_arrow_right_s, size: Size.square(12), ), ], ), ), ), ); } } class SimpleTableContentCellBackgroundColorAction extends StatelessWidget { const SimpleTableContentCellBackgroundColorAction({ super.key, required this.onTap, this.selectedCellBackgroundColor, }); final VoidCallback onTap; final Color? selectedCellBackgroundColor; @override Widget build(BuildContext context) { return Expanded( child: SimpleTableContentActionDecorator( enableRightBorder: true, child: AnimatedGestureDetector( onTapUp: onTap, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildTextBackgroundColorPreview(), const HSpace(10), FlowySvg( FlowySvgs.m_aa_arrow_right_s, size: const Size.square(12), color: selectedCellBackgroundColor, ), ], ), ), ), ); } Widget _buildTextBackgroundColorPreview() { return Container( width: 24, height: 24, decoration: ShapeDecoration( color: selectedCellBackgroundColor ?? const Color(0xFFFFE6FD), shape: RoundedRectangleBorder( side: const BorderSide( color: Color(0xFFCFD3D9), ), borderRadius: BorderRadius.circular(100), ), ), ); } } class SimpleTableContentAlignmentAction extends StatelessWidget { const SimpleTableContentAlignmentAction({ super.key, this.align = TableAlign.left, required this.onTap, }); final TableAlign align; final ValueChanged onTap; @override Widget build(BuildContext context) { return Expanded( child: SimpleTableContentActionDecorator( enableLeftBorder: true, enableRightBorder: true, child: AnimatedGestureDetector( onTapUp: _onTap, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ FlowySvg( align.leftIconSvg, size: const Size.square(24), ), const HSpace(10), const FlowySvg( FlowySvgs.m_aa_arrow_right_s, size: Size.square(12), ), ], ), ), ), ); } void _onTap() { final nextAlign = switch (align) { TableAlign.left => TableAlign.center, TableAlign.center => TableAlign.right, TableAlign.right => TableAlign.left, }; onTap(nextAlign); } } class SimpleTableContentActionDecorator extends StatelessWidget { const SimpleTableContentActionDecorator({ super.key, this.enableLeftBorder = false, this.enableRightBorder = false, this.backgroundColor, required this.child, }); final bool enableLeftBorder; final bool enableRightBorder; final Color? backgroundColor; final Widget child; @override Widget build(BuildContext context) { return Container( height: SimpleTableConstants.actionSheetNormalActionSectionHeight, padding: const EdgeInsets.symmetric(vertical: 10), decoration: ShapeDecoration( color: backgroundColor ?? context.simpleTableInsertActionBackgroundColor, shape: _buildBorder(), ), child: child, ); } RoundedRectangleBorder _buildBorder() { const radius = Radius.circular( SimpleTableConstants.actionSheetButtonRadius, ); return RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: enableLeftBorder ? radius : Radius.zero, topRight: enableRightBorder ? radius : Radius.zero, bottomLeft: enableLeftBorder ? radius : Radius.zero, bottomRight: enableRightBorder ? radius : Radius.zero, ), ); } } class SimpleTableActionButtons extends StatelessWidget { const SimpleTableActionButtons({ super.key, required this.tableNode, required this.editorState, required this.onAlignTap, }); final Node tableNode; final EditorState editorState; final VoidCallback onAlignTap; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: _buildActions(context), ), ); } List _buildActions(BuildContext context) { final actionGroups = [ [ SimpleTableMoreAction.setToPageWidth, SimpleTableMoreAction.distributeColumnsEvenly, ], [ SimpleTableMoreAction.align, ], [ SimpleTableMoreAction.duplicateTable, SimpleTableMoreAction.copyLinkToBlock, ] ]; final List widgets = []; for (final (actionGroupIndex, actionGroup) in actionGroups.indexed) { for (final (index, action) in actionGroup.indexed) { widgets.add( // enable the corner border if the cell is the first or last in the group switch (action) { SimpleTableMoreAction.align => SimpleTableAlignActionButton( onTap: () => _onActionTap( context, action: action, ), ), _ => SimpleTableActionButton( type: action, enableTopBorder: index == 0, enableBottomBorder: index == actionGroup.length - 1, onTap: () => _onActionTap( context, action: action, ), ), }, ); // if the action is not the first or last in the group, add a divider if (index != actionGroup.length - 1) { widgets.add(const FlowyDivider()); } } // add padding to separate the action groups if (actionGroupIndex != actionGroups.length - 1) { widgets.add(const VSpace(16)); } } return widgets; } void _onActionTap( BuildContext context, { required SimpleTableMoreAction action, }) { final optionCubit = BlockActionOptionCubit( editorState: editorState, blockComponentBuilder: {}, ); switch (action) { case SimpleTableMoreAction.setToPageWidth: editorState.setColumnWidthToPageWidth(tableNode: tableNode); case SimpleTableMoreAction.distributeColumnsEvenly: editorState.distributeColumnWidthToPageWidth(tableNode: tableNode); case SimpleTableMoreAction.duplicateTable: optionCubit.handleAction(OptionAction.duplicate, tableNode); case SimpleTableMoreAction.copyLinkToBlock: optionCubit.handleAction(OptionAction.copyLinkToBlock, tableNode); case SimpleTableMoreAction.align: onAlignTap(); default: assert(false, 'Unsupported action: $action'); break; } // close the action menu if (action != SimpleTableMoreAction.align) { Navigator.of(context).pop(); } } } class SimpleTableContentAlignAction extends StatefulWidget { const SimpleTableContentAlignAction({ super.key, required this.isSelected, required this.align, required this.onTap, }); final bool isSelected; final VoidCallback onTap; final TableAlign align; @override State createState() => _SimpleTableContentAlignActionState(); } class _SimpleTableContentAlignActionState extends State { @override Widget build(BuildContext context) { return Expanded( child: SimpleTableContentActionDecorator( backgroundColor: widget.isSelected ? Theme.of(context).colorScheme.primary : null, enableLeftBorder: widget.align == TableAlign.left, enableRightBorder: widget.align == TableAlign.right, child: AnimatedGestureDetector( onTapUp: widget.onTap, child: FlowySvg( widget.align.leftIconSvg, size: const Size.square(24), color: widget.isSelected ? Theme.of(context).colorScheme.onPrimary : null, ), ), ), ); } } /// Quick actions for the table /// /// - Copy /// - Paste /// - Cut /// - Delete class SimpleTableQuickActions extends StatelessWidget { const SimpleTableQuickActions({ super.key, required this.tableNode, required this.editorState, }); final Node tableNode; final EditorState editorState; @override Widget build(BuildContext context) { return SizedBox( height: SimpleTableConstants.actionSheetQuickActionSectionHeight, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ SimpleTableQuickAction( type: SimpleTableMoreAction.cut, onTap: () => _onActionTap( context, SimpleTableMoreAction.cut, ), ), SimpleTableQuickAction( type: SimpleTableMoreAction.copy, onTap: () => _onActionTap( context, SimpleTableMoreAction.copy, ), ), SimpleTableQuickAction( type: SimpleTableMoreAction.paste, onTap: () => _onActionTap( context, SimpleTableMoreAction.paste, ), ), SimpleTableQuickAction( type: SimpleTableMoreAction.delete, onTap: () => _onActionTap( context, SimpleTableMoreAction.delete, ), ), ], ), ); } void _onActionTap(BuildContext context, SimpleTableMoreAction action) { switch (action) { case SimpleTableMoreAction.cut: _onCut(tableNode); case SimpleTableMoreAction.copy: _onCopy(tableNode); case SimpleTableMoreAction.paste: _onPaste(tableNode); case SimpleTableMoreAction.delete: _onDelete(tableNode); default: assert(false, 'Unsupported action: $action'); } // close the action menu Navigator.of(context).pop(); } Future _onCut(Node tableNode) async { final data = await editorState.copyTable( tableNode: tableNode, clearContent: true, ); if (data != null) { await getIt().setData(data); } } Future _onCopy(Node tableNode) async { final data = await editorState.copyTable( tableNode: tableNode, ); if (data != null) { await getIt().setData(data); } } void _onPaste(Node tableNode) => editorState.pasteTable( tableNode: tableNode, ); void _onDelete(Node tableNode) { final transaction = editorState.transaction; transaction.deleteNode(tableNode); editorState.apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_action_sheet.dart ================================================ import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_feedback.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; class SimpleTableMobileDraggableReorderButton extends StatelessWidget { const SimpleTableMobileDraggableReorderButton({ super.key, required this.cellNode, required this.index, required this.isShowingMenu, required this.type, required this.editorState, required this.simpleTableContext, }); final Node cellNode; final int index; final ValueNotifier isShowingMenu; final SimpleTableMoreActionType type; final EditorState editorState; final SimpleTableContext simpleTableContext; @override Widget build(BuildContext context) { return Draggable( data: index, onDragStarted: () => _startDragging(), onDragUpdate: (details) => _onDragUpdate(details), onDragEnd: (_) => _stopDragging(), feedback: SimpleTableFeedback( editorState: editorState, node: cellNode, type: type, index: index, ), child: SimpleTableMobileReorderButton( index: index, type: type, node: cellNode, isShowingMenu: isShowingMenu, ), ); } void _startDragging() { HapticFeedback.lightImpact(); isShowingMenu.value = true; editorState.selection = null; switch (type) { case SimpleTableMoreActionType.column: simpleTableContext.isReorderingColumn.value = (true, index); case SimpleTableMoreActionType.row: simpleTableContext.isReorderingRow.value = (true, index); } } void _onDragUpdate(DragUpdateDetails details) { simpleTableContext.reorderingOffset.value = details.globalPosition; } void _stopDragging() { isShowingMenu.value = false; switch (type) { case SimpleTableMoreActionType.column: _reorderColumn(); case SimpleTableMoreActionType.row: _reorderRow(); } simpleTableContext.reorderingOffset.value = Offset.zero; simpleTableContext.isReorderingHitIndex.value = null; switch (type) { case SimpleTableMoreActionType.column: simpleTableContext.isReorderingColumn.value = (false, -1); break; case SimpleTableMoreActionType.row: simpleTableContext.isReorderingRow.value = (false, -1); break; } } void _reorderColumn() { final fromIndex = simpleTableContext.isReorderingColumn.value.$2; final toIndex = simpleTableContext.isReorderingHitIndex.value; if (toIndex == null) { return; } editorState.reorderColumn( cellNode, fromIndex: fromIndex, toIndex: toIndex, ); } void _reorderRow() { final fromIndex = simpleTableContext.isReorderingRow.value.$2; final toIndex = simpleTableContext.isReorderingHitIndex.value; if (toIndex == null) { return; } editorState.reorderRow( cellNode, fromIndex: fromIndex, toIndex: toIndex, ); } } class SimpleTableMobileReorderButton extends StatefulWidget { const SimpleTableMobileReorderButton({ super.key, required this.index, required this.type, required this.node, required this.isShowingMenu, }); final int index; final SimpleTableMoreActionType type; final Node node; final ValueNotifier isShowingMenu; @override State createState() => _SimpleTableMobileReorderButtonState(); } class _SimpleTableMobileReorderButtonState extends State { late final EditorState editorState = context.read(); late final SimpleTableContext simpleTableContext = context.read(); @override void initState() { super.initState(); simpleTableContext.selectingRow.addListener(_onUpdateShowingMenu); simpleTableContext.selectingColumn.addListener(_onUpdateShowingMenu); } @override void dispose() { simpleTableContext.selectingRow.removeListener(_onUpdateShowingMenu); simpleTableContext.selectingColumn.removeListener(_onUpdateShowingMenu); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( onTap: () async => _onSelecting(), behavior: HitTestBehavior.opaque, child: SizedBox( height: widget.type == SimpleTableMoreActionType.column ? SimpleTableConstants.columnActionSheetHitTestAreaHeight : null, width: widget.type == SimpleTableMoreActionType.row ? SimpleTableConstants.rowActionSheetHitTestAreaWidth : null, child: Align( child: SimpleTableReorderButton( isShowingMenu: widget.isShowingMenu, type: widget.type, ), ), ), ); } Future _onSelecting() async { widget.isShowingMenu.value = true; // update the selecting row or column switch (widget.type) { case SimpleTableMoreActionType.column: simpleTableContext.selectingColumn.value = widget.index; simpleTableContext.selectingRow.value = null; break; case SimpleTableMoreActionType.row: simpleTableContext.selectingRow.value = widget.index; simpleTableContext.selectingColumn.value = null; } editorState.selection = null; // show the bottom sheet await showMobileBottomSheet( context, useSafeArea: false, showDragHandle: true, showDivider: false, enablePadding: false, builder: (context) => Provider.value( value: simpleTableContext, child: SimpleTableCellBottomSheet( type: widget.type, cellNode: widget.node, editorState: editorState, ), ), ); // reset the selecting row or column simpleTableContext.selectingRow.value = null; simpleTableContext.selectingColumn.value = null; widget.isShowingMenu.value = false; } void _onUpdateShowingMenu() { // highlight the reorder button when the row or column is selected final selectingRow = simpleTableContext.selectingRow.value; final selectingColumn = simpleTableContext.selectingColumn.value; if (selectingRow == widget.index && widget.type == SimpleTableMoreActionType.row) { widget.isShowingMenu.value = true; } else if (selectingColumn == widget.index && widget.type == SimpleTableMoreActionType.column) { widget.isShowingMenu.value = true; } else { widget.isShowingMenu.value = false; } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_column_and_row_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class SimpleTableAddColumnAndRowHoverButton extends StatelessWidget { const SimpleTableAddColumnAndRowHoverButton({ super.key, required this.editorState, required this.node, }); final EditorState editorState; final Node node; @override Widget build(BuildContext context) { assert(node.type == SimpleTableBlockKeys.type); if (node.type != SimpleTableBlockKeys.type) { return const SizedBox.shrink(); } return ValueListenableBuilder( valueListenable: context.read().isHoveringOnTableArea, builder: (context, isHoveringOnTableArea, child) { return ValueListenableBuilder( valueListenable: context.read().hoveringTableCell, builder: (context, hoveringTableCell, child) { bool shouldShow = isHoveringOnTableArea; if (hoveringTableCell != null && SimpleTableConstants.enableHoveringLogicV2) { shouldShow = hoveringTableCell.isLastCellInTable; } return shouldShow ? Positioned( bottom: SimpleTableConstants.addColumnAndRowButtonBottomPadding, right: SimpleTableConstants.addColumnButtonPadding, child: SimpleTableAddColumnAndRowButton( onTap: () { // cancel the selection to avoid flashing the selection editorState.selection = null; editorState.addColumnAndRowInTable(node); }, ), ) : const SizedBox.shrink(); }, ); }, ); } } class SimpleTableAddColumnAndRowButton extends StatelessWidget { const SimpleTableAddColumnAndRowButton({ super.key, this.onTap, }); final VoidCallback? onTap; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.document_plugins_simpleTable_clickToAddNewRowAndColumn .tr(), child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: onTap, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( width: SimpleTableConstants.addColumnAndRowButtonWidth, height: SimpleTableConstants.addColumnAndRowButtonHeight, decoration: BoxDecoration( borderRadius: BorderRadius.circular( SimpleTableConstants.addColumnAndRowButtonCornerRadius, ), color: context.simpleTableMoreActionBackgroundColor, ), child: const FlowySvg( FlowySvgs.add_s, ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_column_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class SimpleTableAddColumnHoverButton extends StatefulWidget { const SimpleTableAddColumnHoverButton({ super.key, required this.editorState, required this.tableNode, }); final EditorState editorState; final Node tableNode; @override State createState() => _SimpleTableAddColumnHoverButtonState(); } class _SimpleTableAddColumnHoverButtonState extends State { late final interceptorKey = 'simple_table_add_column_hover_button_${widget.tableNode.id}'; SelectionGestureInterceptor? interceptor; Offset? startDraggingOffset; int? initialColumnCount; @override void initState() { super.initState(); interceptor = SelectionGestureInterceptor( key: interceptorKey, canTap: (details) => !_isTapInBounds(details.globalPosition), ); widget.editorState.service.selectionService .registerGestureInterceptor(interceptor!); } @override void dispose() { widget.editorState.service.selectionService.unregisterGestureInterceptor( interceptorKey, ); super.dispose(); } @override Widget build(BuildContext context) { assert(widget.tableNode.type == SimpleTableBlockKeys.type); if (widget.tableNode.type != SimpleTableBlockKeys.type) { return const SizedBox.shrink(); } return ValueListenableBuilder( valueListenable: context.read().isHoveringOnTableArea, builder: (context, isHoveringOnTableArea, _) { return ValueListenableBuilder( valueListenable: context.read().hoveringTableCell, builder: (context, hoveringTableCell, _) { bool shouldShow = isHoveringOnTableArea; if (hoveringTableCell != null && SimpleTableConstants.enableHoveringLogicV2) { shouldShow = hoveringTableCell.columnIndex + 1 == hoveringTableCell.columnLength; } return Positioned( top: SimpleTableConstants.tableHitTestTopPadding - SimpleTableConstants.cellBorderWidth, bottom: SimpleTableConstants.addColumnButtonBottomPadding, right: 0, child: Opacity( opacity: shouldShow ? 1.0 : 0.0, child: SimpleTableAddColumnButton( onTap: () { // cancel the selection to avoid flashing the selection widget.editorState.selection = null; widget.editorState.addColumnInTable(widget.tableNode); }, onHorizontalDragStart: (details) { context.read().isDraggingColumn = true; startDraggingOffset = details.globalPosition; initialColumnCount = widget.tableNode.columnLength; }, onHorizontalDragEnd: (details) { context.read().isDraggingColumn = false; }, onHorizontalDragUpdate: (details) { _insertColumnInMemory(details); }, ), ), ); }, ); }, ); } bool _isTapInBounds(Offset offset) { final renderBox = context.findRenderObject() as RenderBox?; if (renderBox == null) { return false; } final localPosition = renderBox.globalToLocal(offset); final result = renderBox.paintBounds.contains(localPosition); return result; } void _insertColumnInMemory(DragUpdateDetails details) { if (!SimpleTableConstants.enableDragToExpandTable) { return; } if (startDraggingOffset == null || initialColumnCount == null) { return; } // calculate the horizontal offset from the start dragging offset final horizontalOffset = details.globalPosition.dx - startDraggingOffset!.dx; const columnWidth = SimpleTableConstants.defaultColumnWidth; final columnDelta = (horizontalOffset / columnWidth).round(); // if the change is less than 1 column, skip the operation if (columnDelta.abs() < 1) { return; } final firstEmptyColumnFromRight = widget.tableNode.getFirstEmptyColumnFromRight(); if (firstEmptyColumnFromRight == null) { return; } final currentColumnCount = widget.tableNode.columnLength; final targetColumnCount = initialColumnCount! + columnDelta; // There're 3 cases that we don't want to proceed: // 1. targetColumnCount < 0: the table at least has 1 column // 2. targetColumnCount == currentColumnCount: the table has no change // 3. targetColumnCount <= initialColumnCount: the table has less columns than the initial column count if (targetColumnCount <= 0 || targetColumnCount == currentColumnCount || targetColumnCount <= firstEmptyColumnFromRight) { return; } if (targetColumnCount > currentColumnCount) { widget.editorState.insertColumnInTable( widget.tableNode, targetColumnCount, inMemoryUpdate: true, ); } else { widget.editorState.deleteColumnInTable( widget.tableNode, targetColumnCount, inMemoryUpdate: true, ); } } } class SimpleTableAddColumnButton extends StatelessWidget { const SimpleTableAddColumnButton({ super.key, this.onTap, required this.onHorizontalDragStart, required this.onHorizontalDragEnd, required this.onHorizontalDragUpdate, }); final VoidCallback? onTap; final void Function(DragStartDetails) onHorizontalDragStart; final void Function(DragEndDetails) onHorizontalDragEnd; final void Function(DragUpdateDetails) onHorizontalDragUpdate; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.document_plugins_simpleTable_clickToAddNewColumn.tr(), child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: onTap, onHorizontalDragStart: onHorizontalDragStart, onHorizontalDragEnd: onHorizontalDragEnd, onHorizontalDragUpdate: onHorizontalDragUpdate, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( width: SimpleTableConstants.addColumnButtonWidth, margin: const EdgeInsets.symmetric( horizontal: SimpleTableConstants.addColumnButtonPadding, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular( SimpleTableConstants.addColumnButtonRadius, ), color: context.simpleTableMoreActionBackgroundColor, ), child: const FlowySvg( FlowySvgs.add_s, ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_row_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class SimpleTableAddRowHoverButton extends StatefulWidget { const SimpleTableAddRowHoverButton({ super.key, required this.editorState, required this.tableNode, }); final EditorState editorState; final Node tableNode; @override State createState() => _SimpleTableAddRowHoverButtonState(); } class _SimpleTableAddRowHoverButtonState extends State { late final interceptorKey = 'simple_table_add_row_hover_button_${widget.tableNode.id}'; SelectionGestureInterceptor? interceptor; Offset? startDraggingOffset; int? initialRowCount; @override void initState() { super.initState(); interceptor = SelectionGestureInterceptor( key: interceptorKey, canTap: (details) => !_isTapInBounds(details.globalPosition), ); widget.editorState.service.selectionService .registerGestureInterceptor(interceptor!); } @override void dispose() { widget.editorState.service.selectionService.unregisterGestureInterceptor( interceptorKey, ); super.dispose(); } @override Widget build(BuildContext context) { assert(widget.tableNode.type == SimpleTableBlockKeys.type); if (widget.tableNode.type != SimpleTableBlockKeys.type) { return const SizedBox.shrink(); } final simpleTableContext = context.read(); return ValueListenableBuilder( valueListenable: simpleTableContext.isHoveringOnTableArea, builder: (context, isHoveringOnTableArea, child) { return ValueListenableBuilder( valueListenable: simpleTableContext.hoveringTableCell, builder: (context, hoveringTableCell, _) { bool shouldShow = isHoveringOnTableArea; if (hoveringTableCell != null && SimpleTableConstants.enableHoveringLogicV2) { shouldShow = hoveringTableCell.rowIndex + 1 == hoveringTableCell.rowLength; } if (simpleTableContext.isDraggingRow) { shouldShow = true; } return shouldShow ? child! : const SizedBox.shrink(); }, ); }, child: Positioned( bottom: 2 * SimpleTableConstants.addRowButtonPadding, left: SimpleTableConstants.tableLeftPadding - SimpleTableConstants.cellBorderWidth, right: SimpleTableConstants.addRowButtonRightPadding, child: SimpleTableAddRowButton( onTap: () { // cancel the selection to avoid flashing the selection widget.editorState.selection = null; widget.editorState.addRowInTable( widget.tableNode, ); }, onVerticalDragStart: (details) { context.read().isDraggingRow = true; startDraggingOffset = details.globalPosition; initialRowCount = widget.tableNode.children.length; }, onVerticalDragEnd: (details) { context.read().isDraggingRow = false; }, onVerticalDragUpdate: (details) { _insertRowInMemory(details); }, ), ), ); } bool _isTapInBounds(Offset offset) { final renderBox = context.findRenderObject() as RenderBox?; if (renderBox == null) { return false; } final localPosition = renderBox.globalToLocal(offset); final result = renderBox.paintBounds.contains(localPosition); return result; } void _insertRowInMemory(DragUpdateDetails details) { if (!SimpleTableConstants.enableDragToExpandTable) { return; } if (startDraggingOffset == null || initialRowCount == null) { return; } // calculate the vertical offset from the start dragging offset final verticalOffset = details.globalPosition.dy - startDraggingOffset!.dy; const rowHeight = SimpleTableConstants.defaultRowHeight; final rowDelta = (verticalOffset / rowHeight).round(); // if the change is less than 1 row, skip the operation if (rowDelta.abs() < 1) { return; } final firstEmptyRowFromBottom = widget.tableNode.getFirstEmptyRowFromBottom(); if (firstEmptyRowFromBottom == null) { return; } final currentRowCount = widget.tableNode.children.length; final targetRowCount = initialRowCount! + rowDelta; // There're 3 cases that we don't want to proceed: // 1. targetRowCount < 0: the table at least has 1 row // 2. targetRowCount == currentRowCount: the table has no change // 3. targetRowCount <= initialRowCount: the table has less rows than the initial row count if (targetRowCount <= 0 || targetRowCount == currentRowCount || targetRowCount <= firstEmptyRowFromBottom.$1) { return; } if (targetRowCount > currentRowCount) { widget.editorState.insertRowInTable( widget.tableNode, targetRowCount, inMemoryUpdate: true, ); } else { widget.editorState.deleteRowInTable( widget.tableNode, targetRowCount, inMemoryUpdate: true, ); } } } class SimpleTableAddRowButton extends StatelessWidget { const SimpleTableAddRowButton({ super.key, this.onTap, required this.onVerticalDragStart, required this.onVerticalDragEnd, required this.onVerticalDragUpdate, }); final VoidCallback? onTap; final void Function(DragStartDetails) onVerticalDragStart; final void Function(DragEndDetails) onVerticalDragEnd; final void Function(DragUpdateDetails) onVerticalDragUpdate; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.document_plugins_simpleTable_clickToAddNewRow.tr(), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, onVerticalDragStart: onVerticalDragStart, onVerticalDragEnd: onVerticalDragEnd, onVerticalDragUpdate: onVerticalDragUpdate, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( height: SimpleTableConstants.addRowButtonHeight, margin: const EdgeInsets.symmetric( vertical: SimpleTableConstants.addColumnButtonPadding, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular( SimpleTableConstants.addRowButtonRadius, ), color: context.simpleTableMoreActionBackgroundColor, ), child: const FlowySvg( FlowySvgs.add_s, ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_align_button.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class SimpleTableAlignMenu extends StatefulWidget { const SimpleTableAlignMenu({ super.key, required this.type, required this.tableCellNode, this.mutex, }); final SimpleTableMoreActionType type; final Node tableCellNode; final PopoverMutex? mutex; @override State createState() => _SimpleTableAlignMenuState(); } class _SimpleTableAlignMenuState extends State { @override Widget build(BuildContext context) { final align = switch (widget.type) { SimpleTableMoreActionType.column => widget.tableCellNode.columnAlign, SimpleTableMoreActionType.row => widget.tableCellNode.rowAlign, }; return AppFlowyPopover( mutex: widget.mutex, child: SimpleTableBasicButton( leftIconSvg: align.leftIconSvg, text: LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), onTap: () {}, ), popupBuilder: (popoverContext) { void onClose() => PopoverContainer.of(popoverContext).closeAll(); return Column( mainAxisSize: MainAxisSize.min, children: [ _buildAlignButton(context, TableAlign.left, onClose), _buildAlignButton(context, TableAlign.center, onClose), _buildAlignButton(context, TableAlign.right, onClose), ], ); }, ); } Widget _buildAlignButton( BuildContext context, TableAlign align, VoidCallback onClose, ) { return SimpleTableBasicButton( leftIconSvg: align.leftIconSvg, text: align.name, onTap: () { switch (widget.type) { case SimpleTableMoreActionType.column: context.read().updateColumnAlign( tableCellNode: widget.tableCellNode, align: align, ); break; case SimpleTableMoreActionType.row: context.read().updateRowAlign( tableCellNode: widget.tableCellNode, align: align, ); break; } onClose(); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_background_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class SimpleTableBackgroundColorMenu extends StatefulWidget { const SimpleTableBackgroundColorMenu({ super.key, required this.type, required this.tableCellNode, this.mutex, }); final SimpleTableMoreActionType type; final Node tableCellNode; final PopoverMutex? mutex; @override State createState() => _SimpleTableBackgroundColorMenuState(); } class _SimpleTableBackgroundColorMenuState extends State { @override Widget build(BuildContext context) { final theme = AFThemeExtension.of(context); final backgroundColor = switch (widget.type) { SimpleTableMoreActionType.row => widget.tableCellNode.buildRowColor(context), SimpleTableMoreActionType.column => widget.tableCellNode.buildColumnColor(context), }; return AppFlowyPopover( mutex: widget.mutex, popupBuilder: (popoverContext) { return _buildColorOptionMenu( context, theme: theme, onClose: () => PopoverContainer.of(popoverContext).closeAll(), ); }, direction: PopoverDirection.rightWithCenterAligned, child: SimpleTableBasicButton( leftIconBuilder: (onHover) => ColorOptionIcon( color: backgroundColor ?? Colors.transparent, ), text: LocaleKeys.document_plugins_simpleTable_moreActions_color.tr(), onTap: () {}, ), ); } Widget _buildColorOptionMenu( BuildContext context, { required AFThemeExtension theme, required VoidCallback onClose, }) { final colors = [ // reset to default background color FlowyColorOption( color: Colors.transparent, i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), id: optionActionColorDefaultColor, ), ...FlowyTint.values.map( (e) => FlowyColorOption( color: e.color(context, theme: theme), i18n: e.tintName(AppFlowyEditorL10n.current), id: e.id, ), ), ]; return FlowyColorPicker( colors: colors, border: Border.all( color: theme.onBackground, ), onTap: (option, index) { switch (widget.type) { case SimpleTableMoreActionType.column: context.read().updateColumnBackgroundColor( tableCellNode: widget.tableCellNode, color: option.id, ); break; case SimpleTableMoreActionType.row: context.read().updateRowBackgroundColor( tableCellNode: widget.tableCellNode, color: option.id, ); break; } onClose(); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_basic_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SimpleTableBasicButton extends StatelessWidget { const SimpleTableBasicButton({ super.key, required this.text, required this.onTap, this.leftIconSvg, this.leftIconBuilder, this.rightIcon, }); final FlowySvgData? leftIconSvg; final String text; final VoidCallback onTap; final Widget Function(bool onHover)? leftIconBuilder; final Widget? rightIcon; @override Widget build(BuildContext context) { return Container( height: SimpleTableConstants.moreActionHeight, padding: SimpleTableConstants.moreActionPadding, child: FlowyIconTextButton( margin: SimpleTableConstants.moreActionHorizontalMargin, leftIconBuilder: _buildLeftIcon, iconPadding: 10.0, textBuilder: (onHover) => FlowyText.regular( text, fontSize: 14.0, figmaLineHeight: 18.0, ), onTap: onTap, rightIconBuilder: (onHover) => rightIcon ?? const SizedBox.shrink(), ), ); } Widget _buildLeftIcon(bool onHover) { if (leftIconBuilder != null) { return leftIconBuilder!(onHover); } return leftIconSvg != null ? FlowySvg(leftIconSvg!) : const SizedBox.shrink(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_border_builder.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class SimpleTableBorderBuilder { SimpleTableBorderBuilder({ required this.context, required this.simpleTableContext, required this.node, }); final BuildContext context; final SimpleTableContext simpleTableContext; final Node node; /// Build the border for the cell. Border? buildBorder({ bool isEditingCell = false, }) { if (SimpleTableConstants.borderType != SimpleTableBorderRenderType.cell) { return null; } // check if the cell is in the selected column final isCellInSelectedColumn = node.columnIndex == simpleTableContext.selectingColumn.value; // check if the cell is in the selected row final isCellInSelectedRow = node.rowIndex == simpleTableContext.selectingRow.value; final isReordering = simpleTableContext.isReordering && (simpleTableContext.isReorderingColumn.value.$1 || simpleTableContext.isReorderingRow.value.$1); final editorState = context.read(); final editable = editorState.editable; if (!editable) { return buildCellBorder(); } else if (isReordering) { return buildReorderingBorder(); } else if (simpleTableContext.isSelectingTable.value) { return buildSelectingTableBorder(); } else if (isCellInSelectedColumn) { return buildColumnHighlightBorder(); } else if (isCellInSelectedRow) { return buildRowHighlightBorder(); } else if (isEditingCell) { return buildEditingBorder(); } else { return buildCellBorder(); } } /// the column border means the `VERTICAL` border of the cell /// /// ____ /// | 1 | 2 | /// | 3 | 4 | /// |___| /// /// the border wrapping the cell 2 and cell 4 is the column border Border buildColumnHighlightBorder() { return Border( left: _buildHighlightBorderSide(), right: _buildHighlightBorderSide(), top: node.rowIndex == 0 ? _buildHighlightBorderSide() : _buildLightBorderSide(), bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength ? _buildHighlightBorderSide() : _buildLightBorderSide(), ); } /// the row border means the `HORIZONTAL` border of the cell /// /// ________ /// | 1 | 2 | /// |_______| /// | 3 | 4 | /// /// the border wrapping the cell 1 and cell 2 is the row border Border buildRowHighlightBorder() { return Border( top: _buildHighlightBorderSide(), bottom: _buildHighlightBorderSide(), left: node.columnIndex == 0 ? _buildHighlightBorderSide() : _buildLightBorderSide(), right: node.columnIndex + 1 == node.parentTableNode?.columnLength ? _buildHighlightBorderSide() : _buildLightBorderSide(), ); } /// Build the border for the reordering state. /// /// For example, when reordering a column, we should highlight the border of the /// current column we're hovering. Border buildReorderingBorder() { final isReorderingColumn = simpleTableContext.isReorderingColumn.value.$1; final isReorderingRow = simpleTableContext.isReorderingRow.value.$1; if (isReorderingColumn) { return _buildColumnReorderingBorder(); } else if (isReorderingRow) { return _buildRowReorderingBorder(); } return buildCellBorder(); } /// Build the border for the cell without any state. Border buildCellBorder() { return Border( top: node.rowIndex == 0 ? _buildDefaultBorderSide() : _buildLightBorderSide(), bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength ? _buildDefaultBorderSide() : _buildLightBorderSide(), left: node.columnIndex == 0 ? _buildDefaultBorderSide() : _buildLightBorderSide(), right: node.columnIndex + 1 == node.parentTableNode?.columnLength ? _buildDefaultBorderSide() : _buildLightBorderSide(), ); } /// Build the border for the editing state. Border buildEditingBorder() { return Border.all( color: Theme.of(context).colorScheme.primary, width: 2, ); } /// Build the border for the selecting table state. Border buildSelectingTableBorder() { final rowIndex = node.rowIndex; final columnIndex = node.columnIndex; return Border( top: rowIndex == 0 ? _buildHighlightBorderSide() : _buildLightBorderSide(), bottom: rowIndex + 1 == node.parentTableNode?.rowLength ? _buildHighlightBorderSide() : _buildLightBorderSide(), left: columnIndex == 0 ? _buildHighlightBorderSide() : _buildLightBorderSide(), right: columnIndex + 1 == node.parentTableNode?.columnLength ? _buildHighlightBorderSide() : _buildLightBorderSide(), ); } Border _buildColumnReorderingBorder() { assert(simpleTableContext.isReordering); final isDraggingInCurrentColumn = simpleTableContext.isReorderingColumn.value.$2 == node.columnIndex; // if the dragging column is the current column, don't show the highlight border if (isDraggingInCurrentColumn) { return buildCellBorder(); } bool isHitCurrentCell = false; if (UniversalPlatform.isDesktop) { // On desktop, we use the dragging column index to determine the highlight border // Check if the hovering table cell column index hit the current node column index isHitCurrentCell = simpleTableContext.hoveringTableCell.value?.columnIndex == node.columnIndex; } else if (UniversalPlatform.isMobile) { // On mobile, we use the isReorderingHitIndex to determine the highlight border isHitCurrentCell = simpleTableContext.isReorderingHitIndex.value == node.columnIndex; } // if the hovering column is not the current column, don't show the highlight border if (!isHitCurrentCell) { return buildCellBorder(); } // if the dragging column index is less than the current column index, show the // highlight border on the left side final isLeftSide = simpleTableContext.isReorderingColumn.value.$2 > node.columnIndex; // if the dragging column index is greater than the current column index, show // the highlight border on the right side final isRightSide = simpleTableContext.isReorderingColumn.value.$2 < node.columnIndex; return Border( top: node.rowIndex == 0 ? _buildDefaultBorderSide() : _buildLightBorderSide(), bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength ? _buildDefaultBorderSide() : _buildLightBorderSide(), left: isLeftSide ? _buildHighlightBorderSide() : _buildLightBorderSide(), right: isRightSide ? _buildHighlightBorderSide() : _buildLightBorderSide(), ); } Border _buildRowReorderingBorder() { assert(simpleTableContext.isReordering); final isDraggingInCurrentRow = simpleTableContext.isReorderingRow.value.$2 == node.rowIndex; // if the dragging row is the current row, don't show the highlight border if (isDraggingInCurrentRow) { return buildCellBorder(); } bool isHitCurrentCell = false; if (UniversalPlatform.isDesktop) { // On desktop, we use the dragging row index to determine the highlight border // Check if the hovering table cell row index hit the current node row index isHitCurrentCell = simpleTableContext.hoveringTableCell.value?.rowIndex == node.rowIndex; } else if (UniversalPlatform.isMobile) { // On mobile, we use the isReorderingHitIndex to determine the highlight border isHitCurrentCell = simpleTableContext.isReorderingHitIndex.value == node.rowIndex; } if (!isHitCurrentCell) { return buildCellBorder(); } // For the row reordering, we only need to update the top and bottom border final isTopSide = simpleTableContext.isReorderingRow.value.$2 > node.rowIndex; final isBottomSide = simpleTableContext.isReorderingRow.value.$2 < node.rowIndex; return Border( top: isTopSide ? _buildHighlightBorderSide() : _buildLightBorderSide(), bottom: isBottomSide ? _buildHighlightBorderSide() : _buildLightBorderSide(), left: node.columnIndex == 0 ? _buildDefaultBorderSide() : _buildLightBorderSide(), right: node.columnIndex + 1 == node.parentTableNode?.columnLength ? _buildDefaultBorderSide() : _buildLightBorderSide(), ); } BorderSide _buildHighlightBorderSide() { return BorderSide( color: Theme.of(context).colorScheme.primary, width: 2, ); } BorderSide _buildLightBorderSide() { return BorderSide( color: context.simpleTableBorderColor, width: 0.5, ); } BorderSide _buildDefaultBorderSide() { return BorderSide( color: context.simpleTableBorderColor, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_bottom_sheet.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; enum _SimpleTableBottomSheetMenuState { cellActionMenu, textColor, textBackgroundColor, tableActionMenu, align, } /// This bottom sheet is used for the column or row action menu. /// When selecting a cell and tapping the action menu button around the cell, /// this bottom sheet will be shown. /// /// Note: This widget is only used for mobile. class SimpleTableCellBottomSheet extends StatefulWidget { const SimpleTableCellBottomSheet({ super.key, required this.type, required this.cellNode, required this.editorState, this.scrollController, }); final SimpleTableMoreActionType type; final Node cellNode; final EditorState editorState; final ScrollController? scrollController; @override State createState() => _SimpleTableCellBottomSheetState(); } class _SimpleTableCellBottomSheetState extends State { _SimpleTableBottomSheetMenuState menuState = _SimpleTableBottomSheetMenuState.cellActionMenu; Color? selectedTextColor; Color? selectedCellBackgroundColor; TableAlign? selectedAlign; @override void initState() { super.initState(); selectedTextColor = switch (widget.type) { SimpleTableMoreActionType.column => widget.cellNode.textColorInColumn?.tryToColor(), SimpleTableMoreActionType.row => widget.cellNode.textColorInRow?.tryToColor(), }; selectedCellBackgroundColor = switch (widget.type) { SimpleTableMoreActionType.column => widget.cellNode.buildColumnColor(context), SimpleTableMoreActionType.row => widget.cellNode.buildRowColor(context), }; selectedAlign = switch (widget.type) { SimpleTableMoreActionType.column => widget.cellNode.columnAlign, SimpleTableMoreActionType.row => widget.cellNode.rowAlign, }; } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // header _buildHeader(), // content ...menuState == _SimpleTableBottomSheetMenuState.cellActionMenu ? _buildScrollableContent() : _buildNonScrollableContent(), ], ); } Widget _buildHeader() { switch (menuState) { case _SimpleTableBottomSheetMenuState.cellActionMenu: return BottomSheetHeader( showBackButton: false, showCloseButton: true, showDoneButton: false, showRemoveButton: false, title: widget.type.name.capitalize(), onClose: () => Navigator.pop(context), ); case _SimpleTableBottomSheetMenuState.textColor || _SimpleTableBottomSheetMenuState.textBackgroundColor: return BottomSheetHeader( showBackButton: false, showCloseButton: true, showDoneButton: true, showRemoveButton: false, title: widget.type.name.capitalize(), onClose: () => setState(() { menuState = _SimpleTableBottomSheetMenuState.cellActionMenu; }), onDone: (_) => Navigator.pop(context), ); default: throw UnimplementedError('Unsupported menu state: $menuState'); } } List _buildScrollableContent() { return [ SizedBox( height: SimpleTableConstants.actionSheetBottomSheetHeight, child: Scrollbar( controller: widget.scrollController, child: SingleChildScrollView( controller: widget.scrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ..._buildContent(), // safe area padding VSpace(context.bottomSheetPadding() * 2), ], ), ), ), ), ]; } List _buildNonScrollableContent() { return [ ..._buildContent(), // safe area padding VSpace(context.bottomSheetPadding()), ]; } List _buildContent() { switch (menuState) { case _SimpleTableBottomSheetMenuState.cellActionMenu: return _buildActionButtons(); case _SimpleTableBottomSheetMenuState.textColor: return _buildTextColor(); case _SimpleTableBottomSheetMenuState.textBackgroundColor: return _buildTextBackgroundColor(); default: throw UnimplementedError('Unsupported menu state: $menuState'); } } List _buildActionButtons() { return [ // copy, cut, paste, delete SimpleTableCellQuickActions( type: widget.type, cellNode: widget.cellNode, editorState: widget.editorState, ), const VSpace(12), // insert row, insert column SimpleTableInsertActions( type: widget.type, cellNode: widget.cellNode, editorState: widget.editorState, ), const VSpace(12), // content actions SimpleTableContentActions( type: widget.type, cellNode: widget.cellNode, editorState: widget.editorState, selectedAlign: selectedAlign, selectedTextColor: selectedTextColor, selectedCellBackgroundColor: selectedCellBackgroundColor, onTextColorSelected: () { setState(() { menuState = _SimpleTableBottomSheetMenuState.textColor; }); }, onCellBackgroundColorSelected: () { setState(() { menuState = _SimpleTableBottomSheetMenuState.textBackgroundColor; }); }, onAlignTap: _onAlignTap, ), const VSpace(16), // action buttons SimpleTableCellActionButtons( type: widget.type, cellNode: widget.cellNode, editorState: widget.editorState, ), ]; } List _buildTextColor() { return [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, ), child: FlowyText( LocaleKeys.document_plugins_simpleTable_moreActions_textColor.tr(), fontSize: 14.0, ), ), const VSpace(12.0), Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0, ), child: EditorTextColorWidget( onSelectedColor: _onTextColorSelected, selectedColor: selectedTextColor, ), ), ]; } List _buildTextBackgroundColor() { return [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, ), child: FlowyText( LocaleKeys .document_plugins_simpleTable_moreActions_cellBackgroundColor .tr(), fontSize: 14.0, ), ), const VSpace(12.0), Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0, ), child: EditorBackgroundColors( onSelectedColor: _onCellBackgroundColorSelected, selectedColor: selectedCellBackgroundColor, ), ), ]; } void _onTextColorSelected(Color color) { final hex = color.a == 0 ? null : color.toHex(); switch (widget.type) { case SimpleTableMoreActionType.column: widget.editorState.updateColumnTextColor( tableCellNode: widget.cellNode, color: hex ?? '', ); case SimpleTableMoreActionType.row: widget.editorState.updateRowTextColor( tableCellNode: widget.cellNode, color: hex ?? '', ); } setState(() { selectedTextColor = color; }); } void _onCellBackgroundColorSelected(Color color) { final hex = color.a == 0 ? null : color.toHex(); switch (widget.type) { case SimpleTableMoreActionType.column: widget.editorState.updateColumnBackgroundColor( tableCellNode: widget.cellNode, color: hex ?? '', ); case SimpleTableMoreActionType.row: widget.editorState.updateRowBackgroundColor( tableCellNode: widget.cellNode, color: hex ?? '', ); } setState(() { selectedCellBackgroundColor = color; }); } void _onAlignTap(TableAlign align) { switch (widget.type) { case SimpleTableMoreActionType.column: widget.editorState.updateColumnAlign( tableCellNode: widget.cellNode, align: align, ); case SimpleTableMoreActionType.row: widget.editorState.updateRowAlign( tableCellNode: widget.cellNode, align: align, ); } setState(() { selectedAlign = align; }); } } /// This bottom sheet is used for the table action menu. /// When selecting a table and tapping the action menu button on the top-left corner of the table, /// this bottom sheet will be shown. /// /// Note: This widget is only used for mobile. class SimpleTableBottomSheet extends StatefulWidget { const SimpleTableBottomSheet({ super.key, required this.tableNode, required this.editorState, this.scrollController, }); final Node tableNode; final EditorState editorState; final ScrollController? scrollController; @override State createState() => _SimpleTableBottomSheetState(); } class _SimpleTableBottomSheetState extends State { _SimpleTableBottomSheetMenuState menuState = _SimpleTableBottomSheetMenuState.tableActionMenu; TableAlign? selectedAlign; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // header _buildHeader(), // content SizedBox( height: SimpleTableConstants.actionSheetBottomSheetHeight, child: Scrollbar( controller: widget.scrollController, child: SingleChildScrollView( controller: widget.scrollController, child: Column( children: [ // content ..._buildContent(), // safe area padding VSpace(context.bottomSheetPadding() * 2), ], ), ), ), ), ], ); } Widget _buildHeader() { switch (menuState) { case _SimpleTableBottomSheetMenuState.tableActionMenu: return BottomSheetHeader( showBackButton: false, showCloseButton: true, showDoneButton: false, showRemoveButton: false, title: LocaleKeys.document_plugins_simpleTable_headerName_table.tr(), onClose: () => Navigator.pop(context), ); case _SimpleTableBottomSheetMenuState.align: return BottomSheetHeader( showBackButton: true, showCloseButton: false, showDoneButton: true, showRemoveButton: false, title: LocaleKeys.document_plugins_simpleTable_headerName_table.tr(), onBack: () => setState(() { menuState = _SimpleTableBottomSheetMenuState.tableActionMenu; }), onDone: (_) => Navigator.pop(context), ); default: throw UnimplementedError('Unsupported menu state: $menuState'); } } List _buildContent() { switch (menuState) { case _SimpleTableBottomSheetMenuState.tableActionMenu: return _buildActionButtons(); case _SimpleTableBottomSheetMenuState.align: return _buildAlign(); default: throw UnimplementedError('Unsupported menu state: $menuState'); } } List _buildActionButtons() { return [ // quick actions // copy, cut, paste, delete SimpleTableQuickActions( tableNode: widget.tableNode, editorState: widget.editorState, ), const VSpace(24), // action buttons SimpleTableActionButtons( tableNode: widget.tableNode, editorState: widget.editorState, onAlignTap: _onTapAlignButton, ), ]; } List _buildAlign() { return [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, ), child: Row( children: [ _buildAlignButton(TableAlign.left), const HSpace(2), _buildAlignButton(TableAlign.center), const HSpace(2), _buildAlignButton(TableAlign.right), ], ), ), ]; } Widget _buildAlignButton(TableAlign align) { return SimpleTableContentAlignAction( onTap: () => _onTapAlign(align), align: align, isSelected: selectedAlign == align, ); } void _onTapAlignButton() { setState(() { menuState = _SimpleTableBottomSheetMenuState.align; }); } void _onTapAlign(TableAlign align) { setState(() { selectedAlign = align; }); widget.editorState.updateTableAlign( tableNode: widget.tableNode, align: align, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_column_resize_handle.dart ================================================ import 'dart:ui'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class SimpleTableColumnResizeHandle extends StatefulWidget { const SimpleTableColumnResizeHandle({ super.key, required this.node, this.isPreviousCell = false, }); final Node node; final bool isPreviousCell; @override State createState() => _SimpleTableColumnResizeHandleState(); } class _SimpleTableColumnResizeHandleState extends State { late final simpleTableContext = context.read(); bool isStartDragging = false; // record the previous position of the drag, only used on mobile double previousDx = 0; @override Widget build(BuildContext context) { return UniversalPlatform.isMobile ? _buildMobileResizeHandle() : _buildDesktopResizeHandle(); } Widget _buildDesktopResizeHandle() { return MouseRegion( cursor: SystemMouseCursors.resizeColumn, onEnter: (_) => _onEnterHoverArea(), onExit: (event) => _onExitHoverArea(), child: GestureDetector( onHorizontalDragStart: _onHorizontalDragStart, onHorizontalDragUpdate: _onHorizontalDragUpdate, onHorizontalDragEnd: _onHorizontalDragEnd, child: ValueListenableBuilder( valueListenable: simpleTableContext.hoveringOnResizeHandle, builder: (context, hoveringOnResizeHandle, child) { // when reordering a column, the resize handle should not be shown final isSameRowIndex = hoveringOnResizeHandle?.columnIndex == widget.node.columnIndex && !simpleTableContext.isReordering; return Opacity( opacity: isSameRowIndex ? 1.0 : 0.0, child: child, ); }, child: Container( height: double.infinity, width: SimpleTableConstants.resizeHandleWidth, color: Theme.of(context).colorScheme.primary, ), ), ), ); } Widget _buildMobileResizeHandle() { return GestureDetector( behavior: HitTestBehavior.opaque, onLongPressStart: _onLongPressStart, onLongPressMoveUpdate: _onLongPressMoveUpdate, onLongPressEnd: _onLongPressEnd, onLongPressCancel: _onLongPressCancel, child: ValueListenableBuilder( valueListenable: simpleTableContext.resizingCell, builder: (context, resizingCell, child) { final isSameColumnIndex = widget.node.columnIndex == resizingCell?.columnIndex; if (!isSameColumnIndex) { return child!; } return Container( width: 10, alignment: !widget.isPreviousCell ? Alignment.centerRight : Alignment.centerLeft, child: Container( width: 2, color: Theme.of(context).colorScheme.primary, ), ); }, child: Container( width: 10, color: Colors.transparent, ), ), ); } void _onEnterHoverArea() { simpleTableContext.hoveringOnResizeHandle.value = widget.node; } void _onExitHoverArea() { Future.delayed(const Duration(milliseconds: 100), () { // the onExit event will be triggered before dragging started. // delay the hiding of the resize handle to avoid flickering. if (!isStartDragging) { simpleTableContext.hoveringOnResizeHandle.value = null; } }); } void _onHorizontalDragStart(DragStartDetails details) { // disable the two-finger drag on trackpad if (details.kind == PointerDeviceKind.trackpad) { return; } isStartDragging = true; } void _onLongPressStart(LongPressStartDetails details) { isStartDragging = true; simpleTableContext.resizingCell.value = widget.node; HapticFeedback.lightImpact(); } void _onHorizontalDragUpdate(DragUpdateDetails details) { if (!isStartDragging) { return; } // only update the column width in memory, // the actual update will be applied in _onHorizontalDragEnd context.read().updateColumnWidthInMemory( tableCellNode: widget.node, deltaX: details.delta.dx, ); } void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) { if (!isStartDragging) { return; } // only update the column width in memory, // the actual update will be applied in _onHorizontalDragEnd context.read().updateColumnWidthInMemory( tableCellNode: widget.node, deltaX: details.offsetFromOrigin.dx - previousDx, ); previousDx = details.offsetFromOrigin.dx; } void _onHorizontalDragEnd(DragEndDetails details) { if (!isStartDragging) { return; } isStartDragging = false; context.read().hoveringOnResizeHandle.value = null; // apply the updated column width context.read().updateColumnWidth( tableCellNode: widget.node, width: widget.node.columnWidth, ); } void _onLongPressEnd(LongPressEndDetails details) { if (!isStartDragging) { return; } isStartDragging = false; // apply the updated column width context.read().updateColumnWidth( tableCellNode: widget.node, width: widget.node.columnWidth, ); previousDx = 0; simpleTableContext.resizingCell.value = null; } void _onLongPressCancel() { isStartDragging = false; simpleTableContext.resizingCell.value = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_divider.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:flutter/material.dart'; class SimpleTableRowDivider extends StatelessWidget { const SimpleTableRowDivider({ super.key, }); @override Widget build(BuildContext context) { return VerticalDivider( color: context.simpleTableBorderColor, width: 1.0, ); } } class SimpleTableColumnDivider extends StatelessWidget { const SimpleTableColumnDivider({super.key}); @override Widget build(BuildContext context) { return Divider( color: context.simpleTableBorderColor, height: 1.0, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_feedback.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class SimpleTableFeedback extends StatefulWidget { const SimpleTableFeedback({ super.key, required this.editorState, required this.node, required this.type, required this.index, }); /// The node of the table. /// Its type must be one of the following: /// [SimpleTableBlockKeys.type], [SimpleTableRowBlockKeys.type], [SimpleTableCellBlockKeys.type]. final Node node; /// The type of the more action. /// /// If the type is [SimpleTableMoreActionType.column], the feedback will use index as column index. /// If the type is [SimpleTableMoreActionType.row], the feedback will use index as row index. final SimpleTableMoreActionType type; /// The index of the column or row. final int index; final EditorState editorState; @override State createState() => _SimpleTableFeedbackState(); } class _SimpleTableFeedbackState extends State { final simpleTableContext = SimpleTableContext(); late final Node dummyNode; @override void initState() { super.initState(); assert( [ SimpleTableBlockKeys.type, SimpleTableRowBlockKeys.type, SimpleTableCellBlockKeys.type, ].contains(widget.node.type), 'The node type must be one of the following: ' '[SimpleTableBlockKeys.type], [SimpleTableRowBlockKeys.type], [SimpleTableCellBlockKeys.type].', ); simpleTableContext.isSelectingTable.value = true; dummyNode = _buildDummyNode(); } @override void dispose() { simpleTableContext.dispose(); dummyNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: Provider.value( value: widget.editorState, child: SimpleTableWidget( node: dummyNode, simpleTableContext: simpleTableContext, enableAddColumnButton: false, enableAddRowButton: false, enableAddColumnAndRowButton: false, enableHoverEffect: false, isFeedback: true, ), ), ); } /// Build the dummy node for the feedback. /// /// For example, /// /// If the type is [SimpleTableMoreActionType.row], we should build the dummy table node using the data from the first row of the table node. /// If the type is [SimpleTableMoreActionType.column], we should build the dummy table node using the data from the first column of the table node. Node _buildDummyNode() { // deep copy the table node to avoid mutating the original node final tableNode = widget.node.parentTableNode?.deepCopy(); if (tableNode == null) { return simpleTableBlockNode(children: []); } switch (widget.type) { case SimpleTableMoreActionType.row: if (widget.index >= tableNode.rowLength || widget.index < 0) { return simpleTableBlockNode(children: []); } final row = tableNode.children[widget.index]; return tableNode.copyWith( children: [row], attributes: { ...tableNode.attributes, if (widget.index != 0) SimpleTableBlockKeys.enableHeaderRow: false, }, ); case SimpleTableMoreActionType.column: if (widget.index >= tableNode.columnLength || widget.index < 0) { return simpleTableBlockNode(children: []); } final rows = tableNode.children.map((row) { final cell = row.children[widget.index]; return simpleTableRowBlockNode(children: [cell]); }).toList(); final columnWidth = tableNode.columnWidths[widget.index.toString()] ?? SimpleTableConstants.defaultColumnWidth; return tableNode.copyWith( children: rows, attributes: { ...tableNode.attributes, SimpleTableBlockKeys.columnWidths: { '0': columnWidth, }, if (widget.index != 0) SimpleTableBlockKeys.enableHeaderColumn: false, }, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_more_action_popup.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class SimpleTableMoreActionPopup extends StatefulWidget { const SimpleTableMoreActionPopup({ super.key, required this.index, required this.isShowingMenu, required this.type, }); final int index; final ValueNotifier isShowingMenu; final SimpleTableMoreActionType type; @override State createState() => _SimpleTableMoreActionPopupState(); } class _SimpleTableMoreActionPopupState extends State { late final editorState = context.read(); SelectionGestureInterceptor? gestureInterceptor; RenderBox? get renderBox => context.findRenderObject() as RenderBox?; late final simpleTableContext = context.read(); Node? tableNode; Node? tableCellNode; @override void initState() { super.initState(); tableCellNode = context.read().hoveringTableCell.value; tableNode = tableCellNode?.parentTableNode; gestureInterceptor = SelectionGestureInterceptor( key: 'simple_table_more_action_popup_interceptor_${tableCellNode?.id}', canTap: (details) => !_isTapInBounds(details.globalPosition), ); editorState.service.selectionService.registerGestureInterceptor( gestureInterceptor!, ); } @override void dispose() { if (gestureInterceptor != null) { editorState.service.selectionService.unregisterGestureInterceptor( gestureInterceptor!.key, ); } super.dispose(); } @override Widget build(BuildContext context) { if (tableNode == null) { return const SizedBox.shrink(); } return AppFlowyPopover( onOpen: () => _onOpen(tableCellNode: tableCellNode), onClose: () => _onClose(), canClose: () async { return true; }, direction: widget.type == SimpleTableMoreActionType.row ? PopoverDirection.bottomWithCenterAligned : PopoverDirection.bottomWithLeftAligned, offset: widget.type == SimpleTableMoreActionType.row ? const Offset(24, 14) : const Offset(-14, 8), clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (_) => _buildPopup(tableCellNode: tableCellNode), child: SimpleTableDraggableReorderButton( editorState: editorState, simpleTableContext: simpleTableContext, node: tableNode!, index: widget.index, isShowingMenu: widget.isShowingMenu, type: widget.type, ), ); } Widget _buildPopup({Node? tableCellNode}) { if (tableCellNode == null) { return const SizedBox.shrink(); } return MultiProvider( providers: [ Provider.value( value: context.read(), ), Provider.value( value: context.read(), ), ], child: SimpleTableMoreActionList( type: widget.type, index: widget.index, tableCellNode: tableCellNode, ), ); } void _onOpen({Node? tableCellNode}) { widget.isShowingMenu.value = true; switch (widget.type) { case SimpleTableMoreActionType.column: context.read().selectingColumn.value = tableCellNode?.columnIndex; case SimpleTableMoreActionType.row: context.read().selectingRow.value = tableCellNode?.rowIndex; } // Workaround to clear the selection after the menu is opened. Future.delayed(Durations.short3, () { if (!editorState.isDisposed) { editorState.selection = null; } }); } void _onClose() { widget.isShowingMenu.value = false; // clear the selecting index context.read().selectingColumn.value = null; context.read().selectingRow.value = null; } bool _isTapInBounds(Offset offset) { if (renderBox == null) { return false; } final localPosition = renderBox!.globalToLocal(offset); final result = renderBox!.paintBounds.contains(localPosition); if (result) { editorState.selection = null; } return result; } } class SimpleTableMoreActionList extends StatefulWidget { const SimpleTableMoreActionList({ super.key, required this.type, required this.index, required this.tableCellNode, this.mutex, }); final SimpleTableMoreActionType type; final int index; final Node tableCellNode; final PopoverMutex? mutex; @override State createState() => _SimpleTableMoreActionListState(); } class _SimpleTableMoreActionListState extends State { // ensure the background color menu and align menu exclusive final mutex = PopoverMutex(); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: widget.type .buildDesktopActions( index: widget.index, columnLength: widget.tableCellNode.columnLength, rowLength: widget.tableCellNode.rowLength, ) .map( (action) => SimpleTableMoreActionItem( type: widget.type, action: action, tableCellNode: widget.tableCellNode, popoverMutex: mutex, ), ) .toList(), ); } } class SimpleTableMoreActionItem extends StatefulWidget { const SimpleTableMoreActionItem({ super.key, required this.type, required this.action, required this.tableCellNode, required this.popoverMutex, }); final SimpleTableMoreActionType type; final SimpleTableMoreAction action; final Node tableCellNode; final PopoverMutex popoverMutex; @override State createState() => _SimpleTableMoreActionItemState(); } class _SimpleTableMoreActionItemState extends State { final isEnableHeader = ValueNotifier(false); @override void initState() { super.initState(); _initEnableHeader(); } @override void dispose() { isEnableHeader.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (widget.action == SimpleTableMoreAction.divider) { return _buildDivider(context); } else if (widget.action == SimpleTableMoreAction.align) { return _buildAlignMenu(context); } else if (widget.action == SimpleTableMoreAction.backgroundColor) { return _buildBackgroundColorMenu(context); } else if (widget.action == SimpleTableMoreAction.enableHeaderColumn) { return _buildEnableHeaderButton(context); } else if (widget.action == SimpleTableMoreAction.enableHeaderRow) { return _buildEnableHeaderButton(context); } return _buildActionButton(context); } Widget _buildDivider(BuildContext context) { return const FlowyDivider( padding: EdgeInsets.symmetric( vertical: 4.0, ), ); } Widget _buildAlignMenu(BuildContext context) { return SimpleTableAlignMenu( type: widget.type, tableCellNode: widget.tableCellNode, mutex: widget.popoverMutex, ); } Widget _buildBackgroundColorMenu(BuildContext context) { return SimpleTableBackgroundColorMenu( type: widget.type, tableCellNode: widget.tableCellNode, mutex: widget.popoverMutex, ); } Widget _buildEnableHeaderButton(BuildContext context) { return SimpleTableBasicButton( text: widget.action.name, leftIconSvg: widget.action.leftIconSvg, rightIcon: ValueListenableBuilder( valueListenable: isEnableHeader, builder: (context, isEnableHeader, child) { return Toggle( value: isEnableHeader, onChanged: (value) => _toggleEnableHeader(), padding: EdgeInsets.zero, ); }, ), onTap: _toggleEnableHeader, ); } Widget _buildActionButton(BuildContext context) { return Container( height: SimpleTableConstants.moreActionHeight, padding: SimpleTableConstants.moreActionPadding, child: FlowyIconTextButton( margin: SimpleTableConstants.moreActionHorizontalMargin, leftIconBuilder: (onHover) => FlowySvg( widget.action.leftIconSvg, color: widget.action == SimpleTableMoreAction.delete && onHover ? Theme.of(context).colorScheme.error : null, ), iconPadding: 10.0, textBuilder: (onHover) => FlowyText.regular( widget.action.name, fontSize: 14.0, figmaLineHeight: 18.0, color: widget.action == SimpleTableMoreAction.delete && onHover ? Theme.of(context).colorScheme.error : null, ), onTap: _onAction, ), ); } void _onAction() { switch (widget.action) { case SimpleTableMoreAction.delete: switch (widget.type) { case SimpleTableMoreActionType.column: _deleteColumn(); break; case SimpleTableMoreActionType.row: _deleteRow(); break; } case SimpleTableMoreAction.insertLeft: _insertColumnLeft(); case SimpleTableMoreAction.insertRight: _insertColumnRight(); case SimpleTableMoreAction.insertAbove: _insertRowAbove(); case SimpleTableMoreAction.insertBelow: _insertRowBelow(); case SimpleTableMoreAction.clearContents: _clearContent(); case SimpleTableMoreAction.duplicate: switch (widget.type) { case SimpleTableMoreActionType.column: _duplicateColumn(); break; case SimpleTableMoreActionType.row: _duplicateRow(); break; } case SimpleTableMoreAction.setToPageWidth: _setToPageWidth(); case SimpleTableMoreAction.distributeColumnsEvenly: _distributeColumnsEvenly(); default: break; } PopoverContainer.of(context).close(); } void _setToPageWidth() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, _, _) = value; final editorState = context.read(); editorState.setColumnWidthToPageWidth(tableNode: table); } void _distributeColumnsEvenly() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, _, _) = value; final editorState = context.read(); editorState.distributeColumnWidthToPageWidth(tableNode: table); } void _duplicateRow() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, node, _) = value; final editorState = context.read(); editorState.duplicateRowInTable(table, node.rowIndex); } void _duplicateColumn() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, node, _) = value; final editorState = context.read(); editorState.duplicateColumnInTable(table, node.columnIndex); } void _toggleEnableHeader() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } isEnableHeader.value = !isEnableHeader.value; final (table, _, _) = value; final editorState = context.read(); switch (widget.type) { case SimpleTableMoreActionType.column: editorState.toggleEnableHeaderColumn( tableNode: table, enable: isEnableHeader.value, ); case SimpleTableMoreActionType.row: editorState.toggleEnableHeaderRow( tableNode: table, enable: isEnableHeader.value, ); } PopoverContainer.of(context).close(); } void _clearContent() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, node, _) = value; final editorState = context.read(); if (widget.type == SimpleTableMoreActionType.column) { editorState.clearContentAtColumnIndex( tableNode: table, columnIndex: node.columnIndex, ); } else if (widget.type == SimpleTableMoreActionType.row) { editorState.clearContentAtRowIndex( tableNode: table, rowIndex: node.rowIndex, ); } } void _insertColumnLeft() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, node, _) = value; final columnIndex = node.columnIndex; final editorState = context.read(); editorState.insertColumnInTable(table, columnIndex); final cell = table.getTableCellNode( rowIndex: 0, columnIndex: columnIndex, ); if (cell == null) { return; } // update selection editorState.selection = Selection.collapsed( Position( path: cell.path.child(0), ), ); } void _insertColumnRight() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, node, _) = value; final columnIndex = node.columnIndex; final editorState = context.read(); editorState.insertColumnInTable(table, columnIndex + 1); final cell = table.getTableCellNode( rowIndex: 0, columnIndex: columnIndex + 1, ); if (cell == null) { return; } // update selection editorState.selection = Selection.collapsed( Position( path: cell.path.child(0), ), ); } void _insertRowAbove() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, node, _) = value; final rowIndex = node.rowIndex; final editorState = context.read(); editorState.insertRowInTable(table, rowIndex); final cell = table.getTableCellNode(rowIndex: rowIndex, columnIndex: 0); if (cell == null) { return; } // update selection editorState.selection = Selection.collapsed( Position( path: cell.path.child(0), ), ); } void _insertRowBelow() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, node, _) = value; final rowIndex = node.rowIndex; final editorState = context.read(); editorState.insertRowInTable(table, rowIndex + 1); final cell = table.getTableCellNode(rowIndex: rowIndex + 1, columnIndex: 0); if (cell == null) { return; } // update selection editorState.selection = Selection.collapsed( Position( path: cell.path.child(0), ), ); } void _deleteRow() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, node, _) = value; final rowIndex = node.rowIndex; final editorState = context.read(); editorState.deleteRowInTable(table, rowIndex); } void _deleteColumn() { final value = _getTableAndTableCellAndCellPosition(); if (value == null) { return; } final (table, node, _) = value; final columnIndex = node.columnIndex; final editorState = context.read(); editorState.deleteColumnInTable(table, columnIndex); } (Node, Node, TableCellPosition)? _getTableAndTableCellAndCellPosition() { final cell = widget.tableCellNode; final table = cell.parent?.parent; if (table == null || table.type != SimpleTableBlockKeys.type) { return null; } return (table, cell, cell.cellPosition); } void _initEnableHeader() { final value = _getTableAndTableCellAndCellPosition(); if (value != null) { final (table, _, _) = value; if (widget.type == SimpleTableMoreActionType.column) { isEnableHeader.value = table .attributes[SimpleTableBlockKeys.enableHeaderColumn] as bool? ?? false; } else if (widget.type == SimpleTableMoreActionType.row) { isEnableHeader.value = table.attributes[SimpleTableBlockKeys.enableHeaderRow] as bool? ?? false; } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_reorder_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_feedback.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; class SimpleTableDraggableReorderButton extends StatelessWidget { const SimpleTableDraggableReorderButton({ super.key, required this.node, required this.index, required this.isShowingMenu, required this.type, required this.editorState, required this.simpleTableContext, }); final Node node; final int index; final ValueNotifier isShowingMenu; final SimpleTableMoreActionType type; final EditorState editorState; final SimpleTableContext simpleTableContext; @override Widget build(BuildContext context) { return Draggable( data: index, onDragStarted: () => _startDragging(), onDragUpdate: (details) => _onDragUpdate(details), onDragEnd: (_) => _stopDragging(), feedback: SimpleTableFeedback( editorState: editorState, node: node, type: type, index: index, ), child: SimpleTableReorderButton( isShowingMenu: isShowingMenu, type: type, ), ); } void _startDragging() { switch (type) { case SimpleTableMoreActionType.column: simpleTableContext.isReorderingColumn.value = (true, index); break; case SimpleTableMoreActionType.row: simpleTableContext.isReorderingRow.value = (true, index); break; } } void _onDragUpdate(DragUpdateDetails details) { simpleTableContext.reorderingOffset.value = details.globalPosition; } void _stopDragging() { switch (type) { case SimpleTableMoreActionType.column: _reorderColumn(); case SimpleTableMoreActionType.row: _reorderRow(); } simpleTableContext.reorderingOffset.value = Offset.zero; switch (type) { case SimpleTableMoreActionType.column: simpleTableContext.isReorderingColumn.value = (false, -1); break; case SimpleTableMoreActionType.row: simpleTableContext.isReorderingRow.value = (false, -1); break; } } void _reorderColumn() { final fromIndex = simpleTableContext.isReorderingColumn.value.$2; final toIndex = simpleTableContext.hoveringTableCell.value?.columnIndex; if (toIndex == null) { return; } editorState.reorderColumn( node, fromIndex: fromIndex, toIndex: toIndex, ); } void _reorderRow() { final fromIndex = simpleTableContext.isReorderingRow.value.$2; final toIndex = simpleTableContext.hoveringTableCell.value?.rowIndex; if (toIndex == null) { return; } editorState.reorderRow( node, fromIndex: fromIndex, toIndex: toIndex, ); } } class SimpleTableReorderButton extends StatelessWidget { const SimpleTableReorderButton({ super.key, required this.isShowingMenu, required this.type, }); final ValueNotifier isShowingMenu; final SimpleTableMoreActionType type; @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: isShowingMenu, builder: (context, isShowingMenu, child) { return MouseRegion( cursor: SystemMouseCursors.click, child: Container( decoration: BoxDecoration( color: isShowingMenu ? context.simpleTableMoreActionHoverColor : Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(8.0), border: Border.all( color: context.simpleTableMoreActionBorderColor, ), ), height: 16.0, width: 16.0, child: FlowySvg( type.reorderIconSvg, color: isShowingMenu ? Colors.white : null, size: const Size.square(16.0), ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; import '_desktop_simple_table_widget.dart'; import '_mobile_simple_table_widget.dart'; class SimpleTableWidget extends StatefulWidget { const SimpleTableWidget({ super.key, required this.simpleTableContext, required this.node, this.enableAddColumnButton = true, this.enableAddRowButton = true, this.enableAddColumnAndRowButton = true, this.enableHoverEffect = true, this.isFeedback = false, this.alwaysDistributeColumnWidths = false, }); /// The node of the table. /// /// Its type must be [SimpleTableBlockKeys.type]. final Node node; /// The context of the simple table. final SimpleTableContext simpleTableContext; /// Whether to show the add column button. /// /// For the feedback widget builder, it should be false. final bool enableAddColumnButton; /// Whether to show the add row button. /// /// For the feedback widget builder, it should be false. final bool enableAddRowButton; /// Whether to show the add column and row button. /// /// For the feedback widget builder, it should be false. final bool enableAddColumnAndRowButton; /// Whether to enable the hover effect. /// /// For the feedback widget builder, it should be false. final bool enableHoverEffect; /// Whether the widget is a feedback widget. final bool isFeedback; /// Whether the columns should ignore their widths and fill available space final bool alwaysDistributeColumnWidths; @override State createState() => _SimpleTableWidgetState(); } class _SimpleTableWidgetState extends State { @override Widget build(BuildContext context) { return UniversalPlatform.isDesktop ? DesktopSimpleTableWidget( simpleTableContext: widget.simpleTableContext, node: widget.node, enableAddColumnButton: widget.enableAddColumnButton, enableAddRowButton: widget.enableAddRowButton, enableAddColumnAndRowButton: widget.enableAddColumnAndRowButton, enableHoverEffect: widget.enableHoverEffect, isFeedback: widget.isFeedback, alwaysDistributeColumnWidths: widget.alwaysDistributeColumnWidths, ) : MobileSimpleTableWidget( simpleTableContext: widget.simpleTableContext, node: widget.node, enableAddColumnButton: widget.enableAddColumnButton, enableAddRowButton: widget.enableAddRowButton, enableAddColumnAndRowButton: widget.enableAddColumnAndRowButton, enableHoverEffect: widget.enableHoverEffect, isFeedback: widget.isFeedback, alwaysDistributeColumnWidths: widget.alwaysDistributeColumnWidths, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/widgets.dart ================================================ export 'simple_table_action_sheet.dart'; export 'simple_table_add_column_and_row_button.dart'; export 'simple_table_add_column_button.dart'; export 'simple_table_add_row_button.dart'; export 'simple_table_align_button.dart'; export 'simple_table_background_menu.dart'; export 'simple_table_basic_button.dart'; export 'simple_table_border_builder.dart'; export 'simple_table_bottom_sheet.dart'; export 'simple_table_column_resize_handle.dart'; export 'simple_table_divider.dart'; export 'simple_table_more_action_popup.dart'; export 'simple_table_reorder_button.dart'; export 'simple_table_widget.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart ================================================ import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu.dart'; import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; typedef SlashMenuItemsBuilder = List Function( EditorState editorState, Node node, ); /// Show the slash menu /// /// - support /// - desktop /// final CharacterShortcutEvent appFlowySlashCommand = CharacterShortcutEvent( key: 'show the slash menu', character: '/', handler: (editorState) async => _showSlashMenu( editorState, itemsBuilder: (_, __) => standardSelectionMenuItems, supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, ), ); CharacterShortcutEvent customAppFlowySlashCommand({ required SlashMenuItemsBuilder itemsBuilder, bool shouldInsertSlash = true, bool deleteKeywordsByDefault = false, bool singleColumn = true, SelectionMenuStyle style = SelectionMenuStyle.light, required Set supportSlashMenuNodeTypes, }) { return CharacterShortcutEvent( key: 'show the slash menu', character: '/', handler: (editorState) => _showSlashMenu( editorState, shouldInsertSlash: shouldInsertSlash, deleteKeywordsByDefault: deleteKeywordsByDefault, singleColumn: singleColumn, style: style, supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, itemsBuilder: itemsBuilder, ), ); } SelectionMenuService? _selectionMenuService; Future _showSlashMenu( EditorState editorState, { required SlashMenuItemsBuilder itemsBuilder, bool shouldInsertSlash = true, bool singleColumn = true, bool deleteKeywordsByDefault = false, SelectionMenuStyle style = SelectionMenuStyle.light, required Set supportSlashMenuNodeTypes, }) async { final selection = editorState.selection; if (selection == null) { return false; } // delete the selection if (!selection.isCollapsed) { await editorState.deleteSelection(selection); } final afterSelection = editorState.selection; if (afterSelection == null || !afterSelection.isCollapsed) { assert(false, 'the selection should be collapsed'); return true; } final node = editorState.getNodeAtPath(selection.start.path); // only enable in white-list nodes if (node == null || !_isSupportSlashMenuNode(node, supportSlashMenuNodeTypes)) { return false; } final items = itemsBuilder(editorState, node); // insert the slash character if (shouldInsertSlash) { keepEditorFocusNotifier.increase(); await editorState.insertTextAtPosition('/', position: selection.start); } // show the slash menu final context = editorState.getNodeAtPath(selection.start.path)?.context; if (context != null && context.mounted) { final isLight = Theme.of(context).brightness == Brightness.light; _selectionMenuService?.dismiss(); _selectionMenuService = UniversalPlatform.isMobile ? MobileSelectionMenu( context: context, editorState: editorState, selectionMenuItems: items, deleteSlashByDefault: shouldInsertSlash, deleteKeywordsByDefault: deleteKeywordsByDefault, singleColumn: singleColumn, style: isLight ? MobileSelectionMenuStyle.light : MobileSelectionMenuStyle.dark, startOffset: editorState.selection?.start.offset ?? 0, ) : SelectionMenu( context: context, editorState: editorState, selectionMenuItems: items, deleteSlashByDefault: shouldInsertSlash, deleteKeywordsByDefault: deleteKeywordsByDefault, singleColumn: singleColumn, style: style, ); // disable the keyboard service editorState.service.keyboardService?.disable(); await _selectionMenuService?.show(); // enable the keyboard service editorState.service.keyboardService?.enable(); } if (shouldInsertSlash) { WidgetsBinding.instance.addPostFrameCallback( (timeStamp) => keepEditorFocusNotifier.decrease(), ); } return true; } bool _isSupportSlashMenuNode( Node node, Set supportSlashMenuNodeWhiteList, ) { // Check if current node type is supported if (!supportSlashMenuNodeWhiteList.contains(node.type)) { return false; } // If node has a parent and level > 1, recursively check parent nodes if (node.level > 1 && node.parent != null) { return _isSupportSlashMenuNode( node.parent!, supportSlashMenuNodeWhiteList, ); } return true; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/ai_writer_item.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'ai', 'openai', 'writer', 'ai writer', 'autogenerator', ]; SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem( getName: LocaleKeys.document_slashMenu_name_aiWriter.tr, keywords: [ ..._keywords, LocaleKeys.document_slashMenu_name_aiWriter.tr(), ], handler: (editorState, _, __) async => _insertAiWriter(editorState, AiWriterCommand.userQuestion), icon: (_, isSelected, style) => SelectableSvgWidget( data: AiWriterCommand.userQuestion.icon, isSelected: isSelected, style: style, ), nameBuilder: slashMenuItemNameBuilder, ); SelectionMenuItem continueWritingSlashMenuItem = SelectionMenuItem( getName: LocaleKeys.document_plugins_aiWriter_continueWriting.tr, keywords: [ ..._keywords, LocaleKeys.document_plugins_aiWriter_continueWriting.tr(), ], handler: (editorState, _, __) async => _insertAiWriter(editorState, AiWriterCommand.continueWriting), icon: (_, isSelected, style) => SelectableSvgWidget( data: AiWriterCommand.continueWriting.icon, isSelected: isSelected, style: style, ), nameBuilder: slashMenuItemNameBuilder, ); Future _insertAiWriter( EditorState editorState, AiWriterCommand action, ) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final node = editorState.getNodeAtPath(selection.end.path); if (node == null || node.delta == null) { return; } final newNode = aiWriterNode( selection: selection, command: action, ); // default insert after final path = node.path.next; final transaction = editorState.transaction ..insertNode(path, newNode) ..afterSelection = null; await editorState.apply( transaction, options: const ApplyOptions( recordUndo: false, inMemoryUpdate: true, ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/bulleted_list_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'bulleted list', 'list', 'unordered list', 'ul', ]; /// Bulleted list menu item final bulletedListSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_bulletedList.tr(), keywords: _keywords, handler: (editorState, _, __) { insertBulletedListAfterSelection(editorState); }, nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_bulleted_list_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/callout_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'callout', ]; /// Callout menu item SelectionMenuItem calloutSlashMenuItem = SelectionMenuItem.node( getName: LocaleKeys.document_plugins_callout.tr, keywords: _keywords, nodeBuilder: (editorState, context) => calloutNode(defaultColor: Colors.transparent), replace: (_, node) => node.delta?.isEmpty ?? false, updateSelection: (_, path, __, ___) { return Selection.single(path: path, startOffset: 0); }, nameBuilder: slashMenuItemNameBuilder, iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_callout_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/code_block_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; final _keywords = [ 'code', 'code block', 'codeblock', ]; // code block menu item SelectionMenuItem codeBlockSlashMenuItem = SelectionMenuItem.node( getName: () => LocaleKeys.document_slashMenu_name_code.tr(), keywords: _keywords, nodeBuilder: (_, __) => codeBlockNode(), replace: (_, node) => node.delta?.isEmpty ?? false, nameBuilder: slashMenuItemNameBuilder, iconBuilder: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_code_block_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/database_items.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _gridKeywords = ['grid', 'database']; final _kanbanKeywords = ['board', 'kanban', 'database']; final _calendarKeywords = ['calendar', 'database']; final _linkedDocKeywords = [ 'page', 'notes', 'referenced page', 'referenced document', 'referenced database', 'link to database', 'link to document', 'link to page', 'link to grid', 'link to board', 'link to calendar', ]; final _linkedGridKeywords = [ 'referenced', 'grid', 'database', 'linked', ]; final _linkedKanbanKeywords = [ 'referenced', 'board', 'kanban', 'linked', ]; final _linkedCalendarKeywords = [ 'referenced', 'calendar', 'database', 'linked', ]; /// Grid menu item SelectionMenuItem gridSlashMenuItem(DocumentBloc documentBloc) { return SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_grid.tr(), keywords: _gridKeywords, handler: (editorState, menuService, context) async { // create the view inside current page final parentViewId = documentBloc.documentId; final value = await ViewBackendService.createView( parentViewId: parentViewId, name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layoutType: ViewLayoutPB.Grid, ); value.map((r) => editorState.insertInlinePage(parentViewId, r)); }, nameBuilder: slashMenuItemNameBuilder, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_grid_s, isSelected: onSelected, style: style, ), ); } SelectionMenuItem kanbanSlashMenuItem(DocumentBloc documentBloc) { return SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_kanban.tr(), keywords: _kanbanKeywords, handler: (editorState, menuService, context) async { // create the view inside current page final parentViewId = documentBloc.documentId; final value = await ViewBackendService.createView( parentViewId: parentViewId, name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layoutType: ViewLayoutPB.Board, ); value.map((r) => editorState.insertInlinePage(parentViewId, r)); }, nameBuilder: slashMenuItemNameBuilder, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_kanban_s, isSelected: onSelected, style: style, ), ); } SelectionMenuItem calendarSlashMenuItem(DocumentBloc documentBloc) { return SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_calendar.tr(), keywords: _calendarKeywords, handler: (editorState, menuService, context) async { // create the view inside current page final parentViewId = documentBloc.documentId; final value = await ViewBackendService.createView( parentViewId: parentViewId, name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layoutType: ViewLayoutPB.Calendar, ); value.map((r) => editorState.insertInlinePage(parentViewId, r)); }, nameBuilder: slashMenuItemNameBuilder, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_calendar_s, isSelected: onSelected, style: style, ), ); } // linked doc menu item final linkToPageSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_linkedDoc.tr(), keywords: _linkedDocKeywords, handler: (editorState, menuService, context) => showLinkToPageMenu( editorState, menuService, // enable database and document references insertPage: false, ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_doc_s, isSelected: isSelected, style: style, ), ); // linked grid & board & calendar menu item SelectionMenuItem referencedGridSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_linkedGrid.tr(), keywords: _linkedGridKeywords, handler: (editorState, menuService, context) => showLinkToPageMenu( editorState, menuService, pageType: ViewLayoutPB.Grid, ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_grid_s, isSelected: onSelected, style: style, ), ); SelectionMenuItem referencedKanbanSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_linkedKanban.tr(), keywords: _linkedKanbanKeywords, handler: (editorState, menuService, context) => showLinkToPageMenu( editorState, menuService, pageType: ViewLayoutPB.Board, ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_kanban_s, isSelected: onSelected, style: style, ), ); SelectionMenuItem referencedCalendarSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_linkedCalendar.tr(), keywords: _linkedCalendarKeywords, handler: (editorState, menuService, context) => showLinkToPageMenu( editorState, menuService, pageType: ViewLayoutPB.Calendar, ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, onSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_calendar_s, isSelected: onSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; final _keywords = [ 'insert date', 'date', 'time', 'reminder', 'schedule', ]; // date or reminder menu item SelectionMenuItem dateOrReminderSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), keywords: _keywords, handler: (editorState, _, __) async => editorState.insertDateReference(), nameBuilder: slashMenuItemNameBuilder, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_date_or_reminder_s, isSelected: isSelected, style: style, ), ); extension on EditorState { Future insertDateReference() async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { return; } final node = getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null) { return; } final transaction = this.transaction ..replaceText( node, selection.start.offset, 0, MentionBlockKeys.mentionChar, attributes: MentionBlockKeys.buildMentionDateAttributes( date: DateTime.now().toIso8601String(), reminderId: null, reminderOption: null, includeTime: false, ), ); await apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/divider_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'divider', 'separator', 'line', 'break', 'horizontal line', ]; /// Divider menu item final dividerSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_divider.tr(), keywords: _keywords, handler: (editorState, _, __) async => editorState.insertDividerBlock(), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_divider_s, isSelected: isSelected, style: style, ), ); extension on EditorState { Future insertDividerBlock() async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { return; } final path = selection.end.path; final node = getNodeAtPath(path); final delta = node?.delta; if (node == null || delta == null) { return; } final insertedPath = delta.isEmpty ? path : path.next; final transaction = this.transaction ..insertNode(insertedPath, dividerNode()) ..insertNode(insertedPath, paragraphNode()) ..afterSelection = Selection.collapsed(Position(path: insertedPath.next)); await apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/emoji_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/emoji/emoji_actions_command.dart'; import 'package:appflowy/plugins/emoji/emoji_menu.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'emoji', 'reaction', 'emoticon', ]; // emoji menu item SelectionMenuItem emojiSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_emoji.tr(), keywords: _keywords, handler: (editorState, menuService, context) => editorState.showEmojiPicker( context, menuService: menuService, ), nameBuilder: slashMenuItemNameBuilder, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_emoji_picker_s, isSelected: isSelected, style: style, ), ); extension on EditorState { Future showEmojiPicker( BuildContext context, { required SelectionMenuService menuService, }) async { final container = Overlay.of(context); menuService.dismiss(); if (UniversalPlatform.isMobile || selection == null) { return; } final node = getNodeAtPath(selection!.end.path); final delta = node?.delta; if (node == null || delta == null || node.type == CodeBlockKeys.type) { return; } emojiMenuService = EmojiMenu(editorState: this, overlay: container); emojiMenuService?.show(''); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/file_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'slash_menu_items.dart'; final _keywords = [ 'file upload', 'pdf', 'zip', 'archive', 'upload', 'attachment', ]; // file menu item SelectionMenuItem fileSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_file.tr(), keywords: _keywords, handler: (editorState, _, __) async => editorState.insertFileBlock(), nameBuilder: slashMenuItemNameBuilder, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_file_s, isSelected: isSelected, style: style, ), ); extension on EditorState { Future insertFileBlock() async { final fileGlobalKey = GlobalKey(); await insertEmptyFileBlock(fileGlobalKey); WidgetsBinding.instance.addPostFrameCallback((_) { fileGlobalKey.currentState?.controller.show(); }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/heading_items.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _h1Keywords = [ 'heading 1', 'h1', 'heading1', ]; final _h2Keywords = [ 'heading 2', 'h2', 'heading2', ]; final _h3Keywords = [ 'heading 3', 'h3', 'heading3', ]; final _toggleH1Keywords = [ 'toggle heading 1', 'toggle h1', 'toggle heading1', 'toggleheading1', 'toggleh1', ]; final _toggleH2Keywords = [ 'toggle heading 2', 'toggle h2', 'toggle heading2', 'toggleheading2', 'toggleh2', ]; final _toggleH3Keywords = [ 'toggle heading 3', 'toggle h3', 'toggle heading3', 'toggleheading3', 'toggleh3', ]; // heading 1 - 3 menu items final heading1SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_heading1.tr(), keywords: _h1Keywords, handler: (editorState, _, __) async => insertHeadingAfterSelection( editorState, 1, ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_h1_s, isSelected: isSelected, style: style, ), ); final heading2SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_heading2.tr(), keywords: _h2Keywords, handler: (editorState, _, __) async => insertHeadingAfterSelection( editorState, 2, ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_h2_s, isSelected: isSelected, style: style, ), ); final heading3SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_heading3.tr(), keywords: _h3Keywords, handler: (editorState, _, __) async => insertHeadingAfterSelection( editorState, 3, ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_h3_s, isSelected: isSelected, style: style, ), ); // toggle heading 1 menu item // heading 1 - 3 menu items final toggleHeading1SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), keywords: _toggleH1Keywords, handler: (editorState, _, __) async => insertNodeAfterSelection( editorState, toggleHeadingNode(), ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.toggle_heading1_s, isSelected: isSelected, style: style, ), ); final toggleHeading2SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), keywords: _toggleH2Keywords, handler: (editorState, _, __) async => insertNodeAfterSelection( editorState, toggleHeadingNode(level: 2), ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.toggle_heading2_s, isSelected: isSelected, style: style, ), ); final toggleHeading3SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), keywords: _toggleH3Keywords, handler: (editorState, _, __) async => insertNodeAfterSelection( editorState, toggleHeadingNode(level: 3), ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.toggle_heading3_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'image', 'photo', 'picture', 'img', ]; /// Image menu item final imageSlashMenuItem = buildImageSlashMenuItem(); SelectionMenuItem buildImageSlashMenuItem({FlowySvgData? svg}) => SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_image.tr(), keywords: _keywords, handler: (editorState, _, __) async => editorState.insertImageBlock(), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: svg ?? FlowySvgs.slash_menu_icon_image_s, isSelected: isSelected, style: style, ), ); extension on EditorState { Future insertImageBlock() async { // use the key to retrieve the state of the image block to show the popover automatically final imagePlaceholderKey = GlobalKey(); await insertEmptyImageBlock(imagePlaceholderKey); WidgetsBinding.instance.addPostFrameCallback((_) { imagePlaceholderKey.currentState?.controller.show(); }); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/math_equation_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'tex', 'latex', 'katex', 'math equation', 'formula', ]; // math equation SelectionMenuItem mathEquationSlashMenuItem = SelectionMenuItem.node( getName: () => LocaleKeys.document_slashMenu_name_mathEquation.tr(), keywords: _keywords, nodeBuilder: (editorState, _) => mathEquationNode(), replace: (_, node) => node.delta?.isEmpty ?? false, updateSelection: (editorState, path, __, ___) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { final mathEquationState = editorState.getNodeAtPath(path)?.key.currentState; if (mathEquationState != null && mathEquationState is MathEquationBlockComponentWidgetState) { mathEquationState.showEditingDialog(); } }); return null; }, nameBuilder: slashMenuItemNameBuilder, iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_math_equation_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_items.dart'; final List mobileItems = [ textStyleMobileSlashMenuItem, listMobileSlashMenuItem, toggleListMobileSlashMenuItem, fileAndMediaMobileSlashMenuItem, mobileTableSlashMenuItem, visualsMobileSlashMenuItem, dateOrReminderSlashMenuItem, buildSubpageSlashMenuItem(svg: FlowySvgs.type_page_m), advancedMobileSlashMenuItem, ]; final List mobileItemsInTale = [ textStyleMobileSlashMenuItem, listMobileSlashMenuItem, toggleListMobileSlashMenuItem, fileAndMediaMobileSlashMenuItem, visualsMobileSlashMenuItem, dateOrReminderSlashMenuItem, buildSubpageSlashMenuItem(svg: FlowySvgs.type_page_m), advancedMobileSlashMenuItem, ]; SelectionMenuItemHandler _handler = (_, __, ___) {}; MobileSelectionMenuItem textStyleMobileSlashMenuItem = MobileSelectionMenuItem( getName: LocaleKeys.document_slashMenu_name_textStyle.tr, handler: _handler, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_text_s, isSelected: isSelected, style: style, ), nameBuilder: slashMenuItemNameBuilder, children: [ paragraphSlashMenuItem, heading1SlashMenuItem, heading2SlashMenuItem, heading3SlashMenuItem, ], ); MobileSelectionMenuItem listMobileSlashMenuItem = MobileSelectionMenuItem( getName: LocaleKeys.document_slashMenu_name_list.tr, handler: _handler, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_bulleted_list_s, isSelected: isSelected, style: style, ), nameBuilder: slashMenuItemNameBuilder, children: [ todoListSlashMenuItem, bulletedListSlashMenuItem, numberedListSlashMenuItem, ], ); MobileSelectionMenuItem toggleListMobileSlashMenuItem = MobileSelectionMenuItem( getName: LocaleKeys.document_slashMenu_name_toggle.tr, handler: _handler, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_toggle_s, isSelected: isSelected, style: style, ), nameBuilder: slashMenuItemNameBuilder, children: [ toggleListSlashMenuItem, toggleHeading1SlashMenuItem, toggleHeading2SlashMenuItem, toggleHeading3SlashMenuItem, ], ); MobileSelectionMenuItem fileAndMediaMobileSlashMenuItem = MobileSelectionMenuItem( getName: LocaleKeys.document_slashMenu_name_fileAndMedia.tr, handler: _handler, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_file_s, isSelected: isSelected, style: style, ), nameBuilder: slashMenuItemNameBuilder, children: [ buildImageSlashMenuItem(svg: FlowySvgs.slash_menu_image_m), photoGallerySlashMenuItem, fileSlashMenuItem, ], ); MobileSelectionMenuItem visualsMobileSlashMenuItem = MobileSelectionMenuItem( getName: LocaleKeys.document_slashMenu_name_visuals.tr, handler: _handler, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_visuals_s, isSelected: isSelected, style: style, ), nameBuilder: slashMenuItemNameBuilder, children: [ calloutSlashMenuItem, dividerSlashMenuItem, quoteSlashMenuItem, ], ); MobileSelectionMenuItem advancedMobileSlashMenuItem = MobileSelectionMenuItem( getName: LocaleKeys.document_slashMenu_name_advanced.tr, handler: _handler, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.drag_element_s, isSelected: isSelected, style: style, ), nameBuilder: slashMenuItemNameBuilder, children: [ codeBlockSlashMenuItem, mathEquationSlashMenuItem, ], ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/numbered_list_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'numbered list', 'list', 'ordered list', 'ol', ]; /// Numbered list menu item final numberedListSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_numberedList.tr(), keywords: _keywords, handler: (editorState, _, __) async => insertNumberedListAfterSelection( editorState, ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_numbered_list_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/outline_item.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'outline', 'table of contents', 'toc', 'tableofcontents', ]; /// Outline menu item SelectionMenuItem outlineSlashMenuItem = SelectionMenuItem( getName: LocaleKeys.document_selectionMenu_outline.tr, keywords: _keywords, handler: (editorState, _, __) async => editorState.insertOutline(), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, onSelected, style) { return Icon( Icons.list_alt, color: onSelected ? style.selectionMenuItemSelectedIconColor : style.selectionMenuItemIconColor, size: 16.0, ); }, ); extension on EditorState { Future insertOutline() async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { return; } final node = getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null) { return; } final transaction = this.transaction; final bReplace = node.delta?.isEmpty ?? false; //default insert after var path = node.path.next; if (bReplace) { path = node.path; } final nextNode = getNodeAtPath(path.next); transaction ..insertNodes( path, [ outlineBlockNode(), if (nextNode == null || nextNode.delta == null) paragraphNode(), ], ) ..afterSelection = Selection.collapsed( Position(path: path.next), ); if (bReplace) { transaction.deleteNode(node); } await apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/paragraph_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'text', 'paragraph', ]; // paragraph menu item final paragraphSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_text.tr(), keywords: _keywords, handler: (editorState, _, __) async => insertNodeAfterSelection( editorState, paragraphNode(), ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_text_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/photo_gallery_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'slash_menu_items.dart'; final _keywords = [ LocaleKeys.document_plugins_photoGallery_imageKeyword.tr(), LocaleKeys.document_plugins_photoGallery_imageGalleryKeyword.tr(), LocaleKeys.document_plugins_photoGallery_photoKeyword.tr(), LocaleKeys.document_plugins_photoGallery_photoBrowserKeyword.tr(), LocaleKeys.document_plugins_photoGallery_galleryKeyword.tr(), ]; // photo gallery menu item SelectionMenuItem photoGallerySlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_photoGallery.tr(), keywords: _keywords, handler: (editorState, _, __) async => editorState.insertPhotoGalleryBlock(), nameBuilder: slashMenuItemNameBuilder, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_photo_gallery_s, isSelected: isSelected, style: style, ), ); extension on EditorState { Future insertPhotoGalleryBlock() async { final imagePlaceholderKey = GlobalKey(); await insertEmptyMultiImageBlock(imagePlaceholderKey); WidgetsBinding.instance.addPostFrameCallback( (_) => imagePlaceholderKey.currentState?.controller.show(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/quote_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'quote', 'refer', 'blockquote', 'citation', ]; /// Quote menu item final quoteSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_quote.tr(), keywords: _keywords, handler: (editorState, _, __) async => insertQuoteAfterSelection(editorState), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_quote_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_columns_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; final _baseKeywords = [ 'columns', 'column block', ]; final _twoColumnsKeywords = [ ..._baseKeywords, 'two columns', '2 columns', ]; final _threeColumnsKeywords = [ ..._baseKeywords, 'three columns', '3 columns', ]; final _fourColumnsKeywords = [ ..._baseKeywords, 'four columns', '4 columns', ]; // 2 columns menu item SelectionMenuItem twoColumnsSlashMenuItem = SelectionMenuItem.node( getName: () => LocaleKeys.document_slashMenu_name_twoColumns.tr(), keywords: _twoColumnsKeywords, nodeBuilder: (editorState, __) => _buildColumnsNode(editorState, 2), replace: (_, node) => node.delta?.isEmpty ?? false, nameBuilder: slashMenuItemNameBuilder, iconBuilder: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_two_columns_s, isSelected: isSelected, style: style, ), updateSelection: (_, path, __, ___) { return Selection.single( path: path.child(0).child(0), startOffset: 0, ); }, ); // 3 columns menu item SelectionMenuItem threeColumnsSlashMenuItem = SelectionMenuItem.node( getName: () => LocaleKeys.document_slashMenu_name_threeColumns.tr(), keywords: _threeColumnsKeywords, nodeBuilder: (editorState, __) => _buildColumnsNode(editorState, 3), replace: (_, node) => node.delta?.isEmpty ?? false, nameBuilder: slashMenuItemNameBuilder, iconBuilder: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_three_columns_s, isSelected: isSelected, style: style, ), updateSelection: (_, path, __, ___) { return Selection.single( path: path.child(0).child(0), startOffset: 0, ); }, ); // 4 columns menu item SelectionMenuItem fourColumnsSlashMenuItem = SelectionMenuItem.node( getName: () => LocaleKeys.document_slashMenu_name_fourColumns.tr(), keywords: _fourColumnsKeywords, nodeBuilder: (editorState, __) => _buildColumnsNode(editorState, 4), replace: (_, node) => node.delta?.isEmpty ?? false, nameBuilder: slashMenuItemNameBuilder, iconBuilder: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_four_columns_s, isSelected: isSelected, style: style, ), updateSelection: (_, path, __, ___) { return Selection.single( path: path.child(0).child(0), startOffset: 0, ); }, ); Node _buildColumnsNode(EditorState editorState, int columnCount) { return simpleColumnsNode( columnCount: columnCount, ratio: 1.0 / columnCount, ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_table_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'table', 'rows', 'columns', 'data', ]; // table menu item SelectionMenuItem tableSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_table.tr(), keywords: _keywords, handler: (editorState, _, __) async => editorState.insertSimpleTable(), nameBuilder: slashMenuItemNameBuilder, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_simple_table_s, isSelected: isSelected, style: style, ), ); SelectionMenuItem mobileTableSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_simpleTable.tr(), keywords: _keywords, handler: (editorState, _, __) async => editorState.insertSimpleTable(), nameBuilder: slashMenuItemNameBuilder, icon: (_, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_simple_table_s, isSelected: isSelected, style: style, ), ); extension on EditorState { Future insertSimpleTable() async { final selection = this.selection; if (selection == null || !selection.isCollapsed) { return; } final currentNode = getNodeAtPath(selection.end.path); if (currentNode == null) { return; } // create a simple table with 2 columns and 2 rows final tableNode = createSimpleTableBlockNode( columnCount: 2, rowCount: 2, ); final transaction = this.transaction; final delta = currentNode.delta; if (delta != null && delta.isEmpty) { final path = selection.end.path; transaction ..insertNode(path, tableNode) ..deleteNode(currentNode); transaction.afterSelection = Selection.collapsed( Position( // table -> row -> cell -> paragraph path: path + [0, 0, 0], ), ); } else { final path = selection.end.path.next; transaction.insertNode(path, tableNode); transaction.afterSelection = Selection.collapsed( Position( // table -> row -> cell -> paragraph path: path + [0, 0, 0], ), ); } await apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; /// Builder function for the slash menu item. Widget slashMenuItemNameBuilder( String name, SelectionMenuStyle style, bool isSelected, ) { return SlashMenuItemNameBuilder( name: name, style: style, isSelected: isSelected, ); } Widget slashMenuItemIconBuilder( FlowySvgData data, bool isSelected, SelectionMenuStyle style, ) { return SelectableSvgWidget( data: data, isSelected: isSelected, style: style, ); } /// Build the name of the slash menu item. class SlashMenuItemNameBuilder extends StatelessWidget { const SlashMenuItemNameBuilder({ super.key, required this.name, required this.style, required this.isSelected, }); /// The name of the slash menu item. final String name; /// The style of the slash menu item. final SelectionMenuStyle style; /// Whether the slash menu item is selected. final bool isSelected; @override Widget build(BuildContext context) { final isMobile = UniversalPlatform.isMobile; return FlowyText.regular( name, fontSize: isMobile ? 16.0 : 12.0, figmaLineHeight: 15.0, color: isSelected ? style.selectionMenuItemSelectedTextColor : style.selectionMenuItemTextColor, ); } } /// Build the icon of the slash menu item. class SlashMenuIconBuilder extends StatelessWidget { const SlashMenuIconBuilder({ super.key, required this.data, required this.isSelected, required this.style, }); /// The data of the icon. final FlowySvgData data; /// Whether the slash menu item is selected. final bool isSelected; /// The style of the slash menu item. final SelectionMenuStyle style; @override Widget build(BuildContext context) { final isMobile = UniversalPlatform.isMobile; return SelectableSvgWidget( data: data, isSelected: isSelected, size: isMobile ? Size.square(20) : null, style: style, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_items.dart ================================================ export 'ai_writer_item.dart'; export 'bulleted_list_item.dart'; export 'callout_item.dart'; export 'code_block_item.dart'; export 'database_items.dart'; export 'date_item.dart'; export 'divider_item.dart'; export 'emoji_item.dart'; export 'file_item.dart'; export 'heading_items.dart'; export 'image_item.dart'; export 'math_equation_item.dart'; export 'numbered_list_item.dart'; export 'outline_item.dart'; export 'paragraph_item.dart'; export 'photo_gallery_item.dart'; export 'quote_item.dart'; export 'simple_columns_item.dart'; export 'simple_table_item.dart'; export 'slash_menu_item_builder.dart'; export 'sub_page_item.dart'; export 'todo_list_item.dart'; export 'toggle_list_item.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'slash_menu_items.dart'; final _keywords = [ LocaleKeys.document_slashMenu_subPage_keyword1.tr(), LocaleKeys.document_slashMenu_subPage_keyword2.tr(), LocaleKeys.document_slashMenu_subPage_keyword3.tr(), LocaleKeys.document_slashMenu_subPage_keyword4.tr(), LocaleKeys.document_slashMenu_subPage_keyword5.tr(), LocaleKeys.document_slashMenu_subPage_keyword6.tr(), LocaleKeys.document_slashMenu_subPage_keyword7.tr(), LocaleKeys.document_slashMenu_subPage_keyword8.tr(), ]; // Sub-page menu item SelectionMenuItem subPageSlashMenuItem = buildSubpageSlashMenuItem(); SelectionMenuItem buildSubpageSlashMenuItem({FlowySvgData? svg}) => SelectionMenuItem.node( getName: () => LocaleKeys.document_slashMenu_subPage_name.tr(), keywords: _keywords, updateSelection: (editorState, path, __, ___) { final context = editorState.document.root.context; if (context != null) { final isInDatabase = context.read().isInDatabaseRowPage; if (isInDatabase) { Navigator.of(context).pop(); } } return Selection.collapsed(Position(path: path)); }, replace: (_, node) => node.delta?.isEmpty ?? false, nodeBuilder: (_, __) => subPageNode(), nameBuilder: slashMenuItemNameBuilder, iconBuilder: (_, isSelected, style) => SelectableSvgWidget( data: svg ?? FlowySvgs.insert_document_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/todo_list_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'checkbox', 'todo', 'list', 'to-do', 'task', ]; final todoListSlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.editor_checkbox.tr(), keywords: _keywords, handler: (editorState, _, __) async => insertCheckboxAfterSelection( editorState, ), nameBuilder: slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_checkbox_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/toggle_list_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'slash_menu_item_builder.dart'; final _keywords = [ 'collapsed list', 'toggle list', 'list', 'dropdown', ]; // toggle menu item SelectionMenuItem toggleListSlashMenuItem = SelectionMenuItem.node( getName: () => LocaleKeys.document_slashMenu_name_toggleList.tr(), keywords: _keywords, nodeBuilder: (editorState, _) => toggleListBlockNode(), replace: (_, node) => node.delta?.isEmpty ?? false, nameBuilder: slashMenuItemNameBuilder, iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( data: FlowySvgs.slash_menu_icon_toggle_s, isSelected: isSelected, style: style, ), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart ================================================ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:universal_platform/universal_platform.dart'; import 'slash_menu_items/mobile_items.dart'; import 'slash_menu_items/slash_menu_items.dart'; /// Build slash menu items /// List slashMenuItemsBuilder({ bool isLocalMode = false, DocumentBloc? documentBloc, EditorState? editorState, Node? node, ViewPB? view, }) { final isInTable = node != null && node.parentTableCellNode != null; final isMobile = UniversalPlatform.isMobile; bool isEmpty = false; if (editorState == null || editorState.isEmptyForContinueWriting()) { if (view == null || view.name.isEmpty) { isEmpty = true; } } if (isMobile) { if (isInTable) { return mobileItemsInTale; } else { return mobileItems; } } else { if (isInTable) { return _simpleTableSlashMenuItems(); } else { return _defaultSlashMenuItems( isLocalMode: isLocalMode, documentBloc: documentBloc, isEmpty: isEmpty, ); } } } /// The default slash menu items are used in the text-based block. /// /// Except for the simple table block, the slash menu items in the table block are /// built by the `tableSlashMenuItem` function. /// If in local mode, disable the ai writer feature /// /// The linked database relies on the documentBloc, so it's required to pass in /// the documentBloc when building the slash menu items. If the documentBloc is /// not provided, the linked database items will be disabled. /// /// List _defaultSlashMenuItems({ bool isLocalMode = false, DocumentBloc? documentBloc, bool isEmpty = false, }) { return [ // ai if (!isEmpty) continueWritingSlashMenuItem, aiWriterSlashMenuItem, paragraphSlashMenuItem, // heading 1-3 heading1SlashMenuItem, heading2SlashMenuItem, heading3SlashMenuItem, // image imageSlashMenuItem, // list bulletedListSlashMenuItem, numberedListSlashMenuItem, todoListSlashMenuItem, // divider dividerSlashMenuItem, // quote quoteSlashMenuItem, // simple table tableSlashMenuItem, // link to page linkToPageSlashMenuItem, // columns // 2-4 columns twoColumnsSlashMenuItem, threeColumnsSlashMenuItem, fourColumnsSlashMenuItem, // grid if (documentBloc != null) gridSlashMenuItem(documentBloc), referencedGridSlashMenuItem, // kanban if (documentBloc != null) kanbanSlashMenuItem(documentBloc), referencedKanbanSlashMenuItem, // calendar if (documentBloc != null) calendarSlashMenuItem(documentBloc), referencedCalendarSlashMenuItem, // callout calloutSlashMenuItem, // outline outlineSlashMenuItem, // math equation mathEquationSlashMenuItem, // code block codeBlockSlashMenuItem, // toggle list - toggle headings toggleListSlashMenuItem, toggleHeading1SlashMenuItem, toggleHeading2SlashMenuItem, toggleHeading3SlashMenuItem, // emoji emojiSlashMenuItem, // date or reminder dateOrReminderSlashMenuItem, // photo gallery photoGallerySlashMenuItem, // file fileSlashMenuItem, // sub page subPageSlashMenuItem, ]; } /// The slash menu items in the simple table block. /// /// There're some blocks should be excluded in the slash menu items. /// /// - Database Items /// - Image Gallery List _simpleTableSlashMenuItems() { return [ paragraphSlashMenuItem, // heading 1-3 heading1SlashMenuItem, heading2SlashMenuItem, heading3SlashMenuItem, // image imageSlashMenuItem, // list bulletedListSlashMenuItem, numberedListSlashMenuItem, todoListSlashMenuItem, // divider dividerSlashMenuItem, // quote quoteSlashMenuItem, // link to page linkToPageSlashMenuItem, // callout calloutSlashMenuItem, // math equation mathEquationSlashMenuItem, // code block codeBlockSlashMenuItem, // toggle list - toggle headings toggleListSlashMenuItem, toggleHeading1SlashMenuItem, toggleHeading2SlashMenuItem, toggleHeading3SlashMenuItem, // emoji emojiSlashMenuItem, // date or reminder dateOrReminderSlashMenuItem, // file fileSlashMenuItem, // sub page subPageSlashMenuItem, ]; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class SubPageBlockTransactionHandler extends BlockTransactionHandler { SubPageBlockTransactionHandler() : super(blockType: SubPageBlockKeys.type); final List _beingCreated = []; @override void onRedo( BuildContext context, EditorState editorState, List before, List after, ) { _handleUndoRedo(context, editorState, before, after); } @override void onUndo( BuildContext context, EditorState editorState, List before, List after, ) { _handleUndoRedo(context, editorState, before, after); } void _handleUndoRedo( BuildContext context, EditorState editorState, List before, List after, ) { final additions = after.where((e) => !before.contains(e)).toList(); final removals = before.where((e) => !after.contains(e)).toList(); // Removals goes to trash for (final node in removals) { _subPageDeleted(context, editorState, node); } // Additions are moved to this view for (final node in additions) { _subPageAdded(context, editorState, node); } } @override Future onTransaction( BuildContext context, EditorState editorState, List added, List removed, { bool isUndoRedo = false, bool isPaste = false, bool isDraggingNode = false, String? parentViewId, }) async { if (isDraggingNode) { return; } for (final node in removed) { if (!context.mounted) return; await _subPageDeleted(context, editorState, node); } for (final node in added) { if (!context.mounted) return; await _subPageAdded( context, editorState, node, isPaste: isPaste, parentViewId: parentViewId, ); } } Future _subPageDeleted( BuildContext context, EditorState editorState, Node node, ) async { if (node.type != blockType) { return; } final view = node.attributes[SubPageBlockKeys.viewId]; if (view == null) { return; } // We move the view to Trash final result = await ViewBackendService.deleteView(viewId: view); result.fold( (_) {}, (error) { Log.error(error); if (context.mounted) { showSnapBar( context, LocaleKeys.document_plugins_subPage_errors_failedDeletePage.tr(), ); } }, ); } Future _subPageAdded( BuildContext context, EditorState editorState, Node node, { bool isPaste = false, String? parentViewId, }) async { if (node.type != blockType || _beingCreated.contains(node.id)) { return; } final viewId = node.attributes[SubPageBlockKeys.viewId]; if (viewId == null && parentViewId != null) { _beingCreated.add(node.id); // This is a new Node, we need to create the view final viewOrResult = await ViewBackendService.createView( layoutType: ViewLayoutPB.Document, parentViewId: parentViewId, name: '', ); await viewOrResult.fold( (view) async { final transaction = editorState.transaction ..updateNode(node, {SubPageBlockKeys.viewId: view.id}); await editorState .apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ) .then((_) async { editorState.reload(); // Open the new page if (UniversalPlatform.isDesktop) { getIt().openPlugin(view); } else { if (context.mounted) { await context.pushView(view); } } }); }, (error) async { Log.error(error); showSnapBar( context, LocaleKeys.document_plugins_subPage_errors_failedCreatePage.tr(), ); // Remove the node because it failed final transaction = editorState.transaction..deleteNode(node); await editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); }, ); _beingCreated.remove(node.id); } else if (isPaste) { final wasCut = node.attributes[SubPageBlockKeys.wasCut]; if (wasCut == true && parentViewId != null) { // Just in case, we try to put back from trash before moving await TrashService.putback(viewId); final viewOrResult = await ViewBackendService.moveViewV2( viewId: viewId, newParentId: parentViewId, prevViewId: null, ); viewOrResult.fold( (_) {}, (error) { Log.error(error); showSnapBar( context, LocaleKeys.document_plugins_subPage_errors_failedMovePage.tr(), ); }, ); } else { final viewId = node.attributes[SubPageBlockKeys.viewId]; if (viewId == null) { return; } final viewOrResult = await ViewBackendService.getView(viewId); return viewOrResult.fold( (view) async { final duplicatedViewOrResult = await ViewBackendService.duplicate( view: view, openAfterDuplicate: false, includeChildren: true, syncAfterDuplicate: true, parentViewId: parentViewId, ); return duplicatedViewOrResult.fold( (view) async { final transaction = editorState.transaction ..updateNode(node, { SubPageBlockKeys.viewId: view.id, SubPageBlockKeys.wasCut: false, SubPageBlockKeys.wasCopied: false, }); await editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); editorState.reload(); }, (error) { Log.error(error); if (context.mounted) { showSnapBar( context, LocaleKeys .document_plugins_subPage_errors_failedDuplicatePage .tr(), ); } }, ); }, (error) async { Log.error(error); final transaction = editorState.transaction..deleteNode(node); await editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); editorState.reload(); if (context.mounted) { showSnapBar( context, LocaleKeys .document_plugins_subPage_errors_failedDuplicateFindView .tr(), ); } }, ); } } else { // Try to restore from trash, and move to parent view await TrashService.putback(viewId); // Check if View needs to be moved if (parentViewId != null) { final view = pageMemorizer[viewId] ?? (await ViewBackendService.getView(viewId)).toNullable(); if (view == null) { return Log.error('View not found: $viewId'); } if (view.parentViewId == parentViewId) { return; } await ViewBackendService.moveViewV2( viewId: viewId, newParentId: parentViewId, prevViewId: null, ); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy/plugins/trash/application/trash_listener.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; Node subPageNode({String? viewId}) { return Node( type: SubPageBlockKeys.type, attributes: {SubPageBlockKeys.viewId: viewId}, ); } class SubPageBlockKeys { const SubPageBlockKeys._(); static const String type = 'sub_page'; /// The ID of the View which is being linked to. /// static const String viewId = "view_id"; /// Signifies whether the block was inserted after a Copy operation. /// static const String wasCopied = "was_copied"; /// Signifies whether the block was inserted after a Cut operation. /// static const String wasCut = "was_cut"; } class SubPageBlockComponentBuilder extends BlockComponentBuilder { SubPageBlockComponentBuilder({super.configuration}); @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return SubPageBlockComponent( key: node.key, node: node, showActions: showActions(node), configuration: configuration, actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), ); } @override BlockComponentValidate get validate => (node) => node.children.isEmpty; } class SubPageBlockComponent extends BlockComponentStatefulWidget { const SubPageBlockComponent({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), }); @override State createState() => SubPageBlockComponentState(); } class SubPageBlockComponentState extends State with SelectableMixin, BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @override Node get node => widget.node; RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; final subPageKey = GlobalKey(); ViewListener? viewListener; TrashListener? trashListener; Future? viewFuture; bool isHovering = false; bool isHandlingPaste = false; EditorState get editorState => context.read(); String? parentId; @override void initState() { super.initState(); final viewId = node.attributes[SubPageBlockKeys.viewId]; if (viewId != null) { viewFuture = fetchView(viewId); viewListener = ViewListener(viewId: viewId) ..start(onViewUpdated: onViewUpdated); trashListener = TrashListener()..start(trashUpdated: didUpdateTrash); } } @override void didUpdateWidget(SubPageBlockComponent oldWidget) { final viewId = node.attributes[SubPageBlockKeys.viewId]; final oldViewId = viewListener?.viewId ?? oldWidget.node.attributes[SubPageBlockKeys.viewId]; if (viewId != null && (viewId != oldViewId || viewListener == null)) { viewFuture = fetchView(viewId); viewListener?.stop(); viewListener = ViewListener(viewId: viewId) ..start(onViewUpdated: onViewUpdated); } super.didUpdateWidget(oldWidget); } void didUpdateTrash(FlowyResult, FlowyError> trashOrFailed) { final trashList = trashOrFailed.toNullable(); if (trashList == null) { return; } final viewId = node.attributes[SubPageBlockKeys.viewId]; if (trashList.any((t) => t.id == viewId)) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { final transaction = editorState.transaction..deleteNode(node); editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); } }); } } void onViewUpdated(ViewPB view) { pageMemorizer[view.id] = view; viewFuture = Future.value(view); editorState.reload(); if (parentId != view.parentViewId && parentId != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { final transaction = editorState.transaction..deleteNode(node); editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); } }); } } @override void dispose() { viewListener?.stop(); super.dispose(); } @override Widget build(BuildContext context) { return FutureBuilder( initialData: pageMemorizer[node.attributes[SubPageBlockKeys.viewId]], future: viewFuture, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const SizedBox.shrink(); } final view = snapshot.data; if (view == null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { final transaction = editorState.transaction..deleteNode(node); editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); } }); return const SizedBox.shrink(); } final textStyle = textStyleWithTextSpan(); Widget child = Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => isHovering = true), onExit: (_) => setState(() => isHovering = false), opaque: false, child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), ), child: BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, remoteSelection: editorState.remoteSelections, blockColor: editorState.editorStyle.selectionColor, cursorColor: editorState.editorStyle.cursorColor, selectionColor: editorState.editorStyle.selectionColor, supportTypes: const [ BlockSelectionType.block, BlockSelectionType.cursor, BlockSelectionType.selection, ], child: GestureDetector( // TODO(Mathias): Handle mobile tap onTap: isHandlingPaste ? null : () => _openSubPage(view: view), child: DecoratedBox( decoration: BoxDecoration( color: isHovering ? Theme.of(context).colorScheme.secondary : null, borderRadius: BorderRadius.circular(4), ), child: SizedBox( height: 32, child: Row( children: [ const HSpace(10), view.icon.value.isNotEmpty ? RawEmojiIconWidget( emoji: view.icon.toEmojiIconData(), emojiSize: textStyle.fontSize ?? 16.0, ) : view.defaultIcon(), const HSpace(6), Flexible( child: FlowyText( view.nameOrDefault, fontSize: textStyle.fontSize, fontWeight: textStyle.fontWeight, decoration: TextDecoration.underline, lineHeight: textStyle.height, overflow: TextOverflow.ellipsis, ), ), if (isHandlingPaste) ...[ FlowyText( LocaleKeys .document_plugins_subPage_handlingPasteHint .tr(), fontSize: textStyle.fontSize, fontWeight: textStyle.fontWeight, lineHeight: textStyle.height, color: Theme.of(context).hintColor, ), const HSpace(10), const SizedBox( height: 16, width: 16, child: CircularProgressIndicator( strokeWidth: 1.5, ), ), ], const HSpace(10), ], ), ), ), ), ), ), ), ); if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } if (UniversalPlatform.isMobile) { child = Padding( padding: padding, child: child, ); } return child; }, ); } Future fetchView(String pageId) async { final view = await ViewBackendService.getView(pageId).then( (res) => res.toNullable(), ); if (view == null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { final transaction = editorState.transaction..deleteNode(node); editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); } }); } parentId = view?.parentViewId; return view; } @override Position start() => Position(path: widget.node.path); @override Position end() => Position(path: widget.node.path, offset: 1); @override Position getPositionInOffset(Offset start) => end(); @override bool get shouldCursorBlink => false; @override CursorStyle get cursorStyle => CursorStyle.cover; @override Rect getBlockRect({ bool shiftWithBaseOffset = false, }) { return getRectsInSelection(Selection.invalid()).first; } @override Rect? getCursorRectInPosition( Position position, { bool shiftWithBaseOffset = false, }) { final rects = getRectsInSelection( Selection.collapsed(position), shiftWithBaseOffset: shiftWithBaseOffset, ); return rects.firstOrNull; } @override List getRectsInSelection( Selection selection, { bool shiftWithBaseOffset = false, }) { if (_renderBox == null) { return []; } final parentBox = context.findRenderObject(); final renderBox = subPageKey.currentContext?.findRenderObject(); if (parentBox is RenderBox && renderBox is RenderBox) { return [ renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & renderBox.size, ]; } return [Offset.zero & _renderBox!.size]; } @override Selection getSelectionInRange(Offset start, Offset end) => Selection.single(path: widget.node.path, startOffset: 0, endOffset: 1); @override Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => _renderBox!.localToGlobal(offset); void _openSubPage({ required ViewPB view, }) { if (UniversalPlatform.isDesktop) { final isInDatabase = context.read().isInDatabaseRowPage; if (isInDatabase) { Navigator.of(context).pop(); } getIt().add( TabsEvent.openPlugin( plugin: view.plugin(), view: view, ), ); } else if (UniversalPlatform.isMobile) { context.pushView(view); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:flutter/widgets.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/block_transaction_handler.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; class SubPageTransactionHandler extends BlockTransactionHandler { SubPageTransactionHandler() : super(type: SubPageBlockKeys.type); final List _beingCreated = []; @override Future onTransaction( BuildContext context, String viewId, EditorState editorState, List added, List removed, { bool isCut = false, bool isUndoRedo = false, bool isPaste = false, bool isDraggingNode = false, bool isTurnInto = false, String? parentViewId, }) async { if (isDraggingNode || isTurnInto) { return; } for (final node in removed) { if (!context.mounted) return; await _subPageDeleted(context, node); } for (final node in added) { if (!context.mounted) return; await _subPageAdded( context, editorState, node, isCut: isCut, isPaste: isPaste, parentViewId: parentViewId, ); } } Future _subPageDeleted( BuildContext context, Node node, ) async { if (node.type != type) { return; } final view = node.attributes[SubPageBlockKeys.viewId]; if (view == null) { return; } final result = await ViewBackendService.deleteView(viewId: view); result.fold( (_) {}, (error) { Log.error(error); if (context.mounted) { showSnapBar( context, LocaleKeys.document_plugins_subPage_errors_failedDeletePage.tr(), ); } }, ); } Future _subPageAdded( BuildContext context, EditorState editorState, Node node, { bool isCut = false, bool isPaste = false, String? parentViewId, }) async { if (node.type != type || _beingCreated.contains(node.id)) { return; } final viewId = node.attributes[SubPageBlockKeys.viewId]; if (viewId == null && parentViewId != null) { _beingCreated.add(node.id); // This is a new Node, we need to create the view final viewOrResult = await ViewBackendService.createView( name: '', layoutType: ViewLayoutPB.Document, parentViewId: parentViewId, ); await viewOrResult.fold( (view) async { final transaction = editorState.transaction ..updateNode(node, {SubPageBlockKeys.viewId: view.id}); await editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); editorState.reload(); // Open view getIt().openPlugin(view); }, (error) async { Log.error(error); showSnapBar( context, LocaleKeys.document_plugins_subPage_errors_failedCreatePage.tr(), ); // Remove the node because it failed final transaction = editorState.transaction..deleteNode(node); await editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); }, ); _beingCreated.remove(node.id); } else if (isPaste) { if (isCut && parentViewId != null) { await TrashService.putback(viewId); final viewOrResult = await ViewBackendService.moveViewV2( viewId: viewId, newParentId: parentViewId, prevViewId: null, ); viewOrResult.fold( (_) {}, (error) { Log.error(error); showSnapBar( context, LocaleKeys.document_plugins_subPage_errors_failedMovePage.tr(), ); }, ); } else { final viewId = node.attributes[SubPageBlockKeys.viewId]; if (viewId == null) { return; } final viewOrResult = await ViewBackendService.getView(viewId); return viewOrResult.fold( (view) async { final duplicatedViewOrResult = await ViewBackendService.duplicate( view: view, openAfterDuplicate: false, includeChildren: true, syncAfterDuplicate: true, parentViewId: parentViewId, ); return duplicatedViewOrResult.fold( (view) async { final transaction = editorState.transaction ..updateNode(node, { SubPageBlockKeys.viewId: view.id, SubPageBlockKeys.wasCut: false, SubPageBlockKeys.wasCopied: false, }); await editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); editorState.reload(); }, (error) { Log.error(error); if (context.mounted) { showSnapBar( context, LocaleKeys .document_plugins_subPage_errors_failedDuplicatePage .tr(), ); } }, ); }, (error) async { Log.error(error); final transaction = editorState.transaction..deleteNode(node); await editorState.apply( transaction, withUpdateSelection: false, options: const ApplyOptions(recordUndo: false), ); editorState.reload(); if (context.mounted) { showSnapBar( context, LocaleKeys .document_plugins_subPage_errors_failedDuplicateFindView .tr(), ); } }, ); } } else { // Try to restore from trash, and move to parent view await TrashService.putback(viewId); // Check if View needs to be moved if (parentViewId != null) { final view = (await ViewBackendService.getView(viewId)).toNullable(); if (view == null) { return Log.error('View not found: $viewId'); } if (view.parentViewId == parentViewId) { return; } await ViewBackendService.moveViewV2( viewId: viewId, newParentId: parentViewId, prevViewId: null, ); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/table/table_menu.dart ================================================ import 'dart:math' as math; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/table/table_option_action.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; const tableActions = [ TableOptionAction.addAfter, TableOptionAction.addBefore, TableOptionAction.delete, TableOptionAction.duplicate, TableOptionAction.clear, TableOptionAction.bgColor, ]; class TableMenu extends StatelessWidget { const TableMenu({ super.key, required this.node, required this.editorState, required this.position, required this.dir, this.onBuild, this.onClose, }); final Node node; final EditorState editorState; final int position; final TableDirection dir; final VoidCallback? onBuild; final VoidCallback? onClose; @override Widget build(BuildContext context) { final actions = tableActions.map((action) { switch (action) { case TableOptionAction.bgColor: return TableColorOptionAction( node: node, editorState: editorState, position: position, dir: dir, ); default: return TableOptionActionWrapper(action); } }).toList(); return PopoverActionList( direction: dir == TableDirection.col ? PopoverDirection.bottomWithCenterAligned : PopoverDirection.rightWithTopAligned, actions: actions, onPopupBuilder: onBuild, onClosed: onClose, onSelected: (action, controller) { if (action is TableOptionActionWrapper) { _onSelectAction(action.inner); controller.close(); } }, buildChild: (controller) => _buildOptionButton(controller, context), ); } Widget _buildOptionButton( PopoverController controller, BuildContext context, ) { return Card( elevation: 1.0, child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => controller.show(), child: Transform.rotate( angle: dir == TableDirection.col ? math.pi / 2 : 0, child: const FlowySvg( FlowySvgs.drag_element_s, size: Size.square(18.0), ), ), ), ), ); } void _onSelectAction(TableOptionAction action) { switch (action) { case TableOptionAction.addAfter: TableActions.add(node, position + 1, editorState, dir); break; case TableOptionAction.addBefore: TableActions.add(node, position, editorState, dir); break; case TableOptionAction.delete: TableActions.delete(node, position, editorState, dir); break; case TableOptionAction.clear: TableActions.clear(node, position, editorState, dir); break; case TableOptionAction.duplicate: TableActions.duplicate(node, position, editorState, dir); break; default: } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/table/table_option_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/extensions/flowy_tint_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/extension.dart'; import 'package:flutter/material.dart'; const tableCellDefaultColor = 'appflowy_table_cell_default_color'; enum TableOptionAction { addAfter, addBefore, delete, duplicate, clear, /// row|cell background color bgColor; Widget icon(Color? color) { switch (this) { case TableOptionAction.addAfter: return const FlowySvg(FlowySvgs.add_s); case TableOptionAction.addBefore: return const FlowySvg(FlowySvgs.add_s); case TableOptionAction.delete: return const FlowySvg(FlowySvgs.delete_s); case TableOptionAction.duplicate: return const FlowySvg(FlowySvgs.copy_s); case TableOptionAction.clear: return const FlowySvg(FlowySvgs.close_s); case TableOptionAction.bgColor: return const FlowySvg( FlowySvgs.color_format_m, size: Size.square(12), ).padding(all: 2.0); } } String get description { switch (this) { case TableOptionAction.addAfter: return LocaleKeys.document_plugins_table_addAfter.tr(); case TableOptionAction.addBefore: return LocaleKeys.document_plugins_table_addBefore.tr(); case TableOptionAction.delete: return LocaleKeys.document_plugins_table_delete.tr(); case TableOptionAction.duplicate: return LocaleKeys.document_plugins_table_duplicate.tr(); case TableOptionAction.clear: return LocaleKeys.document_plugins_table_clear.tr(); case TableOptionAction.bgColor: return LocaleKeys.document_plugins_table_bgColor.tr(); } } } class TableOptionActionWrapper extends ActionCell { TableOptionActionWrapper(this.inner); final TableOptionAction inner; @override Widget? leftIcon(Color iconColor) => inner.icon(iconColor); @override String get name => inner.description; } class TableColorOptionAction extends PopoverActionCell { TableColorOptionAction({ required this.node, required this.editorState, required this.position, required this.dir, }); final Node node; final EditorState editorState; final int position; final TableDirection dir; @override Widget? leftIcon(Color iconColor) => TableOptionAction.bgColor.icon(iconColor); @override String get name => TableOptionAction.bgColor.description; @override Widget Function( BuildContext context, PopoverController parentController, PopoverController controller, ) get builder => (context, parentController, controller) { int row = 0, col = position; if (dir == TableDirection.row) { col = 0; row = position; } final cell = node.children.firstWhereOrNull( (n) => n.attributes[TableCellBlockKeys.colPosition] == col && n.attributes[TableCellBlockKeys.rowPosition] == row, ); final key = dir == TableDirection.col ? TableCellBlockKeys.colBackgroundColor : TableCellBlockKeys.rowBackgroundColor; final bgColor = cell?.attributes[key] as String?; final selectedColor = bgColor?.tryToColor(); // get default background color from themeExtension final defaultColor = AFThemeExtension.of(context).tableCellBGColor; final colors = [ // reset to default background color FlowyColorOption( color: defaultColor, i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), id: tableCellDefaultColor, ), ...FlowyTint.values.map( (e) => FlowyColorOption( color: e.color(context), i18n: e.tintName(AppFlowyEditorL10n.current), id: e.id, ), ), ]; return FlowyColorPicker( colors: colors, selected: selectedColor, border: Border.all( color: AFThemeExtension.of(context).onBackground, ), onTap: (option, index) async { final backgroundColor = selectedColor != option.color ? option.id : ''; TableActions.setBgColor( node, position, editorState, backgroundColor, dir, ); controller.close(); parentController.close(); }, ); }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/todo_list/todo_list_icon.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class TodoListIcon extends StatelessWidget { const TodoListIcon({ super.key, required this.node, required this.onCheck, }); final Node node; final VoidCallback onCheck; @override Widget build(BuildContext context) { // the icon height should be equal to the text height * text font size final textStyle = context.read().editorStyle.textStyleConfiguration; final fontSize = textStyle.text.fontSize ?? 16.0; final height = textStyle.text.height ?? textStyle.lineHeight; final iconSize = fontSize * height; final checked = node.attributes[TodoListBlockKeys.checked] ?? false; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { HapticFeedback.lightImpact(); onCheck(); }, child: Container( constraints: BoxConstraints( minWidth: iconSize, minHeight: iconSize, ), margin: const EdgeInsets.only(right: 8.0), alignment: Alignment.center, child: FlowySvg( checked ? FlowySvgs.m_todo_list_checked_s : FlowySvgs.m_todo_list_unchecked_s, blendMode: checked ? null : BlendMode.srcIn, size: Size.square(iconSize * 0.9), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class ToggleListBlockKeys { const ToggleListBlockKeys._(); static const String type = 'toggle_list'; /// The content of a code block. /// /// The value is a String. static const String delta = blockComponentDelta; static const String backgroundColor = blockComponentBackgroundColor; static const String textDirection = blockComponentTextDirection; /// The value is a bool. static const String collapsed = 'collapsed'; /// The value is a int. /// /// If this value is not null, the block represent a toggle heading. static const String level = 'level'; } Node toggleListBlockNode({ String? text, Delta? delta, bool collapsed = false, String? textDirection, Attributes? attributes, Iterable? children, }) { delta ??= Delta()..insert(text ?? ''); return Node( type: ToggleListBlockKeys.type, children: children ?? [], attributes: { if (textDirection != null) ToggleListBlockKeys.textDirection: textDirection, ToggleListBlockKeys.collapsed: collapsed, ToggleListBlockKeys.delta: delta.toJson(), if (attributes != null) ...attributes, }, ); } Node toggleHeadingNode({ int level = 1, String? text, Delta? delta, bool collapsed = false, String? textDirection, Attributes? attributes, Iterable? children, }) { // only support level 1 - 6 level = level.clamp(1, 6); return toggleListBlockNode( text: text, delta: delta, collapsed: collapsed, textDirection: textDirection, children: children, attributes: { if (attributes != null) ...attributes, ToggleListBlockKeys.level: level, }, ); } // defining the toggle list block menu item SelectionMenuItem toggleListBlockItem = SelectionMenuItem.node( getName: LocaleKeys.document_plugins_toggleList.tr, iconData: Icons.arrow_right, keywords: ['collapsed list', 'toggle list', 'list'], nodeBuilder: (editorState, _) => toggleListBlockNode(), replace: (_, node) => node.delta?.isEmpty ?? false, ); class ToggleListBlockComponentBuilder extends BlockComponentBuilder { ToggleListBlockComponentBuilder({ super.configuration, this.padding = const EdgeInsets.all(0), this.textStyleBuilder, }); final EdgeInsets padding; /// The text style of the toggle heading block. final TextStyle Function(int level)? textStyleBuilder; @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { final node = blockComponentContext.node; return ToggleListBlockComponentWidget( key: node.key, node: node, configuration: configuration, padding: padding, textStyleBuilder: textStyleBuilder, showActions: showActions(node), actionBuilder: (context, state) => actionBuilder( blockComponentContext, state, ), actionTrailingBuilder: (context, state) => actionTrailingBuilder( blockComponentContext, state, ), ); } @override BlockComponentValidate get validate => (node) => node.delta != null; } class ToggleListBlockComponentWidget extends BlockComponentStatefulWidget { const ToggleListBlockComponentWidget({ super.key, required super.node, super.showActions, super.actionBuilder, super.actionTrailingBuilder, super.configuration = const BlockComponentConfiguration(), this.padding = const EdgeInsets.all(0), this.textStyleBuilder, }); final EdgeInsets padding; final TextStyle Function(int level)? textStyleBuilder; @override State createState() => _ToggleListBlockComponentWidgetState(); } class _ToggleListBlockComponentWidgetState extends State with SelectableMixin, DefaultSelectableMixin, BlockComponentConfigurable, BlockComponentBackgroundColorMixin, NestedBlockComponentStatefulWidgetMixin, BlockComponentTextDirectionMixin, BlockComponentAlignMixin { // the key used to forward focus to the richtext child @override final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); @override BlockComponentConfiguration get configuration => widget.configuration; @override GlobalKey> get containerKey => node.key; @override GlobalKey> blockComponentKey = GlobalKey( debugLabel: ToggleListBlockKeys.type, ); @override Node get node => widget.node; @override EdgeInsets get indentPadding => configuration.indentPadding( node, calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ), ); bool get collapsed => node.attributes[ToggleListBlockKeys.collapsed] ?? false; int? get level => node.attributes[ToggleListBlockKeys.level] as int?; @override Widget build(BuildContext context) { return collapsed ? buildComponent(context) : buildComponentWithChildren(context); } @override Widget buildComponentWithChildren(BuildContext context) { return Stack( children: [ if (backgroundColor != Colors.transparent) Positioned.fill( left: cachedLeft, top: padding.top, child: Container( width: UniversalPlatform.isDesktop ? double.infinity : null, color: backgroundColor, ), ), Provider( create: (context) => DatabasePluginWidgetBuilderSize(horizontalPadding: 0.0), child: NestedListWidget( indentPadding: indentPadding, child: buildComponent(context), children: editorState.renderer.buildList( context, widget.node.children, ), ), ), ], ); } @override Widget buildComponent( BuildContext context, { bool withBackgroundColor = false, }) { Widget child = _buildToggleBlock(); child = BlockSelectionContainer( node: node, delegate: this, listenable: editorState.selectionNotifier, blockColor: editorState.editorStyle.selectionColor, supportTypes: const [ BlockSelectionType.block, ], child: child, ); child = Padding( padding: padding, child: Container( key: blockComponentKey, color: withBackgroundColor || (backgroundColor != Colors.transparent && collapsed) ? backgroundColor : null, child: child, ), ); if (widget.showActions && widget.actionBuilder != null) { child = BlockComponentActionWrapper( node: node, actionBuilder: widget.actionBuilder!, actionTrailingBuilder: widget.actionTrailingBuilder, child: child, ); } return child; } Widget _buildToggleBlock() { final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); final crossAxisAlignment = textDirection == TextDirection.ltr ? CrossAxisAlignment.start : CrossAxisAlignment.end; return Container( width: double.infinity, alignment: alignment, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: crossAxisAlignment, children: [ Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, textDirection: textDirection, children: [ _buildExpandIcon(), Flexible( child: _buildRichText(), ), ], ), _buildPlaceholder(), ], ), ); } Widget _buildPlaceholder() { // if the toggle block is collapsed or it contains children, don't show the // placeholder. if (collapsed || node.children.isNotEmpty) { return const SizedBox.shrink(); } return Padding( padding: UniversalPlatform.isMobile ? const EdgeInsets.symmetric(horizontal: 26.0) : indentPadding, child: FlowyButton( text: FlowyText( buildPlaceholderText(), color: Theme.of(context).hintColor, ), margin: const EdgeInsets.symmetric(horizontal: 3.0, vertical: 8), onTap: onAddContent, ), ); } Widget _buildRichText() { final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); final level = node.attributes[ToggleListBlockKeys.level]; return AppFlowyRichText( key: forwardKey, delegate: this, node: widget.node, editorState: editorState, placeholderText: placeholderText, lineHeight: 1.5, textSpanDecorator: (textSpan) { var result = textSpan.updateTextStyle( textStyleWithTextSpan(textSpan: textSpan), ); if (level != null) { result = result.updateTextStyle( widget.textStyleBuilder?.call(level), ); } return result; }, placeholderTextSpanDecorator: (textSpan) { var result = textSpan.updateTextStyle( textStyleWithTextSpan(textSpan: textSpan), ); if (level != null && widget.textStyleBuilder != null) { result = result.updateTextStyle( widget.textStyleBuilder?.call(level), ); } return result.updateTextStyle( placeholderTextStyleWithTextSpan(textSpan: textSpan), ); }, textDirection: textDirection, textAlign: alignment?.toTextAlign ?? textAlign, cursorColor: editorState.editorStyle.cursorColor, selectionColor: editorState.editorStyle.selectionColor, ); } Widget _buildExpandIcon() { double buttonHeight = UniversalPlatform.isDesktop ? 22.0 : 26.0; final textDirection = calculateTextDirection( layoutDirection: Directionality.maybeOf(context), ); if (level != null) { // top padding * 2 + button height = height of the heading text final textStyle = widget.textStyleBuilder?.call(level ?? 1); final fontSize = textStyle?.fontSize; final lineHeight = textStyle?.height ?? 1.5; if (fontSize != null) { buttonHeight = fontSize * lineHeight; } } final turns = switch (textDirection) { TextDirection.ltr => collapsed ? 0.0 : 0.25, TextDirection.rtl => collapsed ? -0.5 : -0.75, }; return Container( constraints: BoxConstraints( minWidth: 26, minHeight: buttonHeight, ), alignment: Alignment.center, child: FlowyButton( margin: const EdgeInsets.all(2.0), useIntrinsicWidth: true, onTap: onCollapsed, text: AnimatedRotation( turns: turns, duration: const Duration(milliseconds: 200), child: const Icon( Icons.arrow_right, size: 18.0, ), ), ), ); } Future onCollapsed() async { final transaction = editorState.transaction ..updateNode(node, { ToggleListBlockKeys.collapsed: !collapsed, }); transaction.afterSelection = editorState.selection; await editorState.apply(transaction); } Future onAddContent() async { final transaction = editorState.transaction; final path = node.path.child(0); transaction.insertNode( path, paragraphNode(), ); transaction.afterSelection = Selection.collapsed(Position(path: path)); await editorState.apply(transaction); } String buildPlaceholderText() { if (level != null) { return LocaleKeys.document_plugins_emptyToggleHeading.tr( args: [level.toString()], ); } return LocaleKeys.document_plugins_emptyToggleList.tr(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; const _greater = '>'; /// Convert '> ' to toggle list /// /// - support /// - desktop /// - mobile /// - web /// CharacterShortcutEvent formatGreaterToToggleList = CharacterShortcutEvent( key: 'format greater to toggle list', character: ' ', handler: (editorState) async => _formatGreaterSymbol( editorState, (node) => node.type != ToggleListBlockKeys.type, (_, text, __) => text == _greater, (text, node, delta, afterSelection) async => _formatGreaterToToggleHeading( editorState, text, node, delta, afterSelection, ), ), ); Future _formatGreaterToToggleHeading( EditorState editorState, String text, Node node, Delta delta, Selection afterSelection, ) async { final type = node.type; int? level; if (type == ToggleListBlockKeys.type) { level = node.attributes[ToggleListBlockKeys.level] as int?; } else if (type == HeadingBlockKeys.type) { level = node.attributes[HeadingBlockKeys.level] as int?; } delta = delta.compose(Delta()..delete(_greater.length)); // if the previous block is heading block, convert it to toggle heading block if (type == HeadingBlockKeys.type && level != null) { await BlockActionOptionCubit.turnIntoSingleToggleHeading( type: ToggleListBlockKeys.type, selectedNodes: [node], level: level, delta: delta, editorState: editorState, afterSelection: afterSelection, ); return; } final transaction = editorState.transaction; transaction ..insertNode( node.path, toggleListBlockNode( delta: delta, children: node.children.map((e) => e.deepCopy()).toList(), ), ) ..deleteNode(node); transaction.afterSelection = afterSelection; await editorState.apply(transaction); } /// Press enter key to insert child node inside the toggle list /// /// - support /// - desktop /// - mobile /// - web CharacterShortcutEvent insertChildNodeInsideToggleList = CharacterShortcutEvent( key: 'insert child node inside toggle list', character: '\n', handler: (editorState) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return false; } final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || node.type != ToggleListBlockKeys.type || delta == null) { return false; } final slicedDelta = delta.slice(selection.start.offset); final transaction = editorState.transaction; final bool collapsed = node.attributes[ToggleListBlockKeys.collapsed] ?? false; if (collapsed) { // if the delta is empty, clear the format if (delta.isEmpty) { transaction ..insertNode( selection.start.path.next, paragraphNode(), ) ..deleteNode(node) ..afterSelection = Selection.collapsed( Position(path: selection.start.path), ); } else if (selection.startIndex == 0) { // insert a paragraph block above the current toggle list block transaction.insertNode(selection.start.path, paragraphNode()); transaction.afterSelection = Selection.collapsed( Position(path: selection.start.path.next), ); } else { // insert a toggle list block below the current toggle list block transaction ..deleteText(node, selection.startIndex, slicedDelta.length) ..insertNodes( selection.start.path.next, [ toggleListBlockNode(collapsed: true, delta: slicedDelta), ], ) ..afterSelection = Selection.collapsed( Position(path: selection.start.path.next), ); } } else { // insert a paragraph block inside the current toggle list block transaction ..deleteText(node, selection.startIndex, slicedDelta.length) ..insertNode( selection.start.path + [0], paragraphNode(delta: slicedDelta), ) ..afterSelection = Selection.collapsed( Position(path: selection.start.path + [0]), ); } await editorState.apply(transaction); return true; }, ); /// cmd/ctrl + enter to close or open the toggle list /// /// - support /// - desktop /// - web /// // toggle the todo list final CommandShortcutEvent toggleToggleListCommand = CommandShortcutEvent( key: 'toggle the toggle list', getDescription: () => AppFlowyEditorL10n.current.cmdToggleTodoList, command: 'ctrl+enter', macOSCommand: 'cmd+enter', handler: _toggleToggleListCommandHandler, ); CommandShortcutEventHandler _toggleToggleListCommandHandler = (editorState) { if (UniversalPlatform.isMobile) { assert(false, 'enter key is not supported on mobile platform.'); return KeyEventResult.ignored; } final selection = editorState.selection; if (selection == null) { return KeyEventResult.ignored; } final nodes = editorState.getNodesInSelection(selection); if (nodes.isEmpty || nodes.length > 1) { return KeyEventResult.ignored; } final node = nodes.first; if (node.type != ToggleListBlockKeys.type) { return KeyEventResult.ignored; } final collapsed = node.attributes[ToggleListBlockKeys.collapsed] as bool; final transaction = editorState.transaction; transaction.updateNode(node, { ToggleListBlockKeys.collapsed: !collapsed, }); transaction.afterSelection = selection; editorState.apply(transaction); return KeyEventResult.handled; }; /// Press the backspace at the first position of first line to go to the title /// /// - support /// - desktop /// - web /// final CommandShortcutEvent removeToggleHeadingStyle = CommandShortcutEvent( key: 'remove toggle heading style', command: 'backspace', getDescription: () => 'remove toggle heading style', handler: (editorState) => _removeToggleHeadingStyle( editorState: editorState, ), ); // convert the toggle heading block to heading block KeyEventResult _removeToggleHeadingStyle({ required EditorState editorState, }) { final selection = editorState.selection; if (selection == null || !selection.isCollapsed || selection.start.offset != 0) { return KeyEventResult.ignored; } final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.type != ToggleListBlockKeys.type) { return KeyEventResult.ignored; } final level = node.attributes[ToggleListBlockKeys.level] as int?; if (level == null) { return KeyEventResult.ignored; } final transaction = editorState.transaction; transaction.updateNode(node, { ToggleListBlockKeys.level: null, }); transaction.afterSelection = selection; editorState.apply(transaction); return KeyEventResult.handled; } /// Formats the current node to specified markdown style. /// /// For example, /// bulleted list: '- ' /// numbered list: '1. ' /// quote: '" ' /// ... /// /// The [nodeBuilder] can return a list of nodes, which will be inserted /// into the document. /// For example, when converting a bulleted list to a heading and the heading is /// not allowed to contain children, then the [nodeBuilder] should return a list /// of nodes, which contains the heading node and the children nodes. Future _formatGreaterSymbol( EditorState editorState, bool Function(Node node) shouldFormat, bool Function( Node node, String text, Selection selection, ) predicate, Future Function( String text, Node node, Delta delta, Selection afterSelection, ) onFormat, ) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return false; } final position = selection.end; final node = editorState.getNodeAtPath(position.path); if (node == null || !shouldFormat(node)) { return false; } // Get the text from the start of the document until the selection. final delta = node.delta; if (delta == null) { return false; } final text = delta.toPlainText().substring(0, selection.end.offset); // If the text doesn't match the predicate, then we don't want to // format it. if (!predicate(node, text, selection)) { return false; } final afterSelection = Selection.collapsed( Position( path: node.path, ), ); await onFormat(text, node, delta, afterSelection); return true; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; import 'custom_placeholder_toolbar_item.dart'; import 'toolbar_id_enum.dart'; final List customMarkdownFormatItems = [ _FormatToolbarItem( id: ToolbarId.bold, name: 'bold', svg: FlowySvgs.toolbar_bold_m, ), group1PaddingItem, _FormatToolbarItem( id: ToolbarId.underline, name: 'underline', svg: FlowySvgs.toolbar_underline_m, ), group1PaddingItem, _FormatToolbarItem( id: ToolbarId.italic, name: 'italic', svg: FlowySvgs.toolbar_inline_italic_m, ), ]; final ToolbarItem customInlineCodeItem = _FormatToolbarItem( id: ToolbarId.code, name: 'code', svg: FlowySvgs.toolbar_inline_code_m, group: 2, ); class _FormatToolbarItem extends ToolbarItem { _FormatToolbarItem({ required ToolbarId id, required String name, required FlowySvgData svg, super.group = 1, }) : super( id: id.id, isActive: showInAnyTextType, builder: ( context, editorState, highlightColor, iconColor, tooltipBuilder, ) { final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); final isHighlight = nodes.allSatisfyInSelection( selection, (delta) => delta.isNotEmpty && delta.everyAttributes((attr) => attr[name] == true), ); final hoverColor = isHighlight ? highlightColor : EditorStyleCustomizer.toolbarHoverColor(context); final isDark = !Theme.of(context).isLightMode; final theme = AppFlowyTheme.of(context); final child = FlowyIconButton( width: 36, height: 32, hoverColor: hoverColor, isSelected: isHighlight, icon: FlowySvg( svg, size: Size.square(20.0), color: (isDark && isHighlight) ? Color(0xFF282E3A) : theme.iconColorScheme.primary, ), onPressed: () => editorState.toggleAttribute( name, selection: selection, ), ); if (tooltipBuilder != null) { return tooltipBuilder( context, id.id, _getTooltipText(id), child, ); } return child; }, ); } String _getTooltipText(ToolbarId id) { switch (id) { case ToolbarId.underline: return '${LocaleKeys.toolbar_underline.tr()}${shortcutTooltips( '⌘ + U', 'CTRL + U', 'CTRL + U', )}'; case ToolbarId.bold: return '${LocaleKeys.toolbar_bold.tr()}${shortcutTooltips( '⌘ + B', 'CTRL + B', 'CTRL + B', )}'; case ToolbarId.italic: return '${LocaleKeys.toolbar_italic.tr()}${shortcutTooltips( '⌘ + I', 'CTRL + I', 'CTRL + I', )}'; case ToolbarId.code: return '${LocaleKeys.document_toolbar_inlineCode.tr()}${shortcutTooltips( '⌘ + E', 'CTRL + E', 'CTRL + E', )}'; default: return ''; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'toolbar_id_enum.dart'; String? _customHighlightColorHex; final customHighlightColorItem = ToolbarItem( id: ToolbarId.highlightColor.id, group: 1, isActive: showInAnyTextType, builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) => HighlightColorPickerWidget( editorState: editorState, tooltipBuilder: tooltipBuilder, highlightColor: highlightColor, ), ); class HighlightColorPickerWidget extends StatefulWidget { const HighlightColorPickerWidget({ super.key, required this.editorState, this.tooltipBuilder, required this.highlightColor, }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; final Color highlightColor; @override State createState() => _HighlightColorPickerWidgetState(); } class _HighlightColorPickerWidgetState extends State { final popoverController = PopoverController(); bool isSelected = false; EditorState get editorState => widget.editorState; Color get highlightColor => widget.highlightColor; @override void dispose() { super.dispose(); popoverController.close(); } @override Widget build(BuildContext context) { if (editorState.selection == null) { return const SizedBox.shrink(); } final selectionRectList = editorState.selectionRects(); final top = selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height; return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, offset: Offset(0, top), onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { setState(() { isSelected = false; }); keepEditorFocusNotifier.decrease(); }, margin: EdgeInsets.zero, popupBuilder: (context) => buildPopoverContent(), child: buildChild(context), ); } Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), iconColor = theme.iconColorScheme.primary; final child = FlowyIconButton( width: 36, height: 32, hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), icon: SizedBox( width: 20, child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.toolbar_text_highlight_m, size: Size(20, 16), color: iconColor, ), buildColorfulDivider(iconColor), ], ), ), onPressed: () { setState(() { isSelected = true; }); showPopover(); }, ); return widget.tooltipBuilder?.call( context, ToolbarId.highlightColor.id, AppFlowyEditorL10n.current.highlightColor, child, ) ?? child; } Widget buildColorfulDivider(Color? iconColor) { final List colors = []; final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); final isHighLight = nodes.allSatisfyInSelection(selection, (delta) { if (delta.everyAttributes((attr) => attr.isEmpty)) { return false; } return delta.everyAttributes((attr) { final textColorHex = attr[AppFlowyRichTextKeys.backgroundColor]; if (textColorHex != null) colors.add(textColorHex); return (textColorHex != null); }); }); final colorLength = colors.length; if (colors.isEmpty || !isHighLight) { return Container( width: 20, height: 4, color: iconColor, ); } return SizedBox( width: 20, height: 4, child: Row( children: List.generate(colorLength, (index) { final currentColor = int.tryParse(colors[index]); return Container( width: 20 / colorLength, height: 4, color: currentColor == null ? iconColor : Color(currentColor), ); }), ), ); } Widget buildPopoverContent() { final List colors = []; final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); final isHighlight = nodes.allSatisfyInSelection(selection, (delta) { if (delta.everyAttributes((attr) => attr.isEmpty)) { return false; } return delta.everyAttributes((attributes) { final highlightColorHex = attributes[AppFlowyRichTextKeys.backgroundColor]; if (highlightColorHex != null) colors.add(highlightColorHex); return highlightColorHex != null; }); }); bool showClearButton = false; nodes.allSatisfyInSelection(selection, (delta) { if (!showClearButton) { showClearButton = delta.whereType().any( (element) { return element.attributes?[AppFlowyRichTextKeys.backgroundColor] != null; }, ); } return true; }); return MouseRegion( child: ColorPicker( title: AppFlowyEditorL10n.current.highlightColor, showClearButton: showClearButton, selectedColorHex: (colors.length == 1 && isHighlight) ? colors.first : null, customColorHex: _customHighlightColorHex, colorOptions: generateHighlightColorOptions(), onSubmittedColorHex: (color, isCustomColor) { if (isCustomColor) { _customHighlightColorHex = color; } formatHighlightColor( editorState, editorState.selection, color, withUpdateSelection: true, ); hidePopover(); }, resetText: AppFlowyEditorL10n.current.clearHighlightColor, resetIconName: 'clear_highlight_color', ), ); } void showPopover() { keepEditorFocusNotifier.increase(); popoverController.show(); } void hidePopover() { popoverController.close(); keepEditorFocusNotifier.decrease(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'toolbar_id_enum.dart'; const kIsPageLink = 'is_page_link'; final customLinkItem = ToolbarItem( id: ToolbarId.link.id, group: 4, isActive: (state) => !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) { final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); final isHref = nodes.allSatisfyInSelection(selection, (delta) { return delta.everyAttributes( (attributes) => attributes[AppFlowyRichTextKeys.href] != null, ); }); final isDark = !Theme.of(context).isLightMode; final hoverColor = isHref ? highlightColor : EditorStyleCustomizer.toolbarHoverColor(context); final theme = AppFlowyTheme.of(context); final child = FlowyIconButton( width: 36, height: 32, hoverColor: hoverColor, isSelected: isHref, icon: FlowySvg( FlowySvgs.toolbar_link_m, size: Size.square(20.0), color: (isDark && isHref) ? Color(0xFF282E3A) : theme.iconColorScheme.primary, ), onPressed: () { getIt().hideToolbar(); if (!isHref) { final viewId = context.read()?.documentId ?? ''; showLinkCreateMenu(context, editorState, selection, viewId); } else { WidgetsBinding.instance.addPostFrameCallback((_) { getIt() .call(HoverTriggerKey(nodes.first.id, selection)); }); } }, ); if (tooltipBuilder != null) { return tooltipBuilder( context, ToolbarId.highlightColor.id, AppFlowyEditorL10n.current.link, child, ); } return child; }, ); extension AttributeExtension on Attributes { bool get isPage { if (this[kIsPageLink] is bool) { return this[kIsPageLink]; } return false; } } enum LinkMenuAlignment { topLeft, topRight, bottomLeft, bottomRight, } extension LinkMenuAlignmentExtension on LinkMenuAlignment { bool get isTop => this == LinkMenuAlignment.topLeft || this == LinkMenuAlignment.topRight; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_placeholder_toolbar_item.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'toolbar_id_enum.dart'; final ToolbarItem customPlaceholderItem = ToolbarItem( id: ToolbarId.placeholder.id, group: -1, isActive: (editorState) => true, builder: (context, __, ___, ____, _____) { final isDark = Theme.of(context).brightness == Brightness.dark; return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 5), child: Container( width: 1, color: Color(0xffE8ECF3).withAlpha(isDark ? 40 : 255), ), ); }, ); ToolbarItem buildPaddingPlaceholderItem( int group, { bool Function(EditorState editorState)? isActive, }) => ToolbarItem( id: ToolbarId.paddingPlaceHolder.id, group: group, isActive: isActive, builder: (context, __, ___, ____, _____) => HSpace(4), ); ToolbarItem group0PaddingItem = buildPaddingPlaceholderItem( 0, isActive: onlyShowInTextTypeAndExcludeTable, ); ToolbarItem group1PaddingItem = buildPaddingPlaceholderItem(1, isActive: showInAnyTextType); ToolbarItem group4PaddingItem = buildPaddingPlaceholderItem( 4, isActive: (state) => !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'toolbar_id_enum.dart'; final ToolbarItem customTextAlignItem = ToolbarItem( id: ToolbarId.textAlign.id, group: 4, isActive: (state) => !isNarrowWindow(state) && onlyShowInSingleSelectionAndTextType(state), builder: ( context, editorState, highlightColor, iconColor, tooltipBuilder, ) { return TextAlignActionList( editorState: editorState, tooltipBuilder: tooltipBuilder, highlightColor: highlightColor, ); }, ); class TextAlignActionList extends StatefulWidget { const TextAlignActionList({ super.key, required this.editorState, required this.highlightColor, this.tooltipBuilder, this.child, this.onSelect, this.popoverController, this.popoverDirection = PopoverDirection.bottomWithLeftAligned, this.showOffset = const Offset(0, 2), }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; final Color highlightColor; final Widget? child; final VoidCallback? onSelect; final PopoverController? popoverController; final PopoverDirection popoverDirection; final Offset showOffset; @override State createState() => _TextAlignActionListState(); } class _TextAlignActionListState extends State { late PopoverController popoverController = widget.popoverController ?? PopoverController(); bool isSelected = false; EditorState get editorState => widget.editorState; Color get highlightColor => widget.highlightColor; @override void dispose() { super.dispose(); popoverController.close(); } @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, direction: widget.popoverDirection, offset: widget.showOffset, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { setState(() { isSelected = false; }); keepEditorFocusNotifier.decrease(); }, popupBuilder: (context) => buildPopoverContent(), child: widget.child ?? buildChild(context), ); } void showPopover() { keepEditorFocusNotifier.increase(); popoverController.show(); } Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), iconColor = theme.iconColorScheme.primary; final child = FlowyIconButton( width: 48, height: 32, isSelected: isSelected, hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), icon: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.toolbar_alignment_m, size: Size.square(20), color: iconColor, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), color: iconColor, ), ], ), onPressed: () { setState(() { isSelected = true; }); showPopover(); }, ); return widget.tooltipBuilder?.call( context, ToolbarId.textAlign.id, LocaleKeys.document_toolbar_textAlign.tr(), child, ) ?? child; } Widget buildPopoverContent() { return MouseRegion( child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(4.0), children: List.generate(TextAlignCommand.values.length, (index) { final command = TextAlignCommand.values[index]; final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); final isHighlight = nodes.every( (n) => n.attributes[blockComponentAlign] == command.name, ); return SizedBox( height: 36, child: FlowyButton( leftIconSize: const Size.square(20), leftIcon: FlowySvg(command.svg), iconPadding: 12, text: FlowyText( command.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), rightIcon: isHighlight ? FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () { command.onAlignChanged(editorState); widget.onSelect?.call(); popoverController.close(); }, ), ); }), ), ); } } enum TextAlignCommand { left(FlowySvgs.toolbar_text_align_left_m), center(FlowySvgs.toolbar_text_align_center_m), right(FlowySvgs.toolbar_text_align_right_m); const TextAlignCommand(this.svg); final FlowySvgData svg; String get title { switch (this) { case left: return LocaleKeys.document_toolbar_alignLeft.tr(); case center: return LocaleKeys.document_toolbar_alignCenter.tr(); case right: return LocaleKeys.document_toolbar_alignRight.tr(); } } Future onAlignChanged(EditorState editorState) async { final selection = editorState.selection!; await editorState.updateNode( selection, (node) => node.copyWith( attributes: { ...node.attributes, blockComponentAlign: name, }, ), selectionExtraInfo: { selectionExtraInfoDoNotAttachTextService: true, selectionExtraInfoDisableFloatingToolbar: true, }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'toolbar_id_enum.dart'; String? _customColorHex; final customTextColorItem = ToolbarItem( id: ToolbarId.textColor.id, group: 1, isActive: showInAnyTextType, builder: (context, editorState, highlightColor, iconColor, tooltipBuilder) => TextColorPickerWidget( editorState: editorState, tooltipBuilder: tooltipBuilder, highlightColor: highlightColor, ), ); class TextColorPickerWidget extends StatefulWidget { const TextColorPickerWidget({ super.key, required this.editorState, this.tooltipBuilder, required this.highlightColor, }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; final Color highlightColor; @override State createState() => _TextColorPickerWidgetState(); } class _TextColorPickerWidgetState extends State { final popoverController = PopoverController(); bool isSelected = false; EditorState get editorState => widget.editorState; Color get highlightColor => widget.highlightColor; @override void dispose() { super.dispose(); popoverController.close(); } @override Widget build(BuildContext context) { if (editorState.selection == null) { return const SizedBox.shrink(); } final selectionRectList = editorState.selectionRects(); final top = selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height; return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, offset: Offset(0, top), onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { setState(() { isSelected = false; }); keepEditorFocusNotifier.decrease(); }, margin: EdgeInsets.zero, popupBuilder: (context) => buildPopoverContent(), child: buildChild(context), ); } Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), iconColor = theme.iconColorScheme.primary; final child = FlowyIconButton( width: 36, height: 32, hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), icon: SizedBox( width: 20, child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.toolbar_text_color_m, size: Size(20, 16), color: iconColor, ), buildColorfulDivider(iconColor), ], ), ), onPressed: () { setState(() { isSelected = true; }); showPopover(); }, ); return widget.tooltipBuilder?.call( context, ToolbarId.textColor.id, LocaleKeys.document_toolbar_textColor.tr(), child, ) ?? child; } Widget buildColorfulDivider(Color? iconColor) { final List colors = []; final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); final isHighLight = nodes.allSatisfyInSelection(selection, (delta) { if (delta.everyAttributes((attr) => attr.isEmpty)) { return false; } return delta.everyAttributes((attr) { final textColorHex = attr[AppFlowyRichTextKeys.textColor]; if (textColorHex != null) colors.add(textColorHex); return (textColorHex != null); }); }); final colorLength = colors.length; if (colors.isEmpty || !isHighLight) { return Container( width: 20, height: 4, color: iconColor, ); } return SizedBox( width: 20, height: 4, child: Row( children: List.generate(colorLength, (index) { final currentColor = int.tryParse(colors[index]); return Container( width: 20 / colorLength, height: 4, color: currentColor == null ? iconColor : Color(currentColor), ); }), ), ); } Widget buildPopoverContent() { bool showClearButton = false; final List colors = []; final selection = editorState.selection!; final nodes = editorState.getNodesInSelection(selection); final isHighLight = nodes.allSatisfyInSelection(selection, (delta) { if (delta.everyAttributes((attr) => attr.isEmpty)) { return false; } return delta.everyAttributes((attr) { final textColorHex = attr[AppFlowyRichTextKeys.textColor]; if (textColorHex != null) colors.add(textColorHex); return (textColorHex != null); }); }); nodes.allSatisfyInSelection( selection, (delta) { if (!showClearButton) { showClearButton = delta.whereType().any( (element) { return element.attributes?[AppFlowyRichTextKeys.textColor] != null; }, ); } return true; }, ); return MouseRegion( child: ColorPicker( title: LocaleKeys.document_toolbar_textColor.tr(), showClearButton: showClearButton, selectedColorHex: (colors.length == 1 && isHighLight) ? colors.first : null, customColorHex: _customColorHex, colorOptions: generateTextColorOptions(), onSubmittedColorHex: (color, isCustomColor) { if (isCustomColor) { _customColorHex = color; } formatFontColor( editorState, editorState.selection, color, withUpdateSelection: true, ); hidePopover(); }, resetText: AppFlowyEditorL10n.current.resetToDefaultColor, resetIconName: 'reset_text_color', ), ); } void showPopover() { keepEditorFocusNotifier.increase(); popoverController.show(); } void hidePopover() { popoverController.close(); keepEditorFocusNotifier.decrease(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_page.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: implementation_imports import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'custom_text_align_toolbar_item.dart'; import 'text_suggestions_toolbar_item.dart'; const _kMoreOptionItemId = 'editor.more_option'; const kFontToolbarItemId = 'editor.font'; @visibleForTesting const kFontFamilyToolbarItemKey = ValueKey('FontFamilyToolbarItem'); final ToolbarItem moreOptionItem = ToolbarItem( id: _kMoreOptionItemId, group: 5, isActive: showInAnyTextType, builder: ( context, editorState, highlightColor, iconColor, tooltipBuilder, ) { return MoreOptionActionList( editorState: editorState, tooltipBuilder: tooltipBuilder, highlightColor: highlightColor, ); }, ); class MoreOptionActionList extends StatefulWidget { const MoreOptionActionList({ super.key, required this.editorState, required this.highlightColor, this.tooltipBuilder, }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; final Color highlightColor; @override State createState() => _MoreOptionActionListState(); } class _MoreOptionActionListState extends State { final popoverController = PopoverController(); PopoverController fontPopoverController = PopoverController(); PopoverController suggestionsPopoverController = PopoverController(); PopoverController textAlignPopoverController = PopoverController(); bool isSelected = false; EditorState get editorState => widget.editorState; Color get highlightColor => widget.highlightColor; MoreOptionCommand? tappedCommand; @override void dispose() { super.dispose(); popoverController.close(); fontPopoverController.close(); suggestionsPopoverController.close(); textAlignPopoverController.close(); } @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 2.0), onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { setState(() { isSelected = false; }); keepEditorFocusNotifier.decrease(); }, popupBuilder: (context) => buildPopoverContent(), child: buildChild(context), ); } void showPopover() { keepEditorFocusNotifier.increase(); popoverController.show(); } Widget buildChild(BuildContext context) { final iconColor = Theme.of(context).iconTheme.color; final child = FlowyIconButton( width: 36, height: 32, isSelected: isSelected, hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), icon: FlowySvg( FlowySvgs.toolbar_more_m, size: Size.square(20), color: iconColor, ), onPressed: () { setState(() { isSelected = true; }); showPopover(); }, ); return widget.tooltipBuilder?.call( context, _kMoreOptionItemId, LocaleKeys.document_toolbar_moreOptions.tr(), child, ) ?? child; } Color? getFormulaColor() { if (isFormulaHighlight(editorState)) { return widget.highlightColor; } return null; } Color? getStrikethroughColor() { final selection = editorState.selection; if (selection == null || selection.isCollapsed) { return null; } final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || delta == null) { return null; } final nodes = editorState.getNodesInSelection(selection); final isHighlight = nodes.allSatisfyInSelection( selection, (delta) => delta.isNotEmpty && delta.everyAttributes( (attr) => attr[MoreOptionCommand.strikethrough.name] == true, ), ); return isHighlight ? widget.highlightColor : null; } Widget buildPopoverContent() { final showFormula = onlyShowInSingleSelectionAndTextType(editorState); const fontColor = Color(0xff99A1A8); final isNarrow = isNarrowWindow(editorState); return MouseRegion( child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(4.0), children: [ if (isNarrow) ...[ buildTurnIntoSelector(), buildCommandItem(MoreOptionCommand.link), buildTextAlignSelector(), ], buildFontSelector(), buildCommandItem( MoreOptionCommand.strikethrough, rightIcon: FlowyText( shortcutTooltips( '⌘⇧S', 'Ctrl⇧S', 'Ctrl⇧S', ).trim(), color: fontColor, fontSize: 12, figmaLineHeight: 16, fontWeight: FontWeight.w400, ), ), if (showFormula) buildCommandItem( MoreOptionCommand.formula, rightIcon: FlowyText( shortcutTooltips( '⌘⇧E', 'Ctrl⇧E', 'Ctrl⇧E', ).trim(), color: fontColor, fontSize: 12, figmaLineHeight: 16, fontWeight: FontWeight.w400, ), ), ], ), ); } Widget buildCommandItem( MoreOptionCommand command, { Widget? rightIcon, VoidCallback? onTap, }) { final isFontCommand = command == MoreOptionCommand.font; return SizedBox( height: 36, child: FlowyButton( key: isFontCommand ? kFontFamilyToolbarItemKey : null, leftIconSize: const Size.square(20), leftIcon: FlowySvg(command.svg), rightIcon: rightIcon, iconPadding: 12, text: FlowyText( command.title, figmaLineHeight: 20, fontWeight: FontWeight.w400, ), onTap: onTap ?? () { command.onExecute(editorState, context); hideOtherPopovers(command); if (command != MoreOptionCommand.font) { popoverController.close(); } }, ), ); } Widget buildFontSelector() { final selection = editorState.selection!; final String? currentFontFamily = editorState .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.fontFamily); return FontFamilyDropDown( currentFontFamily: currentFontFamily ?? '', offset: const Offset(-240, 0), popoverController: fontPopoverController, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () => keepEditorFocusNotifier.decrease(), onFontFamilyChanged: (fontFamily) async { fontPopoverController.close(); popoverController.close(); try { await editorState.formatDelta(selection, { AppFlowyRichTextKeys.fontFamily: fontFamily, }); } catch (e) { Log.error('Failed to set font family: $e'); } }, onResetFont: () async { fontPopoverController.close(); popoverController.close(); await editorState .formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null}); }, child: buildCommandItem( MoreOptionCommand.font, rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), ), ); } Widget buildTurnIntoSelector() { final selectionRects = editorState.selectionRects(); double height = -6; if (selectionRects.isNotEmpty) height = selectionRects.first.height; return SuggestionsActionList( editorState: editorState, popoverController: suggestionsPopoverController, popoverDirection: PopoverDirection.leftWithTopAligned, showOffset: Offset(-8, height), onSelect: () => getIt().hideToolbar(), child: buildCommandItem( MoreOptionCommand.suggestions, rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), onTap: () { if (tappedCommand == MoreOptionCommand.suggestions) return; hideOtherPopovers(MoreOptionCommand.suggestions); keepEditorFocusNotifier.increase(); suggestionsPopoverController.show(); }, ), ); } Widget buildTextAlignSelector() { return TextAlignActionList( editorState: editorState, popoverController: textAlignPopoverController, popoverDirection: PopoverDirection.leftWithTopAligned, showOffset: Offset(-8, 0), onSelect: () => getIt().hideToolbar(), highlightColor: highlightColor, child: buildCommandItem( MoreOptionCommand.textAlign, rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m), onTap: () { if (tappedCommand == MoreOptionCommand.textAlign) return; hideOtherPopovers(MoreOptionCommand.textAlign); keepEditorFocusNotifier.increase(); textAlignPopoverController.show(); }, ), ); } void hideOtherPopovers(MoreOptionCommand currentCommand) { if (tappedCommand == currentCommand) return; if (tappedCommand == MoreOptionCommand.font) { fontPopoverController.close(); fontPopoverController = PopoverController(); } else if (tappedCommand == MoreOptionCommand.suggestions) { suggestionsPopoverController.close(); suggestionsPopoverController = PopoverController(); } else if (tappedCommand == MoreOptionCommand.textAlign) { textAlignPopoverController.close(); textAlignPopoverController = PopoverController(); } tappedCommand = currentCommand; } } enum MoreOptionCommand { suggestions(FlowySvgs.turninto_s), link(FlowySvgs.toolbar_link_m), textAlign( FlowySvgs.toolbar_alignment_m, ), font(FlowySvgs.type_font_m), strikethrough(FlowySvgs.type_strikethrough_m), formula(FlowySvgs.type_formula_m); const MoreOptionCommand(this.svg); final FlowySvgData svg; String get title { switch (this) { case suggestions: return LocaleKeys.document_toolbar_turnInto.tr(); case link: return LocaleKeys.document_toolbar_link.tr(); case textAlign: return LocaleKeys.button_align.tr(); case font: return LocaleKeys.document_toolbar_font.tr(); case strikethrough: return LocaleKeys.editor_strikethrough.tr(); case formula: return LocaleKeys.document_toolbar_equation.tr(); } } Future onExecute(EditorState editorState, BuildContext context) async { final selection = editorState.selection!; if (this == link) { final nodes = editorState.getNodesInSelection(selection); final isHref = nodes.allSatisfyInSelection(selection, (delta) { return delta.everyAttributes( (attributes) => attributes[AppFlowyRichTextKeys.href] != null, ); }); getIt().hideToolbar(); if (isHref) { getIt().call( HoverTriggerKey(nodes.first.id, selection), ); } else { final viewId = context.read()?.documentId ?? ''; showLinkCreateMenu(context, editorState, selection, viewId); } } else if (this == strikethrough) { await editorState.toggleAttribute(name); } else if (this == formula) { final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || delta == null) { return; } final transaction = editorState.transaction; final isHighlight = isFormulaHighlight(editorState); if (isHighlight) { final formula = delta .slice(selection.startIndex, selection.endIndex) .whereType() .firstOrNull ?.attributes?[InlineMathEquationKeys.formula]; assert(formula != null); if (formula == null) { return; } // clear the format transaction.replaceText( node, selection.startIndex, selection.length, formula, attributes: {}, ); } else { final text = editorState.getTextInSelection(selection).join(); transaction.replaceText( node, selection.startIndex, selection.length, MentionBlockKeys.mentionChar, attributes: { InlineMathEquationKeys.formula: text, }, ); } await editorState.apply(transaction); } } } bool isFormulaHighlight(EditorState editorState) { final selection = editorState.selection; if (selection == null || selection.isCollapsed) { return false; } final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || delta == null) { return false; } final nodes = editorState.getNodesInSelection(selection); return nodes.allSatisfyInSelection(selection, (delta) { return delta.everyAttributes( (attributes) => attributes[InlineMathEquationKeys.formula] != null, ); }); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'toolbar_id_enum.dart'; final ToolbarItem customTextHeadingItem = ToolbarItem( id: ToolbarId.textHeading.id, group: 1, isActive: onlyShowInSingleTextTypeSelectionAndExcludeTable, builder: ( context, editorState, highlightColor, iconColor, tooltipBuilder, ) { return TextHeadingActionList( editorState: editorState, tooltipBuilder: tooltipBuilder, ); }, ); class TextHeadingActionList extends StatefulWidget { const TextHeadingActionList({ super.key, required this.editorState, this.tooltipBuilder, }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; @override State createState() => _TextHeadingActionListState(); } class _TextHeadingActionListState extends State { final popoverController = PopoverController(); bool isSelected = false; @override void dispose() { super.dispose(); popoverController.close(); } @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 2.0), onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { setState(() { isSelected = false; }); keepEditorFocusNotifier.decrease(); }, popupBuilder: (context) => buildPopoverContent(), child: buildChild(context), ); } void showPopover() { keepEditorFocusNotifier.increase(); popoverController.show(); } Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), iconColor = theme.iconColorScheme.primary; final child = FlowyIconButton( width: 48, height: 32, isSelected: isSelected, hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), icon: Row( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.toolbar_text_format_m, size: Size.square(20), color: iconColor, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), color: iconColor, ), ], ), onPressed: () { setState(() { isSelected = true; }); showPopover(); }, ); return widget.tooltipBuilder?.call( context, ToolbarId.textHeading.id, LocaleKeys.document_toolbar_textSize.tr(), child, ) ?? child; } Widget buildPopoverContent() { final selectingCommand = getSelectingCommand(); return MouseRegion( child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const VSpace(4.0), children: List.generate(TextHeadingCommand.values.length, (index) { final command = TextHeadingCommand.values[index]; return SizedBox( height: 36, child: FlowyButton( leftIconSize: const Size.square(20), leftIcon: FlowySvg(command.svg), iconPadding: 12, text: FlowyText( command.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), rightIcon: selectingCommand == command ? FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () { if (command == selectingCommand) return; command.onExecute(widget.editorState); popoverController.close(); }, ), ); }), ), ); } TextHeadingCommand? getSelectingCommand() { final editorState = widget.editorState; final selection = editorState.selection; if (selection == null || !selection.isSingle) { return null; } final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.delta == null) { return null; } final nodeType = node.type; if (nodeType == ParagraphBlockKeys.type) return TextHeadingCommand.text; if (nodeType == HeadingBlockKeys.type) { final level = node.attributes[HeadingBlockKeys.level] ?? 1; if (level == 1) return TextHeadingCommand.h1; if (level == 2) return TextHeadingCommand.h2; if (level == 3) return TextHeadingCommand.h3; } return null; } } enum TextHeadingCommand { text(FlowySvgs.type_text_m), h1(FlowySvgs.type_h1_m), h2(FlowySvgs.type_h2_m), h3(FlowySvgs.type_h3_m); const TextHeadingCommand(this.svg); final FlowySvgData svg; String get title { switch (this) { case text: return AppFlowyEditorL10n.current.text; case h1: return LocaleKeys.document_toolbar_h1.tr(); case h2: return LocaleKeys.document_toolbar_h2.tr(); case h3: return LocaleKeys.document_toolbar_h3.tr(); } } void onExecute(EditorState state) { switch (this) { case text: formatNodeToText(state); break; case h1: _turnInto(state, 1); break; case h2: _turnInto(state, 2); break; case h3: _turnInto(state, 3); break; } } Future _turnInto(EditorState state, int level) async { final selection = state.selection!; final node = state.getNodeAtPath(selection.start.path)!; await BlockActionOptionCubit.turnIntoBlock( HeadingBlockKeys.type, node, state, level: level, keepSelection: true, ); } } void formatNodeToText(EditorState editorState) { final selection = editorState.selection!; final node = editorState.getNodeAtPath(selection.start.path)!; final delta = (node.delta ?? Delta()).toJson(); editorState.formatNode( selection, (node) => node.copyWith( type: ParagraphBlockKeys.type, attributes: { blockComponentDelta: delta, blockComponentBackgroundColor: node.attributes[blockComponentBackgroundColor], blockComponentTextDirection: node.attributes[blockComponentTextDirection], }, ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart ================================================ import 'dart:collection'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'text_heading_toolbar_item.dart'; import 'toolbar_id_enum.dart'; @visibleForTesting const kSuggestionsItemKey = ValueKey('SuggestionsItem'); @visibleForTesting const kSuggestionsItemListKey = ValueKey('SuggestionsItemList'); final ToolbarItem suggestionsItem = ToolbarItem( id: ToolbarId.suggestions.id, group: 3, isActive: enableSuggestions, builder: ( context, editorState, highlightColor, iconColor, tooltipBuilder, ) { return SuggestionsActionList( editorState: editorState, tooltipBuilder: tooltipBuilder, ); }, ); class SuggestionsActionList extends StatefulWidget { const SuggestionsActionList({ super.key, required this.editorState, this.tooltipBuilder, this.child, this.onSelect, this.popoverController, this.popoverDirection = PopoverDirection.bottomWithLeftAligned, this.showOffset = const Offset(0, 2), }); final EditorState editorState; final ToolbarTooltipBuilder? tooltipBuilder; final Widget? child; final VoidCallback? onSelect; final PopoverController? popoverController; final PopoverDirection popoverDirection; final Offset showOffset; @override State createState() => _SuggestionsActionListState(); } class _SuggestionsActionListState extends State { late PopoverController popoverController = widget.popoverController ?? PopoverController(); bool isSelected = false; final List suggestionItems = suggestions.sublist(0, 4); final List turnIntoItems = suggestions.sublist(4, suggestions.length); EditorState get editorState => widget.editorState; SuggestionItem currentSuggestionItem = textSuggestionItem; @override void initState() { super.initState(); refreshSuggestions(); editorState.selectionNotifier.addListener(refreshSuggestions); } @override void dispose() { editorState.selectionNotifier.removeListener(refreshSuggestions); popoverController.close(); super.dispose(); } @override Widget build(BuildContext context) { return AppFlowyPopover( controller: popoverController, direction: widget.popoverDirection, offset: widget.showOffset, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () { setState(() { isSelected = false; }); keepEditorFocusNotifier.decrease(); }, constraints: const BoxConstraints(maxWidth: 240, maxHeight: 400), popupBuilder: (context) => buildPopoverContent(context), child: widget.child ?? buildChild(context), ); } void showPopover() { keepEditorFocusNotifier.increase(); popoverController.show(); } Widget buildChild(BuildContext context) { final theme = AppFlowyTheme.of(context), iconColor = theme.iconColorScheme.primary; final child = FlowyHover( isSelected: () => isSelected, style: HoverStyle( hoverColor: EditorStyleCustomizer.toolbarHoverColor(context), foregroundColorOnHover: Theme.of(context).iconTheme.color, ), resetHoverOnRebuild: false, child: FlowyTooltip( preferBelow: true, child: RawMaterialButton( key: kSuggestionsItemKey, constraints: BoxConstraints(maxHeight: 32, minWidth: 60), clipBehavior: Clip.antiAlias, hoverElevation: 0, highlightElevation: 0, shape: RoundedRectangleBorder(borderRadius: Corners.s6Border), fillColor: Colors.transparent, hoverColor: Colors.transparent, focusColor: Colors.transparent, splashColor: Colors.transparent, highlightColor: Colors.transparent, elevation: 0, onPressed: () { setState(() { isSelected = true; }); showPopover(); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ FlowyText( currentSuggestionItem.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), HSpace(4), FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), color: iconColor, ), ], ), ), ), ), ); return widget.tooltipBuilder?.call( context, ToolbarId.suggestions.id, currentSuggestionItem.title, child, ) ?? child; } Widget buildPopoverContent(BuildContext context) { final textColor = Color(0xff99A1A8); return MouseRegion( child: SingleChildScrollView( key: kSuggestionsItemListKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ buildSubTitle( LocaleKeys.document_toolbar_suggestions.tr(), textColor, ), ...List.generate(suggestionItems.length, (index) { return buildItem(suggestionItems[index]); }), buildSubTitle(LocaleKeys.document_toolbar_turnInto.tr(), textColor), ...List.generate(turnIntoItems.length, (index) { return buildItem(turnIntoItems[index]); }), ], ), ), ); } Widget buildItem(SuggestionItem item) { final isSelected = item.type == currentSuggestionItem.type; return SizedBox( height: 36, child: FlowyButton( leftIconSize: const Size.square(20), leftIcon: FlowySvg(item.svg), iconPadding: 12, text: FlowyText( item.title, fontWeight: FontWeight.w400, figmaLineHeight: 20, ), rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null, onTap: () { item.onTap(widget.editorState, true); widget.onSelect?.call(); popoverController.close(); }, ), ); } Widget buildSubTitle(String text, Color color) { return Container( height: 32, margin: EdgeInsets.symmetric(horizontal: 8), child: Align( alignment: Alignment.centerLeft, child: FlowyText.semibold( text, color: color, figmaLineHeight: 16, ), ), ); } void refreshSuggestions() { final selection = editorState.selection; if (selection == null || !selection.isSingle) { return; } final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.delta == null) { return; } final nodeType = node.type; SuggestionType? suggestionType; if (nodeType == HeadingBlockKeys.type) { final level = node.attributes[HeadingBlockKeys.level] ?? 1; if (level == 1) { suggestionType = SuggestionType.h1; } else if (level == 2) { suggestionType = SuggestionType.h2; } else if (level == 3) { suggestionType = SuggestionType.h3; } } else if (nodeType == ToggleListBlockKeys.type) { final level = node.attributes[ToggleListBlockKeys.level]; if (level == null) { suggestionType = SuggestionType.toggle; } else if (level == 1) { suggestionType = SuggestionType.toggleH1; } else if (level == 2) { suggestionType = SuggestionType.toggleH2; } else if (level == 3) { suggestionType = SuggestionType.toggleH3; } } else { suggestionType = nodeType2SuggestionType[nodeType]; } if (suggestionType == null) return; suggestionItems.clear(); turnIntoItems.clear(); for (final item in suggestions) { if (item.type.group == suggestionType.group && item.type != suggestionType) { suggestionItems.add(item); } else { turnIntoItems.add(item); } } currentSuggestionItem = suggestions.where((item) => item.type == suggestionType).first; if (mounted) setState(() {}); } } class SuggestionItem { SuggestionItem({ required this.type, required this.title, required this.svg, required this.onTap, }); final SuggestionType type; final String title; final FlowySvgData svg; final Function(EditorState state, bool keepSelection) onTap; } enum SuggestionGroup { textHeading, list, toggle, quote, page } enum SuggestionType { text(SuggestionGroup.textHeading), h1(SuggestionGroup.textHeading), h2(SuggestionGroup.textHeading), h3(SuggestionGroup.textHeading), checkbox(SuggestionGroup.list), bulleted(SuggestionGroup.list), numbered(SuggestionGroup.list), toggle(SuggestionGroup.toggle), toggleH1(SuggestionGroup.toggle), toggleH2(SuggestionGroup.toggle), toggleH3(SuggestionGroup.toggle), callOut(SuggestionGroup.quote), quote(SuggestionGroup.quote), page(SuggestionGroup.page); const SuggestionType(this.group); final SuggestionGroup group; } final textSuggestionItem = SuggestionItem( type: SuggestionType.text, title: AppFlowyEditorL10n.current.text, svg: FlowySvgs.type_text_m, onTap: (state, _) => formatNodeToText(state), ); final h1SuggestionItem = SuggestionItem( type: SuggestionType.h1, title: LocaleKeys.document_toolbar_h1.tr(), svg: FlowySvgs.type_h1_m, onTap: (state, keepSelection) => _turnInto( state, HeadingBlockKeys.type, level: 1, keepSelection: keepSelection, ), ); final h2SuggestionItem = SuggestionItem( type: SuggestionType.h2, title: LocaleKeys.document_toolbar_h2.tr(), svg: FlowySvgs.type_h2_m, onTap: (state, keepSelection) => _turnInto( state, HeadingBlockKeys.type, level: 2, keepSelection: keepSelection, ), ); final h3SuggestionItem = SuggestionItem( type: SuggestionType.h3, title: LocaleKeys.document_toolbar_h3.tr(), svg: FlowySvgs.type_h3_m, onTap: (state, keepSelection) => _turnInto( state, HeadingBlockKeys.type, level: 3, keepSelection: keepSelection, ), ); final checkboxSuggestionItem = SuggestionItem( type: SuggestionType.checkbox, title: LocaleKeys.editor_checkbox.tr(), svg: FlowySvgs.type_todo_m, onTap: (state, keepSelection) => _turnInto( state, TodoListBlockKeys.type, keepSelection: keepSelection, ), ); final bulletedSuggestionItem = SuggestionItem( type: SuggestionType.bulleted, title: LocaleKeys.editor_bulletedListShortForm.tr(), svg: FlowySvgs.type_bulleted_list_m, onTap: (state, keepSelection) => _turnInto( state, BulletedListBlockKeys.type, keepSelection: keepSelection, ), ); final numberedSuggestionItem = SuggestionItem( type: SuggestionType.numbered, title: LocaleKeys.editor_numberedListShortForm.tr(), svg: FlowySvgs.type_numbered_list_m, onTap: (state, keepSelection) => _turnInto( state, NumberedListBlockKeys.type, keepSelection: keepSelection, ), ); final toggleSuggestionItem = SuggestionItem( type: SuggestionType.toggle, title: LocaleKeys.editor_toggleListShortForm.tr(), svg: FlowySvgs.type_toggle_list_m, onTap: (state, keepSelection) => _turnInto( state, ToggleListBlockKeys.type, keepSelection: keepSelection, ), ); final toggleH1SuggestionItem = SuggestionItem( type: SuggestionType.toggleH1, title: LocaleKeys.editor_toggleHeading1ShortForm.tr(), svg: FlowySvgs.type_toggle_h1_m, onTap: (state, keepSelection) => _turnInto( state, ToggleListBlockKeys.type, level: 1, keepSelection: keepSelection, ), ); final toggleH2SuggestionItem = SuggestionItem( type: SuggestionType.toggleH2, title: LocaleKeys.editor_toggleHeading2ShortForm.tr(), svg: FlowySvgs.type_toggle_h2_m, onTap: (state, keepSelection) => _turnInto( state, ToggleListBlockKeys.type, level: 2, keepSelection: keepSelection, ), ); final toggleH3SuggestionItem = SuggestionItem( type: SuggestionType.toggleH3, title: LocaleKeys.editor_toggleHeading3ShortForm.tr(), svg: FlowySvgs.type_toggle_h3_m, onTap: (state, keepSelection) => _turnInto( state, ToggleListBlockKeys.type, level: 3, keepSelection: keepSelection, ), ); final callOutSuggestionItem = SuggestionItem( type: SuggestionType.callOut, title: LocaleKeys.document_plugins_callout.tr(), svg: FlowySvgs.type_callout_m, onTap: (state, keepSelection) => _turnInto( state, CalloutBlockKeys.type, keepSelection: keepSelection, ), ); final quoteSuggestionItem = SuggestionItem( type: SuggestionType.quote, title: LocaleKeys.editor_quote.tr(), svg: FlowySvgs.type_quote_m, onTap: (state, keepSelection) => _turnInto( state, QuoteBlockKeys.type, keepSelection: keepSelection, ), ); final pateItem = SuggestionItem( type: SuggestionType.page, title: LocaleKeys.editor_page.tr(), svg: FlowySvgs.icon_document_s, onTap: (state, keepSelection) => _turnInto( state, SubPageBlockKeys.type, viewId: getIt().latestOpenView?.id, keepSelection: keepSelection, ), ); Future _turnInto( EditorState state, String type, { int? level, String? viewId, bool keepSelection = true, }) async { final selection = state.selection!; final node = state.getNodeAtPath(selection.start.path)!; await BlockActionOptionCubit.turnIntoBlock( type, node, state, level: level, currentViewId: viewId, keepSelection: keepSelection, ); } final suggestions = UnmodifiableListView([ textSuggestionItem, h1SuggestionItem, h2SuggestionItem, h3SuggestionItem, checkboxSuggestionItem, bulletedSuggestionItem, numberedSuggestionItem, toggleSuggestionItem, toggleH1SuggestionItem, toggleH2SuggestionItem, toggleH3SuggestionItem, callOutSuggestionItem, quoteSuggestionItem, pateItem, ]); final nodeType2SuggestionType = UnmodifiableMapView({ ParagraphBlockKeys.type: SuggestionType.text, NumberedListBlockKeys.type: SuggestionType.numbered, BulletedListBlockKeys.type: SuggestionType.bulleted, QuoteBlockKeys.type: SuggestionType.quote, TodoListBlockKeys.type: SuggestionType.checkbox, CalloutBlockKeys.type: SuggestionType.callOut, }); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/toolbar_id_enum.dart ================================================ enum ToolbarId { bold, underline, italic, code, highlightColor, textColor, link, placeholder, paddingPlaceHolder, textAlign, moreOption, textHeading, suggestions, } extension ToolbarIdExtension on ToolbarId { String get id => 'editor.$name'; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/block_transaction_handler.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; /// A handler for transactions that involve a Block Component. /// /// This is a subclass of [EditorTransactionHandler] that is used for block components. /// Specifically this transaction handler only needs to concern itself with changes to /// a [Node], and doesn't care about text deltas. /// abstract class BlockTransactionHandler extends EditorTransactionHandler { const BlockTransactionHandler({required super.type}) : super(livesInDelta: false); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; /// A handler for transactions that involve a Block Component. /// The [T] type is the type of data that this transaction handler takes. /// /// In case of a block component, the [T] type should be a [Node]. /// In case of a mention component, the [T] type should be a [Map]. /// abstract class EditorTransactionHandler { const EditorTransactionHandler({ required this.type, this.livesInDelta = false, }); /// The type of the block/mention that this handler is built for. /// It's used to determine whether to call any of the handlers on certain transactions. /// final String type; /// If the block is a "mention" type, it lives inside the [Delta] of a [Node]. /// final bool livesInDelta; Future onTransaction( BuildContext context, String viewId, EditorState editorState, List added, List removed, { bool isCut = false, bool isUndoRedo = false, bool isPaste = false, bool isDraggingNode = false, bool isTurnInto = false, String? parentViewId, }); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart'; import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'mention_transaction_handler.dart'; final _transactionHandlers = [ if (FeatureFlag.inlineSubPageMention.isOn) ...[ SubPageTransactionHandler(), ChildPageTransactionHandler(), ], DateTransactionHandler(), ]; /// Handles delegating transactions to appropriate handlers. /// /// Such as the [ChildPageTransactionHandler] for inline child pages. /// class EditorTransactionService extends StatefulWidget { const EditorTransactionService({ super.key, required this.viewId, required this.editorState, required this.child, }); final String viewId; final EditorState editorState; final Widget child; @override State createState() => _EditorTransactionServiceState(); } class _EditorTransactionServiceState extends State { StreamSubscription? transactionSubscription; bool isUndoRedo = false; bool isPaste = false; bool isDraggingNode = false; bool isTurnInto = false; @override void initState() { super.initState(); transactionSubscription = widget.editorState.transactionStream.listen(onEditorTransaction); EditorNotification.addListener(onEditorNotification); } @override void dispose() { EditorNotification.removeListener(onEditorNotification); transactionSubscription?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return widget.child; } void onEditorNotification(EditorNotificationType type) { if ([EditorNotificationType.undo, EditorNotificationType.redo] .contains(type)) { isUndoRedo = true; } else if (type == EditorNotificationType.paste) { isPaste = true; } else if (type == EditorNotificationType.dragStart) { isDraggingNode = true; } else if (type == EditorNotificationType.dragEnd) { isDraggingNode = false; } else if (type == EditorNotificationType.turnInto) { isTurnInto = true; } if (type == EditorNotificationType.undo) { undoCommand.execute(widget.editorState); } else if (type == EditorNotificationType.redo) { redoCommand.execute(widget.editorState); } else if (type == EditorNotificationType.exitEditing && widget.editorState.selection != null) { // If the editor is disposed, we don't need to reset the selection. if (!widget.editorState.isDisposed) { widget.editorState.selection = null; } } } /// Collects all nodes of a certain type, including those that are nested. /// List collectMatchingNodes( Node node, String type, { bool livesInDelta = false, }) { final List matchingNodes = []; if (node.type == type) { matchingNodes.add(node); } if (livesInDelta && node.attributes[blockComponentDelta] != null) { final deltas = node.attributes[blockComponentDelta]; if (deltas is List) { for (final delta in deltas) { if (delta['attributes'] != null && delta['attributes'][type] != null) { matchingNodes.add(node); } } } } for (final child in node.children) { matchingNodes.addAll( collectMatchingNodes( child, type, livesInDelta: livesInDelta, ), ); } return matchingNodes; } void onEditorTransaction(EditorTransactionValue event) { final time = event.$1; final transaction = event.$2; if (time == TransactionTime.before) { return; } final Map added = { for (final handler in _transactionHandlers) handler.type: handler.livesInDelta ? [] : [], }; final Map removed = { for (final handler in _transactionHandlers) handler.type: handler.livesInDelta ? [] : [], }; // based on the type of the transaction handler final uniqueTransactionHandlers = {}; for (final handler in _transactionHandlers) { uniqueTransactionHandlers.putIfAbsent(handler.type, () => handler); } for (final op in transaction.operations) { if (op is InsertOperation) { for (final n in op.nodes) { for (final handler in uniqueTransactionHandlers.values) { if (handler.livesInDelta) { added[handler.type]! .addAll(extractMentionsForType(n, handler.type)); } else { added[handler.type]! .addAll(collectMatchingNodes(n, handler.type)); } } } } else if (op is DeleteOperation) { for (final n in op.nodes) { for (final handler in uniqueTransactionHandlers.values) { if (handler.livesInDelta) { removed[handler.type]!.addAll( extractMentionsForType(n, handler.type, false), ); } else { removed[handler.type]! .addAll(collectMatchingNodes(n, handler.type)); } } } } else if (op is UpdateOperation) { final node = widget.editorState.getNodeAtPath(op.path); if (node == null) { continue; } if (op.attributes[blockComponentDelta] is! List || op.oldAttributes[blockComponentDelta] is! List) { continue; } final deltaBefore = Delta.fromJson(op.oldAttributes[blockComponentDelta]); final deltaAfter = Delta.fromJson(op.attributes[blockComponentDelta]); final (add, del) = diffDeltas(deltaBefore, deltaAfter); bool fetchedMentions = false; for (final handler in _transactionHandlers) { if (!handler.livesInDelta || fetchedMentions) { continue; } if (add.isNotEmpty) { final mentionBlockDatas = getMentionBlockData(handler.type, node, add); added[handler.type]!.addAll(mentionBlockDatas); } if (del.isNotEmpty) { final mentionBlockDatas = getMentionBlockData( handler.type, node, del, ); removed[handler.type]!.addAll(mentionBlockDatas); } fetchedMentions = true; } } } for (final handler in _transactionHandlers) { final additions = added[handler.type] ?? []; final removals = removed[handler.type] ?? []; if (additions.isEmpty && removals.isEmpty) { continue; } handler.onTransaction( context, widget.viewId, widget.editorState, additions, removals, isCut: context.read().isCut, isUndoRedo: isUndoRedo, isPaste: isPaste, isDraggingNode: isDraggingNode, isTurnInto: isTurnInto, parentViewId: widget.viewId, ); } isUndoRedo = false; isPaste = false; isTurnInto = false; } /// Takes an iterable of [TextInsert] and returns a list of [MentionBlockData]. /// This is used to extract mentions from a list of text inserts, of a certain type. List getMentionBlockData( String type, Node node, Iterable textInserts, ) { // Additions contain all the text inserts that were added in this // transaction, we only care about the ones that fit the handlers type. // Filter out the text inserts where the attribute for the handler type is present. final relevantTextInserts = textInserts.where((ti) => ti.attributes?[type] != null); // Map it to a list of MentionBlockData. final mentionBlockDatas = relevantTextInserts.map((ti) { // For some text inserts (mostly additions), we might need to modify them after the transaction, // so we pass the index of the delta to the handler. final index = node.delta?.toList().indexOf(ti) ?? -1; return (node, ti.attributes![type], index); }).toList(); return mentionBlockDatas; } List extractMentionsForType( Node node, String mentionType, [ bool includeIndex = true, ]) { final changes = []; final nodesWithDelta = collectMatchingNodes( node, mentionType, livesInDelta: true, ); for (final paragraphNode in nodesWithDelta) { final textInserts = paragraphNode.attributes[blockComponentDelta]; if (textInserts == null || textInserts is! List || textInserts.isEmpty) { continue; } for (final (index, textInsert) in textInserts.indexed) { if (textInsert['attributes'] != null && textInsert['attributes'][mentionType] != null) { changes.add( ( paragraphNode, textInsert['attributes'][mentionType], includeIndex ? index : -1, ), ); } } } return changes; } (Iterable, Iterable) diffDeltas( Delta before, Delta after, ) { final diff = before.diff(after); final inverted = diff.invert(before); final del = inverted.whereType(); final add = diff.whereType(); return (add, del); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/mention_transaction_handler.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; /// The data used to handle transactions for mentions. /// /// [Node] is the block node. /// [Map] is the data of the mention block. /// [int] is the index of the mention block in the list of deltas (after transaction apply). /// typedef MentionBlockData = (Node, Map, int); abstract class MentionTransactionHandler extends EditorTransactionHandler { const MentionTransactionHandler() : super(type: MentionBlockKeys.mention, livesInDelta: true); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// Undo /// /// - support /// - desktop /// - web /// final CommandShortcutEvent customUndoCommand = CommandShortcutEvent( key: 'undo', getDescription: () => AppFlowyEditorL10n.current.cmdUndo, command: 'ctrl+z', macOSCommand: 'cmd+z', handler: (editorState) { final context = editorState.document.root.context; if (context == null) { return KeyEventResult.ignored; } final editorContext = context.read(); if (editorContext.coverTitleFocusNode.hasFocus) { return KeyEventResult.ignored; } EditorNotification.undo().post(); return KeyEventResult.handled; }, ); /// Redo /// /// - support /// - desktop /// - web /// final CommandShortcutEvent customRedoCommand = CommandShortcutEvent( key: 'redo', getDescription: () => AppFlowyEditorL10n.current.cmdRedo, command: 'ctrl+y,ctrl+shift+z', macOSCommand: 'cmd+shift+z', handler: (editorState) { final context = editorState.document.root.context; if (context == null) { return KeyEventResult.ignored; } final editorContext = context.read(); if (editorContext.coverTitleFocusNode.hasFocus) { return KeyEventResult.ignored; } EditorNotification.redo().post(); return KeyEventResult.handled; }, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/video/video_block_component.dart ================================================ class VideoBlockKeys { const VideoBlockKeys._(); static const String type = 'video'; static const String url = 'url'; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/font_colors.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/util/font_family_extension.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:universal_platform/universal_platform.dart'; import 'editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart'; class EditorStyleCustomizer { EditorStyleCustomizer({ required this.context, required this.padding, this.width, this.editorState, }); final BuildContext context; final EdgeInsets padding; final double? width; final EditorState? editorState; static const double maxDocumentWidth = 480 * 4; static const double minDocumentWidth = 480; static EdgeInsets get documentPadding => UniversalPlatform.isMobile ? EdgeInsets.zero : EdgeInsets.only( left: 40, right: 40 + EditorStyleCustomizer.optionMenuWidth, ); static double get nodeHorizontalPadding => UniversalPlatform.isMobile ? 24 : 0; static EdgeInsets get documentPaddingWithOptionMenu => documentPadding + EdgeInsets.only(left: optionMenuWidth); static double get optionMenuWidth => UniversalPlatform.isMobile ? 0 : 44; static Color? toolbarHoverColor(BuildContext context) { return Theme.of(context).brightness == Brightness.dark ? Theme.of(context).colorScheme.secondary : AFThemeExtension.of(context).toolbarHoverColor; } EditorStyle style() { if (UniversalPlatform.isDesktopOrWeb) { return desktop(); } else if (UniversalPlatform.isMobile) { return mobile(); } throw UnimplementedError(); } EditorStyle desktop() { final theme = Theme.of(context); final afThemeExtension = AFThemeExtension.of(context); final appearanceFont = context.read().state.font; final appearance = context.read().state; final fontSize = appearance.fontSize; String fontFamily = appearance.fontFamily; if (fontFamily.isEmpty && appearanceFont.isNotEmpty) { fontFamily = appearanceFont; } final cursorColor = (editorState?.editable ?? true) ? (appearance.cursorColor ?? DefaultAppearanceSettings.getDefaultCursorColor(context)) : Colors.transparent; return EditorStyle.desktop( padding: padding, maxWidth: width, cursorColor: cursorColor, selectionColor: appearance.selectionColor ?? DefaultAppearanceSettings.getDefaultSelectionColor(context), defaultTextDirection: appearance.defaultTextDirection, textStyleConfiguration: TextStyleConfiguration( lineHeight: 1.4, // on Windows, if applyHeightToFirstAscent is true, the first line will be too high. // it will cause the first line not aligned with the prefix icon. applyHeightToFirstAscent: UniversalPlatform.isWindows ? false : true, applyHeightToLastDescent: true, text: baseTextStyle(fontFamily).copyWith( fontSize: fontSize, color: afThemeExtension.onBackground, ), bold: baseTextStyle(fontFamily, fontWeight: FontWeight.bold).copyWith( fontWeight: FontWeight.w600, ), italic: baseTextStyle(fontFamily).copyWith(fontStyle: FontStyle.italic), underline: baseTextStyle(fontFamily).copyWith( decoration: TextDecoration.underline, ), strikethrough: baseTextStyle(fontFamily).copyWith( decoration: TextDecoration.lineThrough, ), href: baseTextStyle(fontFamily).copyWith( color: theme.colorScheme.primary, decoration: TextDecoration.underline, ), code: GoogleFonts.robotoMono( textStyle: baseTextStyle(fontFamily).copyWith( fontSize: fontSize, fontWeight: FontWeight.normal, color: Colors.red, backgroundColor: theme.colorScheme.inverseSurface.withValues(alpha: 0.8), ), ), ), textSpanDecorator: customizeAttributeDecorator, textScaleFactor: context.watch().state.textScaleFactor, textSpanOverlayBuilder: _buildTextSpanOverlay, ); } EditorStyle mobile() { final afThemeExtension = AFThemeExtension.of(context); final pageStyle = context.read().state; final theme = Theme.of(context); final fontSize = pageStyle.fontLayout.fontSize; final lineHeight = pageStyle.lineHeightLayout.lineHeight; final fontFamily = pageStyle.fontFamily ?? context.read().state.font; final defaultTextDirection = context.read().state.defaultTextDirection; final textScaleFactor = context.read().state.textScaleFactor; final baseTextStyle = this.baseTextStyle(fontFamily); return EditorStyle.mobile( padding: padding, defaultTextDirection: defaultTextDirection, textStyleConfiguration: TextStyleConfiguration( lineHeight: lineHeight, text: baseTextStyle.copyWith( fontSize: fontSize, color: afThemeExtension.onBackground, ), bold: baseTextStyle.copyWith(fontWeight: FontWeight.w600), italic: baseTextStyle.copyWith(fontStyle: FontStyle.italic), underline: baseTextStyle.copyWith(decoration: TextDecoration.underline), strikethrough: baseTextStyle.copyWith( decoration: TextDecoration.lineThrough, ), href: baseTextStyle.copyWith( color: theme.colorScheme.primary, decoration: TextDecoration.underline, ), code: GoogleFonts.robotoMono( textStyle: baseTextStyle.copyWith( fontSize: fontSize, fontWeight: FontWeight.normal, color: Colors.red, backgroundColor: Colors.grey.withValues(alpha: 0.3), ), ), applyHeightToFirstAscent: true, applyHeightToLastDescent: true, ), textSpanDecorator: customizeAttributeDecorator, magnifierSize: const Size(144, 96), textScaleFactor: textScaleFactor, mobileDragHandleLeftExtend: 12.0, mobileDragHandleWidthExtend: 24.0, textSpanOverlayBuilder: _buildTextSpanOverlay, ); } TextStyle headingStyleBuilder(int level) { final String? fontFamily; final List fontSizes; final double fontSize; if (UniversalPlatform.isMobile) { final state = context.read().state; fontFamily = state.fontFamily; fontSize = state.fontLayout.fontSize; fontSizes = state.fontLayout.headingFontSizes; } else { fontFamily = context .read() .state .fontFamily .orDefault(context.read().state.font); fontSize = context.read().state.fontSize; fontSizes = [ fontSize + 16, fontSize + 12, fontSize + 8, fontSize + 4, fontSize + 2, fontSize, ]; } return baseTextStyle(fontFamily, fontWeight: FontWeight.w600).copyWith( fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize, ); } CodeBlockStyle codeBlockStyleBuilder() { final fontSize = context.read().state.fontSize; final fontFamily = context.read().state.codeFontFamily; return CodeBlockStyle( textStyle: baseTextStyle(fontFamily).copyWith( fontSize: fontSize, height: 1.5, color: AFThemeExtension.of(context).onBackground, ), backgroundColor: AFThemeExtension.of(context).calloutBGColor, foregroundColor: AFThemeExtension.of(context).textColor.withAlpha(155), ); } TextStyle calloutBlockStyleBuilder() { if (UniversalPlatform.isMobile) { final afThemeExtension = AFThemeExtension.of(context); final pageStyle = context.read().state; final fontSize = pageStyle.fontLayout.fontSize; final fontFamily = pageStyle.fontFamily ?? defaultFontFamily; final baseTextStyle = this.baseTextStyle(fontFamily); return baseTextStyle.copyWith( fontSize: fontSize, color: afThemeExtension.onBackground, ); } else { final fontSize = context.read().state.fontSize; return baseTextStyle(null).copyWith( fontSize: fontSize, height: 1.5, ); } } TextStyle outlineBlockPlaceholderStyleBuilder() { final fontSize = context.read().state.fontSize; return TextStyle( fontFamily: defaultFontFamily, fontSize: fontSize, height: 1.5, color: AFThemeExtension.of(context).onBackground.withValues(alpha: 0.6), ); } TextStyle subPageBlockTextStyleBuilder() { if (UniversalPlatform.isMobile) { final pageStyle = context.read().state; final fontSize = pageStyle.fontLayout.fontSize; final fontFamily = pageStyle.fontFamily ?? defaultFontFamily; final baseTextStyle = this.baseTextStyle(fontFamily); return baseTextStyle.copyWith( fontSize: fontSize, ); } else { final fontSize = context.read().state.fontSize; return baseTextStyle(null).copyWith( fontSize: fontSize, height: 1.5, ); } } SelectionMenuStyle selectionMenuStyleBuilder() { final theme = Theme.of(context); final afThemeExtension = AFThemeExtension.of(context); return SelectionMenuStyle( selectionMenuBackgroundColor: theme.cardColor, selectionMenuItemTextColor: afThemeExtension.onBackground, selectionMenuItemIconColor: afThemeExtension.onBackground, selectionMenuItemSelectedIconColor: theme.colorScheme.onSurface, selectionMenuItemSelectedTextColor: theme.colorScheme.onSurface, selectionMenuItemSelectedColor: afThemeExtension.greyHover, selectionMenuUnselectedLabelColor: afThemeExtension.onBackground, selectionMenuDividerColor: afThemeExtension.greyHover, selectionMenuLinkBorderColor: afThemeExtension.greyHover, selectionMenuInvalidLinkColor: afThemeExtension.onBackground, selectionMenuButtonColor: afThemeExtension.greyHover, selectionMenuButtonTextColor: afThemeExtension.onBackground, selectionMenuButtonIconColor: afThemeExtension.onBackground, selectionMenuButtonBorderColor: afThemeExtension.greyHover, selectionMenuTabIndicatorColor: afThemeExtension.greyHover, ); } InlineActionsMenuStyle inlineActionsMenuStyleBuilder() { final theme = Theme.of(context); final afThemeExtension = AFThemeExtension.of(context); return InlineActionsMenuStyle( backgroundColor: theme.cardColor, groupTextColor: afThemeExtension.onBackground.withValues(alpha: .8), menuItemTextColor: afThemeExtension.onBackground, menuItemSelectedColor: theme.colorScheme.secondary, menuItemSelectedTextColor: theme.colorScheme.onSurface, ); } TextStyle baseTextStyle(String? fontFamily, {FontWeight? fontWeight}) { if (fontFamily == null) { return TextStyle(fontWeight: fontWeight); } else if (fontFamily == defaultFontFamily) { return TextStyle(fontFamily: fontFamily, fontWeight: fontWeight); } try { return getGoogleFontSafely(fontFamily, fontWeight: fontWeight); } on Exception { if ([defaultFontFamily, builtInCodeFontFamily].contains(fontFamily)) { return TextStyle(fontFamily: fontFamily, fontWeight: fontWeight); } return TextStyle(fontWeight: fontWeight); } } InlineSpan customizeAttributeDecorator( BuildContext context, Node node, int index, TextInsert text, TextSpan before, TextSpan after, ) { final attributes = text.attributes; if (attributes == null) { return before; } final suggestion = attributes[AiWriterBlockKeys.suggestion] as String?; final newStyle = suggestion == null ? after.style : _styleSuggestion(after.style, suggestion); if (attributes.backgroundColor != null) { final color = EditorFontColors.fromBuiltInColors( context, attributes.backgroundColor!, ); if (color != null) { return TextSpan( text: before.text, style: newStyle?.merge( TextStyle(backgroundColor: color), ), ); } } // try to refresh font here. if (attributes.fontFamily != null) { try { if (before.text?.contains('_regular') == true) { getGoogleFontSafely(attributes.fontFamily!.parseFontFamilyName()); } else { return TextSpan( text: before.text, style: newStyle?.merge( getGoogleFontSafely(attributes.fontFamily!), ), ); } } catch (_) { // ignore } } // Inline Mentions (Page Reference, Date, Reminder, etc.) final mention = attributes[MentionBlockKeys.mention] as Map?; if (mention != null) { final type = mention[MentionBlockKeys.type]; return WidgetSpan( alignment: PlaceholderAlignment.middle, style: newStyle, child: MentionBlock( key: ValueKey( switch (type) { MentionType.page => mention[MentionBlockKeys.pageId], MentionType.date => mention[MentionBlockKeys.date], _ => MentionBlockKeys.mention, }, ), node: node, index: index, mention: mention, textStyle: newStyle, ), ); } // customize the inline math equation block final formula = attributes[InlineMathEquationKeys.formula]; if (formula is String) { return WidgetSpan( style: after.style, alignment: PlaceholderAlignment.middle, child: InlineMathEquation( node: node, index: index, formula: formula, textStyle: after.style ?? style().textStyleConfiguration.text, ), ); } // customize the link on mobile final href = attributes[AppFlowyRichTextKeys.href] as String?; if (UniversalPlatform.isMobile && href != null) { return TextSpan(style: before.style, text: text.text); } if (suggestion != null) { return TextSpan( text: before.text, style: newStyle, ); } if (href != null) { return TextSpan( style: before.style, text: text.text, mouseCursor: SystemMouseCursors.click, ); } else { return before; } } Widget buildToolbarItemTooltip( BuildContext context, String id, String message, Widget child, ) { final tooltipMessage = _buildTooltipMessage(id, message); child = FlowyTooltip( richMessage: tooltipMessage, preferBelow: false, verticalOffset: 24, child: child, ); // the align/font toolbar item doesn't need the hover effect final toolbarItemsWithoutHover = { kFontToolbarItemId, kAlignToolbarItemId, }; if (!toolbarItemsWithoutHover.contains(id)) { child = Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: FlowyHover( style: HoverStyle( hoverColor: Colors.grey.withValues(alpha: 0.3), ), child: child, ), ); } return child; } TextSpan _buildTooltipMessage(String id, String message) { final markdownItemTooltips = { 'underline': (LocaleKeys.toolbar_underline.tr(), 'U'), 'bold': (LocaleKeys.toolbar_bold.tr(), 'B'), 'italic': (LocaleKeys.toolbar_italic.tr(), 'I'), 'strikethrough': (LocaleKeys.toolbar_strike.tr(), 'Shift+S'), 'code': (LocaleKeys.toolbar_inlineCode.tr(), 'E'), 'editor.inline_math_equation': ( LocaleKeys.document_plugins_createInlineMathEquation.tr(), 'Shift+E' ), }; final markdownItemIds = markdownItemTooltips.keys.toSet(); // the items without shortcuts if (!markdownItemIds.contains(id)) { return TextSpan( text: message, style: context.tooltipTextStyle(), ); } final tooltip = markdownItemTooltips[id]; if (tooltip == null) { return TextSpan( text: message, style: context.tooltipTextStyle(), ); } final textSpan = TextSpan( children: [ TextSpan( text: '${tooltip.$1}\n', style: context.tooltipTextStyle(), ), TextSpan( text: (Platform.isMacOS ? '⌘+' : 'Ctrl+') + tooltip.$2, style: context.tooltipTextStyle()?.copyWith( color: Theme.of(context).hintColor, ), ), ], ); return textSpan; } TextStyle? _styleSuggestion(TextStyle? style, String suggestion) { if (style == null) { return null; } final isLight = Theme.of(context).isLightMode; final textColor = isLight ? Color(0xFF007296) : Color(0xFF49CFF4); final underlineColor = isLight ? Color(0x33005A7A) : Color(0x3349CFF4); return switch (suggestion) { AiWriterBlockKeys.suggestionOriginal => style.copyWith( color: Theme.of(context).disabledColor, decoration: TextDecoration.lineThrough, ), AiWriterBlockKeys.suggestionReplacement => style.copyWith( color: textColor, decoration: TextDecoration.underline, decorationColor: underlineColor, decorationThickness: 1.0, ), _ => style, }; } List _buildTextSpanOverlay( BuildContext context, Node node, SelectableMixin delegate, ) { final delta = node.delta; if (delta == null) return []; final widgets = []; final textInserts = delta.whereType(); int index = 0; final editorState = context.read(); for (final textInsert in textInserts) { if (textInsert.attributes?.href != null) { final nodeSelection = Selection( start: Position(path: node.path, offset: index), end: Position( path: node.path, offset: index + textInsert.length, ), ); final rectList = delegate.getRectsInSelection(nodeSelection); if (rectList.isNotEmpty) { for (final rect in rectList) { widgets.add( Positioned( left: rect.left, top: rect.top, child: SizedBox( width: rect.width, height: rect.height, child: LinkHoverTrigger( editorState: editorState, selection: nodeSelection, attribute: textInsert.attributes!, node: node, size: rect.size, ), ), ), ); } } } index += textInsert.length; } return widgets; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/emoji/emoji_actions_command.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/cupertino.dart'; import 'package:universal_platform/universal_platform.dart'; import 'emoji_menu.dart'; const _emojiCharacter = ':'; final _letterRegExp = RegExp(r'^[a-zA-Z]$'); CharacterShortcutEvent emojiCommand(BuildContext context) => CharacterShortcutEvent( key: 'Opens Emoji Menu', character: '', regExp: _letterRegExp, handler: (editorState) async { return false; }, handlerWithCharacter: (editorState, character) { emojiMenuService = EmojiMenu( overlay: Overlay.of(context), editorState: editorState, ); return emojiCommandHandler(editorState, context, character); }, ); EmojiMenuService? emojiMenuService; Future emojiCommandHandler( EditorState editorState, BuildContext context, String character, ) async { final selection = editorState.selection; if (UniversalPlatform.isMobile || selection == null) { return false; } final node = editorState.getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null || node.type == CodeBlockKeys.type) { return false; } if (selection.end.offset > 0) { final plain = delta.toPlainText(); final previousCharacter = plain[selection.end.offset - 1]; if (previousCharacter != _emojiCharacter) return false; if (!context.mounted) return false; if (!selection.isCollapsed) return false; await editorState.insertTextAtPosition( character, position: selection.start, ); emojiMenuService?.show(character); return true; } return false; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/emoji/emoji_handler.dart ================================================ import 'dart:math'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'emoji_menu.dart'; class EmojiHandler extends StatefulWidget { const EmojiHandler({ super.key, required this.editorState, required this.menuService, required this.onDismiss, required this.onSelectionUpdate, required this.onEmojiSelect, this.cancelBySpaceHandler, this.initialSearchText = '', }); final EditorState editorState; final EmojiMenuService menuService; final VoidCallback onDismiss; final VoidCallback onSelectionUpdate; final SelectEmojiItemHandler onEmojiSelect; final String initialSearchText; final bool Function()? cancelBySpaceHandler; @override State createState() => _EmojiHandlerState(); } class _EmojiHandlerState extends State { final focusNode = FocusNode(debugLabel: 'emoji_menu_handler'); final scrollController = ScrollController(); late EmojiData emojiData; final List searchedEmojis = []; bool loaded = false; int invalidCounter = 0; late int startOffset; late String _search = widget.initialSearchText; double emojiHeight = 36.0; String lastSearchedQuery = ''; final configuration = EmojiPickerConfiguration( defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, ); int get startCharAmount => widget.initialSearchText.length; set search(String search) { _search = search; _doSearch(); } final ValueNotifier selectedIndexNotifier = ValueNotifier(0); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback( (_) => focusNode.requestFocus(), ); startOffset = (widget.editorState.selection?.endIndex ?? 0) - startCharAmount; if (kCachedEmojiData != null) { loadEmojis(kCachedEmojiData!); } else { EmojiData.builtIn().then( (value) { kCachedEmojiData = value; loadEmojis(value); }, ); } } @override void dispose() { focusNode.dispose(); selectedIndexNotifier.dispose(); scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final noEmojis = searchedEmojis.isEmpty; return Focus( focusNode: focusNode, onKeyEvent: onKeyEvent, child: Container( constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360), padding: noEmojis ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6.0), color: Theme.of(context).cardColor, boxShadow: [ BoxShadow( blurRadius: 5, spreadRadius: 1, color: Colors.black.withAlpha(25), ), ], ), child: loaded ? buildEmojis() : buildLoading(), ), ); } Widget buildLoading() { return SizedBox( width: 400, height: 40, child: Center( child: SizedBox.square( dimension: 20, child: CircularProgressIndicator(), ), ), ); } Widget buildEmojis() { final noEmojis = searchedEmojis.isEmpty; if (noEmojis) { return SizedBox( width: 400, height: emojiHeight, child: Center( child: FlowyText.regular(LocaleKeys.inlineActions_noResults.tr()), ), ); } return SizedBox( height: (searchedEmojis.length / configuration.perLine).ceil() * emojiHeight, child: GridView.builder( controller: scrollController, itemCount: searchedEmojis.length, padding: const EdgeInsets.symmetric(horizontal: 16), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: configuration.perLine, ), itemBuilder: (context, index) { final currentEmoji = searchedEmojis[index]; final emojiId = currentEmoji.id; final emoji = emojiData.getEmojiById( emojiId, skinTone: configuration.defaultSkinTone, ); return ValueListenableBuilder( valueListenable: selectedIndexNotifier, builder: (context, value, child) { final isSelected = value == index; return SizedBox.square( dimension: emojiHeight, child: FlowyButton( isSelected: isSelected, margin: EdgeInsets.zero, radius: Corners.s8Border, text: ManualTooltip( key: ValueKey('$emojiId-$isSelected'), message: currentEmoji.name, showAutomaticlly: isSelected, preferBelow: false, child: FlowyText.emoji( emoji, fontSize: configuration.emojiSize, ), ), onTap: () => onSelect(index), ), ); }, ); }, ), ); } void changeSelectedIndex(int index) => selectedIndexNotifier.value = index; void loadEmojis(EmojiData data) { emojiData = data; searchedEmojis.clear(); searchedEmojis.addAll(emojiData.emojis.values); if (mounted) { setState(() { loaded = true; }); } WidgetsBinding.instance.addPostFrameCallback((_) { _doSearch(); }); } void _doSearch() { if (!loaded || !mounted) return; final enableEmptySearch = widget.initialSearchText.isEmpty; if ((_search.startsWith(' ') || _search.isEmpty) && !enableEmptySearch) { widget.onDismiss.call(); return; } final searchEmojiData = emojiData.filterByKeyword(_search); setState(() { searchedEmojis.clear(); searchedEmojis.addAll(searchEmojiData.emojis.values); changeSelectedIndex(0); _scrollToItem(); }); if (searchedEmojis.isEmpty) { if (lastSearchedQuery.isEmpty) { lastSearchedQuery = _search; } if (_search.length - lastSearchedQuery.length >= 5) { widget.onDismiss.call(); } } else { lastSearchedQuery = ''; } } KeyEventResult onKeyEvent(focus, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } const moveKeys = [ LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight, ]; if (event.logicalKey == LogicalKeyboardKey.enter) { onSelect(selectedIndexNotifier.value); return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.escape) { // Workaround to bring focus back to editor widget.editorState .updateSelectionWithReason(widget.editorState.selection); widget.onDismiss.call(); } else if (event.logicalKey == LogicalKeyboardKey.backspace) { if (_search.isEmpty) { if (widget.initialSearchText.isEmpty) { widget.onDismiss.call(); return KeyEventResult.handled; } if (_canDeleteLastCharacter()) { widget.editorState.deleteBackward(); } else { // Workaround for editor regaining focus widget.editorState.apply( widget.editorState.transaction ..afterSelection = widget.editorState.selection, ); } widget.onDismiss.call(); } else { widget.onSelectionUpdate(); widget.editorState.deleteBackward(); _deleteCharacterAtSelection(); } return KeyEventResult.handled; } else if (event.character != null && !moveKeys.contains(event.logicalKey)) { /// Prevents dismissal of context menu by notifying the parent /// that the selection change occurred from the handler. widget.onSelectionUpdate(); if (event.logicalKey == LogicalKeyboardKey.space) { final cancelBySpaceHandler = widget.cancelBySpaceHandler; if (cancelBySpaceHandler != null && cancelBySpaceHandler()) { return KeyEventResult.handled; } } // Interpolation to avoid having a getter for private variable _insertCharacter(event.character!); return KeyEventResult.handled; } else if (moveKeys.contains(event.logicalKey)) { _moveSelection(event.logicalKey); return KeyEventResult.handled; } return KeyEventResult.handled; } void onSelect(int index) { widget.onEmojiSelect.call( context, (startOffset - startCharAmount, startOffset + _search.length), emojiData.getEmojiById(searchedEmojis[index].id), ); widget.onDismiss.call(); } void _insertCharacter(String character) { widget.editorState.insertTextAtCurrentSelection(character); final selection = widget.editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final delta = widget.editorState.getNodeAtPath(selection.end.path)?.delta; if (delta == null) { return; } search = widget.editorState .getTextInSelection( selection.copyWith( start: selection.start.copyWith(offset: startOffset), end: selection.start .copyWith(offset: startOffset + _search.length + 1), ), ) .join(); } void _moveSelection(LogicalKeyboardKey key) { final index = selectedIndexNotifier.value, perLine = configuration.perLine, remainder = index % perLine, length = searchedEmojis.length, currentLine = index ~/ perLine, maxLine = (length / perLine).ceil(); final heightBefore = currentLine * emojiHeight; if (key == LogicalKeyboardKey.arrowUp) { if (currentLine == 0) { final exceptLine = max(0, maxLine - 1); changeSelectedIndex(min(exceptLine * perLine + remainder, length - 1)); } else if (currentLine > 0) { changeSelectedIndex(index - perLine); } } else if (key == LogicalKeyboardKey.arrowDown) { if (currentLine == maxLine - 1) { changeSelectedIndex(remainder); } else if (currentLine < maxLine - 1) { changeSelectedIndex(min(index + perLine, length - 1)); } } else if (key == LogicalKeyboardKey.arrowLeft) { if (index == 0) { changeSelectedIndex(length - 1); } else if (index > 0) { changeSelectedIndex(index - 1); } } else if (key == LogicalKeyboardKey.arrowRight) { if (index == length - 1) { changeSelectedIndex(0); } else if (index < length - 1) { changeSelectedIndex(index + 1); } } final heightAfter = (selectedIndexNotifier.value ~/ configuration.perLine) * emojiHeight; if (mounted && (heightAfter != heightBefore)) { WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToItem(); }); } } void _scrollToItem() { final noEmojis = searchedEmojis.isEmpty; if (noEmojis || !mounted || !scrollController.hasClients) return; final currentItem = selectedIndexNotifier.value; final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight; final maxExtent = scrollController.position.maxScrollExtent; final jumpTo = (exceptHeight - maxExtent > 10 * emojiHeight) ? exceptHeight : min(exceptHeight, maxExtent); scrollController.animateTo( jumpTo, duration: Duration(milliseconds: 300), curve: Curves.linear, ); } void _deleteCharacterAtSelection() { final selection = widget.editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final node = widget.editorState.getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null) { return; } search = delta.toPlainText().substring( startOffset, startOffset + _search.length - 1, ); } bool _canDeleteLastCharacter() { final selection = widget.editorState.selection; if (selection == null || !selection.isCollapsed) { return false; } final delta = widget.editorState.getNodeAtPath(selection.start.path)?.delta; if (delta == null) { return false; } return delta.isNotEmpty; } } typedef SelectEmojiItemHandler = void Function( BuildContext context, (int start, int end) replacement, String emoji, ); ================================================ FILE: frontend/appflowy_flutter/lib/plugins/emoji/emoji_menu.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'emoji_actions_command.dart'; import 'emoji_handler.dart'; abstract class EmojiMenuService { void show(String character); void dismiss(); } class EmojiMenu extends EmojiMenuService { EmojiMenu({ required this.overlay, required this.editorState, this.cancelBySpaceHandler, this.menuHeight = 400, this.menuWidth = 300, }); final EditorState editorState; final double menuHeight; final double menuWidth; final OverlayState overlay; final bool Function()? cancelBySpaceHandler; Offset _offset = Offset.zero; Alignment _alignment = Alignment.topLeft; OverlayEntry? _menuEntry; bool selectionChangedByMenu = false; String initialCharacter = ''; @override void dismiss() { if (_menuEntry != null) { editorState.service.keyboardService?.enable(); editorState.service.scrollService?.enable(); keepEditorFocusNotifier.decrease(); } _menuEntry?.remove(); _menuEntry = null; // workaround: SelectionService has been released after hot reload. final isSelectionDisposed = editorState.service.selectionServiceKey.currentState == null; if (!isSelectionDisposed) { final selectionService = editorState.service.selectionService; selectionService.currentSelection.removeListener(_onSelectionChange); } emojiMenuService = null; } void _onSelectionUpdate() => selectionChangedByMenu = true; @override void show(String character) { initialCharacter = character; WidgetsBinding.instance.addPostFrameCallback((_) => _show()); } void _show() { final selectionService = editorState.service.selectionService; final selectionRects = selectionService.selectionRects; if (selectionRects.isEmpty) { return; } final Size editorSize = editorState.renderBox!.size; calculateSelectionMenuOffset(selectionRects.first); final (left, top, right, bottom) = _getPosition(); _menuEntry = OverlayEntry( builder: (context) => SizedBox( height: editorSize.height, width: editorSize.width, // GestureDetector handles clicks outside of the context menu, // to dismiss the context menu. child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: dismiss, child: Stack( children: [ Positioned( top: top, bottom: bottom, left: left, right: right, child: EmojiHandler( editorState: editorState, menuService: this, onDismiss: dismiss, onSelectionUpdate: _onSelectionUpdate, cancelBySpaceHandler: cancelBySpaceHandler, initialSearchText: initialCharacter, onEmojiSelect: ( BuildContext context, (int, int) replacement, String emoji, ) async { final selection = editorState.selection; if (selection == null) return; final node = editorState.document.nodeAtPath(selection.end.path); if (node == null) return; final transaction = editorState.transaction ..deleteText( node, replacement.$1, replacement.$2 - replacement.$1, ) ..insertText( node, replacement.$1, emoji, ); await editorState.apply(transaction); }, ), ), ], ), ), ), ); overlay.insert(_menuEntry!); keepEditorFocusNotifier.increase(); editorState.service.keyboardService?.disable(showCursor: true); editorState.service.scrollService?.disable(); selectionService.currentSelection.addListener(_onSelectionChange); } void _onSelectionChange() { // workaround: SelectionService has been released after hot reload. final isSelectionDisposed = editorState.service.selectionServiceKey.currentState == null; if (!isSelectionDisposed) { final selectionService = editorState.service.selectionService; if (selectionService.currentSelection.value == null) { return; } } if (!selectionChangedByMenu) { return dismiss(); } selectionChangedByMenu = false; } (double? left, double? top, double? right, double? bottom) _getPosition() { double? left, top, right, bottom; switch (_alignment) { case Alignment.topLeft: left = _offset.dx; top = _offset.dy; break; case Alignment.bottomLeft: left = _offset.dx; bottom = _offset.dy; break; case Alignment.topRight: right = _offset.dx; top = _offset.dy; break; case Alignment.bottomRight: right = _offset.dx; bottom = _offset.dy; break; } return (left, top, right, bottom); } void calculateSelectionMenuOffset(Rect rect) { // Workaround: We can customize the padding through the [EditorStyle], // but the coordinates of overlay are not properly converted currently. // Just subtract the padding here as a result. const menuOffset = Offset(0, 10); final editorOffset = editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; final editorHeight = editorState.renderBox!.size.height; final editorWidth = editorState.renderBox!.size.width; // show below default _alignment = Alignment.topLeft; final bottomRight = rect.bottomRight; final topRight = rect.topRight; var offset = bottomRight + menuOffset; _offset = Offset( offset.dx, offset.dy, ); // show above if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { offset = topRight - menuOffset; _alignment = Alignment.bottomLeft; _offset = Offset( offset.dx, editorHeight + editorOffset.dy - offset.dy, ); } // show on right if (_offset.dx + menuWidth < editorOffset.dx + editorWidth) { _offset = Offset( _offset.dx, _offset.dy, ); } else if (offset.dx - editorOffset.dx > menuWidth) { // show on left _alignment = _alignment == Alignment.topLeft ? Alignment.topRight : Alignment.bottomRight; _offset = Offset( editorWidth - _offset.dx + editorOffset.dx, _offset.dy, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/service_handler.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class InlineChildPageService extends InlineActionsDelegate { InlineChildPageService({required this.currentViewId}); final String currentViewId; @override Future search(String? search) async { final List results = []; if (search != null && search.isNotEmpty) { results.add( InlineActionsMenuItem( label: LocaleKeys.inlineActions_createPage.tr(args: [search]), iconBuilder: (_) => const FlowySvg(FlowySvgs.add_s), onSelected: (context, editorState, service, replacement) => _onSelected(context, editorState, service, replacement, search), ), ); } return InlineActionsResult(results: results); } Future _onSelected( BuildContext context, EditorState editorState, InlineActionsMenuService service, (int, int) replacement, String? search, ) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || delta == null) { return; } final view = (await ViewBackendService.createView( layoutType: ViewLayoutPB.Document, parentViewId: currentViewId, name: search!, )) .toNullable(); if (view == null) { return Log.error('Failed to create view'); } // preload the page info pageMemorizer[view.id] = view; final transaction = editorState.transaction ..replaceText( node, replacement.$1, replacement.$2, MentionBlockKeys.mentionChar, attributes: MentionBlockKeys.buildMentionPageAttributes( mentionType: MentionType.childPage, pageId: view.id, blockId: null, ), ); await editorState.apply(transaction); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart ================================================ import 'package:appflowy/date/date_service.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/service_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; final _keywords = [ LocaleKeys.inlineActions_date.tr().toLowerCase(), ]; class DateReferenceService extends InlineActionsDelegate { DateReferenceService(this.context) { // Initialize locale _locale = context.locale.toLanguageTag(); // Initializes options _setOptions(); } final BuildContext context; late String _locale; late List _allOptions; List options = []; @override Future search([ String? search, ]) async { // Checks if Locale has changed since last _setLocale(); // Filters static options _filterOptions(search); // Searches for date by pattern _searchDate(search); // Searches for date by natural language prompt await _searchDateNLP(search); return InlineActionsResult( title: LocaleKeys.inlineActions_date.tr(), results: options, ); } void _filterOptions(String? search) { if (search == null || search.isEmpty) { options = _allOptions; return; } options = _allOptions .where( (option) => option.keywords != null && option.keywords!.isNotEmpty && option.keywords!.any( (keyword) => keyword.contains(search.toLowerCase()), ), ) .toList(); if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) { _setOptions(); options = _allOptions; } } void _searchDate(String? search) { if (search == null || search.isEmpty) { return; } try { final date = DateFormat.yMd(_locale).parse(search); options.insert(0, _itemFromDate(date)); } catch (_) { return; } } Future _searchDateNLP(String? search) async { if (search == null || search.isEmpty) { return; } final result = await DateService.queryDate(search); result.fold( (date) => options.insert(0, _itemFromDate(date)), (_) {}, ); } Future _insertDateReference( EditorState editorState, DateTime date, int start, int end, ) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final node = editorState.getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null) { return; } final transaction = editorState.transaction ..replaceText( node, start, end, MentionBlockKeys.mentionChar, attributes: MentionBlockKeys.buildMentionDateAttributes( date: date.toIso8601String(), includeTime: false, reminderId: null, reminderOption: null, ), ); await editorState.apply(transaction); } void _setOptions() { final today = DateTime.now(); final tomorrow = today.add(const Duration(days: 1)); final yesterday = today.subtract(const Duration(days: 1)); late InlineActionsMenuItem todayItem; late InlineActionsMenuItem tomorrowItem; late InlineActionsMenuItem yesterdayItem; try { todayItem = _itemFromDate( today, LocaleKeys.relativeDates_today.tr(), [DateFormat.yMd(_locale).format(today)], ); tomorrowItem = _itemFromDate( tomorrow, LocaleKeys.relativeDates_tomorrow.tr(), [DateFormat.yMd(_locale).format(tomorrow)], ); yesterdayItem = _itemFromDate( yesterday, LocaleKeys.relativeDates_yesterday.tr(), [DateFormat.yMd(_locale).format(yesterday)], ); } catch (e) { todayItem = _itemFromDate(today); tomorrowItem = _itemFromDate(tomorrow); yesterdayItem = _itemFromDate(yesterday); } _allOptions = [ todayItem, tomorrowItem, yesterdayItem, ]; } /// Sets Locale on each search to make sure /// keywords are localized void _setLocale() { final locale = context.locale.toLanguageTag(); if (locale != _locale) { _locale = locale; _setOptions(); } } InlineActionsMenuItem _itemFromDate( DateTime date, [ String? label, List? keywords, ]) { late String labelStr; if (label != null) { labelStr = label; } else { try { labelStr = DateFormat.yMd(_locale).format(date); } catch (e) { // fallback to en-US labelStr = DateFormat.yMd('en-US').format(date); } } return InlineActionsMenuItem( label: labelStr.capitalize(), keywords: [labelStr.toLowerCase(), ...?keywords], onSelected: (context, editorState, menuService, replace) => _insertDateReference( editorState, date, replace.$1, replace.$2, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/service_handler.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; import 'package:flutter/material.dart'; // const _channel = "InlinePageReference"; // TODO(Mathias): Clean up and use folder search instead class InlinePageReferenceService extends InlineActionsDelegate { InlinePageReferenceService({ required this.currentViewId, this.viewLayout, this.customTitle, this.insertPage = false, this.limitResults = 5, }) : assert(limitResults > 0, 'limitResults must be greater than 0') { init(); } final Completer _initCompleter = Completer(); final String currentViewId; final ViewLayoutPB? viewLayout; final String? customTitle; /// Defaults to false, if set to true the Page /// will be inserted as a Reference /// When false, a link to the view will be inserted /// final bool insertPage; /// Defaults to 5 /// Will limit the page reference results /// to [limitResults]. /// final int limitResults; late final CachedRecentService _recentService; bool _recentViewsInitialized = false; late final List _recentViews; Future> _getRecentViews() async { if (_recentViewsInitialized) { return _recentViews; } _recentViewsInitialized = true; final sectionViews = await _recentService.recentViews(); final views = sectionViews.unique((e) => e.item.id).map((e) => e.item).toList(); // Filter by viewLayout views.retainWhere( (i) => currentViewId != i.id && (viewLayout == null || i.layout == viewLayout), ); // Map to InlineActionsMenuItem, then take 5 items return _recentViews = views.map(_fromView).take(5).toList(); } bool _viewsInitialized = false; late final List _allViews; Future> _getViews() async { if (_viewsInitialized) { return _allViews; } _viewsInitialized = true; final viewResult = await ViewBackendService.getAllViews(); return _allViews = viewResult .toNullable() ?.items .where((v) => viewLayout == null || v.layout == viewLayout) .toList() ?? const []; } Future init() async { _recentService = getIt(); // _searchListener.start(onResultsClosed: _onResults); } @override Future dispose() async { if (!_initCompleter.isCompleted) { _initCompleter.complete(); } await super.dispose(); } @override Future search([ String? search, ]) async { final isSearching = search != null && search.isNotEmpty; late List items; if (isSearching) { final allViews = await _getViews(); items = allViews .where( (view) => view.id != currentViewId && view.name.toLowerCase().contains(search.toLowerCase()) || (view.name.isEmpty && search.isEmpty) || (view.name.isEmpty && LocaleKeys.menuAppHeader_defaultNewPageName .tr() .toLowerCase() .contains(search.toLowerCase())), ) .take(limitResults) .map((view) => _fromView(view)) .toList(); } else { items = await _getRecentViews(); } return InlineActionsResult( title: customTitle?.isNotEmpty == true ? customTitle! : isSearching ? LocaleKeys.inlineActions_pageReference.tr() : LocaleKeys.inlineActions_recentPages.tr(), results: items, ); } Future _onInsertPageRef( ViewPB view, BuildContext context, EditorState editorState, (int, int) replace, ) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final node = editorState.getNodeAtPath(selection.start.path); if (node != null) { // Delete search term if (replace.$2 > 0) { final transaction = editorState.transaction ..deleteText(node, replace.$1, replace.$2); await editorState.apply(transaction); } // Insert newline before inserting referenced database if (node.delta?.toPlainText().isNotEmpty == true) { await editorState.insertNewLine(); } } try { await editorState.insertReferencePage(view, view.layout); } on FlowyError catch (e) { if (context.mounted) { return Dialogs.show( context, child: AppFlowyErrorPage( error: e, ), ); } } } Future _onInsertLinkRef( ViewPB view, BuildContext context, EditorState editorState, InlineActionsMenuService menuService, (int, int) replace, ) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final node = editorState.getNodeAtPath(selection.start.path); final delta = node?.delta; if (node == null || delta == null) { return; } // @page name -> $ // preload the page infos pageMemorizer[view.id] = view; final transaction = editorState.transaction ..replaceText( node, replace.$1, replace.$2, MentionBlockKeys.mentionChar, attributes: MentionBlockKeys.buildMentionPageAttributes( mentionType: MentionType.page, pageId: view.id, blockId: null, ), ); await editorState.apply(transaction); } InlineActionsMenuItem _fromView(ViewPB view) => InlineActionsMenuItem( keywords: [view.nameOrDefault.toLowerCase()], label: view.nameOrDefault, iconBuilder: (onSelected) { final child = view.icon.value.isNotEmpty ? RawEmojiIconWidget( emoji: view.icon.toEmojiIconData(), emojiSize: 16.0, lineHeight: 18.0 / 16.0, ) : view.defaultIcon(size: const Size(16, 16)); return SizedBox( width: 16, child: child, ); }, onSelected: (context, editorState, menu, replace) => insertPage ? _onInsertPageRef(view, context, editorState, replace) : _onInsertLinkRef(view, context, editorState, menu, replace), ); // Future _fromSearchResult( // SearchResultPB result, // ) async { // final viewRes = await ViewBackendService.getView(result.viewId); // final view = viewRes.toNullable(); // if (view == null) { // return null; // } // return _fromView(view); // } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart ================================================ import 'package:appflowy/date/date_service.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/service_handler.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nanoid/nanoid.dart'; final _keywords = [ LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(), LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(), ]; class ReminderReferenceService extends InlineActionsDelegate { ReminderReferenceService(this.context) { // Initialize locale _locale = context.locale.toLanguageTag(); // Initializes options _setOptions(); } final BuildContext context; late String _locale; late List _allOptions; List options = []; @override Future search([ String? search, ]) async { // Checks if Locale has changed since last _setLocale(); // Filters static options _filterOptions(search); // Searches for date by pattern _searchDate(search); // Searches for date by natural language prompt await _searchDateNLP(search); return _groupFromResults(options); } InlineActionsResult _groupFromResults([ List? options, ]) => InlineActionsResult( title: LocaleKeys.inlineActions_reminder_groupTitle.tr(), results: options ?? [], startsWithKeywords: [ LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(), LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(), ], ); void _filterOptions(String? search) { if (search == null || search.isEmpty) { options = _allOptions; return; } options = _allOptions .where( (option) => option.keywords != null && option.keywords!.isNotEmpty && option.keywords!.any( (keyword) => keyword.contains(search.toLowerCase()), ), ) .toList(); if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) { _setOptions(); options = _allOptions; } } void _searchDate(String? search) { if (search == null || search.isEmpty) { return; } try { final date = DateFormat.yMd(_locale).parse(search); options.insert(0, _itemFromDate(date)); } catch (_) { return; } } Future _searchDateNLP(String? search) async { if (search == null || search.isEmpty) { return; } final result = await DateService.queryDate(search); result.fold( (date) { // Only insert dates in the future if (DateTime.now().isBefore(date)) { options.insert(0, _itemFromDate(date)); } }, (_) {}, ); } Future _insertReminderReference( EditorState editorState, DateTime date, int start, int end, ) async { final selection = editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final node = editorState.getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null) { return; } final viewId = context.read().documentId; final reminder = _reminderFromDate(date, viewId, node); final transaction = editorState.transaction ..replaceText( node, start, end, MentionBlockKeys.mentionChar, attributes: MentionBlockKeys.buildMentionDateAttributes( date: date.toIso8601String(), reminderId: reminder.id, reminderOption: ReminderOption.atTimeOfEvent.name, includeTime: false, ), ); await editorState.apply(transaction); if (context.mounted) { context.read().add(ReminderEvent.add(reminder: reminder)); } } void _setOptions() { final today = DateTime.now(); final tomorrow = today.add(const Duration(days: 1)); final oneWeek = today.add(const Duration(days: 7)); late InlineActionsMenuItem todayItem; late InlineActionsMenuItem oneWeekItem; try { todayItem = _itemFromDate( tomorrow, LocaleKeys.relativeDates_tomorrow.tr(), [DateFormat.yMd(_locale).format(tomorrow)], ); } catch (e) { todayItem = _itemFromDate(today); } try { oneWeekItem = _itemFromDate( oneWeek, LocaleKeys.relativeDates_oneWeek.tr(), [DateFormat.yMd(_locale).format(oneWeek)], ); } catch (e) { oneWeekItem = _itemFromDate(oneWeek); } _allOptions = [ todayItem, oneWeekItem, ]; } /// Sets Locale on each search to make sure /// keywords are localized void _setLocale() { final locale = context.locale.toLanguageTag(); if (locale != _locale) { _locale = locale; _setOptions(); } } InlineActionsMenuItem _itemFromDate( DateTime date, [ String? label, List? keywords, ]) { late String labelStr; if (label != null) { labelStr = label; } else { try { labelStr = DateFormat.yMd(_locale).format(date); } catch (e) { // fallback to en-US labelStr = DateFormat.yMd('en-US').format(date); } } return InlineActionsMenuItem( label: labelStr.capitalize(), keywords: [labelStr.toLowerCase(), ...?keywords], onSelected: (context, editorState, menuService, replace) => _insertReminderReference(editorState, date, replace.$1, replace.$2), ); } ReminderPB _reminderFromDate(DateTime date, String viewId, Node node) { return ReminderPB( id: nanoid(), objectId: viewId, title: LocaleKeys.reminderNotification_title.tr(), message: LocaleKeys.reminderNotification_message.tr(), meta: { ReminderMetaKeys.includeTime: false.toString(), ReminderMetaKeys.blockId: node.id, ReminderMetaKeys.createdAt: DateTime.now().millisecondsSinceEpoch.toString(), }, scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000), isAck: date.isBefore(DateTime.now()), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart ================================================ import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:universal_platform/universal_platform.dart'; const inlineActionCharacter = '@'; CharacterShortcutEvent inlineActionsCommand( InlineActionsService inlineActionsService, { InlineActionsMenuStyle style = const InlineActionsMenuStyle.light(), }) => CharacterShortcutEvent( key: 'Opens Inline Actions Menu', character: inlineActionCharacter, handler: (editorState) => inlineActionsCommandHandler( editorState, inlineActionsService, style, ), ); InlineActionsMenuService? selectionMenuService; Future inlineActionsCommandHandler( EditorState editorState, InlineActionsService service, InlineActionsMenuStyle style, ) async { final selection = editorState.selection; if (selection == null) { return false; } if (!selection.isCollapsed) { await editorState.deleteSelection(selection); } await editorState.insertTextAtPosition( inlineActionCharacter, position: selection.start, ); final List initialResults = []; for (final handler in service.handlers) { final group = await handler.search(null); if (group.results.isNotEmpty) { initialResults.add(group); } } if (service.context != null) { keepEditorFocusNotifier.increase(); selectionMenuService?.dismiss(); selectionMenuService = UniversalPlatform.isMobile ? MobileInlineActionsMenu( context: service.context!, editorState: editorState, service: service, initialResults: initialResults, style: style, ) : InlineActionsMenu( context: service.context!, editorState: editorState, service: service, initialResults: initialResults, style: style, ); // disable the keyboard service editorState.service.keyboardService?.disable(); await selectionMenuService?.show(); // enable the keyboard service editorState.service.keyboardService?.enable(); } return true; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; abstract class InlineActionsMenuService { InlineActionsMenuStyle get style; Future show(); void dismiss(); } class InlineActionsMenu extends InlineActionsMenuService { InlineActionsMenu({ required this.context, required this.editorState, required this.service, required this.initialResults, required this.style, this.startCharAmount = 1, this.cancelBySpaceHandler, }); final BuildContext context; final EditorState editorState; final InlineActionsService service; final List initialResults; final bool Function()? cancelBySpaceHandler; @override final InlineActionsMenuStyle style; final int startCharAmount; OverlayEntry? _menuEntry; bool selectionChangedByMenu = false; @override void dismiss() { if (_menuEntry != null) { editorState.service.keyboardService?.enable(); editorState.service.scrollService?.enable(); keepEditorFocusNotifier.decrease(); } _menuEntry?.remove(); _menuEntry = null; // workaround: SelectionService has been released after hot reload. final isSelectionDisposed = editorState.service.selectionServiceKey.currentState == null; if (!isSelectionDisposed) { final selectionService = editorState.service.selectionService; selectionService.currentSelection.removeListener(_onSelectionChange); } } void _onSelectionUpdate() => selectionChangedByMenu = true; @override Future show() { final completer = Completer(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _show(); completer.complete(); }); return completer.future; } void _show() { dismiss(); final selectionService = editorState.service.selectionService; final selectionRects = selectionService.selectionRects; if (selectionRects.isEmpty) { return; } const double menuHeight = 300.0; const double menuWidth = 200.0; const Offset menuOffset = Offset(0, 10); final Offset editorOffset = editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; final Size editorSize = editorState.renderBox!.size; // Default to opening the overlay below Alignment alignment = Alignment.topLeft; final firstRect = selectionRects.first; Offset offset = firstRect.bottomRight + menuOffset; // Show above if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) { offset = firstRect.topRight - menuOffset; alignment = Alignment.bottomLeft; offset = Offset( offset.dx, MediaQuery.of(context).size.height - offset.dy, ); } // Show on the left final windowWidth = MediaQuery.of(context).size.width; if (offset.dx > (windowWidth - menuWidth)) { alignment = alignment == Alignment.topLeft ? Alignment.topRight : Alignment.bottomRight; offset = Offset( windowWidth - offset.dx, offset.dy, ); } final (left, top, right, bottom) = _getPosition(alignment, offset); _menuEntry = OverlayEntry( builder: (context) => SizedBox( height: editorSize.height, width: editorSize.width, // GestureDetector handles clicks outside of the context menu, // to dismiss the context menu. child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: dismiss, child: Stack( children: [ Positioned( top: top, bottom: bottom, left: left, right: right, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: InlineActionsHandler( service: service, results: initialResults, editorState: editorState, menuService: this, onDismiss: dismiss, onSelectionUpdate: _onSelectionUpdate, style: style, startCharAmount: startCharAmount, cancelBySpaceHandler: cancelBySpaceHandler, ), ), ), ], ), ), ), ); Overlay.of(context).insert(_menuEntry!); editorState.service.keyboardService?.disable(showCursor: true); editorState.service.scrollService?.disable(); selectionService.currentSelection.addListener(_onSelectionChange); } void _onSelectionChange() { // workaround: SelectionService has been released after hot reload. final isSelectionDisposed = editorState.service.selectionServiceKey.currentState == null; if (!isSelectionDisposed) { final selectionService = editorState.service.selectionService; if (selectionService.currentSelection.value == null) { return; } } if (!selectionChangedByMenu) { return dismiss(); } selectionChangedByMenu = false; } (double? left, double? top, double? right, double? bottom) _getPosition( Alignment alignment, Offset offset, ) { double? left, top, right, bottom; switch (alignment) { case Alignment.topLeft: left = offset.dx; top = offset.dy; break; case Alignment.bottomLeft: left = offset.dx; bottom = offset.dy; break; case Alignment.topRight: right = offset.dx; top = offset.dy; break; case Alignment.bottomRight: right = offset.dx; bottom = offset.dy; break; } return (left, top, right, bottom); } } class InlineActionsMenuStyle { InlineActionsMenuStyle({ required this.backgroundColor, required this.groupTextColor, required this.menuItemTextColor, required this.menuItemSelectedColor, required this.menuItemSelectedTextColor, }); const InlineActionsMenuStyle.light() : backgroundColor = Colors.white, groupTextColor = const Color(0xFF555555), menuItemTextColor = const Color(0xFF333333), menuItemSelectedColor = const Color(0xFFE0F8FF), menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247); const InlineActionsMenuStyle.dark() : backgroundColor = const Color(0xFF282E3A), groupTextColor = const Color(0xFFBBC3CD), menuItemTextColor = const Color(0xFFBBC3CD), menuItemSelectedColor = const Color(0xFF00BCF0), menuItemSelectedTextColor = const Color(0xFF131720); /// The background color of the context menu itself /// final Color backgroundColor; /// The color of the [InlineActionsGroup]'s title text /// final Color groupTextColor; /// The text color of an [InlineActionsMenuItem] /// final Color menuItemTextColor; /// The background of the currently selected [InlineActionsMenuItem] /// final Color menuItemSelectedColor; /// The text color of the currently selected [InlineActionsMenuItem] /// final Color menuItemSelectedTextColor; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart ================================================ import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; typedef SelectItemHandler = void Function( BuildContext context, EditorState editorState, InlineActionsMenuService menuService, (int start, int end) replacement, ); class InlineActionsMenuItem { InlineActionsMenuItem({ required this.label, this.iconBuilder, this.keywords, this.onSelected, }); final String label; final Widget Function(bool onSelected)? iconBuilder; final List? keywords; final SelectItemHandler? onSelected; } class InlineActionsResult { InlineActionsResult({ this.title, required this.results, this.startsWithKeywords, }); /// Localized title to be displayed above the results /// of the current group. /// /// If null, no title will be displayed. /// final String? title; /// List of results that will be displayed for this group /// made up of [SelectionMenuItem]s. /// final List results; /// If the search term start with one of these keyword, /// the results will be reordered such that these results /// will be above. /// final List? startsWithKeywords; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/plugins/inline_actions/service_handler.dart'; abstract class _InlineActionsProvider { void dispose(); } class InlineActionsService extends _InlineActionsProvider { InlineActionsService({ required this.context, required this.handlers, }); /// The [BuildContext] in which to show the [InlineActionsMenu] /// BuildContext? context; final List handlers; /// This is a workaround for not having a mounted check. /// Thus when the widget that uses the service is disposed, /// we set the [BuildContext] to null. /// @override Future dispose() async { for (final handler in handlers) { await handler.dispose(); } context = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/service_handler.dart ================================================ import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; abstract class InlineActionsDelegate { Future search(String? search); Future dispose() async {} } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_menu_group.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; /// All heights are in physical pixels const double _groupTextHeight = 14; // 12 height + 2 bottom spacing const double _groupBottomSpacing = 6; const double _itemHeight = 30; // 26 height + 4 vertical spacing (2*2) const double kInlineMenuHeight = 300; const double kInlineMenuWidth = 400; const double _contentHeight = 260; extension _StartWithsSort on List { void sortByStartsWithKeyword(String search) => sort( (a, b) { final aCount = a.startsWithKeywords ?.where( (key) => search.toLowerCase().startsWith(key), ) .length ?? 0; final bCount = b.startsWithKeywords ?.where( (key) => search.toLowerCase().startsWith(key), ) .length ?? 0; if (aCount > bCount) { return -1; } else if (bCount > aCount) { return 1; } return 0; }, ); } const _invalidSearchesAmount = 10; class InlineActionsHandler extends StatefulWidget { const InlineActionsHandler({ super.key, required this.service, required this.results, required this.editorState, required this.menuService, required this.onDismiss, required this.onSelectionUpdate, required this.style, this.startCharAmount = 1, this.cancelBySpaceHandler, }); final InlineActionsService service; final List results; final EditorState editorState; final InlineActionsMenuService menuService; final VoidCallback onDismiss; final VoidCallback onSelectionUpdate; final InlineActionsMenuStyle style; final int startCharAmount; final bool Function()? cancelBySpaceHandler; @override State createState() => _InlineActionsHandlerState(); } class _InlineActionsHandlerState extends State { final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler'); final _scrollController = ScrollController(); late List results = widget.results; int invalidCounter = 0; late int startOffset; String _search = ''; set search(String search) { _search = search; _doSearch(); } Future _doSearch() async { final List newResults = []; for (final handler in widget.service.handlers) { final group = await handler.search(_search); if (group.results.isNotEmpty) { newResults.add(group); } } invalidCounter = results.every((group) => group.results.isEmpty) ? invalidCounter + 1 : 0; if (invalidCounter >= _invalidSearchesAmount) { widget.onDismiss(); // Workaround to bring focus back to editor await widget.editorState .updateSelectionWithReason(widget.editorState.selection); return; } _resetSelection(); newResults.sortByStartsWithKeyword(_search); setState(() => results = newResults); } void _resetSelection() { _selectedGroup = 0; _selectedIndex = 0; } int _selectedGroup = 0; int _selectedIndex = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback( (_) => _focusNode.requestFocus(), ); startOffset = widget.editorState.selection?.endIndex ?? 0; } @override void dispose() { _scrollController.dispose(); _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Focus( focusNode: _focusNode, onKeyEvent: onKeyEvent, child: Container( constraints: const BoxConstraints( maxHeight: kInlineMenuHeight, minWidth: kInlineMenuWidth, ), decoration: BoxDecoration( color: widget.style.backgroundColor, borderRadius: BorderRadius.circular(6.0), boxShadow: [ BoxShadow( blurRadius: 5, spreadRadius: 1, color: Colors.black.withValues(alpha: 0.1), ), ], ), child: Padding( padding: const EdgeInsets.all(8.0), child: noResults ? SizedBox( width: 150, child: FlowyText.regular( LocaleKeys.inlineActions_noResults.tr(), ), ) : SingleChildScrollView( controller: _scrollController, physics: const ClampingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: results .where((g) => g.results.isNotEmpty) .mapIndexed( (index, group) => InlineActionsGroup( result: group, editorState: widget.editorState, menuService: widget.menuService, style: widget.style, onSelected: widget.onDismiss, startOffset: startOffset - widget.startCharAmount, endOffset: _search.length + widget.startCharAmount, isLastGroup: index == results.length - 1, isGroupSelected: _selectedGroup == index, selectedIndex: _selectedIndex, ), ) .toList(), ), ), ), ), ); } bool get noResults => results.isEmpty || results.every((e) => e.results.isEmpty); int get groupLength => results.length; int lengthOfGroup(int index) => results.length > index ? results[index].results.length : -1; InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) => results[groupIndex].results[handlerIndex]; KeyEventResult onKeyEvent(focus, KeyEvent event) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } const moveKeys = [ LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab, ]; if (event.logicalKey == LogicalKeyboardKey.enter) { if (_selectedGroup <= groupLength && _selectedIndex <= lengthOfGroup(_selectedGroup)) { handlerOf(_selectedGroup, _selectedIndex).onSelected?.call( context, widget.editorState, widget.menuService, ( startOffset - widget.startCharAmount, _search.length + widget.startCharAmount ), ); widget.onDismiss(); return KeyEventResult.handled; } if (noResults) { // Workaround to bring focus back to editor widget.editorState .updateSelectionWithReason(widget.editorState.selection); widget.editorState.insertNewLine(); widget.onDismiss(); return KeyEventResult.handled; } } else if (event.logicalKey == LogicalKeyboardKey.escape) { // Workaround to bring focus back to editor widget.editorState .updateSelectionWithReason(widget.editorState.selection); widget.onDismiss(); } else if (event.logicalKey == LogicalKeyboardKey.backspace) { if (_search.isEmpty) { if (_canDeleteLastCharacter()) { widget.editorState.deleteBackward(); } else { // Workaround for editor regaining focus widget.editorState.apply( widget.editorState.transaction ..afterSelection = widget.editorState.selection, ); } widget.onDismiss(); } else { widget.onSelectionUpdate(); widget.editorState.deleteBackward(); _deleteCharacterAtSelection(); } return KeyEventResult.handled; } else if (event.character != null && ![ ...moveKeys, LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight, ].contains(event.logicalKey)) { /// Prevents dismissal of context menu by notifying the parent /// that the selection change occurred from the handler. widget.onSelectionUpdate(); if (event.logicalKey == LogicalKeyboardKey.space) { final cancelBySpaceHandler = widget.cancelBySpaceHandler; if (cancelBySpaceHandler != null && cancelBySpaceHandler()) { return KeyEventResult.handled; } } // Interpolation to avoid having a getter for private variable _insertCharacter(event.character!); return KeyEventResult.handled; } else if (moveKeys.contains(event.logicalKey)) { _moveSelection(event.logicalKey); return KeyEventResult.handled; } if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight] .contains(event.logicalKey)) { widget.onSelectionUpdate(); event.logicalKey == LogicalKeyboardKey.arrowLeft ? widget.editorState.moveCursorForward() : widget.editorState.moveCursorBackward(SelectionMoveRange.character); /// If cursor moves before @ then dismiss menu /// If cursor moves after @search.length then dismiss menu final selection = widget.editorState.selection; if (selection != null && (selection.endIndex < startOffset || selection.endIndex > (startOffset + _search.length))) { widget.onDismiss(); } /// Workaround: When using the move cursor methods, it seems the /// focus goes back to the editor, this makes sure this handler /// receives the next keypress. /// _focusNode.requestFocus(); return KeyEventResult.handled; } return KeyEventResult.handled; } void _insertCharacter(String character) { widget.editorState.insertTextAtCurrentSelection(character); final selection = widget.editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final delta = widget.editorState.getNodeAtPath(selection.end.path)?.delta; if (delta == null) { return; } search = widget.editorState .getTextInSelection( selection.copyWith( start: selection.start.copyWith(offset: startOffset), end: selection.start .copyWith(offset: startOffset + _search.length + 1), ), ) .join(); } void _moveSelection(LogicalKeyboardKey key) { bool didChange = false; if (key == LogicalKeyboardKey.arrowUp || (key == LogicalKeyboardKey.tab && HardwareKeyboard.instance.isShiftPressed)) { if (_selectedIndex == 0 && _selectedGroup > 0) { _selectedGroup -= 1; _selectedIndex = lengthOfGroup(_selectedGroup) - 1; didChange = true; } else if (_selectedIndex > 0) { _selectedIndex -= 1; didChange = true; } } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab] .contains(key)) { if (_selectedIndex < lengthOfGroup(_selectedGroup) - 1) { _selectedIndex += 1; didChange = true; } else if (_selectedGroup < groupLength - 1) { _selectedGroup += 1; _selectedIndex = 0; didChange = true; } } if (mounted && didChange) { setState(() {}); _scrollToItem(); } } void _scrollToItem() { final groups = _selectedGroup + 1; int items = 0; for (int i = 0; i <= _selectedGroup; i++) { items += lengthOfGroup(i); } // Remove the leftover items items -= lengthOfGroup(_selectedGroup) - (_selectedIndex + 1); /// The offset is roughly calculated by: /// - Amount of Groups passed /// - Amount of Items passed final double offset = (_groupTextHeight + _groupBottomSpacing) * groups + _itemHeight * items; // We have a buffer so that when moving up, we show items above the currently // selected item. The buffer is the height of 2 items if (offset <= _scrollController.offset + _itemHeight * 2) { // We want to show the user some options above the newly // focused one, therefore we take the offset and subtract // the height of three items (current + 2) _scrollController.animateTo( offset - _itemHeight * 3, duration: const Duration(milliseconds: 200), curve: Curves.easeIn, ); } else if (offset > _scrollController.offset + _contentHeight - _itemHeight - _groupTextHeight) { // The same here, we want to show the options below the // newly focused item when moving downwards, therefore we add // 2 times the item height to the offset _scrollController.animateTo( offset - _contentHeight + _itemHeight * 2, duration: const Duration(milliseconds: 200), curve: Curves.easeIn, ); } } void _deleteCharacterAtSelection() { final selection = widget.editorState.selection; if (selection == null || !selection.isCollapsed) { return; } final node = widget.editorState.getNodeAtPath(selection.end.path); final delta = node?.delta; if (node == null || delta == null) { return; } search = delta.toPlainText().substring( startOffset, startOffset - 1 + _search.length, ); } bool _canDeleteLastCharacter() { final selection = widget.editorState.selection; if (selection == null || !selection.isCollapsed) { return false; } final delta = widget.editorState.getNodeAtPath(selection.start.path)?.delta; if (delta == null) { return false; } return delta.isNotEmpty; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart ================================================ import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class InlineActionsGroup extends StatelessWidget { const InlineActionsGroup({ super.key, required this.result, required this.editorState, required this.menuService, required this.style, required this.onSelected, required this.startOffset, required this.endOffset, this.isLastGroup = false, this.isGroupSelected = false, this.selectedIndex = 0, }); final InlineActionsResult result; final EditorState editorState; final InlineActionsMenuService menuService; final InlineActionsMenuStyle style; final VoidCallback onSelected; final int startOffset; final int endOffset; final bool isLastGroup; final bool isGroupSelected; final int selectedIndex; @override Widget build(BuildContext context) { return Padding( padding: isLastGroup ? EdgeInsets.zero : const EdgeInsets.only(bottom: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (result.title != null) ...[ FlowyText.medium(result.title!, color: style.groupTextColor), const SizedBox(height: 4), ], ...result.results.mapIndexed( (index, item) => InlineActionsWidget( item: item, editorState: editorState, menuService: menuService, isSelected: isGroupSelected && index == selectedIndex, style: style, onSelected: onSelected, startOffset: startOffset, endOffset: endOffset, ), ), ], ), ); } } class InlineActionsWidget extends StatefulWidget { const InlineActionsWidget({ super.key, required this.item, required this.editorState, required this.menuService, required this.isSelected, required this.style, required this.onSelected, required this.startOffset, required this.endOffset, }); final InlineActionsMenuItem item; final EditorState editorState; final InlineActionsMenuService menuService; final bool isSelected; final InlineActionsMenuStyle style; final VoidCallback onSelected; final int startOffset; final int endOffset; @override State createState() => _InlineActionsWidgetState(); } class _InlineActionsWidgetState extends State { @override Widget build(BuildContext context) { final iconBuilder = widget.item.iconBuilder; final hasIcon = iconBuilder != null; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: SizedBox( width: kInlineMenuWidth, child: FlowyButton( expand: true, isSelected: widget.isSelected, text: Row( children: [ if (hasIcon) ...[ iconBuilder.call(widget.isSelected), SizedBox(width: 12), ], Flexible( child: FlowyText.regular( widget.item.label, figmaLineHeight: 18, overflow: TextOverflow.ellipsis, ), ), ], ), onTap: _onPressed, ), ), ); } void _onPressed() { widget.onSelected(); widget.item.onSelected?.call( context, widget.editorState, widget.menuService, (widget.startOffset, widget.endOffset), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/callback_shortcuts.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; typedef AFBindingCallback = bool Function(); class AFCallbackShortcuts extends StatelessWidget { const AFCallbackShortcuts({ super.key, required this.bindings, required this.child, }); // The bindings for the shortcuts // // The result of the callback will be used to determine if the event is handled final Map bindings; final Widget child; bool _applyKeyEventBinding(ShortcutActivator activator, KeyEvent event) { if (activator.accepts(event, HardwareKeyboard.instance)) { return bindings[activator]?.call() ?? false; } return false; } @override Widget build(BuildContext context) { return Focus( canRequestFocus: false, skipTraversal: true, onKeyEvent: (FocusNode node, KeyEvent event) { KeyEventResult result = KeyEventResult.ignored; for (final ShortcutActivator activator in bindings.keys) { result = _applyKeyEventBinding(activator, event) ? KeyEventResult.handled : result; } return result; }, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/cover_type_ext.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; extension IntoCoverTypePB on CoverType { CoverTypePB into() => switch (this) { CoverType.color => CoverTypePB.ColorCover, CoverType.asset => CoverTypePB.AssetCover, CoverType.file => CoverTypePB.FileCover, _ => CoverTypePB.FileCover, }; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/plugins/shared/share/share_menu.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; class ShareMenuButton extends StatefulWidget { const ShareMenuButton({ super.key, required this.tabs, }); final List tabs; @override State createState() => _ShareMenuButtonState(); } class _ShareMenuButtonState extends State { final popoverController = AFPopoverController(); final popoverGroupId = SharePopoverGroupId(); @override void initState() { super.initState(); popoverController.addListener(() { if (context.mounted && popoverController.isOpen) { context.read().add(const ShareEvent.updatePublishStatus()); } }); } @override void dispose() { popoverController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final shareBloc = context.read(); final databaseBloc = context.read(); final userWorkspaceBloc = context.read(); final shareWithUserBloc = context.read(); // final animationDuration = const Duration(milliseconds: 120); return BlocBuilder( builder: (context, state) { return AFPopover( controller: popoverController, groupId: popoverGroupId, anchor: AFAnchorAuto( offset: const Offset(-176, 12), ), // Enable animation // effects: [ // FadeEffect(duration: animationDuration), // ScaleEffect( // duration: animationDuration, // begin: Offset(0.95, 0.95), // end: Offset(1, 1), // alignment: Alignment.topRight, // ), // MoveEffect( // duration: animationDuration, // begin: Offset(20, -20), // end: Offset(0, 0), // curve: Curves.easeOutQuad, // ), // ], popover: (_) { return ConstrainedBox( constraints: const BoxConstraints( maxWidth: 460, ), child: MultiBlocProvider( providers: [ if (databaseBloc != null) BlocProvider.value( value: databaseBloc, ), BlocProvider.value(value: shareBloc), BlocProvider.value(value: userWorkspaceBloc), BlocProvider.value(value: shareWithUserBloc), ], child: Provider.value( value: popoverGroupId, child: ShareMenu( tabs: widget.tabs, viewName: state.viewName, onClose: () { popoverController.hide(); }, ), ), ), ); }, child: AFFilledTextButton.primary( text: LocaleKeys.shareAction_buttonText.tr(), onTap: () { popoverController.show(); /// Fetch the shared users when the popover is shown context.read().add(ShareTabEvent.loadSharedUsers()); }, ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/constants.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/startup.dart'; class ShareConstants { static const String testBaseWebDomain = 'test.appflowy.com'; static const String defaultBaseWebDomain = 'https://appflowy.com'; static String buildPublishUrl({ required String nameSpace, required String publishName, }) { final baseShareDomain = getIt().appflowyCloudConfig.base_web_domain; final url = '$baseShareDomain/$nameSpace/$publishName'.addSchemaIfNeeded(); return url; } static String buildNamespaceUrl({ required String nameSpace, bool withHttps = false, }) { final baseShareDomain = getIt().appflowyCloudConfig.base_web_domain; String url = baseShareDomain.addSchemaIfNeeded(); if (!withHttps) { url = url.replaceFirst('https://', ''); } return '$url/$nameSpace'; } static String buildShareUrl({ required String workspaceId, required String viewId, String? blockId, }) { final baseShareDomain = getIt().appflowyCloudConfig.base_web_domain; final url = '$baseShareDomain/app/$workspaceId/$viewId'.addSchemaIfNeeded(); if (blockId == null || blockId.isEmpty) { return url; } return '$url?blockId=$blockId'; } } extension on String { String addSchemaIfNeeded() { final schema = Uri.parse(this).scheme; // if the schema is empty, add https schema by default if (schema.isEmpty) { return 'https://$this'; } return this; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/export/document_exporter.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ExportTab extends StatelessWidget { const ExportTab({ super.key, }); @override Widget build(BuildContext context) { final view = context.read().view; if (view.layout == ViewLayoutPB.Document) { return _buildDocumentExportTab(context); } return _buildDatabaseExportTab(context); } Widget _buildDocumentExportTab(BuildContext context) { return Column( children: [ const VSpace(10), _ExportButton( title: LocaleKeys.shareAction_html.tr(), svg: FlowySvgs.export_html_s, onTap: () => _exportHTML(context), ), const VSpace(10), _ExportButton( title: LocaleKeys.shareAction_markdown.tr(), svg: FlowySvgs.export_markdown_s, onTap: () => _exportMarkdown(context), ), const VSpace(10), _ExportButton( title: LocaleKeys.shareAction_clipboard.tr(), svg: FlowySvgs.duplicate_s, onTap: () => _exportToClipboard(context), ), if (kDebugMode) ...[ const VSpace(10), _ExportButton( title: 'JSON (Debug Mode)', svg: FlowySvgs.duplicate_s, onTap: () => _exportJSON(context), ), ], ], ); } Widget _buildDatabaseExportTab(BuildContext context) { return Column( children: [ const VSpace(10), _ExportButton( title: LocaleKeys.shareAction_csv.tr(), svg: FlowySvgs.database_layout_s, onTap: () => _exportCSV(context), ), if (kDebugMode) ...[ const VSpace(10), _ExportButton( title: 'Raw Database Data (Debug Mode)', svg: FlowySvgs.duplicate_s, onTap: () => _exportRawDatabaseData(context), ), ], ], ); } Future _exportHTML(BuildContext context) async { final viewName = context.read().state.viewName; final exportPath = await getIt().saveFile( dialogTitle: '', fileName: '${viewName.toFileName()}.html', ); if (context.mounted && exportPath != null) { context.read().add( ShareEvent.share( ShareType.html, exportPath, ), ); } } Future _exportMarkdown(BuildContext context) async { final viewName = context.read().state.viewName; final exportPath = await getIt().saveFile( dialogTitle: '', fileName: '${viewName.toFileName()}.zip', ); if (context.mounted && exportPath != null) { context.read().add( ShareEvent.share( ShareType.markdown, exportPath, ), ); } } Future _exportJSON(BuildContext context) async { final viewName = context.read().state.viewName; final exportPath = await getIt().saveFile( dialogTitle: '', fileName: '${viewName.toFileName()}.json', ); if (context.mounted && exportPath != null) { context.read().add( ShareEvent.share( ShareType.json, exportPath, ), ); } } Future _exportCSV(BuildContext context) async { final viewName = context.read().state.viewName; final exportPath = await getIt().saveFile( dialogTitle: '', fileName: '${viewName.toFileName()}.csv', ); if (context.mounted && exportPath != null) { context.read().add( ShareEvent.share( ShareType.csv, exportPath, ), ); } } Future _exportRawDatabaseData(BuildContext context) async { final viewName = context.read().state.viewName; final exportPath = await getIt().saveFile( dialogTitle: '', fileName: '${viewName.toFileName()}.json', ); if (context.mounted && exportPath != null) { context.read().add( ShareEvent.share( ShareType.rawDatabaseData, exportPath, ), ); } } Future _exportToClipboard(BuildContext context) async { final documentExporter = DocumentExporter(context.read().view); final result = await documentExporter.export(DocumentExportType.markdown); result.fold( (markdown) { getIt().setData( ClipboardServiceData(plainText: markdown), ); showToastNotification( message: LocaleKeys.message_copy_success.tr(), ); }, (error) => showToastNotification(message: error.msg), ); } } class _ExportButton extends StatelessWidget { const _ExportButton({ required this.title, required this.svg, required this.onTap, }); final String title; final FlowySvgData svg; final VoidCallback onTap; @override Widget build(BuildContext context) { final color = Theme.of(context).isLightMode ? const Color(0x1E14171B) : Colors.white.withValues(alpha: 0.1); final radius = BorderRadius.circular(10.0); return FlowyButton( margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), iconPadding: 12, decoration: BoxDecoration( border: Border.all( color: color, ), borderRadius: radius, ), radius: radius, text: FlowyText( title, lineHeight: 1.0, ), leftIcon: FlowySvg(svg), onTap: onTap, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/publish_color_extension.dart ================================================ import 'package:appflowy/util/theme_extension.dart'; import 'package:flutter/material.dart'; class ShareMenuColors { static Color borderColor(BuildContext context) { final borderColor = Theme.of(context).isLightMode ? const Color(0x1E14171B) : Colors.white.withValues(alpha: 0.1); return borderColor; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/publish_name_generator.dart ================================================ String replaceInvalidChars(String input) { final RegExp invalidCharsRegex = RegExp('[^a-zA-Z0-9-]'); return input.replaceAll(invalidCharsRegex, '-'); } Future generateNameSpace() async { return ''; } // The backend limits the publish name to a maximum of 120 characters. // If the combined length of the ID and the name exceeds 120 characters, // we will truncate the name to ensure the final result is within the limit. // The name should only contain alphanumeric characters and hyphens. Future generatePublishName(String id, String name) async { if (name.length >= 120 - id.length) { name = name.substring(0, 120 - id.length); } return replaceInvalidChars('$name-$id'); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/plugins/shared/share/publish_color_extension.dart'; import 'package:appflowy/plugins/shared/share/publish_name_generator.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/shared/error_code/error_code_map.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class PublishTab extends StatelessWidget { const PublishTab({ super.key, required this.viewName, }); final String viewName; @override Widget build(BuildContext context) { return BlocConsumer( listener: (context, state) { _showToast(context, state); }, builder: (context, state) { if (state.isPublished) { return _PublishedWidget( url: state.url, pathName: state.pathName, namespace: state.namespace, onVisitSite: (url) => afLaunchUrlString(url), onUnPublish: () { context.read().add(const ShareEvent.unPublish()); }, ); } else { return _PublishWidget( onPublish: (selectedViews) async { final id = context.read().view.id; final lastPublishName = context.read().state.pathName; final publishName = lastPublishName.orDefault( await generatePublishName( id, viewName, ), ); if (selectedViews.isNotEmpty) { Log.info( 'Publishing views: ${selectedViews.map((e) => e.name)}', ); } if (context.mounted) { context.read().add( ShareEvent.publish( '', publishName, selectedViews.map((e) => e.id).toList(), ), ); } }, ); } }, ); } void _showToast(BuildContext context, ShareState state) { if (state.publishResult != null) { state.publishResult!.fold( (value) => showToastNotification( message: LocaleKeys.publish_publishSuccessfully.tr(), ), (error) => showToastNotification( message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', type: ToastificationType.error, ), ); } else if (state.unpublishResult != null) { state.unpublishResult!.fold( (value) => showToastNotification( message: LocaleKeys.publish_unpublishSuccessfully.tr(), ), (error) => showToastNotification( message: LocaleKeys.publish_unpublishFailed.tr(), description: error.msg, type: ToastificationType.error, ), ); } else if (state.updatePathNameResult != null) { state.updatePathNameResult!.fold( (value) => showToastNotification( message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ), (error) { Log.error('update path name failed: $error'); showToastNotification( message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), type: ToastificationType.error, description: error.code.publishErrorMessage, ); }, ); } } } class _PublishedWidget extends StatefulWidget { const _PublishedWidget({ required this.url, required this.pathName, required this.namespace, required this.onVisitSite, required this.onUnPublish, }); final String url; final String pathName; final String namespace; final void Function(String url) onVisitSite; final VoidCallback onUnPublish; @override State<_PublishedWidget> createState() => _PublishedWidgetState(); } class _PublishedWidgetState extends State<_PublishedWidget> { final controller = TextEditingController(); @override void initState() { super.initState(); controller.text = widget.pathName; } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(16), const _PublishTabHeader(), const VSpace(16), _PublishUrl( namespace: widget.namespace, controller: controller, onCopy: (_) { final url = context.read().state.url; getIt().setData( ClipboardServiceData(plainText: url), ); showToastNotification( message: LocaleKeys.message_copy_success.tr(), ); }, onSubmitted: (pathName) { context.read().add(ShareEvent.updatePathName(pathName)); }, ), const VSpace(16), Row( mainAxisSize: MainAxisSize.min, children: [ const Spacer(), UnPublishButton( onUnPublish: widget.onUnPublish, ), const HSpace(6), _buildVisitSiteButton(), ], ), ], ); } Widget _buildVisitSiteButton() { return RoundedTextButton( width: 108, height: 36, onPressed: () { final url = context.read().state.url; widget.onVisitSite(url); }, title: LocaleKeys.shareAction_visitSite.tr(), borderRadius: const BorderRadius.all(Radius.circular(10)), fillColor: Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), textColor: Theme.of(context).colorScheme.onPrimary, ); } } class UnPublishButton extends StatelessWidget { const UnPublishButton({ super.key, required this.onUnPublish, }); final VoidCallback onUnPublish; @override Widget build(BuildContext context) { return SizedBox( width: 108, height: 36, child: FlowyButton( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all(color: ShareMenuColors.borderColor(context)), ), radius: BorderRadius.circular(10), text: FlowyText.regular( lineHeight: 1.0, LocaleKeys.shareAction_unPublish.tr(), textAlign: TextAlign.center, ), onTap: onUnPublish, ), ); } } class _PublishWidget extends StatefulWidget { const _PublishWidget({ required this.onPublish, }); final void Function(List selectedViews) onPublish; @override State<_PublishWidget> createState() => _PublishWidgetState(); } class _PublishWidgetState extends State<_PublishWidget> { List _selectedViews = []; @override Widget build(BuildContext context) { final accessLevel = context.read().state.accessLevel; Widget publishButton = PublishButton( onPublish: () { if (context.read().view.layout.isDatabaseView) { // check if any database is selected if (_selectedViews.isEmpty) { showToastNotification( message: LocaleKeys.publish_noDatabaseSelected.tr(), ); return; } } widget.onPublish(_selectedViews); }, ); if (accessLevel == ShareAccessLevel.readOnly) { // readonly user can't publish a page. publishButton = FlowyTooltip( message: 'You are a readonly user, you can\'t publish a page.', child: AbsorbPointer( child: publishButton, ), ); } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(16), const _PublishTabHeader(), const VSpace(16), // if current view is a database, show the database selector if (context.read().view.layout.isDatabaseView) ...[ _PublishDatabaseSelector( view: context.read().view, onSelected: (selectedDatabases) { _selectedViews = selectedDatabases; }, ), const VSpace(16), ], publishButton, ], ); } } class PublishButton extends StatelessWidget { const PublishButton({ super.key, required this.onPublish, }); final VoidCallback onPublish; @override Widget build(BuildContext context) { return PrimaryRoundedButton( text: LocaleKeys.shareAction_publish.tr(), useIntrinsicWidth: false, margin: const EdgeInsets.symmetric(vertical: 9.0), fontSize: 14.0, figmaLineHeight: 18.0, onTap: onPublish, ); } } class _PublishTabHeader extends StatelessWidget { const _PublishTabHeader(); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const FlowySvg(FlowySvgs.share_publish_s), const HSpace(6), FlowyText(LocaleKeys.shareAction_publishToTheWeb.tr()), ], ), const VSpace(4), FlowyText.regular( LocaleKeys.shareAction_publishToTheWebHint.tr(), fontSize: 12, maxLines: 3, color: Theme.of(context).hintColor, ), ], ); } } class _PublishUrl extends StatefulWidget { const _PublishUrl({ required this.namespace, required this.controller, required this.onCopy, required this.onSubmitted, }); final String namespace; final TextEditingController controller; final void Function(String url) onCopy; final void Function(String url) onSubmitted; @override State<_PublishUrl> createState() => _PublishUrlState(); } class _PublishUrlState extends State<_PublishUrl> { final focusNode = FocusNode(); bool showSaveButton = false; @override void initState() { super.initState(); focusNode.addListener(_onFocusChanged); } void _onFocusChanged() => setState(() => showSaveButton = focusNode.hasFocus); @override void dispose() { focusNode.removeListener(_onFocusChanged); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: 36, child: FlowyTextField( autoFocus: false, controller: widget.controller, focusNode: focusNode, enableBorderColor: ShareMenuColors.borderColor(context), prefixIcon: _buildPrefixIcon(context), suffixIcon: _buildSuffixIcon(context), textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 14, height: 18.0 / 14.0, ), ), ); } Widget _buildPrefixIcon(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 230), child: Row( mainAxisSize: MainAxisSize.min, children: [ const HSpace(8.0), Flexible( child: FlowyText.regular( ShareConstants.buildNamespaceUrl( nameSpace: '${widget.namespace}/', ), fontSize: 14, figmaLineHeight: 18.0, overflow: TextOverflow.ellipsis, ), ), const HSpace(6.0), const Padding( padding: EdgeInsets.symmetric(vertical: 2.0), child: VerticalDivider( thickness: 1.0, width: 1.0, ), ), const HSpace(6.0), ], ), ); } Widget _buildSuffixIcon(BuildContext context) { return showSaveButton ? _buildSaveButton(context) : _buildCopyLinkIcon(context); } Widget _buildSaveButton(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), child: FlowyButton( useIntrinsicWidth: true, text: FlowyText.regular( LocaleKeys.button_save.tr(), figmaLineHeight: 18.0, ), onTap: () { widget.onSubmitted(widget.controller.text); focusNode.unfocus(); }, ), ); } Widget _buildCopyLinkIcon(BuildContext context) { return FlowyHover( style: const HoverStyle( contentMargin: EdgeInsets.all(4), ), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => widget.onCopy(widget.controller.text), child: Container( width: 32, height: 32, alignment: Alignment.center, padding: const EdgeInsets.all(6), decoration: const BoxDecoration( border: Border(left: BorderSide(color: Color(0x141F2329))), ), child: const FlowySvg( FlowySvgs.m_toolbar_link_m, ), ), ), ); } } // used to select which database view should be published class _PublishDatabaseSelector extends StatefulWidget { const _PublishDatabaseSelector({ required this.view, required this.onSelected, }); final ViewPB view; final void Function(List selectedDatabases) onSelected; @override State<_PublishDatabaseSelector> createState() => _PublishDatabaseSelectorState(); } class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> { final PropertyValueNotifier> _databaseStatus = PropertyValueNotifier>([]); late final _borderColor = Theme.of(context).hintColor.withValues(alpha: 0.3); @override void initState() { super.initState(); _databaseStatus.addListener(_onDatabaseStatusChanged); _databaseStatus.value = context .read() .state .tabBars .map((e) => (e.view, true)) .toList(); } void _onDatabaseStatusChanged() { final selectedDatabases = _databaseStatus.value.where((e) => e.$2).map((e) => e.$1).toList(); widget.onSelected(selectedDatabases); } @override void dispose() { _databaseStatus.removeListener(_onDatabaseStatusChanged); _databaseStatus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Container( clipBehavior: Clip.antiAlias, decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: BorderSide(color: _borderColor), borderRadius: BorderRadius.circular(8), ), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(10), _buildSelectedDatabaseCount(context), const VSpace(10), _buildDivider(context), const VSpace(10), ...state.tabBars.map( (e) => _buildDatabaseSelector(context, e), ), ], ), ); }, ); } Widget _buildDivider(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Divider( color: _borderColor, thickness: 1, height: 1, ), ); } Widget _buildSelectedDatabaseCount(BuildContext context) { return ValueListenableBuilder( valueListenable: _databaseStatus, builder: (context, selectedDatabases, child) { final count = selectedDatabases.where((e) => e.$2).length; return Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: FlowyText( LocaleKeys.publish_database.plural(count).tr(), color: Theme.of(context).hintColor, fontSize: 13, ), ); }, ); } Widget _buildDatabaseSelector(BuildContext context, DatabaseTabBar tabBar) { final isPrimaryDatabase = tabBar.view.id == widget.view.id; return ValueListenableBuilder( valueListenable: _databaseStatus, builder: (context, selectedDatabases, child) { final isSelected = selectedDatabases.any( (e) => e.$1.id == tabBar.view.id && e.$2, ); return _DatabaseSelectorItem( tabBar: tabBar, isSelected: isSelected, isPrimaryDatabase: isPrimaryDatabase, onTap: () { // unable to deselect the primary database if (isPrimaryDatabase) { showToastNotification( message: LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(), ); return; } // toggle the selection status _databaseStatus.value = _databaseStatus.value .map( (e) => e.$1.id == tabBar.view.id ? (e.$1, !e.$2) : (e.$1, e.$2), ) .toList(); }, ); }, ); } } class _DatabaseSelectorItem extends StatelessWidget { const _DatabaseSelectorItem({ required this.tabBar, required this.isSelected, required this.onTap, required this.isPrimaryDatabase, }); final DatabaseTabBar tabBar; final bool isSelected; final VoidCallback onTap; final bool isPrimaryDatabase; @override Widget build(BuildContext context) { Widget child = _buildItem(context); if (!isPrimaryDatabase) { child = FlowyHover( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: child, ), ); } else { child = FlowyTooltip( message: LocaleKeys.publish_mustSelectPrimaryDatabase.tr(), child: MouseRegion( cursor: SystemMouseCursors.forbidden, child: child, ), ); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: child, ); } Widget _buildItem(BuildContext context) { final svg = isPrimaryDatabase ? FlowySvgs.unable_select_s : isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s; final blendMode = isPrimaryDatabase ? BlendMode.srcIn : null; return Container( height: 30, padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ FlowySvg( svg, blendMode: blendMode, size: const Size.square(18), ), const HSpace(9.0), FlowySvg( tabBar.view.layout.icon, size: const Size.square(16), ), const HSpace(6.0), FlowyText.regular( tabBar.view.name, fontSize: 14, overflow: TextOverflow.ellipsis, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart ================================================ import 'dart:io'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/export/document_exporter.dart'; import 'package:appflowy/workspace/application/settings/share/export_service.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'constants.dart'; part 'share_bloc.freezed.dart'; class ShareBloc extends Bloc { ShareBloc({ required this.view, }) : super(ShareState.initial()) { on((event, emit) async { await event.when( initial: () async { viewListener = ViewListener(viewId: view.id) ..start( onViewUpdated: (value) { add(ShareEvent.updateViewName(value.name, value.id)); }, onViewMoveToTrash: (p0) { add(const ShareEvent.setPublishStatus(false)); }, ); add(const ShareEvent.updatePublishStatus()); }, share: (type, path) async => _share( type, path, emit, ), publish: (nameSpace, publishName, selectedViewIds) => _publish( nameSpace, publishName, selectedViewIds, emit, ), unPublish: () async => _unpublish(emit), updatePublishStatus: () async => _updatePublishStatus(emit), updateViewName: (viewName, viewId) async { emit( state.copyWith( viewName: viewName, viewId: viewId, updatePathNameResult: null, publishResult: null, unpublishResult: null, ), ); }, setPublishStatus: (isPublished) { emit( state.copyWith( isPublished: isPublished, url: isPublished ? state.url : '', ), ); }, updatePathName: (pathName) async => _updatePathName( pathName, emit, ), clearPathNameResult: () async { emit( state.copyWith( updatePathNameResult: null, ), ); }, ); }); } final ViewPB view; late final ViewListener viewListener; late final documentExporter = DocumentExporter(view); @override Future close() async { await viewListener.stop(); return super.close(); } Future _share( ShareType type, String? path, Emitter emit, ) async { if (ShareType.unimplemented.contains(type)) { Log.error('DocumentShareType $type is not implemented'); return; } emit(state.copyWith(isLoading: true)); final result = await _export(type, path); emit( state.copyWith( isLoading: false, exportResult: result, ), ); } Future _publish( String nameSpace, String publishName, List selectedViewIds, Emitter emit, ) async { // set space name try { final result = await ViewBackendService.getPublishNameSpace().getOrThrow(); await ViewBackendService.publish( view, name: publishName, selectedViewIds: selectedViewIds, ).getOrThrow(); emit( state.copyWith( isPublished: true, publishResult: FlowySuccess(null), unpublishResult: null, namespace: result.namespace, pathName: publishName, url: ShareConstants.buildPublishUrl( nameSpace: result.namespace, publishName: publishName, ), ), ); Log.info('publish success: ${result.namespace}/$publishName'); } catch (e) { Log.error('publish error: $e'); emit( state.copyWith( isPublished: false, publishResult: FlowyResult.failure( FlowyError(msg: 'publish error: $e'), ), unpublishResult: null, url: '', ), ); } } Future _unpublish(Emitter emit) async { emit( state.copyWith( publishResult: null, unpublishResult: null, ), ); final result = await ViewBackendService.unpublish(view); final isPublished = !result.isSuccess; result.onFailure((f) { Log.error('unpublish error: $f'); }); emit( state.copyWith( isPublished: isPublished, publishResult: null, unpublishResult: result, url: result.fold((_) => '', (_) => state.url), ), ); } Future _updatePublishStatus(Emitter emit) async { final publishInfo = await ViewBackendService.getPublishInfo(view); final enablePublish = await UserBackendService.getCurrentUserProfile().fold( (v) => v.workspaceType == WorkspaceTypePB.ServerW, (p) => false, ); // skip the "Record not found" error, it's because the view is not published yet publishInfo.fold( (s) { Log.info( 'get publish info success: $publishInfo for view: ${view.name}(${view.id})', ); }, (f) { if (![ ErrorCode.RecordNotFound, ErrorCode.LocalVersionNotSupport, ].contains(f.code)) { Log.info( 'get publish info failed: $f for view: ${view.name}(${view.id})', ); } }, ); String workspaceId = state.workspaceId; if (workspaceId.isEmpty) { workspaceId = await UserBackendService.getCurrentWorkspace().fold( (s) => s.id, (f) => '', ); } final (isPublished, namespace, pathName, url) = publishInfo.fold( (s) { return ( // if the unpublishedAtTimestampSec is not set, it means the view is not unpublished. !s.hasUnpublishedAtTimestampSec(), s.namespace, s.publishName, ShareConstants.buildPublishUrl( nameSpace: s.namespace, publishName: s.publishName, ), ); }, (f) => (false, '', '', ''), ); emit( state.copyWith( isPublished: isPublished, namespace: namespace, pathName: pathName, url: url, viewName: view.name, enablePublish: enablePublish, workspaceId: workspaceId, viewId: view.id, ), ); } Future _updatePathName( String pathName, Emitter emit, ) async { emit( state.copyWith( updatePathNameResult: null, ), ); if (pathName.isEmpty) { emit( state.copyWith( updatePathNameResult: FlowyResult.failure( FlowyError( code: ErrorCode.ViewNameInvalid, msg: 'Path name is invalid', ), ), ), ); return; } final request = SetPublishNamePB() ..viewId = view.id ..newName = pathName; final result = await FolderEventSetPublishName(request).send(); emit( state.copyWith( updatePathNameResult: result, publishResult: null, unpublishResult: null, pathName: result.fold( (_) => pathName, (f) => state.pathName, ), url: result.fold( (s) => ShareConstants.buildPublishUrl( nameSpace: state.namespace, publishName: pathName, ), (f) => state.url, ), ), ); } Future> _export( ShareType type, String? path, ) async { final FlowyResult result; if (type == ShareType.csv) { final exportResult = await BackendExportService.exportDatabaseAsCSV( view.id, ); result = exportResult.fold( (s) => FlowyResult.success(s.data), (f) => FlowyResult.failure(f), ); } else if (type == ShareType.rawDatabaseData) { final exportResult = await BackendExportService.exportDatabaseAsRawData( view.id, ); result = exportResult.fold( (s) => FlowyResult.success(s.data), (f) => FlowyResult.failure(f), ); } else { result = await documentExporter.export(type.documentExportType, path: path); } return result.fold( (s) { if (path != null) { switch (type) { case ShareType.html: case ShareType.csv: case ShareType.json: case ShareType.rawDatabaseData: File(path).writeAsStringSync(s); return FlowyResult.success(type); case ShareType.markdown: return FlowyResult.success(type); default: break; } } return FlowyResult.failure(FlowyError()); }, (f) => FlowyResult.failure(f), ); } } enum ShareType { // available in document markdown, html, text, link, json, // only available in database csv, rawDatabaseData; static List get unimplemented => [link]; DocumentExportType get documentExportType { switch (this) { case ShareType.markdown: return DocumentExportType.markdown; case ShareType.html: return DocumentExportType.html; case ShareType.text: return DocumentExportType.text; case ShareType.json: return DocumentExportType.json; case ShareType.csv: throw UnsupportedError('DocumentShareType.csv is not supported'); case ShareType.link: throw UnsupportedError('DocumentShareType.link is not supported'); case ShareType.rawDatabaseData: throw UnsupportedError( 'DocumentShareType.rawDatabaseData is not supported', ); } } } @freezed class ShareEvent with _$ShareEvent { const factory ShareEvent.initial() = _Initial; const factory ShareEvent.share( ShareType type, String? path, ) = _Share; const factory ShareEvent.publish( String nameSpace, String pageId, List selectedViewIds, ) = _Publish; const factory ShareEvent.unPublish() = _UnPublish; const factory ShareEvent.updateViewName(String name, String viewId) = _UpdateViewName; const factory ShareEvent.updatePublishStatus() = _UpdatePublishStatus; const factory ShareEvent.setPublishStatus(bool isPublished) = _SetPublishStatus; const factory ShareEvent.updatePathName(String pathName) = _UpdatePathName; const factory ShareEvent.clearPathNameResult() = _ClearPathNameResult; } @freezed class ShareState with _$ShareState { const factory ShareState({ required bool isPublished, required bool isLoading, required String url, required String viewName, required bool enablePublish, FlowyResult? exportResult, FlowyResult? publishResult, FlowyResult? unpublishResult, FlowyResult? updatePathNameResult, required String viewId, required String workspaceId, required String namespace, required String pathName, }) = _ShareState; factory ShareState.initial() => const ShareState( isLoading: false, isPublished: false, enablePublish: true, url: '', viewName: '', viewId: '', workspaceId: '', namespace: '', pathName: '', ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart ================================================ import 'package:appflowy/features/share_tab/data/repositories/rust_share_with_user_repository_impl.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/shared/share/_shared.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/plugins/shared/share/share_menu.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ShareButton extends StatelessWidget { const ShareButton({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { final workspaceBloc = context.read(); final workspaceId = workspaceBloc.state.currentWorkspace?.workspaceId ?? ''; final workspaceType = workspaceBloc.state.currentWorkspace?.workspaceType; return MultiBlocProvider( providers: [ BlocProvider( create: (context) => getIt(param1: view)..add(const ShareEvent.initial()), ), if (view.layout.isDatabaseView) BlocProvider( create: (context) => DatabaseTabBarBloc( view: view, compactModeId: view.id, enableCompactMode: false, )..add(const DatabaseTabBarEvent.initial()), ), BlocProvider( create: (context) { final bloc = ShareTabBloc( repository: RustShareWithUserRepositoryImpl(), pageId: view.id, workspaceId: workspaceId, ); if (workspaceType != WorkspaceTypePB.LocalW) { bloc.add(ShareTabEvent.initialize()); } return bloc; }, ), ], child: BlocListener( listener: (context, state) { if (!state.isLoading && state.exportResult != null) { state.exportResult!.fold( (data) => _handleExportSuccess(context, data), (error) => _handleExportError(context, error), ); } }, child: BlocBuilder( builder: (context, state) { final tabs = [ if (state.enablePublish) ...[ // share the same permission with publish ShareMenuTab.share, ShareMenuTab.publish, ], ShareMenuTab.exportAs, ]; return ShareMenuButton(tabs: tabs); }, ), ), ); } void _handleExportSuccess(BuildContext context, ShareType shareType) { switch (shareType) { case ShareType.markdown: case ShareType.html: case ShareType.csv: showToastNotification( message: LocaleKeys.settings_files_exportFileSuccess.tr(), ); break; default: break; } } void _handleExportError(BuildContext context, FlowyError error) { showToastNotification( message: '${LocaleKeys.settings_files_exportFileFail.tr()}: ${error.code}', ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/share_menu.dart ================================================ import 'package:appflowy/features/share_tab/presentation/share_tab.dart' as share_section; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; import 'package:appflowy/plugins/shared/share/export_tab.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/plugins/shared/share/share_tab.dart' as share_plugin; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'publish_tab.dart'; enum ShareMenuTab { share, publish, exportAs; String get i18n { switch (this) { case ShareMenuTab.share: return LocaleKeys.shareAction_shareTab.tr(); case ShareMenuTab.publish: return LocaleKeys.shareAction_publishTab.tr(); case ShareMenuTab.exportAs: return LocaleKeys.shareAction_exportAsTab.tr(); } } } class ShareMenu extends StatefulWidget { const ShareMenu({ super.key, required this.tabs, required this.viewName, required this.onClose, }); final List tabs; final String viewName; final VoidCallback onClose; @override State createState() => _ShareMenuState(); } class _ShareMenuState extends State with SingleTickerProviderStateMixin { late ShareMenuTab selectedTab = widget.tabs.first; late final tabController = TabController( length: widget.tabs.length, vsync: this, initialIndex: widget.tabs.indexOf(selectedTab), ); @override Widget build(BuildContext context) { if (widget.tabs.isEmpty) { return const SizedBox.shrink(); } final theme = AppFlowyTheme.of(context); return Column( mainAxisSize: MainAxisSize.min, children: [ VSpace(theme.spacing.xs), Container( alignment: Alignment.centerLeft, height: 28, child: _buildTabBar(context), ), const AFDivider(), Padding( padding: EdgeInsets.symmetric(horizontal: theme.spacing.m), child: _buildTab(context), ), ], ); } @override void dispose() { tabController.dispose(); super.dispose(); } Widget _buildTabBar(BuildContext context) { final theme = AppFlowyTheme.of(context); final children = [ for (final tab in widget.tabs) Padding( padding: EdgeInsets.only(bottom: theme.spacing.s), child: _Segment( tab: tab, isSelected: selectedTab == tab, ), ), ]; return TabBar( indicatorSize: TabBarIndicatorSize.label, indicator: RoundUnderlineTabIndicator( width: 68.0, borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, width: 3, ), insets: const EdgeInsets.only(bottom: -1), ), isScrollable: true, controller: tabController, tabs: children, onTap: (index) { setState(() { selectedTab = widget.tabs[index]; }); }, ); } Widget _buildTab(BuildContext context) { switch (selectedTab) { case ShareMenuTab.publish: return PublishTab( viewName: widget.viewName, ); case ShareMenuTab.exportAs: return const ExportTab(); case ShareMenuTab.share: if (FeatureFlag.sharedSection.isOn) { final workspace = context.read().state.currentWorkspace; final workspaceId = workspace?.workspaceId ?? context.read().state.workspaceId; final pageId = context.read().state.viewId; final isInProPlan = context .read() .state .workspaceSubscriptionInfo ?.plan == WorkspacePlanPB.ProPlan; return share_section.ShareTab( workspaceId: workspaceId, pageId: pageId, workspaceName: workspace?.name ?? '', workspaceIcon: workspace?.icon ?? '', isInProPlan: isInProPlan, onUpgradeToPro: () { widget.onClose(); _showUpgradeToProDialog(context); }, ); } return const share_plugin.ShareTab(); } } void _showUpgradeToProDialog(BuildContext context) { final state = context.read().state; final workspace = state.currentWorkspace; if (workspace == null) { Log.error('workspace is null'); return; } final workspaceId = workspace.workspaceId; final subscriptionInfo = state.workspaceSubscriptionInfo; final userProfile = state.userProfile; if (subscriptionInfo == null) { Log.error('subscriptionInfo is null'); return; } final role = workspace.role; final title = switch (role) { AFRolePB.Owner => LocaleKeys.shareTab_upgradeToInviteGuest_title_owner.tr(), AFRolePB.Member => LocaleKeys.shareTab_upgradeToInviteGuest_title_member.tr(), AFRolePB.Guest || _ => LocaleKeys.shareTab_upgradeToInviteGuest_title_guest.tr(), }; final description = switch (role) { AFRolePB.Owner => LocaleKeys.shareTab_upgradeToInviteGuest_description_owner.tr(), AFRolePB.Member => LocaleKeys.shareTab_upgradeToInviteGuest_description_member.tr(), AFRolePB.Guest || _ => LocaleKeys.shareTab_upgradeToInviteGuest_description_guest.tr(), }; final style = switch (role) { AFRolePB.Owner => ConfirmPopupStyle.cancelAndOk, AFRolePB.Member || AFRolePB.Guest || _ => ConfirmPopupStyle.onlyOk, }; final confirmLabel = switch (role) { AFRolePB.Owner => LocaleKeys.shareTab_upgrade.tr(), AFRolePB.Member || AFRolePB.Guest || _ => LocaleKeys.button_ok.tr(), }; if (role == AFRolePB.Owner) { showDialog( context: context, builder: (_) => BlocProvider( create: (_) => SettingsPlanBloc( workspaceId: workspaceId, userId: userProfile.id, )..add(const SettingsPlanEvent.started()), child: SettingsPlanComparisonDialog( workspaceId: workspaceId, subscriptionInfo: subscriptionInfo, ), ), ); } else { showConfirmDialog( context: Navigator.of(context, rootNavigator: true).context, title: title, description: description, style: style, confirmLabel: confirmLabel, confirmButtonColor: Theme.of(context).colorScheme.primary, onConfirm: (context) { // fixme: show the upgrade to pro dialog }, ); } } } class _Segment extends StatefulWidget { const _Segment({ required this.tab, required this.isSelected, }); final bool isSelected; final ShareMenuTab tab; @override State<_Segment> createState() => _SegmentState(); } class _SegmentState extends State<_Segment> { bool isHovered = false; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final textColor = widget.isSelected || isHovered ? theme.textColorScheme.primary : theme.textColorScheme.secondary; Widget child = MouseRegion( onEnter: (_) => setState(() => isHovered = true), onExit: (_) => setState(() => isHovered = false), child: Text( widget.tab.i18n, textAlign: TextAlign.center, style: theme.textStyle.body.enhanced( color: textColor, ), ), ); if (widget.tab == ShareMenuTab.publish) { final isPublished = context.watch().state.isPublished; // show checkmark icon if published if (isPublished) { child = Row( children: [ const FlowySvg( FlowySvgs.published_checkmark_s, blendMode: null, ), const HSpace(6), child, ], ); } } return child; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'constants.dart'; class ShareTab extends StatelessWidget { const ShareTab({ super.key, }); @override Widget build(BuildContext context) { return const Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ VSpace(18), _ShareTabHeader(), VSpace(2), _ShareTabDescription(), VSpace(14), _ShareTabContent(), ], ); } } class _ShareTabHeader extends StatelessWidget { const _ShareTabHeader(); @override Widget build(BuildContext context) { return Row( children: [ const FlowySvg(FlowySvgs.share_tab_icon_s), const HSpace(6), FlowyText.medium( LocaleKeys.shareAction_shareTabTitle.tr(), figmaLineHeight: 18.0, ), ], ); } } class _ShareTabDescription extends StatelessWidget { const _ShareTabDescription(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 2.0), child: FlowyText.regular( LocaleKeys.shareAction_shareTabDescription.tr(), fontSize: 13.0, figmaLineHeight: 18.0, color: Theme.of(context).hintColor, ), ); } } class _ShareTabContent extends StatelessWidget { const _ShareTabContent(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final shareUrl = ShareConstants.buildShareUrl( workspaceId: state.workspaceId, viewId: state.viewId, ); return Row( children: [ Expanded( child: SizedBox( height: 36, child: FlowyTextField( text: shareUrl, // todo: add workspace id + view id readOnly: true, borderRadius: BorderRadius.circular(10), ), ), ), const HSpace(8.0), PrimaryRoundedButton( margin: const EdgeInsets.symmetric( vertical: 9.0, horizontal: 14.0, ), text: LocaleKeys.button_copyLink.tr(), figmaLineHeight: 18.0, leftIcon: FlowySvg( FlowySvgs.share_tab_copy_s, color: Theme.of(context).colorScheme.onPrimary, ), onTap: () => _copy(context, shareUrl), ), ], ); }, ); } void _copy(BuildContext context, String url) { getIt().setData( ClipboardServiceData(plainText: url), ); showToastNotification( message: LocaleKeys.message_copy_success.tr(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/shared/sync_indicator.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/sync/database_sync_bloc.dart'; import 'package:appflowy/plugins/document/application/document_sync_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DocumentSyncIndicator extends StatelessWidget { const DocumentSyncIndicator({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DocumentSyncBloc(view: view)..add(const DocumentSyncEvent.initial()), child: BlocBuilder( builder: (context, state) { // don't show indicator if user is local if (!state.shouldShowIndicator) { return const SizedBox.shrink(); } final Color color; final String hintText; if (!state.isNetworkConnected) { color = Colors.grey; hintText = LocaleKeys.newSettings_syncState_noNetworkConnected.tr(); } else { switch (state.syncState) { case DocumentSyncState.SyncFinished: color = Colors.green; hintText = LocaleKeys.newSettings_syncState_synced.tr(); break; case DocumentSyncState.Syncing: case DocumentSyncState.InitSyncBegin: color = Colors.yellow; hintText = LocaleKeys.newSettings_syncState_syncing.tr(); break; default: return const SizedBox.shrink(); } } return FlowyTooltip( message: hintText, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: color, ), width: 8, height: 8, ), ); }, ), ); } } class DatabaseSyncIndicator extends StatelessWidget { const DatabaseSyncIndicator({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DatabaseSyncBloc(view: view)..add(const DatabaseSyncEvent.initial()), child: BlocBuilder( builder: (context, state) { // don't show indicator if user is local if (!state.shouldShowIndicator) { return const SizedBox.shrink(); } final Color color; final String hintText; if (!state.isNetworkConnected) { color = Colors.grey; hintText = LocaleKeys.newSettings_syncState_noNetworkConnected.tr(); } else { switch (state.syncState) { case DatabaseSyncState.SyncFinished: color = Colors.green; hintText = LocaleKeys.newSettings_syncState_synced.tr(); break; case DatabaseSyncState.Syncing: case DatabaseSyncState.InitSyncBegin: color = Colors.yellow; hintText = LocaleKeys.newSettings_syncState_syncing.tr(); break; default: return const SizedBox.shrink(); } } return FlowyTooltip( message: hintText, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: color, ), width: 8, height: 8, ), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/trash/application/prelude.dart ================================================ export 'trash_bloc.dart'; export 'trash_listener.dart'; export 'trash_service.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart ================================================ import 'package:appflowy/plugins/trash/application/trash_listener.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'trash_bloc.freezed.dart'; class TrashBloc extends Bloc { TrashBloc() : _service = TrashService(), _listener = TrashListener(), super(TrashState.init()) { _dispatch(); } final TrashService _service; final TrashListener _listener; void _dispatch() { on((event, emit) async { await event.map( initial: (e) async { _listener.start(trashUpdated: _listenTrashUpdated); final result = await _service.readTrash(); emit( result.fold( (object) => state.copyWith( objects: object.items, successOrFailure: FlowyResult.success(null), ), (error) => state.copyWith(successOrFailure: FlowyResult.failure(error)), ), ); }, didReceiveTrash: (e) async { emit(state.copyWith(objects: e.trash)); }, putback: (e) async { final result = await TrashService.putback(e.trashId); await _handleResult(result, emit); }, delete: (e) async { final result = await _service.deleteViews([e.trash.id]); await _handleResult(result, emit); }, deleteAll: (e) async { final result = await _service.deleteAll(); await _handleResult(result, emit); }, restoreAll: (e) async { final result = await _service.restoreAll(); await _handleResult(result, emit); }, ); }); } Future _handleResult( FlowyResult result, Emitter emit, ) async { emit( result.fold( (l) => state.copyWith(successOrFailure: FlowyResult.success(null)), (error) => state.copyWith(successOrFailure: FlowyResult.failure(error)), ), ); } void _listenTrashUpdated( FlowyResult, FlowyError> trashOrFailed, ) { trashOrFailed.fold( (trash) { add(TrashEvent.didReceiveTrash(trash)); }, (error) { Log.error(error); }, ); } @override Future close() async { await _listener.close(); return super.close(); } } @freezed class TrashEvent with _$TrashEvent { const factory TrashEvent.initial() = Initial; const factory TrashEvent.didReceiveTrash(List trash) = ReceiveTrash; const factory TrashEvent.putback(String trashId) = Putback; const factory TrashEvent.delete(TrashPB trash) = Delete; const factory TrashEvent.restoreAll() = RestoreAll; const factory TrashEvent.deleteAll() = DeleteAll; } @freezed class TrashState with _$TrashState { const factory TrashState({ required List objects, required FlowyResult successOrFailure, }) = _TrashState; factory TrashState.init() => TrashState( objects: [], successOrFailure: FlowyResult.success(null), ); } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/trash/application/trash_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef TrashUpdatedCallback = void Function( FlowyResult, FlowyError> trashOrFailed, ); class TrashListener { StreamSubscription? _subscription; TrashUpdatedCallback? _trashUpdated; FolderNotificationParser? _parser; void start({TrashUpdatedCallback? trashUpdated}) { _trashUpdated = trashUpdated; _parser = FolderNotificationParser( id: "trash", callback: _observableCallback, ); _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); } void _observableCallback( FolderNotification ty, FlowyResult result, ) { switch (ty) { case FolderNotification.DidUpdateTrash: if (_trashUpdated != null) { result.fold( (payload) { final repeatedTrash = RepeatedTrashPB.fromBuffer(payload); _trashUpdated!(FlowyResult.success(repeatedTrash.items)); }, (error) => _trashUpdated!(FlowyResult.failure(error)), ); } break; default: break; } } Future close() async { _parser = null; await _subscription?.cancel(); _trashUpdated = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart ================================================ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class TrashService { Future> readTrash() { return FolderEventListTrashItems().send(); } static Future> putback(String trashId) { final id = TrashIdPB.create()..id = trashId; return FolderEventRestoreTrashItem(id).send(); } Future> deleteViews(List trash) { final items = trash.map((trash) { return TrashIdPB.create()..id = trash; }); final ids = RepeatedTrashIdPB(items: items); return FolderEventPermanentlyDeleteTrashItem(ids).send(); } Future> restoreAll() { return FolderEventRecoverAllTrashItems().send(); } Future> deleteAll() { return FolderEventPermanentlyDeleteAllTrashItem().send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/trash/src/sizes.dart ================================================ class TrashSizes { static double scale = 0.8; static double get headerHeight => 60 * scale; static double get fileNameWidth => 320 * scale; static double get lashModifyWidth => 230 * scale; static double get createTimeWidth => 230 * scale; // padding between createTime and action icon static double get padding => 40 * scale; static double get actionIconWidth => 40 * scale; static double get totalWidth => TrashSizes.fileNameWidth + TrashSizes.lashModifyWidth + TrashSizes.createTimeWidth + TrashSizes.padding + // restore and delete icon 2 * TrashSizes.actionIconWidth; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/trash/src/trash_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; import 'package:flutter/material.dart'; import 'package:fixnum/fixnum.dart' as $fixnum; import 'sizes.dart'; class TrashCell extends StatelessWidget { const TrashCell({ super.key, required this.object, required this.onRestore, required this.onDelete, }); final VoidCallback onRestore; final VoidCallback onDelete; final TrashPB object; @override Widget build(BuildContext context) { return Row( children: [ SizedBox( width: TrashSizes.fileNameWidth, child: FlowyText( object.name.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : object.name, ), ), SizedBox( width: TrashSizes.lashModifyWidth, child: FlowyText(dateFormatter(object.modifiedTime)), ), SizedBox( width: TrashSizes.createTimeWidth, child: FlowyText(dateFormatter(object.createTime)), ), const Spacer(), FlowyIconButton( iconColorOnHover: Theme.of(context).colorScheme.onSurface, width: TrashSizes.actionIconWidth, onPressed: onRestore, iconPadding: const EdgeInsets.all(5), icon: const FlowySvg(FlowySvgs.restore_s), ), const HSpace(20), FlowyIconButton( iconColorOnHover: Theme.of(context).colorScheme.onSurface, width: TrashSizes.actionIconWidth, onPressed: onDelete, iconPadding: const EdgeInsets.all(5), icon: const FlowySvg(FlowySvgs.delete_s), ), ], ); } String dateFormatter($fixnum.Int64 inputTimestamps) { final outputFormat = DateFormat('MM/dd/yyyy hh:mm a'); final date = DateTime.fromMillisecondsSinceEpoch(inputTimestamps.toInt() * 1000); final outputDate = outputFormat.format(date); return outputDate; } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/trash/src/trash_header.dart ================================================ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'sizes.dart'; class TrashHeaderDelegate extends SliverPersistentHeaderDelegate { TrashHeaderDelegate(); @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent, ) { return TrashHeader(); } @override double get maxExtent => TrashSizes.headerHeight; @override double get minExtent => TrashSizes.headerHeight; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return false; } } class TrashHeaderItem { TrashHeaderItem({required this.width, required this.title}); double width; String title; } class TrashHeader extends StatelessWidget { TrashHeader({super.key}); final List items = [ TrashHeaderItem( title: LocaleKeys.trash_pageHeader_fileName.tr(), width: TrashSizes.fileNameWidth, ), TrashHeaderItem( title: LocaleKeys.trash_pageHeader_lastModified.tr(), width: TrashSizes.lashModifyWidth, ), TrashHeaderItem( title: LocaleKeys.trash_pageHeader_created.tr(), width: TrashSizes.createTimeWidth, ), ]; @override Widget build(BuildContext context) { final headerItems = List.empty(growable: true); items.asMap().forEach((index, item) { headerItems.add( SizedBox( width: item.width, child: FlowyText( item.title, color: Theme.of(context).disabledColor, ), ), ); }); return Container( color: Theme.of(context).colorScheme.surface, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ...headerItems, ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/trash/trash.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'trash_page.dart'; export "./src/sizes.dart"; export "./src/trash_cell.dart"; export "./src/trash_header.dart"; class TrashPluginBuilder extends PluginBuilder { @override Plugin build(dynamic data) { return TrashPlugin(pluginType: pluginType); } @override String get menuName => "TrashPB"; @override FlowySvgData get icon => FlowySvgs.trash_m; @override PluginType get pluginType => PluginType.trash; @override ViewLayoutPB get layoutType => ViewLayoutPB.Document; } class TrashPluginConfig implements PluginConfig { @override bool get creatable => false; } class TrashPlugin extends Plugin { TrashPlugin({required PluginType pluginType}) : _pluginType = pluginType; final PluginType _pluginType; @override PluginWidgetBuilder get widgetBuilder => TrashPluginDisplay(); @override PluginId get id => "TrashStack"; @override PluginType get pluginType => _pluginType; } class TrashPluginDisplay extends PluginWidgetBuilder { @override String? get viewName => LocaleKeys.trash_text.tr(); @override Widget get leftBarItem => FlowyText.medium(LocaleKeys.trash_text.tr()); @override Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem; @override Widget? get rightBarItem => null; @override Widget buildWidget({ required PluginContext context, required bool shrinkWrap, Map? data, }) => const TrashPage(key: ValueKey('TrashPage')); @override List get navigationItems => [this]; } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/trash/src/sizes.dart'; import 'package:appflowy/plugins/trash/src/trash_header.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:styled_widget/styled_widget.dart'; import 'application/trash_bloc.dart'; import 'src/trash_cell.dart'; class TrashPage extends StatefulWidget { const TrashPage({super.key}); @override State createState() => _TrashPageState(); } class _TrashPageState extends State { final ScrollController _scrollController = ScrollController(); @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { const horizontalPadding = 80.0; return BlocProvider( create: (context) => getIt()..add(const TrashEvent.initial()), child: BlocBuilder( builder: (context, state) { return SizedBox.expand( child: Column( children: [ _renderTopBar(context, state), const VSpace(32), _renderTrashList(context, state), ], ).padding(horizontal: horizontalPadding, vertical: 48), ); }, ), ); } Widget _renderTrashList(BuildContext context, TrashState state) { const barSize = 6.0; return Expanded( child: ScrollbarListStack( axis: Axis.vertical, controller: _scrollController, scrollbarPadding: EdgeInsets.only(top: TrashSizes.headerHeight), barSize: barSize, child: StyledSingleChildScrollView( barSize: barSize, axis: Axis.horizontal, child: SizedBox( width: TrashSizes.totalWidth, child: ScrollConfiguration( behavior: const ScrollBehavior().copyWith(scrollbars: false), child: CustomScrollView( shrinkWrap: true, physics: StyledScrollPhysics(), controller: _scrollController, slivers: [ _renderListHeader(context, state), _renderListBody(context, state), ], ), ), ), ), ), ); } Widget _renderTopBar(BuildContext context, TrashState state) { return SizedBox( height: 36, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FlowyText.semibold( LocaleKeys.trash_text.tr(), fontSize: FontSizes.s16, color: Theme.of(context).colorScheme.tertiary, ), const Spacer(), IntrinsicWidth( child: FlowyButton( text: FlowyText.medium( LocaleKeys.trash_restoreAll.tr(), lineHeight: 1.0, ), leftIcon: const FlowySvg(FlowySvgs.restore_s), onTap: () => showCancelAndConfirmDialog( context: context, confirmLabel: LocaleKeys.trash_restore.tr(), title: LocaleKeys.trash_confirmRestoreAll_title.tr(), description: LocaleKeys.trash_confirmRestoreAll_caption.tr(), onConfirm: (_) => context .read() .add(const TrashEvent.restoreAll()), ), ), ), const HSpace(6), IntrinsicWidth( child: FlowyButton( text: FlowyText.medium( LocaleKeys.trash_deleteAll.tr(), lineHeight: 1.0, ), leftIcon: const FlowySvg(FlowySvgs.delete_s), onTap: () => showConfirmDeletionDialog( context: context, name: LocaleKeys.trash_confirmDeleteAll_title.tr(), description: LocaleKeys.trash_confirmDeleteAll_caption.tr(), onConfirm: () => context.read().add(const TrashEvent.deleteAll()), ), ), ), ], ), ); } Widget _renderListHeader(BuildContext context, TrashState state) { return SliverPersistentHeader( delegate: TrashHeaderDelegate(), floating: true, pinned: true, ); } Widget _renderListBody(BuildContext context, TrashState state) { return SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { final object = state.objects[index]; return SizedBox( height: 42, child: TrashCell( object: object, onRestore: () => showCancelAndConfirmDialog( context: context, title: LocaleKeys.trash_restorePage_title.tr(args: [object.name]), description: LocaleKeys.trash_restorePage_caption.tr(), confirmLabel: LocaleKeys.trash_restore.tr(), onConfirm: (_) => context .read() .add(TrashEvent.putback(object.id)), ), onDelete: () => showConfirmDeletionDialog( context: context, name: object.name.trim().isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : object.name, description: LocaleKeys.deletePagePrompt_deletePermanentDescription.tr(), onConfirm: () => context.read().add(TrashEvent.delete(object)), ), ), ); }, childCount: state.objects.length, addAutomaticKeepAlives: false, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/plugins/util.dart ================================================ import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; class ViewPluginNotifier extends PluginNotifier { ViewPluginNotifier({ required this.view, }) : _viewListener = ViewListener(viewId: view.id) { _viewListener?.start( onViewUpdated: (updatedView) => view = updatedView, onViewMoveToTrash: (result) => result.fold( (deletedView) => isDeleted.value = deletedView, (err) => Log.error(err), ), ); } ViewPB view; final ViewListener? _viewListener; @override final ValueNotifier isDeleted = ValueNotifier(null); @override void dispose() { isDeleted.dispose(); _viewListener?.stop(); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/af_image.dart ================================================ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; class AFImage extends StatelessWidget { const AFImage({ super.key, required this.url, required this.uploadType, this.height, this.width, this.fit = BoxFit.cover, this.userProfile, this.borderRadius, }) : assert( uploadType != FileUploadTypePB.CloudFile || userProfile != null, 'userProfile must be provided for accessing files from AF Cloud', ); final String url; final FileUploadTypePB uploadType; final double? height; final double? width; final BoxFit fit; final UserProfilePB? userProfile; final BorderRadius? borderRadius; @override Widget build(BuildContext context) { if (uploadType == FileUploadTypePB.CloudFile && userProfile == null) { return const SizedBox.shrink(); } Widget child; if (uploadType == FileUploadTypePB.NetworkFile) { child = Image.network( url, height: height, width: width, fit: fit, isAntiAlias: true, errorBuilder: (context, error, stackTrace) { return const SizedBox.shrink(); }, ); } else if (uploadType == FileUploadTypePB.LocalFile) { child = Image.file( File(url), height: height, width: width, fit: fit, isAntiAlias: true, errorBuilder: (context, error, stackTrace) { return const SizedBox.shrink(); }, ); } else { child = FlowyNetworkImage( url: url, userProfilePB: userProfile, height: height, width: width, errorWidgetBuilder: (context, url, error) { return const SizedBox.shrink(); }, ); } if (borderRadius != null) { child = ClipRRect( clipBehavior: Clip.antiAliasWithSaveLayer, borderRadius: borderRadius!, child: child, ); } return child; } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/af_role_pb_extension.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; extension AFRolePBExtension on AFRolePB { bool get isOwner => this == AFRolePB.Owner; bool get isMember => this == AFRolePB.Member; bool get canInvite => isOwner; bool get canDelete => isOwner; bool get canUpdate => isOwner; bool get canLeave => this != AFRolePB.Owner; String get description { switch (this) { case AFRolePB.Owner: return LocaleKeys.settings_appearance_members_owner.tr(); case AFRolePB.Member: return LocaleKeys.settings_appearance_members_member.tr(); case AFRolePB.Guest: return LocaleKeys.settings_appearance_members_guest.tr(); } throw UnimplementedError('Unknown role: $this'); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/af_user_profile_extension.dart ================================================ import 'dart:convert'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; extension UserProfilePBExtension on UserProfilePB { String? get authToken { try { final map = jsonDecode(token) as Map; return map['access_token'] as String?; } catch (e) { Log.error('Failed to decode auth token: $e'); return null; } } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart ================================================ import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy_backend/log.dart'; import 'package:path_provider/path_provider.dart'; class FlowyCacheManager { final _caches = []; // if you add a new cache, you should register it here. void registerCache(ICache cache) { _caches.add(cache); } void unregisterAllCache(ICache cache) { _caches.clear(); } Future clearAllCache() async { try { for (final cache in _caches) { await cache.clearAll(); } Log.info('Cache cleared'); } catch (e) { Log.error(e); } } Future getCacheSize() async { try { int tmpDirSize = 0; for (final cache in _caches) { tmpDirSize += await cache.cacheSize(); } Log.info('Cache size: $tmpDirSize'); return tmpDirSize; } catch (e) { Log.error(e); return 0; } } } abstract class ICache { Future cacheSize(); Future clearAll(); } class TemporaryDirectoryCache implements ICache { @override Future cacheSize() async { final tmpDir = await getTemporaryDirectory(); final tmpDirStat = await tmpDir.stat(); return tmpDirStat.size; } @override Future clearAll() async { final tmpDir = await getTemporaryDirectory(); await tmpDir.delete(recursive: true); } } class FeatureFlagCache implements ICache { @override Future cacheSize() async { return 0; } @override Future clearAll() async { await FeatureFlag.clear(); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart ================================================ import 'dart:convert'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:string_validator/string_validator.dart'; /// This widget handles the downloading and caching of either internal or network images. /// It will append the access token to the URL if the URL is internal. class FlowyNetworkImage extends StatefulWidget { const FlowyNetworkImage({ super.key, this.userProfilePB, this.width, this.height, this.fit = BoxFit.cover, this.progressIndicatorBuilder, this.errorWidgetBuilder, required this.url, this.maxRetries = 5, this.retryDuration = const Duration(seconds: 6), this.retryErrorCodes = const {404}, this.onImageLoaded, }); /// The URL of the image. final String url; /// The width of the image. final double? width; /// The height of the image. final double? height; /// The fit of the image. final BoxFit fit; /// The user profile. /// /// If the userProfilePB is not null, the image will be downloaded with the access token. final UserProfilePB? userProfilePB; /// The progress indicator builder. final ProgressIndicatorBuilder? progressIndicatorBuilder; /// The error widget builder. final LoadingErrorWidgetBuilder? errorWidgetBuilder; /// Retry loading the image if it fails. final int maxRetries; /// Retry duration final Duration retryDuration; /// Retry error codes. final Set retryErrorCodes; final void Function(bool isImageInCache)? onImageLoaded; @override FlowyNetworkImageState createState() => FlowyNetworkImageState(); } class FlowyNetworkImageState extends State { final manager = CustomImageCacheManager(); final retryCounter = FlowyNetworkRetryCounter(); // This is used to clear the retry count when the widget is disposed in case of the url is the same. String? retryTag; @override void initState() { super.initState(); assert(isURL(widget.url)); if (widget.url.isAppFlowyCloudUrl) { assert( widget.userProfilePB != null && widget.userProfilePB!.token.isNotEmpty, ); } retryTag = retryCounter.add(widget.url); manager.getFileFromCache(widget.url).then((file) { widget.onImageLoaded?.call( file != null && file.file.path.isNotEmpty && file.originalUrl == widget.url, ); }); } @override void reassemble() { super.reassemble(); if (retryTag != null) { retryCounter.clear( tag: retryTag!, url: widget.url, maxRetries: widget.maxRetries, ); } } @override void dispose() { if (retryTag != null) { retryCounter.clear( tag: retryTag!, url: widget.url, maxRetries: widget.maxRetries, ); } super.dispose(); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: retryCounter, builder: (context, child) { final retryCount = retryCounter.getRetryCount(widget.url); return CachedNetworkImage( key: ValueKey('${widget.url}_$retryCount'), cacheManager: manager, httpHeaders: _buildRequestHeader(), imageUrl: widget.url, fit: widget.fit, width: widget.width, height: widget.height, progressIndicatorBuilder: widget.progressIndicatorBuilder, errorWidget: _errorWidgetBuilder, errorListener: (value) async { Log.error( 'Unable to load image: ${value.toString()} - retryCount: $retryCount', ); // clear the cache and retry await manager.removeFile(widget.url); _retryLoadImage(); }, ); }, ); } /// if the error is 404 and the retry count is less than the max retries, it return a loading indicator. Widget _errorWidgetBuilder(BuildContext context, String url, Object error) { final retryCount = retryCounter.getRetryCount(url); if (error is HttpExceptionWithStatus) { if (widget.retryErrorCodes.contains(error.statusCode) && retryCount < widget.maxRetries) { final fakeDownloadProgress = DownloadProgress(url, null, 0); return widget.progressIndicatorBuilder?.call( context, url, fakeDownloadProgress, ) ?? const Center( child: _SensitiveContent(), ); } if (error.statusCode == 422) { // Unprocessable Entity: Used when the server understands the request but cannot process it due to //semantic issues (e.g., sensitive keywords). return const _SensitiveContent(); } } return widget.errorWidgetBuilder?.call(context, url, error) ?? const SizedBox.shrink(); } Map _buildRequestHeader() { final header = {}; final token = widget.userProfilePB?.token; if (token != null) { try { final decodedToken = jsonDecode(token); header['Authorization'] = 'Bearer ${decodedToken['access_token']}'; } catch (e) { Log.error('Unable to decode token: $e'); } } return header; } void _retryLoadImage() { final retryCount = retryCounter.getRetryCount(widget.url); if (retryCount < widget.maxRetries) { Future.delayed(widget.retryDuration, () { Log.debug( 'Retry load image: ${widget.url}, retry count: $retryCount', ); // Increment the retry count for the URL to trigger the image rebuild. retryCounter.increment(widget.url); }); } } } /// This class is used to count the number of retries for a given URL. @visibleForTesting class FlowyNetworkRetryCounter with ChangeNotifier { FlowyNetworkRetryCounter._(); factory FlowyNetworkRetryCounter() => _instance; static final _instance = FlowyNetworkRetryCounter._(); final Map _values = {}; Map get values => {..._values}; /// Get the retry count for a given URL. int getRetryCount(String url) => _values[url] ?? 0; /// Add a new URL to the retry counter. Don't call notifyListeners() here. /// /// This function will return a tag, use it to clear the retry count. /// Because the url may be the same, we need to add a unique tag to the url. String add(String url) { _values.putIfAbsent(url, () => 0); return url + uuid(); } /// Increment the retry count for a given URL. void increment(String url) { final count = _values[url]; if (count == null) { _values[url] = 1; } else { _values[url] = count + 1; } notifyListeners(); } /// Clear the retry count for a given tag. void clear({ required String tag, required String url, int? maxRetries, }) { _values.remove(tag); final retryCount = _values[url]; if (maxRetries == null || (retryCount != null && retryCount >= maxRetries)) { _values.remove(url); } } /// Reset the retry counter. void reset() { _values.clear(); } } class _SensitiveContent extends StatelessWidget { const _SensitiveContent(); @override Widget build(BuildContext context) { return FlowyText(LocaleKeys.ai_contentPolicyViolation.tr()); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/appflowy_network_svg.dart ================================================ import 'dart:developer'; import 'dart:io'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'custom_image_cache_manager.dart'; class FlowyNetworkSvg extends StatefulWidget { FlowyNetworkSvg( this.url, { Key? key, this.cacheKey, this.placeholder, this.errorWidget, this.width, this.height, this.headers, this.fit = BoxFit.contain, this.alignment = Alignment.center, this.matchTextDirection = false, this.allowDrawingOutsideViewBox = false, this.semanticsLabel, this.excludeFromSemantics = false, this.theme = const SvgTheme(), this.fadeDuration = Duration.zero, this.colorFilter, this.placeholderBuilder, BaseCacheManager? cacheManager, }) : cacheManager = cacheManager ?? CustomImageCacheManager(), super(key: key ?? ValueKey(url)); final String url; final String? cacheKey; final Widget? placeholder; final Widget? errorWidget; final double? width; final double? height; final ColorFilter? colorFilter; final Map? headers; final BoxFit fit; final AlignmentGeometry alignment; final bool matchTextDirection; final bool allowDrawingOutsideViewBox; final String? semanticsLabel; final bool excludeFromSemantics; final SvgTheme theme; final Duration fadeDuration; final WidgetBuilder? placeholderBuilder; final BaseCacheManager cacheManager; @override State createState() => _FlowyNetworkSvgState(); static Future preCache( String imageUrl, { String? cacheKey, BaseCacheManager? cacheManager, }) { final key = cacheKey ?? _generateKeyFromUrl(imageUrl); cacheManager ??= DefaultCacheManager(); return cacheManager.downloadFile(key); } static Future clearCacheForUrl( String imageUrl, { String? cacheKey, BaseCacheManager? cacheManager, }) { final key = cacheKey ?? _generateKeyFromUrl(imageUrl); cacheManager ??= DefaultCacheManager(); return cacheManager.removeFile(key); } static Future clearCache({BaseCacheManager? cacheManager}) { cacheManager ??= DefaultCacheManager(); return cacheManager.emptyCache(); } static String _generateKeyFromUrl(String url) => url.split('?').first; } class _FlowyNetworkSvgState extends State with SingleTickerProviderStateMixin { bool _isLoading = false; bool _isError = false; File? _imageFile; late String _cacheKey; late final AnimationController _controller; late final Animation _animation; @override void initState() { super.initState(); _cacheKey = widget.cacheKey ?? FlowyNetworkSvg._generateKeyFromUrl(widget.url); _controller = AnimationController( vsync: this, duration: widget.fadeDuration, ); _animation = Tween(begin: 0.0, end: 1.0).animate(_controller); _loadImage(); } Future _loadImage() async { try { _setToLoadingAfter15MsIfNeeded(); var file = (await widget.cacheManager.getFileFromMemory(_cacheKey))?.file; file ??= await widget.cacheManager.getSingleFile( widget.url, key: _cacheKey, headers: widget.headers ?? {}, ); _imageFile = file; _isLoading = false; _setState(); await _controller.forward(); } catch (e) { log('CachedNetworkSVGImage: $e'); _isError = true; _isLoading = false; _setState(); } } void _setToLoadingAfter15MsIfNeeded() => Future.delayed( const Duration(milliseconds: 15), () { if (!_isLoading && _imageFile == null && !_isError) { _isLoading = true; _setState(); } }, ); void _setState() => mounted ? setState(() {}) : null; @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( width: widget.width, height: widget.height, child: _buildImage(), ); } Widget _buildImage() { if (_isLoading) return _buildPlaceholderWidget(); if (_isError) return _buildErrorWidget(); return FadeTransition( opacity: _animation, child: _buildSVGImage(), ); } Widget _buildPlaceholderWidget() => Center(child: widget.placeholder ?? const SizedBox()); Widget _buildErrorWidget() => Center(child: widget.errorWidget ?? const SizedBox()); Widget _buildSVGImage() { if (_imageFile == null) return const SizedBox(); return SvgPicture.file( _imageFile!, fit: widget.fit, width: widget.width, height: widget.height, alignment: widget.alignment, matchTextDirection: widget.matchTextDirection, allowDrawingOutsideViewBox: widget.allowDrawingOutsideViewBox, colorFilter: widget.colorFilter, semanticsLabel: widget.semanticsLabel, excludeFromSemantics: widget.excludeFromSemantics, placeholderBuilder: widget.placeholderBuilder, theme: widget.theme, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/clipboard_state.dart ================================================ import 'package:flutter/foundation.dart'; /// Class to hold the state of the Clipboard. /// /// Essentially for document in-app json paste, we need to be able /// to differentiate between a cut-paste and a copy-paste. /// /// When a cut-pase has occurred, the next paste operation should be /// seen as a copy-paste. /// class ClipboardState { ClipboardState(); bool _isCut = false; bool get isCut => _isCut; final ValueNotifier isHandlingPasteNotifier = ValueNotifier(false); bool get isHandlingPaste => isHandlingPasteNotifier.value; final Set _handlingPasteIds = {}; void dispose() { isHandlingPasteNotifier.dispose(); } void didCut() { _isCut = true; } void didPaste() { _isCut = false; } void startHandlingPaste(String id) { _handlingPasteIds.add(id); isHandlingPasteNotifier.value = true; } void endHandlingPaste(String id) { _handlingPasteIds.remove(id); if (_handlingPasteIds.isEmpty) { isHandlingPasteNotifier.value = false; } } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/colors.dart ================================================ import 'package:appflowy/util/theme_extension.dart'; import 'package:flutter/material.dart'; extension SharedColors on BuildContext { Color get proPrimaryColor { return Theme.of(this).isLightMode ? const Color(0xFF653E8C) : const Color(0xFFE8E2EE); } Color get proSecondaryColor { return Theme.of(this).isLightMode ? const Color(0xFFE8E2EE) : const Color(0xFF653E8C); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/conditional_listenable_builder.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class ConditionalListenableBuilder extends StatefulWidget { const ConditionalListenableBuilder({ super.key, required this.valueListenable, required this.buildWhen, required this.builder, this.child, }); /// The [ValueListenable] whose value you depend on in order to build. /// /// This widget does not ensure that the [ValueListenable]'s value is not /// null, therefore your [builder] may need to handle null values. final ValueListenable valueListenable; /// The [buildWhen] function will be called on each value change of the /// [valueListenable]. If the [buildWhen] function returns true, the [builder] /// will be called with the new value of the [valueListenable]. /// final bool Function(T previous, T current) buildWhen; /// A [ValueWidgetBuilder] which builds a widget depending on the /// [valueListenable]'s value. /// /// Can incorporate a [valueListenable] value-independent widget subtree /// from the [child] parameter into the returned widget tree. final ValueWidgetBuilder builder; /// A [valueListenable]-independent widget which is passed back to the [builder]. /// /// This argument is optional and can be null if the entire widget subtree the /// [builder] builds depends on the value of the [valueListenable]. For /// example, in the case where the [valueListenable] is a [String] and the /// [builder] returns a [Text] widget with the current [String] value, there /// would be no useful [child]. final Widget? child; @override State createState() => _ConditionalListenableBuilderState(); } class _ConditionalListenableBuilderState extends State> { late T value; @override void initState() { super.initState(); value = widget.valueListenable.value; widget.valueListenable.addListener(_valueChanged); } @override void didUpdateWidget(ConditionalListenableBuilder oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.valueListenable != widget.valueListenable) { oldWidget.valueListenable.removeListener(_valueChanged); value = widget.valueListenable.value; widget.valueListenable.addListener(_valueChanged); } } @override void dispose() { widget.valueListenable.removeListener(_valueChanged); super.dispose(); } void _valueChanged() { if (widget.buildWhen(value, widget.valueListenable.value)) { setState(() { value = widget.valueListenable.value; }); } else { value = widget.valueListenable.value; } } @override Widget build(BuildContext context) { return widget.builder(context, value, widget.child); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart ================================================ import 'package:appflowy/shared/appflowy_cache_manager.dart'; import 'package:appflowy/startup/tasks/prelude.dart'; import 'package:file/file.dart' hide FileSystem; import 'package:file/local.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:path/path.dart' as p; class CustomImageCacheManager extends CacheManager with ImageCacheManager implements ICache { CustomImageCacheManager._() : super( Config( key, fileSystem: CustomIOFileSystem(key), ), ); factory CustomImageCacheManager() => _instance; static final CustomImageCacheManager _instance = CustomImageCacheManager._(); static const key = 'image_cache'; @override Future cacheSize() async { // https://github.com/Baseflow/flutter_cache_manager/issues/239#issuecomment-719475429 // this package does not provide a way to get the cache size return 0; } @override Future clearAll() async { await emptyCache(); } } class CustomIOFileSystem implements FileSystem { CustomIOFileSystem(this._cacheKey) : _fileDir = createDirectory(_cacheKey); final Future _fileDir; final String _cacheKey; static Future createDirectory(String key) async { final baseDir = await appFlowyApplicationDataDirectory(); final path = p.join(baseDir.path, key); 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); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/easy_localiation_service.dart ================================================ import 'package:easy_localization/easy_localization.dart'; // ignore: implementation_imports import 'package:easy_localization/src/easy_localization_controller.dart'; import 'package:flutter/widgets.dart'; class EasyLocalizationService { EasyLocalizationService(); late EasyLocalizationController? controller; String getFallbackTranslation(String token) { final translations = controller?.fallbackTranslations; return translations?.get(token).toString() ?? ''; } String getTranslation(String token) { final translations = controller?.translations; return translations?.get(token).toString() ?? ''; } void init(BuildContext context) { controller = EasyLocalization.of(context)?.delegate.localizationController; } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/error_code/error_code_map.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:easy_localization/easy_localization.dart'; extension PublishNameErrorCodeMap on ErrorCode { String? get publishErrorMessage { return switch (this) { ErrorCode.PublishNameAlreadyExists => LocaleKeys.settings_sites_error_publishNameAlreadyInUse.tr(), ErrorCode.PublishNameInvalidCharacter => LocaleKeys .settings_sites_error_publishNameContainsInvalidCharacters .tr(), ErrorCode.PublishNameTooLong => LocaleKeys.settings_sites_error_publishNameTooLong.tr(), ErrorCode.UserUnauthorized => LocaleKeys.settings_sites_error_publishPermissionDenied.tr(), ErrorCode.ViewNameInvalid => LocaleKeys.settings_sites_error_publishNameCannotBeEmpty.tr(), _ => null, }; } } extension DomainErrorCodeMap on ErrorCode { String? get namespaceErrorMessage { return switch (this) { ErrorCode.CustomNamespaceRequirePlanUpgrade => LocaleKeys.settings_sites_error_proPlanLimitation.tr(), ErrorCode.CustomNamespaceAlreadyTaken => LocaleKeys.settings_sites_error_namespaceAlreadyInUse.tr(), ErrorCode.InvalidNamespace || ErrorCode.InvalidRequest => LocaleKeys.settings_sites_error_invalidNamespace.tr(), ErrorCode.CustomNamespaceTooLong => LocaleKeys.settings_sites_error_namespaceTooLong.tr(), ErrorCode.CustomNamespaceTooShort => LocaleKeys.settings_sites_error_namespaceTooShort.tr(), ErrorCode.CustomNamespaceReserved => LocaleKeys.settings_sites_error_namespaceIsReserved.tr(), ErrorCode.CustomNamespaceInvalidCharacter => LocaleKeys.settings_sites_error_namespaceContainsInvalidCharacters.tr(), _ => null, }; } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/error_page/error_page.dart ================================================ import 'dart:io'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class FlowyErrorPage extends StatelessWidget { factory FlowyErrorPage.error( Error e, { required String howToFix, Key? key, List? actions, }) => FlowyErrorPage._( e.toString(), stackTrace: e.stackTrace?.toString(), howToFix: howToFix, key: key, actions: actions, ); factory FlowyErrorPage.message( String message, { required String howToFix, String? stackTrace, Key? key, List? actions, }) => FlowyErrorPage._( message, key: key, stackTrace: stackTrace, howToFix: howToFix, actions: actions, ); factory FlowyErrorPage.exception( Exception e, { required String howToFix, String? stackTrace, Key? key, List? actions, }) => FlowyErrorPage._( e.toString(), stackTrace: stackTrace, key: key, howToFix: howToFix, actions: actions, ); const FlowyErrorPage._( this.message, { required this.howToFix, this.stackTrace, super.key, this.actions, }); static const _titleFontSize = 24.0; static const _titleToMessagePadding = 8.0; final List? actions; final String howToFix; final String message; final String? stackTrace; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const FlowyText.medium( "AppFlowy Error", fontSize: _titleFontSize, ), const SizedBox(height: _titleToMessagePadding), Listener( behavior: HitTestBehavior.translucent, onPointerDown: (_) async { await getIt().setData( ClipboardServiceData(plainText: message), ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, content: FlowyText( 'Message copied to clipboard', fontSize: kIsWeb || !Platform.isIOS && !Platform.isAndroid ? 14 : 12, ), ), ); } }, child: FlowyHover( style: HoverStyle( backgroundColor: Theme.of(context).colorScheme.tertiaryContainer, ), cursor: SystemMouseCursors.click, child: FlowyTooltip( message: 'Click to copy message', child: Padding( padding: const EdgeInsets.all(4), child: FlowyText.semibold(message, maxLines: 10), ), ), ), ), const SizedBox(height: _titleToMessagePadding), FlowyText.regular(howToFix, maxLines: 10), const SizedBox(height: _titleToMessagePadding), GitHubRedirectButton( title: 'Unexpected error', message: message, stackTrace: stackTrace, ), const SizedBox(height: _titleToMessagePadding), if (stackTrace != null) StackTracePreview(stackTrace!), if (actions != null) Row( mainAxisAlignment: MainAxisAlignment.end, children: actions!, ), ], ), ); } } class StackTracePreview extends StatelessWidget { const StackTracePreview( this.stackTrace, { super.key, }); final String stackTrace; @override Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints( minWidth: 350, maxWidth: 450, ), child: Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), clipBehavior: Clip.antiAlias, margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ const Align( alignment: Alignment.centerLeft, child: FlowyText.semibold( "Stack Trace", ), ), Container( height: 120, padding: const EdgeInsets.symmetric(vertical: 8), child: SingleChildScrollView( child: Text( stackTrace, style: Theme.of(context).textTheme.bodySmall, ), ), ), Align( alignment: Alignment.centerRight, child: FlowyButton( hoverColor: AFThemeExtension.of(context).onBackground, text: const FlowyText( "Copy", ), useIntrinsicWidth: true, onTap: () => getIt().setData( ClipboardServiceData(plainText: stackTrace), ), ), ), ], ), ), ), ); } } class GitHubRedirectButton extends StatelessWidget { const GitHubRedirectButton({ super.key, this.title, this.message, this.stackTrace, }); final String? title; final String? message; final String? stackTrace; static const _height = 32.0; Uri get _gitHubNewBugUri => Uri( scheme: 'https', host: 'github.com', path: '/AppFlowy-IO/AppFlowy/issues/new', query: 'assignees=&labels=&projects=&template=bug_report.yaml&os=$_platform&title=%5BBug%5D+$title&context=$_contextString', ); String get _contextString { if (message == null && stackTrace == null) { return ''; } String msg = ""; if (message != null) { msg += 'Error message:%0A```%0A$message%0A```%0A'; } if (stackTrace != null) { msg += 'StackTrace:%0A```%0A$stackTrace%0A```%0A'; } return msg; } String get _platform { if (kIsWeb) { return 'Web'; } return Platform.operatingSystem; } @override Widget build(BuildContext context) { return FlowyButton( leftIconSize: const Size.square(_height), text: FlowyText(LocaleKeys.appName.tr()), useIntrinsicWidth: true, leftIcon: const Padding( padding: EdgeInsets.all(4.0), child: FlowySvg(FlowySvgData('login/github-mark')), ), onTap: () async { await afLaunchUri(_gitHubNewBugUri); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/feature_flags.dart ================================================ import 'dart:convert'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:collection/collection.dart'; typedef FeatureFlagMap = Map; /// The [FeatureFlag] is used to control the front-end features of the app. /// /// For example, if your feature is still under development, /// you can set the value to `false` to hide the feature. enum FeatureFlag { // used to control the visibility of the collaborative workspace feature // if it's on, you can see the workspace list and the workspace settings // in the top-left corner of the app collaborativeWorkspace, // used to control the visibility of the members settings // if it's on, you can see the members settings in the settings page membersSettings, // used to control the sync feature of the document // if it's on, the document will be synced the events from server in real-time syncDocument, // used to control the sync feature of the database // if it's on, the collaborators will show in the database syncDatabase, // used for the search feature search, // used for controlling whether to show plan+billing options in settings planBilling, // used for space design spaceDesign, // used for the inline sub-page mention inlineSubPageMention, // used for the shared section sharedSection, // used for ignore the conflicted feature flag unknown; static Future initialize() async { final values = await getIt().getWithFormat( KVKeys.featureFlag, (value) => Map.from(jsonDecode(value)).map( (key, value) { final k = FeatureFlag.values.firstWhereOrNull( (e) => e.name == key, ) ?? FeatureFlag.unknown; return MapEntry(k, value as bool); }, ), ) ?? {}; _values = { ...{for (final flag in FeatureFlag.values) flag: false}, ...values, }; } static UnmodifiableMapView get data => UnmodifiableMapView(_values); Future turnOn() async { await update(true); } Future turnOff() async { await update(false); } Future update(bool value) async { _values[this] = value; await getIt().set( KVKeys.featureFlag, jsonEncode( _values.map((key, value) => MapEntry(key.name, value)), ), ); } static Future clear() async { _values = {}; await getIt().remove(KVKeys.featureFlag); } bool get isOn { if ([ FeatureFlag.planBilling, // release this feature in version 0.6.1 FeatureFlag.spaceDesign, // release this feature in version 0.5.9 FeatureFlag.search, // release this feature in version 0.5.6 FeatureFlag.collaborativeWorkspace, FeatureFlag.membersSettings, // release this feature in version 0.5.4 FeatureFlag.syncDatabase, FeatureFlag.syncDocument, FeatureFlag.inlineSubPageMention, ].contains(this)) { return true; } if (_values.containsKey(this)) { return _values[this]!; } switch (this) { case FeatureFlag.planBilling: case FeatureFlag.search: case FeatureFlag.syncDocument: case FeatureFlag.syncDatabase: case FeatureFlag.spaceDesign: case FeatureFlag.inlineSubPageMention: case FeatureFlag.collaborativeWorkspace: case FeatureFlag.membersSettings: return true; case FeatureFlag.sharedSection: case FeatureFlag.unknown: return false; } } String get description { switch (this) { case FeatureFlag.collaborativeWorkspace: return 'if it\'s on, you can see the workspace list and the workspace settings in the top-left corner of the app'; case FeatureFlag.membersSettings: return 'if it\'s on, you can see the members settings in the settings page'; case FeatureFlag.syncDocument: return 'if it\'s on, the document will be synced in real-time'; case FeatureFlag.syncDatabase: return 'if it\'s on, the collaborators will show in the database'; case FeatureFlag.search: return 'if it\'s on, the command palette and search button will be available'; case FeatureFlag.planBilling: return 'if it\'s on, plan and billing pages will be available in Settings'; case FeatureFlag.spaceDesign: return 'if it\'s on, the space design feature will be available'; case FeatureFlag.inlineSubPageMention: return 'if it\'s on, the inline sub-page mention feature will be available'; case FeatureFlag.sharedSection: return 'if it\'s on, the shared section will be available'; case FeatureFlag.unknown: return ''; } } String get key => 'appflowy_feature_flag_${toString()}'; } FeatureFlagMap _values = {}; ================================================ FILE: frontend/appflowy_flutter/lib/shared/feedback_gesture_detector.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; enum HapticFeedbackType { light, medium, heavy, selection, vibrate; void call() { switch (this) { case HapticFeedbackType.light: HapticFeedback.lightImpact(); break; case HapticFeedbackType.medium: HapticFeedback.mediumImpact(); break; case HapticFeedbackType.heavy: HapticFeedback.heavyImpact(); break; case HapticFeedbackType.selection: HapticFeedback.selectionClick(); break; case HapticFeedbackType.vibrate: HapticFeedback.vibrate(); break; } } } class FeedbackGestureDetector extends GestureDetector { FeedbackGestureDetector({ super.key, HitTestBehavior behavior = HitTestBehavior.opaque, HapticFeedbackType feedbackType = HapticFeedbackType.light, required Widget child, required VoidCallback onTap, }) : super( behavior: behavior, onTap: () { feedbackType.call(); onTap(); }, child: child, ); } ================================================ FILE: frontend/appflowy_flutter/lib/shared/flowy_error_page.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class AppFlowyErrorPage extends StatelessWidget { const AppFlowyErrorPage({ super.key, this.error, }); final FlowyError? error; @override Widget build(BuildContext context) { if (UniversalPlatform.isMobile) { return _MobileSyncErrorPage(error: error); } else { return _DesktopSyncErrorPage(error: error); } } } class _MobileSyncErrorPage extends StatelessWidget { const _MobileSyncErrorPage({ this.error, }); final FlowyError? error; @override Widget build(BuildContext context) { return AnimatedGestureDetector( scaleFactor: 0.99, onTapUp: () { getIt().setPlainText(error.toString()); showToastNotification( message: LocaleKeys.message_copy_success.tr(), bottomPadding: 0, ); }, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( FlowySvgs.icon_warning_xl, blendMode: null, ), const VSpace(16.0), FlowyText.medium( LocaleKeys.error_syncError.tr(), fontSize: 15, ), const VSpace(8.0), Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: FlowyText.regular( LocaleKeys.error_syncErrorHint.tr(), fontSize: 13, color: Theme.of(context).hintColor, textAlign: TextAlign.center, maxLines: 10, ), ), const VSpace(2.0), FlowyText.regular( '(${LocaleKeys.error_clickToCopy.tr()})', fontSize: 13, color: Theme.of(context).hintColor, textAlign: TextAlign.center, ), ], ), ); } } class _DesktopSyncErrorPage extends StatelessWidget { const _DesktopSyncErrorPage({ this.error, }); final FlowyError? error; @override Widget build(BuildContext context) { return AnimatedGestureDetector( scaleFactor: 0.995, onTapUp: () { getIt().setPlainText(error.toString()); showToastNotification( message: LocaleKeys.message_copy_success.tr(), bottomPadding: 0, ); }, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( FlowySvgs.icon_warning_xl, blendMode: null, ), const VSpace(16.0), FlowyText.medium( error?.code.toString() ?? '', fontSize: 16, ), const VSpace(8.0), RichText( text: TextSpan( children: [ TextSpan( text: LocaleKeys.errorDialog_howToFixFallbackHint1.tr(), style: TextStyle( fontSize: 14, color: Theme.of(context).hintColor, ), ), TextSpan( text: 'Github', style: TextStyle( fontSize: 14, color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () { afLaunchUrlString( 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?template=bug_report.yaml', ); }, ), TextSpan( text: LocaleKeys.errorDialog_howToFixFallbackHint2.tr(), style: TextStyle( fontSize: 14, color: Theme.of(context).hintColor, ), ), ], ), ), const VSpace(8.0), FlowyText.regular( '(${LocaleKeys.error_clickToCopy.tr()})', fontSize: 14, color: Theme.of(context).hintColor, textAlign: TextAlign.center, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/flowy_gradient_colors.dart ================================================ import 'package:flutter/material.dart'; enum FlowyGradientColor { gradient1, gradient2, gradient3, gradient4, gradient5, gradient6, gradient7; static FlowyGradientColor fromId(String id) { return FlowyGradientColor.values.firstWhere( (element) => element.id == id, orElse: () => FlowyGradientColor.gradient1, ); } String get id { // DON'T change this name because it's saved in the database! switch (this) { case FlowyGradientColor.gradient1: return 'appflowy_them_color_gradient1'; case FlowyGradientColor.gradient2: return 'appflowy_them_color_gradient2'; case FlowyGradientColor.gradient3: return 'appflowy_them_color_gradient3'; case FlowyGradientColor.gradient4: return 'appflowy_them_color_gradient4'; case FlowyGradientColor.gradient5: return 'appflowy_them_color_gradient5'; case FlowyGradientColor.gradient6: return 'appflowy_them_color_gradient6'; case FlowyGradientColor.gradient7: return 'appflowy_them_color_gradient7'; } } LinearGradient get linear { switch (this) { case FlowyGradientColor.gradient1: return const LinearGradient( begin: Alignment(-0.35, -0.94), end: Alignment(0.35, 0.94), colors: [Color(0xFF34BDAF), Color(0xFFB682D4)], ); case FlowyGradientColor.gradient2: return const LinearGradient( begin: Alignment(0.00, -1.00), end: Alignment(0, 1), colors: [Color(0xFF4CC2CC), Color(0xFFE17570)], ); case FlowyGradientColor.gradient3: return const LinearGradient( begin: Alignment(0.00, -1.00), end: Alignment(0, 1), colors: [Color(0xFFAF70E0), Color(0xFFED7196)], ); case FlowyGradientColor.gradient4: return const LinearGradient( begin: Alignment(0.00, -1.00), end: Alignment(0, 1), colors: [Color(0xFFA348D6), Color(0xFF44A7DE)], ); case FlowyGradientColor.gradient5: return const LinearGradient( begin: Alignment(0.38, -0.93), end: Alignment(-0.38, 0.93), colors: [Color(0xFF5749C9), Color(0xFFBB4997)], ); case FlowyGradientColor.gradient6: return const LinearGradient( begin: Alignment(0.00, -1.00), end: Alignment(0, 1), colors: [Color(0xFF036FFA), Color(0xFF00B8E5)], ); case FlowyGradientColor.gradient7: return const LinearGradient( begin: Alignment(0.62, -0.79), end: Alignment(-0.62, 0.79), colors: [Color(0xFFF0C6CF), Color(0xFFDECCE2), Color(0xFFCAD3F9)], ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart ================================================ import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; const _defaultFontFamilies = [ defaultFontFamily, builtInCodeFontFamily, ]; // if the font family is not available, google fonts packages will throw an exception // this method will return the system font family if the font family is not available TextStyle getGoogleFontSafely( String fontFamily, { FontWeight? fontWeight, double? fontSize, Color? fontColor, double? letterSpacing, double? lineHeight, }) { // if the font family is the built-in font family, we can use it directly if (_defaultFontFamilies.contains(fontFamily)) { return TextStyle( fontFamily: fontFamily.isEmpty ? null : fontFamily, fontWeight: fontWeight, fontSize: fontSize, color: fontColor, letterSpacing: letterSpacing, height: lineHeight, ); } else { try { return GoogleFonts.getFont( fontFamily, fontWeight: fontWeight, fontSize: fontSize, color: fontColor, letterSpacing: letterSpacing, height: lineHeight, ); } catch (_) {} } return TextStyle( fontWeight: fontWeight, fontSize: fontSize, color: fontColor, letterSpacing: letterSpacing, height: lineHeight, ); } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/colors.dart ================================================ import 'package:appflowy/util/theme_extension.dart'; import 'package:flutter/material.dart'; extension PickerColors on BuildContext { Color get pickerTextColor { return Theme.of(this).isLightMode ? const Color(0x80171717) : Colors.white.withValues(alpha: 0.5); } Color get pickerIconColor { return Theme.of(this).isLightMode ? const Color(0xFF171717) : Colors.white; } Color get pickerSearchBarBorderColor { return Theme.of(this).isLightMode ? const Color(0x1E171717) : Colors.white.withValues(alpha: 0.12); } Color get pickerButtonBoarderColor { return Theme.of(this).isLightMode ? const Color(0x1E171717) : Colors.white.withValues(alpha: 0.12); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_search_bar.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:universal_platform/universal_platform.dart'; import 'colors.dart'; typedef EmojiKeywordChangedCallback = void Function(String keyword); typedef EmojiSkinToneChanged = void Function(EmojiSkinTone skinTone); class FlowyEmojiSearchBar extends StatefulWidget { const FlowyEmojiSearchBar({ super.key, this.ensureFocus = false, required this.emojiData, required this.onKeywordChanged, required this.onSkinToneChanged, required this.onRandomEmojiSelected, }); final bool ensureFocus; final EmojiData emojiData; final EmojiKeywordChangedCallback onKeywordChanged; final EmojiSkinToneChanged onSkinToneChanged; final EmojiSelectedCallback onRandomEmojiSelected; @override State createState() => _FlowyEmojiSearchBarState(); } class _FlowyEmojiSearchBarState extends State { final TextEditingController controller = TextEditingController(); EmojiSkinTone skinTone = lastSelectedEmojiSkinTone ?? EmojiSkinTone.none; @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.symmetric( vertical: 12.0, horizontal: UniversalPlatform.isDesktopOrWeb ? 0.0 : 8.0, ), child: Row( children: [ Expanded( child: _SearchTextField( onKeywordChanged: widget.onKeywordChanged, ensureFocus: widget.ensureFocus, ), ), const HSpace(8.0), _RandomEmojiButton( skinTone: skinTone, emojiData: widget.emojiData, onRandomEmojiSelected: widget.onRandomEmojiSelected, ), const HSpace(8.0), FlowyEmojiSkinToneSelector( onEmojiSkinToneChanged: (v) { setState(() { skinTone = v; }); widget.onSkinToneChanged.call(v); }, ), ], ), ); } } class _RandomEmojiButton extends StatelessWidget { const _RandomEmojiButton({ required this.skinTone, required this.emojiData, required this.onRandomEmojiSelected, }); final EmojiSkinTone skinTone; final EmojiData emojiData; final EmojiSelectedCallback onRandomEmojiSelected; @override Widget build(BuildContext context) { return Container( width: 36, height: 36, decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: BorderSide(color: context.pickerButtonBoarderColor), borderRadius: BorderRadius.circular(8), ), ), child: FlowyTooltip( message: LocaleKeys.emoji_random.tr(), child: FlowyButton( useIntrinsicWidth: true, text: const FlowySvg( FlowySvgs.icon_shuffle_s, ), onTap: () { final random = emojiData.random; final emojiId = random.$1; final emoji = emojiData.getEmojiById( emojiId, skinTone: skinTone, ); onRandomEmojiSelected( emojiId, emoji, ); }, ), ), ); } } class _SearchTextField extends StatefulWidget { const _SearchTextField({ required this.onKeywordChanged, this.ensureFocus = false, }); final EmojiKeywordChangedCallback onKeywordChanged; final bool ensureFocus; @override State<_SearchTextField> createState() => _SearchTextFieldState(); } class _SearchTextFieldState extends State<_SearchTextField> { final TextEditingController controller = TextEditingController(); final FocusNode focusNode = FocusNode(); @override void initState() { super.initState(); /// Sometimes focus is lost due to the [SelectionGestureInterceptor] in [KeyboardServiceWidgetState] /// this is to ensure that focus can be regained within a short period of time if (widget.ensureFocus) { Future.delayed(const Duration(milliseconds: 200), () { if (!mounted || focusNode.hasFocus) return; focusNode.requestFocus(); }); } } @override void dispose() { controller.dispose(); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: 36.0, child: FlowyTextField( focusNode: focusNode, hintText: LocaleKeys.search_label.tr(), hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: 14.0, fontWeight: FontWeight.w400, color: Theme.of(context).hintColor, ), enableBorderColor: context.pickerSearchBarBorderColor, controller: controller, onChanged: widget.onKeywordChanged, prefixIcon: const Padding( padding: EdgeInsets.only( left: 14.0, right: 8.0, ), child: FlowySvg( FlowySvgs.search_s, ), ), prefixIconConstraints: const BoxConstraints( maxHeight: 20.0, ), suffixIcon: Padding( padding: const EdgeInsets.all(4.0), child: FlowyButton( text: const FlowySvg( FlowySvgs.m_app_bar_close_s, ), margin: EdgeInsets.zero, useIntrinsicWidth: true, onTap: () { if (controller.text.isNotEmpty) { controller.clear(); widget.onKeywordChanged(''); } else { focusNode.unfocus(); } }, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_skin_tone.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'colors.dart'; // use a temporary global value to store last selected skin tone EmojiSkinTone? lastSelectedEmojiSkinTone; @visibleForTesting ValueKey emojiSkinToneKey(String icon) { return ValueKey('emoji_skin_tone_$icon'); } class FlowyEmojiSkinToneSelector extends StatefulWidget { const FlowyEmojiSkinToneSelector({ super.key, required this.onEmojiSkinToneChanged, }); final EmojiSkinToneChanged onEmojiSkinToneChanged; @override State createState() => _FlowyEmojiSkinToneSelectorState(); } class _FlowyEmojiSkinToneSelectorState extends State { EmojiSkinTone skinTone = EmojiSkinTone.none; final controller = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( direction: PopoverDirection.bottomWithCenterAligned, controller: controller, popupBuilder: (context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: EmojiSkinTone.values .map( (e) => _buildIconButton( e.icon, () { setState(() => lastSelectedEmojiSkinTone = e); widget.onEmojiSkinToneChanged(e); controller.close(); }, ), ) .toList(), ); }, child: FlowyTooltip( message: LocaleKeys.emoji_selectSkinTone.tr(), child: _buildIconButton( lastSelectedEmojiSkinTone?.icon ?? '👋', () => controller.show(), ), ), ); } Widget _buildIconButton(String icon, VoidCallback onPressed) { return Container( width: 36, height: 36, decoration: BoxDecoration( border: Border.all(color: context.pickerButtonBoarderColor), borderRadius: BorderRadius.circular(8), ), child: FlowyButton( key: emojiSkinToneKey(icon), margin: EdgeInsets.zero, text: FlowyText.emoji( icon, fontSize: 24.0, ), onTap: onPressed, ), ); } } extension EmojiSkinToneIcon on EmojiSkinTone { String get icon { switch (this) { case EmojiSkinTone.none: return '👋'; case EmojiSkinTone.light: return '👋🏻'; case EmojiSkinTone.mediumLight: return '👋🏼'; case EmojiSkinTone.medium: return '👋🏽'; case EmojiSkinTone.mediumDark: return '👋🏾'; case EmojiSkinTone.dark: return '👋🏿'; } } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart ================================================ import 'dart:math'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/icon.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart' hide Icon; import 'package:flutter/services.dart'; import 'package:universal_platform/universal_platform.dart'; import 'icon_uploader.dart'; extension ToProto on FlowyIconType { ViewIconTypePB toProto() { switch (this) { case FlowyIconType.emoji: return ViewIconTypePB.Emoji; case FlowyIconType.icon: return ViewIconTypePB.Icon; case FlowyIconType.custom: return ViewIconTypePB.Url; } } } extension FromProto on ViewIconTypePB { FlowyIconType fromProto() { switch (this) { case ViewIconTypePB.Emoji: return FlowyIconType.emoji; case ViewIconTypePB.Icon: return FlowyIconType.icon; case ViewIconTypePB.Url: return FlowyIconType.custom; default: return FlowyIconType.custom; } } } extension ToEmojiIconData on ViewIconPB { EmojiIconData toEmojiIconData() => EmojiIconData(ty.fromProto(), value); } enum FlowyIconType { emoji, icon, custom; } extension FlowyIconTypeToPickerTabType on FlowyIconType { PickerTabType? toPickerTabType() => name.toPickerTabType(); } class EmojiIconData { factory EmojiIconData.none() => const EmojiIconData(FlowyIconType.icon, ''); factory EmojiIconData.emoji(String emoji) => EmojiIconData(FlowyIconType.emoji, emoji); factory EmojiIconData.icon(IconsData icon) => EmojiIconData(FlowyIconType.icon, icon.iconString); factory EmojiIconData.custom(String url) => EmojiIconData(FlowyIconType.custom, url); const EmojiIconData( this.type, this.emoji, ); final FlowyIconType type; final String emoji; static EmojiIconData fromViewIconPB(ViewIconPB v) { return EmojiIconData(v.ty.fromProto(), v.value); } ViewIconPB toViewIcon() { return ViewIconPB() ..ty = type.toProto() ..value = emoji; } bool get isEmpty => emoji.isEmpty; bool get isNotEmpty => emoji.isNotEmpty; } class SelectedEmojiIconResult { SelectedEmojiIconResult(this.data, this.keepOpen); final EmojiIconData data; final bool keepOpen; FlowyIconType get type => data.type; String get emoji => data.emoji; } extension EmojiIconDataToSelectedResultExtension on EmojiIconData { SelectedEmojiIconResult toSelectedResult({bool keepOpen = false}) => SelectedEmojiIconResult(this, keepOpen); } class FlowyIconEmojiPicker extends StatefulWidget { const FlowyIconEmojiPicker({ super.key, this.onSelectedEmoji, this.initialType, this.documentId, this.enableBackgroundColorSelection = true, this.tabs = const [ PickerTabType.emoji, PickerTabType.icon, ], }); final ValueChanged? onSelectedEmoji; final bool enableBackgroundColorSelection; final List tabs; final PickerTabType? initialType; final String? documentId; @override State createState() => _FlowyIconEmojiPickerState(); } class _FlowyIconEmojiPickerState extends State with SingleTickerProviderStateMixin { late TabController controller; int currentIndex = 0; @override void initState() { super.initState(); final initialType = widget.initialType; if (initialType != null) { currentIndex = max(widget.tabs.indexOf(initialType), 0); } controller = TabController( initialIndex: currentIndex, length: widget.tabs.length, vsync: this, ); controller.addListener(() { final currentType = widget.tabs[currentIndex]; if (currentType == PickerTabType.custom) { SystemChannels.textInput.invokeMethod('TextInput.hide'); } }); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ Container( height: 46, padding: const EdgeInsets.only(left: 4.0, right: 12.0), child: Row( children: [ Expanded( child: PickerTab( controller: controller, tabs: widget.tabs, onTap: (index) => currentIndex = index, ), ), _RemoveIconButton( onTap: () { widget.onSelectedEmoji ?.call(EmojiIconData.none().toSelectedResult()); }, ), ], ), ), const FlowyDivider(), Expanded( child: TabBarView( controller: controller, children: widget.tabs.map((tab) { switch (tab) { case PickerTabType.emoji: return _buildEmojiPicker(); case PickerTabType.icon: return _buildIconPicker(); case PickerTabType.custom: return _buildIconUploader(); } }).toList(), ), ), ], ); } Widget _buildEmojiPicker() { return FlowyEmojiPicker( ensureFocus: true, emojiPerLine: _getEmojiPerLine(context), onEmojiSelected: (r) { widget.onSelectedEmoji?.call( EmojiIconData.emoji(r.emoji).toSelectedResult(keepOpen: r.isRandom), ); SystemChannels.textInput.invokeMethod('TextInput.hide'); }, ); } int _getEmojiPerLine(BuildContext context) { if (UniversalPlatform.isDesktopOrWeb) { return 9; } final width = MediaQuery.of(context).size.width; return width ~/ 40.0; // the size of the emoji } Widget _buildIconPicker() { return FlowyIconPicker( ensureFocus: true, enableBackgroundColorSelection: widget.enableBackgroundColorSelection, onSelectedIcon: (r) { widget.onSelectedEmoji?.call( r.data.toEmojiIconData().toSelectedResult(keepOpen: r.isRandom), ); SystemChannels.textInput.invokeMethod('TextInput.hide'); }, ); } Widget _buildIconUploader() { return IconUploader( documentId: widget.documentId ?? '', ensureFocus: true, onUrl: (url) { widget.onSelectedEmoji ?.call(SelectedEmojiIconResult(EmojiIconData.custom(url), false)); }, ); } } class _RemoveIconButton extends StatelessWidget { const _RemoveIconButton({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( height: 32, child: FlowyButton( onTap: onTap, useIntrinsicWidth: true, text: FlowyText( fontSize: 14.0, figmaLineHeight: 16.0, fontWeight: FontWeight.w500, LocaleKeys.button_remove.tr(), color: Theme.of(context).hintColor, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon.dart ================================================ import 'package:json_annotation/json_annotation.dart'; part 'icon.g.dart'; @JsonSerializable() class IconGroup { factory IconGroup.fromJson(Map json) { final group = _$IconGroupFromJson(json); // Set the iconGroup reference for each icon for (final icon in group.icons) { icon.iconGroup = group; } return group; } factory IconGroup.fromMapEntry(MapEntry entry) => IconGroup.fromJson({ 'name': entry.key, 'icons': entry.value, }); IconGroup({ required this.name, required this.icons, }) { // Set the iconGroup reference for each icon for (final icon in icons) { icon.iconGroup = this; } } final String name; final List icons; String get displayName => name.replaceAll('_', ' '); IconGroup filter(String keyword) { final lowercaseKey = keyword.toLowerCase(); final filteredIcons = icons .where( (icon) => icon.keywords .any((k) => k.toLowerCase().contains(lowercaseKey)) || icon.name.toLowerCase().contains(lowercaseKey), ) .toList(); return IconGroup(name: name, icons: filteredIcons); } String? getSvgContent(String iconName) { final icon = icons.firstWhere( (icon) => icon.name == iconName, ); return icon.content; } Map toJson() => _$IconGroupToJson(this); } @JsonSerializable() class Icon { factory Icon.fromJson(Map json) => _$IconFromJson(json); Icon({ required this.name, required this.keywords, required this.content, }); final String name; final List keywords; final String content; // Add reference to parent IconGroup IconGroup? iconGroup; String get displayName => name.replaceAll('-', ' '); Map toJson() => _$IconToJson(this); String get iconPath { if (iconGroup == null) { return ''; } return '${iconGroup!.name}/$name'; } } class RecentIcon { factory RecentIcon.fromJson(Map json) => RecentIcon(_$IconFromJson(json), json['groupName'] ?? ''); RecentIcon(this.icon, this.groupName); final Icon icon; final String groupName; String get name => icon.name; List get keywords => icon.keywords; String get content => icon.content; Map toJson() => _$IconToJson( Icon(name: name, keywords: keywords, content: content), )..addAll({'groupName': groupName}); } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_color_picker.dart ================================================ import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; class IconColorPicker extends StatelessWidget { const IconColorPicker({ super.key, required this.onSelected, }); final void Function(String color) onSelected; @override Widget build(BuildContext context) { return GridView.count( shrinkWrap: true, crossAxisCount: 6, mainAxisSpacing: 4.0, children: builtInSpaceColors.map((color) { return FlowyHover( style: HoverStyle(borderRadius: BorderRadius.circular(8.0)), child: GestureDetector( onTap: () => onSelected(color), child: Container( width: 34, height: 34, padding: const EdgeInsets.all(5.0), child: Container( clipBehavior: Clip.antiAlias, decoration: ShapeDecoration( color: Color(int.parse(color)), shape: RoundedRectangleBorder( side: const BorderSide(color: Color(0x2D333333)), borderRadius: BorderRadius.circular(8), ), ), ), ), ), ); }).toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart ================================================ import 'dart:convert'; import 'dart:math'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_search_bar.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/util/debounce.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Icon; import 'package:flutter/services.dart'; import 'colors.dart'; import 'icon_color_picker.dart'; // cache the icon groups to avoid loading them multiple times List? kIconGroups; const _kRecentIconGroupName = 'Recent'; extension IconGroupFilter on List { String? findSvgContent(String key) { final values = key.split('/'); if (values.length != 2) { return null; } final groupName = values[0]; final iconName = values[1]; final svgString = kIconGroups ?.firstWhereOrNull( (group) => group.name == groupName, ) ?.icons .firstWhereOrNull( (icon) => icon.name == iconName, ) ?.content; return svgString; } (IconGroup, Icon) randomIcon() { final random = Random(); final group = this[random.nextInt(length)]; final icon = group.icons[random.nextInt(group.icons.length)]; return (group, icon); } } Future> loadIconGroups() async { if (kIconGroups != null) { return kIconGroups!; } final stopwatch = Stopwatch()..start(); final jsonString = await rootBundle.loadString('assets/icons/icons.json'); try { final json = jsonDecode(jsonString) as Map; final iconGroups = json.entries.map(IconGroup.fromMapEntry).toList(); kIconGroups = iconGroups; return iconGroups; } catch (e) { Log.error('Failed to decode icons.json', e); return []; } finally { stopwatch.stop(); Log.info('Loaded icon groups in ${stopwatch.elapsedMilliseconds}ms'); } } class IconPickerResult { IconPickerResult(this.data, this.isRandom); final IconsData data; final bool isRandom; } extension IconsDataToIconPickerResultExtension on IconsData { IconPickerResult toResult({bool isRandom = false}) => IconPickerResult(this, isRandom); } class FlowyIconPicker extends StatefulWidget { const FlowyIconPicker({ super.key, required this.onSelectedIcon, required this.enableBackgroundColorSelection, this.iconPerLine = 9, this.ensureFocus = false, }); final bool enableBackgroundColorSelection; final ValueChanged onSelectedIcon; final int iconPerLine; final bool ensureFocus; @override State createState() => _FlowyIconPickerState(); } class _FlowyIconPickerState extends State { final List iconGroups = []; bool loaded = false; final ValueNotifier keyword = ValueNotifier(''); final debounce = Debounce(duration: const Duration(milliseconds: 150)); Future loadIcons() async { final localIcons = await loadIconGroups(); final recentIcons = await RecentIcons.getIcons(); if (recentIcons.isNotEmpty) { final filterRecentIcons = recentIcons .sublist( 0, min(recentIcons.length, widget.iconPerLine), ) .skipWhile((e) => e.groupName.isEmpty) .map((e) => e.icon) .toList(); if (filterRecentIcons.isNotEmpty) { iconGroups.add( IconGroup( name: _kRecentIconGroupName, icons: filterRecentIcons, ), ); } } iconGroups.addAll(localIcons); if (mounted) { setState(() { loaded = true; }); } } @override void initState() { super.initState(); loadIcons(); } @override void dispose() { keyword.dispose(); debounce.dispose(); iconGroups.clear(); loaded = false; super.dispose(); } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: IconSearchBar( ensureFocus: widget.ensureFocus, onRandomTap: () { final value = kIconGroups?.randomIcon(); if (value == null) { return; } final color = widget.enableBackgroundColorSelection ? generateRandomSpaceColor() : null; widget.onSelectedIcon( IconsData( value.$1.name, value.$2.name, color, ).toResult(isRandom: true), ); RecentIcons.putIcon(RecentIcon(value.$2, value.$1.name)); }, onKeywordChanged: (keyword) => { debounce.call(() { this.keyword.value = keyword; }), }, ), ), Expanded( child: loaded ? _buildIcons(iconGroups) : const Center( child: SizedBox.square( dimension: 24.0, child: CircularProgressIndicator( strokeWidth: 2.0, ), ), ), ), ], ); } Widget _buildIcons(List iconGroups) { return ValueListenableBuilder( valueListenable: keyword, builder: (_, keyword, __) { if (keyword.isNotEmpty) { final filteredIconGroups = iconGroups .map((iconGroup) => iconGroup.filter(keyword)) .where((iconGroup) => iconGroup.icons.isNotEmpty) .toList(); return IconPicker( iconGroups: filteredIconGroups, enableBackgroundColorSelection: widget.enableBackgroundColorSelection, onSelectedIcon: (r) => widget.onSelectedIcon.call(r.toResult()), iconPerLine: widget.iconPerLine, ); } return IconPicker( iconGroups: iconGroups, enableBackgroundColorSelection: widget.enableBackgroundColorSelection, onSelectedIcon: (r) => widget.onSelectedIcon.call(r.toResult()), iconPerLine: widget.iconPerLine, ); }, ); } } class IconsData { IconsData(this.groupName, this.iconName, this.color); final String groupName; final String iconName; final String? color; String get iconString => jsonEncode({ 'groupName': groupName, 'iconName': iconName, if (color != null) 'color': color, }); EmojiIconData toEmojiIconData() => EmojiIconData.icon(this); IconsData noColor() => IconsData(groupName, iconName, null); static IconsData fromJson(dynamic json) { return IconsData( json['groupName'], json['iconName'], json['color'], ); } String? get svgString => kIconGroups ?.firstWhereOrNull((group) => group.name == groupName) ?.icons .firstWhereOrNull((icon) => icon.name == iconName) ?.content; } class IconPicker extends StatefulWidget { const IconPicker({ super.key, required this.onSelectedIcon, required this.enableBackgroundColorSelection, required this.iconGroups, required this.iconPerLine, }); final List iconGroups; final int iconPerLine; final bool enableBackgroundColorSelection; final ValueChanged onSelectedIcon; @override State createState() => _IconPickerState(); } class _IconPickerState extends State { final mutex = PopoverMutex(); PopoverController? childPopoverController; @override void dispose() { super.dispose(); childPopoverController = null; } @override Widget build(BuildContext context) { return GestureDetector( onTap: hideColorSelector, child: NotificationListener( onNotification: (notificationInfo) { if (notificationInfo is ScrollStartNotification) { hideColorSelector(); } return true; }, child: ListView.builder( itemCount: widget.iconGroups.length, padding: const EdgeInsets.symmetric(horizontal: 16.0), itemBuilder: (context, index) { final iconGroup = widget.iconGroups[index]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText( iconGroup.displayName.capitalize(), fontSize: 12, figmaLineHeight: 18.0, color: context.pickerTextColor, ), const VSpace(4.0), GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: widget.iconPerLine, ), itemCount: iconGroup.icons.length, physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, itemBuilder: (context, index) { final icon = iconGroup.icons[index]; return widget.enableBackgroundColorSelection ? _Icon( icon: icon, mutex: mutex, onOpen: (childPopoverController) { this.childPopoverController = childPopoverController; }, onSelectedColor: (context, color) { String groupName = iconGroup.name; if (groupName == _kRecentIconGroupName) { groupName = getGroupName(index); } widget.onSelectedIcon( IconsData( groupName, icon.name, color, ), ); RecentIcons.putIcon(RecentIcon(icon, groupName)); PopoverContainer.of(context).close(); }, ) : _IconNoBackground( icon: icon, onSelectedIcon: () { String groupName = iconGroup.name; if (groupName == _kRecentIconGroupName) { groupName = getGroupName(index); } widget.onSelectedIcon( IconsData( groupName, icon.name, null, ), ); RecentIcons.putIcon(RecentIcon(icon, groupName)); }, ); }, ), const VSpace(12.0), if (index == widget.iconGroups.length - 1) ...[ const StreamlinePermit(), const VSpace(12.0), ], ], ); }, ), ), ); } void hideColorSelector() { childPopoverController?.close(); childPopoverController = null; } String getGroupName(int index) { final recentIcons = RecentIcons.getIconsSync(); try { return recentIcons[index].groupName; } catch (e) { Log.error('getGroupName with index: $index error', e); return ''; } } } class _IconNoBackground extends StatelessWidget { const _IconNoBackground({ required this.icon, required this.onSelectedIcon, this.isSelected = false, }); final Icon icon; final bool isSelected; final VoidCallback onSelectedIcon; @override Widget build(BuildContext context) { return FlowyTooltip( message: icon.displayName, preferBelow: false, child: FlowyButton( isSelected: isSelected, useIntrinsicWidth: true, onTap: () => onSelectedIcon(), margin: const EdgeInsets.all(8.0), text: Center( child: FlowySvg.string( icon.content, size: const Size.square(20), color: context.pickerIconColor, opacity: 0.7, ), ), ), ); } } class _Icon extends StatefulWidget { const _Icon({ required this.icon, required this.mutex, required this.onSelectedColor, this.onOpen, }); final Icon icon; final PopoverMutex mutex; final void Function(BuildContext context, String color) onSelectedColor; final ValueChanged? onOpen; @override State<_Icon> createState() => _IconState(); } class _IconState extends State<_Icon> { final PopoverController _popoverController = PopoverController(); bool isSelected = false; @override void dispose() { super.dispose(); _popoverController.close(); } @override Widget build(BuildContext context) { return AppFlowyPopover( direction: PopoverDirection.bottomWithCenterAligned, controller: _popoverController, offset: const Offset(0, 6), mutex: widget.mutex, onClose: () { updateIsSelected(false); }, clickHandler: PopoverClickHandler.gestureDetector, child: _IconNoBackground( icon: widget.icon, isSelected: isSelected, onSelectedIcon: () { updateIsSelected(true); _popoverController.show(); widget.onOpen?.call(_popoverController); }, ), popupBuilder: (context) { return Container( padding: const EdgeInsets.all(6.0), child: IconColorPicker( onSelected: (color) => widget.onSelectedColor(context, color), ), ); }, ); } void updateIsSelected(bool isSelected) { setState(() { this.isSelected = isSelected; }); } } class StreamlinePermit extends StatelessWidget { const StreamlinePermit({ super.key, }); @override Widget build(BuildContext context) { // Open source icons from Streamline final textStyle = TextStyle( fontSize: 12.0, height: 18.0 / 12.0, fontWeight: FontWeight.w500, color: context.pickerTextColor, ); return RichText( text: TextSpan( children: [ TextSpan( text: '${LocaleKeys.emoji_openSourceIconsFrom.tr()} ', style: textStyle, ), TextSpan( text: 'Streamline', style: textStyle.copyWith( decoration: TextDecoration.underline, color: Theme.of(context).colorScheme.primary, ), recognizer: TapGestureRecognizer() ..onTap = () { afLaunchUrlString('https://www.streamlinehq.com/'); }, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_search_bar.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:universal_platform/universal_platform.dart'; import 'colors.dart'; typedef IconKeywordChangedCallback = void Function(String keyword); typedef EmojiSkinToneChanged = void Function(EmojiSkinTone skinTone); class IconSearchBar extends StatefulWidget { const IconSearchBar({ super.key, required this.onRandomTap, required this.onKeywordChanged, this.ensureFocus = false, }); final VoidCallback onRandomTap; final bool ensureFocus; final IconKeywordChangedCallback onKeywordChanged; @override State createState() => _IconSearchBarState(); } class _IconSearchBarState extends State { final TextEditingController controller = TextEditingController(); @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.symmetric( vertical: 12.0, horizontal: UniversalPlatform.isDesktopOrWeb ? 0.0 : 8.0, ), child: Row( children: [ Expanded( child: _SearchTextField( onKeywordChanged: widget.onKeywordChanged, ensureFocus: widget.ensureFocus, ), ), const HSpace(8.0), _RandomIconButton( onRandomTap: widget.onRandomTap, ), ], ), ); } } class _RandomIconButton extends StatelessWidget { const _RandomIconButton({ required this.onRandomTap, }); final VoidCallback onRandomTap; @override Widget build(BuildContext context) { return Container( width: 36, height: 36, decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: BorderSide(color: context.pickerButtonBoarderColor), borderRadius: BorderRadius.circular(8), ), ), child: FlowyTooltip( message: LocaleKeys.emoji_random.tr(), child: FlowyButton( useIntrinsicWidth: true, text: const FlowySvg( FlowySvgs.icon_shuffle_s, ), onTap: onRandomTap, ), ), ); } } class _SearchTextField extends StatefulWidget { const _SearchTextField({ required this.onKeywordChanged, this.ensureFocus = false, }); final IconKeywordChangedCallback onKeywordChanged; final bool ensureFocus; @override State<_SearchTextField> createState() => _SearchTextFieldState(); } class _SearchTextFieldState extends State<_SearchTextField> { final TextEditingController controller = TextEditingController(); final FocusNode focusNode = FocusNode(); @override void initState() { super.initState(); /// Sometimes focus is lost due to the [SelectionGestureInterceptor] in [KeyboardServiceWidgetState] /// this is to ensure that focus can be regained within a short period of time if (widget.ensureFocus) { Future.delayed(const Duration(milliseconds: 200), () { if (!mounted || focusNode.hasFocus) return; focusNode.requestFocus(); }); } } @override void dispose() { controller.dispose(); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: 36.0, child: FlowyTextField( focusNode: focusNode, hintText: LocaleKeys.search_label.tr(), hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: 14.0, fontWeight: FontWeight.w400, color: Theme.of(context).hintColor, ), enableBorderColor: context.pickerSearchBarBorderColor, controller: controller, onChanged: widget.onKeywordChanged, prefixIcon: const Padding( padding: EdgeInsets.only( left: 14.0, right: 8.0, ), child: FlowySvg( FlowySvgs.search_s, ), ), prefixIconConstraints: const BoxConstraints( maxHeight: 20.0, ), suffixIcon: Padding( padding: const EdgeInsets.all(4.0), child: FlowyButton( text: const FlowySvg( FlowySvgs.m_app_bar_close_s, ), margin: EdgeInsets.zero, useIntrinsicWidth: true, onTap: () { if (controller.text.isNotEmpty) { controller.clear(); widget.onKeywordChanged(''); } else { focusNode.unfocus(); } }, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_uploader.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/appflowy_network_svg.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/shared/permission/permission_checker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/util/default_extensions.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:dotted_border/dotted_border.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart'; import 'package:image_picker/image_picker.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; @visibleForTesting class IconUploader extends StatefulWidget { const IconUploader({ super.key, required this.onUrl, required this.documentId, this.ensureFocus = false, }); final ValueChanged onUrl; final String documentId; final bool ensureFocus; @override State createState() => _IconUploaderState(); } class _IconUploaderState extends State { bool isActive = false; bool isHovering = false; bool isUploading = false; final List<_Image> pickedImages = []; final FocusNode focusNode = FocusNode(); @override void initState() { super.initState(); /// Sometimes focus is lost due to the [SelectionGestureInterceptor] in [KeyboardServiceWidgetState] /// this is to ensure that focus can be regained within a short period of time if (widget.ensureFocus) { Future.delayed(const Duration(milliseconds: 200), () { if (!mounted || focusNode.hasFocus) return; focusNode.requestFocus(); }); } WidgetsBinding.instance.addPostFrameCallback((_) { enableDocumentDragNotifier.value = false; }); } @override void dispose() { super.dispose(); WidgetsBinding.instance.addPostFrameCallback((_) { enableDocumentDragNotifier.value = true; }); focusNode.dispose(); } @override Widget build(BuildContext context) { return Shortcuts( shortcuts: { LogicalKeySet( Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyV, ): _PasteIntent(), }, child: Actions( actions: { _PasteIntent: CallbackAction<_PasteIntent>( onInvoke: (intent) => pasteAsAnImage(), ), }, child: Focus( autofocus: true, focusNode: focusNode, child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ Expanded( child: DropTarget( onDragEntered: (_) => setState(() => isActive = true), onDragExited: (_) => setState(() => isActive = false), onDragDone: (details) => loadImage(details.files), child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => isHovering = true), onExit: (_) => setState(() => isHovering = false), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: pickImage, child: DottedBorder( dashPattern: const [3, 3], radius: const Radius.circular(8), borderType: BorderType.RRect, color: isActive ? Theme.of(context).colorScheme.primary : Theme.of(context).hintColor, child: Container( alignment: Alignment.center, decoration: isHovering ? BoxDecoration( color: Color(0x0F1F2329), borderRadius: BorderRadius.circular(8), ) : null, child: pickedImages.isEmpty ? (isActive ? hoveringWidget() : dragHint(context)) : previewImage(), ), ), ), ), ), ), Padding( padding: const EdgeInsets.only(top: 16), child: Row( children: [ Spacer(), if (pickedImages.isNotEmpty) Padding( padding: EdgeInsets.only(right: 8), child: _ChangeIconButton( onTap: pickImage, ), ), _ConfirmButton( onTap: uploadImage, enable: pickedImages.isNotEmpty, ), ], ), ), ], ), ), ), ), ); } Widget hoveringWidget() { return Container( color: Color(0xffE0F8FF), child: Center( child: FlowyText( LocaleKeys.emojiIconPicker_iconUploader_dropToUpload.tr(), ), ), ); } Widget dragHint(BuildContext context) { final style = TextStyle( fontSize: 14, color: Color(0xff666D76), fontWeight: FontWeight.w500, ); return Padding( padding: EdgeInsets.symmetric(horizontal: 32), child: RichText( textAlign: TextAlign.center, text: TextSpan( children: [ TextSpan( text: LocaleKeys.emojiIconPicker_iconUploader_placeholderLeft.tr(), ), TextSpan( text: LocaleKeys.emojiIconPicker_iconUploader_placeholderUpload .tr(), style: style.copyWith(color: Color(0xff00BCF0)), ), TextSpan( text: LocaleKeys.emojiIconPicker_iconUploader_placeholderRight.tr(), mouseCursor: SystemMouseCursors.click, ), ], style: style, ), ), ); } Widget previewImage() { final image = pickedImages.first; final url = image.url; if (image is _FileImage) { if (url.endsWith(_svgSuffix)) { return SvgPicture.file( File(url), width: 200, height: 200, ); } return Image.file( File(url), width: 200, height: 200, ); } else if (image is _NetworkImage) { if (url.endsWith(_svgSuffix)) { return FlowyNetworkSvg( url, width: 200, height: 200, ); } return FlowyNetworkImage( width: 200, height: 200, url: url, ); } return const SizedBox.shrink(); } void loadImage(List files) { final imageFiles = files .where( (file) => file.mimeType?.startsWith('image/') ?? false || imgExtensionRegex.hasMatch(file.name) || file.name.endsWith(_svgSuffix), ) .toList(); if (imageFiles.isEmpty) return; if (mounted) { setState(() { pickedImages.clear(); pickedImages.add(_FileImage(imageFiles.first.path)); }); } } Future pickImage() async { if (UniversalPlatform.isDesktopOrWeb) { // on desktop, the users can pick a image file from folder final result = await getIt().pickFiles( dialogTitle: '', type: FileType.custom, allowedExtensions: List.of(defaultImageExtensions)..add('svg'), ); loadImage(result?.files.map((f) => f.xFile).toList() ?? const []); } else { final photoPermission = await PermissionChecker.checkPhotoPermission(context); if (!photoPermission) { Log.error('Has no permission to access the photo library'); return; } // on mobile, the users can pick a image file from camera or image library final result = await ImagePicker().pickMultiImage(); loadImage(result); } } Future uploadImage() async { if (pickedImages.isEmpty || isUploading) return; isUploading = true; String? result; final userProfileResult = await UserBackendService.getCurrentUserProfile(); final userProfile = userProfileResult.fold( (userProfile) => userProfile, (l) => null, ); final isLocalMode = (userProfile?.workspaceType ?? WorkspaceTypePB.LocalW) == WorkspaceTypePB.LocalW; if (isLocalMode) { result = await pickedImages.first.saveToLocal(); } else { result = await pickedImages.first.uploadToCloud(widget.documentId); } isUploading = false; if (result?.isNotEmpty ?? false) { widget.onUrl.call(result!); } } Future pasteAsAnImage() async { final data = await getIt().getData(); final plainText = data.plainText; Log.info('pasteAsAnImage plainText:$plainText'); if (plainText == null) return; if (isURL(plainText) && (await validateImage(plainText))) { setState(() { pickedImages.clear(); pickedImages.add(_NetworkImage(plainText)); }); } } Future validateImage(String imageUrl) async { Response res; try { res = await get(Uri.parse(imageUrl)); } catch (e) { return false; } if (res.statusCode != 200) return false; final Map data = res.headers; return checkIfImage(data['content-type']); } bool checkIfImage(String? param) { if (param == 'image/jpeg' || param == 'image/png' || param == 'image/gif' || param == 'image/tiff' || param == 'image/webp' || param == 'image/svg+xml' || param == 'image/svg') { return true; } return false; } } class _ChangeIconButton extends StatelessWidget { const _ChangeIconButton({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return SizedBox( height: 32, width: 84, child: FlowyButton( text: FlowyText( LocaleKeys.emojiIconPicker_iconUploader_change.tr(), fontSize: 14.0, fontWeight: FontWeight.w500, figmaLineHeight: 20.0, color: isDark ? Colors.white : Color(0xff1F2329), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ), margin: const EdgeInsets.symmetric(horizontal: 14.0), backgroundColor: Theme.of(context).colorScheme.surface, hoverColor: (isDark ? Colors.white : Color(0xffD1D8E0)).withValues(alpha: 0.9), decoration: BoxDecoration( border: Border.all(color: isDark ? Colors.white : Color(0xffD1D8E0)), borderRadius: BorderRadius.circular(10), ), onTap: onTap, ), ); } } class _ConfirmButton extends StatelessWidget { const _ConfirmButton({required this.onTap, this.enable = true}); final VoidCallback onTap; final bool enable; @override Widget build(BuildContext context) { return SizedBox( height: 32, child: Opacity( opacity: enable ? 1.0 : 0.5, child: PrimaryRoundedButton( text: LocaleKeys.button_confirm.tr(), figmaLineHeight: 20.0, onTap: enable ? onTap : null, ), ), ); } } const _svgSuffix = '.svg'; class _PasteIntent extends Intent {} abstract class _Image { String get url; Future saveToLocal(); Future uploadToCloud(String documentId); String get pureUrl => url.split('?').first; } class _FileImage extends _Image { _FileImage(this.url); @override final String url; @override Future saveToLocal() => saveImageToLocalStorage(url); @override Future uploadToCloud(String documentId) async { final (url, errorMsg) = await saveImageToCloudStorage( this.url, documentId, ); if (errorMsg?.isNotEmpty ?? false) { Log.error('upload icon image :${this.url} error :$errorMsg'); } return url; } } class _NetworkImage extends _Image { _NetworkImage(this.url); @override final String url; @override Future saveToLocal() async { final file = await CustomImageCacheManager().downloadFile(pureUrl); return file.file.path; } @override Future uploadToCloud(String documentId) async { final file = await CustomImageCacheManager().downloadFile(pureUrl); final (url, errorMsg) = await saveImageToCloudStorage( file.file.path, documentId, ); if (errorMsg?.isNotEmpty ?? false) { Log.error('upload icon image :${this.url} error :$errorMsg'); } return url; } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/recent_icons.dart ================================================ import 'dart:convert'; import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter/foundation.dart'; import '../../core/config/kv.dart'; import '../../core/config/kv_keys.dart'; import '../../startup/startup.dart'; import 'flowy_icon_emoji_picker.dart'; class RecentIcons { static final Map> _dataMap = {}; static bool _loaded = false; static const maxLength = 20; /// To prevent the Recent Icon feature from affecting the unit tests of the Icon Selector. @visibleForTesting static bool enable = true; static Future putEmoji(String id) async { await _put(FlowyIconType.emoji, id); } static Future putIcon(RecentIcon icon) async { await _put( FlowyIconType.icon, jsonEncode(icon.toJson()), ); } static Future> getEmojiIds() async { await _load(); return _dataMap[FlowyIconType.emoji.name] ?? []; } static Future> getIcons() async { await _load(); return getIconsSync(); } static List getIconsSync() { final iconList = _dataMap[FlowyIconType.icon.name] ?? []; try { final List result = []; for (final map in iconList) { final recentIcon = RecentIcon.fromJson(jsonDecode(map) as Map); if (recentIcon.groupName.isEmpty) { continue; } result.add(recentIcon); } return result; } catch (e) { Log.error('RecentIcons getIcons with :$iconList', e); } return []; } @visibleForTesting static void clear() { _dataMap.clear(); getIt().remove(KVKeys.recentIcons); } static Future _save() async { await getIt().set( KVKeys.recentIcons, jsonEncode(_dataMap), ); } static Future _load() async { if (_loaded || !enable) { return; } final storage = getIt(); final value = await storage.get(KVKeys.recentIcons); if (value == null || value.isEmpty) { _loaded = true; return; } try { final data = jsonDecode(value) as Map; _dataMap ..clear() ..addAll( Map>.from( data.map((k, v) => MapEntry(k, List.from(v))), ), ); } catch (e) { Log.error('RecentIcons load failed with: $value', e); } _loaded = true; } static Future _put(FlowyIconType key, String value) async { await _load(); if (!enable) return; final list = _dataMap[key.name] ?? []; list.remove(value); list.insert(0, value); if (list.length > maxLength) list.removeLast(); _dataMap[key.name] = list; await _save(); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/icon_emoji_picker/tab.dart ================================================ import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; import 'package:flutter/material.dart'; enum PickerTabType { emoji, icon, custom; String get tr { switch (this) { case PickerTabType.emoji: return 'Emojis'; case PickerTabType.icon: return 'Icons'; case PickerTabType.custom: return 'Upload'; } } } extension StringToPickerTabType on String { PickerTabType? toPickerTabType() { try { return PickerTabType.values.byName(this); } on ArgumentError { return null; } } } class PickerTab extends StatelessWidget { const PickerTab({ super.key, this.onTap, required this.controller, required this.tabs, }); final List tabs; final TabController controller; final ValueChanged? onTap; @override Widget build(BuildContext context) { final baseStyle = Theme.of(context).textTheme.bodyMedium; final style = baseStyle?.copyWith( fontWeight: FontWeight.w500, fontSize: 14.0, height: 16.0 / 14.0, ); return TabBar( controller: controller, indicatorSize: TabBarIndicatorSize.label, indicatorColor: Theme.of(context).colorScheme.primary, isScrollable: true, labelStyle: style, labelColor: baseStyle?.color, labelPadding: const EdgeInsets.symmetric(horizontal: 12.0), unselectedLabelStyle: style?.copyWith( color: Theme.of(context).hintColor, ), overlayColor: WidgetStateProperty.all(Colors.transparent), indicator: RoundUnderlineTabIndicator( width: 34.0, borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, width: 3, ), ), onTap: onTap, tabs: tabs .map( (tab) => Tab( text: tab.tr, ), ) .toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/list_extension.dart ================================================ extension Unique on List { List unique([Id Function(E element)? id]) { final ids = {}; final list = [...this]; list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id)); return list; } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/loading.dart ================================================ import 'dart:async'; import 'package:flutter/material.dart'; class Loading { Loading(this.context); BuildContext? loadingContext; final BuildContext context; bool hasStopped = false; void start() => unawaited( showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { loadingContext = context; if (hasStopped) { WidgetsBinding.instance.addPostFrameCallback((_) { Navigator.of(loadingContext!).maybePop(); loadingContext = null; }); } return const SimpleDialog( elevation: 0.0, backgroundColor: Colors.transparent, // can change this to your preferred color children: [ Center( child: CircularProgressIndicator(), ), ], ); }, ), ); void stop() { if (loadingContext != null) { Navigator.of(loadingContext!).pop(); loadingContext = null; } hasStopped = true; } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/markdown_to_document.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:archive/archive.dart'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:share_plus/share_plus.dart'; Document customMarkdownToDocument( String markdown, { double? tableWidth, }) { return markdownToDocument( markdown, markdownParsers: [ const MarkdownCodeBlockParser(), MarkdownSimpleTableParser(tableWidth: tableWidth), ], ); } Future customDocumentToMarkdown( Document document, { String path = '', AsyncValueSetter? onArchive, String lineBreak = '', }) async { final List> fileFutures = []; /// create root Archive and directory final id = document.root.id, archive = Archive(), resourceDir = ArchiveFile('$id/', 0, null)..isFile = false, fileName = p.basenameWithoutExtension(path), dirName = resourceDir.name; String markdown = ''; try { markdown = documentToMarkdown( document, lineBreak: lineBreak, customParsers: [ const MathEquationNodeParser(), const CalloutNodeParser(), const ToggleListNodeParser(), CustomImageNodeFileParser(fileFutures, dirName), CustomMultiImageNodeFileParser(fileFutures, dirName), GridNodeParser(fileFutures, dirName), BoardNodeParser(fileFutures, dirName), CalendarNodeParser(fileFutures, dirName), const CustomParagraphNodeParser(), const SubPageNodeParser(), const SimpleTableNodeParser(), const LinkPreviewNodeParser(), const FileBlockNodeParser(), ], ); } catch (e) { Log.error('documentToMarkdown error: $e'); } /// create resource directory if (fileFutures.isNotEmpty) archive.addFile(resourceDir); for (final fileFuture in fileFutures) { archive.addFile(await fileFuture); } /// add markdown file to Archive final dataBytes = utf8.encode(markdown); archive.addFile(ArchiveFile('$fileName-$id.md', dataBytes.length, dataBytes)); if (archive.isNotEmpty && path.isNotEmpty) { if (onArchive == null) { final zipEncoder = ZipEncoder(); final zip = zipEncoder.encode(archive); if (zip != null) { final zipFile = await File(path).writeAsBytes(zip); if (Platform.isIOS) { await Share.shareUri(zipFile.uri); await zipFile.delete(); } else if (Platform.isAndroid) { await Share.shareXFiles([XFile(zipFile.path)]); await zipFile.delete(); } Log.info('documentToMarkdownFiles to $path'); } } else { await onArchive.call(archive); } } return markdown; } ================================================ FILE: frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart ================================================ const _trailingZerosPattern = r'^(\d+(?:\.\d*?[1-9](?=0|\b))?)\.?0*$'; final trailingZerosRegex = RegExp(_trailingZerosPattern); const _hrefPattern = r'https?://(?:www\.)?[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}(?:/[^\s]*)?'; final hrefRegex = RegExp(_hrefPattern); /// This pattern allows for both HTTP and HTTPS Scheme /// It allows for query parameters /// It only allows the following image extensions: .png, .jpg, .jpeg, .gif, .webm, .webp, .bmp /// const _imgUrlPattern = r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.jpeg|.gif|.webm|.webp|.bmp)(\?[^\s[",><]*)?'; final imgUrlRegex = RegExp(_imgUrlPattern); const _singleLineMarkdownImagePattern = "^!\\[.*\\]\\(($_hrefPattern)\\)\$"; final singleLineMarkdownImageRegex = RegExp(_singleLineMarkdownImagePattern); /// This pattern allows for both HTTP and HTTPS Scheme /// It allows for query parameters /// It only allows the following video extensions: /// .mp4, .mov, .avi, .webm, .flv, .m4v (mpeg), .mpeg, .h264, /// const _videoUrlPattern = r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.mp4|.mov|.avi|.webm|.flv|.m4v|.mpeg|.h264)(\?[^\s[",><]*)?'; final videoUrlRegex = RegExp(_videoUrlPattern); /// This pattern matches both youtube.com and shortened youtu.be urls. /// const _youtubeUrlPattern = r'^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/'; final youtubeUrlRegex = RegExp(_youtubeUrlPattern); const _appflowyCloudUrlPattern = r'^(https:\/\/)(.*)(\.appflowy\.cloud\/)(.*)'; final appflowyCloudUrlRegex = RegExp(_appflowyCloudUrlPattern); const _camelCasePattern = '(?<=[a-z])[A-Z]'; final camelCaseRegex = RegExp(_camelCasePattern); const _macOSVolumesPattern = '^/Volumes/[^/]+'; final macOSVolumesRegex = RegExp(_macOSVolumesPattern); const appflowySharePageLinkPattern = r'^https://appflowy\.com/app/([^/]+)/([^?]+)(?:\?blockId=(.+))?$'; final appflowySharePageLinkRegex = RegExp(appflowySharePageLinkPattern); const _numberedListPattern = r'^(\d+)\.'; final numberedListRegex = RegExp(_numberedListPattern); const _localPathPattern = r'^(file:\/\/|\/|\\|[a-zA-Z]:[/\\]|\.{1,2}[/\\])'; final localPathRegex = RegExp(_localPathPattern, caseSensitive: false); const _wordPattern = r"\S+"; final wordRegex = RegExp(_wordPattern); const _appleNotesPattern = r'\s*'; final appleNotesRegex = RegExp(_appleNotesPattern); ================================================ FILE: frontend/appflowy_flutter/lib/shared/patterns/date_time_patterns.dart ================================================ /// RegExp to match Twelve Hour formats /// Source: https://stackoverflow.com/a/33906224 /// /// Matches eg: "05:05 PM", "5:50 Pm", "10:59 am", etc. /// const _twelveHourTimePattern = r'\b((1[0-2]|0?[1-9]):([0-5][0-9]) ([AaPp][Mm]))'; final twelveHourTimeRegex = RegExp(_twelveHourTimePattern); bool isTwelveHourTime(String? time) => twelveHourTimeRegex.hasMatch(time ?? ''); /// RegExp to match Twenty Four Hour formats /// Source: https://stackoverflow.com/a/7536768 /// /// Matches eg: "0:01", "04:59", "16:30", etc. /// const _twentyFourHourtimePattern = r'^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'; final tewentyFourHourTimeRegex = RegExp(_twentyFourHourtimePattern); bool isTwentyFourHourTime(String? time) => tewentyFourHourTimeRegex.hasMatch(time ?? ''); ================================================ FILE: frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart ================================================ /// This pattern matches a file extension that is an image. /// const _imgExtensionPattern = r'\.(gif|jpe?g|tiff?|png|webp|bmp)$'; final imgExtensionRegex = RegExp(_imgExtensionPattern); /// This pattern matches a file extension that is a video. /// const _videoExtensionPattern = r'\.(mp4|mov|avi|webm|flv|m4v|mpeg|h264)$'; final videoExtensionRegex = RegExp(_videoExtensionPattern); /// This pattern matches a file extension that is an audio. /// const _audioExtensionPattern = r'\.(mp3|wav|ogg|flac|aac|wma|alac|aiff)$'; final audioExtensionRegex = RegExp(_audioExtensionPattern); /// This pattern matches a file extension that is a document. /// const _documentExtensionPattern = r'\.(pdf|doc|docx)$'; final documentExtensionRegex = RegExp(_documentExtensionPattern); /// This pattern matches a file extension that is an archive. /// const _archiveExtensionPattern = r'\.(zip|tar|gz|7z|rar)$'; final archiveExtensionRegex = RegExp(_archiveExtensionPattern); /// This pattern matches a file extension that is a text. /// const _textExtensionPattern = r'\.(txt|md|html|css|js|json|xml|csv)$'; final textExtensionRegex = RegExp(_textExtensionPattern); ================================================ FILE: frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart ================================================ // Check if the user has the required permission to access the device's // - camera // - storage // - ... import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:universal_platform/universal_platform.dart'; class PermissionChecker { static Future checkPhotoPermission(BuildContext context) async { // check the permission first final status = await Permission.photos.status; // if the permission is permanently denied, we should open the app settings if (status.isPermanentlyDenied && context.mounted) { unawaited( showFlowyMobileConfirmDialog( context, title: FlowyText.semibold( LocaleKeys.pageStyle_photoPermissionTitle.tr(), maxLines: 3, textAlign: TextAlign.center, ), content: FlowyText( LocaleKeys.pageStyle_photoPermissionDescription.tr(), maxLines: 5, textAlign: TextAlign.center, fontSize: 12.0, ), actionAlignment: ConfirmDialogActionAlignment.vertical, actionButtonTitle: LocaleKeys.pageStyle_openSettings.tr(), actionButtonColor: Colors.blue, cancelButtonTitle: LocaleKeys.pageStyle_doNotAllow.tr(), cancelButtonColor: Colors.blue, onActionButtonPressed: () { openAppSettings(); }, ), ); return false; } else if (status.isDenied) { // https://github.com/Baseflow/flutter-permission-handler/issues/1262#issuecomment-2006340937 Permission permission = Permission.photos; if (UniversalPlatform.isAndroid && ApplicationInfo.androidSDKVersion <= 32) { permission = Permission.storage; } // if the permission is denied, we should request the permission final newStatus = await permission.request(); if (newStatus.isDenied) { return false; } } return true; } static Future checkCameraPermission(BuildContext context) async { // check the permission first final status = await Permission.camera.status; // if the permission is permanently denied, we should open the app settings if (status.isPermanentlyDenied && context.mounted) { unawaited( showFlowyMobileConfirmDialog( context, title: FlowyText.semibold( LocaleKeys.pageStyle_cameraPermissionTitle.tr(), maxLines: 3, textAlign: TextAlign.center, ), content: FlowyText( LocaleKeys.pageStyle_cameraPermissionDescription.tr(), maxLines: 5, textAlign: TextAlign.center, fontSize: 12.0, ), actionAlignment: ConfirmDialogActionAlignment.vertical, actionButtonTitle: LocaleKeys.pageStyle_openSettings.tr(), actionButtonColor: Colors.blue, cancelButtonTitle: LocaleKeys.pageStyle_doNotAllow.tr(), cancelButtonColor: Colors.blue, onActionButtonPressed: openAppSettings, ), ); return false; } else if (status.isDenied) { final newStatus = await Permission.camera.request(); if (newStatus.isDenied) { return false; } } return true; } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart ================================================ // This file is copied from Flutter source code, // and modified to fit AppFlowy's needs. // changes: // 1. remove the default ink effect // 2. remove the tooltip // 3. support customize transition animation // Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; // Examples can assume: // enum Commands { heroAndScholar, hurricaneCame } // late bool _heroAndScholar; // late dynamic _selection; // late BuildContext context; // void setState(VoidCallback fn) { } // enum Menu { itemOne, itemTwo, itemThree, itemFour } const Duration _kMenuDuration = Duration(milliseconds: 300); const double _kMenuCloseIntervalEnd = 2.0 / 3.0; const double _kMenuDividerHeight = 16.0; const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep; const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; const double _kMenuVerticalPadding = 8.0; const double _kMenuWidthStep = 56.0; const double _kMenuScreenPadding = 8.0; GlobalKey<_PopupMenuState>? _kPopupMenuKey; void closePopupMenu() { _kPopupMenuKey?.currentState?.dismiss(); _kPopupMenuKey = null; } /// A base class for entries in a Material Design popup menu. /// /// The popup menu widget uses this interface to interact with the menu items. /// To show a popup menu, use the [showMenu] function. To create a button that /// shows a popup menu, consider using [PopupMenuButton]. /// /// The type `T` is the type of the value(s) the entry represents. All the /// entries in a given menu must represent values with consistent types. /// /// A [PopupMenuEntry] may represent multiple values, for example a row with /// several icons, or a single entry, for example a menu item with an icon (see /// [PopupMenuItem]), or no value at all (for example, [PopupMenuDivider]). /// /// See also: /// /// * [PopupMenuItem], a popup menu entry for a single value. /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. /// * [CheckedPopupMenuItem], a popup menu item with a checkmark. /// * [showMenu], a method to dynamically show a popup menu at a given location. /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when /// it is tapped. abstract class PopupMenuEntry extends StatefulWidget { /// Abstract const constructor. This constructor enables subclasses to provide /// const constructors so that they can be used in const expressions. const PopupMenuEntry({super.key}); /// The amount of vertical space occupied by this entry. /// /// This value is used at the time the [showMenu] method is called, if the /// `initialValue` argument is provided, to determine the position of this /// entry when aligning the selected entry over the given `position`. It is /// otherwise ignored. double get height; /// Whether this entry represents a particular value. /// /// This method is used by [showMenu], when it is called, to align the entry /// representing the `initialValue`, if any, to the given `position`, and then /// later is called on each entry to determine if it should be highlighted (if /// the method returns true, the entry will have its background color set to /// the ambient [ThemeData.highlightColor]). If `initialValue` is null, then /// this method is not called. /// /// If the [PopupMenuEntry] represents a single value, this should return true /// if the argument matches that value. If it represents multiple values, it /// should return true if the argument matches any of them. bool represents(T? value); } /// A horizontal divider in a Material Design popup menu. /// /// This widget adapts the [Divider] for use in popup menus. /// /// See also: /// /// * [PopupMenuItem], for the kinds of items that this widget divides. /// * [showMenu], a method to dynamically show a popup menu at a given location. /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when /// it is tapped. class PopupMenuDivider extends PopupMenuEntry { /// Creates a horizontal divider for a popup menu. /// /// By default, the divider has a height of 16 logical pixels. const PopupMenuDivider({super.key, this.height = _kMenuDividerHeight}); /// The height of the divider entry. /// /// Defaults to 16 pixels. @override final double height; @override bool represents(void value) => false; @override State createState() => _PopupMenuDividerState(); } class _PopupMenuDividerState extends State { @override Widget build(BuildContext context) => Divider(height: widget.height); } // This widget only exists to enable _PopupMenuRoute to save the sizes of // each menu item. The sizes are used by _PopupMenuRouteLayout to compute the // y coordinate of the menu's origin so that the center of selected menu // item lines up with the center of its PopupMenuButton. class _MenuItem extends SingleChildRenderObjectWidget { const _MenuItem({ required this.onLayout, required super.child, }); final ValueChanged onLayout; @override RenderObject createRenderObject(BuildContext context) { return _RenderMenuItem(onLayout); } @override void updateRenderObject( BuildContext context, covariant _RenderMenuItem renderObject, ) { renderObject.onLayout = onLayout; } } class _RenderMenuItem extends RenderShiftedBox { _RenderMenuItem(this.onLayout, [RenderBox? child]) : super(child); ValueChanged onLayout; @override Size computeDryLayout(BoxConstraints constraints) { return child?.getDryLayout(constraints) ?? Size.zero; } @override void performLayout() { if (child == null) { size = Size.zero; } else { child!.layout(constraints, parentUsesSize: true); size = constraints.constrain(child!.size); final BoxParentData childParentData = child!.parentData! as BoxParentData; childParentData.offset = Offset.zero; } onLayout(size); } } /// An item in a Material Design popup menu. /// /// To show a popup menu, use the [showMenu] function. To create a button that /// shows a popup menu, consider using [PopupMenuButton]. /// /// To show a checkmark next to a popup menu item, consider using /// [CheckedPopupMenuItem]. /// /// Typically the [child] of a [PopupMenuItem] is a [Text] widget. More /// elaborate menus with icons can use a [ListTile]. By default, a /// [PopupMenuItem] is [kMinInteractiveDimension] pixels high. If you use a widget /// with a different height, it must be specified in the [height] property. /// /// {@tool snippet} /// /// Here, a [Text] widget is used with a popup menu item. The `Menu` type /// is an enum, not shown here. /// /// ```dart /// const PopupMenuItem( /// value: Menu.itemOne, /// child: Text('Item 1'), /// ) /// ``` /// {@end-tool} /// /// See the example at [PopupMenuButton] for how this example could be used in a /// complete menu, and see the example at [CheckedPopupMenuItem] for one way to /// keep the text of [PopupMenuItem]s that use [Text] widgets in their [child] /// slot aligned with the text of [CheckedPopupMenuItem]s or of [PopupMenuItem] /// that use a [ListTile] in their [child] slot. /// /// See also: /// /// * [PopupMenuDivider], which can be used to divide items from each other. /// * [CheckedPopupMenuItem], a variant of [PopupMenuItem] with a checkmark. /// * [showMenu], a method to dynamically show a popup menu at a given location. /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when /// it is tapped. class PopupMenuItem extends PopupMenuEntry { /// Creates an item for a popup menu. /// /// By default, the item is [enabled]. const PopupMenuItem({ super.key, this.value, this.onTap, this.enabled = true, this.height = kMinInteractiveDimension, this.padding, this.textStyle, this.labelTextStyle, this.mouseCursor, required this.child, }); /// The value that will be returned by [showMenu] if this entry is selected. final T? value; /// Called when the menu item is tapped. final VoidCallback? onTap; /// Whether the user is permitted to select this item. /// /// Defaults to true. If this is false, then the item will not react to /// touches. final bool enabled; /// The minimum height of the menu item. /// /// Defaults to [kMinInteractiveDimension] pixels. @override final double height; /// The padding of the menu item. /// /// The [height] property may interact with the applied padding. For example, /// If a [height] greater than the height of the sum of the padding and [child] /// is provided, then the padding's effect will not be visible. /// /// If this is null and [ThemeData.useMaterial3] is true, the horizontal padding /// defaults to 12.0 on both sides. /// /// If this is null and [ThemeData.useMaterial3] is false, the horizontal padding /// defaults to 16.0 on both sides. final EdgeInsets? padding; /// The text style of the popup menu item. /// /// If this property is null, then [PopupMenuThemeData.textStyle] is used. /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.titleMedium] /// of [ThemeData.textTheme] is used. final TextStyle? textStyle; /// The label style of the popup menu item. /// /// When [ThemeData.useMaterial3] is true, this styles the text of the popup menu item. /// /// If this property is null, then [PopupMenuThemeData.labelTextStyle] is used. /// If [PopupMenuThemeData.labelTextStyle] is also null, then [TextTheme.labelLarge] /// is used with the [ColorScheme.onSurface] color when popup menu item is enabled and /// the [ColorScheme.onSurface] color with 0.38 opacity when the popup menu item is disabled. final WidgetStateProperty? labelTextStyle; /// {@template flutter.material.popupmenu.mouseCursor} /// The cursor for a mouse pointer when it enters or is hovering over the /// widget. /// /// If [mouseCursor] is a [MaterialStateProperty], /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: /// /// * [WidgetState.hovered]. /// * [WidgetState.focused]. /// * [WidgetState.disabled]. /// {@endtemplate} /// /// If null, then the value of [PopupMenuThemeData.mouseCursor] is used. If /// that is also null, then [WidgetStateMouseCursor.clickable] is used. final MouseCursor? mouseCursor; /// The widget below this widget in the tree. /// /// Typically a single-line [ListTile] (for menus with icons) or a [Text]. An /// appropriate [DefaultTextStyle] is put in scope for the child. In either /// case, the text should be short enough that it won't wrap. final Widget? child; @override bool represents(T? value) => value == this.value; @override PopupMenuItemState> createState() => PopupMenuItemState>(); } /// The [State] for [PopupMenuItem] subclasses. /// /// By default this implements the basic styling and layout of Material Design /// popup menu items. /// /// The [buildChild] method can be overridden to adjust exactly what gets placed /// in the menu. By default it returns [PopupMenuItem.child]. /// /// The [handleTap] method can be overridden to adjust exactly what happens when /// the item is tapped. By default, it uses [Navigator.pop] to return the /// [PopupMenuItem.value] from the menu route. /// /// This class takes two type arguments. The second, `W`, is the exact type of /// the [Widget] that is using this [State]. It must be a subclass of /// [PopupMenuItem]. The first, `T`, must match the type argument of that widget /// class, and is the type of values returned from this menu. class PopupMenuItemState> extends State { /// The menu item contents. /// /// Used by the [build] method. /// /// By default, this returns [PopupMenuItem.child]. Override this to put /// something else in the menu entry. @protected Widget? buildChild() => widget.child; /// The handler for when the user selects the menu item. /// /// Used by the [InkWell] inserted by the [build] method. /// /// By default, uses [Navigator.pop] to return the [PopupMenuItem.value] from /// the menu route. @protected void handleTap() { // Need to pop the navigator first in case onTap may push new route onto navigator. Navigator.pop(context, widget.value); widget.onTap?.call(); } @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final PopupMenuThemeData defaults = theme.useMaterial3 ? _PopupMenuDefaultsM3(context) : _PopupMenuDefaultsM2(context); final Set states = { if (!widget.enabled) WidgetState.disabled, }; TextStyle style = theme.useMaterial3 ? (widget.labelTextStyle?.resolve(states) ?? popupMenuTheme.labelTextStyle?.resolve(states)! ?? defaults.labelTextStyle!.resolve(states)!) : (widget.textStyle ?? popupMenuTheme.textStyle ?? defaults.textStyle!); if (!widget.enabled && !theme.useMaterial3) { style = style.copyWith(color: theme.disabledColor); } Widget item = AnimatedDefaultTextStyle( style: style, duration: kThemeChangeDuration, child: Container( alignment: AlignmentDirectional.centerStart, constraints: BoxConstraints(minHeight: widget.height), padding: widget.padding ?? (theme.useMaterial3 ? _PopupMenuDefaultsM3.menuHorizontalPadding : _PopupMenuDefaultsM2.menuHorizontalPadding), child: buildChild(), ), ); if (!widget.enabled) { final bool isDark = theme.brightness == Brightness.dark; item = IconTheme.merge( data: IconThemeData(opacity: isDark ? 0.5 : 0.38), child: item, ); } return MergeSemantics( child: Semantics( enabled: widget.enabled, button: true, child: GestureDetector( onTap: widget.enabled ? handleTap : null, behavior: HitTestBehavior.opaque, child: ListTileTheme.merge( contentPadding: EdgeInsets.zero, titleTextStyle: style, child: item, ), ), ), ); } } /// An item with a checkmark in a Material Design popup menu. /// /// To show a popup menu, use the [showMenu] function. To create a button that /// shows a popup menu, consider using [PopupMenuButton]. /// /// A [CheckedPopupMenuItem] is kMinInteractiveDimension pixels high, which /// matches the default minimum height of a [PopupMenuItem]. The horizontal /// layout uses [ListTile]; the checkmark is an [Icons.done] icon, shown in the /// [ListTile.leading] position. /// /// {@tool snippet} /// /// Suppose a `Commands` enum exists that lists the possible commands from a /// particular popup menu, including `Commands.heroAndScholar` and /// `Commands.hurricaneCame`, and further suppose that there is a /// `_heroAndScholar` member field which is a boolean. The example below shows a /// menu with one menu item with a checkmark that can toggle the boolean, and /// one menu item without a checkmark for selecting the second option. (It also /// shows a divider placed between the two menu items.) /// /// ```dart /// PopupMenuButton( /// onSelected: (Commands result) { /// switch (result) { /// case Commands.heroAndScholar: /// setState(() { _heroAndScholar = !_heroAndScholar; }); /// case Commands.hurricaneCame: /// // ...handle hurricane option /// break; /// // ...other items handled here /// } /// }, /// itemBuilder: (BuildContext context) => >[ /// CheckedPopupMenuItem( /// checked: _heroAndScholar, /// value: Commands.heroAndScholar, /// child: const Text('Hero and scholar'), /// ), /// const PopupMenuDivider(), /// const PopupMenuItem( /// value: Commands.hurricaneCame, /// child: ListTile(leading: Icon(null), title: Text('Bring hurricane')), /// ), /// // ...other items listed here /// ], /// ) /// ``` /// {@end-tool} /// /// In particular, observe how the second menu item uses a [ListTile] with a /// blank [Icon] in the [ListTile.leading] position to get the same alignment as /// the item with the checkmark. /// /// See also: /// /// * [PopupMenuItem], a popup menu entry for picking a command (as opposed to /// toggling a value). /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. /// * [showMenu], a method to dynamically show a popup menu at a given location. /// * [PopupMenuButton], an [IconButton] that automatically shows a menu when /// it is tapped. class CheckedPopupMenuItem extends PopupMenuItem { /// Creates a popup menu item with a checkmark. /// /// By default, the menu item is [enabled] but unchecked. To mark the item as /// checked, set [checked] to true. const CheckedPopupMenuItem({ super.key, super.value, this.checked = false, super.enabled, super.padding, super.height, super.labelTextStyle, super.mouseCursor, super.child, super.onTap, }); /// Whether to display a checkmark next to the menu item. /// /// Defaults to false. /// /// When true, an [Icons.done] checkmark is displayed. /// /// When this popup menu item is selected, the checkmark will fade in or out /// as appropriate to represent the implied new state. final bool checked; /// The widget below this widget in the tree. /// /// Typically a [Text]. An appropriate [DefaultTextStyle] is put in scope for /// the child. The text should be short enough that it won't wrap. /// /// This widget is placed in the [ListTile.title] slot of a [ListTile] whose /// [ListTile.leading] slot is an [Icons.done] icon. @override Widget? get child => super.child; @override PopupMenuItemState> createState() => _CheckedPopupMenuItemState(); } class _CheckedPopupMenuItemState extends PopupMenuItemState> with SingleTickerProviderStateMixin { static const Duration _fadeDuration = Duration(milliseconds: 150); late AnimationController _controller; Animation get _opacity => _controller.view; @override void initState() { super.initState(); _controller = AnimationController(duration: _fadeDuration, vsync: this) ..value = widget.checked ? 1.0 : 0.0 ..addListener(_updateState); } // Called when animation changed void _updateState() => setState(() {}); @override void dispose() { _controller.removeListener(_updateState); _controller.dispose(); super.dispose(); } @override void handleTap() { // This fades the checkmark in or out when tapped. if (widget.checked) { _controller.reverse(); } else { _controller.forward(); } super.handleTap(); } @override Widget buildChild() { final ThemeData theme = Theme.of(context); final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final PopupMenuThemeData defaults = theme.useMaterial3 ? _PopupMenuDefaultsM3(context) : _PopupMenuDefaultsM2(context); final Set states = { if (widget.checked) WidgetState.selected, }; final WidgetStateProperty? effectiveLabelTextStyle = widget.labelTextStyle ?? popupMenuTheme.labelTextStyle ?? defaults.labelTextStyle; return IgnorePointer( child: ListTileTheme.merge( contentPadding: EdgeInsets.zero, child: ListTile( enabled: widget.enabled, titleTextStyle: effectiveLabelTextStyle?.resolve(states), leading: FadeTransition( opacity: _opacity, child: Icon(_controller.isDismissed ? null : Icons.done), ), title: widget.child, ), ), ); } } class _PopupMenu extends StatefulWidget { const _PopupMenu({ super.key, required this.itemKeys, required this.route, required this.semanticLabel, this.constraints, required this.clipBehavior, }); final List itemKeys; final _PopupMenuRoute route; final String? semanticLabel; final BoxConstraints? constraints; final Clip clipBehavior; @override State<_PopupMenu> createState() => _PopupMenuState(); } class _PopupMenuState extends State<_PopupMenu> { @override Widget build(BuildContext context) { final double unit = 1.0 / (widget.route.items.length + 1.5); // 1.0 for the width and 0.5 for the last item's fade. final List children = []; final ThemeData theme = Theme.of(context); final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final PopupMenuThemeData defaults = theme.useMaterial3 ? _PopupMenuDefaultsM3(context) : _PopupMenuDefaultsM2(context); for (int i = 0; i < widget.route.items.length; i += 1) { final double start = (i + 1) * unit; final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0); final CurvedAnimation opacity = CurvedAnimation( parent: widget.route.animation!, curve: Interval(start, end), ); Widget item = widget.route.items[i]; if (widget.route.initialValue != null && widget.route.items[i].represents(widget.route.initialValue)) { item = ColoredBox( color: Theme.of(context).highlightColor, child: item, ); } children.add( _MenuItem( onLayout: (Size size) { widget.route.itemSizes[i] = size; }, child: FadeTransition( key: widget.itemKeys[i], opacity: opacity, child: item, ), ), ); } final _CurveTween opacity = _CurveTween(curve: const Interval(0.0, 1.0 / 3.0)); final _CurveTween width = _CurveTween(curve: Interval(0.0, unit)); final _CurveTween height = _CurveTween(curve: Interval(0.0, unit * widget.route.items.length)); final Widget child = ConstrainedBox( constraints: widget.constraints ?? const BoxConstraints( minWidth: _kMenuMinWidth, maxWidth: _kMenuMaxWidth, ), child: IntrinsicWidth( stepWidth: _kMenuWidthStep, child: Semantics( scopesRoute: true, namesRoute: true, explicitChildNodes: true, label: widget.semanticLabel, child: SingleChildScrollView( padding: const EdgeInsets.symmetric( vertical: _kMenuVerticalPadding, ), child: ListBody(children: children), ), ), ), ); return AnimatedBuilder( animation: widget.route.animation!, builder: (BuildContext context, Widget? child) { return FadeTransition( opacity: opacity.animate(widget.route.animation!), child: Material( shape: widget.route.shape ?? popupMenuTheme.shape ?? defaults.shape, color: widget.route.color ?? popupMenuTheme.color ?? defaults.color, clipBehavior: widget.clipBehavior, type: MaterialType.card, elevation: widget.route.elevation ?? popupMenuTheme.elevation ?? defaults.elevation!, shadowColor: widget.route.shadowColor ?? popupMenuTheme.shadowColor ?? defaults.shadowColor, surfaceTintColor: widget.route.surfaceTintColor ?? popupMenuTheme.surfaceTintColor ?? defaults.surfaceTintColor, child: Align( alignment: AlignmentDirectional.topEnd, widthFactor: width.evaluate(widget.route.animation!), heightFactor: height.evaluate(widget.route.animation!), child: child, ), ), ); }, child: child, ); } @override void dispose() { _kPopupMenuKey = null; super.dispose(); } void dismiss() { if (_kPopupMenuKey == null) { return; } Navigator.of(context).pop(); _kPopupMenuKey = null; } } // Positioning of the menu on the screen. class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { _PopupMenuRouteLayout( this.position, this.itemSizes, this.selectedItemIndex, this.textDirection, this.padding, this.avoidBounds, ); // Rectangle of underlying button, relative to the overlay's dimensions. final RelativeRect position; // The sizes of each item are computed when the menu is laid out, and before // the route is laid out. List itemSizes; // The index of the selected item, or null if PopupMenuButton.initialValue // was not specified. final int? selectedItemIndex; // Whether to prefer going to the left or to the right. final TextDirection textDirection; // The padding of unsafe area. EdgeInsets padding; // List of rectangles that we should avoid overlapping. Unusable screen area. final Set avoidBounds; // We put the child wherever position specifies, so long as it will fit within // the specified parent size padded (inset) by 8. If necessary, we adjust the // child's position so that it fits. @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { // The menu can be at most the size of the overlay minus 8.0 pixels in each // direction. return BoxConstraints.loose(constraints.biggest).deflate( const EdgeInsets.all(_kMenuScreenPadding) + padding, ); } @override Offset getPositionForChild(Size size, Size childSize) { final double y = position.top; // Find the ideal horizontal position. // size: The size of the overlay. // childSize: The size of the menu, when fully open, as determined by // getConstraintsForChild. double x; if (position.left > position.right) { // Menu button is closer to the right edge, so grow to the left, aligned to the right edge. x = size.width - position.right - childSize.width; } else if (position.left < position.right) { // Menu button is closer to the left edge, so grow to the right, aligned to the left edge. x = position.left; } else { // Menu button is equidistant from both edges, so grow in reading direction. x = switch (textDirection) { TextDirection.rtl => size.width - position.right - childSize.width, TextDirection.ltr => position.left, }; } final Offset wantedPosition = Offset(x, y); final Offset originCenter = position.toRect(Offset.zero & size).center; final Iterable subScreens = DisplayFeatureSubScreen.subScreensInBounds( Offset.zero & size, avoidBounds, ); final Rect subScreen = _closestScreen(subScreens, originCenter); return _fitInsideScreen(subScreen, childSize, wantedPosition); } Rect _closestScreen(Iterable screens, Offset point) { Rect closest = screens.first; for (final Rect screen in screens) { if ((screen.center - point).distance < (closest.center - point).distance) { closest = screen; } } return closest; } Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition) { double x = wantedPosition.dx; double y = wantedPosition.dy; // Avoid going outside an area defined as the rectangle 8.0 pixels from the // edge of the screen in every direction. if (x < screen.left + _kMenuScreenPadding + padding.left) { x = screen.left + _kMenuScreenPadding + padding.left; } else if (x + childSize.width > screen.right - _kMenuScreenPadding - padding.right) { x = screen.right - childSize.width - _kMenuScreenPadding - padding.right; } if (y < screen.top + _kMenuScreenPadding + padding.top) { y = _kMenuScreenPadding + padding.top; } else if (y + childSize.height > screen.bottom - _kMenuScreenPadding - padding.bottom) { y = screen.bottom - childSize.height - _kMenuScreenPadding - padding.bottom; } return Offset(x, y); } @override bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) { // If called when the old and new itemSizes have been initialized then // we expect them to have the same length because there's no practical // way to change length of the items list once the menu has been shown. assert(itemSizes.length == oldDelegate.itemSizes.length); return position != oldDelegate.position || selectedItemIndex != oldDelegate.selectedItemIndex || textDirection != oldDelegate.textDirection || !listEquals(itemSizes, oldDelegate.itemSizes) || padding != oldDelegate.padding || !setEquals(avoidBounds, oldDelegate.avoidBounds); } } class _PopupMenuRoute extends PopupRoute { _PopupMenuRoute({ required this.position, required this.items, required this.itemKeys, this.initialValue, this.elevation, this.surfaceTintColor, this.shadowColor, required this.barrierLabel, this.semanticLabel, this.shape, this.color, required this.capturedThemes, this.constraints, required this.clipBehavior, super.settings, this.popUpAnimationStyle, }) : itemSizes = List.filled(items.length, null), // Menus always cycle focus through their items irrespective of the // focus traversal edge behavior set in the Navigator. super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop); final RelativeRect position; final List> items; final List itemKeys; final List itemSizes; final T? initialValue; final double? elevation; final Color? surfaceTintColor; final Color? shadowColor; final String? semanticLabel; final ShapeBorder? shape; final Color? color; final CapturedThemes capturedThemes; final BoxConstraints? constraints; final Clip clipBehavior; final AnimationStyle? popUpAnimationStyle; @override Animation createAnimation() { if (popUpAnimationStyle != AnimationStyle.noAnimation) { return CurvedAnimation( parent: super.createAnimation(), curve: popUpAnimationStyle?.curve ?? Curves.easeInBack, reverseCurve: popUpAnimationStyle?.reverseCurve ?? const Interval(0.0, _kMenuCloseIntervalEnd), ); } return super.createAnimation(); } void scrollTo(int selectedItemIndex) { SchedulerBinding.instance.addPostFrameCallback((_) { if (itemKeys[selectedItemIndex].currentContext != null) { Scrollable.ensureVisible(itemKeys[selectedItemIndex].currentContext!); } }); } @override Duration get transitionDuration => popUpAnimationStyle?.duration ?? _kMenuDuration; @override bool get barrierDismissible => true; @override Color? get barrierColor => null; @override final String barrierLabel; @override Widget buildTransitions( BuildContext context, Animation animation, Animation secondaryAnimation, Widget child, ) { if (!animation.isCompleted) { final screenWidth = MediaQuery.of(context).size.width; final screenHeight = MediaQuery.of(context).size.height; final size = position.toSize(Size(screenWidth, screenHeight)); final center = size.width / 2.0; final alignment = FractionalOffset( (screenWidth - position.right - center) / screenWidth, (screenHeight - position.bottom - center) / screenHeight, ); child = FadeTransition( opacity: animation, child: ScaleTransition( alignment: alignment, scale: animation, child: child, ), ); } return child; } @override Widget buildPage( BuildContext context, Animation animation, Animation secondaryAnimation, ) { int? selectedItemIndex; if (initialValue != null) { for (int index = 0; selectedItemIndex == null && index < items.length; index += 1) { if (items[index].represents(initialValue)) { selectedItemIndex = index; } } } if (selectedItemIndex != null) { scrollTo(selectedItemIndex); } _kPopupMenuKey ??= GlobalKey<_PopupMenuState>(); final Widget menu = _PopupMenu( key: _kPopupMenuKey, route: this, itemKeys: itemKeys, semanticLabel: semanticLabel, constraints: constraints, clipBehavior: clipBehavior, ); final MediaQueryData mediaQuery = MediaQuery.of(context); return MediaQuery.removePadding( context: context, removeTop: true, removeBottom: true, removeLeft: true, removeRight: true, child: Builder( builder: (BuildContext context) { return CustomSingleChildLayout( delegate: _PopupMenuRouteLayout( position, itemSizes, selectedItemIndex, Directionality.of(context), mediaQuery.padding, _avoidBounds(mediaQuery), ), child: capturedThemes.wrap(menu), ); }, ), ); } Set _avoidBounds(MediaQueryData mediaQuery) { return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(); } } /// Show a popup menu that contains the `items` at `position`. /// /// The `items` parameter must not be empty. /// /// If `initialValue` is specified then the first item with a matching value /// will be highlighted and the value of `position` gives the rectangle whose /// vertical center will be aligned with the vertical center of the highlighted /// item (when possible). /// /// If `initialValue` is not specified then the top of the menu will be aligned /// with the top of the `position` rectangle. /// /// In both cases, the menu position will be adjusted if necessary to fit on the /// screen. /// /// Horizontally, the menu is positioned so that it grows in the direction that /// has the most room. For example, if the `position` describes a rectangle on /// the left edge of the screen, then the left edge of the menu is aligned with /// the left edge of the `position`, and the menu grows to the right. If both /// edges of the `position` are equidistant from the opposite edge of the /// screen, then the ambient [Directionality] is used as a tie-breaker, /// preferring to grow in the reading direction. /// /// The positioning of the `initialValue` at the `position` is implemented by /// iterating over the `items` to find the first whose /// [PopupMenuEntry.represents] method returns true for `initialValue`, and then /// summing the values of [PopupMenuEntry.height] for all the preceding widgets /// in the list. /// /// The `elevation` argument specifies the z-coordinate at which to place the /// menu. The elevation defaults to 8, the appropriate elevation for popup /// menus. /// /// The `context` argument is used to look up the [Navigator] and [Theme] for /// the menu. It is only used when the method is called. Its corresponding /// widget can be safely removed from the tree before the popup menu is closed. /// /// The `useRootNavigator` argument is used to determine whether to push the /// menu to the [Navigator] furthest from or nearest to the given `context`. It /// is `false` by default. /// /// The `semanticLabel` argument is used by accessibility frameworks to /// announce screen transitions when the menu is opened and closed. If this /// label is not provided, it will default to /// [MaterialLocalizations.popupMenuLabel]. /// /// The `clipBehavior` argument is used to clip the shape of the menu. Defaults to /// [Clip.none]. /// /// See also: /// /// * [PopupMenuItem], a popup menu entry for a single value. /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. /// * [CheckedPopupMenuItem], a popup menu item with a checkmark. /// * [PopupMenuButton], which provides an [IconButton] that shows a menu by /// calling this method automatically. /// * [SemanticsConfiguration.namesRoute], for a description of edge triggered /// semantics. Future showMenu({ required BuildContext context, required RelativeRect position, required List> items, T? initialValue, double? elevation, Color? shadowColor, Color? surfaceTintColor, String? semanticLabel, ShapeBorder? shape, Color? color, bool useRootNavigator = false, BoxConstraints? constraints, Clip clipBehavior = Clip.none, RouteSettings? routeSettings, AnimationStyle? popUpAnimationStyle, }) { assert(items.isNotEmpty); assert(debugCheckHasMaterialLocalizations(context)); switch (Theme.of(context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: break; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel; } final List menuItemKeys = List.generate(items.length, (int index) => GlobalKey()); final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator); return navigator.push( _PopupMenuRoute( position: position, items: items, itemKeys: menuItemKeys, initialValue: initialValue, elevation: elevation, shadowColor: shadowColor, surfaceTintColor: surfaceTintColor, semanticLabel: semanticLabel, barrierLabel: MaterialLocalizations.of(context).menuDismissLabel, shape: shape, color: color, capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), constraints: constraints, clipBehavior: clipBehavior, settings: routeSettings, popUpAnimationStyle: popUpAnimationStyle, ), ); } /// Signature for the callback invoked when a menu item is selected. The /// argument is the value of the [PopupMenuItem] that caused its menu to be /// dismissed. /// /// Used by [PopupMenuButton.onSelected]. typedef PopupMenuItemSelected = void Function(T value); /// Signature for the callback invoked when a [PopupMenuButton] is dismissed /// without selecting an item. /// /// Used by [PopupMenuButton.onCanceled]. typedef PopupMenuCanceled = void Function(); /// Signature used by [PopupMenuButton] to lazily construct the items shown when /// the button is pressed. /// /// Used by [PopupMenuButton.itemBuilder]. typedef PopupMenuItemBuilder = List> Function( BuildContext context, ); /// Displays a menu when pressed and calls [onSelected] when the menu is dismissed /// because an item was selected. The value passed to [onSelected] is the value of /// the selected menu item. /// /// One of [child] or [icon] may be provided, but not both. If [icon] is provided, /// then [PopupMenuButton] behaves like an [IconButton]. /// /// If both are null, then a standard overflow icon is created (depending on the /// platform). /// /// ## Updating to [MenuAnchor] /// /// There is a Material 3 component, /// [MenuAnchor] that is preferred for applications that are configured /// for Material 3 (see [ThemeData.useMaterial3]). /// The [MenuAnchor] widget's visuals /// are a little bit different, see the Material 3 spec at /// for /// more details. /// /// The [MenuAnchor] widget's API is also slightly different. /// [MenuAnchor]'s were built to be lower level interface for /// creating menus that are displayed from an anchor. /// /// There are a few steps you would take to migrate from /// [PopupMenuButton] to [MenuAnchor]: /// /// 1. Instead of using the [PopupMenuButton.itemBuilder] to build /// a list of [PopupMenuEntry]s, you would use the [MenuAnchor.menuChildren] /// which takes a list of [Widget]s. Usually, you would use a list of /// [MenuItemButton]s as shown in the example below. /// /// 2. Instead of using the [PopupMenuButton.onSelected] callback, you would /// set individual callbacks for each of the [MenuItemButton]s using the /// [MenuItemButton.onPressed] property. /// /// 3. To anchor the [MenuAnchor] to a widget, you would use the [MenuAnchor.builder] /// to return the widget of choice - usually a [TextButton] or an [IconButton]. /// /// 4. You may want to style the [MenuItemButton]s, see the [MenuItemButton] /// documentation for details. /// /// Use the sample below for an example of migrating from [PopupMenuButton] to /// [MenuAnchor]. /// /// {@tool dartpad} /// This example shows a menu with three items, selecting between an enum's /// values and setting a `selectedMenu` field based on the selection. /// /// ** See code in examples/api/lib/material/popup_menu/popup_menu.0.dart ** /// {@end-tool} /// /// {@tool dartpad} /// This example shows how to migrate the above to a [MenuAnchor]. /// /// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.2.dart ** /// {@end-tool} /// /// {@tool dartpad} /// This sample shows the creation of a popup menu, as described in: /// https://m3.material.io/components/menus/overview /// /// ** See code in examples/api/lib/material/popup_menu/popup_menu.1.dart ** /// {@end-tool} /// /// {@tool dartpad} /// This sample showcases how to override the [PopupMenuButton] animation /// curves and duration using [AnimationStyle]. /// /// ** See code in examples/api/lib/material/popup_menu/popup_menu.2.dart ** /// {@end-tool} /// /// See also: /// /// * [PopupMenuItem], a popup menu entry for a single value. /// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. /// * [CheckedPopupMenuItem], a popup menu item with a checkmark. /// * [showMenu], a method to dynamically show a popup menu at a given location. class PopupMenuButton extends StatefulWidget { /// Creates a button that shows a popup menu. const PopupMenuButton({ super.key, required this.itemBuilder, this.initialValue, this.onOpened, this.onSelected, this.onCanceled, this.tooltip, this.elevation, this.shadowColor, this.surfaceTintColor, this.padding = const EdgeInsets.all(8.0), this.child, this.splashRadius, this.icon, this.iconSize, this.offset = Offset.zero, this.enabled = true, this.shape, this.color, this.iconColor, this.enableFeedback, this.constraints, this.position, this.clipBehavior = Clip.none, this.useRootNavigator = false, this.popUpAnimationStyle, this.routeSettings, this.style, }) : assert( !(child != null && icon != null), 'You can only pass [child] or [icon], not both.', ); /// Called when the button is pressed to create the items to show in the menu. final PopupMenuItemBuilder itemBuilder; /// The value of the menu item, if any, that should be highlighted when the menu opens. final T? initialValue; /// Called when the popup menu is shown. final VoidCallback? onOpened; /// Called when the user selects a value from the popup menu created by this button. /// /// If the popup menu is dismissed without selecting a value, [onCanceled] is /// called instead. final PopupMenuItemSelected? onSelected; /// Called when the user dismisses the popup menu without selecting an item. /// /// If the user selects a value, [onSelected] is called instead. final PopupMenuCanceled? onCanceled; /// Text that describes the action that will occur when the button is pressed. /// /// This text is displayed when the user long-presses on the button and is /// used for accessibility. final String? tooltip; /// The z-coordinate at which to place the menu when open. This controls the /// size of the shadow below the menu. /// /// Defaults to 8, the appropriate elevation for popup menus. final double? elevation; /// The color used to paint the shadow below the menu. /// /// If null then the ambient [PopupMenuThemeData.shadowColor] is used. /// If that is null too, then the overall theme's [ThemeData.shadowColor] /// (default black) is used. final Color? shadowColor; /// The color used as an overlay on [color] to indicate elevation. /// /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], /// which provide more flexibility. The intention is to eventually remove surface tint color from /// the framework. /// /// If null, [PopupMenuThemeData.surfaceTintColor] is used. If that /// is also null, the default value is [Colors.transparent]. /// /// See [Material.surfaceTintColor] for more details on how this /// overlay is applied. final Color? surfaceTintColor; /// Matches IconButton's 8 dps padding by default. In some cases, notably where /// this button appears as the trailing element of a list item, it's useful to be able /// to set the padding to zero. final EdgeInsetsGeometry padding; /// The splash radius. /// /// If null, default splash radius of [InkWell] or [IconButton] is used. final double? splashRadius; /// If provided, [child] is the widget used for this button /// and the button will utilize an [InkWell] for taps. final Widget? child; /// If provided, the [icon] is used for this button /// and the button will behave like an [IconButton]. final Widget? icon; /// The offset is applied relative to the initial position /// set by the [position]. /// /// When not set, the offset defaults to [Offset.zero]. final Offset offset; /// Whether this popup menu button is interactive. /// /// Defaults to true. /// /// If true, the button will respond to presses by displaying the menu. /// /// If false, the button is styled with the disabled color from the /// current [Theme] and will not respond to presses or show the popup /// menu and [onSelected], [onCanceled] and [itemBuilder] will not be called. /// /// This can be useful in situations where the app needs to show the button, /// but doesn't currently have anything to show in the menu. final bool enabled; /// If provided, the shape used for the menu. /// /// If this property is null, then [PopupMenuThemeData.shape] is used. /// If [PopupMenuThemeData.shape] is also null, then the default shape for /// [MaterialType.card] is used. This default shape is a rectangle with /// rounded edges of BorderRadius.circular(2.0). final ShapeBorder? shape; /// If provided, the background color used for the menu. /// /// If this property is null, then [PopupMenuThemeData.color] is used. /// If [PopupMenuThemeData.color] is also null, then /// [ThemeData.cardColor] is used in Material 2. In Material3, defaults to /// [ColorScheme.surfaceContainer]. final Color? color; /// If provided, this color is used for the button icon. /// /// If this property is null, then [PopupMenuThemeData.iconColor] is used. /// If [PopupMenuThemeData.iconColor] is also null then defaults to /// [IconThemeData.color]. final Color? iconColor; /// Whether detected gestures should provide acoustic and/or haptic feedback. /// /// For example, on Android a tap will produce a clicking sound and a /// long-press will produce a short vibration, when feedback is enabled. /// /// See also: /// /// * [Feedback] for providing platform-specific feedback to certain actions. final bool? enableFeedback; /// If provided, the size of the [Icon]. /// /// If this property is null, then [IconThemeData.size] is used. /// If [IconThemeData.size] is also null, then /// default size is 24.0 pixels. final double? iconSize; /// Optional size constraints for the menu. /// /// When unspecified, defaults to: /// ```dart /// const BoxConstraints( /// minWidth: 2.0 * 56.0, /// maxWidth: 5.0 * 56.0, /// ) /// ``` /// /// The default constraints ensure that the menu width matches maximum width /// recommended by the Material Design guidelines. /// Specifying this parameter enables creation of menu wider than /// the default maximum width. final BoxConstraints? constraints; /// Whether the popup menu is positioned over or under the popup menu button. /// /// [offset] is used to change the position of the popup menu relative to the /// position set by this parameter. /// /// If this property is `null`, then [PopupMenuThemeData.position] is used. If /// [PopupMenuThemeData.position] is also `null`, then the position defaults /// to [PopupMenuPosition.over] which makes the popup menu appear directly /// over the button that was used to create it. final PopupMenuPosition? position; /// {@macro flutter.material.Material.clipBehavior} /// /// The [clipBehavior] argument is used the clip shape of the menu. /// /// Defaults to [Clip.none]. final Clip clipBehavior; /// Used to determine whether to push the menu to the [Navigator] furthest /// from or nearest to the given `context`. /// /// Defaults to false. final bool useRootNavigator; /// Used to override the default animation curves and durations of the popup /// menu's open and close transitions. /// /// If [AnimationStyle.curve] is provided, it will be used to override /// the default popup animation curve. Otherwise, defaults to [Curves.linear]. /// /// If [AnimationStyle.reverseCurve] is provided, it will be used to /// override the default popup animation reverse curve. Otherwise, defaults to /// `Interval(0.0, 2.0 / 3.0)`. /// /// If [AnimationStyle.duration] is provided, it will be used to override /// the default popup animation duration. Otherwise, defaults to 300ms. /// /// To disable the theme animation, use [AnimationStyle.noAnimation]. /// /// If this is null, then the default animation will be used. final AnimationStyle? popUpAnimationStyle; /// Optional route settings for the menu. /// /// See [RouteSettings] for details. final RouteSettings? routeSettings; /// Customizes this icon button's appearance. /// /// The [style] is only used for Material 3 [IconButton]s. If [ThemeData.useMaterial3] /// is set to true, [style] is preferred for icon button customization, and any /// parameters defined in [style] will override the same parameters in [IconButton]. /// /// Null by default. final ButtonStyle? style; @override PopupMenuButtonState createState() => PopupMenuButtonState(); } /// The [State] for a [PopupMenuButton]. /// /// See [showButtonMenu] for a way to programmatically open the popup menu /// of your button state. class PopupMenuButtonState extends State> { /// A method to show a popup menu with the items supplied to /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton]. /// /// By default, it is called when the user taps the button and [PopupMenuButton.enabled] /// is set to `true`. Moreover, you can open the button by calling the method manually. /// /// You would access your [PopupMenuButtonState] using a [GlobalKey] and /// show the menu of the button with `globalKey.currentState.showButtonMenu`. void showButtonMenu() { final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final RenderBox button = context.findRenderObject()! as RenderBox; final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; final PopupMenuPosition popupMenuPosition = widget.position ?? popupMenuTheme.position ?? PopupMenuPosition.over; late Offset offset; switch (popupMenuPosition) { case PopupMenuPosition.over: offset = widget.offset; case PopupMenuPosition.under: offset = Offset(0.0, button.size.height) + widget.offset; if (widget.child == null) { // Remove the padding of the icon button. offset -= Offset(0.0, widget.padding.vertical / 2); } } final RelativeRect position = RelativeRect.fromRect( Rect.fromPoints( button.localToGlobal(offset, ancestor: overlay), button.localToGlobal( button.size.bottomRight(Offset.zero) + offset, ancestor: overlay, ), ), Offset.zero & overlay.size, ); final List> items = widget.itemBuilder(context); // Only show the menu if there is something to show if (items.isNotEmpty) { var popUpAnimationStyle = widget.popUpAnimationStyle; if (popUpAnimationStyle == null && defaultTargetPlatform == TargetPlatform.iOS) { popUpAnimationStyle = AnimationStyle( curve: Curves.easeInOut, duration: const Duration(milliseconds: 300), ); } widget.onOpened?.call(); showMenu( context: context, elevation: widget.elevation ?? popupMenuTheme.elevation, shadowColor: widget.shadowColor ?? popupMenuTheme.shadowColor, surfaceTintColor: widget.surfaceTintColor ?? popupMenuTheme.surfaceTintColor, items: items, initialValue: widget.initialValue, position: position, shape: widget.shape ?? popupMenuTheme.shape, color: widget.color ?? popupMenuTheme.color, constraints: widget.constraints, clipBehavior: widget.clipBehavior, useRootNavigator: widget.useRootNavigator, popUpAnimationStyle: popUpAnimationStyle, routeSettings: widget.routeSettings, ).then((T? newValue) { if (!mounted) { return null; } if (newValue == null) { widget.onCanceled?.call(); return null; } widget.onSelected?.call(newValue); }); } } @override Widget build(BuildContext context) { final IconThemeData iconTheme = IconTheme.of(context); final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); final bool enableFeedback = widget.enableFeedback ?? PopupMenuTheme.of(context).enableFeedback ?? true; assert(debugCheckHasMaterialLocalizations(context)); if (widget.child != null) { return AnimatedGestureDetector( scaleFactor: 0.95, onTapUp: widget.enabled ? showButtonMenu : null, child: widget.child!, ); } return IconButton( icon: widget.icon ?? Icon(Icons.adaptive.more), padding: widget.padding, splashRadius: widget.splashRadius, iconSize: widget.iconSize ?? popupMenuTheme.iconSize ?? iconTheme.size, color: widget.iconColor ?? popupMenuTheme.iconColor ?? iconTheme.color, tooltip: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, onPressed: widget.enabled ? showButtonMenu : null, enableFeedback: enableFeedback, style: widget.style, ); } } class _PopupMenuDefaultsM2 extends PopupMenuThemeData { _PopupMenuDefaultsM2(this.context) : super(elevation: 8.0); final BuildContext context; late final ThemeData _theme = Theme.of(context); late final TextTheme _textTheme = _theme.textTheme; @override TextStyle? get textStyle => _textTheme.titleMedium; static EdgeInsets menuHorizontalPadding = const EdgeInsets.symmetric(horizontal: 16.0); } // BEGIN GENERATED TOKEN PROPERTIES - PopupMenu // Do not edit by hand. The code between the "BEGIN GENERATED" and // "END GENERATED" comments are generated from data in the Material // Design token database by the script: // dev/tools/gen_defaults/bin/gen_defaults.dart. class _PopupMenuDefaultsM3 extends PopupMenuThemeData { _PopupMenuDefaultsM3(this.context) : super(elevation: 3.0); final BuildContext context; late final ThemeData _theme = Theme.of(context); late final ColorScheme _colors = _theme.colorScheme; late final TextTheme _textTheme = _theme.textTheme; @override WidgetStateProperty? get labelTextStyle { return WidgetStateProperty.resolveWith((Set states) { final TextStyle style = _textTheme.labelLarge!; if (states.contains(WidgetState.disabled)) { return style.apply(color: _colors.onSurface.withValues(alpha: 0.38)); } return style.apply(color: _colors.onSurface); }); } @override Color? get color => _colors.surfaceContainer; @override Color? get shadowColor => _colors.shadow; @override Color? get surfaceTintColor => Colors.transparent; @override ShapeBorder? get shape => const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4.0)), ); // TODO(tahatesser): This is taken from https://m3.material.io/components/menus/specs // Update this when the token is available. static EdgeInsets menuHorizontalPadding = const EdgeInsets.symmetric(horizontal: 12.0); } // END GENERATED TOKEN PROPERTIES - PopupMenu extension PopupMenuColors on BuildContext { Color get popupMenuBackgroundColor { if (Theme.of(this).brightness == Brightness.light) { return Theme.of(this).colorScheme.surface; } return const Color(0xFF23262B); } } class _CurveTween extends Animatable { /// Creates a curve tween. _CurveTween({required this.curve}); /// The curve to use when transforming the value of the animation. Curve curve; @override double transform(double t) { return curve.transform(t.clamp(0, 1)); } @override String toString() => '${objectRuntimeType(this, 'CurveTween')}(curve: $curve)'; } ================================================ FILE: frontend/appflowy_flutter/lib/shared/settings/show_settings.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; final GlobalKey _settingsDialogKey = GlobalKey(); // show settings dialog with user profile for fully customized settings dialog void showSettingsDialog( BuildContext context, UserProfilePB userProfile, [ UserWorkspaceBloc? bloc, SettingsPage? initPage, ]) { AFFocusManager.of(context).notifyLoseFocus(); showDialog( context: context, builder: (dialogContext) => MultiBlocProvider( key: _settingsDialogKey, providers: [ BlocProvider.value( value: BlocProvider.of(dialogContext), ), BlocProvider.value(value: bloc ?? context.read()), ], child: SettingsDialog( userProfile, initPage: initPage, didLogout: () async { // Pop the dialog using the dialog context Navigator.of(dialogContext).pop(); await runAppFlowy(); }, dismissDialog: () { if (Navigator.of(dialogContext).canPop()) { return Navigator.of(dialogContext).pop(); } Log.warn("Can't pop dialog context"); }, restartApp: () async { // Pop the dialog using the dialog context Navigator.of(dialogContext).pop(); await runAppFlowy(); }, ), ), ); } // show settings dialog without user profile for simple settings dialog // only support // - language // - self-host // - support void showSimpleSettingsDialog(BuildContext context) { showDialog( context: context, builder: (dialogContext) => const SimpleSettingsDialog(), ); } ================================================ FILE: frontend/appflowy_flutter/lib/shared/text_field/text_filed_with_metric_lines.dart ================================================ import 'package:flutter/material.dart'; class TextFieldWithMetricLines extends StatefulWidget { const TextFieldWithMetricLines({ super.key, this.controller, this.focusNode, this.maxLines, this.style, this.decoration, this.onLineCountChange, this.enabled = true, }); final TextEditingController? controller; final FocusNode? focusNode; final int? maxLines; final TextStyle? style; final InputDecoration? decoration; final void Function(int count)? onLineCountChange; final bool enabled; @override State createState() => _TextFieldWithMetricLinesState(); } class _TextFieldWithMetricLinesState extends State { final key = GlobalKey(); late final controller = widget.controller ?? TextEditingController(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { updateDisplayedLineCount(context); }); } @override void dispose() { if (widget.controller == null) { // dispose the controller if it was created by this widget controller.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { return TextField( key: key, enabled: widget.enabled, controller: widget.controller, focusNode: widget.focusNode, maxLines: widget.maxLines, style: widget.style, decoration: widget.decoration, onChanged: (_) => updateDisplayedLineCount(context), ); } // calculate the number of lines that would be displayed in the text field void updateDisplayedLineCount(BuildContext context) { if (widget.onLineCountChange == null) { return; } final renderObject = key.currentContext?.findRenderObject(); if (renderObject == null || renderObject is! RenderBox) { return; } final size = renderObject.size; final text = controller.buildTextSpan( context: context, style: widget.style, withComposing: false, ); final textPainter = TextPainter( text: text, textDirection: Directionality.of(context), ); textPainter.layout(minWidth: size.width, maxWidth: size.width); final lines = textPainter.computeLineMetrics().length; widget.onLineCountChange?.call(lines); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/time_format.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:time/time.dart'; String formatTimestampWithContext( BuildContext context, { required int timestamp, String? prefix, }) { final now = DateTime.now(); final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp); final difference = now.difference(dateTime); final String date; final dateFormat = context.read().state.dateFormat; final timeFormat = context.read().state.timeFormat; if (difference.inMinutes < 1) { date = LocaleKeys.sideBar_justNow.tr(); } else if (difference.inHours < 1 && dateTime.isToday) { // Less than 1 hour date = LocaleKeys.sideBar_minutesAgo .tr(namedArgs: {'count': difference.inMinutes.toString()}); } else if (difference.inHours >= 1 && dateTime.isToday) { // in same day date = timeFormat.formatTime(dateTime); } else { date = dateFormat.formatDate(dateTime, false); } if (difference.inHours >= 1 && prefix != null) { return '$prefix $date'; } return date; } ================================================ FILE: frontend/appflowy_flutter/lib/shared/version_checker/version_checker.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy_backend/log.dart'; import 'package:auto_updater/auto_updater.dart'; import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import 'package:universal_platform/universal_platform.dart'; import 'package:xml/xml.dart' as xml; final versionChecker = VersionChecker(); /// Version checker class to handle update checks using appcast XML feeds class VersionChecker { factory VersionChecker() => _instance; VersionChecker._internal(); String? _feedUrl; static final VersionChecker _instance = VersionChecker._internal(); /// Sets the appcast XML feed URL void setFeedUrl(String url) { _feedUrl = url; if (UniversalPlatform.isWindows || UniversalPlatform.isMacOS) { autoUpdater.setFeedURL(url); // disable the auto update check autoUpdater.setScheduledCheckInterval(0); } } /// Checks for updates by fetching and parsing the appcast XML /// Returns a list of [AppcastItem] or throws an exception if the feed URL is not set Future checkForUpdateInformation() async { if (_feedUrl == null) { Log.error('Feed URL is not set'); return null; } try { final response = await http.get(Uri.parse(_feedUrl!)); if (response.statusCode != 200) { Log.info('Failed to fetch appcast XML: ${response.statusCode}'); return null; } // Parse XML content final document = xml.XmlDocument.parse(response.body); final items = document.findAllElements('item'); // Convert XML items to AppcastItem objects return items .map(_parseAppcastItem) .nonNulls .firstWhereOrNull((e) => e.os == ApplicationInfo.os); } catch (e) { Log.info('Failed to check for updates: $e'); } return null; } /// For Windows and macOS, calling this API will trigger the auto updater to check for updates /// For Linux, it will open the official website in the browser if there is a new version Future checkForUpdate() async { if (UniversalPlatform.isLinux) { // open the official website in the browser await afLaunchUrlString('https://appflowy.com/download'); } else { await autoUpdater.checkForUpdates(); } } AppcastItem? _parseAppcastItem(xml.XmlElement item) { final enclosure = item.findElements('enclosure').firstOrNull; return AppcastItem.fromJson({ 'title': item.findElements('title').firstOrNull?.innerText, 'versionString': item .findElements('sparkle:shortVersionString') .firstOrNull ?.innerText, 'displayVersionString': item .findElements('sparkle:shortVersionString') .firstOrNull ?.innerText, 'releaseNotesUrl': item.findElements('releaseNotesLink').firstOrNull?.innerText, 'pubDate': item.findElements('pubDate').firstOrNull?.innerText, 'fileURL': enclosure?.getAttribute('url') ?? '', 'os': enclosure?.getAttribute('sparkle:os') ?? '', 'criticalUpdate': enclosure?.getAttribute('sparkle:criticalUpdate') ?? false, }); } } ================================================ FILE: frontend/appflowy_flutter/lib/shared/window_title_bar.dart ================================================ import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; import 'package:window_manager/window_manager.dart'; class WindowsButtonListener extends WindowListener { WindowsButtonListener(); final ValueNotifier isMaximized = ValueNotifier(false); @override void onWindowMaximize() => isMaximized.value = true; @override void onWindowUnmaximize() => isMaximized.value = false; void dispose() => isMaximized.dispose(); } class WindowTitleBar extends StatefulWidget { const WindowTitleBar({ super.key, this.leftChildren = const [], }); final List leftChildren; @override State createState() => _WindowTitleBarState(); } class _WindowTitleBarState extends State { late final WindowsButtonListener? windowsButtonListener; bool isMaximized = false; @override void initState() { super.initState(); if (UniversalPlatform.isWindows || UniversalPlatform.isLinux) { windowsButtonListener = WindowsButtonListener(); windowManager.addListener(windowsButtonListener!); windowsButtonListener!.isMaximized.addListener(_isMaximizedChanged); } else { windowsButtonListener = null; } windowManager .isMaximized() .then((v) => mounted ? setState(() => isMaximized = v) : null); } void _isMaximizedChanged() { if (mounted) { setState(() => isMaximized = windowsButtonListener!.isMaximized.value); } } @override void dispose() { if (windowsButtonListener != null) { windowManager.removeListener(windowsButtonListener!); windowsButtonListener!.isMaximized.removeListener(_isMaximizedChanged); windowsButtonListener?.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final brightness = Theme.of(context).brightness; return Container( height: 40, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, ), child: DragToMoveArea( child: Row( children: [ const HSpace(4), ...widget.leftChildren, const Spacer(), WindowCaptionButton.minimize( brightness: brightness, onPressed: () => windowManager.minimize(), ), if (isMaximized) ...[ WindowCaptionButton.unmaximize( brightness: brightness, onPressed: () => windowManager.unmaximize(), ), ] else ...[ WindowCaptionButton.maximize( brightness: brightness, onPressed: () => windowManager.maximize(), ), ], WindowCaptionButton.close( brightness: brightness, onPressed: () => windowManager.close(), ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/deps_resolver.dart ================================================ import 'package:appflowy/ai/service/appflowy_ai_service.dart'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/network_monitor.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/mobile/presentation/search/view_ancestor_cache.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/trash/application/prelude.dart'; import 'package:appflowy/shared/appflowy_cache_manager.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/shared/easy_localiation_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; import 'package:appflowy/user/application/auth/af_cloud_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/prelude.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/application/settings/appearance/desktop_appearance.dart'; import 'package:appflowy/workspace/application/settings/appearance/mobile_appearance.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/workspace/prelude.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:get_it/get_it.dart'; import 'package:universal_platform/universal_platform.dart'; class DependencyResolver { static Future resolve( GetIt getIt, IntegrationMode mode, ) async { // getIt.registerFactory(() => RustKeyValue()); getIt.registerFactory(() => DartKeyValue()); await _resolveCloudDeps(getIt); _resolveUserDeps(getIt, mode); _resolveHomeDeps(getIt); _resolveFolderDeps(getIt); _resolveCommonService(getIt, mode); } } Future _resolveCloudDeps(GetIt getIt) async { final env = await AppFlowyCloudSharedEnv.fromEnv(); Log.info("cloud setting: $env"); getIt.registerFactory(() => env); getIt.registerFactory(() => AppFlowyAIService()); if (isAppFlowyCloudEnabled) { getIt.registerSingleton( AppFlowyCloudDeepLink(), dispose: (obj) async { await obj.dispose(); }, ); } } void _resolveCommonService( GetIt getIt, IntegrationMode mode, ) async { getIt.registerFactory(() => FilePicker()); getIt.registerFactory( () => mode.isTest ? MockApplicationDataStorage() : ApplicationDataStorage(), ); getIt.registerFactory( () => ClipboardService(), ); // theme getIt.registerFactory( () => UniversalPlatform.isMobile ? MobileAppearance() : DesktopAppearance(), ); getIt.registerFactory( () => FlowyCacheManager() ..registerCache(TemporaryDirectoryCache()) ..registerCache(CustomImageCacheManager()) ..registerCache(FeatureFlagCache()), ); getIt.registerSingleton(EasyLocalizationService()); } void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { switch (currentCloudType()) { case AuthenticatorType.local: getIt.registerFactory( () => BackendAuthService( AuthTypePB.Local, ), ); break; case AuthenticatorType.appflowyCloud: case AuthenticatorType.appflowyCloudSelfHost: case AuthenticatorType.appflowyCloudDevelop: getIt.registerFactory(() => AppFlowyCloudAuthService()); break; } getIt.registerFactory(() => AuthRouter()); getIt.registerFactory( () => SignInBloc(getIt()), ); getIt.registerFactory( () => SignUpBloc(getIt()), ); getIt.registerFactory(() => SplashRouter()); getIt.registerFactory(() => EditPanelBloc()); getIt.registerFactory(() => SplashBloc()); getIt.registerLazySingleton(() => NetworkListener()); getIt.registerLazySingleton(() => CachedRecentService()); getIt.registerLazySingleton(() => ViewAncestorCache()); getIt.registerLazySingleton( () => SubscriptionSuccessListenable(), ); } void _resolveHomeDeps(GetIt getIt) { getIt.registerSingleton(FToast()); getIt.registerSingleton(MenuSharedState()); getIt.registerFactoryParam( (user, _) => UserListener(userProfile: user), ); // share getIt.registerFactoryParam( (view, _) => ShareBloc(view: view), ); getIt.registerSingleton(ActionNavigationBloc()); getIt.registerLazySingleton(() => TabsBloc()); getIt.registerSingleton(ReminderBloc()); getIt.registerSingleton(RenameViewBloc(PopoverController())); } void _resolveFolderDeps(GetIt getIt) { // Workspace getIt.registerFactoryParam( (user, workspaceId) => WorkspaceListener(user: user, workspaceId: workspaceId), ); getIt.registerFactoryParam( (view, _) => ViewBloc( view: view, ), ); // User getIt.registerFactoryParam( (user, _) => SettingsUserViewBloc(user), ); // Trash getIt.registerLazySingleton(() => TrashService()); getIt.registerLazySingleton(() => TrashListener()); getIt.registerFactory( () => TrashBloc(), ); // Favorite getIt.registerFactory(() => FavoriteBloc()); } ================================================ FILE: frontend/appflowy_flutter/lib/startup/entry_point.dart ================================================ import 'package:appflowy/startup/launch_configuration.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/presentation/screens/splash_screen.dart'; import 'package:flutter/material.dart'; class AppFlowyApplication implements EntryPoint { @override Widget create(LaunchConfiguration config) { return SplashScreen(isAnon: config.isAnon); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/launch_configuration.dart ================================================ class LaunchConfiguration { const LaunchConfiguration({ this.isAnon = false, required this.version, required this.rustEnvs, }); // APP will automatically register after launching. final bool isAnon; final String version; // final Map rustEnvs; } ================================================ FILE: frontend/appflowy_flutter/lib/startup/plugin/plugin.dart ================================================ library; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flutter/widgets.dart'; export "./src/sandbox.dart"; enum PluginType { document, blank, trash, grid, board, calendar, databaseDocument, chat, } typedef PluginId = String; abstract class Plugin { PluginId get id; PluginWidgetBuilder get widgetBuilder; PluginNotifier? get notifier => null; PluginType get pluginType; void init() {} void dispose() { notifier?.dispose(); } } abstract class PluginNotifier { /// Notify if the plugin get deleted ValueNotifier get isDeleted; void dispose() {} } abstract class PluginBuilder { Plugin build(dynamic data); String get menuName; FlowySvgData get icon; /// The type of this [Plugin]. Each [Plugin] should have a unique [PluginType] PluginType get pluginType; /// The layoutType is used in the backend to determine the layout of the view. /// Currently, AppFlowy supports 4 layout types: Document, Grid, Board, Calendar. ViewLayoutPB? get layoutType; } abstract class PluginConfig { // Return false will disable the user to create it. For example, a trash plugin shouldn't be created by the user, bool get creatable => true; } abstract class PluginWidgetBuilder with NavigationItem { List get navigationItems; EdgeInsets get contentPadding => const EdgeInsets.symmetric(horizontal: 40, vertical: 28); Widget buildWidget({ required PluginContext context, required bool shrinkWrap, Map? data, }); } class PluginContext { PluginContext({ this.userProfile, this.onDeleted, }); // calls when widget of the plugin get deleted final Function(ViewPB, int?)? onDeleted; final UserProfilePB? userProfile; } void registerPlugin({required PluginBuilder builder, PluginConfig? config}) { getIt() .registerPlugin(builder.pluginType, builder, config: config); } /// Make the correct plugin from the [pluginType] and [data]. If the plugin /// is not registered, it will return a blank plugin. Plugin makePlugin({required PluginType pluginType, dynamic data}) { final plugin = getIt().buildPlugin(pluginType, data); return plugin; } List pluginBuilders() { final pluginBuilders = getIt().builders; final pluginConfigs = getIt().pluginConfigs; return pluginBuilders.where( (builder) { final config = pluginConfigs[builder.pluginType]?.creatable; return config ?? true; }, ).toList(); } enum FlowyPluginException { invalidData, } ================================================ FILE: frontend/appflowy_flutter/lib/startup/plugin/src/runner.dart ================================================ class PluginRunner {} ================================================ FILE: frontend/appflowy_flutter/lib/startup/plugin/src/sandbox.dart ================================================ import 'dart:collection'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:flutter/services.dart'; import '../plugin.dart'; import 'runner.dart'; class PluginSandbox { PluginSandbox() { pluginRunner = PluginRunner(); } final LinkedHashMap _pluginBuilders = LinkedHashMap(); final Map _pluginConfigs = {}; late PluginRunner pluginRunner; int indexOf(PluginType pluginType) { final index = _pluginBuilders.keys.toList().indexWhere((ty) => ty == pluginType); if (index == -1) { throw PlatformException( code: '-1', message: "Can't find the flowy plugin type: $pluginType", ); } return index; } /// Build a plugin from [data] with [pluginType] /// If the [pluginType] is not registered, it will return a blank plugin Plugin buildPlugin(PluginType pluginType, dynamic data) { final builder = _pluginBuilders[pluginType] ?? BlankPluginBuilder(); return builder.build(data); } void registerPlugin( PluginType pluginType, PluginBuilder builder, { PluginConfig? config, }) { if (_pluginBuilders.containsKey(pluginType)) { return; } _pluginBuilders[pluginType] = builder; if (config != null) { _pluginConfigs[pluginType] = config; } } List get supportPluginTypes => _pluginBuilders.keys.toList(); List get builders => _pluginBuilders.values.toList(); Map get pluginConfigs => _pluginConfigs; } ================================================ FILE: frontend/appflowy_flutter/lib/startup/startup.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; import 'package:appflowy/util/expand_views.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:synchronized/synchronized.dart'; import 'deps_resolver.dart'; import 'entry_point.dart'; import 'launch_configuration.dart'; import 'plugin/plugin.dart'; import 'tasks/af_navigator_observer.dart'; import 'tasks/file_storage_task.dart'; import 'tasks/prelude.dart'; final getIt = GetIt.instance; abstract class EntryPoint { Widget create(LaunchConfiguration config); } class FlowyRunnerContext { FlowyRunnerContext({required this.applicationDataDirectory}); final Directory applicationDataDirectory; } Future runAppFlowy({bool isAnon = false}) async { Log.info('restart AppFlowy: isAnon: $isAnon'); if (kReleaseMode) { await FlowyRunner.run( AppFlowyApplication(), integrationMode(), isAnon: isAnon, ); } else { // When running the app in integration test mode, we need to // specify the mode to run the app again. await FlowyRunner.run( AppFlowyApplication(), FlowyRunner.currentMode, didInitGetItCallback: IntegrationTestHelper.didInitGetItCallback, rustEnvsBuilder: IntegrationTestHelper.rustEnvsBuilder, isAnon: isAnon, ); } } class FlowyRunner { // This variable specifies the initial mode of the app when it is launched for the first time. // The same mode will be automatically applied in subsequent executions when the runAppFlowy() // method is called. static var currentMode = integrationMode(); static Future run( EntryPoint f, IntegrationMode mode, { // This callback is triggered after the initialization of 'getIt', // which is used for dependency injection throughout the app. // If your functionality depends on 'getIt', ensure to register // your callback here to execute any necessary actions post-initialization. Future Function()? didInitGetItCallback, // Passing the envs to the backend Map Function()? rustEnvsBuilder, // Indicate whether the app is running in anonymous mode. // Note: when the app is running in anonymous mode, the user no need to // sign in, and the app will only save the data in the local storage. bool isAnon = false, }) async { currentMode = mode; // Only set the mode when it's not release mode if (!kReleaseMode) { IntegrationTestHelper.didInitGetItCallback = didInitGetItCallback; IntegrationTestHelper.rustEnvsBuilder = rustEnvsBuilder; } // Disable the log in test mode Log.shared.disableLog = mode.isTest; // Clear and dispose tasks from previous AppLaunch if (getIt.isRegistered(instance: AppLauncher)) { await getIt().dispose(); } // Clear all the states in case of rebuilding. await getIt.reset(); final config = LaunchConfiguration( isAnon: isAnon, // Unit test can't use the package_info_plus plugin version: mode.isUnitTest ? '1.0.0' : await PackageInfo.fromPlatform().then((value) => value.version), rustEnvs: rustEnvsBuilder?.call() ?? {}, ); // Specify the env await initGetIt(getIt, mode, f, config); await didInitGetItCallback?.call(); final applicationDataDirectory = await getIt().getPath().then( (value) => Directory(value), ); // add task final launcher = getIt(); launcher.addTasks( [ // this task should be first task, for handling platform errors. // don't catch errors in test mode if (!mode.isUnitTest && !mode.isIntegrationTest) const PlatformErrorCatcherTask(), // this task should be second task, for handling memory leak. // there's a flag named _enable in memory_leak_detector.dart. If it's false, the task will be ignored. MemoryLeakDetectorTask(), DebugTask(), const FeatureFlagTask(), // localization const InitLocalizationTask(), // init the app window InitAppWindowTask(), // Init Rust SDK InitRustSDKTask(customApplicationPath: applicationDataDirectory), // Load Plugins, like document, grid ... const PluginLoadTask(), const FileStorageTask(), // init the app widget // ignore in test mode if (!mode.isUnitTest) ...[ // The DeviceOrApplicationInfoTask should be placed before the AppWidgetTask to fetch the app information. // It is unable to get the device information from the test environment. const ApplicationInfoTask(), // The auto update task should be placed after the ApplicationInfoTask to fetch the latest version. if (!mode.isIntegrationTest) AutoUpdateTask(), const HotKeyTask(), if (isAppFlowyCloudEnabled) InitAppFlowyCloudTask(), const InitAppWidgetTask(), const InitPlatformServiceTask(), const RecentServiceTask(), ], ], ); await launcher.launch(); // execute the tasks return FlowyRunnerContext( applicationDataDirectory: applicationDataDirectory, ); } } Future initGetIt( GetIt getIt, IntegrationMode mode, EntryPoint f, LaunchConfiguration config, ) async { getIt.registerFactory(() => f); getIt.registerLazySingleton( () { return FlowySDK(); }, dispose: (sdk) async { await sdk.dispose(); }, ); getIt.registerLazySingleton( () => AppLauncher( context: LaunchContext( getIt, mode, config, ), ), dispose: (launcher) async { await launcher.dispose(); }, ); getIt.registerSingleton(PluginSandbox()); getIt.registerSingleton(ViewExpanderRegistry()); getIt.registerSingleton(LinkHoverTriggers()); getIt.registerSingleton(AFNavigatorObserver()); getIt.registerSingleton( FloatingToolbarController(), ); await DependencyResolver.resolve(getIt, mode); } class LaunchContext { LaunchContext(this.getIt, this.env, this.config); GetIt getIt; IntegrationMode env; LaunchConfiguration config; } enum LaunchTaskType { dataProcessing, appLauncher, } /// The interface of an app launch task, which will trigger /// some nonresident indispensable task in app launching task. class LaunchTask { const LaunchTask(); LaunchTaskType get type => LaunchTaskType.dataProcessing; @mustCallSuper Future initialize(LaunchContext context) async { Log.info('LaunchTask: $runtimeType initialize'); } @mustCallSuper Future dispose() async { Log.info('LaunchTask: $runtimeType dispose'); } } class AppLauncher { AppLauncher({ required this.context, }); final LaunchContext context; final List tasks = []; final lock = Lock(); void addTask(LaunchTask task) { lock.synchronized(() { Log.info('AppLauncher: adding task: $task'); tasks.add(task); }); } void addTasks(Iterable tasks) { lock.synchronized(() { Log.info('AppLauncher: adding tasks: ${tasks.map((e) => e.runtimeType)}'); this.tasks.addAll(tasks); }); } Future launch() async { await lock.synchronized(() async { final startTime = Stopwatch()..start(); Log.info('AppLauncher: start initializing tasks'); for (final task in tasks) { final startTaskTime = Stopwatch()..start(); await task.initialize(context); final endTaskTime = startTaskTime.elapsed.inMilliseconds; Log.info( 'AppLauncher: task ${task.runtimeType} initialized in $endTaskTime ms', ); } final endTime = startTime.elapsed.inMilliseconds; Log.info('AppLauncher: tasks initialized in $endTime ms'); }); } Future dispose() async { await lock.synchronized(() async { Log.info('AppLauncher: start clearing tasks'); for (final task in tasks) { await task.dispose(); } tasks.clear(); Log.info('AppLauncher: tasks cleared'); }); } } enum IntegrationMode { develop, release, unitTest, integrationTest; // test mode bool get isTest => isUnitTest || isIntegrationTest; bool get isUnitTest => this == IntegrationMode.unitTest; bool get isIntegrationTest => this == IntegrationMode.integrationTest; // release mode bool get isRelease => this == IntegrationMode.release; // develop mode bool get isDevelop => this == IntegrationMode.develop; } IntegrationMode integrationMode() { if (Platform.environment.containsKey('FLUTTER_TEST')) { return IntegrationMode.unitTest; } if (kReleaseMode) { return IntegrationMode.release; } return IntegrationMode.develop; } /// Only used for integration test class IntegrationTestHelper { static Future Function()? didInitGetItCallback; static Map Function()? rustEnvsBuilder; } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/af_navigator_observer.dart ================================================ import 'package:flutter/material.dart'; class AFNavigatorObserver extends NavigatorObserver { final Set> _listeners = {}; void addListener(ValueChanged listener) { _listeners.add(listener); } void removeListener(ValueChanged listener) { _listeners.remove(listener); } @override void didPush(Route route, Route? previousRoute) { for (final listener in Set.of(_listeners)) { listener(PushRouterInfo(newRoute: route, oldRoute: previousRoute)); } } @override void didPop(Route route, Route? previousRoute) { for (final listener in Set.of(_listeners)) { listener(PopRouterInfo(newRoute: route, oldRoute: previousRoute)); } } @override void didReplace({Route? newRoute, Route? oldRoute}) { for (final listener in Set.of(_listeners)) { listener(ReplaceRouterInfo(newRoute: newRoute, oldRoute: oldRoute)); } } } abstract class RouteInfo { RouteInfo({this.oldRoute, this.newRoute}); final Route? oldRoute; final Route? newRoute; } class PushRouterInfo extends RouteInfo { PushRouterInfo({super.newRoute, super.oldRoute}); } class PopRouterInfo extends RouteInfo { PopRouterInfo({super.newRoute, super.oldRoute}); } class ReplaceRouterInfo extends RouteInfo { ReplaceRouterInfo({super.newRoute, super.oldRoute}); } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart ================================================ import 'dart:io'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy/shared/easy_localiation_service.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/util/font_family_extension.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/notification/notification_service.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:toastification/toastification.dart'; import 'package:universal_platform/universal_platform.dart'; import 'prelude.dart'; class InitAppWidgetTask extends LaunchTask { const InitAppWidgetTask(); @override LaunchTaskType get type => LaunchTaskType.appLauncher; @override Future initialize(LaunchContext context) async { await super.initialize(context); WidgetsFlutterBinding.ensureInitialized(); await NotificationService.initialize(); await loadIconGroups(); final widget = context.getIt().create(context.config); final appearanceSetting = await UserSettingsBackendService().getAppearanceSetting(); final dateTimeSettings = await UserSettingsBackendService().getDateTimeSettings(); // If the passed-in context is not the same as the context of the // application widget, the application widget will be rebuilt. final app = ApplicationWidget( key: ValueKey(context), appearanceSetting: appearanceSetting, dateTimeSettings: dateTimeSettings, appTheme: await appTheme(appearanceSetting.theme), child: widget, ); runApp( EasyLocalization( supportedLocales: const [ // In alphabetical order Locale('am', 'ET'), Locale('ar', 'SA'), Locale('ca', 'ES'), Locale('cs', 'CZ'), Locale('ckb', 'KU'), Locale('de', 'DE'), Locale('en', 'US'), Locale('en', 'GB'), Locale('es', 'VE'), Locale('eu', 'ES'), Locale('el', 'GR'), Locale('fr', 'FR'), Locale('fr', 'CA'), Locale('he'), Locale('hu', 'HU'), Locale('id', 'ID'), Locale('it', 'IT'), Locale('ja', 'JP'), Locale('ko', 'KR'), Locale('pl', 'PL'), Locale('pt', 'BR'), Locale('ru', 'RU'), Locale('sv', 'SE'), Locale('th', 'TH'), Locale('tr', 'TR'), Locale('uk', 'UA'), Locale('ur'), Locale('vi', 'VN'), Locale('zh', 'CN'), Locale('zh', 'TW'), Locale('fa'), Locale('hin'), Locale('mr', 'IN'), ], path: 'assets/translations', fallbackLocale: const Locale('en', 'US'), useFallbackTranslations: true, child: Builder( builder: (context) { getIt.get().init(context); return app; }, ), ), ); return; } } class ApplicationWidget extends StatefulWidget { const ApplicationWidget({ super.key, required this.child, required this.appTheme, required this.appearanceSetting, required this.dateTimeSettings, }); final Widget child; final AppTheme appTheme; final AppearanceSettingsPB appearanceSetting; final DateTimeSettingsPB dateTimeSettings; @override State createState() => _ApplicationWidgetState(); } class _ApplicationWidgetState extends State { late final GoRouter routerConfig; final _commandPaletteNotifier = ValueNotifier(CommandPaletteNotifierValue()); final themeBuilder = AppFlowyDefaultTheme(); @override void initState() { super.initState(); // Avoid rebuild routerConfig when the appTheme is changed. routerConfig = generateRouter(widget.child); } @override void dispose() { _commandPaletteNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ if (FeatureFlag.search.isOn) BlocProvider(create: (_) => CommandPaletteBloc()), BlocProvider( create: (_) => AppearanceSettingsCubit( widget.appearanceSetting, widget.dateTimeSettings, widget.appTheme, )..readLocaleWhenAppLaunch(context), ), BlocProvider( create: (_) => NotificationSettingsCubit(), ), BlocProvider( create: (_) => DocumentAppearanceCubit()..fetch(), ), BlocProvider.value(value: getIt()), BlocProvider.value(value: getIt()), ], child: BlocListener( listenWhen: (_, curr) => curr.action != null, listener: (context, state) { final action = state.action; WidgetsBinding.instance.addPostFrameCallback((_) { if (action?.type == ActionType.openView && UniversalPlatform.isDesktop) { final view = action!.arguments?[ActionArgumentKeys.view] as ViewPB?; final nodePath = action.arguments?[ActionArgumentKeys.nodePath]; final blockId = action.arguments?[ActionArgumentKeys.blockId]; if (view != null) { getIt().openPlugin( view, arguments: { PluginArgumentKeys.selection: nodePath, PluginArgumentKeys.blockId: blockId, }, ); } } else if (action?.type == ActionType.openRow && UniversalPlatform.isMobile) { final view = action!.arguments?[ActionArgumentKeys.view]; if (view != null) { final view = action.arguments?[ActionArgumentKeys.view]; final rowId = action.arguments?[ActionArgumentKeys.rowId]; AppGlobals.rootNavKey.currentContext?.pushView( view, arguments: { PluginArgumentKeys.rowId: rowId, }, ); } } }); }, child: BlocBuilder( builder: (context, state) { _setSystemOverlayStyle(state); return Provider( create: (_) => ClipboardState(), dispose: (_, state) => state.dispose(), child: ToastificationWrapper( child: Listener( onPointerSignal: (pointerSignal) { /// This is a workaround to deal with below question: /// When the mouse hovers over the tooltip, the scroll event is intercepted by it /// Here, we listen for the scroll event and then remove the tooltip to avoid that situation if (pointerSignal is PointerScrollEvent) { Tooltip.dismissAllToolTips(); } }, child: MaterialApp.router( debugShowCheckedModeBanner: false, theme: state.lightTheme, darkTheme: state.darkTheme, themeMode: state.themeMode, localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, locale: state.locale, routerConfig: routerConfig, builder: (context, child) { final brightness = Theme.of(context).brightness; final fontFamily = state.font .orDefault(defaultFontFamily) .fontFamilyName; return AnimatedAppFlowyTheme( data: brightness == Brightness.light ? themeBuilder.light(fontFamily: fontFamily) : themeBuilder.dark(fontFamily: fontFamily), child: MediaQuery( // use the 1.0 as the textScaleFactor to avoid the text size // affected by the system setting. data: MediaQuery.of(context).copyWith( textScaler: TextScaler.linear(state.textScaleFactor), ), child: overlayManagerBuilder( context, !UniversalPlatform.isMobile && FeatureFlag.search.isOn ? CommandPalette( notifier: _commandPaletteNotifier, child: child, ) : child, ), ), ); }, ), ), ), ); }, ), ), ); } void _setSystemOverlayStyle(AppearanceSettingsState state) { if (Platform.isAndroid) { SystemChrome.setEnabledSystemUIMode( SystemUiMode.edgeToEdge, overlays: [], ); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( systemNavigationBarColor: Colors.transparent, ), ); } } } class AppGlobals { static GlobalKey rootNavKey = GlobalKey(); static NavigatorState get nav => rootNavKey.currentState!; static BuildContext get context => rootNavKey.currentContext!; } Future appTheme(String themeName) async { if (themeName.isEmpty) { return AppTheme.fallback; } else { try { return await AppTheme.fromName(themeName); } catch (e) { return AppTheme.fallback; } } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart ================================================ import 'dart:convert'; import 'dart:ui'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/startup/startup.dart'; class WindowSizeManager { static const double minWindowHeight = 640.0; static const double minWindowWidth = 640.0; // Preventing failed assertion due to Texture Descriptor Validation static const double maxWindowHeight = 8192.0; static const double maxWindowWidth = 8192.0; // Default windows size static const double defaultWindowHeight = 960.0; static const double defaultWindowWidth = 1280.0; static const double maxScaleFactor = 2.0; static const double minScaleFactor = 0.5; static const width = 'width'; static const height = 'height'; static const String dx = 'dx'; static const String dy = 'dy'; Future setSize(Size size) async { final windowSize = { height: size.height.clamp(minWindowHeight, maxWindowHeight), width: size.width.clamp(minWindowWidth, maxWindowWidth), }; await getIt().set( KVKeys.windowSize, jsonEncode(windowSize), ); } Future getSize() async { final defaultWindowSize = jsonEncode( { WindowSizeManager.height: defaultWindowHeight, WindowSizeManager.width: defaultWindowWidth, }, ); final windowSize = await getIt().get(KVKeys.windowSize); final size = json.decode( windowSize ?? defaultWindowSize, ); final double width = size[WindowSizeManager.width] ?? minWindowWidth; final double height = size[WindowSizeManager.height] ?? minWindowHeight; return Size( width.clamp(minWindowWidth, maxWindowWidth), height.clamp(minWindowHeight, maxWindowHeight), ); } Future setPosition(Offset offset) async { await getIt().set( KVKeys.windowPosition, jsonEncode({ dx: offset.dx, dy: offset.dy, }), ); } Future getPosition() async { final position = await getIt().get(KVKeys.windowPosition); if (position == null) { return null; } final offset = json.decode(position); return Offset(offset[dx], offset[dy]); } Future getScaleFactor() async { final scaleFactor = await getIt().getWithFormat( KVKeys.scaleFactor, (value) => double.tryParse(value) ?? 1.0, ) ?? 1.0; return scaleFactor.clamp(minScaleFactor, maxScaleFactor); } Future setScaleFactor(double scaleFactor) async { await getIt().set( KVKeys.scaleFactor, '${scaleFactor.clamp(minScaleFactor, maxScaleFactor)}', ); } /// Set the window maximized status Future setWindowMaximized(bool isMaximized) async { await getIt() .set(KVKeys.windowMaximized, isMaximized.toString()); } /// Get the window maximized status Future getWindowMaximized() async { return await getIt().getWithFormat( KVKeys.windowMaximized, (v) => bool.tryParse(v) ?? false, ) ?? false; } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:app_links/app_links.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/expire_login_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/login_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/open_app_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart'; import 'package:appflowy/user/application/auth/auth_error.dart'; import 'package:appflowy/user/application/user_auth_listener.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/material.dart'; import 'package:url_protocol/url_protocol.dart'; const appflowyDeepLinkSchema = 'appflowy-flutter'; class AppFlowyCloudDeepLink { AppFlowyCloudDeepLink() { _deepLinkHandlerRegistry = DeepLinkHandlerRegistry.instance ..register(LoginDeepLinkHandler()) ..register(PaymentDeepLinkHandler()) ..register(InvitationDeepLinkHandler()) ..register(ExpireLoginDeepLinkHandler()) ..register(OpenAppDeepLinkHandler()); _deepLinkSubscription = _AppLinkWrapper.instance.listen( (Uri? uri) async { Log.info('onDeepLink: ${uri.toString()}'); await _handleUri(uri); }, onError: (Object err, StackTrace stackTrace) { Log.error('on DeepLink stream error: ${err.toString()}', stackTrace); _deepLinkSubscription.cancel(); }, ); if (Platform.isWindows) { // register deep link for Windows registerProtocolHandler(appflowyDeepLinkSchema); } } ValueNotifier? _stateNotifier = ValueNotifier(null); Completer>? _completer; set completer(Completer>? value) { Log.debug('AppFlowyCloudDeepLink: $hashCode completer'); _completer = value; } late final StreamSubscription _deepLinkSubscription; late final DeepLinkHandlerRegistry _deepLinkHandlerRegistry; Future dispose() async { Log.debug('AppFlowyCloudDeepLink: $hashCode dispose'); await _deepLinkSubscription.cancel(); _stateNotifier?.dispose(); _stateNotifier = null; completer = null; } void registerCompleter( Completer> completer, ) { this.completer = completer; } VoidCallback subscribeDeepLinkLoadingState( ValueChanged listener, ) { void listenerFn() { if (_stateNotifier?.value != null) { listener(_stateNotifier!.value!); } } _stateNotifier?.addListener(listenerFn); return listenerFn; } void unsubscribeDeepLinkLoadingState(VoidCallback listener) => _stateNotifier?.removeListener(listener); Future passGotrueTokenResponse( GotrueTokenResponsePB gotrueTokenResponse, ) async { final uri = _buildDeepLinkUri(gotrueTokenResponse); await _handleUri(uri); } Future _handleUri( Uri? uri, ) async { _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.none); if (uri == null) { Log.error('onDeepLinkError: Unexpected empty deep link callback'); _completer?.complete(FlowyResult.failure(AuthError.emptyDeepLink)); completer = null; return; } await _deepLinkHandlerRegistry.processDeepLink( uri: uri, onStateChange: (handler, state) { // only handle the login deep link if (handler is LoginDeepLinkHandler) { _stateNotifier?.value = DeepLinkResult(state: state); } }, onResult: (handler, result) async { if (handler is LoginDeepLinkHandler && result is FlowyResult) { // If there is no completer, runAppFlowy() will be called. if (_completer == null) { await result.fold( (_) async { await runAppFlowy(); }, (err) { Log.error(err); final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { showToastNotification( message: err.msg, ); } }, ); } else { _completer?.complete(result); completer = null; } } else if (handler is ExpireLoginDeepLinkHandler) { result.onFailure( (error) { final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { showToastNotification( message: error.msg, type: ToastificationType.error, ); } }, ); } }, onError: (error) { Log.error('onDeepLinkError: Unexpected deep link: $error'); if (_completer == null) { final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { showToastNotification( message: error.msg, type: ToastificationType.error, ); } } else { _completer?.complete(FlowyResult.failure(error)); completer = null; } }, ); } Uri? _buildDeepLinkUri(GotrueTokenResponsePB gotrueTokenResponse) { final params = {}; if (gotrueTokenResponse.hasAccessToken() && gotrueTokenResponse.accessToken.isNotEmpty) { params['access_token'] = gotrueTokenResponse.accessToken; } if (gotrueTokenResponse.hasExpiresAt()) { params['expires_at'] = gotrueTokenResponse.expiresAt.toString(); } if (gotrueTokenResponse.hasExpiresIn()) { params['expires_in'] = gotrueTokenResponse.expiresIn.toString(); } if (gotrueTokenResponse.hasProviderRefreshToken() && gotrueTokenResponse.providerRefreshToken.isNotEmpty) { params['provider_refresh_token'] = gotrueTokenResponse.providerRefreshToken; } if (gotrueTokenResponse.hasProviderAccessToken() && gotrueTokenResponse.providerAccessToken.isNotEmpty) { params['provider_token'] = gotrueTokenResponse.providerAccessToken; } if (gotrueTokenResponse.hasRefreshToken() && gotrueTokenResponse.refreshToken.isNotEmpty) { params['refresh_token'] = gotrueTokenResponse.refreshToken; } if (gotrueTokenResponse.hasTokenType() && gotrueTokenResponse.tokenType.isNotEmpty) { params['token_type'] = gotrueTokenResponse.tokenType; } if (params.isEmpty) { return null; } final fragment = params.entries .map( (e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', ) .join('&'); return Uri.parse('appflowy-flutter://login-callback#$fragment'); } } class InitAppFlowyCloudTask extends LaunchTask { UserAuthStateListener? _authStateListener; bool isLoggingOut = false; @override Future initialize(LaunchContext context) async { await super.initialize(context); if (!isAppFlowyCloudEnabled) { return; } _authStateListener = UserAuthStateListener(); _authStateListener?.start( didSignIn: () { isLoggingOut = false; }, onInvalidAuth: (message) async { Log.error(message); if (!isLoggingOut) { await runAppFlowy(); } }, ); } @override Future dispose() async { await super.dispose(); await _authStateListener?.stop(); _authStateListener = null; } } // wrapper for AppLinks to support multiple listeners class _AppLinkWrapper { _AppLinkWrapper._() { _appLinkSubscription = _appLinks.uriLinkStream.listen((event) { _streamSubscription.sink.add(event); }); } static final _AppLinkWrapper instance = _AppLinkWrapper._(); final AppLinks _appLinks = AppLinks(); final _streamSubscription = StreamController.broadcast(); late final StreamSubscription _appLinkSubscription; StreamSubscription listen( void Function(Uri?) listener, { Function? onError, bool? cancelOnError, }) { return _streamSubscription.stream.listen( listener, onError: onError, cancelOnError: cancelOnError, ); } void dispose() { _streamSubscription.close(); _appLinkSubscription.cancel(); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/auto_update_task.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/version_checker/version_checker.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:auto_updater/auto_updater.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; import '../startup.dart'; class AutoUpdateTask extends LaunchTask { AutoUpdateTask(); static const _feedUrl = 'https://github.com/AppFlowy-IO/AppFlowy/releases/latest/download/appcast-{os}-{arch}.xml'; final _listener = _AppFlowyAutoUpdaterListener(); @override Future initialize(LaunchContext context) async { await super.initialize(context); // the auto updater is not supported on mobile if (UniversalPlatform.isMobile) { return; } // don't use await here, because the auto updater is not a blocking operation unawaited(_setupAutoUpdater()); ApplicationInfo.isCriticalUpdateNotifier.addListener( _showCriticalUpdateDialog, ); } @override Future dispose() async { await super.dispose(); autoUpdater.removeListener(_listener); ApplicationInfo.isCriticalUpdateNotifier.removeListener( _showCriticalUpdateDialog, ); } // On macOS and windows, we use auto_updater to check for updates. // On linux, we use the version checker to check for updates because the auto_updater is not supported. Future _setupAutoUpdater() async { Log.info( '[AutoUpdate] current version: ${ApplicationInfo.applicationVersion}, current cpu architecture: ${ApplicationInfo.architecture}', ); // Since the appcast.xml is not supported the arch, we separate the feed url by os and arch. final feedUrl = _feedUrl .replaceAll('{os}', ApplicationInfo.os) .replaceAll('{arch}', ApplicationInfo.architecture); // the auto updater is only supported on macOS and windows, so we don't need to check the platform if (UniversalPlatform.isMacOS || UniversalPlatform.isWindows) { autoUpdater.addListener(_listener); } Log.info('[AutoUpdate] feed url: $feedUrl'); versionChecker.setFeedUrl(feedUrl); final item = await versionChecker.checkForUpdateInformation(); if (item != null) { ApplicationInfo.latestAppcastItem = item; ApplicationInfo.latestVersionNotifier.value = item.displayVersionString ?? ''; } } void _showCriticalUpdateDialog() { showCustomConfirmDialog( context: AppGlobals.rootNavKey.currentContext!, title: LocaleKeys.autoUpdate_criticalUpdateTitle.tr(), description: LocaleKeys.autoUpdate_criticalUpdateDescription.tr( namedArgs: { 'currentVersion': ApplicationInfo.applicationVersion, 'newVersion': ApplicationInfo.latestVersion, }, ), builder: (context) => const SizedBox.shrink(), // if the update is critical, dont allow the user to dismiss the dialog barrierDismissible: false, showCloseButton: false, enableKeyboardListener: false, closeOnConfirm: false, confirmLabel: LocaleKeys.autoUpdate_criticalUpdateButton.tr(), onConfirm: () async { await versionChecker.checkForUpdate(); }, ); } } class _AppFlowyAutoUpdaterListener extends UpdaterListener { @override void onUpdaterBeforeQuitForUpdate(AppcastItem? item) {} @override void onUpdaterCheckingForUpdate(Appcast? appcast) { // Due to the reason documented in the following link, the update will not be found if the user has skipped the update. // We have to check the skipped version manually. // https://sparkle-project.org/documentation/api-reference/Classes/SPUUpdater.html#/c:objc(cs)SPUUpdater(im)checkForUpdateInformation final items = appcast?.items; if (items != null) { final String? currentPlatform; if (UniversalPlatform.isMacOS) { currentPlatform = 'macos'; } else if (UniversalPlatform.isWindows) { currentPlatform = 'windows'; } else { currentPlatform = null; } final matchingItem = items.firstWhereOrNull( (item) => item.os == currentPlatform, ); if (matchingItem != null) { _updateVersionNotifier(matchingItem); Log.info( '[AutoUpdate] latest version: ${matchingItem.displayVersionString}', ); } } } @override void onUpdaterError(UpdaterError? error) { Log.error('[AutoUpdate] On update error: $error'); } @override void onUpdaterUpdateNotAvailable(UpdaterError? error) { Log.info('[AutoUpdate] Update not available $error'); } @override void onUpdaterUpdateAvailable(AppcastItem? item) { _updateVersionNotifier(item); Log.info('[AutoUpdate] Update available: ${item?.displayVersionString}'); } @override void onUpdaterUpdateDownloaded(AppcastItem? item) { Log.info('[AutoUpdate] Update downloaded: ${item?.displayVersionString}'); } @override void onUpdaterUpdateCancelled(AppcastItem? item) { _updateVersionNotifier(item); Log.info('[AutoUpdate] Update cancelled: ${item?.displayVersionString}'); } @override void onUpdaterUpdateInstalled(AppcastItem? item) { _updateVersionNotifier(item); Log.info('[AutoUpdate] Update installed: ${item?.displayVersionString}'); } @override void onUpdaterUpdateSkipped(AppcastItem? item) { _updateVersionNotifier(item); Log.info('[AutoUpdate] Update skipped: ${item?.displayVersionString}'); } void _updateVersionNotifier(AppcastItem? item) { if (item != null) { ApplicationInfo.latestAppcastItem = item; ApplicationInfo.latestVersionNotifier.value = item.displayVersionString ?? ''; } } } class AppFlowyAutoUpdateVersion { AppFlowyAutoUpdateVersion({ required this.latestVersion, required this.currentVersion, required this.isForceUpdate, }); factory AppFlowyAutoUpdateVersion.initial() => AppFlowyAutoUpdateVersion( latestVersion: '0.0.0', currentVersion: '0.0.0', isForceUpdate: false, ); final String latestVersion; final String currentVersion; final bool isForceUpdate; bool get isUpdateAvailable => latestVersion != currentVersion; } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:talker/talker.dart'; import 'package:talker_bloc_logger/talker_bloc_logger.dart'; import 'package:universal_platform/universal_platform.dart'; class DebugTask extends LaunchTask { DebugTask(); final Talker talker = Talker(); @override Future initialize(LaunchContext context) async { await super.initialize(context); // hide the keyboard on mobile if (UniversalPlatform.isMobile && kDebugMode) { await SystemChannels.textInput.invokeMethod('TextInput.hide'); } // log the bloc events if (kDebugMode) { Bloc.observer = TalkerBlocObserver( talker: talker, settings: TalkerBlocLoggerSettings( enabled: false, printEventFullData: false, printStateFullData: false, printChanges: true, printClosings: true, printCreations: true, transitionFilter: (bloc, transition) { // By default, observe all transitions // You can add your own filter here if needed // when you want to observer a specific bloc return true; }, ), ); // enable rust request tracing // Dispatch.enableTracing = true; } } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/deeplink/deeplink_handler.dart ================================================ import 'dart:async'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef DeepLinkResultHandler = void Function( DeepLinkHandler handler, FlowyResult result, ); typedef DeepLinkStateHandler = void Function( DeepLinkHandler handler, DeepLinkState state, ); typedef DeepLinkErrorHandler = void Function( FlowyError error, ); abstract class DeepLinkHandler { /// Checks if this handler should handle the given URI bool canHandle(Uri uri); /// Handles the deep link URI Future> handle({ required Uri uri, required DeepLinkStateHandler onStateChange, }); } class DeepLinkHandlerRegistry { DeepLinkHandlerRegistry._(); static final instance = DeepLinkHandlerRegistry._(); final List _handlers = []; /// Register a new DeepLink handler void register(DeepLinkHandler handler) { _handlers.add(handler); } Future processDeepLink({ required Uri uri, required DeepLinkStateHandler onStateChange, required DeepLinkResultHandler onResult, required DeepLinkErrorHandler onError, }) async { Log.info('Processing DeepLink: ${uri.toString()}'); bool handled = false; for (final handler in _handlers) { if (handler.canHandle(uri)) { Log.info('Handler ${handler.runtimeType} will handle the DeepLink'); final result = await handler.handle( uri: uri, onStateChange: onStateChange, ); onResult(handler, result); handled = true; break; } } if (!handled) { Log.error('No handler found for DeepLink: ${uri.toString()}'); onError( FlowyError(msg: 'No handler found for DeepLink: ${uri.toString()}'), ); } } } class DeepLinkResult { DeepLinkResult({ required this.state, this.result, }); final DeepLinkState state; final FlowyResult? result; } enum DeepLinkState { none, loading, finish, error, } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/deeplink/expire_login_deeplink_handler.dart ================================================ import 'dart:async'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; /// Expire login deeplink example: /// appflowy-flutter:%23error=access_denied&error_code=403&error_description=Email+link+is+invalid+or+has+expired class ExpireLoginDeepLinkHandler extends DeepLinkHandler { @override bool canHandle(Uri uri) { final isExpireLogin = uri.toString().contains('error=access_denied'); if (!isExpireLogin) { return false; } return true; } @override Future> handle({ required Uri uri, required DeepLinkStateHandler onStateChange, }) async { return FlowyResult.failure( FlowyError( msg: 'Magic link is invalid or has expired', ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_handler.dart ================================================ import 'dart:async'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; // invitation callback deeplink example: // appflowy-flutter://invitation-callback?workspace_id=b2d11122-1fc8-474d-9ef1-ec12fea7ffe8&user_id=275966408418922496 class InvitationDeepLinkHandler extends DeepLinkHandler { static const invitationCallbackHost = 'invitation-callback'; static const invitationCallbackWorkspaceId = 'workspace_id'; static const invitationCallbackEmail = 'email'; @override bool canHandle(Uri uri) { final isInvitationCallback = uri.host == invitationCallbackHost; if (!isInvitationCallback) { return false; } final containsWorkspaceId = uri.queryParameters.containsKey(invitationCallbackWorkspaceId); if (!containsWorkspaceId) { return false; } final containsEmail = uri.queryParameters.containsKey(invitationCallbackEmail); if (!containsEmail) { return false; } return true; } @override Future> handle({ required Uri uri, required DeepLinkStateHandler onStateChange, }) async { final workspaceId = uri.queryParameters[invitationCallbackWorkspaceId]; final email = uri.queryParameters[invitationCallbackEmail]; if (workspaceId == null) { return FlowyResult.failure( FlowyError( msg: 'Workspace ID is required', ), ); } if (email == null) { return FlowyResult.failure( FlowyError( msg: 'Email is required', ), ); } openWorkspaceNotifier.value = WorkspaceNotifyValue( workspaceId: workspaceId, email: email, ); return FlowyResult.success(null); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/deeplink/login_deeplink_handler.dart ================================================ import 'dart:async'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/auth/device_id.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class LoginDeepLinkHandler extends DeepLinkHandler { @override bool canHandle(Uri uri) { final containsAccessToken = uri.fragment.contains('access_token'); if (!containsAccessToken) { return false; } return true; } @override Future> handle({ required Uri uri, required DeepLinkStateHandler onStateChange, }) async { final deviceId = await getDeviceId(); final payload = OauthSignInPB( authType: AuthTypePB.Server, map: { AuthServiceMapKeys.signInURL: uri.toString(), AuthServiceMapKeys.deviceId: deviceId, }, ); onStateChange(this, DeepLinkState.loading); final result = await UserEventOauthSignIn(payload).send(); onStateChange(this, DeepLinkState.finish); return result; } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/deeplink/open_app_deeplink_handler.dart ================================================ import 'dart:async'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class OpenAppDeepLinkHandler extends DeepLinkHandler { @override bool canHandle(Uri uri) { return uri.toString() == 'appflowy-flutter://'; } @override Future> handle({ required Uri uri, required DeepLinkStateHandler onStateChange, }) async { return FlowyResult.success(null); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/deeplink/payment_deeplink_handler.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class PaymentDeepLinkHandler extends DeepLinkHandler { @override bool canHandle(Uri uri) { return uri.host == 'payment-success'; } @override Future> handle({ required Uri uri, required DeepLinkStateHandler onStateChange, }) async { Log.debug("Payment success deep link: ${uri.toString()}"); final plan = uri.queryParameters['plan']; getIt().onPaymentSuccess(plan); return FlowyResult.success(null); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart ================================================ import 'dart:io'; import 'package:appflowy_backend/log.dart'; import 'package:auto_updater/auto_updater.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:version/version.dart'; import '../startup.dart'; class ApplicationInfo { static int androidSDKVersion = -1; static String applicationVersion = ''; static String buildNumber = ''; static String deviceId = ''; static String architecture = ''; static String os = ''; // macOS major version static int? macOSMajorVersion; static int? macOSMinorVersion; // latest version static ValueNotifier latestVersionNotifier = ValueNotifier(''); // the version number is like 0.9.0 static String get latestVersion => latestVersionNotifier.value; // If the latest version is greater than the current version, it means there is an update available static bool get isUpdateAvailable { try { if (latestVersion.isEmpty) { return false; } return Version.parse(latestVersion) > Version.parse(applicationVersion); } catch (e) { return false; } } // the latest appcast item static AppcastItem? _latestAppcastItem; static AppcastItem? get latestAppcastItem => _latestAppcastItem; static set latestAppcastItem(AppcastItem? value) { _latestAppcastItem = value; isCriticalUpdateNotifier.value = value?.criticalUpdate == true; } // is critical update static ValueNotifier isCriticalUpdateNotifier = ValueNotifier(false); static bool get isCriticalUpdate => isCriticalUpdateNotifier.value; } class ApplicationInfoTask extends LaunchTask { const ApplicationInfoTask(); @override Future initialize(LaunchContext context) async { await super.initialize(context); final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); final PackageInfo packageInfo = await PackageInfo.fromPlatform(); if (Platform.isMacOS) { final macInfo = await deviceInfoPlugin.macOsInfo; ApplicationInfo.macOSMajorVersion = macInfo.majorVersion; ApplicationInfo.macOSMinorVersion = macInfo.minorVersion; } if (Platform.isAndroid) { final androidInfo = await deviceInfoPlugin.androidInfo; ApplicationInfo.androidSDKVersion = androidInfo.version.sdkInt; } ApplicationInfo.applicationVersion = packageInfo.version; ApplicationInfo.buildNumber = packageInfo.buildNumber; String? deviceId; String? architecture; String? os; try { if (Platform.isAndroid) { final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo; deviceId = androidInfo.device; architecture = androidInfo.supportedAbis.firstOrNull; os = 'android'; } else if (Platform.isIOS) { final IosDeviceInfo iosInfo = await deviceInfoPlugin.iosInfo; deviceId = iosInfo.identifierForVendor; architecture = iosInfo.utsname.machine; os = 'ios'; } else if (Platform.isMacOS) { final MacOsDeviceInfo macInfo = await deviceInfoPlugin.macOsInfo; deviceId = macInfo.systemGUID; architecture = macInfo.arch; os = 'macos'; } else if (Platform.isWindows) { final WindowsDeviceInfo windowsInfo = await deviceInfoPlugin.windowsInfo; deviceId = windowsInfo.deviceId; // we only support x86_64 on Windows architecture = 'x86_64'; os = 'windows'; } else if (Platform.isLinux) { final LinuxDeviceInfo linuxInfo = await deviceInfoPlugin.linuxInfo; deviceId = linuxInfo.machineId; // we only support x86_64 on Linux architecture = 'x86_64'; os = 'linux'; } else { deviceId = null; architecture = null; os = null; } } catch (e) { Log.error('Failed to get platform version, $e'); } ApplicationInfo.deviceId = deviceId ?? ''; ApplicationInfo.architecture = architecture ?? ''; ApplicationInfo.os = os ?? ''; } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart ================================================ import 'package:appflowy/shared/feature_flags.dart'; import 'package:flutter/foundation.dart'; import '../startup.dart'; class FeatureFlagTask extends LaunchTask { const FeatureFlagTask(); @override Future initialize(LaunchContext context) async { await super.initialize(context); // the hotkey manager is not supported on mobile if (!kDebugMode) { return; } await FeatureFlag.initialize(); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; import 'dart:isolate'; import 'package:flutter/foundation.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-storage/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; import '../startup.dart'; class FileStorageTask extends LaunchTask { const FileStorageTask(); @override Future initialize(LaunchContext context) async { await super.initialize(context); context.getIt.registerSingleton( FileStorageService(), dispose: (service) async => service.dispose(), ); } } class FileStorageService { FileStorageService() { _port.handler = _controller.add; _subscription = _controller.stream.listen( (event) { final fileProgress = FileProgress.fromJsonString(event); if (fileProgress != null) { Log.debug( "FileStorageService upload file: ${fileProgress.fileUrl} ${fileProgress.progress}", ); final notifier = _notifierList[fileProgress.fileUrl]; if (notifier != null) { notifier.value = fileProgress; } } }, ); if (!integrationMode().isTest) { final payload = RegisterStreamPB() ..port = Int64(_port.sendPort.nativePort); FileStorageEventRegisterStream(payload).send(); } } final Map> _notifierList = {}; final RawReceivePort _port = RawReceivePort(); final StreamController _controller = StreamController.broadcast(); late StreamSubscription _subscription; AutoRemoveNotifier onFileProgress({required String fileUrl}) { _notifierList.remove(fileUrl)?.dispose(); final notifier = AutoRemoveNotifier( FileProgress(fileUrl: fileUrl, progress: 0), notifierList: _notifierList, fileId: fileUrl, ); _notifierList[fileUrl] = notifier; // trigger the initial file state getFileState(fileUrl); return notifier; } Future> getFileState(String url) { final payload = QueryFilePB()..url = url; return FileStorageEventQueryFile(payload).send(); } Future dispose() async { // dispose all notifiers for (final notifier in _notifierList.values) { notifier.dispose(); } await _controller.close(); await _subscription.cancel(); _port.close(); } } class FileProgress { FileProgress({ required this.fileUrl, required this.progress, this.error, }); static FileProgress? fromJson(Map? json) { if (json == null) { return null; } try { if (json.containsKey('file_url') && json.containsKey('progress')) { return FileProgress( fileUrl: json['file_url'] as String, progress: (json['progress'] as num).toDouble(), error: json['error'] as String?, ); } } catch (e) { Log.error('unable to parse file progress: $e'); } return null; } // Method to parse a JSON string and return a FileProgress object or null static FileProgress? fromJsonString(String jsonString) { try { final Map jsonMap = jsonDecode(jsonString); return FileProgress.fromJson(jsonMap); } catch (e) { return null; } } final double progress; final String fileUrl; final String? error; } class AutoRemoveNotifier extends ValueNotifier { AutoRemoveNotifier( super.value, { required this.fileId, required Map> notifierList, }) : _notifierList = notifierList; final String fileId; final Map> _notifierList; @override void dispose() { _notifierList.remove(fileId); super.dispose(); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart ================================================ import 'dart:convert'; import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart'; import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_create_field_screen.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_edit_field_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart'; import 'package:appflowy/mobile/presentation/favorite/mobile_favorite_page.dart'; import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart'; import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/search/mobile_search_page.dart'; import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart'; import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart'; import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart'; import 'package:appflowy/mobile/presentation/setting/workspace/add_members_screen.dart'; import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; import 'package:appflowy/plugins/base/color/color_picker_screen.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/presentation/presentation.dart'; import 'package:appflowy/workspace/presentation/home/desktop_home_screen.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flowy_infra/time/duration.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sheet/route.dart'; import 'package:universal_platform/universal_platform.dart'; import '../../shared/icon_emoji_picker/tab.dart'; import 'af_navigator_observer.dart'; GoRouter generateRouter(Widget child) { return GoRouter( navigatorKey: AppGlobals.rootNavKey, observers: [getIt.get()], initialLocation: '/', routes: [ // Root route is SplashScreen. // It needs LaunchConfiguration as a parameter, so we get it from ApplicationWidget's child. _rootRoute(child), // Routes in both desktop and mobile _signInScreenRoute(), _skipLogInScreenRoute(), _workspaceErrorScreenRoute(), // Desktop only if (UniversalPlatform.isDesktop) _desktopHomeScreenRoute(), // Mobile only if (UniversalPlatform.isMobile) ...[ // settings _mobileHomeSettingPageRoute(), _mobileCloudSettingAppFlowyCloudPageRoute(), _mobileLaunchSettingsPageRoute(), _mobileFeatureFlagPageRoute(), // view page _mobileEditorScreenRoute(), _mobileGridScreenRoute(), _mobileBoardScreenRoute(), _mobileCalendarScreenRoute(), _mobileChatScreenRoute(), // card detail page _mobileCardDetailScreenRoute(), _mobileDateCellEditScreenRoute(), _mobileNewPropertyPageRoute(), _mobileEditPropertyPageRoute(), // home // MobileHomeSettingPage is outside the bottom navigation bar, thus it is not in the StatefulShellRoute. _mobileHomeScreenWithNavigationBarRoute(), // trash _mobileHomeTrashPageRoute(), // emoji picker _mobileEmojiPickerPageRoute(), _mobileImagePickerPageRoute(), // color picker _mobileColorPickerPageRoute(), // code language picker _mobileCodeLanguagePickerPageRoute(), _mobileLanguagePickerPageRoute(), _mobileFontPickerPageRoute(), // calendar related _mobileCalendarEventsPageRoute(), _mobileBlockSettingsPageRoute(), // notifications _mobileNotificationMultiSelectPageRoute(), // invite members _mobileInviteMembersPageRoute(), _mobileAddMembersPageRoute(), ], // Desktop and Mobile GoRoute( path: WorkspaceStartScreen.routeName, pageBuilder: (context, state) { final args = state.extra as Map; return CustomTransitionPage( child: WorkspaceStartScreen( userProfile: args[WorkspaceStartScreen.argUserProfile], ), transitionsBuilder: _buildFadeTransition, transitionDuration: _slowDuration, ); }, ), ], ); } /// We use StatefulShellRoute to create a StatefulNavigationShell(ScaffoldWithNavBar) to access to multiple pages, and each page retains its own state. StatefulShellRoute _mobileHomeScreenWithNavigationBarRoute() { return StatefulShellRoute.indexedStack( builder: ( BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell, ) { // Return the widget that implements the custom shell (in this case // using a BottomNavigationBar). The StatefulNavigationShell is passed // to be able access the state of the shell and to navigate to other // branches in a stateful way. return MobileBottomNavigationBar(navigationShell: navigationShell); }, pageBuilder: (context, state, navigationShell) { String name = MobileHomeScreen.routeName; switch (navigationShell.currentIndex) { case 0: name = MobileHomeScreen.routeName; break; case 1: name = MobileSearchScreen.routeName; break; case 2: name = MobileFavoriteScreen.routeName; break; case 3: name = MobileNotificationsScreenV2.routeName; break; } return MaterialExtendedPage( child: MobileBottomNavigationBar(navigationShell: navigationShell), name: name, ); }, branches: [ StatefulShellBranch( routes: [ GoRoute( path: MobileHomeScreen.routeName, pageBuilder: (context, state) => MaterialExtendedPage( child: const MobileHomeScreen(), name: MobileHomeScreen.routeName, ), ), ], ), StatefulShellBranch( routes: [ GoRoute( name: MobileSearchScreen.routeName, path: MobileSearchScreen.routeName, pageBuilder: (context, state) => MaterialExtendedPage( child: const MobileSearchScreen(), name: MobileSearchScreen.routeName, ), ), ], ), StatefulShellBranch( routes: [ GoRoute( name: MobileFavoriteScreen.routeName, path: MobileFavoriteScreen.routeName, pageBuilder: (context, state) => MaterialExtendedPage( child: const MobileFavoriteScreen(), name: MobileFavoriteScreen.routeName, ), ), ], ), StatefulShellBranch( routes: [ GoRoute( name: MobileNotificationsScreenV2.routeName, path: MobileNotificationsScreenV2.routeName, pageBuilder: (context, state) => MaterialExtendedPage( child: const MobileNotificationsScreenV2(), name: MobileNotificationsScreenV2.routeName, ), ), ], ), ], ); } GoRoute _mobileHomeSettingPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileHomeSettingPage.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: MobileHomeSettingPage(), name: MobileHomeSettingPage.routeName, ); }, ); } GoRoute _mobileNotificationMultiSelectPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileNotificationsMultiSelectScreen.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: MobileNotificationsMultiSelectScreen(), name: MobileNotificationsMultiSelectScreen.routeName, ); }, ); } GoRoute _mobileInviteMembersPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: InviteMembersScreen.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: InviteMembersScreen(), name: InviteMembersScreen.routeName, ); }, ); } GoRoute _mobileAddMembersPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: AddMembersScreen.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: AddMembersScreen(), name: AddMembersScreen.routeName, ); }, ); } GoRoute _mobileCloudSettingAppFlowyCloudPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: AppFlowyCloudPage.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: AppFlowyCloudPage(), name: AppFlowyCloudPage.routeName, ); }, ); } GoRoute _mobileLaunchSettingsPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileLaunchSettingsPage.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: MobileLaunchSettingsPage(), name: MobileLaunchSettingsPage.routeName, ); }, ); } GoRoute _mobileFeatureFlagPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: FeatureFlagScreen.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: FeatureFlagScreen(), name: FeatureFlagScreen.routeName, ); }, ); } GoRoute _mobileHomeTrashPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileHomeTrashPage.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: MobileHomeTrashPage(), name: MobileHomeTrashPage.routeName, ); }, ); } GoRoute _mobileBlockSettingsPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileBlockSettingsScreen.routeName, pageBuilder: (context, state) { final actionsString = state.uri.queryParameters[MobileBlockSettingsScreen.supportedActions]; final actions = actionsString ?.split(',') .map(MobileBlockActionType.fromActionString) .toList(); return MaterialExtendedPage( child: MobileBlockSettingsScreen( actions: actions ?? MobileBlockActionType.standard, ), name: MobileBlockSettingsScreen.routeName, ); }, ); } GoRoute _mobileEmojiPickerPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileEmojiPickerScreen.routeName, pageBuilder: (context, state) { final title = state.uri.queryParameters[MobileEmojiPickerScreen.pageTitle]; final selectTabs = state.uri.queryParameters[MobileEmojiPickerScreen.selectTabs] ?? ''; final selectedType = state .uri.queryParameters[MobileEmojiPickerScreen.iconSelectedType] ?.toPickerTabType(); final documentId = state.uri.queryParameters[MobileEmojiPickerScreen.uploadDocumentId]; List tabs = []; try { tabs = selectTabs .split('-') .map((e) => PickerTabType.values.byName(e)) .toList(); } on ArgumentError catch (e) { Log.error('convert selectTabs to pickerTab error', e); } return MaterialExtendedPage( child: tabs.isEmpty ? MobileEmojiPickerScreen( title: title, selectedType: selectedType, documentId: documentId, ) : MobileEmojiPickerScreen( title: title, selectedType: selectedType, tabs: tabs, documentId: documentId, ), name: MobileEmojiPickerScreen.routeName, ); }, ); } GoRoute _mobileColorPickerPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileColorPickerScreen.routeName, pageBuilder: (context, state) { final title = state.uri.queryParameters[MobileColorPickerScreen.pageTitle] ?? ''; return MaterialExtendedPage( child: MobileColorPickerScreen(title: title), name: MobileColorPickerScreen.routeName, ); }, ); } GoRoute _mobileImagePickerPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileImagePickerScreen.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: MobileImagePickerScreen(), name: MobileImagePickerScreen.routeName, ); }, ); } GoRoute _mobileCodeLanguagePickerPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileCodeLanguagePickerScreen.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: MobileCodeLanguagePickerScreen(), name: MobileCodeLanguagePickerScreen.routeName, ); }, ); } GoRoute _mobileLanguagePickerPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: LanguagePickerScreen.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: LanguagePickerScreen(), name: LanguagePickerScreen.routeName, ); }, ); } GoRoute _mobileFontPickerPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: FontPickerScreen.routeName, pageBuilder: (context, state) { return const MaterialExtendedPage( child: FontPickerScreen(), name: FontPickerScreen.routeName, ); }, ); } GoRoute _mobileNewPropertyPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileNewPropertyScreen.routeName, pageBuilder: (context, state) { final viewId = state .uri.queryParameters[MobileNewPropertyScreen.argViewId] as String; final fieldTypeId = state.uri.queryParameters[MobileNewPropertyScreen.argFieldTypeId] ?? FieldType.RichText.value.toString(); final value = int.parse(fieldTypeId); return MaterialExtendedPage( fullscreenDialog: true, child: MobileNewPropertyScreen( viewId: viewId, fieldType: FieldType.valueOf(value), ), name: MobileNewPropertyScreen.routeName, ); }, ); } GoRoute _mobileEditPropertyPageRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileEditPropertyScreen.routeName, pageBuilder: (context, state) { final args = state.extra as Map; return MaterialExtendedPage( fullscreenDialog: true, child: MobileEditPropertyScreen( viewId: args[MobileEditPropertyScreen.argViewId], field: args[MobileEditPropertyScreen.argField], ), name: MobileEditPropertyScreen.routeName, ); }, ); } GoRoute _mobileCalendarEventsPageRoute() { return GoRoute( path: MobileCalendarEventsScreen.routeName, parentNavigatorKey: AppGlobals.rootNavKey, pageBuilder: (context, state) { final args = state.extra as Map; return MaterialExtendedPage( child: MobileCalendarEventsScreen( calendarBloc: args[MobileCalendarEventsScreen.calendarBlocKey], date: args[MobileCalendarEventsScreen.calendarDateKey], events: args[MobileCalendarEventsScreen.calendarEventsKey], rowCache: args[MobileCalendarEventsScreen.calendarRowCacheKey], viewId: args[MobileCalendarEventsScreen.calendarViewIdKey], ), name: MobileCalendarEventsScreen.routeName, ); }, ); } GoRoute _desktopHomeScreenRoute() { return GoRoute( path: DesktopHomeScreen.routeName, pageBuilder: (context, state) { return CustomTransitionPage( child: const DesktopHomeScreen(), transitionsBuilder: _buildFadeTransition, transitionDuration: _slowDuration, ); }, ); } GoRoute _workspaceErrorScreenRoute() { return GoRoute( path: WorkspaceErrorScreen.routeName, pageBuilder: (context, state) { final args = state.extra as Map; return CustomTransitionPage( child: WorkspaceErrorScreen( error: args[WorkspaceErrorScreen.argError], userFolder: args[WorkspaceErrorScreen.argUserFolder], ), transitionsBuilder: _buildFadeTransition, transitionDuration: _slowDuration, ); }, ); } GoRoute _skipLogInScreenRoute() { return GoRoute( path: SkipLogInScreen.routeName, pageBuilder: (context, state) { return CustomTransitionPage( child: const SkipLogInScreen(), transitionsBuilder: _buildFadeTransition, transitionDuration: _slowDuration, ); }, ); } GoRoute _signInScreenRoute() { return GoRoute( path: SignInScreen.routeName, pageBuilder: (context, state) { return CustomTransitionPage( child: const SignInScreen(), transitionsBuilder: _buildFadeTransition, transitionDuration: _slowDuration, ); }, ); } GoRoute _mobileEditorScreenRoute() { return GoRoute( path: MobileDocumentScreen.routeName, parentNavigatorKey: AppGlobals.rootNavKey, pageBuilder: (context, state) { final id = state.uri.queryParameters[MobileDocumentScreen.viewId]!; final title = state.uri.queryParameters[MobileDocumentScreen.viewTitle]; final showMoreButton = bool.tryParse( state.uri.queryParameters[MobileDocumentScreen.viewShowMoreButton] ?? 'true', ); final fixedTitle = state.uri.queryParameters[MobileDocumentScreen.viewFixedTitle]; final blockId = state.uri.queryParameters[MobileDocumentScreen.viewBlockId]; final selectTabs = state.uri.queryParameters[MobileDocumentScreen.viewSelectTabs] ?? ''; List tabs = []; try { tabs = selectTabs .split('-') .map((e) => PickerTabType.values.byName(e)) .toList(); } on ArgumentError catch (e) { Log.error('convert selectTabs to pickerTab error', e); } if (tabs.isEmpty) { tabs = const [PickerTabType.emoji, PickerTabType.icon]; } return MaterialExtendedPage( child: MobileDocumentScreen( id: id, title: title, showMoreButton: showMoreButton ?? true, fixedTitle: fixedTitle, blockId: blockId, tabs: tabs, ), name: MobileDocumentScreen.routeName, ); }, ); } GoRoute _mobileChatScreenRoute() { return GoRoute( path: MobileChatScreen.routeName, parentNavigatorKey: AppGlobals.rootNavKey, pageBuilder: (context, state) { final id = state.uri.queryParameters[MobileChatScreen.viewId]!; final title = state.uri.queryParameters[MobileChatScreen.viewTitle]; return MaterialExtendedPage( child: MobileChatScreen(id: id, title: title), ); }, ); } GoRoute _mobileGridScreenRoute() { return GoRoute( path: MobileGridScreen.routeName, parentNavigatorKey: AppGlobals.rootNavKey, pageBuilder: (context, state) { final id = state.uri.queryParameters[MobileGridScreen.viewId]!; final title = state.uri.queryParameters[MobileGridScreen.viewTitle]; final arguments = state.uri.queryParameters[MobileGridScreen.viewArgs]; return MaterialExtendedPage( child: MobileGridScreen( id: id, title: title, arguments: arguments != null ? jsonDecode(arguments) : null, ), ); }, ); } GoRoute _mobileBoardScreenRoute() { return GoRoute( path: MobileBoardScreen.routeName, parentNavigatorKey: AppGlobals.rootNavKey, pageBuilder: (context, state) { final id = state.uri.queryParameters[MobileBoardScreen.viewId]!; final title = state.uri.queryParameters[MobileBoardScreen.viewTitle]; return MaterialExtendedPage( child: MobileBoardScreen( id: id, title: title, ), ); }, ); } GoRoute _mobileCalendarScreenRoute() { return GoRoute( path: MobileCalendarScreen.routeName, parentNavigatorKey: AppGlobals.rootNavKey, pageBuilder: (context, state) { final id = state.uri.queryParameters[MobileCalendarScreen.viewId]!; final title = state.uri.queryParameters[MobileCalendarScreen.viewTitle]!; return MaterialExtendedPage( child: MobileCalendarScreen( id: id, title: title, ), ); }, ); } GoRoute _mobileCardDetailScreenRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileRowDetailPage.routeName, pageBuilder: (context, state) { var extra = state.extra as Map?; if (kDebugMode && extra == null) { extra = _dynamicValues; } if (extra == null) { return const MaterialExtendedPage( child: SizedBox.shrink(), ); } final databaseController = extra[MobileRowDetailPage.argDatabaseController]; final rowId = extra[MobileRowDetailPage.argRowId]!; if (kDebugMode) { _dynamicValues = extra; } return MaterialExtendedPage( child: MobileRowDetailPage( databaseController: databaseController, rowId: rowId, ), ); }, ); } GoRoute _mobileDateCellEditScreenRoute() { return GoRoute( parentNavigatorKey: AppGlobals.rootNavKey, path: MobileDateCellEditScreen.routeName, pageBuilder: (context, state) { final args = state.extra as Map; final controller = args[MobileDateCellEditScreen.dateCellController]; final fullScreen = args[MobileDateCellEditScreen.fullScreen]; return CustomTransitionPage( transitionsBuilder: (_, __, ___, child) => child, fullscreenDialog: true, opaque: false, barrierDismissible: true, barrierColor: Theme.of(context).bottomSheetTheme.modalBarrierColor, child: MobileDateCellEditScreen( controller: controller, showAsFullScreen: fullScreen ?? true, ), ); }, ); } GoRoute _rootRoute(Widget child) { return GoRoute( path: '/', redirect: (context, state) async { // Every time before navigating to splash screen, we check if user is already logged in desktop. It is used to skip showing splash screen when user just changes appearance settings like theme mode. final userResponse = await getIt().getUser(); final routeName = userResponse.fold( (user) => DesktopHomeScreen.routeName, (error) => null, ); if (routeName != null && !UniversalPlatform.isMobile) return routeName; return null; }, // Root route is SplashScreen. // It needs LaunchConfiguration as a parameter, so we get it from ApplicationWidget's child. pageBuilder: (context, state) => MaterialExtendedPage( child: child, ), ); } Widget _buildFadeTransition( BuildContext context, Animation animation, Animation secondaryAnimation, Widget child, ) => FadeTransition(opacity: animation, child: child); Duration _slowDuration = Duration( milliseconds: RouteDurations.slow.inMilliseconds.round(), ); // ONLY USE IN DEBUG MODE // this is a workaround for the issue of GoRouter not supporting extra with complex types // https://github.com/flutter/flutter/issues/137248 Map _dynamicValues = {}; ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/hot_key.dart ================================================ import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:universal_platform/universal_platform.dart'; import '../startup.dart'; class HotKeyTask extends LaunchTask { const HotKeyTask(); @override Future initialize(LaunchContext context) async { await super.initialize(context); // the hotkey manager is not supported on mobile if (UniversalPlatform.isMobile) { return; } await hotKeyManager.unregisterAll(); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart ================================================ import 'package:appflowy/plugins/ai_chat/chat.dart'; import 'package:appflowy/plugins/database/calendar/calendar.dart'; import 'package:appflowy/plugins/database/board/board.dart'; import 'package:appflowy/plugins/database/grid/grid.dart'; import 'package:appflowy/plugins/database_document/database_document_plugin.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/plugins/document/document.dart'; import 'package:appflowy/plugins/trash/trash.dart'; class PluginLoadTask extends LaunchTask { const PluginLoadTask(); @override LaunchTaskType get type => LaunchTaskType.dataProcessing; @override Future initialize(LaunchContext context) async { await super.initialize(context); registerPlugin(builder: BlankPluginBuilder(), config: BlankPluginConfig()); registerPlugin(builder: TrashPluginBuilder(), config: TrashPluginConfig()); registerPlugin(builder: DocumentPluginBuilder()); registerPlugin(builder: GridPluginBuilder(), config: GridPluginConfig()); registerPlugin(builder: BoardPluginBuilder(), config: BoardPluginConfig()); registerPlugin( builder: CalendarPluginBuilder(), config: CalendarPluginConfig(), ); registerPlugin( builder: DatabaseDocumentPluginBuilder(), config: DatabaseDocumentPluginConfig(), ); registerPlugin( builder: DatabaseDocumentPluginBuilder(), config: DatabaseDocumentPluginConfig(), ); registerPlugin( builder: AIChatPluginBuilder(), config: AIChatPluginConfig(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/localization.dart ================================================ import 'package:easy_localization/easy_localization.dart'; import '../startup.dart'; class InitLocalizationTask extends LaunchTask { const InitLocalizationTask(); @override Future initialize(LaunchContext context) async { await super.initialize(context); await EasyLocalization.ensureInitialized(); EasyLocalization.logger.enableBuildModes = []; } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/memory_leak_detector.dart ================================================ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:leak_tracker/leak_tracker.dart'; import '../startup.dart'; bool enableMemoryLeakDetect = false; bool dumpMemoryLeakPerSecond = false; void dumpMemoryLeak({ LeakType type = LeakType.notDisposed, }) async { final details = await LeakTracking.collectLeaks(); details.dumpDetails(type); } class MemoryLeakDetectorTask extends LaunchTask { MemoryLeakDetectorTask(); Timer? _timer; @override Future initialize(LaunchContext context) async { await super.initialize(context); if (!kDebugMode || !enableMemoryLeakDetect) { return; } LeakTracking.start(); LeakTracking.phase = const PhaseSettings( leakDiagnosticConfig: LeakDiagnosticConfig( collectRetainingPathForNotGCed: true, collectStackTraceOnStart: true, ), ); FlutterMemoryAllocations.instance.addListener((p0) { LeakTracking.dispatchObjectEvent(p0.toMap()); }); // dump memory leak per second if needed if (dumpMemoryLeakPerSecond) { _timer = Timer.periodic(const Duration(seconds: 1), (_) async { final summary = await LeakTracking.checkLeaks(); if (summary.isEmpty) { return; } dumpMemoryLeak(); }); } } @override Future dispose() async { await super.dispose(); if (!kDebugMode || !enableMemoryLeakDetect) { return; } if (dumpMemoryLeakPerSecond) { _timer?.cancel(); _timer = null; } LeakTracking.stop(); } } extension on LeakType { String get desc => switch (this) { LeakType.notDisposed => 'not disposed', LeakType.notGCed => 'not GCed', LeakType.gcedLate => 'GCed late' }; } final _dumpablePackages = [ 'package:appflowy/', 'package:appflowy_editor/', ]; extension on Leaks { void dumpDetails(LeakType type) { final summary = '${type.desc}: ${switch (type) { LeakType.notDisposed => '${notDisposed.length}', LeakType.notGCed => '${notGCed.length}', LeakType.gcedLate => '${gcedLate.length}' }}'; debugPrint(summary); final details = switch (type) { LeakType.notDisposed => notDisposed, LeakType.notGCed => notGCed, LeakType.gcedLate => gcedLate }; // only dump the code in appflowy for (final value in details) { final stack = value.context![ContextKeys.startCallstack]! as StackTrace; final stackInAppFlowy = stack .toString() .split('\n') .where( (stack) => // ignore current file call stack !stack.contains('memory_leak_detector') && _dumpablePackages.any((pkg) => stack.contains(pkg)), ) .join('\n'); // ignore the untreatable leak if (stackInAppFlowy.isEmpty) { continue; } final object = value.type; debugPrint(''' $object ${type.desc} $stackInAppFlowy '''); } } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/platform_error_catcher.dart ================================================ import 'package:appflowy_backend/log.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../startup.dart'; class PlatformErrorCatcherTask extends LaunchTask { const PlatformErrorCatcherTask(); @override Future initialize(LaunchContext context) async { await super.initialize(context); // Handle platform errors not caught by Flutter. // Reduces the likelihood of the app crashing, and logs the error. // only active in non debug mode. if (!kDebugMode) { PlatformDispatcher.instance.onError = (error, stack) { Log.error('Uncaught platform error', error, stack); return true; }; } ErrorWidget.builder = (details) { if (kDebugMode) { return Container( width: double.infinity, height: 30, color: Colors.red, child: FlowyText( 'ERROR: ${details.exceptionAsString()}', color: Colors.white, ), ); } // hide the error widget in release mode return const SizedBox.shrink(); }; } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/platform_service.dart ================================================ import 'package:appflowy/core/network_monitor.dart'; import '../startup.dart'; class InitPlatformServiceTask extends LaunchTask { const InitPlatformServiceTask(); @override LaunchTaskType get type => LaunchTaskType.dataProcessing; @override Future initialize(LaunchContext context) async { await super.initialize(context); return getIt().start(); } @override Future dispose() async { await super.dispose(); await getIt().stop(); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/prelude.dart ================================================ export 'app_widget.dart'; export 'appflowy_cloud_task.dart'; export 'auto_update_task.dart'; export 'debug_task.dart'; export 'device_info_task.dart'; export 'feature_flag_task.dart'; export 'generate_router.dart'; export 'hot_key.dart'; export 'load_plugin.dart'; export 'localization.dart'; export 'memory_leak_detector.dart'; export 'platform_error_catcher.dart'; export 'platform_service.dart'; export 'recent_service_task.dart'; export 'rust_sdk.dart'; export 'windows.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/recent_service_task.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; class RecentServiceTask extends LaunchTask { const RecentServiceTask(); @override Future initialize(LaunchContext context) async { await super.initialize(context); Log.info('[CachedRecentService] Initialized'); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/env/backend_env.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/user/application/auth/device_id.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../startup.dart'; class InitRustSDKTask extends LaunchTask { const InitRustSDKTask({ this.customApplicationPath, }); // Customize the RustSDK initialization path final Directory? customApplicationPath; @override LaunchTaskType get type => LaunchTaskType.dataProcessing; @override Future initialize(LaunchContext context) async { await super.initialize(context); final root = await getApplicationSupportDirectory(); final applicationPath = await appFlowyApplicationDataDirectory(); final dir = customApplicationPath ?? applicationPath; final deviceId = await getDeviceId(); // Pass the environment variables to the Rust SDK final env = _makeAppFlowyConfiguration( root.path, context.config.version, dir.path, applicationPath.path, deviceId, rustEnvs: context.config.rustEnvs, ); await context.getIt().init(jsonEncode(env.toJson())); } } AppFlowyConfiguration _makeAppFlowyConfiguration( String root, String appVersion, String customAppPath, String originAppPath, String deviceId, { required Map rustEnvs, }) { final env = getIt(); return AppFlowyConfiguration( root: root, app_version: appVersion, custom_app_path: customAppPath, origin_app_path: originAppPath, device_id: deviceId, platform: Platform.operatingSystem, authenticator_type: env.authenticatorType.value, appflowy_cloud_config: env.appflowyCloudConfig, envs: rustEnvs, ); } /// The default directory to store the user data. The directory can be /// customized by the user via the [ApplicationDataStorage] Future appFlowyApplicationDataDirectory() async { switch (integrationMode()) { case IntegrationMode.develop: final Directory documentsDir = await getApplicationSupportDirectory() .then((directory) => directory.create()); return Directory(path.join(documentsDir.path, 'data_dev')); case IntegrationMode.release: final Directory documentsDir = await getApplicationSupportDirectory(); return Directory(path.join(documentsDir.path, 'data')); case IntegrationMode.unitTest: case IntegrationMode.integrationTest: return Directory(path.join(Directory.current.path, '.sandbox')); } } ================================================ FILE: frontend/appflowy_flutter/lib/startup/tasks/windows.dart ================================================ import 'dart:async'; import 'dart:ui'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_window_size_manager.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart'; import 'package:scaled_app/scaled_app.dart'; import 'package:window_manager/window_manager.dart'; import 'package:universal_platform/universal_platform.dart'; class InitAppWindowTask extends LaunchTask with WindowListener { InitAppWindowTask({this.title = 'AppFlowy'}); final String title; final windowSizeManager = WindowSizeManager(); @override Future initialize(LaunchContext context) async { await super.initialize(context); // Don't initialize in tests or on web if (context.env.isTest || UniversalPlatform.isWeb) { return; } if (UniversalPlatform.isMobile) { final scale = await windowSizeManager.getScaleFactor(); ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => scale; return; } await windowManager.ensureInitialized(); windowManager.addListener(this); final windowSize = await windowSizeManager.getSize(); final windowOptions = WindowOptions( size: windowSize, minimumSize: const Size( WindowSizeManager.minWindowWidth, WindowSizeManager.minWindowHeight, ), maximumSize: const Size( WindowSizeManager.maxWindowWidth, WindowSizeManager.maxWindowHeight, ), title: title, ); final position = await windowSizeManager.getPosition(); if (UniversalPlatform.isWindows) { await windowManager.setTitleBarStyle(TitleBarStyle.hidden); doWhenWindowReady(() async { appWindow.minSize = windowOptions.minimumSize; appWindow.maxSize = windowOptions.maximumSize; appWindow.size = windowSize; if (position != null) { appWindow.position = position; } /// on Windows we maximize the window if it was previously closed /// from a maximized state. final isMaximized = await windowSizeManager.getWindowMaximized(); if (isMaximized) { appWindow.maximize(); } }); } else { await windowManager.waitUntilReadyToShow(windowOptions, () async { await windowManager.show(); await windowManager.focus(); if (position != null) { await windowManager.setPosition(position); } }); } unawaited( windowSizeManager.getScaleFactor().then( (v) => ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => v, ), ); } @override Future onWindowMaximize() async { super.onWindowMaximize(); await windowSizeManager.setWindowMaximized(true); await windowSizeManager.setPosition(Offset.zero); } @override Future onWindowUnmaximize() async { super.onWindowUnmaximize(); await windowSizeManager.setWindowMaximized(false); final position = await windowManager.getPosition(); return windowSizeManager.setPosition(position); } @override void onWindowEnterFullScreen() async { super.onWindowEnterFullScreen(); await windowSizeManager.setWindowMaximized(true); await windowSizeManager.setPosition(Offset.zero); } @override Future onWindowLeaveFullScreen() async { super.onWindowLeaveFullScreen(); await windowSizeManager.setWindowMaximized(false); final position = await windowManager.getPosition(); return windowSizeManager.setPosition(position); } @override Future onWindowResize() async { super.onWindowResize(); final currentWindowSize = await windowManager.getSize(); return windowSizeManager.setSize(currentWindowSize); } @override void onWindowMoved() async { super.onWindowMoved(); final position = await windowManager.getPosition(); return windowSizeManager.setPosition(position); } @override Future dispose() async { await super.dispose(); windowManager.removeListener(this); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/anon_user_bloc.dart ================================================ import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'anon_user_bloc.freezed.dart'; class AnonUserBloc extends Bloc { AnonUserBloc() : super(AnonUserState.initial()) { on((event, emit) async { await event.when( initial: () async { await _loadHistoricalUsers(); }, didLoadAnonUsers: (List anonUsers) { emit(state.copyWith(anonUsers: anonUsers)); }, openAnonUser: (anonUser) async { await UserBackendService.openAnonUser(); emit(state.copyWith(openedAnonUser: anonUser)); }, ); }); } Future _loadHistoricalUsers() async { final result = await UserBackendService.getAnonUser(); result.fold( (anonUser) { add(AnonUserEvent.didLoadAnonUsers([anonUser])); }, (error) { if (error.code != ErrorCode.RecordNotFound) { Log.error(error); } }, ); } } @freezed class AnonUserEvent with _$AnonUserEvent { const factory AnonUserEvent.initial() = _Initial; const factory AnonUserEvent.didLoadAnonUsers( List historicalUsers, ) = _DidLoadHistoricalUsers; const factory AnonUserEvent.openAnonUser(UserProfilePB anonUser) = _OpenHistoricalUser; } @freezed class AnonUserState with _$AnonUserState { const factory AnonUserState({ required List anonUsers, required UserProfilePB? openedAnonUser, }) = _AnonUserState; factory AnonUserState.initial() => const AnonUserState( anonUsers: [], openedAnonUser: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart ================================================ import 'dart:async'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/auth/backend_auth_service.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:url_launcher/url_launcher.dart'; import 'auth_error.dart'; class AppFlowyCloudAuthService implements AuthService { AppFlowyCloudAuthService(); final BackendAuthService _backendAuthService = BackendAuthService( AuthTypePB.Server, ); @override Future> signUp({ required String name, required String email, required String password, Map params = const {}, }) async { throw UnimplementedError(); } @override Future> signInWithEmailPassword({ required String email, required String password, Map params = const {}, }) async { return _backendAuthService.signInWithEmailPassword( email: email, password: password, params: params, ); } @override Future> signUpWithOAuth({ required String platform, Map params = const {}, }) async { final provider = ProviderTypePBExtension.fromPlatform(platform); // Get the oauth url from the backend final result = await UserEventGetOauthURLWithProvider( OauthProviderPB.create()..provider = provider, ).send(); return result.fold( (data) async { // Open the webview with oauth url final uri = Uri.parse(data.oauthUrl); final isSuccess = await afLaunchUri( uri, mode: LaunchMode.externalApplication, webOnlyWindowName: '_self', ); final completer = Completer>(); if (isSuccess) { // The [AppFlowyCloudDeepLink] must be registered before using the // [AppFlowyCloudAuthService]. if (getIt.isRegistered()) { getIt().registerCompleter(completer); } else { throw Exception('AppFlowyCloudDeepLink is not registered'); } } else { completer.complete( FlowyResult.failure(AuthError.unableToGetDeepLink), ); } return completer.future; }, (r) => FlowyResult.failure(r), ); } @override Future signOut() async { await _backendAuthService.signOut(); } @override Future> signUpAsGuest({ Map params = const {}, }) async { return _backendAuthService.signUpAsGuest(); } @override Future> signInWithMagicLink({ required String email, Map params = const {}, }) async { return _backendAuthService.signInWithMagicLink( email: email, params: params, ); } @override Future> signInWithPasscode({ required String email, required String passcode, }) async { return _backendAuthService.signInWithPasscode( email: email, passcode: passcode, ); } @override Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } } extension ProviderTypePBExtension on ProviderTypePB { static ProviderTypePB fromPlatform(String platform) { switch (platform) { case 'github': return ProviderTypePB.Github; case 'google': return ProviderTypePB.Google; case 'discord': return ProviderTypePB.Discord; case 'apple': return ProviderTypePB.Apple; default: throw UnimplementedError(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart ================================================ import 'dart:async'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/auth/backend_auth_service.dart'; import 'package:appflowy/user/application/auth/device_id.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter/material.dart'; /// Only used for testing. class AppFlowyCloudMockAuthService implements AuthService { AppFlowyCloudMockAuthService({String? email}) : userEmail = email ?? "${uuid()}@appflowy.io"; final String userEmail; final BackendAuthService _appFlowyAuthService = BackendAuthService(AuthTypePB.Server); @override Future> signUp({ required String name, required String email, required String password, Map params = const {}, }) async { throw UnimplementedError(); } @override Future> signInWithEmailPassword({ required String email, required String password, Map params = const {}, }) async { throw UnimplementedError(); } @override Future> signUpWithOAuth({ required String platform, Map params = const {}, }) async { final payload = SignInUrlPayloadPB.create() ..authenticator = AuthTypePB.Server // don't use nanoid here, the gotrue server will transform the email ..email = userEmail; final deviceId = await getDeviceId(); final getSignInURLResult = await UserEventGenerateSignInURL(payload).send(); return getSignInURLResult.fold( (urlPB) async { final payload = OauthSignInPB( authType: AuthTypePB.Server, map: { AuthServiceMapKeys.signInURL: urlPB.signInUrl, AuthServiceMapKeys.deviceId: deviceId, }, ); Log.info("UserEventOauthSignIn with payload: $payload"); return UserEventOauthSignIn(payload).send().then((value) { value.fold( (l) => null, (err) { debugPrint("mock auth service Error: $err"); Log.error(err); }, ); return value; }); }, (r) { debugPrint("mock auth service error: $r"); return FlowyResult.failure(r); }, ); } @override Future signOut() async { await _appFlowyAuthService.signOut(); } @override Future> signUpAsGuest({ Map params = const {}, }) async { return _appFlowyAuthService.signUpAsGuest(); } @override Future> signInWithMagicLink({ required String email, Map params = const {}, }) async { throw UnimplementedError(); } @override Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } @override Future> signInWithPasscode({ required String email, required String passcode, }) async { throw UnimplementedError(); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; class AuthError { static final signInWithOauthError = FlowyError() ..msg = 'sign in with oauth error -10003' ..code = ErrorCode.UserUnauthorized; static final emptyDeepLink = FlowyError() ..msg = 'Unexpected empty DeepLink' ..code = ErrorCode.UnexpectedCalendarFieldType; static final deepLinkError = FlowyError() ..msg = 'DeepLink error' ..code = ErrorCode.Internal; static final unableToGetDeepLink = FlowyError() ..msg = 'Unable to get the deep link' ..code = ErrorCode.Internal; } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class AuthServiceMapKeys { const AuthServiceMapKeys._(); static const String email = 'email'; static const String deviceId = 'device_id'; static const String signInURL = 'sign_in_url'; } /// `AuthService` is an abstract class that defines methods related to user authentication. /// /// This service provides various methods for user sign-in, sign-up, /// OAuth-based registration, and other related functionalities. abstract class AuthService { /// Authenticates a user with their email and password. /// /// - `email`: The email address of the user. /// - `password`: The password of the user. /// - `params`: Additional parameters for authentication (optional). /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. Future> signInWithEmailPassword({ required String email, required String password, Map params, }); /// Registers a new user with their name, email, and password. /// /// - `name`: The name of the user. /// - `email`: The email address of the user. /// - `password`: The password of the user. /// - `params`: Additional parameters for registration (optional). /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. Future> signUp({ required String name, required String email, required String password, Map params, }); /// Registers a new user with an OAuth platform. /// /// - `platform`: The OAuth platform name. /// - `params`: Additional parameters for OAuth registration (optional). /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. Future> signUpWithOAuth({ required String platform, Map params, }); /// Registers a user as a guest. /// /// - `params`: Additional parameters for guest registration (optional). /// /// Returns a default [UserProfilePB]. Future> signUpAsGuest({ Map params, }); /// Authenticates a user with a magic link sent to their email. /// /// - `email`: The email address of the user. /// - `params`: Additional parameters for authentication with magic link (optional). /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. Future> signInWithMagicLink({ required String email, Map params, }); /// Authenticates a user with a passcode sent to their email. /// /// - `email`: The email address of the user. /// - `passcode`: The passcode of the user. /// /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. Future> signInWithPasscode({ required String email, required String passcode, }); /// Signs out the currently authenticated user. Future signOut(); /// Retrieves the currently authenticated user's profile. /// /// Returns [UserProfilePB] if the user has signed in, otherwise returns [FlowyError]. Future> getUser(); } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart ================================================ import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show SignInPayloadPB, SignUpPayloadPB, UserProfilePB; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import '../../../generated/locale_keys.g.dart'; import 'device_id.dart'; class BackendAuthService implements AuthService { BackendAuthService(this.authType); final AuthTypePB authType; @override Future> signInWithEmailPassword({ required String email, required String password, Map params = const {}, }) async { final request = SignInPayloadPB.create() ..email = email ..password = password ..authType = authType ..deviceId = await getDeviceId(); return UserEventSignInWithEmailPassword(request).send(); } @override Future> signUp({ required String name, required String email, required String password, Map params = const {}, }) async { final request = SignUpPayloadPB.create() ..name = name ..email = email ..password = password ..authType = authType ..deviceId = await getDeviceId(); final response = await UserEventSignUp(request).send().then( (value) => value, ); return response; } @override Future signOut({ Map params = const {}, }) async { await UserEventSignOut().send(); return; } @override Future> signUpAsGuest({ Map params = const {}, }) async { const password = "Guest!@123456"; final userEmail = "anon@appflowy.io"; final request = SignUpPayloadPB.create() ..name = LocaleKeys.defaultUsername.tr() ..email = userEmail ..password = password // When sign up as guest, the auth type is always local. ..authType = AuthTypePB.Local ..deviceId = await getDeviceId(); final response = await UserEventSignUp(request).send().then( (value) => value, ); return response; } @override Future> signUpWithOAuth({ required String platform, AuthTypePB authType = AuthTypePB.Local, Map params = const {}, }) async { return FlowyResult.failure( FlowyError.create() ..code = ErrorCode.Internal ..msg = "Unsupported sign up action", ); } @override Future> getUser() async { return UserBackendService.getCurrentUserProfile(); } @override Future> signInWithMagicLink({ required String email, Map params = const {}, }) async { // No need to pass the redirect URL. return UserBackendService.signInWithMagicLink(email, ''); } @override Future> signInWithPasscode({ required String email, required String passcode, }) async { return UserBackendService.signInWithPasscode(email, passcode); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/auth/device_id.dart ================================================ import 'dart:io'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/services.dart'; final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); Future getDeviceId() async { if (integrationMode().isTest) { return "test_device_id"; } String? deviceId; try { if (Platform.isAndroid) { final AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; deviceId = androidInfo.device; } else if (Platform.isIOS) { final IosDeviceInfo iosInfo = await deviceInfo.iosInfo; deviceId = iosInfo.identifierForVendor; } else if (Platform.isMacOS) { final MacOsDeviceInfo macInfo = await deviceInfo.macOsInfo; deviceId = macInfo.systemGUID; } else if (Platform.isWindows) { final WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; deviceId = windowsInfo.deviceId; } else if (Platform.isLinux) { final LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; deviceId = linuxInfo.machineId; } } on PlatformException { Log.error('Failed to get platform version'); } return deviceId ?? ''; } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/notification_filter/notification_filter_bloc.dart ================================================ import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'notification_filter_bloc.freezed.dart'; class NotificationFilterBloc extends Bloc { NotificationFilterBloc() : super(const NotificationFilterState()) { on((event, emit) async { event.when( reset: () => emit(const NotificationFilterState()), toggleShowUnreadsOnly: () => emit( state.copyWith(showUnreadsOnly: !state.showUnreadsOnly), ), ); }); } } @freezed class NotificationFilterEvent with _$NotificationFilterEvent { const factory NotificationFilterEvent.toggleShowUnreadsOnly() = _ToggleShowUnreadsOnly; const factory NotificationFilterEvent.reset() = _Reset; } @freezed class NotificationFilterState with _$NotificationFilterState { const NotificationFilterState._(); const factory NotificationFilterState({ @Default(false) bool showUnreadsOnly, }) = _NotificationFilterState; // If state is not default values, then there are custom changes bool get hasFilters => showUnreadsOnly != false; } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/password/password_bloc.dart ================================================ import 'dart:convert'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/user/application/password/password_http_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'password_bloc.freezed.dart'; class PasswordBloc extends Bloc { PasswordBloc(this.userProfile) : super(PasswordState.initial()) { on( (event, emit) async { await event.when( init: () async => _init(), changePassword: (oldPassword, newPassword) async => _onChangePassword( emit, oldPassword: oldPassword, newPassword: newPassword, ), setupPassword: (newPassword) async => _onSetupPassword( emit, newPassword: newPassword, ), forgotPassword: (email) async => _onForgotPassword( emit, email: email, ), checkHasPassword: () async => _onCheckHasPassword( emit, ), cancel: () {}, ); }, ); } final UserProfilePB userProfile; late final PasswordHttpService passwordHttpService; bool _isInitialized = false; Future _init() async { if (userProfile.userAuthType == AuthTypePB.Local) { Log.debug('PasswordBloc: skip init because user is local authenticator'); return; } final baseUrl = await getAppFlowyCloudUrl(); try { final authToken = jsonDecode(userProfile.token)['access_token']; passwordHttpService = PasswordHttpService( baseUrl: baseUrl, authToken: authToken, ); _isInitialized = true; } catch (e) { Log.error('PasswordBloc: _init: error: $e'); } } Future _onChangePassword( Emitter emit, { required String oldPassword, required String newPassword, }) async { if (!_isInitialized) { Log.info('changePassword: not initialized'); return; } if (state.isSubmitting) { Log.info('changePassword: already submitting'); return; } _clearState(emit, true); final result = await passwordHttpService.changePassword( currentPassword: oldPassword, newPassword: newPassword, ); emit( state.copyWith( isSubmitting: false, changePasswordResult: result, ), ); } Future _onSetupPassword( Emitter emit, { required String newPassword, }) async { if (!_isInitialized) { Log.info('setupPassword: not initialized'); return; } if (state.isSubmitting) { Log.info('setupPassword: already submitting'); return; } _clearState(emit, true); final result = await passwordHttpService.setupPassword( newPassword: newPassword, ); emit( state.copyWith( isSubmitting: false, hasPassword: result.fold( (success) => true, (error) => false, ), setupPasswordResult: result, ), ); } Future _onForgotPassword( Emitter emit, { required String email, }) async { if (!_isInitialized) { Log.info('forgotPassword: not initialized'); return; } if (state.isSubmitting) { Log.info('forgotPassword: already submitting'); return; } _clearState(emit, true); final result = await passwordHttpService.forgotPassword(email: email); emit( state.copyWith( isSubmitting: false, forgotPasswordResult: result, ), ); } Future _onCheckHasPassword(Emitter emit) async { if (!_isInitialized) { Log.info('checkHasPassword: not initialized'); return; } if (state.isSubmitting) { Log.info('checkHasPassword: already submitting'); return; } _clearState(emit, true); final result = await passwordHttpService.checkHasPassword(); emit( state.copyWith( isSubmitting: false, hasPassword: result.fold( (success) => success, (error) => false, ), checkHasPasswordResult: result, ), ); } void _clearState(Emitter emit, bool isSubmitting) { emit( state.copyWith( isSubmitting: isSubmitting, changePasswordResult: null, setupPasswordResult: null, forgotPasswordResult: null, checkHasPasswordResult: null, ), ); } } @freezed class PasswordEvent with _$PasswordEvent { const factory PasswordEvent.init() = Init; // Change password const factory PasswordEvent.changePassword({ required String oldPassword, required String newPassword, }) = ChangePassword; // Setup password const factory PasswordEvent.setupPassword({ required String newPassword, }) = SetupPassword; // Forgot password const factory PasswordEvent.forgotPassword({ required String email, }) = ForgotPassword; // Check has password const factory PasswordEvent.checkHasPassword() = CheckHasPassword; // Cancel operation const factory PasswordEvent.cancel() = Cancel; } @freezed class PasswordState with _$PasswordState { const factory PasswordState({ required bool isSubmitting, required bool hasPassword, required FlowyResult? changePasswordResult, required FlowyResult? setupPasswordResult, required FlowyResult? forgotPasswordResult, required FlowyResult? checkHasPasswordResult, }) = _PasswordState; factory PasswordState.initial() => const PasswordState( isSubmitting: false, hasPassword: false, changePasswordResult: null, setupPasswordResult: null, forgotPasswordResult: null, checkHasPasswordResult: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/password/password_http_service.dart ================================================ import 'dart:convert'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:http/http.dart' as http; enum PasswordEndpoint { changePassword, forgotPassword, setupPassword, checkHasPassword, verifyResetPasswordToken; String get path { switch (this) { case PasswordEndpoint.changePassword: return '/gotrue/user/change-password'; case PasswordEndpoint.forgotPassword: return '/gotrue/recover'; case PasswordEndpoint.setupPassword: return '/gotrue/user/change-password'; case PasswordEndpoint.checkHasPassword: return '/gotrue/user/auth-info'; case PasswordEndpoint.verifyResetPasswordToken: return '/gotrue/verify'; } } String get method { switch (this) { case PasswordEndpoint.changePassword: case PasswordEndpoint.setupPassword: case PasswordEndpoint.forgotPassword: case PasswordEndpoint.verifyResetPasswordToken: return 'POST'; case PasswordEndpoint.checkHasPassword: return 'GET'; } } Uri uri(String baseUrl) => Uri.parse('$baseUrl$path'); } class PasswordHttpService { PasswordHttpService({ required this.baseUrl, required this.authToken, }); final String baseUrl; String authToken; final http.Client client = http.Client(); Map get headers => { 'Content-Type': 'application/json', 'Authorization': 'Bearer $authToken', }; /// Changes the user's password /// /// [currentPassword] - The user's current password /// [newPassword] - The new password to set Future> changePassword({ required String currentPassword, required String newPassword, }) async { final result = await _makeRequest( endpoint: PasswordEndpoint.changePassword, body: { 'current_password': currentPassword, 'password': newPassword, }, errorMessage: 'Failed to change password', ); return result.fold( (data) => FlowyResult.success(true), (error) => FlowyResult.failure(error), ); } /// Sends a password reset email to the user /// /// [email] - The email address of the user Future> forgotPassword({ required String email, }) async { final result = await _makeRequest( endpoint: PasswordEndpoint.forgotPassword, body: {'email': email}, errorMessage: 'Failed to send password reset email', ); return result.fold( (data) => FlowyResult.success(true), (error) => FlowyResult.failure(error), ); } /// Sets up a password for a user that doesn't have one /// /// [newPassword] - The new password to set Future> setupPassword({ required String newPassword, }) async { final result = await _makeRequest( endpoint: PasswordEndpoint.setupPassword, body: {'password': newPassword}, errorMessage: 'Failed to setup password', ); return result.fold( (data) => FlowyResult.success(true), (error) => FlowyResult.failure(error), ); } /// Checks if the user has a password set Future> checkHasPassword() async { final result = await _makeRequest( endpoint: PasswordEndpoint.checkHasPassword, errorMessage: 'Failed to check password status', ); try { return result.fold( (data) => FlowyResult.success(data['has_password'] ?? false), (error) => FlowyResult.failure(error), ); } catch (e) { return FlowyResult.failure( FlowyError(msg: 'Failed to check password status: $e'), ); } } // Verify the reset password token Future> verifyResetPasswordToken({ required String email, required String token, }) async { final result = await _makeRequest( endpoint: PasswordEndpoint.verifyResetPasswordToken, body: { 'type': 'recovery', 'email': email, 'token': token, }, errorMessage: 'Failed to verify reset password token', ); try { return result.fold( (data) { final authToken = data['access_token']; return FlowyResult.success(authToken); }, (error) => FlowyResult.failure(error), ); } catch (e) { return FlowyResult.failure( FlowyError(msg: 'Failed to verify reset password token: $e'), ); } } /// Makes a request to the specified endpoint with the given body Future> _makeRequest({ required PasswordEndpoint endpoint, Map? body, String errorMessage = 'Request failed', }) async { try { final uri = endpoint.uri(baseUrl); http.Response response; if (endpoint.method == 'POST') { response = await client.post( uri, headers: headers, body: body != null ? jsonEncode(body) : null, ); } else if (endpoint.method == 'GET') { response = await client.get( uri, headers: headers, ); } else { return FlowyResult.failure( FlowyError(msg: 'Invalid request method: ${endpoint.method}'), ); } if (response.statusCode == 200) { if (response.body.isNotEmpty) { return FlowyResult.success(jsonDecode(response.body)); } return FlowyResult.success(true); } else { final errorBody = response.body.isNotEmpty ? jsonDecode(response.body) : {}; // the checkHasPassword endpoint will return 403, which is not an error if (endpoint != PasswordEndpoint.checkHasPassword) { Log.info( '${endpoint.name} request failed: ${response.statusCode}, $errorBody ', ); } ErrorCode errorCode = ErrorCode.Internal; if (response.statusCode == 422) { errorCode = ErrorCode.NewPasswordTooWeak; } return FlowyResult.failure( FlowyError( code: errorCode, msg: errorBody['msg'] ?? errorMessage, ), ); } } catch (e) { Log.error('${endpoint.name} request failed: error: $e'); return FlowyResult.failure( FlowyError(msg: 'Network error: ${e.toString()}'), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/prelude.dart ================================================ export 'auth/backend_auth_service.dart'; export './sign_in_bloc.dart'; export './sign_up_bloc.dart'; export './splash_bloc.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/user/application/reminder/reminder_service.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; part 'reminder_bloc.freezed.dart'; class ReminderBloc extends Bloc { ReminderBloc() : super(ReminderState()) { Log.info('ReminderBloc created'); _actionBloc = getIt(); _reminderService = const ReminderService(); timer = _periodicCheck(); _listener = AppLifecycleListener( onResume: () { if (!isClosed) { add(const ReminderEvent.resetTimer()); } }, ); _dispatch(); } late final ActionNavigationBloc _actionBloc; late final ReminderService _reminderService; Timer? timer; late final AppLifecycleListener _listener; final _deepEquality = DeepCollectionEquality(); bool hasReminder(String reminderId) => state.allReminders.where((e) => e.id == reminderId).firstOrNull != null; final List _allViews = []; void _dispatch() { on( (event, emit) async { await event.when( started: () async { add(const ReminderEvent.refresh()); }, refresh: () async { final result = await _reminderService.fetchReminders(); final views = await ViewBackendService.getAllViews(); views.onSuccess((views) { _allViews.clear(); _allViews.addAll(views.items); }); await result.fold( (reminders) async { final availableReminders = await filterAvailableReminders(reminders); // only print the reminder ids are not the same as the previous ones final previousReminderIds = state.reminders.map((e) => e.id).toSet(); final newReminderIds = availableReminders.map((e) => e.id).toSet(); final diff = _deepEquality.equals( previousReminderIds, newReminderIds, ); if (!diff) { Log.info( 'Fetched reminders on refresh: ${availableReminders.length}', ); } if (!isClosed && !emit.isDone) { emit( state.copyWith( reminders: availableReminders, serverReminders: reminders, ), ); } }, (error) { Log.error('Failed to fetch reminders: $error'); }, ); }, removeReminder: (reminderId) async { final result = await _reminderService.removeReminder( reminderId: reminderId, ); result.fold( (_) { Log.info('Removed reminder: $reminderId'); final reminders = List.of(state.reminders); final index = reminders .indexWhere((reminder) => reminder.id == reminderId); if (index != -1) { reminders.removeAt(index); emit(state.copyWith(reminders: reminders)); } }, (error) => Log.error( 'Failed to remove reminder($reminderId): $error', ), ); }, removeReminders: (reminderIds) async { Log.info('Remove reminders: $reminderIds'); final removedIds = {}; for (final reminderId in reminderIds) { final result = await _reminderService.removeReminder( reminderId: reminderId, ); if (result.isSuccess) { Log.info('Removed reminder: $reminderId'); removedIds.add(reminderId); } else { Log.error('Failed to remove reminder: $reminderId'); } } emit( state.copyWith( reminders: state.reminders .where((reminder) => !removedIds.contains(reminder.id)) .toList(), ), ); }, add: (reminder) async { // check the timestamp in the reminder if (reminder.createdAt == null) { reminder.freeze(); reminder = reminder.rebuild((update) { update.meta[ReminderMetaKeys.createdAt] = DateTime.now().millisecondsSinceEpoch.toString(); }); } if (hasReminder(reminder.id)) { Log.error('Reminder: ${reminder.id} failed to be added again'); return; } final result = await _reminderService.addReminder( reminder: reminder, ); return result.fold( (_) async { Log.info('Added reminder: ${reminder.id}'); Log.info('Before adding reminder: ${state.reminders.length}'); final showRightNow = !DateTime.now() .isBefore(reminder.scheduledAt.toDateTime()) && !reminder.isRead; if (showRightNow) { final reminders = [...state.reminders, reminder]; Log.info('After adding reminder: ${reminders.length}'); emit(state.copyWith(reminders: reminders)); } }, (error) { Log.error('Failed to add reminder: $error'); }, ); }, addById: (reminderId, objectId, scheduledAt, meta) async => add( ReminderEvent.add( reminder: ReminderPB( id: reminderId, objectId: objectId, title: LocaleKeys.reminderNotification_title.tr(), message: LocaleKeys.reminderNotification_message.tr(), scheduledAt: scheduledAt, isAck: scheduledAt.toDateTime().isBefore(DateTime.now()), meta: meta, ), ), ), update: (updateObject) async { final reminder = state.allReminders.firstWhereOrNull( (r) => r.id == updateObject.id, ); if (reminder == null) { return; } final newReminder = updateObject.merge(a: reminder); final failureOrUnit = await _reminderService.updateReminder( reminder: newReminder, ); Log.info('Updating reminder: ${newReminder.id}'); await failureOrUnit.fold((_) async { Log.info('Updated reminder: ${newReminder.id}'); final index = state.reminders.indexWhere((r) => r.id == newReminder.id); if (index == -1) { if (await checkReminderAvailable( newReminder, state.allReminders.map((e) => e.id).toSet(), )) { emit( state .copyWith(reminders: [...state.reminders, newReminder]), ); } return; } final reminders = [...state.reminders]; if (await checkReminderAvailable( newReminder, state.allReminders.map((e) => e.id).toSet(), )) { reminders.replaceRange(index, index + 1, [newReminder]); emit(state.copyWith(reminders: reminders)); } else { reminders.removeAt(index); emit(state.copyWith(reminders: reminders)); } }, (error) { Log.error( 'Failed to update reminder(${newReminder.id}): $error', ); }); }, pressReminder: (reminderId, path, view) { final reminder = state.reminders.firstWhereOrNull((r) => r.id == reminderId); if (reminder == null) { return; } add( ReminderEvent.update( ReminderUpdate( id: reminderId, isRead: state.pastReminders.contains(reminder), ), ), ); String? rowId; if (view?.layout != ViewLayoutPB.Document) { rowId = reminder.meta[ReminderMetaKeys.rowId]; } final action = NavigationAction( objectId: reminder.objectId, arguments: { ActionArgumentKeys.view: view, ActionArgumentKeys.nodePath: path, ActionArgumentKeys.rowId: rowId, }, ); if (!isClosed) { _actionBloc.add( ActionNavigationEvent.performAction( action: action, nextActions: [ action.copyWith( type: rowId != null ? ActionType.openRow : ActionType.jumpToBlock, ), ], ), ); } }, markAsRead: (reminderIds) async { final reminders = await _onMarkAsRead(reminderIds: reminderIds); Log.info('Marked reminders as read: $reminderIds'); emit( state.copyWith( reminders: reminders, ), ); }, archive: (reminderIds) async { final reminders = await _onArchived( isArchived: true, reminderIds: reminderIds, ); Log.info('Archived reminders: $reminderIds'); emit( state.copyWith( reminders: reminders, ), ); }, markAllRead: () async { final reminders = await _onMarkAsRead(); Log.info('Marked all reminders as read'); emit( state.copyWith( reminders: reminders, ), ); }, archiveAll: () async { final reminders = await _onArchived(isArchived: true); Log.info('Archived all reminders'); emit( state.copyWith( reminders: reminders, ), ); }, unarchiveAll: () async { final reminders = await _onArchived(isArchived: false); emit( state.copyWith( reminders: reminders, ), ); }, resetTimer: () { timer?.cancel(); timer = _periodicCheck(); }, ); }, ); } @override Future close() async { Log.info('ReminderBloc closed'); _listener.dispose(); timer?.cancel(); await super.close(); } /// Mark the reminder as read /// /// If the [reminderIds] is null, all unread reminders will be marked as read /// Otherwise, only the reminders with the given IDs will be marked as read Future> _onMarkAsRead({ List? reminderIds, }) async { final Iterable remindersToUpdate; if (reminderIds != null) { remindersToUpdate = state.reminders.where( (reminder) => reminderIds.contains(reminder.id) && !reminder.isRead, ); } else { // Get all reminders that are not matching the isArchived flag remindersToUpdate = state.reminders.where( (reminder) => !reminder.isRead, ); } for (final reminder in remindersToUpdate) { reminder.isRead = true; await _reminderService.updateReminder(reminder: reminder); Log.info('Mark reminder ${reminder.id} as read'); } return state.reminders.map((e) { if (reminderIds != null && !reminderIds.contains(e.id)) { return e; } if (e.isRead) { return e; } e.freeze(); return e.rebuild((update) { update.isRead = true; }); }).toList(); } /// Archive or unarchive reminders /// /// If the [reminderIds] is null, all reminders will be archived /// Otherwise, only the reminders with the given IDs will be archived or unarchived Future> _onArchived({ required bool isArchived, List? reminderIds, }) async { final Iterable remindersToUpdate; if (reminderIds != null) { remindersToUpdate = state.reminders.where( (reminder) => reminderIds.contains(reminder.id) && reminder.isArchived != isArchived, ); } else { // Get all reminders that are not matching the isArchived flag remindersToUpdate = state.reminders.where( (reminder) => reminder.isArchived != isArchived, ); } for (final reminder in remindersToUpdate) { reminder.isRead = isArchived; reminder.meta[ReminderMetaKeys.isArchived] = isArchived.toString(); await _reminderService.updateReminder(reminder: reminder); Log.info('Reminder ${reminder.id} is archived: $isArchived'); } return state.reminders.map((e) { if (reminderIds != null && !reminderIds.contains(e.id)) { return e; } if (e.isArchived == isArchived) { return e; } e.freeze(); return e.rebuild((update) { update.isRead = isArchived; update.meta[ReminderMetaKeys.isArchived] = isArchived.toString(); }); }).toList(); } Timer _periodicCheck() { return Timer.periodic( const Duration(seconds: 30), (_) async { if (!isClosed) add(const ReminderEvent.refresh()); }, ); } Future checkReminderAvailable( ReminderPB reminder, Set reminderIds, { Set removeIds = const {}, }) async { /// check if schedule time is coming final scheduledAt = reminder.scheduledAt.toDateTime(); if (!DateTime.now().isAfter(scheduledAt) && !reminder.isRead) { return false; } /// check if view is not null final viewId = reminder.objectId; final view = _allViews.firstWhereOrNull((e) => e.id == viewId); if (view == null) { removeIds.add(reminder.id); return false; } if (view.isDatabase) { return true; } else { /// blockId is null means no node final blockId = reminder.meta[ReminderMetaKeys.blockId]; if (blockId == null) { removeIds.add(reminder.id); return false; } /// check if document is not null final document = await DocumentService() .openDocument(documentId: viewId) .fold((s) => s.toDocument(), (_) => null); if (document == null) { removeIds.add(reminder.id); return false; } Node? searchById(Node current, String id) { if (current.id == id) { return current; } if (current.children.isNotEmpty) { for (final child in current.children) { final node = searchById(child, id); if (node != null) { return node; } } } return null; } /// check if node is not null final node = searchById(document.root, blockId); if (node == null) { removeIds.add(reminder.id); return false; } final textInserts = node.delta?.whereType(); if (textInserts == null) return false; for (final text in textInserts) { final mention = text.attributes?[MentionBlockKeys.mention] as Map?; final reminderId = mention?[MentionBlockKeys.reminderId] as String?; if (reminderIds.contains(reminderId)) { return true; } } removeIds.add(reminder.id); return false; } } Future> filterAvailableReminders( List reminders, { bool removeUnavailableReminder = false, }) async { final List availableReminders = []; final reminderIds = reminders.map((e) => e.id).toSet(); final removeIds = {}; for (final r in reminders) { if (await checkReminderAvailable(r, reminderIds, removeIds: removeIds)) { availableReminders.add(r); } } if (removeUnavailableReminder) { Log.info('Remove unavailable reminder: $removeIds'); add(ReminderEvent.removeReminders(removeIds)); } return availableReminders; } } @freezed class ReminderEvent with _$ReminderEvent { // On startup we fetch all reminders and upcoming ones const factory ReminderEvent.started() = _Started; // Remove a reminder const factory ReminderEvent.removeReminder({required String reminderId}) = _RemoveReminder; // Remove reminders const factory ReminderEvent.removeReminders(Set reminderIds) = _RemoveReminders; // Add a reminder const factory ReminderEvent.add({required ReminderPB reminder}) = _Add; // Add a reminder const factory ReminderEvent.addById({ required String reminderId, required String objectId, required Int64 scheduledAt, @Default(null) Map? meta, }) = _AddById; // Update a reminder (eg. isAck, isRead, etc.) const factory ReminderEvent.update(ReminderUpdate update) = _Update; // Event to mark specific reminders as read, takes a list of reminder IDs const factory ReminderEvent.markAsRead(List reminderIds) = _MarkAsRead; // Event to mark all unread reminders as read const factory ReminderEvent.markAllRead() = _MarkAllRead; // Event to archive specific reminders, takes a list of reminder IDs const factory ReminderEvent.archive(List reminderIds) = _Archive; // Event to archive all reminders const factory ReminderEvent.archiveAll() = _ArchiveAll; // Event to unarchive all reminders const factory ReminderEvent.unarchiveAll() = _UnarchiveAll; // Event to handle reminder press action const factory ReminderEvent.pressReminder({ required String reminderId, @Default(null) int? path, @Default(null) ViewPB? view, }) = _PressReminder; // Event to refresh reminders const factory ReminderEvent.refresh() = _Refresh; const factory ReminderEvent.resetTimer() = _ResetTimer; } /// Object used to merge updates with /// a [ReminderPB] /// class ReminderUpdate { ReminderUpdate({ required this.id, this.isAck, this.isRead, this.scheduledAt, this.includeTime, this.isArchived, this.date, }); final String id; final bool? isAck; final bool? isRead; final DateTime? scheduledAt; final bool? includeTime; final bool? isArchived; final DateTime? date; ReminderPB merge({required ReminderPB a}) { final isAcknowledged = isAck == null && scheduledAt != null ? scheduledAt!.isBefore(DateTime.now()) : a.isAck; final meta = {...a.meta}; if (includeTime != a.includeTime) { meta[ReminderMetaKeys.includeTime] = includeTime.toString(); } if (isArchived != a.isArchived) { meta[ReminderMetaKeys.isArchived] = isArchived.toString(); } if (date != a.date && date != null) { meta[ReminderMetaKeys.date] = date!.millisecondsSinceEpoch.toString(); } return ReminderPB( id: a.id, objectId: a.objectId, scheduledAt: scheduledAt != null ? Int64(scheduledAt!.millisecondsSinceEpoch ~/ 1000) : a.scheduledAt, isAck: isAcknowledged, isRead: isRead ?? a.isRead, title: a.title, message: a.message, meta: meta, ); } } class ReminderState { ReminderState({ List? reminders, this.serverReminders = const [], }) { _reminders = []; pastReminders = []; upcomingReminders = []; if (reminders?.isEmpty ?? true) { return; } final now = DateTime.now(); for (final ReminderPB reminder in reminders ?? []) { final scheduledDate = reminder.scheduledAt.toDateTime(); if (scheduledDate.isBefore(now)) { pastReminders.add(reminder); } else { upcomingReminders.add(reminder); } } pastReminders.sort((a, b) => a.scheduledAt.compareTo(b.scheduledAt)); upcomingReminders.sort((a, b) => a.scheduledAt.compareTo(b.scheduledAt)); _reminders .addAll([...List.of(pastReminders), ...List.of(upcomingReminders)]); } late final List _reminders; List get reminders => _reminders.unique((e) => e.id); List get allReminders => [...serverReminders, ..._reminders].unique((e) => e.id); late final List pastReminders; late final List upcomingReminders; final List serverReminders; ReminderState copyWith({ List? reminders, List? serverReminders, }) => ReminderState( reminders: reminders ?? _reminders, serverReminders: serverReminders ?? this.serverReminders, ); } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; class ReminderMetaKeys { static String includeTime = "include_time"; static String blockId = "block_id"; static String rowId = "row_id"; static String createdAt = "created_at"; static String isArchived = "is_archived"; static String date = "date"; } enum ReminderType { past, today, other, } extension ReminderExtension on ReminderPB { bool? get includeTime { final String? includeTimeStr = meta[ReminderMetaKeys.includeTime]; return includeTimeStr != null ? includeTimeStr == true.toString() : null; } String? get blockId => meta[ReminderMetaKeys.blockId]; String? get rowId => meta[ReminderMetaKeys.rowId]; int? get createdAt { final t = meta[ReminderMetaKeys.createdAt]; return t != null ? int.tryParse(t) : null; } bool get isArchived { final t = meta[ReminderMetaKeys.isArchived]; return t != null ? t == true.toString() : false; } DateTime? get date { final t = meta[ReminderMetaKeys.date]; return t != null ? DateTime.fromMillisecondsSinceEpoch(int.parse(t)) : null; } ReminderType get type { final date = this.date?.millisecondsSinceEpoch; if (date == null) { return ReminderType.other; } final now = DateTime.now().millisecondsSinceEpoch; if (date < now) { return ReminderType.past; } final difference = date - now; const oneDayInMilliseconds = 24 * 60 * 60 * 1000; if (difference < oneDayInMilliseconds) { return ReminderType.today; } return ReminderType.other; } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/reminder/reminder_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/user_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; class UserAwarenessListener { UserAwarenessListener({ required this.workspaceId, }); final String workspaceId; UserNotificationParser? _userParser; StreamSubscription? _subscription; void Function()? onLoadedUserAwareness; void Function(ReminderPB)? onDidUpdateReminder; /// [onLoadedUserAwareness] is called when the user awareness is loaded. After this, can /// call fetch reminders releated events /// void start({ void Function()? onLoadedUserAwareness, void Function(ReminderPB)? onDidUpdateReminder, }) { this.onLoadedUserAwareness = onLoadedUserAwareness; this.onDidUpdateReminder = onDidUpdateReminder; _userParser = UserNotificationParser( id: workspaceId, callback: _userNotificationCallback, ); _subscription = RustStreamReceiver.listen((observable) { _userParser?.parse(observable); }); } void stop() { _userParser = null; _subscription?.cancel(); _subscription = null; } void _userNotificationCallback( UserNotification ty, FlowyResult result, ) { switch (ty) { case UserNotification.DidLoadUserAwareness: onLoadedUserAwareness?.call(); break; case UserNotification.DidUpdateReminder: result.map((r) => onDidUpdateReminder?.call(ReminderPB.fromBuffer(r))); break; default: break; } } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; /// Interface for a Reminder Service that handles /// communication to the backend /// abstract class IReminderService { Future, FlowyError>> fetchReminders(); Future> removeReminder({ required String reminderId, }); Future> addReminder({ required ReminderPB reminder, }); Future> updateReminder({ required ReminderPB reminder, }); } class ReminderService implements IReminderService { const ReminderService(); @override Future> addReminder({ required ReminderPB reminder, }) async { final unitOrFailure = await UserEventCreateReminder(reminder).send(); return unitOrFailure; } @override Future> updateReminder({ required ReminderPB reminder, }) async { final unitOrFailure = await UserEventUpdateReminder(reminder).send(); return unitOrFailure; } @override Future, FlowyError>> fetchReminders() async { final resultOrFailure = await UserEventGetAllReminders().send(); return resultOrFailure.fold( (s) => FlowyResult.success(s.items), (e) => FlowyResult.failure(e), ); } @override Future> removeReminder({ required String reminderId, }) async { final request = ReminderIdentifierPB(id: reminderId); final unitOrFailure = await UserEventRemoveReminder(request).send(); return unitOrFailure; } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/password/password_http_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'sign_in_bloc.freezed.dart'; class SignInBloc extends Bloc { SignInBloc(this.authService) : super(SignInState.initial()) { if (isAppFlowyCloudEnabled) { deepLinkStateListener = getIt().subscribeDeepLinkLoadingState((value) { if (isClosed) return; add(SignInEvent.deepLinkStateChange(value)); }); getAppFlowyCloudUrl().then((baseUrl) { passwordService = PasswordHttpService( baseUrl: baseUrl, authToken: '', // the user is not signed in yet, the auth token should be empty ); }); } on( (event, emit) async { await event.when( signInWithEmailAndPassword: (email, password) async => _onSignInWithEmailAndPassword( emit, email: email, password: password, ), signInWithOAuth: (platform) async => _onSignInWithOAuth( emit, platform: platform, ), signInAsGuest: () async => _onSignInAsGuest(emit), signInWithMagicLink: (email) async => _onSignInWithMagicLink( emit, email: email, ), signInWithPasscode: (email, passcode) async => _onSignInWithPasscode( emit, email: email, passcode: passcode, ), deepLinkStateChange: (result) => _onDeepLinkStateChange(emit, result), cancel: () { emit( state.copyWith( isSubmitting: false, emailError: null, passwordError: null, successOrFail: null, ), ); }, emailChanged: (email) async { emit( state.copyWith( email: email, emailError: null, successOrFail: null, ), ); }, passwordChanged: (password) async { emit( state.copyWith( password: password, passwordError: null, successOrFail: null, ), ); }, switchLoginType: (type) { emit(state.copyWith(loginType: type)); }, forgotPassword: (email) => _onForgotPassword(emit, email: email), validateResetPasswordToken: (email, token) async => _onValidateResetPasswordToken( emit, email: email, token: token, ), resetPassword: (email, newPassword) async => _onResetPassword( emit, email: email, newPassword: newPassword, ), ); }, ); } final AuthService authService; PasswordHttpService? passwordService; VoidCallback? deepLinkStateListener; @override Future close() { deepLinkStateListener?.call(); if (isAppFlowyCloudEnabled && deepLinkStateListener != null) { getIt().unsubscribeDeepLinkLoadingState( deepLinkStateListener!, ); } return super.close(); } Future _onDeepLinkStateChange( Emitter emit, DeepLinkResult result, ) async { final deepLinkState = result.state; switch (deepLinkState) { case DeepLinkState.none: break; case DeepLinkState.loading: emit( state.copyWith( isSubmitting: true, emailError: null, passwordError: null, successOrFail: null, ), ); case DeepLinkState.finish: final newState = result.result?.fold( (s) => state.copyWith( isSubmitting: false, successOrFail: FlowyResult.success(s), ), (f) => _stateFromCode(f), ); if (newState != null) { emit(newState); } case DeepLinkState.error: emit(state.copyWith(isSubmitting: false)); } } Future _onSignInWithEmailAndPassword( Emitter emit, { required String email, required String password, }) async { emit( state.copyWith( isSubmitting: true, emailError: null, passwordError: null, successOrFail: null, ), ); final result = await authService.signInWithEmailPassword( email: email, password: password, ); emit( result.fold( (gotrueTokenResponse) { getIt().passGotrueTokenResponse( gotrueTokenResponse, ); return state.copyWith( isSubmitting: false, ); }, (error) => _stateFromCode(error), ), ); } Future _onSignInWithOAuth( Emitter emit, { required String platform, }) async { emit( state.copyWith( isSubmitting: true, emailError: null, passwordError: null, successOrFail: null, ), ); final result = await authService.signUpWithOAuth(platform: platform); emit( result.fold( (userProfile) => state.copyWith( isSubmitting: false, successOrFail: FlowyResult.success(userProfile), ), (error) => _stateFromCode(error), ), ); } Future _onSignInWithMagicLink( Emitter emit, { required String email, }) async { if (state.isSubmitting) { Log.error('Sign in with magic link is already in progress'); return; } Log.info('Sign in with magic link: $email'); emit( state.copyWith( isSubmitting: true, emailError: null, passwordError: null, successOrFail: null, ), ); final result = await authService.signInWithMagicLink(email: email); emit( result.fold( (userProfile) => state.copyWith( isSubmitting: false, ), (error) => _stateFromCode(error), ), ); } Future _onSignInWithPasscode( Emitter emit, { required String email, required String passcode, }) async { if (state.isSubmitting) { Log.error('Sign in with passcode is already in progress'); return; } Log.info('Sign in with passcode: $email, $passcode'); emit( state.copyWith( isSubmitting: true, emailError: null, passwordError: null, successOrFail: null, ), ); final result = await authService.signInWithPasscode( email: email, passcode: passcode, ); emit( result.fold( (gotrueTokenResponse) { getIt().passGotrueTokenResponse( gotrueTokenResponse, ); return state.copyWith( isSubmitting: false, ); }, (error) => _stateFromCode(error), ), ); } Future _onSignInAsGuest( Emitter emit, ) async { emit( state.copyWith( isSubmitting: true, emailError: null, passwordError: null, successOrFail: null, ), ); final result = await authService.signUpAsGuest(); emit( result.fold( (userProfile) => state.copyWith( isSubmitting: false, successOrFail: FlowyResult.success(userProfile), ), (error) => _stateFromCode(error), ), ); } Future _onForgotPassword( Emitter emit, { required String email, }) async { if (state.isSubmitting) { Log.error('Forgot password is already in progress'); return; } emit( state.copyWith( isSubmitting: true, forgotPasswordSuccessOrFail: null, validateResetPasswordTokenSuccessOrFail: null, resetPasswordSuccessOrFail: null, ), ); final result = await passwordService?.forgotPassword(email: email); result?.fold( (success) { emit( state.copyWith( isSubmitting: false, forgotPasswordSuccessOrFail: FlowyResult.success(true), ), ); }, (error) { emit( state.copyWith( isSubmitting: false, forgotPasswordSuccessOrFail: FlowyResult.failure(error), ), ); }, ); } Future _onValidateResetPasswordToken( Emitter emit, { required String email, required String token, }) async { if (state.isSubmitting) { Log.error('Validate reset password token is already in progress'); return; } Log.info('Validate reset password token: $email, $token'); emit( state.copyWith( isSubmitting: true, validateResetPasswordTokenSuccessOrFail: null, resetPasswordSuccessOrFail: null, ), ); final result = await passwordService?.verifyResetPasswordToken( email: email, token: token, ); result?.fold( (authToken) { Log.info('Validate reset password token success: $authToken'); passwordService?.authToken = authToken; emit( state.copyWith( isSubmitting: false, validateResetPasswordTokenSuccessOrFail: FlowyResult.success(true), ), ); }, (error) { Log.error('Validate reset password token failed: $error'); emit( state.copyWith( isSubmitting: false, validateResetPasswordTokenSuccessOrFail: FlowyResult.failure(error), ), ); }, ); } Future _onResetPassword( Emitter emit, { required String email, required String newPassword, }) async { if (state.isSubmitting) { Log.error('Reset password is already in progress'); return; } Log.info('Reset password: $email, ${newPassword.hashCode}'); emit( state.copyWith( isSubmitting: true, resetPasswordSuccessOrFail: null, ), ); final result = await passwordService?.setupPassword( newPassword: newPassword, ); result?.fold( (success) { Log.info('Reset password success'); emit( state.copyWith( isSubmitting: false, resetPasswordSuccessOrFail: FlowyResult.success(true), ), ); }, (error) { Log.error('Reset password failed: $error'); emit( state.copyWith( isSubmitting: false, resetPasswordSuccessOrFail: FlowyResult.failure(error), ), ); }, ); } SignInState _stateFromCode(FlowyError error) { Log.error('SignInState _stateFromCode: ${error.msg}'); switch (error.code) { case ErrorCode.EmailFormatInvalid: return state.copyWith( isSubmitting: false, emailError: error.msg, passwordError: null, ); case ErrorCode.PasswordFormatInvalid: return state.copyWith( isSubmitting: false, passwordError: error.msg, emailError: null, ); case ErrorCode.UserUnauthorized: final errorMsg = error.msg; String msg = LocaleKeys.signIn_generalError.tr(); if (errorMsg.contains('rate limit') || errorMsg.contains('For security purposes')) { msg = LocaleKeys.signIn_tooFrequentVerificationCodeRequest.tr(); } else if (errorMsg.contains('invalid')) { msg = LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(); } else if (errorMsg.contains('Invalid login credentials')) { msg = LocaleKeys.signIn_invalidLoginCredentials.tr(); } return state.copyWith( isSubmitting: false, successOrFail: FlowyResult.failure( FlowyError(msg: msg), ), ); default: return state.copyWith( isSubmitting: false, successOrFail: FlowyResult.failure( FlowyError(msg: LocaleKeys.signIn_generalError.tr()), ), ); } } } @freezed class SignInEvent with _$SignInEvent { // Sign in methods const factory SignInEvent.signInWithEmailAndPassword({ required String email, required String password, }) = SignInWithEmailAndPassword; const factory SignInEvent.signInWithOAuth({ required String platform, }) = SignInWithOAuth; const factory SignInEvent.signInAsGuest() = SignInAsGuest; const factory SignInEvent.signInWithMagicLink({ required String email, }) = SignInWithMagicLink; const factory SignInEvent.signInWithPasscode({ required String email, required String passcode, }) = SignInWithPasscode; // Event handlers const factory SignInEvent.emailChanged({ required String email, }) = EmailChanged; const factory SignInEvent.passwordChanged({ required String password, }) = PasswordChanged; const factory SignInEvent.deepLinkStateChange(DeepLinkResult result) = DeepLinkStateChange; const factory SignInEvent.cancel() = Cancel; const factory SignInEvent.switchLoginType(LoginType type) = SwitchLoginType; // password const factory SignInEvent.forgotPassword({ required String email, }) = ForgotPassword; const factory SignInEvent.validateResetPasswordToken({ required String email, required String token, }) = ValidateResetPasswordToken; const factory SignInEvent.resetPassword({ required String email, required String newPassword, }) = ResetPassword; } // we support sign in directly without sign up, but we want to allow the users to sign up if they want to // this type is only for the UI to know which form to show enum LoginType { signIn, signUp, } @freezed class SignInState with _$SignInState { const factory SignInState({ String? email, String? password, required bool isSubmitting, required String? passwordError, required String? emailError, required FlowyResult? successOrFail, required FlowyResult? forgotPasswordSuccessOrFail, required FlowyResult? validateResetPasswordTokenSuccessOrFail, required FlowyResult? resetPasswordSuccessOrFail, @Default(LoginType.signIn) LoginType loginType, }) = _SignInState; factory SignInState.initial() => const SignInState( isSubmitting: false, passwordError: null, emailError: null, successOrFail: null, forgotPasswordSuccessOrFail: null, validateResetPasswordTokenSuccessOrFail: null, resetPasswordSuccessOrFail: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/sign_up_bloc.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'sign_up_bloc.freezed.dart'; class SignUpBloc extends Bloc { SignUpBloc(this.authService) : super(SignUpState.initial()) { _dispatch(); } final AuthService authService; void _dispatch() { on( (event, emit) async { await event.map( signUpWithUserEmailAndPassword: (e) async { await _performActionOnSignUp(emit); }, emailChanged: (_EmailChanged value) async { emit( state.copyWith( email: value.email, emailError: null, successOrFail: null, ), ); }, passwordChanged: (_PasswordChanged value) async { emit( state.copyWith( password: value.password, passwordError: null, successOrFail: null, ), ); }, repeatPasswordChanged: (_RepeatPasswordChanged value) async { emit( state.copyWith( repeatedPassword: value.password, repeatPasswordError: null, successOrFail: null, ), ); }, ); }, ); } Future _performActionOnSignUp(Emitter emit) async { emit( state.copyWith( isSubmitting: true, successOrFail: null, ), ); final password = state.password; final repeatedPassword = state.repeatedPassword; if (password == null) { emit( state.copyWith( isSubmitting: false, passwordError: LocaleKeys.signUp_emptyPasswordError.tr(), ), ); return; } if (repeatedPassword == null) { emit( state.copyWith( isSubmitting: false, repeatPasswordError: LocaleKeys.signUp_repeatPasswordEmptyError.tr(), ), ); return; } if (password != repeatedPassword) { emit( state.copyWith( isSubmitting: false, repeatPasswordError: LocaleKeys.signUp_unmatchedPasswordError.tr(), ), ); return; } emit( state.copyWith( passwordError: null, repeatPasswordError: null, ), ); final result = await authService.signUp( name: state.email ?? '', password: state.password ?? '', email: state.email ?? '', ); emit( result.fold( (profile) => state.copyWith( isSubmitting: false, successOrFail: FlowyResult.success(profile), emailError: null, passwordError: null, repeatPasswordError: null, ), (error) => stateFromCode(error), ), ); } SignUpState stateFromCode(FlowyError error) { switch (error.code) { case ErrorCode.EmailFormatInvalid: return state.copyWith( isSubmitting: false, emailError: error.msg, passwordError: null, successOrFail: null, ); case ErrorCode.PasswordFormatInvalid: return state.copyWith( isSubmitting: false, passwordError: error.msg, emailError: null, successOrFail: null, ); default: return state.copyWith( isSubmitting: false, successOrFail: FlowyResult.failure(error), ); } } } @freezed class SignUpEvent with _$SignUpEvent { const factory SignUpEvent.signUpWithUserEmailAndPassword() = SignUpWithUserEmailAndPassword; const factory SignUpEvent.emailChanged(String email) = _EmailChanged; const factory SignUpEvent.passwordChanged(String password) = _PasswordChanged; const factory SignUpEvent.repeatPasswordChanged(String password) = _RepeatPasswordChanged; } @freezed class SignUpState with _$SignUpState { const factory SignUpState({ String? email, String? password, String? repeatedPassword, required bool isSubmitting, required String? passwordError, required String? repeatPasswordError, required String? emailError, required FlowyResult? successOrFail, }) = _SignUpState; factory SignUpState.initial() => const SignUpState( isSubmitting: false, passwordError: null, repeatPasswordError: null, emailError: null, successOrFail: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/splash_bloc.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/domain/auth_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'splash_bloc.freezed.dart'; class SplashBloc extends Bloc { SplashBloc() : super(SplashState.initial()) { on((event, emit) async { await event.map( getUser: (val) async { final response = await getIt().getUser(); final authState = response.fold( (user) => AuthState.authenticated(user), (error) => AuthState.unauthenticated(error), ); emit(state.copyWith(auth: authState)); }, ); }); } } @freezed class SplashEvent with _$SplashEvent { const factory SplashEvent.getUser() = _GetUser; } @freezed class SplashState with _$SplashState { const factory SplashState({ required AuthState auth, }) = _SplashState; factory SplashState.initial() => const SplashState( auth: AuthState.initial(), ); } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/user_auth_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/user_notification.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/notification.pb.dart' as user; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; class UserAuthStateListener { void Function(String)? _onInvalidAuth; void Function()? _didSignIn; StreamSubscription? _subscription; UserNotificationParser? _userParser; void start({ void Function(String)? onInvalidAuth, void Function()? didSignIn, }) { _onInvalidAuth = onInvalidAuth; _didSignIn = didSignIn; _userParser = UserNotificationParser( id: "auth_state_change_notification", callback: _userNotificationCallback, ); _subscription = RustStreamReceiver.listen((observable) { _userParser?.parse(observable); }); } Future stop() async { _userParser = null; await _subscription?.cancel(); _onInvalidAuth = null; } void _userNotificationCallback( user.UserNotification ty, FlowyResult result, ) { switch (ty) { case user.UserNotification.UserAuthStateChanged: result.fold( (payload) { final pb = AuthStateChangedPB.fromBuffer(payload); switch (pb.state) { case AuthStatePB.AuthStateSignIn: _didSignIn?.call(); break; case AuthStatePB.InvalidAuth: _onInvalidAuth?.call(pb.message); break; default: break; } }, (r) => Log.error(r), ); break; default: break; } } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/user_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy/core/notification/user_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/notification.pb.dart' as user; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; import 'package:flutter/foundation.dart'; typedef DidUpdateUserWorkspaceCallback = void Function( UserWorkspacePB workspace, ); typedef DidUpdateUserWorkspacesCallback = void Function( RepeatedUserWorkspacePB workspaces, ); typedef UserProfileNotifyValue = FlowyResult; typedef DidUpdateUserWorkspaceSetting = void Function( WorkspaceSettingsPB settings, ); class UserListener { UserListener({ required UserProfilePB userProfile, }) : _userProfile = userProfile; final UserProfilePB _userProfile; UserNotificationParser? _userParser; StreamSubscription? _subscription; PublishNotifier? _profileNotifier = PublishNotifier(); /// Update notification about _all_ of the users workspaces /// DidUpdateUserWorkspacesCallback? onUserWorkspaceListUpdated; /// Update notification about _one_ workspace /// DidUpdateUserWorkspaceCallback? onUserWorkspaceUpdated; DidUpdateUserWorkspaceSetting? onUserWorkspaceSettingUpdated; DidUpdateUserWorkspaceCallback? onUserWorkspaceOpened; void start({ void Function(UserProfileNotifyValue)? onProfileUpdated, DidUpdateUserWorkspacesCallback? onUserWorkspaceListUpdated, void Function(UserWorkspacePB)? onUserWorkspaceUpdated, DidUpdateUserWorkspaceSetting? onUserWorkspaceSettingUpdated, }) { if (onProfileUpdated != null) { _profileNotifier?.addPublishListener(onProfileUpdated); } this.onUserWorkspaceListUpdated = onUserWorkspaceListUpdated; this.onUserWorkspaceUpdated = onUserWorkspaceUpdated; this.onUserWorkspaceSettingUpdated = onUserWorkspaceSettingUpdated; _userParser = UserNotificationParser( id: _userProfile.id.toString(), callback: _userNotificationCallback, ); _subscription = RustStreamReceiver.listen((observable) { _userParser?.parse(observable); }); } Future stop() async { _userParser = null; await _subscription?.cancel(); _profileNotifier?.dispose(); _profileNotifier = null; } void _userNotificationCallback( user.UserNotification ty, FlowyResult result, ) { switch (ty) { case user.UserNotification.DidUpdateUserProfile: result.fold( (payload) => _profileNotifier?.value = FlowyResult.success(UserProfilePB.fromBuffer(payload)), (error) => _profileNotifier?.value = FlowyResult.failure(error), ); break; case user.UserNotification.DidUpdateUserWorkspaces: result.map( (r) { final value = RepeatedUserWorkspacePB.fromBuffer(r); onUserWorkspaceListUpdated?.call(value); }, ); break; case user.UserNotification.DidUpdateUserWorkspace: result.map( (r) => onUserWorkspaceUpdated?.call(UserWorkspacePB.fromBuffer(r)), ); case user.UserNotification.DidUpdateWorkspaceSetting: result.map( (r) => onUserWorkspaceSettingUpdated ?.call(WorkspaceSettingsPB.fromBuffer(r)), ); break; case user.UserNotification.DidOpenWorkspace: result.fold( (payload) => _profileNotifier?.value = FlowyResult.success(UserProfilePB.fromBuffer(payload)), (error) => _profileNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } } typedef WorkspaceLatestNotifyValue = FlowyResult; class FolderListener { FolderListener({ required this.workspaceId, }); final String workspaceId; final PublishNotifier _latestChangedNotifier = PublishNotifier(); FolderNotificationListener? _listener; void start({ void Function(WorkspaceLatestNotifyValue)? onLatestUpdated, }) { if (onLatestUpdated != null) { _latestChangedNotifier.addPublishListener(onLatestUpdated); } _listener = FolderNotificationListener( objectId: workspaceId, handler: _handleObservableType, ); } void _handleObservableType( FolderNotification ty, FlowyResult result, ) { switch (ty) { case FolderNotification.DidUpdateWorkspaceSetting: result.fold( (payload) => _latestChangedNotifier.value = FlowyResult.success(WorkspaceLatestPB.fromBuffer(payload)), (error) => _latestChangedNotifier.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _latestChangedNotifier.dispose(); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/user_service.dart ================================================ import 'dart:async'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; abstract class IUserBackendService { Future> cancelSubscription( String workspaceId, SubscriptionPlanPB plan, String? reason, ); Future> createSubscription( String workspaceId, SubscriptionPlanPB plan, ); } const _baseBetaUrl = 'https://beta.appflowy.com'; const _baseProdUrl = 'https://appflowy.com'; class UserBackendService implements IUserBackendService { UserBackendService({required this.userId}); final Int64 userId; static Future> getCurrentUserProfile() async { final result = await UserEventGetUserProfile().send(); return result; } Future> updateUserProfile({ String? name, String? password, String? email, String? iconUrl, }) { final payload = UpdateUserProfilePayloadPB.create()..id = userId; if (name != null) { payload.name = name; } if (password != null) { payload.password = password; } if (email != null) { payload.email = email; } if (iconUrl != null) { payload.iconUrl = iconUrl; } return UserEventUpdateUserProfile(payload).send(); } Future> deleteWorkspace({ required String workspaceId, }) { throw UnimplementedError(); } static Future> signInWithMagicLink( String email, String redirectTo, ) async { final payload = MagicLinkSignInPB(email: email, redirectTo: redirectTo); return UserEventMagicLinkSignIn(payload).send(); } static Future> signInWithPasscode( String email, String passcode, ) async { final payload = PasscodeSignInPB(email: email, passcode: passcode); return UserEventPasscodeSignIn(payload).send(); } Future> signInWithPassword( String email, String password, ) { final payload = SignInPayloadPB( email: email, password: password, ); return UserEventSignInWithEmailPassword(payload).send(); } static Future> signOut() { return UserEventSignOut().send(); } Future> initUser() async { return UserEventInitUser().send(); } static Future> getAnonUser() async { return UserEventGetAnonUser().send(); } static Future> openAnonUser() async { return UserEventOpenAnonUser().send(); } Future, FlowyError>> getWorkspaces() { return UserEventGetAllWorkspace().send().then((value) { return value.fold( (workspaces) => FlowyResult.success(workspaces.items), (error) => FlowyResult.failure(error), ); }); } static Future> getWorkspaceById( String workspaceId, ) async { final result = await UserEventGetAllWorkspace().send(); return result.fold( (workspaces) { final workspace = workspaces.items.firstWhere( (workspace) => workspace.workspaceId == workspaceId, ); return FlowyResult.success(workspace); }, (error) => FlowyResult.failure(error), ); } Future> openWorkspace( String workspaceId, WorkspaceTypePB workspaceType, ) { final payload = OpenUserWorkspacePB() ..workspaceId = workspaceId ..workspaceType = workspaceType; return UserEventOpenWorkspace(payload).send(); } static Future> getCurrentWorkspace() { return FolderEventReadCurrentWorkspace().send().then((result) { return result.fold( (workspace) => FlowyResult.success(workspace), (error) => FlowyResult.failure(error), ); }); } Future> createUserWorkspace( String name, WorkspaceTypePB workspaceType, ) { final request = CreateWorkspacePB.create() ..name = name ..workspaceType = workspaceType; return UserEventCreateWorkspace(request).send(); } Future> deleteWorkspaceById( String workspaceId, ) { final request = UserWorkspaceIdPB.create()..workspaceId = workspaceId; return UserEventDeleteWorkspace(request).send(); } Future> renameWorkspace( String workspaceId, String name, ) { final request = RenameWorkspacePB() ..workspaceId = workspaceId ..newName = name; return UserEventRenameWorkspace(request).send(); } Future> updateWorkspaceIcon( String workspaceId, String icon, ) { final request = ChangeWorkspaceIconPB() ..workspaceId = workspaceId ..newIcon = icon; return UserEventChangeWorkspaceIcon(request).send(); } Future> getWorkspaceMembers( String workspaceId, ) async { final data = QueryWorkspacePB()..workspaceId = workspaceId; return UserEventGetWorkspaceMembers(data).send(); } Future> addWorkspaceMember( String workspaceId, String email, ) async { final data = AddWorkspaceMemberPB() ..workspaceId = workspaceId ..email = email; return UserEventAddWorkspaceMember(data).send(); } Future> inviteWorkspaceMember( String workspaceId, String email, { AFRolePB? role, }) async { final data = WorkspaceMemberInvitationPB() ..workspaceId = workspaceId ..inviteeEmail = email; if (role != null) { data.role = role; } return UserEventInviteWorkspaceMember(data).send(); } Future> removeWorkspaceMember( String workspaceId, String email, ) async { final data = RemoveWorkspaceMemberPB() ..workspaceId = workspaceId ..email = email; return UserEventRemoveWorkspaceMember(data).send(); } Future> updateWorkspaceMember( String workspaceId, String email, AFRolePB role, ) async { final data = UpdateWorkspaceMemberPB() ..workspaceId = workspaceId ..email = email ..role = role; return UserEventUpdateWorkspaceMember(data).send(); } Future> leaveWorkspace( String workspaceId, ) async { final data = UserWorkspaceIdPB.create()..workspaceId = workspaceId; return UserEventLeaveWorkspace(data).send(); } static Future> getWorkspaceSubscriptionInfo(String workspaceId) { final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId; return UserEventGetWorkspaceSubscriptionInfo(params).send(); } @override Future> createSubscription( String workspaceId, SubscriptionPlanPB plan, ) { final request = SubscribeWorkspacePB() ..workspaceId = workspaceId ..recurringInterval = RecurringIntervalPB.Year ..workspaceSubscriptionPlan = plan ..successUrl = '${kDebugMode ? _baseBetaUrl : _baseProdUrl}/after-payment?plan=${plan.toRecognizable()}'; return UserEventSubscribeWorkspace(request).send(); } @override Future> cancelSubscription( String workspaceId, SubscriptionPlanPB plan, [ String? reason, ]) { final request = CancelWorkspaceSubscriptionPB() ..workspaceId = workspaceId ..plan = plan; if (reason != null) { request.reason = reason; } return UserEventCancelWorkspaceSubscription(request).send(); } Future> updateSubscriptionPeriod( String workspaceId, SubscriptionPlanPB plan, RecurringIntervalPB interval, ) { final request = UpdateWorkspaceSubscriptionPaymentPeriodPB() ..workspaceId = workspaceId ..plan = plan ..recurringInterval = interval; return UserEventUpdateWorkspaceSubscriptionPaymentPeriod(request).send(); } // NOTE: This function is irreversible and will delete the current user's account. static Future> deleteCurrentAccount() { return UserEventDeleteAccount().send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/user_settings_service.dart ================================================ import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class UserSettingsBackendService { const UserSettingsBackendService(); Future getAppearanceSetting() async { final result = await UserEventGetAppearanceSetting().send(); return result.fold( (AppearanceSettingsPB setting) => setting, (error) => throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty), ); } Future> getUserSetting() { return UserEventGetUserSetting().send(); } Future> setAppearanceSetting( AppearanceSettingsPB setting, ) { return UserEventSetAppearanceSetting(setting).send(); } Future getDateTimeSettings() async { final result = await UserEventGetDateTimeSettings().send(); return result.fold( (DateTimeSettingsPB setting) => setting, (error) => throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty), ); } Future> setDateTimeSettings( DateTimeSettingsPB settings, ) async { return UserEventSetDateTimeSettings(settings).send(); } Future> setNotificationSettings( NotificationSettingsPB settings, ) async { return UserEventSetNotificationSettings(settings).send(); } Future getNotificationSettings() async { final result = await UserEventGetNotificationSettings().send(); return result.fold( (NotificationSettingsPB setting) => setting, (error) => throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart ================================================ import 'package:appflowy/plugins/database/application/defines.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'workspace_error_bloc.freezed.dart'; class WorkspaceErrorBloc extends Bloc { WorkspaceErrorBloc({required this.userFolder, required FlowyError error}) : super(WorkspaceErrorState.initial(error)) { _dispatch(); } final UserFolderPB userFolder; void _dispatch() { on( (event, emit) async { event.when( init: () { // _loadSnapshots(); }, didResetWorkspace: (result) { result.fold( (_) { emit( state.copyWith( loadingState: LoadingState.finish(result), workspaceState: const WorkspaceState.reset(), ), ); }, (err) { emit(state.copyWith(loadingState: LoadingState.finish(result))); }, ); }, logout: () { emit( state.copyWith( workspaceState: const WorkspaceState.logout(), ), ); }, ); }, ); } } @freezed class WorkspaceErrorEvent with _$WorkspaceErrorEvent { const factory WorkspaceErrorEvent.init() = _Init; const factory WorkspaceErrorEvent.logout() = _DidLogout; const factory WorkspaceErrorEvent.didResetWorkspace( FlowyResult result, ) = _DidResetWorkspace; } @freezed class WorkspaceErrorState with _$WorkspaceErrorState { const factory WorkspaceErrorState({ required FlowyError initialError, LoadingState? loadingState, required WorkspaceState workspaceState, }) = _WorkspaceErrorState; factory WorkspaceErrorState.initial(FlowyError error) => WorkspaceErrorState( initialError: error, workspaceState: const WorkspaceState.initial(), ); } @freezed class WorkspaceState with _$WorkspaceState { const factory WorkspaceState.initial() = _Initial; const factory WorkspaceState.logout() = _Logout; const factory WorkspaceState.reset() = _Reset; const factory WorkspaceState.createNewWorkspace() = _NewWorkspace; const factory WorkspaceState.restoreFromSnapshot() = _RestoreFromSnapshot; } ================================================ FILE: frontend/appflowy_flutter/lib/user/domain/auth_state.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'auth_state.freezed.dart'; @freezed class AuthState with _$AuthState { const factory AuthState.authenticated(UserProfilePB userProfile) = Authenticated; const factory AuthState.unauthenticated(FlowyError error) = Unauthenticated; const factory AuthState.initial() = _Initial; } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/anon_user.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/anon_user_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AnonUserList extends StatelessWidget { const AnonUserList({required this.didOpenUser, super.key}); final VoidCallback didOpenUser; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => AnonUserBloc() ..add( const AnonUserEvent.initial(), ), child: BlocBuilder( builder: (context, state) { if (state.anonUsers.isEmpty) { return const SizedBox.shrink(); } else { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Opacity( opacity: 0.6, child: FlowyText.regular( LocaleKeys.settings_menu_historicalUserListTooltip.tr(), fontSize: 13, maxLines: null, ), ), const VSpace(6), Expanded( child: ListView.builder( itemBuilder: (context, index) { final user = state.anonUsers[index]; return AnonUserItem( key: ValueKey(user.id), user: user, isSelected: false, didOpenUser: didOpenUser, ); }, itemCount: state.anonUsers.length, ), ), ], ); } }, ), ); } } class AnonUserItem extends StatelessWidget { const AnonUserItem({ super.key, required this.user, required this.isSelected, required this.didOpenUser, }); final UserProfilePB user; final bool isSelected; final VoidCallback didOpenUser; @override Widget build(BuildContext context) { final icon = isSelected ? const FlowySvg(FlowySvgs.check_s) : null; final isDisabled = isSelected || user.userAuthType != AuthTypePB.Local; final desc = "${user.name}\t ${user.userAuthType}\t"; final child = SizedBox( height: 30, child: FlowyButton( disable: isDisabled, text: FlowyText.medium( desc, fontSize: 12, ), rightIcon: icon, onTap: () { context.read().add(AnonUserEvent.openAnonUser(user)); didOpenUser(); }, ), ); return child; } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:flutter/material.dart'; void handleOpenWorkspaceError(BuildContext context, FlowyError error) { Log.error(error); switch (error.code) { case ErrorCode.WorkspaceDataNotSync: final userFolder = UserFolderPB.fromBuffer(error.payload); getIt().pushWorkspaceErrorScreen(context, userFolder, error); break; case ErrorCode.InvalidEncryptSecret: case ErrorCode.NetworkError: showToastNotification( message: error.msg, type: ToastificationType.error, ); break; default: showToastNotification( message: error.msg, type: ToastificationType.error, callbacks: ToastificationCallbacks( onDismissed: (_) { getIt().signOut(); runAppFlowy(); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart ================================================ export 'handle_open_workspace_error.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/presentation.dart ================================================ export 'screens/screens.dart'; export 'widgets/widgets.dart'; export 'anon_user.dart'; export 'router.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/router.dart ================================================ import 'package:appflowy/mobile/presentation/home/mobile_home_page.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/presentation/screens/screens.dart'; import 'package:appflowy/workspace/presentation/home/desktop_home_screen.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; class AuthRouter { void pushForgetPasswordScreen(BuildContext context) {} void pushWorkspaceStartScreen( BuildContext context, UserProfilePB userProfile, ) { getIt().pushWorkspaceStartScreen(context, userProfile); } /// Navigates to the home screen based on the current workspace and platform. /// /// This function takes in a [BuildContext] and a [UserProfilePB] object to /// determine the user's settings and then navigate to the appropriate home screen /// (`MobileHomeScreen` for mobile platforms, `DesktopHomeScreen` for others). /// /// It first fetches the current workspace settings using [FolderEventGetCurrentWorkspace]. /// If the workspace settings are successfully fetched, it navigates to the home screen. /// If there's an error, it defaults to the workspace start screen. /// /// @param [context] BuildContext for navigating to the appropriate screen. /// @param [userProfile] UserProfilePB object containing the details of the current user. /// Future goHomeScreen( BuildContext context, UserProfilePB userProfile, ) async { final result = await FolderEventGetCurrentWorkspaceSetting().send(); result.fold( (workspaceSetting) { // Replace SignInScreen or SkipLogInScreen as root page. // If user click back button, it will exit app rather than go back to SignInScreen or SkipLogInScreen if (UniversalPlatform.isMobile) { context.go( MobileHomeScreen.routeName, ); } else { context.go( DesktopHomeScreen.routeName, ); } }, (error) => pushWorkspaceStartScreen(context, userProfile), ); } Future pushWorkspaceErrorScreen( BuildContext context, UserFolderPB userFolder, FlowyError error, ) async { await context.push( WorkspaceErrorScreen.routeName, extra: { WorkspaceErrorScreen.argUserFolder: userFolder, WorkspaceErrorScreen.argError: error, }, ); } } class SplashRouter { // Unused for now, it was planed to be used in SignUpScreen. // To let user choose workspace than navigate to corresponding home screen. Future pushWorkspaceStartScreen( BuildContext context, UserProfilePB userProfile, ) async { await context.push( WorkspaceStartScreen.routeName, extra: { WorkspaceStartScreen.argUserProfile: userProfile, }, ); final result = await FolderEventGetCurrentWorkspaceSetting().send(); result.fold( (workspaceSettingPB) => pushHomeScreen(context), (r) => null, ); } void pushHomeScreen( BuildContext context, ) { if (UniversalPlatform.isMobile) { context.push( MobileHomeScreen.routeName, ); } else { context.push( DesktopHomeScreen.routeName, ); } } void goHomeScreen( BuildContext context, ) { if (UniversalPlatform.isMobile) { context.go( MobileHomeScreen.routeName, ); } else { context.go( DesktopHomeScreen.routeName, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart ================================================ export 'sign_in_screen/sign_in_screen.dart'; export 'skip_log_in_screen.dart'; export 'splash_screen.dart'; export 'workspace_error_screen.dart'; export 'workspace_start_screen/workspace_start_screen.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart ================================================ import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/settings/show_settings.dart'; import 'package:appflowy/shared/window_title_bar.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import 'package:window_manager/window_manager.dart'; class DesktopSignInScreen extends StatefulWidget { const DesktopSignInScreen({ super.key, }); @override State createState() => _DesktopSignInScreenState(); } class _DesktopSignInScreenState extends State with WindowListener { @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocBuilder( builder: (context, state) { final bottomPadding = UniversalPlatform.isDesktop ? 20.0 : 24.0; return Scaffold( appBar: _buildAppBar(), body: Center( child: AuthFormContainer( children: [ const Spacer(), // logo and title FlowyLogoTitle( title: LocaleKeys.welcomeText.tr(), logoSize: Size.square(36), ), VSpace(theme.spacing.xxl), // continue with email and password isLocalAuthEnabled ? const SignInAnonymousButtonV3() : const ContinueWithEmailAndPassword(), VSpace(theme.spacing.xxl), // third-party sign in. if (isAuthEnabled) ...[ const _OrDivider(), VSpace(theme.spacing.xxl), const ThirdPartySignInButtons(), VSpace(theme.spacing.xxl), ], // sign in agreement const SignInAgreement(), const Spacer(), // anonymous sign in and settings const Row( mainAxisSize: MainAxisSize.min, children: [ DesktopSignInSettingsButton(), HSpace(20), SignInAnonymousButtonV2(), ], ), VSpace(bottomPadding), ], ), ), ); }, ); } PreferredSize _buildAppBar() { return PreferredSize( preferredSize: Size.fromHeight(UniversalPlatform.isWindows ? 40 : 60), child: UniversalPlatform.isWindows ? const WindowTitleBar() : const MoveWindowDetector(), ); } @override void onWindowFocus() { // https://pub.dev/packages/window_manager#windows // must call setState once when the window is focused setState(() {}); } } class DesktopSignInSettingsButton extends StatelessWidget { const DesktopSignInSettingsButton({ super.key, }); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFGhostIconTextButton( text: LocaleKeys.signIn_settings.tr(), textColor: (context, isHovering, disabled) { return theme.textColorScheme.secondary; }, size: AFButtonSize.s, padding: EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.xs, ), onTap: () => showSimpleSettingsDialog(context), iconBuilder: (context, isHovering, disabled) { return FlowySvg( FlowySvgs.settings_s, size: Size.square(20), color: theme.textColorScheme.secondary, ); }, ); } } class _OrDivider extends StatelessWidget { const _OrDivider(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( children: [ Flexible( child: AFDivider(), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text( LocaleKeys.signIn_or.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ), ), ), Flexible( child: AFDivider(), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileLoadingScreen extends StatelessWidget { const MobileLoadingScreen({ super.key, }); @override Widget build(BuildContext context) { const double spacing = 16; return Scaffold( appBar: FlowyAppBar( showDivider: false, onTapLeading: () => context.read().add( const SignInEvent.cancel(), ), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ FlowyText(LocaleKeys.signIn_signingInText.tr()), const VSpace(spacing), const CircularProgressIndicator(), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart ================================================ import 'dart:io'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/user/presentation/widgets/flowy_logo_title.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class MobileSignInScreen extends StatelessWidget { const MobileSignInScreen({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final theme = AppFlowyTheme.of(context); return Scaffold( resizeToAvoidBottomInset: false, body: Padding( padding: const EdgeInsets.symmetric(vertical: 38, horizontal: 40), child: Column( children: [ const Spacer(), FlowyLogoTitle(title: LocaleKeys.welcomeText.tr()), VSpace(theme.spacing.xxl), isLocalAuthEnabled ? const SignInAnonymousButtonV3() : const ContinueWithEmailAndPassword(), VSpace(theme.spacing.xxl), if (isAuthEnabled) ...[ _buildThirdPartySignInButtons(context), VSpace(theme.spacing.xxl), ], const SignInAgreement(), const Spacer(), _buildSettingsButton(context), ], ), ), ); }, ); } Widget _buildThirdPartySignInButtons(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Expanded(child: Divider()), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( LocaleKeys.signIn_or.tr(), style: TextStyle( fontSize: 16, color: theme.textColorScheme.secondary, ), ), ), const Expanded(child: Divider()), ], ), const VSpace(16), // expand third-party sign in buttons on Android by default. // on iOS, the github and discord buttons are collapsed by default. ThirdPartySignInButtons( expanded: Platform.isAndroid, ), ], ); } Widget _buildSettingsButton(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( mainAxisSize: MainAxisSize.min, children: [ AFGhostIconTextButton( text: LocaleKeys.signIn_settings.tr(), textColor: (context, isHovering, disabled) { return theme.textColorScheme.secondary; }, size: AFButtonSize.s, padding: EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.xs, ), onTap: () => context.push(MobileLaunchSettingsPage.routeName), iconBuilder: (context, isHovering, disabled) { return FlowySvg( FlowySvgs.settings_s, size: Size.square(20), color: theme.textColorScheme.secondary, ); }, ), const HSpace(24), isLocalAuthEnabled ? const ChangeCloudModeButton() : const SignInAnonymousButtonV2(), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; class SignInScreen extends StatelessWidget { const SignInScreen({super.key}); static const routeName = '/SignInScreen'; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => getIt(), child: BlocConsumer( listener: _showSignInError, builder: (context, state) { return UniversalPlatform.isDesktop ? const DesktopSignInScreen() : const MobileSignInScreen(); }, ), ); } void _showSignInError(BuildContext context, SignInState state) { final successOrFail = state.successOrFail; if (successOrFail != null) { successOrFail.fold( (userProfile) { getIt().goHomeScreen(context, userProfile); }, (error) { Log.error('Sign in error: $error'); }, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button/anonymous_sign_in_button.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class AnonymousSignInButton extends StatelessWidget { const AnonymousSignInButton({super.key}); @override Widget build(BuildContext context) { return AFGhostButton.normal( onTap: () {}, builder: (context, isHovering, disabled) { return const Placeholder(); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/anon_user_bloc.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SignInAnonymousButtonV3 extends StatelessWidget { const SignInAnonymousButtonV3({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, signInState) { return BlocProvider( create: (context) => AnonUserBloc() ..add( const AnonUserEvent.initial(), ), child: BlocListener( listener: (context, state) async { if (state.openedAnonUser != null) { await runAppFlowy(); } }, child: BlocBuilder( builder: (context, state) { final text = LocaleKeys.signIn_continueWithLocalModel.tr(); final onTap = state.anonUsers.isEmpty ? () { context .read() .add(const SignInEvent.signInAsGuest()); } : () { final bloc = context.read(); final user = bloc.state.anonUsers.first; bloc.add(AnonUserEvent.openAnonUser(user)); }; return AFFilledTextButton.primary( text: text, size: AFButtonSize.l, alignment: Alignment.center, onTap: onTap, ); }, ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/back_to_login_in_button.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class BackToLoginButton extends StatelessWidget { const BackToLoginButton({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { return AFGhostTextButton( text: LocaleKeys.signIn_backToLogin.tr(), size: AFButtonSize.s, onTap: onTap, padding: EdgeInsets.zero, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (isHovering) { return theme.textColorScheme.actionHover; } return theme.textColorScheme.action; }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_button.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class ContinueWithButton extends StatelessWidget { const ContinueWithButton({ super.key, required this.onTap, required this.text, }); final VoidCallback onTap; final String text; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFFilledTextButton.primary( size: AFButtonSize.l, alignment: Alignment.center, text: text, onTap: onTap, textStyle: theme.textStyle.body.enhanced( color: theme.textColorScheme.onFill, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; class ContinueWithEmail extends StatelessWidget { const ContinueWithEmail({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { return AFFilledTextButton.primary( text: LocaleKeys.signIn_continueWithEmail.tr(), size: AFButtonSize.l, alignment: Alignment.center, onTap: onTap, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; class ContinueWithEmailAndPassword extends StatefulWidget { const ContinueWithEmailAndPassword({super.key}); @override State createState() => _ContinueWithEmailAndPasswordState(); } class _ContinueWithEmailAndPasswordState extends State { final controller = TextEditingController(); final focusNode = FocusNode(); final emailKey = GlobalKey(); bool _hasPushedContinueWithMagicLinkOrPasscodePage = false; @override void dispose() { controller.dispose(); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocListener( listener: (context, state) { final successOrFail = state.successOrFail; // only push the continue with magic link or passcode page if the magic link is sent successfully if (successOrFail != null) { successOrFail.fold( (_) => emailKey.currentState?.clearError(), (error) => emailKey.currentState?.syncError( errorText: error.msg, ), ); } else if (successOrFail == null && !state.isSubmitting) { emailKey.currentState?.clearError(); } }, child: Column( children: [ AFTextField( key: emailKey, controller: controller, hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), onSubmitted: (value) => _signInWithEmail( context, value, ), ), VSpace(theme.spacing.l), ContinueWithEmail( onTap: () => _signInWithEmail( context, controller.text, ), ), VSpace(theme.spacing.l), ContinueWithPassword( onTap: () { final email = controller.text; if (!isEmail(email)) { emailKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidEmail.tr(), ); return; } _pushContinueWithPasswordPage( context, email, ); }, ), ], ), ); } void _signInWithEmail(BuildContext context, String email) { if (!isEmail(email)) { emailKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidEmail.tr(), ); return; } context .read() .add(SignInEvent.signInWithMagicLink(email: email)); _pushContinueWithMagicLinkOrPasscodePage( context, email, ); } void _pushContinueWithMagicLinkOrPasscodePage( BuildContext context, String email, ) { if (_hasPushedContinueWithMagicLinkOrPasscodePage) { return; } final signInBloc = context.read(); // push the a continue with magic link or passcode screen Navigator.push( context, MaterialPageRoute( builder: (context) => BlocProvider.value( value: signInBloc, child: ContinueWithMagicLinkOrPasscodePage( email: email, backToLogin: () { Navigator.pop(context); emailKey.currentState?.clearError(); _hasPushedContinueWithMagicLinkOrPasscodePage = false; }, onEnterPasscode: (passcode) { signInBloc.add( SignInEvent.signInWithPasscode( email: email, passcode: passcode, ), ); }, ), ), ), ); _hasPushedContinueWithMagicLinkOrPasscodePage = true; } void _pushContinueWithPasswordPage( BuildContext context, String email, ) { final signInBloc = context.read(); Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/continue-with-password'), builder: (context) => BlocProvider.value( value: signInBloc, child: ContinueWithPasswordPage( email: email, backToLogin: () { emailKey.currentState?.clearError(); Navigator.pop(context); }, onEnterPassword: (password) => signInBloc.add( SignInEvent.signInWithEmailAndPassword( email: email, password: password, ), ), onForgotPassword: () { // todo: implement forgot password }, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/back_to_login_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/title_logo.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/verifying_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ContinueWithMagicLinkOrPasscodePage extends StatefulWidget { const ContinueWithMagicLinkOrPasscodePage({ super.key, required this.backToLogin, required this.email, required this.onEnterPasscode, }); final String email; final VoidCallback backToLogin; final ValueChanged onEnterPasscode; @override State createState() => _ContinueWithMagicLinkOrPasscodePageState(); } class _ContinueWithMagicLinkOrPasscodePageState extends State { final passcodeController = TextEditingController(); bool isEnteringPasscode = false; ToastificationItem? toastificationItem; final inputPasscodeKey = GlobalKey(); bool isSubmitting = false; @override void dispose() { passcodeController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocListener( listener: (context, state) { final successOrFail = state.successOrFail; if (successOrFail != null && successOrFail.isFailure) { successOrFail.onFailure((error) { inputPasscodeKey.currentState?.syncError( errorText: LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(), ); }); } if (state.isSubmitting != isSubmitting) { setState(() => isSubmitting = state.isSubmitting); } }, child: Scaffold( body: Center( child: SizedBox( width: 320, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Logo, title and description _buildLogoTitleAndDescription(), // Enter code manually ..._buildEnterCodeManually(), // Back to login BackToLoginButton( onTap: widget.backToLogin, ), ], ), ), ), ), ); } List _buildEnterCodeManually() { // todo: ask designer to provide the spacing final spacing = VSpace(20); if (!isEnteringPasscode) { return [ AFFilledTextButton.primary( text: LocaleKeys.signIn_enterCodeManually.tr(), onTap: () => setState(() => isEnteringPasscode = true), size: AFButtonSize.l, alignment: Alignment.center, ), spacing, ]; } return [ // Enter code manually AFTextField( key: inputPasscodeKey, controller: passcodeController, hintText: LocaleKeys.signIn_enterCode.tr(), keyboardType: TextInputType.number, autoFocus: true, onSubmitted: (passcode) { if (passcode.isEmpty) { inputPasscodeKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), ); } else { widget.onEnterPasscode(passcode); } }, ), // todo: ask designer to provide the spacing VSpace(12), // continue to login isSubmitting ? const VerifyingButton() : ContinueWithButton( text: LocaleKeys.signIn_continueWithLoginCode.tr(), onTap: () { final passcode = passcodeController.text; if (passcode.isEmpty) { inputPasscodeKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), ); } else { widget.onEnterPasscode(passcode); } }, ), spacing, ]; } Widget _buildLogoTitleAndDescription() { final theme = AppFlowyTheme.of(context); if (!isEnteringPasscode) { return TitleLogo( title: LocaleKeys.signIn_checkYourEmail.tr(), description: LocaleKeys.signIn_temporaryVerificationLinkSent.tr(), informationBuilder: (context) => Text( widget.email, style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), ); } else { return TitleLogo( title: LocaleKeys.signIn_enterCode.tr(), description: LocaleKeys.signIn_temporaryVerificationCodeSent.tr(), informationBuilder: (context) => Text( widget.email, style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), textAlign: TextAlign.center, ), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class ContinueWithPassword extends StatelessWidget { const ContinueWithPassword({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { return AFOutlinedTextButton.normal( text: LocaleKeys.signIn_continueWithPassword.tr(), size: AFButtonSize.l, alignment: Alignment.center, onTap: onTap, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/back_to_login_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/forgot_password_page.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/title_logo.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/verifying_button.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ContinueWithPasswordPage extends StatefulWidget { const ContinueWithPasswordPage({ super.key, required this.backToLogin, required this.email, required this.onEnterPassword, required this.onForgotPassword, }); final String email; final VoidCallback backToLogin; final ValueChanged onEnterPassword; final VoidCallback onForgotPassword; @override State createState() => _ContinueWithPasswordPageState(); } class _ContinueWithPasswordPageState extends State { final passwordController = TextEditingController(); final inputPasswordKey = GlobalKey(); bool isSubmitting = false; @override void dispose() { passwordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: SizedBox( width: 320, child: BlocListener( listener: (context, state) { final successOrFail = state.successOrFail; if (successOrFail != null && successOrFail.isFailure) { successOrFail.onFailure((error) { inputPasswordKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), ); }); } else if (state.passwordError != null) { inputPasswordKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), ); } else { inputPasswordKey.currentState?.clearError(); } if (isSubmitting != state.isSubmitting) { setState(() { isSubmitting = state.isSubmitting; }); } }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Logo and title _buildLogoAndTitle(), // Password input and buttons ..._buildPasswordSection(), // Back to login BackToLoginButton( onTap: widget.backToLogin, ), ], ), ), ), ), ); } Widget _buildLogoAndTitle() { final theme = AppFlowyTheme.of(context); return TitleLogo( title: LocaleKeys.signIn_enterPassword.tr(), informationBuilder: (context) => // email display RichText( text: TextSpan( children: [ TextSpan( text: LocaleKeys.signIn_loginAs.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), TextSpan( text: ' ${widget.email}', style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), ], ), ), ); } List _buildPasswordSection() { final theme = AppFlowyTheme.of(context); final iconSize = 20.0; return [ // Password input AFTextField( key: inputPasswordKey, controller: passwordController, hintText: LocaleKeys.signIn_enterPassword.tr(), autoFocus: true, obscureText: true, autofillHints: const [AutofillHints.password], suffixIconConstraints: BoxConstraints.tightFor( width: iconSize + theme.spacing.m, height: iconSize, ), suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( isObscured: isObscured, onTap: () { inputPasswordKey.currentState?.syncObscured(!isObscured); }, ), onSubmitted: widget.onEnterPassword, ), // todo: ask designer to provide the spacing VSpace(8), // Forgot password button Align( alignment: Alignment.centerLeft, child: AFGhostTextButton( text: LocaleKeys.signIn_forgotPassword.tr(), size: AFButtonSize.s, padding: EdgeInsets.zero, onTap: () => _pushForgotPasswordPage(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.action, ), textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (isHovering) { return theme.textColorScheme.actionHover; } return theme.textColorScheme.action; }, ), ), VSpace(theme.spacing.xxl), // Continue button isSubmitting ? const VerifyingButton() : ContinueWithButton( text: LocaleKeys.web_continue.tr(), onTap: () => widget.onEnterPassword(passwordController.text), ), VSpace(20), ]; } Future _pushForgotPasswordPage() async { final signInBloc = context.read(); final baseUrl = await getAppFlowyCloudUrl(); if (mounted && context.mounted) { await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/forgot-password'), builder: (context) => BlocProvider.value( value: signInBloc, child: ForgotPasswordPage( email: widget.email, backToLogin: widget.backToLogin, baseUrl: baseUrl, ), ), ), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/forgot_password_page.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/password/password_http_service.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/back_to_login_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/reset_password_page.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/title_logo.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/verifying_button.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; class ForgotPasswordPage extends StatefulWidget { const ForgotPasswordPage({ super.key, required this.backToLogin, required this.email, required this.baseUrl, }); final String email; final VoidCallback backToLogin; final String baseUrl; @override State createState() => _ForgotPasswordPageState(); } class _ForgotPasswordPageState extends State { final passwordController = TextEditingController(); final inputPasswordKey = GlobalKey(); bool isSubmitting = false; late final PasswordHttpService forgotPasswordService = PasswordHttpService( baseUrl: widget.baseUrl, // leave the auth token empty, the user is not signed in yet authToken: '', ); @override void initState() { super.initState(); passwordController.text = widget.email; } @override void dispose() { passwordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: SizedBox( width: 320, child: BlocListener( listener: (context, state) { final successOrFail = state.successOrFail; if (successOrFail != null && successOrFail.isFailure) { successOrFail.onFailure((error) { inputPasswordKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), ); }); } else if (state.passwordError != null) { inputPasswordKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidLoginCredentials.tr(), ); } else { inputPasswordKey.currentState?.clearError(); } if (isSubmitting != state.isSubmitting) { setState(() { isSubmitting = state.isSubmitting; }); } }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Logo and title _buildLogoAndTitle(), // Password input and buttons ..._buildPasswordSection(), // Back to login BackToLoginButton( onTap: widget.backToLogin, ), ], ), ), ), ), ); } Widget _buildLogoAndTitle() { return TitleLogo( title: LocaleKeys.signIn_resetPassword.tr(), description: LocaleKeys.signIn_resetPasswordDescription.tr(), ); } List _buildPasswordSection() { final theme = AppFlowyTheme.of(context); final iconSize = 20.0; return [ // Password input AFTextField( key: inputPasswordKey, controller: passwordController, hintText: LocaleKeys.signIn_enterPassword.tr(), autoFocus: true, suffixIconConstraints: BoxConstraints.tightFor( width: iconSize + theme.spacing.m, height: iconSize, ), onSubmitted: (_) => _onSubmit(), ), VSpace(theme.spacing.xxl), // Continue button isSubmitting ? const VerifyingButton() : ContinueWithButton( text: LocaleKeys.button_submit.tr(), onTap: _onSubmit, ), VSpace(theme.spacing.xxl), ]; } Future _onSubmit() async { final email = passwordController.text; if (!isEmail(email)) { inputPasswordKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidEmail.tr(), ); return; } final signInBloc = context.read(); setState(() { isSubmitting = true; }); final result = await forgotPasswordService.forgotPassword(email: email); setState(() { isSubmitting = false; }); result.fold( (success) { // push the email to the next screen Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/reset-password'), builder: (context) => BlocProvider.value( value: signInBloc, child: ResetPasswordPage( email: email, backToLogin: widget.backToLogin, baseUrl: widget.baseUrl, ), ), ), ); }, (error) { inputPasswordKey.currentState?.syncError( errorText: error.toString(), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/reset_password.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/back_to_login_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/title_logo.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/verifying_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ResetPasswordWidget extends StatefulWidget { const ResetPasswordWidget({ super.key, required this.backToLogin, required this.email, required this.baseUrl, required this.onValidateResetPasswordToken, }); final String email; final VoidCallback backToLogin; final String baseUrl; final ValueChanged onValidateResetPasswordToken; @override State createState() => _ResetPasswordWidgetState(); } class _ResetPasswordWidgetState extends State { final passcodeController = TextEditingController(); ToastificationItem? toastificationItem; final inputPasscodeKey = GlobalKey(); bool isSubmitting = false; @override void dispose() { passcodeController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocConsumer( listener: (context, state) { final successOrFail = state.validateResetPasswordTokenSuccessOrFail; if (successOrFail != null) { successOrFail.fold( (success) { widget.onValidateResetPasswordToken(true); }, (error) { widget.onValidateResetPasswordToken(false); inputPasscodeKey.currentState?.syncError( errorText: LocaleKeys.signIn_resetPasswordFailed.tr(), ); }, ); } if (state.isSubmitting != isSubmitting) { setState(() => isSubmitting = state.isSubmitting); } }, builder: (context, state) { return Scaffold( body: Center( child: SizedBox( width: 320, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Logo, title and description _buildLogoTitleAndDescription(), // Enter code manually ..._buildEnterCodeManually(), // Back to login BackToLoginButton( onTap: widget.backToLogin, ), ], ), ), ), ); }, ); } List _buildEnterCodeManually() { // todo: ask designer to provide the spacing final spacing = VSpace(20); return [ // Enter code manually AFTextField( key: inputPasscodeKey, controller: passcodeController, hintText: LocaleKeys.signIn_enterCode.tr(), keyboardType: TextInputType.number, autoFocus: true, onSubmitted: (passcode) { if (passcode.isEmpty) { inputPasscodeKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), ); } else { // } }, ), // todo: ask designer to provide the spacing VSpace(12), // continue to login isSubmitting ? const VerifyingButton() : ContinueWithButton( text: LocaleKeys.signIn_continueToResetPassword.tr(), onTap: () { final passcode = passcodeController.text; if (passcode.isEmpty) { inputPasscodeKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), ); } else { _onSubmit(); } }, ), spacing, ]; } Widget _buildLogoTitleAndDescription() { final theme = AppFlowyTheme.of(context); return TitleLogo( title: LocaleKeys.signIn_checkYourEmail.tr(), description: LocaleKeys.signIn_temporaryVerificationLinkSent.tr(), informationBuilder: (context) => Text( widget.email, style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), textAlign: TextAlign.center, ), ); } Future _onSubmit() async { final passcode = passcodeController.text; if (passcode.isEmpty) { inputPasscodeKey.currentState?.syncError( errorText: LocaleKeys.signIn_invalidVerificationCode.tr(), ); return; } context.read().add( ValidateResetPasswordToken( email: widget.email, token: passcode, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/reset_password_page.dart ================================================ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/reset_password.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/set_new_password.dart'; import 'package:flutter/material.dart'; enum ResetPasswordPageState { enterPasscode, setNewPassword, } class ResetPasswordPage extends StatefulWidget { const ResetPasswordPage({ super.key, required this.backToLogin, required this.email, required this.baseUrl, }); final String email; final VoidCallback backToLogin; final String baseUrl; @override State createState() => _ResetPasswordPageState(); } class _ResetPasswordPageState extends State { ResetPasswordPageState state = ResetPasswordPageState.enterPasscode; @override void dispose() { super.dispose(); } @override Widget build(BuildContext context) { return switch (state) { ResetPasswordPageState.enterPasscode => ResetPasswordWidget( email: widget.email, backToLogin: widget.backToLogin, baseUrl: widget.baseUrl, onValidateResetPasswordToken: (isValid) { setState(() { state = ResetPasswordPageState.setNewPassword; }); }, ), ResetPasswordPageState.setNewPassword => SetNewPasswordWidget( backToLogin: widget.backToLogin, email: widget.email, ), }; } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/set_new_password.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/back_to_login_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/title_logo.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/verifying_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SetNewPasswordWidget extends StatefulWidget { const SetNewPasswordWidget({ super.key, required this.backToLogin, required this.email, }); final String email; final VoidCallback backToLogin; @override State createState() => _SetNewPasswordWidgetState(); } class _SetNewPasswordWidgetState extends State { final newPasswordController = TextEditingController(); final confirmPasswordController = TextEditingController(); final newPasswordKey = GlobalKey(); final confirmPasswordKey = GlobalKey(); bool isSubmitting = false; @override void dispose() { newPasswordController.dispose(); confirmPasswordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final spacing = theme.spacing.xxl; return BlocConsumer( listener: (context, state) { final successOrFail = state.resetPasswordSuccessOrFail; if (successOrFail != null) { successOrFail.fold( (success) { showToastNotification( message: LocaleKeys.signIn_resetPasswordSuccess.tr(), ); // pop until the login screen is found Navigator.popUntil(context, (route) { return route.settings.name == '/continue-with-password'; }); }, (error) { if (error.code == ErrorCode.NewPasswordTooWeak) { newPasswordKey.currentState?.syncError( errorText: LocaleKeys.signIn_passwordMustContain.tr(), ); } else { newPasswordKey.currentState?.syncError( errorText: error.msg, ); } }, ); } // Handle state changes and validation results here if (state.isSubmitting != isSubmitting) { setState(() => isSubmitting = state.isSubmitting); } }, builder: (context, state) { return Scaffold( body: Center( child: SizedBox( width: 320, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildLogoAndTitle(), _buildPasswordFields(), VSpace(spacing), _buildResetButton(), VSpace(spacing), BackToLoginButton( onTap: widget.backToLogin, ), ], ), ), ), ); }, ); } Widget _buildLogoAndTitle() { final theme = AppFlowyTheme.of(context); return TitleLogo( title: LocaleKeys.signIn_resetPassword.tr(), informationBuilder: (context) => RichText( text: TextSpan( children: [ TextSpan( text: LocaleKeys.signIn_enterNewPasswordFor.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), TextSpan( text: widget.email, style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), ], ), textAlign: TextAlign.center, ), ); } Widget _buildPasswordFields() { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( LocaleKeys.signIn_newPassword.tr(), style: theme.textStyle.caption.enhanced( color: theme.textColorScheme.secondary, ), ), const VSpace(8), AFTextField( key: newPasswordKey, controller: newPasswordController, obscureText: true, autofillHints: const [AutofillHints.password], hintText: LocaleKeys.signIn_enterNewPassword.tr(), onSubmitted: (_) => _validateAndSubmit(), ), const VSpace(16), Text( LocaleKeys.signIn_confirmPassword.tr(), style: theme.textStyle.caption.enhanced( color: theme.textColorScheme.secondary, ), ), const VSpace(8), AFTextField( key: confirmPasswordKey, controller: confirmPasswordController, obscureText: true, autofillHints: const [AutofillHints.password], hintText: LocaleKeys.signIn_confirmNewPassword.tr(), onSubmitted: (_) => _validateAndSubmit(), ), ], ); } Widget _buildResetButton() { return isSubmitting ? const VerifyingButton() : ContinueWithButton( text: LocaleKeys.signIn_resetPassword.tr(), onTap: _validateAndSubmit, ); } void _validateAndSubmit() { final newPassword = newPasswordController.text; final confirmPassword = confirmPasswordController.text; if (newPassword.isEmpty) { newPasswordKey.currentState?.syncError( errorText: LocaleKeys.signIn_newPasswordCannotBeEmpty.tr(), ); return; } if (confirmPassword.isEmpty) { confirmPasswordKey.currentState?.syncError( errorText: LocaleKeys.signIn_confirmPasswordCannotBeEmpty.tr(), ); return; } if (newPassword != confirmPassword) { confirmPasswordKey.currentState?.syncError( errorText: LocaleKeys.signIn_passwordsDoNotMatch.tr(), ); return; } // Add the reset password event to the bloc context.read().add( ResetPassword( email: widget.email, newPassword: newPassword, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/title_logo.dart ================================================ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class TitleLogo extends StatelessWidget { const TitleLogo({ super.key, required this.title, this.description, this.informationBuilder, }); final String title; final String? description; final WidgetBuilder? informationBuilder; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final spacing = VSpace(theme.spacing.xxl); return Column( children: [ // logo const AFLogo(), spacing, // title Text( title, style: theme.textStyle.heading3.enhanced( color: theme.textColorScheme.primary, ), ), spacing, // description if (description != null) Text( description!, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), textAlign: TextAlign.center, ), if (informationBuilder != null) informationBuilder!(context), spacing, ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/verifying_button.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class VerifyingButton extends StatelessWidget { const VerifyingButton({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Opacity( opacity: 0.7, child: AFFilledButton.disabled( size: AFButtonSize.l, backgroundColor: theme.fillColorScheme.themeThick, builder: (context, isHovering, disabled) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox.square( dimension: 15.0, child: CircularProgressIndicator( color: theme.textColorScheme.onFill, strokeWidth: 3.0, ), ), HSpace(theme.spacing.l), Text( LocaleKeys.signIn_verifying.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.onFill, ), ), ], ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flutter/material.dart'; class AFLogo extends StatelessWidget { const AFLogo({ super.key, this.size = const Size.square(36), }); final Size size; @override Widget build(BuildContext context) { return FlowySvg( FlowySvgs.app_logo_xl, blendMode: null, size: size, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; class SignInWithMagicLinkButtons extends StatefulWidget { const SignInWithMagicLinkButtons({super.key}); @override State createState() => _SignInWithMagicLinkButtonsState(); } class _SignInWithMagicLinkButtonsState extends State { final controller = TextEditingController(); final FocusNode _focusNode = FocusNode(); @override void dispose() { controller.dispose(); _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: UniversalPlatform.isMobile ? 38.0 : 48.0, child: FlowyTextField( autoFocus: false, focusNode: _focusNode, controller: controller, borderRadius: BorderRadius.circular(4.0), hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 14.0, color: Theme.of(context).hintColor, ), textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( fontSize: 14.0, ), keyboardType: TextInputType.emailAddress, onSubmitted: (_) => _sendMagicLink(context, controller.text), onTapOutside: (_) => _focusNode.unfocus(), ), ), const VSpace(12), _ConfirmButton( onTap: () => _sendMagicLink(context, controller.text), ), ], ); } void _sendMagicLink(BuildContext context, String email) { if (!isEmail(email)) { showToastNotification( message: LocaleKeys.signIn_invalidEmail.tr(), type: ToastificationType.error, ); return; } context .read() .add(SignInEvent.signInWithMagicLink(email: email)); showConfirmDialog( context: context, title: LocaleKeys.signIn_magicLinkSent.tr(), description: LocaleKeys.signIn_magicLinkSentDescription.tr(), ); } } class _ConfirmButton extends StatelessWidget { const _ConfirmButton({ required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final name = switch (state.loginType) { LoginType.signIn => LocaleKeys.signIn_signInWithMagicLink.tr(), LoginType.signUp => LocaleKeys.signIn_signUpWithMagicLink.tr(), }; if (UniversalPlatform.isMobile) { return ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: const Size(double.infinity, 32), maximumSize: const Size(double.infinity, 38), ), onPressed: onTap, child: FlowyText( name, fontSize: 14, color: Theme.of(context).colorScheme.onPrimary, ), ); } else { return SizedBox( height: 48, child: FlowyButton( isSelected: true, onTap: onTap, hoverColor: Theme.of(context).colorScheme.primary, text: FlowyText.medium( name, textAlign: TextAlign.center, color: Theme.of(context).colorScheme.onPrimary, ), radius: Corners.s6Border, ), ); } }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; class SignInAgreement extends StatelessWidget { const SignInAgreement({ super.key, }); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final textStyle = theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ); final underlinedTextStyle = theme.textStyle.caption.underline( color: theme.textColorScheme.secondary, ); return RichText( textAlign: TextAlign.center, text: TextSpan( children: [ TextSpan( text: LocaleKeys.web_signInAgreement.tr(), style: textStyle, ), TextSpan( text: '${LocaleKeys.web_termOfUse.tr()} ', style: underlinedTextStyle, mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = () => afLaunchUrlString('https://appflowy.com/terms'), ), TextSpan( text: '${LocaleKeys.web_and.tr()} ', style: textStyle, ), TextSpan( text: LocaleKeys.web_privacyPolicy.tr(), style: underlinedTextStyle, mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = () => afLaunchUrlString('https://appflowy.com/privacy'), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/anon_user_bloc.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SignInAnonymousButtonV2 extends StatelessWidget { const SignInAnonymousButtonV2({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, signInState) { return BlocProvider( create: (context) => AnonUserBloc() ..add( const AnonUserEvent.initial(), ), child: BlocListener( listener: (context, state) async { if (state.openedAnonUser != null) { await runAppFlowy(); } }, child: BlocBuilder( builder: (context, state) { final theme = AppFlowyTheme.of(context); final onTap = state.anonUsers.isEmpty ? () { context .read() .add(const SignInEvent.signInAsGuest()); } : () { final bloc = context.read(); final user = bloc.state.anonUsers.first; bloc.add(AnonUserEvent.openAnonUser(user)); }; return AFGhostIconTextButton( text: LocaleKeys.signIn_anonymousMode.tr(), textColor: (context, isHovering, disabled) { return theme.textColorScheme.secondary; }, padding: EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.xs, ), size: AFButtonSize.s, onTap: onTap, iconBuilder: (context, isHovering, disabled) { return FlowySvg( FlowySvgs.anonymous_mode_m, color: theme.textColorScheme.secondary, ); }, ); }, ), ), ); }, ); } } class ChangeCloudModeButton extends StatelessWidget { const ChangeCloudModeButton({ super.key, }); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFGhostIconTextButton( text: LocaleKeys.signIn_switchToAppFlowyCloud.tr(), textColor: (context, isHovering, disabled) { return theme.textColorScheme.secondary; }, size: AFButtonSize.s, padding: EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.xs, ), onTap: () async { await useAppFlowyBetaCloudWithURL( kAppflowyCloudUrl, AuthenticatorType.appflowyCloud, ); await runAppFlowy(); }, iconBuilder: (context, isHovering, disabled) { return FlowySvg( FlowySvgs.cloud_mode_m, size: Size.square(20), color: theme.textColorScheme.secondary, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class MobileLogoutButton extends StatelessWidget { const MobileLogoutButton({ super.key, required this.text, this.textColor, required this.onPressed, }); final String text; final Color? textColor; final VoidCallback onPressed; @override Widget build(BuildContext context) { return AFOutlinedTextButton.normal( alignment: Alignment.center, text: text, onTap: onPressed, size: AFButtonSize.l, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/switch_sign_in_sign_up_button.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SwitchSignInSignUpButton extends StatelessWidget { const SwitchSignInSignUpButton({ super.key, required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: onTap, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ FlowyText( switch (state.loginType) { LoginType.signIn => LocaleKeys.signIn_dontHaveAnAccount.tr(), LoginType.signUp => LocaleKeys.signIn_alreadyHaveAnAccount.tr(), }, fontSize: 12, ), const HSpace(4), FlowyText( switch (state.loginType) { LoginType.signIn => LocaleKeys.signIn_createAccount.tr(), LoginType.signUp => LocaleKeys.signIn_logIn.tr(), }, color: Colors.blue, fontSize: 12, ), ], ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum ThirdPartySignInButtonType { apple, google, github, discord, anonymous; String get provider { switch (this) { case ThirdPartySignInButtonType.apple: return 'apple'; case ThirdPartySignInButtonType.google: return 'google'; case ThirdPartySignInButtonType.github: return 'github'; case ThirdPartySignInButtonType.discord: return 'discord'; case ThirdPartySignInButtonType.anonymous: throw UnsupportedError('Anonymous session does not have a provider'); } } FlowySvgData get icon { switch (this) { case ThirdPartySignInButtonType.apple: return FlowySvgs.m_apple_icon_xl; case ThirdPartySignInButtonType.google: return FlowySvgs.m_google_icon_xl; case ThirdPartySignInButtonType.github: return FlowySvgs.m_github_icon_xl; case ThirdPartySignInButtonType.discord: return FlowySvgs.m_discord_icon_xl; case ThirdPartySignInButtonType.anonymous: return FlowySvgs.m_discord_icon_xl; } } String get labelText { switch (this) { case ThirdPartySignInButtonType.apple: return LocaleKeys.signIn_signInWithApple.tr(); case ThirdPartySignInButtonType.google: return LocaleKeys.signIn_signInWithGoogle.tr(); case ThirdPartySignInButtonType.github: return LocaleKeys.signIn_signInWithGithub.tr(); case ThirdPartySignInButtonType.discord: return LocaleKeys.signIn_signInWithDiscord.tr(); case ThirdPartySignInButtonType.anonymous: return 'Anonymous session'; } } // https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple Color backgroundColor(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; switch (this) { case ThirdPartySignInButtonType.apple: return isDarkMode ? Colors.white : Colors.black; case ThirdPartySignInButtonType.google: case ThirdPartySignInButtonType.github: case ThirdPartySignInButtonType.discord: case ThirdPartySignInButtonType.anonymous: return isDarkMode ? Colors.black : Colors.grey.shade100; } } Color textColor(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; switch (this) { case ThirdPartySignInButtonType.apple: return isDarkMode ? Colors.black : Colors.white; case ThirdPartySignInButtonType.google: case ThirdPartySignInButtonType.github: case ThirdPartySignInButtonType.discord: case ThirdPartySignInButtonType.anonymous: return isDarkMode ? Colors.white : Colors.black; } } BlendMode? get blendMode { switch (this) { case ThirdPartySignInButtonType.apple: case ThirdPartySignInButtonType.github: return BlendMode.srcIn; default: return null; } } } class MobileThirdPartySignInButton extends StatelessWidget { const MobileThirdPartySignInButton({ super.key, this.height = 38, this.fontSize = 14.0, required this.onTap, required this.type, }); final VoidCallback onTap; final double height; final double fontSize; final ThirdPartySignInButtonType type; @override Widget build(BuildContext context) { return AFOutlinedIconTextButton.normal( text: type.labelText, onTap: onTap, size: AFButtonSize.l, iconBuilder: (context, isHovering, disabled) { return FlowySvg( type.icon, size: Size.square(16), blendMode: type.blendMode, ); }, ); } } class DesktopThirdPartySignInButton extends StatelessWidget { const DesktopThirdPartySignInButton({ super.key, required this.type, required this.onTap, }); final ThirdPartySignInButtonType type; final VoidCallback onTap; @override Widget build(BuildContext context) { return AFOutlinedIconTextButton.normal( text: type.labelText, onTap: onTap, size: AFButtonSize.l, iconBuilder: (context, isHovering, disabled) { return FlowySvg( type.icon, size: Size.square(18), blendMode: type.blendMode, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart ================================================ import 'dart:io'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import 'third_party_sign_in_button.dart'; typedef _SignInCallback = void Function(ThirdPartySignInButtonType signInType); @visibleForTesting const Key signInWithGoogleButtonKey = Key('signInWithGoogleButton'); class ThirdPartySignInButtons extends StatelessWidget { /// Used in DesktopSignInScreen, MobileSignInScreen and SettingThirdPartyLogin const ThirdPartySignInButtons({ super.key, this.expanded = false, }); final bool expanded; @override Widget build(BuildContext context) { if (UniversalPlatform.isDesktopOrWeb) { return _DesktopThirdPartySignIn( onSignIn: (type) => _signIn(context, type.provider), ); } else { return _MobileThirdPartySignIn( isExpanded: expanded, onSignIn: (type) => _signIn(context, type.provider), ); } } void _signIn(BuildContext context, String provider) { context.read().add( SignInEvent.signInWithOAuth(platform: provider), ); } } class _DesktopThirdPartySignIn extends StatefulWidget { const _DesktopThirdPartySignIn({ required this.onSignIn, }); final _SignInCallback onSignIn; @override State<_DesktopThirdPartySignIn> createState() => _DesktopThirdPartySignInState(); } class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { bool isExpanded = false; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( children: [ DesktopThirdPartySignInButton( key: signInWithGoogleButtonKey, type: ThirdPartySignInButtonType.google, onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google), ), VSpace(theme.spacing.l), DesktopThirdPartySignInButton( type: ThirdPartySignInButtonType.apple, onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple), ), ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), ], ); } List _buildExpandedButtons() { final theme = AppFlowyTheme.of(context); return [ VSpace(theme.spacing.l), DesktopThirdPartySignInButton( type: ThirdPartySignInButtonType.github, onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github), ), VSpace(theme.spacing.l), DesktopThirdPartySignInButton( type: ThirdPartySignInButtonType.discord, onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord), ), ]; } List _buildCollapsedButtons() { final theme = AppFlowyTheme.of(context); return [ VSpace(theme.spacing.l), AFGhostTextButton( text: 'More options', padding: EdgeInsets.zero, textColor: (context, isHovering, disabled) { if (isHovering) { return theme.textColorScheme.actionHover; } return theme.textColorScheme.action; }, onTap: () { setState(() { isExpanded = !isExpanded; }); }, ), ]; } } class _MobileThirdPartySignIn extends StatefulWidget { const _MobileThirdPartySignIn({ required this.isExpanded, required this.onSignIn, }); final bool isExpanded; final _SignInCallback onSignIn; @override State<_MobileThirdPartySignIn> createState() => _MobileThirdPartySignInState(); } class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> { static const padding = 8.0; bool isExpanded = false; @override void initState() { super.initState(); isExpanded = widget.isExpanded; } @override Widget build(BuildContext context) { return Column( children: [ // only display apple sign in button on iOS if (Platform.isIOS) ...[ MobileThirdPartySignInButton( type: ThirdPartySignInButtonType.apple, onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple), ), const VSpace(padding), ], MobileThirdPartySignInButton( key: signInWithGoogleButtonKey, type: ThirdPartySignInButtonType.google, onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google), ), ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), ], ); } List _buildExpandedButtons() { return [ const VSpace(padding), MobileThirdPartySignInButton( type: ThirdPartySignInButtonType.github, onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github), ), const VSpace(padding), MobileThirdPartySignInButton( type: ThirdPartySignInButtonType.discord, onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord), ), ]; } List _buildCollapsedButtons() { final theme = AppFlowyTheme.of(context); return [ const VSpace(padding * 2), AFGhostTextButton( text: 'More options', textColor: (context, isHovering, disabled) { if (isHovering) { return theme.textColorScheme.actionHover; } return theme.textColorScheme.action; }, onTap: () { setState(() { isExpanded = !isExpanded; }); }, ), ]; } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart ================================================ export 'continue_with/continue_with_email_and_password.dart'; export 'sign_in_agreement.dart'; export 'sign_in_anonymous_button.dart'; export 'sign_in_or_logout_button.dart'; export 'third_party_sign_in_button/third_party_sign_in_button.dart'; // export 'switch_sign_in_sign_up_button.dart'; export 'third_party_sign_in_button/third_party_sign_in_buttons.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart ================================================ import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/anon_user_bloc.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/language.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; class SkipLogInScreen extends StatefulWidget { const SkipLogInScreen({super.key}); static const routeName = '/SkipLogInScreen'; @override State createState() => _SkipLogInScreenState(); } class _SkipLogInScreenState extends State { var _didCustomizeFolder = false; @override Widget build(BuildContext context) { return Scaffold( appBar: const _SkipLoginMoveWindow(), body: Center(child: _renderBody(context)), ); } Widget _renderBody(BuildContext context) { final size = MediaQuery.of(context).size; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Spacer(), FlowyLogoTitle( title: LocaleKeys.welcomeText.tr(), logoSize: Size.square(UniversalPlatform.isMobile ? 80 : 40), ), const VSpace(32), GoButton( onPressed: () { if (_didCustomizeFolder) { _relaunchAppAndAutoRegister(); } else { _autoRegister(context); } }, ), // if (Env.enableCustomCloud) ...[ // const VSpace(10), // const SizedBox( // width: 340, // child: _SetupYourServer(), // ), // ], const VSpace(32), SizedBox( width: size.width * 0.7, child: FolderWidget( createFolderCallback: () async => _didCustomizeFolder = true, ), ), const Spacer(), const SkipLoginPageFooter(), const VSpace(20), ], ); } Future _autoRegister(BuildContext context) async { final result = await getIt().signUpAsGuest(); result.fold( (user) => getIt().goHomeScreen(context, user), (error) => Log.error(error), ); } Future _relaunchAppAndAutoRegister() async => runAppFlowy(isAnon: true); } class SkipLoginPageFooter extends StatelessWidget { const SkipLoginPageFooter({super.key}); @override Widget build(BuildContext context) { // The placeholderWidth should be greater than the longest width of the LanguageSelectorOnWelcomePage const double placeholderWidth = 180; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (!UniversalPlatform.isMobile) const HSpace(placeholderWidth), const Expanded(child: SubscribeButtons()), const SizedBox( width: placeholderWidth, height: 28, child: Row( children: [ Spacer(), LanguageSelectorOnWelcomePage(), ], ), ), ], ), ); } } class SubscribeButtons extends StatelessWidget { const SubscribeButtons({super.key}); @override Widget build(BuildContext context) { return Wrap( alignment: WrapAlignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ FlowyText.regular( LocaleKeys.youCanAlso.tr(), fontSize: FontSizes.s12, ), FlowyTextButton( LocaleKeys.githubStarText.tr(), padding: const EdgeInsets.symmetric(horizontal: 4), fontWeight: FontWeight.w500, fontColor: Theme.of(context).colorScheme.primary, hoverColor: Colors.transparent, fillColor: Colors.transparent, onPressed: () => afLaunchUrlString('https://github.com/AppFlowy-IO/appflowy'), ), ], ), Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ FlowyText.regular(LocaleKeys.and.tr(), fontSize: FontSizes.s12), FlowyTextButton( LocaleKeys.subscribeNewsletterText.tr(), padding: const EdgeInsets.symmetric(horizontal: 4.0), fontWeight: FontWeight.w500, fontColor: Theme.of(context).colorScheme.primary, hoverColor: Colors.transparent, fillColor: Colors.transparent, onPressed: () => afLaunchUrlString('https://www.appflowy.io/blog'), ), ], ), ], ); } } class LanguageSelectorOnWelcomePage extends StatelessWidget { const LanguageSelectorOnWelcomePage({super.key}); @override Widget build(BuildContext context) { return AppFlowyPopover( offset: const Offset(0, -450), direction: PopoverDirection.bottomWithRightAligned, child: FlowyButton( useIntrinsicWidth: true, text: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ const FlowySvg(FlowySvgs.ethernet_m, size: Size.square(20)), const HSpace(4), Builder( builder: (context) { final currentLocale = context.watch().state.locale; return FlowyText(languageFromLocale(currentLocale)); }, ), const FlowySvg(FlowySvgs.drop_menu_hide_m, size: Size.square(20)), ], ), ), popupBuilder: (BuildContext context) { final easyLocalization = EasyLocalization.of(context); if (easyLocalization == null) { return const SizedBox.shrink(); } return LanguageItemsListView( allLocales: easyLocalization.supportedLocales, ); }, ); } } class LanguageItemsListView extends StatelessWidget { const LanguageItemsListView({super.key, required this.allLocales}); final List allLocales; @override Widget build(BuildContext context) { // get current locale from cubit final state = context.watch().state; return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 400), child: ListView.builder( itemCount: allLocales.length, itemBuilder: (context, index) { final locale = allLocales[index]; return LanguageItem(locale: locale, currentLocale: state.locale); }, ), ); } } class LanguageItem extends StatelessWidget { const LanguageItem({ super.key, required this.locale, required this.currentLocale, }); final Locale locale; final Locale currentLocale; @override Widget build(BuildContext context) { return SizedBox( height: 32, child: FlowyButton( text: FlowyText.medium( languageFromLocale(locale), ), rightIcon: currentLocale == locale ? const FlowySvg(FlowySvgs.check_s) : null, onTap: () { if (currentLocale != locale) { context.read().setLocale(context, locale); } PopoverContainer.of(context).close(); }, ), ); } } class GoButton extends StatelessWidget { const GoButton({super.key, required this.onPressed}); final VoidCallback onPressed; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => AnonUserBloc()..add(const AnonUserEvent.initial()), child: BlocListener( listener: (context, state) async { if (state.openedAnonUser != null) { await runAppFlowy(); } }, child: BlocBuilder( builder: (context, state) { final text = state.anonUsers.isEmpty ? LocaleKeys.letsGoButtonText.tr() : LocaleKeys.signIn_continueAnonymousUser.tr(); final textWidget = Row( children: [ Expanded( child: FlowyText.medium( text, textAlign: TextAlign.center, fontSize: 14, ), ), ], ); return SizedBox( width: 340, height: 48, child: FlowyButton( isSelected: true, text: textWidget, radius: Corners.s6Border, onTap: () { if (state.anonUsers.isNotEmpty) { final bloc = context.read(); final historicalUser = state.anonUsers.first; bloc.add( AnonUserEvent.openAnonUser(historicalUser), ); } else { onPressed(); } }, ), ); }, ), ), ); } } class _SkipLoginMoveWindow extends StatelessWidget implements PreferredSizeWidget { const _SkipLoginMoveWindow(); @override Widget build(BuildContext context) => const Row(children: [Expanded(child: MoveWindowDetector())]); @override Size get preferredSize => const Size.fromHeight(55.0); } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/splash_bloc.dart'; import 'package:appflowy/user/domain/auth_state.dart'; import 'package:appflowy/user/presentation/helpers/helpers.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/screens/screens.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; class SplashScreen extends StatelessWidget { /// Root Page of the app. const SplashScreen({super.key, required this.isAnon}); final bool isAnon; @override Widget build(BuildContext context) { if (isAnon) { return FutureBuilder( future: _registerIfNeeded(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const SizedBox.shrink(); } return _buildChild(context); }, ); } else { return _buildChild(context); } } BlocProvider _buildChild(BuildContext context) { return BlocProvider( create: (context) => getIt()..add(const SplashEvent.getUser()), child: Scaffold( body: BlocListener( listener: (context, state) { state.auth.map( authenticated: (r) => _handleAuthenticated(context, r), unauthenticated: (r) => _handleUnauthenticated(context, r), initial: (r) => {}, ); }, child: const Body(), ), ), ); } /// Handles the authentication flow once a user is authenticated. Future _handleAuthenticated( BuildContext context, Authenticated authenticated, ) async { final result = await FolderEventGetCurrentWorkspaceSetting().send(); result.fold( (workspaceSetting) { // After login, replace Splash screen by corresponding home screen getIt().goHomeScreen( context, ); }, (error) => handleOpenWorkspaceError(context, error), ); } void _handleUnauthenticated(BuildContext context, Unauthenticated result) { // replace Splash screen as root page if (isAuthEnabled || UniversalPlatform.isMobile) { context.go(SignInScreen.routeName); } else { // if the env is not configured, we will skip to the 'skip login screen'. context.go(SkipLogInScreen.routeName); } } Future _registerIfNeeded() async { final result = await UserEventGetUserProfile().send(); if (result.isFailure) { await getIt().signUpAsGuest(); } } } class Body extends StatelessWidget { const Body({super.key}); @override Widget build(BuildContext context) { return Container( alignment: Alignment.center, child: UniversalPlatform.isMobile ? const FlowySvg(FlowySvgs.app_logo_xl, blendMode: null) : const _DesktopSplashBody(), ); } } class _DesktopSplashBody extends StatelessWidget { const _DesktopSplashBody(); @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; return SingleChildScrollView( child: Stack( alignment: Alignment.center, children: [ Image( fit: BoxFit.cover, width: size.width, height: size.height, image: const AssetImage( 'assets/images/appflowy_launch_splash.jpg', ), ), const CircularProgressIndicator.adaptive(), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../application/workspace_error_bloc.dart'; class WorkspaceErrorScreen extends StatelessWidget { const WorkspaceErrorScreen({ super.key, required this.userFolder, required this.error, }); final UserFolderPB userFolder; final FlowyError error; static const routeName = "/WorkspaceErrorScreen"; // arguments names to used in GoRouter static const argError = "error"; static const argUserFolder = "userFolder"; @override Widget build(BuildContext context) { return Scaffold( extendBody: true, body: BlocProvider( create: (context) => WorkspaceErrorBloc( userFolder: userFolder, error: error, )..add(const WorkspaceErrorEvent.init()), child: MultiBlocListener( listeners: [ BlocListener( listenWhen: (previous, current) => previous.workspaceState != current.workspaceState, listener: (context, state) async { await state.workspaceState.when( initial: () {}, logout: () async { await getIt().signOut(); await runAppFlowy(); }, reset: () async { await getIt().signOut(); await runAppFlowy(); }, restoreFromSnapshot: () {}, createNewWorkspace: () {}, ); }, ), BlocListener( listenWhen: (previous, current) => previous.loadingState != current.loadingState, listener: (context, state) async { state.loadingState?.when( loading: () {}, finish: (error) { error.fold( (_) {}, (err) { showSnapBar(context, err.msg); }, ); }, idle: () {}, ); }, ), ], child: BlocBuilder( builder: (context, state) { final List children = [ WorkspaceErrorDescription(error: error), ]; children.addAll([ const VSpace(50), const LogoutButton(), const VSpace(20), ]); return Center( child: SizedBox( width: 500, child: IntrinsicHeight( child: Column( children: children, ), ), ), ); }, ), ), ), ); } } class WorkspaceErrorDescription extends StatelessWidget { const WorkspaceErrorDescription({super.key, required this.error}); final FlowyError error; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.medium( state.initialError.msg.toString(), fontSize: 14, maxLines: 10, ), FlowyText.medium( "Error code: ${state.initialError.code.value.toString()}", fontSize: 12, ), ], ); }, ); } } class LogoutButton extends StatelessWidget { const LogoutButton({super.key}); @override Widget build(BuildContext context) { return SizedBox( height: 40, width: 200, child: FlowyButton( text: FlowyText.medium( LocaleKeys.settings_menu_logout.tr(), textAlign: TextAlign.center, ), onTap: () async { context.read().add( const WorkspaceErrorEvent.logout(), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/workspace/application/workspace/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class DesktopWorkspaceStartScreen extends StatelessWidget { const DesktopWorkspaceStartScreen({super.key, required this.workspaceState}); final WorkspaceState workspaceState; @override Widget build(BuildContext context) { return Scaffold( body: Padding( padding: const EdgeInsets.all(60.0), child: Column( children: [ _renderBody(workspaceState), _renderCreateButton(context), ], ), ), ); } } Widget _renderBody(WorkspaceState state) { final body = state.successOrFailure.fold( (_) => _renderList(state.workspaces), (error) => Center( child: AppFlowyErrorPage( error: error, ), ), ); return body; } Widget _renderList(List workspaces) { return Expanded( child: StyledListView( itemBuilder: (BuildContext context, int index) { final workspace = workspaces[index]; return _WorkspaceItem( workspace: workspace, onPressed: (workspace) => _popToWorkspace(context, workspace), ); }, itemCount: workspaces.length, ), ); } class _WorkspaceItem extends StatelessWidget { const _WorkspaceItem({ required this.workspace, required this.onPressed, }); final WorkspacePB workspace; final void Function(WorkspacePB workspace) onPressed; @override Widget build(BuildContext context) { return SizedBox( height: 46, child: FlowyTextButton( workspace.name, hoverColor: AFThemeExtension.of(context).lightGreyHover, fontSize: 14, onPressed: () => onPressed(workspace), ), ); } } Widget _renderCreateButton(BuildContext context) { return SizedBox( width: 200, height: 40, child: FlowyTextButton( LocaleKeys.workspace_create.tr(), fontSize: 14, hoverColor: AFThemeExtension.of(context).lightGreyHover, onPressed: () { // same method as in mobile context.read().add( WorkspaceEvent.createWorkspace( LocaleKeys.workspace_hint.tr(), "", ), ); }, ), ); } // same method as in mobile void _popToWorkspace(BuildContext context, WorkspacePB workspace) { context.pop(workspace.id); } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/workspace/application/workspace/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; // TODO: needs refactor when multiple workspaces are supported class MobileWorkspaceStartScreen extends StatefulWidget { const MobileWorkspaceStartScreen({ super.key, required this.workspaceState, }); @override State createState() => _MobileWorkspaceStartScreenState(); final WorkspaceState workspaceState; } class _MobileWorkspaceStartScreenState extends State { WorkspacePB? selectedWorkspace; final TextEditingController controller = TextEditingController(); @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final style = Theme.of(context); final size = MediaQuery.of(context).size; const double spacing = 16.0; final List> workspaceEntries = >[]; for (final WorkspacePB workspace in widget.workspaceState.workspaces) { workspaceEntries.add( DropdownMenuEntry( value: workspace, label: workspace.name, ), ); } // render the workspace dropdown menu if success, otherwise render error page final body = widget.workspaceState.successOrFailure.fold( (_) { return Padding( padding: const EdgeInsets.fromLTRB(50, 0, 50, 30), child: Column( children: [ const Spacer(), const FlowySvg( FlowySvgs.app_logo_xl, size: Size.square(64), blendMode: null, ), const VSpace(spacing * 2), Text( LocaleKeys.workspace_chooseWorkspace.tr(), style: style.textTheme.displaySmall, textAlign: TextAlign.center, ), const VSpace(spacing * 4), DropdownMenu( width: size.width - 100, // TODO: The following code cause the bad state error, need to fix it // initialSelection: widget.workspaceState.workspaces.first, label: const Text('Workspace'), controller: controller, dropdownMenuEntries: workspaceEntries, onSelected: (WorkspacePB? workspace) { setState(() { selectedWorkspace = workspace; }); }, ), const Spacer(), // TODO: needs to implement create workspace in the future // TextButton( // child: Text( // LocaleKeys.workspace_create.tr(), // style: style.textTheme.labelMedium, // textAlign: TextAlign.center, // ), // onPressed: () { // setState(() { // // same method as in desktop // context.read().add( // WorkspaceEvent.createWorkspace( // LocaleKeys.workspace_hint.tr(), // "", // ), // ); // }); // }, // ), const VSpace(spacing / 2), ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: const Size(double.infinity, 56), ), onPressed: () { if (selectedWorkspace == null) { // If user didn't choose any workspace, pop to the initial workspace(first workspace) _popToWorkspace( context, widget.workspaceState.workspaces.first, ); return; } // pop to the selected workspace _popToWorkspace( context, selectedWorkspace!, ); }, child: Text(LocaleKeys.signUp_getStartedText.tr()), ), const VSpace(spacing), ], ), ); }, (error) { return Center( child: AppFlowyErrorPage( error: error, ), ); }, ); return Scaffold( body: body, ); } } // same method as in desktop void _popToWorkspace(BuildContext context, WorkspacePB workspace) { context.pop(workspace.id); } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/workspace_start_screen.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart'; import 'package:appflowy/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart'; import 'package:appflowy/workspace/application/workspace/workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; // For future use class WorkspaceStartScreen extends StatelessWidget { /// To choose which screen is going to open const WorkspaceStartScreen({super.key, required this.userProfile}); final UserProfilePB userProfile; static const routeName = "/WorkspaceStartScreen"; static const argUserProfile = "userProfile"; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => getIt(param1: userProfile) ..add(const WorkspaceEvent.initial()), child: BlocBuilder( builder: (context, state) { if (UniversalPlatform.isMobile) { return MobileWorkspaceStartScreen( workspaceState: state, ); } return DesktopWorkspaceStartScreen( workspaceState: state, ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart ================================================ import 'package:flutter/material.dart'; class AuthFormContainer extends StatelessWidget { const AuthFormContainer({ super.key, required this.children, }); final List children; static const double width = 320; @override Widget build(BuildContext context) { return SizedBox( width: width, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: children, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart ================================================ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class FlowyLogoTitle extends StatelessWidget { const FlowyLogoTitle({ super.key, required this.title, this.logoSize = const Size.square(40), }); final String title; final Size logoSize; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return SizedBox( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ AFLogo(size: logoSize), const VSpace(20), Text( title, style: theme.textStyle.heading3.enhanced( color: theme.textColorScheme.primary, ), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/widgets/folder_widget.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../generated/locale_keys.g.dart'; import '../../../startup/startup.dart'; import '../../../workspace/presentation/home/toast.dart'; enum _FolderPage { options, create, open, } class FolderWidget extends StatefulWidget { const FolderWidget({ super.key, required this.createFolderCallback, }); final Future Function() createFolderCallback; @override State createState() => _FolderWidgetState(); } class _FolderWidgetState extends State { var page = _FolderPage.options; @override Widget build(BuildContext context) { return _mapIndexToWidget(context); } Widget _mapIndexToWidget(BuildContext context) { switch (page) { case _FolderPage.options: return FolderOptionsWidget( onPressedOpen: () { _openFolder(); }, ); case _FolderPage.create: return CreateFolderWidget( onPressedBack: () { setState(() => page = _FolderPage.options); }, onPressedCreate: widget.createFolderCallback, ); case _FolderPage.open: return const SizedBox.shrink(); } } Future _openFolder() async { final path = await getIt().getDirectoryPath(); if (path != null) { await getIt().setCustomPath(path); await widget.createFolderCallback(); setState(() {}); } } } class FolderOptionsWidget extends StatelessWidget { const FolderOptionsWidget({ super.key, required this.onPressedOpen, }); final VoidCallback onPressedOpen; @override Widget build(BuildContext context) { return FutureBuilder( future: getIt().getPath(), builder: (context, result) { final subtitle = result.hasData ? result.data! : ''; return _FolderCard( icon: const FlowySvg(FlowySvgs.archive_m), title: LocaleKeys.settings_files_defineWhereYourDataIsStored.tr(), subtitle: subtitle, trailing: _buildTextButton( context, LocaleKeys.settings_files_set.tr(), onPressedOpen, ), ); }, ); } } class CreateFolderWidget extends StatefulWidget { const CreateFolderWidget({ super.key, required this.onPressedBack, required this.onPressedCreate, }); final VoidCallback onPressedBack; final Future Function() onPressedCreate; @override State createState() => CreateFolderWidgetState(); } @visibleForTesting class CreateFolderWidgetState extends State { var _folderName = 'appflowy'; @visibleForTesting var directory = ''; final _fToast = FToast(); @override void initState() { super.initState(); _fToast.init(context); } @override Widget build(BuildContext context) { return Column( children: [ Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: widget.onPressedBack, icon: const Icon(Icons.arrow_back_rounded), label: const Text('Back'), ), ), _FolderCard( title: LocaleKeys.settings_files_location.tr(), subtitle: LocaleKeys.settings_files_locationDesc.tr(), trailing: SizedBox( width: 120, child: FlowyTextField( hintText: LocaleKeys.settings_files_folderHintText.tr(), onChanged: (name) => _folderName = name, onSubmitted: (name) => setState( () => _folderName = name, ), ), ), ), _FolderCard( title: LocaleKeys.settings_files_folderPath.tr(), subtitle: _path, trailing: _buildTextButton( context, LocaleKeys.settings_files_browser.tr(), () async { final dir = await getIt().getDirectoryPath(); if (dir != null) { setState(() => directory = dir); } }, ), ), const VSpace(4.0), Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: _buildTextButton( context, LocaleKeys.settings_files_create.tr(), () async { if (_path.isEmpty) { _showToast( LocaleKeys.settings_files_locationCannotBeEmpty.tr(), ); } else { await getIt().setCustomPath(_path); await widget.onPressedCreate(); } }, ), ), ], ); } String get _path { if (directory.isEmpty) return ''; final String path; if (Platform.isMacOS) { path = directory.replaceAll('/Volumes/Macintosh HD', ''); } else { path = directory; } return '$path/$_folderName'; } void _showToast(String message) { _fToast.showToast( child: FlowyMessageToast(message: message), gravity: ToastGravity.CENTER, ); } } Widget _buildTextButton( BuildContext context, String title, VoidCallback onPressed, ) { return SecondaryTextButton( title, mode: TextButtonMode.small, onPressed: onPressed, ); } class _FolderCard extends StatelessWidget { const _FolderCard({ required this.title, required this.subtitle, this.trailing, this.icon, }); final String title; final String subtitle; final Widget? icon; final Widget? trailing; @override Widget build(BuildContext context) { const cardSpacing = 16.0; return Card( child: Padding( padding: const EdgeInsets.symmetric( vertical: cardSpacing, horizontal: cardSpacing, ), child: Row( children: [ if (icon != null) Padding( padding: const EdgeInsets.only(right: cardSpacing), child: icon!, ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: FlowyText.regular( title, fontSize: FontSizes.s14, fontFamily: GoogleFonts.poppins( fontWeight: FontWeight.w500, ).fontFamily, overflow: TextOverflow.ellipsis, ), ), Tooltip( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(6), ), preferBelow: false, richMessage: WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: Container( color: Theme.of(context).colorScheme.surface, padding: const EdgeInsets.all(10), constraints: const BoxConstraints(maxWidth: 450), child: FlowyText( LocaleKeys.settings_menu_customPathPrompt.tr(), maxLines: null, ), ), ), child: const FlowyIconButton( icon: Icon( Icons.warning_amber_rounded, size: 20, color: Colors.orangeAccent, ), ), ), ], ), const VSpace(4), FlowyText.regular( subtitle, overflow: TextOverflow.ellipsis, fontSize: FontSizes.s12, fontFamily: GoogleFonts.poppins( fontWeight: FontWeight.w300, ).fontFamily, ), ], ), ), if (trailing != null) ...[ const HSpace(cardSpacing), trailing!, ], ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/user/presentation/widgets/widgets.dart ================================================ export 'folder_widget.dart'; export 'flowy_logo_title.dart'; export 'auth_form_container.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/util/built_in_svgs.dart ================================================ final builtInSVGIcons = [ '1F9CC', '1F9DB', '1F9DD-200D-2642-FE0F', '1F9DE-200D-2642-FE0F', '1F9DF', '1F42F', '1F43A', '1F431', '1F435', '1F600', '1F984', ]; ================================================ FILE: frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart ================================================ import 'package:flutter/material.dart'; // the color set generated from AI final _builtInColorSet = [ (const Color(0xFF8A2BE2), const Color(0xFFF0E6FF)), (const Color(0xFF2E8B57), const Color(0xFFE0FFF0)), (const Color(0xFF1E90FF), const Color(0xFFE6F3FF)), (const Color(0xFFFF7F50), const Color(0xFFFFF0E6)), (const Color(0xFFFF69B4), const Color(0xFFFFE6F0)), (const Color(0xFF20B2AA), const Color(0xFFE0FFFF)), (const Color(0xFFDC143C), const Color(0xFFFFE6E6)), (const Color(0xFF8B4513), const Color(0xFFFFF0E6)), ]; extension type ColorGenerator(String value) { Color toColor() { final int hash = value.codeUnits.fold(0, (int acc, int unit) => acc + unit); final double hue = (hash % 360).toDouble(); return HSLColor.fromAHSL(1.0, hue, 0.5, 0.8).toColor(); } // shuffle a color from the built-in color set, for the same name, the result should be the same (Color, Color) randomColor() { final hash = value.codeUnits.fold(0, (int acc, int unit) => acc + unit); final index = hash % _builtInColorSet.length; return _builtInColorSet[index]; } } ================================================ FILE: frontend/appflowy_flutter/lib/util/color_to_hex_string.dart ================================================ import 'dart:math' as math; import 'package:flutter/material.dart'; extension ColorExtension on Color { /// return a hex string in 0xff000000 format String toHexString() { final alpha = (a * 255).toInt().toRadixString(16).padLeft(2, '0'); final red = (r * 255).toInt().toRadixString(16).padLeft(2, '0'); final green = (g * 255).toInt().toRadixString(16).padLeft(2, '0'); final blue = (b * 255).toInt().toRadixString(16).padLeft(2, '0'); return '0x$alpha$red$green$blue'.toLowerCase(); } /// return a random color static Color random({double opacity = 1.0}) { return Color((math.Random().nextDouble() * 0xFFFFFF).toInt()) .withValues(alpha: opacity); } } ================================================ FILE: frontend/appflowy_flutter/lib/util/debounce.dart ================================================ import 'dart:async'; class Debounce { Debounce({ this.duration = const Duration(milliseconds: 1000), }); final Duration duration; Timer? _timer; void call(Function action) { dispose(); _timer = Timer(duration, () { action(); }); } void dispose() { _timer?.cancel(); _timer = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/util/default_extensions.dart ================================================ /// List of default file extensions used for images. /// /// This is used to make sure that only images that are allowed are picked/uploaded. The extensions /// should be supported by Flutter, to avoid causing issues. /// /// See [Image-class documentation](https://api.flutter.dev/flutter/widgets/Image-class.html) /// const List defaultImageExtensions = [ 'jpg', 'png', 'jpeg', 'gif', 'webp', 'bmp', ]; bool isNotImageUrl(String url) { final nonImageSuffixRegex = RegExp( r'(\.(io|html|php|json|txt|js|css|xml|md|log)(\?.*)?(#.*)?$)|/$', caseSensitive: false, ); return nonImageSuffixRegex.hasMatch(url); } ================================================ FILE: frontend/appflowy_flutter/lib/util/expand_views.dart ================================================ import 'package:flutter/cupertino.dart'; class ViewExpanderRegistry { /// the key is view id final Map> _viewExpanders = {}; bool isViewExpanded(String id) => getExpander(id)?.isViewExpanded ?? false; void register(String id, ViewExpander expander) { final expanders = _viewExpanders[id] ?? {}; expanders.add(expander); _viewExpanders[id] = expanders; } void unregister(String id, ViewExpander expander) { final expanders = _viewExpanders[id] ?? {}; expanders.remove(expander); if (expanders.isEmpty) { _viewExpanders.remove(id); } else { _viewExpanders[id] = expanders; } } ViewExpander? getExpander(String id) { final expanders = _viewExpanders[id] ?? {}; return expanders.isEmpty ? null : expanders.first; } } class ViewExpander { ViewExpander(this._isExpandedCallback, this._expandCallback); final ValueGetter _isExpandedCallback; final VoidCallback _expandCallback; bool get isViewExpanded => _isExpandedCallback.call(); void expand() => _expandCallback.call(); } ================================================ FILE: frontend/appflowy_flutter/lib/util/field_type_extension.dart ================================================ import 'dart:ui'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:protobuf/protobuf.dart'; extension FieldTypeExtension on FieldType { String get i18n => switch (this) { FieldType.RichText => LocaleKeys.grid_field_textFieldName.tr(), FieldType.Number => LocaleKeys.grid_field_numberFieldName.tr(), FieldType.DateTime => LocaleKeys.grid_field_dateFieldName.tr(), FieldType.SingleSelect => LocaleKeys.grid_field_singleSelectFieldName.tr(), FieldType.MultiSelect => LocaleKeys.grid_field_multiSelectFieldName.tr(), FieldType.Checkbox => LocaleKeys.grid_field_checkboxFieldName.tr(), FieldType.Checklist => LocaleKeys.grid_field_checklistFieldName.tr(), FieldType.URL => LocaleKeys.grid_field_urlFieldName.tr(), FieldType.LastEditedTime => LocaleKeys.grid_field_updatedAtFieldName.tr(), FieldType.CreatedTime => LocaleKeys.grid_field_createdAtFieldName.tr(), FieldType.Relation => LocaleKeys.grid_field_relationFieldName.tr(), FieldType.Summary => LocaleKeys.grid_field_summaryFieldName.tr(), FieldType.Time => LocaleKeys.grid_field_timeFieldName.tr(), FieldType.Translate => LocaleKeys.grid_field_translateFieldName.tr(), FieldType.Media => LocaleKeys.grid_field_mediaFieldName.tr(), _ => throw UnimplementedError(), }; FlowySvgData get svgData => switch (this) { FieldType.RichText => FlowySvgs.text_s, FieldType.Number => FlowySvgs.number_s, FieldType.DateTime => FlowySvgs.date_s, FieldType.SingleSelect => FlowySvgs.single_select_s, FieldType.MultiSelect => FlowySvgs.multiselect_s, FieldType.Checkbox => FlowySvgs.checkbox_s, FieldType.URL => FlowySvgs.url_s, FieldType.Checklist => FlowySvgs.checklist_s, FieldType.LastEditedTime => FlowySvgs.time_s, FieldType.CreatedTime => FlowySvgs.time_s, FieldType.Relation => FlowySvgs.relation_s, FieldType.Summary => FlowySvgs.ai_summary_s, FieldType.Time => FlowySvgs.timer_start_s, FieldType.Translate => FlowySvgs.ai_translate_s, FieldType.Media => FlowySvgs.media_s, _ => throw UnimplementedError(), }; FlowySvgData? get rightIcon => switch (this) { FieldType.Summary => FlowySvgs.ai_indicator_s, FieldType.Translate => FlowySvgs.ai_indicator_s, _ => null, }; Color get mobileIconBackgroundColor => switch (this) { FieldType.RichText => const Color(0xFFBECCFF), FieldType.Number => const Color(0xFFCABDFF), FieldType.URL => const Color(0xFFFFB9EF), FieldType.SingleSelect => const Color(0xFFBECCFF), FieldType.MultiSelect => const Color(0xFFBECCFF), FieldType.DateTime => const Color(0xFFFDEDA7), FieldType.LastEditedTime => const Color(0xFFFDEDA7), FieldType.CreatedTime => const Color(0xFFFDEDA7), FieldType.Checkbox => const Color(0xFF98F4CD), FieldType.Checklist => const Color(0xFF98F4CD), FieldType.Relation => const Color(0xFFFDEDA7), FieldType.Summary => const Color(0xFFBECCFF), FieldType.Time => const Color(0xFFFDEDA7), FieldType.Translate => const Color(0xFFBECCFF), FieldType.Media => const Color(0xFF91EBF5), _ => throw UnimplementedError(), }; // TODO(RS): inner icon color isn't always white Color get mobileIconBackgroundColorDark => switch (this) { FieldType.RichText => const Color(0xFF6859A7), FieldType.Number => const Color(0xFF6859A7), FieldType.URL => const Color(0xFFA75C96), FieldType.SingleSelect => const Color(0xFF5366AB), FieldType.MultiSelect => const Color(0xFF5366AB), FieldType.DateTime => const Color(0xFFB0A26D), FieldType.LastEditedTime => const Color(0xFFB0A26D), FieldType.CreatedTime => const Color(0xFFB0A26D), FieldType.Checkbox => const Color(0xFF42AD93), FieldType.Checklist => const Color(0xFF42AD93), FieldType.Relation => const Color(0xFFFDEDA7), FieldType.Summary => const Color(0xFF6859A7), FieldType.Time => const Color(0xFFFDEDA7), FieldType.Translate => const Color(0xFF6859A7), FieldType.Media => const Color(0xFF91EBF5), _ => throw UnimplementedError(), }; bool get canBeGroup => switch (this) { FieldType.URL || FieldType.Checkbox || FieldType.MultiSelect || FieldType.SingleSelect || FieldType.DateTime => true, _ => false }; bool get canCreateFilter => switch (this) { FieldType.Number || FieldType.Checkbox || FieldType.MultiSelect || FieldType.RichText || FieldType.SingleSelect || FieldType.Checklist || FieldType.URL || FieldType.DateTime || FieldType.CreatedTime || FieldType.LastEditedTime => true, _ => false }; bool get canCreateSort => switch (this) { FieldType.RichText || FieldType.Checkbox || FieldType.Number || FieldType.DateTime || FieldType.SingleSelect || FieldType.MultiSelect || FieldType.LastEditedTime || FieldType.CreatedTime || FieldType.Checklist || FieldType.URL || FieldType.Time => true, _ => false }; bool get canEditHeader => switch (this) { FieldType.MultiSelect => true, FieldType.SingleSelect => true, _ => false, }; bool get canCreateNewGroup => switch (this) { FieldType.MultiSelect => true, FieldType.SingleSelect => true, _ => false, }; bool get canDeleteGroup => switch (this) { FieldType.URL || FieldType.SingleSelect || FieldType.MultiSelect || FieldType.DateTime => true, _ => false, }; List get groupConditions { switch (this) { case FieldType.DateTime: return DateConditionPB.values; default: return []; } } } ================================================ FILE: frontend/appflowy_flutter/lib/util/file_extension.dart ================================================ import 'dart:io'; extension FileSizeExtension on String { int? get fileSize { final file = File(this); if (file.existsSync()) { return file.lengthSync(); } return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/util/font_family_extension.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:easy_localization/easy_localization.dart'; extension FontFamilyExtension on String { String parseFontFamilyName() => replaceAll('_regular', '') .replaceAllMapped(camelCaseRegex, (m) => ' ${m.group(0)}'); // display the default font name if the font family name is empty // or using the default font family String get fontFamilyDisplayName => isEmpty || this == defaultFontFamily ? LocaleKeys.settings_appearance_fontFamily_defaultFont.tr() : parseFontFamilyName(); // the font display name is not the same as the font family name // for example, the display name is "Noto Sans HK" // the font family name is "NotoSansHK_Regular" String get fontFamilyName => isEmpty || this == defaultFontFamily ? defaultFontFamily : getGoogleFontSafely(this).fontFamily ?? defaultFontFamily; } ================================================ FILE: frontend/appflowy_flutter/lib/util/int64_extension.dart ================================================ import 'package:fixnum/fixnum.dart'; extension DateConversion on Int64 { DateTime toDateTime() => DateTime.fromMillisecondsSinceEpoch(toInt() * 1000); } ================================================ FILE: frontend/appflowy_flutter/lib/util/json_print.dart ================================================ import 'dart:convert'; import 'package:appflowy_backend/log.dart'; import 'package:flutter/material.dart'; const JsonEncoder _encoder = JsonEncoder.withIndent(' '); void prettyPrintJson(Object? object) { Log.trace(_encoder.convert(object)); debugPrint(_encoder.convert(object)); } ================================================ FILE: frontend/appflowy_flutter/lib/util/levenshtein.dart ================================================ import 'dart:math'; int levenshtein(String s, String t, {bool caseSensitive = true}) { if (!caseSensitive) { s = s.toLowerCase(); t = t.toLowerCase(); } if (s == t) return 0; final v0 = List.generate(t.length + 1, (i) => i); final v1 = List.filled(t.length + 1, 0); for (var i = 0; i < s.length; i++) { v1[0] = i + 1; for (var j = 0; j < t.length; j++) { final cost = (s[i] == t[j]) ? 0 : 1; v1[j + 1] = min(v1[j] + 1, min(v0[j + 1] + 1, v0[j] + cost)); } v0.setAll(0, v1); } return v1[t.length]; } ================================================ FILE: frontend/appflowy_flutter/lib/util/navigator_context_extension.dart ================================================ import 'package:flutter/material.dart'; extension NavigatorContext on BuildContext { void popToHome() { Navigator.of(this).popUntil((route) { if (route.settings.name == '/') { return true; } return false; }); } } ================================================ FILE: frontend/appflowy_flutter/lib/util/share_log_files.dart ================================================ import 'dart:io'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:archive/archive_io.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; Future shareLogFiles(BuildContext? context) async { final dir = await getApplicationSupportDirectory(); final zipEncoder = ZipEncoder(); final archiveLogFiles = dir .listSync(recursive: true) .where((e) => p.basename(e.path).startsWith('log.')) .map((e) { final bytes = File(e.path).readAsBytesSync(); return ArchiveFile(p.basename(e.path), bytes.length, bytes); }); if (archiveLogFiles.isEmpty) { if (context != null && context.mounted) { showToastNotification( message: LocaleKeys.noLogFiles.tr(), type: ToastificationType.error, ); } return; } final archive = Archive(); for (final file in archiveLogFiles) { archive.addFile(file); } final zip = zipEncoder.encode(archive); if (zip == null) { if (context != null && context.mounted) { showToastNotification( message: LocaleKeys.noLogFiles.tr(), type: ToastificationType.error, ); } return; } // create a zipped appflowy logs file try { final tempDirectory = await getTemporaryDirectory(); final path = Platform.isAndroid ? tempDirectory.path : dir.path; final zipFile = await File(p.join(path, 'appflowy_logs.zip')).writeAsBytes(zip); if (Platform.isIOS) { await Share.shareUri(zipFile.uri); // delete the zipped appflowy logs file await zipFile.delete(); } else if (Platform.isAndroid) { await Share.shareXFiles([XFile(zipFile.path)]); // delete the zipped appflowy logs file await zipFile.delete(); } else { // open the directory await afLaunchUri(zipFile.uri); } } catch (e) { if (context != null && context.mounted) { showToastNotification( message: e.toString(), type: ToastificationType.error, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/util/string_extension.dart ================================================ import 'dart:io'; import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide Icon; extension StringExtension on String { static const _specialCharacters = r'\/:*?"<>| '; /// Encode a string to a file name. /// /// Normalizes the string to remove special characters and replaces the "\/:*?"<>|" with underscores. String toFileName() { final buffer = StringBuffer(); for (final character in characters) { if (_specialCharacters.contains(character)) { buffer.write('_'); } else { buffer.write(character); } } return buffer.toString(); } /// Returns the file size of the file at the given path. /// /// Returns null if the file does not exist. int? get fileSize { final file = File(this); if (file.existsSync()) { return file.lengthSync(); } return null; } /// Returns true if the string is a appflowy cloud url. bool get isAppFlowyCloudUrl => appflowyCloudUrlRegex.hasMatch(this); /// Returns the color of the string. /// /// ONLY used for the cover. Color? coverColor(BuildContext context) { // try to parse the color from the tint id, // if it fails, try to parse the color as a hex string return FlowyTint.fromId(this)?.color(context) ?? tryToColor(); } String orDefault(String defaultValue) { return isEmpty ? defaultValue : this; } } extension NullableStringExtension on String? { String orDefault(String defaultValue) { return this?.isEmpty ?? true ? defaultValue : this ?? ''; } } extension IconExtension on String { Icon? get icon { final values = split('/'); if (values.length != 2) { return null; } final iconGroup = IconGroup(name: values.first, icons: []); if (kDebugMode) { // Ensure the icon group and icon exist assert(kIconGroups!.any((group) => group.name == values.first)); assert( kIconGroups! .firstWhere((group) => group.name == values.first) .icons .any((icon) => icon.name == values.last), ); } return Icon( content: values.last, name: values.last, keywords: [], )..iconGroup = iconGroup; } } extension CounterExtension on String { Counters getCounter() { final wordCount = wordRegex.allMatches(this).length; final charCount = runes.length; return Counters(wordCount: wordCount, charCount: charCount); } } ================================================ FILE: frontend/appflowy_flutter/lib/util/theme_extension.dart ================================================ import 'package:flutter/material.dart'; extension IsLightMode on ThemeData { bool get isLightMode => brightness == Brightness.light; } ================================================ FILE: frontend/appflowy_flutter/lib/util/theme_mode_extension.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; extension LabelTextPhrasing on ThemeMode { String get labelText => switch (this) { ThemeMode.light => LocaleKeys.settings_appearance_themeMode_light.tr(), ThemeMode.dark => LocaleKeys.settings_appearance_themeMode_dark.tr(), ThemeMode.system => LocaleKeys.settings_appearance_themeMode_system.tr(), }; } ================================================ FILE: frontend/appflowy_flutter/lib/util/throttle.dart ================================================ import 'dart:async'; class Throttler { Throttler({ this.duration = const Duration(milliseconds: 1000), }); final Duration duration; Timer? _timer; void call(Function callback) { if (_timer?.isActive ?? false) return; _timer = Timer(duration, () { callback(); }); } void cancel() { _timer?.cancel(); } void dispose() { _timer?.cancel(); _timer = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/util/time.dart ================================================ final RegExp timerRegExp = RegExp(r'(?:(?\d*)h)? ?(?:(?\d*)m)?'); int? parseTime(String timerStr) { int? res = int.tryParse(timerStr); if (res != null) { return res; } final matches = timerRegExp.firstMatch(timerStr); if (matches == null) { return null; } final hours = int.tryParse(matches.namedGroup('hours') ?? ""); final minutes = int.tryParse(matches.namedGroup('minutes') ?? ""); if (hours == null && minutes == null) { return null; } final expected = "${hours != null ? '${hours}h' : ''}${hours != null && minutes != null ? ' ' : ''}${minutes != null ? '${minutes}m' : ''}"; if (timerStr != expected) { return null; } res = 0; res += hours != null ? hours * 60 : res; res += minutes ?? 0; return res; } String formatTime(int minutes) { if (minutes >= 60) { if (minutes % 60 == 0) { return "${minutes ~/ 60}h"; } return "${minutes ~/ 60}h ${minutes % 60}m"; } else if (minutes >= 0) { return "${minutes}m"; } return ""; } ================================================ FILE: frontend/appflowy_flutter/lib/util/xfile_ext.dart ================================================ import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pbenum.dart'; import 'package:cross_file/cross_file.dart'; enum FileType { other, image, link, document, archive, video, audio, text; } extension TypeRecognizer on XFile { FileType get fileType { // Prefer mime over using regexp as it is more reliable. // Refer to Microsoft Documentation for common mime types: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types if (mimeType?.isNotEmpty == true) { if (mimeType!.contains('image')) { return FileType.image; } if (mimeType!.contains('video')) { return FileType.video; } if (mimeType!.contains('audio')) { return FileType.audio; } if (mimeType!.contains('text')) { return FileType.text; } if (mimeType!.contains('application')) { if (mimeType!.contains('pdf') || mimeType!.contains('doc') || mimeType!.contains('docx')) { return FileType.document; } if (mimeType!.contains('zip') || mimeType!.contains('tar') || mimeType!.contains('gz') || mimeType!.contains('7z') || // archive is used in eg. Java archives (jar) mimeType!.contains('archive') || mimeType!.contains('rar')) { return FileType.archive; } if (mimeType!.contains('rtf')) { return FileType.text; } } return FileType.other; } // Check if the file is an image if (imgExtensionRegex.hasMatch(path)) { return FileType.image; } // Check if the file is a video if (videoExtensionRegex.hasMatch(path)) { return FileType.video; } // Check if the file is an audio if (audioExtensionRegex.hasMatch(path)) { return FileType.audio; } // Check if the file is a document if (documentExtensionRegex.hasMatch(path)) { return FileType.document; } // Check if the file is an archive if (archiveExtensionRegex.hasMatch(path)) { return FileType.archive; } // Check if the file is a text if (textExtensionRegex.hasMatch(path)) { return FileType.text; } return FileType.other; } } extension ToMediaFileTypePB on FileType { MediaFileTypePB toMediaFileTypePB() { switch (this) { case FileType.image: return MediaFileTypePB.Image; case FileType.video: return MediaFileTypePB.Video; case FileType.audio: return MediaFileTypePB.Audio; case FileType.document: return MediaFileTypePB.Document; case FileType.archive: return MediaFileTypePB.Archive; case FileType.text: return MediaFileTypePB.Text; default: return MediaFileTypePB.Other; } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/action_navigation/action_navigation_bloc.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:bloc/bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'action_navigation_bloc.freezed.dart'; class ActionNavigationBloc extends Bloc { ActionNavigationBloc() : super(const ActionNavigationState.initial()) { on((event, emit) async { await event.when( performAction: (action, showErrorToast, nextActions) async { NavigationAction currentAction = action; if (currentAction.arguments?[ActionArgumentKeys.view] == null && action.type == ActionType.openView) { final result = await ViewBackendService.getView(action.objectId); final view = result.toNullable(); if (view != null) { if (currentAction.arguments == null) { currentAction = currentAction.copyWith(arguments: {}); } currentAction.arguments?.addAll({ActionArgumentKeys.view: view}); } else { Log.error('Open view failed: ${action.objectId}'); if (showErrorToast) { showToastNotification( message: LocaleKeys.search_pageNotExist.tr(), type: ToastificationType.error, ); } } } emit(state.copyWith(action: currentAction, nextActions: nextActions)); if (nextActions.isNotEmpty) { final newActions = [...nextActions]; final next = newActions.removeAt(0); add( ActionNavigationEvent.performAction( action: next, nextActions: newActions, ), ); } else { emit(state.setNoAction()); } }, ); }); } } @freezed class ActionNavigationEvent with _$ActionNavigationEvent { const factory ActionNavigationEvent.performAction({ required NavigationAction action, @Default(false) bool showErrorToast, @Default([]) List nextActions, }) = _PerformAction; } class ActionNavigationState { const ActionNavigationState.initial() : action = null, nextActions = const []; const ActionNavigationState({ required this.action, this.nextActions = const [], }); final NavigationAction? action; final List nextActions; ActionNavigationState copyWith({ NavigationAction? action, List? nextActions, }) => ActionNavigationState( action: action ?? this.action, nextActions: nextActions ?? this.nextActions, ); ActionNavigationState setNoAction() => const ActionNavigationState(action: null); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/action_navigation/navigation_action.dart ================================================ enum ActionType { openView, jumpToBlock, openRow, } class ActionArgumentKeys { static String view = "view"; static String nodePath = "node_path"; static String blockId = "block_id"; static String rowId = "row_id"; } /// A [NavigationAction] is used to communicate with the /// [ActionNavigationBloc] to perform actions based on an event /// triggered by pressing a notification, such as opening a specific /// view and jumping to a specific block. /// class NavigationAction { const NavigationAction({ this.type = ActionType.openView, this.arguments, required this.objectId, }); final ActionType type; final String objectId; final Map? arguments; NavigationAction copyWith({ ActionType? type, String? objectId, Map? arguments, }) => NavigationAction( type: type ?? this.type, objectId: objectId ?? this.objectId, arguments: arguments ?? this.arguments, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart ================================================ import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flutter/material.dart'; /// A class for the default appearance settings for the app class DefaultAppearanceSettings { static const kDefaultFontFamily = defaultFontFamily; static const kDefaultThemeMode = ThemeMode.system; static const kDefaultThemeName = "Default"; static const kDefaultTheme = BuiltInTheme.defaultTheme; static Color getDefaultCursorColor(BuildContext context) { return Theme.of(context).colorScheme.primary; } static Color getDefaultSelectionColor(BuildContext context) { return Theme.of(context).colorScheme.primary.withValues(alpha: 0.2); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/trash/application/trash_listener.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/workspace/application/command_palette/search_service.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'command_palette_bloc.freezed.dart'; class Debouncer { Debouncer({required this.delay}); final Duration delay; Timer? _timer; void run(void Function() action) { _timer?.cancel(); _timer = Timer(delay, action); } void dispose() { _timer?.cancel(); } } class CommandPaletteBloc extends Bloc { CommandPaletteBloc() : super(CommandPaletteState.initial()) { on<_SearchChanged>(_onSearchChanged); on<_PerformSearch>(_onPerformSearch); on<_NewSearchStream>(_onNewSearchStream); on<_ResultsChanged>(_onResultsChanged); on<_TrashChanged>(_onTrashChanged); on<_WorkspaceChanged>(_onWorkspaceChanged); on<_ClearSearch>(_onClearSearch); on<_GoingToAskAI>(_onGoingToAskAI); on<_AskedAI>(_onAskedAI); on<_RefreshCachedViews>(_onRefreshCachedViews); on<_UpdateCachedViews>(_onUpdateCachedViews); _initTrash(); _refreshCachedViews(); } final Debouncer _searchDebouncer = Debouncer( delay: const Duration(milliseconds: 300), ); final TrashService _trashService = TrashService(); final TrashListener _trashListener = TrashListener(); String? _activeQuery; @override Future close() { _trashListener.close(); _searchDebouncer.dispose(); state.searchResponseStream?.dispose(); return super.close(); } Future _initTrash() async { _trashListener.start( trashUpdated: (trashOrFailed) => add( CommandPaletteEvent.trashChanged( trash: trashOrFailed.toNullable(), ), ), ); final trashOrFailure = await _trashService.readTrash(); trashOrFailure.fold( (trash) { if (!isClosed) { add(CommandPaletteEvent.trashChanged(trash: trash.items)); } }, (error) => debugPrint('Failed to load trash: $error'), ); } Future _refreshCachedViews() async { /// Sometimes non-existent views appear in the search results /// and the icon data for the search results is empty /// Fetching all views can temporarily resolve these issues final repeatedViewPB = (await ViewBackendService.getAllViews()).toNullable(); if (repeatedViewPB == null || isClosed) return; add(CommandPaletteEvent.updateCachedViews(views: repeatedViewPB.items)); } FutureOr _onRefreshCachedViews( _RefreshCachedViews event, Emitter emit, ) { _refreshCachedViews(); } FutureOr _onUpdateCachedViews( _UpdateCachedViews event, Emitter emit, ) { final cachedViews = {}; for (final view in event.views) { cachedViews[view.id] = view; } emit(state.copyWith(cachedViews: cachedViews)); } FutureOr _onSearchChanged( _SearchChanged event, Emitter emit, ) { _searchDebouncer.run( () { if (!isClosed) { add(CommandPaletteEvent.performSearch(search: event.search)); } }, ); } FutureOr _onPerformSearch( _PerformSearch event, Emitter emit, ) async { if (event.search.isEmpty) { emit( state.copyWith( query: null, searching: false, serverResponseItems: [], localResponseItems: [], combinedResponseItems: {}, resultSummaries: [], generatingAIOverview: false, ), ); } else { emit(state.copyWith(query: event.search, searching: true)); _activeQuery = event.search; unawaited( SearchBackendService.performSearch( event.search, ).then( (result) => result.fold( (stream) { if (!isClosed && _activeQuery == event.search) { add(CommandPaletteEvent.newSearchStream(stream: stream)); } }, (error) { debugPrint('Search error: $error'); if (!isClosed) { add( CommandPaletteEvent.resultsChanged( searchId: '', searching: false, generatingAIOverview: false, ), ); } }, ), ), ); } } FutureOr _onNewSearchStream( _NewSearchStream event, Emitter emit, ) { state.searchResponseStream?.dispose(); emit( state.copyWith( searchId: event.stream.searchId, searchResponseStream: event.stream, ), ); event.stream.listen( onLocalItems: (items, searchId) => _handleResultsUpdate( searchId: searchId, localItems: items, ), onServerItems: (items, searchId, searching, generatingAIOverview) => _handleResultsUpdate( searchId: searchId, summaries: [], // when got server search result, summaries should be empty serverItems: items, searching: searching, generatingAIOverview: generatingAIOverview, ), onSummaries: (summaries, searchId, searching, generatingAIOverview) => _handleResultsUpdate( searchId: searchId, summaries: summaries, searching: searching, generatingAIOverview: generatingAIOverview, ), onFinished: (searchId) => _handleResultsUpdate( searchId: searchId, searching: false, ), ); } void _handleResultsUpdate({ required String searchId, List? serverItems, List? localItems, List? summaries, bool searching = true, bool generatingAIOverview = false, }) { if (_isActiveSearch(searchId)) { add( CommandPaletteEvent.resultsChanged( searchId: searchId, serverItems: serverItems, localItems: localItems, summaries: summaries, searching: searching, generatingAIOverview: generatingAIOverview, ), ); } } FutureOr _onResultsChanged( _ResultsChanged event, Emitter emit, ) async { if (state.searchId != event.searchId) return; final combinedItems = {}; for (final item in event.serverItems ?? state.serverResponseItems) { combinedItems[item.id] = SearchResultItem( id: item.id, icon: item.icon, displayName: item.displayName, content: item.content, workspaceId: item.workspaceId, ); } for (final item in event.localItems ?? state.localResponseItems) { combinedItems.putIfAbsent( item.id, () => SearchResultItem( id: item.id, icon: item.icon, displayName: item.displayName, content: '', workspaceId: item.workspaceId, ), ); } emit( state.copyWith( serverResponseItems: event.serverItems ?? state.serverResponseItems, localResponseItems: event.localItems ?? state.localResponseItems, resultSummaries: event.summaries ?? state.resultSummaries, combinedResponseItems: combinedItems, searching: event.searching, generatingAIOverview: event.generatingAIOverview, ), ); } FutureOr _onTrashChanged( _TrashChanged event, Emitter emit, ) async { if (event.trash != null) { emit(state.copyWith(trash: event.trash!)); } else { final trashOrFailure = await _trashService.readTrash(); trashOrFailure.fold((trash) { emit(state.copyWith(trash: trash.items)); }, (error) { // Optionally handle error; otherwise, we simply do nothing. }); } } FutureOr _onWorkspaceChanged( _WorkspaceChanged event, Emitter emit, ) { emit( state.copyWith( query: '', serverResponseItems: [], localResponseItems: [], combinedResponseItems: {}, resultSummaries: [], searching: false, generatingAIOverview: false, ), ); _refreshCachedViews(); } FutureOr _onClearSearch( _ClearSearch event, Emitter emit, ) { emit(CommandPaletteState.initial().copyWith(trash: state.trash)); } FutureOr _onGoingToAskAI( _GoingToAskAI event, Emitter emit, ) { emit(state.copyWith(askAI: true, askAISources: event.sources)); } FutureOr _onAskedAI( _AskedAI event, Emitter emit, ) { emit(state.copyWith(askAI: false, askAISources: null)); } bool _isActiveSearch(String searchId) => !isClosed && state.searchId == searchId; } @freezed class CommandPaletteEvent with _$CommandPaletteEvent { const factory CommandPaletteEvent.searchChanged({required String search}) = _SearchChanged; const factory CommandPaletteEvent.performSearch({required String search}) = _PerformSearch; const factory CommandPaletteEvent.newSearchStream({ required SearchResponseStream stream, }) = _NewSearchStream; const factory CommandPaletteEvent.resultsChanged({ required String searchId, required bool searching, required bool generatingAIOverview, List? serverItems, List? localItems, List? summaries, }) = _ResultsChanged; const factory CommandPaletteEvent.trashChanged({ @Default(null) List? trash, }) = _TrashChanged; const factory CommandPaletteEvent.workspaceChanged({ @Default(null) String? workspaceId, }) = _WorkspaceChanged; const factory CommandPaletteEvent.clearSearch() = _ClearSearch; const factory CommandPaletteEvent.goingToAskAI({ @Default(null) List? sources, }) = _GoingToAskAI; const factory CommandPaletteEvent.askedAI() = _AskedAI; const factory CommandPaletteEvent.refreshCachedViews() = _RefreshCachedViews; const factory CommandPaletteEvent.updateCachedViews({ required List views, }) = _UpdateCachedViews; } class SearchResultItem { const SearchResultItem({ required this.id, required this.icon, required this.content, required this.displayName, this.workspaceId, }); final String id; final String content; final ResultIconPB icon; final String displayName; final String? workspaceId; } @freezed class CommandPaletteState with _$CommandPaletteState { const CommandPaletteState._(); const factory CommandPaletteState({ @Default(null) String? query, @Default([]) List serverResponseItems, @Default([]) List localResponseItems, @Default({}) Map combinedResponseItems, @Default({}) Map cachedViews, @Default([]) List resultSummaries, @Default(null) SearchResponseStream? searchResponseStream, required bool searching, required bool generatingAIOverview, @Default(false) bool askAI, @Default(null) List? askAISources, @Default([]) List trash, @Default(null) String? searchId, }) = _CommandPaletteState; factory CommandPaletteState.initial() => const CommandPaletteState( searching: false, generatingAIOverview: false, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; extension GetIcon on ResultIconPB { Widget? getIcon({ double size = 18.0, double lineHeight = 1.0, Color? iconColor, }) { final iconValue = value, iconType = ty; if (iconType == ResultIconTypePB.Emoji) { if (iconValue.isEmpty) return null; if (UniversalPlatform.isMobile) { return Text( iconValue, strutStyle: StrutStyle( fontSize: size, height: lineHeight, /// currently [forceStrutHeight] set to true seems only work in iOS forceStrutHeight: UniversalPlatform.isIOS, leadingDistribution: TextLeadingDistribution.even, ), ); } return RawEmojiIconWidget( emoji: EmojiIconData.emoji(iconValue), emojiSize: size, lineHeight: lineHeight, ); } else if (iconType == ResultIconTypePB.Icon || iconType == ResultIconTypePB.Url) { if (_resultIconValueTypes.contains(iconValue)) { return FlowySvg( getViewSvg(), size: Size.square(size), color: iconColor, ); } return RawEmojiIconWidget( emoji: EmojiIconData(iconType.toFlowyIconType(), iconValue), emojiSize: size, lineHeight: lineHeight, ); } return null; } } extension ResultIconTypePBToFlowyIconType on ResultIconTypePB { FlowyIconType toFlowyIconType() { switch (this) { case ResultIconTypePB.Emoji: return FlowyIconType.emoji; case ResultIconTypePB.Icon: return FlowyIconType.icon; case ResultIconTypePB.Url: return FlowyIconType.custom; default: return FlowyIconType.custom; } } } extension _ToViewIcon on ResultIconPB { FlowySvgData getViewSvg() => switch (value) { "0" => FlowySvgs.icon_document_s, "1" => FlowySvgs.icon_grid_s, "2" => FlowySvgs.icon_board_s, "3" => FlowySvgs.icon_calendar_s, "4" => FlowySvgs.chat_ai_page_s, _ => FlowySvgs.icon_document_s, }; } const _resultIconValueTypes = {'0', '1', '2', '3', '4'}; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_list_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'search_result_list_bloc.freezed.dart'; class SearchResultListBloc extends Bloc { SearchResultListBloc() : super(SearchResultListState.initial()) { // Register event handlers on<_OnHoverSummary>(_onHoverSummary); on<_OnHoverResult>(_onHoverResult); on<_OpenPage>(_onOpenPage); } FutureOr _onHoverSummary( _OnHoverSummary event, Emitter emit, ) { emit( state.copyWith( hoveredSummary: event.summary, hoveredResult: null, userHovered: event.userHovered, openPageId: null, ), ); } FutureOr _onHoverResult( _OnHoverResult event, Emitter emit, ) { emit( state.copyWith( hoveredSummary: null, hoveredResult: event.item, userHovered: event.userHovered, openPageId: null, ), ); } FutureOr _onOpenPage( _OpenPage event, Emitter emit, ) { emit(state.copyWith(openPageId: event.pageId)); } } @freezed class SearchResultListEvent with _$SearchResultListEvent { const factory SearchResultListEvent.onHoverSummary({ required SearchSummaryPB summary, required bool userHovered, }) = _OnHoverSummary; const factory SearchResultListEvent.onHoverResult({ required SearchResultItem item, required bool userHovered, }) = _OnHoverResult; const factory SearchResultListEvent.openPage({ required String pageId, }) = _OpenPage; } @freezed class SearchResultListState with _$SearchResultListState { const SearchResultListState._(); const factory SearchResultListState({ @Default(null) SearchSummaryPB? hoveredSummary, @Default(null) SearchResultItem? hoveredResult, @Default(null) String? openPageId, @Default(false) bool userHovered, }) = _SearchResultListState; factory SearchResultListState.initial() => const SearchResultListState(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart ================================================ import 'dart:async'; import 'dart:ffi'; import 'dart:isolate'; import 'dart:typed_data'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/query.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; class SearchBackendService { static Future> performSearch( String keyword, ) async { final searchId = DateTime.now().millisecondsSinceEpoch.toString(); final stream = SearchResponseStream(searchId: searchId); final request = SearchQueryPB( search: keyword, searchId: searchId, streamPort: Int64(stream.nativePort), ); unawaited(SearchEventStreamSearch(request).send()); return FlowyResult.success(stream); } } class SearchResponseStream { SearchResponseStream({required this.searchId}) { _port.handler = _controller.add; _subscription = _controller.stream.listen( (Uint8List data) => _onResultsChanged(data), ); } final String searchId; final RawReceivePort _port = RawReceivePort(); final StreamController _controller = StreamController.broadcast(); late StreamSubscription _subscription; void Function( List items, String searchId, bool searching, bool generatingAIOverview, )? _onServerItems; void Function( List summaries, String searchId, bool searching, bool generatingAIOverview, )? _onSummaries; void Function( List items, String searchId, )? _onLocalItems; void Function(String searchId)? _onFinished; int get nativePort => _port.sendPort.nativePort; Future dispose() async { await _subscription.cancel(); _port.close(); } void _onResultsChanged(Uint8List data) { final searchState = SearchStatePB.fromBuffer(data); if (searchState.hasResponse()) { if (searchState.response.hasSearchResult()) { _onServerItems?.call( searchState.response.searchResult.items, searchId, searchState.response.searching, searchState.response.generatingAiSummary, ); } if (searchState.response.hasSearchSummary()) { _onSummaries?.call( searchState.response.searchSummary.items, searchId, searchState.response.searching, searchState.response.generatingAiSummary, ); } if (searchState.response.hasLocalSearchResult()) { _onLocalItems?.call( searchState.response.localSearchResult.items, searchId, ); } } else { _onFinished?.call(searchId); } } void listen({ required void Function( List items, String searchId, bool isLoading, bool generatingAIOverview, )? onServerItems, required void Function( List summaries, String searchId, bool isLoading, bool generatingAIOverview, )? onSummaries, required void Function( List items, String searchId, )? onLocalItems, required void Function(String searchId)? onFinished, }) { _onServerItems = onServerItems; _onSummaries = onSummaries; _onLocalItems = onLocalItems; _onFinished = onFinished; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_context.dart ================================================ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; abstract class EditPanelContext extends Equatable { const EditPanelContext({ required this.identifier, required this.title, required this.child, }); final String identifier; final String title; final Widget child; @override List get props => [identifier]; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_panel_bloc.dart ================================================ import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'edit_panel_bloc.freezed.dart'; class EditPanelBloc extends Bloc { EditPanelBloc() : super(EditPanelState.initial()) { on((event, emit) async { await event.map( startEdit: (e) async { emit(state.copyWith(isEditing: true, editContext: e.context)); }, endEdit: (value) async { emit(state.copyWith(isEditing: false, editContext: null)); }, ); }); } } @freezed class EditPanelEvent with _$EditPanelEvent { const factory EditPanelEvent.startEdit(EditPanelContext context) = _StartEdit; const factory EditPanelEvent.endEdit(EditPanelContext context) = _EndEdit; } @freezed class EditPanelState with _$EditPanelState { const factory EditPanelState({ required bool isEditing, required EditPanelContext? editContext, }) = _EditPanelState; factory EditPanelState.initial() => const EditPanelState( isEditing: false, editContext: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart ================================================ import 'dart:convert'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; enum DocumentExportType { json, markdown, text, html, } class DocumentExporter { const DocumentExporter( this.view, ); final ViewPB view; Future> export( DocumentExportType type, { String? path, }) async { final documentService = DocumentService(); final result = await documentService.openDocument(documentId: view.id); return result.fold( (r) async { final document = r.toDocument(); if (document == null) { return FlowyResult.failure( FlowyError( msg: LocaleKeys.settings_files_exportFileFail.tr(), ), ); } switch (type) { case DocumentExportType.json: return FlowyResult.success(jsonEncode(document)); case DocumentExportType.markdown: if (path != null) { await customDocumentToMarkdown(document, path: path); return FlowyResult.success(''); } else { return FlowyResult.success( await customDocumentToMarkdown(document), ); } case DocumentExportType.text: throw UnimplementedError(); case DocumentExportType.html: final html = documentToHTML( document, ); return FlowyResult.success(html); } }, (error) => FlowyResult.failure(error), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart ================================================ import 'package:appflowy/workspace/application/favorite/favorite_service.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'favorite_listener.dart'; part 'favorite_bloc.freezed.dart'; class FavoriteBloc extends Bloc { FavoriteBloc() : super(FavoriteState.initial()) { _dispatch(); } final _service = FavoriteService(); final _listener = FavoriteListener(); bool isReordering = false; @override Future close() async { await _listener.stop(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { _listener.start( favoritesUpdated: _onFavoritesUpdated, ); add(const FavoriteEvent.fetchFavorites()); }, fetchFavorites: () async { final result = await _service.readFavorites(); emit( result.fold( (favoriteViews) { final views = favoriteViews.items.toList(); final pinnedViews = views.where((v) => v.item.isPinned).toList(); final unpinnedViews = views.where((v) => !v.item.isPinned).toList(); return state.copyWith( isLoading: false, views: views, pinnedViews: pinnedViews, unpinnedViews: unpinnedViews, ); }, (error) => state.copyWith( isLoading: false, views: [], ), ), ); }, toggle: (view) async { final isFavorited = state.views.any((v) => v.item.id == view.id); if (isFavorited) { await _service.unpinFavorite(view); } else if (state.pinnedViews.length < 3) { // pin the view if there are less than 3 pinned views await _service.pinFavorite(view); } await _service.toggleFavorite(view.id); }, pin: (view) async { await _service.pinFavorite(view); add(const FavoriteEvent.fetchFavorites()); }, unpin: (view) async { await _service.unpinFavorite(view); add(const FavoriteEvent.fetchFavorites()); }, reorder: (oldIndex, newIndex) async { /// TODO: this is a workaround to reorder the favorite views isReordering = true; final pinnedViews = state.pinnedViews.toList(); if (oldIndex < newIndex) newIndex -= 1; final target = pinnedViews.removeAt(oldIndex); pinnedViews.insert(newIndex, target); emit(state.copyWith(pinnedViews: pinnedViews)); for (final view in pinnedViews) { await _service.toggleFavorite(view.item.id); await _service.toggleFavorite(view.item.id); } if (!isClosed) { add(const FavoriteEvent.fetchFavorites()); } isReordering = false; }, ); }, ); } void _onFavoritesUpdated( FlowyResult favoriteOrFailed, bool didFavorite, ) { if (!isReordering) { favoriteOrFailed.fold( (favorite) => add(const FetchFavorites()), (error) => Log.error(error), ); } } } @freezed class FavoriteEvent with _$FavoriteEvent { const factory FavoriteEvent.initial() = Initial; const factory FavoriteEvent.toggle(ViewPB view) = ToggleFavorite; const factory FavoriteEvent.fetchFavorites() = FetchFavorites; const factory FavoriteEvent.pin(ViewPB view) = PinFavorite; const factory FavoriteEvent.unpin(ViewPB view) = UnpinFavorite; const factory FavoriteEvent.reorder(int oldIndex, int newIndex) = ReorderFavorite; } @freezed class FavoriteState with _$FavoriteState { const factory FavoriteState({ @Default([]) List views, @Default([]) List pinnedViews, @Default([]) List unpinnedViews, @Default(true) bool isLoading, }) = _FavoriteState; factory FavoriteState.initial() => const FavoriteState(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart ================================================ import 'dart:async'; import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; typedef FavoriteUpdated = void Function( FlowyResult result, bool isFavorite, ); class FavoriteListener { StreamSubscription? _streamSubscription; FolderNotificationParser? _parser; FavoriteUpdated? _favoriteUpdated; void start({ FavoriteUpdated? favoritesUpdated, }) { _favoriteUpdated = favoritesUpdated; _parser = FolderNotificationParser( id: 'favorite', callback: _observableCallback, ); _streamSubscription = RustStreamReceiver.listen( (observable) => _parser?.parse(observable), ); } void _observableCallback( FolderNotification ty, FlowyResult result, ) { switch (ty) { case FolderNotification.DidFavoriteView: result.onSuccess( (success) => _favoriteUpdated?.call( FlowyResult.success(RepeatedViewPB.fromBuffer(success)), true, ), ); case FolderNotification.DidUnfavoriteView: result.map( (success) => _favoriteUpdated?.call( FlowyResult.success(RepeatedViewPB.fromBuffer(success)), false, ), ); break; default: break; } } Future stop() async { _parser = null; await _streamSubscription?.cancel(); _streamSubscription = null; _favoriteUpdated = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart ================================================ import 'dart:convert'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; class FavoriteService { Future> readFavorites() { final result = FolderEventReadFavorites().send(); return result.then((result) { return result.fold( (favoriteViews) { return FlowyResult.success( RepeatedFavoriteViewPB( items: favoriteViews.items.where((e) => !e.item.isSpace), ), ); }, (error) => FlowyResult.failure(error), ); }); } Future> toggleFavorite(String viewId) async { final id = RepeatedViewIdPB.create()..items.add(viewId); return FolderEventToggleFavorite(id).send(); } Future> pinFavorite(ViewPB view) async { return pinOrUnpinFavorite(view, true); } Future> unpinFavorite(ViewPB view) async { return pinOrUnpinFavorite(view, false); } Future> pinOrUnpinFavorite( ViewPB view, bool isPinned, ) async { try { final current = view.extra.isNotEmpty ? jsonDecode(view.extra) : {}; final merged = mergeMaps( current, {ViewExtKeys.isPinnedKey: isPinned}, ); await ViewBackendService.updateView( viewId: view.id, extra: jsonEncode(merged), ); } catch (e) { return FlowyResult.failure(FlowyError(msg: 'Failed to pin favorite: $e')); } return FlowyResult.success(null); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/favorite/prelude.dart ================================================ export 'favorite_bloc.dart'; export 'favorite_listener.dart'; export 'favorite_service.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart ================================================ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart' show WorkspaceLatestPB; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'home_bloc.freezed.dart'; class HomeBloc extends Bloc { HomeBloc(WorkspaceLatestPB workspaceSetting) : _workspaceListener = FolderListener( workspaceId: workspaceSetting.workspaceId, ), super(HomeState.initial(workspaceSetting)) { _dispatch(workspaceSetting); } final FolderListener _workspaceListener; @override Future close() async { await _workspaceListener.stop(); return super.close(); } void _dispatch(WorkspaceLatestPB workspaceSetting) { on( (event, emit) async { await event.map( initial: (_Initial value) { Future.delayed(const Duration(milliseconds: 300), () { if (!isClosed) { add(HomeEvent.didReceiveWorkspaceSetting(workspaceSetting)); } }); _workspaceListener.start( onLatestUpdated: (result) { result.fold( (latest) => add(HomeEvent.didReceiveWorkspaceSetting(latest)), (r) => Log.error(r), ); }, ); }, showLoading: (e) async { emit(state.copyWith(isLoading: e.isLoading)); }, didReceiveWorkspaceSetting: (_DidReceiveWorkspaceSetting value) { // the latest view is shared across all the members of the workspace. final latestView = value.setting.hasLatestView() ? value.setting.latestView : state.latestView; if (latestView != null && latestView.isSpace) { // If the latest view is a space, we don't need to open it. return; } emit( state.copyWith( workspaceSetting: value.setting, latestView: latestView, ), ); }, ); }, ); } } @freezed class HomeEvent with _$HomeEvent { const factory HomeEvent.initial() = _Initial; const factory HomeEvent.showLoading(bool isLoading) = _ShowLoading; const factory HomeEvent.didReceiveWorkspaceSetting( WorkspaceLatestPB setting, ) = _DidReceiveWorkspaceSetting; } @freezed class HomeState with _$HomeState { const factory HomeState({ required bool isLoading, required WorkspaceLatestPB workspaceSetting, ViewPB? latestView, }) = _HomeState; factory HomeState.initial(WorkspaceLatestPB workspaceSetting) => HomeState( isLoading: false, workspaceSetting: workspaceSetting, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart ================================================ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart' show WorkspaceLatestPB; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/time/duration.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'home_setting_bloc.freezed.dart'; class HomeSettingBloc extends Bloc { HomeSettingBloc( WorkspaceLatestPB workspaceSetting, AppearanceSettingsCubit appearanceSettingsCubit, double screenWidthPx, ) : _listener = FolderListener(workspaceId: workspaceSetting.workspaceId), _appearanceSettingsCubit = appearanceSettingsCubit, super( HomeSettingState.initial( workspaceSetting, appearanceSettingsCubit.state, screenWidthPx, ), ) { _dispatch(); } final FolderListener _listener; final AppearanceSettingsCubit _appearanceSettingsCubit; @override Future close() async { await _listener.stop(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.map( initial: (_Initial value) {}, setEditPanel: (e) async { emit(state.copyWith(panelContext: e.editContext)); }, dismissEditPanel: (value) async { emit(state.copyWith(panelContext: null)); }, didReceiveWorkspaceSetting: (_DidReceiveWorkspaceSetting value) { emit(state.copyWith(workspaceSetting: value.setting)); }, changeMenuStatus: (_CollapseMenu e) { final status = e.status; if (state.menuStatus == status) return; if (status != MenuStatus.floating) { _appearanceSettingsCubit.saveIsMenuCollapsed( status == MenuStatus.expanded ? false : true, ); } emit( state.copyWith(menuStatus: status), ); }, collapseNotificationPanel: (_) { final isNotificationPanelCollapsed = !state.isNotificationPanelCollapsed; emit( state.copyWith( isNotificationPanelCollapsed: isNotificationPanelCollapsed, ), ); }, checkScreenSize: (_CheckScreenSize e) { final bool isScreenSmall = e.screenWidthPx < PageBreaks.tabletLandscape; if (state.isScreenSmall == isScreenSmall) return; if (state.hasColappsedMenuManually) { emit(state.copyWith(isScreenSmall: isScreenSmall)); } else { MenuStatus menuStatus = state.menuStatus; if (isScreenSmall && menuStatus == MenuStatus.expanded) { menuStatus = MenuStatus.hidden; } else if (!isScreenSmall && menuStatus == MenuStatus.hidden) { menuStatus = MenuStatus.expanded; } emit( state.copyWith( menuStatus: menuStatus, isScreenSmall: isScreenSmall, ), ); } }, editPanelResizeStart: (_EditPanelResizeStart e) { emit( state.copyWith( resizeType: MenuResizeType.drag, resizeStart: state.resizeOffset, ), ); }, editPanelResized: (_EditPanelResized e) { final newPosition = (state.resizeStart + e.offset).clamp(0, 200).toDouble(); if (state.resizeOffset != newPosition) { emit(state.copyWith(resizeOffset: newPosition)); } }, editPanelResizeEnd: (_EditPanelResizeEnd e) { _appearanceSettingsCubit.saveMenuOffset(state.resizeOffset); emit(state.copyWith(resizeType: MenuResizeType.slide)); }, ); }, ); } bool get isMenuHidden => state.menuStatus == MenuStatus.hidden; bool get isMenuExpanded => state.menuStatus == MenuStatus.expanded; void collapseMenu() { if (isMenuExpanded) { add(HomeSettingEvent.changeMenuStatus(MenuStatus.hidden)); } else if (isMenuHidden) { add(HomeSettingEvent.changeMenuStatus(MenuStatus.expanded)); } } } enum MenuResizeType { slide, drag, } extension MenuResizeTypeExtension on MenuResizeType { Duration duration() { switch (this) { case MenuResizeType.drag: return 30.milliseconds; case MenuResizeType.slide: return 350.milliseconds; } } } @freezed class HomeSettingEvent with _$HomeSettingEvent { const factory HomeSettingEvent.initial() = _Initial; const factory HomeSettingEvent.setEditPanel(EditPanelContext editContext) = _ShowEditPanel; const factory HomeSettingEvent.dismissEditPanel() = _DismissEditPanel; const factory HomeSettingEvent.didReceiveWorkspaceSetting( WorkspaceLatestPB setting, ) = _DidReceiveWorkspaceSetting; const factory HomeSettingEvent.changeMenuStatus(MenuStatus status) = _CollapseMenu; const factory HomeSettingEvent.collapseNotificationPanel() = _CollapseNotificationPanel; const factory HomeSettingEvent.checkScreenSize(double screenWidthPx) = _CheckScreenSize; const factory HomeSettingEvent.editPanelResized(double offset) = _EditPanelResized; const factory HomeSettingEvent.editPanelResizeStart() = _EditPanelResizeStart; const factory HomeSettingEvent.editPanelResizeEnd() = _EditPanelResizeEnd; } @freezed class HomeSettingState with _$HomeSettingState { const factory HomeSettingState({ required EditPanelContext? panelContext, required WorkspaceLatestPB workspaceSetting, required bool unauthorized, required MenuStatus menuStatus, required bool isNotificationPanelCollapsed, required bool isScreenSmall, required bool hasColappsedMenuManually, required double resizeOffset, required double resizeStart, required MenuResizeType resizeType, }) = _HomeSettingState; factory HomeSettingState.initial( WorkspaceLatestPB workspaceSetting, AppearanceSettingsState appearanceSettingsState, double screenWidthPx, ) { return HomeSettingState( panelContext: null, workspaceSetting: workspaceSetting, unauthorized: false, menuStatus: appearanceSettingsState.isMenuCollapsed ? MenuStatus.hidden : MenuStatus.expanded, isNotificationPanelCollapsed: true, isScreenSmall: screenWidthPx < PageBreaks.tabletLandscape, hasColappsedMenuManually: false, resizeOffset: appearanceSettingsState.menuOffset, resizeStart: 0, resizeType: MenuResizeType.slide, ); } } enum MenuStatus { hidden, expanded, floating } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/home/prelude.dart ================================================ ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart ================================================ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'menu_user_bloc.freezed.dart'; class MenuUserBloc extends Bloc { MenuUserBloc(this.userProfile, this.workspaceId) : _userListener = UserListener(userProfile: userProfile), _userWorkspaceListener = FolderListener( workspaceId: workspaceId, ), _userService = UserBackendService(userId: userProfile.id), super(MenuUserState.initial(userProfile)) { _dispatch(); } final String workspaceId; final UserBackendService _userService; final UserListener _userListener; final FolderListener _userWorkspaceListener; final UserProfilePB userProfile; @override Future close() async { await _userListener.stop(); await _userWorkspaceListener.stop(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { _userListener.start(onProfileUpdated: _profileUpdated); await _initUser(); }, didReceiveUserProfile: (UserProfilePB newUserProfile) { emit(state.copyWith(userProfile: newUserProfile)); }, updateUserName: (String name) { _userService.updateUserProfile(name: name).then((result) { result.fold( (l) => null, (err) => Log.error(err), ); }); }, ); }, ); } Future _initUser() async { final result = await _userService.initUser(); result.fold((l) => null, (error) => Log.error(error)); } void _profileUpdated( FlowyResult userProfileOrFailed, ) { if (isClosed) { return; } userProfileOrFailed.fold( (profile) => add(MenuUserEvent.didReceiveUserProfile(profile)), (err) => Log.error(err), ); } } @freezed class MenuUserEvent with _$MenuUserEvent { const factory MenuUserEvent.initial() = _Initial; const factory MenuUserEvent.updateUserName(String name) = _UpdateUserName; const factory MenuUserEvent.didReceiveUserProfile( UserProfilePB newUserProfile, ) = _DidReceiveUserProfile; } @freezed class MenuUserState with _$MenuUserState { const factory MenuUserState({ required UserProfilePB userProfile, required List? workspaces, required FlowyResult successOrFailure, }) = _MenuUserState; factory MenuUserState.initial(UserProfilePB userProfile) => MenuUserState( userProfile: userProfile, workspaces: null, successOrFailure: FlowyResult.success(null), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart ================================================ export 'menu_user_bloc.dart'; export 'sidebar_sections_bloc.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/workspace/workspace_sections_listener.dart'; import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'sidebar_sections_bloc.freezed.dart'; class SidebarSection { const SidebarSection({ required this.publicViews, required this.privateViews, }); const SidebarSection.empty() : publicViews = const [], privateViews = const []; final List publicViews; final List privateViews; List get views => publicViews + privateViews; SidebarSection copyWith({ List? publicViews, List? privateViews, }) { return SidebarSection( publicViews: publicViews ?? this.publicViews, privateViews: privateViews ?? this.privateViews, ); } } /// The [SidebarSectionsBloc] is responsible for /// managing the root views in different sections of the workspace. class SidebarSectionsBloc extends Bloc { SidebarSectionsBloc() : super(SidebarSectionsState.initial()) { on( (event, emit) async { await event.when( initial: (userProfile, workspaceId) async { _initial(userProfile, workspaceId); final sectionViews = await _getSectionViews(); if (sectionViews != null) { final containsSpace = _containsSpace(sectionViews); emit( state.copyWith( section: sectionViews, containsSpace: containsSpace, ), ); } }, reset: (userProfile, workspaceId) async { _reset(userProfile, workspaceId); final sectionViews = await _getSectionViews(); if (sectionViews != null) { final containsSpace = _containsSpace(sectionViews); emit( state.copyWith( section: sectionViews, containsSpace: containsSpace, ), ); } }, createRootViewInSection: (name, section, index) async { final result = await _workspaceService.createView( name: name, viewSection: section, index: index, ); result.fold( (view) => emit( state.copyWith( lastCreatedRootView: view, createRootViewResult: FlowyResult.success(null), ), ), (error) { Log.error('Failed to create root view: $error'); emit( state.copyWith( createRootViewResult: FlowyResult.failure(error), ), ); }, ); }, receiveSectionViewsUpdate: (sectionViews) async { final section = sectionViews.section; switch (section) { case ViewSectionPB.Public: emit( state.copyWith( containsSpace: state.containsSpace || sectionViews.views.any((view) => view.isSpace), section: state.section.copyWith( publicViews: sectionViews.views, ), ), ); case ViewSectionPB.Private: emit( state.copyWith( containsSpace: state.containsSpace || sectionViews.views.any((view) => view.isSpace), section: state.section.copyWith( privateViews: sectionViews.views, ), ), ); break; default: break; } }, moveRootView: (fromIndex, toIndex, fromSection, toSection) async { final views = fromSection == ViewSectionPB.Public ? List.from(state.section.publicViews) : List.from(state.section.privateViews); if (fromIndex < 0 || fromIndex >= views.length) { Log.error( 'Invalid fromIndex: $fromIndex, maxIndex: ${views.length - 1}', ); return; } final view = views[fromIndex]; final result = await _workspaceService.moveView( viewId: view.id, fromIndex: fromIndex, toIndex: toIndex, ); result.fold( (value) { views.insert(toIndex, views.removeAt(fromIndex)); var newState = state; if (fromSection == ViewSectionPB.Public) { newState = newState.copyWith( section: newState.section.copyWith(publicViews: views), ); } else if (fromSection == ViewSectionPB.Private) { newState = newState.copyWith( section: newState.section.copyWith(privateViews: views), ); } emit(newState); }, (error) { Log.error('Failed to move root view: $error'); }, ); }, reload: (userProfile, workspaceId) async { _initial(userProfile, workspaceId); final sectionViews = await _getSectionViews(); if (sectionViews != null) { final containsSpace = _containsSpace(sectionViews); emit( state.copyWith( section: sectionViews, containsSpace: containsSpace, ), ); // try to open the fist view in public section or private section if (sectionViews.publicViews.isNotEmpty) { getIt().add( TabsEvent.openPlugin( plugin: sectionViews.publicViews.first.plugin(), ), ); } else if (sectionViews.privateViews.isNotEmpty) { getIt().add( TabsEvent.openPlugin( plugin: sectionViews.privateViews.first.plugin(), ), ); } else { getIt().add( TabsEvent.openPlugin( plugin: makePlugin(pluginType: PluginType.blank), ), ); } } }, ); }, ); } late WorkspaceService _workspaceService; WorkspaceSectionsListener? _listener; @override Future close() async { await _listener?.stop(); _listener = null; return super.close(); } ViewSectionPB? getViewSection(ViewPB view) { final publicViews = state.section.publicViews.map((e) => e.id); final privateViews = state.section.privateViews.map((e) => e.id); if (publicViews.contains(view.id)) { return ViewSectionPB.Public; } else if (privateViews.contains(view.id)) { return ViewSectionPB.Private; } else { return null; } } Future _getSectionViews() async { try { final publicViews = await _workspaceService.getPublicViews().getOrThrow(); final privateViews = await _workspaceService.getPrivateViews().getOrThrow(); return SidebarSection( publicViews: publicViews, privateViews: privateViews, ); } catch (e) { Log.error('Failed to get section views: $e'); return null; } } bool _containsSpace(SidebarSection section) { return section.publicViews.any((view) => view.isSpace) || section.privateViews.any((view) => view.isSpace); } void _initial(UserProfilePB userProfile, String workspaceId) { _workspaceService = WorkspaceService( workspaceId: workspaceId, userId: userProfile.id, ); _listener = WorkspaceSectionsListener( user: userProfile, workspaceId: workspaceId, )..start( sectionChanged: (result) { if (!isClosed) { result.fold( (s) => add(SidebarSectionsEvent.receiveSectionViewsUpdate(s)), (f) => Log.error('Failed to receive section views: $f'), ); } }, ); } void _reset(UserProfilePB userProfile, String workspaceId) { _listener?.stop(); _listener = null; _initial(userProfile, workspaceId); } } @freezed class SidebarSectionsEvent with _$SidebarSectionsEvent { const factory SidebarSectionsEvent.initial( UserProfilePB userProfile, String workspaceId, ) = _Initial; const factory SidebarSectionsEvent.reset( UserProfilePB userProfile, String workspaceId, ) = _Reset; const factory SidebarSectionsEvent.createRootViewInSection({ required String name, required ViewSectionPB viewSection, int? index, }) = _CreateRootViewInSection; const factory SidebarSectionsEvent.moveRootView({ required int fromIndex, required int toIndex, required ViewSectionPB fromSection, required ViewSectionPB toSection, }) = _MoveRootView; const factory SidebarSectionsEvent.receiveSectionViewsUpdate( SectionViewsPB sectionViews, ) = _ReceiveSectionViewsUpdate; const factory SidebarSectionsEvent.reload( UserProfilePB userProfile, String workspaceId, ) = _Reload; } @freezed class SidebarSectionsState with _$SidebarSectionsState { const factory SidebarSectionsState({ required SidebarSection section, @Default(null) ViewPB? lastCreatedRootView, FlowyResult? createRootViewResult, @Default(true) bool containsSpace, }) = _SidebarSectionsState; factory SidebarSectionsState.initial() => const SidebarSectionsState( section: SidebarSection.empty(), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:local_notifier/local_notifier.dart'; /// The app name used in the local notification. /// /// DO NOT Use i18n here, because the i18n plugin is not ready /// before the local notification is initialized. const _localNotifierAppName = 'AppFlowy'; /// Manages Local Notifications /// /// Currently supports: /// - MacOS /// - Windows /// - Linux /// class NotificationService { static Future initialize() async { await localNotifier.setup( appName: _localNotifierAppName, // Don't create a shortcut on Windows, because the setup.exe will create a shortcut shortcutPolicy: ShortcutPolicy.requireNoCreate, ); } } /// Creates and shows a Notification /// class NotificationMessage { NotificationMessage({ required String title, required String body, String? identifier, VoidCallback? onClick, }) { _notification = LocalNotification( identifier: identifier, title: title, body: body, )..onClick = onClick; _show(); } late final LocalNotification _notification; void _show() => _notification.show(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart ================================================ import 'dart:async'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/recent/recent_listener.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; /// This is a lazy-singleton to share recent views across the application. /// /// Use-cases: /// - Desktop: Command Palette recent view history /// - Desktop: (Documents) Inline-page reference recent view history /// - Mobile: Recent view history on home screen /// /// See the related [LaunchTask] in [RecentServiceTask]. /// class CachedRecentService { CachedRecentService(); Completer _completer = Completer(); ValueNotifier> notifier = ValueNotifier(const []); List get _recentViews => notifier.value; set _recentViews(List value) => notifier.value = value; final _listener = RecentViewsListener(); bool isDisposed = false; Future> recentViews() async { if (_isInitialized || _completer.isCompleted) return _recentViews; _isInitialized = true; _listener.start(recentViewsUpdated: _recentViewsUpdated); _recentViews = await _readRecentViews().fold( (s) => s.items.unique((e) => e.item.id), (_) => [], ); _completer.complete(); return _recentViews; } /// Updates the recent views history Future> updateRecentViews( List viewIds, bool addInRecent, ) async { final List duplicatedViewIds = []; for (final viewId in viewIds) { for (final view in _recentViews) { if (view.item.id == viewId) { duplicatedViewIds.add(viewId); } } } return FolderEventUpdateRecentViews( UpdateRecentViewPayloadPB( viewIds: addInRecent ? viewIds : duplicatedViewIds, addInRecent: addInRecent, ), ).send(); } Future> _readRecentViews() async { final payload = ReadRecentViewsPB(start: Int64(), limit: Int64(100)); final result = await FolderEventReadRecentViews(payload).send(); return result.fold( (recentViews) { return FlowyResult.success( RepeatedRecentViewPB( // filter the space view and the orphan view items: recentViews.items.where( (e) => !e.item.isSpace && e.item.id != e.item.parentViewId, ), ), ); }, (error) => FlowyResult.failure(error), ); } bool _isInitialized = false; Future reset() async { await _listener.stop(); _resetCompleter(); _isInitialized = false; _recentViews = const []; } Future dispose() async { if (isDisposed) return; isDisposed = true; notifier.dispose(); await _listener.stop(); } void _recentViewsUpdated( FlowyResult result, ) async { final viewIds = result.toNullable(); if (viewIds != null) { _recentViews = await _readRecentViews().fold( (s) => s.items.unique((e) => e.item.id), (_) => [], ); } } void _resetCompleter() { if (!_completer.isCompleted) { _completer.complete(); } _completer = Completer(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/recent/prelude.dart ================================================ export 'cached_recent_service.dart'; export 'recent_views_bloc.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/recent/recent_listener.dart ================================================ import 'dart:async'; import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; typedef RecentViewsUpdated = void Function( FlowyResult result, ); class RecentViewsListener { StreamSubscription? _streamSubscription; FolderNotificationParser? _parser; RecentViewsUpdated? _recentViewsUpdated; void start({ RecentViewsUpdated? recentViewsUpdated, }) { _recentViewsUpdated = recentViewsUpdated; _parser = FolderNotificationParser( id: 'recent_views', callback: _observableCallback, ); _streamSubscription = RustStreamReceiver.listen( (observable) => _parser?.parse(observable), ); } void _observableCallback( FolderNotification ty, FlowyResult result, ) { if (_recentViewsUpdated == null) { return; } result.fold( (payload) { final view = RepeatedViewIdPB.fromBuffer(payload); _recentViewsUpdated?.call( FlowyResult.success(view), ); }, (error) => _recentViewsUpdated?.call( FlowyResult.failure(error), ), ); } Future stop() async { _parser = null; await _streamSubscription?.cancel(); _recentViewsUpdated = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'recent_views_bloc.freezed.dart'; class RecentViewsBloc extends Bloc { RecentViewsBloc() : super(RecentViewsState.initial()) { _service = getIt(); _dispatch(); } late final CachedRecentService _service; @override Future close() async { _service.notifier.removeListener(_onRecentViewsUpdated); return super.close(); } void _dispatch() { on( (event, emit) async { await event.map( initial: (e) async { _service.notifier.addListener(_onRecentViewsUpdated); add(const RecentViewsEvent.fetchRecentViews()); }, addRecentViews: (e) async { await _service.updateRecentViews(e.viewIds, true); }, removeRecentViews: (e) async { await _service.updateRecentViews(e.viewIds, false); }, fetchRecentViews: (e) async { emit( state.copyWith( isLoading: false, views: await _service.recentViews(), ), ); }, resetRecentViews: (e) async { await _service.reset(); add(const RecentViewsEvent.fetchRecentViews()); }, hoverView: (e) async { emit( state.copyWith(hoveredView: e.view), ); }, ); }, ); } void _onRecentViewsUpdated() => add(const RecentViewsEvent.fetchRecentViews()); } @freezed class RecentViewsEvent with _$RecentViewsEvent { const factory RecentViewsEvent.initial() = Initial; const factory RecentViewsEvent.addRecentViews(List viewIds) = AddRecentViews; const factory RecentViewsEvent.removeRecentViews(List viewIds) = RemoveRecentViews; const factory RecentViewsEvent.fetchRecentViews() = FetchRecentViews; const factory RecentViewsEvent.resetRecentViews() = ResetRecentViews; const factory RecentViewsEvent.hoverView(ViewPB view) = HoverView; } @freezed class RecentViewsState with _$RecentViewsState { const factory RecentViewsState({ required List views, @Default(true) bool isLoading, @Default(null) ViewPB? hoveredView, }) = _RecentViewsState; factory RecentViewsState.initial() => const RecentViewsState(views: []); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'local_llm_listener.dart'; part 'local_ai_bloc.freezed.dart'; class LocalAiPluginBloc extends Bloc { LocalAiPluginBloc() : super(const LoadingLocalAiPluginState()) { on(_handleEvent); _startListening(); _getLocalAiState(); } final listener = LocalAIStateListener(); @override Future close() async { await listener.stop(); return super.close(); } Future _handleEvent( LocalAiPluginEvent event, Emitter emit, ) async { if (isClosed) { return; } await event.when( didReceiveAiState: (aiState) { emit( LocalAiPluginState.ready( isEnabled: aiState.enabled, isReady: aiState.isReady, lackOfResource: aiState.hasLackOfResource() ? aiState.lackOfResource : null, ), ); }, didReceiveLackOfResources: (resources) { state.maybeMap( ready: (readyState) { emit(readyState.copyWith(lackOfResource: resources)); }, orElse: () {}, ); }, toggle: () async { emit(LocalAiPluginState.loading()); await AIEventToggleLocalAI().send().fold( (aiState) { if (!isClosed) { add(LocalAiPluginEvent.didReceiveAiState(aiState)); } }, Log.error, ); }, restart: () async { emit(LocalAiPluginState.loading()); await AIEventRestartLocalAI().send(); }, ); } void _startListening() { listener.start( stateCallback: (pluginState) { if (!isClosed) { add(LocalAiPluginEvent.didReceiveAiState(pluginState)); } }, resourceCallback: (data) { if (!isClosed) { add(LocalAiPluginEvent.didReceiveLackOfResources(data)); } }, ); } void _getLocalAiState() { AIEventGetLocalAIState().send().fold( (aiState) { if (!isClosed) { add(LocalAiPluginEvent.didReceiveAiState(aiState)); } }, Log.error, ); } } @freezed class LocalAiPluginEvent with _$LocalAiPluginEvent { const factory LocalAiPluginEvent.didReceiveAiState(LocalAIPB aiState) = _DidReceiveAiState; const factory LocalAiPluginEvent.didReceiveLackOfResources( LackOfAIResourcePB resources, ) = _DidReceiveLackOfResources; const factory LocalAiPluginEvent.toggle() = _Toggle; const factory LocalAiPluginEvent.restart() = _Restart; } @freezed class LocalAiPluginState with _$LocalAiPluginState { const LocalAiPluginState._(); const factory LocalAiPluginState.ready({ required bool isEnabled, required bool isReady, required LackOfAIResourcePB? lackOfResource, }) = ReadyLocalAiPluginState; const factory LocalAiPluginState.loading() = LoadingLocalAiPluginState; bool get isEnabled { return maybeWhen( ready: (isEnabled, _, ___) => isEnabled, orElse: () => false, ); } bool get showIndicator { return maybeWhen( ready: (isEnabled, isReady, lackOfResource) => isReady || lackOfResource != null, orElse: () => false, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'local_ai_on_boarding_bloc.freezed.dart'; class LocalAIOnBoardingBloc extends Bloc { LocalAIOnBoardingBloc( this.userProfile, this.currentWorkspaceMemberRole, this.workspaceId, ) : super(const LocalAIOnBoardingState()) { _userService = UserBackendService(userId: userProfile.id); _successListenable = getIt(); _successListenable.addListener(_onPaymentSuccessful); _dispatch(); } void _onPaymentSuccessful() { if (isClosed) { return; } add( LocalAIOnBoardingEvent.paymentSuccessful( _successListenable.subscribedPlan, ), ); } final UserProfilePB userProfile; final AFRolePB? currentWorkspaceMemberRole; final String workspaceId; late final IUserBackendService _userService; late final SubscriptionSuccessListenable _successListenable; @override Future close() async { _successListenable.removeListener(_onPaymentSuccessful); await super.close(); } void _dispatch() { on((event, emit) { event.when( started: () { _loadSubscriptionPlans(); }, addSubscription: (plan) async { emit(state.copyWith(isLoading: true)); final result = await _userService.createSubscription( workspaceId, plan, ); result.fold( (pl) => afLaunchUrlString(pl.paymentLink), (f) => Log.error( 'Failed to fetch paymentlink for $plan: ${f.msg}', f, ), ); }, didGetSubscriptionPlans: (result) { result.fold( (workspaceSubInfo) { final isPurchaseAILocal = workspaceSubInfo.addOns.any((addOn) { return addOn.type == WorkspaceAddOnPBType.AddOnAiLocal; }); emit( state.copyWith(isPurchaseAILocal: isPurchaseAILocal), ); }, (err) { Log.warn("Failed to get subscription plans: $err"); }, ); }, paymentSuccessful: (SubscriptionPlanPB? plan) { if (plan == SubscriptionPlanPB.AiLocal) { emit(state.copyWith(isPurchaseAILocal: true, isLoading: false)); } }, ); }); } void _loadSubscriptionPlans() { final payload = UserWorkspaceIdPB()..workspaceId = workspaceId; UserEventGetWorkspaceSubscriptionInfo(payload).send().then((result) { if (!isClosed) { add(LocalAIOnBoardingEvent.didGetSubscriptionPlans(result)); } }); } } @freezed class LocalAIOnBoardingEvent with _$LocalAIOnBoardingEvent { const factory LocalAIOnBoardingEvent.started() = _Started; const factory LocalAIOnBoardingEvent.addSubscription( SubscriptionPlanPB plan, ) = _AddSubscription; const factory LocalAIOnBoardingEvent.paymentSuccessful( SubscriptionPlanPB? plan, ) = _PaymentSuccessful; const factory LocalAIOnBoardingEvent.didGetSubscriptionPlans( FlowyResult result, ) = _LoadSubscriptionPlans; } @freezed class LocalAIOnBoardingState with _$LocalAIOnBoardingState { const factory LocalAIOnBoardingState({ @Default(false) bool isPurchaseAILocal, @Default(false) bool isLoading, }) = _LocalAIOnBoardingState; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_llm_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; typedef PluginStateCallback = void Function(LocalAIPB state); typedef PluginResourceCallback = void Function(LackOfAIResourcePB data); class LocalAIStateListener { LocalAIStateListener() { _parser = ChatNotificationParser(id: "appflowy_ai_plugin", callback: _callback); _subscription = RustStreamReceiver.listen( (observable) => _parser?.parse(observable), ); } StreamSubscription? _subscription; ChatNotificationParser? _parser; PluginStateCallback? stateCallback; PluginResourceCallback? resourceCallback; void start({ PluginStateCallback? stateCallback, PluginResourceCallback? resourceCallback, }) { this.stateCallback = stateCallback; this.resourceCallback = resourceCallback; } void _callback( ChatNotification ty, FlowyResult result, ) { result.map((r) { switch (ty) { case ChatNotification.UpdateLocalAIState: stateCallback?.call(LocalAIPB.fromBuffer(r)); break; case ChatNotification.LocalAIResourceUpdated: resourceCallback?.call(LackOfAIResourcePB.fromBuffer(r)); break; default: break; } }); } Future stop() async { await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'ollama_setting_bloc.freezed.dart'; const kDefaultChatModel = 'llama3.1:latest'; const kDefaultEmbeddingModel = 'nomic-embed-text:latest'; /// Extension methods to map between PB and UI models class OllamaSettingBloc extends Bloc { OllamaSettingBloc() : super(const OllamaSettingState()) { on<_Started>(_handleStarted); on<_DidLoadLocalModels>(_onLoadLocalModels); on<_DidLoadSetting>(_onLoadSetting); on<_UpdateSetting>(_onLoadSetting); on<_OnEdit>(_onEdit); on<_OnSubmit>(_onSubmit); on<_SetDefaultModel>(_onSetDefaultModel); } Future _handleStarted( _Started event, Emitter emit, ) async { try { final results = await Future.wait([ AIEventGetLocalModelSelection().send().then((r) => r.getOrThrow()), AIEventGetLocalAISetting().send().then((r) => r.getOrThrow()), ]); final models = results[0] as ModelSelectionPB; final setting = results[1] as LocalAISettingPB; if (!isClosed) { add(OllamaSettingEvent.didLoadLocalModels(models)); add(OllamaSettingEvent.didLoadSetting(setting)); } } catch (e, st) { Log.error('Failed to load initial AI data: $e\n$st'); } } void _onLoadLocalModels( _DidLoadLocalModels event, Emitter emit, ) { emit(state.copyWith(localModels: event.models)); } void _onLoadSetting( dynamic event, Emitter emit, ) { final setting = (event as dynamic).setting as LocalAISettingPB; final submitted = setting.toSubmittedItems(); emit( state.copyWith( setting: setting, inputItems: setting.toInputItems(), submittedItems: submitted, originalMap: { for (final item in submitted) item.settingType: item.content, }, isEdited: false, ), ); } void _onEdit( _OnEdit event, Emitter emit, ) { final updated = state.submittedItems .map( (item) => item.settingType == event.settingType ? item.copyWith(content: event.content) : item, ) .toList(); final currentMap = {for (final i in updated) i.settingType: i.content}; final isEdited = !const MapEquality() .equals(state.originalMap, currentMap); emit(state.copyWith(submittedItems: updated, isEdited: isEdited)); } void _onSubmit( _OnSubmit event, Emitter emit, ) { final pb = LocalAISettingPB(); for (final item in state.submittedItems) { switch (item.settingType) { case SettingType.serverUrl: pb.serverUrl = item.content; break; case SettingType.chatModel: pb.globalChatModel = state.selectedModel?.name ?? item.content; break; case SettingType.embeddingModel: pb.embeddingModelName = item.content; break; } } add(OllamaSettingEvent.updateSetting(pb)); AIEventUpdateLocalAISetting(pb).send().fold( (_) => Log.info('AI setting updated successfully'), (err) => Log.error('Update AI setting failed: $err'), ); } void _onSetDefaultModel( _SetDefaultModel event, Emitter emit, ) { emit(state.copyWith(selectedModel: event.model, isEdited: true)); } } /// Setting types for mapping enum SettingType { serverUrl, chatModel, embeddingModel; String get title { switch (this) { case SettingType.serverUrl: return 'Ollama server url'; case SettingType.chatModel: return 'Default model name'; case SettingType.embeddingModel: return 'Embedding model name'; } } } /// Input field representation class SettingItem extends Equatable { const SettingItem({ required this.content, required this.hintText, required this.settingType, this.editable = true, }); final String content; final String hintText; final SettingType settingType; final bool editable; @override List get props => [content, settingType, editable]; } /// Items pending submission class SubmittedItem extends Equatable { const SubmittedItem({ required this.content, required this.settingType, }); final String content; final SettingType settingType; /// Returns a copy of this SubmittedItem with given fields updated. SubmittedItem copyWith({ String? content, SettingType? settingType, }) { return SubmittedItem( content: content ?? this.content, settingType: settingType ?? this.settingType, ); } @override List get props => [content, settingType]; } @freezed class OllamaSettingEvent with _$OllamaSettingEvent { const factory OllamaSettingEvent.started() = _Started; const factory OllamaSettingEvent.didLoadLocalModels( ModelSelectionPB models, ) = _DidLoadLocalModels; const factory OllamaSettingEvent.didLoadSetting( LocalAISettingPB setting, ) = _DidLoadSetting; const factory OllamaSettingEvent.updateSetting( LocalAISettingPB setting, ) = _UpdateSetting; const factory OllamaSettingEvent.setDefaultModel( AIModelPB model, ) = _SetDefaultModel; const factory OllamaSettingEvent.onEdit( String content, SettingType settingType, ) = _OnEdit; const factory OllamaSettingEvent.submit() = _OnSubmit; } @freezed class OllamaSettingState with _$OllamaSettingState { const factory OllamaSettingState({ LocalAISettingPB? setting, @Default([]) List inputItems, AIModelPB? selectedModel, ModelSelectionPB? localModels, AIModelPB? defaultModel, @Default([]) List submittedItems, @Default(false) bool isEdited, @Default({}) Map originalMap, }) = _OllamaSettingState; } extension on LocalAISettingPB { List toInputItems() => [ SettingItem( content: serverUrl, hintText: 'http://localhost:11434', settingType: SettingType.serverUrl, ), SettingItem( content: embeddingModelName, hintText: kDefaultEmbeddingModel, settingType: SettingType.embeddingModel, editable: false, // embedding model is not editable ), ]; List toSubmittedItems() => [ SubmittedItem( content: serverUrl, settingType: SettingType.serverUrl, ), SubmittedItem( content: globalChatModel, settingType: SettingType.chatModel, ), SubmittedItem( content: embeddingModelName, settingType: SettingType.embeddingModel, ), ]; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart ================================================ import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart'; import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_ai_bloc.freezed.dart'; const String aiModelsGlobalActiveModel = "global_active_model"; class SettingsAIBloc extends Bloc { SettingsAIBloc( this.userProfile, this.workspaceId, ) : _userListener = UserListener(userProfile: userProfile), _aiModelSwitchListener = AIModelSwitchListener(objectId: aiModelsGlobalActiveModel), super( SettingsAIState( userProfile: userProfile, ), ) { _aiModelSwitchListener.start( onUpdateSelectedModel: (model) { if (!isClosed) { _loadModelList(); } }, ); _dispatch(); } final UserListener _userListener; final UserProfilePB userProfile; final String workspaceId; final AIModelSwitchListener _aiModelSwitchListener; @override Future close() async { await _userListener.stop(); await _aiModelSwitchListener.stop(); return super.close(); } void _dispatch() { on((event, emit) async { await event.when( started: () { _userListener.start( onProfileUpdated: _onProfileUpdated, onUserWorkspaceSettingUpdated: (settings) { if (!isClosed) { add(SettingsAIEvent.didLoadWorkspaceSetting(settings)); } }, ); _loadModelList(); _loadUserWorkspaceSetting(); }, didReceiveUserProfile: (userProfile) { emit(state.copyWith(userProfile: userProfile)); }, toggleAISearch: () { emit( state.copyWith(enableSearchIndexing: !state.enableSearchIndexing), ); _updateUserWorkspaceSetting( disableSearchIndexing: !(state.aiSettings?.disableSearchIndexing ?? false), ); }, selectModel: (AIModelPB model) async { await AIEventUpdateSelectedModel( UpdateSelectedModelPB( source: aiModelsGlobalActiveModel, selectedModel: model, ), ).send(); }, didLoadWorkspaceSetting: (WorkspaceSettingsPB settings) { emit( state.copyWith( aiSettings: settings, enableSearchIndexing: !settings.disableSearchIndexing, ), ); }, didLoadAvailableModels: (ModelSelectionPB models) { emit( state.copyWith( availableModels: models, ), ); }, ); }); } Future> _updateUserWorkspaceSetting({ bool? disableSearchIndexing, String? model, }) async { final payload = UpdateUserWorkspaceSettingPB( workspaceId: workspaceId, ); if (disableSearchIndexing != null) { payload.disableSearchIndexing = disableSearchIndexing; } if (model != null) { payload.aiModel = model; } final result = await UserEventUpdateWorkspaceSetting(payload).send(); result.fold( (ok) => Log.info('Update workspace setting success'), (err) => Log.error('Update workspace setting failed: $err'), ); return result; } void _onProfileUpdated( FlowyResult userProfileOrFailed, ) => userProfileOrFailed.fold( (profile) => add(SettingsAIEvent.didReceiveUserProfile(profile)), (err) => Log.error(err), ); void _loadModelList() { final payload = ModelSourcePB(source: aiModelsGlobalActiveModel); AIEventGetSettingModelSelection(payload).send().then((result) { result.fold((models) { if (!isClosed) { add(SettingsAIEvent.didLoadAvailableModels(models)); } }, (err) { Log.error(err); }); }); } void _loadUserWorkspaceSetting() { final payload = UserWorkspaceIdPB(workspaceId: workspaceId); UserEventGetWorkspaceSetting(payload).send().then((result) { result.fold((settings) { if (!isClosed) { add(SettingsAIEvent.didLoadWorkspaceSetting(settings)); } }, (err) { Log.error(err); }); }); } } @freezed class SettingsAIEvent with _$SettingsAIEvent { const factory SettingsAIEvent.started() = _Started; const factory SettingsAIEvent.didLoadWorkspaceSetting( WorkspaceSettingsPB settings, ) = _DidLoadWorkspaceSetting; const factory SettingsAIEvent.toggleAISearch() = _toggleAISearch; const factory SettingsAIEvent.selectModel(AIModelPB model) = _SelectAIModel; const factory SettingsAIEvent.didReceiveUserProfile( UserProfilePB newUserProfile, ) = _DidReceiveUserProfile; const factory SettingsAIEvent.didLoadAvailableModels( ModelSelectionPB models, ) = _DidLoadAvailableModels; } @freezed class SettingsAIState with _$SettingsAIState { const factory SettingsAIState({ required UserProfilePB userProfile, WorkspaceSettingsPB? aiSettings, ModelSelectionPB? availableModels, @Default(true) bool enableSearchIndexing, }) = _SettingsAIState; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart ================================================ import 'dart:async'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' show AppFlowyEditorLocalizations; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:universal_platform/universal_platform.dart'; part 'appearance_cubit.freezed.dart'; /// [AppearanceSettingsCubit] is used to modify the appearance of AppFlowy. /// It includes: /// - [AppTheme] /// - [ThemeMode] /// - [TextStyle]'s /// - [Locale] /// - [UserDateFormatPB] /// - [UserTimeFormatPB] /// class AppearanceSettingsCubit extends Cubit { AppearanceSettingsCubit( AppearanceSettingsPB appearanceSettings, DateTimeSettingsPB dateTimeSettings, AppTheme appTheme, ) : _appearanceSettings = appearanceSettings, _dateTimeSettings = dateTimeSettings, super( AppearanceSettingsState.initial( appTheme, appearanceSettings.themeMode, appearanceSettings.font, appearanceSettings.layoutDirection, appearanceSettings.textDirection, appearanceSettings.enableRtlToolbarItems, appearanceSettings.locale, appearanceSettings.isMenuCollapsed, appearanceSettings.menuOffset, dateTimeSettings.dateFormat, dateTimeSettings.timeFormat, dateTimeSettings.timezoneId, appearanceSettings.documentSetting.cursorColor.isEmpty ? null : Color( int.parse(appearanceSettings.documentSetting.cursorColor), ), appearanceSettings.documentSetting.selectionColor.isEmpty ? null : Color( int.parse( appearanceSettings.documentSetting.selectionColor, ), ), 1.0, ), ) { readTextScaleFactor(); } final AppearanceSettingsPB _appearanceSettings; final DateTimeSettingsPB _dateTimeSettings; Future setTextScaleFactor(double textScaleFactor) async { // only saved in local storage, this value is not synced across devices await getIt().set( KVKeys.textScaleFactor, textScaleFactor.toString(), ); // don't allow the text scale factor to be greater than 1.0, it will cause // ui issues emit(state.copyWith(textScaleFactor: textScaleFactor.clamp(0.7, 1.0))); } Future readTextScaleFactor() async { final textScaleFactor = await getIt().getWithFormat( KVKeys.textScaleFactor, (value) => double.parse(value), ) ?? 1.0; emit(state.copyWith(textScaleFactor: textScaleFactor.clamp(0.7, 1.0))); } /// Update selected theme in the user's settings and emit an updated state /// with the AppTheme named [themeName]. Future setTheme(String themeName) async { _appearanceSettings.theme = themeName; unawaited(_saveAppearanceSettings()); try { final theme = await AppTheme.fromName(themeName); emit(state.copyWith(appTheme: theme)); } catch (e) { Log.error("Error setting theme: $e"); if (UniversalPlatform.isMacOS) { showToastNotification( message: LocaleKeys.settings_workspacePage_theme_failedToLoadThemes.tr(), type: ToastificationType.error, ); } } } /// Reset the current user selected theme back to the default Future resetTheme() => setTheme(DefaultAppearanceSettings.kDefaultThemeName); /// Update the theme mode in the user's settings and emit an updated state. void setThemeMode(ThemeMode themeMode) { _appearanceSettings.themeMode = _themeModeToPB(themeMode); _saveAppearanceSettings(); emit(state.copyWith(themeMode: themeMode)); } /// Resets the current brightness setting void resetThemeMode() => setThemeMode(DefaultAppearanceSettings.kDefaultThemeMode); /// Toggle the theme mode void toggleThemeMode() { final currentThemeMode = state.themeMode; setThemeMode( currentThemeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light, ); } void setLayoutDirection(LayoutDirection layoutDirection) { _appearanceSettings.layoutDirection = layoutDirection.toLayoutDirectionPB(); _saveAppearanceSettings(); emit(state.copyWith(layoutDirection: layoutDirection)); } void setTextDirection(AppFlowyTextDirection textDirection) { _appearanceSettings.textDirection = textDirection.toTextDirectionPB(); _saveAppearanceSettings(); emit(state.copyWith(textDirection: textDirection)); } void setEnableRTLToolbarItems(bool value) { _appearanceSettings.enableRtlToolbarItems = value; _saveAppearanceSettings(); emit(state.copyWith(enableRtlToolbarItems: value)); } /// Update selected font in the user's settings and emit an updated state /// with the font name. void setFontFamily(String fontFamilyName) { _appearanceSettings.font = fontFamilyName; _saveAppearanceSettings(); emit(state.copyWith(font: fontFamilyName)); } /// Resets the current font family for the user preferences void resetFontFamily() => setFontFamily(DefaultAppearanceSettings.kDefaultFontFamily); /// Update document cursor color in the appearance settings and emit an updated state. void setDocumentCursorColor(Color color) { _appearanceSettings.documentSetting.cursorColor = color.toHexString(); _saveAppearanceSettings(); emit(state.copyWith(documentCursorColor: color)); } /// Reset document cursor color in the appearance settings void resetDocumentCursorColor() { _appearanceSettings.documentSetting.cursorColor = ''; _saveAppearanceSettings(); emit(state.copyWith(documentCursorColor: null)); } /// Update document selection color in the appearance settings and emit an updated state. void setDocumentSelectionColor(Color color) { _appearanceSettings.documentSetting.selectionColor = color.toHexString(); _saveAppearanceSettings(); emit(state.copyWith(documentSelectionColor: color)); } /// Reset document selection color in the appearance settings void resetDocumentSelectionColor() { _appearanceSettings.documentSetting.selectionColor = ''; _saveAppearanceSettings(); emit(state.copyWith(documentSelectionColor: null)); } /// Updates the current locale and notify the listeners the locale was /// changed. Fallback to [en] locale if [newLocale] is not supported. void setLocale(BuildContext context, Locale newLocale) { if (!context.supportedLocales.contains(newLocale)) { // Log.warn("Unsupported locale: $newLocale, Fallback to locale: en"); newLocale = const Locale('en', 'US'); } context.setLocale(newLocale).catchError((e) { Log.warn('Catch error in setLocale: $e}'); }); // Sync the app's locale with the editor (initialization and update) AppFlowyEditorLocalizations.load(newLocale); if (state.locale != newLocale) { _appearanceSettings.locale.languageCode = newLocale.languageCode; _appearanceSettings.locale.countryCode = newLocale.countryCode ?? ""; _saveAppearanceSettings(); emit(state.copyWith(locale: newLocale)); } } // Saves the menus current visibility void saveIsMenuCollapsed(bool collapsed) { _appearanceSettings.isMenuCollapsed = collapsed; _saveAppearanceSettings(); } // Saves the current resize offset of the menu void saveMenuOffset(double offset) { _appearanceSettings.menuOffset = offset; _saveAppearanceSettings(); } /// Saves key/value setting to disk. /// Removes the key if the passed in value is null void setKeyValue(String key, String? value) { if (key.isEmpty) { Log.warn("The key should not be empty"); return; } if (value == null) { _appearanceSettings.settingKeyValue.remove(key); } if (_appearanceSettings.settingKeyValue[key] != value) { if (value == null) { _appearanceSettings.settingKeyValue.remove(key); } else { _appearanceSettings.settingKeyValue[key] = value; } } _saveAppearanceSettings(); } String? getValue(String key) { if (key.isEmpty) { Log.warn("The key should not be empty"); return null; } return _appearanceSettings.settingKeyValue[key]; } /// Called when the application launches. /// Uses the device locale when the application is opened for the first time. void readLocaleWhenAppLaunch(BuildContext context) { if (_appearanceSettings.resetToDefault) { _appearanceSettings.resetToDefault = false; _saveAppearanceSettings(); setLocale(context, context.deviceLocale); return; } setLocale(context, state.locale); } void setDateFormat(UserDateFormatPB format) { _dateTimeSettings.dateFormat = format; _saveDateTimeSettings(); emit(state.copyWith(dateFormat: format)); } void setTimeFormat(UserTimeFormatPB format) { _dateTimeSettings.timeFormat = format; _saveDateTimeSettings(); emit(state.copyWith(timeFormat: format)); } Future _saveDateTimeSettings() async { final result = await UserSettingsBackendService() .setDateTimeSettings(_dateTimeSettings); result.fold( (_) => null, (error) => Log.error(error), ); } Future _saveAppearanceSettings() async { final result = await UserSettingsBackendService() .setAppearanceSetting(_appearanceSettings); result.fold( (l) => null, (error) => Log.error(error), ); } } ThemeMode _themeModeFromPB(ThemeModePB themeModePB) { switch (themeModePB) { case ThemeModePB.Light: return ThemeMode.light; case ThemeModePB.Dark: return ThemeMode.dark; case ThemeModePB.System: default: return ThemeMode.system; } } ThemeModePB _themeModeToPB(ThemeMode themeMode) { switch (themeMode) { case ThemeMode.light: return ThemeModePB.Light; case ThemeMode.dark: return ThemeModePB.Dark; case ThemeMode.system: return ThemeModePB.System; } } enum LayoutDirection { ltrLayout, rtlLayout; static LayoutDirection fromLayoutDirectionPB( LayoutDirectionPB layoutDirectionPB, ) => layoutDirectionPB == LayoutDirectionPB.RTLLayout ? LayoutDirection.rtlLayout : LayoutDirection.ltrLayout; LayoutDirectionPB toLayoutDirectionPB() => this == LayoutDirection.rtlLayout ? LayoutDirectionPB.RTLLayout : LayoutDirectionPB.LTRLayout; } enum AppFlowyTextDirection { ltr, rtl, auto; static AppFlowyTextDirection fromTextDirectionPB( TextDirectionPB? textDirectionPB, ) { switch (textDirectionPB) { case TextDirectionPB.LTR: return AppFlowyTextDirection.ltr; case TextDirectionPB.RTL: return AppFlowyTextDirection.rtl; case TextDirectionPB.AUTO: return AppFlowyTextDirection.auto; default: return AppFlowyTextDirection.ltr; } } TextDirectionPB toTextDirectionPB() { switch (this) { case AppFlowyTextDirection.ltr: return TextDirectionPB.LTR; case AppFlowyTextDirection.rtl: return TextDirectionPB.RTL; case AppFlowyTextDirection.auto: return TextDirectionPB.AUTO; } } } @freezed class AppearanceSettingsState with _$AppearanceSettingsState { const AppearanceSettingsState._(); const factory AppearanceSettingsState({ required AppTheme appTheme, required ThemeMode themeMode, required String font, required LayoutDirection layoutDirection, required AppFlowyTextDirection textDirection, required bool enableRtlToolbarItems, required Locale locale, required bool isMenuCollapsed, required double menuOffset, required UserDateFormatPB dateFormat, required UserTimeFormatPB timeFormat, required String timezoneId, required Color? documentCursorColor, required Color? documentSelectionColor, required double textScaleFactor, }) = _AppearanceSettingsState; factory AppearanceSettingsState.initial( AppTheme appTheme, ThemeModePB themeModePB, String font, LayoutDirectionPB layoutDirectionPB, TextDirectionPB? textDirectionPB, bool enableRtlToolbarItems, LocaleSettingsPB localePB, bool isMenuCollapsed, double menuOffset, UserDateFormatPB dateFormat, UserTimeFormatPB timeFormat, String timezoneId, Color? documentCursorColor, Color? documentSelectionColor, double textScaleFactor, ) { return AppearanceSettingsState( appTheme: appTheme, font: font, layoutDirection: LayoutDirection.fromLayoutDirectionPB(layoutDirectionPB), textDirection: AppFlowyTextDirection.fromTextDirectionPB(textDirectionPB), enableRtlToolbarItems: enableRtlToolbarItems, themeMode: _themeModeFromPB(themeModePB), locale: Locale(localePB.languageCode, localePB.countryCode), isMenuCollapsed: isMenuCollapsed, menuOffset: menuOffset, dateFormat: dateFormat, timeFormat: timeFormat, timezoneId: timezoneId, documentCursorColor: documentCursorColor, documentSelectionColor: documentSelectionColor, textScaleFactor: textScaleFactor, ); } ThemeData get lightTheme => _getThemeData(Brightness.light); ThemeData get darkTheme => _getThemeData(Brightness.dark); ThemeData _getThemeData(Brightness brightness) { return getIt().getThemeData( appTheme, brightness, font, builtInCodeFontFamily, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart ================================================ import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flutter/material.dart'; // the default font family is empty, so we can use the default font family of the platform // the system will choose the default font family of the platform // iOS: San Francisco // Android: Roboto // Desktop: Based on the OS const defaultFontFamily = ''; const builtInCodeFontFamily = 'RobotoMono'; abstract class BaseAppearance { final white = const Color(0xFFFFFFFF); final Set scrollbarInteractiveStates = { WidgetState.pressed, WidgetState.hovered, WidgetState.dragged, }; TextStyle getFontStyle({ required String fontFamily, double? fontSize, FontWeight? fontWeight, Color? fontColor, double? letterSpacing, double? lineHeight, }) { fontSize = fontSize ?? FontSizes.s14; fontWeight = fontWeight ?? FontWeight.w400; letterSpacing = fontSize * (letterSpacing ?? 0.005); final textStyle = TextStyle( fontFamily: fontFamily.isEmpty ? null : fontFamily, fontSize: fontSize, color: fontColor, fontWeight: fontWeight, letterSpacing: letterSpacing, height: lineHeight, ); if (fontFamily == defaultFontFamily) { return textStyle; } try { return getGoogleFontSafely( fontFamily, fontSize: fontSize, fontColor: fontColor, fontWeight: fontWeight, letterSpacing: letterSpacing, lineHeight: lineHeight, ); } catch (e) { return textStyle; } } TextTheme getTextTheme({ required String fontFamily, required Color fontColor, }) { return TextTheme( displayLarge: getFontStyle( fontFamily: fontFamily, fontSize: FontSizes.s32, fontColor: fontColor, fontWeight: FontWeight.w600, lineHeight: 42.0, ), // h2 displayMedium: getFontStyle( fontFamily: fontFamily, fontSize: FontSizes.s24, fontColor: fontColor, fontWeight: FontWeight.w600, lineHeight: 34.0, ), // h3 displaySmall: getFontStyle( fontFamily: fontFamily, fontSize: FontSizes.s20, fontColor: fontColor, fontWeight: FontWeight.w600, lineHeight: 28.0, ), // h4 titleLarge: getFontStyle( fontFamily: fontFamily, fontSize: FontSizes.s18, fontColor: fontColor, fontWeight: FontWeight.w600, ), // title titleMedium: getFontStyle( fontFamily: fontFamily, fontSize: FontSizes.s16, fontColor: fontColor, fontWeight: FontWeight.w600, ), // heading titleSmall: getFontStyle( fontFamily: fontFamily, fontSize: FontSizes.s14, fontColor: fontColor, fontWeight: FontWeight.w600, ), // subheading bodyMedium: getFontStyle( fontFamily: fontFamily, fontColor: fontColor, ), // body-regular bodySmall: getFontStyle( fontFamily: fontFamily, fontColor: fontColor, fontWeight: FontWeight.w400, ), // body-thin ); } ThemeData getThemeData( AppTheme appTheme, Brightness brightness, String fontFamily, String codeFontFamily, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart ================================================ import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; class DesktopAppearance extends BaseAppearance { @override ThemeData getThemeData( AppTheme appTheme, Brightness brightness, String fontFamily, String codeFontFamily, ) { assert(codeFontFamily.isNotEmpty); fontFamily = fontFamily.isEmpty ? defaultFontFamily : fontFamily; final isLight = brightness == Brightness.light; final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme; final colorScheme = ColorScheme( brightness: brightness, primary: theme.primary, onPrimary: theme.onPrimary, primaryContainer: theme.main2, onPrimaryContainer: white, // page title hover color secondary: theme.hoverBG1, onSecondary: theme.shader1, // setting value hover color secondaryContainer: theme.selector, onSecondaryContainer: theme.topbarBg, tertiary: theme.shader7, // Editor: toolbarColor onTertiary: theme.toolbarColor, tertiaryContainer: theme.questionBubbleBG, surface: theme.surface, // text&icon color when it is hovered onSurface: theme.hoverFG, // grey hover color inverseSurface: theme.hoverBG3, onError: theme.onPrimary, error: theme.red, outline: theme.shader4, surfaceContainerHighest: theme.sidebarBg, shadow: theme.shadow, ); // Due to Desktop version has multiple themes, it relies on the current theme to build the ThemeData return ThemeData( visualDensity: VisualDensity.standard, useMaterial3: false, brightness: brightness, dialogBackgroundColor: theme.surface, textTheme: getTextTheme( fontFamily: fontFamily, fontColor: theme.text, ), textButtonTheme: const TextButtonThemeData( style: ButtonStyle( minimumSize: WidgetStatePropertyAll(Size.zero), ), ), textSelectionTheme: TextSelectionThemeData( cursorColor: theme.main2, selectionHandleColor: theme.main2, ), iconTheme: IconThemeData(color: theme.icon), tooltipTheme: TooltipThemeData( textStyle: getFontStyle( fontFamily: fontFamily, fontSize: FontSizes.s11, fontWeight: FontWeight.w400, fontColor: theme.surface, ), ), scaffoldBackgroundColor: theme.surface, snackBarTheme: SnackBarThemeData( backgroundColor: colorScheme.primary, contentTextStyle: TextStyle(color: colorScheme.onSurface), ), scrollbarTheme: ScrollbarThemeData( thumbColor: WidgetStateProperty.resolveWith( (states) => states.any(scrollbarInteractiveStates.contains) ? theme.scrollbarHoverColor : theme.scrollbarColor, ), thickness: WidgetStateProperty.resolveWith((_) => 4.0), crossAxisMargin: 0.0, mainAxisMargin: 6.0, radius: Corners.s10Radius, ), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, //dropdown menu color canvasColor: theme.surface, dividerColor: theme.divider, hintColor: theme.hint, //action item hover color hoverColor: theme.hoverBG2, disabledColor: theme.shader4, highlightColor: theme.main1, indicatorColor: theme.main1, cardColor: theme.input, colorScheme: colorScheme, extensions: [ AFThemeExtension( warning: theme.yellow, success: theme.green, tint1: theme.tint1, tint2: theme.tint2, tint3: theme.tint3, tint4: theme.tint4, tint5: theme.tint5, tint6: theme.tint6, tint7: theme.tint7, tint8: theme.tint8, tint9: theme.tint9, textColor: theme.text, secondaryTextColor: theme.secondaryText, strongText: theme.strongText, greyHover: theme.hoverBG1, greySelect: theme.bg3, lightGreyHover: theme.hoverBG3, toggleOffFill: theme.shader5, progressBarBGColor: theme.progressBarBGColor, toggleButtonBGColor: theme.toggleButtonBGColor, calendarWeekendBGColor: theme.calendarWeekendBGColor, gridRowCountColor: theme.gridRowCountColor, code: getFontStyle( fontFamily: codeFontFamily, fontColor: theme.shader3, ), callout: getFontStyle( fontFamily: fontFamily, fontSize: FontSizes.s11, fontColor: theme.shader3, ), calloutBGColor: theme.hoverBG3, tableCellBGColor: theme.surface, caption: getFontStyle( fontFamily: fontFamily, fontSize: FontSizes.s11, fontWeight: FontWeight.w400, fontColor: theme.hint, ), onBackground: theme.text, background: theme.surface, borderColor: theme.borderColor, scrollbarColor: theme.scrollbarColor, scrollbarHoverColor: theme.scrollbarHoverColor, lightIconColor: theme.lightIconColor, toolbarHoverColor: theme.toolbarHoverColor, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart ================================================ // ThemeData in mobile import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; class MobileAppearance extends BaseAppearance { static const _primaryColor = Color(0xFF00BCF0); //primary 100 static const _onBackgroundColor = Color(0xff2F3030); // text/title color static const _onSurfaceColor = Color(0xff676666); // text/body color static const _onSecondaryColor = Color(0xFFC5C7CB); // text/body2 color static const _hintColorInDarkMode = Color(0xff626262); // hint color @override ThemeData getThemeData( AppTheme appTheme, Brightness brightness, String fontFamily, String codeFontFamily, ) { assert(codeFontFamily.isNotEmpty); final fontStyle = getFontStyle( fontFamily: fontFamily, fontSize: 16.0, fontWeight: FontWeight.w400, ); final isLight = brightness == Brightness.light; final codeFontStyle = getFontStyle(fontFamily: codeFontFamily); final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme; final colorTheme = isLight ? ColorScheme( brightness: brightness, primary: _primaryColor, onPrimary: Colors.white, // group card header background color primaryContainer: const Color(0xffF1F1F4), // primary 20 // group card & property edit background color secondary: const Color(0xfff7f8fc), // shade 10 onSecondary: _onSecondaryColor, // hidden group title & card text color tertiary: const Color(0xff858585), // for light text error: const Color(0xffFB006D), onError: const Color(0xffFB006D), outline: const Color(0xffe3e3e3), outlineVariant: const Color(0xffCBD5E0).withValues(alpha: 0.24), //Snack bar surface: Colors.white, onSurface: _onSurfaceColor, // text/body color surfaceContainerHighest: theme.sidebarBg, ) : ColorScheme( brightness: brightness, primary: _primaryColor, onPrimary: Colors.black, secondary: const Color(0xff2d2d2d), //temp onSecondary: Colors.white, tertiary: const Color(0xff858585), // temp error: const Color(0xffFB006D), onError: const Color(0xffFB006D), outline: _hintColorInDarkMode, outlineVariant: Colors.black, //Snack bar surface: const Color(0xFF171A1F), onSurface: const Color(0xffC5C6C7), // text/body color surfaceContainerHighest: theme.sidebarBg, ); final hintColor = isLight ? const Color(0x991F2329) : _hintColorInDarkMode; final onBackground = isLight ? _onBackgroundColor : Colors.white; final background = isLight ? Colors.white : const Color(0xff121212); return ThemeData( useMaterial3: false, primaryColor: colorTheme.primary, //primary 100 primaryColorLight: const Color(0xFF57B5F8), //primary 80 dividerColor: colorTheme.outline, //caption hintColor: hintColor, disabledColor: colorTheme.outline, scaffoldBackgroundColor: background, appBarTheme: AppBarTheme( toolbarHeight: 44.0, foregroundColor: onBackground, backgroundColor: background, centerTitle: false, titleTextStyle: TextStyle( color: onBackground, fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: 0.05, ), shadowColor: colorTheme.outlineVariant, ), radioTheme: RadioThemeData( fillColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { return colorTheme.primary; } return colorTheme.outline; }), ), // button elevatedButtonTheme: ElevatedButtonThemeData( style: ButtonStyle( fixedSize: WidgetStateProperty.all(const Size.fromHeight(48)), elevation: WidgetStateProperty.all(0), textStyle: WidgetStateProperty.all( TextStyle( fontSize: 14, fontFamily: fontStyle.fontFamily, fontWeight: FontWeight.w600, ), ), shadowColor: WidgetStateProperty.all(null), foregroundColor: WidgetStateProperty.all(Colors.white), backgroundColor: WidgetStateProperty.resolveWith( (Set states) { if (states.contains(WidgetState.disabled)) { return _primaryColor; } return colorTheme.primary; }, ), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle( textStyle: WidgetStateProperty.all( TextStyle( fontSize: 14, fontFamily: fontStyle.fontFamily, fontWeight: FontWeight.w500, ), ), foregroundColor: WidgetStateProperty.all(onBackground), backgroundColor: WidgetStateProperty.all(background), shape: WidgetStateProperty.all( RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), side: WidgetStateProperty.all( BorderSide(color: colorTheme.outline, width: 0.5), ), padding: WidgetStateProperty.all( const EdgeInsets.symmetric(horizontal: 8, vertical: 12), ), ), ), textButtonTheme: TextButtonThemeData( style: ButtonStyle( textStyle: WidgetStateProperty.all(fontStyle), ), ), // text fontFamily: fontStyle.fontFamily, textTheme: TextTheme( displayLarge: const TextStyle( color: _primaryColor, fontSize: 32, fontWeight: FontWeight.w700, height: 1.20, letterSpacing: 0.16, ), displayMedium: fontStyle.copyWith( color: onBackground, fontSize: 32, fontWeight: FontWeight.w600, height: 1.20, letterSpacing: 0.16, ), // H1 Semi 26 displaySmall: fontStyle.copyWith( color: onBackground, fontWeight: FontWeight.w600, height: 1.10, letterSpacing: 0.13, ), // body2 14 Regular bodyMedium: fontStyle.copyWith( color: onBackground, fontWeight: FontWeight.w400, letterSpacing: 0.07, ), // Trash empty title labelLarge: fontStyle.copyWith( color: onBackground, fontSize: 22, fontWeight: FontWeight.w600, letterSpacing: -0.3, ), // setting item title labelMedium: fontStyle.copyWith( color: onBackground, fontSize: 18, fontWeight: FontWeight.w500, ), // setting group title labelSmall: fontStyle.copyWith( color: onBackground, fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.05, ), ), inputDecorationTheme: InputDecorationTheme( contentPadding: const EdgeInsets.all(8), focusedBorder: const OutlineInputBorder( borderSide: BorderSide( width: 2, color: _primaryColor, ), borderRadius: BorderRadius.all(Radius.circular(6)), ), focusedErrorBorder: OutlineInputBorder( borderSide: BorderSide(color: colorTheme.error), borderRadius: const BorderRadius.all(Radius.circular(6)), ), errorBorder: OutlineInputBorder( borderSide: BorderSide(color: colorTheme.error), borderRadius: const BorderRadius.all(Radius.circular(6)), ), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: colorTheme.outline, ), borderRadius: const BorderRadius.all(Radius.circular(6)), ), ), colorScheme: colorTheme, indicatorColor: Colors.blue, extensions: [ AFThemeExtension( warning: theme.yellow, success: theme.green, tint1: theme.tint1, tint2: theme.tint2, tint3: theme.tint3, tint4: theme.tint4, tint5: theme.tint5, tint6: theme.tint6, tint7: theme.tint7, tint8: theme.tint8, tint9: theme.tint9, textColor: theme.text, secondaryTextColor: theme.secondaryText, strongText: theme.strongText, greyHover: theme.hoverBG1, greySelect: theme.bg3, lightGreyHover: theme.hoverBG3, toggleOffFill: theme.shader5, progressBarBGColor: theme.progressBarBGColor, toggleButtonBGColor: theme.toggleButtonBGColor, calendarWeekendBGColor: theme.calendarWeekendBGColor, gridRowCountColor: theme.gridRowCountColor, code: codeFontStyle.copyWith( color: theme.shader3, ), callout: fontStyle.copyWith( fontSize: FontSizes.s11, color: theme.shader3, ), calloutBGColor: theme.hoverBG3, tableCellBGColor: theme.surface, caption: fontStyle.copyWith( fontSize: FontSizes.s11, fontWeight: FontWeight.w400, color: theme.hint, ), onBackground: onBackground, background: background, borderColor: theme.borderColor, scrollbarColor: theme.scrollbarColor, scrollbarHoverColor: theme.scrollbarHoverColor, lightIconColor: theme.lightIconColor, toolbarHoverColor: theme.toolbarHoverColor, ), ToolbarColorExtension.fromBrightness(brightness), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/cloud_setting_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'appflowy_cloud_setting_bloc.freezed.dart'; class AppFlowyCloudSettingBloc extends Bloc { AppFlowyCloudSettingBloc(CloudSettingPB setting) : _listener = UserCloudConfigListener(), super(AppFlowyCloudSettingState.initial(setting, false)) { _dispatch(); _getWorkspaceType(); } final UserCloudConfigListener _listener; @override Future close() async { await _listener.stop(); return super.close(); } void _getWorkspaceType() { UserEventGetUserProfile().send().then((value) { if (isClosed) { return; } value.fold( (profile) => add( AppFlowyCloudSettingEvent.workspaceTypeChanged( profile.workspaceType, ), ), (error) => Log.error(error), ); }); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { await getSyncLogEnabled().then((value) { emit(state.copyWith(isSyncLogEnabled: value)); }); _listener.start( onSettingChanged: (result) { if (isClosed) { return; } result.fold( (setting) => add(AppFlowyCloudSettingEvent.didReceiveSetting(setting)), (error) => Log.error(error), ); }, ); }, enableSync: (isEnable) async { final config = UpdateCloudConfigPB.create()..enableSync = isEnable; await UserEventSetCloudConfig(config).send(); }, enableSyncLog: (isEnable) async { await setSyncLogEnabled(isEnable); emit(state.copyWith(isSyncLogEnabled: isEnable)); }, didReceiveSetting: (CloudSettingPB setting) { emit( state.copyWith( setting: setting, showRestartHint: setting.serverUrl.isNotEmpty, ), ); }, workspaceTypeChanged: (WorkspaceTypePB workspaceType) { emit(state.copyWith(workspaceType: workspaceType)); }, ); }, ); } } @freezed class AppFlowyCloudSettingEvent with _$AppFlowyCloudSettingEvent { const factory AppFlowyCloudSettingEvent.initial() = _Initial; const factory AppFlowyCloudSettingEvent.enableSync(bool isEnable) = _EnableSync; const factory AppFlowyCloudSettingEvent.enableSyncLog(bool isEnable) = _EnableSyncLog; const factory AppFlowyCloudSettingEvent.didReceiveSetting( CloudSettingPB setting, ) = _DidUpdateSetting; const factory AppFlowyCloudSettingEvent.workspaceTypeChanged( WorkspaceTypePB workspaceType, ) = _WorkspaceTypeChanged; } @freezed class AppFlowyCloudSettingState with _$AppFlowyCloudSettingState { const factory AppFlowyCloudSettingState({ required CloudSettingPB setting, required bool showRestartHint, required bool isSyncLogEnabled, required WorkspaceTypePB workspaceType, }) = _AppFlowyCloudSettingState; factory AppFlowyCloudSettingState.initial( CloudSettingPB setting, bool isSyncLogEnabled, ) => AppFlowyCloudSettingState( setting: setting, showRestartHint: setting.serverUrl.isNotEmpty, isSyncLogEnabled: isSyncLogEnabled, workspaceType: WorkspaceTypePB.ServerW, ); } FlowyResult validateUrl(String url) { try { // Use Uri.parse to validate the url. final uri = Uri.parse(url); if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) { return FlowyResult.success(null); } else { return FlowyResult.failure( LocaleKeys.settings_menu_invalidCloudURLScheme.tr(), ); } } catch (e) { return FlowyResult.failure(e.toString()); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart ================================================ import 'package:appflowy/env/backend_env.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'appflowy_cloud_urls_bloc.freezed.dart'; class AppFlowyCloudURLsBloc extends Bloc { AppFlowyCloudURLsBloc() : super(AppFlowyCloudURLsState.initial()) { on((event, emit) async { await event.when( initial: () async {}, updateServerUrl: (url) { emit( state.copyWith( updatedServerUrl: url, urlError: null, showRestartHint: url.isNotEmpty, ), ); }, updateBaseWebDomain: (url) { emit( state.copyWith( updatedBaseWebDomain: url, urlError: null, showRestartHint: url.isNotEmpty, ), ); }, confirmUpdate: () async { if (state.updatedServerUrl.isEmpty) { emit( state.copyWith( updatedServerUrl: "", urlError: LocaleKeys.settings_menu_appFlowyCloudUrlCanNotBeEmpty.tr(), restartApp: false, ), ); } else { bool isSuccess = false; await validateUrl(state.updatedServerUrl).fold( (url) async { await useSelfHostedAppFlowyCloud(url); isSuccess = true; }, (err) async => emit(state.copyWith(urlError: err)), ); await validateUrl(state.updatedBaseWebDomain).fold( (url) async { await useBaseWebDomain(url); isSuccess = true; }, (err) async => emit(state.copyWith(urlError: err)), ); if (isSuccess) { add(const AppFlowyCloudURLsEvent.didSaveConfig()); } } }, didSaveConfig: () { emit( state.copyWith( urlError: null, restartApp: true, ), ); }, ); }); } } @freezed class AppFlowyCloudURLsEvent with _$AppFlowyCloudURLsEvent { const factory AppFlowyCloudURLsEvent.initial() = _Initial; const factory AppFlowyCloudURLsEvent.updateServerUrl(String text) = _ServerUrl; const factory AppFlowyCloudURLsEvent.updateBaseWebDomain(String text) = _UpdateBaseWebDomain; const factory AppFlowyCloudURLsEvent.confirmUpdate() = _UpdateConfig; const factory AppFlowyCloudURLsEvent.didSaveConfig() = _DidSaveConfig; } @freezed class AppFlowyCloudURLsState with _$AppFlowyCloudURLsState { const factory AppFlowyCloudURLsState({ required AppFlowyCloudConfiguration config, required String updatedServerUrl, required String updatedBaseWebDomain, required String? urlError, required bool restartApp, required bool showRestartHint, }) = _AppFlowyCloudURLsState; factory AppFlowyCloudURLsState.initial() => AppFlowyCloudURLsState( config: getIt().appflowyCloudConfig, urlError: null, updatedServerUrl: getIt().appflowyCloudConfig.base_url, updatedBaseWebDomain: getIt().appflowyCloudConfig.base_web_domain, showRestartHint: getIt() .appflowyCloudConfig .base_url .isNotEmpty, restartApp: false, ); } FlowyResult validateUrl(String url) { try { // Use Uri.parse to validate the url. final uri = Uri.parse(removeTrailingSlash(url)); if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) { return FlowyResult.success(uri.toString()); } else { return FlowyResult.failure( LocaleKeys.settings_menu_invalidCloudURLScheme.tr(), ); } } catch (e) { return FlowyResult.failure(e.toString()); } } String removeTrailingSlash(String input) { if (input.endsWith('/')) { return input.substring(0, input.length - 1); } return input; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart ================================================ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:path/path.dart' as p; import '../../../startup/tasks/prelude.dart'; const appFlowyDataFolder = "AppFlowyDataDoNotRename"; class ApplicationDataStorage { ApplicationDataStorage(); String? _cachePath; /// Set the custom path to store the data. /// If the path is not exists, the path will be created. /// If the path is invalid, the path will be set to the default path. Future setCustomPath(String path) async { if (kIsWeb || Platform.isAndroid || Platform.isIOS) { Log.info('LocalFileStorage is not supported on this platform.'); return; } if (Platform.isMacOS) { // remove the prefix `/Volumes/*` path = path.replaceFirst(macOSVolumesRegex, ''); } else if (Platform.isWindows) { path = path.replaceAll('/', '\\'); } // If the path is not ends with `AppFlowyData`, we will append the // `AppFlowyData` to the path. If the path is ends with `AppFlowyData`, // which means the path is the custom path. if (p.basename(path) != appFlowyDataFolder) { path = p.join(path, appFlowyDataFolder); } // create the directory if not exists. final directory = Directory(path); if (!directory.existsSync()) { await directory.create(recursive: true); } await setPath(path); } Future setPath(String path) async { if (kIsWeb || Platform.isAndroid || Platform.isIOS) { Log.info('LocalFileStorage is not supported on this platform.'); return; } await getIt().set(KVKeys.pathLocation, path); // clear the cache path, and not set the cache path to the new path because the set path may be invalid _cachePath = null; } Future getPath() async { if (_cachePath != null) { return _cachePath!; } final response = await getIt().get(KVKeys.pathLocation); String path; if (response == null) { final directory = await appFlowyApplicationDataDirectory(); path = directory.path; } else { path = response; } _cachePath = path; // if the path is not exists means the path is invalid, so we should clear the kv store if (!Directory(path).existsSync()) { await getIt().clear(); final directory = await appFlowyApplicationDataDirectory(); path = directory.path; } return path; } } class MockApplicationDataStorage extends ApplicationDataStorage { MockApplicationDataStorage(); // this value will be clear after setup // only for the initial step @visibleForTesting static String? initialPath; @override Future getPath() async { final path = initialPath; if (path != null) { initialPath = null; await super.setPath(path); return Future.value(path); } return super.getPath(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart ================================================ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:fixnum/fixnum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; part 'settings_billing_bloc.freezed.dart'; class SettingsBillingBloc extends Bloc { SettingsBillingBloc({ required this.workspaceId, required Int64 userId, }) : super(const _Initial()) { _userService = UserBackendService(userId: userId); _service = WorkspaceService(workspaceId: workspaceId, userId: userId); _successListenable = getIt(); _successListenable.addListener(_onPaymentSuccessful); on((event, emit) async { await event.when( started: () async { emit(const SettingsBillingState.loading()); FlowyError? error; final result = await UserBackendService.getWorkspaceSubscriptionInfo( workspaceId, ); final subscriptionInfo = result.fold( (s) => s, (e) { error = e; return null; }, ); if (subscriptionInfo == null || error != null) { return emit(SettingsBillingState.error(error: error)); } if (!_billingPortalCompleter.isCompleted) { unawaited(_fetchBillingPortal()); unawaited( _billingPortalCompleter.future.then( (result) { if (isClosed) return; result.fold( (portal) { _billingPortal = portal; add( SettingsBillingEvent.billingPortalFetched( billingPortal: portal, ), ); }, (e) => Log.error('Error fetching billing portal: $e'), ); }, ), ); } emit( SettingsBillingState.ready( subscriptionInfo: subscriptionInfo, billingPortal: _billingPortal, ), ); }, billingPortalFetched: (billingPortal) async => state.maybeWhen( orElse: () {}, ready: (subscriptionInfo, _, plan, isLoading) => emit( SettingsBillingState.ready( subscriptionInfo: subscriptionInfo, billingPortal: billingPortal, successfulPlanUpgrade: plan, isLoading: isLoading, ), ), ), openCustomerPortal: () async { if (_billingPortalCompleter.isCompleted && _billingPortal != null) { return afLaunchUrlString(_billingPortal!.url); } await _billingPortalCompleter.future; if (_billingPortal != null) { await afLaunchUrlString(_billingPortal!.url); } }, addSubscription: (plan) async { final result = await _userService.createSubscription(workspaceId, plan); result.fold( (link) => afLaunchUrlString(link.paymentLink), (f) => Log.error(f.msg, f), ); }, cancelSubscription: (plan, reason) async { final s = state.mapOrNull(ready: (s) => s); if (s == null) { return; } emit(s.copyWith(isLoading: true)); final result = await _userService.cancelSubscription(workspaceId, plan, reason); final successOrNull = result.fold( (_) => true, (f) { Log.error( 'Failed to cancel subscription of ${plan.label}: ${f.msg}', f, ); return null; }, ); if (successOrNull != true) { return; } final subscriptionInfo = state.mapOrNull( ready: (s) => s.subscriptionInfo, ); // This is impossible, but for good measure if (subscriptionInfo == null) { return; } subscriptionInfo.freeze(); final newInfo = subscriptionInfo.rebuild((value) { if (plan.isAddOn) { value.addOns.removeWhere( (addon) => addon.addOnSubscription.subscriptionPlan == plan, ); } if (plan == WorkspacePlanPB.ProPlan && value.plan == WorkspacePlanPB.ProPlan) { value.plan = WorkspacePlanPB.FreePlan; value.planSubscription.freeze(); value.planSubscription = value.planSubscription.rebuild((sub) { sub.status = WorkspaceSubscriptionStatusPB.Active; sub.subscriptionPlan = SubscriptionPlanPB.Free; }); } }); emit( SettingsBillingState.ready( subscriptionInfo: newInfo, billingPortal: _billingPortal, ), ); }, paymentSuccessful: (plan) async { final result = await UserBackendService.getWorkspaceSubscriptionInfo( workspaceId, ); final subscriptionInfo = result.toNullable(); if (subscriptionInfo != null) { emit( SettingsBillingState.ready( subscriptionInfo: subscriptionInfo, billingPortal: _billingPortal, ), ); } }, updatePeriod: (plan, interval) async { final s = state.mapOrNull(ready: (s) => s); if (s == null) { return; } emit(s.copyWith(isLoading: true)); final result = await _userService.updateSubscriptionPeriod( workspaceId, plan, interval, ); final successOrNull = result.fold((_) => true, (f) { Log.error( 'Failed to update subscription period of ${plan.label}: ${f.msg}', f, ); return null; }); if (successOrNull != true) { return emit(s.copyWith(isLoading: false)); } // Fetch new subscription info final newResult = await UserBackendService.getWorkspaceSubscriptionInfo( workspaceId, ); final newSubscriptionInfo = newResult.toNullable(); if (newSubscriptionInfo != null) { emit( SettingsBillingState.ready( subscriptionInfo: newSubscriptionInfo, billingPortal: _billingPortal, ), ); } }, ); }); } late final String workspaceId; late final WorkspaceService _service; late final UserBackendService _userService; final _billingPortalCompleter = Completer>(); BillingPortalPB? _billingPortal; late final SubscriptionSuccessListenable _successListenable; @override Future close() { _successListenable.removeListener(_onPaymentSuccessful); return super.close(); } Future _fetchBillingPortal() async { final billingPortalResult = await _service.getBillingPortal(); _billingPortalCompleter.complete(billingPortalResult); } Future _onPaymentSuccessful() async => add( SettingsBillingEvent.paymentSuccessful( plan: _successListenable.subscribedPlan, ), ); } @freezed class SettingsBillingEvent with _$SettingsBillingEvent { const factory SettingsBillingEvent.started() = _Started; const factory SettingsBillingEvent.billingPortalFetched({ required BillingPortalPB billingPortal, }) = _BillingPortalFetched; const factory SettingsBillingEvent.openCustomerPortal() = _OpenCustomerPortal; const factory SettingsBillingEvent.addSubscription(SubscriptionPlanPB plan) = _AddSubscription; const factory SettingsBillingEvent.cancelSubscription( SubscriptionPlanPB plan, { @Default(null) String? reason, }) = _CancelSubscription; const factory SettingsBillingEvent.paymentSuccessful({ SubscriptionPlanPB? plan, }) = _PaymentSuccessful; const factory SettingsBillingEvent.updatePeriod({ required SubscriptionPlanPB plan, required RecurringIntervalPB interval, }) = _UpdatePeriod; } @freezed class SettingsBillingState extends Equatable with _$SettingsBillingState { const SettingsBillingState._(); const factory SettingsBillingState.initial() = _Initial; const factory SettingsBillingState.loading() = _Loading; const factory SettingsBillingState.error({ @Default(null) FlowyError? error, }) = _Error; const factory SettingsBillingState.ready({ required WorkspaceSubscriptionInfoPB subscriptionInfo, required BillingPortalPB? billingPortal, @Default(null) SubscriptionPlanPB? successfulPlanUpgrade, @Default(false) bool isLoading, }) = _Ready; @override List get props => maybeWhen( orElse: () => const [], error: (error) => [error], ready: (subscription, billingPortal, plan, isLoading) => [ subscription, billingPortal, plan, isLoading, ...subscription.addOns, ], ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_bloc.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'cloud_setting_bloc.freezed.dart'; class CloudSettingBloc extends Bloc { CloudSettingBloc(AuthenticatorType cloudType) : super(CloudSettingState.initial(cloudType)) { on((event, emit) async { await event.when( initial: () async {}, updateCloudType: (AuthenticatorType newCloudType) async { emit(state.copyWith(cloudType: newCloudType)); }, ); }); } } @freezed class CloudSettingEvent with _$CloudSettingEvent { const factory CloudSettingEvent.initial() = _Initial; const factory CloudSettingEvent.updateCloudType( AuthenticatorType newCloudType, ) = _UpdateCloudType; } @freezed class CloudSettingState with _$CloudSettingState { const factory CloudSettingState({ required AuthenticatorType cloudType, }) = _CloudSettingState; factory CloudSettingState.initial(AuthenticatorType cloudType) => CloudSettingState( cloudType: cloudType, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import '../../../core/notification/user_notification.dart'; class UserCloudConfigListener { UserCloudConfigListener(); UserNotificationParser? _userParser; StreamSubscription? _subscription; void Function(FlowyResult)? _onSettingChanged; void start({ void Function(FlowyResult)? onSettingChanged, }) { _onSettingChanged = onSettingChanged; _userParser = UserNotificationParser( id: 'user_cloud_config', callback: _userNotificationCallback, ); _subscription = RustStreamReceiver.listen((observable) { _userParser?.parse(observable); }); } Future stop() async { _userParser = null; await _subscription?.cancel(); _onSettingChanged = null; } void _userNotificationCallback( UserNotification ty, FlowyResult result, ) { switch (ty) { case UserNotification.DidUpdateCloudConfig: result.fold( (payload) => _onSettingChanged ?.call(FlowyResult.success(CloudSettingPB.fromBuffer(payload))), (error) => _onSettingChanged?.call(FlowyResult.failure(error)), ); break; default: break; } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart ================================================ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CreateFileSettingsCubit extends Cubit { CreateFileSettingsCubit(super.initialState) { getInitialSettings(); } Future toggle({bool? value}) async { await getIt().set( KVKeys.showRenameDialogWhenCreatingNewFile, (value ?? !state).toString(), ); emit(value ?? !state); } Future getInitialSettings() async { final settingsOrFailure = await getIt().getWithFormat( KVKeys.showRenameDialogWhenCreatingNewFile, (value) => bool.parse(value), ); emit(settingsOrFailure ?? false); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; const _localFmt = 'MM/dd/y'; const _usFmt = 'y/MM/dd'; const _isoFmt = 'y-MM-dd'; const _friendlyFmt = 'MMM dd, y'; const _dmyFmt = 'dd/MM/y'; extension DateFormatter on UserDateFormatPB { DateFormat get toFormat { try { return DateFormat(_toFormat[this] ?? _friendlyFmt); } catch (_) { // fallback to en-US return DateFormat(_toFormat[this] ?? _friendlyFmt, 'en-US'); } } String formatDate( DateTime date, bool includeTime, [ UserTimeFormatPB? timeFormat, ]) { final format = toFormat; if (includeTime) { switch (timeFormat) { case UserTimeFormatPB.TwentyFourHour: return format.add_Hm().format(date); case UserTimeFormatPB.TwelveHour: return format.add_jm().format(date); default: return format.format(date); } } return format.format(date); } } final _toFormat = { UserDateFormatPB.Locally: _localFmt, UserDateFormatPB.US: _usFmt, UserDateFormatPB.ISO: _isoFmt, UserDateFormatPB.Friendly: _friendlyFmt, UserDateFormatPB.DayMonthYear: _dmyFmt, }; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_format_ext.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; extension TimeFormatter on UserTimeFormatPB { DateFormat get toFormat => _toFormat[this]!; String formatTime(DateTime date) => toFormat.format(date); } final _toFormat = { UserTimeFormatPB.TwentyFourHour: DateFormat.Hm(), UserTimeFormatPB.TwelveHour: DateFormat.jm(), }; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/file_storage/file_storage_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/notification_helper.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-storage/notification.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; class StoregeNotificationParser extends NotificationParser { StoregeNotificationParser({ super.id, required super.callback, }) : super( tyParser: (ty, source) => source == "storage" ? StorageNotification.valueOf(ty) : null, errorParser: (bytes) => FlowyError.fromBuffer(bytes), ); } class StoreageNotificationListener { StoreageNotificationListener({ void Function(FlowyError error)? onError, }) : _parser = StoregeNotificationParser( callback: ( StorageNotification ty, FlowyResult result, ) { result.fold( (data) { try { switch (ty) { case StorageNotification.FileStorageLimitExceeded: onError?.call(FlowyError.fromBuffer(data)); break; case StorageNotification.SingleFileLimitExceeded: onError?.call(FlowyError.fromBuffer(data)); break; } } catch (e) { Log.error( "$StoreageNotificationListener deserialize PB fail", e, ); } }, (err) { Log.error("Error in StoreageNotificationListener", err); }, ); }, ) { _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); } StoregeNotificationParser? _parser; StreamSubscription? _subscription; Future stop() async { _parser = null; await _subscription?.cancel(); _subscription = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/notifications/notification_settings_cubit.dart ================================================ import 'dart:async'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'notification_settings_cubit.freezed.dart'; class NotificationSettingsCubit extends Cubit { NotificationSettingsCubit() : super(NotificationSettingsState.initial()) { _initialize(); } final Completer _initCompleter = Completer(); late final NotificationSettingsPB _notificationSettings; Future _initialize() async { _notificationSettings = await UserSettingsBackendService().getNotificationSettings(); final showNotificationSetting = await getIt() .getWithFormat(KVKeys.showNotificationIcon, (v) => bool.parse(v)); emit( state.copyWith( isNotificationsEnabled: _notificationSettings.notificationsEnabled, isShowNotificationsIconEnabled: showNotificationSetting ?? true, ), ); _initCompleter.complete(); } Future toggleNotificationsEnabled() async { await _initCompleter.future; _notificationSettings.notificationsEnabled = !state.isNotificationsEnabled; emit( state.copyWith( isNotificationsEnabled: _notificationSettings.notificationsEnabled, ), ); await _saveNotificationSettings(); } Future toggleShowNotificationIconEnabled() async { await _initCompleter.future; emit( state.copyWith( isShowNotificationsIconEnabled: !state.isShowNotificationsIconEnabled, ), ); } Future _saveNotificationSettings() async { await _initCompleter.future; await getIt().set( KVKeys.showNotificationIcon, state.isShowNotificationsIconEnabled.toString(), ); final result = await UserSettingsBackendService() .setNotificationSettings(_notificationSettings); result.fold( (r) => null, (error) => Log.error(error), ); } } @freezed class NotificationSettingsState with _$NotificationSettingsState { const NotificationSettingsState._(); const factory NotificationSettingsState({ required bool isNotificationsEnabled, required bool isShowNotificationsIconEnabled, }) = _NotificationSettingsState; factory NotificationSettingsState.initial() => const NotificationSettingsState( isNotificationsEnabled: true, isShowNotificationsIconEnabled: true, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; import 'package:bloc/bloc.dart'; import 'package:fixnum/fixnum.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; part 'settings_plan_bloc.freezed.dart'; class SettingsPlanBloc extends Bloc { SettingsPlanBloc({ required this.workspaceId, required Int64 userId, }) : super(const _Initial()) { _service = WorkspaceService( workspaceId: workspaceId, userId: userId, ); _userService = UserBackendService(userId: userId); _successListenable = getIt(); _successListenable.addListener(_onPaymentSuccessful); on((event, emit) async { await event.when( started: (withSuccessfulUpgrade, shouldLoad) async { if (shouldLoad) { emit(const SettingsPlanState.loading()); } final snapshots = await Future.wait([ _service.getWorkspaceUsage(), UserBackendService.getWorkspaceSubscriptionInfo(workspaceId), ]); FlowyError? error; final usageResult = snapshots.first.fold( (s) => s as WorkspaceUsagePB?, (f) { error = f; return null; }, ); final subscriptionInfo = snapshots[1].fold( (s) => s as WorkspaceSubscriptionInfoPB, (f) { error = f; return null; }, ); if (usageResult == null || subscriptionInfo == null || error != null) { return emit(SettingsPlanState.error(error: error)); } emit( SettingsPlanState.ready( workspaceUsage: usageResult, subscriptionInfo: subscriptionInfo, successfulPlanUpgrade: withSuccessfulUpgrade, ), ); if (withSuccessfulUpgrade != null) { emit( SettingsPlanState.ready( workspaceUsage: usageResult, subscriptionInfo: subscriptionInfo, ), ); } }, addSubscription: (plan) async { final result = await _userService.createSubscription( workspaceId, plan, ); result.fold( (pl) => afLaunchUrlString(pl.paymentLink), (f) => Log.error( 'Failed to fetch paymentlink for $plan: ${f.msg}', f, ), ); }, cancelSubscription: (reason) async { final newState = state .mapOrNull(ready: (state) => state) ?.copyWith(downgradeProcessing: true); emit(newState ?? state); // We can hardcode the subscription plan here because we cannot cancel addons // on the Plan page final result = await _userService.cancelSubscription( workspaceId, SubscriptionPlanPB.Pro, reason, ); final successOrNull = result.fold( (_) => true, (f) { Log.error('Failed to cancel subscription of Pro: ${f.msg}', f); return null; }, ); if (successOrNull != true) { return; } final subscriptionInfo = state.mapOrNull( ready: (s) => s.subscriptionInfo, ); // This is impossible, but for good measure if (subscriptionInfo == null) { return; } // We assume their new plan is Free, since we only have Pro plan // at the moment. subscriptionInfo.freeze(); final newInfo = subscriptionInfo.rebuild((value) { value.plan = WorkspacePlanPB.FreePlan; value.planSubscription.freeze(); value.planSubscription = value.planSubscription.rebuild((sub) { sub.status = WorkspaceSubscriptionStatusPB.Active; sub.subscriptionPlan = SubscriptionPlanPB.Free; }); }); // We need to remove unlimited indicator for storage and // AI usage, if they don't have an addon that changes this behavior. final usage = state.mapOrNull(ready: (s) => s.workspaceUsage)!; usage.freeze(); final newUsage = usage.rebuild((value) { if (!newInfo.hasAIMax) { value.aiResponsesUnlimited = false; } value.storageBytesUnlimited = false; }); emit( SettingsPlanState.ready( subscriptionInfo: newInfo, workspaceUsage: newUsage, ), ); }, paymentSuccessful: (plan) { final readyState = state.mapOrNull(ready: (state) => state); if (readyState == null) { return; } add( SettingsPlanEvent.started( withSuccessfulUpgrade: plan, shouldLoad: false, ), ); }, ); }); } late final String workspaceId; late final WorkspaceService _service; late final IUserBackendService _userService; late final SubscriptionSuccessListenable _successListenable; Future _onPaymentSuccessful() async => add( SettingsPlanEvent.paymentSuccessful( plan: _successListenable.subscribedPlan, ), ); @override Future close() async { _successListenable.removeListener(_onPaymentSuccessful); return super.close(); } } @freezed class SettingsPlanEvent with _$SettingsPlanEvent { const factory SettingsPlanEvent.started({ @Default(null) SubscriptionPlanPB? withSuccessfulUpgrade, @Default(true) bool shouldLoad, }) = _Started; const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = _AddSubscription; const factory SettingsPlanEvent.cancelSubscription({ @Default(null) String? reason, }) = _CancelSubscription; const factory SettingsPlanEvent.paymentSuccessful({ @Default(null) SubscriptionPlanPB? plan, }) = _PaymentSuccessful; } @freezed class SettingsPlanState with _$SettingsPlanState { const factory SettingsPlanState.initial() = _Initial; const factory SettingsPlanState.loading() = _Loading; const factory SettingsPlanState.error({ @Default(null) FlowyError? error, }) = _Error; const factory SettingsPlanState.ready({ required WorkspaceUsagePB workspaceUsage, required WorkspaceSubscriptionInfoPB subscriptionInfo, @Default(null) SubscriptionPlanPB? successfulPlanUpgrade, @Default(false) bool downgradeProcessing, }) = _Ready; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; import 'package:easy_localization/easy_localization.dart'; extension SubscriptionInfoHelpers on WorkspaceSubscriptionInfoPB { String get label => switch (plan) { WorkspacePlanPB.FreePlan => LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), WorkspacePlanPB.ProPlan => LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(), WorkspacePlanPB.TeamPlan => LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(), _ => 'N/A', }; String get info => switch (plan) { WorkspacePlanPB.FreePlan => LocaleKeys.settings_planPage_planUsage_currentPlan_freeInfo.tr(), WorkspacePlanPB.ProPlan => LocaleKeys.settings_planPage_planUsage_currentPlan_proInfo.tr(), WorkspacePlanPB.TeamPlan => LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(), _ => 'N/A', }; bool get isBillingPortalEnabled { if (plan != WorkspacePlanPB.FreePlan || addOns.isNotEmpty) { return true; } return false; } } extension AllSubscriptionLabels on SubscriptionPlanPB { String get label => switch (this) { SubscriptionPlanPB.Free => LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), SubscriptionPlanPB.Pro => LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(), SubscriptionPlanPB.Team => LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(), SubscriptionPlanPB.AiMax => LocaleKeys.settings_billingPage_addons_aiMax_label.tr(), SubscriptionPlanPB.AiLocal => LocaleKeys.settings_billingPage_addons_aiOnDevice_label.tr(), _ => 'N/A', }; } extension WorkspaceSubscriptionStatusExt on WorkspaceSubscriptionInfoPB { bool get isCanceled => planSubscription.status == WorkspaceSubscriptionStatusPB.Canceled; } extension WorkspaceAddonsExt on WorkspaceSubscriptionInfoPB { bool get hasAIMax => addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiMax); bool get hasAIOnDevice => addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiLocal); } /// These have to match [SubscriptionSuccessListenable.subscribedPlan] labels extension ToRecognizable on SubscriptionPlanPB { String? toRecognizable() => switch (this) { SubscriptionPlanPB.Free => 'free', SubscriptionPlanPB.Pro => 'pro', SubscriptionPlanPB.Team => 'team', SubscriptionPlanPB.AiMax => 'ai_max', SubscriptionPlanPB.AiLocal => 'ai_local', _ => null, }; } extension PlanHelper on SubscriptionPlanPB { /// Returns true if the plan is an add-on and not /// a workspace plan. /// bool get isAddOn => switch (this) { SubscriptionPlanPB.AiMax => true, SubscriptionPlanPB.AiLocal => true, _ => false, }; String get priceMonthBilling => switch (this) { SubscriptionPlanPB.Free => 'US\$0', SubscriptionPlanPB.Pro => 'US\$12.5', SubscriptionPlanPB.Team => 'US\$15', SubscriptionPlanPB.AiMax => 'US\$10', SubscriptionPlanPB.AiLocal => 'US\$10', _ => 'US\$0', }; String get priceAnnualBilling => switch (this) { SubscriptionPlanPB.Free => 'US\$0', SubscriptionPlanPB.Pro => 'US\$10', SubscriptionPlanPB.Team => 'US\$12.5', SubscriptionPlanPB.AiMax => 'US\$8', SubscriptionPlanPB.AiLocal => 'US\$8', _ => 'US\$0', }; } extension IntervalLabel on RecurringIntervalPB { String get label => switch (this) { RecurringIntervalPB.Month => LocaleKeys.settings_billingPage_monthlyInterval.tr(), RecurringIntervalPB.Year => LocaleKeys.settings_billingPage_annualInterval.tr(), _ => LocaleKeys.settings_billingPage_monthlyInterval.tr(), }; String get priceInfo => switch (this) { RecurringIntervalPB.Month => LocaleKeys.settings_billingPage_monthlyPriceInfo.tr(), RecurringIntervalPB.Year => LocaleKeys.settings_billingPage_annualPriceInfo.tr(), _ => LocaleKeys.settings_billingPage_monthlyPriceInfo.tr(), }; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:intl/intl.dart'; final _storageNumberFormat = NumberFormat() ..maximumFractionDigits = 2 ..minimumFractionDigits = 0; extension PresentableUsage on WorkspaceUsagePB { String get totalBlobInGb { if (storageBytesLimit == 0) { return '0'; } return _storageNumberFormat .format(storageBytesLimit.toInt() / (1024 * 1024 * 1024)); } /// We use [NumberFormat] to format the current blob in GB. /// /// Where the [totalBlobBytes] is the total blob bytes in bytes. /// And [NumberFormat.maximumFractionDigits] is set to 2. /// And [NumberFormat.minimumFractionDigits] is set to 0. /// String get currentBlobInGb => _storageNumberFormat.format(storageBytes.toInt() / 1024 / 1024 / 1024); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/prelude.dart ================================================ export 'application_data_storage.dart'; export 'create_file_settings_cubit.dart'; export 'settings_dialog_bloc.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/setting_file_importer_bloc.dart ================================================ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/plugins/database/application/defines.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/import_data.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'setting_file_importer_bloc.freezed.dart'; class SettingFileImportBloc extends Bloc { SettingFileImportBloc() : super(SettingFileImportState.initial()) { on( (event, emit) async { await event.when( importAppFlowyDataFolder: (String path) async { final formattedDate = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); final spaceId = await getIt().get(KVKeys.lastOpenedSpaceId); final payload = ImportAppFlowyDataPB.create() ..path = path ..importContainerName = "import_$formattedDate"; if (spaceId != null) { payload.parentViewId = spaceId; } emit( state.copyWith(loadingState: const LoadingState.loading()), ); final result = await UserEventImportAppFlowyDataFolder(payload).send(); if (!isClosed) { add(SettingFileImportEvent.finishImport(result)); } }, finishImport: (result) { result.fold( (l) { emit( state.copyWith( successOrFail: FlowyResult.success(null), loadingState: LoadingState.finish(FlowyResult.success(null)), ), ); }, (err) { Log.error(err); emit( state.copyWith( successOrFail: FlowyResult.failure(err), loadingState: LoadingState.finish(FlowyResult.failure(err)), ), ); }, ); }, ); }, ); } } @freezed class SettingFileImportEvent with _$SettingFileImportEvent { const factory SettingFileImportEvent.importAppFlowyDataFolder(String path) = _ImportAppFlowyDataFolder; const factory SettingFileImportEvent.finishImport( FlowyResult result, ) = _ImportResult; } @freezed class SettingFileImportState with _$SettingFileImportState { const factory SettingFileImportState({ required LoadingState loadingState, required FlowyResult? successOrFail, }) = _SettingFileImportState; factory SettingFileImportState.initial() => const SettingFileImportState( loadingState: LoadingState.idle(), successOrFail: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart ================================================ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_dialog_bloc.freezed.dart'; enum SettingsPage { // NEW account, workspace, manageData, shortcuts, ai, plan, billing, sites, // OLD notifications, cloud, member, featureFlags, } class SettingsDialogBloc extends Bloc { SettingsDialogBloc( UserProfilePB userProfile, this.currentWorkspaceMemberRole, { SettingsPage? initPage, }) : _userListener = UserListener(userProfile: userProfile), super(SettingsDialogState.initial(userProfile, initPage)) { _dispatch(); } final AFRolePB? currentWorkspaceMemberRole; final UserListener _userListener; @override Future close() async { await _userListener.stop(); await super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { _userListener.start(onProfileUpdated: _profileUpdated); final isBillingEnabled = await _isBillingEnabled( state.userProfile, currentWorkspaceMemberRole, ); if (isBillingEnabled) { emit(state.copyWith(isBillingEnabled: true)); } }, didReceiveUserProfile: (UserProfilePB newUserProfile) { emit(state.copyWith(userProfile: newUserProfile)); }, setSelectedPage: (SettingsPage page) { emit(state.copyWith(page: page)); }, ); }, ); } void _profileUpdated( FlowyResult userProfileOrFailed, ) { userProfileOrFailed.fold( (newUserProfile) { if (!isClosed) { add(SettingsDialogEvent.didReceiveUserProfile(newUserProfile)); } }, (err) => Log.error(err), ); } Future _isBillingEnabled( UserProfilePB userProfile, [ AFRolePB? currentWorkspaceMemberRole, ]) async { if ([ WorkspaceTypePB.LocalW, ].contains(userProfile.workspaceType)) { return false; } if (currentWorkspaceMemberRole == null || currentWorkspaceMemberRole != AFRolePB.Owner) { return false; } if (kDebugMode) { return true; } final result = await UserEventGetCloudConfig().send(); return result.fold( (cloudSetting) { final whiteList = [ "https://beta.appflowy.cloud", "https://test.appflowy.cloud", ]; return whiteList.contains(cloudSetting.serverUrl); }, (err) { Log.error("Failed to get cloud config: $err"); return false; }, ); } } @freezed class SettingsDialogEvent with _$SettingsDialogEvent { const factory SettingsDialogEvent.initial() = _Initial; const factory SettingsDialogEvent.didReceiveUserProfile( UserProfilePB newUserProfile, ) = _DidReceiveUserProfile; const factory SettingsDialogEvent.setSelectedPage(SettingsPage page) = _SetViewIndex; } @freezed class SettingsDialogState with _$SettingsDialogState { const factory SettingsDialogState({ required UserProfilePB userProfile, required SettingsPage page, required bool isBillingEnabled, }) = _SettingsDialogState; factory SettingsDialogState.initial( UserProfilePB userProfile, SettingsPage? page, ) => SettingsDialogState( userProfile: userProfile, page: page ?? SettingsPage.account, isBillingEnabled: false, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/settings_file_exporter_cubit.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsFileExportState { SettingsFileExportState({ required this.views, }) { initialize(); } List get selectedViews { final selectedViews = []; for (var i = 0; i < views.length; i++) { if (selectedApps[i]) { for (var j = 0; j < views[i].childViews.length; j++) { if (selectedItems[i][j]) { selectedViews.add(views[i].childViews[j]); } } } } return selectedViews; } List views; List expanded = []; List selectedApps = []; List> selectedItems = []; SettingsFileExportState copyWith({ List? views, List? expanded, List? selectedApps, List>? selectedItems, }) { final state = SettingsFileExportState( views: views ?? this.views, ); state.expanded = expanded ?? this.expanded; state.selectedApps = selectedApps ?? this.selectedApps; state.selectedItems = selectedItems ?? this.selectedItems; return state; } void initialize() { expanded = views.map((e) => true).toList(); selectedApps = views.map((e) => true).toList(); selectedItems = views.map((e) => e.childViews.map((e) => true).toList()).toList(); } } class SettingsFileExporterCubit extends Cubit { SettingsFileExporterCubit({ required List views, }) : super(SettingsFileExportState(views: views)); void selectOrDeselectAllItems() { final List> selectedItems = state.selectedItems; final isSelectAll = selectedItems.expand((element) => element).every((element) => element); for (var i = 0; i < selectedItems.length; i++) { for (var j = 0; j < selectedItems[i].length; j++) { selectedItems[i][j] = !isSelectAll; } } emit(state.copyWith(selectedItems: selectedItems)); } void selectOrDeselectItem(int outerIndex, int innerIndex) { final selectedItems = state.selectedItems; selectedItems[outerIndex][innerIndex] = !selectedItems[outerIndex][innerIndex]; emit(state.copyWith(selectedItems: selectedItems)); } void expandOrUnexpandApp(int outerIndex) { final expanded = state.expanded; expanded[outerIndex] = !expanded[outerIndex]; emit(state.copyWith(expanded: expanded)); } Map> fetchSelectedPages() { final views = state.views; final selectedItems = state.selectedItems; final Map> result = {}; for (var i = 0; i < selectedItems.length; i++) { final selectedItem = selectedItems[i]; final ids = []; for (var j = 0; j < selectedItem.length; j++) { if (selectedItem[j]) { ids.add(views[i].childViews[j].id); } } if (ids.isNotEmpty) { result[views[i].id] = ids; } } return result; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/share_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; class BackendExportService { static Future> exportDatabaseAsCSV( String viewId, ) async { final payload = DatabaseViewIdPB.create()..value = viewId; return DatabaseEventExportCSV(payload).send(); } static Future> exportDatabaseAsRawData( String viewId, ) async { final payload = DatabaseViewIdPB.create()..value = viewId; return DatabaseEventExportRawDatabaseData(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart ================================================ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class ImportPayload { ImportPayload({ required this.name, required this.data, required this.layout, }); final String name; final List data; final ViewLayoutPB layout; } class ImportBackendService { static Future> importPages( String parentViewId, List values, ) async { final request = ImportPayloadPB( parentViewId: parentViewId, items: values, ); return FolderEventImportData(request).send(); } static Future> importZipFiles( List values, ) async { for (final value in values) { final result = await FolderEventImportZipFile(value).send(); if (result.isFailure) { return result; } } return FlowyResult.success(null); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_shortcuts_cubit.freezed.dart'; @freezed class ShortcutsState with _$ShortcutsState { const factory ShortcutsState({ @Default([]) List commandShortcutEvents, @Default(ShortcutsStatus.initial) ShortcutsStatus status, @Default('') String error, }) = _ShortcutsState; } enum ShortcutsStatus { initial, updating, success, failure; /// Helper getter for when the [ShortcutsStatus] signifies /// that the shortcuts have not been loaded yet. /// bool get isLoading => [initial, updating].contains(this); /// Helper getter for when the [ShortcutsStatus] signifies /// a failure by itself being [ShortcutsStatus.failure] /// bool get isFailure => this == ShortcutsStatus.failure; /// Helper getter for when the [ShortcutsStatus] signifies /// a success by itself being [ShortcutsStatus.success] /// bool get isSuccess => this == ShortcutsStatus.success; } class ShortcutsCubit extends Cubit { ShortcutsCubit(this.service) : super(const ShortcutsState()); final SettingsShortcutService service; Future fetchShortcuts() async { emit( state.copyWith( status: ShortcutsStatus.updating, error: '', ), ); try { final customizeShortcuts = await service.getCustomizeShortcuts(); await service.updateCommandShortcuts( commandShortcutEvents, customizeShortcuts, ); //sort the shortcuts commandShortcutEvents.sort( (a, b) => a.key.toLowerCase().compareTo(b.key.toLowerCase()), ); emit( state.copyWith( status: ShortcutsStatus.success, commandShortcutEvents: commandShortcutEvents, error: '', ), ); } catch (e) { emit( state.copyWith( status: ShortcutsStatus.failure, error: LocaleKeys.settings_shortcutsPage_couldNotLoadErrorMsg.tr(), ), ); } } Future updateAllShortcuts() async { emit(state.copyWith(status: ShortcutsStatus.updating, error: '')); try { await service.saveAllShortcuts(state.commandShortcutEvents); emit(state.copyWith(status: ShortcutsStatus.success, error: '')); } catch (e) { emit( state.copyWith( status: ShortcutsStatus.failure, error: LocaleKeys.settings_shortcutsPage_couldNotSaveErrorMsg.tr(), ), ); } } Future resetToDefault() async { emit(state.copyWith(status: ShortcutsStatus.updating, error: '')); try { await service.saveAllShortcuts(defaultCommandShortcutEvents); await fetchShortcuts(); } catch (e) { emit( state.copyWith( status: ShortcutsStatus.failure, error: LocaleKeys.settings_shortcutsPage_couldNotSaveErrorMsg.tr(), ), ); } } /// Checks if the new command is conflicting with other shortcut /// We also check using the key, whether this command is a codeblock /// shortcut, if so we only check a conflict with other codeblock shortcut. CommandShortcutEvent? getConflict( CommandShortcutEvent currentShortcut, String command, ) { // check if currentShortcut is a codeblock shortcut. final isCodeBlockCommand = currentShortcut.isCodeBlockCommand; for (final shortcut in state.commandShortcutEvents) { final keybindings = shortcut.command.split(','); if (keybindings.contains(command) && shortcut.isCodeBlockCommand == isCodeBlockCommand) { return shortcut; } } return null; } } extension on CommandShortcutEvent { bool get isCodeBlockCommand => localizedCodeBlockCommands.contains(this); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:path/path.dart' as p; import 'shortcuts_model.dart'; class SettingsShortcutService { /// If file is non null then the SettingsShortcutService uses that /// file to store all the shortcuts, otherwise uses the default /// Document Directory. /// Typically we only intend to pass a file during testing. SettingsShortcutService({ File? file, }) { _initializeService(file); } late final File _file; final _initCompleter = Completer(); /// Takes in commandShortcuts as an input and saves them to the shortcuts.JSON file. Future saveAllShortcuts( List commandShortcuts, ) async { final shortcuts = EditorShortcuts( commandShortcuts: commandShortcuts.toCommandShortcutModelList(), ); await _file.writeAsString( jsonEncode(shortcuts.toJson()), flush: true, ); } /// Checks the file for saved shortcuts. If shortcuts do NOT exist then returns /// an empty list. If shortcuts exist /// then calls an utility method i.e getShortcutsFromJson which returns the saved shortcuts. Future> getCustomizeShortcuts() async { await _initCompleter.future; final shortcutsInJson = await _file.readAsString(); if (shortcutsInJson.isEmpty) { return []; } else { return getShortcutsFromJson(shortcutsInJson); } } // Extracts shortcuts from the saved json file. The shortcuts in the saved file consist of [List]. // This list needs to be converted to List. This function is intended to facilitate the same. List getShortcutsFromJson(String savedJson) { final shortcuts = EditorShortcuts.fromJson(jsonDecode(savedJson)); return shortcuts.commandShortcuts; } Future updateCommandShortcuts( List commandShortcuts, List customizeShortcuts, ) async { for (final shortcut in customizeShortcuts) { final shortcutEvent = commandShortcuts.firstWhereOrNull( (s) => s.key == shortcut.key && s.command != shortcut.command, ); shortcutEvent?.updateCommand(command: shortcut.command); } } Future resetToDefaultShortcuts() async { await _initCompleter.future; await saveAllShortcuts(defaultCommandShortcutEvents); } // Accesses the shortcuts.json file within the default AppFlowy Document Directory or creates a new file if it already doesn't exist. Future _initializeService(File? file) async { _file = file ?? await _defaultShortcutFile(); _initCompleter.complete(); } //returns the default file for storing shortcuts Future _defaultShortcutFile() async { final path = await getIt().getPath(); return File( p.join(path, 'shortcuts', 'shortcuts.json'), )..createSync(recursive: true); } } extension on List { /// Utility method for converting a CommandShortcutEvent List to a /// CommandShortcutModal List. This is necessary for creating shortcuts /// object, which is used for saving the shortcuts list. List toCommandShortcutModelList() => map((e) => CommandShortcutModel.fromCommandEvent(e)).toList(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/shortcuts_model.dart ================================================ import 'package:appflowy_editor/appflowy_editor.dart'; class EditorShortcuts { factory EditorShortcuts.fromJson(Map json) => EditorShortcuts( commandShortcuts: List.from( json["commandShortcuts"].map((x) => CommandShortcutModel.fromJson(x)), ), ); EditorShortcuts({required this.commandShortcuts}); final List commandShortcuts; Map toJson() => { "commandShortcuts": List.from(commandShortcuts.map((x) => x.toJson())), }; } class CommandShortcutModel { factory CommandShortcutModel.fromCommandEvent( CommandShortcutEvent commandShortcutEvent, ) => CommandShortcutModel( key: commandShortcutEvent.key, command: commandShortcutEvent.command, ); factory CommandShortcutModel.fromJson(Map json) => CommandShortcutModel( key: json["key"], command: json["command"] ?? '', ); const CommandShortcutModel({required this.key, required this.command}); final String key; final String command; Map toJson() => {"key": key, "command": command}; @override bool operator ==(Object other) => identical(this, other) || other is CommandShortcutModel && key == other.key && command == other.command; @override int get hashCode => key.hashCode ^ command.hashCode; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/settings/workspace/workspace_settings_bloc.dart ================================================ import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; part 'workspace_settings_bloc.freezed.dart'; class WorkspaceSettingsBloc extends Bloc { WorkspaceSettingsBloc() : super(WorkspaceSettingsState.initial()) { on( (event, emit) async { await event.when( initial: (userProfile, workspace) async { _userService = UserBackendService(userId: userProfile.id); try { final currentWorkspace = await UserBackendService.getCurrentWorkspace().getOrThrow(); final workspaces = await _userService!.getWorkspaces().getOrThrow(); if (workspaces.isEmpty) { workspaces.add( UserWorkspacePB.create() ..workspaceId = currentWorkspace.id ..name = currentWorkspace.name ..createdAtTimestamp = currentWorkspace.createTime, ); } final currentWorkspaceInList = workspaces.firstWhereOrNull( (e) => e.workspaceId == currentWorkspace.id, ) ?? workspaces.firstOrNull; // We emit here because the next event might take longer. emit(state.copyWith(workspace: currentWorkspaceInList)); if (currentWorkspaceInList == null) { return; } final members = await _getWorkspaceMembers( currentWorkspaceInList.workspaceId, ); emit( state.copyWith( workspace: currentWorkspaceInList, members: members, ), ); } catch (e) { Log.error('Failed to get or create current workspace'); } }, updateWorkspaceName: (name) async { final request = RenameWorkspacePB( workspaceId: state.workspace?.workspaceId, newName: name, ); final result = await UserEventRenameWorkspace(request).send(); state.workspace!.freeze(); final update = state.workspace!.rebuild((p0) => p0.name = name); result.fold( (_) => emit(state.copyWith(workspace: update)), (e) => Log.error('Failed to rename workspace: $e'), ); }, updateWorkspaceIcon: (icon) async { if (state.workspace == null) { return null; } final request = ChangeWorkspaceIconPB() ..workspaceId = state.workspace!.workspaceId ..newIcon = icon; final result = await UserEventChangeWorkspaceIcon(request).send(); result.fold( (_) { state.workspace!.freeze(); final newWorkspace = state.workspace!.rebuild((p0) => p0.icon = icon); return emit(state.copyWith(workspace: newWorkspace)); }, (e) => Log.error('Failed to update workspace icon: $e'), ); }, deleteWorkspace: () async => emit(state.copyWith(deleteWorkspace: true)), leaveWorkspace: () async => emit(state.copyWith(leaveWorkspace: true)), ); }, ); } UserBackendService? _userService; Future> _getWorkspaceMembers( String workspaceId, ) async { final data = QueryWorkspacePB()..workspaceId = workspaceId; final result = await UserEventGetWorkspaceMembers(data).send(); return result.fold( (s) => s.items, (e) { Log.error('Failed to read workspace members: $e'); return []; }, ); } } @freezed class WorkspaceSettingsEvent with _$WorkspaceSettingsEvent { const factory WorkspaceSettingsEvent.initial({ required UserProfilePB userProfile, @Default(null) UserWorkspacePB? workspace, }) = Initial; // Workspace itself const factory WorkspaceSettingsEvent.updateWorkspaceName(String name) = UpdateWorkspaceName; const factory WorkspaceSettingsEvent.updateWorkspaceIcon(String icon) = UpdateWorkspaceIcon; const factory WorkspaceSettingsEvent.deleteWorkspace() = DeleteWorkspace; const factory WorkspaceSettingsEvent.leaveWorkspace() = LeaveWorkspace; } @freezed class WorkspaceSettingsState with _$WorkspaceSettingsState { const factory WorkspaceSettingsState({ @Default(null) UserWorkspacePB? workspace, @Default([]) List members, @Default(false) bool deleteWorkspace, @Default(false) bool leaveWorkspace, }) = _WorkspaceSettingsState; factory WorkspaceSettingsState.initial() => const WorkspaceSettingsState(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/file_storage/file_storage_listener.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'sidebar_plan_bloc.freezed.dart'; class SidebarPlanBloc extends Bloc { SidebarPlanBloc() : super(const SidebarPlanState()) { // 1. Listen to user subscription payment callback. After user client 'Open AppFlowy', this listenable will be triggered. _subscriptionListener = getIt(); _subscriptionListener.addListener(_onPaymentSuccessful); // 2. Listen to the storage notification _storageListener = StoreageNotificationListener( onError: (error) { if (!isClosed) { add(SidebarPlanEvent.receiveError(error)); } }, ); // 3. Listen to specific error codes _globalErrorListener = GlobalErrorCodeNotifier.add( onError: (error) { if (!isClosed) { add(SidebarPlanEvent.receiveError(error)); } }, onErrorIf: (error) { const relevantErrorCodes = { ErrorCode.AIResponseLimitExceeded, ErrorCode.FileStorageLimitExceeded, }; return relevantErrorCodes.contains(error.code); }, ); on(_handleEvent); } void _onPaymentSuccessful() { final plan = _subscriptionListener.subscribedPlan; Log.info("Subscription success listenable triggered: $plan"); if (!isClosed) { // Notify the user that they have switched to a new plan. It would be better if we use websocket to // notify the client when plan switching. if (state.workspaceId != null) { final payload = SuccessWorkspaceSubscriptionPB( workspaceId: state.workspaceId, ); if (plan != null) { payload.plan = plan; } UserEventNotifyDidSwitchPlan(payload).send().then((result) { result.fold( // After the user has switched to a new plan, we need to refresh the workspace usage. (_) => _checkWorkspaceUsage(), (error) => Log.error("NotifyDidSwitchPlan failed: $error"), ); }); } else { Log.error( "Unexpected empty workspace id when subscription success listenable triggered. It should not happen. If happens, it must be a bug", ); } } } Future dispose() async { if (_globalErrorListener != null) { GlobalErrorCodeNotifier.remove(_globalErrorListener!); } _subscriptionListener.removeListener(_onPaymentSuccessful); await _storageListener?.stop(); _storageListener = null; } ErrorListener? _globalErrorListener; StoreageNotificationListener? _storageListener; late final SubscriptionSuccessListenable _subscriptionListener; Future _handleEvent( SidebarPlanEvent event, Emitter emit, ) async { await event.when( receiveError: (FlowyError error) async { if (error.code == ErrorCode.AIResponseLimitExceeded) { emit( state.copyWith( tierIndicator: const SidebarToastTierIndicator.aiMaxiLimitHit(), ), ); } else if (error.code == ErrorCode.FileStorageLimitExceeded) { emit( state.copyWith( tierIndicator: const SidebarToastTierIndicator.storageLimitHit(), ), ); } else if (error.code == ErrorCode.SingleUploadLimitExceeded) { emit( state.copyWith( tierIndicator: const SidebarToastTierIndicator.singleFileLimitHit(), ), ); } else { Log.error("Unhandle Unexpected error: $error"); } }, init: (String workspaceId, UserProfilePB userProfile) { emit( state.copyWith( workspaceId: workspaceId, userProfile: userProfile, ), ); _checkWorkspaceUsage(); }, updateWorkspaceUsage: (WorkspaceUsagePB usage) { // when the user's storage bytes are limited, show the upgrade tier button if (!usage.storageBytesUnlimited) { if (usage.storageBytes >= usage.storageBytesLimit) { add( const SidebarPlanEvent.updateTierIndicator( SidebarToastTierIndicator.storageLimitHit(), ), ); /// Checks if the user needs to upgrade to the Pro Plan. /// If the user needs to upgrade, it means they don't need to enable the AI max tier. /// This function simply returns without performing any further actions. return; } } // when user's AI responses are limited, show the AI max tier button. if (!usage.aiResponsesUnlimited) { if (usage.aiResponsesCount >= usage.aiResponsesCountLimit) { add( const SidebarPlanEvent.updateTierIndicator( SidebarToastTierIndicator.aiMaxiLimitHit(), ), ); return; } } // hide the tier indicator add( const SidebarPlanEvent.updateTierIndicator( SidebarToastTierIndicator.loading(), ), ); }, updateTierIndicator: (SidebarToastTierIndicator indicator) { emit( state.copyWith( tierIndicator: indicator, ), ); }, changedWorkspace: (workspaceId) { emit(state.copyWith(workspaceId: workspaceId)); _checkWorkspaceUsage(); }, ); } Future _checkWorkspaceUsage() async { if (state.workspaceId == null || state.userProfile == null) { return; } await WorkspaceService( workspaceId: state.workspaceId!, userId: state.userProfile!.id, ).getWorkspaceUsage().then((result) { result.fold( (usage) { if (!isClosed) { // if the user cannot fetch the workspace usage, // clear the tier indicator if (usage == null) { add( const SidebarPlanEvent.updateTierIndicator( SidebarToastTierIndicator.loading(), ), ); } else { add(SidebarPlanEvent.updateWorkspaceUsage(usage)); } } }, (error) => Log.error("Failed to get workspace usage: $error"), ); }); } } @freezed class SidebarPlanEvent with _$SidebarPlanEvent { const factory SidebarPlanEvent.init( String workspaceId, UserProfilePB userProfile, ) = _Init; const factory SidebarPlanEvent.updateWorkspaceUsage( WorkspaceUsagePB usage, ) = _UpdateWorkspaceUsage; const factory SidebarPlanEvent.updateTierIndicator( SidebarToastTierIndicator indicator, ) = _UpdateTierIndicator; const factory SidebarPlanEvent.receiveError(FlowyError error) = _ReceiveError; const factory SidebarPlanEvent.changedWorkspace({ required String workspaceId, }) = _ChangedWorkspace; } @freezed class SidebarPlanState with _$SidebarPlanState { const factory SidebarPlanState({ FlowyError? error, UserProfilePB? userProfile, String? workspaceId, WorkspaceUsagePB? usage, @Default(SidebarToastTierIndicator.loading()) SidebarToastTierIndicator tierIndicator, }) = _SidebarPlanState; } @freezed class SidebarToastTierIndicator with _$SidebarToastTierIndicator { const factory SidebarToastTierIndicator.storageLimitHit() = _StorageLimitHit; const factory SidebarToastTierIndicator.singleFileLimitHit() = _SingleFileLimitHit; const factory SidebarToastTierIndicator.aiMaxiLimitHit() = _aiMaxLimitHit; const factory SidebarToastTierIndicator.loading() = _Loading; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart ================================================ import 'dart:convert'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'folder_bloc.freezed.dart'; enum FolderSpaceType { favorite, private, public, unknown; ViewSectionPB get toViewSectionPB { switch (this) { case FolderSpaceType.private: return ViewSectionPB.Private; case FolderSpaceType.public: return ViewSectionPB.Public; case FolderSpaceType.favorite: case FolderSpaceType.unknown: throw UnimplementedError(); } } } class FolderBloc extends Bloc { FolderBloc({ required FolderSpaceType type, }) : super(FolderState.initial(type)) { on((event, emit) async { await event.map( initial: (e) async { // fetch the expand status final isExpanded = await _getFolderExpandStatus(); emit(state.copyWith(isExpanded: isExpanded)); }, expandOrUnExpand: (e) async { final isExpanded = e.isExpanded ?? !state.isExpanded; await _setFolderExpandStatus(isExpanded); emit(state.copyWith(isExpanded: isExpanded)); }, ); }); } Future _setFolderExpandStatus(bool isExpanded) async { final result = await getIt().get(KVKeys.expandedViews); var map = {}; if (result != null) { map = jsonDecode(result); } if (isExpanded) { // set expand status to true if it's not expanded map[state.type.name] = true; } else { // remove the expand status if it's expanded map.remove(state.type.name); } await getIt().set(KVKeys.expandedViews, jsonEncode(map)); } Future _getFolderExpandStatus() async { return getIt().get(KVKeys.expandedViews).then((result) { if (result == null) { return true; } final map = jsonDecode(result); return map[state.type.name] ?? true; }); } } @freezed class FolderEvent with _$FolderEvent { const factory FolderEvent.initial() = Initial; const factory FolderEvent.expandOrUnExpand({ bool? isExpanded, }) = ExpandOrUnExpand; } @freezed class FolderState with _$FolderState { const factory FolderState({ required FolderSpaceType type, required bool isExpanded, }) = _FolderState; factory FolderState.initial( FolderSpaceType type, ) => FolderState( type: type, isExpanded: true, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/sidebar/rename_view/rename_view_bloc.dart ================================================ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'rename_view_bloc.freezed.dart'; class RenameViewBloc extends Bloc { RenameViewBloc(PopoverController controller) : _controller = controller, super(RenameViewState(controller: controller)) { on((event, emit) { event.when( open: () => _controller.show(), ); }); } final PopoverController _controller; @override Future close() async { _controller.close(); await super.close(); } } @freezed class RenameViewEvent with _$RenameViewEvent { const factory RenameViewEvent.open() = _Open; } @freezed class RenameViewState with _$RenameViewState { const factory RenameViewState({ required PopoverController controller, }) = _RenameViewState; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/application/workspace/prelude.dart'; import 'package:appflowy/workspace/application/workspace/workspace_sections_listener.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart' hide AFRolePB; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; import 'package:universal_platform/universal_platform.dart'; part 'space_bloc.freezed.dart'; enum SpacePermission { publicToAll, private, } class SidebarSection { const SidebarSection({ required this.publicViews, required this.privateViews, }); const SidebarSection.empty() : publicViews = const [], privateViews = const []; final List publicViews; final List privateViews; List get views => publicViews + privateViews; SidebarSection copyWith({ List? publicViews, List? privateViews, }) { return SidebarSection( publicViews: publicViews ?? this.publicViews, privateViews: privateViews ?? this.privateViews, ); } } /// The [SpaceBloc] is responsible for /// managing the root views in different sections of the workspace. class SpaceBloc extends Bloc { SpaceBloc({ required this.userProfile, required this.workspaceId, }) : super(SpaceState.initial()) { on( (event, emit) async { await event.when( initial: (openFirstPage) async { this.openFirstPage = openFirstPage; _initial(userProfile, workspaceId); final (spaces, publicViews, privateViews) = await _getSpaces(); final currentSpace = await _getLastOpenedSpace(spaces); final isExpanded = await _getSpaceExpandStatus(currentSpace); emit( state.copyWith( spaces: spaces, currentSpace: currentSpace, isExpanded: isExpanded, shouldShowUpgradeDialog: false, isInitialized: true, ), ); if (openFirstPage) { if (currentSpace != null) { if (!isClosed) { add(SpaceEvent.open(space: currentSpace)); } } } }, create: ( name, icon, iconColor, permission, createNewPageByDefault, openAfterCreate, ) async { final space = await _createSpace( name: name, icon: icon, iconColor: iconColor, permission: permission, ); Log.info('create space: $space'); if (space != null) { emit( state.copyWith( spaces: [...state.spaces, space], currentSpace: space, ), ); add(SpaceEvent.open(space: space)); Log.info('open space: ${space.name}(${space.id})'); if (createNewPageByDefault) { add( SpaceEvent.createPage( name: '', index: 0, layout: ViewLayoutPB.Document, openAfterCreate: openAfterCreate, ), ); Log.info('create page: ${space.name}(${space.id})'); } } }, delete: (space) async { if (state.spaces.length <= 1) { return; } final deletedSpace = space ?? state.currentSpace; if (deletedSpace == null) { return; } await ViewBackendService.deleteView(viewId: deletedSpace.id); Log.info('delete space: ${deletedSpace.name}(${deletedSpace.id})'); }, rename: (space, name) async { add( SpaceEvent.update( space: space, name: name, icon: space.spaceIcon, iconColor: space.spaceIconColor, permission: space.spacePermission, ), ); }, changeIcon: (space, icon, iconColor) async { add( SpaceEvent.update( space: space, icon: icon, iconColor: iconColor, ), ); }, update: (space, name, icon, iconColor, permission) async { space ??= state.currentSpace; if (space == null) { Log.error('update space failed, space is null'); return; } if (name != null) { await _rename(space, name); } if (icon != null || iconColor != null || permission != null) { try { final extra = space.extra; final current = extra.isNotEmpty == true ? jsonDecode(extra) : {}; final updated = {}; if (icon != null) { updated[ViewExtKeys.spaceIconKey] = icon; } if (iconColor != null) { updated[ViewExtKeys.spaceIconColorKey] = iconColor; } if (permission != null) { updated[ViewExtKeys.spacePermissionKey] = permission.index; } final merged = mergeMaps(current, updated); await ViewBackendService.updateView( viewId: space.id, extra: jsonEncode(merged), ); Log.info( 'update space: ${space.name}(${space.id}), merged: $merged', ); } catch (e) { Log.error('Failed to migrating cover: $e'); } } else if (icon == null) { try { final extra = space.extra; final Map current = extra.isNotEmpty == true ? jsonDecode(extra) : {}; current.remove(ViewExtKeys.spaceIconKey); current.remove(ViewExtKeys.spaceIconColorKey); await ViewBackendService.updateView( viewId: space.id, extra: jsonEncode(current), ); Log.info( 'update space: ${space.name}(${space.id}), current: $current', ); } catch (e) { Log.error('Failed to migrating cover: $e'); } } if (permission != null) { await ViewBackendService.updateViewsVisibility( [space], permission == SpacePermission.publicToAll, ); } }, open: (space, afterOpen) async { await _openSpace(space); final isExpanded = await _getSpaceExpandStatus(space); final views = await ViewBackendService.getChildViews( viewId: space.id, ); final currentSpace = views.fold( (views) { space.freeze(); return space.rebuild((b) { b.childViews.clear(); b.childViews.addAll(views); }); }, (_) => space, ); emit( state.copyWith( currentSpace: currentSpace, isExpanded: isExpanded, ), ); // don't open the page automatically on mobile if (UniversalPlatform.isDesktop) { // open the first page by default if (currentSpace.childViews.isNotEmpty) { final firstPage = currentSpace.childViews.first; emit( state.copyWith( lastCreatedPage: firstPage, ), ); } else { emit( state.copyWith( lastCreatedPage: ViewPB(), ), ); } } afterOpen?.call(); }, expand: (space, isExpanded) async { await _setSpaceExpandStatus(space, isExpanded); emit(state.copyWith(isExpanded: isExpanded)); }, createPage: (name, layout, index, openAfterCreate) async { final parentViewId = state.currentSpace?.id; if (parentViewId == null) { return; } final result = await ViewBackendService.createView( name: name, layoutType: layout, parentViewId: parentViewId, index: index, openAfterCreate: openAfterCreate, ); result.fold( (view) { emit( state.copyWith( lastCreatedPage: openAfterCreate ? view : null, createPageResult: FlowyResult.success(null), ), ); }, (error) { Log.error('Failed to create root view: $error'); emit( state.copyWith( createPageResult: FlowyResult.failure(error), ), ); }, ); }, didReceiveSpaceUpdate: () async { final (spaces, _, _) = await _getSpaces(); final currentSpace = await _getLastOpenedSpace(spaces); emit( state.copyWith( spaces: spaces, currentSpace: currentSpace, ), ); }, reset: (userProfile, workspaceId, openFirstPage) async { if (this.workspaceId == workspaceId) { return; } _reset(userProfile, workspaceId); add( SpaceEvent.initial( openFirstPage: openFirstPage, ), ); }, migrate: () async { final result = await migrate(); emit(state.copyWith(shouldShowUpgradeDialog: !result)); }, switchToNextSpace: () async { final spaces = state.spaces; if (spaces.isEmpty) { return; } final currentSpace = state.currentSpace; if (currentSpace == null) { return; } final currentIndex = spaces.indexOf(currentSpace); final nextIndex = (currentIndex + 1) % spaces.length; final nextSpace = spaces[nextIndex]; add(SpaceEvent.open(space: nextSpace)); }, duplicate: (space) async { space ??= state.currentSpace; if (space == null) { Log.error('duplicate space failed, space is null'); return; } Log.info('duplicate space: ${space.name}(${space.id})'); emit(state.copyWith(isDuplicatingSpace: true)); final newSpace = await _duplicateSpace(space); // open the duplicated space if (newSpace != null) { add(const SpaceEvent.didReceiveSpaceUpdate()); add(SpaceEvent.open(space: newSpace)); } emit(state.copyWith(isDuplicatingSpace: false)); }, ); }, ); } late WorkspaceService _workspaceService; late String workspaceId; late UserProfilePB userProfile; WorkspaceSectionsListener? _listener; bool openFirstPage = false; @override Future close() async { await _listener?.stop(); _listener = null; return super.close(); } Future<(List, List, List)> _getSpaces() async { final sectionViews = await _getSectionViews(); if (sectionViews == null || sectionViews.views.isEmpty) { return ([], [], []); } final publicViews = sectionViews.publicViews.unique((e) => e.id); final privateViews = sectionViews.privateViews.unique((e) => e.id); final publicSpaces = publicViews.where((e) => e.isSpace); final privateSpaces = privateViews.where((e) => e.isSpace); return ([...publicSpaces, ...privateSpaces], publicViews, privateViews); } Future _createSpace({ required String name, required String icon, required String iconColor, required SpacePermission permission, String? viewId, }) async { final section = switch (permission) { SpacePermission.publicToAll => ViewSectionPB.Public, SpacePermission.private => ViewSectionPB.Private, }; final extra = { ViewExtKeys.isSpaceKey: true, ViewExtKeys.spaceIconKey: icon, ViewExtKeys.spaceIconColorKey: iconColor, ViewExtKeys.spacePermissionKey: permission.index, ViewExtKeys.spaceCreatedAtKey: DateTime.now().millisecondsSinceEpoch, }; final result = await _workspaceService.createView( name: name, viewSection: section, setAsCurrent: true, viewId: viewId, extra: jsonEncode(extra), ); return await result.fold((space) async { Log.info('Space created: $space'); return space; }, (error) { Log.error('Failed to create space: $error'); return null; }); } Future _rename(ViewPB space, String name) async { final result = await ViewBackendService.updateView(viewId: space.id, name: name); return result.fold((_) { space.freeze(); return space.rebuild((b) => b.name = name); }, (error) { Log.error('Failed to rename space: $error'); return space; }); } Future _getSectionViews() async { try { final publicViews = await _workspaceService.getPublicViews().getOrThrow(); final privateViews = await _workspaceService.getPrivateViews().getOrThrow(); return SidebarSection( publicViews: publicViews, privateViews: privateViews, ); } catch (e) { Log.error('Failed to get section views: $e'); return null; } } void _initial(UserProfilePB userProfile, String workspaceId) { _workspaceService = WorkspaceService( workspaceId: workspaceId, userId: userProfile.id, ); this.userProfile = userProfile; this.workspaceId = workspaceId; _listener = WorkspaceSectionsListener( user: userProfile, workspaceId: workspaceId, )..start( sectionChanged: (result) async { if (isClosed) { return; } add(const SpaceEvent.didReceiveSpaceUpdate()); }, ); } void _reset(UserProfilePB userProfile, String workspaceId) { _listener?.stop(); _listener = null; this.userProfile = userProfile; this.workspaceId = workspaceId; } Future _getLastOpenedSpace(List spaces) async { if (spaces.isEmpty) { return null; } final spaceId = await getIt().get(KVKeys.lastOpenedSpaceId); if (spaceId == null) { return spaces.first; } final space = spaces.firstWhereOrNull((e) => e.id == spaceId) ?? spaces.first; return space; } Future _openSpace(ViewPB space) async { await getIt().set(KVKeys.lastOpenedSpaceId, space.id); } Future _setSpaceExpandStatus(ViewPB? space, bool isExpanded) async { if (space == null) { return; } final result = await getIt().get(KVKeys.expandedViews); var map = {}; if (result != null) { map = jsonDecode(result); } if (isExpanded) { // set expand status to true if it's not expanded map[space.id] = true; } else { // remove the expand status if it's expanded map.remove(space.id); } await getIt().set(KVKeys.expandedViews, jsonEncode(map)); } Future _getSpaceExpandStatus(ViewPB? space) async { if (space == null) { return true; } return getIt().get(KVKeys.expandedViews).then((result) { if (result == null) { return true; } final map = jsonDecode(result); return map[space.id] ?? true; }); } Future migrate({bool auto = true}) async { try { final user = await UserBackendService.getCurrentUserProfile().getOrThrow(); final service = UserBackendService(userId: user.id); final members = await service.getWorkspaceMembers(workspaceId).getOrThrow(); final isOwner = members.items .any((e) => e.role == AFRolePB.Owner && e.email == user.email); if (members.items.isEmpty) { return true; } // only one member in the workspace, migrate it immediately // only the owner can migrate the public space if (members.items.length == 1 || isOwner) { // create a new public space and a new private space // move all the views in the workspace to the new public/private space var publicViews = await _workspaceService.getPublicViews().getOrThrow(); final containsPublicSpace = publicViews.any( (e) => e.isSpace && e.spacePermission == SpacePermission.publicToAll, ); publicViews = publicViews.where((e) => !e.isSpace).toList(); for (final view in publicViews) { Log.info( 'migrating: the public view should be migrated: ${view.name}(${view.id})', ); } // if there is already a public space, don't migrate the public space // only migrate the public space if there are any public views if (publicViews.isEmpty || containsPublicSpace) { return true; } final viewId = fixedUuid( user.id.toInt() + workspaceId.hashCode, UuidType.publicSpace, ); final publicSpace = await _createSpace( name: 'Shared', icon: builtInSpaceIcons.first, iconColor: builtInSpaceColors.first, permission: SpacePermission.publicToAll, viewId: viewId, ); Log.info('migrating: created a new public space: ${publicSpace?.id}'); if (publicSpace != null) { for (final view in publicViews.reversed) { if (view.isSpace) { continue; } await ViewBackendService.moveViewV2( viewId: view.id, newParentId: publicSpace.id, prevViewId: null, ); Log.info( 'migrating: migrate ${view.name}(${view.id}) to public space(${publicSpace.id})', ); } } } // create a new private space final viewId = fixedUuid(user.id.toInt(), UuidType.privateSpace); var privateViews = await _workspaceService.getPrivateViews().getOrThrow(); // if there is already a private space, don't migrate the private space final containsPrivateSpace = privateViews.any( (e) => e.isSpace && e.spacePermission == SpacePermission.private, ); privateViews = privateViews.where((e) => !e.isSpace).toList(); for (final view in privateViews) { Log.info( 'migrating: the private view should be migrated: ${view.name}(${view.id})', ); } if (privateViews.isEmpty || containsPrivateSpace) { return true; } // only migrate the private space if there are any private views final privateSpace = await _createSpace( name: 'Private', icon: builtInSpaceIcons.last, iconColor: builtInSpaceColors.last, permission: SpacePermission.private, viewId: viewId, ); Log.info('migrating: created a new private space: ${privateSpace?.id}'); if (privateSpace != null) { for (final view in privateViews.reversed) { if (view.isSpace) { continue; } await ViewBackendService.moveViewV2( viewId: view.id, newParentId: privateSpace.id, prevViewId: null, ); Log.info( 'migrating: migrate ${view.name}(${view.id}) to private space(${privateSpace.id})', ); } } return true; } catch (e) { Log.error('migrate space error: $e'); return false; } } Future shouldShowUpgradeDialog({ required List spaces, required List publicViews, required List privateViews, }) async { final publicSpaces = spaces.where( (e) => e.spacePermission == SpacePermission.publicToAll, ); if (publicSpaces.isEmpty && publicViews.isNotEmpty) { return true; } final privateSpaces = spaces.where( (e) => e.spacePermission == SpacePermission.private, ); if (privateSpaces.isEmpty && privateViews.isNotEmpty) { return true; } return false; } Future _duplicateSpace(ViewPB space) async { // if the space is not duplicated, try to create a new space final icon = space.spaceIcon.orDefault(builtInSpaceIcons.first); final iconColor = space.spaceIconColor.orDefault(builtInSpaceColors.first); final newSpace = await _createSpace( name: '${space.name} (copy)', icon: icon, iconColor: iconColor, permission: space.spacePermission, ); if (newSpace == null) { return null; } for (final view in space.childViews) { await ViewBackendService.duplicate( view: view, openAfterDuplicate: true, syncAfterDuplicate: true, includeChildren: true, parentViewId: newSpace.id, suffix: '', ); } Log.info('Space duplicated: $newSpace'); return newSpace; } } @freezed class SpaceEvent with _$SpaceEvent { const factory SpaceEvent.initial({ required bool openFirstPage, }) = _Initial; const factory SpaceEvent.create({ required String name, required String icon, required String iconColor, required SpacePermission permission, required bool createNewPageByDefault, required bool openAfterCreate, }) = _Create; const factory SpaceEvent.rename({ required ViewPB space, required String name, }) = _Rename; const factory SpaceEvent.changeIcon({ ViewPB? space, String? icon, String? iconColor, }) = _ChangeIcon; const factory SpaceEvent.duplicate({ ViewPB? space, }) = _Duplicate; const factory SpaceEvent.update({ ViewPB? space, String? name, String? icon, String? iconColor, SpacePermission? permission, }) = _Update; const factory SpaceEvent.open({ required ViewPB space, VoidCallback? afterOpen, }) = _Open; const factory SpaceEvent.expand(ViewPB space, bool isExpanded) = _Expand; const factory SpaceEvent.createPage({ required String name, required ViewLayoutPB layout, int? index, required bool openAfterCreate, }) = _CreatePage; const factory SpaceEvent.delete(ViewPB? space) = _Delete; const factory SpaceEvent.didReceiveSpaceUpdate() = _DidReceiveSpaceUpdate; const factory SpaceEvent.reset( UserProfilePB userProfile, String workspaceId, bool openFirstPage, ) = _Reset; const factory SpaceEvent.migrate() = _Migrate; const factory SpaceEvent.switchToNextSpace() = _SwitchToNextSpace; } @freezed class SpaceState with _$SpaceState { const factory SpaceState({ // use root view with space attributes to represent the space @Default([]) List spaces, @Default(null) ViewPB? currentSpace, @Default(true) bool isExpanded, @Default(null) ViewPB? lastCreatedPage, FlowyResult? createPageResult, @Default(false) bool shouldShowUpgradeDialog, @Default(false) bool isDuplicatingSpace, @Default(false) bool isInitialized, }) = _SpaceState; factory SpaceState.initial() => const SpaceState(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_search_bloc.dart ================================================ import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'space_search_bloc.freezed.dart'; class SpaceSearchBloc extends Bloc { SpaceSearchBloc() : super(SpaceSearchState.initial()) { on( (event, emit) async { await event.when( initial: () async { _allViews = await ViewBackendService.getAllViews().fold( (s) => s.items, (_) => [], ); }, search: (query) { if (query.isEmpty) { emit( state.copyWith( queryResults: null, ), ); } else { final queryResults = _allViews.where( (view) => view.name.toLowerCase().contains(query.toLowerCase()), ); emit( state.copyWith( queryResults: queryResults.toList(), ), ); } }, ); }, ); } late final List _allViews; } @freezed class SpaceSearchEvent with _$SpaceSearchEvent { const factory SpaceSearchEvent.initial() = _Initial; const factory SpaceSearchEvent.search(String query) = _Search; } @freezed class SpaceSearchState with _$SpaceSearchState { const factory SpaceSearchState({ List? queryResults, }) = _SpaceSearchState; factory SpaceSearchState.initial() => const SpaceSearchState(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart ================================================ import 'package:appflowy_backend/log.dart'; import 'package:flutter/foundation.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; class SubscriptionSuccessListenable extends ChangeNotifier { SubscriptionSuccessListenable(); String? _plan; SubscriptionPlanPB? get subscribedPlan => switch (_plan) { 'free' => SubscriptionPlanPB.Free, 'pro' => SubscriptionPlanPB.Pro, 'team' => SubscriptionPlanPB.Team, 'ai_max' => SubscriptionPlanPB.AiMax, 'ai_local' => SubscriptionPlanPB.AiLocal, _ => null, }; void onPaymentSuccess(String? plan) { Log.info("Payment success: $plan"); _plan = plan; notifyListeners(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart ================================================ import 'dart:convert'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/expand_views.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'tabs_bloc.freezed.dart'; class TabsBloc extends Bloc { TabsBloc() : super(TabsState()) { menuSharedState = getIt(); _dispatch(); } late final MenuSharedState menuSharedState; String? _lastOpenedPluginId; String? _lastOpenedViewId; DateTime? _lastOpenTime; static const _deduplicationWindow = Duration(milliseconds: 500); @override Future close() { state.dispose(); return super.close(); } void _dispatch() { on( (event, emit) async { event.when( selectTab: (int index) { if (index != state.currentIndex && index >= 0 && index < state.pages) { emit(state.copyWith(currentIndex: index)); _setLatestOpenView(); } }, moveTab: () {}, closeTab: (String pluginId) { final pm = state._pageManagers .firstWhereOrNull((pm) => pm.plugin.id == pluginId); if (pm?.isPinned == true) { return; } emit(state.closeView(pluginId)); _setLatestOpenView(); }, closeCurrentTab: () { if (state.currentPageManager.isPinned) { return; } emit(state.closeView(state.currentPageManager.plugin.id)); _setLatestOpenView(); }, openTab: (Plugin plugin, ViewPB view) { state.currentPageManager ..hideSecondaryPlugin() ..setSecondaryPlugin(BlankPagePlugin()); emit(state.openView(plugin)); _setLatestOpenView(view); }, openPlugin: (Plugin plugin, ViewPB? view, bool setLatest) { final now = DateTime.now(); // deduplicate. skip if same plugin and view were just opened if (_lastOpenedPluginId == plugin.id && _lastOpenedViewId == view?.id && _lastOpenTime != null) { final timeSinceLastOpen = now.difference(_lastOpenTime!); if (timeSinceLastOpen < _deduplicationWindow) { return; } } _lastOpenedPluginId = plugin.id; _lastOpenedViewId = view?.id; _lastOpenTime = now; state.currentPageManager ..hideSecondaryPlugin() ..setSecondaryPlugin(BlankPagePlugin()); emit(state.openPlugin(plugin: plugin, setLatest: setLatest)); if (setLatest) { // the space view should be filtered out. if (view != null && view.isSpace) { return; } _setLatestOpenView(view); if (view != null) _expandAncestors(view); } }, closeOtherTabs: (String pluginId) { final pageManagers = [ ...state._pageManagers .where((pm) => pm.plugin.id == pluginId || pm.isPinned), ]; int newIndex; if (state.currentPageManager.isPinned) { // Retain current index if it's already pinned newIndex = state.currentIndex; } else { final pm = state._pageManagers .firstWhereOrNull((pm) => pm.plugin.id == pluginId); newIndex = pm != null ? pageManagers.indexOf(pm) : 0; } emit( state.copyWith( currentIndex: newIndex, pageManagers: pageManagers, ), ); _setLatestOpenView(); }, togglePin: (String pluginId) { final pm = state._pageManagers .firstWhereOrNull((pm) => pm.plugin.id == pluginId); if (pm != null) { final index = state._pageManagers.indexOf(pm); int newIndex = state.currentIndex; if (pm.isPinned) { // Unpinning logic final indexOfFirstUnpinnedTab = state._pageManagers.indexWhere((tab) => !tab.isPinned); // Determine the correct insertion point final newUnpinnedIndex = indexOfFirstUnpinnedTab != -1 ? indexOfFirstUnpinnedTab // Insert before the first unpinned tab : state._pageManagers .length; // Append at the end if no unpinned tabs exist state._pageManagers.removeAt(index); final adjustedUnpinnedIndex = newUnpinnedIndex > index ? newUnpinnedIndex - 1 : newUnpinnedIndex; state._pageManagers.insert(adjustedUnpinnedIndex, pm); newIndex = _adjustCurrentIndex( currentIndex: state.currentIndex, tabIndex: index, newIndex: adjustedUnpinnedIndex, ); } else { // Pinning logic final indexOfLastPinnedTab = state._pageManagers.lastIndexWhere((tab) => tab.isPinned); final newPinnedIndex = indexOfLastPinnedTab + 1; state._pageManagers.removeAt(index); final adjustedPinnedIndex = newPinnedIndex > index ? newPinnedIndex - 1 : newPinnedIndex; state._pageManagers.insert(adjustedPinnedIndex, pm); newIndex = _adjustCurrentIndex( currentIndex: state.currentIndex, tabIndex: index, newIndex: adjustedPinnedIndex, ); } pm.isPinned = !pm.isPinned; emit( state.copyWith( currentIndex: newIndex, pageManagers: [...state._pageManagers], ), ); } }, openSecondaryPlugin: (plugin, view) { state.currentPageManager ..setSecondaryPlugin(plugin) ..showSecondaryPlugin(); }, closeSecondaryPlugin: () { final pageManager = state.currentPageManager; pageManager.hideSecondaryPlugin(); }, expandSecondaryPlugin: () { final pageManager = state.currentPageManager; pageManager ..hideSecondaryPlugin() ..expandSecondaryPlugin(); _setLatestOpenView(); }, switchWorkspace: (workspaceId) { final pluginId = state.currentPageManager.plugin.id; // Close all tabs except current final pagesToClose = [ ...state._pageManagers .where((pm) => pm.plugin.id != pluginId && !pm.isPinned), ]; if (pagesToClose.isNotEmpty) { final newstate = state; for (final pm in pagesToClose) { newstate.closeView(pm.plugin.id); } emit(newstate.copyWith(currentIndex: 0)); } }, ); }, ); } void _setLatestOpenView([ViewPB? view]) { if (view != null) { menuSharedState.latestOpenView = view; } else { final pageManager = state.currentPageManager; final notifier = pageManager.plugin.notifier; if (notifier is ViewPluginNotifier && menuSharedState.latestOpenView?.id != notifier.view.id) { menuSharedState.latestOpenView = notifier.view; } } } Future _expandAncestors(ViewPB view) async { final viewExpanderRegistry = getIt.get(); if (viewExpanderRegistry.isViewExpanded(view.parentViewId)) return; final value = await getIt().get(KVKeys.expandedViews); try { final Map expandedViews = value == null ? {} : jsonDecode(value); final List ancestors = await ViewBackendService.getViewAncestors(view.id) .fold((s) => s.items.map((e) => e.id).toList(), (f) => []); ViewExpander? viewExpander; for (final id in ancestors) { expandedViews[id] = true; final expander = viewExpanderRegistry.getExpander(id); if (expander == null) continue; if (!expander.isViewExpanded && viewExpander == null) { viewExpander = expander; } } await getIt() .set(KVKeys.expandedViews, jsonEncode(expandedViews)); viewExpander?.expand(); } catch (e) { Log.error('expandAncestors error', e); } } int _adjustCurrentIndex({ required int currentIndex, required int tabIndex, required int newIndex, }) { if (tabIndex < currentIndex && newIndex >= currentIndex) { return currentIndex - 1; // Tab moved forward, shift currentIndex back } else if (tabIndex > currentIndex && newIndex <= currentIndex) { return currentIndex + 1; // Tab moved backward, shift currentIndex forward } else if (tabIndex == currentIndex) { return newIndex; // Tab is the current tab, update to newIndex } return currentIndex; } /// Adds a [TabsEvent.openTab] event for the provided [ViewPB] void openTab(ViewPB view) => add(TabsEvent.openTab(plugin: view.plugin(), view: view)); /// Adds a [TabsEvent.openPlugin] event for the provided [ViewPB] void openPlugin( ViewPB view, { Map arguments = const {}, }) { add( TabsEvent.openPlugin( plugin: view.plugin(arguments: arguments), view: view, ), ); } } @freezed class TabsEvent with _$TabsEvent { const factory TabsEvent.moveTab() = _MoveTab; const factory TabsEvent.closeTab(String pluginId) = _CloseTab; const factory TabsEvent.closeOtherTabs(String pluginId) = _CloseOtherTabs; const factory TabsEvent.closeCurrentTab() = _CloseCurrentTab; const factory TabsEvent.selectTab(int index) = _SelectTab; const factory TabsEvent.togglePin(String pluginId) = _TogglePin; const factory TabsEvent.openTab({ required Plugin plugin, required ViewPB view, }) = _OpenTab; const factory TabsEvent.openPlugin({ required Plugin plugin, ViewPB? view, @Default(true) bool setLatest, }) = _OpenPlugin; const factory TabsEvent.openSecondaryPlugin({ required Plugin plugin, ViewPB? view, }) = _OpenSecondaryPlugin; const factory TabsEvent.closeSecondaryPlugin() = _CloseSecondaryPlugin; const factory TabsEvent.expandSecondaryPlugin() = _ExpandSecondaryPlugin; const factory TabsEvent.switchWorkspace(String workspaceId) = _SwitchWorkspace; } class TabsState { TabsState({ this.currentIndex = 0, List? pageManagers, }) : _pageManagers = pageManagers ?? [PageManager()]; final int currentIndex; final List _pageManagers; int get pages => _pageManagers.length; PageManager get currentPageManager => _pageManagers[currentIndex]; List get pageManagers => _pageManagers; bool get isAllPinned => _pageManagers.every((pm) => pm.isPinned); /// This opens a new tab given a [Plugin]. /// /// If the [Plugin.id] is already associated with an open tab, /// then it selects that tab. /// TabsState openView(Plugin plugin) { final selectExistingPlugin = _selectPluginIfOpen(plugin.id); if (selectExistingPlugin == null) { _pageManagers.add(PageManager()..setPlugin(plugin, true)); return copyWith( currentIndex: pages - 1, pageManagers: [..._pageManagers], ); } return selectExistingPlugin; } TabsState closeView(String pluginId) { // Avoid closing the only open tab if (_pageManagers.length == 1) { return this; } _pageManagers.removeWhere((pm) => pm.plugin.id == pluginId); /// If currentIndex is greater than the amount of allowed indices /// And the current selected tab isn't the first (index 0) /// as currentIndex cannot be -1 /// Then decrease currentIndex by 1 final newIndex = currentIndex > pages - 1 && currentIndex > 0 ? currentIndex - 1 : currentIndex; return copyWith( currentIndex: newIndex, pageManagers: [..._pageManagers], ); } /// This opens a plugin in the current selected tab, /// due to how Document currently works, only one tab /// per plugin can currently be active. /// /// If the plugin is already open in a tab, then that tab /// will become selected. /// TabsState openPlugin({required Plugin plugin, bool setLatest = true}) { final selectExistingPlugin = _selectPluginIfOpen(plugin.id); if (selectExistingPlugin == null) { final pageManagers = [..._pageManagers]; pageManagers[currentIndex].setPlugin(plugin, setLatest); return copyWith(pageManagers: pageManagers); } return selectExistingPlugin; } /// Checks if a [Plugin.id] is already associated with an open tab. /// Returns a [TabState] with new index if there is a match. /// /// If no match it returns null /// TabsState? _selectPluginIfOpen(String id) { final index = _pageManagers.indexWhere((pm) => pm.plugin.id == id); if (index == -1) { return null; } if (index == currentIndex) { return this; } return copyWith(currentIndex: index); } TabsState copyWith({ int? currentIndex, List? pageManagers, }) => TabsState( currentIndex: currentIndex ?? this.currentIndex, pageManagers: pageManagers ?? _pageManagers, ); void dispose() { for (final manager in pageManagers) { manager.dispose(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/user/prelude.dart ================================================ export 'settings_user_bloc.dart'; export 'user_workspace_bloc.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart ================================================ import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'settings_user_bloc.freezed.dart'; class SettingsUserViewBloc extends Bloc { SettingsUserViewBloc(this.userProfile) : _userListener = UserListener(userProfile: userProfile), _userService = UserBackendService(userId: userProfile.id), super(SettingsUserState.initial(userProfile)) { _dispatch(); } final UserBackendService _userService; final UserListener _userListener; final UserProfilePB userProfile; @override Future close() async { await _userListener.stop(); return super.close(); } void _dispatch() { on( (event, emit) async { await event.when( initial: () async { _loadUserProfile(); _userListener.start(onProfileUpdated: _profileUpdated); }, didReceiveUserProfile: (UserProfilePB newUserProfile) { emit(state.copyWith(userProfile: newUserProfile)); }, updateUserName: (String name) { _userService.updateUserProfile(name: name).then((result) { result.fold( (l) => null, (err) => Log.error(err), ); }); }, updateUserIcon: (String iconUrl) { _userService.updateUserProfile(iconUrl: iconUrl).then((result) { result.fold( (l) => null, (err) => Log.error(err), ); }); }, updateUserEmail: (String email) { _userService.updateUserProfile(email: email).then((result) { result.fold( (l) => null, (err) => Log.error(err), ); }); }, updateUserPassword: (String oldPassword, String newPassword) { _userService .updateUserProfile(password: newPassword) .then((result) { result.fold( (l) => null, (err) => Log.error(err), ); }); }, removeUserIcon: () { // Empty Icon URL = No icon _userService.updateUserProfile(iconUrl: "").then((result) { result.fold( (l) => null, (err) => Log.error(err), ); }); }, ); }, ); } void _loadUserProfile() { UserBackendService.getCurrentUserProfile().then((result) { if (isClosed) { return; } result.fold( (userProfile) => add( SettingsUserEvent.didReceiveUserProfile(userProfile), ), (err) => Log.error(err), ); }); } void _profileUpdated( FlowyResult userProfileOrFailed, ) => userProfileOrFailed.fold( (newUserProfile) => add(SettingsUserEvent.didReceiveUserProfile(newUserProfile)), (err) => Log.error(err), ); } @freezed class SettingsUserEvent with _$SettingsUserEvent { const factory SettingsUserEvent.initial() = _Initial; const factory SettingsUserEvent.updateUserName({ required String name, }) = _UpdateUserName; const factory SettingsUserEvent.updateUserEmail({ required String email, }) = _UpdateEmail; const factory SettingsUserEvent.updateUserIcon({ required String iconUrl, }) = _UpdateUserIcon; const factory SettingsUserEvent.updateUserPassword({ required String oldPassword, required String newPassword, }) = _UpdateUserPassword; const factory SettingsUserEvent.removeUserIcon() = _RemoveUserIcon; const factory SettingsUserEvent.didReceiveUserProfile( UserProfilePB newUserProfile, ) = _DidReceiveUserProfile; } @freezed class SettingsUserState with _$SettingsUserState { const factory SettingsUserState({ required UserProfilePB userProfile, required FlowyResult successOrFailure, }) = _SettingsUserState; factory SettingsUserState.initial(UserProfilePB userProfile) => SettingsUserState( userProfile: userProfile, successOrFailure: FlowyResult.success(null), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart ================================================ export 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/view/prelude.dart ================================================ export 'view_bloc.dart'; export 'view_listener.dart'; export 'view_service.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/expand_views.dart'; import 'package:appflowy/workspace/application/favorite/favorite_listener.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; part 'view_bloc.freezed.dart'; class ViewBloc extends Bloc { ViewBloc({ required this.view, this.shouldLoadChildViews = true, this.engagedInExpanding = false, }) : viewBackendSvc = ViewBackendService(), listener = ViewListener(viewId: view.id), favoriteListener = FavoriteListener(), super(ViewState.init(view)) { _dispatch(); if (engagedInExpanding) { expander = ViewExpander( () => state.isExpanded, () => add(const ViewEvent.setIsExpanded(true)), ); getIt().register(view.id, expander); } } final ViewPB view; final ViewBackendService viewBackendSvc; final ViewListener listener; final FavoriteListener favoriteListener; final bool shouldLoadChildViews; final bool engagedInExpanding; late ViewExpander expander; @override Future close() async { await listener.stop(); await favoriteListener.stop(); if (engagedInExpanding) { getIt().unregister(view.id, expander); } return super.close(); } void _dispatch() { on( (event, emit) async { await event.map( initial: (e) async { listener.start( onViewUpdated: (result) { add(ViewEvent.viewDidUpdate(FlowyResult.success(result))); }, onViewChildViewsUpdated: (result) async { final view = await _updateChildViews(result); if (!isClosed && view != null) { add(ViewEvent.viewUpdateChildView(view)); } }, ); favoriteListener.start( favoritesUpdated: (result, isFavorite) { result.fold( (result) { final current = result.items .firstWhereOrNull((v) => v.id == state.view.id); if (current != null) { add( ViewEvent.viewDidUpdate( FlowyResult.success(current), ), ); } }, (error) {}, ); }, ); final isExpanded = await _getViewIsExpanded(view); emit(state.copyWith(isExpanded: isExpanded, view: view)); if (shouldLoadChildViews) { await _loadChildViews(emit); } }, setIsEditing: (e) { emit(state.copyWith(isEditing: e.isEditing)); }, setIsExpanded: (e) async { if (e.isExpanded && !state.isExpanded) { await _loadViewsWhenExpanded(emit, true); } else { emit(state.copyWith(isExpanded: e.isExpanded)); } await _setViewIsExpanded(view, e.isExpanded); }, viewDidUpdate: (e) async { final result = await ViewBackendService.getView(view.id); final view_ = result.fold((l) => l, (r) => null); e.result.fold( (view) async { // ignore child view changes because it only contains one level // children data. if (_isSameViewIgnoreChildren(view, state.view)) { // do nothing. } emit( state.copyWith( view: view_ ?? view, successOrFailure: FlowyResult.success(null), ), ); }, (error) => emit( state.copyWith(successOrFailure: FlowyResult.failure(error)), ), ); }, rename: (e) async { final result = await ViewBackendService.updateView( viewId: view.id, name: e.newName, ); emit( result.fold( (l) { final view = state.view; view.freeze(); final newView = view.rebuild( (b) => b.name = e.newName, ); Log.info('rename view: ${newView.id} to ${newView.name}'); return state.copyWith( successOrFailure: FlowyResult.success(null), view: newView, ); }, (error) { Log.error('rename view failed: $error'); return state.copyWith( successOrFailure: FlowyResult.failure(error), ); }, ), ); }, delete: (e) async { // unpublish the page and all its child pages if they are published await _unpublishPage(view); final result = await ViewBackendService.deleteView(viewId: view.id); emit( result.fold( (l) { return state.copyWith( successOrFailure: FlowyResult.success(null), isDeleted: true, ); }, (error) => state.copyWith( successOrFailure: FlowyResult.failure(error), ), ), ); await getIt().updateRecentViews( [view.id], false, ); }, duplicate: (e) async { final result = await ViewBackendService.duplicate( view: view, openAfterDuplicate: true, syncAfterDuplicate: true, includeChildren: true, suffix: ' (${LocaleKeys.menuAppHeader_pageNameSuffix.tr()})', ); emit( result.fold( (l) => state.copyWith(successOrFailure: FlowyResult.success(null)), (error) => state.copyWith( successOrFailure: FlowyResult.failure(error), ), ), ); }, move: (value) async { final result = await ViewBackendService.moveViewV2( viewId: value.from.id, newParentId: value.newParentId, prevViewId: value.prevId, fromSection: value.fromSection, toSection: value.toSection, ); emit( result.fold( (l) { return state.copyWith( successOrFailure: FlowyResult.success(null), ); }, (error) => state.copyWith( successOrFailure: FlowyResult.failure(error), ), ), ); }, createView: (e) async { final result = await ViewBackendService.createView( parentViewId: view.id, name: e.name, layoutType: e.layoutType, ext: {}, openAfterCreate: e.openAfterCreated, section: e.section, ); emit( result.fold( (view) => state.copyWith( lastCreatedView: view, successOrFailure: FlowyResult.success(null), ), (error) => state.copyWith( successOrFailure: FlowyResult.failure(error), ), ), ); }, viewUpdateChildView: (e) async { emit( state.copyWith( view: e.result, ), ); }, updateViewVisibility: (value) async { final view = value.view; await ViewBackendService.updateViewsVisibility( [view], value.isPublic, ); }, updateIcon: (value) async { await ViewBackendService.updateViewIcon( view: view, viewIcon: view.icon.toEmojiIconData(), ); }, collapseAllPages: (value) async { for (final childView in view.childViews) { await _setViewIsExpanded(childView, false); } add(const ViewEvent.setIsExpanded(false)); }, unpublish: (value) async { if (value.sync) { await _unpublishPage(view); } else { unawaited(_unpublishPage(view)); } }, ); }, ); } Future _loadViewsWhenExpanded( Emitter emit, bool isExpanded, ) async { if (!isExpanded) { emit( state.copyWith( view: view, isExpanded: false, isLoading: false, ), ); return; } final viewsOrFailed = await ViewBackendService.getChildViews(viewId: state.view.id); viewsOrFailed.fold( (childViews) { state.view.freeze(); final viewWithChildViews = state.view.rebuild((b) { b.childViews.clear(); b.childViews.addAll(childViews); }); emit( state.copyWith( view: viewWithChildViews, isExpanded: true, isLoading: false, ), ); }, (error) => emit( state.copyWith( successOrFailure: FlowyResult.failure(error), isExpanded: true, isLoading: false, ), ), ); } Future _loadChildViews( Emitter emit, ) async { final viewsOrFailed = await ViewBackendService.getChildViews(viewId: state.view.id); viewsOrFailed.fold( (childViews) { state.view.freeze(); final viewWithChildViews = state.view.rebuild((b) { b.childViews.clear(); b.childViews.addAll(childViews); }); emit( state.copyWith( view: viewWithChildViews, ), ); }, (error) => emit( state.copyWith( successOrFailure: FlowyResult.failure(error), ), ), ); } Future _setViewIsExpanded(ViewPB view, bool isExpanded) async { final result = await getIt().get(KVKeys.expandedViews); final Map map; if (result != null) { map = jsonDecode(result); } else { map = {}; } if (isExpanded) { map[view.id] = true; } else { map.remove(view.id); } await getIt().set(KVKeys.expandedViews, jsonEncode(map)); } Future _getViewIsExpanded(ViewPB view) { return getIt().get(KVKeys.expandedViews).then((result) { if (result == null) { return false; } final map = jsonDecode(result); return map[view.id] ?? false; }); } Future _updateChildViews( ChildViewUpdatePB update, ) async { if (update.createChildViews.isNotEmpty) { // refresh the child views if the update isn't empty // because there's no info to get the inserted index. assert(update.parentViewId == this.view.id); final view = await ViewBackendService.getView( update.parentViewId, ); return view.fold((l) => l, (r) => null); } final view = state.view; view.freeze(); final childViews = [...view.childViews]; if (update.deleteChildViews.isNotEmpty) { childViews.removeWhere((v) => update.deleteChildViews.contains(v.id)); return view.rebuild((p0) { p0.childViews.clear(); p0.childViews.addAll(childViews); }); } if (update.updateChildViews.isNotEmpty && update.parentViewId.isNotEmpty) { final view = await ViewBackendService.getView(update.parentViewId); final childViews = view.fold((l) => l.childViews, (r) => []); bool isSameOrder = true; if (childViews.length == update.updateChildViews.length) { for (var i = 0; i < childViews.length; i++) { if (childViews[i].id != update.updateChildViews[i].id) { isSameOrder = false; break; } } } else { isSameOrder = false; } if (!isSameOrder) { return view.fold((l) => l, (r) => null); } } return null; } // unpublish the page and all its child pages Future _unpublishPage(ViewPB views) async { final (_, publishedPages) = await ViewBackendService.containPublishedPage( view, ); await Future.wait( publishedPages.map((view) async { Log.info('unpublishing page: ${view.id}, ${view.name}'); await ViewBackendService.unpublish(view); }), ); } bool _isSameViewIgnoreChildren(ViewPB from, ViewPB to) { return _hash(from) == _hash(to); } int _hash(ViewPB view) => Object.hash( view.id, view.name, view.createTime, view.icon, view.parentViewId, view.layout, ); } @freezed class ViewEvent with _$ViewEvent { const factory ViewEvent.initial() = Initial; const factory ViewEvent.setIsEditing(bool isEditing) = SetEditing; const factory ViewEvent.setIsExpanded(bool isExpanded) = SetIsExpanded; const factory ViewEvent.rename(String newName) = Rename; const factory ViewEvent.delete() = Delete; const factory ViewEvent.duplicate() = Duplicate; const factory ViewEvent.move( ViewPB from, String newParentId, String? prevId, ViewSectionPB? fromSection, ViewSectionPB? toSection, ) = Move; const factory ViewEvent.createView( String name, ViewLayoutPB layoutType, { /// open the view after created @Default(true) bool openAfterCreated, ViewSectionPB? section, }) = CreateView; const factory ViewEvent.viewDidUpdate( FlowyResult result, ) = ViewDidUpdate; const factory ViewEvent.viewUpdateChildView(ViewPB result) = ViewUpdateChildView; const factory ViewEvent.updateViewVisibility( ViewPB view, bool isPublic, ) = UpdateViewVisibility; const factory ViewEvent.updateIcon(String? icon) = UpdateIcon; const factory ViewEvent.collapseAllPages() = CollapseAllPages; // this event will unpublish the page and all its child pages if they are published const factory ViewEvent.unpublish({required bool sync}) = Unpublish; } @freezed class ViewState with _$ViewState { const factory ViewState({ required ViewPB view, required bool isEditing, required bool isExpanded, required FlowyResult successOrFailure, @Default(false) bool isDeleted, @Default(true) bool isLoading, @Default(null) ViewPB? lastCreatedView, }) = _ViewState; factory ViewState.init(ViewPB view) => ViewState( view: view, isExpanded: false, isEditing: false, successOrFailure: FlowyResult.success(null), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart ================================================ import 'dart:convert'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/ai_chat/chat.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; import 'package:appflowy/plugins/database/grid/presentation/mobile_grid_page.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; import 'package:appflowy/plugins/document/document.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class PluginArgumentKeys { static String selection = "selection"; static String rowId = "row_id"; static String blockId = "block_id"; } class ViewExtKeys { // used for customizing the font family. static String fontKey = 'font'; // used for customizing the font layout. static String fontLayoutKey = 'font_layout'; // used for customizing the line height layout. static String lineHeightLayoutKey = 'line_height_layout'; // cover keys static String coverKey = 'cover'; static String coverTypeKey = 'type'; static String coverValueKey = 'value'; // is pinned static String isPinnedKey = 'is_pinned'; // space static String isSpaceKey = 'is_space'; static String spaceCreatorKey = 'space_creator'; static String spaceCreatedAtKey = 'space_created_at'; static String spaceIconKey = 'space_icon'; static String spaceIconColorKey = 'space_icon_color'; static String spacePermissionKey = 'space_permission'; } extension MinimalViewExtension on FolderViewMinimalPB { Widget defaultIcon({Size? size}) => FlowySvg( switch (layout) { ViewLayoutPB.Board => FlowySvgs.icon_board_s, ViewLayoutPB.Calendar => FlowySvgs.icon_calendar_s, ViewLayoutPB.Grid => FlowySvgs.icon_grid_s, ViewLayoutPB.Document => FlowySvgs.icon_document_s, ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, _ => FlowySvgs.icon_document_s, }, size: size, ); } extension ViewExtension on ViewPB { String get nameOrDefault => name.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : name; bool get isDocument => pluginType == PluginType.document; bool get isDatabase => [ PluginType.grid, PluginType.board, PluginType.calendar, ].contains(pluginType); Widget defaultIcon({Size? size}) => FlowySvg( switch (layout) { ViewLayoutPB.Board => FlowySvgs.icon_board_s, ViewLayoutPB.Calendar => FlowySvgs.icon_calendar_s, ViewLayoutPB.Grid => FlowySvgs.icon_grid_s, ViewLayoutPB.Document => FlowySvgs.icon_document_s, ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, _ => FlowySvgs.icon_document_s, }, size: size, ); PluginType get pluginType => switch (layout) { ViewLayoutPB.Board => PluginType.board, ViewLayoutPB.Calendar => PluginType.calendar, ViewLayoutPB.Document => PluginType.document, ViewLayoutPB.Grid => PluginType.grid, ViewLayoutPB.Chat => PluginType.chat, _ => throw UnimplementedError(), }; Plugin plugin({ Map arguments = const {}, }) { switch (layout) { case ViewLayoutPB.Board: case ViewLayoutPB.Calendar: case ViewLayoutPB.Grid: final String? rowId = arguments[PluginArgumentKeys.rowId]; return DatabaseTabBarViewPlugin( view: this, pluginType: pluginType, initialRowId: rowId, ); case ViewLayoutPB.Document: final selectionValue = arguments[PluginArgumentKeys.selection]; Selection? initialSelection; if (selectionValue is Selection) initialSelection = selectionValue; final String? initialBlockId = arguments[PluginArgumentKeys.blockId]; return DocumentPlugin( view: this, pluginType: pluginType, initialSelection: initialSelection, initialBlockId: initialBlockId, ); case ViewLayoutPB.Chat: return AIChatPagePlugin(view: this); } throw UnimplementedError; } DatabaseTabBarItemBuilder tabBarItem() => switch (layout) { ViewLayoutPB.Board => BoardPageTabBarBuilderImpl(), ViewLayoutPB.Calendar => CalendarPageTabBarBuilderImpl(), ViewLayoutPB.Grid => DesktopGridTabBarBuilderImpl(), _ => throw UnimplementedError, }; DatabaseTabBarItemBuilder mobileTabBarItem() => switch (layout) { ViewLayoutPB.Board => BoardPageTabBarBuilderImpl(), ViewLayoutPB.Calendar => CalendarPageTabBarBuilderImpl(), ViewLayoutPB.Grid => MobileGridTabBarBuilderImpl(), _ => throw UnimplementedError, }; FlowySvgData get iconData => layout.icon; bool get isSpace { try { if (extra.isEmpty) { return false; } final ext = jsonDecode(extra); final isSpace = ext[ViewExtKeys.isSpaceKey] ?? false; return isSpace; } catch (e) { return false; } } SpacePermission get spacePermission { try { final ext = jsonDecode(extra); final permission = ext[ViewExtKeys.spacePermissionKey] ?? 1; return SpacePermission.values[permission]; } catch (e) { return SpacePermission.private; } } FlowySvg? buildSpaceIconSvg(BuildContext context, {Size? size}) { try { if (extra.isEmpty) { return null; } final ext = jsonDecode(extra); final icon = ext[ViewExtKeys.spaceIconKey]; final color = ext[ViewExtKeys.spaceIconColorKey]; if (icon == null || color == null) { return null; } // before version 0.6.7 if (icon.contains('space_icon')) { return FlowySvg( FlowySvgData('assets/flowy_icons/16x/$icon.svg'), color: Theme.of(context).colorScheme.surface, ); } final values = icon.split('/'); if (values.length != 2) { return null; } final groupName = values[0]; final iconName = values[1]; final svgString = kIconGroups ?.firstWhereOrNull( (group) => group.name == groupName, ) ?.icons .firstWhereOrNull( (icon) => icon.name == iconName, ) ?.content; if (svgString == null) { return null; } return FlowySvg.string( svgString, color: Theme.of(context).colorScheme.surface, size: size, ); } catch (e) { return null; } } String? get spaceIcon { try { final ext = jsonDecode(extra); final icon = ext[ViewExtKeys.spaceIconKey]; return icon; } catch (e) { return null; } } String? get spaceIconColor { try { final ext = jsonDecode(extra); final color = ext[ViewExtKeys.spaceIconColorKey]; return color; } catch (e) { return null; } } bool get isPinned { try { final ext = jsonDecode(extra); final isPinned = ext[ViewExtKeys.isPinnedKey] ?? false; return isPinned; } catch (e) { return false; } } PageStyleCover? get cover { if (layout != ViewLayoutPB.Document) { return null; } if (extra.isEmpty) { return null; } try { final ext = jsonDecode(extra); final cover = ext[ViewExtKeys.coverKey] ?? {}; final coverType = cover[ViewExtKeys.coverTypeKey] ?? PageStyleCoverImageType.none.toString(); final coverValue = cover[ViewExtKeys.coverValueKey] ?? ''; return PageStyleCover( type: PageStyleCoverImageType.fromString(coverType), value: coverValue, ); } catch (e) { return null; } } PageStyleLineHeightLayout get lineHeightLayout { if (layout != ViewLayoutPB.Document) { return PageStyleLineHeightLayout.normal; } try { final ext = jsonDecode(extra); final lineHeight = ext[ViewExtKeys.lineHeightLayoutKey]; return PageStyleLineHeightLayout.fromString(lineHeight); } catch (e) { return PageStyleLineHeightLayout.normal; } } PageStyleFontLayout get fontLayout { if (layout != ViewLayoutPB.Document) { return PageStyleFontLayout.normal; } try { final ext = jsonDecode(extra); final fontLayout = ext[ViewExtKeys.fontLayoutKey]; return PageStyleFontLayout.fromString(fontLayout); } catch (e) { return PageStyleFontLayout.normal; } } @visibleForTesting set isSpace(bool value) { try { if (extra.isEmpty) { extra = jsonEncode({ViewExtKeys.isSpaceKey: value}); } else { final ext = jsonDecode(extra); ext[ViewExtKeys.isSpaceKey] = value; extra = jsonEncode(ext); } } catch (e) { extra = jsonEncode({ViewExtKeys.isSpaceKey: value}); } } } extension ViewLayoutExtension on ViewLayoutPB { FlowySvgData get icon => switch (this) { ViewLayoutPB.Board => FlowySvgs.icon_board_s, ViewLayoutPB.Calendar => FlowySvgs.icon_calendar_s, ViewLayoutPB.Grid => FlowySvgs.icon_grid_s, ViewLayoutPB.Document => FlowySvgs.icon_document_s, ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, _ => FlowySvgs.icon_document_s, }; bool get isDocumentView => switch (this) { ViewLayoutPB.Document => true, ViewLayoutPB.Chat || ViewLayoutPB.Grid || ViewLayoutPB.Board || ViewLayoutPB.Calendar => false, _ => throw Exception('Unknown layout type'), }; bool get isDatabaseView => switch (this) { ViewLayoutPB.Grid || ViewLayoutPB.Board || ViewLayoutPB.Calendar => true, ViewLayoutPB.Document || ViewLayoutPB.Chat => false, _ => throw Exception('Unknown layout type'), }; String get defaultName => switch (this) { ViewLayoutPB.Document => '', _ => LocaleKeys.menuAppHeader_defaultNewPageName.tr(), }; bool get shrinkWrappable => switch (this) { ViewLayoutPB.Grid => true, ViewLayoutPB.Board => true, _ => false, }; double get pluginHeight => switch (this) { ViewLayoutPB.Document || ViewLayoutPB.Board || ViewLayoutPB.Chat => 450, ViewLayoutPB.Calendar => 650, ViewLayoutPB.Grid => double.infinity, _ => throw UnimplementedError(), }; } extension ViewFinder on List { ViewPB? findView(String id) { for (final view in this) { if (view.id == id) { return view; } if (view.childViews.isNotEmpty) { final v = view.childViews.findView(id); if (v != null) { return v; } } } return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/view/view_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; // Delete the view from trash, which means the view was deleted permanently typedef DeleteViewNotifyValue = FlowyResult; // The view get updated typedef UpdateViewNotifiedValue = ViewPB; // Restore the view from trash typedef RestoreViewNotifiedValue = FlowyResult; // Move the view to trash typedef MoveToTrashNotifiedValue = FlowyResult; class ViewListener { ViewListener({required this.viewId}); StreamSubscription? _subscription; void Function(UpdateViewNotifiedValue)? _updatedViewNotifier; void Function(ChildViewUpdatePB)? _updateViewChildViewsNotifier; void Function(DeleteViewNotifyValue)? _deletedNotifier; void Function(RestoreViewNotifiedValue)? _restoredNotifier; void Function(MoveToTrashNotifiedValue)? _moveToTrashNotifier; bool _isDisposed = false; FolderNotificationParser? _parser; final String viewId; void start({ void Function(UpdateViewNotifiedValue)? onViewUpdated, void Function(ChildViewUpdatePB)? onViewChildViewsUpdated, void Function(DeleteViewNotifyValue)? onViewDeleted, void Function(RestoreViewNotifiedValue)? onViewRestored, void Function(MoveToTrashNotifiedValue)? onViewMoveToTrash, }) { if (_isDisposed) { Log.warn("ViewListener is already disposed"); return; } _updatedViewNotifier = onViewUpdated; _deletedNotifier = onViewDeleted; _restoredNotifier = onViewRestored; _moveToTrashNotifier = onViewMoveToTrash; _updateViewChildViewsNotifier = onViewChildViewsUpdated; _parser = FolderNotificationParser( id: viewId, callback: (ty, result) { _handleObservableType(ty, result); }, ); _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); } void _handleObservableType( FolderNotification ty, FlowyResult result, ) { switch (ty) { case FolderNotification.DidUpdateView: result.fold( (payload) { final view = ViewPB.fromBuffer(payload); _updatedViewNotifier?.call(view); }, (error) => Log.error(error), ); break; case FolderNotification.DidUpdateChildViews: result.fold( (payload) { final pb = ChildViewUpdatePB.fromBuffer(payload); _updateViewChildViewsNotifier?.call(pb); }, (error) => Log.error(error), ); break; case FolderNotification.DidDeleteView: result.fold( (payload) => _deletedNotifier ?.call(FlowyResult.success(ViewPB.fromBuffer(payload))), (error) => _deletedNotifier?.call(FlowyResult.failure(error)), ); break; case FolderNotification.DidRestoreView: result.fold( (payload) => _restoredNotifier ?.call(FlowyResult.success(ViewPB.fromBuffer(payload))), (error) => _restoredNotifier?.call(FlowyResult.failure(error)), ); break; case FolderNotification.DidMoveViewToTrash: result.fold( (payload) => _moveToTrashNotifier ?.call(FlowyResult.success(DeletedViewPB.fromBuffer(payload))), (error) => _moveToTrashNotifier?.call(FlowyResult.failure(error)), ); break; default: break; } } Future stop() async { _isDisposed = true; _parser = null; await _subscription?.cancel(); _updatedViewNotifier = null; _deletedNotifier = null; _restoredNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; class ViewBackendService { static Future> createView({ /// The [layoutType] is the type of the view. required ViewLayoutPB layoutType, /// The [parentViewId] is the parent view id. required String parentViewId, /// The [name] is the name of the view. required String name, /// The default value of [openAfterCreate] is false, meaning the view will /// not be opened nor set as the current view. However, if set to true, the /// view will be opened and set as the current view. Upon relaunching the /// app, this view will be opened bool openAfterCreate = false, /// The initial data should be a JSON that represent the DocumentDataPB. /// Currently, only support create document with initial data. List? initialDataBytes, /// The [ext] is used to pass through the custom configuration /// to the backend. /// Linking the view to the existing database, it needs to pass /// the database id. For example: "database_id": "xxx" /// Map ext = const {}, /// The [index] is the index of the view in the parent view. /// If the index is null, the view will be added to the end of the list. int? index, ViewSectionPB? section, final String? viewId, }) { final payload = CreateViewPayloadPB.create() ..parentViewId = parentViewId ..name = name ..layout = layoutType ..setAsCurrent = openAfterCreate ..initialData = initialDataBytes ?? []; if (ext.isNotEmpty) { payload.meta.addAll(ext); } if (index != null) { payload.index = index; } if (section != null) { payload.section = section; } if (viewId != null) { payload.viewId = viewId; } return FolderEventCreateView(payload).send(); } /// The orphan view is meant to be a view that is not attached to any parent view. By default, this /// view will not be shown in the view list unless it is attached to a parent view that is shown in /// the view list. static Future> createOrphanView({ required String viewId, required ViewLayoutPB layoutType, required String name, String? desc, /// The initial data should be a JSON that represent the DocumentDataPB. /// Currently, only support create document with initial data. List? initialDataBytes, }) { final payload = CreateOrphanViewPayloadPB.create() ..viewId = viewId ..name = name ..layout = layoutType ..initialData = initialDataBytes ?? []; return FolderEventCreateOrphanView(payload).send(); } static Future> createDatabaseLinkedView({ required String parentViewId, required String databaseId, required ViewLayoutPB layoutType, required String name, }) { return createView( layoutType: layoutType, parentViewId: parentViewId, name: name, ext: {'database_id': databaseId}, ); } /// Returns a list of views that are the children of the given [viewId]. static Future, FlowyError>> getChildViews({ required String viewId, }) { if (viewId.isEmpty) { return Future.value( FlowyResult, FlowyError>.success([]), ); } final payload = ViewIdPB.create()..value = viewId; return FolderEventGetView(payload).send().then((result) { return result.fold( (view) => FlowyResult.success(view.childViews), (error) => FlowyResult.failure(error), ); }); } static Future> deleteView({ required String viewId, }) { final request = RepeatedViewIdPB.create()..items.add(viewId); return FolderEventDeleteView(request).send(); } static Future> deleteViews({ required List viewIds, }) { final request = RepeatedViewIdPB.create()..items.addAll(viewIds); return FolderEventDeleteView(request).send(); } static Future> duplicate({ required ViewPB view, required bool openAfterDuplicate, // should include children views required bool includeChildren, String? parentViewId, String? suffix, required bool syncAfterDuplicate, }) { final payload = DuplicateViewPayloadPB.create() ..viewId = view.id ..openAfterDuplicate = openAfterDuplicate ..includeChildren = includeChildren ..syncAfterCreate = syncAfterDuplicate; if (parentViewId != null) { payload.parentViewId = parentViewId; } if (suffix != null) { payload.suffix = suffix; } return FolderEventDuplicateView(payload).send(); } static Future> favorite({ required String viewId, }) { final request = RepeatedViewIdPB.create()..items.add(viewId); return FolderEventToggleFavorite(request).send(); } static Future> updateView({ required String viewId, String? name, bool? isFavorite, String? extra, }) { final payload = UpdateViewPayloadPB.create()..viewId = viewId; if (name != null) { payload.name = name; } if (isFavorite != null) { payload.isFavorite = isFavorite; } if (extra != null) { payload.extra = extra; } return FolderEventUpdateView(payload).send(); } static Future> updateViewIcon({ required ViewPB view, required EmojiIconData viewIcon, }) { final viewId = view.id; final oldIcon = view.icon.toEmojiIconData(); final icon = viewIcon.toViewIcon(); final payload = UpdateViewIconPayloadPB.create() ..viewId = viewId ..icon = icon; if (oldIcon.type == FlowyIconType.custom && viewIcon.emoji != oldIcon.emoji) { DocumentEventDeleteFile( DeleteFilePB(url: oldIcon.emoji), ).send().onFailure((e) { Log.error( 'updateViewIcon error while deleting :${oldIcon.emoji}, error: ${e.msg}, ${e.code}', ); }); } return FolderEventUpdateViewIcon(payload).send(); } // deprecated static Future> moveView({ required String viewId, required int fromIndex, required int toIndex, }) { final payload = MoveViewPayloadPB.create() ..viewId = viewId ..from = fromIndex ..to = toIndex; return FolderEventMoveView(payload).send(); } /// Move the view to the new parent view. /// /// supports nested view /// if the [prevViewId] is null, the view will be moved to the beginning of the list static Future> moveViewV2({ required String viewId, required String newParentId, required String? prevViewId, ViewSectionPB? fromSection, ViewSectionPB? toSection, }) { final payload = MoveNestedViewPayloadPB( viewId: viewId, newParentId: newParentId, prevViewId: prevViewId, fromSection: fromSection, toSection: toSection, ); return FolderEventMoveNestedView(payload).send(); } /// Fetches a flattened list of all Views. /// /// Views do not contain their children in this list, as they all exist /// in the same level in this version. /// static Future> getAllViews() async { return FolderEventGetAllViews().send(); } static Future> getView( String viewId, ) async { if (viewId.isEmpty) { Log.error('ViewId is empty'); } final payload = ViewIdPB.create()..value = viewId; return FolderEventGetView(payload).send(); } static Future getMentionPageStatus(String pageId) async { final view = await ViewBackendService.getView(pageId).then( (value) => value.toNullable(), ); // found the page if (view != null) { return (view, false, false); } // if the view is not found, try to fetch from trash final trashViews = await TrashService().readTrash(); final trash = trashViews.fold( (l) => l.items.firstWhereOrNull((element) => element.id == pageId), (r) => null, ); if (trash != null) { final trashView = ViewPB() ..id = trash.id ..name = trash.name; return (trashView, true, false); } // the page was deleted return (null, false, true); } static Future> getViewAncestors( String viewId, ) async { final payload = ViewIdPB.create()..value = viewId; return FolderEventGetViewAncestors(payload).send(); } Future> getChildView({ required String parentViewId, required String childViewId, }) async { final payload = ViewIdPB.create()..value = parentViewId; return FolderEventGetView(payload).send().then((result) { return result.fold( (app) => FlowyResult.success( app.childViews.firstWhere((e) => e.id == childViewId), ), (error) => FlowyResult.failure(error), ); }); } static Future> updateViewsVisibility( List views, bool isPublic, ) async { final payload = UpdateViewVisibilityStatusPayloadPB( viewIds: views.map((e) => e.id).toList(), isPublic: isPublic, ); return FolderEventUpdateViewVisibilityStatus(payload).send(); } static Future> getPublishInfo( ViewPB view, ) async { final payload = ViewIdPB()..value = view.id; return FolderEventGetPublishInfo(payload).send(); } static Future> publish( ViewPB view, { String? name, List? selectedViewIds, }) async { final payload = PublishViewParamsPB()..viewId = view.id; if (name != null) { payload.publishName = name; } if (selectedViewIds != null && selectedViewIds.isNotEmpty) { payload.selectedViewIds = RepeatedViewIdPB(items: selectedViewIds); } return FolderEventPublishView(payload).send(); } static Future> unpublish( ViewPB view, ) async { final payload = UnpublishViewsPayloadPB(viewIds: [view.id]); return FolderEventUnpublishViews(payload).send(); } static Future> setPublishNameSpace( String name, ) async { final payload = SetPublishNamespacePayloadPB()..newNamespace = name; return FolderEventSetPublishNamespace(payload).send(); } static Future> getPublishNameSpace() async { return FolderEventGetPublishNamespace().send(); } static Future> getAllChildViews(ViewPB view) async { final views = []; final childViews = await ViewBackendService.getChildViews(viewId: view.id).fold( (s) => s, (f) => [], ); for (final child in childViews) { // filter the view itself if (child.id == view.id) { continue; } views.add(child); views.addAll(await getAllChildViews(child)); } return views; } static Future<(bool, List)> containPublishedPage(ViewPB view) async { final childViews = await ViewBackendService.getAllChildViews(view); final views = [view, ...childViews]; final List publishedPages = []; for (final view in views) { final publishInfo = await ViewBackendService.getPublishInfo(view); if (publishInfo.isSuccess) { publishedPages.add(view); } } return (publishedPages.isNotEmpty, publishedPages); } static Future> lockView(String viewId) async { final payload = ViewIdPB()..value = viewId; return FolderEventLockView(payload).send(); } static Future> unlockView(String viewId) async { final payload = ViewIdPB()..value = viewId; return FolderEventUnlockView(payload).send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/view_info/view_info_bloc.dart ================================================ import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'view_info_bloc.freezed.dart'; class ViewInfoBloc extends Bloc { ViewInfoBloc({required this.view}) : super(ViewInfoState.initial()) { on((event, emit) { event.when( started: () { emit( state.copyWith( createdAt: view.createTime.toDateTime(), titleCounters: view.name.getCounter(), ), ); }, unregisterEditorState: () { _clearWordCountService(); emit(state.copyWith(documentCounters: null)); }, registerEditorState: (editorState) { _clearWordCountService(); _wordCountService = WordCountService(editorState: editorState) ..addListener(_onWordCountChanged) ..register(); emit( state.copyWith( documentCounters: _wordCountService!.documentCounters, ), ); }, wordCountChanged: () { emit( state.copyWith( documentCounters: _wordCountService?.documentCounters, ), ); }, titleChanged: (s) { emit( state.copyWith( titleCounters: s.getCounter(), ), ); }, workspaceTypeChanged: (s) { emit( state.copyWith(workspaceType: s), ); }, ); }); UserEventGetUserProfile().send().then((value) { value.fold( (s) { if (!isClosed) { add(ViewInfoEvent.workspaceTypeChanged(s.workspaceType)); } }, (e) => Log.error('Failed to get user profile: $e'), ); }); } final ViewPB view; WordCountService? _wordCountService; @override Future close() async { _clearWordCountService(); await super.close(); } void _onWordCountChanged() => add(const ViewInfoEvent.wordCountChanged()); void _clearWordCountService() { _wordCountService ?..removeListener(_onWordCountChanged) ..dispose(); _wordCountService = null; } } @freezed class ViewInfoEvent with _$ViewInfoEvent { const factory ViewInfoEvent.started() = _Started; const factory ViewInfoEvent.unregisterEditorState() = _UnregisterEditorState; const factory ViewInfoEvent.registerEditorState({ required EditorState editorState, }) = _RegisterEditorState; const factory ViewInfoEvent.wordCountChanged() = _WordCountChanged; const factory ViewInfoEvent.titleChanged(String title) = _TitleChanged; const factory ViewInfoEvent.workspaceTypeChanged( WorkspaceTypePB workspaceType, ) = _WorkspaceTypeChanged; } @freezed class ViewInfoState with _$ViewInfoState { const factory ViewInfoState({ required Counters? documentCounters, required Counters? titleCounters, required DateTime? createdAt, required WorkspaceTypePB? workspaceType, }) = _ViewInfoState; factory ViewInfoState.initial() => const ViewInfoState( documentCounters: null, titleCounters: null, createdAt: null, workspaceType: null, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/view_title/view_title_bar_bloc.dart ================================================ import 'package:appflowy/plugins/trash/application/prelude.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'view_title_bar_bloc.freezed.dart'; class ViewTitleBarBloc extends Bloc { ViewTitleBarBloc({required this.view}) : super(ViewTitleBarState.initial()) { on( (event, emit) async { await event.when( reload: () async { final List ancestors = await ViewBackendService.getViewAncestors(view.id).fold( (s) => s.items, (f) => [], ); final isDeleted = (await trashService.readTrash()).fold( (s) => s.items.any((t) => t.id == view.id), (f) => false, ); emit(state.copyWith(ancestors: ancestors, isDeleted: isDeleted)); }, trashUpdated: (trash) { if (trash.any((t) => t.id == view.id)) { emit(state.copyWith(isDeleted: true)); } }, ); }, ); trashService = TrashService(); viewListener = ViewListener(viewId: view.id) ..start( onViewChildViewsUpdated: (_) { if (!isClosed) { add(const ViewTitleBarEvent.reload()); } }, ); trashListener = TrashListener() ..start( trashUpdated: (trashOrFailed) { final trash = trashOrFailed.toNullable(); if (trash != null && !isClosed) { add(ViewTitleBarEvent.trashUpdated(trash: trash)); } }, ); if (!isClosed) { add(const ViewTitleBarEvent.reload()); } } final ViewPB view; late final TrashService trashService; late final ViewListener viewListener; late final TrashListener trashListener; @override Future close() { trashListener.close(); viewListener.stop(); return super.close(); } } @freezed class ViewTitleBarEvent with _$ViewTitleBarEvent { const factory ViewTitleBarEvent.reload() = Reload; const factory ViewTitleBarEvent.trashUpdated({ required List trash, }) = TrashUpdated; } @freezed class ViewTitleBarState with _$ViewTitleBarState { const factory ViewTitleBarState({ required List ancestors, @Default(false) bool isDeleted, }) = _ViewTitleBarState; factory ViewTitleBarState.initial() => const ViewTitleBarState(ancestors: []); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/view_title/view_title_bloc.dart ================================================ import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; part 'view_title_bloc.freezed.dart'; class ViewTitleBloc extends Bloc { ViewTitleBloc({ required this.view, }) : viewListener = ViewListener(viewId: view.id), super(ViewTitleState.initial()) { on( (event, emit) async { await event.when( initial: () async { emit( state.copyWith( name: view.name, icon: view.icon.toEmojiIconData(), view: view, ), ); viewListener.start( onViewUpdated: (view) { add( ViewTitleEvent.updateNameOrIcon( view.name, view.icon.toEmojiIconData(), view, ), ); }, ); }, updateNameOrIcon: (name, icon, view) async { emit( state.copyWith( name: name, icon: icon, view: view, ), ); }, ); }, ); } final ViewPB view; final ViewListener viewListener; @override Future close() { viewListener.stop(); return super.close(); } } @freezed class ViewTitleEvent with _$ViewTitleEvent { const factory ViewTitleEvent.initial() = Initial; const factory ViewTitleEvent.updateNameOrIcon( String name, EmojiIconData icon, ViewPB? view, ) = UpdateNameOrIcon; } @freezed class ViewTitleState with _$ViewTitleState { const factory ViewTitleState({ required String name, required EmojiIconData icon, @Default(null) ViewPB? view, }) = _ViewTitleState; factory ViewTitleState.initial() => ViewTitleState( name: '', icon: EmojiIconData.none(), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/workspace/prelude.dart ================================================ export 'workspace_bloc.dart'; export 'workspace_listener.dart'; export 'workspace_service.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart ================================================ import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'workspace_bloc.freezed.dart'; class WorkspaceBloc extends Bloc { WorkspaceBloc({required this.userService}) : super(WorkspaceState.initial()) { _dispatch(); } final UserBackendService userService; void _dispatch() { on( (event, emit) async { await event.map( initial: (e) async { await _fetchWorkspaces(emit); }, createWorkspace: (e) async { await _createWorkspace(e.name, e.desc, emit); }, workspacesReceived: (e) async { emit( e.workspacesOrFail.fold( (workspaces) => state.copyWith( workspaces: workspaces, successOrFailure: FlowyResult.success(null), ), (error) => state.copyWith( successOrFailure: FlowyResult.failure(error), ), ), ); }, ); }, ); } Future _fetchWorkspaces(Emitter emit) async { final workspacesOrFailed = await userService.getWorkspaces(); emit( workspacesOrFailed.fold( (workspaces) => state.copyWith( workspaces: [], successOrFailure: FlowyResult.success(null), ), (error) { Log.error(error); return state.copyWith(successOrFailure: FlowyResult.failure(error)); }, ), ); } Future _createWorkspace( String name, String desc, Emitter emit, ) async { final result = await userService.createUserWorkspace(name, WorkspaceTypePB.ServerW); emit( result.fold( (workspace) { return state.copyWith(successOrFailure: FlowyResult.success(null)); }, (error) { Log.error(error); return state.copyWith(successOrFailure: FlowyResult.failure(error)); }, ), ); } } @freezed class WorkspaceEvent with _$WorkspaceEvent { const factory WorkspaceEvent.initial() = Initial; const factory WorkspaceEvent.createWorkspace(String name, String desc) = CreateWorkspace; const factory WorkspaceEvent.workspacesReceived( FlowyResult, FlowyError> workspacesOrFail, ) = WorkspacesReceived; } @freezed class WorkspaceState with _$WorkspaceState { const factory WorkspaceState({ required bool isLoading, required List workspaces, required FlowyResult successOrFailure, }) = _WorkspaceState; factory WorkspaceState.initial() => WorkspaceState( isLoading: false, workspaces: List.empty(), successOrFailure: FlowyResult.success(null), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef RootViewsNotifyValue = FlowyResult, FlowyError>; typedef WorkspaceNotifyValue = FlowyResult; /// The [WorkspaceListener] listens to the changes including the below: /// /// - The root views of the workspace. (Not including the views are inside the root views) /// - The workspace itself. class WorkspaceListener { WorkspaceListener({required this.user, required this.workspaceId}); final UserProfilePB user; final String workspaceId; PublishNotifier? _appsChangedNotifier = PublishNotifier(); PublishNotifier? _workspaceUpdatedNotifier = PublishNotifier(); FolderNotificationListener? _listener; void start({ void Function(RootViewsNotifyValue)? appsChanged, void Function(WorkspaceNotifyValue)? onWorkspaceUpdated, }) { if (appsChanged != null) { _appsChangedNotifier?.addPublishListener(appsChanged); } if (onWorkspaceUpdated != null) { _workspaceUpdatedNotifier?.addPublishListener(onWorkspaceUpdated); } _listener = FolderNotificationListener( objectId: workspaceId, handler: _handleObservableType, ); } void _handleObservableType( FolderNotification ty, FlowyResult result, ) { switch (ty) { case FolderNotification.DidUpdateWorkspace: result.fold( (payload) => _workspaceUpdatedNotifier?.value = FlowyResult.success(WorkspacePB.fromBuffer(payload)), (error) => _workspaceUpdatedNotifier?.value = FlowyResult.failure(error), ); break; case FolderNotification.DidUpdateWorkspaceViews: result.fold( (payload) => _appsChangedNotifier?.value = FlowyResult.success(RepeatedViewPB.fromBuffer(payload).items), (error) => _appsChangedNotifier?.value = FlowyResult.failure(error), ); break; default: break; } } Future stop() async { await _listener?.stop(); _appsChangedNotifier?.dispose(); _appsChangedNotifier = null; _workspaceUpdatedNotifier?.dispose(); _workspaceUpdatedNotifier = null; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_sections_listener.dart ================================================ import 'dart:async'; import 'dart:typed_data'; import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; typedef SectionNotifyValue = FlowyResult; /// The [WorkspaceSectionsListener] listens to the changes including the below: /// /// - The root views inside different section of the workspace. (Not including the views are inside the root views) /// depends on the section type(s). class WorkspaceSectionsListener { WorkspaceSectionsListener({ required this.user, required this.workspaceId, }); final UserProfilePB user; final String workspaceId; final _sectionNotifier = PublishNotifier(); late final FolderNotificationListener _listener; void start({ void Function(SectionNotifyValue)? sectionChanged, }) { if (sectionChanged != null) { _sectionNotifier.addPublishListener(sectionChanged); } _listener = FolderNotificationListener( objectId: workspaceId, handler: _handleObservableType, ); } void _handleObservableType( FolderNotification ty, FlowyResult result, ) { switch (ty) { case FolderNotification.DidUpdateSectionViews: final FlowyResult value = result.fold( (s) => FlowyResult.success( SectionViewsPB.fromBuffer(s), ), (f) => FlowyResult.failure(f), ); _sectionNotifier.value = value; break; default: break; } } Future stop() async { _sectionNotifier.dispose(); await _listener.stop(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart ================================================ import 'dart:async'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart' as fixnum; class WorkspaceService { WorkspaceService({required this.workspaceId, required this.userId}); final String workspaceId; final fixnum.Int64 userId; Future> createView({ required String name, required ViewSectionPB viewSection, int? index, ViewLayoutPB? layout, bool? setAsCurrent, String? viewId, String? extra, }) { final payload = CreateViewPayloadPB.create() ..parentViewId = workspaceId ..name = name ..layout = layout ?? ViewLayoutPB.Document ..section = viewSection; if (index != null) { payload.index = index; } if (setAsCurrent != null) { payload.setAsCurrent = setAsCurrent; } if (viewId != null) { payload.viewId = viewId; } if (extra != null) { payload.extra = extra; } return FolderEventCreateView(payload).send(); } Future> getWorkspace() { return FolderEventReadCurrentWorkspace().send(); } Future, FlowyError>> getPublicViews() { final payload = GetWorkspaceViewPB.create()..value = workspaceId; return FolderEventReadWorkspaceViews(payload).send().then((result) { return result.fold( (views) => FlowyResult.success(views.items), (error) => FlowyResult.failure(error), ); }); } Future, FlowyError>> getPrivateViews() { final payload = GetWorkspaceViewPB.create()..value = workspaceId; return FolderEventReadPrivateViews(payload).send().then((result) { return result.fold( (views) => FlowyResult.success(views.items), (error) => FlowyResult.failure(error), ); }); } Future> moveView({ required String viewId, required int fromIndex, required int toIndex, }) { final payload = MoveViewPayloadPB.create() ..viewId = viewId ..from = fromIndex ..to = toIndex; return FolderEventMoveView(payload).send(); } Future> getWorkspaceUsage() async { final payload = UserWorkspaceIdPB(workspaceId: workspaceId); return UserEventGetWorkspaceUsage(payload).send(); } Future> getBillingPortal() { return UserEventGetBillingPortal().send(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import 'widgets/search_ask_ai_entrance.dart'; class CommandPalette extends InheritedWidget { CommandPalette({ super.key, required Widget? child, required this.notifier, }) : super( child: _CommandPaletteController(notifier: notifier, child: child), ); final ValueNotifier notifier; static CommandPalette of(BuildContext context) { final CommandPalette? result = context.dependOnInheritedWidgetOfExactType(); assert(result != null, "CommandPalette could not be found"); return result!; } static CommandPalette? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType(); void toggle({ UserWorkspaceBloc? workspaceBloc, SpaceBloc? spaceBloc, }) { final value = notifier.value; notifier.value = notifier.value.copyWith( isOpen: !value.isOpen, userWorkspaceBloc: workspaceBloc, spaceBloc: spaceBloc, ); } void updateBlocs({ UserWorkspaceBloc? workspaceBloc, SpaceBloc? spaceBloc, }) { notifier.value = notifier.value.copyWith( userWorkspaceBloc: workspaceBloc, spaceBloc: spaceBloc, ); } @override bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; } class _ToggleCommandPaletteIntent extends Intent { const _ToggleCommandPaletteIntent(); } class _CommandPaletteController extends StatefulWidget { const _CommandPaletteController({ required this.child, required this.notifier, }); final Widget? child; final ValueNotifier notifier; @override State<_CommandPaletteController> createState() => _CommandPaletteControllerState(); } class _CommandPaletteControllerState extends State<_CommandPaletteController> { late ValueNotifier _toggleNotifier = widget.notifier; bool _isOpen = false; @override void initState() { super.initState(); _toggleNotifier.addListener(_onToggle); } @override void dispose() { _toggleNotifier.removeListener(_onToggle); super.dispose(); } @override void didUpdateWidget(_CommandPaletteController oldWidget) { if (oldWidget.notifier != widget.notifier) { oldWidget.notifier.removeListener(_onToggle); _toggleNotifier = widget.notifier; _toggleNotifier.addListener(_onToggle); } super.didUpdateWidget(oldWidget); } void _onToggle() { if (_toggleNotifier.value.isOpen && !_isOpen) { _isOpen = true; final workspaceBloc = _toggleNotifier.value.userWorkspaceBloc; final spaceBloc = _toggleNotifier.value.spaceBloc; final commandBloc = context.read(); Log.info( 'CommandPalette onToggle: workspaceType ${workspaceBloc?.state.userProfile.workspaceType}', ); commandBloc.add(CommandPaletteEvent.refreshCachedViews()); FlowyOverlay.show( context: context, builder: (_) => MultiBlocProvider( providers: [ BlocProvider.value(value: commandBloc), if (workspaceBloc != null) BlocProvider.value(value: workspaceBloc), if (spaceBloc != null) BlocProvider.value(value: spaceBloc), ], child: CommandPaletteModal(shortcutBuilder: _buildShortcut), ), ).then((_) { _isOpen = false; _toggleNotifier.value = _toggleNotifier.value.copyWith(isOpen: false); }); } else if (!_toggleNotifier.value.isOpen && _isOpen) { FlowyOverlay.pop(context); _isOpen = false; } } @override Widget build(BuildContext context) => _buildShortcut(widget.child ?? const SizedBox.shrink()); Widget _buildShortcut(Widget child) => FocusableActionDetector( actions: { _ToggleCommandPaletteIntent: CallbackAction<_ToggleCommandPaletteIntent>( onInvoke: (intent) => _toggleNotifier.value = _toggleNotifier.value .copyWith(isOpen: !_toggleNotifier.value.isOpen), ), }, shortcuts: { LogicalKeySet( UniversalPlatform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyP, ): const _ToggleCommandPaletteIntent(), }, child: child, ); } class CommandPaletteModal extends StatelessWidget { const CommandPaletteModal({super.key, required this.shortcutBuilder}); final Widget Function(Widget) shortcutBuilder; @override Widget build(BuildContext context) { final workspaceState = context.read()?.state; final showAskingAI = workspaceState?.userProfile.workspaceType == WorkspaceTypePB.ServerW; return BlocListener( listener: (_, state) { if (state.askAI && context.mounted) { if (Navigator.canPop(context)) FlowyOverlay.pop(context); final currentWorkspace = workspaceState?.workspaces; final spaceBloc = context.read(); if (currentWorkspace != null && spaceBloc != null) { spaceBloc.add( SpaceEvent.createPage( name: '', layout: ViewLayoutPB.Chat, index: 0, openAfterCreate: true, ), ); } } }, child: BlocBuilder( builder: (context, state) { final theme = AppFlowyTheme.of(context); final noQuery = state.query?.isEmpty ?? true, hasQuery = !noQuery; final hasResult = state.combinedResponseItems.isNotEmpty, searching = state.searching; final spaceXl = theme.spacing.xl; return FlowyDialog( backgroundColor: theme.surfaceColorScheme.layer01, alignment: Alignment.topCenter, insetPadding: const EdgeInsets.only(top: 100), constraints: const BoxConstraints( maxHeight: 640, maxWidth: 960, minWidth: 572, minHeight: 640, ), expandHeight: false, child: shortcutBuilder( // Change mainAxisSize to max so Expanded works correctly. Padding( padding: EdgeInsets.fromLTRB(spaceXl, spaceXl, spaceXl, 0), child: Column( children: [ SearchField(query: state.query, isLoading: searching), if (noQuery) Flexible( child: RecentViewsList( onSelected: () => FlowyOverlay.pop(context), ), ), if (hasResult && hasQuery) Flexible( child: SearchResultList( cachedViews: state.cachedViews, resultItems: state.combinedResponseItems.values.toList(), resultSummaries: state.resultSummaries, ), ) // When there are no results and the query is not empty and not loading, // show the no results message, centered in the available space. else if (hasQuery && !searching) ...[ if (showAskingAI) SearchAskAiEntrance(), Expanded( child: const NoSearchResultsHint(), ), ], if (hasQuery && searching && !hasResult) // Show a loading indicator when searching Expanded( child: Center( child: Center( child: CircularProgressIndicator.adaptive(), ), ), ), ], ), ), ), ); }, ), ); } } /// Updated _NoResultsHint now centers its content. class NoSearchResultsHint extends StatelessWidget { const NoSearchResultsHint({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context), textColor = theme.textColorScheme.secondary; return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.m_home_search_icon_m, color: theme.iconColorScheme.secondary, size: Size.square(24), ), const VSpace(8), Text( LocaleKeys.search_noResultForSearching.tr(), style: theme.textStyle.body.enhanced(color: textColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), const VSpace(4), RichText( textAlign: TextAlign.center, text: TextSpan( text: LocaleKeys.search_noResultForSearchingHintWithoutTrash.tr(), style: theme.textStyle.caption.standard(color: textColor), children: [ TextSpan( text: LocaleKeys.trash_text.tr(), style: theme.textStyle.caption.underline(color: textColor), recognizer: TapGestureRecognizer() ..onTap = () { FlowyOverlay.pop(context); getIt().latestOpenView = null; getIt().add( TabsEvent.openPlugin( plugin: makePlugin(pluginType: PluginType.trash), ), ); }, ), ], ), ), ], ), ); } } class CommandPaletteNotifierValue { CommandPaletteNotifierValue({ this.isOpen = false, this.userWorkspaceBloc, this.spaceBloc, }); final bool isOpen; final UserWorkspaceBloc? userWorkspaceBloc; final SpaceBloc? spaceBloc; CommandPaletteNotifierValue copyWith({ bool? isOpen, UserWorkspaceBloc? userWorkspaceBloc, SpaceBloc? spaceBloc, }) { return CommandPaletteNotifierValue( isOpen: isOpen ?? this.isOpen, userWorkspaceBloc: userWorkspaceBloc ?? this.userWorkspaceBloc, spaceBloc: spaceBloc ?? this.spaceBloc, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/navigation_bloc_extension.dart ================================================ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; extension NavigationBlocExtension on String { void navigateTo() { getIt().add( ActionNavigationEvent.performAction( action: NavigationAction(objectId: this), showErrorToast: true, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/page_preview.dart ================================================ import 'dart:io'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/search/mobile_view_ancestors.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/flowy_gradient_colors.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class PagePreview extends StatelessWidget { const PagePreview({ super.key, required this.view, required this.onViewOpened, }); final ViewPB view; final VoidCallback onViewOpened; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final backgroundColor = Theme.of(context).isLightMode ? Color(0xffF8FAFF) : theme.surfaceColorScheme.layer02; return BlocProvider( create: (context) => DocumentImmersiveCoverBloc(view: view) ..add(const DocumentImmersiveCoverEvent.initial()), child: BlocBuilder( builder: (context, state) { final cover = buildCover(state, context); return Container( height: MediaQuery.of(context).size.height, width: 280, decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(12), ), margin: EdgeInsets.only( top: theme.spacing.xs, bottom: theme.spacing.xl, ), child: Stack( children: [ SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(12), ), child: cover ?? VSpace(80), ), VSpace(24), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ buildTitle(context, view), buildPath(context, view), ...buildTime( context, LocaleKeys.commandPalette_created.tr(), view.createTime.toDateTime(), ), if (view.lastEdited != view.createTime) ...buildTime( context, LocaleKeys.commandPalette_edited.tr(), view.lastEdited.toDateTime(), ), ], ), ), ], ), ), Positioned( top: 70, left: 20, child: SizedBox.square( dimension: 24, child: Center(child: buildIcon(theme, view, cover != null)), ), ), ], ), ); }, ), ); } Widget? buildCover(DocumentImmersiveCoverState state, BuildContext context) { final cover = state.cover; final type = state.cover.type; const height = 80.0; if (type == PageStyleCoverImageType.customImage || type == PageStyleCoverImageType.unsplashImage) { final userProfile = context.read()?.state.userProfile; if (userProfile == null) return null; return SizedBox( height: height, width: double.infinity, child: FlowyNetworkImage( url: cover.value, userProfilePB: userProfile, ), ); } if (type == PageStyleCoverImageType.builtInImage) { return SizedBox( height: height, width: double.infinity, child: Image.asset( PageStyleCoverImageType.builtInImagePath(cover.value), fit: BoxFit.cover, ), ); } if (type == PageStyleCoverImageType.pureColor) { final color = FlowyTint.fromId(cover.value)?.color(context) ?? cover.value.tryToColor(); return Container( height: height, width: double.infinity, color: color, ); } if (type == PageStyleCoverImageType.gradientColor) { return Container( height: height, width: double.infinity, decoration: BoxDecoration( gradient: FlowyGradientColor.fromId(cover.value).linear, ), ); } if (type == PageStyleCoverImageType.localImage) { return SizedBox( height: height, width: double.infinity, child: Image.file( File(cover.value), fit: BoxFit.cover, ), ); } return null; } Widget buildIcon(AppFlowyThemeData theme, ViewPB view, bool hasCover) { final hasIcon = view.icon.value.isNotEmpty; if (!hasIcon && hasCover) return const SizedBox.shrink(); return hasIcon ? RawEmojiIconWidget( emoji: view.icon.toEmojiIconData(), emojiSize: 16.0, lineHeight: 20 / 16, ) : FlowySvg( view.iconData, size: const Size.square(20), color: theme.iconColorScheme.secondary, ); } Widget buildTitle(BuildContext context, ViewPB view) { final theme = AppFlowyTheme.of(context); final titleStyle = theme.textStyle.heading4 .enhanced(color: theme.textColorScheme.primary), titleHoverStyle = titleStyle.copyWith(decoration: TextDecoration.underline); return LayoutBuilder( builder: (context, constrains) { final maxWidth = constrains.maxWidth; String displayText = view.nameOrDefault; final painter = TextPainter( text: TextSpan(text: displayText, style: titleStyle), maxLines: 3, textDirection: TextDirection.ltr, ellipsis: '... ', ); painter.layout(maxWidth: maxWidth); if (painter.didExceedMaxLines) { final lines = painter.computeLineMetrics(); final lastLine = lines.last; final offset = Offset( lastLine.left + lastLine.width, lines.map((e) => e.height).reduce((a, b) => a + b), ); final range = painter.getPositionForOffset(offset); displayText = '${displayText.substring(0, range.offset)}...'; } return AFBaseButton( borderColor: (_, __, ___, ____) => Colors.transparent, borderRadius: 0, onTap: onViewOpened, padding: EdgeInsets.zero, builder: (context, isHovering, disabled) { return RichText( text: TextSpan( children: [ TextSpan( text: displayText, style: isHovering ? titleHoverStyle : titleStyle, ), WidgetSpan( alignment: PlaceholderAlignment.middle, child: Padding( padding: const EdgeInsets.only(left: 4), child: FlowyTooltip( message: LocaleKeys.settings_files_open.tr(), child: FlowySvg( FlowySvgs.search_open_tab_m, color: theme.iconColorScheme.secondary, size: const Size.square(20), ), ), ), ), ], ), ); }, ); }, ); } Widget buildPath(BuildContext context, ViewPB view) { final theme = AppFlowyTheme.of(context); return BlocProvider( key: ValueKey(view.id), create: (context) => ViewAncestorBloc(view.id), child: BlocBuilder( builder: (context, state) { final isEmpty = state.ancestor.ancestors.isEmpty; if (!state.isLoading && isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ VSpace(20), Text( LocaleKeys.commandPalette_location.tr(), style: theme.textStyle.caption .standard(color: theme.textColorScheme.primary), ), state.buildPath( context, style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), ), ], ); }, ), ); } List buildTime(BuildContext context, String title, DateTime time) { final theme = AppFlowyTheme.of(context); final appearanceSettings = context.watch().state; final dateFormat = appearanceSettings.dateFormat, timeFormat = appearanceSettings.timeFormat; return [ VSpace(12), Text( title, style: theme.textStyle.caption .standard(color: theme.textColorScheme.primary), ), Text( dateFormat.formatDate(time, true, timeFormat), style: theme.textStyle.caption .standard(color: theme.textColorScheme.secondary), ), ]; } } class SomethingWentWrong extends StatelessWidget { const SomethingWentWrong({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return SizedBox( width: 300, child: Row( children: [ AFDivider(axis: Axis.vertical), Expanded( child: Padding( padding: const EdgeInsets.only(bottom: 100), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowySvg( FlowySvgs.something_wrong_warning_m, color: theme.iconColorScheme.secondary, size: Size.square(24), ), const VSpace(8), Text( LocaleKeys.search_somethingWentWrong.tr(), style: theme.textStyle.body .enhanced(color: theme.textColorScheme.secondary), maxLines: 1, overflow: TextOverflow.ellipsis, ), const VSpace(4), Text( LocaleKeys.search_tryAgainOrLater.tr(), style: theme.textStyle.caption .standard(color: theme.textColorScheme.secondary), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/presentation/command_palette/navigation_bloc_extension.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_icon.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'page_preview.dart'; import 'search_ask_ai_entrance.dart'; class RecentViewsList extends StatelessWidget { const RecentViewsList({super.key, required this.onSelected}); final VoidCallback onSelected; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => RecentViewsBloc()..add(const RecentViewsEvent.initial()), child: BlocBuilder( builder: (context, state) { return LayoutBuilder( builder: (context, constrains) { final maxWidth = constrains.maxWidth; final hidePreview = maxWidth < 884; return Row( children: [ buildLeftPanel(state, context, hidePreview), if (!hidePreview) buildPreview(state), ], ); }, ); }, ), ); } Widget buildLeftPanel( RecentViewsState state, BuildContext context, bool hidePreview, ) { final workspaceState = context.read()?.state; final showAskingAI = workspaceState?.userProfile.workspaceType == WorkspaceTypePB.ServerW; return Flexible( child: Align( alignment: Alignment.topLeft, child: ScrollControllerBuilder( builder: (context, controller) { return Padding( padding: EdgeInsets.only(right: hidePreview ? 0 : 6), child: FlowyScrollbar( controller: controller, thumbVisibility: false, child: SingleChildScrollView( controller: controller, physics: const ClampingScrollPhysics(), child: Padding( padding: EdgeInsets.only( right: hidePreview ? 0 : 6, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showAskingAI) SearchAskAiEntrance(), buildTitle(context), buildViewList(state, context, hidePreview), VSpace(16), ], ), ), ), ), ); }, ), ), ); } Widget buildTitle(BuildContext context) { final theme = AppFlowyTheme.of(context); return Container( padding: EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.s, ), child: Text( LocaleKeys.sideBar_recent.tr(), style: theme.textStyle.body .enhanced(color: theme.textColorScheme.secondary) .copyWith( letterSpacing: 0.2, height: 22 / 16, ), ), ); } Widget buildViewList( RecentViewsState state, BuildContext context, bool hidePreview, ) { final recentViews = state.views.map((e) => e.item).toSet().toList(); if (recentViews.isEmpty) { return const SizedBox.shrink(); } return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: recentViews.length, itemBuilder: (_, index) { final view = recentViews[index]; return SearchRecentViewCell( key: ValueKey(view.id), icon: SizedBox.square( dimension: 20, child: Center(child: view.buildIcon(context)), ), view: view, onSelected: onSelected, isNarrowWindow: hidePreview, ); }, ); } Widget buildPreview(RecentViewsState state) { final hoveredView = state.hoveredView; if (hoveredView == null) { return SizedBox.shrink(); } return Align( alignment: Alignment.topLeft, child: PagePreview( key: ValueKey(hoveredView.id), view: hoveredView, onViewOpened: () { hoveredView.id.navigateTo(); onSelected(); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'search_summary_cell.dart'; class SearchAskAiEntrance extends StatelessWidget { const SearchAskAiEntrance({super.key}); @override Widget build(BuildContext context) { final bloc = context.read(), state = bloc?.state; if (bloc == null || state == null) return _AskAIFor(); final generatingAIOverview = state.generatingAIOverview; if (generatingAIOverview) return _AISearching(); final hasMockSummary = _mockSummary?.isNotEmpty ?? false, hasSummaries = state.resultSummaries.isNotEmpty; if (hasMockSummary || hasSummaries) return _AIOverview(); return _AskAIFor(); } } class _AskAIFor extends StatelessWidget { const _AskAIFor(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final spaceM = theme.spacing.m, spaceL = theme.spacing.l; return Padding( padding: EdgeInsets.symmetric(vertical: theme.spacing.xs), child: AFBaseButton( borderRadius: spaceM, padding: EdgeInsets.all(spaceL), backgroundColor: (context, isHovering, disable) { if (isHovering) { return theme.fillColorScheme.contentHover; } return Colors.transparent; }, borderColor: (context, isHovering, disable, isFocused) => Colors.transparent, builder: (ctx, isHovering, disable) { return Row( children: [ SizedBox.square( dimension: 20, child: Center( child: FlowySvg( FlowySvgs.m_home_ai_chat_icon_m, size: Size.square(20), blendMode: null, ), ), ), HSpace(8), buildText(context), ], ); }, onTap: () { context .read() ?.add(CommandPaletteEvent.goingToAskAI()); }, ), ); } Widget buildText(BuildContext context) { final theme = AppFlowyTheme.of(context); final bloc = context.read(); final queryText = bloc?.state.query ?? ''; if (queryText.isEmpty) { return Text( LocaleKeys.search_askAIAnything.tr(), style: theme.textStyle.body .enhanced(color: theme.textColorScheme.primary) .copyWith(height: 22 / 14), ); } return Flexible( child: RichText( maxLines: 1, overflow: TextOverflow.ellipsis, text: TextSpan( children: [ TextSpan( text: LocaleKeys.search_askAIFor.tr(), style: theme.textStyle.body .standard(color: theme.textColorScheme.primary), ), TextSpan( text: ' "$queryText"', style: theme.textStyle.body .enhanced(color: theme.textColorScheme.primary) .copyWith(height: 22 / 14), ), ], ), ), ); } } class _AISearching extends StatelessWidget { const _AISearching(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Padding( padding: EdgeInsets.symmetric(vertical: theme.spacing.xs), child: Padding( padding: EdgeInsets.all(theme.spacing.l), child: Row( children: [ SizedBox.square( dimension: 20, child: Center( child: FlowySvg( FlowySvgs.m_home_ai_chat_icon_m, size: Size.square(20), blendMode: null, ), ), ), HSpace(8), Text( LocaleKeys.search_searching.tr(), style: theme.textStyle.body .standard(color: theme.textColorScheme.secondary) .copyWith(height: 22 / 14), ), ], ), ), ); } } class _AIOverview extends StatelessWidget { const _AIOverview(); @override Widget build(BuildContext context) { final bloc = context.read(), state = bloc?.state; final theme = AppFlowyTheme.of(context); final summaries = _mockSummary ?? state?.resultSummaries ?? []; if (summaries.isEmpty) { return const SizedBox.shrink(); } final xl = theme.spacing.xl, m = theme.spacing.m, l = theme.spacing.l; return Padding( padding: EdgeInsets.fromLTRB(m, l, m, xl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ buildHeader(context), VSpace(theme.spacing.l), LayoutBuilder( builder: (context, constrains) { final summary = summaries.first; return SearchSummaryCell( key: ValueKey(summary.content.trim()), summary: summary, maxWidth: constrains.maxWidth, theme: AppFlowyTheme.of(context), textStyle: theme.textStyle.body .standard(color: theme.textColorScheme.primary) .copyWith(height: 22 / 14), ); }, ), VSpace(12), SizedBox( width: 143, child: AFOutlinedButton.normal( padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), borderRadius: 16, builder: (context, hovering, disabled) { return Row( children: [ FlowySvg( FlowySvgs.chat_ai_page_s, size: Size.square(20), color: theme.iconColorScheme.primary, ), HSpace(6), Text( LocaleKeys.commandPalette_aiAskFollowUp.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), ], ); }, onTap: () { context.read()?.add( CommandPaletteEvent.goingToAskAI( sources: summaries.first.sources, ), ); }, ), ), ], ), ); } Widget buildHeader(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( children: [ FlowySvg( FlowySvgs.ai_searching_icon_m, size: Size.square(20), blendMode: null, ), HSpace(theme.spacing.l), Text( LocaleKeys.commandPalette_aiOverview.tr(), style: theme.textStyle.body .enhanced(color: theme.textColorScheme.secondary) .copyWith(height: 22 / 16, letterSpacing: 0.2), ), ], ); } } List? _mockSummary; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SearchField extends StatefulWidget { const SearchField({super.key, this.query, this.isLoading = false}); final String? query; final bool isLoading; @override State createState() => _SearchFieldState(); } class _SearchFieldState extends State { late final FocusNode focusNode; late final TextEditingController controller; @override void initState() { super.initState(); controller = TextEditingController(text: widget.query); focusNode = FocusNode(onKeyEvent: _handleKeyEvent); focusNode.requestFocus(); // Update the text selection after the first frame WidgetsBinding.instance.addPostFrameCallback((_) { controller.selection = TextSelection( baseOffset: 0, extentOffset: controller.text.length, ); }); } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (node.hasFocus && event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowDown) { node.nextFocus(); return KeyEventResult.handled; } return KeyEventResult.ignored; } @override void dispose() { focusNode.dispose(); controller.dispose(); super.dispose(); } Widget _buildSuffixIcon(BuildContext context) { final theme = AppFlowyTheme.of(context); return Padding( padding: EdgeInsets.only(left: theme.spacing.m, right: theme.spacing.l), child: FlowyTooltip( message: LocaleKeys.commandPalette_clearSearchTooltip.tr(), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: _clearSearch, child: SizedBox.square( dimension: 28, child: Center( child: FlowySvg( FlowySvgs.search_clear_m, color: AppFlowyTheme.of(context).iconColorScheme.tertiary, size: const Size.square(20), ), ), ), ), ), ), ); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final radius = BorderRadius.circular(theme.spacing.l); final workspace = context.read()?.state.currentWorkspace; return Container( height: 44, margin: EdgeInsets.only(bottom: theme.spacing.m), child: ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { final hasText = value.text.trim().isNotEmpty; return FlowyTextField( focusNode: focusNode, cursorHeight: 22, controller: controller, textStyle: theme.textStyle.heading4 .standard(color: theme.textColorScheme.primary), decoration: InputDecoration( contentPadding: EdgeInsets.symmetric(vertical: 11, horizontal: 12), enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: theme.borderColorScheme.primary), borderRadius: radius, ), isDense: false, hintText: LocaleKeys.search_searchFieldHint .tr(args: ['${workspace?.name}']), hintStyle: theme.textStyle.heading4 .standard(color: theme.textColorScheme.tertiary), hintMaxLines: 1, counterText: "", focusedBorder: OutlineInputBorder( borderRadius: radius, borderSide: BorderSide(color: theme.borderColorScheme.themeThick), ), prefixIcon: Padding( padding: const EdgeInsets.only(left: 12, right: 8), child: FlowySvg( FlowySvgs.search_icon_m, color: theme.iconColorScheme.secondary, size: Size.square(20), ), ), prefixIconConstraints: BoxConstraints.loose(Size(40, 20)), suffixIconConstraints: hasText ? BoxConstraints.loose(Size(48, 28)) : null, suffixIcon: hasText ? _buildSuffixIcon(context) : null, ), onChanged: (value) => context .read() .add(CommandPaletteEvent.searchChanged(search: value)), ); }, ), ); } void _clearSearch() { controller.clear(); context .read() .add(const CommandPaletteEvent.clearSearch()); focusNode.requestFocus(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_icon.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; extension SearchIconExtension on ViewPB { Widget buildIcon(BuildContext context) { final theme = AppFlowyTheme.of(context); return icon.value.isNotEmpty ? SizedBox( width: 16, child: RawEmojiIconWidget( emoji: icon.toEmojiIconData(), emojiSize: 16, lineHeight: 20 / 16, ), ) : FlowySvg( iconData, size: const Size.square(18), color: theme.iconColorScheme.secondary, ); } } extension SearchIconItemExtension on ResultIconPB { Widget? buildIcon(BuildContext context) { final theme = AppFlowyTheme.of(context); final color = theme.iconColorScheme.secondary; if (ty == ResultIconTypePB.Emoji) { return SizedBox( width: 16, child: getIcon(size: 16, lineHeight: 20 / 16, iconColor: color) ?? SizedBox.shrink(), ); } else { return getIcon(iconColor: color) ?? SizedBox.shrink(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart ================================================ import 'package:appflowy/mobile/presentation/search/mobile_view_ancestors.dart'; import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/command_palette/navigation_bloc_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SearchRecentViewCell extends StatefulWidget { const SearchRecentViewCell({ super.key, required this.icon, required this.view, required this.onSelected, required this.isNarrowWindow, }); final Widget icon; final ViewPB view; final VoidCallback onSelected; final bool isNarrowWindow; @override State createState() => _SearchRecentViewCellState(); } class _SearchRecentViewCellState extends State { final focusNode = FocusNode(); ViewPB get view => widget.view; @override void dispose() { focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final spaceL = theme.spacing.l; final bloc = context.read(), state = bloc.state; final hoveredView = state.hoveredView, hasHovered = hoveredView != null; final hovering = hoveredView == view; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _handleSelection(view.id), child: Focus( focusNode: focusNode, onKeyEvent: (node, event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event.logicalKey == LogicalKeyboardKey.enter) { _handleSelection(view.id); return KeyEventResult.handled; } return KeyEventResult.ignored; }, onFocusChange: (hasFocus) { if (hasFocus && !hovering) { bloc.add(RecentViewsEvent.hoverView(view)); } }, child: FlowyHover( onHover: (value) { if (hoveredView == view) return; bloc.add(RecentViewsEvent.hoverView(view)); }, style: HoverStyle( borderRadius: BorderRadius.circular(8), hoverColor: theme.fillColorScheme.contentHover, foregroundColorOnHover: AFThemeExtension.of(context).textColor, ), isSelected: () => hovering, child: Padding( padding: EdgeInsets.all(spaceL), child: Row( children: [ widget.icon, HSpace(8), Container( constraints: BoxConstraints( maxWidth: (!widget.isNarrowWindow && hasHovered) ? 480.0 : 680.0, ), child: Text( view.nameOrDefault, maxLines: 1, style: theme.textStyle.body .enhanced(color: theme.textColorScheme.primary) .copyWith(height: 22 / 14), overflow: TextOverflow.ellipsis, ), ), Flexible(child: buildPath(theme)), ], ), ), ), ), ); } Widget buildPath(AppFlowyThemeData theme) { return BlocProvider( key: ValueKey(view.id), create: (context) => ViewAncestorBloc(view.id), child: BlocBuilder( builder: (context, state) { if (state.ancestor.ancestors.isEmpty) return const SizedBox.shrink(); return state.buildOnelinePath(context); }, ), ); } /// Helper to handle the selection action. void _handleSelection(String id) { widget.onSelected(); id.navigateTo(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_cell.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/search/mobile_search_cell.dart'; import 'package:appflowy/mobile/presentation/search/mobile_view_ancestors.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_icon.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'page_preview.dart'; class SearchResultCell extends StatefulWidget { const SearchResultCell({ super.key, required this.item, required this.isNarrowWindow, this.view, this.query, this.isHovered = false, }); final SearchResultItem item; final ViewPB? view; final String? query; final bool isHovered; final bool isNarrowWindow; @override State createState() => _SearchResultCellState(); } class _SearchResultCellState extends State { bool _hasFocus = false; final focusNode = FocusNode(); String get viewId => item.id; SearchResultItem get item => widget.item; @override void dispose() { focusNode.dispose(); super.dispose(); } /// Helper to handle the selection action. void _handleSelection() { context.read().add( SearchResultListEvent.openPage(pageId: viewId), ); } @override Widget build(BuildContext context) { final title = item.displayName.orDefault( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), ); final searchResultBloc = context.read(); final hasHovered = searchResultBloc.state.hoveredResult != null; final theme = AppFlowyTheme.of(context); final titleStyle = theme.textStyle.body .enhanced(color: theme.textColorScheme.primary) .copyWith(height: 22 / 14); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: _handleSelection, child: Focus( focusNode: focusNode, onKeyEvent: (node, event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event.logicalKey == LogicalKeyboardKey.enter) { _handleSelection(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, onFocusChange: (hasFocus) { setState(() { searchResultBloc.add( SearchResultListEvent.onHoverResult( item: item, userHovered: true, ), ); _hasFocus = hasFocus; }); }, child: FlowyHover( onHover: (value) { context.read().add( SearchResultListEvent.onHoverResult( item: item, userHovered: true, ), ); }, isSelected: () => _hasFocus || widget.isHovered, style: HoverStyle( borderRadius: BorderRadius.circular(8), hoverColor: theme.fillColorScheme.contentHover, foregroundColorOnHover: AFThemeExtension.of(context).textColor, ), child: Padding( padding: EdgeInsets.all(theme.spacing.l), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ SizedBox.square( dimension: 20, child: Center(child: buildIcon(theme)), ), HSpace(8), Container( constraints: BoxConstraints( maxWidth: (!widget.isNarrowWindow && hasHovered) ? 480.0 : 680.0, ), child: RichText( maxLines: 1, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, text: buildHighLightSpan( content: title, normal: titleStyle, highlight: titleStyle.copyWith( backgroundColor: theme.fillColorScheme.themeSelect, ), ), ), ), Flexible(child: buildPath(theme)), ], ), ...buildSummary(theme), ], ), ), ), ), ); } Widget buildIcon(AppFlowyThemeData theme) { final view = widget.view; if (view != null) return view.buildIcon(context); return item.icon.buildIcon(context) ?? const SizedBox.shrink(); } Widget buildPath(AppFlowyThemeData theme) { return BlocProvider( key: ValueKey(item.id), create: (context) => ViewAncestorBloc(item.id), child: BlocBuilder( builder: (context, state) { if (state.ancestor.ancestors.isEmpty) return const SizedBox.shrink(); return state.buildOnelinePath(context); }, ), ); } TextSpan buildHighLightSpan({ required String content, required TextStyle normal, required TextStyle highlight, }) { final queryText = (widget.query ?? '').trim(); if (queryText.isEmpty) { return TextSpan(text: content, style: normal); } final contents = content.splitIncludeSeparator(queryText); return TextSpan( children: List.generate(contents.length, (index) { final content = contents[index]; final isHighlight = content.toLowerCase() == queryText.toLowerCase(); return TextSpan( text: content, style: isHighlight ? highlight : normal, ); }), ); } List buildSummary(AppFlowyThemeData theme) { if (item.content.isEmpty) return []; final style = theme.textStyle.caption .standard(color: theme.textColorScheme.secondary) .copyWith(letterSpacing: 0.1); return [ VSpace(4), Padding( padding: const EdgeInsets.only(left: 28), child: RichText( maxLines: 2, overflow: TextOverflow.ellipsis, text: buildHighLightSpan( content: item.content, normal: style, highlight: style.copyWith( backgroundColor: theme.fillColorScheme.themeSelect, color: theme.textColorScheme.primary, ), ), ), ), ]; } } class SearchResultPreview extends StatelessWidget { const SearchResultPreview({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { return PagePreview( view: view, key: ValueKey(view.id), onViewOpened: () { context .read() ?.add(SearchResultListEvent.openPage(pageId: view.id)); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart'; import 'package:appflowy/workspace/presentation/command_palette/navigation_bloc_extension.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'page_preview.dart'; import 'search_result_cell.dart'; class SearchResultList extends StatefulWidget { const SearchResultList({ required this.cachedViews, required this.resultItems, required this.resultSummaries, super.key, }); final Map cachedViews; final List resultItems; final List resultSummaries; @override State createState() => _SearchResultListState(); } class _SearchResultListState extends State { late final SearchResultListBloc bloc; @override void initState() { super.initState(); bloc = SearchResultListBloc(); } @override void dispose() { bloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: bloc, child: BlocListener( listener: (context, state) { final pageId = state.openPageId; if (pageId != null && pageId.isNotEmpty) { FlowyOverlay.pop(context); pageId.navigateTo(); } }, child: BlocBuilder( builder: (context, state) { final hasHoverResult = state.hoveredResult != null; return LayoutBuilder( builder: (context, constrains) { final maxWidth = constrains.maxWidth; final hidePreview = maxWidth < 884; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: _buildResultsSection(context, hidePreview)), if (!hidePreview && hasHoverResult) const SearchCellPreview(), ], ); }, ); }, ), ), ); } Widget _buildSectionHeader(BuildContext context) { final theme = AppFlowyTheme.of(context); return Padding( padding: EdgeInsets.symmetric( vertical: theme.spacing.s, horizontal: theme.spacing.m, ), child: Text( LocaleKeys.commandPalette_bestMatches.tr(), style: theme.textStyle.body .enhanced(color: theme.textColorScheme.secondary) .copyWith( letterSpacing: 0.2, height: 22 / 16, ), ), ); } Widget _buildResultsSection(BuildContext context, bool hidePreview) { final workspaceState = context.read()?.state; final showAskingAI = workspaceState?.userProfile.workspaceType == WorkspaceTypePB.ServerW; if (widget.resultItems.isEmpty) return const SizedBox.shrink(); List resultItems = widget.resultItems; final hasCachedViews = widget.cachedViews.isNotEmpty; if (hasCachedViews) { resultItems = widget.resultItems .where((item) => widget.cachedViews[item.id] != null) .toList(); } return ScrollControllerBuilder( builder: (context, controller) { final hoveredId = bloc.state.hoveredResult?.id; return Padding( padding: EdgeInsets.only(right: hidePreview ? 0 : 6), child: FlowyScrollbar( controller: controller, thumbVisibility: false, child: SingleChildScrollView( controller: controller, physics: ClampingScrollPhysics(), child: Padding( padding: EdgeInsets.only( right: hidePreview ? 0 : 6, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showAskingAI) SearchAskAiEntrance(), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _buildSectionHeader(context), ListView.builder( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, itemCount: resultItems.length, itemBuilder: (_, index) { final item = resultItems[index]; return SearchResultCell( key: ValueKey(item.id), item: item, isNarrowWindow: hidePreview, view: widget.cachedViews[item.id], isHovered: hoveredId == item.id, query: context .read() ?.state .query, ); }, ), VSpace(16), ], ), ], ), ), ), ), ); }, ); } } class SearchCellPreview extends StatelessWidget { const SearchCellPreview({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final hoverdId = state.hoveredResult?.id ?? ''; final commandPaletteState = context.read().state; final view = commandPaletteState.cachedViews[hoverdId]; if (view != null) { return SearchResultPreview(view: view); } return SomethingWentWrong(); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_summary_cell.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/search/mobile_search_cell.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; import 'package:appflowy/workspace/presentation/command_palette/navigation_bloc_extension.dart'; import 'package:appflowy/workspace/presentation/command_palette/widgets/search_icon.dart'; import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SearchSummaryCell extends StatefulWidget { const SearchSummaryCell({ required this.summary, required this.maxWidth, required this.theme, required this.textStyle, super.key, }); final SearchSummaryPB summary; final double maxWidth; final AppFlowyThemeData theme; final TextStyle textStyle; @override State createState() => _SearchSummaryCellState(); } class _SearchSummaryCellState extends State { late TextPainter _painter; late _TextInfo _textInfo = _TextInfo.normal(summary.content); bool tappedShowMore = false; final maxLines = 5; final popoverController = PopoverController(); bool isLinkHovering = false, isReferenceHovering = false; SearchSummaryPB get summary => widget.summary; double get maxWidth => widget.maxWidth; AppFlowyThemeData get theme => widget.theme; TextStyle get textStyle => widget.textStyle; TextStyle get showMoreStyle => theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ); TextStyle get showMoreUnderlineStyle => theme.textStyle.body.underline( color: theme.textColorScheme.secondary, ); @override void initState() { super.initState(); refreshTextPainter(); } @override void didUpdateWidget(SearchSummaryCell oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.maxWidth != maxWidth) { refreshTextPainter(); } } @override void dispose() { _painter.dispose(); popoverController.close(); super.dispose(); } @override Widget build(BuildContext context) { final showReference = summary.sources.isNotEmpty; final query = summary.highlights.isEmpty ? context.read()?.state.query : summary.highlights; return _textInfo.build( context: context, normal: textStyle, more: showMoreStyle, query: query ?? '', moreUnderline: showMoreUnderlineStyle, showMore: () { setState(() { tappedShowMore = true; _textInfo = _TextInfo.normal(summary.content); }); }, normalWidgetSpan: showReference ? WidgetSpan( child: Padding( padding: const EdgeInsets.only(left: 4), child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (event) { if (!isLinkHovering) { isLinkHovering = true; showPopover(); } }, onExit: (event) { if (isLinkHovering) { isLinkHovering = false; tryToHidePopover(); } }, child: buildReferenceIcon(), ), ), ) : null, ); } Widget buildReferenceIcon() { final iconSize = Size(21, 15), placeholderHeight = iconSize.height + 10.0; return AppFlowyPopover( offset: Offset(0, -iconSize.height), constraints: BoxConstraints(maxWidth: 360, maxHeight: 420 + placeholderHeight), direction: PopoverDirection.bottomWithCenterAligned, margin: EdgeInsets.zero, controller: popoverController, decorationColor: Colors.transparent, popoverDecoration: BoxDecoration(), popupBuilder: (popoverContext) => MouseRegion( onEnter: (event) { if (!isReferenceHovering) { isReferenceHovering = true; } }, onExit: (event) { if (isReferenceHovering) { isReferenceHovering = false; tryToHidePopover(); } }, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox(height: placeholderHeight), Flexible( child: ReferenceSources( summary.sources, onClose: () { hidePopover(); if (context.mounted) FlowyOverlay.pop(context); }, ), ), ], ), ), child: Container( width: iconSize.width, height: iconSize.height, decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, borderRadius: BorderRadius.circular(6), ), child: FlowySvg( FlowySvgs.toolbar_link_m, color: theme.iconColorScheme.primary, size: Size.square(10), ), ), ); } void showPopover() { keepEditorFocusNotifier.increase(); popoverController.show(); } void hidePopover() { popoverController.close(); keepEditorFocusNotifier.decrease(); } void tryToHidePopover() { if (isLinkHovering || isReferenceHovering) return; Future.delayed(Duration(milliseconds: 500), () { if (!context.mounted) return; if (isLinkHovering || isReferenceHovering) return; hidePopover(); }); } void refreshTextPainter() { final content = summary.content, ellipsis = ' ...${LocaleKeys.search_seeMore.tr()}'; if (!tappedShowMore) { _painter = TextPainter( text: TextSpan(text: content, style: textStyle), textDirection: TextDirection.ltr, maxLines: maxLines, ellipsis: ellipsis, ); _painter.layout(maxWidth: maxWidth); if (_painter.didExceedMaxLines) { final lines = _painter.computeLineMetrics(); final lastLine = lines.last; final offset = Offset( lastLine.left + lastLine.width, lines.map((e) => e.height).reduce((a, b) => a + b), ); final range = _painter.getPositionForOffset(offset); final text = content.substring(0, range.offset); _textInfo = _TextInfo.overflow(text); } else { _textInfo = _TextInfo.normal(content); } } } } class _TextInfo { _TextInfo({required this.text, required this.isOverflow}); _TextInfo.normal(this.text) : isOverflow = false; _TextInfo.overflow(this.text) : isOverflow = true; final String text; final bool isOverflow; Widget build({ required BuildContext context, required TextStyle normal, required TextStyle more, required TextStyle moreUnderline, required VoidCallback showMore, required String query, WidgetSpan? normalWidgetSpan, }) { final theme = AppFlowyTheme.of(context); if (isOverflow) { return SelectionArea( child: Text.rich( TextSpan( children: [ _buildHighLightSpan( content: text, normal: normal, query: query, highlight: normal.copyWith( backgroundColor: theme.fillColorScheme.themeSelect, ), ), TextSpan( text: ' ...', style: normal, ), WidgetSpan( alignment: PlaceholderAlignment.middle, child: AFBaseButton( padding: EdgeInsets.zero, builder: (context, isHovering, disabled) => SelectionContainer.disabled( child: Text( LocaleKeys.search_seeMore.tr(), style: isHovering ? moreUnderline : more, ), ), borderColor: (_, __, ___, ____) => Colors.transparent, borderRadius: 0, onTap: showMore, ), ), ], ), ), ); } else { return SelectionArea( child: Text.rich( TextSpan( children: [ _buildHighLightSpan( content: text, normal: normal, query: query, highlight: normal.copyWith( backgroundColor: theme.fillColorScheme.themeSelect, ), ), if (normalWidgetSpan != null) normalWidgetSpan, ], ), ), ); } } TextSpan _buildHighLightSpan({ required String content, required TextStyle normal, required TextStyle highlight, String? query, }) { final queryText = (query ?? '').trim(); if (queryText.isEmpty) { return TextSpan(text: content, style: normal); } final contents = content.splitIncludeSeparator(queryText); return TextSpan( children: List.generate(contents.length, (index) { final content = contents[index]; final isHighlight = content.toLowerCase() == queryText.toLowerCase(); return TextSpan( text: content, style: isHighlight ? highlight : normal, ); }), ); } } class ReferenceSources extends StatelessWidget { const ReferenceSources( this.sources, { super.key, this.onClose, }); final List sources; final VoidCallback? onClose; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final bloc = context.read(), state = bloc?.state; return Container( decoration: ShapeDecoration( color: theme.surfaceColorScheme.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(theme.spacing.m), ), shadows: theme.shadow.small, ), padding: EdgeInsets.all(8), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), child: Text( LocaleKeys.commandPalette_aiOverviewSource.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.secondary, ), ), ), ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { final source = sources[index]; final view = state?.cachedViews[source.id]; final displayName = source.displayName.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : source.displayName; final spaceM = theme.spacing.m, spaceL = theme.spacing.l; return AFBaseButton( borderRadius: spaceM, onTap: () { source.id.navigateTo(); onClose?.call(); }, padding: EdgeInsets.symmetric( vertical: spaceL, horizontal: spaceM, ), backgroundColor: (context, isHovering, disable) { if (isHovering) { return theme.fillColorScheme.contentHover; } return Colors.transparent; }, borderColor: (context, isHovering, disabled, isFocused) => Colors.transparent, builder: (context, isHovering, disabled) => Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox.square( dimension: 20, child: Center( child: view?.buildIcon(context) ?? source.icon.buildIcon(context), ), ), HSpace(8), Flexible( child: Text( displayName, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), ), ], ), ); }, separatorBuilder: (context, index) => AFDivider(), itemCount: sources.length, ), ], ), ), ); } Widget buildIcon(ResultIconPB icon, AppFlowyThemeData theme) { if (icon.ty == ResultIconTypePB.Emoji) { return icon.getIcon(size: 16, lineHeight: 21 / 16) ?? SizedBox.shrink(); } else { return icon.getIcon(iconColor: theme.iconColorScheme.primary) ?? SizedBox.shrink(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/af_focus_manager.dart ================================================ import 'package:flutter/material.dart'; /// Simple ChangeNotifier that can be listened to, notifies the /// application on events that should trigger focus loss. /// /// Eg. lose focus in AppFlowyEditor /// abstract class ShouldLoseFocus with ChangeNotifier {} /// Private implementation to allow the [AFFocusManager] to /// call [notifyListeners] without being directly invokable. /// class _ShouldLoseFocusImpl extends ShouldLoseFocus { void notify() => notifyListeners(); } class AFFocusManager extends InheritedWidget { AFFocusManager({super.key, required super.child}); final ShouldLoseFocus loseFocusNotifier = _ShouldLoseFocusImpl(); void notifyLoseFocus() { (loseFocusNotifier as _ShouldLoseFocusImpl).notify(); } @override bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; static AFFocusManager of(BuildContext context) { final AFFocusManager? result = context.dependOnInheritedWidgetOfExactType(); assert(result != null, "AFFocusManager could not be found"); return result!; } static AFFocusManager? maybeOf(BuildContext context) { return context.dependOnInheritedWidgetOfExactType(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart ================================================ import 'package:appflowy/features/workspace/data/repositories/rust_workspace_repository_impl.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/memory_leak_detector.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/home/home_bloc.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart'; import 'package:appflowy/workspace/presentation/widgets/edit_panel/panel_animation.dart'; import 'package:appflowy/workspace/presentation/widgets/float_bubble/question_bubble.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/style_widget/container.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sized_context/sized_context.dart'; import 'package:styled_widget/styled_widget.dart'; import '../notifications/notification_panel.dart'; import '../widgets/edit_panel/edit_panel.dart'; import '../widgets/sidebar_resizer.dart'; import 'home_layout.dart'; import 'home_stack.dart'; import 'menu/sidebar/slider_menu_hover_trigger.dart'; class DesktopHomeScreen extends StatelessWidget { const DesktopHomeScreen({super.key}); static const routeName = '/DesktopHomeScreen'; @override Widget build(BuildContext context) { return FutureBuilder( future: Future.wait([ FolderEventGetCurrentWorkspaceSetting().send(), getIt().getUser(), ]), builder: (context, snapshots) { if (!snapshots.hasData) { return _buildLoading(); } final workspaceLatest = snapshots.data?[0].fold( (workspaceLatestPB) => workspaceLatestPB as WorkspaceLatestPB, (error) => null, ); final userProfile = snapshots.data?[1].fold( (userProfilePB) => userProfilePB as UserProfilePB, (error) => null, ); // In the unlikely case either of the above is null, eg. // when a workspace is already open this can happen. if (workspaceLatest == null || userProfile == null) { return const WorkspaceFailedScreen(); } return AFFocusManager( child: MultiBlocProvider( key: ValueKey(userProfile.id), providers: [ BlocProvider.value( value: getIt(), ), BlocProvider.value(value: getIt()), BlocProvider( create: (_) => HomeBloc(workspaceLatest)..add(const HomeEvent.initial()), ), BlocProvider( create: (_) => HomeSettingBloc( workspaceLatest, context.read(), context.widthPx, )..add(const HomeSettingEvent.initial()), ), BlocProvider( create: (context) => FavoriteBloc()..add(const FavoriteEvent.initial()), ), ], child: Scaffold( floatingActionButton: enableMemoryLeakDetect ? const FloatingActionButton( onPressed: dumpMemoryLeak, child: Icon(Icons.memory), ) : null, body: BlocListener( listenWhen: (p, c) => p.latestView != c.latestView, listener: (context, state) { final view = state.latestView; if (view != null) { // Only open the last opened view if the [TabsState.currentPageManager] current opened plugin is blank and the last opened view is not null. // All opened widgets that display on the home screen are in the form of plugins. There is a list of built-in plugins defined in the [PluginType] enum, including board, grid and trash. final currentPageManager = context.read().state.currentPageManager; if (currentPageManager.plugin.pluginType == PluginType.blank) { getIt().add( TabsEvent.openPlugin(plugin: view.plugin()), ); } // switch to the space that contains the last opened view _switchToSpace(view); } }, child: BlocBuilder( buildWhen: (previous, current) => previous != current, builder: (context, state) => BlocProvider( create: (_) => UserWorkspaceBloc( userProfile: userProfile, repository: RustWorkspaceRepositoryImpl( userId: userProfile.id, ), )..add(UserWorkspaceEvent.initialize()), child: BlocListener( listenWhen: (previous, current) => previous.currentWorkspace != current.currentWorkspace, listener: (context, state) { if (!context.mounted) return; final workspaceBloc = context.read(); final spaceBloc = context.read(); CommandPalette.maybeOf(context)?.updateBlocs( workspaceBloc: workspaceBloc, spaceBloc: spaceBloc, ); }, child: HomeHotKeys( userProfile: userProfile, child: FlowyContainer( Theme.of(context).colorScheme.surface, child: _buildBody( context, userProfile, workspaceLatest, ), ), ), ), ), ), ), ), ), ); }, ); } Widget _buildLoading() => const Center(child: CircularProgressIndicator.adaptive()); Widget _buildBody( BuildContext context, UserProfilePB userProfile, WorkspaceLatestPB workspaceSetting, ) { final layout = HomeLayout(context); final homeStack = HomeStack( layout: layout, delegate: DesktopHomeScreenStackAdaptor(context), userProfile: userProfile, ); final sidebar = _buildHomeSidebar( context, layout: layout, userProfile: userProfile, workspaceSetting: workspaceSetting, ); final notificationPanel = NotificationPanel(); final sliderHoverTrigger = SliderMenuHoverTrigger(); final homeMenuResizer = layout.showMenu ? const SidebarResizer() : const SizedBox.shrink(); final editPanel = _buildEditPanel(context, layout: layout); return _layoutWidgets( layout: layout, homeStack: homeStack, sidebar: sidebar, editPanel: editPanel, bubble: const QuestionBubble(), homeMenuResizer: homeMenuResizer, notificationPanel: notificationPanel, sliderHoverTrigger: sliderHoverTrigger, ); } Widget _buildHomeSidebar( BuildContext context, { required HomeLayout layout, required UserProfilePB userProfile, required WorkspaceLatestPB workspaceSetting, }) { final homeMenu = HomeSideBar( userProfile: userProfile, workspaceSetting: workspaceSetting, ); return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu)); } Widget _buildEditPanel( BuildContext context, { required HomeLayout layout, }) { final homeBloc = context.read(); return BlocBuilder( buildWhen: (previous, current) => previous.panelContext != current.panelContext, builder: (context, state) { final panelContext = state.panelContext; if (panelContext == null) { return const SizedBox.shrink(); } return FocusTraversalGroup( child: RepaintBoundary( child: EditPanel( panelContext: panelContext, onEndEdit: () => homeBloc.add( const HomeSettingEvent.dismissEditPanel(), ), ), ), ); }, ); } Widget _layoutWidgets({ required HomeLayout layout, required Widget sidebar, required Widget homeStack, required Widget editPanel, required Widget bubble, required Widget homeMenuResizer, required Widget notificationPanel, required Widget sliderHoverTrigger, }) { final isSliderbarShowing = layout.showMenu; return Stack( children: [ homeStack .constrained(minWidth: 500) .positioned( left: layout.homePageLOffset, right: layout.homePageROffset, bottom: 0, top: 0, animate: true, ) .animate(layout.animDuration, Curves.easeOutQuad), bubble .positioned(right: 20, bottom: 16, animate: true) .animate(layout.animDuration, Curves.easeOut), editPanel .animatedPanelX( duration: layout.animDuration.inMilliseconds * 0.001, closeX: layout.editPanelWidth, isClosed: !layout.showEditPanel, curve: Curves.easeOutQuad, ) .positioned( top: 0, right: 0, bottom: 0, width: layout.editPanelWidth, ), notificationPanel .animatedPanelX( closeX: -layout.notificationPanelWidth, isClosed: !layout.showNotificationPanel, curve: Curves.easeOutQuad, duration: layout.animDuration.inMilliseconds * 0.001, ) .positioned( left: isSliderbarShowing ? layout.menuWidth : 0, top: isSliderbarShowing ? 0 : 52, width: layout.notificationPanelWidth, bottom: 0, ), sidebar .animatedPanelX( closeX: -layout.menuWidth, isClosed: !isSliderbarShowing, curve: Curves.easeOutQuad, duration: layout.animDuration.inMilliseconds * 0.001, ) .positioned(left: 0, top: 0, width: layout.menuWidth, bottom: 0), homeMenuResizer .positioned(left: layout.menuWidth) .animate(layout.animDuration, Curves.easeOutQuad), ], ); } Future _switchToSpace(ViewPB view) async { final ancestors = await ViewBackendService.getViewAncestors(view.id); final space = ancestors.fold( (ancestors) => ancestors.items.firstWhereOrNull((ancestor) => ancestor.isSpace), (error) => null, ); if (space?.id != switchToSpaceNotifier.value?.id) { switchToSpaceNotifier.value = space; } } } class DesktopHomeScreenStackAdaptor extends HomeStackDelegate { DesktopHomeScreenStackAdaptor(this.buildContext); final BuildContext buildContext; @override void didDeleteStackWidget(ViewPB view, int? index) { ViewBackendService.getView(view.parentViewId).then( (result) => result.fold( (parentView) { final List views = parentView.childViews; if (views.isNotEmpty) { ViewPB lastView = views.last; if (index != null && index != 0 && views.length > index - 1) { lastView = views[index - 1]; } return getIt() .add(TabsEvent.openPlugin(plugin: lastView.plugin())); } getIt() .add(TabsEvent.openPlugin(plugin: BlankPagePlugin())); }, (err) => Log.error(err), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/errors/workspace_failed_screen.dart ================================================ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:package_info_plus/package_info_plus.dart'; class WorkspaceFailedScreen extends StatefulWidget { const WorkspaceFailedScreen({super.key}); @override State createState() => _WorkspaceFailedScreenState(); } class _WorkspaceFailedScreenState extends State { String version = ''; final String os = Platform.operatingSystem; @override void initState() { super.initState(); initVersion(); } Future initVersion() async { final platformInfo = await PackageInfo.fromPlatform(); setState(() { version = platformInfo.version; }); } @override Widget build(BuildContext context) { return Material( child: Scaffold( body: Center( child: SizedBox( width: 400, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(LocaleKeys.workspace_failedToLoad.tr()), const VSpace(20), Row( children: [ Flexible( child: RoundedTextButton( title: LocaleKeys.workspace_errorActions_reportIssue.tr(), height: 40, onPressed: () => afLaunchUrlString( 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Workspace%20failed%20to%20load&version=$version&os=$os', ), ), ), const HSpace(20), Flexible( child: RoundedTextButton( title: LocaleKeys.workspace_errorActions_reachOut.tr(), height: 40, onPressed: () => afLaunchUrlString('https://discord.gg/JucBXeU2FE'), ), ), ], ), ], ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart ================================================ import 'dart:io' show Platform; import 'dart:math'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; // ignore: import_of_legacy_library_into_null_safe import 'package:sized_context/sized_context.dart'; import 'home_sizes.dart'; class HomeLayout { HomeLayout(BuildContext context) { final homeSetting = context.read().state; showEditPanel = homeSetting.panelContext != null; menuWidth = max( HomeSizes.minimumSidebarWidth + homeSetting.resizeOffset, HomeSizes.minimumSidebarWidth, ); final screenWidthPx = context.widthPx; context .read() .add(HomeSettingEvent.checkScreenSize(screenWidthPx)); showMenu = homeSetting.menuStatus == MenuStatus.expanded; if (showMenu) { menuIsDrawer = context.widthPx <= PageBreaks.tabletPortrait; } showNotificationPanel = !homeSetting.isNotificationPanelCollapsed; homePageLOffset = (showMenu && !menuIsDrawer) ? menuWidth : 0.0; menuSpacing = !showMenu && Platform.isMacOS ? 80.0 : 0.0; animDuration = homeSetting.resizeType.duration(); editPanelWidth = HomeSizes.editPanelWidth; notificationPanelWidth = MediaQuery.of(context).size.width - (showEditPanel ? editPanelWidth : 0); homePageROffset = showEditPanel ? editPanelWidth : 0; } late bool showEditPanel; late double menuWidth; late bool showMenu; late bool menuIsDrawer; late bool showNotificationPanel; late double homePageLOffset; late double menuSpacing; late Duration animDuration; late double editPanelWidth; late double notificationPanelWidth; late double homePageROffset; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart ================================================ class HomeSizes { static const double menuAddButtonHeight = 60; static const double topBarHeight = 44; static const double editPanelTopBarHeight = 60; static const double editPanelWidth = 400; static const double notificationPanelWidth = 380; static const double tabBarHeight = 40; static const double tabBarWidth = 200; static const double workspaceSectionHeight = 32; static const double searchSectionHeight = 30; static const double newPageSectionHeight = 30; static const double minimumSidebarWidth = 268; } class HomeInsets { static const double topBarTitleHorizontalPadding = 12; static const double topBarTitleVerticalPadding = 12; } class HomeSpaceViewSizes { static const double leftPadding = 16.0; static const double viewHeight = 30.0; // mobile, m represents mobile static const double mViewHeight = 48.0; static const double mViewButtonDimension = 34.0; static const double mHorizontalPadding = 20.0; static const double mVerticalPadding = 12.0; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart ================================================ import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/shared/window_title_bar.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/navigation.dart'; import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:time/time.dart'; import 'package:universal_platform/universal_platform.dart'; import 'package:window_manager/window_manager.dart'; import 'home_layout.dart'; typedef NavigationCallback = void Function(String id); abstract class HomeStackDelegate { void didDeleteStackWidget(ViewPB view, int? index); } class HomeStack extends StatefulWidget { const HomeStack({ super.key, required this.delegate, required this.layout, required this.userProfile, }); final HomeStackDelegate delegate; final HomeLayout layout; final UserProfilePB userProfile; @override State createState() => _HomeStackState(); } class _HomeStackState extends State with WindowListener { int selectedIndex = 0; @override Widget build(BuildContext context) { return BlocProvider.value( value: getIt(), child: BlocBuilder( builder: (context, state) => Column( children: [ if (UniversalPlatform.isWindows) WindowTitleBar(leftChildren: [_buildToggleMenuButton(context)]), Padding( padding: EdgeInsets.only(left: widget.layout.menuSpacing), child: TabsManager( onIndexChanged: (index) { if (selectedIndex != index) { // Unfocus editor to hide selection toolbar FocusScope.of(context).unfocus(); context.read().add(TabsEvent.selectTab(index)); setState(() => selectedIndex = index); } }, ), ), Expanded( child: IndexedStack( index: selectedIndex, children: state.pageManagers .map( (pm) => LayoutBuilder( builder: (context, constraints) { return Row( children: [ Expanded( child: Column( children: [ pm.stackTopBar(layout: widget.layout), Expanded( child: PageStack( pageManager: pm, delegate: widget.delegate, userProfile: widget.userProfile, ), ), ], ), ), SecondaryView( pageManager: pm, adaptedPercentageWidth: constraints.maxWidth * 3 / 7, ), ], ); }, ), ) .toList(), ), ), ], ), ), ); } Widget _buildToggleMenuButton(BuildContext context) { if (context.read().isMenuExpanded) { return const SizedBox.shrink(); } final textSpan = TextSpan( children: [ TextSpan( text: '${LocaleKeys.sideBar_openSidebar.tr()}\n', style: context.tooltipTextStyle(), ), TextSpan( text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', style: context .tooltipTextStyle() ?.copyWith(color: Theme.of(context).hintColor), ), ], ); return FlowyTooltip( richMessage: textSpan, child: Listener( behavior: HitTestBehavior.translucent, onPointerDown: (_) { final isMenuExpanded = context.read().isMenuExpanded; final status = isMenuExpanded ? MenuStatus.hidden : MenuStatus.expanded; context .read() .add(HomeSettingEvent.changeMenuStatus(status)); }, child: FlowyHover( child: Container( width: 24, padding: const EdgeInsets.all(4), child: const RotatedBox( quarterTurns: 2, child: FlowySvg(FlowySvgs.hide_menu_s), ), ), ), ), ); } @override void onWindowFocus() { // https://pub.dev/packages/window_manager#windows // must call setState once when the window is focused setState(() {}); } } class PageStack extends StatefulWidget { const PageStack({ super.key, required this.pageManager, required this.delegate, required this.userProfile, }); final PageManager pageManager; final HomeStackDelegate delegate; final UserProfilePB userProfile; @override State createState() => _PageStackState(); } class _PageStackState extends State with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { super.build(context); return Container( color: Theme.of(context).colorScheme.surface, child: FocusTraversalGroup( child: widget.pageManager.stackWidget( userProfile: widget.userProfile, onDeleted: (view, index) { widget.delegate.didDeleteStackWidget(view, index); }, ), ), ); } @override bool get wantKeepAlive => true; } class SecondaryView extends StatefulWidget { const SecondaryView({ super.key, required this.pageManager, required this.adaptedPercentageWidth, }); final PageManager pageManager; final double adaptedPercentageWidth; @override State createState() => _SecondaryViewState(); } class _SecondaryViewState extends State with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { final overlayController = OverlayPortalController(); final layerLink = LayerLink(); late final ValueNotifier widthNotifier; late final AnimationController animationController; late Animation widthAnimation; late final Animation offsetAnimation; late bool hasSecondaryView; CurvedAnimation get curveAnimation => CurvedAnimation( parent: animationController, curve: Curves.easeOut, ); @override void initState() { super.initState(); widget.pageManager.showSecondaryPluginNotifier .addListener(onShowSecondaryChanged); final width = widget.pageManager.showSecondaryPluginNotifier.value ? max(450.0, widget.adaptedPercentageWidth) : 0.0; widthNotifier = ValueNotifier(width) ..addListener(updateWidthAnimation); animationController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); widthAnimation = Tween( begin: 0.0, end: width, ).animate(curveAnimation); offsetAnimation = Tween( begin: const Offset(1.0, 0.0), end: Offset.zero, ).animate(curveAnimation); widget.pageManager.secondaryNotifier.addListener(onSecondaryViewChanged); onSecondaryViewChanged(); overlayController.show(); } @override void dispose() { widget.pageManager.showSecondaryPluginNotifier .removeListener(onShowSecondaryChanged); widget.pageManager.secondaryNotifier.removeListener(onSecondaryViewChanged); widthNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { super.build(context); final isLightMode = Theme.of(context).isLightMode; return OverlayPortal( controller: overlayController, overlayChildBuilder: (context) { return ValueListenableBuilder( valueListenable: widget.pageManager.showSecondaryPluginNotifier, builder: (context, isShowing, child) { return CompositedTransformFollower( link: layerLink, followerAnchor: Alignment.topRight, offset: const Offset(0.0, 120.0), child: Align( alignment: AlignmentDirectional.topEnd, child: AnimatedSwitcher( duration: 150.milliseconds, transitionBuilder: (child, animation) { return NonClippingSizeTransition( sizeFactor: animation, axis: Axis.horizontal, axisAlignment: -1, child: child, ); }, child: isShowing || !hasSecondaryView ? const SizedBox.shrink() : GestureDetector( onTap: () => widget.pageManager .showSecondaryPluginNotifier.value = true, child: Container( height: 36, width: 36, decoration: BoxDecoration( borderRadius: getBorderRadius(), color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( offset: const Offset(0, 4), blurRadius: 20, color: isLightMode ? const Color(0x1F1F2329) : Theme.of(context) .shadowColor .withValues(alpha: 0.08), ), ], ), child: FlowyHover( style: HoverStyle( borderRadius: getBorderRadius(), border: getBorder(context), ), child: const Center( child: FlowySvg( FlowySvgs.rename_s, size: Size.square(16.0), ), ), ), ), ), ), ), ); }, ); }, child: CompositedTransformTarget( link: layerLink, child: Container( color: Theme.of(context).colorScheme.surface, child: FocusTraversalGroup( child: ValueListenableBuilder( valueListenable: widthNotifier, builder: (context, value, child) { return AnimatedBuilder( animation: Listenable.merge([ widthAnimation, offsetAnimation, ]), builder: (context, child) { return Container( width: widthAnimation.value, alignment: Alignment( offsetAnimation.value.dx, offsetAnimation.value.dy, ), child: OverflowBox( alignment: AlignmentDirectional.centerStart, maxWidth: value, child: SecondaryViewResizer( pageManager: widget.pageManager, notifier: widthNotifier, child: Column( children: [ widget.pageManager.stackSecondaryTopBar(value), Expanded( child: widget.pageManager .stackSecondaryWidget(value), ), ], ), ), ), ); }, ); }, ), ), ), ), ); } BoxBorder getBorder(BuildContext context) { final isLightMode = Theme.of(context).isLightMode; final borderSide = BorderSide( color: isLightMode ? const Color(0x141F2329) : Theme.of(context).dividerColor, ); return Border( left: borderSide, top: borderSide, bottom: borderSide, ); } BorderRadius getBorderRadius() { return const BorderRadius.only( topLeft: Radius.circular(12.0), bottomLeft: Radius.circular(12.0), ); } void onSecondaryViewChanged() { hasSecondaryView = widget.pageManager.secondaryNotifier.plugin.pluginType != PluginType.blank; } void onShowSecondaryChanged() async { if (widget.pageManager.showSecondaryPluginNotifier.value) { widthNotifier.value = max(450.0, widget.adaptedPercentageWidth); updateWidthAnimation(); await animationController.forward(); } else { updateWidthAnimation(); await animationController.reverse(); setState(() => widthNotifier.value = 0.0); } } void updateWidthAnimation() { widthAnimation = Tween( begin: 0.0, end: widthNotifier.value, ).animate(curveAnimation); } @override bool get wantKeepAlive => true; } class SecondaryViewResizer extends StatefulWidget { const SecondaryViewResizer({ super.key, required this.pageManager, required this.notifier, required this.child, }); final PageManager pageManager; final ValueNotifier notifier; final Widget child; @override State createState() => _SecondaryViewResizerState(); } class _SecondaryViewResizerState extends State { final overlayController = OverlayPortalController(); final layerLink = LayerLink(); bool isHover = false; bool isDragging = false; Timer? showHoverTimer; @override void initState() { super.initState(); overlayController.show(); } @override Widget build(BuildContext context) { return OverlayPortal( controller: overlayController, overlayChildBuilder: (context) { return CompositedTransformFollower( showWhenUnlinked: false, link: layerLink, targetAnchor: Alignment.center, followerAnchor: Alignment.center, child: Center( child: MouseRegion( cursor: SystemMouseCursors.resizeLeftRight, onEnter: (_) { showHoverTimer = Timer(const Duration(milliseconds: 500), () { setState(() => isHover = true); }); }, onExit: (_) { showHoverTimer?.cancel(); setState(() => isHover = false); }, child: GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragStart: (_) => setState(() => isDragging = true), onHorizontalDragUpdate: (details) { final newWidth = MediaQuery.sizeOf(context).width - details.globalPosition.dx; if (newWidth >= 450.0) { widget.notifier.value = newWidth; } }, onHorizontalDragEnd: (_) => setState(() => isDragging = false), child: TweenAnimationBuilder( tween: ColorTween( end: isHover || isDragging ? Theme.of(context).colorScheme.primary : Colors.transparent, ), duration: const Duration(milliseconds: 100), builder: (context, color, child) { return SizedBox( width: 11, child: Center( child: Container( color: color, width: 2, ), ), ); }, ), ), ), ), ); }, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ CompositedTransformTarget( link: layerLink, child: Container( width: 1, color: Theme.of(context).dividerColor, ), ), Flexible(child: widget.child), ], ), ); } } class FadingIndexedStack extends StatefulWidget { const FadingIndexedStack({ super.key, required this.index, required this.children, this.duration = const Duration(milliseconds: 250), }); final int index; final List children; final Duration duration; @override FadingIndexedStackState createState() => FadingIndexedStackState(); } class FadingIndexedStackState extends State { double _targetOpacity = 1; @override void initState() { super.initState(); initToastWithContext(context); } @override void didUpdateWidget(FadingIndexedStack oldWidget) { if (oldWidget.index == widget.index) return; _targetOpacity = 0; SchedulerBinding.instance.addPostFrameCallback( (_) => setState(() => _targetOpacity = 1), ); super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { return TweenAnimationBuilder( duration: _targetOpacity > 0 ? widget.duration : 0.milliseconds, tween: Tween(begin: 0, end: _targetOpacity), builder: (_, value, child) => Opacity(opacity: value, child: child), child: IndexedStack(index: widget.index, children: widget.children), ); } } abstract mixin class NavigationItem { String? get viewName; Widget get leftBarItem; Widget? get rightBarItem => null; Widget tabBarItem(String pluginId, [bool shortForm = false]); NavigationCallback get action => (id) => throw UnimplementedError(); } class PageNotifier extends ChangeNotifier { PageNotifier({Plugin? plugin}) : _plugin = plugin ?? makePlugin(pluginType: PluginType.blank); Plugin _plugin; Widget get titleWidget => _plugin.widgetBuilder.leftBarItem; Widget tabBarWidget( String pluginId, [ bool shortForm = false, ]) => _plugin.widgetBuilder.tabBarItem(pluginId, shortForm); void setPlugin( Plugin newPlugin, { required bool setLatest, bool disposeExisting = true, }) { if (newPlugin.id != plugin.id && disposeExisting) { _plugin.dispose(); } // Set the plugin view as the latest view. if (setLatest && newPlugin.id.isNotEmpty) { FolderEventSetLatestView(ViewIdPB(value: newPlugin.id)).send(); } _plugin = newPlugin; notifyListeners(); } Plugin get plugin => _plugin; } // PageManager manages the view for one Tab class PageManager { PageManager(); final PageNotifier _notifier = PageNotifier(); final PageNotifier _secondaryNotifier = PageNotifier(); PageNotifier get notifier => _notifier; PageNotifier get secondaryNotifier => _secondaryNotifier; bool isPinned = false; final showSecondaryPluginNotifier = ValueNotifier(false); Plugin get plugin => _notifier.plugin; void setPlugin(Plugin newPlugin, bool setLatest, [bool init = true]) { if (init) { newPlugin.init(); } _notifier.setPlugin(newPlugin, setLatest: setLatest); } void setSecondaryPlugin(Plugin newPlugin) { newPlugin.init(); _secondaryNotifier.setPlugin(newPlugin, setLatest: false); } void expandSecondaryPlugin() { _notifier.setPlugin(_secondaryNotifier.plugin, setLatest: true); _secondaryNotifier.setPlugin( BlankPagePlugin(), setLatest: false, disposeExisting: false, ); } void showSecondaryPlugin() { showSecondaryPluginNotifier.value = true; } void hideSecondaryPlugin() { showSecondaryPluginNotifier.value = false; } Widget stackTopBar({required HomeLayout layout}) { return ChangeNotifierProvider.value( value: _notifier, child: Selector( selector: (context, notifier) => notifier.titleWidget, builder: (_, __, child) => MoveWindowDetector( child: HomeTopBar(layout: layout), ), ), ); } Widget stackWidget({ required UserProfilePB userProfile, required Function(ViewPB, int?) onDeleted, }) { return ChangeNotifierProvider.value( value: _notifier, child: Consumer( builder: (_, notifier, __) { if (notifier.plugin.pluginType == PluginType.blank) { return const BlankPage(); } return FadingIndexedStack( index: getIt().indexOf(notifier.plugin.pluginType), children: getIt().supportPluginTypes.map( (pluginType) { if (pluginType == notifier.plugin.pluginType) { final builder = notifier.plugin.widgetBuilder; final pluginWidget = builder.buildWidget( context: PluginContext( onDeleted: onDeleted, userProfile: userProfile, ), shrinkWrap: false, ); return Padding( padding: builder.contentPadding, child: pluginWidget, ); } return const BlankPage(); }, ).toList(), ); }, ), ); } Widget stackSecondaryWidget(double width) { return ValueListenableBuilder( valueListenable: showSecondaryPluginNotifier, builder: (context, value, child) { if (width == 0.0) { return const SizedBox.shrink(); } return child!; }, child: ChangeNotifierProvider.value( value: _secondaryNotifier, child: Selector( selector: (context, notifier) => notifier.plugin.widgetBuilder, builder: (_, widgetBuilder, __) { return widgetBuilder.buildWidget( context: PluginContext(), shrinkWrap: false, ); }, ), ), ); } Widget stackSecondaryTopBar(double width) { return ValueListenableBuilder( valueListenable: showSecondaryPluginNotifier, builder: (context, value, child) { if (width == 0.0) { return const SizedBox.shrink(); } return child!; }, child: ChangeNotifierProvider.value( value: _secondaryNotifier, child: Selector( selector: (context, notifier) => notifier.plugin.widgetBuilder, builder: (_, widgetBuilder, __) { return const MoveWindowDetector( child: HomeSecondaryTopBar(), ); }, ), ), ); } void dispose() { _notifier.dispose(); _secondaryNotifier.dispose(); showSecondaryPluginNotifier.dispose(); } } class HomeTopBar extends StatefulWidget { const HomeTopBar({super.key, required this.layout}); final HomeLayout layout; @override State createState() => _HomeTopBarState(); } class _HomeTopBarState extends State with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { super.build(context); return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, ), height: HomeSizes.topBarHeight + HomeInsets.topBarTitleVerticalPadding, child: Padding( padding: const EdgeInsets.symmetric( horizontal: HomeInsets.topBarTitleHorizontalPadding, vertical: HomeInsets.topBarTitleVerticalPadding, ), child: Row( children: [ HSpace(widget.layout.menuSpacing), const FlowyNavigation(), const HSpace(16), ChangeNotifierProvider.value( value: Provider.of(context, listen: false), child: Consumer( builder: (_, PageNotifier notifier, __) => notifier.plugin.widgetBuilder.rightBarItem ?? const SizedBox.shrink(), ), ), ], ), ), ); } @override bool get wantKeepAlive => true; } class HomeSecondaryTopBar extends StatelessWidget { const HomeSecondaryTopBar({super.key}); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, ), height: HomeSizes.topBarHeight + HomeInsets.topBarTitleVerticalPadding, child: Padding( padding: const EdgeInsets.symmetric( horizontal: HomeInsets.topBarTitleHorizontalPadding, vertical: HomeInsets.topBarTitleVerticalPadding, ), child: Row( children: [ FlowyIconButton( width: 24, tooltipText: LocaleKeys.sideBar_closeSidebar.tr(), radius: const BorderRadius.all(Radius.circular(8.0)), icon: const FlowySvg( FlowySvgs.show_menu_s, size: Size.square(16), ), onPressed: () { getIt().add(const TabsEvent.closeSecondaryPlugin()); }, ), const HSpace(8.0), FlowyIconButton( width: 24, tooltipText: LocaleKeys.sideBar_expandSidebar.tr(), radius: const BorderRadius.all(Radius.circular(8.0)), icon: const FlowySvg( FlowySvgs.full_view_s, size: Size.square(16), ), onPressed: () { getIt().add(const TabsEvent.expandSecondaryPlugin()); }, ), Expanded( child: Align( alignment: AlignmentDirectional.centerEnd, child: ChangeNotifierProvider.value( value: Provider.of(context, listen: false), child: Consumer( builder: (_, PageNotifier notifier, __) => notifier.plugin.widgetBuilder.rightBarItem ?? const SizedBox.shrink(), ), ), ), ), ], ), ), ); } } /// A version of Flutter's built in SizeTransition widget that clips the child /// more sparingly than the original. class NonClippingSizeTransition extends AnimatedWidget { const NonClippingSizeTransition({ super.key, this.axis = Axis.vertical, required Animation sizeFactor, this.axisAlignment = 0.0, this.fixedCrossAxisSizeFactor, this.child, }) : assert( fixedCrossAxisSizeFactor == null || fixedCrossAxisSizeFactor >= 0.0, ), super(listenable: sizeFactor); /// [Axis.horizontal] if [sizeFactor] modifies the width, otherwise /// [Axis.vertical]. final Axis axis; /// The animation that controls the (clipped) size of the child. /// /// The width or height (depending on the [axis] value) of this widget will be /// its intrinsic width or height multiplied by [sizeFactor]'s value at the /// current point in the animation. /// /// If the value of [sizeFactor] is less than one, the child will be clipped /// in the appropriate axis. Animation get sizeFactor => listenable as Animation; /// Describes how to align the child along the axis that [sizeFactor] is /// modifying. /// /// A value of -1.0 indicates the top when [axis] is [Axis.vertical], and the /// start when [axis] is [Axis.horizontal]. The start is on the left when the /// text direction in effect is [TextDirection.ltr] and on the right when it /// is [TextDirection.rtl]. /// /// A value of 1.0 indicates the bottom or end, depending upon the [axis]. /// /// A value of 0.0 (the default) indicates the center for either [axis] value. final double axisAlignment; /// The factor by which to multiply the cross axis size of the child. /// /// If the value of [fixedCrossAxisSizeFactor] is less than one, the child /// will be clipped along the appropriate axis. /// /// If `null` (the default), the cross axis size is as large as the parent. final double? fixedCrossAxisSizeFactor; /// The widget below this widget in the tree. /// /// {@macro flutter.widgets.ProxyWidget.child} final Widget? child; @override Widget build(BuildContext context) { final AlignmentDirectional alignment; final Edge edge; if (axis == Axis.vertical) { alignment = AlignmentDirectional(-1.0, axisAlignment); edge = switch (axisAlignment) { -1.0 => Edge.bottom, _ => Edge.top }; } else { alignment = AlignmentDirectional(axisAlignment, -1.0); edge = switch (axisAlignment) { -1.0 => Edge.right, _ => Edge.left }; } return ClipRect( clipper: EdgeRectClipper(edge: edge, margin: 20), child: Align( alignment: alignment, heightFactor: axis == Axis.vertical ? max(sizeFactor.value, 0.0) : fixedCrossAxisSizeFactor, widthFactor: axis == Axis.horizontal ? max(sizeFactor.value, 0.0) : fixedCrossAxisSizeFactor, child: child, ), ); } } class EdgeRectClipper extends CustomClipper { const EdgeRectClipper({ required this.edge, required this.margin, }); final Edge edge; final double margin; @override Rect getClip(Size size) { return switch (edge) { Edge.left => Rect.fromLTRB(0.0, -margin, size.width + margin, size.height + margin), Edge.right => Rect.fromLTRB(-margin, -margin, size.width, size.height + margin), Edge.top => Rect.fromLTRB(-margin, 0.0, size.width + margin, size.height + margin), Edge.bottom => Rect.fromLTRB(-margin, -margin, size.width, size.height), }; } @override bool shouldReclip(covariant CustomClipper oldClipper) => false; } enum Edge { left, top, right, bottom; bool get isHorizontal => switch (this) { left || right => true, _ => false, }; bool get isVertical => !isHorizontal; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart ================================================ import 'dart:io'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_window_size_manager.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flutter/material.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:provider/provider.dart'; import 'package:scaled_app/scaled_app.dart'; typedef KeyDownHandler = void Function(HotKey hotKey); ValueNotifier switchToTheNextSpace = ValueNotifier(0); ValueNotifier createNewPageNotifier = ValueNotifier(0); ValueNotifier switchToSpaceNotifier = ValueNotifier(null); @visibleForTesting final zoomInKeyCodes = [KeyCode.equal, KeyCode.numpadAdd, KeyCode.add]; @visibleForTesting final zoomOutKeyCodes = [KeyCode.minus, KeyCode.numpadSubtract]; @visibleForTesting final resetZoomKeyCodes = [KeyCode.digit0, KeyCode.numpad0]; // Use a global value to store the zoom level and update it in the hotkeys. @visibleForTesting double appflowyScaleFactor = 1.0; /// Helper class that utilizes the global [HotKeyManager] to easily /// add a [HotKey] with different handlers. /// /// Makes registration of a [HotKey] simple and easy to read, and makes /// sure the [KeyDownHandler], and other handlers, are grouped with the /// relevant [HotKey]. /// class HotKeyItem { HotKeyItem({ required this.hotKey, this.keyDownHandler, }); final HotKey hotKey; final KeyDownHandler? keyDownHandler; void register() => hotKeyManager.register(hotKey, keyDownHandler: keyDownHandler); } class HomeHotKeys extends StatefulWidget { const HomeHotKeys({ super.key, required this.userProfile, required this.child, }); final UserProfilePB userProfile; final Widget child; @override State createState() => _HomeHotKeysState(); } class _HomeHotKeysState extends State { final windowSizeManager = WindowSizeManager(); late final items = [ // Collapse sidebar menu (using slash) HotKeyItem( hotKey: HotKey( KeyCode.backslash, modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => colappsedMenus(context), ), // Collapse sidebar menu (using .) HotKeyItem( hotKey: HotKey( KeyCode.period, modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => colappsedMenus(context), ), // Toggle theme mode light/dark HotKeyItem( hotKey: HotKey( KeyCode.keyL, modifiers: [ Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, KeyModifier.shift, ], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => context.read().toggleThemeMode(), ), // Close current tab HotKeyItem( hotKey: HotKey( KeyCode.keyW, modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => context.read().add(const TabsEvent.closeCurrentTab()), ), // Go to previous tab HotKeyItem( hotKey: HotKey( KeyCode.pageUp, modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => _selectTab(context, -1), ), // Go to next tab HotKeyItem( hotKey: HotKey( KeyCode.pageDown, modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => _selectTab(context, 1), ), // Rename current view HotKeyItem( hotKey: HotKey( KeyCode.f2, scope: HotKeyScope.inapp, ), keyDownHandler: (_) => getIt().add(const RenameViewEvent.open()), ), // Scale up/down the app // In some keyboards, the system returns equal as + keycode, while others may return add as + keycode, so add them both as zoom in key. ...zoomInKeyCodes.map( (keycode) => HotKeyItem( hotKey: HotKey( keycode, modifiers: [ Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, ], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => _scaleWithStep(0.1), ), ), ...zoomOutKeyCodes.map( (keycode) => HotKeyItem( hotKey: HotKey( keycode, modifiers: [ Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, ], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => _scaleWithStep(-0.1), ), ), // Reset app scaling ...resetZoomKeyCodes.map( (keycode) => HotKeyItem( hotKey: HotKey( keycode, modifiers: [ Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, ], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => _scale(1), ), ), // Switch to the next space HotKeyItem( hotKey: HotKey( KeyCode.keyO, modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => switchToTheNextSpace.value++, ), // Create a new page HotKeyItem( hotKey: HotKey( KeyCode.keyN, modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], scope: HotKeyScope.inapp, ), keyDownHandler: (_) => createNewPageNotifier.value++, ), // Open settings dialog openSettingsHotKey(context), ]; @override void initState() { super.initState(); _registerHotKeys(context); } @override void didChangeDependencies() { super.didChangeDependencies(); _registerHotKeys(context); } @override Widget build(BuildContext context) => widget.child; void _registerHotKeys(BuildContext context) { for (final element in items) { element.register(); } } void _selectTab(BuildContext context, int change) { final bloc = context.read(); bloc.add(TabsEvent.selectTab(bloc.state.currentIndex + change)); } Future _scaleWithStep(double step) async { final currentScaleFactor = await windowSizeManager.getScaleFactor(); double textScale = (currentScaleFactor + step).clamp( WindowSizeManager.minScaleFactor, WindowSizeManager.maxScaleFactor, ); // only keep 2 decimal places textScale = double.parse(textScale.toStringAsFixed(2)); Log.info('scale the app from $currentScaleFactor to $textScale'); await _scale(textScale); } Future _scale(double scaleFactor) async { if (FlowyRunner.currentMode == IntegrationMode.integrationTest) { // The integration test will fail if we check the scale factor in the test. // #0 ScaledWidgetsFlutterBinding.Eval () // #1 ScaledWidgetsFlutterBinding.instance (package:scaled_app/scaled_app.dart:66:62) appflowyScaleFactor = double.parse(scaleFactor.toStringAsFixed(2)); } else { ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => scaleFactor; } await windowSizeManager.setScaleFactor(scaleFactor); } void colappsedMenus(BuildContext context) { final bloc = context.read(); final isNotificationPanelCollapsed = bloc.state.isNotificationPanelCollapsed; if (!isNotificationPanelCollapsed) { bloc.add(const HomeSettingEvent.collapseNotificationPanel()); } else { bloc.collapseMenu(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_shared_state.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; class MenuSharedState { MenuSharedState({ ViewPB? view, }) { _latestOpenView.value = view; } final ValueNotifier _latestOpenView = ValueNotifier(null); ViewPB? get latestOpenView => _latestOpenView.value; ValueNotifier get notifier => _latestOpenView; set latestOpenView(ViewPB? view) { if (_latestOpenView.value?.id != view?.id) { _latestOpenView.value = view; } } void addLatestViewListener(VoidCallback listener) { _latestOpenView.addListener(listener); } void removeLatestViewListener(VoidCallback listener) { _latestOpenView.removeListener(listener); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_more_actions.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_action.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class FavoriteFolder extends StatefulWidget { const FavoriteFolder({super.key, required this.views}); final List views; @override State createState() => _FavoriteFolderState(); } class _FavoriteFolderState extends State { final isHovered = ValueNotifier(false); @override void dispose() { isHovered.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (widget.views.isEmpty) { return const SizedBox.shrink(); } return BlocProvider( create: (context) => FolderBloc(type: FolderSpaceType.favorite) ..add(const FolderEvent.initial()), child: BlocBuilder( builder: (context, state) { return MouseRegion( onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: Column( children: [ FavoriteHeader( onPressed: () => context .read() .add(const FolderEvent.expandOrUnExpand()), ), buildReorderListView(context, state), if (state.isExpanded) ...[ // more button const VSpace(2), const FavoriteMoreButton(), ], ], ), ); }, ), ); } Widget buildReorderListView( BuildContext context, FolderState state, ) { if (!state.isExpanded) return const SizedBox.shrink(); final favoriteBloc = context.read(); final pinnedViews = favoriteBloc.state.pinnedViews.map((e) => e.item).toList(); if (pinnedViews.isEmpty) return const SizedBox.shrink(); if (pinnedViews.length == 1) { return buildViewItem(pinnedViews.first); } return Theme( data: Theme.of(context).copyWith( canvasColor: Colors.transparent, shadowColor: Colors.transparent, ), child: ReorderableListView.builder( shrinkWrap: true, buildDefaultDragHandles: false, itemCount: pinnedViews.length, padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, i) { final view = pinnedViews[i]; return ReorderableDragStartListener( key: ValueKey(view.id), index: i, child: DecoratedBox( decoration: const BoxDecoration(color: Colors.transparent), child: buildViewItem(view), ), ); }, onReorder: (oldIndex, newIndex) { favoriteBloc.add(FavoriteEvent.reorder(oldIndex, newIndex)); }, ), ); } Widget buildViewItem(ViewPB view) { return ViewItem( key: ValueKey('${FolderSpaceType.favorite.name} ${view.id}'), spaceType: FolderSpaceType.favorite, isDraggable: false, isFirstChild: view.id == widget.views.first.id, isFeedback: false, view: view, enableRightClickContext: true, leftPadding: HomeSpaceViewSizes.leftPadding, leftIconBuilder: (_, __) => const HSpace(HomeSpaceViewSizes.leftPadding), level: 0, isHovered: isHovered, rightIconsBuilder: (context, view) => [ Listener( child: FavoriteMoreActions(view: view), onPointerDown: (e) { context.read().add(const ViewEvent.setIsEditing(true)); }, ), const HSpace(8.0), Listener( child: FavoritePinAction(view: view), onPointerDown: (e) { context.read().add(const ViewEvent.setIsEditing(true)); }, ), const HSpace(4.0), ], shouldRenderChildren: false, shouldLoadChildViews: false, onTertiarySelected: (_, view) => context.read().openTab(view), onSelected: (_, view) { if (HardwareKeyboard.instance.isControlPressed) { context.read().openTab(view); } context.read().openPlugin(view); }, ); } } class FavoriteHeader extends StatelessWidget { const FavoriteHeader({super.key, required this.onPressed}); final VoidCallback onPressed; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFGhostIconTextButton.primary( text: LocaleKeys.sideBar_favorites.tr(), mainAxisAlignment: MainAxisAlignment.start, size: AFButtonSize.l, onTap: onPressed, // todo: ask the designer to provide the token. padding: EdgeInsets.symmetric( horizontal: 4, vertical: 6, ), borderRadius: theme.borderRadius.s, iconBuilder: (context, isHover, disabled) => const FlowySvg( FlowySvgs.favorite_header_m, blendMode: null, ), ); } } class FavoriteMoreButton extends StatelessWidget { const FavoriteMoreButton({super.key}); @override Widget build(BuildContext context) { final favoriteBloc = context.watch(); final tabsBloc = context.read(); final unpinnedViews = favoriteBloc.state.unpinnedViews; // only show the more button if there are unpinned views if (unpinnedViews.isEmpty) { return const SizedBox.shrink(); } const minWidth = 260.0; return AppFlowyPopover( constraints: const BoxConstraints( minWidth: minWidth, ), popupBuilder: (_) => MultiBlocProvider( providers: [ BlocProvider.value(value: favoriteBloc), BlocProvider.value(value: tabsBloc), ], child: const FavoriteMenu(minWidth: minWidth), ), margin: EdgeInsets.zero, child: FlowyButton( margin: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 7.0), leftIcon: const FlowySvg(FlowySvgs.workspace_three_dots_s), text: FlowyText.regular(LocaleKeys.button_more.tr()), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_menu_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_more_actions.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_action.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; const double _kHorizontalPadding = 10.0; const double _kVerticalPadding = 10.0; class FavoriteMenu extends StatelessWidget { const FavoriteMenu({super.key, required this.minWidth}); final double minWidth; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only( left: _kHorizontalPadding, right: _kHorizontalPadding, top: _kVerticalPadding, bottom: _kVerticalPadding, ), child: BlocProvider( create: (context) => FavoriteMenuBloc()..add(const FavoriteMenuEvent.initial()), child: BlocBuilder( builder: (context, state) { return Column( mainAxisSize: MainAxisSize.min, children: [ const VSpace(4), SpaceSearchField( width: minWidth - 2 * _kHorizontalPadding, onSearch: (context, text) { context .read() .add(FavoriteMenuEvent.search(text)); }, ), const VSpace(12), _FavoriteGroups( minWidth: minWidth, state: state, ), ], ); }, ), ), ); } } class _FavoriteGroupedViews extends StatelessWidget { const _FavoriteGroupedViews({ required this.views, }); final List views; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: views .map( (e) => ViewItem( key: ValueKey(e.id), view: e, spaceType: FolderSpaceType.favorite, level: 0, onSelected: (_, view) { context.read().openPlugin(view); PopoverContainer.maybeOf(context)?.close(); }, isFeedback: false, isDraggable: false, shouldRenderChildren: false, extendBuilder: (view) => view.isPinned ? [ const HSpace(4.0), const FlowySvg( FlowySvgs.favorite_pin_s, blendMode: null, ), ] : [], leftIconBuilder: (_, __) => const HSpace(4.0), rightIconsBuilder: (_, view) => [ FavoriteMoreActions(view: view), const HSpace(6.0), FavoritePinAction(view: view), const HSpace(4.0), ], ), ) .toList(), ); } } class _FavoriteGroups extends StatelessWidget { const _FavoriteGroups({ required this.minWidth, required this.state, }); final double minWidth; final FavoriteMenuState state; @override Widget build(BuildContext context) { final today = _buildGroups( context, state.todayViews, LocaleKeys.sideBar_today.tr(), ); final thisWeek = _buildGroups( context, state.thisWeekViews, LocaleKeys.sideBar_thisWeek.tr(), ); final others = _buildGroups( context, state.otherViews, LocaleKeys.sideBar_others.tr(), ); return Container( width: minWidth - 2 * _kHorizontalPadding, constraints: const BoxConstraints( maxHeight: 300, ), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (today.isNotEmpty) ...[ ...today, ], if (thisWeek.isNotEmpty) ...[ if (today.isNotEmpty) ...[ const FlowyDivider(), const VSpace(16), ], ...thisWeek, ], if ((thisWeek.isNotEmpty || today.isNotEmpty) && others.isNotEmpty) ...[ const FlowyDivider(), const VSpace(16), ], ...others.isNotEmpty && (today.isNotEmpty || thisWeek.isNotEmpty) ? others : _buildGroups( context, state.otherViews, LocaleKeys.sideBar_others.tr(), showHeader: false, ), ], ), ), ); } List _buildGroups( BuildContext context, List views, String title, { bool showHeader = true, }) { return [ if (views.isNotEmpty) ...[ if (showHeader) FlowyText( title, fontSize: 12.0, color: Theme.of(context).hintColor, ), const VSpace(2), _FavoriteGroupedViews(views: views), const VSpace(8), ], ]; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu_bloc.dart ================================================ import 'package:appflowy/workspace/application/favorite/favorite_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'favorite_menu_bloc.freezed.dart'; class FavoriteMenuBloc extends Bloc { FavoriteMenuBloc() : super(FavoriteMenuState.initial()) { on( (event, emit) async { await event.when( initial: () async { final favoriteViews = await _service.readFavorites(); List views = []; List todayViews = []; List thisWeekViews = []; List otherViews = []; favoriteViews.onSuccess((s) { _source = s; (views, todayViews, thisWeekViews, otherViews) = _getViews(s); }); emit( state.copyWith( views: views, queriedViews: views, todayViews: todayViews, thisWeekViews: thisWeekViews, otherViews: otherViews, ), ); }, search: (query) async { if (_source == null) { return; } var (views, todayViews, thisWeekViews, otherViews) = _getViews(_source!); var queriedViews = views; if (query.isNotEmpty) { queriedViews = _filter(views, query); todayViews = _filter(todayViews, query); thisWeekViews = _filter(thisWeekViews, query); otherViews = _filter(otherViews, query); } emit( state.copyWith( views: views, queriedViews: queriedViews, todayViews: todayViews, thisWeekViews: thisWeekViews, otherViews: otherViews, ), ); }, ); }, ); } final FavoriteService _service = FavoriteService(); RepeatedFavoriteViewPB? _source; List _filter(List views, String query) => views .where((view) => view.name.toLowerCase().contains(query.toLowerCase())) .toList(); // all, today, last week, other (List, List, List, List) _getViews( RepeatedFavoriteViewPB source, ) { final now = DateTime.now(); final List views = source.items.map((v) => v.item).toList(); final List todayViews = []; final List thisWeekViews = []; final List otherViews = []; for (final favoriteView in source.items) { final view = favoriteView.item; final date = DateTime.fromMillisecondsSinceEpoch( favoriteView.timestamp.toInt() * 1000, ); final diff = now.difference(date).inDays; if (diff == 0) { todayViews.add(view); } else if (diff < 7) { thisWeekViews.add(view); } else { otherViews.add(view); } } return (views, todayViews, thisWeekViews, otherViews); } } @freezed class FavoriteMenuEvent with _$FavoriteMenuEvent { const factory FavoriteMenuEvent.initial() = Initial; const factory FavoriteMenuEvent.search(String query) = Search; } @freezed class FavoriteMenuState with _$FavoriteMenuState { const factory FavoriteMenuState({ @Default([]) List views, @Default([]) List queriedViews, @Default([]) List todayViews, @Default([]) List thisWeekViews, @Default([]) List otherViews, }) = _FavoriteMenuState; factory FavoriteMenuState.initial() => const FavoriteMenuState(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_more_actions.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class FavoriteMoreActions extends StatelessWidget { const FavoriteMoreActions({super.key, required this.view}); final ViewPB view; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(), child: ViewMoreActionPopover( view: view, spaceType: FolderSpaceType.favorite, isExpanded: false, onEditing: (value) => context.read().add(ViewEvent.setIsEditing(value)), onAction: (action, _) { switch (action) { case ViewMoreActionType.favorite: case ViewMoreActionType.unFavorite: context.read().add(FavoriteEvent.toggle(view)); PopoverContainer.maybeOf(context)?.closeAll(); break; case ViewMoreActionType.rename: showAFTextFieldDialog( context: context, title: LocaleKeys.disclosureAction_rename.tr(), initialValue: view.nameOrDefault, maxLength: 256, onConfirm: (newValue) { // can not use bloc here because it has been disposed. ViewBackendService.updateView( viewId: view.id, name: newValue, ); }, ); PopoverContainer.maybeOf(context)?.closeAll(); break; case ViewMoreActionType.openInNewTab: getIt().openTab(view); break; case ViewMoreActionType.delete: case ViewMoreActionType.duplicate: default: throw UnsupportedError('$action is not supported'); } }, buildChild: (popover) => FlowyIconButton( width: 24, icon: const FlowySvg(FlowySvgs.workspace_three_dots_s), onPressed: () { popover.show(); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class FavoritePinAction extends StatelessWidget { const FavoritePinAction({super.key, required this.view}); final ViewPB view; @override Widget build(BuildContext context) { final tooltip = view.isPinned ? LocaleKeys.favorite_removeFromSidebar.tr() : LocaleKeys.favorite_addToSidebar.tr(); final icon = FlowySvg( view.isPinned ? FlowySvgs.favorite_section_unpin_s : FlowySvgs.favorite_section_pin_s, ); return FlowyTooltip( message: tooltip, child: FlowyIconButton( width: 24, icon: icon, onPressed: () { view.isPinned ? context.read().add(FavoriteEvent.unpin(view)) : context.read().add(FavoriteEvent.pin(view)); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_bloc.dart ================================================ import 'package:appflowy/workspace/application/favorite/favorite_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'favorite_pin_bloc.freezed.dart'; class FavoritePinBloc extends Bloc { FavoritePinBloc() : super(FavoritePinState.initial()) { on( (event, emit) async { await event.when( initial: () async { final List views = await _service .readFavorites() .fold((s) => s.items.map((v) => v.item).toList(), (f) => []); emit(state.copyWith(views: views, queriedViews: views)); }, search: (query) async { if (query.isEmpty) { emit(state.copyWith(queriedViews: state.views)); return; } final queriedViews = state.views .where( (view) => view.name.toLowerCase().contains(query.toLowerCase()), ) .toList(); emit(state.copyWith(queriedViews: queriedViews)); }, ); }, ); } final FavoriteService _service = FavoriteService(); } @freezed class FavoritePinEvent with _$FavoritePinEvent { const factory FavoritePinEvent.initial() = Initial; const factory FavoritePinEvent.search(String query) = Search; } @freezed class FavoritePinState with _$FavoritePinState { const factory FavoritePinState({ @Default([]) List views, @Default([]) List queriedViews, @Default([]) List> todayViews, @Default([]) List> lastWeekViews, @Default([]) List> otherViews, }) = _FavoritePinState; factory FavoritePinState.initial() => const FavoritePinState(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class FolderHeader extends StatefulWidget { const FolderHeader({ super.key, required this.title, required this.expandButtonTooltip, required this.addButtonTooltip, required this.onPressed, required this.onAdded, required this.isExpanded, }); final String title; final String expandButtonTooltip; final String addButtonTooltip; final VoidCallback onPressed; final VoidCallback onAdded; final bool isExpanded; @override State createState() => _FolderHeaderState(); } class _FolderHeaderState extends State { final isHovered = ValueNotifier(false); @override void dispose() { isHovered.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: HomeSizes.workspaceSectionHeight, child: MouseRegion( onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: FlowyButton( onTap: widget.onPressed, margin: const EdgeInsets.only(left: 6.0, right: 4.0), rightIcon: ValueListenableBuilder( valueListenable: isHovered, builder: (context, onHover, child) => Opacity(opacity: onHover ? 1 : 0, child: child), child: FlowyIconButton( width: 24, iconPadding: const EdgeInsets.all(4.0), tooltipText: widget.addButtonTooltip, icon: const FlowySvg(FlowySvgs.view_item_add_s), onPressed: widget.onAdded, ), ), iconPadding: 10.0, text: Row( children: [ FlowyText( widget.title, lineHeight: 1.15, ), const HSpace(4.0), FlowySvg( widget.isExpanded ? FlowySvgs.workspace_drop_down_menu_show_s : FlowySvgs.workspace_drop_down_menu_hide_s, ), ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SectionFolder extends StatefulWidget { const SectionFolder({ super.key, required this.title, required this.spaceType, required this.views, this.isHoverEnabled = true, required this.expandButtonTooltip, required this.addButtonTooltip, }); final String title; final FolderSpaceType spaceType; final List views; final bool isHoverEnabled; final String expandButtonTooltip; final String addButtonTooltip; @override State createState() => _SectionFolderState(); } class _SectionFolderState extends State { final isHovered = ValueNotifier(false); @override void dispose() { isHovered.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: BlocProvider( create: (_) => FolderBloc(type: widget.spaceType) ..add(const FolderEvent.initial()), child: BlocBuilder( builder: (context, state) => Column( children: [ _buildHeader(context), // Pages const VSpace(4.0), ..._buildViews(context, state, isHovered), // Add a placeholder if there are no views _buildDraggablePlaceholder(context), ], ), ), ), ); } Widget _buildHeader(BuildContext context) { return FolderHeader( title: widget.title, isExpanded: context.watch().state.isExpanded, expandButtonTooltip: widget.expandButtonTooltip, addButtonTooltip: widget.addButtonTooltip, onPressed: () => context.read().add(const FolderEvent.expandOrUnExpand()), onAdded: () { context.read().add( SidebarSectionsEvent.createRootViewInSection( name: '', index: 0, viewSection: widget.spaceType.toViewSectionPB, ), ); context .read() .add(const FolderEvent.expandOrUnExpand(isExpanded: true)); }, ); } Iterable _buildViews( BuildContext context, FolderState state, ValueNotifier isHovered, ) { if (!state.isExpanded) { return []; } return widget.views.map( (view) => ViewItem( key: ValueKey('${widget.spaceType.name} ${view.id}'), spaceType: widget.spaceType, engagedInExpanding: true, isFirstChild: view.id == widget.views.first.id, view: view, level: 0, leftPadding: HomeSpaceViewSizes.leftPadding, isFeedback: false, isHovered: isHovered, enableRightClickContext: true, onSelected: (viewContext, view) { if (HardwareKeyboard.instance.isControlPressed) { context.read().openTab(view); } context.read().openPlugin(view); }, onTertiarySelected: (viewContext, view) => context.read().openTab(view), isHoverEnabled: widget.isHoverEnabled, ), ); } Widget _buildDraggablePlaceholder(BuildContext context) { if (widget.views.isNotEmpty) { return const SizedBox.shrink(); } final parentViewId = context.read().state.currentWorkspace?.workspaceId; return ViewItem( spaceType: widget.spaceType, view: ViewPB(parentViewId: parentViewId ?? ''), level: 0, leftPadding: HomeSpaceViewSizes.leftPadding, isFeedback: false, onSelected: (_, __) {}, isHoverEnabled: widget.isHoverEnabled, isPlaceholder: true, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'sidebar_footer_button.dart'; class SidebarFooter extends StatelessWidget { const SidebarFooter({super.key}); @override Widget build(BuildContext context) { return Column( children: [ if (FeatureFlag.planBilling.isOn) BillingGateGuard( builder: (context) { return const SidebarToast(); }, ), Row( // mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Expanded(child: SidebarTemplateButton()), _buildVerticalDivider(context), const Expanded(child: SidebarTrashButton()), ], ), ], ); } Widget _buildVerticalDivider(BuildContext context) { return Container( width: 1.0, height: 14, margin: const EdgeInsets.symmetric(horizontal: 4), color: AFThemeExtension.of(context).borderColor, ); } } class SidebarTemplateButton extends StatelessWidget { const SidebarTemplateButton({super.key}); @override Widget build(BuildContext context) { return SidebarFooterButton( leftIconSize: const Size.square(16.0), leftIcon: const FlowySvg( FlowySvgs.icon_template_s, ), text: LocaleKeys.template_label.tr(), onTap: () => afLaunchUrlString('https://appflowy.com/templates'), ); } } class SidebarTrashButton extends StatelessWidget { const SidebarTrashButton({super.key}); @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: getIt().notifier, builder: (context, value, child) { return SidebarFooterButton( leftIconSize: const Size.square(18.0), leftIcon: const FlowySvg( FlowySvgs.icon_delete_s, ), text: LocaleKeys.trash_text.tr(), onTap: () { getIt().latestOpenView = null; getIt().add( TabsEvent.openPlugin( plugin: makePlugin(pluginType: PluginType.trash), ), ); }, ); }, ); } } class SidebarWidgetButton extends StatelessWidget { const SidebarWidgetButton({ super.key, }); @override Widget build(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () {}, child: const FlowySvg(FlowySvgs.sidebar_footer_widget_s), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart ================================================ import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; // This button style is used in // - Trash button // - Template button class SidebarFooterButton extends StatelessWidget { const SidebarFooterButton({ super.key, required this.leftIcon, required this.leftIconSize, required this.text, required this.onTap, }); final Widget leftIcon; final Size leftIconSize; final String text; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( height: HomeSizes.workspaceSectionHeight, child: FlowyButton( leftIcon: leftIcon, leftIconSize: leftIconSize, margin: const EdgeInsets.all(4.0), expandText: false, text: Padding( padding: const EdgeInsets.only(right: 6.0), child: FlowyText( text, fontWeight: FontWeight.w400, figmaLineHeight: 18.0, ), ), onTap: onTap, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart ================================================ import 'dart:io'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/billing/sidebar_plan_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarToast extends StatelessWidget { const SidebarToast({super.key}); @override Widget build(BuildContext context) { return BlocConsumer( listener: (_, state) { // Show a dialog when the user hits the storage limit, After user click ok, it will navigate to the plan page. // Even though the dislog is dissmissed, if the user triggers the storage limit again, the dialog will show again. state.tierIndicator.maybeWhen( storageLimitHit: () => WidgetsBinding.instance.addPostFrameCallback( (_) => _showStorageLimitDialog(context), ), singleFileLimitHit: () => WidgetsBinding.instance.addPostFrameCallback( (_) => _showSingleFileLimitDialog(context), ), orElse: () {}, ); }, builder: (_, state) { return state.tierIndicator.when( loading: () => const SizedBox.shrink(), storageLimitHit: () => PlanIndicator( planName: SubscriptionPlanPB.Free.label, text: LocaleKeys.sideBar_upgradeToPro.tr(), onTap: () => _handleOnTap(context, SubscriptionPlanPB.Pro), reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), ), aiMaxiLimitHit: () => PlanIndicator( planName: SubscriptionPlanPB.AiMax.label, text: LocaleKeys.sideBar_upgradeToAIMax.tr(), onTap: () => _handleOnTap(context, SubscriptionPlanPB.AiMax), reason: LocaleKeys.sideBar_aiResponseLimitTitle.tr(), ), singleFileLimitHit: () => const SizedBox.shrink(), ); }, ); } void _showStorageLimitDialog(BuildContext context) => showConfirmDialog( context: context, title: LocaleKeys.sideBar_purchaseStorageSpace.tr(), description: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), confirmLabel: LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(), onConfirm: (_) { WidgetsBinding.instance.addPostFrameCallback( (_) => _handleOnTap(context, SubscriptionPlanPB.Pro), ); }, ); void _showSingleFileLimitDialog(BuildContext context) => showConfirmDialog( context: context, title: LocaleKeys.sideBar_upgradeToPro.tr(), description: LocaleKeys.sideBar_singleFileProPlanLimitationDescription.tr(), confirmLabel: LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(), onConfirm: (_) { WidgetsBinding.instance.addPostFrameCallback( (_) => _handleOnTap(context, SubscriptionPlanPB.Pro), ); }, ); void _handleOnTap(BuildContext context, SubscriptionPlanPB plan) { final userProfile = context.read().state.userProfile; if (userProfile == null) { return Log.error( 'UserProfile is null, this should NOT happen! Please file a bug report', ); } final userWorkspaceBloc = context.read(); final role = userWorkspaceBloc.state.currentWorkspace?.role; if (role == null) { return Log.error( "Member is null. It should not happen. If you see this error, it's a bug", ); } // Only if the user is the workspace owner will we navigate to the plan page. if (role.isOwner) { showSettingsDialog( context, userWorkspaceBloc: userWorkspaceBloc, initPage: SettingsPage.plan, ); } else { final String message; if (plan == SubscriptionPlanPB.AiMax) { message = Platform.isIOS ? LocaleKeys.sideBar_askOwnerToUpgradeToAIMaxIOS.tr() : LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(); } else { message = Platform.isIOS ? LocaleKeys.sideBar_askOwnerToUpgradeToProIOS.tr() : LocaleKeys.sideBar_askOwnerToUpgradeToPro.tr(); } showDialog( context: context, barrierDismissible: false, useRootNavigator: false, builder: (dialogContext) => _AskOwnerToChangePlan( message: message, onOkPressed: () {}, ), ); } } } class PlanIndicator extends StatefulWidget { const PlanIndicator({ super.key, required this.planName, required this.text, required this.onTap, required this.reason, }); final String planName; final String reason; final String text; final Function() onTap; @override State createState() => _PlanIndicatorState(); } class _PlanIndicatorState extends State { final popoverController = PopoverController(); @override void dispose() { popoverController.close(); super.dispose(); } @override Widget build(BuildContext context) { const textGradient = LinearGradient( begin: Alignment.bottomLeft, end: Alignment.bottomRight, colors: [Color(0xFF8032FF), Color(0xFFEF35FF)], stops: [0.1545, 0.8225], ); final backgroundGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ const Color(0xFF8032FF).withValues(alpha: .1), const Color(0xFFEF35FF).withValues(alpha: .1), ], ); return AppFlowyPopover( controller: popoverController, direction: PopoverDirection.rightWithBottomAligned, offset: const Offset(10, -12), popupBuilder: (context) { return Padding( padding: const EdgeInsets.all(8.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyText( widget.text, color: AFThemeExtension.of(context).strongText, ), const VSpace(12), Opacity( opacity: 0.7, child: FlowyText.regular( widget.reason, maxLines: null, lineHeight: 1.3, textAlign: TextAlign.center, ), ), const VSpace(12), Row( children: [ Expanded( child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { popoverController.close(); widget.onTap(); }, child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(9), ), child: Center( child: FlowyText( LocaleKeys .settings_comparePlanDialog_actions_upgrade .tr(), color: Colors.white, fontSize: 12, strutStyle: const StrutStyle( forceStrutHeight: true, ), ), ), ), ), ), ), ], ), ], ), ); }, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), clipBehavior: Clip.antiAlias, decoration: BoxDecoration( gradient: backgroundGradient, borderRadius: BorderRadius.circular(10), ), child: Row( children: [ const FlowySvg( FlowySvgs.upgrade_storage_s, blendMode: null, ), const HSpace(6), ShaderMask( shaderCallback: (bounds) => textGradient.createShader(bounds), blendMode: BlendMode.srcIn, child: FlowyText( widget.text, color: AFThemeExtension.of(context).strongText, ), ), ], ), ), ), ); } } class _AskOwnerToChangePlan extends StatelessWidget { const _AskOwnerToChangePlan({ required this.message, required this.onOkPressed, }); final String message; final VoidCallback onOkPressed; @override Widget build(BuildContext context) { return NavigatorOkCancelDialog( message: message, okTitle: LocaleKeys.button_ok.tr(), onOkPressed: onOkPressed, titleUpperCase: false, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_upgrade_application_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SidebarUpgradeApplicationButton extends StatelessWidget { const SidebarUpgradeApplicationButton({ super.key, required this.onUpdateButtonTap, required this.onCloseButtonTap, }); final VoidCallback onUpdateButtonTap; final VoidCallback onCloseButtonTap; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: context.sidebarUpgradeButtonBackground, borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // title _buildTitle(), const VSpace(2), // description _buildDescription(), const VSpace(10), // update button _buildUpdateButton(), ], ), ); } Widget _buildTitle() { return Row( children: [ const FlowySvg( FlowySvgs.sidebar_upgrade_version_s, blendMode: null, ), const HSpace(6), FlowyText.medium( LocaleKeys.autoUpdate_bannerUpdateTitle.tr(), fontSize: 14, figmaLineHeight: 18, ), const Spacer(), FlowyButton( useIntrinsicWidth: true, text: const FlowySvg(FlowySvgs.upgrade_close_s), onTap: onCloseButtonTap, ), ], ); } Widget _buildDescription() { return Opacity( opacity: 0.7, child: FlowyText( LocaleKeys.autoUpdate_bannerUpdateDescription.tr(), fontSize: 13, figmaLineHeight: 16, maxLines: null, ), ); } Widget _buildUpdateButton() { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: onUpdateButtonTap, child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: ShapeDecoration( color: const Color(0xFFA44AFD), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(9), ), ), child: FlowyText.medium( LocaleKeys.autoUpdate_settingsUpdateButton.tr(), color: Colors.white, fontSize: 12.0, figmaLineHeight: 15.0, ), ), ), ); } } extension on BuildContext { Color get sidebarUpgradeButtonBackground => Theme.of(this).isLightMode ? const Color(0xB2EBE4FF) : const Color(0xB239275B); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart ================================================ import 'dart:io' show Platform; import 'package:appflowy/core/frameless_window.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; /// Sidebar top menu is the top bar of the sidebar. /// /// in the top menu, we have: /// - appflowy icon (Windows or Linux) /// - close / expand sidebar button class SidebarTopMenu extends StatelessWidget { const SidebarTopMenu({ super.key, required this.isSidebarOnHover, }); final ValueNotifier isSidebarOnHover; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, _) => SizedBox( height: !UniversalPlatform.isWindows ? HomeSizes.topBarHeight : 45, child: MoveWindowDetector( child: Row( children: [ _buildLogoIcon(context), const Spacer(), _buildCollapseMenuButton(context), ], ), ), ), ); } Widget _buildLogoIcon(BuildContext context) { if (Platform.isMacOS) { return const SizedBox.shrink(); } final svgData = Theme.of(context).brightness == Brightness.dark ? FlowySvgs.app_logo_with_text_dark_xl : FlowySvgs.app_logo_with_text_light_xl; return Padding( padding: const EdgeInsets.only(top: 12.0, left: 8), child: FlowySvg( svgData, size: const Size(92, 17), blendMode: null, ), ); } Widget _buildCollapseMenuButton(BuildContext context) { final settingState = context.read()?.state; final isNotificationPanelCollapsed = settingState?.isNotificationPanelCollapsed ?? true; final textSpan = TextSpan( children: [ TextSpan( text: LocaleKeys.sideBar_closeSidebar.tr(), style: context.tooltipTextStyle(), ), if (isNotificationPanelCollapsed) TextSpan( text: '\n${Platform.isMacOS ? '⌘+.' : 'Ctrl+\\'}', style: context .tooltipTextStyle() ?.copyWith(color: Theme.of(context).hintColor), ), ], ); final theme = AppFlowyTheme.of(context); return ValueListenableBuilder( valueListenable: isSidebarOnHover, builder: (_, value, ___) => Opacity( opacity: value ? 1 : 0, child: Padding( padding: const EdgeInsets.only(top: 12.0, right: 6.0), child: FlowyTooltip( richMessage: textSpan, child: Listener( behavior: HitTestBehavior.translucent, onPointerDown: (_) => context.read().collapseMenu(), child: FlowyHover( child: SizedBox( width: 24, child: FlowySvg( FlowySvgs.double_back_arrow_m, color: theme.iconColorScheme.secondary, ), ), ), ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/menu/menu_user_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; // keep this widget in case we need to roll back (lucas.xu) class SidebarUser extends StatelessWidget { const SidebarUser({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { final workspaceId = context.read().state.currentWorkspace?.workspaceId ?? ''; return BlocProvider( create: (_) => MenuUserBloc(userProfile, workspaceId), child: BlocBuilder( builder: (context, state) => Row( children: [ const HSpace(4), UserAvatar( iconUrl: state.userProfile.iconUrl, name: state.userProfile.name, size: AFAvatarSize.s, decoration: ShapeDecoration( color: const Color(0xFFFBE8FB), shape: RoundedRectangleBorder( side: const BorderSide(width: 0.50, color: Color(0x19171717)), borderRadius: BorderRadius.circular(8), ), ), ), const HSpace(8), Expanded(child: _buildUserName(context, state)), UserSettingButton(), const HSpace(8.0), NotificationButton(key: ValueKey(userProfile.id)), const HSpace(10.0), ], ), ), ); } Widget _buildUserName(BuildContext context, MenuUserState state) { final String name = _userName(state.userProfile); return FlowyText.medium( name, overflow: TextOverflow.ellipsis, color: Theme.of(context).colorScheme.tertiary, fontSize: 15.0, ); } /// Return the user name, if the user name is empty, return the default user name. String _userName(UserProfilePB userProfile) { String name = userProfile.name; if (name.isEmpty) { name = LocaleKeys.defaultUsername.tr(); } return name; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_panel.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/share/import_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/import/import_type.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/container.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; typedef ImportCallback = void Function( ImportType type, String name, List? document, ); Future showImportPanel( String parentViewId, BuildContext context, ImportCallback callback, ) async { await FlowyOverlay.show( context: context, builder: (context) => FlowyDialog( backgroundColor: Theme.of(context).colorScheme.surface, title: FlowyText.semibold( LocaleKeys.moreAction_import.tr(), fontSize: 20, color: Theme.of(context).colorScheme.tertiary, ), child: Padding( padding: const EdgeInsets.symmetric( vertical: 10.0, horizontal: 20.0, ), child: ImportPanel( parentViewId: parentViewId, importCallback: callback, ), ), ), ); } class ImportPanel extends StatefulWidget { const ImportPanel({ super.key, required this.parentViewId, required this.importCallback, }); final String parentViewId; final ImportCallback importCallback; @override State createState() => _ImportPanelState(); } class _ImportPanelState extends State { final flowyContainerFocusNode = FocusNode(); final ValueNotifier showLoading = ValueNotifier(false); @override void dispose() { flowyContainerFocusNode.dispose(); showLoading.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final width = MediaQuery.of(context).size.width * 0.7; final height = width * 0.5; return KeyboardListener( autofocus: true, focusNode: flowyContainerFocusNode, onKeyEvent: (event) { if (event is KeyDownEvent && event.physicalKey == PhysicalKeyboardKey.escape) { FlowyOverlay.pop(context); } }, child: Stack( children: [ FlowyContainer( Theme.of(context).colorScheme.surface, height: height, width: width, child: GridView.count( childAspectRatio: 1 / .2, crossAxisCount: 2, children: ImportType.values .where((element) => element.enableOnRelease) .map( (e) => Card( child: FlowyButton( leftIcon: e.icon(context), leftIconSize: const Size.square(20), text: FlowyText.medium( e.toString(), fontSize: 15, overflow: TextOverflow.ellipsis, color: Theme.of(context).colorScheme.tertiary, ), onTap: () async { await _importFile(widget.parentViewId, e); if (context.mounted) { FlowyOverlay.pop(context); } }, ), ), ) .toList(), ), ), ValueListenableBuilder( valueListenable: showLoading, builder: (context, showLoading, child) { if (!showLoading) { return const SizedBox.shrink(); } return const Center( child: CircularProgressIndicator(), ); }, ), ], ), ); } Future _importFile(String parentViewId, ImportType importType) async { final result = await getIt().pickFiles( type: FileType.custom, allowMultiple: importType.allowMultiSelect, allowedExtensions: importType.allowedExtensions, ); if (result == null || result.files.isEmpty) { return; } showLoading.value = true; final importValues = []; for (final file in result.files) { final path = file.path; if (path == null) { continue; } final name = p.basenameWithoutExtension(path); switch (importType) { case ImportType.historyDatabase: final data = await File(path).readAsString(); importValues.add( ImportItemPayloadPB.create() ..name = name ..data = utf8.encode(data) ..viewLayout = ViewLayoutPB.Grid ..importType = ImportTypePB.HistoryDatabase, ); break; case ImportType.historyDocument: case ImportType.markdownOrText: final data = await File(path).readAsString(); final bytes = _documentDataFrom(importType, data); if (bytes != null) { importValues.add( ImportItemPayloadPB.create() ..name = name ..data = bytes ..viewLayout = ViewLayoutPB.Document ..importType = ImportTypePB.Markdown, ); } break; case ImportType.csv: final data = await File(path).readAsString(); importValues.add( ImportItemPayloadPB.create() ..name = name ..data = utf8.encode(data) ..viewLayout = ViewLayoutPB.Grid ..importType = ImportTypePB.CSV, ); break; case ImportType.afDatabase: final data = await File(path).readAsString(); importValues.add( ImportItemPayloadPB.create() ..name = name ..data = utf8.encode(data) ..viewLayout = ViewLayoutPB.Grid ..importType = ImportTypePB.AFDatabase, ); break; } } if (importValues.isNotEmpty) { await ImportBackendService.importPages( parentViewId, importValues, ); } showLoading.value = false; widget.importCallback(importType, '', null); } } Uint8List? _documentDataFrom(ImportType importType, String data) { switch (importType) { case ImportType.historyDocument: final document = EditorMigration.migrateDocument(data); return DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); case ImportType.markdownOrText: final document = customMarkdownToDocument(data); return DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); default: assert(false, 'Unsupported Type $importType'); return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_type.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; enum ImportType { historyDocument, historyDatabase, markdownOrText, csv, afDatabase; @override String toString() { switch (this) { case ImportType.historyDocument: return LocaleKeys.importPanel_documentFromV010.tr(); case ImportType.historyDatabase: return LocaleKeys.importPanel_databaseFromV010.tr(); case ImportType.markdownOrText: return LocaleKeys.importPanel_textAndMarkdown.tr(); case ImportType.csv: return LocaleKeys.importPanel_csv.tr(); case ImportType.afDatabase: return LocaleKeys.importPanel_database.tr(); } } WidgetBuilder get icon => (context) { final FlowySvgData svg; switch (this) { case ImportType.historyDatabase: svg = FlowySvgs.document_s; case ImportType.historyDocument: case ImportType.csv: case ImportType.afDatabase: svg = FlowySvgs.board_s; case ImportType.markdownOrText: svg = FlowySvgs.text_s; } return FlowySvg( svg, color: Theme.of(context).colorScheme.tertiary, ); }; bool get enableOnRelease { switch (this) { case ImportType.historyDatabase: case ImportType.historyDocument: case ImportType.afDatabase: return kDebugMode; default: return true; } } List get allowedExtensions { switch (this) { case ImportType.historyDocument: return ['afdoc']; case ImportType.historyDatabase: case ImportType.afDatabase: return ['afdb']; case ImportType.markdownOrText: return ['md', 'txt']; case ImportType.csv: return ['csv']; } } bool get allowMultiSelect { switch (this) { case ImportType.historyDocument: case ImportType.historyDatabase: case ImportType.csv: case ImportType.afDatabase: case ImportType.markdownOrText: return true; } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_search_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; typedef MovePageMenuOnSelected = void Function(ViewPB space, ViewPB view); class MovePageMenu extends StatefulWidget { const MovePageMenu({ super.key, required this.sourceView, required this.onSelected, }); final ViewPB sourceView; final MovePageMenuOnSelected onSelected; @override State createState() => _MovePageMenuState(); } class _MovePageMenuState extends State { final isExpandedNotifier = PropertyValueNotifier(true); final isHoveredNotifier = ValueNotifier(true); @override void dispose() { isExpandedNotifier.dispose(); isHoveredNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (_) => SpaceSearchBloc()..add(const SpaceSearchEvent.initial()), child: BlocBuilder( builder: (context, state) { final space = state.currentSpace; if (space == null) { return const SizedBox.shrink(); } return Column( children: [ SpaceSearchField( width: 240, onSearch: (context, value) => context .read() .add(SpaceSearchEvent.search(value)), ), const VSpace(10), BlocBuilder( builder: (context, state) { if (state.queryResults == null) { return Expanded(child: _buildSpace(space)); } return Expanded( child: _buildGroupedViews(space, state.queryResults!), ); }, ), ], ); }, ), ); } Widget _buildGroupedViews(ViewPB space, List views) { final groupedViews = views .where((v) => !_shouldIgnoreView(v, widget.sourceView) && !v.isSpace) .toList(); return _MovePageGroupedViews( views: groupedViews, onSelected: (view) => widget.onSelected(space, view), ); } Column _buildSpace(ViewPB space) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SpacePopup( useIntrinsicWidth: false, expand: true, height: 30, showCreateButton: false, child: FlowyTooltip( message: LocaleKeys.space_switchSpace.tr(), child: CurrentSpace( // move the page to current space onTapBlankArea: () => widget.onSelected(space, space), space: space, ), ), ), Expanded( child: SingleChildScrollView( physics: const ClampingScrollPhysics(), child: SpacePages( key: ValueKey(space.id), space: space, isHovered: isHoveredNotifier, isExpandedNotifier: isExpandedNotifier, shouldIgnoreView: (view) { if (_shouldIgnoreView(view, widget.sourceView)) { return IgnoreViewType.hide; } if (view.layout != ViewLayoutPB.Document) { return IgnoreViewType.disable; } return IgnoreViewType.none; }, // hide the hover status and disable the editing actions disableSelectedStatus: true, // hide the ... and + buttons rightIconsBuilder: (context, view) => [], onSelected: (_, view) => widget.onSelected(space, view), ), ), ), ], ); } } class _MovePageGroupedViews extends StatelessWidget { const _MovePageGroupedViews({required this.views, required this.onSelected}); final List views; final void Function(ViewPB view) onSelected; @override Widget build(BuildContext context) { return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: views .map( (view) => ViewItem( key: ValueKey(view.id), view: view, spaceType: FolderSpaceType.unknown, level: 0, onSelected: (_, view) => onSelected(view), isFeedback: false, isDraggable: false, shouldRenderChildren: false, leftIconBuilder: (_, __) => const HSpace(0.0), rightIconsBuilder: (_, view) => [], ), ) .toList(), ), ); } } bool _shouldIgnoreView(ViewPB view, ViewPB sourceView) { return view.id == sourceView.id; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarFolder extends StatelessWidget { const SidebarFolder({ super.key, this.isHoverEnabled = true, required this.userProfile, }); final bool isHoverEnabled; final UserProfilePB userProfile; @override Widget build(BuildContext context) { const sectionPadding = 16.0; return ValueListenableBuilder( valueListenable: getIt().notifier, builder: (context, value, child) { return Column( children: [ const VSpace(4.0), // favorite BlocBuilder( builder: (context, state) { if (state.views.isEmpty) { return const SizedBox.shrink(); } return FavoriteFolder( views: state.views.map((e) => e.item).toList(), ); }, ), // public or private BlocBuilder( builder: (context, state) { // only show public and private section if the workspace is collaborative and not local final isCollaborativeWorkspace = context.read().state.isCollabWorkspaceOn; // only show public and private section if the workspace is collaborative return Column( children: isCollaborativeWorkspace ? [ // public const VSpace(sectionPadding), PublicSectionFolder(views: state.section.publicViews), // private const VSpace(sectionPadding), PrivateSectionFolder( views: state.section.privateViews, ), ] : [ // personal const VSpace(sectionPadding), PersonalSectionFolder( views: state.section.publicViews, ), ], ); }, ), const VSpace(200), ], ); }, ); } } class PrivateSectionFolder extends SectionFolder { PrivateSectionFolder({super.key, required super.views}) : super( title: LocaleKeys.sideBar_private.tr(), spaceType: FolderSpaceType.private, expandButtonTooltip: LocaleKeys.sideBar_clickToHidePrivate.tr(), addButtonTooltip: LocaleKeys.sideBar_addAPageToPrivate.tr(), ); } class PublicSectionFolder extends SectionFolder { PublicSectionFolder({super.key, required super.views}) : super( title: LocaleKeys.sideBar_workspace.tr(), spaceType: FolderSpaceType.public, expandButtonTooltip: LocaleKeys.sideBar_clickToHideWorkspace.tr(), addButtonTooltip: LocaleKeys.sideBar_addAPageToWorkspace.tr(), ); } class PersonalSectionFolder extends SectionFolder { PersonalSectionFolder({super.key, required super.views}) : super( title: LocaleKeys.sideBar_personal.tr(), spaceType: FolderSpaceType.public, expandButtonTooltip: LocaleKeys.sideBar_clickToHidePersonal.tr(), addButtonTooltip: LocaleKeys.sideBar_addAPage.tr(), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarNewPageButton extends StatefulWidget { const SidebarNewPageButton({ super.key, }); @override State createState() => _SidebarNewPageButtonState(); } class _SidebarNewPageButtonState extends State { @override void initState() { super.initState(); createNewPageNotifier.addListener(_createNewPage); } @override void dispose() { createNewPageNotifier.removeListener(_createNewPage); super.dispose(); } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8), height: HomeSizes.newPageSectionHeight, child: FlowyButton( onTap: () async => _createNewPage(), leftIcon: const FlowySvg( FlowySvgs.new_app_m, blendMode: null, ), leftIconSize: const Size.square(24.0), margin: const EdgeInsets.only(left: 4.0), iconPadding: 8.0, text: FlowyText.regular( LocaleKeys.newPageText.tr(), lineHeight: 1.15, ), ), ); } Future _createNewPage() async { // if the workspace is collaborative, create the view in the private section by default. final section = context.read().state.isCollabWorkspaceOn ? ViewSectionPB.Private : ViewSectionPB.Public; final spaceState = context.read().state; if (spaceState.spaces.isNotEmpty) { context.read().add( const SpaceEvent.createPage( name: '', index: 0, layout: ViewLayoutPB.Document, openAfterCreate: true, ), ); } else { context.read().add( SidebarSectionsEvent.createRootViewInSection( name: '', viewSection: section, index: 0, ), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/password/password_bloc.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:universal_platform/universal_platform.dart'; final GlobalKey _settingsDialogKey = GlobalKey(); HotKeyItem openSettingsHotKey( BuildContext context, ) => HotKeyItem( hotKey: HotKey( KeyCode.comma, scope: HotKeyScope.inapp, modifiers: [ UniversalPlatform.isMacOS ? KeyModifier.meta : KeyModifier.control, ], ), keyDownHandler: (_) { if (_settingsDialogKey.currentContext == null) { showSettingsDialog( context, userWorkspaceBloc: context.read(), ); } else { Navigator.of(context, rootNavigator: true) .popUntil((route) => route.isFirst); } }, ); class UserSettingButton extends StatefulWidget { const UserSettingButton({ super.key, this.isHover = false, }); final bool isHover; @override State createState() => _UserSettingButtonState(); } class _UserSettingButtonState extends State { late UserWorkspaceBloc _userWorkspaceBloc; late PasswordBloc _passwordBloc; @override void initState() { super.initState(); _userWorkspaceBloc = context.read(); _passwordBloc = PasswordBloc(_userWorkspaceBloc.state.userProfile) ..add(PasswordEvent.init()) ..add(PasswordEvent.checkHasPassword()); } @override void didChangeDependencies() { _userWorkspaceBloc = context.read(); super.didChangeDependencies(); } @override void dispose() { _passwordBloc.close(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox.square( dimension: 28.0, child: FlowyTooltip( message: LocaleKeys.settings_menu_open.tr(), child: BlocProvider.value( value: _passwordBloc, child: FlowyButton( onTap: () => showSettingsDialog( context, userWorkspaceBloc: _userWorkspaceBloc, passwordBloc: _passwordBloc, ), margin: EdgeInsets.zero, text: FlowySvg( FlowySvgs.settings_s, color: widget.isHover ? Theme.of(context).colorScheme.onSurface : null, opacity: 0.7, ), ), ), ), ); } } void showSettingsDialog( BuildContext context, { required UserWorkspaceBloc userWorkspaceBloc, PasswordBloc? passwordBloc, SettingsPage? initPage, }) { final userProfile = context.read().state.userProfile; AFFocusManager.maybeOf(context)?.notifyLoseFocus(); showDialog( context: context, builder: (dialogContext) => MultiBlocProvider( key: _settingsDialogKey, providers: [ passwordBloc != null ? BlocProvider.value( value: passwordBloc, ) : BlocProvider( create: (context) => PasswordBloc(userProfile) ..add(PasswordEvent.init()) ..add(PasswordEvent.checkHasPassword()), ), BlocProvider.value( value: BlocProvider.of(dialogContext), ), BlocProvider.value( value: userWorkspaceBloc, ), ], child: SettingsDialog( userProfile, initPage: initPage, didLogout: () async { // Pop the dialog using the dialog context Navigator.of(dialogContext).pop(); await runAppFlowy(); }, dismissDialog: () { if (Navigator.of(dialogContext).canPop()) { return Navigator.of(dialogContext).pop(); } Log.warn("Can't pop dialog context"); }, restartApp: () async { // Pop the dialog using the dialog context Navigator.of(dialogContext).pop(); await runAppFlowy(); }, ), ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/search/view_ancestor_cache.dart'; import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/shared/version_checker/version_checker.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/favorite/prelude.dart'; import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; import 'package:appflowy/workspace/application/sidebar/billing/sidebar_plan_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_upgrade_application_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_migration.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; Loading? _duplicateSpaceLoading; /// Home Sidebar is the left side bar of the home page. /// /// in the sidebar, we have: /// - user icon, user name /// - settings /// - scrollable document list /// - trash class HomeSideBar extends StatelessWidget { const HomeSideBar({ super.key, required this.userProfile, required this.workspaceSetting, }); final UserProfilePB userProfile; final WorkspaceLatestPB workspaceSetting; @override Widget build(BuildContext context) { // Workspace Bloc: control the current workspace // | // +-- Workspace Menu // | | // | +-- Workspace List: control to switch workspace // | | // | +-- Workspace Settings // | | // | +-- Notification Center // | // +-- Favorite Section // | // +-- Public Or Private Section: control the sections of the workspace // | // +-- Trash Section return BlocProvider( create: (context) => SidebarPlanBloc() ..add(SidebarPlanEvent.init(workspaceSetting.workspaceId, userProfile)), child: BlocConsumer( listenWhen: (prev, curr) => prev.currentWorkspace?.workspaceId != curr.currentWorkspace?.workspaceId, listener: (context, state) { if (FeatureFlag.search.isOn) { // Notify command palette that workspace has changed context.read().add( CommandPaletteEvent.workspaceChanged( workspaceId: state.currentWorkspace?.workspaceId, ), ); } if (state.currentWorkspace != null) { context.read().add( SidebarPlanEvent.changedWorkspace( workspaceId: state.currentWorkspace!.workspaceId, ), ); } // Re-initialize workspace-specific services getIt().reset(); }, // Rebuild the whole sidebar when the current workspace changes buildWhen: (previous, current) => previous.currentWorkspace?.workspaceId != current.currentWorkspace?.workspaceId, builder: (context, state) { if (state.currentWorkspace == null) { return const SizedBox.shrink(); } final workspaceId = state.currentWorkspace?.workspaceId ?? workspaceSetting.workspaceId; return MultiBlocProvider( providers: [ BlocProvider.value(value: getIt()), BlocProvider( create: (_) => SidebarSectionsBloc() ..add(SidebarSectionsEvent.initial(userProfile, workspaceId)), ), BlocProvider( create: (_) => SpaceBloc( userProfile: userProfile, workspaceId: workspaceId, )..add(const SpaceEvent.initial(openFirstPage: false)), ), ], child: MultiBlocListener( listeners: [ BlocListener( listenWhen: (p, c) => p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, listener: (context, state) => context.read().add( TabsEvent.openPlugin( plugin: state.lastCreatedRootView!.plugin(), ), ), ), BlocListener( listenWhen: (prev, curr) => prev.lastCreatedPage?.id != curr.lastCreatedPage?.id || prev.isDuplicatingSpace != curr.isDuplicatingSpace, listener: (context, state) { final page = state.lastCreatedPage; if (page == null || page.id.isEmpty) { // open the blank page context .read() .add(TabsEvent.openPlugin(plugin: BlankPagePlugin())); } else { context.read().add( TabsEvent.openPlugin( plugin: state.lastCreatedPage!.plugin(), ), ); } if (state.isDuplicatingSpace) { _duplicateSpaceLoading ??= Loading(context); _duplicateSpaceLoading?.start(); } else if (_duplicateSpaceLoading != null) { _duplicateSpaceLoading?.stop(); _duplicateSpaceLoading = null; } }, ), BlocListener( listenWhen: (_, curr) => curr.action != null, listener: _onNotificationAction, ), BlocListener( listener: (context, state) { final actionType = state.actionResult?.actionType; if (actionType == WorkspaceActionType.create || actionType == WorkspaceActionType.delete || actionType == WorkspaceActionType.open) { if (context.read().state.spaces.isEmpty) { context.read().add( SidebarSectionsEvent.reload( userProfile, state.currentWorkspace?.workspaceId ?? workspaceSetting.workspaceId, ), ); } else { context.read().add( SpaceEvent.reset( userProfile, state.currentWorkspace?.workspaceId ?? workspaceSetting.workspaceId, true, ), ); } context .read() .add(const FavoriteEvent.fetchFavorites()); } }, ), ], child: _Sidebar(userProfile: userProfile), ), ); }, ), ); } void _onNotificationAction( BuildContext context, ActionNavigationState state, ) { final action = state.action; if (action?.type == ActionType.openView) { final view = action!.arguments?[ActionArgumentKeys.view]; if (view != null) { final Map arguments = {}; final nodePath = action.arguments?[ActionArgumentKeys.nodePath]; if (nodePath != null) { arguments[PluginArgumentKeys.selection] = Selection.collapsed( Position(path: [nodePath]), ); } checkForSpace( context.read(), view, () => openView(action, context, view, arguments), ); openView(action, context, view, arguments); } } } Future checkForSpace( SpaceBloc spaceBloc, ViewPB view, VoidCallback afterOpen, ) async { /// open space final acestorCache = getIt(); final ancestor = await acestorCache.getAncestor(view.id); if (ancestor?.ancestors.isEmpty ?? true) return; final firstAncestor = ancestor!.ancestors.first; if (firstAncestor.id != spaceBloc.state.currentSpace?.id) { final space = (await ViewBackendService.getView(firstAncestor.id)).toNullable(); if (space != null) { Log.info( 'Switching space from (${firstAncestor.name}-${firstAncestor.id}) to (${space.name}-${space.id})', ); spaceBloc.add(SpaceEvent.open(space: space, afterOpen: afterOpen)); } } } void openView( NavigationAction action, BuildContext context, ViewPB view, Map arguments, ) { final blockId = action.arguments?[ActionArgumentKeys.blockId]; if (blockId != null) { arguments[PluginArgumentKeys.blockId] = blockId; } final rowId = action.arguments?[ActionArgumentKeys.rowId]; if (rowId != null) { arguments[PluginArgumentKeys.rowId] = rowId; } WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { context.read().openPlugin(view, arguments: arguments); } }); } } class _Sidebar extends StatefulWidget { const _Sidebar({required this.userProfile}); final UserProfilePB userProfile; @override State<_Sidebar> createState() => _SidebarState(); } class _SidebarState extends State<_Sidebar> { final _scrollController = ScrollController(); Timer? _scrollDebounce; bool _isScrolling = false; final _isHovered = ValueNotifier(false); final _scrollOffset = ValueNotifier(0); // mute the update button during the current application lifecycle. final _muteUpdateButton = ValueNotifier(false); @override void initState() { super.initState(); _scrollController.addListener(_onScrollChanged); } @override void dispose() { _scrollDebounce?.cancel(); _scrollController.removeListener(_onScrollChanged); _scrollController.dispose(); _scrollOffset.dispose(); _isHovered.dispose(); super.dispose(); } @override Widget build(BuildContext context) { const menuHorizontalInset = EdgeInsets.symmetric(horizontal: 8); return MouseRegion( onEnter: (_) => _isHovered.value = true, onExit: (_) => _isHovered.value = false, child: DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, border: Border( right: BorderSide(color: Theme.of(context).dividerColor), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // top menu Padding( padding: menuHorizontalInset, child: SidebarTopMenu( isSidebarOnHover: _isHovered, ), ), // user or workspace, setting BlocBuilder( builder: (context, state) => Container( height: HomeSizes.workspaceSectionHeight, padding: menuHorizontalInset - const EdgeInsets.only(right: 6), // if the workspaces are empty, show the user profile instead child: state.isCollabWorkspaceOn && state.workspaces.isNotEmpty ? SidebarWorkspace(userProfile: widget.userProfile) : SidebarUser(userProfile: widget.userProfile), ), ), if (FeatureFlag.search.isOn) ...[ const VSpace(6), Container( padding: menuHorizontalInset, height: HomeSizes.searchSectionHeight, child: const _SidebarSearchButton(), ), ], if (context .read() .state .currentWorkspace ?.role != AFRolePB.Guest) ...[ const VSpace(6.0), // new page button const SidebarNewPageButton(), ], // scrollable document list const VSpace(12.0), Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: ValueListenableBuilder( valueListenable: _scrollOffset, builder: (_, offset, child) => Opacity( opacity: offset > 0 ? 1 : 0, child: child, ), child: const FlowyDivider(), ), ), _renderFolderOrSpace(menuHorizontalInset), // trash Padding( padding: menuHorizontalInset + const EdgeInsets.symmetric(horizontal: 4.0), child: const FlowyDivider(), ), const VSpace(8), _renderUpgradeSpaceButton(menuHorizontalInset), _buildUpgradeApplicationButton(menuHorizontalInset), const VSpace(8), Padding( padding: menuHorizontalInset + const EdgeInsets.symmetric(horizontal: 4.0), child: const SidebarFooter(), ), const VSpace(14), ], ), ), ); } Widget _renderFolderOrSpace(EdgeInsets menuHorizontalInset) { final spaceState = context.read().state; final workspaceState = context.read().state; if (!spaceState.isInitialized) { return const SizedBox.shrink(); } // there's no space or the workspace is not collaborative, // show the folder section (Workspace, Private, Personal) // otherwise, show the space final sidebarSectionBloc = context.watch(); final containsSpace = sidebarSectionBloc.state.containsSpace; if (containsSpace && spaceState.spaces.isEmpty) { context.read().add(const SpaceEvent.didReceiveSpaceUpdate()); } return !containsSpace || spaceState.spaces.isEmpty || !workspaceState.isCollabWorkspaceOn ? Expanded( child: Padding( padding: menuHorizontalInset - const EdgeInsets.only(right: 6), child: SingleChildScrollView( padding: const EdgeInsets.only(right: 6), controller: _scrollController, physics: const ClampingScrollPhysics(), child: SidebarFolder( userProfile: widget.userProfile, isHoverEnabled: !_isScrolling, ), ), ), ) : Expanded( child: Padding( padding: menuHorizontalInset - const EdgeInsets.only(right: 6), child: FlowyScrollbar( controller: _scrollController, child: SingleChildScrollView( padding: const EdgeInsets.only(right: 6), controller: _scrollController, physics: const ClampingScrollPhysics(), child: SidebarSpace( userProfile: widget.userProfile, isHoverEnabled: !_isScrolling, ), ), ), ), ); } Widget _renderUpgradeSpaceButton(EdgeInsets menuHorizontalInset) { final spaceState = context.watch().state; final workspaceState = context.read().state; return !spaceState.shouldShowUpgradeDialog || !workspaceState.isCollabWorkspaceOn ? const SizedBox.shrink() : Padding( padding: menuHorizontalInset + const EdgeInsets.only( left: 4.0, right: 4.0, top: 8.0, ), child: const SpaceMigration(), ); } Widget _buildUpgradeApplicationButton(EdgeInsets menuHorizontalInset) { return ValueListenableBuilder( valueListenable: _muteUpdateButton, builder: (_, mute, child) { if (mute) { return const SizedBox.shrink(); } return ValueListenableBuilder( valueListenable: ApplicationInfo.latestVersionNotifier, builder: (_, latestVersion, child) { if (!ApplicationInfo.isUpdateAvailable) { return const SizedBox.shrink(); } return Padding( padding: menuHorizontalInset + const EdgeInsets.only( left: 4.0, right: 4.0, ), child: SidebarUpgradeApplicationButton( onUpdateButtonTap: () { versionChecker.checkForUpdate(); }, onCloseButtonTap: () { _muteUpdateButton.value = true; }, ), ); }, ); }, ); } void _onScrollChanged() { setState(() => _isScrolling = true); _scrollDebounce?.cancel(); _scrollDebounce = Timer(const Duration(milliseconds: 300), _setScrollStopped); _scrollOffset.value = _scrollController.offset; } void _setScrollStopped() { if (mounted) { setState(() => _isScrolling = false); } } } class _SidebarSearchButton extends StatelessWidget { const _SidebarSearchButton(); @override Widget build(BuildContext context) { return FlowyTooltip( richMessage: TextSpan( children: [ TextSpan( text: '${LocaleKeys.search_sidebarSearchIcon.tr()}\n', style: context.tooltipTextStyle(), ), TextSpan( text: Platform.isMacOS ? '⌘+P' : 'Ctrl+P', style: context .tooltipTextStyle() ?.copyWith(color: Theme.of(context).hintColor), ), ], ), child: FlowyButton( onTap: () { // exit editing mode when doing search to avoid the toolbar showing up EditorNotification.exitEditing().post(); final workspaceBloc = context.read(); final spaceBloc = context.read(); CommandPalette.of(context).toggle( workspaceBloc: workspaceBloc, spaceBloc: spaceBloc, ); }, leftIcon: const FlowySvg(FlowySvgs.search_s), iconPadding: 12.0, margin: const EdgeInsets.only(left: 8.0), text: FlowyText.regular(LocaleKeys.search_label.tr()), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/slider_menu_hover_trigger.dart ================================================ import 'package:flutter/material.dart'; class SliderMenuHoverTrigger extends StatelessWidget { const SliderMenuHoverTrigger({super.key}); @override Widget build(BuildContext context) { return Container( width: 50, color: Colors.black, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/_extension.dart ================================================ import 'package:appflowy/util/theme_extension.dart'; import 'package:flutter/material.dart'; extension SpacePermissionColorExtension on BuildContext { Color get enableBorderColor => Theme.of(this).isLightMode ? const Color(0x1E171717) : const Color(0xFF3A3F49); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/_extension.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CreateSpacePopup extends StatefulWidget { const CreateSpacePopup({super.key}); @override State createState() => _CreateSpacePopupState(); } class _CreateSpacePopupState extends State { String spaceName = LocaleKeys.space_defaultSpaceName.tr(); String? spaceIcon = kDefaultSpaceIconId; String? spaceIconColor = builtInSpaceColors.first; SpacePermission spacePermission = SpacePermission.publicToAll; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), width: 524, child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyText( LocaleKeys.space_createNewSpace.tr(), fontSize: 18.0, figmaLineHeight: 24.0, ), const VSpace(2.0), FlowyText( LocaleKeys.space_createSpaceDescription.tr(), fontSize: 14.0, fontWeight: FontWeight.w300, color: Theme.of(context).hintColor, figmaLineHeight: 18.0, maxLines: 2, ), const VSpace(16.0), SizedBox.square( dimension: 56, child: SpaceIconPopup( onIconChanged: (icon, iconColor) { spaceIcon = icon; spaceIconColor = iconColor; }, ), ), const VSpace(8.0), _SpaceNameTextField( onChanged: (value) => spaceName = value, onSubmitted: (value) { spaceName = value; _createSpace(); }, ), const VSpace(20.0), SpacePermissionSwitch( onPermissionChanged: (value) => spacePermission = value, ), const VSpace(20.0), SpaceCancelOrConfirmButton( confirmButtonName: LocaleKeys.button_create.tr(), onCancel: () => Navigator.of(context).pop(), onConfirm: () => _createSpace(), ), ], ), ); } void _createSpace() { context.read().add( SpaceEvent.create( name: spaceName, // fixme: space issue icon: spaceIcon!, iconColor: spaceIconColor!, permission: spacePermission, createNewPageByDefault: true, openAfterCreate: true, ), ); Navigator.of(context).pop(); } } class _SpaceNameTextField extends StatelessWidget { const _SpaceNameTextField({ required this.onChanged, required this.onSubmitted, }); final void Function(String name) onChanged; final void Function(String name) onSubmitted; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.regular( LocaleKeys.space_spaceName.tr(), fontSize: 14.0, color: Theme.of(context).hintColor, figmaLineHeight: 18.0, ), const VSpace(6.0), SizedBox( height: 40, child: FlowyTextField( hintText: LocaleKeys.space_spaceNamePlaceholder.tr(), onChanged: onChanged, onSubmitted: onSubmitted, enableBorderColor: context.enableBorderColor, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ManageSpacePopup extends StatefulWidget { const ManageSpacePopup({super.key}); @override State createState() => _ManageSpacePopupState(); } class _ManageSpacePopupState extends State { String? spaceName; String? spaceIcon; String? spaceIconColor; SpacePermission? spacePermission; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), width: 500, child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyText( LocaleKeys.space_manage.tr(), fontSize: 18.0, ), const VSpace(16.0), _SpaceNameTextField( onNameChanged: (name) => spaceName = name, onIconChanged: (icon, color) { spaceIcon = icon; spaceIconColor = color; }, ), const VSpace(16.0), SpacePermissionSwitch( spacePermission: context.read().state.currentSpace?.spacePermission, onPermissionChanged: (value) => spacePermission = value, ), const VSpace(16.0), SpaceCancelOrConfirmButton( confirmButtonName: LocaleKeys.button_save.tr(), onCancel: () => Navigator.of(context).pop(), onConfirm: () { context.read().add( SpaceEvent.update( name: spaceName, icon: spaceIcon, iconColor: spaceIconColor, permission: spacePermission, ), ); Navigator.of(context).pop(); }, ), ], ), ); } } class _SpaceNameTextField extends StatelessWidget { const _SpaceNameTextField({ required this.onNameChanged, required this.onIconChanged, }); final void Function(String name) onNameChanged; final void Function(String? icon, String? color) onIconChanged; @override Widget build(BuildContext context) { final space = context.read().state.currentSpace; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.regular( LocaleKeys.space_spaceName.tr(), fontSize: 14.0, color: Theme.of(context).hintColor, ), const VSpace(8.0), SizedBox( height: 40, child: Row( children: [ SizedBox.square( dimension: 40, child: SpaceIconPopup( space: space, cornerRadius: 12, icon: space?.spaceIcon, iconColor: space?.spaceIconColor, onIconChanged: onIconChanged, ), ), const HSpace(12), Expanded( child: SizedBox( height: 40, child: FlowyTextField( text: space?.name, onChanged: onNameChanged, ), ), ), ], ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/_extension.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; class SpacePermissionSwitch extends StatefulWidget { const SpacePermissionSwitch({ super.key, required this.onPermissionChanged, this.spacePermission, this.showArrow = false, }); final SpacePermission? spacePermission; final void Function(SpacePermission permission) onPermissionChanged; final bool showArrow; @override State createState() => _SpacePermissionSwitchState(); } class _SpacePermissionSwitchState extends State { late SpacePermission spacePermission = widget.spacePermission ?? SpacePermission.publicToAll; final popoverController = PopoverController(); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.regular( LocaleKeys.space_permission.tr(), fontSize: 14.0, color: Theme.of(context).hintColor, figmaLineHeight: 18.0, ), const VSpace(6.0), AppFlowyPopover( controller: popoverController, direction: PopoverDirection.bottomWithCenterAligned, constraints: const BoxConstraints(maxWidth: 500), offset: const Offset(0, 4), margin: EdgeInsets.zero, popupBuilder: (_) => _buildPermissionButtons(), child: DecoratedBox( decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: BorderSide(color: context.enableBorderColor), borderRadius: BorderRadius.circular(10), ), ), child: SpacePermissionButton( showArrow: true, permission: spacePermission, ), ), ), ], ); } Widget _buildPermissionButtons() { return SizedBox( width: 452, child: Column( mainAxisSize: MainAxisSize.min, children: [ SpacePermissionButton( permission: SpacePermission.publicToAll, onTap: () => _onPermissionChanged(SpacePermission.publicToAll), ), SpacePermissionButton( permission: SpacePermission.private, onTap: () => _onPermissionChanged(SpacePermission.private), ), ], ), ); } void _onPermissionChanged(SpacePermission permission) { widget.onPermissionChanged(permission); setState(() { spacePermission = permission; }); popoverController.close(); } } class SpacePermissionButton extends StatelessWidget { const SpacePermissionButton({ super.key, required this.permission, this.onTap, this.showArrow = false, }); final SpacePermission permission; final VoidCallback? onTap; final bool showArrow; @override Widget build(BuildContext context) { final (title, desc, icon) = switch (permission) { SpacePermission.publicToAll => ( LocaleKeys.space_publicPermission.tr(), LocaleKeys.space_publicPermissionDescription.tr(), FlowySvgs.space_permission_public_s ), SpacePermission.private => ( LocaleKeys.space_privatePermission.tr(), LocaleKeys.space_privatePermissionDescription.tr(), FlowySvgs.space_permission_private_s ), }; return FlowyButton( margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), radius: showArrow ? BorderRadius.circular(10) : BorderRadius.zero, iconPadding: 16.0, leftIcon: FlowySvg(icon), leftIconSize: const Size.square(20), rightIcon: showArrow ? const FlowySvg(FlowySvgs.space_permission_dropdown_s) : null, borderColor: Theme.of(context).isLightMode ? const Color(0x1E171717) : const Color(0xFF3A3F49), text: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.regular(title), const VSpace(4.0), FlowyText.regular( desc, fontSize: 12.0, color: Theme.of(context).hintColor, ), ], ), onTap: onTap, ); } } class SpaceCancelOrConfirmButton extends StatelessWidget { const SpaceCancelOrConfirmButton({ super.key, required this.onCancel, required this.onConfirm, required this.confirmButtonName, this.confirmButtonColor, this.confirmButtonBuilder, }); final VoidCallback onCancel; final VoidCallback onConfirm; final String confirmButtonName; final Color? confirmButtonColor; final WidgetBuilder? confirmButtonBuilder; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ AFOutlinedTextButton.normal( size: UniversalPlatform.isDesktop ? AFButtonSize.m : AFButtonSize.l, text: LocaleKeys.button_cancel.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), onTap: onCancel, ), const HSpace(12.0), if (confirmButtonBuilder != null) ...[ confirmButtonBuilder!(context), ] else ...[ DecoratedBox( decoration: ShapeDecoration( color: confirmButtonColor ?? Theme.of(context).colorScheme.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), radius: BorderRadius.circular(8), text: FlowyText.regular( confirmButtonName, lineHeight: 1.0, color: Theme.of(context).colorScheme.onPrimary, ), onTap: onConfirm, ), ), ], ], ); } } class SpaceOkButton extends StatelessWidget { const SpaceOkButton({ super.key, required this.onConfirm, required this.confirmButtonName, this.confirmButtonColor, }); final VoidCallback onConfirm; final String confirmButtonName; final Color? confirmButtonColor; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ PrimaryRoundedButton( text: confirmButtonName, margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), radius: 8.0, onTap: onConfirm, ), ], ); } } enum ConfirmPopupStyle { onlyOk, cancelAndOk, } class ConfirmPopupColor { static Color titleColor(BuildContext context) { return AppFlowyTheme.of(context).textColorScheme.primary; } static Color descriptionColor(BuildContext context) { return AppFlowyTheme.of(context).textColorScheme.primary; } } class ConfirmPopup extends StatefulWidget { const ConfirmPopup({ super.key, this.style = ConfirmPopupStyle.cancelAndOk, required this.title, required this.description, required this.onConfirm, this.onCancel, this.confirmLabel, this.titleStyle, this.descriptionStyle, this.confirmButtonColor, this.confirmButtonBuilder, this.child, this.closeOnAction = true, this.showCloseButton = true, this.enableKeyboardListener = true, }); final String title; final TextStyle? titleStyle; final String description; final TextStyle? descriptionStyle; final void Function(BuildContext context) onConfirm; final VoidCallback? onCancel; final Color? confirmButtonColor; final ConfirmPopupStyle style; /// The label of the confirm button. /// /// Defaults to 'Delete' for [ConfirmPopupStyle.cancelAndOk] style. /// Defaults to 'Ok' for [ConfirmPopupStyle.onlyOk] style. /// final String? confirmLabel; /// Allows to add a child to the popup. /// /// This is useful when you want to add more content to the popup. /// The child will be placed below the description. /// final Widget? child; /// Decides whether the popup should be closed when the confirm button is clicked. /// Defaults to true. /// final bool closeOnAction; /// Show close button. /// Defaults to true. /// final bool showCloseButton; /// Enable keyboard listener. /// Defaults to true. /// final bool enableKeyboardListener; /// Allows to build a custom confirm button. /// final WidgetBuilder? confirmButtonBuilder; @override State createState() => _ConfirmPopupState(); } class _ConfirmPopupState extends State { final focusNode = FocusNode(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return KeyboardListener( focusNode: focusNode, autofocus: true, onKeyEvent: (event) { if (widget.enableKeyboardListener) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { Navigator.of(context).pop(); } else if (event is KeyUpEvent && event.logicalKey == LogicalKeyboardKey.enter) { widget.onConfirm(context); if (widget.closeOnAction) { Navigator.of(context).pop(); } } } }, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(theme.borderRadius.xl), color: AppFlowyTheme.of(context).surfaceColorScheme.primary, ), padding: EdgeInsets.symmetric( horizontal: theme.spacing.xxl, vertical: theme.spacing.xxl, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTitle(), if (widget.description.isNotEmpty) ...[ VSpace(theme.spacing.l), _buildDescription(), ], if (widget.child != null) ...[ const VSpace(12), widget.child!, ], VSpace(theme.spacing.xxl), _buildStyledButton(context), ], ), ), ); } Widget _buildTitle() { final theme = AppFlowyTheme.of(context); return Row( children: [ Expanded( child: Text( widget.title, style: widget.titleStyle ?? theme.textStyle.heading4.prominent( color: ConfirmPopupColor.titleColor(context), ), overflow: TextOverflow.ellipsis, ), ), const HSpace(6.0), if (widget.showCloseButton) ...[ AFGhostButton.normal( size: AFButtonSize.s, padding: EdgeInsets.all(theme.spacing.xs), onTap: () => Navigator.of(context).pop(), builder: (context, isHovering, disabled) => FlowySvg( FlowySvgs.password_close_m, size: const Size.square(20), ), ), ], ], ); } Widget _buildDescription() { if (widget.description.isEmpty) { return const SizedBox.shrink(); } final theme = AppFlowyTheme.of(context); return Text( widget.description, style: widget.descriptionStyle ?? theme.textStyle.body.standard( color: ConfirmPopupColor.descriptionColor(context), ), maxLines: 5, ); } Widget _buildStyledButton(BuildContext context) { switch (widget.style) { case ConfirmPopupStyle.onlyOk: if (widget.confirmButtonBuilder != null) { return widget.confirmButtonBuilder!(context); } return SpaceOkButton( onConfirm: () { widget.onConfirm(context); if (widget.closeOnAction) { Navigator.of(context).pop(); } }, confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(), confirmButtonColor: widget.confirmButtonColor ?? Theme.of(context).colorScheme.primary, ); case ConfirmPopupStyle.cancelAndOk: return SpaceCancelOrConfirmButton( onCancel: () { widget.onCancel?.call(); Navigator.of(context).pop(); }, onConfirm: () { widget.onConfirm(context); if (widget.closeOnAction) { Navigator.of(context).pop(); } }, confirmButtonName: widget.confirmLabel ?? LocaleKeys.space_delete.tr(), confirmButtonColor: widget.confirmButtonColor ?? Theme.of(context).colorScheme.error, confirmButtonBuilder: widget.confirmButtonBuilder, ); } } } class SpacePopup extends StatelessWidget { const SpacePopup({ super.key, this.height, this.useIntrinsicWidth = true, this.expand = false, required this.showCreateButton, required this.child, }); final bool showCreateButton; final bool useIntrinsicWidth; final bool expand; final double? height; final Widget child; @override Widget build(BuildContext context) { return SizedBox( height: height ?? HomeSizes.workspaceSectionHeight, child: AppFlowyPopover( constraints: const BoxConstraints(maxWidth: 260), direction: PopoverDirection.bottomWithLeftAligned, clickHandler: PopoverClickHandler.gestureDetector, offset: const Offset(0, 4), popupBuilder: (_) => BlocProvider.value( value: context.read(), child: SidebarSpaceMenu( showCreateButton: showCreateButton, ), ), child: FlowyButton( useIntrinsicWidth: useIntrinsicWidth, expand: expand, margin: const EdgeInsets.only(left: 3.0, right: 4.0), iconPadding: 10.0, text: child, ), ), ); } } class CurrentSpace extends StatelessWidget { const CurrentSpace({ super.key, this.onTapBlankArea, required this.space, this.isHovered = false, }); final ViewPB space; final VoidCallback? onTapBlankArea; final bool isHovered; @override Widget build(BuildContext context) { final child = Row( mainAxisSize: MainAxisSize.min, children: [ SpaceIcon( dimension: 22, space: space, svgSize: 12, cornerRadius: 8.0, ), const HSpace(10), Flexible( child: FlowyText.medium( space.name, fontSize: 14.0, figmaLineHeight: 18.0, overflow: TextOverflow.ellipsis, color: isHovered ? Theme.of(context).colorScheme.onSurface : null, ), ), const HSpace(4.0), FlowySvg( context.read().state.isExpanded ? FlowySvgs.workspace_drop_down_menu_show_s : FlowySvgs.workspace_drop_down_menu_hide_s, color: isHovered ? Theme.of(context).colorScheme.onSurface : null, ), ], ); if (onTapBlankArea != null) { return Row( children: [ Expanded( flex: 2, child: FlowyHover( child: Padding( padding: const EdgeInsets.all(2.0), child: child, ), ), ), Expanded( child: FlowyTooltip( message: LocaleKeys.space_movePageToSpace.tr(), child: GestureDetector( onTap: onTapBlankArea, ), ), ), ], ); } return child; } } class SpacePages extends StatelessWidget { const SpacePages({ super.key, required this.space, required this.isHovered, required this.isExpandedNotifier, required this.onSelected, this.rightIconsBuilder, this.disableSelectedStatus = false, this.onTertiarySelected, this.shouldIgnoreView, }); final ViewPB space; final ValueNotifier isHovered; final PropertyValueNotifier isExpandedNotifier; final bool disableSelectedStatus; final ViewItemRightIconsBuilder? rightIconsBuilder; final ViewItemOnSelected onSelected; final ViewItemOnSelected? onTertiarySelected; final IgnoreViewType Function(ViewPB view)? shouldIgnoreView; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => ViewBloc(view: space)..add(const ViewEvent.initial()), child: BlocBuilder( builder: (context, state) { // filter the child views that should be ignored List childViews = state.view.childViews; if (shouldIgnoreView != null) { childViews = childViews .where((v) => shouldIgnoreView!(v) != IgnoreViewType.hide) .toList(); } return Column( mainAxisSize: MainAxisSize.min, children: childViews .map( (view) => ViewItem( key: ValueKey('${space.id} ${view.id}'), spaceType: space.spacePermission == SpacePermission.publicToAll ? FolderSpaceType.public : FolderSpaceType.private, isFirstChild: view.id == childViews.first.id, view: view, level: 0, leftPadding: HomeSpaceViewSizes.leftPadding, isFeedback: false, isHovered: isHovered, enableRightClickContext: !disableSelectedStatus, disableSelectedStatus: disableSelectedStatus, isExpandedNotifier: isExpandedNotifier, rightIconsBuilder: rightIconsBuilder, onSelected: onSelected, onTertiarySelected: onTertiarySelected, shouldIgnoreView: shouldIgnoreView, ), ) .toList(), ); }, ), ); } } class SpaceSearchField extends StatefulWidget { const SpaceSearchField({ super.key, required this.width, required this.onSearch, }); final double width; final void Function(BuildContext context, String text) onSearch; @override State createState() => _SpaceSearchFieldState(); } class _SpaceSearchFieldState extends State { final focusNode = FocusNode(); @override void initState() { super.initState(); focusNode.requestFocus(); } @override void dispose() { focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( height: 30, width: widget.width, clipBehavior: Clip.antiAlias, decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: const BorderSide( width: 1.20, strokeAlign: BorderSide.strokeAlignOutside, color: Color(0xFF00BCF0), ), borderRadius: BorderRadius.circular(8), ), ), child: CupertinoSearchTextField( onChanged: (text) => widget.onSearch(context, text), padding: EdgeInsets.zero, focusNode: focusNode, placeholder: LocaleKeys.search_label.tr(), prefixIcon: const FlowySvg(FlowySvgs.magnifier_s), prefixInsets: const EdgeInsets.only(left: 12.0, right: 8.0), suffixIcon: const Icon(Icons.close), suffixInsets: const EdgeInsets.only(right: 8.0), itemSize: 16.0, decoration: const BoxDecoration( color: Colors.transparent, ), placeholderStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, fontWeight: FontWeight.w400, ), style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w400, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart ================================================ import 'package:appflowy/features/shared_section/presentation/shared_section.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart' hide AFRolePB; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; class SidebarSpace extends StatelessWidget { const SidebarSpace({ super.key, this.isHoverEnabled = true, required this.userProfile, }); final bool isHoverEnabled; final UserProfilePB userProfile; @override Widget build(BuildContext context) { final currentWorkspace = context.watch().state.currentWorkspace; final currentWorkspaceId = currentWorkspace?.workspaceId ?? ''; // only show spaces if the user role is member or owner final currentUserRole = currentWorkspace?.role; final shouldShowSpaces = [ AFRolePB.Member, AFRolePB.Owner, ].contains(currentUserRole); return ValueListenableBuilder( valueListenable: getIt().notifier, builder: (_, __, ___) => Provider.value( value: userProfile, child: Column( children: [ const VSpace(4.0), // favorite BlocBuilder( builder: (context, state) { if (state.views.isEmpty) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(bottom: 16.0), child: FavoriteFolder( views: state.views.map((e) => e.item).toList(), ), ); }, ), // shared if (FeatureFlag.sharedSection.isOn) ...[ SharedSection( key: ValueKey(currentWorkspaceId), workspaceId: currentWorkspaceId, ), ], // spaces if (shouldShowSpaces) ...[ // spaces const _Space(), ], const VSpace(200), ], ), ), ); } } class _Space extends StatefulWidget { const _Space(); @override State<_Space> createState() => _SpaceState(); } class _SpaceState extends State<_Space> { final isHovered = ValueNotifier(false); final isExpandedNotifier = PropertyValueNotifier(false); @override void initState() { super.initState(); switchToTheNextSpace.addListener(_switchToNextSpace); switchToSpaceNotifier.addListener(_switchToSpace); } @override void dispose() { switchToTheNextSpace.removeListener(_switchToNextSpace); isHovered.dispose(); isExpandedNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final currentWorkspace = context.watch().state.currentWorkspace; return BlocBuilder( builder: (context, state) { if (state.spaces.isEmpty) { return const SizedBox.shrink(); } final currentSpace = state.currentSpace ?? state.spaces.first; return Column( children: [ SidebarSpaceHeader( isExpanded: state.isExpanded, space: currentSpace, onAdded: (layout) => _showCreatePagePopup( context, currentSpace, layout, ), onCreateNewSpace: () => _showCreateSpaceDialog(context), onCollapseAllPages: () => isExpandedNotifier.value = true, ), if (state.isExpanded) MouseRegion( onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: SpacePages( key: ValueKey( Object.hashAll([ currentWorkspace?.workspaceId ?? '', currentSpace.id, ]), ), isExpandedNotifier: isExpandedNotifier, space: currentSpace, isHovered: isHovered, onSelected: (context, view) { if (HardwareKeyboard.instance.isControlPressed) { context.read().openTab(view); } context.read().openPlugin(view); }, onTertiarySelected: (context, view) => context.read().openTab(view), ), ), ], ); }, ); } void _showCreateSpaceDialog(BuildContext context) { final spaceBloc = context.read(); showDialog( context: context, builder: (_) => Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: BlocProvider.value( value: spaceBloc, child: const CreateSpacePopup(), ), ), ); } void _showCreatePagePopup( BuildContext context, ViewPB space, ViewLayoutPB layout, ) { context.read().add( SpaceEvent.createPage( name: '', layout: layout, index: 0, openAfterCreate: true, ), ); context.read().add(SpaceEvent.expand(space, true)); } void _switchToNextSpace() { context.read().add(const SpaceEvent.switchToNextSpace()); } void _switchToSpace() { if (!mounted || !context.mounted) { return; } final space = switchToSpaceNotifier.value; if (space == null) { return; } context.read().add(SpaceEvent.open(space: space)); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart' hide Icon; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarSpaceHeader extends StatefulWidget { const SidebarSpaceHeader({ super.key, required this.space, required this.onAdded, required this.onCreateNewSpace, required this.onCollapseAllPages, required this.isExpanded, }); final ViewPB space; final void Function(ViewLayoutPB layout) onAdded; final VoidCallback onCreateNewSpace; final VoidCallback onCollapseAllPages; final bool isExpanded; @override State createState() => _SidebarSpaceHeaderState(); } class _SidebarSpaceHeaderState extends State { final isHovered = ValueNotifier(false); final onEditing = ValueNotifier(false); @override void dispose() { isHovered.dispose(); onEditing.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: isHovered, builder: (context, onHover, child) { return MouseRegion( onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: GestureDetector( onTap: () => context .read() .add(SpaceEvent.expand(widget.space, !widget.isExpanded)), child: _buildSpaceName(onHover), ), ); }, ); } Widget _buildSpaceName(bool isHovered) { return Container( height: HomeSizes.workspaceSectionHeight, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(6)), color: isHovered ? Theme.of(context).colorScheme.secondary : null, ), child: Stack( alignment: Alignment.center, children: [ ValueListenableBuilder( valueListenable: onEditing, builder: (context, onEditing, child) => Positioned( left: 3, top: 3, bottom: 3, right: isHovered || onEditing ? 88 : 0, child: SpacePopup( showCreateButton: true, child: _buildChild(isHovered), ), ), ), Positioned( right: 4, child: _buildRightIcon(isHovered), ), ], ), ); } Widget _buildChild(bool isHovered) { final textSpan = TextSpan( children: [ TextSpan( text: '${LocaleKeys.space_quicklySwitch.tr()}\n', style: context.tooltipTextStyle(), ), TextSpan( text: Platform.isMacOS ? '⌘+O' : 'Ctrl+O', style: context .tooltipTextStyle() ?.copyWith(color: Theme.of(context).hintColor), ), ], ); return FlowyTooltip( richMessage: textSpan, child: CurrentSpace( space: widget.space, isHovered: isHovered, ), ); } Widget _buildRightIcon(bool isHovered) { return ValueListenableBuilder( valueListenable: onEditing, builder: (context, onEditing, child) => Opacity( opacity: isHovered || onEditing ? 1 : 0, child: Row( children: [ SpaceMorePopup( space: widget.space, onEditing: (value) => this.onEditing.value = value, onAction: _onAction, isHovered: isHovered, ), const HSpace(8.0), FlowyTooltip( message: LocaleKeys.sideBar_addAPage.tr(), child: ViewAddButton( parentViewId: widget.space.id, onEditing: (_) {}, onSelected: ( pluginBuilder, name, initialDataBytes, openAfterCreated, createNewView, ) { if (pluginBuilder.layoutType == ViewLayoutPB.Document) { name = ''; } if (createNewView) { widget.onAdded(pluginBuilder.layoutType!); } }, isHovered: isHovered, ), ), ], ), ), ); } Future _onAction(SpaceMoreActionType type, dynamic data) async { switch (type) { case SpaceMoreActionType.rename: await _showRenameDialog(); break; case SpaceMoreActionType.changeIcon: if (data is SelectedEmojiIconResult) { if (data.type == FlowyIconType.icon) { try { final iconsData = IconsData.fromJson(jsonDecode(data.emoji)); context.read().add( SpaceEvent.changeIcon( icon: '${iconsData.groupName}/${iconsData.iconName}', iconColor: iconsData.color, ), ); } on FormatException catch (e) { context .read() .add(const SpaceEvent.changeIcon(icon: '')); Log.warn('SidebarSpaceHeader changeIcon error:$e'); } } } break; case SpaceMoreActionType.manage: _showManageSpaceDialog(context); break; case SpaceMoreActionType.addNewSpace: widget.onCreateNewSpace(); break; case SpaceMoreActionType.collapseAllPages: widget.onCollapseAllPages(); break; case SpaceMoreActionType.delete: _showDeleteSpaceDialog(context); break; case SpaceMoreActionType.duplicate: context.read().add(const SpaceEvent.duplicate()); break; case SpaceMoreActionType.divider: break; } } Future _showRenameDialog() async { await showAFTextFieldDialog( context: context, title: LocaleKeys.space_rename.tr(), initialValue: widget.space.name, hintText: LocaleKeys.space_spaceName.tr(), onConfirm: (name) { context.read().add( SpaceEvent.rename( space: widget.space, name: name, ), ); }, ); } void _showManageSpaceDialog(BuildContext context) { final spaceBloc = context.read(); showDialog( context: context, builder: (_) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: BlocProvider.value( value: spaceBloc, child: const ManageSpacePopup(), ), ); }, ); } void _showDeleteSpaceDialog(BuildContext context) { final spaceBloc = context.read(); final space = spaceBloc.state.currentSpace; final name = space != null ? space.name : ''; showConfirmDeletionDialog( context: context, name: name, description: LocaleKeys.space_deleteConfirmationDescription.tr(), onConfirm: () { context.read().add(const SpaceEvent.delete(null)); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarSpaceMenu extends StatelessWidget { const SidebarSpaceMenu({ super.key, required this.showCreateButton, }); final bool showCreateButton; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Column( mainAxisSize: MainAxisSize.min, children: [ const VSpace(4.0), for (final space in state.spaces) SizedBox( height: HomeSpaceViewSizes.viewHeight, child: SidebarSpaceMenuItem( space: space, isSelected: state.currentSpace?.id == space.id, ), ), if (showCreateButton) ...[ const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), child: FlowyDivider(), ), const SizedBox( height: HomeSpaceViewSizes.viewHeight, child: _CreateSpaceButton(), ), ], ], ); }, ); } } class SidebarSpaceMenuItem extends StatelessWidget { const SidebarSpaceMenuItem({ super.key, required this.space, required this.isSelected, }); final ViewPB space; final bool isSelected; @override Widget build(BuildContext context) { return FlowyButton( text: Row( children: [ Flexible( child: FlowyText.regular( space.name, overflow: TextOverflow.ellipsis, ), ), const HSpace(6.0), if (space.spacePermission == SpacePermission.private) FlowyTooltip( message: LocaleKeys.space_privatePermissionDescription.tr(), child: const FlowySvg( FlowySvgs.space_lock_s, ), ), ], ), iconPadding: 10, leftIcon: SpaceIcon( dimension: 20, space: space, svgSize: 12.0, cornerRadius: 6.0, ), leftIconSize: const Size.square(20), rightIcon: isSelected ? const FlowySvg( FlowySvgs.workspace_selected_s, blendMode: null, ) : null, onTap: () { context.read().add(SpaceEvent.open(space: space)); PopoverContainer.of(context).close(); }, ); } } class _CreateSpaceButton extends StatelessWidget { const _CreateSpaceButton(); @override Widget build(BuildContext context) { return FlowyButton( text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()), iconPadding: 10, leftIcon: const FlowySvg( FlowySvgs.space_add_s, ), onTap: () { PopoverContainer.of(context).close(); _showCreateSpaceDialog(context); }, ); } void _showCreateSpaceDialog(BuildContext context) { final spaceBloc = context.read(); showDialog( context: context, builder: (_) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: BlocProvider.value( value: spaceBloc, child: const CreateSpacePopup(), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_action_type.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum SpaceMoreActionType { delete, rename, changeIcon, collapseAllPages, divider, addNewSpace, manage, duplicate, } extension ViewMoreActionTypeExtension on SpaceMoreActionType { String get name { switch (this) { case SpaceMoreActionType.delete: return LocaleKeys.space_delete.tr(); case SpaceMoreActionType.rename: return LocaleKeys.space_rename.tr(); case SpaceMoreActionType.changeIcon: return LocaleKeys.space_changeIcon.tr(); case SpaceMoreActionType.collapseAllPages: return LocaleKeys.space_collapseAllSubPages.tr(); case SpaceMoreActionType.addNewSpace: return LocaleKeys.space_addNewSpace.tr(); case SpaceMoreActionType.manage: return LocaleKeys.space_manage.tr(); case SpaceMoreActionType.duplicate: return LocaleKeys.space_duplicate.tr(); case SpaceMoreActionType.divider: return ''; } } FlowySvgData get leftIconSvg { switch (this) { case SpaceMoreActionType.delete: return FlowySvgs.trash_s; case SpaceMoreActionType.rename: return FlowySvgs.view_item_rename_s; case SpaceMoreActionType.changeIcon: return FlowySvgs.change_icon_s; case SpaceMoreActionType.collapseAllPages: return FlowySvgs.collapse_all_page_s; case SpaceMoreActionType.addNewSpace: return FlowySvgs.space_add_s; case SpaceMoreActionType.manage: return FlowySvgs.space_manage_s; case SpaceMoreActionType.duplicate: return FlowySvgs.duplicate_s; case SpaceMoreActionType.divider: throw UnsupportedError('Divider does not have an icon'); } } Widget get rightIcon { switch (this) { case SpaceMoreActionType.changeIcon: case SpaceMoreActionType.rename: case SpaceMoreActionType.collapseAllPages: case SpaceMoreActionType.divider: case SpaceMoreActionType.delete: case SpaceMoreActionType.addNewSpace: case SpaceMoreActionType.manage: case SpaceMoreActionType.duplicate: return const SizedBox.shrink(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SpaceIcon extends StatelessWidget { const SpaceIcon({ super.key, required this.dimension, this.textDimension, this.cornerRadius = 0, required this.space, this.svgSize, }); final double dimension; final double? textDimension; final double cornerRadius; final ViewPB space; final double? svgSize; @override Widget build(BuildContext context) { final (icon, color) = _buildSpaceIcon(context); return ClipRRect( borderRadius: BorderRadius.circular(cornerRadius), child: Container( width: dimension, height: dimension, color: color, child: Center( child: icon, ), ), ); } (Widget, Color?) _buildSpaceIcon(BuildContext context) { final spaceIcon = space.spaceIcon; if (spaceIcon == null || spaceIcon.isEmpty == true) { // if space icon is null, use the first character of space name as icon return _buildEmptySpaceIcon(context); } else { return _buildCustomSpaceIcon(context); } } (Widget, Color?) _buildEmptySpaceIcon(BuildContext context) { final name = space.name.isNotEmpty ? space.name.capitalize()[0] : ''; final icon = FlowyText.medium( name, color: Theme.of(context).colorScheme.surface, fontSize: svgSize, figmaLineHeight: textDimension ?? dimension, ); Color? color; try { final defaultColor = builtInSpaceColors.firstOrNull; if (defaultColor != null) { color = Color(int.parse(defaultColor)); } } catch (e) { Log.error('Failed to parse default space icon color: $e'); } return (icon, color); } (Widget, Color?) _buildCustomSpaceIcon(BuildContext context) { final spaceIconColor = space.spaceIconColor; final svg = space.buildSpaceIconSvg( context, size: svgSize != null ? Size.square(svgSize!) : null, ); Widget icon; if (svg == null) { icon = const SizedBox.shrink(); } else { icon = svgSize == null || space.spaceIcon?.contains(ViewExtKeys.spaceIconKey) == true ? svg : SizedBox.square(dimension: svgSize!, child: svg); } Color color = Colors.transparent; if (spaceIconColor != null && spaceIconColor.isNotEmpty) { try { color = Color(int.parse(spaceIconColor)); } catch (e) { Log.error( 'Failed to parse space icon color: $e, value: $spaceIconColor', ); } } return (icon, color); } } const kDefaultSpaceIconId = 'interface_essential/home-3'; class DefaultSpaceIcon extends StatelessWidget { const DefaultSpaceIcon({ super.key, required this.dimension, required this.iconDimension, this.cornerRadius = 0, }); final double dimension; final double cornerRadius; final double iconDimension; @override Widget build(BuildContext context) { final svgContent = kIconGroups?.findSvgContent( kDefaultSpaceIconId, ); final Widget svg; if (svgContent != null) { svg = FlowySvg.string( svgContent, size: Size.square(iconDimension), color: Theme.of(context).colorScheme.surface, ); } else { svg = FlowySvg( FlowySvgData('assets/flowy_icons/16x/${builtInSpaceIcons.first}.svg'), color: Theme.of(context).colorScheme.surface, size: Size.square(iconDimension), ); } final color = Color(int.parse(builtInSpaceColors.first)); return ClipRRect( borderRadius: BorderRadius.circular(cornerRadius), child: Container( width: dimension, height: dimension, color: color, child: Center( child: svg, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart ================================================ import 'dart:convert'; import 'dart:math'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart' hide Icon; final builtInSpaceColors = [ '0xFFA34AFD', '0xFFFB006D', '0xFF00C8FF', '0xFFFFBA00', '0xFFF254BC', '0xFF2AC985', '0xFFAAD93D', '0xFF535CE4', '0xFF808080', '0xFFD2515F', '0xFF409BF8', '0xFFFF8933', ]; String generateRandomSpaceColor() { final random = Random(); return builtInSpaceColors[random.nextInt(builtInSpaceColors.length)]; } final builtInSpaceIcons = List.generate(15, (index) => 'space_icon_${index + 1}'); class SpaceIconPopup extends StatefulWidget { const SpaceIconPopup({ super.key, this.icon, this.iconColor, this.cornerRadius = 16, this.space, required this.onIconChanged, }); final String? icon; final String? iconColor; final ViewPB? space; final void Function(String? icon, String? color) onIconChanged; final double cornerRadius; @override State createState() => _SpaceIconPopupState(); } class _SpaceIconPopupState extends State { late ValueNotifier selectedIcon = ValueNotifier( widget.icon, ); late ValueNotifier selectedColor = ValueNotifier( widget.iconColor ?? builtInSpaceColors.first, ); @override void dispose() { selectedColor.dispose(); selectedIcon.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AppFlowyPopover( offset: const Offset(0, 4), constraints: BoxConstraints.loose(const Size(360, 432)), margin: const EdgeInsets.all(0), direction: PopoverDirection.bottomWithCenterAligned, child: _buildPreview(), popupBuilder: (context) { return FlowyIconEmojiPicker( tabs: const [PickerTabType.icon], onSelectedEmoji: (r) { if (r.type == FlowyIconType.icon) { try { final iconsData = IconsData.fromJson(jsonDecode(r.emoji)); final color = iconsData.color; selectedIcon.value = '${iconsData.groupName}/${iconsData.iconName}'; if (color != null) { selectedColor.value = color; } widget.onIconChanged(selectedIcon.value, selectedColor.value); } on FormatException catch (e) { selectedIcon.value = ''; widget.onIconChanged(selectedIcon.value, selectedColor.value); Log.warn('SpaceIconPopup onSelectedEmoji error:$e'); } } PopoverContainer.of(context).close(); }, ); }, ); } Widget _buildPreview() { bool onHover = false; return StatefulBuilder( builder: (context, setState) { return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (event) => setState(() => onHover = true), onExit: (event) => setState(() => onHover = false), child: ValueListenableBuilder( valueListenable: selectedColor, builder: (_, color, __) { return ValueListenableBuilder( valueListenable: selectedIcon, builder: (_, value, __) { Widget child; if (value == null) { if (widget.space == null) { child = DefaultSpaceIcon( cornerRadius: widget.cornerRadius, dimension: 32, iconDimension: 32, ); } else { child = SpaceIcon( dimension: 32, space: widget.space!, svgSize: 24, cornerRadius: widget.cornerRadius, ); } } else if (value.contains('space_icon')) { child = ClipRRect( borderRadius: BorderRadius.circular(widget.cornerRadius), child: Container( color: Color(int.parse(color)), child: Align( child: FlowySvg( FlowySvgData('assets/flowy_icons/16x/$value.svg'), size: const Size.square(42), color: Theme.of(context).colorScheme.surface, ), ), ), ); } else { final content = kIconGroups?.findSvgContent(value); if (content == null) { child = const SizedBox.shrink(); } else { child = ClipRRect( borderRadius: BorderRadius.circular(widget.cornerRadius), child: Container( color: Color(int.parse(color)), child: Align( child: FlowySvg.string( content, size: const Size.square(24), color: Theme.of(context).colorScheme.surface, ), ), ), ); } } if (onHover) { return Stack( children: [ Positioned.fill( child: Opacity(opacity: 0.2, child: child), ), const Center( child: FlowySvg( FlowySvgs.view_item_rename_s, size: Size.square(20), ), ), ], ); } return child; }, ); }, ), ); }, ); } } class SpaceIconPicker extends StatefulWidget { const SpaceIconPicker({ super.key, required this.onIconChanged, this.skipFirstNotification = false, this.icon, this.iconColor, }); final bool skipFirstNotification; final void Function(String icon, String color) onIconChanged; final String? icon; final String? iconColor; @override State createState() => _SpaceIconPickerState(); } class _SpaceIconPickerState extends State { late ValueNotifier selectedColor = ValueNotifier(widget.iconColor ?? builtInSpaceColors.first); late ValueNotifier selectedIcon = ValueNotifier(widget.icon ?? builtInSpaceIcons.first); @override void initState() { super.initState(); if (!widget.skipFirstNotification) { widget.onIconChanged(selectedIcon.value, selectedColor.value); } selectedColor.addListener(_onColorChanged); selectedIcon.addListener(_onIconChanged); } void _onColorChanged() { widget.onIconChanged(selectedIcon.value, selectedColor.value); } void _onIconChanged() { widget.onIconChanged(selectedIcon.value, selectedColor.value); } @override void dispose() { selectedColor.removeListener(_onColorChanged); selectedColor.dispose(); selectedIcon.removeListener(_onIconChanged); selectedIcon.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ FlowyText.regular( LocaleKeys.space_spaceIconBackground.tr(), color: Theme.of(context).hintColor, ), const VSpace(10.0), _Colors( selectedColor: selectedColor.value, onColorSelected: (color) => selectedColor.value = color, ), const VSpace(12.0), FlowyText.regular( LocaleKeys.space_spaceIcon.tr(), color: Theme.of(context).hintColor, ), const VSpace(10.0), ValueListenableBuilder( valueListenable: selectedColor, builder: (_, value, ___) => _Icons( selectedColor: value, selectedIcon: selectedIcon.value, onIconSelected: (icon) => selectedIcon.value = icon, ), ), ], ); } } class _Colors extends StatefulWidget { const _Colors({ required this.selectedColor, required this.onColorSelected, }); final String selectedColor; final void Function(String color) onColorSelected; @override State<_Colors> createState() => _ColorsState(); } class _ColorsState extends State<_Colors> { late String selectedColor = widget.selectedColor; @override Widget build(BuildContext context) { return GridView.count( shrinkWrap: true, crossAxisCount: 6, mainAxisSpacing: 4.0, children: builtInSpaceColors.map((color) { return GestureDetector( onTap: () { setState(() => selectedColor = color); widget.onColorSelected(color); }, child: Container( margin: const EdgeInsets.all(2.0), padding: const EdgeInsets.all(2.0), decoration: selectedColor == color ? ShapeDecoration( shape: RoundedRectangleBorder( side: const BorderSide( width: 1.50, strokeAlign: BorderSide.strokeAlignOutside, color: Color(0xFF00BCF0), ), borderRadius: BorderRadius.circular(20), ), ) : null, child: DecoratedBox( decoration: BoxDecoration( color: Color(int.parse(color)), borderRadius: BorderRadius.circular(20.0), ), ), ), ); }).toList(), ); } } class _Icons extends StatefulWidget { const _Icons({ required this.selectedColor, required this.selectedIcon, required this.onIconSelected, }); final String selectedColor; final String selectedIcon; final void Function(String color) onIconSelected; @override State<_Icons> createState() => _IconsState(); } class _IconsState extends State<_Icons> { late String selectedIcon = widget.selectedIcon; @override Widget build(BuildContext context) { return GridView.count( shrinkWrap: true, crossAxisCount: 5, mainAxisSpacing: 8.0, crossAxisSpacing: 12.0, children: builtInSpaceIcons.map((icon) { return GestureDetector( onTap: () { setState(() => selectedIcon = icon); widget.onIconSelected(icon); }, child: ClipRRect( borderRadius: BorderRadius.circular(8.0), child: FlowySvg( FlowySvgData('assets/flowy_icons/16x/$icon.svg'), color: Color(int.parse(widget.selectedColor)), blendMode: BlendMode.srcOut, ), ), ); }).toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_migration.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SpaceMigration extends StatefulWidget { const SpaceMigration({super.key}); @override State createState() => _SpaceMigrationState(); } class _SpaceMigrationState extends State { bool _isExpanded = false; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), clipBehavior: Clip.antiAlias, decoration: ShapeDecoration( color: Theme.of(context).isLightMode ? const Color(0x66F5EAFF) : const Color(0x1AFFFFFF), shape: RoundedRectangleBorder( side: const BorderSide( strokeAlign: BorderSide.strokeAlignOutside, color: Color(0x339327FF), ), borderRadius: BorderRadius.circular(10), ), ), child: _isExpanded ? _buildExpandedMigrationContent() : _buildCollapsedMigrationContent(), ); } Widget _buildExpandedMigrationContent() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _MigrationTitle( onClose: () => setState(() => _isExpanded = false), ), const VSpace(6.0), Opacity( opacity: 0.7, child: FlowyText.regular( LocaleKeys.space_upgradeSpaceDescription.tr(), maxLines: null, fontSize: 13.0, lineHeight: 1.3, ), ), const VSpace(12.0), _ExpandedUpgradeButton( onUpgrade: () => context.read().add(const SpaceEvent.migrate()), ), ], ); } Widget _buildCollapsedMigrationContent() { const linearGradient = LinearGradient( begin: Alignment.bottomLeft, end: Alignment.bottomRight, colors: [Color(0xFF8032FF), Color(0xFFEF35FF)], stops: [0.1545, 0.8225], ); return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => setState(() => _isExpanded = true), child: Row( children: [ const FlowySvg( FlowySvgs.upgrade_s, blendMode: null, ), const HSpace(8.0), Expanded( child: ShaderMask( shaderCallback: (Rect bounds) => linearGradient.createShader(bounds), blendMode: BlendMode.srcIn, child: FlowyText( LocaleKeys.space_upgradeYourSpace.tr(), ), ), ), const FlowySvg( FlowySvgs.space_arrow_right_s, blendMode: null, ), ], ), ); } } class _MigrationTitle extends StatelessWidget { const _MigrationTitle({required this.onClose}); final VoidCallback? onClose; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const FlowySvg( FlowySvgs.upgrade_s, blendMode: null, ), const HSpace(8.0), Expanded( child: FlowyText( LocaleKeys.space_upgradeSpaceTitle.tr(), maxLines: 3, lineHeight: 1.2, ), ), ], ); } } class _ExpandedUpgradeButton extends StatelessWidget { const _ExpandedUpgradeButton({required this.onUpgrade}); final VoidCallback? onUpgrade; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: onUpgrade, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: ShapeDecoration( color: const Color(0xFFA44AFD), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)), ), child: FlowyText( LocaleKeys.space_upgrade.tr(), color: Colors.white, fontSize: 12.0, strutStyle: const StrutStyle(forceStrutHeight: true), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SpaceMorePopup extends StatelessWidget { const SpaceMorePopup({ super.key, required this.space, required this.onAction, required this.onEditing, this.isHovered = false, }); final ViewPB space; final void Function(SpaceMoreActionType type, dynamic data) onAction; final void Function(bool value) onEditing; final bool isHovered; @override Widget build(BuildContext context) { final wrappers = _buildActionTypeWrappers(); return PopoverActionList( direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 8), actions: wrappers, constraints: const BoxConstraints( minWidth: 260, ), buildChild: (popover) { return FlowyIconButton( width: 24, icon: FlowySvg( FlowySvgs.workspace_three_dots_s, color: isHovered ? Theme.of(context).colorScheme.onSurface : null, ), tooltipText: LocaleKeys.space_manage.tr(), onPressed: () { onEditing(true); popover.show(); }, ); }, onSelected: (_, __) {}, onClosed: () => onEditing(false), ); } List _buildActionTypeWrappers() { final actionTypes = _buildActionTypes(); return actionTypes .map( (e) => SpaceMoreActionTypeWrapper(e, (controller, data) { onAction(e, data); controller.close(); }), ) .toList(); } List _buildActionTypes() { return [ SpaceMoreActionType.rename, SpaceMoreActionType.changeIcon, SpaceMoreActionType.manage, SpaceMoreActionType.duplicate, SpaceMoreActionType.divider, SpaceMoreActionType.addNewSpace, SpaceMoreActionType.collapseAllPages, SpaceMoreActionType.divider, SpaceMoreActionType.delete, ]; } } class SpaceMoreActionTypeWrapper extends CustomActionCell { SpaceMoreActionTypeWrapper(this.inner, this.onTap); final SpaceMoreActionType inner; final void Function(PopoverController controller, dynamic data) onTap; @override Widget buildWithContext( BuildContext context, PopoverController controller, PopoverMutex? mutex, ) { if (inner == SpaceMoreActionType.divider) { return _buildDivider(); } else if (inner == SpaceMoreActionType.changeIcon) { return _buildEmojiActionButton(context, controller); } else { return _buildNormalActionButton(context, controller); } } Widget _buildNormalActionButton( BuildContext context, PopoverController controller, ) { return _buildActionButton(context, () => onTap(controller, null)); } Widget _buildEmojiActionButton( BuildContext context, PopoverController controller, ) { final child = _buildActionButton(context, null); return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(360, 432)), margin: const EdgeInsets.all(0), clickHandler: PopoverClickHandler.gestureDetector, offset: const Offset(0, -40), popupBuilder: (context) { return FlowyIconEmojiPicker( tabs: const [PickerTabType.icon], onSelectedEmoji: (r) => onTap(controller, r), ); }, child: child, ); } Widget _buildDivider() { return const Padding( padding: EdgeInsets.all(8.0), child: FlowyDivider(), ); } Widget _buildActionButton( BuildContext context, VoidCallback? onTap, ) { final spaceBloc = context.read(); final spaces = spaceBloc.state.spaces; final currentSpace = spaceBloc.state.currentSpace; final isOwner = context .read() ?.state .currentWorkspace ?.role .isOwner ?? false; final isPageCreator = currentSpace?.createdBy == context.read().id; final allowToDelete = isOwner || isPageCreator; bool disable = false; var message = ''; if (inner == SpaceMoreActionType.delete) { if (spaces.length <= 1) { disable = true; message = LocaleKeys.space_unableToDeleteLastSpace.tr(); } else if (!allowToDelete) { disable = true; message = LocaleKeys.space_unableToDeleteSpaceNotCreatedByYou.tr(); } } final child = Container( height: 34, padding: const EdgeInsets.symmetric(vertical: 2.0), child: Opacity( opacity: disable ? 0.3 : 1.0, child: FlowyIconTextButton( disable: disable, margin: const EdgeInsets.symmetric(horizontal: 6), iconPadding: 10.0, onTap: onTap, leftIconBuilder: (onHover) => FlowySvg( inner.leftIconSvg, color: inner == SpaceMoreActionType.delete && onHover ? Theme.of(context).colorScheme.error : null, ), rightIconBuilder: (_) => inner.rightIcon, textBuilder: (onHover) => FlowyText.regular( inner.name, fontSize: 14.0, figmaLineHeight: 18.0, color: inner == SpaceMoreActionType.delete && onHover ? Theme.of(context).colorScheme.error : null, ), ), ), ); if (inner == SpaceMoreActionType.delete) { return FlowyTooltip( message: message, child: child, ); } return child; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_import_notion.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/share/import_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/import.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class NotionImporter extends StatelessWidget { const NotionImporter({required this.filePath, super.key}); final String filePath; @override Widget build(BuildContext context) { return ConstrainedBox( constraints: const BoxConstraints(minHeight: 30, maxHeight: 200), child: FutureBuilder( future: _uploadFile(), builder: (context, snapshots) { if (!snapshots.hasData) { return const _Uploading(); } final result = snapshots.data; if (result == null) { return const _UploadSuccess(); } else { return result.fold( (_) => const _UploadSuccess(), (err) => _UploadError(error: err), ); } }, ), ); } Future> _uploadFile() async { final importResult = await ImportBackendService.importZipFiles( [ImportZipPB()..filePath = filePath], ); return importResult; } } class _UploadSuccess extends StatelessWidget { const _UploadSuccess(); @override Widget build(BuildContext context) { return FlowyText( fontSize: 16, LocaleKeys.settings_common_uploadNotionSuccess.tr(), maxLines: 10, ); } } class _Uploading extends StatelessWidget { const _Uploading(); @override Widget build(BuildContext context) { return IntrinsicHeight( child: Center( child: Column( children: [ const CircularProgressIndicator.adaptive(), const VSpace(12), FlowyText( fontSize: 16, LocaleKeys.settings_common_uploadingFile.tr(), maxLines: null, ), ], ), ), ); } } class _UploadError extends StatelessWidget { const _UploadError({required this.error}); final FlowyError error; @override Widget build(BuildContext context) { return FlowyText(error.msg, maxLines: 10); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; enum WorkspaceMoreAction { rename, delete, leave, divider, } class WorkspaceMoreActionList extends StatefulWidget { const WorkspaceMoreActionList({ super.key, required this.workspace, required this.popoverMutex, }); final UserWorkspacePB workspace; final PopoverMutex popoverMutex; @override State createState() => _WorkspaceMoreActionListState(); } class _WorkspaceMoreActionListState extends State { bool isPopoverOpen = false; @override Widget build(BuildContext context) { final myRole = context.read().state.myRole; final actions = []; if (myRole.isOwner) { actions.add(WorkspaceMoreAction.rename); actions.add(WorkspaceMoreAction.divider); actions.add(WorkspaceMoreAction.delete); } else if (myRole.canLeave) { actions.add(WorkspaceMoreAction.leave); } if (actions.isEmpty) { return const SizedBox.shrink(); } return PopoverActionList<_WorkspaceMoreActionWrapper>( direction: PopoverDirection.bottomWithLeftAligned, actions: actions .map( (action) => _WorkspaceMoreActionWrapper( action, widget.workspace, () => PopoverContainer.of(context).closeAll(), ), ) .toList(), mutex: widget.popoverMutex, constraints: const BoxConstraints(minWidth: 220), animationDuration: Durations.short3, slideDistance: 2, beginScaleFactor: 1.0, beginOpacity: 0.8, onClosed: () => isPopoverOpen = false, asBarrier: true, buildChild: (controller) { return SizedBox.square( dimension: 24.0, child: FlowyButton( margin: const EdgeInsets.symmetric(horizontal: 4.0), text: const FlowySvg( FlowySvgs.workspace_three_dots_s, ), onTap: () { if (!isPopoverOpen) { controller.show(); isPopoverOpen = true; } }, ), ); }, onSelected: (action, controller) {}, ); } } class _WorkspaceMoreActionWrapper extends CustomActionCell { _WorkspaceMoreActionWrapper( this.inner, this.workspace, this.closeWorkspaceMenu, ); final WorkspaceMoreAction inner; final UserWorkspacePB workspace; final VoidCallback closeWorkspaceMenu; @override Widget buildWithContext( BuildContext context, PopoverController controller, PopoverMutex? mutex, ) { if (inner == WorkspaceMoreAction.divider) { return const Divider(); } return _buildActionButton(context, controller); } Widget _buildActionButton( BuildContext context, PopoverController controller, ) { return FlowyIconTextButton( leftIconBuilder: (onHover) => buildLeftIcon(context, onHover), iconPadding: 10.0, textBuilder: (onHover) => FlowyText.regular( name, fontSize: 14.0, figmaLineHeight: 18.0, color: [WorkspaceMoreAction.delete, WorkspaceMoreAction.leave] .contains(inner) && onHover ? Theme.of(context).colorScheme.error : null, ), margin: const EdgeInsets.all(6), onTap: () async { PopoverContainer.of(context).closeAll(); closeWorkspaceMenu(); final workspaceBloc = context.read(); switch (inner) { case WorkspaceMoreAction.divider: break; case WorkspaceMoreAction.delete: await showConfirmDeletionDialog( context: context, name: workspace.name, description: LocaleKeys.workspace_deleteWorkspaceHintText.tr(), onConfirm: () { workspaceBloc.add( UserWorkspaceEvent.deleteWorkspace( workspaceId: workspace.workspaceId, ), ); }, ); case WorkspaceMoreAction.rename: await showAFTextFieldDialog( context: context, title: LocaleKeys.workspace_renameWorkspace.tr(), initialValue: workspace.name, hintText: '', onConfirm: (name) async { workspaceBloc.add( UserWorkspaceEvent.renameWorkspace( workspaceId: workspace.workspaceId, name: name, ), ); }, ); case WorkspaceMoreAction.leave: await showConfirmDialog( context: context, title: LocaleKeys.workspace_leaveCurrentWorkspace.tr(), description: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), confirmLabel: LocaleKeys.button_yes.tr(), onConfirm: (_) { workspaceBloc.add( UserWorkspaceEvent.leaveWorkspace( workspaceId: workspace.workspaceId, ), ); }, ); } }, ); } String get name { switch (inner) { case WorkspaceMoreAction.delete: return LocaleKeys.button_delete.tr(); case WorkspaceMoreAction.rename: return LocaleKeys.button_rename.tr(); case WorkspaceMoreAction.leave: return LocaleKeys.workspace_leaveCurrentWorkspace.tr(); case WorkspaceMoreAction.divider: return ''; } } Widget buildLeftIcon(BuildContext context, bool onHover) { switch (inner) { case WorkspaceMoreAction.delete: return FlowySvg( FlowySvgs.trash_s, color: onHover ? Theme.of(context).colorScheme.error : null, ); case WorkspaceMoreAction.rename: return const FlowySvg(FlowySvgs.view_item_rename_s); case WorkspaceMoreAction.leave: return FlowySvg( FlowySvgs.logout_s, color: onHover ? Theme.of(context).colorScheme.error : null, ); case WorkspaceMoreAction.divider: return const SizedBox.shrink(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/util/color_generator/color_generator.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; class WorkspaceIcon extends StatefulWidget { const WorkspaceIcon({ super.key, required this.workspaceIcon, required this.workspaceName, required this.iconSize, required this.isEditable, required this.fontSize, required this.onSelected, required this.borderRadius, required this.emojiSize, required this.figmaLineHeight, this.showBorder = true, }); final String workspaceIcon; final String workspaceName; final double iconSize; final bool isEditable; final double fontSize; final double? emojiSize; final void Function(EmojiIconData) onSelected; final double borderRadius; final double figmaLineHeight; final bool showBorder; @override State createState() => _WorkspaceIconState(); } class _WorkspaceIconState extends State { final controller = PopoverController(); @override Widget build(BuildContext context) { final (textColor, backgroundColor) = ColorGenerator(widget.workspaceName).randomColor(); Widget child = widget.workspaceIcon.isNotEmpty ? FlowyText.emoji( widget.workspaceIcon, fontSize: widget.emojiSize, figmaLineHeight: widget.figmaLineHeight, optimizeEmojiAlign: true, ) : FlowyText.semibold( widget.workspaceName.isEmpty ? '' : widget.workspaceName.substring(0, 1), fontSize: widget.fontSize, color: textColor, ); child = Container( alignment: Alignment.center, width: widget.iconSize, height: widget.iconSize, decoration: BoxDecoration( color: widget.workspaceIcon.isEmpty ? backgroundColor : null, borderRadius: BorderRadius.circular(widget.borderRadius), border: widget.showBorder ? Border.all(color: const Color(0x1A717171)) : null, ), child: child, ); if (widget.isEditable) { child = _buildEditableIcon(child); } return child; } Widget _buildEditableIcon(Widget child) { if (UniversalPlatform.isDesktopOrWeb) { return AppFlowyPopover( offset: const Offset(0, 8), controller: controller, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(364, 356)), clickHandler: PopoverClickHandler.gestureDetector, margin: const EdgeInsets.all(0), popupBuilder: (_) => FlowyIconEmojiPicker( tabs: const [PickerTabType.emoji], onSelectedEmoji: (r) { widget.onSelected(r.data); if (!r.keepOpen) { controller.close(); } }, ), child: MouseRegion( cursor: SystemMouseCursors.click, child: child, ), ); } return GestureDetector( onTap: () async { final result = await context.push( Uri( path: MobileEmojiPickerScreen.routeName, queryParameters: { MobileEmojiPickerScreen.pageTitle: LocaleKeys.settings_workspacePage_workspaceIcon_title.tr(), MobileEmojiPickerScreen.selectTabs: [PickerTabType.emoji.name], }, ).toString(), ); if (result != null) { widget.onSelected(result); } }, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/guest_tag.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; import '_sidebar_import_notion.dart'; @visibleForTesting const createWorkspaceButtonKey = ValueKey('createWorkspaceButton'); @visibleForTesting const importNotionButtonKey = ValueKey('importNotionButton'); class WorkspacesMenu extends StatefulWidget { const WorkspacesMenu({ super.key, required this.userProfile, required this.currentWorkspace, required this.workspaces, }); final UserProfilePB userProfile; final UserWorkspacePB currentWorkspace; final List workspaces; @override State createState() => _WorkspacesMenuState(); } class _WorkspacesMenuState extends State { final popoverMutex = PopoverMutex(); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // user email Padding( padding: const EdgeInsets.only(left: 10.0, top: 6.0, right: 10.0), child: Row( children: [ Expanded( child: FlowyText.medium( _getUserInfo(), fontSize: 12.0, overflow: TextOverflow.ellipsis, color: Theme.of(context).hintColor, ), ), const HSpace(4.0), WorkspaceMoreButton( popoverMutex: popoverMutex, ), const HSpace(8.0), ], ), ), const Padding( padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 6.0), child: Divider(height: 1.0), ), // workspace list Flexible( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final workspace in widget.workspaces) ...[ WorkspaceMenuItem( key: ValueKey(workspace.workspaceId), workspace: workspace, userProfile: widget.userProfile, isSelected: workspace.workspaceId == widget.currentWorkspace.workspaceId, popoverMutex: popoverMutex, ), const VSpace(6.0), ], ], ), ), ), // add new workspace const Padding( padding: EdgeInsets.symmetric(horizontal: 6.0), child: _CreateWorkspaceButton(), ), if (UniversalPlatform.isDesktop) ...[ const Padding( padding: EdgeInsets.only(left: 6.0, top: 6.0, right: 6.0), child: _ImportNotionButton(), ), ], const VSpace(6.0), ], ); } String _getUserInfo() { if (widget.userProfile.email.isNotEmpty) { return widget.userProfile.email; } if (widget.userProfile.name.isNotEmpty) { return widget.userProfile.name; } return LocaleKeys.defaultUsername.tr(); } } class WorkspaceMenuItem extends StatefulWidget { const WorkspaceMenuItem({ super.key, required this.workspace, required this.userProfile, required this.isSelected, required this.popoverMutex, }); final UserProfilePB userProfile; final UserWorkspacePB workspace; final bool isSelected; final PopoverMutex popoverMutex; @override State createState() => _WorkspaceMenuItemState(); } class _WorkspaceMenuItemState extends State { final ValueNotifier isHovered = ValueNotifier(false); @override void dispose() { isHovered.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (_) => WorkspaceMemberBloc( userProfile: widget.userProfile, workspace: widget.workspace, )..add(const WorkspaceMemberEvent.initial()), child: BlocBuilder( builder: (context, state) { // settings right icon inside the flowy button will // cause the popover dismiss intermediately when click the right icon. // so using the stack to put the right icon on the flowy button. return SizedBox( height: 44, child: MouseRegion( onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: Stack( alignment: Alignment.center, children: [ _WorkspaceInfo( isSelected: widget.isSelected, workspace: widget.workspace, ), Positioned(left: 4, child: _buildLeftIcon(context)), Positioned( right: 4.0, child: Align(child: _buildRightIcon(context, isHovered)), ), ], ), ), ); }, ), ); } Widget _buildLeftIcon(BuildContext context) { return FlowyTooltip( message: LocaleKeys.document_plugins_cover_changeIcon.tr(), child: WorkspaceIcon( workspaceName: widget.workspace.name, workspaceIcon: widget.workspace.icon, iconSize: 36, emojiSize: 24.0, fontSize: 18.0, figmaLineHeight: 26.0, borderRadius: 12.0, isEditable: true, onSelected: (result) => context.read().add( UserWorkspaceEvent.updateWorkspaceIcon( workspaceId: widget.workspace.workspaceId, icon: result.emoji, ), ), ), ); } Widget _buildRightIcon(BuildContext context, ValueNotifier isHovered) { return Row( children: [ // only the owner can update or delete workspace. if (!context.read().state.isLoading) ValueListenableBuilder( valueListenable: isHovered, builder: (context, value, child) { return Padding( padding: const EdgeInsets.only(left: 8.0), child: Opacity( opacity: value ? 1.0 : 0.0, child: child, ), ); }, child: WorkspaceMoreActionList( workspace: widget.workspace, popoverMutex: widget.popoverMutex, ), ), const HSpace(8.0), if (widget.isSelected) ...[ const Padding( padding: EdgeInsets.all(5.0), child: FlowySvg( FlowySvgs.workspace_selected_s, blendMode: null, size: Size.square(14.0), ), ), const HSpace(8.0), ], ], ); } } class _WorkspaceInfo extends StatelessWidget { const _WorkspaceInfo({ required this.isSelected, required this.workspace, }); final bool isSelected; final UserWorkspacePB workspace; @override Widget build(BuildContext context) { final memberCount = workspace.memberCount.toInt(); return FlowyButton( onTap: () => _openWorkspace(context), iconPadding: 10.0, leftIconSize: const Size.square(32), leftIcon: const SizedBox.square(dimension: 32), rightIcon: const HSpace(32.0), text: Row( children: [ Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ // workspace name FlowyText.medium( workspace.name, fontSize: 14.0, figmaLineHeight: 17.0, overflow: TextOverflow.ellipsis, withTooltip: true, ), if (workspace.role != AFRolePB.Guest) ...[ // workspace members count FlowyText.regular( memberCount == 0 ? '' : LocaleKeys.settings_appearance_members_membersCount .plural( memberCount, ), fontSize: 10.0, figmaLineHeight: 12.0, color: Theme.of(context).hintColor, ), ], ], ), ), if (workspace.role == AFRolePB.Guest) ...[ const HSpace(6.0), GuestTag(), ], ], ), ); } void _openWorkspace(BuildContext context) { if (!isSelected) { Log.info('open workspace: ${workspace.workspaceId}'); // Persist and close other tabs when switching workspace, restore tabs for new workspace getIt().add(TabsEvent.switchWorkspace(workspace.workspaceId)); context.read().add( UserWorkspaceEvent.openWorkspace( workspaceId: workspace.workspaceId, workspaceType: workspace.workspaceType, ), ); PopoverContainer.of(context).closeAll(); } } } class _CreateWorkspaceButton extends StatelessWidget { const _CreateWorkspaceButton(); @override Widget build(BuildContext context) { return SizedBox( height: 40, child: FlowyButton( key: createWorkspaceButtonKey, onTap: () { _showCreateWorkspaceDialog(context); PopoverContainer.of(context).closeAll(); }, margin: const EdgeInsets.symmetric(horizontal: 4.0), text: Row( children: [ _buildLeftIcon(context), const HSpace(8.0), FlowyText.regular( LocaleKeys.workspace_create.tr(), ), ], ), ), ); } Widget _buildLeftIcon(BuildContext context) { return Container( width: 36.0, height: 36.0, padding: const EdgeInsets.all(7.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0x01717171).withValues(alpha: 0.12), width: 0.8, ), ), child: const FlowySvg(FlowySvgs.add_workspace_s), ); } Future _showCreateWorkspaceDialog(BuildContext context) async { if (context.mounted) { final workspaceBloc = context.read(); await showAFTextFieldDialog( context: context, title: LocaleKeys.workspace_create.tr(), initialValue: '', onConfirm: (name) { workspaceBloc.add( UserWorkspaceEvent.createWorkspace( name: name, workspaceType: WorkspaceTypePB.ServerW, ), ); }, ); } } } class _ImportNotionButton extends StatelessWidget { const _ImportNotionButton(); @override Widget build(BuildContext context) { return SizedBox( height: 40, child: FlowyButton( key: importNotionButtonKey, onTap: () { _showImportNotinoDialog(context); }, margin: const EdgeInsets.symmetric(horizontal: 4.0), text: Row( children: [ _buildLeftIcon(context), const HSpace(8.0), FlowyText.regular( LocaleKeys.workspace_importFromNotion.tr(), ), ], ), rightIcon: FlowyTooltip( message: LocaleKeys.workspace_learnMore.tr(), preferBelow: true, child: FlowyIconButton( icon: const FlowySvg( FlowySvgs.information_s, ), onPressed: () { afLaunchUrlString( 'https://docs.appflowy.io/docs/guides/import-from-notion', ); }, ), ), ), ); } Widget _buildLeftIcon(BuildContext context) { return Container( width: 36.0, height: 36.0, padding: const EdgeInsets.all(7.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0x01717171).withValues(alpha: 0.12), width: 0.8, ), ), child: const FlowySvg(FlowySvgs.add_workspace_s), ); } Future _showImportNotinoDialog(BuildContext context) async { final result = await getIt().pickFiles( type: FileType.custom, allowedExtensions: ['zip'], ); if (result == null || result.files.isEmpty) { return; } final path = result.files.first.path; if (path == null) { return; } if (context.mounted) { PopoverContainer.of(context).closeAll(); await NavigatorCustomDialog( hideCancelButton: true, confirm: () {}, child: NotionImporter( filePath: path, ), ).show(context); } else { Log.error('context is not mounted when showing import notion dialog'); } } } @visibleForTesting class WorkspaceMoreButton extends StatelessWidget { const WorkspaceMoreButton({ super.key, required this.popoverMutex, }); final PopoverMutex popoverMutex; @override Widget build(BuildContext context) { return AppFlowyPopover( direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 6), mutex: popoverMutex, asBarrier: true, popupBuilder: (_) => FlowyButton( margin: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 7.0), leftIcon: const FlowySvg(FlowySvgs.workspace_logout_s), iconPadding: 10.0, text: FlowyText.regular(LocaleKeys.button_logout.tr()), onTap: () async { await getIt().signOut(); await runAppFlowy(); }, ), child: SizedBox.square( dimension: 24.0, child: FlowyButton( useIntrinsicWidth: true, margin: EdgeInsets.zero, text: const FlowySvg( FlowySvgs.workspace_three_dots_s, size: Size.square(16.0), ), onTap: () {}, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarWorkspace extends StatefulWidget { const SidebarWorkspace({super.key, required this.userProfile}); final UserProfilePB userProfile; @override State createState() => _SidebarWorkspaceState(); } class _SidebarWorkspaceState extends State { Loading? loadingIndicator; final ValueNotifier onHover = ValueNotifier(false); int maxRetryCount = 3; int retryCount = 0; @override void initState() { super.initState(); openWorkspaceNotifier.addListener(_openWorkspaceFromInvitation); } @override void dispose() { onHover.dispose(); openWorkspaceNotifier.removeListener(_openWorkspaceFromInvitation); super.dispose(); } @override Widget build(BuildContext context) { return BlocConsumer( listenWhen: (previous, current) => previous.actionResult != current.actionResult, listener: _showResultDialog, builder: (context, state) { final currentWorkspace = state.currentWorkspace; if (currentWorkspace == null) { return const SizedBox.shrink(); } return MouseRegion( onEnter: (_) => onHover.value = true, onExit: (_) => onHover.value = false, child: ValueListenableBuilder( valueListenable: onHover, builder: (_, onHover, child) { return Container( margin: const EdgeInsets.only(right: 8.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8.0), color: onHover ? Theme.of(context).colorScheme.secondary : Colors.transparent, ), child: Row( children: [ Expanded( child: SidebarSwitchWorkspaceButton( userProfile: widget.userProfile, currentWorkspace: currentWorkspace, isHover: onHover, ), ), UserSettingButton( isHover: onHover, ), const HSpace(8.0), NotificationButton( isHover: onHover, key: ValueKey(currentWorkspace.workspaceId), ), const HSpace(4.0), ], ), ); }, ), ); }, ); } void _showResultDialog(BuildContext context, UserWorkspaceState state) { final actionResult = state.actionResult; if (actionResult == null) { return; } final settingBloc = context.read(); if (settingBloc?.state.isNotificationPanelCollapsed == false) { settingBloc?.add(HomeSettingEvent.collapseNotificationPanel()); } final actionType = actionResult.actionType; final result = actionResult.result; final isLoading = actionResult.isLoading; if (isLoading) { loadingIndicator ??= Loading(context)..start(); return; } else { loadingIndicator?.stop(); loadingIndicator = null; } if (result == null) { return; } result.onFailure((f) { Log.error( '[Workspace] Failed to perform ${actionType.toString()} action: $f', ); }); // show a confirmation dialog if the action is create and the result is LimitExceeded failure if (actionType == WorkspaceActionType.create && result.isFailure && result.getFailure().code == ErrorCode.WorkspaceLimitExceeded) { showDialog( context: context, builder: (context) => NavigatorOkCancelDialog( message: LocaleKeys.workspace_createLimitExceeded.tr(), ), ); return; } final String? message; switch (actionType) { case WorkspaceActionType.create: message = result.fold( (s) => LocaleKeys.workspace_createSuccess.tr(), (e) => '${LocaleKeys.workspace_createFailed.tr()}: ${e.msg}', ); break; case WorkspaceActionType.delete: message = result.fold( (s) => LocaleKeys.workspace_deleteSuccess.tr(), (e) => '${LocaleKeys.workspace_deleteFailed.tr()}: ${e.msg}', ); break; case WorkspaceActionType.open: message = result.fold( (s) => LocaleKeys.workspace_openSuccess.tr(), (e) => '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}', ); break; case WorkspaceActionType.updateIcon: message = result.fold( (s) => LocaleKeys.workspace_updateIconSuccess.tr(), (e) => '${LocaleKeys.workspace_updateIconFailed.tr()}: ${e.msg}', ); break; case WorkspaceActionType.rename: message = result.fold( (s) => LocaleKeys.workspace_renameSuccess.tr(), (e) => '${LocaleKeys.workspace_renameFailed.tr()}: ${e.msg}', ); break; case WorkspaceActionType.fetchWorkspaces: case WorkspaceActionType.none: case WorkspaceActionType.leave: case WorkspaceActionType.fetchSubscriptionInfo: message = null; break; } if (message != null) { showToastNotification( message: message, type: result.fold( (_) => ToastificationType.success, (_) => ToastificationType.error, ), ); } } // This function is a workaround, when we support open the workspace from invitation deep link, // we should refactor the code here void _openWorkspaceFromInvitation() { final value = openWorkspaceNotifier.value; final workspaceId = value?.workspaceId; final email = value?.email; if (workspaceId == null) { Log.info('No workspace id to open'); return; } if (email == null) { Log.info('Open workspace from invitation with no email'); return; } final state = context.read().state; final currentWorkspace = state.currentWorkspace; if (currentWorkspace?.workspaceId == workspaceId) { Log.info('Already in the workspace'); return; } if (email != widget.userProfile.email) { Log.info( 'Current user email: ${widget.userProfile.email} is not the same as the email in the invitation: $email', ); return; } final openWorkspace = state.workspaces.firstWhereOrNull( (workspace) => workspace.workspaceId == workspaceId, ); if (openWorkspace == null) { Log.error('Workspace not found, try to fetch workspaces'); context.read().add( UserWorkspaceEvent.fetchWorkspaces( initialWorkspaceId: workspaceId, ), ); Future.delayed( Duration(milliseconds: 250 + retryCount * 250), () { if (retryCount >= maxRetryCount) { openWorkspaceNotifier.value = null; retryCount = 0; Log.error('Failed to open workspace from invitation'); return; } retryCount++; _openWorkspaceFromInvitation(); }, ); return; } context.read().add( UserWorkspaceEvent.openWorkspace( workspaceId: workspaceId, workspaceType: openWorkspace.workspaceType, ), ); openWorkspaceNotifier.value = null; } } class SidebarSwitchWorkspaceButton extends StatefulWidget { const SidebarSwitchWorkspaceButton({ super.key, required this.userProfile, required this.currentWorkspace, this.isHover = false, }); final UserWorkspacePB currentWorkspace; final UserProfilePB userProfile; final bool isHover; @override State createState() => _SidebarSwitchWorkspaceButtonState(); } class _SidebarSwitchWorkspaceButtonState extends State { final PopoverController _popoverController = PopoverController(); @override Widget build(BuildContext context) { return AppFlowyPopover( direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 5), constraints: const BoxConstraints(maxWidth: 300, maxHeight: 600), margin: EdgeInsets.zero, animationDuration: Durations.short3, beginScaleFactor: 1.0, beginOpacity: 0.8, controller: _popoverController, triggerActions: PopoverTriggerFlags.none, onOpen: () { context .read() .add(UserWorkspaceEvent.fetchWorkspaces()); }, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: BlocBuilder( builder: (context, state) { final currentWorkspace = state.currentWorkspace; final workspaces = state.workspaces; if (currentWorkspace == null) { return const SizedBox.shrink(); } return WorkspacesMenu( userProfile: widget.userProfile, currentWorkspace: currentWorkspace, workspaces: workspaces, ); }, ), ); }, child: _SideBarSwitchWorkspaceButtonChild( currentWorkspace: widget.currentWorkspace, popoverController: _popoverController, isHover: widget.isHover, ), ); } } class _SideBarSwitchWorkspaceButtonChild extends StatelessWidget { const _SideBarSwitchWorkspaceButtonChild({ required this.popoverController, required this.currentWorkspace, required this.isHover, }); final PopoverController popoverController; final UserWorkspacePB currentWorkspace; final bool isHover; @override Widget build(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () { context.read().add( UserWorkspaceEvent.fetchWorkspaces(), ); popoverController.show(); }, behavior: HitTestBehavior.opaque, child: SizedBox( height: 30, child: Row( children: [ const HSpace(4.0), WorkspaceIcon( workspaceIcon: currentWorkspace.icon, workspaceName: currentWorkspace.name, iconSize: 26, fontSize: 16, emojiSize: 20, isEditable: false, showBorder: false, borderRadius: 8.0, figmaLineHeight: 18.0, onSelected: (result) => context.read().add( UserWorkspaceEvent.updateWorkspaceIcon( workspaceId: currentWorkspace.workspaceId, icon: result.emoji, ), ), ), const HSpace(6), Flexible( child: FlowyText.medium( currentWorkspace.name, color: isHover ? Theme.of(context).colorScheme.onSurface : null, overflow: TextOverflow.ellipsis, withTooltip: true, fontSize: 15.0, ), ), if (isHover) ...[ const HSpace(4), FlowySvg( FlowySvgs.workspace_drop_down_menu_show_s, color: isHover ? Theme.of(context).colorScheme.onSurface : null, ), ], ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart ================================================ // Workaround for open workspace from invitation deep link import 'package:flutter/material.dart'; ValueNotifier openWorkspaceNotifier = ValueNotifier(null); class WorkspaceNotifyValue { WorkspaceNotifyValue({ this.workspaceId, this.email, }); final String? workspaceId; final String? email; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart ================================================ import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; enum DraggableHoverPosition { none, top, center, bottom, } const kDraggableViewItemDividerHeight = 2.0; class DraggableViewItem extends StatefulWidget { const DraggableViewItem({ super.key, required this.view, this.feedback, required this.child, this.isFirstChild = false, this.centerHighlightColor, this.topHighlightColor, this.bottomHighlightColor, this.onDragging, this.onMove, }); final Widget child; final WidgetBuilder? feedback; final ViewPB view; final bool isFirstChild; final Color? centerHighlightColor; final Color? topHighlightColor; final Color? bottomHighlightColor; final void Function(bool isDragging)? onDragging; final void Function(ViewPB from, ViewPB to)? onMove; @override State createState() => _DraggableViewItemState(); } class _DraggableViewItemState extends State { DraggableHoverPosition position = DraggableHoverPosition.none; final hoverColor = const Color(0xFF00C8FF); @override Widget build(BuildContext context) { // add top border if the draggable item is on the top of the list // highlight the draggable item if the draggable item is on the center // add bottom border if the draggable item is on the bottom of the list final child = UniversalPlatform.isMobile ? _buildMobileDraggableItem() : _buildDesktopDraggableItem(); return DraggableItem( data: widget.view, onDragging: widget.onDragging, onWillAcceptWithDetails: (data) => true, onMove: (data) { final renderBox = context.findRenderObject() as RenderBox; final offset = renderBox.globalToLocal(data.offset); if (offset.dx > renderBox.size.width) { return; } final position = _computeHoverPosition(offset, renderBox.size); if (!_shouldAccept(data.data, position)) { return; } _updatePosition(position); }, onLeave: (_) => _updatePosition( DraggableHoverPosition.none, ), onAcceptWithDetails: (details) { final data = details.data; _move(data, widget.view); _updatePosition(DraggableHoverPosition.none); }, feedback: IntrinsicWidth( child: Opacity( opacity: 0.5, child: widget.feedback?.call(context) ?? child, ), ), child: child, ); } Widget _buildDesktopDraggableItem() { return Column( mainAxisSize: MainAxisSize.min, children: [ // only show the top border when the draggable item is the first child if (widget.isFirstChild) Divider( height: kDraggableViewItemDividerHeight, thickness: kDraggableViewItemDividerHeight, color: position == DraggableHoverPosition.top ? widget.topHighlightColor ?? hoverColor : Colors.transparent, ), DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(6.0), color: position == DraggableHoverPosition.center ? widget.centerHighlightColor ?? hoverColor.withValues(alpha: 0.5) : Colors.transparent, ), child: widget.child, ), Divider( height: kDraggableViewItemDividerHeight, thickness: kDraggableViewItemDividerHeight, color: position == DraggableHoverPosition.bottom ? widget.bottomHighlightColor ?? hoverColor : Colors.transparent, ), ], ); } Widget _buildMobileDraggableItem() { return Stack( children: [ if (widget.isFirstChild) Positioned( top: 0, left: 0, right: 0, height: kDraggableViewItemDividerHeight, child: Divider( height: kDraggableViewItemDividerHeight, thickness: kDraggableViewItemDividerHeight, color: position == DraggableHoverPosition.top ? widget.topHighlightColor ?? Theme.of(context).colorScheme.secondary : Colors.transparent, ), ), DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(4.0), color: position == DraggableHoverPosition.center ? widget.centerHighlightColor ?? Theme.of(context) .colorScheme .secondary .withValues(alpha: 0.5) : Colors.transparent, ), child: widget.child, ), Positioned( bottom: 0, left: 0, right: 0, height: kDraggableViewItemDividerHeight, child: Divider( height: kDraggableViewItemDividerHeight, thickness: kDraggableViewItemDividerHeight, color: position == DraggableHoverPosition.bottom ? widget.bottomHighlightColor ?? Theme.of(context).colorScheme.secondary : Colors.transparent, ), ), ], ); } void _updatePosition(DraggableHoverPosition position) { if (UniversalPlatform.isMobile && position != this.position) { HapticFeedback.mediumImpact(); } setState(() => this.position = position); } void _move(ViewPB from, ViewPB to) { if (position == DraggableHoverPosition.center && to.layout != ViewLayoutPB.Document) { // not support moving into a database return; } if (widget.onMove != null) { widget.onMove?.call(from, to); return; } final fromSection = getViewSection(from); final toSection = getViewSection(to); switch (position) { case DraggableHoverPosition.top: context.read().add( ViewEvent.move( from, to.parentViewId, null, fromSection, toSection, ), ); break; case DraggableHoverPosition.bottom: context.read().add( ViewEvent.move( from, to.parentViewId, to.id, fromSection, toSection, ), ); break; case DraggableHoverPosition.center: context.read().add( ViewEvent.move( from, to.id, to.childViews.lastOrNull?.id, fromSection, toSection, ), ); break; case DraggableHoverPosition.none: break; } } DraggableHoverPosition _computeHoverPosition(Offset offset, Size size) { final threshold = size.height / 5.0; if (widget.isFirstChild && offset.dy < -5.0) { return DraggableHoverPosition.top; } if (offset.dy > threshold) { return DraggableHoverPosition.bottom; } return DraggableHoverPosition.center; } bool _shouldAccept(ViewPB data, DraggableHoverPosition position) { // could not move the view to a database if (widget.view.layout.isDatabaseView && position == DraggableHoverPosition.center) { return false; } // ignore moving the view to itself if (data.id == widget.view.id) { return false; } // ignore moving the view to its child view if (data.containsView(widget.view)) { return false; } return true; } ViewSectionPB? getViewSection(ViewPB view) { return context.read().getViewSection(view); } } extension on ViewPB { bool containsView(ViewPB view) { if (id == view.id) { return true; } return childViews.any((v) => v.containsView(view)); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum ViewMoreActionType { delete, favorite, unFavorite, duplicate, copyLink, // not supported yet. rename, moveTo, openInNewTab, changeIcon, collapseAllPages, // including sub pages divider, lastModified, created, lockPage, leaveSharedPage; static const disableInLockedView = [ delete, rename, moveTo, changeIcon, ]; } extension ViewMoreActionTypeExtension on ViewMoreActionType { String get name { switch (this) { case ViewMoreActionType.delete: return LocaleKeys.disclosureAction_delete.tr(); case ViewMoreActionType.favorite: return LocaleKeys.disclosureAction_favorite.tr(); case ViewMoreActionType.unFavorite: return LocaleKeys.disclosureAction_unfavorite.tr(); case ViewMoreActionType.duplicate: return LocaleKeys.disclosureAction_duplicate.tr(); case ViewMoreActionType.copyLink: return LocaleKeys.disclosureAction_copyLink.tr(); case ViewMoreActionType.rename: return LocaleKeys.disclosureAction_rename.tr(); case ViewMoreActionType.moveTo: return LocaleKeys.disclosureAction_moveTo.tr(); case ViewMoreActionType.openInNewTab: return LocaleKeys.disclosureAction_openNewTab.tr(); case ViewMoreActionType.changeIcon: return LocaleKeys.disclosureAction_changeIcon.tr(); case ViewMoreActionType.collapseAllPages: return LocaleKeys.disclosureAction_collapseAllPages.tr(); case ViewMoreActionType.lockPage: return LocaleKeys.disclosureAction_lockPage.tr(); case ViewMoreActionType.leaveSharedPage: return 'Leave'; case ViewMoreActionType.divider: case ViewMoreActionType.lastModified: case ViewMoreActionType.created: return ''; } } FlowySvgData get leftIconSvg { switch (this) { case ViewMoreActionType.delete: return FlowySvgs.trash_s; case ViewMoreActionType.favorite: return FlowySvgs.favorite_s; case ViewMoreActionType.unFavorite: return FlowySvgs.unfavorite_s; case ViewMoreActionType.duplicate: return FlowySvgs.duplicate_s; case ViewMoreActionType.rename: return FlowySvgs.view_item_rename_s; case ViewMoreActionType.moveTo: return FlowySvgs.move_to_s; case ViewMoreActionType.openInNewTab: return FlowySvgs.view_item_open_in_new_tab_s; case ViewMoreActionType.changeIcon: return FlowySvgs.change_icon_s; case ViewMoreActionType.collapseAllPages: return FlowySvgs.collapse_all_page_s; case ViewMoreActionType.lockPage: return FlowySvgs.lock_page_s; case ViewMoreActionType.leaveSharedPage: return FlowySvgs.leave_workspace_s; case ViewMoreActionType.divider: case ViewMoreActionType.lastModified: case ViewMoreActionType.copyLink: case ViewMoreActionType.created: throw UnsupportedError('No left icon for $this'); } } Widget get rightIcon { switch (this) { case ViewMoreActionType.changeIcon: case ViewMoreActionType.moveTo: case ViewMoreActionType.favorite: case ViewMoreActionType.unFavorite: case ViewMoreActionType.duplicate: case ViewMoreActionType.copyLink: case ViewMoreActionType.rename: case ViewMoreActionType.openInNewTab: case ViewMoreActionType.collapseAllPages: case ViewMoreActionType.divider: case ViewMoreActionType.delete: case ViewMoreActionType.lastModified: case ViewMoreActionType.created: case ViewMoreActionType.lockPage: case ViewMoreActionType.leaveSharedPage: return const SizedBox.shrink(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_add_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/document.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/import/import_panel.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class ViewAddButton extends StatelessWidget { const ViewAddButton({ super.key, required this.parentViewId, required this.onEditing, required this.onSelected, this.isHovered = false, }); final String parentViewId; final void Function(bool value) onEditing; final Function( PluginBuilder, String? name, List? initialDataBytes, bool openAfterCreated, bool createNewView, ) onSelected; final bool isHovered; List get _actions { return [ // document, grid, kanban, calendar ...pluginBuilders().map( (pluginBuilder) => ViewAddButtonActionWrapper( pluginBuilder: pluginBuilder, ), ), // import from ... ...getIt().builders.whereType().map( (pluginBuilder) => ViewImportActionWrapper( pluginBuilder: pluginBuilder, ), ), ]; } @override Widget build(BuildContext context) { return PopoverActionList( direction: PopoverDirection.bottomWithLeftAligned, actions: _actions, offset: const Offset(0, 8), constraints: const BoxConstraints( minWidth: 200, ), buildChild: (popover) { return FlowyIconButton( width: 24, icon: FlowySvg( FlowySvgs.view_item_add_s, color: isHovered ? Theme.of(context).colorScheme.onSurface : null, ), onPressed: () { onEditing(true); popover.show(); }, ); }, onSelected: (action, popover) { onEditing(false); if (action is ViewAddButtonActionWrapper) { _showViewAddButtonActions(context, action); } else if (action is ViewImportActionWrapper) { _showViewImportAction(context, action); } popover.close(); }, onClosed: () { onEditing(false); }, ); } void _showViewAddButtonActions( BuildContext context, ViewAddButtonActionWrapper action, ) { onSelected(action.pluginBuilder, null, null, true, true); } void _showViewImportAction( BuildContext context, ViewImportActionWrapper action, ) { showImportPanel( parentViewId, context, (type, name, initialDataBytes) { onSelected(action.pluginBuilder, null, null, true, false); }, ); } } class ViewAddButtonActionWrapper extends ActionCell { ViewAddButtonActionWrapper({ required this.pluginBuilder, }); final PluginBuilder pluginBuilder; @override Widget? leftIcon(Color iconColor) => FlowySvg( pluginBuilder.icon, size: const Size.square(16), ); @override String get name => pluginBuilder.menuName; PluginType get pluginType => pluginBuilder.pluginType; } class ViewImportActionWrapper extends ActionCell { ViewImportActionWrapper({ required this.pluginBuilder, }); final DocumentPluginBuilder pluginBuilder; @override Widget? leftIcon(Color iconColor) => const FlowySvg(FlowySvgs.icon_import_s); @override String get name => LocaleKeys.moreAction_import.tr(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart ================================================ import 'dart:async'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart'; import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; typedef ViewItemOnSelected = void Function(BuildContext context, ViewPB view); typedef ViewItemLeftIconBuilder = Widget Function( BuildContext context, ViewPB view, ); typedef ViewItemRightIconsBuilder = List Function( BuildContext context, ViewPB view, ); enum IgnoreViewType { none, hide, disable } class ViewItem extends StatelessWidget { const ViewItem({ super.key, required this.view, this.parentView, required this.spaceType, required this.level, this.leftPadding = 10, required this.onSelected, this.onTertiarySelected, this.isFirstChild = false, this.isDraggable = true, required this.isFeedback, this.height = HomeSpaceViewSizes.viewHeight, this.isHoverEnabled = false, this.isPlaceholder = false, this.isHovered, this.shouldRenderChildren = true, this.leftIconBuilder, this.rightIconsBuilder, this.shouldLoadChildViews = true, this.isExpandedNotifier, this.extendBuilder, this.disableSelectedStatus, this.shouldIgnoreView, this.engagedInExpanding = false, this.enableRightClickContext = false, }); final ViewPB view; final ViewPB? parentView; final FolderSpaceType spaceType; // indicate the level of the view item // used to calculate the left padding final int level; // the left padding of the view item for each level // the left padding of the each level = level * leftPadding final double leftPadding; // Selected by normal conventions final ViewItemOnSelected onSelected; // Selected by middle mouse button final ViewItemOnSelected? onTertiarySelected; // used for indicating the first child of the parent view, so that we can // add top border to the first child final bool isFirstChild; // it should be false when it's rendered as feedback widget inside DraggableItem final bool isDraggable; // identify if the view item is rendered as feedback widget inside DraggableItem final bool isFeedback; final double height; final bool isHoverEnabled; // all the view movement depends on the [ViewItem] widget, so we have to add a // placeholder widget to receive the drop event when moving view across sections. final bool isPlaceholder; // used for control the expand/collapse icon final ValueNotifier? isHovered; // render the child views of the view final bool shouldRenderChildren; // custom the left icon widget, if it's null, the default expand/collapse icon will be used final ViewItemLeftIconBuilder? leftIconBuilder; // custom the right icon widget, if it's null, the default ... and + button will be used final ViewItemRightIconsBuilder? rightIconsBuilder; final bool shouldLoadChildViews; final PropertyValueNotifier? isExpandedNotifier; final List Function(ViewPB view)? extendBuilder; // disable the selected status of the view item final bool? disableSelectedStatus; // ignore the views when rendering the child views final IgnoreViewType Function(ViewPB view)? shouldIgnoreView; /// Whether to add right-click to show the view action context menu /// final bool enableRightClickContext; /// to record the ViewBlock which is expanded or collapsed final bool engagedInExpanding; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => ViewBloc( view: view, shouldLoadChildViews: shouldLoadChildViews, engagedInExpanding: engagedInExpanding, )..add(const ViewEvent.initial()), child: BlocConsumer( listenWhen: (p, c) => c.lastCreatedView != null && p.lastCreatedView?.id != c.lastCreatedView!.id, listener: (context, state) => context.read().openPlugin(state.lastCreatedView!), builder: (context, state) { // filter the child views that should be ignored List childViews = state.view.childViews; if (shouldIgnoreView != null) { childViews = childViews .where((v) => shouldIgnoreView!(v) != IgnoreViewType.hide) .toList(); } final Widget child = InnerViewItem( view: state.view, parentView: parentView, childViews: childViews, spaceType: spaceType, level: level, leftPadding: leftPadding, showActions: state.isEditing, enableRightClickContext: enableRightClickContext, isExpanded: state.isExpanded, disableSelectedStatus: disableSelectedStatus, onSelected: onSelected, onTertiarySelected: onTertiarySelected, isFirstChild: isFirstChild, isDraggable: isDraggable, isFeedback: isFeedback, height: height, isHoverEnabled: isHoverEnabled, isPlaceholder: isPlaceholder, isHovered: isHovered, shouldRenderChildren: shouldRenderChildren, leftIconBuilder: leftIconBuilder, rightIconsBuilder: rightIconsBuilder, isExpandedNotifier: isExpandedNotifier, extendBuilder: extendBuilder, shouldIgnoreView: shouldIgnoreView, engagedInExpanding: engagedInExpanding, ); if (shouldIgnoreView?.call(view) == IgnoreViewType.disable) { return Opacity( opacity: 0.5, child: FlowyTooltip( message: LocaleKeys.space_cannotMovePageToDatabase.tr(), child: MouseRegion( cursor: SystemMouseCursors.forbidden, child: IgnorePointer(child: child), ), ), ); } return child; }, ), ); } } // TODO: We shouldn't have local global variables bool _isDragging = false; class InnerViewItem extends StatefulWidget { const InnerViewItem({ super.key, required this.view, required this.parentView, required this.childViews, required this.spaceType, this.isDraggable = true, this.isExpanded = true, required this.level, required this.leftPadding, required this.showActions, this.enableRightClickContext = false, required this.onSelected, this.onTertiarySelected, this.isFirstChild = false, required this.isFeedback, required this.height, this.isHoverEnabled = true, this.isPlaceholder = false, this.isHovered, this.shouldRenderChildren = true, required this.leftIconBuilder, required this.rightIconsBuilder, this.isExpandedNotifier, required this.extendBuilder, this.disableSelectedStatus, this.engagedInExpanding = false, required this.shouldIgnoreView, }); final ViewPB view; final ViewPB? parentView; final List childViews; final FolderSpaceType spaceType; final bool isDraggable; final bool isExpanded; final bool isFirstChild; // identify if the view item is rendered as feedback widget inside DraggableItem final bool isFeedback; final int level; final double leftPadding; final bool showActions; final bool enableRightClickContext; final ViewItemOnSelected onSelected; final ViewItemOnSelected? onTertiarySelected; final double height; final bool isHoverEnabled; final bool isPlaceholder; final bool? disableSelectedStatus; final ValueNotifier? isHovered; final bool shouldRenderChildren; final ViewItemLeftIconBuilder? leftIconBuilder; final ViewItemRightIconsBuilder? rightIconsBuilder; final PropertyValueNotifier? isExpandedNotifier; final List Function(ViewPB view)? extendBuilder; final IgnoreViewType Function(ViewPB view)? shouldIgnoreView; final bool engagedInExpanding; @override State createState() => _InnerViewItemState(); } class _InnerViewItemState extends State { @override void initState() { super.initState(); widget.isExpandedNotifier?.addListener(_collapseAllPages); } @override void dispose() { widget.isExpandedNotifier?.removeListener(_collapseAllPages); super.dispose(); } @override Widget build(BuildContext context) { Widget child = ValueListenableBuilder( valueListenable: getIt().notifier, builder: (context, value, _) { final isSelected = value?.id == widget.view.id; return SingleInnerViewItem( view: widget.view, parentView: widget.parentView, level: widget.level, showActions: widget.showActions, enableRightClickContext: widget.enableRightClickContext, spaceType: widget.spaceType, onSelected: widget.onSelected, onTertiarySelected: widget.onTertiarySelected, isExpanded: widget.isExpanded, isDraggable: widget.isDraggable, leftPadding: widget.leftPadding, isFeedback: widget.isFeedback, height: widget.height, isPlaceholder: widget.isPlaceholder, isHovered: widget.isHovered, leftIconBuilder: widget.leftIconBuilder, rightIconsBuilder: widget.rightIconsBuilder, extendBuilder: widget.extendBuilder, disableSelectedStatus: widget.disableSelectedStatus, shouldIgnoreView: widget.shouldIgnoreView, isSelected: isSelected, ); }, ); // if the view is expanded and has child views, render its child views if (widget.isExpanded && widget.shouldRenderChildren && widget.childViews.isNotEmpty) { final children = widget.childViews.map((childView) { return ViewItem( key: ValueKey('${widget.spaceType.name} ${childView.id}'), parentView: widget.view, spaceType: widget.spaceType, isFirstChild: childView.id == widget.childViews.first.id, view: childView, level: widget.level + 1, enableRightClickContext: widget.enableRightClickContext, onSelected: widget.onSelected, onTertiarySelected: widget.onTertiarySelected, isDraggable: widget.isDraggable, disableSelectedStatus: widget.disableSelectedStatus, leftPadding: widget.leftPadding, isFeedback: widget.isFeedback, isPlaceholder: widget.isPlaceholder, isHovered: widget.isHovered, leftIconBuilder: widget.leftIconBuilder, rightIconsBuilder: widget.rightIconsBuilder, extendBuilder: widget.extendBuilder, shouldIgnoreView: widget.shouldIgnoreView, engagedInExpanding: widget.engagedInExpanding, ); }).toList(); child = Column( mainAxisSize: MainAxisSize.min, children: [child, ...children], ); } // wrap the child with DraggableItem if isDraggable is true if ((widget.isDraggable || widget.isPlaceholder) && !isReferencedDatabaseView(widget.view, widget.parentView)) { child = DraggableViewItem( isFirstChild: widget.isFirstChild, view: widget.view, onDragging: (isDragging) => _isDragging = isDragging, onMove: widget.isPlaceholder ? (from, to) => moveViewCrossSpace( context, null, widget.view, widget.parentView, widget.spaceType, from, to.parentViewId, ) : null, feedback: (context) => Container( width: 250, decoration: BoxDecoration( color: Brightness.light == Theme.of(context).brightness ? Colors.white : Colors.black54, borderRadius: BorderRadius.circular(8), ), child: ViewItem( view: widget.view, parentView: widget.parentView, spaceType: widget.spaceType, level: widget.level, onSelected: widget.onSelected, onTertiarySelected: widget.onTertiarySelected, isDraggable: false, leftPadding: widget.leftPadding, isFeedback: true, enableRightClickContext: widget.enableRightClickContext, leftIconBuilder: widget.leftIconBuilder, rightIconsBuilder: widget.rightIconsBuilder, extendBuilder: widget.extendBuilder, shouldIgnoreView: widget.shouldIgnoreView, ), ), child: child, ); } else { // keep the same height of the DraggableItem child = Padding( padding: const EdgeInsets.only(top: kDraggableViewItemDividerHeight), child: child, ); } return child; } void _collapseAllPages() { if (widget.isExpandedNotifier?.value == true) { context.read().add(const ViewEvent.collapseAllPages()); } } } class SingleInnerViewItem extends StatefulWidget { const SingleInnerViewItem({ super.key, required this.view, required this.parentView, required this.isExpanded, required this.level, required this.leftPadding, this.isDraggable = true, required this.spaceType, required this.showActions, this.enableRightClickContext = false, required this.onSelected, this.onTertiarySelected, required this.isFeedback, required this.height, this.isHoverEnabled = true, this.isPlaceholder = false, this.isHovered, required this.leftIconBuilder, required this.rightIconsBuilder, required this.extendBuilder, required this.disableSelectedStatus, required this.shouldIgnoreView, required this.isSelected, }); final ViewPB view; final ViewPB? parentView; final bool isExpanded; // identify if the view item is rendered as feedback widget inside DraggableItem final bool isFeedback; final int level; final double leftPadding; final bool isDraggable; final bool showActions; final bool enableRightClickContext; final ViewItemOnSelected onSelected; final ViewItemOnSelected? onTertiarySelected; final FolderSpaceType spaceType; final double height; final bool isHoverEnabled; final bool isPlaceholder; final bool? disableSelectedStatus; final ValueNotifier? isHovered; final ViewItemLeftIconBuilder? leftIconBuilder; final ViewItemRightIconsBuilder? rightIconsBuilder; final List Function(ViewPB view)? extendBuilder; final IgnoreViewType Function(ViewPB view)? shouldIgnoreView; final bool isSelected; @override State createState() => _SingleInnerViewItemState(); } class _SingleInnerViewItemState extends State { final controller = PopoverController(); final viewMoreActionController = PopoverController(); bool isIconPickerOpened = false; DateTime? _lastClickTime; static const _clickThrottleDuration = Duration(milliseconds: 200); void _handleViewTap() { final now = DateTime.now(); if (_lastClickTime != null) { final timeSinceLastClick = now.difference(_lastClickTime!); if (timeSinceLastClick < _clickThrottleDuration) { return; } } _lastClickTime = now; widget.onSelected(context, widget.view); } @override Widget build(BuildContext context) { bool isSelected = widget.isSelected; if (widget.disableSelectedStatus == true) { isSelected = false; } if (widget.isPlaceholder) { return const SizedBox(height: 4, width: double.infinity); } if (widget.isFeedback || !widget.isHoverEnabled) { return _buildViewItem( false, !widget.isHoverEnabled ? isSelected : false, ); } return FlowyHover( style: HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), resetHoverOnRebuild: widget.showActions || !isIconPickerOpened, buildWhenOnHover: () => !widget.showActions && !_isDragging && !isIconPickerOpened, isSelected: () => widget.showActions || isSelected, builder: (_, onHover) => _buildViewItem(onHover, isSelected), ); } Widget _buildViewItem(bool onHover, [bool isSelected = false]) { final name = FlowyText.regular( widget.view.nameOrDefault, overflow: TextOverflow.ellipsis, fontSize: 14.0, figmaLineHeight: 18.0, ); final children = [ const HSpace(2), // expand icon or placeholder widget.leftIconBuilder?.call(context, widget.view) ?? _buildLeftIcon(), const HSpace(2), // icon _buildViewIconButton(), const HSpace(6), // title Expanded( child: widget.extendBuilder != null ? Row( children: [ Flexible(child: name), ...widget.extendBuilder!(widget.view), ], ) : name, ), ]; // hover action if (widget.showActions || onHover) { if (widget.rightIconsBuilder != null) { children.addAll(widget.rightIconsBuilder!(context, widget.view)); } else { // ··· more action button children.add( _buildViewMoreActionButton( context, viewMoreActionController, (_) => FlowyTooltip( message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(), child: FlowyIconButton( width: 24, icon: const FlowySvg(FlowySvgs.workspace_three_dots_s), onPressed: viewMoreActionController.show, ), ), ), ); // only support add button for document layout if (widget.view.layout == ViewLayoutPB.Document) { // + button children.add(const HSpace(8.0)); children.add(_buildViewAddButton(context)); } children.add(const HSpace(4.0)); } } final child = GestureDetector( behavior: HitTestBehavior.translucent, onTap: _handleViewTap, onTertiaryTapDown: (_) => widget.onTertiarySelected?.call(context, widget.view), child: SizedBox( height: widget.height, child: Padding( padding: EdgeInsets.only(left: widget.level * widget.leftPadding), child: Listener( onPointerDown: (event) { if (event.buttons == kSecondaryMouseButton && widget.enableRightClickContext) { viewMoreActionController.showAt( // We add some horizontal offset event.position + const Offset(4, 0), ); } }, behavior: HitTestBehavior.opaque, child: Row(children: children), ), ), ), ); if (isSelected) { final popoverController = getIt().state.controller; return AppFlowyPopover( controller: popoverController, triggerActions: PopoverTriggerFlags.none, offset: const Offset(0, 5), direction: PopoverDirection.bottomWithLeftAligned, popupBuilder: (_) => RenameViewPopover( view: widget.view, name: widget.view.name, emoji: widget.view.icon.toEmojiIconData(), popoverController: popoverController, showIconChanger: false, ), child: child, ); } return child; } Widget _buildViewIconButton() { final iconData = widget.view.icon.toEmojiIconData(); final icon = iconData.isNotEmpty ? RawEmojiIconWidget( emoji: iconData, emojiSize: 16.0, lineHeight: 18.0 / 16.0, ) : Opacity(opacity: 0.6, child: widget.view.defaultIcon()); final Widget child = AppFlowyPopover( offset: const Offset(20, 0), controller: controller, direction: PopoverDirection.rightWithCenterAligned, constraints: BoxConstraints.loose(const Size(364, 356)), margin: const EdgeInsets.all(0), onClose: () => setState(() => isIconPickerOpened = false), child: GestureDetector( // prevent the tap event from being passed to the parent widget onTap: () {}, child: FlowyTooltip( message: LocaleKeys.document_plugins_cover_changeIcon.tr(), child: SizedBox(width: 16.0, child: icon), ), ), popupBuilder: (context) { isIconPickerOpened = true; return FlowyIconEmojiPicker( initialType: iconData.type.toPickerTabType(), tabs: const [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ], documentId: widget.view.id, onSelectedEmoji: (r) { ViewBackendService.updateViewIcon( view: widget.view, viewIcon: r.data, ); if (!r.keepOpen) controller.close(); }, ); }, ); if (widget.view.isLocked) { return LockPageButtonWrapper( child: child, ); } return child; } // > button or · button // show > if the view is expandable. // show · if the view can't contain child views. Widget _buildLeftIcon() { return ViewItemDefaultLeftIcon( view: widget.view, parentView: widget.parentView, isExpanded: widget.isExpanded, leftPadding: widget.leftPadding, isHovered: widget.isHovered, ); } // + button Widget _buildViewAddButton(BuildContext context) { return FlowyTooltip( message: LocaleKeys.menuAppHeader_addPageTooltip.tr(), child: ViewAddButton( parentViewId: widget.view.id, onEditing: (value) => context.read().add(ViewEvent.setIsEditing(value)), onSelected: _onSelected, ), ); } void _onSelected( PluginBuilder pluginBuilder, String? name, List? initialDataBytes, bool openAfterCreated, bool createNewView, ) { final viewBloc = context.read(); // the name of new document should be empty final viewName = ![ViewLayoutPB.Document, ViewLayoutPB.Chat] .contains(pluginBuilder.layoutType) ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : ''; viewBloc.add( ViewEvent.createView( viewName, pluginBuilder.layoutType!, openAfterCreated: openAfterCreated, section: widget.spaceType.toViewSectionPB, ), ); viewBloc.add(const ViewEvent.setIsExpanded(true)); } // ··· more action button Widget _buildViewMoreActionButton( BuildContext context, PopoverController controller, Widget Function(PopoverController) buildChild, ) { return BlocProvider( create: (context) => SpaceBloc( userProfile: context.read().userProfile, workspaceId: context.read().workspaceId, )..add(const SpaceEvent.initial(openFirstPage: false)), child: ViewMoreActionPopover( view: widget.view, controller: controller, isExpanded: widget.isExpanded, spaceType: widget.spaceType, onEditing: (value) => context.read().add(ViewEvent.setIsEditing(value)), buildChild: buildChild, onAction: (action, data) async { switch (action) { case ViewMoreActionType.favorite: case ViewMoreActionType.unFavorite: context .read() .add(FavoriteEvent.toggle(widget.view)); break; case ViewMoreActionType.rename: unawaited( showAFTextFieldDialog( context: context, title: LocaleKeys.disclosureAction_rename.tr(), initialValue: widget.view.nameOrDefault, onConfirm: (newValue) { context.read().add(ViewEvent.rename(newValue)); }, maxLength: 256, ), ); break; case ViewMoreActionType.delete: // get if current page contains published child views final (containPublishedPage, _) = await ViewBackendService.containPublishedPage(widget.view); if (containPublishedPage && context.mounted) { await showConfirmDeletionDialog( context: context, name: widget.view.name, description: LocaleKeys.publish_containsPublishedPage.tr(), onConfirm: () => context.read().add(const ViewEvent.delete()), ); } else if (context.mounted) { context.read().add(const ViewEvent.delete()); } break; case ViewMoreActionType.duplicate: context.read().add(const ViewEvent.duplicate()); break; case ViewMoreActionType.openInNewTab: context.read().openTab(widget.view); break; case ViewMoreActionType.collapseAllPages: context.read().add(const ViewEvent.collapseAllPages()); break; case ViewMoreActionType.changeIcon: if (data is! SelectedEmojiIconResult) { return; } await ViewBackendService.updateViewIcon( view: widget.view, viewIcon: data.data, ); break; case ViewMoreActionType.moveTo: final value = data; if (value is! (ViewPB, ViewPB)) { return; } final space = value.$1; final target = value.$2; moveViewCrossSpace( context, space, widget.view, widget.parentView, widget.spaceType, widget.view, target.id, ); default: throw UnsupportedError('$action is not supported'); } }, ), ); } } class _DotIconWidget extends StatelessWidget { const _DotIconWidget(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(6.0), child: Container( width: 4, height: 4, decoration: BoxDecoration( color: Theme.of(context).iconTheme.color, borderRadius: BorderRadius.circular(2), ), ), ); } } // workaround: we should use view.isEndPoint or something to check if the view can contain child views. But currently, we don't have that field. bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) { if (parentView == null) { return false; } return view.layout.isDatabaseView && parentView.layout.isDatabaseView; } void moveViewCrossSpace( BuildContext context, ViewPB? toSpace, ViewPB view, ViewPB? parentView, FolderSpaceType spaceType, ViewPB from, String toId, ) { if (isReferencedDatabaseView(view, parentView)) { return; } if (from.id == toId) { return; } final currentSpace = context.read().state.currentSpace; if (currentSpace != null && toSpace != null && currentSpace.id != toSpace.id) { Log.info( 'Move view(${from.name}) to another space(${toSpace.name}), unpublish the view', ); context.read().add(const ViewEvent.unpublish(sync: false)); switchToSpaceNotifier.value = toSpace; } context.read().add(ViewEvent.move(from, toId, null, null, null)); } class ViewItemDefaultLeftIcon extends StatelessWidget { const ViewItemDefaultLeftIcon({ super.key, required this.view, required this.parentView, required this.isExpanded, required this.leftPadding, required this.isHovered, }); final ViewPB view; final ViewPB? parentView; final bool isExpanded; final double leftPadding; final ValueNotifier? isHovered; @override Widget build(BuildContext context) { if (isReferencedDatabaseView(view, parentView)) { return const _DotIconWidget(); } if (context.read().state.view.childViews.isEmpty) { return HSpace(leftPadding); } final child = FlowyHover( child: GestureDetector( child: FlowySvg( isExpanded ? FlowySvgs.view_item_expand_s : FlowySvgs.view_item_unexpand_s, size: const Size.square(16.0), ), onTap: () => context.read().add(ViewEvent.setIsExpanded(!isExpanded)), ), ); if (isHovered != null) { return ValueListenableBuilder( valueListenable: isHovered!, builder: (_, isHovered, child) => Opacity(opacity: isHovered ? 1.0 : 0.0, child: child), child: child, ); } return child; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// ··· button beside the view name class ViewMoreActionPopover extends StatelessWidget { const ViewMoreActionPopover({ super.key, required this.view, this.controller, required this.onEditing, required this.onAction, required this.spaceType, required this.isExpanded, required this.buildChild, this.showAtCursor = false, }); final ViewPB view; final PopoverController? controller; final void Function(bool value) onEditing; final void Function(ViewMoreActionType type, dynamic data) onAction; final FolderSpaceType spaceType; final bool isExpanded; final Widget Function(PopoverController) buildChild; final bool showAtCursor; @override Widget build(BuildContext context) { final wrappers = _buildActionTypeWrappers(); return PopoverActionList( controller: controller, direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 8), actions: wrappers, constraints: const BoxConstraints(minWidth: 260), onPopupBuilder: () => onEditing(true), buildChild: buildChild, onSelected: (_, __) {}, onClosed: () => onEditing(false), showAtCursor: showAtCursor, ); } List _buildActionTypeWrappers() { final actionTypes = _buildActionTypes(); return actionTypes.map( (e) { final actionWrapper = ViewMoreActionTypeWrapper(e, view, (controller, data) { onEditing(false); onAction(e, data); bool enableClose = true; if (data is SelectedEmojiIconResult) { if (data.keepOpen) enableClose = false; } if (enableClose) controller.close(); }); return actionWrapper; }, ).toList(); } List _buildActionTypes() { final List actionTypes = []; if (spaceType == FolderSpaceType.favorite) { actionTypes.addAll([ ViewMoreActionType.unFavorite, ViewMoreActionType.divider, ViewMoreActionType.rename, ViewMoreActionType.openInNewTab, ]); } else { actionTypes.add( view.isFavorite ? ViewMoreActionType.unFavorite : ViewMoreActionType.favorite, ); actionTypes.addAll([ ViewMoreActionType.divider, ViewMoreActionType.rename, ]); // Chat doesn't change icon and duplicate if (view.layout != ViewLayoutPB.Chat) { actionTypes.addAll([ ViewMoreActionType.changeIcon, ViewMoreActionType.duplicate, ]); } actionTypes.addAll([ ViewMoreActionType.moveTo, ViewMoreActionType.delete, ViewMoreActionType.divider, ]); // Chat doesn't change collapse // Only show collapse all pages if the view has child views if (view.layout != ViewLayoutPB.Chat && view.childViews.isNotEmpty && isExpanded) { actionTypes.add(ViewMoreActionType.collapseAllPages); actionTypes.add(ViewMoreActionType.divider); } actionTypes.add(ViewMoreActionType.openInNewTab); } return actionTypes; } } class ViewMoreActionTypeWrapper extends CustomActionCell { ViewMoreActionTypeWrapper( this.inner, this.sourceView, this.onTap, { this.moveActionDirection, this.moveActionOffset, }); final ViewMoreActionType inner; final ViewPB sourceView; final void Function(PopoverController controller, dynamic data) onTap; // custom the move to action button final PopoverDirection? moveActionDirection; final Offset? moveActionOffset; @override Widget buildWithContext( BuildContext context, PopoverController controller, PopoverMutex? mutex, ) { Widget child; if (inner == ViewMoreActionType.divider) { child = _buildDivider(); } else if (inner == ViewMoreActionType.lastModified) { child = _buildLastModified(context); } else if (inner == ViewMoreActionType.created) { child = _buildCreated(context); } else if (inner == ViewMoreActionType.changeIcon) { child = _buildEmojiActionButton(context, controller); } else if (inner == ViewMoreActionType.moveTo) { child = _buildMoveToActionButton(context, controller); } else { child = _buildNormalActionButton(context, controller); } if (ViewMoreActionType.disableInLockedView.contains(inner) && sourceView.isLocked) { child = LockPageButtonWrapper( child: child, ); } return child; } Widget _buildNormalActionButton( BuildContext context, PopoverController controller, ) { return _buildActionButton(context, () => onTap(controller, null)); } Widget _buildEmojiActionButton( BuildContext context, PopoverController controller, ) { final child = _buildActionButton(context, null); return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(364, 356)), margin: const EdgeInsets.all(0), clickHandler: PopoverClickHandler.gestureDetector, popupBuilder: (_) => FlowyIconEmojiPicker( tabs: const [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ], documentId: sourceView.id, initialType: sourceView.icon.toEmojiIconData().type.toPickerTabType(), onSelectedEmoji: (result) => onTap(controller, result), ), child: child, ); } Widget _buildMoveToActionButton( BuildContext context, PopoverController controller, ) { final userProfile = context.read().userProfile; // move to feature doesn't support in local mode if (userProfile.workspaceType != WorkspaceTypePB.ServerW) { return const SizedBox.shrink(); } return BlocProvider.value( value: context.read(), child: BlocBuilder( builder: (context, state) { final child = _buildActionButton(context, null); return AppFlowyPopover( constraints: const BoxConstraints( maxWidth: 260, maxHeight: 345, ), margin: const EdgeInsets.symmetric( horizontal: 14.0, vertical: 12.0, ), clickHandler: PopoverClickHandler.gestureDetector, direction: moveActionDirection ?? PopoverDirection.rightWithTopAligned, offset: moveActionOffset, popupBuilder: (_) { return BlocProvider.value( value: context.read(), child: MovePageMenu( sourceView: sourceView, onSelected: (space, view) { onTap(controller, (space, view)); }, ), ); }, child: child, ); }, ), ); } Widget _buildDivider() { return const Padding( padding: EdgeInsets.all(8.0), child: FlowyDivider(), ); } Widget _buildLastModified(BuildContext context) { return Container( height: 40, width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 8.0), child: const Column( crossAxisAlignment: CrossAxisAlignment.start, ), ); } Widget _buildCreated(BuildContext context) { return Container( height: 40, width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 8.0), child: const Column( crossAxisAlignment: CrossAxisAlignment.start, ), ); } Widget _buildActionButton( BuildContext context, VoidCallback? onTap, ) { return Container( height: 34, padding: const EdgeInsets.symmetric(vertical: 2.0), child: FlowyIconTextButton( margin: const EdgeInsets.symmetric(horizontal: 6), onTap: onTap, // show the error color when delete is hovered leftIconBuilder: (onHover) => FlowySvg( inner.leftIconSvg, color: inner == ViewMoreActionType.delete && onHover ? Theme.of(context).colorScheme.error : null, ), rightIconBuilder: (_) => inner.rightIcon, iconPadding: 10.0, textBuilder: (onHover) => FlowyText.regular( inner.name, fontSize: 14.0, lineHeight: 1.0, figmaLineHeight: 18.0, color: inner == ViewMoreActionType.delete && onHover ? Theme.of(context).colorScheme.error : null, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:universal_platform/universal_platform.dart'; import '../notifications/number_red_dot.dart'; class NavigationNotifier with ChangeNotifier { NavigationNotifier({required this.navigationItems}); List navigationItems; void update(PageNotifier notifier) { if (navigationItems != notifier.plugin.widgetBuilder.navigationItems) { navigationItems = notifier.plugin.widgetBuilder.navigationItems; notifyListeners(); } } } class FlowyNavigation extends StatelessWidget { const FlowyNavigation({super.key}); @override Widget build(BuildContext context) { return ChangeNotifierProxyProvider( create: (_) { final notifier = Provider.of(context, listen: false); return NavigationNotifier( navigationItems: notifier.plugin.widgetBuilder.navigationItems, ); }, update: (_, notifier, controller) => controller!..update(notifier), child: Expanded( child: Row( children: [ _renderCollapse(context), Selector>( selector: (context, notifier) => notifier.navigationItems, builder: (ctx, items, child) => Expanded( child: Row( children: _renderNavigationItems(items), ), ), ), ], ), ), ); } Widget _renderCollapse(BuildContext context) { return BlocBuilder( buildWhen: (p, c) => p.menuStatus != c.menuStatus, builder: (context, state) { if (!UniversalPlatform.isWindows && state.menuStatus == MenuStatus.hidden) { final textSpan = TextSpan( children: [ TextSpan( text: '${LocaleKeys.sideBar_openSidebar.tr()}\n', style: context.tooltipTextStyle(), ), TextSpan( text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', style: context .tooltipTextStyle() ?.copyWith(color: Theme.of(context).hintColor), ), ], ); final theme = AppFlowyTheme.of(context); return Padding( padding: const EdgeInsets.only(right: 8.0), child: SizedBox( width: 24, height: 24, child: Stack( children: [ RotationTransition( turns: const AlwaysStoppedAnimation(180 / 360), child: FlowyTooltip( richMessage: textSpan, child: Listener( onPointerDown: (event) => context.read().collapseMenu(), child: SizedBox( width: 24, height: 24, child: FlowyIconButton( width: 24, onPressed: () {}, icon: FlowySvg( FlowySvgs.double_back_arrow_m, color: theme.iconColorScheme.secondary, ), ), ), ), ), ), Align( alignment: Alignment.topRight, child: NumberedRedDot.desktop(), ), ], ), ), ); } return const SizedBox.shrink(); }, ); } List _renderNavigationItems(List items) { if (items.isEmpty) { return []; } final List newItems = _filter(items); final Widget last = NaviItemWidget(newItems.removeLast()); final List widgets = List.empty(growable: true); // widgets.addAll(newItems.map((item) => NaviItemDivider(child: NaviItemWidget(item))).toList()); for (final item in newItems) { widgets.add(NaviItemWidget(item)); widgets.add(const Text('/')); } widgets.add(last); return widgets; } List _filter(List items) { final length = items.length; if (length > 4) { final first = items[0]; final ellipsisItems = items.getRange(1, length - 2).toList(); final last = items.getRange(length - 2, length).toList(); return [ first, EllipsisNaviItem(items: ellipsisItems), ...last, ]; } else { return items; } } } class NaviItemWidget extends StatelessWidget { const NaviItemWidget(this.item, {super.key}); final NavigationItem item; @override Widget build(BuildContext context) { return Expanded( child: item.leftBarItem.padding(horizontal: 2, vertical: 2), ); } } class EllipsisNaviItem extends NavigationItem { EllipsisNaviItem({required this.items}); final List items; @override String? get viewName => null; @override Widget get leftBarItem => FlowyText.medium('...', fontSize: FontSizes.s16); @override Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem; @override NavigationCallback get action => (id) {}; } TextSpan sidebarTooltipTextSpan(BuildContext context, String hintText) => TextSpan( children: [ TextSpan( text: "$hintText\n", ), TextSpan( text: Platform.isMacOS ? "⌘+." : "Ctrl+\\", ), ], ); ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; class FlowyTab extends StatefulWidget { const FlowyTab({ super.key, required this.pageManager, required this.isCurrent, required this.onTap, required this.isAllPinned, }); final PageManager pageManager; final bool isCurrent; final VoidCallback onTap; /// Signifies whether all tabs are pinned /// final bool isAllPinned; @override State createState() => _FlowyTabState(); } class _FlowyTabState extends State { final controller = PopoverController(); @override Widget build(BuildContext context) { return SizedBox( width: widget.pageManager.isPinned ? 54 : null, child: _wrapInTooltip( widget.pageManager.plugin.widgetBuilder.viewName, child: FlowyHover( resetHoverOnRebuild: false, style: HoverStyle( borderRadius: BorderRadius.zero, backgroundColor: widget.isCurrent ? Theme.of(context).colorScheme.surface : Theme.of(context).colorScheme.surfaceContainerHighest, hoverColor: widget.isCurrent ? Theme.of(context).colorScheme.surface : null, ), builder: (context, isHovering) => AppFlowyPopover( controller: controller, offset: const Offset(4, 4), triggerActions: PopoverTriggerFlags.secondaryClick, showAtCursor: true, popupBuilder: (_) => BlocProvider.value( value: context.read(), child: TabMenu( controller: controller, pageId: widget.pageManager.plugin.id, isPinned: widget.pageManager.isPinned, isAllPinned: widget.isAllPinned, ), ), child: ChangeNotifierProvider.value( value: widget.pageManager.notifier, child: Consumer( builder: (context, value, _) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), // We use a Listener to avoid gesture detector onPanStart debounce child: Listener( onPointerDown: (event) { if (event.buttons == kPrimaryButton) { widget.onTap(); } }, child: GestureDetector( behavior: HitTestBehavior.opaque, // Stop move window detector onPanStart: (_) {}, child: Container( constraints: BoxConstraints( maxWidth: HomeSizes.tabBarWidth, minWidth: widget.pageManager.isPinned ? 54 : 100, ), height: HomeSizes.tabBarHeight, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: widget.pageManager.notifier.tabBarWidget( widget.pageManager.plugin.id, widget.pageManager.isPinned, ), ), if (!widget.pageManager.isPinned) ...[ Visibility( visible: isHovering, child: SizedBox( width: 26, height: 26, child: FlowyIconButton( onPressed: () => _closeTab(context), icon: const FlowySvg( FlowySvgs.close_s, size: Size.square(22), ), ), ), ), ], ], ), ), ), ), ), ), ), ), ), ), ); } void _closeTab(BuildContext context) => context .read() .add(TabsEvent.closeTab(widget.pageManager.plugin.id)); Widget _wrapInTooltip(String? viewName, {required Widget child}) { if (viewName != null) { return FlowyTooltip( message: viewName, child: child, ); } return child; } } @visibleForTesting class TabMenu extends StatelessWidget { const TabMenu({ super.key, required this.controller, required this.pageId, required this.isPinned, required this.isAllPinned, }); final PopoverController controller; final String pageId; final bool isPinned; final bool isAllPinned; @override Widget build(BuildContext context) { return SeparatedColumn( separatorBuilder: () => const VSpace(4), mainAxisSize: MainAxisSize.min, children: [ Opacity( opacity: isPinned ? 0.5 : 1, child: _wrapInTooltip( shouldWrap: isPinned, message: LocaleKeys.tabMenu_closeDisabledHint.tr(), child: FlowyButton( text: FlowyText.regular(LocaleKeys.tabMenu_close.tr()), onTap: () => _closeTab(context), disable: isPinned, ), ), ), Opacity( opacity: isAllPinned ? 0.5 : 1, child: _wrapInTooltip( shouldWrap: true, message: isAllPinned ? LocaleKeys.tabMenu_closeOthersDisabledHint.tr() : LocaleKeys.tabMenu_closeOthersHint.tr(), child: FlowyButton( text: FlowyText.regular( LocaleKeys.tabMenu_closeOthers.tr(), ), onTap: () => _closeOtherTabs(context), disable: isAllPinned, ), ), ), const Divider(height: 0.5), FlowyButton( text: FlowyText.regular( isPinned ? LocaleKeys.tabMenu_unpinTab.tr() : LocaleKeys.tabMenu_pinTab.tr(), ), onTap: () => _togglePin(context), ), ], ); } Widget _wrapInTooltip({ required bool shouldWrap, String? message, required Widget child, }) { if (shouldWrap) { return FlowyTooltip( message: message, child: child, ); } return child; } void _closeTab(BuildContext context) { context.read().add(TabsEvent.closeTab(pageId)); controller.close(); } void _closeOtherTabs(BuildContext context) { context.read().add(TabsEvent.closeOtherTabs(pageId)); controller.close(); } void _togglePin(BuildContext context) { context.read().add(TabsEvent.togglePin(pageId)); controller.close(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart ================================================ import 'package:appflowy/core/frameless_window.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class TabsManager extends StatelessWidget { const TabsManager({super.key, required this.onIndexChanged}); final void Function(int) onIndexChanged; @override Widget build(BuildContext context) { return BlocConsumer( listenWhen: (prev, curr) => prev.currentIndex != curr.currentIndex || prev.pages != curr.pages, listener: (context, state) => onIndexChanged(state.currentIndex), builder: (context, state) { if (state.pages == 1) { return const SizedBox.shrink(); } final isAllPinned = state.isAllPinned; return Container( alignment: Alignment.bottomLeft, height: HomeSizes.tabBarHeight, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, ), child: MoveWindowDetector( child: Row( children: state.pageManagers.map((pm) { return Flexible( child: ConstrainedBox( constraints: const BoxConstraints( maxWidth: HomeSizes.tabBarWidth, ), child: FlowyTab( key: ValueKey('tab-${pm.plugin.id}'), pageManager: pm, isCurrent: state.currentPageManager == pm, isAllPinned: isAllPinned, onTap: () { if (state.currentPageManager != pm) { final index = state.pageManagers.indexOf(pm); onIndexChanged(index); } }, ), ), ); }).toList(), ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:universal_platform/universal_platform.dart'; class FlowyMessageToast extends StatelessWidget { const FlowyMessageToast({required this.message, super.key}); final String message; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), color: Theme.of(context).colorScheme.surface, ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: FlowyText.medium( message, fontSize: FontSizes.s16, maxLines: 3, ), ), ); } } void initToastWithContext(BuildContext context) { getIt().init(context); } void showMessageToast( String message, { BuildContext? context, ToastGravity gravity = ToastGravity.BOTTOM, }) { final child = FlowyMessageToast(message: message); final toast = context == null ? getIt() : (FToast()..init(context)); toast.showToast( child: child, gravity: gravity, toastDuration: const Duration(seconds: 3), ); } void showSnackBarMessage( BuildContext context, String message, { bool showCancel = false, Duration duration = const Duration(seconds: 4), }) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, duration: duration, action: !showCancel ? null : SnackBarAction( label: LocaleKeys.button_cancel.tr(), textColor: Colors.white, onPressed: () { ScaffoldMessenger.of(context).hideCurrentSnackBar(); }, ), content: FlowyText( message, maxLines: 2, fontSize: UniversalPlatform.isDesktop ? 14 : 12, ), ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_panel.dart ================================================ import 'dart:io'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'widgets/notification_tab.dart'; import 'widgets/notification_tab_bar.dart'; class NotificationPanel extends StatefulWidget { const NotificationPanel({super.key}); @override State createState() => _NotificationPanelState(); } class _NotificationPanelState extends State with SingleTickerProviderStateMixin { late TabController tabController; final PopoverController moreActionController = PopoverController(); final tabs = [ NotificationTabType.inbox, NotificationTabType.unread, NotificationTabType.archive, ]; @override void initState() { super.initState(); tabController = TabController(length: 3, vsync: this); } @override void dispose() { tabController.dispose(); moreActionController.close(); super.dispose(); } @override Widget build(BuildContext context) { final settingBloc = context.read(); final theme = AppFlowyTheme.of(context); return GestureDetector( onTap: () => settingBloc.add(HomeSettingEvent.collapseNotificationPanel()), child: Container( color: Colors.transparent, child: Align( alignment: Alignment.centerLeft, child: GestureDetector( onTap: () {}, child: Container( width: 380, decoration: BoxDecoration( color: theme.backgroundColorScheme.primary, boxShadow: theme.shadow.small, ), padding: EdgeInsets.symmetric(vertical: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ buildTitle( context: context, onHide: () => settingBloc .add(HomeSettingEvent.collapseNotificationPanel()), ), const VSpace(12), NotificationTabBar( tabController: tabController, tabs: tabs, ), const VSpace(14), Expanded( child: TabBarView( controller: tabController, children: tabs.map((e) => NotificationTab(tabType: e)).toList(), ), ), ], ), ), ), ), ), ); } Widget buildTitle({ required BuildContext context, required VoidCallback onHide, }) { final theme = AppFlowyTheme.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 16), height: 24, child: Row( children: [ FlowyText.medium( LocaleKeys.notificationHub_title.tr(), fontSize: 16, figmaLineHeight: 24, ), Spacer(), FlowyIconButton( width: 24, icon: FlowySvg( FlowySvgs.double_back_arrow_m, color: theme.iconColorScheme.secondary, ), richTooltipText: colappsedButtonTooltip(context), onPressed: onHide, ), HSpace(8), buildMoreActionButton(context), ], ), ); } Widget buildMoreActionButton(BuildContext context) { final theme = AppFlowyTheme.of(context); return AppFlowyPopover( constraints: BoxConstraints.loose(const Size(240, 78)), offset: const Offset(-24, 24), margin: EdgeInsets.zero, controller: moreActionController, onOpen: () => keepEditorFocusNotifier.increase(), onClose: () => keepEditorFocusNotifier.decrease(), popupBuilder: (_) => buildMoreActions(), child: FlowyIconButton( width: 24, icon: FlowySvg( FlowySvgs.three_dots_m, color: theme.iconColorScheme.secondary, ), onPressed: () { keepEditorFocusNotifier.increase(); moreActionController.show(); }, ), ); } Widget buildMoreActions() { return Container( padding: EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: BorderRadius.all(Radius.circular(8)), boxShadow: [ BoxShadow( offset: Offset(0, 4), blurRadius: 24, color: Color(0x0000001F), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 30, child: FlowyButton( text: FlowyText.regular( LocaleKeys.settings_notifications_settings_markAllAsRead.tr(), ), leftIcon: FlowySvg(FlowySvgs.m_notification_mark_as_read_s), onTap: () { showToastNotification( message: LocaleKeys.notificationHub_markAllAsReadSucceedToast.tr(), ); context .read() .add(const ReminderEvent.markAllRead()); moreActionController.close(); }, ), ), VSpace(2), SizedBox( height: 30, child: FlowyButton( text: FlowyText.regular( LocaleKeys.settings_notifications_settings_archiveAll.tr(), ), leftIcon: FlowySvg(FlowySvgs.m_notification_archived_s), onTap: () { showToastNotification( message: LocaleKeys .notificationHub_markAllAsArchivedSucceedToast .tr(), ); context .read() .add(const ReminderEvent.archiveAll()); moreActionController.close(); }, ), ), ], ), ); } TextSpan colappsedButtonTooltip(BuildContext context) { return TextSpan( children: [ TextSpan( text: '${LocaleKeys.notificationHub_closeNotification.tr()}\n', style: context.tooltipTextStyle(), ), TextSpan( text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', style: context .tooltipTextStyle() ?.copyWith(color: Theme.of(context).hintColor), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/number_red_dot.dart ================================================ import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class NumberedRedDot extends StatelessWidget { const NumberedRedDot.desktop({ super.key, this.fontSize = 10, }) : size = const NumberedSize( min: Size.square(14), middle: Size(17, 14), max: Size(24, 14), ); const NumberedRedDot.mobile({ super.key, this.fontSize = 14, }) : size = const NumberedSize( min: Size.square(20), middle: Size(26, 20), max: Size(35, 20), ); const NumberedRedDot({ super.key, required this.size, required this.fontSize, }); final NumberedSize size; final double fontSize; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocBuilder( builder: (context, state) { int unreadReminder = 0; for (final reminder in state.reminders) { if (!reminder.isRead) unreadReminder++; } if (unreadReminder == 0) return SizedBox.shrink(); final overNumber = unreadReminder > 99; Size size = this.size.min; if (unreadReminder >= 10 && unreadReminder <= 99) { size = this.size.middle; } else if (unreadReminder > 99) { size = this.size.max; } return Container( width: size.width, height: size.height, decoration: BoxDecoration( color: theme.borderColorScheme.errorThick, borderRadius: BorderRadius.all(Radius.circular(size.height / 2)), ), child: Center( child: Text( overNumber ? '99+' : '$unreadReminder', textAlign: TextAlign.center, style: TextStyle( fontWeight: FontWeight.w500, color: Colors.white, fontSize: fontSize, height: 1, ), ), ), ); }, ); } } class NumberedSize { const NumberedSize({ required this.min, required this.middle, required this.max, }); final Size min; final Size middle; final Size max; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/reminder_extension.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:collection/collection.dart'; extension ReminderSort on Iterable { List sortByScheduledAt() => sorted((a, b) => b.scheduledAt.compareTo(a.scheduledAt)); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart ================================================ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class FlowyTabItem extends StatelessWidget { const FlowyTabItem({ super.key, required this.label, required this.isSelected, }); final String label; final bool isSelected; static const double mobileHeight = 40; static const EdgeInsets mobilePadding = EdgeInsets.symmetric(horizontal: 12); static const double desktopHeight = 26; static const EdgeInsets desktopPadding = EdgeInsets.symmetric(horizontal: 8); @override Widget build(BuildContext context) { return Tab( height: UniversalPlatform.isMobile ? mobileHeight : desktopHeight, child: Padding( padding: UniversalPlatform.isMobile ? mobilePadding : desktopPadding, child: FlowyText.regular( label, color: isSelected ? AFThemeExtension.of(context).textColor : Theme.of(context).hintColor, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/inbox_action_bar.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class InboxActionBar extends StatelessWidget { const InboxActionBar({ super.key, required this.showUnreadsOnly, }); final bool showUnreadsOnly; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( border: Border( bottom: BorderSide( color: AFThemeExtension.of(context).calloutBGColor, ), ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _MarkAsReadButton( onMarkAllRead: () => context .read() .add(const ReminderEvent.markAllRead()), ), _ToggleUnreadsButton( showUnreadsOnly: showUnreadsOnly, onToggled: (_) => context .read() .add(const NotificationFilterEvent.toggleShowUnreadsOnly()), ), ], ), ), ); } } class _ToggleUnreadsButton extends StatefulWidget { const _ToggleUnreadsButton({ required this.onToggled, this.showUnreadsOnly = false, }); final Function(bool) onToggled; final bool showUnreadsOnly; @override State<_ToggleUnreadsButton> createState() => _ToggleUnreadsButtonState(); } class _ToggleUnreadsButtonState extends State<_ToggleUnreadsButton> { late bool showUnreadsOnly = widget.showUnreadsOnly; @override Widget build(BuildContext context) { return SegmentedButton( onSelectionChanged: (Set newSelection) { setState(() => showUnreadsOnly = newSelection.first); widget.onToggled(showUnreadsOnly); }, showSelectedIcon: false, style: ButtonStyle( tapTargetSize: MaterialTapTargetSize.shrinkWrap, side: WidgetStatePropertyAll( BorderSide(color: Theme.of(context).dividerColor), ), shape: const WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: Corners.s6Border, ), ), foregroundColor: WidgetStateProperty.resolveWith( (state) { if (state.contains(WidgetState.selected)) { return Theme.of(context).colorScheme.onPrimary; } return AFThemeExtension.of(context).textColor; }, ), backgroundColor: WidgetStateProperty.resolveWith( (state) { if (state.contains(WidgetState.selected)) { return Theme.of(context).colorScheme.primary; } if (state.contains(WidgetState.hovered)) { return AFThemeExtension.of(context).lightGreyHover; } return Theme.of(context).cardColor; }, ), ), segments: [ ButtonSegment( value: false, label: Text( LocaleKeys.notificationHub_actions_showAll.tr(), style: const TextStyle(fontSize: 12), ), ), ButtonSegment( value: true, label: Text( LocaleKeys.notificationHub_actions_showUnreads.tr(), style: const TextStyle(fontSize: 12), ), ), ], selected: {showUnreadsOnly}, ); } } class _MarkAsReadButton extends StatefulWidget { const _MarkAsReadButton({this.onMarkAllRead}); final VoidCallback? onMarkAllRead; @override State<_MarkAsReadButton> createState() => _MarkAsReadButtonState(); } class _MarkAsReadButtonState extends State<_MarkAsReadButton> { bool _isHovering = false; @override Widget build(BuildContext context) { return Opacity( opacity: widget.onMarkAllRead != null ? 1 : 0.5, child: FlowyHover( onHover: (isHovering) => setState(() => _isHovering = isHovering), resetHoverOnRebuild: false, child: FlowyTextButton( LocaleKeys.notificationHub_actions_markAllRead.tr(), fontColor: widget.onMarkAllRead != null && _isHovering ? Theme.of(context).colorScheme.onSurface : AFThemeExtension.of(context).textColor, heading: FlowySvg( FlowySvgs.checklist_s, color: widget.onMarkAllRead != null && _isHovering ? Theme.of(context).colorScheme.onSurface : AFThemeExtension.of(context).textColor, ), hoverColor: widget.onMarkAllRead != null && _isHovering ? Theme.of(context).colorScheme.primary : null, onPressed: widget.onMarkAllRead, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; import 'package:appflowy/workspace/presentation/notifications/number_red_dot.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class NotificationButton extends StatefulWidget { const NotificationButton({ super.key, this.isHover = false, }); final bool isHover; @override State createState() => _NotificationButtonState(); } class _NotificationButtonState extends State { final mutex = PopoverMutex(); @override void initState() { super.initState(); getIt().add(const ReminderEvent.started()); } @override void dispose() { mutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: getIt(), child: BlocBuilder( builder: (homeSettingContext, homeSettingState) { return BlocBuilder( builder: (notificationSettingsContext, notificationSettingsState) { final homeSettingBloc = context.read(); return BlocBuilder( builder: (context, state) { return notificationSettingsState .isShowNotificationsIconEnabled ? _buildNotificationIcon( context, state.reminders, () => homeSettingBloc.add( HomeSettingEvent.collapseNotificationPanel(), ), ) : const SizedBox.shrink(); }, ); }, ); }, ), ); } Widget _buildNotificationIcon( BuildContext context, List reminders, VoidCallback onTap, ) { return SizedBox.square( dimension: 28.0, child: Stack( children: [ Center( child: SizedBox.square( dimension: 28.0, child: FlowyButton( useIntrinsicWidth: true, margin: EdgeInsets.zero, text: FlowySvg( FlowySvgs.notification_s, color: widget.isHover ? Theme.of(context).colorScheme.onSurface : null, opacity: 0.7, ), onTap: onTap, ), ), ), Align( alignment: Alignment.topRight, child: NumberedRedDot.desktop(), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_content_v2.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/shared.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class NotificationItemContentV2 extends StatelessWidget { const NotificationItemContentV2({super.key, required this.reminder}); final ReminderPB reminder; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final view = state.view; if (view == null) { return const SizedBox.shrink(); } final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _buildHeader(state.scheduledAt, theme), _buildPageName(context, state.isLocked, state.pageTitle, theme), _buildContent(view, state.nodes, theme), ], ); }, ); } Widget _buildHeader(String createAt, AppFlowyThemeData theme) { return SizedBox( height: 22, child: Row( children: [ FlowyText.medium( LocaleKeys.settings_notifications_titles_reminder.tr(), fontSize: 14, figmaLineHeight: 22, color: theme.textColorScheme.primary, ), Spacer(), if (createAt.isNotEmpty) FlowyText.regular( createAt, fontSize: 12, figmaLineHeight: 16, color: theme.textColorScheme.secondary, ), if (!reminder.isRead) ...[ HSpace(4), const UnreadRedDot(), ], ], ), ); } Widget _buildPageName( BuildContext context, bool isLocked, String pageTitle, AppFlowyThemeData theme, ) { return SizedBox( height: 18, child: Row( children: [ FlowyText.regular( LocaleKeys.notificationHub_mentionedYou.tr(), fontSize: 12, figmaLineHeight: 18, color: theme.textColorScheme.secondary, ), const NotificationEllipse(), if (isLocked) Padding( padding: EdgeInsets.only(right: 5), child: FlowySvg( FlowySvgs.notification_lock_s, color: theme.iconColorScheme.secondary, ), ), Flexible( child: FlowyText.regular( pageTitle, fontSize: 12, figmaLineHeight: 18, color: theme.textColorScheme.secondary, overflow: TextOverflow.ellipsis, ), ), ], ), ); } Widget _buildContent( ViewPB view, List? nodes, AppFlowyThemeData theme, ) { if (view.layout.isDocumentView && nodes != null) { return IntrinsicHeight( child: BlocProvider( create: (context) => DocumentPageStyleBloc(view: view), child: NotificationDocumentContent(reminder: reminder, nodes: nodes), ), ); } else if (view.layout.isDatabaseView) { return FlowyText( reminder.message, fontSize: 14, figmaLineHeight: 22, color: theme.textColorScheme.primary, maxLines: 3, overflow: TextOverflow.ellipsis, ); } return const SizedBox.shrink(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_hub_title.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class NotificationHubTitle extends StatelessWidget { const NotificationHubTitle({ super.key, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(top: 12, bottom: 4), child: FlowyText.semibold( LocaleKeys.notificationHub_title.tr(), color: Theme.of(context).colorScheme.tertiary, fontSize: 16, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class NotificationItem extends StatefulWidget { const NotificationItem({ super.key, required this.reminder, required this.title, required this.scheduled, required this.body, required this.isRead, this.block, this.includeTime = false, this.readOnly = false, this.onAction, this.onReadChanged, this.view, }); final ReminderPB reminder; final String title; final Int64 scheduled; final String body; final bool isRead; final ViewPB? view; /// If [block] is provided, then [body] will be shown only if /// [block] fails to fetch. /// /// [block] is rendered as a result of a [FutureBuilder]. /// final Future? block; final bool includeTime; final bool readOnly; final void Function(int? path)? onAction; final void Function(bool isRead)? onReadChanged; @override State createState() => _NotificationItemState(); } class _NotificationItemState extends State { final PopoverMutex mutex = PopoverMutex(); bool _isHovering = false; int? path; late final String infoString; @override void initState() { super.initState(); widget.block?.then((b) => path = b?.path.first); infoString = _buildInfoString(); } @override void dispose() { mutex.dispose(); super.dispose(); } String _buildInfoString() { String scheduledString = _scheduledString(widget.scheduled, widget.includeTime); if (widget.view != null) { scheduledString = '$scheduledString - ${widget.view!.name}'; } return scheduledString; } @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => _onHover(true), onExit: (_) => _onHover(false), cursor: widget.onAction != null ? SystemMouseCursors.click : MouseCursor.defer, child: Stack( children: [ GestureDetector( onTap: () => widget.onAction?.call(path), child: AbsorbPointer( child: DecoratedBox( decoration: BoxDecoration( border: Border( bottom: UniversalPlatform.isMobile ? BorderSide( color: AFThemeExtension.of(context).calloutBGColor, ) : BorderSide.none, ), ), child: Opacity( opacity: widget.isRead && !widget.readOnly ? 0.5 : 1, child: DecoratedBox( decoration: BoxDecoration( color: _isHovering && widget.onAction != null ? AFThemeExtension.of(context).lightGreyHover : Colors.transparent, border: widget.isRead || widget.readOnly ? null : Border( left: BorderSide( width: UniversalPlatform.isMobile ? 4 : 2, color: Theme.of(context).colorScheme.primary, ), ), ), child: Padding( padding: const EdgeInsets.symmetric( vertical: 10, horizontal: 16, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowySvg( FlowySvgs.time_s, size: Size.square( UniversalPlatform.isMobile ? 24 : 20, ), color: AFThemeExtension.of(context).textColor, ), const HSpace(16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.semibold( widget.title, fontSize: UniversalPlatform.isMobile ? 16 : 14, color: AFThemeExtension.of(context).textColor, ), FlowyText.regular( infoString, fontSize: UniversalPlatform.isMobile ? 12 : 10, ), const VSpace(5), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( borderRadius: Corners.s8Border, color: Theme.of(context).colorScheme.surface, ), child: _NotificationContent( block: widget.block, reminder: widget.reminder, body: widget.body, ), ), ], ), ), ], ), ), ), ), ), ), ), if (UniversalPlatform.isMobile && !widget.readOnly || _isHovering && !widget.readOnly) Positioned( right: UniversalPlatform.isMobile ? 8 : 4, top: UniversalPlatform.isMobile ? 8 : 4, child: NotificationItemActions( isRead: widget.isRead, onReadChanged: widget.onReadChanged, ), ), ], ), ); } String _scheduledString(Int64 secondsSinceEpoch, bool includeTime) { final appearance = context.read().state; return appearance.dateFormat.formatDate( DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch.toInt() * 1000), includeTime, appearance.timeFormat, ); } void _onHover(bool isHovering) => setState(() => _isHovering = isHovering); } class _NotificationContent extends StatelessWidget { const _NotificationContent({ required this.body, required this.reminder, required this.block, }); final String body; final ReminderPB reminder; final Future? block; @override Widget build(BuildContext context) { return FutureBuilder( future: block, builder: (context, snapshot) { if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { return FlowyText.regular(body, maxLines: 4); } return IntrinsicHeight( child: NotificationDocumentContent( nodes: [snapshot.data!], reminder: reminder, ), ); }, ); } } class NotificationItemActions extends StatelessWidget { const NotificationItemActions({ super.key, required this.isRead, this.onReadChanged, }); final bool isRead; final void Function(bool isRead)? onReadChanged; @override Widget build(BuildContext context) { final double size = UniversalPlatform.isMobile ? 40.0 : 30.0; return Container( height: size, decoration: BoxDecoration( color: Theme.of(context).cardColor, border: Border.all( color: AFThemeExtension.of(context).lightGreyHover, ), borderRadius: BorderRadius.circular(6), ), child: IntrinsicHeight( child: Row( children: [ if (isRead) ...[ FlowyIconButton( height: size, width: size, radius: BorderRadius.circular(4), tooltipText: LocaleKeys.reminderNotification_tooltipMarkUnread.tr(), icon: const FlowySvg(FlowySvgs.restore_s), iconColorOnHover: Theme.of(context).colorScheme.onSurface, onPressed: () => onReadChanged?.call(false), ), ] else ...[ FlowyIconButton( height: size, width: size, radius: BorderRadius.circular(4), tooltipText: LocaleKeys.reminderNotification_tooltipMarkRead.tr(), iconColorOnHover: Theme.of(context).colorScheme.onSurface, icon: const FlowySvg(FlowySvgs.messages_s), onPressed: () => onReadChanged?.call(true), ), ], ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item_v2.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'notification_content_v2.dart'; class NotificationItemV2 extends StatelessWidget { const NotificationItemV2({ super.key, required this.tabType, required this.reminder, }); final NotificationTabType tabType; final ReminderPB reminder; @override Widget build(BuildContext context) { final settings = context.read().state; final dateFormate = settings.dateFormat; final timeFormate = settings.timeFormat; return BlocProvider( create: (context) => NotificationReminderBloc() ..add( NotificationReminderEvent.initial( reminder, dateFormate, timeFormate, ), ), child: BlocBuilder( builder: (context, state) { final reminderBloc = context.read(); final homeSetting = context.read(); if (state.status == NotificationReminderStatus.loading || state.status == NotificationReminderStatus.initial) { return const SizedBox.shrink(); } if (state.status == NotificationReminderStatus.error) { // error handle. return const SizedBox.shrink(); } final child = Padding( padding: const EdgeInsets.fromLTRB(16, 14, 14, 10), child: _InnerNotificationItem( tabType: tabType, reminder: reminder, ), ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: FlowyHover( style: HoverStyle( hoverColor: Theme.of(context).colorScheme.secondary, borderRadius: BorderRadius.all(Radius.circular(8)), ), resetHoverOnRebuild: false, builder: (context, hover) { return GestureDetector( behavior: HitTestBehavior.translucent, child: Stack( children: [ child, if (hover) buildActions(context), ], ), onTap: () async { final view = state.view; if (view == null) { return; } homeSetting .add(HomeSettingEvent.collapseNotificationPanel()); final documentFuture = DocumentService().openDocument( documentId: reminder.objectId, ); final blockId = reminder.meta[ReminderMetaKeys.blockId]; int? path; if (blockId != null) { final node = await _getNodeFromDocument(documentFuture, blockId); path = node?.path.first; } reminderBloc.add( ReminderEvent.pressReminder( reminderId: reminder.id, path: path, ), ); }, ); }, ), ); }, ), ); } Widget buildActions(BuildContext context) { final theme = AppFlowyTheme.of(context); final borderColor = theme.borderColorScheme.primary; final decoration = BoxDecoration( border: Border.all(color: borderColor), borderRadius: BorderRadius.all(Radius.circular(6)), color: theme.surfaceColorScheme.primary, ); Widget child; if (tabType == NotificationTabType.archive) { child = Container( width: 32, height: 28, decoration: decoration, child: FlowyIconButton( tooltipText: LocaleKeys.notificationHub_unarchiveTooltip.tr(), icon: FlowySvg(FlowySvgs.notification_unarchive_s), onPressed: () { context.read().add( ReminderEvent.update( ReminderUpdate( id: reminder.id, isArchived: false, ), ), ); }, width: 24, height: 24, ), ); } else { child = Container( padding: EdgeInsets.fromLTRB(4, 2, 4, 2), decoration: decoration, child: Row( children: [ if (!reminder.isRead) ...[ FlowyIconButton( tooltipText: LocaleKeys.notificationHub_markAsReadTooltip.tr(), icon: FlowySvg(FlowySvgs.notification_markasread_s), width: 24, height: 24, onPressed: () { context.read().add( ReminderEvent.update( ReminderUpdate( id: reminder.id, isRead: true, ), ), ); showToastNotification( message: LocaleKeys.notificationHub_markAsReadSucceedToast.tr(), ); }, ), HSpace(6), ], FlowyIconButton( tooltipText: LocaleKeys.notificationHub_archivedTooltip.tr(), icon: FlowySvg( FlowySvgs.notification_archive_s, ), width: 24, height: 24, onPressed: () { context.read().add( ReminderEvent.update( ReminderUpdate( id: reminder.id, isArchived: true, isRead: true, ), ), ); showToastNotification( message: LocaleKeys.notificationHub_markAsArchivedSucceedToast .tr(), ); }, ), ], ), ); } return Positioned( top: 8, right: 8, child: child, ); } Future _getNodeFromDocument( Future> documentFuture, String blockId, ) async { final document = (await documentFuture).fold( (document) => document, (_) => null, ); if (document == null) { return null; } final rootNode = document.toDocument()?.root; if (rootNode == null) { return null; } return _searchById(rootNode, blockId); } Node? _searchById(Node current, String id) { if (current.id == id) { return current; } if (current.children.isNotEmpty) { for (final child in current.children) { final node = _searchById(child, id); if (node != null) { return node; } } } return null; } } class _InnerNotificationItem extends StatelessWidget { const _InnerNotificationItem({ required this.reminder, required this.tabType, }); final NotificationTabType tabType; final ReminderPB reminder; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ NotificationIcon(reminder: reminder, atSize: 14), const HSpace(12.0), Expanded( child: NotificationItemContentV2(reminder: reminder), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_tab.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/notifications/widgets/empty.dart'; import 'package:appflowy/shared/list_extension.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'notification_item_v2.dart'; import 'notification_tab_bar.dart'; class NotificationTab extends StatefulWidget { const NotificationTab({ super.key, required this.tabType, }); final NotificationTabType tabType; @override State createState() => _NotificationTabState(); } class _NotificationTabState extends State with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); return BlocBuilder( builder: (context, state) { final reminders = _filterReminders(state.reminders); if (reminders.isEmpty) return EmptyNotification(type: widget.tabType); final dateTimeNow = DateTime.now(); final List todayReminders = []; final List olderReminders = []; for (final reminder in reminders) { final scheduledAt = reminder.scheduledAt.toDateTime(); if (dateTimeNow.difference(scheduledAt).inDays < 1) { todayReminders.add(reminder); } else { olderReminders.add(reminder); } } final child = SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ buildReminders( LocaleKeys.notificationHub_today.tr(), todayReminders, ), buildReminders( LocaleKeys.notificationHub_older.tr(), olderReminders, ), ], ), ); return RefreshIndicator.adaptive( onRefresh: () async => _onRefresh(context), child: child, ); }, ); } Widget buildReminders( String title, List reminders, ) { if (reminders.isEmpty) return SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: FlowyText.regular( title, fontSize: 14, figmaLineHeight: 18, ), ), const VSpace(4), ...List.generate(reminders.length, (index) { final reminder = reminders[index]; return NotificationItemV2( key: ValueKey('${widget.tabType}_${reminder.id}'), tabType: widget.tabType, reminder: reminder, ); }), ], ); } Future _onRefresh(BuildContext context) async { context.read().add(const ReminderEvent.refresh()); // at least 0.5 seconds to dismiss the refresh indicator. // otherwise, it will be dismissed immediately. await context.read().stream.firstOrNull; await Future.delayed(const Duration(milliseconds: 500)); if (context.mounted) { showToastNotification( message: LocaleKeys.settings_notifications_refreshSuccess.tr(), ); } } List _filterReminders(List reminders) { switch (widget.tabType) { case NotificationTabType.inbox: return reminders.reversed .where((reminder) => !reminder.isArchived) .toList() .unique((reminder) => reminder.id); case NotificationTabType.archive: return reminders.reversed .where((reminder) => reminder.isArchived) .toList() .unique((reminder) => reminder.id); case NotificationTabType.unread: return reminders.reversed .where((reminder) => !reminder.isRead) .toList() .unique((reminder) => reminder.id); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_tab_bar.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum NotificationTabType { inbox, unread, archive; String get tr { switch (this) { case NotificationTabType.inbox: return LocaleKeys.settings_notifications_tabs_inbox.tr(); case NotificationTabType.unread: return LocaleKeys.settings_notifications_tabs_unread.tr(); case NotificationTabType.archive: return LocaleKeys.settings_notifications_tabs_archived.tr(); } } } class NotificationTabBar extends StatelessWidget { const NotificationTabBar({ super.key, required this.tabController, this.height = 32, required this.tabs, }); final double height; final List tabs; final TabController tabController; @override Widget build(BuildContext context) { final baseStyle = Theme.of(context).textTheme.bodyMedium; final labelStyle = baseStyle?.copyWith( fontWeight: FontWeight.w500, fontSize: 16.0, height: 22.0 / 16.0, ); final unselectedLabelStyle = baseStyle?.copyWith( fontWeight: FontWeight.w400, fontSize: 15.0, height: 22.0 / 15.0, ); return Container( height: height, padding: const EdgeInsets.only(left: 16), child: TabBar( controller: tabController, tabs: tabs.map((e) => Tab(text: e.tr)).toList(), indicatorSize: TabBarIndicatorSize.label, isScrollable: true, labelStyle: labelStyle, labelColor: baseStyle?.color, labelPadding: const EdgeInsets.only(right: 20), unselectedLabelStyle: unselectedLabelStyle, overlayColor: WidgetStateProperty.all(Colors.transparent), indicator: const RoundUnderlineTabIndicator( width: 28.0, borderSide: BorderSide( color: Color(0xFF00C8FF), width: 3, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart ================================================ import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notifications_hub_empty.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/material.dart'; /// Displays a Lsit of Notifications, currently used primarily to /// display Reminders. /// /// Optimized for both Mobile & Desktop use /// class NotificationsView extends StatelessWidget { const NotificationsView({ super.key, required this.shownReminders, required this.reminderBloc, required this.views, this.isUpcoming = false, this.onAction, this.onReadChanged, this.actionBar, }); final List shownReminders; final ReminderBloc reminderBloc; final List views; final bool isUpcoming; final Function(ReminderPB reminder, int? path, ViewPB? view)? onAction; final Function(ReminderPB reminder, bool isRead)? onReadChanged; final Widget? actionBar; @override Widget build(BuildContext context) { if (shownReminders.isEmpty) { return Column( children: [ if (actionBar != null) actionBar!, const Expanded(child: NotificationsHubEmpty()), ], ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (actionBar != null) actionBar!, Expanded( child: SingleChildScrollView( child: Column( children: [ ...shownReminders.map( (ReminderPB reminder) { final blockId = reminder.meta[ReminderMetaKeys.blockId]; final documentService = DocumentService(); final documentFuture = documentService.openDocument( documentId: reminder.objectId, ); Future? nodeBuilder; if (blockId != null) { nodeBuilder = _getNodeFromDocument(documentFuture, blockId); } final view = views.findView(reminder.objectId); return NotificationItem( reminder: reminder, key: ValueKey(reminder.id), title: reminder.title, scheduled: reminder.scheduledAt, body: reminder.message, block: nodeBuilder, isRead: reminder.isRead, includeTime: reminder.includeTime ?? false, readOnly: isUpcoming, onReadChanged: (isRead) => onReadChanged?.call(reminder, isRead), onAction: (path) => onAction?.call(reminder, path, view), view: view, ); }, ), ], ), ), ), ], ); } Future _getNodeFromDocument( Future> documentFuture, String blockId, ) async { final document = (await documentFuture).fold( (document) => document, (_) => null, ); if (document == null) { return null; } final rootNode = document.toDocument()?.root; if (rootNode == null) { return null; } return _searchById(rootNode, blockId); } } /// Recursively iterates a [Node] and compares by its [id] /// Node? _searchById(Node current, String id) { if (current.id == id) { return current; } if (current.children.isNotEmpty) { for (final child in current.children) { final node = _searchById(child, id); if (node != null) { return node; } } } return null; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notifications_hub_empty.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class NotificationsHubEmpty extends StatelessWidget { const NotificationsHubEmpty({super.key}); @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ FlowyText( LocaleKeys.notificationHub_emptyTitle.tr(), fontWeight: FontWeight.w700, fontSize: 14, ), const VSpace(8), FlowyText.regular( LocaleKeys.notificationHub_emptyBody.tr(), textAlign: TextAlign.center, maxLines: 2, ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/about/app_version.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/version_checker/version_checker.dart'; import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SettingsAppVersion extends StatelessWidget { const SettingsAppVersion({ super.key, }); @override Widget build(BuildContext context) { return ApplicationInfo.isUpdateAvailable ? const _UpdateAppSection() : _buildIsUpToDate(context); } Widget _buildIsUpToDate(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( LocaleKeys.settings_accountPage_isUpToDate.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), VSpace(theme.spacing.s), Text( LocaleKeys.settings_accountPage_officialVersion.tr( namedArgs: { 'version': ApplicationInfo.applicationVersion, }, ), style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), ), ], ); } } class _UpdateAppSection extends StatelessWidget { const _UpdateAppSection(); @override Widget build(BuildContext context) { return Row( children: [ Expanded(child: _buildDescription(context)), _buildUpdateButton(), ], ); } Widget _buildUpdateButton() { return PrimaryRoundedButton( text: LocaleKeys.autoUpdate_settingsUpdateButton.tr(), margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), fontWeight: FontWeight.w500, radius: 8.0, onTap: () { Log.info('[AutoUpdater] Checking for updates'); versionChecker.checkForUpdate(); }, ); } Widget _buildDescription(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _buildRedDot(), const HSpace(6), Flexible( child: FlowyText.medium( LocaleKeys.autoUpdate_settingsUpdateTitle.tr( namedArgs: { 'newVersion': ApplicationInfo.latestVersion, }, ), figmaLineHeight: 17, overflow: TextOverflow.ellipsis, ), ), ], ), const VSpace(4), _buildCurrentVersionAndLatestVersion(context), ], ); } Widget _buildCurrentVersionAndLatestVersion(BuildContext context) { return Row( children: [ Flexible( child: Opacity( opacity: 0.7, child: FlowyText.regular( LocaleKeys.autoUpdate_settingsUpdateDescription.tr( namedArgs: { 'currentVersion': ApplicationInfo.applicationVersion, 'newVersion': ApplicationInfo.latestVersion, }, ), fontSize: 12, figmaLineHeight: 13, overflow: TextOverflow.ellipsis, ), ), ), const HSpace(6), MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () { afLaunchUrlString('https://www.appflowy.io/what-is-new'); }, child: FlowyText.regular( LocaleKeys.autoUpdate_settingsUpdateWhatsNew.tr(), decoration: TextDecoration.underline, color: Theme.of(context).colorScheme.primary, fontSize: 12, figmaLineHeight: 13, overflow: TextOverflow.ellipsis, ), ), ), ], ); } Widget _buildRedDot() { return Container( width: 8, height: 8, decoration: const BoxDecoration( color: Color(0xFFFB006D), shape: BoxShape.circle, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart ================================================ export 'account_deletion.dart'; export 'account_sign_in_out.dart'; export 'account_user_profile.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/util/navigator_context_extension.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; const _acceptableConfirmTexts = [ 'delete my account', 'deletemyaccount', 'DELETE MY ACCOUNT', 'DELETEMYACCOUNT', ]; class AccountDeletionButton extends StatefulWidget { const AccountDeletionButton({ super.key, }); @override State createState() => _AccountDeletionButtonState(); } class _AccountDeletionButtonState extends State { final textEditingController = TextEditingController(); final isCheckedNotifier = ValueNotifier(false); @override void dispose() { textEditingController.dispose(); isCheckedNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( LocaleKeys.button_deleteAccount.tr(), style: theme.textStyle.heading4.enhanced( color: theme.textColorScheme.primary, ), ), const VSpace(4), Text( LocaleKeys.newSettings_myAccount_deleteAccount_description.tr(), maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), ), ], ), ), AFOutlinedTextButton.destructive( text: LocaleKeys.button_deleteAccount.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.error, weight: FontWeight.w400, ), onTap: () { isCheckedNotifier.value = false; textEditingController.clear(); showCancelAndDeleteDialog( context: context, title: LocaleKeys.newSettings_myAccount_deleteAccount_title.tr(), description: '', builder: (_) => _AccountDeletionDialog( controller: textEditingController, isChecked: isCheckedNotifier, ), onDelete: () => deleteMyAccount( context, textEditingController.text.trim(), isCheckedNotifier.value, onSuccess: () { context.popToHome(); }, ), ); }, ), ], ); } } class _AccountDeletionDialog extends StatelessWidget { const _AccountDeletionDialog({ required this.controller, required this.isChecked, }); final TextEditingController controller; final ValueNotifier isChecked; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ FlowyText.regular( LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint1.tr(), fontSize: 14.0, figmaLineHeight: 18.0, maxLines: 2, color: ConfirmPopupColor.descriptionColor(context), ), const VSpace(12.0), FlowyTextField( hintText: LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(), controller: controller, ), const VSpace(16), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => isChecked.value = !isChecked.value, child: ValueListenableBuilder( valueListenable: isChecked, builder: (context, isChecked, _) { return FlowySvg( isChecked ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, size: const Size.square(16.0), blendMode: isChecked ? null : BlendMode.srcIn, ); }, ), ), const HSpace(6.0), Expanded( child: FlowyText.regular( LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint2 .tr(), fontSize: 14.0, figmaLineHeight: 16.0, maxLines: 3, color: ConfirmPopupColor.descriptionColor(context), ), ), ], ), ], ); } } bool _isConfirmTextValid(String text) { // don't convert the text to lower case or upper case, // just check if the text is in the list return _acceptableConfirmTexts.contains(text) || text == LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(); } Future deleteMyAccount( BuildContext context, String confirmText, bool isChecked, { VoidCallback? onSuccess, VoidCallback? onFailure, }) async { final bottomPadding = UniversalPlatform.isMobile ? MediaQuery.of(context).viewInsets.bottom : 0.0; if (!isChecked) { showToastNotification( type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys .newSettings_myAccount_deleteAccount_checkToConfirmError .tr(), ); return; } if (!context.mounted) { return; } if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { showToastNotification( type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys .newSettings_myAccount_deleteAccount_confirmTextValidationFailed .tr(), ); return; } final loading = Loading(context)..start(); await UserBackendService.deleteCurrentAccount().fold( (s) { Log.info('account deletion success'); loading.stop(); showToastNotification( message: LocaleKeys .newSettings_myAccount_deleteAccount_deleteAccountSuccess .tr(), ); // delay 1 second to make sure the toast notification is shown Future.delayed(const Duration(seconds: 1), () async { onSuccess?.call(); // restart the application await runAppFlowy(); }); }, (f) { Log.error('account deletion failed, error: $f'); loading.stop(); showToastNotification( type: ToastificationType.error, bottomPadding: bottomPadding, message: f.msg, ); onFailure?.call(); }, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/password/password_bloc.dart'; import 'package:appflowy/user/application/prelude.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart'; import 'package:appflowy/util/navigator_context_extension.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/change_password.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/setup_password.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AccountSignInOutSection extends StatelessWidget { const AccountSignInOutSection({ super.key, required this.userProfile, required this.onAction, this.signIn = true, }); final UserProfilePB userProfile; final VoidCallback onAction; final bool signIn; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( children: [ Text( LocaleKeys.settings_accountPage_login_title.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), const Spacer(), AccountSignInOutButton( userProfile: userProfile, onAction: onAction, signIn: signIn, ), ], ); } } class AccountSignInOutButton extends StatelessWidget { const AccountSignInOutButton({ super.key, required this.userProfile, required this.onAction, this.signIn = true, }); final UserProfilePB userProfile; final VoidCallback onAction; final bool signIn; @override Widget build(BuildContext context) { return AFFilledTextButton.primary( text: signIn ? LocaleKeys.settings_accountPage_login_loginLabel.tr() : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), onTap: () => signIn ? _showSignInDialog(context) : _showLogoutDialog(context), ); } void _showLogoutDialog(BuildContext context) { showCancelAndConfirmDialog( context: context, title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), description: LocaleKeys.settings_menu_logoutPrompt.tr(), confirmLabel: LocaleKeys.button_yes.tr(), onConfirm: (_) async { await getIt().signOut(); onAction(); }, ); } Future _showSignInDialog(BuildContext context) async { await showDialog( context: context, builder: (context) => BlocProvider( create: (context) => getIt(), child: const FlowyDialog( constraints: BoxConstraints(maxHeight: 485, maxWidth: 375), child: _SignInDialogContent(), ), ), ); } } class ChangePasswordSection extends StatelessWidget { const ChangePasswordSection({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocBuilder( builder: (context, state) { return Row( children: [ Text( LocaleKeys.newSettings_myAccount_password_title.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), const Spacer(), state.hasPassword ? AFFilledTextButton.primary( text: LocaleKeys .newSettings_myAccount_password_changePassword .tr(), onTap: () => _showChangePasswordDialog(context), ) : AFFilledTextButton.primary( text: LocaleKeys .newSettings_myAccount_password_setupPassword .tr(), onTap: () => _showSetPasswordDialog(context), ), ], ); }, ); } Future _showChangePasswordDialog(BuildContext context) async { final theme = AppFlowyTheme.of(context); await showDialog( context: context, barrierDismissible: false, builder: (_) => MultiBlocProvider( providers: [ BlocProvider.value( value: context.read(), ), BlocProvider.value( value: getIt(), ), ], child: Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(theme.borderRadius.xl), ), child: ChangePasswordDialogContent( userProfile: userProfile, ), ), ), ); } Future _showSetPasswordDialog(BuildContext context) async { final theme = AppFlowyTheme.of(context); await showDialog( context: context, barrierDismissible: false, builder: (_) => MultiBlocProvider( providers: [ BlocProvider.value( value: context.read(), ), BlocProvider.value( value: getIt(), ), ], child: Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(theme.borderRadius.xl), ), child: SetupPasswordDialogContent( userProfile: userProfile, ), ), ), ); } } class _SignInDialogContent extends StatelessWidget { const _SignInDialogContent(); @override Widget build(BuildContext context) { return ScaffoldMessenger( child: Scaffold( body: Padding( padding: const EdgeInsets.all(24), child: SingleChildScrollView( child: Column( children: [ const _DialogHeader(), const _DialogTitle(), const VSpace(16), const ContinueWithEmailAndPassword(), if (isAuthEnabled) ...[ const VSpace(20), const _OrDivider(), const VSpace(10), SettingThirdPartyLogin( didLogin: () { context.popToHome(); }, ), ], ], ), ), ), ), ); } } class _DialogHeader extends StatelessWidget { const _DialogHeader(); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildBackButton(context), _buildCloseButton(context), ], ); } Widget _buildBackButton(BuildContext context) { return GestureDetector( onTap: Navigator.of(context).pop, child: MouseRegion( cursor: SystemMouseCursors.click, child: Row( children: [ const FlowySvg(FlowySvgs.arrow_back_m, size: Size.square(24)), const HSpace(8), FlowyText.semibold(LocaleKeys.button_back.tr(), fontSize: 16), ], ), ), ); } Widget _buildCloseButton(BuildContext context) { return GestureDetector( onTap: Navigator.of(context).pop, child: MouseRegion( cursor: SystemMouseCursors.click, child: FlowySvg( FlowySvgs.m_close_m, size: const Size.square(20), color: Theme.of(context).colorScheme.outline, ), ), ); } } class _DialogTitle extends StatelessWidget { const _DialogTitle(); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: FlowyText.medium( LocaleKeys.settings_accountPage_login_loginLabel.tr(), fontSize: 22, color: Theme.of(context).colorScheme.tertiary, maxLines: null, ), ), ], ); } } class _OrDivider extends StatelessWidget { const _OrDivider(); @override Widget build(BuildContext context) { return Row( children: [ const Flexible(child: Divider(thickness: 1)), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: FlowyText.regular(LocaleKeys.signIn_or.tr()), ), const Flexible(child: Divider(thickness: 1)), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../shared/icon_emoji_picker/tab.dart'; // Account name and account avatar class AccountUserProfile extends StatefulWidget { const AccountUserProfile({ super.key, required this.name, required this.iconUrl, this.onSave, }); final String name; final String iconUrl; final void Function(String)? onSave; @override State createState() => _AccountUserProfileState(); } class _AccountUserProfileState extends State { late final TextEditingController nameController = TextEditingController(text: widget.name); final FocusNode focusNode = FocusNode(); bool isEditing = false; bool isHovering = false; @override void initState() { super.initState(); focusNode ..addListener(_handleFocusChange) ..onKeyEvent = _handleKeyEvent; } @override void dispose() { nameController.dispose(); focusNode.removeListener(_handleFocusChange); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildAvatar(), const HSpace(16), Flexible( child: isEditing ? _buildEditingField() : _buildNameDisplay(), ), ], ); } Widget _buildAvatar() { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _showIconPickerDialog(context), child: FlowyHover( resetHoverOnRebuild: false, onHover: (state) => setState(() => isHovering = state), style: HoverStyle( hoverColor: Colors.transparent, borderRadius: BorderRadius.circular(100), ), child: FlowyTooltip( message: LocaleKeys.settings_accountPage_general_changeProfilePicture.tr(), verticalOffset: 28, child: AFAvatar( url: widget.iconUrl, name: widget.name, size: AFAvatarSize.l, ), ), ), ); } Widget _buildNameDisplay() { final theme = AppFlowyTheme.of(context); return Padding( padding: const EdgeInsets.only(top: 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( widget.name, overflow: TextOverflow.ellipsis, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), ), const HSpace(4), AFGhostButton.normal( size: AFButtonSize.s, padding: EdgeInsets.all(theme.spacing.xs), onTap: () => setState(() => isEditing = true), builder: (context, isHovering, disabled) => FlowySvg( FlowySvgs.toolbar_link_edit_m, size: const Size.square(20), ), ), ], ), ); } Widget _buildEditingField() { return SettingsInputField( textController: nameController, value: widget.name, focusNode: focusNode..requestFocus(), onCancel: () => setState(() => isEditing = false), onSave: (_) => _saveChanges(), ); } Future _showIconPickerDialog(BuildContext context) { return showDialog( context: context, builder: (dialogContext) => SimpleDialog( children: [ Container( height: 380, width: 360, margin: const EdgeInsets.all(0), child: FlowyIconEmojiPicker( tabs: const [PickerTabType.emoji], onSelectedEmoji: (r) { context .read() .add(SettingsUserEvent.updateUserIcon(iconUrl: r.emoji)); Navigator.of(dialogContext).pop(); }, ), ), ], ), ); } void _handleFocusChange() { if (!focusNode.hasFocus && isEditing && mounted) { _saveChanges(); } } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape && isEditing && mounted) { setState(() => isEditing = false); return KeyEventResult.handled; } return KeyEventResult.ignored; } void _saveChanges() { widget.onSave?.call(nameController.text); setState(() => isEditing = false); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/email/email_section.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/widgets.dart'; class SettingsEmailSection extends StatelessWidget { const SettingsEmailSection({ super.key, required this.userProfile, }); final UserProfilePB userProfile; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( LocaleKeys.settings_accountPage_email_title.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), VSpace(theme.spacing.s), Text( userProfile.email, style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/change_password.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/password/password_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/error_extensions.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ChangePasswordDialogContent extends StatefulWidget { const ChangePasswordDialogContent({ super.key, required this.userProfile, this.showTitle = true, this.showCloseAndSaveButton = true, this.showSaveButton = false, this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 16), }); final UserProfilePB userProfile; // display the title final bool showTitle; // display the desktop style close and save button final bool showCloseAndSaveButton; // display the mobile style save button final bool showSaveButton; final EdgeInsets padding; @override State createState() => _ChangePasswordDialogContentState(); } class _ChangePasswordDialogContentState extends State { final currentPasswordTextFieldKey = GlobalKey(); final newPasswordTextFieldKey = GlobalKey(); final confirmPasswordTextFieldKey = GlobalKey(); final currentPasswordController = TextEditingController(); final newPasswordController = TextEditingController(); final confirmPasswordController = TextEditingController(); final iconSize = 20.0; @override void dispose() { currentPasswordController.dispose(); newPasswordController.dispose(); confirmPasswordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocListener( listener: _onPasswordStateChanged, child: Container( padding: widget.padding, constraints: const BoxConstraints(maxWidth: 400), decoration: BoxDecoration( borderRadius: BorderRadius.circular(theme.borderRadius.xl), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.showTitle) ...[ _buildTitle(context), VSpace(theme.spacing.xl), ], ..._buildCurrentPasswordFields(context), VSpace(theme.spacing.xl), ..._buildNewPasswordFields(context), VSpace(theme.spacing.xl), ..._buildConfirmPasswordFields(context), VSpace(theme.spacing.xl), if (widget.showCloseAndSaveButton) ...[ _buildSubmitButton(context), ], if (widget.showSaveButton) ...[ _buildSaveButton(context), ], ], ), ), ); } Widget _buildTitle(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( LocaleKeys.newSettings_myAccount_password_changePassword.tr(), style: theme.textStyle.heading4.prominent( color: theme.textColorScheme.primary, ), ), const Spacer(), AFGhostButton.normal( size: AFButtonSize.s, padding: EdgeInsets.all(theme.spacing.xs), onTap: () => Navigator.of(context).pop(), builder: (context, isHovering, disabled) => FlowySvg( FlowySvgs.password_close_m, size: const Size.square(20), ), ), ], ); } List _buildCurrentPasswordFields(BuildContext context) { final theme = AppFlowyTheme.of(context); return [ Text( LocaleKeys.newSettings_myAccount_password_currentPassword.tr(), style: theme.textStyle.caption.enhanced( color: theme.textColorScheme.secondary, ), ), VSpace(theme.spacing.xs), AFTextField( key: currentPasswordTextFieldKey, controller: currentPasswordController, hintText: LocaleKeys .newSettings_myAccount_password_hint_enterYourCurrentPassword .tr(), keyboardType: TextInputType.visiblePassword, obscureText: true, autofillHints: const [AutofillHints.password], suffixIconConstraints: BoxConstraints.tightFor( width: iconSize + theme.spacing.m, height: iconSize, ), suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( isObscured: isObscured, onTap: () { currentPasswordTextFieldKey.currentState?.syncObscured(!isObscured); }, ), ), ]; } List _buildNewPasswordFields(BuildContext context) { final theme = AppFlowyTheme.of(context); return [ Text( LocaleKeys.newSettings_myAccount_password_newPassword.tr(), style: theme.textStyle.caption.enhanced( color: theme.textColorScheme.secondary, ), ), VSpace(theme.spacing.xs), AFTextField( key: newPasswordTextFieldKey, controller: newPasswordController, hintText: LocaleKeys .newSettings_myAccount_password_hint_enterYourNewPassword .tr(), keyboardType: TextInputType.visiblePassword, obscureText: true, autofillHints: const [AutofillHints.password], suffixIconConstraints: BoxConstraints.tightFor( width: iconSize + theme.spacing.m, height: iconSize, ), suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( isObscured: isObscured, onTap: () { newPasswordTextFieldKey.currentState?.syncObscured(!isObscured); }, ), ), ]; } List _buildConfirmPasswordFields(BuildContext context) { final theme = AppFlowyTheme.of(context); return [ Text( LocaleKeys.newSettings_myAccount_password_confirmNewPassword.tr(), style: theme.textStyle.caption.enhanced( color: theme.textColorScheme.secondary, ), ), VSpace(theme.spacing.xs), AFTextField( key: confirmPasswordTextFieldKey, controller: confirmPasswordController, hintText: LocaleKeys .newSettings_myAccount_password_hint_confirmYourNewPassword .tr(), keyboardType: TextInputType.visiblePassword, obscureText: true, autofillHints: const [AutofillHints.password], suffixIconConstraints: BoxConstraints.tightFor( width: iconSize + theme.spacing.m, height: iconSize, ), suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( isObscured: isObscured, onTap: () { confirmPasswordTextFieldKey.currentState?.syncObscured(!isObscured); }, ), ), ]; } Widget _buildSubmitButton(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ AFOutlinedTextButton.normal( text: LocaleKeys.button_cancel.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.primary, weight: FontWeight.w400, ), onTap: () => Navigator.of(context).pop(), ), HSpace(theme.spacing.l), AFFilledTextButton.primary( text: LocaleKeys.button_save.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.onFill, weight: FontWeight.w400, ), onTap: () => _save(context), ), ], ); } Widget _buildSaveButton(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFFilledTextButton.primary( text: LocaleKeys.button_save.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.onFill, ), size: AFButtonSize.l, alignment: Alignment.center, onTap: () => _save(context), ); } void _save(BuildContext context) async { _resetError(); final currentPassword = currentPasswordController.text; final newPassword = newPasswordController.text; final confirmPassword = confirmPasswordController.text; if (currentPassword.isEmpty) { currentPasswordTextFieldKey.currentState?.syncError( errorText: LocaleKeys .newSettings_myAccount_password_error_currentPasswordIsRequired .tr(), ); return; } if (newPassword.isEmpty) { newPasswordTextFieldKey.currentState?.syncError( errorText: LocaleKeys .newSettings_myAccount_password_error_newPasswordIsRequired .tr(), ); return; } if (confirmPassword.isEmpty) { confirmPasswordTextFieldKey.currentState?.syncError( errorText: LocaleKeys .newSettings_myAccount_password_error_confirmPasswordIsRequired .tr(), ); return; } if (newPassword != confirmPassword) { confirmPasswordTextFieldKey.currentState?.syncError( errorText: LocaleKeys .newSettings_myAccount_password_error_passwordsDoNotMatch .tr(), ); return; } if (newPassword == currentPassword) { newPasswordTextFieldKey.currentState?.syncError( errorText: LocaleKeys .newSettings_myAccount_password_error_newPasswordIsSameAsCurrent .tr(), ); return; } // all the verification passed, save the new password context.read().add( PasswordEvent.changePassword( oldPassword: currentPassword, newPassword: newPassword, ), ); } void _resetError() { currentPasswordTextFieldKey.currentState?.clearError(); newPasswordTextFieldKey.currentState?.clearError(); confirmPasswordTextFieldKey.currentState?.clearError(); } void _onPasswordStateChanged(BuildContext context, PasswordState state) { bool hasError = false; String message = ''; final changePasswordResult = state.changePasswordResult; final setPasswordResult = state.setupPasswordResult; if (changePasswordResult != null) { changePasswordResult.fold( (success) { message = LocaleKeys .newSettings_myAccount_password_toast_passwordUpdatedSuccessfully .tr(); }, (error) { hasError = true; message = LocaleKeys .newSettings_myAccount_password_toast_passwordUpdatedFailed .tr(); if (AFPasswordErrorExtension.incorrectPasswordPattern .hasMatch(error.msg)) { currentPasswordTextFieldKey.currentState?.syncError( errorText: AFPasswordErrorExtension.getErrorMessage(error), ); } else if (AFPasswordErrorExtension.tooShortPasswordPattern .hasMatch(error.msg)) { newPasswordTextFieldKey.currentState?.syncError( errorText: AFPasswordErrorExtension.getErrorMessage(error), ); } else if (AFPasswordErrorExtension.tooLongPasswordPattern .hasMatch(error.msg)) { newPasswordTextFieldKey.currentState?.syncError( errorText: AFPasswordErrorExtension.getErrorMessage(error), ); } else if (error.code == ErrorCode.NewPasswordTooWeak) { newPasswordTextFieldKey.currentState?.syncError( errorText: LocaleKeys.signIn_passwordMustContain.tr(), ); } else { newPasswordTextFieldKey.currentState?.syncError( errorText: error.msg, ); } }, ); } else if (setPasswordResult != null) { setPasswordResult.fold( (success) { message = LocaleKeys .newSettings_myAccount_password_toast_passwordSetupSuccessfully .tr(); }, (error) { hasError = true; }, ); } if (!state.isSubmitting && message.isNotEmpty) { if (!hasError) { showToastNotification( message: message, ); Navigator.of(context).pop(); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/error_extensions.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:easy_localization/easy_localization.dart'; class AFPasswordErrorExtension { static final RegExp incorrectPasswordPattern = RegExp('Incorrect current password'); static final RegExp tooShortPasswordPattern = RegExp(r'Password should be at least (\d+) characters'); static final RegExp tooLongPasswordPattern = RegExp(r'Password cannot be longer than (\d+) characters'); static String getErrorMessage(FlowyError error) { final msg = error.msg; if (incorrectPasswordPattern.hasMatch(msg)) { return LocaleKeys .newSettings_myAccount_password_error_currentPasswordIsIncorrect .tr(); } else if (tooShortPasswordPattern.hasMatch(msg)) { return LocaleKeys .newSettings_myAccount_password_error_passwordShouldBeAtLeast6Characters .tr( namedArgs: { 'min': tooShortPasswordPattern.firstMatch(msg)?.group(1) ?? '6', }, ); } else if (tooLongPasswordPattern.hasMatch(msg)) { return LocaleKeys .newSettings_myAccount_password_error_passwordCannotBeLongerThan72Characters .tr( namedArgs: { 'max': tooLongPasswordPattern.firstMatch(msg)?.group(1) ?? '72', }, ); } return msg; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class PasswordSuffixIcon extends StatelessWidget { const PasswordSuffixIcon({ super.key, required this.isObscured, required this.onTap, }); final bool isObscured; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( padding: EdgeInsets.only(right: theme.spacing.m), child: FlowySvg( isObscured ? FlowySvgs.show_s : FlowySvgs.hide_s, color: theme.textColorScheme.secondary, size: const Size.square(20), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/password/setup_password.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/password/password_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/error_extensions.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SetupPasswordDialogContent extends StatefulWidget { const SetupPasswordDialogContent({ super.key, required this.userProfile, this.showCloseAndSaveButton = true, this.showSaveButton = false, this.showTitle = true, this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 16), }); final UserProfilePB userProfile; // display the desktop style close and save button final bool showCloseAndSaveButton; // display the mobile style save button final bool showSaveButton; // display the title final bool showTitle; // padding final EdgeInsets padding; @override State createState() => _SetupPasswordDialogContentState(); } class _SetupPasswordDialogContentState extends State { final passwordTextFieldKey = GlobalKey(); final confirmPasswordTextFieldKey = GlobalKey(); final passwordController = TextEditingController(); final confirmPasswordController = TextEditingController(); final iconSize = 20.0; @override void dispose() { passwordController.dispose(); confirmPasswordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return BlocListener( listener: _onPasswordStateChanged, child: Container( padding: widget.padding, constraints: const BoxConstraints(maxWidth: 400), decoration: BoxDecoration( borderRadius: BorderRadius.circular(theme.borderRadius.xl), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.showTitle) ...[ _buildTitle(context), VSpace(theme.spacing.xl), ], ..._buildPasswordFields(context), VSpace(theme.spacing.xl), ..._buildConfirmPasswordFields(context), VSpace(theme.spacing.xl), if (widget.showCloseAndSaveButton) ...[ _buildSubmitButton(context), ], if (widget.showSaveButton) ...[ _buildSaveButton(context), ], ], ), ), ); } Widget _buildTitle(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( LocaleKeys.newSettings_myAccount_password_setupPassword.tr(), style: theme.textStyle.heading4.prominent( color: theme.textColorScheme.primary, ), ), const Spacer(), AFGhostButton.normal( size: AFButtonSize.s, padding: EdgeInsets.all(theme.spacing.xs), onTap: () => Navigator.of(context).pop(), builder: (context, isHovering, disabled) => FlowySvg( FlowySvgs.password_close_m, size: const Size.square(20), ), ), ], ); } List _buildPasswordFields(BuildContext context) { final theme = AppFlowyTheme.of(context); return [ Text( LocaleKeys.newSettings_myAccount_password_title.tr(), style: theme.textStyle.caption.enhanced( color: theme.textColorScheme.secondary, ), ), VSpace(theme.spacing.xs), AFTextField( key: passwordTextFieldKey, controller: passwordController, hintText: LocaleKeys .newSettings_myAccount_password_hint_confirmYourPassword .tr(), keyboardType: TextInputType.visiblePassword, obscureText: true, autofillHints: const [AutofillHints.password], suffixIconConstraints: BoxConstraints.tightFor( width: iconSize + theme.spacing.m, height: iconSize, ), suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( isObscured: isObscured, onTap: () { passwordTextFieldKey.currentState?.syncObscured(!isObscured); }, ), ), ]; } List _buildConfirmPasswordFields(BuildContext context) { final theme = AppFlowyTheme.of(context); return [ Text( LocaleKeys.newSettings_myAccount_password_confirmPassword.tr(), style: theme.textStyle.caption.enhanced( color: theme.textColorScheme.secondary, ), ), VSpace(theme.spacing.xs), AFTextField( key: confirmPasswordTextFieldKey, controller: confirmPasswordController, hintText: LocaleKeys .newSettings_myAccount_password_hint_confirmYourPassword .tr(), keyboardType: TextInputType.visiblePassword, obscureText: true, autofillHints: const [AutofillHints.password], suffixIconConstraints: BoxConstraints.tightFor( width: iconSize + theme.spacing.m, height: iconSize, ), suffixIconBuilder: (context, isObscured) => PasswordSuffixIcon( isObscured: isObscured, onTap: () { confirmPasswordTextFieldKey.currentState?.syncObscured(!isObscured); }, ), ), ]; } Widget _buildSubmitButton(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ AFOutlinedTextButton.normal( text: LocaleKeys.button_cancel.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.primary, weight: FontWeight.w400, ), onTap: () => Navigator.of(context).pop(), ), HSpace(theme.spacing.l), AFFilledTextButton.primary( text: LocaleKeys.button_save.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.onFill, weight: FontWeight.w400, ), onTap: () => _save(context), ), ], ); } Widget _buildSaveButton(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFFilledTextButton.primary( text: LocaleKeys.button_save.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.onFill, ), size: AFButtonSize.l, alignment: Alignment.center, onTap: () => _save(context), ); } void _save(BuildContext context) async { _resetError(); final password = passwordController.text; final confirmPassword = confirmPasswordController.text; if (password.isEmpty) { passwordTextFieldKey.currentState?.syncError( errorText: LocaleKeys .newSettings_myAccount_password_error_newPasswordIsRequired .tr(), ); return; } if (confirmPassword.isEmpty) { confirmPasswordTextFieldKey.currentState?.syncError( errorText: LocaleKeys .newSettings_myAccount_password_error_confirmPasswordIsRequired .tr(), ); return; } if (password != confirmPassword) { confirmPasswordTextFieldKey.currentState?.syncError( errorText: LocaleKeys .newSettings_myAccount_password_error_passwordsDoNotMatch .tr(), ); return; } // all the verification passed, save the password context.read().add( PasswordEvent.setupPassword( newPassword: password, ), ); } void _resetError() { passwordTextFieldKey.currentState?.clearError(); confirmPasswordTextFieldKey.currentState?.clearError(); } void _onPasswordStateChanged(BuildContext context, PasswordState state) { bool hasError = false; String message = ''; final setPasswordResult = state.setupPasswordResult; if (setPasswordResult != null) { setPasswordResult.fold( (success) { message = LocaleKeys .newSettings_myAccount_password_toast_passwordSetupSuccessfully .tr(); }, (error) { hasError = true; if (AFPasswordErrorExtension.tooShortPasswordPattern .hasMatch(error.msg)) { passwordTextFieldKey.currentState?.syncError( errorText: AFPasswordErrorExtension.getErrorMessage(error), ); } else if (AFPasswordErrorExtension.tooLongPasswordPattern .hasMatch(error.msg)) { passwordTextFieldKey.currentState?.syncError( errorText: AFPasswordErrorExtension.getErrorMessage(error), ); } else if (error.code == ErrorCode.NewPasswordTooWeak) { passwordTextFieldKey.currentState?.syncError( errorText: LocaleKeys.signIn_passwordMustContain.tr(), ); } else { passwordTextFieldKey.currentState?.syncError( errorText: error.msg, ); } }, ); } if (!state.isSubmitting && message.isNotEmpty) { if (!hasError) { showToastNotification( message: message, ); Navigator.of(context).pop(); } } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/fix_data_widget.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class FixDataWidget extends StatelessWidget { const FixDataWidget({super.key}); @override Widget build(BuildContext context) { return SettingsCategory( title: LocaleKeys.settings_manageDataPage_data_fixYourData.tr(), children: [ SingleSettingAction( labelMaxLines: 4, label: LocaleKeys.settings_manageDataPage_data_fixYourDataDescription .tr(), buttonLabel: LocaleKeys.settings_manageDataPage_data_fixButton.tr(), onPressed: () { WorkspaceDataManager.checkWorkspaceHealth(dryRun: true); }, ), ], ); } } class WorkspaceDataManager { static Future checkWorkspaceHealth({ required bool dryRun, }) async { try { final currentWorkspace = await UserBackendService.getCurrentWorkspace().getOrThrow(); // get all the views in the workspace final result = await ViewBackendService.getAllViews().getOrThrow(); final allViews = result.items; // dump all the views in the workspace dumpViews('all views', allViews); // get the workspace final workspaces = allViews.where( (e) => e.parentViewId == '' && e.id == currentWorkspace.id, ); dumpViews('workspaces', workspaces.toList()); if (workspaces.length != 1) { Log.error('Failed to fix workspace: workspace not found'); // there should be only one workspace return; } final workspace = workspaces.first; // check the health of the spaces await checkSpaceHealth(workspace: workspace, allViews: allViews); // check the health of the views await checkViewHealth(workspace: workspace, allViews: allViews); // add other checks here // ... } catch (e) { Log.error('Failed to fix space relation: $e'); } } static Future checkSpaceHealth({ required ViewPB workspace, required List allViews, bool dryRun = true, }) async { try { final workspaceChildViews = await ViewBackendService.getChildViews(viewId: workspace.id) .getOrThrow(); final workspaceChildViewIds = workspaceChildViews.map((e) => e.id).toSet(); final spaces = allViews.where((e) => e.isSpace).toList(); for (final space in spaces) { // the space is the top level view, so its parent view id should be the workspace id // and the workspace should have the space in its child views if (space.parentViewId != workspace.id || !workspaceChildViewIds.contains(space.id)) { Log.info('found an issue: space is not in the workspace: $space'); if (!dryRun) { // move the space to the workspace if it is not in the workspace await ViewBackendService.moveViewV2( viewId: space.id, newParentId: workspace.id, prevViewId: null, ); } workspaceChildViewIds.add(space.id); } } } catch (e) { Log.error('Failed to check space health: $e'); } } static Future> checkViewHealth({ ViewPB? workspace, List? allViews, bool dryRun = true, }) async { // Views whose parent view does not have the view in its child views final List unlistedChildViews = []; // Views whose parent is not in allViews final List orphanViews = []; // Row pages final List rowPageViews = []; try { if (workspace == null || allViews == null) { final currentWorkspace = await UserBackendService.getCurrentWorkspace().getOrThrow(); // get all the views in the workspace final result = await ViewBackendService.getAllViews().getOrThrow(); allViews = result.items; workspace = allViews.firstWhereOrNull( (e) => e.id == currentWorkspace.id, ); } for (final view in allViews) { if (view.parentViewId == '') { continue; } final parentView = allViews.firstWhereOrNull( (e) => e.id == view.parentViewId, ); if (parentView == null) { orphanViews.add(view); continue; } if (parentView.id == view.id) { rowPageViews.add(view); continue; } final childViewsOfParent = await ViewBackendService.getChildViews(viewId: parentView.id) .getOrThrow(); final result = childViewsOfParent.any((e) => e.id == view.id); if (!result) { unlistedChildViews.add(view); } } } catch (e) { Log.error('Failed to check space health: $e'); return []; } for (final view in unlistedChildViews) { Log.info( '[workspace] found an issue: view is not in the parent view\'s child views, view: ${view.toProto3Json()}}', ); } for (final view in orphanViews) { Log.info('[workspace] orphanViews: ${view.toProto3Json()}'); } for (final view in rowPageViews) { Log.info('[workspace] rowPageViews: ${view.toProto3Json()}'); } if (!dryRun && unlistedChildViews.isNotEmpty) { Log.info( '[workspace] start to fix ${unlistedChildViews.length} unlistedChildViews ...', ); for (final view in unlistedChildViews) { // move the view to the parent view if it is not in the parent view's child views Log.info( '[workspace] move view: $view to its parent view ${view.parentViewId}', ); await ViewBackendService.moveViewV2( viewId: view.id, newParentId: view.parentViewId, prevViewId: null, ); } Log.info('[workspace] end to fix unlistedChildViews'); } if (unlistedChildViews.isEmpty && orphanViews.isEmpty) { Log.info('[workspace] all views are healthy'); } Log.info('[workspace] done checking view health'); return unlistedChildViews; } static void dumpViews(String prefix, List views) { for (int i = 0; i < views.length; i++) { final view = views[i]; Log.info('$prefix $i: $view)'); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:expandable/expandable.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'ollama_setting.dart'; import 'plugin_status_indicator.dart'; class LocalAISetting extends StatefulWidget { const LocalAISetting({super.key}); @override State createState() => _LocalAISettingState(); } class _LocalAISettingState extends State { final expandableController = ExpandableController(initialExpanded: false); @override void dispose() { expandableController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (context) => LocalAiPluginBloc(), child: BlocConsumer( listener: (context, state) { expandableController.value = state.isEnabled; }, builder: (context, state) { return ExpandablePanel( controller: expandableController, theme: ExpandableThemeData( tapBodyToCollapse: false, hasIcon: false, tapBodyToExpand: false, tapHeaderToExpand: false, ), header: LocalAiSettingHeader( isEnabled: state.isEnabled, ), collapsed: const SizedBox.shrink(), expanded: Padding( padding: EdgeInsets.only(top: 12), child: LocalAISettingPanel(), ), ); }, ), ); } } class LocalAiSettingHeader extends StatelessWidget { const LocalAiSettingHeader({ super.key, required this.isEnabled, }); final bool isEnabled; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), HSpace(theme.spacing.s), FlowyTooltip( message: LocaleKeys.workspace_learnMore.tr(), child: AFGhostButton.normal( padding: EdgeInsets.zero, builder: (context, isHovering, disabled) { return FlowySvg( FlowySvgs.ai_explain_m, size: Size.square(20), ); }, onTap: () { afLaunchUrlString( 'https://appflowy.com/guide/appflowy-local-ai-ollama', ); }, ), ), ], ), const VSpace(4), FlowyText( LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(), maxLines: 3, fontSize: 12, ), ], ), ), Toggle( value: isEnabled, onChanged: (value) { _onToggleChanged(value, context); }, ), ], ); } void _onToggleChanged(bool value, BuildContext context) { if (value) { context.read().add(const LocalAiPluginEvent.toggle()); } else { showConfirmDialog( context: context, title: LocaleKeys.settings_aiPage_keys_disableLocalAITitle.tr(), description: LocaleKeys.settings_aiPage_keys_disableLocalAIDescription.tr(), confirmLabel: LocaleKeys.button_confirm.tr(), onConfirm: (_) { context .read() .add(const LocalAiPluginEvent.toggle()); }, ); } } } class LocalAISettingPanel extends StatelessWidget { const LocalAISettingPanel({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state is! ReadyLocalAiPluginState) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const LocalAIStatusIndicator(), const VSpace(10), OllamaSettingPage(), ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_settings_ai_view.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class LocalSettingsAIView extends StatelessWidget { const LocalSettingsAIView({ super.key, required this.userProfile, required this.workspaceId, }); final UserProfilePB userProfile; final String workspaceId; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => SettingsAIBloc(userProfile, workspaceId) ..add(const SettingsAIEvent.started()), child: SettingsBody( title: LocaleKeys.settings_aiPage_title.tr(), description: "", children: [ const LocalAISetting(), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart ================================================ import 'package:appflowy/ai/ai.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AIModelSelection extends StatelessWidget { const AIModelSelection({super.key}); static const double height = 49; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final models = state.availableModels?.models; if (models == null) { return const SizedBox( // Using same height as SettingsDropdown to avoid layout shift height: height, ); } final localModels = models.where((model) => model.isLocal).toList(); final cloudModels = models.where((model) => !model.isLocal).toList(); final selectedModel = state.availableModels!.selectedModel; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ Expanded( child: FlowyText.medium( LocaleKeys.settings_aiPage_keys_llmModelType.tr(), overflow: TextOverflow.ellipsis, ), ), Flexible( child: SettingsDropdown( key: ValueKey(selectedModel.name), onChanged: (model) => context .read() .add(SettingsAIEvent.selectModel(model)), selectedOption: selectedModel, selectOptionCompare: (left, right) => left?.name == right?.name, options: [...localModels, ...cloudModels] .map( (model) => buildDropdownMenuEntry( context, value: model, label: model.isLocal ? "${model.i18n} 🔐" : model.i18n, subLabel: model.desc, maximumHeight: height, ), ) .toList(), ), ), ], ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart ================================================ import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; import 'package:easy_localization/easy_localization.dart'; class OllamaSettingPage extends StatelessWidget { const OllamaSettingPage({super.key}); @override Widget build(BuildContext context) { return BlocProvider( create: (context) => OllamaSettingBloc()..add(const OllamaSettingEvent.started()), child: BlocBuilder( buildWhen: (previous, current) => previous.inputItems != current.inputItems || previous.isEdited != current.isEdited, builder: (context, state) { return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.all(Radius.circular(8.0)), ), padding: EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 10, children: [ for (final item in state.inputItems) _SettingItemWidget(item: item), const LocalAIModelSelection(), _SaveButton(isEdited: state.isEdited), ], ), ); }, ), ); } } class _SettingItemWidget extends StatelessWidget { const _SettingItemWidget({required this.item}); final SettingItem item; @override Widget build(BuildContext context) { return Column( key: ValueKey(item.content + item.settingType.title), crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText( item.settingType.title, fontSize: 12, figmaLineHeight: 16, ), const VSpace(4), SizedBox( height: 32, child: FlowyTooltip( message: item.editable ? null : LocaleKeys.settings_aiPage_keys_readOnlyField.tr(), child: FlowyTextField( autoFocus: false, hintText: item.hintText, text: item.content, readOnly: !item.editable, onChanged: item.editable ? (content) { context.read().add( OllamaSettingEvent.onEdit( content, item.settingType, ), ); } : null, ), ), ), ], ); } } class _SaveButton extends StatelessWidget { const _SaveButton({required this.isEdited}); final bool isEdited; @override Widget build(BuildContext context) { return Align( alignment: AlignmentDirectional.centerEnd, child: FlowyTooltip( message: isEdited ? null : 'No changes', child: SizedBox( child: FlowyButton( text: FlowyText( 'Apply', figmaLineHeight: 20, color: Theme.of(context).colorScheme.onPrimary, ), disable: !isEdited, expandText: false, margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primary.withAlpha(200), onTap: () { if (isEdited) { context .read() .add(const OllamaSettingEvent.submit()); } }, ), ), ), ); } } class LocalAIModelSelection extends StatelessWidget { const LocalAIModelSelection({super.key}); static const double height = 49; @override Widget build(BuildContext context) { return BlocBuilder( buildWhen: (previous, current) => previous.localModels != current.localModels, builder: (context, state) { final models = state.localModels; if (models == null) { return const SizedBox( // Using same height as SettingsDropdown to avoid layout shift height: height, ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.medium( LocaleKeys.settings_aiPage_keys_globalLLMModel.tr(), fontSize: 12, figmaLineHeight: 16, ), const VSpace(4), SizedBox( height: 40, child: SettingsDropdown( key: const Key('_AIModelSelection'), textStyle: Theme.of(context).textTheme.bodySmall, onChanged: (model) => context .read() .add(OllamaSettingEvent.setDefaultModel(model)), selectedOption: models.selectedModel, selectOptionCompare: (left, right) => left?.name == right?.name, options: models.models .map( (model) => buildDropdownMenuEntry( context, value: model, label: model.i18n, subLabel: model.desc, maximumHeight: height, ), ) .toList(), ), ), ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_status_indicator.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class LocalAIStatusIndicator extends StatelessWidget { const LocalAIStatusIndicator({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return state.maybeWhen( ready: (_, isReady, lackOfResource) { if (lackOfResource != null) { return _LackOfResource(resource: lackOfResource); } return switch (isReady) { true => const _LocalAIRunning(), false => const _RestartPluginButton(), }; }, orElse: () => const SizedBox.shrink(), ); }, ); } } class _RestartPluginButton extends StatelessWidget { const _RestartPluginButton(); @override Widget build(BuildContext context) { final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); return Container( decoration: BoxDecoration( color: Theme.of(context).isLightMode ? const Color(0x80FFE7EE) : const Color(0x80591734), borderRadius: BorderRadius.all(Radius.circular(8.0)), ), padding: const EdgeInsets.all(8.0), child: Row( children: [ const FlowySvg( FlowySvgs.toast_error_filled_s, size: Size.square(20.0), blendMode: null, ), const HSpace(8), Expanded( child: RichText( maxLines: 3, text: TextSpan( children: [ TextSpan( text: LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr(), style: textStyle, ), TextSpan( text: ' ', style: textStyle, ), TextSpan( text: LocaleKeys.settings_aiPage_keys_restartLocalAI.tr(), style: textStyle?.copyWith( fontWeight: FontWeight.w600, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () { context .read() .add(const LocalAiPluginEvent.restart()); }, ), ], ), ), ), ], ), ); } } class _LocalAIRunning extends StatelessWidget { const _LocalAIRunning(); @override Widget build(BuildContext context) { final runningText = LocaleKeys.settings_aiPage_keys_localAIRunning.tr(); return Container( decoration: const BoxDecoration( color: Color(0xFFEDF7ED), borderRadius: BorderRadius.all(Radius.circular(8.0)), ), padding: const EdgeInsets.all(8.0), child: Row( children: [ const FlowySvg( FlowySvgs.download_success_s, color: Color(0xFF2E7D32), ), const HSpace(6), Expanded( child: FlowyText( runningText, color: const Color(0xFF1E4620), maxLines: 3, ), ), ], ), ); } } class _LackOfResource extends StatelessWidget { const _LackOfResource({required this.resource}); final LackOfAIResourcePB resource; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: Theme.of(context).isLightMode ? const Color(0x80FFE7EE) : const Color(0x80591734), borderRadius: BorderRadius.all(Radius.circular(8.0)), ), padding: const EdgeInsets.all(8.0), child: Row( children: [ FlowySvg( FlowySvgs.toast_error_filled_s, size: const Size.square(20.0), blendMode: null, ), const HSpace(8), Expanded( child: switch (resource.resourceType) { LackOfAIResourceTypePB.PluginExecutableNotReady => _buildNoLAI(context), LackOfAIResourceTypePB.OllamaServerNotReady => _buildNoOllama(context), LackOfAIResourceTypePB.MissingModel => _buildNoModel(context, resource.missingModelNames), _ => const SizedBox.shrink(), }, ), ], ), ); } TextStyle? _textStyle(BuildContext context) { return Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5); } Widget _buildNoLAI(BuildContext context) { final textStyle = _textStyle(context); return RichText( maxLines: 3, text: TextSpan( children: [ TextSpan( text: LocaleKeys.settings_aiPage_keys_laiNotReady.tr(), style: textStyle, ), TextSpan(text: ' ', style: _textStyle(context)), ..._downloadInstructions(textStyle), ], ), ); } Widget _buildNoOllama(BuildContext context) { final textStyle = _textStyle(context); return RichText( maxLines: 3, text: TextSpan( children: [ TextSpan( text: LocaleKeys.settings_aiPage_keys_ollamaNotReady.tr(), style: textStyle, ), TextSpan(text: ' ', style: textStyle), ..._downloadInstructions(textStyle), ], ), ); } Widget _buildNoModel(BuildContext context, List modelNames) { final textStyle = _textStyle(context); return RichText( maxLines: 3, text: TextSpan( children: [ TextSpan( text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(), style: textStyle, ), TextSpan( text: modelNames.join(', '), style: textStyle, ), TextSpan( text: ' ', style: textStyle, ), TextSpan( text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(), style: textStyle, ), TextSpan( text: ' ', style: textStyle, ), TextSpan( text: LocaleKeys.settings_aiPage_keys_instructions.tr(), style: textStyle?.copyWith( fontWeight: FontWeight.w600, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () { afLaunchUrlString( "https://appflowy.com/guide/appflowy-local-ai-ollama", ); }, ), TextSpan( text: ' ', style: textStyle, ), TextSpan( text: LocaleKeys.settings_aiPage_keys_downloadModel.tr(), style: textStyle, ), ], ), ); } List _downloadInstructions(TextStyle? textStyle) { return [ TextSpan( text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(), style: textStyle, ), TextSpan( text: ' ', style: textStyle, ), TextSpan( text: LocaleKeys.settings_aiPage_keys_instructions.tr(), style: textStyle?.copyWith( fontWeight: FontWeight.w600, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () { afLaunchUrlString( "https://appflowy.com/guide/appflowy-local-ai-ollama", ); }, ), TextSpan(text: ' ', style: textStyle), TextSpan( text: LocaleKeys.settings_aiPage_keys_installOllamaLai.tr(), style: textStyle, ), ]; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsAIView extends StatelessWidget { const SettingsAIView({ super.key, required this.userProfile, required this.currentWorkspaceMemberRole, required this.workspaceId, }); final UserProfilePB userProfile; final AFRolePB? currentWorkspaceMemberRole; final String workspaceId; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => SettingsAIBloc(userProfile, workspaceId) ..add(const SettingsAIEvent.started()), child: SettingsBody( title: LocaleKeys.settings_aiPage_title.tr(), description: LocaleKeys.settings_aiPage_keys_aiSettingsDescription.tr(), children: [ const AIModelSelection(), const _AISearchToggle(value: false), const LocalAISetting(), ], ), ); } } class _AISearchToggle extends StatelessWidget { const _AISearchToggle({required this.value}); final bool value; @override Widget build(BuildContext context) { return Column( children: [ Row( children: [ FlowyText.medium( LocaleKeys.settings_aiPage_keys_enableAISearchTitle.tr(), ), const Spacer(), BlocBuilder( builder: (context, state) { if (state.aiSettings == null) { return const Padding( padding: EdgeInsets.only(top: 6), child: SizedBox( height: 26, width: 26, child: CircularProgressIndicator.adaptive(), ), ); } else { return Toggle( value: state.enableSearchIndexing, onChanged: (_) => context .read() .add(const SettingsAIEvent.toggleAISearch()), ); } }, ), ], ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/about/app_version.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/email/email_section.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsAccountView extends StatefulWidget { const SettingsAccountView({ super.key, required this.userProfile, required this.didLogin, required this.didLogout, }); final UserProfilePB userProfile; // Called when the user signs in from the setting dialog final VoidCallback didLogin; // Called when the user logout in the setting dialog final VoidCallback didLogout; @override State createState() => _SettingsAccountViewState(); } class _SettingsAccountViewState extends State { late String userName = widget.userProfile.name; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => getIt(param1: widget.userProfile) ..add(const SettingsUserEvent.initial()), child: BlocBuilder( builder: (context, state) { return SettingsBody( title: LocaleKeys.newSettings_myAccount_title.tr(), children: [ // user profile SettingsCategory( title: LocaleKeys.newSettings_myAccount_myProfile.tr(), children: [ AccountUserProfile( name: userName, iconUrl: state.userProfile.iconUrl, onSave: (newName) { // Pseudo change the name to update the UI before the backend // processes the request. This is to give the user a sense of // immediate feedback, and avoid UI flickering. setState(() => userName = newName); context .read() .add(SettingsUserEvent.updateUserName(name: newName)); }, ), ], ), // user email // Only show email if the user is authenticated and not using local auth if (isAuthEnabled && state.userProfile.userAuthType != AuthTypePB.Local) ...[ SettingsCategory( title: LocaleKeys.newSettings_myAccount_myAccount.tr(), children: [ SettingsEmailSection( userProfile: state.userProfile, ), ChangePasswordSection( userProfile: state.userProfile, ), AccountSignInOutSection( userProfile: state.userProfile, onAction: state.userProfile.userAuthType == AuthTypePB.Local ? widget.didLogin : widget.didLogout, signIn: state.userProfile.userAuthType == AuthTypePB.Local, ), ], ), ], if (isAuthEnabled && state.userProfile.userAuthType == AuthTypePB.Local) ...[ SettingsCategory( title: LocaleKeys.settings_accountPage_login_title.tr(), children: [ AccountSignInOutSection( userProfile: state.userProfile, onAction: state.userProfile.userAuthType == AuthTypePB.Local ? widget.didLogin : widget.didLogout, signIn: state.userProfile.userAuthType == AuthTypePB.Local, ), ], ), ], // App version SettingsCategory( title: LocaleKeys.newSettings_myAccount_aboutAppFlowy.tr(), children: const [ SettingsAppVersion(), ], ), // user deletion if (widget.userProfile.userAuthType == AuthTypePB.Server) const AccountDeletionButton(), ], ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart ================================================ import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_dashed_divider.dart'; import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../generated/locale_keys.g.dart'; const _buttonsMinWidth = 100.0; class SettingsBillingView extends StatefulWidget { const SettingsBillingView({ super.key, required this.workspaceId, required this.user, }); final String workspaceId; final UserProfilePB user; @override State createState() => _SettingsBillingViewState(); } class _SettingsBillingViewState extends State { Loading? loadingIndicator; RecurringIntervalPB? selectedInterval; final ValueNotifier enablePlanChangeNotifier = ValueNotifier(false); @override Widget build(BuildContext context) { return BlocProvider( create: (_) => SettingsBillingBloc( workspaceId: widget.workspaceId, userId: widget.user.id, )..add(const SettingsBillingEvent.started()), child: BlocConsumer( listenWhen: (previous, current) => previous.mapOrNull(ready: (s) => s.isLoading) != current.mapOrNull(ready: (s) => s.isLoading), listener: (context, state) { if (state.mapOrNull(ready: (s) => s.isLoading) == true) { loadingIndicator = Loading(context)..start(); } else { loadingIndicator?.stop(); loadingIndicator = null; } }, builder: (context, state) { return state.map( initial: (_) => const SizedBox.shrink(), loading: (_) => const Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator.adaptive(strokeWidth: 3), ), ), error: (state) { if (state.error != null) { return Padding( padding: const EdgeInsets.all(16), child: Center( child: AppFlowyErrorPage( error: state.error!, ), ), ); } return ErrorWidget.withDetails(message: 'Something went wrong!'); }, ready: (state) { final billingPortalEnabled = state.subscriptionInfo.isBillingPortalEnabled; return SettingsBody( title: LocaleKeys.settings_billingPage_title.tr(), children: [ SettingsCategory( title: LocaleKeys.settings_billingPage_plan_title.tr(), children: [ SingleSettingAction( onPressed: () => _openPricingDialog( context, widget.workspaceId, widget.user.id, state.subscriptionInfo, ), fontWeight: FontWeight.w500, label: state.subscriptionInfo.label, buttonLabel: LocaleKeys .settings_billingPage_plan_planButtonLabel .tr(), minWidth: _buttonsMinWidth, ), if (billingPortalEnabled) SingleSettingAction( onPressed: () { SettingsAlertDialog( title: LocaleKeys .settings_billingPage_changePeriod .tr(), enableConfirmNotifier: enablePlanChangeNotifier, children: [ ChangePeriod( plan: state.subscriptionInfo.planSubscription .subscriptionPlan, selectedInterval: state.subscriptionInfo .planSubscription.interval, onSelected: (interval) { enablePlanChangeNotifier.value = interval != state.subscriptionInfo.planSubscription .interval; selectedInterval = interval; }, ), ], confirm: () { if (selectedInterval != state.subscriptionInfo.planSubscription .interval) { context.read().add( SettingsBillingEvent.updatePeriod( plan: state .subscriptionInfo .planSubscription .subscriptionPlan, interval: selectedInterval!, ), ); } Navigator.of(context).pop(); }, ).show(context); }, label: LocaleKeys .settings_billingPage_plan_billingPeriod .tr(), description: state .subscriptionInfo.planSubscription.interval.label, fontWeight: FontWeight.w500, buttonLabel: LocaleKeys .settings_billingPage_plan_periodButtonLabel .tr(), minWidth: _buttonsMinWidth, ), ], ), if (billingPortalEnabled) SettingsCategory( title: LocaleKeys .settings_billingPage_paymentDetails_title .tr(), children: [ SingleSettingAction( onPressed: () => context .read() .add( const SettingsBillingEvent.openCustomerPortal(), ), label: LocaleKeys .settings_billingPage_paymentDetails_methodLabel .tr(), fontWeight: FontWeight.w500, buttonLabel: LocaleKeys .settings_billingPage_paymentDetails_methodButtonLabel .tr(), minWidth: _buttonsMinWidth, ), ], ), SettingsCategory( title: LocaleKeys.settings_billingPage_addons_title.tr(), children: [ _AITile( plan: SubscriptionPlanPB.AiMax, label: LocaleKeys .settings_billingPage_addons_aiMax_label .tr(), description: LocaleKeys .settings_billingPage_addons_aiMax_description, activeDescription: LocaleKeys .settings_billingPage_addons_aiMax_activeDescription, canceledDescription: LocaleKeys .settings_billingPage_addons_aiMax_canceledDescription, subscriptionInfo: state.subscriptionInfo.addOns.firstWhereOrNull( (a) => a.type == WorkspaceAddOnPBType.AddOnAiMax, ), ), const SettingsDashedDivider(), ], ), ], ); }, ); }, ), ); } void _openPricingDialog( BuildContext context, String workspaceId, Int64 userId, WorkspaceSubscriptionInfoPB subscriptionInfo, ) => showDialog( context: context, builder: (_) => BlocProvider( create: (_) => SettingsPlanBloc(workspaceId: workspaceId, userId: widget.user.id) ..add(const SettingsPlanEvent.started()), child: SettingsPlanComparisonDialog( workspaceId: workspaceId, subscriptionInfo: subscriptionInfo, ), ), ).then((didChangePlan) { if (didChangePlan == true && context.mounted) { context .read() .add(const SettingsBillingEvent.started()); } }); } class _AITile extends StatefulWidget { const _AITile({ required this.label, required this.description, required this.canceledDescription, required this.activeDescription, required this.plan, this.subscriptionInfo, }); final String label; final String description; final String canceledDescription; final String activeDescription; final SubscriptionPlanPB plan; final WorkspaceAddOnPB? subscriptionInfo; @override State<_AITile> createState() => _AITileState(); } class _AITileState extends State<_AITile> { RecurringIntervalPB? selectedInterval; final enableConfirmNotifier = ValueNotifier(false); @override Widget build(BuildContext context) { final isCanceled = widget.subscriptionInfo?.addOnSubscription.status == WorkspaceSubscriptionStatusPB.Canceled; final dateFormat = context.read().state.dateFormat; return Column( children: [ SingleSettingAction( label: widget.label, description: widget.subscriptionInfo != null && isCanceled ? widget.canceledDescription.tr( args: [ dateFormat.formatDate( widget.subscriptionInfo!.addOnSubscription.endDate .toDateTime(), false, ), ], ) : widget.subscriptionInfo != null ? widget.activeDescription.tr( args: [ dateFormat.formatDate( widget.subscriptionInfo!.addOnSubscription.endDate .toDateTime(), false, ), ], ) : widget.description.tr(), buttonLabel: widget.subscriptionInfo != null ? isCanceled ? LocaleKeys.settings_billingPage_addons_renewLabel.tr() : LocaleKeys.settings_billingPage_addons_removeLabel.tr() : LocaleKeys.settings_billingPage_addons_addLabel.tr(), fontWeight: FontWeight.w500, minWidth: _buttonsMinWidth, onPressed: () async { if (widget.subscriptionInfo != null) { await showConfirmDialog( context: context, style: ConfirmPopupStyle.cancelAndOk, title: LocaleKeys.settings_billingPage_addons_removeDialog_title .tr(args: [widget.plan.label]).tr(), description: LocaleKeys .settings_billingPage_addons_removeDialog_description .tr(namedArgs: {"plan": widget.plan.label.tr()}), confirmLabel: LocaleKeys.button_confirm.tr(), onConfirm: (_) => context .read() .add(SettingsBillingEvent.cancelSubscription(widget.plan)), ); } else { // Add the addon context .read() .add(SettingsBillingEvent.addSubscription(widget.plan)); } }, ), if (widget.subscriptionInfo != null) ...[ const VSpace(10), SingleSettingAction( label: LocaleKeys.settings_billingPage_planPeriod.tr( args: [ widget .subscriptionInfo!.addOnSubscription.subscriptionPlan.label, ], ), description: widget.subscriptionInfo!.addOnSubscription.interval.label, buttonLabel: LocaleKeys.settings_billingPage_plan_periodButtonLabel.tr(), minWidth: _buttonsMinWidth, onPressed: () { enableConfirmNotifier.value = false; SettingsAlertDialog( title: LocaleKeys.settings_billingPage_changePeriod.tr(), enableConfirmNotifier: enableConfirmNotifier, children: [ ChangePeriod( plan: widget .subscriptionInfo!.addOnSubscription.subscriptionPlan, selectedInterval: widget.subscriptionInfo!.addOnSubscription.interval, onSelected: (interval) { enableConfirmNotifier.value = interval != widget.subscriptionInfo!.addOnSubscription.interval; selectedInterval = interval; }, ), ], confirm: () { if (selectedInterval != widget.subscriptionInfo!.addOnSubscription.interval) { context.read().add( SettingsBillingEvent.updatePeriod( plan: widget.subscriptionInfo!.addOnSubscription .subscriptionPlan, interval: selectedInterval!, ), ); } Navigator.of(context).pop(); }, ).show(context); }, ), ], ], ); } } class ChangePeriod extends StatefulWidget { const ChangePeriod({ super.key, required this.plan, required this.selectedInterval, required this.onSelected, }); final SubscriptionPlanPB plan; final RecurringIntervalPB selectedInterval; final Function(RecurringIntervalPB interval) onSelected; @override State createState() => _ChangePeriodState(); } class _ChangePeriodState extends State { RecurringIntervalPB? _selectedInterval; @override void initState() { super.initState(); _selectedInterval = widget.selectedInterval; } @override void didChangeDependencies() { _selectedInterval = widget.selectedInterval; super.didChangeDependencies(); } @override Widget build(BuildContext context) { return Column( children: [ _PeriodSelector( price: widget.plan.priceMonthBilling, interval: RecurringIntervalPB.Month, isSelected: _selectedInterval == RecurringIntervalPB.Month, isCurrent: widget.selectedInterval == RecurringIntervalPB.Month, onSelected: () { widget.onSelected(RecurringIntervalPB.Month); setState( () => _selectedInterval = RecurringIntervalPB.Month, ); }, ), const VSpace(16), _PeriodSelector( price: widget.plan.priceAnnualBilling, interval: RecurringIntervalPB.Year, isSelected: _selectedInterval == RecurringIntervalPB.Year, isCurrent: widget.selectedInterval == RecurringIntervalPB.Year, onSelected: () { widget.onSelected(RecurringIntervalPB.Year); setState( () => _selectedInterval = RecurringIntervalPB.Year, ); }, ), ], ); } } class _PeriodSelector extends StatelessWidget { const _PeriodSelector({ required this.price, required this.interval, required this.onSelected, required this.isSelected, required this.isCurrent, }); final String price; final RecurringIntervalPB interval; final VoidCallback onSelected; final bool isSelected; final bool isCurrent; @override Widget build(BuildContext context) { return Opacity( opacity: isCurrent && !isSelected ? 0.7 : 1, child: GestureDetector( onTap: isCurrent ? null : onSelected, child: DecoratedBox( decoration: BoxDecoration( border: Border.all( color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).dividerColor, ), borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ FlowyText( interval.label, fontSize: 16, fontWeight: FontWeight.w500, ), if (isCurrent) ...[ const HSpace(8), DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(6), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 1, ), child: FlowyText( LocaleKeys .settings_billingPage_currentPeriodBadge .tr(), fontSize: 11, fontWeight: FontWeight.w500, color: Colors.white, ), ), ), ], ], ), const VSpace(8), FlowyText( price, fontSize: 14, fontWeight: FontWeight.w500, ), const VSpace(4), FlowyText( interval.priceInfo, fontWeight: FontWeight.w400, fontSize: 12, ), ], ), const Spacer(), if (!isCurrent && !isSelected || isSelected) ...[ DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( width: 1.5, color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).dividerColor, ), ), child: SizedBox( height: 22, width: 22, child: Center( child: SizedBox( width: 10, height: 10, child: DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, color: isSelected ? Theme.of(context).colorScheme.primary : Colors.transparent, ), ), ), ), ), ), ], ], ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart ================================================ import 'dart:async'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/features/settings/settings.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/appflowy_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/share_log_files.dart'; import 'package:appflowy/workspace/application/settings/setting_file_importer_bloc.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/settings/pages/fix_data_widget.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import '../shared/setting_action.dart'; class SettingsManageDataView extends StatelessWidget { const SettingsManageDataView({ super.key, required this.workspace, required this.userProfile, }); final UserWorkspacePB workspace; final UserProfilePB userProfile; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => DataLocationBloc( repository: const RustSettingsRepositoryImpl(), )..add(DataLocationEvent.initial()), child: BlocConsumer( listenWhen: (previous, current) => previous.didResetToDefault != current.didResetToDefault, listener: (context, state) { if (state.didResetToDefault) { Navigator.of(context).pop(); runAppFlowy(isAnon: true); } }, builder: (context, state) { // final _ = state.userDataLocation?.isCustom ?? false; final isCloudWorkspace = workspace.workspaceType == WorkspaceTypePB.ServerW; final path = state.userDataLocation?.path; return SettingsBody( title: LocaleKeys.settings_manageDataPage_title.tr(), description: LocaleKeys.settings_manageDataPage_description.tr(), children: [ SettingsCategory( title: LocaleKeys.settings_manageDataPage_dataStorage_title.tr(), tooltip: LocaleKeys.settings_manageDataPage_dataStorage_tooltip.tr(), actions: [ if (isCloudWorkspace) SettingAction( tooltip: LocaleKeys .settings_manageDataPage_dataStorage_actions_resetTooltip .tr(), icon: const FlowySvg( FlowySvgs.restore_s, size: Size.square(20), ), label: LocaleKeys.settings_common_reset.tr(), onPressed: () { showSimpleAFDialog( context: context, title: LocaleKeys .settings_manageDataPage_dataStorage_resetDialog_title .tr(), content: LocaleKeys .settings_manageDataPage_dataStorage_resetDialog_description .tr(), primaryAction: ( LocaleKeys.button_confirm.tr(), (_) { context .read() .add(DataLocationResetToDefault()); } ), secondaryAction: ( LocaleKeys.button_cancel.tr(), (_) {}, ), ); }, ), ], children: path == null ? [ const CircularProgressIndicator(), ] : [ _CurrentPath(path: path), if (isCloudWorkspace) _DataPathActions(path: path), ], ), SettingsCategory( title: LocaleKeys.settings_manageDataPage_importData_title.tr(), tooltip: LocaleKeys.settings_manageDataPage_importData_tooltip.tr(), children: const [_ImportDataField()], ), if (kDebugMode) ...[ SettingsCategory( title: LocaleKeys.settings_files_exportData.tr(), children: const [ SettingsExportFileWidget(), FixDataWidget(), ], ), ], SettingsCategory( title: LocaleKeys.workspace_errorActions_exportLogFiles.tr(), children: [ SingleSettingAction( labelMaxLines: 4, label: LocaleKeys.workspace_errorActions_exportLogFiles.tr(), buttonLabel: LocaleKeys.settings_files_export.tr(), onPressed: () { shareLogFiles(context); }, ), ], ), SettingsCategory( title: LocaleKeys.settings_manageDataPage_cache_title.tr(), children: [ SingleSettingAction( labelMaxLines: 4, label: LocaleKeys.settings_manageDataPage_cache_description .tr(), buttonLabel: LocaleKeys.settings_manageDataPage_cache_title.tr(), onPressed: () { showCancelAndConfirmDialog( context: context, title: LocaleKeys .settings_manageDataPage_cache_dialog_title .tr(), description: LocaleKeys .settings_manageDataPage_cache_dialog_description .tr(), confirmLabel: LocaleKeys.button_ok.tr(), onConfirm: (_) async { // clear all cache await getIt().clearAllCache(); // check the workspace and space health await WorkspaceDataManager.checkViewHealth( dryRun: false, ); if (context.mounted) { showToastNotification( message: LocaleKeys .settings_manageDataPage_cache_dialog_successHint .tr(), ); } }, ); }, ), ], ), ], ); }, ), ); } } // class _EncryptDataSetting extends StatelessWidget { // const _EncryptDataSetting({required this.userProfile}); // final UserProfilePB userProfile; // @override // Widget build(BuildContext context) { // return BlocProvider.value( // value: context.read(), // child: BlocBuilder( // builder: (context, state) { // if (state.loadingState?.isLoading() == true) { // return const Row( // children: [ // SizedBox( // width: 20, // height: 20, // child: CircularProgressIndicator( // strokeWidth: 3, // ), // ), // HSpace(16), // FlowyText.medium( // 'Encrypting data...', // fontSize: 14, // ), // ], // ); // } // if (userProfile.encryptionType == EncryptionTypePB.NoEncryption) { // return Row( // children: [ // SizedBox( // height: 42, // child: FlowyTextButton( // LocaleKeys.settings_manageDataPage_encryption_action.tr(), // padding: const EdgeInsets.symmetric( // horizontal: 24, // vertical: 12, // ), // fontWeight: FontWeight.w600, // radius: BorderRadius.circular(12), // fillColor: Theme.of(context).colorScheme.primary, // hoverColor: const Color(0xFF005483), // fontHoverColor: Colors.white, // onPressed: () => SettingsAlertDialog( // title: LocaleKeys // .settings_manageDataPage_encryption_dialog_title // .tr(), // subtitle: LocaleKeys // .settings_manageDataPage_encryption_dialog_description // .tr(), // confirmLabel: LocaleKeys // .settings_manageDataPage_encryption_dialog_title // .tr(), // implyLeading: true, // // Generate a secret one time for the user // confirm: () => context // .read() // .add(const EncryptSecretEvent.setEncryptSecret('')), // ).show(context), // ), // ), // ], // ); // } // // Show encryption secret for copy/save // return const SizedBox.shrink(); // }, // ), // ); // } // } class _ImportDataField extends StatefulWidget { const _ImportDataField(); @override State<_ImportDataField> createState() => _ImportDataFieldState(); } class _ImportDataFieldState extends State<_ImportDataField> { final _fToast = FToast(); @override void initState() { super.initState(); _fToast.init(context); } @override void dispose() { _fToast.removeQueuedCustomToasts(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider( create: (context) => SettingFileImportBloc(), child: BlocConsumer( listenWhen: (previous, current) => previous.successOrFail != current.successOrFail, listener: (_, state) => state.successOrFail?.fold( (_) => _showToast(LocaleKeys.settings_menu_importSuccess.tr()), (_) => _showToast(LocaleKeys.settings_menu_importFailed.tr()), ), builder: (context, state) { return SingleSettingAction( label: LocaleKeys.settings_manageDataPage_importData_description.tr(), labelMaxLines: 2, buttonLabel: LocaleKeys.settings_manageDataPage_importData_action.tr(), onPressed: () async { final path = await getIt().getDirectoryPath(); if (path == null || !context.mounted) { return; } context .read() .add(SettingFileImportEvent.importAppFlowyDataFolder(path)); }, ); }, ), ); } void _showToast(String message) { _fToast.showToast( child: FlowyMessageToast(message: message), gravity: ToastGravity.CENTER, ); } } class _CurrentPath extends StatefulWidget { const _CurrentPath({required this.path}); final String path; @override State<_CurrentPath> createState() => _CurrentPathState(); } class _CurrentPathState extends State<_CurrentPath> { Timer? linkCopiedTimer; bool showCopyMessage = false; bool isHovering = false; @override void dispose() { linkCopiedTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( children: [ Row( children: [ Expanded( child: FlowyTooltip( message: LocaleKeys .settings_manageDataPage_dataStorage_actions_openTooltip .tr(), child: GestureDetector( onTap: () => { afLaunchUri(Uri.file(widget.path)), }, child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => isHovering = true), onExit: (_) => setState(() => isHovering = false), child: Text( widget.path, maxLines: 2, style: theme.textStyle.body .standard(color: theme.textColorScheme.action) .copyWith( decoration: isHovering ? TextDecoration.underline : null, ), overflow: TextOverflow.ellipsis, ), ), ), ), ), HSpace( theme.spacing.m, ), IndexedStack( alignment: Alignment.centerRight, index: showCopyMessage ? 0 : 1, children: [ Container( decoration: BoxDecoration( color: AFThemeExtension.of(context).tint7, borderRadius: BorderRadius.circular(8), ), padding: EdgeInsets.symmetric( horizontal: theme.spacing.l, vertical: theme.spacing.m, ), child: Text( LocaleKeys .settings_manageDataPage_dataStorage_actions_copiedHint .tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, ), ), FlowyTooltip( message: LocaleKeys .settings_manageDataPage_dataStorage_actions_copy .tr(), child: AFGhostButton.normal( builder: (context, _, __) { return FlowySvg( FlowySvgs.copy_s, size: Size.square(20), color: theme.textColorScheme.primary, ); }, padding: EdgeInsets.all(theme.spacing.m), onTap: () => _copyLink(widget.path), ), ), ], ), ], ), ], ); } void _copyLink(String? path) { AppFlowyClipboard.setData(text: path); setState(() => showCopyMessage = true); linkCopiedTimer?.cancel(); linkCopiedTimer = Timer( const Duration(milliseconds: 300), () { if (mounted) { setState(() => showCopyMessage = false); } }, ); } } class _DataPathActions extends StatelessWidget { const _DataPathActions({required this.path}); final String path; @override Widget build(BuildContext context) { return AFFilledTextButton.primary( text: LocaleKeys.settings_manageDataPage_dataStorage_actions_change.tr(), onTap: () async { final path = await getIt().getDirectoryPath(); if (!context.mounted || path == null || path == path) { return; } context .read() .add(DataLocationEvent.setCustomPath(path)); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../generated/locale_keys.g.dart'; class SettingsPlanComparisonDialog extends StatefulWidget { const SettingsPlanComparisonDialog({ super.key, required this.workspaceId, required this.subscriptionInfo, }); final String workspaceId; final WorkspaceSubscriptionInfoPB subscriptionInfo; @override State createState() => _SettingsPlanComparisonDialogState(); } class _SettingsPlanComparisonDialogState extends State { final horizontalController = ScrollController(); final verticalController = ScrollController(); late WorkspaceSubscriptionInfoPB currentInfo = widget.subscriptionInfo; Loading? loadingIndicator; @override void dispose() { horizontalController.dispose(); verticalController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final isLM = Theme.of(context).isLightMode; return BlocConsumer( listener: (context, state) { final readyState = state.mapOrNull(ready: (state) => state); if (readyState == null) { return; } if (readyState.downgradeProcessing) { loadingIndicator = Loading(context)..start(); } else { loadingIndicator?.stop(); loadingIndicator = null; } if (readyState.successfulPlanUpgrade != null) { showConfirmDialog( context: context, title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title .tr(args: [readyState.successfulPlanUpgrade!.label]), description: LocaleKeys .settings_comparePlanDialog_paymentSuccess_description .tr(args: [readyState.successfulPlanUpgrade!.label]), confirmLabel: LocaleKeys.button_close.tr(), onConfirm: (_) {}, ); } setState(() => currentInfo = readyState.subscriptionInfo); }, builder: (context, state) => FlowyDialog( constraints: const BoxConstraints(maxWidth: 784, minWidth: 674), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 24, left: 24, right: 24), child: Row( mainAxisSize: MainAxisSize.min, children: [ FlowyText.semibold( LocaleKeys.settings_comparePlanDialog_title.tr(), fontSize: 24, color: AFThemeExtension.of(context).strongText, ), const Spacer(), GestureDetector( onTap: () => Navigator.of(context).pop( currentInfo.plan != widget.subscriptionInfo.plan, ), child: MouseRegion( cursor: SystemMouseCursors.click, child: FlowySvg( FlowySvgs.m_close_m, size: const Size.square(20), color: AFThemeExtension.of(context).strongText, ), ), ), ], ), ), const VSpace(16), Flexible( child: SingleChildScrollView( controller: horizontalController, scrollDirection: Axis.horizontal, child: SingleChildScrollView( controller: verticalController, padding: const EdgeInsets.only( left: 24, right: 24, bottom: 24, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 250, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(30), SizedBox( height: 116, child: FlowyText.semibold( LocaleKeys .settings_comparePlanDialog_planFeatures .tr(), fontSize: 24, maxLines: 2, color: isLM ? const Color(0xFF5C3699) : const Color(0xFFE8E0FF), ), ), const SizedBox(height: 116), const SizedBox(height: 56), ..._planLabels.map( (e) => _ComparisonCell( label: e.label, tooltip: e.tooltip, ), ), ], ), ), _PlanTable( title: LocaleKeys .settings_comparePlanDialog_freePlan_title .tr(), description: LocaleKeys .settings_comparePlanDialog_freePlan_description .tr(), price: LocaleKeys .settings_comparePlanDialog_freePlan_price .tr( args: [ SubscriptionPlanPB.Free.priceMonthBilling, ], ), priceInfo: LocaleKeys .settings_comparePlanDialog_freePlan_priceInfo .tr(), cells: _freeLabels, isCurrent: currentInfo.plan == WorkspacePlanPB.FreePlan, buttonType: WorkspacePlanPB.FreePlan.buttonTypeFor( currentInfo.plan, ), onSelected: () async { if (currentInfo.plan == WorkspacePlanPB.FreePlan || currentInfo.isCanceled) { return; } final reason = await showCancelSurveyDialog(context); if (reason == null || !context.mounted) { return; } await showConfirmDialog( context: context, title: LocaleKeys .settings_comparePlanDialog_downgradeDialog_title .tr(args: [currentInfo.label]), description: LocaleKeys .settings_comparePlanDialog_downgradeDialog_description .tr(), confirmLabel: LocaleKeys .settings_comparePlanDialog_downgradeDialog_downgradeLabel .tr(), style: ConfirmPopupStyle.cancelAndOk, onConfirm: (_) => context.read().add( SettingsPlanEvent.cancelSubscription( reason: reason, ), ), ); }, ), _PlanTable( title: LocaleKeys .settings_comparePlanDialog_proPlan_title .tr(), description: LocaleKeys .settings_comparePlanDialog_proPlan_description .tr(), price: LocaleKeys .settings_comparePlanDialog_proPlan_price .tr( args: [SubscriptionPlanPB.Pro.priceAnnualBilling], ), priceInfo: LocaleKeys .settings_comparePlanDialog_proPlan_priceInfo .tr( args: [SubscriptionPlanPB.Pro.priceMonthBilling], ), cells: _proLabels, isCurrent: currentInfo.plan == WorkspacePlanPB.ProPlan, buttonType: WorkspacePlanPB.ProPlan.buttonTypeFor( currentInfo.plan, ), onSelected: () => context.read().add( const SettingsPlanEvent.addSubscription( SubscriptionPlanPB.Pro, ), ), ), ], ), ], ), ), ), ), ], ), ), ); } } enum _PlanButtonType { none, upgrade, downgrade; bool get isDowngrade => this == downgrade; bool get isUpgrade => this == upgrade; } extension _ButtonTypeFrom on WorkspacePlanPB { /// Returns the button type for the given plan, taking the /// current plan as [other]. /// _PlanButtonType buttonTypeFor(WorkspacePlanPB other) { /// Current plan, no action if (this == other) { return _PlanButtonType.none; } // Free plan, can downgrade if not on the free plan if (this == WorkspacePlanPB.FreePlan && other != WorkspacePlanPB.FreePlan) { return _PlanButtonType.downgrade; } // Else we can assume it's an upgrade return _PlanButtonType.upgrade; } } class _PlanTable extends StatelessWidget { const _PlanTable({ required this.title, required this.description, required this.price, required this.priceInfo, required this.cells, required this.isCurrent, required this.onSelected, this.buttonType = _PlanButtonType.none, }); final String title; final String description; final String price; final String priceInfo; final List<_CellItem> cells; final bool isCurrent; final VoidCallback onSelected; final _PlanButtonType buttonType; @override Widget build(BuildContext context) { final highlightPlan = !isCurrent && buttonType == _PlanButtonType.upgrade; final isLM = Theme.of(context).isLightMode; return Container( width: 215, decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), gradient: !highlightPlan ? null : LinearGradient( colors: [ isLM ? const Color(0xFF251D37) : const Color(0xFF7459AD), isLM ? const Color(0xFF7547C0) : const Color(0xFFDDC8FF), ], ), ), padding: !highlightPlan ? const EdgeInsets.only(top: 4) : const EdgeInsets.all(4), child: Container( padding: isCurrent ? const EdgeInsets.only(bottom: 22) : const EdgeInsets.symmetric(vertical: 22), decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), color: Theme.of(context).cardColor, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (isCurrent) const _CurrentBadge(), const VSpace(4), _Heading( title: title, description: description, isPrimary: !highlightPlan, ), _Heading( title: price, description: priceInfo, isPrimary: !highlightPlan, ), if (buttonType == _PlanButtonType.none) ...[ const SizedBox(height: 56), ] else ...[ Opacity( opacity: 1, child: Padding( padding: EdgeInsets.only( left: 12 + (buttonType.isUpgrade ? 12 : 0), ), child: _ActionButton( label: buttonType.isUpgrade ? LocaleKeys.settings_comparePlanDialog_actions_upgrade .tr() : LocaleKeys .settings_comparePlanDialog_actions_downgrade .tr(), onPressed: onSelected, isUpgrade: buttonType.isUpgrade, useGradientBorder: buttonType.isUpgrade, ), ), ), ], ...cells.map( (cell) => _ComparisonCell( label: cell.label, icon: cell.icon, isHighlighted: highlightPlan, ), ), ], ), ), ); } } class _CurrentBadge extends StatelessWidget { const _CurrentBadge(); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(left: 12), height: 22, width: 72, decoration: BoxDecoration( color: Theme.of(context).isLightMode ? const Color(0xFF4F3F5F) : const Color(0xFFE8E0FF), borderRadius: BorderRadius.circular(4), ), child: Center( child: FlowyText.medium( LocaleKeys.settings_comparePlanDialog_current.tr(), fontSize: 12, color: Theme.of(context).isLightMode ? Colors.white : Colors.black, ), ), ); } } class _ComparisonCell extends StatelessWidget { const _ComparisonCell({ this.label, this.icon, this.tooltip, this.isHighlighted = false, }); final String? label; final FlowySvgData? icon; final String? tooltip; final bool isHighlighted; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12) + EdgeInsets.only(left: isHighlighted ? 12 : 0), height: 36, decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Theme.of(context).dividerColor, ), ), ), child: Row( children: [ if (icon != null) ...[ FlowySvg( icon!, color: AFThemeExtension.of(context).strongText, ), ] else if (label != null) ...[ Expanded( child: FlowyText.medium( label!, lineHeight: 1.2, color: AFThemeExtension.of(context).strongText, ), ), ], if (tooltip != null) FlowyTooltip( message: tooltip, child: FlowySvg( FlowySvgs.information_s, color: AFThemeExtension.of(context).strongText, ), ), ], ), ); } } class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, required this.onPressed, required this.isUpgrade, this.useGradientBorder = false, }); final String label; final VoidCallback? onPressed; final bool isUpgrade; final bool useGradientBorder; @override Widget build(BuildContext context) { final isLM = Theme.of(context).isLightMode; return SizedBox( height: 56, child: Row( children: [ GestureDetector( onTap: onPressed, child: MouseRegion( cursor: onPressed != null ? SystemMouseCursors.click : MouseCursor.defer, child: _drawBorder( context, isLM: isLM, isUpgrade: isUpgrade, child: Container( height: 36, width: 148, decoration: BoxDecoration( color: useGradientBorder ? Theme.of(context).cardColor : Colors.transparent, border: Border.all(color: Colors.transparent), borderRadius: BorderRadius.circular(14), ), child: Center(child: _drawText(label, isLM, isUpgrade)), ), ), ), ), ], ), ); } Widget _drawText(String text, bool isLM, bool isUpgrade) { final child = FlowyText( text, fontSize: 14, lineHeight: 1.2, fontWeight: useGradientBorder ? FontWeight.w600 : FontWeight.w500, color: isUpgrade ? const Color(0xFFC49BEC) : null, ); if (!useGradientBorder || !isLM) { return child; } return ShaderMask( blendMode: BlendMode.srcIn, shaderCallback: (bounds) => const LinearGradient( transform: GradientRotation(-1.55), stops: [0.4, 1], colors: [Color(0xFF251D37), Color(0xFF7547C0)], ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)), child: child, ); } Widget _drawBorder( BuildContext context, { required bool isLM, required bool isUpgrade, required Widget child, }) { return Container( padding: const EdgeInsets.all(2), decoration: BoxDecoration( gradient: isUpgrade ? LinearGradient( transform: const GradientRotation(-1.2), stops: const [0.4, 1], colors: [ isLM ? const Color(0xFF251D37) : const Color(0xFF7459AD), isLM ? const Color(0xFF7547C0) : const Color(0xFFDDC8FF), ], ) : null, border: isUpgrade ? null : Border.all(color: const Color(0xFF333333)), borderRadius: BorderRadius.circular(16), ), child: child, ); } } class _Heading extends StatelessWidget { const _Heading({ required this.title, this.description, this.isPrimary = true, }); final String title; final String? description; final bool isPrimary; @override Widget build(BuildContext context) { return SizedBox( width: 185, height: 116, child: Padding( padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: FlowyText.semibold( title, fontSize: 24, overflow: TextOverflow.ellipsis, color: isPrimary ? AFThemeExtension.of(context).strongText : Theme.of(context).isLightMode ? const Color(0xFF5C3699) : const Color(0xFFC49BEC), ), ), ], ), if (description != null && description!.isNotEmpty) ...[ const VSpace(4), Flexible( child: FlowyText.regular( description!, fontSize: 12, maxLines: 5, lineHeight: 1.5, ), ), ], ], ), ), ); } } class _PlanItem { const _PlanItem({required this.label, this.tooltip}); final String label; final String? tooltip; } final _planLabels = [ _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemOne.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemTwo.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemThree.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFour.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFive.tr(), tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipFive.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_intelligentSearch.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSix.tr(), tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSeven.tr(), tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFileUpload.tr(), ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_customNamespace.tr(), tooltip: LocaleKeys .settings_comparePlanDialog_planLabels_customNamespaceTooltip .tr(), ), ]; class _CellItem { const _CellItem({this.label, this.icon}); final String? label; final FlowySvgData? icon; } final List<_CellItem> _freeLabels = [ _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemOne.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemTwo.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemThree.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(), icon: FlowySvgs.check_m, ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_intelligentSearch.tr(), icon: FlowySvgs.check_m, ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemSeven.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFileUpload.tr(), ), const _CellItem( label: '', ), ]; final List<_CellItem> _proLabels = [ _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemOne.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemTwo.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemThree.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(), icon: FlowySvgs.check_m, ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_intelligentSearch.tr(), icon: FlowySvgs.check_m, ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemSeven.tr(), ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFileUpload.tr(), ), const _CellItem( label: '', icon: FlowySvgs.check_m, ), ]; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/colors.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; import 'package:appflowy/shared/loading.dart'; import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy/workspace/application/settings/plan/workspace_usage_ext.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/shared/flowy_gradient_button.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsPlanView extends StatefulWidget { const SettingsPlanView({ super.key, required this.workspaceId, required this.user, }); final String workspaceId; final UserProfilePB user; @override State createState() => _SettingsPlanViewState(); } class _SettingsPlanViewState extends State { Loading? loadingIndicator; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => SettingsPlanBloc( workspaceId: widget.workspaceId, userId: widget.user.id, )..add(const SettingsPlanEvent.started()), child: BlocConsumer( listenWhen: (previous, current) => previous.mapOrNull(ready: (s) => s.downgradeProcessing) != current.mapOrNull(ready: (s) => s.downgradeProcessing), listener: (context, state) { if (state.mapOrNull(ready: (s) => s.downgradeProcessing) == true) { loadingIndicator = Loading(context)..start(); } else { loadingIndicator?.stop(); loadingIndicator = null; } }, builder: (context, state) { return state.map( initial: (_) => const SizedBox.shrink(), loading: (_) => const Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator.adaptive(strokeWidth: 3), ), ), error: (state) { if (state.error != null) { return Padding( padding: const EdgeInsets.all(16), child: Center( child: AppFlowyErrorPage( error: state.error!, ), ), ); } return ErrorWidget.withDetails(message: 'Something went wrong!'); }, ready: (state) => SettingsBody( autoSeparate: false, title: LocaleKeys.settings_planPage_title.tr(), children: [ _PlanUsageSummary( usage: state.workspaceUsage, subscriptionInfo: state.subscriptionInfo, ), const VSpace(16), _CurrentPlanBox(subscriptionInfo: state.subscriptionInfo), const VSpace(16), FlowyText( LocaleKeys.settings_planPage_planUsage_addons_title.tr(), fontSize: 18, color: AFThemeExtension.of(context).strongText, fontWeight: FontWeight.w600, ), const VSpace(8), Row( children: [ Flexible( child: _AddOnBox( title: LocaleKeys .settings_planPage_planUsage_addons_aiMax_title .tr(), description: LocaleKeys .settings_planPage_planUsage_addons_aiMax_description .tr(), price: LocaleKeys .settings_planPage_planUsage_addons_aiMax_price .tr( args: [SubscriptionPlanPB.AiMax.priceAnnualBilling], ), priceInfo: LocaleKeys .settings_planPage_planUsage_addons_aiMax_priceInfo .tr(), recommend: '', buttonText: state.subscriptionInfo.hasAIMax ? LocaleKeys .settings_planPage_planUsage_addons_activeLabel .tr() : LocaleKeys .settings_planPage_planUsage_addons_addLabel .tr(), isActive: state.subscriptionInfo.hasAIMax, plan: SubscriptionPlanPB.AiMax, ), ), const HSpace(8), ], ), ], ), ); }, ), ); } } class _CurrentPlanBox extends StatefulWidget { const _CurrentPlanBox({required this.subscriptionInfo}); final WorkspaceSubscriptionInfoPB subscriptionInfo; @override State<_CurrentPlanBox> createState() => _CurrentPlanBoxState(); } class _CurrentPlanBoxState extends State<_CurrentPlanBox> { late SettingsPlanBloc planBloc; @override void initState() { super.initState(); planBloc = context.read(); } @override void didChangeDependencies() { planBloc = context.read(); super.didChangeDependencies(); } @override Widget build(BuildContext context) { return Stack( children: [ Container( margin: const EdgeInsets.only(top: 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all(color: const Color(0xFFBDBDBD)), borderRadius: BorderRadius.circular(16), ), child: Column( children: [ Row( children: [ Expanded( flex: 6, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const VSpace(4), FlowyText.semibold( widget.subscriptionInfo.label, fontSize: 24, color: AFThemeExtension.of(context).strongText, ), const VSpace(8), FlowyText.regular( widget.subscriptionInfo.info, fontSize: 14, color: AFThemeExtension.of(context).strongText, maxLines: 3, ), ], ), ), Flexible( flex: 5, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ConstrainedBox( constraints: const BoxConstraints(maxWidth: 220), child: FlowyGradientButton( label: LocaleKeys .settings_planPage_planUsage_currentPlan_upgrade .tr(), onPressed: () => _openPricingDialog( context, context.read().workspaceId, widget.subscriptionInfo, ), ), ), ], ), ), ], ), if (widget.subscriptionInfo.isCanceled) ...[ const VSpace(12), FlowyText( LocaleKeys .settings_planPage_planUsage_currentPlan_canceledInfo .tr( args: [_canceledDate(context)], ), maxLines: 5, fontSize: 12, color: Theme.of(context).colorScheme.error, ), ], ], ), ), Positioned( top: 0, left: 0, child: Container( height: 30, padding: const EdgeInsets.symmetric(horizontal: 24), decoration: const BoxDecoration( color: Color(0xFF4F3F5F), borderRadius: BorderRadius.only( topLeft: Radius.circular(4), topRight: Radius.circular(4), bottomRight: Radius.circular(4), ), ), child: Center( child: FlowyText.semibold( LocaleKeys.settings_planPage_planUsage_currentPlan_bannerLabel .tr(), fontSize: 14, color: Colors.white, ), ), ), ), ], ); } String _canceledDate(BuildContext context) { final appearance = context.read().state; return appearance.dateFormat.formatDate( widget.subscriptionInfo.planSubscription.endDate.toDateTime(), false, ); } void _openPricingDialog( BuildContext context, String workspaceId, WorkspaceSubscriptionInfoPB subscriptionInfo, ) => showDialog( context: context, builder: (_) => BlocProvider.value( value: planBloc, child: SettingsPlanComparisonDialog( workspaceId: workspaceId, subscriptionInfo: subscriptionInfo, ), ), ); } class _PlanUsageSummary extends StatelessWidget { const _PlanUsageSummary({ required this.usage, required this.subscriptionInfo, }); final WorkspaceUsagePB usage; final WorkspaceSubscriptionInfoPB subscriptionInfo; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.semibold( LocaleKeys.settings_planPage_planUsage_title.tr(), maxLines: 2, fontSize: 16, overflow: TextOverflow.ellipsis, color: AFThemeExtension.of(context).secondaryTextColor, ), const VSpace(16), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _UsageBox( title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(), unlimitedLabel: LocaleKeys .settings_planPage_planUsage_unlimitedStorageLabel .tr(), unlimited: usage.storageBytesUnlimited, label: LocaleKeys.settings_planPage_planUsage_storageUsage.tr( args: [ usage.currentBlobInGb, usage.totalBlobInGb, ], ), value: usage.storageBytes.toInt() / usage.storageBytesLimit.toInt(), ), ), Expanded( child: _UsageBox( title: LocaleKeys.settings_planPage_planUsage_aiResponseLabel.tr(), label: LocaleKeys.settings_planPage_planUsage_aiResponseUsage.tr( args: [ usage.aiResponsesCount.toString(), usage.aiResponsesCountLimit.toString(), ], ), unlimitedLabel: LocaleKeys .settings_planPage_planUsage_unlimitedAILabel .tr(), unlimited: usage.aiResponsesUnlimited, value: usage.aiResponsesCount.toInt() / usage.aiResponsesCountLimit.toInt(), ), ), ], ), const VSpace(16), SeparatedColumn( crossAxisAlignment: CrossAxisAlignment.start, separatorBuilder: () => const VSpace(4), children: [ if (subscriptionInfo.plan == WorkspacePlanPB.FreePlan) ...[ _ToggleMore( value: false, label: LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(), badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(), onTap: () async { context.read().add( const SettingsPlanEvent.addSubscription( SubscriptionPlanPB.Pro, ), ); await Future.delayed(const Duration(seconds: 2), () {}); }, ), ], if (!subscriptionInfo.hasAIMax && !usage.aiResponsesUnlimited) ...[ _ToggleMore( value: false, label: LocaleKeys.settings_planPage_planUsage_aiMaxToggle.tr(), badgeLabel: LocaleKeys.settings_planPage_planUsage_proBadge.tr(), onTap: () async { context.read().add( const SettingsPlanEvent.addSubscription( SubscriptionPlanPB.AiMax, ), ); await Future.delayed(const Duration(seconds: 2), () {}); }, ), ], ], ), ], ); } } class _UsageBox extends StatelessWidget { const _UsageBox({ required this.title, required this.label, required this.value, required this.unlimitedLabel, this.unlimited = false, }); final String title; final String label; final double value; final String unlimitedLabel; // Replaces the progress bar with an unlimited badge final bool unlimited; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.medium( title, fontSize: 11, color: AFThemeExtension.of(context).secondaryTextColor, ), if (unlimited) ...[ Padding( padding: const EdgeInsets.only(top: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ const FlowySvg( FlowySvgs.check_circle_outlined_s, color: Color(0xFF9C00FB), ), const HSpace(4), FlowyText( unlimitedLabel, fontWeight: FontWeight.w500, fontSize: 11, ), ], ), ), ] else ...[ const VSpace(4), _PlanProgressIndicator(label: label, progress: value), ], ], ); } } class _ToggleMore extends StatefulWidget { const _ToggleMore({ required this.value, required this.label, this.badgeLabel, this.onTap, }); final bool value; final String label; final String? badgeLabel; final Future Function()? onTap; @override State<_ToggleMore> createState() => _ToggleMoreState(); } class _ToggleMoreState extends State<_ToggleMore> { late bool toggleValue = widget.value; @override Widget build(BuildContext context) { return Row( children: [ Toggle( value: toggleValue, padding: EdgeInsets.zero, onChanged: (_) async { if (widget.onTap == null || toggleValue) { return; } setState(() => toggleValue = !toggleValue); await widget.onTap!(); if (mounted) { setState(() => toggleValue = !toggleValue); } }, ), const HSpace(10), FlowyText.regular( widget.label, fontSize: 14, color: AFThemeExtension.of(context).strongText, ), if (widget.badgeLabel != null && widget.badgeLabel!.isNotEmpty) ...[ const HSpace(10), SizedBox( height: 26, child: Badge( padding: const EdgeInsets.symmetric(horizontal: 10), backgroundColor: context.proSecondaryColor, label: FlowyText.semibold( widget.badgeLabel!, fontSize: 12, color: context.proPrimaryColor, ), ), ), ], ], ); } } class _PlanProgressIndicator extends StatelessWidget { const _PlanProgressIndicator({required this.label, required this.progress}); final String label; final double progress; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Row( children: [ Expanded( child: Container( height: 8, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: AFThemeExtension.of(context).progressBarBGColor, border: Border.all( color: const Color(0xFFDDF1F7).withValues( alpha: theme.brightness == Brightness.light ? 1 : 0.1, ), ), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Stack( children: [ FractionallySizedBox( widthFactor: progress, child: Container( decoration: BoxDecoration( color: progress >= 1 ? theme.colorScheme.error : theme.colorScheme.primary, ), ), ), ], ), ), ), ), const HSpace(8), FlowyText.medium( label, fontSize: 11, color: AFThemeExtension.of(context).secondaryTextColor, ), const HSpace(16), ], ); } } class _AddOnBox extends StatelessWidget { const _AddOnBox({ required this.title, required this.description, required this.price, required this.priceInfo, required this.recommend, required this.buttonText, required this.isActive, required this.plan, }); final String title; final String description; final String price; final String priceInfo; final String recommend; final String buttonText; final bool isActive; final SubscriptionPlanPB plan; @override Widget build(BuildContext context) { final isLM = Theme.of(context).isLightMode; return Container( height: 220, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), decoration: BoxDecoration( border: Border.all( color: isActive ? const Color(0xFFBDBDBD) : const Color(0xFF9C00FB), ), color: const Color(0xFFF7F8FC).withValues(alpha: 0.05), borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.semibold( title, fontSize: 14, color: AFThemeExtension.of(context).strongText, ), const VSpace(10), FlowyText.regular( description, fontSize: 12, color: AFThemeExtension.of(context).secondaryTextColor, maxLines: 4, ), const VSpace(10), FlowyText( price, fontSize: 24, color: AFThemeExtension.of(context).strongText, ), FlowyText( priceInfo, fontSize: 12, color: AFThemeExtension.of(context).strongText, ), const VSpace(12), Row( children: [ Expanded( child: FlowyText( recommend, color: AFThemeExtension.of(context).secondaryTextColor, fontSize: 11, maxLines: 2, ), ), ], ), const Spacer(), Row( children: [ Expanded( child: FlowyTextButton( buttonText, heading: isActive ? const FlowySvg( FlowySvgs.check_circle_outlined_s, color: Color(0xFF9C00FB), ) : null, mainAxisAlignment: MainAxisAlignment.center, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), fillColor: isActive ? const Color(0xFFE8E2EE) : isLM ? Colors.transparent : const Color(0xFF5C3699), constraints: const BoxConstraints(minWidth: 115), radius: Corners.s16Border, hoverColor: isActive ? const Color(0xFFE8E2EE) : isLM ? const Color(0xFF5C3699) : const Color(0xFF4d3472), fontColor: isLM || isActive ? const Color(0xFF5C3699) : Colors.white, fontHoverColor: isActive ? const Color(0xFF5C3699) : Colors.white, borderColor: isActive ? const Color(0xFFE8E2EE) : isLM ? const Color(0xFF5C3699) : const Color(0xFF4d3472), fontSize: 12, onPressed: isActive ? null : () => context .read() .add(SettingsPlanEvent.addSubscription(plan)), ), ), ], ), ], ), ); } } /// Uncomment if we need it in the future // class _DealBox extends StatelessWidget { // const _DealBox(); // @override // Widget build(BuildContext context) { // final isLM = Theme.of(context).brightness == Brightness.light; // return Container( // clipBehavior: Clip.antiAlias, // decoration: BoxDecoration( // gradient: LinearGradient( // stops: isLM ? null : [.2, .3, .6], // transform: isLM ? null : const GradientRotation(-.9), // begin: isLM ? Alignment.centerLeft : Alignment.topRight, // end: isLM ? Alignment.centerRight : Alignment.bottomLeft, // colors: [ // isLM // ? const Color(0xFF7547C0).withAlpha(60) // : const Color(0xFF7547C0), // if (!isLM) const Color.fromARGB(255, 94, 57, 153), // isLM // ? const Color(0xFF251D37).withAlpha(60) // : const Color(0xFF251D37), // ], // ), // borderRadius: BorderRadius.circular(16), // ), // child: Stack( // children: [ // Padding( // padding: const EdgeInsets.all(16), // child: Row( // children: [ // Expanded( // child: Column( // crossAxisAlignment: CrossAxisAlignment.start, // children: [ // const VSpace(18), // FlowyText.semibold( // LocaleKeys.settings_planPage_planUsage_deal_title.tr(), // fontSize: 24, // color: Theme.of(context).colorScheme.tertiary, // ), // const VSpace(8), // FlowyText.medium( // LocaleKeys.settings_planPage_planUsage_deal_info.tr(), // maxLines: 6, // color: Theme.of(context).colorScheme.tertiary, // ), // const VSpace(8), // FlowyGradientButton( // label: LocaleKeys // .settings_planPage_planUsage_deal_viewPlans // .tr(), // fontWeight: FontWeight.w500, // backgroundColor: isLM ? null : Colors.white, // textColor: isLM // ? Colors.white // : Theme.of(context).colorScheme.onPrimary, // ), // ], // ), // ), // ], // ), // ), // Positioned( // right: 0, // top: 9, // child: Container( // height: 32, // padding: const EdgeInsets.symmetric(horizontal: 16), // decoration: BoxDecoration( // gradient: LinearGradient( // transform: const GradientRotation(.7), // colors: [ // if (isLM) const Color(0xFF7156DF), // isLM // ? const Color(0xFF3B2E8A) // : const Color(0xFFCE006F).withAlpha(150), // isLM ? const Color(0xFF261A48) : const Color(0xFF431459), // ], // ), // ), // child: Center( // child: FlowyText.semibold( // LocaleKeys.settings_planPage_planUsage_deal_bannerLabel.tr(), // fontSize: 16, // color: Colors.white, // ), // ), // ), // ), // ], // ), // ); // } // } /// Uncomment if we need it in the future // class _AddAICreditBox extends StatelessWidget { // const _AddAICreditBox(); // @override // Widget build(BuildContext context) { // return DecoratedBox( // decoration: BoxDecoration( // border: Border.all(color: const Color(0xFFBDBDBD)), // borderRadius: BorderRadius.circular(16), // ), // child: Padding( // padding: const EdgeInsets.all(16), // child: Column( // crossAxisAlignment: CrossAxisAlignment.start, // children: [ // FlowyText.semibold( // LocaleKeys.settings_planPage_planUsage_aiCredit_title.tr(), // fontSize: 18, // color: AFThemeExtension.of(context).secondaryTextColor, // ), // const VSpace(8), // Row( // crossAxisAlignment: CrossAxisAlignment.start, // children: [ // Flexible( // flex: 5, // child: ConstrainedBox( // constraints: const BoxConstraints(maxWidth: 180), // child: Column( // mainAxisSize: MainAxisSize.min, // crossAxisAlignment: CrossAxisAlignment.start, // children: [ // FlowyText.semibold( // LocaleKeys.settings_planPage_planUsage_aiCredit_price // .tr(args: ['5\$]), // fontSize: 24, // ), // FlowyText.medium( // LocaleKeys // .settings_planPage_planUsage_aiCredit_priceDescription // .tr(), // fontSize: 14, // color: // AFThemeExtension.of(context).secondaryTextColor, // ), // const VSpace(8), // FlowyGradientButton( // label: LocaleKeys // .settings_planPage_planUsage_aiCredit_purchase // .tr(), // ), // ], // ), // ), // ), // const HSpace(16), // Flexible( // flex: 6, // child: Column( // crossAxisAlignment: CrossAxisAlignment.start, // mainAxisSize: MainAxisSize.min, // children: [ // FlowyText.regular( // LocaleKeys.settings_planPage_planUsage_aiCredit_info // .tr(), // overflow: TextOverflow.ellipsis, // maxLines: 5, // ), // const VSpace(8), // SeparatedColumn( // separatorBuilder: () => const VSpace(4), // children: [ // _AIStarItem( // label: LocaleKeys // .settings_planPage_planUsage_aiCredit_infoItemOne // .tr(), // ), // _AIStarItem( // label: LocaleKeys // .settings_planPage_planUsage_aiCredit_infoItemTwo // .tr(), // ), // ], // ), // ], // ), // ), // ], // ), // ], // ), // ), // ); // } // } /// Uncomment if we need it in the future // class _AIStarItem extends StatelessWidget { // const _AIStarItem({required this.label}); // final String label; // @override // Widget build(BuildContext context) { // return Row( // children: [ // const FlowySvg(FlowySvgs.ai_star_s, color: Color(0xFF750D7E)), // const HSpace(4), // Expanded(child: FlowyText(label, maxLines: 2)), // ], // ); // } // } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_cut_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart'; import 'package:appflowy/shared/error_page/error_page.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; class SettingsShortcutsView extends StatefulWidget { const SettingsShortcutsView({super.key}); @override State createState() => _SettingsShortcutsViewState(); } class _SettingsShortcutsViewState extends State { String _query = ''; bool _isEditing = false; @override Widget build(BuildContext context) { return BlocProvider( create: (_) => ShortcutsCubit(SettingsShortcutService())..fetchShortcuts(), child: Builder( builder: (context) => SettingsBody( title: LocaleKeys.settings_shortcutsPage_title.tr(), autoSeparate: false, children: [ Row( children: [ Flexible( child: _SearchBar( onSearchChanged: (v) => setState(() => _query = v), ), ), const HSpace(10), _ResetButton( onReset: () { showConfirmDialog( context: context, title: LocaleKeys.settings_shortcutsPage_resetDialog_title .tr(), description: LocaleKeys .settings_shortcutsPage_resetDialog_description .tr(), confirmLabel: LocaleKeys .settings_shortcutsPage_resetDialog_buttonLabel .tr(), onConfirm: (_) { context.read().resetToDefault(); Navigator.of(context).pop(); }, style: ConfirmPopupStyle.cancelAndOk, ); }, ), ], ), BlocBuilder( builder: (context, state) { final filtered = state.commandShortcutEvents .where( (e) => e.afLabel .toLowerCase() .contains(_query.toLowerCase()), ) .toList(); return Column( children: [ const VSpace(16), if (state.status.isLoading) ...[ const CircularProgressIndicator(), ] else if (state.status.isFailure) ...[ FlowyErrorPage.message( LocaleKeys.settings_shortcutsPage_errorPage_message .tr(args: [state.error]), howToFix: LocaleKeys .settings_shortcutsPage_errorPage_howToFix .tr(), ), ] else ...[ ListView.builder( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, itemCount: filtered.length, itemBuilder: (context, index) => ShortcutSettingTile( command: filtered[index], canStartEditing: () => !_isEditing, onStartEditing: () => setState(() => _isEditing = true), onFinishEditing: () => setState(() => _isEditing = false), ), ), ], ], ); }, ), ], ), ), ); } } class _SearchBar extends StatelessWidget { const _SearchBar({this.onSearchChanged}); final void Function(String)? onSearchChanged; @override Widget build(BuildContext context) { return AFTextField( onChanged: onSearchChanged, hintText: LocaleKeys.settings_shortcutsPage_searchHint.tr(), ); } } class _ResetButton extends StatelessWidget { const _ResetButton({this.onReset}); final void Function()? onReset; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: onReset, child: FlowyHover( child: Padding( padding: const EdgeInsets.symmetric( vertical: 4.0, horizontal: 6, ), child: Row( children: [ const FlowySvg( FlowySvgs.restore_s, size: Size.square(20), ), const HSpace(6), SizedBox( height: 16, child: FlowyText.regular( LocaleKeys.settings_shortcutsPage_actions_resetDefault.tr(), color: AFThemeExtension.of(context).strongText, ), ), ], ), ), ), ); } } class ShortcutSettingTile extends StatefulWidget { const ShortcutSettingTile({ super.key, required this.command, required this.onStartEditing, required this.onFinishEditing, required this.canStartEditing, }); final CommandShortcutEvent command; final VoidCallback onStartEditing; final VoidCallback onFinishEditing; final bool Function() canStartEditing; @override State createState() => _ShortcutSettingTileState(); } class _ShortcutSettingTileState extends State { final keybindController = TextEditingController(); late final FocusNode focusNode; bool isHovering = false; bool isEditing = false; bool canClickOutside = false; @override void initState() { super.initState(); focusNode = FocusNode( onKeyEvent: (focusNode, key) { if (key is! KeyDownEvent && key is! KeyRepeatEvent) { return KeyEventResult.ignored; } if (key.logicalKey == LogicalKeyboardKey.enter && !HardwareKeyboard.instance.isShiftPressed) { if (keybindController.text == widget.command.command) { _finishEditing(); return KeyEventResult.handled; } final conflict = context.read().getConflict( widget.command, keybindController.text, ); if (conflict != null) { canClickOutside = true; SettingsAlertDialog( title: LocaleKeys.settings_shortcutsPage_conflictDialog_title .tr(args: [keybindController.text]), confirm: () { conflict.clearCommand(); _updateCommand(); Navigator.of(context).pop(); }, confirmLabel: LocaleKeys .settings_shortcutsPage_conflictDialog_confirmLabel .tr(), children: [ RichText( textAlign: TextAlign.center, text: TextSpan( style: Theme.of(context).textTheme.bodySmall?.copyWith( fontSize: 16, fontWeight: FontWeight.normal, ), children: [ TextSpan( text: LocaleKeys .settings_shortcutsPage_conflictDialog_descriptionPrefix .tr(), ), TextSpan( text: conflict.afLabel, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontSize: 16, fontWeight: FontWeight.bold, ), ), TextSpan( text: LocaleKeys .settings_shortcutsPage_conflictDialog_descriptionSuffix .tr(args: [keybindController.text]), ), ], ), ), ], ).show(context).then((_) => canClickOutside = false); } else { _updateCommand(); } } else if (key.logicalKey == LogicalKeyboardKey.escape) { _finishEditing(); } else { // Extract complete keybinding setState(() => keybindController.text = key.toCommand); } return KeyEventResult.handled; }, ); } void _finishEditing() => setState(() { isEditing = false; keybindController.clear(); widget.onFinishEditing(); }); void _updateCommand() { widget.command.updateCommand(command: keybindController.text); context.read().updateAllShortcuts(); _finishEditing(); } @override void dispose() { focusNode.dispose(); keybindController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( border: Border( top: BorderSide(color: Theme.of(context).dividerColor), ), ), child: FlowyHover( cursor: MouseCursor.defer, style: HoverStyle( hoverColor: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.zero, ), resetHoverOnRebuild: false, builder: (context, isHovering) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ const HSpace(8), Expanded( child: Padding( padding: const EdgeInsets.only(right: 10), child: FlowyText.regular( widget.command.afLabel, fontSize: 14, lineHeight: 1, maxLines: 2, color: AFThemeExtension.of(context).strongText, ), ), ), Expanded( child: isEditing ? _renderKeybindEditor() : _renderKeybindings(isHovering), ), ], ), ), ), ); } Widget _renderKeybindings(bool isHovering) => Row( children: [ if (widget.command.keybindings.isNotEmpty) ...[ ..._toParts(widget.command.keybindings.first).map( (key) => KeyBadge(keyLabel: key), ), ] else ...[ const SizedBox(height: 24), ], const Spacer(), if (isHovering) GestureDetector( onTap: () { if (widget.canStartEditing()) { setState(() { widget.onStartEditing(); isEditing = true; }); } }, child: MouseRegion( cursor: SystemMouseCursors.click, child: FlowyTooltip( message: LocaleKeys.settings_shortcutsPage_editTooltip.tr(), child: const FlowySvg( FlowySvgs.edit_s, size: Size.square(16), ), ), ), ), const HSpace(8), ], ); Widget _renderKeybindEditor() => TapRegion( onTapOutside: canClickOutside ? null : (_) => _finishEditing(), child: FlowyTextField( focusNode: focusNode, controller: keybindController, hintText: LocaleKeys.settings_shortcutsPage_editBindingHint.tr(), onChanged: (_) => setState(() {}), suffixIcon: keybindController.text.isNotEmpty ? MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => setState(() => keybindController.clear()), child: const FlowySvg( FlowySvgs.close_s, size: Size.square(10), ), ), ) : null, ), ); List _toParts(Keybinding binding) { final List keys = []; if (binding.isControlPressed) { keys.add('ctrl'); } if (binding.isMetaPressed) { keys.add('meta'); } if (binding.isShiftPressed) { keys.add('shift'); } if (binding.isAltPressed) { keys.add('alt'); } return keys..add(binding.keyLabel); } } @visibleForTesting class KeyBadge extends StatelessWidget { const KeyBadge({super.key, required this.keyLabel}); final String keyLabel; @override Widget build(BuildContext context) { if (iconData == null && keyLabel.isEmpty) { return const SizedBox.shrink(); } return Container( height: 24, margin: const EdgeInsets.only(right: 4), padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: AFThemeExtension.of(context).greySelect, borderRadius: Corners.s4Border, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.25), blurRadius: 1, offset: const Offset(0, 1), ), ], ), child: Center( child: iconData != null ? FlowySvg(iconData!, color: Colors.black) : FlowyText.medium( keyLabel.toLowerCase(), fontSize: 12, color: Colors.black, ), ), ); } FlowySvgData? get iconData => switch (keyLabel) { 'meta' => FlowySvgs.keyboard_meta_s, 'arrow left' => FlowySvgs.keyboard_arrow_left_s, 'arrow right' => FlowySvgs.keyboard_arrow_right_s, 'arrow up' => FlowySvgs.keyboard_arrow_up_s, 'arrow down' => FlowySvgs.keyboard_arrow_down_s, 'shift' => FlowySvgs.keyboard_shift_s, 'tab' => FlowySvgs.keyboard_tab_s, 'enter' || 'return' => FlowySvgs.keyboard_return_s, 'opt' || 'option' => FlowySvgs.keyboard_option_s, _ => null, }; } extension ToCommand on KeyEvent { String get toCommand { String command = ''; if (HardwareKeyboard.instance.isControlPressed) { command += 'ctrl+'; } if (HardwareKeyboard.instance.isMetaPressed) { command += 'meta+'; } if (HardwareKeyboard.instance.isShiftPressed) { command += 'shift+'; } if (HardwareKeyboard.instance.isAltPressed) { command += 'alt+'; } if ([ LogicalKeyboardKey.control, LogicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlRight, LogicalKeyboardKey.meta, LogicalKeyboardKey.metaLeft, LogicalKeyboardKey.metaRight, LogicalKeyboardKey.alt, LogicalKeyboardKey.altLeft, LogicalKeyboardKey.altRight, LogicalKeyboardKey.shift, LogicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftRight, ].contains(logicalKey)) { return command; } final keyPressed = keyToCodeMapping.keys.firstWhere( (k) => keyToCodeMapping[k] == logicalKey.keyId, orElse: () => '', ); return command += keyPressed; } } extension CommandLabel on CommandShortcutEvent { String get afLabel { String? label; if (key == toggleToggleListCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_toggleToDoList.tr(); } else if (key == insertNewParagraphNextToCodeBlockCommand('').key) { label = LocaleKeys .settings_shortcutsPage_keybindings_insertNewParagraphInCodeblock .tr(); } else if (key == pasteInCodeblock('').key) { label = LocaleKeys.settings_shortcutsPage_keybindings_pasteInCodeblock.tr(); } else if (key == selectAllInCodeBlockCommand('').key) { label = LocaleKeys.settings_shortcutsPage_keybindings_selectAllCodeblock.tr(); } else if (key == tabToInsertSpacesInCodeBlockCommand('').key) { label = LocaleKeys.settings_shortcutsPage_keybindings_indentLineCodeblock .tr(); } else if (key == tabToDeleteSpacesInCodeBlockCommand('').key) { label = LocaleKeys.settings_shortcutsPage_keybindings_outdentLineCodeblock .tr(); } else if (key == tabSpacesAtCurosrInCodeBlockCommand('').key) { label = LocaleKeys .settings_shortcutsPage_keybindings_twoSpacesCursorCodeblock .tr(); } else if (key == customCopyCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_copy.tr(); } else if (key == customPasteCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_paste.tr(); } else if (key == customCutCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_cut.tr(); } else if (key == customTextLeftAlignCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_alignLeft.tr(); } else if (key == customTextCenterAlignCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_alignCenter.tr(); } else if (key == customTextRightAlignCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_alignRight.tr(); } else if (key == insertInlineMathEquationCommand.key) { label = LocaleKeys .settings_shortcutsPage_keybindings_insertInlineMathEquation .tr(); } else if (key == undoCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_undo.tr(); } else if (key == redoCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_redo.tr(); } else if (key == convertToParagraphCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_convertToParagraph.tr(); } else if (key == backspaceCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_backspace.tr(); } else if (key == deleteLeftWordCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_deleteLeftWord.tr(); } else if (key == deleteLeftSentenceCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_deleteLeftSentence.tr(); } else if (key == deleteCommand.key) { label = UniversalPlatform.isMacOS ? LocaleKeys.settings_shortcutsPage_keybindings_deleteMacOS.tr() : LocaleKeys.settings_shortcutsPage_keybindings_delete.tr(); } else if (key == deleteRightWordCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_deleteRightWord.tr(); } else if (key == moveCursorLeftCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorLeft.tr(); } else if (key == moveCursorToBeginCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorBeginning .tr(); } else if (key == moveCursorToLeftWordCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorLeftWord.tr(); } else if (key == moveCursorLeftSelectCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorLeftSelect .tr(); } else if (key == moveCursorBeginSelectCommand.key) { label = LocaleKeys .settings_shortcutsPage_keybindings_moveCursorBeginSelect .tr(); } else if (key == moveCursorLeftWordSelectCommand.key) { label = LocaleKeys .settings_shortcutsPage_keybindings_moveCursorLeftWordSelect .tr(); } else if (key == moveCursorRightCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorRight.tr(); } else if (key == moveCursorToEndCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorEnd.tr(); } else if (key == moveCursorToRightWordCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorRightWord .tr(); } else if (key == moveCursorRightSelectCommand.key) { label = LocaleKeys .settings_shortcutsPage_keybindings_moveCursorRightSelect .tr(); } else if (key == moveCursorEndSelectCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorEndSelect .tr(); } else if (key == moveCursorRightWordSelectCommand.key) { label = LocaleKeys .settings_shortcutsPage_keybindings_moveCursorRightWordSelect .tr(); } else if (key == moveCursorUpCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorUp.tr(); } else if (key == moveCursorTopSelectCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorTopSelect .tr(); } else if (key == moveCursorTopCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorTop.tr(); } else if (key == moveCursorUpSelectCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorUpSelect.tr(); } else if (key == moveCursorDownCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorDown.tr(); } else if (key == moveCursorBottomSelectCommand.key) { label = LocaleKeys .settings_shortcutsPage_keybindings_moveCursorBottomSelect .tr(); } else if (key == moveCursorBottomCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorBottom.tr(); } else if (key == moveCursorDownSelectCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorDownSelect .tr(); } else if (key == homeCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_home.tr(); } else if (key == endCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_end.tr(); } else if (key == toggleBoldCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_toggleBold.tr(); } else if (key == toggleItalicCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_toggleItalic.tr(); } else if (key == toggleUnderlineCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_toggleUnderline.tr(); } else if (key == toggleStrikethroughCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_toggleStrikethrough .tr(); } else if (key == toggleCodeCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_toggleCode.tr(); } else if (key == toggleHighlightCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_toggleHighlight.tr(); } else if (key == showLinkMenuCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_showLinkMenu.tr(); } else if (key == openInlineLinkCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_openInlineLink.tr(); } else if (key == openLinksCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_openLinks.tr(); } else if (key == indentCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_indent.tr(); } else if (key == outdentCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_outdent.tr(); } else if (key == exitEditingCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_exit.tr(); } else if (key == pageUpCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_pageUp.tr(); } else if (key == pageDownCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_pageDown.tr(); } else if (key == selectAllCommand.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_selectAll.tr(); } else if (key == pasteTextWithoutFormattingCommand.key) { label = LocaleKeys .settings_shortcutsPage_keybindings_pasteWithoutFormatting .tr(); } else if (key == emojiShortcutEvent.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_showEmojiPicker.tr(); } else if (key == enterInTableCell.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_enterInTableCell.tr(); } else if (key == leftInTableCell.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_leftInTableCell.tr(); } else if (key == rightInTableCell.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_rightInTableCell.tr(); } else if (key == upInTableCell.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_upInTableCell.tr(); } else if (key == downInTableCell.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_downInTableCell.tr(); } else if (key == tabInTableCell.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_tabInTableCell.tr(); } else if (key == shiftTabInTableCell.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_shiftTabInTableCell .tr(); } else if (key == backSpaceInTableCell.key) { label = LocaleKeys.settings_shortcutsPage_keybindings_backSpaceInTableCell .tr(); } return label ?? description?.capitalize() ?? ''; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart ================================================ import 'dart:async'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/util/font_family_extension.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; import 'package:appflowy/workspace/application/settings/workspace/workspace_settings_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; import 'package:appflowy/workspace/presentation/settings/shared/document_color_setting_button.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_action.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_dashed_divider.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_radio_select.dart'; import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/language.dart'; import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; import 'package:flowy_infra/plugins/bloc/dynamic_plugin_event.dart'; import 'package:flowy_infra/plugins/bloc/dynamic_plugin_state.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; class SettingsWorkspaceView extends StatelessWidget { const SettingsWorkspaceView({ super.key, required this.userProfile, this.currentWorkspaceMemberRole, }); final UserProfilePB userProfile; final AFRolePB? currentWorkspaceMemberRole; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => WorkspaceSettingsBloc() ..add(WorkspaceSettingsEvent.initial(userProfile: userProfile)), child: BlocConsumer( listener: (context, state) { if (state.deleteWorkspace) { context.read().add( UserWorkspaceEvent.deleteWorkspace( workspaceId: state.workspace!.workspaceId, ), ); Navigator.of(context).pop(); } if (state.leaveWorkspace) { context.read().add( UserWorkspaceEvent.leaveWorkspace( workspaceId: state.workspace!.workspaceId, ), ); Navigator.of(context).pop(); } }, builder: (context, state) { return SettingsBody( title: LocaleKeys.settings_workspacePage_title.tr(), description: LocaleKeys.settings_workspacePage_description.tr(), autoSeparate: false, children: [ // We don't allow changing workspace name/icon for local/offline if (userProfile.workspaceType != WorkspaceTypePB.LocalW) ...[ SettingsCategory( title: LocaleKeys.settings_workspacePage_workspaceName_title .tr(), children: [ _WorkspaceNameSetting( currentWorkspaceMemberRole: currentWorkspaceMemberRole, ), ], ), const SettingsCategorySpacer(), SettingsCategory( title: LocaleKeys.settings_workspacePage_workspaceIcon_title .tr(), description: LocaleKeys .settings_workspacePage_workspaceIcon_description .tr(), children: [ _WorkspaceIconSetting( enableEdit: currentWorkspaceMemberRole?.isOwner ?? false, workspace: state.workspace, ), ], ), const SettingsCategorySpacer(), ], SettingsCategory( title: LocaleKeys.settings_workspacePage_appearance_title.tr(), children: const [AppearanceSelector()], ), const VSpace(16), // const SettingsCategorySpacer(), SettingsCategory( title: LocaleKeys.settings_workspacePage_theme_title.tr(), description: LocaleKeys.settings_workspacePage_theme_description.tr(), children: const [ _ThemeDropdown(), _DocumentCursorColorSetting(), _DocumentSelectionColorSetting(), DocumentPaddingSetting(), ], ), const SettingsCategorySpacer(), SettingsCategory( title: LocaleKeys.settings_workspacePage_workspaceFont_title.tr(), children: [ _FontSelectorDropdown( currentFont: context.read().state.font, ), SettingsDashedDivider( color: Theme.of(context).colorScheme.outline, ), SettingsCategory( title: LocaleKeys.settings_workspacePage_textDirection_title .tr(), children: const [ TextDirectionSelect(), EnableRTLItemsSwitcher(), ], ), ], ), const VSpace(16), SettingsCategory( title: LocaleKeys.settings_workspacePage_layoutDirection_title .tr(), children: const [_LayoutDirectionSelect()], ), const SettingsCategorySpacer(), SettingsCategory( title: LocaleKeys.settings_workspacePage_dateTime_title.tr(), children: [ const _DateTimeFormatLabel(), const _TimeFormatSwitcher(), SettingsDashedDivider( color: Theme.of(context).colorScheme.outline, ), const _DateFormatDropdown(), ], ), const SettingsCategorySpacer(), SettingsCategory( title: LocaleKeys.settings_workspacePage_language_title.tr(), children: const [LanguageDropdown()], ), const SettingsCategorySpacer(), if (userProfile.workspaceType != WorkspaceTypePB.LocalW) ...[ SingleSettingAction( label: LocaleKeys.settings_workspacePage_manageWorkspace_title .tr(), fontSize: 16, fontWeight: FontWeight.w600, onPressed: () => showConfirmDialog( context: context, title: currentWorkspaceMemberRole?.isOwner ?? false ? LocaleKeys .settings_workspacePage_deleteWorkspacePrompt_title .tr() : LocaleKeys .settings_workspacePage_leaveWorkspacePrompt_title .tr(), description: currentWorkspaceMemberRole?.isOwner ?? false ? LocaleKeys .settings_workspacePage_deleteWorkspacePrompt_content .tr() : LocaleKeys .settings_workspacePage_leaveWorkspacePrompt_content .tr(), style: ConfirmPopupStyle.cancelAndOk, onConfirm: (_) => context.read().add( currentWorkspaceMemberRole?.isOwner ?? false ? const WorkspaceSettingsEvent.deleteWorkspace() : const WorkspaceSettingsEvent.leaveWorkspace(), ), ), buttonType: SingleSettingsButtonType.danger, buttonLabel: currentWorkspaceMemberRole?.isOwner ?? false ? LocaleKeys .settings_workspacePage_manageWorkspace_deleteWorkspace .tr() : LocaleKeys .settings_workspacePage_manageWorkspace_leaveWorkspace .tr(), ), ], ], ); }, ), ); } } class _WorkspaceNameSetting extends StatefulWidget { const _WorkspaceNameSetting({ this.currentWorkspaceMemberRole, }); final AFRolePB? currentWorkspaceMemberRole; @override State<_WorkspaceNameSetting> createState() => _WorkspaceNameSettingState(); } class _WorkspaceNameSettingState extends State<_WorkspaceNameSetting> { final TextEditingController workspaceNameController = TextEditingController(); final focusNode = FocusNode(); Timer? debounce; bool isSaving = false; @override void dispose() { focusNode.dispose(); workspaceNameController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocConsumer( listener: (_, state) { if (isSaving) { return; } final newName = state.workspace?.name; if (newName != null && newName != workspaceNameController.text) { workspaceNameController.text = newName; } }, builder: (_, state) { if (widget.currentWorkspaceMemberRole == null || !widget.currentWorkspaceMemberRole!.isOwner) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2.5), child: FlowyText.regular( workspaceNameController.text, fontSize: 14, ), ); } return Flexible( child: SettingsInputField( textController: workspaceNameController, value: workspaceNameController.text, focusNode: focusNode, onSave: (_) => _saveWorkspaceName(name: workspaceNameController.text), onChanged: _debounceSaveName, hideActions: true, ), ); }, ); } void _debounceSaveName(String name) { isSaving = true; debounce?.cancel(); debounce = Timer( const Duration(milliseconds: 300), () { _saveWorkspaceName(name: name); isSaving = false; }, ); } void _saveWorkspaceName({required String name}) { if (name.isNotEmpty) { context .read() .add(WorkspaceSettingsEvent.updateWorkspaceName(name)); } } } @visibleForTesting class LanguageDropdown extends StatelessWidget { const LanguageDropdown({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SettingsDropdown( key: const Key('LanguageDropdown'), expandWidth: false, onChanged: (locale) => context .read() .setLocale(context, locale), selectedOption: state.locale, options: EasyLocalization.of(context)! .supportedLocales .map( (locale) => buildDropdownMenuEntry( context, selectedValue: state.locale, value: locale, label: languageFromLocale(locale), ), ) .toList(), ); }, ); } } class _WorkspaceIconSetting extends StatelessWidget { const _WorkspaceIconSetting({ required this.enableEdit, this.workspace, }); final bool enableEdit; final UserWorkspacePB? workspace; @override Widget build(BuildContext context) { if (workspace == null) { return const SizedBox( height: 64, width: 64, child: CircularProgressIndicator(), ); } Widget child = WorkspaceIcon( workspaceIcon: workspace!.icon, workspaceName: workspace!.name, iconSize: 64.0, emojiSize: 24.0, fontSize: 24.0, figmaLineHeight: 26.0, borderRadius: 18.0, isEditable: true, onSelected: (r) => context .read() .add(WorkspaceSettingsEvent.updateWorkspaceIcon(r.emoji)), ); if (!enableEdit) { child = IgnorePointer( child: child, ); } return child; } } @visibleForTesting class TextDirectionSelect extends StatelessWidget { const TextDirectionSelect({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final selectedItem = state.textDirection; return SettingsRadioSelect( onChanged: (item) { context .read() .setTextDirection(item.value); context .read() .syncDefaultTextDirection(item.value.name); }, items: [ SettingsRadioItem( value: AppFlowyTextDirection.ltr, icon: const FlowySvg(FlowySvgs.textdirection_ltr_m), label: LocaleKeys.settings_workspacePage_textDirection_leftToRight .tr(), isSelected: selectedItem == AppFlowyTextDirection.ltr, ), SettingsRadioItem( value: AppFlowyTextDirection.rtl, icon: const FlowySvg(FlowySvgs.textdirection_rtl_m), label: LocaleKeys.settings_workspacePage_textDirection_rightToLeft .tr(), isSelected: selectedItem == AppFlowyTextDirection.rtl, ), SettingsRadioItem( value: AppFlowyTextDirection.auto, icon: const FlowySvg(FlowySvgs.textdirection_auto_m), label: LocaleKeys.settings_workspacePage_textDirection_auto.tr(), isSelected: selectedItem == AppFlowyTextDirection.auto, ), ], ); }, ); } } @visibleForTesting class EnableRTLItemsSwitcher extends StatelessWidget { const EnableRTLItemsSwitcher({super.key}); @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: FlowyText.regular( LocaleKeys.settings_workspacePage_textDirection_enableRTLItems.tr(), fontSize: 16, ), ), const HSpace(16), Toggle( value: context .watch() .state .enableRtlToolbarItems, onChanged: (value) => context .read() .setEnableRTLToolbarItems(value), ), ], ); } } class _LayoutDirectionSelect extends StatelessWidget { const _LayoutDirectionSelect(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SettingsRadioSelect( onChanged: (item) => context .read() .setLayoutDirection(item.value), items: [ SettingsRadioItem( value: LayoutDirection.ltrLayout, icon: const FlowySvg(FlowySvgs.textdirection_ltr_m), label: LocaleKeys .settings_workspacePage_layoutDirection_leftToRight .tr(), isSelected: state.layoutDirection == LayoutDirection.ltrLayout, ), SettingsRadioItem( value: LayoutDirection.rtlLayout, icon: const FlowySvg(FlowySvgs.textdirection_rtl_m), label: LocaleKeys .settings_workspacePage_layoutDirection_rightToLeft .tr(), isSelected: state.layoutDirection == LayoutDirection.rtlLayout, ), ], ); }, ); } } class _DateFormatDropdown extends StatelessWidget { const _DateFormatDropdown(); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Padding( padding: const EdgeInsets.only(top: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.regular( LocaleKeys.settings_workspacePage_dateTime_dateFormat_label .tr(), fontSize: 16, ), const VSpace(8), SettingsDropdown( key: const Key('DateFormatDropdown'), expandWidth: false, onChanged: (format) => context .read() .setDateFormat(format), selectedOption: state.dateFormat, options: UserDateFormatPB.values .map( (format) => buildDropdownMenuEntry( context, value: format, label: _formatLabel(format), ), ) .toList(), ), ], ), ); }, ); } String _formatLabel(UserDateFormatPB format) => switch (format) { UserDateFormatPB.Locally => LocaleKeys.settings_workspacePage_dateTime_dateFormat_local.tr(), UserDateFormatPB.US => LocaleKeys.settings_workspacePage_dateTime_dateFormat_us.tr(), UserDateFormatPB.ISO => LocaleKeys.settings_workspacePage_dateTime_dateFormat_iso.tr(), UserDateFormatPB.Friendly => LocaleKeys.settings_workspacePage_dateTime_dateFormat_friendly.tr(), UserDateFormatPB.DayMonthYear => LocaleKeys.settings_workspacePage_dateTime_dateFormat_dmy.tr(), _ => "Unknown format", }; } class _DateTimeFormatLabel extends StatelessWidget { const _DateTimeFormatLabel(); @override Widget build(BuildContext context) { final now = DateTime.now(); return BlocBuilder( builder: (context, state) { return FlowyText.regular( LocaleKeys.settings_workspacePage_dateTime_example.tr( args: [ state.dateFormat.formatDate(now, false), state.timeFormat.formatTime(now), now.timeZoneName, ], ), maxLines: 2, fontSize: 16, color: AFThemeExtension.of(context).secondaryTextColor, ); }, ); } } class _TimeFormatSwitcher extends StatelessWidget { const _TimeFormatSwitcher(); @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: FlowyText.regular( LocaleKeys.settings_workspacePage_dateTime_24HourTime.tr(), fontSize: 16, ), ), const HSpace(16), Toggle( value: context.watch().state.timeFormat == UserTimeFormatPB.TwentyFourHour, onChanged: (value) => context.read().setTimeFormat( value ? UserTimeFormatPB.TwentyFourHour : UserTimeFormatPB.TwelveHour, ), ), ], ); } } class _ThemeDropdown extends StatelessWidget { const _ThemeDropdown(); @override Widget build(BuildContext context) { return BlocProvider( create: (context) => DynamicPluginBloc()..add(DynamicPluginEvent.load()), child: BlocBuilder( buildWhen: (_, current) => current is Ready, builder: (context, state) { final appearance = context.watch().state; final isLightMode = Theme.of(context).brightness == Brightness.light; final customThemes = state.whenOrNull( ready: (ps) => ps.map((p) => p.theme).whereType(), ); return SettingsDropdown( key: const Key('ThemeSelectorDropdown'), actions: [ SettingAction( tooltip: LocaleKeys .settings_workspacePage_theme_uploadCustomThemeTooltip .tr(), icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(20)), onPressed: () => Dialogs.show( context, child: BlocProvider.value( value: context.read(), child: const FlowyDialog( constraints: BoxConstraints(maxHeight: 300), child: ThemeUploadWidget(), ), ), ).then((val) { if (val != null && context.mounted) { showSnackBarMessage( context, LocaleKeys.settings_appearance_themeUpload_uploadSuccess .tr(), ); } }), ), SettingAction( icon: const FlowySvg( FlowySvgs.restore_s, size: Size.square(20), ), label: LocaleKeys.settings_common_reset.tr(), onPressed: () => context .read() .setTheme(AppTheme.builtins.first.themeName), ), ], onChanged: (theme) => context.read().setTheme(theme), selectedOption: appearance.appTheme.themeName, options: [ ...AppTheme.builtins.map( (t) { final theme = isLightMode ? t.lightTheme : t.darkTheme; return buildDropdownMenuEntry( context, selectedValue: appearance.appTheme.themeName, value: t.themeName, label: t.themeName, leadingWidget: _ThemeLeading(color: theme.sidebarBg), ); }, ), ...?customThemes?.map( (t) { final theme = isLightMode ? t.lightTheme : t.darkTheme; return buildDropdownMenuEntry( context, selectedValue: appearance.appTheme.themeName, value: t.themeName, label: t.themeName, leadingWidget: _ThemeLeading(color: theme.sidebarBg), trailingWidget: FlowyIconButton( icon: const FlowySvg(FlowySvgs.delete_s), iconColorOnHover: Theme.of(context).colorScheme.onSurface, onPressed: () { context.read().add( DynamicPluginEvent.removePlugin( name: t.themeName, ), ); if (appearance.appTheme.themeName == t.themeName) { context .read() .setTheme(AppTheme.builtins.first.themeName); } }, ), ); }, ), ], ); }, ), ); } } class _ThemeLeading extends StatelessWidget { const _ThemeLeading({required this.color}); final Color color; @override Widget build(BuildContext context) { return Container( width: 16, height: 16, decoration: BoxDecoration( color: color, borderRadius: Corners.s4Border, border: Border.all(color: Theme.of(context).colorScheme.outline), ), ); } } @visibleForTesting class AppearanceSelector extends StatelessWidget { const AppearanceSelector({super.key}); @override Widget build(BuildContext context) { final themeMode = context.read().state.themeMode; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ...ThemeMode.values.map( (t) => Padding( padding: const EdgeInsets.only(right: 16), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => context.read().setThemeMode(t), child: FlowyHover( style: HoverStyle.transparent( foregroundColorOnHover: AFThemeExtension.of(context).textColor, ), child: Column( children: [ Container( width: 88, height: 72, decoration: BoxDecoration( border: Border.all( color: t == themeMode ? Theme.of(context).colorScheme.onSecondary : Theme.of(context).colorScheme.outline, ), borderRadius: Corners.s4Border, image: DecorationImage( fit: BoxFit.cover, image: AssetImage( 'assets/images/appearance/${t.name.toLowerCase()}.png', ), ), ), child: t != themeMode ? null : const _SelectedModeIndicator(), ), const VSpace(6), FlowyText.regular(getLabel(t), textAlign: TextAlign.center), ], ), ), ), ), ), ], ); } String getLabel(ThemeMode t) => switch (t) { ThemeMode.system => LocaleKeys.settings_workspacePage_appearance_options_system.tr(), ThemeMode.light => LocaleKeys.settings_workspacePage_appearance_options_light.tr(), ThemeMode.dark => LocaleKeys.settings_workspacePage_appearance_options_dark.tr(), }; } class _SelectedModeIndicator extends StatelessWidget { const _SelectedModeIndicator(); @override Widget build(BuildContext context) { return Stack( children: [ Positioned( top: 4, left: 4, child: Material( shape: const CircleBorder(), elevation: 2, child: Container( decoration: const BoxDecoration( shape: BoxShape.circle, ), height: 16, width: 16, child: const FlowySvg( FlowySvgs.settings_selected_theme_m, size: Size.square(16), blendMode: BlendMode.dstIn, ), ), ), ), ], ); } } class _FontSelectorDropdown extends StatefulWidget { const _FontSelectorDropdown({required this.currentFont}); final String currentFont; @override State<_FontSelectorDropdown> createState() => _FontSelectorDropdownState(); } class _FontSelectorDropdownState extends State<_FontSelectorDropdown> { late final _options = [defaultFontFamily, ...GoogleFonts.asMap().keys]; final _focusNode = FocusNode(); final _controller = PopoverController(); late final ScrollController _scrollController; final _textController = TextEditingController(); @override void initState() { super.initState(); const itemExtent = 32; final index = _options.indexOf(widget.currentFont); final newPosition = (index * itemExtent).toDouble(); _scrollController = ScrollController(initialScrollOffset: newPosition); WidgetsBinding.instance.addPostFrameCallback((_) { _textController.text = context .read() .state .font .fontFamilyDisplayName; }); } @override void dispose() { _controller.close(); _focusNode.dispose(); _scrollController.dispose(); _textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final appearance = context.watch().state; return LayoutBuilder( builder: (context, constraints) => AppFlowyPopover( margin: EdgeInsets.zero, controller: _controller, skipTraversal: true, triggerActions: PopoverTriggerFlags.none, onClose: () { _focusNode.unfocus(); setState(() {}); }, direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints( maxHeight: 150, maxWidth: constraints.maxWidth - 90, ), borderRadius: const BorderRadius.all(Radius.circular(4.0)), popupBuilder: (_) => _FontListPopup( currentFont: appearance.font, scrollController: _scrollController, controller: _controller, options: _options, textController: _textController, focusNode: _focusNode, ), child: Row( children: [ Expanded( child: TapRegion( behavior: HitTestBehavior.translucent, onTapOutside: (_) { _focusNode.unfocus(); setState(() {}); }, child: Listener( onPointerDown: (_) { _focusNode.requestFocus(); setState(() {}); _controller.show(); }, child: FlowyTextField( autoFocus: false, focusNode: _focusNode, controller: _textController, decoration: InputDecoration( suffixIcon: const MouseRegion( cursor: SystemMouseCursors.click, child: Icon(Icons.arrow_drop_down), ), counterText: '', contentPadding: const EdgeInsets.symmetric( vertical: 12, horizontal: 18, ), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.outline, ), borderRadius: Corners.s8Border, ), focusedBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), borderRadius: Corners.s8Border, ), errorBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.error, ), borderRadius: Corners.s8Border, ), focusedErrorBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.error, ), borderRadius: Corners.s8Border, ), ), ), ), ), ), const HSpace(16), GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => context .read() .setFontFamily(defaultFontFamily), child: SizedBox( height: 26, child: FlowyHover( resetHoverOnRebuild: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( children: [ const FlowySvg( FlowySvgs.restore_s, size: Size.square(20), ), const HSpace(4), FlowyText.regular( LocaleKeys.settings_common_reset.tr(), ), ], ), ), ), ), ), ], ), ), ); } } class _FontListPopup extends StatefulWidget { const _FontListPopup({ required this.controller, required this.scrollController, required this.options, required this.currentFont, required this.textController, required this.focusNode, }); final ScrollController scrollController; final List options; final String currentFont; final TextEditingController textController; final FocusNode focusNode; final PopoverController controller; @override State<_FontListPopup> createState() => _FontListPopupState(); } class _FontListPopupState extends State<_FontListPopup> { late List _filteredOptions = widget.options; @override void initState() { super.initState(); widget.textController.addListener(_onTextFieldChanged); } void _onTextFieldChanged() { final value = widget.textController.text; if (value.trim().isEmpty) { _filteredOptions = widget.options; } else { if (value.fontFamilyDisplayName == widget.currentFont.fontFamilyDisplayName) { return; } _filteredOptions = widget.options .where( (f) => f.toLowerCase().contains(value.trim().toLowerCase()) || f.fontFamilyDisplayName .toLowerCase() .contains(value.trim().fontFamilyDisplayName.toLowerCase()), ) .toList(); // Default font family is "", but the display name is "System", // which means it's hard compared to other font families to find this one. if (!_filteredOptions.contains(defaultFontFamily) && 'system'.contains(value.trim().toLowerCase())) { _filteredOptions.insert(0, defaultFontFamily); } } setState(() {}); } @override void dispose() { widget.textController.removeListener(_onTextFieldChanged); super.dispose(); } @override Widget build(BuildContext context) { return Material( type: MaterialType.transparency, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_filteredOptions.isEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: FlowyText.medium( LocaleKeys.settings_workspacePage_workspaceFont_noFontHint.tr(), ), ), Flexible( child: ListView.separated( shrinkWrap: _filteredOptions.length < 10, controller: widget.scrollController, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), itemCount: _filteredOptions.length, separatorBuilder: (_, __) => const VSpace(6), itemBuilder: (context, index) { final font = _filteredOptions[index]; final isSelected = widget.currentFont == font; return SizedBox( height: 29, child: ListTile( minVerticalPadding: 0, selected: isSelected, dense: true, hoverColor: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.12), selectedTileColor: Theme.of(context) .colorScheme .primary .withValues(alpha: 0.12), contentPadding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), minTileHeight: 0, onTap: () { context .read() .setFontFamily(font); widget.textController.text = font.fontFamilyDisplayName; // This is a workaround such that when dialog rebuilds due // to font changing, the font selector won't retain focus. widget.focusNode.parent?.requestFocus(); widget.controller.close(); }, title: Align( alignment: AlignmentDirectional.centerStart, child: Text( font.fontFamilyDisplayName, style: TextStyle( color: AFThemeExtension.of(context).textColor, fontFamily: getGoogleFontSafely(font).fontFamily, ), ), ), trailing: isSelected ? const FlowySvg(FlowySvgs.check_s) : null, ), ); }, ), ), ], ), ); } } class _DocumentCursorColorSetting extends StatelessWidget { const _DocumentCursorColorSetting(); @override Widget build(BuildContext context) { final label = LocaleKeys.settings_appearance_documentSettings_cursorColor.tr(); return BlocBuilder( builder: (context, state) { return SettingListTile( label: label, resetButtonKey: const Key('DocumentCursorColorResetButton'), onResetRequested: () { showConfirmDialog( context: context, title: LocaleKeys.settings_workspacePage_resetCursorColor_title.tr(), description: LocaleKeys .settings_workspacePage_resetCursorColor_description .tr(), style: ConfirmPopupStyle.cancelAndOk, confirmLabel: LocaleKeys.settings_common_reset.tr(), onConfirm: (_) => context ..read().resetDocumentCursorColor() ..read().syncCursorColor(null), ); }, trailing: [ DocumentColorSettingButton( key: const Key('DocumentCursorColorSettingButton'), currentColor: state.cursorColor ?? DefaultAppearanceSettings.getDefaultCursorColor(context), previewWidgetBuilder: (color) => _CursorColorValueWidget( cursorColor: color ?? DefaultAppearanceSettings.getDefaultCursorColor(context), ), dialogTitle: label, onApply: (color) => context ..read().setDocumentCursorColor(color) ..read().syncCursorColor(color), ), ], ); }, ); } } class _CursorColorValueWidget extends StatelessWidget { const _CursorColorValueWidget({required this.cursorColor}); final Color cursorColor; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container(color: cursorColor, width: 2, height: 16), FlowyText( LocaleKeys.appName.tr(), // To avoid the text color changes when it is hovered in dark mode color: AFThemeExtension.of(context).onBackground, ), ], ); } } class _DocumentSelectionColorSetting extends StatelessWidget { const _DocumentSelectionColorSetting(); @override Widget build(BuildContext context) { final label = LocaleKeys.settings_appearance_documentSettings_selectionColor.tr(); return BlocBuilder( builder: (context, state) { return SettingListTile( label: label, resetButtonKey: const Key('DocumentSelectionColorResetButton'), onResetRequested: () { showConfirmDialog( context: context, title: LocaleKeys.settings_workspacePage_resetSelectionColor_title .tr(), description: LocaleKeys .settings_workspacePage_resetSelectionColor_description .tr(), style: ConfirmPopupStyle.cancelAndOk, confirmLabel: LocaleKeys.settings_common_reset.tr(), onConfirm: (_) => context ..read().resetDocumentSelectionColor() ..read().syncSelectionColor(null), ); }, trailing: [ DocumentColorSettingButton( currentColor: state.selectionColor ?? DefaultAppearanceSettings.getDefaultSelectionColor(context), previewWidgetBuilder: (color) => _SelectionColorValueWidget( selectionColor: color ?? DefaultAppearanceSettings.getDefaultSelectionColor(context), ), dialogTitle: label, onApply: (c) => context ..read().setDocumentSelectionColor(c) ..read().syncSelectionColor(c), ), ], ); }, ); } } class _SelectionColorValueWidget extends StatelessWidget { const _SelectionColorValueWidget({required this.selectionColor}); final Color selectionColor; @override Widget build(BuildContext context) { // To avoid the text color changes when it is hovered in dark mode final textColor = AFThemeExtension.of(context).onBackground; return Row( mainAxisSize: MainAxisSize.min, children: [ Container( color: selectionColor, child: FlowyText( LocaleKeys.settings_appearance_documentSettings_app.tr(), color: textColor, ), ), FlowyText( LocaleKeys.settings_appearance_documentSettings_flowy.tr(), color: textColor, ), ], ); } } class DocumentPaddingSetting extends StatelessWidget { const DocumentPaddingSetting({ super.key, }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Column( children: [ Row( children: [ FlowyText.medium( LocaleKeys.settings_appearance_documentSettings_width.tr(), ), const Spacer(), SettingsResetButton( onResetRequested: () => context.read().syncWidth(null), ), ], ), const VSpace(6), Container( height: 32, padding: const EdgeInsets.only(right: 4), child: _DocumentPaddingSlider( onPaddingChanged: (value) { context.read().syncWidth(value); }, ), ), ], ); }, ); } } class _DocumentPaddingSlider extends StatefulWidget { const _DocumentPaddingSlider({ required this.onPaddingChanged, }); final void Function(double) onPaddingChanged; @override State<_DocumentPaddingSlider> createState() => _DocumentPaddingSliderState(); } class _DocumentPaddingSliderState extends State<_DocumentPaddingSlider> { late double width; @override void initState() { super.initState(); width = context.read().state.width; } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state.width != width) { width = state.width; } return SliderTheme( data: Theme.of(context).sliderTheme.copyWith( showValueIndicator: ShowValueIndicator.never, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 8, ), overlayShape: SliderComponentShape.noThumb, ), child: Slider( value: width.clamp( EditorStyleCustomizer.minDocumentWidth, EditorStyleCustomizer.maxDocumentWidth, ), min: EditorStyleCustomizer.minDocumentWidth, max: EditorStyleCustomizer.maxDocumentWidth, divisions: 10, onChanged: (value) { setState(() => width = value); widget.onPaddingChanged(value); }, ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class SettingsPageSitesConstants { static const threeDotsButtonWidth = 26.0; static const alignPadding = 6.0; static final dateFormat = DateFormat('MMM d, yyyy'); static final publishedViewHeaderTitles = [ LocaleKeys.settings_sites_publishedPage_page.tr(), LocaleKeys.settings_sites_publishedPage_pathName.tr(), LocaleKeys.settings_sites_publishedPage_date.tr(), ]; static final namespaceHeaderTitles = [ LocaleKeys.settings_sites_namespaceHeader.tr(), LocaleKeys.settings_sites_homepageHeader.tr(), ]; // the published view name is longer than the other two, so we give it more flex static final publishedViewItemFlexes = [1, 1, 1]; } class SettingsPageSitesEvent { static void visitSite( PublishInfoViewPB publishInfoView, { String? nameSpace, }) { // visit the site final url = ShareConstants.buildPublishUrl( nameSpace: nameSpace ?? publishInfoView.info.namespace, publishName: publishInfoView.info.publishName, ); afLaunchUrlString(url); } static void copySiteLink( BuildContext context, PublishInfoViewPB publishInfoView, { String? nameSpace, }) { final url = ShareConstants.buildPublishUrl( nameSpace: nameSpace ?? publishInfoView.info.namespace, publishName: publishInfoView.info.publishName, ); getIt().setData(ClipboardServiceData(plainText: url)); showToastNotification( message: LocaleKeys.message_copy_success.tr(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_header.dart ================================================ import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class DomainHeader extends StatelessWidget { const DomainHeader({ super.key, }); @override Widget build(BuildContext context) { return Row( children: [ ...SettingsPageSitesConstants.namespaceHeaderTitles.map( (title) => Expanded( child: FlowyText.medium( title, fontSize: 14.0, textAlign: TextAlign.left, ), ), ), // it used to align the three dots button in the published page item const HSpace(SettingsPageSitesConstants.threeDotsButtonWidth), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/shared/colors.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/home_page_menu.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/publish_info_view_item.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DomainItem extends StatelessWidget { const DomainItem({ super.key, required this.namespace, required this.homepage, }); final String namespace; final String homepage; @override Widget build(BuildContext context) { final namespaceUrl = ShareConstants.buildNamespaceUrl( nameSpace: namespace, ); return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ // Namespace Expanded( child: _buildNamespace(context, namespaceUrl), ), // Homepage Expanded( child: _buildHomepage(context), ), // ... button DomainMoreAction(namespace: namespace), ], ); } Widget _buildNamespace(BuildContext context, String namespaceUrl) { return Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.only(right: 12.0), child: FlowyTooltip( message: '${LocaleKeys.shareAction_visitSite.tr()}\n$namespaceUrl', child: FlowyButton( useIntrinsicWidth: true, text: FlowyText( namespaceUrl, fontSize: 14.0, overflow: TextOverflow.ellipsis, ), onTap: () { final namespaceUrl = ShareConstants.buildNamespaceUrl( nameSpace: namespace, withHttps: true, ); afLaunchUrlString(namespaceUrl); }, ), ), ); } Widget _buildHomepage(BuildContext context) { final plan = context.read().state.subscriptionInfo?.plan; if (plan == null) { return const SizedBox.shrink(); } final isFreePlan = plan == WorkspacePlanPB.FreePlan; if (isFreePlan) { return const Padding( padding: EdgeInsets.only( left: SettingsPageSitesConstants.alignPadding, ), child: _FreePlanUpgradeButton(), ); } return const _HomePageButton(); } } class _HomePageButton extends StatelessWidget { const _HomePageButton(); @override Widget build(BuildContext context) { final settingsSitesState = context.watch().state; if (settingsSitesState.isLoading) { return const SizedBox.shrink(); } final isOwner = context .watch() .state .currentWorkspace ?.role .isOwner ?? false; final homePageView = settingsSitesState.homePageView; Widget child = homePageView == null ? _defaultHomePageButton(context) : PublishInfoViewItem( publishInfoView: homePageView, margin: isOwner ? null : EdgeInsets.zero, ); if (isOwner) { child = _buildHomePageButtonForOwner( context, homePageView: homePageView, child: child, ); } else { child = _buildHomePageButtonForNonOwner(context, child); } return Container( alignment: Alignment.centerLeft, child: child, ); } Widget _buildHomePageButtonForOwner( BuildContext context, { required PublishInfoViewPB? homePageView, required Widget child, }) { return Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: AppFlowyPopover( direction: PopoverDirection.bottomWithCenterAligned, constraints: const BoxConstraints( maxWidth: 260, maxHeight: 345, ), margin: const EdgeInsets.symmetric( horizontal: 14.0, vertical: 12.0, ), popupBuilder: (_) { final bloc = context.read(); return BlocProvider.value( value: bloc, child: SelectHomePageMenu( userProfile: bloc.user, workspaceId: bloc.workspaceId, onSelected: (view) {}, ), ); }, child: child, ), ), if (homePageView != null) FlowyTooltip( message: LocaleKeys.settings_sites_clearHomePage.tr(), child: FlowyButton( margin: const EdgeInsets.all(4.0), useIntrinsicWidth: true, onTap: () { context.read().add( const SettingsSitesEvent.removeHomePage(), ); }, text: const FlowySvg( FlowySvgs.close_m, size: Size.square(19.0), ), ), ), ], ); } Widget _buildHomePageButtonForNonOwner( BuildContext context, Widget child, ) { return FlowyTooltip( message: LocaleKeys .settings_sites_namespace_onlyWorkspaceOwnerCanSetHomePage .tr(), child: IgnorePointer( child: child, ), ); } Widget _defaultHomePageButton(BuildContext context) { return FlowyButton( useIntrinsicWidth: true, leftIcon: const FlowySvg( FlowySvgs.search_s, ), leftIconSize: const Size.square(14.0), text: FlowyText( LocaleKeys.settings_sites_selectHomePage.tr(), figmaLineHeight: 18.0, ), ); } } class _FreePlanUpgradeButton extends StatelessWidget { const _FreePlanUpgradeButton(); @override Widget build(BuildContext context) { final isOwner = context .watch() .state .currentWorkspace ?.role .isOwner ?? false; return Container( alignment: Alignment.centerLeft, child: FlowyTooltip( message: LocaleKeys.settings_sites_homePage_upgradeToPro.tr(), child: PrimaryRoundedButton( text: 'Pro ↗', fontSize: 12.0, figmaLineHeight: 16.0, fontWeight: FontWeight.w600, radius: 8.0, textColor: context.proPrimaryColor, backgroundColor: context.proSecondaryColor, margin: const EdgeInsets.symmetric( horizontal: 8.0, vertical: 6.0, ), hoverColor: context.proSecondaryColor.withValues(alpha: 0.9), onTap: () { if (isOwner) { showToastNotification( message: LocaleKeys.settings_sites_namespace_redirectToPayment.tr(), type: ToastificationType.error, ); context.read().add( const SettingsSitesEvent.upgradeSubscription(), ); } else { showToastNotification( message: LocaleKeys .settings_sites_namespace_pleaseAskOwnerToSetHomePage .tr(), type: ToastificationType.error, ); } }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart ================================================ import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DomainMoreAction extends StatefulWidget { const DomainMoreAction({ super.key, required this.namespace, }); final String namespace; @override State createState() => _DomainMoreActionState(); } class _DomainMoreActionState extends State { @override void initState() { super.initState(); // update the current workspace to ensure the owner check is correct context.read().add(UserWorkspaceEvent.initialize()); } @override Widget build(BuildContext context) { return AppFlowyPopover( constraints: const BoxConstraints(maxWidth: 188), offset: const Offset(6, 0), animationDuration: Durations.short3, beginScaleFactor: 1.0, beginOpacity: 0.8, child: const SizedBox( width: SettingsPageSitesConstants.threeDotsButtonWidth, child: FlowyButton( useIntrinsicWidth: true, text: FlowySvg(FlowySvgs.three_dots_s), ), ), popupBuilder: (builderContext) { return BlocProvider.value( value: context.read(), child: _buildUpdateNamespaceButton( context, builderContext, ), ); }, ); } Widget _buildUpdateNamespaceButton( BuildContext context, BuildContext builderContext, ) { final child = _buildActionButton( context, builderContext, type: _ActionType.updateNamespace, ); final plan = context.read().state.subscriptionInfo?.plan; if (plan != WorkspacePlanPB.ProPlan) { return _buildForbiddenActionButton( context, tooltipMessage: LocaleKeys.settings_sites_namespace_upgradeToPro.tr(), child: child, ); } final isOwner = context .watch() .state .currentWorkspace ?.role .isOwner ?? false; if (!isOwner) { return _buildForbiddenActionButton( context, tooltipMessage: LocaleKeys .settings_sites_error_onlyWorkspaceOwnerCanUpdateNamespace .tr(), child: child, ); } return child; } Widget _buildForbiddenActionButton( BuildContext context, { required String tooltipMessage, required Widget child, }) { return Opacity( opacity: 0.5, child: FlowyTooltip( message: tooltipMessage, child: MouseRegion( cursor: SystemMouseCursors.forbidden, child: IgnorePointer(child: child), ), ), ); } Widget _buildActionButton( BuildContext context, BuildContext builderContext, { required _ActionType type, }) { return Container( height: 34, padding: const EdgeInsets.symmetric(vertical: 2.0), child: FlowyIconTextButton( margin: const EdgeInsets.symmetric(horizontal: 6), iconPadding: 10.0, onTap: () => _onTap(context, builderContext, type), leftIconBuilder: (onHover) => FlowySvg( type.leftIconSvg, ), textBuilder: (onHover) => FlowyText.regular( type.name, fontSize: 14.0, figmaLineHeight: 18.0, overflow: TextOverflow.ellipsis, ), ), ); } void _onTap( BuildContext context, BuildContext builderContext, _ActionType type, ) { switch (type) { case _ActionType.updateNamespace: _showSettingsDialog( context, builderContext, ); break; case _ActionType.removeHomePage: context.read().add( const SettingsSitesEvent.removeHomePage(), ); break; } PopoverContainer.of(builderContext).closeAll(); } void _showSettingsDialog( BuildContext context, BuildContext builderContext, ) { showDialog( context: context, builder: (_) { return BlocProvider.value( value: context.read(), child: Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: SizedBox( width: 460, child: DomainSettingsDialog( namespace: widget.namespace, ), ), ), ); }, ); } } enum _ActionType { updateNamespace, removeHomePage, } extension _ActionTypeExtension on _ActionType { String get name => switch (this) { _ActionType.updateNamespace => LocaleKeys.settings_sites_updateNamespace.tr(), _ActionType.removeHomePage => LocaleKeys.settings_sites_removeHomepage.tr(), }; FlowySvgData get leftIconSvg => switch (this) { _ActionType.updateNamespace => FlowySvgs.view_item_rename_s, _ActionType.removeHomePage => FlowySvgs.trash_s, }; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/plugins/shared/share/publish_color_extension.dart'; import 'package:appflowy/shared/error_code/error_code_map.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DomainSettingsDialog extends StatefulWidget { const DomainSettingsDialog({ super.key, required this.namespace, }); final String namespace; @override State createState() => _DomainSettingsDialogState(); } class _DomainSettingsDialogState extends State { final focusNode = FocusNode(); final controller = TextEditingController(); late final controllerText = ValueNotifier(widget.namespace); String errorHintText = ''; @override void initState() { super.initState(); controller.text = widget.namespace; controller.addListener(_onTextChanged); } void _onTextChanged() => controllerText.value = controller.text; @override void dispose() { focusNode.dispose(); controller.removeListener(_onTextChanged); controller.dispose(); controllerText.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocListener( listener: _onListener, child: KeyboardListener( focusNode: focusNode, autofocus: true, onKeyEvent: (event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { Navigator.of(context).pop(); } }, child: Container( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTitle(), const VSpace(12), _buildNamespaceDescription(), const VSpace(20), _buildNamespaceTextField(), _buildPreviewNamespace(), _buildErrorHintText(), const VSpace(20), _buildButtons(), ], ), ), ), ); } Widget _buildTitle() { return Row( children: [ FlowyText( LocaleKeys.settings_sites_namespace_updateExistingNamespace.tr(), fontSize: 16.0, figmaLineHeight: 22.0, fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, ), const HSpace(6.0), FlowyTooltip( message: LocaleKeys.settings_sites_namespace_tooltip.tr(), child: const FlowySvg(FlowySvgs.information_s), ), const HSpace(6.0), const Spacer(), FlowyButton( margin: const EdgeInsets.all(3), useIntrinsicWidth: true, text: const FlowySvg( FlowySvgs.upgrade_close_s, size: Size.square(18.0), ), onTap: () => Navigator.of(context).pop(), ), ], ); } Widget _buildNamespaceDescription() { return FlowyText( LocaleKeys.settings_sites_namespace_description.tr(), fontSize: 14.0, color: Theme.of(context).hintColor, figmaLineHeight: 16.0, maxLines: 3, ); } Widget _buildNamespaceTextField() { return SizedBox( height: 36, child: FlowyTextField( autoFocus: false, controller: controller, enableBorderColor: ShareMenuColors.borderColor(context), ), ); } Widget _buildButtons() { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ OutlinedRoundedButton( text: LocaleKeys.button_cancel.tr(), onTap: () => Navigator.of(context).pop(), ), const HSpace(12.0), PrimaryRoundedButton( text: LocaleKeys.button_save.tr(), radius: 8.0, margin: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 9.0, ), onTap: _onSave, ), ], ); } Widget _buildErrorHintText() { if (errorHintText.isEmpty) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(top: 4.0, left: 2.0), child: FlowyText( errorHintText, fontSize: 12.0, figmaLineHeight: 18.0, color: Theme.of(context).colorScheme.error, ), ); } Widget _buildPreviewNamespace() { return ValueListenableBuilder( valueListenable: controllerText, builder: (context, value, child) { final url = ShareConstants.buildNamespaceUrl( nameSpace: value, ); return Padding( padding: const EdgeInsets.only(top: 4.0, left: 2.0), child: Opacity( opacity: 0.8, child: FlowyText( url, fontSize: 14.0, figmaLineHeight: 18.0, withTooltip: true, overflow: TextOverflow.ellipsis, ), ), ); }, ); } void _onSave() { // listen on the result context .read() .add(SettingsSitesEvent.updateNamespace(controller.text)); } void _onListener(BuildContext context, SettingsSitesState state) { final actionResult = state.actionResult; final type = actionResult?.actionType; final result = actionResult?.result; if (type != SettingsSitesActionType.updateNamespace || result == null) { return; } result.fold( (s) { showToastNotification( message: LocaleKeys.settings_sites_success_namespaceUpdated.tr(), ); Navigator.of(context).pop(); }, (f) { final basicErrorMessage = LocaleKeys.settings_sites_error_failedToUpdateNamespace.tr(); final errorMessage = f.code.namespaceErrorMessage; setState(() { errorHintText = errorMessage.orDefault(basicErrorMessage); }); Log.error('Failed to update namespace: $f'); showToastNotification( message: basicErrorMessage, type: ToastificationType.error, description: errorMessage, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/home_page_menu.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/publish_info_view_item.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; typedef OnSelectedHomePage = void Function(ViewPB view); class SelectHomePageMenu extends StatefulWidget { const SelectHomePageMenu({ super.key, required this.onSelected, required this.userProfile, required this.workspaceId, }); final OnSelectedHomePage onSelected; final UserProfilePB userProfile; final String workspaceId; @override State createState() => _SelectHomePageMenuState(); } class _SelectHomePageMenuState extends State { List source = []; List views = []; @override void initState() { super.initState(); source = context.read().state.publishedViews; views = [...source]; } @override Widget build(BuildContext context) { if (views.isEmpty) { return _buildNoPublishedViews(); } return _buildMenu(context); } Widget _buildNoPublishedViews() { return FlowyText.regular( LocaleKeys.settings_sites_publishedPage_noPublishedPages.tr(), color: Theme.of(context).hintColor, overflow: TextOverflow.ellipsis, ); } Widget _buildMenu(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SpaceSearchField( width: 240, onSearch: (context, value) => _onSearch(value), ), const VSpace(10), Expanded( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ ...views.map( (view) => Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: PublishInfoViewItem( publishInfoView: view, useIntrinsicWidth: false, onTap: () { context.read().add( SettingsSitesEvent.setHomePage(view.info.viewId), ); PopoverContainer.of(context).close(); }, ), ), ), ], ), ), ), ], ); } void _onSearch(String value) { setState(() { if (value.isEmpty) { views = source; } else { views = source .where( (view) => view.view.name.toLowerCase().contains(value.toLowerCase()), ) .toList(); } }); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/publish_info_view_item.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class PublishInfoViewItem extends StatelessWidget { const PublishInfoViewItem({ super.key, required this.publishInfoView, this.onTap, this.useIntrinsicWidth = true, this.margin, this.extraTooltipMessage, }); final PublishInfoViewPB publishInfoView; final VoidCallback? onTap; final bool useIntrinsicWidth; final EdgeInsets? margin; final String? extraTooltipMessage; @override Widget build(BuildContext context) { final name = publishInfoView.view.name.orDefault( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), ); final tooltipMessage = extraTooltipMessage != null ? '$extraTooltipMessage\n$name' : name; return Container( alignment: Alignment.centerLeft, child: FlowyButton( margin: margin, useIntrinsicWidth: useIntrinsicWidth, mainAxisAlignment: MainAxisAlignment.start, leftIcon: _buildIcon(), text: FlowyTooltip( message: tooltipMessage, child: FlowyText.regular( name, fontSize: 14.0, figmaLineHeight: 18.0, overflow: TextOverflow.ellipsis, ), ), onTap: onTap, ), ); } Widget _buildIcon() { final icon = publishInfoView.view.icon.toEmojiIconData(); return icon.isNotEmpty ? RawEmojiIconWidget( emoji: icon, emojiSize: 16.0, lineHeight: 1.1, ) : publishInfoView.view.defaultIcon(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/navigator_context_extension.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/publish_info_view_item.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class PublishedViewItem extends StatelessWidget { const PublishedViewItem({ super.key, required this.publishInfoView, }); final PublishInfoViewPB publishInfoView; @override Widget build(BuildContext context) { final flexes = SettingsPageSitesConstants.publishedViewItemFlexes; return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ // Published page name Expanded( flex: flexes[0], child: _buildPublishedPageName(context), ), // Published Name Expanded( flex: flexes[1], child: _buildPublishedName(context), ), // Published at Expanded( flex: flexes[2], child: Padding( padding: const EdgeInsets.only( left: SettingsPageSitesConstants.alignPadding, ), child: _buildPublishedAt(context), ), ), // More actions PublishedViewMoreAction( publishInfoView: publishInfoView, ), ], ); } Widget _buildPublishedPageName(BuildContext context) { return PublishInfoViewItem( extraTooltipMessage: LocaleKeys.settings_sites_publishedPage_clickToOpenPageInApp.tr(), publishInfoView: publishInfoView, onTap: () { context.popToHome(); getIt().add( ActionNavigationEvent.performAction( action: NavigationAction( objectId: publishInfoView.view.viewId, ), ), ); }, ); } Widget _buildPublishedAt(BuildContext context) { final formattedDate = SettingsPageSitesConstants.dateFormat.format( DateTime.fromMillisecondsSinceEpoch( publishInfoView.info.publishTimestampSec.toInt() * 1000, ), ); return FlowyText( formattedDate, fontSize: 14.0, overflow: TextOverflow.ellipsis, ); } Widget _buildPublishedName(BuildContext context) { return Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.only(right: 48.0), child: FlowyButton( useIntrinsicWidth: true, onTap: () { final url = ShareConstants.buildPublishUrl( nameSpace: publishInfoView.info.namespace, publishName: publishInfoView.info.publishName, ); afLaunchUrlString(url); }, text: FlowyTooltip( message: '${LocaleKeys.settings_sites_publishedPage_clickToOpenPageInBrowser.tr()}\n${publishInfoView.info.publishName}', child: FlowyText( publishInfoView.info.publishName, fontSize: 14.0, figmaLineHeight: 18.0, overflow: TextOverflow.ellipsis, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_item_header.dart ================================================ import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class PublishViewItemHeader extends StatelessWidget { const PublishViewItemHeader({ super.key, }); @override Widget build(BuildContext context) { final items = List.generate( SettingsPageSitesConstants.publishedViewHeaderTitles.length, (index) => ( title: SettingsPageSitesConstants.publishedViewHeaderTitles[index], flex: SettingsPageSitesConstants.publishedViewItemFlexes[index], ), ); return Row( children: [ ...items.map( (item) => Expanded( flex: item.flex, child: FlowyText.medium( item.title, fontSize: 14.0, textAlign: TextAlign.left, ), ), ), // it used to align the three dots button in the published page item const HSpace(SettingsPageSitesConstants.threeDotsButtonWidth), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class PublishedViewMoreAction extends StatelessWidget { const PublishedViewMoreAction({ super.key, required this.publishInfoView, }); final PublishInfoViewPB publishInfoView; @override Widget build(BuildContext context) { return AppFlowyPopover( constraints: const BoxConstraints(maxWidth: 168), offset: const Offset(6, 0), animationDuration: Durations.short3, beginScaleFactor: 1.0, beginOpacity: 0.8, child: const SizedBox( width: SettingsPageSitesConstants.threeDotsButtonWidth, child: FlowyButton( useIntrinsicWidth: true, text: FlowySvg(FlowySvgs.three_dots_s), ), ), popupBuilder: (builderContext) { return BlocProvider.value( value: context.read(), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildActionButton( context, builderContext, type: _ActionType.viewSite, ), _buildActionButton( context, builderContext, type: _ActionType.copySiteLink, ), _buildActionButton( context, builderContext, type: _ActionType.unpublish, ), _buildActionButton( context, builderContext, type: _ActionType.customUrl, ), _buildActionButton( context, builderContext, type: _ActionType.settings, ), ], ), ); }, ); } Widget _buildActionButton( BuildContext context, BuildContext builderContext, { required _ActionType type, }) { return Container( height: 34, padding: const EdgeInsets.symmetric(vertical: 2.0), child: FlowyIconTextButton( margin: const EdgeInsets.symmetric(horizontal: 6), iconPadding: 10.0, onTap: () => _onTap(context, builderContext, type), leftIconBuilder: (onHover) => FlowySvg( type.leftIconSvg, ), textBuilder: (onHover) => FlowyText.regular( type.name, fontSize: 14.0, figmaLineHeight: 18.0, ), ), ); } void _onTap( BuildContext context, BuildContext builderContext, _ActionType type, ) { switch (type) { case _ActionType.viewSite: SettingsPageSitesEvent.visitSite( publishInfoView, nameSpace: context.read().state.namespace, ); break; case _ActionType.copySiteLink: SettingsPageSitesEvent.copySiteLink( context, publishInfoView, nameSpace: context.read().state.namespace, ); break; case _ActionType.settings: _showSettingsDialog( context, builderContext, ); break; case _ActionType.unpublish: context.read().add( SettingsSitesEvent.unpublishView(publishInfoView.info.viewId), ); PopoverContainer.maybeOf(builderContext)?.close(); break; case _ActionType.customUrl: _showSettingsDialog( context, builderContext, ); break; } PopoverContainer.of(builderContext).closeAll(); } void _showSettingsDialog( BuildContext context, BuildContext builderContext, ) { showDialog( context: context, builder: (_) { return BlocProvider.value( value: context.read(), child: Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: SizedBox( width: 440, child: PublishedViewSettingsDialog( publishInfoView: publishInfoView, ), ), ), ); }, ); } } enum _ActionType { viewSite, copySiteLink, settings, unpublish, customUrl; String get name => switch (this) { _ActionType.viewSite => LocaleKeys.shareAction_visitSite.tr(), _ActionType.copySiteLink => LocaleKeys.shareAction_copyLink.tr(), _ActionType.settings => LocaleKeys.settings_popupMenuItem_settings.tr(), _ActionType.unpublish => LocaleKeys.shareAction_unPublish.tr(), _ActionType.customUrl => LocaleKeys.settings_sites_customUrl.tr(), }; FlowySvgData get leftIconSvg => switch (this) { _ActionType.viewSite => FlowySvgs.share_publish_s, _ActionType.copySiteLink => FlowySvgs.copy_s, _ActionType.settings => FlowySvgs.settings_s, _ActionType.unpublish => FlowySvgs.delete_s, _ActionType.customUrl => FlowySvgs.edit_s, }; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/shared/share/publish_color_extension.dart'; import 'package:appflowy/shared/error_code/error_code_map.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class PublishedViewSettingsDialog extends StatefulWidget { const PublishedViewSettingsDialog({ super.key, required this.publishInfoView, }); final PublishInfoViewPB publishInfoView; @override State createState() => _PublishedViewSettingsDialogState(); } class _PublishedViewSettingsDialogState extends State { final focusNode = FocusNode(); final controller = TextEditingController(); @override void initState() { super.initState(); controller.text = widget.publishInfoView.info.publishName; } @override void dispose() { focusNode.dispose(); controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocListener( listener: _onListener, child: KeyboardListener( focusNode: focusNode, autofocus: true, onKeyEvent: (event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { Navigator.of(context).pop(); } }, child: Container( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTitle(), const VSpace(20), _buildPublishNameLabel(), const VSpace(8), _buildPublishNameTextField(), const VSpace(20), _buildButtons(), ], ), ), ), ); } Widget _buildTitle() { return Row( children: [ Expanded( child: FlowyText( LocaleKeys.settings_sites_publishedPage_settings.tr(), fontSize: 16.0, figmaLineHeight: 22.0, fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, ), ), const HSpace(6.0), FlowyButton( margin: const EdgeInsets.all(3), useIntrinsicWidth: true, text: const FlowySvg( FlowySvgs.upgrade_close_s, size: Size.square(18.0), ), onTap: () => Navigator.of(context).pop(), ), ], ); } Widget _buildPublishNameLabel() { return FlowyText( LocaleKeys.settings_sites_publishedPage_pathName.tr(), fontSize: 14.0, color: Theme.of(context).hintColor, ); } Widget _buildPublishNameTextField() { return Row( children: [ Expanded( child: SizedBox( height: 36, child: FlowyTextField( autoFocus: false, controller: controller, enableBorderColor: ShareMenuColors.borderColor(context), textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( height: 1.4, ), ), ), ), const HSpace(12.0), OutlinedRoundedButton( text: LocaleKeys.button_save.tr(), radius: 8.0, margin: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 11.0, ), onTap: _savePublishName, ), ], ); } Widget _buildButtons() { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ OutlinedRoundedButton( text: LocaleKeys.shareAction_unPublish.tr(), onTap: _unpublishView, ), const HSpace(12.0), PrimaryRoundedButton( text: LocaleKeys.shareAction_visitSite.tr(), radius: 8.0, margin: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 9.0, ), onTap: _visitSite, ), ], ); } void _savePublishName() { context.read().add( SettingsSitesEvent.updatePublishName( widget.publishInfoView.info.viewId, controller.text, ), ); } void _unpublishView() { context.read().add( SettingsSitesEvent.unpublishView( widget.publishInfoView.info.viewId, ), ); Navigator.of(context).pop(); } void _visitSite() { SettingsPageSitesEvent.visitSite( widget.publishInfoView, nameSpace: context.read().state.namespace, ); } void _onListener(BuildContext context, SettingsSitesState state) { final actionResult = state.actionResult; final result = actionResult?.result; if (actionResult == null || result == null || actionResult.actionType != SettingsSitesActionType.updatePublishName) { return; } result.fold( (s) { showToastNotification( message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), ); Navigator.of(context).pop(); }, (f) { Log.error('update path name failed: $f'); showToastNotification( message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), type: ToastificationType.error, description: f.code.publishErrorMessage, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart' hide FieldInfo; part 'settings_sites_bloc.freezed.dart'; // workspaceId -> namespace Map _namespaceCache = {}; class SettingsSitesBloc extends Bloc { SettingsSitesBloc({ required this.workspaceId, required this.user, }) : super(const SettingsSitesState()) { on((event, emit) async { await event.when( initial: () async => _initial(emit), upgradeSubscription: () async => _upgradeSubscription(emit), unpublishView: (viewId) async => _unpublishView( viewId, emit, ), updateNamespace: (namespace) async => _updateNamespace( namespace, emit, ), updatePublishName: (viewId, name) async => _updatePublishName( viewId, name, emit, ), setHomePage: (viewId) async => _setHomePage( viewId, emit, ), removeHomePage: () async => _removeHomePage(emit), ); }); } final String workspaceId; final UserProfilePB user; Future _initial(Emitter emit) async { final lastNamespace = _namespaceCache[workspaceId] ?? ''; emit( state.copyWith( isLoading: true, namespace: lastNamespace, ), ); // Combine fetching subscription info and namespace final (subscriptionInfo, namespace) = await ( _fetchUserSubscription(), _fetchPublishNamespace(), ).wait; emit( state.copyWith( subscriptionInfo: subscriptionInfo, namespace: namespace, ), ); // This request is not blocking, render the namespace and subscription info first. final (publishViews, homePageId) = await ( _fetchPublishedViews(), _fetchHomePageView(), ).wait; final homePageView = publishViews.firstWhereOrNull( (view) => view.info.viewId == homePageId, ); emit( state.copyWith( publishedViews: publishViews, homePageView: homePageView, isLoading: false, ), ); } Future _fetchUserSubscription() async { final result = await UserBackendService.getWorkspaceSubscriptionInfo( workspaceId, ); return result.fold((s) => s, (f) { Log.error('Failed to fetch user subscription info: $f'); return null; }); } Future _fetchPublishNamespace() async { final result = await FolderEventGetPublishNamespace().send(); _namespaceCache[workspaceId] = result.fold((s) => s.namespace, (_) => null); return _namespaceCache[workspaceId] ?? ''; } Future> _fetchPublishedViews() async { final result = await FolderEventListPublishedViews().send(); return result.fold( // new -> old (s) => s.items.sorted( (a, b) => b.info.publishTimestampSec.toInt() - a.info.publishTimestampSec.toInt(), ), (_) => [], ); } Future _unpublishView( String viewId, Emitter emit, ) async { emit( state.copyWith( actionResult: const SettingsSitesActionResult( actionType: SettingsSitesActionType.unpublishView, isLoading: true, result: null, ), ), ); final request = UnpublishViewsPayloadPB(viewIds: [viewId]); final result = await FolderEventUnpublishViews(request).send(); final publishedViews = result.fold( (_) => state.publishedViews .where((view) => view.info.viewId != viewId) .toList(), (_) => state.publishedViews, ); final isHomepage = result.fold( (_) => state.homePageView?.info.viewId == viewId, (_) => false, ); emit( state.copyWith( publishedViews: publishedViews, actionResult: SettingsSitesActionResult( actionType: SettingsSitesActionType.unpublishView, isLoading: false, result: result, ), homePageView: isHomepage ? null : state.homePageView, ), ); } Future _updateNamespace( String namespace, Emitter emit, ) async { emit( state.copyWith( actionResult: const SettingsSitesActionResult( actionType: SettingsSitesActionType.updateNamespace, isLoading: true, result: null, ), ), ); final request = SetPublishNamespacePayloadPB()..newNamespace = namespace; final result = await FolderEventSetPublishNamespace(request).send(); emit( state.copyWith( namespace: result.fold((_) => namespace, (_) => state.namespace), actionResult: SettingsSitesActionResult( actionType: SettingsSitesActionType.updateNamespace, isLoading: false, result: result, ), ), ); } Future _updatePublishName( String viewId, String name, Emitter emit, ) async { emit( state.copyWith( actionResult: const SettingsSitesActionResult( actionType: SettingsSitesActionType.updatePublishName, isLoading: true, result: null, ), ), ); final request = SetPublishNamePB() ..viewId = viewId ..newName = name; final result = await FolderEventSetPublishName(request).send(); final publishedViews = result.fold( (_) => state.publishedViews.map((view) { view.freeze(); if (view.info.viewId == viewId) { view = view.rebuild((b) { final info = b.info; info.freeze(); b.info = info.rebuild((b) => b.publishName = name); }); } return view; }).toList(), (_) => state.publishedViews, ); emit( state.copyWith( publishedViews: publishedViews, actionResult: SettingsSitesActionResult( actionType: SettingsSitesActionType.updatePublishName, isLoading: false, result: result, ), ), ); } Future _upgradeSubscription(Emitter emit) async { final userService = UserBackendService(userId: user.id); final result = await userService.createSubscription( workspaceId, SubscriptionPlanPB.Pro, ); result.onSuccess((s) { afLaunchUrlString(s.paymentLink); }); emit( state.copyWith( actionResult: SettingsSitesActionResult( actionType: SettingsSitesActionType.upgradeSubscription, isLoading: false, result: result, ), ), ); } Future _setHomePage( String? viewId, Emitter emit, ) async { if (viewId == null) { return; } final viewIdPB = ViewIdPB()..value = viewId; final result = await FolderEventSetDefaultPublishView(viewIdPB).send(); final homePageView = state.publishedViews.firstWhereOrNull( (view) => view.info.viewId == viewId, ); emit( state.copyWith( homePageView: homePageView, actionResult: SettingsSitesActionResult( actionType: SettingsSitesActionType.setHomePage, isLoading: false, result: result, ), ), ); } Future _removeHomePage(Emitter emit) async { final result = await FolderEventRemoveDefaultPublishView().send(); emit( state.copyWith( homePageView: result.fold((_) => null, (_) => state.homePageView), actionResult: SettingsSitesActionResult( actionType: SettingsSitesActionType.removeHomePage, isLoading: false, result: result, ), ), ); } Future _fetchHomePageView() async { final result = await FolderEventGetDefaultPublishInfo().send(); return result.fold((s) => s.viewId, (_) => null); } } @freezed class SettingsSitesState with _$SettingsSitesState { const factory SettingsSitesState({ @Default([]) List publishedViews, SettingsSitesActionResult? actionResult, @Default('') String namespace, @Default(null) WorkspaceSubscriptionInfoPB? subscriptionInfo, @Default(true) bool isLoading, @Default(null) PublishInfoViewPB? homePageView, }) = _SettingsSitesState; factory SettingsSitesState.initial() => const SettingsSitesState(); } @freezed class SettingsSitesEvent with _$SettingsSitesEvent { const factory SettingsSitesEvent.initial() = _Initial; const factory SettingsSitesEvent.unpublishView(String viewId) = _UnpublishView; const factory SettingsSitesEvent.updateNamespace(String namespace) = _UpdateNamespace; const factory SettingsSitesEvent.updatePublishName( String viewId, String name, ) = _UpdatePublishName; const factory SettingsSitesEvent.upgradeSubscription() = _UpgradeSubscription; const factory SettingsSitesEvent.setHomePage(String? viewId) = _SetHomePage; const factory SettingsSitesEvent.removeHomePage() = _RemoveHomePage; } enum SettingsSitesActionType { none, unpublishView, updateNamespace, fetchPublishedViews, updatePublishName, fetchUserSubscription, upgradeSubscription, setHomePage, removeHomePage, } class SettingsSitesActionResult { const SettingsSitesActionResult({ required this.actionType, required this.isLoading, required this.result, }); factory SettingsSitesActionResult.none() => const SettingsSitesActionResult( actionType: SettingsSitesActionType.none, isLoading: false, result: null, ); final SettingsSitesActionType actionType; final FlowyResult? result; final bool isLoading; @override String toString() { return 'SettingsSitesActionResult(actionType: $actionType, isLoading: $isLoading, result: $result)'; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart ================================================ import 'package:appflowy/features/workspace/data/repositories/rust_workspace_repository_impl.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_header.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_item.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_item_header.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsSitesPage extends StatelessWidget { const SettingsSitesPage({ super.key, required this.workspaceId, required this.user, }); final String workspaceId; final UserProfilePB user; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (context) => SettingsSitesBloc( workspaceId: workspaceId, user: user, )..add(const SettingsSitesEvent.initial()), ), BlocProvider( create: (context) => UserWorkspaceBloc( userProfile: user, repository: RustWorkspaceRepositoryImpl( userId: user.id, ), )..add(UserWorkspaceEvent.initialize()), ), ], child: const _SettingsSitesPageView(), ); } } class _SettingsSitesPageView extends StatelessWidget { const _SettingsSitesPageView(); @override Widget build(BuildContext context) { return SettingsBody( title: LocaleKeys.settings_sites_title.tr(), autoSeparate: false, children: [ // Domain / Namespace _buildNamespaceCategory(context), const VSpace(36), // All published pages _buildPublishedViewsCategory(context), ], ); } Widget _buildNamespaceCategory(BuildContext context) { return SettingsCategory( title: LocaleKeys.settings_sites_namespaceHeader.tr(), description: LocaleKeys.settings_sites_namespaceDescription.tr(), descriptionColor: Theme.of(context).hintColor, children: [ const FlowyDivider(), BlocConsumer( listener: _onListener, builder: (context, state) { return SeparatedColumn( crossAxisAlignment: CrossAxisAlignment.start, separatorBuilder: () => const FlowyDivider( padding: EdgeInsets.symmetric(vertical: 12.0), ), children: [ const DomainHeader(), ConstrainedBox( constraints: const BoxConstraints(minHeight: 36.0), child: Transform.translate( offset: const Offset( -SettingsPageSitesConstants.alignPadding, 0, ), child: DomainItem( namespace: state.namespace, homepage: '', ), ), ), ], ); }, ), ], ); } Widget _buildPublishedViewsCategory(BuildContext context) { return SettingsCategory( title: LocaleKeys.settings_sites_publishedPage_title.tr(), description: LocaleKeys.settings_sites_publishedPage_description.tr(), descriptionColor: Theme.of(context).hintColor, children: [ const FlowyDivider(), BlocBuilder( builder: (context, state) { return SeparatedColumn( crossAxisAlignment: CrossAxisAlignment.start, separatorBuilder: () => const FlowyDivider( padding: EdgeInsets.symmetric(vertical: 12.0), ), children: _buildPublishedViewsResult(context, state), ); }, ), ], ); } List _buildPublishedViewsResult( BuildContext context, SettingsSitesState state, ) { final publishedViews = state.publishedViews; final List children = [ const PublishViewItemHeader(), ]; if (!state.isLoading) { if (publishedViews.isEmpty) { children.add( FlowyText.regular( LocaleKeys.settings_sites_publishedPage_emptyHinText.tr(), color: Theme.of(context).hintColor, ), ); } else { children.addAll( publishedViews.map( (view) => Transform.translate( offset: const Offset( -SettingsPageSitesConstants.alignPadding, 0, ), child: PublishedViewItem(publishInfoView: view), ), ), ); } } else { children.add( const Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator.adaptive(strokeWidth: 3), ), ), ); } return children; } void _onListener(BuildContext context, SettingsSitesState state) { final actionResult = state.actionResult; final type = actionResult?.actionType; final result = actionResult?.result; if (type == SettingsSitesActionType.upgradeSubscription && result != null) { result.onFailure((f) { Log.error('Failed to generate payment link for Pro Plan: ${f.msg}'); showToastNotification( message: LocaleKeys.settings_sites_error_failedToGeneratePaymentLink.tr(), type: ToastificationType.error, ); }); } else if (type == SettingsSitesActionType.unpublishView && result != null) { result.fold((_) { showToastNotification( message: LocaleKeys.publish_unpublishSuccessfully.tr(), ); }, (f) { Log.error('Failed to unpublish view: ${f.msg}'); showToastNotification( message: LocaleKeys.publish_unpublishFailed.tr(), type: ToastificationType.error, description: f.msg, ); }); } else if (type == SettingsSitesActionType.setHomePage && result != null) { result.fold((s) { showToastNotification( message: LocaleKeys.settings_sites_success_setHomepageSuccess.tr(), ); }, (f) { Log.error('Failed to set homepage: ${f.msg}'); showToastNotification( message: LocaleKeys.settings_sites_error_setHomepageFailed.tr(), type: ToastificationType.error, ); }); } else if (type == SettingsSitesActionType.removeHomePage && result != null) { result.fold((s) { showToastNotification( message: LocaleKeys.settings_sites_success_removeHomePageSuccess.tr(), ); }, (f) { Log.error('Failed to remove homepage: ${f.msg}'); showToastNotification( message: LocaleKeys.settings_sites_error_removeHomePageFailed.tr(), type: ToastificationType.error, ); }); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/shared/appflowy_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/share_log_files.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_billing_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_manage_data_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_shortcuts_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_view.dart'; import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_notifications_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/web_url_hint_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'pages/setting_ai_view/local_settings_ai_view.dart'; import 'widgets/setting_cloud.dart'; @visibleForTesting const kSelfHostedTextInputFieldKey = ValueKey('self_hosted_url_input_text_field'); @visibleForTesting const kSelfHostedWebTextInputFieldKey = ValueKey('self_hosted_web_url_input_text_field'); class SettingsDialog extends StatelessWidget { SettingsDialog( this.user, { required this.dismissDialog, required this.didLogout, required this.restartApp, this.initPage, }) : super(key: ValueKey(user.id)); final UserProfilePB user; final SettingsPage? initPage; final VoidCallback dismissDialog; final VoidCallback didLogout; final VoidCallback restartApp; @override Widget build(BuildContext context) { final width = MediaQuery.of(context).size.width * 0.6; final theme = AppFlowyTheme.of(context); final currentWorkspaceMemberRole = context.read().state.currentWorkspace?.role; return BlocProvider( create: (context) => SettingsDialogBloc( user, currentWorkspaceMemberRole, initPage: initPage, )..add(const SettingsDialogEvent.initial()), child: BlocBuilder( builder: (context, state) => FlowyDialog( width: width, constraints: const BoxConstraints(minWidth: 564), child: ScaffoldMessenger( child: Scaffold( backgroundColor: theme.backgroundColorScheme.primary, body: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 204, child: SettingsMenu( userProfile: user, changeSelectedPage: (index) => context .read() .add(SettingsDialogEvent.setSelectedPage(index)), currentPage: context.read().state.page, currentUserRole: currentWorkspaceMemberRole, isBillingEnabled: state.isBillingEnabled, ), ), AFDivider( axis: Axis.vertical, color: theme.borderColorScheme.primary, ), BlocBuilder( builder: (context, state) { return Expanded( child: getSettingsView( state.currentWorkspace!, context.read().state.page, state.userProfile, state.currentWorkspace?.role, ), ); }, ), ], ), ), ), ), ), ); } Widget getSettingsView( UserWorkspacePB workspace, SettingsPage page, UserProfilePB user, AFRolePB? currentWorkspaceMemberRole, ) { switch (page) { case SettingsPage.account: return SettingsAccountView( userProfile: user, didLogout: didLogout, didLogin: dismissDialog, ); case SettingsPage.workspace: return SettingsWorkspaceView( userProfile: user, currentWorkspaceMemberRole: currentWorkspaceMemberRole, ); case SettingsPage.manageData: return SettingsManageDataView( userProfile: user, workspace: workspace, ); case SettingsPage.notifications: return const SettingsNotificationsView(); case SettingsPage.cloud: return SettingCloud(restartAppFlowy: () => restartApp()); case SettingsPage.shortcuts: return const SettingsShortcutsView(); case SettingsPage.ai: if (user.workspaceType == WorkspaceTypePB.ServerW) { return SettingsAIView( key: ValueKey(workspace.workspaceId), userProfile: user, currentWorkspaceMemberRole: currentWorkspaceMemberRole, workspaceId: workspace.workspaceId, ); } else { return LocalSettingsAIView( key: ValueKey(workspace.workspaceId), userProfile: user, workspaceId: workspace.workspaceId, ); } case SettingsPage.member: return WorkspaceMembersPage( userProfile: user, workspaceId: workspace.workspaceId, ); case SettingsPage.plan: return SettingsPlanView( workspaceId: workspace.workspaceId, user: user, ); case SettingsPage.billing: return SettingsBillingView( workspaceId: workspace.workspaceId, user: user, ); case SettingsPage.sites: return SettingsSitesPage( workspaceId: workspace.workspaceId, user: user, ); case SettingsPage.featureFlags: return const FeatureFlagsPage(); } } } class SimpleSettingsDialog extends StatefulWidget { const SimpleSettingsDialog({super.key}); @override State createState() => _SimpleSettingsDialogState(); } class _SimpleSettingsDialogState extends State { SettingsPage page = SettingsPage.cloud; @override Widget build(BuildContext context) { final settings = context.watch().state; return FlowyDialog( width: MediaQuery.of(context).size.width * 0.7, constraints: const BoxConstraints(maxWidth: 784, minWidth: 564), child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // header FlowyText( LocaleKeys.signIn_settings.tr(), fontSize: 36.0, fontWeight: FontWeight.w600, ), const VSpace(18.0), // language _LanguageSettings(key: ValueKey('language${settings.hashCode}')), const VSpace(22.0), // self-host cloud _SelfHostSettings(key: ValueKey('selfhost${settings.hashCode}')), const VSpace(22.0), // support _SupportSettings(key: ValueKey('support${settings.hashCode}')), ], ), ), ), ); } } class _LanguageSettings extends StatelessWidget { const _LanguageSettings({ super.key, }); @override Widget build(BuildContext context) { return SettingsCategory( title: LocaleKeys.settings_workspacePage_language_title.tr(), children: const [LanguageDropdown()], ); } } class _SelfHostSettings extends StatefulWidget { const _SelfHostSettings({ super.key, }); @override State<_SelfHostSettings> createState() => _SelfHostSettingsState(); } class _SelfHostSettingsState extends State<_SelfHostSettings> { final cloudUrlTextController = TextEditingController(); final webUrlTextController = TextEditingController(); AuthenticatorType type = AuthenticatorType.appflowyCloud; @override void initState() { super.initState(); _fetchUrls(); } @override void dispose() { cloudUrlTextController.dispose(); webUrlTextController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SettingsCategory( title: LocaleKeys.settings_menu_cloudAppFlowy.tr(), children: [ Flexible( child: SettingsServerDropdownMenu( selectedServer: type, onSelected: _onSelected, ), ), if (type == AuthenticatorType.appflowyCloudSelfHost) _buildInputField(), ], ); } Widget _buildInputField() { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _SelfHostUrlField( textFieldKey: kSelfHostedTextInputFieldKey, textController: cloudUrlTextController, title: LocaleKeys.settings_menu_cloudURL.tr(), hintText: LocaleKeys.settings_menu_cloudURLHint.tr(), onSave: (url) => _saveUrl( cloudUrl: url, webUrl: webUrlTextController.text, type: AuthenticatorType.appflowyCloudSelfHost, ), ), const VSpace(12.0), _SelfHostUrlField( textFieldKey: kSelfHostedWebTextInputFieldKey, textController: webUrlTextController, title: LocaleKeys.settings_menu_webURL.tr(), hintText: LocaleKeys.settings_menu_webURLHint.tr(), hintBuilder: (context) => const WebUrlHintWidget(), onSave: (url) => _saveUrl( cloudUrl: cloudUrlTextController.text, webUrl: url, type: AuthenticatorType.appflowyCloudSelfHost, ), ), const VSpace(12.0), _buildSaveButton(), ], ); } Widget _buildSaveButton() { return Container( height: 36, constraints: const BoxConstraints(minWidth: 78), child: OutlinedRoundedButton( text: LocaleKeys.button_save.tr(), onTap: () => _saveUrl( cloudUrl: cloudUrlTextController.text, webUrl: webUrlTextController.text, type: AuthenticatorType.appflowyCloudSelfHost, ), ), ); } void _onSelected(AuthenticatorType type) { if (type == this.type) { return; } Log.info('Switching server type to $type'); setState(() { this.type = type; }); if (type == AuthenticatorType.appflowyCloud) { cloudUrlTextController.text = kAppflowyCloudUrl; webUrlTextController.text = ShareConstants.defaultBaseWebDomain; _saveUrl( cloudUrl: kAppflowyCloudUrl, webUrl: ShareConstants.defaultBaseWebDomain, type: type, ); } } Future _saveUrl({ required String cloudUrl, required String webUrl, required AuthenticatorType type, }) async { if (cloudUrl.isEmpty || webUrl.isEmpty) { showToastNotification( message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), type: ToastificationType.error, ); return; } final isValid = await _validateUrl(cloudUrl) && await _validateUrl(webUrl); if (mounted) { if (isValid) { showToastNotification( message: LocaleKeys.settings_menu_changeUrl.tr(args: [cloudUrl]), ); Navigator.of(context).pop(); await useBaseWebDomain(webUrl); await useAppFlowyBetaCloudWithURL(cloudUrl, type); await runAppFlowy(); } else { showToastNotification( message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), type: ToastificationType.error, ); } } } Future _validateUrl(String url) async { return await validateUrl(url).fold( (url) async { return true; }, (err) { Log.error(err); return false; }, ); } Future _fetchUrls() async { await Future.wait([ getAppFlowyCloudUrl(), getAppFlowyShareDomain(), ]).then((values) { if (values.length != 2) { return; } cloudUrlTextController.text = values[0]; webUrlTextController.text = values[1]; if (kAppflowyCloudUrl != values[0]) { setState(() { type = AuthenticatorType.appflowyCloudSelfHost; }); } }); } } @visibleForTesting extension SettingsServerDropdownMenuExtension on AuthenticatorType { String get label { switch (this) { case AuthenticatorType.appflowyCloud: return LocaleKeys.settings_menu_cloudAppFlowy.tr(); case AuthenticatorType.appflowyCloudSelfHost: return LocaleKeys.settings_menu_cloudAppFlowySelfHost.tr(); default: throw Exception('Unsupported server type: $this'); } } } @visibleForTesting class SettingsServerDropdownMenu extends StatelessWidget { const SettingsServerDropdownMenu({ super.key, required this.selectedServer, required this.onSelected, }); final AuthenticatorType selectedServer; final void Function(AuthenticatorType type) onSelected; // in the settings page from sign in page, we only support appflowy cloud and self-hosted static final supportedServers = [ AuthenticatorType.appflowyCloud, AuthenticatorType.appflowyCloudSelfHost, ]; @override Widget build(BuildContext context) { return SettingsDropdown( expandWidth: false, onChanged: onSelected, selectedOption: selectedServer, options: supportedServers .map( (serverType) => buildDropdownMenuEntry( context, selectedValue: selectedServer, value: serverType, label: serverType.label, ), ) .toList(), ); } } class _SupportSettings extends StatelessWidget { const _SupportSettings({ super.key, }); @override Widget build(BuildContext context) { return SettingsCategory( title: LocaleKeys.settings_mobile_support.tr(), children: [ // export logs Row( children: [ FlowyText( LocaleKeys.workspace_errorActions_exportLogFiles.tr(), ), const Spacer(), ConstrainedBox( constraints: const BoxConstraints(minWidth: 78), child: OutlinedRoundedButton( text: LocaleKeys.settings_files_export.tr(), onTap: () { shareLogFiles(context); }, ), ), ], ), // clear cache Row( children: [ FlowyText( LocaleKeys.settings_files_clearCache.tr(), ), const Spacer(), ConstrainedBox( constraints: const BoxConstraints(minWidth: 78), child: OutlinedRoundedButton( text: LocaleKeys.button_clear.tr(), onTap: () async { await getIt().clearAllCache(); if (context.mounted) { showToastNotification( message: LocaleKeys .settings_manageDataPage_cache_dialog_successHint .tr(), ); } }, ), ), ], ), ], ); } } class _SelfHostUrlField extends StatelessWidget { const _SelfHostUrlField({ required this.textController, required this.title, required this.hintText, required this.onSave, this.textFieldKey, this.hintBuilder, }); final TextEditingController textController; final String title; final String hintText; final ValueChanged onSave; final Key? textFieldKey; final WidgetBuilder? hintBuilder; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildHintWidget(context), const VSpace(6.0), SizedBox( height: 36, child: FlowyTextField( key: textFieldKey, controller: textController, autoFocus: false, textStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w400, ), hintText: hintText, onEditingComplete: () => onSave(textController.text), ), ), ], ); } Widget _buildHintWidget(BuildContext context) { return Row( children: [ FlowyText( title, overflow: TextOverflow.ellipsis, ), hintBuilder?.call(context) ?? const SizedBox.shrink(), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; DropdownMenuEntry buildDropdownMenuEntry( BuildContext context, { required T value, required String label, String subLabel = '', T? selectedValue, Widget? leadingWidget, Widget? trailingWidget, String? fontFamily, double maximumHeight = 29, }) { final fontFamilyUsed = fontFamily != null ? getGoogleFontSafely(fontFamily).fontFamily ?? defaultFontFamily : defaultFontFamily; Widget? labelWidget; if (subLabel.isNotEmpty) { labelWidget = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.regular( label, fontSize: 14, ), const VSpace(4), FlowyText.regular( subLabel, fontSize: 10, ), ], ); } else { labelWidget = FlowyText.regular( label, fontSize: 14, textAlign: TextAlign.start, fontFamily: fontFamilyUsed, ); } return DropdownMenuEntry( style: ButtonStyle( foregroundColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.primary), padding: WidgetStateProperty.all( const EdgeInsets.symmetric(horizontal: 6, vertical: 4), ), minimumSize: const WidgetStatePropertyAll(Size(double.infinity, 29)), maximumSize: WidgetStatePropertyAll(Size(double.infinity, maximumHeight)), ), value: value, label: label, leadingIcon: leadingWidget, labelWidget: labelWidget, trailingIcon: Row( children: [ if (trailingWidget != null) ...[ trailingWidget, const HSpace(8), ], value == selectedValue ? const FlowySvg(FlowySvgs.check_s) : const SizedBox.shrink(), ], ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/document_color_setting_button.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; class DocumentColorSettingButton extends StatefulWidget { const DocumentColorSettingButton({ super.key, required this.currentColor, required this.previewWidgetBuilder, required this.dialogTitle, required this.onApply, }); /// current color from backend final Color currentColor; /// Build a preview widget with the given color /// It shows both on the [DocumentColorSettingButton] and [_DocumentColorSettingDialog] final Widget Function(Color? color) previewWidgetBuilder; final String dialogTitle; final void Function(Color selectedColorOnDialog) onApply; @override State createState() => _DocumentColorSettingButtonState(); } class _DocumentColorSettingButtonState extends State { late Color newColor = widget.currentColor; @override Widget build(BuildContext context) { return FlowyButton( margin: const EdgeInsets.all(8), text: widget.previewWidgetBuilder.call(widget.currentColor), hoverColor: Theme.of(context).colorScheme.secondaryContainer, expandText: false, onTap: () => SettingsAlertDialog( title: widget.dialogTitle, confirm: () { widget.onApply(newColor); Navigator.of(context).pop(); }, children: [ _DocumentColorSettingDialog( formKey: GlobalKey(), currentColor: widget.currentColor, previewWidgetBuilder: widget.previewWidgetBuilder, onChanged: (color) => newColor = color, ), ], ).show(context), ); } } class _DocumentColorSettingDialog extends StatefulWidget { const _DocumentColorSettingDialog({ required this.formKey, required this.currentColor, required this.previewWidgetBuilder, required this.onChanged, }); final GlobalKey formKey; final Color currentColor; final Widget Function(Color?) previewWidgetBuilder; final void Function(Color selectedColor) onChanged; @override State<_DocumentColorSettingDialog> createState() => DocumentColorSettingDialogState(); } class DocumentColorSettingDialogState extends State<_DocumentColorSettingDialog> { /// The color displayed in the dialog. /// It is `null` when the user didn't enter a valid color value. late Color? selectedColorOnDialog; late String currentColorHexString; late TextEditingController hexController; late TextEditingController opacityController; @override void initState() { super.initState(); selectedColorOnDialog = widget.currentColor; currentColorHexString = ColorExtension(widget.currentColor).toHexString(); hexController = TextEditingController( text: currentColorHexString.extractHex(), ); opacityController = TextEditingController( text: currentColorHexString.extractOpacity(), ); } @override void dispose() { hexController.dispose(); opacityController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ SizedBox( width: 100, height: 40, child: Center( child: widget.previewWidgetBuilder( selectedColorOnDialog, ), ), ), const VSpace(8), Form( key: widget.formKey, child: Column( children: [ _ColorSettingTextField( controller: hexController, labelText: LocaleKeys.editor_hexValue.tr(), hintText: '6fc9e7', onChanged: (_) => _updateSelectedColor(), onFieldSubmitted: (_) => _updateSelectedColor(), validator: (v) => validateHexValue(v, opacityController.text), suffixIcon: Padding( padding: const EdgeInsets.all(6.0), child: FlowyIconButton( onPressed: () => _showColorPickerDialog( context: context, currentColor: widget.currentColor, updateColor: _updateColor, ), icon: const FlowySvg( FlowySvgs.m_aa_color_s, size: Size.square(20), ), ), ), ), const VSpace(8), _ColorSettingTextField( controller: opacityController, labelText: LocaleKeys.editor_opacity.tr(), hintText: '50', onChanged: (_) => _updateSelectedColor(), onFieldSubmitted: (_) => _updateSelectedColor(), validator: (value) => validateOpacityValue(value), ), ], ), ), ], ); } void _updateSelectedColor() { if (widget.formKey.currentState!.validate()) { setState(() { final colorValue = int.tryParse( hexController.text.combineHexWithOpacity(opacityController.text), ); // colorValue has been validated in the _ColorSettingTextField for hex value and it won't be null as this point selectedColorOnDialog = Color(colorValue!); widget.onChanged(selectedColorOnDialog!); }); } } void _updateColor(Color color) { setState(() { hexController.text = ColorExtension(color).toHexString().extractHex(); opacityController.text = ColorExtension(color).toHexString().extractOpacity(); }); _updateSelectedColor(); } } class _ColorSettingTextField extends StatelessWidget { const _ColorSettingTextField({ required this.controller, required this.labelText, required this.hintText, required this.onFieldSubmitted, this.suffixIcon, this.onChanged, this.validator, }); final TextEditingController controller; final String labelText; final String hintText; final void Function(String) onFieldSubmitted; final Widget? suffixIcon; final void Function(String)? onChanged; final String? Function(String?)? validator; @override Widget build(BuildContext context) { final style = Theme.of(context); return TextFormField( controller: controller, decoration: InputDecoration( labelText: labelText, hintText: hintText, suffixIcon: suffixIcon, border: OutlineInputBorder( borderSide: BorderSide(color: style.colorScheme.outline), ), enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: style.colorScheme.outline), ), ), style: style.textTheme.bodyMedium, onChanged: onChanged, onFieldSubmitted: onFieldSubmitted, validator: validator, autovalidateMode: AutovalidateMode.onUserInteraction, ); } } String? validateHexValue(String? hexValue, String opacityValue) { if (hexValue == null || hexValue.isEmpty) { return LocaleKeys.settings_appearance_documentSettings_hexEmptyError.tr(); } if (hexValue.length != 6) { return LocaleKeys.settings_appearance_documentSettings_hexLengthError.tr(); } if (validateOpacityValue(opacityValue) == null) { final colorValue = int.tryParse(hexValue.combineHexWithOpacity(opacityValue)); if (colorValue == null) { return LocaleKeys.settings_appearance_documentSettings_hexInvalidError .tr(); } } return null; } String? validateOpacityValue(String? value) { if (value == null || value.isEmpty) { return LocaleKeys.settings_appearance_documentSettings_opacityEmptyError .tr(); } final opacityInt = int.tryParse(value); if (opacityInt == null || opacityInt > 100 || opacityInt <= 0) { return LocaleKeys.settings_appearance_documentSettings_opacityRangeError .tr(); } return null; } const _kColorCircleWidth = 32.0; const _kColorCircleHeight = 32.0; const _kColorCircleRadius = 20.0; const _kColorOpacityThumbRadius = 23.0; const _kDialogButtonPaddingHorizontal = 24.0; const _kDialogButtonPaddingVertical = 12.0; const _kColorsColumnSpacing = 12.0; class _ColorPicker extends StatelessWidget { const _ColorPicker({ required this.selectedColor, required this.onColorChanged, }); final Color selectedColor; final void Function(Color) onColorChanged; @override Widget build(BuildContext context) { final theme = Theme.of(context); return ColorPicker( width: _kColorCircleWidth, height: _kColorCircleHeight, borderRadius: _kColorCircleRadius, enableOpacity: true, opacityThumbRadius: _kColorOpacityThumbRadius, columnSpacing: _kColorsColumnSpacing, enableTooltips: false, hasBorder: true, borderColor: theme.colorScheme.outline, pickersEnabled: const { ColorPickerType.both: false, ColorPickerType.primary: true, ColorPickerType.accent: true, ColorPickerType.wheel: true, }, subheading: Text( LocaleKeys.settings_appearance_documentSettings_colorShade.tr(), style: theme.textTheme.labelLarge, ), opacitySubheading: Text( LocaleKeys.settings_appearance_documentSettings_opacity.tr(), style: theme.textTheme.labelLarge, ), onColorChanged: onColorChanged, ); } } class _ColorPickerActions extends StatelessWidget { const _ColorPickerActions({ required this.onReset, required this.onUpdate, }); final VoidCallback onReset; final VoidCallback onUpdate; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( height: 24, child: FlowyTextButton( LocaleKeys.button_cancel.tr(), padding: const EdgeInsets.symmetric( horizontal: _kDialogButtonPaddingHorizontal, vertical: _kDialogButtonPaddingVertical, ), fontColor: AFThemeExtension.of(context).textColor, fillColor: Colors.transparent, hoverColor: Colors.transparent, radius: Corners.s12Border, onPressed: onReset, ), ), const HSpace(8), SizedBox( height: 48, child: FlowyTextButton( LocaleKeys.button_done.tr(), padding: const EdgeInsets.symmetric( horizontal: _kDialogButtonPaddingHorizontal, vertical: _kDialogButtonPaddingVertical, ), radius: Corners.s12Border, fontHoverColor: Colors.white, fillColor: Theme.of(context).colorScheme.primary, hoverColor: const Color(0xFF005483), onPressed: onUpdate, ), ), ], ); } } void _showColorPickerDialog({ required BuildContext context, String? title, required Color currentColor, required void Function(Color) updateColor, }) { Color selectedColor = currentColor; showDialog( context: context, barrierColor: const Color.fromARGB(128, 0, 0, 0), builder: (context) => FlowyDialog( expandHeight: false, title: Row( children: [ const FlowySvg(FlowySvgs.m_aa_color_s), const HSpace(12), FlowyText( title ?? LocaleKeys.settings_appearance_documentSettings_pickColor.tr(), fontSize: 20, ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _ColorPicker( selectedColor: selectedColor, onColorChanged: (color) => selectedColor = color, ), Padding( padding: const EdgeInsets.all(16), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ const HSpace(8), _ColorPickerActions( onReset: () { updateColor(currentColor); Navigator.of(context).pop(); }, onUpdate: () { updateColor(selectedColor); Navigator.of(context).pop(); }, ), ], ), ), ], ), ), ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart ================================================ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class FlowyGradientButton extends StatefulWidget { const FlowyGradientButton({ super.key, required this.label, this.onPressed, this.fontWeight = FontWeight.w600, this.textColor = Colors.white, this.backgroundColor, }); final String label; final VoidCallback? onPressed; final FontWeight fontWeight; /// Used to provide a custom foreground color for the button, used in cases /// where a custom [backgroundColor] is provided and the default text color /// does not have enough contrast. /// final Color textColor; /// Used to provide a custom background color for the button, this will /// override the gradient behavior, and is mostly used in rare cases /// where the gradient doesn't have contrast with the background. /// final Color? backgroundColor; @override State createState() => _FlowyGradientButtonState(); } class _FlowyGradientButtonState extends State { bool isHovering = false; @override Widget build(BuildContext context) { return Listener( onPointerDown: (_) => widget.onPressed?.call(), child: MouseRegion( onEnter: (_) => setState(() => isHovering = true), onExit: (_) => setState(() => isHovering = false), cursor: SystemMouseCursors.click, child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( boxShadow: [ BoxShadow( blurRadius: 4, color: Colors.black.withValues(alpha: 0.25), offset: const Offset(0, 2), ), ], borderRadius: BorderRadius.circular(16), color: widget.backgroundColor, gradient: widget.backgroundColor != null ? null : LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ isHovering ? const Color.fromARGB(255, 57, 40, 92) : const Color(0xFF44326B), isHovering ? const Color.fromARGB(255, 96, 53, 164) : const Color(0xFF7547C0), ], ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: FlowyText( widget.label, fontSize: 16, fontWeight: widget.fontWeight, color: widget.textColor, maxLines: 2, textAlign: TextAlign.center, ), ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_action.dart ================================================ import 'package:flutter/material.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; class SettingAction extends StatelessWidget { const SettingAction({ super.key, required this.onPressed, required this.icon, this.label, this.tooltip, }); final VoidCallback onPressed; final Widget icon; final String? label; final String? tooltip; @override Widget build(BuildContext context) { final child = GestureDetector( behavior: HitTestBehavior.opaque, onTap: onPressed, child: SizedBox( height: 26, child: FlowyHover( resetHoverOnRebuild: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( children: [ icon, if (label != null) ...[ const HSpace(4), FlowyText.regular(label!), ], ], ), ), ), ), ); if (tooltip != null) { return FlowyTooltip( message: tooltip!, child: child, ); } return child; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SettingListTile extends StatelessWidget { const SettingListTile({ super.key, this.resetTooltipText, this.resetButtonKey, required this.label, this.hint, this.trailing, this.subtitle, this.onResetRequested, }); final String label; final String? hint; final String? resetTooltipText; final Key? resetButtonKey; final List? trailing; final List? subtitle; final VoidCallback? onResetRequested; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.medium( label, fontSize: 14, overflow: TextOverflow.ellipsis, ), if (hint != null) Padding( padding: const EdgeInsets.only(bottom: 4), child: FlowyText.regular( hint!, fontSize: 10, color: Theme.of(context).hintColor, ), ), if (subtitle != null) ...subtitle!, ], ), ), if (trailing != null) ...trailing!, if (onResetRequested != null) SettingsResetButton( key: resetButtonKey, resetTooltipText: resetTooltipText, onResetRequested: onResetRequested, ), ], ); } } class SettingsResetButton extends StatelessWidget { const SettingsResetButton({ super.key, this.resetTooltipText, this.onResetRequested, }); final String? resetTooltipText; final VoidCallback? onResetRequested; @override Widget build(BuildContext context) { return FlowyIconButton( hoverColor: Theme.of(context).colorScheme.secondaryContainer, width: 24, icon: FlowySvg( FlowySvgs.restore_s, color: Theme.of(context).iconTheme.color, size: const Size.square(20), ), iconColorOnHover: Theme.of(context).colorScheme.onPrimary, tooltipText: resetTooltipText ?? LocaleKeys.settings_appearance_resetSetting.tr(), onPressed: onResetRequested, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart ================================================ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SettingValueDropDown extends StatefulWidget { const SettingValueDropDown({ super.key, required this.currentValue, required this.popupBuilder, this.popoverKey, this.onClose, this.child, this.popoverController, this.offset, this.boxConstraints, this.margin = const EdgeInsets.all(6), }); final String currentValue; final Key? popoverKey; final Widget Function(BuildContext) popupBuilder; final void Function()? onClose; final Widget? child; final PopoverController? popoverController; final Offset? offset; final BoxConstraints? boxConstraints; final EdgeInsets margin; @override State createState() => _SettingValueDropDownState(); } class _SettingValueDropDownState extends State { @override Widget build(BuildContext context) { return AppFlowyPopover( key: widget.popoverKey, controller: widget.popoverController, direction: PopoverDirection.bottomWithCenterAligned, margin: widget.margin, popupBuilder: widget.popupBuilder, constraints: widget.boxConstraints ?? const BoxConstraints( minWidth: 80, maxWidth: 160, maxHeight: 400, ), offset: widget.offset, onClose: widget.onClose, child: widget.child ?? FlowyTextButton( widget.currentValue, fontColor: AFThemeExtension.maybeOf(context)?.onBackground, fillColor: Colors.transparent, onPressed: () {}, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_actionable_input.dart ================================================ import 'package:flutter/material.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; class SettingsActionableInput extends StatelessWidget { const SettingsActionableInput({ super.key, required this.controller, this.focusNode, this.placeholder, this.onSave, this.actions = const [], }); final TextEditingController controller; final FocusNode? focusNode; final String? placeholder; final Function(String)? onSave; final List actions; @override Widget build(BuildContext context) { return Row( children: [ Flexible( child: SizedBox( height: 48, child: FlowyTextField( controller: controller, focusNode: focusNode, hintText: placeholder, autoFocus: false, isDense: false, suffixIconConstraints: BoxConstraints.tight(const Size(23 + 18, 24)), textStyle: const TextStyle( fontWeight: FontWeight.w500, fontSize: 14, ), onSubmitted: onSave, ), ), ), if (actions.isNotEmpty) ...[ const HSpace(8), SeparatedRow( separatorBuilder: () => const HSpace(16), children: actions, ), ], ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_alert_dialog.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; import 'package:flutter/material.dart'; class SettingsAlertDialog extends StatefulWidget { const SettingsAlertDialog({ super.key, this.icon, required this.title, this.subtitle, this.children, this.cancel, this.confirm, this.confirmLabel, this.hideCancelButton = false, this.isDangerous = false, this.implyLeading = false, this.enableConfirmNotifier, }); final Widget? icon; final String title; final String? subtitle; final List? children; final void Function()? cancel; final void Function()? confirm; final String? confirmLabel; final bool hideCancelButton; final bool isDangerous; final ValueNotifier? enableConfirmNotifier; /// If true, a back button will show in the top left corner final bool implyLeading; @override State createState() => _SettingsAlertDialogState(); } class _SettingsAlertDialogState extends State { bool enableConfirm = true; @override void initState() { super.initState(); if (widget.enableConfirmNotifier != null) { widget.enableConfirmNotifier!.addListener(_updateEnableConfirm); enableConfirm = widget.enableConfirmNotifier!.value; } } void _updateEnableConfirm() { setState(() => enableConfirm = widget.enableConfirmNotifier!.value); } @override void dispose() { if (widget.enableConfirmNotifier != null) { widget.enableConfirmNotifier!.removeListener(_updateEnableConfirm); } super.dispose(); } @override void didUpdateWidget(covariant SettingsAlertDialog oldWidget) { oldWidget.enableConfirmNotifier?.removeListener(_updateEnableConfirm); widget.enableConfirmNotifier?.addListener(_updateEnableConfirm); enableConfirm = widget.enableConfirmNotifier?.value ?? true; super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { return StyledDialog( maxHeight: 600, maxWidth: 600, padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.end, children: [ if (widget.implyLeading) ...[ GestureDetector( onTap: Navigator.of(context).pop, child: MouseRegion( cursor: SystemMouseCursors.click, child: Row( children: [ const FlowySvg( FlowySvgs.arrow_back_m, size: Size.square(24), ), const HSpace(8), FlowyText.semibold( LocaleKeys.button_back.tr(), fontSize: 16, ), ], ), ), ), ], const Spacer(), GestureDetector( onTap: Navigator.of(context).pop, child: MouseRegion( cursor: SystemMouseCursors.click, child: FlowySvg( FlowySvgs.m_close_m, size: const Size.square(20), color: Theme.of(context).colorScheme.outline, ), ), ), ], ), if (widget.icon != null) ...[ widget.icon!, const VSpace(16), ], Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: FlowyText.medium( widget.title, fontSize: 22, color: Theme.of(context).colorScheme.tertiary, maxLines: null, ), ), ], ), if (widget.subtitle?.isNotEmpty ?? false) ...[ const VSpace(16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: FlowyText.regular( widget.subtitle!, fontSize: 16, color: Theme.of(context).colorScheme.tertiary, textAlign: TextAlign.center, maxLines: null, ), ), ], ), ], if (widget.children?.isNotEmpty ?? false) ...[ const VSpace(16), ...widget.children!, ], if (widget.confirm != null || !widget.hideCancelButton) ...[ const VSpace(20), ], _Actions( hideCancelButton: widget.hideCancelButton, confirmLabel: widget.confirmLabel, cancel: widget.cancel, confirm: widget.confirm, isDangerous: widget.isDangerous, enableConfirm: enableConfirm, ), ], ), ); } } class _Actions extends StatelessWidget { const _Actions({ required this.hideCancelButton, this.confirmLabel, this.cancel, this.confirm, this.isDangerous = false, this.enableConfirm = true, }); final bool hideCancelButton; final String? confirmLabel; final VoidCallback? cancel; final VoidCallback? confirm; final bool isDangerous; final bool enableConfirm; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (!hideCancelButton) ...[ SizedBox( height: 48, child: PrimaryRoundedButton( text: LocaleKeys.button_cancel.tr(), margin: const EdgeInsets.symmetric( horizontal: 24, vertical: 12, ), fontWeight: FontWeight.w600, radius: 12.0, onTap: () { cancel?.call(); Navigator.of(context).pop(); }, ), ), ], if (confirm != null && !hideCancelButton) ...[ const HSpace(8), ], if (confirm != null) ...[ SizedBox( height: 48, child: FlowyTextButton( confirmLabel ?? LocaleKeys.button_confirm.tr(), padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 12, ), radius: Corners.s12Border, fontColor: isDangerous ? Colors.white : null, fontHoverColor: !enableConfirm ? null : Colors.white, fillColor: !enableConfirm ? Theme.of(context).dividerColor : isDangerous ? Theme.of(context).colorScheme.error : Theme.of(context).colorScheme.primary, hoverColor: !enableConfirm ? Theme.of(context).dividerColor : isDangerous ? Theme.of(context).colorScheme.error : const Color(0xFF005483), onPressed: enableConfirm ? confirm : null, ), ), ], ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart ================================================ import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class SettingsBody extends StatelessWidget { const SettingsBody({ super.key, required this.title, this.description, this.descriptionBuilder, this.autoSeparate = true, required this.children, }); final String title; final String? description; final WidgetBuilder? descriptionBuilder; final bool autoSeparate; final List children; @override Widget build(BuildContext context) { return SingleChildScrollView( physics: const ClampingScrollPhysics(), padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ SettingsHeader( title: title, description: description, descriptionBuilder: descriptionBuilder, ), SettingsCategorySpacer(), Flexible( child: SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => autoSeparate ? const SettingsCategorySpacer() : const SizedBox.shrink(), crossAxisAlignment: CrossAxisAlignment.start, children: children, ), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; /// Renders a simple category taking a title and the list /// of children (settings) to be rendered. /// class SettingsCategory extends StatelessWidget { const SettingsCategory({ super.key, required this.title, this.description, this.descriptionColor, this.tooltip, this.actions, required this.children, }); final String title; final String? description; final Color? descriptionColor; final String? tooltip; final List? actions; final List children; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( title, style: theme.textStyle.heading4.enhanced( color: theme.textColorScheme.primary, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), if (tooltip != null) ...[ const HSpace(4), FlowyTooltip( message: tooltip, child: const FlowySvg(FlowySvgs.information_s), ), ], const Spacer(), if (actions != null) ...actions!, ], ), const VSpace(16), if (description?.isNotEmpty ?? false) ...[ FlowyText.regular( description!, maxLines: 4, fontSize: 12, overflow: TextOverflow.ellipsis, color: descriptionColor, ), const VSpace(8), ], SeparatedColumn( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, separatorBuilder: () => children.length > 1 ? const VSpace(16) : const SizedBox.shrink(), children: children, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; /// This is used to create a uniform space and divider /// between categories in settings. /// class SettingsCategorySpacer extends StatelessWidget { const SettingsCategorySpacer({ super.key, this.topSpacing, this.bottomSpacing, }); final double? topSpacing; final double? bottomSpacing; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Padding( padding: EdgeInsets.only( top: topSpacing ?? theme.spacing.l, bottom: bottomSpacing ?? theme.spacing.l, ), child: Divider( color: theme.borderColorScheme.primary, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dashed_divider.dart ================================================ import 'package:flutter/material.dart'; /// Renders a dashed divider /// /// The length of each dash is the same as the gap. /// class SettingsDashedDivider extends StatelessWidget { const SettingsDashedDivider({ super.key, this.color, this.height, this.strokeWidth = 1.0, this.gap = 3.0, this.direction = Axis.horizontal, }); // The color of the divider, defaults to the theme's divider color final Color? color; // The height of the divider, this will surround the divider equally final double? height; // Thickness of the divider final double strokeWidth; // Gap between the dashes final double gap; // Direction of the divider final Axis direction; @override Widget build(BuildContext context) { final double padding = height != null && height! > 0 ? (height! - strokeWidth) / 2 : 0; return LayoutBuilder( builder: (context, constraints) { final items = _calculateItems(constraints); return Padding( padding: EdgeInsets.symmetric( vertical: direction == Axis.horizontal ? padding : 0, horizontal: direction == Axis.vertical ? padding : 0, ), child: Wrap( direction: direction, children: List.generate( items, (index) => Container( margin: EdgeInsets.only( right: direction == Axis.horizontal ? gap : 0, bottom: direction == Axis.vertical ? gap : 0, ), width: direction == Axis.horizontal ? gap : strokeWidth, height: direction == Axis.vertical ? gap : strokeWidth, decoration: BoxDecoration( color: color ?? Theme.of(context).dividerColor, borderRadius: BorderRadius.circular(1.0), ), ), ), ), ); }, ); } int _calculateItems(BoxConstraints constraints) { final double totalLength = direction == Axis.horizontal ? constraints.maxWidth : constraints.maxHeight; return (totalLength / (gap * 2)).floor(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart ================================================ import 'package:appflowy/flutter/af_dropdown_menu.dart'; import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:collection/collection.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsDropdown extends StatefulWidget { const SettingsDropdown({ super.key, required this.selectedOption, required this.options, this.onChanged, this.actions, this.expandWidth = true, this.selectOptionCompare, this.textStyle, }); final T selectedOption; final CompareFunction? selectOptionCompare; final List> options; final void Function(T)? onChanged; final List? actions; final bool expandWidth; final TextStyle? textStyle; @override State> createState() => _SettingsDropdownState(); } class _SettingsDropdownState extends State> { late final TextEditingController controller = TextEditingController( text: widget.selectedOption is String ? widget.selectedOption as String : widget.options .firstWhereOrNull((e) => e.value == widget.selectedOption) ?.label ?? '', ); @override Widget build(BuildContext context) { final fontFamily = context.read().state.font; final fontFamilyUsed = getGoogleFontSafely(fontFamily).fontFamily ?? defaultFontFamily; return Row( children: [ Expanded( child: AFDropdownMenu( controller: controller, expandedInsets: widget.expandWidth ? EdgeInsets.zero : null, initialSelection: widget.selectedOption, dropdownMenuEntries: widget.options, selectOptionCompare: widget.selectOptionCompare, textStyle: widget.textStyle ?? Theme.of(context).textTheme.bodyLarge?.copyWith( fontFamily: fontFamilyUsed, fontWeight: FontWeight.w400, ), menuStyle: MenuStyle( maximumSize: const WidgetStatePropertyAll(Size(double.infinity, 250)), elevation: const WidgetStatePropertyAll(10), shadowColor: WidgetStatePropertyAll(Colors.black.withValues(alpha: 0.4)), backgroundColor: WidgetStatePropertyAll( Theme.of(context).cardColor, ), padding: const WidgetStatePropertyAll( EdgeInsets.symmetric(horizontal: 6, vertical: 8), ), alignment: Alignment.bottomLeft, ), inputDecorationTheme: InputDecorationTheme( contentPadding: const EdgeInsets.symmetric( vertical: 12, horizontal: 18, ), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.outline, ), borderRadius: Corners.s8Border, ), focusedBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), borderRadius: Corners.s8Border, ), errorBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.error, ), borderRadius: Corners.s8Border, ), focusedErrorBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.error, ), borderRadius: Corners.s8Border, ), ), onSelected: (v) async { v != null ? widget.onChanged?.call(v) : null; }, ), ), if (widget.actions?.isNotEmpty == true) ...[ const HSpace(16), SeparatedRow( separatorBuilder: () => const HSpace(8), children: widget.actions!, ), ], ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; /// Renders a simple header for the settings view /// class SettingsHeader extends StatelessWidget { const SettingsHeader({ super.key, required this.title, this.description, this.descriptionBuilder, }); final String title; final String? description; final WidgetBuilder? descriptionBuilder; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textStyle.heading2.enhanced( color: theme.textColorScheme.primary, ), ), if (descriptionBuilder != null) ...[ VSpace(theme.spacing.xs), descriptionBuilder!(context), ] else if (description?.isNotEmpty == true) ...[ VSpace(theme.spacing.xs), Text( description!, maxLines: 4, style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), ), ], ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_input_field.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/pages/account/password/password_suffix_icon.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; /// This is used to describe a settings input field /// /// The input will have secondary action of "save" and "cancel" /// which will only be shown when the input has changed. /// /// _Note: The label can overflow and will be ellipsized._ /// class SettingsInputField extends StatefulWidget { const SettingsInputField({ super.key, this.label, this.textController, this.focusNode, this.obscureText = false, this.value, this.placeholder, this.tooltip, this.onSave, this.onCancel, this.hideActions = false, this.onChanged, }); final String? label; final TextEditingController? textController; final FocusNode? focusNode; /// If true, the input field will be obscured /// and an option to toggle to show the text will be provided. /// final bool obscureText; final String? value; final String? placeholder; final String? tooltip; /// If true the save and cancel options will not show below the /// input field. /// final bool hideActions; final void Function(String)? onSave; /// The action to be performed when the cancel button is pressed. /// /// If null the button will **NOT** be disabled! Instead it will /// reset the input to the original value. /// final void Function()? onCancel; final void Function(String)? onChanged; @override State createState() => _SettingsInputFieldState(); } class _SettingsInputFieldState extends State { late final controller = widget.textController ?? TextEditingController(text: widget.value); late final FocusNode focusNode = widget.focusNode ?? FocusNode(); late bool obscureText = widget.obscureText; @override void dispose() { if (widget.focusNode == null) { focusNode.dispose(); } if (widget.textController == null) { controller.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ Row( children: [ if (widget.label?.isNotEmpty == true) ...[ Flexible( child: FlowyText.medium( widget.label!, color: AFThemeExtension.of(context).secondaryTextColor, ), ), ], if (widget.tooltip != null) ...[ const HSpace(4), FlowyTooltip( message: widget.tooltip, child: const FlowySvg(FlowySvgs.information_s), ), ], ], ), if (widget.label?.isNotEmpty ?? false || widget.tooltip != null) const VSpace(8), AFTextField( controller: controller, focusNode: focusNode, hintText: widget.placeholder, obscureText: obscureText, onSubmitted: widget.onSave, onChanged: (_) { widget.onChanged?.call(controller.text); setState(() {}); }, suffixIconBuilder: (context, isObscured) => widget.obscureText ? PasswordSuffixIcon( isObscured: isObscured, onTap: () { setState(() => obscureText = !obscureText); }, ) : null, ), if (!widget.hideActions && ((widget.value == null && controller.text.isNotEmpty) || widget.value != null && widget.value != controller.text)) ...[ const VSpace(8), Row( children: [ const Spacer(), SizedBox( height: 21, child: FlowyTextButton( LocaleKeys.button_save.tr(), fontWeight: FontWeight.normal, padding: EdgeInsets.zero, fillColor: Colors.transparent, hoverColor: Colors.transparent, fontColor: AFThemeExtension.of(context).textColor, onPressed: () => widget.onSave?.call(controller.text), ), ), const HSpace(24), SizedBox( height: 21, child: FlowyTextButton( LocaleKeys.button_cancel.tr(), fontWeight: FontWeight.normal, padding: EdgeInsets.zero, fillColor: Colors.transparent, hoverColor: Colors.transparent, fontColor: AFThemeExtension.of(context).textColor, onPressed: () { setState(() => controller.text = widget.value ?? ''); widget.onCancel?.call(); }, ), ), ], ), ], ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_radio_select.dart ================================================ import 'package:flutter/material.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; class SettingsRadioItem { const SettingsRadioItem({ required this.value, required this.label, required this.isSelected, this.icon, }); final T value; final String label; final bool isSelected; final Widget? icon; } class SettingsRadioSelect extends StatelessWidget { const SettingsRadioSelect({ super.key, required this.items, required this.onChanged, this.selectedItem, }); final List> items; final void Function(SettingsRadioItem) onChanged; final SettingsRadioItem? selectedItem; @override Widget build(BuildContext context) { return Wrap( spacing: 24, runSpacing: 8, children: items .map( (i) => GestureDetector( onTap: () => onChanged(i), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 14, height: 14, padding: const EdgeInsets.all(2), decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: AFThemeExtension.of(context).textColor, ), ), child: DecoratedBox( decoration: BoxDecoration( color: i.isSelected ? AFThemeExtension.of(context).textColor : Colors.transparent, shape: BoxShape.circle, ), ), ), const HSpace(8), if (i.icon != null) ...[i.icon!, const HSpace(4)], FlowyText.regular(i.label, fontSize: 14), ], ), ), ) .toList(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_subcategory.dart ================================================ import 'package:flutter/material.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; /// Renders a simple category taking a title and the list /// of children (settings) to be rendered. /// class SettingsSubcategory extends StatelessWidget { const SettingsSubcategory({ super.key, required this.title, required this.children, }); final String title; final List children; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText.medium( title, color: AFThemeExtension.of(context).secondaryTextColor, maxLines: 2, fontSize: 14, overflow: TextOverflow.ellipsis, ), const VSpace(8), SeparatedColumn( mainAxisSize: MainAxisSize.min, separatorBuilder: () => children.length > 1 ? const VSpace(16) : const SizedBox.shrink(), children: children, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart ================================================ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; enum SingleSettingsButtonType { primary, danger, highlight; bool get isPrimary => this == primary; bool get isDangerous => this == danger; bool get isHighlight => this == highlight; } /// This is used to describe a single setting action /// /// This will render a simple action that takes the title, /// the button label, and the button action. /// /// _Note: The label can overflow and will be ellipsized, /// unless maxLines is overriden._ /// class SingleSettingAction extends StatelessWidget { const SingleSettingAction({ super.key, required this.label, this.description, this.labelMaxLines, required this.buttonLabel, this.onPressed, this.buttonType = SingleSettingsButtonType.primary, this.fontSize = 14, this.fontWeight = FontWeight.normal, this.minWidth, }); final String label; final String? description; final int? labelMaxLines; final String buttonLabel; /// The action to be performed when the button is pressed /// /// If null the button will be rendered as disabled. /// final VoidCallback? onPressed; final SingleSettingsButtonType buttonType; final double fontSize; final FontWeight fontWeight; final double? minWidth; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Column( children: [ Row( children: [ Expanded( child: FlowyText( label, fontSize: fontSize, fontWeight: fontWeight, maxLines: labelMaxLines, overflow: TextOverflow.ellipsis, color: AFThemeExtension.of(context).secondaryTextColor, ), ), ], ), if (description != null) ...[ const VSpace(4), Row( children: [ Expanded( child: FlowyText.regular( description!, fontSize: 11, color: AFThemeExtension.of(context).secondaryTextColor, maxLines: 2, ), ), ], ), ], ], ), ), const HSpace(24), ConstrainedBox( constraints: BoxConstraints( minWidth: minWidth ?? 0.0, maxHeight: 32, minHeight: 32, ), child: FlowyTextButton( buttonLabel, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), fillColor: fillColor(context), radius: Corners.s8Border, hoverColor: hoverColor(context), fontColor: fontColor(context), fontHoverColor: fontHoverColor(context), borderColor: borderColor(context), fontSize: 12, isDangerous: buttonType.isDangerous, onPressed: onPressed, lineHeight: 1.0, ), ), ], ); } Color? fillColor(BuildContext context) { if (buttonType.isPrimary) { return Theme.of(context).colorScheme.primary; } return Colors.transparent; } Color? hoverColor(BuildContext context) { if (buttonType.isDangerous) { return Theme.of(context).colorScheme.error.withValues(alpha: 0.1); } if (buttonType.isPrimary) { return Theme.of(context).colorScheme.primary.withValues(alpha: 0.9); } if (buttonType.isHighlight) { return const Color(0xFF5C3699); } return null; } Color? fontColor(BuildContext context) { if (buttonType.isDangerous) { return Theme.of(context).colorScheme.error; } if (buttonType.isHighlight) { return const Color(0xFF5C3699); } return Theme.of(context).colorScheme.onPrimary; } Color? fontHoverColor(BuildContext context) { return Colors.white; } Color? borderColor(BuildContext context) { if (buttonType.isHighlight) { return const Color(0xFF5C3699); } return null; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; class RestartButton extends StatelessWidget { const RestartButton({ super.key, required this.showRestartHint, required this.onClick, }); final bool showRestartHint; final VoidCallback onClick; @override Widget build(BuildContext context) { final List children = [_buildRestartButton(context)]; if (showRestartHint) { children.add( Padding( padding: const EdgeInsets.only(top: 10), child: FlowyText( LocaleKeys.settings_menu_restartAppTip.tr(), maxLines: null, ), ), ); } return Column(children: children); } Widget _buildRestartButton(BuildContext context) { if (UniversalPlatform.isDesktopOrWeb) { return Row( children: [ SizedBox( height: 42, child: PrimaryRoundedButton( text: LocaleKeys.settings_menu_restartApp.tr(), margin: const EdgeInsets.symmetric(horizontal: 24), fontWeight: FontWeight.w600, radius: 12.0, onTap: onClick, ), ), ], ); // Row( // children: [ // FlowyButton( // isSelected: true, // useIntrinsicWidth: true, // margin: const EdgeInsets.symmetric( // horizontal: 30, // vertical: 10, // ), // text: FlowyText( // LocaleKeys.settings_menu_restartApp.tr(), // ), // onTap: onClick, // ), // const Spacer(), // ], // ); } else { return MobileLogoutButton( text: LocaleKeys.settings_menu_restartApp.tr(), onPressed: onClick, ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart ================================================ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; Future showCancelSurveyDialog(BuildContext context) { return showDialog( context: context, builder: (_) => const _Survey(), ); } class _Survey extends StatefulWidget { const _Survey(); @override State<_Survey> createState() => _SurveyState(); } class _SurveyState extends State<_Survey> { final PageController pageController = PageController(); final Map answers = {}; @override void dispose() { pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: SizedBox( width: 674, child: Padding( padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Survey title Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: FlowyText( LocaleKeys.settings_cancelSurveyDialog_title.tr(), fontSize: 22.0, overflow: TextOverflow.ellipsis, color: AFThemeExtension.of(context).strongText, ), ), FlowyButton( useIntrinsicWidth: true, text: const FlowySvg(FlowySvgs.upgrade_close_s), onTap: () => Navigator.of(context).pop(), ), ], ), const VSpace(12), // Survey explanation FlowyText( LocaleKeys.settings_cancelSurveyDialog_description.tr(), maxLines: 3, ), const VSpace(8), const Divider(), const VSpace(8), // Question "sheet" SizedBox( height: 400, width: 650, child: PageView.builder( controller: pageController, itemCount: _questionsAndAnswers.length, itemBuilder: (context, index) => _QAPage( qa: _questionsAndAnswers[index], isFirstQuestion: index == 0, isFinalQuestion: index == _questionsAndAnswers.length - 1, selectedAnswer: answers[_questionsAndAnswers[index].question], onPrevious: () { if (index > 0) { pageController.animateToPage( index - 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } }, onAnswerChanged: (answer) { answers[_questionsAndAnswers[index].question] = answer; }, onAnswerSelected: (answer) { answers[_questionsAndAnswers[index].question] = answer; if (index == _questionsAndAnswers.length - 1) { Navigator.of(context).pop(jsonEncode(answers)); } else { pageController.animateToPage( index + 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } }, ), ), ), ], ), ), ], ), ), ), ); } } class _QAPage extends StatefulWidget { const _QAPage({ required this.qa, required this.onAnswerSelected, required this.onAnswerChanged, required this.onPrevious, this.selectedAnswer, this.isFirstQuestion = false, this.isFinalQuestion = false, }); final _QA qa; final String? selectedAnswer; /// Called when "Next" is pressed /// final Function(String) onAnswerSelected; /// Called whenever an answer is selected or changed /// final Function(String) onAnswerChanged; final VoidCallback onPrevious; final bool isFirstQuestion; final bool isFinalQuestion; @override State<_QAPage> createState() => _QAPageState(); } class _QAPageState extends State<_QAPage> { final otherController = TextEditingController(); int _selectedIndex = -1; String? answer; @override void initState() { super.initState(); if (widget.selectedAnswer != null) { answer = widget.selectedAnswer; _selectedIndex = widget.qa.answers.indexOf(widget.selectedAnswer!); if (_selectedIndex == -1) { // We assume the last question is "Other" _selectedIndex = widget.qa.answers.length - 1; otherController.text = widget.selectedAnswer!; } } } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FlowyText( widget.qa.question, fontSize: 16.0, color: AFThemeExtension.of(context).strongText, ), const VSpace(18), SeparatedColumn( separatorBuilder: () => const VSpace(6), crossAxisAlignment: CrossAxisAlignment.start, children: widget.qa.answers .mapIndexed( (index, option) => _AnswerOption( prefix: _indexToLetter(index), option: option, isSelected: _selectedIndex == index, onTap: () => setState(() { _selectedIndex = index; if (_selectedIndex == widget.qa.answers.length - 1 && widget.qa.lastIsOther) { answer = otherController.text; } else { answer = option; } widget.onAnswerChanged(option); }), ), ) .toList(), ), if (widget.qa.lastIsOther && _selectedIndex == widget.qa.answers.length - 1) ...[ const VSpace(8), FlowyTextField( controller: otherController, hintText: LocaleKeys.settings_cancelSurveyDialog_otherHint.tr(), onChanged: (value) => setState(() { answer = value; widget.onAnswerChanged(value); }), ), ], const VSpace(20), Row( children: [ if (!widget.isFirstQuestion) ...[ DecoratedBox( decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: const BorderSide(color: Color(0x1E14171B)), borderRadius: BorderRadius.circular(8), ), ), child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 9.0, ), text: FlowyText.regular(LocaleKeys.button_previous.tr()), onTap: widget.onPrevious, ), ), const HSpace(12.0), ], DecoratedBox( decoration: ShapeDecoration( color: Theme.of(context).colorScheme.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), radius: BorderRadius.circular(8), text: FlowyText.regular( widget.isFinalQuestion ? LocaleKeys.button_submit.tr() : LocaleKeys.button_next.tr(), color: Colors.white, ), disable: !canProceed(), onTap: canProceed() ? () => widget.onAnswerSelected( answer ?? widget.qa.answers[_selectedIndex], ) : null, ), ), ], ), ], ); } bool canProceed() { if (_selectedIndex == widget.qa.answers.length - 1 && widget.qa.lastIsOther) { return answer != null && answer!.isNotEmpty && answer != LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(); } return _selectedIndex != -1; } } class _AnswerOption extends StatelessWidget { const _AnswerOption({ required this.prefix, required this.option, required this.onTap, this.isSelected = false, }); final String prefix; final String option; final VoidCallback onTap; final bool isSelected; @override Widget build(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( borderRadius: Corners.s8Border, border: Border.all( width: 2, color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).dividerColor, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const HSpace(2), Container( width: 24, height: 24, decoration: BoxDecoration( color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).dividerColor, borderRadius: Corners.s6Border, ), child: Center( child: FlowyText( prefix, color: isSelected ? Colors.white : null, ), ), ), const HSpace(8), FlowyText( option, fontWeight: FontWeight.w400, fontSize: 16.0, color: AFThemeExtension.of(context).strongText, ), const HSpace(6), ], ), ), ), ); } } final _questionsAndAnswers = [ _QA( question: LocaleKeys.settings_cancelSurveyDialog_questionOne_question.tr(), answers: [ LocaleKeys.settings_cancelSurveyDialog_questionOne_answerOne.tr(), LocaleKeys.settings_cancelSurveyDialog_questionOne_answerTwo.tr(), LocaleKeys.settings_cancelSurveyDialog_questionOne_answerThree.tr(), LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFour.tr(), LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFive.tr(), LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(), ], lastIsOther: true, ), _QA( question: LocaleKeys.settings_cancelSurveyDialog_questionTwo_question.tr(), answers: [ LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerOne.tr(), LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerTwo.tr(), LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerThree.tr(), LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFour.tr(), LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFive.tr(), ], ), _QA( question: LocaleKeys.settings_cancelSurveyDialog_questionThree_question.tr(), answers: [ LocaleKeys.settings_cancelSurveyDialog_questionThree_answerOne.tr(), LocaleKeys.settings_cancelSurveyDialog_questionThree_answerTwo.tr(), LocaleKeys.settings_cancelSurveyDialog_questionThree_answerThree.tr(), LocaleKeys.settings_cancelSurveyDialog_questionThree_answerFour.tr(), LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(), ], lastIsOther: true, ), _QA( question: LocaleKeys.settings_cancelSurveyDialog_questionFour_question.tr(), answers: [ LocaleKeys.settings_cancelSurveyDialog_questionFour_answerOne.tr(), LocaleKeys.settings_cancelSurveyDialog_questionFour_answerTwo.tr(), LocaleKeys.settings_cancelSurveyDialog_questionFour_answerThree.tr(), LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFour.tr(), LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFive.tr(), ], ), ]; class _QA { const _QA({ required this.question, required this.answers, this.lastIsOther = false, }); final String question; final List answers; final bool lastIsOther; } /// Returns the letter corresponding to the index. /// /// Eg. 0 -> A, 1 -> B, 2 -> C, ..., and so forth. /// String _indexToLetter(int index) { return String.fromCharCode(65 + index); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart ================================================ export 'emoji_shortcut_event.dart'; export 'src/emji_picker_config.dart'; export 'src/emoji_picker.dart'; export 'src/emoji_picker_builder.dart'; export 'src/flowy_emoji_picker_config.dart'; export 'src/models/emoji_model.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart ================================================ import 'package:appflowy/plugins/emoji/emoji_actions_command.dart'; import 'package:appflowy/plugins/emoji/emoji_menu.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; final CommandShortcutEvent emojiShortcutEvent = CommandShortcutEvent( key: 'Ctrl + Alt + E to show emoji picker', command: 'ctrl+alt+e', macOSCommand: 'cmd+alt+e', getDescription: () => 'Show an emoji picker', handler: _emojiShortcutHandler, ); CommandShortcutEventHandler _emojiShortcutHandler = (editorState) { final selection = editorState.selection; if (selection == null) { return KeyEventResult.ignored; } final node = editorState.getNodeAtPath(selection.start.path); final context = node?.context; if (node == null || context == null || node.delta == null || node.type == CodeBlockKeys.type) { return KeyEventResult.ignored; } final container = Overlay.of(context); emojiMenuService = EmojiMenu(editorState: editorState, overlay: container); emojiMenuService?.show(''); return KeyEventResult.handled; }; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/default_emoji_picker_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'emoji_picker.dart'; import 'emoji_picker_builder.dart'; import 'models/emoji_category_models.dart'; import 'models/emoji_model.dart'; class DefaultEmojiPickerView extends EmojiPickerBuilder { const DefaultEmojiPickerView( super.config, super.state, { super.key, }); @override DefaultEmojiPickerViewState createState() => DefaultEmojiPickerViewState(); } class DefaultEmojiPickerViewState extends State with TickerProviderStateMixin { PageController? _pageController; TabController? _tabController; final TextEditingController _emojiController = TextEditingController(); final FocusNode _emojiFocusNode = FocusNode(); EmojiCategoryGroup searchEmojiList = EmojiCategoryGroup(EmojiCategory.SEARCH, []); final scrollController = ScrollController(); @override void initState() { super.initState(); int initCategory = widget.state.emojiCategoryGroupList.indexWhere( (el) => el.category == widget.config.initCategory, ); if (initCategory == -1) { initCategory = 0; } _tabController = TabController( initialIndex: initCategory, length: widget.state.emojiCategoryGroupList.length, vsync: this, ); _pageController = PageController(initialPage: initCategory); _emojiFocusNode.requestFocus(); _emojiController.addListener(_onEmojiChanged); } @override void dispose() { _emojiController.removeListener(_onEmojiChanged); _emojiController.dispose(); _emojiFocusNode.dispose(); _pageController?.dispose(); _tabController?.dispose(); scrollController.dispose(); super.dispose(); } void _onEmojiChanged() { final String query = _emojiController.text.toLowerCase(); if (query.isEmpty) { searchEmojiList.emoji.clear(); _pageController!.jumpToPage(_tabController!.index); } else { searchEmojiList.emoji.clear(); for (final element in widget.state.emojiCategoryGroupList) { searchEmojiList.emoji.addAll( element.emoji .where((item) => item.name.toLowerCase().contains(query)) .toList(), ); } } setState(() {}); } Widget _buildBackspaceButton() { if (widget.state.onBackspacePressed != null) { return Material( type: MaterialType.transparency, child: IconButton( padding: const EdgeInsets.only(bottom: 2), icon: Icon(Icons.backspace, color: widget.config.backspaceColor), onPressed: () => widget.state.onBackspacePressed!(), ), ); } return const SizedBox.shrink(); } bool isEmojiSearching() => searchEmojiList.emoji.isNotEmpty || _emojiController.text.isNotEmpty; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final emojiSize = widget.config.getEmojiSize(constraints.maxWidth); final style = Theme.of(context); return Container( color: widget.config.bgColor, padding: const EdgeInsets.all(4), child: Column( children: [ const VSpace(4), // search bar SizedBox( height: 32.0, child: TextField( controller: _emojiController, focusNode: _emojiFocusNode, autofocus: true, style: style.textTheme.bodyMedium, cursorColor: style.textTheme.bodyMedium?.color, decoration: InputDecoration( contentPadding: const EdgeInsets.all(8), hintText: widget.config.searchHintText, hintStyle: widget.config.serachHintTextStyle, enabledBorder: widget.config.serachBarEnableBorder, focusedBorder: widget.config.serachBarFocusedBorder, ), ), ), const VSpace(4), Row( children: [ Expanded( child: TabBar( labelColor: widget.config.selectedCategoryIconColor, unselectedLabelColor: widget.config.categoryIconColor, controller: isEmojiSearching() ? TabController(length: 1, vsync: this) : _tabController, labelPadding: EdgeInsets.zero, indicatorColor: widget.config.selectedCategoryIconBackgroundColor, padding: const EdgeInsets.symmetric(vertical: 4.0), indicator: BoxDecoration( border: Border.all(color: Colors.transparent), borderRadius: BorderRadius.circular(4.0), color: style.colorScheme.secondary, ), onTap: (index) { _pageController!.animateToPage( index, duration: widget.config.tabIndicatorAnimDuration, curve: Curves.ease, ); }, tabs: isEmojiSearching() ? [_buildCategory(EmojiCategory.SEARCH, emojiSize)] : widget.state.emojiCategoryGroupList .asMap() .entries .map( (item) => _buildCategory( item.value.category, emojiSize, ), ) .toList(), ), ), _buildBackspaceButton(), ], ), Flexible( child: PageView.builder( itemCount: searchEmojiList.emoji.isNotEmpty ? 1 : widget.state.emojiCategoryGroupList.length, controller: _pageController, physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { final EmojiCategoryGroup emojiCategoryGroup = isEmojiSearching() ? searchEmojiList : widget.state.emojiCategoryGroupList[index]; return _buildPage(emojiSize, emojiCategoryGroup); }, ), ), ], ), ); }, ); } Widget _buildCategory(EmojiCategory category, double categorySize) { return Tab( height: categorySize, child: Icon( widget.config.getIconForCategory(category), size: categorySize / 1.3, ), ); } Widget _buildButtonWidget({ required VoidCallback onPressed, required Widget child, }) { if (widget.config.buttonMode == ButtonMode.MATERIAL) { return InkWell(onTap: onPressed, child: child); } return GestureDetector(onTap: onPressed, child: child); } Widget _buildPage(double emojiSize, EmojiCategoryGroup emojiCategoryGroup) { // Display notice if recent has no entries yet if (emojiCategoryGroup.category == EmojiCategory.RECENT && emojiCategoryGroup.emoji.isEmpty) { return _buildNoRecent(); } else if (emojiCategoryGroup.category == EmojiCategory.SEARCH && emojiCategoryGroup.emoji.isEmpty) { return Center(child: Text(widget.config.noEmojiFoundText)); } // Build page normally return ScrollbarListStack( axis: Axis.vertical, controller: scrollController, barSize: 4.0, scrollbarPadding: const EdgeInsets.symmetric(horizontal: 4.0), handleColor: widget.config.scrollBarHandleColor, showTrack: true, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: GridView.builder( controller: scrollController, padding: const EdgeInsets.all(0), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: widget.config.emojiNumberPerRow, mainAxisSpacing: widget.config.verticalSpacing, crossAxisSpacing: widget.config.horizontalSpacing, ), itemCount: emojiCategoryGroup.emoji.length, itemBuilder: (context, index) { final item = emojiCategoryGroup.emoji[index]; return _buildEmoji(emojiSize, emojiCategoryGroup, item); }, cacheExtent: 10, ), ), ); } Widget _buildEmoji( double emojiSize, EmojiCategoryGroup emojiCategoryGroup, Emoji emoji, ) { return _buildButtonWidget( onPressed: () { widget.state.onEmojiSelected(emojiCategoryGroup.category, emoji); }, child: FlowyHover( child: FittedBox( child: Text( emoji.emoji, style: TextStyle(fontSize: emojiSize), ), ), ), ); } Widget _buildNoRecent() { return Center( child: Text( widget.config.noRecentsText, style: widget.config.noRecentsStyle, textAlign: TextAlign.center, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'emoji_picker.dart'; import 'models/emoji_category_models.dart'; part 'emji_picker_config.freezed.dart'; @freezed class EmojiPickerConfig with _$EmojiPickerConfig { // private empty constructor is used to make method work in freezed // https://pub.dev/packages/freezed#adding-getters-and-methods-to-our-models const EmojiPickerConfig._(); const factory EmojiPickerConfig({ @Default(7) int emojiNumberPerRow, // The maximum size(width and height) of emoji // It also depaneds on the screen size and emojiNumberPerRow @Default(32) double emojiSizeMax, // Vertical spacing between emojis @Default(0) double verticalSpacing, // Horizontal spacing between emojis @Default(0) double horizontalSpacing, // The initial [EmojiCategory] that will be selected @Default(EmojiCategory.RECENT) EmojiCategory initCategory, // The background color of the Widget @Default(Color(0xFFEBEFF2)) Color? bgColor, // The color of the category icons @Default(Colors.grey) Color? categoryIconColor, // The color of the category icon when selected @Default(Colors.blue) Color? selectedCategoryIconColor, // The color of the category indicator @Default(Colors.blue) Color? selectedCategoryIconBackgroundColor, // The color of the loading indicator during initialization @Default(Colors.blue) Color? progressIndicatorColor, // The color of the backspace icon button @Default(Colors.blue) Color? backspaceColor, // Show extra tab with recently used emoji @Default(true) bool showRecentsTab, // Limit of recently used emoji that will be saved @Default(28) int recentsLimit, @Default('Search emoji') String searchHintText, TextStyle? serachHintTextStyle, InputBorder? serachBarEnableBorder, InputBorder? serachBarFocusedBorder, // The text to be displayed if no recent emojis to display @Default('No recent emoji') String noRecentsText, TextStyle? noRecentsStyle, // The text to be displayed if no emoji found @Default('No emoji found') String noEmojiFoundText, Color? scrollBarHandleColor, // Duration of tab indicator to animate to next category @Default(kTabScrollDuration) Duration tabIndicatorAnimDuration, // Determines the icon to display for each [EmojiCategory] @Default(EmojiCategoryIcons()) EmojiCategoryIcons emojiCategoryIcons, // Change between Material and Cupertino button style @Default(ButtonMode.MATERIAL) ButtonMode buttonMode, }) = _EmojiPickerConfig; /// Get Emoji size based on properties and screen width double getEmojiSize(double width) { final maxSize = width / emojiNumberPerRow; return min(maxSize, emojiSizeMax); } /// Returns the icon for the category IconData getIconForCategory(EmojiCategory category) { switch (category) { case EmojiCategory.RECENT: return emojiCategoryIcons.recentIcon; case EmojiCategory.SMILEYS: return emojiCategoryIcons.smileyIcon; case EmojiCategory.ANIMALS: return emojiCategoryIcons.animalIcon; case EmojiCategory.FOODS: return emojiCategoryIcons.foodIcon; case EmojiCategory.TRAVEL: return emojiCategoryIcons.travelIcon; case EmojiCategory.ACTIVITIES: return emojiCategoryIcons.activityIcon; case EmojiCategory.OBJECTS: return emojiCategoryIcons.objectIcon; case EmojiCategory.SYMBOLS: return emojiCategoryIcons.symbolIcon; case EmojiCategory.FLAGS: return emojiCategoryIcons.flagIcon; case EmojiCategory.SEARCH: return emojiCategoryIcons.searchIcon; } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_lists.dart ================================================ // Copyright information // File originally from https://github.com/JeffG05/emoji_picker // import 'emoji.dart'; // final List> temp = [smileys, animals, foods, activities, travel, objects, symbols, flags]; // final List emojiSearchList = temp // .map((element) { // return element.entries.map((entry) => Emoji(entry.key, entry.value)).toList(); // }) // .toList() // .first; /// Map of all possible emojis along with their names in [Category.SMILEYS] final Map smileys = Map.fromIterables([ 'Grinning Face', 'Grinning Face With Big Eyes', 'Grinning Face With Smiling Eyes', 'Beaming Face With Smiling Eyes', 'Grinning Squinting Face', 'Grinning Face With Sweat', 'Rolling on the Floor Laughing', 'Face With Tears of Joy', 'Slightly Smiling Face', 'Upside-Down Face', 'Winking Face', 'Smiling Face With Smiling Eyes', 'Smiling Face With Halo', 'Smiling Face With Hearts', 'Smiling Face With Heart-Eyes', 'Star-Struck', 'Face Blowing a Kiss', 'Kissing Face', 'Smiling Face', 'Kissing Face With Closed Eyes', 'Kissing Face With Smiling Eyes', 'Face Savoring Food', 'Face With Tongue', 'Winking Face With Tongue', 'Zany Face', 'Squinting Face With Tongue', 'Money-Mouth Face', 'Hugging Face', 'Face With Hand Over Mouth', 'Shushing Face', 'Thinking Face', 'Zipper-Mouth Face', 'Face With Raised Eyebrow', 'Neutral Face', 'Expressionless Face', 'Face Without Mouth', 'Smirking Face', 'Unamused Face', 'Face With Rolling Eyes', 'Grimacing Face', 'Lying Face', 'Relieved Face', 'Pensive Face', 'Sleepy Face', 'Drooling Face', 'Sleeping Face', 'Face With Medical Mask', 'Face With Thermometer', 'Face With Head-Bandage', 'Nauseated Face', 'Face Vomiting', 'Sneezing Face', 'Hot Face', 'Cold Face', 'Woozy Face', 'Dizzy Face', 'Exploding Head', 'Cowboy Hat Face', 'Partying Face', 'Smiling Face With Sunglasses', 'Nerd Face', 'Face With Monocle', 'Confused Face', 'Worried Face', 'Slightly Frowning Face', 'Frowning Face', 'Face With Open Mouth', 'Hushed Face', 'Astonished Face', 'Flushed Face', 'Pleading Face', 'Frowning Face With Open Mouth', 'Anguished Face', 'Fearful Face', 'Anxious Face With Sweat', 'Sad but Relieved Face', 'Crying Face', 'Loudly Crying Face', 'Face Screaming in Fear', 'Confounded Face', 'Persevering Face', 'Disappointed Face', 'Downcast Face With Sweat', 'Weary Face', 'Tired Face', 'Face With Steam From Nose', 'Pouting Face', 'Angry Face', 'Face With Symbols on Mouth', 'Smiling Face With Horns', 'Angry Face With Horns', 'Skull', 'Skull and Crossbones', 'Pile of Poo', 'Clown Face', 'Ogre', 'Goblin', 'Ghost', 'Alien', 'Alien Monster', 'Robot Face', 'Grinning Cat Face', 'Grinning Cat Face With Smiling Eyes', 'Cat Face With Tears of Joy', 'Smiling Cat Face With Heart-Eyes', 'Cat Face With Wry Smile', 'Kissing Cat Face', 'Weary Cat Face', 'Crying Cat Face', 'Pouting Cat Face', 'Kiss Mark', 'Waving Hand', 'Raised Back of Hand', 'Hand With Fingers Splayed', 'Raised Hand', 'Vulcan Salute', 'OK Hand', 'Victory Hand', 'Crossed Fingers', 'Love-You Gesture', 'Sign of the Horns', 'Call Me Hand', 'Backhand Index Pointing Left', 'Backhand Index Pointing Right', 'Backhand Index Pointing Up', 'Middle Finger', 'Backhand Index Pointing Down', 'Index Pointing Up', 'Thumbs Up', 'Thumbs Down', 'Raised Fist', 'Oncoming Fist', 'Left-Facing Fist', 'Right-Facing Fist', 'Clapping Hands', 'Raising Hands', 'Open Hands', 'Palms Up Together', 'Handshake', 'Folded Hands', 'Writing Hand', 'Nail Polish', 'Selfie', 'Flexed Biceps', 'Leg', 'Foot', 'Ear', 'Nose', 'Brain', 'Tooth', 'Bone', 'Eyes', 'Eye', 'Tongue', 'Mouth', 'Baby', 'Child', 'Boy', 'Girl', 'Person', 'Man', 'Man: Beard', 'Man: Blond Hair', 'Man: Red Hair', 'Man: Curly Hair', 'Man: White Hair', 'Man: Bald', 'Woman', 'Woman: Blond Hair', 'Woman: Red Hair', 'Woman: Curly Hair', 'Woman: White Hair', 'Woman: Bald', 'Older Person', 'Old Man', 'Old Woman', 'Man Frowning', 'Woman Frowning', 'Man Pouting', 'Woman Pouting', 'Man Gesturing No', 'Woman Gesturing No', 'Man Gesturing OK', 'Woman Gesturing OK', 'Man Tipping Hand', 'Woman Tipping Hand', 'Man Raising Hand', 'Woman Raising Hand', 'Man Bowing', 'Woman Bowing', 'Man Facepalming', 'Woman Facepalming', 'Man Shrugging', 'Woman Shrugging', 'Man Health Worker', 'Woman Health Worker', 'Man Student', 'Woman Student', 'Man Teacher', 'Woman Teacher', 'Man Judge', 'Woman Judge', 'Man Farmer', 'Woman Farmer', 'Man Cook', 'Woman Cook', 'Man Mechanic', 'Woman Mechanic', 'Man Factory Worker', 'Woman Factory Worker', 'Man Office Worker', 'Woman Office Worker', 'Man Scientist', 'Woman Scientist', 'Man Technologist', 'Woman Technologist', 'Man Singer', 'Woman Singer', 'Man Artist', 'Woman Artist', 'Man Pilot', 'Woman Pilot', 'Man Astronaut', 'Woman Astronaut', 'Man Firefighter', 'Woman Firefighter', 'Man Police Officer', 'Woman Police Officer', 'Man Detective', 'Woman Detective', 'Man Guard', 'Woman Guard', 'Man Construction Worker', 'Woman Construction Worker', 'Prince', 'Princess', 'Man Wearing Turban', 'Woman Wearing Turban', 'Man With Chinese Cap', 'Woman With Headscarf', 'Man in Tuxedo', 'Bride With Veil', 'Pregnant Woman', 'Breast-Feeding', 'Baby Angel', 'Santa Claus', 'Mrs. Claus', 'Man Superhero', 'Woman Superhero', 'Man Supervillain', 'Woman Supervillain', 'Man Mage', 'Woman Mage', 'Man Fairy', 'Woman Fairy', 'Man Vampire', 'Woman Vampire', 'Merman', 'Mermaid', 'Man Elf', 'Woman Elf', 'Man Genie', 'Woman Genie', 'Man Zombie', 'Woman Zombie', 'Man Getting Massage', 'Woman Getting Massage', 'Man Getting Haircut', 'Woman Getting Haircut', 'Man Walking', 'Woman Walking', 'Man Running', 'Woman Running', 'Woman Dancing', 'Man Dancing', 'Man in Suit Levitating', 'Men With Bunny Ears', 'Women With Bunny Ears', 'Man in Steamy Room', 'Woman in Steamy Room', 'Person in Lotus Position', 'Women Holding Hands', 'Woman and Man Holding Hands', 'Men Holding Hands', 'Kiss', 'Kiss: Man, Man', 'Kiss: Woman, Woman', 'Couple With Heart', 'Couple With Heart: Man, Man', 'Couple With Heart: Woman, Woman', 'Family', 'Family: Man, Woman, Boy', 'Family: Man, Woman, Girl', 'Family: Man, Woman, Girl, Boy', 'Family: Man, Woman, Boy, Boy', 'Family: Man, Woman, Girl, Girl', 'Family: Man, Man, Boy', 'Family: Man, Man, Girl', 'Family: Man, Man, Girl, Boy', 'Family: Man, Man, Boy, Boy', 'Family: Man, Man, Girl, Girl', 'Family: Woman, Woman, Boy', 'Family: Woman, Woman, Girl', 'Family: Woman, Woman, Girl, Boy', 'Family: Woman, Woman, Boy, Boy', 'Family: Woman, Woman, Girl, Girl', 'Family: Man, Boy', 'Family: Man, Boy, Boy', 'Family: Man, Girl', 'Family: Man, Girl, Boy', 'Family: Man, Girl, Girl', 'Family: Woman, Boy', 'Family: Woman, Boy, Boy', 'Family: Woman, Girl', 'Family: Woman, Girl, Boy', 'Family: Woman, Girl, Girl', 'Speaking Head', 'Bust in Silhouette', 'Busts in Silhouette', 'Footprints', 'Luggage', 'Closed Umbrella', 'Umbrella', 'Thread', 'Yarn', 'Glasses', 'Sunglasses', 'Goggles', 'Lab Coat', 'Necktie', 'T-Shirt', 'Jeans', 'Scarf', 'Gloves', 'Coat', 'Socks', 'Dress', 'Kimono', 'Bikini', 'Woman’s Clothes', 'Purse', 'Handbag', 'Clutch Bag', 'Backpack', 'Man’s Shoe', 'Running Shoe', 'Hiking Boot', 'Flat Shoe', 'High-Heeled Shoe', 'Woman’s Sandal', 'Woman’s Boot', 'Crown', 'Woman’s Hat', 'Top Hat', 'Graduation Cap', 'Billed Cap', 'Rescue Worker’s Helmet', 'Lipstick', 'Ring', 'Briefcase', ], [ '😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '☺', '😚', '😙', '😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴', '😵', '🤯', '🤠', '🥳', '😎', '🤓', '🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲', '😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢', '😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫', '😤', '😡', '😠', '🤬', '😈', '👿', '💀', '☠', '💩', '🤡', '👹', '👺', '👻', '👽', '👾', '🤖', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾', '💋', '👋', '🤚', '🖐', '✋', '🖖', '👌', '✌', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍', '💅', '🤳', '💪', '🦵', '🦶', '👂', '👃', '🧠', '🦷', '🦴', '👀', '👁', '👅', '👄', '👶', '🧒', '👦', '👧', '🧑', '👨', '🧔', '👱', '👨‍🦰', '👨‍🦱', '👨‍🦳', '👨‍🦲', '👩', '👱', '👩‍🦰', '👩‍🦱', '👩‍🦳', '👩‍🦲', '🧓', '👴', '👵', '🙍', '🙍', '🙎', '🙎', '🙅', '🙅', '🙆', '🙆', '💁', '💁', '🙋', '🙋', '🙇', '🙇', '🤦', '🤦', '🤷', '🤷', '👨‍⚕️', '👩‍⚕️', '👨‍🎓', '👩‍🎓', '👨‍🏫', '👩‍🏫', '👨‍⚖️', '👩‍⚖️', '👨‍🌾', '👩‍🌾', '👨‍🍳', '👩‍🍳', '👨‍🔧', '👩‍🔧', '👨‍🏭', '👩‍🏭', '👨‍💼', '👩‍💼', '👨‍🔬', '👩‍🔬', '👨‍💻', '👩‍💻', '👨‍🎤', '👩‍🎤', '👨‍🎨', '👩‍🎨', '👨‍✈️', '👩‍✈️', '👨‍🚀', '👩‍🚀', '👨‍🚒', '👩‍🚒', '👮', '👮', '🕵️', '🕵️', '💂', '💂', '👷', '👷', '🤴', '👸', '👳', '👳', '👲', '🧕', '🤵', '👰', '🤰', '🤱', '👼', '🎅', '🤶', '🦸', '🦸', '🦹', '🦹', '🧙', '🧙', '🧚', '🧚', '🧛', '🧛', '🧜', '🧜', '🧝', '🧝', '🧞', '🧞', '🧟', '🧟', '💆', '💆', '💇', '💇', '🚶', '🚶', '🏃', '🏃', '💃', '🕺', '🕴', '👯', '👯', '🧖', '🧖', '🧘', '👭', '👫', '👬', '💏', '👨‍❤️‍💋‍👨', '👩‍❤️‍💋‍👩', '💑', '👨‍❤️‍👨', '👩‍❤️‍👩', '👪', '👨‍👩‍👦', '👨‍👩‍👧', '👨‍👩‍👧‍👦', '👨‍👩‍👦‍👦', '👨‍👩‍👧‍👧', '👨‍👨‍👦', '👨‍👨‍👧', '👨‍👨‍👧‍👦', '👨‍👨‍👦‍👦', '👨‍👨‍👧‍👧', '👩‍👩‍👦', '👩‍👩‍👧', '👩‍👩‍👧‍👦', '👩‍👩‍👦‍👦', '👩‍👩‍👧‍👧', '👨‍👦', '👨‍👦‍👦', '👨‍👧', '👨‍👧‍👦', '👨‍👧‍👧', '👩‍👦', '👩‍👦‍👦', '👩‍👧', '👩‍👧‍👦', '👩‍👧‍👧', '🗣', '👤', '👥', '👣', '🧳', '🌂', '☂', '🧵', '🧶', '👓', '🕶', '🥽', '🥼', '👔', '👕', '👖', '🧣', '🧤', '🧥', '🧦', '👗', '👘', '👙', '👚', '👛', '👜', '👝', '🎒', '👞', '👟', '🥾', '🥿', '👠', '👡', '👢', '👑', '👒', '🎩', '🎓', '🧢', '⛑', '💄', '💍', '💼', ]); /// Map of all possible emojis along with their names in [Category.ANIMALS] final Map animals = Map.fromIterables([ 'Dog Face', 'Cat Face', 'Mouse Face', 'Hamster Face', 'Rabbit Face', 'Fox Face', 'Bear Face', 'Panda Face', 'Koala Face', 'Tiger Face', 'Lion Face', 'Cow Face', 'Pig Face', 'Pig Nose', 'Frog Face', 'Monkey Face', 'See-No-Evil Monkey', 'Hear-No-Evil Monkey', 'Speak-No-Evil Monkey', 'Monkey', 'Collision', 'Dizzy', 'Sweat Droplets', 'Dashing Away', 'Gorilla', 'Dog', 'Poodle', 'Wolf Face', 'Raccoon', 'Cat', 'Tiger', 'Leopard', 'Horse Face', 'Horse', 'Unicorn Face', 'Zebra', 'Ox', 'Water Buffalo', 'Cow', 'Pig', 'Boar', 'Ram', 'Ewe', 'Goat', 'Camel', 'Two-Hump Camel', 'Llama', 'Giraffe', 'Elephant', 'Rhinoceros', 'Hippopotamus', 'Mouse', 'Rat', 'Rabbit', 'Chipmunk', 'Hedgehog', 'Bat', 'Kangaroo', 'Badger', 'Paw Prints', 'Turkey', 'Chicken', 'Rooster', 'Hatching Chick', 'Baby Chick', 'Front-Facing Baby Chick', 'Bird', 'Penguin', 'Dove', 'Eagle', 'Duck', 'Swan', 'Owl', 'Peacock', 'Parrot', 'Crocodile', 'Turtle', 'Lizard', 'Snake', 'Dragon Face', 'Dragon', 'Sauropod', 'T-Rex', 'Spouting Whale', 'Whale', 'Dolphin', 'Fish', 'Tropical Fish', 'Blowfish', 'Shark', 'Octopus', 'Spiral Shell', 'Snail', 'Butterfly', 'Bug', 'Ant', 'Honeybee', 'Lady Beetle', 'Cricket', 'Spider', 'Spider Web', 'Scorpion', 'Mosquito', 'Microbe', 'Bouquet', 'Cherry Blossom', 'White Flower', 'Rosette', 'Rose', 'Wilted Flower', 'Hibiscus', 'Sunflower', 'Blossom', 'Tulip', 'Seedling', 'Evergreen Tree', 'Deciduous Tree', 'Palm Tree', 'Cactus', 'Sheaf of Rice', 'Herb', 'Shamrock', 'Four Leaf Clover', 'Maple Leaf', 'Fallen Leaf', 'Leaf Fluttering in Wind', 'Mushroom', 'Chestnut', 'Crab', 'Lobster', 'Shrimp', 'Squid', 'Globe Showing Europe-Africa', 'Globe Showing Americas', 'Globe Showing Asia-Australia', 'Globe With Meridians', 'New Moon', 'Waxing Crescent Moon', 'First Quarter Moon', 'Waxing Gibbous Moon', 'Full Moon', 'Waning Gibbous Moon', 'Last Quarter Moon', 'Waning Crescent Moon', 'Crescent Moon', 'New Moon Face', 'First Quarter Moon Face', 'Last Quarter Moon Face', 'Sun', 'Full Moon Face', 'Sun With Face', 'Star', 'Glowing Star', 'Shooting Star', 'Cloud', 'Sun Behind Cloud', 'Cloud With Lightning and Rain', 'Sun Behind Small Cloud', 'Sun Behind Large Cloud', 'Sun Behind Rain Cloud', 'Cloud With Rain', 'Cloud With Snow', 'Cloud With Lightning', 'Tornado', 'Fog', 'Wind Face', 'Rainbow', 'Umbrella', 'Umbrella With Rain Drops', 'High Voltage', 'Snowflake', 'Snowman Without Snow', 'Snowman', 'Comet', 'Fire', 'Droplet', 'Water Wave', 'Christmas Tree', 'Sparkles', 'Tanabata Tree', 'Pine Decoration', ], [ '🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐽', '🐸', '🐵', '🙈', '🙉', '🙊', '🐒', '💥', '💫', '💦', '💨', '🦍', '🐕', '🐩', '🐺', '🦝', '🐈', '🐅', '🐆', '🐴', '🐎', '🦄', '🦓', '🐂', '🐃', '🐄', '🐖', '🐗', '🐏', '🐑', '🐐', '🐪', '🐫', '🦙', '🦒', '🐘', '🦏', '🦛', '🐁', '🐀', '🐇', '🐿', '🦔', '🦇', '🦘', '🦡', '🐾', '🦃', '🐔', '🐓', '🐣', '🐤', '🐥', '🐦', '🐧', '🕊', '🦅', '🦆', '🦢', '🦉', '🦚', '🦜', '🐊', '🐢', '🦎', '🐍', '🐲', '🐉', '🦕', '🦖', '🐳', '🐋', '🐬', '🐟', '🐠', '🐡', '🦈', '🐙', '🐚', '🐌', '🦋', '🐛', '🐜', '🐝', '🐞', '🦗', '🕷', '🕸', '🦂', '🦟', '🦠', '💐', '🌸', '💮', '🏵', '🌹', '🥀', '🌺', '🌻', '🌼', '🌷', '🌱', '🌲', '🌳', '🌴', '🌵', '🌾', '🌿', '☘', '🍀', '🍁', '🍂', '🍃', '🍄', '🌰', '🦀', '🦞', '🦐', '🦑', '🌍', '🌎', '🌏', '🌐', '🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘', '🌙', '🌚', '🌛', '🌜', '☀', '🌝', '🌞', '⭐', '🌟', '🌠', '☁', '⛅', '⛈', '🌤', '🌥', '🌦', '🌧', '🌨', '🌩', '🌪', '🌫', '🌬', '🌈', '☂', '☔', '⚡', '❄', '☃', '⛄', '☄', '🔥', '💧', '🌊', '🎄', '✨', '🎋', '🎍', ]); /// Map of all possible emojis along with their names in [Category.FOODS] final Map foods = Map.fromIterables([ 'Grapes', 'Melon', 'Watermelon', 'Tangerine', 'Lemon', 'Banana', 'Pineapple', 'Mango', 'Red Apple', 'Green Apple', 'Pear', 'Peach', 'Cherries', 'Strawberry', 'Kiwi Fruit', 'Tomato', 'Coconut', 'Avocado', 'Eggplant', 'Potato', 'Carrot', 'Ear of Corn', 'Hot Pepper', 'Cucumber', 'Leafy Green', 'Broccoli', 'Mushroom', 'Peanuts', 'Chestnut', 'Bread', 'Croissant', 'Baguette Bread', 'Pretzel', 'Bagel', 'Pancakes', 'Cheese Wedge', 'Meat on Bone', 'Poultry Leg', 'Cut of Meat', 'Bacon', 'Hamburger', 'French Fries', 'Pizza', 'Hot Dog', 'Sandwich', 'Taco', 'Burrito', 'Stuffed Flatbread', 'Cooking', 'Shallow Pan of Food', 'Pot of Food', 'Bowl With Spoon', 'Green Salad', 'Popcorn', 'Salt', 'Canned Food', 'Bento Box', 'Rice Cracker', 'Rice Ball', 'Cooked Rice', 'Curry Rice', 'Steaming Bowl', 'Spaghetti', 'Roasted Sweet Potato', 'Oden', 'Sushi', 'Fried Shrimp', 'Fish Cake With Swirl', 'Moon Cake', 'Dango', 'Dumpling', 'Fortune Cookie', 'Takeout Box', 'Soft Ice Cream', 'Shaved Ice', 'Ice Cream', 'Doughnut', 'Cookie', 'Birthday Cake', 'Shortcake', 'Cupcake', 'Pie', 'Chocolate Bar', 'Candy', 'Lollipop', 'Custard', 'Honey Pot', 'Baby Bottle', 'Glass of Milk', 'Hot Beverage', 'Teacup Without Handle', 'Sake', 'Bottle With Popping Cork', 'Wine Glass', 'Cocktail Glass', 'Tropical Drink', 'Beer Mug', 'Clinking Beer Mugs', 'Clinking Glasses', 'Tumbler Glass', 'Cup With Straw', 'Chopsticks', 'Fork and Knife With Plate', 'Fork and Knife', 'Spoon', ], [ '🍇', '🍈', '🍉', '🍊', '🍋', '🍌', '🍍', '🥭', '🍎', '🍏', '🍐', '🍑', '🍒', '🍓', '🥝', '🍅', '🥥', '🥑', '🍆', '🥔', '🥕', '🌽', '🌶', '🥒', '🥬', '🥦', '🍄', '🥜', '🌰', '🍞', '🥐', '🥖', '🥨', '🥯', '🥞', '🧀', '🍖', '🍗', '🥩', '🥓', '🍔', '🍟', '🍕', '🌭', '🥪', '🌮', '🌯', '🥙', '🍳', '🥘', '🍲', '🥣', '🥗', '🍿', '🧂', '🥫', '🍱', '🍘', '🍙', '🍚', '🍛', '🍜', '🍝', '🍠', '🍢', '🍣', '🍤', '🍥', '🥮', '🍡', '🥟', '🥠', '🥡', '🍦', '🍧', '🍨', '🍩', '🍪', '🎂', '🍰', '🧁', '🥧', '🍫', '🍬', '🍭', '🍮', '🍯', '🍼', '🥛', '☕', '🍵', '🍶', '🍾', '🍷', '🍸', '🍹', '🍺', '🍻', '🥂', '🥃', '🥤', '🥢', '🍽', '🍴', '🥄', ]); /// Map of all possible emojis along with their names in [Category.TRAVEL] final Map travel = Map.fromIterables([ 'Person Rowing Boat', 'Map of Japan', 'Snow-Capped Mountain', 'Mountain', 'Volcano', 'Mount Fuji', 'Camping', 'Beach With Umbrella', 'Desert', 'Desert Island', 'National Park', 'Stadium', 'Classical Building', 'Building Construction', 'Houses', 'Derelict House', 'House', 'House With Garden', 'Office Building', 'Japanese Post Office', 'Post Office', 'Hospital', 'Bank', 'Hotel', 'Love Hotel', 'Convenience Store', 'School', 'Department Store', 'Factory', 'Japanese Castle', 'Castle', 'Wedding', 'Tokyo Tower', 'Statue of Liberty', 'Church', 'Mosque', 'Synagogue', 'Shinto Shrine', 'Kaaba', 'Fountain', 'Tent', 'Foggy', 'Night With Stars', 'Cityscape', 'Sunrise Over Mountains', 'Sunrise', 'Cityscape at Dusk', 'Sunset', 'Bridge at Night', 'Carousel Horse', 'Ferris Wheel', 'Roller Coaster', 'Locomotive', 'Railway Car', 'High-Speed Train', 'Bullet Train', 'Train', 'Metro', 'Light Rail', 'Station', 'Tram', 'Monorail', 'Mountain Railway', 'Tram Car', 'Bus', 'Oncoming Bus', 'Trolleybus', 'Minibus', 'Ambulance', 'Fire Engine', 'Police Car', 'Oncoming Police Car', 'Taxi', 'Oncoming Taxi', 'Automobile', 'Oncoming Automobile', 'Delivery Truck', 'Articulated Lorry', 'Tractor', 'Racing Car', 'Motorcycle', 'Motor Scooter', 'Bicycle', 'Kick Scooter', 'Bus Stop', 'Railway Track', 'Fuel Pump', 'Police Car Light', 'Horizontal Traffic Light', 'Vertical Traffic Light', 'Construction', 'Anchor', 'Sailboat', 'Speedboat', 'Passenger Ship', 'Ferry', 'Motor Boat', 'Ship', 'Airplane', 'Small Airplane', 'Airplane Departure', 'Airplane Arrival', 'Seat', 'Helicopter', 'Suspension Railway', 'Mountain Cableway', 'Aerial Tramway', 'Satellite', 'Rocket', 'Flying Saucer', 'Shooting Star', 'Milky Way', 'Umbrella on Ground', 'Fireworks', 'Sparkler', 'Moon Viewing Ceremony', 'Yen Banknote', 'Dollar Banknote', 'Euro Banknote', 'Pound Banknote', 'Moai', 'Passport Control', 'Customs', 'Baggage Claim', 'Left Luggage', ], [ '🚣', '🗾', '🏔', '⛰', '🌋', '🗻', '🏕', '🏖', '🏜', '🏝', '🏞', '🏟', '🏛', '🏗', '🏘', '🏚', '🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨', '🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '💒', '🗼', '🗽', '⛪', '🕌', '🕍', '⛩', '🕋', '⛲', '⛺', '🌁', '🌃', '🏙', '🌄', '🌅', '🌆', '🌇', '🌉', '🎠', '🎡', '🎢', '🚂', '🚃', '🚄', '🚅', '🚆', '🚇', '🚈', '🚉', '🚊', '🚝', '🚞', '🚋', '🚌', '🚍', '🚎', '🚐', '🚑', '🚒', '🚓', '🚔', '🚕', '🚖', '🚗', '🚘', '🚚', '🚛', '🚜', '🏎', '🏍', '🛵', '🚲', '🛴', '🚏', '🛤', '⛽', '🚨', '🚥', '🚦', '🚧', '⚓', '⛵', '🚤', '🛳', '⛴', '🛥', '🚢', '✈', '🛩', '🛫', '🛬', '💺', '🚁', '🚟', '🚠', '🚡', '🛰', '🚀', '🛸', '🌠', '🌌', '⛱', '🎆', '🎇', '🎑', '💴', '💵', '💶', '💷', '🗿', '🛂', '🛃', '🛄', '🛅', ]); /// Map of all possible emojis along with their names in [Category.ACTIVITIES] final Map activities = Map.fromIterables([ 'Man in Suit Levitating', 'Man Climbing', 'Woman Climbing', 'Horse Racing', 'Skier', 'Snowboarder', 'Man Golfing', 'Woman Golfing', 'Man Surfing', 'Woman Surfing', 'Man Rowing Boat', 'Woman Rowing Boat', 'Man Swimming', 'Woman Swimming', 'Man Bouncing Ball', 'Woman Bouncing Ball', 'Man Lifting Weights', 'Woman Lifting Weights', 'Man Biking', 'Woman Biking', 'Man Mountain Biking', 'Woman Mountain Biking', 'Man Cartwheeling', 'Woman Cartwheeling', 'Men Wrestling', 'Women Wrestling', 'Man Playing Water Polo', 'Woman Playing Water Polo', 'Man Playing Handball', 'Woman Playing Handball', 'Man Juggling', 'Woman Juggling', 'Man in Lotus Position', 'Woman in Lotus Position', 'Circus Tent', 'Skateboard', 'Reminder Ribbon', 'Admission Tickets', 'Ticket', 'Military Medal', 'Trophy', 'Sports Medal', '1st Place Medal', '2nd Place Medal', '3rd Place Medal', 'Soccer Ball', 'Baseball', 'Softball', 'Basketball', 'Volleyball', 'American Football', 'Rugby Football', 'Tennis', 'Flying Disc', 'Bowling', 'Cricket Game', 'FieldPB Hockey', 'Ice Hockey', 'Lacrosse', 'Ping Pong', 'Badminton', 'Boxing Glove', 'Martial Arts Uniform', 'Flag in Hole', 'Ice Skate', 'Fishing Pole', 'Running Shirt', 'Skis', 'Sled', 'Curling Stone', 'Direct Hit', 'Pool 8 Ball', 'Video Game', 'Slot Machine', 'Game Die', 'Jigsaw', 'Chess Pawn', 'Performing Arts', 'Artist Palette', 'Thread', 'Yarn', 'Musical Score', 'Microphone', 'Headphone', 'Saxophone', 'Guitar', 'Musical Keyboard', 'Trumpet', 'Violin', 'Drum', 'Clapper Board', 'Bow and Arrow', ], [ '🕴', '🧗', '🧗', '🏇', '⛷', '🏂', '🏌️', '🏌️', '🏄', '🏄', '🚣', '🚣', '🏊', '🏊', '⛹️', '⛹️', '🏋️', '🏋️', '🚴', '🚴', '🚵', '🚵', '🤸', '🤸', '🤼', '🤼', '🤽', '🤽', '🤾', '🤾', '🤹', '🤹', '🧘🏻‍♂️', '🧘🏻‍♀️', '🎪', '🛹', '🎗', '🎟', '🎫', '🎖', '🏆', '🏅', '🥇', '🥈', '🥉', '⚽', '⚾', '🥎', '🏀', '🏐', '🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '⛳', '⛸', '🎣', '🎽', '🎿', '🛷', '🥌', '🎯', '🎱', '🎮', '🎰', '🎲', '🧩', '♟', '🎭', '🎨', '🧵', '🧶', '🎼', '🎤', '🎧', '🎷', '🎸', '🎹', '🎺', '🎻', '🥁', '🎬', '🏹', ]); /// Map of all possible emojis along with their names in [Category.OBJECTS] final Map objects = Map.fromIterables([ 'Love Letter', 'Hole', 'Bomb', 'Person Taking Bath', 'Person in Bed', 'Kitchen Knife', 'Amphora', 'World Map', 'Compass', 'Brick', 'Barber Pole', 'Oil Drum', 'Bellhop Bell', 'Luggage', 'Hourglass Done', 'Hourglass Not Done', 'Watch', 'Alarm Clock', 'Stopwatch', 'Timer Clock', 'Mantelpiece Clock', 'Thermometer', 'Umbrella on Ground', 'Firecracker', 'Balloon', 'Party Popper', 'Confetti Ball', 'Japanese Dolls', 'Carp Streamer', 'Wind Chime', 'Red Envelope', 'Ribbon', 'Wrapped Gift', 'Crystal Ball', 'Nazar Amulet', 'Joystick', 'Teddy Bear', 'Framed Picture', 'Thread', 'Yarn', 'Shopping Bags', 'Prayer Beads', 'Gem Stone', 'Postal Horn', 'Studio Microphone', 'Level Slider', 'Control Knobs', 'Radio', 'Mobile Phone', 'Mobile Phone With Arrow', 'Telephone', 'Telephone Receiver', 'Pager', 'Fax Machine', 'Battery', 'Electric Plug', 'Laptop Computer', 'Desktop Computer', 'Printer', 'Keyboard', 'Computer Mouse', 'Trackball', 'Computer Disk', 'Floppy Disk', 'Optical Disk', 'DVD', 'Abacus', 'Movie Camera', 'Film Frames', 'Film Projector', 'Television', 'Camera', 'Camera With Flash', 'Video Camera', 'Videocassette', 'Magnifying Glass Tilted Left', 'Magnifying Glass Tilted Right', 'Candle', 'Light Bulb', 'Flashlight', 'Red Paper Lantern', 'Notebook With Decorative Cover', 'Closed Book', 'Open Book', 'Green Book', 'Blue Book', 'Orange Book', 'Books', 'Notebook', 'Page With Curl', 'Scroll', 'Page Facing Up', 'Newspaper', 'Rolled-Up Newspaper', 'Bookmark Tabs', 'Bookmark', 'Label', 'Money Bag', 'Yen Banknote', 'Dollar Banknote', 'Euro Banknote', 'Pound Banknote', 'Money With Wings', 'Credit Card', 'Receipt', 'Envelope', 'E-Mail', 'Incoming Envelope', 'Envelope With Arrow', 'Outbox Tray', 'Inbox Tray', 'Package', 'Closed Mailbox With Raised Flag', 'Closed Mailbox With Lowered Flag', 'Open Mailbox With Raised Flag', 'Open Mailbox With Lowered Flag', 'Postbox', 'Ballot Box With Ballot', 'Pencil', 'Black Nib', 'Fountain Pen', 'Pen', 'Paintbrush', 'Crayon', 'Memo', 'File Folder', 'Open File Folder', 'Card Index Dividers', 'Calendar', 'Tear-Off Calendar', 'Spiral Notepad', 'Spiral Calendar', 'Card Index', 'Chart Increasing', 'Chart Decreasing', 'Bar Chart', 'Clipboard', 'Pushpin', 'Round Pushpin', 'Paperclip', 'Linked Paperclips', 'Straight Ruler', 'Triangular Ruler', 'Scissors', 'Card File Box', 'File Cabinet', 'Wastebasket', 'Locked', 'Unlocked', 'Locked With Pen', 'Locked With Key', 'Key', 'Old Key', 'Hammer', 'Pick', 'Hammer and Pick', 'Hammer and Wrench', 'Dagger', 'Crossed Swords', 'Pistol', 'Shield', 'Wrench', 'Nut and Bolt', 'Gear', 'Clamp', 'Balance Scale', 'Link', 'Chains', 'Toolbox', 'Magnet', 'Alembic', 'Test Tube', 'Petri Dish', 'DNA', 'Microscope', 'Telescope', 'Satellite Antenna', 'Syringe', 'Pill', 'Door', 'Bed', 'Couch and Lamp', 'Toilet', 'Shower', 'Bathtub', 'Lotion Bottle', 'Safety Pin', 'Broom', 'Basket', 'Roll of Paper', 'Soap', 'Sponge', 'Fire Extinguisher', 'Cigarette', 'Coffin', 'Funeral Urn', 'Moai', 'Potable Water', ], [ '💌', '🕳', '💣', '🛀', '🛌', '🔪', '🏺', '🗺', '🧭', '🧱', '💈', '🛢', '🛎', '🧳', '⌛', '⏳', '⌚', '⏰', '⏱', '⏲', '🕰', '🌡', '⛱', '🧨', '🎈', '🎉', '🎊', '🎎', '🎏', '🎐', '🧧', '🎀', '🎁', '🔮', '🧿', '🕹', '🧸', '🖼', '🧵', '🧶', '🛍', '📿', '💎', '📯', '🎙', '🎚', '🎛', '📻', '📱', '📲', '☎', '📞', '📟', '📠', '🔋', '🔌', '💻', '🖥', '🖨', '⌨', '🖱', '🖲', '💽', '💾', '💿', '📀', '🧮', '🎥', '🎞', '📽', '📺', '📷', '📸', '📹', '📼', '🔍', '🔎', '🕯', '💡', '🔦', '🏮', '📔', '📕', '📖', '📗', '📘', '📙', '📚', '📓', '📃', '📜', '📄', '📰', '🗞', '📑', '🔖', '🏷', '💰', '💴', '💵', '💶', '💷', '💸', '💳', '🧾', '✉', '📧', '📨', '📩', '📤', '📥', '📦', '📫', '📪', '📬', '📭', '📮', '🗳', '✏', '✒', '🖋', '🖊', '🖌', '🖍', '📝', '📁', '📂', '🗂', '📅', '📆', '🗒', '🗓', '📇', '📈', '📉', '📊', '📋', '📌', '📍', '📎', '🖇', '📏', '📐', '✂', '🗃', '🗄', '🗑', '🔒', '🔓', '🔏', '🔐', '🔑', '🗝', '🔨', '⛏', '⚒', '🛠', '🗡', '⚔', '🔫', '🛡', '🔧', '🔩', '⚙', '🗜', '⚖', '🔗', '⛓', '🧰', '🧲', '⚗', '🧪', '🧫', '🧬', '🔬', '🔭', '📡', '💉', '💊', '🚪', '🛏', '🛋', '🚽', '🚿', '🛁', '🧴', '🧷', '🧹', '🧺', '🧻', '🧼', '🧽', '🧯', '🚬', '⚰', '⚱', '🗿', '🚰', ]); /// Map of all possible emojis along with their names in [Category.SYMBOLS] final Map symbols = Map.fromIterables([ 'Heart With Arrow', 'Heart With Ribbon', 'Sparkling Heart', 'Growing Heart', 'Beating Heart', 'Revolving Hearts', 'Two Hearts', 'Heart Decoration', 'Heavy Heart Exclamation', 'Broken Heart', 'Red Heart', 'Orange Heart', 'Yellow Heart', 'Green Heart', 'Blue Heart', 'Purple Heart', 'Black Heart', 'Hundred Points', 'Anger Symbol', 'Speech Balloon', 'Eye in Speech Bubble', 'Right Anger Bubble', 'Thought Balloon', 'Zzz', 'White Flower', 'Hot Springs', 'Barber Pole', 'Stop Sign', 'Twelve O’Clock', 'Twelve-Thirty', 'One O’Clock', 'One-Thirty', 'Two O’Clock', 'Two-Thirty', 'Three O’Clock', 'Three-Thirty', 'Four O’Clock', 'Four-Thirty', 'Five O’Clock', 'Five-Thirty', 'Six O’Clock', 'Six-Thirty', 'Seven O’Clock', 'Seven-Thirty', 'Eight O’Clock', 'Eight-Thirty', 'Nine O’Clock', 'Nine-Thirty', 'Ten O’Clock', 'Ten-Thirty', 'Eleven O’Clock', 'Eleven-Thirty', 'Cyclone', 'Spade Suit', 'Heart Suit', 'Diamond Suit', 'Club Suit', 'Joker', 'Mahjong Red Dragon', 'Flower Playing Cards', 'Muted Speaker', 'Speaker Low Volume', 'Speaker Medium Volume', 'Speaker High Volume', 'Loudspeaker', 'Megaphone', 'Postal Horn', 'Bell', 'Bell With Slash', 'Musical Note', 'Musical Notes', 'ATM Sign', 'Litter in Bin Sign', 'Potable Water', 'Wheelchair Symbol', 'Men’s Room', 'Women’s Room', 'Restroom', 'Baby Symbol', 'Water Closet', 'Warning', 'Children Crossing', 'No Entry', 'Prohibited', 'No Bicycles', 'No Smoking', 'No Littering', 'Non-Potable Water', 'No Pedestrians', 'No One Under Eighteen', 'Radioactive', 'Biohazard', 'Up Arrow', 'Up-Right Arrow', 'Right Arrow', 'Down-Right Arrow', 'Down Arrow', 'Down-Left Arrow', 'Left Arrow', 'Up-Left Arrow', 'Up-Down Arrow', 'Left-Right Arrow', 'Right Arrow Curving Left', 'Left Arrow Curving Right', 'Right Arrow Curving Up', 'Right Arrow Curving Down', 'Clockwise Vertical Arrows', 'Counterclockwise Arrows Button', 'Back Arrow', 'End Arrow', 'On! Arrow', 'Soon Arrow', 'Top Arrow', 'Place of Worship', 'Atom Symbol', 'Om', 'Star of David', 'Wheel of Dharma', 'Yin Yang', 'Latin Cross', 'Orthodox Cross', 'Star and Crescent', 'Peace Symbol', 'Menorah', 'Dotted Six-Pointed Star', 'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces', 'Ophiuchus', 'Shuffle Tracks Button', 'Repeat Button', 'Repeat Single Button', 'Play Button', 'Fast-Forward Button', 'Reverse Button', 'Fast Reverse Button', 'Upwards Button', 'Fast Up Button', 'Downwards Button', 'Fast Down Button', 'Stop Button', 'Eject Button', 'Cinema', 'Dim Button', 'Bright Button', 'Antenna Bars', 'Vibration Mode', 'Mobile Phone Off', 'Infinity', 'Recycling Symbol', 'Trident Emblem', 'Name Badge', 'Japanese Symbol for Beginner', 'Heavy Large Circle', 'White Heavy Check Mark', 'Ballot Box With Check', 'Heavy Check Mark', 'Heavy Multiplication X', 'Cross Mark', 'Cross Mark Button', 'Heavy Plus Sign', 'Heavy Minus Sign', 'Heavy Division Sign', 'Curly Loop', 'Double Curly Loop', 'Part Alternation Mark', 'Eight-Spoked Asterisk', 'Eight-Pointed Star', 'Sparkle', 'Double Exclamation Mark', 'Exclamation Question Mark', 'Question Mark', 'White Question Mark', 'White Exclamation Mark', 'Exclamation Mark', 'Copyright', 'Registered', 'Trade Mark', 'Keycap Number Sign', 'Keycap Digit Zero', 'Keycap Digit One', 'Keycap Digit Two', 'Keycap Digit Three', 'Keycap Digit Four', 'Keycap Digit Five', 'Keycap Digit Six', 'Keycap Digit Seven', 'Keycap Digit Eight', 'Keycap Digit Nine', 'Keycap: 10', 'Input Latin Uppercase', 'Input Latin Lowercase', 'Input Numbers', 'Input Symbols', 'Input Latin Letters', 'A Button (Blood Type)', 'AB Button (Blood Type)', 'B Button (Blood Type)', 'CL Button', 'Cool Button', 'Free Button', 'Information', 'ID Button', 'Circled M', 'New Button', 'NG Button', 'O Button (Blood Type)', 'OK Button', 'P Button', 'SOS Button', 'Up! Button', 'Vs Button', 'Japanese “Here” Button', 'Japanese “Service Charge” Button', 'Japanese “Monthly Amount” Button', 'Japanese “Not Free of Charge” Button', 'Japanese “Reserved” Button', 'Japanese “Bargain” Button', 'Japanese “Discount” Button', 'Japanese “Free of Charge” Button', 'Japanese “Prohibited” Button', 'Japanese “Acceptable” Button', 'Japanese “Application” Button', 'Japanese “Passing Grade” Button', 'Japanese “Vacancy” Button', 'Japanese “Congratulations” Button', 'Japanese “Secret” Button', 'Japanese “Open for Business” Button', 'Japanese “No Vacancy” Button', 'Red Circle', 'Blue Circle', 'Black Circle', 'White Circle', 'Black Large Square', 'White Large Square', 'Black Medium Square', 'White Medium Square', 'Black Medium-Small Square', 'White Medium-Small Square', 'Black Small Square', 'White Small Square', 'Large Orange Diamond', 'Large Blue Diamond', 'Small Orange Diamond', 'Small Blue Diamond', 'Red Triangle Pointed Up', 'Red Triangle Pointed Down', 'Diamond With a Dot', 'White Square Button', 'Black Square Button', ], [ '💘', '💝', '💖', '💗', '💓', '💞', '💕', '💟', '❣', '💔', '❤', '🧡', '💛', '💚', '💙', '💜', '🖤', '💯', '💢', '💬', '👁️‍🗨️', '🗯', '💭', '💤', '💮', '♨', '💈', '🛑', '🕛', '🕧', '🕐', '🕜', '🕑', '🕝', '🕒', '🕞', '🕓', '🕟', '🕔', '🕠', '🕕', '🕡', '🕖', '🕢', '🕗', '🕣', '🕘', '🕤', '🕙', '🕥', '🕚', '🕦', '🌀', '♠', '♥', '♦', '♣', '🃏', '🀄', '🎴', '🔇', '🔈', '🔉', '🔊', '📢', '📣', '📯', '🔔', '🔕', '🎵', '🎶', '🏧', '🚮', '🚰', '♿', '🚹', '🚺', '🚻', '🚼', '🚾', '⚠', '🚸', '⛔', '🚫', '🚳', '🚭', '🚯', '🚱', '🚷', '🔞', '☢', '☣', '⬆', '↗', '➡', '↘', '⬇', '↙', '⬅', '↖', '↕', '↔', '↩', '↪', '⤴', '⤵', '🔃', '🔄', '🔙', '🔚', '🔛', '🔜', '🔝', '🛐', '⚛', '🕉', '✡', '☸', '☯', '✝', '☦', '☪', '☮', '🕎', '🔯', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '⛎', '🔀', '🔁', '🔂', '▶', '⏩', '◀', '⏪', '🔼', '⏫', '🔽', '⏬', '⏹', '⏏', '🎦', '🔅', '🔆', '📶', '📳', '📴', '♾', '♻', '🔱', '📛', '🔰', '⭕', '✅', '☑', '✔', '✖', '❌', '❎', '➕', '➖', '➗', '➰', '➿', '〽', '✳', '✴', '❇', '‼', '⁉', '❓', '❔', '❕', '❗', '©', '®', '™', '#️⃣', '0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟', '🔠', '🔡', '🔢', '🔣', '🔤', '🅰', '🆎', '🅱', '🆑', '🆒', '🆓', 'ℹ', '🆔', 'Ⓜ', '🆕', '🆖', '🅾', '🆗', '🅿', '🆘', '🆙', '🆚', '🈁', '🈂', '🈷', '🈶', '🈯', '🉐', '🈹', '🈚', '🈲', '🉑', '🈸', '🈴', '🈳', '㊗', '㊙', '🈺', '🈵', '🔴', '🔵', '⚫', '⚪', '⬛', '⬜', '◼', '◻', '◾', '◽', '▪', '▫', '🔶', '🔷', '🔸', '🔹', '🔺', '🔻', '💠', '🔳', '🔲', ]); /// Map of all possible emojis along with their names in [Category.FLAGS] final Map flags = Map.fromIterables([ 'Chequered Flag', 'Triangular Flag', 'Crossed Flags', 'Black Flag', 'White Flag', 'Rainbow Flag', 'Pirate Flag', 'Flag: Ascension Island', 'Flag: Andorra', 'Flag: United Arab Emirates', 'Flag: Afghanistan', 'Flag: Antigua & Barbuda', 'Flag: Anguilla', 'Flag: Albania', 'Flag: Armenia', 'Flag: Angola', 'Flag: Antarctica', 'Flag: argentina', 'Flag: American Samoa', 'Flag: Austria', 'Flag: Australia', 'Flag: Aruba', 'Flag: Åland Islands', 'Flag: Azerbaijan', 'Flag: Bosnia & Herzegovina', 'Flag: Barbados', 'Flag: Bangladesh', 'Flag: Belgium', 'Flag: Burkina Faso', 'Flag: Bulgaria', 'Flag: Bahrain', 'Flag: Burundi', 'Flag: Benin', 'Flag: St. Barthélemy', 'Flag: Bermuda', 'Flag: Brunei', 'Flag: Bolivia', 'Flag: Caribbean Netherlands', 'Flag: Brazil', 'Flag: Bahamas', 'Flag: Bhutan', 'Flag: Bouvet Island', 'Flag: Botswana', 'Flag: Belarus', 'Flag: Belize', 'Flag: Canada', 'Flag: Cocos (Keeling) Islands', 'Flag: Congo - Kinshasa', 'Flag: Central African Republic', 'Flag: Congo - Brazzaville', 'Flag: Switzerland', 'Flag: Côte d’Ivoire', 'Flag: Cook Islands', 'Flag: Chile', 'Flag: Cameroon', 'Flag: China', 'Flag: Colombia', 'Flag: Clipperton Island', 'Flag: Costa Rica', 'Flag: Cuba', 'Flag: Cape Verde', 'Flag: Curaçao', 'Flag: Christmas Island', 'Flag: Cyprus', 'Flag: Czechia', 'Flag: Germany', 'Flag: Diego Garcia', 'Flag: Djibouti', 'Flag: Denmark', 'Flag: Dominica', 'Flag: Dominican Republic', 'Flag: Algeria', 'Flag: Ceuta & Melilla', 'Flag: Ecuador', 'Flag: Estonia', 'Flag: Egypt', 'Flag: Western Sahara', 'Flag: Eritrea', 'Flag: Spain', 'Flag: Ethiopia', 'Flag: European Union', 'Flag: Finland', 'Flag: Fiji', 'Flag: Falkland Islands', 'Flag: Micronesia', 'Flag: Faroe Islands', 'Flag: france', 'Flag: Gabon', 'Flag: United Kingdom', 'Flag: Grenada', 'Flag: Georgia', 'Flag: French Guiana', 'Flag: Guernsey', 'Flag: Ghana', 'Flag: Gibraltar', 'Flag: Greenland', 'Flag: Gambia', 'Flag: Guinea', 'Flag: Guadeloupe', 'Flag: Equatorial Guinea', 'Flag: Greece', 'Flag: South Georgia & South Sandwich Islands', 'Flag: Guatemala', 'Flag: Guam', 'Flag: Guinea-Bissau', 'Flag: Guyana', 'Flag: Hong Kong SAR China', 'Flag: Heard & McDonald Islands', 'Flag: Honduras', 'Flag: Croatia', 'Flag: Haiti', 'Flag: Hungary', 'Flag: Canary Islands', 'Flag: Indonesia', 'Flag: Ireland', 'Flag: Israel', 'Flag: Isle of Man', 'Flag: India', 'Flag: British Indian Ocean Territory', 'Flag: Iraq', 'Flag: Iran', 'Flag: Iceland', 'Flag: Italy', 'Flag: Jersey', 'Flag: Jamaica', 'Flag: Jordan', 'Flag: Japan', 'Flag: Kenya', 'Flag: Kyrgyzstan', 'Flag: Cambodia', 'Flag: Kiribati', 'Flag: Comoros', 'Flag: St. Kitts & Nevis', 'Flag: North Korea', 'Flag: South Korea', 'Flag: Kuwait', 'Flag: Cayman Islands', 'Flag: Kazakhstan', 'Flag: Laos', 'Flag: Lebanon', 'Flag: St. Lucia', 'Flag: Liechtenstein', 'Flag: Sri Lanka', 'Flag: Liberia', 'Flag: Lesotho', 'Flag: Lithuania', 'Flag: Luxembourg', 'Flag: Latvia', 'Flag: Libya', 'Flag: Morocco', 'Flag: Monaco', 'Flag: Moldova', 'Flag: Montenegro', 'Flag: St. Martin', 'Flag: Madagascar', 'Flag: Marshall Islands', 'Flag: North Macedonia', 'Flag: Mali', 'Flag: Myanmar (Burma)', 'Flag: Mongolia', 'Flag: Macau Sar China', 'Flag: Northern Mariana Islands', 'Flag: Martinique', 'Flag: Mauritania', 'Flag: Montserrat', 'Flag: Malta', 'Flag: Mauritius', 'Flag: Maldives', 'Flag: Malawi', 'Flag: Mexico', 'Flag: Malaysia', 'Flag: Mozambique', 'Flag: Namibia', 'Flag: New Caledonia', 'Flag: Niger', 'Flag: Norfolk Island', 'Flag: Nigeria', 'Flag: Nicaragua', 'Flag: Netherlands', 'Flag: Norway', 'Flag: Nepal', 'Flag: Nauru', 'Flag: Niue', 'Flag: New Zealand', 'Flag: Oman', 'Flag: Panama', 'Flag: Peru', 'Flag: French Polynesia', 'Flag: Papua New Guinea', 'Flag: Philippines', 'Flag: Pakistan', 'Flag: Poland', 'Flag: St. Pierre & Miquelon', 'Flag: Pitcairn Islands', 'Flag: Puerto Rico', 'Flag: Palestinian Territories', 'Flag: Portugal', 'Flag: Palau', 'Flag: Paraguay', 'Flag: Qatar', 'Flag: Réunion', 'Flag: Romania', 'Flag: Serbia', 'Flag: Russia', 'Flag: Rwanda', 'Flag: Saudi Arabia', 'Flag: Solomon Islands', 'Flag: Seychelles', 'Flag: Sudan', 'Flag: Sweden', 'Flag: Singapore', 'Flag: St. Helena', 'Flag: Slovenia', 'Flag: Svalbard & Jan Mayen', 'Flag: Slovakia', 'Flag: Sierra Leone', 'Flag: San Marino', 'Flag: Senegal', 'Flag: Somalia', 'Flag: Suriname', 'Flag: South Sudan', 'Flag: São Tomé & Príncipe', 'Flag: El Salvador', 'Flag: Sint Maarten', 'Flag: Syria', 'Flag: Swaziland', 'Flag: Tristan Da Cunha', 'Flag: Turks & Caicos Islands', 'Flag: Chad', 'Flag: French Southern Territories', 'Flag: Togo', 'Flag: Thailand', 'Flag: Tajikistan', 'Flag: Tokelau', 'Flag: Timor-Leste', 'Flag: Turkmenistan', 'Flag: Tunisia', 'Flag: Tonga', 'Flag: Turkey', 'Flag: Trinidad & Tobago', 'Flag: Tuvalu', 'Flag: Taiwan', 'Flag: Tanzania', 'Flag: Ukraine', 'Flag: Uganda', 'Flag: U.S. Outlying Islands', 'Flag: United Nations', 'Flag: United States', 'Flag: Uruguay', 'Flag: Uzbekistan', 'Flag: Vatican City', 'Flag: St. Vincent & Grenadines', 'Flag: Venezuela', 'Flag: British Virgin Islands', 'Flag: U.S. Virgin Islands', 'Flag: Vietnam', 'Flag: Vanuatu', 'Flag: Wallis & Futuna', 'Flag: Samoa', 'Flag: Kosovo', 'Flag: Yemen', 'Flag: Mayotte', 'Flag: South Africa', 'Flag: Zambia', 'Flag: Zimbabwe', ], [ '🏁', '🚩', '🎌', '🏴', '🏳', '🏳️‍🌈', '🏴‍☠️', '🇦🇨', '🇦🇩', '🇦🇪', '🇦🇫', '🇦🇬', '🇦🇮', '🇦🇱', '🇦🇲', '🇦🇴', '🇦🇶', '🇦🇷', '🇦🇸', '🇦🇹', '🇦🇺', '🇦🇼', '🇦🇽', '🇦🇿', '🇧🇦', '🇧🇧', '🇧🇩', '🇧🇪', '🇧🇫', '🇧🇬', '🇧🇭', '🇧🇮', '🇧🇯', '🇧🇱', '🇧🇲', '🇧🇳', '🇧🇴', '🇧🇶', '🇧🇷', '🇧🇸', '🇧🇹', '🇧🇻', '🇧🇼', '🇧🇾', '🇧🇿', '🇨🇦', '🇨🇨', '🇨🇩', '🇨🇫', '🇨🇬', '🇨🇭', '🇨🇮', '🇨🇰', '🇨🇱', '🇨🇲', '🇨🇳', '🇨🇴', '🇨🇵', '🇨🇷', '🇨🇺', '🇨🇻', '🇨🇼', '🇨🇽', '🇨🇾', '🇨🇿', '🇩🇪', '🇩🇬', '🇩🇯', '🇩🇰', '🇩🇲', '🇩🇴', '🇩🇿', '🇪🇦', '🇪🇨', '🇪🇪', '🇪🇬', '🇪🇭', '🇪🇷', '🇪🇸', '🇪🇹', '🇪🇺', '🇫🇮', '🇫🇯', '🇫🇰', '🇫🇲', '🇫🇴', '🇫🇷', '🇬🇦', '🇬🇧', '🇬🇩', '🇬🇪', '🇬🇫', '🇬🇬', '🇬🇭', '🇬🇮', '🇬🇱', '🇬🇲', '🇬🇳', '🇬🇵', '🇬🇶', '🇬🇷', '🇬🇸', '🇬🇹', '🇬🇺', '🇬🇼', '🇬🇾', '🇭🇰', '🇭🇲', '🇭🇳', '🇭🇷', '🇭🇹', '🇭🇺', '🇮🇨', '🇮🇩', '🇮🇪', '🇮🇱', '🇮🇲', '🇮🇳', '🇮🇴', '🇮🇶', '🇮🇷', '🇮🇸', '🇮🇹', '🇯🇪', '🇯🇲', '🇯🇴', '🇯🇵', '🇰🇪', '🇰🇬', '🇰🇭', '🇰🇮', '🇰🇲', '🇰🇳', '🇰🇵', '🇰🇷', '🇰🇼', '🇰🇾', '🇰🇿', '🇱🇦', '🇱🇧', '🇱🇨', '🇱🇮', '🇱🇰', '🇱🇷', '🇱🇸', '🇱🇹', '🇱🇺', '🇱🇻', '🇱🇾', '🇲🇦', '🇲🇨', '🇲🇩', '🇲🇪', '🇲🇫', '🇲🇬', '🇲🇭', '🇲🇰', '🇲🇱', '🇲🇲', '🇲🇳', '🇲🇴', '🇲🇵', '🇲🇶', '🇲🇷', '🇲🇸', '🇲🇹', '🇲🇺', '🇲🇻', '🇲🇼', '🇲🇽', '🇲🇾', '🇲🇿', '🇳🇦', '🇳🇨', '🇳🇪', '🇳🇫', '🇳🇬', '🇳🇮', '🇳🇱', '🇳🇴', '🇳🇵', '🇳🇷', '🇳🇺', '🇳🇿', '🇴🇲', '🇵🇦', '🇵🇪', '🇵🇫', '🇵🇬', '🇵🇭', '🇵🇰', '🇵🇱', '🇵🇲', '🇵🇳', '🇵🇷', '🇵🇸', '🇵🇹', '🇵🇼', '🇵🇾', '🇶🇦', '🇷🇪', '🇷🇴', '🇷🇸', '🇷🇺', '🇷🇼', '🇸🇦', '🇸🇧', '🇸🇨', '🇸🇩', '🇸🇪', '🇸🇬', '🇸🇭', '🇸🇮', '🇸🇯', '🇸🇰', '🇸🇱', '🇸🇲', '🇸🇳', '🇸🇴', '🇸🇷', '🇸🇸', '🇸🇹', '🇸🇻', '🇸🇽', '🇸🇾', '🇸🇿', '🇹🇦', '🇹🇨', '🇹🇩', '🇹🇫', '🇹🇬', '🇹🇭', '🇹🇯', '🇹🇰', '🇹🇱', '🇹🇲', '🇹🇳', '🇹🇴', '🇹🇷', '🇹🇹', '🇹🇻', '🇹🇼', '🇹🇿', '🇺🇦', '🇺🇬', '🇺🇲', '🇺🇳', '🇺🇸', '🇺🇾', '🇺🇿', '🇻🇦', '🇻🇨', '🇻🇪', '🇻🇬', '🇻🇮', '🇻🇳', '🇻🇺', '🇼🇫', '🇼🇸', '🇽🇰', '🇾🇪', '🇾🇹', '🇿🇦', '🇿🇲', '🇿🇼', ]); ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker.dart ================================================ // ignore_for_file: constant_identifier_names import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'models/emoji_category_models.dart'; import 'emji_picker_config.dart'; import 'default_emoji_picker_view.dart'; import 'models/emoji_model.dart'; import 'emoji_lists.dart' as emoji_list; import 'emoji_view_state.dart'; import 'models/recent_emoji_model.dart'; /// The emoji category shown on the category tab enum EmojiCategory { /// Searched emojis SEARCH, /// Recent emojis RECENT, /// Smiley emojis SMILEYS, /// Animal emojis ANIMALS, /// Food emojis FOODS, /// Activity emojis ACTIVITIES, /// Travel emojis TRAVEL, /// Objects emojis OBJECTS, /// Sumbol emojis SYMBOLS, /// Flag emojis FLAGS, } /// Enum to alter the keyboard button style enum ButtonMode { /// Android button style - gives the button a splash color with ripple effect MATERIAL, /// iOS button style - gives the button a fade out effect when pressed CUPERTINO } /// Callback function for when emoji is selected /// /// The function returns the selected [Emoji] as well /// as the [EmojiCategory] from which it originated typedef OnEmojiSelected = void Function(EmojiCategory category, Emoji emoji); /// Callback function for backspace button typedef OnBackspacePressed = void Function(); /// Callback function for custom view typedef EmojiViewBuilder = Widget Function( EmojiPickerConfig config, EmojiViewState state, ); /// The Emoji Keyboard widget /// /// This widget displays a grid of [Emoji] sorted by [EmojiCategory] /// which the user can horizontally scroll through. /// /// There is also a bottombar which displays all the possible [EmojiCategory] /// and allow the user to quickly switch to that [EmojiCategory] class EmojiPicker extends StatefulWidget { /// EmojiPicker for flutter const EmojiPicker({ super.key, required this.onEmojiSelected, this.onBackspacePressed, this.config = const EmojiPickerConfig(), this.customWidget, }); /// Custom widget final EmojiViewBuilder? customWidget; /// The function called when the emoji is selected final OnEmojiSelected onEmojiSelected; /// The function called when backspace button is pressed final OnBackspacePressed? onBackspacePressed; /// Config for customizations final EmojiPickerConfig config; @override EmojiPickerState createState() => EmojiPickerState(); } class EmojiPickerState extends State { static const platform = MethodChannel('emoji_picker_flutter'); List emojiCategoryGroupList = List.empty(growable: true); List recentEmojiList = List.empty(growable: true); late Future updateEmojiFuture; // Prevent emojis to be reloaded with every build bool loaded = false; @override void initState() { super.initState(); updateEmojiFuture = _updateEmojis(); } @override void didUpdateWidget(covariant EmojiPicker oldWidget) { if (oldWidget.config != widget.config) { // EmojiPickerConfig changed - rebuild EmojiPickerView completely loaded = false; updateEmojiFuture = _updateEmojis(); } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { if (!loaded) { // Load emojis updateEmojiFuture.then( (value) => WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() { loaded = true; }); }), ); // Show loading indicator return const Center(child: CircularProgressIndicator()); } if (widget.config.showRecentsTab) { emojiCategoryGroupList[0].emoji = recentEmojiList.map((e) => e.emoji).toList().cast(); } final state = EmojiViewState( emojiCategoryGroupList, _getOnEmojiListener(), widget.onBackspacePressed, ); // Build return widget.customWidget == null ? DefaultEmojiPickerView(widget.config, state) : widget.customWidget!(widget.config, state); } // Add recent emoji handling to tap listener OnEmojiSelected _getOnEmojiListener() { return (category, emoji) { if (widget.config.showRecentsTab) { _addEmojiToRecentlyUsed(emoji).then((value) { if (category != EmojiCategory.RECENT && mounted) { setState(() { // rebuild to update recent emoji tab // when it is not current tab }); } }); } widget.onEmojiSelected(category, emoji); }; } // Initialize emoji data Future _updateEmojis() async { emojiCategoryGroupList.clear(); if (widget.config.showRecentsTab) { recentEmojiList = await _getRecentEmojis(); final List recentEmojiMap = recentEmojiList.map((e) => e.emoji).toList().cast(); emojiCategoryGroupList .add(EmojiCategoryGroup(EmojiCategory.RECENT, recentEmojiMap)); } emojiCategoryGroupList.addAll([ EmojiCategoryGroup( EmojiCategory.SMILEYS, await _getAvailableEmojis(emoji_list.smileys, title: 'smileys'), ), EmojiCategoryGroup( EmojiCategory.ANIMALS, await _getAvailableEmojis(emoji_list.animals, title: 'animals'), ), EmojiCategoryGroup( EmojiCategory.FOODS, await _getAvailableEmojis(emoji_list.foods, title: 'foods'), ), EmojiCategoryGroup( EmojiCategory.ACTIVITIES, await _getAvailableEmojis( emoji_list.activities, title: 'activities', ), ), EmojiCategoryGroup( EmojiCategory.TRAVEL, await _getAvailableEmojis(emoji_list.travel, title: 'travel'), ), EmojiCategoryGroup( EmojiCategory.OBJECTS, await _getAvailableEmojis(emoji_list.objects, title: 'objects'), ), EmojiCategoryGroup( EmojiCategory.SYMBOLS, await _getAvailableEmojis(emoji_list.symbols, title: 'symbols'), ), EmojiCategoryGroup( EmojiCategory.FLAGS, await _getAvailableEmojis(emoji_list.flags, title: 'flags'), ), ]); } // Get available emoji for given category title Future> _getAvailableEmojis( Map map, { required String title, }) async { Map? newMap; // Get Emojis cached locally if available newMap = await _restoreFilteredEmojis(title); if (newMap == null) { // Check if emoji is available on this platform newMap = await _getPlatformAvailableEmoji(map); // Save available Emojis to local storage for faster loading next time if (newMap != null) { await _cacheFilteredEmojis(title, newMap); } } // Map to Emoji Object return newMap!.entries .map((entry) => Emoji(entry.key, entry.value)) .toList(); } // Check if emoji is available on current platform Future?> _getPlatformAvailableEmoji( Map emoji, ) async { if (Platform.isAndroid) { Map? filtered = {}; const delimiter = '|'; try { final entries = emoji.values.join(delimiter); final keys = emoji.keys.join(delimiter); final result = (await platform.invokeMethod( 'checkAvailability', {'emojiKeys': keys, 'emojiEntries': entries}, )) as String; final resultKeys = result.split(delimiter); for (var i = 0; i < resultKeys.length; i++) { filtered[resultKeys[i]] = emoji[resultKeys[i]]!; } } on PlatformException catch (_) { filtered = null; } return filtered; } else { return emoji; } } // Restore locally cached emoji Future?> _restoreFilteredEmojis(String title) async { final prefs = await SharedPreferences.getInstance(); final emojiJson = prefs.getString(title); if (emojiJson == null) { return null; } final emojis = Map.from(jsonDecode(emojiJson) as Map); return emojis; } // Stores filtered emoji locally for faster access next time Future _cacheFilteredEmojis( String title, Map emojis, ) async { final prefs = await SharedPreferences.getInstance(); final emojiJson = jsonEncode(emojis); await prefs.setString(title, emojiJson); } // Returns list of recently used emoji from cache Future> _getRecentEmojis() async { final prefs = await SharedPreferences.getInstance(); final emojiJson = prefs.getString('recent'); if (emojiJson == null) { return []; } final json = jsonDecode(emojiJson) as List; return json.map(RecentEmoji.fromJson).toList(); } // Add an emoji to recently used list or increase its counter Future _addEmojiToRecentlyUsed(Emoji emoji) async { final prefs = await SharedPreferences.getInstance(); final recentEmojiIndex = recentEmojiList .indexWhere((element) => element.emoji.emoji == emoji.emoji); if (recentEmojiIndex != -1) { // Already exist in recent list // Just update counter recentEmojiList[recentEmojiIndex].counter++; } else { recentEmojiList.add(RecentEmoji(emoji, 1)); } // Sort by counter desc recentEmojiList.sort((a, b) => b.counter - a.counter); // Limit entries to recentsLimit recentEmojiList = recentEmojiList.sublist( 0, min(widget.config.recentsLimit, recentEmojiList.length), ); // save locally await prefs.setString('recent', jsonEncode(recentEmojiList)); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker_builder.dart ================================================ import 'package:flutter/material.dart'; import 'emji_picker_config.dart'; import 'emoji_view_state.dart'; /// Template class for custom implementation /// Inherit this class to create your own EmojiPicker abstract class EmojiPickerBuilder extends StatefulWidget { /// Constructor const EmojiPickerBuilder(this.config, this.state, {super.key}); /// Config for customizations final EmojiPickerConfig config; /// State that holds current emoji data final EmojiViewState state; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_view_state.dart ================================================ import 'models/emoji_category_models.dart'; import 'emoji_picker.dart'; /// State that holds current emoji data class EmojiViewState { /// Constructor EmojiViewState( this.emojiCategoryGroupList, this.onEmojiSelected, this.onBackspacePressed, ); /// List of all categories including their emojis final List emojiCategoryGroupList; /// Callback when pressed on emoji final OnEmojiSelected onEmojiSelected; /// Callback when pressed on backspace final OnBackspacePressed? onBackspacePressed; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/flowy_emoji_picker_config.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; EmojiPickerConfig buildFlowyEmojiPickerConfig(BuildContext context) { final style = Theme.of(context); return EmojiPickerConfig( bgColor: style.cardColor, categoryIconColor: style.iconTheme.color, selectedCategoryIconColor: style.colorScheme.onSurface, selectedCategoryIconBackgroundColor: style.colorScheme.primary, progressIndicatorColor: style.colorScheme.primary, backspaceColor: style.colorScheme.primary, searchHintText: LocaleKeys.emoji_search.tr(), serachHintTextStyle: style.textTheme.bodyMedium?.copyWith( color: style.hintColor, ), serachBarEnableBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(4), borderSide: BorderSide(color: style.dividerColor), ), serachBarFocusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(4), borderSide: BorderSide( color: style.colorScheme.primary, ), ), noRecentsText: LocaleKeys.emoji_noRecent.tr(), noRecentsStyle: style.textTheme.bodyMedium, noEmojiFoundText: LocaleKeys.emoji_noEmojiFound.tr(), scrollBarHandleColor: style.colorScheme.onSurface, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/emoji_category_models.dart ================================================ import 'package:flutter/material.dart'; import 'emoji_model.dart'; import '../emoji_picker.dart'; /// EmojiCategory with its emojis class EmojiCategoryGroup { EmojiCategoryGroup(this.category, this.emoji); final EmojiCategory category; /// List of emoji of this category List emoji; @override String toString() { return 'Name: $category, Emoji: $emoji'; } } /// Class that defines the icon representing a [EmojiCategory] class EmojiCategoryIcon { /// Icon of Category const EmojiCategoryIcon({ required this.icon, this.color = const Color(0xffd3d3d3), this.selectedColor = const Color(0xffb2b2b2), }); /// The icon to represent the category final IconData icon; /// The default color of the icon final Color color; /// The color of the icon once the category is selected final Color selectedColor; } /// Class used to define all the [EmojiCategoryIcon] shown for each [EmojiCategory] /// /// This allows the keyboard to be personalized by changing icons shown. /// If a [EmojiCategoryIcon] is set as null or not defined during initialization, /// the default icons will be used instead class EmojiCategoryIcons { /// Constructor const EmojiCategoryIcons({ this.recentIcon = Icons.access_time, this.smileyIcon = Icons.tag_faces, this.animalIcon = Icons.pets, this.foodIcon = Icons.fastfood, this.activityIcon = Icons.directions_run, this.travelIcon = Icons.location_city, this.objectIcon = Icons.lightbulb_outline, this.symbolIcon = Icons.emoji_symbols, this.flagIcon = Icons.flag, this.searchIcon = Icons.search, }); /// Icon for [EmojiCategory.RECENT] final IconData recentIcon; /// Icon for [EmojiCategory.SMILEYS] final IconData smileyIcon; /// Icon for [EmojiCategory.ANIMALS] final IconData animalIcon; /// Icon for [EmojiCategory.FOODS] final IconData foodIcon; /// Icon for [EmojiCategory.ACTIVITIES] final IconData activityIcon; /// Icon for [EmojiCategory.TRAVEL] final IconData travelIcon; /// Icon for [EmojiCategory.OBJECTS] final IconData objectIcon; /// Icon for [EmojiCategory.SYMBOLS] final IconData symbolIcon; /// Icon for [EmojiCategory.FLAGS] final IconData flagIcon; /// Icon for [EmojiCategory.SEARCH] final IconData searchIcon; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/emoji_model.dart ================================================ /// A class to store data for each individual emoji class Emoji { /// Emoji constructor const Emoji(this.name, this.emoji); /// The name or description for this emoji final String name; /// The unicode string for this emoji /// /// This is the string that should be displayed to view the emoji final String emoji; @override String toString() { // return 'Name: $name, Emoji: $emoji'; return name; } /// Parse Emoji from json static Emoji fromJson(Map json) { return Emoji(json['name'] as String, json['emoji'] as String); } /// Encode Emoji to json Map toJson() { return { 'name': name, 'emoji': emoji, }; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/recent_emoji_model.dart ================================================ import 'emoji_model.dart'; /// Class that holds an recent emoji /// Recent Emoji has an instance of the emoji /// And a counter, which counts how often this emoji /// has been used before class RecentEmoji { /// Constructor RecentEmoji(this.emoji, this.counter); /// Emoji instance final Emoji emoji; /// Counter how often emoji has been used before int counter = 0; /// Parse RecentEmoji from json static RecentEmoji fromJson(dynamic json) { return RecentEmoji( Emoji.fromJson(json['emoji'] as Map), json['counter'] as int, ); } /// Encode RecentEmoji to json Map toJson() => { 'emoji': emoji, 'counter': counter, }; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; class FeatureFlagsPage extends StatelessWidget { const FeatureFlagsPage({ super.key, }); @override Widget build(BuildContext context) { return SettingsBody( title: 'Feature flags', children: [ SeparatedColumn( children: FeatureFlag.data.entries .where((e) => e.key != FeatureFlag.unknown) .map((e) => _FeatureFlagItem(featureFlag: e.key)) .toList(), ), FlowyTextButton( 'Restart the app to apply changes', fontSize: 16.0, fontColor: Colors.red, padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), onPressed: () async => runAppFlowy(), ), ], ); } } class _FeatureFlagItem extends StatefulWidget { const _FeatureFlagItem({required this.featureFlag}); final FeatureFlag featureFlag; @override State<_FeatureFlagItem> createState() => _FeatureFlagItemState(); } class _FeatureFlagItemState extends State<_FeatureFlagItem> { @override Widget build(BuildContext context) { return ListTile( title: FlowyText(widget.featureFlag.name, fontSize: 16.0), subtitle: FlowyText.small(widget.featureFlag.description, maxLines: 3), trailing: Switch.adaptive( value: widget.featureFlag.isOn, onChanged: (value) async { await widget.featureFlag.update(value); setState(() {}); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart ================================================ import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart'; import 'package:flutter/material.dart'; class FeatureFlagScreen extends StatelessWidget { const FeatureFlagScreen({ super.key, }); static const routeName = '/feature_flag'; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Feature Flags'), ), body: const FeatureFlagsPage(), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_file_exporter_widget.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:styled_widget/styled_widget.dart'; import '../../../../../generated/locale_keys.g.dart'; class SettingsExportFileWidget extends StatefulWidget { const SettingsExportFileWidget({super.key}); @override State createState() => SettingsExportFileWidgetState(); } @visibleForTesting class SettingsExportFileWidgetState extends State { @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ FlowyText.medium( LocaleKeys.settings_files_exportData.tr(), fontSize: 13, overflow: TextOverflow.ellipsis, ).padding(horizontal: 5.0), const Spacer(), _OpenExportedDirectoryButton( onTap: () async { await showDialog( context: context, builder: (context) { return const FlowyDialog( child: Padding( padding: EdgeInsets.symmetric( horizontal: 16, vertical: 20, ), child: FileExporterWidget(), ), ); }, ); }, ), ], ); } } class _OpenExportedDirectoryButton extends StatelessWidget { const _OpenExportedDirectoryButton({ required this.onTap, }); final VoidCallback onTap; @override Widget build(BuildContext context) { return FlowyIconButton( hoverColor: Theme.of(context).colorScheme.secondaryContainer, tooltipText: LocaleKeys.settings_files_export.tr(), icon: FlowySvg( FlowySvgs.open_folder_lg, color: Theme.of(context).iconTheme.color, ), onPressed: onTap, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_exporter_widget.dart ================================================ import 'dart:io'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/navigator_context_extension.dart'; import 'package:appflowy/workspace/application/export/document_exporter.dart'; import 'package:appflowy/workspace/application/settings/settings_file_exporter_cubit.dart'; import 'package:appflowy/workspace/application/settings/share/export_service.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:path/path.dart' as p; import '../../../../../generated/locale_keys.g.dart'; class FileExporterWidget extends StatefulWidget { const FileExporterWidget({super.key}); @override State createState() => _FileExporterWidgetState(); } class _FileExporterWidgetState extends State { // Map> _selectedPages = {}; SettingsFileExporterCubit? cubit; @override Widget build(BuildContext context) { return FutureBuilder>( future: FolderEventReadCurrentWorkspace().send(), builder: (context, snapshot) { if (snapshot.hasData && snapshot.connectionState == ConnectionState.done) { final workspace = snapshot.data?.fold((s) => s, (e) => null); if (workspace != null) { final views = workspace.views; cubit ??= SettingsFileExporterCubit(views: views); return BlocProvider.value( value: cubit!, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ FlowyText.medium( LocaleKeys.settings_files_selectFiles.tr(), fontSize: 16.0, ), BlocBuilder( builder: (context, state) => FlowyTextButton( state.selectedItems .expand((element) => element) .every((element) => element) ? LocaleKeys.settings_files_deselectAll.tr() : LocaleKeys.settings_files_selectAll.tr(), fontColor: AFThemeExtension.of(context).textColor, onPressed: () { context .read() .selectOrDeselectAllItems(); }, ), ), ], ), const VSpace(8), const Expanded(child: _ExpandedList()), const VSpace(8), _buildButtons(), ], ), ); } } return const CircularProgressIndicator(); }, ); } Widget _buildButtons() { return Row( children: [ const Spacer(), FlowyTextButton( LocaleKeys.button_cancel.tr(), fontColor: AFThemeExtension.of(context).textColor, onPressed: () => Navigator.of(context).pop(), ), const HSpace(8), FlowyTextButton( LocaleKeys.button_ok.tr(), fontColor: AFThemeExtension.of(context).textColor, onPressed: () async { await getIt() .getDirectoryPath() .then((exportPath) async { if (exportPath != null && cubit != null) { final views = cubit!.state.selectedViews; final result = await _AppFlowyFileExporter.exportToPath(exportPath, views); if (mounted) { if (result.$1) { // success showSnackBarMessage( context, LocaleKeys.settings_files_exportFileSuccess.tr(), ); } else { showSnackBarMessage( context, LocaleKeys.settings_files_exportFileFail.tr() + result.$2.join('\n'), ); } } } else if (mounted) { showSnackBarMessage( context, LocaleKeys.settings_files_exportFileFail.tr(), ); } if (mounted) { context.popToHome(); } }); }, ), ], ); } } class _ExpandedList extends StatefulWidget { const _ExpandedList(); // final List apps; // final void Function(Map> selectedPages) onChanged; @override State<_ExpandedList> createState() => _ExpandedListState(); } class _ExpandedListState extends State<_ExpandedList> { @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Material( color: Colors.transparent, child: SingleChildScrollView( child: Column( children: _buildChildren(context), ), ), ); }, ); } List _buildChildren(BuildContext context) { final apps = context.read().state.views; final List children = []; for (var i = 0; i < apps.length; i++) { children.add(_buildExpandedItem(context, i)); } return children; } Widget _buildExpandedItem(BuildContext context, int index) { final state = context.read().state; final apps = state.views; final expanded = state.expanded; final selectedItems = state.selectedItems; final isExpanded = expanded[index] == true; final List expandedChildren = []; if (isExpanded) { for (var i = 0; i < selectedItems[index].length; i++) { final name = apps[index].childViews[i].name; final checkbox = CheckboxListTile( value: selectedItems[index][i], onChanged: (value) { // update selected item context .read() .selectOrDeselectItem(index, i); }, title: FlowyText.regular(' $name'), ); expandedChildren.add(checkbox); } } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => context .read() .expandOrUnexpandApp(index), child: ListTile( title: FlowyText.medium(apps[index].name), trailing: Icon( isExpanded ? Icons.arrow_drop_down_rounded : Icons.arrow_drop_up_rounded, ), ), ), ...expandedChildren, ], ); } } class _AppFlowyFileExporter { static Future<(bool result, List failedNames)> exportToPath( String path, List views, ) async { final failedFileNames = []; final Map names = {}; for (final view in views) { String? content; String? fileExtension; switch (view.layout) { case ViewLayoutPB.Document: final documentExporter = DocumentExporter(view); final result = await documentExporter.export( DocumentExportType.json, ); result.fold( (json) { content = json; }, (e) => Log.error(e), ); fileExtension = 'afdocument'; break; default: final result = await BackendExportService.exportDatabaseAsCSV(view.id); result.fold( (l) => content = l.data, (r) => Log.error(r), ); fileExtension = 'csv'; break; } if (content != null) { final count = names.putIfAbsent(view.name, () => 0); final name = count == 0 ? view.name : '${view.name}($count)'; final file = File(p.join(path, '$name.$fileExtension')); await file.writeAsString(content!); names[view.name] = count + 1; } else { failedFileNames.add(view.name); } } return (failedFileNames.isEmpty, failedFileNames); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/invite_member_by_email.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; class InviteMemberByEmail extends StatefulWidget { const InviteMemberByEmail({super.key}); @override State createState() => _InviteMemberByEmailState(); } class _InviteMemberByEmailState extends State { final _emailController = TextEditingController(); @override void dispose() { _emailController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( LocaleKeys.settings_appearance_members_inviteMemberByEmail.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), ), VSpace(theme.spacing.m), Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: AFTextField( size: AFTextFieldSize.m, controller: _emailController, hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(), onSubmitted: (value) => _inviteMember(), ), ), HSpace(theme.spacing.l), AFFilledTextButton.primary( text: LocaleKeys.settings_appearance_members_sendInvite.tr(), onTap: _inviteMember, ), ], ), ], ); } void _inviteMember() { final email = _emailController.text; if (!isEmail(email)) { showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), ); return; } context .read() .add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email)); // clear the email field after inviting _emailController.clear(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/invite_member_by_link.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class InviteMemberByLink extends StatelessWidget { const InviteMemberByLink({super.key}); @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _Title(), _Description(), ], ), ), _CopyLinkButton(), ], ); } } class _Title extends StatelessWidget { const _Title(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Text( LocaleKeys.settings_appearance_members_inviteLinkToAddMember.tr(), style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ); } } class _Description extends StatelessWidget { const _Description(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Text.rich( TextSpan( children: [ TextSpan( text: LocaleKeys.settings_appearance_members_clickToCopyLink.tr(), style: theme.textStyle.caption.standard( color: theme.textColorScheme.primary, ), ), TextSpan( text: ' ${LocaleKeys.settings_appearance_members_or.tr()} ', style: theme.textStyle.caption.standard( color: theme.textColorScheme.primary, ), ), TextSpan( text: LocaleKeys.settings_appearance_members_generateANewLink.tr(), style: theme.textStyle.caption.standard( color: theme.textColorScheme.action, ), mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = () => _onGenerateInviteLink(context), ), ], ), ); } Future _onGenerateInviteLink(BuildContext context) async { final state = context.read().state; final subscriptionInfo = state.subscriptionInfo; final inviteLink = state.inviteLink; // check the current workspace member count, if it exceed the limit, show a upgrade dialog. // prevent hard code here, because the member count may exceed the limit after the invite link is generated. if (inviteLink == null && subscriptionInfo?.plan == WorkspacePlanPB.FreePlan && state.members.length >= 2) { await showConfirmDialog( context: context, title: LocaleKeys.settings_appearance_members_inviteFailedDialogTitle.tr(), description: LocaleKeys.settings_appearance_members_inviteFailedMemberLimit.tr(), confirmLabel: LocaleKeys.upgradePlanModal_actionButton.tr(), onConfirm: (_) => context .read() .add(const WorkspaceMemberEvent.upgradePlan()), ); return; } if (inviteLink != null) { // show a dialog to confirm if the user wants to copy the link to the clipboard await showConfirmDialog( context: context, style: ConfirmPopupStyle.cancelAndOk, title: LocaleKeys.settings_appearance_members_resetInviteLink.tr(), description: LocaleKeys .settings_appearance_members_resetInviteLinkDescription .tr(), confirmLabel: LocaleKeys.settings_appearance_members_reset.tr(), onConfirm: (_) { context.read().add( const WorkspaceMemberEvent.generateInviteLink(), ); }, confirmButtonBuilder: (_) => AFFilledTextButton.destructive( text: LocaleKeys.settings_appearance_members_reset.tr(), onTap: () { context.read().add( const WorkspaceMemberEvent.generateInviteLink(), ); Navigator.of(context).pop(); }, ), ); } else { context.read().add( const WorkspaceMemberEvent.generateInviteLink(), ); } } } class _CopyLinkButton extends StatefulWidget { const _CopyLinkButton(); @override State<_CopyLinkButton> createState() => _CopyLinkButtonState(); } class _CopyLinkButtonState extends State<_CopyLinkButton> { ToastificationItem? toastificationItem; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFOutlinedTextButton.normal( text: LocaleKeys.settings_appearance_members_copyLink.tr(), textStyle: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), padding: EdgeInsets.symmetric( horizontal: theme.spacing.l, vertical: theme.spacing.s, ), onTap: () async { final state = context.read().state; final subscriptionInfo = state.subscriptionInfo; // check the current workspace member count, if it exceed the limit, show a upgrade dialog. // prevent hard code here, because the member count may exceed the limit after the invite link is generated. if (subscriptionInfo?.plan == WorkspacePlanPB.FreePlan && state.members.length >= 2) { await showConfirmDialog( context: context, title: LocaleKeys .settings_appearance_members_inviteFailedDialogTitle .tr(), description: LocaleKeys .settings_appearance_members_inviteFailedMemberLimit .tr(), confirmLabel: LocaleKeys.upgradePlanModal_actionButton.tr(), onConfirm: (_) => context .read() .add(const WorkspaceMemberEvent.upgradePlan()), ); return; } final link = state.inviteLink; if (link != null) { await getIt().setData( ClipboardServiceData( plainText: link, ), ); if (toastificationItem != null) { toastification.dismiss(toastificationItem!); } toastificationItem = showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } else { showToastNotification( message: LocaleKeys.settings_appearance_members_noInviteLink.tr(), type: ToastificationType.error, ); } }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_email.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; class MInviteMemberByEmail extends StatefulWidget { const MInviteMemberByEmail({super.key}); @override State createState() => _MInviteMemberByEmailState(); } class _MInviteMemberByEmailState extends State { final _emailController = TextEditingController(); bool _isInviteButtonEnabled = false; @override void initState() { super.initState(); _emailController.addListener(_onEmailChanged); } @override void dispose() { _emailController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AFTextField( autoFocus: true, controller: _emailController, hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(), onSubmitted: (value) => _inviteMember(), ), VSpace(theme.spacing.m), _isInviteButtonEnabled ? AFFilledTextButton.primary( text: 'Send invite', alignment: Alignment.center, size: AFButtonSize.l, textStyle: theme.textStyle.heading4.enhanced( color: theme.textColorScheme.onFill, ), onTap: _inviteMember, ) : AFFilledTextButton.disabled( text: 'Send invite', alignment: Alignment.center, size: AFButtonSize.l, textStyle: theme.textStyle.heading4.enhanced( color: theme.textColorScheme.tertiary, ), ), ], ); } void _inviteMember() { final email = _emailController.text; if (!isEmail(email)) { showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), ); return; } context .read() .add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email)); // clear the email field after inviting _emailController.clear(); } void _onEmailChanged() { setState(() { _isInviteButtonEnabled = _emailController.text.isNotEmpty; }); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_link.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; class MInviteMemberByLink extends StatelessWidget { const MInviteMemberByLink({super.key}); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _Title(), VSpace(theme.spacing.l), _CopyLinkButton(), VSpace(theme.spacing.l), _Description(), ], ); } } class _Title extends StatelessWidget { const _Title(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Text( LocaleKeys.settings_appearance_members_inviteLinkToAddMember.tr(), style: theme.textStyle.heading4.enhanced( color: theme.textColorScheme.primary, ), ); } } class _Description extends StatelessWidget { const _Description(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Text.rich( TextSpan( children: [ TextSpan( text: LocaleKeys.settings_appearance_members_clickToCopyLink.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), TextSpan( text: ' ${LocaleKeys.settings_appearance_members_or.tr()} ', style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), TextSpan( text: LocaleKeys.settings_appearance_members_generateANewLink.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.action, ), mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = () => _onGenerateInviteLink(context), ), ], ), ); } Future _onGenerateInviteLink(BuildContext context) async { final inviteLink = context.read().state.inviteLink; if (inviteLink != null) { // show a dialog to confirm if the user wants to copy the link to the clipboard await showConfirmDialog( context: context, style: ConfirmPopupStyle.cancelAndOk, title: LocaleKeys.settings_appearance_members_resetInviteLink.tr(), description: LocaleKeys .settings_appearance_members_resetInviteLinkDescription .tr(), confirmLabel: LocaleKeys.settings_appearance_members_reset.tr(), onConfirm: (_) { context.read().add( const WorkspaceMemberEvent.generateInviteLink(), ); }, confirmButtonBuilder: (dialogContext) => AFFilledTextButton.destructive( size: UniversalPlatform.isDesktop ? AFButtonSize.m : AFButtonSize.l, text: LocaleKeys.settings_appearance_members_reset.tr(), onTap: () { context.read().add( const WorkspaceMemberEvent.generateInviteLink(), ); Navigator.of(dialogContext).pop(); }, ), ); } else { context.read().add( const WorkspaceMemberEvent.generateInviteLink(), ); } } } class _CopyLinkButton extends StatelessWidget { const _CopyLinkButton(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFOutlinedTextButton.normal( size: AFButtonSize.l, alignment: Alignment.center, text: LocaleKeys.button_copyLink.tr(), textStyle: theme.textStyle.heading4.enhanced( color: theme.textColorScheme.primary, ), onTap: () { final link = context.read().state.inviteLink; if (link != null) { getIt().setData( ClipboardServiceData( plainText: link, ), ); showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); } else { showToastNotification( message: LocaleKeys.settings_appearance_members_noInviteLink.tr(), type: ToastificationType.error, ); } }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/member_http_service.dart ================================================ import 'dart:convert'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:http/http.dart' as http; enum InviteCodeEndpoint { getInviteCode, deleteInviteCode, generateInviteCode; String get path { switch (this) { case InviteCodeEndpoint.getInviteCode: case InviteCodeEndpoint.deleteInviteCode: case InviteCodeEndpoint.generateInviteCode: return '/api/workspace/{workspaceId}/invite-code'; } } String get method { switch (this) { case InviteCodeEndpoint.getInviteCode: return 'GET'; case InviteCodeEndpoint.deleteInviteCode: return 'DELETE'; case InviteCodeEndpoint.generateInviteCode: return 'POST'; } } Uri uri(String baseUrl, String workspaceId) => Uri.parse(path.replaceAll('{workspaceId}', workspaceId)).replace( scheme: Uri.parse(baseUrl).scheme, host: Uri.parse(baseUrl).host, port: Uri.parse(baseUrl).port, ); } class MemberHttpService { MemberHttpService({ required this.baseUrl, required this.authToken, }); final String baseUrl; final String authToken; final http.Client client = http.Client(); Map get headers => { 'Content-Type': 'application/json', 'Authorization': 'Bearer $authToken', }; /// Gets the invite code for a workspace Future> getInviteCode({ required String workspaceId, }) async { final result = await _makeRequest( endpoint: InviteCodeEndpoint.getInviteCode, workspaceId: workspaceId, errorMessage: 'Failed to get invite code', ); try { return result.fold( (data) { final code = data['data']['code']; if (code is! String || code.isEmpty) { return FlowyResult.failure( FlowyError(msg: 'Failed to get invite code: $code'), ); } return FlowyResult.success(code); }, (error) => FlowyResult.failure(error), ); } catch (e) { return FlowyResult.failure( FlowyError(msg: 'Failed to get invite code: $e'), ); } } /// Deletes the invite code for a workspace Future> deleteInviteCode({ required String workspaceId, }) async { final result = await _makeRequest( endpoint: InviteCodeEndpoint.deleteInviteCode, workspaceId: workspaceId, errorMessage: 'Failed to delete invite code', ); return result.fold( (data) => FlowyResult.success(true), (error) => FlowyResult.failure(error), ); } /// Generates a new invite code for a workspace /// /// [workspaceId] - The ID of the workspace Future> generateInviteCode({ required String workspaceId, int? validityPeriodHours, }) async { final result = await _makeRequest( endpoint: InviteCodeEndpoint.generateInviteCode, workspaceId: workspaceId, errorMessage: 'Failed to generate invite code', body: { 'validity_period_hours': validityPeriodHours, }, ); try { return result.fold( (data) => FlowyResult.success(data['data']['code'].toString()), (error) => FlowyResult.failure(error), ); } catch (e) { return FlowyResult.failure( FlowyError(msg: 'Failed to generate invite code: $e'), ); } } /// Makes a request to the specified endpoint Future> _makeRequest({ required InviteCodeEndpoint endpoint, required String workspaceId, Map? body, String errorMessage = 'Request failed', }) async { try { final uri = endpoint.uri(baseUrl, workspaceId); http.Response response; switch (endpoint.method) { case 'GET': response = await client.get( uri, headers: headers, ); break; case 'DELETE': response = await client.delete( uri, headers: headers, ); break; case 'POST': response = await client.post( uri, headers: headers, body: body != null ? jsonEncode(body) : null, ); break; default: return FlowyResult.failure( FlowyError(msg: 'Invalid request method: ${endpoint.method}'), ); } if (response.statusCode == 200) { if (response.body.isNotEmpty) { return FlowyResult.success(jsonDecode(response.body)); } return FlowyResult.success(true); } else { final errorBody = response.body.isNotEmpty ? jsonDecode(response.body) : {}; Log.info( '${endpoint.name} request failed: ${response.statusCode}, $errorBody', ); return FlowyResult.failure( FlowyError( msg: errorBody['msg'] ?? errorMessage, ), ); } } catch (e) { return FlowyResult.failure( FlowyError(msg: 'Network error: ${e.toString()}'), ); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart ================================================ import 'dart:async'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/shared/af_user_profile_extension.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/invitation/member_http_service.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; part 'workspace_member_bloc.freezed.dart'; // 1. get the workspace members // 2. display the content based on the user role // Owner: // - invite member button // - delete member button // - member list // Member: // Guest: // - member list class WorkspaceMemberBloc extends Bloc { WorkspaceMemberBloc({ required this.userProfile, String? workspaceId, this.workspace, }) : _userBackendService = UserBackendService(userId: userProfile.id), super(WorkspaceMemberState.initial()) { on((event, emit) async { await event.when( initial: () async => _onInitial(emit, workspaceId), getWorkspaceMembers: () async => _onGetWorkspaceMembers(emit), addWorkspaceMember: (email) async => _onAddWorkspaceMember(emit, email), inviteWorkspaceMemberByEmail: (email) async => _onInviteWorkspaceMemberByEmail(emit, email), removeWorkspaceMemberByEmail: (email) async => _onRemoveWorkspaceMemberByEmail(emit, email), inviteWorkspaceMemberByLink: (link) async => _onInviteWorkspaceMemberByLink(emit, link), generateInviteLink: () async => _onGenerateInviteLink(emit), updateWorkspaceMember: (email, role) async => _onUpdateWorkspaceMember(emit, email, role), updateSubscriptionInfo: (info) async => _onUpdateSubscriptionInfo(emit, info), upgradePlan: () async => _onUpgradePlan(), getInviteCode: () async => _onGetInviteCode(emit), updateInviteLink: (inviteLink) async => emit( state.copyWith( inviteLink: inviteLink, ), ), ); }); } @override Future close() async { _workspaceId.dispose(); await super.close(); } final UserProfilePB userProfile; final UserWorkspacePB? workspace; final UserBackendService _userBackendService; final ValueNotifier _workspaceId = ValueNotifier(null); MemberHttpService? _memberHttpService; Future _onInitial( Emitter emit, String? workspaceId, ) async { _workspaceId.addListener(() { if (!isClosed) { add(const WorkspaceMemberEvent.getInviteCode()); } }); await _setCurrentWorkspaceId(workspaceId); final currentWorkspaceId = _workspaceId.value; if (currentWorkspaceId == null) { Log.error('Failed to get workspace members: workspaceId is null'); return; } final result = await _userBackendService.getWorkspaceMembers(currentWorkspaceId); final members = result.fold>( (s) => s.items, (e) => [], ); final myRole = _getMyRole(members); if (myRole.isOwner) { unawaited(_fetchWorkspaceSubscriptionInfo()); } emit( state.copyWith( members: members, myRole: myRole, isLoading: false, actionResult: WorkspaceMemberActionResult( actionType: WorkspaceMemberActionType.get, result: result, ), ), ); } Future _onGetWorkspaceMembers( Emitter emit, ) async { final workspaceId = _workspaceId.value; if (workspaceId == null) { Log.error('Failed to get workspace members: workspaceId is null'); return; } final result = await _userBackendService.getWorkspaceMembers(workspaceId); final members = result.fold>( (s) => s.items, (e) => [], ); final myRole = _getMyRole(members); emit( state.copyWith( members: members, myRole: myRole, actionResult: WorkspaceMemberActionResult( actionType: WorkspaceMemberActionType.get, result: result, ), ), ); } Future _onAddWorkspaceMember( Emitter emit, String email, ) async { final workspaceId = _workspaceId.value; if (workspaceId == null) { Log.error('Failed to add workspace member by email: workspaceId is null'); return; } final result = await _userBackendService.addWorkspaceMember( workspaceId, email, ); emit( state.copyWith( actionResult: WorkspaceMemberActionResult( actionType: WorkspaceMemberActionType.addByEmail, result: result, ), ), ); // the addWorkspaceMember doesn't return the updated members, // so we need to get the members again result.onSuccess((s) { add(const WorkspaceMemberEvent.getWorkspaceMembers()); }); } Future _onInviteWorkspaceMemberByEmail( Emitter emit, String email, ) async { final workspaceId = _workspaceId.value; if (workspaceId == null) { Log.error( 'Failed to invite workspace member by email: workspaceId is null', ); return; } final result = await _userBackendService.inviteWorkspaceMember( workspaceId, email, role: AFRolePB.Member, ); emit( state.copyWith( actionResult: WorkspaceMemberActionResult( actionType: WorkspaceMemberActionType.inviteByEmail, result: result, ), ), ); } Future _onRemoveWorkspaceMemberByEmail( Emitter emit, String email, ) async { final workspaceId = _workspaceId.value; if (workspaceId == null) { Log.error( 'Failed to remove workspace member by email: workspaceId is null', ); return; } final result = await _userBackendService.removeWorkspaceMember( workspaceId, email, ); final members = result.fold( (s) => state.members.where((e) => e.email != email).toList(), (e) => state.members, ); emit( state.copyWith( members: members, actionResult: WorkspaceMemberActionResult( actionType: WorkspaceMemberActionType.removeByEmail, result: result, ), ), ); } Future _onInviteWorkspaceMemberByLink( Emitter emit, String link, ) async {} Future _onGenerateInviteLink( Emitter emit, ) async { final workspaceId = _workspaceId.value; if (workspaceId == null) { Log.error('Failed to generate invite link: workspaceId is null'); return; } final resetInviteLink = state.inviteLink != null; final result = await _memberHttpService?.generateInviteCode( workspaceId: workspaceId, ); await result?.fold( (s) async { final inviteLink = await _buildInviteLink(inviteCode: s); emit( state.copyWith( inviteLink: inviteLink, actionResult: WorkspaceMemberActionResult( actionType: resetInviteLink ? WorkspaceMemberActionType.resetInviteLink : WorkspaceMemberActionType.generateInviteLink, result: result, ), ), ); }, (e) async { Log.error('Failed to generate invite link: ${e.msg}', e); emit( state.copyWith( actionResult: WorkspaceMemberActionResult( actionType: resetInviteLink ? WorkspaceMemberActionType.resetInviteLink : WorkspaceMemberActionType.generateInviteLink, result: result, ), ), ); }, ); } Future _onUpdateWorkspaceMember( Emitter emit, String email, AFRolePB role, ) async { final workspaceId = _workspaceId.value; if (workspaceId == null) { Log.error('Failed to update workspace member: workspaceId is null'); return; } final result = await _userBackendService.updateWorkspaceMember( workspaceId, email, role, ); final members = result.fold( (s) => state.members.map((e) { if (e.email == email) { e.freeze(); return e.rebuild((p0) => p0.role = role); } return e; }).toList(), (e) => state.members, ); emit( state.copyWith( members: members, actionResult: WorkspaceMemberActionResult( actionType: WorkspaceMemberActionType.updateRole, result: result, ), ), ); } Future _onUpdateSubscriptionInfo( Emitter emit, WorkspaceSubscriptionInfoPB info, ) async { emit(state.copyWith(subscriptionInfo: info)); } Future _onUpgradePlan() async { final workspaceId = _workspaceId.value; if (workspaceId == null) { Log.error('Failed to upgrade plan: workspaceId is null'); return; } final plan = state.subscriptionInfo?.plan; if (plan == null) { return Log.error('Failed to upgrade plan: plan is null'); } if (plan == WorkspacePlanPB.FreePlan) { final checkoutLink = await _userBackendService.createSubscription( workspaceId, SubscriptionPlanPB.Pro, ); checkoutLink.fold( (pl) => afLaunchUrlString(pl.paymentLink), (f) => Log.error('Failed to create subscription: ${f.msg}', f), ); } } Future _onGetInviteCode(Emitter emit) async { final baseUrl = await getAppFlowyCloudUrl(); final authToken = userProfile.authToken; final workspaceId = _workspaceId.value; if (authToken != null && workspaceId != null) { _memberHttpService = MemberHttpService( baseUrl: baseUrl, authToken: authToken, ); unawaited( _memberHttpService?.getInviteCode(workspaceId: workspaceId).fold( (s) async { final inviteLink = await _buildInviteLink(inviteCode: s); if (!isClosed) { add(WorkspaceMemberEvent.updateInviteLink(inviteLink)); } }, (e) {}, ), ); } else { Log.error('Failed to get auth token'); } } AFRolePB _getMyRole(List members) { final role = members .firstWhereOrNull( (e) => e.email == userProfile.email, ) ?.role; if (role == null) { Log.error('Failed to get my role'); return AFRolePB.Guest; } return role; } Future _setCurrentWorkspaceId(String? workspaceId) async { if (workspace != null) { _workspaceId.value = workspace!.workspaceId; } else if (workspaceId != null && workspaceId.isNotEmpty) { _workspaceId.value = workspaceId; } else { final currentWorkspace = await FolderEventReadCurrentWorkspace().send(); currentWorkspace.fold((s) { _workspaceId.value = s.id; }, (e) { assert(false, 'Failed to read current workspace: $e'); Log.error('Failed to read current workspace: $e'); }); } } Future _fetchWorkspaceSubscriptionInfo() async { final workspaceId = _workspaceId.value; if (workspaceId == null) { Log.error('Failed to fetch subscription info: workspaceId is null'); return; } final result = await UserBackendService.getWorkspaceSubscriptionInfo( workspaceId, ); result.fold( (info) { if (!isClosed) { add(WorkspaceMemberEvent.updateSubscriptionInfo(info)); } }, (f) => Log.error('Failed to fetch subscription info: ${f.msg}', f), ); } Future _buildInviteLink({required String inviteCode}) async { final baseUrl = await getAppFlowyShareDomain(); final authToken = userProfile.authToken; if (authToken != null) { return '$baseUrl/app/invited/$inviteCode'; } return ''; } } @freezed class WorkspaceMemberEvent with _$WorkspaceMemberEvent { const factory WorkspaceMemberEvent.initial() = Initial; // Members related events const factory WorkspaceMemberEvent.getWorkspaceMembers() = GetWorkspaceMembers; const factory WorkspaceMemberEvent.addWorkspaceMember(String email) = AddWorkspaceMember; const factory WorkspaceMemberEvent.inviteWorkspaceMemberByEmail( String email, ) = InviteWorkspaceMemberByEmail; const factory WorkspaceMemberEvent.removeWorkspaceMemberByEmail( String email, ) = RemoveWorkspaceMemberByEmail; const factory WorkspaceMemberEvent.updateWorkspaceMember( String email, AFRolePB role, ) = UpdateWorkspaceMember; // Subscription related events const factory WorkspaceMemberEvent.updateSubscriptionInfo( WorkspaceSubscriptionInfoPB subscriptionInfo, ) = UpdateSubscriptionInfo; const factory WorkspaceMemberEvent.upgradePlan() = UpgradePlan; // Invite link related events const factory WorkspaceMemberEvent.inviteWorkspaceMemberByLink( String link, ) = InviteWorkspaceMemberByLink; const factory WorkspaceMemberEvent.getInviteCode() = GetInviteCode; const factory WorkspaceMemberEvent.generateInviteLink() = GenerateInviteLink; const factory WorkspaceMemberEvent.updateInviteLink(String inviteLink) = UpdateInviteLink; } enum WorkspaceMemberActionType { none, get, // this event will send an invitation to the member inviteByEmail, inviteByLink, generateInviteLink, resetInviteLink, // this event will add the member without sending an invitation addByEmail, removeByEmail, updateRole, } class WorkspaceMemberActionResult { const WorkspaceMemberActionResult({ required this.actionType, required this.result, }); final WorkspaceMemberActionType actionType; final FlowyResult result; } @freezed class WorkspaceMemberState with _$WorkspaceMemberState { const WorkspaceMemberState._(); const factory WorkspaceMemberState({ @Default([]) List members, @Default(AFRolePB.Guest) AFRolePB myRole, @Default(null) WorkspaceMemberActionResult? actionResult, @Default(true) bool isLoading, @Default(null) WorkspaceSubscriptionInfoPB? subscriptionInfo, @Default(null) String? inviteLink, }) = _WorkspaceMemberState; factory WorkspaceMemberState.initial() => const WorkspaceMemberState(); @override int get hashCode => runtimeType.hashCode; @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is WorkspaceMemberState && other.members == members && other.myRole == myRole && other.subscriptionInfo == subscriptionInfo && other.inviteLink == inviteLink && identical(other.actionResult, actionResult); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/invitation/invite_member_by_email.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/invitation/invite_member_by_link.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class WorkspaceMembersPage extends StatelessWidget { const WorkspaceMembersPage({ super.key, required this.userProfile, required this.workspaceId, }); final UserProfilePB userProfile; final String workspaceId; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => WorkspaceMemberBloc(userProfile: userProfile) ..add(const WorkspaceMemberEvent.initial()) ..add(const WorkspaceMemberEvent.getInviteCode()), child: BlocConsumer( listener: _showResultDialog, builder: (context, state) { return SettingsBody( title: LocaleKeys.settings_appearance_members_title.tr(), // Enable it when the backend support admin panel // descriptionBuilder: _buildDescription, autoSeparate: false, children: [ if (state.myRole.canInvite) ...[ const InviteMemberByLink(), const SettingsCategorySpacer(), const InviteMemberByEmail(), const SettingsCategorySpacer( bottomSpacing: 0, ), ], if (state.members.isNotEmpty) _MemberList( members: state.members, userProfile: userProfile, myRole: state.myRole, ), ], ); }, ), ); } // Enable it when the backend support admin panel // Widget _buildDescription(BuildContext context) { // final theme = AppFlowyTheme.of(context); // return Text.rich( // TextSpan( // children: [ // TextSpan( // text: // '${LocaleKeys.settings_appearance_members_memberPageDescription1.tr()} ', // style: theme.textStyle.caption.standard( // color: theme.textColorScheme.secondary, // ), // ), // TextSpan( // text: LocaleKeys.settings_appearance_members_adminPanel.tr(), // style: theme.textStyle.caption.underline( // color: theme.textColorScheme.secondary, // ), // mouseCursor: SystemMouseCursors.click, // recognizer: TapGestureRecognizer() // ..onTap = () async { // final baseUrl = await getAppFlowyCloudUrl(); // await afLaunchUrlString(baseUrl); // }, // ), // TextSpan( // text: // ' ${LocaleKeys.settings_appearance_members_memberPageDescription2.tr()} ', // style: theme.textStyle.caption.standard( // color: theme.textColorScheme.secondary, // ), // ), // ], // ), // ); // } // Widget _showMemberLimitWarning( // BuildContext context, // WorkspaceMemberState state, // ) { // // We promise that state.actionResult != null before calling // // this method // final actionResult = state.actionResult!.result; // final actionType = state.actionResult!.actionType; // if (actionType == WorkspaceMemberActionType.inviteByEmail && // actionResult.isFailure) { // final error = actionResult.getFailure().code; // if (error == ErrorCode.WorkspaceMemberLimitExceeded) { // return Row( // children: [ // const FlowySvg( // FlowySvgs.warning_s, // blendMode: BlendMode.dst, // size: Size.square(20), // ), // const HSpace(12), // Expanded( // child: RichText( // text: TextSpan( // children: [ // if (state.subscriptionInfo?.plan == // WorkspacePlanPB.ProPlan) ...[ // TextSpan( // text: LocaleKeys // .settings_appearance_members_memberLimitExceededPro // .tr(), // style: TextStyle( // fontSize: 14, // fontWeight: FontWeight.w400, // color: AFThemeExtension.of(context).strongText, // ), // ), // WidgetSpan( // child: MouseRegion( // cursor: SystemMouseCursors.click, // child: GestureDetector( // // Hardcoded support email, in the future we might // // want to add this to an environment variable // onTap: () async => afLaunchUrlString( // 'mailto:support@appflowy.io', // ), // child: FlowyText( // LocaleKeys // .settings_appearance_members_memberLimitExceededProContact // .tr(), // fontSize: 14, // fontWeight: FontWeight.w400, // color: Theme.of(context).colorScheme.primary, // ), // ), // ), // ), // ] else ...[ // TextSpan( // text: LocaleKeys // .settings_appearance_members_memberLimitExceeded // .tr(), // style: TextStyle( // fontSize: 14, // fontWeight: FontWeight.w400, // color: AFThemeExtension.of(context).strongText, // ), // ), // WidgetSpan( // child: MouseRegion( // cursor: SystemMouseCursors.click, // child: GestureDetector( // onTap: () => context // .read() // .add(const WorkspaceMemberEvent.upgradePlan()), // child: FlowyText( // LocaleKeys // .settings_appearance_members_memberLimitExceededUpgrade // .tr(), // fontSize: 14, // fontWeight: FontWeight.w400, // color: Theme.of(context).colorScheme.primary, // ), // ), // ), // ), // ], // ], // ), // ), // ), // ], // ); // } // } // return const SizedBox.shrink(); // } void _showResultDialog(BuildContext context, WorkspaceMemberState state) { final actionResult = state.actionResult; if (actionResult == null) { return; } final actionType = actionResult.actionType; final result = actionResult.result; // only show the result dialog when the action is WorkspaceMemberActionType.add if (actionType == WorkspaceMemberActionType.addByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), ); }, (f) { Log.error('add workspace member failed: $f'); final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded ? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr() : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); showDialog( context: context, builder: (context) => NavigatorOkCancelDialog(message: message), ); }, ); } else if (actionType == WorkspaceMemberActionType.inviteByEmail) { result.fold( (s) { showToastNotification( message: LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), ); }, (f) { Log.error('invite workspace member failed: $f'); final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded ? LocaleKeys.settings_appearance_members_inviteFailedMemberLimit .tr() : LocaleKeys.settings_appearance_members_failedToInviteMember .tr(); showConfirmDialog( context: context, title: LocaleKeys .settings_appearance_members_inviteFailedDialogTitle .tr(), description: message, confirmLabel: LocaleKeys .settings_appearance_members_memberLimitExceededUpgrade .tr(), onConfirm: (_) => context .read() .add(const WorkspaceMemberEvent.upgradePlan()), ); }, ); } else if (actionType == WorkspaceMemberActionType.generateInviteLink) { result.fold( (s) async { showToastNotification( message: LocaleKeys .settings_appearance_members_generatedLinkSuccessfully .tr(), ); // copy the invite link to the clipboard final inviteLink = state.inviteLink; if (inviteLink != null) { await getIt().setPlainText(inviteLink); Future.delayed(const Duration(milliseconds: 200), () { showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); }); } }, (f) { Log.error('generate invite link failed: $f'); showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_generatedLinkFailed.tr(), ); }, ); } else if (actionType == WorkspaceMemberActionType.resetInviteLink) { result.fold( (s) async { showToastNotification( message: LocaleKeys .settings_appearance_members_resetLinkSuccessfully .tr(), ); // copy the invite link to the clipboard final inviteLink = state.inviteLink; if (inviteLink != null) { await getIt().setPlainText(inviteLink); Future.delayed(const Duration(milliseconds: 200), () { showToastNotification( message: LocaleKeys.shareAction_copyLinkSuccess.tr(), ); }); } }, (f) { Log.error('generate invite link failed: $f'); showToastNotification( type: ToastificationType.error, message: LocaleKeys.settings_appearance_members_resetLinkFailed.tr(), ); }, ); } } } class _MemberList extends StatelessWidget { const _MemberList({ required this.members, required this.myRole, required this.userProfile, }); final List members; final AFRolePB myRole; final UserProfilePB userProfile; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return SeparatedColumn( crossAxisAlignment: CrossAxisAlignment.start, separatorBuilder: () => Divider( color: theme.borderColorScheme.primary, ), children: [ const _MemberListHeader(), ...members.map( (member) => _MemberItem( member: member, myRole: myRole, userProfile: userProfile, ), ), ], ); } } class _MemberListHeader extends StatelessWidget { const _MemberListHeader(); @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( children: [ Expanded( flex: 4, child: Text( LocaleKeys.settings_appearance_members_user.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ), ), ), Expanded( flex: 2, child: Text( LocaleKeys.settings_appearance_members_role.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ), ), ), Expanded( flex: 3, child: Text( LocaleKeys.settings_accountPage_email_title.tr(), style: theme.textStyle.body.standard( color: theme.textColorScheme.secondary, ), ), ), const HSpace(28.0), ], ); } } class _MemberItem extends StatelessWidget { const _MemberItem({ required this.member, required this.myRole, required this.userProfile, }); final WorkspaceMemberPB member; final AFRolePB myRole; final UserProfilePB userProfile; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Row( children: [ Expanded( flex: 4, child: Row( children: [ UserAvatar( iconUrl: member.avatarUrl, name: member.name, size: AFAvatarSize.s, ), HSpace(8), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( member.name, style: theme.textStyle.body.enhanced( color: theme.textColorScheme.primary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( _formatJoinedDate(member.joinedAt.toInt()), style: theme.textStyle.caption.standard( color: theme.textColorScheme.secondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), HSpace(8), ], ), ), Expanded( flex: 2, child: member.role.isOwner || !myRole.canUpdate ? Text( member.role.description, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ) : _MemberRoleActionList( member: member, ), ), Expanded( flex: 3, child: FlowyTooltip( message: member.email, child: Text( member.email, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), ), ), myRole.canDelete && member.email != userProfile.email // can't delete self ? _MemberMoreActionList(member: member) : const HSpace(28.0), ], ); } String _formatJoinedDate(int joinedAt) { final date = DateTime.fromMillisecondsSinceEpoch(joinedAt * 1000); return 'Joined on ${DateFormat('MMM d, y').format(date)}'; } } enum _MemberMoreAction { delete, } class _MemberMoreActionList extends StatelessWidget { const _MemberMoreActionList({ required this.member, }); final WorkspaceMemberPB member; @override Widget build(BuildContext context) { return PopoverActionList<_MemberMoreActionWrapper>( asBarrier: true, direction: PopoverDirection.bottomWithCenterAligned, actions: _MemberMoreAction.values .map((e) => _MemberMoreActionWrapper(e, member)) .toList(), buildChild: (controller) { return FlowyButton( useIntrinsicWidth: true, text: const FlowySvg( FlowySvgs.three_dots_s, ), onTap: () { controller.show(); }, ); }, onSelected: (action, controller) { switch (action.inner) { case _MemberMoreAction.delete: showCancelAndConfirmDialog( context: context, title: LocaleKeys.settings_appearance_members_removeMember.tr(), description: LocaleKeys .settings_appearance_members_areYouSureToRemoveMember .tr(), confirmLabel: LocaleKeys.button_yes.tr(), onConfirm: (_) => context.read().add( WorkspaceMemberEvent.removeWorkspaceMemberByEmail( action.member.email, ), ), ); break; } controller.close(); }, ); } } class _MemberMoreActionWrapper extends ActionCell { _MemberMoreActionWrapper(this.inner, this.member); final _MemberMoreAction inner; final WorkspaceMemberPB member; @override String get name { switch (inner) { case _MemberMoreAction.delete: return LocaleKeys.settings_appearance_members_removeFromWorkspace.tr(); } } } class _MemberRoleActionList extends StatelessWidget { const _MemberRoleActionList({ required this.member, }); final WorkspaceMemberPB member; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Text( member.role.description, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/shared/error_page/error_page.dart'; import 'package:appflowy/workspace/application/settings/appflowy_cloud_setting_bloc.dart'; import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_button.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/web_url_hint_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AppFlowyCloudViewSetting extends StatelessWidget { const AppFlowyCloudViewSetting({ super.key, this.serverURL = kAppflowyCloudUrl, this.authenticatorType = AuthenticatorType.appflowyCloud, required this.restartAppFlowy, }); final String serverURL; final AuthenticatorType authenticatorType; final VoidCallback restartAppFlowy; @override Widget build(BuildContext context) { return FutureBuilder>( future: UserEventGetCloudConfig().send(), builder: (context, snapshot) { if (snapshot.data != null && snapshot.connectionState == ConnectionState.done) { return snapshot.data!.fold( (setting) => _renderContent(context, setting), (err) => FlowyErrorPage.message(err.toString(), howToFix: ""), ); } return const Center( child: CircularProgressIndicator(), ); }, ); } BlocProvider _renderContent( BuildContext context, CloudSettingPB setting, ) { return BlocProvider( create: (context) => AppFlowyCloudSettingBloc(setting) ..add(const AppFlowyCloudSettingEvent.initial()), child: BlocBuilder( builder: (context, state) { return Column( children: [ const VSpace(8), if (state.workspaceType == WorkspaceTypePB.ServerW) const AppFlowyCloudEnableSync(), const VSpace(6), // const AppFlowyCloudSyncLogEnabled(), const VSpace(12), RestartButton( onClick: () { NavigatorAlertDialog( title: LocaleKeys.settings_menu_restartAppTip.tr(), confirm: () async { await useBaseWebDomain( ShareConstants.defaultBaseWebDomain, ); await useAppFlowyBetaCloudWithURL( serverURL, authenticatorType, ); restartAppFlowy(); }, ).show(context); }, showRestartHint: state.showRestartHint, ), ], ); }, ), ); } } class CustomAppFlowyCloudView extends StatelessWidget { const CustomAppFlowyCloudView({required this.restartAppFlowy, super.key}); final VoidCallback restartAppFlowy; @override Widget build(BuildContext context) { return FutureBuilder>( future: UserEventGetCloudConfig().send(), builder: (context, snapshot) { if (snapshot.data != null && snapshot.connectionState == ConnectionState.done) { return snapshot.data!.fold( (setting) => _renderContent(setting), (err) => FlowyErrorPage.message(err.toString(), howToFix: ""), ); } else { return const Center( child: CircularProgressIndicator(), ); } }, ); } BlocProvider _renderContent( CloudSettingPB setting, ) { final List children = []; children.addAll([ const AppFlowyCloudEnableSync(), // const AppFlowyCloudSyncLogEnabled(), const VSpace(40), ]); // If the enableCustomCloud flag is true, then the user can dynamically configure cloud settings. Otherwise, the user cannot dynamically configure cloud settings. if (Env.enableCustomCloud) { children.add( AppFlowyCloudURLs(restartAppFlowy: () => restartAppFlowy()), ); } else { children.add( Row( children: [ FlowyText(LocaleKeys.settings_menu_cloudServerType.tr()), const Spacer(), const FlowyText(Env.afCloudUrl), ], ), ); } return BlocProvider( create: (context) => AppFlowyCloudSettingBloc(setting) ..add(const AppFlowyCloudSettingEvent.initial()), child: Column( mainAxisSize: MainAxisSize.min, children: children, ), ); } } class AppFlowyCloudURLs extends StatelessWidget { const AppFlowyCloudURLs({super.key, required this.restartAppFlowy}); final VoidCallback restartAppFlowy; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => AppFlowyCloudURLsBloc()..add(const AppFlowyCloudURLsEvent.initial()), child: BlocListener( listener: (context, state) async { if (state.restartApp) { restartAppFlowy(); } }, child: BlocBuilder( builder: (context, state) { return Column( mainAxisSize: MainAxisSize.min, children: [ const AppFlowySelfHostTip(), const VSpace(12), CloudURLInput( title: LocaleKeys.settings_menu_cloudURL.tr(), url: state.config.base_url, hint: LocaleKeys.settings_menu_cloudURLHint.tr(), onChanged: (text) { context.read().add( AppFlowyCloudURLsEvent.updateServerUrl( text, ), ); }, ), const VSpace(8), CloudURLInput( title: LocaleKeys.settings_menu_webURL.tr(), url: state.config.base_web_domain, hint: LocaleKeys.settings_menu_webURLHint.tr(), hintBuilder: (context) => const WebUrlHintWidget(), onChanged: (text) { context.read().add( AppFlowyCloudURLsEvent.updateBaseWebDomain( text, ), ); }, ), const VSpace(12), RestartButton( onClick: () { NavigatorAlertDialog( title: LocaleKeys.settings_menu_restartAppTip.tr(), confirm: () { context.read().add( const AppFlowyCloudURLsEvent.confirmUpdate(), ); }, ).show(context); }, showRestartHint: state.showRestartHint, ), ], ); }, ), ), ); } } class AppFlowySelfHostTip extends StatelessWidget { const AppFlowySelfHostTip({super.key}); final url = "https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy#build-appflowy-with-a-self-hosted-server"; @override Widget build(BuildContext context) { return Opacity( opacity: 0.6, child: RichText( text: TextSpan( children: [ TextSpan( text: LocaleKeys.settings_menu_selfHostStart.tr(), style: Theme.of(context).textTheme.bodySmall!, ), TextSpan( text: " ${LocaleKeys.settings_menu_selfHostContent.tr()} ", style: Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: FontSizes.s14, color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), recognizer: TapGestureRecognizer() ..onTap = () => afLaunchUrlString(url), ), TextSpan( text: LocaleKeys.settings_menu_selfHostEnd.tr(), style: Theme.of(context).textTheme.bodySmall!, ), ], ), ), ); } } @visibleForTesting class CloudURLInput extends StatefulWidget { const CloudURLInput({ super.key, required this.title, required this.url, required this.hint, required this.onChanged, this.hintBuilder, }); final String title; final String url; final String hint; final ValueChanged onChanged; final WidgetBuilder? hintBuilder; @override CloudURLInputState createState() => CloudURLInputState(); } class CloudURLInputState extends State { late TextEditingController _controller; @override void initState() { super.initState(); _controller = TextEditingController(text: widget.url); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildHint(context), SizedBox( height: 28, child: TextField( controller: _controller, style: Theme.of(context).textTheme.titleMedium!.copyWith( fontSize: 14, fontWeight: FontWeight.w400, ), decoration: InputDecoration( enabledBorder: UnderlineInputBorder( borderSide: BorderSide( color: AFThemeExtension.of(context).onBackground, ), ), focusedBorder: UnderlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, ), ), hintText: widget.hint, errorText: context.read().state.urlError, ), onChanged: widget.onChanged, ), ), ], ); } Widget _buildHint(BuildContext context) { final children = [ FlowyText( widget.title, fontSize: 12, ), ]; if (widget.hintBuilder != null) { children.add(widget.hintBuilder!(context)); } return Row( mainAxisSize: MainAxisSize.min, children: children, ); } } class AppFlowyCloudEnableSync extends StatelessWidget { const AppFlowyCloudEnableSync({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Row( children: [ FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()), const Spacer(), Toggle( value: state.setting.enableSync, onChanged: (value) => context .read() .add(AppFlowyCloudSettingEvent.enableSync(value)), ), ], ); }, ); } } class AppFlowyCloudSyncLogEnabled extends StatelessWidget { const AppFlowyCloudSyncLogEnabled({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return Row( children: [ FlowyText.medium(LocaleKeys.settings_menu_enableSyncLog.tr()), const Spacer(), Toggle( value: state.isSyncLogEnabled, onChanged: (value) { if (value) { showCancelAndConfirmDialog( context: context, title: LocaleKeys.settings_menu_enableSyncLog.tr(), description: LocaleKeys.settings_menu_enableSyncLogWarning.tr(), confirmLabel: LocaleKeys.button_confirm.tr(), onConfirm: (_) { context .read() .add(AppFlowyCloudSettingEvent.enableSyncLog(value)); }, ); } else { context .read() .add(AppFlowyCloudSettingEvent.enableSyncLog(value)); } }, ), ], ); }, ); } } class BillingGateGuard extends StatelessWidget { const BillingGateGuard({required this.builder, super.key}); final Widget Function(BuildContext context) builder; @override Widget build(BuildContext context) { return FutureBuilder( future: isBillingEnabled(), builder: (context, snapshot) { final isBillingEnabled = snapshot.data ?? false; if (isBillingEnabled && snapshot.connectionState == ConnectionState.done) { return builder(context); } // If the billing is not enabled, show nothing return const SizedBox.shrink(); }, ); } } Future isBillingEnabled() async { final result = await UserEventGetCloudConfig().send(); return result.fold( (cloudSetting) { final whiteList = [ "https://beta.appflowy.cloud", "https://test.appflowy.cloud", ]; if (kDebugMode) { whiteList.add("http://localhost:8000"); } final isWhiteListed = whiteList.contains(cloudSetting.serverUrl); if (!isWhiteListed) { Log.warn("Billing is not enabled for server ${cloudSetting.serverUrl}"); } return isWhiteListed; }, (err) { Log.error("Failed to get cloud config: $err"); return false; }, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/cloud_setting_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_local_cloud.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:universal_platform/universal_platform.dart'; import 'setting_appflowy_cloud.dart'; class SettingCloud extends StatelessWidget { const SettingCloud({ super.key, required this.restartAppFlowy, }); final VoidCallback restartAppFlowy; @override Widget build(BuildContext context) { return FutureBuilder( future: getAuthenticatorType(), builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { final cloudType = snapshot.data!; return BlocProvider( create: (context) => CloudSettingBloc(cloudType), child: BlocBuilder( builder: (context, state) { return SettingsBody( title: LocaleKeys.settings_menu_cloudSettings.tr(), autoSeparate: false, children: [ if (Env.enableCustomCloud) _CloudServerSwitcher(cloudType: state.cloudType), _viewFromCloudType(state.cloudType), ], ); }, ), ); } else { return const Center(child: CircularProgressIndicator()); } }, ); } Widget _viewFromCloudType(AuthenticatorType cloudType) { switch (cloudType) { case AuthenticatorType.local: return SettingLocalCloud(restartAppFlowy: restartAppFlowy); case AuthenticatorType.appflowyCloud: return AppFlowyCloudViewSetting(restartAppFlowy: restartAppFlowy); case AuthenticatorType.appflowyCloudSelfHost: return CustomAppFlowyCloudView(restartAppFlowy: restartAppFlowy); case AuthenticatorType.appflowyCloudDevelop: return AppFlowyCloudViewSetting( serverURL: "http://localhost", authenticatorType: AuthenticatorType.appflowyCloudDevelop, restartAppFlowy: restartAppFlowy, ); } } } class CloudTypeSwitcher extends StatelessWidget { const CloudTypeSwitcher({ super.key, required this.cloudType, required this.onSelected, }); final AuthenticatorType cloudType; final Function(AuthenticatorType) onSelected; @override Widget build(BuildContext context) { final isDevelopMode = integrationMode().isDevelop; // Only show the appflowyCloudDevelop in develop mode final values = AuthenticatorType.values.where((element) { return isDevelopMode || element != AuthenticatorType.appflowyCloudDevelop; }).toList(); return UniversalPlatform.isDesktopOrWeb ? SettingsDropdown( selectedOption: cloudType, onChanged: (type) { if (type != cloudType) { NavigatorAlertDialog( title: LocaleKeys.settings_menu_changeServerTip.tr(), confirm: () async { onSelected(type); }, hideCancelButton: true, ).show(context); } }, options: values .map( (type) => buildDropdownMenuEntry( context, value: type, label: titleFromCloudType(type), ), ) .toList(), ) : FlowyButton( text: FlowyText( titleFromCloudType(cloudType), ), useIntrinsicWidth: true, rightIcon: const Icon( Icons.chevron_right, ), onTap: () => showMobileBottomSheet( context, showHeader: true, showDragHandle: true, showDivider: false, title: LocaleKeys.settings_menu_cloudServerType.tr(), builder: (context) => Column( children: values .mapIndexed( (i, e) => FlowyOptionTile.checkbox( text: titleFromCloudType(values[i]), isSelected: cloudType == values[i], onTap: () { onSelected(e); context.pop(); }, showBottomBorder: i == values.length - 1, ), ) .toList(), ), ), ); } } class CloudTypeItem extends StatelessWidget { const CloudTypeItem({ super.key, required this.cloudType, required this.currentCloudType, required this.onSelected, }); final AuthenticatorType cloudType; final AuthenticatorType currentCloudType; final Function(AuthenticatorType) onSelected; @override Widget build(BuildContext context) { return SizedBox( height: 32, child: FlowyButton( text: FlowyText.medium( titleFromCloudType(cloudType), ), rightIcon: currentCloudType == cloudType ? const FlowySvg(FlowySvgs.check_s) : null, onTap: () { if (currentCloudType != cloudType) { NavigatorAlertDialog( title: LocaleKeys.settings_menu_changeServerTip.tr(), confirm: () async { onSelected(cloudType); }, hideCancelButton: true, ).show(context); } PopoverContainer.of(context).close(); }, ), ); } } class _CloudServerSwitcher extends StatelessWidget { const _CloudServerSwitcher({ required this.cloudType, }); final AuthenticatorType cloudType; @override Widget build(BuildContext context) { return UniversalPlatform.isDesktopOrWeb ? Row( children: [ Expanded( child: FlowyText.medium( LocaleKeys.settings_menu_cloudServerType.tr(), ), ), Flexible( child: CloudTypeSwitcher( cloudType: cloudType, onSelected: (type) => context .read() .add(CloudSettingEvent.updateCloudType(type)), ), ), ], ) : Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ FlowyText.medium( LocaleKeys.settings_menu_cloudServerType.tr(), ), CloudTypeSwitcher( cloudType: cloudType, onSelected: (type) => context .read() .add(CloudSettingEvent.updateCloudType(type)), ), ], ); } } String titleFromCloudType(AuthenticatorType cloudType) { switch (cloudType) { case AuthenticatorType.local: return LocaleKeys.settings_menu_cloudLocal.tr(); case AuthenticatorType.appflowyCloud: return LocaleKeys.settings_menu_cloudAppFlowy.tr(); case AuthenticatorType.appflowyCloudSelfHost: return LocaleKeys.settings_menu_cloudAppFlowySelfHost.tr(); case AuthenticatorType.appflowyCloudDevelop: return "AppFlowyCloud Develop"; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_local_cloud.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; class SettingLocalCloud extends StatelessWidget { const SettingLocalCloud({super.key, required this.restartAppFlowy}); final VoidCallback restartAppFlowy; @override Widget build(BuildContext context) { return RestartButton( onClick: () => onPressed(context), showRestartHint: true, ); } void onPressed(BuildContext context) { NavigatorAlertDialog( title: LocaleKeys.settings_menu_restartAppTip.tr(), confirm: () async { await useLocalServer(); restartAppFlowy(); }, ).show(context); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart ================================================ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingThirdPartyLogin extends StatelessWidget { const SettingThirdPartyLogin({ super.key, required this.didLogin, }); final VoidCallback didLogin; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => getIt(), child: BlocConsumer( listener: (context, state) { final successOrFail = state.successOrFail; if (successOrFail != null) { _handleSuccessOrFail(successOrFail, context); } }, builder: (_, state) { final indicator = state.isSubmitting ? const LinearProgressIndicator(minHeight: 1) : const SizedBox.shrink(); final promptMessage = state.isSubmitting ? FlowyText.medium( LocaleKeys.signIn_syncPromptMessage.tr(), maxLines: null, ) : const SizedBox.shrink(); return Column( children: [ promptMessage, const VSpace(6), indicator, const VSpace(6), if (isAuthEnabled) const ThirdPartySignInButtons(), ], ); }, ), ); } Future _handleSuccessOrFail( FlowyResult result, BuildContext context, ) async { result.fold( (user) async { didLogin(); await runAppFlowy(); }, (error) => showSnapBar(context, error.msg), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class SettingsMenu extends StatelessWidget { const SettingsMenu({ super.key, required this.changeSelectedPage, required this.currentPage, required this.userProfile, required this.isBillingEnabled, required this.currentUserRole, }); final Function changeSelectedPage; final SettingsPage currentPage; final UserProfilePB userProfile; final bool isBillingEnabled; final AFRolePB? currentUserRole; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Container( decoration: BoxDecoration( color: theme.surfaceContainerColorScheme.layer01, borderRadius: BorderRadiusDirectional.horizontal( start: Radius.circular(theme.spacing.m), ), ), height: double.infinity, child: SingleChildScrollView( padding: EdgeInsets.symmetric( vertical: 24, horizontal: theme.spacing.l, ), physics: const ClampingScrollPhysics(), child: Column( spacing: theme.spacing.xs, children: [ SettingsMenuElement( page: SettingsPage.account, selectedPage: currentPage, label: LocaleKeys.settings_accountPage_menuLabel.tr(), icon: const FlowySvg(FlowySvgs.settings_page_user_m), changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( page: SettingsPage.workspace, selectedPage: currentPage, label: LocaleKeys.settings_workspacePage_menuLabel.tr(), icon: const FlowySvg(FlowySvgs.settings_page_workspace_m), changeSelectedPage: changeSelectedPage, ), if (FeatureFlag.membersSettings.isOn && userProfile.workspaceType == WorkspaceTypePB.ServerW && currentUserRole != null && currentUserRole != AFRolePB.Guest) SettingsMenuElement( page: SettingsPage.member, selectedPage: currentPage, label: LocaleKeys.settings_appearance_members_label.tr(), icon: const FlowySvg(FlowySvgs.settings_page_users_m), changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( page: SettingsPage.manageData, selectedPage: currentPage, label: LocaleKeys.settings_manageDataPage_menuLabel.tr(), icon: const FlowySvg(FlowySvgs.settings_page_database_m), changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( page: SettingsPage.notifications, selectedPage: currentPage, label: LocaleKeys.settings_menu_notifications.tr(), icon: const FlowySvg(FlowySvgs.settings_page_bell_m), changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( page: SettingsPage.cloud, selectedPage: currentPage, label: LocaleKeys.settings_menu_cloudSettings.tr(), icon: const FlowySvg(FlowySvgs.settings_page_cloud_m), changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( page: SettingsPage.shortcuts, selectedPage: currentPage, label: LocaleKeys.settings_shortcutsPage_menuLabel.tr(), icon: const FlowySvg(FlowySvgs.settings_page_keyboard_m), changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( page: SettingsPage.ai, selectedPage: currentPage, label: LocaleKeys.settings_aiPage_menuLabel.tr(), icon: const FlowySvg( FlowySvgs.settings_page_ai_m, ), changeSelectedPage: changeSelectedPage, ), if (userProfile.workspaceType == WorkspaceTypePB.ServerW && currentUserRole != null && currentUserRole != AFRolePB.Guest) SettingsMenuElement( page: SettingsPage.sites, selectedPage: currentPage, label: LocaleKeys.settings_sites_title.tr(), icon: const FlowySvg(FlowySvgs.settings_page_earth_m), changeSelectedPage: changeSelectedPage, ), if (FeatureFlag.planBilling.isOn && isBillingEnabled) ...[ SettingsMenuElement( page: SettingsPage.plan, selectedPage: currentPage, label: LocaleKeys.settings_planPage_menuLabel.tr(), icon: const FlowySvg(FlowySvgs.settings_page_plan_m), changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( page: SettingsPage.billing, selectedPage: currentPage, label: LocaleKeys.settings_billingPage_menuLabel.tr(), icon: const FlowySvg(FlowySvgs.settings_page_credit_card_m), changeSelectedPage: changeSelectedPage, ), ], if (kDebugMode) SettingsMenuElement( // no need to translate this page page: SettingsPage.featureFlags, selectedPage: currentPage, label: 'Feature Flags', icon: const Icon( Icons.flag, size: 20, ), changeSelectedPage: changeSelectedPage, ), ], ), ), ); } } class SimpleSettingsMenu extends StatelessWidget { const SimpleSettingsMenu({super.key}); @override Widget build(BuildContext context) { return Column( children: [ Expanded( child: Container( padding: const EdgeInsets.symmetric(vertical: 8) + const EdgeInsets.only(left: 8, right: 4), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.only( topLeft: Radius.circular(8), bottomLeft: Radius.circular(8), ), ), child: SingleChildScrollView( // Right padding is added to make the scrollbar centered // in the space between the menu and the content padding: const EdgeInsets.only(right: 4) + const EdgeInsets.symmetric(vertical: 16), physics: const ClampingScrollPhysics(), child: SeparatedColumn( separatorBuilder: () => const VSpace(16), children: [ SettingsMenuElement( page: SettingsPage.cloud, selectedPage: SettingsPage.cloud, label: LocaleKeys.settings_menu_cloudSettings.tr(), icon: const Icon(Icons.sync), changeSelectedPage: () {}, ), if (kDebugMode) SettingsMenuElement( // no need to translate this page page: SettingsPage.featureFlags, selectedPage: SettingsPage.cloud, label: 'Feature Flags', icon: const Icon(Icons.flag), changeSelectedPage: () {}, ), ], ), ), ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu_element.dart ================================================ import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class SettingsMenuElement extends StatelessWidget { const SettingsMenuElement({ super.key, required this.page, required this.label, required this.icon, required this.changeSelectedPage, required this.selectedPage, }); final SettingsPage page; final SettingsPage selectedPage; final String label; final Widget icon; final Function changeSelectedPage; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFBaseButton( onTap: () => changeSelectedPage(page), padding: EdgeInsets.all(theme.spacing.m), borderRadius: theme.borderRadius.m, borderColor: (_, __, ___, ____) => Colors.transparent, backgroundColor: (_, isHovering, __) { if (isHovering) { return theme.fillColorScheme.contentHover; } else if (page == selectedPage) { return theme.fillColorScheme.themeSelect; } return Colors.transparent; }, builder: (_, __, ___) { return Row( children: [ icon, HSpace(theme.spacing.m), Text( label, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), ], ); }, ); } // return FlowyHover( // isSelected: () => page == selectedPage, // resetHoverOnRebuild: false, // style: HoverStyle( // hoverColor: AFThemeExtension.of(context).greyHover, // borderRadius: BorderRadius.circular(4), // ), // builder: (_, isHovering) => ListTile( // dense: true, // leading: iconWidget( // isHovering || page == selectedPage // ? Theme.of(context).colorScheme.onSurface // : AFThemeExtension.of(context).textColor, // ), // onTap: () => changeSelectedPage(page), // selected: page == selectedPage, // selectedColor: Theme.of(context).colorScheme.onSurface, // selectedTileColor: Theme.of(context).colorScheme.primary, // shape: RoundedRectangleBorder( // borderRadius: BorderRadius.circular(5), // ), // minLeadingWidth: 0, // title: FlowyText.medium( // label, // fontSize: FontSizes.s14, // overflow: TextOverflow.ellipsis, // color: page == selectedPage // ? Theme.of(context).colorScheme.onSurface // : null, // ), // ), // ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsNotificationsView extends StatelessWidget { const SettingsNotificationsView({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return SettingsBody( title: LocaleKeys.settings_menu_notifications.tr(), children: [ SettingListTile( label: LocaleKeys.settings_notifications_enableNotifications_label .tr(), hint: LocaleKeys.settings_notifications_enableNotifications_hint .tr(), trailing: [ Toggle( value: state.isNotificationsEnabled, onChanged: (_) => context .read() .toggleNotificationsEnabled(), ), ], ), SettingListTile( label: LocaleKeys .settings_notifications_showNotificationsIcon_label .tr(), hint: LocaleKeys.settings_notifications_showNotificationsIcon_hint .tr(), trailing: [ Toggle( value: state.isShowNotificationsIconEnabled, onChanged: (_) => context .read() .toggleShowNotificationIconEnabled(), ), ], ), ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_confirm_delete_dialog.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'theme_upload_view.dart'; class ThemeConfirmDeleteDialog extends StatelessWidget { const ThemeConfirmDeleteDialog({ super.key, required this.theme, }); final AppTheme theme; void onConfirm(BuildContext context) => Navigator.of(context).pop(true); void onCancel(BuildContext context) => Navigator.of(context).pop(false); @override Widget build(BuildContext context) { return FlowyDialog( padding: EdgeInsets.zero, constraints: const BoxConstraints.tightFor( width: 300, height: 100, ), title: FlowyText.regular( LocaleKeys.document_plugins_cover_alertDialogConfirmation.tr(), textAlign: TextAlign.center, ), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ SizedBox( width: ThemeUploadWidget.buttonSize.width, child: FlowyButton( text: FlowyText.semibold( LocaleKeys.button_ok.tr(), fontSize: ThemeUploadWidget.buttonFontSize, ), onTap: () => onConfirm(context), ), ), SizedBox( width: ThemeUploadWidget.buttonSize.width, child: FlowyButton( text: FlowyText.semibold( LocaleKeys.button_cancel.tr(), fontSize: ThemeUploadWidget.buttonFontSize, ), onTap: () => onCancel(context), ), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload.dart ================================================ export 'theme_confirm_delete_dialog.dart'; export 'theme_upload_button.dart'; export 'theme_upload_learn_more_button.dart'; export 'theme_upload_decoration.dart'; export 'theme_upload_failure_widget.dart'; export 'theme_upload_loading_widget.dart'; export 'theme_upload_view.dart'; export 'upload_new_theme_widget.dart'; ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_button.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; import 'package:flowy_infra/plugins/bloc/dynamic_plugin_event.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'theme_upload_view.dart'; class ThemeUploadButton extends StatelessWidget { const ThemeUploadButton({super.key, this.color}); final Color? color; @override Widget build(BuildContext context) { return SizedBox.fromSize( size: ThemeUploadWidget.buttonSize, child: FlowyButton( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: color ?? Theme.of(context).colorScheme.primary, ), hoverColor: color, text: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ FlowyText.medium( fontSize: ThemeUploadWidget.buttonFontSize, color: Theme.of(context).colorScheme.onPrimary, LocaleKeys.settings_appearance_themeUpload_button.tr(), ), ], ), onTap: () => BlocProvider.of(context) .add(DynamicPluginEvent.addPlugin()), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart ================================================ import 'package:dotted_border/dotted_border.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'theme_upload_view.dart'; class ThemeUploadDecoration extends StatelessWidget { const ThemeUploadDecoration({super.key, required this.child}); final Widget child; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius), color: Theme.of(context).colorScheme.surface, border: Border.all( color: AFThemeExtension.of(context).onBackground.withValues( alpha: ThemeUploadWidget.fadeOpacity, ), ), ), padding: ThemeUploadWidget.padding, child: DottedBorder( borderType: BorderType.RRect, dashPattern: const [6, 6], color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: ThemeUploadWidget.fadeOpacity), radius: const Radius.circular(ThemeUploadWidget.borderRadius), child: ClipRRect( borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius), child: child, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class ThemeUploadFailureWidget extends StatelessWidget { const ThemeUploadFailureWidget({super.key, required this.errorMessage}); final String errorMessage; @override Widget build(BuildContext context) { return Container( color: Theme.of(context) .colorScheme .error .withValues(alpha: ThemeUploadWidget.fadeOpacity), constraints: const BoxConstraints.expand(), padding: ThemeUploadWidget.padding, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Spacer(), FlowySvg( FlowySvgs.close_m, size: ThemeUploadWidget.iconSize, color: AFThemeExtension.of(context).onBackground, ), FlowyText.medium( errorMessage, overflow: TextOverflow.ellipsis, ), ThemeUploadWidget.elementSpacer, const ThemeUploadLearnMoreButton(), ThemeUploadWidget.elementSpacer, ThemeUploadButton(color: Theme.of(context).colorScheme.error), ThemeUploadWidget.elementSpacer, ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/error_page/error_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flutter/material.dart'; class ThemeUploadLearnMoreButton extends StatelessWidget { const ThemeUploadLearnMoreButton({super.key}); static const learnMoreURL = 'https://docs.appflowy.io/docs/appflowy/product/themes'; @override Widget build(BuildContext context) { return SizedBox( height: ThemeUploadWidget.buttonSize.height, child: IntrinsicWidth( child: SecondaryButton( outlineColor: AFThemeExtension.of(context).onBackground, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: FlowyText.medium( fontSize: ThemeUploadWidget.buttonFontSize, LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(), ), ), onPressed: () async { final uri = Uri.parse(learnMoreURL); await afLaunchUri( uri, context: context, onFailure: (_) async { if (context.mounted) { await Dialogs.show( context, child: FlowyDialog( child: FlowyErrorPage.message( LocaleKeys .settings_appearance_themeUpload_urlUploadFailure .tr() .replaceAll( '{}', uri.toString(), ), howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), ), ), ); } }, ); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class ThemeUploadLoadingWidget extends StatelessWidget { const ThemeUploadLoadingWidget({super.key}); @override Widget build(BuildContext context) { return Container( padding: ThemeUploadWidget.padding, color: Theme.of(context) .colorScheme .surface .withValues(alpha: ThemeUploadWidget.fadeOpacity), constraints: const BoxConstraints.expand(), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator( color: Theme.of(context).colorScheme.primary, ), ThemeUploadWidget.elementSpacer, FlowyText.regular( LocaleKeys.settings_appearance_themeUpload_loading.tr(), ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; import 'package:flowy_infra/plugins/bloc/dynamic_plugin_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'theme_upload_decoration.dart'; import 'theme_upload_failure_widget.dart'; import 'theme_upload_loading_widget.dart'; import 'upload_new_theme_widget.dart'; class ThemeUploadWidget extends StatefulWidget { const ThemeUploadWidget({super.key}); static const double borderRadius = 8; static const double buttonFontSize = 14; static const Size buttonSize = Size(100, 32); static const EdgeInsets padding = EdgeInsets.all(12.0); static const Size iconSize = Size.square(48); static const Widget elementSpacer = SizedBox(height: 12); static const double fadeOpacity = 0.5; static const Duration fadeDuration = Duration(milliseconds: 750); @override State createState() => _ThemeUploadWidgetState(); } class _ThemeUploadWidgetState extends State { void listen(BuildContext context, DynamicPluginState state) { setState(() { state.whenOrNull( ready: (plugins) { child = const UploadNewThemeWidget(key: Key('upload_new_theme_widget')); }, deletionSuccess: () { child = const UploadNewThemeWidget(key: Key('upload_new_theme_widget')); }, processing: () { child = const ThemeUploadLoadingWidget( key: Key('upload_theme_loading_widget'), ); }, compilationFailure: (errorMessage) { child = ThemeUploadFailureWidget( key: const Key('upload_theme_failure_widget'), errorMessage: errorMessage, ); }, compilationSuccess: () { if (Navigator.of(context).canPop()) { Navigator.of(context) .pop(const DynamicPluginState.compilationSuccess()); } }, ); }); } Widget child = const UploadNewThemeWidget( key: Key('upload_new_theme_widget'), ); @override Widget build(BuildContext context) { return BlocListener( listener: listen, child: ThemeUploadDecoration( child: Center( child: AnimatedSwitcher( duration: ThemeUploadWidget.fadeDuration, switchInCurve: Curves.easeInOutCubicEmphasized, child: child, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class UploadNewThemeWidget extends StatelessWidget { const UploadNewThemeWidget({super.key}); @override Widget build(BuildContext context) { return Container( color: Theme.of(context) .colorScheme .surface .withValues(alpha: ThemeUploadWidget.fadeOpacity), padding: ThemeUploadWidget.padding, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Spacer(), FlowySvg( FlowySvgs.folder_m, size: ThemeUploadWidget.iconSize, color: AFThemeExtension.of(context).onBackground, ), FlowyText.medium( LocaleKeys.settings_appearance_themeUpload_description.tr(), overflow: TextOverflow.ellipsis, ), ThemeUploadWidget.elementSpacer, const ThemeUploadLearnMoreButton(), ThemeUploadWidget.elementSpacer, const Divider(), ThemeUploadWidget.elementSpacer, const ThemeUploadButton(), ThemeUploadWidget.elementSpacer, ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/form_factor.dart ================================================ enum FormFactor { mobile._(600), tablet._(840), desktop._(1280); const FormFactor._(this.width); factory FormFactor.fromWidth(double width) { if (width < FormFactor.mobile.width) { return FormFactor.mobile; } else if (width < FormFactor.tablet.width) { return FormFactor.tablet; } else { return FormFactor.desktop; } } final double width; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart ================================================ extension HexOpacityExtension on String { /// Only used in a valid color String like '0xff00bcf0' String extractHex() { return substring(4); } /// Only used in a valid color String like '0xff00bcf0' String extractOpacity() { final opacityString = substring(2, 4); final opacityInt = int.parse(opacityString, radix: 16) / 2.55; return opacityInt.toStringAsFixed(0); } /// Apply on the hex string like '00bcf0', with opacity like '100' String combineHexWithOpacity(String opacity) { final opacityInt = (int.parse(opacity) * 2.55).round().toRadixString(16); return '0x$opacityInt$this'; } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/web_url_hint_widget.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; class WebUrlHintWidget extends StatelessWidget { const WebUrlHintWidget({super.key}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(left: 2), child: FlowyTooltip( message: LocaleKeys.workspace_learnMore.tr(), preferBelow: false, child: FlowyIconButton( width: 24, height: 24, icon: const FlowySvg( FlowySvgs.information_s, ), onPressed: () { afLaunchUrlString( 'https://appflowy.com/docs/self-host-appflowy-run-appflowy-web', ); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/widgets.dart'; import 'widgets/reminder_selector.dart'; typedef DaySelectedCallback = void Function(DateTime); typedef RangeSelectedCallback = void Function(DateTime, DateTime); typedef IsRangeChangedCallback = void Function(bool, DateTime?, DateTime?); typedef IncludeTimeChangedCallback = void Function(bool, DateTime?, DateTime?); abstract class AppFlowyDatePicker extends StatefulWidget { const AppFlowyDatePicker({ super.key, required this.dateTime, this.endDateTime, required this.includeTime, required this.isRange, this.reminderOption = ReminderOption.none, required this.dateFormat, required this.timeFormat, this.onDaySelected, this.onRangeSelected, this.onIncludeTimeChanged, this.onIsRangeChanged, this.onReminderSelected, }); final DateTime? dateTime; final DateTime? endDateTime; final DateFormatPB dateFormat; final TimeFormatPB timeFormat; /// Called when the date is picked, whether by submitting a date from the top /// or by selecting a date in the calendar. Will not be called if isRange is /// true final DaySelectedCallback? onDaySelected; /// Called when a date range is picked. Will not be called if isRange is false final RangeSelectedCallback? onRangeSelected; /// Whether the date picker allows inputting a time in addition to the date final bool includeTime; /// Called when the include time value is changed. This callback has the side /// effect of changing the dateTime values as well final IncludeTimeChangedCallback? onIncludeTimeChanged; // Whether the date picker supports date ranges final bool isRange; /// Called when the is range value is changed. This callback has the side /// effect of changing the dateTime values as well final IsRangeChangedCallback? onIsRangeChanged; final ReminderOption reminderOption; final OnReminderSelected? onReminderSelected; } abstract class AppFlowyDatePickerState extends State { // store date values in the state and refresh the ui upon any changes made, instead of only updating them after receiving update from backend. late DateTime? dateTime; late DateTime? startDateTime; late DateTime? endDateTime; late bool includeTime; late bool isRange; late ReminderOption reminderOption; late DateTime focusedDateTime; PageController? pageController; bool justChangedIsRange = false; @override void initState() { super.initState(); dateTime = widget.dateTime; startDateTime = widget.isRange ? widget.dateTime : null; endDateTime = widget.isRange ? widget.endDateTime : null; includeTime = widget.includeTime; isRange = widget.isRange; reminderOption = widget.reminderOption; focusedDateTime = widget.dateTime ?? DateTime.now(); } @override void didUpdateWidget(covariant oldWidget) { dateTime = widget.dateTime; if (widget.isRange) { startDateTime = widget.dateTime; endDateTime = widget.endDateTime; } else { startDateTime = endDateTime = null; } includeTime = widget.includeTime; isRange = widget.isRange; if (oldWidget.reminderOption != widget.reminderOption) { reminderOption = widget.reminderOption; } super.didUpdateWidget(oldWidget); } void onDateSelectedFromDatePicker( DateTime? newStartDateTime, DateTime? newEndDateTime, ) { if (newStartDateTime == null) { return; } if (isRange) { if (newEndDateTime == null) { if (justChangedIsRange && dateTime != null) { justChangedIsRange = false; DateTime start = dateTime!; DateTime end = combineDateTimes( DateTime( newStartDateTime.year, newStartDateTime.month, newStartDateTime.day, ), start, ); if (end.isBefore(start)) { (start, end) = (end, start); } widget.onRangeSelected?.call(start, end); setState(() { // hAcK: Resetting these state variables to null to reset the click counter of the table calendar widget, which doesn't expose a controller for us to do so otherwise. The parent widget needs to provide the data again so that it can be shown. dateTime = startDateTime = endDateTime = null; focusedDateTime = getNewFocusedDay(newStartDateTime); }); } else { final combined = combineDateTimes(newStartDateTime, dateTime); setState(() { dateTime = combined; startDateTime = combined; endDateTime = null; focusedDateTime = getNewFocusedDay(combined); }); } } else { bool switched = false; DateTime combinedDateTime = combineDateTimes(newStartDateTime, dateTime); DateTime combinedEndDateTime = combineDateTimes(newEndDateTime, widget.endDateTime); if (combinedEndDateTime.isBefore(combinedDateTime)) { (combinedDateTime, combinedEndDateTime) = (combinedEndDateTime, combinedDateTime); switched = true; } widget.onRangeSelected?.call(combinedDateTime, combinedEndDateTime); setState(() { dateTime = switched ? combinedDateTime : combinedEndDateTime; startDateTime = combinedDateTime; endDateTime = combinedEndDateTime; focusedDateTime = getNewFocusedDay(newEndDateTime); }); } } else { final combinedDateTime = combineDateTimes(newStartDateTime, dateTime); widget.onDaySelected?.call(combinedDateTime); setState(() { dateTime = combinedDateTime; focusedDateTime = getNewFocusedDay(combinedDateTime); }); } } DateTime combineDateTimes(DateTime date, DateTime? time) { final timeComponent = time == null ? Duration.zero : Duration(hours: time.hour, minutes: time.minute); return DateTime(date.year, date.month, date.day).add(timeComponent); } void onDateTimeInputSubmitted(DateTime value) { if (isRange) { DateTime end = endDateTime ?? value; if (end.isBefore(value)) { (value, end) = (end, value); } widget.onRangeSelected?.call(value, end); setState(() { dateTime = value; startDateTime = value; endDateTime = end; focusedDateTime = getNewFocusedDay(value); }); } else { widget.onDaySelected?.call(value); setState(() { dateTime = value; focusedDateTime = getNewFocusedDay(value); }); } } void onEndDateTimeInputSubmitted(DateTime value) { if (isRange) { if (endDateTime == null) { value = combineDateTimes(value, widget.endDateTime); } DateTime start = startDateTime ?? value; if (value.isBefore(start)) { (start, value) = (value, start); } widget.onRangeSelected?.call(start, value); if (endDateTime == null) { // hAcK: Resetting these state variables to null to reset the click counter of the table calendar widget, which doesn't expose a controller for us to do so otherwise. The parent widget needs to provide the data again so that it can be shown. setState(() { dateTime = startDateTime = endDateTime = null; focusedDateTime = getNewFocusedDay(value); }); } else { setState(() { dateTime = start; startDateTime = start; endDateTime = value; focusedDateTime = getNewFocusedDay(value); }); } } else { widget.onDaySelected?.call(value); setState(() { dateTime = value; focusedDateTime = getNewFocusedDay(value); }); } } DateTime getNewFocusedDay(DateTime dateTime) { if (focusedDateTime.year != dateTime.year || focusedDateTime.month != dateTime.month) { return DateTime(dateTime.year, dateTime.month); } else { return focusedDateTime; } } void onIsRangeChanged(bool value) { if (value) { justChangedIsRange = true; } final now = DateTime.now(); final fillerDate = includeTime ? DateTime(now.year, now.month, now.day, now.hour, now.minute) : DateTime(now.year, now.month, now.day); final newDateTime = dateTime ?? fillerDate; if (value) { widget.onIsRangeChanged!.call(value, newDateTime, newDateTime); } else { widget.onIsRangeChanged!.call(value, null, null); } setState(() { isRange = value; dateTime = focusedDateTime = newDateTime; if (value) { startDateTime = endDateTime = newDateTime; } else { startDateTime = endDateTime = null; } }); } void onIncludeTimeChanged(bool value) { late final DateTime? newDateTime; late final DateTime? newEndDateTime; final now = DateTime.now(); final fillerDate = value ? DateTime(now.year, now.month, now.day, now.hour, now.minute) : DateTime(now.year, now.month, now.day); if (value) { // fill date if empty, add time component newDateTime = dateTime == null ? fillerDate : combineDateTimes(dateTime!, fillerDate); newEndDateTime = isRange ? endDateTime == null ? fillerDate : combineDateTimes(endDateTime!, fillerDate) : null; } else { // fill date if empty, remove time component newDateTime = dateTime == null ? fillerDate : DateTime( dateTime!.year, dateTime!.month, dateTime!.day, ); newEndDateTime = isRange ? endDateTime == null ? fillerDate : DateTime( endDateTime!.year, endDateTime!.month, endDateTime!.day, ) : null; } widget.onIncludeTimeChanged!.call(value, newDateTime, newEndDateTime); setState(() { includeTime = value; dateTime = newDateTime ?? dateTime; if (isRange) { startDateTime = newDateTime ?? dateTime; endDateTime = newEndDateTime ?? endDateTime; } else { startDateTime = endDateTime = null; } }); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'appflowy_date_picker_base.dart'; import 'widgets/date_picker.dart'; import 'widgets/date_time_text_field.dart'; import 'widgets/end_time_button.dart'; import 'widgets/reminder_selector.dart'; class OptionGroup { OptionGroup({required this.options}); final List options; } class DesktopAppFlowyDatePicker extends AppFlowyDatePicker { const DesktopAppFlowyDatePicker({ super.key, required super.dateTime, super.endDateTime, required super.includeTime, required super.isRange, super.reminderOption = ReminderOption.none, required super.dateFormat, required super.timeFormat, super.onDaySelected, super.onRangeSelected, super.onIncludeTimeChanged, super.onIsRangeChanged, super.onReminderSelected, this.popoverMutex, this.options = const [], }); final PopoverMutex? popoverMutex; final List options; @override State createState() => DesktopAppFlowyDatePickerState(); } @visibleForTesting class DesktopAppFlowyDatePickerState extends AppFlowyDatePickerState { final isTabPressedNotifier = ValueNotifier(false); final refreshStartTextFieldNotifier = RefreshDateTimeTextFieldController(); final refreshEndTextFieldNotifier = RefreshDateTimeTextFieldController(); @override void dispose() { isTabPressedNotifier.dispose(); refreshStartTextFieldNotifier.dispose(); refreshEndTextFieldNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // GestureDetector is a workaround to stop popover from closing // when clicking on the date picker. return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () {}, child: Padding( padding: const EdgeInsets.only(top: 18.0, bottom: 12.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ DateTimeTextField( key: const ValueKey('date_time_text_field'), includeTime: includeTime, dateTime: isRange ? startDateTime : dateTime, dateFormat: widget.dateFormat, timeFormat: widget.timeFormat, popoverMutex: widget.popoverMutex, isTabPressed: isTabPressedNotifier, refreshTextController: refreshStartTextFieldNotifier, onSubmitted: onDateTimeInputSubmitted, showHint: true, ), if (isRange) ...[ const VSpace(8), DateTimeTextField( key: const ValueKey('end_date_time_text_field'), includeTime: includeTime, dateTime: endDateTime, dateFormat: widget.dateFormat, timeFormat: widget.timeFormat, popoverMutex: widget.popoverMutex, isTabPressed: isTabPressedNotifier, refreshTextController: refreshEndTextFieldNotifier, onSubmitted: onEndDateTimeInputSubmitted, showHint: isRange && !(dateTime != null && endDateTime == null), ), ], const VSpace(14), Focus( descendantsAreTraversable: false, child: _buildDatePickerHeader(), ), const VSpace(14), DatePicker( isRange: isRange, onDaySelected: (selectedDay, focusedDay) { onDateSelectedFromDatePicker(selectedDay, null); }, onRangeSelected: (start, end, focusedDay) { onDateSelectedFromDatePicker(start, end); }, selectedDay: dateTime, startDay: isRange ? startDateTime : null, endDay: isRange ? endDateTime : null, focusedDay: focusedDateTime, onCalendarCreated: (controller) { pageController = controller; }, onPageChanged: (focusedDay) { setState( () => focusedDateTime = DateTime( focusedDay.year, focusedDay.month, focusedDay.day, ), ); }, ), if (widget.onIsRangeChanged != null || widget.onIncludeTimeChanged != null) const TypeOptionSeparator(spacing: 12.0), if (widget.onIsRangeChanged != null) ...[ EndTimeButton( isRange: isRange, onChanged: onIsRangeChanged, ), const VSpace(4.0), ], if (widget.onIncludeTimeChanged != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: IncludeTimeButton( includeTime: includeTime, onChanged: onIncludeTimeChanged, ), ), if (widget.onReminderSelected != null) ...[ const _GroupSeparator(), ReminderSelector( mutex: widget.popoverMutex, hasTime: includeTime, timeFormat: widget.timeFormat, selectedOption: reminderOption, onOptionSelected: (option) { widget.onReminderSelected?.call(option); setState(() => reminderOption = option); }, ), ], if (widget.options.isNotEmpty) ...[ const _GroupSeparator(), ListView.separated( shrinkWrap: true, itemCount: widget.options.length, physics: const NeverScrollableScrollPhysics(), separatorBuilder: (_, __) => const _GroupSeparator(), itemBuilder: (_, index) => _renderGroupOptions(widget.options[index].options), ), ], ], ), ), ); } Widget _buildDatePickerHeader() { return Padding( padding: const EdgeInsetsDirectional.only(start: 22.0, end: 18.0), child: Row( children: [ Expanded( child: FlowyText( DateFormat.yMMMM().format(focusedDateTime), ), ), FlowyIconButton( width: 20, icon: FlowySvg( FlowySvgs.arrow_left_s, color: Theme.of(context).iconTheme.color, size: const Size.square(20.0), ), onPressed: () => pageController?.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ), ), const HSpace(4.0), FlowyIconButton( width: 20, icon: FlowySvg( FlowySvgs.arrow_right_s, color: Theme.of(context).iconTheme.color, size: const Size.square(20.0), ), onPressed: () { pageController?.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }, ), ], ), ); } Widget _renderGroupOptions(List options) => ListView.separated( shrinkWrap: true, itemCount: options.length, separatorBuilder: (_, __) => const VSpace(4), itemBuilder: (_, index) => options[index], ); @override void onDateTimeInputSubmitted(DateTime value) { if (isRange) { DateTime end = endDateTime ?? value; if (end.isBefore(value)) { (value, end) = (end, value); refreshStartTextFieldNotifier.refresh(); } widget.onRangeSelected?.call(value, end); setState(() { dateTime = value; startDateTime = value; endDateTime = end; }); } else { widget.onDaySelected?.call(value); setState(() { dateTime = value; focusedDateTime = getNewFocusedDay(value); }); } } @override void onEndDateTimeInputSubmitted(DateTime value) { if (isRange) { if (endDateTime == null) { value = combineDateTimes(value, widget.endDateTime); } DateTime start = startDateTime ?? value; if (value.isBefore(start)) { (start, value) = (value, start); refreshEndTextFieldNotifier.refresh(); } widget.onRangeSelected?.call(start, value); if (endDateTime == null) { // hAcK: Resetting these state variables to null to reset the click counter of the table calendar widget, which doesn't expose a controller for us to do so otherwise. The parent widget needs to provide the data again so that it can be shown. setState(() { dateTime = startDateTime = endDateTime = null; focusedDateTime = getNewFocusedDay(value); }); } else { setState(() { dateTime = start; startDateTime = start; endDateTime = value; focusedDateTime = getNewFocusedDay(value); }); } } else { widget.onDaySelected?.call(value); setState(() { dateTime = value; focusedDateTime = getNewFocusedDay(value); }); } } } class _GroupSeparator extends StatelessWidget { const _GroupSeparator(); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Container(color: Theme.of(context).dividerColor, height: 1.0), ); } class RefreshDateTimeTextFieldController extends ChangeNotifier { void refresh() => notifyListeners(); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_date_picker.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_option_decorate_box.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'appflowy_date_picker_base.dart'; class MobileAppFlowyDatePicker extends AppFlowyDatePicker { const MobileAppFlowyDatePicker({ super.key, required super.dateTime, super.endDateTime, required super.includeTime, required super.isRange, super.reminderOption = ReminderOption.none, required super.dateFormat, required super.timeFormat, super.onDaySelected, super.onRangeSelected, super.onIncludeTimeChanged, super.onIsRangeChanged, super.onReminderSelected, this.onClearDate, }); final VoidCallback? onClearDate; @override State createState() => _MobileAppFlowyDatePickerState(); } class _MobileAppFlowyDatePickerState extends AppFlowyDatePickerState { @override Widget build(BuildContext context) { return Column( children: [ FlowyOptionDecorateBox( showTopBorder: false, child: _TimePicker( dateTime: isRange ? startDateTime : dateTime, endDateTime: endDateTime, includeTime: includeTime, isRange: isRange, dateFormat: widget.dateFormat, timeFormat: widget.timeFormat, onStartTimeChanged: onDateTimeInputSubmitted, onEndTimeChanged: onEndDateTimeInputSubmitted, ), ), const _Divider(), FlowyOptionDecorateBox( child: MobileDatePicker( isRange: isRange, selectedDay: dateTime, startDay: isRange ? startDateTime : null, endDay: isRange ? endDateTime : null, focusedDay: focusedDateTime, onDaySelected: (selectedDay) { onDateSelectedFromDatePicker(selectedDay, null); }, onRangeSelected: (start, end) { onDateSelectedFromDatePicker(start, end); }, onPageChanged: (focusedDay) { setState(() => focusedDateTime = focusedDay); }, ), ), const _Divider(), if (widget.onIsRangeChanged != null) _IsRangeSwitch( isRange: widget.isRange, onRangeChanged: onIsRangeChanged, ), if (widget.onIncludeTimeChanged != null) _IncludeTimeSwitch( showTopBorder: widget.onIsRangeChanged == null, includeTime: includeTime, onIncludeTimeChanged: onIncludeTimeChanged, ), if (widget.onReminderSelected != null) ...[ const _Divider(), _ReminderSelector( selectedReminderOption: reminderOption, onReminderSelected: (option) { widget.onReminderSelected!.call(option); setState(() => reminderOption = option); }, timeFormat: widget.timeFormat, hasTime: widget.includeTime, ), ], if (widget.onClearDate != null) ...[ const _Divider(), _ClearDateButton( onClearDate: () { widget.onClearDate!.call(); Navigator.of(context).pop(); }, ), ], const _Divider(), ], ); } } class _Divider extends StatelessWidget { const _Divider(); @override Widget build(BuildContext context) => const VSpace(20.0); } class _ReminderSelector extends StatelessWidget { const _ReminderSelector({ this.selectedReminderOption, required this.onReminderSelected, required this.timeFormat, this.hasTime = false, }); final ReminderOption? selectedReminderOption; final OnReminderSelected onReminderSelected; final TimeFormatPB timeFormat; final bool hasTime; @override Widget build(BuildContext context) { final option = selectedReminderOption ?? ReminderOption.none; final availableOptions = [...ReminderOption.values]; if (option != ReminderOption.custom) { availableOptions.remove(ReminderOption.custom); } availableOptions.removeWhere( (o) => !o.timeExempt && (!hasTime ? !o.withoutTime : o.requiresNoTime), ); return FlowyOptionTile.text( text: LocaleKeys.datePicker_reminderLabel.tr(), trailing: Row( children: [ const HSpace(6.0), FlowyText( option.label, color: Theme.of(context).hintColor, ), const HSpace(4.0), FlowySvg( FlowySvgs.arrow_right_s, color: Theme.of(context).hintColor, size: const Size.square(18.0), ), ], ), onTap: () => showMobileBottomSheet( context, builder: (_) => DraggableScrollableSheet( expand: false, snap: true, initialChildSize: 0.7, minChildSize: 0.7, builder: (context, controller) => Column( children: [ ColoredBox( color: Theme.of(context).colorScheme.surface, child: const Center(child: DragHandle()), ), const _ReminderSelectHeader(), Flexible( child: SingleChildScrollView( controller: controller, child: Column( children: availableOptions.map( (o) { String label = o.label; if (o.withoutTime && !o.timeExempt) { const time = "09:00"; final t = timeFormat == TimeFormatPB.TwelveHour ? "$time AM" : time; label = "$label ($t)"; } return FlowyOptionTile.text( text: label, showTopBorder: o == ReminderOption.none, onTap: () { onReminderSelected(o); context.pop(); }, ); }, ).toList() ..insert(0, const _Divider()), ), ), ), ], ), ), ), ); } } class _ReminderSelectHeader extends StatelessWidget { const _ReminderSelectHeader(); @override Widget build(BuildContext context) { return Container( height: 56, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, border: Border( bottom: BorderSide( color: Theme.of(context).dividerColor, ), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ SizedBox( width: 120, child: AppBarCancelButton(onTap: context.pop), ), FlowyText.medium( LocaleKeys.datePicker_selectReminder.tr(), fontSize: 17.0, ), const HSpace(120), ], ), ); } } class _TimePicker extends StatelessWidget { const _TimePicker({ required this.dateTime, required this.endDateTime, required this.dateFormat, required this.timeFormat, required this.includeTime, required this.isRange, required this.onStartTimeChanged, this.onEndTimeChanged, }); final DateTime? dateTime; final DateTime? endDateTime; final bool includeTime; final bool isRange; final DateFormatPB dateFormat; final TimeFormatPB timeFormat; final void Function(DateTime time) onStartTimeChanged; final void Function(DateTime time)? onEndTimeChanged; @override Widget build(BuildContext context) { final dateStr = getDateStr(dateTime); final timeStr = getTimeStr(dateTime); final endDateStr = getDateStr(endDateTime); final endTimeStr = getTimeStr(endDateTime); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTime( context, dateStr, timeStr, includeTime, true, ), if (isRange) ...[ VSpace(8.0, color: Theme.of(context).colorScheme.surface), _buildTime( context, endDateStr, endTimeStr, includeTime, false, ), ], ], ), ); } Widget _buildTime( BuildContext context, String dateStr, String timeStr, bool includeTime, bool isStartDay, ) { final List children = []; final now = DateTime.now(); final hintDate = DateTime(now.year, now.month, 1, 9); if (!includeTime) { children.add( Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () async { final result = await _showDateTimePicker( context, isStartDay ? dateTime : endDateTime, use24hFormat: timeFormat == TimeFormatPB.TwentyFourHour, mode: CupertinoDatePickerMode.date, ); handleDateTimePickerResult(result, isStartDay, true); }, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0, vertical: 8, ), child: FlowyText( dateStr.isNotEmpty ? dateStr : getDateStr(hintDate), color: dateStr.isEmpty ? Theme.of(context).hintColor : null, ), ), ), ), ); } else { children.addAll([ Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () async { final result = await _showDateTimePicker( context, isStartDay ? dateTime : endDateTime, use24hFormat: timeFormat == TimeFormatPB.TwentyFourHour, mode: CupertinoDatePickerMode.date, ); handleDateTimePickerResult(result, isStartDay, true); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: FlowyText( dateStr.isNotEmpty ? dateStr : "", textAlign: TextAlign.center, ), ), ), ), Container( width: 1, height: 16, color: Theme.of(context).colorScheme.outline, ), Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () async { final result = await _showDateTimePicker( context, isStartDay ? dateTime : endDateTime, use24hFormat: timeFormat == TimeFormatPB.TwentyFourHour, mode: CupertinoDatePickerMode.time, ); handleDateTimePickerResult(result, isStartDay, false); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: FlowyText( timeStr.isNotEmpty ? timeStr : "", textAlign: TextAlign.center, ), ), ), ), ]); } return Container( constraints: const BoxConstraints(minHeight: 36), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), color: Theme.of(context).colorScheme.secondaryContainer, border: Border.all( color: Theme.of(context).colorScheme.outline, ), ), child: Row( children: children, ), ); } Future _showDateTimePicker( BuildContext context, DateTime? dateTime, { required CupertinoDatePickerMode mode, required bool use24hFormat, }) async { DateTime? result; return showMobileBottomSheet( context, builder: (context) => Column( mainAxisSize: MainAxisSize.min, children: [ ConstrainedBox( constraints: const BoxConstraints(maxHeight: 300), child: CupertinoDatePicker( mode: mode, initialDateTime: dateTime, use24hFormat: use24hFormat, onDateTimeChanged: (dateTime) { result = dateTime; }, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 36), child: FlowyTextButton( LocaleKeys.button_confirm.tr(), constraints: const BoxConstraints.tightFor(height: 42), mainAxisAlignment: MainAxisAlignment.center, fontColor: Theme.of(context).colorScheme.onPrimary, fillColor: Theme.of(context).primaryColor, onPressed: () { Navigator.of(context).pop(result); }, ), ), const VSpace(18.0), ], ), ); } void handleDateTimePickerResult( DateTime? result, bool isStartDay, bool isDate, ) { if (result == null) { return; } if (isDate) { final date = isStartDay ? dateTime : endDateTime; if (date != null) { final timeComponent = Duration(hours: date.hour, minutes: date.minute); result = DateTime(result.year, result.month, result.day).add(timeComponent); } } if (isStartDay) { onStartTimeChanged.call(result); } else { onEndTimeChanged?.call(result); } } String getDateStr(DateTime? dateTime) { if (dateTime == null) { return ""; } return DateFormat(dateFormat.pattern).format(dateTime); } String getTimeStr(DateTime? dateTime) { if (dateTime == null || !includeTime) { return ""; } return DateFormat(timeFormat.pattern).format(dateTime); } } class _IsRangeSwitch extends StatelessWidget { const _IsRangeSwitch({ required this.isRange, required this.onRangeChanged, }); final bool isRange; final Function(bool) onRangeChanged; @override Widget build(BuildContext context) { return FlowyOptionTile.toggle( text: LocaleKeys.grid_field_isRange.tr(), isSelected: isRange, onValueChanged: onRangeChanged, ); } } class _IncludeTimeSwitch extends StatelessWidget { const _IncludeTimeSwitch({ this.showTopBorder = true, required this.includeTime, required this.onIncludeTimeChanged, }); final bool showTopBorder; final bool includeTime; final Function(bool) onIncludeTimeChanged; @override Widget build(BuildContext context) { return FlowyOptionTile.toggle( showTopBorder: showTopBorder, text: LocaleKeys.grid_field_includeTime.tr(), isSelected: includeTime, onValueChanged: onIncludeTimeChanged, ); } } class _ClearDateButton extends StatelessWidget { const _ClearDateButton({required this.onClearDate}); final VoidCallback onClearDate; @override Widget build(BuildContext context) { return FlowyOptionTile.text( text: LocaleKeys.grid_field_clearDate.tr(), onTap: onClearDate, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; extension ToDateFormat on UserDateFormatPB { DateFormatPB get simplified => switch (this) { UserDateFormatPB.DayMonthYear => DateFormatPB.DayMonthYear, UserDateFormatPB.Friendly => DateFormatPB.Friendly, UserDateFormatPB.ISO => DateFormatPB.ISO, UserDateFormatPB.Locally => DateFormatPB.Local, UserDateFormatPB.US => DateFormatPB.US, _ => DateFormatPB.Friendly, }; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/layout.dart ================================================ import 'package:flutter/material.dart'; class DatePickerSize { static double scale = 1; static double get itemHeight => 26 * scale; static double get seperatorHeight => 4 * scale; static EdgeInsets get itemOptionInsets => const EdgeInsets.all(4); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart ================================================ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; extension ToTimeFormat on UserTimeFormatPB { TimeFormatPB get simplified => switch (this) { UserTimeFormatPB.TwelveHour => TimeFormatPB.TwelveHour, UserTimeFormatPB.TwentyFourHour => TimeFormatPB.TwentyFourHour, _ => TimeFormatPB.TwentyFourHour, }; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart ================================================ import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; class ClearDateButton extends StatelessWidget { const ClearDateButton({ super.key, required this.onClearDate, }); final VoidCallback onClearDate; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: SizedBox( height: DatePickerSize.itemHeight, child: FlowyButton( text: FlowyText(LocaleKeys.datePicker_clearDate.tr()), onTap: () { onClearDate(); PopoverContainer.of(context).close(); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker.dart ================================================ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:universal_platform/universal_platform.dart'; final kFirstDay = DateTime.utc(1970); final kLastDay = DateTime.utc(2100); class DatePicker extends StatefulWidget { const DatePicker({ super.key, required this.isRange, this.calendarFormat = CalendarFormat.month, this.startDay, this.endDay, this.selectedDay, required this.focusedDay, this.onDaySelected, this.onRangeSelected, this.onCalendarCreated, this.onPageChanged, }); final bool isRange; final CalendarFormat calendarFormat; final DateTime? startDay; final DateTime? endDay; final DateTime? selectedDay; final DateTime focusedDay; final void Function( DateTime selectedDay, DateTime focusedDay, )? onDaySelected; final void Function( DateTime? start, DateTime? end, DateTime focusedDay, )? onRangeSelected; final void Function(PageController pageController)? onCalendarCreated; final void Function(DateTime focusedDay)? onPageChanged; @override State createState() => _DatePickerState(); } class _DatePickerState extends State { late CalendarFormat _calendarFormat = widget.calendarFormat; @override Widget build(BuildContext context) { final textStyle = Theme.of(context).textTheme.bodyMedium!; final boxDecoration = BoxDecoration( color: Theme.of(context).cardColor, shape: BoxShape.circle, ); final calendarStyle = UniversalPlatform.isMobile ? _CalendarStyle.mobile( dowTextStyle: textStyle.copyWith( color: Theme.of(context).hintColor, fontSize: 14.0, ), ) : _CalendarStyle.desktop( dowTextStyle: AFThemeExtension.of(context).caption, selectedColor: Theme.of(context).colorScheme.primary, ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: TableCalendar( firstDay: kFirstDay, lastDay: kLastDay, focusedDay: widget.focusedDay, rowHeight: calendarStyle.rowHeight, calendarFormat: _calendarFormat, daysOfWeekHeight: calendarStyle.dowHeight, rangeSelectionMode: widget.isRange ? RangeSelectionMode.enforced : RangeSelectionMode.disabled, rangeStartDay: widget.isRange ? widget.startDay : null, rangeEndDay: widget.isRange ? widget.endDay : null, availableGestures: calendarStyle.availableGestures, availableCalendarFormats: const {CalendarFormat.month: 'Month'}, onCalendarCreated: widget.onCalendarCreated, headerVisible: calendarStyle.headerVisible, headerStyle: calendarStyle.headerStyle, calendarStyle: CalendarStyle( cellMargin: const EdgeInsets.all(3.5), defaultDecoration: boxDecoration, selectedDecoration: boxDecoration.copyWith( color: calendarStyle.selectedColor, ), todayDecoration: boxDecoration.copyWith( color: Colors.transparent, border: Border.all(color: calendarStyle.selectedColor), ), weekendDecoration: boxDecoration, outsideDecoration: boxDecoration, rangeStartDecoration: boxDecoration.copyWith( color: calendarStyle.selectedColor, ), rangeEndDecoration: boxDecoration.copyWith( color: calendarStyle.selectedColor, ), defaultTextStyle: textStyle, weekendTextStyle: textStyle, selectedTextStyle: textStyle.copyWith( color: Theme.of(context).colorScheme.surface, ), rangeStartTextStyle: textStyle.copyWith( color: Theme.of(context).colorScheme.surface, ), rangeEndTextStyle: textStyle.copyWith( color: Theme.of(context).colorScheme.surface, ), todayTextStyle: textStyle, outsideTextStyle: textStyle.copyWith( color: Theme.of(context).disabledColor, ), rangeHighlightColor: Theme.of(context).colorScheme.secondaryContainer, ), calendarBuilders: CalendarBuilders( dowBuilder: (context, day) { final locale = context.locale.toLanguageTag(); final label = DateFormat.E(locale).format(day); return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Center( child: Text(label, style: calendarStyle.dowTextStyle), ), ); }, ), selectedDayPredicate: (day) => widget.isRange ? false : isSameDay(widget.selectedDay, day), onFormatChanged: (calendarFormat) => setState(() => _calendarFormat = calendarFormat), onPageChanged: (focusedDay) { widget.onPageChanged?.call(focusedDay); }, onDaySelected: widget.onDaySelected, onRangeSelected: widget.onRangeSelected, ), ); } } class _CalendarStyle { _CalendarStyle.desktop({ required this.selectedColor, required this.dowTextStyle, }) : rowHeight = 33, dowHeight = 35, headerVisible = false, headerStyle = const HeaderStyle(), availableGestures = AvailableGestures.horizontalSwipe; _CalendarStyle.mobile({required this.dowTextStyle}) : rowHeight = 48, dowHeight = 48, headerVisible = false, headerStyle = const HeaderStyle(), selectedColor = const Color(0xFF00BCF0), availableGestures = AvailableGestures.horizontalSwipe; _CalendarStyle({ required this.rowHeight, required this.dowHeight, required this.headerVisible, required this.headerStyle, required this.dowTextStyle, required this.selectedColor, required this.availableGestures, }); final double rowHeight; final double dowHeight; final bool headerVisible; final HeaderStyle headerStyle; final TextStyle dowTextStyle; final Color selectedColor; final AvailableGestures availableGestures; } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart ================================================ import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/decoration.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; /// Provides arguemnts for [AppFlowyDatePicker] when showing /// a [DatePickerMenu] /// class DatePickerOptions { DatePickerOptions({ DateTime? focusedDay, this.selectedDay, this.includeTime = false, this.isRange = false, this.dateFormat = UserDateFormatPB.Friendly, this.timeFormat = UserTimeFormatPB.TwentyFourHour, this.selectedReminderOption, this.onDaySelected, this.onRangeSelected, this.onIncludeTimeChanged, this.onIsRangeChanged, this.onReminderSelected, }) : focusedDay = focusedDay ?? DateTime.now(); final DateTime focusedDay; final DateTime? selectedDay; final bool includeTime; final bool isRange; final UserDateFormatPB dateFormat; final UserTimeFormatPB timeFormat; final ReminderOption? selectedReminderOption; final DaySelectedCallback? onDaySelected; final RangeSelectedCallback? onRangeSelected; final IncludeTimeChangedCallback? onIncludeTimeChanged; final IsRangeChangedCallback? onIsRangeChanged; final OnReminderSelected? onReminderSelected; DatePickerOptions copyWith({ DateTime? focusedDay, DateTime? selectedDay, bool? includeTime, bool? isRange, UserDateFormatPB? dateFormat, UserTimeFormatPB? timeFormat, ReminderOption? selectedReminderOption, DaySelectedCallback? onDaySelected, RangeSelectedCallback? onRangeSelected, IncludeTimeChangedCallback? onIncludeTimeChanged, IsRangeChangedCallback? onIsRangeChanged, OnReminderSelected? onReminderSelected, }) { return DatePickerOptions( focusedDay: focusedDay ?? this.focusedDay, selectedDay: selectedDay ?? this.selectedDay, includeTime: includeTime ?? this.includeTime, isRange: isRange ?? this.isRange, dateFormat: dateFormat ?? this.dateFormat, timeFormat: timeFormat ?? this.timeFormat, selectedReminderOption: selectedReminderOption ?? this.selectedReminderOption, onDaySelected: onDaySelected ?? this.onDaySelected, onRangeSelected: onRangeSelected ?? this.onRangeSelected, onIncludeTimeChanged: onIncludeTimeChanged ?? this.onIncludeTimeChanged, onIsRangeChanged: onIsRangeChanged ?? this.onIsRangeChanged, onReminderSelected: onReminderSelected ?? this.onReminderSelected, ); } } abstract class DatePickerService { void show(Offset offset, {required DatePickerOptions options}); void dismiss(); } const double _datePickerWidth = 260; const double _datePickerHeight = 404; const double _ySpacing = 15; class DatePickerMenu extends DatePickerService { DatePickerMenu({required this.context, required this.editorState}); final BuildContext context; final EditorState editorState; PopoverMutex? popoverMutex; OverlayEntry? _menuEntry; @override void dismiss() { _menuEntry?.remove(); _menuEntry = null; popoverMutex?.close(); popoverMutex?.dispose(); popoverMutex = null; } @override void show(Offset offset, {required DatePickerOptions options}) => _show(offset, options: options); void _show(Offset offset, {required DatePickerOptions options}) { dismiss(); final editorSize = editorState.renderBox!.size; double offsetX = offset.dx; double offsetY = offset.dy; final showRight = (offset.dx + _datePickerWidth) < editorSize.width; if (!showRight) { offsetX = offset.dx - _datePickerWidth; } final showBelow = (offset.dy + _datePickerHeight) < editorSize.height; if (!showBelow) { if ((offset.dy - _datePickerHeight) < 0) { // Show dialog in the middle offsetY = offset.dy - (_datePickerHeight / 3); } else { // Show above offsetY = offset.dy - _datePickerHeight; } } popoverMutex = PopoverMutex(); _menuEntry = OverlayEntry( builder: (_) => Material( type: MaterialType.transparency, child: SizedBox( height: editorSize.height, width: editorSize.width, child: KeyboardListener( focusNode: FocusNode()..requestFocus(), onKeyEvent: (event) { if (event.logicalKey == LogicalKeyboardKey.escape) { dismiss(); } }, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: dismiss, child: Stack( children: [ _AnimatedDatePicker( offset: Offset(offsetX, offsetY), showBelow: showBelow, options: options, popoverMutex: popoverMutex, ), ], ), ), ), ), ), ); Overlay.of(context).insert(_menuEntry!); } } class _AnimatedDatePicker extends StatefulWidget { const _AnimatedDatePicker({ required this.offset, required this.showBelow, required this.options, this.popoverMutex, }); final Offset offset; final bool showBelow; final DatePickerOptions options; final PopoverMutex? popoverMutex; @override State<_AnimatedDatePicker> createState() => _AnimatedDatePickerState(); } class _AnimatedDatePickerState extends State<_AnimatedDatePicker> { late DatePickerOptions options = widget.options; @override Widget build(BuildContext context) { final dy = widget.offset.dy + (widget.showBelow ? _ySpacing : -_ySpacing); return AnimatedPositioned( duration: const Duration(milliseconds: 200), top: dy, left: widget.offset.dx, child: Container( decoration: FlowyDecoration.decoration( Theme.of(context).cardColor, Theme.of(context).colorScheme.shadow, ), constraints: BoxConstraints.loose(const Size(_datePickerWidth, 465)), child: DesktopAppFlowyDatePicker( includeTime: options.includeTime, isRange: options.isRange, dateFormat: options.dateFormat.simplified, timeFormat: options.timeFormat.simplified, dateTime: options.selectedDay, popoverMutex: widget.popoverMutex, reminderOption: options.selectedReminderOption ?? ReminderOption.none, onDaySelected: options.onDaySelected == null ? null : (d) { options.onDaySelected?.call(d); setState(() { options = options.copyWith(selectedDay: d); }); }, onIsRangeChanged: options.onIsRangeChanged == null ? null : (isRange, s, e) { options.onIsRangeChanged?.call(isRange, s, e); }, onIncludeTimeChanged: options.onIncludeTimeChanged == null ? null : (include, s, e) { options.onIncludeTimeChanged?.call(include, s, e); setState(() { options = options.copyWith(includeTime: include, selectedDay: s); }); }, onRangeSelected: options.onRangeSelected == null ? null : (s, e) { options.onRangeSelected?.call(s, e); }, onReminderSelected: options.onReminderSelected == null ? null : (o) { options.onReminderSelected?.call(o); setState(() { options = options.copyWith(selectedReminderOption: o); }); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; class DateTimeSetting extends StatefulWidget { const DateTimeSetting({ super.key, required this.dateFormat, required this.timeFormat, required this.onDateFormatChanged, required this.onTimeFormatChanged, }); final DateFormatPB dateFormat; final TimeFormatPB timeFormat; final Function(DateFormatPB) onDateFormatChanged; final Function(TimeFormatPB) onTimeFormatChanged; @override State createState() => _DateTimeSettingState(); } class _DateTimeSettingState extends State { final timeSettingPopoverMutex = PopoverMutex(); String? overlayIdentifier; @override void dispose() { timeSettingPopoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final List children = [ AppFlowyPopover( mutex: timeSettingPopoverMutex, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(8, 0), popupBuilder: (_) => DateFormatList( selectedFormat: widget.dateFormat, onSelected: _onDateFormatChanged, ), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 6.0), child: DateFormatButton(), ), ), AppFlowyPopover( mutex: timeSettingPopoverMutex, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(8, 0), popupBuilder: (_) => TimeFormatList( selectedFormat: widget.timeFormat, onSelected: _onTimeFormatChanged, ), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 6.0), child: TimeFormatButton(), ), ), ]; return SizedBox( width: 180, child: ListView.separated( shrinkWrap: true, separatorBuilder: (_, __) => VSpace(DatePickerSize.seperatorHeight), itemCount: children.length, itemBuilder: (_, int index) => children[index], padding: const EdgeInsets.symmetric(vertical: 6.0), ), ); } void _onTimeFormatChanged(TimeFormatPB format) { widget.onTimeFormatChanged(format); timeSettingPopoverMutex.close(); } void _onDateFormatChanged(DateFormatPB format) { widget.onDateFormatChanged(format); timeSettingPopoverMutex.close(); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_text_field.dart ================================================ import 'package:any_date/any_date.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../desktop_date_picker.dart'; import 'date_picker.dart'; class DateTimeTextField extends StatefulWidget { const DateTimeTextField({ super.key, required this.dateTime, required this.includeTime, required this.dateFormat, this.timeFormat, this.onSubmitted, this.popoverMutex, this.isTabPressed, this.refreshTextController, required this.showHint, }) : assert(includeTime && timeFormat != null || !includeTime); final DateTime? dateTime; final bool includeTime; final void Function(DateTime dateTime)? onSubmitted; final DateFormatPB dateFormat; final TimeFormatPB? timeFormat; final PopoverMutex? popoverMutex; final ValueNotifier? isTabPressed; final RefreshDateTimeTextFieldController? refreshTextController; final bool showHint; @override State createState() => _DateTimeTextFieldState(); } class _DateTimeTextFieldState extends State { late final FocusNode focusNode; late final FocusNode dateFocusNode; late final FocusNode timeFocusNode; final dateTextController = TextEditingController(); final timeTextController = TextEditingController(); final statesController = WidgetStatesController(); bool justSubmitted = false; DateFormat get dateFormat => DateFormat(widget.dateFormat.pattern); DateFormat get timeFormat => DateFormat(widget.timeFormat?.pattern); @override void initState() { super.initState(); updateTextControllers(); focusNode = FocusNode()..addListener(focusNodeListener); dateFocusNode = FocusNode(onKeyEvent: textFieldOnKeyEvent) ..addListener(dateFocusNodeListener); timeFocusNode = FocusNode(onKeyEvent: textFieldOnKeyEvent) ..addListener(timeFocusNodeListener); widget.isTabPressed?.addListener(isTabPressedListener); widget.refreshTextController?.addListener(updateTextControllers); widget.popoverMutex?.addPopoverListener(popoverListener); } @override void didUpdateWidget(covariant oldWidget) { if (oldWidget.dateTime != widget.dateTime || oldWidget.dateFormat != widget.dateFormat || oldWidget.timeFormat != widget.timeFormat) { statesController.update(WidgetState.error, false); updateTextControllers(); } super.didUpdateWidget(oldWidget); } @override void dispose() { dateTextController.dispose(); timeTextController.dispose(); widget.popoverMutex?.removePopoverListener(popoverListener); widget.isTabPressed?.removeListener(isTabPressedListener); widget.refreshTextController?.removeListener(updateTextControllers); dateFocusNode ..removeListener(dateFocusNodeListener) ..dispose(); timeFocusNode ..removeListener(timeFocusNodeListener) ..dispose(); focusNode ..removeListener(focusNodeListener) ..dispose(); statesController.dispose(); super.dispose(); } void focusNodeListener() { if (focusNode.hasFocus) { statesController.update(WidgetState.focused, true); widget.popoverMutex?.close(); } else { statesController.update(WidgetState.focused, false); } } void isTabPressedListener() { if (!dateFocusNode.hasFocus && !timeFocusNode.hasFocus) { return; } final controller = dateFocusNode.hasFocus ? dateTextController : timeTextController; if (widget.isTabPressed != null && widget.isTabPressed!.value) { controller.selection = TextSelection( baseOffset: 0, extentOffset: controller.text.characters.length, ); widget.isTabPressed?.value = false; } } KeyEventResult textFieldOnKeyEvent(FocusNode node, KeyEvent event) { if (event is KeyUpEvent && event.logicalKey == LogicalKeyboardKey.tab) { widget.isTabPressed?.value = true; } return KeyEventResult.ignored; } void dateFocusNodeListener() { if (dateFocusNode.hasFocus || justSubmitted) { justSubmitted = true; return; } final expected = widget.dateTime == null ? "" : DateFormat(widget.dateFormat.pattern).format(widget.dateTime!); if (expected != dateTextController.text.trim()) { onDateTextFieldSubmitted(); } } void timeFocusNodeListener() { if (timeFocusNode.hasFocus || widget.timeFormat == null || justSubmitted) { justSubmitted = true; return; } final expected = widget.dateTime == null ? "" : DateFormat(widget.timeFormat!.pattern).format(widget.dateTime!); if (expected != timeTextController.text.trim()) { onTimeTextFieldSubmitted(); } } void popoverListener() { if (focusNode.hasFocus) { focusNode.unfocus(); } } void updateTextControllers() { if (widget.dateTime == null) { dateTextController.clear(); timeTextController.clear(); return; } dateTextController.text = dateFormat.format(widget.dateTime!); timeTextController.text = timeFormat.format(widget.dateTime!); } void onDateTextFieldSubmitted() { DateTime? dateTime = parseDateTimeStr(dateTextController.text.trim()); if (dateTime == null) { statesController.update(WidgetState.error, true); return; } statesController.update(WidgetState.error, false); if (widget.dateTime != null) { final timeComponent = Duration( hours: widget.dateTime!.hour, minutes: widget.dateTime!.minute, seconds: widget.dateTime!.second, ); dateTime = DateTime( dateTime.year, dateTime.month, dateTime.day, ).add(timeComponent); } widget.onSubmitted?.call(dateTime); } void onTimeTextFieldSubmitted() { // this happens in the middle of a date range selection if (widget.dateTime == null) { widget.refreshTextController?.refresh(); statesController.update(WidgetState.error, true); return; } final adjustedTimeStr = "${dateTextController.text} ${timeTextController.text.trim()}"; final dateTime = parseDateTimeStr(adjustedTimeStr); if (dateTime == null) { statesController.update(WidgetState.error, true); return; } statesController.update(WidgetState.error, false); widget.onSubmitted?.call(dateTime); } DateTime? parseDateTimeStr(String string) { final locale = context.locale.toLanguageTag(); final parser = AnyDate.fromLocale(locale); final result = parser.tryParse(string); if (result == null || result.isBefore(kFirstDay) || result.isAfter(kLastDay)) { return null; } return result; } late final WidgetStateProperty borderColor = WidgetStateProperty.resolveWith( (states) { if (states.contains(WidgetState.error)) { return Theme.of(context).colorScheme.errorContainer; } if (states.contains(WidgetState.focused)) { return Theme.of(context).colorScheme.primary; } return Theme.of(context).colorScheme.outline; }, ); @override Widget build(BuildContext context) { final now = DateTime.now(); final hintDate = DateTime(now.year, now.month, 1, 9); return Focus( focusNode: focusNode, skipTraversal: true, child: wrapWithGestures( child: ListenableBuilder( listenable: statesController, builder: (context, child) { final resolved = borderColor.resolve(statesController.value); return Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), child: Container( constraints: const BoxConstraints.tightFor(height: 32), decoration: BoxDecoration( border: Border.fromBorderSide( BorderSide( color: resolved ?? Colors.transparent, ), ), borderRadius: Corners.s8Border, ), child: child, ), ); }, child: widget.includeTime ? Row( children: [ Expanded( child: TextField( key: const ValueKey('date_time_text_field_date'), focusNode: dateFocusNode, controller: dateTextController, style: Theme.of(context).textTheme.bodyMedium, decoration: getInputDecoration( const EdgeInsetsDirectional.fromSTEB(12, 6, 6, 6), dateFormat.format(hintDate), ), onSubmitted: (value) { justSubmitted = true; onDateTextFieldSubmitted(); }, ), ), VerticalDivider( indent: 4, endIndent: 4, width: 1, color: Theme.of(context).colorScheme.outline, ), Expanded( child: TextField( key: const ValueKey('date_time_text_field_time'), focusNode: timeFocusNode, controller: timeTextController, style: Theme.of(context).textTheme.bodyMedium, maxLength: widget.timeFormat == TimeFormatPB.TwelveHour ? 8 // 12:34 PM = 8 characters : 5, // 12:34 = 5 characters inputFormatters: [ FilteringTextInputFormatter.allow( RegExp('[0-9:AaPpMm]'), ), ], decoration: getInputDecoration( const EdgeInsetsDirectional.fromSTEB(6, 6, 12, 6), timeFormat.format(hintDate), ), onSubmitted: (value) { justSubmitted = true; onTimeTextFieldSubmitted(); }, ), ), ], ) : Center( child: TextField( key: const ValueKey('date_time_text_field_date'), focusNode: dateFocusNode, controller: dateTextController, style: Theme.of(context).textTheme.bodyMedium, decoration: getInputDecoration( const EdgeInsets.symmetric(horizontal: 12, vertical: 6), dateFormat.format(hintDate), ), onSubmitted: (value) { justSubmitted = true; onDateTextFieldSubmitted(); }, ), ), ), ), ); } Widget wrapWithGestures({required Widget child}) { return GestureDetector( onTapDown: (_) { statesController.update(WidgetState.pressed, true); }, onTapCancel: () { statesController.update(WidgetState.pressed, false); }, onTap: () { statesController.update(WidgetState.pressed, false); }, child: child, ); } InputDecoration getInputDecoration( EdgeInsetsGeometry padding, String? hintText, ) { return InputDecoration( border: InputBorder.none, contentPadding: padding, isCollapsed: true, isDense: true, hintText: widget.showHint ? hintText : null, counterText: "", hintStyle: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: Theme.of(context).hintColor), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; class DateTypeOptionButton extends StatelessWidget { const DateTypeOptionButton({ super.key, required this.dateFormat, required this.timeFormat, required this.onDateFormatChanged, required this.onTimeFormatChanged, required this.popoverMutex, }); final DateFormatPB dateFormat; final TimeFormatPB timeFormat; final Function(DateFormatPB) onDateFormatChanged; final Function(TimeFormatPB) onTimeFormatChanged; final PopoverMutex? popoverMutex; @override Widget build(BuildContext context) { final title = "${LocaleKeys.datePicker_dateFormat.tr()} & ${LocaleKeys.datePicker_timeFormat.tr()}"; return AppFlowyPopover( mutex: popoverMutex, offset: const Offset(8, 0), margin: EdgeInsets.zero, constraints: BoxConstraints.loose(const Size(140, 100)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: SizedBox( height: GridSize.popoverItemHeight, child: FlowyButton( text: FlowyText(title), rightIcon: const FlowySvg(FlowySvgs.more_s), ), ), ), popupBuilder: (_) => DateTimeSetting( dateFormat: dateFormat, timeFormat: timeFormat, onDateFormatChanged: (format) { onDateFormatChanged(format); popoverMutex?.close(); }, onTimeFormatChanged: (format) { onTimeFormatChanged(format); popoverMutex?.close(); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; class EndTimeButton extends StatelessWidget { const EndTimeButton({ super.key, required this.isRange, required this.onChanged, }); final bool isRange; final Function(bool value) onChanged; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: SizedBox( height: GridSize.popoverItemHeight, child: Padding( padding: GridSize.typeOptionContentInsets, child: Row( children: [ FlowySvg( FlowySvgs.date_s, color: Theme.of(context).iconTheme.color, ), const HSpace(6), FlowyText(LocaleKeys.datePicker_isRange.tr()), const Spacer(), Toggle( value: isRange, onChanged: onChanged, padding: EdgeInsets.zero, ), ], ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; class MobileDatePicker extends StatefulWidget { const MobileDatePicker({ super.key, this.selectedDay, this.startDay, this.endDay, required this.focusedDay, required this.isRange, this.onDaySelected, this.onRangeSelected, this.onPageChanged, }); final DateTime? selectedDay; final DateTime? startDay; final DateTime? endDay; final DateTime focusedDay; final bool isRange; final void Function(DateTime)? onDaySelected; final void Function(DateTime?, DateTime?)? onRangeSelected; final void Function(DateTime)? onPageChanged; @override State createState() => _MobileDatePickerState(); } class _MobileDatePickerState extends State { PageController? pageController; @override Widget build(BuildContext context) { return Column( children: [ const VSpace(8.0), _buildHeader(context), const VSpace(8.0), _buildCalendar(context), const VSpace(16.0), ], ); } Widget _buildCalendar(BuildContext context) { return DatePicker( isRange: widget.isRange, onDaySelected: (selectedDay, _) { widget.onDaySelected?.call(selectedDay); }, focusedDay: widget.focusedDay, onRangeSelected: (start, end, focusedDay) { widget.onRangeSelected?.call(start, end); }, selectedDay: widget.selectedDay, startDay: widget.startDay, endDay: widget.endDay, onCalendarCreated: (pageController) { this.pageController = pageController; }, onPageChanged: widget.onPageChanged, ); } Widget _buildHeader(BuildContext context) { return Padding( padding: const EdgeInsetsDirectional.only(start: 16, end: 8), child: Row( children: [ Expanded( child: FlowyText( DateFormat.yMMMM().format(widget.focusedDay), ), ), FlowyButton( useIntrinsicWidth: true, text: FlowySvg( FlowySvgs.arrow_left_s, color: Theme.of(context).iconTheme.color, size: const Size.square(24.0), ), onTap: () { pageController?.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }, ), const HSpace(24.0), FlowyButton( useIntrinsicWidth: true, text: FlowySvg( FlowySvgs.arrow_right_s, color: Theme.of(context).iconTheme.color, size: const Size.square(24.0), ), onTap: () { pageController?.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }, ), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; const _height = 44.0; class MobileDateHeader extends StatelessWidget { const MobileDateHeader({super.key}); @override Widget build(BuildContext context) { return Container( color: Theme.of(context).colorScheme.surface, child: Stack( children: [ const Align( alignment: Alignment.centerLeft, child: AppBarCloseButton(), ), Align( child: FlowyText.medium( LocaleKeys.grid_field_dateFieldName.tr(), fontSize: 16, ), ), ].map((e) => SizedBox(height: _height, child: e)).toList(), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; typedef OnReminderSelected = void Function(ReminderOption option); class ReminderSelector extends StatelessWidget { const ReminderSelector({ super.key, required this.mutex, required this.selectedOption, required this.onOptionSelected, required this.timeFormat, this.hasTime = false, }); final PopoverMutex? mutex; final ReminderOption selectedOption; final OnReminderSelected? onOptionSelected; final TimeFormatPB timeFormat; final bool hasTime; @override Widget build(BuildContext context) { final options = ReminderOption.values.toList(); if (selectedOption != ReminderOption.custom) { options.remove(ReminderOption.custom); } options.removeWhere( (o) => !o.timeExempt && (!hasTime ? !o.withoutTime : o.requiresNoTime), ); final optionWidgets = options.map( (o) { String label = o.label; if (o.withoutTime && !o.timeExempt) { const time = "09:00"; final t = timeFormat == TimeFormatPB.TwelveHour ? "$time AM" : time; label = "$label ($t)"; } return SizedBox( height: DatePickerSize.itemHeight, child: FlowyButton( text: FlowyText(label), rightIcon: o == selectedOption ? const FlowySvg(FlowySvgs.check_s) : null, onTap: () { if (o != selectedOption) { onOptionSelected?.call(o); mutex?.close(); } }, ), ); }, ).toList(); return AppFlowyPopover( mutex: mutex, offset: const Offset(8, 0), margin: EdgeInsets.zero, constraints: const BoxConstraints(maxHeight: 400, maxWidth: 205), popupBuilder: (_) => Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.all(6.0), child: SeparatedColumn( children: optionWidgets, separatorBuilder: () => VSpace(DatePickerSize.seperatorHeight), ), ), ], ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: SizedBox( height: DatePickerSize.itemHeight, child: FlowyButton( text: FlowyText(LocaleKeys.datePicker_reminderLabel.tr()), rightIcon: Row( children: [ FlowyText.regular(selectedOption.label), const FlowySvg(FlowySvgs.more_s), ], ), ), ), ), ); } } enum ReminderOption { none(time: Duration()), atTimeOfEvent(time: Duration()), fiveMinsBefore(time: Duration(minutes: 5)), tenMinsBefore(time: Duration(minutes: 10)), fifteenMinsBefore(time: Duration(minutes: 15)), thirtyMinsBefore(time: Duration(minutes: 30)), oneHourBefore(time: Duration(hours: 1)), twoHoursBefore(time: Duration(hours: 2)), onDayOfEvent( time: Duration(hours: 9), withoutTime: true, requiresNoTime: true, ), // 9:00 AM the day before (24-9) oneDayBefore(time: Duration(hours: 15), withoutTime: true), twoDaysBefore(time: Duration(days: 1, hours: 15), withoutTime: true), oneWeekBefore(time: Duration(days: 6, hours: 15), withoutTime: true), custom(time: Duration()); const ReminderOption({ required this.time, this.withoutTime = false, this.requiresNoTime = false, }) : assert(!requiresNoTime || withoutTime); final Duration time; /// If true, don't consider the time component of the dateTime final bool withoutTime; /// If true, [withoutTime] must be true as well. Will add time instead of subtract to get notification time. final bool requiresNoTime; bool get timeExempt => [ReminderOption.none, ReminderOption.custom].contains(this); String get label => switch (this) { ReminderOption.none => LocaleKeys.datePicker_reminderOptions_none.tr(), ReminderOption.atTimeOfEvent => LocaleKeys.datePicker_reminderOptions_atTimeOfEvent.tr(), ReminderOption.fiveMinsBefore => LocaleKeys.datePicker_reminderOptions_fiveMinsBefore.tr(), ReminderOption.tenMinsBefore => LocaleKeys.datePicker_reminderOptions_tenMinsBefore.tr(), ReminderOption.fifteenMinsBefore => LocaleKeys.datePicker_reminderOptions_fifteenMinsBefore.tr(), ReminderOption.thirtyMinsBefore => LocaleKeys.datePicker_reminderOptions_thirtyMinsBefore.tr(), ReminderOption.oneHourBefore => LocaleKeys.datePicker_reminderOptions_oneHourBefore.tr(), ReminderOption.twoHoursBefore => LocaleKeys.datePicker_reminderOptions_twoHoursBefore.tr(), ReminderOption.onDayOfEvent => LocaleKeys.datePicker_reminderOptions_onDayOfEvent.tr(), ReminderOption.oneDayBefore => LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), ReminderOption.twoDaysBefore => LocaleKeys.datePicker_reminderOptions_twoDaysBefore.tr(), ReminderOption.oneWeekBefore => LocaleKeys.datePicker_reminderOptions_oneWeekBefore.tr(), ReminderOption.custom => LocaleKeys.datePicker_reminderOptions_custom.tr(), }; static ReminderOption fromDateDifference( DateTime eventDate, DateTime reminderDate, ) { final def = fromMinutes(eventDate.difference(reminderDate).inMinutes); if (def != ReminderOption.custom) { return def; } final diff = eventDate.withoutTime.difference(reminderDate).inMinutes; return fromMinutes(diff); } static ReminderOption fromMinutes(int minutes) => switch (minutes) { 0 => ReminderOption.atTimeOfEvent, 5 => ReminderOption.fiveMinsBefore, 10 => ReminderOption.tenMinsBefore, 15 => ReminderOption.fifteenMinsBefore, 30 => ReminderOption.thirtyMinsBefore, 60 => ReminderOption.oneHourBefore, 120 => ReminderOption.twoHoursBefore, // Negative because Event Day Today + 940 minutes -540 => ReminderOption.onDayOfEvent, 900 => ReminderOption.oneDayBefore, 2340 => ReminderOption.twoDaysBefore, 9540 => ReminderOption.oneWeekBefore, _ => ReminderOption.custom, }; DateTime getNotificationDateTime(DateTime date) { return withoutTime ? requiresNoTime ? date.withoutTime.add(time) : date.withoutTime.subtract(time) : date.subtract(time); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialog_v2.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; typedef SimpleAFDialogAction = (String, void Function(BuildContext)?); /// A simple dialog with a title, content, and actions. /// /// The primary button is a filled button and colored using theme or destructive /// color depending on the [isDestructive] parameter. The secondary button is an /// outlined button. /// Future showSimpleAFDialog({ required BuildContext context, required String title, required String content, bool isDestructive = false, required SimpleAFDialogAction primaryAction, SimpleAFDialogAction? secondaryAction, bool barrierDismissible = true, }) { final theme = AppFlowyTheme.of(context); return showDialog( context: context, barrierColor: theme.surfaceColorScheme.overlay, barrierDismissible: barrierDismissible, builder: (_) { return AFModal( constraints: BoxConstraints( maxWidth: AFModalDimension.S, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ AFModalHeader( leading: Text( title, ), trailing: [ AFGhostButton.normal( onTap: () => Navigator.of(context).pop(), padding: EdgeInsets.all(theme.spacing.xs), builder: (context, isHovering, disabled) { return FlowySvg( FlowySvgs.toast_close_s, size: Size.square(20), ); }, ), ], ), Flexible( child: ConstrainedBox( // AFModalDimension.dialogHeight - header - footer constraints: BoxConstraints(minHeight: 108.0), child: AFModalBody( child: Text( content, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), ), ), ), AFModalFooter( trailing: [ if (secondaryAction != null) AFOutlinedTextButton.normal( text: secondaryAction.$1, onTap: () { secondaryAction.$2?.call(context); Navigator.of(context).pop(); }, ), isDestructive ? AFFilledTextButton.destructive( text: primaryAction.$1, onTap: () { primaryAction.$2?.call(context); Navigator.of(context).pop(); }, ) : AFFilledTextButton.primary( text: primaryAction.$1, onTap: () { primaryAction.$2?.call(context); Navigator.of(context).pop(); }, ), ], ), ], ), ); }, ); } /// Shows a dialog for renaming an item with a text field. /// The API is flexible: either provide a callback for confirmation or use the /// returned Future to get the new value. /// Future showAFTextFieldDialog({ required BuildContext context, required String title, required String initialValue, void Function(String)? onConfirm, bool barrierDismissible = true, bool selectAll = true, int? maxLength, String? hintText, }) { return showDialog( context: context, barrierColor: AppFlowyTheme.of(context).surfaceColorScheme.overlay, barrierDismissible: barrierDismissible, builder: (context) { return AFTextFieldDialog( title: title, initialValue: initialValue, onConfirm: onConfirm, selectAll: selectAll, maxLength: maxLength, hintText: hintText, ); }, ); } class AFTextFieldDialog extends StatefulWidget { const AFTextFieldDialog({ super.key, required this.title, required this.initialValue, this.onConfirm, this.selectAll = true, this.maxLength, this.hintText, }); final String title; final String initialValue; final void Function(String)? onConfirm; final bool selectAll; final int? maxLength; final String? hintText; @override State createState() => _AFTextFieldDialogState(); } class _AFTextFieldDialogState extends State { final textController = TextEditingController(); @override void initState() { super.initState(); textController.value = TextEditingValue( text: widget.initialValue, selection: widget.selectAll ? TextSelection( baseOffset: 0, extentOffset: widget.initialValue.length, ) : TextSelection.collapsed( offset: widget.initialValue.length, ), ); } @override void dispose() { textController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFModal( constraints: BoxConstraints( maxWidth: AFModalDimension.S, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ AFModalHeader( leading: Text( widget.title, ), trailing: [ AFGhostButton.normal( onTap: () => Navigator.of(context).pop(), padding: EdgeInsets.all(theme.spacing.xs), builder: (context, isHovering, disabled) { return FlowySvg( FlowySvgs.toast_close_s, size: Size.square(20), ); }, ), ], ), Flexible( child: AFModalBody( child: AFTextField( autoFocus: true, size: AFTextFieldSize.m, hintText: widget.hintText, maxLength: widget.maxLength, controller: textController, onSubmitted: (_) { handleConfirm(); }, ), ), ), AFModalFooter( trailing: [ AFOutlinedTextButton.normal( text: LocaleKeys.button_cancel.tr(), onTap: () => Navigator.of(context).pop(), ), ValueListenableBuilder( valueListenable: textController, builder: (contex, value, child) { return AFFilledTextButton.primary( text: LocaleKeys.button_confirm.tr(), disabled: value.text.trim().isEmpty, onTap: handleConfirm, ); }, ), ], ), ], ), ); } void handleConfirm() { final text = textController.text.trim(); if (text.isEmpty) { return; } widget.onConfirm?.call(text); Navigator.of(context).pop(text); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:toastification/toastification.dart'; import 'package:universal_platform/universal_platform.dart'; export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; export 'package:toastification/toastification.dart'; class NavigatorCustomDialog extends StatefulWidget { const NavigatorCustomDialog({ super.key, required this.child, this.cancel, this.confirm, this.hideCancelButton = false, }); final Widget child; final void Function()? cancel; final void Function()? confirm; final bool hideCancelButton; @override State createState() => _NavigatorCustomDialog(); } class _NavigatorCustomDialog extends State { @override Widget build(BuildContext context) { return StyledDialog( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ ...[ ConstrainedBox( constraints: const BoxConstraints( maxWidth: 400, maxHeight: 260, ), child: widget.child, ), ], if (widget.confirm != null) ...[ const VSpace(20), OkCancelButton( onOkPressed: () { widget.confirm?.call(); Navigator.of(context).pop(); }, onCancelPressed: widget.hideCancelButton ? null : () { widget.cancel?.call(); Navigator.of(context).pop(); }, ), ], ], ), ); } } class NavigatorAlertDialog extends StatefulWidget { const NavigatorAlertDialog({ super.key, required this.title, this.cancel, this.confirm, this.hideCancelButton = false, this.constraints, }); final String title; final void Function()? cancel; final void Function()? confirm; final bool hideCancelButton; final BoxConstraints? constraints; @override State createState() => _CreateFlowyAlertDialog(); } class _CreateFlowyAlertDialog extends State { @override Widget build(BuildContext context) { return StyledDialog( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ ...[ ConstrainedBox( constraints: widget.constraints ?? const BoxConstraints( maxWidth: 400, maxHeight: 260, ), child: FlowyText.medium( widget.title, fontSize: FontSizes.s16, textAlign: TextAlign.center, color: Theme.of(context).colorScheme.tertiary, maxLines: null, ), ), ], if (widget.confirm != null) ...[ const VSpace(20), OkCancelButton( onOkPressed: () { widget.confirm?.call(); Navigator.of(context).pop(); }, onCancelPressed: widget.hideCancelButton ? null : () { widget.cancel?.call(); Navigator.of(context).pop(); }, ), ], ], ), ); } } class NavigatorOkCancelDialog extends StatelessWidget { const NavigatorOkCancelDialog({ super.key, this.onOkPressed, this.onCancelPressed, this.okTitle, this.cancelTitle, this.title, this.message, this.maxWidth, this.titleUpperCase = true, this.autoDismiss = true, }); final VoidCallback? onOkPressed; final VoidCallback? onCancelPressed; final String? okTitle; final String? cancelTitle; final String? title; final String? message; final double? maxWidth; final bool titleUpperCase; final bool autoDismiss; @override Widget build(BuildContext context) { final onCancel = onCancelPressed == null ? null : () { onCancelPressed?.call(); if (autoDismiss) { Navigator.of(context).pop(); } }; return StyledDialog( maxWidth: maxWidth ?? 500, padding: EdgeInsets.symmetric(horizontal: Insets.xl, vertical: Insets.l), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (title != null) ...[ FlowyText.medium( titleUpperCase ? title!.toUpperCase() : title!, fontSize: FontSizes.s16, maxLines: 3, ), VSpace(Insets.sm * 1.5), Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, height: 1, ), VSpace(Insets.m * 1.5), ], if (message != null) FlowyText.medium( message!, maxLines: 3, ), SizedBox(height: Insets.l), OkCancelButton( onOkPressed: () { onOkPressed?.call(); if (autoDismiss) { Navigator.of(context).pop(); } }, onCancelPressed: onCancel, okTitle: okTitle?.toUpperCase(), cancelTitle: cancelTitle?.toUpperCase(), ), ], ), ); } } class OkCancelButton extends StatelessWidget { const OkCancelButton({ super.key, this.onOkPressed, this.onCancelPressed, this.okTitle, this.cancelTitle, this.minHeight, this.alignment = MainAxisAlignment.spaceAround, this.mode = TextButtonMode.big, }); final VoidCallback? onOkPressed; final VoidCallback? onCancelPressed; final String? okTitle; final String? cancelTitle; final double? minHeight; final MainAxisAlignment alignment; final TextButtonMode mode; @override Widget build(BuildContext context) { return SizedBox( height: 48, child: Row( mainAxisAlignment: alignment, children: [ if (onCancelPressed != null) SecondaryTextButton( cancelTitle ?? LocaleKeys.button_cancel.tr(), onPressed: onCancelPressed, mode: mode, ), if (onCancelPressed != null) HSpace(Insets.m), if (onOkPressed != null) PrimaryTextButton( okTitle ?? LocaleKeys.button_ok.tr(), onPressed: onOkPressed, mode: mode, ), ], ), ); } } ToastificationItem showToastNotification({ BuildContext? context, String? message, TextSpan? richMessage, String? description, ToastificationType type = ToastificationType.success, ToastificationCallbacks? callbacks, bool showCloseButton = false, double bottomPadding = 100, }) { assert( (message == null) != (richMessage == null), "Exactly one of message or richMessage must be non-null.", ); return toastification.showCustom( context: context, alignment: Alignment.bottomCenter, autoCloseDuration: const Duration(milliseconds: 3000), callbacks: callbacks ?? const ToastificationCallbacks(), builder: (_, item) { return UniversalPlatform.isMobile ? _MobileToast( message: message, type: type, bottomPadding: bottomPadding, description: description, ) : DesktopToast( message: message, richMessage: richMessage, type: type, onDismiss: () => toastification.dismiss(item), showCloseButton: showCloseButton, ); }, ); } class _MobileToast extends StatelessWidget { const _MobileToast({ this.message, this.type = ToastificationType.success, this.bottomPadding = 100, this.description, }); final String? message; final ToastificationType type; final double bottomPadding; final String? description; @override Widget build(BuildContext context) { if (message == null) { return const SizedBox.shrink(); } final hintText = FlowyText.regular( message!, fontSize: 16.0, figmaLineHeight: 18.0, color: Colors.white, maxLines: 10, ); final descriptionText = description != null ? FlowyText.regular( description!, fontSize: 12, color: Colors.white, maxLines: 10, ) : null; return Container( alignment: Alignment.bottomCenter, padding: EdgeInsets.only( bottom: bottomPadding, left: 16, right: 16, ), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12.0, vertical: 13.0, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12.0), color: const Color(0xE5171717), ), child: type == ToastificationType.success ? Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ if (type == ToastificationType.success) ...[ const FlowySvg( FlowySvgs.success_s, blendMode: null, ), const HSpace(8.0), ], Expanded(child: hintText), ], ), if (descriptionText != null) ...[ const VSpace(4.0), descriptionText, ], ], ) : Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ hintText, if (descriptionText != null) ...[ const VSpace(4.0), descriptionText, ], ], ), ), ); } } @visibleForTesting class DesktopToast extends StatelessWidget { const DesktopToast({ super.key, this.message, this.richMessage, required this.type, this.onDismiss, this.showCloseButton = false, }); final String? message; final TextSpan? richMessage; final ToastificationType type; final void Function()? onDismiss; final bool showCloseButton; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Center( child: Container( constraints: const BoxConstraints(maxWidth: 360.0), padding: EdgeInsets.symmetric( horizontal: theme.spacing.xl, vertical: theme.spacing.l, ), margin: const EdgeInsets.only(bottom: 32.0), decoration: BoxDecoration( color: theme.surfaceColorScheme.inverse, borderRadius: BorderRadius.circular(theme.borderRadius.l), boxShadow: theme.shadow.small, ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ // icon FlowySvg( switch (type) { ToastificationType.warning => FlowySvgs.toast_warning_filled_s, ToastificationType.success => FlowySvgs.toast_checked_filled_s, ToastificationType.error => FlowySvgs.toast_error_filled_s, _ => throw UnimplementedError(), }, size: const Size.square(20.0), blendMode: null, ), HSpace( theme.spacing.m, ), // text Flexible( child: message != null ? Text( message!, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textStyle.body.standard( color: theme.textColorScheme.onFill, ), ) : RichText( text: richMessage!, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), if (showCloseButton) ...[ HSpace( theme.spacing.xl, ), // close MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onDismiss, child: const SizedBox.square( dimension: 24.0, child: Center( child: FlowySvg( FlowySvgs.toast_close_s, size: Size.square(20.0), color: Color(0xFFBDBDBD), ), ), ), ), ), ], ], ), ), ); } } Future showConfirmDeletionDialog({ required BuildContext context, required String name, required String description, required VoidCallback onConfirm, }) { return showDialog( context: context, builder: (_) { final title = LocaleKeys.space_deleteConfirmation.tr() + name; return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: SizedBox( width: 440, child: ConfirmPopup( title: title, description: description, onConfirm: (_) => onConfirm(), ), ), ); }, ); } Future showConfirmDialog({ required BuildContext context, required String title, required String description, TextStyle? titleStyle, TextStyle? descriptionStyle, void Function(BuildContext context)? onConfirm, VoidCallback? onCancel, String? confirmLabel, ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk, WidgetBuilder? confirmButtonBuilder, Color? confirmButtonColor, }) { return showDialog( context: context, builder: (context) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: SizedBox( width: 440, child: ConfirmPopup( title: title, description: description, titleStyle: titleStyle, descriptionStyle: descriptionStyle, confirmButtonBuilder: confirmButtonBuilder, onConfirm: (_) => onConfirm?.call(context), onCancel: () => onCancel?.call(), confirmLabel: confirmLabel, style: style, confirmButtonColor: confirmButtonColor, ), ), ); }, ); } Future showCancelAndConfirmDialog({ required BuildContext context, required String title, required String description, void Function(BuildContext context)? onConfirm, VoidCallback? onCancel, String? confirmLabel, }) { return showDialog( context: context, builder: (_) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: SizedBox( width: 440, child: ConfirmPopup( title: title, description: description, onConfirm: (context) => onConfirm?.call(context), confirmLabel: confirmLabel, confirmButtonColor: Theme.of(context).colorScheme.primary, onCancel: () => onCancel?.call(), ), ), ); }, ); } Future showCustomConfirmDialog({ required BuildContext context, required String title, required String description, required Widget Function(BuildContext) builder, VoidCallback? onConfirm, VoidCallback? onCancel, String? confirmLabel, ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk, bool closeOnConfirm = true, bool showCloseButton = true, bool enableKeyboardListener = true, bool barrierDismissible = true, }) { return showDialog( context: context, barrierDismissible: barrierDismissible, builder: (context) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: SizedBox( width: 440, child: ConfirmPopup( title: title, description: description, onConfirm: (_) => onConfirm?.call(), onCancel: onCancel, confirmLabel: confirmLabel, confirmButtonColor: Theme.of(context).colorScheme.primary, style: style, closeOnAction: closeOnConfirm, showCloseButton: showCloseButton, enableKeyboardListener: enableKeyboardListener, child: builder(context), ), ), ); }, ); } Future showCancelAndDeleteDialog({ required BuildContext context, required String title, required String description, Widget Function(BuildContext)? builder, VoidCallback? onDelete, String? confirmLabel, bool closeOnAction = false, }) { return showDialog( context: context, builder: (_) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: SizedBox( width: 440, child: ConfirmPopup( title: title, description: description, onConfirm: (_) => onDelete?.call(), closeOnAction: closeOnAction, confirmLabel: confirmLabel, confirmButtonColor: Theme.of(context).colorScheme.error, child: builder?.call(context), ), ), ); }, ); } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/draggable_item/draggable_item.dart ================================================ import 'package:flutter/material.dart'; import 'package:universal_platform/universal_platform.dart'; /// This value is used to disable the auto scroll when dragging. /// /// It is used to prevent the auto scroll when dragging a view item to a document. bool disableAutoScrollWhenDragging = false; class DraggableItem extends StatefulWidget { const DraggableItem({ super.key, required this.child, required this.data, this.feedback, this.childWhenDragging, this.onAcceptWithDetails, this.onWillAcceptWithDetails, this.onMove, this.onLeave, this.enableAutoScroll = true, this.hitTestSize = const Size(100, 100), this.onDragging, }); final T data; final Widget child; final Widget? feedback; final Widget? childWhenDragging; final DragTargetAcceptWithDetails? onAcceptWithDetails; final DragTargetWillAcceptWithDetails? onWillAcceptWithDetails; final DragTargetMove? onMove; final DragTargetLeave? onLeave; /// Whether to enable auto scroll when dragging. /// /// If true, the draggable item must be wrapped inside a [Scrollable] widget. final bool enableAutoScroll; final Size hitTestSize; final void Function(bool isDragging)? onDragging; @override State> createState() => _DraggableItemState(); } class _DraggableItemState extends State> { ScrollableState? scrollable; EdgeDraggingAutoScroller? autoScroller; Rect? dragTarget; @override void didChangeDependencies() { super.didChangeDependencies(); initAutoScrollerIfNeeded(context); } @override Widget build(BuildContext context) { initAutoScrollerIfNeeded(context); return DragTarget( onAcceptWithDetails: widget.onAcceptWithDetails, onWillAcceptWithDetails: widget.onWillAcceptWithDetails, onMove: widget.onMove, onLeave: widget.onLeave, builder: (_, __, ___) => _Draggable( data: widget.data, feedback: widget.feedback ?? widget.child, childWhenDragging: widget.childWhenDragging ?? widget.child, child: widget.child, onDragUpdate: (details) { if (widget.enableAutoScroll && !disableAutoScrollWhenDragging) { dragTarget = details.globalPosition & widget.hitTestSize; autoScroller?.startAutoScrollIfNecessary(dragTarget!); } widget.onDragging?.call(true); }, onDragEnd: (details) { autoScroller?.stopAutoScroll(); dragTarget = null; widget.onDragging?.call(false); }, onDraggableCanceled: (_, __) { autoScroller?.stopAutoScroll(); dragTarget = null; widget.onDragging?.call(false); }, ), ); } void initAutoScrollerIfNeeded(BuildContext context) { if (!widget.enableAutoScroll || disableAutoScrollWhenDragging) { return; } scrollable = Scrollable.of(context); if (scrollable == null) { throw FlutterError( 'DraggableItem must be wrapped inside a Scrollable widget ' 'when enableAutoScroll is true.', ); } autoScroller?.stopAutoScroll(); autoScroller = EdgeDraggingAutoScroller( scrollable!, onScrollViewScrolled: () { if (dragTarget != null && !disableAutoScrollWhenDragging) { autoScroller!.startAutoScrollIfNecessary(dragTarget!); } }, velocityScalar: 20, ); } } class _Draggable extends StatelessWidget { const _Draggable({ required this.child, required this.feedback, this.data, this.childWhenDragging, this.onDragStarted, this.onDragUpdate, this.onDraggableCanceled, this.onDragEnd, this.onDragCompleted, }); /// The data that will be dropped by this draggable. final T? data; final Widget child; final Widget? childWhenDragging; final Widget feedback; /// Called when the draggable starts being dragged. final VoidCallback? onDragStarted; final DragUpdateCallback? onDragUpdate; final DraggableCanceledCallback? onDraggableCanceled; final VoidCallback? onDragCompleted; final DragEndCallback? onDragEnd; @override Widget build(BuildContext context) { return UniversalPlatform.isMobile ? LongPressDraggable( data: data, feedback: feedback, childWhenDragging: childWhenDragging, onDragUpdate: onDragUpdate, onDragEnd: onDragEnd, onDraggableCanceled: onDraggableCanceled, child: child, ) : Draggable( data: data, feedback: feedback, childWhenDragging: childWhenDragging, onDragUpdate: onDragUpdate, onDragEnd: onDragEnd, onDraggableCanceled: onDraggableCanceled, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/edit_panel/edit_panel.dart ================================================ import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart'; import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/bar_title.dart'; import 'package:flowy_infra_ui/style_widget/close_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; class EditPanel extends StatelessWidget { const EditPanel({ super.key, required this.panelContext, required this.onEndEdit, }); final EditPanelContext panelContext; final VoidCallback onEndEdit; @override Widget build(BuildContext context) { return Container( color: Theme.of(context).colorScheme.secondary, child: BlocProvider( create: (context) => getIt(), child: BlocBuilder( builder: (context, state) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ EditPanelTopBar(onClose: () => onEndEdit()), Expanded( child: panelContext.child, ), ], ); }, ), ), ); } } class EditPanelTopBar extends StatelessWidget { const EditPanelTopBar({super.key, required this.onClose}); final VoidCallback onClose; @override Widget build(BuildContext context) { return SizedBox( height: HomeSizes.editPanelTopBarHeight, child: Padding( padding: const EdgeInsets.all(8.0), child: Row( children: [ FlowyBarTitle( title: LocaleKeys.title.tr(), ), const Spacer(), FlowyCloseButton(onPressed: onClose), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart ================================================ import 'package:flutter/material.dart'; class AnimatedPanel extends StatefulWidget { const AnimatedPanel({ super.key, this.isClosed = false, this.closedX = 0.0, this.closedY = 0.0, this.duration = 0.0, this.curve, this.child, }); final bool isClosed; final double closedX; final double closedY; final double duration; final Curve? curve; final Widget? child; @override AnimatedPanelState createState() => AnimatedPanelState(); } class AnimatedPanelState extends State { bool _isHidden = true; @override Widget build(BuildContext context) { final Offset closePos = Offset(widget.closedX, widget.closedY); final double duration = _isHidden && widget.isClosed ? 0 : widget.duration; return TweenAnimationBuilder( curve: widget.curve ?? Curves.easeOut, tween: Tween( begin: !widget.isClosed ? Offset.zero : closePos, end: !widget.isClosed ? Offset.zero : closePos, ), duration: Duration(milliseconds: (duration * 1000).round()), builder: (_, Offset value, Widget? c) { _isHidden = widget.isClosed && value == Offset(widget.closedX, widget.closedY); return _isHidden ? const SizedBox.shrink() : Transform.translate(offset: value, child: c); }, child: widget.child, ); } } extension AnimatedPanelExtensions on Widget { Widget animatedPanelX({ double closeX = 0.0, bool? isClosed, double? duration, Curve? curve, }) => animatedPanel( closePos: Offset(closeX, 0), isClosed: isClosed, curve: curve, duration: duration, ); Widget animatedPanelY({ double closeY = 0.0, bool? isClosed, double? duration, Curve? curve, }) => animatedPanel( closePos: Offset(0, closeY), isClosed: isClosed, curve: curve, duration: duration, ); Widget animatedPanel({ required Offset closePos, bool? isClosed, double? duration, Curve? curve, }) { return AnimatedPanel( closedX: closePos.dx, closedY: closePos.dy, isClosed: isClosed ?? false, duration: duration ?? .35, curve: curve, child: this, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ViewFavoriteButton extends StatelessWidget { const ViewFavoriteButton({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { final isFavorite = state.views.any((v) => v.item.id == view.id); return Listener( onPointerDown: (_) => context.read().add(FavoriteEvent.toggle(view)), child: FlowyTooltip( message: isFavorite ? LocaleKeys.button_removeFromFavorites.tr() : LocaleKeys.button_addToFavorites.tr(), child: FlowyHover( resetHoverOnRebuild: false, child: Padding( padding: const EdgeInsets.all(6), child: FlowySvg( isFavorite ? FlowySvgs.favorited_s : FlowySvgs.favorite_s, size: const Size.square(18), blendMode: isFavorite ? null : BlendMode.srcIn, ), ), ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/tasks/rust_sdk.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/widgets/float_bubble/social_media_section.dart'; import 'package:appflowy/workspace/presentation/widgets/float_bubble/version_section.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class QuestionBubble extends StatelessWidget { const QuestionBubble({super.key}); @override Widget build(BuildContext context) { return const SizedBox.square( dimension: 32.0, child: BubbleActionList(), ); } } class BubbleActionList extends StatefulWidget { const BubbleActionList({super.key}); @override State createState() => _BubbleActionListState(); } class _BubbleActionListState extends State { bool isOpen = false; Color get fontColor => isOpen ? Theme.of(context).colorScheme.onPrimary : Theme.of(context).colorScheme.tertiary; Color get fillColor => isOpen ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.tertiaryContainer; void toggle() { setState(() { isOpen = !isOpen; }); } @override Widget build(BuildContext context) { final List actions = []; actions.addAll( BubbleAction.values.map((action) => BubbleActionWrapper(action)), ); actions.add(SocialMediaSection()); actions.add(FlowyVersionSection()); final (color, borderColor, shadowColor, iconColor) = Theme.of(context).isLightMode ? ( Colors.white, const Color(0x2D454849), const Color(0x14000000), Colors.black, ) : ( const Color(0xFF242B37), const Color(0x2DFFFFFF), const Color(0x14000000), Colors.white, ); return PopoverActionList( direction: PopoverDirection.topWithRightAligned, actions: actions, offset: const Offset(0, -8), constraints: const BoxConstraints( minWidth: 200, maxWidth: 460, maxHeight: 400, ), buildChild: (controller) { return FlowyTooltip( message: LocaleKeys.questionBubble_getSupport.tr(), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( child: Container( padding: const EdgeInsets.all(8.0), decoration: ShapeDecoration( color: color, shape: RoundedRectangleBorder( side: BorderSide(width: 0.50, color: borderColor), borderRadius: BorderRadius.circular(18), ), shadows: [ BoxShadow( color: shadowColor, blurRadius: 20, offset: const Offset(0, 8), ), ], ), child: FlowySvg( FlowySvgs.help_center_s, color: iconColor, ), ), onTap: () => controller.show(), ), ), ); }, onClosed: toggle, onSelected: (action, controller) { if (action is BubbleActionWrapper) { switch (action.inner) { case BubbleAction.whatsNews: afLaunchUrlString('https://www.appflowy.io/what-is-new'); break; case BubbleAction.getSupport: afLaunchUrlString('https://discord.gg/9Q2xaN37tV'); break; case BubbleAction.debug: _DebugToast().show(); break; case BubbleAction.shortcuts: afLaunchUrlString( 'https://docs.appflowy.io/docs/appflowy/product/shortcuts', ); break; case BubbleAction.markdown: afLaunchUrlString( 'https://docs.appflowy.io/docs/appflowy/product/markdown', ); break; case BubbleAction.github: afLaunchUrlString( 'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose', ); break; case BubbleAction.helpAndDocumentation: afLaunchUrlString( 'https://appflowy.com/guide', ); break; } } controller.close(); }, ); } } class _DebugToast { void show() async { String debugInfo = ''; debugInfo += await _getDeviceInfo(); debugInfo += await _getDocumentPath(); await Clipboard.setData(ClipboardData(text: debugInfo)); showMessageToast(LocaleKeys.questionBubble_debug_success.tr()); } Future _getDeviceInfo() async { final deviceInfoPlugin = DeviceInfoPlugin(); final deviceInfo = await deviceInfoPlugin.deviceInfo; return deviceInfo.data.entries .fold('', (prev, el) => '$prev${el.key}: ${el.value}\n'); } Future _getDocumentPath() async { return appFlowyApplicationDataDirectory().then((directory) { final path = directory.path.toString(); return 'Document: $path\n'; }); } } enum BubbleAction { whatsNews, helpAndDocumentation, getSupport, debug, shortcuts, markdown, github, } class BubbleActionWrapper extends ActionCell { BubbleActionWrapper(this.inner); final BubbleAction inner; @override Widget? leftIcon(Color iconColor) => inner.icons; @override String get name => inner.name; } extension QuestionBubbleExtension on BubbleAction { String get name { switch (this) { case BubbleAction.whatsNews: return LocaleKeys.questionBubble_whatsNew.tr(); case BubbleAction.helpAndDocumentation: return LocaleKeys.questionBubble_helpAndDocumentation.tr(); case BubbleAction.getSupport: return LocaleKeys.questionBubble_getSupport.tr(); case BubbleAction.debug: return LocaleKeys.questionBubble_debug_name.tr(); case BubbleAction.shortcuts: return LocaleKeys.questionBubble_shortcuts.tr(); case BubbleAction.markdown: return LocaleKeys.questionBubble_markdown.tr(); case BubbleAction.github: return LocaleKeys.questionBubble_feedback.tr(); } } Widget? get icons { switch (this) { case BubbleAction.whatsNews: return const FlowySvg(FlowySvgs.star_s); case BubbleAction.helpAndDocumentation: return const FlowySvg( FlowySvgs.help_and_documentation_s, size: Size.square(16.0), ); case BubbleAction.getSupport: return const FlowySvg(FlowySvgs.message_support_s); case BubbleAction.debug: return const FlowySvg(FlowySvgs.debug_s); case BubbleAction.shortcuts: return const FlowySvg(FlowySvgs.keyboard_s); case BubbleAction.markdown: return const FlowySvg(FlowySvgs.number_s); case BubbleAction.github: return const FlowySvg(FlowySvgs.share_feedback_s); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart ================================================ import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; class SocialMediaSection extends CustomActionCell { @override Widget buildWithContext( BuildContext context, PopoverController controller, PopoverMutex? mutex, ) { final List children = [ Divider( height: 1, color: Theme.of(context).dividerColor, thickness: 1.0, ), ]; children.addAll( SocialMedia.values.map( (social) { return ActionCellWidget( action: SocialMediaWrapper(social), itemHeight: ActionListSizes.itemHeight, onSelected: (action) { final url = switch (action.inner) { SocialMedia.reddit => 'https://www.reddit.com/r/AppFlowy/', SocialMedia.twitter => 'https://x.com/appflowy', SocialMedia.forum => 'https://forum.appflowy.com/', }; afLaunchUrlString(url); }, ); }, ), ); return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Column( children: children, ), ); } } enum SocialMedia { forum, twitter, reddit } class SocialMediaWrapper extends ActionCell { SocialMediaWrapper(this.inner); final SocialMedia inner; @override Widget? leftIcon(Color iconColor) => inner.icons; @override String get name => inner.name; @override Color? textColor(BuildContext context) => inner.textColor(context); } extension QuestionBubbleExtension on SocialMedia { Color? textColor(BuildContext context) { switch (this) { case SocialMedia.reddit: return Theme.of(context).hintColor; case SocialMedia.twitter: return Theme.of(context).hintColor; case SocialMedia.forum: return Theme.of(context).hintColor; } } String get name { switch (this) { case SocialMedia.forum: return 'Community Forum'; case SocialMedia.twitter: return 'Twitter – @appflowy'; case SocialMedia.reddit: return 'Reddit – r/appflowy'; } } Widget? get icons { switch (this) { case SocialMedia.reddit: return null; case SocialMedia.twitter: return null; case SocialMedia.forum: return null; } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart ================================================ import 'package:appflowy/env/env.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:styled_widget/styled_widget.dart'; class FlowyVersionSection extends CustomActionCell { @override Widget buildWithContext( BuildContext context, PopoverController controller, PopoverMutex? mutex, ) { return FutureBuilder( future: PackageInfo.fromPlatform(), builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.hasError) { return FlowyText( "Error: ${snapshot.error}", color: Theme.of(context).disabledColor, ); } final PackageInfo packageInfo = snapshot.data; final String appName = packageInfo.appName; final String version = packageInfo.version; return SizedBox( height: 30, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Divider( height: 1, color: Theme.of(context).dividerColor, thickness: 1.0, ), const VSpace(6), GestureDetector( behavior: HitTestBehavior.opaque, onDoubleTap: () { if (Env.internalBuild != '1' && !kDebugMode) { return; } enableDocumentInternalLog = !enableDocumentInternalLog; showToastNotification( message: enableDocumentInternalLog ? 'Enabled Internal Log' : 'Disabled Internal Log', ); }, child: FlowyText( '$appName $version', color: Theme.of(context).hintColor, fontSize: 12, ).padding( horizontal: ActionListSizes.itemHPadding, ), ), ], ), ); } else { return const SizedBox(height: 30); } }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:flutter/widgets.dart'; /// Abstract class for providing images to the [InteractiveImageViewer]. /// abstract class AFImageProvider { const AFImageProvider({this.onDeleteImage}); /// Provide this callback if you want it to be possible to /// delete the Image through the [InteractiveImageViewer]. /// final Function(int index)? onDeleteImage; int get imageCount; int get initialIndex; ImageBlockData getImage(int index); Widget renderImage( BuildContext context, int index, [ UserProfilePB? userProfile, ]); } class AFBlockImageProvider implements AFImageProvider { const AFBlockImageProvider({ required this.images, this.initialIndex = 0, this.onDeleteImage, }); final List images; @override final Function(int)? onDeleteImage; @override final int initialIndex; @override int get imageCount => images.length; @override ImageBlockData getImage(int index) => images[index]; @override Widget renderImage( BuildContext context, int index, [ UserProfilePB? userProfile, ]) { final image = getImage(index); if (image.type == CustomImageType.local && localPathRegex.hasMatch(image.url)) { return Image(image: image.toImageProvider()); } return FlowyNetworkImage( url: image.url, userProfilePB: userProfile, fit: BoxFit.contain, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart'; import 'package:universal_platform/universal_platform.dart'; class InteractiveImageToolbar extends StatelessWidget { const InteractiveImageToolbar({ super.key, required this.currentImage, required this.imageCount, required this.isFirstIndex, required this.isLastIndex, required this.currentScale, required this.onPrevious, required this.onNext, required this.onZoomIn, required this.onZoomOut, required this.onScaleChanged, this.onDelete, this.userProfile, }); final ImageBlockData currentImage; final int imageCount; final bool isFirstIndex; final bool isLastIndex; final int currentScale; final VoidCallback onPrevious; final VoidCallback onNext; final VoidCallback onZoomIn; final VoidCallback onZoomOut; final Function(double scale) onScaleChanged; final UserProfilePB? userProfile; final VoidCallback? onDelete; @override Widget build(BuildContext context) { return Positioned( bottom: 16, left: 0, right: 0, child: Padding( padding: const EdgeInsets.all(16.0), child: SizedBox( width: 200, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (imageCount > 1) _renderToolbarItems( children: [ _ToolbarItem( isDisabled: isFirstIndex, tooltip: LocaleKeys .document_imageBlock_interactiveViewer_toolbar_previousImageTooltip .tr(), icon: FlowySvgs.arrow_left_s, onTap: () { if (!isFirstIndex) { onPrevious(); } }, ), _ToolbarItem( isDisabled: isLastIndex, tooltip: LocaleKeys .document_imageBlock_interactiveViewer_toolbar_nextImageTooltip .tr(), icon: FlowySvgs.arrow_right_s, onTap: () { if (!isLastIndex) { onNext(); } }, ), ], ), const HSpace(10), _renderToolbarItems( children: [ _ToolbarItem( tooltip: LocaleKeys .document_imageBlock_interactiveViewer_toolbar_zoomOutTooltip .tr(), icon: FlowySvgs.minus_s, onTap: onZoomOut, ), AppFlowyPopover( offset: const Offset(0, -8), decorationColor: Colors.transparent, direction: PopoverDirection.topWithCenterAligned, constraints: const BoxConstraints(maxHeight: 50), popupBuilder: (context) => _renderToolbarItems( children: [ _ScaleSlider( currentScale: currentScale, onScaleChanged: onScaleChanged, ), ], ), child: FlowyTooltip( message: LocaleKeys .document_imageBlock_interactiveViewer_toolbar_changeZoomLevelTooltip .tr(), child: FlowyHover( resetHoverOnRebuild: false, style: HoverStyle( hoverColor: Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Padding( padding: const EdgeInsets.all(6), child: SizedBox( width: 40, child: Center( child: FlowyText( LocaleKeys .document_imageBlock_interactiveViewer_toolbar_scalePercentage .tr(args: [currentScale.toString()]), color: Colors.white, ), ), ), ), ), ), ), _ToolbarItem( tooltip: LocaleKeys .document_imageBlock_interactiveViewer_toolbar_zoomInTooltip .tr(), icon: FlowySvgs.add_s, onTap: onZoomIn, ), ], ), const HSpace(10), _renderToolbarItems( children: [ if (onDelete != null) _ToolbarItem( tooltip: LocaleKeys .document_imageBlock_interactiveViewer_toolbar_deleteImageTooltip .tr(), icon: FlowySvgs.delete_s, onTap: () { onDelete!(); Navigator.of(context).pop(); }, ), if (!UniversalPlatform.isMobile) ...[ _ToolbarItem( tooltip: currentImage.isNotInternal ? LocaleKeys .document_imageBlock_interactiveViewer_toolbar_openLocalImage .tr() : LocaleKeys .document_imageBlock_interactiveViewer_toolbar_downloadImage .tr(), icon: currentImage.isNotInternal ? currentImage.isLocal ? FlowySvgs.folder_m : FlowySvgs.m_aa_link_s : FlowySvgs.download_s, onTap: () => _locateOrDownloadImage(context), ), ], ], ), const HSpace(10), _renderToolbarItems( children: [ _ToolbarItem( tooltip: LocaleKeys .document_imageBlock_interactiveViewer_toolbar_closeViewer .tr(), icon: FlowySvgs.close_viewer_s, onTap: () => Navigator.of(context).pop(), ), ], ), ], ), ), ), ); } Widget _renderToolbarItems({required List children}) { return DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), color: Colors.black.withValues(alpha: 0.6), ), child: Padding( padding: const EdgeInsets.all(4), child: SeparatedRow( mainAxisSize: MainAxisSize.min, separatorBuilder: () => const HSpace(4), children: children, ), ), ); } Future _locateOrDownloadImage(BuildContext context) async { if (currentImage.isLocal || currentImage.isNotInternal) { /// If the image type is local, we simply open the image /// /// // In case of eg. Unsplash images (images without extension type in URL), // we don't know their mimetype. In the future we can write a parser // using the Mime package and read the image to get the proper extension. await afLaunchUrlString(currentImage.url); } else { if (userProfile == null) { return showSnapBar( context, LocaleKeys.document_plugins_image_imageDownloadFailedToken.tr(), ); } final uri = Uri.parse(currentImage.url); final imgFile = File(uri.pathSegments.last); final savePath = await FilePicker().saveFile( fileName: basename(imgFile.path), ); if (savePath != null) { final uri = Uri.parse(currentImage.url); final token = jsonDecode(userProfile!.token)['access_token']; final response = await http.get( uri, headers: {'Authorization': 'Bearer $token'}, ); if (response.statusCode == 200) { final imgFile = File(savePath); await imgFile.writeAsBytes(response.bodyBytes); } else if (context.mounted) { showSnapBar( context, LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), ); } } } } } class _ToolbarItem extends StatelessWidget { const _ToolbarItem({ required this.tooltip, required this.icon, required this.onTap, this.isDisabled = false, }); final String tooltip; final FlowySvgData icon; final VoidCallback onTap; final bool isDisabled; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: FlowyTooltip( message: tooltip, child: FlowyHover( resetHoverOnRebuild: false, style: HoverStyle( hoverColor: isDisabled ? Colors.transparent : Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Container( width: 32, height: 32, padding: const EdgeInsets.all(8), child: FlowySvg( icon, color: isDisabled ? Colors.grey : Colors.white, ), ), ), ), ); } } class _ScaleSlider extends StatefulWidget { const _ScaleSlider({ required this.currentScale, required this.onScaleChanged, }); final int currentScale; final Function(double scale) onScaleChanged; @override State<_ScaleSlider> createState() => __ScaleSliderState(); } class __ScaleSliderState extends State<_ScaleSlider> { late int _currentScale = widget.currentScale; @override Widget build(BuildContext context) { return Slider( max: 5.0, min: 0.5, value: _currentScale / 100, onChanged: (scale) { widget.onScaleChanged(scale); setState( () => _currentScale = (scale * 100).toInt(), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:provider/provider.dart'; const double _minScaleFactor = .5; const double _maxScaleFactor = 5; class InteractiveImageViewer extends StatefulWidget { const InteractiveImageViewer({ super.key, this.userProfile, required this.imageProvider, }); final UserProfilePB? userProfile; final AFImageProvider imageProvider; @override State createState() => _InteractiveImageViewerState(); } class _InteractiveImageViewerState extends State { final TransformationController controller = TransformationController(); final focusNode = FocusNode(); int currentScale = 100; late int currentIndex = widget.imageProvider.initialIndex; bool get isLastIndex => currentIndex == widget.imageProvider.imageCount - 1; bool get isFirstIndex => currentIndex == 0; late ImageBlockData currentImage; UserProfilePB? userProfile; @override void initState() { super.initState(); controller.addListener(_onControllerChanged); currentImage = widget.imageProvider.getImage(currentIndex); userProfile = widget.userProfile ?? context.read().state.userProfilePB; focusNode.requestFocus(); } void _onControllerChanged() { final scale = controller.value.getMaxScaleOnAxis(); final percentage = (scale * 100).toInt(); setState(() => currentScale = percentage); } @override void dispose() { controller.removeListener(_onControllerChanged); controller.dispose(); focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; return KeyboardListener( focusNode: focusNode, onKeyEvent: (event) { if (event is! KeyDownEvent) { return; } if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { _move(-1); } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { _move(1); } else if ([ LogicalKeyboardKey.add, LogicalKeyboardKey.numpadAdd, ].contains(event.logicalKey)) { _zoom(1.1, size); } else if ([ LogicalKeyboardKey.minus, LogicalKeyboardKey.numpadSubtract, ].contains(event.logicalKey)) { _zoom(.9, size); } else if ([ LogicalKeyboardKey.numpad0, LogicalKeyboardKey.digit0, ].contains(event.logicalKey)) { controller.value = Matrix4.identity(); _onControllerChanged(); } }, child: Stack( fit: StackFit.expand, children: [ SizedBox.expand( child: InteractiveViewer( boundaryMargin: const EdgeInsets.all(double.infinity), transformationController: controller, constrained: false, minScale: _minScaleFactor, maxScale: _maxScaleFactor, scaleFactor: 500, child: SizedBox( height: size.height, width: size.width, child: GestureDetector( // We can consider adding zoom behavior instead in a later iteration onDoubleTap: () => Navigator.of(context).pop(), child: widget.imageProvider.renderImage( context, currentIndex, userProfile, ), ), ), ), ), InteractiveImageToolbar( currentImage: currentImage, imageCount: widget.imageProvider.imageCount, isFirstIndex: isFirstIndex, isLastIndex: isLastIndex, currentScale: currentScale, userProfile: userProfile, onPrevious: () => _move(-1), onNext: () => _move(1), onZoomIn: () => _zoom(1.1, size), onZoomOut: () => _zoom(.9, size), onScaleChanged: (scale) { final currentScale = controller.value.getMaxScaleOnAxis(); final scaleStep = scale / currentScale; _zoom(scaleStep, size); }, onDelete: widget.imageProvider.onDeleteImage == null ? null : () => widget.imageProvider.onDeleteImage?.call(currentIndex), ), ], ), ); } void _move(int steps) { setState(() { final index = currentIndex + steps; currentIndex = index.clamp(0, widget.imageProvider.imageCount - 1); currentImage = widget.imageProvider.getImage(currentIndex); }); } void _zoom(double scaleStep, Size size) { final center = Offset(size.width / 2, size.height / 2); final scenePointBefore = controller.toScene(center); final currentScale = controller.value.getMaxScaleOnAxis(); final newScale = (currentScale * scaleStep).clamp( _minScaleFactor, _maxScaleFactor, ); // Create a new transformation final newMatrix = Matrix4.identity() ..translate(scenePointBefore.dx, scenePointBefore.dy) ..scale(newScale / currentScale) ..translate(-scenePointBefore.dx, -scenePointBefore.dy); // Apply the new transformation controller.value = newMatrix * controller.value; // Convert the center point to scene coordinates after scaling final scenePointAfter = controller.toScene(center); // Compute difference to keep the same center point final dx = scenePointAfter.dx - scenePointBefore.dx; final dy = scenePointAfter.dy - scenePointBefore.dy; // Apply the translation controller.value = Matrix4.identity() ..translate(-dx, -dy) ..multiply(controller.value); _onControllerChanged(); } } void openInteractiveViewerFromFile( BuildContext context, MediaFilePB file, { required void Function(int) onDeleteImage, UserProfilePB? userProfile, }) => showDialog( context: context, builder: (_) => InteractiveImageViewer( userProfile: userProfile, imageProvider: AFBlockImageProvider( images: [ ImageBlockData( url: file.url, type: file.uploadType.toCustomImageType(), ), ], onDeleteImage: onDeleteImage, ), ), ); void openInteractiveViewerFromFiles( BuildContext context, List files, { required void Function(int) onDeleteImage, int initialIndex = 0, UserProfilePB? userProfile, }) => showDialog( context: context, builder: (_) => InteractiveImageViewer( userProfile: userProfile, imageProvider: AFBlockImageProvider( initialIndex: initialIndex, images: files .map( (f) => ImageBlockData( url: f.url, type: f.uploadType.toCustomImageType(), ), ) .toList(), onDeleteImage: onDeleteImage, ), ), ); ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MoreViewActions extends StatefulWidget { const MoreViewActions({ super.key, required this.view, this.customActions = const [], }); /// The view to show the actions for. /// final ViewPB view; /// Custom actions to show in the popover, will be laid out at the top. /// final List customActions; @override State createState() => _MoreViewActionsState(); } class _MoreViewActionsState extends State { final popoverMutex = PopoverMutex(); @override void dispose() { popoverMutex.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return AppFlowyPopover( mutex: popoverMutex, constraints: const BoxConstraints(maxWidth: 245), direction: PopoverDirection.bottomWithRightAligned, offset: const Offset(0, 12), popupBuilder: (_) => _buildPopup(state), child: const _ThreeDots(), ); }, ); } Widget _buildPopup(ViewInfoState viewInfoState) { final userWorkspaceBloc = context.read(); final userProfile = userWorkspaceBloc.state.userProfile; final workspaceId = userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? ''; return MultiBlocProvider( providers: [ BlocProvider( create: (_) => ViewBloc(view: widget.view) ..add( const ViewEvent.initial(), ), ), BlocProvider( create: (context) => SpaceBloc( userProfile: userProfile, workspaceId: workspaceId, )..add( const SpaceEvent.initial(openFirstPage: false), ), ), BlocProvider.value( value: context.read(), ), ], child: BlocBuilder( builder: (context, viewState) { return BlocBuilder( builder: (context, state) { if (state.spaces.isEmpty && userProfile.workspaceType == WorkspaceTypePB.ServerW) { return const SizedBox.shrink(); } final actions = _buildActions( context, viewInfoState, ); return ListView.builder( key: ValueKey(state.spaces.hashCode), shrinkWrap: true, padding: EdgeInsets.zero, itemCount: actions.length, physics: StyledScrollPhysics(), itemBuilder: (_, index) => actions[index], ); }, ); }, ), ); } List _buildActions(BuildContext context, ViewInfoState state) { final pageAccessLevelBloc = context.watch(); final pageAccessLevelState = pageAccessLevelBloc.state; final view = pageAccessLevelState.view; final appearanceSettings = context.watch().state; final dateFormat = appearanceSettings.dateFormat; final timeFormat = appearanceSettings.timeFormat; final viewMoreActionTypes = switch (pageAccessLevelState.accessLevel) { ShareAccessLevel.readOnly => [], _ => [ if (widget.view.layout != ViewLayoutPB.Chat) ViewMoreActionType.duplicate, ViewMoreActionType.moveTo, ViewMoreActionType.delete, ViewMoreActionType.divider, ], }; final actions = [ ...widget.customActions, if (widget.view.isDocument) ...[ const FontSizeAction(), ViewAction( type: ViewMoreActionType.divider, view: view, mutex: popoverMutex, ), ], if (state.workspaceType == WorkspaceTypePB.ServerW && (widget.view.isDocument || widget.view.isDatabase) && !pageAccessLevelState.isReadOnly) ...[ LockPageAction( view: view, ), ViewAction( type: ViewMoreActionType.divider, view: view, mutex: popoverMutex, ), ], ...viewMoreActionTypes.map( (type) => ViewAction( type: type, view: view, mutex: popoverMutex, ), ), if (state.documentCounters != null || state.createdAt != null) ...[ ViewMetaInfo( dateFormat: dateFormat, timeFormat: timeFormat, documentCounters: state.documentCounters, titleCounters: state.titleCounters, createdAt: state.createdAt, ), const VSpace(4.0), ], ]; return actions; } } class _ThreeDots extends StatelessWidget { const _ThreeDots(); @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.moreAction_moreOptions.tr(), child: FlowyHover( style: HoverStyle( foregroundColorOnHover: Theme.of(context).colorScheme.onPrimary, ), builder: (context, isHovering) => Padding( padding: const EdgeInsets.all(6), child: FlowySvg( FlowySvgs.three_dots_s, size: const Size.square(18), color: isHovering ? Theme.of(context).colorScheme.onSurface : Theme.of(context).iconTheme.color, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ViewAction extends StatelessWidget { const ViewAction({ super.key, required this.type, required this.view, this.mutex, }); final ViewMoreActionType type; final ViewPB view; final PopoverMutex? mutex; @override Widget build(BuildContext context) { final wrapper = ViewMoreActionTypeWrapper( type, view, (controller, data) async { await _onAction(context, data); mutex?.close(); }, moveActionDirection: PopoverDirection.leftWithTopAligned, moveActionOffset: const Offset(-10, 0), ); return wrapper.buildWithContext( context, // this is a dummy controller, we don't need to control the popover here. PopoverController(), null, ); } Future _onAction( BuildContext context, dynamic data, ) async { switch (type) { case ViewMoreActionType.delete: final (containPublishedPage, _) = await ViewBackendService.containPublishedPage(view); if (containPublishedPage && context.mounted) { await showConfirmDeletionDialog( context: context, name: view.nameOrDefault, description: LocaleKeys.publish_containsPublishedPage.tr(), onConfirm: () { context.read().add(const ViewEvent.delete()); }, ); } else if (context.mounted) { context.read().add(const ViewEvent.delete()); } case ViewMoreActionType.duplicate: context.read().add(const ViewEvent.duplicate()); case ViewMoreActionType.moveTo: final value = data; if (value is! (ViewPB, ViewPB)) { return; } final space = value.$1; final target = value.$2; final result = await ViewBackendService.getView(view.parentViewId); result.fold( (parentView) => moveViewCrossSpace( context, space, view, parentView, FolderSpaceType.public, view, target.id, ), (f) => Log.error(f), ); // the move action is handled in the button itself break; default: throw UnimplementedError(); } } } class CustomViewAction extends StatelessWidget { const CustomViewAction({ super.key, required this.view, required this.leftIcon, required this.label, this.tooltipMessage, this.disabled = false, this.onTap, this.mutex, }); final ViewPB view; final FlowySvgData leftIcon; final String label; final bool disabled; final String? tooltipMessage; final VoidCallback? onTap; final PopoverMutex? mutex; @override Widget build(BuildContext context) { return Container( height: 34, padding: const EdgeInsets.symmetric(vertical: 2.0), child: FlowyTooltip( message: tooltipMessage, child: FlowyButton( margin: const EdgeInsets.symmetric(horizontal: 6), disable: disabled, onTap: onTap, leftIcon: FlowySvg( leftIcon, size: const Size.square(16.0), color: disabled ? Theme.of(context).disabledColor : null, ), iconPadding: 10.0, text: FlowyText( label, figmaLineHeight: 18.0, color: disabled ? Theme.of(context).disabledColor : null, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class FontSizeAction extends StatelessWidget { const FontSizeAction({super.key}); @override Widget build(BuildContext context) { return AppFlowyPopover( direction: PopoverDirection.leftWithCenterAligned, constraints: const BoxConstraints(maxHeight: 40, maxWidth: 240), offset: const Offset(-10, 0), popupBuilder: (context) { return BlocBuilder( builder: (_, state) => FontSizeStepper( minimumValue: 10, maximumValue: 24, value: state.fontSize, divisions: 8, onChanged: (newFontSize) => context .read() .syncFontSize(newFontSize), ), ); }, child: Container( height: 34, padding: const EdgeInsets.symmetric(vertical: 2.0), child: FlowyButton( text: FlowyText.regular( LocaleKeys.moreAction_fontSize.tr(), fontSize: 14.0, lineHeight: 1.0, figmaLineHeight: 18.0, color: AFThemeExtension.of(context).textColor, ), leftIcon: Icon( Icons.format_size_sharp, color: Theme.of(context).iconTheme.color, size: 18, ), leftIconSize: const Size(18, 18), hoverColor: AFThemeExtension.of(context).lightGreyHover, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart ================================================ import 'package:flutter/material.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; class FontSizeStepper extends StatefulWidget { const FontSizeStepper({ super.key, required this.minimumValue, required this.maximumValue, required this.value, required this.divisions, required this.onChanged, }); final double minimumValue; final double maximumValue; final double value; final ValueChanged onChanged; final int divisions; @override State createState() => _FontSizeStepperState(); } class _FontSizeStepperState extends State { late double _value = widget.value.clamp( widget.minimumValue, widget.maximumValue, ); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( children: [ const FlowyText('A', fontSize: 14), const HSpace(6), Expanded( child: SliderTheme( data: Theme.of(context).sliderTheme.copyWith( showValueIndicator: ShowValueIndicator.never, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 8, ), overlayShape: const RoundSliderOverlayShape( overlayRadius: 16, ), ), child: Slider( value: _value, min: widget.minimumValue, max: widget.maximumValue, divisions: widget.divisions, onChanged: (value) { setState(() => _value = value); widget.onChanged(value); }, ), ), ), const HSpace(6), const FlowyText('A', fontSize: 20), ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/lock_page_action.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class LockPageAction extends StatefulWidget { const LockPageAction({ super.key, required this.view, }); final ViewPB view; @override State createState() => _LockPageActionState(); } class _LockPageActionState extends State { @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return _buildTextButton(context); }, ); } Widget _buildTextButton( BuildContext context, ) { return Container( height: 34, padding: const EdgeInsets.symmetric(vertical: 2.0), child: FlowyIconTextButton( margin: const EdgeInsets.symmetric(horizontal: 6), onTap: () => _toggle(context), leftIconBuilder: (onHover) => FlowySvg( FlowySvgs.lock_page_s, size: const Size.square(16.0), ), iconPadding: 10.0, textBuilder: (onHover) => FlowyText( LocaleKeys.disclosureAction_lockPage.tr(), figmaLineHeight: 18.0, ), rightIconBuilder: (_) => _buildSwitch( context, ), ), ); } Widget _buildSwitch(BuildContext context) { final lockState = context.read().state; if (lockState.isLoadingLockStatus) { return SizedBox.shrink(); } return Container( width: 30, height: 20, margin: const EdgeInsets.only(right: 6), child: FittedBox( fit: BoxFit.fill, child: CupertinoSwitch( value: lockState.isLocked, activeTrackColor: Theme.of(context).colorScheme.primary, onChanged: (_) => _toggle(context), ), ), ); } Future _toggle(BuildContext context) async { final isLocked = context.read().state.isLocked; context.read().add( isLocked ? PageAccessLevelEvent.unlock() : PageAccessLevelEvent.lock(), ); Log.info('update page(${widget.view.id}) lock status: $isLocked'); } } class LockPageButtonWrapper extends StatelessWidget { const LockPageButtonWrapper({ super.key, required this.child, }); final Widget child; @override Widget build(BuildContext context) { return FlowyTooltip( message: LocaleKeys.lockPage_lockedOperationTooltip.tr(), child: IgnorePointer( child: child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class ViewMetaInfo extends StatelessWidget { const ViewMetaInfo({ super.key, required this.dateFormat, required this.timeFormat, this.documentCounters, this.titleCounters, this.createdAt, }); final UserDateFormatPB dateFormat; final UserTimeFormatPB timeFormat; final Counters? documentCounters; final Counters? titleCounters; final DateTime? createdAt; @override Widget build(BuildContext context) { final numberFormat = NumberFormat(); // If more info is added to this Widget, use a separated ListView return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (documentCounters != null && titleCounters != null) ...[ FlowyText.regular( LocaleKeys.moreAction_wordCount.tr( args: [ numberFormat .format( documentCounters!.wordCount + titleCounters!.wordCount, ) .toString(), ], ), fontSize: 12, color: Theme.of(context).hintColor, ), const VSpace(2), FlowyText.regular( LocaleKeys.moreAction_charCount.tr( args: [ numberFormat .format( documentCounters!.charCount + titleCounters!.charCount, ) .toString(), ], ), fontSize: 12, color: Theme.of(context).hintColor, ), ], if (createdAt != null) ...[ if (documentCounters != null && titleCounters != null) const VSpace(2), FlowyText.regular( LocaleKeys.moreAction_createdAt.tr( args: [dateFormat.formatDate(createdAt!, true, timeFormat)], ), fontSize: 12, maxLines: 2, color: Theme.of(context).hintColor, ), ], ], ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:styled_widget/styled_widget.dart'; class PopoverActionList extends StatefulWidget { const PopoverActionList({ super.key, this.controller, this.popoverMutex, required this.actions, required this.buildChild, required this.onSelected, this.mutex, this.onClosed, this.onPopupBuilder, this.direction = PopoverDirection.rightWithTopAligned, this.asBarrier = false, this.offset = Offset.zero, this.animationDuration = const Duration(), this.slideDistance = 20, this.beginScaleFactor = 0.9, this.endScaleFactor = 1.0, this.beginOpacity = 0.0, this.endOpacity = 1.0, this.constraints = const BoxConstraints( minWidth: 120, maxWidth: 460, maxHeight: 300, ), this.showAtCursor = false, }); final PopoverController? controller; final PopoverMutex? popoverMutex; final List actions; final Widget Function(PopoverController) buildChild; final Function(T, PopoverController) onSelected; final PopoverMutex? mutex; final VoidCallback? onClosed; final VoidCallback? onPopupBuilder; final PopoverDirection direction; final bool asBarrier; final Offset offset; final BoxConstraints constraints; final Duration animationDuration; final double slideDistance; final double beginScaleFactor; final double endScaleFactor; final double beginOpacity; final double endOpacity; final bool showAtCursor; @override State> createState() => _PopoverActionListState(); } class _PopoverActionListState extends State> { late PopoverController popoverController = widget.controller ?? PopoverController(); @override void dispose() { if (widget.controller == null) { popoverController.close(); } super.dispose(); } @override void didUpdateWidget(covariant PopoverActionList oldWidget) { if (widget.controller != oldWidget.controller) { popoverController.close(); popoverController = widget.controller ?? PopoverController(); } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { final child = widget.buildChild(popoverController); return AppFlowyPopover( asBarrier: widget.asBarrier, animationDuration: widget.animationDuration, slideDistance: widget.slideDistance, beginScaleFactor: widget.beginScaleFactor, endScaleFactor: widget.endScaleFactor, beginOpacity: widget.beginOpacity, endOpacity: widget.endOpacity, controller: popoverController, constraints: widget.constraints, direction: widget.direction, mutex: widget.mutex, offset: widget.offset, triggerActions: PopoverTriggerFlags.none, onClose: widget.onClosed, showAtCursor: widget.showAtCursor, popupBuilder: (_) { widget.onPopupBuilder?.call(); final List children = widget.actions.map((action) { if (action is ActionCell) { return ActionCellWidget( action: action, itemHeight: ActionListSizes.itemHeight, onSelected: (action) { widget.onSelected(action, popoverController); }, ); } else if (action is PopoverActionCell) { return PopoverActionCellWidget( popoverMutex: widget.popoverMutex, popoverController: popoverController, action: action, itemHeight: ActionListSizes.itemHeight, ); } else { final custom = action as CustomActionCell; return custom.buildWithContext( context, popoverController, widget.popoverMutex, ); } }).toList(); return IntrinsicHeight( child: IntrinsicWidth( child: Column(children: children), ), ); }, child: child, ); } } abstract class ActionCell extends PopoverAction { Widget? leftIcon(Color iconColor) => null; Widget? rightIcon(Color iconColor) => null; String get name; Color? textColor(BuildContext context) { return null; } } typedef PopoverActionCellBuilder = Widget Function( BuildContext context, PopoverController parentController, PopoverController controller, ); abstract class PopoverActionCell extends PopoverAction { Widget? leftIcon(Color iconColor) => null; Widget? rightIcon(Color iconColor) => null; String get name; PopoverActionCellBuilder get builder; } abstract class CustomActionCell extends PopoverAction { Widget buildWithContext( BuildContext context, PopoverController controller, PopoverMutex? mutex, ); } abstract class PopoverAction {} class ActionListSizes { static double itemHPadding = 10; static double itemHeight = 20; static double vPadding = 6; static double hPadding = 10; } class ActionCellWidget extends StatelessWidget { const ActionCellWidget({ super.key, required this.action, required this.onSelected, required this.itemHeight, }); final T action; final Function(T) onSelected; final double itemHeight; @override Widget build(BuildContext context) { final actionCell = action as ActionCell; final leftIcon = actionCell.leftIcon(Theme.of(context).colorScheme.onSurface); final rightIcon = actionCell.rightIcon(Theme.of(context).colorScheme.onSurface); return HoverButton( itemHeight: itemHeight, leftIcon: leftIcon, rightIcon: rightIcon, name: actionCell.name, textColor: actionCell.textColor(context), onTap: () => onSelected(action), ); } } class PopoverActionCellWidget extends StatefulWidget { const PopoverActionCellWidget({ super.key, this.popoverMutex, required this.popoverController, required this.action, required this.itemHeight, }); final PopoverMutex? popoverMutex; final T action; final double itemHeight; final PopoverController popoverController; @override State createState() => _PopoverActionCellWidgetState(); } class _PopoverActionCellWidgetState extends State> { final popoverController = PopoverController(); @override Widget build(BuildContext context) { final actionCell = widget.action as PopoverActionCell; final leftIcon = actionCell.leftIcon(Theme.of(context).colorScheme.onSurface); final rightIcon = actionCell.rightIcon(Theme.of(context).colorScheme.onSurface); return AppFlowyPopover( mutex: widget.popoverMutex, controller: popoverController, asBarrier: true, popupBuilder: (context) => actionCell.builder( context, widget.popoverController, popoverController, ), child: HoverButton( itemHeight: widget.itemHeight, leftIcon: leftIcon, rightIcon: rightIcon, name: actionCell.name, onTap: () => popoverController.show(), ), ); } } class HoverButton extends StatelessWidget { const HoverButton({ super.key, required this.onTap, required this.itemHeight, this.leftIcon, required this.name, this.rightIcon, this.textColor, }); final VoidCallback onTap; final double itemHeight; final Widget? leftIcon; final Widget? rightIcon; final String name; final Color? textColor; @override Widget build(BuildContext context) { return FlowyHover( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: SizedBox( height: itemHeight, child: Row( children: [ if (leftIcon != null) ...[ leftIcon!, HSpace(ActionListSizes.itemHPadding), ], Expanded( child: FlowyText.regular( name, overflow: TextOverflow.visible, lineHeight: 1.15, color: textColor, ), ), if (rightIcon != null) ...[ HSpace(ActionListSizes.itemHPadding), rightIcon!, ], ], ), ).padding( horizontal: ActionListSizes.hPadding, vertical: ActionListSizes.vPadding, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; class RenameViewPopover extends StatefulWidget { const RenameViewPopover({ super.key, required this.view, required this.name, required this.popoverController, required this.emoji, this.icon, this.showIconChanger = true, this.tabs = const [PickerTabType.emoji, PickerTabType.icon], }); final ViewPB view; final String name; final PopoverController popoverController; final EmojiIconData emoji; final Widget? icon; final bool showIconChanger; final List tabs; @override State createState() => _RenameViewPopoverState(); } class _RenameViewPopoverState extends State { final TextEditingController _controller = TextEditingController(); @override void initState() { super.initState(); _controller.text = widget.name; _controller.selection = TextSelection(baseOffset: 0, extentOffset: widget.name.length); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ if (widget.showIconChanger) ...[ SizedBox( width: 30.0, child: EmojiPickerButton( emoji: widget.emoji, defaultIcon: widget.icon, direction: PopoverDirection.bottomWithCenterAligned, offset: const Offset(0, 18), onSubmitted: _updateViewIcon, documentId: widget.view.id, tabs: widget.tabs, ), ), const HSpace(6), ], SizedBox( height: 32.0, width: 220, child: FlowyTextField( controller: _controller, maxLength: 256, onSubmitted: _updateViewName, onCanceled: () => _updateViewName(_controller.text), showCounter: false, ), ), ], ); } Future _updateViewName(String name) async { if (name.isNotEmpty && name != widget.name) { await ViewBackendService.updateView( viewId: widget.view.id, name: _controller.text, ); widget.popoverController.close(); } } Future _updateViewIcon( SelectedEmojiIconResult r, PopoverController? _, ) async { await ViewBackendService.updateViewIcon( view: widget.view, viewIcon: r.data, ); if (!r.keepOpen) { widget.popoverController.close(); } } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/sidebar_resizer.dart ================================================ import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarResizer extends StatefulWidget { const SidebarResizer({super.key}); @override State createState() => _SidebarResizerState(); } class _SidebarResizerState extends State { final ValueNotifier isHovered = ValueNotifier(false); final ValueNotifier isDragging = ValueNotifier(false); @override void dispose() { isHovered.dispose(); isDragging.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.resizeLeftRight, onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: GestureDetector( dragStartBehavior: DragStartBehavior.down, behavior: HitTestBehavior.translucent, onHorizontalDragStart: (details) { isDragging.value = true; context .read() .add(const HomeSettingEvent.editPanelResizeStart()); }, onHorizontalDragUpdate: (details) { isDragging.value = true; context .read() .add(HomeSettingEvent.editPanelResized(details.localPosition.dx)); }, onHorizontalDragEnd: (details) { isDragging.value = false; context .read() .add(const HomeSettingEvent.editPanelResizeEnd()); }, onHorizontalDragCancel: () { isDragging.value = false; context .read() .add(const HomeSettingEvent.editPanelResizeEnd()); }, child: ValueListenableBuilder( valueListenable: isHovered, builder: (context, isHovered, _) { return ValueListenableBuilder( valueListenable: isDragging, builder: (context, isDragging, _) { return Container( width: 2, // increase the width of the resizer to make it easier to drag margin: const EdgeInsets.only(right: 2.0), height: MediaQuery.of(context).size.height, color: isHovered || isDragging ? const Color(0xFF00B5FF) : Colors.transparent, ); }, ); }, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; class ViewTabBarItem extends StatefulWidget { const ViewTabBarItem({ super.key, required this.view, this.shortForm = false, }); final ViewPB view; final bool shortForm; @override State createState() => _ViewTabBarItemState(); } class _ViewTabBarItemState extends State { late final ViewListener _viewListener; late ViewPB view; @override void initState() { super.initState(); view = widget.view; _viewListener = ViewListener(viewId: widget.view.id); _viewListener.start( onViewUpdated: (updatedView) { if (mounted) { setState(() => view = updatedView); } }, ); } @override void dispose() { _viewListener.stop(); super.dispose(); } @override Widget build(BuildContext context) { return Row( mainAxisAlignment: widget.shortForm ? MainAxisAlignment.center : MainAxisAlignment.start, children: [ if (widget.view.icon.value.isNotEmpty) RawEmojiIconWidget( emoji: widget.view.icon.toEmojiIconData(), emojiSize: 16, ), if (!widget.shortForm && view.icon.value.isNotEmpty) const HSpace(6), if (!widget.shortForm || view.icon.value.isEmpty) ...[ Flexible( child: FlowyText.medium( view.nameOrDefault, overflow: TextOverflow.ellipsis, ), ), ], ], ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart ================================================ import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; class ToggleStyle { const ToggleStyle({ required this.height, required this.width, required this.thumbRadius, }); const ToggleStyle.big() : height = 16, width = 27, thumbRadius = 14; const ToggleStyle.small() : height = 10, width = 16, thumbRadius = 8; const ToggleStyle.mobile() : height = 24, width = 42, thumbRadius = 18; final double height; final double width; final double thumbRadius; } class Toggle extends StatelessWidget { const Toggle({ super.key, required this.value, required this.onChanged, this.style = const ToggleStyle.big(), this.thumbColor, this.activeBackgroundColor, this.inactiveBackgroundColor, this.duration = const Duration(milliseconds: 150), this.padding = const EdgeInsets.all(8.0), }); final bool value; final void Function(bool) onChanged; final ToggleStyle style; final Color? thumbColor; final Color? activeBackgroundColor; final Color? inactiveBackgroundColor; final EdgeInsets padding; final Duration duration; @override Widget build(BuildContext context) { final backgroundColor = value ? activeBackgroundColor ?? Theme.of(context).colorScheme.primary : inactiveBackgroundColor ?? AFThemeExtension.of(context).toggleButtonBGColor; return GestureDetector( onTap: () => onChanged(!value), child: Padding( padding: padding, child: Stack( children: [ Container( height: style.height, width: style.width, decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(style.height / 2), ), ), AnimatedPositioned( duration: duration, top: (style.height - style.thumbRadius) / 2, left: value ? style.width - style.thumbRadius - 1 : 1, child: Container( height: style.thumbRadius, width: style.thumbRadius, decoration: BoxDecoration( color: thumbColor ?? Colors.white, borderRadius: BorderRadius.circular(style.thumbRadius / 2), ), ), ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class UserAvatar extends StatelessWidget { const UserAvatar({ super.key, required this.iconUrl, required this.name, required this.size, this.isHovering = false, this.decoration, }); final String iconUrl; final String name; final AFAvatarSize size; final Decoration? decoration; // If true, a border will be applied on top of the avatar final bool isHovering; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return SizedBox.square( dimension: size.size, child: DecoratedBox( decoration: decoration ?? BoxDecoration( shape: BoxShape.circle, border: isHovering ? Border.all( color: theme.iconColorScheme.primary, width: 4, ) : null, ), child: AFAvatar( url: iconUrl, name: name, size: size, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart ================================================ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; import 'package:appflowy/features/share_tab/data/models/share_section_type.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view_title/view_title_bar_bloc.dart'; import 'package:appflowy/workspace/application/view_title/view_title_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart' hide AFRolePB; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; // space name > ... > view_title class ViewTitleBar extends StatelessWidget { const ViewTitleBar({ super.key, required this.view, }); final ViewPB view; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ BlocProvider( create: (_) => ViewTitleBarBloc( view: view, ), ), ], child: BlocBuilder( buildWhen: (previous, current) => previous.isLoadingLockStatus != current.isLoadingLockStatus, builder: (context, pageAccessLevelState) { return BlocConsumer( listener: (context, state) { // update the page section type when the space permission is changed final spacePermission = state.ancestors .firstWhereOrNull( (ancestor) => ancestor.isSpace, ) ?.spacePermission; if (spacePermission == null) { return; } final sectionType = switch (spacePermission) { SpacePermission.publicToAll => SharedSectionType.public, SpacePermission.private => SharedSectionType.private, }; final bloc = context.read(); if (!bloc.isClosed && !bloc.state.isShared) { bloc.add( PageAccessLevelEvent.updateSectionType(sectionType), ); } }, builder: (context, state) { final theme = AppFlowyTheme.of(context); final ancestors = state.ancestors; if (ancestors.isEmpty) { return const SizedBox.shrink(); } return SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox( height: 24, child: Row( children: [ ..._buildViewTitles( context, ancestors, state.isDeleted, pageAccessLevelState.isEditable, pageAccessLevelState, ), HSpace(theme.spacing.m), _buildLockPageStatus(context), ], ), ), ); }, ); }, ), ); } Widget _buildLockPageStatus(BuildContext context) { return BlocConsumer( listenWhen: (previous, current) => previous.isLoadingLockStatus == current.isLoadingLockStatus && current.isLoadingLockStatus == false, listener: (context, state) { if (state.isLocked) { showToastNotification( message: LocaleKeys.lockPage_pageLockedToast.tr(), ); } }, builder: (context, state) { if (state.isLocked) { return LockedPageStatus(); } else if (!state.isLocked && state.lockCounter > 0) { return ReLockedPageStatus(); } return const SizedBox.shrink(); }, ); } List _buildViewTitles( BuildContext context, List views, bool isDeleted, bool isEditable, PageAccessLevelState pageAccessLevelState, ) { final theme = AppFlowyTheme.of(context); if (isDeleted) { return _buildDeletedTitle(context, views.last); } // if the level is too deep, only show the last two view, the first one view and the root view // for example: // if the views are [root, view1, view2, view3, view4, view5], only show [root, view1, ..., view4, view5] // if the views are [root, view1, view2, view3], show [root, view1, view2, view3] const lowerBound = 2; final upperBound = views.length - 2; bool hasAddedEllipsis = false; final children = []; if (views.length <= 1) { return []; } // remove the space from views if the current user role is a guest final myRole = context.read().state.currentWorkspace?.role; if (myRole == AFRolePB.Guest) { views = views.where((view) => !view.isSpace).toList(); } // ignore the workspace name, use section name instead in the future // skip the workspace view for (var i = 1; i < views.length; i++) { final view = views[i]; if (i >= lowerBound && i < upperBound) { if (!hasAddedEllipsis) { hasAddedEllipsis = true; children.addAll([ const FlowyText.regular(' ... '), const FlowySvg(FlowySvgs.title_bar_divider_s), ]); } continue; } final child = FlowyTooltip( key: ValueKey(view.id), message: view.name, child: ViewTitle( view: view, behavior: i == views.length - 1 && !view.isLocked && isEditable ? ViewTitleBehavior.editable // only the last one is editable : ViewTitleBehavior.uneditable, // others are not editable onUpdated: () { if (context.mounted) { context .read() .add(const ViewTitleBarEvent.reload()); } }, ), ); children.add(child); if (i != views.length - 1) { // if not the last one, add a divider children.add(const FlowySvg(FlowySvgs.title_bar_divider_s)); } } // add the section icon in the breadcrumb children.addAll([ HSpace(theme.spacing.xs), BlocBuilder( buildWhen: (previous, current) => previous.sectionType != current.sectionType, builder: (context, state) { return _buildSectionIcon(context, state); }, ), ]); return children; } List _buildDeletedTitle(BuildContext context, ViewPB view) { return [ const TrashBreadcrumb(), const FlowySvg(FlowySvgs.title_bar_divider_s), FlowyTooltip( key: ValueKey(view.id), message: view.name, child: ViewTitle( view: view, onUpdated: () => context .read() .add(const ViewTitleBarEvent.reload()), ), ), ]; } Widget _buildSectionIcon( BuildContext context, PageAccessLevelState pageAccessLevelState, ) { final theme = AppFlowyTheme.of(context); final state = context.read().state; if (state.currentWorkspace?.workspaceType == WorkspaceTypePB.LocalW) { return const SizedBox.shrink(); } final iconName = switch (pageAccessLevelState.sectionType) { SharedSectionType.public => FlowySvgs.public_section_icon_m, SharedSectionType.private => FlowySvgs.private_section_icon_m, SharedSectionType.shared => FlowySvgs.shared_section_icon_m, SharedSectionType.unknown => throw UnsupportedError('Unknown section type'), }; final icon = FlowySvg( iconName, color: theme.iconColorScheme.tertiary, size: Size.square(20), ); final text = switch (pageAccessLevelState.sectionType) { SharedSectionType.public => 'Team space', SharedSectionType.private => 'Private', SharedSectionType.shared => 'Shared', SharedSectionType.unknown => throw UnsupportedError('Unknown section type'), }; final workspaceName = state.currentWorkspace?.name; final tooltipText = switch (pageAccessLevelState.sectionType) { SharedSectionType.public => 'Everyone at $workspaceName has access', SharedSectionType.private => 'Only you have access', SharedSectionType.shared => '', SharedSectionType.unknown => throw UnsupportedError('Unknown section type'), }; return FlowyTooltip( message: tooltipText, child: Row( textBaseline: TextBaseline.alphabetic, crossAxisAlignment: CrossAxisAlignment.baseline, children: [ HSpace(theme.spacing.xs), icon, const HSpace(4.0), // ask designer to provide the spacing Text( text, style: theme.textStyle.caption .enhanced(color: theme.textColorScheme.tertiary), ), HSpace(theme.spacing.xs), ], ), ); } } class TrashBreadcrumb extends StatelessWidget { const TrashBreadcrumb({super.key}); @override Widget build(BuildContext context) { return SizedBox( height: 32, child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric(horizontal: 6.0), onTap: () { getIt().latestOpenView = null; getIt().add( TabsEvent.openPlugin( plugin: makePlugin(pluginType: PluginType.trash), ), ); }, text: Row( children: [ const FlowySvg(FlowySvgs.trash_s, size: Size.square(14)), const HSpace(4.0), FlowyText.regular( LocaleKeys.trash_text.tr(), fontSize: 14.0, overflow: TextOverflow.ellipsis, figmaLineHeight: 18.0, ), ], ), ), ); } } enum ViewTitleBehavior { editable, uneditable, } class ViewTitle extends StatefulWidget { const ViewTitle({ super.key, required this.view, this.behavior = ViewTitleBehavior.editable, required this.onUpdated, }); final ViewPB view; final ViewTitleBehavior behavior; final VoidCallback onUpdated; @override State createState() => _ViewTitleState(); } class _ViewTitleState extends State { final popoverController = PopoverController(); final textEditingController = TextEditingController(); @override void dispose() { textEditingController.dispose(); popoverController.close(); super.dispose(); } @override Widget build(BuildContext context) { final isEditable = widget.behavior == ViewTitleBehavior.editable; return BlocProvider( create: (_) => ViewTitleBloc(view: widget.view) ..add( const ViewTitleEvent.initial(), ), child: BlocConsumer( listenWhen: (previous, current) { if (previous.view == null || current.view == null) { return false; } return previous.view != current.view; }, listener: (_, state) { _resetTextEditingController(state); widget.onUpdated(); }, builder: (context, state) { // root view if (widget.view.parentViewId.isEmpty) { return Row( children: [ FlowyText.regular(state.name), const HSpace(4.0), ], ); } else if (widget.view.isSpace) { return _buildSpaceTitle(context, state); } else if (isEditable) { return _buildEditableViewTitle(context, state); } else { return _buildUnEditableViewTitle(context, state); } }, ), ); } Widget _buildSpaceTitle(BuildContext context, ViewTitleState state) { return Container( alignment: Alignment.center, margin: const EdgeInsets.symmetric(horizontal: 6.0), child: _buildIconAndName(context, state, false), ); } Widget _buildUnEditableViewTitle(BuildContext context, ViewTitleState state) { return Listener( onPointerDown: (_) => context.read().openPlugin(widget.view), child: SizedBox( height: 32.0, child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric(horizontal: 6.0), text: _buildIconAndName(context, state, false), ), ), ); } Widget _buildEditableViewTitle(BuildContext context, ViewTitleState state) { return AppFlowyPopover( constraints: const BoxConstraints( maxWidth: 300, maxHeight: 44, ), controller: popoverController, direction: PopoverDirection.bottomWithLeftAligned, offset: const Offset(0, 6), popupBuilder: (context) { // icon + textfield _resetTextEditingController(state); return RenameViewPopover( view: widget.view, name: widget.view.name, popoverController: popoverController, icon: widget.view.defaultIcon(), emoji: state.icon, tabs: const [ PickerTabType.emoji, PickerTabType.icon, PickerTabType.custom, ], ); }, child: SizedBox( height: 32.0, child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric(horizontal: 6.0), text: _buildIconAndName(context, state, true), ), ), ); } Widget _buildIconAndName( BuildContext context, ViewTitleState state, bool isEditable, ) { final view = state.view ?? widget.view; final spaceIcon = view.buildSpaceIconSvg(context); final icon = state.icon.isNotEmpty ? state.icon : view.icon.toEmojiIconData(); final name = state.name.isEmpty ? widget.view.name : state.name; return SingleChildScrollView( child: Row( children: [ if (icon.isNotEmpty) ...[ RawEmojiIconWidget(emoji: icon, emojiSize: 14.0), const HSpace(4.0), ], if (view.isSpace && spaceIcon != null) ...[ SpaceIcon( dimension: 14, svgSize: 8.5, space: view, cornerRadius: 4, ), const HSpace(6.0), ], Opacity( opacity: isEditable ? 1.0 : 0.5, child: FlowyText.regular( name.orDefault(LocaleKeys.menuAppHeader_defaultNewPageName.tr()), fontSize: 14.0, overflow: TextOverflow.ellipsis, figmaLineHeight: 18.0, ), ), ], ), ); } void _resetTextEditingController(ViewTitleState state) { textEditingController ..text = state.name ..selection = TextSelection( baseOffset: 0, extentOffset: state.name.length, ); } } class LockedPageStatus extends StatelessWidget { const LockedPageStatus({super.key}); @override Widget build(BuildContext context) { final color = const Color(0xFFD95A0B); return FlowyTooltip( message: LocaleKeys.lockPage_lockTooltip.tr(), child: DecoratedBox( decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: BorderSide(color: color), borderRadius: BorderRadius.circular(6), ), color: context.lockedPageButtonBackground, ), child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric( horizontal: 4.0, vertical: 4.0, ), iconPadding: 4.0, text: FlowyText.regular( LocaleKeys.lockPage_lockPage.tr(), color: color, fontSize: 12.0, ), hoverColor: color.withValues(alpha: 0.1), leftIcon: FlowySvg( FlowySvgs.lock_page_fill_s, blendMode: null, ), onTap: () => context.read().add( const PageAccessLevelEvent.unlock(), ), ), ), ); } } class ReLockedPageStatus extends StatelessWidget { const ReLockedPageStatus({super.key}); @override Widget build(BuildContext context) { final iconColor = const Color(0xFF8F959E); return DecoratedBox( decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: BorderSide(color: iconColor), borderRadius: BorderRadius.circular(6), ), color: context.lockedPageButtonBackground, ), child: FlowyButton( useIntrinsicWidth: true, margin: const EdgeInsets.symmetric( horizontal: 4.0, vertical: 4.0, ), iconPadding: 4.0, text: FlowyText.regular( LocaleKeys.lockPage_reLockPage.tr(), fontSize: 12.0, ), leftIcon: FlowySvg( FlowySvgs.unlock_page_s, color: iconColor, blendMode: null, ), onTap: () => context.read().add( const PageAccessLevelEvent.lock(), ), ), ); } } extension on BuildContext { Color get lockedPageButtonBackground { if (Theme.of(this).brightness == Brightness.light) { return Colors.white.withValues(alpha: 0.75); } return Color(0xB21B1A22); } } ================================================ FILE: frontend/appflowy_flutter/linux/.gitignore ================================================ flutter/ephemeral ================================================ FILE: frontend/appflowy_flutter/linux/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.10) project(runner LANGUAGES CXX) set(BINARY_NAME "AppFlowy") set(APPLICATION_ID "io.appflowy.appflowy") cmake_policy(SET CMP0063 NEW) 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() # Configure build 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. 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() set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") # Flutter library and tool build rules. 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}") # Application build add_executable(${BINARY_NAME} "main.cc" "my_application.cc" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" ) apply_standard_settings(${BINARY_NAME}) target_link_libraries(${BINARY_NAME} PRIVATE flutter) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) 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(DART_FFI_DIR "${CMAKE_INSTALL_PREFIX}/lib") 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 "${DART_FFI_DLL}" DESTINATION "${DART_FFI_DIR}" COMPONENT Runtime) install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) if(PLUGIN_BUNDLED_LIBRARIES) install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() # 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() ================================================ FILE: frontend/appflowy_flutter/linux/flutter/CMakeLists.txt ================================================ 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(DART_FFI_DLL "${CMAKE_CURRENT_SOURCE_DIR}/dart_ffi/libdart_ffi.so" 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} ) ================================================ FILE: frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h ================================================ #include #include #include #include int64_t init_sdk(int64_t port, char *data); void async_event(int64_t port, const uint8_t *input, uintptr_t len); const uint8_t *sync_event(const uint8_t *input, uintptr_t len); int32_t set_stream_port(int64_t port); int32_t set_log_stream_port(int64_t port); void link_me_please(void); void rust_log(int64_t level, const char *data); void set_env(const char *data); ================================================ FILE: frontend/appflowy_flutter/linux/main.cc ================================================ #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); } ================================================ FILE: frontend/appflowy_flutter/linux/my_application.cc ================================================ #include "my_application.h" #include #ifdef GDK_WINDOWING_X11 #include #endif #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); GList* windows = gtk_application_get_windows(GTK_APPLICATION(application)); if (windows) { gtk_window_present(GTK_WINDOW(windows->data)); return; } GtkWindow *window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); gtk_window_set_title(window, "AppFlowy"); gtk_window_set_default_size(window, 1280, 720); 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 FALSE; } // 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_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_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, nullptr)); } ================================================ FILE: frontend/appflowy_flutter/linux/my_application.h ================================================ #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_ ================================================ FILE: frontend/appflowy_flutter/linux/packaging/deb/make_config.yaml ================================================ display_name: AppFlowy package_name: appflowy maintainer: name: AppFlowy email: support@appflowy.io keywords: - AppFlowy - Office - Document - Database - Note - Kanban - Note installed_size: 100000 icon: linux/packaging/assets/logo.png generic_name: AppFlowy categories: - Office - Productivity startup_notify: true essential: false section: x11 priority: optional supportedMimeType: x-scheme-handler/appflowy-flutter dependencies: - libnotify-bin - libkeybinder-3.0-0 ================================================ FILE: frontend/appflowy_flutter/linux/packaging/rpm/make_config.yaml ================================================ display_name: AppFlowy icon: linux/packaging/assets/logo.png group: Applications/Office vendor: AppFlowy packager: AppFlowy packagerEmail: support@appflowy.io license: APGL-3.0 url: https://github.com/AppFlowy-IO/appflowy build_arch: x86_64 keywords: - AppFlowy - Office - Document - Database - Note - Kanban - Note generic_name: AppFlowy categories: - Office - Productivity startup_notify: true supportedMimeType: x-scheme-handler/appflowy-flutter requires: - libnotify - keybinder ================================================ FILE: frontend/appflowy_flutter/macos/.gitignore ================================================ # Flutter-related **/Flutter/ephemeral/ **/Pods/ # Xcode-related **/xcuserdata/ ================================================ FILE: frontend/appflowy_flutter/macos/Flutter/Flutter-Debug.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/macos/Flutter/Flutter-Release.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/macos/Podfile ================================================ MINIMUM_MACOSX_DEPLOYMENT_TARGET_SUPPORTED = '10.14' platform :osx, MINIMUM_MACOSX_DEPLOYMENT_TARGET_SUPPORTED # 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 def build_specify_archs_only if ENV.has_key?('BUILD_ACTIVE_ARCHS_ONLY') xcodeproj_path = File.dirname(__FILE__) + '/Runner.xcodeproj' project = Xcodeproj::Project.open(xcodeproj_path) project.targets.each do |target| if target.name == 'Runner' target.build_configurations.each do |config| config.build_settings['ONLY_ACTIVE_ARCH'] = ENV['BUILD_ACTIVE_ARCHS_ONLY'] end end end project.save() end if ENV.has_key?('BUILD_ARCHS') xcodeproj_path = File.dirname(__FILE__) + '/Runner.xcodeproj' project = Xcodeproj::Project.open(xcodeproj_path) project.targets.each do |target| if target.name == 'Runner' target.build_configurations.each do |config| config.build_settings['ARCHS'] = ENV['BUILD_ARCHS'] end end end project.save() end end build_specify_archs_only() target 'Runner' do use_frameworks! use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) end installer.aggregate_targets.each do |target| target.xcconfigs.each do |variant, xcconfig| xcconfig_path = target.client_root + target.xcconfig_relative_path(variant) IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) end end installer.pods_project.targets.each do |target| target.build_configurations.each do |config| if config.base_configuration_reference.is_a? Xcodeproj::Project::Object::PBXFileReference xcconfig_path = config.base_configuration_reference.real_path IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) end config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = MINIMUM_MACOSX_DEPLOYMENT_TARGET_SUPPORTED end end end ================================================ FILE: frontend/appflowy_flutter/macos/Runner/AppDelegate.swift ================================================ import Cocoa import FlutterMacOS @main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { if !flag { for window in sender.windows { window.makeKeyAndOrderFront(self) } } return true } override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } } ================================================ FILE: frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "40.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "filename" : "60.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "filename" : "29.png", "idiom" : "iphone", "scale" : "1x", "size" : "29x29" }, { "filename" : "58.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "filename" : "87.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "filename" : "80.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "filename" : "57.png", "idiom" : "iphone", "scale" : "1x", "size" : "57x57" }, { "filename" : "114.png", "idiom" : "iphone", "scale" : "2x", "size" : "57x57" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "filename" : "180.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "filename" : "20.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "filename" : "40.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "filename" : "29.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "filename" : "58.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "filename" : "40.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "filename" : "80.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "filename" : "50.png", "idiom" : "ipad", "scale" : "1x", "size" : "50x50" }, { "filename" : "100.png", "idiom" : "ipad", "scale" : "2x", "size" : "50x50" }, { "filename" : "72.png", "idiom" : "ipad", "scale" : "1x", "size" : "72x72" }, { "filename" : "144.png", "idiom" : "ipad", "scale" : "2x", "size" : "72x72" }, { "filename" : "76.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "filename" : "152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "filename" : "167.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "filename" : "1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }, { "filename" : "16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "filename" : "32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "filename" : "32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "filename" : "64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "filename" : "128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "filename" : "256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "filename" : "256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "filename" : "512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "filename" : "512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "filename" : "1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: frontend/appflowy_flutter/macos/Runner/Base.lproj/MainMenu.xib ================================================ ================================================ FILE: frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig ================================================ // 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 = AppFlowy // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = io.appflowy.appflowy // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2025 AppFlowy.IO. All rights reserved. ================================================ FILE: frontend/appflowy_flutter/macos/Runner/Configs/Debug.xcconfig ================================================ #include "../../Flutter/Flutter-Debug.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/macos/Runner/Configs/Release.xcconfig ================================================ #include "../../Flutter/Flutter-Release.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/macos/Runner/Configs/Warnings.xcconfig ================================================ 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 ================================================ FILE: frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.files.downloads.read-write com.apple.security.files.user-selected.read-write com.apple.security.network.client com.apple.security.network.server com.apple.security.temporary-exception.files.absolute-path.read-write / ================================================ FILE: frontend/appflowy_flutter/macos/Runner/Info.plist ================================================ LSApplicationCategoryType public.app-category.productivity CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIconFile CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleLocalizations en fr it zh CFBundleName $(PRODUCT_NAME) CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleURLTypes CFBundleURLName CFBundleURLSchemes appflowy-flutter CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSAppTransportSecurity NSAllowsArbitraryLoads NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) NSMainNibFile MainMenu NSPrincipalClass NSApplication SUPublicEDKey Bs++IOmOwYmNTjMMC2jMqLNldP+mndDp/LwujCg2/kw= SUAllowsAutomaticUpdates ================================================ FILE: frontend/appflowy_flutter/macos/Runner/MainFlutterWindow.swift ================================================ import Cocoa import FlutterMacOS private let kTrafficLightOffetTop = 14 class MainFlutterWindow: NSWindow { func registerMethodChannel(flutterViewController: FlutterViewController) { let cocoaWindowChannel = FlutterMethodChannel(name: "flutter/cocoaWindow", binaryMessenger: flutterViewController.engine.binaryMessenger) cocoaWindowChannel.setMethodCallHandler({ (call: FlutterMethodCall, result: FlutterResult) -> Void in if call.method == "setWindowPosition" { guard let position = call.arguments as? NSArray else { result(nil) return } let nX = position[0] as! NSNumber let nY = position[1] as! NSNumber let x = nX.doubleValue let y = nY.doubleValue self.setFrameOrigin(NSPoint(x: x, y: y)) result(nil) return } else if call.method == "getWindowPosition" { let frame = self.frame result([frame.origin.x, frame.origin.y]) return } else if call.method == "zoom" { self.zoom(self) result(nil) return } result(FlutterMethodNotImplemented) }) } func layoutTrafficLightButton(titlebarView: NSView, button: NSButton, offsetTop: CGFloat, offsetLeft: CGFloat) { button.translatesAutoresizingMaskIntoConstraints = false; titlebarView.addConstraint(NSLayoutConstraint.init( item: button, attribute: NSLayoutConstraint.Attribute.top, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.top, multiplier: 1, constant: offsetTop)) titlebarView.addConstraint(NSLayoutConstraint.init( item: button, attribute: NSLayoutConstraint.Attribute.left, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.left, multiplier: 1, constant: offsetLeft)) } func layoutTrafficLights() { let closeButton = self.standardWindowButton(ButtonType.closeButton)! let minButton = self.standardWindowButton(ButtonType.miniaturizeButton)! let zoomButton = self.standardWindowButton(ButtonType.zoomButton)! let titlebarView = closeButton.superview! self.layoutTrafficLightButton(titlebarView: titlebarView, button: closeButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 12) self.layoutTrafficLightButton(titlebarView: titlebarView, button: minButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 30) self.layoutTrafficLightButton(titlebarView: titlebarView, button: zoomButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 48) let customToolbar = NSTitlebarAccessoryViewController() let newView = NSView() newView.frame = NSRect(origin: CGPoint(), size: CGSize(width: 0, height: 40)) // only the height is cared customToolbar.view = newView self.addTitlebarAccessoryViewController(customToolbar) } override func awakeFromNib() { let flutterViewController = FlutterViewController.init() let windowFrame = self.frame self.contentViewController = flutterViewController self.registerMethodChannel(flutterViewController: flutterViewController) self.setFrame(windowFrame, display: true) self.titlebarAppearsTransparent = true self.titleVisibility = .hidden self.styleMask.insert(StyleMask.fullSizeContentView) self.isMovableByWindowBackground = true // For the macOS version 15 or higher, set it to true to enable the window tiling if #available(macOS 15.0, *) { self.isMovable = true } else { self.isMovable = false } self.layoutTrafficLights() RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() } } ================================================ FILE: frontend/appflowy_flutter/macos/Runner/Release.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.files.downloads.read-write com.apple.security.files.user-selected.read-write com.apple.security.network.client com.apple.security.network.server com.apple.security.temporary-exception.files.absolute-path.read-write / ================================================ FILE: frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj ================================================ // !$*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 */ 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 */; }; 706E045829F286F600B789F4 /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 706E045729F286EC00B789F4 /* libc++.tbd */; }; D7360C6D6177708F7B2D3C9D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */; }; FB4E0E4F2CC9F3F900C57E87 /* libbz2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = FB4E0E4E2CC9F3E900C57E87 /* libbz2.tbd */; }; FB54062C2D22665000223D60 /* liblzma.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = FB54062B2D22664200223D60 /* liblzma.tbd */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 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 */ 1823EB6E74189944EAA69652 /* 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 = ""; }; 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 /* AppFlowy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppFlowy.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 = ""; }; 6DEEC7DEFA746DDF1338FF4D /* 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 = ""; }; 706E045729F286EC00B789F4 /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 7D41C30A3910C3A40B6085E3 /* 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; FB4E0E4E2CC9F3E900C57E87 /* libbz2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libbz2.tbd; path = usr/lib/libbz2.tbd; sourceTree = SDKROOT; }; FB54062B2D22664200223D60 /* liblzma.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = liblzma.tbd; path = usr/lib/liblzma.tbd; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 33CC10EA2044A3C60003C045 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( FB4E0E4F2CC9F3F900C57E87 /* libbz2.tbd in Frameworks */, 706E045829F286F600B789F4 /* libc++.tbd in Frameworks */, FB54062C2D22665000223D60 /* liblzma.tbd in Frameworks */, D7360C6D6177708F7B2D3C9D /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 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 */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, 6B44C542FA0845A8F2FA3624 /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* AppFlowy.app */, ); 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 = ""; }; 6B44C542FA0845A8F2FA3624 /* Pods */ = { isa = PBXGroup; children = ( 6DEEC7DEFA746DDF1338FF4D /* Pods-Runner.debug.xcconfig */, 7D41C30A3910C3A40B6085E3 /* Pods-Runner.release.xcconfig */, 1823EB6E74189944EAA69652 /* Pods-Runner.profile.xcconfig */, ); path = Pods; sourceTree = ""; }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( FB54062B2D22664200223D60 /* liblzma.tbd */, FB4E0E4E2CC9F3E900C57E87 /* libbz2.tbd */, 706E045729F286EC00B789F4 /* libc++.tbd */, 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 33CC10EC2044A3C60003C045 /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 611CF908D5E75C6DF581F81A /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, 4372B34726148A3BC297850A /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* AppFlowy.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; ProvisioningStyle = Automatic; 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 */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 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 */ 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"; }; 4372B34726148A3BC297850A /* [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; }; 611CF908D5E75C6DF581F81A /* [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; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 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 */ 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 */ 338D0CE9231458BD00FA5F75 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; 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; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; 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; STRIP_STYLE = "non-global"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; name = Profile; }; 338D0CEA231458BD00FA5F75 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; ENABLE_HARDENED_RUNTIME = NO; EXCLUDED_ARCHS = ""; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; PRODUCT_NAME = AppFlowy; PROVISIONING_PROFILE_SPECIFIER = ""; STRIP_STYLE = "non-global"; 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; 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; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; 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; STRIP_STYLE = "non-global"; 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; 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; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; 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; STRIP_STYLE = "non-global"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; name = Release; }; 33CC10FC2044A3C60003C045 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; ENABLE_HARDENED_RUNTIME = NO; EXCLUDED_ARCHS = ""; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; PRODUCT_NAME = AppFlowy; PROVISIONING_PROFILE_SPECIFIER = ""; STRIP_STYLE = "non-global"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; }; name = Debug; }; 33CC10FD2044A3C60003C045 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; ENABLE_HARDENED_RUNTIME = YES; EXCLUDED_ARCHS = ""; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.14; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; PRODUCT_NAME = AppFlowy; PROVISIONING_PROFILE_SPECIFIER = ""; STRIP_STYLE = "non-global"; 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 */ 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 */; } ================================================ FILE: frontend/appflowy_flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: frontend/appflowy_flutter/macos/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/project/PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1-json ================================================ {"appPreferencesBuildSettings":{},"buildConfigurations":[{"buildSettings":{"ALWAYS_SEARCH_USER_PATHS":"NO","CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED":"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_ENABLE_OBJC_WEAK":"YES","CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING":"YES","CLANG_WARN_BOOL_CONVERSION":"YES","CLANG_WARN_COMMA":"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_IMPLICIT_RETAIN_SELF":"YES","CLANG_WARN_OBJC_LITERAL_CONVERSION":"YES","CLANG_WARN_OBJC_ROOT_CLASS":"YES_ERROR","CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER":"YES","CLANG_WARN_RANGE_LOOP_ANALYSIS":"YES","CLANG_WARN_STRICT_PROTOTYPES":"YES","CLANG_WARN_SUSPICIOUS_MOVE":"YES","CLANG_WARN_UNGUARDED_AVAILABILITY":"YES_AGGRESSIVE","CLANG_WARN_UNREACHABLE_CODE":"YES","CLANG_WARN__DUPLICATE_METHOD_MATCH":"YES","COPY_PHASE_STRIP":"NO","DEBUG_INFORMATION_FORMAT":"dwarf","ENABLE_STRICT_OBJC_MSGSEND":"YES","ENABLE_TESTABILITY":"YES","GCC_C_LANGUAGE_STANDARD":"gnu11","GCC_DYNAMIC_NO_PIC":"NO","GCC_NO_COMMON_BLOCKS":"YES","GCC_OPTIMIZATION_LEVEL":"0","GCC_PREPROCESSOR_DEFINITIONS":"POD_CONFIGURATION_DEBUG=1 DEBUG=1 $(inherited)","GCC_WARN_64_TO_32_BIT_CONVERSION":"YES","GCC_WARN_ABOUT_RETURN_TYPE":"YES_ERROR","GCC_WARN_UNDECLARED_SELECTOR":"YES","GCC_WARN_UNINITIALIZED_AUTOS":"YES_AGGRESSIVE","GCC_WARN_UNUSED_FUNCTION":"YES","GCC_WARN_UNUSED_VARIABLE":"YES","IPHONEOS_DEPLOYMENT_TARGET":"12.0","MTL_ENABLE_DEBUG_INFO":"INCLUDE_SOURCE","MTL_FAST_MATH":"YES","ONLY_ACTIVE_ARCH":"YES","PRODUCT_NAME":"$(TARGET_NAME)","STRIP_INSTALLED_PRODUCT":"NO","SWIFT_ACTIVE_COMPILATION_CONDITIONS":"DEBUG","SWIFT_OPTIMIZATION_LEVEL":"-Onone","SWIFT_VERSION":"5.0","SYMROOT":"${SRCROOT}/../build"},"guid":"bfdfe7dc352907fc980b868725387e98814b7e2c3bac55ee99d78eaa8d1ec61e","name":"Debug"},{"buildSettings":{"ALWAYS_SEARCH_USER_PATHS":"NO","CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED":"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_ENABLE_OBJC_WEAK":"YES","CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING":"YES","CLANG_WARN_BOOL_CONVERSION":"YES","CLANG_WARN_COMMA":"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_IMPLICIT_RETAIN_SELF":"YES","CLANG_WARN_OBJC_LITERAL_CONVERSION":"YES","CLANG_WARN_OBJC_ROOT_CLASS":"YES_ERROR","CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER":"YES","CLANG_WARN_RANGE_LOOP_ANALYSIS":"YES","CLANG_WARN_STRICT_PROTOTYPES":"YES","CLANG_WARN_SUSPICIOUS_MOVE":"YES","CLANG_WARN_UNGUARDED_AVAILABILITY":"YES_AGGRESSIVE","CLANG_WARN_UNREACHABLE_CODE":"YES","CLANG_WARN__DUPLICATE_METHOD_MATCH":"YES","COPY_PHASE_STRIP":"NO","DEBUG_INFORMATION_FORMAT":"dwarf-with-dsym","ENABLE_NS_ASSERTIONS":"NO","ENABLE_STRICT_OBJC_MSGSEND":"YES","GCC_C_LANGUAGE_STANDARD":"gnu11","GCC_NO_COMMON_BLOCKS":"YES","GCC_PREPROCESSOR_DEFINITIONS":"POD_CONFIGURATION_PROFILE=1 $(inherited)","GCC_WARN_64_TO_32_BIT_CONVERSION":"YES","GCC_WARN_ABOUT_RETURN_TYPE":"YES_ERROR","GCC_WARN_UNDECLARED_SELECTOR":"YES","GCC_WARN_UNINITIALIZED_AUTOS":"YES_AGGRESSIVE","GCC_WARN_UNUSED_FUNCTION":"YES","GCC_WARN_UNUSED_VARIABLE":"YES","IPHONEOS_DEPLOYMENT_TARGET":"12.0","MTL_ENABLE_DEBUG_INFO":"NO","MTL_FAST_MATH":"YES","PRODUCT_NAME":"$(TARGET_NAME)","STRIP_INSTALLED_PRODUCT":"NO","SWIFT_COMPILATION_MODE":"wholemodule","SWIFT_OPTIMIZATION_LEVEL":"-O","SWIFT_VERSION":"5.0","SYMROOT":"${SRCROOT}/../build"},"guid":"bfdfe7dc352907fc980b868725387e98c22f26ca3341c3062f2313dc737070d4","name":"Profile"},{"buildSettings":{"ALWAYS_SEARCH_USER_PATHS":"NO","CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED":"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_ENABLE_OBJC_WEAK":"YES","CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING":"YES","CLANG_WARN_BOOL_CONVERSION":"YES","CLANG_WARN_COMMA":"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_IMPLICIT_RETAIN_SELF":"YES","CLANG_WARN_OBJC_LITERAL_CONVERSION":"YES","CLANG_WARN_OBJC_ROOT_CLASS":"YES_ERROR","CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER":"YES","CLANG_WARN_RANGE_LOOP_ANALYSIS":"YES","CLANG_WARN_STRICT_PROTOTYPES":"YES","CLANG_WARN_SUSPICIOUS_MOVE":"YES","CLANG_WARN_UNGUARDED_AVAILABILITY":"YES_AGGRESSIVE","CLANG_WARN_UNREACHABLE_CODE":"YES","CLANG_WARN__DUPLICATE_METHOD_MATCH":"YES","COPY_PHASE_STRIP":"NO","DEBUG_INFORMATION_FORMAT":"dwarf-with-dsym","ENABLE_NS_ASSERTIONS":"NO","ENABLE_STRICT_OBJC_MSGSEND":"YES","GCC_C_LANGUAGE_STANDARD":"gnu11","GCC_NO_COMMON_BLOCKS":"YES","GCC_PREPROCESSOR_DEFINITIONS":"POD_CONFIGURATION_RELEASE=1 $(inherited)","GCC_WARN_64_TO_32_BIT_CONVERSION":"YES","GCC_WARN_ABOUT_RETURN_TYPE":"YES_ERROR","GCC_WARN_UNDECLARED_SELECTOR":"YES","GCC_WARN_UNINITIALIZED_AUTOS":"YES_AGGRESSIVE","GCC_WARN_UNUSED_FUNCTION":"YES","GCC_WARN_UNUSED_VARIABLE":"YES","IPHONEOS_DEPLOYMENT_TARGET":"12.0","MTL_ENABLE_DEBUG_INFO":"NO","MTL_FAST_MATH":"YES","PRODUCT_NAME":"$(TARGET_NAME)","STRIP_INSTALLED_PRODUCT":"NO","SWIFT_COMPILATION_MODE":"wholemodule","SWIFT_OPTIMIZATION_LEVEL":"-O","SWIFT_VERSION":"5.0","SYMROOT":"${SRCROOT}/../build"},"guid":"bfdfe7dc352907fc980b868725387e9828903703a9fe9e3707306e58aab67b51","name":"Release"}],"classPrefix":"","defaultConfigurationName":"Release","developmentRegion":"en","groupTree":{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98d0b25d39b515a574839e998df229c3cb","path":"../Podfile","sourceTree":"SOURCE_ROOT","type":"file"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f986fc29daf4cb723e5ecd0e77c9cc3a","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Classes/AppLinksPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983499ae993d0615bc4a25c6d23d299cd2","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Classes/AppLinksPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9878bdb70529628b051bfb14170b6ce281","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Classes/SwiftAppLinksPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98803a16064d6b26357b9fa2d9c81eb2c0","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e982bcf9ac9f04ccd93893e9ae54d152c11","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e980af336cd97bd48a5f76247c45af3ca5a","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d4c688735e4b7c4c9af25f0254256c5a","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980f47fa79356e5b78e85637df4eab1e8f","name":"app_links","path":"app_links","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9852951db13a823ccb98a790f496fab4e3","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988786e23fb077ded3affc0f7a37fc275c","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9874ef26f64fe74d7c02d1a94bcde1184c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989187ad07f57154eca7d638acb8377adb","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dc7eee9f7cdf2e623ebc316ac5388a1e","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982c3c6bda68e418bfa0a7d818fbfb0037","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a15f02f386a528bc8eb605ae37642455","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cbbda7998cd576c276e2258fb3bad5a5","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982b881f7fdcc02480bc57ddb51bd8d98f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98aa491e8e0f7d7e2c84ecbe06c2f4df77","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ae796e67846c10ae19147ab24013260d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ee7362a7cb62b913a6f351fa670031fb","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c70d2ff23f2582de73eade3b63ca6732","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cfdf5bb4bb8df40c98fd16d64963a3be","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9827cc495d5b0d43a98c370d69de3ff8ae","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/ios/app_links.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98b1016bc412809827cc7f8ffd7be68d60","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/app_links-6.3.3/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e988f23ea9e443884f6d6a834c279bb42c4","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989d5bbbb2531f246d5e384ddd39c9ef94","path":"app_links.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985bbf5bd8a81b79ac8b94165aebec6742","path":"app_links-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98a0510b51afa43c66fac3cfa1b303b58d","path":"app_links-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988921fa59229e94f886b598374e0a0a6c","path":"app_links-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988a8ca854307b43eb2b99c9daa9a1cc96","path":"app_links-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986a31ee17a0bf46ce08a50fbab9d7e0d7","path":"app_links.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e981b9d938e8177ba8be7381344ebbf3f72","path":"app_links.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98faf0d598eef28587701cbceba0a82824","path":"ResourceBundle-app_links_ios_privacy-app_links-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9834af3cd6a85915e92c7086a5cef194bd","name":"Support Files","path":"../../../../Pods/Target Support Files/app_links","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985fe1a1ae80c7c29a049e0b0f5a968eb6","name":"app_links","path":"../.symlinks/plugins/app_links/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c9b1d2b694569060c7a89ce432ae59aa","path":"../../../../../../packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d2eb541b9cb3760f4d8a0de1aceac0cd","path":"../../../../../../packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98ff9d3e6fbf7f3ebdf3b2d98a4d52480e","path":"../../../../../../packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98082f3bd8728bb955d3b231cc205df22a","path":"../../../../../../packages/appflowy_backend/ios/Classes/binding.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981cf16cdf61f891cb9befc3392b13ff5f","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ba42de9b682ddbb7d9ecb3a91add63c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ce8a30ee373383f69298444d24489306","name":"appflowy_backend","path":"appflowy_backend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c86a47e2309e0b005ed79419dd093f0d","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98503452eddbee5272ed788d0cc749960e","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98055cce552f828e105a78ac03f0bdd805","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ca2c764817f57c4676d84d72558b3899","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f5501b2ed32cd958d3563e60f001d703","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b9c0603d95a0886bda3e008ea3e3501a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9877a3c2776a0dd48b2c60d087afb651a0","name":"..","path":"../../../../../packages/appflowy_backend/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"archive.ar","guid":"bfdfe7dc352907fc980b868725387e9836c8d2e47d81f8a6899c11848d60e876","path":"../../../../../packages/appflowy_backend/ios/libdart_ffi.a","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5592f7a99092768eeefc4cfc096cae8","name":"Frameworks","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98c2aceac4d9f7a88a7f2aef9ddb559c68","path":"../../../../../packages/appflowy_backend/ios/appflowy_backend.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e985e17269d373b81baa61bd43ba6a542e5","path":"../../../../../packages/appflowy_backend/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9850866a0df8df8e8e5c4a2fae74fa1e1f","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98e87c56b9467409c44163e34bc882ac5d","path":"appflowy_backend.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e158e9b03f83f13bbdf8668ff066134b","path":"appflowy_backend-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98eaf420deb60cd2d5a76ad867f2138dae","path":"appflowy_backend-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9805ed6ca977fdc6df3bcf5eed84819eb9","path":"appflowy_backend-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983bb1fa0b0f13951c6fa653a914b3fa2c","path":"appflowy_backend-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986cd74f83369370fbe7e403ef8053e619","path":"appflowy_backend.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98403ef0361dbe02708d9acfefd0464e16","path":"appflowy_backend.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a1e14bed5ed1f1fb81e768bd14f444fe","name":"Support Files","path":"../../../../Pods/Target Support Files/appflowy_backend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9835d1d3e8ff3e04e32db8f29bce41a67d","name":"appflowy_backend","path":"../.symlinks/plugins/appflowy_backend/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98624a11fdf6f56961a285723dfd21440e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ConnectivityPlusPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985824c2dcc3b51cb92cca4c0ee6b76711","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ConnectivityPlusPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e980ce7873e1e41d09d3e0e844e23814078","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ConnectivityProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98880b04837ff2e7c369e7e0eb127f9146","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/PathMonitorConnectivityProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986c4b5d0d184e7bdaae9f3f567209b6a8","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/ReachabilityConnectivityProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982d898374f337072929ea1137cd9b0531","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/Classes/SwiftConnectivityPlusPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a0e254b7ac4017377847f08d93859c98","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98403bc21ffba3f9ff7918b7c9d3ed9571","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f8845f64df2ea83ab8319d64b1bbadde","name":"connectivity_plus","path":"connectivity_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989eb115a9dec07b1f45a9c5d5afae3331","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9811c71b5edc42403c378790dde71cd61e","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988ba6e533cf0750afe861cf587836d63c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98337f70e0b3205d77252416fe9d53ade5","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d3ba4a1d262c8736c1a3dd2bbee95feb","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98935b95f888c988c238c3ba19998f568c","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9815214da764f00e2aace6e3402b97ff27","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c4748e70a6f1ff450d8ad89293faf1ad","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ddf25614c5494e3e2e964d3362a7e13","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98652b1ddb86551418439feb3a3cde66c8","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98413da6f3e8f068df77eefa2354cc1260","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b18d54474e5f2acd3f7e93b85008ecaa","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ff9bc5085e73af685f00aee655d65cbd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ec4569066db3377ae3957a0893989f6a","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e981911c6f4ab7da1f59fb48cce8d267a76","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/ios/connectivity_plus.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e982050d0252ea66aa742807a457832653a","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986076caee38a9da764b3dfa1e4e1a5d41","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e986c1079ccb9ad2a0dd836bbde26dfebf0","path":"connectivity_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6ddb885fad9a4542a6329a470fa2fe4","path":"connectivity_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98e592f2c2e44ef3deac3cd02784cc784d","path":"connectivity_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d207fb51488c7ea7ac1275e911a94150","path":"connectivity_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9898468587a6cab520cdcb8933d0b368ca","path":"connectivity_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9818c5888400978b2fa22d952059454061","path":"connectivity_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98d82fe1b7b4db7bc85ab18441f1e2c0ce","path":"connectivity_plus.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984b570fd876e00e3c1622ee7a13633dbe","name":"Support Files","path":"../../../../Pods/Target Support Files/connectivity_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a5f07478f75bc3b752964b6b533c114d","name":"connectivity_plus","path":"../.symlinks/plugins/connectivity_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba19832f3a1fec7b3b56e3071bf4c9f7","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/Classes/FPPDeviceInfoPlusPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98589f97f59a6ebf70206fa946d33c6a98","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/Classes/FPPDeviceInfoPlusPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9830307b7eb9685258ae248d413d28a51f","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b1c115a5b698e82de643427263904f6e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b72e875a2313b2fd273ee6413267e0b7","name":"device_info_plus","path":"device_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a016968d1537de5fee216f39b9b13a4b","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984163ac08562702d9111e13bc58eadee3","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863be3299f9535e1f37edeca566c56956","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ec71f83543e78b02bb8878f2bdaa1770","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983b57caee95bb0defd7fbea09e4630e95","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9866f39d80aa059eb566807578dd56d3e0","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d5774272732a1c387c19dcb20953a259","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9884b715eaf409b020791c09bd1a45ac40","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989064cf80f6cbf753a3f2f76769676869","name":"..","path":"..","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98b80404716ec61d84484617356e734544","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9853d1bd10368491358c120edf0879a1c1","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cd41f321f927f1501da3ce4c3e1705d3","name":"device_info_plus","path":"device_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c8a696a7575bdfd695b50552bda53e63","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986fad17d5ebb6a1b1e363579811099dac","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9896b7c1d7f56d6b335d559dde146c1187","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7caa4a8157f551fd5157e38236212ff","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eb0be9618fdb3c31fc76e6c391bb5a54","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d79e90f30e8071ea0755a235e7bbe47a","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f7e502581e9e563f0e716961fe919778","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ceb019bbffa37850d4328a3b5e80e961","name":"dev","path":"../dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d1e6ecf8a8f5e7866d4a3bf6a028da56","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803607fd62a02c7946a1d3477b61e1422","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984cfdc50cf761e86a346d06e7bbf2d3a1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e53458045fdd4d428512566882c92e7e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a82b79305f187eb48310a393f4bfe813","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e987f003eaf8659e7fff511891b3f2afc0d","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/ios/device_info_plus.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e983862a4e9dba3759d8615b149cba8a0dc","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/device_info_plus-10.1.2/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e982a7f5deb6a64ae5e5a8c21df8be555e2","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9800ef6f27822088506da77df111adaf81","path":"device_info_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d3729b6804108439237d0ad0b05ddce2","path":"device_info_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e984decbb29f8ccfc95e475df5348ee3959","path":"device_info_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98051abdaf3129a419e9bb3b8c7a83e64b","path":"device_info_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e913211adfbc9d70bfc43b4439f4332c","path":"device_info_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e984bb4748a26339d307020f1110f05e895","path":"device_info_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e981f4d52f92d8b0f360ee8bef71882f85a","path":"device_info_plus.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f354426d7c8f682caf04a755240d2fde","path":"ResourceBundle-device_info_plus_privacy-device_info_plus-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98623cfe9c4e66d55a221902e7792e5d43","name":"Support Files","path":"../../../../Pods/Target Support Files/device_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ba853655f8a54c79510e0b66012fac89","name":"device_info_plus","path":"../.symlinks/plugins/device_info_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e3b3f804d927f2bf6183213a182625c0","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileInfo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e16f08255892d0f684af6ee4b4380824","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileInfo.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f56ed42bf21b1a52ac8fcb9a82336a65","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FilePickerPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988e93662f755da0279387c09ed20fcb67","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FilePickerPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982542f63e48192694c2301b53dd8da392","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987347d2eccffbba5b92a6737ac7c7f11f","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/FileUtils.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984b1a288da1e6fe981b46f8315944e21d","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/ImageUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e04840a4cc59ee085ade0b92a852884e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Classes/ImageUtils.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98ed38e9ec1e8cd7f7427a8e3752fa5961","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e9830eb81718ca8a4637d18322144cb97c2","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e980cdda343b1e3945e28451531e9c1a605","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9845180a5dc5686acdc4e9429fa972713e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9836c9feed71b513f9919701e702f5ef43","name":"file_picker","path":"file_picker","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5fd1b4108172e277e33c8a87d3fcd70","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880d01b963c8ef608a0ad04583a5fc312","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9805619a0f02043e6fdb7a2be76189cfff","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9849b67c3d88226e7c72c75fa15e3bbdc0","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e1d9d867fa533872b45a7339a773d9b3","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b0187227de80dc084fabcf302004870b","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9825d0e9fb8adf9bdf05bf7db5c5fc456d","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9800d40be9558dad2dacf5f93047196827","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98abd6b198ade79f4872ee96e030a87e36","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9862b34fd8ad582ae69aae1fce3d396520","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ffed80c94d5fea9d76f4fe35d4478984","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9852117d814a38fe3eba010475bafd632a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e72e01936d9a14e0e9b869c88ad45424","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9875e49cbfeae37a120af486d9eb2d93c5","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98e67d477f82d32d5c7c9d30d2e81d066f","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/ios/file_picker.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e9808d1f81b0d4082e0417db2f18a2acfb6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/file_picker-8.1.4/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98cbd123e02025168eb151b7d71e556d22","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98b0953817803e455c620122efd668eb5f","path":"file_picker.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9837a1507700518fa7f49a60aac4b7767c","path":"file_picker-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98d8e8a876a8ca0ce915464fbc27689c44","path":"file_picker-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98273092b724e2281270954671470d86c2","path":"file_picker-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98330b1e95388e708f5ad38ed7ce90e28c","path":"file_picker-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98893e38e9593d6a06c8522268bf23390a","path":"file_picker.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e981a76198b195c337f9587084c94a43437","path":"file_picker.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e982406cc43be2014b06d264ea164e97c61","path":"ResourceBundle-file_picker_ios_privacy-file_picker-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a98dabe6e24ef65809527fcab892dd86","name":"Support Files","path":"../../../../Pods/Target Support Files/file_picker","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f62c63ccec2525926d94e2db6b738061","name":"file_picker","path":"../.symlinks/plugins/file_picker/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982640e5da9d4da0b1c1284d679e8bfff4","path":"../../../../../../packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982c61e60ddf03cc04542492a7725f11f3","path":"../../../../../../packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a2f3125bf6a20381c479cb67f97aa968","path":"../../../../../../packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUIPlugin.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e984ba1576a81594fe8f17aea9519f8b1fd","path":"../../../../../../../packages/flowy_infra_ui/ios/Classes/Event/KeyboardEventHandler.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5424bf8b4f526e22486e2a27f3268a5","name":"Event","path":"Event","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988d8845aeb0c1ed01cfe07a5f22cc0ec9","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803f83e7a1d900c14ec7e6905f0092826","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bf611f0f52fc18d7226f7e7f09c38245","name":"flowy_infra_ui","path":"flowy_infra_ui","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981fb46775118dd702b7316a553c95e90e","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98616f2715911b1a74d97ddaada4344fbc","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984cd1c794c9b9ad19f2e4f0ca3f026819","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b37b705e2925a02d2062aaafe5099089","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982add91273b39db2a9cf969fe86dca06a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983b3dd8aad5aabbc2496c663812f03cfd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d656d9598f5d14273b8b8981109e8b9e","name":"..","path":"../../../../../packages/flowy_infra_ui/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e989d6db7e7b482c3241d9a1acce4813ee8","path":"../../../../../packages/flowy_infra_ui/ios/flowy_infra_ui.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e989fd87faba1a0f13d4d7494191f208760","path":"../../../../../packages/flowy_infra_ui/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98145bf30753fca245e5d38777385d9388","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9893b48c5d4d9ce2656928a0ee3ff7944f","path":"flowy_infra_ui.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985398a052a839596d179b48db347a52c5","path":"flowy_infra_ui-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98c1829b249c76d5ac78b6563b1ee7fde3","path":"flowy_infra_ui-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985b5e2f476270884826aa113914031988","path":"flowy_infra_ui-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983e584fb8f2aede8db256844ee817b410","path":"flowy_infra_ui-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98892689e861aa6009551af6801e83c338","path":"flowy_infra_ui.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98f6af4fb94ad21206a16cbaf1e6453010","path":"flowy_infra_ui.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e982cc4e6d82b8278ce278988c26691765e","name":"Support Files","path":"../../../../Pods/Target Support Files/flowy_infra_ui","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f43591bd9ec58ad67ffe4453fafc4a1a","name":"flowy_infra_ui","path":"../.symlinks/plugins/flowy_infra_ui/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98fb18cc16183813fcd8641d3cadfad33b","path":"Flutter.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d37e3d81bea52e1b4801db4886715c89","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9812cdcc535f32a8a76cc3d8e883d2013f","path":"Flutter.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98048531a74a78f7613c93ba91f15c7ef8","path":"Flutter.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98152d9dfbdddbef1340635c08e31152c6","name":"Support Files","path":"../Pods/Target Support Files/Flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988b26d3d01bc6aaf3315f6d51c851186d","name":"Flutter","path":"../Flutter","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b785d8b1f51c643229e4fdd18985825e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/Classes/FluttertoastPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9877147b007839a2216fd57593e0f3ba5f","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/Classes/FluttertoastPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987efa3ca5a1d26929ed3b109a26806afa","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98792f001acf47168aaffa10afd959b5c4","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9812c06060cb56dec48c82c3b93bd2fdf2","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a85c4c34db915b09efc04ad74e71906","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98879342f1ae53be318ccc851c8fb5f741","name":"fluttertoast","path":"fluttertoast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986e74552a0b437b7d398a0e712dfea078","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cd6a60c3b14da76ce2370d2a8be6b094","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e29b7bd0bc91b1bd5e1a2b480473e1d7","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984dd71cdc0b7c0e396503163601d414ef","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9892ba1e9027f479ab7d1c9354ba3a2aee","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d4cad64b7e33d505b2ecf5d631c9d3e7","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987932e250d513fa720967e0dd955cb5c2","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9869aa178a8c06888c2f7f224a2918a492","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98316da1c1d27426250efb3febcdf6e95a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a6847ce3a370c3b3d860bf1b58a643e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9898b42eaa404b9ed45fdaf7f308bef4aa","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b9d4812f1ec13b817bb1728f24b63ef3","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9850c5079f1f3a336ba56135604b1311cd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ef2416841219f34ad24eb0617d29faa","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e982ae0f7d0422d2f75f6cdde13de9e63ab","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/ios/fluttertoast.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e983d3e3470809b19b23bd82e0a81446281","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/fluttertoast-8.2.10/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d1afec4a601b8594f84cb4f864f71af4","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e983d17b3300e70f7d2c18f92e0d276131b","path":"fluttertoast.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981f77f9386b6ff408a5830dc5ec967554","path":"fluttertoast-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f000b535e938ba4dfcadc2d08b7f9dcb","path":"fluttertoast-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980af04a50d29871944a910af21b08eb54","path":"fluttertoast-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d7bc0cf610c976df935aac7605febe19","path":"fluttertoast-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e984e7d38849f0179ae896bfe33295f4bcb","path":"fluttertoast.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98bfeefd631cf811ef5ce08bdcc7b5e371","path":"fluttertoast.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98740356b6d05058396c4af19281498c88","path":"ResourceBundle-fluttertoast_privacy-fluttertoast-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98781d0486e386be35e6ed9b7fe0e393a9","name":"Support Files","path":"../../../../Pods/Target Support Files/fluttertoast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988aa84318e78c044eca9d3adcb2083544","name":"fluttertoast","path":"../.symlinks/plugins/fluttertoast/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98d61d652dffc4f8948dc5e51433b55422","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984a37fbaf4a9886ee0471de51a32ce11c","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98593629a4be85977669ea4c059a1d10a3","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f1b4186d564806fb6d451c19a178c28c","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d5d0ee78f468d5980cfa1ea4ad6dc829","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985041391a222e0b4847d8b2a02427be61","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9875530726d790ea59a3b2315529b92cc3","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989ea0a989d0c2a93f9b189cf1469cea87","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98391dbed2b1e45fe5718c27685e1d7c67","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986721fa987b7c82532939c240860d8345","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fb63c7bf2a24aed477a61c0970de0960","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b2bbb2e48ebc4b126f757c2703266204","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9817ce3811d81e530356a343c80efe8ad4","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9849f3c66c3da79f5b15d867daabb3187b","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d3fa3916bdb222eafea4d7820d6eb9e8","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cd7d949543e498a3b27db6a1893718cd","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e7820fbe6a5f106704f492a88059c536","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerImageUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982369589aa7940b6e167faf588a3802a0","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerMetaDataUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983f29585ff52e44e910a275777a9f20c5","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPhotoAssetUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9859a809cba5c8a38df54a2381d8b2365c","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTImagePickerPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98de28739697251ea419e62df80ffef732","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/FLTPHPickerSaveImageToPathOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98919c321c361ef3f5f9d9f7385fcfcbc8","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/messages.g.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9850445506038bd7f616867a4a123a3ac1","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios-umbrella.h","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9883e47689f14ae5fdf581d0fab5f920e0","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerImageUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9831e620cfa600d0005b2000f1d54a69f4","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerMetaDataUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987f45fcb71f8e934254d2b14ac634b539","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPhotoAssetUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fe75cfd0b755ea439e9f1406676fbfcb","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d0c46c12ac4b3b637f4201b418d7f26f","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTImagePickerPlugin_Test.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9808b4daa14bfdb0a669f7e77d74ae3f17","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/FLTPHPickerSaveImageToPathOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985d17117714f9b0c4ea80392bb9bccf16","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/image_picker_ios/messages.g.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e982e942c8a9d3701363338a82b251071ac","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98502dd1df3bee9584d607bb1b1a902b26","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982056603c1983c7c0b15b4f9d4e839e1e","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9892285f318b670b27f7fbf2287b291843","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986a27670c0d2f4e5b9f45ade587b13d2c","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987b8f4a03e01f25f540c609ad50dc4076","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d97a722bba91079df06c440102468bba","name":"image_picker_ios","path":"image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98af8e2dbb1e8185946e7becdd47b97f9e","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989ae881df58521cd150fc286fffe35603","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803a91fc4ede7e5b0c2afbd5fab18fee9","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981a39b88b7383525d9d473bd93d1e2f0b","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9811f7fa0b42b0c8fb654345ec4ebb8e66","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98623219eb0a2c578df94d5883bc419325","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981125f09590385ffd9ea5a9fe5aa60ba5","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d31ae9e961eabf22319ec6a1fca4f113","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9817eb7c23f31f300a1c251c4305fda8c1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ec140a00b7550fc8d6fbc0aaceaa7a89","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bfbf2e646d2c92073ff2e83bebd9af2d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f2692e38d39bd674e71f6bb22b891e4a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9829706d777976177d0bf097f898b5b58f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985a01f741d7f1ce665f3e325d6c6a57f8","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98646c57f09cb01b0c762a0d93ca3f7b78","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98221a992b68c7cffb1a8eb5cbfbb297e9","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9853e18fbd58f9c37e8a3e2681a7b69feb","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios.podspec","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9804dde312afcdb12eedb33fc8bd45d59c","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/ios/image_picker_ios/Sources/image_picker_ios/include/ImagePickerPlugin.modulemap","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e988990923df59cdef8e5b1e19216f2a97a","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/image_picker_ios-0.8.12+2/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98aa0c16b2297382848b598f9592b0c3f0","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98a37a01b5033e0b3fe7a1dbb5d490ef35","path":"image_picker_ios.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fd29ed6fd89520fddcfbd7124ff888d8","path":"image_picker_ios-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e983d86dee1d916fb29b63d5b1bfcc7b5f9","path":"image_picker_ios-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9803f06581ec423d44280cf7868eea5e93","path":"image_picker_ios-prefix.pch","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9862b8fc1ccbc32fefecbae61cf5888907","path":"image_picker_ios.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e985530ede90a4a8c2c7507499317499697","path":"image_picker_ios.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98bd2861c7f94ca0a221c93e59dabe2757","path":"ResourceBundle-image_picker_ios_privacy-image_picker_ios-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9859b1bb772d76fea02e84d0016d099c3a","name":"Support Files","path":"../../../../Pods/Target Support Files/image_picker_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9830cadcc7a1263e98e67a0cf722e5d0ab","name":"image_picker_ios","path":"../.symlinks/plugins/image_picker_ios/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987800ae9687eb588b086e319ea6177659","path":"../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/FLTIntegrationTestRunner.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98edd6a3512846802c34d22c5afb07300b","path":"../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestIosTest.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980fa8dc3bbeb73b3d252e726a70557124","path":"../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestPlugin.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980db9a591d712346350116e799f0e85b9","path":"../../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/include/FLTIntegrationTestRunner.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984a0d38d682beee02329dbf3bf514cce1","path":"../../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/include/IntegrationTestIosTest.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ae12dfa62d0f539c9a3638bb6061375d","path":"../../../../../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources/integration_test/include/IntegrationTestPlugin.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9823e5a3226da96ed3914b1c8fcf559df9","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bde8b1c5794795e3b29ba817afae8d21","name":"integration_test","path":"integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7f37a3e9ff7a757003a42461c25c365","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ede6ae88c67694e327de9c425a7f6132","name":"integration_test","path":"integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987f156cd8efcf8b450a0acccba337affe","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989dcf9316567af671febf7c01cbe9f506","name":"integration_test","path":"integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987cf80e4fed23235d2406a799ca33eeca","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9847a104d2bb397b64bd133d45453210d2","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989392c96daffc1a0e321f346049c20e7e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980176812f8f0fbbedf15c13f3c2ee1f26","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985aa0f0e528439e82d21295d31624797f","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db8efb64140c28021c5072d8734e4699","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986ba3dab5252afe70f25f2838a01bdd4b","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b67e71ad7e85fc1ac5853ac3428427de","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985f23f974cdcdf9412df80483d5680940","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9873283974ea9ea8d983cfb681aeeee604","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db229454a7e5041b4830de422e60e499","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a2ced9ad917530dc3e50756255a1e21","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b9379d3571941dbae7d47d880a7a296","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985055bbeb70e29da915730a29ed24173f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988070745f3c2f414eef05f58b72da6f5c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863c8c730e09674801ad705a3ac5f0bb5","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989899d04f24c9ff95f1314d4fabeea094","name":"..","path":"../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e980c15437a062ac65671b286006afd1f2d","path":"../../../../../../../../../../fvm/versions/3.27.4/packages/integration_test/ios/integration_test.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d94ca862be564f152946859a75277d34","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98201d61b0d848a49c65589ceb7506e08d","path":"integration_test.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98af0a4e5755a22cb43543a81c374a2d51","path":"integration_test-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98a8522fb10db2dfa210c208f905afd9f3","path":"integration_test-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c0b24b129604eddd217c08a7d0c062db","path":"integration_test-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9896f6fa393191b60c6487f245272c94e3","path":"integration_test-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98105bcf1dafac8dc8d85eaff566c35643","path":"integration_test.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986a9f0cec960a86a1eea7dbdb4c49fcc4","path":"integration_test.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981a27bfd3655d560ebee82c3ab7c104f3","name":"Support Files","path":"../../../../Pods/Target Support Files/integration_test","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98051dc09e6cf1b3ea30ba9f39d11035d5","name":"integration_test","path":"../.symlinks/plugins/integration_test/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8ee44916529381498405522e751f8b0","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios/Classes/EngineContextPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984c462c42948edabe677ebac748da5d96","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios/Classes/EngineContextPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fc9b7f6e025c71f553fe6444717223ed","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db377227d8253ffe98324018634bc2ba","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986f2a88ed98a92034ce863ada1219ab5f","name":"irondash_engine_context","path":"irondash_engine_context","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98da61f0e74006e8fbd784bf4a3b970e5f","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98635b7210c5b62e20e037994cad1bf111","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c64150b3d0970cb99fece561f5a8799c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d3fdbea6d8cb166625df79788fcad443","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cbeb45be878891b685575f6b25a8528b","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9847ce44e75f1621ef4e0a6a5ae85f248d","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b2ae8d52765d0e5d9bea0d19cd7aebc9","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c84ed3eae59cea51b25d355ea7faec4c","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9899ac6782e67161e5aecf52fb2c668a4c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98710388c0277b6c02dc2bfce47465f2f8","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c5e6f44e93118427f696de099ce587e1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c97afc961fd21222514666e42cbbfd8f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984e9cef67c7f53259a28a5a877e4a2131","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985b651d4fcaab066c39a7ebe7cff5daa9","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98b58b99914223d1b29b5e344e157a1987","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/ios/irondash_engine_context.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e9830fe1bed9eaa6250033f52ceaadf7d59","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.4/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986fa867be18f4a0a3be7766d74146209f","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98425d4148b72c406e4fd6672ecc362707","path":"irondash_engine_context.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d84855c0549724120cd6c3172b83cfa2","path":"irondash_engine_context-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98de048098637f376810dd3bb08c83895c","path":"irondash_engine_context-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b78febae09cf0c8f6fc7540345bee7a3","path":"irondash_engine_context-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899f4dc54067ed61c6ad87cabdfa23817","path":"irondash_engine_context-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9859e866190126bed1c816e3db8cf733a3","path":"irondash_engine_context.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9837cfa75eec5f21f87364f26642dcde07","path":"irondash_engine_context.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9832c8cd0ed43920be52e135a0de0ef859","name":"Support Files","path":"../../../../Pods/Target Support Files/irondash_engine_context","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98abf194361c2fa0e4ef4dbec1a81fd4cf","name":"irondash_engine_context","path":"../.symlinks/plugins/irondash_engine_context/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e987c73d9371bc96dd532ba087fb5fe818f","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/ios/Classes/KeyboardHeightPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98bc635b84f7af43f9a693a5e68a4759e0","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f0eadcb14d473ce9c1a7c484bf311b2e","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cf80142a4ef1966c0c5c5b502225cf5a","name":"keyboard_height_plugin","path":"keyboard_height_plugin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989d420609dd708f812028e4f49f0e9d67","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9895cace1167e8f8a1209977ad38dfccfb","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98422a86793790ea0d327a52c60169ac60","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9830a1c8fc44b202d4661674a7fad9873c","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e85d16fef38a2779c13639b43f1e3726","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9842be4aab321e08d3762331c112720d13","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d8f5a70339a345d74b94ca7b959328bb","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986d3eca9fadfaabd2c455a69807986068","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d98f50f9676e73dee28b8bb61da9f8ff","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9807172992c96c01947c7f63e7875b1880","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982737307bd1600ca8956d8f22af636541","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98854bc6907e9bd1273078ea0de43aa17e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d53a1d0636736242ed1151c333b5fc9f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980c1dd450aea5e1194ba1fff003416312","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e980bfe355de52abf2bb6aedb754200dccc","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/ios/keyboard_height_plugin.podspec","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e985cbc6607d34630da0312e81033a0eac3","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/keyboard_height_plugin-0.1.5/LICENSE","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fe47c46f6c2454354c85a993a081845d","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98619ee239792a4f0a0de0a4a0b0a159ed","path":"keyboard_height_plugin.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a419ee90c48f680450b1d3ead4ab82d1","path":"keyboard_height_plugin-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98b22dd52696b4067993e38f2551b63980","path":"keyboard_height_plugin-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ac24e13278278e052df0627e6f2cfe76","path":"keyboard_height_plugin-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988d785c658ed39de0a4c38be45dfbc1bb","path":"keyboard_height_plugin-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98b6b8fa71d08d5539059bc6a5868a679d","path":"keyboard_height_plugin.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e985de3091a8d3281d32c2891e218876b88","path":"keyboard_height_plugin.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98b6f518b56ca945c4e48ea2f80f2dde0e","name":"Support Files","path":"../../../../Pods/Target Support Files/keyboard_height_plugin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98de11b38cb40d0a466e962277af5c13cc","name":"keyboard_height_plugin","path":"../.symlinks/plugins/keyboard_height_plugin/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fa9794d4853209dd9b9dc8e446307a60","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios/Classes/OpenFilePlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fda7caf052d83850aa64439013c284eb","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios/Classes/OpenFilePlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987c5f0895df45e85ab2470a8073a4c8f0","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98edc66a76af5791867252ee9a4fb21910","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9895e2c6870f605df8c86b47a953028581","name":"open_filex","path":"open_filex","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980fa51cd5f979e99f137ab48d9cfe538c","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989c915eac28a038451586120b5e42a4dd","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9844dffd7af5aba7efc4912e98f2c99fa8","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982963e1dbef544728f2fbcde398f7ce6b","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9820c60640726eda7bd6b2af35bd76df61","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bb93350f484f56f7d755b99459ba03a1","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5b26b9b30e5f21dd0e61b63275d1293","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986605830136cc6fec7889ae13c84cbe94","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b903bd75146f71f5101abe9a847888e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9835c6d6f45f08bbdd25355fae03109fc9","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989b22dd54825d4836f9aeb5366f4d1942","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fc1763d383faa8bc59b0d7f9aeef3f0a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bc4e9c0a74a0677415efe8c07067e305","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9898e553f7a823f6dc70a807a2f2d230a6","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98ee0863552468cb4b187a007dc6d9a9b2","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9852adb17f9ae796569014283b63994f43","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/open_filex-4.6.0/ios/open_filex.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9881839ee4b95080a8084a269cdb03d9cf","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e985fad692e094e1d0680167d0e9b810fb5","path":"open_filex.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9840ba9a41d5060da79af9271d16ff75b4","path":"open_filex-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98481c5fe7d76a4040e0ad917154c7e4d5","path":"open_filex-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983eb20923999fb0272a7d7981812ac419","path":"open_filex-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98045ac1ed0e248a91c7e040ef0eb573ac","path":"open_filex-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98d756e4a333f24f739d0261675ee1a4ba","path":"open_filex.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e989220364d0a7adb8651f9b9241f5c7291","path":"open_filex.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984048ca38c41c417f8623767606347b2e","name":"Support Files","path":"../../../../Pods/Target Support Files/open_filex","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d744b5aadcc662d9087e0ff8ecfa7db1","name":"open_filex","path":"../.symlinks/plugins/open_filex/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981d03ed1c16bd8f4f97657768978ceefc","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m","sourceTree":"","type":"file"},{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e989c46f3ac5eacaf84d06c89a1618d018a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources/package_info_plus/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b8b9ba080e191e0b180722bf92e5f6d0","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources/package_info_plus/include/package_info_plus/FPPPackageInfoPlusPlugin.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98434c664522ec8a949437da93121dc4ea","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98da4508512de7ed1cf62ba23f6e0a1782","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987977607c1ffbf525bd13f89c1d4d9af6","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a38ddce212e7e062f995ed0c81943891","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98931f5d8098ad1aa7c93eec26372ba236","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98285660b922695a353a0d34c11f224681","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9850cc5da0429ea6a6d2b70a2656fcab30","name":"package_info_plus","path":"package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ddc93fc8ade4d1b45b41d4e43f4567c","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98265c9ab7615abe783c697b6cd4393241","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d03678f78b9690ff2b539e1f76337f73","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ad2375b9e2190a33ef70bfb739550e5","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7ffa0b49f80dc8ef09e80c7b9ea10d9","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9870b15a53dd8bece6cba98fe4fe76a795","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c4af5d8efdfb0599410662c8d5fbef98","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9875f8bf110ce0646b5fb64007eacf9418","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c89cf11d4dd01c776076b9aba66f6de9","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9856bac1d729bbf1ed2a72d1315cebd361","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9891ba0e8cfe4277c3831c309656922ca3","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980531ebbd14b9b1db9cb595aee441e728","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989afbfb7501eabf645dfdd3b2d5371318","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987d5f78597ba1b1ca57b94d699cac9587","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e0fd6aabdc57252bbd2b8424bc907ff7","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9890c7045574c1805ae1374327a927bd10","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e987939da84758bc78ea493a94d1cea8578","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e981e6125d7e62187e31adc58e8a2ab9ea5","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/package_info_plus-8.1.3/ios/package_info_plus.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a53ea27f341ed23389e7d2e47e722d47","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989676689969e25b7e2b922fdcc545264e","path":"package_info_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985313e6a90d7b731193c46689110da675","path":"package_info_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98ecaad6d841c680a103b7c06250152c10","path":"package_info_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987e968239394f15ed507902e17a5a5d3b","path":"package_info_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983b443c6a2f8b2d550ac2cdd9baa30e9c","path":"package_info_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9833b0ea6cf67df0b17b6edee85d07e536","path":"package_info_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98fe3643fe82f3241c56cd3e148f9bfcfb","path":"package_info_plus.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98e9bc7cd75e23035e788a0e7c25bf5266","path":"ResourceBundle-package_info_plus_privacy-package_info_plus-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98597a17b9183cd7dd4bf1dc3afc5c61ea","name":"Support Files","path":"../../../../Pods/Target Support Files/package_info_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98aacab2d91d3b0af7ec103ba0eb959971","name":"package_info_plus","path":"../.symlinks/plugins/package_info_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98fa855961429fcf8f989cbc521f984b6b","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources/path_provider_foundation/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98b4c976140e1e1950199f10d209e74f5b","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eacafab16506b203843ef40c684430b1","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98afc251afea8ca3c7c50e9df03700de10","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e09b0557ec981a89a5d3cdcc6842f0c7","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98848154feecc038860b30933f28fcd571","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e4c4edafb8828d8853578114f39e78ab","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988219086c27550c56b47393a9c0e39e4c","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e704029916b2a9af5f095363d987f0fe","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98357eba85aa36d189cd46406d27f5211c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987a6b9a2268f0d1806de5375f49c7ad34","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f47342e61ef54b290a2f053ba08d9dca","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d0b8c28e93e04c0badf7323d031eae30","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9837afaa61f9ce1c7f4fd458c2cf5adf98","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ca9305ea66f0600f4817de50cde8db74","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9808e63fd1bbc402ab215a50d1d16dcd32","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989546965c4112992540058bf89b75515b","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources/path_provider_foundation/messages.g.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985086f0886ccfd52bf38850cbdf060b2f","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources/path_provider_foundation/PathProviderPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f3577bef4fc38afcab726db64a001531","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982b0a750596bd71fbc8cfcf1a9c72ca42","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c1402de7d04e960dca8c1c9a29277190","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b0db58f72b87ea1465b9bbf9fa254cd1","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d11257d51b57c6c8f8403335be4b346e","name":"path_provider_foundation","path":"path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b2fe29e812c42cd3759a2d5cda315ac9","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9866a118eb431e0fc65900b0d1ed264fc9","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c5612f9fa3a0fea2a9cb1e98a04ed834","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ba29a7b05ab240400ab764ea3778d63","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e6d60acafabcaba2c92afe3f89dccc00","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98957bcf5769cd6c1ddb7b1bacb4045c62","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9857a303219d5ef02905623f5642388e38","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d0517ff6d03f19c2b5d8f5ae77998f57","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c6054740313f5e60a651b62a3a9fddff","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bb43e5bcfafff3075b881ff2a9506820","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98952ef0fb2ed4fabd37dfc72628c7e3ba","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c173f0cea5f6ab214f5630fe12869f9b","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b3e481ae0756598ee53e01ba3ade5dfc","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9815a65875b9784344b15a908a00046200","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9827c3714a1d7d43b116f6f2b348cb54b5","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98600a8661b70b3bcc185abb7a4e9008f4","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98a8b04a5824c42b6f4f609567865faee6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e981bdde8f32acfbdc05bfd1e4b808852a6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.1/darwin/path_provider_foundation.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d9cf578a3f3bd7e82b6d5e62241a3287","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9861842fd411d053b57aced9948ca56491","path":"path_provider_foundation.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cc5a067b653131ca99fde3ba1debf062","path":"path_provider_foundation-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e989dd8857730e4c3db203f2e8d33299b7f","path":"path_provider_foundation-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ba8eca3e056e4a5b65fa675f2756611","path":"path_provider_foundation-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a687ca6ea8584ff3e8089580a0210540","path":"path_provider_foundation-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e989a3ec1c60f383374577efaaaba58a590","path":"path_provider_foundation.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98c312fad1a73dfd52c70c16e3d25e37e6","path":"path_provider_foundation.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98e9d12e6cfc445534a2c16d64aa703cc3","path":"ResourceBundle-path_provider_foundation_privacy-path_provider_foundation-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f3324a2357137a370c65dc36aa758feb","name":"Support Files","path":"../../../../Pods/Target Support Files/path_provider_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986a2b2d2d35d31671cf8d3a967a7db258","name":"path_provider_foundation","path":"../.symlinks/plugins/path_provider_foundation/darwin","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e318c8a8d2c9dc308e66d48a374d44d9","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionHandlerEnums.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d3c436acf3f4f4e33e39114153074d49","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionHandlerPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987887890697cf58754f65bdc3473cc551","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionHandlerPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a74eea3ef4e2a60a3951ba0e47037853","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f80ba72d7ab8de1bbe80c04c71d79ce6","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/PermissionManager.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988f16b65cffacfc53fcc954ce586fc713","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c853197e3241e98fb93603a21ed56089","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ce0ef02ade9d0a1304458c836d16e93f","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AssistantPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98688a017d26dc611c7c96d1741b382970","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AssistantPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985e535aca2400f5f683c5e24da33fa945","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AudioVideoPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9822f423a57f83424ebc6bfefdee8b3991","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/AudioVideoPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98393b75ed0ceeda793874088622fd76fd","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BackgroundRefreshStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98df055a47a4e10ed4075cbf80ce6ec767","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BackgroundRefreshStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98df5b697e834c60412ac60dfaf5b92739","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BluetoothPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b97a3c6c28463a93a507d1b992be86ea","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/BluetoothPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981d3465f51e1a6151315cb6853ef86eb1","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/ContactPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b85c4c9dd82c2d504573d0304be09cbe","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/ContactPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f6cc08933266f6af7683b3abe5dc3ace","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/CriticalAlertsPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e1614978d545aec846c24669228cfa1c","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/CriticalAlertsPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988634e5ad5b489cfb8621854418e2b38e","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/EventPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980b79f1643454a89c62ad82f48b83100d","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/EventPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98874ffa55cffa9b07dd0dc545368418e8","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/LocationPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d0f2795f5c06163cd56c66c1973fa5e6","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/LocationPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9820710d8f569064b9b4711ed685cbb429","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/MediaLibraryPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98922dbc3046dcd8b009793d4a2382c211","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/MediaLibraryPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985b7826319f99ae1623d37b8cf2f4eba5","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/NotificationPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9805e45e592399c0af6ed4e7318ba5d2c2","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/NotificationPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a0d58fb5a1923e677273beb359f58ab9","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b1b544d0a39e91ce76da89768ba82cb7","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhonePermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985eb1cc6a74764d8f7285312afe97653b","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhonePermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984e680efc7f3195ff924eec54756ed68e","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhotoPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6c7b999fdb1f6499108be37ae374b63","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/PhotoPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987f8fe14bbd3ac1b56df04f30ce3befc7","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SensorPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98af1cc35431393d9bafe812d1459bbdf1","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SensorPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985c3d41a4498df42a0c6816c971e66b90","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SpeechPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f22ec1e8227f9be641e5842788c56cf3","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/SpeechPermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986e866dd7a325c4b0ac10a86655c960f4","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/StoragePermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988cf99d004f1e5c25193675ec100372ac","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/StoragePermissionStrategy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9812e94ec5545f5483160eaed2e319fd2c","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/UnknownPermissionStrategy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984d86125607541fcdd295dcf2c90de152","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/strategies/UnknownPermissionStrategy.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98bb6db6682fe6132381afffdd0b60c949","name":"strategies","path":"strategies","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983970b54b6bc58e9c785411c2b48da98a","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/util/Codec.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9852af6ca8a2b5eb428a1585731297f789","path":"../../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Classes/util/Codec.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5b3b31059ef8de5598d516260c77291","name":"util","path":"util","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b21a21f20e31c9dffd8a301b181e7499","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98adf4c31a2e50275af41b8a579edc68e7","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e989ac6ab6d3c4d5bfd6ee635e5bfaf86a2","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986adbf3486d93b28d124272c05d31c535","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9873343d816dbbcd1b184b6d68e52b8f8b","name":"permission_handler_apple","path":"permission_handler_apple","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e29a61315e2ef4299defb08c834d8658","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9813dc0e9e99378f2e9402361650d4f3c8","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e01ef4d5c3bed4f78810b1761f0bfd2b","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982268db3e1a4139e97ecd2061e336e5bb","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985a0dfbbd565a647af8ec44544f29a21d","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98656f043d890499eb834029f3c937d260","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98de17afe181ca6321b521909130e6f662","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986adad874bc346fed5ccaed27e453b741","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ff2305908cc93c4a0725655e88bdc78","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9857ae62fcfbdc453c8d905748f5c3feee","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a7884dba25b1f761bcc93d0154f12971","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9828b593dc8b9a43bd8236b7d7a05a125d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9840d397c5e3d46385c36e81d47ddb4e1d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b5d7000c1c54eb9b83d1cdaabf7aa111","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98eedb8bb38abe4a753d3e5a4a50166ce0","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98bf01ad6eb9325fb5f924a7a939b9c3d5","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.5/ios/permission_handler_apple.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981b1b5aec77e91493469b21f5697a5678","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9895519dd776dc6b6b6a0d5a99939759a6","path":"permission_handler_apple.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d3090640601ef537b4effff19f6956f9","path":"permission_handler_apple-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98693e4bbf87f0173ce83ad9dad9c4ec64","path":"permission_handler_apple-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98db4011adc2801eb0447df5d29a88d434","path":"permission_handler_apple-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9872ca1772cd655bc190d399786a965f06","path":"permission_handler_apple-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98f0f251835fe2b85ba8e20304af40d5bf","path":"permission_handler_apple.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98498a3b2ced2fb3f55d9af8975a87d769","path":"permission_handler_apple.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986ea3ec8648859708ff7049b01e77d7e6","path":"ResourceBundle-permission_handler_apple_privacy-permission_handler_apple-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981e5c39acd14afb0de574df88226e3506","name":"Support Files","path":"../../../../Pods/Target Support Files/permission_handler_apple","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98373a889c847b4c599507bdad83234fe7","name":"permission_handler_apple","path":"../.symlinks/plugins/permission_handler_apple/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9881b60c802c0c2009406bdc039b7f0068","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Classes/SaverGalleryPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98dcbc4e0c213bbd743d9703fa7e437dfc","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Classes/SaverGalleryPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98f7bbe1f859cede2224d42b729d4c355e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Classes/SwiftSaverGalleryPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d5b49ba1d17a7857dc59f1175110cc13","name":"Classes","path":"Classes","sourceTree":"","type":"group"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98c89b00fc416882d2d0d7c9b090fa7c9c","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9820ce2023299a06e421e4a78afc6b0b82","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9865ece6bf5cc7c6cce51ccbe39d149b83","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98541499aef6d248a8301980d2821058d4","name":"saver_gallery","path":"saver_gallery","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9822ec8e7cdcda0404b791382d92111f01","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9864d2f6413ca98b754c7ee8a5171ad1fa","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98230a7213b882ddae22c49a7606136e55","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985321a2bb5473af9a5a2bc5bc4f834a04","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dc31a6400e820a903a3adf3e0472b97d","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b76ca3af3e88ee3e30fcf7e21bc6d0e","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988541bb9a4c72550c30a45563b09a2e33","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e4d469fa76bd962f8bbbec14965af39f","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988ab99b3f5ad06e51078cf9e780a1d69a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98362dc58617351a4e100aac833e0a9775","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980dde8a02284f890277e1c2802444a83b","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863783b60069d614fac80a368d82f00e3","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989d4a0878fd359b487c9cea0fa7016714","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980cda4027ee69c106bb442ab08ef4d82b","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98187e4b7080377e470ceccd8012e8f22b","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98f1b23c2ea102d15d2d3364c334ab1150","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/saver_gallery-4.0.1/ios/saver_gallery.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f1988bfa026ce78ad365f9598fb48ccf","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e980dce72be9e98a0e7938fea67d5dec3f0","path":"ResourceBundle-saver_gallery-saver_gallery-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98f8441040a6b86dbd9daae56dbd15f227","path":"saver_gallery.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98bc00fdad72f5f3771416bb7da4f843ac","path":"saver_gallery-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98b3812fe968f19a56dff3b7b21717a930","path":"saver_gallery-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9851858f7e87dff1cd2d14bcfec05bf9e7","path":"saver_gallery-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fdb913e186672d6dd019c490a01906ce","path":"saver_gallery-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986375a6a5aae83e2825b2fa84412a7b38","path":"saver_gallery.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986cfaff9f48d79d9aac38b1a4200ab7b7","path":"saver_gallery.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9870bd2de048ce790265ba57145fada872","name":"Support Files","path":"../../../../Pods/Target Support Files/saver_gallery","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e9f8053722083a22097cca53b68ac915","name":"saver_gallery","path":"../.symlinks/plugins/saver_gallery/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98988b8f30525a0740a56a45f26ec9e5ee","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutter.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b3f62b7cd428533f32ccbe502d30579a","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutterPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9826cd8809b1744007cf2cdc49b9ee183e","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutterPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9814197e8a8c3142283755e74bb1e1a542","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/Classes/SentryFlutterPluginApple.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98ed1bfbd13db96f1a5a9a509f62b23475","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988a2e2c7191f3342f69288dc5b6f24eac","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986b57f5aa6fe19896cfd444c1d7e3374b","name":"sentry_flutter","path":"sentry_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e95123df781f7246754488867eecad12","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980c8dbba9160b89547d42fa282bc4d833","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98408185297f1419b37849ee4ef61dd14a","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db140395c21d264a6950ef09ad273630","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a317c3cc4f246a4d86dee21027be9f4b","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982c4e624eda92287d712d01e09bae857c","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988c938458c24c5a8a3de86b13a240257d","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9816b3e0cb5684faafbde3cd04104a8bf1","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982a1a4fb826ee2604d98a1124cda9655e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984814fee408387d1cb1ac9abf04007801","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98646b3421a4027d57df2335874d741e7a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d7d7eb47d8b13b56642d25993bad53b7","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9834206fc222aad06497489cefdfcadd01","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98472caaca9c22e04c9586a3fde6ae90f3","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e988942381687e6a83dfb7f7b51b7114eae","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98d7a35162a09b1bafe73fd4acf4dcd74e","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sentry_flutter-8.8.0/ios/sentry_flutter.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98c631af1f252c1a6185a574de8e401916","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98b2b1e766516fc1973912c9ed7d38fba5","path":"sentry_flutter.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b20710f0afc9a65fd11ac053ab5224c4","path":"sentry_flutter-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98fb88983eae4d4fb5041df897b5eb48b7","path":"sentry_flutter-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98140ec5a195fd3cd2e6672a247b3c50dc","path":"sentry_flutter-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980060be8e96d53611f0f35fc6b03e1144","path":"sentry_flutter-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98898ca6b789d6ebda968b7bf2c32c2927","path":"sentry_flutter.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98eb2509e05e4701c0e7caf39cb9f1a7ad","path":"sentry_flutter.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f6c519cd219bbe493b512e88f6691f63","name":"Support Files","path":"../../../../Pods/Target Support Files/sentry_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9890b2588f7d4f7005fbfcb636532e8266","name":"sentry_flutter","path":"../.symlinks/plugins/sentry_flutter/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fd63b0ee8c393f403cd165102963d89a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources/share_plus/FPPSharePlusPlugin.m","sourceTree":"","type":"file"},{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e988387a65c6b9b6cb60327ffbe6c59158a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources/share_plus/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cb436b045699de5c33251ecb6224be07","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources/share_plus/include/share_plus/FPPSharePlusPlugin.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e988aa5f3177cec22ad24112deb2bd5df88","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989b5ccde2486e01b9f408c52039e15189","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fb60a3fc698011e0520a294091444220","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c0adb9f898335bb561ebd6a92c8b5764","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987c61ae0ab682495e830ebfec4bb2d992","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fec752fcf63dc774ef6492dfb4088824","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98061408887ffc6320474b1b3b9e56e869","name":"share_plus","path":"share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981ca0473827b2355cd2af25d5d7d10627","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984fc5d20b622f4b201b585186291a8f67","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c1ceacb5959c41905f6d4367a79a62d4","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988d04b2310b73a2f1377c4bab4917fbb0","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9858dedb5230d6424239e49823b8eaf146","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a939846af6f55181341850124da10438","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987236a4c37717175b816032b6e16c35db","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eefdcc530f2e92f8f7f62696466d831b","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e999dc81b190f4772b868a75138aa75e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989a1108d6d840a33f47b9c0fc3af3275d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988dae09aab63bdbe088b9c18b957886b4","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cdcb3d9a575e64374dc1a1db82409a54","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988352975c34e4d7ee8941ab48a3068611","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9864da257bade8dccae972a95edd8d4097","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981dc40745a0624f3b853ce39f5bd094cd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98253b8bf66bd8ccf4c977852ef0a7714b","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e981e6f67f07605eeaa639fc9e31c57db8a","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98851d3db594f8658c9786f56478fbb6a5","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/share_plus-10.1.4/ios/share_plus.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98e42d81eea266b46708846d237e6b9514","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986288ba151af560df873b930937f73b07","path":"ResourceBundle-share_plus_privacy-share_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98f12cd395a2605f620e5facab18ef7617","path":"share_plus.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982db5430b3897df3342086c41d0ab0cc2","path":"share_plus-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98875a85a28fcc15b9fc0d81e86a96d256","path":"share_plus-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e15cc77379f3a5d2d644bb59a33c227a","path":"share_plus-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f3d67ef42ee4a797c3b450c5f684db3a","path":"share_plus-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9874f3142bf7e3eb1d60d86ef7b8277153","path":"share_plus.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e986e07cf0736987a294d8f475ef4545d77","path":"share_plus.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a569546e20b60ea744093ab0ba2e1905","name":"Support Files","path":"../../../../Pods/Target Support Files/share_plus","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988982bbfb1ebde9b684d127cb40ed59ae","name":"share_plus","path":"../.symlinks/plugins/share_plus/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98bbefd940fe64ae1bdba547246e327226","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources/shared_preferences_foundation/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e980424689f89b333dcef6b831656c09796","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d76e5d546741e7c1bff558bc1604fa29","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9833fd9e8a667e58c3eab9ad18ac331952","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a2f6b7afaf2afa6bd09f4e0f94068d4d","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fb40156ec424853fa943291f5b303d0f","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b1e3c36a262a1ed5ad3b185a0227eb67","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9831ab42991c30549603d3d8ca536224cf","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98508ae9e3aa5011f9d4942b2f89441d82","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985f334cf968485fb19584ceb692110236","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98138f3662de1a399453cb9534c664235e","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9826b71c8c69e21da06f07c82c6cf362d3","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9828e3c8782ecd686251f0f260cb28a2d7","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9806bae6027bc7a368204df9fc3614abdb","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5fd31bd7e3de1ebf8947d9a8ac4c2ca","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d049289db0016cf5f53369d79d91af15","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98c588eadd95143f0918dce747cd1a1e44","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources/shared_preferences_foundation/messages.g.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985fee1a88b88d89bbd0b0617cbca27ded","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources/shared_preferences_foundation/SharedPreferencesPlugin.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98939dcc4abff9273e20bbabe5b4fd6244","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98642d561628a9b022c32e461ac6978d8d","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c2f343ab24d82d39b3b8df75288becb2","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988e8c1d85da5ff6697e97a0b62ee47e2a","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984be81f35c0b61917ec47fbad62d32af3","name":"shared_preferences_foundation","path":"shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9803adc645aa9991244006b1d25477fdab","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9830ff183ca4264558be2d7888f36dfe2d","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b46089f714cf1702ed485a5fc0b2746c","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dd0a1e90245c099ea267ffdb9fe7727f","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9806173dfed9c5271631519792136dcb97","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9896acf7fc50fc7d46cf4fd58ba1a01a8e","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9877cd8eaf1b44ad950ab2c1de574d545e","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a4a438f84238468ae34a00396733c267","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9826990e339d1e6bfb92c43f2df4021253","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9839d6e5e860b74845e418690de949729d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980437a510b83f078e975ac6e022596220","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985a9c0f13a0e24494a94657ac6f06a09e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f45a8957e29b9d8d573538c7c2a6660e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989bc0f1da02a3a34375b7c9fb790aa6de","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9873814c430069fbcad3cc2b2068a2f2c2","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982a9fea2f1146b6aa8643c58308630eb8","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e987d0661ac6c8236d9bcf05223d8b5321c","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e988319841cde2f015576fce1144b7c103b","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.4/darwin/shared_preferences_foundation.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98538b20e19bcb07bd154b1117ca2e7f62","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98bf7e5ed8e9ee51963dd29079b941aefc","path":"ResourceBundle-shared_preferences_foundation_privacy-shared_preferences_foundation-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98fe484d482b057065dd2bc365ef0b1cbd","path":"shared_preferences_foundation.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987cfa1a168d224a15e60944154b171acd","path":"shared_preferences_foundation-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e9882ef96f96deea78c69f4201bad0a0f36","path":"shared_preferences_foundation-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98321ebbc91006eb23c9f9908d066f0fb5","path":"shared_preferences_foundation-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988ce14ca997c1ee5ad8eed12f49b985ef","path":"shared_preferences_foundation-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9863033d3e663dd173908d510d04d9645f","path":"shared_preferences_foundation.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e983dff215e39548f9188a7b8c8d4c24c32","path":"shared_preferences_foundation.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9843a113f040e59beff6b2bad0d9500e2d","name":"Support Files","path":"../../../../Pods/Target Support Files/shared_preferences_foundation","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9815e66823fa48c84facba1c31c881a7f2","name":"shared_preferences_foundation","path":"../.symlinks/plugins/shared_preferences_foundation/darwin","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e989d8d7f21b58b6c6ea6cdbf54b4310500","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98ff6e5bd448ece42e55969cd7ee2dcd22","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986016d04cf905713b9d5b98ae9bb69fd9","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982743918f80430a5f6e3fb467f2d4e58a","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98db5cfaa6497e017576049d2d766fe43d","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9887c4ce35bf05c88dedb678a8a05f7ddb","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fbfd6a91559f348dcd214ab1cffe04a8","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a41b93d2bbcfa5fa03eef8097eb619a2","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9835810861c4e0c37a8ee85af809b7fae6","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e622054adbb0796c1efd972eb720d1ed","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982499ce39bf8d8ed82a87b79418b6050f","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984cfa4ad175bfd7a9f0e02542ff45ab26","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986069180160f76ffe39b94ecee9268997","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98adeb13adbc566e980f9d252124dcea3f","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9820d6e1cf31e5551a3d68c121e504d8d1","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985f4b97fdfbc2f7c5f93d9c36aaac6a3a","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d7c1e03fcf1c567ec11990def452273d","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteCursor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9853d850559207c4384f4df37b5e85a414","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteCursor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aba81c10b979d23a5281707bb944aebd","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabase.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9868b319ba9a799ba0a2bc5271ebda13ef","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabase.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ca5fc8019efea2c0a2df87621e6bfd20","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseAdditions.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98eaab2d07055d05ca402daaf728b22722","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseAdditions.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f164259be96c4b6bb6608f7ce49cf3de","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseQueue.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985ad6eba05b61ecb94890b1ef3012502a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDatabaseQueue.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982b2273f60e8670b2f5db571354e1ea07","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinDB.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98463be45d0e22ba2b4d01b4ebe090555a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinImport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9872cbb441a98d645c1ab53aa8198b5a91","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinResultSet.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980a984e700108dd111790049ae08ac1e0","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDarwinResultSet.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984fee7f0226744b7cccabb95aa1a2a58d","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDatabase.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9813f225fcdcd4540ad3a526d2488cee7d","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteDatabase.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987dca200d5c95cb57fe2d64a729f10e59","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteImport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9870809aee28a31c0ce2cfe6427a7a84ca","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a82a52d3958fd262c4d9754ada0841a1","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqfliteOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98174fc872150c14b5ae3ef8ed6f674bac","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqflitePlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fe7eed89bf2c775aa63c02aebf8e6b33","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/SqflitePlugin.m","sourceTree":"","type":"file"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c822bb9bb66315e186526fd41e667547","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/include/sqflite_darwin/SqfliteImportPublic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d179bdefff96b31664c33767a93f03d1","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources/sqflite_darwin/include/sqflite_darwin/SqflitePluginPublic.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9892e5814da7ab09018c72d77ff35a15cf","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a8b633bfa4915a0040bf5c00fcd27ba1","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b7b37f7cc0762d9ca0708a155f22b8eb","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989eba8369512e5541562107b2d8b760a0","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e33f20f0d475d4d5413f81e2e96ca687","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987eef1cebbe3bb46644378bc4631fe02d","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ed219e0fa4be3681e4e695b0532eae32","name":"sqflite_darwin","path":"sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988972fd127184a48f0dc3e6aee302404e","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9877fca5587d318fba37442797f6f705b8","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983ae2fa1946bb36f5e91787fb46d589c5","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984f9f6b5fd902d714436e433e3f764dd9","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dd7c7199347a79274529e6303ba01ef7","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ca3a121f05226c04281b755cc833b917","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9893f425a7aea6b4482e64d0a5bc657629","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e49b35d1a40e4dde1b2357ef1cb8d669","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987e5e76ea8d51c8b8d79c16a34746f33e","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9854e643717fa41262edb3c3f1d725d61a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ed6f9da6b7cdf2cb6886e30e6cdfbc8c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98de4bc8577c1a9bea6d84ede9bedadcbb","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987d9613248b511bc9325f7bcd3f3e4dd2","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988f60c32fce1cfc81f7648e0319f50cb0","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9897a924c3a55a4bbf49fa6473d66861c6","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c43b6316619d3de45f006103a22d7931","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e981d8665643615515f27809c8e1ce6963e","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/LICENSE","sourceTree":"","type":"file"},{"fileType":"net.daringfireball.markdown","guid":"bfdfe7dc352907fc980b868725387e9852fdd6f48c5deae81f51387413e1e002","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/README.md","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98856a830972b607e532cd4373e4bd4903","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/darwin/sqflite_darwin.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fe65523c54c78b02ecd1fb77f3e46537","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f79a082bb47738f827607307a0e2452a","path":"ResourceBundle-sqflite_darwin_privacy-sqflite_darwin-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98c804c2a12db2f8031b9549edefec60e1","path":"sqflite_darwin.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987dd296579fd63723f2385fc3e309a485","path":"sqflite_darwin-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98dd8f3f1e4afb05338b44768a2eea883a","path":"sqflite_darwin-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9839943824b9f6edc4e040c5c8b2151f82","path":"sqflite_darwin-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9867c38360f7a8c57f89677b1b8dd0f63b","path":"sqflite_darwin-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e983c5c591d1e2590f943bcf841c7250c29","path":"sqflite_darwin.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ecc2b7f452e75bd86ca68cc787ffd0e6","path":"sqflite_darwin.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9863d40e6f29652cdc2796922cd0db2932","name":"Support Files","path":"../../../../Pods/Target Support Files/sqflite_darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984e01895d459ef3578b9180bd22234ef2","name":"sqflite_darwin","path":"../.symlinks/plugins/sqflite_darwin/darwin","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ddf54a4994459d8d533e977b9d3701b4","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios/Classes/SuperNativeExtensionsPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98227f3bf9a2096f16c498e39e527dfd82","path":"../../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios/Classes/SuperNativeExtensionsPlugin.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9812ef15f301a3279be9158ecc5e2ee578","name":"Classes","path":"Classes","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9821ac568b46c2e99c638c4468b42476d3","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98288b3abd52cc5524befa714a293f1d78","name":"super_native_extensions","path":"super_native_extensions","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9847c7f918c61997a2cee2375876483e92","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98038dcce1516b7703d28d4d5925b0e167","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e27492f09959f12010d60e1537d0abd4","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989b82d7b34f027e37120d28d733c9bc63","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984e2d0c4c003b18b59d642ab053cd2e85","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98dac882f2bd6878262999058971045bb1","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98906dd99ca335afe3dd9a7c6cc05a2048","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9895327431cbeda356cd4093b0b03dde53","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98b3779ad14c3af16a3d17c6cb4a8af745","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98413ff013f5792a21699c27aa303d4851","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98503a720642223ab224d97e9c5f9bbe9d","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9823726b2431178718aa367a59ee6a520c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f023da7cab226ce319043a4e226d36dd","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e550084af2ca7acaf14461ed1f151852","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e988cb8e2ab455bb20dc19f7a3a2246df30","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9896d5871a48fd625e7d723f390e6a98b6","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/super_native_extensions-0.8.24/ios/super_native_extensions.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98eeb382127790a1793c33c9b12ba2e097","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98a18f45e0ed141c453cff3a3c4527ecbd","path":"super_native_extensions.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985c10bd2540461e46ce7f11028e018d75","path":"super_native_extensions-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98fb6de20b66e6b8dbd451f64bf906989c","path":"super_native_extensions-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fdcda392f627466e5edccd1508970413","path":"super_native_extensions-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c698b70fb576aa4fe26ace0b9b8afcdd","path":"super_native_extensions-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ecc61835f7006374d2df8ec0b73025d7","path":"super_native_extensions.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98960e413e1bdfa694f48dc871e7ef827a","path":"super_native_extensions.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f219566d0916d6a77685c33cd16653c8","name":"Support Files","path":"../../../../Pods/Target Support Files/super_native_extensions","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f569f35312eec5d67d042173116f99c8","name":"super_native_extensions","path":"../.symlinks/plugins/super_native_extensions/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e988380eb098928f2df260922e9d961ad9b","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98247df5322ea5db3a84214881c52ee25d","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9887c8fcbc6272382fe1137437a8cfd2aa","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987ab2c88ca66fa6efb7003fcd0a5fecbd","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98aca3ef13270c3be3fdb24dff41e0219a","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988826612973646a4053217e2324d0644f","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980d552426f9b9a7580f9c3386dc024b81","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987cd73363ca2db27832a5adec82341972","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fefce717b106b030e9dc126dd095e251","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9899ed1fd19213f07006bd2b9430d7772b","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9863db1d0c1fc2b855158b6fa51dde21fd","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988e2efb7aff436f31626c71ead15daeef","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c34f07f7b163887aa57af2d6c5172187","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f1d44c68f08f6c906df9811d7caf2ef3","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9832553b1b2fc5c507feabcb35c959e868","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984fb77cebbaaabf4e807dd6ebc12b73f9","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98490bb9f80fa5964176076d4f1e6394fa","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/Launcher.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98daf626000fc0d232b897ba1dda9c0f31","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/messages.g.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985162a45777def5ce41cdd4f46ff104a9","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/URLLauncherPlugin.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e983b3ce4248a360b7aac31c20aa78daf2e","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources/url_launcher_ios/URLLaunchSession.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987ea293b0366abc71ebb96acf17297ef7","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9888d93807e2dc1f667f941643f134e44c","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981798185d018f93fe0298b8aba5d63560","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981019612be8bea02a74431d38ce3b9cbd","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880eb5ac131bdc80edb26f033f8a72fce","name":"url_launcher_ios","path":"url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9851161dc629e1fb4d2d3fd8bd10ee4ca2","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989fc545117a2c4837792115a214e84a3e","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98cae5b473355e5f3f56b441bc383d1a87","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98025d2d7ef7d681723bfdf777175c2ffd","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9834b9faca7fd3324a1c2164a4cf7e5313","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988303e4a8f1f82c7fb4ea2702bb5cb5dd","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984516cc538c760100f78348eb10326053","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98732e82463b828bce4818d57c1ea975ab","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880fbcdef07ddfda2f3153be29f969c0f","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981e967b61253c26da24779f0e1571787a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983de9bb36b851937d715c96cb7d26dc7a","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98611614762b8213684ec434869762ea22","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988cb0470925262a8de3be403c8ae31ff0","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9833562037aa4c75db5121f347c9e53091","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e70d73185437e27f145504e3dc9f1033","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98ed5927b53a4d1ccd1f9684043eea4526","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98a5500bec173c13a85e52bd5fff791d8f","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e9856b8b14381dcf0263199d3e9429dbbba","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.2/ios/url_launcher_ios.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98cceb9263cc3f6dd5b9212b964cfa7054","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98604688695bcf046b591ad88eaebd26fc","path":"ResourceBundle-url_launcher_ios_privacy-url_launcher_ios-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e987be471ca7fbb33b50a518eea5dcb87e8","path":"url_launcher_ios.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9871fa081d7f580f4936897eb46aae0878","path":"url_launcher_ios-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986f18d2a83f59c045caefbc68524cb3d3","path":"url_launcher_ios-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ad745689d7ee76ea50404b77fa12b6f0","path":"url_launcher_ios-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eec495e5daf9423acf950402315f3522","path":"url_launcher_ios-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98b3b3d080e11ff54026ba894393f62b3a","path":"url_launcher_ios.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e980bab71536c4bda286b310a48eedac214","path":"url_launcher_ios.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98938e093d7e3b952e819ce9ac1448e73b","name":"Support Files","path":"../../../../Pods/Target Support Files/url_launcher_ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9833186a359a29a050b940ca2dbdf952b9","name":"url_launcher_ios","path":"../.symlinks/plugins/url_launcher_ios/ios","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98273c41ad57101f59fb22ee04a3de8ec9","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9877cdee8b856454ff8e4213d5b4b8e81e","name":"Resources","path":"Resources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982e8fed289e7a93127c4e71b0a59be8c1","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989c9e91c05d6fcd60cecbf709fcfae9f8","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e5bb761b833aa1c82ed6c79ade71f8d2","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f9d8e7c800ff72bd4f22b25217d72bd2","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98c7188e90eaef7902fa09bbd3b6c34138","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980d69590ab194d9defd371868c962c163","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987e1eeba79f29d46cbd63b8f82bc2f113","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982e575334bd02bdf114450e05cddb31e6","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987403e6bff419acd09288ab9193a47d46","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983974551ef8940d0a77ce7e5dba345c15","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984bffff6dbf90752f811d1b628435611f","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980016f1720faf508de4c79265c93531f1","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985409f0ee94bca73500af103a1f36ec69","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98863ea06f124e08ce56a9dc6093f14a5e","name":"..","path":".","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"children":[{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9898207ddf6fb048e55cbc82f1767304ad","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FLTWebViewFlutterPlugin.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c78e7356e2edc959fdb6dcf759d9ea32","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFDataConverters.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984555345bd3de9a732d0775aeaa250a8a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFGeneratedWebKitApis.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98dbff9619978e59ca5bd2bd9b78df76e8","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFHTTPCookieStoreHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983ed9e6db451958c4b973973bda4af232","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFInstanceManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9897e653c33b1ca27c845f24161d5df94b","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFNavigationDelegateHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d512e4282ea1aaea1f31ec22635e6a73","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFObjectHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9863ec51aa8f06ccdd336b2b840124b5bd","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFPreferencesHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9852d8f233325638788b9c883bef4c434e","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFScriptMessageHandlerHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9839386e23f7ec2e92ef671f9a65fe9467","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFScrollViewDelegateHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98554327c6feb943c123568d30776d18b5","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFScrollViewHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988abd8337fa8780181224b1c59e11080a","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFUIDelegateHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98bbd5fcab06bd2b4eb52aea88b514bbf5","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFUIViewHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d8f2fa4ff0132b374c1eca3de40fe4ce","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLAuthenticationChallengeHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cdfda639596827302a7f54cea81404f7","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLCredentialHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982d6b13259c9952197ed43ec8ae880592","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984f11e46b828b92865af10c3b4c4c9d68","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFURLProtectionSpaceHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984b54bebd3894ad50c5db03675b718387","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFUserContentControllerHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e0160a9358f35f9d1c0b5a60a3d0f682","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebsiteDataStoreHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6eb829538253c8fd04f8ad89b11abf3","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebViewConfigurationHostApi.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a1bb5ceebb3ce6eafef856cbadc7f5d2","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebViewFlutterWKWebViewExternalAPI.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a83be649d9b3f6edd971f560f1bdc837","path":"../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/FWFWebViewHostApi.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98dfb923094c09a8b2984e29f4ed12750a","path":"../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview-umbrella.h","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9854b0c1f62864b38122a6cd2adf415fc8","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FLTWebViewFlutterPlugin.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98dba23cc94d8fa0e09dc855fe286cf011","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFDataConverters.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba40413987e8fe9a668fab4d2f62e968","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFGeneratedWebKitApis.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b41a55f1ff6a49c7cb1bfc6754b0048a","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFHTTPCookieStoreHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aa71e1ea8eea7861996ce4eea6fa83c7","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFInstanceManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982f56450de8c5ce26b62883f42bd50aff","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFInstanceManager_Test.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bdc5d2d37bbc00acdd9d5e37557c61aa","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFNavigationDelegateHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9876af14ebe27607bf4d780619a0507e5a","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFObjectHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9812c7ccf1eea1c9c80d378418ff91f3ef","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFPreferencesHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ad06b47c00b9bddb782d0454f9545c23","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFScriptMessageHandlerHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989c04773599c925e49e8e9df79338c2cb","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFScrollViewDelegateHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982c3bc52c27128b5552e2f492ee8e9d38","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFScrollViewHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983638c2d3e148c4aff7f56dae69d5bd44","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFUIDelegateHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9852648a53c3238ae232bc1cf732368733","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFUIViewHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983ac2710f7f8419957a975656f4f11010","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLAuthenticationChallengeHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f29772b4f240416b82bfca4c7240cc52","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLCredentialHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98287942d628c319dfb58242b7a056e501","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984b2e4dc50da302b519989286271f986f","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFURLProtectionSpaceHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da68ab6d947fd76c2248fff2397f9474","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFUserContentControllerHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98db1f999a296f5d4b787f4f9b4b82b565","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebsiteDataStoreHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98635d8b53208d8a44fe1b27f33c27ad74","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebViewConfigurationHostApi.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98de08c86939fbb40b81087f28b271e5b5","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebViewFlutterWKWebViewExternalAPI.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98984ca0249b9df8b5209a6dcb1c869dfc","path":"../../../../../../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/webview_flutter_wkwebview/FWFWebViewHostApi.h","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d786586788316e81a204e06a860d9b2d","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986d305bdf63057a110d9a04a946a6a0b3","name":"include","path":"include","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e985af659c04f6cdf4a7c73cb8559d95a55","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98e1e70587e6aba44c0bf6b4fa49bed627","name":"Sources","path":"Sources","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e983b00ae6fdbc30a34dfacc53e5e678acb","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9865432e0f8cd5ef04acb4722323bfaef7","name":"darwin","path":"darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981fe60fe58df7074f3f027dfb308c785b","name":"webview_flutter_wkwebview","path":"webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98299b41c4840e81d29fd9defe63f562c1","name":"plugins","path":"plugins","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98a1a72cddbe31b7904461fb30eabe3f41","name":".symlinks","path":".symlinks","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9866cb0dd0dbb6d8ae7c42bf7e70385636","name":"ios","path":"ios","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982c9395928d2b28bc4832e7eaf6338061","name":"appflowy_flutter","path":"appflowy_flutter","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981cf382f136f655ee4ef53b31836e9976","name":"frontend","path":"frontend","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982695540da12cb3688e9cd89de55b4c12","name":"AppFlowy","path":"AppFlowy","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989cea8ab7b2bc9aafea94b071fcb36fe9","name":"samples","path":"samples","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987cb1234016d9a45a8e6ebfa24a1ca6b6","name":"dev","path":"dev","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e981ba95ad9f21e528ba007f114552e8faa","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989c9004908f380362c00015c90fe116d5","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fe5984c239273e4752feee9c1cd0d417","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98eb7ac77a35fceda15f40d2e6dfe455e1","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98481664ecd25a98fbf478061e4214182c","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984d34857cedf24e0ffba417d4ddb7a5a7","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98f104ab4138053278b0c742e5da3a37b6","name":"..","path":"..","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986ba8636d82c00ef31adc8e2a02c78383","name":"..","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98e4b951171365bdf1469e58e30bb095be","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview/Sources/webview_flutter_wkwebview/include/FlutterWebView.modulemap","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e98f15216db275bd0a141b390a80247ad56","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/LICENSE","sourceTree":"","type":"file"},{"fileType":"text.script.ruby","guid":"bfdfe7dc352907fc980b868725387e98319f1ecfeb379cf849cfb0c9a15fbdf7","path":"../../../../../../../../../../.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.17.0/darwin/webview_flutter_wkwebview.podspec","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98d1afcef86fa9f9ff1253aefecce2db52","name":"Pod","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e980dc040649dc9e2eab53c3da3c40c35b0","path":"ResourceBundle-webview_flutter_wkwebview_privacy-webview_flutter_wkwebview-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989d9a8732910c4132ba5e9ee6c49a6fd0","path":"webview_flutter_wkwebview.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985debbc2cab2d792fa92242781697aa86","path":"webview_flutter_wkwebview-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f10ce8a5ece3a15020d640ce3ed6466e","path":"webview_flutter_wkwebview-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9818b0186c11c004e985a36edb09183861","path":"webview_flutter_wkwebview-prefix.pch","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98e3c8f3c679654f0d8322c0bfe86cb48c","path":"webview_flutter_wkwebview.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98de306c3f6c2c14f312be69bcd973767d","path":"webview_flutter_wkwebview.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98fb0231873e76e3fdd25c08c4f7142e58","name":"Support Files","path":"../../../../Pods/Target Support Files/webview_flutter_wkwebview","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98daffe5a2e168f85d5660f57f2cc4f3d1","name":"webview_flutter_wkwebview","path":"../.symlinks/plugins/webview_flutter_wkwebview/darwin","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e9880465010b585dd5bc5b7af0a27d58067","name":"Development Pods","path":"","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e984bcd4feee9e1dfb73f639f9bac23b2e2","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/AVFoundation.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e987724d547599f4408ee3b3d56fa903a20","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/AVKit.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e98c18474134a48ed556f51c824bfca3246","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/CoreTelephony.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e9885770c7fb25aa0db0cfd09c5443f9797","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e9802c3ee0032b21aafbeccc5b7db734fb2","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/ImageIO.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e984497b71acede5531fd260c9ac8b3d9e1","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Photos.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e98d671fdb5a7bd1c3267d3e6f35907cbca","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/QuartzCore.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e9872b55968a8ae9b74a90d6bfa9882dd5a","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/SystemConfiguration.framework","sourceTree":"DEVELOPER_DIR","type":"file"},{"fileType":"wrapper.framework","guid":"bfdfe7dc352907fc980b868725387e98d159ebe55fadf57df5691badabf215cd","path":"Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/UIKit.framework","sourceTree":"DEVELOPER_DIR","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9827102c11d594e53de5adfd4435cd953d","name":"iOS","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986f57df5597ed36b645cb934c885be56d","name":"Frameworks","path":"","sourceTree":"","type":"group"},{"children":[{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e980f256812a9858de27fb60bd1accdd32c","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupCellItemProtocol.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9837ba02f2eab7de1730b72c879d53f332","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailBaseCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981791c992ccf9564106a690e1d96420b8","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailCameraCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981149a1d26875e8b68800278ef1ef0dd2","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailImageCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98c8875336f053558b1f9ed7c43e262ddd","path":"Sources/DKImagePickerController/View/DKAssetGroupDetailVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98164873936b2ccd15bdc608cc60c2cc65","path":"Sources/DKImagePickerController/View/Cell/DKAssetGroupDetailVideoCell.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a7cecd4700762c7a109513bfb52989bb","path":"Sources/DKImagePickerController/View/DKAssetGroupGridLayout.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989c224ca64eb47c09a922a3d927cf7f10","path":"Sources/DKImagePickerController/View/DKAssetGroupListVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986c20dce043f12383acfeb98f96470e02","path":"Sources/DKImagePickerController/DKImageAssetExporter.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9855672fea159780d4dd23a1f5a30a7837","path":"Sources/DKImagePickerController/DKImageExtensionController.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98261f313efe36b40a087a9d35206c9607","path":"Sources/DKImagePickerController/DKImagePickerController.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982975067e23a41db0b8f6979b4a4a3b55","path":"Sources/DKImagePickerController/DKImagePickerControllerBaseUIDelegate.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d331501c9a3bbd1cec21da6d1bed6a79","path":"Sources/DKImagePickerController/View/DKPermissionView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9851006020f4c7fc277370bdaceee539fd","path":"Sources/DKImagePickerController/DKPopoverViewController.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98f1abbceda566cfe605dbb72a81ac8f8e","name":"Core","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e10cad38a7b8b791b5523321d2112ef0","path":"Sources/DKImageDataManager/Model/DKAsset.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985cdfbee7f0fa63af790094c590a82f83","path":"Sources/DKImageDataManager/Model/DKAsset+Export.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982173071859389679a6c45171b90987da","path":"Sources/DKImageDataManager/Model/DKAsset+Fetch.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a89a84fdf4f961d13d26db8520129232","path":"Sources/DKImageDataManager/Model/DKAssetGroup.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e418cf226491ab81be949451bec00d12","path":"Sources/DKImageDataManager/DKImageBaseManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9878488e740dd474df94f5eec1f4ebdef4","path":"Sources/DKImageDataManager/DKImageDataManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e01ac6181d083bc327440f955529b52b","path":"Sources/DKImageDataManager/DKImageGroupDataManager.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e981e366accc57fcc9cc1a70ecd199c50e7","name":"ImageDataManager","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982a05c3bf490f4a4639be6133ce46fc50","path":"Sources/Extensions/DKImageExtensionGallery.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9878b271eaafac7e3a83dd4dcb67d7b63e","name":"PhotoGallery","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9892de1f95603b3309408b855b150222e8","path":"Sources/DKImagePickerController/Resource/DKImagePickerControllerResource.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e982cb145326ee0fe9b023de780deed78c8","path":"Sources/DKImagePickerController/Resource/Resources/ar.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98aa1b813ba9ab60abaff79984557a95ae","path":"Sources/DKImagePickerController/Resource/Resources/Base.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e980c9106c6deeaa489bece52872ddc8302","path":"Sources/DKImagePickerController/Resource/Resources/da.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98638a8ead3c4921071ce058b31e0789b1","path":"Sources/DKImagePickerController/Resource/Resources/de.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e9808c6c81d6544958ee5022314e9257bea","path":"Sources/DKImagePickerController/Resource/Resources/en.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e989d1190414344f8164f52c639a0b3923e","path":"Sources/DKImagePickerController/Resource/Resources/es.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98a3612de5bfe6103968b0ad0e24321c06","path":"Sources/DKImagePickerController/Resource/Resources/fr.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e9840393198352d2a493eca64a5e8366781","path":"Sources/DKImagePickerController/Resource/Resources/hu.lproj","sourceTree":"","type":"file"},{"fileType":"folder.assetcatalog","guid":"bfdfe7dc352907fc980b868725387e9866b7b2a063b357116fff6128faab6010","path":"Sources/DKImagePickerController/Resource/Resources/Images.xcassets","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98389b54d175b1ebff57cec1e50f863fcc","path":"Sources/DKImagePickerController/Resource/Resources/it.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98653c7ebf4d1cd10720ef59b098ac5602","path":"Sources/DKImagePickerController/Resource/Resources/ja.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98104b68b4ff0a25a5296d0b83fcc56453","path":"Sources/DKImagePickerController/Resource/Resources/ko.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98d31edcc28dfbbe3d8ce637b5afc30729","path":"Sources/DKImagePickerController/Resource/Resources/nb-NO.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e984482abef551eb7f8c2328ba24695640c","path":"Sources/DKImagePickerController/Resource/Resources/nl.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e987d2482c68c58caea8c964ddc88375bce","path":"Sources/DKImagePickerController/Resource/Resources/pt_BR.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98e82421c9939f56bd2bac60c90c0972a4","path":"Sources/DKImagePickerController/Resource/Resources/ru.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98641e96c7b9979000ba15756d8c4088c5","path":"Sources/DKImagePickerController/Resource/Resources/tr.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e987dbf20be783e65771c0b6b10146e0526","path":"Sources/DKImagePickerController/Resource/Resources/ur.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98496e8767ebc06f02f3a190467b76c669","path":"Sources/DKImagePickerController/Resource/Resources/vi.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e9851d4a9e151fbaba3b43b0c1c328e7692","path":"Sources/DKImagePickerController/Resource/Resources/zh-Hans.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e989af334eda3f4b74b226c99b858e3a8ee","path":"Sources/DKImagePickerController/Resource/Resources/zh-Hant.lproj","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9804dfbef9d9e61df9bb559e2872b5da24","name":"Resources","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d02568085c3a0b85378f96f4eb289f1a","name":"Resource","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e985e388c776c1b334623438d45d3541462","path":"DKImagePickerController.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982407d1e8008282cbe7def1a4ffcc63a5","path":"DKImagePickerController-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98ea7538dde57b4e0f1e4dd4e75d1c0d0c","path":"DKImagePickerController-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b27014ca8a98af66a8fbde29e88273ac","path":"DKImagePickerController-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989c1f453bb5d9dee5b1b66f4e8eb4eb4d","path":"DKImagePickerController-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9806dc5717c6a31d76aad6bd6ece1d0e8a","path":"DKImagePickerController.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e982c698840c90027623e3da75a6ca7c080","path":"DKImagePickerController.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987e62c3ecefb84e0040e723e52f3a31b8","path":"ResourceBundle-DKImagePickerController-DKImagePickerController-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e983adef97a715cd560344faddd2136ca7c","name":"Support Files","path":"../Target Support Files/DKImagePickerController","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986a64e2ff11892e14ae7fcb90015c6a37","name":"DKImagePickerController","path":"DKImagePickerController","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9863e802284c73bd79713e7ebbd9884a03","path":"DKPhotoGallery/DKPhotoGallery.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e980341a413639d3324d70cd433b477ad56","path":"DKPhotoGallery/DKPhotoGalleryContentVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989de872349f4afb6a3ec10554d20a0824","path":"DKPhotoGallery/Transition/DKPhotoGalleryInteractiveTransition.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9891931bbad7bcd3c15555c1ab3b5e9eda","path":"DKPhotoGallery/DKPhotoGalleryScrollView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989304b9d8408d53cfd4dc626445b7aa78","path":"DKPhotoGallery/Transition/DKPhotoGalleryTransitionController.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9851ce307e43c7abf9a92db60b62d92eed","path":"DKPhotoGallery/Transition/DKPhotoGalleryTransitionDismiss.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98bb524791f17b91cf8d8e69a04b9d6241","path":"DKPhotoGallery/Transition/DKPhotoGalleryTransitionPresent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9817d140dcad4deb3eb9494e9b79f6a54f","path":"DKPhotoGallery/DKPhotoIncrementalIndicator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b08fe598889329654b388a853ab11345","path":"DKPhotoGallery/DKPhotoPreviewFactory.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98abec4317337fcb2bdb4f60b9acc75558","name":"Core","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981fe52c027e9b931957c809c3731fe5e8","path":"DKPhotoGallery/DKPhotoGalleryItem.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98790064f3a8ebed4c57f29d168a40864a","name":"Model","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98c8c44b9bad588a8cbce8ae16059cc044","path":"DKPhotoGallery/Preview/PDFPreview/DKPDFView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989def67d6c082310eb82979d6e6048439","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoBaseImagePreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e64926604b2aa420c550979dce302c11","path":"DKPhotoGallery/Preview/DKPhotoBasePreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985f43f00297e493a6c1ff3f01846dd6ab","path":"DKPhotoGallery/Preview/DKPhotoContentAnimationView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982b1b24b667eeb0aa9df1641d1afbf02f","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImageDownloader.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b7c4392f573762b73d261ae2c1ad25f5","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImagePreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98036bb4411bb8d063d059db4e041547ed","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImageUtility.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b73272e18580fd7065bff4ab280cf9d1","path":"DKPhotoGallery/Preview/ImagePreview/DKPhotoImageView.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b16bf7005681538906dcee4437e1937d","path":"DKPhotoGallery/Preview/PDFPreview/DKPhotoPDFPreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989a757ab8e8a9fc630d1a57794cb67b77","path":"DKPhotoGallery/Preview/PlayerPreview/DKPhotoPlayerPreviewVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988a849dc79b3d29aaf31294b33a51b25b","path":"DKPhotoGallery/Preview/DKPhotoProgressIndicator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a03d72595a015012babd53f62ce9fe7b","path":"DKPhotoGallery/Preview/DKPhotoProgressIndicatorProtocol.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9895fb6b2018a434a37a846c5e29fe4c20","path":"DKPhotoGallery/Preview/QRCode/DKPhotoQRCodeResultVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9806b510aa1c18127530918269f3b10ecd","path":"DKPhotoGallery/Preview/QRCode/DKPhotoWebVC.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988b5a7957a4c4d63e718fe2f2bfa1a142","path":"DKPhotoGallery/Preview/PlayerPreview/DKPlayerView.swift","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98a4f4a3f314a53e8278137c97d48a2a7d","name":"Preview","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982c920e4b6a548594014fc19d89752af0","path":"DKPhotoGallery/Resource/DKPhotoGalleryResource.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98322362f55daedd42cf2628aab1e7ee5a","path":"DKPhotoGallery/Resource/Resources/Base.lproj","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e98257fc87c20dddd559c3a65cb29ef2a8c","path":"DKPhotoGallery/Resource/Resources/en.lproj","sourceTree":"","type":"file"},{"fileType":"folder.assetcatalog","guid":"bfdfe7dc352907fc980b868725387e98632a77eb6e8a5c194970ffbb837e3163","path":"DKPhotoGallery/Resource/Resources/Images.xcassets","sourceTree":"","type":"file"},{"fileType":"folder","guid":"bfdfe7dc352907fc980b868725387e981cdbb40ea77c1fcafa54f43ddd5ead0e","path":"DKPhotoGallery/Resource/Resources/zh-Hans.lproj","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9833f8b21cf1e493b32aa79c353bb36c59","name":"Resources","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e987e4621e932260350f9fe18d776062789","name":"Resource","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98669eec05ea75fd44fb0e2666721dbfac","path":"DKPhotoGallery.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986ea1aeb64b4ee3b54e278e3a7146573c","path":"DKPhotoGallery-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e984490f039b0eb3468d94b0ab2e6a714c8","path":"DKPhotoGallery-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9827c39759d14ae68710c6e6f3caaa10e9","path":"DKPhotoGallery-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986024dcc5015a387313d8adc4bce7b36a","path":"DKPhotoGallery-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98b1d81c0ffe868a64db997ed9da144cf0","path":"DKPhotoGallery.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e988204bc98e0d67983074132a4f5665a6b","path":"DKPhotoGallery.release.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987ab489cc4f30435cd7ded82f0c6eae68","path":"ResourceBundle-DKPhotoGallery-DKPhotoGallery-Info.plist","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987ec59054c088e47e413206915ef9028f","name":"Support Files","path":"../Target Support Files/DKPhotoGallery","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e986c674d50d3b10e235f4f35f7a35d5ae8","name":"DKPhotoGallery","path":"DKPhotoGallery","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9896448ec1f9cbf14857b3fe6b95caa011","path":"Sources/Reachability.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989bacf7eba26d0bb486de3cbfe5ea13c2","path":"ReachabilitySwift.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b6244bc1a6a40e6115703cb3d00b8446","path":"ReachabilitySwift-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987b7b6b5547766ec62d38737cd24e68cd","path":"ReachabilitySwift-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98547e39e19bf28c52f14233768cc21bd9","path":"ReachabilitySwift-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988aeea5b451523377c4d21395c5903a7c","path":"ReachabilitySwift-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98901baae58940cfd27e962f9d5011362f","path":"ReachabilitySwift.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e989a874d2419abc53ad8b0cd3d2a5318ae","path":"ReachabilitySwift.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9896d1894bae44836917da92d3aab54f93","name":"Support Files","path":"../Target Support Files/ReachabilitySwift","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98bebab75b96c37c53dc67122f6031b002","name":"ReachabilitySwift","path":"ReachabilitySwift","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cbbfeea198fc4bce239c47fc57d8a961","path":"SDWebImage/Private/NSBezierPath+SDRoundedCorners.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a4610931d9a303cf4a5413361d1ada97","path":"SDWebImage/Private/NSBezierPath+SDRoundedCorners.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980e908f1ceaaa33a36d5403f550e8b9aa","path":"SDWebImage/Core/NSButton+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9817f5ea7520f6058c0504b71e2865c594","path":"SDWebImage/Core/NSButton+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984f06f7f319f35e24d3947bb38a46b4b9","path":"SDWebImage/Core/NSData+ImageContentType.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987b53a8893f5f3cdd6faadb0d3bfc4d62","path":"SDWebImage/Core/NSData+ImageContentType.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9890edfb9455d8d050d856d19de294b76c","path":"SDWebImage/Core/NSImage+Compatibility.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987853d28d16fa30841c1a55b61c447d01","path":"SDWebImage/Core/NSImage+Compatibility.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c8d140d68f858c8de679df2d49e39ce1","path":"SDWebImage/Core/SDAnimatedImage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b0ebe90ca974f5cad05396d5c0c407cc","path":"SDWebImage/Core/SDAnimatedImage.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9809df879cdb20a2d85e42e0ec9467d874","path":"SDWebImage/Core/SDAnimatedImagePlayer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e2ed6b92aae530671fb6a85f4968036a","path":"SDWebImage/Core/SDAnimatedImagePlayer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f116af502c254813038fb0fa9b6d7e43","path":"SDWebImage/Core/SDAnimatedImageRep.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987a40b9365faa88cb247c44544207a3ce","path":"SDWebImage/Core/SDAnimatedImageRep.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985026f7f77e36035ebbdce01168f1c703","path":"SDWebImage/Core/SDAnimatedImageView.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d1c51ae1c8f49cab13fbed609b95490d","path":"SDWebImage/Core/SDAnimatedImageView.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9853c881de2f36eb982e91b6111edd2159","path":"SDWebImage/Core/SDAnimatedImageView+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98229f3dff826db691d58cbf3df3ae4b2c","path":"SDWebImage/Core/SDAnimatedImageView+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a7fb3d4eb5f455ce911e0e84b77ee5df","path":"SDWebImage/Private/SDAssociatedObject.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9889c0b922f6710e9bbaad47d90e458796","path":"SDWebImage/Private/SDAssociatedObject.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9870a50afed1d5bb331637a22e1b2cdab6","path":"SDWebImage/Private/SDAsyncBlockOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989a88b4352ddd2268b71af2cececb468f","path":"SDWebImage/Private/SDAsyncBlockOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9894060044116c365175a4d8c9d018b47b","path":"SDWebImage/Private/SDDeviceHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98379407cdd5ec5f0842afe1dfb96cddaf","path":"SDWebImage/Private/SDDeviceHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9820bbf68e6d4b7e185246c728464b69b4","path":"SDWebImage/Core/SDDiskCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98556c8c3201769e199eb76c9fc9d5a0de","path":"SDWebImage/Core/SDDiskCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9822703fb114a75f5c5f2f254bb3f6484c","path":"SDWebImage/Private/SDDisplayLink.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982e5995722797bf81f987a7b34e8d29db","path":"SDWebImage/Private/SDDisplayLink.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98688ef71815752ca7f6167d44ae846d81","path":"SDWebImage/Private/SDFileAttributeHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4442f69c6514bc98fae925092574979","path":"SDWebImage/Private/SDFileAttributeHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cc8572110877f8beb99b8973afcea556","path":"SDWebImage/Core/SDGraphicsImageRenderer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d990503ee20a63ae3a6ac274eb15e500","path":"SDWebImage/Core/SDGraphicsImageRenderer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a81bf725f34f12d4633981ff1a9be615","path":"SDWebImage/Core/SDImageAPNGCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a93d56dd2776df0d9323eaf235ac4573","path":"SDWebImage/Core/SDImageAPNGCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98063a7a83ef11a2830cff9aef0f246a46","path":"SDWebImage/Private/SDImageAssetManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9878a035a3947c0bfb2c74de160efc90f8","path":"SDWebImage/Private/SDImageAssetManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba45c50b7e93c71a1094293aeb130683","path":"SDWebImage/Core/SDImageAWebPCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988b1062c96ec96331da01cae06e95e9bb","path":"SDWebImage/Core/SDImageAWebPCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98adff88ced90105be82ec5c254de38c39","path":"SDWebImage/Core/SDImageCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b59fb4d17416bbf73623c9ca148d4781","path":"SDWebImage/Core/SDImageCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da4185ba0b8e0c270b9045f1c0ea7f34","path":"SDWebImage/Core/SDImageCacheConfig.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f690a5caa9ac98baa8bfaca0d8119a97","path":"SDWebImage/Core/SDImageCacheConfig.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984fc8806592e30baa20155c119711a319","path":"SDWebImage/Core/SDImageCacheDefine.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aa465b740f26e7c857d665d9e948f68f","path":"SDWebImage/Core/SDImageCacheDefine.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98025e98798e009017d38c6b8f9f98dd3b","path":"SDWebImage/Core/SDImageCachesManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989babcbb0bec790231c9ea03d45485895","path":"SDWebImage/Core/SDImageCachesManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9848a18db96f9cc4192f5855b2c6ec65fc","path":"SDWebImage/Private/SDImageCachesManagerOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988c4bfa1a1a41475204073a9c37cc4138","path":"SDWebImage/Private/SDImageCachesManagerOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9878c179b604ae00d511bcfe7b1c26229a","path":"SDWebImage/Core/SDImageCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e79c00adee724aeb4ee07faf25c36816","path":"SDWebImage/Core/SDImageCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899370db25d5ccec85c5bc3841d9150c3","path":"SDWebImage/Core/SDImageCoderHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9864cb7481f2ac08dacad300433c1e0e01","path":"SDWebImage/Core/SDImageCoderHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98466aa14d7abf59b152f093b8602bd34b","path":"SDWebImage/Core/SDImageCodersManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a351c522c640e5c3836b0b3b0ff15805","path":"SDWebImage/Core/SDImageCodersManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989938d9775335a6ffdff595f50234b88b","path":"SDWebImage/Core/SDImageFrame.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a8ca264bae9407c5ab570cb5c4eaa6e8","path":"SDWebImage/Core/SDImageFrame.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989a78ff5eec8840469dfa52c4df920d84","path":"SDWebImage/Core/SDImageGIFCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984d22bab198a56a739526a9f9fac69088","path":"SDWebImage/Core/SDImageGIFCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b137bd091f2422235254ba87eeeebb86","path":"SDWebImage/Core/SDImageGraphics.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987ec2e6e40d48d5e1c573c77074e16edf","path":"SDWebImage/Core/SDImageGraphics.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9817f302504ade0c532160f76082a200fc","path":"SDWebImage/Core/SDImageHEICCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cec361cb5581e3f861f0c9ebc7eb79b3","path":"SDWebImage/Core/SDImageHEICCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9863c1633045c8f4e6a7fefb974e14ca36","path":"SDWebImage/Core/SDImageIOAnimatedCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98728975404c77f683b561feae9c1d429f","path":"SDWebImage/Core/SDImageIOAnimatedCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9840158ea8f0a0e2c720c2f9a0c43bc29e","path":"SDWebImage/Private/SDImageIOAnimatedCoderInternal.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981a3f021a5e50a888a5ae1abc4e2e57fe","path":"SDWebImage/Core/SDImageIOCoder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9810ea71fbe3808e0193ef3ebfe97b71ed","path":"SDWebImage/Core/SDImageIOCoder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e3114679d79d3ddb667c3e6fb257d6fb","path":"SDWebImage/Core/SDImageLoader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d8bfeefcb91662f7491bdee227ce85b8","path":"SDWebImage/Core/SDImageLoader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987a235e20b10d513a95f2fe5a4faa9fb2","path":"SDWebImage/Core/SDImageLoadersManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ae292dc3f6734ffa8a30d1eb750b312a","path":"SDWebImage/Core/SDImageLoadersManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98555b647b7b68b3c59175202ef82bc955","path":"SDWebImage/Core/SDImageTransformer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98379f432747c66b556608cfcb163dd08a","path":"SDWebImage/Core/SDImageTransformer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984b63b84d1f79f14a6dc3a12cac2809ed","path":"SDWebImage/Private/SDInternalMacros.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aad7fdfe00389f16c787afd556ef9f8e","path":"SDWebImage/Private/SDInternalMacros.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98209eac57b1854edf591b16e3f80fec69","path":"SDWebImage/Core/SDMemoryCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982b6d0c6ce7e156d1ba21129855543471","path":"SDWebImage/Core/SDMemoryCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9898c0fd4390df5133c07e348bdbd55d9c","path":"SDWebImage/Private/SDmetamacros.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988e65ef0d735983c43262cb848c637768","path":"SDWebImage/Private/SDWeakProxy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c1a6e6955d89f3463af21ab50142f735","path":"SDWebImage/Private/SDWeakProxy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9802c2dd1c8e6745d6ad8dfa6ac15a50df","path":"WebImage/SDWebImage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98583254b6e10a610161162b2462c9a243","path":"SDWebImage/Core/SDWebImageCacheKeyFilter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d67745e1c95b9a3b0b6afc23ba0e53d1","path":"SDWebImage/Core/SDWebImageCacheKeyFilter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e6013c78eea89a230352d0a58977ee65","path":"SDWebImage/Core/SDWebImageCacheSerializer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c798c0650901d3f454e84b38b32b7c14","path":"SDWebImage/Core/SDWebImageCacheSerializer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987bcdc97ee957f7d3dcc46ccd3df087d6","path":"SDWebImage/Core/SDWebImageCompat.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98175d683f5876f4bef28a5c568a03ee0e","path":"SDWebImage/Core/SDWebImageCompat.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a1f302e052441cf2364ea9fdbb633665","path":"SDWebImage/Core/SDWebImageDefine.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9801960d18f6952523241592bfcf8df74f","path":"SDWebImage/Core/SDWebImageDefine.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da783a4512ad557f5bed33b07819428b","path":"SDWebImage/Core/SDWebImageDownloader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988bf2ba3046f2a1dc82c3ae3236bb5c29","path":"SDWebImage/Core/SDWebImageDownloader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987349aeb8db30aa64526eda7973f155b2","path":"SDWebImage/Core/SDWebImageDownloaderConfig.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987233e0726650facd69de48131fbfb887","path":"SDWebImage/Core/SDWebImageDownloaderConfig.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981f0f6fc0eca1c4df41989a0e01130390","path":"SDWebImage/Core/SDWebImageDownloaderDecryptor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b1e56d48b1b3c795cd043d008ac2d69f","path":"SDWebImage/Core/SDWebImageDownloaderDecryptor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986735d6c802483fa77c3db72c66398b93","path":"SDWebImage/Core/SDWebImageDownloaderOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986a5410248346eb3a5db888eeaf016ed3","path":"SDWebImage/Core/SDWebImageDownloaderOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980f2c0526259c08fb215f33283d062669","path":"SDWebImage/Core/SDWebImageDownloaderRequestModifier.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986771283dce17b4fb9bd4b5fe643ed3b9","path":"SDWebImage/Core/SDWebImageDownloaderRequestModifier.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ae78c144f7dd1ffa4151c834e1856244","path":"SDWebImage/Core/SDWebImageDownloaderResponseModifier.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cd959e9548caf1d3b10d572543028967","path":"SDWebImage/Core/SDWebImageDownloaderResponseModifier.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981f2f1b5fb7e7a0259d587693c70e17dc","path":"SDWebImage/Core/SDWebImageError.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d7b5236ded30e2d2a5ff450a32f7bd62","path":"SDWebImage/Core/SDWebImageError.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98db171e7f43254466428cd7d160d4bc66","path":"SDWebImage/Core/SDWebImageIndicator.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988059612109acc8adb78283f1ba5f6c8e","path":"SDWebImage/Core/SDWebImageIndicator.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989a4f6041648958d4aff810b46b83ec9d","path":"SDWebImage/Core/SDWebImageManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985ec38f9cb9f5d4978c0311d1008e04cc","path":"SDWebImage/Core/SDWebImageManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982c27b087e8cd840abc3ef59829b80efa","path":"SDWebImage/Core/SDWebImageOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989ab8963206759569d84f30727fa4cba2","path":"SDWebImage/Core/SDWebImageOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b72cfadd9fa624c843ceaca86f14fba7","path":"SDWebImage/Core/SDWebImageOptionsProcessor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b5e7d24f1e11dbac304e87bddaff0bab","path":"SDWebImage/Core/SDWebImageOptionsProcessor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982a13b238680429b9cb2b8bf0839d5f79","path":"SDWebImage/Core/SDWebImagePrefetcher.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985fdfabd035ec7a467b035a8ce350edd5","path":"SDWebImage/Core/SDWebImagePrefetcher.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987fbe1b7021e7099d47e3d29bb0d246b0","path":"SDWebImage/Core/SDWebImageTransition.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d898bb0c82f19d5c90770a13e1992bc4","path":"SDWebImage/Core/SDWebImageTransition.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e46d3f1b4958c657b86076222797d146","path":"SDWebImage/Private/SDWebImageTransitionInternal.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989b6a95fea066801733ba0d9eb7781a8d","path":"SDWebImage/Core/UIButton+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a7d08c8d1537b994f19071149f068bb7","path":"SDWebImage/Core/UIButton+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9845d7911932c79e171dd089fd4628b5b8","path":"SDWebImage/Private/UIColor+SDHexString.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98008deca9be811cf53f205b0ddcc4983a","path":"SDWebImage/Private/UIColor+SDHexString.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986bb62f370bbe234b6bd0d7c77da71782","path":"SDWebImage/Core/UIImage+ExtendedCacheData.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aa1127430a2307c0512ba13f3695fe9e","path":"SDWebImage/Core/UIImage+ExtendedCacheData.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ccd7d7396bf3843012af71140da2a49c","path":"SDWebImage/Core/UIImage+ForceDecode.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9863a4e0085b4eca2b418099edfec4340a","path":"SDWebImage/Core/UIImage+ForceDecode.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b5c221940cf71976a9792c00ded2a77c","path":"SDWebImage/Core/UIImage+GIF.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d05fd41d05faa2360aeaf46fb5065514","path":"SDWebImage/Core/UIImage+GIF.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98662eda270b9081b770cb1f2a02e387b2","path":"SDWebImage/Core/UIImage+MemoryCacheCost.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fd8dd526fbbf1c35d004173853ebc817","path":"SDWebImage/Core/UIImage+MemoryCacheCost.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986357ed01b566bb1d1be0954a632e35e0","path":"SDWebImage/Core/UIImage+Metadata.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986a263fb34dde4d01069f152b021094c7","path":"SDWebImage/Core/UIImage+Metadata.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98deca6db8fdc22a81efbb6cd75f7c9144","path":"SDWebImage/Core/UIImage+MultiFormat.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981f8ce8389cf620585da7cd0d5ae68c6d","path":"SDWebImage/Core/UIImage+MultiFormat.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f79a5a836cf1d081150335f28fe35761","path":"SDWebImage/Core/UIImage+Transform.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c1367cac0329507a751b00959f8b1fe9","path":"SDWebImage/Core/UIImage+Transform.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9836fad0c1818f3ebd7ea94ecdb2cda58b","path":"SDWebImage/Core/UIImageView+HighlightedWebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e869a02fe4a657b9ddb0f148af11b090","path":"SDWebImage/Core/UIImageView+HighlightedWebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fc1f6b2f5b528239f7eb374a16707dd2","path":"SDWebImage/Core/UIImageView+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987a0efb32908f89fd6eaf93a60386fe37","path":"SDWebImage/Core/UIImageView+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d835b0cd54e8e5c3eefa756ca6a8cf4f","path":"SDWebImage/Core/UIView+WebCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98da978efa2c20cf80e6a593952a64f214","path":"SDWebImage/Core/UIView+WebCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982999b5735c33706130ed303f0efe7372","path":"SDWebImage/Core/UIView+WebCacheOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983963a49bc38ac28ca13543440440738c","path":"SDWebImage/Core/UIView+WebCacheOperation.m","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986e6118052c889416524a66863865f2d2","name":"Core","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98b569f5963060c97f2f2ba38385fe0047","path":"SDWebImage.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e582145da4c7bad45fd0fea722620980","path":"SDWebImage-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98f69556d85c791f9f7bb4864067304e9e","path":"SDWebImage-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a5d7cf445f11b8a851603791bb80dccd","path":"SDWebImage-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9849c916368f02890a1ae21d78a5082dce","path":"SDWebImage-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98cbf68d1f922bc4817004dd6a2700f369","path":"SDWebImage.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e982a978c9504e7f820a9a62749b4639eb7","path":"SDWebImage.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98c5c4f2160b2c64c56cc35e1563f69dfa","name":"Support Files","path":"../Target Support Files/SDWebImage","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e989ab633a406a0ba749c8183ca48f70ca1","name":"SDWebImage","path":"SDWebImage","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98198cb7323cf38f49c099a19af424e38e","path":"Sources/Swift/Metrics/BucketsMetricsAggregator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9820b096789e7bd1bfa4b58fa13bc5b50f","path":"Sources/Swift/Metrics/CounterMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98853fa02d04d0430ad3f2d7b333874af4","path":"Sources/Swift/Metrics/DistributionMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98ac0780da839edf981809953008673060","path":"Sources/Swift/Metrics/EncodeMetrics.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982bd4f7e001b4dd1cf079308ca514554b","path":"Sources/Swift/Metrics/GaugeMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9824d6ee3483a76d2eb18b2c33833c5c8a","path":"Sources/Swift/Tools/HTTPHeaderSanitizer.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98382df2b05839d9ea3f03c148af3ae692","path":"Sources/Swift/Metrics/LocalMetricsAggregator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981b4b3945f043de125e7b8c8aaeacc7c3","path":"Sources/Swift/Metrics/Metric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9830b09011eccca71f62b2f66238867f76","path":"Sources/Swift/Metrics/MetricsAggregator.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aa15c1b3d311498677dcd94b75280f47","path":"Sources/Sentry/include/NSArray+SentrySanitize.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982ad87d7afe24ffd2d6bd213296aea811","path":"Sources/Sentry/NSArray+SentrySanitize.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f4b859c713f2344c6fc1477b46cbafeb","path":"Sources/Sentry/include/NSLocale+Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987f7b4c8e450d6bc934891b572509dd5c","path":"Sources/Sentry/NSLocale+Sentry.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b9346543c1b98901f22f3c7e23aff838","path":"Sources/Swift/Extensions/NSLock.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989b00bcbca3893520fc4f39ff18d0b1f5","path":"Sources/Sentry/include/NSMutableDictionary+Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98902ebd6036a41aaacac357ee5ccce1b9","path":"Sources/Sentry/NSMutableDictionary+Sentry.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9885b82b46b9dd005b768f8ef3c751040f","path":"Sources/Swift/Extensions/NumberExtensions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a716e75ccb528744d3ffde348c74054d","path":"Sources/Sentry/include/HybridPublic/PrivateSentrySDKOnly.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9810ac796ebca563e59fec2d96f029829c","path":"Sources/Sentry/PrivateSentrySDKOnly.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ed4d648fc835701dd40045ac4f0eb42","path":"Sources/Sentry/include/HybridPublic/PrivatesHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9825d3d80b5d5f47d8c3577be50d208f17","path":"Sources/Sentry/Public/Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9881e78b26fce5be69601e8f39debf1815","path":"Sources/Sentry/include/SentryANRTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988933b4bc3332ec51f502704c510ed48f","path":"Sources/Sentry/SentryANRTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c4aec8c696347d8a5fd3da2c9ae3b34b","path":"Sources/Sentry/include/SentryANRTrackerV2.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98143f06533f29ffe1f4e62e249ee7c61a","path":"Sources/Sentry/SentryANRTrackerV2.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9805b65a9fd5a17f8804adb0010a2bb994","path":"Sources/Swift/Integrations/ANR/SentryANRTrackerV2Delegate.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a9377495765c6b05f4938e987beaecc4","path":"Sources/Sentry/include/SentryANRTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9803a9b0b910a8e69ce5d48707b2f7b0ff","path":"Sources/Sentry/SentryANRTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9853bb97ef1af68a2e8a6cd65845220a21","path":"Sources/Sentry/include/SentryANRTrackingIntegrationV2.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f85156689aeaf18e00084edae9890433","path":"Sources/Sentry/SentryANRTrackingIntegrationV2.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982f3eaf81b6a498c005395bd74ff9b40c","path":"Sources/Sentry/include/HybridPublic/SentryAppStartMeasurement.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98573cf1fba6ec7158ac9e5d30872d3358","path":"Sources/Sentry/SentryAppStartMeasurement.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da6d3a4aa155bc2b99c1c5776fa1d7ab","path":"Sources/Sentry/include/SentryAppStartTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9858a59bcb56abe22c97af586957612f8a","path":"Sources/Sentry/SentryAppStartTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9897bfe385980cf04d6052b9a9a48626bd","path":"Sources/Sentry/include/SentryAppStartTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984c7e16723e7d6c5950d220f08a0f3a1b","path":"Sources/Sentry/SentryAppStartTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98711c5fa9ee620eb44232227aea5b4980","path":"Sources/Sentry/include/SentryAppState.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e102ca54129581ea6ac1fd19ca2f900d","path":"Sources/Sentry/SentryAppState.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f4aff5850320b2892171d2f9050e54f0","path":"Sources/Sentry/include/SentryAppStateManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98945bba539ff28910dddfef800d776212","path":"Sources/Sentry/SentryAppStateManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c7abc40702780507f67178f200c91e33","path":"Sources/Sentry/include/SentryAsynchronousOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f2c93c0f5d5274544f463c3dd147b69e","path":"Sources/Sentry/SentryAsynchronousOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9848c4b2580de96341fc0bfc5e6780e93a","path":"Sources/Sentry/SentryAsyncSafeLog.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98503f56b704c70eeeaa87f393ca606f46","path":"Sources/Sentry/SentryAsyncSafeLog.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9839b12ad72defb9e70cd9c490eab0f56b","path":"Sources/Sentry/Public/SentryAttachment.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9817625a85b70840a0b0718c986516528e","path":"Sources/Sentry/SentryAttachment.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98999febd8a8da745b5ea4843c406440a6","path":"Sources/Sentry/include/SentryAttachment+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980c19c6b2f6ae9fa9cbfb42827276a528","path":"Sources/Sentry/include/SentryAutoBreadcrumbTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982375cf366eb9fa65b8bba986be274ede","path":"Sources/Sentry/SentryAutoBreadcrumbTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986e0e69f1089a15308ed825159b9e98f7","path":"Sources/Sentry/include/SentryAutoSessionTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a4037e5e9e7feba04419a743f2be779b","path":"Sources/Sentry/SentryAutoSessionTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e981de7f79c30c8772c5fa58d8ea6d1b1b6","path":"Sources/Sentry/SentryBacktrace.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9891ba718e9d3cff3ab8d9bfee8bd0462b","path":"Sources/Sentry/include/SentryBacktrace.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989f61aa00b8a1516208dca7f64cd6a75c","path":"Sources/Sentry/Public/SentryBaggage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b20f7baef1201383ed42eb1c42b1ef2a","path":"Sources/Sentry/SentryBaggage.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e983e4f391d969af2e5ce4e2e2dd4a41dfb","path":"Sources/Swift/Helper/SentryBaggageSerialization.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985a62059eff00244af5c87c6ddd480877","path":"Sources/Sentry/include/SentryBaseIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987a9f6073313425fbba5130148b9b3a65","path":"Sources/Sentry/SentryBaseIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ccba6be3c8cd08597e295bd9856dd7e3","path":"Sources/Sentry/include/HybridPublic/SentryBinaryImageCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98963cbdfa2428d743619bf4f2c4cdf206","path":"Sources/Sentry/SentryBinaryImageCache.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98963224f46996165eb8c62129394cfe81","path":"Sources/Sentry/Public/SentryBreadcrumb.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e6b4f1738c9e7b78c11959faf476bb67","path":"Sources/Sentry/SentryBreadcrumb.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98647433b4e086798b973e947311127f7c","path":"Sources/Sentry/include/HybridPublic/SentryBreadcrumb+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b337f47392c6e73cb95920efdda0b675","path":"Sources/Sentry/include/SentryBreadcrumbDelegate.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fd297ac79b8c53f88805194c653eb324","path":"Sources/Sentry/include/SentryBreadcrumbTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98917b84e096fb7a3cee094b304c0d1f1e","path":"Sources/Sentry/SentryBreadcrumbTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987869ca5f51cd67f10fb06cf6d2a072e1","path":"Sources/Sentry/include/SentryBuildAppStartSpans.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b16fdba02a928575106b9d1ac1686733","path":"Sources/Sentry/SentryBuildAppStartSpans.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899a99bf57fcbf0c333b505bdb2e414cc","path":"Sources/Sentry/include/SentryByteCountFormatter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e29dfdbdcbf120e148ffa7d4a4c248a4","path":"Sources/Sentry/SentryByteCountFormatter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98be06b9e44be8b3f68cdabea1eab410fb","path":"Sources/Sentry/Public/SentryClient.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981f87b3406b001d130316dbc8ada38a00","path":"Sources/Sentry/SentryClient.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987b01f905126992881fc2dce8d8608866","path":"Sources/Sentry/include/SentryClient+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987f499c7f3f52d80336b1b533a1394782","path":"Sources/Sentry/include/SentryClientReport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a4dc6d699fd7d93e90a761eda46c4f64","path":"Sources/Sentry/SentryClientReport.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9830ba7417bb270438177deb73683c5536","path":"Sources/Sentry/include/SentryCompiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985f16ffcf4dfbc88b10ce30fd3e633708","path":"Sources/Sentry/include/SentryConcurrentRateLimitsDictionary.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98869ae30e47af8e56237caf99320cf284","path":"Sources/Sentry/SentryConcurrentRateLimitsDictionary.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f66f182cfe191dbc0994d7db36647922","path":"Sources/Sentry/include/SentryContinuousProfiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98fe03e1800d75ed025acc7c9f643b8f04","path":"Sources/Sentry/Profiling/SentryContinuousProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9860162861b3210d454479d7e1ac71d658","path":"Sources/Sentry/include/SentryCoreDataSwizzling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9828efb6a05eb4ef1519bb559204168266","path":"Sources/Sentry/SentryCoreDataSwizzling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9813f1af6eada9adbb72a6632c8d2b2829","path":"Sources/Sentry/include/SentryCoreDataTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980e9d7ced294ae24069ba05792c403f21","path":"Sources/Sentry/SentryCoreDataTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98373aa59d4a768c9ebed68430e50f9fd4","path":"Sources/Sentry/include/SentryCoreDataTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d656120ee1d06ad362116f5cd2c4da69","path":"Sources/Sentry/SentryCoreDataTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98971753585da08da03da7583249217f27","path":"Sources/Sentry/include/SentryCPU.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e71ce65934ced440e8087b210d26e9dd","path":"Sources/SentryCrash/Recording/SentryCrash.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9810ab6f5523c4427b61de387291c7a269","path":"Sources/SentryCrash/Recording/SentryCrash.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e983e0adaf7be093e8acf73fd7ca3cd8020","path":"Sources/SentryCrash/Recording/SentryCrashBinaryImageCache.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986c59b016150adc6042a09462cf5f2949","path":"Sources/SentryCrash/Recording/SentryCrashBinaryImageCache.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba2cf898e20c56ff809039433c33cfd0","path":"Sources/Sentry/include/SentryCrashBinaryImageProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982f6e693d7a2699f92a735b00a3b748ec","path":"Sources/SentryCrash/Recording/SentryCrashC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988880cf5ad48383745a739a9820534574","path":"Sources/SentryCrash/Recording/SentryCrashC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e981c394d73a0449af8d0295e4f249392e4","path":"Sources/SentryCrash/Recording/SentryCrashCachedData.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984943986cd47c7fbf04e945d0f7ce23dc","path":"Sources/SentryCrash/Recording/SentryCrashCachedData.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e985af7183f551d475ec21df7099e52ba19","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9819e75d68bf8cdd116d83607aa0c87f45","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8b934d46e8ace1a43b18243a1180d46","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_Apple.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e986e6252e39b90f3ce76d83e5d6c3974b2","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_arm.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98a418c37e4b8f0cae47ee4e0ef8a63b52","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_arm64.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9854f64306da17dca29bd7ff5a1c475bbe","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_x86_32.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9858b9351f994445a2cced9788b61b0857","path":"Sources/SentryCrash/Recording/Tools/SentryCrashCPU_x86_64.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9815c5e7deb3496c383b03ad2cb8c63c54","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDate.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98920a4695f60aba5f7a2d55e19953a7c3","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDate.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982e916154b2f4a8ee629c521943af2be5","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDebug.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fa25002e2a2a304eb735465f026645f3","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDebug.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9862a562ca672b78057b80f0d6b1381746","path":"Sources/Sentry/include/SentryCrashDefaultBinaryImageProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986d3ae0f245877d1a34a61568db103431","path":"Sources/Sentry/SentryCrashDefaultBinaryImageProvider.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989f539a8dd7e053b0be13bac966b57726","path":"Sources/Sentry/include/SentryCrashDefaultMachineContextWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98834119e584475e9bbe8dde34e6124eed","path":"Sources/Sentry/SentryCrashDefaultMachineContextWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987c323ed9eabfe9ca185b337c9308d491","path":"Sources/SentryCrash/Recording/SentryCrashDoctor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e86a5d681f8907d2cfc0b4615cae7aa6","path":"Sources/SentryCrash/Recording/SentryCrashDoctor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98872202ab332a20c50d416271f9d2372a","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDynamicLinker.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ba5fbfcc911db7d4ae22629456fd9144","path":"Sources/SentryCrash/Recording/Tools/SentryCrashDynamicLinker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98328deee97d6c62d2e5b511634b98f69a","path":"Sources/Sentry/Public/SentryCrashExceptionApplication.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ced46e42add5fe763f983e5bb41852eb","path":"Sources/Sentry/SentryCrashExceptionApplication.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9851df5069bd644d351f378d5a24e99531","path":"Sources/SentryCrash/Recording/Tools/SentryCrashFileUtils.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ee672a6996837477d96028b68c1168d1","path":"Sources/SentryCrash/Recording/Tools/SentryCrashFileUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9884856fe388c064570227a322157015ee","path":"Sources/SentryCrash/Recording/Tools/SentryCrashID.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9895df3868e47b3489c27cba402f91a418","path":"Sources/SentryCrash/Recording/Tools/SentryCrashID.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eafd27653478c04bf42d5a09c9475684","path":"Sources/SentryCrash/Installations/SentryCrashInstallation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98785810b1ca7d160b372c70ed55188bdb","path":"Sources/SentryCrash/Installations/SentryCrashInstallation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98789ed62e82862651822eb9e968b4183b","path":"Sources/SentryCrash/Installations/SentryCrashInstallation+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987b19143bc7df2526be6323a84f13d0cd","path":"Sources/Sentry/include/SentryCrashInstallationReporter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9843bd4e1a8c710f4388f1611823aa8703","path":"Sources/Sentry/SentryCrashInstallationReporter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fa237d860d80a5137ce637c70f1d45ae","path":"Sources/Sentry/include/SentryCrashIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984f9135a1bacbd9b7d700d830329cfa18","path":"Sources/Sentry/SentryCrashIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ed40eb6a8415d9209bc656e3e9003d20","path":"Sources/Sentry/include/SentryCrashIsAppImage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e983ace9e6326466c6006d7903e835f2298","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodec.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984db0ba7ef8f9c62a4768e1244e0175a5","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodec.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980949e62d558d1222c68ac80103de072a","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodecObjC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fb551adfbd11861983f13d4ea4ac5672","path":"Sources/SentryCrash/Recording/Tools/SentryCrashJSONCodecObjC.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ebe01a592e2d14b2852a1fb5fbd2aec6","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMach.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e63f4dd5624ba45cbd9550973bc7a64b","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMach.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ac0ec3dc837141d75865164b98357241","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMachineContext.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982d7b304e56d6074933295f9cbe624568","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMachineContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b5dcb1a00343dc6a7015edc3f087c60a","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMachineContext_Apple.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9890a37d86794ca14855675168cd3383ad","path":"Sources/Sentry/include/SentryCrashMachineContextWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982bc91af71f89cf0a1a1788830d8adeba","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMemory.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bad5c89792bfd17e382bb695ad8ed3f7","path":"Sources/SentryCrash/Recording/Tools/SentryCrashMemory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98cb3e637b4b9c0fd20f7ea80210d8b3c8","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f6864c5d37412b769927b5d5c74fe602","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e981c4aa5985300ab845f08441631c136e0","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_AppState.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986db33830a858c9794d31e24b7b5cdb20","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_AppState.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e98928789958ea08294471edb78122163bf","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_CPPException.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d88503a787c4bef8d3f64a16f8ac7431","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_CPPException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e985631641a5bf876e0a4464325cd89f9f0","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_MachException.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f9cccecbb59fac96ef00765633935089","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_MachException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981f0f66e36982c6f19b8196b5549c1151","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_NSException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9854c3e488a79f2baa48bf1bde77c46cd4","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_NSException.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98fc0ee9ce543fb5029fa8d77a33a2a927","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_Signal.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ef5045f7ebe869b4e0ded07a2e0b854f","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_Signal.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986d521257b90b2beb37d3cbeb2b852f91","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_System.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989b790d6fb8d01f7933ddbca6b6c2e119","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitor_System.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b15723d60c434030589a9c72cc466027","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitorContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e984023b76db5ad50719b293030bedfa158","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitorType.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e0d4db171ade52ab9810b83e9e46027a","path":"Sources/SentryCrash/Recording/Monitors/SentryCrashMonitorType.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9858f76db325762543559f151e9cde7092","path":"Sources/SentryCrash/Recording/Tools/SentryCrashNSErrorUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98036351eb99bc61418fa7904beb886a4b","path":"Sources/SentryCrash/Recording/Tools/SentryCrashNSErrorUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ec8643356b925433575de2c29645e403","path":"Sources/SentryCrash/Recording/Tools/SentryCrashObjC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d43045f94dfb5f8084c33ff0795a4fb3","path":"Sources/SentryCrash/Recording/Tools/SentryCrashObjC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983ff24760fdf3ff3241a74acaab8abd66","path":"Sources/SentryCrash/Recording/Tools/SentryCrashObjCApple.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e52be506e681f88f6f6ca68d2f91cc4e","path":"Sources/SentryCrash/Recording/Tools/SentryCrashPlatformSpecificDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ebb3d6360d98f69e909523c8039f5805","path":"Sources/SentryCrash/Recording/SentryCrashReport.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9882eca2c2727b3da70dc9991523763b33","path":"Sources/SentryCrash/Recording/SentryCrashReport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9857e1affb989c53e044956e4452fdbcea","path":"Sources/Sentry/include/SentryCrashReportConverter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982deeb46c4817dcdcd9ac3ff4d08481d4","path":"Sources/Sentry/SentryCrashReportConverter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982aad48539d3cdacf06eba4d8e46a2584","path":"Sources/SentryCrash/Recording/SentryCrashReportFields.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981e9d85b65beaeba8501a1c62b8febe5b","path":"Sources/SentryCrash/Reporting/Filters/SentryCrashReportFilter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980d8271e53cbfcf85f6cd065e708f2de2","path":"Sources/SentryCrash/Reporting/Filters/SentryCrashReportFilterBasic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9840d849ddebfb811a2974576cbaeecf80","path":"Sources/SentryCrash/Reporting/Filters/SentryCrashReportFilterBasic.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98a9d5de5d3dfe92644037d71ee5190757","path":"Sources/SentryCrash/Recording/SentryCrashReportFixer.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f69b87a8cdb3abcbac109f20e35935f7","path":"Sources/SentryCrash/Recording/SentryCrashReportFixer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9875fc97b0b53f66ab6b047f69319bae1f","path":"Sources/Sentry/include/SentryCrashReportSink.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98f0c689d1974688e39dbfb4912fa84a98","path":"Sources/Sentry/SentryCrashReportSink.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9896c2e55b5af11c135ee2e941bdd0d7f7","path":"Sources/SentryCrash/Recording/SentryCrashReportStore.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9837c90d38984770cab39b73a4ada41ded","path":"Sources/SentryCrash/Recording/SentryCrashReportStore.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f92815bb8d5e910d80c83c261ef2665a","path":"Sources/SentryCrash/Recording/SentryCrashReportVersion.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982b8d6627048f9dc97cf8b1ea4105f91b","path":"Sources/SentryCrash/Recording/SentryCrashReportWriter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d078af0694b1dfb7d9ccde999b44e4fb","path":"Sources/Sentry/include/SentryCrashScopeObserver.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98078b4b6983c2612f332996e0802c4c78","path":"Sources/Sentry/SentryCrashScopeObserver.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98fc0f9f2e8fb1eb0f1596f10d50e88f2f","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSignalInfo.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983af198c0f2730501f9ce1047fe9febf5","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSignalInfo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e982f50e0ec35d87b0e7665d862c74ad244","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ab8a94243d149d379bbd54e70a367d2","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e989255de6ce3a4e6431e337169ea12941c","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_Backtrace.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98227f0ab7c55a7b2a8c57667847371c5c","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_Backtrace.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e981ded049e9550dae6bfea340e4044795f","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_MachineContext.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985481f9043d0f6eec88a8931509d27ca7","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_MachineContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98172b9a0d48d2f67d576708e2c15c5681","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_SelfThread.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980331a56274b42b435802d99adc0f0030","path":"Sources/SentryCrash/Recording/Tools/SentryCrashStackCursor_SelfThread.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98616e193ad92d875887b6acd5f702b997","path":"Sources/Sentry/include/SentryCrashStackEntryMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fcbe81bbd35a2c2ad89d2e20c97dcc80","path":"Sources/Sentry/SentryCrashStackEntryMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98cac8c3e4cc76215e854ea8e9fd0b4fbd","path":"Sources/SentryCrash/Recording/Tools/SentryCrashString.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982bc1b6754e56222800d995cd75f8c2bb","path":"Sources/SentryCrash/Recording/Tools/SentryCrashString.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98e542917dc6ed22f0dc8928e137282679","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSymbolicator.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9842aa558befabfc401dc6c66d86529e41","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSymbolicator.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9805e8aec9ac44b4f557bd1c2c0ed33d7e","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSysCtl.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987d038ee2ee6d13d961dcc8f8cef0e8f9","path":"Sources/SentryCrash/Recording/Tools/SentryCrashSysCtl.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9814ac97981888d650562ae1699fbed050","path":"Sources/SentryCrash/Recording/Tools/SentryCrashThread.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983761e96361f55650fcff3ec415c306c0","path":"Sources/SentryCrash/Recording/Tools/SentryCrashThread.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98cf8824f371b9de403b95fafde5e63a58","path":"Sources/SentryCrash/Recording/Tools/SentryCrashUUIDConversion.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9824542f73d2e98c60530e9c276356c21d","path":"Sources/SentryCrash/Recording/Tools/SentryCrashUUIDConversion.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f8bc45a2584bffecf7c9393d70e3ac1b","path":"Sources/SentryCrash/Reporting/Filters/Tools/SentryCrashVarArgs.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d8def6fa7b0dff52e0288684681cd235","path":"Sources/Sentry/include/SentryCrashWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98844dfbd5d2a8a41ef38196b8c228710e","path":"Sources/Sentry/SentryCrashWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98beaf5cf161791d03e65fca4463133522","path":"Sources/Swift/Helper/SentryCurrentDateProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9820c8946792aadcff4c6b7911cd3509b1","path":"Sources/Sentry/include/SentryDataCategory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9897ecba504c62f71a990fd084fdd0aa08","path":"Sources/Sentry/include/SentryDataCategoryMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98993a135e0861037578f65e78f6f19fa5","path":"Sources/Sentry/SentryDataCategoryMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fb2c2fb737cef61e3aa32bc173200734","path":"Sources/Sentry/include/SentryDateUtil.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980da3c3a8a5a8e75a2b495f25b864ffff","path":"Sources/Sentry/SentryDateUtil.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982cfb6141f563ef3b224e37c23dfd6ee7","path":"Sources/Sentry/include/SentryDateUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981e501be4e9e4ce930febba0a67b92f83","path":"Sources/Sentry/SentryDateUtils.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986de643aa7e4d68dd257a682eb4ecf094","path":"Sources/Sentry/Public/SentryDebugImageProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988714d874b4c7d300f8820a9568aac3b9","path":"Sources/Sentry/SentryDebugImageProvider.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ccd67327f2cef046d47a060a50e2b033","path":"Sources/Sentry/Public/SentryDebugMeta.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d62e6ab2a2d7467600f6feb7e2671a52","path":"Sources/Sentry/SentryDebugMeta.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98057cb7e081eab9c13bc45a99e2046c64","path":"Sources/Sentry/include/SentryDefaultObjCRuntimeWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98755befdde596ed98608c0f49e465f2e9","path":"Sources/Sentry/SentryDefaultObjCRuntimeWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987fb8b09d68a8675e669ccaa2b3d70f8b","path":"Sources/Sentry/include/SentryDefaultRateLimits.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98284e598b5703764339c9c4dd2d39c047","path":"Sources/Sentry/SentryDefaultRateLimits.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8b66c1f8b8fe6afdb3bd5083ac798dd","path":"Sources/Sentry/Public/SentryDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982b9ddd57b7d448702427527f15078729","path":"Sources/Sentry/include/SentryDelayedFrame.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b80297ad046a9e2ee2fc01b38f886866","path":"Sources/Sentry/SentryDelayedFrame.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c933c980d9fd2d6b18ab7660be826a32","path":"Sources/Sentry/include/SentryDelayedFramesTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9883f0c58fdda69214b888d7a89386438d","path":"Sources/Sentry/SentryDelayedFramesTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988e3fca574186ef9a3fbf60c7936614dd","path":"Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988e075ecfc8e39865eebcf99287896107","path":"Sources/Sentry/SentryDependencyContainer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ab472310b921cb357b3403f16d6bcc91","path":"Sources/Sentry/include/SentryDevice.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98ce8faf244cb4f73da113703dbb0fd493","path":"Sources/Sentry/SentryDevice.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f496c08b830fbf3baf100455494f579e","path":"Sources/SentryCrash/Reporting/Filters/Tools/SentryDictionaryDeepSearch.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ed54e2f0091c3b6700b4882495308ca7","path":"Sources/SentryCrash/Reporting/Filters/Tools/SentryDictionaryDeepSearch.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c50f7a6b19aff7f4ca9db7611b185ffc","path":"Sources/Sentry/include/SentryDiscardedEvent.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982b532fff8e4b1e30be209e64ad5f2328","path":"Sources/Sentry/SentryDiscardedEvent.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989c9e6c345c44f79bfb78251b2dbb8c7b","path":"Sources/Sentry/include/SentryDiscardReason.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c183e6d53e8117f062c3f4b7cd156530","path":"Sources/Sentry/include/SentryDiscardReasonMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986c8a3f333ed0b95751e7fbf78d7c98b9","path":"Sources/Sentry/SentryDiscardReasonMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bba374cd8c0dc6a0b88cd9da50d55613","path":"Sources/Sentry/include/SentryDispatchFactory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b61addc447754e7e5ec64e70a8a8d043","path":"Sources/Sentry/SentryDispatchFactory.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98181ef37baebf7620fdb3ed512d9c5b2c","path":"Sources/Sentry/include/SentryDispatchQueueWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fdfd3d660615791f5b19cb7a270b3d33","path":"Sources/Sentry/SentryDispatchQueueWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98edef8192fe71dc5c3aa5e7605d8bc231","path":"Sources/Sentry/include/SentryDispatchSourceWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ad912bcb51aa320ac7d9ae0d6b1c29d0","path":"Sources/Sentry/SentryDispatchSourceWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985d9e7a648cec7e7fc0ad3cf75672ea68","path":"Sources/Sentry/include/SentryDisplayLinkWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cebef9f2b8583ad43ec280bc3f601765","path":"Sources/Sentry/include/SentryDisplayLinkWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eb4e19f7af34fa2f92906307734634ab","path":"Sources/Sentry/Public/SentryDsn.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98bc58b953a2bf19f0ba887c35a848c43d","path":"Sources/Sentry/SentryDsn.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98629100bfbc75470514774b17e16c6ed2","path":"Sources/Swift/Helper/SentryEnabledFeaturesBuilder.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f5d9dabdb733a787122fe8ba4ca78980","path":"Sources/Sentry/include/HybridPublic/SentryEnvelope.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989ef35f84679b5286380254cf3782d3fc","path":"Sources/Sentry/SentryEnvelope.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982792cab6ec953388de02a2ff6935ac57","path":"Sources/Sentry/include/SentryEnvelope+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f6cedb8fc664cf2a66556a10e0939328","path":"Sources/Sentry/include/SentryEnvelopeAttachmentHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987e289e99946da7963f37deadbe959fdd","path":"Sources/Sentry/SentryEnvelopeAttachmentHeader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98141370ca334e2965d382e9f5cee9eb40","path":"Sources/Sentry/Public/SentryEnvelopeItemHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987942c3b2f4ab61de1e45f7316786b84c","path":"Sources/Sentry/SentryEnvelopeItemHeader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98781a30887bd009417ad1a4112c2fb3fe","path":"Sources/Sentry/include/HybridPublic/SentryEnvelopeItemType.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9895aa5c265104e54b26110f7557073d7b","path":"Sources/Sentry/include/SentryEnvelopeRateLimit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d5a56c7cb96529d8791d765d11773537","path":"Sources/Sentry/SentryEnvelopeRateLimit.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bafc78f7024b690ecf9471384a9902b4","path":"Sources/Sentry/Public/SentryError.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98ca1115e3b61c2229c1c80bd36f06de1b","path":"Sources/Sentry/SentryError.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98413720c3decab33490a7c5172007d190","path":"Sources/Sentry/Public/SentryEvent.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983743f3d27289e139c0b954aaa4e0628a","path":"Sources/Sentry/SentryEvent.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a8f47a674006a0bd05b6739dc62b4f4f","path":"Sources/Sentry/include/SentryEvent+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987159465a79e7a721e0255b49a250409e","path":"Sources/Sentry/Public/SentryException.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9895825e81ff4e9350316aa59835550564","path":"Sources/Sentry/SentryException.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9862683b73d213711a5794050fbedb940e","path":"Sources/Swift/SentryExperimentalOptions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a207c835059cd1e43b5179b933097323","path":"Sources/Sentry/SentryExtraContextProvider.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e15159df27cf33be5cf5b699238e167f","path":"Sources/Sentry/SentryExtraContextProvider.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e61558513e2bb03c67587242c1ae2320","path":"Sources/Swift/Helper/SentryFileContents.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9891743b33ff8051d960b0188178ff8d18","path":"Sources/Sentry/include/SentryFileIOTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9811f125cb4ea42b0ee92dca25f4b88671","path":"Sources/Sentry/SentryFileIOTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c2c1ff1a63d933d631b4428ce2e38740","path":"Sources/Sentry/include/SentryFileManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987dc757a9b6a393b594b0e7f493805852","path":"Sources/Sentry/SentryFileManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c2ef2bd076cd4dd059f7e298bf852014","path":"Sources/Sentry/include/HybridPublic/SentryFormatter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9852b362bca7cc0c9e4f0ac4357b8926c2","path":"Sources/Sentry/Public/SentryFrame.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98cb0c8f2e66fc06982ca30a2cf3eb9bfc","path":"Sources/Sentry/SentryFrame.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981a64e72c9b8c3c97fd22013edcdae5ca","path":"Sources/Sentry/include/SentryFrameRemover.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9887e6e078e6301374b2fef621090a7c44","path":"Sources/Sentry/SentryFrameRemover.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98dba5b9e2f053ff10581a7a52a1336881","path":"Sources/Swift/Integrations/FramesTracking/SentryFramesDelayResult.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980aa82f1dc751a60b99dfdc678c1d417c","path":"Sources/Sentry/include/HybridPublic/SentryFramesTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9851e95210c8e69b336ec75546cb826a45","path":"Sources/Sentry/SentryFramesTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98efaa72ef40eaa86dff924769099fe63f","path":"Sources/Sentry/include/SentryFramesTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98732b4c467888ee6e063ecfd31db00fc1","path":"Sources/Sentry/SentryFramesTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9891ed9d2029d00cffa36a1f5fc06398a1","path":"Sources/Sentry/Public/SentryGeo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988d6d449e12ef4dade75902629ee9c5e5","path":"Sources/Sentry/SentryGeo.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ca8e4cce263f1f47c0f162e4c586c4f8","path":"Sources/Sentry/include/SentryGlobalEventProcessor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ed988e9fdf3783f895274a6a759ea0e0","path":"Sources/Sentry/SentryGlobalEventProcessor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986e3e378fc6f7cc204b68b3caa7236f27","path":"Sources/Sentry/include/SentryHttpDateParser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a9f385e05164f46553c2ee2cf481b7d4","path":"Sources/Sentry/SentryHttpDateParser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c3bfc35223f1b5b6943049d2fb6c4389","path":"Sources/Sentry/Public/SentryHttpStatusCodeRange.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98768627ada7e32a0631bc8af27cb1d1ff","path":"Sources/Sentry/SentryHttpStatusCodeRange.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cca6fefe9cb49705f068665278f8ac02","path":"Sources/Sentry/include/SentryHttpStatusCodeRange+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989de0a39761e8a1050f2257cbe8713a04","path":"Sources/Sentry/include/SentryHttpTransport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985ee2ceb1a7cef57f920d5af9053a5d61","path":"Sources/Sentry/SentryHttpTransport.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98836f5531819312d8b361c85d8e8b18a4","path":"Sources/Sentry/Public/SentryHub.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98252ac7b4e2a6feb7cd60fdd1a4f09dc4","path":"Sources/Sentry/SentryHub.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98638a129b104a232f59a5551902b1f3f4","path":"Sources/Sentry/include/SentryHub+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98118a67a7239494acc31abf04261df084","path":"Sources/Swift/Protocol/SentryId.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98070046bf0c41b80e5921e2353abd1714","path":"Sources/Sentry/include/SentryInAppLogic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c06ead7e0dbabb3e7c310df0e456432b","path":"Sources/Sentry/SentryInAppLogic.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e38ea514f750e6ef90ab207cb16028c2","path":"Sources/Sentry/include/SentryInstallation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981b188529a60da3e88d8e87a3cef822ea","path":"Sources/Sentry/SentryInstallation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98f1ecb074aa05d438550e8283fcf8840e","path":"Sources/Swift/Protocol/SentryIntegrationProtocol.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9880707aac6a0b662d864e53db8ec3e873","path":"Sources/Sentry/include/SentryInternalCDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b48ba94d494b08c87d61b93a678e3dd2","path":"Sources/Sentry/include/SentryInternalDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9827d2c767a3050b20c39203308cb87f8d","path":"Sources/Sentry/include/SentryInternalNotificationNames.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9849c7220f6bfe0eeb615b9a7accf8b533","path":"Sources/Sentry/include/SentryInternalSerializable.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98def9cc150a565d2550979ec5b69998b1","path":"Sources/Sentry/include/SentryLaunchProfiling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98911fdc6061a2135c748153f2e4a05aa3","path":"Sources/Sentry/Profiling/SentryLaunchProfiling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989df9dc056cea8fcf0f59af8f85cd8282","path":"Sources/Swift/Helper/Log/SentryLevel.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983c8f81c7f1106715ce45bc09d968a4cb","path":"Sources/Sentry/include/SentryLevelHelper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9827103d0e236a2917fac90b75c47f4fec","path":"Sources/Sentry/SentryLevelHelper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98844dd3ba156013068dbf7f35651a5957","path":"Sources/Sentry/include/SentryLevelMapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986b5d8de5042f9b6c6debab299d006332","path":"Sources/Sentry/SentryLevelMapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988d673b72b0119aacb08d60c172d07c94","path":"Sources/Sentry/include/SentryLog.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9828d4fd172c6650cc894dc077f2713654","path":"Sources/Swift/Tools/SentryLog.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c9238e73552f603c4ec002a1af34b21c","path":"Sources/Sentry/include/SentryLogC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980bbcb909a9d03a552f1277782e18761a","path":"Sources/Sentry/SentryLogC.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986b40c6c48b55db5046d6db511942fa38","path":"Sources/Swift/Tools/SentryLogOutput.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e9849d90f9a6a3c8d0dad49a3899b83cec1","path":"Sources/Sentry/SentryMachLogging.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9800ab9a3ecaf9fb561465a8384a1a4232","path":"Sources/Sentry/include/SentryMachLogging.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982d535deadc12989119b0615b9dd28f36","path":"Sources/Sentry/Public/SentryMeasurementUnit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d114189b8a76ae18f56bcfc4aaa7ea75","path":"Sources/Sentry/SentryMeasurementUnit.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980ca01cfbb872cb4259cd1e35227961de","path":"Sources/Sentry/include/SentryMeasurementValue.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987666c44d8e4bd63cee7a88e33191a7e6","path":"Sources/Sentry/SentryMeasurementValue.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9851202d61adfe91c437af0f1c67e03d04","path":"Sources/Sentry/Public/SentryMechanism.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e451424b1cdbc8b40cd309ecd4bbbf52","path":"Sources/Sentry/SentryMechanism.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98bab24acd4702a28a3c4c9063e4ef0dce","path":"Sources/Sentry/Public/SentryMechanismMeta.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982968cc739e708fdcb3738f171b8efd43","path":"Sources/Sentry/SentryMechanismMeta.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9830744347f699402adbc18f50caf4e2d7","path":"Sources/Sentry/Public/SentryMessage.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981082286186bf8efb07f5f2ce82e71bfc","path":"Sources/Sentry/SentryMessage.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9899ce478e31a9826614e13502b4400b26","path":"Sources/Sentry/include/SentryMeta.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980d00aea86836a212dca15301d0f6be80","path":"Sources/Sentry/SentryMeta.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ead309d87cf3d8ca9c2bc918c7485105","path":"Sources/Sentry/include/SentryMetricKitIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982f522d156c7a74cefb6fc138d7ed9eea","path":"Sources/Sentry/SentryMetricKitIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987cd2cb4adcd0f896a6a9aa250d475f99","path":"Sources/Sentry/include/SentryMetricProfiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9869f27a6b12c3ecdb44b5cf7f02588341","path":"Sources/Sentry/SentryMetricProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e986ce9c2df801c62045926d8864e84a2a1","path":"Sources/Swift/Metrics/SentryMetricsAPI.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981e970cc41f9443b9aec9727b6001ea4c","path":"Sources/Swift/Metrics/SentryMetricsClient.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982f7d0a3333cb2dcfcbe9c5afd9ec20f5","path":"Sources/Sentry/include/SentryMigrateSessionInit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c0abc314e5dda9b6db74ec0670b9817b","path":"Sources/Sentry/SentryMigrateSessionInit.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984f295a6e7dc5aee192e374504ed7fe03","path":"Sources/Sentry/include/SentryMsgPackSerializer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9886c992067744c43e9998edd72ad5e859","path":"Sources/Sentry/SentryMsgPackSerializer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98571b885c8f8846fcc4fd4db493b7816d","path":"Sources/Swift/MetricKit/SentryMXCallStackTree.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e987aff5927f8aad996cb87fb760614dbfd","path":"Sources/Swift/MetricKit/SentryMXManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e6c11cbc922ee1e497e67191309e6292","path":"Sources/Sentry/include/SentryNetworkTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9845b3a6e7f24267ede88073dada9a10f1","path":"Sources/Sentry/SentryNetworkTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98940d6066dd7b5b655ab91100255b0082","path":"Sources/Sentry/include/SentryNetworkTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e7ed27e60fec75e818894a08259699a4","path":"Sources/Sentry/SentryNetworkTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983581727c987fa4cca0954306818b12b7","path":"Sources/Sentry/include/SentryNoOpSpan.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9805654afa1f82958dd293c561ac9ab271","path":"Sources/Sentry/SentryNoOpSpan.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9833e522abc1d5b99c6068adbf830967c2","path":"Sources/Sentry/include/SentryNSDataSwizzling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9833178f1b00328efc29f49ca21f6fa919","path":"Sources/Sentry/SentryNSDataSwizzling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a32f1dc5a94f948c465e56c4010cf7e2","path":"Sources/Sentry/include/SentryNSDataTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984d57188a0ae1e5a026a97e63d1e37dd4","path":"Sources/Sentry/SentryNSDataTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987e9ef0b68a6618bff633d8257bbe4807","path":"Sources/Sentry/include/SentryNSDataUtils.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9846770a46bcf2b781d57ce3c925c8d341","path":"Sources/Sentry/SentryNSDataUtils.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981b65e1d892bcf2bcb3c5f20ec5972415","path":"Sources/Sentry/include/SentryNSDictionarySanitize.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986c8f20ef0729cf34cac9cba109e20051","path":"Sources/Sentry/SentryNSDictionarySanitize.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983337f11aeab3a289f5c8c19c3908b921","path":"Sources/Sentry/Public/SentryNSError.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984e9914a4b4293c692a90ba05b97965b5","path":"Sources/Sentry/SentryNSError.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9880ca6abb4fff5d7995dc2123497283ad","path":"Sources/Sentry/include/SentryNSNotificationCenterWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986f2b065fb1bca703459a4977185b8d05","path":"Sources/Sentry/SentryNSNotificationCenterWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98663c96b18dbadd2be6f08f5b8ea31c5e","path":"Sources/Sentry/include/SentryNSProcessInfoWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9894ae4b1a56d92b5ab6ed329af307db93","path":"Sources/Sentry/SentryNSProcessInfoWrapper.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9878f37430fba46c9dd937dbfac8cf691c","path":"Sources/Sentry/include/SentryNSTimerFactory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98692460fbec7a54719df44466b20bcd96","path":"Sources/Sentry/SentryNSTimerFactory.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98680d2dc7ef64f30c303c18b4117568fc","path":"Sources/Sentry/include/SentryNSURLRequest.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e288c4e7a426b08b04b780e40e1f743f","path":"Sources/Sentry/SentryNSURLRequest.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c1a99b0cf864330bb01057e2bffc60fc","path":"Sources/Sentry/include/SentryNSURLRequestBuilder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4217e23e64d40642d3a033d561f8d35","path":"Sources/Sentry/SentryNSURLRequestBuilder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989354f19ee7a39a29a3b6520194111f66","path":"Sources/Sentry/include/SentryNSURLSessionTaskSearch.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985cf2ec8edccb99946f64c83878c3c803","path":"Sources/Sentry/SentryNSURLSessionTaskSearch.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98537f34ec2613ecc57890ea95d1c7af35","path":"Sources/Sentry/include/SentryObjCRuntimeWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e987bcb2b2868ee46b3d212b5e9a35106d4","path":"Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989e50a47e4c378c019942525106400715","path":"Sources/Sentry/Public/SentryOptions.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98063d68f2c40f62f6810d0ed406bb382e","path":"Sources/Sentry/SentryOptions.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98eeec8be2c9ebaa5285c5472c3b36bf2e","path":"Sources/Sentry/include/HybridPublic/SentryOptions+HybridSDKs.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984bba6bc40fc60e061c7795ed9d14710d","path":"Sources/Sentry/include/SentryOptions+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984efbed9b76890e16efc0580a6c7f5776","path":"Sources/Sentry/include/SentryPerformanceTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989a15e09aff4d797481eb78d448d68934","path":"Sources/Sentry/SentryPerformanceTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981abbc71e31b4628810dbd6cd1779a6ab","path":"Sources/Sentry/include/SentryPerformanceTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4b7a5dd39a2fec7b7ddc0ee48570ad5","path":"Sources/Sentry/SentryPerformanceTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988b84a4cbbf82db3ee0783089fc6f3bba","path":"Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9855c159eb37097297bb381315fef4af83","path":"Sources/Sentry/include/SentryPredicateDescriptor.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d4338a3e8437f3b1d1acce2c48ddc4fb","path":"Sources/Sentry/SentryPredicateDescriptor.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cb932407441bd3d3ca9e06a6841edf04","path":"Sources/Sentry/include/SentryPrivate.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a5d56cb9cee92ba68a856453c30f353d","path":"Sources/Sentry/include/SentryProfiledTracerConcurrency.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e987291ecb593ccdba0e5fc6a98cbf5fdae","path":"Sources/Sentry/Profiling/SentryProfiledTracerConcurrency.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e989733858144eb7e903d25bb39b2aa1d69","path":"Sources/Sentry/SentryProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98023f426c062e3f41cac6b9ff2674eb7c","path":"Sources/Sentry/include/SentryProfiler+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981591d1e000f7e36a693757459cc651de","path":"Sources/Sentry/Profiling/SentryProfilerDefines.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9834c19d0343e8b716ae4009d27cd5343f","path":"Sources/Sentry/include/SentryProfilerSerialization.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98e9464d6a322ac39a174f4614fab171f4","path":"Sources/Sentry/Profiling/SentryProfilerSerialization.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986d6c0901f1b81ea1582095356892c849","path":"Sources/Sentry/Profiling/SentryProfilerSerialization+Test.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9849170326f9e7324f758fad5c8fda7c26","path":"Sources/Sentry/include/SentryProfilerState.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e988624d29c0763de3bcc218c7c4acf5c0e","path":"Sources/Sentry/Profiling/SentryProfilerState.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c97c721f87e21f23b35f49ab3480ddd9","path":"Sources/Sentry/include/SentryProfilerState+ObjCpp.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9803b8fbdce4e2ed80e49b511afbfdf7ba","path":"Sources/Sentry/include/SentryProfilerTestHelpers.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9857b858be807d990227d8cc8a467799eb","path":"Sources/Sentry/Profiling/SentryProfilerTestHelpers.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d57d7ff8850a7f68bc5127a2de78e1c6","path":"Sources/Sentry/include/SentryProfileTimeseries.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98cf09f39b101036d9039a5e9fdd5d4910","path":"Sources/Sentry/SentryProfileTimeseries.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980fa47d49e2bb65216878780f3a37d5f1","path":"Sources/Sentry/Public/SentryProfilingConditionals.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98dbbe1e35eb18685486a5c8b6577bbbe9","path":"Sources/Sentry/SentryPropagationContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9888a2658ee08672b053eb8cc3642a4e76","path":"Sources/Sentry/SentryPropagationContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989bdf26ad09027d76fb370df8816c6eb9","path":"Sources/Sentry/include/SentryQueueableRequestManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c2d294613ba90e126869f812c1ab6325","path":"Sources/Sentry/SentryQueueableRequestManager.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983454e6f421a0345f33e633ea310f2406","path":"Sources/Sentry/include/SentryRandom.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980f3118093a755d081ed6850275016cf3","path":"Sources/Sentry/SentryRandom.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a60f227949bd23fe3b07ff7b9601fc22","path":"Sources/Sentry/include/SentryRateLimitParser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fe5ac5ab93a3da03e0e408b697c6a079","path":"Sources/Sentry/SentryRateLimitParser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989d3798a7794bb34165683bfa0463151d","path":"Sources/Sentry/include/SentryRateLimits.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986a01eacf4fd9c5cbbfa18985456424be","path":"Sources/Sentry/include/SentryReachability.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d3daca73881c4a4b4874c14922476273","path":"Sources/Sentry/SentryReachability.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98b22a5ba1237fd87037d77d3641902363","path":"Sources/Swift/Protocol/SentryRedactOptions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9842a47bed079d580cd20989b9075294e3","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985726026a1a1c31e50325233986a3b382","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9805701d4fccb49684aa1b55a60f18fb3c","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayRecording.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e989cc077d015a9f88874f161283b85d1af","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayType.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9857bc555ad325fec19f683147a099c7e1","path":"Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98de5de5142f58314b7d12a2e669c41266","path":"Sources/Sentry/Public/SentryRequest.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9871b012a3b5f1462b9d197c7012741e34","path":"Sources/Sentry/SentryRequest.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985df69af6f60d3c7f31989a95e3f3a9e8","path":"Sources/Sentry/include/SentryRequestManager.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987657341f336ac02acfcf382525f1509c","path":"Sources/Sentry/include/SentryRequestOperation.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d263d257b6329d0a2dfee2e60a97fb5c","path":"Sources/Sentry/SentryRequestOperation.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987942c8ac2f032c0940a6a38ab91bf734","path":"Sources/Sentry/include/SentryRetryAfterHeaderParser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ac9e44632d815a3c81252e6452f71035","path":"Sources/Sentry/SentryRetryAfterHeaderParser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98f50457f417b0f79a0e1b0de834cf9aca","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebBreadcrumbEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98cb2f125ee7e524b3c4b954ba30af5d66","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebCustomEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988d52ddcc81c8bca32b6adcfe81c6b681","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98078862f44140b3e99c42e7d1ab12699b","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebMetaEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9846929a8c54eb68f7ae47aba553065cd4","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebSpanEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98fbbaa747f79a3331a947c4a688bc013a","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebTouchEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98cb215688c9705038d49294cb6576f234","path":"Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cf48b342ad07ad2abb993872033b47d0","path":"Sources/Sentry/include/SentrySample.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983cab1af79491ca23a25ea7c74d671896","path":"Sources/Sentry/Profiling/SentrySample.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984d00b5598fb7c97a8153da8d65037dfa","path":"Sources/Sentry/Public/SentrySampleDecision.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986c69e66e55629c2c42ad5d27ff70b570","path":"Sources/Sentry/SentrySampleDecision.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981142043015590766f312d36cdd419e39","path":"Sources/Sentry/include/SentrySampleDecision+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987eb65cfe85a754fabdf367fc17bec2d2","path":"Sources/Sentry/include/SentrySamplerDecision.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9894d865a928ae3975ad2746f118b4468b","path":"Sources/Sentry/SentrySamplerDecision.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ea213eb10687e172d1d3aded3bd41ae7","path":"Sources/Sentry/include/SentrySampling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98662bd827119a1617f1274adce996caf7","path":"Sources/Sentry/SentrySampling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987d47f3af45086a117a1cc24b2df9365a","path":"Sources/Sentry/Public/SentrySamplingContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989ff077c3356757e30d11f375da1ea1eb","path":"Sources/Sentry/SentrySamplingContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e981a82de91e8f2e5e3932cf0c7756ee711","path":"Sources/Sentry/SentrySamplingProfiler.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e985cd5b426c8a926a07fa8d251d5dab624","path":"Sources/Sentry/include/SentrySamplingProfiler.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988185ba6a88b996493cdf38ca379baabb","path":"Sources/Sentry/Public/SentryScope.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a51dc90ee182f3f234e31a6c3f115461","path":"Sources/Sentry/SentryScope.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98918a5c81a8e0b096accec29a8a6d8bb3","path":"Sources/Sentry/include/SentryScope+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e441058a2a5fac727c3f9be0e75a7e8e","path":"Sources/Sentry/include/SentryScopeObserver.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e98ff6e8cbde607cb070aaf521c95fb961a","path":"Sources/Sentry/SentryScopeSyncC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9881d0f7b202c7e8a98e9936b29655c95d","path":"Sources/Sentry/include/SentryScopeSyncC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e989a369f24a3d33c353bfec1e7f64b3ea6","path":"Sources/Sentry/include/HybridPublic/SentryScreenFrames.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e46fe301c2548cc68a7e96ce8366b59a","path":"Sources/Sentry/SentryScreenFrames.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d09812498914ddbf97a6798491555ab6","path":"Sources/Sentry/include/SentryScreenshot.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e04f95ec1867f216e77231ddd1dda094","path":"Sources/Sentry/SentryScreenshot.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b1ff561b93fc4c5e149438256f956387","path":"Sources/Sentry/include/SentryScreenshotIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989e9f3ce88297e823291af52c5ad53475","path":"Sources/Sentry/SentryScreenshotIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9826acbc0b304b2a0ad2003d840178f0ff","path":"Sources/Sentry/Public/SentrySDK.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980e9d69924caebf6884c96f9ce74bc334","path":"Sources/Sentry/SentrySDK.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c7d38ced97e72f78e89610670b1b3fb3","path":"Sources/Sentry/include/SentrySDK+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984d5ae9898138c992b2021f68ec8babf6","path":"Sources/Sentry/include/SentrySdkInfo.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98faa847346efca28ffffb35376ea3a952","path":"Sources/Sentry/SentrySdkInfo.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cf616d9f6cd818fe3348d29c0c916aa2","path":"Sources/Sentry/Public/SentrySerializable.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e1dd28873beb5b2dcb1af5beb95d271b","path":"Sources/Sentry/include/SentrySerialization.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980d6f259ec16188737b09287bd283b367","path":"Sources/Sentry/SentrySerialization.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f0fafebc44b49d7ff8414673d3cef1c9","path":"Sources/Sentry/include/SentrySession.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980b8b57442bb9cab2603b9a999081af38","path":"Sources/Sentry/SentrySession.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e982116f2e7bb2c8e2ac20df990e0f81236","path":"Sources/Sentry/include/SentrySession+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983c8c985666c5352b6af3c7433239a020","path":"Sources/Sentry/include/SentrySessionCrashedHandler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b12e7dd603ab333ae8345872f96182a8","path":"Sources/Sentry/SentrySessionCrashedHandler.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98928b60c2da7f7825875efa439d557e6b","path":"Sources/Swift/Protocol/SentrySessionListener.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9828b59ccaf8ff87aa8c8c271b9b5bf184","path":"Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98aed94b002bc11a182b8826fa79b3c5e5","path":"Sources/Sentry/include/SentrySessionReplayIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98fa40304186e2c99ee5ccbfc8d92800fc","path":"Sources/Sentry/SentrySessionReplayIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e987cc66d7b02be2cb3d51c5934a525009a","path":"Sources/Sentry/include/SentrySessionReplayIntegration+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ada9c823043bb19207c45e2346ff5a6a","path":"Sources/Sentry/include/HybridPublic/SentrySessionReplayIntegration-Hybrid.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.c","guid":"bfdfe7dc352907fc980b868725387e9894dee7e19fea06bda4dbaaf5c1d9d35b","path":"Sources/Sentry/SentrySessionReplaySyncC.c","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9864e08ba6ae71bf1d1c7b269fd8a8fe0f","path":"Sources/Sentry/include/SentrySessionReplaySyncC.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e28c0aa12b28173b3abf435b1fa19a34","path":"Sources/Sentry/include/SentrySessionTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c0c10fbaa1cb5e98abbbef20d36e7b41","path":"Sources/Sentry/SentrySessionTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c6bf18b7cbf57a94d6c0d96399d10e5c","path":"Sources/Sentry/include/SentrySpan.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d09bbbc9887ced326bec2d6d7246dce1","path":"Sources/Sentry/SentrySpan.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98f21c40f0c349752b7f501e01c22fa48b","path":"Sources/Sentry/include/SentrySpan+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ecbbe5eadcd9001b4c2fc21e078f887a","path":"Sources/Sentry/Public/SentrySpanContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980572088b35705ae125400e5eaf1ca5c0","path":"Sources/Sentry/SentrySpanContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9853c456af0a27c58300581e4f45771983","path":"Sources/Sentry/include/SentrySpanContext+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c593d7d98c929c6a2b0f7d4d0589a1d4","path":"Sources/Sentry/Public/SentrySpanId.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9826f4fe2fca7fe75078c0e9e4e65a2d02","path":"Sources/Sentry/SentrySpanId.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980a3fbb743bd3ac9e28a18b2bd756f025","path":"Sources/Sentry/include/SentrySpanOperations.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9871bde8593bddf6bbbc9f5418fd2e33c4","path":"Sources/Sentry/Public/SentrySpanProtocol.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9878c897661c68cc4ff7868fa62493291e","path":"Sources/Sentry/Public/SentrySpanStatus.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c18eedf9441788560deccc91341f4ef1","path":"Sources/Sentry/SentrySpanStatus.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98786a47c67a0dc6ec0050a48c5a5e4e3d","path":"Sources/Sentry/include/SentrySpotlightTransport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98571366b1f9a87519f69133aa94ea1592","path":"Sources/Sentry/SentrySpotlightTransport.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e985987b898ae3639d21a5bfb374115d528","path":"Sources/Swift/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverter.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e98a1f775d7fcfd5f7d435fe7abffb91fb3","path":"Sources/Sentry/include/SentryStackBounds.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e98ea3cc80c8121c0454193a096db3b0641","path":"Sources/Sentry/include/SentryStackFrame.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988ebb06d6eb20ea09e0d5cce625106b6e","path":"Sources/Sentry/Public/SentryStacktrace.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9803eaef84f8cf151765e8f2dacff23bae","path":"Sources/Sentry/SentryStacktrace.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9863ffcb1f013b44034ba0bfda59953036","path":"Sources/Sentry/include/SentryStacktraceBuilder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985614a6bc35ea979167d2101aaf82ca72","path":"Sources/Sentry/SentryStacktraceBuilder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e70d13d7772d677d98ce5f06ab6a2bc7","path":"Sources/Sentry/include/SentryStatsdClient.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e988a1606defceb762419fa23b4bb125351","path":"Sources/Sentry/SentryStatsdClient.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e05ba937b597322372e39fc0358edb5f","path":"Sources/Sentry/include/SentrySubClassFinder.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989c1b2a8ac0bcddb420b0941ef640d0eb","path":"Sources/Sentry/SentrySubClassFinder.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e984d4ee0056f4fbde7672fb570961a8583","path":"Sources/Sentry/include/SentrySwift.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980bdb04ca4987d86965932ea3417f118b","path":"Sources/Sentry/include/SentrySwiftAsyncIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a09e471bf53c0e8d38c894fb2f23deda","path":"Sources/Sentry/SentrySwiftAsyncIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9864929f6d55b4bf658809924b262e5263","path":"Sources/Sentry/include/HybridPublic/SentrySwizzle.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d97ee0c09e9e900424b1603d10be7375","path":"Sources/Sentry/SentrySwizzle.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9877c9f1b3633c0e198960f68aa0c0810f","path":"Sources/Sentry/include/SentrySwizzleWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98711558a1dd411bb478cd26d8f3f745f2","path":"Sources/Sentry/SentrySwizzleWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98993f9b39cd9919f8ec83374ff13b1328","path":"Sources/Sentry/include/SentrySysctl.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e985deb0edc70afd30a8360f3dc8e856686","path":"Sources/Sentry/SentrySysctl.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ff36fbd967725834366ae2130fb2bed0","path":"Sources/Sentry/include/SentrySystemEventBreadcrumbs.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98a94a3a2ea869ff4381e8499b0d762c37","path":"Sources/Sentry/SentrySystemEventBreadcrumbs.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e988821b58f8dee309405f48b809d77c389","path":"Sources/Sentry/include/SentrySystemWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98e7b36496f0059bd09052fe0723a1482e","path":"Sources/Sentry/SentrySystemWrapper.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9846f8b71aa0d77e70aad6baf2780ede40","path":"Sources/Sentry/Public/SentryThread.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98333f641f00d2f2094339ae17ba284755","path":"Sources/Sentry/SentryThread.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e98f983405d6088b8487df98cbf1cf08289","path":"Sources/Sentry/SentryThreadHandle.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9804a7db4ceccb6811912f7206f78e9447","path":"Sources/Sentry/include/SentryThreadHandle.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9862bc8fb6ad96b53f2b26b863c155ac1e","path":"Sources/Sentry/include/SentryThreadInspector.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9880761a78208e56b6a4707d7016a455cc","path":"Sources/Sentry/SentryThreadInspector.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.cpp","guid":"bfdfe7dc352907fc980b868725387e98bebf1492e8f631b28d50d71933f1372b","path":"Sources/Sentry/SentryThreadMetadataCache.cpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e9826a9f2e728b361a2543a40d1abedebd0","path":"Sources/Sentry/include/SentryThreadMetadataCache.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.h","guid":"bfdfe7dc352907fc980b868725387e98706ef18fd5b8c82cc56ff430d4229cb2","path":"Sources/Sentry/include/SentryThreadState.hpp","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980755b34b65dc8dfadeb1feee1a3cf958","path":"Sources/Sentry/include/SentryThreadWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e980b6ae641edc0eb8755e4ed4a2488c062","path":"Sources/Sentry/SentryThreadWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da71b3b7edd671b3d5c2cd01db469838","path":"Sources/Sentry/include/SentryTime.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e989800de374e76adb4a26b0bcddeecc2fd","path":"Sources/Sentry/SentryTime.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980cb9271b7f81c1a2d6b0f37452893839","path":"Sources/Sentry/include/SentryTimeToDisplayTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c7ad08268e0d328f29de3b10cd63ddc2","path":"Sources/Sentry/SentryTimeToDisplayTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e982a75db7baa0413a63ae20e85adb1d22b","path":"Sources/Swift/Integrations/SessionReplay/SentryTouchTracker.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a2a1ef82a610bc27d79d0c54c67af7f0","path":"Sources/Sentry/Public/SentryTraceContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9887cc63e57392bb0beca2e79621425813","path":"Sources/Sentry/SentryTraceContext.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9892f3522efd5a3f19de8e39f523c688d6","path":"Sources/Sentry/Public/SentryTraceHeader.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98df898a62101ed695fd27677ba8483559","path":"Sources/Sentry/SentryTraceHeader.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c6f2a07c2d050533d09cf4c390de1f8c","path":"Sources/Sentry/include/SentryTraceOrigins.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98b7d38aba29d59ffea50820df6a97c3d1","path":"Sources/Sentry/include/SentryTraceProfiler.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e98d882e4234aaaf8e0069c816fdd59f579","path":"Sources/Sentry/Profiling/SentryTraceProfiler.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981398c4eb84cdf497790fb1fe607e2e63","path":"Sources/Sentry/include/SentryTracer.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e982bf9fe4d6f2326a7fccee18a75faa388","path":"Sources/Sentry/SentryTracer.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98872ffad4b49352b651b261a73dad0060","path":"Sources/Sentry/include/SentryTracer+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9810cd37e42899add31fcc33f4bb739aac","path":"Sources/Sentry/include/SentryTracerConfiguration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e986e709c30b562ffcb2b1eda4a0ad426d4","path":"Sources/Sentry/SentryTracerConfiguration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9866a291b84196da3f36541b841f97bd0e","path":"Sources/Sentry/include/SentryTransaction.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98221eaf92859927540a5d4291e050542e","path":"Sources/Sentry/SentryTransaction.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98da808eab441d9e75217ff3d80a5e32a4","path":"Sources/Sentry/Public/SentryTransactionContext.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.cpp.objcpp","guid":"bfdfe7dc352907fc980b868725387e9841ff7a58c398688fb8514f02d4c41c49","path":"Sources/Sentry/SentryTransactionContext.mm","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98cb1d9988a5b4400c251fe18f3b1dd801","path":"Sources/Sentry/include/SentryTransactionContext+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9836194029038246f4ec8bf1410be089cc","path":"Sources/Swift/Integrations/Performance/SentryTransactionNameSource.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98e2f9ec322d655c0ea0950183b52221d8","path":"Sources/Sentry/include/SentryTransport.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d6a32bf0e617b57a20d2c33c46eb7914","path":"Sources/Sentry/include/SentryTransportAdapter.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e984be035c6e10d36e66be434b1c21a073b","path":"Sources/Sentry/SentryTransportAdapter.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98954fa85d06420d5ec52380486fd5cb33","path":"Sources/Sentry/include/SentryTransportFactory.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b676ca7f4497f4d1fe4138d1f5e18cf7","path":"Sources/Sentry/SentryTransportFactory.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e986a376fd26c1eb7097e69832b6f02467c","path":"Sources/Sentry/include/SentryUIApplication.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98ad8cadda5d19b22d7d5e391f3f2e5773","path":"Sources/Sentry/SentryUIApplication.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9894a25cc56626f69559a458a15ac02efd","path":"Sources/Sentry/include/SentryUIDeviceWrapper.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983ef575e6594a639f646ed7e15b622f05","path":"Sources/Sentry/SentryUIDeviceWrapper.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98d16e83a31b866dde2772c9ce54293036","path":"Sources/Sentry/include/SentryUIEventTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c7648303d42528f7384cd7e96635cb91","path":"Sources/Sentry/SentryUIEventTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98727154a288bb63516c37f191b7f989fc","path":"Sources/Sentry/include/SentryUIEventTrackerMode.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ec06dab8b33576c79bb7f8e7c442836d","path":"Sources/Sentry/include/SentryUIEventTrackerTransactionMode.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e4854f9e24894360225810635de170d4","path":"Sources/Sentry/SentryUIEventTrackerTransactionMode.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98521d00d65862188e87f09af5fdd2b143","path":"Sources/Sentry/include/SentryUIEventTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c9a689aa4dad67a6b1ca280f2f6ebea4","path":"Sources/Sentry/SentryUIEventTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980e3a99429d2074eae3ed0f01f406b861","path":"Sources/Sentry/include/SentryUIViewControllerPerformanceTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e9845042217e88518e3e855ac6610a6bd28","path":"Sources/Sentry/SentryUIViewControllerPerformanceTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9889e3af4a95fcd5340aa42b761c61304c","path":"Sources/Sentry/include/SentryUIViewControllerSwizzling.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e983176fb557a25d7b21f81988edd0f03bb","path":"Sources/Sentry/SentryUIViewControllerSwizzling.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9893fe513507e3ed2aee8e215050e2e0d7","path":"Sources/Sentry/Public/SentryUser.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e981599944e2dac3b6e693aef6d13475bc3","path":"Sources/Sentry/SentryUser.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98306548f7ff648626ef748253421486b8","path":"Sources/Sentry/include/HybridPublic/SentryUser+Private.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983d67248f1449eda03bef4a9ab05fe6a4","path":"Sources/Sentry/Public/SentryUserFeedback.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98aea6546754779f273d8124b565989345","path":"Sources/Sentry/SentryUserFeedback.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9821d0b241774df222e3b7377348f85f63","path":"Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980bb1557164f81c9e1bfbb7f8363a7867","path":"Sources/Sentry/include/SentryViewHierarchy.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98299ac7ba68df1b240a6b268ff9a86b76","path":"Sources/Sentry/SentryViewHierarchy.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980afc7a0cee689b8418b92fe22b0c3615","path":"Sources/Sentry/include/SentryViewHierarchyIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98b53e903d92672c16fb54d8ff0231cef9","path":"Sources/Sentry/SentryViewHierarchyIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98817181aab5ecfaf90b5b85e17c49dec8","path":"Sources/Swift/Tools/SentryViewPhotographer.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9889f837ea12a895bd661390ab3127aa6d","path":"Sources/Swift/Tools/SentryViewScreenshotProvider.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98831b777b6c8ddefb76a396274e08cf65","path":"Sources/Sentry/include/SentryWatchdogTerminationLogic.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98018b75d110252e6219e0ee545362ac37","path":"Sources/Sentry/SentryWatchdogTerminationLogic.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981b4d68ef1663be58c2c7d79a11afa4df","path":"Sources/Sentry/include/SentryWatchdogTerminationScopeObserver.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98364193a20b48ba275110ac3d6d5e9cda","path":"Sources/Sentry/SentryWatchdogTerminationScopeObserver.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98fd01427898ca74956b565fd99d02dc1c","path":"Sources/Sentry/include/SentryWatchdogTerminationTracker.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e987e91816c36c7362ce51491a1a85d80cd","path":"Sources/Sentry/SentryWatchdogTerminationTracker.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98780c1b9a14bc63cecf76b3990a59877b","path":"Sources/Sentry/include/SentryWatchdogTerminationTrackingIntegration.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98491b83da9ba737ee9f38229ad6e420d5","path":"Sources/Sentry/SentryWatchdogTerminationTrackingIntegration.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ed78e714a1400e941f04e4a22e3d5b2e","path":"Sources/Sentry/Public/SentryWithoutUIKit.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e981fe228c1e3eb9e98e34f508e02afeb4d","path":"Sources/Swift/Metrics/SetMetric.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98fa7dc020d34d966016e6329a66dd8e9f","path":"Sources/Swift/Extensions/StringExtensions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d9c9b6d5fc746b0b6e694918a128dda3","path":"Sources/Swift/SwiftDescriptor.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98cb607a81a62d906f3b39c57798758ac5","path":"Sources/Swift/Tools/UIImageHelper.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e988bedd1aa102f8e0c2b086358fe8c4265","path":"Sources/Swift/Tools/UIRedactBuilder.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98199af03d7bbe972473ba296e49e9e28f","path":"Sources/Sentry/include/UIViewController+Sentry.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c67c9dd02046243d9509ed2cd4798160","path":"Sources/Sentry/UIViewController+Sentry.m","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98a9bdf6e3af1025f342b74ed83538e99f","path":"Sources/Swift/Extensions/UIViewExtensions.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9847561447b26da1538d79830f9f25d534","path":"Sources/Swift/Tools/UrlSanitized.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e0f7059daf81ae00dbaea4f94fd0940e","path":"Sources/Swift/Tools/URLSessionTaskHelper.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"text.xml","guid":"bfdfe7dc352907fc980b868725387e98720140f27bb1633c64c1b1da36ff7150","path":"Sources/Resources/PrivacyInfo.xcprivacy","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e984e8e4b6b4cdf0c8e787fc62391dd92fc","name":"Resources","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98fde7c7fa201b545fa7346c2dbc65d01e","name":"HybridSDK","path":"","sourceTree":"","type":"group"},{"children":[{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e98ef1bcc008e87a9a25e802f910bd3b338","path":"ResourceBundle-Sentry-Sentry-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98379c05280a321bf688329fe3aef3105b","path":"Sentry.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98743f2bd1841f8f09decb589fed2636c8","path":"Sentry-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e983b019ef6e78b06a8a837bc6d70936ca6","path":"Sentry-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98a244d1195fc83689d6ce3ffc5e36f6e2","path":"Sentry-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e983938971700c25e06de2a31eeb4ea8038","path":"Sentry-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98008eb440e134dedec2875572805c0b95","path":"Sentry.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9802292b204c9bad4ac7f8961219de2b07","path":"Sentry.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e986fb9ef72a20518718c75331fd4883cdd","name":"Support Files","path":"../Target Support Files/Sentry","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e982359f49a15cf54f5a5642b64cc3eb2e3","name":"Sentry","path":"Sentry","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d16ccbe7206fb0e2ba6426ac4ec38dd9","path":"SwiftyGif/NSImage+SwiftyGif.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9889573bb4021c2391fbff0a825ae4178b","path":"SwiftyGif/NSImageView+SwiftyGif.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e9835fe91271911430f451a39e60aa1986c","path":"SwiftyGif/ObjcAssociatedWeakObject.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98ee06371f21d2935a5ee20d4d5bb4e44f","path":"SwiftyGif/SwiftyGif.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98e7350beaaae8ffbf946713feea0a4648","path":"SwiftyGif/SwiftyGifManager.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98595dbe4b870633814d56f32dc4618746","path":"SwiftyGif/UIImage+SwiftyGif.swift","sourceTree":"","type":"file"},{"fileType":"sourcecode.swift","guid":"bfdfe7dc352907fc980b868725387e98d770d4ce570ae939fbded8f93e00ff8e","path":"SwiftyGif/UIImageView+SwiftyGif.swift","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e9875483e8f93a2b38f9b1fe471ba33c3a7","path":"SwiftyGif.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98c6490049da5fff353f3196233e5aa86b","path":"SwiftyGif-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e988ad7a0fe1ae6306e9a821e4ed13fff44","path":"SwiftyGif-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e981915be642f44f92bbb0c663859b70dfe","path":"SwiftyGif-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e980626803cd8030a41b813ca8f28fffea0","path":"SwiftyGif-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ed5df08130fde981e5066c74824ca0ad","path":"SwiftyGif.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98be3854286ad572fdc485c3f4251ca09f","path":"SwiftyGif.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e98810193ceb3c555979788ed2a5c372602","name":"Support Files","path":"../Target Support Files/SwiftyGif","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e980ae2061b06cbc4928ef7854c13f38740","name":"SwiftyGif","path":"SwiftyGif","sourceTree":"","type":"group"},{"children":[{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98632304d423ab2bf91c956eac7a76a9c7","path":"Toast-Framework/Toast.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9890a726412a53fe90ba2295a50dfd22a2","path":"Toast/UIView+Toast.h","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e989752157b2cd16354797296f760ae1aef","path":"Toast/UIView+Toast.m","sourceTree":"","type":"file"},{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e989938462c628741b000a566d13b66d51d","path":"Toast.modulemap","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98e3d5685abb47d94d856240e992947bc4","path":"Toast-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e989038dc03edeabc512af644ec374031bf","path":"Toast-Info.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e985032ba3070912dc548c2a9e4e7a902a9","path":"Toast-prefix.pch","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e98c66dfb77b319009b7b9b119ff175df41","path":"Toast-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e9863fe2c666a46f95fbbe4c0f7414d7d8b","path":"Toast.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98ca2bbfca057495e362d7fe67afdfd6b9","path":"Toast.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e987533233d249f8595cd310d0355eed423","name":"Support Files","path":"../Target Support Files/Toast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e984ea7b243f231f45db36c593b6182c199","name":"Toast","path":"Toast","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e988cfe4b62ae2e73f7f7be4602344a0f59","name":"Pods","path":"","sourceTree":"","type":"group"},{"guid":"bfdfe7dc352907fc980b868725387e9879aabb07c832697b70c102efdf5309db","name":"Products","path":"","sourceTree":"","type":"group"},{"children":[{"children":[{"fileType":"sourcecode.module-map","guid":"bfdfe7dc352907fc980b868725387e98bc9b33687cf974a825db433d5a4ce66f","path":"Pods-Runner.modulemap","sourceTree":"","type":"file"},{"fileType":"text","guid":"bfdfe7dc352907fc980b868725387e987edd064bed57826d8d017ae149a3d52e","path":"Pods-Runner-acknowledgements.markdown","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e987fb378bbcd6c5ed58c790a44f8de1ad8","path":"Pods-Runner-acknowledgements.plist","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.objc","guid":"bfdfe7dc352907fc980b868725387e98d314b0e0b0622c4b6805a741f2686226","path":"Pods-Runner-dummy.m","sourceTree":"","type":"file"},{"fileType":"text.script.sh","guid":"bfdfe7dc352907fc980b868725387e98c868b1c7c8b6f1d4f98ebdaeb296a675","path":"Pods-Runner-frameworks.sh","sourceTree":"","type":"file"},{"fileType":"text.plist.xml","guid":"bfdfe7dc352907fc980b868725387e986fd78883b66d5d9077a96975628773ce","path":"Pods-Runner-Info.plist","sourceTree":"","type":"file"},{"fileType":"text.script.sh","guid":"bfdfe7dc352907fc980b868725387e983a42af4f91479a2ff6a94702f9452725","path":"Pods-Runner-resources.sh","sourceTree":"","type":"file"},{"fileType":"sourcecode.c.h","guid":"bfdfe7dc352907fc980b868725387e9847c36cf1fe2165ccb62332bdeff26348","path":"Pods-Runner-umbrella.h","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e985f6ec3891a0be6525111807007bbcf76","path":"Pods-Runner.debug.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e988db807c74690161e03dc575ee7464945","path":"Pods-Runner.profile.xcconfig","sourceTree":"","type":"file"},{"fileType":"text.xcconfig","guid":"bfdfe7dc352907fc980b868725387e98778a51cd43710d278531fd4c4e5ed80f","path":"Pods-Runner.release.xcconfig","sourceTree":"","type":"file"}],"guid":"bfdfe7dc352907fc980b868725387e9863ac39dbb3c5e6697e1e483c96fdcf12","name":"Pods-Runner","path":"Target Support Files/Pods-Runner","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98d87df373aa945c7cffc56572b5b5c1d0","name":"Targets Support Files","path":"","sourceTree":"","type":"group"}],"guid":"bfdfe7dc352907fc980b868725387e98677e601b37074db53aff90e47c8f96d1","name":"Pods","path":"","sourceTree":"","type":"group"},"guid":"bfdfe7dc352907fc980b868725387e98","path":"/Users/morn/dev/samples/AppFlowy/frontend/appflowy_flutter/ios/Pods/Pods.xcodeproj","projectDirectory":"/Users/morn/dev/samples/AppFlowy/frontend/appflowy_flutter/ios/Pods","targets":["TARGET@v11_hash=8afac9aa7f2f7ae8a47d13626813e422","TARGET@v11_hash=dba02e4185326fadc962f9bcc306afff","TARGET@v11_hash=9ed764e6a36ac0463803ed04679a098b","TARGET@v11_hash=343abb3a1787caba848689df913b48cc","TARGET@v11_hash=18cc54ce823da0aaace1f19b54169b79","TARGET@v11_hash=455e18a547e744c268f0ab0be67b484f","TARGET@v11_hash=ce18edbb47580206399cf4783eec7ed5","TARGET@v11_hash=50777e38e58c4490a53094c0b174a83e","TARGET@v11_hash=be4bfd549192ab886b16634e611a5cfb","TARGET@v11_hash=bc8a844879af7a4b46ae41879f040474","TARGET@v11_hash=41f53d07703b6cdfcf27ef7c9560cf8c","TARGET@v11_hash=fe89e7ef4549c341d03d86fb3e6322bd","TARGET@v11_hash=8fb782e24ce265c815ff1b320853f917","TARGET@v11_hash=532913e38c7e06e5eea62089d1193ee4","TARGET@v11_hash=3c05d2ce5e305ec83a99f3301a5236aa","TARGET@v11_hash=a588ecdf5bfe2f1ad6c5d9a6bb9940f1","TARGET@v11_hash=c433bd69b99230b9785c1be714ce0175","TARGET@v11_hash=2438ab2bbd7e02a80b06e65e631e1ee9","TARGET@v11_hash=559c3084339e631943ea8fbb0ff14658","TARGET@v11_hash=78c419d7e36f388dac9ad87ec6534e43","TARGET@v11_hash=72545b20ee6d4e64d463a237167e469f","TARGET@v11_hash=7931e16ef4631bcfa5b05077cd140cef","TARGET@v11_hash=91393af516387dfbbafa2eb5029109fc","TARGET@v11_hash=c51f85455c2588dcd567c74a4396fcbf","TARGET@v11_hash=a1a63d5178cbcd2daae2e0cba9b032e5","TARGET@v11_hash=03dea4a492a969d9433ed28b4b2a0aec","TARGET@v11_hash=1dcf7cc21e4184e0f28a9789b4c382c9","TARGET@v11_hash=eaabb77f2569c0713fe5909f5362b3fa","TARGET@v11_hash=817de712cb6fac2be24baa7ec42aaf97","TARGET@v11_hash=fbd6377f91e5f0cc1620995c99b99ff0","TARGET@v11_hash=b5817aa8a8a5b233abd08d304efe013d","TARGET@v11_hash=86aab23948c9cd257baaff836f7414a1","TARGET@v11_hash=29ec1227ae80fa5e85545dce343417e5","TARGET@v11_hash=ab8b0fc009ec3b369e9ae605936ce603","TARGET@v11_hash=64039072b063670902e1ef354134e49d","TARGET@v11_hash=5449496bc380a949b05257eb8db9d316","TARGET@v11_hash=3cca9ca389e095b67ce3af588be9188d","TARGET@v11_hash=f78ac38fdf215d89c0281e470c44b101","TARGET@v11_hash=692a56120a7530dd608fbaa413d3d410","TARGET@v11_hash=bd3c446e66dacbac35fda866591052c9","TARGET@v11_hash=7d8a079c75bc93528df1276ff6c1a06e","TARGET@v11_hash=22014fcd8061c49ec1a7011011fa29d2","TARGET@v11_hash=0c4ac04efd08ba24acda74bf403c30fe","TARGET@v11_hash=6d6f324e26347bf163f3e2dcaa278075","TARGET@v11_hash=36e48dc34e49b20eaf26c3a4d1213a82","TARGET@v11_hash=583d53cd439ec89e8ee070a321e88f4e","TARGET@v11_hash=65477760b7bea77bb4f50c90f24afed5","TARGET@v11_hash=40f8368e8026f113aa896b4bd218efee","TARGET@v11_hash=78da11ebe0789216e22d2e6aaa220c0a"]} ================================================ FILE: frontend/appflowy_flutter/macos/build/ios/XCBuildData/PIFCache/workspace/WORKSPACE@v11_hash=(null)_subobjects=9b6915bad2214bcc5eb58b855fe7b55a-json ================================================ {"guid":"dc4b70c03e8043e50e38f2068887b1d4","name":"Pods","path":"/Users/morn/dev/samples/AppFlowy/frontend/appflowy_flutter/ios/Pods/Pods.xcodeproj/project.xcworkspace","projects":["PROJECT@v11_mod=a7fbf46937053896f73cc7c7ec6baefb_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1"]} ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # 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/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .packages .pub-cache/ .pub/ build/ # Android related **/android/**/gradle-wrapper.jar **/android/.gradle **/android/captures/ **/android/gradlew **/android/gradlew.bat **/android/local.properties **/android/**/GeneratedPluginRegistrant.java # iOS/XCode related **/ios/**/*.mode1v3 **/ios/**/*.mode2v3 **/ios/**/*.moved-aside **/ios/**/*.pbxuser **/ios/**/*.perspectivev3 **/ios/**/*sync/ **/ios/**/.sconsign.dblite **/ios/**/.tags* **/ios/**/.vagrant/ **/ios/**/DerivedData/ **/ios/**/Icon? **/ios/**/Pods/ **/ios/**/.symlinks/ **/ios/**/profile **/ios/**/xcuserdata **/ios/.generated/ **/ios/Flutter/App.framework **/ios/Flutter/Flutter.framework **/ios/Flutter/Flutter.podspec **/ios/Flutter/Generated.xcconfig **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ **/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !**/ios/**/default.mode1v3 !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 **/*.dylib **/*.a **/*.lib **/*.dll **/*.so lib/protobuf lib/dispatch/dart_event ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/.metadata ================================================ # 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. version: revision: 135454af32477f815a7525073027a3ff9eff1bfd channel: stable project_type: plugin # Tracks metadata for the flutter migrate command migration: platforms: - platform: root create_revision: 135454af32477f815a7525073027a3ff9eff1bfd base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - platform: android create_revision: 135454af32477f815a7525073027a3ff9eff1bfd base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - platform: ios create_revision: 135454af32477f815a7525073027a3ff9eff1bfd base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - platform: macos create_revision: 135454af32477f815a7525073027a3ff9eff1bfd base_revision: 135454af32477f815a7525073027a3ff9eff1bfd - platform: windows create_revision: 135454af32477f815a7525073027a3ff9eff1bfd base_revision: 135454af32477f815a7525073027a3ff9eff1bfd # 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' ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/CHANGELOG.md ================================================ ## 0.0.1 * TODO: Describe initial release. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/LICENSE ================================================ TODO: Add your license here. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/README.md ================================================ # appflowy_backend A new flutter plugin project. ## Getting Started This project is a starting point for a Flutter [plug-in package](https://flutter.dev/developing-packages/), a specialized package that includes platform-specific implementation code for Android and/or iOS. For help getting started with Flutter, view our [online documentation](https://flutter.dev/docs), which offers tutorials, samples, guidance on mobile development, and a full API reference. The plugin project was generated without specifying the `--platforms` flag, no platforms are currently supported. To add platforms, run `flutter create -t plugin --platforms .` under the same directory. You can also find a detailed instruction on how to add platforms in the `pubspec.yaml` at https://flutter.dev/docs/development/packages-and-plugins/developing-packages#plugin-platforms. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/analysis_options.yaml ================================================ analyzer: exclude: - "**/*.g.dart" - "**/*.pb.dart" - "**/*.freezed.dart" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/android/.gitignore ================================================ *.iml .gradle /local.properties /.idea/workspace.xml /.idea/libraries .DS_Store /build /captures ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/android/build.gradle ================================================ group 'com.plugin.appflowy_backend' version '1.0-SNAPSHOT' buildscript { ext.kotlin_version = '1.8.0' repositories { google() jcenter() } dependencies { classpath 'com.android.tools.build:gradle:8.1.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } rootProject.allprojects { repositories { google() jcenter() } } apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { compileSdkVersion 33 namespace 'com.plugin.appflowy_backend' compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { minSdkVersion 23 } } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/android/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/android/settings.gradle ================================================ rootProject.name = 'appflowy_backend' ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/android/src/main/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/android/src/main/kotlin/com/plugin/appflowy_backend/AppFlowyBackendPlugin.kt ================================================ package com.plugin.appflowy_backend import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry.Registrar /** AppFlowyBackendPlugin */ class AppFlowyBackendPlugin: FlutterPlugin, MethodCallHandler { /// The MethodChannel that will the communication between Flutter and native Android /// /// This local reference serves to register the plugin with the Flutter Engine and unregister it /// when the Flutter Engine is detached from the Activity private lateinit var channel : MethodChannel override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "appflowy_backend") channel.setMethodCallHandler(this) } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { if (call.method == "getPlatformVersion") { result.success("Android ${android.os.Build.VERSION.RELEASE}") } else { result.notImplemented() } } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # 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 .packages .pub-cache/ .pub/ /build/ # Web related lib/generated_plugin_registrant.dart # 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 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/.metadata ================================================ # 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: 63062a64432cce03315d6b5196fda7912866eb37 channel: dev project_type: app ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/README.md ================================================ # flowy_sdk_example Demonstrates how to use the appflowy_backend plugin. ## Getting Started This project is a starting point for a Flutter application. A few resources to get you started if this is your first Flutter project: - [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) - [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) For help getting started with Flutter, view our [online documentation](https://flutter.dev/docs), which offers tutorials, samples, guidance on mobile development, and a full API reference. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/analysis_options.yaml ================================================ include: ../analysis_options.yaml analyzer: exclude: ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/.gitignore ================================================ gradle-wrapper.jar /.gradle /captures/ /gradlew /gradlew.bat /local.properties GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app key.properties ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/build.gradle ================================================ def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) } } def flutterRoot = localProperties.getProperty('flutter.sdk') if (flutterRoot == null) { throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { flutterVersionName = '1.0' } apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { compileSdkVersion 30 sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.plugin.flowy_sdk_example" minSdkVersion 33 targetSdkVersion 33 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } } flutter { source '../..' } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/kotlin/com/plugin/flowy_sdk_example/MainActivity.kt ================================================ package com.plugin.flowy_sdk_example import io.flutter.embedding.android.FlutterActivity class MainActivity: FlutterActivity() { } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/drawable/launch_background.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/drawable-v21/launch_background.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/values/styles.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/values-night/styles.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/profile/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/build.gradle ================================================ buildscript { ext.kotlin_version = '1.6.10' repositories { google() jcenter() } dependencies { classpath 'com.android.tools.build:gradle:4.1.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() jcenter() } } rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } subprojects { project.evaluationDependsOn(':app') } task clean(type: Delete) { delete rootProject.buildDir } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/gradle/wrapper/gradle-wrapper.properties ================================================ #Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/android/settings.gradle ================================================ include ':app' def localPropertiesFile = new File(rootProject.projectDir, "local.properties") def properties = new Properties() assert localPropertiesFile.exists() localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/integration_test/app_test.dart ================================================ // This is a basic Flutter integration test. // // To perform an interaction with a widget in your test, use the WidgetTester // utility that Flutter provides. For example, you can send tap and scroll // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. // import 'package:flutter/material.dart'; // import 'package:flutter_test/flutter_test.dart'; // import 'package:integration_test/integration_test.dart'; // import 'package:flowy_sdk_example/main.dart' as app; // void main() => run(_testMain); // void _testMain() { // testWidgets('Counter increments smoke test', (WidgetTester tester) async { // // Build our app and trigger a frame. // app.main(); // // Trigger a frame. // await tester.pumpAndSettle(); // // Verify that platform version is retrieved. // expect( // find.byWidgetPredicate( // (Widget widget) => widget is Text && // widget.data.startsWith('Running on:'), // ), // findsOneWidget, // ); // }); // } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/integration_test/driver.dart ================================================ // This file is provided as a convenience for running integration tests via the // flutter drive command. // // flutter drive --driver integration_test/driver.dart --target integration_test/app_test.dart ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/.gitignore ================================================ *.mode1v3 *.mode2v3 *.moved-aside *.pbxuser *.perspectivev3 **/*sync/ .sconsign.dblite .tags* **/.vagrant/ **/DerivedData/ Icon? **/Pods/ **/.symlinks/ profile xcuserdata **/.generated/ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !default.mode1v3 !default.mode2v3 !default.pbxuser !default.perspectivev3 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Flutter/AppFrameworkInfo.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable App CFBundleIdentifier io.flutter.flutter.app CFBundleInfoDictionaryVersion 6.0 CFBundleName App CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1.0 MinimumOSVersion 8.0 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Flutter/Debug.xcconfig ================================================ #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Flutter/Release.xcconfig ================================================ #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Podfile ================================================ # Uncomment this line to define a global platform for your project # platform :ios, '9.0' # 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', '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 Generated.xcconfig, then run flutter pub get" end require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) flutter_ios_podfile_setup target 'Runner' do use_frameworks! use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) end end ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/AppDelegate.swift ================================================ import UIKit import Flutter @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@3x.png", "scale" : "3x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@3x.png", "scale" : "3x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@3x.png", "scale" : "3x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@2x.png", "scale" : "2x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@3x.png", "scale" : "3x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@1x.png", "scale" : "1x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@1x.png", "scale" : "1x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@1x.png", "scale" : "1x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@2x.png", "scale" : "2x" }, { "size" : "83.5x83.5", "idiom" : "ipad", "filename" : "Icon-App-83.5x83.5@2x.png", "scale" : "2x" }, { "size" : "1024x1024", "idiom" : "ios-marketing", "filename" : "Icon-App-1024x1024@1x.png", "scale" : "1x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LaunchImage.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LaunchImage@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md ================================================ # Launch Screen Assets You can customize the launch screen with your own desired assets by replacing the image files in this directory. You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Base.lproj/Main.storyboard ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName flowy_sdk_example CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleSignature ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Runner-Bridging-Header.h ================================================ #import "GeneratedPluginRegistrant.h" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 46; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); name = Flutter; sourceTree = ""; }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, ); sourceTree = ""; }; 97C146EF1CF9000F007C117D /* Products */ = { isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, ); name = Products; sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, ); buildRules = ( ); dependencies = ( ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { LastUpgradeCheck = 1020; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 97C146FB1CF9000F007C117D /* Base */, ); name = Main.storyboard; sourceTree = ""; }; 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 97C147001CF9000F007C117D /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Profile; }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.plugin.flowySdkExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; 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_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; 97C147061CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.plugin.flowySdkExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 97C147071CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.plugin.flowySdkExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147031CF9000F007C117D /* Debug */, 97C147041CF9000F007C117D /* Release */, 249021D3217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147061CF9000F007C117D /* Debug */, 97C147071CF9000F007C117D /* Release */, 249021D4217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/lib/main.dart ================================================ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; void main() { runApp(MyApp()); } class MyApp extends StatefulWidget { @override _MyAppState createState() => _MyAppState(); } class _MyAppState extends State { String _platformVersion = 'Unknown'; @override void initState() { super.initState(); initPlatformState(); } // Platform messages are asynchronous, so we initialize in an async method. Future initPlatformState() async { String platformVersion; // Platform messages may fail, so we use a try/catch PlatformException. try { platformVersion = await FlowySDK.platformVersion; } on PlatformException { platformVersion = 'Failed to get platform version.'; } // If the widget was removed from the tree while the asynchronous platform // message was in flight, we want to discard the reply rather than calling // setState to update our non-existent appearance. if (!mounted) return; setState(() => _platformVersion = platformVersion); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar(title: const Text('Plugin example app')), body: Center(child: Text('Running on: $_platformVersion\n')), ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/.gitignore ================================================ # Flutter-related **/Flutter/ephemeral/ **/Pods/ # Xcode-related **/xcuserdata/ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Flutter/Flutter-Debug.xcconfig ================================================ #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Flutter/Flutter-Release.xcconfig ================================================ #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile ================================================ platform :osx, '10.14' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' project 'Runner', { 'Debug' => :debug, 'Profile' => :release, 'Release' => :release, } def parse_KV_file(file, separator='=') file_abs_path = File.expand_path(file) if !File.exists? file_abs_path return []; end pods_ary = [] skip_line_start_symbols = ["#", "/"] File.foreach(file_abs_path) { |line| next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } plugin = line.split(pattern=separator) if plugin.length == 2 podname = plugin[0].strip() path = plugin[1].strip() podpath = File.expand_path("#{path}", file_abs_path) pods_ary.push({:name => podname, :path => podpath}); else puts "Invalid plugin specification: #{line}" end } return pods_ary end def pubspec_supports_macos(file) file_abs_path = File.expand_path(file) if !File.exists? file_abs_path return false; end File.foreach(file_abs_path) { |line| return true if line =~ /^\s*macos:/ } return false end target 'Runner' do use_frameworks! use_modular_headers! # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock # referring to absolute paths on developers' machines. ephemeral_dir = File.join('Flutter', 'ephemeral') symlink_dir = File.join(ephemeral_dir, '.symlinks') symlink_plugins_dir = File.join(symlink_dir, 'plugins') system("rm -rf #{symlink_dir}") system("mkdir -p #{symlink_plugins_dir}") # Flutter Pods generated_xcconfig = parse_KV_file(File.join(ephemeral_dir, 'Flutter-Generated.xcconfig')) if generated_xcconfig.empty? puts "Flutter-Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first." end generated_xcconfig.map { |p| if p[:name] == 'FLUTTER_FRAMEWORK_DIR' symlink = File.join(symlink_dir, 'flutter') File.symlink(File.dirname(p[:path]), symlink) pod 'FlutterMacOS', :path => File.join(symlink, File.basename(p[:path])) end } # Plugin Pods plugin_pods = parse_KV_file('../.flutter-plugins') plugin_pods.map { |p| symlink = File.join(symlink_plugins_dir, p[:name]) File.symlink(p[:path], symlink) if pubspec_supports_macos(File.join(symlink, 'pubspec.yaml')) pod p[:name], :path => File.join(symlink, 'macos') end } end # Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. install! 'cocoapods', :disable_input_output_paths => true ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/AppDelegate.swift ================================================ import Cocoa import FlutterMacOS @main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "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" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Base.lproj/MainMenu.xib ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/AppInfo.xcconfig ================================================ // 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 = flowy_sdk_example // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.plugin.flowySdkExample // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2021 com.plugin. All rights reserved. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/Debug.xcconfig ================================================ #include "../../Flutter/Flutter-Debug.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/Release.xcconfig ================================================ #include "../../Flutter/Flutter-Release.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/Warnings.xcconfig ================================================ 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 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/DebugProfile.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.cs.allow-jit com.apple.security.network.server ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Info.plist ================================================ 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 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/MainFlutterWindow.swift ================================================ import Cocoa import FlutterMacOS class MainFlutterWindow: NSWindow { override func awakeFromNib() { let flutterViewController = FlutterViewController.init() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Release.entitlements ================================================ com.apple.security.app-sandbox ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj ================================================ // !$*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 */ 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 */; }; B7C2E82907836001B5A6F548 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 49D7864808B727FDFB82A4C2 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 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 */ 0E4B7400A641C13F34C1A73C /* 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 = ""; }; 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 /* flowy_sdk_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flowy_sdk_example.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 = ""; }; 4959329ACC9EF83FB15AC0E5 /* 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 = ""; }; 49D7864808B727FDFB82A4C2 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; A82DF8E6F43DF0AD4D0653DC /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 33CC10EA2044A3C60003C045 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( B7C2E82907836001B5A6F548 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 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 */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, 426A48CDE70CED9D3E9185FC /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* flowy_sdk_example.app */, ); 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 = ""; }; 426A48CDE70CED9D3E9185FC /* Pods */ = { isa = PBXGroup; children = ( A82DF8E6F43DF0AD4D0653DC /* Pods-Runner.debug.xcconfig */, 4959329ACC9EF83FB15AC0E5 /* Pods-Runner.release.xcconfig */, 0E4B7400A641C13F34C1A73C /* Pods-Runner.profile.xcconfig */, ); name = Pods; path = Pods; sourceTree = ""; }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( 49D7864808B727FDFB82A4C2 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 33CC10EC2044A3C60003C045 /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 40E676EAFF7C261E0E226351 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, 648FD3193605213263C327B2 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* flowy_sdk_example.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; ProvisioningStyle = Automatic; 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 */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 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 */ 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"; }; 40E676EAFF7C261E0E226351 /* [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; }; 648FD3193605213263C327B2 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 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 */ 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 */ 338D0CE9231458BD00FA5F75 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; 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; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; 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_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter/ephemeral", ); 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; 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; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; 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; 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; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; 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_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter/ephemeral", ); 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_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter/ephemeral", ); 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 */ 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 */; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/pubspec.yaml ================================================ name: flowy_sdk_example description: Demonstrates how to use the appflowy_backend plugin. # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev environment: sdk: ">=2.12.0 <3.0.0" dependencies: flutter: sdk: flutter appflowy_backend: # When depending on this package from a real application you should use: # appflowy_backend: ^x.y.z # See https://dart.dev/tools/pub/dependencies#version-constraints # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.1 dev_dependencies: flutter_test: sdk: flutter integration_test: sdk: flutter flutter_lints: ^3.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter. flutter: # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. # For details regarding adding assets from package dependencies, see # https://flutter.dev/assets-and-images/#from-packages # To add custom fonts to your application, add a fonts section here, # in this "flutter" section. Each entry in this list should have a # "family" key with the font family name, and a "fonts" key with a # list giving the asset and other descriptors for the font. For # example: # fonts: # - family: Schyler # fonts: # - asset: fonts/Schyler-Regular.ttf # - asset: fonts/Schyler-Italic.ttf # style: italic # - family: Trajan Pro # fonts: # - asset: fonts/TrajanPro.ttf # - asset: fonts/TrajanPro_Bold.ttf # weight: 700 # # For details regarding fonts from package dependencies, # see https://flutter.dev/custom-fonts/#from-packages ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/test/widget_test.dart ================================================ // This is a basic Flutter widget test. // // To perform an interaction with a widget in your test, use the WidgetTester // utility that Flutter provides. For example, you can send tap and scroll // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. void main() {} ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/.gitignore ================================================ flutter/ephemeral/ # Visual Studio user-specific files. *.suo *.user *.userosscache *.sln.docstates # Visual Studio build-related files. x64/ x86/ # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) project(flowy_sdk_example LANGUAGES CXX) set(BINARY_NAME "flowy_sdk_example") cmake_policy(SET CMP0063 NEW) set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") # Configure build options. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" CACHE STRING "" FORCE) else() 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() endif() set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") # Use Unicode for all projects. add_definitions(-DUNICODE -D_UNICODE) # Compilation settings that should be applied to most targets. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") target_compile_options(${TARGET} PRIVATE /EHsc) target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") endfunction() set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") # Flutter library and tool build rules. add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build add_subdirectory("runner") # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) # === Installation === # Support files are copied into place next to the executable, so that it can # run in place. This is done instead of making a separate bundle (as on Linux) # so that building and running from within Visual Studio will work. set(BUILD_BUNDLE_DIR "$") # Make the "install" step default, as it's required to run. set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) endif() set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") 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) if(PLUGIN_BUNDLED_LIBRARIES) install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() # 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. install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" CONFIGURATIONS Profile;Release COMPONENT Runtime) ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/flutter/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) 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. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") # 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/windows/app.so" PARENT_SCOPE) list(APPEND FLUTTER_LIBRARY_HEADERS "flutter_export.h" "flutter_windows.h" "flutter_messenger.h" "flutter_plugin_registrar.h" "flutter_texture_registrar.h" ) list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") add_library(flutter INTERFACE) target_include_directories(flutter INTERFACE "${EPHEMERAL_DIR}" ) target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") add_dependencies(flutter flutter_assemble) # === Wrapper === list(APPEND CPP_WRAPPER_SOURCES_CORE "core_implementations.cc" "standard_codec.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_PLUGIN "plugin_registrar.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_APP "flutter_engine.cc" "flutter_view_controller.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") # Wrapper sources needed for a plugin. add_library(flutter_wrapper_plugin STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ) apply_standard_settings(flutter_wrapper_plugin) set_target_properties(flutter_wrapper_plugin PROPERTIES POSITION_INDEPENDENT_CODE ON) set_target_properties(flutter_wrapper_plugin PROPERTIES CXX_VISIBILITY_PRESET hidden) target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) target_include_directories(flutter_wrapper_plugin PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_plugin flutter_assemble) # Wrapper sources needed for the runner. add_library(flutter_wrapper_app STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_APP} ) apply_standard_settings(flutter_wrapper_app) target_link_libraries(flutter_wrapper_app PUBLIC flutter) target_include_directories(flutter_wrapper_app PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_app 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. set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) add_custom_command( OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ${PHONY_OUTPUT} COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" windows-x64 $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS "${FLUTTER_LIBRARY}" ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ) ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) project(runner LANGUAGES CXX) add_executable(${BINARY_NAME} WIN32 "flutter_window.cpp" "main.cpp" "utils.cpp" "win32_window.cpp" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" "Runner.rc" "runner.exe.manifest" ) apply_standard_settings(${BINARY_NAME}) target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") add_dependencies(${BINARY_NAME} flutter_assemble) ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/Runner.rc ================================================ // Microsoft Visual C++ generated resource script. // #pragma code_page(65001) #include "resource.h" #define APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 2 resource. // #include "winres.h" ///////////////////////////////////////////////////////////////////////////// #undef APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // English (United States) resources #if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US #ifdef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // TEXTINCLUDE // 1 TEXTINCLUDE BEGIN "resource.h\0" END 2 TEXTINCLUDE BEGIN "#include ""winres.h""\r\n" "\0" END 3 TEXTINCLUDE BEGIN "\r\n" "\0" END #endif // APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Icon // // Icon with lowest ID value placed first to ensure application icon // remains consistent on all systems. IDI_APP_ICON ICON "resources\\app_icon.ico" ///////////////////////////////////////////////////////////////////////////// // // Version // #ifdef FLUTTER_BUILD_NUMBER #define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER #else #define VERSION_AS_NUMBER 1,0,0 #endif #ifdef FLUTTER_BUILD_NAME #define VERSION_AS_STRING #FLUTTER_BUILD_NAME #else #define VERSION_AS_STRING "1.0.0" #endif VS_VERSION_INFO VERSIONINFO FILEVERSION VERSION_AS_NUMBER PRODUCTVERSION VERSION_AS_NUMBER FILEFLAGSMASK VS_FFI_FILEFLAGSMASK #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG #else FILEFLAGS 0x0L #endif FILEOS VOS__WINDOWS32 FILETYPE VFT_APP FILESUBTYPE 0x0L BEGIN BLOCK "StringFileInfo" BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "com.plugin" "\0" VALUE "FileDescription", "Demonstrates how to use the appflowy_backend plugin." "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "flowy_sdk_example" "\0" VALUE "LegalCopyright", "Copyright (C) 2021 com.plugin. All rights reserved." "\0" VALUE "OriginalFilename", "flowy_sdk_example.exe" "\0" VALUE "ProductName", "flowy_sdk_example" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END BLOCK "VarFileInfo" BEGIN VALUE "Translation", 0x409, 1252 END END #endif // English (United States) resources ///////////////////////////////////////////////////////////////////////////// #ifndef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 3 resource. // ///////////////////////////////////////////////////////////////////////////// #endif // not APSTUDIO_INVOKED ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/flutter_window.cpp ================================================ #include "flutter_window.h" #include #include "flutter/generated_plugin_registrant.h" FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} FlutterWindow::~FlutterWindow() {} bool FlutterWindow::OnCreate() { if (!Win32Window::OnCreate()) { return false; } RECT frame = GetClientArea(); // The size here must match the window dimensions to avoid unnecessary surface // creation / destruction in the startup path. flutter_controller_ = std::make_unique( frame.right - frame.left, frame.bottom - frame.top, project_); // Ensure that basic setup of the controller was successful. if (!flutter_controller_->engine() || !flutter_controller_->view()) { return false; } RegisterPlugins(flutter_controller_->engine()); SetChildContent(flutter_controller_->view()->GetNativeWindow()); return true; } void FlutterWindow::OnDestroy() { if (flutter_controller_) { flutter_controller_ = nullptr; } Win32Window::OnDestroy(); } LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam); if (result) { return *result; } } switch (message) { case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; } return Win32Window::MessageHandler(hwnd, message, wparam, lparam); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/flutter_window.h ================================================ #ifndef RUNNER_FLUTTER_WINDOW_H_ #define RUNNER_FLUTTER_WINDOW_H_ #include #include #include #include "win32_window.h" // A window that does nothing but host a Flutter view. class FlutterWindow : public Win32Window { public: // Creates a new FlutterWindow hosting a Flutter view running |project|. explicit FlutterWindow(const flutter::DartProject& project); virtual ~FlutterWindow(); protected: // Win32Window: bool OnCreate() override; void OnDestroy() override; LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept override; private: // The project to run. flutter::DartProject project_; // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; }; #endif // RUNNER_FLUTTER_WINDOW_H_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/main.cpp ================================================ #include #include #include #include "flutter_window.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { CreateAndAttachConsole(); } // Initialize COM, so that it is available for use in the library and/or // plugins. ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); if (!window.CreateAndShow(L"flowy_sdk_example", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); ::MSG msg; while (::GetMessage(&msg, nullptr, 0, 0)) { ::TranslateMessage(&msg); ::DispatchMessage(&msg); } ::CoUninitialize(); return EXIT_SUCCESS; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/resource.h ================================================ //{{NO_DEPENDENCIES}} // Microsoft Visual C++ generated include file. // Used by Runner.rc // #define IDI_APP_ICON 101 // Next default values for new objects // #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 102 #define _APS_NEXT_COMMAND_VALUE 40001 #define _APS_NEXT_CONTROL_VALUE 1001 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/runner.exe.manifest ================================================ PerMonitorV2 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/utils.cpp ================================================ #include "utils.h" #include #include #include #include #include void CreateAndAttachConsole() { if (::AllocConsole()) { FILE *unused; if (freopen_s(&unused, "CONOUT$", "w", stdout)) { _dup2(_fileno(stdout), 1); } if (freopen_s(&unused, "CONOUT$", "w", stderr)) { _dup2(_fileno(stdout), 2); } std::ios::sync_with_stdio(); FlutterDesktopResyncOutputStreams(); } } std::vector GetCommandLineArguments() { // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. int argc; wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); if (argv == nullptr) { return std::vector(); } std::vector command_line_arguments; // Skip the first argument as it's the binary name. for (int i = 1; i < argc; i++) { command_line_arguments.push_back(Utf8FromUtf16(argv[i])); } ::LocalFree(argv); return command_line_arguments; } std::string Utf8FromUtf16(const wchar_t* utf16_string) { if (utf16_string == nullptr) { return std::string(); } int target_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, nullptr, 0, nullptr, nullptr); if (target_length == 0) { return std::string(); } std::string utf8_string; utf8_string.resize(target_length); int converted_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, utf8_string.data(), target_length, nullptr, nullptr); if (converted_length == 0) { return std::string(); } return utf8_string; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/utils.h ================================================ #ifndef RUNNER_UTILS_H_ #define RUNNER_UTILS_H_ #include #include // Creates a console for the process, and redirects stdout and stderr to // it for both the runner and the Flutter library. void CreateAndAttachConsole(); // Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string // encoded in UTF-8. Returns an empty std::string on failure. std::string Utf8FromUtf16(const wchar_t* utf16_string); // Gets the command line arguments passed in as a std::vector, // encoded in UTF-8. Returns an empty std::vector on failure. std::vector GetCommandLineArguments(); #endif // RUNNER_UTILS_H_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/win32_window.cpp ================================================ #include "win32_window.h" #include #include "resource.h" namespace { constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; // The number of Win32Window objects that currently exist. static int g_active_window_count = 0; using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); // Scale helper to convert logical scaler values to physical using passed in // scale factor int Scale(int source, double scale_factor) { return static_cast(source * scale_factor); } // Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. // This API is only needed for PerMonitor V1 awareness mode. void EnableFullDpiSupportIfAvailable(HWND hwnd) { HMODULE user32_module = LoadLibraryA("User32.dll"); if (!user32_module) { return; } auto enable_non_client_dpi_scaling = reinterpret_cast( GetProcAddress(user32_module, "EnableNonClientDpiScaling")); if (enable_non_client_dpi_scaling != nullptr) { enable_non_client_dpi_scaling(hwnd); FreeLibrary(user32_module); } } } // namespace // Manages the Win32Window's window class registration. class WindowClassRegistrar { public: ~WindowClassRegistrar() = default; // Returns the singleton registar instance. static WindowClassRegistrar* GetInstance() { if (!instance_) { instance_ = new WindowClassRegistrar(); } return instance_; } // Returns the name of the window class, registering the class if it hasn't // previously been registered. const wchar_t* GetWindowClass(); // Unregisters the window class. Should only be called if there are no // instances of the window. void UnregisterWindowClass(); private: WindowClassRegistrar() = default; static WindowClassRegistrar* instance_; bool class_registered_ = false; }; WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; const wchar_t* WindowClassRegistrar::GetWindowClass() { if (!class_registered_) { WNDCLASS window_class{}; window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); window_class.lpszClassName = kWindowClassName; window_class.style = CS_HREDRAW | CS_VREDRAW; window_class.cbClsExtra = 0; window_class.cbWndExtra = 0; window_class.hInstance = GetModuleHandle(nullptr); window_class.hIcon = LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); window_class.hbrBackground = 0; window_class.lpszMenuName = nullptr; window_class.lpfnWndProc = Win32Window::WndProc; RegisterClass(&window_class); class_registered_ = true; } return kWindowClassName; } void WindowClassRegistrar::UnregisterWindowClass() { UnregisterClass(kWindowClassName, nullptr); class_registered_ = false; } Win32Window::Win32Window() { ++g_active_window_count; } Win32Window::~Win32Window() { --g_active_window_count; Destroy(); } bool Win32Window::CreateAndShow(const std::wstring& title, const Point& origin, const Size& size) { Destroy(); const wchar_t* window_class = WindowClassRegistrar::GetInstance()->GetWindowClass(); const POINT target_point = {static_cast(origin.x), static_cast(origin.y)}; HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); double scale_factor = dpi / 96.0; HWND window = CreateWindow( window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), Scale(size.width, scale_factor), Scale(size.height, scale_factor), nullptr, nullptr, GetModuleHandle(nullptr), this); if (!window) { return false; } return OnCreate(); } // static LRESULT CALLBACK Win32Window::WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { if (message == WM_NCCREATE) { auto window_struct = reinterpret_cast(lparam); SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast(window_struct->lpCreateParams)); auto that = static_cast(window_struct->lpCreateParams); EnableFullDpiSupportIfAvailable(window); that->window_handle_ = window; } else if (Win32Window* that = GetThisFromHandle(window)) { return that->MessageHandler(window, message, wparam, lparam); } return DefWindowProc(window, message, wparam, lparam); } LRESULT Win32Window::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { switch (message) { case WM_DESTROY: window_handle_ = nullptr; Destroy(); if (quit_on_close_) { PostQuitMessage(0); } return 0; case WM_DPICHANGED: { auto newRectSize = reinterpret_cast(lparam); LONG newWidth = newRectSize->right - newRectSize->left; LONG newHeight = newRectSize->bottom - newRectSize->top; SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, newHeight, SWP_NOZORDER | SWP_NOACTIVATE); return 0; } case WM_SIZE: { RECT rect = GetClientArea(); if (child_content_ != nullptr) { // Size and position the child window. MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, TRUE); } return 0; } case WM_ACTIVATE: if (child_content_ != nullptr) { SetFocus(child_content_); } return 0; } return DefWindowProc(window_handle_, message, wparam, lparam); } void Win32Window::Destroy() { OnDestroy(); if (window_handle_) { DestroyWindow(window_handle_); window_handle_ = nullptr; } if (g_active_window_count == 0) { WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); } } Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { return reinterpret_cast( GetWindowLongPtr(window, GWLP_USERDATA)); } void Win32Window::SetChildContent(HWND content) { child_content_ = content; SetParent(content, window_handle_); RECT frame = GetClientArea(); MoveWindow(content, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, true); SetFocus(child_content_); } RECT Win32Window::GetClientArea() { RECT frame; GetClientRect(window_handle_, &frame); return frame; } HWND Win32Window::GetHandle() { return window_handle_; } void Win32Window::SetQuitOnClose(bool quit_on_close) { quit_on_close_ = quit_on_close; } bool Win32Window::OnCreate() { // No-op; provided for subclasses. return true; } void Win32Window::OnDestroy() { // No-op; provided for subclasses. } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/win32_window.h ================================================ #ifndef RUNNER_WIN32_WINDOW_H_ #define RUNNER_WIN32_WINDOW_H_ #include #include #include #include // A class abstraction for a high DPI-aware Win32 Window. Intended to be // inherited from by classes that wish to specialize with custom // rendering and input handling class Win32Window { public: struct Point { unsigned int x; unsigned int y; Point(unsigned int x, unsigned int y) : x(x), y(y) {} }; struct Size { unsigned int width; unsigned int height; Size(unsigned int width, unsigned int height) : width(width), height(height) {} }; Win32Window(); virtual ~Win32Window(); // Creates and shows a win32 window with |title| and position and size using // |origin| and |size|. New windows are created on the default monitor. Window // sizes are specified to the OS in physical pixels, hence to ensure a // consistent size to will treat the width height passed in to this function // as logical pixels and scale to appropriate for the default monitor. Returns // true if the window was created successfully. bool CreateAndShow(const std::wstring& title, const Point& origin, const Size& size); // Release OS resources associated with window. void Destroy(); // Inserts |content| into the window tree. void SetChildContent(HWND content); // Returns the backing Window handle to enable clients to set icon and other // window properties. Returns nullptr if the window has been destroyed. HWND GetHandle(); // If true, closing this window will quit the application. void SetQuitOnClose(bool quit_on_close); // Return a RECT representing the bounds of the current client area. RECT GetClientArea(); protected: // Processes and route salient window messages for mouse handling, // size change and DPI. Delegates handling of these to member overloads that // inheriting classes can handle. virtual LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Called when CreateAndShow is called, allowing subclass window-related // setup. Subclasses should return false if setup fails. virtual bool OnCreate(); // Called when Destroy is called. virtual void OnDestroy(); private: friend class WindowClassRegistrar; // OS callback called by message pump. Handles the WM_NCCREATE message which // is passed when the non-client area is being created and enables automatic // non-client DPI scaling so that the non-client area automatically // responsponds to changes in DPI. All other messages are handled by // MessageHandler. static LRESULT CALLBACK WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Retrieves a class instance pointer for |window| static Win32Window* GetThisFromHandle(HWND const window) noexcept; bool quit_on_close_ = false; // window handle for top level window. HWND window_handle_ = nullptr; // window handle for hosted content. HWND child_content_ = nullptr; }; #endif // RUNNER_WIN32_WINDOW_H_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/ios/.gitignore ================================================ .idea/ .vagrant/ .sconsign.dblite .svn/ .DS_Store *.swp profile DerivedData/ build/ GeneratedPluginRegistrant.h GeneratedPluginRegistrant.m .generated/ *.pbxuser *.mode1v3 *.mode2v3 *.perspectivev3 !default.pbxuser !default.mode1v3 !default.mode2v3 !default.perspectivev3 xcuserdata *.moved-aside *.pyc *sync/ Icon? .tags* /Flutter/Generated.xcconfig /Flutter/flutter_export_environment.sh ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/ios/Assets/.gitkeep ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.h ================================================ #import @interface AppFlowyBackendPlugin : NSObject @end ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.m ================================================ #import "AppFlowyBackendPlugin.h" #if __has_include() #import #else // Support project import fallback if the generated compatibility header // is not copied when this plugin is created as a library. // https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 #import "appflowy_backend-Swift.h" #endif @implementation AppFlowyBackendPlugin + (void)registerWithRegistrar:(NSObject*)registrar { [SwiftAppFlowyBackendPlugin registerWithRegistrar:registrar]; } @end ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.swift ================================================ import Flutter import UIKit public class SwiftAppFlowyBackendPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "appflowy_backend", binaryMessenger: registrar.messenger()) let instance = SwiftAppFlowyBackendPlugin() registrar.addMethodCallDelegate(instance, channel: channel) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { result("iOS " + UIDevice.current.systemVersion) } public static func dummyMethodToEnforceBundling() { link_me_please() } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/binding.h ================================================ #include #include #include #include int64_t init_sdk(int64_t port, char *data); void async_event(int64_t port, const uint8_t *input, uintptr_t len); const uint8_t *sync_event(const uint8_t *input, uintptr_t len); int32_t set_stream_port(int64_t port); int32_t set_log_stream_port(int64_t port); void link_me_please(void); void rust_log(int64_t level, const char *data); void set_env(const char *data); ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/ios/appflowy_backend.podspec ================================================ # # To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. # Run `pod lib lint appflowy_backend.podspec' to validate before publishing. # Pod::Spec.new do |s| s.name = 'appflowy_backend' s.version = '0.0.1' s.summary = 'A new flutter plugin project.' s.description = <<-DESC A new flutter plugin project. DESC s.homepage = 'http://example.com' s.license = { :file => '../LICENSE' } s.author = { 'AppFlowy' => 'annie@appflowy.io' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' s.platform = :ios, '8.0' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } s.swift_version = '5.0' s.static_framework = true s.vendored_libraries = "libdart_ffi.a" s.library = "c++" end ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'ffi.dart' as ffi; export 'package:async/async.dart'; enum ExceptionType { AppearanceSettingsIsEmpty, } class FlowySDKException implements Exception { ExceptionType type; FlowySDKException(this.type); } class FlowySDK { static const MethodChannel _channel = MethodChannel('appflowy_backend'); static Future get platformVersion async { final String version = await _channel.invokeMethod('getPlatformVersion'); return version; } FlowySDK(); Future dispose() async {} Future init(String configuration) async { ffi.set_stream_port(RustStreamReceiver.shared.port); ffi.store_dart_post_cobject(NativeApi.postCObject); // On iOS, VSCode can't print logs from Rust, so we need to use a different method to print logs. // So we use a shared port to receive logs from Rust and print them using the logger. In release mode, we don't print logs. if (Platform.isIOS && kDebugMode) { ffi.set_log_stream_port(RustLogStreamReceiver.logShared.port); } // final completer = Completer(); // // Create a SendPort that accepts only one message. // final sendPort = singleCompletePort(completer); final code = ffi.init_sdk(0, configuration.toNativeUtf8()); if (code != 0) { throw Exception('Failed to initialize the SDK'); } // return completer.future; } } class RustLogStreamReceiver { static RustLogStreamReceiver logShared = RustLogStreamReceiver._internal(); late RawReceivePort _ffiPort; late StreamController _streamController; late StreamSubscription _subscription; int get port => _ffiPort.sendPort.nativePort; RustLogStreamReceiver._internal() { _ffiPort = RawReceivePort(); _streamController = StreamController(); _ffiPort.handler = _streamController.add; _subscription = _streamController.stream.listen((data) { String decodedString = utf8.decode(data); Log.info(decodedString); }); } factory RustLogStreamReceiver() { return logShared; } Future dispose() async { await _streamController.close(); await _subscription.cancel(); _ffiPort.close(); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend_method_channel.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'appflowy_backend_platform_interface.dart'; /// An implementation of [AppFlowyBackendPlatform] that uses method channels. class MethodChannelFlowySdk extends AppFlowyBackendPlatform { /// The method channel used to interact with the native platform. @visibleForTesting final methodChannel = const MethodChannel('appflowy_backend'); @override Future getPlatformVersion() async { final version = await methodChannel.invokeMethod('getPlatformVersion'); return version; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend_platform_interface.dart ================================================ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'appflowy_backend_method_channel.dart'; abstract class AppFlowyBackendPlatform extends PlatformInterface { /// Constructs a FlowySdkPlatform. AppFlowyBackendPlatform() : super(token: _token); static final Object _token = Object(); static AppFlowyBackendPlatform _instance = MethodChannelFlowySdk(); /// The default instance of [AppFlowyBackendPlatform] to use. /// /// Defaults to [MethodChannelFlowySdk]. static AppFlowyBackendPlatform get instance => _instance; /// Platform-specific implementations should set this with their own /// platform-specific class that extends [AppFlowyBackendPlatform] when /// they register themselves. static set instance(AppFlowyBackendPlatform instance) { PlatformInterface.verifyToken(instance, _token); _instance = instance; } Future getPlatformVersion() { throw UnimplementedError('platformVersion() has not been implemented.'); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart ================================================ import 'dart:async'; import 'dart:convert' show utf8; import 'dart:ffi'; import 'dart:typed_data'; import 'package:flutter/services.dart'; import 'package:appflowy_backend/ffi.dart' as ffi; import 'package:appflowy_backend/log.dart'; // ignore: unnecessary_import import 'package:appflowy_backend/protobuf/dart-ffi/ffi_response.pb.dart'; import 'package:appflowy_backend/protobuf/dart-ffi/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-search/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-storage/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:ffi/ffi.dart'; import 'package:isolates/isolates.dart'; import 'package:isolates/ports.dart'; import 'package:protobuf/protobuf.dart'; import '../protobuf/flowy-date/entities.pb.dart'; import '../protobuf/flowy-date/event_map.pb.dart'; import 'error.dart'; part 'dart_event/flowy-folder/dart_event.dart'; part 'dart_event/flowy-user/dart_event.dart'; part 'dart_event/flowy-database2/dart_event.dart'; part 'dart_event/flowy-document/dart_event.dart'; part 'dart_event/flowy-date/dart_event.dart'; part 'dart_event/flowy-search/dart_event.dart'; part 'dart_event/flowy-ai/dart_event.dart'; part 'dart_event/flowy-storage/dart_event.dart'; enum FFIException { RequestIsEmpty, } class DispatchException implements Exception { FFIException type; DispatchException(this.type); } class Dispatch { static bool enableTracing = false; static Future> asyncRequest( FFIRequest request, ) async { Future> _asyncRequest() async { final bytesFuture = _sendToRust(request); final response = await _extractResponse(bytesFuture); final payload = _extractPayload(response); return payload; } if (enableTracing) { final start = DateTime.now(); final result = await _asyncRequest(); final duration = DateTime.now().difference(start); Log.debug('Dispatch ${request.event} took ${duration.inMilliseconds}ms'); return result; } return _asyncRequest(); } } FlowyResult _extractPayload( FlowyResult response, ) { return response.fold( (response) { switch (response.code) { case FFIStatusCode.Ok: return FlowySuccess(Uint8List.fromList(response.payload)); case FFIStatusCode.Err: final errorBytes = Uint8List.fromList(response.payload); GlobalErrorCodeNotifier.receiveErrorBytes(errorBytes); return FlowyFailure(errorBytes); case FFIStatusCode.Internal: final error = utf8.decode(response.payload); Log.error("Dispatch internal error: $error"); return FlowyFailure(emptyBytes()); default: Log.error("Impossible to here"); return FlowyFailure(emptyBytes()); } }, (error) { Log.error("Response should not be empty $error"); return FlowyFailure(emptyBytes()); }, ); } Future> _extractResponse( Completer bytesFuture, ) async { final bytes = await bytesFuture.future; try { final response = FFIResponse.fromBuffer(bytes); return FlowySuccess(response); } catch (e, s) { final error = StackTraceError(e, s); Log.error('Deserialize response failed. ${error.toString()}'); return FlowyFailure(error.asFlowyError()); } } Completer _sendToRust(FFIRequest request) { Uint8List bytes = request.writeToBuffer(); assert(bytes.isEmpty == false); if (bytes.isEmpty) { throw DispatchException(FFIException.RequestIsEmpty); } final Pointer input = calloc.allocate(bytes.length); final list = input.asTypedList(bytes.length); list.setAll(0, bytes); final completer = Completer(); final port = singleCompletePort(completer); ffi.async_event(port.nativePort, input, bytes.length); calloc.free(input); return completer; } Uint8List requestToBytes(T? message) { try { if (message != null) { return message.writeToBuffer(); } else { return emptyBytes(); } } catch (e, s) { final error = StackTraceError(e, s); Log.error('Serial request failed. ${error.toString()}'); return emptyBytes(); } } Uint8List emptyBytes() { return Uint8List.fromList([]); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart ================================================ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/dart-ffi/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:flutter/foundation.dart'; class FlowyInternalError { late FFIStatusCode _statusCode; late String _error; FFIStatusCode get statusCode { return _statusCode; } String get error { return _error; } bool get has_error { return _statusCode != FFIStatusCode.Ok; } String toString() { return "$_statusCode: $_error"; } FlowyInternalError({ required FFIStatusCode statusCode, required String error, }) { _statusCode = statusCode; _error = error; } } class StackTraceError { Object error; StackTrace trace; StackTraceError( this.error, this.trace, ); FlowyInternalError asFlowyError() { return FlowyInternalError( statusCode: FFIStatusCode.Err, error: this.toString()); } String toString() { return '${error.runtimeType}. Stack trace: $trace'; } } typedef void ErrorListener(); /// Receive error when Rust backend send error message back to the flutter frontend /// class GlobalErrorCodeNotifier extends ChangeNotifier { // Static instance with lazy initialization static final GlobalErrorCodeNotifier _instance = GlobalErrorCodeNotifier._internal(); FlowyError? _error; // Private internal constructor GlobalErrorCodeNotifier._internal(); // Factory constructor to return the same instance factory GlobalErrorCodeNotifier() { return _instance; } static void receiveError(FlowyError error) { if (_instance._error?.code != error.code) { _instance._error = error; _instance.notifyListeners(); } } static void receiveErrorBytes(Uint8List bytes) { try { final error = FlowyError.fromBuffer(bytes); if (_instance._error?.code != error.code) { _instance._error = error; _instance.notifyListeners(); } } catch (e) { Log.error("Can not parse error bytes: $e"); } } static ErrorListener add({ required void Function(FlowyError error) onError, bool Function(FlowyError code)? onErrorIf, }) { void listener() { final error = _instance._error; if (error != null) { if (onErrorIf == null || onErrorIf(error)) { onError(error); } } } _instance.addListener(listener); return listener; } static void remove(ErrorListener listener) { _instance.removeListener(listener); } } extension FlowyErrorExtension on FlowyError { bool get isAIResponseLimitExceeded => code == ErrorCode.AIResponseLimitExceeded; bool get isStorageLimitExceeded => code == ErrorCode.FileStorageLimitExceeded; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart ================================================ /// bindings for `libdart_ffi` import 'dart:ffi'; import 'dart:io'; // ignore: import_of_legacy_library_into_null_safe import 'package:ffi/ffi.dart' as ffi; import 'package:flutter/foundation.dart' as Foundation; // ignore_for_file: unused_import, camel_case_types, non_constant_identifier_names final DynamicLibrary _dart_ffi_lib = _open(); /// Reference to the Dynamic Library, it should be only used for low-level access final DynamicLibrary dl = _dart_ffi_lib; DynamicLibrary _open() { if (Platform.environment.containsKey('FLUTTER_TEST')) { final prefix = "${Directory.current.path}/.sandbox"; if (Platform.isLinux) return DynamicLibrary.open('${prefix}/libdart_ffi.so'); if (Platform.isAndroid) return DynamicLibrary.open('${prefix}/libdart_ffi.so'); if (Platform.isMacOS) return DynamicLibrary.open('${prefix}/libdart_ffi.dylib'); if (Platform.isIOS) return DynamicLibrary.open('${prefix}/libdart_ffi.a'); if (Platform.isWindows) return DynamicLibrary.open('${prefix}/dart_ffi.dll'); } else { if (Platform.isLinux) return DynamicLibrary.open('libdart_ffi.so'); if (Platform.isAndroid) return DynamicLibrary.open('libdart_ffi.so'); if (Platform.isMacOS) return DynamicLibrary.executable(); if (Platform.isIOS) return DynamicLibrary.executable(); if (Platform.isWindows) return DynamicLibrary.open('dart_ffi.dll'); } throw UnsupportedError('This platform is not supported.'); } /// C function `async_event`. void async_event( int port, Pointer input, int len, ) { _invoke_async(port, input, len); } final _invoke_async_Dart _invoke_async = _dart_ffi_lib .lookupFunction<_invoke_async_C, _invoke_async_Dart>('async_event'); typedef _invoke_async_C = Void Function( Int64 port, Pointer input, Uint64 len, ); typedef _invoke_async_Dart = void Function( int port, Pointer input, int len, ); /// C function `sync_event`. Pointer sync_event( Pointer input, int len, ) { return _invoke_sync(input, len); } final _invoke_sync_Dart _invoke_sync = _dart_ffi_lib .lookupFunction<_invoke_sync_C, _invoke_sync_Dart>('sync_event'); typedef _invoke_sync_C = Pointer Function( Pointer input, Uint64 len, ); typedef _invoke_sync_Dart = Pointer Function( Pointer input, int len, ); /// C function `init_sdk`. int init_sdk( int port, Pointer data, ) { return _init_sdk(port, data); } final _init_sdk_Dart _init_sdk = _dart_ffi_lib.lookupFunction<_init_sdk_C, _init_sdk_Dart>('init_sdk'); typedef _init_sdk_C = Int64 Function( Int64 port, Pointer path, ); typedef _init_sdk_Dart = int Function( int port, Pointer path, ); /// C function `init_stream`. int set_stream_port(int port) { return _set_stream_port(port); } final _set_stream_port_Dart _set_stream_port = _dart_ffi_lib.lookupFunction<_set_stream_port_C, _set_stream_port_Dart>( 'set_stream_port'); typedef _set_stream_port_C = Int32 Function( Int64 port, ); typedef _set_stream_port_Dart = int Function( int port, ); /// C function `set log stream port`. int set_log_stream_port(int port) { return _set_log_stream_port(port); } final _set_log_stream_port_Dart _set_log_stream_port = _dart_ffi_lib .lookupFunction<_set_log_stream_port_C, _set_log_stream_port_Dart>( 'set_log_stream_port'); typedef _set_log_stream_port_C = Int32 Function( Int64 port, ); typedef _set_log_stream_port_Dart = int Function( int port, ); /// C function `link_me_please`. void link_me_please() { _link_me_please(); } final _link_me_please_Dart _link_me_please = _dart_ffi_lib .lookupFunction<_link_me_please_C, _link_me_please_Dart>('link_me_please'); typedef _link_me_please_C = Void Function(); typedef _link_me_please_Dart = void Function(); /// Binding to `allo-isolate` crate void store_dart_post_cobject( Pointer)>> ptr, ) { _store_dart_post_cobject(ptr); } final _store_dart_post_cobject_Dart _store_dart_post_cobject = _dart_ffi_lib .lookupFunction<_store_dart_post_cobject_C, _store_dart_post_cobject_Dart>( 'store_dart_post_cobject'); typedef _store_dart_post_cobject_C = Void Function( Pointer)>> ptr, ); typedef _store_dart_post_cobject_Dart = void Function( Pointer)>> ptr, ); void rust_log( int level, Pointer data, ) { _invoke_rust_log(level, data); } final _invoke_rust_log_Dart _invoke_rust_log = _dart_ffi_lib .lookupFunction<_invoke_rust_log_C, _invoke_rust_log_Dart>('rust_log'); typedef _invoke_rust_log_C = Void Function( Int64 level, Pointer data, ); typedef _invoke_rust_log_Dart = void Function( int level, Pointer, ); /// C function `set_env`. void set_env( Pointer data, ) { _set_env(data); } final _set_env_Dart _set_env = _dart_ffi_lib.lookupFunction<_set_env_C, _set_env_Dart>('set_env'); typedef _set_env_C = Void Function( Pointer data, ); typedef _set_env_Dart = void Function( Pointer data, ); ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart ================================================ // ignore: import_of_legacy_library_into_null_safe import 'dart:ffi'; import 'package:ffi/ffi.dart' as ffi; import 'package:flutter/foundation.dart'; import 'package:talker/talker.dart'; import 'ffi.dart'; class Log { static final shared = Log(); late Talker _logger; bool enableFlutterLog = true; // used to disable log in tests bool disableLog = false; Log() { _logger = Talker( filter: LogLevelTalkerFilter(), ); } // Generic internal logging function to reduce code duplication static void _log( LogLevel level, int rustLevel, dynamic msg, [ dynamic error, StackTrace? stackTrace, ]) { // only forward logs to flutter in debug mode, otherwise log to rust to // persist logs in the file system if (shared.enableFlutterLog && kDebugMode) { shared._logger.log(msg, logLevel: level, stackTrace: stackTrace); } else { String formattedMessage = _formatMessageWithStackTrace(msg, stackTrace); rust_log(rustLevel, toNativeUtf8(formattedMessage)); } } static void info(dynamic msg, [dynamic error, StackTrace? stackTrace]) { if (shared.disableLog) { return; } _log(LogLevel.info, 0, msg, error, stackTrace); } static void debug(dynamic msg, [dynamic error, StackTrace? stackTrace]) { if (shared.disableLog) { return; } _log(LogLevel.debug, 1, msg, error, stackTrace); } static void warn(dynamic msg, [dynamic error, StackTrace? stackTrace]) { if (shared.disableLog) { return; } _log(LogLevel.warning, 3, msg, error, stackTrace); } static void trace(dynamic msg, [dynamic error, StackTrace? stackTrace]) { if (shared.disableLog) { return; } _log(LogLevel.verbose, 2, msg, error, stackTrace); } static void error(dynamic msg, [dynamic error, StackTrace? stackTrace]) { if (shared.disableLog) { return; } _log(LogLevel.error, 4, msg, error, stackTrace); } } bool isReleaseVersion() { return kReleaseMode; } // Utility to convert a message to native Utf8 (used in rust_log) Pointer toNativeUtf8(dynamic msg) { return "$msg".toNativeUtf8(); } String _formatMessageWithStackTrace(dynamic msg, StackTrace? stackTrace) { if (stackTrace != null) { return "$msg\nStackTrace:\n$stackTrace"; // Append the stack trace to the message } return msg.toString(); } class LogLevelTalkerFilter implements TalkerFilter { @override bool filter(TalkerData data) { // filter out the debug logs in release mode return kDebugMode ? true : data.logLevel != LogLevel.debug; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/lib/rust_stream.dart ================================================ import 'dart:async'; import 'dart:ffi'; import 'dart:isolate'; import 'dart:typed_data'; import 'package:appflowy_backend/log.dart'; import 'protobuf/flowy-notification/subject.pb.dart'; typedef ObserverCallback = void Function(SubscribeObject observable); class RustStreamReceiver { static RustStreamReceiver shared = RustStreamReceiver._internal(); late RawReceivePort _ffiPort; late StreamController _streamController; late StreamController _observableController; late StreamSubscription _ffiSubscription; int get port => _ffiPort.sendPort.nativePort; StreamController get observable => _observableController; RustStreamReceiver._internal() { _ffiPort = RawReceivePort(); _streamController = StreamController(); _observableController = StreamController.broadcast(); _ffiPort.handler = _streamController.add; _ffiSubscription = _streamController.stream.listen(_streamCallback); } factory RustStreamReceiver() { return shared; } static StreamSubscription listen( void Function(SubscribeObject subject) callback) { return RustStreamReceiver.shared.observable.stream.listen(callback); } void _streamCallback(Uint8List bytes) { try { final observable = SubscribeObject.fromBuffer(bytes); _observableController.add(observable); } catch (e, s) { Log.error( 'RustStreamReceiver SubscribeObject deserialize error: ${e.runtimeType}'); Log.error('Stack trace \n $s'); rethrow; } } Future dispose() async { await _ffiSubscription.cancel(); await _streamController.close(); await _observableController.close(); _ffiPort.close(); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/linux/Classes/binding.h ================================================ #include #include #include #include int64_t init_sdk(char *path); void async_command(int64_t port, const uint8_t *input, uintptr_t len); const uint8_t *sync_command(const uint8_t *input, uintptr_t len); int32_t set_stream_port(int64_t port); void link_me_please(void); void rust_log(int64_t level, const char *data); void set_env(const char *data); ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/AppFlowyBackendPlugin.swift ================================================ import Cocoa import FlutterMacOS public class AppFlowyBackendPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "appflowy_backend", binaryMessenger: registrar.messenger) let instance = AppFlowyBackendPlugin() registrar.addMethodCallDelegate(instance, channel: channel) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "getPlatformVersion": result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString) default: result(FlutterMethodNotImplemented) } } public static func dummyMethodToEnforceBundling() { link_me_please() } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/binding.h ================================================ #include #include #include #include int64_t init_sdk(int64_t port, char *data); void async_event(int64_t port, const uint8_t *input, uintptr_t len); const uint8_t *sync_event(const uint8_t *input, uintptr_t len); int32_t set_stream_port(int64_t port); int32_t set_log_stream_port(int64_t port); void link_me_please(void); void rust_log(int64_t level, const char *data); void set_env(const char *data); ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/macos/appflowy_backend.podspec ================================================ # # To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. # Run `pod lib lint appflowy_backend.podspec' to validate before publishing. # Pod::Spec.new do |s| s.name = 'appflowy_backend' s.version = '0.0.1' s.summary = 'A new flutter plugin project.' s.description = <<-DESC A new flutter plugin project. DESC s.homepage = 'http://example.com' s.license = { :file => '../LICENSE' } s.author = { 'AppFlowy' => 'annie@appflowy.io' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'FlutterMacOS' s.platform = :osx, '10.13' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.swift_version = '5.0' s.static_framework = true s.vendored_libraries = "libdart_ffi.a" end ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml ================================================ name: appflowy_backend description: A new flutter plugin project. version: 0.0.1 homepage: https://appflowy.io publish_to: "none" environment: sdk: ">=2.17.0-0 <3.0.0" flutter: ">=1.17.0" dependencies: flutter: sdk: flutter ffi: ^2.0.2 isolates: ^3.0.3+8 protobuf: ^3.1.0 talker: ^4.7.1 plugin_platform_interface: ^2.1.3 appflowy_result: path: ../appflowy_result fixnum: ^1.1.0 async: ^2.11.0 dev_dependencies: flutter_test: sdk: flutter # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter. flutter: # This section identifies this Flutter project as a plugin project. # The 'pluginClass' and Android 'package' identifiers should not ordinarily # be modified. They are used by the tooling to maintain consistency when # adding or updating assets for this project. plugin: platforms: android: package: com.plugin.appflowy_backend pluginClass: AppFlowyBackendPlugin ios: pluginClass: AppFlowyBackendPlugin macos: pluginClass: AppFlowyBackendPlugin windows: pluginClass: AppFlowyBackendPlugin # To add assets to your plugin package, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # # For details regarding assets in packages, see # https://flutter.dev/assets-and-images/#from-packages # # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. # To add custom fonts to your plugin package, add a fonts section here, # in this "flutter" section. Each entry in this list should have a # "family" key with the font family name, and a "fonts" key with a # list giving the asset and other descriptors for the font. For # example: # fonts: # - family: Schyler # fonts: # - asset: fonts/Schyler-Regular.ttf # - asset: fonts/Schyler-Italic.ttf # style: italic # - family: Trajan Pro # fonts: # - asset: fonts/TrajanPro.ttf # - asset: fonts/TrajanPro_Bold.ttf # weight: 700 # # For details regarding fonts in packages, see # https://flutter.dev/custom-fonts/#from-packages ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/test/appflowy_backend_method_channel_test.dart ================================================ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:appflowy_backend/appflowy_backend_method_channel.dart'; void main() { MethodChannelFlowySdk platform = MethodChannelFlowySdk(); const MethodChannel channel = MethodChannel('appflowy_backend'); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( channel, (MethodCall methodCall) async { return '42'; }, ); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( channel, null, ); }); test('getPlatformVersion', () async { expect(await platform.getPlatformVersion(), '42'); }); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/test/appflowy_backend_test.dart ================================================ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:appflowy_backend/appflowy_backend.dart'; void main() { const MethodChannel channel = MethodChannel('appflowy_backend'); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { return '42'; }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( channel, null, ); }); test('getPlatformVersion', () async { expect(await FlowySDK.platformVersion, '42'); }); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/windows/.gitignore ================================================ flutter/ # Visual Studio user-specific files. *.suo *.user *.userosscache *.sln.docstates # Visual Studio build-related files. x64/ x86/ # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/windows/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) set(PROJECT_NAME "appflowy_backend") project(${PROJECT_NAME} LANGUAGES CXX) # This value is used when generating builds using this plugin, so it must # not be changed set(PLUGIN_NAME "appflowy_backend_plugin") add_library(${PLUGIN_NAME} SHARED "appflowy_backend_plugin.cpp" ) apply_standard_settings(${PLUGIN_NAME}) set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) # List of absolute paths to libraries that should be bundled with the plugin set(appflowy_backend_bundled_libraries "" PARENT_SCOPE ) ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/windows/app_flowy_backend_plugin.h ================================================ #ifndef FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ #define FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ #include #include #include namespace appflowy_backend { class AppFlowyBackendPlugin : public flutter::Plugin { public: static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); AppFlowyBackendPlugin(); virtual ~AppFlowyBackendPlugin(); // Disallow copy and assign. AppFlowyBackendPlugin(const AppFlowyBackendPlugin&) = delete; AppFlowyBackendPlugin& operator=(const AppFlowyBackendPlugin&) = delete; private: // Called when a method is called on this plugin's channel from Dart. void HandleMethodCall( const flutter::MethodCall &method_call, std::unique_ptr> result); }; } // namespace appflowy_backend #endif // FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin.cpp ================================================ // This must be included before many other Windows headers. #include // For getPlatformVersion; remove unless needed for your plugin implementation. #include #include #include #include #include #include #include #include "include/appflowy_backend/app_flowy_backend_plugin.h" namespace { class AppFlowyBackendPlugin : public flutter::Plugin { public: static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); AppFlowyBackendPlugin(); virtual ~AppFlowyBackendPlugin(); private: // Called when a method is called on this plugin's channel from Dart. void HandleMethodCall( const flutter::MethodCall &method_call, std::unique_ptr> result); }; // static void AppFlowyBackendPlugin::RegisterWithRegistrar( flutter::PluginRegistrarWindows *registrar) { auto channel = std::make_unique>( registrar->messenger(), "appflowy_backend", &flutter::StandardMethodCodec::GetInstance()); auto plugin = std::make_unique(); channel->SetMethodCallHandler( [plugin_pointer = plugin.get()](const auto &call, auto result) { plugin_pointer->HandleMethodCall(call, std::move(result)); }); registrar->AddPlugin(std::move(plugin)); } AppFlowyBackendPlugin::AppFlowyBackendPlugin() {} AppFlowyBackendPlugin::~AppFlowyBackendPlugin() {} void AppFlowyBackendPlugin::HandleMethodCall( const flutter::MethodCall &method_call, std::unique_ptr> result) { if (method_call.method_name().compare("getPlatformVersion") == 0) { std::ostringstream version_stream; version_stream << "Windows "; if (IsWindows10OrGreater()) { version_stream << "10+"; } else if (IsWindows8OrGreater()) { version_stream << "8"; } else if (IsWindows7OrGreater()) { version_stream << "7"; } result->Success(flutter::EncodableValue(version_stream.str())); } else { result->NotImplemented(); } } } // namespace void AppFlowyBackendPluginRegisterWithRegistrar( FlutterDesktopPluginRegistrarRef registrar) { AppFlowyBackendPlugin::RegisterWithRegistrar( flutter::PluginRegistrarManager::GetInstance() ->GetRegistrar(registrar)); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin_c_api.cpp ================================================ #include "include/appflowy_backend/appflowy_backend_plugin_c_api.h" #include #include "appflowy_flutter_backend_plugin.h" void AppFlowyBackendPluginCApiRegisterWithRegistrar( FlutterDesktopPluginRegistrarRef registrar) { appflowy_backend::AppFlowyBackendPlugin::RegisterWithRegistrar( flutter::PluginRegistrarManager::GetInstance() ->GetRegistrar(registrar)); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/windows/include/appflowy_backend/app_flowy_backend_plugin.h ================================================ #ifndef FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ #define FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ #include #ifdef FLUTTER_PLUGIN_IMPL #define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) #else #define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) #endif #if defined(__cplusplus) extern "C" { #endif FLUTTER_PLUGIN_EXPORT void AppFlowyBackendPluginRegisterWithRegistrar( FlutterDesktopPluginRegistrarRef registrar); #if defined(__cplusplus) } // extern "C" #endif #endif // FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_backend/windows/include/appflowy_backend/appflowy_backend_plugin_c_api.h ================================================ #ifndef FLUTTER_PLUGIN_appflowy_backend_plugin_c_api_H_ #define FLUTTER_PLUGIN_appflowy_backend_plugin_c_api_H_ #include #ifdef FLUTTER_PLUGIN_IMPL #define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) #else #define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) #endif #if defined(__cplusplus) extern "C" { #endif FLUTTER_PLUGIN_EXPORT void AppFlowyBackendPluginCApiRegisterWithRegistrar( FlutterDesktopPluginRegistrarRef registrar); #if defined(__cplusplus) } // extern "C" #endif #endif // FLUTTER_PLUGIN_appflowy_backend_plugin_c_api_H_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ 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 # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. /pubspec.lock **/doc/api/ .dart_tool/ .packages build/ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/.metadata ================================================ # 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: f1875d570e39de09040c8f79aa13cc56baab8db1 channel: stable project_type: package ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/CHANGELOG.md ================================================ ## 0.0.1 * TODO: Describe initial release. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/LICENSE ================================================ TODO: Add your license here. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/README.md ================================================ # AppFlowy Popover A Popover can be used to display some content on top of another. It can be used to display a dropdown menu. > A popover is a transient view that appears above other content onscreen when you tap a control or in an area. Typically, a popover includes an arrow pointing to the location from which it emerged. Popovers can be nonmodal or modal. A nonmodal popover is dismissed by tapping another part of the screen or a button on the popover. A modal popover is dismissed by tapping a Cancel or other button on the popover. Source: [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/views/popovers/). ## Features - Basic popover style - Follow the target automatically - Nested popover support - Exclusive API ![](./screenshot.png) ## Example ```dart Popover( // Define how to trigger the popover triggerActions: PopoverTriggerActionFlags.click, child: TextButton(child: Text("Popover"), onPressed: () {}), // Define the direction of the popover direction: PopoverDirection.bottomWithLeftAligned, popupBuilder(BuildContext context) { return PopoverMenu(); }, ); ``` ### Trigger the popover manually Sometimes, if you want to trigger the popover manually, you can use a `PopoverController`. ```dart class MyWidgetState extends State { late PopoverController _popover; @override void initState() { _popover = PopoverController(); super.initState(); } // triggered by another widget _onClick() { _popover.show(); } @override Widget build(BuildContext context) { return Popover( controller: _popover, ... ) } } ``` ### Make several popovers exclusive The popover has a mechanism to make sure there are only one popover is shown in a group of popovers. It's called `PopoverMutex`. If you pass the same mutex object to the popovers, there will be only one popover is triggered. ```dart class MyWidgetState extends State { final _popoverMutex = PopoverMutex(); @override Widget build(BuildContext context) { return Row( children: [ Popover( mutex: _popoverMutex, ... ), Popover( mutex: _popoverMutex, ... ), Popover( mutex: _popoverMutex, ... ), ] ) } } ``` ## API | Param | Description | Type | | -------------- | ---------------------------------------------------------------- | --------------------------------------- | | offset | The offset between the popover and the child | `Offset` | | popupBuilder | The function used to build the popover | `Widget Function(BuildContext context)` | | triggerActions | Define the actions about how to trigger the popover | `int` | | mutex | If multiple popovers are exclusive, pass the same mutex to them. | `PopoverMutex` | | direction | The direction where the popover should be placed | `PopoverDirection` | | onClose | The callback will be called after the popover is closed | `void Function()` | | child | The child to trigger the popover | `Widget` | ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/analysis_options.yaml ================================================ # This file configures the analyzer, which statically analyzes Dart code to # check for errors, warnings, and lints. # # The issues identified by the analyzer are surfaced in the UI of Dart-enabled # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be # invoked from the command line by running `flutter analyze`. # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml analyzer: exclude: - "**/*.g.dart" - "**/*.freezed.dart" linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at # https://dart-lang.github.io/linter/lints/index.html. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code # or a specific dart file by using the `// ignore: name_of_lint` and # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: - require_trailing_commas - prefer_collection_literals - prefer_final_fields - prefer_final_in_for_each - prefer_final_locals - sized_box_for_whitespace - use_decorated_box - unnecessary_parenthesis - unnecessary_await_in_return - unnecessary_raw_strings - avoid_unnecessary_containers - avoid_redundant_argument_values - avoid_unused_constructor_parameters - always_declare_return_types - sort_constructors_first - unawaited_futures - prefer_single_quotes # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options errors: invalid_annotation_target: ignore ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ 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 .packages .pub-cache/ .pub/ /build/ # Web related lib/generated_plugin_registrant.dart # 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 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/.metadata ================================================ # 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. version: revision: f1875d570e39de09040c8f79aa13cc56baab8db1 channel: stable project_type: app # Tracks metadata for the flutter migrate command migration: platforms: - platform: root create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - platform: android create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - platform: ios create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - platform: linux create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - platform: macos create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - platform: web create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 - platform: windows create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 # 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' ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/README.md ================================================ # example A new Flutter project. ## Getting Started This project is a starting point for a Flutter application. A few resources to get you started if this is your first Flutter project: - [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) - [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) For help getting started with Flutter development, view the [online documentation](https://docs.flutter.dev/), which offers tutorials, samples, guidance on mobile development, and a full API reference. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/analysis_options.yaml ================================================ # This file configures the analyzer, which statically analyzes Dart code to # check for errors, warnings, and lints. # # The issues identified by the analyzer are surfaced in the UI of Dart-enabled # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be # invoked from the command line by running `flutter analyze`. # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml analyzer: exclude: - "**/*.g.dart" - "**/*.freezed.dart" linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at # https://dart-lang.github.io/linter/lints/index.html. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code # or a specific dart file by using the `// ignore: name_of_lint` and # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: - require_trailing_commas - prefer_collection_literals - prefer_final_fields - prefer_final_in_for_each - prefer_final_locals - sized_box_for_whitespace - use_decorated_box - unnecessary_parenthesis - unnecessary_await_in_return - unnecessary_raw_strings - avoid_unnecessary_containers - avoid_redundant_argument_values - avoid_unused_constructor_parameters - always_declare_return_types - sort_constructors_first - unawaited_futures - prefer_single_quotes # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options errors: invalid_annotation_target: ignore ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/.gitignore ================================================ gradle-wrapper.jar /.gradle /captures/ /gradlew /gradlew.bat /local.properties GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app key.properties **/*.keystore **/*.jks ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/build.gradle ================================================ def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) } } def flutterRoot = localProperties.getProperty('flutter.sdk') if (flutterRoot == null) { throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { flutterVersionName = '1.0' } apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { compileSdkVersion flutter.compileSdkVersion ndkVersion flutter.ndkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.example" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } } flutter { source '../..' } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt ================================================ package com.example.example import io.flutter.embedding.android.FlutterActivity class MainActivity: FlutterActivity() { } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/drawable/launch_background.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/drawable-v21/launch_background.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/values/styles.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/values-night/styles.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/profile/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/build.gradle ================================================ buildscript { ext.kotlin_version = '1.6.10' repositories { google() mavenCentral() } dependencies { classpath 'com.android.tools.build:gradle:7.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() mavenCentral() } } rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } subprojects { project.evaluationDependsOn(':app') } task clean(type: Delete) { delete rootProject.buildDir } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/gradle/wrapper/gradle-wrapper.properties ================================================ #Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/android/settings.gradle ================================================ include ':app' def localPropertiesFile = new File(rootProject.projectDir, "local.properties") def properties = new Properties() assert localPropertiesFile.exists() localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/.gitignore ================================================ **/dgph *.mode1v3 *.mode2v3 *.moved-aside *.pbxuser *.perspectivev3 **/*sync/ .sconsign.dblite .tags* **/.vagrant/ **/DerivedData/ Icon? **/Pods/ **/.symlinks/ profile xcuserdata **/.generated/ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !default.mode1v3 !default.mode2v3 !default.pbxuser !default.perspectivev3 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable App CFBundleIdentifier io.flutter.flutter.app CFBundleInfoDictionaryVersion 6.0 CFBundleName App CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1.0 MinimumOSVersion 12.0 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/Debug.xcconfig ================================================ #include "Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/Release.xcconfig ================================================ #include "Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/AppDelegate.swift ================================================ import UIKit import Flutter @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@3x.png", "scale" : "3x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@3x.png", "scale" : "3x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@3x.png", "scale" : "3x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@2x.png", "scale" : "2x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@3x.png", "scale" : "3x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@1x.png", "scale" : "1x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@1x.png", "scale" : "1x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@1x.png", "scale" : "1x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@2x.png", "scale" : "2x" }, { "size" : "83.5x83.5", "idiom" : "ipad", "filename" : "Icon-App-83.5x83.5@2x.png", "scale" : "2x" }, { "size" : "1024x1024", "idiom" : "ios-marketing", "filename" : "Icon-App-1024x1024@1x.png", "scale" : "1x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LaunchImage.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LaunchImage@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md ================================================ # Launch Screen Assets You can customize the launch screen with your own desired assets by replacing the image files in this directory. You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Base.lproj/Main.storyboard ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Example CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName example CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleSignature ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Runner-Bridging-Header.h ================================================ #import "GeneratedPluginRegistrant.h" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); name = Flutter; sourceTree = ""; }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, ); sourceTree = ""; }; 97C146EF1CF9000F007C117D /* Products */ = { isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, ); name = Products; sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, ); buildRules = ( ); dependencies = ( ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 97C146FB1CF9000F007C117D /* Base */, ); name = Main.storyboard; sourceTree = ""; }; 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 97C147001CF9000F007C117D /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Profile; }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; 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_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; 97C147061CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 97C147071CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147031CF9000F007C117D /* Debug */, 97C147041CF9000F007C117D /* Release */, 249021D3217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147061CF9000F007C117D /* Debug */, 97C147071CF9000F007C117D /* Release */, 249021D4217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart ================================================ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; class PopoverMenu extends StatefulWidget { const PopoverMenu({super.key}); @override State createState() => _PopoverMenuState(); } class _PopoverMenuState extends State { final PopoverMutex popOverMutex = PopoverMutex(); @override Widget build(BuildContext context) { return Material( type: MaterialType.transparency, child: Container( width: 200, height: 200, decoration: BoxDecoration( color: Colors.white, borderRadius: const BorderRadius.all(Radius.circular(8)), boxShadow: [ BoxShadow( color: Colors.grey.withValues(alpha: 0.5), spreadRadius: 5, blurRadius: 7, offset: const Offset(0, 3), // changes position of shadow ), ], ), child: ListView( children: [ Container( margin: const EdgeInsets.all(8), child: const Text( 'Popover', style: TextStyle( fontSize: 14, color: Colors.black, ), ), ), Popover( triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, mutex: popOverMutex, offset: const Offset(10, 0), asBarrier: true, debugId: 'First', popupBuilder: (BuildContext context) { return const PopoverMenu(); }, child: TextButton( onPressed: () {}, child: const Text('First'), ), ), Popover( triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, mutex: popOverMutex, asBarrier: true, debugId: 'Second', offset: const Offset(10, 0), popupBuilder: (BuildContext context) { return const PopoverMenu(); }, child: TextButton( onPressed: () {}, child: const Text('Second'), ), ), ], ), ), ); } } class ExampleButton extends StatelessWidget { const ExampleButton({ super.key, required this.label, required this.direction, this.offset = Offset.zero, }); final String label; final Offset? offset; final PopoverDirection direction; @override Widget build(BuildContext context) { return Popover( triggerActions: PopoverTriggerFlags.click, animationDuration: Durations.medium1, offset: offset, direction: direction, debugId: label, child: TextButton( child: Text(label), onPressed: () {}, ), popupBuilder: (BuildContext context) { return const PopoverMenu(); }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/lib/main.dart ================================================ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import './example_button.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: const MyHomePage(title: 'AppFlowy Popover Example'), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: const Padding( padding: EdgeInsets.symmetric(horizontal: 48.0, vertical: 24.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ExampleButton( label: 'Left top', offset: Offset(0, 10), direction: PopoverDirection.bottomWithLeftAligned, ), ExampleButton( label: 'Left Center', offset: Offset(0, -10), direction: PopoverDirection.rightWithCenterAligned, ), ExampleButton( label: 'Left bottom', offset: Offset(0, -10), direction: PopoverDirection.topWithLeftAligned, ), ], ), Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ExampleButton( label: 'Top', offset: Offset(0, 10), direction: PopoverDirection.bottomWithCenterAligned, ), Column( mainAxisAlignment: MainAxisAlignment.center, children: [ ExampleButton( label: 'Central', offset: Offset(0, 10), direction: PopoverDirection.bottomWithCenterAligned, ), ], ), ExampleButton( label: 'Bottom', offset: Offset(0, -10), direction: PopoverDirection.topWithCenterAligned, ), ], ), Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ExampleButton( label: 'Right top', offset: Offset(0, 10), direction: PopoverDirection.bottomWithRightAligned, ), ExampleButton( label: 'Right Center', offset: Offset(0, 10), direction: PopoverDirection.leftWithCenterAligned, ), ExampleButton( label: 'Right bottom', offset: Offset(0, -10), direction: PopoverDirection.topWithRightAligned, ), ], ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/linux/.gitignore ================================================ flutter/ephemeral ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/linux/CMakeLists.txt ================================================ # 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 "example") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID set(APPLICATION_ID "com.example.example") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. cmake_policy(SET CMP0063 NEW) # 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) # 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() ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/linux/flutter/CMakeLists.txt ================================================ # 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} ) ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/linux/main.cc ================================================ #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); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/linux/my_application.cc ================================================ #include "my_application.h" #include #ifdef GDK_WINDOWING_X11 #include #endif #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(gtk_application_window_new(GTK_APPLICATION(application))); // Use a header bar when running in GNOME as this is the common style used // by applications and is the setup most users will be using (e.g. Ubuntu // desktop). // If running on X and not using GNOME then just use a traditional title bar // in case the window manager does more exotic layout, e.g. tiling. // If running on Wayland assume the header bar will work (may need changing // if future cases occur). gboolean use_header_bar = TRUE; #ifdef GDK_WINDOWING_X11 GdkScreen* screen = gtk_window_get_screen(window); if (GDK_IS_X11_SCREEN(screen)) { const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); if (g_strcmp0(wm_name, "GNOME Shell") != 0) { use_header_bar = FALSE; } } #endif if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); gtk_header_bar_set_title(header_bar, "example"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { gtk_window_set_title(window, "example"); } gtk_window_set_default_size(window, 1280, 720); 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 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_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)); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/linux/my_application.h ================================================ #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_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/.gitignore ================================================ # Flutter-related **/Flutter/ephemeral/ **/Pods/ # Xcode-related **/dgph **/xcuserdata/ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Flutter/Flutter-Debug.xcconfig ================================================ #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Flutter/Flutter-Release.xcconfig ================================================ #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/AppDelegate.swift ================================================ import Cocoa import FlutterMacOS @NSApplicationMain class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "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" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Base.lproj/MainMenu.xib ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/AppInfo.xcconfig ================================================ // 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 = example // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.example.example // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2022 com.example. All rights reserved. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/Debug.xcconfig ================================================ #include "../../Flutter/Flutter-Debug.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/Release.xcconfig ================================================ #include "../../Flutter/Flutter-Release.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/Warnings.xcconfig ================================================ 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 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/DebugProfile.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.cs.allow-jit com.apple.security.network.server ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Info.plist ================================================ 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 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/MainFlutterWindow.swift ================================================ import Cocoa import FlutterMacOS class MainFlutterWindow: NSWindow { override func awakeFromNib() { let flutterViewController = FlutterViewController.init() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Release.entitlements ================================================ com.apple.security.app-sandbox ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.pbxproj ================================================ // !$*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 */ 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 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 */ 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 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example.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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 33CC10EA2044A3C60003C045 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 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 */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* example.app */, ); 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 = ""; }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 33CC10EC2044A3C60003C045 /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, ); buildRules = ( ); dependencies = ( 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* example.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; ProvisioningStyle = Automatic; 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 */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 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 */ 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"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 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 */ 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 */ 338D0CE9231458BD00FA5F75 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; 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; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; 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_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; 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; 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; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; 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; 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; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; 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_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; 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_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; 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 */ 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 */; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/pubspec.yaml ================================================ name: example description: A new Flutter project. # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 # followed by an optional build number separated by a +. # Both the version and the builder number may be overridden in flutter # build by specifying --build-name and --build-number, respectively. # In Android, build-name is used as versionName while build-number used as versionCode. # Read more about Android versioning at https://developer.android.com/studio/publish/versioning # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html version: 1.0.0+1 environment: sdk: ">=3.3.0 <4.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions # consider running `flutter pub upgrade --major-versions`. Alternatively, # dependencies can be manually updated by changing the version numbers below to # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 appflowy_popover: path: ../ dev_dependencies: flutter_test: sdk: flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^3.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter packages. flutter: # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware # For details regarding adding assets from package dependencies, see # https://flutter.dev/assets-and-images/#from-packages # To add custom fonts to your application, add a fonts section here, # in this "flutter" section. Each entry in this list should have a # "family" key with the font family name, and a "fonts" key with a # list giving the asset and other descriptors for the font. For # example: # fonts: # - family: Schyler # fonts: # - asset: fonts/Schyler-Regular.ttf # - asset: fonts/Schyler-Italic.ttf # style: italic # - family: Trajan Pro # fonts: # - asset: fonts/TrajanPro.ttf # - asset: fonts/TrajanPro_Bold.ttf # weight: 700 # # For details regarding fonts from package dependencies, # see https://flutter.dev/custom-fonts/#from-packages ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/web/index.html ================================================ example ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/web/manifest.json ================================================ { "name": "example", "short_name": "example", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", "description": "A new Flutter project.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ { "src": "icons/Icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "icons/Icon-512.png", "sizes": "512x512", "type": "image/png" }, { "src": "icons/Icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "icons/Icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/.gitignore ================================================ flutter/ephemeral/ # Visual Studio user-specific files. *.suo *.user *.userosscache *.sln.docstates # Visual Studio build-related files. x64/ x86/ # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/CMakeLists.txt ================================================ # Project-level configuration. cmake_minimum_required(VERSION 3.14) project(example 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 "example") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. cmake_policy(SET CMP0063 NEW) # Define build configuration option. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" CACHE STRING "" FORCE) else() 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() endif() # Define settings for the Profile build mode. set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") # Use Unicode for all projects. add_definitions(-DUNICODE -D_UNICODE) # 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_17) target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") target_compile_options(${TARGET} PRIVATE /EHsc) target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") endfunction() # Flutter library and tool build rules. set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) # === Installation === # Support files are copied into place next to the executable, so that it can # run in place. This is done instead of making a separate bundle (as on Linux) # so that building and running from within Visual Studio will work. set(BUILD_BUNDLE_DIR "$") # Make the "install" step default, as it's required to run. set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) endif() set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") 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) if(PLUGIN_BUNDLED_LIBRARIES) install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() # 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. install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" CONFIGURATIONS Profile;Release COMPONENT Runtime) ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/flutter/CMakeLists.txt ================================================ # This file controls Flutter-level build steps. It should not be edited. cmake_minimum_required(VERSION 3.14) 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. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") # 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/windows/app.so" PARENT_SCOPE) list(APPEND FLUTTER_LIBRARY_HEADERS "flutter_export.h" "flutter_windows.h" "flutter_messenger.h" "flutter_plugin_registrar.h" "flutter_texture_registrar.h" ) list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") add_library(flutter INTERFACE) target_include_directories(flutter INTERFACE "${EPHEMERAL_DIR}" ) target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") add_dependencies(flutter flutter_assemble) # === Wrapper === list(APPEND CPP_WRAPPER_SOURCES_CORE "core_implementations.cc" "standard_codec.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_PLUGIN "plugin_registrar.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_APP "flutter_engine.cc" "flutter_view_controller.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") # Wrapper sources needed for a plugin. add_library(flutter_wrapper_plugin STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ) apply_standard_settings(flutter_wrapper_plugin) set_target_properties(flutter_wrapper_plugin PROPERTIES POSITION_INDEPENDENT_CODE ON) set_target_properties(flutter_wrapper_plugin PROPERTIES CXX_VISIBILITY_PRESET hidden) target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) target_include_directories(flutter_wrapper_plugin PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_plugin flutter_assemble) # Wrapper sources needed for the runner. add_library(flutter_wrapper_app STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_APP} ) apply_standard_settings(flutter_wrapper_app) target_link_libraries(flutter_wrapper_app PUBLIC flutter) target_include_directories(flutter_wrapper_app PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_app 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. set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) add_custom_command( OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ${PHONY_OUTPUT} COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" windows-x64 $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS "${FLUTTER_LIBRARY}" ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ) ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) project(runner LANGUAGES CXX) # Define the application target. To change its name, change BINARY_NAME in the # top-level CMakeLists.txt, 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} WIN32 "flutter_window.cpp" "main.cpp" "utils.cpp" "win32_window.cpp" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" "Runner.rc" "runner.exe.manifest" ) # Apply the standard set of build settings. This can be removed for applications # that need different build settings. apply_standard_settings(${BINARY_NAME}) # Disable Windows macros that collide with C++ standard library functions. target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") # Add dependency libraries and include directories. Add any application-specific # dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/Runner.rc ================================================ // Microsoft Visual C++ generated resource script. // #pragma code_page(65001) #include "resource.h" #define APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 2 resource. // #include "winres.h" ///////////////////////////////////////////////////////////////////////////// #undef APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // English (United States) resources #if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US #ifdef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // TEXTINCLUDE // 1 TEXTINCLUDE BEGIN "resource.h\0" END 2 TEXTINCLUDE BEGIN "#include ""winres.h""\r\n" "\0" END 3 TEXTINCLUDE BEGIN "\r\n" "\0" END #endif // APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Icon // // Icon with lowest ID value placed first to ensure application icon // remains consistent on all systems. IDI_APP_ICON ICON "resources\\app_icon.ico" ///////////////////////////////////////////////////////////////////////////// // // Version // #ifdef FLUTTER_BUILD_NUMBER #define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER #else #define VERSION_AS_NUMBER 1,0,0 #endif #ifdef FLUTTER_BUILD_NAME #define VERSION_AS_STRING #FLUTTER_BUILD_NAME #else #define VERSION_AS_STRING "1.0.0" #endif VS_VERSION_INFO VERSIONINFO FILEVERSION VERSION_AS_NUMBER PRODUCTVERSION VERSION_AS_NUMBER FILEFLAGSMASK VS_FFI_FILEFLAGSMASK #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG #else FILEFLAGS 0x0L #endif FILEOS VOS__WINDOWS32 FILETYPE VFT_APP FILESUBTYPE 0x0L BEGIN BLOCK "StringFileInfo" BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "com.example" "\0" VALUE "FileDescription", "example" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "example" "\0" VALUE "LegalCopyright", "Copyright (C) 2022 com.example. All rights reserved." "\0" VALUE "OriginalFilename", "example.exe" "\0" VALUE "ProductName", "example" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END BLOCK "VarFileInfo" BEGIN VALUE "Translation", 0x409, 1252 END END #endif // English (United States) resources ///////////////////////////////////////////////////////////////////////////// #ifndef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 3 resource. // ///////////////////////////////////////////////////////////////////////////// #endif // not APSTUDIO_INVOKED ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/flutter_window.cpp ================================================ #include "flutter_window.h" #include #include "flutter/generated_plugin_registrant.h" FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} FlutterWindow::~FlutterWindow() {} bool FlutterWindow::OnCreate() { if (!Win32Window::OnCreate()) { return false; } RECT frame = GetClientArea(); // The size here must match the window dimensions to avoid unnecessary surface // creation / destruction in the startup path. flutter_controller_ = std::make_unique( frame.right - frame.left, frame.bottom - frame.top, project_); // Ensure that basic setup of the controller was successful. if (!flutter_controller_->engine() || !flutter_controller_->view()) { return false; } RegisterPlugins(flutter_controller_->engine()); SetChildContent(flutter_controller_->view()->GetNativeWindow()); return true; } void FlutterWindow::OnDestroy() { if (flutter_controller_) { flutter_controller_ = nullptr; } Win32Window::OnDestroy(); } LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam); if (result) { return *result; } } switch (message) { case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; } return Win32Window::MessageHandler(hwnd, message, wparam, lparam); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/flutter_window.h ================================================ #ifndef RUNNER_FLUTTER_WINDOW_H_ #define RUNNER_FLUTTER_WINDOW_H_ #include #include #include #include "win32_window.h" // A window that does nothing but host a Flutter view. class FlutterWindow : public Win32Window { public: // Creates a new FlutterWindow hosting a Flutter view running |project|. explicit FlutterWindow(const flutter::DartProject& project); virtual ~FlutterWindow(); protected: // Win32Window: bool OnCreate() override; void OnDestroy() override; LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept override; private: // The project to run. flutter::DartProject project_; // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; }; #endif // RUNNER_FLUTTER_WINDOW_H_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/main.cpp ================================================ #include #include #include #include "flutter_window.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { CreateAndAttachConsole(); } // Initialize COM, so that it is available for use in the library and/or // plugins. ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); if (!window.CreateAndShow(L"example", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); ::MSG msg; while (::GetMessage(&msg, nullptr, 0, 0)) { ::TranslateMessage(&msg); ::DispatchMessage(&msg); } ::CoUninitialize(); return EXIT_SUCCESS; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/resource.h ================================================ //{{NO_DEPENDENCIES}} // Microsoft Visual C++ generated include file. // Used by Runner.rc // #define IDI_APP_ICON 101 // Next default values for new objects // #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 102 #define _APS_NEXT_COMMAND_VALUE 40001 #define _APS_NEXT_CONTROL_VALUE 1001 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/runner.exe.manifest ================================================ PerMonitorV2 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/utils.cpp ================================================ #include "utils.h" #include #include #include #include #include void CreateAndAttachConsole() { if (::AllocConsole()) { FILE *unused; if (freopen_s(&unused, "CONOUT$", "w", stdout)) { _dup2(_fileno(stdout), 1); } if (freopen_s(&unused, "CONOUT$", "w", stderr)) { _dup2(_fileno(stdout), 2); } std::ios::sync_with_stdio(); FlutterDesktopResyncOutputStreams(); } } std::vector GetCommandLineArguments() { // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. int argc; wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); if (argv == nullptr) { return std::vector(); } std::vector command_line_arguments; // Skip the first argument as it's the binary name. for (int i = 1; i < argc; i++) { command_line_arguments.push_back(Utf8FromUtf16(argv[i])); } ::LocalFree(argv); return command_line_arguments; } std::string Utf8FromUtf16(const wchar_t* utf16_string) { if (utf16_string == nullptr) { return std::string(); } int target_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, nullptr, 0, nullptr, nullptr); std::string utf8_string; if (target_length == 0 || target_length > utf8_string.max_size()) { return utf8_string; } utf8_string.resize(target_length); int converted_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, utf8_string.data(), target_length, nullptr, nullptr); if (converted_length == 0) { return std::string(); } return utf8_string; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/utils.h ================================================ #ifndef RUNNER_UTILS_H_ #define RUNNER_UTILS_H_ #include #include // Creates a console for the process, and redirects stdout and stderr to // it for both the runner and the Flutter library. void CreateAndAttachConsole(); // Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string // encoded in UTF-8. Returns an empty std::string on failure. std::string Utf8FromUtf16(const wchar_t* utf16_string); // Gets the command line arguments passed in as a std::vector, // encoded in UTF-8. Returns an empty std::vector on failure. std::vector GetCommandLineArguments(); #endif // RUNNER_UTILS_H_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/win32_window.cpp ================================================ #include "win32_window.h" #include #include "resource.h" namespace { constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; // The number of Win32Window objects that currently exist. static int g_active_window_count = 0; using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); // Scale helper to convert logical scaler values to physical using passed in // scale factor int Scale(int source, double scale_factor) { return static_cast(source * scale_factor); } // Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. // This API is only needed for PerMonitor V1 awareness mode. void EnableFullDpiSupportIfAvailable(HWND hwnd) { HMODULE user32_module = LoadLibraryA("User32.dll"); if (!user32_module) { return; } auto enable_non_client_dpi_scaling = reinterpret_cast( GetProcAddress(user32_module, "EnableNonClientDpiScaling")); if (enable_non_client_dpi_scaling != nullptr) { enable_non_client_dpi_scaling(hwnd); FreeLibrary(user32_module); } } } // namespace // Manages the Win32Window's window class registration. class WindowClassRegistrar { public: ~WindowClassRegistrar() = default; // Returns the singleton registar instance. static WindowClassRegistrar* GetInstance() { if (!instance_) { instance_ = new WindowClassRegistrar(); } return instance_; } // Returns the name of the window class, registering the class if it hasn't // previously been registered. const wchar_t* GetWindowClass(); // Unregisters the window class. Should only be called if there are no // instances of the window. void UnregisterWindowClass(); private: WindowClassRegistrar() = default; static WindowClassRegistrar* instance_; bool class_registered_ = false; }; WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; const wchar_t* WindowClassRegistrar::GetWindowClass() { if (!class_registered_) { WNDCLASS window_class{}; window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); window_class.lpszClassName = kWindowClassName; window_class.style = CS_HREDRAW | CS_VREDRAW; window_class.cbClsExtra = 0; window_class.cbWndExtra = 0; window_class.hInstance = GetModuleHandle(nullptr); window_class.hIcon = LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); window_class.hbrBackground = 0; window_class.lpszMenuName = nullptr; window_class.lpfnWndProc = Win32Window::WndProc; RegisterClass(&window_class); class_registered_ = true; } return kWindowClassName; } void WindowClassRegistrar::UnregisterWindowClass() { UnregisterClass(kWindowClassName, nullptr); class_registered_ = false; } Win32Window::Win32Window() { ++g_active_window_count; } Win32Window::~Win32Window() { --g_active_window_count; Destroy(); } bool Win32Window::CreateAndShow(const std::wstring& title, const Point& origin, const Size& size) { Destroy(); const wchar_t* window_class = WindowClassRegistrar::GetInstance()->GetWindowClass(); const POINT target_point = {static_cast(origin.x), static_cast(origin.y)}; HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); double scale_factor = dpi / 96.0; HWND window = CreateWindow( window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), Scale(size.width, scale_factor), Scale(size.height, scale_factor), nullptr, nullptr, GetModuleHandle(nullptr), this); if (!window) { return false; } return OnCreate(); } // static LRESULT CALLBACK Win32Window::WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { if (message == WM_NCCREATE) { auto window_struct = reinterpret_cast(lparam); SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast(window_struct->lpCreateParams)); auto that = static_cast(window_struct->lpCreateParams); EnableFullDpiSupportIfAvailable(window); that->window_handle_ = window; } else if (Win32Window* that = GetThisFromHandle(window)) { return that->MessageHandler(window, message, wparam, lparam); } return DefWindowProc(window, message, wparam, lparam); } LRESULT Win32Window::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { switch (message) { case WM_DESTROY: window_handle_ = nullptr; Destroy(); if (quit_on_close_) { PostQuitMessage(0); } return 0; case WM_DPICHANGED: { auto newRectSize = reinterpret_cast(lparam); LONG newWidth = newRectSize->right - newRectSize->left; LONG newHeight = newRectSize->bottom - newRectSize->top; SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, newHeight, SWP_NOZORDER | SWP_NOACTIVATE); return 0; } case WM_SIZE: { RECT rect = GetClientArea(); if (child_content_ != nullptr) { // Size and position the child window. MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, TRUE); } return 0; } case WM_ACTIVATE: if (child_content_ != nullptr) { SetFocus(child_content_); } return 0; } return DefWindowProc(window_handle_, message, wparam, lparam); } void Win32Window::Destroy() { OnDestroy(); if (window_handle_) { DestroyWindow(window_handle_); window_handle_ = nullptr; } if (g_active_window_count == 0) { WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); } } Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { return reinterpret_cast( GetWindowLongPtr(window, GWLP_USERDATA)); } void Win32Window::SetChildContent(HWND content) { child_content_ = content; SetParent(content, window_handle_); RECT frame = GetClientArea(); MoveWindow(content, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, true); SetFocus(child_content_); } RECT Win32Window::GetClientArea() { RECT frame; GetClientRect(window_handle_, &frame); return frame; } HWND Win32Window::GetHandle() { return window_handle_; } void Win32Window::SetQuitOnClose(bool quit_on_close) { quit_on_close_ = quit_on_close; } bool Win32Window::OnCreate() { // No-op; provided for subclasses. return true; } void Win32Window::OnDestroy() { // No-op; provided for subclasses. } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/win32_window.h ================================================ #ifndef RUNNER_WIN32_WINDOW_H_ #define RUNNER_WIN32_WINDOW_H_ #include #include #include #include // A class abstraction for a high DPI-aware Win32 Window. Intended to be // inherited from by classes that wish to specialize with custom // rendering and input handling class Win32Window { public: struct Point { unsigned int x; unsigned int y; Point(unsigned int x, unsigned int y) : x(x), y(y) {} }; struct Size { unsigned int width; unsigned int height; Size(unsigned int width, unsigned int height) : width(width), height(height) {} }; Win32Window(); virtual ~Win32Window(); // Creates and shows a win32 window with |title| and position and size using // |origin| and |size|. New windows are created on the default monitor. Window // sizes are specified to the OS in physical pixels, hence to ensure a // consistent size to will treat the width height passed in to this function // as logical pixels and scale to appropriate for the default monitor. Returns // true if the window was created successfully. bool CreateAndShow(const std::wstring& title, const Point& origin, const Size& size); // Release OS resources associated with window. void Destroy(); // Inserts |content| into the window tree. void SetChildContent(HWND content); // Returns the backing Window handle to enable clients to set icon and other // window properties. Returns nullptr if the window has been destroyed. HWND GetHandle(); // If true, closing this window will quit the application. void SetQuitOnClose(bool quit_on_close); // Return a RECT representing the bounds of the current client area. RECT GetClientArea(); protected: // Processes and route salient window messages for mouse handling, // size change and DPI. Delegates handling of these to member overloads that // inheriting classes can handle. virtual LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Called when CreateAndShow is called, allowing subclass window-related // setup. Subclasses should return false if setup fails. virtual bool OnCreate(); // Called when Destroy is called. virtual void OnDestroy(); private: friend class WindowClassRegistrar; // OS callback called by message pump. Handles the WM_NCCREATE message which // is passed when the non-client area is being created and enables automatic // non-client DPI scaling so that the non-client area automatically // responsponds to changes in DPI. All other messages are handled by // MessageHandler. static LRESULT CALLBACK WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Retrieves a class instance pointer for |window| static Win32Window* GetThisFromHandle(HWND const window) noexcept; bool quit_on_close_ = false; // window handle for top level window. HWND window_handle_ = nullptr; // window handle for hosted content. HWND child_content_ = nullptr; }; #endif // RUNNER_WIN32_WINDOW_H_ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart ================================================ /// AppFlowyBoard library library; export 'src/mutex.dart'; export 'src/popover.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/lib/src/follower.dart ================================================ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; class PopoverCompositedTransformFollower extends CompositedTransformFollower { const PopoverCompositedTransformFollower({ super.key, required super.link, super.showWhenUnlinked = true, super.offset = Offset.zero, super.targetAnchor = Alignment.topLeft, super.followerAnchor = Alignment.topLeft, super.child, }); @override PopoverRenderFollowerLayer createRenderObject(BuildContext context) { final screenSize = MediaQuery.of(context).size; return PopoverRenderFollowerLayer( screenSize: screenSize, link: link, showWhenUnlinked: showWhenUnlinked, offset: offset, leaderAnchor: targetAnchor, followerAnchor: followerAnchor, ); } @override void updateRenderObject( BuildContext context, PopoverRenderFollowerLayer renderObject, ) { final screenSize = MediaQuery.of(context).size; renderObject ..screenSize = screenSize ..link = link ..showWhenUnlinked = showWhenUnlinked ..offset = offset ..leaderAnchor = targetAnchor ..followerAnchor = followerAnchor; } } class PopoverRenderFollowerLayer extends RenderFollowerLayer { PopoverRenderFollowerLayer({ required super.link, super.showWhenUnlinked = true, super.offset = Offset.zero, super.leaderAnchor = Alignment.topLeft, super.followerAnchor = Alignment.topLeft, super.child, required this.screenSize, }); Size screenSize; @override void paint(PaintingContext context, Offset offset) { super.paint(context, offset); if (link.leader == null) { return; } } } class EdgeFollowerLayer extends FollowerLayer { EdgeFollowerLayer({ required super.link, super.showWhenUnlinked = true, super.unlinkedOffset = Offset.zero, super.linkedOffset = Offset.zero, }); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/lib/src/layout.dart ================================================ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import './popover.dart'; class PopoverLayoutDelegate extends SingleChildLayoutDelegate { PopoverLayoutDelegate({ required this.link, required this.direction, required this.offset, required this.windowPadding, this.position, this.showAtCursor = false, }); PopoverLink link; PopoverDirection direction; final Offset offset; final EdgeInsets windowPadding; /// Required when [showAtCursor] is true. /// final Offset? position; /// If true, the popover will be shown at the cursor position. /// This will ignore the [direction], and the child size. /// final bool showAtCursor; @override bool shouldRelayout(PopoverLayoutDelegate oldDelegate) { if (direction != oldDelegate.direction) { return true; } if (link != oldDelegate.link) { return true; } if (link.leaderOffset != oldDelegate.link.leaderOffset) { return true; } if (link.leaderSize != oldDelegate.link.leaderSize) { return true; } return false; } @override Size getSize(BoxConstraints constraints) { return Size( constraints.maxWidth - windowPadding.left - windowPadding.right, constraints.maxHeight - windowPadding.top - windowPadding.bottom, ); } @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { return BoxConstraints( maxWidth: constraints.maxWidth - windowPadding.left - windowPadding.right, maxHeight: constraints.maxHeight - windowPadding.top - windowPadding.bottom, ); } @override Offset getPositionForChild(Size size, Size childSize) { final effectiveOffset = link.leaderOffset; final leaderSize = link.leaderSize; if (effectiveOffset == null || leaderSize == null) { return Offset.zero; } Offset position; if (showAtCursor && this.position != null) { position = this.position! + Offset( effectiveOffset.dx + offset.dx, effectiveOffset.dy + offset.dy, ); } else { final anchorRect = Rect.fromLTWH( effectiveOffset.dx + offset.dx, effectiveOffset.dy + offset.dy, leaderSize.width, leaderSize.height, ); switch (direction) { case PopoverDirection.topLeft: position = Offset( anchorRect.left - childSize.width, anchorRect.top - childSize.height, ); break; case PopoverDirection.topRight: position = Offset( anchorRect.right, anchorRect.top - childSize.height, ); break; case PopoverDirection.bottomLeft: position = Offset( anchorRect.left - childSize.width, anchorRect.bottom, ); break; case PopoverDirection.bottomRight: position = Offset( anchorRect.right, anchorRect.bottom, ); break; case PopoverDirection.center: position = anchorRect.center; break; case PopoverDirection.topWithLeftAligned: position = Offset( anchorRect.left, anchorRect.top - childSize.height, ); break; case PopoverDirection.topWithCenterAligned: position = Offset( anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, anchorRect.top - childSize.height, ); break; case PopoverDirection.topWithRightAligned: position = Offset( anchorRect.right - childSize.width, anchorRect.top - childSize.height, ); break; case PopoverDirection.rightWithTopAligned: position = Offset(anchorRect.right, anchorRect.top); break; case PopoverDirection.rightWithCenterAligned: position = Offset( anchorRect.right, anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, ); break; case PopoverDirection.rightWithBottomAligned: position = Offset( anchorRect.right, anchorRect.bottom - childSize.height, ); break; case PopoverDirection.bottomWithLeftAligned: position = Offset( anchorRect.left, anchorRect.bottom, ); break; case PopoverDirection.bottomWithCenterAligned: position = Offset( anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, anchorRect.bottom, ); break; case PopoverDirection.bottomWithRightAligned: position = Offset( anchorRect.right - childSize.width, anchorRect.bottom, ); break; case PopoverDirection.leftWithTopAligned: position = Offset( anchorRect.left - childSize.width, anchorRect.top, ); break; case PopoverDirection.leftWithCenterAligned: position = Offset( anchorRect.left - childSize.width, anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, ); break; case PopoverDirection.leftWithBottomAligned: position = Offset( anchorRect.left - childSize.width, anchorRect.bottom - childSize.height, ); break; default: throw UnimplementedError(); } } return Offset( math.max( windowPadding.left, math.min( windowPadding.left + size.width - childSize.width, position.dx, ), ), math.max( windowPadding.top, math.min( windowPadding.top + size.height - childSize.height, position.dy, ), ), ); } PopoverLayoutDelegate copyWith({ PopoverLink? link, PopoverDirection? direction, Offset? offset, EdgeInsets? windowPadding, Offset? position, bool? showAtCursor, }) { return PopoverLayoutDelegate( link: link ?? this.link, direction: direction ?? this.direction, offset: offset ?? this.offset, windowPadding: windowPadding ?? this.windowPadding, position: position ?? this.position, showAtCursor: showAtCursor ?? this.showAtCursor, ); } } class PopoverTarget extends SingleChildRenderObjectWidget { const PopoverTarget({ super.key, super.child, required this.link, }); final PopoverLink link; @override PopoverTargetRenderBox createRenderObject(BuildContext context) { return PopoverTargetRenderBox( link: link, ); } @override void updateRenderObject( BuildContext context, PopoverTargetRenderBox renderObject, ) { renderObject.link = link; } } class PopoverTargetRenderBox extends RenderProxyBox { PopoverTargetRenderBox({ required this.link, RenderBox? child, }) : super(child); PopoverLink link; @override bool get alwaysNeedsCompositing => true; @override void performLayout() { super.performLayout(); link.leaderSize = size; } @override void paint(PaintingContext context, Offset offset) { link.leaderOffset = localToGlobal(Offset.zero); super.paint(context, offset); } @override void detach() { super.detach(); link.leaderOffset = null; link.leaderSize = null; } @override void attach(covariant PipelineOwner owner) { super.attach(owner); if (hasSize) { // The leaderSize was set after [performLayout], but was // set to null when [detach] get called. // // set the leaderSize when attach get called link.leaderSize = size; } } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('link', link)); } } class PopoverLink { Offset? leaderOffset; Size? leaderSize; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart ================================================ import 'dart:collection'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; typedef _EntryMap = LinkedHashMap; class RootOverlayEntry { final _EntryMap _entries = _EntryMap(); bool contains(PopoverState state) => _entries.containsKey(state); bool get isEmpty => _entries.isEmpty; bool get isNotEmpty => _entries.isNotEmpty; void addEntry( BuildContext context, String id, PopoverState newState, OverlayEntry entry, bool asBarrier, AnimationController animationController, ) { _entries[newState] = OverlayEntryContext( id, entry, newState, asBarrier, animationController, ); Overlay.of(context).insert(entry); } void removeEntry(PopoverState state) { final removedEntry = _entries.remove(state); removedEntry?.overlayEntry.remove(); } OverlayEntryContext? popEntry() { if (isEmpty) { return null; } final lastEntry = _entries.values.last; _entries.remove(lastEntry.popoverState); lastEntry.animationController.reverse().then((_) { lastEntry.overlayEntry.remove(); lastEntry.popoverState.widget.onClose?.call(); }); return lastEntry.asBarrier ? lastEntry : popEntry(); } bool isLastEntryAsBarrier() { if (isEmpty) { return false; } return _entries.values.last.asBarrier; } } class OverlayEntryContext { OverlayEntryContext( this.id, this.overlayEntry, this.popoverState, this.asBarrier, this.animationController, ); final String id; final OverlayEntry overlayEntry; final PopoverState popoverState; final bool asBarrier; final AnimationController animationController; @override String toString() { return 'OverlayEntryContext(id: $id, asBarrier: $asBarrier, popoverState: ${popoverState.widget.debugId})'; } } class PopoverMask extends StatelessWidget { const PopoverMask({ super.key, required this.onTap, this.decoration, }); final VoidCallback onTap; final Decoration? decoration; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( decoration: decoration, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mutex.dart ================================================ import 'package:flutter/material.dart'; import 'popover.dart'; /// If multiple popovers are exclusive, /// pass the same mutex to them. class PopoverMutex { PopoverMutex(); final _PopoverStateNotifier _stateNotifier = _PopoverStateNotifier(); void addPopoverListener(VoidCallback listener) { _stateNotifier.addListener(listener); } void removePopoverListener(VoidCallback listener) { _stateNotifier.removeListener(listener); } void close() => _stateNotifier.state?.close(); PopoverState? get state => _stateNotifier.state; set state(PopoverState? newState) => _stateNotifier.state = newState; void removeState() { _stateNotifier.state = null; } void dispose() { _stateNotifier.dispose(); } } class _PopoverStateNotifier extends ChangeNotifier { PopoverState? _state; PopoverState? get state => _state; set state(PopoverState? newState) { if (_state != null && _state != newState) { _state?.close(); } _state = newState; notifyListeners(); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart ================================================ import 'package:appflowy_popover/src/layout.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'mask.dart'; import 'mutex.dart'; class PopoverController { PopoverState? _state; void close() => _state?.close(); void show() => _state?.showOverlay(); void showAt(Offset position) => _state?.showOverlay(position); } class PopoverTriggerFlags { static const int none = 0x00; static const int click = 0x01; static const int hover = 0x02; static const int secondaryClick = 0x04; } enum PopoverDirection { // Corner aligned with a corner of the SourceWidget topLeft, topRight, bottomLeft, bottomRight, center, // Edge aligned with a edge of the SourceWidget topWithLeftAligned, topWithCenterAligned, topWithRightAligned, rightWithTopAligned, rightWithCenterAligned, rightWithBottomAligned, bottomWithLeftAligned, bottomWithCenterAligned, bottomWithRightAligned, leftWithTopAligned, leftWithCenterAligned, leftWithBottomAligned, custom, } enum PopoverClickHandler { listener, gestureDetector, } class Popover extends StatefulWidget { const Popover({ super.key, required this.child, required this.popupBuilder, this.controller, this.offset, this.triggerActions = 0, this.direction = PopoverDirection.rightWithTopAligned, this.mutex, this.windowPadding, this.onOpen, this.onClose, this.canClose, this.asBarrier = false, this.clickHandler = PopoverClickHandler.listener, this.skipTraversal = false, this.animationDuration = const Duration(milliseconds: 200), this.beginOpacity = 0.0, this.endOpacity = 1.0, this.beginScaleFactor = 1.0, this.endScaleFactor = 1.0, this.slideDistance = 5.0, this.debugId, this.maskDecoration = const BoxDecoration( color: Color.fromARGB(0, 244, 67, 54), ), this.showAtCursor = false, }); final PopoverController? controller; /// The offset from the [child] where the popover will be drawn final Offset? offset; /// Amount of padding between the edges of the window and the popover final EdgeInsets? windowPadding; final Decoration? maskDecoration; /// The function used to build the popover. final Widget? Function(BuildContext context) popupBuilder; /// Specify how the popover can be triggered when interacting with the child /// by supplying a bitwise-OR combination of one or more [PopoverTriggerFlags] final int triggerActions; /// If multiple popovers are exclusive, /// pass the same mutex to them. final PopoverMutex? mutex; /// The direction of the popover final PopoverDirection direction; final VoidCallback? onOpen; final VoidCallback? onClose; final Future Function()? canClose; final bool asBarrier; /// The widget that will be used to trigger the popover. /// /// Why do we need this? /// Because if the parent widget of the popover is GestureDetector, /// the conflict won't be resolve by using Listener, we want these two gestures exclusive. final PopoverClickHandler clickHandler; final bool skipTraversal; /// Animation time of the popover. final Duration animationDuration; /// The distance of the popover's slide animation. final double slideDistance; /// The scale factor of the popover's scale animation. final double beginScaleFactor; final double endScaleFactor; /// The opacity of the popover's fade animation. final double beginOpacity; final double endOpacity; final String? debugId; /// Whether the popover should be shown at the cursor position. /// /// This only works when using [PopoverClickHandler.listener] as the click handler. /// /// Alternatively for having a normal popover, and use the cursor position only on /// secondary click, consider showing the popover programatically with [PopoverController.showAt]. /// final bool showAtCursor; /// The content area of the popover. final Widget child; @override State createState() => PopoverState(); } class PopoverState extends State with SingleTickerProviderStateMixin { static final RootOverlayEntry rootEntry = RootOverlayEntry(); final PopoverLink popoverLink = PopoverLink(); late PopoverLayoutDelegate layoutDelegate = PopoverLayoutDelegate( direction: widget.direction, link: popoverLink, offset: widget.offset ?? Offset.zero, windowPadding: widget.windowPadding ?? EdgeInsets.zero, ); late AnimationController animationController; late Animation fadeAnimation; late Animation scaleAnimation; late Animation slideAnimation; // If the widget is disposed, prevent the animation from being called. bool isDisposed = false; Offset? cursorPosition; @override void initState() { super.initState(); widget.controller?._state = this; _buildAnimations(); } @override void deactivate() { close(notify: false); super.deactivate(); } @override void dispose() { isDisposed = true; animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return PopoverTarget( link: popoverLink, child: _buildChild(context), ); } @override void reassemble() { // clear the overlay while (rootEntry.isNotEmpty) { rootEntry.popEntry(); } super.reassemble(); } void showOverlay([Offset? position]) { close(withAnimation: true); if (widget.mutex != null) { widget.mutex?.state = this; } if (position != null) { final RenderBox? renderBox = context.findRenderObject() as RenderBox?; final offset = renderBox?.globalToLocal(position); layoutDelegate = layoutDelegate.copyWith( position: offset ?? position, windowPadding: EdgeInsets.zero, showAtCursor: true, ); } final shouldAddMask = rootEntry.isEmpty; rootEntry.addEntry( context, widget.debugId ?? '', this, OverlayEntry(builder: (_) => _buildOverlayContent(shouldAddMask)), widget.asBarrier, animationController, ); if (widget.animationDuration != Duration.zero) { animationController.forward(); } } void close({ bool notify = true, bool withAnimation = false, }) { if (rootEntry.contains(this)) { void callback() { rootEntry.removeEntry(this); if (notify) { widget.onClose?.call(); } } if (isDisposed || !withAnimation || widget.animationDuration == Duration.zero) { callback(); } else { animationController.reverse().then((_) => callback()); } } } void _removeRootOverlay() { rootEntry.popEntry(); if (widget.mutex?.state == this) { widget.mutex?.removeState(); } } Widget _buildChild(BuildContext context) { Widget child = widget.child; if (widget.triggerActions == 0) { return child; } child = _buildClickHandler( child, () { widget.onOpen?.call(); if (widget.triggerActions & PopoverTriggerFlags.none != 0) { return; } showOverlay(cursorPosition); }, ); if (widget.triggerActions & PopoverTriggerFlags.hover != 0) { child = MouseRegion( onEnter: (event) => showOverlay(), child: child, ); } return child; } Widget _buildClickHandler(Widget child, VoidCallback handler) { return switch (widget.clickHandler) { PopoverClickHandler.listener => Listener( onPointerDown: (event) { cursorPosition = widget.showAtCursor ? event.position : null; if (event.buttons == kSecondaryMouseButton && widget.triggerActions & PopoverTriggerFlags.secondaryClick != 0) { return _callHandler(handler); } if (event.buttons == kPrimaryMouseButton && widget.triggerActions & PopoverTriggerFlags.click != 0) { return _callHandler(handler); } }, child: child, ), PopoverClickHandler.gestureDetector => GestureDetector( onTap: () { if (widget.triggerActions & PopoverTriggerFlags.click != 0) { return _callHandler(handler); } }, onSecondaryTap: () { if (widget.triggerActions & PopoverTriggerFlags.secondaryClick != 0) { return _callHandler(handler); } }, child: child, ), }; } void _callHandler(VoidCallback handler) { if (rootEntry.contains(this)) { close(); } else { handler(); } } Widget _buildOverlayContent(bool shouldAddMask) { return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): _removeRootOverlay, }, child: FocusScope( child: Stack( children: [ if (shouldAddMask) _buildMask(), _buildPopoverContainer(), ], ), ), ); } Widget _buildMask() { return PopoverMask( decoration: widget.maskDecoration, onTap: () async { if (await widget.canClose?.call() ?? true) { _removeRootOverlay(); } }, ); } Widget _buildPopoverContainer() { Widget child = PopoverContainer( delegate: layoutDelegate, popupBuilder: widget.popupBuilder, skipTraversal: widget.skipTraversal, onClose: close, onCloseAll: _removeRootOverlay, ); if (widget.animationDuration != Duration.zero) { child = AnimatedBuilder( animation: animationController, builder: (_, child) => Opacity( opacity: fadeAnimation.value, child: Transform.scale( scale: scaleAnimation.value, child: Transform.translate( offset: slideAnimation.value, child: child, ), ), ), child: child, ); } return child; } void _buildAnimations() { animationController = AnimationController( duration: widget.animationDuration, vsync: this, ); fadeAnimation = _buildFadeAnimation(); scaleAnimation = _buildScaleAnimation(); slideAnimation = _buildSlideAnimation(); } Animation _buildFadeAnimation() { return Tween( begin: widget.beginOpacity, end: widget.endOpacity, ).animate( CurvedAnimation( parent: animationController, curve: Curves.easeInOut, ), ); } Animation _buildScaleAnimation() { return Tween( begin: widget.beginScaleFactor, end: widget.endScaleFactor, ).animate( CurvedAnimation( parent: animationController, curve: Curves.easeInOut, ), ); } Animation _buildSlideAnimation() { final values = _getSlideAnimationValues(); return Tween( begin: values.$1, end: values.$2, ).animate( CurvedAnimation( parent: animationController, curve: Curves.linear, ), ); } (Offset, Offset) _getSlideAnimationValues() { final slideDistance = widget.slideDistance; switch (widget.direction) { case PopoverDirection.bottomWithLeftAligned: return ( Offset(-slideDistance, -slideDistance), Offset.zero, ); case PopoverDirection.bottomWithCenterAligned: return ( Offset(0, -slideDistance), Offset.zero, ); case PopoverDirection.bottomWithRightAligned: return ( Offset(slideDistance, -slideDistance), Offset.zero, ); case PopoverDirection.topWithLeftAligned: return ( Offset(-slideDistance, slideDistance), Offset.zero, ); case PopoverDirection.topWithCenterAligned: return ( Offset(0, slideDistance), Offset.zero, ); case PopoverDirection.topWithRightAligned: return ( Offset(slideDistance, slideDistance), Offset.zero, ); case PopoverDirection.leftWithTopAligned: case PopoverDirection.leftWithCenterAligned: case PopoverDirection.leftWithBottomAligned: return ( Offset(slideDistance, 0), Offset.zero, ); case PopoverDirection.rightWithTopAligned: case PopoverDirection.rightWithCenterAligned: case PopoverDirection.rightWithBottomAligned: return ( Offset(-slideDistance, 0), Offset.zero, ); default: return (Offset.zero, Offset.zero); } } } class PopoverContainer extends StatefulWidget { const PopoverContainer({ super.key, required this.popupBuilder, required this.delegate, required this.onClose, required this.onCloseAll, required this.skipTraversal, }); final Widget? Function(BuildContext context) popupBuilder; final void Function() onClose; final void Function() onCloseAll; final bool skipTraversal; final PopoverLayoutDelegate delegate; @override State createState() => PopoverContainerState(); static PopoverContainerState of(BuildContext context) { if (context is StatefulElement && context.state is PopoverContainerState) { return context.state as PopoverContainerState; } return context.findAncestorStateOfType()!; } static PopoverContainerState? maybeOf(BuildContext context) { if (context is StatefulElement && context.state is PopoverContainerState) { return context.state as PopoverContainerState; } return context.findAncestorStateOfType(); } } class PopoverContainerState extends State { @override Widget build(BuildContext context) { return Focus( autofocus: true, skipTraversal: widget.skipTraversal, child: CustomSingleChildLayout( delegate: widget.delegate, child: widget.popupBuilder(context), ), ); } void close() => widget.onClose(); void closeAll() => widget.onCloseAll(); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/pubspec.yaml ================================================ name: appflowy_popover description: A new Flutter package project. version: 0.0.1 homepage: https://appflowy.io environment: flutter: ">=3.22.0" sdk: ">=3.3.0 <4.0.0" dependencies: flutter: sdk: flutter dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_popover/test/popover_test.dart ================================================ import 'package:flutter_test/flutter_test.dart'; void main() { test('adds one to input values', () { // final calculator = Calculator(); // expect(calculator.addOne(2), 3); // expect(calculator.addOne(-7), -6); // expect(calculator.addOne(0), 1); }); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ 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 # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. /pubspec.lock **/doc/api/ .dart_tool/ build/ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/.metadata ================================================ # 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: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4" channel: "[user-branch]" project_type: package ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/CHANGELOG.md ================================================ ## 0.0.1 * TODO: Describe initial release. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/LICENSE ================================================ TODO: Add your license here. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/README.md ================================================ TODO: Put a short description of the package here that helps potential users know whether this package might be useful for them. ## Features TODO: List what your package can do. Maybe include images, gifs, or videos. ## Getting started TODO: List prerequisites and provide or point to information on how to start using the package. ## Usage TODO: Include short and useful examples for package users. Add longer examples to `/example` folder. ```dart const like = 'sample'; ``` ## Additional information TODO: Tell users more about the package: where to find more information, how to contribute to the package, how to file issues, what response they can expect from the package authors, and more. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/analysis_options.yaml ================================================ include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart ================================================ /// AppFlowyPopover library library; export 'src/async_result.dart'; export 'src/result.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart ================================================ import 'package:appflowy_result/appflowy_result.dart'; typedef FlowyAsyncResult = Future>; extension FlowyAsyncResultExtension on FlowyAsyncResult { Future getOrElse(S Function(F f) onFailure) { return then((result) => result.getOrElse(onFailure)); } Future toNullable() { return then((result) => result.toNullable()); } Future getOrThrow() { return then((result) => result.getOrThrow()); } Future fold( W Function(S s) onSuccess, W Function(F f) onFailure, ) { return then((result) => result.fold(onSuccess, onFailure)); } Future isError() { return then((result) => result.isFailure); } Future isSuccess() { return then((result) => result.isSuccess); } FlowyAsyncResult onFailure(void Function(F failure) onFailure) { return then((result) => result..onFailure(onFailure)); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart ================================================ abstract class FlowyResult { const FlowyResult(); factory FlowyResult.success(S s) => FlowySuccess(s); factory FlowyResult.failure(F f) => FlowyFailure(f); T fold(T Function(S s) onSuccess, T Function(F f) onFailure); FlowyResult map(T Function(S success) fn); FlowyResult mapError(T Function(F failure) fn); bool get isSuccess; bool get isFailure; S? toNullable(); T? onSuccess(T? Function(S s) onSuccess); T? onFailure(T? Function(F f) onFailure); S getOrElse(S Function(F failure) onFailure); S getOrThrow(); F getFailure(); } class FlowySuccess implements FlowyResult { final S _value; FlowySuccess(this._value); S get value => _value; @override bool operator ==(Object other) => identical(this, other) || other is FlowySuccess && runtimeType == other.runtimeType && _value == other._value; @override int get hashCode => _value.hashCode; @override String toString() => 'Success(value: $_value)'; @override T fold(T Function(S s) onSuccess, T Function(F e) onFailure) => onSuccess(_value); @override map(T Function(S success) fn) { return FlowySuccess(fn(_value)); } @override FlowyResult mapError(T Function(F error) fn) { return FlowySuccess(_value); } @override bool get isSuccess => true; @override bool get isFailure => false; @override S? toNullable() { return _value; } @override T? onSuccess(T? Function(S success) onSuccess) { return onSuccess(_value); } @override T? onFailure(T? Function(F failure) onFailure) { return null; } @override S getOrElse(S Function(F failure) onFailure) { return _value; } @override S getOrThrow() { return _value; } @override F getFailure() { throw UnimplementedError(); } } class FlowyFailure implements FlowyResult { final F _value; FlowyFailure(this._value); F get error => _value; @override bool operator ==(Object other) => identical(this, other) || other is FlowyFailure && runtimeType == other.runtimeType && _value == other._value; @override int get hashCode => _value.hashCode; @override String toString() => 'Failure(error: $_value)'; @override T fold(T Function(S s) onSuccess, T Function(F e) onFailure) => onFailure(_value); @override map(T Function(S success) fn) { return FlowyFailure(_value); } @override FlowyResult mapError(T Function(F error) fn) { return FlowyFailure(fn(_value)); } @override bool get isSuccess => false; @override bool get isFailure => true; @override S? toNullable() { return null; } @override T? onSuccess(T? Function(S success) onSuccess) { return null; } @override T? onFailure(T? Function(F failure) onFailure) { return onFailure(_value); } @override S getOrElse(S Function(F failure) onFailure) { return onFailure(_value); } @override S getOrThrow() { throw _value; } @override F getFailure() { return _value; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml ================================================ name: appflowy_result description: "A new Flutter package project." version: 0.0.1 homepage: https://github.com/appflowy-io/appflowy environment: sdk: ">=3.3.0 <4.0.0" flutter: ">=1.17.0" dev_dependencies: flutter_lints: ^3.0.0 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ 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 # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. /pubspec.lock **/doc/api/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies build/ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/.metadata ================================================ # 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: "d8a9f9a52e5af486f80d932e838ee93861ffd863" channel: "[user-branch]" project_type: package ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/CHANGELOG.md ================================================ ## 0.0.1 * TODO: Describe initial release. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/LICENSE ================================================ TODO: Add your license here. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/README.md ================================================ # AppFlowy UI AppFlowy UI is a Flutter package that provides a collection of reusable UI components following the AppFlowy design system. These components are designed to be consistent, accessible, and easy to use. ## Features - **Design System Components**: Buttons, text fields, and more UI components that follow the AppFlowy design system - **Theming**: Consistent theming across all components with light and dark mode support ## Installation Add the following to your `pubspec.yaml` file: ```yaml dependencies: appflowy_ui: ^1.0.0 ``` ## Supported components - [x] Button - [x] TextField - [ ] Avatar - [ ] Checkbox - [ ] Grid - [ ] Link - [ ] Loading & Progress Indicator - [ ] Menu - [ ] Message Box - [ ] Navigation Bar - [ ] Popover - [ ] Scroll Bar - [ ] Tab Bar - [ ] Toggle - [ ] Tooltip ## Reference Figma: https://www.figma.com/design/aphWa2OgkqyIragpatdk7a/Design-System ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/analysis_options.yaml ================================================ include: package:flutter_lints/flutter.yaml linter: rules: - require_trailing_commas - prefer_collection_literals - prefer_final_fields - prefer_final_in_for_each - prefer_final_locals - sized_box_for_whitespace - use_decorated_box - unnecessary_parenthesis - unnecessary_await_in_return - unnecessary_raw_strings - avoid_unnecessary_containers - avoid_redundant_argument_values - avoid_unused_constructor_parameters - always_declare_return_types - sort_constructors_first - unawaited_futures errors: invalid_annotation_target: ignore ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/.gitignore ================================================ # 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 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/.metadata ================================================ # 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: "d8a9f9a52e5af486f80d932e838ee93861ffd863" channel: "[user-branch]" project_type: app # Tracks metadata for the flutter migrate command migration: platforms: - platform: root create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 - platform: macos create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 # 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' ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/README.md ================================================ # AppFlowy UI Example This example demonstrates how to use the `appflowy_ui` package in a Flutter application. ## Getting Started To run this example: 1. Ensure you have Flutter installed and set up on your machine 2. Clone this repository 3. Navigate to the example directory: ```bash cd example ``` 4. Get the dependencies: ```bash flutter pub get ``` 5. Run the example: ```bash flutter run ``` ## Features Demonstrated - Basic app structure using AppFlowy UI components - Material 3 design integration - Responsive layout ## Project Structure - `lib/main.dart`: The main application file - `pubspec.yaml`: Project dependencies and configuration ## Additional Resources For more information about the AppFlowy UI package, please refer to: - The main package documentation - [AppFlowy Website](https://appflowy.io) - [AppFlowy GitHub Repository](https://github.com/AppFlowy-IO/AppFlowy) ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/analysis_options.yaml ================================================ # This file configures the analyzer, which statically analyzes Dart code to # check for errors, warnings, and lints. # # The issues identified by the analyzer are surfaced in the UI of Dart-enabled # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be # invoked from the command line by running `flutter analyze`. # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at https://dart.dev/lints. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code # or a specific dart file by using the `// ignore: name_of_lint` and # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/lib/main.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; import 'src/avatar/avatar_page.dart'; import 'src/buttons/buttons_page.dart'; import 'src/dropdown_menu/dropdown_menu_page.dart'; import 'src/menu/menu_page.dart'; import 'src/modal/modal_page.dart'; import 'src/textfield/textfield_page.dart'; enum ThemeMode { light, dark, } final themeMode = ValueNotifier(ThemeMode.light); void main() { runApp( const MyApp(), ); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: themeMode, builder: (context, themeMode, child) { final themeBuilder = AppFlowyDefaultTheme(); final themeData = themeMode == ThemeMode.light ? ThemeData.light() : ThemeData.dark(); return AnimatedAppFlowyTheme( data: themeMode == ThemeMode.light ? themeBuilder.light() : themeBuilder.dark(), child: MaterialApp( debugShowCheckedModeBanner: false, title: 'AppFlowy UI Example', theme: themeData.copyWith( visualDensity: VisualDensity.standard, ), home: const MyHomePage( title: 'AppFlowy UI', ), ), ); }, ); } } class MyHomePage extends StatefulWidget { const MyHomePage({ super.key, required this.title, }); final String title; @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { final tabs = [ Tab(text: 'Button'), Tab(text: 'TextField'), Tab(text: 'Modal'), Tab(text: 'Avatar'), Tab(text: 'Menu'), Tab(text: 'Dropdown Menu'), ]; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return DefaultTabController( length: tabs.length, child: Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text( widget.title, style: theme.textStyle.title.enhanced( color: theme.textColorScheme.primary, ), ), actions: [ IconButton( icon: Icon( Theme.of(context).brightness == Brightness.light ? Icons.dark_mode : Icons.light_mode, ), onPressed: _toggleTheme, tooltip: 'Toggle theme', ), ], ), body: TabBarView( children: [ ButtonsPage(), TextFieldPage(), ModalPage(), AvatarPage(), MenuPage(), DropdownMenuPage(), ], ), bottomNavigationBar: TabBar( tabs: tabs, ), floatingActionButton: null, ), ); } void _toggleTheme() { themeMode.value = themeMode.value == ThemeMode.light ? ThemeMode.dark : ThemeMode.light; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/avatar/avatar_page.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class AvatarPage extends StatelessWidget { const AvatarPage({super.key}); @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionTitle('Avatar with Name (Initials)'), Wrap( spacing: 16, children: [ AFAvatar(name: 'Lucas', size: AFAvatarSize.xs), AFAvatar(name: 'Vivian', size: AFAvatarSize.s), AFAvatar(name: 'John', size: AFAvatarSize.m), AFAvatar(name: 'Cindy', size: AFAvatarSize.l), AFAvatar(name: 'Alex', size: AFAvatarSize.xl), ], ), const SizedBox(height: 32), _sectionTitle('Avatar with Image URL'), Wrap( spacing: 16, children: [ AFAvatar( url: 'https://avatar.iran.liara.run/public/35', size: AFAvatarSize.xs, ), AFAvatar( url: 'https://avatar.iran.liara.run/public/36', size: AFAvatarSize.s, ), AFAvatar( url: 'https://avatar.iran.liara.run/public/37', size: AFAvatarSize.m, ), AFAvatar( url: 'https://avatar.iran.liara.run/public/38', size: AFAvatarSize.l, ), AFAvatar( url: 'https://avatar.iran.liara.run/public/39', size: AFAvatarSize.xl, ), ], ), const SizedBox(height: 32), _sectionTitle('Custom Colors'), Wrap( spacing: 16, children: [ AFAvatar( name: 'Nina', size: AFAvatarSize.l, backgroundColor: Colors.deepPurple, textColor: Colors.white, ), AFAvatar( name: 'Lucas Xu', size: AFAvatarSize.l, backgroundColor: Colors.amber, textColor: Colors.black, ), AFAvatar( name: 'A', size: AFAvatarSize.l, backgroundColor: Colors.green, textColor: Colors.white, ), ], ), ], ), ); } Widget _sectionTitle(String text) => Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( text, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class ButtonsPage extends StatelessWidget { const ButtonsPage({super.key}); @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSection( 'Filled Text Buttons', [ AFFilledTextButton.primary( text: 'Primary Button', onTap: () {}, ), const SizedBox(width: 16), AFFilledTextButton.destructive( text: 'Destructive Button', onTap: () {}, ), const SizedBox(width: 16), AFFilledTextButton.disabled( text: 'Disabled Button', ), ], ), const SizedBox(height: 32), _buildSection( 'Filled Icon Text Buttons', [ AFFilledButton.primary( onTap: () {}, builder: (context, isHovering, disabled) => Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.add, size: 20, color: AppFlowyTheme.of(context).textColorScheme.onFill, ), const SizedBox(width: 8), Text( 'Primary Button', style: TextStyle( color: AppFlowyTheme.of(context).textColorScheme.onFill, ), ), ], ), ), const SizedBox(width: 16), AFFilledButton.destructive( onTap: () {}, builder: (context, isHovering, disabled) => Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.delete, size: 20, color: AppFlowyTheme.of(context).textColorScheme.onFill, ), const SizedBox(width: 8), Text( 'Destructive Button', style: TextStyle( color: AppFlowyTheme.of(context).textColorScheme.onFill, ), ), ], ), ), const SizedBox(width: 16), AFFilledButton.disabled( builder: (context, isHovering, disabled) => Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.block, size: 20, color: AppFlowyTheme.of(context).textColorScheme.tertiary, ), const SizedBox(width: 8), Text( 'Disabled Button', style: TextStyle( color: AppFlowyTheme.of(context).textColorScheme.tertiary, ), ), ], ), ), ], ), const SizedBox(height: 32), _buildSection( 'Outlined Text Buttons', [ AFOutlinedTextButton.normal( text: 'Normal Button', onTap: () {}, ), const SizedBox(width: 16), AFOutlinedTextButton.destructive( text: 'Destructive Button', onTap: () {}, ), const SizedBox(width: 16), AFOutlinedTextButton.disabled( text: 'Disabled Button', ), ], ), const SizedBox(height: 32), _buildSection( 'Outlined Icon Text Buttons', [ AFOutlinedButton.normal( onTap: () {}, builder: (context, isHovering, disabled) => Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.add, size: 20, color: AppFlowyTheme.of(context).textColorScheme.primary, ), const SizedBox(width: 8), Text( 'Normal Button', style: TextStyle( color: AppFlowyTheme.of(context).textColorScheme.primary, ), ), ], ), ), const SizedBox(width: 16), AFOutlinedButton.destructive( onTap: () {}, builder: (context, isHovering, disabled) => Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.delete, size: 20, color: AppFlowyTheme.of(context).textColorScheme.error, ), const SizedBox(width: 8), Text( 'Destructive Button', style: TextStyle( color: AppFlowyTheme.of(context).textColorScheme.error, ), ), ], ), ), const SizedBox(width: 16), AFOutlinedButton.disabled( builder: (context, isHovering, disabled) => Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.block, size: 20, color: AppFlowyTheme.of(context).textColorScheme.tertiary, ), const SizedBox(width: 8), Text( 'Disabled Button', style: TextStyle( color: AppFlowyTheme.of(context).textColorScheme.tertiary, ), ), ], ), ), ], ), const SizedBox(height: 32), _buildSection( 'Ghost Buttons', [ AFGhostTextButton.primary( text: 'Primary Button', onTap: () {}, ), const SizedBox(width: 16), AFGhostTextButton.disabled( text: 'Disabled Button', ), ], ), const SizedBox(height: 32), _buildSection( 'Button with alignment', [ SizedBox( width: 200, child: AFFilledTextButton.primary( text: 'Left Button', onTap: () {}, alignment: Alignment.centerLeft, ), ), const SizedBox(width: 16), SizedBox( width: 200, child: AFFilledTextButton.primary( text: 'Center Button', onTap: () {}, alignment: Alignment.center, ), ), const SizedBox(width: 16), SizedBox( width: 200, child: AFFilledTextButton.primary( text: 'Right Button', onTap: () {}, alignment: Alignment.centerRight, ), ), ], ), const SizedBox(height: 32), _buildSection( 'Button Sizes', [ AFFilledTextButton.primary( text: 'Small Button', onTap: () {}, size: AFButtonSize.s, ), const SizedBox(width: 16), AFFilledTextButton.primary( text: 'Medium Button', onTap: () {}, ), const SizedBox(width: 16), AFFilledTextButton.primary( text: 'Large Button', onTap: () {}, size: AFButtonSize.l, ), const SizedBox(width: 16), AFFilledTextButton.primary( text: 'Extra Large Button', onTap: () {}, size: AFButtonSize.xl, ), ], ), ], ), ); } Widget _buildSection(String title, List children) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, children: children, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/dropdown_menu/dropdown_menu_page.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class AFDropDownMenuItem with AFDropDownMenuMixin { const AFDropDownMenuItem({ required this.label, }); @override final String label; } class DropdownMenuPage extends StatefulWidget { const DropdownMenuPage({super.key}); @override State createState() => _DropdownMenuPageState(); } class _DropdownMenuPageState extends State { List selectedItems = []; bool isDisabled = false; bool isMultiselect = false; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Stack( children: [ Positioned( left: theme.spacing.xxl, top: theme.spacing.xxl, child: Container( padding: EdgeInsets.all( theme.spacing.m, ), decoration: BoxDecoration( color: theme.backgroundColorScheme.primary, borderRadius: BorderRadius.circular(theme.borderRadius.m), boxShadow: theme.shadow.medium, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildOption( 'is disabled', isDisabled, (value) { setState(() => isDisabled = value); }, ), // _buildOption( // 'multiselect', // isMultiselect, // (value) { // setState(() => isMultiselect = value); // }, // ), ], ), ), ), Center( child: SizedBox( width: 240, child: AFDropDownMenu( items: items, selectedItems: selectedItems, isDisabled: isDisabled, // isMultiselect: isMultiselect, onSelected: (value) { if (value != null) { setState(() { if (isMultiselect) { if (selectedItems.contains(value)) { selectedItems.remove(value); } else { selectedItems.add(value); } } else { selectedItems ..clear() ..add(value); } }); } }, ), ), ), ], ); } Widget _buildOption( String label, bool value, void Function(bool) onChanged, ) { return Row( children: [ SizedBox( width: 200, child: Text(label), ), Switch( value: value, onChanged: onChanged, ), ], ); } static const items = [ AFDropDownMenuItem(label: 'Item 1'), AFDropDownMenuItem(label: 'Item 2'), AFDropDownMenuItem(label: 'Item 3'), AFDropDownMenuItem(label: 'Item 4'), AFDropDownMenuItem(label: 'Item 5'), ]; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/menu/menu_page.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_svg/svg.dart'; /// A showcase page for the AFMenu, AFMenuSection, and AFTextMenuItem components. class MenuPage extends StatefulWidget { const MenuPage({super.key}); @override State createState() => _MenuPageState(); } class _MenuPageState extends State { final popoverController = AFPopoverController(); @override void dispose() { popoverController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final leading = SvgPicture.asset( 'assets/images/vector.svg', colorFilter: ColorFilter.mode( theme.textColorScheme.primary, BlendMode.srcIn, ), ); final logo = Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(theme.borderRadius.m), border: Border.all( color: theme.borderColorScheme.primary, ), ), padding: EdgeInsets.all(theme.spacing.xs), child: const FlutterLogo(size: 18), ); final arrowRight = SvgPicture.asset( 'assets/images/arrow_right.svg', width: 20, height: 20, ); final animationDuration = const Duration(milliseconds: 120); return Center( child: SingleChildScrollView( child: Wrap( crossAxisAlignment: WrapCrossAlignment.start, runAlignment: WrapAlignment.start, runSpacing: 16, spacing: 16, children: [ AFMenu( width: 240, children: [ AFMenuSection( title: 'Section 1', children: [ AFTextMenuItem( leading: leading, title: 'Menu Item 1', selected: true, onTap: () {}, ), AFPopover( controller: popoverController, shadows: theme.shadow.medium, anchor: const AFAnchor( offset: Offset(0, -20), overlayAlignment: Alignment.centerRight, ), effects: [ FadeEffect(duration: animationDuration), ScaleEffect( duration: animationDuration, begin: Offset(.95, .95), end: Offset(1, 1), ), MoveEffect( duration: animationDuration, begin: Offset(-10, 0), end: Offset(0, 0), ), ], popover: (context) { return AFMenu( children: [ AFTextMenuItem( leading: leading, title: 'Menu Item 2-1', onTap: () {}, ), AFTextMenuItem( leading: leading, title: 'Menu Item 2-2', onTap: () {}, ), AFTextMenuItem( leading: leading, title: 'Menu Item 2-3', onTap: () {}, ), ], ); }, child: AFTextMenuItem( leading: leading, title: 'Menu Item 2', onTap: () { popoverController.toggle(); }, ), ), AFTextMenuItem( leading: leading, title: 'Menu Item 3', onTap: () {}, ), ], ), AFMenuSection( title: 'Section 2', children: [ AFTextMenuItem( leading: logo, title: 'Menu Item 4', subtitle: 'Menu Item', trailing: const Icon( Icons.check, size: 18, color: Colors.blueAccent, ), onTap: () {}, ), AFTextMenuItem( leading: logo, title: 'Menu Item 5', subtitle: 'Menu Item', onTap: () {}, ), AFTextMenuItem( leading: logo, title: 'Menu Item 6', subtitle: 'Menu Item', onTap: () {}, ), ], ), AFMenuSection( title: 'Section 3', children: [ AFTextMenuItem( leading: leading, title: 'Menu Item 7', trailing: arrowRight, onTap: () {}, ), AFTextMenuItem( leading: leading, title: 'Menu Item 8', trailing: arrowRight, onTap: () {}, ), ], ), ], ), const SizedBox(height: 32), // Example: Menu with search bar AFMenu( width: 240, children: [ AFTextMenuItem( leading: leading, title: 'Menu Item 1', onTap: () {}, ), AFTextMenuItem( leading: leading, title: 'Menu Item 2', onTap: () {}, ), AFTextMenuItem( leading: leading, title: 'Menu Item 3', onTap: () {}, ), ], ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/modal/modal_page.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class ModalPage extends StatefulWidget { const ModalPage({super.key}); @override State createState() => _ModalPageState(); } class _ModalPageState extends State { double width = AFModalDimension.M; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Center( child: Container( constraints: BoxConstraints(maxWidth: 600), padding: EdgeInsets.symmetric(horizontal: theme.spacing.xl), child: Column( spacing: theme.spacing.l, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( spacing: theme.spacing.m, mainAxisSize: MainAxisSize.min, children: [ AFGhostButton.normal( onTap: () => setState(() => width = AFModalDimension.S), builder: (context, isHovering, disabled) { return Text( 'S', style: TextStyle( color: width == AFModalDimension.S ? theme.textColorScheme.action : theme.textColorScheme.primary, ), ); }, ), AFGhostButton.normal( onTap: () => setState(() => width = AFModalDimension.M), builder: (context, isHovering, disabled) { return Text( 'M', style: TextStyle( color: width == AFModalDimension.M ? theme.textColorScheme.action : theme.textColorScheme.primary, ), ); }, ), AFGhostButton.normal( onTap: () => setState(() => width = AFModalDimension.L), builder: (context, isHovering, disabled) { return Text( 'L', style: TextStyle( color: width == AFModalDimension.L ? theme.textColorScheme.action : theme.textColorScheme.primary, ), ); }, ), ], ), AFFilledButton.primary( builder: (context, isHovering, disabled) { return Text( 'Show Modal', style: TextStyle( color: AppFlowyTheme.of(context).textColorScheme.onFill, ), ); }, onTap: () { showDialog( context: context, barrierColor: theme.surfaceColorScheme.overlay, builder: (context) { final theme = AppFlowyTheme.of(context); return Center( child: AFModal( constraints: BoxConstraints( maxWidth: width, maxHeight: AFModalDimension.dialogHeight, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ AFModalHeader( leading: Text( 'Header', style: theme.textStyle.heading4.prominent( color: theme.textColorScheme.primary, ), ), trailing: [ AFGhostButton.normal( onTap: () => Navigator.of(context).pop(), builder: (context, isHovering, disabled) { return const Icon(Icons.close); }, ) ], ), Expanded( child: AFModalBody( child: Text( 'A dialog briefly presents information or requests confirmation, allowing users to continue their workflow after interaction.'), ), ), AFModalFooter( trailing: [ AFOutlinedButton.normal( onTap: () => Navigator.of(context).pop(), builder: (context, isHovering, disabled) { return const Text('Cancel'); }, ), AFFilledButton.primary( onTap: () => Navigator.of(context).pop(), builder: (context, isHovering, disabled) { return Text( 'Apply', style: TextStyle( color: AppFlowyTheme.of(context) .textColorScheme .onFill, ), ); }, ), ], ) ], )), ); }, ); }, ), ], ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/textfield/textfield_page.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class TextFieldPage extends StatelessWidget { const TextFieldPage({super.key}); @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSection( 'TextField Sizes', [ AFTextField( hintText: 'Please enter your name', size: AFTextFieldSize.m, ), AFTextField( hintText: 'Please enter your name', ), ], ), const SizedBox(height: 32), _buildSection( 'TextField with hint text', [ AFTextField( hintText: 'Please enter your name', ), ], ), const SizedBox(height: 32), _buildSection( 'TextField with initial text', [ AFTextField( initialText: 'https://appflowy.com', ), ], ), const SizedBox(height: 32), _buildSection( 'TextField with validator ', [ AFTextField( validator: (controller) { if (controller.text.isEmpty) { return (true, 'This field is required'); } final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); if (!emailRegex.hasMatch(controller.text)) { return (true, 'Please enter a valid email address'); } return (false, ''); }, ), ], ), ], ), ); } Widget _buildSection(String title, List children) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, children: children, ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/.gitignore ================================================ # Flutter-related **/Flutter/ephemeral/ **/Pods/ # Xcode-related **/dgph **/xcuserdata/ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Debug.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Flutter/Flutter-Release.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Podfile ================================================ platform :osx, '10.14' # 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) end end ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/AppDelegate.swift ================================================ import Cocoa import FlutterMacOS @main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "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" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Base.lproj/MainMenu.xib ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/AppInfo.xcconfig ================================================ // 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 = appflowy_ui_example // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Debug.xcconfig ================================================ #include "../../Flutter/Flutter-Debug.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Release.xcconfig ================================================ #include "../../Flutter/Flutter-Release.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Configs/Warnings.xcconfig ================================================ 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 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/DebugProfile.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.cs.allow-jit com.apple.security.network.server com.apple.security.network.client ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Info.plist ================================================ 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 ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/MainFlutterWindow.swift ================================================ 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() } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner/Release.entitlements ================================================ com.apple.security.app-sandbox ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.pbxproj ================================================ // !$*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 */ 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 */; }; A8240F6CBA460C4ECE77B497 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67F505219B8EAD64EC2D7058 /* Pods_Runner.framework */; }; ED985151D7C5493A133E2F4A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 99B39EDC5EC15A319D26431B /* Pods_RunnerTests.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 */ 126A5ABCAF9A616F39D0C99A /* 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 = ""; }; 294E5A6C2D9F40258167A63C /* 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 /* appflowy_ui_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = appflowy_ui_example.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 = ""; }; 67F505219B8EAD64EC2D7058 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7323BEF11E938E0621A2FE27 /* 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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 99B39EDC5EC15A319D26431B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C27947A571D4D6E82250D9D4 /* 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 = ""; }; DECDE2B61D56E070AC9CFE43 /* 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 = ""; }; E8C30E6DE13EF821101E690E /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 331C80D2294CF70F00263BE5 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ED985151D7C5493A133E2F4A /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 33CC10EA2044A3C60003C045 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( A8240F6CBA460C4ECE77B497 /* 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 */, D0B4F9D1672BB1F3A8ADB1D5 /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* appflowy_ui_example.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 = ""; }; D0B4F9D1672BB1F3A8ADB1D5 /* Pods */ = { isa = PBXGroup; children = ( 126A5ABCAF9A616F39D0C99A /* Pods-Runner.debug.xcconfig */, 7323BEF11E938E0621A2FE27 /* Pods-Runner.release.xcconfig */, DECDE2B61D56E070AC9CFE43 /* Pods-Runner.profile.xcconfig */, E8C30E6DE13EF821101E690E /* Pods-RunnerTests.debug.xcconfig */, 294E5A6C2D9F40258167A63C /* Pods-RunnerTests.release.xcconfig */, C27947A571D4D6E82250D9D4 /* Pods-RunnerTests.profile.xcconfig */, ); name = Pods; path = Pods; sourceTree = ""; }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( 67F505219B8EAD64EC2D7058 /* Pods_Runner.framework */, 99B39EDC5EC15A319D26431B /* 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 = ( 259F838CE5DFD9B02C9072E2 /* [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 = ( B8FE1B340A42EC42E5F2652D /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, 3CE63926704EF25F1635BDFC /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* appflowy_ui_example.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; ProvisioningStyle = Automatic; 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 */ 259F838CE5DFD9B02C9072E2 /* [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; }; 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"; }; 3CE63926704EF25F1635BDFC /* [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; }; B8FE1B340A42EC42E5F2652D /* [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; }; /* 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 = E8C30E6DE13EF821101E690E /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; }; name = Debug; }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 294E5A6C2D9F40258167A63C /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; }; name = Release; }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = C27947A571D4D6E82250D9D4 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.appflowyUiExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/appflowy_ui_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/appflowy_ui_example"; }; 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[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = VHB67HRSZG; 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[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = VHB67HRSZG; 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[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = VHB67HRSZG; 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 */; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/macos/RunnerTests/RunnerTests.swift ================================================ 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. } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/pubspec.yaml ================================================ name: appflowy_ui_example description: "Example app showcasing AppFlowy UI components and widgets" publish_to: "none" version: 1.0.0+1 environment: flutter: ">=3.27.4" sdk: ">=3.3.0 <4.0.0" dependencies: flutter: sdk: flutter appflowy_ui: path: ../ cupertino_icons: ^1.0.6 flutter_svg: ^2.1.0 flutter_animate: ^4.5.2 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 flutter: uses-material-design: true assets: - assets/images/vector.svg - assets/images/arrow_right.svg ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/example/test/widget_test.dart ================================================ // This is a basic Flutter widget test. // // To perform an interaction with a widget in your test, use the WidgetTester // utility in the flutter_test package. For example, you can send tap and scroll // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:appflowy_ui_example/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. await tester.pumpWidget(const MyApp()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget); expect(find.text('1'), findsNothing); // Tap the '+' icon and trigger a frame. await tester.tap(find.byIcon(Icons.add)); await tester.pump(); // Verify that our counter has incremented. expect(find.text('0'), findsNothing); expect(find.text('1'), findsOneWidget); }); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/appflowy_ui.dart ================================================ export 'src/component/component.dart'; export 'src/theme/data/appflowy_default/primitive.dart'; export 'src/theme/theme.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/avatar/avatar.dart ================================================ import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:appflowy_ui/src/theme/definition/theme_data.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; /// Avatar sizes in pixels enum AFAvatarSize { xs, s, m, l, xl; double get size { switch (this) { case AFAvatarSize.xs: return 16.0; case AFAvatarSize.s: return 24.0; case AFAvatarSize.m: return 32.0; case AFAvatarSize.l: return 48.0; case AFAvatarSize.xl: return 64.0; } } TextStyle buildTextStyle(AppFlowyThemeData theme, Color color) { switch (this) { case AFAvatarSize.xs: return theme.textStyle.caption.standard(color: color); case AFAvatarSize.s: return theme.textStyle.body.standard(color: color); case AFAvatarSize.m: return theme.textStyle.heading4.standard(color: color); case AFAvatarSize.l: return theme.textStyle.heading3.standard(color: color); case AFAvatarSize.xl: return theme.textStyle.heading2.standard(color: color); } } } /// Avatar widget class AFAvatar extends StatelessWidget { /// Displays an avatar. Precedence: [child] > [url] > [name]. /// /// If [child] is provided, it is shown. Otherwise, if [url] is provided and non-empty, the image is shown. Otherwise, initials from [name] are shown. const AFAvatar({ super.key, this.name, this.url, this.size = AFAvatarSize.m, this.textColor, this.backgroundColor, this.child, this.colorHash, }); /// The name of the avatar. Used for initials if [child] and [url] are not provided. final String? name; /// The URL of the avatar image. Used if [child] is not provided. final String? url; /// Custom widget to display as the avatar. Takes highest precedence. final Widget? child; /// The size of the avatar. final AFAvatarSize size; /// The text color for initials. Only applies when showing initials. /// If not provided, a matching thick color from badge color scheme will be used. final Color? textColor; /// The background color for initials. Only applies when showing initials. /// If not provided, a light color from badge color scheme will be used. final Color? backgroundColor; /// The hash value used to pick the color. If it's not provided, the name hash will be used. final String? colorHash; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final double avatarSize = size.size; // Pick color index based on name hash (1-20) final int colorIndex = _pickColorIndexFromName(colorHash ?? name); final Color backgroundColor = this.backgroundColor ?? _getBadgeBackgroundColor(theme, colorIndex); final Color textColor = this.textColor ?? _getBadgeTextColor(theme, colorIndex); final TextStyle textStyle = size.buildTextStyle(theme, textColor); final Widget avatarContent = _buildAvatarContent( avatarSize: avatarSize, bgColor: backgroundColor, textStyle: textStyle, ); return SizedBox( width: avatarSize, height: avatarSize, child: avatarContent, ); } Widget _buildAvatarContent({ required double avatarSize, required Color bgColor, required TextStyle textStyle, }) { if (child != null) { return ClipOval( child: SizedBox( width: avatarSize, height: avatarSize, child: child, ), ); } else if (url != null && url!.isNotEmpty) { return ClipOval( child: CachedNetworkImage( imageUrl: url!, width: avatarSize, height: avatarSize, fit: BoxFit.cover, // fallback to initials if the image is not found errorWidget: (context, error, stackTrace) => _buildInitialsCircle( avatarSize, bgColor, textStyle, ), ), ); } else { return _buildInitialsCircle( avatarSize, bgColor, textStyle, ); } } Widget _buildInitialsCircle(double size, Color bgColor, TextStyle textStyle) { final initial = _getInitials(name); return Container( decoration: BoxDecoration( color: bgColor, shape: BoxShape.circle, ), alignment: Alignment.center, child: Text( initial, style: textStyle, textAlign: TextAlign.center, ), ); } String _getInitials(String? name) { if (name == null || name.trim().isEmpty) return ''; // Always return just the first letter of the name return name.trim()[0].toUpperCase(); } /// Deterministically pick a color index (1-20) based on the user name int _pickColorIndexFromName(String? name) { if (name == null || name.isEmpty) return 1; int hash = 0; for (int i = 0; i < name.length; i++) { hash = name.codeUnitAt(i) + ((hash << 5) - hash); } return (hash.abs() % 20) + 1; } /// Gets the background color from badge color scheme using a list Color _getBadgeBackgroundColor(AppFlowyThemeData theme, int colorIndex) { final List backgroundColors = [ theme.badgeColorScheme.color1Light2, theme.badgeColorScheme.color2Light2, theme.badgeColorScheme.color3Light2, theme.badgeColorScheme.color4Light2, theme.badgeColorScheme.color5Light2, theme.badgeColorScheme.color6Light2, theme.badgeColorScheme.color7Light2, theme.badgeColorScheme.color8Light2, theme.badgeColorScheme.color9Light2, theme.badgeColorScheme.color10Light2, theme.badgeColorScheme.color11Light2, theme.badgeColorScheme.color12Light2, theme.badgeColorScheme.color13Light2, theme.badgeColorScheme.color14Light2, theme.badgeColorScheme.color15Light2, theme.badgeColorScheme.color16Light2, theme.badgeColorScheme.color17Light2, theme.badgeColorScheme.color18Light2, theme.badgeColorScheme.color19Light2, theme.badgeColorScheme.color20Light2, ]; return backgroundColors[(colorIndex - 1).clamp(0, 19)]; } /// Gets the text color from badge color scheme using a list Color _getBadgeTextColor(AppFlowyThemeData theme, int colorIndex) { final List textColors = [ theme.badgeColorScheme.color1Thick3, theme.badgeColorScheme.color2Thick3, theme.badgeColorScheme.color3Thick3, theme.badgeColorScheme.color4Thick3, theme.badgeColorScheme.color5Thick3, theme.badgeColorScheme.color6Thick3, theme.badgeColorScheme.color7Thick3, theme.badgeColorScheme.color8Thick3, theme.badgeColorScheme.color9Thick3, theme.badgeColorScheme.color10Thick3, theme.badgeColorScheme.color11Thick3, theme.badgeColorScheme.color12Thick3, theme.badgeColorScheme.color13Thick3, theme.badgeColorScheme.color14Thick3, theme.badgeColorScheme.color15Thick3, theme.badgeColorScheme.color16Thick3, theme.badgeColorScheme.color17Thick3, theme.badgeColorScheme.color18Thick3, theme.badgeColorScheme.color19Thick3, theme.badgeColorScheme.color20Thick3, ]; return textColors[(colorIndex - 1).clamp(0, 19)]; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base.dart ================================================ import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/widgets.dart'; enum AFButtonSize { s, m, l, xl; TextStyle buildTextStyle(BuildContext context) { final theme = AppFlowyTheme.of(context); return switch (this) { AFButtonSize.s => theme.textStyle.body.enhanced(), AFButtonSize.m => theme.textStyle.body.enhanced(), AFButtonSize.l => theme.textStyle.body.enhanced(), AFButtonSize.xl => theme.textStyle.title.enhanced(), }; } EdgeInsetsGeometry buildPadding(BuildContext context) { final theme = AppFlowyTheme.of(context); return switch (this) { AFButtonSize.s => EdgeInsets.symmetric( horizontal: theme.spacing.l, vertical: theme.spacing.xs, ), AFButtonSize.m => EdgeInsets.symmetric( horizontal: theme.spacing.xl, vertical: theme.spacing.s, ), AFButtonSize.l => EdgeInsets.symmetric( horizontal: theme.spacing.xl, vertical: 10, // why? ), AFButtonSize.xl => EdgeInsets.symmetric( horizontal: theme.spacing.xl, vertical: 14, // why? ), }; } double buildBorderRadius(BuildContext context) { final theme = AppFlowyTheme.of(context); return switch (this) { AFButtonSize.s => theme.borderRadius.m, AFButtonSize.m => theme.borderRadius.m, AFButtonSize.l => 10, // why? AFButtonSize.xl => theme.borderRadius.xl, }; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_button.dart ================================================ import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; typedef AFBaseButtonColorBuilder = Color Function( BuildContext context, bool isHovering, bool disabled, ); typedef AFBaseButtonBorderColorBuilder = Color Function( BuildContext context, bool isHovering, bool disabled, bool isFocused, ); class AFBaseButton extends StatefulWidget { const AFBaseButton({ super.key, required this.onTap, required this.builder, required this.padding, required this.borderRadius, this.borderColor, this.backgroundColor, this.ringColor, this.disabled = false, this.autofocus = false, this.showFocusRing = true, }); final VoidCallback? onTap; final AFBaseButtonBorderColorBuilder? borderColor; final AFBaseButtonBorderColorBuilder? ringColor; final AFBaseButtonColorBuilder? backgroundColor; final EdgeInsetsGeometry padding; final double borderRadius; final bool disabled; final bool autofocus; final bool showFocusRing; final Widget Function( BuildContext context, bool isHovering, bool disabled, ) builder; @override State createState() => _AFBaseButtonState(); } class _AFBaseButtonState extends State { final FocusNode focusNode = FocusNode(); bool isHovering = false; bool isFocused = false; @override void dispose() { focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final Color borderColor = _buildBorderColor(context); final Color backgroundColor = _buildBackgroundColor(context); final Color ringColor = _buildRingColor(context); return Actions( actions: { ActivateIntent: CallbackAction( onInvoke: (_) { if (!widget.disabled) { widget.onTap?.call(); } return; }, ), }, child: Focus( focusNode: focusNode, onFocusChange: (isFocused) { setState(() => this.isFocused = isFocused); }, autofocus: widget.autofocus, child: MouseRegion( cursor: widget.onTap == null ? SystemMouseCursors.basic : widget.disabled ? SystemMouseCursors.basic : SystemMouseCursors.click, onEnter: (_) => setState(() => isHovering = true), onExit: (_) => setState(() => isHovering = false), child: GestureDetector( onTap: widget.disabled ? null : widget.onTap, child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(widget.borderRadius), border: isFocused && widget.showFocusRing ? Border.all( color: ringColor, width: 2, strokeAlign: BorderSide.strokeAlignOutside, ) : null, ), child: DecoratedBox( decoration: BoxDecoration( color: backgroundColor, border: Border.all(color: borderColor), borderRadius: BorderRadius.circular(widget.borderRadius), ), child: Padding( padding: widget.padding, child: widget.builder( context, isHovering, widget.disabled, ), ), ), ), ), ), ), ); } Color _buildBorderColor(BuildContext context) { final theme = AppFlowyTheme.of(context); return widget.borderColor ?.call(context, isHovering, widget.disabled, isFocused) ?? theme.borderColorScheme.primary; } Color _buildBackgroundColor(BuildContext context) { final theme = AppFlowyTheme.of(context); return widget.backgroundColor?.call(context, isHovering, widget.disabled) ?? theme.fillColorScheme.content; } Color _buildRingColor(BuildContext context) { final theme = AppFlowyTheme.of(context); if (widget.ringColor != null) { return widget.ringColor! .call(context, isHovering, widget.disabled, isFocused); } if (isFocused) { return theme.borderColorScheme.themeThick.withAlpha(128); } return Colors.transparent; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/base_button/base_text_button.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class AFBaseTextButton extends StatelessWidget { const AFBaseTextButton({ super.key, required this.text, required this.onTap, this.disabled = false, this.size = AFButtonSize.m, this.padding, this.borderRadius, this.textColor, this.backgroundColor, this.alignment, this.textStyle, }); /// The text of the button. final String text; /// Whether the button is disabled. final bool disabled; /// The callback when the button is tapped. final VoidCallback onTap; /// The size of the button. final AFButtonSize size; /// The padding of the button. final EdgeInsetsGeometry? padding; /// The border radius of the button. final double? borderRadius; /// The text color of the button. final AFBaseButtonColorBuilder? textColor; /// The background color of the button. final AFBaseButtonColorBuilder? backgroundColor; /// The alignment of the button. /// /// If it's null, the button size will be the size of the text with padding. final Alignment? alignment; /// The text style of the button. final TextStyle? textStyle; @override Widget build(BuildContext context) { throw UnimplementedError(); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart ================================================ // Base button export 'base_button/base.dart'; export 'base_button/base_button.dart'; export 'base_button/base_text_button.dart'; // Filled buttons export 'filled_button/filled_button.dart'; export 'filled_button/filled_icon_text_button.dart'; export 'filled_button/filled_text_button.dart'; // Ghost buttons export 'ghost_button/ghost_button.dart'; export 'ghost_button/ghost_icon_text_button.dart'; export 'ghost_button/ghost_text_button.dart'; // Outlined buttons export 'outlined_button/outlined_button.dart'; export 'outlined_button/outlined_icon_text_button.dart'; export 'outlined_button/outlined_text_button.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_button.dart ================================================ import 'package:appflowy_ui/src/component/component.dart'; import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; typedef AFFilledButtonWidgetBuilder = Widget Function( BuildContext context, bool isHovering, bool disabled, ); class AFFilledButton extends StatelessWidget { const AFFilledButton._({ super.key, required this.builder, required this.onTap, required this.backgroundColor, this.size = AFButtonSize.m, this.padding, this.borderRadius, this.disabled = false, }); /// Primary text button. factory AFFilledButton.primary({ Key? key, required AFFilledButtonWidgetBuilder builder, required VoidCallback onTap, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, }) { return AFFilledButton._( key: key, builder: builder, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, backgroundColor: (context, isHovering, disabled) { if (disabled) { return AppFlowyTheme.of(context).fillColorScheme.contentHover; } if (isHovering) { return AppFlowyTheme.of(context).fillColorScheme.themeThickHover; } return AppFlowyTheme.of(context).fillColorScheme.themeThick; }, ); } /// Destructive text button. factory AFFilledButton.destructive({ Key? key, required AFFilledButtonWidgetBuilder builder, required VoidCallback onTap, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, }) { return AFFilledButton._( key: key, builder: builder, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, backgroundColor: (context, isHovering, disabled) { if (disabled) { return AppFlowyTheme.of(context).fillColorScheme.contentHover; } if (isHovering) { return AppFlowyTheme.of(context).fillColorScheme.errorThickHover; } return AppFlowyTheme.of(context).fillColorScheme.errorThick; }, ); } /// Disabled text button. factory AFFilledButton.disabled({ Key? key, required AFFilledButtonWidgetBuilder builder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, Color? backgroundColor, }) { return AFFilledButton._( key: key, builder: builder, onTap: () {}, size: size, disabled: true, padding: padding, borderRadius: borderRadius, backgroundColor: (context, isHovering, disabled) => backgroundColor ?? AppFlowyTheme.of(context).fillColorScheme.contentHover, ); } final VoidCallback onTap; final bool disabled; final AFButtonSize size; final EdgeInsetsGeometry? padding; final double? borderRadius; final AFBaseButtonColorBuilder? backgroundColor; final AFFilledButtonWidgetBuilder builder; @override Widget build(BuildContext context) { return AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, borderColor: (_, __, ___, ____) => Colors.transparent, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, builder: builder, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_icon_text_button.dart ================================================ import 'package:appflowy_ui/src/component/component.dart'; import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; typedef AFFilledIconBuilder = Widget Function( BuildContext context, bool isHovering, bool disabled, ); class AFFilledIconTextButton extends StatelessWidget { const AFFilledIconTextButton._({ super.key, required this.text, required this.onTap, required this.iconBuilder, this.textColor, this.backgroundColor, this.size = AFButtonSize.m, this.padding, this.borderRadius, }); /// Primary filled text button. factory AFFilledIconTextButton.primary({ Key? key, required String text, required VoidCallback onTap, required AFFilledIconBuilder iconBuilder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, }) { return AFFilledIconTextButton._( key: key, text: text, onTap: onTap, iconBuilder: iconBuilder, size: size, padding: padding, borderRadius: borderRadius, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.tertiary; } if (isHovering) { return theme.fillColorScheme.themeThickHover; } return theme.fillColorScheme.themeThick; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return theme.textColorScheme.onFill; }, ); } /// Destructive filled text button. factory AFFilledIconTextButton.destructive({ Key? key, required String text, required VoidCallback onTap, required AFFilledIconBuilder iconBuilder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, }) { return AFFilledIconTextButton._( key: key, text: text, iconBuilder: iconBuilder, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.tertiary; } if (isHovering) { return theme.fillColorScheme.errorThickHover; } return theme.fillColorScheme.errorThick; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return theme.textColorScheme.onFill; }, ); } /// Disabled filled text button. factory AFFilledIconTextButton.disabled({ Key? key, required String text, required AFFilledIconBuilder iconBuilder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, }) { return AFFilledIconTextButton._( key: key, text: text, iconBuilder: iconBuilder, onTap: () {}, size: size, padding: padding, borderRadius: borderRadius, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return theme.fillColorScheme.tertiary; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return theme.textColorScheme.onFill; }, ); } /// Ghost filled text button with transparent background that shows color on hover. factory AFFilledIconTextButton.ghost({ Key? key, required String text, required VoidCallback onTap, required AFFilledIconBuilder iconBuilder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, }) { return AFFilledIconTextButton._( key: key, text: text, iconBuilder: iconBuilder, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return Colors.transparent; } if (isHovering) { return theme.fillColorScheme.themeThickHover; } return Colors.transparent; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.textColorScheme.tertiary; } return theme.textColorScheme.primary; }, ); } final String text; final VoidCallback onTap; final AFButtonSize size; final EdgeInsetsGeometry? padding; final double? borderRadius; final AFFilledIconBuilder iconBuilder; final AFBaseButtonColorBuilder? textColor; final AFBaseButtonColorBuilder? backgroundColor; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFBaseButton( backgroundColor: backgroundColor, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, builder: (context, isHovering, disabled) { final textColor = this.textColor?.call(context, isHovering, disabled) ?? theme.textColorScheme.onFill; return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ iconBuilder(context, isHovering, disabled), SizedBox(width: theme.spacing.s), Text( text, style: size.buildTextStyle(context).copyWith( color: textColor, ), ), ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/filled_button/filled_text_button.dart ================================================ import 'package:appflowy_ui/src/component/component.dart'; import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; class AFFilledTextButton extends AFBaseTextButton { const AFFilledTextButton({ super.key, required super.text, required super.onTap, required super.backgroundColor, required super.textColor, super.size = AFButtonSize.m, super.padding, super.borderRadius, super.disabled = false, super.alignment, super.textStyle, }); /// Primary text button. factory AFFilledTextButton.primary({ Key? key, required String text, required VoidCallback onTap, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, Alignment? alignment, TextStyle? textStyle, }) { return AFFilledTextButton( key: key, text: text, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, alignment: alignment, textStyle: textStyle, textColor: (context, isHovering, disabled) { if (disabled) { return AppFlowyTheme.of(context).textColorScheme.tertiary; } return AppFlowyTheme.of(context).textColorScheme.onFill; }, backgroundColor: (context, isHovering, disabled) { if (disabled) { return AppFlowyTheme.of(context).fillColorScheme.contentHover; } if (isHovering) { return AppFlowyTheme.of(context).fillColorScheme.themeThickHover; } return AppFlowyTheme.of(context).fillColorScheme.themeThick; }, ); } /// Destructive text button. factory AFFilledTextButton.destructive({ Key? key, required String text, required VoidCallback onTap, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, Alignment? alignment, TextStyle? textStyle, }) { return AFFilledTextButton( key: key, text: text, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, alignment: alignment, textStyle: textStyle, textColor: (context, isHovering, disabled) { if (disabled) { return AppFlowyTheme.of(context).textColorScheme.tertiary; } return AppFlowyTheme.of(context).textColorScheme.onFill; }, backgroundColor: (context, isHovering, disabled) { if (disabled) { return AppFlowyTheme.of(context).fillColorScheme.contentHover; } if (isHovering) { return AppFlowyTheme.of(context).fillColorScheme.errorThickHover; } return AppFlowyTheme.of(context).fillColorScheme.errorThick; }, ); } /// Disabled text button. factory AFFilledTextButton.disabled({ Key? key, required String text, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, Alignment? alignment, TextStyle? textStyle, }) { return AFFilledTextButton( key: key, text: text, onTap: () {}, size: size, padding: padding, borderRadius: borderRadius, disabled: true, alignment: alignment, textStyle: textStyle, textColor: (context, isHovering, disabled) => AppFlowyTheme.of(context).textColorScheme.tertiary, backgroundColor: (context, isHovering, disabled) => AppFlowyTheme.of(context).fillColorScheme.contentHover, ); } @override Widget build(BuildContext context) { return ConstrainedBox( constraints: BoxConstraints( minWidth: 76, ), child: AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, borderColor: (_, __, ___, ____) => Colors.transparent, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, builder: (context, isHovering, disabled) { final textColor = this.textColor?.call(context, isHovering, disabled) ?? AppFlowyTheme.of(context).textColorScheme.onFill; Widget child = Text( text, style: textStyle ?? size.buildTextStyle(context).copyWith(color: textColor), textAlign: TextAlign.center, ); final alignment = this.alignment; if (alignment != null) { child = Align( alignment: alignment, child: child, ); } return child; }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_button.dart ================================================ import 'package:appflowy_ui/src/component/component.dart'; import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; typedef AFGhostButtonWidgetBuilder = Widget Function( BuildContext context, bool isHovering, bool disabled, ); class AFGhostButton extends StatelessWidget { const AFGhostButton._({ super.key, required this.onTap, required this.backgroundColor, required this.builder, this.size = AFButtonSize.m, this.padding, this.borderRadius, this.disabled = false, }); /// Normal ghost button. factory AFGhostButton.normal({ Key? key, required VoidCallback onTap, required AFGhostButtonWidgetBuilder builder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, }) { return AFGhostButton._( key: key, builder: builder, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.content; } if (isHovering) { return theme.fillColorScheme.contentHover; } return theme.fillColorScheme.content; }, ); } /// Disabled ghost button. factory AFGhostButton.disabled({ Key? key, required AFGhostButtonWidgetBuilder builder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, }) { return AFGhostButton._( key: key, builder: builder, onTap: () {}, size: size, padding: padding, borderRadius: borderRadius, disabled: true, backgroundColor: (context, isHovering, disabled) => AppFlowyTheme.of(context).fillColorScheme.content, ); } final VoidCallback onTap; final bool disabled; final AFButtonSize size; final EdgeInsetsGeometry? padding; final double? borderRadius; final AFBaseButtonColorBuilder? backgroundColor; final AFGhostButtonWidgetBuilder builder; @override Widget build(BuildContext context) { return AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, borderColor: (_, __, ___, ____) => Colors.transparent, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, builder: builder, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_icon_text_button.dart ================================================ import 'package:appflowy_ui/src/component/component.dart'; import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; typedef AFGhostIconBuilder = Widget Function( BuildContext context, bool isHovering, bool disabled, ); class AFGhostIconTextButton extends StatelessWidget { const AFGhostIconTextButton({ super.key, required this.text, required this.onTap, required this.iconBuilder, this.textColor, this.backgroundColor, this.size = AFButtonSize.m, this.padding, this.borderRadius, this.disabled = false, this.mainAxisAlignment = MainAxisAlignment.center, }); /// Primary ghost text button. factory AFGhostIconTextButton.primary({ Key? key, required String text, required VoidCallback onTap, required AFGhostIconBuilder iconBuilder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, MainAxisAlignment mainAxisAlignment = MainAxisAlignment.center, }) { return AFGhostIconTextButton( key: key, text: text, onTap: onTap, iconBuilder: iconBuilder, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, mainAxisAlignment: mainAxisAlignment, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return Colors.transparent; } if (isHovering) { return theme.fillColorScheme.contentHover; } return Colors.transparent; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.textColorScheme.tertiary; } return theme.textColorScheme.primary; }, ); } /// Disabled ghost text button. factory AFGhostIconTextButton.disabled({ Key? key, required String text, required AFGhostIconBuilder iconBuilder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, MainAxisAlignment mainAxisAlignment = MainAxisAlignment.center, }) { return AFGhostIconTextButton( key: key, text: text, iconBuilder: iconBuilder, onTap: () {}, size: size, padding: padding, borderRadius: borderRadius, disabled: true, mainAxisAlignment: mainAxisAlignment, backgroundColor: (context, isHovering, disabled) { return Colors.transparent; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return theme.textColorScheme.tertiary; }, ); } final String text; final bool disabled; final VoidCallback onTap; final AFButtonSize size; final EdgeInsetsGeometry? padding; final double? borderRadius; final AFGhostIconBuilder iconBuilder; final AFBaseButtonColorBuilder? textColor; final AFBaseButtonColorBuilder? backgroundColor; final MainAxisAlignment mainAxisAlignment; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, borderColor: (context, isHovering, disabled, isFocused) { return Colors.transparent; }, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, builder: (context, isHovering, disabled) { final textColor = this.textColor?.call(context, isHovering, disabled) ?? theme.textColorScheme.primary; return Row( mainAxisAlignment: mainAxisAlignment, children: [ iconBuilder( context, isHovering, disabled, ), SizedBox(width: theme.spacing.m), Text( text, style: size.buildTextStyle(context).copyWith( color: textColor, ), ), ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/ghost_button/ghost_text_button.dart ================================================ import 'package:appflowy_ui/src/component/component.dart'; import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; class AFGhostTextButton extends AFBaseTextButton { const AFGhostTextButton({ super.key, required super.text, required super.onTap, super.textColor, super.backgroundColor, super.size = AFButtonSize.m, super.padding, super.borderRadius, super.disabled = false, super.alignment, super.textStyle, }); /// Normal ghost text button. factory AFGhostTextButton.primary({ Key? key, required String text, required VoidCallback onTap, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, Alignment? alignment, TextStyle? textStyle, }) { return AFGhostTextButton( key: key, text: text, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, alignment: alignment, textStyle: textStyle, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (isHovering) { return theme.fillColorScheme.contentHover; } return theme.fillColorScheme.content; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.textColorScheme.tertiary; } if (isHovering) { return theme.textColorScheme.primary; } return theme.textColorScheme.primary; }, ); } /// Disabled ghost text button. factory AFGhostTextButton.disabled({ Key? key, required String text, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, Alignment? alignment, TextStyle? textStyle, }) { return AFGhostTextButton( key: key, text: text, onTap: () {}, size: size, padding: padding, borderRadius: borderRadius, disabled: true, alignment: alignment, textStyle: textStyle, textColor: (context, isHovering, disabled) => AppFlowyTheme.of(context).textColorScheme.tertiary, backgroundColor: (context, isHovering, disabled) => AppFlowyTheme.of(context).fillColorScheme.content, ); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return ConstrainedBox( constraints: BoxConstraints( minWidth: 76, ), child: AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, borderColor: (_, __, ___, ____) => Colors.transparent, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, builder: (context, isHovering, disabled) { final textColor = this.textColor?.call(context, isHovering, disabled) ?? theme.textColorScheme.primary; Widget child = Text( text, style: textStyle ?? size.buildTextStyle(context).copyWith(color: textColor), textAlign: TextAlign.center, ); final alignment = this.alignment; if (alignment != null) { child = Align( alignment: alignment, child: child, ); } return child; }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_button.dart ================================================ import 'package:appflowy_ui/src/component/component.dart'; import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; typedef AFOutlinedButtonWidgetBuilder = Widget Function( BuildContext context, bool isHovering, bool disabled, ); class AFOutlinedButton extends StatelessWidget { const AFOutlinedButton._({ super.key, required this.onTap, required this.builder, this.borderColor, this.backgroundColor, this.size = AFButtonSize.m, this.padding, this.borderRadius, this.disabled = false, }); /// Normal outlined button. factory AFOutlinedButton.normal({ Key? key, required AFOutlinedButtonWidgetBuilder builder, required VoidCallback onTap, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, }) { return AFOutlinedButton._( key: key, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.primary; } if (isHovering) { return theme.borderColorScheme.primaryHover; } return theme.borderColorScheme.primary; }, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.content; } if (isHovering) { return theme.fillColorScheme.contentHover; } return theme.fillColorScheme.content; }, builder: builder, ); } /// Destructive outlined button. factory AFOutlinedButton.destructive({ Key? key, required AFOutlinedButtonWidgetBuilder builder, required VoidCallback onTap, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, }) { return AFOutlinedButton._( key: key, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.errorThick; } if (isHovering) { return theme.fillColorScheme.errorThickHover; } return theme.fillColorScheme.errorThick; }, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.errorThick; } if (isHovering) { return theme.fillColorScheme.errorSelect; } return theme.fillColorScheme.content; }, builder: builder, ); } /// Disabled outlined text button. factory AFOutlinedButton.disabled({ Key? key, required AFOutlinedButtonWidgetBuilder builder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, }) { return AFOutlinedButton._( key: key, onTap: () {}, size: size, padding: padding, borderRadius: borderRadius, disabled: true, borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.primary; } if (isHovering) { return theme.borderColorScheme.primaryHover; } return theme.borderColorScheme.primary; }, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.content; } if (isHovering) { return theme.fillColorScheme.contentHover; } return theme.fillColorScheme.content; }, builder: builder, ); } final VoidCallback onTap; final bool disabled; final AFButtonSize size; final EdgeInsetsGeometry? padding; final double? borderRadius; final AFBaseButtonBorderColorBuilder? borderColor; final AFBaseButtonColorBuilder? backgroundColor; final AFOutlinedButtonWidgetBuilder builder; @override Widget build(BuildContext context) { return AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, borderColor: borderColor, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, builder: builder, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_icon_text_button.dart ================================================ import 'package:appflowy_ui/src/component/component.dart'; import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; typedef AFOutlinedIconBuilder = Widget Function( BuildContext context, bool isHovering, bool disabled, ); class AFOutlinedIconTextButton extends StatelessWidget { const AFOutlinedIconTextButton._({ super.key, required this.text, required this.onTap, required this.iconBuilder, this.borderColor, this.textColor, this.backgroundColor, this.size = AFButtonSize.m, this.padding, this.borderRadius, this.disabled = false, this.alignment = MainAxisAlignment.center, }); /// Normal outlined text button. factory AFOutlinedIconTextButton.normal({ Key? key, required String text, required VoidCallback onTap, required AFOutlinedIconBuilder iconBuilder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, MainAxisAlignment alignment = MainAxisAlignment.center, }) { return AFOutlinedIconTextButton._( key: key, text: text, onTap: onTap, iconBuilder: iconBuilder, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, alignment: alignment, borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.primary; } if (isHovering) { return theme.borderColorScheme.primaryHover; } return theme.borderColorScheme.primary; }, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.content; } if (isHovering) { return theme.fillColorScheme.contentHover; } return theme.fillColorScheme.content; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.textColorScheme.tertiary; } if (isHovering) { return theme.textColorScheme.primary; } return theme.textColorScheme.primary; }, ); } /// Destructive outlined text button. factory AFOutlinedIconTextButton.destructive({ Key? key, required String text, required VoidCallback onTap, required AFOutlinedIconBuilder iconBuilder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, MainAxisAlignment alignment = MainAxisAlignment.center, }) { return AFOutlinedIconTextButton._( key: key, text: text, iconBuilder: iconBuilder, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, alignment: alignment, borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.errorThick; } if (isHovering) { return theme.fillColorScheme.errorThickHover; } return theme.fillColorScheme.errorThick; }, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.errorThick; } if (isHovering) { return theme.fillColorScheme.errorThickHover; } return theme.fillColorScheme.errorThick; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return disabled ? theme.textColorScheme.error : theme.textColorScheme.error; }, ); } /// Disabled outlined text button. factory AFOutlinedIconTextButton.disabled({ Key? key, required String text, required AFOutlinedIconBuilder iconBuilder, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, MainAxisAlignment alignment = MainAxisAlignment.center, }) { return AFOutlinedIconTextButton._( key: key, text: text, iconBuilder: iconBuilder, onTap: () {}, size: size, padding: padding, borderRadius: borderRadius, disabled: true, alignment: alignment, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return disabled ? theme.textColorScheme.tertiary : theme.textColorScheme.primary; }, borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.primary; } if (isHovering) { return theme.borderColorScheme.primaryHover; } return theme.borderColorScheme.primary; }, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.content; } if (isHovering) { return theme.fillColorScheme.contentHover; } return theme.fillColorScheme.content; }, ); } final String text; final bool disabled; final VoidCallback onTap; final AFButtonSize size; final EdgeInsetsGeometry? padding; final double? borderRadius; final MainAxisAlignment alignment; final AFOutlinedIconBuilder iconBuilder; final AFBaseButtonColorBuilder? textColor; final AFBaseButtonBorderColorBuilder? borderColor; final AFBaseButtonColorBuilder? backgroundColor; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFBaseButton( backgroundColor: backgroundColor, borderColor: borderColor, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, disabled: disabled, builder: (context, isHovering, disabled) { final textColor = this.textColor?.call(context, isHovering, disabled) ?? theme.textColorScheme.primary; return Row( mainAxisAlignment: alignment, children: [ iconBuilder(context, isHovering, disabled), SizedBox(width: theme.spacing.s), Text( text, style: size.buildTextStyle(context).copyWith( color: textColor, ), ), ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/outlined_button/outlined_text_button.dart ================================================ import 'package:appflowy_ui/src/component/component.dart'; import 'package:appflowy_ui/src/theme/appflowy_theme.dart'; import 'package:flutter/material.dart'; class AFOutlinedTextButton extends AFBaseTextButton { const AFOutlinedTextButton._({ super.key, required super.text, required super.onTap, this.borderColor, super.textStyle, super.textColor, super.backgroundColor, super.size = AFButtonSize.m, super.padding, super.borderRadius, super.disabled = false, super.alignment, }); /// Normal outlined text button. factory AFOutlinedTextButton.normal({ Key? key, required String text, required VoidCallback onTap, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, Alignment? alignment, TextStyle? textStyle, AFBaseButtonColorBuilder? backgroundColor, }) { return AFOutlinedTextButton._( key: key, text: text, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, alignment: alignment, textStyle: textStyle, borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.primary; } if (isHovering) { return theme.borderColorScheme.primaryHover; } return theme.borderColorScheme.primary; }, backgroundColor: backgroundColor ?? (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.content; } if (isHovering) { return theme.fillColorScheme.contentHover; } return theme.fillColorScheme.content; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.textColorScheme.tertiary; } if (isHovering) { return theme.textColorScheme.primary; } return theme.textColorScheme.primary; }, ); } /// Destructive outlined text button. factory AFOutlinedTextButton.destructive({ Key? key, required String text, required VoidCallback onTap, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, bool disabled = false, Alignment? alignment, TextStyle? textStyle, }) { return AFOutlinedTextButton._( key: key, text: text, onTap: onTap, size: size, padding: padding, borderRadius: borderRadius, disabled: disabled, alignment: alignment, textStyle: textStyle, borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.errorThick; } if (isHovering) { return theme.fillColorScheme.errorThickHover; } return theme.fillColorScheme.errorThick; }, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.errorThick; } if (isHovering) { return theme.fillColorScheme.errorSelect; } return theme.fillColorScheme.content; }, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return disabled ? theme.textColorScheme.error : theme.textColorScheme.error; }, ); } /// Disabled outlined text button. factory AFOutlinedTextButton.disabled({ Key? key, required String text, AFButtonSize size = AFButtonSize.m, EdgeInsetsGeometry? padding, double? borderRadius, Alignment? alignment, TextStyle? textStyle, }) { return AFOutlinedTextButton._( key: key, text: text, onTap: () {}, size: size, padding: padding, borderRadius: borderRadius, disabled: true, alignment: alignment, textStyle: textStyle, textColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); return disabled ? theme.textColorScheme.tertiary : theme.textColorScheme.primary; }, borderColor: (context, isHovering, disabled, isFocused) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.borderColorScheme.primary; } if (isHovering) { return theme.borderColorScheme.primaryHover; } return theme.borderColorScheme.primary; }, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.content; } if (isHovering) { return theme.fillColorScheme.contentHover; } return theme.fillColorScheme.content; }, ); } final AFBaseButtonBorderColorBuilder? borderColor; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return ConstrainedBox( constraints: BoxConstraints( minWidth: 76, ), child: AFBaseButton( disabled: disabled, backgroundColor: backgroundColor, borderColor: borderColor, padding: padding ?? size.buildPadding(context), borderRadius: borderRadius ?? size.buildBorderRadius(context), onTap: onTap, builder: (context, isHovering, disabled) { final textColor = this.textColor?.call(context, isHovering, disabled) ?? theme.textColorScheme.primary; Widget child = Text( text, style: textStyle ?? size.buildTextStyle(context).copyWith(color: textColor), textAlign: TextAlign.center, ); final alignment = this.alignment; if (alignment != null) { child = Align( alignment: alignment, child: child, ); } return child; }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/component.dart ================================================ export 'button/button.dart'; export 'dropdown_menu/dropdown_menu.dart'; export 'separator/divider.dart'; export 'modal/modal.dart'; export 'textfield/textfield.dart'; export 'avatar/avatar.dart'; export 'menu/menu.dart'; export 'popover/popover.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/dropdown_menu/dropdown_menu.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; mixin AFDropDownMenuMixin { String get label; } class AFDropDownMenu extends StatefulWidget { const AFDropDownMenu({ super.key, required this.items, required this.selectedItems, this.onSelected, this.closeOnSelect, this.controller, this.isClearEnabled = true, this.errorText, this.isRequired = false, this.isDisabled = false, this.emptyLabel, this.clearIcon, this.dropdownIcon, this.selectedIcon, }); final List items; final List selectedItems; final void Function(T? value)? onSelected; final bool? closeOnSelect; final AFPopoverController? controller; final String? errorText; final bool isRequired; final bool isDisabled; final bool isMultiselect = false; final bool isClearEnabled; final String? emptyLabel; final Widget? clearIcon; final Widget? dropdownIcon; final Widget? selectedIcon; @override State> createState() => _AFDropDownMenuState(); } class _AFDropDownMenuState extends State> { late final AFPopoverController controller; bool isHovering = false; bool isOpen = false; @override void initState() { super.initState(); controller = widget.controller ?? AFPopoverController(); controller.addListener(popoverListener); } @override void dispose() { if (widget.controller == null) { controller.dispose(); } else { controller.removeListener(popoverListener); } super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return LayoutBuilder( builder: (context, constraints) { return AFPopover( controller: controller, padding: EdgeInsets.zero, anchor: AFAnchor( childAlignment: Alignment.topCenter, overlayAlignment: Alignment.bottomCenter, offset: Offset(0, theme.spacing.xs), ), decoration: BoxDecoration( color: theme.surfaceColorScheme.layer01, borderRadius: BorderRadius.circular(theme.borderRadius.m), boxShadow: theme.shadow.small, ), popover: (popoverContext) { return ConstrainedBox( constraints: BoxConstraints( maxWidth: constraints.maxWidth, maxHeight: 300, ), child: _DropdownPopoverContents( items: widget.items, onSelected: (item) { widget.onSelected?.call(item); if ((widget.closeOnSelect == null && !widget.isMultiselect) || widget.closeOnSelect == true) { controller.hide(); } }, selectedItems: widget.selectedItems, selectedIcon: widget.selectedIcon, isMultiselect: widget.isMultiselect, ), ); }, child: MouseRegion( onEnter: (_) => setState(() => isHovering = true), onExit: (_) => setState(() => isHovering = false), child: GestureDetector( onTap: () { if (widget.isDisabled) { return; } if (controller.isOpen) { controller.hide(); } else { controller.show(); } }, child: Container( constraints: const BoxConstraints.tightFor(height: 32), decoration: BoxDecoration( border: Border.all( color: widget.isDisabled ? theme.borderColorScheme.primary : isOpen ? theme.borderColorScheme.themeThick : isHovering ? theme.borderColorScheme.primaryHover : theme.borderColorScheme.primary, ), color: widget.isDisabled ? theme.fillColorScheme.contentHover : null, borderRadius: BorderRadius.circular(theme.borderRadius.m), ), padding: EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.xs, ), child: Row( spacing: theme.spacing.xs, children: [ Expanded( child: _DropdownButtonContents( items: widget.selectedItems, isMultiselect: widget.isMultiselect, isDisabled: widget.isDisabled, emptyLabel: widget.emptyLabel, ), ), if (widget.isClearEnabled && isOpen && widget.clearIcon != null) widget.clearIcon!, widget.dropdownIcon ?? SizedBox.square( dimension: 20, child: Icon( Icons.arrow_drop_down, size: 16, ), ), ], ), ), ), ), ); }, ); } void popoverListener() { setState(() { isOpen = controller.isOpen; }); } } class _DropdownButtonContents extends StatelessWidget { const _DropdownButtonContents({ super.key, required this.items, this.isDisabled = false, this.isMultiselect = false, this.emptyLabel, }); final List items; final bool isMultiselect; final bool isDisabled; final String? emptyLabel; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); if (isMultiselect) { return SingleChildScrollView( scrollDirection: Axis.horizontal, padding: EdgeInsets.zero, child: Row( spacing: theme.spacing.xs, children: [ ...items.map((item) { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(theme.spacing.s), color: theme.surfaceContainerColorScheme.layer02, ), padding: EdgeInsetsDirectional.fromSTEB( theme.spacing.m, 1.0, theme.spacing.s, 1.0, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( item.label, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), ), ), Icon( Icons.cancel, size: 16, color: theme.iconColorScheme.tertiary, ), ], ), ); }), TextField( enabled: !isDisabled, decoration: InputDecoration( hintText: items.isEmpty ? emptyLabel ?? "(optional)" : null, hintStyle: theme.textStyle.body.standard( color: theme.textColorScheme.tertiary, ), border: InputBorder.none, constraints: const BoxConstraints(maxWidth: 120), isCollapsed: true, isDense: true, ), style: theme.textStyle.body.standard( color: isDisabled ? theme.textColorScheme.tertiary : theme.textColorScheme.primary, ), ), ], ), ); } return Text( items.isEmpty ? emptyLabel ?? "(optional)" : items.first.label, style: theme.textStyle.body.standard( color: isDisabled || items.isEmpty ? theme.textColorScheme.tertiary : theme.textColorScheme.primary, ), overflow: TextOverflow.ellipsis, maxLines: 1, ); } } class _DropdownPopoverContents extends StatelessWidget { const _DropdownPopoverContents({ super.key, required this.items, this.selectedItems = const [], this.onSelected, this.isMultiselect = false, this.selectedIcon, }); final List items; final List selectedItems; final void Function(T? value)? onSelected; final bool isMultiselect; final Widget? selectedIcon; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return FocusScope( autofocus: true, child: ListView.builder( itemCount: items.length, physics: const ClampingScrollPhysics(), padding: EdgeInsets.all(theme.spacing.m), shrinkWrap: true, itemBuilder: itemBuilder, ), ); } Widget itemBuilder(BuildContext context, int index) { final theme = AppFlowyTheme.of(context); final item = items[index]; return AFBaseButton( padding: EdgeInsets.symmetric( vertical: theme.spacing.s, horizontal: theme.spacing.m, ), borderRadius: theme.borderRadius.m, borderColor: (context, isHovering, disabled, isFocused) { return Colors.transparent; }, showFocusRing: false, builder: (context, _, __) { return Row( spacing: theme.spacing.m, children: [ Expanded( child: Text( item.label, style: theme.textStyle.body .standard(color: theme.textColorScheme.primary) .copyWith(overflow: TextOverflow.ellipsis), ), ), if (selectedItems.contains(item) && isMultiselect) selectedIcon ?? Icon( Icons.check, color: theme.fillColorScheme.themeThick, size: 20.0, ), ], ); }, backgroundColor: (context, isHovering, _) { if (selectedItems.contains(item) && !isMultiselect) { return theme.fillColorScheme.themeSelect; } if (isHovering) { return theme.fillColorScheme.contentHover; } return Colors.transparent; }, onTap: () { onSelected?.call(item); }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/menu/menu.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; export 'menu_item.dart'; export 'section.dart'; export 'text_menu_item.dart'; /// The main menu container widget, supporting sections, menu items. class AFMenu extends StatelessWidget { const AFMenu({ super.key, required this.children, this.width, this.backgroundColor, }); /// The list of widgets to display in the menu (sections or menu items). final List children; /// The width of the menu. final double? width; final Color? backgroundColor; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Container( decoration: BoxDecoration( color: backgroundColor ?? theme.surfaceColorScheme.primary, borderRadius: BorderRadius.circular(theme.borderRadius.l), border: Border.all( color: theme.borderColorScheme.primary, ), boxShadow: theme.shadow.medium, ), width: width, padding: EdgeInsets.all(theme.spacing.m), child: Column( mainAxisSize: MainAxisSize.min, children: children, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/menu/menu_item.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; /// Menu item widget class AFMenuItem extends StatelessWidget { /// Creates a menu item. /// /// [title] and [onTap] are required. Optionally provide [leading], [subtitle], [selected], and [trailing]. const AFMenuItem({ super.key, required this.title, this.onTap, this.leading, this.subtitle, this.selected = false, this.trailing, this.padding, this.showSelectedBackground = true, }); /// Widget to display before the title (e.g., an icon or avatar). final Widget? leading; /// The main text of the menu item. final Widget title; /// Optional secondary text displayed below the title. final Widget? subtitle; /// Whether the menu item is selected. final bool selected; /// Whether to show the selected background color. final bool showSelectedBackground; /// Called when the menu item is tapped. final VoidCallback? onTap; /// Widget to display after the title (e.g., a trailing icon). final Widget? trailing; /// Padding of the menu item. final EdgeInsets? padding; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final effectivePadding = padding ?? EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.s, ); return AFBaseButton( onTap: onTap, padding: effectivePadding, borderRadius: theme.borderRadius.m, borderColor: (context, isHovering, disabled, isFocused) { return Colors.transparent; }, backgroundColor: (context, isHovering, disabled) { final theme = AppFlowyTheme.of(context); if (disabled) { return theme.fillColorScheme.content; } if (selected && showSelectedBackground) { return theme.fillColorScheme.themeSelect; } if (isHovering && onTap != null) { return theme.fillColorScheme.contentHover; } return theme.fillColorScheme.content; }, builder: (context, isHovering, disabled) { return Row( children: [ // Leading widget (icon/avatar), if provided if (leading != null) ...[ leading!, SizedBox(width: theme.spacing.m), ], // Main content: title and optional subtitle Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Title text title, // Subtitle text, if provided if (subtitle != null) subtitle!, ], ), ), // Trailing widget (e.g., icon), if provided if (trailing != null) trailing!, ], ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/menu/section.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; /// A section in the menu, optionally with a title and a list of children. class AFMenuSection extends StatelessWidget { const AFMenuSection({ super.key, this.title, required this.children, this.padding, this.constraints, }); /// The title of the section (e.g., 'Section 1'). final String? title; /// The widgets to display in this section (typically AFMenuItem widgets). final List children; /// Section padding. final EdgeInsets? padding; /// The height of the section. final BoxConstraints? constraints; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final effectivePadding = padding ?? EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.s, ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (title != null) ...[ Padding( padding: effectivePadding, child: Text( title!, style: theme.textStyle.caption.enhanced( color: theme.textColorScheme.tertiary, ), ), ), ], Container( constraints: constraints, child: SingleChildScrollView( child: Column( children: children, ), ), ), ], ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/menu/text_menu_item.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; /// Text menu item widget class AFTextMenuItem extends StatelessWidget { /// Creates a text menu item. /// /// [title] and [onTap] are required. Optionally provide [leading], [subtitle], [selected], and [trailing]. const AFTextMenuItem({ super.key, required this.title, required this.onTap, this.leading, this.subtitle, this.selected = false, this.trailing, this.titleColor, this.subtitleColor, this.showSelectedBackground = true, }); /// Widget to display before the title (e.g., an icon or avatar). final Widget? leading; /// The main text of the menu item. final String title; /// The color of the title. final Color? titleColor; /// Optional secondary text displayed below the title. final String? subtitle; /// The color of the subtitle. final Color? subtitleColor; /// Whether the menu item is selected. final bool selected; /// Whether to show the selected background color. final bool showSelectedBackground; /// Called when the menu item is tapped. final VoidCallback onTap; /// Widget to display after the title (e.g., a trailing icon). final Widget? trailing; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return AFMenuItem( title: Text( title, style: theme.textStyle.body.standard( color: titleColor ?? theme.textColorScheme.primary, ), ), subtitle: subtitle != null ? Text( subtitle!, style: theme.textStyle.caption.standard( color: subtitleColor ?? theme.textColorScheme.secondary, ), ) : null, leading: leading, trailing: trailing, selected: selected, showSelectedBackground: showSelectedBackground, onTap: onTap, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/dimension.dart ================================================ class AFModalDimension { const AFModalDimension._(); static const double S = 400.0; static const double M = 560.0; static const double L = 720.0; static const double dialogHeight = 200.0; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/modal/modal.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; export 'dimension.dart'; class AFModal extends StatelessWidget { const AFModal({ super.key, this.constraints = const BoxConstraints(), this.backgroundColor, required this.child, }); final BoxConstraints constraints; final Color? backgroundColor; final Widget child; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Center( child: Padding( padding: EdgeInsets.all(theme.spacing.xl), child: ConstrainedBox( constraints: constraints, child: DecoratedBox( decoration: BoxDecoration( boxShadow: theme.shadow.medium, borderRadius: BorderRadius.circular(theme.borderRadius.xl), color: backgroundColor ?? theme.surfaceColorScheme.primary, ), child: Material( color: Colors.transparent, child: child, ), ), ), ), ); } } class AFModalHeader extends StatelessWidget { const AFModalHeader({ super.key, required this.leading, this.trailing = const [], }); final Widget leading; final List trailing; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Padding( padding: EdgeInsets.only( top: theme.spacing.xl, left: theme.spacing.xxl, right: theme.spacing.xxl, ), child: DefaultTextStyle( style: theme.textStyle.heading4.prominent( color: theme.textColorScheme.primary, ), child: Row( spacing: theme.spacing.s, children: [ Expanded(child: leading), ...trailing, ], ), ), ); } } class AFModalFooter extends StatelessWidget { const AFModalFooter({ super.key, this.leading = const [], this.trailing = const [], }); final List leading; final List trailing; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Padding( padding: EdgeInsets.only( bottom: theme.spacing.xl, left: theme.spacing.xxl, right: theme.spacing.xxl, ), child: Row( spacing: theme.spacing.l, children: [ ...leading, Spacer(), ...trailing, ], ), ); } } class AFModalBody extends StatelessWidget { const AFModalBody({ super.key, required this.child, }); final Widget child; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); return Padding( padding: EdgeInsets.symmetric( vertical: theme.spacing.l, horizontal: theme.spacing.xxl, ), child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/popover/anchor.dart ================================================ import 'package:appflowy_ui/src/component/popover/shadcn/_portal.dart'; import 'package:flutter/material.dart'; /// Automatically infers the position of the [ShadPortal] in the global /// coordinate system adjusting according to the [offset], /// [followerAnchor] and [targetAnchor] properties. @immutable class AFAnchorAuto extends ShadAnchorAuto { const AFAnchorAuto({ super.offset, super.followTargetOnResize, super.followerAnchor, super.targetAnchor, }); } /// Manually specifies the position of the [ShadPortal] in the global /// coordinate system. @immutable class AFAnchor extends ShadAnchor { const AFAnchor({ super.childAlignment, super.overlayAlignment, super.offset, }); } /// Manually specifies the position of the [ShadPortal] in the global /// coordinate system. @immutable class AFGlobalAnchor extends ShadGlobalAnchor { const AFGlobalAnchor(super.offset); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/popover/popover.dart ================================================ import 'dart:ui'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:appflowy_ui/src/component/popover/shadcn/_mouse_area.dart'; import 'package:appflowy_ui/src/component/popover/shadcn/_portal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; export 'anchor.dart'; /// Notes: The implementation of this page is copied from [flutter_shadcn_ui](https://github.com/nank1ro/flutter-shadcn-ui). /// /// Renaming is for the consistency of the AppFlowy UI. /// Controls the visibility of a [AFPopover]. class AFPopoverController extends ChangeNotifier { AFPopoverController({bool isOpen = false}) : _isOpen = isOpen; bool _isOpen = false; /// Indicates if the popover is visible. bool get isOpen => _isOpen; /// Displays the popover. void show() { if (_isOpen) return; _isOpen = true; notifyListeners(); } /// Hides the popover. void hide() { if (!_isOpen) return; _isOpen = false; notifyListeners(); } void setOpen(bool open) { if (_isOpen == open) return; _isOpen = open; notifyListeners(); } /// Toggles the visibility of the popover. void toggle() => _isOpen ? hide() : show(); } class AFPopover extends StatefulWidget { const AFPopover({ super.key, required this.child, required this.popover, this.controller, this.visible, this.closeOnTapOutside = true, this.focusNode, this.anchor, this.effects, this.shadows, this.padding, this.decoration, this.filter, this.groupId, this.areaGroupId, this.useSameGroupIdForChild = true, }) : assert( (controller != null) ^ (visible != null), 'Either controller or visible must be provided', ); /// {@template ShadPopover.popover} /// The widget displayed as a popover. /// {@endtemplate} final WidgetBuilder popover; /// {@template ShadPopover.child} /// The child widget. /// {@endtemplate} final Widget child; /// {@template ShadPopover.controller} /// The controller that controls the visibility of the [popover]. /// {@endtemplate} final AFPopoverController? controller; /// {@template ShadPopover.visible} /// Indicates if the popover should be visible. /// {@endtemplate} final bool? visible; /// {@template ShadPopover.closeOnTapOutside} /// Closes the popover when the user taps outside, defaults to true. /// {@endtemplate} final bool closeOnTapOutside; /// {@template ShadPopover.focusNode} /// The focus node of the child, the [popover] will be shown when /// focused. /// {@endtemplate} final FocusNode? focusNode; ///{@template ShadPopover.anchor} /// The position of the [popover] in the global coordinate system. /// /// Defaults to `ShadAnchorAuto()`. /// {@endtemplate} final ShadAnchorBase? anchor; /// {@template ShadPopover.effects} /// The animation effects applied to the [popover]. Defaults to /// [FadeEffect(), ScaleEffect(begin: Offset(.95, .95), end: Offset(1, 1)), /// MoveEffect(begin: Offset(0, 2), end: Offset(0, 0))]. /// {@endtemplate} final List>? effects; /// {@template ShadPopover.shadows} /// The shadows applied to the [popover], defaults to /// [ShadShadows.md]. /// {@endtemplate} final List? shadows; /// {@template ShadPopover.padding} /// The padding of the [popover], defaults to /// `EdgeInsets.symmetric(horizontal: 12, vertical: 6)`. /// {@endtemplate} final EdgeInsetsGeometry? padding; /// {@template ShadPopover.decoration} /// The decoration of the [popover]. /// {@endtemplate} final BoxDecoration? decoration; /// {@template ShadPopover.filter} /// The filter of the [popover], defaults to `null`. /// {@endtemplate} final ImageFilter? filter; /// {@template ShadPopover.groupId} /// The group id of the [popover], defaults to `UniqueKey()`. /// /// Used to determine it the tap is inside the [popover] or not. /// {@endtemplate} final Object? groupId; /// {@macro ShadMouseArea.groupId} final Object? areaGroupId; /// {@template ShadPopover.useSameGroupIdForChild} /// Whether the [groupId] should be used for the child widget, defaults to /// `true`. This teams that taps on the child widget will be handled as inside /// the popover. /// {@endtemplate} final bool useSameGroupIdForChild; @override State createState() => _AFPopoverState(); } class _AFPopoverState extends State { static final List<_AFPopoverState> _openPopovers = []; static int? _lastPopoverClosedTimestamp; static void _markPopoverClosedThisFrame() { _lastPopoverClosedTimestamp = DateTime.now().microsecondsSinceEpoch; WidgetsBinding.instance.addPostFrameCallback((_) { _lastPopoverClosedTimestamp = null; }); } AFPopoverController? _controller; AFPopoverController get controller => widget.controller ?? _controller!; bool animating = false; late final _popoverKey = UniqueKey(); Object get groupId => widget.groupId ?? _popoverKey; bool get _isTopMostPopover => _openPopovers.isNotEmpty && _openPopovers.last == this; @override void initState() { super.initState(); if (widget.controller == null) { _controller = AFPopoverController(); } controller.addListener(_onControllerChanged); if (controller.isOpen) { _registerPopover(); } } void _onControllerChanged() { if (controller.isOpen) { _registerPopover(); } else { _unregisterPopover(); } } @override void didUpdateWidget(covariant AFPopover oldWidget) { super.didUpdateWidget(oldWidget); if (widget.visible != null) { if (widget.visible! && !controller.isOpen) { controller.show(); } else if (!widget.visible! && controller.isOpen) { controller.hide(); } } } @override void dispose() { controller.removeListener(_onControllerChanged); _unregisterPopover(); _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final effectiveEffects = widget.effects ?? []; final effectivePadding = widget.padding ?? EdgeInsets.symmetric( horizontal: theme.spacing.m, vertical: theme.spacing.l, ); final effectiveAnchor = widget.anchor ?? const ShadAnchorAuto(); final effectiveDecoration = widget.decoration ?? BoxDecoration( color: theme.surfaceColorScheme.layer01, borderRadius: BorderRadius.circular(theme.borderRadius.m), boxShadow: theme.shadow.medium, ); final effectiveFilter = widget.filter; Widget popover = ShadMouseArea( groupId: widget.areaGroupId, child: DecoratedBox( decoration: effectiveDecoration, child: Padding( padding: effectivePadding, child: DefaultTextStyle( style: TextStyle( color: theme.textColorScheme.primary, ), child: Builder( builder: widget.popover, ), ), ), ), ); if (effectiveFilter != null) { popover = BackdropFilter( filter: widget.filter!, child: popover, ); } if (effectiveEffects.isNotEmpty) { popover = Animate( effects: effectiveEffects, child: popover, ); } if (widget.closeOnTapOutside) { popover = TapRegion( groupId: groupId, behavior: HitTestBehavior.opaque, onTapOutside: (_) { final now = DateTime.now().microsecondsSinceEpoch; if (_isTopMostPopover && (_lastPopoverClosedTimestamp == null || now - _lastPopoverClosedTimestamp! > 1000)) { controller.hide(); _markPopoverClosedThisFrame(); } }, child: popover, ); } Widget child = ListenableBuilder( listenable: controller, builder: (context, _) { return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): () { controller.hide(); }, }, child: ShadPortal( portalBuilder: (_) => popover, visible: controller.isOpen, anchor: effectiveAnchor, child: widget.child, ), ); }, ); if (widget.useSameGroupIdForChild) { child = TapRegion( groupId: groupId, child: child, ); } return child; } void _registerPopover() { if (!_openPopovers.contains(this)) { _openPopovers.add(this); } } void _unregisterPopover() { _openPopovers.remove(this); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/popover/shadcn/_mouse_area.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; /// Notes: The implementation of this page is copied from [flutter_shadcn_ui](https://github.com/nank1ro/flutter-shadcn-ui). abstract class MouseAreaRegistry { /// Register the given [ShadMouseAreaRenderBox] with the registry. void registerMouseArea(ShadMouseAreaRenderBox region); /// Unregister the given [ShadMouseAreaRenderBox] with the registry. void unregisterMouseArea(ShadMouseAreaRenderBox region); /// Allows finding of the nearest [MouseAreaRegistry], such as a /// [MouseAreaSurfaceRenderBox]. static MouseAreaRegistry? maybeOf(BuildContext context) { return context.findAncestorRenderObjectOfType(); } /// Allows finding of the nearest [MouseAreaRegistry], such as a /// [MouseAreaSurfaceRenderBox]. /// /// Will throw if a [MouseAreaRegistry] isn't found. static MouseAreaRegistry of(BuildContext context) { final registry = maybeOf(context); assert(() { if (registry == null) { throw FlutterError( ''' MouseRegionRegistry.of() was called with a context that does not contain a MouseRegionSurface widget.\n No MouseRegionSurface widget ancestor could be found starting from the context that was passed to MouseRegionRegistry.of().\n The context used was:\n $context ''', ); } return true; }()); return registry!; } } class MouseAreaSurfaceRenderBox extends RenderProxyBoxWithHitTestBehavior implements MouseAreaRegistry { final Expando _cachedResults = Expando(); final Set _registeredRegions = {}; final Map> _groupIdToRegions = >{}; @override void handleEvent(PointerEvent event, HitTestEntry entry) { assert(debugHandleEvent(event, entry)); assert( () { for (final region in _registeredRegions) { if (!region.enabled) { return false; } } return true; }(), 'A MouseAreaRegion was registered when it was disabled.', ); if (_registeredRegions.isEmpty) { return; } final result = _cachedResults[entry]; if (result == null) { return; } // A child was hit, so we need to call onExit for those regions or // groups of regions that were not hit. final hitRegions = _getRegionsHit(_registeredRegions, result.path) .cast() .toSet(); final insideRegions = { for (final ShadMouseAreaRenderBox region in hitRegions) if (region.groupId == null) region // Adding all grouped regions, so they act as a single region. else ..._groupIdToRegions[region.groupId]!, }; // If they're not inside, then they're outside. final outsideRegions = _registeredRegions.difference(insideRegions); for (final region in outsideRegions) { region.onExit?.call( PointerExitEvent( viewId: event.viewId, timeStamp: event.timeStamp, pointer: event.pointer, device: event.device, position: event.position, delta: event.delta, buttons: event.buttons, obscured: event.obscured, pressureMin: event.pressureMin, pressureMax: event.pressureMax, distance: event.distance, distanceMax: event.distanceMax, size: event.size, radiusMajor: event.radiusMajor, radiusMinor: event.radiusMinor, radiusMin: event.radiusMin, radiusMax: event.radiusMax, orientation: event.orientation, tilt: event.tilt, down: event.down, synthesized: event.synthesized, embedderId: event.embedderId, ), ); } for (final region in insideRegions) { region.onEnter?.call( PointerEnterEvent( viewId: event.viewId, timeStamp: event.timeStamp, pointer: event.pointer, device: event.device, position: event.position, delta: event.delta, buttons: event.buttons, obscured: event.obscured, pressureMin: event.pressureMin, pressureMax: event.pressureMax, distance: event.distance, distanceMax: event.distanceMax, size: event.size, radiusMajor: event.radiusMajor, radiusMinor: event.radiusMinor, radiusMin: event.radiusMin, radiusMax: event.radiusMax, orientation: event.orientation, tilt: event.tilt, down: event.down, synthesized: event.synthesized, embedderId: event.embedderId, ), ); } } @override bool hitTest(BoxHitTestResult result, {required Offset position}) { if (!size.contains(position)) { return false; } final hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position); if (hitTarget) { final entry = BoxHitTestEntry(this, position); _cachedResults[entry] = result; result.add(entry); } return hitTarget; } @override void registerMouseArea(ShadMouseAreaRenderBox region) { assert(!_registeredRegions.contains(region)); _registeredRegions.add(region); if (region.groupId != null) { _groupIdToRegions[region.groupId] ??= {}; _groupIdToRegions[region.groupId]!.add(region); } } @override void unregisterMouseArea(ShadMouseAreaRenderBox region) { assert(_registeredRegions.contains(region)); _registeredRegions.remove(region); if (region.groupId != null) { assert(_groupIdToRegions.containsKey(region.groupId)); _groupIdToRegions[region.groupId]!.remove(region); if (_groupIdToRegions[region.groupId]!.isEmpty) { _groupIdToRegions.remove(region.groupId); } } } // Returns the registered regions that are in the hit path. Set _getRegionsHit( Set detectors, Iterable hitTestPath, ) { return { for (final HitTestEntry entry in hitTestPath) if (entry.target case final HitTestTarget target) if (_registeredRegions.contains(target)) target, }; } } class ShadMouseArea extends SingleChildRenderObjectWidget { /// Creates a const [ShadMouseArea]. /// /// The [child] argument is required. const ShadMouseArea({ super.key, super.child, this.enabled = true, this.behavior = HitTestBehavior.deferToChild, this.groupId, this.onEnter, this.onExit, this.cursor = MouseCursor.defer, String? debugLabel, }) : debugLabel = kReleaseMode ? null : debugLabel; /// Whether or not this [ShadMouseArea] is enabled as part of the composite /// region. final bool enabled; /// How to behave during hit testing when deciding how the hit test propagates /// to children and whether to consider targets behind this [ShadMouseArea]. /// /// Defaults to [HitTestBehavior.deferToChild]. /// /// See [HitTestBehavior] for the allowed values and their meanings. final HitTestBehavior behavior; /// {@template ShadMouseArea.groupId} /// An optional group ID that groups [ShadMouseArea]s together so that they /// operate as one region. If any member of a group is hit by a particular /// hover, then all members will have their [onEnter] or [onExit] called. /// /// If the group id is null, then only this region is hit tested. /// {@endtemplate} final Object? groupId; /// Triggered when a pointer enters the region. final PointerEnterEventListener? onEnter; /// Triggered when a pointer exits the region. final PointerExitEventListener? onExit; /// The mouse cursor for mouse pointers that are hovering over the region. /// /// When a mouse enters the region, its cursor will be changed to the [cursor] /// When the mouse leaves the region, the cursor will be decided by the region /// found at the new location. /// /// The [cursor] defaults to [MouseCursor.defer], deferring the choice of /// cursor to the next region behind it in hit-test order. final MouseCursor cursor; /// An optional debug label to help with debugging in debug mode. /// /// Will be null in release mode. final String? debugLabel; @override RenderObject createRenderObject(BuildContext context) { return ShadMouseAreaRenderBox( registry: MouseAreaRegistry.maybeOf(context), enabled: enabled, behavior: behavior, groupId: groupId, debugLabel: debugLabel, onEnter: onEnter, onExit: onExit, cursor: cursor, ); } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties ..add( FlagProperty( 'enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true, ), ) ..add( DiagnosticsProperty( 'behavior', behavior, defaultValue: HitTestBehavior.deferToChild, ), ) ..add( DiagnosticsProperty( 'debugLabel', debugLabel, defaultValue: null, ), ) ..add( DiagnosticsProperty('groupId', groupId, defaultValue: null), ); } @override void updateRenderObject( BuildContext context, covariant ShadMouseAreaRenderBox renderObject, ) { renderObject ..registry = MouseAreaRegistry.maybeOf(context) ..enabled = enabled ..behavior = behavior ..groupId = groupId ..onEnter = onEnter ..onExit = onExit; if (!kReleaseMode) { renderObject.debugLabel = debugLabel; } } } class ShadMouseAreaRenderBox extends RenderProxyBoxWithHitTestBehavior { /// Creates a [ShadMouseAreaRenderBox]. ShadMouseAreaRenderBox({ this.onEnter, this.onExit, MouseAreaRegistry? registry, bool enabled = true, super.behavior = HitTestBehavior.deferToChild, bool validForMouseTracker = true, Object? groupId, String? debugLabel, MouseCursor cursor = MouseCursor.defer, }) : _registry = registry, _cursor = cursor, _validForMouseTracker = validForMouseTracker, _enabled = enabled, _groupId = groupId, debugLabel = kReleaseMode ? null : debugLabel; bool _isRegistered = false; /// A label used in debug builds. Will be null in release builds. String? debugLabel; bool _enabled; Object? _groupId; MouseAreaRegistry? _registry; bool _validForMouseTracker; MouseCursor _cursor; PointerEnterEventListener? onEnter; PointerExitEventListener? onExit; MouseCursor get cursor => _cursor; set cursor(MouseCursor value) { if (_cursor != value) { _cursor = value; // A repaint is needed in order to trigger a device update of // [MouseTracker] so that this new value can be found. markNeedsPaint(); } } /// Whether or not this region should participate in the composite region. bool get enabled => _enabled; set enabled(bool value) { if (_enabled != value) { _enabled = value; markNeedsLayout(); } } /// An optional group ID that groups [ShadMouseAreaRenderBox]s together so /// that they operate as one region. If any member of a group is hit by a /// particular hover, then all members will have their /// [onEnter] or [onExit] called. /// /// If the group id is null, then only this region is hit tested. Object? get groupId => _groupId; set groupId(Object? value) { if (_groupId != value) { // If the group changes, we need to unregister and re-register under the // new group. The re-registration happens automatically in layout(). if (_isRegistered) { _registry!.unregisterMouseArea(this); _isRegistered = false; } _groupId = value; markNeedsLayout(); } } /// The registry that this [ShadMouseAreaRenderBox] should register with. /// /// If the [registry] is null, then this region will not be registered /// anywhere, and will not do any tap detection. /// /// A [MouseAreaSurfaceRenderBox] is a [MouseAreaRegistry]. MouseAreaRegistry? get registry => _registry; set registry(MouseAreaRegistry? value) { if (_registry != value) { if (_isRegistered) { _registry!.unregisterMouseArea(this); _isRegistered = false; } _registry = value; markNeedsLayout(); } } bool get validForMouseTracker => _validForMouseTracker; @override void attach(PipelineOwner owner) { super.attach(owner); _validForMouseTracker = true; } @override Size computeSizeForNoChild(BoxConstraints constraints) { return constraints.biggest; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties ..add( DiagnosticsProperty( 'debugLabel', debugLabel, defaultValue: null, ), ) ..add( DiagnosticsProperty('groupId', groupId, defaultValue: null), ) ..add( FlagProperty( 'enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true, ), ); } @override void detach() { // It's possible that the renderObject be detached during mouse events // dispatching, set the [MouseTrackerAnnotation.validForMouseTracker] false // to prevent the callbacks from being called. _validForMouseTracker = false; super.detach(); } @override void dispose() { if (_isRegistered) { _registry!.unregisterMouseArea(this); } super.dispose(); } @override void layout(Constraints constraints, {bool parentUsesSize = false}) { super.layout(constraints, parentUsesSize: parentUsesSize); if (_registry == null) { return; } if (_isRegistered) { _registry!.unregisterMouseArea(this); } final shouldBeRegistered = _enabled && _registry != null; if (shouldBeRegistered) { _registry!.registerMouseArea(this); } _isRegistered = shouldBeRegistered; } } /// A widget that provides notification of a hover inside or outside of a set of /// registered regions, grouped by [ShadMouseArea.groupId], without /// participating in the [gesture disambiguation](https://flutter.dev/to/gesture-disambiguation) system. class ShadMouseAreaSurface extends SingleChildRenderObjectWidget { /// Creates a const [RenderTapRegionSurface]. /// /// The [child] attribute is required. const ShadMouseAreaSurface({ super.key, required Widget super.child, }); @override RenderObject createRenderObject(BuildContext context) { return MouseAreaSurfaceRenderBox(); } @override void updateRenderObject( BuildContext context, RenderProxyBoxWithHitTestBehavior renderObject, ) {} } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/popover/shadcn/_portal.dart ================================================ import 'package:flutter/material.dart'; /// Notes: The implementation of this page is copied from [flutter_shadcn_ui](https://github.com/nank1ro/flutter-shadcn-ui). /// The position of the [ShadPortal] in the global coordinate system. sealed class ShadAnchorBase { const ShadAnchorBase(); } /// Automatically infers the position of the [ShadPortal] in the global /// coordinate system adjusting according to the [offset], /// [followerAnchor] and [targetAnchor] properties. @immutable class ShadAnchorAuto extends ShadAnchorBase { const ShadAnchorAuto({ this.offset = Offset.zero, this.followTargetOnResize = true, this.followerAnchor = Alignment.bottomCenter, this.targetAnchor = Alignment.bottomCenter, }); /// The offset of the overlay from the target widget. final Offset offset; /// Whether the overlay is automatically adjusted to follow the target /// widget when the target widget moves dues to a window resize. final bool followTargetOnResize; /// The coordinates of the overlay from which the overlay starts, which /// is calculated from the initial [targetAnchor]. final Alignment followerAnchor; /// The coordinates of the target from which the overlay starts. final Alignment targetAnchor; @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is ShadAnchorAuto && other.offset == offset && other.followTargetOnResize == followTargetOnResize && other.followerAnchor == followerAnchor && other.targetAnchor == targetAnchor; } @override int get hashCode => offset.hashCode ^ followTargetOnResize.hashCode ^ followerAnchor.hashCode ^ targetAnchor.hashCode; } /// Manually specifies the position of the [ShadPortal] in the global /// coordinate system. @immutable class ShadAnchor extends ShadAnchorBase { const ShadAnchor({ this.childAlignment = Alignment.topLeft, this.overlayAlignment = Alignment.bottomLeft, this.offset = Offset.zero, }); final Alignment childAlignment; final Alignment overlayAlignment; final Offset offset; static const center = ShadAnchor( childAlignment: Alignment.topCenter, overlayAlignment: Alignment.bottomCenter, ); ShadAnchor copyWith({ Alignment? childAlignment, Alignment? overlayAlignment, Offset? offset, }) { return ShadAnchor( childAlignment: childAlignment ?? this.childAlignment, overlayAlignment: overlayAlignment ?? this.overlayAlignment, offset: offset ?? this.offset, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is ShadAnchor && other.childAlignment == childAlignment && other.overlayAlignment == overlayAlignment && other.offset == offset; } @override int get hashCode { return childAlignment.hashCode ^ overlayAlignment.hashCode ^ offset.hashCode; } } @immutable class ShadGlobalAnchor extends ShadAnchorBase { const ShadGlobalAnchor(this.offset); /// The global offset where the overlay is positioned. final Offset offset; @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is ShadGlobalAnchor && other.offset == offset; } @override int get hashCode => offset.hashCode; } class ShadPortal extends StatefulWidget { const ShadPortal({ super.key, required this.child, required this.portalBuilder, required this.visible, required this.anchor, }); final Widget child; final WidgetBuilder portalBuilder; final bool visible; final ShadAnchorBase anchor; @override State createState() => _ShadPortalState(); } class _ShadPortalState extends State { final layerLink = LayerLink(); final overlayPortalController = OverlayPortalController(); final overlayKey = GlobalKey(); @override void initState() { super.initState(); updateVisibility(); } @override void didUpdateWidget(covariant ShadPortal oldWidget) { super.didUpdateWidget(oldWidget); updateVisibility(); } @override void dispose() { hide(); super.dispose(); } void updateVisibility() { final shouldShow = widget.visible; WidgetsBinding.instance.addPostFrameCallback((timer) { shouldShow ? show() : hide(); }); } void hide() { if (overlayPortalController.isShowing) { overlayPortalController.hide(); } } void show() { if (!overlayPortalController.isShowing) { overlayPortalController.show(); } } Widget buildAutoPosition( BuildContext context, ShadAnchorAuto anchor, ) { if (anchor.followTargetOnResize) { MediaQuery.sizeOf(context); } final overlayState = Overlay.of(context, debugRequiredFor: widget); final box = this.context.findRenderObject()! as RenderBox; final overlayAncestor = overlayState.context.findRenderObject()! as RenderBox; final overlay = overlayKey.currentContext?.findRenderObject() as RenderBox?; final overlaySize = overlay?.size ?? Size.zero; final targetOffset = switch (anchor.targetAnchor) { Alignment.topLeft => box.size.topLeft(Offset.zero), Alignment.topCenter => box.size.topCenter(Offset.zero), Alignment.topRight => box.size.topRight(Offset.zero), Alignment.centerLeft => box.size.centerLeft(Offset.zero), Alignment.center => box.size.center(Offset.zero), Alignment.centerRight => box.size.centerRight(Offset.zero), Alignment.bottomLeft => box.size.bottomLeft(Offset.zero), Alignment.bottomCenter => box.size.bottomCenter(Offset.zero), Alignment.bottomRight => box.size.bottomRight(Offset.zero), final alignment => throw Exception( """ShadAnchorAuto doesn't support the alignment $alignment you provided""", ), }; var followerOffset = switch (anchor.followerAnchor) { Alignment.topLeft => Offset(-overlaySize.width / 2, -overlaySize.height), Alignment.topCenter => Offset(0, -overlaySize.height), Alignment.topRight => Offset(overlaySize.width / 2, -overlaySize.height), Alignment.centerLeft => Offset(-overlaySize.width / 2, -overlaySize.height / 2), Alignment.center => Offset(0, -overlaySize.height / 2), Alignment.centerRight => Offset(overlaySize.width / 2, -overlaySize.height / 2), Alignment.bottomLeft => Offset(-overlaySize.width / 2, 0), Alignment.bottomCenter => Offset.zero, Alignment.bottomRight => Offset(overlaySize.width / 2, 0), final alignment => throw Exception( """ShadAnchorAuto doesn't support the alignment $alignment you provided""", ), }; followerOffset += targetOffset + anchor.offset; final target = box.localToGlobal( followerOffset, ancestor: overlayAncestor, ); if (overlay == null) { WidgetsBinding.instance.addPostFrameCallback((_) { setState(() {}); }); } return CustomSingleChildLayout( delegate: ShadPositionDelegate( target: target, verticalOffset: 0, preferBelow: true, ), child: KeyedSubtree( key: overlayKey, child: Visibility.maintain( // The overlay layout details are available only after the view is // rendered, in this way we can avoid the flickering effect. visible: overlay != null, child: IgnorePointer( ignoring: overlay == null, child: widget.portalBuilder(context), ), ), ), ); } Widget buildManualPosition( BuildContext context, ShadAnchor anchor, ) { return CompositedTransformFollower( link: layerLink, offset: anchor.offset, followerAnchor: anchor.childAlignment, targetAnchor: anchor.overlayAlignment, child: widget.portalBuilder(context), ); } Widget buildGlobalPosition( BuildContext context, ShadGlobalAnchor anchor, ) { return CustomSingleChildLayout( delegate: ShadPositionDelegate( target: anchor.offset, verticalOffset: 0, preferBelow: true, ), child: widget.portalBuilder(context), ); } @override Widget build(BuildContext context) { return CompositedTransformTarget( link: layerLink, child: OverlayPortal( controller: overlayPortalController, overlayChildBuilder: (context) { return Material( type: MaterialType.transparency, child: Center( widthFactor: 1, heightFactor: 1, child: switch (widget.anchor) { final ShadAnchorAuto anchor => buildAutoPosition(context, anchor), final ShadAnchor anchor => buildManualPosition(context, anchor), final ShadGlobalAnchor anchor => buildGlobalPosition(context, anchor), }, ), ); }, child: widget.child, ), ); } } /// A delegate for computing the layout of an overlay to be displayed above or /// below a target specified in the global coordinate system. class ShadPositionDelegate extends SingleChildLayoutDelegate { /// Creates a delegate for computing the layout of an overlay. ShadPositionDelegate({ required this.target, required this.verticalOffset, required this.preferBelow, }); /// The offset of the target the overlay is positioned near in the global /// coordinate system. final Offset target; /// The amount of vertical distance between the target and the displayed /// overlay. final double verticalOffset; /// Whether the overlay is displayed below its widget by default. /// /// If there is insufficient space to display the tooltip in the preferred /// direction, the tooltip will be displayed in the opposite direction. final bool preferBelow; @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen(); @override Offset getPositionForChild(Size size, Size childSize) { return positionDependentBox( size: size, childSize: childSize, target: target, verticalOffset: verticalOffset, preferBelow: preferBelow, margin: 0, ); } @override bool shouldRelayout(ShadPositionDelegate oldDelegate) { return target != oldDelegate.target || verticalOffset != oldDelegate.verticalOffset || preferBelow != oldDelegate.preferBelow; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/separator/divider.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/widgets.dart'; class AFDivider extends StatelessWidget { const AFDivider({ super.key, this.axis = Axis.horizontal, this.color, this.thickness = 1.0, this.spacing = 0.0, this.startIndent = 0.0, this.endIndent = 0.0, }) : assert(thickness > 0.0), assert(spacing >= 0.0), assert(startIndent >= 0.0), assert(endIndent >= 0.0); final Axis axis; final double thickness; final double spacing; final double startIndent; final double endIndent; final Color? color; @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final color = this.color ?? theme.borderColorScheme.primary; return switch (axis) { Axis.horizontal => Container( height: thickness, color: color, margin: EdgeInsetsDirectional.only( start: startIndent, end: endIndent, top: spacing, bottom: spacing, ), ), Axis.vertical => Container( width: thickness, color: color, margin: EdgeInsets.only( left: spacing, right: spacing, top: startIndent, bottom: endIndent, ), ), }; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart ================================================ import 'package:appflowy_ui/src/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; typedef AFTextFieldValidator = (bool result, String errorText) Function( TextEditingController controller, ); abstract class AFTextFieldState extends State { // Error handler void syncError({required String errorText}) {} void clearError() {} /// Obscure the text. void syncObscured(bool isObscured) {} } class AFTextField extends StatefulWidget { const AFTextField({ super.key, this.hintText, this.initialText, this.keyboardType, this.validator, this.controller, this.onChanged, this.onSubmitted, this.autoFocus, this.obscureText = false, this.suffixIconBuilder, this.suffixIconConstraints, this.size = AFTextFieldSize.l, this.groupId = EditableText, this.focusNode, this.readOnly = false, this.maxLength, }); /// The hint text to display when the text field is empty. final String? hintText; /// The initial text to display in the text field. final String? initialText; /// The type of keyboard to display. final TextInputType? keyboardType; /// The size variant of the text field. final AFTextFieldSize size; /// The validator to use for the text field. final AFTextFieldValidator? validator; /// The controller to use for the text field. /// /// If it's not provided, the text field will use a new controller. final TextEditingController? controller; /// The callback to call when the text field changes. final void Function(String)? onChanged; /// The callback to call when the text field is submitted. final void Function(String)? onSubmitted; /// Enable auto focus. final bool? autoFocus; /// Obscure the text. final bool obscureText; /// The trailing widget to display. final Widget? Function(BuildContext context, bool isObscured)? suffixIconBuilder; /// The size of the suffix icon. final BoxConstraints? suffixIconConstraints; /// The group ID for the text field. final Object groupId; /// The focus node for the text field. final FocusNode? focusNode; /// Readonly. final bool readOnly; /// The maximum length of the text field. final int? maxLength; @override State createState() => _AFTextFieldState(); } class _AFTextFieldState extends AFTextFieldState { late final TextEditingController effectiveController; bool hasError = false; String errorText = ''; bool isObscured = false; @override void initState() { super.initState(); effectiveController = widget.controller ?? TextEditingController(); final initialText = widget.initialText; if (initialText != null) { effectiveController.text = initialText; } effectiveController.addListener(_validate); isObscured = widget.obscureText; } @override void dispose() { effectiveController.removeListener(_validate); if (widget.controller == null) { effectiveController.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final theme = AppFlowyTheme.of(context); final borderRadius = widget.size.borderRadius(theme); final contentPadding = widget.size.contentPadding(theme); final errorBorderColor = theme.borderColorScheme.errorThick; final defaultBorderColor = theme.borderColorScheme.primary; final border = OutlineInputBorder( borderSide: BorderSide( color: hasError ? errorBorderColor : defaultBorderColor, ), borderRadius: borderRadius, ); final enabledBorder = OutlineInputBorder( borderSide: BorderSide( color: hasError ? errorBorderColor : defaultBorderColor, ), borderRadius: borderRadius, ); final focusedBorder = OutlineInputBorder( borderSide: BorderSide( color: widget.readOnly ? defaultBorderColor : hasError ? errorBorderColor : theme.borderColorScheme.themeThick, ), borderRadius: borderRadius, ); final errorBorder = OutlineInputBorder( borderSide: BorderSide( color: errorBorderColor, ), borderRadius: borderRadius, ); final focusedErrorBorder = OutlineInputBorder( borderSide: BorderSide( color: errorBorderColor, ), borderRadius: borderRadius, ); Widget child = TextField( groupId: widget.groupId, focusNode: widget.focusNode, controller: effectiveController, keyboardType: widget.keyboardType, readOnly: widget.readOnly, style: theme.textStyle.body.standard( color: theme.textColorScheme.primary, ), obscureText: isObscured, onChanged: widget.onChanged, onSubmitted: widget.onSubmitted, autofocus: widget.autoFocus ?? false, maxLength: widget.maxLength, maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, decoration: InputDecoration( hintText: widget.hintText, hintStyle: theme.textStyle.body.standard( color: theme.textColorScheme.tertiary, ), isDense: true, constraints: BoxConstraints(), contentPadding: contentPadding, border: border, enabledBorder: enabledBorder, focusedBorder: focusedBorder, errorBorder: errorBorder, focusedErrorBorder: focusedErrorBorder, hoverColor: theme.borderColorScheme.primaryHover, suffixIcon: widget.suffixIconBuilder?.call(context, isObscured), suffixIconConstraints: widget.suffixIconConstraints, ), ); if (hasError && errorText.isNotEmpty) { child = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ child, SizedBox(height: theme.spacing.xs), Text( errorText, style: theme.textStyle.caption.standard( color: theme.textColorScheme.error, ), ), ], ); } return child; } void _validate() { final validator = widget.validator; if (validator != null) { final result = validator(effectiveController); setState(() { hasError = result.$1; errorText = result.$2; }); } } @override void syncError({ required String errorText, }) { setState(() { hasError = true; this.errorText = errorText; }); } @override void clearError() { setState(() { hasError = false; errorText = ''; }); } @override void syncObscured(bool isObscured) { setState(() { this.isObscured = isObscured; }); } } enum AFTextFieldSize { m, l; EdgeInsetsGeometry contentPadding(AppFlowyThemeData theme) { return EdgeInsets.symmetric( vertical: switch (this) { AFTextFieldSize.m => theme.spacing.s, AFTextFieldSize.l => 10.0, }, horizontal: theme.spacing.m, ); } BorderRadius borderRadius(AppFlowyThemeData theme) { return BorderRadius.circular( switch (this) { AFTextFieldSize.m => theme.borderRadius.m, AFTextFieldSize.l => 10.0, }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/appflowy_theme.dart ================================================ import 'package:appflowy_ui/src/theme/theme.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class AppFlowyTheme extends StatelessWidget { const AppFlowyTheme({ super.key, required this.data, required this.child, }); final AppFlowyThemeData data; final Widget child; static AppFlowyThemeData of(BuildContext context, {bool listen = true}) { final provider = maybeOf(context, listen: listen); if (provider == null) { throw FlutterError( ''' AppFlowyTheme.of() called with a context that does not contain a AppFlowyTheme.\n No AppFlowyTheme ancestor could be found starting from the context that was passed to AppFlowyTheme.of(). This can happen because you do not have a AppFlowyTheme widget (which introduces a AppFlowyTheme), or it can happen if the context you use comes from a widget above this widget.\n The context used was: $context''', ); } return provider; } static AppFlowyThemeData? maybeOf( BuildContext context, { bool listen = true, }) { if (listen) { return context .dependOnInheritedWidgetOfExactType() ?.themeData; } final provider = context .getElementForInheritedWidgetOfExactType() ?.widget; return (provider as AppFlowyInheritedTheme?)?.themeData; } @override Widget build(BuildContext context) { return AppFlowyInheritedTheme( themeData: data, child: child, ); } } class AppFlowyInheritedTheme extends InheritedTheme { const AppFlowyInheritedTheme({ super.key, required this.themeData, required super.child, }); final AppFlowyThemeData themeData; @override Widget wrap(BuildContext context, Widget child) { return AppFlowyTheme(data: themeData, child: child); } @override bool updateShouldNotify(AppFlowyInheritedTheme oldWidget) => themeData != oldWidget.themeData; } /// An interpolation between two [AppFlowyThemeData]s. /// /// This class specializes the interpolation of [Tween] to /// call the [AppFlowyThemeData.lerp] method. /// /// See [Tween] for a discussion on how to use interpolation objects. class AppFlowyThemeDataTween extends Tween { /// Creates a [AppFlowyThemeData] tween. /// /// The [begin] and [end] properties must be non-null before the tween is /// first used, but the arguments can be null if the values are going to be /// filled in later. AppFlowyThemeDataTween({super.begin, super.end}); @override AppFlowyThemeData lerp(double t) => AppFlowyThemeData.lerp(begin!, end!, t); } class AnimatedAppFlowyTheme extends ImplicitlyAnimatedWidget { /// Creates an animated theme. /// /// By default, the theme transition uses a linear curve. const AnimatedAppFlowyTheme({ super.key, required this.data, super.curve, super.duration = kThemeAnimationDuration, super.onEnd, required this.child, }); /// Specifies the color and typography values for descendant widgets. final AppFlowyThemeData data; /// The widget below this widget in the tree. /// /// {@macro flutter.widgets.ProxyWidget.child} final Widget child; @override AnimatedWidgetBaseState createState() => _AnimatedThemeState(); } class _AnimatedThemeState extends AnimatedWidgetBaseState { AppFlowyThemeDataTween? data; @override void forEachTween(TweenVisitor visitor) { data = visitor( data, widget.data, (dynamic value) => AppFlowyThemeDataTween(begin: value as AppFlowyThemeData), )! as AppFlowyThemeDataTween; } @override Widget build(BuildContext context) { return AppFlowyTheme( data: data!.evaluate(animation), child: widget.child, ); } @override void debugFillProperties(DiagnosticPropertiesBuilder description) { super.debugFillProperties(description); description.add( DiagnosticsProperty( 'data', data, showName: false, defaultValue: null, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/primitive.dart ================================================ // ignore_for_file: constant_identifier_names, non_constant_identifier_names // // AUTO-GENERATED - DO NOT EDIT DIRECTLY // // This file is auto-generated by the generate_theme.dart script // Generation time: 2025-05-15T16:35:45.557032 // // To modify these colors, edit the source JSON files and run the script: // // dart run script/generate_theme.dart // import 'package:flutter/material.dart'; class AppFlowyPrimitiveTokens { AppFlowyPrimitiveTokens._(); /// #f8faff static Color get neutral100 => Color(0xFFF8FAFF); /// #e4e8f5 static Color get neutral200 => Color(0xFFE4E8F5); /// #ced3e6 static Color get neutral300 => Color(0xFFCED3E6); /// #b5bbd3 static Color get neutral400 => Color(0xFFB5BBD3); /// #989eb7 static Color get neutral500 => Color(0xFF989EB7); /// #6f748c static Color get neutral600 => Color(0xFF6F748C); /// #54596e static Color get neutral700 => Color(0xFF54596E); /// #3d404f static Color get neutral800 => Color(0xFF3D404F); /// #363845 static Color get neutral830 => Color(0xFF363845); /// #32343f static Color get neutral850 => Color(0xFF32343F); /// #272930 static Color get neutral900 => Color(0xFF272930); /// #21232a static Color get neutral1000 => Color(0xFF21232A); /// #000000 static Color get neutralBlack => Color(0xFF000000); /// #00000099 static Color get neutralAlphaBlack60 => Color(0x99000000); /// #ffffff static Color get neutralWhite => Color(0xFFFFFFFF); /// #ffffff00 static Color get neutralAlphaWhite0 => Color(0x00FFFFFF); /// #f9fafd0d static Color get neutralAlphaGrey10005 => Color(0x0DF9FAFD); /// #f9fafd1a static Color get neutralAlphaGrey10010 => Color(0x1AF9FAFD); /// #1f23290d static Color get neutralAlphaGrey100005 => Color(0x0D1F2329); /// #1f23291a static Color get neutralAlphaGrey100010 => Color(0x1A1F2329); /// #e3f6ff static Color get blue100 => Color(0xFFE3F6FF); /// #a9e2ff static Color get blue200 => Color(0xFFA9E2FF); /// #80d2ff static Color get blue300 => Color(0xFF80D2FF); /// #4ec1ff static Color get blue400 => Color(0xFF4EC1FF); /// #00b5ff static Color get blue500 => Color(0xFF00B5FF); /// #0092d6 static Color get blue600 => Color(0xFF0092D6); /// #0078c0 static Color get blue700 => Color(0xFF0078C0); /// #0065a9 static Color get blue800 => Color(0xFF0065A9); /// #00508f static Color get blue900 => Color(0xFF00508F); /// #003c77 static Color get blue1000 => Color(0xFF003C77); /// #00b5ff26 static Color get blueAlphaBlue50015 => Color(0x2600B5FF); /// #00b5ff33 static Color get blueAlphaBlue50020 => Color(0x3300B5FF); /// #ecf9f5 static Color get green100 => Color(0xFFECF9F5); /// #c3e5d8 static Color get green200 => Color(0xFFC3E5D8); /// #9ad1bc static Color get green300 => Color(0xFF9AD1BC); /// #71bd9f static Color get green400 => Color(0xFF71BD9F); /// #48a982 static Color get green500 => Color(0xFF48A982); /// #248569 static Color get green600 => Color(0xFF248569); /// #29725d static Color get green700 => Color(0xFF29725D); /// #2e6050 static Color get green800 => Color(0xFF2E6050); /// #305548 static Color get green900 => Color(0xFF305548); /// #305244 static Color get green1000 => Color(0xFF305244); /// #f1e0ff static Color get purple100 => Color(0xFFF1E0FF); /// #e1b3ff static Color get purple200 => Color(0xFFE1B3FF); /// #d185ff static Color get purple300 => Color(0xFFD185FF); /// #bc58ff static Color get purple400 => Color(0xFFBC58FF); /// #9327ff static Color get purple500 => Color(0xFF9327FF); /// #7a1dcc static Color get purple600 => Color(0xFF7A1DCC); /// #6617b3 static Color get purple700 => Color(0xFF6617B3); /// #55138f static Color get purple800 => Color(0xFF55138F); /// #470c72 static Color get purple900 => Color(0xFF470C72); /// #380758 static Color get purple1000 => Color(0xFF380758); /// #ffe5ef static Color get magenta100 => Color(0xFFFFE5EF); /// #ffb8d1 static Color get magenta200 => Color(0xFFFFB8D1); /// #ff8ab2 static Color get magenta300 => Color(0xFFFF8AB2); /// #ff5c93 static Color get magenta400 => Color(0xFFFF5C93); /// #fb006d static Color get magenta500 => Color(0xFFFB006D); /// #d2005f static Color get magenta600 => Color(0xFFD2005F); /// #d2005f static Color get magenta700 => Color(0xFFD2005F); /// #850040 static Color get magenta800 => Color(0xFF850040); /// #610031 static Color get magenta900 => Color(0xFF610031); /// #400022 static Color get magenta1000 => Color(0xFF400022); /// #ffd2dd static Color get red100 => Color(0xFFFFD2DD); /// #ffa5b4 static Color get red200 => Color(0xFFFFA5B4); /// #ff7d87 static Color get red300 => Color(0xFFFF7D87); /// #ff5050 static Color get red400 => Color(0xFFFF5050); /// #f33641 static Color get red500 => Color(0xFFF33641); /// #e71d32 static Color get red600 => Color(0xFFE71D32); /// #ad1625 static Color get red700 => Color(0xFFAD1625); /// #8c101c static Color get red800 => Color(0xFF8C101C); /// #6e0a1e static Color get red900 => Color(0xFF6E0A1E); /// #4c0a17 static Color get red1000 => Color(0xFF4C0A17); /// #f336411a static Color get redAlphaRed50010 => Color(0x1AF33641); /// #fff3d5 static Color get orange100 => Color(0xFFFFF3D5); /// #ffe4ab static Color get orange200 => Color(0xFFFFE4AB); /// #ffd181 static Color get orange300 => Color(0xFFFFD181); /// #ffbe62 static Color get orange400 => Color(0xFFFFBE62); /// #ffa02e static Color get orange500 => Color(0xFFFFA02E); /// #db7e21 static Color get orange600 => Color(0xFFDB7E21); /// #b75f17 static Color get orange700 => Color(0xFFB75F17); /// #93450e static Color get orange800 => Color(0xFF93450E); /// #7a3108 static Color get orange900 => Color(0xFF7A3108); /// #602706 static Color get orange1000 => Color(0xFF602706); /// #fff9b2 static Color get yellow100 => Color(0xFFFFF9B2); /// #ffec66 static Color get yellow200 => Color(0xFFFFEC66); /// #ffdf1a static Color get yellow300 => Color(0xFFFFDF1A); /// #ffcc00 static Color get yellow400 => Color(0xFFFFCC00); /// #ffce00 static Color get yellow500 => Color(0xFFFFCE00); /// #e6b800 static Color get yellow600 => Color(0xFFE6B800); /// #cc9f00 static Color get yellow700 => Color(0xFFCC9F00); /// #b38a00 static Color get yellow800 => Color(0xFFB38A00); /// #9a7500 static Color get yellow900 => Color(0xFF9A7500); /// #7f6200 static Color get yellow1000 => Color(0xFF7F6200); /// #fcf2f2 static Color get subtleColorRose100 => Color(0xFFFCF2F2); /// #fae3e3 static Color get subtleColorRose200 => Color(0xFFFAE3E3); /// #fad9d9 static Color get subtleColorRose300 => Color(0xFFFAD9D9); /// #edadad static Color get subtleColorRose400 => Color(0xFFEDADAD); /// #cc4e4e static Color get subtleColorRose500 => Color(0xFFCC4E4E); /// #702828 static Color get subtleColorRose600 => Color(0xFF702828); /// #fcf4f0 static Color get subtleColorPapaya100 => Color(0xFFFCF4F0); /// #fae8de static Color get subtleColorPapaya200 => Color(0xFFFAE8DE); /// #fadfd2 static Color get subtleColorPapaya300 => Color(0xFFFADFD2); /// #f0bda3 static Color get subtleColorPapaya400 => Color(0xFFF0BDA3); /// #d67240 static Color get subtleColorPapaya500 => Color(0xFFD67240); /// #6b3215 static Color get subtleColorPapaya600 => Color(0xFF6B3215); /// #fff7ed static Color get subtleColorTangerine100 => Color(0xFFFFF7ED); /// #fcedd9 static Color get subtleColorTangerine200 => Color(0xFFFCEDD9); /// #fae5ca static Color get subtleColorTangerine300 => Color(0xFFFAE5CA); /// #f2cb99 static Color get subtleColorTangerine400 => Color(0xFFF2CB99); /// #db8f2c static Color get subtleColorTangerine500 => Color(0xFFDB8F2C); /// #613b0a static Color get subtleColorTangerine600 => Color(0xFF613B0A); /// #fff9ec static Color get subtleColorMango100 => Color(0xFFFFF9EC); /// #fcf1d7 static Color get subtleColorMango200 => Color(0xFFFCF1D7); /// #fae9c3 static Color get subtleColorMango300 => Color(0xFFFAE9C3); /// #f5d68e static Color get subtleColorMango400 => Color(0xFFF5D68E); /// #e0a416 static Color get subtleColorMango500 => Color(0xFFE0A416); /// #5c4102 static Color get subtleColorMango600 => Color(0xFF5C4102); /// #fffbe8 static Color get subtleColorLemon100 => Color(0xFFFFFBE8); /// #fcf5cf static Color get subtleColorLemon200 => Color(0xFFFCF5CF); /// #faefb9 static Color get subtleColorLemon300 => Color(0xFFFAEFB9); /// #f5e282 static Color get subtleColorLemon400 => Color(0xFFF5E282); /// #e0bb00 static Color get subtleColorLemon500 => Color(0xFFE0BB00); /// #574800 static Color get subtleColorLemon600 => Color(0xFF574800); /// #f9fae6 static Color get subtleColorOlive100 => Color(0xFFF9FAE6); /// #f6f7d0 static Color get subtleColorOlive200 => Color(0xFFF6F7D0); /// #f0f2b3 static Color get subtleColorOlive300 => Color(0xFFF0F2B3); /// #dbde83 static Color get subtleColorOlive400 => Color(0xFFDBDE83); /// #adb204 static Color get subtleColorOlive500 => Color(0xFFADB204); /// #4a4c03 static Color get subtleColorOlive600 => Color(0xFF4A4C03); /// #f6f9e6 static Color get subtleColorLime100 => Color(0xFFF6F9E6); /// #eef5ce static Color get subtleColorLime200 => Color(0xFFEEF5CE); /// #e7f0bb static Color get subtleColorLime300 => Color(0xFFE7F0BB); /// #cfdb91 static Color get subtleColorLime400 => Color(0xFFCFDB91); /// #92a822 static Color get subtleColorLime500 => Color(0xFF92A822); /// #414d05 static Color get subtleColorLime600 => Color(0xFF414D05); /// #f4faeb static Color get subtleColorGrass100 => Color(0xFFF4FAEB); /// #e9f5d7 static Color get subtleColorGrass200 => Color(0xFFE9F5D7); /// #def0c5 static Color get subtleColorGrass300 => Color(0xFFDEF0C5); /// #bfd998 static Color get subtleColorGrass400 => Color(0xFFBFD998); /// #75a828 static Color get subtleColorGrass500 => Color(0xFF75A828); /// #334d0c static Color get subtleColorGrass600 => Color(0xFF334D0C); /// #f1faf0 static Color get subtleColorForest100 => Color(0xFFF1FAF0); /// #e2f5df static Color get subtleColorForest200 => Color(0xFFE2F5DF); /// #d7f0d3 static Color get subtleColorForest300 => Color(0xFFD7F0D3); /// #a8d6a1 static Color get subtleColorForest400 => Color(0xFFA8D6A1); /// #49a33b static Color get subtleColorForest500 => Color(0xFF49A33B); /// #1e4f16 static Color get subtleColorForest600 => Color(0xFF1E4F16); /// #f0faf6 static Color get subtleColorJade100 => Color(0xFFF0FAF6); /// #dff5eb static Color get subtleColorJade200 => Color(0xFFDFF5EB); /// #cef0e1 static Color get subtleColorJade300 => Color(0xFFCEF0E1); /// #90d1b5 static Color get subtleColorJade400 => Color(0xFF90D1B5); /// #1c9963 static Color get subtleColorJade500 => Color(0xFF1C9963); /// #075231 static Color get subtleColorJade600 => Color(0xFF075231); /// #f0f9fa static Color get subtleColorAqua100 => Color(0xFFF0F9FA); /// #dff3f5 static Color get subtleColorAqua200 => Color(0xFFDFF3F5); /// #ccecf0 static Color get subtleColorAqua300 => Color(0xFFCCECF0); /// #83ccd4 static Color get subtleColorAqua400 => Color(0xFF83CCD4); /// #008e9e static Color get subtleColorAqua500 => Color(0xFF008E9E); /// #004e57 static Color get subtleColorAqua600 => Color(0xFF004E57); /// #f0f6fa static Color get subtleColorAzure100 => Color(0xFFF0F6FA); /// #e1eef7 static Color get subtleColorAzure200 => Color(0xFFE1EEF7); /// #d3e6f5 static Color get subtleColorAzure300 => Color(0xFFD3E6F5); /// #88c0eb static Color get subtleColorAzure400 => Color(0xFF88C0EB); /// #0877cc static Color get subtleColorAzure500 => Color(0xFF0877CC); /// #154469 static Color get subtleColorAzure600 => Color(0xFF154469); /// #f0f3fa static Color get subtleColorDenim100 => Color(0xFFF0F3FA); /// #e3ebfa static Color get subtleColorDenim200 => Color(0xFFE3EBFA); /// #d7e2f7 static Color get subtleColorDenim300 => Color(0xFFD7E2F7); /// #9ab6ed static Color get subtleColorDenim400 => Color(0xFF9AB6ED); /// #3267d1 static Color get subtleColorDenim500 => Color(0xFF3267D1); /// #223c70 static Color get subtleColorDenim600 => Color(0xFF223C70); /// #f2f2fc static Color get subtleColorMauve100 => Color(0xFFF2F2FC); /// #e6e6fa static Color get subtleColorMauve200 => Color(0xFFE6E6FA); /// #dcdcf7 static Color get subtleColorMauve300 => Color(0xFFDCDCF7); /// #aeaef5 static Color get subtleColorMauve400 => Color(0xFFAEAEF5); /// #5555e0 static Color get subtleColorMauve500 => Color(0xFF5555E0); /// #36366b static Color get subtleColorMauve600 => Color(0xFF36366B); /// #f6f3fc static Color get subtleColorLavender100 => Color(0xFFF6F3FC); /// #ebe3fa static Color get subtleColorLavender200 => Color(0xFFEBE3FA); /// #e4daf7 static Color get subtleColorLavender300 => Color(0xFFE4DAF7); /// #c1aaf0 static Color get subtleColorLavender400 => Color(0xFFC1AAF0); /// #8153db static Color get subtleColorLavender500 => Color(0xFF8153DB); /// #462f75 static Color get subtleColorLavender600 => Color(0xFF462F75); /// #f7f0fa static Color get subtleColorLilac100 => Color(0xFFF7F0FA); /// #f0e1f7 static Color get subtleColorLilac200 => Color(0xFFF0E1F7); /// #edd7f7 static Color get subtleColorLilac300 => Color(0xFFEDD7F7); /// #d3a9e8 static Color get subtleColorLilac400 => Color(0xFFD3A9E8); /// #9e4cc7 static Color get subtleColorLilac500 => Color(0xFF9E4CC7); /// #562d6b static Color get subtleColorLilac600 => Color(0xFF562D6B); /// #faf0fa static Color get subtleColorMallow100 => Color(0xFFFAF0FA); /// #f5e1f4 static Color get subtleColorMallow200 => Color(0xFFF5E1F4); /// #f5d7f4 static Color get subtleColorMallow300 => Color(0xFFF5D7F4); /// #dea4dc static Color get subtleColorMallow400 => Color(0xFFDEA4DC); /// #b240af static Color get subtleColorMallow500 => Color(0xFFB240AF); /// #632861 static Color get subtleColorMallow600 => Color(0xFF632861); /// #f9eff3 static Color get subtleColorCamellia100 => Color(0xFFF9EFF3); /// #f7e1eb static Color get subtleColorCamellia200 => Color(0xFFF7E1EB); /// #f7d7e5 static Color get subtleColorCamellia300 => Color(0xFFF7D7E5); /// #e5a3c0 static Color get subtleColorCamellia400 => Color(0xFFE5A3C0); /// #c24279 static Color get subtleColorCamellia500 => Color(0xFFC24279); /// #6e2343 static Color get subtleColorCamellia600 => Color(0xFF6E2343); /// #f5f5f5 static Color get subtleColorSmoke100 => Color(0xFFF5F5F5); /// #e8e8e8 static Color get subtleColorSmoke200 => Color(0xFFE8E8E8); /// #dedede static Color get subtleColorSmoke300 => Color(0xFFDEDEDE); /// #b8b8b8 static Color get subtleColorSmoke400 => Color(0xFFB8B8B8); /// #6e6e6e static Color get subtleColorSmoke500 => Color(0xFF6E6E6E); /// #404040 static Color get subtleColorSmoke600 => Color(0xFF404040); /// #f2f4f7 static Color get subtleColorIron100 => Color(0xFFF2F4F7); /// #e6e9f0 static Color get subtleColorIron200 => Color(0xFFE6E9F0); /// #dadee5 static Color get subtleColorIron300 => Color(0xFFDADEE5); /// #b0b5bf static Color get subtleColorIron400 => Color(0xFFB0B5BF); /// #666f80 static Color get subtleColorIron500 => Color(0xFF666F80); /// #394152 static Color get subtleColorIron600 => Color(0xFF394152); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/appflowy_default/semantic.dart ================================================ // ignore_for_file: constant_identifier_names, non_constant_identifier_names // // AUTO-GENERATED - DO NOT EDIT DIRECTLY // // This file is auto-generated by the generate_theme.dart script // Generation time: 2025-05-15T16:35:45.572178 // // To modify these colors, edit the source JSON files and run the script: // // dart run script/generate_theme.dart // import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; import '../shared.dart'; class AppFlowyDefaultTheme implements AppFlowyThemeBuilder { @override AppFlowyThemeData light({ String? fontFamily, }) { final textStyle = AppFlowyBaseTextStyle.customFontFamily(fontFamily ?? ''); final borderRadius = AppFlowySharedTokens.buildBorderRadius(); final spacing = AppFlowySharedTokens.buildSpacing(); final shadow = AppFlowySharedTokens.buildShadow(Brightness.light); final textColorScheme = AppFlowyTextColorScheme( primary: AppFlowyPrimitiveTokens.neutral1000, secondary: AppFlowyPrimitiveTokens.neutral600, tertiary: AppFlowyPrimitiveTokens.neutral500, quaternary: AppFlowyPrimitiveTokens.neutral200, onFill: AppFlowyPrimitiveTokens.neutralWhite, action: AppFlowyPrimitiveTokens.blue600, actionHover: AppFlowyPrimitiveTokens.blue700, info: AppFlowyPrimitiveTokens.blue600, infoHover: AppFlowyPrimitiveTokens.blue700, success: AppFlowyPrimitiveTokens.green600, successHover: AppFlowyPrimitiveTokens.green700, warning: AppFlowyPrimitiveTokens.orange600, warningHover: AppFlowyPrimitiveTokens.orange700, error: AppFlowyPrimitiveTokens.red600, errorHover: AppFlowyPrimitiveTokens.red700, featured: AppFlowyPrimitiveTokens.purple500, featuredHover: AppFlowyPrimitiveTokens.purple600, ); final iconColorScheme = AppFlowyIconColorScheme( primary: AppFlowyPrimitiveTokens.neutral1000, secondary: AppFlowyPrimitiveTokens.neutral600, tertiary: AppFlowyPrimitiveTokens.neutral400, quaternary: AppFlowyPrimitiveTokens.neutral200, infoThick: AppFlowyPrimitiveTokens.blue600, infoThickHover: AppFlowyPrimitiveTokens.blue700, successThick: AppFlowyPrimitiveTokens.green600, successThickHover: AppFlowyPrimitiveTokens.green700, warningThick: AppFlowyPrimitiveTokens.orange600, warningThickHover: AppFlowyPrimitiveTokens.orange700, errorThick: AppFlowyPrimitiveTokens.red600, errorThickHover: AppFlowyPrimitiveTokens.red700, featuredThick: AppFlowyPrimitiveTokens.purple500, featuredThickHover: AppFlowyPrimitiveTokens.purple600, onFill: AppFlowyPrimitiveTokens.neutralWhite, ); final borderColorScheme = AppFlowyBorderColorScheme( primary: AppFlowyPrimitiveTokens.neutral200, primaryHover: AppFlowyPrimitiveTokens.neutral300, secondary: AppFlowyPrimitiveTokens.neutral800, secondaryHover: AppFlowyPrimitiveTokens.neutral700, tertiary: AppFlowyPrimitiveTokens.neutral1000, tertiaryHover: AppFlowyPrimitiveTokens.neutral900, themeThick: AppFlowyPrimitiveTokens.blue500, themeThickHover: AppFlowyPrimitiveTokens.blue600, infoThick: AppFlowyPrimitiveTokens.blue500, infoThickHover: AppFlowyPrimitiveTokens.blue600, successThick: AppFlowyPrimitiveTokens.green600, successThickHover: AppFlowyPrimitiveTokens.green700, warningThick: AppFlowyPrimitiveTokens.orange600, warningThickHover: AppFlowyPrimitiveTokens.orange700, errorThick: AppFlowyPrimitiveTokens.red600, errorThickHover: AppFlowyPrimitiveTokens.red700, featuredThick: AppFlowyPrimitiveTokens.purple500, featuredThickHover: AppFlowyPrimitiveTokens.purple600, ); final fillColorScheme = AppFlowyFillColorScheme( primary: AppFlowyPrimitiveTokens.neutral100, primaryHover: AppFlowyPrimitiveTokens.neutral200, secondary: AppFlowyPrimitiveTokens.neutral300, secondaryHover: AppFlowyPrimitiveTokens.neutral400, tertiary: AppFlowyPrimitiveTokens.neutral600, tertiaryHover: AppFlowyPrimitiveTokens.neutral500, quaternary: AppFlowyPrimitiveTokens.neutral1000, quaternaryHover: AppFlowyPrimitiveTokens.neutral900, content: AppFlowyPrimitiveTokens.neutralAlphaWhite0, contentHover: AppFlowyPrimitiveTokens.neutralAlphaGrey100005, contentVisible: AppFlowyPrimitiveTokens.neutralAlphaGrey100005, contentVisibleHover: AppFlowyPrimitiveTokens.neutralAlphaGrey100010, themeThick: AppFlowyPrimitiveTokens.blue500, themeThickHover: AppFlowyPrimitiveTokens.blue600, themeSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50015, textSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50020, infoLight: AppFlowyPrimitiveTokens.blue100, infoLightHover: AppFlowyPrimitiveTokens.blue200, infoThick: AppFlowyPrimitiveTokens.blue500, infoThickHover: AppFlowyPrimitiveTokens.blue600, successLight: AppFlowyPrimitiveTokens.green100, successLightHover: AppFlowyPrimitiveTokens.green200, warningLight: AppFlowyPrimitiveTokens.orange100, warningLightHover: AppFlowyPrimitiveTokens.orange200, errorLight: AppFlowyPrimitiveTokens.red100, errorLightHover: AppFlowyPrimitiveTokens.red200, errorThick: AppFlowyPrimitiveTokens.red600, errorThickHover: AppFlowyPrimitiveTokens.red700, errorSelect: AppFlowyPrimitiveTokens.redAlphaRed50010, featuredLight: AppFlowyPrimitiveTokens.purple100, featuredLightHover: AppFlowyPrimitiveTokens.purple200, featuredThick: AppFlowyPrimitiveTokens.purple500, featuredThickHover: AppFlowyPrimitiveTokens.purple600, ); final surfaceColorScheme = AppFlowySurfaceColorScheme( primary: AppFlowyPrimitiveTokens.neutralWhite, primaryHover: AppFlowyPrimitiveTokens.neutral100, layer01: AppFlowyPrimitiveTokens.neutralWhite, layer01Hover: AppFlowyPrimitiveTokens.neutral100, layer02: AppFlowyPrimitiveTokens.neutralWhite, layer02Hover: AppFlowyPrimitiveTokens.neutral100, layer03: AppFlowyPrimitiveTokens.neutralWhite, layer03Hover: AppFlowyPrimitiveTokens.neutral100, layer04: AppFlowyPrimitiveTokens.neutralWhite, layer04Hover: AppFlowyPrimitiveTokens.neutral100, inverse: AppFlowyPrimitiveTokens.neutral1000, secondary: AppFlowyPrimitiveTokens.neutral1000, overlay: AppFlowyPrimitiveTokens.neutralAlphaBlack60, ); final surfaceContainerColorScheme = AppFlowySurfaceContainerColorScheme( layer01: AppFlowyPrimitiveTokens.neutral100, layer02: AppFlowyPrimitiveTokens.neutral200, layer03: AppFlowyPrimitiveTokens.neutral300, ); final backgroundColorScheme = AppFlowyBackgroundColorScheme( primary: AppFlowyPrimitiveTokens.neutralWhite, ); final badgeColorScheme = AppFlowyBadgeColorScheme( color1Light1: AppFlowyPrimitiveTokens.subtleColorRose100, color1Light2: AppFlowyPrimitiveTokens.subtleColorRose200, color1Light3: AppFlowyPrimitiveTokens.subtleColorRose300, color1Thick1: AppFlowyPrimitiveTokens.subtleColorRose400, color1Thick2: AppFlowyPrimitiveTokens.subtleColorRose500, color1Thick3: AppFlowyPrimitiveTokens.subtleColorRose600, color2Light1: AppFlowyPrimitiveTokens.subtleColorPapaya100, color2Light2: AppFlowyPrimitiveTokens.subtleColorPapaya200, color2Light3: AppFlowyPrimitiveTokens.subtleColorPapaya300, color2Thick1: AppFlowyPrimitiveTokens.subtleColorPapaya400, color2Thick2: AppFlowyPrimitiveTokens.subtleColorPapaya500, color2Thick3: AppFlowyPrimitiveTokens.subtleColorPapaya600, color3Light1: AppFlowyPrimitiveTokens.subtleColorTangerine100, color3Light2: AppFlowyPrimitiveTokens.subtleColorTangerine200, color3Light3: AppFlowyPrimitiveTokens.subtleColorTangerine300, color3Thick1: AppFlowyPrimitiveTokens.subtleColorTangerine400, color3Thick2: AppFlowyPrimitiveTokens.subtleColorTangerine500, color3Thick3: AppFlowyPrimitiveTokens.subtleColorTangerine600, color4Light1: AppFlowyPrimitiveTokens.subtleColorMango100, color4Light2: AppFlowyPrimitiveTokens.subtleColorMango200, color4Light3: AppFlowyPrimitiveTokens.subtleColorMango300, color4Thick1: AppFlowyPrimitiveTokens.subtleColorMango400, color4Thick2: AppFlowyPrimitiveTokens.subtleColorMango500, color4Thick3: AppFlowyPrimitiveTokens.subtleColorMango600, color5Light1: AppFlowyPrimitiveTokens.subtleColorLemon100, color5Light2: AppFlowyPrimitiveTokens.subtleColorLemon200, color5Light3: AppFlowyPrimitiveTokens.subtleColorLemon300, color5Thick1: AppFlowyPrimitiveTokens.subtleColorLemon400, color5Thick2: AppFlowyPrimitiveTokens.subtleColorLemon500, color5Thick3: AppFlowyPrimitiveTokens.subtleColorLemon600, color6Light1: AppFlowyPrimitiveTokens.subtleColorOlive100, color6Light2: AppFlowyPrimitiveTokens.subtleColorOlive200, color6Light3: AppFlowyPrimitiveTokens.subtleColorOlive300, color6Thick1: AppFlowyPrimitiveTokens.subtleColorOlive400, color6Thick2: AppFlowyPrimitiveTokens.subtleColorOlive500, color6Thick3: AppFlowyPrimitiveTokens.subtleColorOlive600, color7Light1: AppFlowyPrimitiveTokens.subtleColorLime100, color7Light2: AppFlowyPrimitiveTokens.subtleColorLime200, color7Light3: AppFlowyPrimitiveTokens.subtleColorLime300, color7Thick1: AppFlowyPrimitiveTokens.subtleColorLime400, color7Thick2: AppFlowyPrimitiveTokens.subtleColorLime500, color7Thick3: AppFlowyPrimitiveTokens.subtleColorLime600, color8Light1: AppFlowyPrimitiveTokens.subtleColorGrass100, color8Light2: AppFlowyPrimitiveTokens.subtleColorGrass200, color8Light3: AppFlowyPrimitiveTokens.subtleColorGrass300, color8Thick1: AppFlowyPrimitiveTokens.subtleColorGrass400, color8Thick2: AppFlowyPrimitiveTokens.subtleColorGrass500, color8Thick3: AppFlowyPrimitiveTokens.subtleColorGrass600, color9Light1: AppFlowyPrimitiveTokens.subtleColorForest100, color9Light2: AppFlowyPrimitiveTokens.subtleColorForest200, color9Light3: AppFlowyPrimitiveTokens.subtleColorForest300, color9Thick1: AppFlowyPrimitiveTokens.subtleColorForest400, color9Thick2: AppFlowyPrimitiveTokens.subtleColorForest500, color9Thick3: AppFlowyPrimitiveTokens.subtleColorForest600, color10Light1: AppFlowyPrimitiveTokens.subtleColorJade100, color10Light2: AppFlowyPrimitiveTokens.subtleColorJade200, color10Light3: AppFlowyPrimitiveTokens.subtleColorJade300, color10Thick1: AppFlowyPrimitiveTokens.subtleColorJade400, color10Thick2: AppFlowyPrimitiveTokens.subtleColorJade500, color10Thick3: AppFlowyPrimitiveTokens.subtleColorJade600, color11Light1: AppFlowyPrimitiveTokens.subtleColorAqua100, color11Light2: AppFlowyPrimitiveTokens.subtleColorAqua200, color11Light3: AppFlowyPrimitiveTokens.subtleColorAqua300, color11Thick1: AppFlowyPrimitiveTokens.subtleColorAqua400, color11Thick2: AppFlowyPrimitiveTokens.subtleColorAqua500, color11Thick3: AppFlowyPrimitiveTokens.subtleColorAqua600, color12Light1: AppFlowyPrimitiveTokens.subtleColorAzure100, color12Light2: AppFlowyPrimitiveTokens.subtleColorAzure200, color12Light3: AppFlowyPrimitiveTokens.subtleColorAzure300, color12Thick1: AppFlowyPrimitiveTokens.subtleColorAzure400, color12Thick2: AppFlowyPrimitiveTokens.subtleColorAzure500, color12Thick3: AppFlowyPrimitiveTokens.subtleColorAzure600, color13Light1: AppFlowyPrimitiveTokens.subtleColorDenim100, color13Light2: AppFlowyPrimitiveTokens.subtleColorDenim200, color13Light3: AppFlowyPrimitiveTokens.subtleColorDenim300, color13Thick1: AppFlowyPrimitiveTokens.subtleColorDenim400, color13Thick2: AppFlowyPrimitiveTokens.subtleColorDenim500, color13Thick3: AppFlowyPrimitiveTokens.subtleColorDenim600, color14Light1: AppFlowyPrimitiveTokens.subtleColorMauve100, color14Light2: AppFlowyPrimitiveTokens.subtleColorMauve200, color14Light3: AppFlowyPrimitiveTokens.subtleColorMauve300, color14Thick1: AppFlowyPrimitiveTokens.subtleColorMauve400, color14Thick2: AppFlowyPrimitiveTokens.subtleColorMauve500, color14Thick3: AppFlowyPrimitiveTokens.subtleColorMauve600, color15Light1: AppFlowyPrimitiveTokens.subtleColorLavender100, color15Light2: AppFlowyPrimitiveTokens.subtleColorLavender200, color15Light3: AppFlowyPrimitiveTokens.subtleColorLavender300, color15Thick1: AppFlowyPrimitiveTokens.subtleColorLavender400, color15Thick2: AppFlowyPrimitiveTokens.subtleColorLavender500, color15Thick3: AppFlowyPrimitiveTokens.subtleColorLavender600, color16Light1: AppFlowyPrimitiveTokens.subtleColorLilac100, color16Light2: AppFlowyPrimitiveTokens.subtleColorLilac200, color16Light3: AppFlowyPrimitiveTokens.subtleColorLilac300, color16Thick1: AppFlowyPrimitiveTokens.subtleColorLilac400, color16Thick2: AppFlowyPrimitiveTokens.subtleColorLilac500, color16Thick3: AppFlowyPrimitiveTokens.subtleColorLilac600, color17Light1: AppFlowyPrimitiveTokens.subtleColorMallow100, color17Light2: AppFlowyPrimitiveTokens.subtleColorMallow200, color17Light3: AppFlowyPrimitiveTokens.subtleColorMallow300, color17Thick1: AppFlowyPrimitiveTokens.subtleColorMallow400, color17Thick2: AppFlowyPrimitiveTokens.subtleColorMallow500, color17Thick3: AppFlowyPrimitiveTokens.subtleColorMallow600, color18Light1: AppFlowyPrimitiveTokens.subtleColorCamellia100, color18Light2: AppFlowyPrimitiveTokens.subtleColorCamellia200, color18Light3: AppFlowyPrimitiveTokens.subtleColorCamellia300, color18Thick1: AppFlowyPrimitiveTokens.subtleColorCamellia400, color18Thick2: AppFlowyPrimitiveTokens.subtleColorCamellia500, color18Thick3: AppFlowyPrimitiveTokens.subtleColorCamellia600, color19Light1: AppFlowyPrimitiveTokens.subtleColorSmoke100, color19Light2: AppFlowyPrimitiveTokens.subtleColorSmoke200, color19Light3: AppFlowyPrimitiveTokens.subtleColorSmoke300, color19Thick1: AppFlowyPrimitiveTokens.subtleColorSmoke400, color19Thick2: AppFlowyPrimitiveTokens.subtleColorSmoke500, color19Thick3: AppFlowyPrimitiveTokens.subtleColorSmoke600, color20Light1: AppFlowyPrimitiveTokens.subtleColorIron100, color20Light2: AppFlowyPrimitiveTokens.subtleColorIron200, color20Light3: AppFlowyPrimitiveTokens.subtleColorIron300, color20Thick1: AppFlowyPrimitiveTokens.subtleColorIron400, color20Thick2: AppFlowyPrimitiveTokens.subtleColorIron500, color20Thick3: AppFlowyPrimitiveTokens.subtleColorIron600, ); final brandColorScheme = AppFlowyBrandColorScheme( skyline: Color(0xFF00B5FF), aqua: Color(0xFF00C8FF), violet: Color(0xFF9327FF), amethyst: Color(0xFF8427E0), berry: Color(0xFFE3006D), coral: Color(0xFFFB006D), golden: Color(0xFFF7931E), amber: Color(0xFFFFBD00), lemon: Color(0xFFFFCE00), ); final otherColorsColorScheme = AppFlowyOtherColorsColorScheme( textHighlight: AppFlowyPrimitiveTokens.blue200, ); return AppFlowyThemeData( textStyle: textStyle, textColorScheme: textColorScheme, borderColorScheme: borderColorScheme, fillColorScheme: fillColorScheme, surfaceColorScheme: surfaceColorScheme, backgroundColorScheme: backgroundColorScheme, iconColorScheme: iconColorScheme, brandColorScheme: brandColorScheme, otherColorsColorScheme: otherColorsColorScheme, borderRadius: borderRadius, surfaceContainerColorScheme: surfaceContainerColorScheme, badgeColorScheme: badgeColorScheme, spacing: spacing, shadow: shadow, ); } @override AppFlowyThemeData dark({ String? fontFamily, }) { final textStyle = AppFlowyBaseTextStyle.customFontFamily(fontFamily ?? ''); final borderRadius = AppFlowySharedTokens.buildBorderRadius(); final spacing = AppFlowySharedTokens.buildSpacing(); final shadow = AppFlowySharedTokens.buildShadow(Brightness.dark); final textColorScheme = AppFlowyTextColorScheme( primary: AppFlowyPrimitiveTokens.neutral200, secondary: AppFlowyPrimitiveTokens.neutral500, tertiary: AppFlowyPrimitiveTokens.neutral600, quaternary: AppFlowyPrimitiveTokens.neutral1000, onFill: AppFlowyPrimitiveTokens.neutralWhite, action: AppFlowyPrimitiveTokens.blue500, actionHover: AppFlowyPrimitiveTokens.blue400, info: AppFlowyPrimitiveTokens.blue500, infoHover: AppFlowyPrimitiveTokens.blue400, success: AppFlowyPrimitiveTokens.green600, successHover: AppFlowyPrimitiveTokens.green500, warning: AppFlowyPrimitiveTokens.orange600, warningHover: AppFlowyPrimitiveTokens.orange500, error: AppFlowyPrimitiveTokens.red500, errorHover: AppFlowyPrimitiveTokens.red400, featured: AppFlowyPrimitiveTokens.purple500, featuredHover: AppFlowyPrimitiveTokens.purple400, ); final iconColorScheme = AppFlowyIconColorScheme( primary: AppFlowyPrimitiveTokens.neutral200, secondary: AppFlowyPrimitiveTokens.neutral400, tertiary: AppFlowyPrimitiveTokens.neutral600, quaternary: AppFlowyPrimitiveTokens.neutral1000, infoThick: AppFlowyPrimitiveTokens.blue500, infoThickHover: AppFlowyPrimitiveTokens.blue400, successThick: AppFlowyPrimitiveTokens.green600, successThickHover: AppFlowyPrimitiveTokens.green500, warningThick: AppFlowyPrimitiveTokens.orange600, warningThickHover: AppFlowyPrimitiveTokens.orange500, errorThick: AppFlowyPrimitiveTokens.red500, errorThickHover: AppFlowyPrimitiveTokens.red400, featuredThick: AppFlowyPrimitiveTokens.purple500, featuredThickHover: AppFlowyPrimitiveTokens.purple400, onFill: AppFlowyPrimitiveTokens.neutralWhite, ); final borderColorScheme = AppFlowyBorderColorScheme( primary: AppFlowyPrimitiveTokens.neutral800, primaryHover: AppFlowyPrimitiveTokens.neutral700, secondary: AppFlowyPrimitiveTokens.neutral300, secondaryHover: AppFlowyPrimitiveTokens.neutral200, tertiary: AppFlowyPrimitiveTokens.neutral100, tertiaryHover: AppFlowyPrimitiveTokens.neutralWhite, themeThick: AppFlowyPrimitiveTokens.blue500, themeThickHover: AppFlowyPrimitiveTokens.blue600, infoThick: AppFlowyPrimitiveTokens.blue500, infoThickHover: AppFlowyPrimitiveTokens.blue400, successThick: AppFlowyPrimitiveTokens.green600, successThickHover: AppFlowyPrimitiveTokens.green500, warningThick: AppFlowyPrimitiveTokens.orange600, warningThickHover: AppFlowyPrimitiveTokens.orange500, errorThick: AppFlowyPrimitiveTokens.red500, errorThickHover: AppFlowyPrimitiveTokens.red400, featuredThick: AppFlowyPrimitiveTokens.purple500, featuredThickHover: AppFlowyPrimitiveTokens.purple400, ); final fillColorScheme = AppFlowyFillColorScheme( primary: AppFlowyPrimitiveTokens.neutral900, primaryHover: AppFlowyPrimitiveTokens.neutral800, secondary: AppFlowyPrimitiveTokens.neutral600, secondaryHover: AppFlowyPrimitiveTokens.neutral500, tertiary: AppFlowyPrimitiveTokens.neutral300, tertiaryHover: AppFlowyPrimitiveTokens.neutral200, quaternary: AppFlowyPrimitiveTokens.neutral100, quaternaryHover: AppFlowyPrimitiveTokens.neutralWhite, content: AppFlowyPrimitiveTokens.neutralAlphaWhite0, contentHover: AppFlowyPrimitiveTokens.neutralAlphaGrey10005, contentVisible: AppFlowyPrimitiveTokens.neutralAlphaGrey10005, contentVisibleHover: AppFlowyPrimitiveTokens.neutralAlphaGrey10010, themeThick: AppFlowyPrimitiveTokens.blue500, themeThickHover: AppFlowyPrimitiveTokens.blue600, themeSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50015, textSelect: AppFlowyPrimitiveTokens.blueAlphaBlue50020, infoLight: AppFlowyPrimitiveTokens.blue200, infoLightHover: AppFlowyPrimitiveTokens.blue100, infoThick: AppFlowyPrimitiveTokens.blue500, infoThickHover: AppFlowyPrimitiveTokens.blue400, successLight: AppFlowyPrimitiveTokens.green200, successLightHover: AppFlowyPrimitiveTokens.green100, warningLight: AppFlowyPrimitiveTokens.orange200, warningLightHover: AppFlowyPrimitiveTokens.orange100, errorLight: AppFlowyPrimitiveTokens.red200, errorLightHover: AppFlowyPrimitiveTokens.red100, errorThick: AppFlowyPrimitiveTokens.red500, errorThickHover: AppFlowyPrimitiveTokens.red400, errorSelect: AppFlowyPrimitiveTokens.redAlphaRed50010, featuredLight: AppFlowyPrimitiveTokens.purple200, featuredLightHover: AppFlowyPrimitiveTokens.purple100, featuredThick: AppFlowyPrimitiveTokens.purple500, featuredThickHover: AppFlowyPrimitiveTokens.purple400, ); final surfaceColorScheme = AppFlowySurfaceColorScheme( primary: AppFlowyPrimitiveTokens.neutral900, primaryHover: AppFlowyPrimitiveTokens.neutral800, layer01: AppFlowyPrimitiveTokens.neutral900, layer01Hover: AppFlowyPrimitiveTokens.neutral800, layer02: AppFlowyPrimitiveTokens.neutral850, layer02Hover: AppFlowyPrimitiveTokens.neutral800, layer03: AppFlowyPrimitiveTokens.neutral850, layer03Hover: AppFlowyPrimitiveTokens.neutral800, layer04: AppFlowyPrimitiveTokens.neutral830, layer04Hover: AppFlowyPrimitiveTokens.neutral800, inverse: AppFlowyPrimitiveTokens.neutral800, secondary: AppFlowyPrimitiveTokens.neutral800, overlay: AppFlowyPrimitiveTokens.neutralAlphaBlack60, ); final surfaceContainerColorScheme = AppFlowySurfaceContainerColorScheme( layer01: AppFlowyPrimitiveTokens.neutral900, layer02: AppFlowyPrimitiveTokens.neutral800, layer03: AppFlowyPrimitiveTokens.neutral700, ); final backgroundColorScheme = AppFlowyBackgroundColorScheme( primary: AppFlowyPrimitiveTokens.neutral1000, ); final badgeColorScheme = AppFlowyBadgeColorScheme( color1Light1: AppFlowyPrimitiveTokens.subtleColorRose100, color1Light2: AppFlowyPrimitiveTokens.subtleColorRose200, color1Light3: AppFlowyPrimitiveTokens.subtleColorRose300, color1Thick1: AppFlowyPrimitiveTokens.subtleColorRose400, color1Thick2: AppFlowyPrimitiveTokens.subtleColorRose500, color1Thick3: AppFlowyPrimitiveTokens.subtleColorRose600, color2Light1: AppFlowyPrimitiveTokens.subtleColorPapaya100, color2Light2: AppFlowyPrimitiveTokens.subtleColorPapaya200, color2Light3: AppFlowyPrimitiveTokens.subtleColorPapaya300, color2Thick1: AppFlowyPrimitiveTokens.subtleColorPapaya400, color2Thick2: AppFlowyPrimitiveTokens.subtleColorPapaya500, color2Thick3: AppFlowyPrimitiveTokens.subtleColorPapaya600, color3Light1: AppFlowyPrimitiveTokens.subtleColorTangerine100, color3Light2: AppFlowyPrimitiveTokens.subtleColorTangerine200, color3Light3: AppFlowyPrimitiveTokens.subtleColorTangerine300, color3Thick1: AppFlowyPrimitiveTokens.subtleColorTangerine400, color3Thick2: AppFlowyPrimitiveTokens.subtleColorTangerine500, color3Thick3: AppFlowyPrimitiveTokens.subtleColorTangerine600, color4Light1: AppFlowyPrimitiveTokens.subtleColorMango100, color4Light2: AppFlowyPrimitiveTokens.subtleColorMango200, color4Light3: AppFlowyPrimitiveTokens.subtleColorMango300, color4Thick1: AppFlowyPrimitiveTokens.subtleColorMango400, color4Thick2: AppFlowyPrimitiveTokens.subtleColorMango500, color4Thick3: AppFlowyPrimitiveTokens.subtleColorMango600, color5Light1: AppFlowyPrimitiveTokens.subtleColorLemon100, color5Light2: AppFlowyPrimitiveTokens.subtleColorLemon200, color5Light3: AppFlowyPrimitiveTokens.subtleColorLemon300, color5Thick1: AppFlowyPrimitiveTokens.subtleColorLemon400, color5Thick2: AppFlowyPrimitiveTokens.subtleColorLemon500, color5Thick3: AppFlowyPrimitiveTokens.subtleColorLemon600, color6Light1: AppFlowyPrimitiveTokens.subtleColorOlive100, color6Light2: AppFlowyPrimitiveTokens.subtleColorOlive200, color6Light3: AppFlowyPrimitiveTokens.subtleColorOlive300, color6Thick1: AppFlowyPrimitiveTokens.subtleColorOlive400, color6Thick2: AppFlowyPrimitiveTokens.subtleColorOlive500, color6Thick3: AppFlowyPrimitiveTokens.subtleColorOlive600, color7Light1: AppFlowyPrimitiveTokens.subtleColorLime100, color7Light2: AppFlowyPrimitiveTokens.subtleColorLime200, color7Light3: AppFlowyPrimitiveTokens.subtleColorLime300, color7Thick1: AppFlowyPrimitiveTokens.subtleColorLime400, color7Thick2: AppFlowyPrimitiveTokens.subtleColorLime500, color7Thick3: AppFlowyPrimitiveTokens.subtleColorLime600, color8Light1: AppFlowyPrimitiveTokens.subtleColorGrass100, color8Light2: AppFlowyPrimitiveTokens.subtleColorGrass200, color8Light3: AppFlowyPrimitiveTokens.subtleColorGrass300, color8Thick1: AppFlowyPrimitiveTokens.subtleColorGrass400, color8Thick2: AppFlowyPrimitiveTokens.subtleColorGrass500, color8Thick3: AppFlowyPrimitiveTokens.subtleColorGrass600, color9Light1: AppFlowyPrimitiveTokens.subtleColorForest100, color9Light2: AppFlowyPrimitiveTokens.subtleColorForest200, color9Light3: AppFlowyPrimitiveTokens.subtleColorForest300, color9Thick1: AppFlowyPrimitiveTokens.subtleColorForest400, color9Thick2: AppFlowyPrimitiveTokens.subtleColorForest500, color9Thick3: AppFlowyPrimitiveTokens.subtleColorForest600, color10Light1: AppFlowyPrimitiveTokens.subtleColorJade100, color10Light2: AppFlowyPrimitiveTokens.subtleColorJade200, color10Light3: AppFlowyPrimitiveTokens.subtleColorJade300, color10Thick1: AppFlowyPrimitiveTokens.subtleColorJade400, color10Thick2: AppFlowyPrimitiveTokens.subtleColorJade500, color10Thick3: AppFlowyPrimitiveTokens.subtleColorJade600, color11Light1: AppFlowyPrimitiveTokens.subtleColorAqua100, color11Light2: AppFlowyPrimitiveTokens.subtleColorAqua200, color11Light3: AppFlowyPrimitiveTokens.subtleColorAqua300, color11Thick1: AppFlowyPrimitiveTokens.subtleColorAqua400, color11Thick2: AppFlowyPrimitiveTokens.subtleColorAqua500, color11Thick3: AppFlowyPrimitiveTokens.subtleColorAqua600, color12Light1: AppFlowyPrimitiveTokens.subtleColorAzure100, color12Light2: AppFlowyPrimitiveTokens.subtleColorAzure200, color12Light3: AppFlowyPrimitiveTokens.subtleColorAzure300, color12Thick1: AppFlowyPrimitiveTokens.subtleColorAzure400, color12Thick2: AppFlowyPrimitiveTokens.subtleColorAzure500, color12Thick3: AppFlowyPrimitiveTokens.subtleColorAzure600, color13Light1: AppFlowyPrimitiveTokens.subtleColorDenim100, color13Light2: AppFlowyPrimitiveTokens.subtleColorDenim200, color13Light3: AppFlowyPrimitiveTokens.subtleColorDenim300, color13Thick1: AppFlowyPrimitiveTokens.subtleColorDenim400, color13Thick2: AppFlowyPrimitiveTokens.subtleColorDenim500, color13Thick3: AppFlowyPrimitiveTokens.subtleColorDenim600, color14Light1: AppFlowyPrimitiveTokens.subtleColorMauve100, color14Light2: AppFlowyPrimitiveTokens.subtleColorMauve200, color14Light3: AppFlowyPrimitiveTokens.subtleColorMauve300, color14Thick1: AppFlowyPrimitiveTokens.subtleColorMauve400, color14Thick2: AppFlowyPrimitiveTokens.subtleColorMauve500, color14Thick3: AppFlowyPrimitiveTokens.subtleColorMauve600, color15Light1: AppFlowyPrimitiveTokens.subtleColorLavender100, color15Light2: AppFlowyPrimitiveTokens.subtleColorLavender200, color15Light3: AppFlowyPrimitiveTokens.subtleColorLavender300, color15Thick1: AppFlowyPrimitiveTokens.subtleColorLavender400, color15Thick2: AppFlowyPrimitiveTokens.subtleColorLavender500, color15Thick3: AppFlowyPrimitiveTokens.subtleColorLavender600, color16Light1: AppFlowyPrimitiveTokens.subtleColorLilac100, color16Light2: AppFlowyPrimitiveTokens.subtleColorLilac200, color16Light3: AppFlowyPrimitiveTokens.subtleColorLilac300, color16Thick1: AppFlowyPrimitiveTokens.subtleColorLilac400, color16Thick2: AppFlowyPrimitiveTokens.subtleColorLilac500, color16Thick3: AppFlowyPrimitiveTokens.subtleColorLilac600, color17Light1: AppFlowyPrimitiveTokens.subtleColorMallow100, color17Light2: AppFlowyPrimitiveTokens.subtleColorMallow200, color17Light3: AppFlowyPrimitiveTokens.subtleColorMallow300, color17Thick1: AppFlowyPrimitiveTokens.subtleColorMallow400, color17Thick2: AppFlowyPrimitiveTokens.subtleColorMallow500, color17Thick3: AppFlowyPrimitiveTokens.subtleColorMallow600, color18Light1: AppFlowyPrimitiveTokens.subtleColorCamellia100, color18Light2: AppFlowyPrimitiveTokens.subtleColorCamellia200, color18Light3: AppFlowyPrimitiveTokens.subtleColorCamellia300, color18Thick1: AppFlowyPrimitiveTokens.subtleColorCamellia400, color18Thick2: AppFlowyPrimitiveTokens.subtleColorCamellia500, color18Thick3: AppFlowyPrimitiveTokens.subtleColorCamellia600, color19Light1: AppFlowyPrimitiveTokens.subtleColorSmoke100, color19Light2: AppFlowyPrimitiveTokens.subtleColorSmoke200, color19Light3: AppFlowyPrimitiveTokens.subtleColorSmoke300, color19Thick1: AppFlowyPrimitiveTokens.subtleColorSmoke400, color19Thick2: AppFlowyPrimitiveTokens.subtleColorSmoke500, color19Thick3: AppFlowyPrimitiveTokens.subtleColorSmoke600, color20Light1: AppFlowyPrimitiveTokens.subtleColorIron100, color20Light2: AppFlowyPrimitiveTokens.subtleColorIron200, color20Light3: AppFlowyPrimitiveTokens.subtleColorIron300, color20Thick1: AppFlowyPrimitiveTokens.subtleColorIron400, color20Thick2: AppFlowyPrimitiveTokens.subtleColorIron500, color20Thick3: AppFlowyPrimitiveTokens.subtleColorIron600, ); final brandColorScheme = AppFlowyBrandColorScheme( skyline: Color(0xFF00B5FF), aqua: Color(0xFF00C8FF), violet: Color(0xFF9327FF), amethyst: Color(0xFF8427E0), berry: Color(0xFFE3006D), coral: Color(0xFFFB006D), golden: Color(0xFFF7931E), amber: Color(0xFFFFBD00), lemon: Color(0xFFFFCE00), ); final otherColorsColorScheme = AppFlowyOtherColorsColorScheme( textHighlight: AppFlowyPrimitiveTokens.blue200, ); return AppFlowyThemeData( textStyle: textStyle, textColorScheme: textColorScheme, borderColorScheme: borderColorScheme, fillColorScheme: fillColorScheme, surfaceColorScheme: surfaceColorScheme, backgroundColorScheme: backgroundColorScheme, iconColorScheme: iconColorScheme, brandColorScheme: brandColorScheme, otherColorsColorScheme: otherColorsColorScheme, borderRadius: borderRadius, surfaceContainerColorScheme: surfaceContainerColorScheme, badgeColorScheme: badgeColorScheme, spacing: spacing, shadow: shadow, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/built_in_themes.dart ================================================ export 'appflowy_default/semantic.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/custom/custom_theme.dart ================================================ import 'package:appflowy_ui/appflowy_ui.dart'; class CustomTheme implements AppFlowyThemeBuilder { const CustomTheme({ required this.lightThemeJson, required this.darkThemeJson, }); final Map lightThemeJson; final Map darkThemeJson; @override AppFlowyThemeData light({ String? fontFamily, }) { throw UnimplementedError(); } @override AppFlowyThemeData dark({ String? fontFamily, }) { throw UnimplementedError(); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/shared.dart ================================================ import 'package:appflowy_ui/src/theme/definition/border_radius/border_radius.dart'; import 'package:appflowy_ui/src/theme/definition/shadow/shadow.dart'; import 'package:appflowy_ui/src/theme/definition/spacing/spacing.dart'; import 'package:flutter/material.dart'; class AppFlowySpacingConstant { static const double spacing100 = 4; static const double spacing200 = 6; static const double spacing300 = 8; static const double spacing400 = 12; static const double spacing500 = 16; static const double spacing600 = 20; } class AppFlowyBorderRadiusConstant { static const double radius100 = 4; static const double radius200 = 6; static const double radius300 = 8; static const double radius400 = 12; static const double radius500 = 16; static const double radius600 = 20; } class AppFlowySharedTokens { const AppFlowySharedTokens(); static AppFlowyBorderRadius buildBorderRadius() { return AppFlowyBorderRadius( xs: AppFlowyBorderRadiusConstant.radius100, s: AppFlowyBorderRadiusConstant.radius200, m: AppFlowyBorderRadiusConstant.radius300, l: AppFlowyBorderRadiusConstant.radius400, xl: AppFlowyBorderRadiusConstant.radius500, xxl: AppFlowyBorderRadiusConstant.radius600, ); } static AppFlowySpacing buildSpacing() { return AppFlowySpacing( xs: AppFlowySpacingConstant.spacing100, s: AppFlowySpacingConstant.spacing200, m: AppFlowySpacingConstant.spacing300, l: AppFlowySpacingConstant.spacing400, xl: AppFlowySpacingConstant.spacing500, xxl: AppFlowySpacingConstant.spacing600, ); } static AppFlowyShadow buildShadow( Brightness brightness, ) { return switch (brightness) { Brightness.light => AppFlowyShadow( small: [ BoxShadow( offset: Offset(0, 2), blurRadius: 16, color: Color(0x1F000000), ), ], medium: [ BoxShadow( offset: Offset(0, 4), blurRadius: 32, color: Color(0x1F000000), ), ], ), Brightness.dark => AppFlowyShadow( small: [ BoxShadow( offset: Offset(0, 2), blurRadius: 16, color: Color(0x7A000000), ), ], medium: [ BoxShadow( offset: Offset(0, 4), blurRadius: 32, color: Color(0x7A000000), ), ], ), }; } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/border_radius/border_radius.dart ================================================ class AppFlowyBorderRadius { const AppFlowyBorderRadius({ required this.xs, required this.s, required this.m, required this.l, required this.xl, required this.xxl, }); final double xs; final double s; final double m; final double l; final double xl; final double xxl; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/background_color_scheme.dart ================================================ import 'package:flutter/material.dart'; class AppFlowyBackgroundColorScheme { const AppFlowyBackgroundColorScheme({ required this.primary, }); final Color primary; AppFlowyBackgroundColorScheme lerp( AppFlowyBackgroundColorScheme other, double t, ) { return AppFlowyBackgroundColorScheme( primary: Color.lerp( primary, other.primary, t, )!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/badge_color_scheme.dart ================================================ import 'package:flutter/material.dart'; class AppFlowyBadgeColorScheme { AppFlowyBadgeColorScheme({ required this.color1Light1, required this.color1Light2, required this.color1Light3, required this.color1Thick1, required this.color1Thick2, required this.color1Thick3, required this.color2Light1, required this.color2Light2, required this.color2Light3, required this.color2Thick1, required this.color2Thick2, required this.color2Thick3, required this.color3Light1, required this.color3Light2, required this.color3Light3, required this.color3Thick1, required this.color3Thick2, required this.color3Thick3, required this.color4Light1, required this.color4Light2, required this.color4Light3, required this.color4Thick1, required this.color4Thick2, required this.color4Thick3, required this.color5Light1, required this.color5Light2, required this.color5Light3, required this.color5Thick1, required this.color5Thick2, required this.color5Thick3, required this.color6Light1, required this.color6Light2, required this.color6Light3, required this.color6Thick1, required this.color6Thick2, required this.color6Thick3, required this.color7Light1, required this.color7Light2, required this.color7Light3, required this.color7Thick1, required this.color7Thick2, required this.color7Thick3, required this.color8Light1, required this.color8Light2, required this.color8Light3, required this.color8Thick1, required this.color8Thick2, required this.color8Thick3, required this.color9Light1, required this.color9Light2, required this.color9Light3, required this.color9Thick1, required this.color9Thick2, required this.color9Thick3, required this.color10Light1, required this.color10Light2, required this.color10Light3, required this.color10Thick1, required this.color10Thick2, required this.color10Thick3, required this.color11Light1, required this.color11Light2, required this.color11Light3, required this.color11Thick1, required this.color11Thick2, required this.color11Thick3, required this.color12Light1, required this.color12Light2, required this.color12Light3, required this.color12Thick1, required this.color12Thick2, required this.color12Thick3, required this.color13Light1, required this.color13Light2, required this.color13Light3, required this.color13Thick1, required this.color13Thick2, required this.color13Thick3, required this.color14Light1, required this.color14Light2, required this.color14Light3, required this.color14Thick1, required this.color14Thick2, required this.color14Thick3, required this.color15Light1, required this.color15Light2, required this.color15Light3, required this.color15Thick1, required this.color15Thick2, required this.color15Thick3, required this.color16Light1, required this.color16Light2, required this.color16Light3, required this.color16Thick1, required this.color16Thick2, required this.color16Thick3, required this.color17Light1, required this.color17Light2, required this.color17Light3, required this.color17Thick1, required this.color17Thick2, required this.color17Thick3, required this.color18Light1, required this.color18Light2, required this.color18Light3, required this.color18Thick1, required this.color18Thick2, required this.color18Thick3, required this.color19Light1, required this.color19Light2, required this.color19Light3, required this.color19Thick1, required this.color19Thick2, required this.color19Thick3, required this.color20Light1, required this.color20Light2, required this.color20Light3, required this.color20Thick1, required this.color20Thick2, required this.color20Thick3, }); final Color color1Light1; final Color color1Light2; final Color color1Light3; final Color color1Thick1; final Color color1Thick2; final Color color1Thick3; final Color color2Light1; final Color color2Light2; final Color color2Light3; final Color color2Thick1; final Color color2Thick2; final Color color2Thick3; final Color color3Light1; final Color color3Light2; final Color color3Light3; final Color color3Thick1; final Color color3Thick2; final Color color3Thick3; final Color color4Light1; final Color color4Light2; final Color color4Light3; final Color color4Thick1; final Color color4Thick2; final Color color4Thick3; final Color color5Light1; final Color color5Light2; final Color color5Light3; final Color color5Thick1; final Color color5Thick2; final Color color5Thick3; final Color color6Light1; final Color color6Light2; final Color color6Light3; final Color color6Thick1; final Color color6Thick2; final Color color6Thick3; final Color color7Light1; final Color color7Light2; final Color color7Light3; final Color color7Thick1; final Color color7Thick2; final Color color7Thick3; final Color color8Light1; final Color color8Light2; final Color color8Light3; final Color color8Thick1; final Color color8Thick2; final Color color8Thick3; final Color color9Light1; final Color color9Light2; final Color color9Light3; final Color color9Thick1; final Color color9Thick2; final Color color9Thick3; final Color color10Light1; final Color color10Light2; final Color color10Light3; final Color color10Thick1; final Color color10Thick2; final Color color10Thick3; final Color color11Light1; final Color color11Light2; final Color color11Light3; final Color color11Thick1; final Color color11Thick2; final Color color11Thick3; final Color color12Light1; final Color color12Light2; final Color color12Light3; final Color color12Thick1; final Color color12Thick2; final Color color12Thick3; final Color color13Light1; final Color color13Light2; final Color color13Light3; final Color color13Thick1; final Color color13Thick2; final Color color13Thick3; final Color color14Light1; final Color color14Light2; final Color color14Light3; final Color color14Thick1; final Color color14Thick2; final Color color14Thick3; final Color color15Light1; final Color color15Light2; final Color color15Light3; final Color color15Thick1; final Color color15Thick2; final Color color15Thick3; final Color color16Light1; final Color color16Light2; final Color color16Light3; final Color color16Thick1; final Color color16Thick2; final Color color16Thick3; final Color color17Light1; final Color color17Light2; final Color color17Light3; final Color color17Thick1; final Color color17Thick2; final Color color17Thick3; final Color color18Light1; final Color color18Light2; final Color color18Light3; final Color color18Thick1; final Color color18Thick2; final Color color18Thick3; final Color color19Light1; final Color color19Light2; final Color color19Light3; final Color color19Thick1; final Color color19Thick2; final Color color19Thick3; final Color color20Light1; final Color color20Light2; final Color color20Light3; final Color color20Thick1; final Color color20Thick2; final Color color20Thick3; AppFlowyBadgeColorScheme lerp( AppFlowyBadgeColorScheme other, double t, ) { return AppFlowyBadgeColorScheme( color1Light1: Color.lerp( color1Light1, other.color1Light1, t, )!, color1Light2: Color.lerp( color1Light2, other.color1Light2, t, )!, color1Light3: Color.lerp( color1Light3, other.color1Light3, t, )!, color1Thick1: Color.lerp( color1Thick1, other.color1Thick1, t, )!, color1Thick2: Color.lerp( color1Thick2, other.color1Thick2, t, )!, color1Thick3: Color.lerp( color1Thick3, other.color1Thick3, t, )!, color2Light1: Color.lerp( color2Light1, other.color2Light1, t, )!, color2Light2: Color.lerp( color2Light2, other.color2Light2, t, )!, color2Light3: Color.lerp( color2Light3, other.color2Light3, t, )!, color2Thick1: Color.lerp( color2Thick1, other.color2Thick1, t, )!, color2Thick2: Color.lerp( color2Thick2, other.color2Thick2, t, )!, color2Thick3: Color.lerp( color2Thick3, other.color2Thick3, t, )!, color3Light1: Color.lerp( color3Light1, other.color3Light1, t, )!, color3Light2: Color.lerp( color3Light2, other.color3Light2, t, )!, color3Light3: Color.lerp( color3Light3, other.color3Light3, t, )!, color3Thick1: Color.lerp( color3Thick1, other.color3Thick1, t, )!, color3Thick2: Color.lerp( color3Thick2, other.color3Thick2, t, )!, color3Thick3: Color.lerp( color3Thick3, other.color3Thick3, t, )!, color4Light1: Color.lerp( color4Light1, other.color4Light1, t, )!, color4Light2: Color.lerp( color4Light2, other.color4Light2, t, )!, color4Light3: Color.lerp( color4Light3, other.color4Light3, t, )!, color4Thick1: Color.lerp( color4Thick1, other.color4Thick1, t, )!, color4Thick2: Color.lerp( color4Thick2, other.color4Thick2, t, )!, color4Thick3: Color.lerp( color4Thick3, other.color4Thick3, t, )!, color5Light1: Color.lerp( color5Light1, other.color5Light1, t, )!, color5Light2: Color.lerp( color5Light2, other.color5Light2, t, )!, color5Light3: Color.lerp( color5Light3, other.color5Light3, t, )!, color5Thick1: Color.lerp( color5Thick1, other.color5Thick1, t, )!, color5Thick2: Color.lerp( color5Thick2, other.color5Thick2, t, )!, color5Thick3: Color.lerp( color5Thick3, other.color5Thick3, t, )!, color6Light1: Color.lerp( color6Light1, other.color6Light1, t, )!, color6Light2: Color.lerp( color6Light2, other.color6Light2, t, )!, color6Light3: Color.lerp( color6Light3, other.color6Light3, t, )!, color6Thick1: Color.lerp( color6Thick1, other.color6Thick1, t, )!, color6Thick2: Color.lerp( color6Thick2, other.color6Thick2, t, )!, color6Thick3: Color.lerp( color6Thick3, other.color6Thick3, t, )!, color7Light1: Color.lerp( color7Light1, other.color7Light1, t, )!, color7Light2: Color.lerp( color7Light2, other.color7Light2, t, )!, color7Light3: Color.lerp( color7Light3, other.color7Light3, t, )!, color7Thick1: Color.lerp( color7Thick1, other.color7Thick1, t, )!, color7Thick2: Color.lerp( color7Thick2, other.color7Thick2, t, )!, color7Thick3: Color.lerp( color7Thick3, other.color7Thick3, t, )!, color8Light1: Color.lerp( color8Light1, other.color8Light1, t, )!, color8Light2: Color.lerp( color8Light2, other.color8Light2, t, )!, color8Light3: Color.lerp( color8Light3, other.color8Light3, t, )!, color8Thick1: Color.lerp( color8Thick1, other.color8Thick1, t, )!, color8Thick2: Color.lerp( color8Thick2, other.color8Thick2, t, )!, color8Thick3: Color.lerp( color8Thick3, other.color8Thick3, t, )!, color9Light1: Color.lerp( color9Light1, other.color9Light1, t, )!, color9Light2: Color.lerp( color9Light2, other.color9Light2, t, )!, color9Light3: Color.lerp( color9Light3, other.color9Light3, t, )!, color9Thick1: Color.lerp( color9Thick1, other.color9Thick1, t, )!, color9Thick2: Color.lerp( color9Thick2, other.color9Thick2, t, )!, color9Thick3: Color.lerp( color9Thick3, other.color9Thick3, t, )!, color10Light1: Color.lerp( color10Light1, other.color10Light1, t, )!, color10Light2: Color.lerp( color10Light2, other.color10Light2, t, )!, color10Light3: Color.lerp( color10Light3, other.color10Light3, t, )!, color10Thick1: Color.lerp( color10Thick1, other.color10Thick1, t, )!, color10Thick2: Color.lerp( color10Thick2, other.color10Thick2, t, )!, color10Thick3: Color.lerp( color10Thick3, other.color10Thick3, t, )!, color11Light1: Color.lerp( color11Light1, other.color11Light1, t, )!, color11Light2: Color.lerp( color11Light2, other.color11Light2, t, )!, color11Light3: Color.lerp( color11Light3, other.color11Light3, t, )!, color11Thick1: Color.lerp( color11Thick1, other.color11Thick1, t, )!, color11Thick2: Color.lerp( color11Thick2, other.color11Thick2, t, )!, color11Thick3: Color.lerp( color11Thick3, other.color11Thick3, t, )!, color12Light1: Color.lerp( color12Light1, other.color12Light1, t, )!, color12Light2: Color.lerp( color12Light2, other.color12Light2, t, )!, color12Light3: Color.lerp( color12Light3, other.color12Light3, t, )!, color12Thick1: Color.lerp( color12Thick1, other.color12Thick1, t, )!, color12Thick2: Color.lerp( color12Thick2, other.color12Thick2, t, )!, color12Thick3: Color.lerp( color12Thick3, other.color12Thick3, t, )!, color13Light1: Color.lerp( color13Light1, other.color13Light1, t, )!, color13Light2: Color.lerp( color13Light2, other.color13Light2, t, )!, color13Light3: Color.lerp( color13Light3, other.color13Light3, t, )!, color13Thick1: Color.lerp( color13Thick1, other.color13Thick1, t, )!, color13Thick2: Color.lerp( color13Thick2, other.color13Thick2, t, )!, color13Thick3: Color.lerp( color13Thick3, other.color13Thick3, t, )!, color14Light1: Color.lerp( color14Light1, other.color14Light1, t, )!, color14Light2: Color.lerp( color14Light2, other.color14Light2, t, )!, color14Light3: Color.lerp( color14Light3, other.color14Light3, t, )!, color14Thick1: Color.lerp( color14Thick1, other.color14Thick1, t, )!, color14Thick2: Color.lerp( color14Thick2, other.color14Thick2, t, )!, color14Thick3: Color.lerp( color14Thick3, other.color14Thick3, t, )!, color15Light1: Color.lerp( color15Light1, other.color15Light1, t, )!, color15Light2: Color.lerp( color15Light2, other.color15Light2, t, )!, color15Light3: Color.lerp( color15Light3, other.color15Light3, t, )!, color15Thick1: Color.lerp( color15Thick1, other.color15Thick1, t, )!, color15Thick2: Color.lerp( color15Thick2, other.color15Thick2, t, )!, color15Thick3: Color.lerp( color15Thick3, other.color15Thick3, t, )!, color16Light1: Color.lerp( color16Light1, other.color16Light1, t, )!, color16Light2: Color.lerp( color16Light2, other.color16Light2, t, )!, color16Light3: Color.lerp( color16Light3, other.color16Light3, t, )!, color16Thick1: Color.lerp( color16Thick1, other.color16Thick1, t, )!, color16Thick2: Color.lerp( color16Thick2, other.color16Thick2, t, )!, color16Thick3: Color.lerp( color16Thick3, other.color16Thick3, t, )!, color17Light1: Color.lerp( color17Light1, other.color17Light1, t, )!, color17Light2: Color.lerp( color17Light2, other.color17Light2, t, )!, color17Light3: Color.lerp( color17Light3, other.color17Light3, t, )!, color17Thick1: Color.lerp( color17Thick1, other.color17Thick1, t, )!, color17Thick2: Color.lerp( color17Thick2, other.color17Thick2, t, )!, color17Thick3: Color.lerp( color17Thick3, other.color17Thick3, t, )!, color18Light1: Color.lerp( color18Light1, other.color18Light1, t, )!, color18Light2: Color.lerp( color18Light2, other.color18Light2, t, )!, color18Light3: Color.lerp( color18Light3, other.color18Light3, t, )!, color18Thick1: Color.lerp( color18Thick1, other.color18Thick1, t, )!, color18Thick2: Color.lerp( color18Thick2, other.color18Thick2, t, )!, color18Thick3: Color.lerp( color18Thick3, other.color18Thick3, t, )!, color19Light1: Color.lerp( color19Light1, other.color19Light1, t, )!, color19Light2: Color.lerp( color19Light2, other.color19Light2, t, )!, color19Light3: Color.lerp( color19Light3, other.color19Light3, t, )!, color19Thick1: Color.lerp( color19Thick1, other.color19Thick1, t, )!, color19Thick2: Color.lerp( color19Thick2, other.color19Thick2, t, )!, color19Thick3: Color.lerp( color19Thick3, other.color19Thick3, t, )!, color20Light1: Color.lerp( color20Light1, other.color20Light1, t, )!, color20Light2: Color.lerp( color20Light2, other.color20Light2, t, )!, color20Light3: Color.lerp( color20Light3, other.color20Light3, t, )!, color20Thick1: Color.lerp( color20Thick1, other.color20Thick1, t, )!, color20Thick2: Color.lerp( color20Thick2, other.color20Thick2, t, )!, color20Thick3: Color.lerp( color20Thick3, other.color20Thick3, t, )!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/border_color_scheme.dart ================================================ import 'package:flutter/material.dart'; class AppFlowyBorderColorScheme { AppFlowyBorderColorScheme({ required this.primary, required this.primaryHover, required this.secondary, required this.secondaryHover, required this.tertiary, required this.tertiaryHover, required this.themeThick, required this.themeThickHover, required this.infoThick, required this.infoThickHover, required this.successThick, required this.successThickHover, required this.warningThick, required this.warningThickHover, required this.errorThick, required this.errorThickHover, required this.featuredThick, required this.featuredThickHover, }); final Color primary; final Color primaryHover; final Color secondary; final Color secondaryHover; final Color tertiary; final Color tertiaryHover; final Color themeThick; final Color themeThickHover; final Color infoThick; final Color infoThickHover; final Color successThick; final Color successThickHover; final Color warningThick; final Color warningThickHover; final Color errorThick; final Color errorThickHover; final Color featuredThick; final Color featuredThickHover; AppFlowyBorderColorScheme lerp( AppFlowyBorderColorScheme other, double t, ) { return AppFlowyBorderColorScheme( primary: Color.lerp( primary, other.primary, t, )!, primaryHover: Color.lerp( primaryHover, other.primaryHover, t, )!, secondary: Color.lerp( secondary, other.secondary, t, )!, secondaryHover: Color.lerp( secondaryHover, other.secondaryHover, t, )!, tertiary: Color.lerp( tertiary, other.tertiary, t, )!, tertiaryHover: Color.lerp( tertiaryHover, other.tertiaryHover, t, )!, themeThick: Color.lerp( themeThick, other.themeThick, t, )!, themeThickHover: Color.lerp( themeThickHover, other.themeThickHover, t, )!, infoThick: Color.lerp( infoThick, other.infoThick, t, )!, infoThickHover: Color.lerp( infoThickHover, other.infoThickHover, t, )!, successThick: Color.lerp( successThick, other.successThick, t, )!, successThickHover: Color.lerp( successThickHover, other.successThickHover, t, )!, warningThick: Color.lerp( warningThick, other.warningThick, t, )!, warningThickHover: Color.lerp( warningThickHover, other.warningThickHover, t, )!, errorThick: Color.lerp( errorThick, other.errorThick, t, )!, errorThickHover: Color.lerp( errorThickHover, other.errorThickHover, t, )!, featuredThick: Color.lerp( featuredThick, other.featuredThick, t, )!, featuredThickHover: Color.lerp( featuredThickHover, other.featuredThickHover, t, )!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/brand_color_scheme.dart ================================================ import 'package:flutter/material.dart'; class AppFlowyBrandColorScheme { const AppFlowyBrandColorScheme({ required this.skyline, required this.aqua, required this.violet, required this.amethyst, required this.berry, required this.coral, required this.golden, required this.amber, required this.lemon, }); final Color skyline; final Color aqua; final Color violet; final Color amethyst; final Color berry; final Color coral; final Color golden; final Color amber; final Color lemon; AppFlowyBrandColorScheme lerp( AppFlowyBrandColorScheme other, double t, ) { return AppFlowyBrandColorScheme( skyline: Color.lerp(skyline, other.skyline, t)!, aqua: Color.lerp(aqua, other.aqua, t)!, violet: Color.lerp(violet, other.violet, t)!, amethyst: Color.lerp(amethyst, other.amethyst, t)!, berry: Color.lerp(berry, other.berry, t)!, coral: Color.lerp(coral, other.coral, t)!, golden: Color.lerp(golden, other.golden, t)!, amber: Color.lerp(amber, other.amber, t)!, lemon: Color.lerp(lemon, other.lemon, t)!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/color_scheme.dart ================================================ export 'background_color_scheme.dart'; export 'badge_color_scheme.dart'; export 'border_color_scheme.dart'; export 'brand_color_scheme.dart'; export 'fill_color_scheme.dart'; export 'icon_color_scheme.dart'; export 'other_color_scheme.dart'; export 'surface_color_scheme.dart'; export 'surface_container_color_scheme.dart'; export 'text_color_scheme.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/fill_color_scheme.dart ================================================ import 'package:flutter/material.dart'; class AppFlowyFillColorScheme { const AppFlowyFillColorScheme({ required this.primary, required this.primaryHover, required this.secondary, required this.secondaryHover, required this.tertiary, required this.tertiaryHover, required this.quaternary, required this.quaternaryHover, required this.content, required this.contentHover, required this.contentVisible, required this.contentVisibleHover, required this.themeThick, required this.themeThickHover, required this.themeSelect, required this.textSelect, required this.infoLight, required this.infoLightHover, required this.infoThick, required this.infoThickHover, required this.successLight, required this.successLightHover, required this.warningLight, required this.warningLightHover, required this.errorLight, required this.errorLightHover, required this.errorThick, required this.errorThickHover, required this.errorSelect, required this.featuredLight, required this.featuredLightHover, required this.featuredThick, required this.featuredThickHover, }); final Color primary; final Color primaryHover; final Color secondary; final Color secondaryHover; final Color tertiary; final Color tertiaryHover; final Color quaternary; final Color quaternaryHover; final Color content; final Color contentHover; final Color contentVisible; final Color contentVisibleHover; final Color themeThick; final Color themeThickHover; final Color themeSelect; final Color textSelect; final Color infoLight; final Color infoLightHover; final Color infoThick; final Color infoThickHover; final Color successLight; final Color successLightHover; final Color warningLight; final Color warningLightHover; final Color errorLight; final Color errorLightHover; final Color errorThick; final Color errorThickHover; final Color errorSelect; final Color featuredLight; final Color featuredLightHover; final Color featuredThick; final Color featuredThickHover; AppFlowyFillColorScheme lerp( AppFlowyFillColorScheme other, double t, ) { return AppFlowyFillColorScheme( primary: Color.lerp( primary, other.primary, t, )!, primaryHover: Color.lerp( primaryHover, other.primaryHover, t, )!, secondary: Color.lerp( secondary, other.secondary, t, )!, secondaryHover: Color.lerp( secondaryHover, other.secondaryHover, t, )!, tertiary: Color.lerp( tertiary, other.tertiary, t, )!, tertiaryHover: Color.lerp( tertiaryHover, other.tertiaryHover, t, )!, quaternary: Color.lerp( quaternary, other.quaternary, t, )!, quaternaryHover: Color.lerp( quaternaryHover, other.quaternaryHover, t, )!, content: Color.lerp( content, other.content, t, )!, contentHover: Color.lerp( contentHover, other.contentHover, t, )!, contentVisible: Color.lerp( contentVisible, other.contentVisible, t, )!, contentVisibleHover: Color.lerp( contentVisibleHover, other.contentVisibleHover, t, )!, themeThick: Color.lerp( themeThick, other.themeThick, t, )!, themeThickHover: Color.lerp( themeThickHover, other.themeThickHover, t, )!, themeSelect: Color.lerp( themeSelect, other.themeSelect, t, )!, textSelect: Color.lerp( textSelect, other.textSelect, t, )!, infoLight: Color.lerp( infoLight, other.infoLight, t, )!, infoLightHover: Color.lerp( infoLightHover, other.infoLightHover, t, )!, infoThick: Color.lerp( infoThick, other.infoThick, t, )!, infoThickHover: Color.lerp( infoThickHover, other.infoThickHover, t, )!, successLight: Color.lerp( successLight, other.successLight, t, )!, successLightHover: Color.lerp( successLightHover, other.successLightHover, t, )!, warningLight: Color.lerp( warningLight, other.warningLight, t, )!, warningLightHover: Color.lerp( warningLightHover, other.warningLightHover, t, )!, errorLight: Color.lerp( errorLight, other.errorLight, t, )!, errorLightHover: Color.lerp( errorLightHover, other.errorLightHover, t, )!, errorThick: Color.lerp( errorThick, other.errorThick, t, )!, errorThickHover: Color.lerp( errorThickHover, other.errorThickHover, t, )!, errorSelect: Color.lerp( errorSelect, other.errorSelect, t, )!, featuredLight: Color.lerp( featuredLight, other.featuredLight, t, )!, featuredLightHover: Color.lerp( featuredLightHover, other.featuredLightHover, t, )!, featuredThick: Color.lerp( featuredThick, other.featuredThick, t, )!, featuredThickHover: Color.lerp( featuredThickHover, other.featuredThickHover, t, )!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/icon_color_scheme.dart ================================================ import 'package:flutter/material.dart'; class AppFlowyIconColorScheme { const AppFlowyIconColorScheme({ required this.primary, required this.secondary, required this.tertiary, required this.quaternary, required this.onFill, required this.featuredThick, required this.featuredThickHover, required this.infoThick, required this.infoThickHover, required this.successThick, required this.successThickHover, required this.warningThick, required this.warningThickHover, required this.errorThick, required this.errorThickHover, }); final Color primary; final Color secondary; final Color tertiary; final Color quaternary; final Color onFill; final Color featuredThick; final Color featuredThickHover; final Color infoThick; final Color infoThickHover; final Color successThick; final Color successThickHover; final Color warningThick; final Color warningThickHover; final Color errorThick; final Color errorThickHover; AppFlowyIconColorScheme lerp( AppFlowyIconColorScheme other, double t, ) { return AppFlowyIconColorScheme( primary: Color.lerp( primary, other.primary, t, )!, secondary: Color.lerp( secondary, other.secondary, t, )!, tertiary: Color.lerp( tertiary, other.tertiary, t, )!, quaternary: Color.lerp( quaternary, other.quaternary, t, )!, onFill: Color.lerp( onFill, other.onFill, t, )!, featuredThick: Color.lerp( featuredThick, other.featuredThick, t, )!, featuredThickHover: Color.lerp( featuredThickHover, other.featuredThickHover, t, )!, infoThick: Color.lerp( infoThick, other.infoThick, t, )!, infoThickHover: Color.lerp( infoThickHover, other.infoThickHover, t, )!, successThick: Color.lerp( successThick, other.successThick, t, )!, successThickHover: Color.lerp( successThickHover, other.successThickHover, t, )!, warningThick: Color.lerp( warningThick, other.warningThick, t, )!, warningThickHover: Color.lerp( warningThickHover, other.warningThickHover, t, )!, errorThick: Color.lerp( errorThick, other.errorThick, t, )!, errorThickHover: Color.lerp( errorThickHover, other.errorThickHover, t, )!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/other_color_scheme.dart ================================================ import 'dart:ui'; class AppFlowyOtherColorsColorScheme { const AppFlowyOtherColorsColorScheme({ required this.textHighlight, }); final Color textHighlight; AppFlowyOtherColorsColorScheme lerp( AppFlowyOtherColorsColorScheme other, double t, ) { return AppFlowyOtherColorsColorScheme( textHighlight: Color.lerp(textHighlight, other.textHighlight, t)!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_color_scheme.dart ================================================ import 'package:flutter/material.dart'; class AppFlowySurfaceColorScheme { const AppFlowySurfaceColorScheme({ required this.primary, required this.primaryHover, required this.layer01, required this.layer01Hover, required this.layer02, required this.layer02Hover, required this.layer03, required this.layer03Hover, required this.layer04, required this.layer04Hover, required this.inverse, required this.secondary, required this.overlay, }); final Color primary; final Color primaryHover; final Color layer01; final Color layer01Hover; final Color layer02; final Color layer02Hover; final Color layer03; final Color layer03Hover; final Color layer04; final Color layer04Hover; final Color inverse; final Color secondary; final Color overlay; AppFlowySurfaceColorScheme lerp( AppFlowySurfaceColorScheme other, double t, ) { return AppFlowySurfaceColorScheme( primary: Color.lerp( primary, other.primary, t, )!, primaryHover: Color.lerp( primaryHover, other.primaryHover, t, )!, layer01: Color.lerp( layer01, other.layer01, t, )!, layer01Hover: Color.lerp( layer01Hover, other.layer01Hover, t, )!, layer02: Color.lerp( layer02, other.layer02, t, )!, layer02Hover: Color.lerp( layer02Hover, other.layer02Hover, t, )!, layer03: Color.lerp( layer03, other.layer03, t, )!, layer03Hover: Color.lerp( layer03Hover, other.layer03Hover, t, )!, layer04: Color.lerp( layer04, other.layer04, t, )!, layer04Hover: Color.lerp( layer04Hover, other.layer04Hover, t, )!, inverse: Color.lerp( inverse, other.inverse, t, )!, secondary: Color.lerp( secondary, other.secondary, t, )!, overlay: Color.lerp( overlay, other.overlay, t, )!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/surface_container_color_scheme.dart ================================================ import 'package:flutter/material.dart'; class AppFlowySurfaceContainerColorScheme { const AppFlowySurfaceContainerColorScheme({ required this.layer01, required this.layer02, required this.layer03, }); final Color layer01; final Color layer02; final Color layer03; AppFlowySurfaceContainerColorScheme lerp( AppFlowySurfaceContainerColorScheme other, double t, ) { return AppFlowySurfaceContainerColorScheme( layer01: Color.lerp( layer01, other.layer01, t, )!, layer02: Color.lerp( layer02, other.layer02, t, )!, layer03: Color.lerp( layer03, other.layer03, t, )!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/color_scheme/text_color_scheme.dart ================================================ import 'package:flutter/material.dart'; class AppFlowyTextColorScheme { const AppFlowyTextColorScheme({ required this.primary, required this.secondary, required this.tertiary, required this.quaternary, required this.onFill, required this.action, required this.actionHover, required this.info, required this.infoHover, required this.success, required this.successHover, required this.warning, required this.warningHover, required this.error, required this.errorHover, required this.featured, required this.featuredHover, }); final Color primary; final Color secondary; final Color tertiary; final Color quaternary; final Color onFill; final Color action; final Color actionHover; final Color info; final Color infoHover; final Color success; final Color successHover; final Color warning; final Color warningHover; final Color error; final Color errorHover; final Color featured; final Color featuredHover; AppFlowyTextColorScheme lerp( AppFlowyTextColorScheme other, double t, ) { return AppFlowyTextColorScheme( primary: Color.lerp( primary, other.primary, t, )!, secondary: Color.lerp( secondary, other.secondary, t, )!, tertiary: Color.lerp( tertiary, other.tertiary, t, )!, quaternary: Color.lerp( quaternary, other.quaternary, t, )!, onFill: Color.lerp( onFill, other.onFill, t, )!, action: Color.lerp( action, other.action, t, )!, actionHover: Color.lerp( actionHover, other.actionHover, t, )!, info: Color.lerp( info, other.info, t, )!, infoHover: Color.lerp( infoHover, other.infoHover, t, )!, success: Color.lerp( success, other.success, t, )!, successHover: Color.lerp( successHover, other.successHover, t, )!, warning: Color.lerp( warning, other.warning, t, )!, warningHover: Color.lerp( warningHover, other.warningHover, t, )!, error: Color.lerp( error, other.error, t, )!, errorHover: Color.lerp( errorHover, other.errorHover, t, )!, featured: Color.lerp( featured, other.featured, t, )!, featuredHover: Color.lerp( featuredHover, other.featuredHover, t, )!, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/shadow/shadow.dart ================================================ import 'package:flutter/widgets.dart'; class AppFlowyShadow { AppFlowyShadow({ required this.small, required this.medium, }); final List small; final List medium; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/spacing/spacing.dart ================================================ class AppFlowySpacing { const AppFlowySpacing({ required this.xs, required this.s, required this.m, required this.l, required this.xl, required this.xxl, }); final double xs; final double s; final double m; final double l; final double xl; final double xxl; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/base/default_text_style.dart ================================================ import 'package:flutter/widgets.dart'; abstract class TextThemeType { const TextThemeType({ required this.fontFamily, }); final String fontFamily; TextStyle standard({ String? family, Color? color, FontWeight? weight, }); TextStyle enhanced({ String? family, Color? color, FontWeight? weight, }); TextStyle prominent({ String? family, Color? color, FontWeight? weight, }); TextStyle underline({ String? family, Color? color, FontWeight? weight, }); } class TextThemeHeading1 extends TextThemeType { const TextThemeHeading1({ required super.fontFamily, }); @override TextStyle standard({ String? family, Color? color, FontWeight? weight, }) => _defaultTextStyle( family: family ?? super.fontFamily, fontSize: 36, height: 40 / 36, color: color, weight: weight ?? FontWeight.w400, ); @override TextStyle enhanced({ String? family, Color? color, FontWeight? weight, }) => _defaultTextStyle( family: family ?? super.fontFamily, fontSize: 36, height: 40 / 36, color: color, weight: weight ?? FontWeight.w600, ); @override TextStyle prominent({ String? family, Color? color, FontWeight? weight, }) => _defaultTextStyle( family: family ?? super.fontFamily, fontSize: 36, height: 40 / 36, color: color, weight: weight ?? FontWeight.w700, ); @override TextStyle underline({ String? family, Color? color, FontWeight? weight, }) => _defaultTextStyle( family: family ?? super.fontFamily, fontSize: 36, height: 40 / 36, color: color, weight: weight ?? FontWeight.bold, decoration: TextDecoration.underline, ); static TextStyle _defaultTextStyle({ required String family, required double fontSize, required double height, TextDecoration decoration = TextDecoration.none, Color? color, FontWeight weight = FontWeight.bold, }) => TextStyle( inherit: false, fontSize: fontSize, decoration: decoration, fontStyle: FontStyle.normal, fontWeight: weight, height: height, fontFamily: family, color: color, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even, ); } class TextThemeHeading2 extends TextThemeType { const TextThemeHeading2({ required super.fontFamily, }); @override TextStyle standard({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w400, ); @override TextStyle enhanced({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w600, ); @override TextStyle prominent({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w700, ); @override TextStyle underline({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w400, decoration: TextDecoration.underline, ); static TextStyle _defaultTextStyle({ required String family, double fontSize = 24, double height = 32 / 24, TextDecoration decoration = TextDecoration.none, FontWeight weight = FontWeight.w400, Color? color, }) => TextStyle( inherit: false, fontSize: fontSize, decoration: decoration, fontStyle: FontStyle.normal, fontWeight: weight, height: height, fontFamily: family, color: color, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even, ); } class TextThemeHeading3 extends TextThemeType { const TextThemeHeading3({ required super.fontFamily, }); @override TextStyle standard({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w400, ); @override TextStyle enhanced({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w600, ); @override TextStyle prominent({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w700, ); @override TextStyle underline({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w400, decoration: TextDecoration.underline, ); static TextStyle _defaultTextStyle({ required String family, double fontSize = 20, double height = 28 / 20, TextDecoration decoration = TextDecoration.none, FontWeight weight = FontWeight.w400, Color? color, }) => TextStyle( inherit: false, fontSize: fontSize, decoration: decoration, fontStyle: FontStyle.normal, fontWeight: weight, height: height, fontFamily: family, color: color, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even, ); } class TextThemeHeading4 extends TextThemeType { const TextThemeHeading4({ required super.fontFamily, }); @override TextStyle standard({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w400, ); @override TextStyle enhanced({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w600, ); @override TextStyle prominent({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w700, ); @override TextStyle underline({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w400, decoration: TextDecoration.underline, ); static TextStyle _defaultTextStyle({ required String family, double fontSize = 16, double height = 22 / 16, TextDecoration decoration = TextDecoration.none, FontWeight weight = FontWeight.w400, Color? color, }) => TextStyle( inherit: false, fontSize: fontSize, decoration: decoration, fontStyle: FontStyle.normal, fontWeight: weight, height: height, fontFamily: family, color: color, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even, ); } class TextThemeHeadline extends TextThemeType { const TextThemeHeadline({ required super.fontFamily, }); @override TextStyle standard({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.normal, ); @override TextStyle enhanced({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w500, ); @override TextStyle prominent({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.bold, ); @override TextStyle underline({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.normal, decoration: TextDecoration.underline, ); static TextStyle _defaultTextStyle({ required String family, double fontSize = 24, double height = 36 / 24, TextDecoration decoration = TextDecoration.none, FontWeight weight = FontWeight.normal, Color? color, }) => TextStyle( inherit: false, fontSize: fontSize, decoration: decoration, fontStyle: FontStyle.normal, fontWeight: weight, height: height, fontFamily: family, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even, color: color, ); } class TextThemeTitle extends TextThemeType { const TextThemeTitle({ required super.fontFamily, }); @override TextStyle standard({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.normal, ); @override TextStyle enhanced({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w500, ); @override TextStyle prominent({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.bold, ); @override TextStyle underline({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.normal, decoration: TextDecoration.underline, ); static TextStyle _defaultTextStyle({ required String family, double fontSize = 20, double height = 28 / 20, FontWeight weight = FontWeight.normal, TextDecoration decoration = TextDecoration.none, Color? color, }) => TextStyle( inherit: false, fontSize: fontSize, decoration: decoration, fontStyle: FontStyle.normal, fontWeight: weight, height: height, fontFamily: family, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even, color: color, ); } class TextThemeBody extends TextThemeType { const TextThemeBody({ required super.fontFamily, }); @override TextStyle standard({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.normal, ); @override TextStyle enhanced({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w500, ); @override TextStyle prominent({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.bold, ); @override TextStyle underline({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.normal, decoration: TextDecoration.underline, ); static TextStyle _defaultTextStyle({ required String family, double fontSize = 14, double height = 20 / 14, FontWeight weight = FontWeight.normal, TextDecoration decoration = TextDecoration.none, Color? color, }) => TextStyle( inherit: false, fontSize: fontSize, decoration: decoration, fontStyle: FontStyle.normal, fontWeight: weight, height: height, fontFamily: family, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even, color: color, ); } class TextThemeCaption extends TextThemeType { const TextThemeCaption({ required super.fontFamily, }); @override TextStyle standard({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.normal, ); @override TextStyle enhanced({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.w500, ); @override TextStyle prominent({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.bold, ); @override TextStyle underline({String? family, Color? color, FontWeight? weight}) => _defaultTextStyle( family: family ?? super.fontFamily, color: color, weight: weight ?? FontWeight.normal, decoration: TextDecoration.underline, ); static TextStyle _defaultTextStyle({ required String family, double fontSize = 12, double height = 18 / 12, FontWeight weight = FontWeight.normal, TextDecoration decoration = TextDecoration.none, Color? color, }) => TextStyle( inherit: false, fontSize: fontSize, decoration: decoration, fontStyle: FontStyle.normal, fontWeight: weight, height: height, fontFamily: family, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even, color: color, ); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/text_style/text_style.dart ================================================ import 'package:appflowy_ui/src/theme/definition/text_style/base/default_text_style.dart'; class AppFlowyBaseTextStyle { factory AppFlowyBaseTextStyle.customFontFamily(String fontFamily) => AppFlowyBaseTextStyle( heading1: TextThemeHeading1(fontFamily: fontFamily), heading2: TextThemeHeading2(fontFamily: fontFamily), heading3: TextThemeHeading3(fontFamily: fontFamily), heading4: TextThemeHeading4(fontFamily: fontFamily), headline: TextThemeHeadline(fontFamily: fontFamily), title: TextThemeTitle(fontFamily: fontFamily), body: TextThemeBody(fontFamily: fontFamily), caption: TextThemeCaption(fontFamily: fontFamily), ); const AppFlowyBaseTextStyle({ this.heading1 = const TextThemeHeading1(fontFamily: ''), this.heading2 = const TextThemeHeading2(fontFamily: ''), this.heading3 = const TextThemeHeading3(fontFamily: ''), this.heading4 = const TextThemeHeading4(fontFamily: ''), this.headline = const TextThemeHeadline(fontFamily: ''), this.title = const TextThemeTitle(fontFamily: ''), this.body = const TextThemeBody(fontFamily: ''), this.caption = const TextThemeCaption(fontFamily: ''), }); final TextThemeType heading1; final TextThemeType heading2; final TextThemeType heading3; final TextThemeType heading4; final TextThemeType headline; final TextThemeType title; final TextThemeType body; final TextThemeType caption; } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/definition/theme_data.dart ================================================ import 'border_radius/border_radius.dart'; import 'color_scheme/color_scheme.dart'; import 'shadow/shadow.dart'; import 'spacing/spacing.dart'; import 'text_style/text_style.dart'; /// [AppFlowyThemeData] defines the structure of the design system, and contains /// the data that all child widgets will have access to. class AppFlowyThemeData { const AppFlowyThemeData({ required this.textColorScheme, required this.textStyle, required this.iconColorScheme, required this.borderColorScheme, required this.backgroundColorScheme, required this.fillColorScheme, required this.surfaceColorScheme, required this.borderRadius, required this.spacing, required this.shadow, required this.brandColorScheme, required this.surfaceContainerColorScheme, required this.badgeColorScheme, required this.otherColorsColorScheme, }); final AppFlowyTextColorScheme textColorScheme; final AppFlowyBaseTextStyle textStyle; final AppFlowyIconColorScheme iconColorScheme; final AppFlowyBorderColorScheme borderColorScheme; final AppFlowyBackgroundColorScheme backgroundColorScheme; final AppFlowyFillColorScheme fillColorScheme; final AppFlowySurfaceColorScheme surfaceColorScheme; final AppFlowyBorderRadius borderRadius; final AppFlowySpacing spacing; final AppFlowyShadow shadow; final AppFlowyBrandColorScheme brandColorScheme; final AppFlowySurfaceContainerColorScheme surfaceContainerColorScheme; final AppFlowyBadgeColorScheme badgeColorScheme; final AppFlowyOtherColorsColorScheme otherColorsColorScheme; static AppFlowyThemeData lerp( AppFlowyThemeData begin, AppFlowyThemeData end, double t, ) { return AppFlowyThemeData( textColorScheme: begin.textColorScheme.lerp( end.textColorScheme, t, ), textStyle: end.textStyle, iconColorScheme: begin.iconColorScheme.lerp( end.iconColorScheme, t, ), borderColorScheme: begin.borderColorScheme.lerp( end.borderColorScheme, t, ), backgroundColorScheme: begin.backgroundColorScheme.lerp( end.backgroundColorScheme, t, ), fillColorScheme: begin.fillColorScheme.lerp( end.fillColorScheme, t, ), surfaceColorScheme: begin.surfaceColorScheme.lerp( end.surfaceColorScheme, t, ), borderRadius: end.borderRadius, spacing: end.spacing, shadow: end.shadow, brandColorScheme: begin.brandColorScheme.lerp( end.brandColorScheme, t, ), otherColorsColorScheme: begin.otherColorsColorScheme.lerp( end.otherColorsColorScheme, t, ), surfaceContainerColorScheme: begin.surfaceContainerColorScheme.lerp( end.surfaceContainerColorScheme, t, ), badgeColorScheme: begin.badgeColorScheme.lerp( end.badgeColorScheme, t, ), ); } } /// [AppFlowyThemeBuilder] is used to build the light and dark themes. Extend /// this class to create a built-in theme, or use the [CustomTheme] class to /// create a custom theme from JSON data. /// /// See also: /// /// - [AppFlowyThemeData] for the main theme data class. abstract class AppFlowyThemeBuilder { const AppFlowyThemeBuilder(); AppFlowyThemeData light({ String? fontFamily, }); AppFlowyThemeData dark({ String? fontFamily, }); } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/theme.dart ================================================ export 'appflowy_theme.dart'; export 'data/built_in_themes.dart'; export 'definition/border_radius/border_radius.dart'; export 'definition/color_scheme/color_scheme.dart'; export 'definition/theme_data.dart'; export 'definition/spacing/spacing.dart'; export 'definition/shadow/shadow.dart'; export 'definition/text_style/text_style.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/pubspec.yaml ================================================ name: appflowy_ui description: "A Flutter package for AppFlowy UI components and widgets" version: 1.0.0 homepage: https://github.com/appflowy-io/appflowy environment: sdk: ^3.6.2 flutter: ">=1.17.0" dependencies: cached_network_image: ^3.4.1 flutter: sdk: flutter flutter_animate: ^4.5.2 flutter_lints: ^5.0.0 dev_dependencies: flutter_test: sdk: flutter ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/script/Primitive.Mode 1.tokens.json ================================================ { "Neutral": { "100": { "$type": "color", "$value": "#f8faff" }, "200": { "$type": "color", "$value": "#e4e8f5" }, "300": { "$type": "color", "$value": "#ced3e6" }, "400": { "$type": "color", "$value": "#b5bbd3" }, "500": { "$type": "color", "$value": "#989eb7" }, "600": { "$type": "color", "$value": "#6f748c" }, "700": { "$type": "color", "$value": "#54596e" }, "800": { "$type": "color", "$value": "#3d404f" }, "830": { "$type": "color", "$value": "#363845" }, "850": { "$type": "color", "$value": "#32343f" }, "900": { "$type": "color", "$value": "#272930" }, "1000": { "$type": "color", "$value": "#21232a" }, "black": { "$type": "color", "$value": "#000000" }, "alpha-black-60": { "$type": "color", "$value": "#00000099" }, "white": { "$type": "color", "$value": "#ffffff" }, "alpha-white-0": { "$type": "color", "$value": "#ffffff00" }, "alpha-grey-100-05": { "$type": "color", "$value": "#f9fafd0d" }, "alpha-grey-100-10": { "$type": "color", "$value": "#f9fafd1a" }, "alpha-grey-1000-05": { "$type": "color", "$value": "#1f23290d" }, "alpha-grey-1000-10": { "$type": "color", "$value": "#1f23291a" } }, "Blue": { "100": { "$type": "color", "$value": "#e3f6ff" }, "200": { "$type": "color", "$value": "#a9e2ff" }, "300": { "$type": "color", "$value": "#80d2ff" }, "400": { "$type": "color", "$value": "#4ec1ff" }, "500": { "$type": "color", "$value": "#00b5ff" }, "600": { "$type": "color", "$value": "#0092d6" }, "700": { "$type": "color", "$value": "#0078c0" }, "800": { "$type": "color", "$value": "#0065a9" }, "900": { "$type": "color", "$value": "#00508f" }, "1000": { "$type": "color", "$value": "#003c77" }, "alpha-blue-500-15": { "$type": "color", "$value": "#00b5ff26" }, "alpha-blue-500-20": { "$type": "color", "$value": "#00b5ff33", "$description": "Text Selected Effect" } }, "Green": { "100": { "$type": "color", "$value": "#ecf9f5" }, "200": { "$type": "color", "$value": "#c3e5d8" }, "300": { "$type": "color", "$value": "#9ad1bc" }, "400": { "$type": "color", "$value": "#71bd9f" }, "500": { "$type": "color", "$value": "#48a982" }, "600": { "$type": "color", "$value": "#248569" }, "700": { "$type": "color", "$value": "#29725d" }, "800": { "$type": "color", "$value": "#2e6050" }, "900": { "$type": "color", "$value": "#305548" }, "1000": { "$type": "color", "$value": "#305244" } }, "Purple": { "100": { "$type": "color", "$value": "#f1e0ff" }, "200": { "$type": "color", "$value": "#e1b3ff" }, "300": { "$type": "color", "$value": "#d185ff" }, "400": { "$type": "color", "$value": "#bc58ff" }, "500": { "$type": "color", "$value": "#9327ff" }, "600": { "$type": "color", "$value": "#7a1dcc" }, "700": { "$type": "color", "$value": "#6617b3" }, "800": { "$type": "color", "$value": "#55138f" }, "900": { "$type": "color", "$value": "#470c72" }, "1000": { "$type": "color", "$value": "#380758" } }, "Magenta": { "100": { "$type": "color", "$value": "#ffe5ef" }, "200": { "$type": "color", "$value": "#ffb8d1" }, "300": { "$type": "color", "$value": "#ff8ab2" }, "400": { "$type": "color", "$value": "#ff5c93" }, "500": { "$type": "color", "$value": "#fb006d" }, "600": { "$type": "color", "$value": "#d2005f" }, "700": { "$type": "color", "$value": "#d2005f" }, "800": { "$type": "color", "$value": "#850040" }, "900": { "$type": "color", "$value": "#610031" }, "1000": { "$type": "color", "$value": "#400022" } }, "Red": { "100": { "$type": "color", "$value": "#ffd2dd" }, "200": { "$type": "color", "$value": "#ffa5b4" }, "300": { "$type": "color", "$value": "#ff7d87" }, "400": { "$type": "color", "$value": "#ff5050" }, "500": { "$type": "color", "$value": "#f33641" }, "600": { "$type": "color", "$value": "#e71d32" }, "700": { "$type": "color", "$value": "#ad1625" }, "800": { "$type": "color", "$value": "#8c101c" }, "900": { "$type": "color", "$value": "#6e0a1e" }, "1000": { "$type": "color", "$value": "#4c0a17" }, "alpha-red-500-10": { "$type": "color", "$value": "#f336411a" } }, "Orange": { "100": { "$type": "color", "$value": "#fff3d5" }, "200": { "$type": "color", "$value": "#ffe4ab" }, "300": { "$type": "color", "$value": "#ffd181" }, "400": { "$type": "color", "$value": "#ffbe62" }, "500": { "$type": "color", "$value": "#ffa02e" }, "600": { "$type": "color", "$value": "#db7e21" }, "700": { "$type": "color", "$value": "#b75f17" }, "800": { "$type": "color", "$value": "#93450e" }, "900": { "$type": "color", "$value": "#7a3108" }, "1000": { "$type": "color", "$value": "#602706" } }, "Yellow": { "100": { "$type": "color", "$value": "#fff9b2" }, "200": { "$type": "color", "$value": "#ffec66" }, "300": { "$type": "color", "$value": "#ffdf1a" }, "400": { "$type": "color", "$value": "#ffcc00" }, "500": { "$type": "color", "$value": "#ffce00" }, "600": { "$type": "color", "$value": "#e6b800" }, "700": { "$type": "color", "$value": "#cc9f00" }, "800": { "$type": "color", "$value": "#b38a00" }, "900": { "$type": "color", "$value": "#9a7500" }, "1000": { "$type": "color", "$value": "#7f6200" } }, "Subtle_Color": { "Rose": { "100": { "$type": "color", "$value": "#fcf2f2" }, "200": { "$type": "color", "$value": "#fae3e3" }, "300": { "$type": "color", "$value": "#fad9d9" }, "400": { "$type": "color", "$value": "#edadad" }, "500": { "$type": "color", "$value": "#cc4e4e" }, "600": { "$type": "color", "$value": "#702828" } }, "Papaya": { "100": { "$type": "color", "$value": "#fcf4f0" }, "200": { "$type": "color", "$value": "#fae8de" }, "300": { "$type": "color", "$value": "#fadfd2" }, "400": { "$type": "color", "$value": "#f0bda3" }, "500": { "$type": "color", "$value": "#d67240" }, "600": { "$type": "color", "$value": "#6b3215" } }, "Tangerine": { "100": { "$type": "color", "$value": "#fff7ed" }, "200": { "$type": "color", "$value": "#fcedd9" }, "300": { "$type": "color", "$value": "#fae5ca" }, "400": { "$type": "color", "$value": "#f2cb99" }, "500": { "$type": "color", "$value": "#db8f2c" }, "600": { "$type": "color", "$value": "#613b0a" } }, "Mango": { "100": { "$type": "color", "$value": "#fff9ec" }, "200": { "$type": "color", "$value": "#fcf1d7" }, "300": { "$type": "color", "$value": "#fae9c3" }, "400": { "$type": "color", "$value": "#f5d68e" }, "500": { "$type": "color", "$value": "#e0a416" }, "600": { "$type": "color", "$value": "#5c4102" } }, "Lemon": { "100": { "$type": "color", "$value": "#fffbe8" }, "200": { "$type": "color", "$value": "#fcf5cf" }, "300": { "$type": "color", "$value": "#faefb9" }, "400": { "$type": "color", "$value": "#f5e282" }, "500": { "$type": "color", "$value": "#e0bb00" }, "600": { "$type": "color", "$value": "#574800" } }, "Olive": { "100": { "$type": "color", "$value": "#f9fae6" }, "200": { "$type": "color", "$value": "#f6f7d0" }, "300": { "$type": "color", "$value": "#f0f2b3" }, "400": { "$type": "color", "$value": "#dbde83" }, "500": { "$type": "color", "$value": "#adb204" }, "600": { "$type": "color", "$value": "#4a4c03" } }, "Lime": { "100": { "$type": "color", "$value": "#f6f9e6" }, "200": { "$type": "color", "$value": "#eef5ce" }, "300": { "$type": "color", "$value": "#e7f0bb" }, "400": { "$type": "color", "$value": "#cfdb91" }, "500": { "$type": "color", "$value": "#92a822" }, "600": { "$type": "color", "$value": "#414d05" } }, "Grass": { "100": { "$type": "color", "$value": "#f4faeb" }, "200": { "$type": "color", "$value": "#e9f5d7" }, "300": { "$type": "color", "$value": "#def0c5" }, "400": { "$type": "color", "$value": "#bfd998" }, "500": { "$type": "color", "$value": "#75a828" }, "600": { "$type": "color", "$value": "#334d0c" } }, "Forest": { "100": { "$type": "color", "$value": "#f1faf0" }, "200": { "$type": "color", "$value": "#e2f5df" }, "300": { "$type": "color", "$value": "#d7f0d3" }, "400": { "$type": "color", "$value": "#a8d6a1" }, "500": { "$type": "color", "$value": "#49a33b" }, "600": { "$type": "color", "$value": "#1e4f16" } }, "Jade": { "100": { "$type": "color", "$value": "#f0faf6" }, "200": { "$type": "color", "$value": "#dff5eb" }, "300": { "$type": "color", "$value": "#cef0e1" }, "400": { "$type": "color", "$value": "#90d1b5" }, "500": { "$type": "color", "$value": "#1c9963" }, "600": { "$type": "color", "$value": "#075231" } }, "Aqua": { "100": { "$type": "color", "$value": "#f0f9fa" }, "200": { "$type": "color", "$value": "#dff3f5" }, "300": { "$type": "color", "$value": "#ccecf0" }, "400": { "$type": "color", "$value": "#83ccd4" }, "500": { "$type": "color", "$value": "#008e9e" }, "600": { "$type": "color", "$value": "#004e57" } }, "Azure": { "100": { "$type": "color", "$value": "#f0f6fa" }, "200": { "$type": "color", "$value": "#e1eef7" }, "300": { "$type": "color", "$value": "#d3e6f5" }, "400": { "$type": "color", "$value": "#88c0eb" }, "500": { "$type": "color", "$value": "#0877cc" }, "600": { "$type": "color", "$value": "#154469" } }, "Denim": { "100": { "$type": "color", "$value": "#f0f3fa" }, "200": { "$type": "color", "$value": "#e3ebfa" }, "300": { "$type": "color", "$value": "#d7e2f7" }, "400": { "$type": "color", "$value": "#9ab6ed" }, "500": { "$type": "color", "$value": "#3267d1" }, "600": { "$type": "color", "$value": "#223c70" } }, "Mauve": { "100": { "$type": "color", "$value": "#f2f2fc" }, "200": { "$type": "color", "$value": "#e6e6fa" }, "300": { "$type": "color", "$value": "#dcdcf7" }, "400": { "$type": "color", "$value": "#aeaef5" }, "500": { "$type": "color", "$value": "#5555e0" }, "600": { "$type": "color", "$value": "#36366b" } }, "Lavender": { "100": { "$type": "color", "$value": "#f6f3fc" }, "200": { "$type": "color", "$value": "#ebe3fa" }, "300": { "$type": "color", "$value": "#e4daf7" }, "400": { "$type": "color", "$value": "#c1aaf0" }, "500": { "$type": "color", "$value": "#8153db" }, "600": { "$type": "color", "$value": "#462f75" } }, "Lilac": { "100": { "$type": "color", "$value": "#f7f0fa" }, "200": { "$type": "color", "$value": "#f0e1f7" }, "300": { "$type": "color", "$value": "#edd7f7" }, "400": { "$type": "color", "$value": "#d3a9e8" }, "500": { "$type": "color", "$value": "#9e4cc7" }, "600": { "$type": "color", "$value": "#562d6b" } }, "Mallow": { "100": { "$type": "color", "$value": "#faf0fa" }, "200": { "$type": "color", "$value": "#f5e1f4" }, "300": { "$type": "color", "$value": "#f5d7f4" }, "400": { "$type": "color", "$value": "#dea4dc" }, "500": { "$type": "color", "$value": "#b240af" }, "600": { "$type": "color", "$value": "#632861" } }, "Camellia": { "100": { "$type": "color", "$value": "#f9eff3" }, "200": { "$type": "color", "$value": "#f7e1eb" }, "300": { "$type": "color", "$value": "#f7d7e5" }, "400": { "$type": "color", "$value": "#e5a3c0" }, "500": { "$type": "color", "$value": "#c24279" }, "600": { "$type": "color", "$value": "#6e2343" } }, "Smoke": { "100": { "$type": "color", "$value": "#f5f5f5" }, "200": { "$type": "color", "$value": "#e8e8e8" }, "300": { "$type": "color", "$value": "#dedede" }, "400": { "$type": "color", "$value": "#b8b8b8" }, "500": { "$type": "color", "$value": "#6e6e6e" }, "600": { "$type": "color", "$value": "#404040" } }, "Iron": { "100": { "$type": "color", "$value": "#f2f4f7" }, "200": { "$type": "color", "$value": "#e6e9f0" }, "300": { "$type": "color", "$value": "#dadee5" }, "400": { "$type": "color", "$value": "#b0b5bf" }, "500": { "$type": "color", "$value": "#666f80" }, "600": { "$type": "color", "$value": "#394152" } } }, "Spacing": { "0": { "$type": "dimension", "$value": "0px" }, "100": { "$type": "dimension", "$value": "4px" }, "200": { "$type": "dimension", "$value": "6px" }, "300": { "$type": "dimension", "$value": "8px" }, "400": { "$type": "dimension", "$value": "12px" }, "500": { "$type": "dimension", "$value": "16px" }, "600": { "$type": "dimension", "$value": "20px" }, "1000": { "$type": "dimension", "$value": "1000px" } }, "Border-Radius": { "0": { "$type": "dimension", "$value": "0px" }, "100": { "$type": "dimension", "$value": "4px" }, "200": { "$type": "dimension", "$value": "6px" }, "300": { "$type": "dimension", "$value": "8px" }, "400": { "$type": "dimension", "$value": "12px" }, "500": { "$type": "dimension", "$value": "16px" }, "600": { "$type": "dimension", "$value": "20px" }, "1000": { "$type": "dimension", "$value": "1000px" } } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Dark Mode.tokens.json ================================================ { "Text": { "primary": { "$type": "color", "$value": "{Neutral.200}" }, "secondary": { "$type": "color", "$value": "{Neutral.500}" }, "tertiary": { "$type": "color", "$value": "{Neutral.600}" }, "quaternary": { "$type": "color", "$value": "{Neutral.1000}" }, "on-fill": { "$type": "color", "$value": "{Neutral.white}" }, "action": { "$type": "color", "$value": "{Blue.500}" }, "action-hover": { "$type": "color", "$value": "{Blue.400}" }, "info": { "$type": "color", "$value": "{Blue.500}" }, "info-hover": { "$type": "color", "$value": "{Blue.400}" }, "success": { "$type": "color", "$value": "{Green.600}" }, "success-hover": { "$type": "color", "$value": "{Green.500}" }, "warning": { "$type": "color", "$value": "{Orange.600}" }, "warning-hover": { "$type": "color", "$value": "{Orange.500}" }, "error": { "$type": "color", "$value": "{Red.500}" }, "error-hover": { "$type": "color", "$value": "{Red.400}" }, "featured": { "$type": "color", "$value": "{Purple.500}" }, "featured-hover": { "$type": "color", "$value": "{Purple.400}" } }, "Icon": { "primary": { "$type": "color", "$value": "{Neutral.200}" }, "secondary": { "$type": "color", "$value": "{Neutral.400}" }, "tertiary": { "$type": "color", "$value": "{Neutral.600}" }, "quaternary": { "$type": "color", "$value": "{Neutral.1000}" }, "info-thick": { "$type": "color", "$value": "{Blue.500}" }, "info-thick-hover": { "$type": "color", "$value": "{Blue.400}" }, "success-thick": { "$type": "color", "$value": "{Green.600}" }, "success-thick-hover": { "$type": "color", "$value": "{Green.500}" }, "warning-thick": { "$type": "color", "$value": "{Orange.600}" }, "warning-thick-hover": { "$type": "color", "$value": "{Orange.500}" }, "error-thick": { "$type": "color", "$value": "{Red.500}" }, "error-thick-hover": { "$type": "color", "$value": "{Red.400}" }, "featured-thick": { "$type": "color", "$value": "{Purple.500}" }, "featured-thick-hover": { "$type": "color", "$value": "{Purple.400}" }, "on-fill": { "$type": "color", "$value": "{Neutral.white}" } }, "Border": { "primary": { "$type": "color", "$value": "{Neutral.800}" }, "primary-hover": { "$type": "color", "$value": "{Neutral.700}" }, "secondary": { "$type": "color", "$value": "{Neutral.300}" }, "secondary-hover": { "$type": "color", "$value": "{Neutral.200}" }, "tertiary": { "$type": "color", "$value": "{Neutral.100}" }, "tertiary-hover": { "$type": "color", "$value": "{Neutral.white}" }, "theme-thick": { "$type": "color", "$value": "{Blue.500}" }, "theme-thick-hover": { "$type": "color", "$value": "{Blue.600}" }, "info-thick": { "$type": "color", "$value": "{Blue.500}" }, "info-thick-hover": { "$type": "color", "$value": "{Blue.400}" }, "success-thick": { "$type": "color", "$value": "{Green.600}" }, "success-thick-hover": { "$type": "color", "$value": "{Green.500}" }, "warning-thick": { "$type": "color", "$value": "{Orange.600}" }, "warning-thick-hover": { "$type": "color", "$value": "{Orange.500}" }, "error-thick": { "$type": "color", "$value": "{Red.500}" }, "error-thick-hover": { "$type": "color", "$value": "{Red.400}" }, "featured-thick": { "$type": "color", "$value": "{Purple.500}" }, "featured-thick-hover": { "$type": "color", "$value": "{Purple.400}" } }, "Fill": { "primary": { "$type": "color", "$value": "{Neutral.900}", "$description": "No longer available" }, "primary-hover": { "$type": "color", "$value": "{Neutral.800}", "$description": "No longer available" }, "secondary": { "$type": "color", "$value": "{Neutral.600}", "$description": "No longer available" }, "secondary-hover": { "$type": "color", "$value": "{Neutral.500}", "$description": "No longer available" }, "tertiary": { "$type": "color", "$value": "{Neutral.300}", "$description": "No longer available" }, "tertiary-hover": { "$type": "color", "$value": "{Neutral.200}", "$description": "No longer available" }, "quaternary": { "$type": "color", "$value": "{Neutral.100}", "$description": "No longer available" }, "quaternary-hover": { "$type": "color", "$value": "{Neutral.white}", "$description": "No longer available" }, "content": { "$type": "color", "$value": "{Neutral.alpha-white-0}" }, "content-hover": { "$type": "color", "$value": "{Neutral.alpha-grey-100-05}", "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." }, "content-visible": { "$type": "color", "$value": "{Neutral.alpha-grey-100-05}", "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." }, "content-visible-hover": { "$type": "color", "$value": "{Neutral.alpha-grey-100-10}" }, "theme-thick": { "$type": "color", "$value": "{Blue.500}" }, "theme-thick-hover": { "$type": "color", "$value": "{Blue.600}" }, "theme-select": { "$type": "color", "$value": "{Blue.alpha-blue-500-15}" }, "text-select": { "$type": "color", "$value": "{Blue.alpha-blue-500-20}" }, "info-light": { "$type": "color", "$value": "{Blue.200}" }, "info-light-hover": { "$type": "color", "$value": "{Blue.100}" }, "info-thick": { "$type": "color", "$value": "{Blue.500}" }, "info-thick-hover": { "$type": "color", "$value": "{Blue.400}" }, "success-light": { "$type": "color", "$value": "{Green.200}" }, "success-light-hover": { "$type": "color", "$value": "{Green.100}" }, "warning-light": { "$type": "color", "$value": "{Orange.200}" }, "warning-light-hover": { "$type": "color", "$value": "{Orange.100}" }, "error-light": { "$type": "color", "$value": "{Red.200}" }, "error-light-hover": { "$type": "color", "$value": "{Red.100}" }, "error-thick": { "$type": "color", "$value": "{Red.500}" }, "error-thick-hover": { "$type": "color", "$value": "{Red.400}" }, "error-select": { "$type": "color", "$value": "{Red.alpha-red-500-10}" }, "featured-light": { "$type": "color", "$value": "{Purple.200}" }, "featured-light-hover": { "$type": "color", "$value": "{Purple.100}" }, "featured-thick": { "$type": "color", "$value": "{Purple.500}" }, "featured-thick-hover": { "$type": "color", "$value": "{Purple.400}" } }, "Surface": { "primary": { "$type": "color", "$value": "{Neutral.900}" }, "primary-hover": { "$type": "color", "$value": "{Neutral.800}" }, "layer-01": { "$type": "color", "$value": "{Neutral.900}", "$description": "Settings Window" }, "layer-01-hover": { "$type": "color", "$value": "{Neutral.800}" }, "layer-02": { "$type": "color", "$value": "{Neutral.850}", "$description": "Settings Side Panel, Modal Window" }, "layer-02-hover": { "$type": "color", "$value": "{Neutral.800}" }, "layer-03": { "$type": "color", "$value": "{Neutral.850}", "$description": "Dialog" }, "layer-03-hover": { "$type": "color", "$value": "{Neutral.800}" }, "layer-04": { "$type": "color", "$value": "{Neutral.830}", "$description": "Dropdown Menu" }, "layer-04-hover": { "$type": "color", "$value": "{Neutral.800}" }, "inverse": { "$type": "color", "$value": "{Neutral.800}" }, "secondary": { "$type": "color", "$value": "{Neutral.800}" }, "overlay": { "$type": "color", "$value": "{Neutral.alpha-black-60}" } }, "Surface_Container": { "layer-01": { "$type": "color", "$value": "{Neutral.900}" }, "layer-02": { "$type": "color", "$value": "{Neutral.800}" }, "layer-03": { "$type": "color", "$value": "{Neutral.700}" } }, "Background": { "primary": { "$type": "color", "$value": "{Neutral.1000}" } }, "Badge": { "color-1": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Rose.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Rose.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Rose.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Rose.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Rose.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Rose.600}" } }, "color-2": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Papaya.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Papaya.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Papaya.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Papaya.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Papaya.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Papaya.600}" } }, "color-3": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Tangerine.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Tangerine.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Tangerine.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Tangerine.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Tangerine.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Tangerine.600}" } }, "color-4": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Mango.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Mango.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Mango.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Mango.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Mango.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Mango.600}" } }, "color-5": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Lemon.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Lemon.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Lemon.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Lemon.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Lemon.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Lemon.600}" } }, "color-6": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Olive.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Olive.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Olive.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Olive.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Olive.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Olive.600}" } }, "color-7": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Lime.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Lime.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Lime.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Lime.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Lime.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Lime.600}" } }, "color-8": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Grass.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Grass.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Grass.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Grass.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Grass.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Grass.600}" } }, "color-9": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Forest.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Forest.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Forest.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Forest.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Forest.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Forest.600}" } }, "color-10": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Jade.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Jade.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Jade.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Jade.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Jade.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Jade.600}" } }, "color-11": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Aqua.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Aqua.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Aqua.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Aqua.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Aqua.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Aqua.600}" } }, "color-12": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Azure.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Azure.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Azure.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Azure.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Azure.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Azure.600}" } }, "color-13": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Denim.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Denim.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Denim.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Denim.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Denim.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Denim.600}" } }, "color-14": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Mauve.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Mauve.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Mauve.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Mauve.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Mauve.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Mauve.600}" } }, "color-15": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Lavender.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Lavender.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Lavender.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Lavender.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Lavender.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Lavender.600}" } }, "color-16": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Lilac.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Lilac.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Lilac.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Lilac.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Lilac.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Lilac.600}" } }, "color-17": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Mallow.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Mallow.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Mallow.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Mallow.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Mallow.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Mallow.600}" } }, "color-18": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Camellia.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Camellia.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Camellia.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Camellia.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Camellia.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Camellia.600}" } }, "color-19": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Smoke.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Smoke.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Smoke.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Smoke.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Smoke.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Smoke.600}" } }, "color-20": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Iron.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Iron.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Iron.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Iron.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Iron.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Iron.600}" } } }, "Brand": { "Skyline": { "$type": "color", "$value": "#00b5ff" }, "Aqua": { "$type": "color", "$value": "#00c8ff" }, "Violet": { "$type": "color", "$value": "#9327ff" }, "Amethyst": { "$type": "color", "$value": "#8427e0" }, "Berry": { "$type": "color", "$value": "#e3006d" }, "Coral": { "$type": "color", "$value": "#fb006d" }, "Golden": { "$type": "color", "$value": "#f7931e" }, "Amber": { "$type": "color", "$value": "#ffbd00" }, "Lemon": { "$type": "color", "$value": "#ffce00" } }, "Other_Colors": { "text-highlight": { "$type": "color", "$value": "{Blue.200}" } }, "Spacing": { "spacing-0": { "$type": "dimension", "$value": "{Spacing.0}" }, "spacing-xs": { "$type": "dimension", "$value": "{Spacing.100}" }, "spacing-s": { "$type": "dimension", "$value": "{Spacing.200}" }, "spacing-m": { "$type": "dimension", "$value": "{Spacing.300}" }, "spacing-l": { "$type": "dimension", "$value": "{Spacing.400}" }, "spacing-xl": { "$type": "dimension", "$value": "{Spacing.500}" }, "spacing-xxl": { "$type": "dimension", "$value": "{Spacing.600}" }, "spacing-full": { "$type": "dimension", "$value": "{Spacing.1000}" } }, "Border_Radius": { "border-radius-0": { "$type": "dimension", "$value": "{Border-Radius.0}" }, "border-radius-xs": { "$type": "dimension", "$value": "{Border-Radius.100}" }, "border-radius-s": { "$type": "dimension", "$value": "{Border-Radius.200}" }, "border-radius-m": { "$type": "dimension", "$value": "{Border-Radius.300}" }, "border-radius-l": { "$type": "dimension", "$value": "{Border-Radius.400}" }, "border-radius-xl": { "$type": "dimension", "$value": "{Border-Radius.500}" }, "border-radius-xxl": { "$type": "dimension", "$value": "{Border-Radius.600}" }, "border-radius-full": { "$type": "dimension", "$value": "{Border-Radius.1000}" } } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/script/Semantic.Light Mode.tokens.json ================================================ { "Text": { "primary": { "$type": "color", "$value": "{Neutral.1000}" }, "secondary": { "$type": "color", "$value": "{Neutral.600}" }, "tertiary": { "$type": "color", "$value": "{Neutral.500}" }, "quaternary": { "$type": "color", "$value": "{Neutral.200}" }, "on-fill": { "$type": "color", "$value": "{Neutral.white}" }, "action": { "$type": "color", "$value": "{Blue.600}" }, "action-hover": { "$type": "color", "$value": "{Blue.700}" }, "info": { "$type": "color", "$value": "{Blue.600}" }, "info-hover": { "$type": "color", "$value": "{Blue.700}" }, "success": { "$type": "color", "$value": "{Green.600}" }, "success-hover": { "$type": "color", "$value": "{Green.700}" }, "warning": { "$type": "color", "$value": "{Orange.600}" }, "warning-hover": { "$type": "color", "$value": "{Orange.700}" }, "error": { "$type": "color", "$value": "{Red.600}" }, "error-hover": { "$type": "color", "$value": "{Red.700}" }, "featured": { "$type": "color", "$value": "{Purple.500}" }, "featured-hover": { "$type": "color", "$value": "{Purple.600}" } }, "Icon": { "primary": { "$type": "color", "$value": "{Neutral.1000}" }, "secondary": { "$type": "color", "$value": "{Neutral.600}" }, "tertiary": { "$type": "color", "$value": "{Neutral.400}" }, "quaternary": { "$type": "color", "$value": "{Neutral.200}" }, "info-thick": { "$type": "color", "$value": "{Blue.600}" }, "info-thick-hover": { "$type": "color", "$value": "{Blue.700}" }, "success-thick": { "$type": "color", "$value": "{Green.600}" }, "success-thick-hover": { "$type": "color", "$value": "{Green.700}" }, "warning-thick": { "$type": "color", "$value": "{Orange.600}" }, "warning-thick-hover": { "$type": "color", "$value": "{Orange.700}" }, "error-thick": { "$type": "color", "$value": "{Red.600}" }, "error-thick-hover": { "$type": "color", "$value": "{Red.700}" }, "featured-thick": { "$type": "color", "$value": "{Purple.500}" }, "featured-thick-hover": { "$type": "color", "$value": "{Purple.600}" }, "on-fill": { "$type": "color", "$value": "{Neutral.white}" } }, "Border": { "primary": { "$type": "color", "$value": "{Neutral.200}" }, "primary-hover": { "$type": "color", "$value": "{Neutral.300}" }, "secondary": { "$type": "color", "$value": "{Neutral.800}" }, "secondary-hover": { "$type": "color", "$value": "{Neutral.700}" }, "tertiary": { "$type": "color", "$value": "{Neutral.1000}" }, "tertiary-hover": { "$type": "color", "$value": "{Neutral.900}" }, "theme-thick": { "$type": "color", "$value": "{Blue.500}" }, "theme-thick-hover": { "$type": "color", "$value": "{Blue.600}" }, "info-thick": { "$type": "color", "$value": "{Blue.500}" }, "info-thick-hover": { "$type": "color", "$value": "{Blue.600}" }, "success-thick": { "$type": "color", "$value": "{Green.600}" }, "success-thick-hover": { "$type": "color", "$value": "{Green.700}" }, "warning-thick": { "$type": "color", "$value": "{Orange.600}" }, "warning-thick-hover": { "$type": "color", "$value": "{Orange.700}" }, "error-thick": { "$type": "color", "$value": "{Red.600}" }, "error-thick-hover": { "$type": "color", "$value": "{Red.700}" }, "featured-thick": { "$type": "color", "$value": "{Purple.500}" }, "featured-thick-hover": { "$type": "color", "$value": "{Purple.600}" } }, "Fill": { "primary": { "$type": "color", "$value": "{Neutral.100}", "$description": "No longer available" }, "primary-hover": { "$type": "color", "$value": "{Neutral.200}", "$description": "No longer available" }, "secondary": { "$type": "color", "$value": "{Neutral.300}", "$description": "No longer available" }, "secondary-hover": { "$type": "color", "$value": "{Neutral.400}", "$description": "No longer available" }, "tertiary": { "$type": "color", "$value": "{Neutral.600}", "$description": "No longer available" }, "tertiary-hover": { "$type": "color", "$value": "{Neutral.500}", "$description": "No longer available" }, "quaternary": { "$type": "color", "$value": "{Neutral.1000}", "$description": "No longer available" }, "quaternary-hover": { "$type": "color", "$value": "{Neutral.900}", "$description": "No longer available" }, "content": { "$type": "color", "$value": "{Neutral.alpha-white-0}" }, "content-hover": { "$type": "color", "$value": "{Neutral.alpha-grey-1000-05}", "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." }, "content-visible": { "$type": "color", "$value": "{Neutral.alpha-grey-1000-05}", "$description": "Used for hover state, eg. button, navigation item, menu item and grid item." }, "content-visible-hover": { "$type": "color", "$value": "{Neutral.alpha-grey-1000-10}" }, "theme-thick": { "$type": "color", "$value": "{Blue.500}" }, "theme-thick-hover": { "$type": "color", "$value": "{Blue.600}" }, "theme-select": { "$type": "color", "$value": "{Blue.alpha-blue-500-15}" }, "text-select": { "$type": "color", "$value": "{Blue.alpha-blue-500-20}" }, "info-light": { "$type": "color", "$value": "{Blue.100}" }, "info-light-hover": { "$type": "color", "$value": "{Blue.200}" }, "info-thick": { "$type": "color", "$value": "{Blue.500}" }, "info-thick-hover": { "$type": "color", "$value": "{Blue.600}" }, "success-light": { "$type": "color", "$value": "{Green.100}" }, "success-light-hover": { "$type": "color", "$value": "{Green.200}" }, "warning-light": { "$type": "color", "$value": "{Orange.100}" }, "warning-light-hover": { "$type": "color", "$value": "{Orange.200}" }, "error-light": { "$type": "color", "$value": "{Red.100}" }, "error-light-hover": { "$type": "color", "$value": "{Red.200}" }, "error-thick": { "$type": "color", "$value": "{Red.600}" }, "error-thick-hover": { "$type": "color", "$value": "{Red.700}" }, "error-select": { "$type": "color", "$value": "{Red.alpha-red-500-10}" }, "featured-light": { "$type": "color", "$value": "{Purple.100}" }, "featured-light-hover": { "$type": "color", "$value": "{Purple.200}" }, "featured-thick": { "$type": "color", "$value": "{Purple.500}" }, "featured-thick-hover": { "$type": "color", "$value": "{Purple.600}" } }, "Surface": { "primary": { "$type": "color", "$value": "{Neutral.white}" }, "primary-hover": { "$type": "color", "$value": "{Neutral.100}" }, "layer-01": { "$type": "color", "$value": "{Neutral.white}", "$description": "Settings Window" }, "layer-01-hover": { "$type": "color", "$value": "{Neutral.100}" }, "layer-02": { "$type": "color", "$value": "{Neutral.white}", "$description": "Settings Side Panel, Modal Window" }, "layer-02-hover": { "$type": "color", "$value": "{Neutral.100}" }, "layer-03": { "$type": "color", "$value": "{Neutral.white}", "$description": "Dialog" }, "layer-03-hover": { "$type": "color", "$value": "{Neutral.100}" }, "layer-04": { "$type": "color", "$value": "{Neutral.white}", "$description": "Dropdown Menu" }, "layer-04-hover": { "$type": "color", "$value": "{Neutral.100}" }, "inverse": { "$type": "color", "$value": "{Neutral.1000}" }, "secondary": { "$type": "color", "$value": "{Neutral.1000}" }, "overlay": { "$type": "color", "$value": "{Neutral.alpha-black-60}" } }, "Surface_Container": { "layer-01": { "$type": "color", "$value": "{Neutral.100}" }, "layer-02": { "$type": "color", "$value": "{Neutral.200}" }, "layer-03": { "$type": "color", "$value": "{Neutral.300}" } }, "Background": { "primary": { "$type": "color", "$value": "{Neutral.white}" } }, "Badge": { "color-1": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Rose.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Rose.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Rose.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Rose.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Rose.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Rose.600}" } }, "color-2": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Papaya.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Papaya.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Papaya.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Papaya.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Papaya.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Papaya.600}" } }, "color-3": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Tangerine.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Tangerine.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Tangerine.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Tangerine.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Tangerine.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Tangerine.600}" } }, "color-4": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Mango.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Mango.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Mango.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Mango.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Mango.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Mango.600}" } }, "color-5": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Lemon.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Lemon.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Lemon.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Lemon.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Lemon.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Lemon.600}" } }, "color-6": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Olive.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Olive.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Olive.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Olive.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Olive.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Olive.600}" } }, "color-7": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Lime.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Lime.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Lime.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Lime.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Lime.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Lime.600}" } }, "color-8": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Grass.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Grass.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Grass.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Grass.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Grass.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Grass.600}" } }, "color-9": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Forest.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Forest.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Forest.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Forest.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Forest.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Forest.600}" } }, "color-10": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Jade.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Jade.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Jade.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Jade.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Jade.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Jade.600}" } }, "color-11": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Aqua.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Aqua.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Aqua.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Aqua.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Aqua.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Aqua.600}" } }, "color-12": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Azure.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Azure.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Azure.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Azure.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Azure.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Azure.600}" } }, "color-13": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Denim.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Denim.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Denim.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Denim.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Denim.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Denim.600}" } }, "color-14": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Mauve.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Mauve.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Mauve.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Mauve.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Mauve.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Mauve.600}" } }, "color-15": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Lavender.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Lavender.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Lavender.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Lavender.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Lavender.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Lavender.600}" } }, "color-16": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Lilac.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Lilac.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Lilac.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Lilac.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Lilac.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Lilac.600}" } }, "color-17": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Mallow.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Mallow.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Mallow.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Mallow.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Mallow.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Mallow.600}" } }, "color-18": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Camellia.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Camellia.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Camellia.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Camellia.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Camellia.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Camellia.600}" } }, "color-19": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Smoke.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Smoke.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Smoke.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Smoke.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Smoke.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Smoke.600}" } }, "color-20": { "light-1": { "$type": "color", "$value": "{Subtle_Color.Iron.100}" }, "light-2": { "$type": "color", "$value": "{Subtle_Color.Iron.200}" }, "light-3": { "$type": "color", "$value": "{Subtle_Color.Iron.300}" }, "thick-1": { "$type": "color", "$value": "{Subtle_Color.Iron.400}" }, "thick-2": { "$type": "color", "$value": "{Subtle_Color.Iron.500}" }, "thick-3": { "$type": "color", "$value": "{Subtle_Color.Iron.600}" } } }, "Brand": { "Skyline": { "$type": "color", "$value": "#00b5ff" }, "Aqua": { "$type": "color", "$value": "#00c8ff" }, "Violet": { "$type": "color", "$value": "#9327ff" }, "Amethyst": { "$type": "color", "$value": "#8427e0" }, "Berry": { "$type": "color", "$value": "#e3006d" }, "Coral": { "$type": "color", "$value": "#fb006d" }, "Golden": { "$type": "color", "$value": "#f7931e" }, "Amber": { "$type": "color", "$value": "#ffbd00" }, "Lemon": { "$type": "color", "$value": "#ffce00" } }, "Other_Colors": { "text-highlight": { "$type": "color", "$value": "{Blue.200}" } }, "Spacing": { "spacing-0": { "$type": "dimension", "$value": "{Spacing.0}" }, "spacing-xs": { "$type": "dimension", "$value": "{Spacing.100}" }, "spacing-s": { "$type": "dimension", "$value": "{Spacing.200}" }, "spacing-m": { "$type": "dimension", "$value": "{Spacing.300}" }, "spacing-l": { "$type": "dimension", "$value": "{Spacing.400}" }, "spacing-xl": { "$type": "dimension", "$value": "{Spacing.500}" }, "spacing-xxl": { "$type": "dimension", "$value": "{Spacing.600}" }, "spacing-full": { "$type": "dimension", "$value": "{Spacing.1000}" } }, "Border_Radius": { "border-radius-0": { "$type": "dimension", "$value": "{Border-Radius.0}" }, "border-radius-xs": { "$type": "dimension", "$value": "{Border-Radius.100}" }, "border-radius-s": { "$type": "dimension", "$value": "{Border-Radius.200}" }, "border-radius-m": { "$type": "dimension", "$value": "{Border-Radius.300}" }, "border-radius-l": { "$type": "dimension", "$value": "{Border-Radius.400}" }, "border-radius-xl": { "$type": "dimension", "$value": "{Border-Radius.500}" }, "border-radius-xxl": { "$type": "dimension", "$value": "{Border-Radius.600}" }, "border-radius-full": { "$type": "dimension", "$value": "{Border-Radius.1000}" } } } ================================================ FILE: frontend/appflowy_flutter/packages/appflowy_ui/script/generate_theme.dart ================================================ // ignore_for_file: avoid_print, depend_on_referenced_packages import 'dart:convert'; import 'dart:io'; import 'package:collection/collection.dart'; void main() { generatePrimitive(); generateSemantic(); } void generatePrimitive() { // 1. Load the JSON file. final jsonString = File('script/Primitive.Mode 1.tokens.json').readAsStringSync(); final jsonData = jsonDecode(jsonString) as Map; // 2. Prepare the output code. final buffer = StringBuffer(); buffer.writeln(''' // ignore_for_file: constant_identifier_names, non_constant_identifier_names // // AUTO-GENERATED - DO NOT EDIT DIRECTLY // // This file is auto-generated by the generate_theme.dart script // Generation time: ${DateTime.now().toIso8601String()} // // To modify these colors, edit the source JSON files and run the script: // // dart run script/generate_theme.dart // import 'package:flutter/material.dart'; class AppFlowyPrimitiveTokens { AppFlowyPrimitiveTokens._();'''); // 3. Process each color category. jsonData.forEach((categoryName, categoryData) { categoryData.forEach((tokenName, tokenData) { processPrimitiveTokenData( buffer, tokenData, '${categoryName}_$tokenName', ); }); }); buffer.writeln('}'); // 4. Write the output to a Dart file. final outputFile = File('lib/src/theme/data/appflowy_default/primitive.dart'); outputFile.writeAsStringSync(buffer.toString()); print('Successfully generated ${outputFile.path}'); } void processPrimitiveTokenData( StringBuffer buffer, Map tokenData, final String currentTokenName, ) { if (tokenData case { r'$type': 'color', r'$value': final String colorValue, }) { final dartColorValue = convertColor(colorValue); final dartTokenName = currentTokenName.replaceAll('-', '_').toCamelCase(); buffer.writeln(''' /// $colorValue static Color get $dartTokenName => Color(0x$dartColorValue);'''); } else { tokenData.forEach((key, value) { if (value is Map) { processPrimitiveTokenData(buffer, value, '${currentTokenName}_$key'); } }); } } void generateSemantic() { // 1. Load the JSON file. final lightJsonString = File('script/Semantic.Light Mode.tokens.json').readAsStringSync(); final darkJsonString = File('script/Semantic.Dark Mode.tokens.json').readAsStringSync(); final lightJsonData = jsonDecode(lightJsonString) as Map; final darkJsonData = jsonDecode(darkJsonString) as Map; // 2. Prepare the output code. final buffer = StringBuffer(); buffer.writeln(''' // ignore_for_file: constant_identifier_names, non_constant_identifier_names // // AUTO-GENERATED - DO NOT EDIT DIRECTLY // // This file is auto-generated by the generate_theme.dart script // Generation time: ${DateTime.now().toIso8601String()} // // To modify these colors, edit the source JSON files and run the script: // // dart run script/generate_theme.dart // import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; import '../shared.dart'; class AppFlowyDefaultTheme implements AppFlowyThemeBuilder {'''); // 3. Process light mode semantic tokens buffer.writeln(''' @override AppFlowyThemeData light({ String? fontFamily, }) { final textStyle = AppFlowyBaseTextStyle.customFontFamily(fontFamily ?? ''); final borderRadius = AppFlowySharedTokens.buildBorderRadius(); final spacing = AppFlowySharedTokens.buildSpacing(); final shadow = AppFlowySharedTokens.buildShadow(Brightness.light);'''); lightJsonData.forEach((categoryName, categoryData) { if ([ 'Spacing', 'Border_Radius', 'Shadow', 'Badge_Color', ].contains(categoryName)) { return; } final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; buffer ..writeln() ..writeln(' final $fullCategoryName = $className('); categoryData.forEach((tokenName, tokenData) { processSemanticTokenData(buffer, tokenData, tokenName); }); buffer.writeln(' );'); }); buffer.writeln(); buffer.writeln(''' return AppFlowyThemeData( textStyle: textStyle, textColorScheme: textColorScheme, borderColorScheme: borderColorScheme, fillColorScheme: fillColorScheme, surfaceColorScheme: surfaceColorScheme, backgroundColorScheme: backgroundColorScheme, iconColorScheme: iconColorScheme, brandColorScheme: brandColorScheme, otherColorsColorScheme: otherColorsColorScheme, borderRadius: borderRadius, surfaceContainerColorScheme: surfaceContainerColorScheme, badgeColorScheme: badgeColorScheme, spacing: spacing, shadow: shadow, ); }'''); buffer.writeln(); buffer.writeln(''' @override AppFlowyThemeData dark({ String? fontFamily, }) { final textStyle = AppFlowyBaseTextStyle.customFontFamily(fontFamily ?? ''); final borderRadius = AppFlowySharedTokens.buildBorderRadius(); final spacing = AppFlowySharedTokens.buildSpacing(); final shadow = AppFlowySharedTokens.buildShadow(Brightness.dark);'''); darkJsonData.forEach((categoryName, categoryData) { if ([ 'Spacing', 'Border_Radius', 'Shadow', 'Badge_Color', ].contains(categoryName)) { return; } final fullCategoryName = "${categoryName}_color_scheme".toCamelCase(); final className = 'AppFlowy${fullCategoryName.toCapitalize()}'; buffer ..writeln() ..writeln(' final $fullCategoryName = $className('); categoryData.forEach((tokenName, tokenData) { if (tokenData is Map) { processSemanticTokenData(buffer, tokenData, tokenName); } }); buffer.writeln(' );'); }); buffer.writeln(); buffer.writeln(''' return AppFlowyThemeData( textStyle: textStyle, textColorScheme: textColorScheme, borderColorScheme: borderColorScheme, fillColorScheme: fillColorScheme, surfaceColorScheme: surfaceColorScheme, backgroundColorScheme: backgroundColorScheme, iconColorScheme: iconColorScheme, brandColorScheme: brandColorScheme, otherColorsColorScheme: otherColorsColorScheme, borderRadius: borderRadius, surfaceContainerColorScheme: surfaceContainerColorScheme, badgeColorScheme: badgeColorScheme, spacing: spacing, shadow: shadow, ); }'''); buffer.writeln('}'); // 4. Write the output to a Dart file. final outputFile = File('lib/src/theme/data/appflowy_default/semantic.dart'); outputFile.writeAsStringSync(buffer.toString()); print('Successfully generated ${outputFile.path}'); } void processSemanticTokenData( StringBuffer buffer, Map json, final String currentTokenName, ) { if (json case { r'$type': 'color', r'$value': final String value, }) { final semanticTokenName = currentTokenName.replaceAll('-', '_').toCamelCase(); final String colorValueOrPrimitiveToken; if (value.isColor) { colorValueOrPrimitiveToken = 'Color(0x${convertColor(value)})'; } else { final primitiveToken = value .replaceAll(RegExp(r'\{|\}'), '') .replaceAll(RegExp(r'\.|-'), '_') .toCamelCase(); colorValueOrPrimitiveToken = 'AppFlowyPrimitiveTokens.$primitiveToken'; } buffer.writeln(' $semanticTokenName: $colorValueOrPrimitiveToken,'); } else { json.forEach((key, value) { if (value is Map) { processSemanticTokenData( buffer, value, '${currentTokenName}_$key', ); } }); } } String convertColor(String hexColor) { String color = hexColor.toUpperCase().replaceAll('#', ''); if (color.length == 6) { color = 'FF$color'; // Add missing alpha channel } else if (color.length == 8) { color = color.substring(6) + color.substring(0, 6); // Rearrange to ARGB } return color; } extension on String { String toCamelCase() { return split('_').mapIndexed((index, part) { if (index == 0) { return part.toLowerCase(); } else { return part[0].toUpperCase() + part.substring(1).toLowerCase(); } }).join(); } String toCapitalize() { if (isEmpty) { return this; } return '${this[0].toUpperCase()}${substring(1)}'; } bool get isColor => startsWith('#') || (startsWith('0x') && length == 10) || (startsWith('0xFF') && length == 12); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # 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/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .packages .pub-cache/ .pub/ build/ # Android related **/android/**/gradle-wrapper.jar **/android/.gradle **/android/captures/ **/android/gradlew **/android/gradlew.bat **/android/local.properties **/android/**/GeneratedPluginRegistrant.java # iOS/XCode related **/ios/**/*.mode1v3 **/ios/**/*.mode2v3 **/ios/**/*.moved-aside **/ios/**/*.pbxuser **/ios/**/*.perspectivev3 **/ios/**/*sync/ **/ios/**/.sconsign.dblite **/ios/**/.tags* **/ios/**/.vagrant/ **/ios/**/DerivedData/ **/ios/**/Icon? **/ios/**/Pods/ **/ios/**/.symlinks/ **/ios/**/profile **/ios/**/xcuserdata **/ios/.generated/ **/ios/Flutter/App.framework **/ios/Flutter/Flutter.framework **/ios/Flutter/Flutter.podspec **/ios/Flutter/Generated.xcconfig **/ios/Flutter/ephemeral **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ **/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !**/ios/**/default.mode1v3 !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/.metadata ================================================ # 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: 96bbcd006fafade4ad7a4abde77cec32df6846ea channel: dev project_type: package ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/LICENSE ================================================ TODO: Add your license here. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/analysis_options.yaml ================================================ include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart ================================================ import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/utils/color_converter.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dandelion.dart'; import 'default_colorscheme.dart'; import 'lavender.dart'; import 'lemonade.dart'; part 'colorscheme.g.dart'; /// A map of all the built-in themes. /// /// The key is the theme name, and the value is a list of two color schemes: /// the first is for light mode, and the second is for dark mode. const Map> themeMap = { BuiltInTheme.defaultTheme: [ DefaultColorScheme.light(), DefaultColorScheme.dark(), ], BuiltInTheme.dandelion: [ DandelionColorScheme.light(), DandelionColorScheme.dark(), ], BuiltInTheme.lemonade: [ LemonadeColorScheme.light(), LemonadeColorScheme.dark(), ], BuiltInTheme.lavender: [ LavenderColorScheme.light(), LavenderColorScheme.dark(), ], }; @JsonSerializable(converters: [ColorConverter()]) class FlowyColorScheme { const FlowyColorScheme({ required this.surface, required this.hover, required this.selector, required this.red, required this.yellow, required this.green, required this.shader1, required this.shader2, required this.shader3, required this.shader4, required this.shader5, required this.shader6, required this.shader7, required this.bg1, required this.bg2, required this.bg3, required this.bg4, required this.tint1, required this.tint2, required this.tint3, required this.tint4, required this.tint5, required this.tint6, required this.tint7, required this.tint8, required this.tint9, required this.main1, required this.main2, required this.shadow, required this.sidebarBg, required this.divider, required this.topbarBg, required this.icon, required this.text, required this.secondaryText, required this.strongText, required this.input, required this.hint, required this.primary, required this.onPrimary, required this.hoverBG1, required this.hoverBG2, required this.hoverBG3, required this.hoverFG, required this.questionBubbleBG, required this.progressBarBGColor, required this.toolbarColor, required this.toggleButtonBGColor, required this.calendarWeekendBGColor, required this.gridRowCountColor, required this.borderColor, required this.scrollbarColor, required this.scrollbarHoverColor, required this.lightIconColor, required this.toolbarHoverColor, }); final Color surface; final Color hover; final Color selector; final Color red; final Color yellow; final Color green; final Color shader1; final Color shader2; final Color shader3; final Color shader4; final Color shader5; final Color shader6; final Color shader7; final Color bg1; final Color bg2; final Color bg3; final Color bg4; final Color tint1; final Color tint2; final Color tint3; final Color tint4; final Color tint5; final Color tint6; final Color tint7; final Color tint8; final Color tint9; final Color main1; final Color main2; final Color shadow; final Color sidebarBg; final Color divider; final Color topbarBg; final Color icon; final Color text; final Color secondaryText; final Color strongText; final Color input; final Color hint; final Color primary; final Color onPrimary; //page title hover effect final Color hoverBG1; //action item hover effect final Color hoverBG2; final Color hoverBG3; //the text color when it is hovered final Color hoverFG; final Color questionBubbleBG; final Color progressBarBGColor; //editor toolbar BG color final Color toolbarColor; final Color toggleButtonBGColor; final Color calendarWeekendBGColor; //grid bottom count color final Color gridRowCountColor; final Color borderColor; final Color scrollbarColor; final Color scrollbarHoverColor; final Color lightIconColor; final Color toolbarHoverColor; factory FlowyColorScheme.fromJson(Map json) => _$FlowyColorSchemeFromJson(json); Map toJson() => _$FlowyColorSchemeToJson(this); /// Merges the given [json] with the default color scheme /// based on the given [brightness]. /// factory FlowyColorScheme.fromJsonSoft( Map json, [ Brightness brightness = Brightness.light, ]) { final colorScheme = brightness == Brightness.light ? const DefaultColorScheme.light() : const DefaultColorScheme.dark(); final defaultMap = colorScheme.toJson(); final mergedMap = Map.from(defaultMap)..addAll(json); return FlowyColorScheme.fromJson(mergedMap); } /// Useful in validating that a teheme adheres to the default color scheme. /// Returns the keys that are missing from the [json]. /// /// We use this for testing and debugging, and we might make it possible for users to /// check their themes for missing keys in the future. /// /// Sample usage: /// ```dart /// final lightJson = await jsonDecode(await light.readAsString()); /// final lightMissingKeys = FlowyColorScheme.getMissingKeys(lightJson); /// ``` /// static List getMissingKeys(Map json) { final defaultKeys = const DefaultColorScheme.light().toJson().keys; final jsonKeys = json.keys; return defaultKeys.where((key) => !jsonKeys.contains(key)).toList(); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart ================================================ import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flutter/material.dart'; import 'colorscheme.dart'; const _black = Color(0xff000000); const _white = Color(0xFFFFFFFF); const _lightBg1 = Color(0xFFFFD13E); const _lightShader1 = Color(0xff333333); const _lightShader3 = Color(0xff828282); const _lightShader5 = Color(0xffe0e0e0); const _lightShader6 = Color(0xfff2f2f2); const _lightDandelionYellow = Color(0xffffcb00); const _lightDandelionLightYellow = Color(0xffffdf66); const _lightDandelionGreen = Color(0xff9bc53d); const _lightTint9 = Color(0xffe1fbff); const _darkShader1 = Color(0xff131720); const _darkShader2 = Color(0xff1A202C); const _darkShader3 = Color(0xff363D49); const _darkShader5 = Color(0xffBBC3CD); const _darkShader6 = Color(0xffF2F2F2); const _darkMain1 = Color(0xffffcb00); const _darkInput = Color(0xff282E3A); class DandelionColorScheme extends FlowyColorScheme { const DandelionColorScheme.light() : super( surface: Colors.white, hover: const Color(0xFFe0f8ff), // hover effect on setting value selector: _lightDandelionLightYellow, red: const Color(0xfffb006d), yellow: const Color(0xffffd667), green: const Color(0xff66cf80), shader1: const Color(0xff333333), shader2: const Color(0xff4f4f4f), shader3: const Color(0xff828282), // disable text color shader4: const Color(0xffbdbdbd), shader5: _lightShader5, shader6: const Color(0xfff2f2f2), shader7: _black, bg1: _lightBg1, bg2: const Color(0xffedeef2), // Hover color on trash button bg3: _lightDandelionYellow, bg4: const Color(0xff2c144b), tint1: const Color(0xffe8e0ff), tint2: const Color(0xffffe7fd), tint3: const Color(0xffffe7ee), tint4: const Color(0xffffefe3), tint5: const Color(0xfffff2cd), tint6: const Color(0xfff5ffdc), tint7: const Color(0xffddffd6), tint8: const Color(0xffdefff1), tint9: _lightTint9, main1: _lightDandelionYellow, // cursor color main2: _lightDandelionYellow, shadow: const Color.fromRGBO(0, 0, 0, 0.15), sidebarBg: _lightDandelionGreen, divider: _lightShader6, topbarBg: _white, icon: _lightShader1, text: _lightShader1, secondaryText: _lightShader1, strongText: Colors.black, input: _white, hint: _lightShader3, primary: _lightDandelionYellow, onPrimary: _lightShader1, // hover color in sidebar hoverBG1: _lightDandelionYellow, // tool bar hover color hoverBG2: _lightDandelionLightYellow, hoverBG3: _lightShader6, hoverFG: _lightShader1, questionBubbleBG: _lightDandelionLightYellow, progressBarBGColor: _lightTint9, toolbarColor: _lightShader1, toggleButtonBGColor: _lightDandelionYellow, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: _black, borderColor: ColorSchemeConstants.lightBorderColor, scrollbarColor: const Color(0x3F171717), scrollbarHoverColor: const Color(0x7F171717), lightIconColor: const Color(0xFF8F959E), toolbarHoverColor: const Color(0xFFF2F4F7), ); const DandelionColorScheme.dark() : super( surface: const Color(0xff292929), hover: const Color(0xff1f1f1f), selector: _darkShader2, red: const Color(0xfffb006d), yellow: const Color(0xffffd667), green: const Color(0xff66cf80), shader1: _white, shader2: _darkShader2, shader3: const Color(0xff828282), shader4: const Color(0xffbdbdbd), shader5: _darkShader5, shader6: _darkShader6, shader7: _white, bg1: const Color(0xFFD5A200), bg2: _black, bg3: _darkMain1, bg4: const Color(0xff2c144b), tint1: const Color(0x4d9327FF), tint2: const Color(0x66FC0088), tint3: const Color(0x4dFC00E2), tint4: const Color(0x80BE5B00), tint5: const Color(0x33F8EE00), tint6: const Color(0x4d6DC300), tint7: const Color(0x5900BD2A), tint8: const Color(0x80008890), tint9: const Color(0x4d0029FF), main1: _darkMain1, main2: _darkMain1, shadow: const Color(0xff0F131C), sidebarBg: const Color(0xff25300e), divider: _darkShader3, topbarBg: _darkShader1, icon: _darkShader5, text: _darkShader5, secondaryText: _darkShader5, strongText: Colors.white, input: _darkInput, hint: _darkShader5, primary: _darkMain1, onPrimary: _darkShader1, hoverBG1: _darkMain1, hoverBG2: _darkMain1, hoverBG3: _darkShader3, hoverFG: _darkShader1, questionBubbleBG: _darkShader3, progressBarBGColor: _darkShader3, toolbarColor: _darkInput, toggleButtonBGColor: _darkShader1, calendarWeekendBGColor: const Color(0xff121212), gridRowCountColor: _darkMain1, borderColor: ColorSchemeConstants.darkBorderColor, scrollbarColor: const Color(0x40FFFFFF), scrollbarHoverColor: const Color(0x80FFFFFF), lightIconColor: const Color(0xFF8F959E), toolbarHoverColor: _lightShader6, ); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart ================================================ import 'package:flutter/material.dart'; import 'colorscheme.dart'; class ColorSchemeConstants { static const white = Color(0xFFFFFFFF); static const lightHover = Color(0xFFe0f8FF); static const lightSelector = Color(0xFFf2fcFF); static const lightBg1 = Color(0xFFf7f8fc); static const lightBg2 = Color(0x0F1F2329); static const lightShader1 = Color(0xFF333333); static const lightShader3 = Color(0xFF828282); static const lightShader5 = Color(0xFFe0e0e0); static const lightShader6 = Color(0xFFf2f2f2); static const lightMain1 = Color(0xFF00bcf0); static const lightTint9 = Color(0xFFe1fbFF); static const darkShader1 = Color(0xFF131720); static const darkShader2 = Color(0xFF1A202C); static const darkShader3 = Color(0xFF363D49); static const darkShader5 = Color(0xFFBBC3CD); static const darkShader6 = Color(0xFFF2F2F2); static const darkMain1 = Color(0xFF00BCF0); static const darkMain2 = Color(0xFF00BCF0); static const darkInput = Color(0xFF282E3A); static const lightBorderColor = Color(0xFFEDEDEE); static const darkBorderColor = Color(0xFF3A3F49); } class DefaultColorScheme extends FlowyColorScheme { const DefaultColorScheme.light() : super( surface: ColorSchemeConstants.white, hover: ColorSchemeConstants.lightHover, selector: ColorSchemeConstants.lightSelector, red: const Color(0xFFfb006d), yellow: const Color(0xFFFFd667), green: const Color(0xFF66cf80), shader1: ColorSchemeConstants.lightShader1, shader2: const Color(0xFF4f4f4f), shader3: ColorSchemeConstants.lightShader3, shader4: const Color(0xFFbdbdbd), shader5: ColorSchemeConstants.lightShader5, shader6: ColorSchemeConstants.lightShader6, shader7: ColorSchemeConstants.lightShader1, bg1: ColorSchemeConstants.lightBg1, bg2: ColorSchemeConstants.lightBg2, bg3: const Color(0xFFe2e4eb), bg4: const Color(0xFF2c144b), tint1: const Color(0xFFe8e0FF), tint2: const Color(0xFFFFe7fd), tint3: const Color(0xFFFFe7ee), tint4: const Color(0xFFFFefe3), tint5: const Color(0xFFFFf2cd), tint6: const Color(0xFFf5FFdc), tint7: const Color(0xFFddFFd6), tint8: const Color(0xFFdeFFf1), tint9: ColorSchemeConstants.lightTint9, main1: ColorSchemeConstants.lightMain1, main2: const Color(0xFF00b7ea), shadow: const Color.fromRGBO(0, 0, 0, 0.15), sidebarBg: ColorSchemeConstants.lightBg1, divider: ColorSchemeConstants.lightShader6, topbarBg: ColorSchemeConstants.white, icon: ColorSchemeConstants.lightShader1, text: ColorSchemeConstants.lightShader1, secondaryText: const Color(0xFF4f4f4f), strongText: Colors.black, input: ColorSchemeConstants.white, hint: ColorSchemeConstants.lightShader3, primary: ColorSchemeConstants.lightMain1, onPrimary: ColorSchemeConstants.white, hoverBG1: ColorSchemeConstants.lightBg2, hoverBG2: ColorSchemeConstants.lightHover, hoverBG3: ColorSchemeConstants.lightShader6, hoverFG: ColorSchemeConstants.lightShader1, questionBubbleBG: ColorSchemeConstants.lightSelector, progressBarBGColor: ColorSchemeConstants.lightTint9, toolbarColor: ColorSchemeConstants.lightShader1, toggleButtonBGColor: ColorSchemeConstants.lightShader5, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: ColorSchemeConstants.lightShader1, borderColor: ColorSchemeConstants.lightBorderColor, scrollbarColor: const Color(0x3F171717), scrollbarHoverColor: const Color(0x7F171717), lightIconColor: const Color(0xFF8F959E), toolbarHoverColor: const Color(0xFFF2F4F7), ); const DefaultColorScheme.dark() : super( surface: ColorSchemeConstants.darkShader2, hover: ColorSchemeConstants.darkMain1, selector: ColorSchemeConstants.darkShader2, red: const Color(0xFFfb006d), yellow: const Color(0xFFF7CF46), green: const Color(0xFF66CF80), shader1: ColorSchemeConstants.darkShader1, shader2: ColorSchemeConstants.darkShader2, shader3: ColorSchemeConstants.darkShader3, shader4: const Color(0xFF505469), shader5: ColorSchemeConstants.darkShader5, shader6: ColorSchemeConstants.darkShader6, shader7: ColorSchemeConstants.white, bg1: const Color(0xFF1A202C), bg2: const Color(0xFFEDEEF2), bg3: ColorSchemeConstants.darkMain1, bg4: const Color(0xFF2C144B), tint1: const Color(0x4D502FD6), tint2: const Color(0x4DBF1CC0), tint3: const Color(0x4DC42A53), tint4: const Color(0x4DD77922), tint5: const Color(0x4DC59A1A), tint6: const Color(0x4DA4C824), tint7: const Color(0x4D23CA2E), tint8: const Color(0x4D19CCAC), tint9: const Color(0x4D04A9D7), main1: ColorSchemeConstants.darkMain2, main2: const Color(0xFF00B7EA), shadow: const Color(0xFF0F131C), sidebarBg: const Color(0xFF232B38), divider: ColorSchemeConstants.darkShader3, topbarBg: ColorSchemeConstants.darkShader1, icon: ColorSchemeConstants.darkShader5, text: ColorSchemeConstants.darkShader5, secondaryText: ColorSchemeConstants.darkShader5, strongText: Colors.white, input: ColorSchemeConstants.darkInput, hint: const Color(0xFF59647a), primary: ColorSchemeConstants.darkMain2, onPrimary: ColorSchemeConstants.darkShader1, hoverBG1: const Color(0x1AFFFFFF), hoverBG2: ColorSchemeConstants.darkMain1, hoverBG3: ColorSchemeConstants.darkShader3, hoverFG: const Color(0xE5FFFFFF), questionBubbleBG: ColorSchemeConstants.darkShader3, progressBarBGColor: ColorSchemeConstants.darkShader3, toolbarColor: ColorSchemeConstants.darkInput, toggleButtonBGColor: const Color(0xFF828282), calendarWeekendBGColor: ColorSchemeConstants.darkShader1, gridRowCountColor: ColorSchemeConstants.darkShader5, borderColor: ColorSchemeConstants.darkBorderColor, scrollbarColor: const Color(0x40FFFFFF), scrollbarHoverColor: const Color(0x80FFFFFF), lightIconColor: const Color(0xFF8F959E), toolbarHoverColor: ColorSchemeConstants.lightShader6, ); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart ================================================ import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flutter/material.dart'; import 'colorscheme.dart'; const _black = Color(0xff000000); const _white = Color(0xFFFFFFFF); const _lightHover = Color(0xffd8d6fc); const _lightSelector = Color(0xffe5e3f9); const _lightBg1 = Color(0xfff2f0f6); const _lightBg2 = Color(0xffd8d6fc); const _lightShader1 = Color(0xff333333); const _lightShader3 = Color(0xff828282); const _lightShader5 = Color(0xffe0e0e0); const _lightShader6 = Color(0xffd8d6fc); const _lightMain1 = Color(0xffaba9e7); const _lightTint9 = Color(0xffe1fbff); const _darkShader1 = Color(0xff131720); const _darkShader2 = Color(0xff1A202C); const _darkShader3 = Color(0xff363D49); const _darkShader5 = Color(0xffBBC3CD); const _darkShader6 = Color(0xffF2F2F2); const _darkMain1 = Color(0xffab00ff); const _darkInput = Color(0xff282E3A); class LavenderColorScheme extends FlowyColorScheme { const LavenderColorScheme.light() : super( surface: Colors.white, hover: _lightHover, selector: _lightSelector, red: const Color(0xfffb006d), yellow: const Color(0xffffd667), green: const Color(0xff66cf80), shader1: const Color(0xff333333), shader2: const Color(0xff4f4f4f), shader3: const Color(0xff828282), shader4: const Color(0xffbdbdbd), shader5: _lightShader5, shader6: const Color(0xfff2f2f2), shader7: _black, bg1: const Color(0xffAC59FF), bg2: const Color(0xffedeef2), bg3: _lightHover, bg4: const Color(0xff2c144b), tint1: const Color(0xffe8e0ff), tint2: const Color(0xffffe7fd), tint3: const Color(0xffffe7ee), tint4: const Color(0xffffefe3), tint5: const Color(0xfffff2cd), tint6: const Color(0xfff5ffdc), tint7: const Color(0xffddffd6), tint8: const Color(0xffdefff1), tint9: _lightMain1, main1: _lightMain1, main2: _lightMain1, shadow: const Color.fromRGBO(0, 0, 0, 0.15), sidebarBg: _lightBg1, divider: _lightShader6, topbarBg: _white, icon: _lightShader1, text: _lightShader1, secondaryText: _lightShader1, strongText: Colors.black, input: _white, hint: _lightShader3, primary: _lightMain1, onPrimary: _lightShader1, hoverBG1: _lightBg2, hoverBG2: _lightHover, hoverBG3: _lightShader6, hoverFG: _lightShader1, questionBubbleBG: _lightSelector, progressBarBGColor: _lightTint9, toolbarColor: _lightShader1, toggleButtonBGColor: _lightSelector, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: _black, borderColor: ColorSchemeConstants.lightBorderColor, scrollbarColor: const Color(0x3F171717), scrollbarHoverColor: const Color(0x7F171717), lightIconColor: const Color(0xFF8F959E), toolbarHoverColor: const Color(0xFFF2F4F7), ); const LavenderColorScheme.dark() : super( surface: const Color(0xFF1B1A1D), hover: _darkMain1, selector: _darkShader2, red: const Color(0xfffb006d), yellow: const Color(0xffffd667), green: const Color(0xff66cf80), shader1: _white, shader2: _darkShader2, shader3: const Color(0xff828282), shader4: const Color(0xffbdbdbd), shader5: _white, shader6: _darkShader6, shader7: _white, bg1: const Color(0xff8C23F6), bg2: _black, bg3: _darkMain1, bg4: const Color(0xff2c144b), tint1: const Color(0x4d9327FF), tint2: const Color(0x66FC0088), tint3: const Color(0x4dFC00E2), tint4: const Color(0x80BE5B00), tint5: const Color(0x33F8EE00), tint6: const Color(0x4d6DC300), tint7: const Color(0x5900BD2A), tint8: const Color(0x80008890), tint9: const Color(0x4d0029FF), main1: _darkMain1, main2: _darkMain1, shadow: const Color(0xff0F131C), sidebarBg: const Color(0xff2D223B), divider: _darkShader3, topbarBg: _darkShader1, icon: _darkShader5, text: _darkShader5, secondaryText: _darkShader5, strongText: Colors.white, input: _darkInput, hint: _darkShader5, primary: _darkMain1, onPrimary: _darkShader1, hoverBG1: _darkMain1, hoverBG2: _darkMain1, hoverBG3: _darkShader3, hoverFG: _darkShader1, questionBubbleBG: _darkShader3, progressBarBGColor: _darkShader3, toolbarColor: _darkInput, toggleButtonBGColor: _darkShader1, calendarWeekendBGColor: const Color(0xff121212), gridRowCountColor: _darkMain1, borderColor: ColorSchemeConstants.darkBorderColor, scrollbarColor: const Color(0x40FFFFFF), scrollbarHoverColor: const Color(0x80FFFFFF), lightIconColor: const Color(0xFF8F959E), toolbarHoverColor: _lightShader6, ); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart ================================================ import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flutter/material.dart'; import 'colorscheme.dart'; const _black = Color(0xff000000); const _white = Color(0xFFFFFFFF); const _lightBg1 = Color(0xFFFFD13E); const _lightShader1 = Color(0xff333333); const _lightShader3 = Color(0xff828282); const _lightShader5 = Color(0xffe0e0e0); const _lightShader6 = Color(0xfff2f2f2); const _lightDandelionYellow = Color(0xffffcb00); const _lightDandelionLightYellow = Color(0xffffdf66); const _lightTint9 = Color(0xffe1fbff); const _darkShader1 = Color(0xff131720); const _darkShader2 = Color(0xff1A202C); const _darkShader3 = Color(0xff363D49); const _darkShader5 = Color(0xffBBC3CD); const _darkShader6 = Color(0xffF2F2F2); const _darkMain1 = Color(0xffffcb00); const _darkInput = Color(0xff282E3A); // Derive from [DandelionColorScheme] // Use a light yellow color in the sidebar intead of a green color in Dandelion // Some field name are still included 'Dandelion' to indicate they are the same color as the one in Dandelion class LemonadeColorScheme extends FlowyColorScheme { const LemonadeColorScheme.light() : super( surface: Colors.white, hover: const Color(0xFFe0f8ff), // hover effect on setting value selector: _lightDandelionLightYellow, red: const Color(0xfffb006d), yellow: const Color(0xffffd667), green: const Color(0xff66cf80), shader1: const Color(0xff333333), shader2: const Color(0xff4f4f4f), shader3: const Color(0xff828282), // disable text color shader4: const Color(0xffbdbdbd), shader5: _lightShader5, shader6: const Color(0xfff2f2f2), shader7: _black, bg1: _lightBg1, bg2: const Color(0xffedeef2), // Hover color on trash button bg3: _lightDandelionYellow, bg4: const Color(0xff2c144b), tint1: const Color(0xffe8e0ff), tint2: const Color(0xffffe7fd), tint3: const Color(0xffffe7ee), tint4: const Color(0xffffefe3), tint5: const Color(0xfffff2cd), tint6: const Color(0xfff5ffdc), tint7: const Color(0xffddffd6), tint8: const Color(0xffdefff1), tint9: _lightTint9, main1: _lightDandelionYellow, // cursor color main2: _lightDandelionYellow, shadow: const Color.fromRGBO(0, 0, 0, 0.15), sidebarBg: const Color(0xfffaf0c8), divider: _lightShader6, topbarBg: _white, icon: _lightShader1, text: _lightShader1, secondaryText: _lightShader1, strongText: Colors.black, input: _white, hint: _lightShader3, primary: _lightDandelionYellow, onPrimary: _lightShader1, // hover color in sidebar hoverBG1: _lightDandelionYellow, // tool bar hover color hoverBG2: _lightDandelionLightYellow, hoverBG3: _lightShader6, hoverFG: _lightShader1, questionBubbleBG: _lightDandelionLightYellow, progressBarBGColor: _lightTint9, toolbarColor: _lightShader1, toggleButtonBGColor: _lightDandelionYellow, calendarWeekendBGColor: const Color(0xFFFBFBFC), gridRowCountColor: _black, borderColor: ColorSchemeConstants.lightBorderColor, scrollbarColor: const Color(0x3F171717), scrollbarHoverColor: const Color(0x7F171717), lightIconColor: const Color(0xFF8F959E), toolbarHoverColor: const Color(0xFFF2F4F7), ); const LemonadeColorScheme.dark() : super( surface: const Color(0xff292929), hover: const Color(0xff1f1f1f), selector: _darkShader2, red: const Color(0xfffb006d), yellow: const Color(0xffffd667), green: const Color(0xff66cf80), shader1: _white, shader2: _darkShader2, shader3: const Color(0xff828282), shader4: const Color(0xffbdbdbd), shader5: _darkShader5, shader6: _darkShader6, shader7: _white, bg1: const Color(0xFFD5A200), bg2: _black, bg3: _darkMain1, bg4: const Color(0xff2c144b), tint1: const Color(0x4d9327FF), tint2: const Color(0x66FC0088), tint3: const Color(0x4dFC00E2), tint4: const Color(0x80BE5B00), tint5: const Color(0x33F8EE00), tint6: const Color(0x4d6DC300), tint7: const Color(0x5900BD2A), tint8: const Color(0x80008890), tint9: const Color(0x4d0029FF), main1: _darkMain1, main2: _darkMain1, shadow: _black, sidebarBg: const Color(0xff232B38), divider: _darkShader3, topbarBg: _darkShader1, icon: _darkShader5, text: _darkShader5, secondaryText: _darkShader5, strongText: Colors.white, input: _darkInput, hint: _darkShader5, primary: _darkMain1, onPrimary: _darkShader1, hoverBG1: _darkMain1, hoverBG2: _darkMain1, hoverBG3: _darkShader3, hoverFG: _darkShader1, questionBubbleBG: _darkShader3, progressBarBGColor: _darkShader3, toolbarColor: _darkInput, toggleButtonBGColor: _darkShader1, calendarWeekendBGColor: const Color(0xff121212), gridRowCountColor: _darkMain1, borderColor: ColorSchemeConstants.darkBorderColor, scrollbarColor: const Color(0x40FFFFFF), scrollbarHoverColor: const Color(0x80FFFFFF), lightIconColor: const Color(0xFF8F959E), toolbarHoverColor: _lightShader6); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart ================================================ import 'package:flutter/services.dart'; import 'package:file_picker/file_picker.dart' as fp; import 'package:flowy_infra/file_picker/file_picker_service.dart'; class FilePicker implements FilePickerService { @override Future getDirectoryPath({String? title}) { return fp.FilePicker.platform.getDirectoryPath(); } @override Future pickFiles({ String? dialogTitle, String? initialDirectory, fp.FileType type = fp.FileType.any, List? allowedExtensions, Function(fp.FilePickerStatus p1)? onFileLoading, bool allowCompression = true, bool allowMultiple = false, bool withData = false, bool withReadStream = false, bool lockParentWindow = false, }) async { final result = await fp.FilePicker.platform.pickFiles( dialogTitle: dialogTitle, initialDirectory: initialDirectory, type: type, allowedExtensions: allowedExtensions, onFileLoading: onFileLoading, allowCompression: allowCompression, allowMultiple: allowMultiple, withData: withData, withReadStream: withReadStream, lockParentWindow: lockParentWindow, ); return FilePickerResult(result?.files ?? []); } /// On Desktop it will return the path to which the file should be saved. /// /// On Mobile it will return the path to where the file has been saved, and will /// automatically save it. The [bytes] parameter is required on Mobile. /// @override Future saveFile({ String? dialogTitle, String? fileName, String? initialDirectory, FileType type = FileType.any, List? allowedExtensions, bool lockParentWindow = false, Uint8List? bytes, }) async { final result = await fp.FilePicker.platform.saveFile( dialogTitle: dialogTitle, fileName: fileName, initialDirectory: initialDirectory, type: type, allowedExtensions: allowedExtensions, lockParentWindow: lockParentWindow, bytes: bytes, ); return result; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart ================================================ import 'package:file_picker/file_picker.dart'; export 'package:file_picker/file_picker.dart' show FileType, FilePickerStatus, PlatformFile; class FilePickerResult { const FilePickerResult(this.files); /// Picked files. final List files; } /// Abstract file picker as a service to implement dependency injection. abstract class FilePickerService { Future getDirectoryPath({ String? title, }) async => throw UnimplementedError('getDirectoryPath() has not been implemented.'); Future pickFiles({ String? dialogTitle, String? initialDirectory, FileType type = FileType.any, List? allowedExtensions, Function(FilePickerStatus)? onFileLoading, bool allowCompression = true, bool allowMultiple = false, bool withData = false, bool withReadStream = false, bool lockParentWindow = false, }) async => throw UnimplementedError('pickFiles() has not been implemented.'); Future saveFile({ String? dialogTitle, String? fileName, String? initialDirectory, FileType type = FileType.any, List? allowedExtensions, bool lockParentWindow = false, }) async => throw UnimplementedError('saveFile() has not been implemented.'); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/icon_data.dart ================================================ /// Flutter icons FlowyIconData /// Copyright (C) 2021 by original authors @ fluttericon.com, fontello.com /// This font was generated by FlutterIcon.com, which is derived from Fontello. /// /// To use this font, place it in your fonts/ directory and include the /// following in your pubspec.yaml /// /// flutter: /// fonts: /// - family: FlowyIconData /// fonts: /// - asset: fonts/FlowyIconData.ttf /// /// /// library; // ignore_for_file: constant_identifier_names import 'package:flutter/widgets.dart'; class FlowyIconData { FlowyIconData._(); static const _kFontFam = 'FlowyIconData'; static const String? _kFontPkg = null; static const IconData drop_down_hide = IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData drop_down_show = IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart ================================================ import 'package:flutter/material.dart'; String languageFromLocale(Locale locale) { switch (locale.languageCode) { // Most often used languages case "en": switch (locale.countryCode) { case "GB": return "English (GB)"; case "US": return "English (US)"; default: return "English (US)"; } case "zh": switch (locale.countryCode) { case "CN": return "简体中文"; case "TW": return "繁體中文"; default: return locale.languageCode; } // Then in alphabetical order case "am": return "አማርኛ"; case "ar": return "العربية"; case "ca": return "Català"; case "cs": return "Čeština"; case "ckb": switch (locale.countryCode) { case "KU": return "کوردی سۆرانی"; default: return locale.languageCode; } case "de": return "Deutsch"; case "es": return "Español"; case "eu": return "Euskera"; case "el": return "Ελληνικά"; case "fr": switch (locale.countryCode) { case "CA": return "Français (CA)"; case "FR": return "Français (FR)"; default: return locale.languageCode; } case "mr": return "मराठी"; case "he": return "עברית"; case "hu": return "Magyar"; case "id": return "Bahasa Indonesia"; case "it": return "Italiano"; case "ja": return "日本語"; case "ko": return "한국어"; case "pl": return "Polski"; case "pt": return "Português"; case "ru": return "русский"; case "sv": return "Svenska"; case "th": return "ไทย"; case "tr": return "Türkçe"; case "fa": return "فارسی"; case "uk": return "українська"; case "ur": return "اردو"; case "hin": return "हिन्दी"; } // If not found then the language code will be displayed return locale.languageCode; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/notifier.dart ================================================ import 'package:flutter/material.dart'; abstract class Comparable { bool compare(T? previous, T? current); } class ObjectComparable extends Comparable { @override bool compare(T? previous, T? current) { return previous == current; } } class PublishNotifier extends ChangeNotifier { T? _value; Comparable? comparable = ObjectComparable(); PublishNotifier({this.comparable}); set value(T newValue) { if (comparable != null) { if (comparable!.compare(_value, newValue)) { _value = newValue; notifyListeners(); } } else { _value = newValue; notifyListeners(); } } T? get currentValue => _value; void addPublishListener(void Function(T) callback, {bool Function()? listenWhen}) { super.addListener( () { if (_value == null) { return; } else {} if (listenWhen != null && listenWhen() == false) { return; } callback(_value as T); }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/platform_extension.dart ================================================ import 'dart:io'; import 'package:flutter/foundation.dart'; extension PlatformExtension on Platform { /// Returns true if the operating system is macOS and not running on Web platform. static bool get isMacOS { if (kIsWeb) { return false; } return Platform.isMacOS; } /// Returns true if the operating system is Windows and not running on Web platform. static bool get isWindows { if (kIsWeb) { return false; } return Platform.isWindows; } /// Returns true if the operating system is Linux and not running on Web platform. static bool get isLinux { if (kIsWeb) { return false; } return Platform.isLinux; } static bool get isDesktopOrWeb { if (kIsWeb) { return true; } return isDesktop; } static bool get isDesktop { if (kIsWeb) { return false; } return Platform.isWindows || Platform.isLinux || Platform.isMacOS; } static bool get isMobile { if (kIsWeb) { return false; } return Platform.isAndroid || Platform.isIOS; } static bool get isNotMobile { if (kIsWeb) { return false; } return !isMobile; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_bloc.dart ================================================ import 'package:bloc/bloc.dart'; import 'package:flowy_infra/plugins/service/models/exceptions.dart'; import 'package:flowy_infra/plugins/service/plugin_service.dart'; import '../../file_picker/file_picker_impl.dart'; import 'dynamic_plugin_event.dart'; import 'dynamic_plugin_state.dart'; class DynamicPluginBloc extends Bloc { DynamicPluginBloc({FilePicker? filePicker}) : super(const DynamicPluginState.uninitialized()) { on(dispatch); add(DynamicPluginEvent.load()); } Future dispatch( DynamicPluginEvent event, Emitter emit) async { await event.when( addPlugin: () => addPlugin(emit), removePlugin: (name) => removePlugin(emit, name), load: () => onLoadRequested(emit), ); } Future onLoadRequested(Emitter emit) async { emit( DynamicPluginState.ready( plugins: await FlowyPluginService.instance.plugins, ), ); } Future addPlugin(Emitter emit) async { emit(const DynamicPluginState.processing()); try { final plugin = await FlowyPluginService.pick(); if (plugin == null) { return emit( DynamicPluginState.ready( plugins: await FlowyPluginService.instance.plugins, ), ); } await FlowyPluginService.instance.addPlugin(plugin); } on PluginCompilationException catch (exception) { return emit( DynamicPluginState.compilationFailure(errorMessage: exception.message), ); } emit(const DynamicPluginState.compilationSuccess()); emit( DynamicPluginState.ready( plugins: await FlowyPluginService.instance.plugins, ), ); } Future removePlugin( Emitter emit, String name, ) async { emit(const DynamicPluginState.processing()); final plugin = await FlowyPluginService.instance.lookup(name: name); if (plugin == null) { return emit( DynamicPluginState.ready( plugins: await FlowyPluginService.instance.plugins, ), ); } await FlowyPluginService.removePlugin(plugin); emit(const DynamicPluginState.deletionSuccess()); emit( DynamicPluginState.ready( plugins: await FlowyPluginService.instance.plugins, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_event.dart ================================================ import 'package:freezed_annotation/freezed_annotation.dart'; part 'dynamic_plugin_event.freezed.dart'; @freezed class DynamicPluginEvent with _$DynamicPluginEvent { factory DynamicPluginEvent.addPlugin() = _AddPlugin; factory DynamicPluginEvent.removePlugin({required String name}) = _RemovePlugin; factory DynamicPluginEvent.load() = _Load; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_state.dart ================================================ import 'package:freezed_annotation/freezed_annotation.dart'; import '../service/models/flowy_dynamic_plugin.dart'; part 'dynamic_plugin_state.freezed.dart'; @freezed class DynamicPluginState with _$DynamicPluginState { const factory DynamicPluginState.uninitialized() = _Uninitialized; const factory DynamicPluginState.ready({ required Iterable plugins, }) = Ready; const factory DynamicPluginState.processing() = _Processing; const factory DynamicPluginState.compilationFailure( {required String errorMessage}) = _CompilationFailure; const factory DynamicPluginState.deletionFailure({ required String path, }) = _DeletionFailure; const factory DynamicPluginState.deletionSuccess() = _DeletionSuccess; const factory DynamicPluginState.compilationSuccess() = _CompilationSuccess; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/location_service.dart ================================================ import 'dart:io'; class PluginLocationService { const PluginLocationService({ required Future fallback, }) : _fallback = fallback; final Future _fallback; Future get fallback async => _fallback; Future get location async => fallback; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/exceptions.dart ================================================ class PluginCompilationException implements Exception { final String message; PluginCompilationException(this.message); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/flowy_dynamic_plugin.dart ================================================ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:file/memory.dart'; import 'package:flowy_infra/colorscheme/colorscheme.dart'; import 'package:flowy_infra/plugins/service/models/exceptions.dart'; import 'package:flowy_infra/theme.dart'; import 'package:path/path.dart' as p; import 'plugin_type.dart'; typedef DynamicPluginLibrary = Iterable; /// A class that encapsulates dynamically loaded plugins for AppFlowy. /// /// This class can be modified to support loading node widget builders and other /// plugins that are dynamically loaded at runtime for the editor. For now, /// it only supports loading app themes. class FlowyDynamicPlugin { FlowyDynamicPlugin._({ required String name, required String path, this.theme, }) : _name = name, _path = path; /// The plugins should be loaded into a folder with the extension `.flowy_plugin`. static bool isPlugin(FileSystemEntity entity) => entity is Directory && p.extension(entity.path).contains(ext); /// The extension for the plugin folder. static const String ext = 'flowy_plugin'; static String get lightExtension => ['light', 'json'].join('.'); static String get darkExtension => ['dark', 'json'].join('.'); String get name => _name; late final String _name; String get _fsPluginName => [name, ext].join('.'); final AppTheme? theme; final String _path; Directory get source { return Directory(_path); } /// Loads and "compiles" loaded plugins. /// /// If the plugin loaded does not contain the `.flowy_plugin` extension, this /// this method will throw an error. Likewise, if the plugin does not follow /// the expected format, this method will throw an error. static Future decode({required Directory src}) async { // throw an error if the plugin does not follow the proper format. if (!isPlugin(src)) { throw PluginCompilationException( 'The plugin directory must have the extension `.flowy_plugin`.', ); } // throws an error if the plugin does not follow the proper format. final type = PluginType.from(src: src); switch (type) { case PluginType.theme: return _theme(src: src); } } /// Encodes the plugin in memory. The Directory given is not the actual /// directory on the file system, but rather a virtual directory in memory. /// /// Instances of this class should always have a path on disk, otherwise a /// compilation error will be thrown during the construction of this object. Future encode() async { final fs = MemoryFileSystem(); final directory = fs.directory(_fsPluginName)..createSync(); final lightThemeFileName = '$name.$lightExtension'; directory.childFile(lightThemeFileName).createSync(); directory .childFile(lightThemeFileName) .writeAsStringSync(jsonEncode(theme!.lightTheme.toJson())); final darkThemeFileName = '$name.$darkExtension'; directory.childFile(darkThemeFileName).createSync(); directory .childFile(darkThemeFileName) .writeAsStringSync(jsonEncode(theme!.darkTheme.toJson())); return directory; } /// Theme plugins should have the following format. /// > directory.flowy_plugin // plugin root /// > - theme.light.json // the light theme /// > - theme.dark.json // the dark theme /// /// If the theme does not adhere to that format, it is considered an error. static Future _theme({required Directory src}) async { late final String name; try { name = p.basenameWithoutExtension(src.path).split('.').first; } catch (e) { throw PluginCompilationException( 'The theme plugin does not adhere to the following format: `.flowy_plugin`.', ); } final light = src .listSync() .where((event) => event is File && p.basename(event.path).contains(lightExtension)) .first as File; final dark = src .listSync() .where((event) => event is File && p.basename(event.path).contains(darkExtension)) .first as File; late final FlowyColorScheme lightTheme; late final FlowyColorScheme darkTheme; try { lightTheme = FlowyColorScheme.fromJsonSoft( await jsonDecode(await light.readAsString()), ); } catch (e) { throw PluginCompilationException( 'The light theme json file is not valid.', ); } try { darkTheme = FlowyColorScheme.fromJsonSoft( await jsonDecode(await dark.readAsString()), Brightness.dark, ); } catch (e) { throw PluginCompilationException( 'The dark theme json file is not valid.', ); } final theme = AppTheme( themeName: name, builtIn: false, lightTheme: lightTheme, darkTheme: darkTheme, ); return FlowyDynamicPlugin._( name: theme.themeName, path: src.path, theme: theme, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/plugin_type.dart ================================================ import 'dart:io'; import 'package:flowy_infra/plugins/service/models/exceptions.dart'; import 'package:flowy_infra/plugins/service/models/flowy_dynamic_plugin.dart'; import 'package:path/path.dart' as p; enum PluginType { theme._(); const PluginType._(); factory PluginType.from({required Directory src}) { if (_isTheme(src)) { return PluginType.theme; } throw PluginCompilationException( 'Could not determine the plugin type from source `$src`.'); } static bool _isTheme(Directory plugin) { final files = plugin.listSync(); return files.any((entity) => entity is File && p .basename(entity.path) .endsWith(FlowyDynamicPlugin.lightExtension)) && files.any((entity) => entity is File && p.basename(entity.path).endsWith(FlowyDynamicPlugin.darkExtension)); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/plugin_service.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'location_service.dart'; import 'models/flowy_dynamic_plugin.dart'; /// A service to maintain the state of the plugins for AppFlowy. class FlowyPluginService { FlowyPluginService._(); static final FlowyPluginService _instance = FlowyPluginService._(); static FlowyPluginService get instance => _instance; PluginLocationService _locationService = PluginLocationService( fallback: getApplicationDocumentsDirectory(), ); void setLocation(PluginLocationService locationService) => _locationService = locationService; Future> get _targets async { final location = await _locationService.location; final targets = location.listSync().where(FlowyDynamicPlugin.isPlugin); return targets.map((entity) => entity as Directory).toList(); } /// Searches the [PluginLocationService.location] for plugins and compiles them. Future get plugins async { final List compiled = []; for (final src in await _targets) { final plugin = await FlowyDynamicPlugin.decode(src: src); compiled.add(plugin); } return compiled; } /// Chooses a plugin from the file system using FilePickerService and tries to compile it. /// /// If the operation is cancelled or the plugin is invalid, this method will return null. static Future pick({FilePicker? service}) async { service ??= FilePicker(); final result = await service.getDirectoryPath(); if (result == null) { return null; } final directory = Directory(result); return FlowyDynamicPlugin.decode(src: directory); } /// Searches the plugin registry for a plugin with the given name. Future lookup({required String name}) async { final library = await plugins; return library // cast to nullable type to allow return of null if not found. .cast() // null assert is fine here because the original list was non-nullable .firstWhere((plugin) => plugin!.name == name, orElse: () => null); } /// Adds a plugin to the registry. To construct a [FlowyDynamicPlugin] /// use [FlowyDynamicPlugin.encode()] Future addPlugin(FlowyDynamicPlugin plugin) async { // try to compile the plugin before we add it to the registry. final source = await plugin.encode(); // add the plugin to the registry final destionation = [ (await _locationService.location).path, p.basename(source.path), ].join(Platform.pathSeparator); _copyDirectorySync(source, Directory(destionation)); } /// Removes a plugin from the registry. static Future removePlugin(FlowyDynamicPlugin plugin) async { final target = plugin.source; await target.delete(recursive: true); } static void _copyDirectorySync(Directory source, Directory destination) { if (!destination.existsSync()) { destination.createSync(recursive: true); } for (final child in source.listSync(recursive: false)) { final newPath = p.join(destination.path, p.basename(child.path)); if (child is File) { File(newPath) ..createSync(recursive: true) ..writeAsStringSync(child.readAsStringSync()); } else if (child is Directory) { _copyDirectorySync(child, Directory(newPath)); } } } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart ================================================ import 'package:flutter/material.dart'; class PageBreaks { static double get largePhone => 550; static double get tabletPortrait => 768; static double get tabletLandscape => 1024; static double get desktop => 1440; } class Insets { /// Dynamic insets, may get scaled with the device size static double scale = 1; static double get xs => 2 * scale; static double get sm => 6 * scale; static double get m => 12 * scale; static double get l => 24 * scale; static double get xl => 36 * scale; static double get xxl => 64 * scale; static double get xxxl => 80 * scale; } class FontSizes { static double get scale => 1; static double get s11 => 11 * scale; static double get s12 => 12 * scale; static double get s14 => 14 * scale; static double get s16 => 16 * scale; static double get s18 => 18 * scale; static double get s20 => 20 * scale; static double get s24 => 24 * scale; static double get s32 => 32 * scale; static double get s44 => 44 * scale; } class Sizes { static double hitScale = 1; static double get hit => 40 * hitScale; static double get iconMed => 20; } class Corners { static const BorderRadius s3Border = BorderRadius.all(s3Radius); static const Radius s3Radius = Radius.circular(3); static const BorderRadius s4Border = BorderRadius.all(s4Radius); static const Radius s4Radius = Radius.circular(4); static const BorderRadius s5Border = BorderRadius.all(s5Radius); static const Radius s5Radius = Radius.circular(5); static const BorderRadius s6Border = BorderRadius.all(s6Radius); static const Radius s6Radius = Radius.circular(6); static const BorderRadius s8Border = BorderRadius.all(s8Radius); static const Radius s8Radius = Radius.circular(8); static const BorderRadius s10Border = BorderRadius.all(s10Radius); static const Radius s10Radius = Radius.circular(10); static const BorderRadius s12Border = BorderRadius.all(s12Radius); static const Radius s12Radius = Radius.circular(12); static const BorderRadius s16Border = BorderRadius.all(s16Radius); static const Radius s16Radius = Radius.circular(16); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/theme.dart ================================================ import 'package:flowy_infra/colorscheme/colorscheme.dart'; import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'plugins/service/plugin_service.dart'; class BuiltInTheme { static const String defaultTheme = 'Default'; static const String dandelion = 'Dandelion'; static const String lemonade = 'Lemonade'; static const String lavender = 'Lavender'; } class AppTheme { // metadata member final bool builtIn; final String themeName; final FlowyColorScheme lightTheme; final FlowyColorScheme darkTheme; // static final Map _cachedJsonData = {}; const AppTheme({ required this.builtIn, required this.themeName, required this.lightTheme, required this.darkTheme, }); static const AppTheme fallback = AppTheme( builtIn: true, themeName: BuiltInTheme.defaultTheme, lightTheme: DefaultColorScheme.light(), darkTheme: DefaultColorScheme.dark(), ); static Future> _plugins(FlowyPluginService service) async { final plugins = await service.plugins; return plugins.map((plugin) => plugin.theme).whereType(); } static Iterable get builtins => themeMap.entries .map( (entry) => AppTheme( builtIn: true, themeName: entry.key, lightTheme: entry.value[0], darkTheme: entry.value[1], ), ) .toList(); static Future> themes(FlowyPluginService service) async => [ ...builtins, ...(await _plugins(service)), ]; static Future fromName( String themeName, { FlowyPluginService? pluginService, }) async { pluginService ??= FlowyPluginService.instance; for (final theme in await themes(pluginService)) { if (theme.themeName == themeName) { return theme; } } throw ArgumentError('The theme $themeName does not exist.'); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart ================================================ import 'package:flutter/material.dart'; @immutable class AFThemeExtension extends ThemeExtension { static AFThemeExtension of(BuildContext context) => Theme.of(context).extension()!; static AFThemeExtension? maybeOf(BuildContext context) => Theme.of(context).extension(); const AFThemeExtension({ required this.warning, required this.success, required this.tint1, required this.tint2, required this.tint3, required this.tint4, required this.tint5, required this.tint6, required this.tint7, required this.tint8, required this.tint9, required this.greyHover, required this.greySelect, required this.lightGreyHover, required this.toggleOffFill, required this.textColor, required this.secondaryTextColor, required this.strongText, required this.calloutBGColor, required this.tableCellBGColor, required this.calendarWeekendBGColor, required this.code, required this.callout, required this.caption, required this.progressBarBGColor, required this.toggleButtonBGColor, required this.gridRowCountColor, required this.background, required this.onBackground, required this.borderColor, required this.scrollbarColor, required this.scrollbarHoverColor, required this.toolbarHoverColor, required this.lightIconColor, }); final Color? warning; final Color? success; final Color tint1; final Color tint2; final Color tint3; final Color tint4; final Color tint5; final Color tint6; final Color tint7; final Color tint8; final Color tint9; final Color textColor; final Color secondaryTextColor; final Color strongText; final Color greyHover; final Color greySelect; final Color lightGreyHover; final Color toggleOffFill; final Color progressBarBGColor; final Color toggleButtonBGColor; final Color calloutBGColor; final Color tableCellBGColor; final Color calendarWeekendBGColor; final Color gridRowCountColor; final TextStyle code; final TextStyle callout; final TextStyle caption; final Color background; final Color onBackground; /// The color of the border of the widget. /// /// This is used in the divider, outline border, etc. final Color borderColor; final Color scrollbarColor; final Color scrollbarHoverColor; final Color toolbarHoverColor; final Color lightIconColor; @override AFThemeExtension copyWith({ Color? warning, Color? success, Color? tint1, Color? tint2, Color? tint3, Color? tint4, Color? tint5, Color? tint6, Color? tint7, Color? tint8, Color? tint9, Color? textColor, Color? secondaryTextColor, Color? strongText, Color? calloutBGColor, Color? tableCellBGColor, Color? greyHover, Color? greySelect, Color? lightGreyHover, Color? toggleOffFill, Color? progressBarBGColor, Color? toggleButtonBGColor, Color? calendarWeekendBGColor, Color? gridRowCountColor, TextStyle? code, TextStyle? callout, TextStyle? caption, Color? background, Color? onBackground, Color? borderColor, Color? scrollbarColor, Color? scrollbarHoverColor, Color? lightIconColor, Color? toolbarHoverColor, }) => AFThemeExtension( warning: warning ?? this.warning, success: success ?? this.success, tint1: tint1 ?? this.tint1, tint2: tint2 ?? this.tint2, tint3: tint3 ?? this.tint3, tint4: tint4 ?? this.tint4, tint5: tint5 ?? this.tint5, tint6: tint6 ?? this.tint6, tint7: tint7 ?? this.tint7, tint8: tint8 ?? this.tint8, tint9: tint9 ?? this.tint9, textColor: textColor ?? this.textColor, secondaryTextColor: secondaryTextColor ?? this.secondaryTextColor, strongText: strongText ?? this.strongText, calloutBGColor: calloutBGColor ?? this.calloutBGColor, tableCellBGColor: tableCellBGColor ?? this.tableCellBGColor, greyHover: greyHover ?? this.greyHover, greySelect: greySelect ?? this.greySelect, lightGreyHover: lightGreyHover ?? this.lightGreyHover, toggleOffFill: toggleOffFill ?? this.toggleOffFill, progressBarBGColor: progressBarBGColor ?? this.progressBarBGColor, toggleButtonBGColor: toggleButtonBGColor ?? this.toggleButtonBGColor, calendarWeekendBGColor: calendarWeekendBGColor ?? this.calendarWeekendBGColor, gridRowCountColor: gridRowCountColor ?? this.gridRowCountColor, code: code ?? this.code, callout: callout ?? this.callout, caption: caption ?? this.caption, onBackground: onBackground ?? this.onBackground, background: background ?? this.background, borderColor: borderColor ?? this.borderColor, scrollbarColor: scrollbarColor ?? this.scrollbarColor, scrollbarHoverColor: scrollbarHoverColor ?? this.scrollbarHoverColor, lightIconColor: lightIconColor ?? this.lightIconColor, toolbarHoverColor: toolbarHoverColor ?? this.toolbarHoverColor, ); @override ThemeExtension lerp( ThemeExtension? other, double t) { if (other is! AFThemeExtension) { return this; } return AFThemeExtension( warning: Color.lerp(warning, other.warning, t), success: Color.lerp(success, other.success, t), tint1: Color.lerp(tint1, other.tint1, t)!, tint2: Color.lerp(tint2, other.tint2, t)!, tint3: Color.lerp(tint3, other.tint3, t)!, tint4: Color.lerp(tint4, other.tint4, t)!, tint5: Color.lerp(tint5, other.tint5, t)!, tint6: Color.lerp(tint6, other.tint6, t)!, tint7: Color.lerp(tint7, other.tint7, t)!, tint8: Color.lerp(tint8, other.tint8, t)!, tint9: Color.lerp(tint9, other.tint9, t)!, textColor: Color.lerp(textColor, other.textColor, t)!, secondaryTextColor: Color.lerp( secondaryTextColor, other.secondaryTextColor, t, )!, strongText: Color.lerp( strongText, other.strongText, t, )!, calloutBGColor: Color.lerp(calloutBGColor, other.calloutBGColor, t)!, tableCellBGColor: Color.lerp(tableCellBGColor, other.tableCellBGColor, t)!, greyHover: Color.lerp(greyHover, other.greyHover, t)!, greySelect: Color.lerp(greySelect, other.greySelect, t)!, lightGreyHover: Color.lerp(lightGreyHover, other.lightGreyHover, t)!, toggleOffFill: Color.lerp(toggleOffFill, other.toggleOffFill, t)!, progressBarBGColor: Color.lerp(progressBarBGColor, other.progressBarBGColor, t)!, toggleButtonBGColor: Color.lerp(toggleButtonBGColor, other.toggleButtonBGColor, t)!, calendarWeekendBGColor: Color.lerp(calendarWeekendBGColor, other.calendarWeekendBGColor, t)!, gridRowCountColor: Color.lerp(gridRowCountColor, other.gridRowCountColor, t)!, code: other.code, callout: other.callout, caption: other.caption, onBackground: Color.lerp(onBackground, other.onBackground, t)!, background: Color.lerp(background, other.background, t)!, borderColor: Color.lerp(borderColor, other.borderColor, t)!, scrollbarColor: Color.lerp(scrollbarColor, other.scrollbarColor, t)!, scrollbarHoverColor: Color.lerp(scrollbarHoverColor, other.scrollbarHoverColor, t)!, lightIconColor: Color.lerp(lightIconColor, other.lightIconColor, t)!, toolbarHoverColor: Color.lerp(toolbarHoverColor, other.toolbarHoverColor, t)!, ); } } enum FlowyTint { tint1, tint2, tint3, tint4, tint5, tint6, tint7, tint8, tint9; String toJson() => name; static FlowyTint fromJson(String json) { try { return FlowyTint.values.byName(json); } catch (_) { return FlowyTint.tint1; } } static FlowyTint? fromId(String id) { for (final value in FlowyTint.values) { if (value.id == id) { return value; } } return null; } Color color(BuildContext context, {AFThemeExtension? theme}) => switch (this) { FlowyTint.tint1 => theme?.tint1 ?? AFThemeExtension.of(context).tint1, FlowyTint.tint2 => theme?.tint2 ?? AFThemeExtension.of(context).tint2, FlowyTint.tint3 => theme?.tint3 ?? AFThemeExtension.of(context).tint3, FlowyTint.tint4 => theme?.tint4 ?? AFThemeExtension.of(context).tint4, FlowyTint.tint5 => theme?.tint5 ?? AFThemeExtension.of(context).tint5, FlowyTint.tint6 => theme?.tint6 ?? AFThemeExtension.of(context).tint6, FlowyTint.tint7 => theme?.tint7 ?? AFThemeExtension.of(context).tint7, FlowyTint.tint8 => theme?.tint8 ?? AFThemeExtension.of(context).tint8, FlowyTint.tint9 => theme?.tint9 ?? AFThemeExtension.of(context).tint9, }; String get id => switch (this) { // DON'T change this name because it's saved in the database! FlowyTint.tint1 => 'appflowy_them_color_tint1', FlowyTint.tint2 => 'appflowy_them_color_tint2', FlowyTint.tint3 => 'appflowy_them_color_tint3', FlowyTint.tint4 => 'appflowy_them_color_tint4', FlowyTint.tint5 => 'appflowy_them_color_tint5', FlowyTint.tint6 => 'appflowy_them_color_tint6', FlowyTint.tint7 => 'appflowy_them_color_tint7', FlowyTint.tint8 => 'appflowy_them_color_tint8', FlowyTint.tint9 => 'appflowy_them_color_tint9', }; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/time/duration.dart ================================================ import 'package:time/time.dart'; export 'package:time/time.dart'; class FlowyDurations { static Duration get fastest => .15.seconds; static Duration get fast => .25.seconds; static Duration get medium => .35.seconds; static Duration get slow => .7.seconds; } class RouteDurations { static Duration get slow => .7.seconds; static Duration get medium => .35.seconds; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/time/prelude.dart ================================================ export "duration.dart"; export 'package:time/time.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart ================================================ import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; class ColorConverter implements JsonConverter { const ColorConverter(); static const Color fallback = Colors.transparent; @override Color fromJson(String radixString) { final int? color = int.tryParse(radixString); return color == null ? fallback : Color(color); } @override String toJson(Color color) { final alpha = (color.a * 255).toInt().toRadixString(16).padLeft(2, '0'); final red = (color.r * 255).toInt().toRadixString(16).padLeft(2, '0'); final green = (color.g * 255).toInt().toRadixString(16).padLeft(2, '0'); final blue = (color.b * 255).toInt().toRadixString(16).padLeft(2, '0'); return '0x$alpha$red$green$blue'.toLowerCase(); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/lib/uuid.dart ================================================ import 'package:uuid/data.dart'; import 'package:uuid/rng.dart'; import 'package:uuid/uuid.dart'; const _uuid = Uuid(); String uuid() { return _uuid.v4(); } String fixedUuid(int seed, UuidType type) { return _uuid.v4(config: V4Options(null, MathRNG(seed: seed + type.index))); } enum UuidType { // 0.6.0 publicSpace, privateSpace, } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml ================================================ name: flowy_infra description: AppFlowy Infra. version: 0.0.1 homepage: https://appflowy.io environment: sdk: ">=3.0.0 <4.0.0" flutter: ">=3.10.1" dependencies: flutter: sdk: flutter json_annotation: ^4.7.0 path_provider: ^2.0.15 path: ^1.8.2 time: ">=2.0.0" uuid: ">=2.2.2" bloc: ^9.0.0 freezed_annotation: ^2.1.0 file_picker: ^8.0.2 file: ^7.0.0 analyzer: 6.11.0 dev_dependencies: build_runner: ^2.4.9 flutter_lints: ^3.0.1 freezed: ^2.4.7 json_serializable: ^6.5.4 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/.gitignore ================================================ .DS_Store .dart_tool/ .packages .pub/ build/ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/.metadata ================================================ # 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: fa5883b78e566877613ad1ccb48dd92075cb5c23 channel: dev project_type: plugin ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/CHANGELOG.md ================================================ ## 0.0.1 * TODO: Describe initial release. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/LICENSE ================================================ TODO: Add your license here. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/README.md ================================================ # flowy_infra_ui A new flutter plugin project. ## Getting Started This project is a starting point for a Flutter [plug-in package](https://flutter.dev/developing-packages/), a specialized package that includes platform-specific implementation code for Android and/or iOS. For help getting started with Flutter, view our [online documentation](https://flutter.dev/docs), which offers tutorials, samples, guidance on mobile development, and a full API reference. The plugin project was generated without specifying the `--platforms` flag, no platforms are currently supported. To add platforms, run `flutter create -t plugin --platforms .` under the same directory. You can also find a detailed instruction on how to add platforms in the `pubspec.yaml` at https://flutter.dev/docs/development/packages-and-plugins/developing-packages#plugin-platforms. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/analysis_options.yaml ================================================ include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/.classpath ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/.gitignore ================================================ *.iml .gradle /local.properties /.idea/workspace.xml /.idea/libraries .DS_Store /build /captures ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/.project ================================================ flowy_infra_ui Project flowy_infra_ui created by Buildship. org.eclipse.jdt.core.javabuilder org.eclipse.buildship.core.gradleprojectbuilder org.eclipse.jdt.core.javanature org.eclipse.buildship.core.gradleprojectnature 1693395487121 30 org.eclipse.core.resources.regexFilterMatcher node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/.settings/org.eclipse.buildship.core.prefs ================================================ arguments=--init-script /var/folders/th/tfqrqcp12kvgzs3c3z0xqxlc0000gn/T/d146c9752a26f79b52047fb6dc6ed385d064e120494f96f08ca63a317c41f94c.gradle --init-script /var/folders/th/tfqrqcp12kvgzs3c3z0xqxlc0000gn/T/52cde0cfcf3e28b8b7510e992210d9614505e0911af0c190bd590d7158574963.gradle auto.sync=false build.scans.enabled=false connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(7.4.2)) connection.project.dir= eclipse.preferences.version=1 gradle.user.home= java.home=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home jvm.arguments= offline.mode=false override.workspace.settings=true show.console.view=true show.executions.view=true ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/build.gradle ================================================ group 'com.example.flowy_infra_ui' version '1.0' buildscript { ext.kotlin_version = '1.8.0' repositories { google() mavenCentral() } dependencies { classpath 'com.android.tools.build:gradle:7.4.2' } } rootProject.allprojects { repositories { google() mavenCentral() } } apply plugin: 'com.android.library' android { compileSdkVersion 33 namespace 'com.example.flowy_infra_ui' compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } dependencies { implementation "androidx.core:core:1.5.0-rc01" } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/settings.gradle ================================================ rootProject.name = 'flowy_infra_ui' ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/FlowyInfraUIPlugin.java ================================================ package com.example.flowy_infra_ui; import android.app.Activity; import androidx.annotation.NonNull; import com.example.flowy_infra_ui.event.KeyboardEventHandler; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; /** FlowyInfraUIPlugin */ public class FlowyInfraUIPlugin implements FlutterPlugin, ActivityAware, MethodCallHandler { // MARK: - Constant public static final String INFRA_UI_METHOD_CHANNEL_NAME = "flowy_infra_ui_method"; public static final String INFRA_UI_KEYBOARD_EVENT_CHANNEL_NAME = "flowy_infra_ui_event/keyboard"; public static final String INFRA_UI_METHOD_GET_PLATFORM_VERSION = "getPlatformVersion"; // Method Channel private MethodChannel methodChannel; // Event Channel private KeyboardEventHandler keyboardEventHandler = new KeyboardEventHandler(); @Override public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { methodChannel = new MethodChannel( flutterPluginBinding.getBinaryMessenger(), INFRA_UI_METHOD_CHANNEL_NAME); methodChannel.setMethodCallHandler(this); final EventChannel keyboardEventChannel = new EventChannel( flutterPluginBinding.getBinaryMessenger(), INFRA_UI_KEYBOARD_EVENT_CHANNEL_NAME); keyboardEventChannel.setStreamHandler(keyboardEventHandler); } @Override public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { methodChannel.setMethodCallHandler(null); keyboardEventHandler.cancelObserveKeyboardAction(); } @Override public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { keyboardEventHandler.observeKeyboardAction(binding.getActivity()); } @Override public void onDetachedFromActivityForConfigChanges() { keyboardEventHandler.cancelObserveKeyboardAction(); } @Override public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { keyboardEventHandler.observeKeyboardAction(binding.getActivity()); } @Override public void onDetachedFromActivity() { keyboardEventHandler.cancelObserveKeyboardAction(); } // MARK: - Method Channel @Override public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { if (call.method.equals(INFRA_UI_METHOD_GET_PLATFORM_VERSION)) { result.success("Android " + android.os.Build.VERSION.RELEASE); } else { result.notImplemented(); } } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/event/KeyboardEventHandler.java ================================================ package com.example.flowy_infra_ui.event; import android.app.Activity; import android.os.Build; import android.view.View; import androidx.annotation.RequiresApi; import androidx.core.view.OnApplyWindowInsetsListener; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import io.flutter.plugin.common.EventChannel; public class KeyboardEventHandler implements EventChannel.StreamHandler { private EventChannel.EventSink eventSink; private View rootView; private boolean isKeyboardShow = false; @Override public void onListen(Object arguments, EventChannel.EventSink events) { eventSink = events; } @Override public void onCancel(Object arguments) { eventSink = null; } // MARK: - Helper @RequiresApi(Build.VERSION_CODES.R) public void observeKeyboardAction(Activity activity) { rootView = activity.findViewById(android.R.id.content); ViewCompat.setOnApplyWindowInsetsListener(rootView, new OnApplyWindowInsetsListener() { @Override public WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) { isKeyboardShow = insets.isVisible(WindowInsetsCompat.Type.ime()); if (eventSink != null) { eventSink.success(isKeyboardShow); } return insets; } }); } public void cancelObserveKeyboardAction() { if (rootView != null) { ViewCompat.setOnApplyWindowInsetsListener(rootView, null); rootView = null; } } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/kotlin/com/example/flowy_infra_ui/FlowyInfraUiPlugin.kt ================================================ package com.example.flowy_infra_ui import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result /** FlowyInfraUIPlugin */ class FlowyInfraUIPlugin: FlutterPlugin, MethodCallHandler { /// The MethodChannel that will the communication between Flutter and native Android /// /// This local reference serves to register the plugin with the Flutter Engine and unregister it /// when the Flutter Engine is detached from the Activity private lateinit var channel : MethodChannel override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "flowy_infra_ui") channel.setMethodCallHandler(this) } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { if (call.method == "getPlatformVersion") { result.success("Android ${android.os.Build.VERSION.RELEASE}") } else { result.notImplemented() } } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # 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 .packages .pub-cache/ .pub/ /build/ # Web related lib/generated_plugin_registrant.dart # 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 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/.metadata ================================================ # 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: fa5883b78e566877613ad1ccb48dd92075cb5c23 channel: dev project_type: app ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/README.md ================================================ # flowy_infra_ui_example Demonstrates how to use the flowy_infra_ui plugin. ## Getting Started This project is a starting point for a Flutter application. A few resources to get you started if this is your first Flutter project: - [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) - [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) For help getting started with Flutter, view our [online documentation](https://flutter.dev/docs), which offers tutorials, samples, guidance on mobile development, and a full API reference. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/analysis_options.yaml ================================================ # This file configures the analyzer, which statically analyzes Dart code to # check for errors, warnings, and lints. # # The issues identified by the analyzer are surfaced in the UI of Dart-enabled # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be # invoked from the command line by running `flutter analyze`. # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at # https://dart-lang.github.io/linter/lints/index.html. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code # or a specific dart file by using the `// ignore: name_of_lint` and # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/.gitignore ================================================ gradle-wrapper.jar /.gradle /captures/ /gradlew /gradlew.bat /local.properties GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app key.properties **/*.keystore **/*.jks ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/.project ================================================ android Project android created by Buildship. org.eclipse.buildship.core.gradleprojectbuilder org.eclipse.buildship.core.gradleprojectnature 1626576261654 30 org.eclipse.core.resources.regexFilterMatcher node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/.settings/org.eclipse.buildship.core.prefs ================================================ arguments= auto.sync=false build.scans.enabled=false connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) connection.project.dir= eclipse.preferences.version=1 gradle.user.home= java.home=/Library/Java/JavaVirtualMachines/jdk11.0.5-zulu.jdk/Contents/Home jvm.arguments= offline.mode=false override.workspace.settings=true show.console.view=true show.executions.view=true ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.classpath ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.project ================================================ app Project app created by Buildship. org.eclipse.jdt.core.javabuilder org.eclipse.buildship.core.gradleprojectbuilder org.eclipse.jdt.core.javanature org.eclipse.buildship.core.gradleprojectnature 1626576261660 30 org.eclipse.core.resources.regexFilterMatcher node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.settings/org.eclipse.buildship.core.prefs ================================================ arguments= auto.sync=false build.scans.enabled=false connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) connection.project.dir= eclipse.preferences.version=1 gradle.user.home= java.home=/Library/Java/JavaVirtualMachines/jdk11.0.5-zulu.jdk/Contents/Home jvm.arguments= offline.mode=false override.workspace.settings=true show.console.view=true show.executions.view=true ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/build.gradle ================================================ def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) } } def flutterRoot = localProperties.getProperty('flutter.sdk') if (flutterRoot == null) { throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { flutterVersionName = '1.0' } apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" //apply plugin: 'kotlin-android-extensions' //androidExtensions { // experimental = true //} android { compileSdkVersion flutter.compileSdkVersion ndkVersion flutter.ndkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.flowy_infra_ui_example" minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true } buildTypes { release { minifyEnabled true shrinkResources true // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } } flutter { source '../..' } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'com.android.support:multidex:2.0.1' } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt ================================================ package com.example.example import io.flutter.embedding.android.FlutterActivity class MainActivity: FlutterActivity() { } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/flowy_infra_ui_example/MainActivity.kt ================================================ package com.example.flowy_infra_ui_example import io.flutter.embedding.android.FlutterActivity class MainActivity: FlutterActivity() { } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/drawable/launch_background.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/drawable-v21/launch_background.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/values/styles.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/values-night/styles.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/profile/AndroidManifest.xml ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/build.gradle ================================================ buildscript { ext.kotlin_version = '1.6.10' repositories { google() mavenCentral() } dependencies { classpath 'com.android.tools.build:gradle:4.1.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() mavenCentral() } } rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" project.evaluationDependsOn(':app') } task clean(type: Delete) { delete rootProject.buildDir } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/gradle/wrapper/gradle-wrapper.properties ================================================ #Sat Jul 17 23:27:26 CST 2021 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-bin.zip ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/settings.gradle ================================================ include ':app' def localPropertiesFile = new File(rootProject.projectDir, "local.properties") def properties = new Properties() assert localPropertiesFile.exists() localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() def plugins = new Properties() def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') if (pluginsFile.exists()) { pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } } plugins.each { name, path -> def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() include ":$name" project(":$name").projectDir = pluginDirectory } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/example/android/app/src/main/java/com/example/flowy_infra_ui_example/FlutterActivity.java ================================================ package example.android.app.src.main.java.com.example.flowy_infra_ui_example; public class FlutterActivity { } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/.gitignore ================================================ *.mode1v3 *.mode2v3 *.moved-aside *.pbxuser *.perspectivev3 **/*sync/ .sconsign.dblite .tags* **/.vagrant/ **/DerivedData/ Icon? **/Pods/ **/.symlinks/ profile xcuserdata **/.generated/ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !default.mode1v3 !default.mode2v3 !default.pbxuser !default.perspectivev3 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Flutter/AppFrameworkInfo.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable App CFBundleIdentifier io.flutter.flutter.app CFBundleInfoDictionaryVersion 6.0 CFBundleName App CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1.0 MinimumOSVersion 8.0 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Flutter/Debug.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Flutter/Release.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Podfile ================================================ # Uncomment this line to define a global platform for your project # platform :ios, '9.0' # 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', '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 Generated.xcconfig, then run flutter pub get" end require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) flutter_ios_podfile_setup target 'Runner' do use_frameworks! use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) end end ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/AppDelegate.swift ================================================ import UIKit import Flutter @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@3x.png", "scale" : "3x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@3x.png", "scale" : "3x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@3x.png", "scale" : "3x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@2x.png", "scale" : "2x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@3x.png", "scale" : "3x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@1x.png", "scale" : "1x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@1x.png", "scale" : "1x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@1x.png", "scale" : "1x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@2x.png", "scale" : "2x" }, { "size" : "83.5x83.5", "idiom" : "ipad", "filename" : "Icon-App-83.5x83.5@2x.png", "scale" : "2x" }, { "size" : "1024x1024", "idiom" : "ios-marketing", "filename" : "Icon-App-1024x1024@1x.png", "scale" : "1x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LaunchImage.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LaunchImage@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md ================================================ # Launch Screen Assets You can customize the launch screen with your own desired assets by replacing the image files in this directory. You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/Main.storyboard ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName flowy_infra_ui_example CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleSignature ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Runner-Bridging-Header.h ================================================ #import "GeneratedPluginRegistrant.h" ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 46; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; B061A1718EA00FC8FD116CB3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 609D91DEF4C1832DC41DF975 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 330E5DF8FFAD644160722BE7 /* 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 = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 609D91DEF4C1832DC41DF975 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E433772F3E94B24F3479C2F9 /* 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 = ""; }; F09BA83EC3ECFDE285785DB2 /* 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 */ 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( B061A1718EA00FC8FD116CB3 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 35285CDC74580BB1F9C79F75 /* Frameworks */ = { isa = PBXGroup; children = ( 609D91DEF4C1832DC41DF975 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; }; 6F690F158B0591DDAF4BC371 /* Pods */ = { isa = PBXGroup; children = ( 330E5DF8FFAD644160722BE7 /* Pods-Runner.debug.xcconfig */, F09BA83EC3ECFDE285785DB2 /* Pods-Runner.release.xcconfig */, E433772F3E94B24F3479C2F9 /* Pods-Runner.profile.xcconfig */, ); name = Pods; path = Pods; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); name = Flutter; sourceTree = ""; }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 6F690F158B0591DDAF4BC371 /* Pods */, 35285CDC74580BB1F9C79F75 /* Frameworks */, ); sourceTree = ""; }; 97C146EF1CF9000F007C117D /* Products */ = { isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, ); name = Products; sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( DA35FF5721D34BC0BE4E1DD5 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 8A9E1CD9725728F74665B246 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { LastUpgradeCheck = 1020; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 8A9E1CD9725728F74665B246 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", "${BUILT_PRODUCTS_DIR}/flowy_infra_ui/flowy_infra_ui.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flowy_infra_ui.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; DA35FF5721D34BC0BE4E1DD5 /* [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; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 97C146FB1CF9000F007C117D /* Base */, ); name = Main.storyboard; sourceTree = ""; }; 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 97C147001CF9000F007C117D /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Profile; }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.example.flowyInfraUiExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; 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_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 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_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 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_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; 97C147061CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.example.flowyInfraUiExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 97C147071CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.example.flowyInfraUiExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147031CF9000F007C117D /* Debug */, 97C147041CF9000F007C117D /* Release */, 249021D3217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147061CF9000F007C117D /* Debug */, 97C147071CF9000F007C117D /* Release */, 249021D4217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/home/demo_item.dart ================================================ import 'package:flutter/material.dart'; abstract class ListItem {} abstract class DemoItem extends ListItem { String buildTitle(); void handleTap(BuildContext context); } class SectionHeaderItem extends ListItem { SectionHeaderItem(this.title); final String title; Widget buildWidget(BuildContext context) => Text(title); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/home/home_screen.dart ================================================ import 'package:flutter/material.dart'; import '../overlay/overlay_screen.dart'; import '../keyboard/keyboard_screen.dart'; import 'demo_item.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); static List items = [ SectionHeaderItem('Widget Demos'), KeyboardItem(), OverlayItem(), ]; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Demos'), ), body: ListView.builder( itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; if (item is SectionHeaderItem) { return Container( constraints: const BoxConstraints(maxHeight: 48.0), color: Colors.grey[300], alignment: Alignment.center, child: ListTile( title: Text(item.title), ), ); } else if (item is DemoItem) { return ListTile( title: Text(item.buildTitle()), onTap: () => item.handleTap(context), ); } return const ListTile( title: Text('Unknow.'), ); }, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; import 'package:flutter/material.dart'; import '../home/demo_item.dart'; class KeyboardItem extends DemoItem { @override String buildTitle() => 'Keyboard Listener'; @override void handleTap(BuildContext context) { Navigator.of(context).push( MaterialPageRoute( builder: (context) { return const KeyboardScreen(); }, ), ); } } class KeyboardScreen extends StatefulWidget { const KeyboardScreen({super.key}); @override State createState() => _KeyboardScreenState(); } class _KeyboardScreenState extends State { bool _isKeyboardVisible = false; final TextEditingController _controller = TextEditingController(text: 'Hello Flowy'); @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Keyboard Visibility Demo'), ), body: KeyboardVisibilityDetector( onKeyboardVisibilityChange: (isKeyboardVisible) { setState(() => _isKeyboardVisible = isKeyboardVisible); }, child: GestureDetector( onTap: () => _dismissKeyboard(context), behavior: HitTestBehavior.translucent, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 36), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 12.0), child: Text( 'Keyboard Visible: $_isKeyboardVisible', style: const TextStyle(fontSize: 24.0), ), ), TextField( style: const TextStyle(fontSize: 20), controller: _controller, ), ], ), ), ), ), ), ); } void _dismissKeyboard(BuildContext context) { final currentFocus = FocusScope.of(context); if (!currentFocus.hasPrimaryFocus && currentFocus.hasFocus) { FocusManager.instance.primaryFocus?.unfocus(); } } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/main.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; import 'package:flutter/material.dart'; import 'home/home_screen.dart'; void main() { runApp(const ExampleApp()); } class ExampleApp extends StatelessWidget { const ExampleApp({super.key}); @override Widget build(BuildContext context) { return const MaterialApp( builder: overlayManagerBuilder, title: "Flowy Infra Title", home: HomeScreen(), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../home/demo_item.dart'; class OverlayItem extends DemoItem { @override String buildTitle() => 'Overlay'; @override void handleTap(BuildContext context) { Navigator.of(context).push( MaterialPageRoute( builder: (context) { return const OverlayScreen(); }, ), ); } } class OverlayDemoConfiguration extends ChangeNotifier { OverlayDemoConfiguration(this._anchorDirection, this._overlapBehaviour); AnchorDirection _anchorDirection; AnchorDirection get anchorDirection => _anchorDirection; set anchorDirection(AnchorDirection value) { _anchorDirection = value; notifyListeners(); } OverlapBehaviour _overlapBehaviour; OverlapBehaviour get overlapBehaviour => _overlapBehaviour; set overlapBehaviour(OverlapBehaviour value) { _overlapBehaviour = value; notifyListeners(); } } class OverlayScreen extends StatelessWidget { const OverlayScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Overlay Demo'), ), body: ChangeNotifierProvider( create: (context) => OverlayDemoConfiguration( AnchorDirection.rightWithTopAligned, OverlapBehaviour.stretch), child: Builder(builder: (providerContext) { return Center( child: ConstrainedBox( constraints: const BoxConstraints.tightFor(width: 500), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(height: 48.0), ElevatedButton( onPressed: () { final windowSize = MediaQuery.of(context).size; FlowyOverlay.of(context).insertCustom( widget: Positioned( left: windowSize.width / 2.0 - 100, top: 200, child: SizedBox( width: 200, height: 100, child: Card( color: Colors.green[200], child: GestureDetector( // ignore: avoid_print onTapDown: (_) => print('Hello Flutter'), child: const Center(child: FlutterLogo(size: 100)), ), ), ), ), identifier: 'overlay_flutter_logo', delegate: null, ); }, child: const Text('Show Overlay'), ), const SizedBox(height: 24.0), DropdownButton( value: providerContext .watch() .anchorDirection, onChanged: (AnchorDirection? newValue) { if (newValue != null) { providerContext .read() .anchorDirection = newValue; } }, items: AnchorDirection.values.map((AnchorDirection classType) { return DropdownMenuItem( value: classType, child: Text(classType.toString())); }).toList(), ), const SizedBox(height: 24.0), DropdownButton( value: providerContext .watch() .overlapBehaviour, onChanged: (OverlapBehaviour? newValue) { if (newValue != null) { providerContext .read() .overlapBehaviour = newValue; } }, items: OverlapBehaviour.values .map((OverlapBehaviour classType) { return DropdownMenuItem( value: classType, child: Text(classType.toString())); }).toList(), ), const SizedBox(height: 24.0), Builder(builder: (buttonContext) { return SizedBox( height: 100, child: ElevatedButton( onPressed: () { FlowyOverlay.of(context).insertWithAnchor( widget: SizedBox( width: 300, height: 50, child: Card( color: Colors.grey[200], child: GestureDetector( // ignore: avoid_print onTapDown: (_) => print('Hello Flutter'), child: const Center( child: FlutterLogo(size: 50)), ), ), ), identifier: 'overlay_anchored_card', delegate: null, anchorContext: buttonContext, anchorDirection: providerContext .read() .anchorDirection, overlapBehaviour: providerContext .read() .overlapBehaviour, ); }, child: const Text('Show Anchored Overlay'), ), ); }), const SizedBox(height: 24.0), ElevatedButton( onPressed: () { final windowSize = MediaQuery.of(context).size; FlowyOverlay.of(context).insertWithRect( widget: SizedBox( width: 200, height: 100, child: Card( color: Colors.orange[200], child: GestureDetector( // ignore: avoid_print onTapDown: (_) => debugPrint('Hello Flutter'), child: const Center(child: FlutterLogo(size: 100)), ), ), ), identifier: 'overlay_positioned_card', delegate: null, anchorPosition: Offset(0, windowSize.height - 200), anchorSize: Size.zero, anchorDirection: providerContext .read() .anchorDirection, overlapBehaviour: providerContext .read() .overlapBehaviour, ); }, child: const Text('Show Positioned Overlay'), ), const SizedBox(height: 24.0), Builder(builder: (buttonContext) { return ElevatedButton( onPressed: () { ListOverlay.showWithAnchor( context, itemBuilder: (_, index) => Card( margin: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 12.0), elevation: 0, child: Text( 'Option $index', style: const TextStyle( fontSize: 20.0, color: Colors.black), ), ), itemCount: 10, identifier: 'overlay_list_menu', anchorContext: buttonContext, anchorDirection: providerContext .read() .anchorDirection, overlapBehaviour: providerContext .read() .overlapBehaviour, constraints: BoxConstraints.tight(const Size(200, 200)), ); }, child: const Text('Show List Overlay'), ); }), const SizedBox(height: 24.0), Builder(builder: (buttonContext) { return ElevatedButton( onPressed: () { OptionOverlay.showWithAnchor( context, items: [ 'Alpha', 'Beta', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel' ], onHover: (value, index) => debugPrint( 'Did hover option $index, value $value'), onTap: (value, index) => debugPrint('Did tap option $index, value $value'), identifier: 'overlay_options', anchorContext: buttonContext, anchorDirection: providerContext .read() .anchorDirection, overlapBehaviour: providerContext .read() .overlapBehaviour, ); }, child: const Text('Show Options Overlay'), ); }), ], ), ), ); }), ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/.gitignore ================================================ flutter/ephemeral ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.10) project(runner LANGUAGES CXX) set(BINARY_NAME "flowy_infra_ui_example") set(APPLICATION_ID "com.example.flowy_infra_ui") cmake_policy(SET CMP0063 NEW) 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() # Configure build 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. 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() set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") # Flutter library and tool build rules. 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}") # Application build add_executable(${BINARY_NAME} "main.cc" "my_application.cc" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" ) apply_standard_settings(${BINARY_NAME}) target_link_libraries(${BINARY_NAME} PRIVATE flutter) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) 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) if(PLUGIN_BUNDLED_LIBRARIES) install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() # 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() ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/flutter/CMakeLists.txt ================================================ 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} ) ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/main.cc ================================================ #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); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/my_application.cc ================================================ #include "my_application.h" #include #ifdef GDK_WINDOWING_X11 #include #endif #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(gtk_application_window_new(GTK_APPLICATION(application))); // Use a header bar when running in GNOME as this is the common style used // by applications and is the setup most users will be using (e.g. Ubuntu // desktop). // If running on X and not using GNOME then just use a traditional title bar // in case the window manager does more exotic layout, e.g. tiling. // If running on Wayland assume the header bar will work (may need changing // if future cases occur). gboolean use_header_bar = TRUE; #ifdef GDK_WINDOWING_X11 GdkScreen* screen = gtk_window_get_screen(window); if (GDK_IS_X11_SCREEN(screen)) { const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); if (g_strcmp0(wm_name, "GNOME Shell") != 0) { use_header_bar = FALSE; } } #endif if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); gtk_header_bar_set_title(header_bar, "flowy_infra_ui_example"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { gtk_window_set_title(window, "flowy_infra_ui_example"); } gtk_window_set_default_size(window, 1280, 720); 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 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_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)); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/my_application.h ================================================ #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_ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/.gitignore ================================================ # Flutter-related **/Flutter/ephemeral/ **/Pods/ # Xcode-related **/xcuserdata/ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Debug.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Release.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Podfile ================================================ platform :osx, '10.13' # 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__)) end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) end end ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/AppDelegate.swift ================================================ import Cocoa import FlutterMacOS @NSApplicationMain class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "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" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Base.lproj/MainMenu.xib ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/AppInfo.xcconfig ================================================ // 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 = flowy_infra_ui_example // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.example.flowyInfraUiExample // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2021 com.example. All rights reserved. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/Debug.xcconfig ================================================ #include "../../Flutter/Flutter-Debug.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/Release.xcconfig ================================================ #include "../../Flutter/Flutter-Release.xcconfig" #include "Warnings.xcconfig" ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/Warnings.xcconfig ================================================ 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 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/DebugProfile.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.cs.allow-jit com.apple.security.network.server ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Info.plist ================================================ 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 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/MainFlutterWindow.swift ================================================ import Cocoa import FlutterMacOS class MainFlutterWindow: NSWindow { override func awakeFromNib() { let flutterViewController = FlutterViewController.init() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Release.entitlements ================================================ com.apple.security.app-sandbox ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 51; 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 */ 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 */; }; 7912A075158F80106DD95645 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0464B205D770E7A68F28B6D /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 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 */ 2D5900421C18B1A15A65A9EC /* 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 = ""; }; 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 /* flowy_infra_ui_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flowy_infra_ui_example.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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 97A4E031C17F0C7BEEE33734 /* 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 = ""; }; AB3F9417FEFE6929B49F80FE /* 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 = ""; }; C0464B205D770E7A68F28B6D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 33CC10EA2044A3C60003C045 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 7912A075158F80106DD95645 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 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 */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, A750A856115646FFDA1D1478 /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* flowy_infra_ui_example.app */, ); 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 = ""; }; A750A856115646FFDA1D1478 /* Pods */ = { isa = PBXGroup; children = ( 97A4E031C17F0C7BEEE33734 /* Pods-Runner.debug.xcconfig */, 2D5900421C18B1A15A65A9EC /* Pods-Runner.release.xcconfig */, AB3F9417FEFE6929B49F80FE /* Pods-Runner.profile.xcconfig */, ); name = Pods; path = Pods; sourceTree = ""; }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( C0464B205D770E7A68F28B6D /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 33CC10EC2044A3C60003C045 /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 2FBE5781370B1EF78F66CD14 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, 196EE237C3BE7811FE50A841 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* flowy_infra_ui_example.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; LastUpgradeCheck = 0930; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; ProvisioningStyle = Automatic; 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 */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 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 */ 196EE237C3BE7811FE50A841 /* [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; }; 2FBE5781370B1EF78F66CD14 /* [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; }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; 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"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 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 */ 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 */ 338D0CE9231458BD00FA5F75 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; 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; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; 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.11; 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_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; 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; 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; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; 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.11; 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; 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; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; 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.11; 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_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; 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_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; 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 */ 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 */; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/pubspec.yaml ================================================ name: flowy_infra_ui_example description: Demonstrates how to use the flowy_infra_ui plugin. publish_to: 'none' # Remove this line if you wish to publish to pub.dev environment: flutter: ">=3.22.0" sdk: ">=3.1.5 <4.0.0" dependencies: flutter: sdk: flutter flowy_infra_ui: path: ../ cupertino_icons: ^1.0.2 provider: dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.1 flutter: uses-material-design: true ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/test/widget_test.dart ================================================ // This is a basic Flutter widget test. // // To perform an interaction with a widget in your test, use the WidgetTester // utility that Flutter provides. For example, you can send tap and scroll // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. void main() {} ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/index.html ================================================ flowy_infra_ui_example ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/manifest.json ================================================ { "name": "flowy_infra_ui_example", "short_name": "flowy_infra_ui_example", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", "description": "Demonstrates how to use the flowy_infra_ui plugin.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ { "src": "icons/Icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "icons/Icon-512.png", "sizes": "512x512", "type": "image/png" } ] } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/.gitignore ================================================ flutter/ephemeral/ # Visual Studio user-specific files. *.suo *.user *.userosscache *.sln.docstates # Visual Studio build-related files. x64/ x86/ # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.15) project(flowy_infra_ui_example LANGUAGES CXX) set(BINARY_NAME "flowy_infra_ui_example") cmake_policy(SET CMP0063 NEW) set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") # Configure build options. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" CACHE STRING "" FORCE) else() 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() endif() set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") # Use Unicode for all projects. add_definitions(-DUNICODE -D_UNICODE) # Compilation settings that should be applied to most targets. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") target_compile_options(${TARGET} PRIVATE /EHsc) target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") endfunction() set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") # Flutter library and tool build rules. add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build add_subdirectory("runner") # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) # === Installation === # Support files are copied into place next to the executable, so that it can # run in place. This is done instead of making a separate bundle (as on Linux) # so that building and running from within Visual Studio will work. set(BUILD_BUNDLE_DIR "$") # Make the "install" step default, as it's required to run. set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) endif() set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") 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) if(PLUGIN_BUNDLED_LIBRARIES) install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() # 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. install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" CONFIGURATIONS Profile;Release COMPONENT Runtime) ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/flutter/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.15) 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. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") # 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/windows/app.so" PARENT_SCOPE) list(APPEND FLUTTER_LIBRARY_HEADERS "flutter_export.h" "flutter_windows.h" "flutter_messenger.h" "flutter_plugin_registrar.h" "flutter_texture_registrar.h" ) list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") add_library(flutter INTERFACE) target_include_directories(flutter INTERFACE "${EPHEMERAL_DIR}" ) target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") add_dependencies(flutter flutter_assemble) # === Wrapper === list(APPEND CPP_WRAPPER_SOURCES_CORE "core_implementations.cc" "standard_codec.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_PLUGIN "plugin_registrar.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_APP "flutter_engine.cc" "flutter_view_controller.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") # Wrapper sources needed for a plugin. add_library(flutter_wrapper_plugin STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ) apply_standard_settings(flutter_wrapper_plugin) set_target_properties(flutter_wrapper_plugin PROPERTIES POSITION_INDEPENDENT_CODE ON) set_target_properties(flutter_wrapper_plugin PROPERTIES CXX_VISIBILITY_PRESET hidden) target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) target_include_directories(flutter_wrapper_plugin PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_plugin flutter_assemble) # Wrapper sources needed for the runner. add_library(flutter_wrapper_app STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_APP} ) apply_standard_settings(flutter_wrapper_app) target_link_libraries(flutter_wrapper_app PUBLIC flutter) target_include_directories(flutter_wrapper_app PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_app 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. set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) add_custom_command( OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ${PHONY_OUTPUT} COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" windows-x64 $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS "${FLUTTER_LIBRARY}" ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ) ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.15) project(runner LANGUAGES CXX) add_executable(${BINARY_NAME} WIN32 "flutter_window.cpp" "main.cpp" "utils.cpp" "win32_window.cpp" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" "Runner.rc" "runner.exe.manifest" ) apply_standard_settings(${BINARY_NAME}) target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") add_dependencies(${BINARY_NAME} flutter_assemble) ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/Runner.rc ================================================ // Microsoft Visual C++ generated resource script. // #pragma code_page(65001) #include "resource.h" #define APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 2 resource. // #include "winres.h" ///////////////////////////////////////////////////////////////////////////// #undef APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // English (United States) resources #if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US #ifdef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // TEXTINCLUDE // 1 TEXTINCLUDE BEGIN "resource.h\0" END 2 TEXTINCLUDE BEGIN "#include ""winres.h""\r\n" "\0" END 3 TEXTINCLUDE BEGIN "\r\n" "\0" END #endif // APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Icon // // Icon with lowest ID value placed first to ensure application icon // remains consistent on all systems. IDI_APP_ICON ICON "resources\\app_icon.ico" ///////////////////////////////////////////////////////////////////////////// // // Version // #ifdef FLUTTER_BUILD_NUMBER #define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER #else #define VERSION_AS_NUMBER 1,0,0 #endif #ifdef FLUTTER_BUILD_NAME #define VERSION_AS_STRING #FLUTTER_BUILD_NAME #else #define VERSION_AS_STRING "1.0.0" #endif VS_VERSION_INFO VERSIONINFO FILEVERSION VERSION_AS_NUMBER PRODUCTVERSION VERSION_AS_NUMBER FILEFLAGSMASK VS_FFI_FILEFLAGSMASK #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG #else FILEFLAGS 0x0L #endif FILEOS VOS__WINDOWS32 FILETYPE VFT_APP FILESUBTYPE 0x0L BEGIN BLOCK "StringFileInfo" BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "com.example" "\0" VALUE "FileDescription", "Demonstrates how to use the flowy_infra_ui plugin." "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "flowy_infra_ui_example" "\0" VALUE "LegalCopyright", "Copyright (C) 2021 com.example. All rights reserved." "\0" VALUE "OriginalFilename", "flowy_infra_ui_example.exe" "\0" VALUE "ProductName", "flowy_infra_ui_example" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END BLOCK "VarFileInfo" BEGIN VALUE "Translation", 0x409, 1252 END END #endif // English (United States) resources ///////////////////////////////////////////////////////////////////////////// #ifndef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 3 resource. // ///////////////////////////////////////////////////////////////////////////// #endif // not APSTUDIO_INVOKED ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/flutter_window.cpp ================================================ #include "flutter_window.h" #include #include "flutter/generated_plugin_registrant.h" FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} FlutterWindow::~FlutterWindow() {} bool FlutterWindow::OnCreate() { if (!Win32Window::OnCreate()) { return false; } RECT frame = GetClientArea(); // The size here must match the window dimensions to avoid unnecessary surface // creation / destruction in the startup path. flutter_controller_ = std::make_unique( frame.right - frame.left, frame.bottom - frame.top, project_); // Ensure that basic setup of the controller was successful. if (!flutter_controller_->engine() || !flutter_controller_->view()) { return false; } RegisterPlugins(flutter_controller_->engine()); SetChildContent(flutter_controller_->view()->GetNativeWindow()); return true; } void FlutterWindow::OnDestroy() { if (flutter_controller_) { flutter_controller_ = nullptr; } Win32Window::OnDestroy(); } LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam); if (result) { return *result; } } switch (message) { case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; } return Win32Window::MessageHandler(hwnd, message, wparam, lparam); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/flutter_window.h ================================================ #ifndef RUNNER_FLUTTER_WINDOW_H_ #define RUNNER_FLUTTER_WINDOW_H_ #include #include #include #include "win32_window.h" // A window that does nothing but host a Flutter view. class FlutterWindow : public Win32Window { public: // Creates a new FlutterWindow hosting a Flutter view running |project|. explicit FlutterWindow(const flutter::DartProject& project); virtual ~FlutterWindow(); protected: // Win32Window: bool OnCreate() override; void OnDestroy() override; LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept override; private: // The project to run. flutter::DartProject project_; // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; }; #endif // RUNNER_FLUTTER_WINDOW_H_ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/main.cpp ================================================ #include #include #include #include "flutter_window.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { CreateAndAttachConsole(); } // Initialize COM, so that it is available for use in the library and/or // plugins. ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); if (!window.CreateAndShow(L"flowy_infra_ui_example", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); ::MSG msg; while (::GetMessage(&msg, nullptr, 0, 0)) { ::TranslateMessage(&msg); ::DispatchMessage(&msg); } ::CoUninitialize(); return EXIT_SUCCESS; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/resource.h ================================================ //{{NO_DEPENDENCIES}} // Microsoft Visual C++ generated include file. // Used by Runner.rc // #define IDI_APP_ICON 101 // Next default values for new objects // #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 102 #define _APS_NEXT_COMMAND_VALUE 40001 #define _APS_NEXT_CONTROL_VALUE 1001 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/runner.exe.manifest ================================================ PerMonitorV2 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/utils.cpp ================================================ #include "utils.h" #include #include #include #include #include void CreateAndAttachConsole() { if (::AllocConsole()) { FILE *unused; if (freopen_s(&unused, "CONOUT$", "w", stdout)) { _dup2(_fileno(stdout), 1); } if (freopen_s(&unused, "CONOUT$", "w", stderr)) { _dup2(_fileno(stdout), 2); } std::ios::sync_with_stdio(); FlutterDesktopResyncOutputStreams(); } } std::vector GetCommandLineArguments() { // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. int argc; wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); if (argv == nullptr) { return std::vector(); } std::vector command_line_arguments; // Skip the first argument as it's the binary name. for (int i = 1; i < argc; i++) { command_line_arguments.push_back(Utf8FromUtf16(argv[i])); } ::LocalFree(argv); return command_line_arguments; } std::string Utf8FromUtf16(const wchar_t* utf16_string) { if (utf16_string == nullptr) { return std::string(); } int target_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, nullptr, 0, nullptr, nullptr); if (target_length == 0) { return std::string(); } std::string utf8_string; utf8_string.resize(target_length); int converted_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, utf8_string.data(), target_length, nullptr, nullptr); if (converted_length == 0) { return std::string(); } return utf8_string; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/utils.h ================================================ #ifndef RUNNER_UTILS_H_ #define RUNNER_UTILS_H_ #include #include // Creates a console for the process, and redirects stdout and stderr to // it for both the runner and the Flutter library. void CreateAndAttachConsole(); // Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string // encoded in UTF-8. Returns an empty std::string on failure. std::string Utf8FromUtf16(const wchar_t* utf16_string); // Gets the command line arguments passed in as a std::vector, // encoded in UTF-8. Returns an empty std::vector on failure. std::vector GetCommandLineArguments(); #endif // RUNNER_UTILS_H_ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/win32_window.cpp ================================================ #include "win32_window.h" #include #include "resource.h" namespace { constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; // The number of Win32Window objects that currently exist. static int g_active_window_count = 0; using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); // Scale helper to convert logical scaler values to physical using passed in // scale factor int Scale(int source, double scale_factor) { return static_cast(source * scale_factor); } // Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. // This API is only needed for PerMonitor V1 awareness mode. void EnableFullDpiSupportIfAvailable(HWND hwnd) { HMODULE user32_module = LoadLibraryA("User32.dll"); if (!user32_module) { return; } auto enable_non_client_dpi_scaling = reinterpret_cast( GetProcAddress(user32_module, "EnableNonClientDpiScaling")); if (enable_non_client_dpi_scaling != nullptr) { enable_non_client_dpi_scaling(hwnd); FreeLibrary(user32_module); } } } // namespace // Manages the Win32Window's window class registration. class WindowClassRegistrar { public: ~WindowClassRegistrar() = default; // Returns the singleton registar instance. static WindowClassRegistrar* GetInstance() { if (!instance_) { instance_ = new WindowClassRegistrar(); } return instance_; } // Returns the name of the window class, registering the class if it hasn't // previously been registered. const wchar_t* GetWindowClass(); // Unregisters the window class. Should only be called if there are no // instances of the window. void UnregisterWindowClass(); private: WindowClassRegistrar() = default; static WindowClassRegistrar* instance_; bool class_registered_ = false; }; WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; const wchar_t* WindowClassRegistrar::GetWindowClass() { if (!class_registered_) { WNDCLASS window_class{}; window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); window_class.lpszClassName = kWindowClassName; window_class.style = CS_HREDRAW | CS_VREDRAW; window_class.cbClsExtra = 0; window_class.cbWndExtra = 0; window_class.hInstance = GetModuleHandle(nullptr); window_class.hIcon = LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); window_class.hbrBackground = 0; window_class.lpszMenuName = nullptr; window_class.lpfnWndProc = Win32Window::WndProc; RegisterClass(&window_class); class_registered_ = true; } return kWindowClassName; } void WindowClassRegistrar::UnregisterWindowClass() { UnregisterClass(kWindowClassName, nullptr); class_registered_ = false; } Win32Window::Win32Window() { ++g_active_window_count; } Win32Window::~Win32Window() { --g_active_window_count; Destroy(); } bool Win32Window::CreateAndShow(const std::wstring& title, const Point& origin, const Size& size) { Destroy(); const wchar_t* window_class = WindowClassRegistrar::GetInstance()->GetWindowClass(); const POINT target_point = {static_cast(origin.x), static_cast(origin.y)}; HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); double scale_factor = dpi / 96.0; HWND window = CreateWindow( window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), Scale(size.width, scale_factor), Scale(size.height, scale_factor), nullptr, nullptr, GetModuleHandle(nullptr), this); if (!window) { return false; } return OnCreate(); } // static LRESULT CALLBACK Win32Window::WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { if (message == WM_NCCREATE) { auto window_struct = reinterpret_cast(lparam); SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast(window_struct->lpCreateParams)); auto that = static_cast(window_struct->lpCreateParams); EnableFullDpiSupportIfAvailable(window); that->window_handle_ = window; } else if (Win32Window* that = GetThisFromHandle(window)) { return that->MessageHandler(window, message, wparam, lparam); } return DefWindowProc(window, message, wparam, lparam); } LRESULT Win32Window::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { switch (message) { case WM_DESTROY: window_handle_ = nullptr; Destroy(); if (quit_on_close_) { PostQuitMessage(0); } return 0; case WM_DPICHANGED: { auto newRectSize = reinterpret_cast(lparam); LONG newWidth = newRectSize->right - newRectSize->left; LONG newHeight = newRectSize->bottom - newRectSize->top; SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, newHeight, SWP_NOZORDER | SWP_NOACTIVATE); return 0; } case WM_SIZE: { RECT rect = GetClientArea(); if (child_content_ != nullptr) { // Size and position the child window. MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, TRUE); } return 0; } case WM_ACTIVATE: if (child_content_ != nullptr) { SetFocus(child_content_); } return 0; } return DefWindowProc(window_handle_, message, wparam, lparam); } void Win32Window::Destroy() { OnDestroy(); if (window_handle_) { DestroyWindow(window_handle_); window_handle_ = nullptr; } if (g_active_window_count == 0) { WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); } } Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { return reinterpret_cast( GetWindowLongPtr(window, GWLP_USERDATA)); } void Win32Window::SetChildContent(HWND content) { child_content_ = content; SetParent(content, window_handle_); RECT frame = GetClientArea(); MoveWindow(content, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, true); SetFocus(child_content_); } RECT Win32Window::GetClientArea() { RECT frame; GetClientRect(window_handle_, &frame); return frame; } HWND Win32Window::GetHandle() { return window_handle_; } void Win32Window::SetQuitOnClose(bool quit_on_close) { quit_on_close_ = quit_on_close; } bool Win32Window::OnCreate() { // No-op; provided for subclasses. return true; } void Win32Window::OnDestroy() { // No-op; provided for subclasses. } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/win32_window.h ================================================ #ifndef RUNNER_WIN32_WINDOW_H_ #define RUNNER_WIN32_WINDOW_H_ #include #include #include #include // A class abstraction for a high DPI-aware Win32 Window. Intended to be // inherited from by classes that wish to specialize with custom // rendering and input handling class Win32Window { public: struct Point { unsigned int x; unsigned int y; Point(unsigned int x, unsigned int y) : x(x), y(y) {} }; struct Size { unsigned int width; unsigned int height; Size(unsigned int width, unsigned int height) : width(width), height(height) {} }; Win32Window(); virtual ~Win32Window(); // Creates and shows a win32 window with |title| and position and size using // |origin| and |size|. New windows are created on the default monitor. Window // sizes are specified to the OS in physical pixels, hence to ensure a // consistent size to will treat the width height passed in to this function // as logical pixels and scale to appropriate for the default monitor. Returns // true if the window was created successfully. bool CreateAndShow(const std::wstring& title, const Point& origin, const Size& size); // Release OS resources associated with window. void Destroy(); // Inserts |content| into the window tree. void SetChildContent(HWND content); // Returns the backing Window handle to enable clients to set icon and other // window properties. Returns nullptr if the window has been destroyed. HWND GetHandle(); // If true, closing this window will quit the application. void SetQuitOnClose(bool quit_on_close); // Return a RECT representing the bounds of the current client area. RECT GetClientArea(); protected: // Processes and route salient window messages for mouse handling, // size change and DPI. Delegates handling of these to member overloads that // inheriting classes can handle. virtual LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Called when CreateAndShow is called, allowing subclass window-related // setup. Subclasses should return false if setup fails. virtual bool OnCreate(); // Called when Destroy is called. virtual void OnDestroy(); private: friend class WindowClassRegistrar; // OS callback called by message pump. Handles the WM_NCCREATE message which // is passed when the non-client area is being created and enables automatic // non-client DPI scaling so that the non-client area automatically // responsponds to changes in DPI. All other messages are handled by // MessageHandler. static LRESULT CALLBACK WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Retrieves a class instance pointer for |window| static Win32Window* GetThisFromHandle(HWND const window) noexcept; bool quit_on_close_ = false; // window handle for top level window. HWND window_handle_ = nullptr; // window handle for hosted content. HWND child_content_ = nullptr; }; #endif // RUNNER_WIN32_WINDOW_H_ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # 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/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .packages .pub-cache/ .pub/ build/ # Android related **/android/**/gradle-wrapper.jar **/android/.gradle **/android/captures/ **/android/gradlew **/android/gradlew.bat **/android/local.properties **/android/**/GeneratedPluginRegistrant.java # iOS/XCode related **/ios/**/*.mode1v3 **/ios/**/*.mode2v3 **/ios/**/*.moved-aside **/ios/**/*.pbxuser **/ios/**/*.perspectivev3 **/ios/**/*sync/ **/ios/**/.sconsign.dblite **/ios/**/.tags* **/ios/**/.vagrant/ **/ios/**/DerivedData/ **/ios/**/Icon? **/ios/**/Pods/ **/ios/**/.symlinks/ **/ios/**/profile **/ios/**/xcuserdata **/ios/.generated/ **/ios/Flutter/App.framework **/ios/Flutter/Flutter.framework **/ios/Flutter/Flutter.podspec **/ios/Flutter/Generated.xcconfig **/ios/Flutter/ephemeral **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ **/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !**/ios/**/default.mode1v3 !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.metadata ================================================ # 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: fa5883b78e566877613ad1ccb48dd92075cb5c23 channel: dev project_type: package ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/CHANGELOG.md ================================================ ## 0.0.1 * TODO: Describe initial release. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/LICENSE ================================================ TODO: Add your license here. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/README.md ================================================ # flowy_infra_ui_platform_interface A new Flutter package project. ## Getting Started This project is a starting point for a Dart [package](https://flutter.dev/developing-packages/), a library module containing code that can be shared easily across multiple Flutter or Dart projects. For help getting started with Flutter, view our [online documentation](https://flutter.dev/docs), which offers tutorials, samples, guidance on mobile development, and a full API reference. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/analysis_options.yaml ================================================ include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/flowy_infra_ui_platform_interface.dart ================================================ library flowy_infra_ui_platform_interface; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'src/method_channel_flowy_infra_ui.dart'; abstract class FlowyInfraUIPlatform extends PlatformInterface { FlowyInfraUIPlatform() : super(token: _token); static final Object _token = Object(); static FlowyInfraUIPlatform _instance = MethodChannelFlowyInfraUI(); static FlowyInfraUIPlatform get instance => _instance; static set instance(FlowyInfraUIPlatform instance) { PlatformInterface.verifyToken(instance, _token); _instance = instance; } Stream get onKeyboardVisibilityChange { throw UnimplementedError( '`onKeyboardChange` should be overridden by subclass.'); } Future getPlatformVersion() { throw UnimplementedError( '`getPlatformVersion` should be overridden by subclass.'); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/src/method_channel_flowy_infra_ui.dart ================================================ import 'package:flowy_infra_ui_platform_interface/flowy_infra_ui_platform_interface.dart'; import 'package:flutter/services.dart'; // ignore_for_file: constant_identifier_names const INFRA_UI_METHOD_CHANNEL_NAME = 'flowy_infra_ui_method'; const INFRA_UI_KEYBOARD_EVENT_CHANNEL_NAME = 'flowy_infra_ui_event/keyboard'; const INFRA_UI_METHOD_GET_PLATFORM_VERSION = 'getPlatformVersion'; class MethodChannelFlowyInfraUI extends FlowyInfraUIPlatform { final MethodChannel _methodChannel = const MethodChannel(INFRA_UI_METHOD_CHANNEL_NAME); final EventChannel _keyboardChannel = const EventChannel(INFRA_UI_KEYBOARD_EVENT_CHANNEL_NAME); late final Stream _onKeyboardVisibilityChange = _keyboardChannel.receiveBroadcastStream().map((event) => event as bool); @override Stream get onKeyboardVisibilityChange => _onKeyboardVisibilityChange; @override Future getPlatformVersion() async { String? version = await _methodChannel .invokeMethod(INFRA_UI_METHOD_GET_PLATFORM_VERSION); return version ?? 'unknow'; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml ================================================ name: flowy_infra_ui_platform_interface description: A new Flutter package project. version: 0.0.1 homepage: https://github.com/appflowy-io/appflowy environment: sdk: ">=2.12.0 <3.0.0" flutter: ">=1.17.0" dependencies: flutter: sdk: flutter plugin_platform_interface: ^2.0.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.1 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/test/flowy_infra_ui_platform_interface_test.dart ================================================ void main() {} ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # 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/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .packages .pub-cache/ .pub/ build/ # Android related **/android/**/gradle-wrapper.jar **/android/.gradle **/android/captures/ **/android/gradlew **/android/gradlew.bat **/android/local.properties **/android/**/GeneratedPluginRegistrant.java # iOS/XCode related **/ios/**/*.mode1v3 **/ios/**/*.mode2v3 **/ios/**/*.moved-aside **/ios/**/*.pbxuser **/ios/**/*.perspectivev3 **/ios/**/*sync/ **/ios/**/.sconsign.dblite **/ios/**/.tags* **/ios/**/.vagrant/ **/ios/**/DerivedData/ **/ios/**/Icon? **/ios/**/Pods/ **/ios/**/.symlinks/ **/ios/**/profile **/ios/**/xcuserdata **/ios/.generated/ **/ios/Flutter/App.framework **/ios/Flutter/Flutter.framework **/ios/Flutter/Flutter.podspec **/ios/Flutter/Generated.xcconfig **/ios/Flutter/ephemeral **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ **/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !**/ios/**/default.mode1v3 !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/.metadata ================================================ # 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: fa5883b78e566877613ad1ccb48dd92075cb5c23 channel: dev project_type: package ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/CHANGELOG.md ================================================ ## 0.0.1 * TODO: Describe initial release. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/LICENSE ================================================ TODO: Add your license here. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/README.md ================================================ # flowy_infra_ui_web A new Flutter package project. ## Getting Started This project is a starting point for a Dart [package](https://flutter.dev/developing-packages/), a library module containing code that can be shared easily across multiple Flutter or Dart projects. For help getting started with Flutter, view our [online documentation](https://flutter.dev/docs), which offers tutorials, samples, guidance on mobile development, and a full API reference. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/analysis_options.yaml ================================================ include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/lib/flowy_infra_ui_web.dart ================================================ library flowy_infra_ui_web; import 'dart:html' as html show window; import 'package:flowy_infra_ui_platform_interface/flowy_infra_ui_platform_interface.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; class FlowyInfraUIPlugin extends FlowyInfraUIPlatform { static void registerWith(Registrar registrar) { FlowyInfraUIPlatform.instance = FlowyInfraUIPlugin(); } // MARK: - Keyboard @override Stream get onKeyboardVisibilityChange async* { // suppose that keyboard won't show in web side yield false; } @override Future getPlatformVersion() async { final version = html.window.navigator.userAgent; return Future.value(version); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml ================================================ name: flowy_infra_ui_web description: A new Flutter package project. version: 0.0.1 homepage: https://github.com/appflowy-io/appflowy publish_to: none environment: sdk: ">=2.12.0 <3.0.0" flutter: ">=1.17.0" dependencies: flutter_web_plugins: sdk: flutter flowy_infra_ui_platform_interface: path: ../flowy_infra_ui_platform_interface dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.1 flutter: plugin: platforms: web: pluginClass: FlowyInfraUIPlugin fileName: flowy_infra_ui_web.dart ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/test/flowy_infra_ui_web_test.dart ================================================ void main() {} ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/ios/.gitignore ================================================ .idea/ .vagrant/ .sconsign.dblite .svn/ .DS_Store *.swp profile DerivedData/ build/ GeneratedPluginRegistrant.h GeneratedPluginRegistrant.m .generated/ *.pbxuser *.mode1v3 *.mode2v3 *.perspectivev3 !default.pbxuser !default.mode1v3 !default.mode2v3 !default.perspectivev3 xcuserdata *.moved-aside *.pyc *sync/ Icon? .tags* /Flutter/Generated.xcconfig /Flutter/ephemeral/ /Flutter/flutter_export_environment.sh ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Assets/.gitkeep ================================================ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/Event/KeyboardEventHandler.swift ================================================ // // KeyboardEventHandler.swift // flowy_infra_ui // // Created by Jaylen Bian on 7/17/21. // class KeyboardEventHandler: NSObject, FlutterStreamHandler { var isKeyboardShow: Bool = false var eventSink: FlutterEventSink? override init() { super.init() NotificationCenter.default.addObserver( self, selector: #selector(handleKeyboardWillShow), name: UIApplication.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(handleKeyboardDidShow), name: UIApplication.keyboardDidShowNotification, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(handleKeyboardWillHide), name: UIApplication.keyboardWillHideNotification, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(handleKeyboardDidHide), name: UIApplication.keyboardDidHideNotification, object: nil) } func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { eventSink = events return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { eventSink = nil return nil } // MARK: Helper @objc private func handleKeyboardWillShow() { guard !isKeyboardShow else { return } isKeyboardShow = true eventSink?(NSNumber(booleanLiteral: true)) } @objc private func handleKeyboardDidShow() { guard !isKeyboardShow else { return } isKeyboardShow = true eventSink?(NSNumber(booleanLiteral: true)) } @objc private func handleKeyboardWillHide() { guard isKeyboardShow else { return } isKeyboardShow = false eventSink?(NSNumber(booleanLiteral: false)) } @objc private func handleKeyboardDidHide() { guard isKeyboardShow else { return } isKeyboardShow = false eventSink?(NSNumber(booleanLiteral: false)) } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.h ================================================ #import @interface FlowyInfraUIPlugin : NSObject @end ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.m ================================================ #import "FlowyInfraUIPlugin.h" #if __has_include() #import #else // Support project import fallback if the generated compatibility header // is not copied when this plugin is created as a library. // https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 #import "flowy_infra_ui-Swift.h" #endif @implementation FlowyInfraUIPlugin + (void)registerWithRegistrar:(NSObject*)registrar { [SwiftFlowyInfraUIPlugin registerWithRegistrar:registrar]; } @end ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUIPlugin.swift ================================================ import Flutter import UIKit public class SwiftFlowyInfraUIPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "flowy_infra_ui", binaryMessenger: registrar.messenger()) let instance = SwiftFlowyInfraUIPlugin() registrar.addMethodCallDelegate(instance, channel: channel) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { result("iOS " + UIDevice.current.systemVersion) } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/ios/flowy_infra_ui.podspec ================================================ # # To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. # Run `pod lib lint flowy_infra_ui.podspec` to validate before publishing. # Pod::Spec.new do |s| s.name = 'flowy_infra_ui' s.version = '0.0.1' s.summary = 'A new flutter plugin project.' s.description = <<-DESC A new flutter plugin project. DESC s.homepage = 'http://example.com' s.license = { :file => '../LICENSE' } s.author = { 'AppFlowy' => 'annie@appflowy.io' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' s.platform = :ios, '8.0' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } s.swift_version = '5.0' end ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/basis.dart ================================================ // MARK: - Shared Builder typedef IndexedCallback = void Function(int index); typedef IndexedValueCallback = void Function(T value, int index); ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/flowy_infra_ui.dart ================================================ // Basis export '/widget/flowy_tooltip.dart'; export '/widget/separated_flex.dart'; export '/widget/spacing.dart'; export 'basis.dart'; export 'src/flowy_overlay/appflowy_popover.dart'; export 'src/flowy_overlay/flowy_dialog.dart'; // Overlay export 'src/flowy_overlay/flowy_overlay.dart'; export 'src/flowy_overlay/list_overlay.dart'; export 'src/flowy_overlay/option_overlay.dart'; // Keyboard export 'src/keyboard/keyboard_visibility_detector.dart'; export 'style_widget/button.dart'; export 'style_widget/color_picker.dart'; export 'style_widget/divider.dart'; export 'style_widget/icon_button.dart'; export 'style_widget/primary_rounded_button.dart'; export 'style_widget/scrollbar.dart'; export 'style_widget/scrolling/styled_list.dart'; export 'style_widget/scrolling/styled_scroll_bar.dart'; export 'style_widget/text.dart'; export 'style_widget/text_field.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/flowy_infra_ui_web.dart ================================================ // Basis export 'basis.dart'; // Keyboard export 'src/keyboard/keyboard_visibility_detector.dart'; // Overlay export 'src/flowy_overlay/flowy_overlay.dart'; export 'src/flowy_overlay/list_overlay.dart'; export 'src/flowy_overlay/option_overlay.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart ================================================ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flutter/material.dart'; export 'package:appflowy_popover/appflowy_popover.dart'; class ShadowConstants { ShadowConstants._(); static const List lightSmall = [ BoxShadow(offset: Offset(0, 4), blurRadius: 20, color: Color(0x1A1F2329)), ]; static const List lightMedium = [ BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x121F2225)), ]; static const List darkSmall = [ BoxShadow(offset: Offset(0, 2), blurRadius: 16, color: Color(0x7A000000)), ]; static const List darkMedium = [ BoxShadow(offset: Offset(0, 4), blurRadius: 32, color: Color(0x7A000000)), ]; } class AppFlowyPopover extends StatelessWidget { const AppFlowyPopover({ super.key, required this.child, required this.popupBuilder, this.direction = PopoverDirection.rightWithTopAligned, this.onOpen, this.onClose, this.canClose, this.constraints = const BoxConstraints(maxWidth: 240, maxHeight: 600), this.mutex, this.triggerActions = PopoverTriggerFlags.click, this.offset, this.controller, this.asBarrier = false, this.margin = const EdgeInsets.all(6), this.windowPadding = const EdgeInsets.all(8.0), this.clickHandler = PopoverClickHandler.listener, this.skipTraversal = false, this.decorationColor, this.borderRadius, this.popoverDecoration, this.animationDuration = const Duration(), this.slideDistance = 5.0, this.beginScaleFactor = 0.9, this.endScaleFactor = 1.0, this.beginOpacity = 0.0, this.endOpacity = 1.0, this.showAtCursor = false, }); final Widget child; final PopoverController? controller; final Widget Function(BuildContext context) popupBuilder; final PopoverDirection direction; final int triggerActions; final BoxConstraints constraints; final VoidCallback? onOpen; final VoidCallback? onClose; final Future Function()? canClose; final PopoverMutex? mutex; final Offset? offset; final bool asBarrier; final EdgeInsets margin; final EdgeInsets windowPadding; final Color? decorationColor; final BorderRadius? borderRadius; final Duration animationDuration; final double slideDistance; final double beginScaleFactor; final double endScaleFactor; final double beginOpacity; final double endOpacity; final Decoration? popoverDecoration; /// The widget that will be used to trigger the popover. /// /// Why do we need this? /// Because if the parent widget of the popover is GestureDetector, /// the conflict won't be resolve by using Listener, we want these two gestures exclusive. final PopoverClickHandler clickHandler; /// If true the popover will not participate in focus traversal. /// final bool skipTraversal; /// Whether the popover should be shown at the cursor position. /// If true, the [offset] will be ignored. /// /// This only works when using [PopoverClickHandler.listener] as the click handler. /// /// Alternatively for having a normal popover, and use the cursor position only on /// secondary click, consider showing the popover programatically with [PopoverController.showAt]. /// final bool showAtCursor; @override Widget build(BuildContext context) { return Popover( controller: controller, animationDuration: animationDuration, slideDistance: slideDistance, beginScaleFactor: beginScaleFactor, endScaleFactor: endScaleFactor, beginOpacity: beginOpacity, endOpacity: endOpacity, onOpen: onOpen, onClose: onClose, canClose: canClose, direction: direction, mutex: mutex, asBarrier: asBarrier, triggerActions: triggerActions, windowPadding: windowPadding, offset: offset, clickHandler: clickHandler, skipTraversal: skipTraversal, popupBuilder: (context) => _PopoverContainer( constraints: constraints, margin: margin, decoration: popoverDecoration, decorationColor: decorationColor, borderRadius: borderRadius, child: popupBuilder(context), ), showAtCursor: showAtCursor, child: child, ); } } class _PopoverContainer extends StatelessWidget { const _PopoverContainer({ this.decorationColor, this.borderRadius, this.decoration, required this.child, required this.margin, required this.constraints, }); final Widget child; final BoxConstraints constraints; final EdgeInsets margin; final Color? decorationColor; final BorderRadius? borderRadius; final Decoration? decoration; @override Widget build(BuildContext context) { return Material( type: MaterialType.transparency, child: Container( padding: margin, decoration: decoration ?? context.getPopoverDecoration( color: decorationColor, borderRadius: borderRadius, ), constraints: constraints, child: child, ), ); } } extension PopoverDecoration on BuildContext { /// The decoration of the popover. /// /// Don't customize the entire decoration of the popover, /// use the built-in popoverDecoration instead and ask the designer before changing it. ShapeDecoration getPopoverDecoration({ Color? color, BorderRadius? borderRadius, }) { final borderColor = Theme.of(this).brightness == Brightness.light ? ColorSchemeConstants.lightBorderColor : ColorSchemeConstants.darkBorderColor; final shadows = Theme.of(this).brightness == Brightness.light ? ShadowConstants.lightSmall : ShadowConstants.darkSmall; return ShapeDecoration( color: color ?? Theme.of(this).cardColor, shape: RoundedRectangleBorder( side: BorderSide( width: 1, strokeAlign: BorderSide.strokeAlignOutside, color: color != Colors.transparent ? borderColor : color!, ), borderRadius: borderRadius ?? BorderRadius.circular(10), ), shadows: shadows, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart ================================================ import 'package:flutter/material.dart'; const _overlayContainerPadding = EdgeInsets.symmetric(vertical: 12); const overlayContainerMaxWidth = 760.0; const overlayContainerMinWidth = 320.0; const _defaultInsetPadding = EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0); class FlowyDialog extends StatelessWidget { const FlowyDialog({ super.key, required this.child, this.title, this.shape, this.constraints, this.padding = _overlayContainerPadding, this.backgroundColor, this.expandHeight = true, this.alignment, this.insetPadding, this.width, }); final Widget? title; final ShapeBorder? shape; final Widget child; final BoxConstraints? constraints; final EdgeInsets padding; final Color? backgroundColor; final bool expandHeight; // Position of the Dialog final Alignment? alignment; // Inset of the Dialog final EdgeInsets? insetPadding; final double? width; @override Widget build(BuildContext context) { final windowSize = MediaQuery.of(context).size; final size = windowSize * 0.7; return SimpleDialog( alignment: alignment, insetPadding: insetPadding ?? _defaultInsetPadding, contentPadding: EdgeInsets.zero, backgroundColor: backgroundColor ?? Theme.of(context).cardColor, title: title, shape: shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), clipBehavior: Clip.antiAliasWithSaveLayer, children: [ Material( type: MaterialType.transparency, child: Container( height: expandHeight ? size.height : null, width: width ?? size.width, constraints: constraints, child: child, ), ) ], ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart ================================================ // ignore_for_file: unused_element import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flowy_infra_ui/src/flowy_overlay/layout.dart'; /// Specifies how overlay are anchored to the SourceWidget enum AnchorDirection { // Corner aligned with a corner of the SourceWidget topLeft, topRight, bottomLeft, bottomRight, center, // Edge aligned with a edge of the SourceWidget topWithLeftAligned, topWithCenterAligned, topWithRightAligned, rightWithTopAligned, rightWithCenterAligned, rightWithBottomAligned, bottomWithLeftAligned, bottomWithCenterAligned, bottomWithRightAligned, leftWithTopAligned, leftWithCenterAligned, leftWithBottomAligned, // Custom position custom, } /// The behaviour of overlay when overlap with anchor widget enum OverlapBehaviour { /// Maintain overlay size, which may cover the anchor widget. none, /// Resize overlay to avoid overlapping the anchor widget. stretch, } enum OnBackBehavior { /// Won't handle the back action none, /// Animate to get the user's attention alert, /// Intercept the back action and abort directly abort, /// Intercept the back action and dismiss overlay dismiss, } class FlowyOverlayStyle { final Color barrierColor; bool blur; FlowyOverlayStyle( {this.barrierColor = Colors.transparent, this.blur = false}); } final GlobalKey _key = GlobalKey(); /// Invoke this method in app generation process Widget overlayManagerBuilder(BuildContext context, Widget? child) { assert(child != null, 'Child can\'t be null.'); return FlowyOverlay(key: _key, child: child!); } abstract mixin class FlowyOverlayDelegate { bool asBarrier() => false; void didRemove() => {}; } class FlowyOverlay extends StatefulWidget { const FlowyOverlay({super.key, required this.child}); final Widget child; static FlowyOverlayState of( BuildContext context, { bool rootOverlay = false, }) { FlowyOverlayState? state = maybeOf(context, rootOverlay: rootOverlay); assert(() { if (state == null) { throw FlutterError( 'Can\'t find overlay manager in current context, please check if already wrapped by overlay manager.', ); } return true; }()); return state!; } static FlowyOverlayState? maybeOf( BuildContext context, { bool rootOverlay = false, }) { FlowyOverlayState? state; if (rootOverlay) { state = context.findRootAncestorStateOfType(); } else { state = context.findAncestorStateOfType(); } return state; } static Future show({ required BuildContext context, required WidgetBuilder builder, }) async { await showDialog( context: context, builder: builder, ); } static void pop(BuildContext context) { Navigator.of(context).pop(); } @override FlowyOverlayState createState() => FlowyOverlayState(); } class OverlayItem { Widget widget; String identifier; FlowyOverlayDelegate? delegate; FocusNode focusNode; OverlayItem({ required this.widget, required this.identifier, required this.focusNode, this.delegate, }); void dispose() { focusNode.dispose(); } } class FlowyOverlayState extends State { final List _overlayList = []; FlowyOverlayStyle style = FlowyOverlayStyle(); final Map _keyboardShortcutBindings = {}; /// Insert a overlay widget which frame is set by the widget, not the component. /// Be sure to specify the offset and size using a anchorable widget (like `Position`, `CompositedTransformFollower`) void insertCustom({ required Widget widget, required String identifier, FlowyOverlayDelegate? delegate, }) { _showOverlay( widget: widget, identifier: identifier, shouldAnchor: false, delegate: delegate, ); } void insertWithRect({ required Widget widget, required String identifier, required Offset anchorPosition, required Size anchorSize, AnchorDirection? anchorDirection, FlowyOverlayDelegate? delegate, OverlapBehaviour? overlapBehaviour, FlowyOverlayStyle? style, }) { if (style != null) { this.style = style; } _showOverlay( widget: widget, identifier: identifier, shouldAnchor: true, delegate: delegate, anchorPosition: anchorPosition, anchorSize: anchorSize, anchorDirection: anchorDirection, overlapBehaviour: overlapBehaviour, ); } void insertWithAnchor({ required Widget widget, required String identifier, required BuildContext anchorContext, AnchorDirection? anchorDirection, FlowyOverlayDelegate? delegate, OverlapBehaviour? overlapBehaviour, FlowyOverlayStyle? style, Offset? anchorOffset, }) { this.style = style ?? FlowyOverlayStyle(); _showOverlay( widget: widget, identifier: identifier, shouldAnchor: true, delegate: delegate, anchorContext: anchorContext, anchorDirection: anchorDirection, overlapBehaviour: overlapBehaviour, anchorOffset: anchorOffset, ); } void remove(String identifier) { setState(() { final index = _overlayList.indexWhere((item) => item.identifier == identifier); if (index != -1) { final OverlayItem item = _overlayList.removeAt(index); item.delegate?.didRemove(); item.dispose(); } }); } void removeAll() { setState(() { if (_overlayList.isEmpty) { return; } final reveredList = _overlayList.reversed.toList(); final firstItem = reveredList.removeAt(0); _overlayList.remove(firstItem); if (firstItem.delegate != null) { firstItem.delegate!.didRemove(); firstItem.dispose(); if (firstItem.delegate!.asBarrier()) { return; } } for (final element in reveredList) { if (element.delegate?.asBarrier() ?? false) { return; } else { element.delegate?.didRemove(); element.dispose(); _overlayList.remove(element); } } }); } void _markDirty() { if (mounted) { setState(() {}); } } void _showOverlay({ required Widget widget, required String identifier, required bool shouldAnchor, Offset? anchorPosition, Size? anchorSize, AnchorDirection? anchorDirection, BuildContext? anchorContext, Offset? anchorOffset, OverlapBehaviour? overlapBehaviour, FlowyOverlayDelegate? delegate, }) { Widget overlay = widget; final offset = anchorOffset ?? Offset.zero; final focusNode = FocusNode(); if (shouldAnchor) { assert( anchorPosition != null || anchorContext != null, 'Must provide `anchorPosition` or `anchorContext` to locating overlay.', ); Offset targetAnchorPosition = anchorPosition ?? Offset.zero; Size targetAnchorSize = anchorSize ?? Size.zero; if (anchorContext != null) { RenderObject renderObject = anchorContext.findRenderObject()!; assert( renderObject is RenderBox, 'Unexpecteded non-RenderBox render object caught.', ); final renderBox = renderObject as RenderBox; targetAnchorPosition = renderBox.localToGlobal(Offset.zero); targetAnchorSize = renderBox.size; } final anchorRect = Rect.fromLTWH( targetAnchorPosition.dx + offset.dx, targetAnchorPosition.dy + offset.dy, targetAnchorSize.width, targetAnchorSize.height, ); overlay = CustomSingleChildLayout( delegate: OverlayLayoutDelegate( anchorRect: anchorRect, anchorDirection: anchorDirection ?? AnchorDirection.rightWithTopAligned, overlapBehaviour: overlapBehaviour ?? OverlapBehaviour.stretch, ), child: Focus( focusNode: focusNode, onKeyEvent: (node, event) { KeyEventResult result = KeyEventResult.ignored; for (final ShortcutActivator activator in _keyboardShortcutBindings.keys) { if (activator.accepts(event, HardwareKeyboard.instance)) { _keyboardShortcutBindings[activator]!.call(identifier); result = KeyEventResult.handled; } } return result; }, child: widget), ); } setState(() { _overlayList.add(OverlayItem( widget: overlay, identifier: identifier, focusNode: focusNode, delegate: delegate, )); }); } @override void initState() { super.initState(); _keyboardShortcutBindings.addAll({ LogicalKeySet(LogicalKeyboardKey.escape): (identifier) => remove(identifier), }); } @override Widget build(BuildContext context) { final overlays = _overlayList.map((item) { Widget widget = item.widget; // requestFocus will cause the children weird focus behaviors. // item.focusNode.requestFocus(); if (item.delegate?.asBarrier() ?? false) { widget = Container( color: style.barrierColor, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: _handleTapOnBackground, child: widget, ), ); } return widget; }).toList(); List children = [widget.child]; Widget? child = _renderBackground(overlays); if (child != null) { children.add(child); } // Try to fix there is no overlay for editabletext widget. e.g. TextField. // // Check out the TextSelectionOverlay class in text_selection.dart. // // ... // // final OverlayState? overlay = Overlay.of(context, rootOverlay: true); // // assert( // // overlay != null, // // 'No Overlay widget exists above $context.\n' // // 'Usually the Navigator created by WidgetsApp provides the overlay. Perhaps your ' // // 'app content was created above the Navigator with the WidgetsApp builder parameter.', // // ); // // ... return MaterialApp( theme: Theme.of(context), debugShowCheckedModeBanner: false, home: Stack(children: children..addAll(overlays)), ); } void _handleTapOnBackground() => removeAll(); Widget? _renderBackground(List overlays) { Widget? child; if (overlays.isNotEmpty) { child = Container( color: style.barrierColor, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: _handleTapOnBackground, ), ); if (style.blur) { child = BackdropFilter( filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), child: child, ); } } return child; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover_layout.dart ================================================ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'flowy_overlay.dart'; class PopoverLayoutDelegate extends SingleChildLayoutDelegate { PopoverLayoutDelegate({ required this.anchorRect, required this.anchorDirection, required this.overlapBehaviour, }); final Rect anchorRect; final AnchorDirection anchorDirection; final OverlapBehaviour overlapBehaviour; @override bool shouldRelayout(PopoverLayoutDelegate oldDelegate) { return anchorRect != oldDelegate.anchorRect || anchorDirection != oldDelegate.anchorDirection || overlapBehaviour != oldDelegate.overlapBehaviour; } @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { switch (overlapBehaviour) { case OverlapBehaviour.none: return constraints.loosen(); case OverlapBehaviour.stretch: BoxConstraints childConstraints; switch (anchorDirection) { case AnchorDirection.topLeft: childConstraints = BoxConstraints.loose(Size( anchorRect.left, anchorRect.top, )); break; case AnchorDirection.topRight: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, anchorRect.top, )); break; case AnchorDirection.bottomLeft: childConstraints = BoxConstraints.loose(Size( anchorRect.left, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.bottomRight: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.center: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth, constraints.maxHeight, )); break; case AnchorDirection.topWithLeftAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.left, anchorRect.top, )); break; case AnchorDirection.topWithCenterAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth, anchorRect.top, )); break; case AnchorDirection.topWithRightAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.right, anchorRect.top, )); break; case AnchorDirection.rightWithTopAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, constraints.maxHeight - anchorRect.top, )); break; case AnchorDirection.rightWithCenterAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, constraints.maxHeight, )); break; case AnchorDirection.rightWithBottomAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, anchorRect.bottom, )); break; case AnchorDirection.bottomWithLeftAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.left, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.bottomWithCenterAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.bottomWithRightAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.right, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.leftWithTopAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.left, constraints.maxHeight - anchorRect.top, )); break; case AnchorDirection.leftWithCenterAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.left, constraints.maxHeight, )); break; case AnchorDirection.leftWithBottomAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.left, anchorRect.bottom, )); break; case AnchorDirection.custom: childConstraints = constraints.loosen(); break; } return childConstraints; } } @override Offset getPositionForChild(Size size, Size childSize) { Offset position; switch (anchorDirection) { case AnchorDirection.topLeft: position = Offset( anchorRect.left - childSize.width, anchorRect.top - childSize.height, ); break; case AnchorDirection.topRight: position = Offset( anchorRect.right, anchorRect.top - childSize.height, ); break; case AnchorDirection.bottomLeft: position = Offset( anchorRect.left - childSize.width, anchorRect.bottom, ); break; case AnchorDirection.bottomRight: position = Offset( anchorRect.right, anchorRect.bottom, ); break; case AnchorDirection.center: position = anchorRect.center; break; case AnchorDirection.topWithLeftAligned: position = Offset( anchorRect.left, anchorRect.top - childSize.height, ); break; case AnchorDirection.topWithCenterAligned: position = Offset( anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, anchorRect.top - childSize.height, ); break; case AnchorDirection.topWithRightAligned: position = Offset( anchorRect.right - childSize.width, anchorRect.top - childSize.height, ); break; case AnchorDirection.rightWithTopAligned: position = Offset(anchorRect.right, anchorRect.top); break; case AnchorDirection.rightWithCenterAligned: position = Offset( anchorRect.right, anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, ); break; case AnchorDirection.rightWithBottomAligned: position = Offset( anchorRect.right, anchorRect.bottom - childSize.height, ); break; case AnchorDirection.bottomWithLeftAligned: position = Offset( anchorRect.left, anchorRect.bottom, ); break; case AnchorDirection.bottomWithCenterAligned: position = Offset( anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, anchorRect.bottom, ); break; case AnchorDirection.bottomWithRightAligned: position = Offset( anchorRect.right - childSize.width, anchorRect.bottom, ); break; case AnchorDirection.leftWithTopAligned: position = Offset( anchorRect.left - childSize.width, anchorRect.top, ); break; case AnchorDirection.leftWithCenterAligned: position = Offset( anchorRect.left - childSize.width, anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, ); break; case AnchorDirection.leftWithBottomAligned: position = Offset( anchorRect.left - childSize.width, anchorRect.bottom - childSize.height, ); break; default: throw UnimplementedError(); } return Offset( math.max(0.0, math.min(size.width - childSize.width, position.dx)), math.max(0.0, math.min(size.height - childSize.height, position.dy)), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart ================================================ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'flowy_overlay.dart'; class OverlayLayoutDelegate extends SingleChildLayoutDelegate { OverlayLayoutDelegate({ required this.anchorRect, required this.anchorDirection, required this.overlapBehaviour, }); final Rect anchorRect; final AnchorDirection anchorDirection; final OverlapBehaviour overlapBehaviour; @override bool shouldRelayout(OverlayLayoutDelegate oldDelegate) { return anchorRect != oldDelegate.anchorRect || anchorDirection != oldDelegate.anchorDirection || overlapBehaviour != oldDelegate.overlapBehaviour; } @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { switch (overlapBehaviour) { case OverlapBehaviour.none: return constraints.loosen(); case OverlapBehaviour.stretch: BoxConstraints childConstraints; switch (anchorDirection) { case AnchorDirection.topLeft: childConstraints = BoxConstraints.loose(Size( anchorRect.left, anchorRect.top, )); break; case AnchorDirection.topRight: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, anchorRect.top, )); break; case AnchorDirection.bottomLeft: childConstraints = BoxConstraints.loose(Size( anchorRect.left, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.bottomRight: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.center: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth, constraints.maxHeight, )); break; case AnchorDirection.topWithLeftAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.left, anchorRect.top, )); break; case AnchorDirection.topWithCenterAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth, anchorRect.top, )); break; case AnchorDirection.topWithRightAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.right, anchorRect.top, )); break; case AnchorDirection.rightWithTopAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, constraints.maxHeight - anchorRect.top, )); break; case AnchorDirection.rightWithCenterAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, constraints.maxHeight, )); break; case AnchorDirection.rightWithBottomAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth - anchorRect.right, anchorRect.bottom, )); break; case AnchorDirection.bottomWithLeftAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.left, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.bottomWithCenterAligned: childConstraints = BoxConstraints.loose(Size( constraints.maxWidth, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.bottomWithRightAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.right, constraints.maxHeight - anchorRect.bottom, )); break; case AnchorDirection.leftWithTopAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.left, constraints.maxHeight - anchorRect.top, )); break; case AnchorDirection.leftWithCenterAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.left, constraints.maxHeight, )); break; case AnchorDirection.leftWithBottomAligned: childConstraints = BoxConstraints.loose(Size( anchorRect.left, anchorRect.bottom, )); break; case AnchorDirection.custom: childConstraints = constraints.loosen(); break; } return childConstraints; } } @override Offset getPositionForChild(Size size, Size childSize) { Offset position; switch (anchorDirection) { case AnchorDirection.topLeft: position = Offset( anchorRect.left - childSize.width, anchorRect.top - childSize.height, ); break; case AnchorDirection.topRight: position = Offset( anchorRect.right, anchorRect.top - childSize.height, ); break; case AnchorDirection.bottomLeft: position = Offset( anchorRect.left - childSize.width, anchorRect.bottom, ); break; case AnchorDirection.bottomRight: position = Offset( anchorRect.right, anchorRect.bottom, ); break; case AnchorDirection.center: position = anchorRect.center; break; case AnchorDirection.topWithLeftAligned: position = Offset( anchorRect.left, anchorRect.top - childSize.height, ); break; case AnchorDirection.topWithCenterAligned: position = Offset( anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, anchorRect.top - childSize.height, ); break; case AnchorDirection.topWithRightAligned: position = Offset( anchorRect.right - childSize.width, anchorRect.top - childSize.height, ); break; case AnchorDirection.rightWithTopAligned: position = Offset(anchorRect.right, anchorRect.top); break; case AnchorDirection.rightWithCenterAligned: position = Offset( anchorRect.right, anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, ); break; case AnchorDirection.rightWithBottomAligned: position = Offset( anchorRect.right, anchorRect.bottom - childSize.height, ); break; case AnchorDirection.bottomWithLeftAligned: position = Offset( anchorRect.left, anchorRect.bottom, ); break; case AnchorDirection.bottomWithCenterAligned: position = Offset( anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, anchorRect.bottom, ); break; case AnchorDirection.bottomWithRightAligned: position = Offset( anchorRect.right - childSize.width, anchorRect.bottom, ); break; case AnchorDirection.leftWithTopAligned: position = Offset( anchorRect.left - childSize.width, anchorRect.top, ); break; case AnchorDirection.leftWithCenterAligned: position = Offset( anchorRect.left - childSize.width, anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, ); break; case AnchorDirection.leftWithBottomAligned: position = Offset( anchorRect.left - childSize.width, anchorRect.bottom - childSize.height, ); break; default: throw UnimplementedError(); } return Offset( math.max(0.0, math.min(size.width - childSize.width, position.dx)), math.max(0.0, math.min(size.height - childSize.height, position.dy)), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart ================================================ import 'dart:math'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flowy_infra_ui/style_widget/decoration.dart'; class ListOverlayFooter { Widget widget; double height; EdgeInsets padding; ListOverlayFooter({ required this.widget, required this.height, this.padding = EdgeInsets.zero, }); } class ListOverlay extends StatelessWidget { const ListOverlay({ super.key, required this.itemBuilder, this.itemCount = 0, this.controller, this.constraints = const BoxConstraints(), this.footer, }); final IndexedWidgetBuilder itemBuilder; final int itemCount; final ScrollController? controller; final BoxConstraints constraints; final ListOverlayFooter? footer; @override Widget build(BuildContext context) { const padding = EdgeInsets.symmetric(horizontal: 6, vertical: 6); double totalHeight = constraints.minHeight + padding.vertical; if (footer != null) { totalHeight = totalHeight + footer!.height + footer!.padding.vertical; } final innerConstraints = BoxConstraints( minHeight: totalHeight, maxHeight: max(constraints.maxHeight, totalHeight), minWidth: constraints.minWidth, maxWidth: constraints.maxWidth, ); List children = []; for (var i = 0; i < itemCount; i++) { children.add(itemBuilder(context, i)); } return OverlayContainer( constraints: innerConstraints, padding: padding, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: IntrinsicWidth( child: Column( mainAxisSize: MainAxisSize.max, children: [ ...children, if (footer != null) Padding( padding: footer!.padding, child: footer!.widget, ), ], ), ), ), ); } static void showWithAnchor( BuildContext context, { required String identifier, required IndexedWidgetBuilder itemBuilder, int itemCount = 0, ScrollController? controller, BoxConstraints constraints = const BoxConstraints(), required BuildContext anchorContext, AnchorDirection? anchorDirection, FlowyOverlayDelegate? delegate, OverlapBehaviour? overlapBehaviour, FlowyOverlayStyle? style, Offset? anchorOffset, ListOverlayFooter? footer, }) { FlowyOverlay.of(context).insertWithAnchor( widget: ListOverlay( itemBuilder: itemBuilder, itemCount: itemCount, controller: controller, constraints: constraints, footer: footer, ), identifier: identifier, anchorContext: anchorContext, anchorDirection: anchorDirection, delegate: delegate, overlapBehaviour: overlapBehaviour, anchorOffset: anchorOffset, style: style, ); } } const overlayContainerPadding = EdgeInsets.all(12); class OverlayContainer extends StatelessWidget { final Widget child; final BoxConstraints? constraints; final EdgeInsets padding; const OverlayContainer({ required this.child, this.constraints, this.padding = overlayContainerPadding, super.key, }); @override Widget build(BuildContext context) { return Material( type: MaterialType.transparency, child: Container( padding: padding, decoration: FlowyDecoration.decoration( Theme.of(context).colorScheme.surface, Theme.of(context).colorScheme.shadow.withValues(alpha: 0.15), ), constraints: constraints, child: child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/option_overlay.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; import 'package:flutter/material.dart'; class OptionItem { const OptionItem(this.icon, this.title); final Icon? icon; final String title; } class OptionOverlay extends StatelessWidget { const OptionOverlay({ super.key, required this.items, this.onHover, this.onTap, }); final List items; final IndexedValueCallback? onHover; final IndexedValueCallback? onTap; static void showWithAnchor( BuildContext context, { required List items, required String identifier, required BuildContext anchorContext, IndexedValueCallback? onHover, IndexedValueCallback? onTap, AnchorDirection? anchorDirection, OverlapBehaviour? overlapBehaviour, FlowyOverlayDelegate? delegate, }) { FlowyOverlay.of(context).insertWithAnchor( widget: OptionOverlay( items: items, onHover: onHover, onTap: onTap, ), identifier: identifier, anchorContext: anchorContext, anchorDirection: anchorDirection, delegate: delegate, overlapBehaviour: overlapBehaviour, ); } @override Widget build(BuildContext context) { final List<_OptionListItem> listItems = items.map((e) => _OptionListItem(e)).toList(); return ListOverlay( itemBuilder: (context, index) { return MouseRegion( cursor: SystemMouseCursors.click, onHover: onHover != null ? (_) => onHover!(items[index], index) : null, child: GestureDetector( onTap: onTap != null ? () => onTap!(items[index], index) : null, child: listItems[index], ), ); }, itemCount: listItems.length, ); } } class _OptionListItem extends StatelessWidget { const _OptionListItem( this.value, { super.key, }); final T value; @override Widget build(BuildContext context) { if (T == String || T == OptionItem) { var children = []; if (value is String) { children = [ Text(value as String), ]; } else if (value is OptionItem) { final optionItem = value as OptionItem; children = [ if (optionItem.icon != null) optionItem.icon!, Text(optionItem.title), ]; } return Column( mainAxisSize: MainAxisSize.min, children: children, ); } throw UnimplementedError('The type $T is not supported by option list.'); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/focus/auto_unfocus_overlay.dart ================================================ import 'package:flutter/material.dart'; class AutoUnfocus extends StatelessWidget { const AutoUnfocus({ super.key, required this.child, }); final Widget child; @override Widget build(BuildContext context) { return GestureDetector( onTap: () => _unfocusWidget(context), child: child, ); } void _unfocusWidget(BuildContext context) { final focusing = FocusScope.of(context); if (!focusing.hasPrimaryFocus && focusing.hasFocus) { FocusManager.instance.primaryFocus?.unfocus(); } } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart ================================================ import 'dart:async'; import 'package:flowy_infra_ui_platform_interface/flowy_infra_ui_platform_interface.dart'; import 'package:flutter/material.dart'; class KeyboardVisibilityDetector extends StatefulWidget { const KeyboardVisibilityDetector({ super.key, required this.child, this.onKeyboardVisibilityChange, }); final Widget child; final void Function(bool)? onKeyboardVisibilityChange; @override State createState() => _KeyboardVisibilityDetectorState(); } class _KeyboardVisibilityDetectorState extends State { FlowyInfraUIPlatform get _platform => FlowyInfraUIPlatform.instance; bool isObserving = false; bool isKeyboardVisible = false; late StreamSubscription _keyboardSubscription; @override void initState() { super.initState(); _keyboardSubscription = _platform.onKeyboardVisibilityChange.listen((newValue) { setState(() { isKeyboardVisible = newValue; if (widget.onKeyboardVisibilityChange != null) { widget.onKeyboardVisibilityChange!(newValue); } }); }); } @override void dispose() { _keyboardSubscription.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return _KeyboardVisibilityDetectorInheritedWidget( isKeyboardVisible: isKeyboardVisible, child: widget.child, ); } } class _KeyboardVisibilityDetectorInheritedWidget extends InheritedWidget { const _KeyboardVisibilityDetectorInheritedWidget({ required this.isKeyboardVisible, required super.child, }); final bool isKeyboardVisible; @override bool updateShouldNotify( _KeyboardVisibilityDetectorInheritedWidget oldWidget) { return isKeyboardVisible != oldWidget.isKeyboardVisible; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/bar_title.dart ================================================ import 'package:flutter/material.dart'; class FlowyBarTitle extends StatelessWidget { final String title; const FlowyBarTitle({ super.key, required this.title, }); @override Widget build(BuildContext context) { return Text( title, style: const TextStyle(fontSize: 24), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart ================================================ import 'dart:io'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; class FlowyIconTextButton extends StatelessWidget { final Widget Function(bool onHover) textBuilder; final VoidCallback? onTap; final VoidCallback? onSecondaryTap; final void Function(bool)? onHover; final EdgeInsets? margin; final Widget? Function(bool onHover)? leftIconBuilder; final Widget? Function(bool onHover)? rightIconBuilder; final Color? hoverColor; final bool isSelected; final BorderRadius? radius; final BoxDecoration? decoration; final bool useIntrinsicWidth; final bool disable; final double disableOpacity; final Size? leftIconSize; final bool expandText; final MainAxisAlignment mainAxisAlignment; final bool showDefaultBoxDecorationOnMobile; final double iconPadding; final bool expand; final Color? borderColor; final bool resetHoverOnRebuild; const FlowyIconTextButton({ super.key, required this.textBuilder, this.onTap, this.onSecondaryTap, this.onHover, this.margin, this.leftIconBuilder, this.rightIconBuilder, this.hoverColor, this.isSelected = false, this.radius, this.decoration, this.useIntrinsicWidth = false, this.disable = false, this.disableOpacity = 0.5, this.leftIconSize = const Size.square(16), this.expandText = true, this.mainAxisAlignment = MainAxisAlignment.center, this.showDefaultBoxDecorationOnMobile = false, this.iconPadding = 6, this.expand = false, this.borderColor, this.resetHoverOnRebuild = true, }); @override Widget build(BuildContext context) { final color = hoverColor ?? Theme.of(context).colorScheme.secondary; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: disable ? null : onTap, onSecondaryTap: disable ? null : onSecondaryTap, child: FlowyHover( resetHoverOnRebuild: resetHoverOnRebuild, cursor: disable ? SystemMouseCursors.forbidden : SystemMouseCursors.click, style: HoverStyle( borderRadius: radius ?? Corners.s6Border, hoverColor: color, border: borderColor == null ? null : Border.all(color: borderColor!), ), onHover: disable ? null : onHover, isSelected: () => isSelected, builder: (context, onHover) => _render(context, onHover), ), ); } Widget _render(BuildContext context, bool onHover) { final List children = []; final Widget? leftIcon = leftIconBuilder?.call(onHover); if (leftIcon != null) { children.add( SizedBox.fromSize( size: leftIconSize, child: leftIcon, ), ); children.add(HSpace(iconPadding)); } if (expandText) { children.add(Expanded(child: textBuilder(onHover))); } else { children.add(textBuilder(onHover)); } final Widget? rightIcon = rightIconBuilder?.call(onHover); if (rightIcon != null) { children.add(HSpace(iconPadding)); // No need to define the size of rightIcon. Just use its intrinsic width children.add(rightIcon); } Widget child = Row( mainAxisAlignment: mainAxisAlignment, crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, children: children, ); if (useIntrinsicWidth) { child = IntrinsicWidth(child: child); } final decoration = this.decoration ?? (showDefaultBoxDecorationOnMobile && (Platform.isIOS || Platform.isAndroid) ? BoxDecoration( border: Border.all( color: borderColor ?? Theme.of(context).colorScheme.surfaceContainerHighest, width: 1.0, )) : null); return Container( decoration: decoration, child: Padding( padding: margin ?? const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: child, ), ); } } class FlowyButton extends StatelessWidget { final Widget text; final VoidCallback? onTap; final VoidCallback? onSecondaryTap; final void Function(bool)? onHover; final EdgeInsetsGeometry? margin; final Widget? leftIcon; final Widget? rightIcon; final Color? hoverColor; final bool isSelected; final BorderRadius? radius; final BoxDecoration? decoration; final bool useIntrinsicWidth; final bool disable; final double disableOpacity; final Size? leftIconSize; final bool expandText; final MainAxisAlignment mainAxisAlignment; final bool showDefaultBoxDecorationOnMobile; final double iconPadding; final bool expand; final Color? borderColor; final Color? backgroundColor; final bool resetHoverOnRebuild; const FlowyButton({ super.key, required this.text, this.onTap, this.onSecondaryTap, this.onHover, this.margin, this.leftIcon, this.rightIcon, this.hoverColor, this.isSelected = false, this.radius, this.decoration, this.useIntrinsicWidth = false, this.disable = false, this.disableOpacity = 0.5, this.leftIconSize = const Size.square(16), this.expandText = true, this.mainAxisAlignment = MainAxisAlignment.center, this.showDefaultBoxDecorationOnMobile = false, this.iconPadding = 6, this.expand = false, this.borderColor, this.backgroundColor, this.resetHoverOnRebuild = true, }); @override Widget build(BuildContext context) { final color = hoverColor ?? Theme.of(context).colorScheme.secondary; final alpha = (255 * disableOpacity).toInt(); color.withAlpha(alpha); if (Platform.isIOS || Platform.isAndroid) { return InkWell( splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, onTap: disable ? null : onTap, onSecondaryTap: disable ? null : onSecondaryTap, borderRadius: radius ?? Corners.s6Border, child: _render(context), ); } return GestureDetector( behavior: HitTestBehavior.opaque, onTap: disable ? null : onTap, onSecondaryTap: disable ? null : onSecondaryTap, child: FlowyHover( resetHoverOnRebuild: resetHoverOnRebuild, cursor: disable ? SystemMouseCursors.forbidden : SystemMouseCursors.click, style: HoverStyle( borderRadius: radius ?? Corners.s6Border, hoverColor: color, border: borderColor == null ? null : Border.all(color: borderColor!), backgroundColor: backgroundColor ?? Colors.transparent, ), onHover: disable ? null : onHover, isSelected: () => isSelected, builder: (context, onHover) => _render(context), ), ); } Widget _render(BuildContext context) { final List children = []; if (leftIcon != null) { children.add( SizedBox.fromSize( size: leftIconSize, child: leftIcon!, ), ); children.add(HSpace(iconPadding)); } if (expandText) { children.add(Expanded(child: text)); } else { children.add(text); } if (rightIcon != null) { children.add(HSpace(iconPadding)); // No need to define the size of rightIcon. Just use its intrinsic width children.add(rightIcon!); } Widget child = Row( mainAxisAlignment: mainAxisAlignment, crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, children: children, ); if (useIntrinsicWidth) { child = IntrinsicWidth(child: child); } var decoration = this.decoration; if (decoration == null && (showDefaultBoxDecorationOnMobile && (Platform.isIOS || Platform.isAndroid))) { decoration = BoxDecoration( color: backgroundColor ?? Theme.of(context).colorScheme.surface, ); } if (decoration == null && (Platform.isIOS || Platform.isAndroid)) { if (showDefaultBoxDecorationOnMobile) { decoration = BoxDecoration( border: Border.all( color: borderColor ?? Theme.of(context).colorScheme.outline, width: 1.0, ), borderRadius: radius, ); } else if (backgroundColor != null) { decoration = BoxDecoration( color: backgroundColor, borderRadius: radius, ); } } return Container( decoration: decoration, child: Padding( padding: margin ?? const EdgeInsets.symmetric( horizontal: 6, vertical: 4, ), child: child, ), ); } } class FlowyTextButton extends StatelessWidget { const FlowyTextButton( this.text, { super.key, this.onPressed, this.fontSize, this.fontColor, this.fontHoverColor, this.overflow = TextOverflow.ellipsis, this.fontWeight, this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), this.hoverColor, this.fillColor, this.heading, this.radius, this.mainAxisAlignment = MainAxisAlignment.start, this.tooltip, this.constraints = const BoxConstraints(minWidth: 0.0, minHeight: 0.0), this.decoration, this.fontFamily, this.isDangerous = false, this.borderColor, this.lineHeight, }); factory FlowyTextButton.primary({ required BuildContext context, required String text, VoidCallback? onPressed, }) => FlowyTextButton( text, constraints: const BoxConstraints(minHeight: 32), fillColor: Theme.of(context).colorScheme.primary, hoverColor: const Color(0xFF005483), fontColor: Theme.of(context).colorScheme.onPrimary, fontHoverColor: Colors.white, onPressed: onPressed, ); factory FlowyTextButton.secondary({ required BuildContext context, required String text, VoidCallback? onPressed, }) => FlowyTextButton( text, constraints: const BoxConstraints(minHeight: 32), fillColor: Colors.transparent, hoverColor: Theme.of(context).colorScheme.primary, fontColor: Theme.of(context).colorScheme.primary, borderColor: Theme.of(context).colorScheme.primary, fontHoverColor: Colors.white, onPressed: onPressed, ); final String text; final FontWeight? fontWeight; final Color? fontColor; final Color? fontHoverColor; final double? fontSize; final TextOverflow overflow; final VoidCallback? onPressed; final EdgeInsets padding; final Widget? heading; final Color? hoverColor; final Color? fillColor; final BorderRadius? radius; final MainAxisAlignment mainAxisAlignment; final String? tooltip; final BoxConstraints constraints; final TextDecoration? decoration; final String? fontFamily; final bool isDangerous; final Color? borderColor; final double? lineHeight; @override Widget build(BuildContext context) { List children = []; if (heading != null) { children.add(heading!); children.add(const HSpace(8)); } children.add(Text( text, overflow: overflow, textAlign: TextAlign.center, )); Widget child = Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: mainAxisAlignment, children: children, ); child = ConstrainedBox( constraints: constraints, child: TextButton( onPressed: onPressed, focusNode: FocusNode(skipTraversal: onPressed == null), style: ButtonStyle( overlayColor: const WidgetStatePropertyAll(Colors.transparent), splashFactory: NoSplash.splashFactory, tapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: WidgetStateProperty.all(padding), elevation: WidgetStateProperty.all(0), shape: WidgetStateProperty.all( RoundedRectangleBorder( side: BorderSide( color: borderColor ?? (isDangerous ? Theme.of(context).colorScheme.error : Colors.transparent), ), borderRadius: radius ?? Corners.s6Border, ), ), textStyle: WidgetStateProperty.all( Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: fontWeight ?? FontWeight.w500, fontSize: fontSize, color: fontColor ?? Theme.of(context).colorScheme.onPrimary, decoration: decoration, fontFamily: fontFamily, height: lineHeight ?? 1.1, ), ), backgroundColor: WidgetStateProperty.resolveWith( (states) { if (states.contains(WidgetState.hovered)) { return hoverColor ?? (isDangerous ? Theme.of(context).colorScheme.error : Theme.of(context).colorScheme.secondary); } return fillColor ?? (isDangerous ? Colors.transparent : Theme.of(context).colorScheme.secondaryContainer); }, ), foregroundColor: WidgetStateProperty.resolveWith( (states) { if (states.contains(WidgetState.hovered)) { return fontHoverColor ?? (fontColor ?? Theme.of(context).colorScheme.onSurface); } return fontColor ?? Theme.of(context).colorScheme.onSurface; }, ), ), child: child, ), ); if (tooltip != null) { child = FlowyTooltip(message: tooltip!, child: child); } if (onPressed == null) { child = ExcludeFocus(child: child); } return child; } } class FlowyRichTextButton extends StatelessWidget { const FlowyRichTextButton( this.text, { super.key, this.onPressed, this.overflow = TextOverflow.ellipsis, this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), this.hoverColor, this.fillColor, this.heading, this.radius, this.mainAxisAlignment = MainAxisAlignment.start, this.tooltip, this.constraints = const BoxConstraints(minWidth: 58.0, minHeight: 30.0), this.decoration, }); final InlineSpan text; final TextOverflow overflow; final VoidCallback? onPressed; final EdgeInsets padding; final Widget? heading; final Color? hoverColor; final Color? fillColor; final BorderRadius? radius; final MainAxisAlignment mainAxisAlignment; final String? tooltip; final BoxConstraints constraints; final TextDecoration? decoration; @override Widget build(BuildContext context) { List children = []; if (heading != null) { children.add(heading!); children.add(const HSpace(6)); } children.add( RichText(text: text, overflow: overflow, textAlign: TextAlign.center), ); Widget child = Padding( padding: padding, child: Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: mainAxisAlignment, children: children, ), ); child = RawMaterialButton( hoverElevation: 0, highlightElevation: 0, shape: RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border), fillColor: fillColor ?? Theme.of(context).colorScheme.secondaryContainer, hoverColor: hoverColor ?? Theme.of(context).colorScheme.secondary, focusColor: Colors.transparent, splashColor: Colors.transparent, highlightColor: Colors.transparent, elevation: 0, constraints: constraints, onPressed: () {}, child: child, ); child = IgnoreParentGestureWidget(onPress: onPressed, child: child); if (tooltip != null) { child = FlowyTooltip(message: tooltip!, child: child); } return child; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/close_button.dart ================================================ import 'package:flutter/material.dart'; class FlowyCloseButton extends StatelessWidget { final VoidCallback? onPressed; const FlowyCloseButton({ super.key, this.onPressed, }); @override Widget build(BuildContext context) { return TextButton(onPressed: onPressed, child: const Icon(Icons.close)); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart ================================================ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_svg/flowy_svg.dart'; import 'package:flutter/material.dart'; class FlowyColorOption { const FlowyColorOption({ required this.color, required this.i18n, required this.id, }); final Color color; final String i18n; final String id; } class FlowyColorPicker extends StatelessWidget { final List colors; final Color? selected; final Function(FlowyColorOption option, int index)? onTap; final double separatorSize; final double iconSize; final double itemHeight; final Border? border; const FlowyColorPicker({ super.key, required this.colors, this.selected, this.onTap, this.separatorSize = 4, this.iconSize = 16, this.itemHeight = 32, this.border, }); @override Widget build(BuildContext context) { return ListView.separated( shrinkWrap: true, separatorBuilder: (context, index) { return VSpace(separatorSize); }, itemCount: colors.length, physics: StyledScrollPhysics(), itemBuilder: (BuildContext context, int index) { return _buildColorOption(colors[index], index); }, ); } Widget _buildColorOption( FlowyColorOption option, int i, ) { Widget? checkmark; if (selected == option.color) { checkmark = const FlowySvg(FlowySvgData("grid/checkmark")); } final colorIcon = ColorOptionIcon( color: option.color, iconSize: iconSize, ); return SizedBox( height: itemHeight, child: FlowyButton( text: FlowyText(option.i18n), leftIcon: colorIcon, rightIcon: checkmark, iconPadding: 10, onTap: () { onTap?.call(option, i); }, ), ); } } class ColorOptionIcon extends StatelessWidget { const ColorOptionIcon({ super.key, required this.color, this.iconSize = 16.0, }); final Color color; final double iconSize; @override Widget build(BuildContext context) { return SizedBox.square( dimension: iconSize, child: DecoratedBox( decoration: BoxDecoration( color: color, shape: BoxShape.circle, border: color == Colors.transparent ? Border.all(color: const Color(0xFFCFD3D9)) : null, ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/container.dart ================================================ import 'package:flowy_infra/time/duration.dart'; import 'package:flutter/material.dart'; class FlowyContainer extends StatelessWidget { final Color color; final BorderRadiusGeometry? borderRadius; final List? shadows; final Widget? child; final double? width; final double? height; final Alignment? align; final EdgeInsets? margin; final Duration? duration; final BoxBorder? border; const FlowyContainer(this.color, {super.key, this.borderRadius, this.shadows, this.child, this.width, this.height, this.align, this.margin, this.duration, this.border}); @override Widget build(BuildContext context) { return AnimatedContainer( width: width, height: height, margin: margin, alignment: align, duration: duration ?? FlowyDurations.medium, decoration: BoxDecoration( color: color, borderRadius: borderRadius, boxShadow: shadows, border: border), child: child); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/decoration.dart ================================================ import 'package:flutter/material.dart'; class FlowyDecoration { static Decoration decoration( Color boxColor, Color boxShadow, { double spreadRadius = 0, double blurRadius = 20, Offset offset = Offset.zero, double borderRadius = 6, BoxBorder? border, }) { return BoxDecoration( color: boxColor, borderRadius: BorderRadius.all(Radius.circular(borderRadius)), boxShadow: [ BoxShadow( color: boxShadow, spreadRadius: spreadRadius, blurRadius: blurRadius, offset: offset, ), ], border: border, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/divider.dart ================================================ import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; class FlowyDivider extends StatelessWidget { const FlowyDivider({ super.key, this.padding, }); final EdgeInsets? padding; @override Widget build(BuildContext context) { return Padding( padding: padding ?? EdgeInsets.zero, child: Divider( height: 1.0, thickness: 1.0, color: AFThemeExtension.of(context).borderColor, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/extension.dart ================================================ import 'package:flutter/material.dart'; export 'package:styled_widget/styled_widget.dart'; class TopBorder extends StatelessWidget { const TopBorder({ super.key, this.width = 1.0, this.color = Colors.grey, required this.child, }); final Widget child; final double width; final Color color; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( border: Border( top: BorderSide(width: width, color: color), ), ), child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart ================================================ import 'package:flutter/material.dart'; typedef HoverBuilder = Widget Function(BuildContext context, bool onHover); class FlowyHover extends StatefulWidget { final HoverStyle? style; final HoverBuilder? builder; final Widget? child; final bool Function()? isSelected; final void Function(bool)? onHover; final MouseCursor? cursor; /// Reset the hover state when the parent widget get rebuild. /// Default to true. final bool resetHoverOnRebuild; /// Determined whether the [builder] should get called when onEnter/onExit /// happened /// /// [FlowyHover] show hover when [MouseRegion]'s onEnter get called /// [FlowyHover] hide hover when [MouseRegion]'s onExit get called /// final bool Function()? buildWhenOnHover; const FlowyHover({ super.key, this.builder, this.child, this.style, this.isSelected, this.onHover, this.cursor, this.resetHoverOnRebuild = true, this.buildWhenOnHover, }); @override State createState() => _FlowyHoverState(); } class _FlowyHoverState extends State { bool _onHover = false; @override void didUpdateWidget(covariant FlowyHover oldWidget) { if (widget.resetHoverOnRebuild) { // Reset the _onHover to false when the parent widget get rebuild. _onHover = false; } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { return MouseRegion( cursor: widget.cursor != null ? widget.cursor! : SystemMouseCursors.click, opaque: false, onHover: (_) => _setOnHover(true), onEnter: (_) => _setOnHover(true), onExit: (_) => _setOnHover(false), child: FlowyHoverContainer( style: widget.style ?? HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), applyStyle: _onHover || (widget.isSelected?.call() ?? false), child: widget.child ?? widget.builder!(context, _onHover), ), ); } void _setOnHover(bool isHovering) { if (isHovering == _onHover) return; if (widget.buildWhenOnHover?.call() ?? true) { setState(() => _onHover = isHovering); if (widget.onHover != null) { widget.onHover!(isHovering); } } } } class HoverStyle { final BoxBorder? border; final Color? hoverColor; final Color? foregroundColorOnHover; final BorderRadius borderRadius; final EdgeInsets contentMargin; final Color backgroundColor; const HoverStyle({ this.border, this.borderRadius = const BorderRadius.all(Radius.circular(6)), this.contentMargin = EdgeInsets.zero, this.backgroundColor = Colors.transparent, this.hoverColor, this.foregroundColorOnHover, }); const HoverStyle.transparent({ this.borderRadius = const BorderRadius.all(Radius.circular(6)), this.contentMargin = EdgeInsets.zero, this.backgroundColor = Colors.transparent, this.foregroundColorOnHover, }) : hoverColor = Colors.transparent, border = null; } class FlowyHoverContainer extends StatelessWidget { final HoverStyle style; final Widget child; final bool applyStyle; const FlowyHoverContainer({ super.key, required this.child, required this.style, this.applyStyle = false, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final textTheme = theme.textTheme; final iconTheme = theme.iconTheme; // override text's theme with foregroundColorOnHover when it is hovered final hoverTheme = theme.copyWith( textTheme: textTheme.copyWith( bodyMedium: textTheme.bodyMedium?.copyWith( color: style.foregroundColorOnHover ?? theme.colorScheme.onSurface, ), ), iconTheme: iconTheme.copyWith( color: style.foregroundColorOnHover ?? theme.colorScheme.onSurface, ), ); return Container( margin: style.contentMargin, decoration: BoxDecoration( border: style.border, color: applyStyle ? style.hoverColor ?? Theme.of(context).colorScheme.secondary : style.backgroundColor, borderRadius: style.borderRadius, ), child: Theme( data: applyStyle ? hoverTheme : Theme.of(context), child: child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart ================================================ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_svg/flowy_svg.dart'; class FlowyIconButton extends StatelessWidget { final double width; final double? height; final Widget icon; final VoidCallback? onPressed; final Color? fillColor; final Color? hoverColor; final Color? iconColorOnHover; final EdgeInsets iconPadding; final BorderRadius? radius; final String? tooltipText; final InlineSpan? richTooltipText; final bool preferBelow; final BoxDecoration? decoration; final bool? isSelected; const FlowyIconButton({ super.key, this.width = 30, this.height, this.onPressed, this.fillColor = Colors.transparent, this.hoverColor, this.iconColorOnHover, this.iconPadding = EdgeInsets.zero, this.radius, this.decoration, this.tooltipText, this.richTooltipText, this.preferBelow = true, this.isSelected, required this.icon, }) : assert((richTooltipText != null && tooltipText == null) || (richTooltipText == null && tooltipText != null) || (richTooltipText == null && tooltipText == null)); @override Widget build(BuildContext context) { Widget child = icon; final size = Size(width, height ?? width); final tooltipMessage = tooltipText == null && richTooltipText == null ? '' : tooltipText; assert(size.width > iconPadding.horizontal); assert(size.height > iconPadding.vertical); child = Padding( padding: iconPadding, child: Center(child: child), ); if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { child = FlowyHover( isSelected: isSelected != null ? () => isSelected! : null, style: HoverStyle( hoverColor: hoverColor, foregroundColorOnHover: iconColorOnHover ?? Theme.of(context).iconTheme.color, borderRadius: radius ?? Corners.s6Border //Do not set background here. Use [fillColor] instead. ), resetHoverOnRebuild: false, child: child, ); } return Container( constraints: BoxConstraints.tightFor( width: size.width, height: size.height, ), decoration: decoration, child: FlowyTooltip( preferBelow: preferBelow, message: tooltipMessage, richMessage: richTooltipText, child: RawMaterialButton( clipBehavior: Clip.antiAlias, hoverElevation: 0, highlightElevation: 0, shape: RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border), fillColor: fillColor, hoverColor: Colors.transparent, focusColor: Colors.transparent, splashColor: Colors.transparent, highlightColor: Colors.transparent, elevation: 0, onPressed: onPressed, child: child, ), ), ); } } class FlowyDropdownButton extends StatelessWidget { const FlowyDropdownButton({super.key, this.onPressed}); final VoidCallback? onPressed; @override Widget build(BuildContext context) { return FlowyIconButton( width: 16, onPressed: onPressed, icon: const FlowySvg(FlowySvgData("home/drop_down_show")), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/image_icon.dart ================================================ import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; class FlowyImageIcon extends StatelessWidget { final AssetImage image; final Color? color; final double? size; const FlowyImageIcon(this.image, {super.key, this.color, this.size}); @override Widget build(BuildContext context) { return ImageIcon(image, size: size ?? Sizes.iconMed, color: color ?? Colors.white); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart ================================================ import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; class PrimaryRoundedButton extends StatelessWidget { const PrimaryRoundedButton({ super.key, required this.text, this.fontSize, this.fontWeight, this.color, this.radius, this.margin, this.onTap, this.hoverColor, this.backgroundColor, this.useIntrinsicWidth = true, this.lineHeight, this.figmaLineHeight, this.leftIcon, this.textColor, }); final String text; final double? fontSize; final FontWeight? fontWeight; final Color? color; final double? radius; final EdgeInsets? margin; final VoidCallback? onTap; final Color? hoverColor; final Color? backgroundColor; final bool useIntrinsicWidth; final double? lineHeight; final double? figmaLineHeight; final Widget? leftIcon; final Color? textColor; @override Widget build(BuildContext context) { return FlowyButton( useIntrinsicWidth: useIntrinsicWidth, leftIcon: leftIcon, text: FlowyText( text, fontSize: fontSize ?? 14.0, fontWeight: fontWeight ?? FontWeight.w500, lineHeight: lineHeight ?? 1.0, figmaLineHeight: figmaLineHeight, color: textColor ?? Theme.of(context).colorScheme.onPrimary, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ), margin: margin ?? const EdgeInsets.symmetric(horizontal: 14.0), backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary, hoverColor: hoverColor ?? Theme.of(context).colorScheme.primary.withValues(alpha: 0.9), radius: BorderRadius.circular(radius ?? 10.0), onTap: onTap, ); } } class OutlinedRoundedButton extends StatelessWidget { const OutlinedRoundedButton({ super.key, required this.text, this.onTap, this.margin, this.radius, }); final String text; final VoidCallback? onTap; final EdgeInsets? margin; final double? radius; @override Widget build(BuildContext context) { return DecoratedBox( decoration: ShapeDecoration( shape: RoundedRectangleBorder( side: Theme.of(context).brightness == Brightness.light ? const BorderSide(color: Color(0x1E14171B)) : const BorderSide(color: Colors.white10), borderRadius: BorderRadius.circular(radius ?? 8), ), ), child: FlowyButton( useIntrinsicWidth: true, margin: margin ?? const EdgeInsets.symmetric( horizontal: 16.0, vertical: 9.0, ), radius: BorderRadius.circular(radius ?? 8), text: FlowyText.regular( text, lineHeight: 1.0, textAlign: TextAlign.center, ), onTap: onTap, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/progress_indicator.dart ================================================ import 'package:flutter/material.dart'; import 'package:loading_indicator/loading_indicator.dart'; List _kDefaultRainbowColors = const [ Colors.red, Colors.orange, Colors.yellow, Colors.green, Colors.blue, Colors.indigo, Colors.purple, ]; // CircularProgressIndicator() class FlowyProgressIndicator extends StatelessWidget { const FlowyProgressIndicator({super.key}); @override Widget build(BuildContext context) { return SizedBox.expand( child: Center( child: SizedBox( width: 60, child: LoadingIndicator( indicatorType: Indicator.pacman, colors: _kDefaultRainbowColors, strokeWidth: 4.0, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrollbar.dart ================================================ import 'package:flutter/material.dart'; class FlowyScrollbar extends StatefulWidget { const FlowyScrollbar({ super.key, this.controller, this.thumbVisibility = true, required this.child, }); final ScrollController? controller; final Widget child; final bool thumbVisibility; @override State createState() => _FlowyScrollbarState(); } class _FlowyScrollbarState extends State { final ValueNotifier isHovered = ValueNotifier(false); @override void dispose() { isHovered.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => isHovered.value = true, onExit: (_) => isHovered.value = false, child: ValueListenableBuilder( valueListenable: isHovered, builder: (context, isHovered, child) { return Scrollbar( thumbVisibility: isHovered && widget.thumbVisibility, // the radius should be fixed to 12 radius: const Radius.circular(12), controller: widget.controller, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( scrollbars: false, ), child: child!, ), ); }, child: widget.child, ), ); } } class ScrollControllerBuilder extends StatefulWidget { const ScrollControllerBuilder({super.key, required this.builder}); final ScrollControllerWidgetBuilder builder; @override State createState() => _ScrollControllerBuilderState(); } class _ScrollControllerBuilderState extends State { final ScrollController controller = ScrollController(); @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return widget.builder(context, controller); } } typedef ScrollControllerWidgetBuilder = Widget Function( BuildContext context, ScrollController controller, ); ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_list.dart ================================================ import 'package:flutter/material.dart'; import 'styled_scroll_bar.dart'; class StyledScrollPhysics extends AlwaysScrollableScrollPhysics {} /// Core ListView for the app. /// Wraps a [ScrollbarListStack] + [ListView.builder] and assigns the 'Styled' scroll physics for the app /// Exposes a controller so other widgets can manipulate the list class StyledListView extends StatefulWidget { final double? itemExtent; final int? itemCount; final Axis axis; final EdgeInsets? padding; final EdgeInsets? scrollbarPadding; final double? barSize; final IndexedWidgetBuilder itemBuilder; StyledListView({ super.key, required this.itemBuilder, required this.itemCount, this.itemExtent, this.axis = Axis.vertical, this.padding, this.barSize, this.scrollbarPadding, }) { assert(itemExtent != 0, 'Item extent should never be 0, null is ok.'); } @override StyledListViewState createState() => StyledListViewState(); } /// State is public so this can easily be controlled externally class StyledListViewState extends State { final scrollController = ScrollController(); @override void dispose() { scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final contentSize = (widget.itemCount ?? 0.0) * (widget.itemExtent ?? 00.0); Widget listContent = ScrollbarListStack( contentSize: contentSize, axis: widget.axis, controller: scrollController, barSize: widget.barSize ?? 8, scrollbarPadding: widget.scrollbarPadding, child: ListView.builder( padding: widget.padding, scrollDirection: widget.axis, physics: StyledScrollPhysics(), controller: scrollController, itemExtent: widget.itemExtent, itemCount: widget.itemCount, itemBuilder: widget.itemBuilder, ), ); return listContent; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart ================================================ import 'dart:async'; import 'dart:math'; import 'package:async/async.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/widget/mouse_hover_builder.dart'; import 'package:flutter/material.dart'; import 'package:styled_widget/styled_widget.dart'; class StyledScrollbar extends StatefulWidget { const StyledScrollbar({ super.key, this.size, required this.axis, required this.controller, this.onDrag, this.contentSize, this.showTrack = false, this.autoHideScrollbar = true, this.handleColor, this.trackColor, }); final double? size; final Axis axis; final ScrollController controller; final Function(double)? onDrag; final bool showTrack; final bool autoHideScrollbar; final Color? handleColor; final Color? trackColor; // ignore: todo // TODO: Remove contentHeight if we can fix this issue // https://stackoverflow.com/questions/60855712/flutter-how-to-force-scrollcontroller-to-recalculate-position-maxextents final double? contentSize; @override ScrollbarState createState() => ScrollbarState(); } class ScrollbarState extends State { double _viewExtent = 100; CancelableOperation? _hideScrollbarOperation; bool hideHandler = false; @override void initState() { super.initState(); widget.controller.addListener(_onScrollChanged); widget.controller.position.isScrollingNotifier .addListener(_hideScrollbarInTime); } @override void dispose() { if (widget.controller.hasClients) { widget.controller.removeListener(_onScrollChanged); widget.controller.position.isScrollingNotifier .removeListener(_hideScrollbarInTime); } super.dispose(); } void _onScrollChanged() => setState(() {}); @override Widget build(BuildContext context) { return LayoutBuilder( builder: (_, BoxConstraints constraints) { double maxExtent; final double? contentSize = widget.contentSize; switch (widget.axis) { case Axis.vertical: // Use supplied contentSize if we have it, otherwise just fallback to maxScrollExtents if (contentSize != null && contentSize > 0) { maxExtent = contentSize - constraints.maxHeight; } else { maxExtent = widget.controller.position.maxScrollExtent; } _viewExtent = constraints.maxHeight; break; case Axis.horizontal: // Use supplied contentSize if we have it, otherwise just fallback to maxScrollExtents if (contentSize != null && contentSize > 0) { maxExtent = contentSize - constraints.maxWidth; } else { maxExtent = widget.controller.position.maxScrollExtent; } _viewExtent = constraints.maxWidth; break; } final contentExtent = maxExtent + _viewExtent; // Calculate the alignment for the handle, this is a value between 0 and 1, // it automatically takes the handle size into acct // ignore: omit_local_variable_types double handleAlignment = maxExtent == 0 ? 0 : widget.controller.offset / maxExtent; // Convert handle alignment from [0, 1] to [-1, 1] handleAlignment *= 2.0; handleAlignment -= 1.0; // Calculate handleSize by comparing the total content size to our viewport double handleExtent = _viewExtent; if (contentExtent > _viewExtent) { // Make sure handle is never small than the minSize handleExtent = max(60, _viewExtent * _viewExtent / contentExtent); } // Hide the handle if content is < the viewExtent var showHandle = hideHandler ? false : contentExtent > _viewExtent && contentExtent > 0; // Track color var trackColor = widget.trackColor ?? (Theme.of(context).brightness == Brightness.dark ? AFThemeExtension.of(context).lightGreyHover : AFThemeExtension.of(context).greyHover); // Layout the stack, it just contains a child, and return Stack( children: [ /// TRACK, thin strip, aligned along the end of the parent if (widget.showTrack) Align( alignment: const Alignment(1, 1), child: Container( color: trackColor, width: widget.axis == Axis.vertical ? widget.size : double.infinity, height: widget.axis == Axis.horizontal ? widget.size : double.infinity, ), ), /// HANDLE - Clickable shape that changes scrollController when dragged Align( // Use calculated alignment to position handle from -1 to 1, let Alignment do the rest of the work alignment: Alignment( widget.axis == Axis.vertical ? 1 : handleAlignment, widget.axis == Axis.horizontal ? 1 : handleAlignment, ), child: GestureDetector( onVerticalDragUpdate: _handleVerticalDrag, onHorizontalDragUpdate: _handleHorizontalDrag, // HANDLE SHAPE child: MouseHoverBuilder( builder: (_, isHovered) { final handleColor = Theme.of(context).scrollbarTheme.thumbColor?.resolve( isHovered ? {WidgetState.dragged} : {}, ); return Container( width: widget.axis == Axis.vertical ? widget.size : handleExtent, height: widget.axis == Axis.horizontal ? widget.size : handleExtent, decoration: BoxDecoration( color: handleColor, borderRadius: Corners.s3Border, ), ); }, ), ), ) ], ).opacity(showHandle ? 1.0 : 0.0, animate: true); }, ); } void _hideScrollbarInTime() { if (!mounted || !widget.autoHideScrollbar) return; _hideScrollbarOperation?.cancel(); if (!widget.controller.position.isScrollingNotifier.value) { _hideScrollbarOperation = CancelableOperation.fromFuture( Future.delayed(const Duration(seconds: 2)), ).then((_) { hideHandler = true; if (mounted) { setState(() {}); } }); } else { hideHandler = false; } } void _handleHorizontalDrag(DragUpdateDetails details) { var pos = widget.controller.offset; var pxRatio = (widget.controller.position.maxScrollExtent + _viewExtent) / _viewExtent; widget.controller.jumpTo((pos + details.delta.dx * pxRatio) .clamp(0.0, widget.controller.position.maxScrollExtent)); widget.onDrag?.call(details.delta.dx); } void _handleVerticalDrag(DragUpdateDetails details) { var pos = widget.controller.offset; var pxRatio = (widget.controller.position.maxScrollExtent + _viewExtent) / _viewExtent; widget.controller.jumpTo((pos + details.delta.dy * pxRatio) .clamp(0.0, widget.controller.position.maxScrollExtent)); widget.onDrag?.call(details.delta.dy); } } class ScrollbarListStack extends StatelessWidget { const ScrollbarListStack({ super.key, required this.barSize, required this.axis, required this.child, required this.controller, this.contentSize, this.scrollbarPadding, this.handleColor, this.autoHideScrollbar = true, this.trackColor, this.showTrack = false, this.includeInsets = true, }); final double barSize; final Axis axis; final Widget child; final ScrollController controller; final double? contentSize; final EdgeInsets? scrollbarPadding; final Color? handleColor; final Color? trackColor; final bool showTrack; final bool autoHideScrollbar; final bool includeInsets; @override Widget build(BuildContext context) { return Stack( children: [ /// Wrap with a bit of padding on the right or bottom to make room for the scrollbar Padding( padding: !includeInsets ? EdgeInsets.zero : EdgeInsets.only( right: axis == Axis.vertical ? barSize + Insets.m : 0, bottom: axis == Axis.horizontal ? barSize + Insets.m : 0, ), child: child, ), /// Display the scrollbar Padding( padding: scrollbarPadding ?? EdgeInsets.zero, child: StyledScrollbar( size: barSize, axis: axis, controller: controller, contentSize: contentSize, trackColor: trackColor, handleColor: handleColor, autoHideScrollbar: autoHideScrollbar, showTrack: showTrack, ), ) // The animate will be used by the children that are using styled_widget. .animate(const Duration(milliseconds: 250), Curves.easeOut), ], ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart ================================================ import 'package:flutter/material.dart'; import 'styled_list.dart'; import 'styled_scroll_bar.dart'; class StyledSingleChildScrollView extends StatefulWidget { const StyledSingleChildScrollView({ super.key, required this.child, this.contentSize, this.axis = Axis.vertical, this.trackColor, this.handleColor, this.controller, this.scrollbarPadding, this.barSize = 8, this.autoHideScrollbar = true, this.includeInsets = true, }); final Widget? child; final double? contentSize; final Axis axis; final Color? trackColor; final Color? handleColor; final ScrollController? controller; final EdgeInsets? scrollbarPadding; final double barSize; final bool autoHideScrollbar; final bool includeInsets; @override State createState() => StyledSingleChildScrollViewState(); } class StyledSingleChildScrollViewState extends State { late final ScrollController scrollController = widget.controller ?? ScrollController(); @override void dispose() { if (widget.controller == null) { scrollController.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { return ScrollbarListStack( autoHideScrollbar: widget.autoHideScrollbar, contentSize: widget.contentSize, axis: widget.axis, controller: scrollController, scrollbarPadding: widget.scrollbarPadding, barSize: widget.barSize, trackColor: widget.trackColor, handleColor: widget.handleColor, includeInsets: widget.includeInsets, child: SingleChildScrollView( scrollDirection: widget.axis, physics: StyledScrollPhysics(), controller: scrollController, child: widget.child, ), ); } } class StyledCustomScrollView extends StatefulWidget { const StyledCustomScrollView({ super.key, this.axis = Axis.vertical, this.trackColor, this.handleColor, this.verticalController, this.slivers = const [], this.barSize = 8, }); final Axis axis; final Color? trackColor; final Color? handleColor; final ScrollController? verticalController; final List slivers; final double barSize; @override StyledCustomScrollViewState createState() => StyledCustomScrollViewState(); } class StyledCustomScrollViewState extends State { late final ScrollController controller = widget.verticalController ?? ScrollController(); @override Widget build(BuildContext context) { var child = ScrollConfiguration( behavior: const ScrollBehavior().copyWith(scrollbars: false), child: CustomScrollView( scrollDirection: widget.axis, physics: StyledScrollPhysics(), controller: controller, slivers: widget.slivers, ), ); return ScrollbarListStack( axis: widget.axis, controller: controller, barSize: widget.barSize, trackColor: widget.trackColor, handleColor: widget.handleColor, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/snap_bar.dart ================================================ import 'dart:io'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; void showSnapBar(BuildContext context, String title, {VoidCallback? onClosed}) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context) .showSnackBar( SnackBar( backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, duration: const Duration(milliseconds: 8000), content: FlowyText( title, maxLines: 2, fontSize: (Platform.isLinux || Platform.isWindows || Platform.isMacOS) ? 14 : 12, ), ), ) .closed .then((value) => onClosed?.call()); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart ================================================ import 'dart:io'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; class FlowyText extends StatelessWidget { final String text; final TextOverflow? overflow; final double? fontSize; final FontWeight? fontWeight; final TextAlign? textAlign; final int? maxLines; final Color? color; final TextDecoration? decoration; final Color? decorationColor; final double? decorationThickness; final String? fontFamily; final List? fallbackFontFamily; final bool withTooltip; final StrutStyle? strutStyle; final bool isEmoji; /// this is used to control the line height in Flutter. final double? lineHeight; /// this is used to control the line height from Figma. final double? figmaLineHeight; final bool optimizeEmojiAlign; const FlowyText( this.text, { super.key, this.overflow = TextOverflow.clip, this.fontSize, this.fontWeight, this.textAlign, this.color, this.maxLines = 1, this.decoration, this.decorationColor, this.fontFamily, this.fallbackFontFamily, // // https://api.flutter.dev/flutter/painting/TextStyle/height.html this.lineHeight, this.figmaLineHeight, this.withTooltip = false, this.isEmoji = false, this.strutStyle, this.optimizeEmojiAlign = false, this.decorationThickness, }); FlowyText.small( this.text, { super.key, this.overflow, this.color, this.textAlign, this.maxLines = 1, this.decoration, this.decorationColor, this.fontFamily, this.fallbackFontFamily, this.lineHeight, this.withTooltip = false, this.isEmoji = false, this.strutStyle, this.figmaLineHeight, this.optimizeEmojiAlign = false, this.decorationThickness, }) : fontWeight = FontWeight.w400, fontSize = (Platform.isIOS || Platform.isAndroid) ? 14 : 12; const FlowyText.regular( this.text, { super.key, this.fontSize, this.overflow, this.color, this.textAlign, this.maxLines = 1, this.decoration, this.decorationColor, this.fontFamily, this.fallbackFontFamily, this.lineHeight, this.withTooltip = false, this.isEmoji = false, this.strutStyle, this.figmaLineHeight, this.optimizeEmojiAlign = false, this.decorationThickness, }) : fontWeight = FontWeight.w400; const FlowyText.medium( this.text, { super.key, this.fontSize, this.overflow, this.color, this.textAlign, this.maxLines = 1, this.decoration, this.decorationColor, this.fontFamily, this.fallbackFontFamily, this.lineHeight, this.withTooltip = false, this.isEmoji = false, this.strutStyle, this.figmaLineHeight, this.optimizeEmojiAlign = false, this.decorationThickness, }) : fontWeight = FontWeight.w500; const FlowyText.semibold( this.text, { super.key, this.fontSize, this.overflow, this.color, this.textAlign, this.maxLines = 1, this.decoration, this.decorationColor, this.fontFamily, this.fallbackFontFamily, this.lineHeight, this.withTooltip = false, this.isEmoji = false, this.strutStyle, this.figmaLineHeight, this.optimizeEmojiAlign = false, this.decorationThickness, }) : fontWeight = FontWeight.w600; // Some emojis are not supported on Linux and Android, fallback to noto color emoji const FlowyText.emoji( this.text, { super.key, this.fontSize, this.overflow, this.color, this.textAlign = TextAlign.center, this.maxLines = 1, this.decoration, this.decorationColor, this.lineHeight, this.withTooltip = false, this.strutStyle = const StrutStyle(forceStrutHeight: true), this.isEmoji = true, this.fontFamily, this.figmaLineHeight, this.optimizeEmojiAlign = false, this.decorationThickness, }) : fontWeight = FontWeight.w400, fallbackFontFamily = null; @override Widget build(BuildContext context) { Widget child; var fontFamily = this.fontFamily; var fallbackFontFamily = this.fallbackFontFamily; var fontSize = this.fontSize ?? Theme.of(context).textTheme.bodyMedium!.fontSize!; if (isEmoji && _useNotoColorEmoji) { fontFamily = _loadEmojiFontFamilyIfNeeded(); if (fontFamily != null && fallbackFontFamily == null) { fallbackFontFamily = [fontFamily]; } } double? lineHeight; // use figma line height as first priority if (figmaLineHeight != null) { lineHeight = figmaLineHeight! / fontSize; } else if (this.lineHeight != null) { lineHeight = this.lineHeight!; } if (isEmoji && (_useNotoColorEmoji || Platform.isWindows)) { const scaleFactor = 0.9; fontSize *= scaleFactor; if (lineHeight != null) { lineHeight /= scaleFactor; } } final textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: fontSize, fontWeight: fontWeight, color: color, decoration: decoration, decorationColor: decorationColor, decorationThickness: decorationThickness, fontFamily: fontFamily, fontFamilyFallback: fallbackFontFamily, height: lineHeight, leadingDistribution: isEmoji && optimizeEmojiAlign ? TextLeadingDistribution.even : null, ); child = Text( text, maxLines: maxLines, textAlign: textAlign, overflow: overflow ?? TextOverflow.clip, style: textStyle, strutStyle: !isEmoji || (isEmoji && optimizeEmojiAlign) ? StrutStyle.fromTextStyle( textStyle, forceStrutHeight: true, leadingDistribution: TextLeadingDistribution.even, height: lineHeight, ) : null, ); if (withTooltip) { child = FlowyTooltip( message: text, child: child, ); } return child; } String? _loadEmojiFontFamilyIfNeeded() { if (_useNotoColorEmoji) { return GoogleFonts.notoColorEmoji().fontFamily; } return null; } bool get _useNotoColorEmoji => Platform.isLinux; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart ================================================ import 'dart:async'; import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class FlowyTextField extends StatefulWidget { final String? hintText; final String? text; final TextStyle? textStyle; final void Function(String)? onChanged; final void Function()? onEditingComplete; final void Function(String)? onSubmitted; final void Function()? onCanceled; final FocusNode? focusNode; final bool autoFocus; final int? maxLength; final TextEditingController? controller; final bool autoClearWhenDone; final bool submitOnLeave; final Duration? debounceDuration; final String? errorText; final Widget? error; final int? maxLines; final bool showCounter; final Widget? prefixIcon; final Widget? suffixIcon; final BoxConstraints? prefixIconConstraints; final BoxConstraints? suffixIconConstraints; final BoxConstraints? hintTextConstraints; final TextStyle? hintStyle; final InputDecoration? decoration; final TextAlignVertical? textAlignVertical; final TextInputAction? textInputAction; final TextInputType? keyboardType; final List? inputFormatters; final bool obscureText; final bool isDense; final bool readOnly; final Color? enableBorderColor; final double? cursorHeight; final BorderRadius? borderRadius; final void Function()? onTap; final Function(PointerDownEvent)? onTapOutside; const FlowyTextField({ super.key, this.hintText = "", this.text, this.textStyle, this.onChanged, this.onEditingComplete, this.onSubmitted, this.onCanceled, this.focusNode, this.autoFocus = true, this.maxLength, this.controller, this.autoClearWhenDone = false, this.submitOnLeave = false, this.debounceDuration, this.errorText, this.error, this.maxLines = 1, this.showCounter = true, this.prefixIcon, this.suffixIcon, this.prefixIconConstraints, this.suffixIconConstraints, this.hintTextConstraints, this.hintStyle, this.decoration, this.textAlignVertical, this.textInputAction, this.keyboardType = TextInputType.multiline, this.inputFormatters, this.obscureText = false, this.isDense = true, this.readOnly = false, this.enableBorderColor, this.borderRadius, this.onTap, this.onTapOutside, this.cursorHeight, }); @override State createState() => FlowyTextFieldState(); } class FlowyTextFieldState extends State { late FocusNode focusNode; late TextEditingController controller; Timer? _debounceOnChanged; @override void initState() { super.initState(); focusNode = widget.focusNode ?? FocusNode(); focusNode.addListener(notifyDidEndEditing); controller = widget.controller ?? TextEditingController(); if (widget.text != null) { controller.text = widget.text!; } if (widget.autoFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); if (widget.controller == null) { controller.selection = TextSelection.fromPosition( TextPosition(offset: controller.text.length), ); } }); } } @override void dispose() { focusNode.removeListener(notifyDidEndEditing); if (widget.focusNode == null) { focusNode.dispose(); } if (widget.controller == null) { controller.dispose(); } _debounceOnChanged?.cancel(); super.dispose(); } void _debounceOnChangedText(Duration duration, String text) { _debounceOnChanged?.cancel(); _debounceOnChanged = Timer(duration, () async { if (mounted) { _onChanged(text); } }); } void _onChanged(String text) { widget.onChanged?.call(text); setState(() {}); } void _onSubmitted(String text) { widget.onSubmitted?.call(text); if (widget.autoClearWhenDone) { controller.clear(); } } @override Widget build(BuildContext context) { return TextField( cursorHeight: widget.cursorHeight, readOnly: widget.readOnly, controller: controller, focusNode: focusNode, onChanged: (text) { if (widget.debounceDuration != null) { _debounceOnChangedText(widget.debounceDuration!, text); } else { _onChanged(text); } }, onSubmitted: _onSubmitted, onEditingComplete: widget.onEditingComplete, onTap: widget.onTap, onTapOutside: widget.onTapOutside, minLines: 1, maxLines: widget.maxLines, maxLength: widget.maxLength, maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, style: widget.textStyle ?? Theme.of(context).textTheme.bodySmall, textAlignVertical: widget.textAlignVertical ?? TextAlignVertical.center, keyboardType: widget.keyboardType, inputFormatters: widget.inputFormatters, obscureText: widget.obscureText, decoration: widget.decoration ?? InputDecoration( constraints: widget.hintTextConstraints ?? BoxConstraints( maxHeight: widget.errorText?.isEmpty ?? true ? 32 : 58, ), contentPadding: EdgeInsets.symmetric( horizontal: widget.isDense ? 12 : 18, vertical: (widget.maxLines == null || widget.maxLines! > 1) ? 12 : 0, ), enabledBorder: OutlineInputBorder( borderRadius: widget.borderRadius ?? Corners.s8Border, borderSide: BorderSide( color: widget.enableBorderColor ?? Theme.of(context).colorScheme.outline, ), ), isDense: false, hintText: widget.hintText, errorText: widget.errorText, error: widget.error, errorStyle: Theme.of(context) .textTheme .bodySmall! .copyWith(color: Theme.of(context).colorScheme.error), hintStyle: widget.hintStyle ?? Theme.of(context) .textTheme .bodySmall! .copyWith(color: Theme.of(context).hintColor), suffixText: widget.showCounter ? _suffixText() : "", counterText: "", focusedBorder: OutlineInputBorder( borderRadius: widget.borderRadius ?? Corners.s8Border, borderSide: BorderSide( color: widget.readOnly ? widget.enableBorderColor ?? Theme.of(context).colorScheme.outline : Theme.of(context).colorScheme.primary, ), ), errorBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.error, ), borderRadius: widget.borderRadius ?? Corners.s8Border, ), focusedErrorBorder: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.error, ), borderRadius: widget.borderRadius ?? Corners.s8Border, ), prefixIcon: widget.prefixIcon, suffixIcon: widget.suffixIcon, prefixIconConstraints: widget.prefixIconConstraints, suffixIconConstraints: widget.suffixIconConstraints, ), ); } void notifyDidEndEditing() { if (!focusNode.hasFocus) { if (controller.text.isNotEmpty && widget.submitOnLeave) { widget.onSubmitted?.call(controller.text); } else { widget.onCanceled?.call(); } } } String? _suffixText() { if (widget.maxLength != null) { return ' ${controller.text.length}/${widget.maxLength}'; } return null; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart ================================================ import 'dart:async'; import 'dart:math' as math; import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class FlowyFormTextInput extends StatelessWidget { static EdgeInsets kDefaultTextInputPadding = const EdgeInsets.only(bottom: 2); final String? label; final bool? autoFocus; final String? initialValue; final String? hintText; final EdgeInsets? contentPadding; final TextStyle? textStyle; final TextAlign textAlign; final int? maxLines; final int? maxLength; final bool showCounter; final TextEditingController? controller; final TextCapitalization? capitalization; final Function(String)? onChanged; final Function()? onEditingComplete; final Function(bool)? onFocusChanged; final Function(FocusNode)? onFocusCreated; const FlowyFormTextInput({ super.key, this.label, this.autoFocus, this.initialValue, this.onChanged, this.onEditingComplete, this.hintText, this.onFocusChanged, this.onFocusCreated, this.controller, this.contentPadding, this.capitalization, this.textStyle, this.textAlign = TextAlign.center, this.maxLines, this.maxLength, this.showCounter = true, }); @override Widget build(BuildContext context) { return StyledSearchTextInput( capitalization: capitalization, label: label, autoFocus: autoFocus, initialValue: initialValue, onChanged: onChanged, onFocusCreated: onFocusCreated, style: textStyle ?? Theme.of(context).textTheme.bodyMedium, textAlign: textAlign, onEditingComplete: onEditingComplete, onFocusChanged: onFocusChanged, controller: controller, maxLines: maxLines, maxLength: maxLength, showCounter: showCounter, contentPadding: contentPadding ?? kDefaultTextInputPadding, hintText: hintText, hintStyle: Theme.of(context) .textTheme .bodyMedium! .copyWith(color: Theme.of(context).hintColor.withValues(alpha: 0.7)), isDense: true, inputBorder: const ThinUnderlineBorder( borderSide: BorderSide(width: 5, color: Colors.red), ), ); } } class StyledSearchTextInput extends StatefulWidget { final String? label; final TextStyle? style; final TextAlign textAlign; final EdgeInsets? contentPadding; final bool? autoFocus; final bool? obscureText; final IconData? icon; final String? initialValue; final int? maxLines; final int? maxLength; final bool showCounter; final TextEditingController? controller; final TextCapitalization? capitalization; final TextInputType? type; final bool? enabled; final bool? autoValidate; final bool? enableSuggestions; final bool? autoCorrect; final bool isDense; final String? errorText; final String? hintText; final TextStyle? hintStyle; final Widget? prefixIcon; final Widget? suffixIcon; final InputDecoration? inputDecoration; final InputBorder? inputBorder; final Function(String)? onChanged; final Function()? onEditingComplete; final Function()? onEditingCancel; final Function(bool)? onFocusChanged; final Function(FocusNode)? onFocusCreated; final Function(String)? onFieldSubmitted; final Function(String?)? onSaved; final VoidCallback? onTap; const StyledSearchTextInput({ super.key, this.label, this.autoFocus = false, this.obscureText = false, this.type = TextInputType.text, this.textAlign = TextAlign.center, this.icon, this.initialValue = '', this.controller, this.enabled, this.autoValidate = false, this.enableSuggestions = true, this.autoCorrect = true, this.isDense = false, this.errorText, this.style, this.contentPadding, this.prefixIcon, this.suffixIcon, this.inputDecoration, this.onChanged, this.onEditingComplete, this.onEditingCancel, this.onFocusChanged, this.onFocusCreated, this.onFieldSubmitted, this.onSaved, this.onTap, this.hintText, this.hintStyle, this.capitalization, this.maxLines, this.maxLength, this.showCounter = false, this.inputBorder, }); @override StyledSearchTextInputState createState() => StyledSearchTextInputState(); } class StyledSearchTextInputState extends State { late TextEditingController _controller; late FocusNode _focusNode; @override void initState() { super.initState(); _controller = widget.controller ?? TextEditingController(text: widget.initialValue); _focusNode = FocusNode( debugLabel: widget.label, canRequestFocus: true, onKeyEvent: (node, event) { if (event.logicalKey == LogicalKeyboardKey.escape) { widget.onEditingCancel?.call(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, ); // Listen for focus out events _focusNode.addListener(_onFocusChanged); widget.onFocusCreated?.call(_focusNode); if (widget.autoFocus ?? false) { scheduleMicrotask(() => _focusNode.requestFocus()); } } void _onFocusChanged() => widget.onFocusChanged?.call(_focusNode.hasFocus); @override void dispose() { if (widget.controller == null) { _controller.dispose(); } _focusNode.removeListener(_onFocusChanged); _focusNode.dispose(); super.dispose(); } void clear() => _controller.clear(); String get text => _controller.text; set text(String value) => _controller.text = value; @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.symmetric(vertical: Insets.sm), child: TextFormField( onChanged: widget.onChanged, onEditingComplete: widget.onEditingComplete, onFieldSubmitted: widget.onFieldSubmitted, onSaved: widget.onSaved, onTap: widget.onTap, autofocus: widget.autoFocus ?? false, focusNode: _focusNode, keyboardType: widget.type, obscureText: widget.obscureText ?? false, autocorrect: widget.autoCorrect ?? false, enableSuggestions: widget.enableSuggestions ?? false, style: widget.style ?? Theme.of(context).textTheme.bodyMedium, cursorColor: Theme.of(context).colorScheme.primary, controller: _controller, showCursor: true, enabled: widget.enabled, maxLines: widget.maxLines, maxLength: widget.maxLength, textCapitalization: widget.capitalization ?? TextCapitalization.none, textAlign: widget.textAlign, decoration: widget.inputDecoration ?? InputDecoration( prefixIcon: widget.prefixIcon, suffixIcon: widget.suffixIcon, counterText: "", suffixText: widget.showCounter ? _suffixText() : "", contentPadding: widget.contentPadding ?? EdgeInsets.all(Insets.m), border: widget.inputBorder ?? const OutlineInputBorder(borderSide: BorderSide.none), isDense: widget.isDense, icon: widget.icon == null ? null : Icon(widget.icon), errorText: widget.errorText, errorMaxLines: 2, hintText: widget.hintText, hintStyle: widget.hintStyle ?? Theme.of(context) .textTheme .bodyMedium! .copyWith(color: Theme.of(context).hintColor), labelText: widget.label, ), ), ); } String? _suffixText() { if (widget.controller != null && widget.maxLength != null) { return ' ${widget.controller!.text.length}/${widget.maxLength}'; } return null; } } class ThinUnderlineBorder extends InputBorder { /// Creates an underline border for an [InputDecorator]. /// /// The [borderSide] parameter defaults to [BorderSide.none] (it must not be /// null). Applications typically do not specify a [borderSide] parameter /// because the input decorator substitutes its own, using [copyWith], based /// on the current theme and [InputDecorator.isFocused]. /// /// The [borderRadius] parameter defaults to a value where the top left /// and right corners have a circular radius of 4.0. The [borderRadius] /// parameter must not be null. const ThinUnderlineBorder({ super.borderSide = const BorderSide(), this.borderRadius = const BorderRadius.only( topLeft: Radius.circular(4.0), topRight: Radius.circular(4.0), ), }); /// The radii of the border's rounded rectangle corners. /// /// When this border is used with a filled input decorator, see /// [InputDecoration.filled], the border radius defines the shape /// of the background fill as well as the bottom left and right /// edges of the underline itself. /// /// By default the top right and top left corners have a circular radius /// of 4.0. final BorderRadius borderRadius; @override bool get isOutline => false; @override UnderlineInputBorder copyWith({ BorderSide? borderSide, BorderRadius? borderRadius, }) { return UnderlineInputBorder( borderSide: borderSide ?? this.borderSide, borderRadius: borderRadius ?? this.borderRadius, ); } @override EdgeInsetsGeometry get dimensions => EdgeInsets.only(bottom: borderSide.width); @override UnderlineInputBorder scale(double t) => UnderlineInputBorder(borderSide: borderSide.scale(t)); @override Path getInnerPath(Rect rect, {TextDirection? textDirection}) { return Path() ..addRect(Rect.fromLTWH(rect.left, rect.top, rect.width, math.max(0.0, rect.height - borderSide.width))); } @override Path getOuterPath(Rect rect, {TextDirection? textDirection}) { return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect)); } @override ShapeBorder? lerpFrom(ShapeBorder? a, double t) { if (a is UnderlineInputBorder) { final newBorderRadius = BorderRadius.lerp(a.borderRadius, borderRadius, t); if (newBorderRadius != null) { return UnderlineInputBorder( borderSide: BorderSide.lerp(a.borderSide, borderSide, t), borderRadius: newBorderRadius, ); } } return super.lerpFrom(a, t); } @override ShapeBorder? lerpTo(ShapeBorder? b, double t) { if (b is UnderlineInputBorder) { final newBorderRadius = BorderRadius.lerp(b.borderRadius, borderRadius, t); if (newBorderRadius != null) { return UnderlineInputBorder( borderSide: BorderSide.lerp(borderSide, b.borderSide, t), borderRadius: newBorderRadius, ); } } return super.lerpTo(b, t); } /// Draw a horizontal line at the bottom of [rect]. /// /// The [borderSide] defines the line's color and weight. The `textDirection` /// `gap` and `textDirection` parameters are ignored. /// @override @override void paint( Canvas canvas, Rect rect, { double? gapStart, double gapExtent = 0.0, double gapPercentage = 0.0, TextDirection? textDirection, }) { if (borderRadius.bottomLeft != Radius.zero || borderRadius.bottomRight != Radius.zero) { canvas.clipPath(getOuterPath(rect, textDirection: textDirection)); } canvas.drawLine(rect.bottomLeft, rect.bottomRight, borderSide.toPaint()); } @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other.runtimeType != runtimeType) return false; return other is InputBorder && other.borderSide == borderSide; } @override int get hashCode => borderSide.hashCode; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/toolbar_button.dart ================================================ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter/material.dart'; class FlowyToolbarButton extends StatelessWidget { final Widget child; final VoidCallback? onPressed; final EdgeInsets padding; final String? tooltip; const FlowyToolbarButton({ super.key, this.onPressed, this.tooltip, this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 6), required this.child, }); @override Widget build(BuildContext context) { final tooltipMessage = tooltip ?? ''; return FlowyTooltip( message: tooltipMessage, padding: EdgeInsets.zero, child: RawMaterialButton( clipBehavior: Clip.antiAlias, constraints: const BoxConstraints(minWidth: 36, minHeight: 32), hoverElevation: 0, highlightElevation: 0, padding: EdgeInsets.zero, shape: const RoundedRectangleBorder(borderRadius: Corners.s6Border), hoverColor: Colors.transparent, focusColor: Colors.transparent, splashColor: Colors.transparent, highlightColor: Colors.transparent, elevation: 0, onPressed: onPressed, child: child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart ================================================ import 'package:flowy_infra/size.dart'; import 'package:flutter/material.dart'; class BaseStyledButton extends StatefulWidget { final Widget child; final VoidCallback? onPressed; final Function(bool)? onFocusChanged; final Function(bool)? onHighlightChanged; final Color? bgColor; final Color? focusColor; final Color? hoverColor; final Color? highlightColor; final EdgeInsets? contentPadding; final double? minWidth; final double? minHeight; final BorderRadius? borderRadius; final bool useBtnText; final bool autoFocus; final ShapeBorder? shape; final Color outlineColor; const BaseStyledButton({ super.key, required this.child, this.onPressed, this.onFocusChanged, this.onHighlightChanged, this.bgColor, this.focusColor, this.contentPadding, this.minWidth, this.minHeight, this.borderRadius, this.hoverColor, this.highlightColor, this.shape, this.useBtnText = true, this.autoFocus = false, this.outlineColor = Colors.transparent, }); @override State createState() => BaseStyledBtnState(); } class BaseStyledBtnState extends State { late FocusNode _focusNode; bool _isFocused = false; @override void initState() { super.initState(); _focusNode = FocusNode(debugLabel: '', canRequestFocus: true); _focusNode.addListener(_onFocusChanged); } void _onFocusChanged() { if (_focusNode.hasFocus != _isFocused) { setState(() => _isFocused = _focusNode.hasFocus); widget.onFocusChanged?.call(_isFocused); } } @override void dispose() { _focusNode.removeListener(_onFocusChanged); _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: widget.bgColor ?? Theme.of(context).colorScheme.surface, borderRadius: widget.borderRadius ?? Corners.s10Border, boxShadow: _isFocused ? [ BoxShadow( color: Theme.of(context).colorScheme.shadow, offset: Offset.zero, blurRadius: 8.0, spreadRadius: 0.0, ), BoxShadow( color: widget.bgColor ?? Theme.of(context).colorScheme.surface, offset: Offset.zero, blurRadius: 8.0, spreadRadius: -4.0, ), ] : [], ), foregroundDecoration: _isFocused ? ShapeDecoration( shape: RoundedRectangleBorder( side: BorderSide( width: 1.8, color: Theme.of(context).colorScheme.outline, ), borderRadius: widget.borderRadius ?? Corners.s10Border, ), ) : null, child: RawMaterialButton( focusNode: _focusNode, autofocus: widget.autoFocus, textStyle: widget.useBtnText ? Theme.of(context).textTheme.bodyMedium : null, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, // visualDensity: VisualDensity.compact, splashColor: Colors.transparent, mouseCursor: SystemMouseCursors.click, elevation: 0, hoverElevation: 0, highlightElevation: 0, focusElevation: 0, fillColor: Colors.transparent, hoverColor: widget.hoverColor ?? Colors.transparent, highlightColor: widget.highlightColor ?? Colors.transparent, focusColor: widget.focusColor ?? Colors.grey.withValues(alpha: 0.35), constraints: BoxConstraints( minHeight: widget.minHeight ?? 0, minWidth: widget.minWidth ?? 0), onPressed: widget.onPressed, shape: widget.shape ?? RoundedRectangleBorder( side: BorderSide(color: widget.outlineColor, width: 1.5), borderRadius: widget.borderRadius ?? Corners.s10Border, ), child: Opacity( opacity: widget.onPressed != null ? 1 : .7, child: Padding( padding: widget.contentPadding ?? EdgeInsets.all(Insets.m), child: widget.child, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart ================================================ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'base_styled_button.dart'; import 'secondary_button.dart'; class PrimaryTextButton extends StatelessWidget { final String label; final VoidCallback? onPressed; final TextButtonMode mode; const PrimaryTextButton(this.label, {super.key, this.onPressed, this.mode = TextButtonMode.big}); @override Widget build(BuildContext context) { return PrimaryButton( mode: mode, onPressed: onPressed, child: FlowyText.regular( label, color: Theme.of(context).colorScheme.onPrimary, ), ); } } class PrimaryButton extends StatelessWidget { const PrimaryButton({ super.key, required this.child, this.onPressed, this.mode = TextButtonMode.big, this.backgroundColor, }); final Widget child; final VoidCallback? onPressed; final TextButtonMode mode; final Color? backgroundColor; @override Widget build(BuildContext context) { return BaseStyledButton( minWidth: mode.size.width, minHeight: mode.size.height, contentPadding: const EdgeInsets.symmetric(horizontal: 6), bgColor: backgroundColor ?? Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primaryContainer, borderRadius: mode.borderRadius, onPressed: onPressed, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart ================================================ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flowy_infra/size.dart'; import 'base_styled_button.dart'; enum TextButtonMode { normal, big, small; Size get size { switch (this) { case TextButtonMode.normal: return const Size(80, 32); case TextButtonMode.big: return const Size(100, 40); case TextButtonMode.small: return const Size(100, 30); } } BorderRadius get borderRadius { switch (this) { case TextButtonMode.normal: return Corners.s8Border; case TextButtonMode.big: return Corners.s12Border; case TextButtonMode.small: return Corners.s6Border; } } } class SecondaryTextButton extends StatelessWidget { const SecondaryTextButton( this.label, { super.key, this.onPressed, this.textColor, this.outlineColor, this.mode = TextButtonMode.normal, }); final String label; final VoidCallback? onPressed; final TextButtonMode mode; final Color? textColor; final Color? outlineColor; @override Widget build(BuildContext context) { return SecondaryButton( mode: mode, onPressed: onPressed, outlineColor: outlineColor, child: FlowyText.regular( label, color: textColor ?? Theme.of(context).colorScheme.primary, ), ); } } class SecondaryButton extends StatelessWidget { const SecondaryButton({ super.key, required this.child, this.onPressed, this.outlineColor, this.mode = TextButtonMode.normal, }); final Widget child; final VoidCallback? onPressed; final TextButtonMode mode; final Color? outlineColor; @override Widget build(BuildContext context) { final size = mode.size; return BaseStyledButton( minWidth: size.width, minHeight: size.height, contentPadding: EdgeInsets.zero, bgColor: Colors.transparent, outlineColor: outlineColor ?? Theme.of(context).colorScheme.primary, borderRadius: mode.borderRadius, onPressed: onPressed, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/constraint_flex_view.dart ================================================ import 'package:flutter/material.dart'; class ConstrainedFlexView extends StatelessWidget { final Widget child; final double minSize; final Axis axis; final EdgeInsets scrollPadding; const ConstrainedFlexView(this.minSize, {super.key, required this.child, this.axis = Axis.horizontal, this.scrollPadding = EdgeInsets.zero}); bool get isHz => axis == Axis.horizontal; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (_, constraints) { final viewSize = isHz ? constraints.maxWidth : constraints.maxHeight; if (viewSize > minSize) return child; return Padding( padding: scrollPadding, child: SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: isHz ? double.infinity : minSize, maxWidth: isHz ? minSize : double.infinity), child: child, ), ), ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/dialog_size.dart ================================================ class DialogSize { static double get minDialogWidth => 400; } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart ================================================ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; import 'package:flowy_infra_ui/widget/dialog/dialog_size.dart'; extension IntoDialog on Widget { Future show(BuildContext context) async { FocusNode dialogFocusNode = FocusNode(); await Dialogs.show( child: KeyboardListener( focusNode: dialogFocusNode, onKeyEvent: (event) { if (event.logicalKey == LogicalKeyboardKey.escape) { Navigator.of(context).pop(); } }, child: this, ), context, ); dialogFocusNode.dispose(); } } class StyledDialog extends StatelessWidget { final Widget child; final double? maxWidth; final double? maxHeight; final EdgeInsets? padding; final EdgeInsets? margin; final BorderRadius? borderRadius; final Color? bgColor; final bool shrinkWrap; const StyledDialog({ super.key, required this.child, this.maxWidth, this.maxHeight, this.padding, this.margin, this.bgColor, this.borderRadius = const BorderRadius.all(Radius.circular(6)), this.shrinkWrap = true, }); @override Widget build(BuildContext context) { Widget innerContent = Container( padding: padding ?? EdgeInsets.symmetric(horizontal: Insets.xxl, vertical: Insets.xl), color: bgColor ?? Theme.of(context).colorScheme.surface, child: child, ); if (shrinkWrap) { innerContent = IntrinsicWidth( child: IntrinsicHeight( child: innerContent, )); } return FocusTraversalGroup( child: Container( margin: margin ?? EdgeInsets.all(Insets.sm * 2), alignment: Alignment.center, child: ConstrainedBox( constraints: BoxConstraints( minWidth: DialogSize.minDialogWidth, maxHeight: maxHeight ?? double.infinity, maxWidth: maxWidth ?? double.infinity, ), child: ClipRRect( borderRadius: borderRadius ?? BorderRadius.zero, child: SingleChildScrollView( physics: StyledScrollPhysics(), //https://medium.com/saugo360/https-medium-com-saugo360-flutter-using-overlay-to-display-floating-widgets-2e6d0e8decb9 child: Material( type: MaterialType.transparency, child: innerContent, ), ), ), ), ), ); } } class Dialogs { static Future show(BuildContext context, {required Widget child}) async { return await Navigator.of(context).push( StyledDialogRoute( barrier: DialogBarrier(color: Colors.black.withValues(alpha: 0.4)), pageBuilder: (BuildContext buildContext, Animation animation, Animation secondaryAnimation) { return SafeArea(child: child); }, ), ); } } class DialogBarrier { String label; Color color; bool dismissible; ImageFilter? filter; DialogBarrier({ this.dismissible = true, this.color = Colors.transparent, this.label = '', this.filter, }); } class StyledDialogRoute extends PopupRoute { final RoutePageBuilder _pageBuilder; final DialogBarrier barrier; StyledDialogRoute({ required RoutePageBuilder pageBuilder, required this.barrier, Duration transitionDuration = const Duration(milliseconds: 300), RouteTransitionsBuilder? transitionBuilder, super.settings, }) : _pageBuilder = pageBuilder, _transitionDuration = transitionDuration, _transitionBuilder = transitionBuilder, super(filter: barrier.filter); @override bool get barrierDismissible { return barrier.dismissible; } @override String get barrierLabel => barrier.label; @override Color get barrierColor => barrier.color; @override Duration get transitionDuration => _transitionDuration; final Duration _transitionDuration; final RouteTransitionsBuilder? _transitionBuilder; @override Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return Semantics( scopesRoute: true, explicitChildNodes: true, child: _pageBuilder(context, animation, secondaryAnimation), ); } @override Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { if (_transitionBuilder == null) { return FadeTransition( opacity: CurvedAnimation(parent: animation, curve: Curves.easeInOut), child: child); } else { return _transitionBuilder!(context, animation, secondaryAnimation, child); } // Some default transition } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart ================================================ import 'package:flutter/material.dart'; const _tooltipWaitDuration = Duration(milliseconds: 300); class FlowyTooltip extends StatelessWidget { const FlowyTooltip({ super.key, this.message, this.richMessage, this.preferBelow, this.margin, this.verticalOffset, this.padding, this.child, }); final String? message; final InlineSpan? richMessage; final bool? preferBelow; final EdgeInsetsGeometry? margin; final Widget? child; final double? verticalOffset; final EdgeInsets? padding; @override Widget build(BuildContext context) { if (message == null && richMessage == null) { return child ?? const SizedBox.shrink(); } return Tooltip( margin: margin, verticalOffset: verticalOffset ?? 16.0, padding: padding ?? const EdgeInsets.symmetric( horizontal: 12.0, vertical: 8.0, ), decoration: BoxDecoration( color: context.tooltipBackgroundColor(), borderRadius: BorderRadius.circular(10.0), ), waitDuration: _tooltipWaitDuration, message: message, textStyle: message != null ? context.tooltipTextStyle() : null, richMessage: richMessage, preferBelow: preferBelow, child: child, ); } } class ManualTooltip extends StatefulWidget { const ManualTooltip({ super.key, this.message, this.richMessage, this.preferBelow, this.margin, this.verticalOffset, this.padding, this.showAutomaticlly = false, this.child, }); final String? message; final InlineSpan? richMessage; final bool? preferBelow; final EdgeInsetsGeometry? margin; final Widget? child; final double? verticalOffset; final EdgeInsets? padding; final bool showAutomaticlly; @override State createState() => _ManualTooltipState(); } class _ManualTooltipState extends State { final key = GlobalKey(); @override void initState() { if (widget.showAutomaticlly) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) key.currentState?.ensureTooltipVisible(); }); } super.initState(); } @override Widget build(BuildContext context) { return Tooltip( key: key, margin: widget.margin, verticalOffset: widget.verticalOffset ?? 16.0, triggerMode: widget.showAutomaticlly ? TooltipTriggerMode.manual : null, padding: widget.padding ?? const EdgeInsets.symmetric( horizontal: 12.0, vertical: 8.0, ), decoration: BoxDecoration( color: context.tooltipBackgroundColor(), borderRadius: BorderRadius.circular(10.0), ), waitDuration: _tooltipWaitDuration, message: widget.message, textStyle: widget.message != null ? context.tooltipTextStyle() : null, richMessage: widget.richMessage, preferBelow: widget.preferBelow, child: widget.child, ); } } extension FlowyToolTipExtension on BuildContext { double tooltipFontSize() => 14.0; double tooltipHeight({double? fontSize}) => 20.0 / (fontSize ?? tooltipFontSize()); Color tooltipFontColor() => Theme.of(this).brightness == Brightness.light ? Colors.white : Colors.black; TextStyle? tooltipTextStyle({Color? fontColor, double? fontSize}) { return Theme.of(this).textTheme.bodyMedium?.copyWith( color: fontColor ?? tooltipFontColor(), fontSize: fontSize ?? tooltipFontSize(), fontWeight: FontWeight.w400, height: tooltipHeight(fontSize: fontSize), leadingDistribution: TextLeadingDistribution.even, ); } TextStyle? tooltipHintTextStyle({double? fontSize}) => tooltipTextStyle( fontColor: tooltipFontColor().withValues(alpha: 0.7), fontSize: fontSize, ); Color tooltipBackgroundColor() => Theme.of(this).brightness == Brightness.light ? const Color(0xFF1D2129) : const Color(0xE5E5E5E5); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/ignore_parent_gesture.dart ================================================ import 'package:flutter/material.dart'; class IgnoreParentGestureWidget extends StatelessWidget { const IgnoreParentGestureWidget({ super.key, required this.child, this.onPress, }); final Widget child; final VoidCallback? onPress; @override Widget build(BuildContext context) { // https://docs.flutter.dev/development/ui/advanced/gestures#gesture-disambiguation // https://github.com/AppFlowy-IO/AppFlowy/issues/1290 return Listener( onPointerDown: (event) { onPress?.call(); }, onPointerSignal: (event) {}, onPointerMove: (event) {}, onPointerUp: (event) {}, onPointerHover: (event) {}, onPointerPanZoomStart: (event) {}, onPointerPanZoomUpdate: (event) {}, onPointerPanZoomEnd: (event) {}, child: child, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart ================================================ import 'package:flutter/material.dart'; typedef HoverBuilder = Widget Function(BuildContext context, bool onHover); class MouseHoverBuilder extends StatefulWidget { final bool isClickable; const MouseHoverBuilder( {super.key, required this.builder, this.isClickable = false}); final HoverBuilder builder; @override State createState() => _MouseHoverBuilderState(); } class _MouseHoverBuilderState extends State { bool _onHover = false; @override Widget build(BuildContext context) { return MouseRegion( cursor: widget.isClickable ? SystemMouseCursors.click : SystemMouseCursors.basic, onEnter: (p) => setOnHover(true), onExit: (p) => setOnHover(false), child: widget.builder(context, _onHover), ); } void setOnHover(bool value) => setState(() => _onHover = value); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart ================================================ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flutter/material.dart'; class RoundedTextButton extends StatelessWidget { final VoidCallback? onPressed; final String? title; final double? width; final double? height; final BorderRadius? borderRadius; final Color borderColor; final Color? fillColor; final Color? hoverColor; final Color? textColor; final double? fontSize; final FontWeight? fontWeight; final EdgeInsets padding; const RoundedTextButton({ super.key, this.onPressed, this.title, this.width, this.height, this.borderRadius, this.borderColor = Colors.transparent, this.fillColor, this.hoverColor, this.textColor, this.fontSize, this.fontWeight, this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), }); @override Widget build(BuildContext context) { return ConstrainedBox( constraints: BoxConstraints( minWidth: 10, maxWidth: width ?? double.infinity, minHeight: 10, maxHeight: height ?? 60, ), child: SizedBox.expand( child: FlowyTextButton( title ?? '', fontWeight: fontWeight, onPressed: onPressed, fontSize: fontSize, mainAxisAlignment: MainAxisAlignment.center, radius: borderRadius ?? Corners.s6Border, fontColor: textColor ?? Theme.of(context).colorScheme.onPrimary, fillColor: fillColor ?? Theme.of(context).colorScheme.primary, hoverColor: hoverColor ?? Theme.of(context).colorScheme.primaryContainer, padding: padding, ), ), ); } } class RoundedImageButton extends StatelessWidget { final VoidCallback? press; final double size; final BorderRadius borderRadius; final Color borderColor; final Color color; final Widget child; const RoundedImageButton({ super.key, this.press, required this.size, this.borderRadius = BorderRadius.zero, this.borderColor = Colors.transparent, this.color = Colors.transparent, required this.child, }); @override Widget build(BuildContext context) { return SizedBox( width: size, height: size, child: TextButton( onPressed: press, style: ButtonStyle( shape: WidgetStateProperty.all( RoundedRectangleBorder(borderRadius: borderRadius))), child: child, ), ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_input_field.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/time/duration.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; class RoundedInputField extends StatefulWidget { final String? hintText; final bool obscureText; final Widget? obscureIcon; final Widget? obscureHideIcon; final Color? normalBorderColor; final Color? errorBorderColor; final Color? cursorColor; final Color? focusBorderColor; final String errorText; final TextStyle? style; final ValueChanged? onChanged; final Function(String)? onEditingComplete; final String? initialValue; final EdgeInsets margin; final EdgeInsets padding; final EdgeInsets contentPadding; final double height; final FocusNode? focusNode; final TextEditingController? controller; final bool autoFocus; final int? maxLength; final Function(String)? onFieldSubmitted; const RoundedInputField({ super.key, this.hintText, this.errorText = "", this.initialValue, this.obscureText = false, this.obscureIcon, this.obscureHideIcon, this.onChanged, this.onEditingComplete, this.normalBorderColor, this.errorBorderColor, this.focusBorderColor, this.cursorColor, this.style, this.margin = EdgeInsets.zero, this.padding = EdgeInsets.zero, this.contentPadding = const EdgeInsets.symmetric(horizontal: 10), this.height = 48, this.focusNode, this.controller, this.autoFocus = false, this.maxLength, this.onFieldSubmitted, }); @override State createState() => _RoundedInputFieldState(); } class _RoundedInputFieldState extends State { String inputText = ""; bool obscureText = false; @override void initState() { super.initState(); obscureText = widget.obscureText; inputText = widget.controller != null ? widget.controller!.text : widget.initialValue ?? ""; } String? _suffixText() => widget.maxLength != null ? ' ${widget.controller!.text.length}/${widget.maxLength}' : null; @override Widget build(BuildContext context) { Color borderColor = widget.normalBorderColor ?? Theme.of(context).colorScheme.outline; Color focusBorderColor = widget.focusBorderColor ?? Theme.of(context).colorScheme.primary; if (widget.errorText.isNotEmpty) { borderColor = Theme.of(context).colorScheme.error; focusBorderColor = borderColor; } List children = [ Container( margin: widget.margin, padding: widget.padding, height: widget.height, child: TextFormField( controller: widget.controller, initialValue: widget.initialValue, focusNode: widget.focusNode, autofocus: widget.autoFocus, maxLength: widget.maxLength, maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, onFieldSubmitted: widget.onFieldSubmitted, onChanged: (value) { inputText = value; if (widget.onChanged != null) { widget.onChanged!(value); } setState(() {}); }, onEditingComplete: () { if (widget.onEditingComplete != null) { widget.onEditingComplete!(inputText); } }, cursorColor: widget.cursorColor ?? Theme.of(context).colorScheme.primary, obscureText: obscureText, style: widget.style ?? Theme.of(context).textTheme.bodyMedium, decoration: InputDecoration( contentPadding: widget.contentPadding, hintText: widget.hintText, hintStyle: Theme.of(context) .textTheme .bodySmall! .copyWith(color: Theme.of(context).hintColor), suffixText: _suffixText(), counterText: "", enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: borderColor, width: 1.0), borderRadius: Corners.s10Border, ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: focusBorderColor, width: 1.0), borderRadius: Corners.s10Border, ), suffixIcon: obscureIcon(), ), ), ), ]; if (widget.errorText.isNotEmpty) { children.add( Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text( widget.errorText, style: widget.style, ), ), ), ); } return AnimatedSize( duration: .4.seconds, curve: Curves.easeInOut, child: Column(children: children), ); } Widget? obscureIcon() { if (widget.obscureText == false) { return null; } const double iconWidth = 16; if (inputText.isEmpty) { return SizedBox.fromSize(size: const Size.square(iconWidth)); } assert(widget.obscureIcon != null && widget.obscureHideIcon != null); final icon = obscureText ? widget.obscureIcon! : widget.obscureHideIcon!; return RoundedImageButton( size: iconWidth, press: () => setState(() => obscureText = !obscureText), child: icon, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/route/animation.dart ================================================ import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; typedef PageBuilder = Widget Function(); class PageRoutes { static const double kDefaultDuration = .35; static const Curve kDefaultEaseFwd = Curves.easeOut; static const Curve kDefaultEaseReverse = Curves.easeOut; static Route fade(PageBuilder pageBuilder, RouteSettings? settings, [double duration = kDefaultDuration]) { return PageRouteBuilder( settings: settings, transitionDuration: Duration(milliseconds: (duration * 1000).round()), pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition(opacity: animation, child: child); }, ); } static Route fadeThrough(PageBuilder pageBuilder, [double duration = kDefaultDuration]) { return PageRouteBuilder( transitionDuration: Duration(milliseconds: (duration * 1000).round()), pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeThroughTransition( animation: animation, secondaryAnimation: secondaryAnimation, child: child); }, ); } static Route fadeScale(PageBuilder pageBuilder, [double duration = kDefaultDuration]) { return PageRouteBuilder( transitionDuration: Duration(milliseconds: (duration * 1000).round()), pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeScaleTransition(animation: animation, child: child); }, ); } static Route sharedAxis(PageBuilder pageBuilder, [SharedAxisTransitionType type = SharedAxisTransitionType.scaled, double duration = kDefaultDuration]) { return PageRouteBuilder( transitionDuration: Duration(milliseconds: (duration * 1000).round()), pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), transitionsBuilder: (context, animation, secondaryAnimation, child) { return SharedAxisTransition( animation: animation, secondaryAnimation: secondaryAnimation, transitionType: type, child: child, ); }, ); } static Route slide(PageBuilder pageBuilder, {double duration = kDefaultDuration, Offset startOffset = const Offset(1, 0), Curve easeFwd = kDefaultEaseFwd, Curve easeReverse = kDefaultEaseReverse}) { return PageRouteBuilder( transitionDuration: Duration(milliseconds: (duration * 1000).round()), pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), transitionsBuilder: (context, animation, secondaryAnimation, child) { bool reverse = animation.status == AnimationStatus.reverse; return SlideTransition( position: Tween(begin: startOffset, end: const Offset(0, 0)) .animate(CurvedAnimation( parent: animation, curve: reverse ? easeReverse : easeFwd)), child: child, ); }, ); } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart ================================================ import 'package:flutter/material.dart'; typedef SeparatorBuilder = Widget Function(); Widget _defaultColumnSeparatorBuilder() => const Divider(); Widget _defaultRowSeparatorBuilder() => const VerticalDivider(); class SeparatedColumn extends Column { SeparatedColumn({ super.key, super.mainAxisAlignment, super.crossAxisAlignment, super.mainAxisSize, super.textBaseline, super.textDirection, super.verticalDirection, SeparatorBuilder separatorBuilder = _defaultColumnSeparatorBuilder, required List children, }) : super(children: _insertSeparators(children, separatorBuilder)); } class SeparatedRow extends Row { SeparatedRow({ super.key, super.mainAxisAlignment, super.crossAxisAlignment, super.mainAxisSize, super.textBaseline, super.textDirection, super.verticalDirection, SeparatorBuilder separatorBuilder = _defaultRowSeparatorBuilder, required List children, }) : super(children: _insertSeparators(children, separatorBuilder)); } List _insertSeparators( List children, SeparatorBuilder separatorBuilder, ) { if (children.length < 2) { return children; } List newChildren = []; for (int i = 0; i < children.length - 1; i++) { newChildren.add(children[i]); newChildren.add(separatorBuilder()); } return newChildren..add(children.last); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/spacing.dart ================================================ import 'package:flutter/cupertino.dart'; class Space extends StatelessWidget { final double width; final double height; const Space(this.width, this.height, {super.key}); @override Widget build(BuildContext context) => SizedBox(width: width, height: height); } class VSpace extends StatelessWidget { const VSpace( this.size, { super.key, this.color, }); final double size; final Color? color; @override Widget build(BuildContext context) { if (color != null) { return SizedBox( height: size, width: double.infinity, child: ColoredBox( color: color!, ), ); } else { return Space(0, size); } } } class HSpace extends StatelessWidget { const HSpace( this.size, { super.key, this.color, }); final double size; final Color? color; @override Widget build(BuildContext context) { if (color != null) { return SizedBox( height: double.infinity, width: size, child: ColoredBox( color: color!, ), ); } else { return Space(size, 0); } } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/linux/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.10) set(PROJECT_NAME "flowy_infra_ui") project(${PROJECT_NAME} LANGUAGES CXX) # This value is used when generating builds using this plugin, so it must # not be changed set(PLUGIN_NAME "flowy_infra_ui_plugin") add_library(${PLUGIN_NAME} SHARED "flowy_infra_ui_plugin.cc" "flowy_infra_u_i_plugin.cc" ) apply_standard_settings(${PLUGIN_NAME}) set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) # List of absolute paths to libraries that should be bundled with the plugin set(flowy_infra_ui_bundled_libraries "" PARENT_SCOPE ) ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/linux/flowy_infra_u_i_plugin.cc ================================================ #include "include/flowy_infra_ui/flowy_infra_u_i_plugin.h" #include #include #include #include #define FLOWY_INFRA_U_I_PLUGIN(obj) \ (G_TYPE_CHECK_INSTANCE_CAST((obj), flowy_infra_u_i_plugin_get_type(), \ FlowyInfraUiPlugin)) struct _FlowyInfraUiPlugin { GObject parent_instance; }; G_DEFINE_TYPE(FlowyInfraUiPlugin, flowy_infra_u_i_plugin, g_object_get_type()) // Called when a method call is received from Flutter. static void flowy_infra_u_i_plugin_handle_method_call( FlowyInfraUiPlugin* self, FlMethodCall* method_call) { g_autoptr(FlMethodResponse) response = nullptr; const gchar* method = fl_method_call_get_name(method_call); if (strcmp(method, "getPlatformVersion") == 0) { struct utsname uname_data = {}; uname(&uname_data); g_autofree gchar *version = g_strdup_printf("Linux %s", uname_data.version); g_autoptr(FlValue) result = fl_value_new_string(version); response = FL_METHOD_RESPONSE(fl_method_success_response_new(result)); } else { response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); } fl_method_call_respond(method_call, response, nullptr); } static void flowy_infra_u_i_plugin_dispose(GObject* object) { G_OBJECT_CLASS(flowy_infra_u_i_plugin_parent_class)->dispose(object); } static void flowy_infra_u_i_plugin_class_init(FlowyInfraUiPluginClass* klass) { G_OBJECT_CLASS(klass)->dispose = flowy_infra_u_i_plugin_dispose; } static void flowy_infra_u_i_plugin_init(FlowyInfraUiPlugin* self) {} static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { FlowyInfraUiPlugin* plugin = FLOWY_INFRA_U_I_PLUGIN(user_data); flowy_infra_u_i_plugin_handle_method_call(plugin, method_call); } void flowy_infra_u_i_plugin_register_with_registrar(FlPluginRegistrar* registrar) { FlowyInfraUiPlugin* plugin = FLOWY_INFRA_U_I_PLUGIN( g_object_new(flowy_infra_u_i_plugin_get_type(), nullptr)); g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); g_autoptr(FlMethodChannel) channel = fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), "flowy_infra_u_i", FL_METHOD_CODEC(codec)); fl_method_channel_set_method_call_handler(channel, method_call_cb, g_object_ref(plugin), g_object_unref); g_object_unref(plugin); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/linux/flowy_infra_ui_plugin.cc ================================================ #include "include/flowy_infra_ui/flowy_infra_ui_plugin.h" #include #include #include #include #define FLOWY_INFRA_UI_PLUGIN(obj) \ (G_TYPE_CHECK_INSTANCE_CAST((obj), flowy_infra_ui_plugin_get_type(), \ FlowyInfraUiPlugin)) struct _FlowyInfraUiPlugin { GObject parent_instance; }; G_DEFINE_TYPE(FlowyInfraUiPlugin, flowy_infra_ui_plugin, g_object_get_type()) // Called when a method call is received from Flutter. static void flowy_infra_ui_plugin_handle_method_call( FlowyInfraUiPlugin* self, FlMethodCall* method_call) { g_autoptr(FlMethodResponse) response = nullptr; const gchar* method = fl_method_call_get_name(method_call); if (strcmp(method, "getPlatformVersion") == 0) { struct utsname uname_data = {}; uname(&uname_data); g_autofree gchar *version = g_strdup_printf("Linux %s", uname_data.version); g_autoptr(FlValue) result = fl_value_new_string(version); response = FL_METHOD_RESPONSE(fl_method_success_response_new(result)); } else { response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); } fl_method_call_respond(method_call, response, nullptr); } static void flowy_infra_ui_plugin_dispose(GObject* object) { G_OBJECT_CLASS(flowy_infra_ui_plugin_parent_class)->dispose(object); } static void flowy_infra_ui_plugin_class_init(FlowyInfraUiPluginClass* klass) { G_OBJECT_CLASS(klass)->dispose = flowy_infra_ui_plugin_dispose; } static void flowy_infra_ui_plugin_init(FlowyInfraUiPlugin* self) {} static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { FlowyInfraUiPlugin* plugin = FLOWY_INFRA_UI_PLUGIN(user_data); flowy_infra_ui_plugin_handle_method_call(plugin, method_call); } void flowy_infra_ui_plugin_register_with_registrar(FlPluginRegistrar* registrar) { FlowyInfraUiPlugin* plugin = FLOWY_INFRA_UI_PLUGIN( g_object_new(flowy_infra_ui_plugin_get_type(), nullptr)); g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); g_autoptr(FlMethodChannel) channel = fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), "flowy_infra_ui", FL_METHOD_CODEC(codec)); fl_method_channel_set_method_call_handler(channel, method_call_cb, g_object_ref(plugin), g_object_unref); g_object_unref(plugin); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_u_i_plugin.h ================================================ #ifndef FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ #define FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ #include G_BEGIN_DECLS #ifdef FLUTTER_PLUGIN_IMPL #define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) #else #define FLUTTER_PLUGIN_EXPORT #endif typedef struct _FlowyInfraUiPlugin FlowyInfraUiPlugin; typedef struct { GObjectClass parent_class; } FlowyInfraUiPluginClass; FLUTTER_PLUGIN_EXPORT GType flowy_infra_u_i_plugin_get_type(); FLUTTER_PLUGIN_EXPORT void flowy_infra_u_i_plugin_register_with_registrar( FlPluginRegistrar* registrar); G_END_DECLS #endif // FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_ui_plugin.h ================================================ #ifndef FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ #define FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ #include G_BEGIN_DECLS #ifdef FLUTTER_PLUGIN_IMPL #define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) #else #define FLUTTER_PLUGIN_EXPORT #endif typedef struct _FlowyInfraUiPlugin FlowyInfraUiPlugin; typedef struct { GObjectClass parent_class; } FlowyInfraUiPluginClass; FLUTTER_PLUGIN_EXPORT GType flowy_infra_ui_plugin_get_type(); FLUTTER_PLUGIN_EXPORT void flowy_infra_ui_plugin_register_with_registrar( FlPluginRegistrar* registrar); G_END_DECLS #endif // FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/macos/Classes/FlowyInfraUiPlugin.swift ================================================ import Cocoa import FlutterMacOS public class FlowyInfraUIPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "flowy_infra_ui", binaryMessenger: registrar.messenger) let instance = FlowyInfraUIPlugin() registrar.addMethodCallDelegate(instance, channel: channel) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "getPlatformVersion": result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString) default: result(FlutterMethodNotImplemented) } } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/macos/flowy_infra_ui.podspec ================================================ # # To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. # Run `pod lib lint flowy_infra_ui.podspec` to validate before publishing. # Pod::Spec.new do |s| s.name = 'flowy_infra_ui' s.version = '0.0.1' s.summary = 'A new flutter plugin project.' s.description = <<-DESC A new flutter plugin project. DESC s.homepage = 'http://example.com' s.license = { :file => '../LICENSE' } s.author = { 'AppFlowy' => 'annie@appflowy.io' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'FlutterMacOS' s.platform = :osx, '10.13' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.swift_version = '5.0' end ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml ================================================ name: flowy_infra_ui description: A new flutter plugin project. version: 0.0.1 homepage: https://appflowy.io publish_to: "none" environment: sdk: ">=3.0.0 <4.0.0" flutter: ">=3.10.1" dependencies: flutter: sdk: flutter # Thirdparty packages styled_widget: ^0.4.1 animations: ^2.0.7 loading_indicator: ^3.1.0 async: url_launcher: ^6.1.11 google_fonts: ^6.1.0 # Federated Platform Interface flowy_infra_ui_platform_interface: path: flowy_infra_ui_platform_interface appflowy_popover: path: ../appflowy_popover flowy_infra: path: ../flowy_infra flowy_svg: path: ../flowy_svg analyzer: 6.11.0 dev_dependencies: build_runner: ^2.4.9 provider: ^6.0.5 flutter_test: sdk: flutter flutter_lints: ^3.0.1 flutter: plugin: platforms: # TODO: uncomment android part will fail the Linux build process, will resolve later # android: # package: com.example.flowy_infra_ui # pluginClass: FlowyInfraUIPlugin ios: pluginClass: FlowyInfraUIPlugin macos: pluginClass: FlowyInfraUIPlugin windows: pluginClass: FlowyInfraUIPlugin linux: pluginClass: FlowyInfraUIPlugin web: default_package: flowy_infra_ui_web ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/test/flowy_infra_ui_test.dart ================================================ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { const MethodChannel channel = MethodChannel('flowy_infra_ui'); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { return '42'; }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( channel, null, ); }); test('getPlatformVersion', () async {}); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/windows/.gitignore ================================================ flutter/ # Visual Studio user-specific files. *.suo *.user *.userosscache *.sln.docstates # Visual Studio build-related files. x64/ x86/ # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/windows/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.15) set(PROJECT_NAME "flowy_infra_ui") project(${PROJECT_NAME} LANGUAGES CXX) # This value is used when generating builds using this plugin, so it must # not be changed set(PLUGIN_NAME "flowy_infra_ui_plugin") add_library(${PLUGIN_NAME} SHARED "flowy_infra_ui_plugin.cpp" ) apply_standard_settings(${PLUGIN_NAME}) set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) # List of absolute paths to libraries that should be bundled with the plugin set(flowy_infra_ui_bundled_libraries "" PARENT_SCOPE ) ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/windows/flowy_infra_ui_plugin.cpp ================================================ #include "include/flowy_infra_ui/flowy_infra_ui_plugin.h" // This must be included before many other Windows headers. #include // For getPlatformVersion; remove unless needed for your plugin implementation. #include #include #include #include #include #include #include namespace { class FlowyInfraUIPlugin : public flutter::Plugin { public: static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); FlowyInfraUIPlugin(); virtual ~FlowyInfraUIPlugin(); private: // Called when a method is called on this plugin's channel from Dart. void HandleMethodCall( const flutter::MethodCall &method_call, std::unique_ptr> result); }; // static void FlowyInfraUIPlugin::RegisterWithRegistrar( flutter::PluginRegistrarWindows *registrar) { auto channel = std::make_unique>( registrar->messenger(), "flowy_infra_ui", &flutter::StandardMethodCodec::GetInstance()); auto plugin = std::make_unique(); channel->SetMethodCallHandler( [plugin_pointer = plugin.get()](const auto &call, auto result) { plugin_pointer->HandleMethodCall(call, std::move(result)); }); registrar->AddPlugin(std::move(plugin)); } FlowyInfraUIPlugin::FlowyInfraUIPlugin() {} FlowyInfraUIPlugin::~FlowyInfraUIPlugin() {} void FlowyInfraUIPlugin::HandleMethodCall( const flutter::MethodCall &method_call, std::unique_ptr> result) { if (method_call.method_name().compare("getPlatformVersion") == 0) { std::ostringstream version_stream; version_stream << "Windows "; if (IsWindows10OrGreater()) { version_stream << "10+"; } else if (IsWindows8OrGreater()) { version_stream << "8"; } else if (IsWindows7OrGreater()) { version_stream << "7"; } result->Success(flutter::EncodableValue(version_stream.str())); } else { result->NotImplemented(); } } } // namespace void FlowyInfraUIPluginRegisterWithRegistrar( FlutterDesktopPluginRegistrarRef registrar) { FlowyInfraUIPlugin::RegisterWithRegistrar( flutter::PluginRegistrarManager::GetInstance() ->GetRegistrar(registrar)); } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_u_i_plugin.h ================================================ #ifndef FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ #define FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ #include #ifdef FLUTTER_PLUGIN_IMPL #define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) #else #define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) #endif #if defined(__cplusplus) extern "C" { #endif FLUTTER_PLUGIN_EXPORT void FlowyInfraUIPluginRegisterWithRegistrar( FlutterDesktopPluginRegistrarRef registrar); #if defined(__cplusplus) } // extern "C" #endif #endif // FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_ui_plugin.h ================================================ #ifndef FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ #define FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ #include #ifdef FLUTTER_PLUGIN_IMPL #define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) #else #define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) #endif #if defined(__cplusplus) extern "C" { #endif FLUTTER_PLUGIN_EXPORT void FlowyInfraUIPluginRegisterWithRegistrar( FlutterDesktopPluginRegistrarRef registrar); #if defined(__cplusplus) } // extern "C" #endif #endif // FLUTTER_PLUGIN_FLOWY_INFRA_UI_PLUGIN_H_ ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug Report about: Create a report to help us improve title: "fix: " labels: bug --- **Description** A clear and concise description of what the bug is. **Steps To Reproduce** 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected Behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Additional Context** Add any other context about the problem here. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/build.md ================================================ --- name: Build System about: Changes that affect the build system or external dependencies title: "build: " labels: build --- **Description** Describe what changes need to be done to the build system and why. **Requirements** - [ ] The build system is passing ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/chore.md ================================================ --- name: Chore about: Other changes that don't modify src or test files title: "chore: " labels: chore --- **Description** Clearly describe what change is needed and why. If this changes code then please use another issue type. **Requirements** - [ ] No functional changes to the code ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/ci.md ================================================ --- name: Continuous Integration about: Changes to the CI configuration files and scripts title: "ci: " labels: ci --- **Description** Describe what changes need to be done to the ci/cd system and why. **Requirements** - [ ] The ci system is passing ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/documentation.md ================================================ --- name: Documentation about: Improve the documentation so all collaborators have a common understanding title: "docs: " labels: documentation --- **Description** Clearly describe what documentation you are looking to add or improve. **Requirements** - [ ] Requirements go here ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature Request about: A new feature to be added to the project title: "feat: " labels: feature --- **Description** Clearly describe what you are looking to add. The more context the better. **Requirements** - [ ] Checklist of requirements to be fulfilled **Additional Context** Add any other context or screenshots about the feature request go here. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/performance.md ================================================ --- name: Performance Update about: A code change that improves performance title: "perf: " labels: performance --- **Description** Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience. **Requirements** - [ ] There is no drop in test coverage. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/refactor.md ================================================ --- name: Refactor about: A code change that neither fixes a bug nor adds a feature title: "refactor: " labels: refactor --- **Description** Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize. **Requirements** - [ ] There is no drop in test coverage. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/revert.md ================================================ --- name: Revert Commit about: Reverts a previous commit title: "revert: " labels: revert --- **Description** Provide a link to a PR/Commit that you are looking to revert and why. **Requirements** - [ ] Change has been reverted - [ ] No change in test coverage has happened - [ ] A new ticket is created for any follow on work that needs to happen ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/style.md ================================================ --- name: Style Changes about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc) title: "style: " labels: style --- **Description** Clearly describe what you are looking to change and why. **Requirements** - [ ] There is no drop in test coverage. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/test.md ================================================ --- name: Test about: Adding missing tests or correcting existing tests title: "test: " labels: test --- **Description** List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past. **Requirements** - [ ] There is no drop in test coverage. ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/PULL_REQUEST_TEMPLATE.md ================================================ ## Status **READY/IN DEVELOPMENT/HOLD** ## Description ## Type of Change - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 🛠️ Bug fix (non-breaking change which fixes an issue) - [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change) - [ ] 🧹 Code refactor - [ ] ✅ Build configuration change - [ ] 📝 Documentation - [ ] 🗑️ Chore ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/cspell.json ================================================ { "version": "0.2", "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", "dictionaries": ["vgv_allowed", "vgv_forbidden"], "dictionaryDefinitions": [ { "name": "vgv_allowed", "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", "description": "Allowed VGV Spellings" }, { "name": "vgv_forbidden", "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", "description": "Forbidden VGV Spellings" } ], "useGitignore": true, "words": [ "flowy_svg" ] } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/dependabot.yaml ================================================ version: 2 enable-beta-ecosystems: true updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" - package-ecosystem: "pub" directory: "/" schedule: interval: "daily" ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.github/workflows/main.yaml ================================================ name: ci concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: pull_request: branches: - main jobs: semantic_pull_request: uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 spell-check: uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 with: includes: "**/*.md" modified_files_only: false build: uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 with: flutter_channel: stable ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # IntelliJ related *.iml *.ipr *.iws .idea/ # VSCode related .vscode/ # Flutter/Dart/Pub related **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .packages .pub-cache/ .pub/ /build/ pubspec.lock # Web related lib/generated_plugin_registrant.dart # Symbolication related app.*.symbols # Obfuscation related app.*.map.json # Test related coverage ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/analysis_options.yaml ================================================ linter: ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart ================================================ import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'package:args/args.dart'; import 'package:path/path.dart' as path; import 'options.dart'; const languageKeywords = [ 'abstract', 'else', 'import', 'show', 'as', 'enum', 'static', 'assert', 'export', 'interface', 'super', 'async', 'extends', 'is', 'switch', 'await', 'extension', 'late', 'sync', 'base', 'external', 'library', 'this', 'break', 'factory', 'mixin', 'throw', 'case', 'false', 'new', 'true', 'catch', 'final', 'variable', 'null', 'try', 'class', 'final', 'class', 'on', 'typedef', 'const', 'finally', 'operator', 'var', 'continue', 'for', 'part', 'void', 'covariant', 'Function', 'required', 'when', 'default', 'get', 'rethrow', 'while', 'deferred', 'hide', 'return', 'with', 'do', 'if', 'sealed', 'yield', 'dynamic', 'implements', 'set', ]; void main(List args) { if (_isHelpCommand(args)) { _printHelperDisplay(); } else { generateSvgData(_generateOption(args)); } } bool _isHelpCommand(List args) { return args.length == 1 && (args[0] == '--help' || args[0] == '-h'); } void _printHelperDisplay() { final parser = _generateArgParser(null); log(parser.usage); } Options _generateOption(List args) { final generateOptions = Options(); _generateArgParser(generateOptions).parse(args); return generateOptions; } ArgParser _generateArgParser(Options? generateOptions) { final parser = ArgParser() ..addOption( 'source-dir', abbr: 'S', defaultsTo: '/assets/flowy_icons', callback: (String? x) => generateOptions!.sourceDir = x, help: 'Folder containing localization files', ) ..addOption( 'output-dir', abbr: 'O', defaultsTo: '/lib/generated', callback: (String? x) => generateOptions!.outputDir = x, help: 'Output folder stores for the generated file', ) ..addOption( 'name', abbr: 'N', defaultsTo: 'flowy_svgs.g.dart', callback: (String? x) => generateOptions!.outputFile = x, help: 'The name of the output file that this tool will generate', ); return parser; } Directory source(Options options) => Directory( [ Directory.current.path, Directory.fromUri( Uri.file( options.sourceDir!, windows: Platform.isWindows, ), ).path, ].join(), ); File output(Options options) => File( [ Directory.current.path, Directory.fromUri( Uri.file(options.outputDir!, windows: Platform.isWindows), ).path, Platform.pathSeparator, File.fromUri( Uri.file( options.outputFile!, windows: Platform.isWindows, ), ).path, ].join(), ); /// generates the svg data Future generateSvgData(Options options) async { // the source directory that this is targeting final src = source(options); // the output directory that this is targeting final out = output(options); var files = await dirContents(src); files = files.where((f) => f.path.contains('.svg')).toList(); await generate(files, out, options); } /// List the contents of the directory Future> dirContents(Directory dir) { final files = []; final completer = Completer>(); dir.list(recursive: true).listen( files.add, onDone: () => completer.complete(files), ); return completer.future; } /// Generate the abstract class for the FlowySvg data. Future generate( List files, File output, Options options, ) async { final generated = File(output.path); // create the output file if it doesn't exist if (!generated.existsSync()) { generated.createSync(recursive: true); } // content of the generated file final builder = StringBuffer()..writeln(prelude); files.whereType().forEach( (element) => builder.writeln(lineFor(element, options)), ); builder.writeln(postlude); generated.writeAsStringSync(builder.toString()); } String lineFor(File file, Options options) { final name = varNameFor(file, options); return " static const $name = FlowySvgData('${pathFor(file)}');"; } String pathFor(File file) { final relative = path.relative(file.path, from: Directory.current.path); final uri = Uri.file(relative); return uri.toFilePath(windows: false); } String varNameFor(File file, Options options) { final from = source(options).path; final relative = Uri.file(path.relative(file.path, from: from)); final parts = relative.pathSegments; final cleaned = parts.map(clean).toList(); var simplified = cleaned.reversed // join all cleaned path segments with an underscore .join('_') // there are some cases where the segment contains a dart reserved keyword // in this case, the path will be suffixed with an underscore which means // there will be a double underscore, so we have to replace the double // underscore with one underscore .replaceAll(RegExp('_+'), '_'); // rename icon based on relative path folder name (16x, 24x, etc.) for (final key in sizeMap.keys) { simplified = simplified.replaceAll(key, sizeMap[key]!); } return simplified; } const sizeMap = { r'$16x': 's', r'$20x': 'm', r'$24x': 'm', r'$32x': 'lg', r'$40x': 'xl' }; /// cleans the path segment before rejoining the path into a variable name String clean(String segment) { final cleaned = segment // replace all dashes with underscores (dash is invalid in // a variable name) .replaceAll('-', '_') // replace all spaces with an underscore .replaceAll(RegExp(r'\s+'), '_') // replace all file extensions with an empty string .replaceAll(RegExp(r'\.[^.]*$'), '') // convert everything to lower case .toLowerCase(); if (languageKeywords.contains(cleaned)) { return '${cleaned}_'; } else if (cleaned.startsWith(RegExp('[0-9]'))) { return '\$$cleaned'; } return cleaned; } /// The prelude for the generated file const prelude = ''' // DO NOT EDIT. This code is generated by the flowy_svg script // import the widget with from this package import 'package:flowy_svg/flowy_svg.dart'; // export as convenience to the programmer export 'package:flowy_svg/flowy_svg.dart'; /// A class to easily list all the svgs in the app class FlowySvgs {'''; /// The postlude for the generated file const postlude = ''' } '''; ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/bin/options.dart ================================================ /// The options for the command line tool class Options { /// The source directory which the tool will use to generate the output file String? sourceDir; /// The output directory which the tool will use to output the file(s) String? outputDir; /// The name of the file that will be generated String? outputFile; @override String toString() { return ''' Options: sourceDir: $sourceDir outputDir: $outputDir name: $outputFile '''; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/lib/flowy_svg.dart ================================================ /// A Flutter package to generate Dart code for SVG files. library flowy_svg; export 'src/flowy_svg.dart'; ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; export 'package:flutter_svg/flutter_svg.dart'; /// The class for FlowySvgData that the code generator will implement class FlowySvgData { /// The svg data const FlowySvgData( this.path, ); /// The path to the svg data in appflowy assets/images final String path; } /// For icon that needs to change color when it is on hovered /// /// Get the hover color from ThemeData class FlowySvg extends StatelessWidget { /// Construct a FlowySvg Widget const FlowySvg( this.svg, { super.key, this.size, this.color, this.blendMode = BlendMode.srcIn, this.opacity, this.svgString, }); /// Construct a FlowySvg Widget from a string factory FlowySvg.string( String svgString, { Key? key, Size? size, Color? color, BlendMode? blendMode = BlendMode.srcIn, double? opacity, }) { return FlowySvg( const FlowySvgData(''), key: key, size: size, color: color, blendMode: blendMode, opacity: opacity, svgString: svgString, ); } /// The data for the flowy svg. Will be generated by the generator in this /// package within bin/flowy_svg.dart final FlowySvgData svg; /// The size of the svg final Size? size; /// The svg string final String? svgString; /// The color of the svg. /// /// This property will not be applied to the underlying svg widget if the /// blend mode is null, but the blend mode defaults to [BlendMode.srcIn] /// if it is not explicitly set to null. final Color? color; /// The blend mode applied to the svg. /// /// If the blend mode is null then the icon color will not be applied. /// Set both the icon color and blendMode in order to apply color to the /// svg widget. final BlendMode? blendMode; /// The opacity of the svg /// /// if null then use the opacity of the iconColor final double? opacity; @override Widget build(BuildContext context) { Color? iconColor = color ?? Theme.of(context).iconTheme.color; if (opacity != null) { iconColor = iconColor?.withValues(alpha: opacity!); } final textScaleFactor = MediaQuery.textScalerOf(context).scale(1); final Widget svg; if (svgString != null) { svg = SvgPicture.string( svgString!, width: size?.width, height: size?.height, colorFilter: iconColor != null && blendMode != null ? ColorFilter.mode( iconColor, blendMode!, ) : null, ); } else { svg = SvgPicture.asset( _normalized(), width: size?.width, height: size?.height, colorFilter: iconColor != null && blendMode != null ? ColorFilter.mode( iconColor, blendMode!, ) : null, ); } return Transform.scale( scale: textScaleFactor, child: SizedBox( width: size?.width, height: size?.height, child: svg, ), ); } /// If the SVG's path does not start with `assets/`, it is /// normalized and directed to `assets/images/` /// /// If the SVG does not end with `.svg`, then we append the file extension /// String _normalized() { var path = svg.path; if (!path.toLowerCase().startsWith('assets/')) { path = 'assets/images/$path'; } if (!path.toLowerCase().endsWith('.svg')) { path = '$path.svg'; } return path; } } ================================================ FILE: frontend/appflowy_flutter/packages/flowy_svg/pubspec.yaml ================================================ name: flowy_svg description: AppFlowy Svgs version: 0.1.0+1 publish_to: none environment: sdk: ">=3.0.0 <4.0.0" flutter: 3.10.0 dependencies: args: ^2.4.2 flutter: sdk: flutter flutter_svg: ^2.0.7 path: ^1.8.3 dev_dependencies: very_good_analysis: ^5.0.0 ================================================ FILE: frontend/appflowy_flutter/pubspec.lock ================================================ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: _fe_analyzer_shared: dependency: transitive description: name: _fe_analyzer_shared sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted version: "76.0.0" _macros: dependency: transitive description: dart source: sdk version: "0.3.3" analyzer: dependency: "direct main" description: name: analyzer sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted version: "6.11.0" animations: dependency: transitive description: name: animations sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb url: "https://pub.dev" source: hosted version: "2.0.11" ansicolor: dependency: transitive description: name: ansicolor sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" url: "https://pub.dev" source: hosted version: "2.0.3" any_date: dependency: "direct main" description: name: any_date sha256: e9ed245ba44ccebf3c2d6daa3592213f409821128593d448b219a1f8e9bd17a1 url: "https://pub.dev" source: hosted version: "1.1.1" app_links: dependency: "direct main" description: name: app_links sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950" url: "https://pub.dev" source: hosted version: "6.3.3" app_links_linux: dependency: transitive description: name: app_links_linux sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 url: "https://pub.dev" source: hosted version: "1.0.3" app_links_platform_interface: dependency: transitive description: name: app_links_platform_interface sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" url: "https://pub.dev" source: hosted version: "2.0.2" app_links_web: dependency: transitive description: name: app_links_web sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 url: "https://pub.dev" source: hosted version: "1.0.4" appflowy_backend: dependency: "direct main" description: path: "packages/appflowy_backend" relative: true source: path version: "0.0.1" appflowy_board: dependency: "direct main" description: path: "." ref: e8317c0d1af8d23dc5707b02ea43864536b6de91 resolved-ref: e8317c0d1af8d23dc5707b02ea43864536b6de91 url: "https://github.com/AppFlowy-IO/appflowy-board.git" source: git version: "0.1.2" appflowy_editor: dependency: "direct main" description: path: "." ref: "470c4e7" resolved-ref: "470c4e77c71b63f693ce0923a927afcd667d6f3b" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "5.2.0" appflowy_editor_plugins: dependency: "direct main" description: path: "packages/appflowy_editor_plugins" ref: "4efcff7" resolved-ref: "4efcff720ed01dd4d0f5f88a9f1ff6f79f423caa" url: "https://github.com/AppFlowy-IO/AppFlowy-plugins.git" source: git version: "0.0.6" appflowy_popover: dependency: "direct main" description: path: "packages/appflowy_popover" relative: true source: path version: "0.0.1" appflowy_result: dependency: "direct main" description: path: "packages/appflowy_result" relative: true source: path version: "0.0.1" appflowy_ui: dependency: "direct main" description: path: "packages/appflowy_ui" relative: true source: path version: "1.0.0" archive: dependency: "direct main" 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" async: dependency: transitive description: name: async sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" url: "https://pub.dev" source: hosted version: "2.11.0" auto_size_text_field: dependency: "direct main" description: name: auto_size_text_field sha256: "41c90b2270e38edc6ce5c02e5a17737a863e65e246bdfc94565a38f3ec399144" url: "https://pub.dev" source: hosted version: "2.2.4" auto_updater: dependency: "direct main" description: path: "packages/auto_updater" ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" resolved-ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" url: "https://github.com/LucasXu0/auto_updater.git" source: git version: "1.0.0" auto_updater_macos: dependency: "direct overridden" description: path: "packages/auto_updater_macos" ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" resolved-ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" url: "https://github.com/LucasXu0/auto_updater.git" source: git version: "1.0.0" auto_updater_platform_interface: dependency: "direct overridden" description: path: "packages/auto_updater_platform_interface" ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" resolved-ref: "1d81a824f3633f1d0200ba51b78fe0f9ce429458" url: "https://github.com/LucasXu0/auto_updater.git" source: git version: "1.0.0" auto_updater_windows: dependency: transitive description: name: auto_updater_windows sha256: "2bba20a71eee072f49b7267fedd5c4f1406c4b1b1e5b83932c634dbab75b80c9" url: "https://pub.dev" source: hosted version: "1.0.0" avatar_stack: dependency: "direct main" description: name: avatar_stack sha256: "354527ba139956fd6439e2c49199d8298d72afdaa6c4cd6f37f26b97faf21f7e" url: "https://pub.dev" source: hosted version: "3.0.0" barcode: dependency: transitive description: name: barcode sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" url: "https://pub.dev" source: hosted version: "2.2.9" bidi: dependency: transitive description: name: bidi sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" url: "https://pub.dev" source: hosted version: "2.0.13" bitsdojo_window: dependency: "direct main" description: name: bitsdojo_window sha256: "88ef7765dafe52d97d7a3684960fb5d003e3151e662c18645c1641c22b873195" url: "https://pub.dev" source: hosted version: "0.1.6" bitsdojo_window_linux: dependency: transitive description: name: bitsdojo_window_linux sha256: "9519c0614f98be733e0b1b7cb15b827007886f6fe36a4fb62cf3d35b9dd578ab" url: "https://pub.dev" source: hosted version: "0.1.4" bitsdojo_window_macos: dependency: transitive description: name: bitsdojo_window_macos sha256: f7c5be82e74568c68c5b8449e2c5d8fd12ec195ecd70745a7b9c0f802bb0268f url: "https://pub.dev" source: hosted version: "0.1.4" bitsdojo_window_platform_interface: dependency: transitive description: name: bitsdojo_window_platform_interface sha256: "65daa015a0c6dba749bdd35a0f092e7a8ba8b0766aa0480eb3ef808086f6e27c" url: "https://pub.dev" source: hosted version: "0.1.2" bitsdojo_window_windows: dependency: transitive description: name: bitsdojo_window_windows sha256: fa982cf61ede53f483e50b257344a1c250af231a3cdc93a7064dd6dc0d720b68 url: "https://pub.dev" source: hosted version: "0.1.6" bloc: dependency: "direct main" description: name: bloc sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" url: "https://pub.dev" source: hosted version: "9.0.0" bloc_test: dependency: "direct dev" description: name: bloc_test sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" url: "https://pub.dev" source: hosted version: "10.0.0" 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" calendar_view: dependency: "direct main" description: path: "." ref: "6fe0c98" resolved-ref: "6fe0c989289b077569858d5472f3f7ec05b7746f" url: "https://github.com/Xazin/flutter_calendar_view" source: git version: "1.0.5" characters: dependency: transitive description: name: characters sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" url: "https://pub.dev" source: hosted version: "1.3.0" charcode: dependency: transitive description: name: charcode sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a url: "https://pub.dev" source: hosted version: "1.4.0" checked_yaml: dependency: transitive description: name: checked_yaml sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted version: "2.0.3" 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" connectivity_plus: dependency: "direct main" description: name: connectivity_plus sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0" url: "https://pub.dev" source: hosted version: "5.0.2" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a url: "https://pub.dev" source: hosted version: "1.2.4" convert: dependency: transitive description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted version: "3.1.2" coverage: dependency: transitive description: name: coverage sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 url: "https://pub.dev" source: hosted version: "1.11.1" cross_cache: dependency: transitive description: name: cross_cache sha256: "80329477264c73f09945ee780ccdc84df9231f878dc7227d132d301e34ff310b" url: "https://pub.dev" source: hosted version: "0.0.4" 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: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted version: "1.0.2" dart_style: dependency: transitive description: name: dart_style sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted version: "2.3.7" dbus: dependency: transitive description: name: dbus sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" url: "https://pub.dev" source: hosted version: "0.7.10" defer_pointer: dependency: "direct main" description: name: defer_pointer sha256: d69e6f8c1d0f052d2616cc1db3782e0ea73f42e4c6f6122fd1a548dfe79faf02 url: "https://pub.dev" source: hosted version: "0.0.2" desktop_drop: dependency: "direct main" description: name: desktop_drop sha256: "03abf1c0443afdd1d65cf8fa589a2f01c67a11da56bbb06f6ea1de79d5628e94" url: "https://pub.dev" source: hosted version: "0.5.0" device_info_plus: dependency: "direct main" description: name: device_info_plus sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da" url: "https://pub.dev" source: hosted version: "11.3.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" url: "https://pub.dev" source: hosted version: "7.0.2" diff_match_patch: dependency: transitive description: name: diff_match_patch sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" url: "https://pub.dev" source: hosted version: "0.4.1" diffutil_dart: dependency: "direct main" description: name: diffutil_dart sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" url: "https://pub.dev" source: hosted version: "4.0.1" dio: dependency: transitive 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" dotted_border: dependency: "direct main" description: name: dotted_border sha256: "108837e11848ca776c53b30bc870086f84b62ed6e01c503ed976e8f8c7df9c04" url: "https://pub.dev" source: hosted version: "2.1.0" easy_debounce: dependency: transitive description: name: easy_debounce sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236 url: "https://pub.dev" source: hosted version: "2.0.3" easy_localization: dependency: "direct main" description: name: easy_localization sha256: fa59bcdbbb911a764aa6acf96bbb6fa7a5cf8234354fc45ec1a43a0349ef0201 url: "https://pub.dev" source: hosted version: "3.0.7" easy_logger: dependency: transitive description: name: easy_logger sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 url: "https://pub.dev" source: hosted version: "0.0.2" envied: dependency: "direct main" description: name: envied sha256: "08a9012e5d93e1a816919a52e37c7b8367e73ebb8d52d1ca7dd6fcd875a2cd2c" url: "https://pub.dev" source: hosted version: "1.0.1" envied_generator: dependency: "direct dev" description: name: envied_generator sha256: "9a49ca9f3744069661c4f2c06993647699fae2773bca10b593fbb3228d081027" url: "https://pub.dev" source: hosted version: "1.0.1" equatable: dependency: "direct main" description: name: equatable sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" url: "https://pub.dev" source: hosted version: "2.0.7" event_bus: dependency: "direct main" description: name: event_bus sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" url: "https://pub.dev" source: hosted version: "2.0.1" expandable: dependency: "direct main" description: name: expandable sha256: "9604d612d4d1146dafa96c6d8eec9c2ff0994658d6d09fed720ab788c7f5afc2" url: "https://pub.dev" source: hosted version: "5.0.1" extended_text_field: dependency: "direct main" description: name: extended_text_field sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d" url: "https://pub.dev" source: hosted version: "16.0.2" extended_text_library: dependency: "direct main" description: name: extended_text_library sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" url: "https://pub.dev" source: hosted version: "12.0.1" 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: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted version: "7.0.0" file_picker: dependency: "direct overridden" description: name: file_picker sha256: "16dc141db5a2ccc6520ebb6a2eb5945b1b09e95085c021d9f914f8ded7f1465c" url: "https://pub.dev" source: hosted version: "8.1.4" file_selector_linux: dependency: transitive 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_windows: dependency: transitive description: name: file_selector_windows sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4" url: "https://pub.dev" source: hosted version: "0.9.3+3" fixnum: dependency: "direct main" description: name: fixnum sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted version: "1.1.1" flex_color_picker: dependency: "direct main" description: name: flex_color_picker sha256: c083b79f1c57eaeed9f464368be376951230b3cb1876323b784626152a86e480 url: "https://pub.dev" source: hosted version: "3.7.0" flex_seed_scheme: dependency: transitive description: name: flex_seed_scheme sha256: d3ba3c5c92d2d79d45e94b4c6c71d01fac3c15017da1545880c53864da5dfeb0 url: "https://pub.dev" source: hosted version: "3.5.0" flowy_infra: dependency: "direct main" description: path: "packages/flowy_infra" relative: true source: path version: "0.0.1" flowy_infra_ui: dependency: "direct main" description: path: "packages/flowy_infra_ui" relative: true source: path version: "0.0.1" flowy_infra_ui_platform_interface: dependency: transitive description: path: "packages/flowy_infra_ui/flowy_infra_ui_platform_interface" relative: true source: path version: "0.0.1" flowy_svg: dependency: "direct main" description: path: "packages/flowy_svg" relative: true source: path version: "0.1.0+1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" flutter_animate: dependency: "direct main" description: name: flutter_animate sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" url: "https://pub.dev" source: hosted version: "4.5.2" flutter_bloc: dependency: "direct main" description: name: flutter_bloc sha256: "1046d719fbdf230330d3443187cc33cc11963d15c9089f6cc56faa42a4c5f0cc" url: "https://pub.dev" source: hosted version: "9.1.0" flutter_cache_manager: dependency: "direct main" description: path: flutter_cache_manager ref: HEAD resolved-ref: fbab857b1b1d209240a146d32f496379b9f62276 url: "https://github.com/LucasXu0/flutter_cache_manager.git" source: git version: "3.3.1" flutter_chat_core: dependency: "direct main" description: name: flutter_chat_core sha256: "14557aaac7c71b80c279eca41781d214853940cf01727934c742b5845c42dd1e" url: "https://pub.dev" source: hosted version: "0.0.2" flutter_chat_types: dependency: transitive description: name: flutter_chat_types sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9 url: "https://pub.dev" source: hosted version: "3.6.2" flutter_chat_ui: dependency: "direct main" description: name: flutter_chat_ui sha256: "2afd22eaebaf0f6ec8425048921479c3dd1a229604015dca05b174c6e8e44292" url: "https://pub.dev" source: hosted version: "2.0.0-dev.1" flutter_driver: dependency: transitive description: flutter source: sdk version: "0.0.0" flutter_emoji_mart: dependency: "direct main" description: path: "." ref: "355aa56" resolved-ref: "355aa56e9c74a91e00370a882739e0bb98c30bd8" url: "https://github.com/LucasXu0/emoji_mart.git" source: git version: "1.0.2" flutter_highlight: dependency: transitive description: name: flutter_highlight sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" url: "https://pub.dev" source: hosted version: "0.7.0" flutter_link_previewer: dependency: transitive description: name: flutter_link_previewer sha256: "007069e60f42419fb59872beb7a3cc3ea21e9f1bdff5d40239f376fa62ca9f20" url: "https://pub.dev" source: hosted version: "3.2.2" flutter_linkify: dependency: transitive description: name: flutter_linkify sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" url: "https://pub.dev" source: hosted version: "6.0.0" flutter_lints: dependency: "direct dev" description: name: flutter_lints sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://pub.dev" source: hosted version: "5.0.0" flutter_localizations: dependency: transitive description: flutter source: sdk version: "0.0.0" flutter_math_fork: dependency: "direct main" description: name: flutter_math_fork sha256: "284bab89b2fbf1bc3a0baf13d011c1dd324d004e35d177626b77f2fc056366ac" url: "https://pub.dev" source: hosted version: "0.7.3" 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_shaders: dependency: transitive description: name: flutter_shaders sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" url: "https://pub.dev" source: hosted version: "0.1.3" flutter_slidable: dependency: "direct main" description: name: flutter_slidable sha256: a857de7ea701f276fd6a6c4c67ae885b60729a3449e42766bb0e655171042801 url: "https://pub.dev" source: hosted version: "3.1.2" flutter_staggered_grid_view: dependency: "direct main" description: name: flutter_staggered_grid_view sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" url: "https://pub.dev" source: hosted version: "0.7.0" flutter_sticky_header: dependency: "direct overridden" description: name: flutter_sticky_header sha256: "7f76d24d119424ca0c95c146b8627a457e8de8169b0d584f766c2c545db8f8be" url: "https://pub.dev" source: hosted version: "0.7.0" flutter_svg: dependency: transitive description: name: flutter_svg sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b url: "https://pub.dev" source: hosted version: "2.0.17" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" flutter_tex: dependency: "direct main" description: name: flutter_tex sha256: ef7896946052e150514a2afe10f6e33e4fe0e7e4fc51195b65da811cb33c59ab url: "https://pub.dev" source: hosted version: "4.0.13" flutter_web_plugins: dependency: transitive description: flutter source: sdk version: "0.0.0" fluttertoast: dependency: "direct main" description: name: fluttertoast sha256: "24467dc20bbe49fd63e57d8e190798c4d22cbbdac30e54209d153a15273721d1" url: "https://pub.dev" source: hosted version: "8.2.10" freezed: dependency: "direct dev" description: name: freezed sha256: "44c19278dd9d89292cf46e97dc0c1e52ce03275f40a97c5a348e802a924bf40e" url: "https://pub.dev" source: hosted version: "2.5.7" freezed_annotation: dependency: "direct main" description: name: freezed_annotation sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 url: "https://pub.dev" source: hosted version: "2.4.4" frontend_server_client: dependency: transitive description: name: frontend_server_client sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted version: "4.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter source: sdk version: "0.0.0" get_it: dependency: "direct main" 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" go_router: dependency: "direct main" description: name: go_router sha256: "7c2d40b59890a929824f30d442e810116caf5088482629c894b9e4478c67472d" url: "https://pub.dev" source: hosted version: "14.6.3" google_fonts: dependency: "direct main" description: name: google_fonts sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 url: "https://pub.dev" source: hosted version: "6.2.1" graphs: dependency: transitive description: name: graphs sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted version: "2.3.2" gtk: dependency: transitive description: name: gtk sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c url: "https://pub.dev" source: hosted version: "2.1.0" highlight: dependency: "direct main" description: name: highlight sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" url: "https://pub.dev" source: hosted version: "0.7.0" hive: dependency: transitive description: name: hive sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" url: "https://pub.dev" source: hosted version: "2.2.3" hive_flutter: dependency: "direct main" description: name: hive_flutter sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc url: "https://pub.dev" source: hosted version: "1.1.0" hotkey_manager: dependency: "direct main" description: name: hotkey_manager sha256: "8aaa0aeaca7015b8c561a58d02eb7ebba95e93357fc9540398c5751ee24afd7c" url: "https://pub.dev" source: hosted version: "0.1.8" html: dependency: transitive description: name: html sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" url: "https://pub.dev" source: hosted version: "0.15.5" html2md: dependency: "direct main" description: name: html2md sha256: "465cf8ffa1b510fe0e97941579bf5b22e2d575f2cecb500a9c0254efe33a8036" url: "https://pub.dev" source: hosted version: "1.3.2" http: dependency: "direct main" 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: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted version: "4.1.2" iconsax_flutter: dependency: transitive description: name: iconsax_flutter sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d" url: "https://pub.dev" source: hosted version: "1.0.0" image: dependency: transitive description: name: image sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted version: "4.3.0" image_picker: dependency: "direct main" description: name: image_picker sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" url: "https://pub.dev" source: hosted version: "1.1.2" image_picker_android: dependency: transitive description: name: image_picker_android sha256: b62d34a506e12bb965e824b6db4fbf709ee4589cf5d3e99b45ab2287b008ee0c url: "https://pub.dev" source: hosted version: "0.8.12+20" image_picker_for_web: dependency: transitive description: name: image_picker_for_web sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" url: "https://pub.dev" source: hosted version: "3.0.6" image_picker_ios: dependency: transitive description: name: image_picker_ios sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" url: "https://pub.dev" source: hosted version: "0.8.12+2" image_picker_linux: dependency: transitive description: name: image_picker_linux sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" url: "https://pub.dev" source: hosted version: "0.2.1+1" image_picker_macos: dependency: transitive description: name: image_picker_macos sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" url: "https://pub.dev" source: hosted version: "0.2.1+1" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" url: "https://pub.dev" source: hosted version: "2.10.1" image_picker_windows: dependency: transitive description: name: image_picker_windows sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" url: "https://pub.dev" source: hosted version: "0.2.1+1" integration_test: dependency: "direct dev" description: flutter source: sdk version: "0.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" irondash_engine_context: dependency: transitive description: name: irondash_engine_context sha256: cd7b769db11a2b5243b037c8a9b1ecaef02e1ae27a2d909ffa78c1dad747bb10 url: "https://pub.dev" source: hosted version: "0.5.4" irondash_message_channel: dependency: transitive description: name: irondash_message_channel sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 url: "https://pub.dev" source: hosted version: "0.7.0" isolates: dependency: transitive description: name: isolates sha256: ce89e4141b27b877326d3715be2dceac7a7ba89f3229785816d2d318a75ddf28 url: "https://pub.dev" source: hosted version: "3.0.3+8" js: dependency: transitive description: name: js sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted version: "0.6.7" json_annotation: dependency: "direct main" description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted version: "4.9.0" json_serializable: dependency: "direct dev" description: name: json_serializable sha256: c2fcb3920cf2b6ae6845954186420fca40bc0a8abcc84903b7801f17d7050d7c url: "https://pub.dev" source: hosted version: "6.9.0" keyboard_height_plugin: dependency: "direct main" description: name: keyboard_height_plugin sha256: "3a51c8ebb43465ebe0b3bad17f3b6d945421e58011f3f5a08134afe69a3d775f" url: "https://pub.dev" source: hosted version: "0.1.5" leak_tracker: dependency: "direct main" 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" linked_scroll_controller: dependency: "direct main" description: name: linked_scroll_controller sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a url: "https://pub.dev" source: hosted version: "0.2.0" linkify: dependency: transitive description: name: linkify sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" url: "https://pub.dev" source: hosted version: "5.0.0" lint: dependency: transitive description: name: lint sha256: "4a539aa34ec5721a2c7574ae2ca0336738ea4adc2a34887d54b7596310b33c85" url: "https://pub.dev" source: hosted version: "1.10.0" lints: dependency: transitive description: name: lints sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted version: "5.1.1" loading_indicator: dependency: transitive description: name: loading_indicator sha256: a101ffb2aa3e646137d7810bfa90b50525dd3f72c01235b6df7491cf6af6f284 url: "https://pub.dev" source: hosted version: "3.1.1" local_notifier: dependency: "direct main" description: name: local_notifier sha256: f6cfc933c6fbc961f4e52b5c880f68e41b2d3cd29aad557cc654fd211093a025 url: "https://pub.dev" source: hosted version: "0.1.6" logging: dependency: transitive description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted version: "1.3.0" macros: dependency: transitive description: name: macros sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted version: "0.1.3-main.0" markdown: dependency: "direct main" description: name: markdown sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" url: "https://pub.dev" source: hosted version: "7.3.0" markdown_widget: dependency: "direct main" description: name: markdown_widget sha256: "216dced98962d7699a265344624bc280489d739654585ee881c95563a3252fac" url: "https://pub.dev" source: hosted version: "2.3.2+6" 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" 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" mockito: dependency: transitive description: name: mockito sha256: f99d8d072e249f719a5531735d146d8cf04c580d93920b04de75bef6dfb2daf6 url: "https://pub.dev" source: hosted version: "5.4.5" mocktail: dependency: "direct dev" description: name: mocktail sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" url: "https://pub.dev" source: hosted version: "1.0.4" nanoid: dependency: "direct main" description: name: nanoid sha256: be3f8752d9046c825df2f3914195151eb876f3ad64b9d833dd0b799b77b8759e url: "https://pub.dev" source: hosted version: "1.0.0" nested: dependency: transitive description: name: nested sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" url: "https://pub.dev" source: hosted version: "1.0.0" nm: dependency: transitive description: name: nm sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" url: "https://pub.dev" source: hosted version: "0.5.0" node_preamble: dependency: transitive description: name: node_preamble sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" url: "https://pub.dev" source: hosted version: "2.0.2" numerus: dependency: "direct main" description: name: numerus sha256: a17a3f34527497e89378696a76f382b40dc534c4a57b3778de246ebc1ce2ca99 url: "https://pub.dev" source: hosted version: "2.3.0" octo_image: dependency: transitive description: name: octo_image sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" url: "https://pub.dev" source: hosted version: "2.1.0" open_filex: dependency: "direct main" description: name: open_filex sha256: dcb7bd3d32db8db5260253a62f1564c02c2c8df64bc0187cd213f65f827519bd url: "https://pub.dev" source: hosted version: "4.6.0" package_config: dependency: transitive description: name: package_config sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" url: "https://pub.dev" source: hosted version: "2.1.1" package_info_plus: dependency: "direct main" description: name: package_info_plus sha256: "739e0a5c3c4055152520fa321d0645ee98e932718b4c8efeeb51451968fe0790" url: "https://pub.dev" source: hosted version: "8.1.3" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b url: "https://pub.dev" source: hosted version: "3.0.2" path: dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted version: "1.9.0" path_drawing: dependency: transitive description: name: path_drawing sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 url: "https://pub.dev" source: hosted version: "1.0.1" 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" pausable_timer: dependency: transitive description: name: pausable_timer sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074" url: "https://pub.dev" source: hosted version: "3.1.0+3" pdf: dependency: transitive description: name: pdf sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" url: "https://pub.dev" source: hosted version: "3.11.3" percent_indicator: dependency: "direct main" description: name: percent_indicator sha256: c37099ad833a883c9d71782321cb65c3a848c21b6939b6185f0ff6640d05814c url: "https://pub.dev" source: hosted version: "4.2.3" permission_handler: dependency: "direct main" description: path: permission_handler ref: faef1c9 resolved-ref: faef1c97970de29995642bfae61b884591798684 url: "https://github.com/LucasXu0/flutter-permission-handler.git" source: git version: "12.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" url: "https://pub.dev" source: hosted version: "13.0.1" permission_handler_apple: dependency: transitive description: name: permission_handler_apple sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 url: "https://pub.dev" source: hosted version: "9.4.7" permission_handler_html: dependency: transitive description: name: permission_handler_html sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" url: "https://pub.dev" source: hosted version: "0.1.3+5" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 url: "https://pub.dev" source: hosted version: "4.3.0" permission_handler_windows: dependency: transitive description: name: permission_handler_windows sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" url: "https://pub.dev" source: hosted version: "0.2.1" petitparser: dependency: transitive description: name: petitparser sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted version: "6.0.2" pixel_snap: dependency: transitive description: name: pixel_snap sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" url: "https://pub.dev" source: hosted version: "0.1.5" platform: dependency: transitive description: name: platform sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted version: "3.1.5" plugin_platform_interface: dependency: "direct dev" 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" process: dependency: transitive description: name: process sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" url: "https://pub.dev" source: hosted version: "5.0.2" protobuf: dependency: "direct main" description: name: protobuf sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" url: "https://pub.dev" source: hosted version: "3.1.0" provider: dependency: "direct main" description: name: provider sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c url: "https://pub.dev" source: hosted version: "6.1.2" 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: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted version: "1.5.0" qr: dependency: transitive description: name: qr sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted version: "3.0.2" recase: dependency: transitive description: name: recase sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 url: "https://pub.dev" source: hosted version: "4.1.0" reorderable_tabbar: dependency: "direct main" description: path: "." ref: "93c4977" resolved-ref: "93c4977ffab68906694cdeaea262be6045543c94" url: "https://github.com/LucasXu0/reorderable_tabbar" source: git version: "1.0.6" reorderables: dependency: "direct main" description: name: reorderables sha256: "004a886e4878df1ee27321831c838bc1c976311f4ca6a74ce7d561e506540a77" url: "https://pub.dev" source: hosted version: "0.6.0" run_with_network_images: dependency: "direct dev" description: name: run_with_network_images sha256: "8bf2de4e5120ab24037eda09596408938aa8f5b09f6afabd49683bd01c7baa36" url: "https://pub.dev" source: hosted version: "0.0.1" rxdart: dependency: transitive description: name: rxdart sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" url: "https://pub.dev" source: hosted version: "0.27.7" saver_gallery: dependency: "direct main" description: name: saver_gallery sha256: bf59475e50b73d666630bed7a5fdb621fed92d637f64e3c61ce81653ec6a833c url: "https://pub.dev" source: hosted version: "4.0.1" scaled_app: dependency: "direct main" description: name: scaled_app sha256: a2ad9f22cf2200a5ce455b59c5ea7bfb09a84acfc52452d1db54f4958c99d76a url: "https://pub.dev" source: hosted version: "2.3.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" scrollable_positioned_list: dependency: "direct main" description: name: scrollable_positioned_list sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" url: "https://pub.dev" source: hosted version: "0.3.8" share_plus: dependency: "direct main" description: name: share_plus sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da url: "https://pub.dev" source: hosted version: "10.1.4" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b url: "https://pub.dev" source: hosted version: "5.0.2" shared_preferences: dependency: "direct main" description: name: shared_preferences sha256: a752ce92ea7540fc35a0d19722816e04d0e72828a4200e83a98cf1a1eb524c9a url: "https://pub.dev" source: hosted version: "2.3.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android sha256: bf808be89fe9dc467475e982c1db6c2faf3d2acf54d526cd5ec37d86c99dbd84 url: "https://pub.dev" source: hosted version: "2.4.1" 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" sheet: dependency: "direct main" description: path: sheet ref: e44458d resolved-ref: e44458d2359565324e117bb3d41da04f5e60362e url: "https://github.com/jamesblasco/modal_bottom_sheet" source: git version: "1.0.0" shelf: dependency: transitive description: name: shelf sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted version: "1.4.2" shelf_packages_handler: dependency: transitive description: name: shelf_packages_handler sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" url: "https://pub.dev" source: hosted version: "3.0.2" shelf_static: dependency: transitive description: name: shelf_static sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 url: "https://pub.dev" source: hosted version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted version: "2.0.1" simple_gesture_detector: dependency: transitive description: name: simple_gesture_detector sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 url: "https://pub.dev" source: hosted version: "0.2.1" sized_context: dependency: "direct main" description: name: sized_context sha256: "9921e6c09e018132c3e1c6a18e14febbc1cc5c87a200d64ff7578cb49991f6e7" url: "https://pub.dev" source: hosted version: "1.0.0+4" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" sliver_tools: dependency: transitive description: name: sliver_tools sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 url: "https://pub.dev" source: hosted version: "0.2.12" source_gen: dependency: transitive description: name: source_gen sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted version: "1.5.0" source_helper: dependency: transitive description: name: source_helper sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" url: "https://pub.dev" source: hosted version: "1.3.5" source_map_stack_trace: dependency: transitive description: name: source_map_stack_trace sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b url: "https://pub.dev" source: hosted version: "2.1.2" source_maps: dependency: transitive description: name: source_maps sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" url: "https://pub.dev" source: hosted version: "0.10.13" 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: transitive 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_darwin: dependency: transitive description: name: sqflite_darwin sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" url: "https://pub.dev" source: hosted version: "2.4.1+1" sqflite_platform_interface: dependency: transitive description: name: sqflite_platform_interface sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" url: "https://pub.dev" source: hosted version: "2.4.0" stack_trace: dependency: transitive description: name: stack_trace sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted version: "1.12.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" string_validator: dependency: "direct main" description: name: string_validator sha256: a278d038104aa2df15d0e09c47cb39a49f907260732067d0034dc2f2e4e2ac94 url: "https://pub.dev" source: hosted version: "1.1.0" styled_widget: dependency: "direct main" description: name: styled_widget sha256: "4d439802919b6ccf10d1488798656da8804633b03012682dd1c8ca70a084aa84" url: "https://pub.dev" source: hosted version: "0.4.1" super_clipboard: dependency: "direct main" description: name: super_clipboard sha256: "4a6ae6dfaa282ec1f2bff750976f535517ed8ca842d5deae13985eb11c00ac1f" url: "https://pub.dev" source: hosted version: "0.8.24" super_native_extensions: dependency: transitive description: name: super_native_extensions sha256: a433bba8186cd6b707560c42535bf284804665231c00bca86faf1aa4968b7637 url: "https://pub.dev" source: hosted version: "0.8.24" sync_http: dependency: transitive description: name: sync_http sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" url: "https://pub.dev" source: hosted version: "0.3.1" synchronized: dependency: "direct main" description: name: synchronized sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" url: "https://pub.dev" source: hosted version: "3.3.0+3" tab_indicator_styler: dependency: transitive description: name: tab_indicator_styler sha256: "9e7e90367e20f71f3882fc6578fdcced35ab1c66ab20fcb623cdcc20d2796c76" url: "https://pub.dev" source: hosted version: "2.0.0" table_calendar: dependency: "direct main" description: name: table_calendar sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63 url: "https://pub.dev" source: hosted version: "3.1.3" talker: dependency: "direct main" description: name: talker sha256: f4b3f6110b03f78ef314f897322e47c6be2b0fbe6fafa62e4041ac5321e88620 url: "https://pub.dev" source: hosted version: "4.8.1" talker_bloc_logger: dependency: "direct main" description: name: talker_bloc_logger sha256: "2f3ccf88c473105b7fecc4a81289f4345ee5aa652dd2f43108a60dded08afc4a" url: "https://pub.dev" source: hosted version: "4.8.1" talker_logger: dependency: transitive description: name: talker_logger sha256: "4e526350aa917d8c68eeded19604ce82ffe68ceeb9fd803225d30a12924ca506" url: "https://pub.dev" source: hosted version: "4.8.1" term_glyph: dependency: transitive description: name: term_glyph sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 url: "https://pub.dev" source: hosted version: "1.2.1" test: dependency: transitive description: name: test sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" url: "https://pub.dev" source: hosted version: "1.25.8" test_api: dependency: transitive description: name: test_api sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted version: "0.7.3" test_core: dependency: transitive description: name: test_core sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" url: "https://pub.dev" source: hosted version: "0.6.5" time: dependency: "direct main" description: name: time sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" url: "https://pub.dev" source: hosted version: "2.1.5" timing: dependency: transitive description: name: timing sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" url: "https://pub.dev" source: hosted version: "1.0.2" toastification: dependency: "direct main" description: name: toastification sha256: "4d97fbfa463dfe83691044cba9f37cb185a79bb9205cfecb655fa1f6be126a13" url: "https://pub.dev" source: hosted version: "2.3.0" tuple: dependency: transitive description: name: tuple sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 url: "https://pub.dev" source: hosted version: "2.0.2" typed_data: dependency: transitive description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted version: "1.4.0" universal_html: dependency: transitive description: name: universal_html sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" url: "https://pub.dev" source: hosted version: "2.2.4" universal_io: dependency: transitive description: name: universal_io sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" url: "https://pub.dev" source: hosted version: "2.2.2" universal_platform: dependency: "direct main" description: name: universal_platform sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" url: "https://pub.dev" source: hosted version: "1.1.0" unsplash_client: dependency: "direct main" description: path: "." ref: a8411fc resolved-ref: a8411fcead178834d1f4572f64dc78b059ffa221 url: "https://github.com/LucasXu0/unsplash_client.git" source: git version: "2.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: "direct dev" 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: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" url: "https://pub.dev" source: hosted version: "2.4.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted version: "3.1.4" url_protocol: dependency: "direct main" description: path: "." ref: HEAD resolved-ref: "77a84201ed8ca50082f4248f3a373d053b1c0462" url: "https://github.com/LucasXu0/flutter_url_protocol.git" source: git version: "1.0.0" uuid: dependency: "direct overridden" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted version: "4.5.1" value_layout_builder: dependency: transitive description: name: value_layout_builder sha256: c02511ea91ca5c643b514a33a38fa52536f74aa939ec367d02938b5ede6807fa url: "https://pub.dev" source: hosted version: "0.4.0" 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: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted version: "1.1.13" 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" version: dependency: "direct main" description: name: version sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94" url: "https://pub.dev" source: hosted version: "3.0.2" visibility_detector: dependency: transitive description: name: visibility_detector sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 url: "https://pub.dev" source: hosted version: "0.4.0+2" vm_service: dependency: transitive description: name: vm_service sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted version: "14.3.0" 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" webdriver: dependency: transitive description: name: webdriver sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" url: "https://pub.dev" source: hosted version: "3.0.4" webkit_inspection_protocol: dependency: transitive description: name: webkit_inspection_protocol sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" url: "https://pub.dev" source: hosted version: "1.2.1" webview_flutter: dependency: transitive description: name: webview_flutter sha256: "889a0a678e7c793c308c68739996227c9661590605e70b1f6cf6b9a6634f7aec" url: "https://pub.dev" source: hosted version: "4.10.0" webview_flutter_android: dependency: transitive description: name: webview_flutter_android sha256: d1ee28f44894cbabb1d94cc42f9980297f689ff844d067ec50ff88d86e27d63f url: "https://pub.dev" source: hosted version: "4.3.0" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface sha256: d937581d6e558908d7ae3dc1989c4f87b786891ab47bb9df7de548a151779d8d url: "https://pub.dev" source: hosted version: "2.10.0" webview_flutter_plus: dependency: transitive description: name: webview_flutter_plus sha256: f883dfc94d03b1a2a17441c8e8a8e1941558ed3322f2b586cd06486114e18048 url: "https://pub.dev" source: hosted version: "0.4.10" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview sha256: "4adc14ea9a770cc9e2c8f1ac734536bd40e82615bd0fa6b94be10982de656cc7" url: "https://pub.dev" source: hosted version: "3.17.0" win32: dependency: transitive description: name: win32 sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" url: "https://pub.dev" source: hosted version: "5.10.0" win32_registry: dependency: transitive description: name: win32_registry sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" url: "https://pub.dev" source: hosted version: "1.1.5" window_manager: dependency: "direct main" description: path: "packages/window_manager" ref: "5a43aed" resolved-ref: "5a43aed33ffd9f86e1bac2fc9f4931383cc2ee2b" url: "https://github.com/leanflutter/window_manager.git" source: git version: "0.4.3" xdg_directories: dependency: transitive description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted version: "1.1.0" xml: dependency: "direct main" 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" sdks: dart: ">=3.6.2 <4.0.0" flutter: ">=3.27.4" ================================================ FILE: frontend/appflowy_flutter/pubspec.yaml ================================================ name: appflowy description: Bring projects, wikis, and teams together with AI. AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data. The best open source alternative to Notion. publish_to: "none" version: 0.9.9 environment: flutter: ">=3.27.4" sdk: ">=3.3.0 <4.0.0" dependencies: any_date: ^1.0.4 app_links: ^6.3.3 appflowy_backend: path: packages/appflowy_backend appflowy_board: git: url: https://github.com/AppFlowy-IO/appflowy-board.git ref: e8317c0d1af8d23dc5707b02ea43864536b6de91 appflowy_editor: appflowy_editor_plugins: appflowy_popover: path: packages/appflowy_popover appflowy_result: path: packages/appflowy_result appflowy_ui: path: packages/appflowy_ui archive: ^3.4.10 auto_size_text_field: ^2.2.3 auto_updater: ^1.0.0 avatar_stack: ^3.0.0 # BitsDojo Window for Windows bitsdojo_window: ^0.1.6 bloc: ^9.0.0 cached_network_image: ^3.3.0 calendar_view: git: url: https://github.com/Xazin/flutter_calendar_view ref: "6fe0c98" collection: ^1.17.1 connectivity_plus: ^5.0.2 cross_file: ^0.3.4+1 # Desktop Drop uses Cross File (XFile) data type defer_pointer: ^0.0.2 desktop_drop: ^0.5.0 device_info_plus: diffutil_dart: ^4.0.1 dotted_border: ^2.0.0+3 easy_localization: ^3.0.2 envied: ^1.0.1 equatable: ^2.0.5 expandable: ^5.0.1 extended_text_field: ^16.0.2 extended_text_library: ^12.0.0 file: ^7.0.0 fixnum: ^1.1.0 flex_color_picker: ^3.5.1 flowy_infra: path: packages/flowy_infra flowy_infra_ui: path: packages/flowy_infra_ui flowy_svg: path: packages/flowy_svg flutter: sdk: flutter flutter_animate: ^4.5.0 flutter_bloc: ^9.1.0 flutter_cache_manager: ^3.3.1 flutter_chat_core: 0.0.2 flutter_chat_ui: ^2.0.0-dev.1 flutter_emoji_mart: git: url: https://github.com/LucasXu0/emoji_mart.git ref: "355aa56" flutter_math_fork: ^0.7.3 flutter_slidable: ^3.0.0 flutter_staggered_grid_view: ^0.7.0 flutter_tex: ^4.0.9 fluttertoast: ^8.2.6 freezed_annotation: ^2.2.0 get_it: ^8.0.3 go_router: ^14.2.0 google_fonts: ^6.1.0 highlight: ^0.7.0 hive_flutter: ^1.1.0 hotkey_manager: ^0.1.7 html2md: ^1.3.2 http: ^1.0.0 image_picker: ^1.0.4 # third party packages intl: ^0.19.0 json_annotation: ^4.8.1 keyboard_height_plugin: ^0.1.5 leak_tracker: ^10.0.0 linked_scroll_controller: ^0.2.0 # Notifications # TODO: Consider implementing custom package # to gather notification handling for all platforms local_notifier: ^0.1.5 markdown: markdown_widget: ^2.3.2+6 mime: ^2.0.0 nanoid: ^1.0.0 numerus: ^2.1.2 # Used to open local files on Mobile open_filex: ^4.5.0 package_info_plus: ^8.0.2 path: ^1.8.3 path_provider: ^2.0.15 percent_indicator: 4.2.3 permission_handler: ^11.3.1 protobuf: ^3.1.0 provider: ^6.0.5 reorderable_tabbar: ^1.0.6 reorderables: ^0.6.0 scaled_app: ^2.3.0 scroll_to_index: ^3.0.1 scrollable_positioned_list: ^0.3.8 share_plus: ^10.0.2 shared_preferences: ^2.2.2 sheet: sized_context: ^1.0.0+4 string_validator: ^1.0.0 styled_widget: ^0.4.1 super_clipboard: ^0.8.24 synchronized: ^3.1.0+1 table_calendar: ^3.0.9 time: ^2.1.3 event_bus: ^2.0.1 toastification: ^2.0.0 universal_platform: ^1.1.0 unsplash_client: ^2.1.1 url_launcher: ^6.1.11 url_protocol: # Window Manager for MacOS and Linux version: ^3.0.2 xml: ^6.5.0 window_manager: ^0.4.3 saver_gallery: ^4.0.1 talker_bloc_logger: ^4.8.1 talker: ^4.7.1 analyzer: 6.11.0 dev_dependencies: # Introduce talker to log the bloc events, and only log the events in the development mode bloc_test: ^10.0.0 build_runner: ^2.4.9 envied_generator: ^1.0.1 flutter_lints: ^5.0.0 flutter_test: sdk: flutter freezed: ^2.4.7 integration_test: sdk: flutter json_serializable: ^6.7.1 mocktail: ^1.0.1 plugin_platform_interface: any run_with_network_images: ^0.0.1 url_launcher_platform_interface: any dependency_overrides: http: ^1.0.0 device_info_plus: ^11.2.2 url_protocol: git: url: https://github.com/LucasXu0/flutter_url_protocol.git commit: 737681d appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git ref: "470c4e7" appflowy_editor_plugins: git: url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git path: "packages/appflowy_editor_plugins" ref: "4efcff7" sheet: git: url: https://github.com/jamesblasco/modal_bottom_sheet ref: e44458d path: sheet window_manager: git: url: https://github.com/leanflutter/window_manager.git ref: "5a43aed" path: packages/window_manager uuid: ^4.4.0 flutter_cache_manager: git: url: https://github.com/LucasXu0/flutter_cache_manager.git commit: fbab857b1b1d209240a146d32f496379b9f62276 path: flutter_cache_manager flutter_sticky_header: ^0.7.0 reorderable_tabbar: git: url: https://github.com/LucasXu0/reorderable_tabbar ref: 93c4977 # Don't upgrade file_picker until the issue is fixed # https://github.com/miguelpruivo/flutter_file_picker/issues/1652 file_picker: 8.1.4 auto_updater: git: url: https://github.com/LucasXu0/auto_updater.git path: packages/auto_updater ref: 1d81a824f3633f1d0200ba51b78fe0f9ce429458 auto_updater_macos: git: url: https://github.com/LucasXu0/auto_updater.git path: packages/auto_updater_macos ref: 1d81a824f3633f1d0200ba51b78fe0f9ce429458 auto_updater_platform_interface: git: url: https://github.com/LucasXu0/auto_updater.git path: packages/auto_updater_platform_interface ref: 1d81a824f3633f1d0200ba51b78fe0f9ce429458 unsplash_client: git: url: https://github.com/LucasXu0/unsplash_client.git ref: a8411fc # https://github.com/LucasXu0/flutter-permission-handler/commit/faef1c97970de29995642bfae61b884591798684 # Prevent the location from being accessed continuously on Windows permission_handler: git: url: https://github.com/LucasXu0/flutter-permission-handler.git path: permission_handler ref: faef1c9 flutter: generate: true uses-material-design: true fonts: - family: Poppins fonts: - asset: assets/google_fonts/Poppins/Poppins-ExtraLight.ttf weight: 100 - asset: assets/google_fonts/Poppins/Poppins-Thin.ttf weight: 200 - asset: assets/google_fonts/Poppins/Poppins-Light.ttf weight: 300 - asset: assets/google_fonts/Poppins/Poppins-Regular.ttf weight: 400 - asset: assets/google_fonts/Poppins/Poppins-Medium.ttf weight: 500 - asset: assets/google_fonts/Poppins/Poppins-SemiBold.ttf weight: 600 - asset: assets/google_fonts/Poppins/Poppins-Bold.ttf weight: 700 - asset: assets/google_fonts/Poppins/Poppins-Black.ttf weight: 800 - asset: assets/google_fonts/Poppins/Poppins-ExtraBold.ttf weight: 900 - family: RobotoMono fonts: - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf style: italic # White-label font configuration will be added here # BEGIN: WHITE_LABEL_FONT # END: WHITE_LABEL_FONT # To add assets to your application, add an assets section, like this: assets: - assets/images/ - assets/images/appearance/ - assets/images/built_in_cover_images/ - assets/flowy_icons/ - assets/flowy_icons/16x/ - assets/flowy_icons/20x/ - assets/flowy_icons/24x/ - assets/flowy_icons/32x/ - assets/flowy_icons/40x/ - assets/images/emoji/ - assets/images/login/ - assets/translations/ - assets/icons/icons.json - assets/fonts/ - assets/built_in_prompts.json # The following assets will be excluded in release. # BEGIN: EXCLUDE_IN_RELEASE - assets/test/workspaces/ - assets/test/images/ - assets/template/ - assets/test/workspaces/markdowns/ - assets/test/workspaces/database/ # END: EXCLUDE_IN_RELEASE ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart ================================================ import 'dart:async'; import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import '../../util.dart'; const _aiResponse = 'UPDATED:'; class _MockCompletionStream extends Mock implements CompletionStream {} class _MockAIRepository extends Mock implements AppFlowyAIService { @override Future<(String, CompletionStream)?> streamCompletion({ String? objectId, required String text, PredefinedFormat? format, String? promptId, List sourceIds = const [], List history = const [], required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, required void Function(LocalAIStreamingState state) onLocalAIStreamingStateChange, }) async { final stream = _MockCompletionStream(); unawaited( Future(() async { await onStart(); final lines = text.split('\n'); for (final line in lines) { if (line.isNotEmpty) { await processMessage('$_aiResponse $line\n\n'); } } await onEnd(); }), ); return ('mock_id', stream); } } class _MockAIRepositoryLess extends Mock implements AppFlowyAIService { @override Future<(String, CompletionStream)?> streamCompletion({ String? objectId, required String text, PredefinedFormat? format, String? promptId, List sourceIds = const [], List history = const [], required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, required void Function(LocalAIStreamingState state) onLocalAIStreamingStateChange, }) async { final stream = _MockCompletionStream(); unawaited( Future(() async { await onStart(); // only return 1 line. await processMessage('Hello World'); await onEnd(); }), ); return ('mock_id', stream); } } class _MockAIRepositoryMore extends Mock implements AppFlowyAIService { @override Future<(String, CompletionStream)?> streamCompletion({ String? objectId, required String text, PredefinedFormat? format, String? promptId, List sourceIds = const [], List history = const [], required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, required void Function(LocalAIStreamingState state) onLocalAIStreamingStateChange, }) async { final stream = _MockCompletionStream(); unawaited( Future(() async { await onStart(); // return 10 lines for (var i = 0; i < 10; i++) { await processMessage('Hello World\n\n'); } await onEnd(); }), ); return ('mock_id', stream); } } class _MockErrorRepository extends Mock implements AppFlowyAIService { @override Future<(String, CompletionStream)?> streamCompletion({ String? objectId, required String text, PredefinedFormat? format, String? promptId, List sourceIds = const [], List history = const [], required CompletionTypePB completionType, required Future Function() onStart, required Future Function(String text) processMessage, required Future Function(String text) processAssistMessage, required Future Function() onEnd, required void Function(AIError error) onError, required void Function(LocalAIStreamingState state) onLocalAIStreamingStateChange, }) async { final stream = _MockCompletionStream(); unawaited( Future(() async { await onStart(); onError( const AIError( message: 'Error', code: AIErrorCode.aiResponseLimitExceeded, ), ); }), ); return ('mock_id', stream); } } void registerMockRepository(AppFlowyAIService mock) { if (getIt.isRegistered()) { getIt.unregister(); } getIt.registerFactory(() => mock); } void main() { group('AIWriterCubit:', () { const text1 = '1. Select text to style using the toolbar menu.'; const text2 = '2. Discover more styling options in Aa.'; const text3 = '3. AppFlowy empowers you to beautifully and effortlessly style your content.'; setUp(() { TestWidgetsFlutterBinding.ensureInitialized(); }); blocTest( 'send request before the bloc is initialized', build: () { final document = Document( root: pageNode( children: [ paragraphNode(text: text1), paragraphNode(text: text2), paragraphNode(text: text3), ], ), ); final selection = Selection( start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ); final editorState = EditorState(document: document) ..selection = selection; registerMockRepository(_MockAIRepository()); return AiWriterCubit( documentId: '', editorState: editorState, ); }, act: (bloc) => bloc.register( aiWriterNode( command: AiWriterCommand.explain, selection: Selection( start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ), ), ), wait: Duration(seconds: 1), expect: () => [ isA() .having((s) => s.markdownText, 'result', isEmpty), isA() .having((s) => s.markdownText, 'result', isNotEmpty) .having((s) => s.markdownText, 'result', contains('UPDATED:')), isA() .having((s) => s.markdownText, 'result', isNotEmpty) .having((s) => s.markdownText, 'result', contains('UPDATED:')), isA() .having((s) => s.markdownText, 'result', isNotEmpty) .having((s) => s.markdownText, 'result', contains('UPDATED:')), isA() .having((s) => s.markdownText, 'result', isNotEmpty) .having((s) => s.markdownText, 'result', contains('UPDATED:')), ], ); blocTest( 'exceed the ai response limit', build: () { const text1 = '1. Select text to style using the toolbar menu.'; const text2 = '2. Discover more styling options in Aa.'; const text3 = '3. AppFlowy empowers you to beautifully and effortlessly style your content.'; final document = Document( root: pageNode( children: [ paragraphNode(text: text1), paragraphNode(text: text2), paragraphNode(text: text3), ], ), ); final selection = Selection( start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ); final editorState = EditorState(document: document) ..selection = selection; registerMockRepository(_MockErrorRepository()); return AiWriterCubit( documentId: '', editorState: editorState, ); }, act: (bloc) => bloc.register( aiWriterNode( command: AiWriterCommand.explain, selection: Selection( start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ), ), ), wait: Duration(seconds: 1), expect: () => [ isA() .having((s) => s.markdownText, 'result', isEmpty), isA().having( (s) => s.error.code, 'error code', AIErrorCode.aiResponseLimitExceeded, ), ], ); test('improve writing - the result contains the same number of paragraphs', () async { final selection = Selection( start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ); final document = Document( root: pageNode( children: [ paragraphNode(text: text1), paragraphNode(text: text2), paragraphNode(text: text3), aiWriterNode( command: AiWriterCommand.improveWriting, selection: selection, ), ], ), ); final editorState = EditorState(document: document) ..selection = selection; final aiNode = editorState.getNodeAtPath([3])!; registerMockRepository(_MockAIRepository()); final bloc = AiWriterCubit( documentId: '', editorState: editorState, ); bloc.register(aiNode); await blocResponseFuture(); bloc.runResponseAction(SuggestionAction.accept); await blocResponseFuture(); expect( editorState.document.root.children.length, 3, ); expect( editorState.getNodeAtPath([0])!.delta!.toPlainText(), '$_aiResponse $text1', ); expect( editorState.getNodeAtPath([1])!.delta!.toPlainText(), '$_aiResponse $text2', ); expect( editorState.getNodeAtPath([2])!.delta!.toPlainText(), '$_aiResponse $text3', ); }); test('improve writing - discard', () async { final selection = Selection( start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ); final document = Document( root: pageNode( children: [ paragraphNode(text: text1), paragraphNode(text: text2), paragraphNode(text: text3), aiWriterNode( command: AiWriterCommand.improveWriting, selection: selection, ), ], ), ); final editorState = EditorState(document: document) ..selection = selection; final aiNode = editorState.getNodeAtPath([3])!; registerMockRepository(_MockAIRepository()); final bloc = AiWriterCubit( documentId: '', editorState: editorState, ); bloc.register(aiNode); await blocResponseFuture(); bloc.runResponseAction(SuggestionAction.discard); await blocResponseFuture(); expect( editorState.document.root.children.length, 3, ); expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), text1); expect(editorState.getNodeAtPath([1])!.delta!.toPlainText(), text2); expect(editorState.getNodeAtPath([2])!.delta!.toPlainText(), text3); }); test('improve writing - the result less than the original text', () async { final selection = Selection( start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ); final document = Document( root: pageNode( children: [ paragraphNode(text: text1), paragraphNode(text: text2), paragraphNode(text: text3), aiWriterNode( command: AiWriterCommand.improveWriting, selection: selection, ), ], ), ); final editorState = EditorState(document: document) ..selection = selection; final aiNode = editorState.getNodeAtPath([3])!; registerMockRepository(_MockAIRepositoryLess()); final bloc = AiWriterCubit( documentId: '', editorState: editorState, ); bloc.register(aiNode); await blocResponseFuture(); bloc.runResponseAction(SuggestionAction.accept); await blocResponseFuture(); expect(editorState.document.root.children.length, 2); expect( editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'Hello World', ); }); test('improve writing - the result more than the original text', () async { final selection = Selection( start: Position(path: [0]), end: Position(path: [2], offset: text3.length), ); final document = Document( root: pageNode( children: [ paragraphNode(text: text1), paragraphNode(text: text2), paragraphNode(text: text3), aiWriterNode( command: AiWriterCommand.improveWriting, selection: selection, ), ], ), ); final editorState = EditorState(document: document) ..selection = selection; final aiNode = editorState.getNodeAtPath([3])!; registerMockRepository(_MockAIRepositoryMore()); final bloc = AiWriterCubit( documentId: '', editorState: editorState, ); bloc.register(aiNode); await blocResponseFuture(); bloc.runResponseAction(SuggestionAction.accept); await blocResponseFuture(); expect(editorState.document.root.children.length, 10); for (var i = 0; i < 10; i++) { expect( editorState.getNodeAtPath([i])!.delta!.toPlainText(), 'Hello World', ); } }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart ================================================ import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../util.dart'; void main() { // ignore: unused_local_variable late AppFlowyUnitTest context; setUpAll(() async { context = await AppFlowyUnitTest.ensureInitialized(); }); group('$AppearanceSettingsCubit', () { late AppearanceSettingsPB appearanceSetting; late DateTimeSettingsPB dateTimeSettings; setUp(() async { appearanceSetting = await UserSettingsBackendService().getAppearanceSetting(); dateTimeSettings = await UserSettingsBackendService().getDateTimeSettings(); await blocResponseFuture(); }); blocTest( 'default theme', build: () => AppearanceSettingsCubit( appearanceSetting, dateTimeSettings, AppTheme.fallback, ), verify: (bloc) { expect(bloc.state.font, defaultFontFamily); expect(bloc.state.themeMode, ThemeMode.system); }, ); blocTest( 'save key/value', build: () => AppearanceSettingsCubit( appearanceSetting, dateTimeSettings, AppTheme.fallback, ), act: (bloc) { bloc.setKeyValue("123", "456"); }, verify: (bloc) { expect(bloc.getValue("123"), "456"); }, ); blocTest( 'remove key/value', build: () => AppearanceSettingsCubit( appearanceSetting, dateTimeSettings, AppTheme.fallback, ), act: (bloc) { bloc.setKeyValue("123", null); }, verify: (bloc) { expect(bloc.getValue("123"), null); }, ); blocTest( 'initial state uses fallback theme', build: () => AppearanceSettingsCubit( appearanceSetting, dateTimeSettings, AppTheme.fallback, ), verify: (bloc) { expect(bloc.state.appTheme.themeName, AppTheme.fallback.themeName); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart ================================================ import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); group('DocumentAppearanceCubit', () { late SharedPreferences preferences; late DocumentAppearanceCubit cubit; setUpAll(() async { SharedPreferences.setMockInitialValues({}); }); setUp(() async { preferences = await SharedPreferences.getInstance(); cubit = DocumentAppearanceCubit(); }); tearDown(() async { await preferences.clear(); await cubit.close(); }); test('Initial state', () { expect(cubit.state.fontSize, 16.0); expect(cubit.state.fontFamily, defaultFontFamily); }); test('Fetch document appearance from SharedPreferences', () async { await preferences.setDouble(KVKeys.kDocumentAppearanceFontSize, 18.0); await preferences.setString( KVKeys.kDocumentAppearanceFontFamily, 'Arial', ); await cubit.fetch(); expect(cubit.state.fontSize, 18.0); expect(cubit.state.fontFamily, 'Arial'); }); test('Sync font size to SharedPreferences', () async { await cubit.syncFontSize(20.0); final fontSize = preferences.getDouble(KVKeys.kDocumentAppearanceFontSize); expect(fontSize, 20.0); expect(cubit.state.fontSize, 20.0); }); test('Sync font family to SharedPreferences', () async { await cubit.syncFontFamily('Helvetica'); final fontFamily = preferences.getString(KVKeys.kDocumentAppearanceFontFamily); expect(fontFamily, 'Helvetica'); expect(cubit.state.fontFamily, 'Helvetica'); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/board_test/create_card_test.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; void main() { late AppFlowyBoardTest boardTest; setUpAll(() async { boardTest = await AppFlowyBoardTest.ensureInitialized(); }); test('create kanban baord card', () async { final context = await boardTest.createTestBoard(); final databaseController = DatabaseController(view: context.gridView); final boardBloc = BoardBloc( databaseController: databaseController, )..add(const BoardEvent.initial()); await boardResponseFuture(); List groupIds = boardBloc.state.maybeMap( orElse: () => const [], ready: (value) => value.groupIds, ); String lastGroupId = groupIds.last; // the group at index 3 is the 'No status' group; assert(boardBloc.groupControllers[lastGroupId]!.group.rows.isEmpty); assert( groupIds.length == 4, 'but receive ${groupIds.length}', ); boardBloc.add( BoardEvent.createRow( groupIds[3], OrderObjectPositionTypePB.End, null, null, ), ); await boardResponseFuture(); groupIds = boardBloc.state.maybeMap( orElse: () => [], ready: (value) => value.groupIds, ); lastGroupId = groupIds.last; assert( boardBloc.groupControllers[lastGroupId]!.group.rows.length == 1, 'but receive ${boardBloc.groupControllers[lastGroupId]!.group.rows.length}', ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; void main() { late AppFlowyBoardTest boardTest; setUpAll(() async { boardTest = await AppFlowyBoardTest.ensureInitialized(); }); test('create build-in kanban board test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); assert(boardBloc.groupControllers.values.length == 4); assert(context.fieldContexts.length == 2); }); test('edit kanban board field name test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); final fieldInfo = context.singleSelectFieldContext(); final editorBloc = FieldEditorBloc( viewId: context.gridView.id, fieldInfo: fieldInfo, fieldController: context.fieldController, isNew: false, ); await boardResponseFuture(); editorBloc.add(const FieldEditorEvent.renameField('Hello world')); await boardResponseFuture(); // assert the groups were not changed assert( boardBloc.groupControllers.values.length == 4, "Expected 4, but receive ${boardBloc.groupControllers.values.length}", ); assert( context.fieldContexts.length == 2, "Expected 2, but receive ${context.fieldContexts.length}", ); }); test('create a new field in kanban board test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); await context.createField(FieldType.Checkbox); await boardResponseFuture(); final checkboxField = context.fieldContexts.last.field; assert(checkboxField.fieldType == FieldType.Checkbox); assert( boardBloc.groupControllers.values.length == 4, "Expected 4, but receive ${boardBloc.groupControllers.values.length}", ); assert( context.fieldContexts.length == 3, "Expected 3, but receive ${context.fieldContexts.length}", ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/board_test/group_by_checkbox_field_test.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/setting/group_bloc.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; void main() { late AppFlowyBoardTest boardTest; setUpAll(() async { boardTest = await AppFlowyBoardTest.ensureInitialized(); }); // Group by checkbox field test('group by checkbox field test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); // assert the initial values assert(boardBloc.groupControllers.values.length == 4); assert(context.fieldContexts.length == 2); // create checkbox field await context.createField(FieldType.Checkbox); await boardResponseFuture(); assert(context.fieldContexts.length == 3); // set group by checkbox final checkboxField = context.fieldContexts.last.field; final gridGroupBloc = DatabaseGroupBloc( viewId: context.gridView.id, databaseController: context.databaseController, )..add(const DatabaseGroupEvent.initial()); gridGroupBloc.add( DatabaseGroupEvent.setGroupByField( checkboxField.id, checkboxField.fieldType, ), ); await boardResponseFuture(); assert(boardBloc.groupControllers.values.length == 2); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/board_test/group_by_date_test.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/setting/group_bloc.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'util.dart'; void main() { late AppFlowyBoardTest boardTest; setUpAll(() async { boardTest = await AppFlowyBoardTest.ensureInitialized(); }); test('group by date field test', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); // assert the initial values assert(boardBloc.groupControllers.values.length == 4); assert(context.fieldContexts.length == 2); await context.createField(FieldType.DateTime); await boardResponseFuture(); assert(context.fieldContexts.length == 3); final dateField = context.fieldContexts.last.field; final cellController = context.makeCellControllerFromFieldId(dateField.id) as DateCellController; final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: getIt(), ); await boardResponseFuture(); bloc.add(DateCellEditorEvent.updateDateTime(DateTime.now())); await boardResponseFuture(); final gridGroupBloc = DatabaseGroupBloc( viewId: context.gridView.id, databaseController: context.databaseController, )..add(const DatabaseGroupEvent.initial()); gridGroupBloc.add( DatabaseGroupEvent.setGroupByField( dateField.id, dateField.fieldType, ), ); await boardResponseFuture(); assert(boardBloc.groupControllers.values.length == 2); assert( boardBloc.boardController.groupDatas.last.headerData.groupName == LocaleKeys.board_dateCondition_today.tr(), ); }); test('group by date field with condition', () async { final context = await boardTest.createTestBoard(); final boardBloc = BoardBloc( databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); // assert the initial values assert(boardBloc.groupControllers.values.length == 4); assert(context.fieldContexts.length == 2); await context.createField(FieldType.DateTime); await boardResponseFuture(); assert(context.fieldContexts.length == 3); final dateField = context.fieldContexts.last.field; final cellController = context.makeCellControllerFromFieldId(dateField.id) as DateCellController; final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: getIt(), ); await boardResponseFuture(); bloc.add(DateCellEditorEvent.updateDateTime(DateTime.now())); await boardResponseFuture(); final gridGroupBloc = DatabaseGroupBloc( viewId: context.gridView.id, databaseController: context.databaseController, )..add(const DatabaseGroupEvent.initial()); final settingContent = DateGroupConfigurationPB() ..condition = DateConditionPB.Year; gridGroupBloc.add( DatabaseGroupEvent.setGroupByField( dateField.id, dateField.fieldType, settingContent.writeToBuffer(), ), ); await boardResponseFuture(); assert(boardBloc.groupControllers.values.length == 2); assert( boardBloc.boardController.groupDatas.last.headerData.groupName == DateTime.now().year.toString(), ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/setting/group_bloc.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; void main() { late AppFlowyBoardTest boardTest; setUpAll(() async { boardTest = await AppFlowyBoardTest.ensureInitialized(); }); test('no status group name test', () async { final context = await boardTest.createTestBoard(); // create multi-select field await context.createField(FieldType.MultiSelect); await boardResponseFuture(); assert(context.fieldContexts.length == 3); final multiSelectField = context.fieldContexts.last.field; // set grouped by the new multi-select field" final gridGroupBloc = DatabaseGroupBloc( viewId: context.gridView.id, databaseController: context.databaseController, )..add(const DatabaseGroupEvent.initial()); await boardResponseFuture(); gridGroupBloc.add( DatabaseGroupEvent.setGroupByField( multiSelectField.id, multiSelectField.fieldType, ), ); await boardResponseFuture(); // assert only have the 'No status' group final boardBloc = BoardBloc( databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); assert( boardBloc.groupControllers.values.length == 1, "Expected 1, but receive ${boardBloc.groupControllers.values.length}", ); }); test('group by multi select with no options test', () async { final context = await boardTest.createTestBoard(); // create multi-select field await context.createField(FieldType.MultiSelect); await boardResponseFuture(); assert(context.fieldContexts.length == 3); final multiSelectField = context.fieldContexts.last.field; // Create options final cellController = context.makeCellControllerFromFieldId(multiSelectField.id) as SelectOptionCellController; final bloc = SelectOptionCellEditorBloc(cellController: cellController); await boardResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await boardResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("B")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await boardResponseFuture(); // set grouped by the new multi-select field" final gridGroupBloc = DatabaseGroupBloc( viewId: context.gridView.id, databaseController: context.databaseController, )..add(const DatabaseGroupEvent.initial()); await boardResponseFuture(); gridGroupBloc.add( DatabaseGroupEvent.setGroupByField( multiSelectField.id, multiSelectField.fieldType, ), ); await boardResponseFuture(); // assert there are only three group final boardBloc = BoardBloc( databaseController: DatabaseController(view: context.gridView), )..add(const BoardEvent.initial()); await boardResponseFuture(); assert( boardBloc.groupControllers.values.length == 3, "Expected 3, but receive ${boardBloc.groupControllers.values.length}", ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/board_test/group_by_unsupport_field_test.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; void main() { late AppFlowyBoardTest boardTest; late FieldEditorBloc editorBloc; late BoardTestContext context; setUpAll(() async { boardTest = await AppFlowyBoardTest.ensureInitialized(); context = await boardTest.createTestBoard(); final fieldInfo = context.singleSelectFieldContext(); editorBloc = context.makeFieldEditor( fieldInfo: fieldInfo, ); await boardResponseFuture(); }); group('Group with not support grouping field', () { blocTest( "switch to text field", build: () => editorBloc, wait: boardResponseDuration(), act: (bloc) async { bloc.add(const FieldEditorEvent.switchFieldType(FieldType.RichText)); }, verify: (bloc) { assert(bloc.state.field.fieldType == FieldType.RichText); }, ); blocTest( 'assert the number of groups is 1', build: () => BoardBloc( databaseController: DatabaseController(view: context.gridView), )..add( const BoardEvent.initial(), ), wait: boardResponseDuration(), verify: (bloc) { assert( bloc.groupControllers.values.length == 1, "Expected 1, but receive ${bloc.groupControllers.values.length}", ); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/board_test/util.dart ================================================ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/board/board.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import '../../util.dart'; import '../grid_test/util.dart'; class AppFlowyBoardTest { AppFlowyBoardTest({required this.unitTest}); final AppFlowyUnitTest unitTest; static Future ensureInitialized() async { final inner = await AppFlowyUnitTest.ensureInitialized(); return AppFlowyBoardTest(unitTest: inner); } Future createTestBoard() async { final app = await unitTest.createWorkspace(); final builder = BoardPluginBuilder(); return ViewBackendService.createView( parentViewId: app.id, name: "Test Board", layoutType: builder.layoutType, openAfterCreate: true, ).then((result) { return result.fold( (view) async { final context = BoardTestContext( view, DatabaseController(view: view), ); final result = await context._boardDataController.open(); result.fold((l) => null, (r) => throw Exception(r)); return context; }, (error) { throw Exception(); }, ); }); } } Future boardResponseFuture() { return Future.delayed(boardResponseDuration()); } Duration boardResponseDuration({int milliseconds = 2000}) { return Duration(milliseconds: milliseconds); } class BoardTestContext { BoardTestContext(this.gridView, this._boardDataController); final ViewPB gridView; final DatabaseController _boardDataController; List get rowInfos { return _boardDataController.rowCache.rowInfos; } List get fieldContexts => fieldController.fieldInfos; FieldController get fieldController { return _boardDataController.fieldController; } DatabaseController get databaseController => _boardDataController; FieldEditorBloc makeFieldEditor({ required FieldInfo fieldInfo, }) => FieldEditorBloc( viewId: databaseController.viewId, fieldController: fieldController, fieldInfo: fieldInfo, isNew: false, ); CellController makeCellControllerFromFieldId(String fieldId) { return makeCellController( _boardDataController, CellContext(fieldId: fieldId, rowId: rowInfos.last.rowId), ); } Future createField(FieldType fieldType) async { final editorBloc = await createFieldEditor(databaseController: _boardDataController); await gridResponseFuture(); editorBloc.add(FieldEditorEvent.switchFieldType(fieldType)); await gridResponseFuture(); return Future(() => editorBloc); } FieldInfo singleSelectFieldContext() { final fieldInfo = fieldContexts .firstWhere((element) => element.fieldType == FieldType.SingleSelect); return fieldInfo; } FieldInfo textFieldContext() { final fieldInfo = fieldContexts .firstWhere((element) => element.fieldType == FieldType.RichText); return fieldInfo; } FieldInfo checkboxFieldContext() { final fieldInfo = fieldContexts .firstWhere((element) => element.fieldType == FieldType.Checkbox); return fieldInfo; } } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/chat_test/chat_load_message_test.dart ================================================ import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; void main() { // ignore: unused_local_variable late AppFlowyChatTest chatTest; setUpAll(() async { chatTest = await AppFlowyChatTest.ensureInitialized(); }); test('send message', () async { // final context = await chatTest.createChat(); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart ================================================ import 'package:appflowy/plugins/ai_chat/chat.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import '../../util.dart'; class AppFlowyChatTest { AppFlowyChatTest({required this.unitTest}); final AppFlowyUnitTest unitTest; static Future ensureInitialized() async { final inner = await AppFlowyUnitTest.ensureInitialized(); return AppFlowyChatTest(unitTest: inner); } Future createChat() async { final app = await unitTest.createWorkspace(); final builder = AIChatPluginBuilder(); return ViewBackendService.createView( parentViewId: app.id, name: "Test Chat", layoutType: builder.layoutType, openAfterCreate: true, ).then((result) { return result.fold( (view) async { return view; }, (error) { throw Exception(); }, ); }); } } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/cell/checklist_cell_bloc_test.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:flutter_test/flutter_test.dart'; import '../util.dart'; void main() { late AppFlowyGridTest cellTest; setUpAll(() async { cellTest = await AppFlowyGridTest.ensureInitialized(); }); group('checklist cell bloc:', () { late GridTestContext context; late ChecklistCellController cellController; setUp(() async { context = await cellTest.makeDefaultTestGrid(); await FieldBackendService.createField( viewId: context.viewId, fieldType: FieldType.Checklist, ); await gridResponseFuture(); final fieldIndex = context.fieldController.fieldInfos .indexWhere((field) => field.fieldType == FieldType.Checklist); cellController = context.makeGridCellController(fieldIndex, 0).as(); }); test('create tasks', () async { final bloc = ChecklistCellBloc(cellController: cellController); await gridResponseFuture(); expect(bloc.state.tasks.length, 0); bloc.add(const ChecklistCellEvent.createNewTask("B")); await gridResponseFuture(); expect(bloc.state.tasks.length, 1); bloc.add(const ChecklistCellEvent.createNewTask("A", index: 0)); await gridResponseFuture(); expect(bloc.state.tasks.length, 2); expect(bloc.state.tasks.first.data.name, "A"); expect(bloc.state.tasks.last.data.name, "B"); }); test('rename task', () async { final bloc = ChecklistCellBloc(cellController: cellController); await gridResponseFuture(); expect(bloc.state.tasks.length, 0); bloc.add(const ChecklistCellEvent.createNewTask("B")); await gridResponseFuture(); expect(bloc.state.tasks.length, 1); expect(bloc.state.tasks.first.data.name, "B"); bloc.add( ChecklistCellEvent.updateTaskName(bloc.state.tasks.first.data, "A"), ); await gridResponseFuture(); expect(bloc.state.tasks.length, 1); expect(bloc.state.tasks.first.data.name, "A"); }); test('select task', () async { final bloc = ChecklistCellBloc(cellController: cellController); await gridResponseFuture(); bloc.add(const ChecklistCellEvent.createNewTask("A")); await gridResponseFuture(); expect(bloc.state.tasks.length, 1); expect(bloc.state.tasks.first.isSelected, false); bloc.add(const ChecklistCellEvent.selectTask('A')); await gridResponseFuture(); expect(bloc.state.tasks.first.isSelected, false); bloc.add( ChecklistCellEvent.selectTask(bloc.state.tasks.first.data.id), ); await gridResponseFuture(); expect(bloc.state.tasks.first.isSelected, true); }); test('delete task', () async { final bloc = ChecklistCellBloc(cellController: cellController); await gridResponseFuture(); expect(bloc.state.tasks.length, 0); bloc.add(const ChecklistCellEvent.createNewTask("A")); await gridResponseFuture(); expect(bloc.state.tasks.length, 1); expect(bloc.state.tasks.first.isSelected, false); bloc.add(const ChecklistCellEvent.deleteTask('A')); await gridResponseFuture(); expect(bloc.state.tasks.length, 1); bloc.add( ChecklistCellEvent.deleteTask(bloc.state.tasks.first.data.id), ); await gridResponseFuture(); expect(bloc.state.tasks.length, 0); }); test('reorder task', () async { final bloc = ChecklistCellBloc(cellController: cellController); await gridResponseFuture(); bloc.add(const ChecklistCellEvent.createNewTask("A")); await gridResponseFuture(); bloc.add(const ChecklistCellEvent.createNewTask("B")); await gridResponseFuture(); bloc.add(const ChecklistCellEvent.createNewTask("C")); await gridResponseFuture(); bloc.add(const ChecklistCellEvent.createNewTask("D")); await gridResponseFuture(); expect(bloc.state.tasks.length, 4); bloc.add(const ChecklistCellEvent.reorderTask(0, 2)); await gridResponseFuture(); expect(bloc.state.tasks.length, 4); expect(bloc.state.tasks[0].data.name, "B"); expect(bloc.state.tasks[1].data.name, "A"); expect(bloc.state.tasks[2].data.name, "C"); expect(bloc.state.tasks[3].data.name, "D"); bloc.add(const ChecklistCellEvent.reorderTask(3, 1)); await gridResponseFuture(); expect(bloc.state.tasks.length, 4); expect(bloc.state.tasks[0].data.name, "B"); expect(bloc.state.tasks[1].data.name, "D"); expect(bloc.state.tasks[2].data.name, "A"); expect(bloc.state.tasks[3].data.name, "C"); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/cell/date_cell_bloc_test.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:time/time.dart'; import '../util.dart'; void main() { late AppFlowyGridTest cellTest; setUpAll(() async { cellTest = await AppFlowyGridTest.ensureInitialized(); }); group('date time cell bloc:', () { late GridTestContext context; late DateCellController cellController; setUp(() async { context = await cellTest.makeDefaultTestGrid(); await FieldBackendService.createField( viewId: context.viewId, fieldType: FieldType.DateTime, ); await gridResponseFuture(); final fieldIndex = context.fieldController.fieldInfos .indexWhere((field) => field.fieldType == FieldType.DateTime); cellController = context.makeGridCellController(fieldIndex, 0).as(); }); test('select date', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); expect(bloc.state.dateTime, null); expect(bloc.state.endDateTime, null); expect(bloc.state.includeTime, false); expect(bloc.state.isRange, false); final now = DateTime.now(); bloc.add(DateCellEditorEvent.updateDateTime(now)); await gridResponseFuture(); expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); }); test('include time', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); final now = DateTime.now(); bloc.add(DateCellEditorEvent.setIncludeTime(true, now, null)); await gridResponseFuture(); expect(bloc.state.includeTime, true); expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); expect(bloc.state.endDateTime, null); bloc.add(const DateCellEditorEvent.setIncludeTime(false, null, null)); await gridResponseFuture(); expect(bloc.state.includeTime, false); expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); expect(bloc.state.endDateTime, null); }); test('end time basic', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); expect(bloc.state.isRange, false); expect(bloc.state.dateTime, null); expect(bloc.state.endDateTime, null); final now = DateTime.now(); bloc.add(DateCellEditorEvent.updateDateTime(now)); await gridResponseFuture(); expect(bloc.state.isRange, false); expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); expect(bloc.state.endDateTime, null); bloc.add(const DateCellEditorEvent.setIsRange(true, null, null)); await gridResponseFuture(); expect(bloc.state.isRange, true); expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); expect(bloc.state.endDateTime!.isAtSameMinuteAs(now), true); bloc.add(const DateCellEditorEvent.setIsRange(false, null, null)); await gridResponseFuture(); expect(bloc.state.isRange, false); expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); expect(bloc.state.endDateTime, null); }); test('end time from empty', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); expect(bloc.state.isRange, false); expect(bloc.state.dateTime, null); expect(bloc.state.endDateTime, null); final now = DateTime.now(); bloc.add(DateCellEditorEvent.setIsRange(true, now, now)); await gridResponseFuture(); expect(bloc.state.isRange, true); expect(bloc.state.dateTime!.isAtSameDayAs(now), true); expect(bloc.state.endDateTime!.isAtSameDayAs(now), true); bloc.add(const DateCellEditorEvent.setIsRange(false, null, null)); await gridResponseFuture(); expect(bloc.state.isRange, false); expect(bloc.state.dateTime!.isAtSameDayAs(now), true); expect(bloc.state.endDateTime, null); }); test('end time unexpected null', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); expect(bloc.state.isRange, false); expect(bloc.state.dateTime, null); expect(bloc.state.endDateTime, null); final now = DateTime.now(); // pass in unexpected null as end date time bloc.add(DateCellEditorEvent.setIsRange(true, now, null)); await gridResponseFuture(); // no changes expect(bloc.state.isRange, false); expect(bloc.state.dateTime, null); expect(bloc.state.endDateTime, null); }); test('end time unexpected end', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); expect(bloc.state.isRange, false); expect(bloc.state.dateTime, null); expect(bloc.state.endDateTime, null); final now = DateTime.now(); bloc.add(DateCellEditorEvent.setIsRange(true, now, now)); await gridResponseFuture(); bloc.add(DateCellEditorEvent.setIsRange(false, now, now)); await gridResponseFuture(); // no change expect(bloc.state.isRange, true); expect(bloc.state.dateTime!.isAtSameDayAs(now), true); expect(bloc.state.endDateTime!.isAtSameDayAs(now), true); }); test('clear date', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); final now = DateTime.now(); bloc.add(DateCellEditorEvent.setIsRange(true, now, now)); await gridResponseFuture(); bloc.add(DateCellEditorEvent.setIncludeTime(true, now, now)); await gridResponseFuture(); expect(bloc.state.isRange, true); expect(bloc.state.includeTime, true); expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); expect(bloc.state.endDateTime!.isAtSameMinuteAs(now), true); bloc.add(const DateCellEditorEvent.clearDate()); await gridResponseFuture(); expect(bloc.state.dateTime, null); expect(bloc.state.endDateTime, null); expect(bloc.state.includeTime, false); expect(bloc.state.isRange, false); }); test('set date format', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); expect( bloc.state.dateTypeOptionPB.dateFormat, DateFormatPB.Friendly, ); expect( bloc.state.dateTypeOptionPB.timeFormat, TimeFormatPB.TwentyFourHour, ); bloc.add( const DateCellEditorEvent.setDateFormat(DateFormatPB.ISO), ); await gridResponseFuture(); expect( bloc.state.dateTypeOptionPB.dateFormat, DateFormatPB.ISO, ); bloc.add( const DateCellEditorEvent.setTimeFormat(TimeFormatPB.TwelveHour), ); await gridResponseFuture(); expect( bloc.state.dateTypeOptionPB.timeFormat, TimeFormatPB.TwelveHour, ); }); test('set reminder option', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); expect(reminderBloc.state.reminders.length, 0); final now = DateTime.now(); final yesterday = DateTime(now.year, now.month, now.day - 1); bloc.add(DateCellEditorEvent.updateDateTime(yesterday)); await gridResponseFuture(); bloc.add( const DateCellEditorEvent.setReminderOption( ReminderOption.onDayOfEvent, ), ); await gridResponseFuture(); expect(reminderBloc.state.reminders.length, 1); expect( reminderBloc.state.reminders.first.scheduledAt, Int64( yesterday.add(const Duration(hours: 9)).millisecondsSinceEpoch ~/ 1000, ), ); bloc.add( const DateCellEditorEvent.setReminderOption(ReminderOption.none), ); await gridResponseFuture(); expect(reminderBloc.state.reminders.length, 0); reminderBloc.add(const ReminderEvent.refresh()); await gridResponseFuture(); expect(reminderBloc.state.reminders.length, 0); }); test('set reminder option with later time', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); expect(reminderBloc.state.reminders.length, 0); final now = DateTime.now(); final threeDaysFromToday = DateTime(now.year, now.month, now.day + 3); bloc.add(DateCellEditorEvent.updateDateTime(threeDaysFromToday)); await gridResponseFuture(); bloc.add( const DateCellEditorEvent.setReminderOption( ReminderOption.onDayOfEvent, ), ); await gridResponseFuture(); expect(reminderBloc.state.reminders.length, 0); }); test('set reminder option from empty', () async { final reminderBloc = ReminderBloc(); final bloc = DateCellEditorBloc( cellController: cellController, reminderBloc: reminderBloc, ); await gridResponseFuture(); final now = DateTime.now(); final yesterday = DateTime(now.year, now.month, now.day - 1); bloc.add(DateCellEditorEvent.updateDateTime(yesterday)); await gridResponseFuture(); bloc.add( const DateCellEditorEvent.setReminderOption( ReminderOption.onDayOfEvent, ), ); await gridResponseFuture(); expect(bloc.state.dateTime, yesterday); expect(reminderBloc.state.reminders.length, 1); expect( reminderBloc.state.reminders.first.scheduledAt, Int64( yesterday.add(const Duration(hours: 9)).millisecondsSinceEpoch ~/ 1000, ), ); bloc.add(const DateCellEditorEvent.clearDate()); await gridResponseFuture(); expect(reminderBloc.state.reminders.length, 0); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import '../util.dart'; void main() { late AppFlowyGridTest cellTest; setUpAll(() async { cellTest = await AppFlowyGridTest.ensureInitialized(); }); group('select cell bloc:', () { late GridTestContext context; late SelectOptionCellController cellController; setUp(() async { context = await cellTest.makeDefaultTestGrid(); await RowBackendService.createRow(viewId: context.viewId); final fieldIndex = context.fieldController.fieldInfos .indexWhere((field) => field.fieldType == FieldType.SingleSelect); cellController = context.makeGridCellController(fieldIndex, 0).as(); }); test('create options', () async { final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); expect(bloc.state.options.length, 1); expect(bloc.state.options[0].name, "A"); }); test('update options', () async { final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); final SelectOptionPB optionUpdate = bloc.state.options[0] ..color = SelectOptionColorPB.Aqua ..name = "B"; bloc.add(SelectOptionCellEditorEvent.updateOption(optionUpdate)); expect(bloc.state.options.length, 1); expect(bloc.state.options[0].name, "B"); expect(bloc.state.options[0].color, SelectOptionColorPB.Aqua); }); test('delete options', () async { final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); assert( bloc.state.options.length == 1, "Expect 1 but receive ${bloc.state.options.length}, Options: ${bloc.state.options}", ); bloc.add(const SelectOptionCellEditorEvent.filterOption("B")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); assert( bloc.state.options.length == 2, "Expect 2 but receive ${bloc.state.options.length}, Options: ${bloc.state.options}", ); bloc.add(const SelectOptionCellEditorEvent.filterOption("C")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); assert( bloc.state.options.length == 3, "Expect 3 but receive ${bloc.state.options.length}. Options: ${bloc.state.options}", ); bloc.add(SelectOptionCellEditorEvent.deleteOption(bloc.state.options[0])); await gridResponseFuture(); assert( bloc.state.options.length == 2, "Expect 2 but receive ${bloc.state.options.length}. Options: ${bloc.state.options}", ); bloc.add(const SelectOptionCellEditorEvent.deleteAllOptions()); await gridResponseFuture(); assert( bloc.state.options.isEmpty, "Expect empty but receive ${bloc.state.options.length}. Options: ${bloc.state.options}", ); }); test('select/unselect option', () async { final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); final optionId = bloc.state.options[0].id; bloc.add(SelectOptionCellEditorEvent.unselectOption(optionId)); await gridResponseFuture(); assert(bloc.state.selectedOptions.isEmpty); bloc.add(SelectOptionCellEditorEvent.selectOption(optionId)); await gridResponseFuture(); assert(bloc.state.selectedOptions.length == 1); expect(bloc.state.selectedOptions[0].name, "A"); }); test('select an option or create one', () async { final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("B")); bloc.add(const SelectOptionCellEditorEvent.submitTextField()); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); bloc.add(const SelectOptionCellEditorEvent.submitTextField()); await gridResponseFuture(); expect(bloc.state.selectedOptions.length, 1); expect(bloc.state.options.length, 1); expect(bloc.state.selectedOptions[0].name, "A"); }); test('select multiple options', () async { final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("B")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); bloc.add( const SelectOptionCellEditorEvent.selectMultipleOptions( ["A", "B", "C"], "x", ), ); await gridResponseFuture(); assert(bloc.state.selectedOptions.length == 1); expect(bloc.state.selectedOptions[0].name, "A"); expect(bloc.filter, "x"); }); test('filter options', () async { final bloc = SelectOptionCellEditorBloc(cellController: cellController); await gridResponseFuture(); bloc.add(const SelectOptionCellEditorEvent.filterOption("abcd")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); expect( bloc.state.options.length, 1, reason: "Options: ${bloc.state.options}", ); bloc.add(const SelectOptionCellEditorEvent.filterOption("aaaa")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); expect( bloc.state.options.length, 2, reason: "Options: ${bloc.state.options}", ); bloc.add(const SelectOptionCellEditorEvent.filterOption("defg")); bloc.add(const SelectOptionCellEditorEvent.createOption()); await gridResponseFuture(); expect( bloc.state.options.length, 3, reason: "Options: ${bloc.state.options}", ); bloc.add(const SelectOptionCellEditorEvent.filterOption("a")); await gridResponseFuture(); expect( bloc.state.options.length, 2, reason: "Options: ${bloc.state.options}", ); expect( bloc.allOptions.length, 3, reason: "Options: ${bloc.state.options}", ); expect(bloc.state.createSelectOptionSuggestion!.name, "a"); expect(bloc.filter, "a"); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/cell/text_cell_bloc_test.dart ================================================ import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; import 'package:flutter_test/flutter_test.dart'; import '../util.dart'; void main() { late AppFlowyGridTest cellTest; setUpAll(() async { cellTest = await AppFlowyGridTest.ensureInitialized(); }); group('text cell bloc:', () { late GridTestContext context; late TextCellController cellController; setUp(() async { context = await cellTest.makeDefaultTestGrid(); await RowBackendService.createRow(viewId: context.viewId); final fieldIndex = context.fieldController.fieldInfos .indexWhere((field) => field.fieldType == FieldType.RichText); cellController = context.makeGridCellController(fieldIndex, 0).as(); }); test('update text', () async { final bloc = TextCellBloc(cellController: cellController); await gridResponseFuture(); expect(bloc.state.content, ""); bloc.add(const TextCellEvent.updateText("A")); await gridResponseFuture(milliseconds: 600); expect(bloc.state.content, "A"); }); test('non-primary text field emoji and hasDocument', () async { final primaryBloc = TextCellBloc(cellController: cellController); expect(primaryBloc.state.emoji == null, false); expect(primaryBloc.state.hasDocument == null, false); await primaryBloc.close(); await FieldBackendService.createField( viewId: context.viewId, fieldName: "Second", ); await gridResponseFuture(); final fieldIndex = context.fieldController.fieldInfos.indexWhere( (field) => field.fieldType == FieldType.RichText && !field.isPrimary, ); cellController = context.makeGridCellController(fieldIndex, 0).as(); final nonPrimaryBloc = TextCellBloc(cellController: cellController); await gridResponseFuture(); expect(nonPrimaryBloc.state.emoji == null, true); expect(nonPrimaryBloc.state.hasDocument == null, true); }); test('update wrap cell content', () async { final bloc = TextCellBloc(cellController: cellController); await gridResponseFuture(); expect(bloc.state.wrap, true); await FieldSettingsBackendService( viewId: context.viewId, ).updateFieldSettings( fieldId: cellController.fieldId, wrapCellContent: false, ); await gridResponseFuture(); expect(bloc.state.wrap, false); }); test('update emoji', () async { final bloc = TextCellBloc(cellController: cellController); await gridResponseFuture(); expect(bloc.state.emoji!.value, ""); await RowBackendService(viewId: context.viewId) .updateMeta(rowId: cellController.rowId, iconURL: "dummy"); await gridResponseFuture(); expect(bloc.state.emoji!.value, "dummy"); }); test('update document data', () async { // This is so fake? final bloc = TextCellBloc(cellController: cellController); await gridResponseFuture(); expect(bloc.state.hasDocument!.value, false); await RowBackendService(viewId: context.viewId) .updateMeta(rowId: cellController.rowId, isDocumentEmpty: false); await gridResponseFuture(); expect(bloc.state.hasDocument!.value, true); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_cell_bloc_test.dart ================================================ import 'package:appflowy/plugins/database/application/field/field_cell_bloc.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import '../util.dart'; void main() { late AppFlowyGridTest gridTest; setUpAll(() async { gridTest = await AppFlowyGridTest.ensureInitialized(); }); group('field cell bloc:', () { late GridTestContext context; late double width; setUp(() async { context = await gridTest.makeDefaultTestGrid(); }); blocTest( 'update field width', build: () => FieldCellBloc( fieldInfo: context.fieldController.fieldInfos[0], viewId: context.viewId, ), act: (bloc) { width = bloc.state.width; bloc.add(const FieldCellEvent.onResizeStart()); bloc.add(const FieldCellEvent.startUpdateWidth(100)); bloc.add(const FieldCellEvent.endUpdateWidth()); }, verify: (bloc) { expect(bloc.state.width, width + 100); }, ); blocTest( 'field width should not be less than 50px', build: () => FieldCellBloc( viewId: context.viewId, fieldInfo: context.fieldController.fieldInfos[0], ), act: (bloc) { bloc.add(const FieldCellEvent.onResizeStart()); bloc.add(const FieldCellEvent.startUpdateWidth(-110)); bloc.add(const FieldCellEvent.endUpdateWidth()); }, verify: (bloc) { expect(bloc.state.width, 50); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_editor_bloc_test.dart ================================================ import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:nanoid/nanoid.dart'; import '../util.dart'; void main() { late AppFlowyGridTest gridTest; setUpAll(() async { gridTest = await AppFlowyGridTest.ensureInitialized(); }); group('field editor bloc:', () { late GridTestContext context; late FieldEditorBloc editorBloc; setUp(() async { context = await gridTest.makeDefaultTestGrid(); final fieldInfo = context.fieldController.fieldInfos .firstWhere((field) => field.fieldType == FieldType.SingleSelect); editorBloc = FieldEditorBloc( viewId: context.viewId, fieldController: context.fieldController, fieldInfo: fieldInfo, isNew: false, ); }); test('rename field', () async { expect(editorBloc.state.field.name, equals("Type")); editorBloc.add(const FieldEditorEvent.renameField('Hello world')); await gridResponseFuture(); expect(editorBloc.state.field.name, equals("Hello world")); }); test('edit icon', () async { expect(editorBloc.state.field.icon, equals("")); editorBloc.add(const FieldEditorEvent.updateIcon('emoji/smiley-face')); await gridResponseFuture(); expect(editorBloc.state.field.icon, equals("emoji/smiley-face")); editorBloc.add(const FieldEditorEvent.updateIcon("")); await gridResponseFuture(); expect(editorBloc.state.field.icon, equals("")); }); test('switch to text field', () async { expect(editorBloc.state.field.fieldType, equals(FieldType.SingleSelect)); editorBloc.add( const FieldEditorEvent.switchFieldType(FieldType.RichText), ); await gridResponseFuture(); expect(editorBloc.state.field.fieldType, equals(FieldType.RichText)); }); test('update field type option', () async { final selectOption = SelectOptionPB() ..id = nanoid(4) ..color = SelectOptionColorPB.Lime ..name = "New option"; final typeOptionData = SingleSelectTypeOptionPB() ..options.addAll([selectOption]); editorBloc.add( FieldEditorEvent.updateTypeOption(typeOptionData.writeToBuffer()), ); await gridResponseFuture(); final actual = SingleSelectTypeOptionDataParser() .fromBuffer(editorBloc.state.field.field.typeOptionData); expect(actual, equals(typeOptionData)); }); test('update visibility', () async { expect( editorBloc.state.field.visibility, equals(FieldVisibility.AlwaysShown), ); editorBloc.add(const FieldEditorEvent.toggleFieldVisibility()); await gridResponseFuture(); expect( editorBloc.state.field.visibility, equals(FieldVisibility.AlwaysHidden), ); }); test('update wrap cell', () async { expect( editorBloc.state.field.wrapCellContent, equals(true), ); editorBloc.add(const FieldEditorEvent.toggleWrapCellContent()); await gridResponseFuture(); expect( editorBloc.state.field.wrapCellContent, equals(false), ); }); test('insert left and right', () async { expect( context.fieldController.fieldInfos.length, equals(3), ); editorBloc.add(const FieldEditorEvent.insertLeft()); await gridResponseFuture(); editorBloc.add(const FieldEditorEvent.insertRight()); await gridResponseFuture(); expect( context.fieldController.fieldInfos.length, equals(5), ); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_editor_bloc_test.dart ================================================ import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/domain/filter_service.dart'; import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import '../util.dart'; void main() { late AppFlowyGridTest gridTest; setUpAll(() async { gridTest = await AppFlowyGridTest.ensureInitialized(); }); group('filter editor bloc:', () { late GridTestContext context; late FilterEditorBloc filterBloc; setUp(() async { context = await gridTest.makeDefaultTestGrid(); filterBloc = FilterEditorBloc( viewId: context.viewId, fieldController: context.fieldController, ); }); FieldInfo getFirstFieldByType(FieldType fieldType) { return context.fieldController.fieldInfos .firstWhere((field) => field.fieldType == fieldType); } test('create filter', () async { expect(filterBloc.state.filters.length, equals(0)); expect(filterBloc.state.fields.length, equals(3)); // through domain directly final textField = getFirstFieldByType(FieldType.RichText); final service = FilterBackendService(viewId: context.viewId); await service.insertTextFilter( fieldId: textField.id, condition: TextFilterConditionPB.TextIsEmpty, content: "", ); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(1)); expect(filterBloc.state.fields.length, equals(3)); // through bloc event final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); filterBloc.add(FilterEditorEvent.createFilter(selectOptionField)); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(2)); expect(filterBloc.state.filters.first.fieldId, equals(textField.id)); expect(filterBloc.state.filters[1].fieldId, equals(selectOptionField.id)); final filter = filterBloc.state.filters.first as TextFilter; expect(filter.condition, equals(TextFilterConditionPB.TextIsEmpty)); expect(filter.content, equals("")); final filter2 = filterBloc.state.filters[1] as SelectOptionFilter; expect(filter2.condition, equals(SelectOptionFilterConditionPB.OptionIs)); expect(filter2.optionIds.length, equals(0)); expect(filterBloc.state.fields.length, equals(3)); }); test('change filtering field', () async { final textField = getFirstFieldByType(FieldType.RichText); final selectField = getFirstFieldByType(FieldType.Checkbox); filterBloc.add(FilterEditorEvent.createFilter(textField)); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(1)); expect(filterBloc.state.fields.length, equals(3)); expect( filterBloc.state.filters.first.fieldType, equals(FieldType.RichText), ); final filter = filterBloc.state.filters.first; filterBloc.add( FilterEditorEvent.changeFilteringField(filter.filterId, selectField), ); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(1)); expect( filterBloc.state.filters.first.fieldType, equals(FieldType.Checkbox), ); expect(filterBloc.state.fields.length, equals(3)); }); test('delete filter', () async { final textField = getFirstFieldByType(FieldType.RichText); filterBloc.add(FilterEditorEvent.createFilter(textField)); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(1)); expect(filterBloc.state.fields.length, equals(3)); final filter = filterBloc.state.filters.first; filterBloc.add(FilterEditorEvent.deleteFilter(filter.filterId)); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(0)); expect(filterBloc.state.fields.length, equals(3)); }); test('update filter', () async { final service = FilterBackendService(viewId: context.viewId); final textField = getFirstFieldByType(FieldType.RichText); // Create filter await service.insertTextFilter( fieldId: textField.id, condition: TextFilterConditionPB.TextIsEmpty, content: "", ); await gridResponseFuture(); TextFilter filter = filterBloc.state.filters.first as TextFilter; expect(filter.condition, equals(TextFilterConditionPB.TextIsEmpty)); final textFilter = context.fieldController.filters.first; // Update the existing filter await service.insertTextFilter( fieldId: textField.id, filterId: textFilter.filterId, condition: TextFilterConditionPB.TextIs, content: "ABC", ); await gridResponseFuture(); filter = filterBloc.state.filters.first as TextFilter; expect(filter.condition, equals(TextFilterConditionPB.TextIs)); expect(filter.content, equals("ABC")); }); test('update filtering field\'s name', () async { final textField = getFirstFieldByType(FieldType.RichText); filterBloc.add(FilterEditorEvent.createFilter(textField)); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(1)); expect(filterBloc.state.fields.length, equals(3)); expect(filterBloc.state.fields.first.name, equals("Name")); // edit field await FieldBackendService( viewId: context.viewId, fieldId: textField.id, ).updateField(name: "New Name"); await gridResponseFuture(); expect(filterBloc.state.fields.length, equals(3)); expect(filterBloc.state.fields.first.name, equals("New Name")); }); test('update field type', () async { final checkboxField = getFirstFieldByType(FieldType.Checkbox); filterBloc.add(FilterEditorEvent.createFilter(checkboxField)); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(1)); // edit field await FieldBackendService( viewId: context.viewId, fieldId: checkboxField.id, ).updateType(fieldType: FieldType.DateTime); await gridResponseFuture(); // filter is removed expect(filterBloc.state.filters.length, equals(0)); expect(filterBloc.state.fields.length, equals(3)); expect(filterBloc.state.fields[2].fieldType, FieldType.DateTime); }); test('update filter field', () async { final checkboxField = getFirstFieldByType(FieldType.Checkbox); filterBloc.add(FilterEditorEvent.createFilter(checkboxField)); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(1)); // edit field await FieldBackendService( viewId: context.viewId, fieldId: checkboxField.id, ).updateField(name: "HERRO"); await gridResponseFuture(); expect(filterBloc.state.filters.length, equals(1)); expect(filterBloc.state.fields.length, equals(3)); expect(filterBloc.state.fields[2].name, "HERRO"); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_entities_test.dart ================================================ import 'dart:typed_data'; import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('parsing filter entities:', () { FilterPB createFilterPB( FieldType fieldType, Uint8List data, ) { return FilterPB( id: "FT", filterType: FilterType.Data, data: FilterDataPB( fieldId: "FD", fieldType: fieldType, data: data, ), ); } test('text', () async { expect( DatabaseFilter.fromPB( createFilterPB( FieldType.RichText, TextFilterPB( condition: TextFilterConditionPB.TextContains, content: "c", ).writeToBuffer(), ), ), equals( TextFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.RichText, condition: TextFilterConditionPB.TextContains, content: "c", ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.RichText, TextFilterPB( condition: TextFilterConditionPB.TextContains, content: "", ).writeToBuffer(), ), ), equals( TextFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.RichText, condition: TextFilterConditionPB.TextContains, content: "", ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.RichText, TextFilterPB( condition: TextFilterConditionPB.TextIsEmpty, content: "", ).writeToBuffer(), ), ), equals( TextFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.RichText, condition: TextFilterConditionPB.TextIsEmpty, content: "", ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.RichText, TextFilterPB( condition: TextFilterConditionPB.TextIsEmpty, content: "", ).writeToBuffer(), ), ), equals( TextFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.RichText, condition: TextFilterConditionPB.TextIsEmpty, content: "c", ), ), ); }); test('number', () async { expect( DatabaseFilter.fromPB( createFilterPB( FieldType.Number, NumberFilterPB( condition: NumberFilterConditionPB.GreaterThan, content: "", ).writeToBuffer(), ), ), equals( NumberFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.Number, condition: NumberFilterConditionPB.GreaterThan, content: "", ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.Number, NumberFilterPB( condition: NumberFilterConditionPB.GreaterThan, content: "123", ).writeToBuffer(), ), ), equals( NumberFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.Number, condition: NumberFilterConditionPB.GreaterThan, content: "123", ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.Number, NumberFilterPB( condition: NumberFilterConditionPB.NumberIsEmpty, content: "", ).writeToBuffer(), ), ), equals( NumberFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.Number, condition: NumberFilterConditionPB.NumberIsEmpty, content: "", ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.Number, NumberFilterPB( condition: NumberFilterConditionPB.NumberIsEmpty, content: "", ).writeToBuffer(), ), ), equals( NumberFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.Number, condition: NumberFilterConditionPB.NumberIsEmpty, content: "123", ), ), ); }); test('checkbox', () async { expect( DatabaseFilter.fromPB( createFilterPB( FieldType.Checkbox, CheckboxFilterPB( condition: CheckboxFilterConditionPB.IsChecked, ).writeToBuffer(), ), ), equals( const CheckboxFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.Checkbox, condition: CheckboxFilterConditionPB.IsChecked, ), ), ); }); test('checklist', () async { expect( DatabaseFilter.fromPB( createFilterPB( FieldType.Checklist, ChecklistFilterPB( condition: ChecklistFilterConditionPB.IsComplete, ).writeToBuffer(), ), ), equals( const ChecklistFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.Checklist, condition: ChecklistFilterConditionPB.IsComplete, ), ), ); }); test('single select option', () async { expect( DatabaseFilter.fromPB( createFilterPB( FieldType.SingleSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionIs, optionIds: [], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.SingleSelect, condition: SelectOptionFilterConditionPB.OptionIs, optionIds: const [], ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.SingleSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionIs, optionIds: ['a'], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.SingleSelect, condition: SelectOptionFilterConditionPB.OptionIs, optionIds: const ['a'], ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.SingleSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionIs, optionIds: ['a', 'b'], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.SingleSelect, condition: SelectOptionFilterConditionPB.OptionIs, optionIds: const ['a', 'b'], ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.SingleSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionIsEmpty, optionIds: [], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.SingleSelect, condition: SelectOptionFilterConditionPB.OptionIsEmpty, optionIds: const [], ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.SingleSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionIsEmpty, optionIds: ['a'], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.SingleSelect, condition: SelectOptionFilterConditionPB.OptionIsEmpty, optionIds: const [], ), ), ); }); test('multi select option', () async { expect( DatabaseFilter.fromPB( createFilterPB( FieldType.MultiSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionContains, optionIds: [], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.MultiSelect, condition: SelectOptionFilterConditionPB.OptionContains, optionIds: const [], ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.MultiSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionContains, optionIds: ['a'], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.MultiSelect, condition: SelectOptionFilterConditionPB.OptionContains, optionIds: const ['a'], ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.MultiSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionContains, optionIds: ['a', 'b'], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.MultiSelect, condition: SelectOptionFilterConditionPB.OptionContains, optionIds: const ['a', 'b'], ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.MultiSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionIs, optionIds: ['a', 'b'], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.MultiSelect, condition: SelectOptionFilterConditionPB.OptionIs, optionIds: const ['a', 'b'], ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.MultiSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionIsEmpty, optionIds: [], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.MultiSelect, condition: SelectOptionFilterConditionPB.OptionIsEmpty, optionIds: const [], ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.MultiSelect, SelectOptionFilterPB( condition: SelectOptionFilterConditionPB.OptionIsEmpty, optionIds: ['a'], ).writeToBuffer(), ), ), equals( SelectOptionFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.MultiSelect, condition: SelectOptionFilterConditionPB.OptionIsEmpty, optionIds: const [], ), ), ); }); test('date time', () { expect( DatabaseFilter.fromPB( createFilterPB( FieldType.DateTime, DateFilterPB( condition: DateFilterConditionPB.DateStartsOn, timestamp: Int64(5), ).writeToBuffer(), ), ), equals( DateTimeFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.DateTime, condition: DateFilterConditionPB.DateStartsOn, timestamp: DateTime.fromMillisecondsSinceEpoch(5 * 1000), ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.DateTime, DateFilterPB( condition: DateFilterConditionPB.DateStartsOn, ).writeToBuffer(), ), ), equals( DateTimeFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.DateTime, condition: DateFilterConditionPB.DateStartsOn, ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.DateTime, DateFilterPB( condition: DateFilterConditionPB.DateStartsOn, start: Int64(5), ).writeToBuffer(), ), ), equals( DateTimeFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.DateTime, condition: DateFilterConditionPB.DateStartsOn, ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.DateTime, DateFilterPB( condition: DateFilterConditionPB.DateEndsBetween, start: Int64(5), ).writeToBuffer(), ), ), equals( DateTimeFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.DateTime, condition: DateFilterConditionPB.DateEndsBetween, start: DateTime.fromMillisecondsSinceEpoch(5 * 1000), ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.DateTime, DateFilterPB( condition: DateFilterConditionPB.DateEndIsNotEmpty, ).writeToBuffer(), ), ), equals( DateTimeFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.DateTime, condition: DateFilterConditionPB.DateEndIsNotEmpty, ), ), ); expect( DatabaseFilter.fromPB( createFilterPB( FieldType.DateTime, DateFilterPB( condition: DateFilterConditionPB.DateEndIsNotEmpty, start: Int64(5), end: Int64(5), timestamp: Int64(5), ).writeToBuffer(), ), ), equals( DateTimeFilter( filterId: "FT", fieldId: "FD", fieldType: FieldType.DateTime, condition: DateFilterConditionPB.DateEndIsNotEmpty, ), ), ); }); }); // group('write to buffer', () { // test('text', () {}); // }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/grid_bloc_test.dart ================================================ import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'util.dart'; void main() { late AppFlowyGridTest gridTest; setUpAll(() async { gridTest = await AppFlowyGridTest.ensureInitialized(); }); group('Edit Grid:', () { late GridTestContext context; setUp(() async { context = await gridTest.makeDefaultTestGrid(); }); // The initial number of rows is 3 for each grid // We create one row so we expect 4 rows blocTest( "create a row", build: () => GridBloc( view: context.view, databaseController: DatabaseController(view: context.view), )..add(const GridEvent.initial()), act: (bloc) => bloc.add(const GridEvent.createRow()), wait: gridResponseDuration(), verify: (bloc) { expect(bloc.state.rowInfos.length, equals(4)); }, ); blocTest( "delete the last row", build: () => GridBloc( view: context.view, databaseController: DatabaseController(view: context.view), )..add(const GridEvent.initial()), act: (bloc) async { await gridResponseFuture(); bloc.add(GridEvent.deleteRow(bloc.state.rowInfos.last)); }, wait: gridResponseDuration(), verify: (bloc) { expect(bloc.state.rowInfos.length, equals(2)); }, ); String? firstId; String? secondId; String? thirdId; blocTest( 'reorder rows', build: () => GridBloc( view: context.view, databaseController: DatabaseController(view: context.view), )..add(const GridEvent.initial()), act: (bloc) async { await gridResponseFuture(); firstId = bloc.state.rowInfos[0].rowId; secondId = bloc.state.rowInfos[1].rowId; thirdId = bloc.state.rowInfos[2].rowId; bloc.add(const GridEvent.moveRow(0, 2)); }, wait: gridResponseDuration(), verify: (bloc) { expect(secondId, equals(bloc.state.rowInfos[0].rowId)); expect(thirdId, equals(bloc.state.rowInfos[1].rowId)); expect(firstId, equals(bloc.state.rowInfos[2].rowId)); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/sort/sort_editor_bloc_test.dart ================================================ import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter_test/flutter_test.dart'; import '../util.dart'; void main() { late AppFlowyGridTest gridTest; setUpAll(() async { gridTest = await AppFlowyGridTest.ensureInitialized(); }); group('sort editor bloc:', () { late GridTestContext context; late SortEditorBloc sortBloc; setUp(() async { context = await gridTest.makeDefaultTestGrid(); sortBloc = SortEditorBloc( viewId: context.viewId, fieldController: context.fieldController, ); }); FieldInfo getFirstFieldByType(FieldType fieldType) { return context.fieldController.fieldInfos .firstWhere((field) => field.fieldType == fieldType); } test('create sort', () async { expect(sortBloc.state.sorts.length, equals(0)); expect(sortBloc.state.creatableFields.length, equals(3)); expect(sortBloc.state.allFields.length, equals(3)); final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); await gridResponseFuture(); expect(sortBloc.state.sorts.length, 1); expect(sortBloc.state.sorts.first.fieldId, selectOptionField.id); expect( sortBloc.state.sorts.first.condition, SortConditionPB.Ascending, ); expect(sortBloc.state.creatableFields.length, equals(2)); expect(sortBloc.state.allFields.length, equals(3)); }); test('change sort field', () async { final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); await gridResponseFuture(); expect( sortBloc.state.creatableFields .map((e) => e.id) .contains(selectOptionField.id), false, ); final checkboxField = getFirstFieldByType(FieldType.Checkbox); sortBloc.add( SortEditorEvent.editSort( sortId: sortBloc.state.sorts.first.sortId, fieldId: checkboxField.id, ), ); await gridResponseFuture(); expect(sortBloc.state.creatableFields.length, equals(2)); expect( sortBloc.state.creatableFields .map((e) => e.id) .contains(checkboxField.id), false, ); }); test('update sort direction', () async { final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); await gridResponseFuture(); expect( sortBloc.state.sorts.first.condition, SortConditionPB.Ascending, ); sortBloc.add( SortEditorEvent.editSort( sortId: sortBloc.state.sorts.first.sortId, condition: SortConditionPB.Descending, ), ); await gridResponseFuture(); expect( sortBloc.state.sorts.first.condition, SortConditionPB.Descending, ); }); test('reorder sorts', () async { final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); final checkboxField = getFirstFieldByType(FieldType.Checkbox); sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); await gridResponseFuture(); sortBloc.add(SortEditorEvent.createSort(fieldId: checkboxField.id)); await gridResponseFuture(); expect(sortBloc.state.sorts[0].fieldId, selectOptionField.id); expect(sortBloc.state.sorts[1].fieldId, checkboxField.id); expect(sortBloc.state.creatableFields.length, equals(1)); expect(sortBloc.state.allFields.length, equals(3)); sortBloc.add( const SortEditorEvent.reorderSort(0, 2), ); await gridResponseFuture(); expect(sortBloc.state.sorts[0].fieldId, checkboxField.id); expect(sortBloc.state.sorts[1].fieldId, selectOptionField.id); expect(sortBloc.state.creatableFields.length, equals(1)); expect(sortBloc.state.allFields.length, equals(3)); }); test('delete sort', () async { final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); await gridResponseFuture(); expect(sortBloc.state.sorts.length, 1); sortBloc.add( SortEditorEvent.deleteSort(sortBloc.state.sorts.first.sortId), ); await gridResponseFuture(); expect(sortBloc.state.sorts.length, 0); expect(sortBloc.state.creatableFields.length, equals(3)); expect(sortBloc.state.allFields.length, equals(3)); }); test('delete all sorts', () async { final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); final checkboxField = getFirstFieldByType(FieldType.Checkbox); sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); await gridResponseFuture(); sortBloc.add(SortEditorEvent.createSort(fieldId: checkboxField.id)); await gridResponseFuture(); expect(sortBloc.state.sorts.length, 2); sortBloc.add(const SortEditorEvent.deleteAllSorts()); await gridResponseFuture(); expect(sortBloc.state.sorts.length, 0); expect(sortBloc.state.creatableFields.length, equals(3)); expect(sortBloc.state.allFields.length, equals(3)); }); test('update sort field', () async { final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); await gridResponseFuture(); expect(sortBloc.state.sorts.length, equals(1)); // edit field await FieldBackendService( viewId: context.viewId, fieldId: selectOptionField.id, ).updateField(name: "HERRO"); await gridResponseFuture(); expect(sortBloc.state.sorts.length, equals(1)); expect(sortBloc.state.allFields[1].name, "HERRO"); expect(sortBloc.state.creatableFields.length, equals(2)); expect(sortBloc.state.allFields.length, equals(3)); }); test('delete sorting field', () async { final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); await gridResponseFuture(); expect(sortBloc.state.sorts.length, equals(1)); // edit field await FieldBackendService( viewId: context.viewId, fieldId: selectOptionField.id, ).delete(); await gridResponseFuture(); expect(sortBloc.state.sorts.length, equals(0)); expect(sortBloc.state.creatableFields.length, equals(2)); expect(sortBloc.state.allFields.length, equals(2)); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/workspace/application/settings/share/import_service.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/services.dart'; import '../../util.dart'; const v020GridFileName = "v020.afdb"; const v069GridFileName = "v069.afdb"; class GridTestContext { GridTestContext(this.view, this.databaseController); final ViewPB view; final DatabaseController databaseController; String get viewId => view.id; List get rowInfos { return databaseController.rowCache.rowInfos; } FieldController get fieldController => databaseController.fieldController; Future createField(FieldType fieldType) async { final editorBloc = await createFieldEditor(databaseController: databaseController); await gridResponseFuture(); editorBloc.add(FieldEditorEvent.switchFieldType(fieldType)); await gridResponseFuture(); return editorBloc; } CellController makeGridCellController(int fieldIndex, int rowIndex) { return makeCellController( databaseController, CellContext( fieldId: fieldController.fieldInfos[fieldIndex].id, rowId: rowInfos[rowIndex].rowId, ), ).as(); } } Future createFieldEditor({ required DatabaseController databaseController, }) async { final result = await FieldBackendService.createField( viewId: databaseController.viewId, ); await gridResponseFuture(); return result.fold( (field) { return FieldEditorBloc( viewId: databaseController.viewId, fieldController: databaseController.fieldController, fieldInfo: databaseController.fieldController.getField(field.id)!, isNew: true, ); }, (err) => throw Exception(err), ); } /// Create a empty Grid for test class AppFlowyGridTest { AppFlowyGridTest({required this.unitTest}); final AppFlowyUnitTest unitTest; static Future ensureInitialized() async { final inner = await AppFlowyUnitTest.ensureInitialized(); return AppFlowyGridTest(unitTest: inner); } Future makeDefaultTestGrid() async { final workspace = await unitTest.createWorkspace(); final context = await ViewBackendService.createView( parentViewId: workspace.id, name: "Test Grid", layoutType: ViewLayoutPB.Grid, openAfterCreate: true, ).fold( (view) async { final databaseController = DatabaseController(view: view); await databaseController .open() .fold((l) => null, (r) => throw Exception(r)); return GridTestContext( view, databaseController, ); }, (error) => throw Exception(), ); return context; } Future makeTestGridFromImportedData( String fileName, ) async { final workspace = await unitTest.createWorkspace(); // Don't use the p.join to build the path that used in loadString. It // is not working on windows. final data = await rootBundle .loadString("assets/test/workspaces/database/$fileName"); final context = await ImportBackendService.importPages( workspace.id, [ ImportItemPayloadPB() ..name = fileName ..data = utf8.encode(data) ..viewLayout = ViewLayoutPB.Grid ..importType = ImportTypePB.AFDatabase, ], ).fold( (views) async { final view = views.items.first; final databaseController = DatabaseController(view: view); await databaseController .open() .fold((l) => null, (r) => throw Exception(r)); return GridTestContext( view, databaseController, ); }, (err) => throw Exception(), ); return context; } } Future gridResponseFuture({int milliseconds = 300}) { return Future.delayed( gridResponseDuration(milliseconds: milliseconds), ); } Duration gridResponseDuration({int milliseconds = 300}) { return Duration(milliseconds: milliseconds); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/home_test/home_bloc_test.dart ================================================ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/workspace/application/home/home_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../util.dart'; void main() { late AppFlowyUnitTest testContext; setUpAll(() async { testContext = await AppFlowyUnitTest.ensureInitialized(); }); test('init home screen', () async { final workspaceSetting = await FolderEventGetCurrentWorkspaceSetting() .send() .then((result) => result.fold((l) => l, (r) => throw Exception())); await blocResponseFuture(); final homeBloc = HomeBloc(workspaceSetting)..add(const HomeEvent.initial()); await blocResponseFuture(); assert(homeBloc.state.workspaceSetting.hasLatestView()); }); test('open the document', () async { final workspaceSetting = await FolderEventGetCurrentWorkspaceSetting() .send() .then((result) => result.fold((l) => l, (r) => throw Exception())); await blocResponseFuture(); final homeBloc = HomeBloc(workspaceSetting)..add(const HomeEvent.initial()); await blocResponseFuture(); final app = await testContext.createWorkspace(); final appBloc = ViewBloc(view: app)..add(const ViewEvent.initial()); assert(appBloc.state.lastCreatedView == null); appBloc.add( const ViewEvent.createView( "New document", ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(); assert(appBloc.state.lastCreatedView != null); final latestView = appBloc.state.lastCreatedView!; final _ = DocumentBloc(documentId: latestView.id) ..add(const DocumentEvent.initial()); await FolderEventSetLatestView(ViewIdPB(value: latestView.id)).send(); await blocResponseFuture(); final actual = homeBloc.state.workspaceSetting.latestView.id; assert(actual == latestView.id); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/home_test/sidebar_section_bloc_test.dart ================================================ import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../util.dart'; void main() { late AppFlowyUnitTest testContext; setUpAll(() async { testContext = await AppFlowyUnitTest.ensureInitialized(); }); test('assert initial apps is the build-in app', () async { final menuBloc = SidebarSectionsBloc() ..add( SidebarSectionsEvent.initial( testContext.userProfile, testContext.currentWorkspace.id, ), ); await blocResponseFuture(); assert(menuBloc.state.section.publicViews.length == 1); assert(menuBloc.state.section.privateViews.isEmpty); }); test('create views', () async { final menuBloc = SidebarSectionsBloc() ..add( SidebarSectionsEvent.initial( testContext.userProfile, testContext.currentWorkspace.id, ), ); await blocResponseFuture(); final names = ['View 1', 'View 2', 'View 3']; for (final name in names) { menuBloc.add( SidebarSectionsEvent.createRootViewInSection( name: name, index: 0, viewSection: ViewSectionPB.Public, ), ); await blocResponseFuture(); } final reversedNames = names.reversed.toList(); for (var i = 0; i < names.length; i++) { assert( menuBloc.state.section.publicViews[i].name == reversedNames[i], ); } }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/home_test/trash_bloc_test.dart ================================================ import 'package:appflowy/plugins/trash/application/trash_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../util.dart'; class TrashTestContext { TrashTestContext(this.unitTest); late ViewPB view; late ViewBloc viewBloc; late List allViews; final AppFlowyUnitTest unitTest; Future initialize() async { view = await unitTest.createWorkspace(); viewBloc = ViewBloc(view: view)..add(const ViewEvent.initial()); await blocResponseFuture(); viewBloc.add( const ViewEvent.createView( "Document 1", ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(millisecond: 300); viewBloc.add( const ViewEvent.createView( "Document 2", ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(millisecond: 300); viewBloc.add( const ViewEvent.createView( "Document 3", ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(millisecond: 300); allViews = [...viewBloc.state.view.childViews]; assert(allViews.length == 3, 'but receive ${allViews.length}'); } } void main() { late AppFlowyUnitTest unitTest; setUpAll(() async { unitTest = await AppFlowyUnitTest.ensureInitialized(); }); // 1. Create three views // 2. Delete a view and check the state // 3. Delete all views and check the state // 4. Put back a view // 5. Put back all views group('trash test: ', () { test('delete a view', () async { final context = TrashTestContext(unitTest); await context.initialize(); final trashBloc = TrashBloc()..add(const TrashEvent.initial()); await blocResponseFuture(); // delete a view final deletedView = context.viewBloc.state.view.childViews[0]; final deleteViewBloc = ViewBloc(view: deletedView) ..add(const ViewEvent.initial()); await blocResponseFuture(); deleteViewBloc.add(const ViewEvent.delete()); await blocResponseFuture(millisecond: 1000); assert(context.viewBloc.state.view.childViews.length == 2); assert(trashBloc.state.objects.length == 1); assert(trashBloc.state.objects.first.id == deletedView.id); // put back trashBloc.add(TrashEvent.putback(deletedView.id)); await blocResponseFuture(millisecond: 1000); assert(context.viewBloc.state.view.childViews.length == 3); assert(trashBloc.state.objects.isEmpty); // delete all views for (final view in context.allViews) { final deleteViewBloc = ViewBloc(view: view) ..add(const ViewEvent.initial()); await blocResponseFuture(); deleteViewBloc.add(const ViewEvent.delete()); await blocResponseFuture(millisecond: 1000); } expect(trashBloc.state.objects[0].id, context.allViews[0].id); expect(trashBloc.state.objects[1].id, context.allViews[1].id); expect(trashBloc.state.objects[2].id, context.allViews[2].id); // delete a view permanently trashBloc.add(TrashEvent.delete(trashBloc.state.objects[0])); await blocResponseFuture(millisecond: 1000); expect(trashBloc.state.objects.length, 2); // delete all view permanently trashBloc.add(const TrashEvent.deleteAll()); await blocResponseFuture(millisecond: 1000); assert( trashBloc.state.objects.isEmpty, "but receive ${trashBloc.state.objects.length}", ); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart ================================================ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../util.dart'; void main() { const name = 'Hello world'; late AppFlowyUnitTest testContext; setUpAll(() async { testContext = await AppFlowyUnitTest.ensureInitialized(); }); Future createTestViewBloc() async { final view = await testContext.createWorkspace(); final viewBloc = ViewBloc(view: view) ..add( const ViewEvent.initial(), ); await blocResponseFuture(); return viewBloc; } test('rename view test', () async { final viewBloc = await createTestViewBloc(); viewBloc.add(const ViewEvent.rename(name)); await blocResponseFuture(); expect(viewBloc.state.view.name, name); }); test('duplicate view test', () async { final viewBloc = await createTestViewBloc(); // create a nested view viewBloc.add( const ViewEvent.createView( name, ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(); expect(viewBloc.state.view.childViews.length, 1); final childViewBloc = ViewBloc(view: viewBloc.state.view.childViews.first) ..add( const ViewEvent.initial(), ); childViewBloc.add(const ViewEvent.duplicate()); await blocResponseFuture(millisecond: 1000); expect(viewBloc.state.view.childViews.length, 2); }); test('delete view test', () async { final viewBloc = await createTestViewBloc(); viewBloc.add( const ViewEvent.createView( name, ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(); expect(viewBloc.state.view.childViews.length, 1); final childViewBloc = ViewBloc(view: viewBloc.state.view.childViews.first) ..add( const ViewEvent.initial(), ); await blocResponseFuture(); childViewBloc.add(const ViewEvent.delete()); await blocResponseFuture(); assert(viewBloc.state.view.childViews.isEmpty); }); test('create nested view test', () async { final viewBloc = await createTestViewBloc(); viewBloc.add( const ViewEvent.createView( 'Document 1', ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(); final document1Bloc = ViewBloc(view: viewBloc.state.view.childViews.first) ..add( const ViewEvent.initial(), ); await blocResponseFuture(); const name = 'Document 1 - 1'; document1Bloc.add( const ViewEvent.createView( 'Document 1 - 1', ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(); expect(document1Bloc.state.view.childViews.length, 1); expect(document1Bloc.state.view.childViews.first.name, name); }); test('create documents in order', () async { final viewBloc = await createTestViewBloc(); final names = ['1', '2', '3']; for (final name in names) { viewBloc.add( ViewEvent.createView( name, ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(millisecond: 400); } expect(viewBloc.state.view.childViews.length, 3); for (var i = 0; i < names.length; i++) { expect(viewBloc.state.view.childViews[i].name, names[i]); } }); test('open latest view test', () async { final viewBloc = await createTestViewBloc(); expect(viewBloc.state.lastCreatedView, isNull); viewBloc.add( const ViewEvent.createView( '1', ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(); expect( viewBloc.state.lastCreatedView!.id, viewBloc.state.view.childViews.last.id, ); expect( viewBloc.state.lastCreatedView!.name, '1', ); viewBloc.add( const ViewEvent.createView( '2', ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(); expect( viewBloc.state.lastCreatedView!.name, '2', ); }); test('open latest document test', () async { const name1 = 'document'; final viewBloc = await createTestViewBloc(); viewBloc.add( const ViewEvent.createView( name1, ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(); final document = viewBloc.state.lastCreatedView!; assert(document.name == name1); const gird = 'grid'; viewBloc.add( const ViewEvent.createView( gird, ViewLayoutPB.Document, section: ViewSectionPB.Public, ), ); await blocResponseFuture(); assert(viewBloc.state.lastCreatedView!.name == gird); var workspaceLatest = await FolderEventGetCurrentWorkspaceSetting().send().then( (result) => result.fold( (l) => l, (r) => throw Exception(), ), ); workspaceLatest.latestView.id == viewBloc.state.lastCreatedView!.id; // ignore: unused_local_variable final documentBloc = DocumentBloc(documentId: document.id) ..add( const DocumentEvent.initial(), ); await blocResponseFuture(); workspaceLatest = await FolderEventGetCurrentWorkspaceSetting().send().then( (result) => result.fold( (l) => l, (r) => throw Exception(), ), ); workspaceLatest.latestView.id == document.id; }); test('create views', () async { final viewBloc = await createTestViewBloc(); const layouts = ViewLayoutPB.values; for (var i = 0; i < layouts.length; i++) { final layout = layouts[i]; if (layout == ViewLayoutPB.Chat) { continue; } viewBloc.add( ViewEvent.createView( 'Test $layout', layout, section: ViewSectionPB.Public, ), ); await blocResponseFuture(millisecond: 1000); expect(viewBloc.state.view.childViews.length, i + 1); expect(viewBloc.state.view.childViews.last.name, 'Test $layout'); expect(viewBloc.state.view.childViews.last.layout, layout); } }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/lib/features/settings/data_location_bloc_test.dart ================================================ import 'package:appflowy/features/settings/settings.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; class MockSettingsRepository extends Mock implements SettingsRepository {} void main() { late MockSettingsRepository repository; late DataLocationBloc bloc; const defaultPath = '/default/path'; const customPath = '/custom/path'; setUp(() { repository = MockSettingsRepository(); when(() => repository.getUserDataLocation()).thenAnswer( (_) async => FlowyResult.success( UserDataLocation(path: defaultPath, isCustom: false), ), ); bloc = DataLocationBloc(repository: repository) ..add(DataLocationEvent.initial()); }); tearDown(() async { await bloc.close(); }); blocTest( 'emits updated state when resetting to default', build: () => bloc, setUp: () { when(() => repository.resetUserDataLocation()).thenAnswer( (_) async => FlowyResult.success( UserDataLocation(path: defaultPath, isCustom: false), ), ); }, act: (bloc) => bloc.add(DataLocationEvent.resetToDefault()), wait: const Duration(milliseconds: 100), expect: () => [ DataLocationState( userDataLocation: UserDataLocation(path: defaultPath, isCustom: false), didResetToDefault: true, ), ], ); blocTest( 'emits updated state when setting custom path', build: () => bloc, setUp: () { when(() => repository.setCustomLocation(customPath)).thenAnswer( (_) async => FlowyResult.success( UserDataLocation(path: customPath, isCustom: true), ), ); }, act: (bloc) => bloc.add(DataLocationEvent.setCustomPath(customPath)), wait: const Duration(milliseconds: 100), expect: () => [ DataLocationState( userDataLocation: UserDataLocation(path: customPath, isCustom: true), didResetToDefault: false, ), ], ); blocTest( 'emits state with cleared reset flag', build: () => bloc, seed: () => DataLocationState( userDataLocation: UserDataLocation(path: defaultPath, isCustom: false), didResetToDefault: true, ), act: (bloc) => bloc.add(DataLocationEvent.clearState()), wait: const Duration(milliseconds: 100), expect: () => [ DataLocationState( userDataLocation: UserDataLocation( path: defaultPath, isCustom: false, ), didResetToDefault: false, ), ], ); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/lib/features/share_section/shared_section_bloc_test.dart ================================================ import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/shared_section/data/repositories/shared_pages_repository.dart'; import 'package:appflowy/features/shared_section/logic/shared_section_bloc.dart'; import 'package:appflowy/features/shared_section/models/shared_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; class MockSharePagesRepository extends Mock implements SharedPagesRepository {} void main() { late MockSharePagesRepository repository; late SharedSectionBloc bloc; const workspaceId = 'workspace-id'; final initialPages = []; final updatedPages = [ SharedPage( view: ViewPB( id: '1', name: 'Page 1', ), accessLevel: ShareAccessLevel.readOnly, ), ]; setUp(() { repository = MockSharePagesRepository(); when(() => repository.getSharedPages()) .thenAnswer((_) async => FlowyResult.success(initialPages)); bloc = SharedSectionBloc( workspaceId: workspaceId, repository: repository, )..add(const SharedSectionEvent.init()); }); tearDown(() async { await bloc.close(); }); blocTest( 'emits updated sharedPages on updateSharedPages', build: () => bloc, act: (bloc) => bloc.add( SharedSectionEvent.updateSharedPages( sharedPages: updatedPages, ), ), wait: const Duration(milliseconds: 100), expect: () => [ SharedSectionState.initial().copyWith( sharedPages: updatedPages, ), ], ); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/lib/features/share_tab/share_tab_bloc_test.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/data/repositories/local_share_with_user_repository_impl.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { const pageId = 'test_page_id'; const workspaceId = 'test_workspace_id'; late LocalShareWithUserRepositoryImpl repository; late ShareTabBloc bloc; setUp(() { repository = LocalShareWithUserRepositoryImpl(); bloc = ShareTabBloc( repository: repository, pageId: pageId, workspaceId: workspaceId, ); }); tearDown(() async { await bloc.close(); }); const email = 'lucas.xu@appflowy.io'; group('ShareTabBloc', () { blocTest( 'shares page with user', build: () => bloc, act: (bloc) => bloc.add( ShareTabEvent.inviteUsers( emails: [email], accessLevel: ShareAccessLevel.readOnly, ), ), wait: const Duration(milliseconds: 100), expect: () => [ // First state: shareResult is null isA().having( (s) => s.shareResult, 'shareResult', isNull, ), // Second state: shareResult is Success and users updated isA() .having((s) => s.shareResult, 'shareResult', isNotNull) .having( (s) => s.users.any((u) => u.email == email), 'users contains new user', isTrue, ), ], ); blocTest( 'removes user from page', build: () => bloc, act: (bloc) => bloc.add( ShareTabEvent.removeUsers( emails: [email], ), ), wait: const Duration(milliseconds: 100), expect: () => [ // First state: removeResult is null isA() .having((s) => s.removeResult, 'removeResult', isNull), // Second state: removeResult is Success and users updated isA() .having((s) => s.removeResult, 'removeResult', isNotNull) .having( (s) => s.users.any((u) => u.email == email), 'users contains removed user', isFalse, ), ], ); blocTest( 'updates access level for user', build: () => bloc, act: (bloc) => bloc.add( ShareTabEvent.updateUserAccessLevel( email: email, accessLevel: ShareAccessLevel.fullAccess, ), ), wait: const Duration(milliseconds: 100), expect: () => [ // First state: updateAccessLevelResult is null isA().having( (s) => s.updateAccessLevelResult, 'updateAccessLevelResult', isNull, ), // Second state: updateAccessLevelResult is Success and users updated isA() .having( (s) => s.updateAccessLevelResult, 'updateAccessLevelResult', isNotNull, ) .having( (s) => s.users.firstWhere((u) => u.email == email).accessLevel, 'vivian accessLevel', ShareAccessLevel.fullAccess, ), ], ); final guestEmail = 'guest@appflowy.io'; blocTest( 'turns user into member', build: () => bloc, act: (bloc) => bloc ..add( ShareTabEvent.inviteUsers( emails: [guestEmail], accessLevel: ShareAccessLevel.readOnly, ), ) ..add( ShareTabEvent.convertToMember( email: guestEmail, ), ), wait: const Duration(milliseconds: 100), expect: () => [ // First state: shareResult is null isA().having( (s) => s.shareResult, 'shareResult', isNull, ), // Second state: shareResult is Success and users updated isA() .having( (s) => s.shareResult, 'shareResult', isNotNull, ) .having( (s) => s.users.any((u) => u.email == guestEmail), 'users contains guest@appflowy.io', isTrue, ), // Third state: turnIntoMemberResult is Success and users updated isA() .having( (s) => s.turnIntoMemberResult, 'turnIntoMemberResult', isNotNull, ) .having( (s) => s.users.firstWhere((u) => u.email == guestEmail).role, 'guest@appflowy.io role', ShareRole.member, ), ], ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart ================================================ import 'dart:ffi'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; // ignore: depend_on_referenced_packages import 'package:mocktail/mocktail.dart'; class MockSettingsShortcutService extends Mock implements SettingsShortcutService {} void main() { group("ShortcutsCubit", () { late SettingsShortcutService service; late ShortcutsCubit shortcutsCubit; setUp(() async { service = MockSettingsShortcutService(); when( () => service.saveAllShortcuts(any()), ).thenAnswer((_) async => true); when( () => service.getCustomizeShortcuts(), ).thenAnswer((_) async => []); when( () => service.updateCommandShortcuts(any(), any()), ).thenAnswer((_) async => Void); shortcutsCubit = ShortcutsCubit(service); }); test('initial state is correct', () { final shortcutsCubit = ShortcutsCubit(service); expect(shortcutsCubit.state, const ShortcutsState()); }); group('fetchShortcuts', () { blocTest( 'calls getCustomizeShortcuts() once', build: () => shortcutsCubit, act: (cubit) => cubit.fetchShortcuts(), verify: (_) { verify(() => service.getCustomizeShortcuts()).called(1); }, ); blocTest( 'emits [updating, failure] when getCustomizeShortcuts() throws', setUp: () { when( () => service.getCustomizeShortcuts(), ).thenThrow(Exception('oops')); }, build: () => shortcutsCubit, act: (cubit) => cubit.fetchShortcuts(), expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() .having((w) => w.status, 'status', ShortcutsStatus.failure), ], ); blocTest( 'emits [updating, success] when getCustomizeShortcuts() returns shortcuts', build: () => shortcutsCubit, act: (cubit) => cubit.fetchShortcuts(), expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() .having((w) => w.status, 'status', ShortcutsStatus.success) .having( (w) => w.commandShortcutEvents, 'shortcuts', commandShortcutEvents, ), ], ); }); group('updateShortcut', () { blocTest( 'calls saveAllShortcuts() once', build: () => shortcutsCubit, act: (cubit) => cubit.updateAllShortcuts(), verify: (_) { verify(() => service.saveAllShortcuts(any())).called(1); }, ); blocTest( 'emits [updating, failure] when saveAllShortcuts() throws', setUp: () { when( () => service.saveAllShortcuts(any()), ).thenThrow(Exception('oops')); }, build: () => shortcutsCubit, act: (cubit) => cubit.updateAllShortcuts(), expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() .having((w) => w.status, 'status', ShortcutsStatus.failure), ], ); blocTest( 'emits [updating, success] when saveAllShortcuts() is successful', build: () => shortcutsCubit, act: (cubit) => cubit.updateAllShortcuts(), expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() .having((w) => w.status, 'status', ShortcutsStatus.success), ], ); }); group('resetToDefault', () { blocTest( 'calls saveAllShortcuts() once', build: () => shortcutsCubit, act: (cubit) => cubit.resetToDefault(), verify: (_) { verify(() => service.saveAllShortcuts(any())).called(1); verify(() => service.getCustomizeShortcuts()).called(1); }, ); blocTest( 'emits [updating, failure] when saveAllShortcuts() throws', setUp: () { when( () => service.saveAllShortcuts(any()), ).thenThrow(Exception('oops')); }, build: () => shortcutsCubit, act: (cubit) => cubit.resetToDefault(), expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() .having((w) => w.status, 'status', ShortcutsStatus.failure), ], ); blocTest( 'emits [updating, success] when getCustomizeShortcuts() returns shortcuts', build: () => shortcutsCubit, act: (cubit) => cubit.resetToDefault(), expect: () => [ const ShortcutsState(status: ShortcutsStatus.updating), isA() .having((w) => w.status, 'status', ShortcutsStatus.success) .having( (w) => w.commandShortcutEvents, 'shortcuts', commandShortcutEvents, ), ], ); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/view_selector_test.dart ================================================ import 'package:appflowy/ai/service/view_selector_cubit.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { ViewPB testView( String id, String name, ViewLayoutPB layout, [ bool isSpace = false, List children = const [], ]) { return ViewPB() ..id = id ..name = name ..layout = layout ..isSpace = isSpace ..childViews.addAll(children); } List createTestViews() { return [ testView('1', 'View 1', ViewLayoutPB.Document, true, [ testView('1-1', 'View 1-1', ViewLayoutPB.Document), testView('1-2', 'View 1-2', ViewLayoutPB.Document), ]), testView('2', 'View 2', ViewLayoutPB.Document, false, [ testView('2-1', 'View 2-1', ViewLayoutPB.Document), testView('2-2', 'View 2-2', ViewLayoutPB.Grid), testView('2-3', 'View 2-3', ViewLayoutPB.Document, false, [ testView('2-3-1', 'View 2-3-1', ViewLayoutPB.Document), ]), ]), testView('3', 'View 3', ViewLayoutPB.Document, true, [ testView('3-1', 'View 3-1', ViewLayoutPB.Grid, false, [ testView('3-1-1', 'View 3-1-1', ViewLayoutPB.Board), ]), ]), testView('4', 'View 4', ViewLayoutPB.Document, true, [ testView('4-1', 'View 4-1', ViewLayoutPB.Chat), testView('4-2', 'View 4-2', ViewLayoutPB.Document, false, [ testView('4-2-1', 'View 4-2-1', ViewLayoutPB.Document), testView('4-2-2', 'View 4-2-2', ViewLayoutPB.Document), ]), ]), ]; } Map getSelectedStatus( List items, ) { return { for (final item in items) item.view.id: item.selectedStatus, for (final item in items) if (item.children.isNotEmpty) ...getSelectedStatus(item.children), }; } group('ViewSelectorCubit test:', () { blocTest( 'initial state', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 1, ), act: (_) {}, verify: (cubit) { final s = cubit.state; expect(s.visibleSources, isEmpty); expect(s.selectedSources, isEmpty); }, ); blocTest( 'update sources', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 1, ), act: (cubit) async { final views = createTestViews(); await cubit.refreshSources(views, views.first); }, verify: (cubit) { final s = cubit.state; expect(s.visibleSources.length, 4); expect(s.visibleSources[0].isExpanded, isTrue); expect(s.visibleSources[0].children.length, 2); expect(s.visibleSources[1].children.length, 3); expect(s.visibleSources[2].children.length, 1); expect(s.visibleSources[3].children.length, 2); expect(s.selectedSources.isEmpty, isTrue); }, ); blocTest( 'update sources multiple times', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 1, ), act: (cubit) async { final views = createTestViews(); await cubit.refreshSources([], null); await cubit.refreshSources(views, null); await cubit.refreshSources([], null); }, expect: () => [ predicate( (s) => s.visibleSources.isEmpty && s.selectedSources.isEmpty, ), predicate( (s) => s.visibleSources.isNotEmpty && s.selectedSources.isEmpty, ), predicate( (s) => s.visibleSources.isEmpty && s.selectedSources.isEmpty, ), ], ); blocTest( 'update selected sources', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 100, ), act: (cubit) async { final views = createTestViews(); cubit.updateSelectedSources([ '2-3-1', '3-1', '3-1-1', '4-2', '4-2-1', ]); await cubit.refreshSources(views, null); }, skip: 1, expect: () => [ predicate((s) { final lengthCheck = s.visibleSources.length == 4 && s.selectedSources.length == 3; final expected = { '1': ViewSelectedStatus.unselected, '1-1': ViewSelectedStatus.unselected, '1-2': ViewSelectedStatus.unselected, '2': ViewSelectedStatus.partiallySelected, '2-1': ViewSelectedStatus.unselected, '2-2': ViewSelectedStatus.unselected, '2-3': ViewSelectedStatus.partiallySelected, '2-3-1': ViewSelectedStatus.selected, '3': ViewSelectedStatus.partiallySelected, '3-1': ViewSelectedStatus.selected, '3-1-1': ViewSelectedStatus.selected, '4': ViewSelectedStatus.partiallySelected, '4-1': ViewSelectedStatus.unselected, '4-2': ViewSelectedStatus.partiallySelected, '4-2-1': ViewSelectedStatus.selected, '4-2-2': ViewSelectedStatus.unselected, }; final actual = getSelectedStatus(s.visibleSources); return lengthCheck && mapEquals(expected, actual); }), ], ); blocTest( 'select a source 1', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 100, ), act: (cubit) async { final views = createTestViews(); await cubit.refreshSources(views, null); cubit.toggleSelectedStatus( cubit.state.visibleSources[1].children[2].children[0], // '2-3-1', false, ); }, skip: 1, expect: () => [ predicate((s) { return getSelectedStatus(s.visibleSources) .values .every((value) => value == ViewSelectedStatus.unselected); }), predicate((s) { final selectedStatusMap = getSelectedStatus(s.visibleSources); return selectedStatusMap['2-3'] == ViewSelectedStatus.partiallySelected && selectedStatusMap['2-3-1'] == ViewSelectedStatus.selected; }), ], ); blocTest( 'select a source 2', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 100, ), act: (cubit) async { final views = createTestViews(); await cubit.refreshSources(views, null); cubit.toggleSelectedStatus( cubit.state.visibleSources[1].children[2], // '2-3', false, ); cubit.toggleSelectedStatus( cubit.state.visibleSources[1].children[2].children[0], // '2-3-1', false, ); }, skip: 2, expect: () => [ predicate((s) { final selectedStatusMap = getSelectedStatus(s.visibleSources); return selectedStatusMap['2-3'] == ViewSelectedStatus.selected && selectedStatusMap['2-3-1'] == ViewSelectedStatus.selected; }), predicate((s) { final selectedStatusMap = getSelectedStatus(s.visibleSources); return selectedStatusMap['2-3'] == ViewSelectedStatus.partiallySelected && selectedStatusMap['2-3-1'] == ViewSelectedStatus.unselected; }), ], ); blocTest( 'select a source 3', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 100, ), act: (cubit) async { final views = createTestViews(); cubit.updateSelectedSources(['2-3', '2-3-1']); await cubit.refreshSources(views, null); cubit.toggleSelectedStatus( cubit.state.visibleSources[1].children[2], // '2-3', false, ); }, skip: 1, expect: () => [ predicate((s) { final selectedStatusMap = getSelectedStatus(s.visibleSources); return selectedStatusMap['2-3'] == ViewSelectedStatus.selected && selectedStatusMap['2-3-1'] == ViewSelectedStatus.selected; }), predicate((s) { final selectedStatusMap = getSelectedStatus(s.visibleSources); return selectedStatusMap['2-3'] == ViewSelectedStatus.unselected && selectedStatusMap['2-3-1'] == ViewSelectedStatus.unselected; }), ], ); blocTest( 'select a source 4', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 100, ), act: (cubit) async { final views = createTestViews(); cubit.updateSelectedSources(['4-2', '4-2-1']); await cubit.refreshSources(views, null); cubit.toggleSelectedStatus( cubit.state.visibleSources[3].children[1].children[1], // 4-2-2, false, ); }, skip: 1, expect: () => [ predicate((s) { final selectedStatusMap = getSelectedStatus(s.visibleSources); return selectedStatusMap['4-2'] == ViewSelectedStatus.partiallySelected && selectedStatusMap['4-2-1'] == ViewSelectedStatus.selected && selectedStatusMap['4-2-2'] == ViewSelectedStatus.unselected; }), predicate((s) { final selectedStatusMap = getSelectedStatus(s.visibleSources); return selectedStatusMap['4-2'] == ViewSelectedStatus.selected && selectedStatusMap['4-2-1'] == ViewSelectedStatus.selected && selectedStatusMap['4-2-2'] == ViewSelectedStatus.selected; }), ], ); blocTest( 'select a source 5', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 100, ), act: (cubit) async { final views = createTestViews(); cubit.updateSelectedSources(['4-2', '4-2-1']); await cubit.refreshSources(views, null); cubit.toggleSelectedStatus( cubit.state.visibleSources[3].children[1], // 4-2 false, ); }, verify: (cubit) { final selectedStatusMap = getSelectedStatus(cubit.state.visibleSources); expect(selectedStatusMap['4-2'], ViewSelectedStatus.unselected); expect(selectedStatusMap['4-2-1'], ViewSelectedStatus.unselected); expect(selectedStatusMap['4-2-2'], ViewSelectedStatus.unselected); }, ); blocTest( 'cannot select more than maximum selection limit', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 2, ), act: (cubit) async { final views = createTestViews(); cubit.updateSelectedSources(['1-1', '2-1']); await cubit.refreshSources(views, null); }, verify: (cubit) { final s = cubit.state; expect(s.visibleSources[0].children[0].isDisabled, isFalse); expect(s.visibleSources[0].children[1].isDisabled, isFalse); expect(s.visibleSources[1].children[0].isDisabled, isFalse); expect(s.visibleSources[1].children[1].isDisabled, isFalse); expect(s.visibleSources[2].children[0].isDisabled, isTrue); }, ); blocTest( 'filter sources correctly', build: () => ViewSelectorCubit( getIgnoreViewType: (_) => IgnoreViewType.none, maxSelectedParentPageCount: 1, ), act: (cubit) async { final views = createTestViews(); await cubit.refreshSources(views, null); cubit.filterTextController.text = 'View 1'; }, verify: (cubit) { final s = cubit.state; expect(s.visibleSources.length, 1); expect(s.visibleSources[0].children.length, 2); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/test/bloc_test/workspace_test/workspace_bloc_test.dart ================================================ import 'package:appflowy/features/workspace/data/repositories/workspace_repository.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:fixnum/fixnum.dart' as fixnum; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; class MockWorkspaceRepository extends Mock implements WorkspaceRepository {} class MockReminderBloc extends Mock implements ReminderBloc {} class FakeWorkspaceTypePB extends Fake implements WorkspaceTypePB {} class FakeReminderEvent extends Fake implements ReminderEvent {} Future mockIsBillingEnabled() async => false; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() { registerFallbackValue(FakeWorkspaceTypePB()); registerFallbackValue(FakeReminderEvent()); }); group('UserWorkspaceBloc', () { late MockWorkspaceRepository mockRepository; late MockReminderBloc mockReminderBloc; late UserProfilePB userProfile; UserWorkspaceBloc? bloc; setUp(() { mockRepository = MockWorkspaceRepository(); mockReminderBloc = MockReminderBloc(); userProfile = UserProfilePB() ..id = fixnum.Int64(123) ..name = 'Test User' ..email = 'test@example.com' ..userAuthType = AuthTypePB.Local; getIt.registerLazySingleton(() => mockReminderBloc); when(() => mockReminderBloc.add(any())).thenReturn(null); when(() => mockRepository.getCurrentWorkspace()).thenAnswer( (_) async => FlowyResult.failure( FlowyError()..code = ErrorCode.Internal, ), ); when(() => mockRepository.getWorkspaces()).thenAnswer( (_) async => FlowyResult.success([]), ); when( () => mockRepository.openWorkspace( workspaceId: any(named: 'workspaceId'), workspaceType: any(named: 'workspaceType'), ), ).thenAnswer( (_) async => FlowyResult.success(null), ); when( () => mockRepository.getWorkspaceSubscriptionInfo( workspaceId: any(named: 'workspaceId'), ), ).thenAnswer( (_) async => FlowyResult.success(WorkspaceSubscriptionInfoPB()), ); }); tearDown(() { if (bloc != null && !bloc!.isClosed) { bloc!.close(); } if (getIt.isRegistered()) { getIt.unregister(); } }); UserWorkspacePB createTestWorkspace({ required String id, required String name, String icon = '', WorkspaceTypePB workspaceType = WorkspaceTypePB.LocalW, int createdAt = 1000, }) { return UserWorkspacePB() ..workspaceId = id ..name = name ..icon = icon ..workspaceType = workspaceType ..createdAtTimestamp = fixnum.Int64(createdAt); } WorkspacePB createCurrentWorkspace({ required String id, required String name, int createdAt = 1000, }) { return WorkspacePB() ..id = id ..name = name ..createTime = fixnum.Int64(createdAt); } group('initial state', () { test('should have correct initial state', () { bloc = UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ); expect(bloc!.state.userProfile, equals(userProfile)); expect(bloc!.state.workspaces, isEmpty); expect(bloc!.state.currentWorkspace, isNull); expect(bloc!.state.actionResult, isNull); expect(bloc!.state.isCollabWorkspaceOn, isFalse); expect(bloc!.state.workspaceSubscriptionInfo, isNull); }); }); group('fetchWorkspaces', () { blocTest( 'should fetch workspaces successfully when current workspace exists in list', setUp: () { final currentWorkspace = createCurrentWorkspace( id: 'workspace-1', name: 'Workspace 1', ); final workspaces = [ createTestWorkspace(id: 'workspace-1', name: 'Workspace 1'), createTestWorkspace(id: 'workspace-2', name: 'Workspace 2'), ]; when(() => mockRepository.getCurrentWorkspace()).thenAnswer( (_) async => FlowyResult.success(currentWorkspace), ); when(() => mockRepository.getWorkspaces()).thenAnswer( (_) async => FlowyResult.success(workspaces), ); when(() => mockRepository.isBillingEnabled()).thenAnswer( (_) async => true, ); }, build: () => UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ), act: (bloc) => bloc.add(UserWorkspaceEvent.fetchWorkspaces()), expect: () => [ // First: workspaces are loaded predicate( (state) => state.workspaces.length == 2 && state.currentWorkspace == null, ), // Second: opening workspace action starts predicate( (state) => state.workspaces.length == 2 && state.actionResult?.actionType == WorkspaceActionType.open && state.actionResult?.isLoading == true, ), // Third: opening workspace action completes and currentWorkspace is set predicate( (state) => state.workspaces.length == 2 && state.currentWorkspace != null && state.currentWorkspace?.workspaceId == 'workspace-1' && state.actionResult?.isLoading == false, ), // Fourth: subscription info is fetched predicate( (state) => state.workspaces.length == 2 && state.currentWorkspace?.workspaceId == 'workspace-1' && state.workspaceSubscriptionInfo != null, ), ], verify: (bloc) { expect(bloc.state.workspaces.length, equals(2)); expect( bloc.state.workspaces.first.workspaceId, equals('workspace-1'), ); expect(bloc.state.workspaces.last.workspaceId, equals('workspace-2')); expect( bloc.state.currentWorkspace?.workspaceId, equals('workspace-1'), ); }, ); blocTest( 'should handle error when fetching current workspace fails but workspaces succeed', setUp: () { final workspaces = [ createTestWorkspace(id: 'workspace-1', name: 'Workspace 1'), createTestWorkspace(id: 'workspace-2', name: 'Workspace 2'), ]; when(() => mockRepository.getCurrentWorkspace()).thenAnswer( (_) async => FlowyResult.failure( FlowyError()..code = ErrorCode.Internal, ), ); when(() => mockRepository.getWorkspaces()).thenAnswer( (_) async => FlowyResult.success(workspaces), ); when(() => mockRepository.isBillingEnabled()).thenAnswer( (_) async => true, ); }, build: () => UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ), act: (bloc) => bloc.add(UserWorkspaceEvent.fetchWorkspaces()), expect: () => [ // First: workspaces are loaded, first workspace becomes current predicate( (state) => state.workspaces.length == 2 && state.currentWorkspace == null, ), // Second: opening workspace action starts predicate( (state) => state.workspaces.length == 2 && state.actionResult?.actionType == WorkspaceActionType.open && state.actionResult?.isLoading == true, ), // Third: opening workspace action completes and currentWorkspace is set predicate( (state) => state.workspaces.length == 2 && state.currentWorkspace != null && state.currentWorkspace?.workspaceId == 'workspace-1' && state.actionResult?.isLoading == false, ), // Fourth: subscription info is fetched predicate( (state) => state.workspaces.length == 2 && state.currentWorkspace?.workspaceId == 'workspace-1' && state.workspaceSubscriptionInfo != null, ), ], verify: (bloc) { expect(bloc.state.workspaces.length, equals(2)); expect( bloc.state.currentWorkspace?.workspaceId, equals('workspace-1'), ); }, ); blocTest( 'should handle error when fetching workspaces fails', setUp: () { when(() => mockRepository.getCurrentWorkspace()).thenAnswer( (_) async => FlowyResult.failure( FlowyError()..code = ErrorCode.Internal, ), ); when(() => mockRepository.getWorkspaces()).thenAnswer( (_) async => FlowyResult.failure( FlowyError()..code = ErrorCode.Internal, ), ); }, build: () => UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ), act: (bloc) => bloc.add(UserWorkspaceEvent.fetchWorkspaces()), verify: (bloc) { expect(bloc.state.workspaces, isEmpty); expect(bloc.state.currentWorkspace, isNull); verifyNever(() => mockReminderBloc.add(any())); }, ); }); group('createWorkspace', () { blocTest( 'should create workspace successfully', setUp: () { final newWorkspace = createTestWorkspace( id: 'new-workspace', name: 'New Workspace', ); when( () => mockRepository.createWorkspace( name: 'New Workspace', workspaceType: WorkspaceTypePB.LocalW, ), ).thenAnswer( (_) async => FlowyResult.success(newWorkspace), ); when(() => mockRepository.isBillingEnabled()).thenAnswer( (_) async => true, ); }, build: () => UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ), act: (bloc) => bloc.add( UserWorkspaceEvent.createWorkspace( name: 'New Workspace', workspaceType: WorkspaceTypePB.LocalW, ), ), expect: () => [ // First: create workspace action starts predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.create && state.actionResult?.isLoading == true, ), // Second: create workspace action completes, workspace is added predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.create && state.actionResult?.isLoading == false && state.actionResult?.result?.isSuccess == true && state.workspaces.isNotEmpty, ), // Third: opening workspace action starts predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.open && state.actionResult?.isLoading == true && state.workspaces.isNotEmpty, ), // Fourth: opening workspace action completes, currentWorkspace is set predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.open && state.actionResult?.isLoading == false && state.currentWorkspace != null && state.currentWorkspace?.workspaceId == 'new-workspace', ), // Fifth: subscription info is fetched predicate( (state) => state.currentWorkspace?.workspaceId == 'new-workspace' && state.workspaceSubscriptionInfo != null, ), ], verify: (bloc) { expect( bloc.state.workspaces.any((w) => w.workspaceId == 'new-workspace'), isTrue, ); }, ); blocTest( 'should handle error when creating workspace fails', setUp: () { when( () => mockRepository.createWorkspace( name: any(named: 'name'), workspaceType: any(named: 'workspaceType'), ), ).thenAnswer( (_) async => FlowyResult.failure( FlowyError()..code = ErrorCode.Internal, ), ); }, build: () => UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ), act: (bloc) => bloc.add( UserWorkspaceEvent.createWorkspace( name: 'New Workspace', workspaceType: WorkspaceTypePB.LocalW, ), ), expect: () => [ predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.create && state.actionResult?.isLoading == true, ), predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.create && state.actionResult?.isLoading == false && state.actionResult?.result?.isFailure == true, ), ], verify: (bloc) { verifyNever(() => mockReminderBloc.add(any())); }, ); }); group('deleteWorkspace', () { blocTest( 'should prevent deleting the only workspace', setUp: () { when(() => mockRepository.getCurrentWorkspace()).thenAnswer( (_) async => FlowyResult.success( createCurrentWorkspace(id: 'workspace-1', name: 'Workspace 1'), ), ); when(() => mockRepository.getWorkspaces()).thenAnswer( (_) async => FlowyResult.success([ createTestWorkspace(id: 'workspace-1', name: 'Workspace 1'), ]), ); }, build: () { final bloc = UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ); bloc.emit( bloc.state.copyWith( workspaces: [ createTestWorkspace(id: 'workspace-1', name: 'Workspace 1'), ], ), ); return bloc; }, act: (bloc) => bloc.add( UserWorkspaceEvent.deleteWorkspace(workspaceId: 'workspace-1'), ), expect: () => [ predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.delete && state.actionResult?.isLoading == true, ), predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.delete && state.actionResult?.isLoading == false && state.actionResult?.result?.isFailure == true, ), ], ); blocTest( 'should delete workspace successfully when more than one workspace exists', setUp: () { // Create a sequence of responses for getWorkspaces calls var callCount = 0; when( () => mockRepository.deleteWorkspace( workspaceId: any(named: 'workspaceId'), ), ).thenAnswer( (_) async => FlowyResult.success(null), ); when(() => mockRepository.getCurrentWorkspace()).thenAnswer( (_) async => FlowyResult.success( createCurrentWorkspace(id: 'workspace-1', name: 'Workspace 1'), ), ); // Return 2 workspaces on first call (for deletion validation) // Return 1 workspace on second call (after deletion) when(() => mockRepository.getWorkspaces()).thenAnswer( (_) async { callCount++; if (callCount == 1) { return FlowyResult.success([ createTestWorkspace(id: 'workspace-1', name: 'Workspace 1'), createTestWorkspace(id: 'workspace-2', name: 'Workspace 2'), ]); } else { return FlowyResult.success([ createTestWorkspace(id: 'workspace-1', name: 'Workspace 1'), ]); } }, ); when(() => mockRepository.isBillingEnabled()).thenAnswer( (_) async => true, ); }, build: () { final bloc = UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ); bloc.emit( bloc.state.copyWith( workspaces: [ createTestWorkspace(id: 'workspace-1', name: 'Workspace 1'), createTestWorkspace(id: 'workspace-2', name: 'Workspace 2'), ], currentWorkspace: createTestWorkspace( id: 'workspace-1', name: 'Workspace 1', ), ), ); return bloc; }, act: (bloc) => bloc.add( UserWorkspaceEvent.deleteWorkspace(workspaceId: 'workspace-2'), ), expect: () => [ predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.delete && state.actionResult?.isLoading == true, ), predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.delete && state.actionResult?.isLoading == false && state.actionResult?.result?.isSuccess == true, ), ], verify: (bloc) { expect(bloc.state.workspaces.length, equals(1)); expect( bloc.state.workspaces.any((w) => w.workspaceId == 'workspace-2'), isFalse, ); }, ); }); group('renameWorkspace', () { blocTest( 'should rename workspace successfully', setUp: () { when( () => mockRepository.renameWorkspace( workspaceId: 'workspace-1', name: 'Renamed Workspace', ), ).thenAnswer( (_) async => FlowyResult.success(null), ); }, build: () { final bloc = UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ); bloc.emit( bloc.state.copyWith( workspaces: [ createTestWorkspace(id: 'workspace-1', name: 'Original Name'), ], currentWorkspace: createTestWorkspace( id: 'workspace-1', name: 'Original Name', ), ), ); return bloc; }, act: (bloc) => bloc.add( UserWorkspaceEvent.renameWorkspace( workspaceId: 'workspace-1', name: 'Renamed Workspace', ), ), expect: () => [ predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.rename && state.actionResult?.isLoading == false && state.workspaces.first.name == 'Renamed Workspace' && state.currentWorkspace?.name == 'Renamed Workspace', ), ], ); blocTest( 'should handle error when renaming workspace fails', setUp: () { when( () => mockRepository.renameWorkspace( workspaceId: 'workspace-1', name: 'Renamed Workspace', ), ).thenAnswer( (_) async => FlowyResult.failure( FlowyError()..code = ErrorCode.Internal, ), ); }, build: () { final bloc = UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ); bloc.emit( bloc.state.copyWith( workspaces: [ createTestWorkspace(id: 'workspace-1', name: 'Original Name'), ], ), ); return bloc; }, act: (bloc) => bloc.add( UserWorkspaceEvent.renameWorkspace( workspaceId: 'workspace-1', name: 'Renamed Workspace', ), ), expect: () => [ predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.rename && state.actionResult?.isLoading == false && state.actionResult?.result?.isFailure == true && state.workspaces.first.name == 'Original Name', ), ], ); }); group('updateWorkspaceIcon', () { blocTest( 'should update workspace icon successfully', setUp: () { when( () => mockRepository.updateWorkspaceIcon( workspaceId: 'workspace-1', icon: '🚀', ), ).thenAnswer( (_) async => FlowyResult.success(null), ); }, build: () { final bloc = UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ); bloc.emit( bloc.state.copyWith( workspaces: [ createTestWorkspace(id: 'workspace-1', name: 'Workspace 1'), ], currentWorkspace: createTestWorkspace( id: 'workspace-1', name: 'Workspace 1', ), ), ); return bloc; }, act: (bloc) => bloc.add( UserWorkspaceEvent.updateWorkspaceIcon( workspaceId: 'workspace-1', icon: '🚀', ), ), expect: () => [ predicate( (state) => state.actionResult?.actionType == WorkspaceActionType.updateIcon && state.actionResult?.isLoading == false && state.workspaces.first.icon == '🚀' && state.currentWorkspace?.icon == '🚀', ), ], ); blocTest( 'should ignore updating to same icon', build: () { final bloc = UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ); bloc.emit( bloc.state.copyWith( workspaces: [ createTestWorkspace( id: 'workspace-1', name: 'Workspace 1', icon: '🚀', ), ], ), ); return bloc; }, act: (bloc) => bloc.add( UserWorkspaceEvent.updateWorkspaceIcon( workspaceId: 'workspace-1', icon: '🚀', ), ), expect: () => [], ); }); group('updateWorkspaceSubscriptionInfo', () { blocTest( 'should update subscription info', build: () => UserWorkspaceBloc( repository: mockRepository, userProfile: userProfile, ), act: (bloc) { final subscriptionInfo = WorkspaceSubscriptionInfoPB(); bloc.add( UserWorkspaceEvent.updateWorkspaceSubscriptionInfo( workspaceId: 'workspace-1', subscriptionInfo: subscriptionInfo, ), ); }, expect: () => [ predicate( (state) => state.workspaceSubscriptionInfo != null, ), ], ); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart ================================================ import 'package:appflowy/util/levenshtein.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('Levenshtein distance between identical strings', () { final distance = levenshtein('abc', 'abc'); expect(distance, 0); }); test('Levenshtein distance between strings of different lengths', () { final distance = levenshtein('kitten', 'sitting'); expect(distance, 3); }); test('Levenshtein distance between case-insensitive strings', () { final distance = levenshtein('Hello', 'hello', caseSensitive: false); expect(distance, 0); }); test('Levenshtein distance between strings with substitutions', () { final distance = levenshtein('kitten', 'smtten'); expect(distance, 2); }); test('Levenshtein distance between strings with deletions', () { final distance = levenshtein('kitten', 'kiten'); expect(distance, 1); }); test('Levenshtein distance between strings with insertions', () { final distance = levenshtein('kitten', 'kitxten'); expect(distance, 1); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart ================================================ import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/login_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/open_app_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('deep link handler: ', () { final deepLinkHandlerRegistry = DeepLinkHandlerRegistry.instance ..register(LoginDeepLinkHandler()) ..register(PaymentDeepLinkHandler()) ..register(InvitationDeepLinkHandler()) ..register(OpenAppDeepLinkHandler()); test('invitation deep link handler', () { final uri = Uri.parse( 'appflowy-flutter://invitation-callback?email=lucas@appflowy.com&workspace_id=123', ); deepLinkHandlerRegistry.processDeepLink( uri: uri, onStateChange: (handler, state) { expect(handler, isA()); }, onResult: (handler, result) { expect(handler, isA()); expect(result.isSuccess, true); }, onError: (error) { expect(error, isNull); }, ); }); test('login deep link handler', () { final uri = Uri.parse('appflowy-flutter://login-callback#access_token=123'); expect(LoginDeepLinkHandler().canHandle(uri), true); }); test('payment deep link handler', () { final uri = Uri.parse('appflowy-flutter://payment-success'); expect(PaymentDeepLinkHandler().canHandle(uri), true); }); test('unknown deep link handler', () { final uri = Uri.parse('appflowy-flutter://unknown-callback?workspace_id=123'); deepLinkHandlerRegistry.processDeepLink( uri: uri, onStateChange: (handler, state) {}, onResult: (handler, result) {}, onError: (error) { expect(error, isNotNull); }, ); }); test('open app deep link handler', () { final uri = Uri.parse('appflowy-flutter://'); expect(OpenAppDeepLinkHandler().canHandle(uri), true); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/document/document_diff/document_diff_test.dart ================================================ import 'package:appflowy/plugins/document/application/document_diff.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('document diff:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); const diff = DocumentDiff(); Node createNodeWithId({required String id, required String text}) { return Node( id: id, type: ParagraphBlockKeys.type, attributes: { ParagraphBlockKeys.delta: (Delta()..insert(text)).toJson(), }, ); } Future applyOperationAndVerifyDocument( Document before, Document after, List operations, ) async { final expected = after.toJson(); final editorState = EditorState(document: before); final transaction = editorState.transaction; for (final operation in operations) { transaction.add(operation); } await editorState.apply(transaction); expect(editorState.document.toJson(), expected); } test('no diff when the document is the same', () async { // create two nodes with the same id and texts final node1 = createNodeWithId(id: '1', text: 'Hello AppFlowy'); final node2 = createNodeWithId(id: '1', text: 'Hello AppFlowy'); final previous = Document.blank()..insert([0], [node1]); final next = Document.blank()..insert([0], [node2]); final operations = diff.diffDocument(previous, next); expect(operations, isEmpty); await applyOperationAndVerifyDocument(previous, next, operations); }); test('update text diff with the same id', () async { final node1 = createNodeWithId(id: '1', text: 'Hello AppFlowy'); final node2 = createNodeWithId(id: '1', text: 'Hello AppFlowy 2'); final previous = Document.blank()..insert([0], [node1]); final next = Document.blank()..insert([0], [node2]); final operations = diff.diffDocument(previous, next); expect(operations.length, 1); expect(operations[0], isA()); await applyOperationAndVerifyDocument(previous, next, operations); }); test('delete and insert text diff with different id', () async { final node1 = createNodeWithId(id: '1', text: 'Hello AppFlowy'); final node2 = createNodeWithId(id: '2', text: 'Hello AppFlowy 2'); final previous = Document.blank()..insert([0], [node1]); final next = Document.blank()..insert([0], [node2]); final operations = diff.diffDocument(previous, next); expect(operations.length, 2); expect(operations[0], isA()); expect(operations[1], isA()); await applyOperationAndVerifyDocument(previous, next, operations); }); test('insert single text diff', () async { final node1 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node21 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node22 = createNodeWithId( id: '2', text: 'Hello AppFlowy - Second line', ); final previous = Document.blank()..insert([0], [node1]); final next = Document.blank()..insert([0], [node21, node22]); final operations = diff.diffDocument(previous, next); expect(operations.length, 1); expect(operations[0], isA()); await applyOperationAndVerifyDocument(previous, next, operations); }); test('delete single text diff', () async { final node11 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node12 = createNodeWithId( id: '2', text: 'Hello AppFlowy - Second line', ); final node21 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final previous = Document.blank()..insert([0], [node11, node12]); final next = Document.blank()..insert([0], [node21]); final operations = diff.diffDocument(previous, next); expect(operations.length, 1); expect(operations[0], isA()); await applyOperationAndVerifyDocument(previous, next, operations); }); test('insert multiple texts diff', () async { final node11 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node15 = createNodeWithId( id: '5', text: 'Hello AppFlowy - Fifth line', ); final node21 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node22 = createNodeWithId( id: '2', text: 'Hello AppFlowy - Second line', ); final node23 = createNodeWithId( id: '3', text: 'Hello AppFlowy - Third line', ); final node24 = createNodeWithId( id: '4', text: 'Hello AppFlowy - Fourth line', ); final node25 = createNodeWithId( id: '5', text: 'Hello AppFlowy - Fifth line', ); final previous = Document.blank() ..insert( [0], [ node11, node15, ], ); final next = Document.blank() ..insert( [0], [ node21, node22, node23, node24, node25, ], ); final operations = diff.diffDocument(previous, next); expect(operations.length, 1); final op = operations[0] as InsertOperation; expect(op.path, [1]); expect(op.nodes, [node22, node23, node24]); await applyOperationAndVerifyDocument(previous, next, operations); }); test('delete multiple texts diff', () async { final node11 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node12 = createNodeWithId( id: '2', text: 'Hello AppFlowy - Second line', ); final node13 = createNodeWithId( id: '3', text: 'Hello AppFlowy - Third line', ); final node14 = createNodeWithId( id: '4', text: 'Hello AppFlowy - Fourth line', ); final node15 = createNodeWithId( id: '5', text: 'Hello AppFlowy - Fifth line', ); final node21 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node25 = createNodeWithId( id: '5', text: 'Hello AppFlowy - Fifth line', ); final previous = Document.blank() ..insert( [0], [ node11, node12, node13, node14, node15, ], ); final next = Document.blank() ..insert( [0], [ node21, node25, ], ); final operations = diff.diffDocument(previous, next); expect(operations.length, 1); final op = operations[0] as DeleteOperation; expect(op.path, [1]); expect(op.nodes, [node12, node13, node14]); await applyOperationAndVerifyDocument(previous, next, operations); }); test('multiple delete and update diff', () async { final node11 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node12 = createNodeWithId( id: '2', text: 'Hello AppFlowy - Second line', ); final node13 = createNodeWithId( id: '3', text: 'Hello AppFlowy - Third line', ); final node14 = createNodeWithId( id: '4', text: 'Hello AppFlowy - Fourth line', ); final node15 = createNodeWithId( id: '5', text: 'Hello AppFlowy - Fifth line', ); final node21 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node22 = createNodeWithId( id: '2', text: '', ); final node25 = createNodeWithId( id: '5', text: 'Hello AppFlowy - Fifth line', ); final previous = Document.blank() ..insert( [0], [ node11, node12, node13, node14, node15, ], ); final next = Document.blank() ..insert( [0], [ node21, node22, node25, ], ); final operations = diff.diffDocument(previous, next); expect(operations.length, 2); final op1 = operations[0] as UpdateOperation; expect(op1.path, [1]); expect(op1.attributes, node22.attributes); final op2 = operations[1] as DeleteOperation; expect(op2.path, [2]); expect(op2.nodes, [node13, node14]); await applyOperationAndVerifyDocument(previous, next, operations); }); test('multiple insert and update diff', () async { final node11 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node12 = createNodeWithId( id: '2', text: 'Hello AppFlowy - Second line', ); final node13 = createNodeWithId( id: '3', text: 'Hello AppFlowy - Third line', ); final node21 = createNodeWithId( id: '1', text: 'Hello AppFlowy - First line', ); final node22 = createNodeWithId( id: '2', text: 'Hello AppFlowy - Second line - Updated', ); final node23 = createNodeWithId( id: '3', text: 'Hello AppFlowy - Third line - Updated', ); final node24 = createNodeWithId( id: '4', text: 'Hello AppFlowy - Fourth line - Updated', ); final node25 = createNodeWithId( id: '5', text: 'Hello AppFlowy - Fifth line - Updated', ); final previous = Document.blank() ..insert( [0], [ node11, node12, node13, ], ); final next = Document.blank() ..insert( [0], [ node21, node22, node23, node24, node25, ], ); final operations = diff.diffDocument(previous, next); expect(operations.length, 3); final op1 = operations[0] as InsertOperation; expect(op1.path, [3]); expect(op1.nodes, [node24, node25]); final op2 = operations[1] as UpdateOperation; expect(op2.path, [1]); expect(op2.attributes, node22.attributes); final op3 = operations[2] as UpdateOperation; expect(op3.path, [2]); expect(op3.attributes, node23.attributes); await applyOperationAndVerifyDocument(previous, next, operations); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/document/html/_html_samples.dart ================================================ // | Month | Savings | // | -------- | ------- | // | January | $250 | // | February | $80 | // | March | $420 | const tableFromNotion = '''
Month Savings
January \$250
February \$80
March \$420
'''; // | Month | Savings | // | -------- | ------- | // | January | $250 | // | February | $80 | // | March | $420 | const tableFromGoogleDocs = '''

Month

Savings

January

\$250

February

\$80

March

\$420

'''; // | Month | Savings | // | -------- | ------- | // | January | $250 | // | February | $80 | // | March | $420 | const tableFromGoogleSheets = '''
Month Savings
January \$250
February \$80
March \$420
'''; // # The Benefits of a Balanced Diet // A balanced diet is crucial for maintaining overall health and well-being. It provides the necessary nutrients your body needs to function effectively, supports growth and development, and helps prevent chronic diseases. In this guide, we will explore the key benefits of a balanced diet and how it can improve your life. // --- // ## Key Components of a Balanced Diet // A balanced diet consists of various food groups, each providing essential nutrients. The main components include: // 1. **Carbohydrates** – Provide energy for daily activities. // 1. **Proteins** – Support growth, muscle repair, and immune function. // 1. **Fats** – Aid in cell function and energy storage. // 1. **Vitamins and Minerals** – Essential for immune function, bone health, and overall bodily processes. // 1. **Fiber** – Promotes healthy digestion and reduces the risk of chronic diseases. // 1. **Water** – Vital for hydration and proper bodily functions. // --- // ## Health Benefits of a Balanced Diet // Maintaining a balanced diet can have profound effects on your health. Below are some of the most significant benefits: // --- // ### 1. **Improved Heart Health** // A balanced diet rich in fruits, vegetables, and healthy fats helps lower cholesterol levels, reduce inflammation, and maintain a healthy blood pressure. // ### 2. **Better Weight Management** // By consuming nutrient-dense foods and avoiding overeating, you can achieve and maintain a healthy weight. // ### 3. **Enhanced Mental Health** // Proper nutrition supports brain function, which can improve mood, cognitive performance, and mental well-being. // ### 4. **Stronger Immune System** // A diet full of vitamins and minerals strengthens the immune system and helps the body fight off infections. // --- // ## Recommended Daily Nutrient Intake // Below is a table that outlines the recommended daily intake for adults based on the different food groups: // |Nutrient|Recommended Daily Intake|Example Foods| // |---|---|---| // |**Carbohydrates**|45-65% of total calories|Whole grains, fruits, vegetables| // |**Proteins**|10-35% of total calories|Lean meats, beans, legumes, nuts, dairy| // |**Fats**|20-35% of total calories|Olive oil, avocado, nuts, fatty fish| // |**Fiber**|25-30 grams|Whole grains, fruits, vegetables, legumes| // |**Vitamins & Minerals**|Varies (See below)|Fruits, vegetables, dairy, fortified cereals| // |**Water**|2-3 liters/day|Water, herbal teas, soups| // --- // ## Conclusion // Incorporating a variety of nutrient-rich foods into your diet is essential for maintaining your health. A balanced diet helps improve your physical and mental well-being, boosts energy levels, and reduces the risk of chronic conditions. By following the guidelines above, you can work toward achieving a healthier and happier life. const tableFromChatGPT = '''

The Benefits of a Balanced Diet

A balanced diet is crucial for maintaining overall health and well-being. It provides the necessary nutrients your body needs to function effectively, supports growth and development, and helps prevent chronic diseases. In this guide, we will explore the key benefits of a balanced diet and how it can improve your life.


Key Components of a Balanced Diet

A balanced diet consists of various food groups, each providing essential nutrients. The main components include:

  1. Carbohydrates – Provide energy for daily activities.
  2. Proteins – Support growth, muscle repair, and immune function.
  3. Fats – Aid in cell function and energy storage.
  4. Vitamins and Minerals – Essential for immune function, bone health, and overall bodily processes.
  5. Fiber – Promotes healthy digestion and reduces the risk of chronic diseases.
  6. Water – Vital for hydration and proper bodily functions.

Health Benefits of a Balanced Diet

Maintaining a balanced diet can have profound effects on your health. Below are some of the most significant benefits:


1. Improved Heart Health

A balanced diet rich in fruits, vegetables, and healthy fats helps lower cholesterol levels, reduce inflammation, and maintain a healthy blood pressure.

2. Better Weight Management

By consuming nutrient-dense foods and avoiding overeating, you can achieve and maintain a healthy weight.

3. Enhanced Mental Health

Proper nutrition supports brain function, which can improve mood, cognitive performance, and mental well-being.

4. Stronger Immune System

A diet full of vitamins and minerals strengthens the immune system and helps the body fight off infections.


Recommended Daily Nutrient Intake

Below is a table that outlines the recommended daily intake for adults based on the different food groups:

Nutrient Recommended Daily Intake Example Foods
Carbohydrates 45-65% of total calories Whole grains, fruits, vegetables
Proteins 10-35% of total calories Lean meats, beans, legumes, nuts, dairy
Fats 20-35% of total calories Olive oil, avocado, nuts, fatty fish
Fiber 25-30 grams Whole grains, fruits, vegetables, legumes
Vitamins & Minerals Varies (See below) Fruits, vegetables, dairy, fortified cereals
Water 2-3 liters/day Water, herbal teas, soups

Conclusion

Incorporating a variety of nutrient-rich foods into your diet is essential for maintaining your health. A balanced diet helps improve your physical and mental well-being, boosts energy levels, and reduces the risk of chronic conditions. By following the guidelines above, you can work toward achieving a healthier and happier life.

'''; // | Month | Savings | // | -------- | ------- | // | January | $250 | // | February | $80 | // | March | $420 | const tableFromAppleNotes = '''

Month

Savings

January

\$250

February

\$80

March

\$420

'''; ================================================ FILE: frontend/appflowy_flutter/test/unit_test/document/html/paste_from_html_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import '_html_samples.dart'; void main() { group('paste from html:', () { void checkTable(String html) { final nodes = EditorState.blank().convertHtmlToNodes(html); expect(nodes.length, 1); final table = nodes.first; expect(table.type, SimpleTableBlockKeys.type); expect(table.getCellText(0, 0), 'Month'); expect(table.getCellText(0, 1), 'Savings'); expect(table.getCellText(1, 0), 'January'); expect(table.getCellText(1, 1), '\$250'); expect(table.getCellText(2, 0), 'February'); expect(table.getCellText(2, 1), '\$80'); expect(table.getCellText(3, 0), 'March'); expect(table.getCellText(3, 1), '\$420'); } test('sample 1 - paste table from Notion', () { checkTable(tableFromNotion); }); test('sample 2 - paste table from Google Docs', () { checkTable(tableFromGoogleDocs); }); test('sample 3 - paste table from Google Sheets', () { checkTable(tableFromGoogleSheets); }); test('sample 4 - paste table from ChatGPT', () { final nodes = EditorState.blank().convertHtmlToNodes(tableFromChatGPT); final table = nodes.where((node) => node.type == SimpleTableBlockKeys.type).first; expect(table.columnLength, 3); expect(table.rowLength, 7); final dividers = nodes.where((node) => node.type == DividerBlockKeys.type); expect(dividers.length, 5); }); test('sample 5 - paste table from Apple Notes', () { checkTable(tableFromAppleNotes); }); }); } extension on Node { String getCellText( int row, int column, { int index = 0, }) { return children[row] .children[column] .children[index] .delta ?.toPlainText() ?? ''; } } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/document/option_menu/block_action_option_cubit_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('block action option cubit:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('delete blocks', () async { const text = 'paragraph'; final document = Document.blank() ..insert([ 0, ], [ paragraphNode(text: text), paragraphNode(text: text), paragraphNode(text: text), ]); final editorState = EditorState(document: document); final cubit = BlockActionOptionCubit( editorState: editorState, blockComponentBuilder: {}, ); editorState.selection = Selection( start: Position(path: [0]), end: Position(path: [2], offset: text.length), ); editorState.selectionType = SelectionType.block; await cubit.handleAction(OptionAction.delete, document.nodeAtPath([0])!); // all the nodes should be deleted expect(document.root.children, isEmpty); editorState.dispose(); }); test('duplicate blocks', () async { const text = 'paragraph'; final document = Document.blank() ..insert([ 0, ], [ paragraphNode(text: text), paragraphNode(text: text), paragraphNode(text: text), ]); final editorState = EditorState(document: document); final cubit = BlockActionOptionCubit( editorState: editorState, blockComponentBuilder: {}, ); editorState.selection = Selection( start: Position(path: [0]), end: Position(path: [2], offset: text.length), ); editorState.selectionType = SelectionType.block; await cubit.handleAction( OptionAction.duplicate, document.nodeAtPath([0])!, ); expect(document.root.children, hasLength(6)); editorState.dispose(); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/document/shortcuts/format_shortcut_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('format shortcut:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('turn = + > into ⇒', () async { final document = Document.blank() ..insert([ 0, ], [ paragraphNode(text: '='), ]); final editorState = EditorState(document: document); editorState.selection = Selection.collapsed( Position(path: [0], offset: 1), ); final result = await customFormatGreaterEqual.execute(editorState); expect(result, true); expect(editorState.document.root.children.length, 1); final node = editorState.document.root.children[0]; expect(node.delta!.toPlainText(), '⇒'); // use undo to revert the change undoCommand.execute(editorState); expect(editorState.document.root.children.length, 1); final nodeAfterUndo = editorState.document.root.children[0]; expect(nodeAfterUndo.delta!.toPlainText(), '=>'); editorState.dispose(); }); test('turn - + > into →', () async { final document = Document.blank() ..insert([ 0, ], [ paragraphNode(text: '-'), ]); final editorState = EditorState(document: document); editorState.selection = Selection.collapsed( Position(path: [0], offset: 1), ); final result = await customFormatDashGreater.execute(editorState); expect(result, true); expect(editorState.document.root.children.length, 1); final node = editorState.document.root.children[0]; expect(node.delta!.toPlainText(), '→'); // use undo to revert the change undoCommand.execute(editorState); expect(editorState.document.root.children.length, 1); final nodeAfterUndo = editorState.document.root.children[0]; expect(nodeAfterUndo.delta!.toPlainText(), '->'); editorState.dispose(); }); test('turn -- into —', () async { final document = Document.blank() ..insert([ 0, ], [ paragraphNode(text: '-'), ]); final editorState = EditorState(document: document); editorState.selection = Selection.collapsed( Position(path: [0], offset: 1), ); final result = await customFormatDoubleHyphenEmDash.execute(editorState); expect(result, true); expect(editorState.document.root.children.length, 1); final node = editorState.document.root.children[0]; expect(node.delta!.toPlainText(), '—'); // use undo to revert the change undoCommand.execute(editorState); expect(editorState.document.root.children.length, 1); final nodeAfterUndo = editorState.document.root.children[0]; expect(nodeAfterUndo.delta!.toPlainText(), '--'); editorState.dispose(); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/document/shortcuts/toggle_list_shortcut_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('toggle list shortcut:', () { Document createDocument(List nodes) { final document = Document.blank(); document.insert([0], nodes); return document; } setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); testWidgets('> + #', (tester) async { const heading1 = '>Heading 1'; const paragraph1 = 'paragraph 1'; const paragraph2 = 'paragraph 2'; final document = createDocument([ headingNode(level: 1, text: heading1), paragraphNode(text: paragraph1), paragraphNode(text: paragraph2), ]); final editorState = EditorState(document: document); editorState.selection = Selection.collapsed( Position(path: [0], offset: 1), ); final result = await formatGreaterToToggleList.execute(editorState); expect(result, true); expect(editorState.document.root.children.length, 1); final node = editorState.document.root.children[0]; expect(node.type, ToggleListBlockKeys.type); expect(node.attributes[ToggleListBlockKeys.level], 1); expect(node.delta!.toPlainText(), 'Heading 1'); expect(node.children.length, 2); expect(node.children[0].delta!.toPlainText(), paragraph1); expect(node.children[1].delta!.toPlainText(), paragraph2); editorState.dispose(); }); testWidgets('convert block contains children to toggle list', (tester) async { const paragraph1 = '>paragraph 1'; const paragraph1_1 = 'paragraph 1.1'; const paragraph1_2 = 'paragraph 1.2'; final document = createDocument([ paragraphNode( text: paragraph1, children: [ paragraphNode(text: paragraph1_1), paragraphNode(text: paragraph1_2), ], ), ]); final editorState = EditorState(document: document); editorState.selection = Selection.collapsed( Position(path: [0], offset: 1), ); final result = await formatGreaterToToggleList.execute(editorState); expect(result, true); expect(editorState.document.root.children.length, 1); final node = editorState.document.root.children[0]; expect(node.type, ToggleListBlockKeys.type); expect(node.delta!.toPlainText(), 'paragraph 1'); expect(node.children.length, 2); expect(node.children[0].delta!.toPlainText(), paragraph1_1); expect(node.children[1].delta!.toPlainText(), paragraph1_2); editorState.dispose(); }); testWidgets('press the enter key in empty toggle list', (tester) async { const text = 'AppFlowy'; final document = createDocument([ toggleListBlockNode(text: text, collapsed: true), ]); final editorState = EditorState(document: document); editorState.selection = Selection.collapsed( Position(path: [0], offset: text.length), ); // simulate the enter key press final result = await insertChildNodeInsideToggleList.execute(editorState); expect(result, true); final nodes = editorState.document.root.children; expect(nodes.length, 2); for (var i = 0; i < nodes.length; i++) { final node = nodes[i]; expect(node.type, ToggleListBlockKeys.type); } editorState.dispose(); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('markdown text robot:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); Future testLiveRefresh( List texts, { required void Function(EditorState) expect, }) async { final editorState = EditorState.blank(); editorState.selection = Selection.collapsed(Position(path: [0])); final markdownTextRobot = MarkdownTextRobot( editorState: editorState, ); markdownTextRobot.start(); for (final text in texts) { await markdownTextRobot.appendMarkdownText(text); // mock the delay of the text robot await Future.delayed(const Duration(milliseconds: 10)); } await markdownTextRobot.persist(); expect(editorState); } test('parse markdown text (1)', () async { final editorState = EditorState.blank(); editorState.selection = Selection.collapsed(Position(path: [0])); final markdownTextRobot = MarkdownTextRobot( editorState: editorState, ); markdownTextRobot.start(); await markdownTextRobot.appendMarkdownText(_sample1); await markdownTextRobot.persist(); final nodes = editorState.document.root.children; expect(nodes.length, 4); final n1 = nodes[0]; expect(n1.delta!.toPlainText(), 'The Curious Cat'); expect(n1.type, HeadingBlockKeys.type); final n2 = nodes[1]; expect(n2.type, ParagraphBlockKeys.type); expect(n2.delta!.toJson(), [ {'insert': 'Once upon a time in a '}, { 'insert': 'quiet village', 'attributes': {'bold': true}, }, {'insert': ', there lived a curious cat named '}, { 'insert': 'Whiskers', 'attributes': {'italic': true}, }, {'insert': '. Unlike other cats, Whiskers had a passion for '}, { 'insert': 'exploration', 'attributes': {'bold': true}, }, { 'insert': '. Every day, he\'d wander through the village, discovering hidden spots and making new friends with the local animals.', }, ]); final n3 = nodes[2]; expect(n3.type, ParagraphBlockKeys.type); expect(n3.delta!.toJson(), [ {'insert': 'One sunny morning, Whiskers stumbled upon a mysterious '}, { 'insert': 'wooden box', 'attributes': {'bold': true}, }, {'insert': ' behind the old barn. It was covered in '}, { 'insert': 'vines and dust', 'attributes': {'italic': true}, }, { 'insert': '. Intrigued, he nudged it open with his paw and found a collection of ancient maps. These maps led to secret trails around the village.', }, ]); final n4 = nodes[3]; expect(n4.type, ParagraphBlockKeys.type); expect(n4.delta!.toJson(), [ { 'insert': 'Whiskers became the village\'s hero, guiding everyone on exciting adventures.', }, ]); }); // Live refresh - Partial sample // ## The Decision // - Aria found an ancient map in her grandmother's attic. // - The map hinted at a mystical place known as the Enchanted Forest. // - Legends spoke of the forest as a realm where dreams came to life. test('live refresh (2)', () async { await testLiveRefresh( _liveRefreshSample2, expect: (editorState) { final nodes = editorState.document.root.children; expect(nodes.length, 4); final n1 = nodes[0]; expect(n1.type, HeadingBlockKeys.type); expect(n1.delta!.toPlainText(), 'The Decision'); final n2 = nodes[1]; expect(n2.type, BulletedListBlockKeys.type); expect( n2.delta!.toPlainText(), 'Aria found an ancient map in her grandmother\'s attic.', ); final n3 = nodes[2]; expect(n3.type, BulletedListBlockKeys.type); expect( n3.delta!.toPlainText(), 'The map hinted at a mystical place known as the Enchanted Forest.', ); final n4 = nodes[3]; expect(n4.type, BulletedListBlockKeys.type); expect( n4.delta!.toPlainText(), 'Legends spoke of the forest as a realm where dreams came to life.', ); }, ); }); // Partial sample // ## The Preparation // Before embarking on her journey, Aria prepared meticulously: // 1. Gather Supplies // - A sturdy backpack // - A compass and a map // - Provisions for the week // 2. Seek Guidance // - Visited the village elder for advice // - Listened to tales of past adventurers // 3. Sharpen Skills // - Practiced archery and swordsmanship // - Enhanced survival skills test('live refresh (3)', () async { await testLiveRefresh( _liveRefreshSample3, expect: (editorState) { final nodes = editorState.document.root.children; expect(nodes.length, 5); final n1 = nodes[0]; expect(n1.type, HeadingBlockKeys.type); expect(n1.delta!.toPlainText(), 'The Preparation'); final n2 = nodes[1]; expect(n2.type, ParagraphBlockKeys.type); expect( n2.delta!.toPlainText(), 'Before embarking on her journey, Aria prepared meticulously:', ); final n3 = nodes[2]; expect(n3.type, NumberedListBlockKeys.type); expect( n3.delta!.toPlainText(), 'Gather Supplies', ); final n3c1 = n3.children[0]; expect(n3c1.type, BulletedListBlockKeys.type); expect(n3c1.delta!.toPlainText(), 'A sturdy backpack'); final n3c2 = n3.children[1]; expect(n3c2.type, BulletedListBlockKeys.type); expect(n3c2.delta!.toPlainText(), 'A compass and a map'); final n3c3 = n3.children[2]; expect(n3c3.type, BulletedListBlockKeys.type); expect(n3c3.delta!.toPlainText(), 'Provisions for the week'); final n4 = nodes[3]; expect(n4.type, NumberedListBlockKeys.type); expect(n4.delta!.toPlainText(), 'Seek Guidance'); final n4c1 = n4.children[0]; expect(n4c1.type, BulletedListBlockKeys.type); expect( n4c1.delta!.toPlainText(), 'Visited the village elder for advice', ); final n4c2 = n4.children[1]; expect(n4c2.type, BulletedListBlockKeys.type); expect( n4c2.delta!.toPlainText(), 'Listened to tales of past adventurers', ); final n5 = nodes[4]; expect(n5.type, NumberedListBlockKeys.type); expect( n5.delta!.toPlainText(), 'Sharpen Skills', ); final n5c1 = n5.children[0]; expect(n5c1.type, BulletedListBlockKeys.type); expect( n5c1.delta!.toPlainText(), 'Practiced archery and swordsmanship', ); final n5c2 = n5.children[1]; expect(n5c2.type, BulletedListBlockKeys.type); expect( n5c2.delta!.toPlainText(), 'Enhanced survival skills', ); }, ); }); // Partial sample // Sure, let's provide an alternative Rust implementation for the Two Sum problem, focusing on clarity and efficiency but with a slightly different approach: // ```rust // fn two_sum(nums: &[i32], target: i32) -> Vec<(usize, usize)> { // let mut results = Vec::new(); // let mut map = std::collections::HashMap::new(); // // for (i, &num) in nums.iter().enumerate() { // let complement = target - num; // if let Some(&j) = map.get(&complement) { // results.push((j, i)); // } // map.insert(num, i); // } // // results // } // // fn main() { // let nums = vec![2, 7, 11, 15]; // let target = 9; // // let pairs = two_sum(&nums, target); // if pairs.is_empty() { // println!("No two sum solution found"); // } else { // for (i, j) in pairs { // println!("Indices: {}, {}", i, j); // } // } // } // ``` test('live refresh (4)', () async { await testLiveRefresh( _liveRefreshSample4, expect: (editorState) { final nodes = editorState.document.root.children; expect(nodes.length, 2); final n1 = nodes[0]; expect(n1.type, ParagraphBlockKeys.type); expect( n1.delta!.toPlainText(), '''Sure, let's provide an alternative Rust implementation for the Two Sum problem, focusing on clarity and efficiency but with a slightly different approach:''', ); final n2 = nodes[1]; expect(n2.type, CodeBlockKeys.type); expect( n2.delta!.toPlainText(), isNotEmpty, ); expect(n2.attributes[CodeBlockKeys.language], 'rust'); }, ); }); }); group('markdown text robot - replace in same line:', () { final text1 = '''The introduction of the World Wide Web in the early 1990s marked a turning point. '''; final text2 = '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. '''; final text3 = '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; Document buildTestDocument() { return Document( root: pageNode( children: [ paragraphNode(delta: Delta()..insert(text1 + text2 + text3)), ], ), ); } // 1. create a document with a paragraph node // 2. use the text robot to replace the selected content in the same line // 3. check the document test('the selection is in the middle of the text', () async { final document = buildTestDocument(); final editorState = EditorState(document: document); editorState.selection = Selection( start: Position( path: [0], offset: text1.length, ), end: Position( path: [0], offset: text1.length + text2.length, ), ); final markdownText = '''Tim Berners-Lee's invention of the **World Wide Web** transformed the internet, making it accessible to _non-technical users_ and opening the floodgates for global mass adoption.'''; final markdownTextRobot = MarkdownTextRobot( editorState: editorState, ); await markdownTextRobot.replace( selection: editorState.selection!, markdownText: markdownText, ); final afterDelta = editorState.document.root.children[0].delta!.toList(); expect(afterDelta.length, 5); final d1 = afterDelta[0] as TextInsert; expect(d1.text, '${text1}Tim Berners-Lee\'s invention of the '); expect(d1.attributes, null); final d2 = afterDelta[1] as TextInsert; expect(d2.text, 'World Wide Web'); expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); final d3 = afterDelta[2] as TextInsert; expect(d3.text, ' transformed the internet, making it accessible to '); expect(d3.attributes, null); final d4 = afterDelta[3] as TextInsert; expect(d4.text, 'non-technical users'); expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); final d5 = afterDelta[4] as TextInsert; expect( d5.text, ' and opening the floodgates for global mass adoption.$text3', ); expect(d5.attributes, null); }); test('replace markdown text with selection from start to middle', () async { final document = buildTestDocument(); final editorState = EditorState(document: document); editorState.selection = Selection( start: Position( path: [0], ), end: Position( path: [0], offset: text1.length, ), ); final markdownText = '''The **invention** of the _World Wide Web_ by Tim Berners-Lee transformed how we access information.'''; final markdownTextRobot = MarkdownTextRobot( editorState: editorState, ); await markdownTextRobot.replace( selection: editorState.selection!, markdownText: markdownText, ); final afterDelta = editorState.document.root.children[0].delta!.toList(); expect(afterDelta.length, 5); final d1 = afterDelta[0] as TextInsert; expect(d1.text, 'The '); expect(d1.attributes, null); final d2 = afterDelta[1] as TextInsert; expect(d2.text, 'invention'); expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); final d3 = afterDelta[2] as TextInsert; expect(d3.text, ' of the '); expect(d3.attributes, null); final d4 = afterDelta[3] as TextInsert; expect(d4.text, 'World Wide Web'); expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); final d5 = afterDelta[4] as TextInsert; expect( d5.text, ' by Tim Berners-Lee transformed how we access information.$text2$text3', ); expect(d5.attributes, null); }); test('replace markdown text with selection from middle to end', () async { final document = buildTestDocument(); final editorState = EditorState(document: document); editorState.selection = Selection( start: Position( path: [0], offset: text1.length + text2.length, ), end: Position( path: [0], offset: text1.length + text2.length + text3.length, ), ); final markdownText = '''**Email** became widespread, and instant messaging services like *ICQ* and **AOL Instant Messenger** gained tremendous popularity, allowing for seamless real-time text communication across the globe.'''; final markdownTextRobot = MarkdownTextRobot( editorState: editorState, ); await markdownTextRobot.replace( selection: editorState.selection!, markdownText: markdownText, ); final afterDelta = editorState.document.root.children[0].delta!.toList(); expect(afterDelta.length, 7); final d1 = afterDelta[0] as TextInsert; expect( d1.text, text1 + text2, ); expect(d1.attributes, null); final d2 = afterDelta[1] as TextInsert; expect(d2.text, 'Email'); expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); final d3 = afterDelta[2] as TextInsert; expect( d3.text, ' became widespread, and instant messaging services like ', ); expect(d3.attributes, null); final d4 = afterDelta[3] as TextInsert; expect(d4.text, 'ICQ'); expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); final d5 = afterDelta[4] as TextInsert; expect(d5.text, ' and '); expect(d5.attributes, null); final d6 = afterDelta[5] as TextInsert; expect( d6.text, 'AOL Instant Messenger', ); expect(d6.attributes, {AppFlowyRichTextKeys.bold: true}); final d7 = afterDelta[6] as TextInsert; expect( d7.text, ' gained tremendous popularity, allowing for seamless real-time text communication across the globe.', ); expect(d7.attributes, null); }); test('replace markdown text with selection from start to end', () async { final text1 = '''The introduction of the World Wide Web in the early 1990s marked a turning point.'''; final text2 = '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption.'''; final text3 = '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; final document = Document( root: pageNode( children: [ paragraphNode(delta: Delta()..insert(text1)), paragraphNode(delta: Delta()..insert(text2)), paragraphNode(delta: Delta()..insert(text3)), ], ), ); final editorState = EditorState(document: document); editorState.selection = Selection( start: Position(path: [0]), end: Position(path: [0], offset: text1.length), ); final markdownText = '''1. $text1 2. $text1 3. $text1'''; final markdownTextRobot = MarkdownTextRobot( editorState: editorState, ); await markdownTextRobot.replace( selection: editorState.selection!, markdownText: markdownText, ); final nodes = editorState.document.root.children; expect(nodes.length, 5); final d1 = nodes[0].delta!.toList()[0] as TextInsert; expect(d1.text, text1); expect(d1.attributes, null); expect(nodes[0].type, NumberedListBlockKeys.type); final d2 = nodes[1].delta!.toList()[0] as TextInsert; expect(d2.text, text1); expect(d2.attributes, null); expect(nodes[1].type, NumberedListBlockKeys.type); final d3 = nodes[2].delta!.toList()[0] as TextInsert; expect(d3.text, text1); expect(d3.attributes, null); expect(nodes[2].type, NumberedListBlockKeys.type); final d4 = nodes[3].delta!.toList()[0] as TextInsert; expect(d4.text, text2); expect(d4.attributes, null); final d5 = nodes[4].delta!.toList()[0] as TextInsert; expect(d5.text, text3); expect(d5.attributes, null); }); }); group('markdown text robot - replace in multiple lines:', () { final text1 = '''The introduction of the World Wide Web in the early 1990s marked a turning point. '''; final text2 = '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. '''; final text3 = '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; Document buildTestDocument() { return Document( root: pageNode( children: [ paragraphNode(delta: Delta()..insert(text1)), paragraphNode(delta: Delta()..insert(text2)), paragraphNode(delta: Delta()..insert(text3)), ], ), ); } // 1. create a document with 3 paragraph nodes // 2. use the text robot to replace the selected content in the multiple lines // 3. check the document test( 'the selection starts with the first paragraph and ends with the middle of second paragraph', () async { final document = buildTestDocument(); final editorState = EditorState(document: document); editorState.selection = Selection( start: Position( path: [0], ), end: Position( path: [1], offset: text2.length - ', opening the floodgates for mass adoption. '.length, ), ); final markdownText = '''The **introduction** of the World Wide Web in the *early 1990s* marked a significant turning point. Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users'''; final markdownTextRobot = MarkdownTextRobot( editorState: editorState, ); await markdownTextRobot.replace( selection: editorState.selection!, markdownText: markdownText, ); final afterNodes = editorState.document.root.children; expect(afterNodes.length, 3); { // first paragraph final delta1 = afterNodes[0].delta!.toList(); expect(delta1.length, 5); final d1 = delta1[0] as TextInsert; expect(d1.text, 'The '); expect(d1.attributes, null); final d2 = delta1[1] as TextInsert; expect(d2.text, 'introduction'); expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); final d3 = delta1[2] as TextInsert; expect(d3.text, ' of the World Wide Web in the '); expect(d3.attributes, null); final d4 = delta1[3] as TextInsert; expect(d4.text, 'early 1990s'); expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); final d5 = delta1[4] as TextInsert; expect(d5.text, ' marked a significant turning point.'); expect(d5.attributes, null); } { // second paragraph final delta2 = afterNodes[1].delta!.toList(); expect(delta2.length, 3); final d1 = delta2[0] as TextInsert; expect(d1.text, "Tim Berners-Lee's "); expect(d1.attributes, null); final d2 = delta2[1] as TextInsert; expect(d2.text, "revolutionary invention"); expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); final d3 = delta2[2] as TextInsert; expect( d3.text, " made the internet accessible to non-technical users, opening the floodgates for mass adoption. ", ); expect(d3.attributes, null); } { // third paragraph final delta3 = afterNodes[2].delta!.toList(); expect(delta3.length, 1); final d1 = delta3[0] as TextInsert; expect(d1.text, text3); expect(d1.attributes, null); } }); test( 'the selection starts with the middle of the first paragraph and ends with the middle of last paragraph', () async { final document = buildTestDocument(); final editorState = EditorState(document: document); editorState.selection = Selection( start: Position( path: [0], offset: 'The introduction of the World Wide Web'.length, ), end: Position( path: [2], offset: 'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity' .length, ), ); final markdownText = ''' in the **early 1990s** marked a *significant turning point* in technological history. Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users, opening the floodgates for *unprecedented mass adoption*. Email became **widely prevalent**, and instant messaging services like *ICQ* and *AOL Instant Messenger* gained tremendous popularity '''; final markdownTextRobot = MarkdownTextRobot( editorState: editorState, ); await markdownTextRobot.replace( selection: editorState.selection!, markdownText: markdownText, ); final afterNodes = editorState.document.root.children; expect(afterNodes.length, 3); { // first paragraph final delta1 = afterNodes[0].delta!.toList(); expect(delta1.length, 5); final d1 = delta1[0] as TextInsert; expect(d1.text, 'The introduction of the World Wide Web in the '); expect(d1.attributes, null); final d2 = delta1[1] as TextInsert; expect(d2.text, 'early 1990s'); expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); final d3 = delta1[2] as TextInsert; expect(d3.text, ' marked a '); expect(d3.attributes, null); final d4 = delta1[3] as TextInsert; expect(d4.text, 'significant turning point'); expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); final d5 = delta1[4] as TextInsert; expect(d5.text, ' in technological history.'); expect(d5.attributes, null); } { // second paragraph final delta2 = afterNodes[1].delta!.toList(); expect(delta2.length, 5); final d1 = delta2[0] as TextInsert; expect(d1.text, "Tim Berners-Lee's "); expect(d1.attributes, null); final d2 = delta2[1] as TextInsert; expect(d2.text, "revolutionary invention"); expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); final d3 = delta2[2] as TextInsert; expect( d3.text, " made the internet accessible to non-technical users, opening the floodgates for ", ); expect(d3.attributes, null); final d4 = delta2[3] as TextInsert; expect(d4.text, "unprecedented mass adoption"); expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); final d5 = delta2[4] as TextInsert; expect(d5.text, "."); expect(d5.attributes, null); } { // third paragraph // third paragraph final delta3 = afterNodes[2].delta!.toList(); expect(delta3.length, 7); final d1 = delta3[0] as TextInsert; expect(d1.text, "Email became "); expect(d1.attributes, null); final d2 = delta3[1] as TextInsert; expect(d2.text, "widely prevalent"); expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); final d3 = delta3[2] as TextInsert; expect(d3.text, ", and instant messaging services like "); expect(d3.attributes, null); final d4 = delta3[3] as TextInsert; expect(d4.text, "ICQ"); expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); final d5 = delta3[4] as TextInsert; expect(d5.text, " and "); expect(d5.attributes, null); final d6 = delta3[5] as TextInsert; expect(d6.text, "AOL Instant Messenger"); expect(d6.attributes, {AppFlowyRichTextKeys.italic: true}); final d7 = delta3[6] as TextInsert; expect( d7.text, " gained tremendous popularity, allowing for real-time text communication.", ); expect(d7.attributes, null); } }); test( 'the length of the returned response less than the length of the selected text', () async { final document = buildTestDocument(); final editorState = EditorState(document: document); editorState.selection = Selection( start: Position( path: [0], offset: 'The introduction of the World Wide Web'.length, ), end: Position( path: [2], offset: 'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity' .length, ), ); final markdownText = ''' in the **early 1990s** marked a *significant turning point* in technological history.'''; final markdownTextRobot = MarkdownTextRobot( editorState: editorState, ); await markdownTextRobot.replace( selection: editorState.selection!, markdownText: markdownText, ); final afterNodes = editorState.document.root.children; expect(afterNodes.length, 2); { // first paragraph final delta1 = afterNodes[0].delta!.toList(); expect(delta1.length, 5); final d1 = delta1[0] as TextInsert; expect(d1.text, "The introduction of the World Wide Web in the "); expect(d1.attributes, null); final d2 = delta1[1] as TextInsert; expect(d2.text, "early 1990s"); expect(d2.attributes, {AppFlowyRichTextKeys.bold: true}); final d3 = delta1[2] as TextInsert; expect(d3.text, " marked a "); expect(d3.attributes, null); final d4 = delta1[3] as TextInsert; expect(d4.text, "significant turning point"); expect(d4.attributes, {AppFlowyRichTextKeys.italic: true}); final d5 = delta1[4] as TextInsert; expect(d5.text, " in technological history."); expect(d5.attributes, null); } { // second paragraph final delta2 = afterNodes[1].delta!.toList(); expect(delta2.length, 1); final d1 = delta2[0] as TextInsert; expect(d1.text, ", allowing for real-time text communication."); expect(d1.attributes, null); } }); }); } const _sample1 = '''# The Curious Cat Once upon a time in a **quiet village**, there lived a curious cat named *Whiskers*. Unlike other cats, Whiskers had a passion for **exploration**. Every day, he'd wander through the village, discovering hidden spots and making new friends with the local animals. One sunny morning, Whiskers stumbled upon a mysterious **wooden box** behind the old barn. It was covered in _vines and dust_. Intrigued, he nudged it open with his paw and found a collection of ancient maps. These maps led to secret trails around the village. Whiskers became the village's hero, guiding everyone on exciting adventures.'''; const _liveRefreshSample2 = [ "##", " The", " Decision", "\n\n", "-", " Ar", "ia", " found", " an", " ancient map", " in her grandmother", "'s attic", ".\n", "-", " The map", " hinted at", " a", " mystical", " place", " known", " as", " the", " En", "ch", "anted", " Forest", ".\n", "-", " Legends", " spoke", " of", " the", " forest", " as", " a realm", " where dreams", " came", " to", " life", ".\n\n", ]; const _liveRefreshSample3 = [ "##", " The", " Preparation\n\n", "Before", " embarking", " on", " her", " journey", ", Aria prepared", " meticulously:\n\n", "1", ".", " **", "Gather", " Supplies**", " \n", " ", " -", " A", " sturdy", " backpack", "\n", " ", " -", " A", " compass", " and", " a map", "\n ", " -", " Pro", "visions", " for", " the", " week", "\n\n", "2", ".", " **", "Seek", " Guidance", "**", " \n", " ", " -", " Vis", "ited", " the", " village", " elder for advice", "\n", " -", " List", "ened", " to", " tales", " of past", " advent", "urers", "\n\n", "3", ".", " **", "Shar", "pen", " Skills", "**", " \n", " ", " -", " Pract", "iced", " arch", "ery", " and", " swordsmanship", "\n ", " -", " Enhanced", " survival skills", ]; const _liveRefreshSample4 = [ "Sure", ", let's", " provide an", " alternative Rust", " implementation for the Two", " Sum", " problem", ",", " focusing", " on", " clarity", " and efficiency", " but with", " a slightly", " different approach", ":\n\n", "```", "rust", "\nfn two", "_sum", "(nums", ": &[", "i", "32", "],", " target", ":", " i", "32", ")", " ->", " Vec", "<(usize", ", usize", ")>", " {\n", " ", " let", " mut results", " = Vec::", "new", "();\n", " ", " let mut", " map", " =", " std::collections", "::", "HashMap", "::", "new", "();\n\n ", " for (", "i,", " &num", ") in", " nums.iter", "().enumer", "ate()", " {\n let", " complement", " = target", " - num", ";\n", " ", " if", " let", " Some(&", "j)", " =", " map", ".get(&", "complement", ") {\n", " results", ".push((", "j", ",", " i));\n }\n", " ", " map", ".insert", "(num", ", i", ");\n", " ", " }\n\n ", " results\n", "}\n\n", "fn", " main()", " {\n", " ", " let", " nums", " =", " vec![2, ", "7", ",", " 11, 15];\n", " let", " target", " =", " ", "9", ";\n\n", " ", " let", " pairs", " = two", "_sum", "(&", "nums", ",", " target);\n", " ", " if", " pairs", ".is", "_empty()", " {\n ", " println", "!(\"", "No", " two", " sum solution", " found\");\n", " ", " }", " else {\n for", " (", "i", ", j", ") in", " pairs {\n", " println", "!(\"Indices", ":", " {},", " {}\",", " i", ",", " j", ");\n ", " }\n ", " }\n}\n", "```\n\n", ]; ================================================ FILE: frontend/appflowy_flutter/test/unit_test/document/text_robot/text_robot_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('text robot:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('auto insert text with sentence mode (1)', () async { final editorState = EditorState.blank(); editorState.selection = Selection.collapsed(Position(path: [0])); final textRobot = TextRobot( editorState: editorState, ); for (final text in _sample1) { await textRobot.autoInsertTextSync( text, separator: r'\n\n', inputType: TextRobotInputType.sentence, delay: Duration.zero, ); } final p1 = editorState.document.nodeAtPath([0])!.delta!.toPlainText(); final p2 = editorState.document.nodeAtPath([1])!.delta!.toPlainText(); final p3 = editorState.document.nodeAtPath([2])!.delta!.toPlainText(); expect( p1, 'In a quaint village nestled between rolling hills, a young girl named Elara discovered a hidden garden. She stumbled upon it while chasing a mischievous rabbit through a narrow, winding path. ', ); expect( p2, 'The garden was a vibrant oasis, brimming with colorful flowers and whispering trees. Elara felt an inexplicable connection to the place, as if it held secrets from a forgotten time. ', ); expect( p3, 'Determined to uncover its mysteries, she visited daily, unraveling tales of ancient magic and wisdom. The garden transformed her spirit, teaching her the importance of harmony and the beauty of nature\'s wonders.', ); }); test('auto insert text with sentence mode (2)', () async { final editorState = EditorState.blank(); editorState.selection = Selection.collapsed(Position(path: [0])); final textRobot = TextRobot( editorState: editorState, ); var breakCount = 0; for (final text in _sample2) { if (text.contains('\n\n')) { breakCount++; } await textRobot.autoInsertTextSync( text, separator: r'\n\n', inputType: TextRobotInputType.sentence, delay: Duration.zero, ); } final len = editorState.document.root.children.length; expect(len, breakCount + 1); expect(len, 7); final p1 = editorState.document.nodeAtPath([0])!.delta!.toPlainText(); final p2 = editorState.document.nodeAtPath([1])!.delta!.toPlainText(); final p3 = editorState.document.nodeAtPath([2])!.delta!.toPlainText(); final p4 = editorState.document.nodeAtPath([3])!.delta!.toPlainText(); final p5 = editorState.document.nodeAtPath([4])!.delta!.toPlainText(); final p6 = editorState.document.nodeAtPath([5])!.delta!.toPlainText(); final p7 = editorState.document.nodeAtPath([6])!.delta!.toPlainText(); expect( p1, 'Once upon a time in the small, whimsical village of Greenhollow, nestled between rolling hills and lush forests, there lived a young girl named Elara. Unlike the other villagers, Elara had a unique gift: she could communicate with animals. This extraordinary ability made her both a beloved and mysterious figure in Greenhollow.', ); expect( p2, 'One crisp autumn morning, as golden leaves danced in the breeze, Elara heard a distressed call from the forest. Following the sound, she discovered a young fox trapped in a hunter\'s snare. With gentle hands and a calming voice, she freed the frightened creature, who introduced himself as Rufus. Grateful for her help, Rufus promised to assist Elara whenever she needed.', ); expect( p3, 'Word of Elara\'s kindness spread among the forest animals, and soon she found herself surrounded by a diverse group of animal friends, from wise old owls to playful otters. Together, they shared stories, solved problems, and looked out for one another.', ); expect( p4, 'One day, the village faced an unexpected threat: a severe drought that threatened their crops and water supply. The villagers grew anxious, unsure of how to cope with the impending scarcity. Elara, determined to help, turned to her animal friends for guidance.', ); expect( p5, 'The animals led Elara to a hidden spring deep within the forest, a source of fresh water unknown to the villagers. With Rufus\'s clever planning and the otters\' help in directing the flow, they managed to channel the spring water to the village, saving the crops and quenching the villagers\' thirst.', ); expect( p6, 'Grateful and amazed, the villagers hailed Elara as a hero. They came to understand the importance of living harmoniously with nature and the wonders that could be achieved through kindness and cooperation.', ); expect( p7, 'From that day on, Greenhollow thrived as a community where humans and animals lived together in harmony, cherishing the bonds that Elara had helped forge. And whenever challenges arose, the villagers knew they could rely on Elara and her extraordinary friends to guide them through, ensuring that the spirit of unity and compassion always prevailed.', ); }); }); } final _sample1 = [ "In", " a quaint", " village", " nestled", " between", " rolling", " hills", ",", " a", " young", " girl", " named", " El", "ara discovered", " a hidden", " garden", ".", " She stumbled", " upon", " it", " while", " chasing", " a", " misch", "iev", "ous rabbit", " through", " a", " narrow,", " winding path", ".", " \n\n", "The", " garden", " was", " a", " vibrant", " oasis", ",", " br", "imming with", " colorful", " flowers", " and whisper", "ing", " trees", ".", " El", "ara", " felt", " an inexp", "licable", " connection", " to", " the", " place,", " as", " if", " it held", " secrets", " from", " a", " forgotten", " time", ".", " \n\n", "Determ", "ined to", " uncover", " its", " mysteries", ",", " she", " visited", " daily,", " unravel", "ing", " tales", " of", " ancient", " magic", " and", " wisdom", ".", " The", " garden transformed", " her", " spirit", ", teaching", " her the", " importance of harmony and", " the", " beauty", " of", " nature", "'s wonders.", ]; final _sample2 = [ "Once", " upon", " a", " time", " in", " the small", ",", " whimsical", " village", " of", " Green", "h", "ollow", ",", " nestled", " between", " rolling hills", " and", " lush", " forests", ",", " there", " lived", " a young", " girl", " named", " Elara.", " Unlike the", " other", " villagers", ",", " El", "ara", " had", " a unique", " gift", ":", " she could", " communicate", " with", " animals", ".", " This", " extraordinary", " ability", " made", " her both a", " beloved", " and", " mysterious", " figure", " in", " Green", "h", "ollow", ".\n\n", "One", " crisp", " autumn", " morning,", " as", " golden", " leaves", " danced", " in", " the", " breeze", ", El", "ara heard", " a distressed", " call", " from", " the", " forest", ".", " Following", " the", " sound", ",", " she", " discovered", " a", " young", " fox", " trapped", " in", " a", " hunter's", " snare", ".", " With", " gentle", " hands", " and", " a", " calming", " voice", ",", " she", " freed", " the", " frightened", " creature", ", who", " introduced", " himself", " as Ruf", "us.", " Gr", "ateful", " for", " her", " help", ",", " Rufus promised", " to assist", " Elara", " whenever", " she", " needed.\n\n", "Word", " of", " Elara", "'s kindness", " spread among", " the forest", " animals", ",", " and soon", " she", " found", " herself", " surrounded", " by", " a", " diverse", " group", " of", " animal", " friends", ",", " from", " wise", " old ow", "ls to playful", " ot", "ters.", " Together,", " they", " shared stories", ",", " solved problems", ",", " and", " looked", " out", " for", " one", " another", ".\n\n", "One", " day", ", the village faced", " an unexpected", " threat", ":", " a", " severe", " drought", " that", " threatened", " their", " crops", " and", " water supply", ".", " The", " villagers", " grew", " anxious", ",", " unsure", " of", " how to", " cope", " with", " the", " impending", " scarcity", ".", " El", "ara", ",", " determined", " to", " help", ",", " turned", " to her", " animal friends", " for", " guidance", ".\n\nThe", " animals", " led", " El", "ara", " to", " a", " hidden", " spring", " deep", " within", " the forest,", " a source", " of", " fresh", " water unknown", " to the", " villagers", ".", " With", " Ruf", "us's", " clever planning", " and the", " ot", "ters", "'", " help", " in directing", " the", " flow", ",", " they", " managed", " to", " channel the", " spring", " water", " to", " the", " village,", " saving the", " crops", " and", " quenching", " the", " villagers", "'", " thirst", ".\n\n", "Gr", "ateful and", " amazed,", " the", " villagers", " hailed El", "ara as", " a", " hero", ".", " They", " came", " to", " understand the", " importance", " of living", " harmon", "iously", " with", " nature", " and", " the", " wonders", " that", " could", " be", " achieved", " through kindness", " and cooperation", ".\n\nFrom", " that day", " on", ",", " Greenh", "ollow", " thr", "ived", " as", " a", " community", " where", " humans", " and", " animals", " lived together", " in", " harmony", ",", " cher", "ishing", " the", " bonds that", " El", "ara", " had", " helped", " forge", ".", " And whenever", " challenges arose", ", the", " villagers", " knew", " they", " could", " rely on", " El", "ara and", " her", " extraordinary", " friends", " to", " guide them", " through", ",", " ensuring", " that", " the", " spirit", " of", " unity", " and", " compassion", " always prevailed.", ]; ================================================ FILE: frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide quoteNode, QuoteBlockKeys; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('turn into:', () { Document createDocument(List nodes) { final document = Document.blank(); document.insert([0], nodes); return document; } Future checkTurnInto( Document document, String originalType, String originalText, { Selection? selection, String? toType, int? level, void Function(EditorState editorState, Node node)? afterTurnInto, }) async { final editorState = EditorState(document: document); final types = toType == null ? EditorOptionActionType.turnInto.supportTypes : [toType]; for (final type in types) { if (type == originalType || type == SubPageBlockKeys.type) { continue; } editorState.selectionType = SelectionType.block; editorState.selection = selection ?? Selection.collapsed(Position(path: [0])); final node = editorState.getNodeAtPath([0])!; expect(node.type, originalType); final result = await BlockActionOptionCubit.turnIntoBlock( type, node, editorState, level: level, ); expect(result, true); final newNode = editorState.getNodeAtPath([0])!; expect(newNode.type, type); expect(newNode.delta!.toPlainText(), originalText); afterTurnInto?.call( editorState, newNode, ); // turn it back the originalType for the next test editorState.selectionType = SelectionType.block; editorState.selection = selection ?? Selection.collapsed( Position(path: [0]), ); await BlockActionOptionCubit.turnIntoBlock( originalType, newNode, editorState, ); expect(result, true); } } setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('from heading to another blocks', () async { const text = 'Heading 1'; final document = createDocument([headingNode(level: 1, text: text)]); await checkTurnInto(document, HeadingBlockKeys.type, text); }); test('from paragraph to another blocks', () async { const text = 'Paragraph'; final document = createDocument([paragraphNode(text: text)]); await checkTurnInto( document, ParagraphBlockKeys.type, text, ); }); test('from quote list to another blocks', () async { const text = 'Quote'; final document = createDocument([ quoteNode( delta: Delta()..insert(text), ), ]); await checkTurnInto( document, QuoteBlockKeys.type, text, ); }); test('from todo list to another blocks', () async { const text = 'Todo'; final document = createDocument([ todoListNode( checked: false, text: text, ), ]); await checkTurnInto( document, TodoListBlockKeys.type, text, ); }); test('from bulleted list to another blocks', () async { const text = 'bulleted list'; final document = createDocument([ bulletedListNode( text: text, ), ]); await checkTurnInto( document, BulletedListBlockKeys.type, text, ); }); test('from numbered list to another blocks', () async { const text = 'numbered list'; final document = createDocument([ numberedListNode( delta: Delta()..insert(text), ), ]); await checkTurnInto( document, NumberedListBlockKeys.type, text, ); }); test('from callout to another blocks', () async { const text = 'callout'; final document = createDocument([ calloutNode( delta: Delta()..insert(text), ), ]); await checkTurnInto( document, CalloutBlockKeys.type, text, ); }); for (final type in [ HeadingBlockKeys.type, ]) { test('from nested bulleted list to $type', () async { const text = 'bulleted list'; const nestedText1 = 'nested bulleted list 1'; const nestedText2 = 'nested bulleted list 2'; const nestedText3 = 'nested bulleted list 3'; final document = createDocument([ bulletedListNode( text: text, children: [ bulletedListNode( text: nestedText1, ), bulletedListNode( text: nestedText2, ), bulletedListNode( text: nestedText3, ), ], ), ]); await checkTurnInto( document, BulletedListBlockKeys.type, text, toType: type, afterTurnInto: (editorState, node) { expect(node.type, type); expect(node.children.length, 0); expect(node.delta!.toPlainText(), text); expect(editorState.document.root.children.length, 4); expect( editorState.document.root.children[1].type, BulletedListBlockKeys.type, ); expect( editorState.document.root.children[1].delta!.toPlainText(), nestedText1, ); expect( editorState.document.root.children[2].type, BulletedListBlockKeys.type, ); expect( editorState.document.root.children[2].delta!.toPlainText(), nestedText2, ); expect( editorState.document.root.children[3].type, BulletedListBlockKeys.type, ); expect( editorState.document.root.children[3].delta!.toPlainText(), nestedText3, ); }, ); }); } for (final type in [ HeadingBlockKeys.type, ]) { test('from nested numbered list to $type', () async { const text = 'numbered list'; const nestedText1 = 'nested numbered list 1'; const nestedText2 = 'nested numbered list 2'; const nestedText3 = 'nested numbered list 3'; final document = createDocument([ numberedListNode( delta: Delta()..insert(text), children: [ numberedListNode( delta: Delta()..insert(nestedText1), ), numberedListNode( delta: Delta()..insert(nestedText2), ), numberedListNode( delta: Delta()..insert(nestedText3), ), ], ), ]); await checkTurnInto( document, NumberedListBlockKeys.type, text, toType: type, afterTurnInto: (editorState, node) { expect(node.type, type); expect(node.children.length, 0); expect(node.delta!.toPlainText(), text); expect(editorState.document.root.children.length, 4); expect( editorState.document.root.children[1].type, NumberedListBlockKeys.type, ); expect( editorState.document.root.children[1].delta!.toPlainText(), nestedText1, ); expect( editorState.document.root.children[2].type, NumberedListBlockKeys.type, ); expect( editorState.document.root.children[2].delta!.toPlainText(), nestedText2, ); expect( editorState.document.root.children[3].type, NumberedListBlockKeys.type, ); expect( editorState.document.root.children[3].delta!.toPlainText(), nestedText3, ); }, ); }); } for (final type in [ HeadingBlockKeys.type, ]) { // numbered list, bulleted list, todo list // before // - numbered list 1 // - nested list 1 // - bulleted list 2 // - nested list 2 // - todo list 3 // - nested list 3 // after // - heading 1 // - nested list 1 // - heading 2 // - nested list 2 // - heading 3 // - nested list 3 test('from nested mixed list to $type', () async { const text1 = 'numbered list 1'; const text2 = 'bulleted list 2'; const text3 = 'todo list 3'; const nestedText1 = 'nested list 1'; const nestedText2 = 'nested list 2'; const nestedText3 = 'nested list 3'; final document = createDocument([ numberedListNode( delta: Delta()..insert(text1), children: [ numberedListNode( delta: Delta()..insert(nestedText1), ), ], ), bulletedListNode( delta: Delta()..insert(text2), children: [ bulletedListNode( delta: Delta()..insert(nestedText2), ), ], ), todoListNode( checked: false, text: text3, children: [ todoListNode( checked: false, text: nestedText3, ), ], ), ]); await checkTurnInto( document, NumberedListBlockKeys.type, text1, toType: type, selection: Selection( start: Position(path: [0]), end: Position(path: [2]), ), afterTurnInto: (editorState, node) { final nodes = editorState.document.root.children; expect(nodes.length, 6); final texts = [ text1, nestedText1, text2, nestedText2, text3, nestedText3, ]; final types = [ type, NumberedListBlockKeys.type, type, BulletedListBlockKeys.type, type, TodoListBlockKeys.type, ]; for (var i = 0; i < 6; i++) { expect(nodes[i].type, types[i]); expect(nodes[i].children.length, 0); expect(nodes[i].delta!.toPlainText(), texts[i]); } }, ); }); } for (final type in [ ParagraphBlockKeys.type, BulletedListBlockKeys.type, NumberedListBlockKeys.type, TodoListBlockKeys.type, QuoteBlockKeys.type, CalloutBlockKeys.type, ]) { // numbered list, bulleted list, todo list // before // - numbered list 1 // - nested list 1 // - bulleted list 2 // - nested list 2 // - todo list 3 // - nested list 3 // after // - new_list_type // - nested list 1 // - new_list_type // - nested list 2 // - new_list_type // - nested list 3 test('from nested mixed list to $type', () async { const text1 = 'numbered list 1'; const text2 = 'bulleted list 2'; const text3 = 'todo list 3'; const nestedText1 = 'nested list 1'; const nestedText2 = 'nested list 2'; const nestedText3 = 'nested list 3'; final document = createDocument([ numberedListNode( delta: Delta()..insert(text1), children: [ numberedListNode( delta: Delta()..insert(nestedText1), ), ], ), bulletedListNode( delta: Delta()..insert(text2), children: [ bulletedListNode( delta: Delta()..insert(nestedText2), ), ], ), todoListNode( checked: false, text: text3, children: [ todoListNode( checked: false, text: nestedText3, ), ], ), ]); await checkTurnInto( document, NumberedListBlockKeys.type, text1, toType: type, selection: Selection( start: Position(path: [0]), end: Position(path: [2]), ), afterTurnInto: (editorState, node) { final nodes = editorState.document.root.children; expect(nodes.length, 3); final texts = [ text1, text2, text3, ]; final nestedTexts = [ nestedText1, nestedText2, nestedText3, ]; final types = [ NumberedListBlockKeys.type, BulletedListBlockKeys.type, TodoListBlockKeys.type, ]; for (var i = 0; i < 3; i++) { expect(nodes[i].type, type); expect(nodes[i].children.length, 1); expect(nodes[i].delta!.toPlainText(), texts[i]); expect(nodes[i].children[0].type, types[i]); expect(nodes[i].children[0].delta!.toPlainText(), nestedTexts[i]); } }, ); }); } test('undo, redo', () async { const text1 = 'numbered list 1'; const nestedText1 = 'nested list 1'; final document = createDocument([ numberedListNode( delta: Delta()..insert(text1), children: [ numberedListNode( delta: Delta()..insert(nestedText1), ), ], ), ]); await checkTurnInto( document, NumberedListBlockKeys.type, text1, toType: HeadingBlockKeys.type, afterTurnInto: (editorState, node) { expect(editorState.document.root.children.length, 2); editorState.selection = Selection.collapsed( Position(path: [0]), ); KeyEventResult result = undoCommand.execute(editorState); expect(result, KeyEventResult.handled); expect(editorState.document.root.children.length, 1); editorState.selection = Selection.collapsed( Position(path: [0]), ); result = redoCommand.execute(editorState); expect(result, KeyEventResult.handled); expect(editorState.document.root.children.length, 2); }, ); }); test('calculate selection when turn into', () { // Example: // - bulleted list item 1 // - bulleted list item 1-1 // - bulleted list item 1-2 // - bulleted list item 2 // - bulleted list item 2-1 // - bulleted list item 2-2 // - bulleted list item 3 // - bulleted list item 3-1 // - bulleted list item 3-2 const text = 'bulleted list'; const nestedText = 'nested bulleted list'; final document = createDocument([ bulletedListNode( text: '$text 1', children: [ bulletedListNode(text: '$nestedText 1-1'), bulletedListNode(text: '$nestedText 1-2'), ], ), bulletedListNode( text: '$text 2', children: [ bulletedListNode(text: '$nestedText 2-1'), bulletedListNode(text: '$nestedText 2-2'), ], ), bulletedListNode( text: '$text 3', children: [ bulletedListNode(text: '$nestedText 3-1'), bulletedListNode(text: '$nestedText 3-2'), ], ), ]); final editorState = EditorState(document: document); final cubit = BlockActionOptionCubit( editorState: editorState, blockComponentBuilder: {}, ); // case 1: collapsed selection and the selection is in the top level // and tap the turn into button at the [0] final selection1 = Selection.collapsed( Position(path: [0], offset: 1), ); expect( cubit.calculateTurnIntoSelection( editorState.getNodeAtPath([0])!, selection1, ), selection1, ); // case 2: collapsed selection and the selection is in the nested level // and tap the turn into button at the [0] final selection2 = Selection.collapsed( Position(path: [0, 0], offset: 1), ); expect( cubit.calculateTurnIntoSelection( editorState.getNodeAtPath([0])!, selection2, ), Selection.collapsed(Position(path: [0])), ); // case 3, collapsed selection and the selection is in the nested level // and tap the turn into button at the [0, 0] final selection3 = Selection.collapsed( Position(path: [0, 0], offset: 1), ); expect( cubit.calculateTurnIntoSelection( editorState.getNodeAtPath([0, 0])!, selection3, ), selection3, ); // case 4, not collapsed selection and the selection is in the top level // and tap the turn into button at the [0] final selection4 = Selection( start: Position(path: [0], offset: 1), end: Position(path: [1], offset: 1), ); expect( cubit.calculateTurnIntoSelection( editorState.getNodeAtPath([0])!, selection4, ), selection4, ); // case 5, not collapsed selection and the selection is in the nested level // and tap the turn into button at the [0] final selection5 = Selection( start: Position(path: [0, 0], offset: 1), end: Position(path: [0, 1], offset: 1), ); expect( cubit.calculateTurnIntoSelection( editorState.getNodeAtPath([0])!, selection5, ), Selection.collapsed(Position(path: [0])), ); // case 6, not collapsed selection and the selection is in the nested level // and tap the turn into button at the [0, 0] final selection6 = Selection( start: Position(path: [0, 0], offset: 1), end: Position(path: [0, 1], offset: 1), ); expect( cubit.calculateTurnIntoSelection( editorState.getNodeAtPath([0])!, selection6, ), Selection.collapsed(Position(path: [0])), ); // case 7, multiple blocks selection, and tap the turn into button of one of the selected nodes final selection7 = Selection( start: Position(path: [0], offset: 1), end: Position(path: [2], offset: 1), ); expect( cubit.calculateTurnIntoSelection( editorState.getNodeAtPath([1])!, selection7, ), selection7, ); // case 8, multiple blocks selection, and tap the turn into button of one of the non-selected nodes final selection8 = Selection( start: Position(path: [0], offset: 1), end: Position(path: [1], offset: 1), ); expect( cubit.calculateTurnIntoSelection( editorState.getNodeAtPath([2])!, selection8, ), Selection.collapsed(Position(path: [2])), ); }); group('turn into toggle list', () { const heading1 = 'heading 1'; const heading2 = 'heading 2'; const heading3 = 'heading 3'; const paragraph1 = 'paragraph 1'; const paragraph2 = 'paragraph 2'; const paragraph3 = 'paragraph 3'; test('turn heading 1 block to toggle heading 1 block', () async { // before // # Heading 1 // paragraph 1 // paragraph 2 // paragraph 3 // after // > # Heading 1 // paragraph 1 // paragraph 2 // paragraph 3 final document = createDocument([ headingNode(level: 1, text: heading1), paragraphNode(text: paragraph1), paragraphNode(text: paragraph2), paragraphNode(text: paragraph3), ]); await checkTurnInto( document, HeadingBlockKeys.type, heading1, selection: Selection.collapsed(Position(path: [0])), toType: ToggleListBlockKeys.type, level: 1, afterTurnInto: (editorState, node) { expect(editorState.document.root.children.length, 1); expect(node.type, ToggleListBlockKeys.type); expect(node.attributes[ToggleListBlockKeys.level], 1); expect(node.children.length, 3); for (var i = 0; i < 3; i++) { expect(node.children[i].type, ParagraphBlockKeys.type); expect( node.children[i].delta!.toPlainText(), [paragraph1, paragraph2, paragraph3][i], ); } // test undo together final result = undoCommand.execute(editorState); expect(result, KeyEventResult.handled); expect(editorState.document.root.children.length, 4); }, ); }); test('turn toggle heading 1 block to heading 1 block', () async { // before // > # Heading 1 // paragraph 1 // paragraph 2 // paragraph 3 // after // # Heading 1 // paragraph 1 // paragraph 2 // paragraph 3 final document = createDocument([ toggleHeadingNode( text: heading1, children: [ paragraphNode(text: paragraph1), paragraphNode(text: paragraph2), paragraphNode(text: paragraph3), ], ), ]); await checkTurnInto( document, ToggleListBlockKeys.type, heading1, selection: Selection.collapsed( Position(path: [0]), ), toType: HeadingBlockKeys.type, level: 1, afterTurnInto: (editorState, node) { expect(editorState.document.root.children.length, 4); expect(node.type, HeadingBlockKeys.type); expect(node.attributes[HeadingBlockKeys.level], 1); expect(node.children.length, 0); for (var i = 1; i <= 3; i++) { final node = editorState.getNodeAtPath([i])!; expect(node.type, ParagraphBlockKeys.type); expect( node.delta!.toPlainText(), [paragraph1, paragraph2, paragraph3][i - 1], ); } // test undo together editorState.selection = Selection.collapsed( Position(path: [0]), ); final result = undoCommand.execute(editorState); expect(result, KeyEventResult.handled); expect(editorState.document.root.children.length, 1); final afterNode = editorState.getNodeAtPath([0])!; expect(afterNode.type, ToggleListBlockKeys.type); expect(afterNode.attributes[ToggleListBlockKeys.level], 1); expect(afterNode.children.length, 3); }, ); }); test('turn heading 2 block to toggle heading 2 block - case 1', () async { // before // ## Heading 2 // paragraph 1 // ### Heading 3 // paragraph 2 // # Heading 1 <- the heading 1 block will not be converted // paragraph 3 // after // > ## Heading 2 // paragraph 1 // ## Heading 2 // paragraph 2 // # Heading 1 // paragraph 3 final document = createDocument([ headingNode(level: 2, text: heading2), paragraphNode(text: paragraph1), headingNode(level: 3, text: heading3), paragraphNode(text: paragraph2), headingNode(level: 1, text: heading1), paragraphNode(text: paragraph3), ]); await checkTurnInto( document, HeadingBlockKeys.type, heading2, selection: Selection.collapsed( Position(path: [0]), ), toType: ToggleListBlockKeys.type, level: 2, afterTurnInto: (editorState, node) { expect(editorState.document.root.children.length, 3); expect(node.type, ToggleListBlockKeys.type); expect(node.attributes[ToggleListBlockKeys.level], 2); expect(node.children.length, 3); expect(node.children[0].delta!.toPlainText(), paragraph1); expect(node.children[1].delta!.toPlainText(), heading3); expect(node.children[2].delta!.toPlainText(), paragraph2); // the heading 1 block will not be converted final heading1Node = editorState.getNodeAtPath([1])!; expect(heading1Node.type, HeadingBlockKeys.type); expect(heading1Node.attributes[HeadingBlockKeys.level], 1); expect(heading1Node.delta!.toPlainText(), heading1); final paragraph3Node = editorState.getNodeAtPath([2])!; expect(paragraph3Node.type, ParagraphBlockKeys.type); expect(paragraph3Node.delta!.toPlainText(), paragraph3); // test undo together final result = undoCommand.execute(editorState); expect(result, KeyEventResult.handled); expect(editorState.document.root.children.length, 6); }, ); }); test('turn heading 2 block to toggle heading 2 block - case 2', () async { // before // ## Heading 2 // paragraph 1 // ## Heading 2 <- the heading 2 block will not be converted // paragraph 2 // # Heading 1 <- the heading 1 block will not be converted // paragraph 3 // after // > ## Heading 2 // paragraph 1 // ## Heading 2 // paragraph 2 // # Heading 1 // paragraph 3 final document = createDocument([ headingNode(level: 2, text: heading2), paragraphNode(text: paragraph1), headingNode(level: 2, text: heading2), paragraphNode(text: paragraph2), headingNode(level: 1, text: heading1), paragraphNode(text: paragraph3), ]); await checkTurnInto( document, HeadingBlockKeys.type, heading2, selection: Selection.collapsed( Position(path: [0]), ), toType: ToggleListBlockKeys.type, level: 2, afterTurnInto: (editorState, node) { expect(editorState.document.root.children.length, 5); expect(node.type, ToggleListBlockKeys.type); expect(node.attributes[ToggleListBlockKeys.level], 2); expect(node.children.length, 1); expect(node.children[0].delta!.toPlainText(), paragraph1); final heading2Node = editorState.getNodeAtPath([1])!; expect(heading2Node.type, HeadingBlockKeys.type); expect(heading2Node.attributes[HeadingBlockKeys.level], 2); expect(heading2Node.delta!.toPlainText(), heading2); final paragraph2Node = editorState.getNodeAtPath([2])!; expect(paragraph2Node.type, ParagraphBlockKeys.type); expect(paragraph2Node.delta!.toPlainText(), paragraph2); // the heading 1 block will not be converted final heading1Node = editorState.getNodeAtPath([3])!; expect(heading1Node.type, HeadingBlockKeys.type); expect(heading1Node.attributes[HeadingBlockKeys.level], 1); expect(heading1Node.delta!.toPlainText(), heading1); final paragraph3Node = editorState.getNodeAtPath([4])!; expect(paragraph3Node.type, ParagraphBlockKeys.type); expect(paragraph3Node.delta!.toPlainText(), paragraph3); // test undo together final result = undoCommand.execute(editorState); expect(result, KeyEventResult.handled); expect(editorState.document.root.children.length, 6); }, ); }); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/editor/editor_drop_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:cross_file/cross_file.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../util.dart'; void main() { setUpAll(() async { await AppFlowyUnitTest.ensureInitialized(); }); group('drop images and files in EditorState', () { test('dropImages on same path as paragraph node ', () async { final editorState = EditorState( document: Document.blank(withInitialText: true), ); expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); const dropPath = [0]; const imagePath = 'assets/test/images/sample.jpeg'; final imageFile = XFile(imagePath); await editorState.dropImages(dropPath, [imageFile], 'documentId', true); final node = editorState.getNodeAtPath(dropPath); expect(node, isNotNull); expect(node!.type, CustomImageBlockKeys.type); expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type); }); test('dropImages should insert image node on empty path', () async { final editorState = EditorState( document: Document.blank(withInitialText: true), ); expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); const dropPath = [1]; const imagePath = 'assets/test/images/sample.jpeg'; final imageFile = XFile(imagePath); await editorState.dropImages(dropPath, [imageFile], 'documentId', true); final node = editorState.getNodeAtPath(dropPath); expect(node, isNotNull); expect(node!.type, CustomImageBlockKeys.type); expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); expect(editorState.getNodeAtPath([2]), null); }); test('dropFiles on same path as paragraph node ', () async { final editorState = EditorState( document: Document.blank(withInitialText: true), ); expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); const dropPath = [0]; const filePath = 'assets/test/images/sample.jpeg'; final file = XFile(filePath); await editorState.dropFiles(dropPath, [file], 'documentId', true); final node = editorState.getNodeAtPath(dropPath); expect(node, isNotNull); expect(node!.type, FileBlockKeys.type); expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type); }); test('dropFiles should insert file node on empty path', () async { final editorState = EditorState( document: Document.blank(withInitialText: true), ); const dropPath = [1]; const filePath = 'assets/test/images/sample.jpeg'; final file = XFile(filePath); await editorState.dropFiles(dropPath, [file], 'documentId', true); final node = editorState.getNodeAtPath(dropPath); expect(node, isNotNull); expect(node!.type, FileBlockKeys.type); expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); expect(editorState.getNodeAtPath([2]), null); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/editor/editor_migration_test.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('editor migration, from v0.1.x to 0.2', () { test('migrate readme', () async { final readme = await rootBundle.loadString('assets/template/readme.json'); final oldDocument = DocumentV0.fromJson(json.decode(readme)); final document = EditorMigration.migrateDocument(readme); expect(document.root.type, 'page'); expect(oldDocument.root.children.length, document.root.children.length); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart ================================================ import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; class MockDocumentAppearanceCubit extends Mock implements DocumentAppearanceCubit {} class MockBuildContext extends Mock implements BuildContext {} void main() { WidgetsFlutterBinding.ensureInitialized(); group('EditorStyleCustomizer', () { late EditorStyleCustomizer editorStyleCustomizer; late MockBuildContext mockBuildContext; setUp(() { mockBuildContext = MockBuildContext(); editorStyleCustomizer = EditorStyleCustomizer( context: mockBuildContext, padding: EdgeInsets.zero, ); }); test('baseTextStyle should return the expected TextStyle', () { const fontFamily = 'Roboto'; final result = editorStyleCustomizer.baseTextStyle(fontFamily); expect(result, isA()); expect(result.fontFamily, 'Roboto_regular'); }); test( 'baseTextStyle should return the null TextStyle when an exception occurs', () { const garbage = 'Garbage'; final result = editorStyleCustomizer.baseTextStyle(garbage); expect(result, isA()); expect( result.fontFamily, null, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/editor/file_block_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('FileBlock:', () { test('insert file block in non-empty paragraph', () async { final document = Document.blank() ..insert( [0], [paragraphNode(text: 'Hello World')], ); final editorState = EditorState(document: document); editorState.selection = Selection.collapsed(Position(path: [0])); // insert file block after the first line await editorState.insertEmptyFileBlock(GlobalKey()); final afterDocument = editorState.document; expect(afterDocument.root.children.length, 2); expect(afterDocument.root.children[1].type, FileBlockKeys.type); expect(afterDocument.root.children[0].type, ParagraphBlockKeys.type); expect( afterDocument.root.children[0].delta!.toPlainText(), 'Hello World', ); }); test('insert file block in empty paragraph', () async { final document = Document.blank() ..insert( [0], [paragraphNode(text: '')], ); final editorState = EditorState(document: document); editorState.selection = Selection.collapsed(Position(path: [0])); await editorState.insertEmptyFileBlock(GlobalKey()); final afterDocument = editorState.document; expect(afterDocument.root.children.length, 1); expect(afterDocument.root.children[0].type, FileBlockKeys.type); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/editor/share_markdown_test.dart ================================================ import 'dart:convert'; import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('share markdown', () { test('math equation', () { const text = ''' { "document":{ "type":"page", "children":[ { "type":"math_equation", "data":{ "formula":"E = MC^2" } } ] } } '''; final document = Document.fromJson( Map.from(json.decode(text)), ); final result = documentToMarkdown( document, customParsers: [ const MathEquationNodeParser(), ], ); expect(result, r'$$E = MC^2$$'); }); test('code block', () { const text = ''' { "document":{ "type":"page", "children":[ { "type":"code", "data":{ "delta": [ { "insert": "Some Code" } ] } } ] } } '''; final document = Document.fromJson( Map.from(json.decode(text)), ); final result = documentToMarkdown( document, customParsers: [ const CodeBlockNodeParser(), ], ); expect(result, '```\nSome Code\n```'); }); test('divider', () { const text = ''' { "document":{ "type":"page", "children":[ { "type":"divider" } ] } } '''; final document = Document.fromJson( Map.from(json.decode(text)), ); final result = documentToMarkdown( document, customParsers: [ const DividerNodeParser(), ], ); expect(result, '---\n'); }); test('callout', () { const text = ''' { "document":{ "type":"page", "children":[ { "type":"callout", "data":{ "icon": "😁", "delta": [ { "insert": "Callout" } ] } } ] } } '''; final document = Document.fromJson( Map.from(json.decode(text)), ); final result = documentToMarkdown( document, customParsers: [ const CalloutNodeParser(), ], ); expect(result, '''> 😁 > Callout '''); }); test('toggle list', () { const text = ''' { "document":{ "type":"page", "children":[ { "type":"toggle_list", "data":{ "delta": [ { "insert": "Toggle list" } ] } } ] } } '''; final document = Document.fromJson( Map.from(json.decode(text)), ); final result = documentToMarkdown( document, customParsers: [ const ToggleListNodeParser(), ], ); expect(result, '- Toggle list\n'); }); test('custom image', () { const image = 'https://images.unsplash.com/photo-1694984121999-36d30b67f391?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHwzfHx8ZW58MHx8fHx8&auto=format&fit=crop&w=800&q=60'; const text = ''' { "document":{ "type":"page", "children":[ { "type":"image", "data":{ "url": "$image" } } ] } } '''; final document = Document.fromJson( Map.from(json.decode(text)), ); final result = documentToMarkdown( document, customParsers: [ const CustomImageNodeParser(), ], ); expect( result, '![]($image)\n', ); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart ================================================ import 'dart:async'; import 'package:appflowy/plugins/document/application/document_service.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('TransactionAdapter:', () { test('toBlockAction insert node with children operation', () { final editorState = EditorState.blank(); final transaction = editorState.transaction; transaction.insertNode( [0], paragraphNode( children: [ paragraphNode(text: '1', children: [paragraphNode(text: '1.1')]), paragraphNode(text: '2'), paragraphNode(text: '3', children: [paragraphNode(text: '3.1')]), paragraphNode(text: '4'), ], ), ); expect(transaction.operations.length, 1); expect(transaction.operations[0] is InsertOperation, true); final actions = transaction.operations[0].toBlockAction(editorState, ''); expect(actions.length, 7); for (final action in actions) { expect(action.blockActionPB.action, BlockActionTypePB.Insert); } expect( actions[0].blockActionPB.payload.parentId, editorState.document.root.id, reason: '0 - parent id', ); expect( actions[0].blockActionPB.payload.prevId, '', reason: '0 - prev id', ); expect( actions[1].blockActionPB.payload.parentId, actions[0].blockActionPB.payload.block.id, reason: '1 - parent id', ); expect( actions[1].blockActionPB.payload.prevId, '', reason: '1 - prev id', ); expect( actions[2].blockActionPB.payload.parentId, actions[1].blockActionPB.payload.block.id, reason: '2 - parent id', ); expect( actions[2].blockActionPB.payload.prevId, '', reason: '2 - prev id', ); expect( actions[3].blockActionPB.payload.parentId, actions[0].blockActionPB.payload.block.id, reason: '3 - parent id', ); expect( actions[3].blockActionPB.payload.prevId, actions[1].blockActionPB.payload.block.id, reason: '3 - prev id', ); expect( actions[4].blockActionPB.payload.parentId, actions[0].blockActionPB.payload.block.id, reason: '4 - parent id', ); expect( actions[4].blockActionPB.payload.prevId, actions[3].blockActionPB.payload.block.id, reason: '4 - prev id', ); expect( actions[5].blockActionPB.payload.parentId, actions[4].blockActionPB.payload.block.id, reason: '5 - parent id', ); expect( actions[5].blockActionPB.payload.prevId, '', reason: '5 - prev id', ); expect( actions[6].blockActionPB.payload.parentId, actions[0].blockActionPB.payload.block.id, reason: '6 - parent id', ); expect( actions[6].blockActionPB.payload.prevId, actions[4].blockActionPB.payload.block.id, reason: '6 - prev id', ); }); test('toBlockAction insert node before all children nodes', () { final document = Document( root: Node( type: 'page', children: [ paragraphNode(children: [paragraphNode(text: '1')]), ], ), ); final editorState = EditorState(document: document); final transaction = editorState.transaction; transaction.insertNodes([0, 0], [paragraphNode(), paragraphNode()]); expect(transaction.operations.length, 1); expect(transaction.operations[0] is InsertOperation, true); final actions = transaction.operations[0].toBlockAction(editorState, ''); expect(actions.length, 2); for (final action in actions) { expect(action.blockActionPB.action, BlockActionTypePB.Insert); } expect( actions[0].blockActionPB.payload.parentId, editorState.document.root.children.first.id, reason: '0 - parent id', ); expect( actions[0].blockActionPB.payload.prevId, '', reason: '0 - prev id', ); expect( actions[1].blockActionPB.payload.parentId, editorState.document.root.children.first.id, reason: '1 - parent id', ); expect( actions[1].blockActionPB.payload.prevId, actions[0].blockActionPB.payload.block.id, reason: '1 - prev id', ); }); test('update the external id and external type', () async { // create a node without external id and external type // the editing this node, the adapter should generate a new action // to assign a new external id and external type. final node = bulletedListNode(text: 'Hello'); final document = Document( root: pageNode( children: [ node, ], ), ); final transactionAdapter = TransactionAdapter( documentId: '', documentService: DocumentService(), ); final editorState = EditorState( document: document, ); final completer = Completer(); editorState.transactionStream.listen((event) { final time = event.$1; if (time == TransactionTime.before) { final actions = transactionAdapter.transactionToBlockActions( event.$2, editorState, ); final textActions = transactionAdapter.filterTextDeltaActions(actions); final blockActions = transactionAdapter.filterBlockActions(actions); expect(textActions.length, 1); expect(blockActions.length, 1); // check text operation final textAction = textActions.first; final textId = textAction.textDeltaPayloadPB?.textId; { expect(textAction.textDeltaType, TextDeltaType.create); expect(textId, isNotEmpty); final delta = textAction.textDeltaPayloadPB?.delta; expect(delta, equals('[{"insert":"HelloWorld"}]')); } // check block operation { final blockAction = blockActions.first; expect(blockAction.action, BlockActionTypePB.Update); expect(blockAction.payload.block.id, node.id); expect( blockAction.payload.block.externalId, textId, ); expect(blockAction.payload.block.externalType, kExternalTextType); } } else if (time == TransactionTime.after) { completer.complete(); } }); await editorState.insertText( 5, 'World', node: node, ); await completer.future; }); test('use delta from prev attributes if current delta is null', () async { final node = todoListNode( checked: false, delta: Delta()..insert('AppFlowy'), ); final document = Document( root: pageNode( children: [ node, ], ), ); final transactionAdapter = TransactionAdapter( documentId: '', documentService: DocumentService(), ); final editorState = EditorState( document: document, ); final completer = Completer(); editorState.transactionStream.listen((event) { final time = event.$1; if (time == TransactionTime.before) { final actions = transactionAdapter.transactionToBlockActions( event.$2, editorState, ); final textActions = transactionAdapter.filterTextDeltaActions(actions); final blockActions = transactionAdapter.filterBlockActions(actions); expect(textActions.length, 1); expect(blockActions.length, 1); // check text operation final textAction = textActions.first; final textId = textAction.textDeltaPayloadPB?.textId; { expect(textAction.textDeltaType, TextDeltaType.create); expect(textId, isNotEmpty); final delta = textAction.textDeltaPayloadPB?.delta; expect(delta, equals('[{"insert":"AppFlowy"}]')); } // check block operation { final blockAction = blockActions.first; expect(blockAction.action, BlockActionTypePB.Update); expect(blockAction.payload.block.id, node.id); expect( blockAction.payload.block.externalId, textId, ); expect(blockAction.payload.block.externalType, kExternalTextType); } } else if (time == TransactionTime.after) { completer.complete(); } }); final transaction = editorState.transaction; transaction.updateNode(node, {TodoListBlockKeys.checked: true}); await editorState.apply(transaction); await completer.future; }); test('text retain with attributes that are false', () async { final node = paragraphNode( delta: Delta() ..insert( 'Hello AppFlowy', attributes: { 'bold': true, }, ), ); final document = Document( root: pageNode( children: [ node, ], ), ); final transactionAdapter = TransactionAdapter( documentId: '', documentService: DocumentService(), ); final editorState = EditorState( document: document, ); int counter = 0; final completer = Completer(); editorState.transactionStream.listen((event) { final time = event.$1; if (time == TransactionTime.before) { final actions = transactionAdapter.transactionToBlockActions( event.$2, editorState, ); final textActions = transactionAdapter.filterTextDeltaActions(actions); final blockActions = transactionAdapter.filterBlockActions(actions); expect(textActions.length, 1); expect(blockActions.length, 1); if (counter == 1) { // check text operation final textAction = textActions.first; final textId = textAction.textDeltaPayloadPB?.textId; { expect(textAction.textDeltaType, TextDeltaType.create); expect(textId, isNotEmpty); final delta = textAction.textDeltaPayloadPB?.delta; expect( delta, equals( '[{"insert":"Hello","attributes":{"bold":null}},{"insert":" AppFlowy","attributes":{"bold":true}}]', ), ); } } else if (counter == 3) { final textAction = textActions.first; final textId = textAction.textDeltaPayloadPB?.textId; { expect(textAction.textDeltaType, TextDeltaType.update); expect(textId, isNotEmpty); final delta = textAction.textDeltaPayloadPB?.delta; expect( delta, equals( '[{"retain":5,"attributes":{"bold":null}}]', ), ); } } } else if (time == TransactionTime.after && counter == 3) { completer.complete(); } }); counter = 1; final insertTransaction = editorState.transaction; insertTransaction.formatText(node, 0, 5, { 'bold': false, }); await editorState.apply(insertTransaction); counter = 2; final updateTransaction = editorState.transaction; updateTransaction.formatText(node, 0, 5, { 'bold': true, }); await editorState.apply(updateTransaction); counter = 3; final formatTransaction = editorState.transaction; formatTransaction.formatText(node, 0, 5, { 'bold': false, }); await editorState.apply(formatTransaction); await completer.future; }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/image/appflowy_network_image_test.dart ================================================ import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('AppFlowy Network Image:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test( 'retry count should be clear if the value exceeds max retries', () async { const maxRetries = 5; const fakeUrl = 'https://plus.unsplash.com/premium_photo-1731948132439'; final retryCounter = FlowyNetworkRetryCounter(); final tag = retryCounter.add(fakeUrl); for (var i = 0; i < maxRetries; i++) { retryCounter.increment(fakeUrl); expect(retryCounter.getRetryCount(fakeUrl), i + 1); } retryCounter.clear( tag: tag, url: fakeUrl, maxRetries: maxRetries, ); expect(retryCounter.getRetryCount(fakeUrl), 0); }, ); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; void main() async { test( 'description', () async { final links = [ 'https://www.baidu.com/', 'https://appflowy.io/', 'https://github.com/AppFlowy-IO/AppFlowy', 'https://github.com/', 'https://www.figma.com/design/3K0ai4FhDOJ3Lts8G3KOVP/Page?node-id=7282-4007&p=f&t=rpfvEvh9K9J9WkIo-0', 'https://www.figma.com/files/drafts', 'https://www.youtube.com/watch?v=LyY5Rh9qBvA', 'https://www.youtube.com/', 'https://www.youtube.com/watch?v=a6GDT7', 'http://www.test.com/', 'https://www.baidu.com/s?wd=test&rsv_spt=1&rsv_iqid=0xb6a7840b00e5324a&issp=1&f=8&rsv_bp=1&rsv_idx=2&ie=utf-8&tn=22073068_7_oem_dg&rsv_dl=tb&rsv_enter=1&rsv_sug3=5&rsv_sug1=4&rsv_sug7=100&rsv_sug2=0&rsv_btype=i&prefixsug=test&rsp=9&inputT=478&rsv_sug4=547', 'https://www.google.com/', 'https://www.google.com.hk/search?q=test&oq=test&gs_lcrp=EgZjaHJvbWUyCQgAEEUYORiABDIHCAEQABiABDIHCAIQABiABDIHCAMQABiABDIHCAQQABiABDIHCAUQABiABDIHCAYQABiABDIHCAcQABiABDIHCAgQLhiABDIHCAkQABiABNIBCTE4MDJqMGoxNagCCLACAfEFAQs7K9PprSfxBQELOyvT6a0n&sourceid=chrome&ie=UTF-8', 'www.baidu.com', 'baidu.com', 'com', 'https://www.baidu.com', 'https://github.com/AppFlowy-IO/AppFlowy', 'https://appflowy.com/app/c29fafc4-b7c0-4549-8702-71339b0fd9ea/59f36be8-9b2f-4d3e-b6a1-816c6c2043e5?blockId=GCY_T4', ]; final parser = DefaultParser(); int i = 1; for (final link in links) { final formatLink = LinkInfoParser.formatUrl(link); final siteInfo = await parser .parse(Uri.tryParse(formatLink) ?? Uri.parse(formatLink)); if (siteInfo?.isEmpty() ?? true) { debugPrint('$i : $formatLink ---- empty \n'); } else { debugPrint('$i : $formatLink ---- \n$siteInfo \n'); } i++; } }, timeout: const Timeout(Duration(seconds: 120)), ); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('export markdown to document', () { test('file block', () async { final document = Document.blank() ..insert( [0], [ fileNode( name: 'file.txt', url: 'https://file.com', ), ], ); final markdown = await customDocumentToMarkdown(document); expect(markdown, '[file.txt](https://file.com)\n'); }); test('link preview', () async { final document = Document.blank() ..insert( [0], [linkPreviewNode(url: 'https://www.link_preview.com')], ); final markdown = await customDocumentToMarkdown(document); expect( markdown, '[https://www.link_preview.com](https://www.link_preview.com)\n', ); }); test('multiple images', () async { const png1 = 'https://www.appflowy.png', png2 = 'https://www.appflowy2.png'; final document = Document.blank() ..insert( [0], [ multiImageNode( images: [ ImageBlockData( url: png1, type: CustomImageType.external, ), ImageBlockData( url: png2, type: CustomImageType.external, ), ], ), ], ); final markdown = await customDocumentToMarkdown(document); expect( markdown, '![]($png1)\n![]($png2)', ); }); test('subpage block', () async { const testSubpageId = 'testSubpageId'; final subpageNode = pageMentionNode(testSubpageId); final document = Document.blank() ..insert( [0], [subpageNode], ); final markdown = await customDocumentToMarkdown(document); expect( markdown, '[]($testSubpageId)\n', ); }); test('date or reminder', () async { final dateTime = DateTime.now(); final document = Document.blank() ..insert( [0], [dateMentionNode()], ); final markdown = await customDocumentToMarkdown(document); expect( markdown, '${DateFormat.yMMMd().format(dateTime)}\n', ); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/search/split_search_test.dart ================================================ import 'package:appflowy/mobile/presentation/search/mobile_search_cell.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('Test for searching text with split query', () { int checkLength(String query, List contents) { int i = 0; for (final content in contents) { if (content.toLowerCase() == query.toLowerCase()) { i++; } } return i; } test('split with space', () { final content = 'Hello HELLO hello HeLLo'; final query = 'Hello'; final contents = content .splitIncludeSeparator(query) .where((e) => e.isNotEmpty) .toList(); assert(contents.join() == content); assert(checkLength(query, contents) == 4); }); test('split without space', () { final content = 'HelloHELLOhelloHeLLo'; final query = 'Hello'; final contents = content .splitIncludeSeparator(query) .where((e) => e.isNotEmpty) .toList(); assert(contents.join() == content); assert(checkLength(query, contents) == 4); }); test('split without space and with error content', () { final content = 'HellHELLOhelloeLLo'; final query = 'Hello'; final contents = content .splitIncludeSeparator(query) .where((e) => e.isNotEmpty) .toList(); assert(contents.join() == content); assert(checkLength(query, contents) == 2); }); test('split with space and with error content', () { final content = 'Hell HELLOhello eLLo'; final query = 'Hello'; final contents = content .splitIncludeSeparator(query) .where((e) => e.isNotEmpty) .toList(); assert(contents.join() == content); assert(checkLength(query, contents) == 2); }); test('split without longer query', () { final content = 'Hello'; final query = 'HelloHelloHelloHello'; final contents = content .splitIncludeSeparator(query) .where((e) => e.isNotEmpty) .toList(); assert(contents.join() == content); assert(checkLength(query, contents) == 0); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/select_option_split_text_input.dart ================================================ import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_text_field.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { const textSeparators = [',']; group('split input unit test', () { test('empty input', () { var (submitted, remainder) = splitInput(' ', textSeparators); expect(submitted, []); expect(remainder, ''); (submitted, remainder) = splitInput(', , , ', textSeparators); expect(submitted, []); expect(remainder, ''); }); test('simple input', () { var (submitted, remainder) = splitInput('exampleTag', textSeparators); expect(submitted, []); expect(remainder, 'exampleTag'); (submitted, remainder) = splitInput('tag with longer name', textSeparators); expect(submitted, []); expect(remainder, 'tag with longer name'); (submitted, remainder) = splitInput('trailing space ', textSeparators); expect(submitted, []); expect(remainder, 'trailing space '); }); test('input with commas', () { var (submitted, remainder) = splitInput('a, b, c', textSeparators); expect(submitted, ['a', 'b']); expect(remainder, 'c'); (submitted, remainder) = splitInput('a, b, c, ', textSeparators); expect(submitted, ['a', 'b', 'c']); expect(remainder, ''); (submitted, remainder) = splitInput(',tag 1 ,2nd tag, third tag ', textSeparators); expect(submitted, ['tag 1', '2nd tag']); expect(remainder, 'third tag '); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/settings/shortcuts/settings_shortcut_service_test.dart ================================================ import 'dart:convert'; import 'dart:io' show File; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/shortcuts_model.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; // ignore: depend_on_referenced_packages import 'package:file/memory.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { late SettingsShortcutService service; late File mockFile; String shortcutsJson = ''; setUp(() async { final MemoryFileSystem fileSystem = MemoryFileSystem.test(); mockFile = await fileSystem.file("shortcuts.json").create(recursive: true); service = SettingsShortcutService(file: mockFile); shortcutsJson = """{ "commandShortcuts":[ { "key":"move the cursor upward", "command":"alt+arrow up" }, { "key":"move the cursor backward one character", "command":"alt+arrow left" }, { "key":"move the cursor downward", "command":"alt+arrow down" } ] }"""; }); group("Settings Shortcut Service", () { test( "returns default standard shortcuts if file is empty", () async { expect(await service.getCustomizeShortcuts(), []); }, ); test('returns updated shortcut event list from json', () { final commandShortcuts = service.getShortcutsFromJson(shortcutsJson); final cursorUpShortcut = commandShortcuts .firstWhere((el) => el.key == "move the cursor upward"); final cursorDownShortcut = commandShortcuts .firstWhere((el) => el.key == "move the cursor downward"); expect( commandShortcuts.length, 3, ); expect(cursorUpShortcut.command, "alt+arrow up"); expect(cursorDownShortcut.command, "alt+arrow down"); }); test( "saveAllShortcuts saves shortcuts", () async { //updating one of standard command shortcut events. final currentCommandShortcuts = standardCommandShortcutEvents; const kKey = "scroll one page down"; const oldCommand = "page down"; const newCommand = "alt+page down"; final commandShortcutEvent = currentCommandShortcuts .firstWhere((element) => element.key == kKey); expect(commandShortcutEvent.command, oldCommand); //updating the command. commandShortcutEvent.updateCommand( command: newCommand, ); //saving the updated shortcuts await service.saveAllShortcuts(currentCommandShortcuts); //reading from the mock file the saved shortcut list. final savedDataInFile = await mockFile.readAsString(); //Check if the lists where properly converted to JSON and saved. final shortcuts = EditorShortcuts( commandShortcuts: currentCommandShortcuts.toCommandShortcutModelList(), ); expect(jsonEncode(shortcuts.toJson()), savedDataInFile); //now checking if the modified command of "move the cursor upward" is "arrow up" final newCommandShortcuts = service.getShortcutsFromJson(savedDataInFile); final updatedCommandEvent = newCommandShortcuts.firstWhere((el) => el.key == kKey); expect(updatedCommandEvent.command, newCommand); }, ); test('load shortcuts from file', () async { //updating one of standard command shortcut event. const kKey = "scroll one page up"; const oldCommand = "page up"; const newCommand = "alt+page up"; final currentCommandShortcuts = standardCommandShortcutEvents; final commandShortcutEvent = currentCommandShortcuts.firstWhere((element) => element.key == kKey); expect(commandShortcutEvent.command, oldCommand); //updating the command. commandShortcutEvent.updateCommand(command: newCommand); //saving the updated shortcuts await service.saveAllShortcuts(currentCommandShortcuts); //now directly fetching the shortcuts from loadShortcuts final commandShortcuts = await service.getCustomizeShortcuts(); expect( commandShortcuts, currentCommandShortcuts.toCommandShortcutModelList(), ); final updatedCommandEvent = commandShortcuts.firstWhere((el) => el.key == kKey); expect(updatedCommandEvent.command, newCommand); }); test('updateCommandShortcuts works properly', () async { //updating one of standard command shortcut event. const kKey = "move the cursor backward one character"; const oldCommand = "arrow left"; const newCommand = "alt+arrow left"; final currentCommandShortcuts = standardCommandShortcutEvents; //check if the current shortcut event's key is set to old command. final currentCommandEvent = currentCommandShortcuts.firstWhere((el) => el.key == kKey); expect(currentCommandEvent.command, oldCommand); final commandShortcutModelList = EditorShortcuts.fromJson(jsonDecode(shortcutsJson)).commandShortcuts; //now calling the updateCommandShortcuts method await service.updateCommandShortcuts( currentCommandShortcuts, commandShortcutModelList, ); //check if the shortcut event's key is updated. final updatedCommandEvent = currentCommandShortcuts.firstWhere((el) => el.key == kKey); expect(updatedCommandEvent.command, newCommand); }); }); } extension on List { List toCommandShortcutModelList() => map((e) => CommandShortcutModel.fromCommandEvent(e)).toList(); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/settings/theme_missing_keys_test.dart ================================================ import 'package:flowy_infra/colorscheme/colorscheme.dart'; import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('Theme missing keys', () { test('no missing keys', () { const colorScheme = DefaultColorScheme.light(); final toJson = colorScheme.toJson(); expect(toJson.containsKey('surface'), true); final missingKeys = FlowyColorScheme.getMissingKeys(toJson); expect(missingKeys.isEmpty, true); }); test('missing surface and bg2', () { const colorScheme = DefaultColorScheme.light(); final toJson = colorScheme.toJson() ..remove('surface') ..remove('bg2'); expect(toJson.containsKey('surface'), false); expect(toJson.containsKey('bg2'), false); final missingKeys = FlowyColorScheme.getMissingKeys(toJson); expect(missingKeys.length, 2); expect(missingKeys.contains('surface'), true); expect(missingKeys.contains('bg2'), true); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_contente_operation_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter_test/flutter_test.dart'; import 'simple_table_test_helper.dart'; void main() { group('Simple table content operation:', () { void setupDependencyInjection() { getIt.registerSingleton(ClipboardService()); } setUpAll(() { Log.shared.disableLog = true; setupDependencyInjection(); }); tearDownAll(() { Log.shared.disableLog = false; }); test('clear content at row 1', () async { const defaultContent = 'default content'; final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, defaultContent: defaultContent, ); await editorState.clearContentAtRowIndex( tableNode: tableNode, rowIndex: 0, ); for (var i = 0; i < tableNode.rowLength; i++) { for (var j = 0; j < tableNode.columnLength; j++) { expect( tableNode .getTableCellNode(rowIndex: i, columnIndex: j) ?.children .first .delta ?.toPlainText(), i == 0 ? '' : defaultContent, ); } } }); test('clear content at row 3', () async { const defaultContent = 'default content'; final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, defaultContent: defaultContent, ); await editorState.clearContentAtRowIndex( tableNode: tableNode, rowIndex: 2, ); for (var i = 0; i < tableNode.rowLength; i++) { for (var j = 0; j < tableNode.columnLength; j++) { expect( tableNode .getTableCellNode(rowIndex: i, columnIndex: j) ?.children .first .delta ?.toPlainText(), i == 2 ? '' : defaultContent, ); } } }); test('clear content at column 1', () async { const defaultContent = 'default content'; final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, defaultContent: defaultContent, ); await editorState.clearContentAtColumnIndex( tableNode: tableNode, columnIndex: 0, ); for (var i = 0; i < tableNode.rowLength; i++) { for (var j = 0; j < tableNode.columnLength; j++) { expect( tableNode .getTableCellNode(rowIndex: i, columnIndex: j) ?.children .first .delta ?.toPlainText(), j == 0 ? '' : defaultContent, ); } } }); test('clear content at column 4', () async { const defaultContent = 'default content'; final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, defaultContent: defaultContent, ); await editorState.clearContentAtColumnIndex( tableNode: tableNode, columnIndex: 3, ); for (var i = 0; i < tableNode.rowLength; i++) { for (var j = 0; j < tableNode.columnLength; j++) { expect( tableNode .getTableCellNode(rowIndex: i, columnIndex: j) ?.children .first .delta ?.toPlainText(), j == 3 ? '' : defaultContent, ); } } }); test('copy row 1-2', () async { const rowCount = 2; const columnCount = 3; final (editorState, tableNode) = createEditorStateAndTable( rowCount: rowCount, columnCount: columnCount, contentBuilder: (rowIndex, columnIndex) => 'row $rowIndex, column $columnIndex', ); for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { final data = await editorState.copyRow( tableNode: tableNode, rowIndex: rowIndex, ); expect(data, isNotNull); expect( data?.plainText, 'row $rowIndex, column 0\nrow $rowIndex, column 1\nrow $rowIndex, column 2', ); } }); test('copy column 1-2', () async { const rowCount = 2; const columnCount = 3; final (editorState, tableNode) = createEditorStateAndTable( rowCount: rowCount, columnCount: columnCount, contentBuilder: (rowIndex, columnIndex) => 'row $rowIndex, column $columnIndex', ); for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) { final data = await editorState.copyColumn( tableNode: tableNode, columnIndex: columnIndex, ); expect(data, isNotNull); expect( data?.plainText, 'row 0, column $columnIndex\nrow 1, column $columnIndex', ); } }); test('cut row 1-2', () async { const rowCount = 2; const columnCount = 3; final (editorState, tableNode) = createEditorStateAndTable( rowCount: rowCount, columnCount: columnCount, contentBuilder: (rowIndex, columnIndex) => 'row $rowIndex, column $columnIndex', ); for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { final data = await editorState.copyRow( tableNode: tableNode, rowIndex: rowIndex, clearContent: true, ); expect(data, isNotNull); expect( data?.plainText, 'row $rowIndex, column 0\nrow $rowIndex, column 1\nrow $rowIndex, column 2', ); } for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) { expect( tableNode .getTableCellNode(rowIndex: rowIndex, columnIndex: columnIndex) ?.children .first .delta ?.toPlainText(), '', ); } } }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_delete_operation_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter_test/flutter_test.dart'; import 'simple_table_test_helper.dart'; void main() { group('Simple table delete operation:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('delete 2 rows in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); await editorState.deleteRowInTable(tableNode, 0); await editorState.deleteRowInTable(tableNode, 0); expect(tableNode.rowLength, 1); expect(tableNode.columnLength, 4); }); test('delete 2 columns in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); await editorState.deleteColumnInTable(tableNode, 0); await editorState.deleteColumnInTable(tableNode, 0); expect(tableNode.rowLength, 3); expect(tableNode.columnLength, 2); }); test('delete a row and a column in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); await editorState.deleteColumnInTable(tableNode, 0); await editorState.deleteRowInTable(tableNode, 0); expect(tableNode.rowLength, 2); expect(tableNode.columnLength, 3); }); test('delete a row with background and align (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // delete the row 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 1, columnIndex: 0); await editorState.updateRowBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); expect(tableCellNode.rowColors, { '1': '0xFF0000FF', }); await editorState.updateRowAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.rowAligns, { '1': TableAlign.center.key, }); await editorState.deleteRowInTable(tableNode, 1); expect(tableNode.rowLength, 2); expect(tableNode.columnLength, 4); expect(tableCellNode.rowColors, {}); expect(tableNode.rowAligns, {}); }); test('delete a row with background and align (2)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // delete the row 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 1, columnIndex: 0); await editorState.updateRowBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); expect(tableCellNode.rowColors, { '1': '0xFF0000FF', }); await editorState.updateRowAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.rowAligns, { '1': TableAlign.center.key, }); await editorState.deleteRowInTable(tableNode, 0); expect(tableNode.rowLength, 2); expect(tableNode.columnLength, 4); expect(tableCellNode.rowColors, { '0': '0xFF0000FF', }); expect(tableNode.rowAligns, { '0': TableAlign.center.key, }); }); test('delete a column with background and align (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // delete the column 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); await editorState.updateColumnBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); expect(tableCellNode.columnColors, { '1': '0xFF0000FF', }); await editorState.updateColumnAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.columnAligns, { '1': TableAlign.center.key, }); await editorState.deleteColumnInTable(tableNode, 1); expect(tableNode.rowLength, 3); expect(tableNode.columnLength, 3); expect(tableCellNode.columnColors, {}); expect(tableNode.columnAligns, {}); }); test('delete a column with background (2)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // delete the column 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); await editorState.updateColumnBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); expect(tableCellNode.columnColors, { '1': '0xFF0000FF', }); await editorState.updateColumnAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.columnAligns, { '1': TableAlign.center.key, }); await editorState.deleteColumnInTable(tableNode, 0); expect(tableNode.rowLength, 3); expect(tableNode.columnLength, 3); expect(tableCellNode.columnColors, { '0': '0xFF0000FF', }); expect(tableNode.columnAligns, { '0': TableAlign.center.key, }); }); test('delete a column with text color & bold style (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // delete the column 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); await editorState.updateColumnTextColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.toggleColumnBoldAttribute( tableCellNode: tableCellNode, isBold: true, ); expect(tableNode.columnTextColors, { '1': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '1': true, }); await editorState.deleteColumnInTable(tableNode, 0); expect(tableNode.columnTextColors, { '0': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '0': true, }); expect(tableNode.rowLength, 3); expect(tableNode.columnLength, 3); }); test('delete a column with text color & bold style (2)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // delete the column 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); await editorState.updateColumnTextColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.toggleColumnBoldAttribute( tableCellNode: tableCellNode, isBold: true, ); expect(tableNode.columnTextColors, { '1': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '1': true, }); await editorState.deleteColumnInTable(tableNode, 1); expect(tableNode.columnTextColors, {}); expect(tableNode.columnBoldAttributes, {}); expect(tableNode.rowLength, 3); expect(tableNode.columnLength, 3); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_duplicate_operation_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter_test/flutter_test.dart'; import 'simple_table_test_helper.dart'; void main() { group('Simple table delete operation:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('duplicate a row', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); await editorState.duplicateRowInTable(tableNode, 0); expect(tableNode.rowLength, 4); expect(tableNode.columnLength, 4); }); test('duplicate a column', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); await editorState.duplicateColumnInTable(tableNode, 0); expect(tableNode.rowLength, 3); expect(tableNode.columnLength, 5); }); test('duplicate a row with background and align (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // duplicate the row 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 1, columnIndex: 0); await editorState.updateRowBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); expect(tableCellNode.rowColors, { '1': '0xFF0000FF', }); await editorState.updateRowAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.rowAligns, { '1': TableAlign.center.key, }); await editorState.duplicateRowInTable(tableNode, 1); expect(tableCellNode.rowColors, { '1': '0xFF0000FF', '2': '0xFF0000FF', }); expect(tableNode.rowAligns, { '1': TableAlign.center.key, '2': TableAlign.center.key, }); }); test('duplicate a row with background and align (2)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // duplicate the row 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 1, columnIndex: 0); await editorState.updateRowBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); expect(tableCellNode.rowColors, { '1': '0xFF0000FF', }); await editorState.updateRowAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.rowAligns, { '1': TableAlign.center.key, }); await editorState.duplicateRowInTable(tableNode, 2); expect(tableCellNode.rowColors, { '1': '0xFF0000FF', }); expect(tableNode.rowAligns, { '1': TableAlign.center.key, }); }); test('duplicate a column with background and align (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // duplicate the column 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); await editorState.updateColumnBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.updateColumnAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.columnColors, { '1': '0xFF0000FF', }); expect(tableNode.columnAligns, { '1': TableAlign.center.key, }); await editorState.duplicateColumnInTable(tableNode, 1); expect(tableCellNode.columnColors, { '1': '0xFF0000FF', '2': '0xFF0000FF', }); expect(tableNode.columnAligns, { '1': TableAlign.center.key, '2': TableAlign.center.key, }); }); test('duplicate a column with background and align (2)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // duplicate the column 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); await editorState.updateColumnBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.updateColumnAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.columnColors, { '1': '0xFF0000FF', }); expect(tableNode.columnAligns, { '1': TableAlign.center.key, }); await editorState.duplicateColumnInTable(tableNode, 2); expect(tableCellNode.columnColors, { '1': '0xFF0000FF', }); expect(tableNode.columnAligns, { '1': TableAlign.center.key, }); }); test('duplicate a column with text color & bold style (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // duplicate the column 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); await editorState.updateColumnTextColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.toggleColumnBoldAttribute( tableCellNode: tableCellNode, isBold: true, ); expect(tableNode.columnTextColors, { '1': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '1': true, }); await editorState.duplicateColumnInTable(tableNode, 1); expect(tableNode.columnTextColors, { '1': '0xFF0000FF', '2': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '1': true, '2': true, }); }); test('duplicate a column with text color & bold style (2)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 4, ); // duplicate the column 1 final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); await editorState.updateColumnTextColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.toggleColumnBoldAttribute( tableCellNode: tableCellNode, isBold: true, ); expect(tableNode.columnTextColors, { '1': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '1': true, }); await editorState.duplicateColumnInTable(tableNode, 0); expect(tableNode.columnTextColors, { '2': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '2': true, }); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_header_operation_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter_test/flutter_test.dart'; import 'simple_table_test_helper.dart'; void main() { group('Simple table header operation:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('enable header column in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); // default is not header column expect(tableNode.isHeaderColumnEnabled, false); await editorState.toggleEnableHeaderColumn( tableNode: tableNode, enable: true, ); expect(tableNode.isHeaderColumnEnabled, true); await editorState.toggleEnableHeaderColumn( tableNode: tableNode, enable: false, ); expect(tableNode.isHeaderColumnEnabled, false); expect(tableNode.rowLength, 2); expect(tableNode.columnLength, 3); }); test('enable header row in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); // default is not header row expect(tableNode.isHeaderRowEnabled, false); await editorState.toggleEnableHeaderRow( tableNode: tableNode, enable: true, ); expect(tableNode.isHeaderRowEnabled, true); await editorState.toggleEnableHeaderRow( tableNode: tableNode, enable: false, ); expect(tableNode.isHeaderRowEnabled, false); expect(tableNode.rowLength, 2); expect(tableNode.columnLength, 3); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_insert_operation_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter_test/flutter_test.dart'; import 'simple_table_test_helper.dart'; void main() { group('Simple table insert operation:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('add 2 rows in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); await editorState.addRowInTable(tableNode); await editorState.addRowInTable(tableNode); expect(tableNode.rowLength, 4); expect(tableNode.columnLength, 3); }); test('add 2 columns in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); await editorState.addColumnInTable(tableNode); await editorState.addColumnInTable(tableNode); expect(tableNode.rowLength, 2); expect(tableNode.columnLength, 5); }); test('add 2 rows and 2 columns in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); await editorState.addColumnAndRowInTable(tableNode); await editorState.addColumnAndRowInTable(tableNode); expect(tableNode.rowLength, 4); expect(tableNode.columnLength, 5); }); test('insert a row at the first position in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); await editorState.insertRowInTable(tableNode, 0); expect(tableNode.rowLength, 3); expect(tableNode.columnLength, 3); }); test('insert a column at the first position in table', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); await editorState.insertColumnInTable(tableNode, 0); expect(tableNode.columnLength, 4); expect(tableNode.rowLength, 2); }); test('insert a row with background and align (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); // insert the row at the first position final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); await editorState.updateRowBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); expect(tableNode.rowColors, { '0': '0xFF0000FF', }); await editorState.updateRowAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.rowAligns, { '0': TableAlign.center.key, }); await editorState.insertRowInTable(tableNode, 0); expect(tableNode.rowColors, { '1': '0xFF0000FF', }); expect(tableNode.rowAligns, { '1': TableAlign.center.key, }); }); test('insert a row with background and align (2)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); // insert the row at the first position final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); await editorState.updateRowBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); expect(tableNode.rowColors, { '0': '0xFF0000FF', }); await editorState.updateRowAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.rowAligns, { '0': TableAlign.center.key, }); await editorState.insertRowInTable(tableNode, 1); expect(tableNode.rowColors, { '0': '0xFF0000FF', }); expect(tableNode.rowAligns, { '0': TableAlign.center.key, }); }); test('insert a column with background and align (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); // insert the column at the first position final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); await editorState.updateColumnBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.updateColumnAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.columnColors, { '0': '0xFF0000FF', }); expect(tableNode.columnAligns, { '0': TableAlign.center.key, }); await editorState.insertColumnInTable(tableNode, 0); expect(tableNode.columnColors, { '1': '0xFF0000FF', }); expect(tableNode.columnAligns, { '1': TableAlign.center.key, }); }); test('insert a column with background and align (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); // insert the column at the first position final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); await editorState.updateColumnBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.updateColumnAlign( tableCellNode: tableCellNode, align: TableAlign.center, ); expect(tableNode.columnColors, { '0': '0xFF0000FF', }); expect(tableNode.columnAligns, { '0': TableAlign.center.key, }); await editorState.insertColumnInTable(tableNode, 1); expect(tableNode.columnColors, { '0': '0xFF0000FF', }); expect(tableNode.columnAligns, { '0': TableAlign.center.key, }); }); test('insert a column with text color & bold style (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); // insert the column at the first position final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); await editorState.updateColumnTextColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.toggleColumnBoldAttribute( tableCellNode: tableCellNode, isBold: true, ); expect(tableNode.columnTextColors, { '0': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '0': true, }); await editorState.insertColumnInTable(tableNode, 0); expect(tableNode.columnTextColors, { '1': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '1': true, }); }); test('insert a column with text color & bold style (2)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); // insert the column at the first position final tableCellNode = tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); await editorState.updateColumnTextColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); await editorState.toggleColumnBoldAttribute( tableCellNode: tableCellNode, isBold: true, ); expect(tableNode.columnTextColors, { '0': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '0': true, }); await editorState.insertColumnInTable(tableNode, 1); expect(tableNode.columnTextColors, { '0': '0xFF0000FF', }); expect(tableNode.columnBoldAttributes, { '0': true, }); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_markdown_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('Simple table markdown:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('convert simple table to markdown (1)', () async { final tableNode = createSimpleTableBlockNode( columnCount: 7, rowCount: 11, contentBuilder: (rowIndex, columnIndex) => _sampleContents[rowIndex][columnIndex], ); final markdown = const SimpleTableNodeParser().transform( tableNode, null, ); expect(markdown, '''|Index|Customer Id|First Name|Last Name|Company|City|Country| |---|---|---|---|---|---|---| |1|DD37Cf93aecA6Dc|Sheryl|Baxter|Rasmussen Group|East Leonard|Chile| |2|1Ef7b82A4CAAD10|Preston|Lozano|Vega-Gentry|East Jimmychester|Djibouti| |3|6F94879bDAfE5a6|Roy|Berry|Murillo-Perry|Isabelborough|Antigua and Barbuda| |4|5Cef8BFA16c5e3c|Linda|Olsen|Dominguez, Mcmillan and Donovan|Bensonview|Dominican Republic| |5|053d585Ab6b3159|Joanna|Bender|Martin, Lang and Andrade|West Priscilla|Slovakia (Slovak Republic)| |6|2d08FB17EE273F4|Aimee|Downs|Steele Group|Chavezborough|Bosnia and Herzegovina| |7|EAd384DfDbBf77|Darren|Peck|Lester, Woodard and Mitchell|Lake Ana|Pitcairn Islands| |8|0e04AFde9f225dE|Brett|Mullen|Sanford, Davenport and Giles|Kimport|Bulgaria| |9|C2dE4dEEc489ae0|Sheryl|Meyers|Browning-Simon|Robersonstad|Cyprus| |10|8C2811a503C7c5a|Michelle|Gallagher|Beck-Hendrix|Elaineberg|Timor-Leste| '''); }); test('convert markdown to simple table (1)', () async { final document = customMarkdownToDocument(_sampleMarkdown1); expect(document, isNotNull); final tableNode = document.nodeAtPath([0])!; expect(tableNode, isNotNull); expect(tableNode.type, equals(SimpleTableBlockKeys.type)); expect(tableNode.rowLength, equals(4)); expect(tableNode.columnLength, equals(4)); }); test('convert markdown to simple table (2)', () async { final document = customMarkdownToDocument( _sampleMarkdown1, tableWidth: 200, ); expect(document, isNotNull); final tableNode = document.nodeAtPath([0])!; expect(tableNode, isNotNull); expect(tableNode.type, equals(SimpleTableBlockKeys.type)); expect(tableNode.columnWidths.length, 4); for (final entry in tableNode.columnWidths.entries) { expect(entry.value, equals(200)); } }); }); } const _sampleContents = >[ [ "Index", "Customer Id", "First Name", "Last Name", "Company", "City", "Country", ], [ "1", "DD37Cf93aecA6Dc", "Sheryl", "Baxter", "Rasmussen Group", "East Leonard", "Chile", ], [ "2", "1Ef7b82A4CAAD10", "Preston", "Lozano", "Vega-Gentry", "East Jimmychester", "Djibouti", ], [ "3", "6F94879bDAfE5a6", "Roy", "Berry", "Murillo-Perry", "Isabelborough", "Antigua and Barbuda", ], [ "4", "5Cef8BFA16c5e3c", "Linda", "Olsen", "Dominguez, Mcmillan and Donovan", "Bensonview", "Dominican Republic", ], [ "5", "053d585Ab6b3159", "Joanna", "Bender", "Martin, Lang and Andrade", "West Priscilla", "Slovakia (Slovak Republic)", ], [ "6", "2d08FB17EE273F4", "Aimee", "Downs", "Steele Group", "Chavezborough", "Bosnia and Herzegovina", ], [ "7", "EAd384DfDbBf77", "Darren", "Peck", "Lester, Woodard and Mitchell", "Lake Ana", "Pitcairn Islands", ], [ "8", "0e04AFde9f225dE", "Brett", "Mullen", "Sanford, Davenport and Giles", "Kimport", "Bulgaria", ], [ "9", "C2dE4dEEc489ae0", "Sheryl", "Meyers", "Browning-Simon", "Robersonstad", "Cyprus", ], [ "10", "8C2811a503C7c5a", "Michelle", "Gallagher", "Beck-Hendrix", "Elaineberg", "Timor-Leste", ], ]; const _sampleMarkdown1 = '''|A|B|C|| |---|---|---|---| |D|E|F|| |1|2|3|| ||||| '''; ================================================ FILE: frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_reorder_operation_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:flutter_test/flutter_test.dart'; import 'simple_table_test_helper.dart'; void main() { group('Simple table reorder operation:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); group('reorder column', () { test('reorder column from index 1 to index 2', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 4, columnCount: 3, contentBuilder: (rowIndex, columnIndex) => 'cell $rowIndex-$columnIndex', ); await editorState.reorderColumn(tableNode, fromIndex: 1, toIndex: 2); expect(tableNode.columnLength, 3); expect(tableNode.rowLength, 4); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), 'cell 0-0', ); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 1), 'cell 0-2', ); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 2), 'cell 0-1', ); }); test('reorder column from index 2 to index 0', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 4, columnCount: 3, contentBuilder: (rowIndex, columnIndex) => 'cell $rowIndex-$columnIndex', ); await editorState.reorderColumn(tableNode, fromIndex: 2, toIndex: 0); expect(tableNode.columnLength, 3); expect(tableNode.rowLength, 4); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), 'cell 0-2', ); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 1), 'cell 0-0', ); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 2), 'cell 0-1', ); }); test('reorder column with same index', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 4, columnCount: 3, contentBuilder: (rowIndex, columnIndex) => 'cell $rowIndex-$columnIndex', ); await editorState.reorderColumn(tableNode, fromIndex: 1, toIndex: 1); expect(tableNode.columnLength, 3); expect(tableNode.rowLength, 4); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), 'cell 0-0', ); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 1), 'cell 0-1', ); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 2), 'cell 0-2', ); }); test( 'reorder column from index 0 to index 2 with align/color/width attributes (1)', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 4, columnCount: 3, contentBuilder: (rowIndex, columnIndex) => 'cell $rowIndex-$columnIndex', ); // before reorder // Column 0: align: right, color: 0xFF0000, width: 100 // Column 1: align: center, color: 0x00FF00, width: 150 // Column 2: align: left, color: 0x0000FF, width: 200 await updateTableColumnAttributes( editorState, tableNode, columnIndex: 0, align: TableAlign.right, color: '#FF0000', width: 100, ); await updateTableColumnAttributes( editorState, tableNode, columnIndex: 1, align: TableAlign.center, color: '#00FF00', width: 150, ); await updateTableColumnAttributes( editorState, tableNode, columnIndex: 2, align: TableAlign.left, color: '#0000FF', width: 200, ); // after reorder // Column 0: align: center, color: 0x00FF00, width: 150 // Column 1: align: left, color: 0x0000FF, width: 200 // Column 2: align: right, color: 0xFF0000, width: 100 await editorState.reorderColumn(tableNode, fromIndex: 0, toIndex: 2); expect(tableNode.columnLength, 3); expect(tableNode.rowLength, 4); expect(tableNode.columnAligns, { "0": TableAlign.center.key, "1": TableAlign.left.key, "2": TableAlign.right.key, }); expect(tableNode.columnColors, { "0": '#00FF00', "1": '#0000FF', "2": '#FF0000', }); expect(tableNode.columnWidths, { "0": 150, "1": 200, "2": 100, }); }); test( 'reorder column from index 0 to index 2 and reorder it back to index 0', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, contentBuilder: (rowIndex, columnIndex) => 'cell $rowIndex-$columnIndex', ); // before reorder // Column 0: null // Column 1: align: center, color: 0x0000FF, width: 200 // Column 2: align: right, color: 0x0000FF, width: 250 await updateTableColumnAttributes( editorState, tableNode, columnIndex: 1, align: TableAlign.center, color: '#FF0000', width: 200, ); await updateTableColumnAttributes( editorState, tableNode, columnIndex: 2, align: TableAlign.right, color: '#0000FF', width: 250, ); // move column from index 0 to index 2 await editorState.reorderColumn(tableNode, fromIndex: 0, toIndex: 2); // move column from index 2 to index 0 await editorState.reorderColumn(tableNode, fromIndex: 2, toIndex: 0); expect(tableNode.columnLength, 3); expect(tableNode.rowLength, 2); expect(tableNode.columnAligns, { "1": TableAlign.center.key, "2": TableAlign.right.key, }); expect(tableNode.columnColors, { "1": '#FF0000', "2": '#0000FF', }); expect(tableNode.columnWidths, { "1": 200, "2": 250, }); }); }); group('reorder row', () { test('reorder row from index 1 to index 2', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 2, contentBuilder: (rowIndex, columnIndex) => 'cell $rowIndex-$columnIndex', ); await editorState.reorderRow(tableNode, fromIndex: 1, toIndex: 2); expect(tableNode.columnLength, 2); expect(tableNode.rowLength, 3); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), 'cell 0-0', ); expect( tableNode.getTableCellContent(rowIndex: 1, columnIndex: 0), 'cell 2-0', ); expect( tableNode.getTableCellContent(rowIndex: 2, columnIndex: 0), 'cell 1-0', ); }); test('reorder row from index 2 to index 0', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 2, contentBuilder: (rowIndex, columnIndex) => 'cell $rowIndex-$columnIndex', ); await editorState.reorderRow(tableNode, fromIndex: 2, toIndex: 0); expect(tableNode.columnLength, 2); expect(tableNode.rowLength, 3); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), 'cell 2-0', ); expect( tableNode.getTableCellContent(rowIndex: 1, columnIndex: 0), 'cell 0-0', ); expect( tableNode.getTableCellContent(rowIndex: 2, columnIndex: 0), 'cell 1-0', ); }); test('reorder row with same', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 2, contentBuilder: (rowIndex, columnIndex) => 'cell $rowIndex-$columnIndex', ); await editorState.reorderRow(tableNode, fromIndex: 1, toIndex: 1); expect(tableNode.columnLength, 2); expect(tableNode.rowLength, 3); expect( tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), 'cell 0-0', ); expect( tableNode.getTableCellContent(rowIndex: 1, columnIndex: 0), 'cell 1-0', ); expect( tableNode.getTableCellContent(rowIndex: 2, columnIndex: 0), 'cell 2-0', ); }); test('reorder row from index 0 to index 2 with align/color attributes', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 3, columnCount: 2, contentBuilder: (rowIndex, columnIndex) => 'cell $rowIndex-$columnIndex', ); // before reorder // Row 0: align: right, color: 0xFF0000 // Row 1: align: center, color: 0x00FF00 // Row 2: align: left, color: 0x0000FF await updateTableRowAttributes( editorState, tableNode, rowIndex: 0, align: TableAlign.right, color: '#FF0000', ); await updateTableRowAttributes( editorState, tableNode, rowIndex: 1, align: TableAlign.center, color: '#00FF00', ); await updateTableRowAttributes( editorState, tableNode, rowIndex: 2, align: TableAlign.left, color: '#0000FF', ); // after reorder // Row 0: align: center, color: 0x00FF00 // Row 1: align: left, color: 0x0000FF // Row 2: align: right, color: 0xFF0000 await editorState.reorderRow(tableNode, fromIndex: 0, toIndex: 2); expect(tableNode.columnLength, 2); expect(tableNode.rowLength, 3); expect(tableNode.rowAligns, { "0": TableAlign.center.key, "1": TableAlign.left.key, "2": TableAlign.right.key, }); expect(tableNode.rowColors, { "0": '#00FF00', "1": '#0000FF', "2": '#FF0000', }); }); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_style_operation_test.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'simple_table_test_helper.dart'; void main() { group('Simple table style operation:', () { setUpAll(() { Log.shared.disableLog = true; }); tearDownAll(() { Log.shared.disableLog = false; }); test('update column width in memory', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); // check the default column width expect(tableNode.columnWidths, isEmpty); final tableCellNode = tableNode.getTableCellNode( rowIndex: 0, columnIndex: 0, ); await editorState.updateColumnWidthInMemory( tableCellNode: tableCellNode!, deltaX: 100, ); expect(tableNode.columnWidths, { '0': SimpleTableConstants.defaultColumnWidth + 100, }); // set the width less than the minimum column width await editorState.updateColumnWidthInMemory( tableCellNode: tableCellNode, deltaX: -1000, ); expect(tableNode.columnWidths, { '0': SimpleTableConstants.minimumColumnWidth, }); }); test('update column width', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); expect(tableNode.columnWidths, isEmpty); for (var i = 0; i < tableNode.columnLength; i++) { final tableCellNode = tableNode.getTableCellNode( rowIndex: 0, columnIndex: i, ); await editorState.updateColumnWidth( tableCellNode: tableCellNode!, width: 100, ); } expect(tableNode.columnWidths, { '0': 100, '1': 100, '2': 100, }); // set the width less than the minimum column width for (var i = 0; i < tableNode.columnLength; i++) { final tableCellNode = tableNode.getTableCellNode( rowIndex: 0, columnIndex: i, ); await editorState.updateColumnWidth( tableCellNode: tableCellNode!, width: -1000, ); } expect(tableNode.columnWidths, { '0': SimpleTableConstants.minimumColumnWidth, '1': SimpleTableConstants.minimumColumnWidth, '2': SimpleTableConstants.minimumColumnWidth, }); }); test('update column align', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); for (var i = 0; i < tableNode.columnLength; i++) { final tableCellNode = tableNode.getTableCellNode( rowIndex: 0, columnIndex: i, ); await editorState.updateColumnAlign( tableCellNode: tableCellNode!, align: TableAlign.center, ); } expect(tableNode.columnAligns, { '0': TableAlign.center.key, '1': TableAlign.center.key, '2': TableAlign.center.key, }); }); test('update row align', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); for (var i = 0; i < tableNode.rowLength; i++) { final tableCellNode = tableNode.getTableCellNode( rowIndex: i, columnIndex: 0, ); await editorState.updateRowAlign( tableCellNode: tableCellNode!, align: TableAlign.center, ); } expect(tableNode.rowAligns, { '0': TableAlign.center.key, '1': TableAlign.center.key, }); }); test('update column background color', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); for (var i = 0; i < tableNode.columnLength; i++) { final tableCellNode = tableNode.getTableCellNode( rowIndex: 0, columnIndex: i, ); await editorState.updateColumnBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); } expect(tableNode.columnColors, { '0': '0xFF0000FF', '1': '0xFF0000FF', '2': '0xFF0000FF', }); }); test('update row background color', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); for (var i = 0; i < tableNode.rowLength; i++) { final tableCellNode = tableNode.getTableCellNode( rowIndex: i, columnIndex: 0, ); await editorState.updateRowBackgroundColor( tableCellNode: tableCellNode!, color: '0xFF0000FF', ); } expect(tableNode.rowColors, { '0': '0xFF0000FF', '1': '0xFF0000FF', }); }); test('update table align', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); for (final align in [ TableAlign.center, TableAlign.right, TableAlign.left, ]) { await editorState.updateTableAlign( tableNode: tableNode, align: align, ); expect(tableNode.tableAlign, align); } }); test('clear the existing align of the column before updating', () async { final (editorState, tableNode) = createEditorStateAndTable( rowCount: 2, columnCount: 3, ); final firstCellNode = tableNode.getTableCellNode( rowIndex: 0, columnIndex: 0, ); Node firstParagraphNode = firstCellNode!.children.first; // format the first paragraph to center align final transaction = editorState.transaction; transaction.updateNode( firstParagraphNode, { blockComponentAlign: TableAlign.right.key, }, ); await editorState.apply(transaction); firstParagraphNode = editorState.getNodeAtPath([0, 0, 0, 0])!; expect( firstParagraphNode.attributes[blockComponentAlign], TableAlign.right.key, ); await editorState.updateColumnAlign( tableCellNode: firstCellNode, align: TableAlign.center, ); expect( firstParagraphNode.attributes[blockComponentAlign], null, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_test_helper.dart ================================================ import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; (EditorState editorState, Node tableNode) createEditorStateAndTable({ required int rowCount, required int columnCount, String? defaultContent, String Function(int rowIndex, int columnIndex)? contentBuilder, }) { final document = Document.blank() ..insert( [0], [ createSimpleTableBlockNode( columnCount: columnCount, rowCount: rowCount, defaultContent: defaultContent, contentBuilder: contentBuilder, ), ], ); final editorState = EditorState(document: document); return (editorState, document.nodeAtPath([0])!); } Future updateTableColumnAttributes( EditorState editorState, Node tableNode, { required int columnIndex, TableAlign? align, String? color, double? width, }) async { final cell = tableNode.getTableCellNode( rowIndex: 0, columnIndex: columnIndex, )!; if (align != null) { await editorState.updateColumnAlign( tableCellNode: cell, align: align, ); } if (color != null) { await editorState.updateColumnBackgroundColor( tableCellNode: cell, color: color, ); } if (width != null) { await editorState.updateColumnWidth( tableCellNode: cell, width: width, ); } } Future updateTableRowAttributes( EditorState editorState, Node tableNode, { required int rowIndex, TableAlign? align, String? color, }) async { final cell = tableNode.getTableCellNode( rowIndex: rowIndex, columnIndex: 0, )!; if (align != null) { await editorState.updateRowAlign( tableCellNode: cell, align: align, ); } if (color != null) { await editorState.updateRowBackgroundColor( tableCellNode: cell, color: color, ); } } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/theme/theme_test.dart ================================================ import 'package:flowy_infra/colorscheme/colorscheme.dart'; import 'package:flowy_infra/plugins/service/location_service.dart'; import 'package:flowy_infra/plugins/service/models/flowy_dynamic_plugin.dart'; import 'package:flowy_infra/plugins/service/plugin_service.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; class MockPluginService implements FlowyPluginService { @override Future addPlugin(FlowyDynamicPlugin plugin) => throw UnimplementedError(); @override Future lookup({required String name}) => throw UnimplementedError(); @override Future get plugins async => const Iterable.empty(); @override void setLocation(PluginLocationService locationService) => throw UnimplementedError(); } void main() { WidgetsFlutterBinding.ensureInitialized(); group('AppTheme', () { test('fallback theme', () { const theme = AppTheme.fallback; expect(theme.builtIn, true); expect(theme.themeName, BuiltInTheme.defaultTheme); expect(theme.lightTheme, isA()); expect(theme.darkTheme, isA()); }); test('built-in themes', () { final themes = AppTheme.builtins; expect(themes, isNotEmpty); for (final theme in themes) { expect(theme.builtIn, true); expect( theme.themeName, anyOf([ BuiltInTheme.defaultTheme, BuiltInTheme.dandelion, BuiltInTheme.lavender, BuiltInTheme.lemonade, ]), ); expect(theme.lightTheme, isA()); expect(theme.darkTheme, isA()); } }); test('fromName returns existing theme', () async { final theme = await AppTheme.fromName( BuiltInTheme.defaultTheme, pluginService: MockPluginService(), ); expect(theme, isNotNull); expect(theme.builtIn, true); expect(theme.themeName, BuiltInTheme.defaultTheme); expect(theme.lightTheme, isA()); expect(theme.darkTheme, isA()); }); test('fromName throws error for non-existent theme', () async { expect( () async => AppTheme.fromName( 'bogus', pluginService: MockPluginService(), ), throwsArgumentError, ); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart ================================================ import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('url launcher unit test', () { test('launch local uri', () async { const localUris = [ 'file://path/to/file.txt', '/path/to/file.txt', 'C:\\path\\to\\file.txt', '../path/to/file.txt', ]; for (final uri in localUris) { final result = localPathRegex.hasMatch(uri); expect(result, true); } }); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/util/recent_icons_test.dart ================================================ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; import 'package:collection/collection.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { setUpAll(() { TestWidgetsFlutterBinding.ensureInitialized(); SharedPreferences.setMockInitialValues({}); getIt.registerFactory(() => DartKeyValue()); Log.shared.disableLog = true; }); bool equalIcon(RecentIcon a, RecentIcon b) => a.groupName == b.groupName && a.name == b.name && a.keywords.equals(b.keywords) && a.content == b.content; test('putEmoji', () async { List emojiIds = await RecentIcons.getEmojiIds(); assert(emojiIds.isEmpty); await RecentIcons.putEmoji('1'); emojiIds = await RecentIcons.getEmojiIds(); assert(emojiIds.equals(['1'])); await RecentIcons.putEmoji('2'); assert(emojiIds.equals(['2', '1'])); await RecentIcons.putEmoji('1'); emojiIds = await RecentIcons.getEmojiIds(); assert(emojiIds.equals(['1', '2'])); for (var i = 0; i < RecentIcons.maxLength; ++i) { await RecentIcons.putEmoji('${i + 100}'); } emojiIds = await RecentIcons.getEmojiIds(); assert(emojiIds.length == RecentIcons.maxLength); assert( emojiIds.equals( List.generate(RecentIcons.maxLength, (i) => '${i + 100}') .reversed .toList(), ), ); }); test('putIcons', () async { List icons = await RecentIcons.getIcons(); assert(icons.isEmpty); await loadIconGroups(); final groups = kIconGroups!; final List localIcons = []; for (final e in groups) { localIcons.addAll(e.icons.map((e) => RecentIcon(e, e.name)).toList()); } await RecentIcons.putIcon(localIcons.first); icons = await RecentIcons.getIcons(); assert(icons.length == 1); assert(equalIcon(icons.first, localIcons.first)); await RecentIcons.putIcon(localIcons[1]); icons = await RecentIcons.getIcons(); assert(icons.length == 2); assert(equalIcon(icons[0], localIcons[1])); assert(equalIcon(icons[1], localIcons[0])); await RecentIcons.putIcon(localIcons.first); icons = await RecentIcons.getIcons(); assert(icons.length == 2); assert(equalIcon(icons[1], localIcons[1])); assert(equalIcon(icons[0], localIcons[0])); for (var i = 0; i < RecentIcons.maxLength; ++i) { await RecentIcons.putIcon(localIcons[10 + i]); } icons = await RecentIcons.getIcons(); assert(icons.length == RecentIcons.maxLength); for (var i = 0; i < RecentIcons.maxLength; ++i) { assert( equalIcon(icons[RecentIcons.maxLength - i - 1], localIcons[10 + i]), ); } }); test('put without group name', () async { RecentIcons.clear(); List icons = await RecentIcons.getIcons(); assert(icons.isEmpty); await loadIconGroups(); final groups = kIconGroups!; final List localIcons = []; for (final e in groups) { localIcons.addAll(e.icons.map((e) => RecentIcon(e, e.name)).toList()); } await RecentIcons.putIcon(RecentIcon(localIcons.first.icon, '')); icons = await RecentIcons.getIcons(); assert(icons.isEmpty); await RecentIcons.putIcon( RecentIcon(localIcons.first.icon, 'Test group name'), ); icons = await RecentIcons.getIcons(); assert(icons.isNotEmpty); }); } ================================================ FILE: frontend/appflowy_flutter/test/unit_test/util/time.dart ================================================ import 'package:appflowy/util/time.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('parseTime should parse time string to minutes', () { expect(parseTime('10'), 10); expect(parseTime('70m'), 70); expect(parseTime('4h 20m'), 260); expect(parseTime('1h 80m'), 140); expect(parseTime('asffsa2h3m'), null); expect(parseTime('2h3m'), null); expect(parseTime('blah'), null); expect(parseTime('10a'), null); expect(parseTime('2h'), 120); }); test('formatTime should format time minutes to formatted string', () { expect(formatTime(5), "5m"); expect(formatTime(75), "1h 15m"); expect(formatTime(120), "2h"); expect(formatTime(-50), ""); expect(formatTime(0), "0m"); }); } ================================================ FILE: frontend/appflowy_flutter/test/util.dart ================================================ import 'package:appflowy/startup/launch_configuration.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; class AppFlowyUnitTest { late UserProfilePB userProfile; late UserBackendService userService; late WorkspaceService workspaceService; late WorkspacePB workspace; static Future ensureInitialized() async { TestWidgetsFlutterBinding.ensureInitialized(); SharedPreferences.setMockInitialValues({}); _pathProviderInitialized(); await FlowyRunner.run( AppFlowyApplicationUnitTest(), IntegrationMode.unitTest, ); final test = AppFlowyUnitTest(); await test._signIn(); await test._loadWorkspace(); await test._initialServices(); return test; } Future _signIn() async { final authService = getIt(); const password = "AppFlowy123@"; final uid = uuid(); final userEmail = "$uid@appflowy.io"; final result = await authService.signUp( name: "TestUser", password: password, email: userEmail, ); result.fold( (user) { userProfile = user; userService = UserBackendService(userId: userProfile.id); }, (error) { assert(false, 'Error: $error'); }, ); } WorkspacePB get currentWorkspace => workspace; Future _loadWorkspace() async { final result = await UserBackendService.getCurrentWorkspace(); result.fold( (value) => workspace = value, (error) { throw Exception(error); }, ); } Future _initialServices() async { workspaceService = WorkspaceService( workspaceId: currentWorkspace.id, userId: userProfile.id, ); } Future createWorkspace() async { final result = await workspaceService.createView( name: "Test App", viewSection: ViewSectionPB.Public, ); return result.fold( (app) => app, (error) => throw Exception(error), ); } } void _pathProviderInitialized() { const MethodChannel channel = MethodChannel('plugins.flutter.io/path_provider'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { return '.'; }); } class AppFlowyApplicationUnitTest implements EntryPoint { @override Widget create(LaunchConfiguration config) { return const SizedBox.shrink(); } } Future blocResponseFuture({int millisecond = 200}) { return Future.delayed(Duration(milliseconds: millisecond)); } Duration blocResponseDuration({int milliseconds = 200}) { return Duration(milliseconds: milliseconds); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/confirm_dialog_test.dart ================================================ import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../integration_test/shared/util.dart'; import 'test_material_app.dart'; class _ConfirmPopupMock extends Mock { void confirm(); } void main() { setUpAll(() async { SharedPreferences.setMockInitialValues({}); EasyLocalization.logger.enableLevels = []; await EasyLocalization.ensureInitialized(); }); Widget buildDialog(VoidCallback onConfirm) { return Builder( builder: (context) { return TextButton( child: const Text(""), onPressed: () { showDialog( context: context, builder: (_) { return AppFlowyTheme( data: AppFlowyDefaultTheme().light(), child: Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: ConfirmPopup( description: "desc", title: "title", onConfirm: (_) => onConfirm(), ), ), ); }, ); }, ); }, ); } testWidgets('confirm dialog shortcut events', (tester) async { final callback = _ConfirmPopupMock(); // escape await tester.pumpWidget( WidgetTestApp( child: buildDialog(callback.confirm), ), ); await tester.pumpAndSettle(); await tester.tap(find.byType(TextButton)); await tester.pumpAndSettle(); expect(find.byType(ConfirmPopup), findsOneWidget); await tester.simulateKeyEvent(LogicalKeyboardKey.escape); verifyNever(() => callback.confirm()); verifyNever(() => callback.confirm()); expect(find.byType(ConfirmPopup), findsNothing); // enter await tester.pumpWidget( WidgetTestApp( child: buildDialog(callback.confirm), ), ); await tester.pumpAndSettle(); await tester.tap(find.byType(TextButton)); await tester.pumpAndSettle(); expect(find.byType(ConfirmPopup), findsOneWidget); await tester.simulateKeyEvent(LogicalKeyboardKey.enter); verify(() => callback.confirm()).called(1); verifyNever(() => callback.confirm()); expect(find.byType(ConfirmPopup), findsNothing); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/date_picker_test.dart ================================================ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_time_text_field.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:time/time.dart'; import '../../integration_test/shared/util.dart'; import 'test_material_app.dart'; const _mockDatePickerDelay = Duration(milliseconds: 200); class _DatePickerDataStub { _DatePickerDataStub({ required this.dateTime, required this.endDateTime, required this.includeTime, required this.isRange, }); _DatePickerDataStub.empty() : dateTime = null, endDateTime = null, includeTime = false, isRange = false; DateTime? dateTime; DateTime? endDateTime; bool includeTime; bool isRange; } class _MockDatePicker extends StatefulWidget { const _MockDatePicker({ this.data, this.dateFormat, this.timeFormat, }); final _DatePickerDataStub? data; final DateFormatPB? dateFormat; final TimeFormatPB? timeFormat; @override State<_MockDatePicker> createState() => _MockDatePickerState(); } class _MockDatePickerState extends State<_MockDatePicker> { late final _DatePickerDataStub data; late DateFormatPB dateFormat; late TimeFormatPB timeFormat; @override void initState() { super.initState(); data = widget.data ?? _DatePickerDataStub.empty(); dateFormat = widget.dateFormat ?? DateFormatPB.Friendly; timeFormat = widget.timeFormat ?? TimeFormatPB.TwelveHour; } void updateDateFormat(DateFormatPB dateFormat) async { setState(() { this.dateFormat = dateFormat; }); } void updateTimeFormat(TimeFormatPB timeFormat) async { setState(() { this.timeFormat = timeFormat; }); } void updateDateCellData({ required DateTime? dateTime, required DateTime? endDateTime, required bool isRange, required bool includeTime, }) { setState(() { data.dateTime = dateTime; data.endDateTime = endDateTime; data.includeTime = includeTime; data.isRange = isRange; }); } @override Widget build(BuildContext context) { return DesktopAppFlowyDatePicker( dateTime: data.dateTime, endDateTime: data.endDateTime, includeTime: data.includeTime, isRange: data.isRange, dateFormat: dateFormat, timeFormat: timeFormat, onDaySelected: (date) async { await Future.delayed(_mockDatePickerDelay); setState(() { data.dateTime = date; }); }, onRangeSelected: (start, end) async { await Future.delayed(_mockDatePickerDelay); setState(() { data.dateTime = start; data.endDateTime = end; }); }, onIncludeTimeChanged: (value, dateTime, endDateTime) async { await Future.delayed(_mockDatePickerDelay); setState(() { data.includeTime = value; if (dateTime != null) { data.dateTime = dateTime; } if (endDateTime != null) { data.endDateTime = endDateTime; } }); }, onIsRangeChanged: (value, dateTime, endDateTime) async { await Future.delayed(_mockDatePickerDelay); setState(() { data.isRange = value; if (dateTime != null) { data.dateTime = dateTime; } if (endDateTime != null) { data.endDateTime = endDateTime; } }); }, ); } } void main() { setUpAll(() async { SharedPreferences.setMockInitialValues({}); EasyLocalization.logger.enableLevels = []; await EasyLocalization.ensureInitialized(); }); Finder dayInDatePicker(int day) { final findCalendar = find.byType(TableCalendar); final findDay = find.text(day.toString()); return find.descendant( of: findCalendar, matching: findDay, ); } DateTime getLastMonth(DateTime date) { if (date.month == 1) { return DateTime(date.year - 1, 12); } else { return DateTime(date.year, date.month - 1); } } _MockDatePickerState getMockState(WidgetTester tester) => tester.state<_MockDatePickerState>(find.byType(_MockDatePicker)); AppFlowyDatePickerState getAfState(WidgetTester tester) => tester.state( find.byType(DesktopAppFlowyDatePicker), ); group('AppFlowy date picker:', () { testWidgets('default state', (tester) async { await tester.pumpWidget( const WidgetTestApp( child: _MockDatePicker(), ), ); await tester.pumpAndSettle(); expect(find.byType(DesktopAppFlowyDatePicker), findsOneWidget); expect( find.byWidgetPredicate( (w) => w is DateTimeTextField && w.dateTime == null, ), findsOneWidget, ); expect( find.byWidgetPredicate((w) => w is DatePicker && w.selectedDay == null), findsOneWidget, ); expect( find.byWidgetPredicate((w) => w is IncludeTimeButton && !w.includeTime), findsOneWidget, ); expect( find.byWidgetPredicate((w) => w is EndTimeButton && !w.isRange), findsOneWidget, ); }); testWidgets('passed in state', (tester) async { await tester.pumpWidget( WidgetTestApp( child: _MockDatePicker( data: _DatePickerDataStub( dateTime: DateTime(2024, 10, 12, 13), endDateTime: DateTime(2024, 10, 14, 5), includeTime: true, isRange: true, ), ), ), ); await tester.pumpAndSettle(); expect(find.byType(DesktopAppFlowyDatePicker), findsOneWidget); expect(find.byType(DateTimeTextField), findsNWidgets(2)); expect(find.byType(DatePicker), findsOneWidget); expect( find.byWidgetPredicate((w) => w is IncludeTimeButton && w.includeTime), findsOneWidget, ); expect( find.byWidgetPredicate((w) => w is EndTimeButton && w.isRange), findsOneWidget, ); final afState = getAfState(tester); expect(afState.focusedDateTime, DateTime(2024, 10, 12, 13)); }); testWidgets('date and time formats', (tester) async { final date = DateTime(2024, 10, 12, 13); await tester.pumpWidget( WidgetTestApp( child: _MockDatePicker( dateFormat: DateFormatPB.Friendly, timeFormat: TimeFormatPB.TwelveHour, data: _DatePickerDataStub( dateTime: date, endDateTime: null, includeTime: true, isRange: false, ), ), ), ); await tester.pumpAndSettle(); final dateText = find.descendant( of: find.byKey(const ValueKey('date_time_text_field_date')), matching: find.text(DateFormat(DateFormatPB.Friendly.pattern).format(date)), ); expect(dateText, findsOneWidget); final timeText = find.descendant( of: find.byKey(const ValueKey('date_time_text_field_time')), matching: find.text(DateFormat(TimeFormatPB.TwelveHour.pattern).format(date)), ); expect(timeText, findsOneWidget); _MockDatePickerState mockState = getMockState(tester); mockState.updateDateFormat(DateFormatPB.US); await tester.pumpAndSettle(); final dateText2 = find.descendant( of: find.byKey(const ValueKey('date_time_text_field_date')), matching: find.text(DateFormat(DateFormatPB.US.pattern).format(date)), ); expect(dateText2, findsOneWidget); mockState = getMockState(tester); mockState.updateTimeFormat(TimeFormatPB.TwentyFourHour); await tester.pumpAndSettle(); final timeText2 = find.descendant( of: find.byKey(const ValueKey('date_time_text_field_time')), matching: find .text(DateFormat(TimeFormatPB.TwentyFourHour.pattern).format(date)), ); expect(timeText2, findsOneWidget); }); testWidgets('page turn buttons', (tester) async { await tester.pumpWidget( const WidgetTestApp( child: _MockDatePicker(), ), ); await tester.pumpAndSettle(); final now = DateTime.now(); expect( find.text(DateFormat.yMMMM().format(now)), findsOneWidget, ); final lastMonth = getLastMonth(now); await tester.tap(find.byFlowySvg(FlowySvgs.arrow_left_s)); await tester.pumpAndSettle(); expect( find.text(DateFormat.yMMMM().format(lastMonth)), findsOneWidget, ); await tester.tap(find.byFlowySvg(FlowySvgs.arrow_right_s)); await tester.pumpAndSettle(); expect( find.text(DateFormat.yMMMM().format(now)), findsOneWidget, ); }); testWidgets('select date', (tester) async { await tester.pumpWidget( const WidgetTestApp( child: _MockDatePicker(), ), ); await tester.pumpAndSettle(); final now = DateTime.now(); final third = dayInDatePicker(3).first; await tester.tap(third); await tester.pump(); DateTime expected = DateTime(now.year, now.month, 3); AppFlowyDatePickerState afState = getAfState(tester); _MockDatePickerState mockState = getMockState(tester); expect(afState.dateTime, expected); expect(mockState.data.dateTime, null); await tester.pumpAndSettle(); mockState = getMockState(tester); expect(mockState.data.dateTime, expected); final firstOfNextMonth = dayInDatePicker(1); // for certain months, the first of next month isn't shown if (firstOfNextMonth.allCandidates.length == 2) { await tester.tap(firstOfNextMonth); await tester.pumpAndSettle(); expected = DateTime(now.year, now.month + 1); afState = getAfState(tester); expect(afState.dateTime, expected); expect(afState.focusedDateTime, expected); } }); testWidgets('select date range', (tester) async { await tester.pumpWidget( WidgetTestApp( child: _MockDatePicker( data: _DatePickerDataStub( dateTime: null, endDateTime: null, includeTime: false, isRange: true, ), ), ), ); await tester.pumpAndSettle(); AppFlowyDatePickerState afState = getAfState(tester); _MockDatePickerState mockState = getMockState(tester); expect(afState.startDateTime, null); expect(afState.endDateTime, null); expect(mockState.data.dateTime, null); expect(mockState.data.endDateTime, null); // 3-10 final now = DateTime.now(); final third = dayInDatePicker(3).first; await tester.tap(third); await tester.pumpAndSettle(); final expectedStart = DateTime(now.year, now.month, 3); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.startDateTime, expectedStart); expect(afState.endDateTime, null); expect(mockState.data.dateTime, null); expect(mockState.data.endDateTime, null); final tenth = dayInDatePicker(10).first; await tester.tap(tenth); await tester.pump(); final expectedEnd = DateTime(now.year, now.month, 10); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.startDateTime, expectedStart); expect(afState.endDateTime, expectedEnd); expect(mockState.data.dateTime, null); expect(mockState.data.endDateTime, null); await tester.pumpAndSettle(); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.startDateTime, expectedStart); expect(afState.endDateTime, expectedEnd); expect(mockState.data.dateTime, expectedStart); expect(mockState.data.endDateTime, expectedEnd); // 7-18, backwards final eighteenth = dayInDatePicker(18).first; await tester.tap(eighteenth); await tester.pumpAndSettle(); final expectedEnd2 = DateTime(now.year, now.month, 18); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.startDateTime, expectedEnd2); expect(afState.endDateTime, null); expect(mockState.data.dateTime, expectedStart); expect(mockState.data.endDateTime, expectedEnd); final seventh = dayInDatePicker(7).first; await tester.tap(seventh); await tester.pump(); final expectedStart2 = DateTime(now.year, now.month, 7); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.startDateTime, expectedStart2); expect(afState.endDateTime, expectedEnd2); expect(mockState.data.dateTime, expectedStart); expect(mockState.data.endDateTime, expectedEnd); await tester.pumpAndSettle(); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.startDateTime, expectedStart2); expect(afState.endDateTime, expectedEnd2); expect(mockState.data.dateTime, expectedStart2); expect(mockState.data.endDateTime, expectedEnd2); }); testWidgets('select date range after toggling is range', (tester) async { final now = DateTime.now(); final fourteenthDateTime = DateTime(now.year, now.month, 14); await tester.pumpWidget( WidgetTestApp( child: _MockDatePicker( data: _DatePickerDataStub( dateTime: fourteenthDateTime, endDateTime: null, includeTime: false, isRange: false, ), ), ), ); await tester.pumpAndSettle(); AppFlowyDatePickerState afState = getAfState(tester); _MockDatePickerState mockState = getMockState(tester); expect(afState.dateTime, fourteenthDateTime); expect(afState.startDateTime, null); expect(afState.endDateTime, null); expect(afState.justChangedIsRange, false); await tester.tap( find.descendant( of: find.byType(EndTimeButton), matching: find.byType(Toggle), ), ); await tester.pump(); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.isRange, true); expect(afState.dateTime, fourteenthDateTime); expect(afState.startDateTime, fourteenthDateTime); expect(afState.endDateTime, fourteenthDateTime); expect(afState.justChangedIsRange, true); expect(mockState.data.isRange, false); expect(mockState.data.dateTime, fourteenthDateTime); expect(mockState.data.endDateTime, null); await tester.pumpAndSettle(); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.isRange, true); expect(afState.dateTime, fourteenthDateTime); expect(afState.startDateTime, fourteenthDateTime); expect(afState.endDateTime, fourteenthDateTime); expect(afState.justChangedIsRange, true); expect(mockState.data.isRange, true); expect(mockState.data.dateTime, fourteenthDateTime); expect(mockState.data.endDateTime, fourteenthDateTime); final twentyFirst = dayInDatePicker(21).first; await tester.tap(twentyFirst); await tester.pumpAndSettle(); final expected = DateTime(now.year, now.month, 21); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.dateTime, fourteenthDateTime); expect(afState.startDateTime, fourteenthDateTime); expect(afState.endDateTime, expected); expect(afState.justChangedIsRange, false); expect(mockState.data.dateTime, fourteenthDateTime); expect(mockState.data.endDateTime, expected); expect(mockState.data.isRange, true); }); testWidgets('include time and modify', (tester) async { final now = DateTime.now(); final fourteenthDateTime = now.copyWith(day: 14); await tester.pumpWidget( WidgetTestApp( child: _MockDatePicker( data: _DatePickerDataStub( dateTime: DateTime( fourteenthDateTime.year, fourteenthDateTime.month, fourteenthDateTime.day, ), endDateTime: null, includeTime: false, isRange: false, ), ), ), ); await tester.pumpAndSettle(); AppFlowyDatePickerState afState = getAfState(tester); _MockDatePickerState mockState = getMockState(tester); expect(afState.dateTime!.isAtSameDayAs(fourteenthDateTime), true); expect(afState.dateTime!.isAtSameMinuteAs(fourteenthDateTime), false); expect(afState.startDateTime, null); expect(afState.endDateTime, null); expect(afState.includeTime, false); await tester.tap( find.descendant( of: find.byType(IncludeTimeButton), matching: find.byType(Toggle), ), ); await tester.pump(); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.dateTime!.isAtSameMinuteAs(fourteenthDateTime), true); expect(afState.includeTime, true); expect( mockState.data.dateTime!.isAtSameDayAs(fourteenthDateTime), true, ); expect( mockState.data.dateTime!.isAtSameMinuteAs(fourteenthDateTime), false, ); expect(mockState.data.includeTime, false); await tester.pumpAndSettle(300.milliseconds); mockState = getMockState(tester); expect( mockState.data.dateTime!.isAtSameMinuteAs(fourteenthDateTime), true, ); expect(mockState.data.includeTime, true); final timeField = find.byKey(const ValueKey('date_time_text_field_time')); await tester.enterText(timeField, "1"); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(300.milliseconds); DateTime expected = DateTime( fourteenthDateTime.year, fourteenthDateTime.month, fourteenthDateTime.day, 1, ); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.dateTime, expected); expect(mockState.data.dateTime, expected); final dateText = find.descendant( of: find.byKey(const ValueKey('date_time_text_field_date')), matching: find .text(DateFormat(DateFormatPB.Friendly.pattern).format(expected)), ); expect(dateText, findsOneWidget); final timeText = find.descendant( of: find.byKey(const ValueKey('date_time_text_field_time')), matching: find .text(DateFormat(TimeFormatPB.TwelveHour.pattern).format(expected)), ); expect(timeText, findsOneWidget); final third = dayInDatePicker(3).first; await tester.tap(third); await tester.pumpAndSettle(); expected = DateTime( fourteenthDateTime.year, fourteenthDateTime.month, 3, 1, ); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.dateTime, expected); expect(mockState.data.dateTime, expected); }); testWidgets( 'turn on include time, turn on end date, then select date range', (tester) async { final fourteenth = DateTime(2024, 10, 14); await tester.pumpWidget( WidgetTestApp( child: _MockDatePicker( data: _DatePickerDataStub( dateTime: fourteenth, endDateTime: null, includeTime: false, isRange: false, ), ), ), ); await tester.pumpAndSettle(); await tester.tap( find.descendant( of: find.byType(EndTimeButton), matching: find.byType(Toggle), ), ); await tester.pumpAndSettle(); final now = DateTime.now(); await tester.tap( find.descendant( of: find.byType(IncludeTimeButton), matching: find.byType(Toggle), ), ); await tester.pumpAndSettle(); final third = dayInDatePicker(21).first; await tester.tap(third); await tester.pumpAndSettle(); final afState = getAfState(tester); final mockState = getMockState(tester); final expectedTime = Duration(hours: now.hour, minutes: now.minute); final expectedStart = fourteenth.add(expectedTime); final expectedEnd = fourteenth.copyWith(day: 21).add(expectedTime); expect(afState.justChangedIsRange, false); expect(afState.includeTime, true); expect(afState.isRange, true); expect(afState.dateTime, expectedStart); expect(afState.startDateTime, expectedStart); expect(afState.endDateTime, expectedEnd); expect(mockState.data.dateTime, expectedStart); expect(mockState.data.endDateTime, expectedEnd); expect(mockState.data.isRange, true); }, ); testWidgets('edit text field causes start and end to get swapped', (tester) async { final fourteenth = DateTime(2024, 10, 14, 1); await tester.pumpWidget( WidgetTestApp( child: _MockDatePicker( data: _DatePickerDataStub( dateTime: fourteenth, endDateTime: fourteenth, includeTime: true, isRange: true, ), ), ), ); await tester.pumpAndSettle(); expect( find.text( DateFormat(DateFormatPB.Friendly.pattern).format(fourteenth), ), findsNWidgets(2), ); final dateTextField = find.descendant( of: find.byKey(const ValueKey('date_time_text_field')), matching: find.byKey(const ValueKey('date_time_text_field_date')), ); expect(dateTextField, findsOneWidget); await tester.enterText(dateTextField, "Nov 30, 2024"); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); await tester.pumpAndSettle(); final day = DateTime(2024, 11, 30, 1); expect( find.descendant( of: find.byKey(const ValueKey('date_time_text_field')), matching: find.text( DateFormat(DateFormatPB.Friendly.pattern).format(fourteenth), ), ), findsOneWidget, ); expect( find.descendant( of: find.byKey(const ValueKey('end_date_time_text_field')), matching: find.text( DateFormat(DateFormatPB.Friendly.pattern).format(day), ), ), findsOneWidget, ); final mockState = getMockState(tester); expect(mockState.data.dateTime, fourteenth); expect(mockState.data.endDateTime, day); }); testWidgets( 'select start date with calendar and then enter end date with keyboard', (tester) async { final fourteenth = DateTime(2024, 10, 14, 1); await tester.pumpWidget( WidgetTestApp( child: _MockDatePicker( data: _DatePickerDataStub( dateTime: fourteenth, endDateTime: fourteenth, includeTime: true, isRange: true, ), ), ), ); await tester.pumpAndSettle(); final third = dayInDatePicker(3).first; await tester.tap(third); await tester.pumpAndSettle(); final start = DateTime(2024, 10, 3, 1); AppFlowyDatePickerState afState = getAfState(tester); _MockDatePickerState mockState = getMockState(tester); expect(afState.dateTime, start); expect(afState.startDateTime, start); expect(afState.endDateTime, null); expect(mockState.data.dateTime, fourteenth); expect(mockState.data.endDateTime, fourteenth); expect(mockState.data.isRange, true); final dateTextField = find.descendant( of: find.byKey(const ValueKey('end_date_time_text_field')), matching: find.byKey(const ValueKey('date_time_text_field_date')), ); expect(dateTextField, findsOneWidget); await tester.enterText(dateTextField, "Oct 18, 2024"); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); await tester.pumpAndSettle(); final end = DateTime(2024, 10, 18, 1); expect( find.descendant( of: find.byKey(const ValueKey('date_time_text_field')), matching: find.text( DateFormat(DateFormatPB.Friendly.pattern).format(start), ), ), findsOneWidget, ); expect( find.descendant( of: find.byKey(const ValueKey('end_date_time_text_field')), matching: find.text( DateFormat(DateFormatPB.Friendly.pattern).format(end), ), ), findsOneWidget, ); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.dateTime, start); expect(afState.startDateTime, start); expect(afState.endDateTime, end); expect(mockState.data.dateTime, start); expect(mockState.data.endDateTime, end); // make sure click counter was reset final twentyFifth = dayInDatePicker(25).first; final expected = DateTime(2024, 10, 25, 1); await tester.tap(twentyFifth); await tester.pumpAndSettle(); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.dateTime, expected); expect(afState.startDateTime, expected); expect(afState.endDateTime, null); expect(mockState.data.dateTime, start); expect(mockState.data.endDateTime, end); }); testWidgets('same as above but enter time', (tester) async { final fourteenth = DateTime(2024, 10, 14, 1); await tester.pumpWidget( WidgetTestApp( child: _MockDatePicker( data: _DatePickerDataStub( dateTime: fourteenth, endDateTime: fourteenth, includeTime: true, isRange: true, ), ), ), ); await tester.pumpAndSettle(); final third = dayInDatePicker(3).first; await tester.tap(third); await tester.pumpAndSettle(); final start = DateTime(2024, 10, 3, 1); final dateTextField = find.descendant( of: find.byKey(const ValueKey('end_date_time_text_field')), matching: find.byKey(const ValueKey('date_time_text_field_time')), ); expect(dateTextField, findsOneWidget); await tester.enterText(dateTextField, "15:00"); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); await tester.pumpAndSettle(); expect( find.descendant( of: find.byKey(const ValueKey('date_time_text_field')), matching: find.text( DateFormat(DateFormatPB.Friendly.pattern).format(start), ), ), findsOneWidget, ); expect( find.descendant( of: find.byKey(const ValueKey('end_date_time_text_field')), matching: find.text("15:00"), ), findsNothing, ); AppFlowyDatePickerState afState = getAfState(tester); _MockDatePickerState mockState = getMockState(tester); expect(afState.dateTime, start); expect(afState.startDateTime, start); expect(afState.endDateTime, null); expect(mockState.data.dateTime, fourteenth); expect(mockState.data.endDateTime, fourteenth); // select for real now final twentyFifth = dayInDatePicker(25).first; final expected = DateTime(2024, 10, 25, 1); await tester.tap(twentyFifth); await tester.pumpAndSettle(); await tester.pumpAndSettle(); afState = getAfState(tester); mockState = getMockState(tester); expect(afState.dateTime, start); expect(afState.startDateTime, start); expect(afState.endDateTime, expected); expect(mockState.data.dateTime, start); expect(mockState.data.endDateTime, expected); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/direction_setting_test.dart ================================================ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_radio_select.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import '../util.dart'; class MockAppearanceSettingsBloc extends MockBloc implements AppearanceSettingsCubit {} class MockDocumentAppearanceCubit extends Mock implements DocumentAppearanceCubit {} class MockDocumentAppearance extends Mock implements DocumentAppearance {} void main() { late AppearanceSettingsPB appearanceSettings; late DateTimeSettingsPB dateTimeSettings; setUp(() async { await AppFlowyUnitTest.ensureInitialized(); appearanceSettings = await UserSettingsBackendService().getAppearanceSetting(); dateTimeSettings = await UserSettingsBackendService().getDateTimeSettings(); registerFallbackValue(AppFlowyTextDirection.ltr); }); testWidgets('TextDirectionSelect update default text direction setting', (WidgetTester tester) async { final appearanceSettingsState = AppearanceSettingsState.initial( AppTheme.fallback, appearanceSettings.themeMode, appearanceSettings.font, appearanceSettings.layoutDirection, appearanceSettings.textDirection, appearanceSettings.enableRtlToolbarItems, appearanceSettings.locale, appearanceSettings.isMenuCollapsed, appearanceSettings.menuOffset, dateTimeSettings.dateFormat, dateTimeSettings.timeFormat, dateTimeSettings.timezoneId, appearanceSettings.documentSetting.cursorColor.isEmpty ? null : Color( int.parse(appearanceSettings.documentSetting.cursorColor), ), appearanceSettings.documentSetting.selectionColor.isEmpty ? null : Color( int.parse( appearanceSettings.documentSetting.selectionColor, ), ), 1.0, ); final mockAppearanceSettingsBloc = MockAppearanceSettingsBloc(); when(() => mockAppearanceSettingsBloc.state).thenReturn( appearanceSettingsState, ); final mockDocumentAppearanceCubit = MockDocumentAppearanceCubit(); when(() => mockDocumentAppearanceCubit.stream).thenAnswer( (_) => Stream.fromIterable([MockDocumentAppearance()]), ); await tester.pumpWidget( MultiBlocProvider( providers: [ BlocProvider.value( value: mockAppearanceSettingsBloc, ), BlocProvider.value( value: mockDocumentAppearanceCubit, ), ], child: MaterialApp( theme: appearanceSettingsState.lightTheme, home: MultiBlocProvider( providers: [ BlocProvider.value( value: mockAppearanceSettingsBloc, ), BlocProvider.value( value: mockDocumentAppearanceCubit, ), ], child: const Scaffold( body: TextDirectionSelect(), ), ), ), ), ); await tester.pumpAndSettle(); expect( find.text( LocaleKeys.settings_workspacePage_textDirection_leftToRight.tr(), ), findsOne, ); expect( find.text( LocaleKeys.settings_workspacePage_textDirection_rightToLeft.tr(), ), findsOne, ); expect( find.text( LocaleKeys.settings_workspacePage_textDirection_auto.tr(), ), findsOne, ); final radioSelectFinder = find.byType(SettingsRadioSelect); expect(radioSelectFinder, findsOne); when( () => mockAppearanceSettingsBloc.setTextDirection( any(), ), ).thenAnswer((_) async => {}); when( () => mockDocumentAppearanceCubit.syncDefaultTextDirection( any(), ), ).thenAnswer((_) async {}); final radioSelect = tester.widget(radioSelectFinder) as SettingsRadioSelect; final rtlSelect = radioSelect.items .firstWhere((select) => select.value == AppFlowyTextDirection.rtl); radioSelect.onChanged(rtlSelect); verify( () => mockAppearanceSettingsBloc.setTextDirection( any(), ), ).called(1); verify( () => mockDocumentAppearanceCubit.syncDefaultTextDirection( any(), ), ).called(1); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_section/refresh_button_test.dart ================================================ import 'package:appflowy/features/shared_section/presentation/widgets/refresh_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('refresh_button.dart: ', () { testWidgets('shows refresh icon and triggers callback', (WidgetTester tester) async { bool pressed = false; await tester.pumpWidget( WidgetTestWrapper( child: RefreshSharedSectionButton( onTap: () => pressed = true, ), ), ); expect(find.byIcon(Icons.refresh), findsOneWidget); await tester.tap(find.byIcon(Icons.refresh)); await tester.pumpAndSettle(); expect(pressed, isTrue); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_section/shared_page_actions_button_test.dart ================================================ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_page_actions_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import '../../../widget_test_wrapper.dart'; void main() { setUp(() { final mockStorage = MockKeyValueStorage(); // Stub methods to return appropriate Future values when(() => mockStorage.get(any())).thenAnswer((_) => Future.value()); when(() => mockStorage.set(any(), any())).thenAnswer((_) => Future.value()); when(() => mockStorage.remove(any())).thenAnswer((_) => Future.value()); when(() => mockStorage.clear()).thenAnswer((_) => Future.value()); GetIt.I.registerSingleton(mockStorage); GetIt.I.registerSingleton(MenuSharedState()); }); tearDown(() { GetIt.I.reset(); }); group('SharedPageActionsButton: ', () { late ViewPB testView; late List capturedActions; late List capturedEditingStates; setUp(() { testView = ViewPB() ..id = 'test_view_id' ..name = 'Test View' ..layout = ViewLayoutPB.Document ..isFavorite = false; capturedActions = []; capturedEditingStates = []; }); Widget buildTestWidget({ required ShareAccessLevel accessLevel, ViewPB? view, }) { return WidgetTestWrapper( child: Scaffold( body: SharedPageActionsButton( view: view ?? testView, accessLevel: accessLevel, onAction: (type, view, data) { capturedActions.add(type); }, onSetEditing: (context, value) { capturedEditingStates.add(value); }, buildChild: (controller) => ElevatedButton( onPressed: () => controller.show(), child: const Text('Actions'), ), ), ), ); } testWidgets('renders action button correctly', (WidgetTester tester) async { await tester.pumpWidget( buildTestWidget(accessLevel: ShareAccessLevel.readOnly), ); expect(find.text('Actions'), findsOneWidget); expect(find.byType(SharedPageActionsButton), findsOneWidget); }); testWidgets('shows popover when button is tapped', (WidgetTester tester) async { await tester.pumpWidget( buildTestWidget(accessLevel: ShareAccessLevel.readOnly), ); // Tap the button to show popover await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); // Should show the AFMenu popover expect(find.byType(AFMenu), findsOneWidget); }); testWidgets('shows correct menu items for read-only access', (WidgetTester tester) async { await tester.pumpWidget( buildTestWidget(accessLevel: ShareAccessLevel.readOnly), ); await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); // For read-only access, should only show favorite, leave shared page and open in new tab expect(find.byType(AFTextMenuItem), findsNWidgets(3)); // Should find favorite action (since view is not favorited) expect(find.text(ViewMoreActionType.favorite.name), findsOneWidget); expect( find.text(ViewMoreActionType.leaveSharedPage.name), findsOneWidget, ); // Should find open in new tab action expect(find.text(ViewMoreActionType.openInNewTab.name), findsOneWidget); // Should NOT find editable actions expect(find.text(ViewMoreActionType.rename.name), findsNothing); expect(find.text(ViewMoreActionType.delete.name), findsNothing); }); testWidgets('shows correct menu items for edit access', (WidgetTester tester) async { await tester.pumpWidget( buildTestWidget(accessLevel: ShareAccessLevel.readAndWrite), ); await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); // Should show favorite, rename, change icon, and open in new tab expect(find.text(ViewMoreActionType.favorite.name), findsOneWidget); expect(find.text(ViewMoreActionType.rename.name), findsOneWidget); expect(find.text(ViewMoreActionType.changeIcon.name), findsOneWidget); expect(find.text(ViewMoreActionType.openInNewTab.name), findsOneWidget); // Should NOT show delete for edit access expect(find.text(ViewMoreActionType.delete.name), findsNothing); }); testWidgets('shows correct menu items for full access', (WidgetTester tester) async { await tester.pumpWidget( buildTestWidget(accessLevel: ShareAccessLevel.fullAccess), ); await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); // Should show all actions including delete expect(find.text(ViewMoreActionType.favorite.name), findsOneWidget); expect(find.text(ViewMoreActionType.rename.name), findsOneWidget); expect(find.text(ViewMoreActionType.changeIcon.name), findsOneWidget); expect(find.text(ViewMoreActionType.delete.name), findsOneWidget); expect(find.text(ViewMoreActionType.openInNewTab.name), findsOneWidget); }); testWidgets('shows unfavorite when view is favorited', (WidgetTester tester) async { final favoritedView = ViewPB() ..id = 'test_view_id' ..name = 'Test View' ..layout = ViewLayoutPB.Document ..isFavorite = true; await tester.pumpWidget( buildTestWidget( accessLevel: ShareAccessLevel.readOnly, view: favoritedView, ), ); await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); expect(find.text(ViewMoreActionType.unFavorite.name), findsOneWidget); expect(find.text(ViewMoreActionType.favorite.name), findsNothing); }); testWidgets('does not show change icon for chat layout', (WidgetTester tester) async { final chatView = ViewPB() ..id = 'test_view_id' ..name = 'Test Chat' ..layout = ViewLayoutPB.Chat ..isFavorite = false; await tester.pumpWidget( buildTestWidget( accessLevel: ShareAccessLevel.readAndWrite, view: chatView, ), ); await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); // Should show rename but not change icon for chat expect(find.text(ViewMoreActionType.rename.name), findsOneWidget); expect(find.text(ViewMoreActionType.changeIcon.name), findsNothing); }); testWidgets('triggers onAction callback when menu item is tapped', (WidgetTester tester) async { await tester.pumpWidget( buildTestWidget(accessLevel: ShareAccessLevel.fullAccess), ); await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); // Tap on the favorite action await tester.tap(find.text(ViewMoreActionType.favorite.name)); await tester.pumpAndSettle(); expect(capturedActions, contains(ViewMoreActionType.favorite)); }); testWidgets('shows dividers between action groups', (WidgetTester tester) async { await tester.pumpWidget( buildTestWidget(accessLevel: ShareAccessLevel.fullAccess), ); await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); // Should have dividers separating action groups expect(find.byType(AFDivider), findsAtLeastNWidgets(1)); }); testWidgets('delete action shows error color', (WidgetTester tester) async { await tester.pumpWidget( buildTestWidget(accessLevel: ShareAccessLevel.fullAccess), ); await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); // Find the delete menu item final deleteMenuItem = find.ancestor( of: find.text(ViewMoreActionType.delete.name), matching: find.byType(AFTextMenuItem), ); expect(deleteMenuItem, findsOneWidget); // The delete action should be present expect(find.text(ViewMoreActionType.delete.name), findsOneWidget); }); testWidgets('popover hides when menu item is selected', (WidgetTester tester) async { await tester.pumpWidget( buildTestWidget(accessLevel: ShareAccessLevel.readOnly), ); await tester.tap(find.text('Actions')); await tester.pumpAndSettle(); // Popover should be visible expect(find.byType(AFMenu), findsOneWidget); // Tap on favorite action await tester.tap(find.text(ViewMoreActionType.favorite.name)); await tester.pumpAndSettle(); // Popover should be hidden expect(find.byType(AFMenu), findsNothing); }); }); } class MockKeyValueStorage extends Mock implements KeyValueStorage {} ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_section/shared_pages_list_test.dart ================================================ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/shared_section/models/shared_page.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_page_list.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import '../../../widget_test_wrapper.dart'; void main() { setUp(() { final mockStorage = MockKeyValueStorage(); // Stub methods to return appropriate Future values when(() => mockStorage.get(any())).thenAnswer((_) => Future.value()); when(() => mockStorage.set(any(), any())).thenAnswer((_) => Future.value()); when(() => mockStorage.remove(any())).thenAnswer((_) => Future.value()); when(() => mockStorage.clear()).thenAnswer((_) => Future.value()); GetIt.I.registerSingleton(mockStorage); GetIt.I.registerSingleton(MenuSharedState()); }); tearDown(() { GetIt.I.reset(); }); group('shared_pages_list.dart: ', () { testWidgets('shows list of shared pages', (WidgetTester tester) async { final sharedPages = [ SharedPage( view: ViewPB() ..id = '1' ..name = 'Page 1', accessLevel: ShareAccessLevel.readOnly, ), SharedPage( view: ViewPB() ..id = '2' ..name = 'Page 2', accessLevel: ShareAccessLevel.readOnly, ), ]; await tester.pumpWidget( WidgetTestWrapper( child: SingleChildScrollView( child: SharedPageList( sharedPages: sharedPages, onAction: (action, view, data) {}, onSelected: (context, view) {}, onTertiarySelected: (context, view) {}, onSetEditing: (context, value) {}, ), ), ), ); expect(find.text('Page 1'), findsOneWidget); expect(find.text('Page 2'), findsOneWidget); expect(find.byType(SharedPageList), findsOneWidget); }); }); } class MockKeyValueStorage extends Mock implements KeyValueStorage {} ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_section/shared_section_error_test.dart ================================================ import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_error.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('shared_section_error.dart: ', () { testWidgets('shows error message', (WidgetTester tester) async { await tester.pumpWidget( WidgetTestWrapper( child: SharedSectionError(errorMessage: 'An error occurred'), ), ); expect(find.text('An error occurred'), findsOneWidget); expect(find.byType(SharedSectionError), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_section/shared_section_header_test.dart ================================================ import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_header.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('shared_section_header.dart: ', () { testWidgets('shows header title', (WidgetTester tester) async { await tester.pumpWidget( WidgetTestWrapper( child: SharedSectionHeader( onTap: () {}, ), ), ); expect(find.text(LocaleKeys.shareSection_shared.tr()), findsOneWidget); expect(find.byType(SharedSectionHeader), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_section/shared_section_loading_test.dart ================================================ import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_loading.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('shared_section_loading.dart: ', () { testWidgets('shows loading indicator', (WidgetTester tester) async { await tester.pumpWidget( WidgetTestWrapper( child: SharedSectionLoading(), ), ); expect(find.byType(SharedSectionLoading), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_tab/access_level_list_widget_test.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('access_level_list_widget.dart: ', () { testWidgets('shows all access levels and highlights selected', (WidgetTester tester) async { // Track callback invocations ShareAccessLevel? selectedLevel; bool turnedIntoMember = false; bool removedAccess = false; await tester.pumpWidget( WidgetTestWrapper( child: AccessLevelListWidget( selectedAccessLevel: ShareAccessLevel.readAndWrite, supportedAccessLevels: ShareAccessLevel.values, additionalUserManagementOptions: AdditionalUserManagementOptions.values, callbacks: AccessLevelListCallbacks( onSelectAccessLevel: (level) => selectedLevel = level, onTurnIntoMember: () => turnedIntoMember = true, onRemoveAccess: () => removedAccess = true, ), ), ), ); // Check all access level options are present expect(find.text(ShareAccessLevel.fullAccess.title), findsOneWidget); expect(find.text(ShareAccessLevel.readAndWrite.title), findsOneWidget); expect(find.text(ShareAccessLevel.readAndComment.title), findsOneWidget); expect(find.text(ShareAccessLevel.readOnly.title), findsOneWidget); // Check that the selected access level is visually marked final selectedTile = tester .widgetList(find.byType(AFTextMenuItem)) .where((item) => item.selected); expect(selectedTile.length, 1); expect(selectedTile.first.title, ShareAccessLevel.readAndWrite.title); // Tap on another access level await tester.tap(find.text(ShareAccessLevel.readOnly.title)); await tester.pumpAndSettle(); expect(selectedLevel, ShareAccessLevel.readOnly); // Tap on Turn into Member await tester.tap(find.text(LocaleKeys.shareTab_turnIntoMember.tr())); await tester.pumpAndSettle(); expect(turnedIntoMember, isTrue); // Tap on Remove access await tester.tap(find.text(LocaleKeys.shareTab_removeAccess.tr())); await tester.pumpAndSettle(); expect(removedAccess, isTrue); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_tab/copy_link_widget_test.dart ================================================ import 'package:appflowy/features/share_tab/data/repositories/local_share_with_user_repository_impl.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/copy_link_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import '../../../widget_test_wrapper.dart'; void main() { setUpAll(() { registerFallbackValue(const ClipboardServiceData()); }); setUp(() { if (getIt.isRegistered()) { getIt.unregister(); } getIt.registerSingleton(_MockClipboardService()); }); group('copy_link_widget.dart: ', () { testWidgets('shows the share link and copy button, triggers callback', (WidgetTester tester) async { final mockClipboard = getIt() as _MockClipboardService; when(() => mockClipboard.setData(any())).thenAnswer((_) async {}); final bloc = ShareTabBloc( repository: LocalShareWithUserRepositoryImpl(), pageId: 'pageId', workspaceId: 'workspaceId', ); const testLink = 'https://test.link'; await tester.pumpWidget( WidgetTestWrapper( child: BlocProvider.value( value: bloc, child: CopyLinkWidget(shareLink: testLink), ), ), ); expect(find.text(LocaleKeys.shareTab_copyLink.tr()), findsOneWidget); await tester.tap(find.text(LocaleKeys.shareTab_copyLink.tr())); await tester.pumpAndSettle(); await tester.pump(const Duration(seconds: 4)); verify(() => mockClipboard.setData(any())).called(1); }); }); } class _MockClipboardService extends Mock implements ClipboardService {} ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_tab/edit_access_level_widget_test.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('edit_access_level_widget.dart: ', () { testWidgets('shows selected access level and opens popover', (WidgetTester tester) async { await tester.pumpWidget( WidgetTestWrapper( child: EditAccessLevelWidget( selectedAccessLevel: ShareAccessLevel.readOnly, supportedAccessLevels: ShareAccessLevel.values, additionalUserManagementOptions: AdditionalUserManagementOptions.values, callbacks: AccessLevelListCallbacks( onSelectAccessLevel: (level) {}, onTurnIntoMember: () {}, onRemoveAccess: () {}, ), ), ), ); // Check selected access level is shown expect(find.text(ShareAccessLevel.readOnly.title), findsOneWidget); // Tap to open popover await tester.tap(find.text(ShareAccessLevel.readOnly.title)); await tester.pumpAndSettle(); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_tab/general_access_section_test.dart ================================================ import 'package:appflowy/features/share_tab/data/models/shared_group.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/general_access_section.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('general_access_section.dart: ', () { testWidgets('shows section title and SharedGroupWidget', (WidgetTester tester) async { await tester.pumpWidget( WidgetTestWrapper( child: GeneralAccessSection( group: SharedGroup( id: '1', name: 'Group 1', icon: '👥', ), ), ), ); expect(find.text(LocaleKeys.shareTab_generalAccess.tr()), findsOneWidget); expect(find.byType(GeneralAccessSection), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_tab/people_with_access_section_test.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/people_with_access_section.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('people_with_access_section.dart: ', () { testWidgets('shows section title and user widgets, triggers callbacks', (WidgetTester tester) async { final user = SharedUser( name: 'Test User', email: 'test@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: PeopleWithAccessSection( isInPublicPage: true, currentUserEmail: user.email, users: [user], callbacks: PeopleWithAccessSectionCallbacks( onRemoveAccess: (_) {}, onTurnIntoMember: (_) {}, onSelectAccessLevel: (_, level) {}, ), ), ), ); expect(find.text('People with access'), findsOneWidget); expect(find.text('Test User'), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_tab/share_with_user_widget_test.dart ================================================ import 'package:appflowy/features/share_tab/presentation/widgets/share_with_user_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('share_with_user_widget.dart: ', () { testWidgets('shows input and button, triggers callback on valid email', (WidgetTester tester) async { List? invited; await tester.pumpWidget( WidgetTestWrapper( child: ShareWithUserWidget( onInvite: (emails) => invited = emails, ), ), ); expect(find.byType(TextField), findsOneWidget); expect(find.text(LocaleKeys.shareTab_invite.tr()), findsOneWidget); await tester.enterText(find.byType(TextField), 'test@user.com'); await tester.pumpAndSettle(); await tester.tap(find.text(LocaleKeys.shareTab_invite.tr())); await tester.pumpAndSettle(); expect(invited, isNotNull); expect(invited, contains('test@user.com')); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_tab/shared_group_widget_test.dart ================================================ import 'package:appflowy/features/share_tab/data/models/shared_group.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/shared_group_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('shared_group_widget.dart: ', () { testWidgets('shows group name, description, and trailing widget', (WidgetTester tester) async { await tester.pumpWidget( WidgetTestWrapper( child: SharedGroupWidget( group: SharedGroup( id: '1', name: 'Group 1', icon: '👥', ), ), ), ); expect( find.text(LocaleKeys.shareTab_anyoneAtWorkspace.tr()), findsOneWidget, ); expect( find.text(LocaleKeys.shareTab_anyoneInGroupWithLinkCanEdit.tr()), findsOneWidget, ); // Trailing widget: EditAccessLevelWidget (disabled) expect(find.byType(SharedGroupWidget), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/lib/features/share_tab/shared_user_widget_test.dart ================================================ import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/shared_user_widget.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/turn_into_member_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../../widget_test_wrapper.dart'; void main() { group('shared_user_widget.dart: ', () { testWidgets('shows user name, email, and role', (WidgetTester tester) async { final user = SharedUser( name: 'Test User', email: 'test@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: true, user: user, currentUser: user, ), ), ); expect(find.text('Test User'), findsOneWidget); expect(find.text('test@user.com'), findsOneWidget); expect(find.text(LocaleKeys.shareTab_you.tr()), findsOneWidget); }); testWidgets('shows Guest label for guest user', (WidgetTester tester) async { final user = SharedUser( name: 'Guest User', email: 'guest@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.guest, ); await tester.pumpWidget( WidgetTestWrapper( child: IntrinsicWidth( child: SharedUserWidget( isInPublicPage: true, user: user, currentUser: user, ), ), ), ); expect(find.text(LocaleKeys.shareTab_guest.tr()), findsOneWidget); }); testWidgets('readonly user can only see remove self action in menu', (WidgetTester tester) async { final user = SharedUser( name: 'Readonly User', email: 'readonly@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: false, user: user, currentUser: user.copyWith(accessLevel: ShareAccessLevel.readOnly), ), ), ); // Tap the EditAccessLevelWidget to open the menu await tester.tap(find.byType(EditAccessLevelWidget)); await tester.pumpAndSettle(); // Only remove access should be visible as an actionable item expect(find.text(LocaleKeys.shareTab_removeAccess.tr()), findsOneWidget); }); testWidgets('edit user can only see remove self action in menu', (WidgetTester tester) async { final user = SharedUser( name: 'Edit User', email: 'edit@user.com', accessLevel: ShareAccessLevel.readAndWrite, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: false, user: user, currentUser: user.copyWith( accessLevel: ShareAccessLevel.readAndWrite, ), ), ), ); // Tap the EditAccessLevelWidget to open the menu await tester.tap(find.byType(EditAccessLevelWidget)); await tester.pumpAndSettle(); // Only remove access should be visible as an actionable item expect(find.text(LocaleKeys.shareTab_removeAccess.tr()), findsOneWidget); }); testWidgets('full access user can change another people permission', (WidgetTester tester) async { final user = SharedUser( name: 'Other User', email: 'other@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.member, ); final currentUser = SharedUser( name: 'Full Access User', email: 'full@user.com', accessLevel: ShareAccessLevel.fullAccess, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: false, user: user, currentUser: currentUser, ), ), ); // Tap the EditAccessLevelWidget to open the menu await tester.tap(find.byType(EditAccessLevelWidget)); await tester.pumpAndSettle(); // Permission change options should be visible expect(find.text(ShareAccessLevel.readOnly.title), findsWidgets); expect(find.text(ShareAccessLevel.readAndWrite.title), findsWidgets); expect(find.text(LocaleKeys.shareTab_removeAccess.tr()), findsOneWidget); }); testWidgets('full access user can turn a guest into member', (WidgetTester tester) async { bool turnedIntoMember = false; final guestUser = SharedUser( name: 'Guest User', email: 'guest@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.guest, ); final currentUser = SharedUser( name: 'Full Access User', email: 'full@user.com', accessLevel: ShareAccessLevel.fullAccess, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: true, user: guestUser, currentUser: currentUser, callbacks: AccessLevelListCallbacks( onSelectAccessLevel: (_) {}, onTurnIntoMember: () { turnedIntoMember = true; }, onRemoveAccess: () {}, ), ), ), ); // The TurnIntoMemberWidget should be present expect(find.byType(TurnIntoMemberWidget), findsOneWidget); // Tap the button (AFGhostButton inside TurnIntoMemberWidget) await tester.tap(find.byType(TurnIntoMemberWidget)); await tester.pumpAndSettle(); expect(turnedIntoMember, isTrue); }); // Additional tests for more coverage testWidgets('public page: member/owner always gets disabled button', (WidgetTester tester) async { final user = SharedUser( name: 'Member User', email: 'member@user.com', accessLevel: ShareAccessLevel.readAndWrite, role: ShareRole.member, ); final currentUser = user.copyWith(accessLevel: ShareAccessLevel.fullAccess); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: true, user: user, currentUser: currentUser, ), ), ); expect(find.byType(AFGhostTextButton), findsOneWidget); expect(find.byType(EditAccessLevelWidget), findsNothing); }); testWidgets('private page: full access user can manage others', (WidgetTester tester) async { final user = SharedUser( name: 'Other User', email: 'other@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.member, ); final currentUser = SharedUser( name: 'Full Access User', email: 'full@user.com', accessLevel: ShareAccessLevel.fullAccess, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: false, user: user, currentUser: currentUser, ), ), ); expect(find.byType(EditAccessLevelWidget), findsOneWidget); }); testWidgets('private page: readonly user sees disabled button for others', (WidgetTester tester) async { final user = SharedUser( name: 'Other User', email: 'other@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.member, ); final currentUser = SharedUser( name: 'Readonly User', email: 'readonly@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: false, user: user, currentUser: currentUser, ), ), ); expect(find.byType(AFGhostTextButton), findsOneWidget); expect(find.byType(EditAccessLevelWidget), findsNothing); }); testWidgets('self: full access user cannot change own access', (WidgetTester tester) async { final user = SharedUser( name: 'Full Access User', email: 'full@user.com', accessLevel: ShareAccessLevel.fullAccess, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: false, user: user, currentUser: user, ), ), ); expect(find.byType(AFGhostTextButton), findsOneWidget); expect(find.byType(EditAccessLevelWidget), findsNothing); }); testWidgets('self: readonly user can only remove self', (WidgetTester tester) async { final user = SharedUser( name: 'Readonly User', email: 'readonly@user.com', accessLevel: ShareAccessLevel.readOnly, role: ShareRole.member, ); await tester.pumpWidget( WidgetTestWrapper( child: SharedUserWidget( isInPublicPage: false, user: user, currentUser: user, ), ), ); expect(find.byType(EditAccessLevelWidget), findsOneWidget); // Open the menu and check only remove access is present await tester.tap(find.byType(EditAccessLevelWidget)); await tester.pumpAndSettle(); expect(find.text(LocaleKeys.shareTab_removeAccess.tr()), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/select_option_text_field_test.dart ================================================ import 'dart:collection'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_text_field.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../bloc_test/grid_test/util.dart'; void main() { setUpAll(() { AppFlowyGridTest.ensureInitialized(); }); group('text_field.dart', () { String submit = ''; String remainder = ''; List select = []; final textController = TextEditingController(); final textField = SelectOptionTextField( options: const [], selectedOptionMap: LinkedHashMap(), distanceToText: 0.0, onSubmitted: () => submit = textController.text, onPaste: (options, remaining) { remainder = remaining; select = options; }, onRemove: (_) {}, newText: (text) => remainder = text, textSeparators: const [','], textController: textController, focusNode: FocusNode(), ); testWidgets('SelectOptionTextField callback outputs', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Material( child: textField, ), ), ); // test that the input field exists expect(find.byType(TextField), findsOneWidget); // simulate normal input await tester.enterText(find.byType(TextField), 'abcd'); expect(remainder, 'abcd'); await tester.enterText(find.byType(TextField), ' '); expect(remainder, ''); // test submit functionality (aka pressing enter) await tester.enterText(find.byType(TextField), 'an option'); await tester.testTextInput.receiveAction(TextInputAction.done); expect(submit, 'an option'); // test inputs containing commas await tester.enterText(find.byType(TextField), 'a a, bbbb , c'); expect(remainder, 'c'); expect(select, ['a a', 'bbbb']); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/spae_cion_test.dart ================================================ import 'dart:convert'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('space_icon.dart', () { testWidgets('space icon is empty', (WidgetTester tester) async { final emptySpaceIcon = { ViewExtKeys.spaceIconKey: '', ViewExtKeys.spaceIconColorKey: '', }; final space = ViewPB( name: 'test', extra: jsonEncode(emptySpaceIcon), ); await tester.pumpWidget( MaterialApp( home: Material( child: SpaceIcon(dimension: 22, space: space), ), ), ); // test that the input field exists expect(find.byType(SpaceIcon), findsOneWidget); // use the first character of page name as icon expect(find.text('T'), findsOneWidget); }); testWidgets('space icon is null', (WidgetTester tester) async { final emptySpaceIcon = { ViewExtKeys.spaceIconKey: null, ViewExtKeys.spaceIconColorKey: null, }; final space = ViewPB( name: 'test', extra: jsonEncode(emptySpaceIcon), ); await tester.pumpWidget( MaterialApp( home: Material( child: SpaceIcon(dimension: 22, space: space), ), ), ); expect(find.byType(SpaceIcon), findsOneWidget); // use the first character of page name as icon expect(find.text('T'), findsOneWidget); }); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/test_asset_bundle.dart ================================================ import 'dart:convert'; import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; /// TestAssetBundle is required in order to avoid issues with large assets /// /// ref: https://medium.com/@sardox/flutter-test-and-randomly-missing-assets-in-goldens-ea959cdd336a /// /// "If your AssetManifest.json file exceeds 10kb, it will be /// loaded with isolate that (most likely) will cause your /// test to finish before assets are loaded so goldens will /// get empty assets." /// class TestAssetBundle extends CachingAssetBundle { @override Future loadString(String key, {bool cache = true}) async { // overriding this method to avoid limit of 10KB per asset try { final data = await load(key); return utf8.decode(data.buffer.asUint8List()); } catch (err) { throw FlutterError('Unable to load asset: $key'); } } @override Future load(String key) async => rootBundle.load(key); } final testAssetBundle = TestAssetBundle(); /// Loads from our custom asset bundle class TestBundleAssetLoader extends AssetLoader { const TestBundleAssetLoader(); String getLocalePath(String basePath, Locale locale) { return '$basePath/${locale.toStringWithSeparator(separator: "-")}.json'; } @override Future> load(String path, Locale locale) async { final localePath = getLocalePath(path, locale); return json.decode(await testAssetBundle.loadString(localePath)); } } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/test_material_app.dart ================================================ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flutter/material.dart'; import 'test_asset_bundle.dart'; class WidgetTestApp extends StatelessWidget { const WidgetTestApp({ super.key, required this.child, }); final Widget child; @override Widget build(BuildContext context) { return EasyLocalization( supportedLocales: const [Locale('en', 'US')], path: 'assets/translations', fallbackLocale: const Locale('en', 'US'), useFallbackTranslations: true, saveLocale: false, assetLoader: const TestBundleAssetLoader(), child: Builder( builder: (context) => MaterialApp( locale: const Locale('en', 'US'), localizationsDelegates: context.localizationDelegates, theme: ThemeData.light().copyWith( extensions: const [ AFThemeExtension( warning: Colors.transparent, success: Colors.transparent, tint1: Colors.transparent, tint2: Colors.transparent, tint3: Colors.transparent, tint4: Colors.transparent, tint5: Colors.transparent, tint6: Colors.transparent, tint7: Colors.transparent, tint8: Colors.transparent, tint9: Colors.transparent, textColor: Colors.transparent, secondaryTextColor: Colors.transparent, strongText: Colors.transparent, greyHover: Colors.transparent, greySelect: Colors.transparent, lightGreyHover: Colors.transparent, toggleOffFill: Colors.transparent, progressBarBGColor: Colors.transparent, toggleButtonBGColor: Colors.transparent, calendarWeekendBGColor: Colors.transparent, gridRowCountColor: Colors.transparent, code: TextStyle(), callout: TextStyle(), calloutBGColor: Colors.transparent, tableCellBGColor: Colors.transparent, caption: TextStyle(), onBackground: Colors.transparent, background: Colors.transparent, borderColor: Colors.transparent, scrollbarColor: Colors.transparent, scrollbarHoverColor: Colors.transparent, lightIconColor: Colors.transparent, toolbarHoverColor: Colors.transparent, ), ], ), home: Scaffold( body: child, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart ================================================ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; class MockAppearanceSettingsCubit extends Mock implements AppearanceSettingsCubit {} class MockDocumentAppearanceCubit extends Mock implements DocumentAppearanceCubit {} class MockAppearanceSettingsState extends Mock implements AppearanceSettingsState {} class MockDocumentAppearance extends Mock implements DocumentAppearance {} void main() { late MockAppearanceSettingsCubit appearanceSettingsCubit; late MockDocumentAppearanceCubit documentAppearanceCubit; setUp(() { appearanceSettingsCubit = MockAppearanceSettingsCubit(); when(() => appearanceSettingsCubit.stream).thenAnswer( (_) => Stream.fromIterable([MockAppearanceSettingsState()]), ); documentAppearanceCubit = MockDocumentAppearanceCubit(); when(() => documentAppearanceCubit.stream).thenAnswer( (_) => Stream.fromIterable([MockDocumentAppearance()]), ); }); testWidgets('ThemeFontFamilySetting updates font family on selection', (WidgetTester tester) async { await tester.pumpWidget( MultiBlocProvider( providers: [ BlocProvider.value( value: appearanceSettingsCubit, ), BlocProvider.value( value: documentAppearanceCubit, ), ], child: MaterialApp( home: MultiBlocProvider( providers: [ BlocProvider.value( value: appearanceSettingsCubit, ), BlocProvider.value( value: documentAppearanceCubit, ), ], child: const Scaffold( body: ThemeFontFamilySetting( currentFontFamily: defaultFontFamily, ), ), ), ), ), ); final popover = find.byType(AppFlowyPopover); await tester.tap(popover); await tester.pumpAndSettle(); // Verify the initial font family expect( find.text(LocaleKeys.settings_appearance_fontFamily_defaultFont.tr()), findsAtLeastNWidgets(1), ); when(() => appearanceSettingsCubit.setFontFamily(any())) .thenAnswer((_) async {}); verifyNever(() => appearanceSettingsCubit.setFontFamily(any())); when(() => documentAppearanceCubit.syncFontFamily(any())) .thenAnswer((_) async {}); verifyNever(() => documentAppearanceCubit.syncFontFamily(any())); // Tap on a different font family final abel = find.textContaining('Abel'); await tester.tap(abel); await tester.pumpAndSettle(); // Verify that the font family is updated verify(() => appearanceSettingsCubit.setFontFamily(any())) .called(1); verify(() => documentAppearanceCubit.syncFontFamily(any())) .called(1); }); } ================================================ FILE: frontend/appflowy_flutter/test/widget_test/widget_test_wrapper.dart ================================================ import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; class WidgetTestWrapper extends StatelessWidget { const WidgetTestWrapper({super.key, required this.child}); final Widget child; @override Widget build(BuildContext context) { final brightness = Theme.of(context).brightness; final themeBuilder = AppFlowyDefaultTheme(); return ToastificationWrapper( child: MaterialApp( home: Material( child: AppFlowyTheme( data: brightness == Brightness.light ? themeBuilder.light() : themeBuilder.dark(), child: child, ), ), ), ); } } ================================================ FILE: frontend/appflowy_flutter/web/index.html ================================================ appflowy_flutter ================================================ FILE: frontend/appflowy_flutter/web/manifest.json ================================================ { "name": "appflowy_flutter", "short_name": "appflowy_flutter", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", "description": "A new Flutter project.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ { "src": "icons/Icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "icons/Icon-512.png", "sizes": "512x512", "type": "image/png" } ] } ================================================ FILE: frontend/appflowy_flutter/windows/.gitignore ================================================ flutter/ephemeral/ # Visual Studio user-specific files. *.suo *.user *.userosscache *.sln.docstates # Visual Studio build-related files. x64/ x86/ # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ flutter/generated_plugin_registrant.cc flutter/generated_plugin_registrant.h flutter/generated_plugins.cmake ================================================ FILE: frontend/appflowy_flutter/windows/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) project(appflowy_flutter LANGUAGES CXX) set(BINARY_NAME "AppFlowy") cmake_policy(SET CMP0063 NEW) set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") # Configure build options. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" CACHE STRING "" FORCE) else() 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() endif() set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") # Use Unicode for all projects. add_definitions(-DUNICODE -D_UNICODE) # Compilation settings that should be applied to most targets. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") target_compile_options(${TARGET} PRIVATE /EHsc) target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") endfunction() set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") # Flutter library and tool build rules. add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build add_subdirectory("runner") # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) # === Installation === # Support files are copied into place next to the executable, so that it can # run in place. This is done instead of making a separate bundle (as on Linux) # so that building and running from within Visual Studio will work. set(BUILD_BUNDLE_DIR "$") # Make the "install" step default, as it's required to run. set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) endif() set(DART_FFI_DIR "${CMAKE_INSTALL_PREFIX}") set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") 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 "${DART_FFI_DLL}" DESTINATION "${DART_FFI_DIR}" COMPONENT Runtime) install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) if(PLUGIN_BUNDLED_LIBRARIES) install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() # 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. install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" CONFIGURATIONS Profile;Release COMPONENT Runtime) ================================================ FILE: frontend/appflowy_flutter/windows/flutter/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) 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. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") # Set fallback configurations for older versions of the flutter tool. if (NOT DEFINED FLUTTER_TARGET_PLATFORM) set(FLUTTER_TARGET_PLATFORM "windows-x64") endif() # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") # 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(DART_FFI_DLL "${CMAKE_CURRENT_SOURCE_DIR}/dart_ffi/dart_ffi.dll" PARENT_SCOPE) set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) list(APPEND FLUTTER_LIBRARY_HEADERS "flutter_export.h" "flutter_windows.h" "flutter_messenger.h" "flutter_plugin_registrar.h" "flutter_texture_registrar.h" ) list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") add_library(flutter INTERFACE) target_include_directories(flutter INTERFACE "${EPHEMERAL_DIR}" ) target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") add_dependencies(flutter flutter_assemble) # === Wrapper === list(APPEND CPP_WRAPPER_SOURCES_CORE "core_implementations.cc" "standard_codec.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_PLUGIN "plugin_registrar.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_APP "flutter_engine.cc" "flutter_view_controller.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") # Wrapper sources needed for a plugin. add_library(flutter_wrapper_plugin STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ) apply_standard_settings(flutter_wrapper_plugin) set_target_properties(flutter_wrapper_plugin PROPERTIES POSITION_INDEPENDENT_CODE ON) set_target_properties(flutter_wrapper_plugin PROPERTIES CXX_VISIBILITY_PRESET hidden) target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) target_include_directories(flutter_wrapper_plugin PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_plugin flutter_assemble) # Wrapper sources needed for the runner. add_library(flutter_wrapper_app STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_APP} ) apply_standard_settings(flutter_wrapper_app) target_link_libraries(flutter_wrapper_app PUBLIC flutter) target_include_directories(flutter_wrapper_app PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_app 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. set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) add_custom_command( OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ${PHONY_OUTPUT} COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS "${FLUTTER_LIBRARY}" ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ) ================================================ FILE: frontend/appflowy_flutter/windows/runner/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) project(runner LANGUAGES CXX) # Define the application target. To change its name, change BINARY_NAME in the # top-level CMakeLists.txt, 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} WIN32 "flutter_window.cpp" "main.cpp" "utils.cpp" "win32_window.cpp" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" "Runner.rc" "runner.exe.manifest" ) # Apply the standard set of build settings. This can be removed for applications # that need different build settings. apply_standard_settings(${BINARY_NAME}) # Add preprocessor definitions for the build version. target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") # Disable Windows macros that collide with C++ standard library functions. target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") # Add dependency libraries and include directories. Add any application-specific # dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) ================================================ FILE: frontend/appflowy_flutter/windows/runner/Runner.rc ================================================ // Microsoft Visual C++ generated resource script. // #pragma code_page(65001) #include "resource.h" #define APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 2 resource. // #include "winres.h" ///////////////////////////////////////////////////////////////////////////// #undef APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // English (United States) resources #if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US #ifdef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // TEXTINCLUDE // 1 TEXTINCLUDE BEGIN "resource.h\0" END 2 TEXTINCLUDE BEGIN "#include ""winres.h""\r\n" "\0" END 3 TEXTINCLUDE BEGIN "\r\n" "\0" END #endif // APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Icon // // Icon with lowest ID value placed first to ensure application icon // remains consistent on all systems. IDI_APP_ICON ICON "resources\\app_icon.ico" ///////////////////////////////////////////////////////////////////////////// // // Version // #if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) #define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else #define VERSION_AS_NUMBER 1,0,0,0 #endif #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else #define VERSION_AS_STRING "1.0.0" #endif VS_VERSION_INFO VERSIONINFO FILEVERSION VERSION_AS_NUMBER PRODUCTVERSION VERSION_AS_NUMBER FILEFLAGSMASK VS_FFI_FILEFLAGSMASK #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG #else FILEFLAGS 0x0L #endif FILEOS VOS__WINDOWS32 FILETYPE VFT_APP FILESUBTYPE 0x0L BEGIN BLOCK "StringFileInfo" BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "io.appflowy" "\0" VALUE "FileDescription", "AppFlowy" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "AppFlowy" "\0" VALUE "LegalCopyright", "Copyright (C) 2024 io.appflowy. All rights reserved." "\0" VALUE "OriginalFilename", "AppFlowy.exe" "\0" VALUE "ProductName", "AppFlowy" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END BLOCK "VarFileInfo" BEGIN VALUE "Translation", 0x409, 1252 END END #endif // English (United States) resources ///////////////////////////////////////////////////////////////////////////// #ifndef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 3 resource. // ///////////////////////////////////////////////////////////////////////////// #endif // not APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // WinSparkle // // And verify signature using DSA public key: DSAPub DSAPEM "../../dsa_pub.pem" ================================================ FILE: frontend/appflowy_flutter/windows/runner/flutter_window.cpp ================================================ #include "flutter_window.h" #include #include "flutter/generated_plugin_registrant.h" FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} FlutterWindow::~FlutterWindow() {} bool FlutterWindow::OnCreate() { if (!Win32Window::OnCreate()) { return false; } RECT frame = GetClientArea(); // The size here must match the window dimensions to avoid unnecessary surface // creation / destruction in the startup path. flutter_controller_ = std::make_unique( frame.right - frame.left, frame.bottom - frame.top, project_); // Ensure that basic setup of the controller was successful. if (!flutter_controller_->engine() || !flutter_controller_->view()) { return false; } RegisterPlugins(flutter_controller_->engine()); SetChildContent(flutter_controller_->view()->GetNativeWindow()); flutter_controller_->engine()->SetNextFrameCallback([&]() { // https://pub.dev/packages/window_manager#windows // this->Show(); }); // Flutter can complete the first frame before the "show window" callback is // registered. The following call ensures a frame is pending to ensure the // window is shown. It is a no-op if the first frame hasn't completed yet. flutter_controller_->ForceRedraw(); return true; } void FlutterWindow::OnDestroy() { if (flutter_controller_) { flutter_controller_ = nullptr; } Win32Window::OnDestroy(); } LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam); if (result) { return *result; } } switch (message) { case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; } return Win32Window::MessageHandler(hwnd, message, wparam, lparam); } ================================================ FILE: frontend/appflowy_flutter/windows/runner/flutter_window.h ================================================ #ifndef RUNNER_FLUTTER_WINDOW_H_ #define RUNNER_FLUTTER_WINDOW_H_ #include #include #include #include "win32_window.h" // A window that does nothing but host a Flutter view. class FlutterWindow : public Win32Window { public: // Creates a new FlutterWindow hosting a Flutter view running |project|. explicit FlutterWindow(const flutter::DartProject& project); virtual ~FlutterWindow(); protected: // Win32Window: bool OnCreate() override; void OnDestroy() override; LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept override; private: // The project to run. flutter::DartProject project_; // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; }; #endif // RUNNER_FLUTTER_WINDOW_H_ ================================================ FILE: frontend/appflowy_flutter/windows/runner/main.cpp ================================================ #include #include #include #include "flutter_window.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { HANDLE hMutexInstance = CreateMutex(NULL, TRUE, L"AppFlowyMutex"); HWND handle = FindWindowA(NULL, "AppFlowy"); if (GetLastError() == ERROR_ALREADY_EXISTS) { flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); if (window.SendAppLinkToInstance(L"AppFlowy")) { return false; } WINDOWPLACEMENT place = {sizeof(WINDOWPLACEMENT)}; GetWindowPlacement(handle, &place); ShowWindow(handle, SW_NORMAL); return 0; } // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { CreateAndAttachConsole(); } // Initialize COM, so that it is available for use in the library and/or // plugins. ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); if (!window.Create(L"AppFlowy", origin, size)) { return EXIT_FAILURE; } window.Show(); window.SetQuitOnClose(true); ::MSG msg; while (::GetMessage(&msg, nullptr, 0, 0)) { ::TranslateMessage(&msg); ::DispatchMessage(&msg); } ::CoUninitialize(); ReleaseMutex(hMutexInstance); return EXIT_SUCCESS; } ================================================ FILE: frontend/appflowy_flutter/windows/runner/resource.h ================================================ //{{NO_DEPENDENCIES}} // Microsoft Visual C++ generated include file. // Used by Runner.rc // #define IDI_APP_ICON 101 // Next default values for new objects // #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 102 #define _APS_NEXT_COMMAND_VALUE 40001 #define _APS_NEXT_CONTROL_VALUE 1001 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif ================================================ FILE: frontend/appflowy_flutter/windows/runner/runner.exe.manifest ================================================ PerMonitorV2 ================================================ FILE: frontend/appflowy_flutter/windows/runner/utils.cpp ================================================ #include "utils.h" #include #include #include #include #include void CreateAndAttachConsole() { if (::AllocConsole()) { FILE *unused; if (freopen_s(&unused, "CONOUT$", "w", stdout)) { _dup2(_fileno(stdout), 1); } if (freopen_s(&unused, "CONOUT$", "w", stderr)) { _dup2(_fileno(stdout), 2); } std::ios::sync_with_stdio(); FlutterDesktopResyncOutputStreams(); } } std::vector GetCommandLineArguments() { // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. int argc; wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); if (argv == nullptr) { return std::vector(); } std::vector command_line_arguments; // Skip the first argument as it's the binary name. for (int i = 1; i < argc; i++) { command_line_arguments.push_back(Utf8FromUtf16(argv[i])); } ::LocalFree(argv); return command_line_arguments; } std::string Utf8FromUtf16(const wchar_t* utf16_string) { if (utf16_string == nullptr) { return std::string(); } int target_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, nullptr, 0, nullptr, nullptr); if (target_length == 0) { return std::string(); } std::string utf8_string; utf8_string.resize(target_length); int converted_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, utf8_string.data(), target_length, nullptr, nullptr); if (converted_length == 0) { return std::string(); } return utf8_string; } ================================================ FILE: frontend/appflowy_flutter/windows/runner/utils.h ================================================ #ifndef RUNNER_UTILS_H_ #define RUNNER_UTILS_H_ #include #include // Creates a console for the process, and redirects stdout and stderr to // it for both the runner and the Flutter library. void CreateAndAttachConsole(); // Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string // encoded in UTF-8. Returns an empty std::string on failure. std::string Utf8FromUtf16(const wchar_t* utf16_string); // Gets the command line arguments passed in as a std::vector, // encoded in UTF-8. Returns an empty std::vector on failure. std::vector GetCommandLineArguments(); #endif // RUNNER_UTILS_H_ ================================================ FILE: frontend/appflowy_flutter/windows/runner/win32_window.cpp ================================================ #include "win32_window.h" #include #include #include "resource.h" #include "app_links/app_links_plugin_c_api.h" namespace { /// Window attribute that enables dark mode window decorations. /// /// Redefined in case the developer's machine has a Windows SDK older than /// version 10.0.22000.0. /// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute #ifndef DWMWA_USE_IMMERSIVE_DARK_MODE #define DWMWA_USE_IMMERSIVE_DARK_MODE 20 #endif constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; /// Registry key for app theme preference. /// /// A value of 0 indicates apps should use dark mode. A non-zero or missing /// value indicates apps should use light mode. constexpr const wchar_t kGetPreferredBrightnessRegKey[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; // The number of Win32Window objects that currently exist. static int g_active_window_count = 0; using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); // Scale helper to convert logical scaler values to physical using passed in // scale factor int Scale(int source, double scale_factor) { return static_cast(source * scale_factor); } // Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. // This API is only needed for PerMonitor V1 awareness mode. void EnableFullDpiSupportIfAvailable(HWND hwnd) { HMODULE user32_module = LoadLibraryA("User32.dll"); if (!user32_module) { return; } auto enable_non_client_dpi_scaling = reinterpret_cast( GetProcAddress(user32_module, "EnableNonClientDpiScaling")); if (enable_non_client_dpi_scaling != nullptr) { enable_non_client_dpi_scaling(hwnd); } FreeLibrary(user32_module); } } // namespace // Manages the Win32Window's window class registration. class WindowClassRegistrar { public: ~WindowClassRegistrar() = default; // Returns the singleton registrar instance. static WindowClassRegistrar* GetInstance() { if (!instance_) { instance_ = new WindowClassRegistrar(); } return instance_; } // Returns the name of the window class, registering the class if it hasn't // previously been registered. const wchar_t* GetWindowClass(); // Unregisters the window class. Should only be called if there are no // instances of the window. void UnregisterWindowClass(); private: WindowClassRegistrar() = default; static WindowClassRegistrar* instance_; bool class_registered_ = false; }; WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; const wchar_t* WindowClassRegistrar::GetWindowClass() { if (!class_registered_) { WNDCLASS window_class{}; window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); window_class.lpszClassName = kWindowClassName; window_class.style = CS_HREDRAW | CS_VREDRAW; window_class.cbClsExtra = 0; window_class.cbWndExtra = 0; window_class.hInstance = GetModuleHandle(nullptr); window_class.hIcon = LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); window_class.hbrBackground = 0; window_class.lpszMenuName = nullptr; window_class.lpfnWndProc = Win32Window::WndProc; RegisterClass(&window_class); class_registered_ = true; } return kWindowClassName; } void WindowClassRegistrar::UnregisterWindowClass() { UnregisterClass(kWindowClassName, nullptr); class_registered_ = false; } Win32Window::Win32Window() { ++g_active_window_count; } Win32Window::~Win32Window() { --g_active_window_count; Destroy(); } bool Win32Window::Create(const std::wstring& title, const Point& origin, const Size& size) { Destroy(); if (SendAppLinkToInstance(title)) { return false; } const wchar_t* window_class = WindowClassRegistrar::GetInstance()->GetWindowClass(); const POINT target_point = {static_cast(origin.x), static_cast(origin.y)}; HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); double scale_factor = dpi / 96.0; HWND window = CreateWindow( window_class, title.c_str(), WS_OVERLAPPEDWINDOW, Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), Scale(size.width, scale_factor), Scale(size.height, scale_factor), nullptr, nullptr, GetModuleHandle(nullptr), this); if (!window) { return false; } UpdateTheme(window); return OnCreate(); } bool Win32Window::Show() { return ShowWindow(window_handle_, SW_SHOWNORMAL); } // static LRESULT CALLBACK Win32Window::WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { if (message == WM_NCCREATE) { auto window_struct = reinterpret_cast(lparam); SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast(window_struct->lpCreateParams)); auto that = static_cast(window_struct->lpCreateParams); EnableFullDpiSupportIfAvailable(window); that->window_handle_ = window; } else if (Win32Window* that = GetThisFromHandle(window)) { return that->MessageHandler(window, message, wparam, lparam); } return DefWindowProc(window, message, wparam, lparam); } LRESULT Win32Window::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { switch (message) { case WM_DESTROY: window_handle_ = nullptr; Destroy(); if (quit_on_close_) { PostQuitMessage(0); } return 0; case WM_DPICHANGED: { auto newRectSize = reinterpret_cast(lparam); LONG newWidth = newRectSize->right - newRectSize->left; LONG newHeight = newRectSize->bottom - newRectSize->top; SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, newHeight, SWP_NOZORDER | SWP_NOACTIVATE); return 0; } case WM_SIZE: { RECT rect = GetClientArea(); if (child_content_ != nullptr) { // Size and position the child window. MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, TRUE); } return 0; } case WM_ACTIVATE: if (child_content_ != nullptr) { SetFocus(child_content_); } return 0; case WM_DWMCOLORIZATIONCOLORCHANGED: UpdateTheme(hwnd); return 0; } return DefWindowProc(window_handle_, message, wparam, lparam); } void Win32Window::Destroy() { OnDestroy(); if (window_handle_) { DestroyWindow(window_handle_); window_handle_ = nullptr; } if (g_active_window_count == 0) { WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); } } Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { return reinterpret_cast( GetWindowLongPtr(window, GWLP_USERDATA)); } void Win32Window::SetChildContent(HWND content) { child_content_ = content; SetParent(content, window_handle_); RECT frame = GetClientArea(); MoveWindow(content, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, true); SetFocus(child_content_); } RECT Win32Window::GetClientArea() { RECT frame; GetClientRect(window_handle_, &frame); return frame; } HWND Win32Window::GetHandle() { return window_handle_; } void Win32Window::SetQuitOnClose(bool quit_on_close) { quit_on_close_ = quit_on_close; } bool Win32Window::OnCreate() { // No-op; provided for subclasses. return true; } void Win32Window::OnDestroy() { // No-op; provided for subclasses. } void Win32Window::UpdateTheme(HWND const window) { DWORD light_mode; DWORD light_mode_size = sizeof(light_mode); LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr, &light_mode, &light_mode_size); if (result == ERROR_SUCCESS) { BOOL enable_dark_mode = light_mode == 0; DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, &enable_dark_mode, sizeof(enable_dark_mode)); } } bool Win32Window::SendAppLinkToInstance(const std::wstring &title) { // Find our exact window HWND hwnd = ::FindWindow(kWindowClassName, title.c_str()); if (hwnd) { // Dispatch new link to current window SendAppLink(hwnd); // (Optional) Restore our window to front in same state WINDOWPLACEMENT place = {sizeof(WINDOWPLACEMENT)}; GetWindowPlacement(hwnd, &place); switch (place.showCmd) { case SW_SHOWMAXIMIZED: ShowWindow(hwnd, SW_SHOWMAXIMIZED); break; case SW_SHOWMINIMIZED: ShowWindow(hwnd, SW_RESTORE); break; default: ShowWindow(hwnd, SW_NORMAL); break; } SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE); SetForegroundWindow(hwnd); // Window has been found, don't create another one. return true; } return false; } ================================================ FILE: frontend/appflowy_flutter/windows/runner/win32_window.h ================================================ #ifndef RUNNER_WIN32_WINDOW_H_ #define RUNNER_WIN32_WINDOW_H_ #include #include #include #include // A class abstraction for a high DPI-aware Win32 Window. Intended to be // inherited from by classes that wish to specialize with custom // rendering and input handling class Win32Window { public: struct Point { unsigned int x; unsigned int y; Point(unsigned int x, unsigned int y) : x(x), y(y) {} }; struct Size { unsigned int width; unsigned int height; Size(unsigned int width, unsigned int height) : width(width), height(height) {} }; Win32Window(); virtual ~Win32Window(); // Creates a win32 window with |title| that is positioned and sized using // |origin| and |size|. New windows are created on the default monitor. Window // sizes are specified to the OS in physical pixels, hence to ensure a // consistent size this function will scale the inputted width and height as // as appropriate for the default monitor. The window is invisible until // |Show| is called. Returns true if the window was created successfully. bool Create(const std::wstring& title, const Point& origin, const Size& size); // Show the current window. Returns true if the window was successfully shown. bool Show(); // Release OS resources associated with window. void Destroy(); // Inserts |content| into the window tree. void SetChildContent(HWND content); // Returns the backing Window handle to enable clients to set icon and other // window properties. Returns nullptr if the window has been destroyed. HWND GetHandle(); // If true, closing this window will quit the application. void SetQuitOnClose(bool quit_on_close); // Return a RECT representing the bounds of the current client area. RECT GetClientArea(); // Dispatches link if any. // This method enables our app to be with a single instance too. bool SendAppLinkToInstance(const std::wstring &title); protected: // Processes and route salient window messages for mouse handling, // size change and DPI. Delegates handling of these to member overloads that // inheriting classes can handle. virtual LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Called when CreateAndShow is called, allowing subclass window-related // setup. Subclasses should return false if setup fails. virtual bool OnCreate(); // Called when Destroy is called. virtual void OnDestroy(); private: friend class WindowClassRegistrar; // OS callback called by message pump. Handles the WM_NCCREATE message which // is passed when the non-client area is being created and enables automatic // non-client DPI scaling so that the non-client area automatically // responds to changes in DPI. All other messages are handled by // MessageHandler. static LRESULT CALLBACK WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Retrieves a class instance pointer for |window| static Win32Window* GetThisFromHandle(HWND const window) noexcept; // Update the window frame's theme to match the system theme. static void UpdateTheme(HWND const window); bool quit_on_close_ = false; // window handle for top level window. HWND window_handle_ = nullptr; // window handle for hosted content. HWND child_content_ = nullptr; }; #endif // RUNNER_WIN32_WINDOW_H_ ================================================ FILE: frontend/resources/translations/am-ET.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Me", "welcomeText": "እንኳን በደህና መጡ @:appName መተግበሪያ ስም", "githubStarText": "በ Github ላይ ኮከብ", "subscribeNewsletterText": "ለዜና ጽሑፍ ይመዝገቡ", "letsGoButtonText": "ፈጣን ጅምር", "title": "ርዕስ", "youCanAlso": "እርስዎም ይችላሉ", "and": "እና", "blockActions": { "addBelowTooltip": "ከዚህ በታች ለመጨመር ጠቅ ያድርጉ", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "ከላይ ለመጨመር", "dragTooltip": "ለመንቀሳቀስ ጎትት", "openMenuTooltip": "ምናሌን ለመክፈት ጠቅ ያድርጉ" }, "signUp": { "buttonText": "ተመዝገቢ", "title": "ይመዝገቡ @:appName", "getStartedText": "እንጀምር", "emptyPasswordError": "የይለፍ ቃል ባዶ ሊሆን አይችልም", "repeatPasswordEmptyError": "ይድገሙ የይለፍ ቃል ባዶ ሊሆን አይችልም", "unmatchedPasswordError": "ይድገሙ የይለፍ ቃል እንደ የይለፍ ቃል አንድ አይነት አይደለም", "alreadyHaveAnAccount": "ቀድሞውኑ መለያ አለዎት?", "emailHint": "ኢሜል", "passwordHint": "የይለፍ ቃል", "repeatPasswordHint": "የማለፊያ ቃልዎን ይድገሙ", "signUpWith": "ይመዝገቡ" }, "signIn": { "loginTitle": "ወደ @:appName ይግቡ", "loginButtonText": "ግባ", "loginStartWithAnonymous": "ስም-አልባ ክፍለ ጊዜ ይጀምሩ", "continueAnonymousUser": "ስም-አልባ ክፍለ ጊዜን ይቀጥሉ", "buttonText": "ግባ", "forgotPassword": "የይለፍ ቃሉን ረሳኽው?", "emailHint": "ኢሜል", "passwordHint": "የይለፍ ቃል", "dontHaveAnAccount": "መለያ የለዎትም?", "repeatPasswordEmptyError": "ይድገሙ የይለፍ ቃል ባዶ ሊሆን አይችልም", "unmatchedPasswordError": "ይድገሙ የይለፍ ቃል እንደ የይለፍ ቃል አንድ አይነት አይደለም", "syncPromptMessage": "ውሂቡን ማመሳሰል የተወሰነ ጊዜ ሊወስድ ይችላል.እባክዎን ይህንን ገጽ አይዝጉ", "or": "ወይም", "LogInWithGoogle": "በ Google ይግቡ", "LogInWithGithub": "በ Github ይግቡ", "LogInWithDiscord": "በ Discord ይግቡ", "signInWith": "በመለያ ይግቡ:" }, "workspace": { "chooseWorkspace": "የስራ ቦታዎን ይምረጡ", "create": "የስራ ቦታ ፍጠር", "reset": "የስራ ቦታን ዳግም ያስጀምሩ", "resetWorkspacePrompt": "የስራ ቦታን ዳግም ማስጀመር በውስጡ ያሉትን ገጾች እና መረጃዎችን ያጠፋል። እርግጠኛ ነዎት የስራ ቦታውን እንደገና ማስጀመር ይፈልጋሉ? ወይም የስራ ቦታውን ለማደስ የድጋፍ ቡድኑን ማነጋገር ይችላሉ", "hint": "የስራ ቦታ", "notFoundError": "የስራ ቦታ አልተገኘም", "failedToLoad": "የሆነ ስህተት ተከስቷል! የስራ ቦታውን መጫን አልተሳካም። ማንኛውንም የተከፈተ የአፕሊኬሽንን ምሳሌ ለመዝጋት ይሞክሩ ከዛ እንደገና ይሞክሩ።", "errorActions": { "reportIssue": "ችግር ሪፖርት ያድርጉ", "reachOut": "Discord ላይ ያግኙን" } }, "shareAction": { "buttonText": "አጋራ", "workInProgress": "በቅርብ ቀን", "markdown": "Markdown", "csv": "CSV", "copyLink": "አገናኝ ቅዳ" }, "moreAction": { "small": "ትንሽ", "medium": "መካከለኛ", "large": "ትልቅ", "fontSize": "የቅርጸ-ቁምፊ መጠን", "import": "ማስመጣት", "moreOptions": "ተጨማሪ አማራጮች" }, "importPanel": { "textAndMarkdown": "ጽሑፍ እና Markdown", "documentFromV010": "ሰነድ ከ v0.1.0", "databaseFromV010": "የመረጃ ቋት ከ v0.1.0", "csv": "CSV", "database": "የመረጃ ቋት" }, "disclosureAction": { "rename": "እንደገና ይሰይሙ", "delete": "ሰርዝ", "duplicate": "አባዛ", "unfavorite": "ከተወዳጅዎች ያስወግዱ", "favorite": "ወደ ተወዳጆች ያክሉ", "openNewTab": "በአዲስ ትር ውስጥ ይክፈቱ", "moveTo": "ወደ ይሂዱ", "addToFavorites": "ወደ ተወዳጆች ያክሉ", "copyLink": "አገናኝ ቅዳ" }, "blankPageTitle": "ባዶ ገጽ", "newPageText": "አዲስ ገጽ", "newDocumentText": "አዲስ ሰነድ", "newGridText": "አዲስ ፍርግርግ", "newCalendarText": "አዲስ የቀን መቁጠሪያ", "newBoardText": "አዲስ ቦርድ", "trash": { "text": "መጣያ", "restoreAll": "ሁሉንም ወደነበረበት መመለስ", "deleteAll": "ሁሉንም ሰርዝ", "pageHeader": { "fileName": "የመዝገብ ስም", "lastModified": "ለመጨረሻ ጊዜ የተሻሻለው", "created": "ተፈጠረ" }, "confirmDeleteAll": { "title": "ሁሉም ገጾችን በቆሻሻ መጣያ ውስጥ ለመሰረዝ እርግጠኛ ነዎት?", "caption": "ይህ እርምጃ ሊቀለበስ አይችልም።" }, "confirmRestoreAll": { "title": "ሁሉንም ገጾች በቆሻሻ መጣያ ውስጥ እንደገና መመልስዎን እርግጠኛ ነዎት?", "caption": "ይህ እርምጃ ሊቀለበስ አይችልም።" } }, "deletePagePrompt": { "text": "ይህ ገጽ በቆሻሻ መጣያ ውስጥ ነው", "restore": "መልሶ መመለስ", "deletePermanent": "በቋሚነት ሰርዝ" }, "dialogCreatePageNameHint": "ገጽ ስም", "questionBubble": { "shortcuts": "አቋራጮች", "whatsNew": "ምን አዲስ ነገር አለ?", "help": "እገዛ እና ድጋፍ", "markdown": "ምልክት ተደርጎበታል", "debug": { "name": "ማረም መረጃ", "success": "የተገለበጠ የአድራሻ መረጃ ወደ ቅንጥብ ሰሌዳ!", "fail": "የማረም መረጃ ወደ ቅንጥብ ሰሌዳ መቅዳት አልተቻለም" }, "feedback": "ግብረመልስ" }, "menuAppHeader": { "moreButtonToolTip": "ያስወግዱ፣ እንደገና ይሰይሙ እና ተጨማሪ ...", "addPageTooltip": "በፍጥነት አንድ ገጽ ውስጥ ያክሉ", "defaultNewPageName": "ላልተሰራ", "renameDialog": "እንደገና ይሰይሙ" }, "toolbar": { "undo": "መቀልበስ", "redo": "ድጋሚ", "bold": "ደፋር", "italic": "ጣዕሙ", "underline": "ከመስመር ውጭ", "strike": "ቀሚስ", "numList": "ቁጥሩ ዝርዝር", "bulletList": "ጉልበተኛ ዝርዝር", "checkList": "ዝርዝርን ይመልከቱ", "inlineCode": "የውስጥ ኮድ", "quote": "ጥቅስ", "header": "አርዕስት", "highlight": "ያድጉ", "color": "ቀለም", "addLink": "አገናኝን ያክሉ", "link": "አገናኝ" }, "tooltip": { "lightMode": "ወደ ቀላል ሁኔታ ቀይር", "darkMode": "ወደ ጨለማ ሁኔታ ቀይር", "openAsPage": "እንደ ገጽ ይክፈቱ", "addNewRow": "አዲስ ረድፍ ያክሉ", "openMenu": "ምናሌን ለመክፈት ጠቅ ያድርጉ", "dragRow": "ረድፉን እንደገና ለማዳበር ረዥም ፕሬስ", "viewDataBase": "የመረጃ ቋት ይመልከቱ", "referencePage": "ይህ {name} የተጠቀሰ ነው", "addBlockBelow": "ከዚህ በታች የሆነ አግድ ያክሉ" }, "sideBar": { "closeSidebar": "የጎን አሞሌ ይዝጉ", "openSidebar": "የተከፈተ የጎን አሞሌ", "personal": "የግል", "favorites": "ተወዳጆች", "clickToHidePersonal": "የግል ክፍልን ለመደበቅ ጠቅ ያድርጉ", "clickToHideFavorites": "የሚወዱትን ክፍል ለመደበቅ ጠቅ ያድርጉ", "addAPage": "አንድ ገጽ ያክሉ" }, "notifications": { "export": { "markdown": "ለመላክ የተላከው ማስታወሻ", "path": "Documents/flowy" } }, "contactsPage": { "title": "እውቂያዎች", "whatsHappening": "በዚህ ሳምንት ምን እየሆነ ነው?", "addContact": "እውቂያ ይጨምሩ", "editContact": "እውቂያ ያርትዑ" }, "button": { "ok": "እሺ", "cancel": "ይቅር", "signIn": "ይግቡ", "signOut": "ዘግተው ውጣ", "complete": "ተጠናቀቀ", "save": "አስቀምጥ", "generate": "ማመንጨት", "esc": "Esc", "keep": "ጠብቅ", "tryAgain": "እንደገና ሞክር", "discard": "መጣል", "replace": "ይተኩ", "insertBelow": "ከዚህ በታች ያስገቡ", "upload": "ይስቀሉ", "edit": "አርትዕ", "delete": "ሰርዝ", "duplicate": "የተባዛ", "done": "ተከናውኗል", "putback": "ይቅር" }, "label": { "welcome": "እንኳን ደና መጡ!", "firstName": "የመጀመሪያ ስም", "middleName": "የአባት ስም", "lastName": "የአያት ስም", "stepX": "ደረጃ {x}" }, "oAuth": { "err": { "failedTitle": "ከመለያዎ ጋር መገናኘት አልተቻለም።", "failedMsg": "እባክዎን በአሳሽዎ ውስጥ የመግቢያ ሂደቱን ማጠናቀቁዎን ያረጋግጡ።" }, "google": { "title": "ጉግል መግቢያ", "instruction1": "የ Google እውቂያዎችዎን ለማስመጣት ይህንን መተግበሪያ የድር አሳሽንዎን በመጠቀም ይህንን መተግበሪያ መፍቀድ ያስፈልግዎታል።", "instruction2": "ጽሑፉን አዶን ጠቅ በማድረግ ወይም መምረጥ ወይም መምረጥ ይህንን ኮድ ወደ ቅንጥብ ሰሌዳዎ ይቅዱ።", "instruction3": "በድር አሳሽዎ ውስጥ ወደሚከተለው አገናኝ ይሂዱ እና ከላይ ያለውን ኮድ ያስገቡ", "instruction4": "ምዝገባ ሲያጠናቅቁ ከዚህ በታች ያለውን ቁልፍ ተጫን" } }, "settings": { "title": "ቅንብሮች", "menu": { "appearance": "መልክ", "language": "ቋንቋ", "user": "ተጠቃሚ", "files": "ፋይሎች", "notifications": "ማሳወቂያዎች", "open": "ክፍት ቅንብሮች", "logout": "ውጣ", "logoutPrompt": "እርግጠኛ ነዎት ረጃጁን ማውጣት ይፈልጋሉ?", "selfEncryptionLogoutPrompt": "እርግጠኛ ነዎት ዘግተው መውጣት ይፈልጋሉ? እባክዎን የምስጢር ምስጢሩን እንደገለበጡ ያረጋግጡ", "syncSetting": "ቅንብሮችን አመሳስል", "enableSync": "ማመሳሰልን አንቃ", "enableEncrypt": "ውሂብን ኢንክሪፕት ማድረግ", "enableEncryptPrompt": "በዚህ ምስጢር ውሂብዎን ለማስጠበቅ ምስጠራን ያግብሩ። በደህና ያከማቹ; አንዴ ከነቃ, ሊጠፋ አይችልም። ከጠፋ, የእርስዎ ውሂብ እየተስተካከለ ይሆናል። ለመቅዳት ጠቅ ያድርጉ", "inputEncryptPrompt": "እባክዎ የምስጠራ ምስጢርዎን ያስገቡ ለ", "clickToCopySecret": "ምስጢር ለመቅዳት ጠቅ ያድርጉ", "inputTextFieldHint": "ምስጢርዎ", "historicalUserList": "የተጠቃሚ የመግቢያ ታሪክ", "historicalUserListTooltip": "ይህ ዝርዝር ስም-አልባ መለያዎችዎን ያሳያል። ዝርዝሩን ለማየት መለያ ላይ ጠቅ ማድረግ ይችላሉ። ያልታወቁ መለያዎች 'የተጀመሩ' ቁልፍን ጠቅ በማድረግ የተፈጠሩ ናቸው", "openHistoricalUser": "ማንነቱ ያልታሰበ መለያ ለመክፈት ጠቅ ያድርጉ" }, "notifications": { "enableNotifications": { "label": "ማሳወቂያዎችን አንቃ", "hint": "የአካባቢያዊ ማሳወቂያዎችን ከማየት ለማቆም ያጥፉ." } }, "appearance": { "resetSetting": "ይህንን ቅንብር እንደገና ያስጀምሩ", "fontFamily": { "label": "የቅርጸ-ቁምፊ ቤተሰብ", "search": "ፍለጋ" }, "themeMode": { "label": "ጭብጥ ሁኔታ", "light": "ቀላል ሁኔታ", "dark": "ጨለማ ሁነታን", "system": "ከስርዓት ጋር መላመድ" }, "layoutDirection": { "label": "አቀማመጥ አቅጣጫ", "hint": "ከግራ ወደ ቀኝ ወይም ወደ ግራ ወይም ወደ ቀኝ በማያ ገጽዎ ላይ ያለውን የይዘት ፍሰት ይቆጣጠሩ።", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "ነባሪ የጽሑፍ አቅጣጫ", "hint": "ጽሑፉ ከግራ ወይም ከቀኝ እንደ ነባሪው መጀመር እንዳለበት ይግለጹ።", "ltr": "LTR", "rtl": "RTL", "auto": "ራስ-ሰር", "fallback": "እንደ አቀማመጥ አቅጣጫ ተመሳሳይ ነው" }, "themeUpload": { "button": "ይስቀሉ", "uploadTheme": "ጭብጥ ይስቀሉ", "description": "ከዚህ በታች ያለውን አዝራር በመጠቀም የራስዎን የአፕልሎሎሪያ ገጽ ጭብጥ ይስቀሉ።", "failure": "የተሰቀሉት ጭብጥ ልክ ያልሆነ ቅርጸት ነበረው።", "loading": "እባክዎ ጭብጥዎን በማረጋገጥ እና ሲሰቅል እባክዎ ይጠብቁ ...", "uploadSuccess": "ጭብጥዎ በተሳካ ሁኔታ ተሰቅሏል", "deletionFailure": "ጭብጡን መሰረዝ አልተቻለም.እሱን እራስዎ ለመሰረዝ ይሞክሩ።", "filePickerDialogTitle": "የ .flowy_plugin ፋይል ይምረጡ", "urlUploadFailure": "URL ን መክፈት አልተሳካም: {}" }, "theme": "ጭብጥ", "builtInsLabel": "የተገነቡ ገጽታዎች", "pluginsLabel": "ተሰኪዎች", "dateFormat": { "label": "የቀን ቅርጸት", "local": "አካባቢያዊ", "us": "US", "iso": "ISO", "friendly": "ተስማሚ", "dmy": "D/M/Y" }, "timeFormat": { "label": "የጊዜ ቅርጸት", "twelveHour": "አሥራ ሁለት ሰዓት", "twentyFourHour": "ሀያ አራት ሰዓት" }, "showNamingDialogWhenCreatingPage": "አንድን ገጽ በሚፈጥርበት ጊዜ የ ስምምነቱን ንግግር ያሳዩ" }, "files": { "copy": "ቅጂ", "defaultLocation": "ፋይሎችን እና የውሂብ ማከማቻ ቦታን ያንብቡ", "exportData": "ውሂብዎን ወደ ውጭ ይላኩ", "doubleTapToCopy": "መንገዱን ለመቅዳት ሁለቴ መታ ያድርጉ", "restoreLocation": "ወደ APPFOFLAY ነባሪ ጎዳና ወደነበረበት መመለስ", "customizeLocation": "ሌላ አቃፊ ይክፈቱ", "restartApp": "ተግባራዊ ለሆኑ ለውጦች መተግበሪያን እንደገና ያስጀምሩ።", "exportDatabase": "የመረጃ ቋት የመረጃ ቋት", "selectFiles": "ወደ ውጭ ለመላክ የሚያስፈልጉትን ፋይሎች ይምረጡ", "selectAll": "ሁሉንም ምረጥ", "deselectAll": "ሁሉንም አይምረጡ", "createNewFolder": "አዲስ አቃፊ ይፍጠሩ", "createNewFolderDesc": "ውሂብዎን ማከማቸት እንደሚፈልጉ ይንገሩን", "defineWhereYourDataIsStored": "ውሂብዎ የት እንደተከማቸ ይግለጹ", "open": "ክፈት", "openFolder": "አሁን ያለውን አቃፊ ይክፈቱ", "openFolderDesc": "አሁን ባለው የ Appflowy አቃፊዎ ውስጥ ያንብቡ እና ይፃፉ", "folderHintText": "አቃፊ ስም", "location": "አዲስ አቃፊ መፍጠር", "locationDesc": "ለ Appflowy ውሂብ አቃፊዎ ስም ይምረጡ", "browser": "ያስሱ", "create": "ፍጠር", "set": "አዘጋጅ", "folderPath": "አቃፊዎን ለማከማቸት መንገድ", "locationCannotBeEmpty": "መንገድ ባዶ ሊሆን አይችልም", "pathCopiedSnackbar": "የፋይል ማከማቻ መንገድ ወደ ቅንጥብ ሰሌዳው ተቀብሏል!", "changeLocationTooltips": "የውሂብ ማውጫውን ይለውጡ", "change": "ለውጥ", "openLocationTooltips": "ሌላ የውሂብ ማውጫ ይክፈቱ", "openCurrentDataFolder": "የአሁኑን የውሂብ ማውጫ ይክፈቱ", "recoverLocationTooltips": "ወደ Appflowy ነባሪ የውሂብ ማውጫ ዳግም ያስጀምሩ", "exportFileSuccess": "ወደ ውጭ የመላክ ፋይል በተሳካ ሁኔታ!", "exportFileFail": "ወደ ውጭ የመላክ ፋይል አልተሳካም!", "export": "ወደ ውጭ ይላኩ" }, "user": { "name": "ስም", "email": "ኢሜል", "tooltipSelectIcon": "አዶን ይምረጡ", "selectAnIcon": "አዶን ይምረጡ", "pleaseInputYourOpenAIKey": "እባክዎን AI ቁልፍዎን ያስገቡ", "pleaseInputYourStabilityAIKey": "እባክዎ Stability AI ቁልፍን ያስገቡ", "clickToLogout": "የአሁኑን ተጠቃሚ ለመግባት ጠቅ ያድርጉ" }, "shortcuts": { "shortcutsLabel": "አቋራጮች", "command": "ትእዛዝ", "keyBinding": "የቁልፍ ሰሌዳ", "addNewCommand": "አዲስ ትዕዛዝ ያክሉ", "updateShortcutStep": "የሚፈለገውን ቁልፍ ጥምረት እና አስገባን ይጫኑ", "shortcutIsAlreadyUsed": "ይህ አቋራጭ አስቀድሞ ጥቅም ላይ ውሏል- {conflict}", "resetToDefault": "ወደ ነባሪ የቁልፍ ማያያዣዎች ዳግም ያስጀምሩ", "couldNotLoadErrorMsg": "አቋራጮችን መጫን አልተቻለም, እንደገና ይሞክሩ", "couldNotSaveErrorMsg": "አቋራጮችን ማስቀመጥ አልተቻለም, እንደገና ይሞክሩ" } }, "grid": { "deleteView": "እርግጠኛ ነዎት ይህንን አመለካከት መሰረዝ ይፈልጋሉ?", "createView": "አዲስ", "title": { "placeholder": "ላልተሰራ" }, "settings": { "filter": "ማጣሪያ", "sort": "ደርድር", "sortBy": "ቅደምተከተሉ የተስተካከለው", "properties": "ንብረቶች", "reorderPropertiesTooltip": "ለመደናቀፊያዎች ንድፍ ለመጎተት ይጎትቱ", "group": "ቡድን", "addFilter": "ማጣሪያ ያክሉ", "deleteFilter": "ማጣሪያ ሰርዝ", "filterBy": "ማጣሪያ በ ...", "typeAValue": "እሴት ይተይቡ ...", "layout": "አቀማመጥ", "databaseLayout": "አቀማመጥ" }, "textFilter": { "contains": "ይይዛል", "doesNotContain": "አይይዝም", "endsWith": "ከ ጋር ያበቃል", "startWith": "ይጀምራል", "is": "ነው", "isNot": "አይደለም", "isEmpty": "ባዶ ነው", "isNotEmpty": "ባዶ አይደለም", "choicechipPrefix": { "isNot": "አይደለም", "startWith": "ይጀምራል", "endWith": "ከ ጋር ያበቃል", "isEmpty": "ይጀምራል", "isNotEmpty": "ነው" } }, "checkboxFilter": { "isChecked": "አይደለም", "isUnchecked": "ባዶ ነው", "choicechipPrefix": { "is": "ባዶ አይደለም" } }, "checklistFilter": { "isComplete": "አይደለም", "isIncomplted": "ባዶ ነው" }, "selectOptionFilter": { "is": "ነው", "isNot": "አይደለም", "contains": "ይይዛል", "doesNotContain": "አይይዝም", "isEmpty": "ባዶ ነው", "isNotEmpty": "ባዶ አይደለም" }, "field": { "hide": "አይደለም", "show": "ባዶ ነው", "insertLeft": "ባዶ አይደለም", "insertRight": "ምልክት ተደርጎበታል", "duplicate": "ቁጥጥር ካልተደረገበት", "delete": "ነው", "textFieldName": "ተጠናቅቋል", "checkboxFieldName": "ያልተሟላ ነው", "dateFieldName": "ደብቅ", "updatedAtFieldName": "አሳይ", "createdAtFieldName": "ግራ ያስገቡ", "numberFieldName": "ቀኝ ያስገቡ", "singleSelectFieldName": "የተባዛ", "multiSelectFieldName": "ሰርዝ", "urlFieldName": "ጽሑፍ", "checklistFieldName": "አመልካች ሳጥን", "numberFormat": "ቀን", "dateFormat": "ለመጨረሻ ጊዜ የተስተካከለ ጊዜ", "includeTime": "የተፈጠረ ጊዜ", "isRange": "ቁጥሮች", "dateFormatFriendly": "ይምረጡ", "dateFormatISO": "Year-Month-Day", "dateFormatLocal": "Month/Day/Year", "dateFormatUS": "Year/Month/Day", "dateFormatDayMonthYear": "Day/Month/Year", "timeFormat": "ልዩ መልሶች", "invalidTimeFormat": "ዩ አር ኤል", "timeFormatTwelveHour": "የማረጋገጫ ዝርዝር", "timeFormatTwentyFourHour": "የቁጥር ቅርጸት", "clearDate": "የቀን ቅርጸት", "addSelectOption": "ጊዜን ያካትቱ", "optionTitle": "ቀኑ ቀኑ", "addOption": "ወር ቀን, ዓመት", "editProperty": "የጊዜ ቅርጸት", "newProperty": "የተሳሳተ ቅርጸት", "deleteFieldPromptMessage": "12 ሰዓት", "newColumn": "24 ሰዓት" }, "rowPage": { "newField": "ግልጽ ቀን", "fieldDragElementTooltip": "አንድ አማራጭ ያክሉ", "showHiddenFields": { "one": "የተደበቁ {} ፊልዶችን ያሳዩ", "many": "የተደበቁ {} ፊልዶችን ያሳዩ", "other": "የተደበቁ {} ፊልዶችን ያሳዩ" }, "hideHiddenFields": { "one": "የተደበቁ {} ፊልዶችን ይደብቁ", "many": "የተደበቁ {} ፊልዶችን ይደብቁ", "other": "የተደበቁ {} ፊልዶችን ይደብቁ" } }, "sort": { "ascending": "ኧረይህ ንብረት ይሰረዛል", "descending": "አዲስ አምድ", "deleteAllSorts": "አዲስ መስክ ያክሉ", "addSort": "ምናሌን ለመክፈት ጠቅ ያድርጉ" }, "row": { "duplicate": "ቁጥጥር ካልተደረገበት", "delete": "ነው", "titlePlaceholder": "ላልተሰራ", "textPlaceholder": "ተጠናቅቋል", "copyProperty": "ያልተሟላ ነው", "count": "ደብቅ", "newRow": "አሳይ", "action": "ግራ ያስገቡ", "add": "ቀኝ ያስገቡ", "drag": "የተባዛ" }, "selectOption": { "create": "ሰርዝ", "purpleColor": "ጽሑፍ", "pinkColor": "አመልካች ሳጥን", "lightPinkColor": "ቀን", "orangeColor": "ለመጨረሻ ጊዜ የተስተካከለ ጊዜ", "yellowColor": "የተፈጠረ ጊዜ", "limeColor": "ቁጥሮች", "greenColor": "ይምረጡ", "aquaColor": "ልዩ መልሶች", "blueColor": "ዩ አር ኤል", "deleteTag": "የማረጋገጫ ዝርዝር", "colorPanelTitle": "የቁጥር ቅርጸት", "panelTitle": "የቀን ቅርጸት", "searchOption": "ጊዜን ያካትቱ", "searchOrCreateOption": "ቀኑ ቀኑ", "createNew": "ወር ቀን, ዓመት", "orSelectOne": "የጊዜ ቅርጸት" }, "checklist": { "taskHint": "የተሳሳተ ቅርጸት", "addNew": "12 ሰዓት", "submitNewTask": "ሰርዝ" }, "menuName": "ጽሑፍ", "referencedGridPrefix": "አመልካች ሳጥን" }, "document": { "menuName": "ሰነድ", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "ለማገናኘት ቦርድ ይምረጡ", "createANewBoard": "አዲስ ቦርድ ይፍጠሩ" }, "grid": { "selectAGridToLinkTo": "ለማገናኘት ፍርግርግ ይምረጡ", "createANewGrid": "አዲስ ፍርግርግ ይፍጠሩ" }, "calendar": { "selectACalendarToLinkTo": "ለማገናኘት የቀን መቁጠሪያ ይምረጡ", "createANewCalendar": "አዲስ የቀን መቁጠሪያ ይፍጠሩ" } }, "selectionMenu": { "outline": "ዝርዝር", "codeBlock": "ኮድ ብሎክ" }, "plugins": { "referencedBoard": "ማጣቀሻ ቦርድ", "referencedGrid": "ማጣቀሻ ፍርግርግ", "referencedCalendar": "የቀን ቀን መቁጠሪያ", "autoGeneratorMenuItemName": "AI ጸሐፊ", "autoGeneratorTitleName": "AI ማንኛውንም ነገር እንዲጽፉ ይጠይቁ ...", "autoGeneratorLearnMore": "ተጨማሪ እወቅ", "autoGeneratorGenerate": "ማመንጨት", "autoGeneratorHintText": "AI ይጠይቁ ...", "autoGeneratorCantGetOpenAIKey": "የ AI ቁልፍ ማግኘት አልተቻለም", "autoGeneratorRewrite": "እንደገና ይፃፉ", "smartEdit": "ረዳቶች", "openAI": "ኦፔና", "smartEditFixSpelling": "የፊደል አጻጻፍ", "warning": "⚠️ የAI ምላሾች ትክክል ያልሆኑ ወይም አሳሳች ሊሆኑ ይችላሉ.", "smartEditSummarize": "ማጠቃለል", "smartEditImproveWriting": "ጽሑፍን ማሻሻል", "smartEditMakeLonger": "ረዘም ላለ ጊዜ ያድርጉ", "smartEditCouldNotFetchResult": "ከOpenAI ውጤት ማምለጥ አልተቻለም", "smartEditCouldNotFetchKey": "ኦፕናይ ቁልፍን ማጣት አልተቻለም", "smartEditDisabled": "በቅንብሮች ውስጥ AI ያገናኙ", "discardResponse": "የ AI ምላሾችን መጣል ይፈልጋሉ?", "createInlineMathEquation": "እኩልነት ይፍጠሩ", "toggleList": "የተስተካከለ ዝርዝር", "cover": { "changeCover": "ሽፋን", "colors": "ቀለሞች", "images": "ምስሎች", "clearAll": "ሁሉንም ያፅዱ", "abstract": "ረቂቅ", "addCover": "ሽፋን ጨምር", "addLocalImage": "የአከባቢ ምስል ያክሉ", "invalidImageUrl": "ልክ ያልሆነ ምስል ዩአርኤል", "failedToAddImageToGallery": "ወደ ማዕከለ-ስዕላት ምስልን ማከል አልተሳካም", "enterImageUrl": "የምስል ዩአርኤል ያስገቡ", "add": "ጨምር", "back": "ተመለስ", "saveToGallery": "ወደ ማዕከለ-ስዕላት ይቆጥቡ", "removeIcon": "አዶ ያስወግዱ", "pasteImageUrl": "የመለጠጥ ምስል ዩ አር ኤል", "or": "ወይም", "pickFromFiles": "ከፋይሎች ይምረጡ", "couldNotFetchImage": "ምስልን ማምጣት አልተቻለም", "imageSavingFailed": "ምስል ቁጠባ አልተሳካም", "addIcon": "አዶን ያክሉ", "coverRemoveAlert": "ከተሰረዘ በኋላ ከሽፋን ይወገዳል።", "alertDialogConfirmation": "እርግጠኛ ነዎት ለመቀጠል ይፈልጋሉ?" }, "mathEquation": { "addMathEquation": "የሂሳብ እኩልታን ያክሉ", "editMathEquation": "የሂሳብ እኩልትን ያርትዑ" }, "optionAction": { "click": "ጠቅ ያድርጉ", "toOpenMenu": " ምናሌን ለመክፈት", "delete": "ሰርዝ", "duplicate": "የተባዛ", "turnInto": "መለወጥ", "moveUp": "ተንቀሳቀሱ", "moveDown": "ውረድ", "color": "ቀለም", "align": "ስልጣን", "left": "ግራ", "center": "ማዕከል", "right": "ቀኝ", "defaultColor": "ነባሪ" }, "image": { "copiedToPasteBoard": "የምስል አገናኝ ወደ ቅንጥብ ሰሌዳው ተቀድቷል", "addAnImage": "ምስል ያክሉ" }, "outline": { "addHeadingToCreateOutline": "የርዕስ ማውጫ ለመፍጠር የርዕስ ማውጫዎችን ለማክበር ያክሉ." }, "table": { "addAfter": "በኋላ ያክሉ", "addBefore": "ከዚህ በፊት ያክሉ", "delete": "ሰርዝ", "clear": "የተባዛ", "duplicate": "የተባዛ", "bgColor": "መለወጥ" }, "contextMenu": { "copy": "ተንቀሳቀሱ", "cut": "ውረድ", "paste": "ቀለም" } }, "textBlock": { "placeholder": "ለትዛዞች '/' ይጻፉ" }, "title": { "placeholder": "ያልተሰየመ" }, "imageBlock": { "placeholder": "ምስልን ለማከል ጠቅ ያድርጉ", "upload": { "label": "ይስቀሉ", "placeholder": "ምስልን ለመስቀል ጠቅ ያድርጉ" }, "url": { "label": "የምስል ዩአርኤል", "placeholder": "የምስል ዩአርኤል ያስገቡ" }, "ai": { "label": "ምስል AI ውስጥ ምስልን ማመንጨት", "placeholder": "ምስልን ለማመንጨት እባክዎን ለ AI ይጠይቁ" }, "stability_ai": { "label": "ምስልን Stability AI ያመነጫል", "placeholder": "እባክዎን ምስሉን ለማመንጨት እባክዎ Stability AI አጥነት ያስገቡ" }, "support": "የምስል መጠን ወሰን 5 ሜባ ነው.የሚደገፉ ቅርፀቶች: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "ልክ ያልሆነ ምስል", "invalidImageSize": "የምስል መጠን ከ 5 ሜባ በታች መሆን አለበት", "invalidImageFormat": "የምስል ቅርጸት አይደገፍም.የሚደገፉ ቅርፀቶች: JPEG, PNG, GIF, SVG", "invalidImageUrl": "ልክ ያልሆነ ምስል ዩአርኤል" }, "embedLink": { "label": "አገናኝ", "placeholder": "የመለጠጥ ወይም የምስል አገናኝን ይለዩ" }, "searchForAnImage": "ምስልን ይፈልጉ", "pleaseInputYourOpenAIKey": "እባክዎ በቅንብሮች ገጽ ውስጥ የኦፕሬና ቁልፍዎን ያስገቡ", "pleaseInputYourStabilityAIKey": "በቅንብሮች ገጽ ውስጥ የመረጋጋት Stability AI ቁልፍን ያስገቡ" }, "codeBlock": { "language": { "label": "ቋንቋ", "placeholder": "ቋንቋ ይምረጡ" } }, "inlineLink": { "placeholder": "መለጠፍ ወይም አገናኝ ይተይቡ", "openInNewTab": "በአዲስ ትር ክፈት", "copyLink": "አገናኝ ቅዳ", "removeLink": "አገናኝን ያስወግዱ", "url": { "label": "አገናኝ ዩአርኤል", "placeholder": "አገናኝ ዩአርኤል ያስገቡ" }, "title": { "label": "አገናኝ ርዕስ", "placeholder": "የአገናኝ ርዕስ ያስገቡ" } }, "mention": { "placeholder": "አንድን ሰው ወይም አንድ ገጽ ወይም ቀን ጥቀስ ...", "page": { "label": "ወደ ገጽ አገናኝ", "tooltip": "ገጽ ለመክፈት ጠቅ ያድርጉ" } }, "toolbar": { "resetToDefaultFont": "ወደ ነባሪ ዳግም ያስጀምሩ" }, "errorBlock": { "theBlockIsNotSupported": "የአሁኑ ስሪት ይህንን ብሎክ አይደግፍም።", "blockContentHasBeenCopied": "ብሎክ ይዘቱ ተቀብሏል።" } }, "board": { "column": { "createNewCard": "አዲስ" }, "menuName": "ቦርድ", "referencedBoardPrefix": "እይታ" }, "calendar": { "menuName": "የቀን መቁጠሪያ", "defaultNewCalendarTitle": "ላልተሰራ", "newEventButtonTooltip": "አዲስ ክስተት ያክሉ", "navigation": { "today": "ዛሬ", "jumpToday": "እስከ ዛሬ ድረስ ዝለል", "previousMonth": "ያለፈው ወር", "nextMonth": "በሚቀጥለው ወር" }, "settings": { "showWeekNumbers": "የሳምንቱ ቁጥሮች ያሳዩ", "showWeekends": "ቅዳሜና እሁድን ያሳዩ", "firstDayOfWeek": "ሳምንት ይጀምሩ", "layoutDateField": "የቀን ቀን መቁጠሪያ በ", "noDateTitle": "ቀን የለም", "noDateHint": "ያልተስተካከሉ ክስተቶች እዚህ ይታያሉ", "clickToAdd": "ወደ የቀን መቁጠሪያው ለማከል ጠቅ ያድርጉ", "name": "የቀን መቁጠሪያ አቀማመጥ" }, "referencedCalendarPrefix": "View of" }, "errorDialog": { "title": "Appflowy ስህተት", "howToFixFallback": "ለተፈጠረው ችግር ይቅርታ እንጠይቃለን!ስህተትዎን በሚገልፅ የእኛ የ Gitubub ገጽ ላይ አንድ ጉዳይ ያስገቡ.", "github": "በ Github ላይ ይመልከቱ" }, "search": { "label": "ፍለጋ", "placeholder": { "actions": "የፍለጋ እርምጃዎች ..." } }, "message": { "copy": { "success": "Copied!", "fail": "መቅዳት አልተቻለም" } }, "unSupportBlock": "የአሁኑ ስሪት ይህንን ብሎክ አይደግፍም።", "views": { "deleteContentTitle": "እርግጠኛ ነዎት {pageType} መሰረዝ ይፈልጋሉ?", "deleteContentCaption": "ይህን {pageType} ከሆነ, ከቆሻሻ መጣያ መመለስ ይችላሉ።" }, "colors": { "custom": "ብጁ", "default": "ነባሪ", "red": "ቀይ", "orange": "ብርቱካናማ", "yellow": "ቢጫ", "green": "አረንጓዴ", "blue": "ሰማያዊ", "purple": "ሐምራዊ", "pink": "ሐምራዊ", "brown": "ብናማ", "gray": "ግራጫ" }, "emoji": { "search": "ፈልግ ኢሞጂዲ", "noRecent": "ምንም የቅርብ ጊዜ ኢሞጂ የለም", "noEmojiFound": "ምንም ኢሞጂ አልተገኘም", "filter": "ማጣሪያ", "random": "የዘፈቀደ", "selectSkinTone": "የቆዳ ድምጽ ይምረጡ", "remove": "ኢሞጂን ያስወግዱ", "categories": { "smileys": "ፈገግታ እና ስሜቶች", "people": "ሰዎች እና አካል", "animals": "እንስሳት እና ተፈጥሮ", "food": "ምግብ እና መጠጥ", "activities": "እንቅስቃሴዎች", "places": "ጉዞ እና ቦታዎች", "objects": "ነገሮች", "symbols": "ምልክቶች", "flags": "ባንዲራዎች", "nature": "ተፈጥሮ", "frequentlyUsed": "ብዙ ጊዜ ጥቅም ላይ ውሏል" } }, "inlineActions": { "noResults": "ምንም ውጤቶች የሉም", "pageReference": "ገጽ ማጣቀሻ", "date": "ቀን", "reminder": { "groupTitle": "አስታዋሽ", "shortKeyword": "አስታውስ" } }, "datePicker": { "dateTimeFormatTooltip": "በቅንብሮች ውስጥ ያለውን ቀን እና የጊዜ ቅርጸት ይለውጡ" }, "relativeDates": { "yesterday": "ትናንት", "today": "ዛሬ", "tomorrow": "ነገ", "oneWeek": "1 ሳምንት" }, "notificationHub": { "title": "ማሳወቂያዎች", "emptyTitle": "ሁሉም ተነሱ!", "emptyBody": "ምንም የማሳወቂያዎች ወይም እርምጃዎች የለም.በተረጋጋና ይደሰቱ.", "tabs": { "inbox": "የገቢ መልእክት ሳጥን", "upcoming": "መጪ" }, "actions": { "markAllRead": "ሁሉንም እንደ ንባብ ምልክት ያድርጉበት", "showAll": "ሁሉም", "showUnreads": "ያልተነበበ" }, "filters": { "ascending": "መውጣት", "descending": "መውረድ", "groupByDate": "ቀን", "showUnreadsOnly": "ያልተነበቡ ቃላቶችን ብቻ ያሳዩ", "resetToDefault": "ወደ ነባሪ ዳግም ያስጀምሩ" } }, "reminderNotification": { "title": "አስታዋሽ", "message": "አስታውስ", "tooltipDelete": "በቅንብሮች ውስጥ ያለውን ቀን እና የጊዜ ቅርጸት ይለውጡ", "tooltipMarkRead": "ትናንት", "tooltipMarkUnread": "ዛሬ" }, "findAndReplace": { "find": "ነገ", "previousMatch": "1 ሳምንት", "nextMatch": "ማሳወቂያዎች", "close": "ሁሉም ተነሱ!", "replace": "ምንም የማሳወቂያዎች ወይም እርምጃዎች የለም.በተረጋጋና ይደሰቱ.", "replaceAll": "የገቢ መልእክት ሳጥን", "noResult": "ምንም ውጤቶች የሉም", "caseSensitive": "መጪ" } } ================================================ FILE: frontend/resources/translations/ar-SA.json ================================================ { "appName": "AppFlowy", "defaultUsername": "أنا", "welcomeText": "مرحبًا بك في @: appName", "welcomeTo": "مرحبا بكم في", "githubStarText": "نجمة على GitHub", "subscribeNewsletterText": "اشترك في النشرة الإخبارية", "letsGoButtonText": "بداية سريعة", "title": "عنوان", "youCanAlso": "بامكانك ايضا", "and": "و", "failedToOpenUrl": "فشل في فتح الرابط: {}", "blockActions": { "addBelowTooltip": "انقر للإضافة أدناه", "addAboveCmd": "Alt + انقر", "addAboveMacCmd": "Option + انقر", "addAboveTooltip": "لإضافة أعلاه", "dragTooltip": "اسحب للتحريك", "openMenuTooltip": "انقر لفتح القائمة" }, "signUp": { "buttonText": "اشتراك", "title": "قم بالتسجيل في @: appName", "getStartedText": "البدء", "emptyPasswordError": "لا يمكن أن تكون كلمة المرور فارغة", "repeatPasswordEmptyError": "إعادة كلمة المرور لا يمكن أن تكون فارغة", "unmatchedPasswordError": "تكرار كلمة المرور ليس هو نفسه كلمة المرور", "alreadyHaveAnAccount": "هل لديك حساب؟", "emailHint": "بريد إلكتروني", "passwordHint": "كلمة المرور", "repeatPasswordHint": "اعد كلمة السر", "signUpWith": "انشئ حساب باستخدام:" }, "signIn": { "loginTitle": "تسجيل الدخول إلى @: appName", "loginButtonText": "تسجيل الدخول", "loginStartWithAnonymous": "ابدأ بجلسة خفية", "continueAnonymousUser": "استمر بجلسة خفية", "continueWithLocalModel": "الاستمرار بالنموذج المحلي", "switchToAppFlowyCloud": "AppFlowy السحابي", "anonymousMode": "الوضع المجهول", "buttonText": "تسجيل الدخول", "signingInText": "جاري تسجيل الدخول...", "forgotPassword": "هل نسيت كلمة السر؟", "emailHint": "بريد إلكتروني", "passwordHint": "كلمة المرور", "dontHaveAnAccount": "ليس لديك حساب؟", "createAccount": "إنشاء حساب", "repeatPasswordEmptyError": "إعادة كلمة المرور لا يمكن أن تكون فارغة", "unmatchedPasswordError": "تكرار كلمة المرور ليس هو نفسه كلمة المرور", "passwordMustContain": "يجب أن تحتوي كلمة المرور على حرف واحد ورقم واحد ورمز واحد على الأقل.", "syncPromptMessage": "قد تستغرق مزامنة البيانات بعض الوقت. من فضلك لا تغلق هذه الصفحة", "or": "أو", "signInWithGoogle": "استكمال باستخدام Google", "signInWithGithub": "استكمال باستخدام Github", "signInWithDiscord": "استكمال باستخدام Discord", "signInWithApple": "استكمال باستخدام Apple", "continueAnotherWay": "استكمال بطريقة أخرى", "signUpWithGoogle": "سجل باستخدام Google", "signUpWithGithub": "سجل باستخدام Github", "signUpWithDiscord": "سجل باستخدام Discord", "signInWith": "تسجيل الدخول ب:", "signInWithEmail": "متابعة باستخدام البريد الإلكتروني", "signInWithMagicLink": "استكمال", "signUpWithMagicLink": "سجل باستخدام Magic Link", "pleaseInputYourEmail": "الرجاء إدخال عنوان بريدك الإلكتروني", "settings": "إعدادات", "magicLinkSent": "تم إرسال Magic Link!", "invalidEmail": "يرجى إدخال عنوان بريد إلكتروني صالح", "alreadyHaveAnAccount": "هل لديك حساب؟", "logIn": "تسجيل الدخول", "generalError": "حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقًا", "limitRateError": "لأسباب أمنية، يمكنك طلب Magic Link كل 60 ثانية فقط", "magicLinkSentDescription": "تم إرسال Magic Link إلى بريدك الإلكتروني. انقر على الرابط لإكمال تسجيل الدخول. ستنتهي صلاحية الرابط بعد 5 دقائق.", "tokenHasExpiredOrInvalid": "الرمز منتهي الصلاحية أو غير صالح. يُرجى المحاولة مرة أخرى.", "signingIn": "جاري تسجيل الدخول...", "checkYourEmail": "تحقق من بريدك الإلكتروني", "temporaryVerificationLinkSent": "تم إرسال رابط التحقق المؤقت.\nيرجى التحقق من صندوق الوارد الخاص بك على", "temporaryVerificationCodeSent": "لقد تم إرسال رمز التحقق المؤقت.\nيرجى التحقق من صندوق الوارد الخاص بك على", "continueToSignIn": "متابعة تسجيل الدخول", "continueWithLoginCode": "متابعة مع رمز تسجيل الدخول", "backToLogin": "العودة إلى تسجيل الدخول", "enterCode": "أدخل الرمز", "enterCodeManually": "أدخل الرمز يدويًا", "continueWithEmail": "متابعة مع البريد الإلكتروني", "enterPassword": "أدخل كلمة المرور", "loginAs": "تسجيل الدخول باسم", "invalidVerificationCode": "الرجاء إدخال رمز التحقق الصحيح", "tooFrequentVerificationCodeRequest": "لقد قدمت طلبات كثيرة. يُرجى المحاولة لاحقًا.", "invalidLoginCredentials": "كلمة المرور الخاصة بك غير صحيحة، يرجى المحاولة مرة أخرى", "resetPassword": "إعادة ضبط كلمة المرور", "resetPasswordDescription": "أدخل بريدك الإلكتروني لإعادة ضبط كلمة المرور الخاصة بك", "continueToResetPassword": "متابعة إعادة ضبط كلمة المرور", "resetPasswordSuccess": "تم إعادة ضبط كلمة المرور بنجاح", "resetPasswordFailed": "فشل إعادة ضبط كلمة المرور", "resetPasswordLinkSent": "تم إرسال رابط إعادة ضبط كلمة المرور إلى بريدك الإلكتروني. يُرجى مراجعة بريدك الوارد على", "resetPasswordLinkExpired": "انتهت صلاحية رابط إعادة ضبط كلمة المرور. يُرجى طلب رابط جديد.", "resetPasswordLinkInvalid": "رابط إعادة ضبط كلمة المرور غير صالح. يُرجى طلب رابط جديد.", "enterNewPasswordFor": "أدخل كلمة المرور الجديدة لـ ", "newPassword": "كلمة المرور الجديدة", "enterNewPassword": "أدخل كلمة المرور الجديدة", "confirmPassword": "تأكيد كلمة المرور", "confirmNewPassword": "أدخل كلمة المرور الجديدة", "newPasswordCannotBeEmpty": "لا يمكن أن تكون كلمة المرور الجديدة فارغة", "confirmPasswordCannotBeEmpty": "تأكيد كلمة المرور لا يمكن أن يكون فارغا", "passwordsDoNotMatch": "كلمات المرور غير متطابقة", "verifying": "جاري التحقق...", "continueWithPassword": "متابعة مع كلمة المرور", "youAreInLocalMode": "أنت في الوضع المحلي.", "loginToAppFlowyCloud": "تسجيل الدخول إلى AppFlowy Cloud", "anonymous": "مجهول", "LogInWithGoogle": "تسجيل الدخول عبر جوجل", "LogInWithGithub": "تسجيل الدخول مع جيثب", "LogInWithDiscord": "تسجيل الدخول مع ديسكورد", "loginAsGuestButtonText": "تسجيل دخول كضيف" }, "workspace": { "chooseWorkspace": "اختر مساحة العمل الخاصة بك", "defaultName": "مساحة العمل الخاصة بي", "create": "قم بإنشاء مساحة عمل", "new": "مساحة عمل جديدة", "importFromNotion": "الاستيراد من Notion", "learnMore": "تعلم المزيد", "reset": "إعادة تعيين مساحة العمل", "renameWorkspace": "إعادة تسمية مساحة العمل", "workspaceNameCannotBeEmpty": "لا يمكن أن يكون اسم مساحة العمل فارغًا", "resetWorkspacePrompt": "ستؤدي إعادة تعيين مساحة العمل إلى حذف جميع الصفحات والبيانات الموجودة بداخلها. هل أنت متأكد أنك تريد إعادة تعيين مساحة العمل؟ وبدلاً من ذلك، يمكنك الاتصال بفريق الدعم لاستعادة مساحة العمل", "hint": "مساحة العمل", "notFoundError": "مساحة العمل غير موجودة", "failedToLoad": "هناك خطأ ما! فشل تحميل مساحة العمل. حاول إغلاق أي مثيل مفتوح لـ @:appName وحاول مرة أخرى.", "errorActions": { "reportIssue": "بلغ عن خطأ", "reportIssueOnGithub": "الإبلاغ عن مشكلة على Github", "exportLogFiles": "تصدير ملفات السجل", "reachOut": "تواصل مع ديسكورد" }, "menuTitle": "مساحات العمل", "deleteWorkspaceHintText": " هل أنت متأكد من أنك تريد حذف مساحة العمل؟ لا يمكن التراجع عن هذا الإجراء، وستختفي أي صفحات قمت بنشرها سابقاً.", "createSuccess": "تم إنشاء مساحة العمل بنجاح", "createFailed": "فشل في إنشاء مساحة العمل", "createLimitExceeded": "لقد وصلت إلى الحد الأقصى لمساحة العمل المسموح بها لحسابك. إذا كنت بحاجة إلى مساحات عمل إضافية لمواصلة عملك، فيرجى تقديم طلب على Github", "deleteSuccess": "تم حذف مساحة العمل بنجاح", "deleteFailed": "فشل في حذف مساحة العمل", "openSuccess": "تم فتح مساحة العمل بنجاح", "openFailed": "فشل في فتح مساحة العمل", "renameSuccess": "تم إعادة تسمية مساحة العمل بنجاح", "renameFailed": "فشل في إعادة تسمية مساحة العمل", "updateIconSuccess": "تم تحديث أيقونة مساحة العمل بنجاح", "updateIconFailed": "فشل تحديث أيقونة مساحة العمل", "cannotDeleteTheOnlyWorkspace": "لا يمكن حذف مساحة العمل الوحيدة", "fetchWorkspacesFailed": "فشل في الوصول لمساحات العمل", "leaveCurrentWorkspace": "مغادرة مساحة العمل", "leaveCurrentWorkspacePrompt": "هل أنت متأكد أنك تريد مغادرة مساحة العمل الحالية؟" }, "shareAction": { "buttonText": "مشاركه", "workInProgress": "قريباً", "markdown": "Markdown", "html": "HTML", "clipboard": "نسخ إلى الحافظة", "csv": "CSV", "copyLink": "نسخ الرابط", "publishToTheWeb": "نشر على الويب", "publishToTheWebHint": "إنشاء موقع ويب مع AppFlowy", "publish": "نشر", "unPublish": "التراجع عن النشر", "visitSite": "زيارة الموقع", "exportAsTab": "تصدير كـ", "publishTab": "نشر", "shareTab": "مشاركة", "publishOnAppFlowy": "نشر على AppFlowy", "shareTabTitle": "دعوة للتعاون", "shareTabDescription": "من أجل التعاون السهل مع أي شخص", "copyLinkSuccess": "تم نسخ الرابط إلى الحافظة", "copyShareLink": "نسخ رابط المشاركة", "copyLinkFailed": "فشل في نسخ الرابط إلى الحافظة", "copyLinkToBlockSuccess": "تم نسخ رابط الكتلة إلى الحافظة", "copyLinkToBlockFailed": "فشل في نسخ رابط الكتلة إلى الحافظة", "manageAllSites": "إدارة كافة المواقع", "updatePathName": "تحديث اسم المسار" }, "moreAction": { "small": "صغير", "medium": "متوسط", "large": "كبير", "fontSize": "حجم الخط", "import": "استيراد", "moreOptions": "المزيد من الخيارات", "wordCount": "عدد الكلمات: {}", "charCount": "عدد الأحرف: {}", "createdAt": "منشأ: {}", "deleteView": "يمسح", "duplicateView": "تكرار", "wordCountLabel": "عدد الكلمات: ", "charCountLabel": "عدد الأحرف: ", "createdAtLabel": "تم إنشاؤه: ", "syncedAtLabel": "تم المزامنة: ", "saveAsNewPage": "حفظ الرسائل في الصفحة", "saveAsNewPageDisabled": "لا توجد رسائل متاحة" }, "importPanel": { "textAndMarkdown": "نص و Markdown", "documentFromV010": "مستند من الإصدار 0.1.0", "databaseFromV010": "قاعدة بيانات من الإصدار 0.1.0", "notionZip": "ملف Zip المُصدَّر من Notion", "csv": "CSV", "database": "قاعدة البيانات" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "اسحب وأفلِت ملفًا، وانقر فوقه ", "placeholderUpload": "رفع", "placeholderRight": "أو قم بلصق رابط الصورة.", "dropToUpload": "إفلات ملف لتحميله", "change": "تغير" } }, "disclosureAction": { "rename": "إعادة تسمية", "delete": "يمسح", "duplicate": "كرر", "unfavorite": "إزالة من المفضلة", "favorite": "اضافة الى المفضلة", "openNewTab": "افتح في علامة تبويب جديدة", "moveTo": "نقل إلى", "addToFavorites": "اضافة الى المفضلة", "copyLink": "نسخ الرابط", "changeIcon": "تغيير الأيقونة", "collapseAllPages": "طي جميع الصفحات الفرعية", "movePageTo": "تحريك الصفحة إلى", "move": "تحريك", "lockPage": "إلغاء تأمين الصفحة" }, "blankPageTitle": "صفحة فارغة", "newPageText": "صفحة جديدة", "newDocumentText": "مستند جديد", "newGridText": "شبكة جديدة", "newCalendarText": "تقويم جديد", "newBoardText": "سبورة جديدة", "chat": { "newChat": "الدردشة بالذكاء الاصطناعي", "inputMessageHint": "اسأل @:appName AI", "inputLocalAIMessageHint": "اسأل @:appName Local AI", "unsupportedCloudPrompt": "هذه الميزة متوفرة فقط عند استخدام @:appName Cloud", "relatedQuestion": "ذات صلة", "serverUnavailable": "الخدمة غير متاحة مؤقتًا. يرجى المحاولة مرة أخرى لاحقًا.", "aiServerUnavailable": "تم فقد الاتصال. يرجى التحقق من اتصالك بالإنترنت", "retry": "إعادة المحاولة", "clickToRetry": "انقر لإعادة المحاولة", "regenerateAnswer": "إعادة توليد", "question1": "كيفية استخدام كانبان لإدارة المهام", "question2": "شرح طريقة GTD", "question3": "لماذا تستخدم Rust", "question4": "وصفة بما في مطبخي", "question5": "إنشاء رسم توضيحي لصفحتي", "question6": "قم بإعداد قائمة بالمهام التي يجب القيام بها خلال الأسبوع القادم", "aiMistakePrompt": "الذكاء الاصطناعي قد يرتكب أخطاء. تحقق من المعلومات المهمة.", "chatWithFilePrompt": "هل تريد الدردشة مع الملف؟", "indexFileSuccess": "تم فهرسة الملف بنجاح", "inputActionNoPages": "لا توجد نتائج للصفحة", "referenceSource": { "zero": "تم العثور على 0 من مصادر", "one": "تم العثور على {count} مصدر", "other": "تم العثور على {count} مصادر" }, "clickToMention": "اذكر صفحة", "uploadFile": "إرفاق ملفات PDF أو ملفات نصية أو ملفات Markdown", "questionDetail": "مرحبًا {}! كيف يمكنني مساعدتك اليوم؟", "indexingFile": "الفهرسة {}", "generatingResponse": "توليد الاستجابة", "selectSources": "اختر المصادر", "currentPage": "الصفحة الحالية", "sourcesLimitReached": "يمكنك فقط تحديد ما يصل إلى 3 مستندات من المستوى العلوي ومستنداتها الفرعية", "sourceUnsupported": "نحن لا ندعم الدردشة مع قواعد البيانات في الوقت الحالي", "regenerate": "حاول ثانية", "addToPageButton": "أضف إلى الصفحة", "addToPageTitle": "أضف رسالة إلى...", "addToNewPage": "أضف إلى صفحة جديدة", "addToNewPageName": "الرسائل المستخرجة من \"{}\"", "addToNewPageSuccessToast": "تمت إضافة الرسالة إلى", "openPagePreviewFailedToast": "فشل في فتح الصفحة", "changeFormat": { "actionButton": "تغيير التنسيق", "confirmButton": "إعادة توليد مع هذا التنسيق", "textOnly": "النص", "imageOnly": "الصورة فقط", "textAndImage": "النص والصورة", "text": "الفقرة", "bullet": "قائمة نقطية", "number": "قائمة مرقمة", "table": "الجدول", "blankDescription": "تنسيق الاستجابة", "defaultDescription": "الوضع التلقائي", "textWithImageDescription": "@:chat.changeFormat.text مع الصورة", "numberWithImageDescription": "@:chat.changeFormat.number مع الصورة", "bulletWithImageDescription": "@:chat.changeFormat.bullet مع الصورة", "tableWithImageDescription": "@:chat.changeFormat.table مع الصورة" }, "switchModel": { "label": "تبديل النموذج", "localModel": "النموذج المحلي", "cloudModel": "نموذج السحابة", "autoModel": "آلي" }, "selectBanner": { "saveButton": "أضف إلى...", "selectMessages": "حدد الرسائل", "nSelected": "{} تم التحديد", "allSelected": "جميعها محددة" }, "stopTooltip": "توقف عن التوليد" }, "trash": { "text": "المهملات", "restoreAll": "استعادة الكل", "restore": "إسترجاع", "deleteAll": "حذف الكل", "pageHeader": { "fileName": "اسم الملف", "lastModified": "آخر تعديل", "created": "تم انشاؤها" }, "confirmDeleteAll": { "title": "هل أنت متأكد من حذف جميع الصفحات في المهملات؟", "caption": "لا يمكن التراجع عن هذا الإجراء." }, "confirmRestoreAll": { "title": "هل أنت متأكد من استعادة جميع الصفحات في المهملات؟", "caption": "لا يمكن التراجع عن هذا الإجراء." }, "restorePage": { "title": "إسترجاع: {}", "caption": "هل أنت متأكد أنك تريد استعادة هذه الصفحة؟" }, "mobile": { "actions": "إجراءات سلة المهملات", "empty": "سلة المهملات فارغة", "emptyDescription": "ليس لديك أي ملفات محذوفة", "isDeleted": "محذوف", "isRestored": "تمت استعادته" }, "confirmDeleteTitle": "هل أنت متأكد أنك تريد حذف هذه الصفحة نهائياً؟" }, "deletePagePrompt": { "text": "هذه الصفحة في المهملات", "restore": "استعادة الصفحة", "deletePermanent": "الحذف بشكل نهائي", "deletePermanentDescription": "هل أنت متأكد أنك تريد حذف هذه الصفحة بشكل دائم؟ هذا لا يمكن التراجع عنه." }, "dialogCreatePageNameHint": "اسم الصفحة", "questionBubble": { "shortcuts": "الاختصارات", "whatsNew": "ما هو الجديد؟", "helpAndDocumentation": "المساعدة والتوثيق", "getSupport": "احصل على الدعم", "markdown": "Markdown", "debug": { "name": "معلومات التصحيح", "success": "تم نسخ معلومات التصحيح إلى الحافظة!", "fail": "تعذر نسخ معلومات التصحيح إلى الحافظة" }, "feedback": "تعليق", "help": "المساعدة والدعم" }, "menuAppHeader": { "moreButtonToolTip": "إزالة وإعادة تسمية والمزيد...", "addPageTooltip": "أضف صفحة في الداخل بسرعة", "defaultNewPageName": "بدون عنوان", "renameDialog": "إعادة تسمية", "pageNameSuffix": "نسخ" }, "noPagesInside": "لا توجد صفحات في الداخل", "toolbar": { "undo": "الغاء التحميل", "redo": "إعادة", "bold": "عريض", "italic": "مائل", "underline": "تسطير", "strike": "يتوسطه خط", "numList": "قائمة مرقمة", "bulletList": "قائمة نقطية", "checkList": "قائمة تدقيق", "inlineCode": "رمز مضمّن", "quote": "كتلة اقتباس", "header": "رأس", "highlight": "تسليط الضوء", "color": "لون", "addLink": "إضافة رابط", "link": "وصلة" }, "tooltip": { "lightMode": "قم بالتبديل إلى وضع الإضاءة", "darkMode": "قم بالتبديل إلى الوضع الداكن", "openAsPage": "فتح كصفحة", "addNewRow": "أضف صفًا جديدًا", "openMenu": "انقر لفتح القائمة", "dragRow": "اضغط مطولاً لإعادة ترتيب الصف", "viewDataBase": "عرض قاعدة البيانات", "referencePage": "تمت الإشارة إلى هذا {name}", "addBlockBelow": "إضافة كتلة أدناه", "aiGenerate": "توليد" }, "sideBar": { "closeSidebar": "إغلاق الشريط الجانبي", "openSidebar": "فتح الشريط الجانبي", "expandSidebar": "توسيع كصفحة كاملة", "personal": "شخصي", "private": "خاص", "workspace": "مساحة العمل", "favorites": "المفضلة", "clickToHidePrivate": "انقر لإخفاء المساحة الخاصة\nالصفحات التي قمت بإنشائها هنا مرئية لك فقط", "clickToHideWorkspace": "انقر لإخفاء مساحة العمل\nالصفحات التي قمت بإنشائها هنا تكون مرئية لكل الأعضاء", "clickToHidePersonal": "انقر لإخفاء القسم الشخصي", "clickToHideFavorites": "انقر لإخفاء القسم المفضل", "addAPage": "أضف صفحة", "addAPageToPrivate": "أضف صفحة إلى مساحة خاصة", "addAPageToWorkspace": "إضافة صفحة إلى مساحة العمل", "recent": "مؤخرًا", "today": "اليوم", "thisWeek": "هذا الأسبوع", "others": "المفضلات السابقة", "earlier": "سابقًا", "justNow": "الآن", "minutesAgo": "منذ {count} دقيقة", "lastViewed": "آخر مشاهدة", "favoriteAt": "المفضلة", "emptyRecent": "لا توجد صفحات حديثة", "emptyRecentDescription": "عند عرض الصفحات، ستظهر هنا لسهولة استرجاعها.", "emptyFavorite": "لا توجد صفحات مفضلة", "emptyFavoriteDescription": "قم بوضع علامة على الصفحات كمفضلة - سيتم إدراجها هنا للوصول السريع!", "removePageFromRecent": "هل تريد إزالة هذه الصفحة من القائمة الأخيرة؟", "removeSuccess": "تمت الإزالة بنجاح", "favoriteSpace": "المفضلة", "RecentSpace": "مؤخرًا", "Spaces": "المساحات", "upgradeToPro": "الترقية إلى الإصدار الاحترافي", "upgradeToAIMax": "إلغاء تأمين الذكاء الاصطناعي غير المحدود", "storageLimitDialogTitle": "لقد نفدت مساحة التخزين المجانية لديك. قم بالترقية لإلغاء تأمين مساحة تخزين غير محدودة", "storageLimitDialogTitleIOS": "لقد نفدت مساحة التخزين المجانية.", "aiResponseLimitTitle": "لقد نفدت منك استجابات الذكاء الاصطناعي المجانية. قم بالترقية إلى الخطة الاحترافية أو قم بشراء إضافة الذكاء الاصطناعي لفتح عدد غير محدود من الاستجابات", "aiResponseLimitDialogTitle": "تم الوصول إلى الحد الأقصى لاستجابات الذكاء الاصطناعي", "aiResponseLimit": "لقد نفدت استجابات الذكاء الاصطناعي المجانية.\nانتقل إلى الإعدادات -> الخطة -> انقر فوق AI Max أو Pro Plan للحصول على المزيد من استجابات الذكاء الاصطناعي", "askOwnerToUpgradeToPro": "مساحة العمل الخاصة بك نفدت من مساحة التخزين المجانية. يرجى مطالبة مالك مساحة العمل الخاصة بك بالترقية إلى الخطة الاحترافية", "askOwnerToUpgradeToProIOS": "مساحة العمل الخاصة بك على وشك النفاد من مساحة التخزين المجانية.", "askOwnerToUpgradeToAIMax": "لقد نفدت الاستجابات المجانية للذكاء الاصطناعي من مساحة العمل الخاصة بك. يرجى مطالبة مالك مساحة العمل الخاصة بك بترقية الخطة أو شراء إضافات الذكاء الاصطناعي", "askOwnerToUpgradeToAIMaxIOS": "مساحة العمل الخاصة بك تفتقر إلى الاستجابات المجانية للذكاء الاصطناعي.", "purchaseAIMax": "لقد نفدت استجابات الصور بالذكاء الاصطناعي من مساحة العمل الخاصة بك. يرجى مطالبة مالك مساحة العمل الخاصة بك بشراء AI Max", "aiImageResponseLimit": "لقد نفدت استجابات الصور الخاصة بالذكاء الاصطناعي.\nانتقل إلى الإعدادات -> الخطة -> انقر فوق AI Max للحصول على المزيد من استجابات صور AI", "purchaseStorageSpace": "شراء مساحة تخزين", "singleFileProPlanLimitationDescription": "لقد تجاوزت الحد الأقصى لحجم تحميل الملف المسموح به في الخطة المجانية. يرجى الترقية إلى الخطة الاحترافية لتحميل ملفات أكبر حجمًا", "purchaseAIResponse": "شراء", "askOwnerToUpgradeToLocalAI": "اطلب من مالك مساحة العمل تمكين الذكاء الاصطناعي على الجهاز", "upgradeToAILocal": "قم بتشغيل النماذج المحلية على جهازك لتحقيق أقصى قدر من الخصوصية", "upgradeToAILocalDesc": "الدردشة باستخدام ملفات PDF، وتحسين كتابتك، وملء الجداول تلقائيًا باستخدام الذكاء الاصطناعي المحلي" }, "notifications": { "export": { "markdown": "تم تصدير ملاحظة إلى Markdown", "path": "Documents/flowy" } }, "contactsPage": { "title": "جهات الاتصال", "whatsHappening": "ماذا يحدث هذا الاسبوع؟", "addContact": "إضافة جهة اتصال", "editContact": "تحرير جهة الاتصال" }, "button": { "ok": "حسنا", "confirm": "تأكيد", "done": "منتهي", "cancel": "الغاء", "signIn": "تسجيل الدخول", "signOut": "خروج", "complete": "مكتمل", "save": "حفظ", "generate": "يولد", "esc": "خروج", "keep": "ابقاء", "tryAgain": "حاول ثانية", "discard": "تجاهل", "replace": "يستبدل", "insertBelow": "إدراج أدناه", "insertAbove": "أدخل أعلاه", "upload": "رفع", "edit": "يحرر", "delete": "يمسح", "copy": "النسخ", "duplicate": "ينسخ", "putback": "ضعها بالخلف", "update": "تحديث", "share": "مشاركة", "removeFromFavorites": "إزالة من المفضلة", "removeFromRecent": "إزالة من الأحدث", "addToFavorites": "اضافة الى المفضلة", "favoriteSuccessfully": "النجاح المفضل", "unfavoriteSuccessfully": "النجاح غير المفضل", "duplicateSuccessfully": "تم التكرار بنجاح", "rename": "إعادة تسمية", "helpCenter": "مركز المساعدة", "add": "اضافة", "yes": "نعم", "no": "لا", "clear": "مسح", "remove": "حذف", "dontRemove": "لا تقم بالإزالة", "copyLink": "نسخ الرابط", "align": "المحاذاة", "login": "تسجيل الدخول", "logout": "تسجيل الخروج", "deleteAccount": "حذف الحساب", "back": "خلف", "signInGoogle": "متابعة مع جوجل", "signInGithub": "متابعة مع GitHub", "signInDiscord": "متابعة مع Discord", "more": "أكثر", "create": "إنشاء", "close": "إغلاق", "next": "التالي", "previous": "السابق", "submit": "إرسال", "download": "تحميل", "backToHome": "العودة إلى الصفحة الرئيسية", "viewing": "عرض", "editing": "تحرير", "gotIt": "فهمتها", "retry": "إعادة المحاولة", "uploadFailed": "فشل الرفع.", "copyLinkOriginal": "نسخ الرابط إلى الأصل", "tryAGain": "حاول ثانية" }, "label": { "welcome": "مرحباً!", "firstName": "الاسم الأول", "middleName": "الاسم الأوسط", "lastName": "اسم العائلة", "stepX": "الخطوة {X}" }, "oAuth": { "err": { "failedTitle": "غير قادر على الاتصال بحسابك.", "failedMsg": "يرجى التأكد من إكمال عملية تسجيل الدخول في متصفحك." }, "google": { "title": "تسجيل الدخول إلى GOOGLE", "instruction1": "لاستيراد جهات اتصال Google الخاصة بك ، ستحتاج إلى ترخيص هذا التطبيق باستخدام متصفح الويب الخاص بك.", "instruction2": "انسخ هذا الرمز إلى الحافظة الخاصة بك عن طريق النقر فوق الرمز أو تحديد النص:", "instruction3": "انتقل إلى الرابط التالي في متصفح الويب الخاص بك ، وأدخل الرمز أعلاه:", "instruction4": "اضغط على الزر أدناه عند الانتهاء من التسجيل:" } }, "settings": { "title": "إعدادات", "popupMenuItem": { "settings": "إعدادات", "members": "الأعضاء", "trash": "سلة المحذوفات", "helpAndDocumentation": "المساعدة والتوثيق", "getSupport": "احصل على الدعم", "helpAndSupport": "المساعدة والدعم" }, "sites": { "title": "المواقع", "namespaceTitle": "مساحة الاسم", "namespaceDescription": "إدارة مساحة الاسم والصفحة الرئيسية الخاصة بك", "namespaceHeader": "مساحة الاسم", "homepageHeader": "الصفحة الرئيسية", "updateNamespace": "تحديث مساحة الاسم", "removeHomepage": "إزالة الصفحة الرئيسية", "selectHomePage": "حدد الصفحة", "clearHomePage": "مسح الصفحة الرئيسية لهذه المساحة الاسمية", "customUrl": "عنوان URL مخصص", "homePage": { "upgradeToPro": "قم بالترقية إلى الخطة الاحترافية لتعيين الصفحة الرئيسية" }, "namespace": { "description": "سيتم تطبيق هذا التغيير على جميع الصفحات المنشورة الموجودة على مساحة الاسم هذه بشكل فوري", "tooltip": "نحن نحتفظ بالحق في إزالة أي مساحات أسماء غير مناسبة", "updateExistingNamespace": "تحديث مساحة الاسم الحالية", "upgradeToPro": "قم بالترقية إلى الخطة الاحترافية لتعيين الصفحة الرئيسية", "redirectToPayment": "إعادة التوجيه إلى صفحة الدفع...", "onlyWorkspaceOwnerCanSetHomePage": "يمكن فقط لمالك مساحة العمل تعيين الصفحة الرئيسية", "pleaseAskOwnerToSetHomePage": "يرجى طلب الترقية إلى الخطة الاحترافية من مالك مساحة العمل" }, "publishedPage": { "title": "جميع الصفحات المنشورة", "description": "إدارة صفحاتك المنشورة", "page": "صفحة", "pathName": "اسم المسار", "date": "تاريخ النشر", "emptyHinText": "ليس لديك صفحات منشورة في مساحة العمل هذه", "noPublishedPages": "لا توجد صفحات منشورة", "settings": "نشر الإعدادات", "clickToOpenPageInApp": "فتح الصفحة في التطبيق", "clickToOpenPageInBrowser": "فتح الصفحة في المتصفح" }, "error": { "failedToGeneratePaymentLink": "فشل في إنشاء رابط الدفع لخطة الاحترافية", "failedToUpdateNamespace": "فشل في تحديث مساحة الاسم", "proPlanLimitation": "يجب عليك الترقية إلى الخطة الاحترافية لتحديث مساحة الاسم", "namespaceAlreadyInUse": "تم أخذ مساحة الاسم بالفعل، يرجى تجربة مساحة اسم أخرى", "invalidNamespace": "مساحة اسم غير صالحة، يرجى تجربة مساحة اسم أخرى", "namespaceLengthAtLeast2Characters": "يجب أن تكون مساحة الاسم بطول حرفين على الأقل", "onlyWorkspaceOwnerCanUpdateNamespace": "يمكن فقط لمالك مساحة العمل تحديث مساحة الاسم", "onlyWorkspaceOwnerCanRemoveHomepage": "لا يمكن إلا لمالك مساحة العمل إزالة الصفحة الرئيسية", "setHomepageFailed": "فشل في تعيين الصفحة الرئيسية", "namespaceTooLong": "مساحة الاسم طويلة جدًا، يرجى تجربة مساحة اسم أخرى", "namespaceTooShort": "مساحة الاسم قصيرة جدًا، يرجى تجربة مساحة اسم أخرى", "namespaceIsReserved": "تم حجز مساحة الاسم، يرجى تجربة مساحة اسم أخرى", "updatePathNameFailed": "فشل في تحديث اسم المسار", "removeHomePageFailed": "فشل في إزالة الصفحة الرئيسية", "publishNameContainsInvalidCharacters": "يحتوي اسم المسار على أحرف غير صالحة، يرجى تجربة اسم مسار آخر", "publishNameTooShort": "اسم المسار قصير جدًا، يرجى تجربة اسم مسار آخر", "publishNameTooLong": "اسم المسار طويل جدًا، يرجى تجربة اسم مسار آخر", "publishNameAlreadyInUse": "اسم المسار قيد الاستخدام بالفعل، يرجى تجربة اسم مسار آخر", "namespaceContainsInvalidCharacters": "تحتوي مساحة الاسم على أحرف غير صالحة، يرجى تجربة مساحة اسم أخرى", "publishPermissionDenied": "يمكن فقط لمالك مساحة العمل أو ناشر الصفحة إدارة إعدادات النشر", "publishNameCannotBeEmpty": "لا يمكن أن يكون اسم المسار فارغًا، يرجى تجربة اسم مسار آخر" }, "success": { "namespaceUpdated": "تم تحديث مساحة الاسم بنجاح", "setHomepageSuccess": "تم تعيين الصفحة الرئيسية بنجاح", "updatePathNameSuccess": "تم تحديث اسم المسار بنجاح", "removeHomePageSuccess": "تم إزالة الصفحة الرئيسية بنجاح" } }, "accountPage": { "menuLabel": "حسابي", "title": "حسابي", "general": { "title": "اسم الحساب وصورة الملف الشخصي", "changeProfilePicture": "تغيير صورة الملف الشخصي" }, "email": { "title": "بريد إلكتروني", "actions": { "change": "تغيير البريد الإلكتروني" } }, "login": { "title": "تسجيل الدخول إلى الحساب", "loginLabel": "تسجيل الدخول", "logoutLabel": "تسجيل الخروج" }, "isUpToDate": "تم تحديث @:appName !", "officialVersion": "الإصدار {version} (الإصدار الرسمي)" }, "workspacePage": { "menuLabel": "مساحة العمل", "title": "مساحة العمل", "description": "قم بتخصيص مظهر مساحة العمل الخاصة بك، والسمة، والخط، وتخطيط النص، وتنسيق التاريخ/الوقت، واللغة.", "workspaceName": { "title": "اسم مساحة العمل" }, "workspaceIcon": { "title": "أيقونة مساحة العمل", "description": "قم بتحميل صورة أو استخدم رمزًا تعبيريًا لمساحة عملك. ستظهر الأيقونة في الشريط الجانبي والإشعارات." }, "appearance": { "title": "المظهر", "description": "قم بتخصيص مظهر مساحة العمل الخاصة بك والموضوع والخط وتخطيط النص والتاريخ والوقت واللغة.", "options": { "system": "آلي", "light": "فاتح", "dark": "داكن" } }, "resetCursorColor": { "title": "إعادة تعيين لون مؤشر المستند", "description": "هل أنت متأكد أنك تريد إعادة تعيين لون المؤشر؟" }, "resetSelectionColor": { "title": "إعادة تعيين لون اختيار المستند", "description": "هل أنت متأكد أنك تريد إعادة تعيين لون الإختيار؟" }, "resetWidth": { "resetSuccess": "تم إعادة تعيين عرض المستند بنجاح" }, "theme": { "title": "السمة", "description": "حدد السمة المحددة مسبقًا، أو قم برفع موضوعك المخصص.", "uploadCustomThemeTooltip": "رفع السمة المخصصة", "failedToLoadThemes": "فشل تحميل السمات، يرجى التحقق من إعدادات الأذونات في إعدادات النظام < الخصوصية والأمان <الملفات والمجلدات < @:appName" }, "workspaceFont": { "title": "خط مساحة العمل", "noFontHint": "لم يتم العثور على الخط، حاول مصطلحًا آخر." }, "textDirection": { "title": "اتجاه النص", "leftToRight": "من اليسار إلى اليمين", "rightToLeft": "من اليمين إلى اليسار", "auto": "آلي", "enableRTLItems": "تمكين عناصر شريط أدوات RTL" }, "layoutDirection": { "title": "اتجاه التخطيط", "leftToRight": "من اليسار إلى اليمين", "rightToLeft": "من اليمين إلى اليسار" }, "dateTime": { "title": "التاريخ والوقت", "example": "{} في {} ({})", "24HourTime": "الوقت بنظام 24 ساعة", "dateFormat": { "label": "تنسيق التاريخ", "local": "محلي", "us": "US أميركي", "iso": "ايزو", "friendly": "ودي", "dmy": "يوم/شهر/سنة" } }, "language": { "title": "اللغة" }, "deleteWorkspacePrompt": { "title": "حذف مساحة العمل", "content": "هل أنت متأكد من أنك تريد حذف مساحة العمل هذه؟ لا يمكن التراجع عن هذا الإجراء، وسيتم إلغاء نشر أي صفحات قمت بنشرها." }, "leaveWorkspacePrompt": { "title": "مغادرة مساحة العمل", "content": "هل أنت متأكد من أنك تريد مغادرة مساحة العمل هذه؟ ستفقد إمكانية الوصول إلى جميع الصفحات والبيانات الموجودة فيها.", "success": "لقد غادرت مساحة العمل بنجاح.", "fail": "فشل في مغادرة مساحة العمل." }, "manageWorkspace": { "title": "إدارة مساحة العمل", "leaveWorkspace": "مغادرة مساحة العمل", "deleteWorkspace": "حذف مساحة العمل" } }, "manageDataPage": { "menuLabel": "إدارة البيانات", "title": "إدارة البيانات", "description": "إدارة تخزين البيانات محليًا أو استيراد البيانات الموجودة لديك إلى @:appName .", "dataStorage": { "title": "موقع تخزين الملفات", "tooltip": "الموقع الذي يتم تخزين ملفاتك فيه", "actions": { "change": "تغيير المسار", "open": "افتح المجلد", "openTooltip": "فتح موقع مجلد البيانات الحالي", "copy": "نسخ المسار", "copiedHint": "تم نسخ المسار!", "resetTooltip": "إعادة التعيين إلى الموقع الافتراضي" }, "resetDialog": { "title": "هل أنت متأكد؟", "description": "لن يؤدي إعادة تعيين المسار إلى موقع البيانات الافتراضي إلى حذف بياناتك. إذا كنت تريد إعادة استيراد بياناتك الحالية، فيجب عليك نسخ مسار موقعك الحالي أولاً." } }, "importData": { "title": "استيراد البيانات", "tooltip": "استيراد البيانات من مجلدات النسخ الاحتياطية/البيانات @:appName", "description": "نسخ البيانات من مجلد بيانات خارجي @:appName", "action": "تصفح الملف" }, "encryption": { "title": "التشفير", "tooltip": "إدارة كيفية تخزين بياناتك وتشفيرها", "descriptionNoEncryption": "سيؤدي تشغيل التشفير إلى تشفير كافة البيانات. ولا يمكن التراجع عن هذا الإجراء.", "descriptionEncrypted": "بياناتك مشفرة.", "action": "تشفير البيانات", "dialog": { "title": "هل تريد تشفير كافة بياناتك؟", "description": "سيؤدي تشفير جميع بياناتك إلى الحفاظ على بياناتك آمنة. لا يمكن التراجع عن هذا الإجراء. هل أنت متأكد من أنك تريد المتابعة؟" } }, "cache": { "title": "مسح ذاكرة التخزين المؤقت", "description": "ساعد في حل مشكلات مثل عدم تحميل الصور، أو عدم ظهور الصفحات في مساحة، أو عدم تحميل الخطوط. لن يؤثر هذا على بياناتك.", "dialog": { "title": "مسح ذاكرة التخزين المؤقت", "description": "ساعد في حل مشكلات مثل عدم تحميل الصور، أو عدم ظهور الصفحات في مساحة، أو عدم تحميل الخطوط. لن يؤثر هذا على بياناتك.", "successHint": "تم مسح ذاكرة التخزين المؤقت!" } }, "data": { "fixYourData": "إصلاح بياناتك", "fixButton": "إصلاح", "fixYourDataDescription": "إذا كنت تواجه مشكلات مع بياناتك، فيمكنك محاولة إصلاحها هنا." } }, "shortcutsPage": { "menuLabel": "اختصارات", "title": "اختصارات", "editBindingHint": "إدخال ربط جديد", "searchHint": "بحث", "actions": { "resetDefault": "إعادة ضبط الافتراضي" }, "errorPage": { "message": "فشل تحميل الاختصارات: {}", "howToFix": "يرجى المحاولة مرة أخرى، إذا استمرت المشكلة، فيرجى التواصل معنا عبر GitHub." }, "resetDialog": { "title": "إعادة ضبط الاختصارات", "description": "سيؤدي هذا إلى إعادة ضبط كافة ارتباطات المفاتيح الخاصة بك إلى الوضع الافتراضي، ولا يمكنك التراجع عن هذا لاحقًا، هل أنت متأكد من أنك تريد المتابعة؟", "buttonLabel": "إعادة ضبط" }, "conflictDialog": { "title": "{} قيد الاستخدام حاليًا", "descriptionPrefix": "يتم استخدام ارتباطات المفاتيح هذه حاليًا بواسطة ", "descriptionSuffix": "إذا قمت باستبدال ارتباط المفتاح هذا، فسيتم إزالته من {}.", "confirmLabel": "متابعة" }, "editTooltip": "اضغط لبدء تحرير ربط المفتاح", "keybindings": { "toggleToDoList": "التبديل إلى قائمة المهام", "insertNewParagraphInCodeblock": "إدراج فقرة جديدة", "pasteInCodeblock": "لصق في كتلة الكود", "selectAllCodeblock": "حدد الكل", "indentLineCodeblock": "أدخل فراغين فاصلين في بداية السطر", "outdentLineCodeblock": "حذف فراغين فاصلين في بداية السطر", "twoSpacesCursorCodeblock": "إدراج فراغين فاصلين عند المؤشر", "copy": "اختيار النسخ", "paste": "لصق في المحتوى", "cut": "اختيار القص", "alignLeft": "محاذاة النص إلى اليسار", "alignCenter": "محاذاة النص إلى الوسط", "alignRight": "محاذاة النص إلى اليمين", "insertInlineMathEquation": "إدراج معادلة رياضية مضمنة", "undo": "التراجع", "redo": "الإعادة", "convertToParagraph": "تحويل الكتلة إلى فقرة", "backspace": "الحذف", "deleteLeftWord": "حذف الكلمة اليسرى", "deleteLeftSentence": "حذف الجملة اليسرى", "delete": "حذف الحرف الأيمن", "deleteMacOS": "حذف الحرف الأيسر", "deleteRightWord": "حذف الكلمة اليمنى", "moveCursorLeft": "حرك المؤشر إلى اليسار", "moveCursorBeginning": "حرك المؤشر إلى البداية", "moveCursorLeftWord": "حرك المؤشر إلى اليسار كلمة واحدة", "moveCursorLeftSelect": "حدد وحرك المؤشر إلى اليسار", "moveCursorBeginSelect": "حدد وحرك المؤشر إلى البداية", "moveCursorLeftWordSelect": "حدد وحرك المؤشر إلى اليسار كلمة واحدة", "moveCursorRight": "حرك المؤشر إلى اليمين", "moveCursorEnd": "حرك المؤشر إلى النهاية", "moveCursorRightWord": "حرك المؤشر إلى اليمين كلمة واحدة", "moveCursorRightSelect": "حدد المؤشر وحركه إلى اليمين", "moveCursorEndSelect": "حدد وحرك المؤشر إلى النهاية", "moveCursorRightWordSelect": "حدد وحرك المؤشر إلى اليمين كلمة واحدة", "moveCursorUp": "حرك المؤشر لأعلى", "moveCursorTopSelect": "حدد وحرك المؤشر إلى الأعلى", "moveCursorTop": "حرك المؤشر إلى الأعلى", "moveCursorUpSelect": "حدد وحرك المؤشر لأعلى", "moveCursorBottomSelect": "حدد وحرك المؤشر إلى الأسفل", "moveCursorBottom": "حرك المؤشر إلى الأسفل", "moveCursorDown": "حرك المؤشر إلى الأسفل", "moveCursorDownSelect": "حدد وحرك المؤشر لأسفل", "home": "تمرير إلى الأعلى", "end": "تمرير إلى الأسفل", "toggleBold": "تبديل الخط الغامق", "toggleItalic": "تبديل الخط المائل", "toggleUnderline": "تبديل التسطير", "toggleStrikethrough": "تبديل الشطب", "toggleCode": "تبديل الكود المضمن", "toggleHighlight": "تبديل التمييز", "showLinkMenu": "عرض قائمة الروابط", "openInlineLink": "افتح الرابط المضمن", "openLinks": "فتح جميع الروابط المحددة", "indent": "المسافة البادئة", "outdent": "مسافة بادئة سلبية", "exit": "الخروج من التحرير", "pageUp": "قم بالتمرير صفحة واحدة لأعلى", "pageDown": "قم بالتمرير صفحة واحدة لأسفل", "selectAll": "حدد الكل", "pasteWithoutFormatting": "لصق المحتوى بدون تنسيق", "showEmojiPicker": "عرض أداة اختيار الرموز التعبيرية", "enterInTableCell": "إضافة فاصل الأسطر في الجدول", "leftInTableCell": "حرك خلية واحدة إلى اليسار في الجدول", "rightInTableCell": "حرك خلية واحدة إلى اليمين في الجدول", "upInTableCell": "التحرك لأعلى خلية واحدة في الجدول", "downInTableCell": "التحرك لأسفل خلية واحدة في الجدول", "tabInTableCell": "انتقل إلى الخلية التالية المتوفرة في الجدول", "shiftTabInTableCell": "انتقل إلى الخلية المتوفرة سابقًا في الجدول", "backSpaceInTableCell": "توقف في بداية الخلية" }, "commands": { "codeBlockNewParagraph": "إدراج فقرة جديدة بجوار كتلة الكود", "codeBlockIndentLines": "أدخل مسافتين في بداية السطر في كتلة الكود", "codeBlockOutdentLines": "حذف مسافتين في بداية السطر في كتلة الكود", "codeBlockAddTwoSpaces": "أدخل مسافتين في موضع المؤشر في كتلة الكود", "codeBlockSelectAll": "تحديد كل المحتوى داخل كتلة الكود", "codeBlockPasteText": "لصق النص في كتلة الكود", "textAlignLeft": "محاذاة النص إلى اليسار", "textAlignCenter": "محاذاة النص إلى الوسط", "textAlignRight": "محاذاة النص إلى اليمين" }, "couldNotLoadErrorMsg": "لم نتمكن من تحميل الاختصارات، حاول مرة أخرى", "couldNotSaveErrorMsg": "لم نتمكن من حفظ الاختصارات، حاول مرة أخرى" }, "aiPage": { "title": "إعدادات الذكاء الاصطناعي", "menuLabel": "إعدادات الذكاء الاصطناعي", "keys": { "enableAISearchTitle": "بحث الذكاء الاصطناعي", "aiSettingsDescription": "اختر النموذج المفضل لديك لتشغيل AppFlowy AI. يتضمن الآن GPT 4-o وClaude 3,5 وLlama 3.1 وMistral 7B", "loginToEnableAIFeature": "لا يتم تمكين ميزات الذكاء الاصطناعي إلا بعد تسجيل الدخول باستخدام @:appName Cloud. إذا لم يكن لديك حساب @:appName ، فانتقل إلى \"حسابي\" للتسجيل", "llmModel": "نموذج اللغة", "globalLLMModel": "نموذج اللغة العام", "readOnlyField": "هذا الحقل للقراءة فقط", "llmModelType": "نوع نموذج اللغة", "downloadLLMPrompt": "تنزيل {}", "downloadAppFlowyOfflineAI": "سيؤدي تنزيل حزمة AI دون اتصال بالإنترنت إلى تفعيل تشغيل AI على جهازك. هل تريد الاستمرار؟", "downloadLLMPromptDetail": " تنزيل النموذج المحلي {} سيستخدم ما يصل إلى {} من مساحة التخزين. هل تريد الاستمرار؟", "downloadBigFilePrompt": "قد يستغرق الأمر حوالي 10 دقائق لإكمال التنزيل", "downloadAIModelButton": "تنزيل", "downloadingModel": "جاري التنزيل", "localAILoaded": "تمت إضافة نموذج الذكاء الاصطناعي المحلي بنجاح وهو جاهز للاستخدام", "localAIStart": "بدأت الدردشة المحلية بالذكاء الاصطناعي...", "localAILoading": "جاري تحميل نموذج الدردشة المحلية للذكاء الاصطناعي...", "localAIStopped": "تم إيقاف الذكاء الاصطناعي المحلي", "localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل", "localAINotReadyRetryLater": "جاري تهيئة الذكاء الاصطناعي المحلي، يرجى المحاولة مرة أخرى لاحقًا", "localAIDisabled": "أنت تستخدم الذكاء الاصطناعي المحلي، ولكنه مُعطّل. يُرجى الانتقال إلى الإعدادات لتفعيله أو تجربة نموذج آخر.", "localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك", "localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي", "localAIDisabledTextFieldPrompt": "لا يمكنك التحرير أثناء تعطيل الذكاء الاصطناعي المحلي", "failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي", "restartLocalAI": "إعادة تشغيل الذكاء الاصطناعي المحلي", "disableLocalAITitle": "تعطيل الذكاء الاصطناعي المحلي", "disableLocalAIDescription": "هل تريد تعطيل الذكاء الاصطناعي المحلي؟", "localAIToggleTitle": "التبديل لتفعيل أو تعطيل الذكاء الاصطناعي المحلي", "localAIToggleSubTitle": "قم بتشغيل نماذج الذكاء الاصطناعي المحلية الأكثر تقدمًا داخل AppFlowy للحصول على أقصى درجات الخصوصية والأمان", "offlineAIInstruction1": "اتبع", "offlineAIInstruction2": "تعليمات", "offlineAIInstruction3": "لتفعيل الذكاء الاصطناعي دون اتصال بالإنترنت.", "offlineAIDownload1": "إذا لم تقم بتنزيل AppFlowy AI، فيرجى", "offlineAIDownload2": "التنزيل", "offlineAIDownload3": "إنه أولا", "activeOfflineAI": "نشط", "downloadOfflineAI": "التنزيل", "openModelDirectory": "افتح المجلد", "laiNotReady": "لم يتم تثبيت تطبيق الذكاء الاصطناعي المحلي بشكل صحيح.", "ollamaNotReady": "خادم Ollama غير جاهز.", "pleaseFollowThese": "اتبع هؤلاء", "instructions": "التعليمات", "installOllamaLai": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل", "modelsMissing": "لم يتم العثور على النماذج المطلوبة.", "downloadModel": "لتنزيلها.", "startLocalAI": "قد يستغرق الأمر بضع ثوانٍ لبدء تشغيل الذكاء الاصطناعي المحلي" } }, "planPage": { "menuLabel": "الخطة", "title": "خطة التسعير", "planUsage": { "title": "ملخص استخدام الخطة", "storageLabel": "تخزين", "storageUsage": "{} من {} جيجا بايت", "unlimitedStorageLabel": "تخزين غير محدود", "collaboratorsLabel": "أعضاء", "collaboratorsUsage": "{} ل {}", "aiResponseLabel": "استجابات الذكاء الاصطناعي", "aiResponseUsage": "{} ل {}", "unlimitedAILabel": "استجابات غير محدودة", "proBadge": "احترافية", "aiMaxBadge": "الذكاء الاصطناعي ماكس", "aiOnDeviceBadge": "الذكاء الاصطناعي على الجهاز لنظام التشغيل Mac", "memberProToggle": "مزيد من الأعضاء وذكاء اصطناعي غير محدود", "aiMaxToggle": "ذكاء اصطناعي غير محدود وإمكانية الوصول إلى نماذج متقدمة", "aiOnDeviceToggle": "الذكاء الاصطناعي المحلي لتحقيق الخصوصية القصوى", "aiCredit": { "title": "أضف رصيد الذكاء الاصطناعي @:appName", "price": "{}", "priceDescription": "مقابل 1000 نقطة", "purchase": "شراء الذكاء الاصطناعي", "info": "أضف 1000 أرصدة الذكاء الاصطناعي لكل مساحة عمل وقم بدمج الذكاء الاصطناعي القابل للتخصيص بسلاسة في سير عملك للحصول على نتائج أذكى وأسرع مع ما يصل إلى:", "infoItemOne": "10000 استجابة لكل قاعدة بيانات", "infoItemTwo": "1000 استجابة لكل مساحة عمل" }, "currentPlan": { "bannerLabel": "الخطة الحالية", "freeTitle": "مجاني", "proTitle": "محترف", "teamTitle": "فريق", "freeInfo": "مثالي للأفراد حتى عضوين لتنظيم كل شيء", "proInfo": "مثالي للفرق الصغيرة والمتوسطة التي يصل عددها إلى 10 أعضاء.", "teamInfo": "مثالي لجميع الفرق المنتجة والمنظمة جيدًا.", "upgrade": "تغيير الخطة", "canceledInfo": "لقد تم إلغاء خطتك، وسيتم تخفيض مستوى خطتك إلى الخطة المجانية في {}." }, "addons": { "title": "مَرافِق", "addLabel": "إضافة", "activeLabel": "تمت الإضافة", "aiMax": { "title": "الحد الأقصى للذكاء الاصطناعي", "description": "استجابات الذكاء الاصطناعي غير المحدودة مدعومة بـ GPT-4o وClaude 3.5 Sonnet والمزيد", "price": "{}", "priceInfo": "لكل مستخدم شهريًا يتم تحصيل الرسوم سنويًا" }, "aiOnDevice": { "title": "الذكاء الاصطناعي على الجهاز لنظام التشغيل Mac", "description": "قم بتشغيل Mistral 7B وLLAMA 3 والمزيد من النماذج المحلية على جهازك", "price": "{}", "priceInfo": "لكل مستخدم شهريًا يتم تحصيل الرسوم سنويًا", "recommend": "يوصى باستخدام M1 أو أحدث" } }, "deal": { "bannerLabel": "صفقة العام الجديد!", "title": "تنمية فريقك!", "info": "قم بالترقية واحصل على خصم 10% على خطط Pro وTeam! عزز إنتاجية مساحة العمل لديك من خلال ميزات جديدة قوية بما في ذلك الذكاء الاصطناعي @:appName .", "viewPlans": "عرض الخطط" } } }, "billingPage": { "menuLabel": "الفوترة", "title": "الفوترة", "plan": { "title": "الخطة", "freeLabel": "مجاني", "proLabel": "مجاني", "planButtonLabel": "تغيير الخطة", "billingPeriod": "فترة الفوترة", "periodButtonLabel": "تعديل الفترة" }, "paymentDetails": { "title": "تفاصيل الدفع", "methodLabel": "طريقة الدفع", "methodButtonLabel": "طريقة التعديل" }, "addons": { "title": "مَرافِق", "addLabel": "إضافة", "removeLabel": "إزالة", "renewLabel": "تجديد", "aiMax": { "label": "الحد الأقصى للذكاء الاصطناعي", "description": "إلغاء تأمين عدد غير محدود من الذكاء الاصطناعي والنماذج المتقدمة", "activeDescription": "الفاتورة القادمة مستحقة في {}", "canceledDescription": "سيكون AI Max متوفرا حتى {}" }, "aiOnDevice": { "label": "الذكاء الاصطناعي على الجهاز لنظام التشغيل Mac", "description": "إلغاء تأمين الذكاء الاصطناعي غير المحدود على جهازك", "activeDescription": "الفاتورة القادمة مستحقة في {}", "canceledDescription": "ستتوفر ميزة AI On-device for Mac حتى {}" }, "removeDialog": { "title": "إزالة {}", "description": "هل أنت متأكد من أنك تريد إزالة {plan}؟ ستفقد الوصول إلى ميزات وفوائد {plan} على الفور." } }, "currentPeriodBadge": "الحالي", "changePeriod": "فترة التغيير", "planPeriod": "{} فترة", "monthlyInterval": "شهريا", "monthlyPriceInfo": "لكل مقعد يتم دفع الفاتورة شهريًا", "annualInterval": "سنويا", "annualPriceInfo": "لكل مقعد يتم دفع الفاتورة سنويًا" }, "comparePlanDialog": { "title": "قارن واختر الخطة", "planFeatures": " الخطة\nميزات", "current": "الحالي", "actions": { "upgrade": "ترقية", "downgrade": "يرجع إلى إصدار أقدم", "current": "الحالي" }, "freePlan": { "title": "مجاني", "description": "للأفراد حتى عضوين لتنظيم كل شيء", "price": "{}", "priceInfo": "مجاني للأبد" }, "proPlan": { "title": "احترافي", "description": "للفرق الصغيرة لإدارة المشاريع ومعرفة الفريق", "price": "{}", "priceInfo": "لكل مستخدم شهريا\nيتم فوترتها سنويا\n\n{} يتم الفوترتها شهريًا" }, "planLabels": { "itemOne": "مساحات العمل", "itemTwo": "الأعضاء", "itemThree": "التخزين", "itemFour": "التعاون في الزمن الحقيقي", "itemFive": "تطبيق الجوال", "itemSix": "استجابات الذكاء الاصطناعي", "itemSeven": "صور الذكاء الاصطناعي", "itemFileUpload": "رفع الملفات", "customNamespace": "مساحة اسم مخصصة", "tooltipFive": "تعاون في صفحات محددة مع غير الأعضاء", "tooltipSix": "تعني مدة الحياة أن عدد الاستجابات لا يتم إعادة ضبطه أبدًا", "intelligentSearch": "البحث الذكي", "tooltipSeven": "يتيح لك تخصيص جزء من عنوان URL لمساحة العمل الخاصة بك", "customNamespaceTooltip": "عنوان URL للموقع المنشور المخصص" }, "freeLabels": { "itemOne": "يتم فرض رسوم على كل مساحة عمل", "itemTwo": "حتى 2", "itemThree": "5 جيجا بايت", "itemFour": "نعم", "itemFive": "نعم", "itemSix": "10 مدى الحياة", "itemSeven": "2 مدى الحياة", "itemFileUpload": "حتى 7 ميجا بايت", "intelligentSearch": "البحث الذكي" }, "proLabels": { "itemOne": "تتم الفوترة على كل مساحة عمل", "itemTwo": "حتى 10", "itemThree": "غير محدود", "itemFour": "نعم", "itemFive": "نعم", "itemSix": "غير محدود", "itemSeven": "10 صور شهريا", "itemFileUpload": "غير محدود", "intelligentSearch": "البحث الذكي" }, "paymentSuccess": { "title": "أنت الآن على خطة {}!", "description": "لقد تمت معالجة الدفع بنجاح وتم ترقية خطتك إلى @:appName {}. يمكنك عرض تفاصيل خطتك على صفحة الخطة" }, "downgradeDialog": { "title": "هل أنت متأكد أنك تريد تخفيض خطتك؟", "description": "سيؤدي تخفيض مستوى خطتك إلى إعادتك إلى الخطة المجانية. قد يفقد الأعضاء إمكانية الوصول إلى مساحة العمل هذه وقد تحتاج إلى تحرير مساحة لتلبية حدود التخزين للخطة المجانية.", "downgradeLabel": " تخفيض الخطة" } }, "cancelSurveyDialog": { "title": "نحن نأسف لرحيلك", "description": "نحن نأسف لرحيلك. يسعدنا سماع تعليقاتك لمساعدتنا على تحسين @:appName . يُرجى تخصيص بعض الوقت للإجابة على بعض الأسئلة.", "commonOther": "آخر", "otherHint": "اكتب إجابتك هنا", "questionOne": { "question": "ما الذي دفعك إلى إلغاء اشتراكك @:appName Pro؟", "answerOne": "التكلفة مرتفعة للغاية", "answerTwo": "الميزات لم تلبي التوقعات", "answerThree": "وجدت بديلا أفضل", "answerFour": "لم أستخدمه بشكل كافي لتبرير التكلفة", "answerFive": "مشكلة في الخدمة أو صعوبات فنية" }, "questionTwo": { "question": "ما مدى احتمالية أن تفكر في إعادة الاشتراك في @:appName Pro في المستقبل؟", "answerOne": "من المرجح جدًا", "answerTwo": "من المحتمل إلى حد ما", "answerThree": "غير متأكد", "answerFour": "من غير المحتمل", "answerFive": "من غير المحتمل جدًا" }, "questionThree": { "question": "ما هي الميزة الاحترافية التي تقدرها أكثر أثناء اشتراكك؟", "answerOne": "التعاون بين المستخدمين المتعددين", "answerTwo": "تاريخ الإصدار الأطول", "answerThree": "استجابات الذكاء الاصطناعي غير المحدودة", "answerFour": "الوصول إلى نماذج الذكاء الاصطناعي المحلية" }, "questionFour": { "question": "كيف تصف تجربتك العامة مع @:appName ؟", "answerOne": "عظيم", "answerTwo": "جيد", "answerThree": "متوسط", "answerFour": "أقل من المتوسط", "answerFive": "غير راضٍ" } }, "common": { "uploadingFile": "جاري رفع الملف. الرجاء عدم الخروج من التطبيق", "uploadNotionSuccess": "تم تحميل ملف Notion zip الخاص بك بنجاح. بمجرد اكتمال عملية الاستيراد، ستتلقى رسالة تأكيد عبر البريد الإلكتروني", "reset": "إعادة ضبط" }, "menu": { "appearance": "مظهر", "language": "لغة", "user": "مستخدم", "files": "الملفات", "notifications": "إشعارات", "open": "أفتح الإعدادات", "logout": "تسجيل خروج", "logoutPrompt": "هل أنت متأكد من تسجيل الخروج؟", "selfEncryptionLogoutPrompt": "هل أنت متأكد أنك تريد تسجيل الخروج؟ يرجى التأكد من قيامك بنسخ رمز التشفير", "syncSetting": "إعدادات المزامنة", "cloudSettings": "إعدادات السحابة", "enableSync": "تفعيل المزامنة", "enableSyncLog": "تفعيل تسجيل المزامنة", "enableSyncLogWarning": "شكرًا لك على مساعدتك في تشخيص مشكلات المزامنة. سيؤدي هذا إلى تسجيل تعديلات المستندات الخاصة بك في ملف محلي. يرجى الخروج من التطبيق وإعادة فتحه بعد تفعيله", "enableEncrypt": "تشفير البيانات", "cloudURL": "الرابط الأساسي", "webURL": "عنوان الويب", "invalidCloudURLScheme": "مخطط غير صالح", "cloudServerType": "خادم سحابي", "cloudServerTypeTip": "يرجى ملاحظة أنه قد يقوم بتسجيل الخروج من حسابك الحالي بعد تبديل الخادم السحابي", "cloudLocal": "محلي", "cloudAppFlowy": "سحابة @:appName", "cloudAppFlowySelfHost": "@:appName استضافة ذاتية على السحابة", "appFlowyCloudUrlCanNotBeEmpty": "لا يمكن أن يكون عنوان URL السحابي فارغًا", "clickToCopy": "انقر للنسخ", "selfHostStart": "إذا لم يكن لديك خادم، يرجى الرجوع إلى", "selfHostContent": "مستند", "selfHostEnd": "للحصول على إرشادات حول كيفية استضافة الخادم الخاص بك ذاتيًا", "pleaseInputValidURL": "الرجاء إدخال عنوان URL صالح", "changeUrl": "تغيير عنوان URL المستضاف ذاتيًا إلى {}", "cloudURLHint": "أدخل الرابط الأساسي لخادمك", "webURLHint": "أدخل عنوان URL الأساسي لخادم الويب الخاص بك", "cloudWSURL": "عنوان Websockey", "cloudWSURLHint": "أدخل عنوان websocket لخادمك", "restartApp": "إعادة تشغيل", "restartAppTip": "أعد تشغيل التطبيق لتصبح التغييرات سارية المفعول. يرجى ملاحظة أن هذا قد يؤدي إلى تسجيل الخروج من حسابك الحالي", "changeServerTip": "بعد تغيير الخادم يجب عليك الضغط على زر إعادة التشغيل حتى تسري التغييرات", "enableEncryptPrompt": "قم بتنشيط التشفير لتأمين بياناتك بهذا السر. قم بتخزينها بأمان؛ بمجرد تمكينه، لا يمكن إيقاف تشغيله. في حالة فقدانها، تصبح بياناتك غير قابلة للاسترداد. انقر للنسخ", "inputEncryptPrompt": "الرجاء إدخال سر التشفير الخاص بك ل", "clickToCopySecret": "انقر لنسخ السر", "configServerSetting": "قم بتكوين إعدادات الخادم الخاص بك", "configServerGuide": "بعد تحديد \"البدء السريع\"، انتقل إلى \"الإعدادات\" ثم \"إعدادات السحابة\" لتهيئة خادمك المستضاف ذاتيًا.", "inputTextFieldHint": "السر الخاصة بك", "historicalUserList": "سجل تسجيل دخول المستخدم", "historicalUserListTooltip": "تعرض هذه القائمة حساباتك المجهولة. يمكنك النقر على الحساب لعرض تفاصيله. يتم إنشاء الحسابات المجهولة بالنقر فوق الزر \"البدء\".", "openHistoricalUser": "انقر لفتح الحساب الخفي", "customPathPrompt": "قد يؤدي تخزين مجلد بيانات @:appName في مجلد متزامن على السحابة مثل Google Drive إلى مخاطر. إذا تم الوصول إلى قاعدة البيانات الموجودة في هذا المجلد أو تعديلها من مواقع متعددة في نفس الوقت، فقد يؤدي ذلك إلى حدوث تعارضات في المزامنة وتلف محتمل للبيانات", "importAppFlowyData": "استيراد البيانات من مجلد خارجي @:appName", "importingAppFlowyDataTip": "جاري استيراد البيانات. يرجى عدم إغلاق التطبيق", "importAppFlowyDataDescription": "انسخ البيانات من مجلد بيانات خارجي @:appName واستوردها إلى مجلد بيانات AppFlowy الحالي", "importSuccess": "تم استيراد مجلد البيانات @:appName بنجاح", "importFailed": "فشل استيراد مجلد البيانات @:appName", "importGuide": "لمزيد من التفاصيل، يرجى مراجعة الوثيقة المشار إليها", "cloudSetting": "إعداد السحابة" }, "notifications": { "enableNotifications": { "label": "تمكين الإشعارات", "hint": "قم بإيقاف تشغيله لمنع ظهور الإشعارات المحلية." }, "showNotificationsIcon": { "label": "إظهار أيقونة الإشعارات", "hint": "قم بإيقاف تشغيله لإخفاء أيقونة الإشعار في الشريط الجانبي." }, "archiveNotifications": { "allSuccess": "تم أرشفة جميع الإشعارات بنجاح", "success": "تم أرشفة الإشعار بنجاح" }, "markAsReadNotifications": { "allSuccess": "تم وضع علامة على كل شيء كمقروء بنجاح", "success": "تم وضع علامة على القراءة بنجاح" }, "action": { "markAsRead": "وضع علامة كمقروءة", "multipleChoice": "إختر المزيد", "archive": "أرشيف" }, "settings": { "settings": "إعدادات", "markAllAsRead": "وضع علامة على الكل كمقروء", "archiveAll": "أرشفة الكل" }, "emptyInbox": { "title": "صندوق الوارد صفر!", "description": "قم بتعيين التذكيرات لتلقي الإشعارات هنا." }, "emptyUnread": { "title": "لا توجد إشعارات غير مقروءة", "description": "لقد تم قراءة جميع الرسائل." }, "emptyArchived": { "title": "غير مؤرشفة", "description": "ستظهر الإشعارات المؤرشفة هنا." }, "tabs": { "inbox": "صندوق الوارد", "unread": "غير مقروء", "archived": "مؤرشفة" }, "refreshSuccess": "تم تحديث الإشعارات بنجاح", "titles": { "notifications": "إشعارات", "reminder": "تذكير" } }, "appearance": { "resetSetting": "إعادة ضبط هذا الإعداد", "fontFamily": { "label": "الخط", "search": "يبحث", "defaultFont": "نظام" }, "themeMode": { "label": "مظهر السمة", "light": " المظهر الفاتح", "dark": "المظهر الداكن", "system": "التكيف مع النظام" }, "fontScaleFactor": "عامل مقياس الخط", "displaySize": "حجم العرض", "documentSettings": { "cursorColor": "لون مؤشر المستند", "selectionColor": "لون اختيار المستند", "width": "عرض المستند", "changeWidth": "تغير", "pickColor": "حدد اللون", "colorShade": "ظل اللون", "opacity": "الشفافية", "hexEmptyError": "لا يمكن أن يكون اللون السادسة عشرية فارغًا", "hexLengthError": "يجب أن تكون القيمة السداسية عشرية مكونة من 6 أرقام", "hexInvalidError": "القيمة السادسة العشرية غير صالحة", "opacityEmptyError": "لا يمكن أن تكون الشفافية فارغة", "opacityRangeError": "يجب أن تكون الشفافية بين 1 و 100", "app": "App", "flowy": "Flowy", "apply": "تطبيق" }, "layoutDirection": { "label": "اتجاه التخطيط", "hint": "التحكم في تدفق المحتوى على شاشتك، من اليسار إلى اليمين أو من اليمين إلى اليسار.", "ltr": "من اليسار الى اليمين", "rtl": "من اليمين الى اليسار" }, "textDirection": { "label": "اتجاه النص الافتراضي", "hint": "حدد ما إذا كان النص يجب أن يبدأ من اليسار أو اليمين كإعداد افتراضي.", "ltr": "من اليسار الى اليمين", "rtl": "من اليمين الى اليسار", "auto": "آلي", "fallback": "نفس اتجاه التخطيط" }, "themeUpload": { "button": "رفع", "uploadTheme": "رفع السمة", "description": "قم برفع السمة @:appName الخاص بك باستخدام الزر أدناه.", "loading": "يرجى الانتظار بينما نقوم بالتحقق من صحة السمة الخاصة بك وترفعها ...", "uploadSuccess": "تم رفع سمتك بنجاح", "deletionFailure": "فشل حذف الموضوع. حاول حذفه يدويًا.", "filePickerDialogTitle": "اختر ملف .flowy_plugin", "urlUploadFailure": "فشل فتح عنوان url: {}", "failure": "النسق الذي تم تحميله به تنسيق غير صالح." }, "theme": "سمة", "builtInsLabel": "ثيمات مدمجة", "pluginsLabel": "الإضافات", "dateFormat": { "label": "صيغة التاريخ", "local": "محلي", "us": "الولايات المتحدة", "iso": "ISO", "friendly": "سهل الاستخدام", "dmy": "ي/ش/س" }, "timeFormat": { "label": "تنسيق الوقت", "twelveHour": " نظام اثنتي عشرة ساعة", "twentyFourHour": "نظام أربع وعشرون ساعة" }, "showNamingDialogWhenCreatingPage": "إظهار مربع حوار التسمية عند إنشاء الصفحة", "enableRTLToolbarItems": "تمكين عناصر شريط أدوات RTL", "members": { "title": "إعدادات الأعضاء", "inviteMembers": "دعوة الأعضاء", "inviteHint": "دعوة عبر البريد الإلكتروني", "sendInvite": "إرسال دعوة", "copyInviteLink": "نسخ رابط الدعوة", "label": "الأعضاء", "user": "المستخدم", "role": "الدور", "removeFromWorkspace": "الإزالة من مساحة العمل", "removeFromWorkspaceSuccess": "تمت الإزالة من مساحة العمل بنجاح", "removeFromWorkspaceFailed": "فشلت عملية الإزالة من مساحة العمل", "owner": "المالك", "guest": "الضيف", "member": "العضو", "memberHintText": "يمكن للعضو قراءة الصفحات وتحريرها", "guestHintText": "يمكن للضيف القراءة والرد والتعليق وتحرير صفحات معينة بإذن.", "emailInvalidError": "بريد إلكتروني غير صالح، يرجى التحقق والمحاولة مرة أخرى", "emailSent": "تم إرسال البريد الإلكتروني، يرجى التحقق من صندوق الوارد", "members": "الأعضاء", "membersCount": { "zero": "{} الأعضاء", "one": "{} العضو", "other": "{} الأعضاء" }, "inviteFailedDialogTitle": "فشل في إرسال الدعوة", "inviteFailedMemberLimit": "لقد تم الوصول إلى الحد الأقصى للأعضاء، يرجى الترقية لدعوة المزيد من الأعضاء.", "inviteFailedMemberLimitMobile": "لقد وصلت مساحة العمل الخاصة بك إلى الحد الأقصى للأعضاء.", "memberLimitExceeded": "تم الوصول إلى الحد الأقصى للأعضاء، لدعوة المزيد من الأعضاء، يرجى ", "memberLimitExceededUpgrade": "ترقية", "memberLimitExceededPro": "تم الوصول إلى الحد الأقصى للأعضاء، إذا كنت بحاجة إلى المزيد من الأعضاء، فاتصل بنا ", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "فشل في إضافة العضو", "addMemberSuccess": "تمت إضافة العضو بنجاح", "removeMember": "إزالة العضو", "areYouSureToRemoveMember": "هل أنت متأكد أنك تريد إزالة هذا العضو؟", "inviteMemberSuccess": "لقد تم ارسال الدعوة بنجاح", "failedToInviteMember": "فشل في دعوة العضو", "workspaceMembersError": "عفواً، حدث خطأ ما", "workspaceMembersErrorDescription": "لم نتمكن من تحميل قائمة الأعضاء في هذا الوقت. يرجى المحاولة مرة أخرى لاحقًا", "inviteLinkToAddMember": "رابط الدعوة لإضافة عضو", "clickToCopyLink": "انقر هنا لنسخ الرابط", "or": "أو", "generateANewLink": "إنشاء رابط جديد", "inviteMemberByEmail": "دعوة الأعضاء عبر البريد الإلكتروني", "inviteMemberHintText": "دعوة عبر البريد الإلكتروني", "resetInviteLink": "إعادة ضبط رابط الدعوة؟", "resetInviteLinkDescription": "ستؤدي إعادة الضبط إلى إلغاء تفعيل الرابط الحالي لجميع أعضاء المساحة وإنشاء رابط جديد. لن يعود الرابط القديم صالحًا.", "adminPanel": "لوحة الإدارة", "reset": "إعادة ضبط", "resetInviteLinkSuccess": "تمت إعادة ضبط رابط الدعوة بنجاح", "resetInviteLinkFailed": "فشل إعادة ضبط رابط الدعوة", "resetInviteLinkFailedDescription": "الرجاء المحاولة مرة أخرى لاحقًا", "memberPageDescription1": "الوصول إلى", "memberPageDescription2": "لإدارة الضيوف والمستخدمين المتقدمين.", "noInviteLink": "لم تقم بإنشاء رابط دعوة بعد.", "copyLink": "انسخ الرابط", "generatedLinkSuccessfully": "تم إنشاء الرابط بنجاح", "generatedLinkFailed": "فشل في إنشاء الرابط", "resetLinkSuccessfully": "تم إعادة ضبط الرابط بنجاح", "resetLinkFailed": "فشل إعادة ضبط الرابط" }, "lightLabel": "فاتح", "darkLabel": "غامق" }, "files": { "copy": "انسخ", "defaultLocation": "أين يتم تخزين بياناتك الآن", "exportData": "قم بتصدير بياناتك", "doubleTapToCopy": "انقر نقرًا مزدوجًا لنسخ المسار", "restoreLocation": "استعادة المسار الافتراضي @:appName", "customizeLocation": "افتح مجلدًا آخر", "restartApp": "يرجى إعادة تشغيل التطبيق لتصبح التغييرات سارية المفعول.", "exportDatabase": "تصدير قاعدة البيانات", "selectFiles": "حدد الملفات التي تريد تصديرها", "selectAll": "اختر الكل", "deselectAll": "الغاء تحديد الكل", "createNewFolder": "انشاء مجلد جديد", "createNewFolderDesc": "أخبرنا بالمكان الذي تريد تخزين بياناتك فيه", "defineWhereYourDataIsStored": "حدد مكان تخزين بياناتك", "open": "يفتح", "openFolder": "افتح مجلدًا موجودًا", "openFolderDesc": "اقرأها واكتبها في مجلد @:appName الموجود لديك", "folderHintText": "إسم الملف", "location": "إنشاء مجلد جديد", "locationDesc": "اختر اسمًا لمجلد بيانات @:appName", "browser": "تصفح", "create": "يخلق", "set": "تعيين", "folderPath": "مسار لتخزين المجلد الخاص بك", "locationCannotBeEmpty": "لا يمكن أن يكون المسار فارغًا", "pathCopiedSnackbar": "تم نسخ مسار تخزين الملفات إلى الحافظة!", "changeLocationTooltips": "قم بتغيير دليل البيانات", "change": "يتغير", "openLocationTooltips": "افتح دليل بيانات آخر", "openCurrentDataFolder": "افتح دليل البيانات الحالي", "recoverLocationTooltips": "إعادة التعيين إلى دليل البيانات الافتراضي لـ @:appName", "exportFileSuccess": "تم تصدير الملف بنجاح!", "exportFileFail": "فشل تصدير الملف!", "export": "يصدّر", "clearCache": "مسح ذاكرة التخزين المؤقت", "clearCacheDesc": "إذا واجهت مشكلات تتعلق بعدم تحميل الصور أو عدم عرض الخطوط بشكل صحيح، فحاول مسح ذاكرة التخزين المؤقت. لن يؤدي هذا الإجراء إلى إزالة بيانات المستخدم الخاصة بك.", "areYouSureToClearCache": "هل أنت متأكد من مسح ذاكرة التخزين المؤقت؟", "clearCacheSuccess": "تم مسح ذاكرة التخزين المؤقت بنجاح!" }, "user": { "name": "اسم", "email": "بريد إلكتروني", "tooltipSelectIcon": "حدد أيقونة", "selectAnIcon": "حدد أيقونة", "pleaseInputYourOpenAIKey": "الرجاء إدخال مفتاح AI الخاص بك", "clickToLogout": "انقر لتسجيل خروج المستخدم الحالي", "pleaseInputYourStabilityAIKey": "يرجى إدخال رمز Stability AI الخاص بك" }, "mobile": { "personalInfo": "معلومات شخصية", "username": "اسم المستخدم", "usernameEmptyError": "لا يمكن أن يكون اسم المستخدم فارغا", "about": "حول", "pushNotifications": "اشعارات لحظية", "support": "دعم", "joinDiscord": "انضم إلينا على ديسكورد", "privacyPolicy": "سياسة الخصوصية", "userAgreement": "اتفاقية المستخدم", "termsAndConditions": "الشروط والأحكام", "userprofileError": "فشل تحميل ملف تعريف المستخدم", "userprofileErrorDescription": "يرجى محاولة تسجيل الخروج وتسجيل الدخول مرة أخرى للتحقق مما إذا كانت المشكلة لا تزال قائمة.", "selectLayout": "حدد الشكل", "selectStartingDay": "اختر يوم البدء", "version": "النسخة" }, "shortcuts": { "shortcutsLabel": "الاختصارات", "command": "امر", "keyBinding": "ربط المفاتيح", "addNewCommand": "إضافة أمر جديد", "updateShortcutStep": "اضغط على مجموعة المفاتيح المطلوبة ثم اضغط على ENTER", "shortcutIsAlreadyUsed": "هذا الاختصار مستخدم بالفعل لـ: {conflict}", "resetToDefault": "إعادة التعيين إلى روابط المفاتيح الافتراضية", "couldNotLoadErrorMsg": "تعذر تحميل الاختصارات، حاول مرة أخرى", "couldNotSaveErrorMsg": "تعذر حفظ الاختصارات، حاول مرة أخرى" } }, "grid": { "deleteView": "هل أنت متأكد من حذف هذه الواجهة؟", "createView": "جديد", "title": { "placeholder": "بدون عنوان" }, "settings": { "filter": "تنقية", "sort": "ترتيب", "sortBy": "ترتيب حسب", "properties": "ملكيات", "reorderPropertiesTooltip": "اسحب لإعادة ترتيب الخصائص", "group": "مجموعة", "addFilter": "أضف عامل تصفية", "deleteFilter": "حذف عامل التصفية", "filterBy": "مصنف بواسطة...", "typeAValue": "اكتب قيمة ...", "layout": "تَخطِيط", "compactMode": "الوضع المضغوط", "databaseLayout": "تَخطِيط", "viewList": { "zero": "0 مشاهدات", "one": "{count} مشاهدة", "other": "{count} المشاهدات" }, "editView": "تعديل العرض", "boardSettings": "إعدادات اللوحة", "calendarSettings": "إعدادات التقويم", "createView": "عرض جديد", "duplicateView": "عرض مكرر", "deleteView": "حذف العرض", "numberOfVisibleFields": "{} تم عرضه", "Properties": "ملكيات" }, "filter": { "empty": "لا توجد عوامل تصفية نشطة", "addFilter": "أضف عامل التصفية", "cannotFindCreatableField": "لا يمكن العثور على حقل مناسب للتصفية حسبه", "conditon": "حالة", "where": "أين" }, "textFilter": { "contains": "يتضمن", "doesNotContain": "لا يحتوي", "endsWith": "ينتهي بـ", "startWith": "ابدا ب", "is": "يكون", "isNot": "ليس", "isEmpty": "فارغ", "isNotEmpty": "ليس فارغا", "choicechipPrefix": { "isNot": "لا", "startWith": "ابدا ب", "endWith": "ينتهي بـ", "isEmpty": "فارغ", "isNotEmpty": "ليس فارغا" } }, "checkboxFilter": { "isChecked": "التحقق", "isUnchecked": "لم يتم التحقق منه", "choicechipPrefix": { "is": "يكون", "da": "بادئة خانة الاختيار" } }, "checklistFilter": { "isComplete": "كاملة", "isIncomplted": "غير مكتمل" }, "selectOptionFilter": { "is": "يكون", "isNot": "ليس", "contains": "يتضمن", "doesNotContain": "لا يحتوي", "isEmpty": "فارغ", "isNotEmpty": "ليس فارغا" }, "dateFilter": { "is": "يكون", "before": "يكون قبل", "after": "يكون بعد", "onOrBefore": "يكون في او قبل", "onOrAfter": "يكون في او بعد", "between": "يتراوح ما بين", "empty": "فارغ", "notEmpty": "ليس فارغا", "startDate": "تاريخ البدء", "endDate": "تاريخ النهاية", "choicechipPrefix": { "before": "قبل", "after": "بعد", "between": "بين", "onOrBefore": "في أو قبل", "onOrAfter": "في أو بعد", "isEmpty": "هو فارغ", "isNotEmpty": "ليس فارغا" } }, "numberFilter": { "equal": "يساوي", "notEqual": "لا يساوي", "lessThan": "أقل من", "greaterThan": "أكبر من", "lessThanOrEqualTo": "أقل من أو يساوي", "greaterThanOrEqualTo": "أكبر من أو يساوي", "isEmpty": "هو فارغ", "isNotEmpty": "ليس فارغا" }, "field": { "label": "خاصية", "hide": "يخفي", "show": "عرض", "insertLeft": "أدخل اليسار", "insertRight": "أدخل اليمين", "duplicate": "ينسخ", "delete": "يمسح", "wrapCellContent": "لف النص", "clear": "مسح الخلايا", "switchPrimaryFieldTooltip": "لا يمكن تغيير نوع الحقل للحقل الأساسي", "textFieldName": "نص", "checkboxFieldName": "خانة اختيار", "dateFieldName": "تاريخ", "updatedAtFieldName": "وقت آخر تعديل", "createdAtFieldName": "وقت الإنشاء", "numberFieldName": "أعداد", "singleSelectFieldName": "يختار", "multiSelectFieldName": "تحديد متعدد", "urlFieldName": "URL", "checklistFieldName": "قائمة تدقيق", "relationFieldName": "العلاقة", "summaryFieldName": "ملخص الذكاء الاصطناعي", "timeFieldName": "وقت", "mediaFieldName": "الملفات والوسائط", "translateFieldName": "الترجمة بالذكاء الاصطناعي", "translateTo": "ترجم إلى", "numberFormat": "تنسيق الأرقام", "dateFormat": "صيغة التاريخ", "includeTime": "أضف الوقت", "isRange": "تاريخ الانتهاء", "dateFormatFriendly": "شهر يوم سنه", "dateFormatISO": "سنة شهر يوم", "dateFormatLocal": "شهر يوم سنه", "dateFormatUS": "سنة شهر يوم", "dateFormatDayMonthYear": "يوم شهر سنة", "timeFormat": "تنسيق الوقت", "invalidTimeFormat": "تنسيق غير صالح", "timeFormatTwelveHour": "12 ساعة", "timeFormatTwentyFourHour": "24 ساعة", "clearDate": "مسح التاريخ", "dateTime": "الوقت و التاريخ", "startDateTime": "وقت تاريخ البدء", "endDateTime": "وقت تاريخ الانتهاء", "failedToLoadDate": "فشل تحميل قيمة التاريخ", "selectTime": "حدد الوقت", "selectDate": "حدد تاريخ", "visibility": "الظهور", "propertyType": "نوع الملكية", "addSelectOption": "أضف خيارًا", "typeANewOption": "اكتب خيارًا جديدًا", "optionTitle": "خيارات", "addOption": "إضافة خيار", "editProperty": "تحرير الملكية", "newProperty": "خاصية جديدة", "openRowDocument": "افتح كصفحة", "deleteFieldPromptMessage": "هل أنت متأكد؟ سيتم حذف هذه الخاصية", "clearFieldPromptMessage": "هل أنت متأكد؟ سيتم إفراغ جميع الخلايا في هذا العمود", "newColumn": "عمود جديد", "format": "شكل", "reminderOnDateTooltip": "تحتوي هذه الخلية على تذكير مجدول", "optionAlreadyExist": "الخيار موجود بالفعل" }, "rowPage": { "newField": "إضافة حقل جديد", "fieldDragElementTooltip": "انقر لفتح القائمة", "showHiddenFields": { "one": "إظهار {count} حقل", "many": "إظهار {count} الحقول المخفية", "other": "إظهار {count} الحقول المخفية" }, "hideHiddenFields": { "one": "إخفاء {count} الحقل المخفي", "many": "إخفاء {count} الحقول المخفية", "other": "إخفاء {count} الحقول المخفية" }, "openAsFullPage": "افتح كصفحة كاملة", "viewDatabase": "عرض قاعدة البيانات الأصلية", "moreRowActions": "مزيد من إجراءات الصف" }, "sort": { "ascending": "تصاعدي", "descending": "تنازلي", "by": "بواسطة", "empty": "لا توجد تصنيفات نشطة", "cannotFindCreatableField": "لا يمكن العثور على حقل مناسب للتصنيف حسبه", "deleteAllSorts": "حذف جميع التراتيب", "addSort": "أضف نوعًا", "sortsActive": "لا يمكن {intention} أثناء التصنيف", "removeSorting": "هل ترغب في إزالة كافة التصنيفات في هذا العرض والمتابعة؟", "fieldInUse": "أنت تقوم بالفعل بالتصنيف حسب هذا الحقل", "deleteSort": "حذف الفرز" }, "row": { "label": "صف", "duplicate": "مكرره", "delete": "يمسح", "titlePlaceholder": "بدون عنوان", "textPlaceholder": "فارغ", "copyProperty": "نسخ الممتلكات إلى الحافظة", "count": "عدد", "newRow": "صف جديد", "loadMore": "تحميل المزيد", "action": "فعل", "add": "انقر فوق إضافة إلى أدناه", "drag": "اسحب للتحريك", "deleteRowPrompt": "هل أنت متأكد من أنك تريد حذف هذا الصف؟ لا يمكن التراجع عن هذا الإجراء.", "deleteCardPrompt": "هل أنت متأكد من أنك تريد حذف هذه البطاقة؟ لا يمكن التراجع عن هذا الإجراء.", "dragAndClick": "اسحب للتحريك، انقر لفتح القائمة", "insertRecordAbove": "أدخل السجل أعلاه", "insertRecordBelow": "أدخل السجل أدناه", "noContent": "لا يوجد محتوى", "reorderRowDescription": "إعادة ترتيب الصف", "createRowAboveDescription": "إنشاء صف أعلى", "createRowBelowDescription": "أدخل صفًا أدناه" }, "selectOption": { "create": "يخلق", "purpleColor": "أرجواني", "pinkColor": "لون القرنفل", "lightPinkColor": "وردي فاتح", "orangeColor": "البرتقالي", "yellowColor": "أصفر", "limeColor": "جير", "greenColor": "أخضر", "aquaColor": "أكوا", "blueColor": "أزرق", "deleteTag": "حذف العلامة", "colorPanelTitle": "الألوان", "panelTitle": "حدد خيارًا أو أنشئ خيارًا", "searchOption": "ابحث عن خيار", "searchOrCreateOption": "بحث أو إنشاء خيار...", "createNew": "إنشاء جديد", "orSelectOne": "أو حدد خيارًا", "typeANewOption": "اكتب خيارًا جديدًا", "tagName": "اسم العلامة" }, "checklist": { "taskHint": "وصف المهمة", "addNew": "أضف عنصرًا", "submitNewTask": "انشئ", "hideComplete": "إخفاء المهام المكتملة", "showComplete": "إظهار كافة المهام" }, "url": { "launch": "فتح في المتصفح", "copy": "إنسخ الرابط", "textFieldHint": "أدخل عنوان URL" }, "relation": { "relatedDatabasePlaceLabel": "قاعدة البيانات ذات الصلة", "relatedDatabasePlaceholder": "لا أحد", "inRelatedDatabase": "في", "rowSearchTextFieldPlaceholder": "بحث", "noDatabaseSelected": "لم يتم تحديد قاعدة بيانات، الرجاء تحديد قاعدة بيانات واحدة أولاً من القائمة أدناه:", "emptySearchResult": "لم يتم العثور على أي سجلات", "linkedRowListLabel": "{count} صفوف مرتبطة", "unlinkedRowListLabel": "ربط صف آخر" }, "menuName": "شبكة", "referencedGridPrefix": "نظرا ل", "calculate": "احسب", "calculationTypeLabel": { "none": "لا أحد", "average": "المعدل", "max": "الحد الأقصى", "median": "المتوسط", "min": "الحد الأدنى", "sum": "المجموع", "count": "العدد", "countEmpty": "عدد فارغ", "countEmptyShort": "فارغ", "countNonEmpty": "العدد ليس فارغا", "countNonEmptyShort": "مملوء" }, "media": { "rename": "إعادة تسمية", "download": "التنزيل", "expand": "توسيع", "delete": "الحذف", "moreFilesHint": "+{}", "addFileOrImage": "إضافة ملف أو رابط", "attachmentsHint": "{}", "addFileMobile": "إضافة ملف", "extraCount": "+{}", "deleteFileDescription": "هل أنت متأكد من أنك تريد حذف هذا الملف؟ هذا الإجراء لا رجعة فيه.", "showFileNames": "إظهار اسم الملف", "downloadSuccess": "تم تنزيل الملف", "downloadFailedToken": "فشل تنزيل الملف، الرمز المميز للمستخدم غير متوفر", "setAsCover": "تعيين كغلاف", "openInBrowser": "فتح في المتصفح", "embedLink": "تضمين رابط الملف" } }, "document": { "menuName": "وثيقة", "date": { "timeHintTextInTwelveHour": "01:00 مساءً", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "جارٍ الإنشاء...", "slashMenu": { "board": { "selectABoardToLinkTo": "حدد لوحة للارتباط بها", "createANewBoard": "قم بإنشاء لوحة جديدة" }, "grid": { "selectAGridToLinkTo": "حدد الشبكة للارتباط بها", "createANewGrid": "قم بإنشاء شبكة جديدة" }, "calendar": { "selectACalendarToLinkTo": "حدد تقويمًا للارتباط به", "createANewCalendar": "قم بإنشاء تقويم جديد" }, "document": { "selectADocumentToLinkTo": "حدد مستندًا للارتباط به" }, "name": { "textStyle": "نمط النص", "list": "قائمة", "toggle": "تبديل", "fileAndMedia": "الملفات والوسائط", "simpleTable": "جدول بسيط", "visuals": "المرئيات", "document": "وثيقة", "advanced": "متقدم", "text": "نص", "heading1": "العنوان 1", "heading2": "العنوان 2", "heading3": "العنوان 3", "image": "صورة", "bulletedList": "قائمة نقطية", "numberedList": "قائمة مرقمة", "todoList": "قائمة المهام", "doc": "وثيقة", "linkedDoc": "رابط الصفحة", "grid": "شبكة", "linkedGrid": "الشبكة المرتبطة", "kanban": "كانبان", "linkedKanban": "كانبان مرتبط", "calendar": "تقويم", "linkedCalendar": "التقويم المرتبط", "quote": "اقتباس", "divider": "فاصل", "table": "الجدول", "callout": "وسيلة الشرح", "outline": "مخطط تفصيلي", "mathEquation": "معادلة الرياضيات", "code": "الكود", "toggleList": "قائمة التبديل", "toggleHeading1": "تبديل العنوان 1", "toggleHeading2": "تبديل العنوان 2", "toggleHeading3": "تبديل العنوان 3", "emoji": "الرموز التعبيرية", "aiWriter": "كاتب الذكاء الاصطناعي", "dateOrReminder": "التاريخ أو التذكير", "photoGallery": "معرض الصور", "file": "الملف", "twoColumns": "عمودين", "threeColumns": "3 أعمدة", "fourColumns": "4 أعمدة" }, "subPage": { "name": "المستند", "keyword1": "الصفحة الفرعية", "keyword2": "الصفحة", "keyword3": "الصفحة الطفل", "keyword4": "ادراج الصفحة", "keyword5": "تضمين الصفحة", "keyword6": "صفحة جديدة", "keyword7": "إنشاء صفحة", "keyword8": "المستند" } }, "selectionMenu": { "outline": "الخطوط العريضة", "codeBlock": "كتلة الكود" }, "plugins": { "referencedBoard": "المجلس المشار إليه", "referencedGrid": "الشبكة المشار إليها", "referencedCalendar": "التقويم المشار إليه", "referencedDocument": "الوثيقة المشار إليها", "aiWriter": { "userQuestion": "اسأل الذكاء الاصطناعي عن أي شيء", "continueWriting": "استمر في الكتابة", "fixSpelling": "تصحيح الأخطاء الإملائية والنحوية", "improveWriting": "تحسين الكتابة", "summarize": "تلخيص", "explain": "اشرح", "makeShorter": "اجعلها أقصر", "makeLonger": "اجعلها أطول" }, "autoGeneratorMenuItemName": "كاتب AI", "autoGeneratorTitleName": "AI: اطلب من الذكاء الاصطناعي كتابة أي شيء ...", "autoGeneratorLearnMore": "يتعلم أكثر", "autoGeneratorGenerate": "يولد", "autoGeneratorHintText": "اسأل AI ...", "autoGeneratorCantGetOpenAIKey": "لا يمكن الحصول على مفتاح AI", "autoGeneratorRewrite": "اعادة كتابة", "smartEdit": "مساعدي الذكاء الاصطناعي", "aI": "AI", "smartEditFixSpelling": "أصلح التهجئة", "warning": "⚠️ يمكن أن تكون استجابات الذكاء الاصطناعي غير دقيقة أو مضللة.", "smartEditSummarize": "لخص", "smartEditImproveWriting": "تحسين الكتابة", "smartEditMakeLonger": "اجعله أطول", "smartEditCouldNotFetchResult": "تعذر جلب النتيجة من AI", "smartEditCouldNotFetchKey": "تعذر جلب مفتاح AI", "smartEditDisabled": "قم بتوصيل AI في الإعدادات", "appflowyAIEditDisabled": "تسجيل الدخول لتمكين ميزات الذكاء الاصطناعي", "discardResponse": "هل تريد تجاهل استجابات الذكاء الاصطناعي؟", "createInlineMathEquation": "اصنع معادلة", "fonts": "الخطوط", "insertDate": "أدخل التاريخ", "emoji": "الرموز التعبيرية", "toggleList": "تبديل القائمة", "emptyToggleHeading": "تبديل فارغ h{}. انقر لإضافة محتوى.", "emptyToggleList": "قائمة تبديل فارغة. انقر لإضافة المحتوى.", "emptyToggleHeadingWeb": "تبديل فارغ h{level}. انقر لإضافة محتوى", "quoteList": "قائمة الاقتباس", "numberedList": "قائمة مرقمة", "bulletedList": "قائمة نقطية", "todoList": "قائمة مهام", "callout": "استدعاء", "simpleTable": { "moreActions": { "color": "اللون", "align": "المحاذاة", "delete": "حذف", "duplicate": "تكرار", "insertLeft": "أدخل اليسار", "insertRight": "إدراج إلى اليمين", "insertAbove": "إدراج أعلاه", "insertBelow": "إدراج أدناه", "headerColumn": "عمود رأس الصفحة", "headerRow": "صف رأس الصفحة", "clearContents": "مسح المحتويات", "setToPageWidth": "اضبط على عرض الصفحة", "distributeColumnsWidth": "توزيع الأعمدة بالتساوي", "duplicateRow": "صف مكرر", "duplicateColumn": "عمود مكرر", "textColor": "لون النص", "cellBackgroundColor": "لون خلفية الخلية", "duplicateTable": "جدول مكرر" }, "clickToAddNewRow": "انقر لإضافة صف جديد", "clickToAddNewColumn": "انقر لإضافة عمود جديد", "clickToAddNewRowAndColumn": "انقر لإضافة صف وعمود جديدين", "headerName": { "table": "الجدول", "alignText": "محاذاة النص" } }, "cover": { "changeCover": "تبديل الغطاء", "colors": "الألوان", "images": "الصور", "clearAll": "امسح الكل", "abstract": "خلاصة", "addCover": "أضف الغلاف", "addLocalImage": "أضف الصورة المحلية", "invalidImageUrl": "عنوان URL للصورة غير صالح", "failedToAddImageToGallery": "فشل في إضافة الصورة إلى المعرض", "enterImageUrl": "أدخل عنوان URL للصورة", "add": "يضيف", "back": "خلف", "saveToGallery": "حفظ في المعرض", "removeIcon": "إزالة الرمز", "removeCover": "إزالة الغلاف", "pasteImageUrl": "لصق عنوان URL للصورة", "or": "أو", "pickFromFiles": "اختر من الملفات", "couldNotFetchImage": "تعذر جلب الصورة", "imageSavingFailed": "فشل حفظ الصورة", "addIcon": "إضافة أيقونة", "changeIcon": "تغيير الايقونة", "coverRemoveAlert": "ستتم إزالته من الغلاف بعد حذفه.", "alertDialogConfirmation": "هل أنت متأكد أنك تريد الاستمرار؟" }, "mathEquation": { "name": "معادلة رياضية", "addMathEquation": "أضف معادلة رياضية", "editMathEquation": "تحرير المعادلة الرياضية" }, "optionAction": { "click": "انقر", "toOpenMenu": " لفتح القائمة", "drag": "سحب", "toMove": " حرك", "delete": "يمسح", "duplicate": "ينسخ", "turnInto": "تحول إلى", "moveUp": "تحرك", "moveDown": "تحرك لأسفل", "color": "لون", "align": "محاذاة", "left": "غادر", "center": "مركز", "right": "يمين", "defaultColor": "تقصير", "depth": "عمق", "copyLinkToBlock": "نسخ الرابط إلى الكتلة" }, "image": { "addAnImage": "أضف صورة", "copiedToPasteBoard": "تم نسخ رابط الصورة إلى الحافظة", "addAnImageDesktop": "أضف صورة", "addAnImageMobile": "انقر لإضافة صورة واحدة أو أكثر", "dropImageToInsert": "إسقاط الصور لإدراجها", "imageUploadFailed": "فشل رفع الصورة", "imageDownloadFailed": "فشل تنزيل الصورة، يرجى المحاولة مرة أخرى", "imageDownloadFailedToken": "فشل تنزيل الصورة بسبب عدم وجود رمز المستخدم، يرجى المحاولة مرة أخرى", "errorCode": "كود الخطأ" }, "photoGallery": { "name": "معرض الصور", "imageKeyword": "صورة", "imageGalleryKeyword": "معرض الصور", "photoKeyword": "صورة", "photoBrowserKeyword": "متصفح الصور", "galleryKeyword": "معرض الصور", "addImageTooltip": "أضف صورة", "changeLayoutTooltip": "تغيير التخطيط", "browserLayout": "المتصفح", "gridLayout": "شبكة", "deleteBlockTooltip": "حذف المعرض بأكمله" }, "math": { "copiedToPasteBoard": "تم نسخ المعادلة الرياضية إلى الحافظة" }, "urlPreview": { "copiedToPasteBoard": "تم نسخ الرابط إلى الحافظة", "convertToLink": "تحويل إلى رابط التضمين" }, "outline": { "addHeadingToCreateOutline": "أضف عناوين لإنشاء جدول محتويات.", "noMatchHeadings": "لم يتم العثور على عناوين مطابقة." }, "table": { "addAfter": "أضف بعد", "addBefore": "أضف قبل", "delete": "حذف", "clear": "مسح المحتوى", "duplicate": "نسخة طبق الاصل", "bgColor": "لون الخلفية" }, "contextMenu": { "copy": "نسخ", "cut": "قطع", "paste": "لصق", "pasteAsPlainText": "لصق كنص عادي" }, "action": "أجراءات", "database": { "selectDataSource": "حدد مصدر البيانات", "noDataSource": "لا يوجد مصدر للبيانات", "selectADataSource": "حدد مصدر البيانات", "toContinue": "للمتابعة", "newDatabase": "قاعدة بيانات جديدة", "linkToDatabase": "رابط لقاعدة البيانات" }, "date": "تاريخ", "video": { "label": "فيديو", "emptyLabel": "أضف فيديو", "placeholder": "ألصق رابط الفيديو", "copiedToPasteBoard": "تم نسخ رابط الفيديو إلى الحافظة", "insertVideo": "إضافة الفيديو", "invalidVideoUrl": "لم يتم دعم عنوان URL المصدر بعد.", "invalidVideoUrlYouTube": "لم يتم دعم YouTube بعد.", "supportedFormats": "التنسيقات المدعومة: MP4، WebM، MOV، AVI، FLV، MPEG/M4V، H.264" }, "file": { "name": "ملف", "uploadTab": "رفع", "uploadMobile": "اختر ملف", "uploadMobileGallery": "من معرض الصور", "networkTab": "تضمين الرابط", "placeholderText": "رفع أو تضمين ملف", "placeholderDragging": "إفلات الملف للرفع", "dropFileToUpload": "إفلات ملف لتحميله", "fileUploadHint": "اسحب وأفلِت ملفًا أو انقر فوقه ", "fileUploadHintSuffix": "تصفح", "networkHint": "لصق رابط الملف", "networkUrlInvalid": "عنوان URL غير صالح. تحقق من عنوان URL وحاول مرة أخرى.", "networkAction": "تضمين", "fileTooBigError": "حجم الملف كبير جدًا، يرجى رفع ملف بحجم أقل من 10 ميجا بايت", "renameFile": { "title": "إعادة تسمية الملف", "description": "أدخل الاسم الجديد لهذا الملف", "nameEmptyError": "لا يمكن ترك اسم الملف فارغًا." }, "uploadedAt": "تم الرفع في {}", "linkedAt": "تمت إضافة الرابط في {}", "failedToOpenMsg": "فشل في الفتح، لم يتم العثور على الملف" }, "subPage": { "handlingPasteHint": " - (التعامل مع اللصق)", "errors": { "failedDeletePage": "فشل في حذف الصفحة", "failedCreatePage": "فشل في إنشاء الصفحة", "failedMovePage": "فشل نقل الصفحة إلى هذا المستند", "failedDuplicatePage": "فشل في تكرار الصفحة", "failedDuplicateFindView": "فشل في تكرار الصفحة - لم يتم العثور على العرض الأصلي" } }, "cannotMoveToItsChildren": "لا يمكن الانتقال إلى أطفاله", "linkPreview": { "typeSelection": { "pasteAs": "لصق كـ", "mention": "ذِكْر", "URL": "عنوان URL", "bookmark": "إشارة مرجعية", "embed": "تضمين" }, "linkPreviewMenu": { "toMetion": "تحويل إلى ذكر", "toUrl": "تحويل إلى عنوان URL", "toEmbed": "تحويل إلى تضمين", "toBookmark": "تحويل إلى إشارة مرجعية", "copyLink": "نسخ الرابط", "replace": "استبدال", "reload": "إعادة التحميل", "removeLink": "إزالة الرابط", "pasteHint": "ألصق في https://...", "unableToDisplay": "غير قادر على العرض" } }, "autoCompletionMenuItemName": "عنصر قائمة الإكمال التلقائي" }, "outlineBlock": { "placeholder": "جدول المحتويات" }, "textBlock": { "placeholder": "اكتب \"/\" للأوامر" }, "title": { "placeholder": "بدون عنوان" }, "imageBlock": { "placeholder": "انقر لإضافة الصورة", "upload": { "label": "رفع", "placeholder": "انقر لتحميل الصورة" }, "url": { "label": "رابط الصورة", "placeholder": "أدخل عنوان URL للصورة" }, "ai": { "label": "إنشاء صورة من AI", "placeholder": "يرجى إدخال الامر الواصف لـ AI لإنشاء الصورة" }, "stability_ai": { "label": "إنشاء صورة من Stability AI", "placeholder": "يرجى إدخال المطالبة الخاصة بـ Stability AI لإنشاء الصورة" }, "support": "الحد الأقصى لحجم الصورة هو 5 ميغا بايت. التنسيقات المدعومة: JPEG ، PNG ، GIF ، SVG", "error": { "invalidImage": "صورة غير صالحة", "invalidImageSize": "يجب أن يكون حجم الصورة أقل من 5 ميغا بايت", "invalidImageFormat": "تنسيق الصورة غير مدعوم. التنسيقات المدعومة: JPEG ، PNG ، GIF ، SVG", "invalidImageUrl": "عنوان URL للصورة غير صالح", "noImage": "لا يوجد مثل هذا الملف أو الدليل", "multipleImagesFailed": "فشلت عملية رفع صورة واحدة أو أكثر، يرجى المحاولة مرة أخرى" }, "embedLink": { "label": "رابط متضمن", "placeholder": "الصق أو اكتب رابط الصورة" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "ابحث عن صورة", "pleaseInputYourOpenAIKey": "يرجى إدخال مفتاح AI الخاص بك في صفحة الإعدادات", "saveImageToGallery": "احفظ الصورة", "failedToAddImageToGallery": "فشلت إضافة الصورة إلى المعرض", "successToAddImageToGallery": "تمت إضافة الصورة إلى المعرض بنجاح", "unableToLoadImage": "غير قادر على تحميل الصورة", "maximumImageSize": "الحد الأقصى لحجم الصورة المسموح برفعها هو 10 ميجا بايت", "uploadImageErrorImageSizeTooBig": "يجب أن يكون حجم الصورة أقل من 10 ميجا بايت", "imageIsUploading": "جاري رفع الصورة", "openFullScreen": "افتح في الشاشة الكاملة", "interactiveViewer": { "toolbar": { "previousImageTooltip": "الصورة السابقة", "nextImageTooltip": "الصورة التالية", "zoomOutTooltip": "تصغير", "zoomInTooltip": "تكبير", "changeZoomLevelTooltip": "تغيير مستوى التكبير", "openLocalImage": "افتح الصورة", "downloadImage": "تنزيل الصورة", "closeViewer": "إغلاق العارض التفاعلي", "scalePercentage": "{}%", "deleteImageTooltip": "حذف الصورة" } }, "pleaseInputYourStabilityAIKey": "يرجى إدخال مفتاح Stability AI الخاص بك في صفحة الإعدادات" }, "codeBlock": { "language": { "label": "لغة", "placeholder": "اختار اللغة", "auto": "آلي" }, "copyTooltip": "نسخ", "searchLanguageHint": "ابحث عن لغة", "codeCopiedSnackbar": "تم نسخ الكود إلى الحافظة!" }, "inlineLink": { "placeholder": "الصق أو اكتب ارتباطًا", "openInNewTab": "فتح في علامة تبويب جديدة", "copyLink": "نسخ الرابط", "removeLink": "إزالة الرابط", "url": { "label": "URL رابط", "placeholder": "أدخل رابط" }, "title": { "label": "عنوان الارتباط", "placeholder": "أدخل عنوان الرابط" } }, "mention": { "placeholder": "اذكر شخص أو صفحة أو تاريخ...", "page": { "label": "رابط إلى الصفحة", "tooltip": "انقر لفتح الصفحة" }, "deleted": "تم الحذف", "deletedContent": "هذا المحتوى غير موجود أو تم حذفه", "noAccess": "لا يوجد وصول", "deletedPage": "الصفحة المحذوفة", "trashHint": " - في سلة المهملات", "morePages": "المزيد من الصفحات" }, "toolbar": { "resetToDefaultFont": "إعادة تعيين إلى الافتراضي", "textSize": "حجم النص", "textColor": "لون النص", "h1": "العنوان 1", "h2": "العنوان 2", "h3": "العنوان 3", "alignLeft": "محاذاة إلى اليسار", "alignRight": "محاذاة إلى اليمين", "alignCenter": "محاذاة إلى الوسط", "link": "وصلة", "textAlign": "محاذاة النص", "moreOptions": "المزيد من الخيارات", "font": "الخط", "inlineCode": "الكود المضمن", "suggestions": "اقتراحات", "turnInto": "تحول إلى", "equation": "معادلة", "insert": "إدراج", "linkInputHint": "لصق الرابط أو البحث عن الصفحات", "pageOrURL": "الصفحة أو عنوان URL", "linkName": "اسم الرابط", "linkNameHint": "اسم رابط الإدخال" }, "errorBlock": { "theBlockIsNotSupported": "الإصدار الحالي لا يدعم هذا الحقل.", "clickToCopyTheBlockContent": "انقر هنا لنسخ محتوى الكتلة", "blockContentHasBeenCopied": "تم نسخ محتوى الحقل.", "parseError": "حدث خطأ أثناء تحليل الكتلة {}.", "copyBlockContent": "نسخ محتوى الكتلة" }, "mobilePageSelector": { "title": "حدد الصفحة", "failedToLoad": "فشل تحميل قائمة الصفحات", "noPagesFound": "لم يتم العثور على صفحات" }, "attachmentMenu": { "choosePhoto": "اختر الصورة", "takePicture": "التقط صورة", "chooseFile": "اختر الملف" }, "data": { "timeHintTextInTwelveHour": "اثنا عشر ساعة", "timeHintTextInTwentyFourHour": "أربع و عشرون ساعة" } }, "board": { "column": { "label": "عمود", "createNewCard": "جديد", "renameGroupTooltip": "اضغط لإعادة تسمية المجموعة", "createNewColumn": "أضف مجموعة جديدة", "addToColumnTopTooltip": "أضف بطاقة جديدة في الأعلى", "addToColumnBottomTooltip": "أضف بطاقة جديدة في الأسفل", "renameColumn": "إعادة تسمية", "hideColumn": "اخفاء", "newGroup": "مجموعة جديدة", "deleteColumn": "مسح", "deleteColumnConfirmation": "سيؤدي هذا إلى حذف هذه المجموعة وجميع البطاقات الموجودة فيها. هل أنت متأكد من الاستمرار؟", "groupActions": "إجراءات المجموعة" }, "hiddenGroupSection": { "sectionTitle": "المجموعات المخفية", "collapseTooltip": "إخفاء المجموعات المخفية", "expandTooltip": "عرض المجموعات المخفية" }, "cardDetail": "تفاصيل البطاقة", "cardActions": "إجراءات البطاقة", "cardDuplicated": "تم تكرار البطاقة", "cardDeleted": "تم حذف البطاقة", "showOnCard": "اظهار التفاصيل على البطاقة", "setting": "اعداد", "propertyName": "اسم الخاصية", "menuName": "سبورة", "showUngrouped": "إظهار العناصر غير المجمعة", "ungroupedButtonText": "غير مجمعة", "ungroupedButtonTooltip": "تحتوي على بطاقات لا تنتمي إلى أي مجموعة", "ungroupedItemsTitle": "انقر للإضافة إلى السبورة", "groupBy": "مجموعة من", "groupCondition": "حالة المجموعة", "referencedBoardPrefix": "نظرا ل", "notesTooltip": "ملاحظات بالداخل", "mobile": { "editURL": "تعديل الرابط", "showGroup": "إظهار المجموعة", "showGroupContent": "هل أنت متأكد من إظهار هذه المجموعة على السبورة؟", "failedToLoad": "فشل تحميل عرض السبورة" }, "dateCondition": { "weekOf": "أسبوع {} - {}", "today": "اليوم", "yesterday": "أمس", "tomorrow": "غداً", "lastSevenDays": "آخر 7 أيام", "nextSevenDays": "الأيام 7 القادمة", "lastThirtyDays": "آخر 30 يوما", "nextThirtyDays": "30 يوما القادم" }, "noGroup": "لا توجد مجموعة حسب الخاصية", "noGroupDesc": "تتطلب وجهات نظر اللوحة خاصية للتجميع من أجل العرض", "media": { "cardText": "{} {}", "fallbackName": "الملفات" } }, "calendar": { "menuName": "تقويم", "defaultNewCalendarTitle": "بدون عنوان", "newEventButtonTooltip": "إضافة حدث جديد", "navigation": { "today": "اليوم", "jumpToday": "انتقل إلى اليوم", "previousMonth": "الشهر الماضى", "nextMonth": "الشهر القادم", "views": { "day": "يوم", "week": "أسبوع", "month": "شهر", "year": "سنة" } }, "mobileEventScreen": { "emptyTitle": "لا يوجد أحداث حتى الآن", "emptyBody": "اضغط على زر الإضافة \"+\" لإنشاء حدث في هذا اليوم." }, "settings": { "showWeekNumbers": "إظهار أرقام الأسبوع", "showWeekends": "عرض عطلات نهاية الأسبوع", "firstDayOfWeek": "اليوم الأول من الأسبوع", "layoutDateField": "تقويم التخطيط بواسطة", "changeLayoutDateField": "تغيير حقل التخطيط", "noDateTitle": "بدون تاريخ", "unscheduledEventsTitle": "الأحداث غير المجدولة", "clickToAdd": "انقر للإضافة إلى التقويم", "name": "تخطيط التقويم", "clickToOpen": "انقر لفتح السجل", "noDateHint": "ستظهر الأحداث غير المجدولة هنا" }, "referencedCalendarPrefix": "نظرا ل", "quickJumpYear": "انتقل إلى", "duplicateEvent": "حدث مكرر" }, "errorDialog": { "title": "خطأ @:appName", "howToFixFallback": "نأسف للإزعاج! قم بإرسال مشكلة على صفحة GitHub الخاصة بنا والتي تصف الخطأ الخاص بك.", "howToFixFallbackHint1": "نحن نأسف للإزعاج! أرسل مشكلة على ", "howToFixFallbackHint2": " الصفحة التي تصف الخطأ الخاص بك.", "github": "عرض على جيثب" }, "search": { "label": "يبحث", "sidebarSearchIcon": "ابحث وانتقل بسرعة إلى الصفحة", "searchOrAskAI": "ابحث أو اسأل الذكاء الاصطناعي", "askAIAnything": "اسأل الذكاء الاصطناعي عن أي شيء", "askAIFor": "اسأل الذكاء الاصطناعي", "noResultForSearching": "لم يتم العثور على أي تطابقات", "noResultForSearchingHint": "حاول استخدام أسئلة أو كلمات رئيسية مختلفة.\nقد تكون بعض الصفحات في سلة المهملات.", "bestMatch": "أفضل تطابق", "showMore": "إظهار المزيد", "somethingWentWrong": "لقد حدث خطأ ما", "pageNotExist": "هذه الصفحة غير موجودة", "tryAgainOrLater": "الرجاء المحاولة مرة أخرى لاحقًا", "placeholder": { "actions": "إجراءات البحث ..." } }, "message": { "copy": { "success": "نسخ!", "fail": "غير قادر على النسخ" } }, "unSupportBlock": "الإصدار الحالي لا يدعم هذه الكتلة.", "views": { "deleteContentTitle": "هل أنت متأكد من أنك تريد حذف {pageType}؟", "deleteContentCaption": "إذا قمت بحذف {pageType} هذه ، فيمكنك استعادتها من سلة المهملات." }, "colors": { "custom": "مخصص", "default": "اساسي", "red": "أحمر", "orange": "برتقالي", "yellow": "أصفر", "green": "أخضر", "blue": "أزرق", "purple": "بنفسجي", "pink": "وردي", "brown": "بني", "gray": "رمادي" }, "emoji": { "emojiTab": "رمز تعبيري", "search": "ابحث عن الرموز التعبيرية", "noRecent": "لا توجد رموز تعبيرية حديثة", "noEmojiFound": "لم يتم العثور على رموز تعبيرية", "filter": "منقي", "random": "عشوائي", "selectSkinTone": "حدد لون البشرة", "remove": "إزالة الرموز التعبيرية", "categories": { "smileys": "الوجوه الضاحكة والعاطفة", "people": "الناس والجسم", "animals": "الحيوانات والطبيعة", "food": "طعام شراب", "activities": "أنشطة", "places": "السفر والأماكن", "objects": "أشياء", "symbols": "حرف او رمز", "flags": "أعلام", "nature": "طبيعة", "frequentlyUsed": "تستخدم بشكل متكرر" }, "skinTone": { "default": "اساسي", "light": "فاتح", "mediumLight": "فاتح قليلا", "medium": "متوسط", "mediumDark": "متوسطة الظلمة", "dark": "مظلم" }, "openSourceIconsFrom": "أيقونات مفتوحة المصدر من" }, "inlineActions": { "noResults": "لا نتائج", "recentPages": "الصفحات الأخيرة", "pageReference": "مرجع الصفحة", "docReference": "مرجع المستند", "boardReference": "مرجع اللوحة", "calReference": "مرجع التقويم", "gridReference": "مرجع الشبكة", "date": "تاريخ", "reminder": { "groupTitle": "تذكير", "shortKeyword": "يذكر" }, "createPage": "إنشاء صفحة فرعية \"{}\"" }, "datePicker": { "dateTimeFormatTooltip": "تغيير تنسيق التاريخ والوقت في الإعدادات", "dateFormat": "تنسيق التاريخ", "includeTime": "تضمين الوقت", "isRange": "تاريخ النهاية", "timeFormat": "تنسيق الوقت", "clearDate": "مسح التاريخ", "reminderLabel": "تذكير", "selectReminder": "حدد التذكير", "reminderOptions": { "none": "لا شيء", "atTimeOfEvent": "وقت الحدث", "fiveMinsBefore": "قبل 5 دقائق", "tenMinsBefore": "قبل 10 دقائق", "fifteenMinsBefore": "قبل 15 دقيقة", "thirtyMinsBefore": "قبل 30 دقيقة", "oneHourBefore": "قبل ساعة واحدة", "twoHoursBefore": "قبل ساعتين", "onDayOfEvent": "في يوم الحدث", "oneDayBefore": "قبل يوم واحد", "twoDaysBefore": "قبل يومين", "oneWeekBefore": "قبل اسبوع واحد", "custom": "مخصص" } }, "relativeDates": { "yesterday": "أمس", "today": "اليوم", "tomorrow": "غداً", "oneWeek": "أسبوع" }, "notificationHub": { "title": "إشعارات", "closeNotification": "إغلاق الإشعار", "viewNotifications": "عرض الإشعارات", "noNotifications": "لا يوجد إشعارات حتى الآن", "mentionedYou": "ذكرتك", "archivedTooltip": "أرشفة هذا الإشعار", "unarchiveTooltip": "إلغاء أرشفة هذا الإشعار", "markAsReadTooltip": "قم بتمييز هذا الإشعار كمقروء", "markAsArchivedSucceedToast": "تم الأرشفة بنجاح", "markAllAsArchivedSucceedToast": "تم أرشفة كل شيء بنجاح", "markAsReadSucceedToast": "وضع علامة كمقروءة بنجاح", "markAllAsReadSucceedToast": "تم وضع علامة على كل شيء كمقروء بنجاح", "today": "اليوم", "older": "أقدم", "mobile": { "title": "تحديثات" }, "emptyTitle": "انت على اخر اصدار!", "emptyBody": "لا توجد إخطارات أو إجراءات معلقة. استمتع بالهدوء.", "tabs": { "inbox": "صندوق الوارد", "upcoming": "القادمة" }, "actions": { "markAllRead": "اشر عليها بانها قرات", "showAll": "الجميع", "showUnreads": "غير مقروءة" }, "filters": { "ascending": "تصاعدي", "descending": "تنازلي", "groupByDate": "ترتيب حسب التاريخ", "showUnreadsOnly": "إظهار الرسائل غير المقروءة فقط", "resetToDefault": "إعادة تعيين إلى الافتراضي" }, "empty": "فارغ" }, "reminderNotification": { "title": "تذكير", "message": "تذكر أن تتحقق من هذا قبل أن تنسى!", "tooltipDelete": "مسح", "tooltipMarkRead": "ضع إشارة مقروء", "tooltipMarkUnread": "وضع علامة كغير مقروءة" }, "findAndReplace": { "find": "البحث", "previousMatch": "المباراة السابقة", "nextMatch": "نتيجة البحث القادمة", "close": "اغلق", "replace": "استبدل", "replaceAll": "استبدال الكل", "noResult": "لا نتائج", "caseSensitive": "دقة الحروف", "searchMore": "ابحث للعثور على المزيد من النتائج" }, "error": { "weAreSorry": "آسفون", "loadingViewError": "نواجه مشكلة في تحميل هذا العرض. يرجى التحقق من اتصالك بالإنترنت، وتحديث التطبيق، ولا تتردد في التواصل مع الفريق إذا استمرت المشكلة.", "syncError": "لم تتم مزامنة البيانات من جهاز آخر", "syncErrorHint": "يرجى إعادة فتح هذه الصفحة على الجهاز الذي تم تحريرها عليه آخر مرة، ثم فتحها مرة أخرى على الجهاز الحالي.", "clickToCopy": "انقر هنا لنسخ كود الخطأ" }, "editor": { "bold": "عريض", "bulletedList": "قائمة نقطية", "bulletedListShortForm": "نقطية", "checkbox": "خانة الاختيار", "embedCode": "كود متضمن", "heading1": "رأسية اولى", "heading2": "رأسية ثانية", "heading3": "رأسية ثالثة", "highlight": "ابراز", "color": "لون", "image": "صورة", "date": "تاريخ", "page": "صفحة", "italic": "مائل", "link": "رابط", "numberedList": "قائمة مرقمة", "numberedListShortForm": "مرقمة", "toggleHeading1ShortForm": "تبديل h1", "toggleHeading2ShortForm": "تبديل h2", "toggleHeading3ShortForm": "تبديل h3", "quote": "اقتباس", "strikethrough": "يتوسطه خط", "text": "نص", "underline": "تسطير", "fontColorDefault": "اساسي", "fontColorGray": "رمادي", "fontColorBrown": "بني", "fontColorOrange": "برتقالي", "fontColorYellow": "أصفر", "fontColorGreen": "أخضر", "fontColorBlue": "أزرق", "fontColorPurple": "بنفسجي", "fontColorPink": "وردي", "fontColorRed": "أحمر", "backgroundColorDefault": "الخلفية الافتراضية", "backgroundColorGray": "خلفية رمادية", "backgroundColorBrown": "خلفية بنية", "backgroundColorOrange": "خلفية برتقالية", "backgroundColorYellow": "خلفية صفراء", "backgroundColorGreen": "خلفية خضراء", "backgroundColorBlue": "الخلفية الزرقاء", "backgroundColorPurple": "خلفية بنفسجية", "backgroundColorPink": "خلفية وردية", "backgroundColorRed": "خلفية حمراء", "backgroundColorLime": "خلفية ليمونية", "backgroundColorAqua": "خلفية مائية", "done": "تم", "cancel": "الغاء", "tint1": "صبغة 1", "tint2": "صبغة 2", "tint3": "صبغة 3", "tint4": "صبغة 4", "tint5": "صبغة 5", "tint6": "صبغة 6", "tint7": "صبغة 7", "tint8": "صبغة 8", "tint9": "صبغة 9", "lightLightTint1": "بنفسجي", "lightLightTint2": "وردي", "lightLightTint3": "وردي فاتح", "lightLightTint4": "برتقالي", "lightLightTint5": "أصفر", "lightLightTint6": "ليموني", "lightLightTint7": "أخضر", "lightLightTint8": "أكوا", "lightLightTint9": "أزرق", "urlHint": "رابط", "mobileHeading1": "عنوان 1", "mobileHeading2": "العنوان 2", "mobileHeading3": "العنوان 3", "mobileHeading4": "العنوان 4", "mobileHeading5": "العنوان 5", "mobileHeading6": "العنوان 6", "textColor": "لون الخط", "backgroundColor": "لون الخلفية", "addYourLink": "أضف الرابط الخاص بك", "openLink": "افتح الرابط", "copyLink": "انسخ الرابط", "removeLink": "إزالة الرابط", "editLink": "تعديل الرابط", "convertTo": "تحويل إلى", "linkText": "نص", "linkTextHint": "الرجاء إدخال النص", "linkAddressHint": "الرجاء إدخال الرابط", "highlightColor": "تسليط الضوء على اللون", "clearHighlightColor": "مسح لون التمييز", "customColor": "لون مخصص", "hexValue": "قيمة سداسية", "opacity": "العتامة", "resetToDefaultColor": "إعادة التعيين إلى اللون الافتراضي", "ltr": "من اليسار الى اليمين", "rtl": "من اليمين الى اليسار", "auto": "آلي", "cut": "قص", "copy": "نسخ", "paste": "لصق", "find": "البحث", "select": "حدد", "selectAll": "حدد الكل", "previousMatch": "نتيجة البحث السابقة", "nextMatch": "نتيجة البحث التالية", "closeFind": "اغلاق", "replace": "يستبدل", "replaceAll": "استبدال الكل", "regex": "التعبير العادي", "caseSensitive": "دقة الحروف", "uploadImage": "تحميل الصور", "urlImage": "رابط صورة ", "incorrectLink": "رابط غير صحيح", "upload": "رفع", "chooseImage": "اختر صورة", "loading": "تحميل", "imageLoadFailed": "لا يمكن تحميل الصورة", "divider": "مقسم", "table": "جدول", "colAddBefore": "إضافة قبل", "rowAddBefore": "إضافة قبل", "colAddAfter": "إضافة بعد", "rowAddAfter": "إضافة بعد", "colRemove": "ازالة", "rowRemove": "ازالة", "colDuplicate": "نسخة طبق الاصل", "rowDuplicate": "نسخة طبق الاصل", "colClear": "مسح المحتوى", "rowClear": "مسح المحتوى", "slashPlaceHolder": "اكتب \"/\" لإدراج كتلة، أو ابدأ الكتابة", "typeSomething": "اكتب شيئا ما...", "toggleListShortForm": "تبديل", "quoteListShortForm": "إقتباس", "mathEquationShortForm": "صيغة", "codeBlockShortForm": "الكود" }, "favorite": { "noFavorite": "لا توجد صفحة مفضلة", "noFavoriteHintText": "اسحب الصفحة إلى اليسار لإضافتها إلى المفضلة لديك", "removeFromSidebar": "إزالة من الشريط الجانبي", "addToSidebar": "تثبيت على الشريط الجانبي" }, "cardDetails": { "notesPlaceholder": "أدخل / لإدراج كتلة، أو ابدأ في الكتابة" }, "blockPlaceholders": { "todoList": "مهام", "bulletList": "قائمة", "numberList": "قائمة", "quote": "اقتباس", "heading": "العنوان {}" }, "titleBar": { "pageIcon": "رمز الصفحة", "language": "لغة", "font": "الخط", "actions": "أجراءات", "date": "تاريخ", "addField": "إضافة حقل", "userIcon": "رمز المستخدم" }, "noLogFiles": "لا توجد ملفات السجل", "newSettings": { "myAccount": { "title": "حسابي", "subtitle": "قم بتخصيص ملفك الشخصي، وإدارة أمان الحساب، وفتح مفاتيح الذكاء الاصطناعي، أو تسجيل الدخول إلى حسابك.", "profileLabel": "اسم الحساب وصورة الملف الشخصي", "profileNamePlaceholder": "أدخل اسمك", "accountSecurity": "أمان الحساب", "2FA": "المصادقة بخطوتين", "aiKeys": "مفاتيح الذكاء الاصطناعي", "accountLogin": "تسجيل الدخول إلى الحساب", "updateNameError": "فشل في تحديث الاسم", "updateIconError": "فشل في تحديث الأيقونة", "aboutAppFlowy": "حول appName", "deleteAccount": { "title": "حذف الحساب", "subtitle": "احذف حسابك وجميع بياناتك بشكل دائم.", "description": "احذف حسابك نهائيًا وأزل إمكانية الوصول إلى جميع مساحات العمل.", "deleteMyAccount": "حذف حسابي", "dialogTitle": "حذف الحساب", "dialogContent1": "هل أنت متأكد أنك تريد حذف حسابك نهائياً؟", "dialogContent2": "لا يمكن التراجع عن هذا الإجراء، وسوف يؤدي إلى إزالة الوصول من جميع مساحات العمل، ومسح حسابك بالكامل، بما في ذلك مساحات العمل الخاصة، وإزالتك من جميع مساحات العمل المشتركة.", "confirmHint1": "من فضلك اكتب \"@:newSettings.myAccount.deleteAccount.confirmHint3\" للتأكيد.", "confirmHint2": "أفهم أن هذا الإجراء لا رجعة فيه وسيؤدي إلى حذف حسابي وجميع البيانات المرتبطة به بشكل دائم.", "confirmHint3": "حذف حسابي", "checkToConfirmError": "يجب عليك تحديد المربع لتأكيد الحذف", "failedToGetCurrentUser": "فشل في الحصول على البريد الإلكتروني الحالي للمستخدم", "confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "تم حذف الحساب بنجاح" }, "password": { "title": "كلمة المرور", "confirmPassword": "تأكيد كلمة المرور", "changePassword": "تغيير كلمة المرور", "currentPassword": "كلمة المرور الحالية", "newPassword": "كلمة المرور الجديدة", "confirmNewPassword": "تأكيد كلمة المرور الجديدة", "setupPassword": "إعداد كلمة المرور", "error": { "currentPasswordIsRequired": "كلمة المرور الحالية مطلوبة", "newPasswordIsRequired": "مطلوب كلمة مرور جديدة", "confirmPasswordIsRequired": "تأكيد كلمة المرور مطلوب", "passwordsDoNotMatch": "كلمات المرور غير متطابقة", "newPasswordIsSameAsCurrent": "كلمة المرور الجديدة هي نفس كلمة المرور الحالية", "currentPasswordIsIncorrect": "كلمة المرور الحالية غير صحيحة", "passwordShouldBeAtLeast6Characters": "يجب أن تتكون كلمة المرور من {min} مِحْرَف على الأقل", "passwordCannotBeLongerThan72Characters": "لا يمكن أن تكون كلمة المرور أطول من {max} مِحْرَف" }, "toast": { "passwordUpdatedSuccessfully": "تم تحديث كلمة المرور بنجاح", "passwordUpdatedFailed": "فشل في تحديث كلمة المرور", "passwordSetupSuccessfully": "تم إعداد كلمة المرور بنجاح", "passwordSetupFailed": "فشل في إعداد كلمة المرور" }, "hint": { "enterYourPassword": "أدخل كلمة المرور الخاصة بك", "confirmYourPassword": "تأكيد كلمة المرور الخاصة بك", "enterYourCurrentPassword": "أدخل كلمة المرور الحالية الخاصة بك", "enterYourNewPassword": "أدخل كلمة المرور الجديدة", "confirmYourNewPassword": "تأكيد كلمة المرور الجديدة" } }, "myAccount": "حسابي", "myProfile": "ملفي الشخصي" }, "workplace": { "name": "مكان العمل", "title": "إعدادات مكان العمل", "subtitle": "قم بتخصيص مظهر مساحة العمل الخاصة بك والسمة والخط وتخطيط النص والتاريخ والوقت واللغة.", "workplaceName": "اسم مكان العمل", "workplaceNamePlaceholder": "أدخل اسم مكان العمل", "workplaceIcon": "أيقونة مكان العمل", "workplaceIconSubtitle": "قم بتحميل صورة أو استخدم رمزًا تعبيريًا لمساحة عملك. سيظهر الرمز في الشريط الجانبي والإشعارات.", "renameError": "فشل في إعادة تسمية مكان العمل", "updateIconError": "فشل في تحديث الأيقونة", "chooseAnIcon": "اختر أيقونة", "appearance": { "name": "المظهر", "themeMode": { "auto": "آلي", "light": "فاتح", "dark": "داكن" }, "language": "لغة" } }, "syncState": { "syncing": "المزامنة", "synced": "متزامنة", "noNetworkConnected": "لا يوجد شبكة متصلة" } }, "pageStyle": { "title": "نمط الصفحة", "layout": "تَخطِيط", "coverImage": "صورة الغلاف", "pageIcon": "أيقونة الصفحة", "colors": "الألوان", "gradient": "متدرج", "backgroundImage": "صورة الخلفية", "presets": "الإعدادات المسبقة", "photo": "صورة", "unsplash": "Unsplash", "pageCover": "غلاف الصفحة", "none": "لا شيء", "openSettings": "افتح الإعدادات", "photoPermissionTitle": "يرغب @:appName في الوصول إلى مكتبة الصور الخاصة بك", "photoPermissionDescription": "يحتاج @:appName إلى الوصول إلى صورك للسماح لك بإضافة صور إلى مستنداتك", "cameraPermissionTitle": "يريد @:appName الوصول إلى الكاميرا الخاصة بك", "cameraPermissionDescription": "يحتاج @:appName إلى الوصول إلى الكاميرا للسماح لك بإضافة صور إلى مستنداتك من الكاميرا", "doNotAllow": "لا تسمح", "image": "صورة" }, "commandPalette": { "placeholder": "ابحث أو اطرح سؤالا...", "bestMatches": "أفضل المطابقات", "aiOverview": "نظرة عامة على الذكاء الاصطناعي", "aiOverviewSource": "مصادر مرجعية", "aiOverviewMoreDetails": "مزيد من التفاصيل", "pagePreview": "معاينة المحتوى", "clickToOpenPage": "انقر لفتح الصفحة", "recentHistory": "التاريخ الحديث", "navigateHint": "للتنقل", "loadingTooltip": "نحن نبحث عن نتائج...", "betaLabel": "النسخة التجريبية", "betaTooltip": "نحن ندعم حاليًا البحث عن الصفحات والمحتوى في المستندات فقط", "fromTrashHint": "من سلة المحذوفات", "noResultsHint": "لم نعثر على ما تبحث عنه، حاول البحث عن مصطلح آخر.", "clearSearchTooltip": "مسح حقل البحث", "location": "موقع", "created": "تم الإنشاء", "edited": "تم التعديل" }, "space": { "delete": "حذف", "deleteConfirmation": "حذف: ", "deleteConfirmationDescription": "سيتم حذف جميع الصفحات الموجودة ضمن هذه المساحة ونقلها إلى سلة المحذوفات، وسيتم إلغاء نشر أي صفحات منشورة.", "rename": "إعادة تسمية المساحة", "changeIcon": "تغيير الأيقونة", "manage": "إدارة المساحة", "addNewSpace": "إنشاء مساحة", "collapseAllSubPages": "طي كل الصفحات الفرعية", "createNewSpace": "إنشاء مساحة جديدة", "createSpaceDescription": "إنشاء مساحات عامة وخاصة متعددة لتنظيم عملك بشكل أفضل.", "spaceName": "اسم المساحة", "spaceNamePlaceholder": "على سبيل المثال التسويق والهندسة والموارد البشرية", "permission": "إذن المساحة", "publicPermission": "عام", "publicPermissionDescription": "جميع أعضاء مساحة العمل لديهم إمكانية الوصول الكامل", "privatePermission": "خاص", "privatePermissionDescription": "أنت فقط من يمكنه الوصول إلى هذه المساحة", "spaceIconBackground": "لون الخلفية", "spaceIcon": "أيقونة", "dangerZone": "منطقة الخطر", "unableToDeleteLastSpace": "غير قادر على حذف المساحة الأخيرة", "unableToDeleteSpaceNotCreatedByYou": "غير قادر على حذف المساحات التي أنشأها الآخرون", "enableSpacesForYourWorkspace": "تمكين المساحات لمساحة العمل الخاصة بك", "title": "المساحات", "defaultSpaceName": "عام", "upgradeSpaceTitle": "تمكين المساحات", "upgradeSpaceDescription": "قم بإنشاء مساحات عامة وخاصة متعددة لتنظيم مساحة عملك بشكل أفضل.", "upgrade": "تحديث", "upgradeYourSpace": "إنشاء مساحات متعددة", "quicklySwitch": "انتقل بسرعة إلى المساحة التالية", "duplicate": "مساحة مكررة", "movePageToSpace": "نقل الصفحة إلى المساحة", "cannotMovePageToDatabase": "لا يمكن نقل الصفحة إلى قاعدة البيانات", "switchSpace": "تبديل المساحة", "spaceNameCannotBeEmpty": "لا يمكن أن يكون اسم المساحة فارغًا", "success": { "deleteSpace": "تم حذف المساحة بنجاح", "renameSpace": "تم إعادة تسمية المساحة بنجاح", "duplicateSpace": "تم تكرار المساحة بنجاح", "updateSpace": "تم تحديث المساحة بنجاح" }, "error": { "deleteSpace": "فشل في حذف المساحة", "renameSpace": "فشل في إعادة تسمية المساحة", "duplicateSpace": "فشل في تكرار المساحة", "updateSpace": "فشل في تحديث المساحة" }, "createSpace": "إنشاء مساحة", "manageSpace": "إدارة المساحة", "renameSpace": "إعادة تسمية المساحة", "mSpaceIconColor": "لون أيقونة المساحة", "mSpaceIcon": "أيقونة المساحة" }, "publish": { "hasNotBeenPublished": "لم يتم نشر هذه الصفحة بعد", "spaceHasNotBeenPublished": "لم يتم دعم نشر المساحة بعد", "reportPage": "صفحة التقرير", "databaseHasNotBeenPublished": "لم يتم دعم نشر قاعدة البيانات بعد.", "createdWith": "تم إنشاؤه باستخدام", "downloadApp": "تنزيل AppFlowy", "copy": { "codeBlock": "تم نسخ محتوى كتلة الكود إلى الحافظة", "imageBlock": "تم نسخ رابط الصورة إلى الحافظة", "mathBlock": "تم نسخ المعادلة الرياضية إلى الحافظة", "fileBlock": "تم نسخ رابط الملف إلى الحافظة" }, "containsPublishedPage": "تحتوي هذه الصفحة على صفحة واحدة أو أكثر منشورة. إذا تابعت، فسيتم إلغاء نشرها. هل تريد متابعة الحذف؟", "publishSuccessfully": "تم النشر بنجاح", "unpublishSuccessfully": "تم إلغاء النشر بنجاح", "publishFailed": "فشل في النشر", "unpublishFailed": "فشل في إلغاء النشر", "noAccessToVisit": "لا يمكن الوصول إلى هذه الصفحة...", "createWithAppFlowy": "إنشاء موقع ويب مع AppFlowy", "fastWithAI": "سريع وسهل مع الذكاء الاصطناعي.", "tryItNow": "جربها الآن", "onlyGridViewCanBePublished": "لا يمكن نشر سوى عرض الشبكة", "database": { "zero": "نشر {} عرض محدد", "one": "نشر {} عروض محددة", "many": "نشر {} عروض محددة", "other": "نشر {} عروض محددة" }, "mustSelectPrimaryDatabase": "يجب تحديد العرض الأساسي", "noDatabaseSelected": "لم يتم تحديد أي قاعدة بيانات، يرجى تحديد قاعدة بيانات واحدة على الأقل.", "unableToDeselectPrimaryDatabase": "غير قادر على إلغاء تحديد قاعدة البيانات الأساسية", "saveThisPage": "ابدأ بهذا القالب", "duplicateTitle": "أين تريد أن تضيف", "selectWorkspace": "حدد مساحة العمل", "addTo": "أضف إلى", "duplicateSuccessfully": "تمت إضافته إلى مساحة العمل الخاصة بك", "duplicateSuccessfullyDescription": "لم تقم بتثبيت AppFlowy؟ سيبدأ التنزيل تلقائيًا بعد النقر فوق \"تنزيل\".", "downloadIt": "تنزيل", "openApp": "افتح في التطبيق", "duplicateFailed": "مكررة فشلت", "membersCount": { "zero": "لا يوجد أعضاء", "one": "1 عضو", "many": "{count} عضوا", "other": "{count} عضوا" }, "useThisTemplate": "استخدم القالب" }, "web": { "continue": "متابعة", "or": "أو", "continueWithGoogle": "متابعة مع جوجل", "continueWithGithub": "متابعة مع GitHub", "continueWithDiscord": "متابعة مع Discord", "continueWithApple": "متابعة مع Apple ", "moreOptions": "المزيد من الخيارات", "collapse": "طي", "signInAgreement": "بالنقر فوق \"متابعة\" أعلاه، فإنك توافق على شروط استخدام AppFlowy", "signInLocalAgreement": "من خلال النقر على \"البدء\" أعلاه، فإنك توافق على شروط وأحكام AppFlowy", "and": "و", "termOfUse": "شروط", "privacyPolicy": "سياسة الخصوصية", "signInError": "خطأ في تسجيل الدخول", "login": "سجل أو قم بتسجيل الدخول", "fileBlock": { "uploadedAt": "تم الرفع في {time}", "linkedAt": "تمت إضافة الرابط في {time}", "empty": "رفع أو تضمين ملف", "uploadFailed": "فشل الرفع، يرجى المحاولة مرة أخرى", "retry": "إعادة المحاولة" }, "importNotion": "الاستيراد من Notion", "import": "الاستيراد", "importSuccess": "تم الرفع بنجاح", "importSuccessMessage": "سنخطرك عند اكتمال عملية الاستيراد. بعد ذلك، يمكنك عرض الصفحات المستوردة في الشريط الجانبي.", "importFailed": "فشل الاستيراد، يرجى التحقق من تنسيق الملف", "dropNotionFile": "قم بإسقاط ملف Notion zip الخاص بك هنا للرفع، أو انقر للاستعراض", "error": { "pageNameIsEmpty": "اسم الصفحة فارغ، الرجاء تجربة صفحة أخرى" } }, "globalComment": { "comments": "تعليقات", "addComment": "أضف تعليق", "reactedBy": "تفاعل من قبل", "addReaction": "إضافة تفاعل", "reactedByMore": "و {count} آخرين", "showSeconds": { "one": "منذ ثانية واحدة", "other": "منذ {count} ثانية", "zero": "الآن", "many": "منذ {count} ثانية" }, "showMinutes": { "one": "منذ دقيقة واحدة", "other": "منذ {count} دقيقة", "many": "منذ {count} دقيقة" }, "showHours": { "one": "منذ ساعة واحدة", "other": "منذ {count} ساعة", "many": "منذ {count} ساعة" }, "showDays": { "one": "منذ يوم واحد", "other": "منذ {count} يوم", "many": "منذ {count} يوم" }, "showMonths": { "one": "منذ شهر واحد", "other": "منذ {count} شهر", "many": "منذ {count} شهر" }, "showYears": { "one": "منذ سنة واحدة", "other": "منذ {count} سنة", "many": "منذ {count} سنة" }, "reply": "رد", "deleteComment": "حذف التعليق", "youAreNotOwner": "أنت لست صاحب هذا التعليق", "confirmDeleteDescription": "هل أنت متأكد أنك تريد حذف هذا التعليق؟", "hasBeenDeleted": "تم الحذف", "replyingTo": "الرد على", "noAccessDeleteComment": "لا يجوز لك حذف هذا التعليق", "collapse": "طي", "readMore": "اقرأ المزيد", "failedToAddComment": "فشل في إضافة التعليق", "commentAddedSuccessfully": "تمت إضافة التعليق بنجاح.", "commentAddedSuccessTip": "لقد قمت للتو بإضافة تعليق أو الرد عليه. هل ترغب في الانتقال إلى الأعلى لمشاهدة أحدث التعليقات؟" }, "template": { "asTemplate": "حفظ كقالب", "name": "اسم القالب", "description": "وصف القالب", "about": "قالب حول", "deleteFromTemplate": "حذف من القوالب", "preview": "معاينة القالب", "categories": "فئات القوالب", "isNewTemplate": "PIN إلى قالب جديد", "featured": "PIN إلى المميز", "relatedTemplates": "القوالب ذات الصلة", "requiredField": "{field} مطلوب", "addCategory": "أضف \"{category}\"", "addNewCategory": "إضافة فئة جديدة", "addNewCreator": "إضافة مبدع جديد", "deleteCategory": "حذف الفئة", "editCategory": "تعديل الفئة", "editCreator": "مبدع التعديل", "category": { "name": "اسم الفئة", "icon": "أيقونة الفئة", "bgColor": "لون خلفية الفئة", "priority": "أولوية الفئة", "desc": "وصف الفئة", "type": "نوع الفئة", "icons": "أيقونات الفئة", "colors": "فئة الألوان", "byUseCase": "حسب حالة الاستخدام", "byFeature": "حسب الميزة", "deleteCategory": "حذف الفئة", "deleteCategoryDescription": "هل أنت متأكد أنك تريد حذف هذه الفئة؟", "typeToSearch": "اكتب للبحث عن الفئات..." }, "creator": { "label": "مبدع القالب", "name": "اسم المنشئ", "avatar": "الصورة الرمزية للمبدع", "accountLinks": "روابط حسابات المبدع", "uploadAvatar": "انقر هنا لتحميل الصورة الرمزية", "deleteCreator": "حذف المبدع", "deleteCreatorDescription": "هل أنت متأكد أنك تريد حذف هذا المبدع؟", "typeToSearch": "اكتب للبحث عن المبدعين..." }, "uploadSuccess": "تم تحميل القالب بنجاح", "uploadSuccessDescription": "لقد تم رفع القالب الخاص بك بنجاح. يمكنك الآن عرضه في معرض القوالب.", "viewTemplate": "عرض القالب", "deleteTemplate": "حذف القالب", "deleteSuccess": "تم حذف القالب بنجاح", "deleteTemplateDescription": "لن يؤثر هذا على الصفحة الحالية أو حالة النشر. هل أنت متأكد من أنك تريد حذف هذا القالب؟", "addRelatedTemplate": "إضافة قالب ذو صلة", "removeRelatedTemplate": "إزالة القالب ذو الصلة", "uploadAvatar": "رفع الصورة الرمزية", "searchInCategory": "البحث في {category}", "label": "القوالب" }, "fileDropzone": { "dropFile": "انقر أو اسحب الملف إلى هذه المنطقة لتحميله", "uploading": "جاري الرفع...", "uploadFailed": "فشل الرفع", "uploadSuccess": "تم الرفع بنجاح", "uploadSuccessDescription": "تم رفع الملف بنجاح", "uploadFailedDescription": "فشل رفع الملف", "uploadingDescription": "جاري رفع الملف" }, "gallery": { "preview": "افتح في شاشة كاملة", "copy": "نسخ", "download": "تنزيل", "prev": "السابق", "next": "التالي", "resetZoom": "إعادة ضبط التكبير", "zoomIn": "التكبير ", "zoomOut": "التصغير" }, "invitation": { "join": "الانضمام", "on": "على", "invitedBy": "بدعوة من", "membersCount": { "zero": "{count} عضوا", "one": "{count} عضو", "many": "{count} عضوا", "other": "{count} عضوا" }, "tip": "لقد تمت دعوتك للانضمام إلى مساحة العمل هذه باستخدام معلومات الاتصال أدناه. إذا كانت هذه المعلومات غير صحيحة، فاتصل بالمسؤول لإعادة إرسال الدعوة.", "joinWorkspace": "انضم إلى مساحة العمل", "success": "لقد انضممت بنجاح إلى مساحة العمل", "successMessage": "يمكنك الآن الوصول إلى كافة الصفحات ومساحات العمل الموجودة بداخله.", "openWorkspace": "افتح AppFlowy", "alreadyAccepted": "لقد قبلت الدعوة بالفعل", "errorModal": { "title": "لقد حدث خطأ ما", "description": "قد لا يكون لحسابك الحالي {email} حق الوصول إلى مساحة العمل هذه. يرجى تسجيل الدخول بالحساب الصحيح أو الاتصال بمالك مساحة العمل للحصول على المساعدة.", "contactOwner": "اتصل بالمالك", "close": "العودة إلى الرئيسية", "changeAccount": "تغيير الحساب" } }, "requestAccess": { "title": "لا يمكن الوصول إلى هذه الصفحة", "subtitle": "يمكنك طلب الوصول من مالك هذه الصفحة. بمجرد الموافقة، يمكنك عرض الصفحة.", "requestAccess": "طلب الوصول", "backToHome": "العودة إلى الرئيسية", "tip": "لقد قمت بتسجيل الدخول حاليًا باسم .", "mightBe": "قد تحتاج إلى مع حساب مختلف.", "successful": "تم إرسال الطلب بنجاح", "successfulMessage": "سيتم إعلامك بمجرد موافقة المالك على طلبك.", "requestError": "فشل في طلب الوصول", "repeatRequestError": "لقد طلبت بالفعل الوصول إلى هذه الصفحة" }, "approveAccess": { "title": "الموافقة على طلب الانضمام إلى مساحة العمل", "requestSummary": "طلبات الانضمام والوصول", "upgrade": "ترقية", "downloadApp": "تنزيل AppFlowy", "approveButton": "موافقة", "approveSuccess": "تمت الموافقة بنجاح", "approveError": "فشل في الموافقة، تأكد من عدم تجاوز حد خطة مساحة العمل", "getRequestInfoError": "فشل في الحصول على معلومات الطلب", "memberCount": { "zero": "لا يوجد أعضاء", "one": "عضو واحد", "many": "{count} عضوا", "other": "{count} عضوا" }, "alreadyProTitle": "لقد وصلت إلى الحد الأقصى لخطة مساحة العمل", "alreadyProMessage": "اطلب منهم الاتصال لإلغاء تأمين المزيد من الأعضاء", "repeatApproveError": "لقد وافقت بالفعل على هذا الطلب", "ensurePlanLimit": "تأكد من عدم تجاوز حد خطة مساحة العمل. إذا تم تجاوز الحد، ففكر في خطة مساحة العمل أو .", "requestToJoin": "طلب الانضمام", "asMember": "كعضو" }, "upgradePlanModal": { "title": "الترقية إلى الإصدار الاحترافي", "message": "وصل {name} إلى الحد الأقصى للعضوية المجانية. قم بالترقية إلى الخطة الاحترافية لدعوة المزيد من الأعضاء.", "upgradeSteps": "كيفية ترقية خطتك على AppFlowy:", "step1": "1. انتقل إلى الإعدادات", "step2": "2. انقر فوق \"الخطة\"", "step3": "3. حدد \"تغيير الخطة\"", "appNote": "ملحوظة: ", "actionButton": "ترقية", "downloadLink": "تنزيل التطبيق", "laterButton": "لاحقاً", "refreshNote": "بعد الترقية الناجحة، انقر فوق لتفعيل ميزاتك الجديدة.", "refresh": "هنا" }, "breadcrumbs": { "label": "مسارات التنقل" }, "time": { "justNow": "الآن", "seconds": { "one": "ثانية واحدة", "other": "{count} من الثواني" }, "minutes": { "one": "دقيقة واحدة", "other": "{count} من الدقائق" }, "hours": { "one": "ساعة واحدة", "other": "{count} من الساعات" }, "days": { "one": "يوم واحد", "other": "{count} من الأيام" }, "weeks": { "one": "اسبوع واحد", "other": "{count} من الأسابيع" }, "months": { "one": "شهر واحد", "other": "{count} من الاشهر" }, "years": { "one": "سنة واحدة", "other": "{count} من السنوات" }, "ago": "منذ", "yesterday": "أمس", "today": "اليوم" }, "members": { "zero": "لا يوجد أعضاء", "one": "عضو واحد", "many": "{count} من الأعضاء", "other": "{count} من الأعضاء" }, "tabMenu": { "close": "إغلاق", "closeDisabledHint": "لا يمكن إغلاق علامة تبويب مثبتة، يرجى إلغاء التثبيت أولاً", "closeOthers": "إغلاق علامات التبويب الأخرى", "closeOthersHint": "سيؤدي هذا إلى إغلاق جميع علامات التبويب غير المثبتة باستثناء هذه العلامة", "closeOthersDisabledHint": "تم تثبيت جميع علامات التبويب، ولا يمكن العثور على أي علامات تبويب لإغلاقها", "favorite": "مفضل", "unfavorite": "غير مفضل", "favoriteDisabledHint": "لا يمكن إضافة هذا العرض إلى المفضلة", "pinTab": "تثبيت", "unpinTab": "إزالة التثبيت" }, "openFileMessage": { "success": "تم فتح الملف بنجاح", "fileNotFound": "لم يتم العثور على الملف", "noAppToOpenFile": "لا يوجد تطبيق لفتح هذا الملف", "permissionDenied": "لا يوجد إذن لفتح هذا الملف", "unknownError": "فشل فتح الملف" }, "inviteMember": { "requestInviteMembers": "دعوة إلى مكان مساحة عملك", "inviteFailedMemberLimit": "لقد تم الوصول إلى الحد الأقصى للأعضاء، يرجى ", "upgrade": "ترقية", "addEmail": "email@example.com، email2@example.com...", "requestInvites": "إرسال الدعوات", "inviteAlready": "لقد قمت بالفعل بدعوة هذا البريد الإلكتروني: {email}", "inviteSuccess": "تم إرسال الدعوة بنجاح", "description": "أدخل رسائل البريد الإلكتروني أدناه مع وضع فاصلة بينها. تعتمد الرسوم على عدد الأعضاء.", "emails": "بريد إلكتروني" }, "quickNote": { "label": "ملاحظة سريعة", "quickNotes": "ملاحظات سريعة", "search": "بحث ملاحظات سريعة", "collapseFullView": "طي العرض الكامل", "expandFullView": "توسيع العرض الكامل", "createFailed": "فشل في إنشاء ملاحظة سريعة", "quickNotesEmpty": "لا توجد ملاحظات سريعة", "emptyNote": "ملاحظة فارغة", "deleteNotePrompt": "سيتم حذف الملاحظة المحددة بشكل دائم. هل أنت متأكد من أنك تريد حذفها؟", "addNote": "ملاحظة جديدة", "noAdditionalText": "لا يوجد نص إضافي" }, "subscribe": { "upgradePlanTitle": "قارن واختر الخطة", "yearly": "سنوي", "save": "وفر {discount}%", "monthly": "شهريا", "priceIn": "السعر في ", "free": "مجاني", "pro": "احترافي", "freeDescription": "للأفراد حتى عضوين لتنظيم كل شيء", "proDescription": "للفرق الصغيرة لإدارة المشاريع ومعرفة الفريق", "proDuration": { "monthly": "لكل عضو شهريا\nيتم دفع الفاتورة شهريا", "yearly": "لكل عضو شهريا\nيتم دفعها سنويا" }, "cancel": "يرجع إلى إصدار أقدم", "changePlan": "الترقية إلى الخطة الاحترافية", "everythingInFree": "كل شيء مجاني +", "currentPlan": "الحالي", "freeDuration": "للأبد", "freePoints": { "first": "مساحة عمل تعاونية واحدة تتسع لعضوين كحد أقصى", "second": "صفحات وكتل غير محدودة", "three": "سعة تخزين 5 جيجا بايت", "four": "البحث الذكي", "five": "20 استجابة الذكاء الاصطناعي", "six": "تطبيق الجوال", "seven": "التعاون في الوقت الحقيقي" }, "proPoints": { "first": "تخزين غير محدود", "second": "ما يصل إلى 10 أعضاء في مساحة العمل", "three": "استجابات الذكاء الاصطناعي غير المحدودة", "four": "رفع ملفات غير محدودة", "five": "مساحة اسم مخصصة" }, "cancelPlan": { "title": "نأسف على مغادرتك", "success": "لقد تم إلغاء اشتراكك بنجاح", "description": "يؤسفنا رحيلك. يسعدنا سماع تعليقاتك لمساعدتنا على تحسين AppFlowy. يُرجى تخصيص بعض الوقت للإجابة على بعض الأسئلة.", "commonOther": "آخر", "otherHint": "اكتب إجابتك هنا", "questionOne": { "question": "ما الذي دفعك إلى إلغاء اشتراكك في AppFlowy Pro؟", "answerOne": "التكلفة مرتفعة للغاية", "answerTwo": "الميزات لم ترق إلى مستوى التوقعات", "answerThree": "وجدت بديلا أفضل", "answerFour": "لم أستخدمه بشكل كافي لتبرير التكلفة", "answerFive": "مشكلة في الخدمة أو صعوبات فنية" }, "questionTwo": { "question": "ما مدى احتمالية تفكيرك في إعادة الاشتراك في AppFlowy Pro في المستقبل؟", "answerOne": "من المرجح جدًا", "answerTwo": "من المحتمل إلى حد ما", "answerThree": "غير متأكد", "answerFour": "من غير المحتمل", "answerFive": "من غير المحتمل جدًا" }, "questionThree": { "question": "ما هي الميزة الاحترافية التي تقدرها أكثر أثناء اشتراكك؟", "answerOne": "التعاون بين المستخدمين المتعددين", "answerTwo": "سجل تاريخ الإصدارات لفترة أطول", "answerThree": "استجابات الذكاء الاصطناعي غير المحدودة", "answerFour": "الوصول إلى نماذج الذكاء الاصطناعي المحلية" }, "questionFour": { "question": "كيف تصف تجربتك الشاملة مع AppFlowy؟", "answerOne": "عظيمة", "answerTwo": "جيدة", "answerThree": "متوسطة", "answerFour": "أقل من المتوسط", "answerFive": "غير راضٍ" } } }, "ai": { "contentPolicyViolation": "فشل إنشاء الصورة بسبب محتوى حساس. يرجى إعادة صياغة إدخالك والمحاولة مرة أخرى", "textLimitReachedDescription": "لقد نفدت الاستجابات المجانية للذكاء الاصطناعي من مساحة عملك. قم بالترقية إلى الخطة الاحترافية أو قم بشراء إضافة للذكاء الاصطناعي لفتح عدد غير محدود من الاستجابات", "imageLimitReachedDescription": "لقد استنفدت حصتك المجانية من صور الذكاء الاصطناعي. يُرجى الترقية إلى الخطة الاحترافية أو شراء إضافة الذكاء الاصطناعي لفتح عدد غير محدود من الاستجابات", "limitReachedAction": { "textDescription": "لقد نفدت الاستجابات المجانية للذكاء الاصطناعي من مساحة عملك. للحصول على المزيد من الاستجابات، يرجى", "imageDescription": "لقد استنفدت حصتك المجانية من صور الذكاء الاصطناعي. يرجى", "upgrade": "ترقية", "toThe": "الى", "proPlan": "الخطة الاحترافية", "orPurchaseAn": "أو شراء", "aiAddon": "مَرافِق الذكاء الاصطناعي" }, "editing": "تحرير", "analyzing": "تحليل", "continueWritingEmptyDocumentTitle": "استمر في كتابة الخطأ", "continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!", "more": "أكثر", "customPrompt": { "browsePrompts": "تصفح المطالبات", "usePrompt": "استخدم المطالبة", "featured": "مميز", "example": "مثال المطالبة", "all": "الجميع", "development": "التطوير", "writing": "الكتابة", "healthAndFitness": "الصحة واللياقة البدنية", "business": "العمل", "marketing": "التسويق", "travel": "السفر", "others": "آخر", "prompt": "المطالبة", "sampleOutput": "عينة الإخراج", "contentSeo": "المحتوى/ت.م.ب", "emailMarketing": "التسويق عبر البريد الإلكتروني", "paidAds": "الإعلانات المدفوعة", "prCommunication": "العلاقات العامة/الاتصالات", "recruiting": "التوظيف", "sales": "مبيعات", "socialMedia": "وسائل التواصل الاجتماعي", "strategy": "الاستراتيجية", "caseStudies": "دراسات الحالة", "salesCopy": "النص البيعي", "education": "التعليم", "work": "العمل", "podcastProduction": "إنتاج البودكاست", "copyWriting": "كتابة التسويق", "customerSuccess": "نجاح العملاء" } }, "autoUpdate": { "criticalUpdateTitle": "التحديث ضروري للمتابعة", "criticalUpdateDescription": "لقد أجرينا تحسينات لتحسين تجربتك! يُرجى التحديث من {currentVersion} إلى {newVersion} لمواصلة استخدام التطبيق.", "criticalUpdateButton": "تحديث", "bannerUpdateTitle": "النسخة الجديدة متاحة!", "bannerUpdateDescription": "احصل على أحدث الميزات والإصلاحات. انقر على \"تحديث\" للتثبيت الآن.", "bannerUpdateButton": "تحديث", "settingsUpdateTitle": "الإصدار الجديد ({newVersion}) متاح!", "settingsUpdateDescription": "الإصدار الحالي: {currentVersion} (الإصدار الرسمي) → {newVersion}", "settingsUpdateButton": "تحديث", "settingsUpdateWhatsNew": "ما الجديد" }, "lockPage": { "lockPage": "مقفل", "reLockPage": "إعادة القفل", "lockTooltip": "تم قفل الصفحة لمنع التعديل غير المقصود. انقر لفتح القفل.", "pageLockedToast": "الصفحة مقفلة. التعديل معطل حتى يفتحها أحد.", "lockedOperationTooltip": "تم قفل الصفحة لمنع التعديل غير المقصود." }, "suggestion": { "accept": "يقبل", "keep": "يحفظ", "discard": "تجاهل", "close": "يغلق", "tryAgain": "حاول ثانية", "rewrite": "إعادة كتابة", "insertBelow": "أدخل أدناه" } } ================================================ FILE: frontend/resources/translations/ca-ES.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Jo", "welcomeText": "Benvingut a @:appName", "welcomeTo": "Benvingut a", "githubStarText": "Preferit a Github", "subscribeNewsletterText": "Subscriu-me al butlletí", "letsGoButtonText": "Endavant", "title": "Títol", "youCanAlso": "Tu pots també", "and": "i", "blockActions": { "addBelowTooltip": "Feu clic per afegir a continuació", "addAboveCmd": "Alt+clic", "addAboveMacCmd": "Opció+clic", "addAboveTooltip": "afegir a dalt", "dragTooltip": "Arrossegueu per moure's", "openMenuTooltip": "Feu clic per obrir el menú" }, "signUp": { "buttonText": "Registra't", "title": "Registra't a @:appName", "getStartedText": "Comencem", "emptyPasswordError": "La contrasenya no pot ser buida", "repeatPasswordEmptyError": "La contrasenya repetida no pot ser buida", "unmatchedPasswordError": "Les contrasenyes no concorden", "alreadyHaveAnAccount": "Ja tens un compte?", "emailHint": "Correu electrònic", "passwordHint": "Contrasenya", "repeatPasswordHint": "Repeteix la contrasenya", "signUpWith": "Registra't amb:" }, "signIn": { "loginTitle": "Inicia sessió a @:appName", "loginButtonText": "Inicia sessió", "loginStartWithAnonymous": "Comenceu amb una sessió anònima", "continueAnonymousUser": "Continueu amb una sessió anònima", "buttonText": "Inicia sessió", "signingInText": "S'està iniciant la sessió...", "forgotPassword": "Has oblidat la contrasenya?", "emailHint": "Correu electrònic", "passwordHint": "Contrasenya", "dontHaveAnAccount": "No tens un compte?", "repeatPasswordEmptyError": "La contrasenya repetida no pot ser buida", "unmatchedPasswordError": "Les contrasenyes no concorden", "or": "O", "signInWith": "Inicia sessió amb:", "LogInWithGoogle": "Inicieu sessió amb Google", "LogInWithGithub": "Inicieu sessió amb Github", "LogInWithDiscord": "Inicieu sessió amb Discord", "loginAsGuestButtonText": "Començar" }, "workspace": { "chooseWorkspace": "Tria el teu espai de treball", "create": "Crear un espai de treball", "reset": "Restableix l'espai de treball", "hint": "espai de treball", "notFoundError": "No s'ha trobat l'espai de treball", "errorActions": { "reportIssue": "Informar d'un problema", "reachOut": "Posa't en contacte amb Discord" } }, "shareAction": { "buttonText": "Compartir", "workInProgress": "Pròximament", "markdown": "Markdown", "csv": "CSV", "copyLink": "Copiar l'enllaç" }, "moreAction": { "small": "petit", "medium": "mitjà", "large": "gran", "fontSize": "Mida de la font", "import": "Importar", "moreOptions": "Més opcions" }, "importPanel": { "textAndMarkdown": "Text i rebaixa", "documentFromV010": "Document de la v0.1.0", "databaseFromV010": "Base de dades de la v0.1.0", "csv": "CSV", "database": "Base de dades" }, "disclosureAction": { "rename": "Canviar el nom", "delete": "Eliminar", "duplicate": "Duplicar", "unfavorite": "Elimina dels preferits", "favorite": "Afegir a preferits", "openNewTab": "Obre en una pestanya nova", "moveTo": "Moure's cap a", "addToFavorites": "Afegir a preferits", "copyLink": "Copia l'enllaç" }, "blankPageTitle": "Pàgina en blanc", "newPageText": "Nova pàgina", "newDocumentText": "Nou document", "newCalendarText": "Nou calendari", "newBoardText": "Nou tauler", "trash": { "text": "Paperera", "restoreAll": "Recuperar-ho tot", "deleteAll": "Eliminar-ho tot", "pageHeader": { "fileName": "Nom del fitxer", "lastModified": "Última modificació", "created": "Creat" }, "confirmDeleteAll": { "title": "Esteu segur que suprimiu totes les pàgines de la paperera?", "caption": "Aquesta acció no es pot desfer." }, "confirmRestoreAll": { "title": "Esteu segur de restaurar totes les pàgines a la paperera?", "caption": "Aquesta acció no es pot desfer." }, "mobile": { "actions": "Accions de paperera", "empty": "La paperera està buida", "emptyDescription": "No tens cap fitxer suprimit", "isDeleted": "está eliminat", "isRestored": "es restaura" } }, "deletePagePrompt": { "text": "Aquest pàgina es troba a la paperera", "restore": "Recuperar-la", "deletePermanent": "Elimina-la" }, "dialogCreatePageNameHint": "Nom de la pàgina", "questionBubble": { "shortcuts": "Dreceres", "whatsNew": "Què hi ha de nou?", "markdown": "Reducció", "debug": { "name": "Informació de depuració", "success": "S'ha copiat la informació de depuració!", "fail": "No es pot copiar la informació de depuració" }, "feedback": "Feedback", "help": "Ajuda i Suport" }, "menuAppHeader": { "moreButtonToolTip": "Suprimeix, canvia el nom i més...", "addPageTooltip": "Afegeix ràpidament una pàgina dins", "defaultNewPageName": "Sense títol", "renameDialog": "Canviar el nom" }, "noPagesInside": "No hi ha pàgines dins", "toolbar": { "undo": "Desfer", "redo": "Refer", "bold": "Negreta", "italic": "Cursiva", "underline": "Text subratllar", "strike": "Ratllat", "numList": "Llista numerada", "bulletList": "Llista de punts", "checkList": "Llista de comprovació", "inlineCode": "Inserir codi", "quote": "Bloc citat", "header": "Capçalera", "highlight": "Subratllar", "color": "Color", "addLink": "Afegeix un enllaç", "link": "Enllaç" }, "tooltip": { "lightMode": "Canviar a mode clar", "darkMode": "Canviar a mode fosc", "openAsPage": "Obre com a pàgina", "addNewRow": "Afegeix una fila nova", "openMenu": "Feu clic per obrir el menú", "dragRow": "Premeu llargament per reordenar la fila", "viewDataBase": "Veure base de dades", "referencePage": "Es fa referència a aquest {nom}", "addBlockBelow": "Afegeix un bloc a continuació" }, "sideBar": { "closeSidebar": "Close sidebar", "openSidebar": "Open sidebar", "personal": "Personal", "favorites": "Preferits", "clickToHidePersonal": "Feu clic per amagar la secció personal", "clickToHideFavorites": "Feu clic per amagar la secció preferida", "addAPage": "Afegeix una pàgina", "recent": "Recent" }, "notifications": { "export": { "markdown": "Nota exportada a Markdown", "path": "Documents/fluides" } }, "contactsPage": { "title": "Contactes", "whatsHappening": "Que passa aquesta setmana?", "addContact": "Afegir un contacte", "editContact": "Editar un contacte" }, "button": { "ok": "D'acord", "done": "Fet", "cancel": "Cancel · lar", "signIn": "Iniciar sessió", "signOut": "Tancar sessió", "complete": "Completar", "save": "Guardar", "generate": "Generar", "esc": "ESC", "keep": "Mantenir", "tryAgain": "Torna-ho a provar", "discard": "Descartar", "replace": "Substitueix", "insertBelow": "Insereix a continuació", "insertAbove": "Insereix a dalt", "upload": "Carrega", "edit": "Edita", "delete": "Suprimeix", "duplicate": "Duplicat", "putback": "Posar enrere", "update": "Actualització", "share": "Compartir", "removeFromFavorites": "Elimina dels preferits", "addToFavorites": "Afegir a preferits", "rename": "Renombrar", "helpCenter": "Centre d'ajuda", "add": "Afegir", "yes": "Sí" }, "label": { "welcome": "Benvingut!", "firstName": "Nom", "middleName": "Segon Nom", "lastName": "Cognom", "stepX": "Pas {X}" }, "oAuth": { "err": { "failedTitle": "No s'ha pogut connectar al teu compte.", "failedMsg": "Assegureu-vos que heu completat el procés d'inici de sessió al vostre navegador." }, "google": { "title": "Iniciar sessió amb Google", "instruction1": "Per importar els vostres contactes de Google, haureu d'autoritzar aquesta aplicació mitjançant el vostre navegador web.", "instruction2": "Copia aquest codi clicant la icona o seleccionant el text:", "instruction3": "Navega al següent enllaç amb el teu navegador i insereix el codi anterior:", "instruction4": "Pressiona el botó d'avall una vegada hagis completat el registre:" } }, "settings": { "title": "Configuració", "menu": { "appearance": "Aparença", "language": "Idioma", "user": "Usuari", "files": "Fitxers", "notifications": "Notificacions", "open": "Obrir la configuració", "logout": "Tancar sessió", "logoutPrompt": "Esteu segur de tancar la sessió?", "selfEncryptionLogoutPrompt": "Esteu segur que voleu tancar la sessió? Assegureu-vos d'haver copiat el secret de xifratge", "syncSetting": "Configuració de sincronització", "cloudSettings": "Configuració del núvol", "enableSync": "Activa la sincronització", "enableEncrypt": "Xifrar dades", "cloudURL": "URL base", "invalidCloudURLScheme": "Esquema no vàlid", "cloudServerType": "Servidor al núvol", "cloudLocal": "Local", "cloudAppFlowy": "@:appName Cloud Beta", "clickToCopy": "Feu clic per copiar", "selfHostContent": "document", "cloudURLHint": "Introduïu l'URL base del vostre servidor", "restartApp": "Reinicia", "inputEncryptPrompt": "Introduïu el vostre secret de xifratge per a", "clickToCopySecret": "Feu clic per copiar el secret", "inputTextFieldHint": "El teu secret", "importSuccess": "S'ha importat correctament la carpeta de dades d'@:appName" }, "notifications": { "enableNotifications": { "label": "Activa les notificacions" } }, "appearance": { "resetSetting": "Restableix", "fontFamily": { "label": "Família de lletres", "search": "Cerca" }, "themeMode": { "label": "Theme Mode", "light": "Mode Clar", "dark": "Mode Fosc", "system": "Adapt to System" }, "documentSettings": { "cursorColor": "Color del cursor del document", "selectionColor": "Color de selecció del document", "hexEmptyError": "El color hexadecimal no pot estar buit", "hexLengthError": "El valor hexadecimal ha de tenir 6 dígits", "hexInvalidError": "Valor hexadecimal no vàlid", "opacityEmptyError": "L'opacitat no pot estar buida", "opacityRangeError": "L'opacitat ha d'estar entre 1 i 100", "app": "App", "flowy": "Flowy", "apply": "Aplicar" }, "layoutDirection": { "label": "Direcció de maquetació" }, "textDirection": { "auto": "AUTO" }, "themeUpload": { "button": "Carrega", "uploadTheme": "Carrega el tema", "description": "Carregueu el vostre propi tema @:appName amb el botó següent.", "loading": "Si us plau, espereu mentre validem i carreguem el vostre tema...", "uploadSuccess": "El teu tema s'ha penjat correctament", "deletionFailure": "No s'ha pogut suprimir el tema. Intenta esborrar-lo manualment.", "filePickerDialogTitle": "Trieu un fitxer .flowy_plugin", "urlUploadFailure": "No s'ha pogut obrir l'URL: {}", "failure": "El tema que s'ha penjat tenia un format no vàlid." }, "theme": "Tema", "builtInsLabel": "Temes integrats", "pluginsLabel": "Connectors", "dateFormat": { "label": "Format de data", "local": "Local", "iso": "ISO", "dmy": "D/M/A" }, "timeFormat": { "label": "Format horari", "twelveHour": "Dotze hores", "twentyFourHour": "Vint-i-quatre hores" } }, "files": { "copy": "Còpia", "defaultLocation": "Llegir fitxers i ubicació d'emmagatzematge de dades", "exportData": "Exporteu les vostres dades", "doubleTapToCopy": "Fes doble toc per copiar el camí", "restoreLocation": "Restaura al camí predeterminat d'@:appName", "customizeLocation": "Obriu una altra carpeta", "restartApp": "Si us plau, reinicieu l'aplicació perquè els canvis tinguin efecte.", "exportDatabase": "Exportar la base de dades", "selectFiles": "Seleccioneu els fitxers que cal exportar", "selectAll": "Seleccionar tot", "deselectAll": "Deseleccionar tot", "createNewFolder": "Creeu una carpeta nova", "createNewFolderDesc": "Digueu-nos on voleu emmagatzemar les vostres dades", "defineWhereYourDataIsStored": "Definiu on s'emmagatzemen les vostres dades", "open": "Obert", "openFolder": "Obre una carpeta existent", "openFolderDesc": "Llegiu-lo i escriviu-lo a la vostra carpeta @:appName existent", "folderHintText": "nom de la carpeta", "location": "Creació d'una carpeta nova", "locationDesc": "Trieu un nom per a la vostra carpeta de dades d'@:appName", "browser": "Navega", "create": "Crear", "set": "Conjunt", "folderPath": "Camí per emmagatzemar la vostra carpeta", "locationCannotBeEmpty": "El camí no pot estar buit", "pathCopiedSnackbar": "S'ha copiat el camí d'emmagatzematge del fitxer al porta-retalls!", "changeLocationTooltips": "Canvia el directori de dades", "change": "Canviar", "openLocationTooltips": "Obriu un altre directori de dades", "openCurrentDataFolder": "Obre el directori de dades actual", "recoverLocationTooltips": "Restableix al directori de dades predeterminat d'@:appName", "exportFileSuccess": "Exporta el fitxer correctament!", "exportFileFail": "Ha fallat l'exportació del fitxer!", "export": "Exporta" }, "user": { "name": "Nom", "email": "Correu electrònic", "tooltipSelectIcon": "Seleccioneu la icona", "selectAnIcon": "Seleccioneu una icona", "pleaseInputYourOpenAIKey": "si us plau, introduïu la vostra clau AI" }, "mobile": { "personalInfo": "Informació personal", "username": "Nom d'usuari", "about": "Sobre", "pushNotifications": "Notificacions push", "support": "Suport", "joinDiscord": "Uneix-te a nosaltres a Discord", "privacyPolicy": "Política de privacitat", "userAgreement": "Acord d'usuari", "termsAndConditions": "Termes i condicions", "userprofileError": "No s'ha pogut carregar el perfil d'usuari", "selectStartingDay": "Seleccioneu el dia d'inici", "version": "Versió" }, "shortcuts": { "command": "Comandament", "addNewCommand": "Afegeix una comanda nova" } }, "grid": { "deleteView": "Esteu segur que voleu suprimir aquesta vista?", "createView": "Nou", "title": { "placeholder": "Sense títol" }, "settings": { "filter": "Filtre", "sort": "Ordena", "sortBy": "Ordenar per", "properties": "Propietats", "reorderPropertiesTooltip": "Arrossegueu per reordenar les propietats", "group": "Grup", "addFilter": "Afegeix un filtre", "deleteFilter": "Suprimeix el filtre", "filterBy": "Filtra per...", "typeAValue": "Escriu un valor...", "layout": "Disseny", "databaseLayout": "Disseny", "editView": "Edita la vista", "boardSettings": "Configuració del tauler", "calendarSettings": "Configuració del calendari", "createView": "Vista nova", "duplicateView": "Vista duplicada", "deleteView": "Suprimeix la vista", "viewList": "Visualitzacions de la base de dades" }, "textFilter": { "contains": "Conté", "doesNotContain": "No conté", "endsWith": "Acaba amb", "startWith": "Comença amb", "is": "És", "isNot": "No és", "isEmpty": "Està buit", "isNotEmpty": "No està buit", "choicechipPrefix": { "isNot": "No", "startWith": "Comença amb", "endWith": "Acaba amb", "isEmpty": "està buit", "isNotEmpty": "no està buida" } }, "checkboxFilter": { "isChecked": "Comprovat", "isUnchecked": "Sense marcar", "choicechipPrefix": { "is": "és" } }, "checklistFilter": { "isComplete": "està completa", "isIncomplted": "és incompleta" }, "selectOptionFilter": { "is": "És", "isNot": "No és", "contains": "Conté", "doesNotContain": "No conté", "isEmpty": "Està buit", "isNotEmpty": "No està buit" }, "dateFilter": { "before": "És abans", "after": "És després", "onOrBefore": "Està activat o abans", "onOrAfter": "Està activat o després", "between": "Està entre", "empty": "Està buit", "notEmpty": "No està buit" }, "field": { "hide": "Amaga", "show": "Espectacle", "insertLeft": "Insereix a l'esquerra", "insertRight": "Insereix a la dreta", "duplicate": "Duplicat", "delete": "Suprimeix", "textFieldName": "Text", "checkboxFieldName": "casella de selecció", "dateFieldName": "Data", "updatedAtFieldName": "Hora de l'última modificació", "createdAtFieldName": "Temps creat", "numberFieldName": "Nombres", "singleSelectFieldName": "Seleccioneu", "multiSelectFieldName": "Selecció múltiple", "urlFieldName": "URL", "checklistFieldName": "Llista de verificació", "numberFormat": "Format numèric", "dateFormat": "Format de data", "includeTime": "Inclou el temps", "isRange": "Data de finalització", "dateFormatFriendly": "Mes Dia, Any", "dateFormatISO": "Any-Mes-Dia", "dateFormatLocal": "Mes/Dia/Any", "dateFormatUS": "Any/Mes/Dia", "dateFormatDayMonthYear": "Dia/Mes/Any", "timeFormat": "Format horari", "invalidTimeFormat": "Format no vàlid", "timeFormatTwelveHour": "12 hores", "timeFormatTwentyFourHour": "24 hores", "clearDate": "Data clara", "dateTime": "Data i hora", "startDateTime": "Data d'inici hora", "endDateTime": "Data de finalització hora", "failedToLoadDate": "No s'ha pogut carregar el valor de la data", "selectTime": "Seleccioneu l'hora", "selectDate": "Seleccioneu la data", "visibility": "Visibilitat", "propertyType": "Tipus de propietat", "addSelectOption": "Afegeix una opció", "typeANewOption": "Escriviu una nova opció", "optionTitle": "Opcions", "addOption": "Afegeix opció", "editProperty": "Edita la propietat", "newProperty": "Nova propietat", "deleteFieldPromptMessage": "Estàs segur? Aquesta propietat se suprimirà", "newColumn": "Nova columna", "format": "Format", "reminderOnDateTooltip": "Aquesta cel·la té un recordatori programat" }, "rowPage": { "newField": "Afegeix un camp nou", "fieldDragElementTooltip": "Feu clic per obrir el menú" }, "sort": { "ascending": "Ascendent", "descending": "Descens", "addSort": "Afegeix ordenació", "deleteSort": "Suprimeix l'ordenació" }, "row": { "duplicate": "Duplicat", "delete": "Suprimeix", "titlePlaceholder": "Sense títol", "textPlaceholder": "Buit", "copyProperty": "S'ha copiat la propietat al porta-retalls", "count": "Compte", "newRow": "Nova fila", "action": "Acció", "drag": "Arrossegueu per moure's" }, "selectOption": { "create": "Crear", "purpleColor": "Porpra", "pinkColor": "Rosa", "lightPinkColor": "Rosa clar", "orangeColor": "taronja", "yellowColor": "Groc", "limeColor": "Lima", "greenColor": "Verd", "aquaColor": "Aqua", "blueColor": "Blau", "deleteTag": "Suprimeix l'etiqueta", "colorPanelTitle": "Colors", "panelTitle": "Seleccioneu una opció o creeu-ne una", "searchOption": "Cerca una opció" }, "checklist": { "addNew": "Afegeix un element", "submitNewTask": "Crear" }, "url": { "launch": "Oberta al navegador", "copy": "Copia l'URL" }, "menuName": "Quadrícula", "referencedGridPrefix": "Vista de" }, "document": { "menuName": "Document", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Seleccioneu un tauler per enllaçar", "createANewBoard": "Crea una nova Junta" }, "grid": { "selectAGridToLinkTo": "Seleccioneu una quadrícula per enllaçar", "createANewGrid": "Creeu una nova quadrícula" }, "calendar": { "selectACalendarToLinkTo": "Seleccioneu un calendari al qual voleu enllaçar", "createANewCalendar": "Crea un calendari nou" } }, "selectionMenu": { "outline": "Esquema", "codeBlock": "Bloc de codi" }, "plugins": { "referencedBoard": "Junta de referència", "referencedGrid": "Quadrícula de referència", "referencedCalendar": "Calendari de referència", "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Demana a AI que escrigui qualsevol cosa...", "autoGeneratorLearnMore": "Aprèn més", "autoGeneratorGenerate": "Generar", "autoGeneratorHintText": "Pregunta a AI...", "autoGeneratorCantGetOpenAIKey": "No es pot obtenir la clau AI", "autoGeneratorRewrite": "Reescriure", "smartEdit": "Assistents d'IA", "aI": "AI", "smartEditFixSpelling": "Corregir l'ortografia", "warning": "⚠️ Les respostes de la IA poden ser inexactes o enganyoses.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "Millorar l'escriptura", "smartEditMakeLonger": "Fer més llarg", "smartEditCouldNotFetchResult": "No s'ha pogut obtenir el resultat d'AI", "smartEditCouldNotFetchKey": "No s'ha pogut obtenir la clau AI", "smartEditDisabled": "Connecteu AI a Configuració", "discardResponse": "Voleu descartar les respostes d'IA?", "createInlineMathEquation": "Crea una equació", "fonts": "Fonts", "toggleList": "Commuta la llista", "bulletedList": "Llista amb pics", "todoList": "Llista de tasques", "cover": { "changeCover": "Canvi de coberta", "colors": "Colors", "images": "Imatges", "clearAll": "Esborra-ho tot", "abstract": "Resum", "addCover": "Afegeix coberta", "addLocalImage": "Afegeix una imatge local", "invalidImageUrl": "URL de la imatge no vàlid", "failedToAddImageToGallery": "No s'ha pogut afegir la imatge a la galeria", "enterImageUrl": "Introduïu l'URL de la imatge", "add": "Afegeix", "back": "esquena", "saveToGallery": "Desa a la galeria", "removeIcon": "Elimina la icona", "pasteImageUrl": "Enganxa l'URL de la imatge", "or": "O", "pickFromFiles": "Trieu entre fitxers", "couldNotFetchImage": "No s'ha pogut recuperar la imatge", "imageSavingFailed": "No s'ha pogut desar la imatge", "addIcon": "Afegeix una icona", "changeIcon": "Canvia la icona", "coverRemoveAlert": "S'eliminarà de la coberta després de suprimir-lo.", "alertDialogConfirmation": "N'estàs segur, vols continuar?" }, "mathEquation": { "name": "Equació matemàtica", "addMathEquation": "Afegeix una equació matemàtica", "editMathEquation": "Edita l'equació matemàtica" }, "optionAction": { "click": "Feu clic", "toOpenMenu": " per obrir el menú", "delete": "Suprimeix", "duplicate": "Duplicat", "turnInto": "Converteix-te en", "moveUp": "Mou-te", "moveDown": "Moure cap avall", "color": "Color", "align": "Alinear", "left": "Esquerra", "center": "Centre", "right": "Dret", "defaultColor": "Per defecte" }, "image": { "addAnImage": "Afegeix una imatge", "copiedToPasteBoard": "L'enllaç de la imatge s'ha copiat al porta-retalls" }, "outline": { "addHeadingToCreateOutline": "Afegiu títols per crear una taula de continguts." }, "table": { "addAfter": "Afegeix després", "addBefore": "Afegeix abans", "delete": "Suprimeix", "duplicate": "Duplicar", "bgColor": "Color de fon" }, "contextMenu": { "copy": "Còpia", "cut": "Talla" }, "action": "Accions", "database": { "selectDataSource": "Seleccioneu la font de dades", "noDataSource": "Sense font de dades", "toContinue": "per continuar", "newDatabase": "Nova base de dades", "linkToDatabase": "Enllaç a la base de dades" } }, "textBlock": { "placeholder": "Escriviu '/' per a les ordres" }, "title": { "placeholder": "Sense títol" }, "imageBlock": { "placeholder": "Feu clic per afegir imatge", "upload": { "label": "Carrega", "placeholder": "Feu clic per pujar la imatge" }, "url": { "label": "URL de la imatge", "placeholder": "Introduïu l'URL de la imatge" }, "ai": { "label": "Generar imatge des d'AI" }, "support": "El límit de mida de la imatge és de 5 MB. Formats admesos: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Imatge no vàlida", "invalidImageSize": "La mida de la imatge ha de ser inferior a 5 MB", "invalidImageFormat": "El format d'imatge no és compatible. Formats admesos: JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL de la imatge no vàlid" }, "embedLink": { "label": "Insereix l'enllaç", "placeholder": "Enganxeu o escriviu un enllaç d'imatge" }, "searchForAnImage": "Cerca una imatge", "saveImageToGallery": "Guardar imatge", "unableToLoadImage": "No es pot carregar la imatge" }, "codeBlock": { "language": { "label": "Llenguatge", "placeholder": "Escolliu l'idioma" } }, "inlineLink": { "placeholder": "Enganxeu o escriviu un enllaç", "openInNewTab": "Obre en una pestanya nova", "copyLink": "Copia l'enllaç", "removeLink": "Elimina l'enllaç", "url": { "label": "URL de l'enllaç", "placeholder": "Introduïu l'URL de l'enllaç" }, "title": { "label": "Títol de l'enllaç", "placeholder": "Introduïu el títol de l'enllaç" } }, "mention": { "page": { "label": "Enllaç a la pàgina" } } }, "board": { "column": { "createNewCard": "Nou" }, "menuName": "Pissarra", "referencedBoardPrefix": "Vista de", "mobile": { "showGroup": "Mostra grup", "showGroupContent": "Esteu segur que voleu mostrar aquest grup al tauler?", "failedToLoad": "No s'ha pogut carregar la vista del tauler" } }, "calendar": { "menuName": "Calendari", "defaultNewCalendarTitle": "Sense títol", "navigation": { "today": "Avui", "jumpToday": "Salta a Avui", "previousMonth": "Mes anterior", "nextMonth": "El mes que ve" }, "settings": { "showWeekNumbers": "Mostra els números de la setmana", "showWeekends": "Mostra els caps de setmana", "firstDayOfWeek": "Comença la setmana", "layoutDateField": "Disseny del calendari per", "noDateTitle": "Sense data", "clickToAdd": "Feu clic per afegir al calendari", "name": "Disseny del calendari", "noDateHint": "Els esdeveniments no programats es mostraran aquí" }, "referencedCalendarPrefix": "Vista de" }, "errorDialog": { "title": "Error d'@:appName", "howToFixFallback": "Lamentem les molèsties! Envieu un problema a la nostra pàgina de GitHub que descrigui el vostre error.", "github": "Veure a GitHub" }, "search": { "label": "Cerca", "placeholder": { "actions": "Cerca accions..." } }, "message": { "copy": { "success": "Copiat!", "fail": "No es pot copiar" } }, "unSupportBlock": "La versió actual no admet aquest bloc.", "views": { "deleteContentTitle": "Esteu segur que voleu suprimir {pageType}?", "deleteContentCaption": "si suprimiu aquest {pageType}, podeu restaurar-lo des de la paperera." } } ================================================ FILE: frontend/resources/translations/ckb-KU.json ================================================ { "appName": "AppFlowy", "defaultUsername": "من", "welcomeText": "@:appName بەخێربێن بۆ", "welcomeTo": "بەخێربێن بۆ", "githubStarText": "بە گیتهابەکەمان ئەستێرە بدەن", "subscribeNewsletterText": "سەبسکرایبی هەواڵنامە بکە", "letsGoButtonText": "دەست پێ بکە", "title": "سه‌ردێڕ", "youCanAlso": "هەروەها ئەتوانی", "and": "وە", "failedToOpenUrl": "شکستی هێنا لە کردنەوەی url: {}", "blockActions": { "addBelowTooltip": "بۆ زیادکردن لە خوارەوە کلیک بکە", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "بۆ زیادکردنی سەرەوە", "dragTooltip": "ڕاکێشان بۆ جوڵە", "openMenuTooltip": "کلیک کردن بۆ کردنەوەی مینیوەکە" }, "signUp": { "buttonText": "ناو نووسین", "title": " ناو نووسین لە @:appName", "getStartedText": "دەست پێ بکە", "emptyPasswordError": "ناتوانرێت تێپه‌ڕه‌وشه بەتاڵ بێت", "repeatPasswordEmptyError": "تێپه‌ڕه‌وشەی دووبارەکراو ناتوانرێت بەتاڵ بێت", "unmatchedPasswordError": "دووبارەکراوەی تێپه‌ڕه‌وشه هەمان تێپه‌ڕه‌وشه نییە", "alreadyHaveAnAccount": "لە پێشتر هه‌ژمارت هەیە؟", "emailHint": "ئیمەیڵ", "passwordHint": "تێپه‌ڕه‌وشه", "repeatPasswordHint": "دووبارە کردنی تێپه‌ڕه‌وشه", "signUpWith": "ناونووسین بە:" }, "signIn": { "loginTitle": "چوونه‌ژووره‌وه‌ بە @:appName", "loginButtonText": "چوونه‌ژووره‌وه‌", "loginStartWithAnonymous": "بە دانیشتنێکی بێناو دەست پێ بکە", "continueAnonymousUser": "وەک بەکارهێنەری میوان بەردەوام بە", "buttonText": "چوونه‌ژووره‌وه‌", "signingInText": "چوونە ناوەوە...", "forgotPassword": "تێپه‌ڕه‌وشەت لەبیر كردووە ؟", "emailHint": "ئیمەیڵ", "passwordHint": "تێپه‌ڕه‌وشه", "dontHaveAnAccount": "هه‌ژمارت نییە؟", "repeatPasswordEmptyError": "ناتوانرێت تێپه‌ڕه‌وشه بەتاڵ بێت", "unmatchedPasswordError": "دووبارەکراوەی تێپه‌ڕه‌وشه هەمان تێپه‌ڕه‌وشه نییە", "syncPromptMessage": "ڕەنگە هاوکاتکردنی داتاکان ماوەیەکی پێبچێت. تکایە ئەم پەیجە دامەخە", "or": "یان", "signInWith": "ناونووسین وە:", "signInWithEmail": "لە ڕێگەی ئیمەیڵەوە بچۆرە ژوورەوە", "pleaseInputYourEmail": "تکایە ئیمەیڵەکەت بنووسە", "magicLinkSent": "مەجیک لینک بۆ ئیمەیڵەکەت نێردراوە، تکایە ئیمەیڵەکەت بپشکنە", "invalidEmail": "تکایە ئیمەیڵێکی دروست دابنێ", "LogInWithGoogle": "چوونە ژوورەوە لە ڕێگەی گووگڵەوە", "LogInWithGithub": "چوونە ژوورەوە لە ڕێگەی گیتهاب", "LogInWithDiscord": "چوونە ژوورەوە لە ڕێگەی دیسکۆرد", "loginAsGuestButtonText": "دەست پێ بکە", "logInWithMagicLink": "بە مەجیک لینک بچۆرە ژوورەوە" }, "workspace": { "chooseWorkspace": "هەڵبژاردنی شوێنی کارەکەت", "create": "دروستکردنی شوێنی کارکردن", "reset": "شوێنی کار ڕێست بکەرەوە", "resetWorkspacePrompt": "ڕێستکردنی شوێنی کارەکە هەموو لاپەڕە و داتاکانی ناوی دەسڕێتەوە. ئایا دڵنیای کە دەتەوێت شوێنی کارەکە ڕێست بکەیتەوە؟ یان دەتوانیت پەیوەندی بە تیمی پشتگیرییەوە بکەیت بۆ گەڕاندنەوەی شوێنی کارەکە", "hint": "شوێنی کارکردن", "notFoundError": "هیچ شوێنێکی کار نەدۆزراوە", "failedToLoad": "هەندێ شت بە هەڵە ڕۆیشت! شکستی هێنا لە بارکردنی شوێنی کارکردن. هەوڵبدە هەر نموونەیەکی کراوەی @:appName دابخەیت و دووبارە هەوڵبدەرەوە.", "errorActions": { "reportIssue": "ڕاپۆرت کردنی کێشەیەک", "reportIssueOnGithub": "ڕاپۆرت کردنی کێشەیەک لەسەر گیتهابەوە ", "exportLogFiles": "هەناردەکردنی فایلەکانی گوزارش", "reachOut": "پەیوەندی لەگەڵ دیسکۆرد" }, "menuTitle": "شوێنی کارکردن", "deleteWorkspaceHintText": "ئایا دڵنیای کە دەتەوێت شوێنی کارەکە بسڕیتەوە؟ ئەم کارە ناتوانرێت پووچەڵ بکرێتەوە.", "createSuccess": "شوێنی کارکردن بە سەرکەوتوویی دروستکرا", "createFailed": "شکستی هێنا لە دروستکردنی شوێنی کارکردن", "createLimitExceeded": "تۆ گەیشتووی بە زۆرترین سنووری شوێنی کارکردن کە ڕێگەپێدراوە بۆ ئەکاونتەکەت. ئەگەر پێویستت بە شوێنی کاری زیاترە بۆ بەردەوامبوون لە کارەکانت، تکایە لە Github داوای بکە", "deleteSuccess": "شوێنی کارکردن بە سەرکەوتوویی سڕاوەتەوە", "deleteFailed": "شکستی هێنا لە سڕینەوەی شوێنی کارکردن", "openSuccess": "بە سەرکەوتوویی شوێنی کارکردن بکەرەوە", "openFailed": "شکستی هێنا لە کردنەوەی شوێنی کارکردن", "renameSuccess": "ناوی شوێنی کار بە سەرکەوتوویی گۆڕدرا", "renameFailed": "شکستی هێنا لە گۆڕینی ناوی شوێنی کار", "updateIconSuccess": "ئایکۆنی شوێنی کار بە سەرکەوتوویی نوێکرایەوە", "updateIconFailed": "لە نوێکردنی ئایکۆنی شوێنی کار شکستی هێنا", "cannotDeleteTheOnlyWorkspace": "ناتوانرێت تاکە شوێنی کارکردن بسڕدرێتەوە", "fetchWorkspacesFailed": "شکستی هێنا لە هێنانی شوێنەکانی کار", "leaveCurrentWorkspace": "شوێنی کارکردن بەجێبهێڵە", "leaveCurrentWorkspacePrompt": "ئایا دڵنیای کە دەتەوێت شوێنی کارکردنی ئێستا بەجێبهێڵیت؟" }, "shareAction": { "buttonText": "هاوبەشکردن", "workInProgress": "بەم زووانە", "markdown": "Markdown", "html": "HTML", "clipboard": "کۆپی بکە بۆ کلیپبۆرد", "csv": "CSV", "copyLink": "کۆپی کردنی لینک" }, "moreAction": { "small": "بچووک", "medium": "ناوەند", "large": "گەورە", "fontSize": "قەبارەی قەڵەم", "import": "زیادکردن", "moreOptions": "بژاردەی زیاتر", "wordCount": "ژمارەی ووشە: {}", "charCount": "ژمارەی پیتەکان: {}", "createdAt": "دروستکراوە: {}", "deleteView": "سڕینەوە", "duplicateView": "دووبارە کردنەوە" }, "importPanel": { "textAndMarkdown": "Text & Markdown", "documentFromV010": "به‌ڵگه‌نامه لە وەشانی 0.1.0", "databaseFromV010": "داتابەیس لە وەشانی 0.1.0", "csv": "CSV", "database": "بنکەدراوە" }, "disclosureAction": { "rename": "گۆڕینی ناو", "delete": "سڕینەوە", "duplicate": "دووبارەکردنەوە", "unfavorite": "سڕینەوە لە دڵخوازەکان", "favorite": "خستنە لیستی هەڵبژاردەکان", "openNewTab": "لە تابێکی نوێدا بکرێتەوە", "moveTo": "گواستنەوە بۆ", "addToFavorites": "خستنە لیستی هەڵبژاردەکان", "copyLink": "کۆپی کردنی لینک" }, "blankPageTitle": "لاپەڕەی بەتاڵ", "newPageText": "لاپەڕەی نوێ", "newDocumentText": "بەڵگەنامەی نوێ", "newGridText": "تۆڕی نوێ", "newCalendarText": "ڕۆژژمێری نوێ", "newBoardText": "تەختەی نوێ", "trash": { "text": "زبڵدان", "restoreAll": "گەڕاندنەوەی هەموو", "deleteAll": "هەمووی بسڕەوە", "pageHeader": { "fileName": "ناوی پەڕگە", "lastModified": "دوایین پیاچوونەوە", "created": "دروستکراوە" }, "confirmDeleteAll": { "title": "دەتەوێت هەموو لاپەڕەکانی ناو زبڵدان بسڕیتەوە؟", "caption": "ئەم کارە پێچەوانە نابێتەوە." }, "confirmRestoreAll": { "title": "ئایا دەتەوێت هەموو لاپەڕەکانی ناو زبڵدانەکە بگەڕێنێتەوە؟", "caption": "ئەم کارە پێچەوانە نابێتەوە." }, "mobile": { "actions": "کردەکانی زبڵدان", "empty": "تەنەکەی زبڵدان بەتاڵە", "emptyDescription": "هیچ فایلێکی سڕاوەت نییە", "isDeleted": "دەسڕدرێتەوە", "isRestored": "دەگەڕێتەوە" }, "confirmDeleteTitle": "دڵنیای کە دەتەوێت ئەم لاپەڕەیە بۆ هەمیشە بسڕیتەوە؟" }, "deletePagePrompt": { "text": "ئەم لاپەڕەیە لە زبڵداندایە", "restore": "گەڕانەوەی لاپەڕە", "deletePermanent": "سڕینەوەی هەمیشەیی" }, "dialogCreatePageNameHint": "ناوی لاپەڕە", "questionBubble": { "shortcuts": "کورتە ڕێگاکان", "whatsNew": "نوێترین", "markdown": "Markdown", "debug": { "name": "زانیاری دیباگ", "success": "زانیارییەکانی دیباگ کۆپی کراون بۆ کلیپبۆرد!", "fail": "ناتوانرێت زانیارییەکانی دیباگ کۆپی بکات بۆ کلیپبۆرد" }, "feedback": "فیدباک", "help": "پشتیوانی و یارمەتی" }, "menuAppHeader": { "moreButtonToolTip": "سڕینەوە، گۆڕینی ناو، و زۆر شتی تر...", "addPageTooltip": "لاپەڕەیەک لە ناوەوە زیاد بکە", "defaultNewPageName": "بێ ناونیشان", "renameDialog": "گۆڕینی ناو" }, "noPagesInside": "لە ناوەوە هیچ لاپەڕەیەک نییە", "toolbar": { "undo": "پاشەکشە", "redo": "Redo", "bold": "تۆخ", "italic": "لار", "underline": "هێڵ بەژێرداهێنان", "strike": "لەگەڵ هێڵ لە ناوەڕاستدا", "numList": "لیستی ژمارەدار", "bulletList": "بووڵت لیست", "checkList": "لیستی پیاچوونه‌وه‌", "inlineCode": "کۆدی ناو هێڵ", "quote": "ده‌ق", "header": "سه‌رپه‌ڕه‌", "highlight": "بەرجەستەکردن", "color": "ڕەنگ", "addLink": "زیادکردنی لینک", "link": "بەستەر" }, "tooltip": { "lightMode": "دۆخی ڕووناک", "darkMode": "دۆخی تاریک", "openAsPage": "کردنەوە وەک لاپەڕە", "addNewRow": "زیادکردنی ڕیزێکی نوێ", "openMenu": "Menu کردنەوەی", "dragRow": "بۆ ڕێکخستنەوەی ڕیزەکە فشارێکی درێژ بکە", "viewDataBase": "بینینی بنکەدراوە", "referencePage": "ئەم {name} ڕەوانە کراوە", "addBlockBelow": "لە خوارەوە بلۆکێک زیاد بکە", "urlLaunchAccessory": "کردنەوە لە وێبگەڕ", "urlCopyAccessory": "کۆپی کردنی URL" }, "sideBar": { "closeSidebar": "داخستنی سایدبار", "openSidebar": "کردنەوەی سایدبار", "personal": "کەسی", "private": "تایبه‌تی", "workspace": "شوێنی کارکردن", "favorites": "دڵخوازەکان", "clickToHidePrivate": "بۆ شاردنەوەی شوێنی تایبەت کلیک بکە\nئەو لاپەڕانەی لێرە دروستت کردووە تەنها بۆ تۆ دیارە", "clickToHideWorkspace": "بۆ شاردنەوەی شوێنی کارکردن کلیک بکە\nئەو پەیجانەی لێرە دروستت کردووە بۆ هەموو ئەندامێک دیارە", "clickToHidePersonal": "بۆ شاردنەوەی بەشی کەسی کلیک بکە", "clickToHideFavorites": "بۆ شاردنەوەی بەشی دڵخوازەکان کلیک بکە", "addAPage": "زیاد کردنی لاپەڕەیەک", "addAPageToPrivate": "لاپەڕەیەک زیاد بکە بۆ شوێنی تایبەت", "addAPageToWorkspace": "لاپەڕەیەک زیاد بکە بۆ شوێنی کارکردن", "recent": "نوێ" }, "notifications": { "export": { "markdown": "گۆڕینی دەق بۆ تێبینی", "path": "Documents/flowy" } }, "contactsPage": { "title": "بەردەنگەکان", "whatsHappening": "چی ڕوودەدات", "addContact": "زیادکردنی پەیوەندی", "editContact": "دەستکاریکردنی پەیوەندی" }, "button": { "ok": "باشە", "done": "ئەنجامدرا", "cancel": "ڕەتکردنەوە", "signIn": "چوونە ژوورەوە", "signOut": "دەرچوون", "complete": "تەواوە", "save": "پاشەکەوتکردن", "generate": "دروستکردن", "esc": "ESC", "keep": "پاراستن", "tryAgain": "جارێکی تر هەوڵبدەرەوە", "discard": "ڕەتکردنەوە", "replace": "شوێن گرتنەوە", "insertBelow": "insert لە خوارەوە", "insertAbove": "لە سەرەوە دابنێ", "upload": "بارکردن...", "edit": "بژارکردن", "delete": "سڕینەوە", "duplicate": "هاوشێوە کردن", "putback": "بیخەرەوە بۆ دواوە", "update": "نوێکردنەوە", "share": "هاوبەشکردن", "removeFromFavorites": "سڕینەوە لە دڵخوازەکان", "addToFavorites": "خستنە لیستی دڵخوازەکان", "rename": "گۆڕینی ناو", "helpCenter": "ناوەندی یارمەتی", "add": "زیادکردن", "yes": "بەڵێ", "clear": "سڕدن", "remove": "لابردن", "dontRemove": "لامەبە", "copyLink": "کۆپی بەستەر", "align": "لاڕێككردن", "login": "چوونه‌ژووره‌وه‌", "logout": "دەرچوون", "deleteAccount": "سڕینەوەی هەژمار", "back": "پاش", "signInGoogle": "لە ڕێگەی گووگڵەوە بچۆرە ژوورەوە", "signInGithub": "لە ڕێگەی گیتهاب بچۆرە ژوورەوە", "signInDiscord": "لە ڕێگەی دیسکۆرد بچۆرە ژوورەوە", "Done": "تەواوه", "Cancel": "ڕەتکردن", "OK": "ئۆکەی" }, "label": { "welcome": "بەخێربێن!", "firstName": "ناو", "middleName": "ناوی ناوەڕاست", "lastName": "ناوی خێزانی", "stepX": "هەنگاو {X}" }, "oAuth": { "err": { "failedTitle": "ناتوانرێت پەیوەندی بە ئەکاونتەکەتەوە بکرێت", "failedMsg": "تکایە دڵنیابە لە تەواوکردنی پرۆسەی چوونەژوورەوە لە وێبگەڕەکەتدا." }, "google": { "title": "چوونە ژوورەوە بە ئەکاونتی گووگڵ", "instruction1": "بۆ دەستگەیشتن بە کانتەکتەکان لە گووگڵ، پێویستە لە ڕێگەی وێبگەڕەکەتەوە بچیتە ناو ئەم بەرنامەیە.", "instruction2": "ئەم کۆدە بە کرتەکردن لەسەر ئایکۆن یان دەق هەڵبژێرە کۆپی بکە بۆ کلیپبۆردەکەت:", "instruction3": "لە وێبگەڕەکەتدا بڕۆ بۆ ئەم بەستەرەی خوارەوە و ئەو کۆدەی سەرەوە دابنێ:", "instruction4": "دوای تەواوکردنی ناو نووسین، کرتە بکە سەر ئەم دوگمەیەی خوارەوە" } }, "settings": { "title": "ڕێکخستنەکان", "menu": { "appearance": "ڕووکار", "language": "زمانەکان", "user": "بەکارهێنەر", "files": "فایلەکان", "notifications": "ئاگادارکردنەوەکان", "open": "کردنەوەی ڕێکخستنەکان", "logout": "دەرچوون", "logoutPrompt": "دڵنیای کە دەتەوێت بچیتە دەرەوە؟", "selfEncryptionLogoutPrompt": "دڵنیای کە دەتەوێت بچیتە دەرەوە؟ تکایە دڵنیابە کە نهێنی کۆدکردنەکەت کۆپی کردووە", "syncSetting": "ڕێکخستنەکانی هاوکاتکردن", "cloudSettings": "ڕێکخستنەکانی کڵاود", "enableSync": "چالاک کردنی هاوکاتکردن", "enableEncrypt": "کۆدکردنی داتاکان", "cloudURL": "بەستەری سەرەکی", "invalidCloudURLScheme": "پلانی نادروست", "cloudServerType": "ڕاژەکاری کڵاود", "cloudServerTypeTip": "تکایە ئاگاداربە کە لەوانەیە دوای گۆڕینی ڕاژەکاری کڵاودکە لە ئەکاونتی ئێستات دەربچێت", "cloudLocal": "خۆماڵی", "cloudAppFlowy": "ئەپفلۆوی کلاود بێتا", "cloudAppFlowySelfHost": "ئەپفلۆوی کلاود بە هۆستی خۆیی", "appFlowyCloudUrlCanNotBeEmpty": "url ی هەور ناتوانێت بەتاڵ بێت", "clickToCopy": "کرتە بۆ کۆپی کردن", "selfHostStart": "ئەگەر ڕاژه‌كارت نییە، تکایە سەردانی بکە...", "selfHostContent": "به‌ڵگه‌نامه", "selfHostEnd": "بۆ ڕێنمایی لەسەر چۆنیەتی خۆهۆستکردنی ڕاژەکاری خۆت", "cloudURLHint": "URL ی بنەڕەتی ڕاژەکارەکەت بنووسە", "cloudWSURL": "URL ی وێبسۆکێت", "cloudWSURLHint": "ناونیشانی وێبسۆکێتی ڕاژەکارەکەت دابنێ", "restartApp": "دووبارە دەستپێکردنەوە", "restartAppTip": "بەرنامەکە دووبارە دەستپێبکەرەوە بۆ ئەوەی گۆڕانکارییەکان کاریگەرییان هەبێت. تکایە ئاگاداربە کە ئەمە ڕەنگە ئەکاونتی ئێستات دەربچێت", "changeServerTip": "دوای گۆڕینی ڕاژەکارەکە، پێویستە کلیک لەسەر دوگمەی دووبارە دەستپێکردنەوە بکەیت بۆ ئەوەی گۆڕانکارییەکان کاریگەرییان هەبێت", "enableEncryptPrompt": "کۆدکردن چالاک بکە بۆ پاراستنی داتاکانت بەم نهێنییە. بە سەلامەتی هەڵیبگرە؛ کاتێک چالاک کرا، ناتوانرێت بکوژێنرێتەوە. ئەگەر لەدەستچوو، داتاکانت دەبنە شتێکی وەرنەگیراو. بۆ کۆپیکردن کلیک بکە", "inputEncryptPrompt": "تکایە نهێنی کۆدکردنەکەت بنووسە بۆ...", "clickToCopySecret": "بۆ کۆپیکردنی نهێنی کلیک بکە", "configServerSetting": "ڕێکخستنەکانی ڕاژەکارەکەت ڕێکبخە", "configServerGuide": "دوای هەڵبژاردنی `دەستپێکردنی خێرا`، بچۆ بۆ `ڕێکخستنەکان` و پاشان \"ڕێکخستنەکانی کڵاود\" بۆ ڕێکخستنی سێرڤەری خۆهۆستکراوەکەت.", "inputTextFieldHint": "نهێنی تۆ", "historicalUserList": "مێژووی چوونەژوورەوەی بەکارهێنەر", "historicalUserListTooltip": "ئەم لیستە ئەکاونتە بێناوەکانت پیشان دەدات. دەتوانیت کلیک لەسەر ئەکاونتێک بکەیت بۆ بینینی وردەکارییەکانی. ئەکاونتی بێناو بە کلیک کردن لەسەر دوگمەی دەستپێکردن دروست دەکرێت", "openHistoricalUser": "بۆ کردنەوەی ئەکاونتی بێناو کلیک بکە", "customPathPrompt": "هەڵگرتنی فۆڵدەری داتاکانی @:appName لە فۆڵدەرێکی هاوکاتی کڵاود وەک گووگڵ درایڤ دەتوانێت مەترسی دروست بکات. ئەگەر بنکەدراوەی ناو ئەم فۆڵدەرە لە یەک کاتدا لە چەندین شوێنەوە دەستی پێ بگات یان دەستکاری بکرێت، لەوانەیە ببێتە هۆی ناکۆکی هاوکاتکردن و ئەگەری تێکچوونی داتاکان", "importAppFlowyData": "هێنانی داتا لە فۆڵدەری دەرەکی @:appName", "importingAppFlowyDataTip": "هێنانی داتا لە قۆناغی جێبەجێکردندایە. تکایە ئەپەکە دامەخە", "importAppFlowyDataDescription": "داتا لە فۆڵدەری داتای دەرەکی @:appName کۆپی بکە و هاوردە بکە بۆ ناو فۆڵدەری داتاکانی @:appName ی ئێستا", "importSuccess": "بە سەرکەوتوویی فۆڵدەری داتاکانی @:appName هاوردە کرد", "importFailed": "هاوردەکردنی فۆڵدەری داتاکانی @:appName شکستی هێنا", "importGuide": "بۆ زانیاری زیاتر، تکایە بەڵگەنامەی ئاماژەپێکراو بپشکنە" }, "notifications": { "enableNotifications": { "label": "چالاک کردنی ئاگادارکردنەوەکان", "hint": "کوژاندنەوە بۆ وەستاندنی دەرکەوتنی ئاگادارکردنەوە ناوخۆییەکان" } }, "appearance": { "resetSetting": "ڕێکخستن لە سفرەوە", "fontFamily": { "label": "فۆنتفامیلی", "search": "گەڕان" }, "themeMode": { "label": "مۆدی تێم", "light": "مۆدی ڕوناک", "dark": "مۆدی تاریک", "system": "خۆگونجاندن لەگەڵ سیستەمدا" }, "documentSettings": { "cursorColor": "ڕەنگی جێنیشانده‌ری بەڵگەنامە", "selectionColor": "ڕەنگی \"دیاریكراو\" بەڵگەنامە", "hexEmptyError": "ڕەنگی هێکس ناتوانێت بەتاڵ بێت", "hexLengthError": "بەهای هێکس دەبێت درێژییەکەی ٦ ژمارە بێت", "hexInvalidError": "بەهای هێکسی نادروست", "opacityEmptyError": "لێڵی ناتوانێت بەتاڵ بێت", "opacityRangeError": "لێڵی دەبێت لە نێوان 1 بۆ 100 بێت", "app": "App", "flowy": "Flowy", "apply": "به‌کاربردن" }, "layoutDirection": { "label": "ئاراستەی داڕشتن", "hint": "کۆنتڕۆڵی ڕۆیشتنی ناوەڕۆک لەسەر شاشەکەت بکە، لە چەپەوە بۆ ڕاست یان ڕاست بۆ چەپ.", "ltr": " چەپ بۆ ڕاست", "rtl": "ڕاست بۆ چەپ" }, "textDirection": { "label": "ئاراستەی دەقی پێشوەختە", "hint": "دیاری بکە کە ئایا دەق دەبێت لە چەپەوە دەستپێبکات یان ڕاست وەکو پێشوەختە.", "ltr": " چەپ بۆ ڕاست", "rtl": "ڕاست بۆ چەپ", "auto": "خۆکار", "fallback": "هەمان شێوەی ئاراستەی نەخشە" }, "themeUpload": { "button": "بارکردن", "uploadTheme": "بارکردنی تێم", "description": "بە بەکارهێنانی دوگمەی خوارەوە تێمی @:appName ـەکەت باربکە.", "loading": "تکایە چاوەڕوان بن تا ئێمە تێمی قاڵبەکەت پشتڕاست دەکەینەوە و بار دەکەین...", "uploadSuccess": "تێمی قاڵبەکەت بە سەرکەوتوویی بارکرا", "deletionFailure": "تێمەکە نەسڕدرایەوە. هەوڵبدە بە دەستی لابەریت.", "filePickerDialogTitle": "پەڕگەیەکی .flowy_plugin هەڵبژێرە", "urlUploadFailure": "ناتوانرێت URL بکرێتەوە: {}", "failure": "تێمی قاڵبی بارکراو نادروستە." }, "theme": "تێم و ڕووکار", "builtInsLabel": "قاڵبی پێش دروستکراو", "pluginsLabel": "پێوەکراوەکان", "dateFormat": { "label": "فۆرماتی بەروار", "local": "ناوخۆیی", "us": "US", "iso": "ISO", "friendly": "بەکارهێنانی ئاسانە", "dmy": "ڕ/م/س" }, "timeFormat": { "label": "فۆرماتی کات", "twelveHour": "دوانزە کاتژمێر", "twentyFourHour": "بیست و چوار کاتژمێر" }, "showNamingDialogWhenCreatingPage": "پیشاندانی دیالۆگی ناونان لە کاتی دروستکردنی لاپەڕەیەکدا" }, "files": { "copy": "کۆپی", "defaultLocation": "خوێندنەوەی پەڕگەکان و شوێنی هەڵگرتنی داتاکان", "exportData": "دەرچوون لە داتاکانتەوە بەدەست بهێنە", "doubleTapToCopy": "بۆ کۆپیکردن دووجار کلیک بکە", "restoreLocation": "گەڕاندنەوە بۆ ڕێڕەوی پێشوەختەی @:appName", "customizeLocation": "فۆڵدەرێکی دیکە بکەرەوە", "restartApp": "تکایە ئەپەکە دابخە و بیکەرەوە بۆ ئەوەی گۆڕانکارییەکان جێبەجێ بکرێن.", "exportDatabase": "هەناردە کردنی بنکەدراوە", "selectFiles": "پەڕگەکان هەڵبژێرە بۆ هەناردە کردن", "selectAll": "هەڵبژاردنی هەموویان", "deselectAll": "هەڵبژاردەی هەموو هەڵبگرە", "createNewFolder": "درووست کردنی فۆڵدەری نوێ", "createNewFolderDesc": "پێمان بڵێ دەتەوێت داتاکانت لە کوێ هەڵبگریت", "defineWhereYourDataIsStored": "پێناسە بکە کە داتاکانت لە کوێ هەڵدەگیرێن", "open": "کردنەوە", "openFolder": "فۆڵدەرێکی هەبوو بکەرەوە", "openFolderDesc": "خوێندن و نووسین بۆ فۆڵدەری @:appName ی ئێستات", "folderHintText": "ناوی فۆڵدەر", "location": "دروستکردنی فۆڵدەرێکی نوێ", "locationDesc": "ناوێک بۆ فۆڵدەری داتاکانی @:appName هەڵبژێرە", "browser": "وێبگەڕ", "create": "دروستکردن", "set": "دانان", "folderPath": "ڕێڕەوی پاشەکەوتکردنی فۆڵدەر", "locationCannotBeEmpty": "ڕێڕەو ناتوانرێت بەتاڵ بێت", "pathCopiedSnackbar": "ڕێڕەوی پاشەکەوتکردنی فایلەکە کۆپی کرا بۆ کلیپبۆرد!", "changeLocationTooltips": "گۆڕینی دایرێکتۆری داتاکان", "change": "گوڕین", "openLocationTooltips": "دایرێکتۆرێکی تری داتا بکەرەوە", "openCurrentDataFolder": "کردنەووەی دایرێکتۆری ئێستای داتا", "recoverLocationTooltips": "گەڕاندنەوە بۆ دایرێکتۆری پێشووی داتاکان", "exportFileSuccess": "هەناردەکردنی فایل بە سەرکەوتوویی!", "exportFileFail": "هەناردەکردنی فایلەکە شکستی هێنا!", "export": "هەناردەکردن" }, "user": { "name": "ناو", "email": "ئیمەیڵ", "tooltipSelectIcon": "هەڵبژاەدنی وێنۆچكه‌", "selectAnIcon": "هەڵبژاردنی وێنۆچكه‌", "pleaseInputYourOpenAIKey": "تکایە کلیلی AI ـەکەت بنووسە", "clickToLogout": "بۆ دەرچوون لە بەکارهێنەری ئێستا کلیک بکە", "pleaseInputYourStabilityAIKey": "تکایە جێگیری کلیلی AI ـەکەت بنووسە" }, "mobile": { "personalInfo": "زانیاری کەسی", "username": "ناوی بەکارهێنەر", "usernameEmptyError": "ناوی بەکارهێنەر ناتوانێت بەتاڵ بێت", "about": "لەربارەی", "pushNotifications": "ئاگادارکردنەوەکانی خستنه‌سه‌ر", "support": "پشتیوانی", "joinDiscord": "لە دیسکۆرد لەگەڵمان بن", "privacyPolicy": "سیاسەتی پاراستنی نهێنی", "userAgreement": "ڕێککەوتنی بەکارهێنەر", "termsAndConditions": "بار و دۆخ و مەرجەکان", "userprofileError": "شکستی هێنا لە بارکردنی پڕۆفایلی بەکارهێنەر", "userprofileErrorDescription": "تکایە هەوڵبدە بچیتە دەرەوە و بچۆرەوە ژوورەوە بۆ ئەوەی بزانیت ئایا کێشەکە هێشتا بەردەوامە یان نا.", "selectLayout": "نەخشە هەڵبژێرە", "selectStartingDay": "ڕۆژی دەستپێکردنەکەت هەڵبژێرە", "version": "وەشان" }, "shortcuts": { "shortcutsLabel": "کورتە ڕێگاکان", "command": "فەرمان", "keyBinding": "کورتکراوەکانی تەختەکلیل", "addNewCommand": "زیاد کردنی فەرمانێکی نوێ", "updateShortcutStep": "تێکەڵەی کلیلی دڵخواز داگرە و ENTER داگرە", "shortcutIsAlreadyUsed": "ئەم کورتە ڕێگایە پێشتر بۆ: {conflict} بەکارهاتووە.", "resetToDefault": "گەڕاندنەوە بۆ کلیلەکانی بنه‌ڕه‌ت", "couldNotLoadErrorMsg": "کورتە ڕێگاکان نەتوانرا باربکرێن، تکایە دووبارە هەوڵبدەرەوە", "couldNotSaveErrorMsg": "کورتە ڕێگاکان نەتوانرا پاشەکەوت بکرێن، تکایە دووبارە هەوڵبدەرەوە" } }, "grid": { "deleteView": "ئایا دڵنیای کە دەتەوێت ئەم دیمەنە بسڕیتەوە؟", "createView": "نوێ", "title": { "placeholder": "بێ ناونیشان" }, "settings": { "filter": "فیلتێر", "sort": "پۆلێن کردن", "sortBy": "ڕیزکردن بەپێی", "properties": "تایبەتمەندیەکان", "reorderPropertiesTooltip": "بۆ ڕێکخستنەوەی تایبەتمەندییەکان ڕابکێشە", "group": "ده‌سته‌", "addFilter": "زیادکردنی فیلتێر", "deleteFilter": "سڕینەوەی فیلتێر", "filterBy": "فیلتێر بەپێی...", "typeAValue": "بەهایەک بنووسە...", "layout": "گه‌ڵاڵه‌به‌ندی", "databaseLayout": "گه‌ڵاڵه‌به‌ندی", "viewList": { "zero": "0 بینین", "one": "{count} بینین", "other": "{count} بینینەکان" }, "editView": "دەستکاری دیمەن", "boardSettings": "ڕێکخستنەکانی تەختە", "calendarSettings": "ڕێکخستنەکانی ساڵنامە", "numberOfVisibleFields": "{} نیشان دراوە" }, "textFilter": { "contains": "لەخۆ دەگرێت", "doesNotContain": "لەخۆناگرێت", "endsWith": "کۆتایی دێت بە", "startWith": "دەسپێکردن بە", "is": "هەیە", "isNot": "نییە", "isEmpty": "به‌تاڵه‌", "isNotEmpty": "بەتاڵ نییە", "choicechipPrefix": { "isNot": "لەدژی", "startWith": "دەسپێکردن بە", "endWith": "کۆتایی بە", "isEmpty": "به‌تاڵه‌", "isNotEmpty": "بەتاڵ نییە" } }, "checkboxFilter": { "isChecked": "پشکنین کراوە", "isUnchecked": "پشکنین نەکراوە", "choicechipPrefix": { "is": "هەیە" } }, "checklistFilter": { "isComplete": "تەواوە", "isIncomplted": "ناتەواوە" }, "dateFilter": { "is": "هەیە", "before": "پێشترە", "after": "دوای ئەبێت", "between": "لە نێواندایە", "empty": "به‌تاڵه‌", "notEmpty": "بەتاڵ نییە" }, "field": { "hide": "شاردنەوە", "show": "نیشاندان", "insertLeft": "جێگیرکردن لە چەپ", "insertRight": "جێگیرکردن لە ڕاست", "duplicate": "دووبارەکردنەوە", "delete": "سڕینەوە", "textFieldName": "دەق", "checkboxFieldName": "بابەتە هەڵبژێردراوەکان", "dateFieldName": "ڕێکەوت", "updatedAtFieldName": "دوایین گۆڕانکاری", "createdAtFieldName": "دروستکراوە لە...", "numberFieldName": "ژمارەکان", "singleSelectFieldName": "هەڵبژاردن", "multiSelectFieldName": "فرە هەڵبژاردن", "urlFieldName": "ناونیشانی ئینتەرنێتی", "checklistFieldName": "لیستی پشکنین", "numberFormat": "فۆرمات ژمارە", "dateFormat": "فۆرمات ڕێکەوت", "includeTime": "کات لەخۆ بگرێت", "isRange": "ڕۆژی کۆتایی", "dateFormatFriendly": "Mang Roj, Sall", "dateFormatISO": "Sall-Mang-Roj", "dateFormatLocal": "Mang/Roj/Sall", "dateFormatUS": "Sall/Mang/Roj", "dateFormatDayMonthYear": "Roj/Mang/Sall", "timeFormat": "فۆرماتی کات", "invalidTimeFormat": "فۆرماتێکی نادروست", "timeFormatTwelveHour": "دوانزە کاتژمێر", "timeFormatTwentyFourHour": "بیست و چوار کاتژمێر", "clearDate": "سڕینەوەی ڕێکەوت", "dateTime": "کاتی بەروار", "startDateTime": "کاتی بەرواری دەستپێک", "endDateTime": "کاتی بەرواری کۆتایی", "failedToLoadDate": "شکستی هێنا لە بارکردنی بەهای بەروار", "selectTime": "کات هەڵبژێرە", "selectDate": "بەروار هەڵبژێرە", "visibility": "پلەی بینین", "propertyType": "جۆری تایبه‌تمه‌ندی", "addSelectOption": "زیادکردنی بژاردەیەک", "typeANewOption": "بژاردەیەکی نوێ بنووسە", "optionTitle": "بژاردەکان", "addOption": "زیادکردنی بژاردە", "editProperty": "دەستکاریکردنی تایبەتمەندی", "newProperty": "تایبەتمەندی نوێ", "deleteFieldPromptMessage": "ئایا دڵنیایت لە سڕدنەوەی ئەم تایبەتمەندییە؟", "newColumn": "ستوونی نوێ", "format": "فۆرمات", "reminderOnDateTooltip": "ئەم خانەیە بیرخستنەوەیەکی بەرنامە بۆ داڕێژراوی هەیە" }, "rowPage": { "newField": "خانەیێکی نوێ زیاد بکە", "fieldDragElementTooltip": "بۆ کردنەوەی مێنۆ کرتە بکە" }, "sort": { "ascending": "هەڵکشاو", "descending": "بەرەو خوار", "deleteAllSorts": "هەموو ڕیزکردنەکان لاببە", "addSort": "زیادکردنی ڕیزکردن" }, "row": { "duplicate": "دووبارە کردنەوە", "delete": "سڕینەوە", "titlePlaceholder": "بێ ناونیشان", "textPlaceholder": "بەتاڵ", "copyProperty": "تایبەتمەندی کۆپی کرا بۆ کلیپبۆرد", "count": "سەرژمێرکردن", "newRow": "ڕیزی نوێ", "action": "کردەوە", "drag": "ڕاکێشان بۆ جوڵە", "dragAndClick": "بۆ جوڵاندن ڕابکێشە، کلیک بکە بۆ کردنەوەی مێنۆ" }, "selectOption": { "create": "دروستکردن", "purpleColor": "مۆر", "pinkColor": "پەمەیی", "lightPinkColor": "پەمەیی کاڵ", "orangeColor": "پرتەقاڵی", "yellowColor": "زەرد", "limeColor": "لیمۆیی", "greenColor": "سەوز", "aquaColor": "ئاکوا", "blueColor": "شین", "deleteTag": "سڕینەوە تاگ", "colorPanelTitle": "ڕەنگەکان", "panelTitle": "بژاردەیەک زیاد یان دروستی بکە.", "searchOption": "گەڕان بەدوای بژاردەیەکدا", "searchOrCreateOption": "گەڕان یان دروستکردنی بژاردەیەک...", "createNew": "دروستکردنی نوێ", "orSelectOne": "یان بژاردەیەک هەڵبژێرە", "typeANewOption": "بژاردەیەکی نوێ بنووسە", "tagName": "ناوی تاگ" }, "checklist": { "taskHint": "وەسفکردنی ئەرک", "addNew": "شتێک زیاد بکە", "submitNewTask": "ئافراندن", "hideComplete": "شاردنەوەی ئەرکە تەواوکراوەکان", "showComplete": "هەموو ئەرکەکان پیشان بدە" }, "menuName": "تۆڕ", "referencedGridPrefix": "نواندن", "calculate": "حیساب بکە", "calculationTypeLabel": { "none": "هیچ", "average": "تێکڕا و ڕێژە", "max": "زۆر", "median": "ناوەند", "min": "کەم", "sum": "کۆ" }, "singleSelectOptionFilter": { "is": "هەیە", "isNot": "نییە", "isEmpty": "به‌تاڵه‌", "isNotEmpty": "بەتاڵ نییە" }, "multiSelectOptionFilter": { "contains": "لەخۆ دەگرێت", "doesNotContain": "لەخۆناگرێت", "isEmpty": "به‌تاڵه‌", "isNotEmpty": "بەتاڵ نییە" } }, "document": { "menuName": "بەڵگەنامە", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "بۆردێک هەڵبژێرە بۆ ئەوەی لینکی بۆ بدەیت", "createANewBoard": "بۆردێکی نوێ دروست بکە" }, "grid": { "selectAGridToLinkTo": "Gridێک هەڵبژێرە بۆ ئەوەی لینکی بۆ بکەیت", "createANewGrid": "دروستکردنی تۆڕێکی نوێی پیشاندانی" }, "calendar": { "selectACalendarToLinkTo": "ساڵنامەیەک هەڵبژێرە بۆ ئەوەی لینکی بۆ بکەیت.", "createANewCalendar": "ساڵنامەیەکی نوێ دروست بکە" }, "document": { "selectADocumentToLinkTo": "بەڵگەنامەیەک هەڵبژێرە کە بەستەرەکەی بۆ دابنێیت" } }, "selectionMenu": { "outline": "گەڵاڵە", "codeBlock": "بلۆکی کۆد" }, "plugins": { "referencedBoard": "بۆردی چاوگ", "referencedGrid": "تۆڕی ئاماژەپێکراو", "referencedCalendar": "ساڵنامەی ئاماژەپێکراو", "referencedDocument": "بەڵگەنامەی ئاماژەپێکراو", "autoGeneratorMenuItemName": "AI نووسەری", "autoGeneratorTitleName": "داوا لە AI بکە هەر شتێک بنووسێت...", "autoGeneratorLearnMore": "زیاتر زانین", "autoGeneratorGenerate": "بنووسە", "autoGeneratorHintText": "لە AI پرسیار بکە...", "autoGeneratorCantGetOpenAIKey": "نەتوانرا کلیلی AI بەدەست بهێنرێت", "autoGeneratorRewrite": "دووبارە نووسینەوە", "smartEdit": "یاریدەدەری زیرەک", "smartEditFixSpelling": "ڕاستکردنەوەی نووسین", "warning": "⚠️ وەڵامەکانی AI دەتوانن هەڵە یان چەواشەکارانە بن", "smartEditSummarize": "کورتەنووسی", "smartEditImproveWriting": "پێشخستن نوووسین", "smartEditMakeLonger": "درێژتری بکەرەوە", "smartEditCouldNotFetchResult": "هیچ ئەنجامێک لە AI وەرنەگیرا", "smartEditCouldNotFetchKey": "نەتوانرا کلیلی AI بهێنێتە ئاراوە", "smartEditDisabled": "لە ڕێکخستنەکاندا پەیوەندی بە AI بکە", "discardResponse": "ئایا دەتەوێت وەڵامەکانی AI بسڕیتەوە؟", "createInlineMathEquation": "درووست کردنی هاوکێشە", "fonts": "فۆنتەکان", "toggleList": "Toggle لیستی", "quoteList": "لیستی وەرگرتە", "numberedList": "لیستی ژمارەدار", "cover": { "changeCover": "گۆڕینی بەرگ", "colors": "ڕەنگەکان", "images": "وێنەکان", "clearAll": "سڕینەوەی هەموو", "abstract": "کورتە", "addCover": "زیاد کردنی بەرگ", "addLocalImage": "خستنه‌سه‌ری وێنەی خۆماڵی", "invalidImageUrl": "ڕێڕەوی وێنەکە نادروستە", "failedToAddImageToGallery": "نەمتوانی وێنەکەت بخەمە پێشانگا😔️", "enterImageUrl": "ڕێڕەوی وێنەکە بنووسە", "add": "زیادکردن", "back": "چوونە دووا", "saveToGallery": "پاشەکەوت لە پێشانگا", "removeIcon": "سڕینەوەی وێنۆچكه‌", "pasteImageUrl": "نووسینی ڕێڕەوی وێنە", "or": "یان", "pickFromFiles": "لە نێوان په‌ڕگەکاندا هەڵبژێرە", "couldNotFetchImage": "نەمتوانی وێنەکەت بهێنم", "imageSavingFailed": "پاشەکەوتکردنی وێنە شکستی هێنا", "addIcon": "زیاد کردنی وێنۆچكه‌", "coverRemoveAlert": "دوای لابردنی، لە بەرگەکە دەسڕدرێتەوە!", "alertDialogConfirmation": "دڵنیای کە دەتەوێت بەردەوام بیت؟🤨️" }, "mathEquation": { "addMathEquation": "هاوکێشەی بیرکاری زیاد بکە", "editMathEquation": "ده‌ستكاری هاوکێشەی بیرکاری" }, "optionAction": { "click": "کرتە بکە", "toOpenMenu": "بۆ کردنەوەی پێڕست", "delete": "سڕینەوە", "duplicate": "هاوشێوە کردن", "turnInto": "گۆڕینی بۆ...", "moveUp": "بەرزکردنەوە", "moveDown": "دابەزاندن", "color": "ڕەنگ", "align": "ڕیزبەندی", "left": "چەپ", "center": "ناوەند", "right": "ڕاست", "defaultColor": "ڕەنگی بنەڕەت" }, "image": { "copiedToPasteBoard": "بەستەری وێنەکە کۆپی کرا بۆ کلیپبۆرد" }, "outline": { "addHeadingToCreateOutline": "بۆ دروستکردنی خشتەی ناوەڕۆک سەردێڕەکان داخڵ بکە" }, "openAI": "AI ژیری دەستکرد" }, "textBlock": { "placeholder": "بۆ فەرمانەکان '/' بنووسە" }, "title": { "placeholder": "بێ ناونیشان" }, "imageBlock": { "placeholder": "بۆ زیادکردنی وێنە کلیک بکە", "upload": { "label": "بەرزکردنەوە", "placeholder": "بۆ بارکردنی وێنە کلیک بکە" }, "url": { "label": "بەستەری وێنە", "placeholder": "بەستەری وێنەکە بنووسە" }, "support": "سنووری قەبارەی وێنە 5 مێگابایت. فۆرماتەکەنی پشتیوانی کراو: JPEG، PNG، GIF، SVG", "error": { "invalidImage": "وێنەیەکی نادروست", "invalidImageSize": "قەبارەی وێنەکە دەبێت لە 5 مێگابایت کەمتر بێت", "invalidImageFormat": "فۆرماتی وێنەکە پشتیوانی ناکرێ. فۆرماتەکەنی پشتیوانی کراو: JPEG، PNG، GIF، SVG", "invalidImageUrl": "Urlی وێنەکە نادروستە" } }, "codeBlock": { "language": { "label": "زمان", "placeholder": "هەڵبژاردنی زمان" } }, "inlineLink": { "placeholder": "ڕێڕەوەکە بلکێنە یان بینووسە", "openInNewTab": "کردنەوە لە تابێکی نوێدا", "copyLink": "کۆپی کردنی بەستەر", "removeLink": "لابردنی بەستەر", "url": { "label": "بەستەر", "placeholder": "بەستەرەکە بنووسە" }, "title": { "label": "سه‌ردێڕی بەستەرەکە", "placeholder": "سه‌ردێڕی بەستەرەکە بنووسە" } }, "mention": { "placeholder": "باسی کەسێک یان لاپەڕەیەک یان بەروارێک یان سرووشتێک بکە...", "page": { "label": "لینکی پەیج", "tooltip": "کلیک بکە بۆ کردنەوەی لاپەڕەکە" } } }, "board": { "column": { "createNewCard": "دروست کردنی نوێ" }, "menuName": "تەختە", "referencedBoardPrefix": "دیمەنی...", "mobile": { "showGroup": "نیشاندانی گروپ", "showGroupContent": "ئایا دڵنیایت دەتەوێت ئەم گروپە لەسەر تەختە نیشان بدەیت؟", "failedToLoad": "بارکردنی بینینی تەختە سەرکەوتوو نەبوو" } }, "calendar": { "menuName": "ساڵنامە", "defaultNewCalendarTitle": "بێ ناونیشان", "navigation": { "today": "ئەمڕۆ", "jumpToday": "باز بدە بۆ ئەمڕۆ", "previousMonth": "مانگی پێشوو", "nextMonth": "مانگی داهاتوو" }, "settings": { "showWeekNumbers": "پیشاندانی ژمارەکانی هەفتە", "showWeekends": "پیشاندانی کۆتایی هەفتە", "firstDayOfWeek": "سەرەتای هەفتە لە", "layoutDateField": "ڕیزبەستی ساڵنامە بە", "noDateTitle": "بە بێ بەروارێک", "clickToAdd": "بۆ زیادکردن بۆ ساڵنامە کرتە بکە", "name": "شێواز و گه‌ڵاڵه‌به‌ندی ڕۆژژمێر", "noDateHint": "ڕووداوە بێ بەرنامە داڕێژراوەکان لێرەدا نیشان دەدرێن" }, "referencedCalendarPrefix": "دیمەنی..." }, "errorDialog": { "title": "هەڵەی⛔️ @:appName", "howToFixFallback": "ببورن بۆ کێشەکە🥺️! پرسەکە و وەسفەکەی لە لاپەڕەی GitHub ـمان بنێرن.", "github": "بینین لە GitHub" }, "search": { "label": "گەڕان", "placeholder": { "actions": "کردەوەکانی گەڕان..." } }, "message": { "copy": { "success": "ڕوونووس کرا!", "fail": "ناتوانێت ڕوونووس بکرێت" } }, "unSupportBlock": "وەشانی ئێستا پشتگیری ئەم بلۆکە ناکات.", "views": { "deleteContentTitle": "ئایا دڵنیای کە دەتەوێت {pageType} بسڕیتەوە؟", "deleteContentCaption": "ئەگەر ئەم {pageType} بسڕیتەوە، دەتوانیت لە سەتڵی زبڵ وەریبگریتەوە." }, "colors": { "custom": "ڕاسپێراو-دڵخواز", "default": "بنەڕەتی", "red": "سوور", "orange": "پرتەقاڵی", "yellow": "زەرد", "green": "سەوز", "blue": "شین", "purple": "مۆر", "pink": "پەمەیی", "brown": "قاوەیی", "gray": "خۆڵەمێشی" }, "emoji": { "filter": "پاڵێو", "random": "هەڕەمەکی", "selectSkinTone": "هەڵبژاردنی ڕەنگی پێست", "remove": "لابردنی ئیمۆجی", "categories": { "smileys": "پێکەنینەکان", "people": "خەڵک و جەستە", "animals": "ئاژەڵ و سروشت", "food": "چێشت و خواردنەوە", "activities": "چالاکیەکان", "places": "گەشت و گوزار و شوێنەکان", "objects": "شتەکان", "symbols": "هێماکان", "flags": "ئاڵاکان", "nature": "سروشت", "frequentlyUsed": "زۆرجار بەکارت هێناوە" } } } ================================================ FILE: frontend/resources/translations/cs-CZ.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Já", "welcomeText": "Vítej", "welcomeTo": "Vítejte v", "githubStarText": "Ohvě", "subscribeNewsletterText": "Přihlásit k odběru newsletteru", "letsGoButtonText": "Rychlá", "title": "Název", "youCanAlso": "Můžete také", "and": "a", "failedToOpenUrl": "Nepodařilo se otevřít URL: {}", "blockActions": { "addBelowTooltip": "Kliknutím přidat pod", "addAboveCmd": "Alt+kliknutí", "addAboveMacCmd": "Option+kliknutí", "addAboveTooltip": "Přidat ", "dragTooltip": "Posouvejte přetažením", "openMenuTooltip": "Kliknutím otevřete menu" }, "signUp": { "buttonText": "Regi", "title": "Zaregistrujte se do @:appName", "getStartedText": "Začínáme", "emptyPasswordError": "Heslo nesmí být prázdné", "repeatPasswordEmptyError": "Heslo pro potvrzení nesmí být prázdné", "unmatchedPasswordError": "Hesla se neshodují", "alreadyHaveAnAccount": "Už máte účet?", "emailHint": "E-mail", "passwordHint": "Heslo", "repeatPasswordHint": "Heslo znovu", "signUpWith": "Zaregistrovat přes:" }, "signIn": { "loginTitle": "Přihlásit do @:appName", "loginButtonText": "Přihlásit", "loginStartWithAnonymous": "Začít anonymní sezení", "continueAnonymousUser": "Pokra", "continueWithLocalModel": "Pokračovat s lokálním modelem", "switchToAppFlowyCloud": "AppFlowy Cloud", "anonymousMode": "Anonymní režim", "buttonText": "Přihlásit se", "signingInText": "Přihlašování...", "forgotPassword": "Zapomenuté heslo?", "emailHint": "E-mail", "passwordHint": "Heslo", "dontHaveAnAccount": "Nemáte účet?", "createAccount": "Vytvořit účet", "repeatPasswordEmptyError": "Heslo nesmí být prázné", "unmatchedPasswordError": "Hesla se neshodují", "passwordMustContain": "Heslo musí obsahovat alespoň jedno písmeno, jedno číslo a jeden symbol.", "syncPromptMessage": "Synchronizace dat může chvilku trvat. Nezavírejte prosím tuto stránku.", "or": "NEBO", "signInWithGoogle": "Pokračovat s Googlem", "signInWithGithub": "Pokračovat s GitHubem", "signInWithDiscord": "Pokračovat s Discordem", "signInWithApple": "Pokračujte s Applem", "continueAnotherWay": "Pokračovat jinak", "signUpWithGoogle": "Zaregistrujte se přes Googlu", "signUpWithGithub": "Zaregistrujte se přes Github", "signUpWithDiscord": "Zaregistrujte se přes Discord", "signInWith": "Přihlásit přes:", "signInWithEmail": "Pokračovat s e-mailem", "signInWithMagicLink": "Pokračovat", "signUpWithMagicLink": "Zaregistrujte se přes Magic Link", "pleaseInputYourEmail": "Zadejte prosím svou e-mailovou adresu", "settings": "Nastavení", "magicLinkSent": "Magic Link odeslán!", "invalidEmail": "Zadejte prosím platnou e-mailovou adresu", "alreadyHaveAnAccount": "Již máte účet?", "logIn": "Přihlásit se", "generalError": "Něco se pokazilo. Zkuste to prosím znovu později.", "limitRateError": "Z bezpečnostních důvodů můžete požádat o magic link pouze každých 60 sekund.", "magicLinkSentDescription": "Na váš e-mail byl odeslán magic link. Kliknutím na odkaz dokončíte přihlášení. Platnost odkazu vyprší po 5 minutách.", "tokenHasExpiredOrInvalid": "Platnost kódu vypršela nebo je kód neplatný. Zkuste to prosím znovu.", "signingIn": "Přihlašování...", "checkYourEmail": "Zkontrolujte si e-mail", "temporaryVerificationLinkSent": "Byl odeslán dočasný ověřovací odkaz.\nZkontrolujte si prosím svou doručenou schránku", "temporaryVerificationCodeSent": "Byl odeslán dočasný ověřovací kód.\nZkontrolujte si prosím doručenou poštu na adrese", "continueToSignIn": "Pokračovat k přihlášení", "continueWithLoginCode": "Pokračovat s přihlašovacím kódem", "backToLogin": "Zpět k přihlášení", "enterCode": "Zadejte kód", "enterCodeManually": "Zadejte kód ručně", "continueWithEmail": "Pokračovat s e-mailem", "enterPassword": "Zadejte heslo", "loginAs": "Přihlásit se jako", "invalidVerificationCode": "Zadejte prosím platný ověřovací kód", "tooFrequentVerificationCodeRequest": "Odeslali jste příliš mnoho požadavků. Zkuste to prosím znovu později.", "invalidLoginCredentials": "Vaše heslo je nesprávné, zkuste to prosím znovu.", "resetPassword": "Obnovit heslo", "resetPasswordDescription": "Zadejte svůj e-mail pro obnovení hesla", "continueToResetPassword": "Pokračovat k resetování hesla", "resetPasswordSuccess": "Úspěšné obnovení hesla", "resetPasswordFailed": "Obnovení hesla se nezdařilo", "resetPasswordLinkSent": "Odkaz pro obnovení hesla byl odeslán na váš e-mail. Zkontrolujte si prosím doručenou poštu na adrese", "resetPasswordLinkExpired": "Platnost odkazu pro obnovení hesla vypršela. Požádejte o nový odkaz.", "resetPasswordLinkInvalid": "Odkaz pro resetování hesla je neplatný. Požádejte o nový odkaz.", "enterNewPasswordFor": "Zadejte nové heslo pro ", "newPassword": "Nové heslo", "enterNewPassword": "Zadejte nové heslo", "confirmPassword": "Potvrzení hesla", "confirmNewPassword": "Zadejte nové heslo", "newPasswordCannotBeEmpty": "Nové heslo nemůže být prázdné", "confirmPasswordCannotBeEmpty": "Potvrzení hesla nemůže být prázdné", "passwordsDoNotMatch": "Hesla se neshodují", "verifying": "Ověřování...", "continueWithPassword": "Pokračovat s heslem", "youAreInLocalMode": "Jste v lokálním režimu.", "loginToAppFlowyCloud": "Přihlášení do AppFlowy Cloud", "LogInWithGoogle": "Přihlásit přes Google", "LogInWithGithub": "Přihlásit přes GitHub", "LogInWithDiscord": "Přihlásit přes Discord" }, "workspace": { "chooseWorkspace": "Vyberte pracovní ůplochu", "defaultName": "Můj pracovní prostor", "create": "Vytvořit pracovní prostor", "new": "Nový pracovní prostor", "importFromNotion": "Importovat z Notionu", "learnMore": "Zjistěte více", "reset": "Resetovat plochu", "renameWorkspace": "Přejmenovat pracovní prostor", "workspaceNameCannotBeEmpty": "Název pracovního prostoru nemůže být prázdný", "resetWorkspacePrompt": "Obnovením pracovního prostoru smažete všechny stránky a data v nich. Opravdu chcete obnovit pracovní prostor? Pro obnovení pracovního prostoru můžete kontaktovat podporu.", "hint": "pracovní plocha", "notFoundError": "Pracovní prostor nenalezen", "failedToLoad": "Něco se pokazilo! Nepodařilo se načíst pracovní prostor. Zkuste zavřít a znovu otevřít @:appName a zkuste to znovu.", "errorActions": { "reportIssue": "Nahlásit problém", "reportIssueOnGithub": "Nahlásit problém na Githubu", "exportLogFiles": "Exportovat soubory protokolu", "reachOut": "Ozvat se na Discordu" }, "menuTitle": "Pracovní prostory", "deleteWorkspaceHintText": "Opravdu chcete smazat pracovní prostor? Tuto akci nelze vrátit zpět a všechny zveřejněné stránky přestanou být dostupné.", "createSuccess": "Pracovní prostor byl úspěšně vytvořen", "createFailed": "Vytvoření pracovního prostoru se nezdařilo", "createLimitExceeded": "Dosáhli jste maximálního povoleného počtu pracovních prostorů pro váš účet. Pokud potřebujete další pracovní prostory pro pokračování v práci, požádejte o ně na Githubu", "deleteSuccess": "Pracovní prostor byl úspěšně smazán", "deleteFailed": "Nepodařilo se smazat pracovní prostor", "openSuccess": "Pracovní prostor bylo úspěšně otevřeno", "openFailed": "Nepodařilo se otevřít pracovní prostor", "renameSuccess": "Pracovní prostor byl úspěšně přejmenován", "renameFailed": "Přejmenování pracovního prostoru se nezdařilo", "updateIconSuccess": "Ikona pracovního prostoru byla úspěšně aktualizována", "updateIconFailed": "Aktualizace ikony pracovního prostoru se nezdařilo", "cannotDeleteTheOnlyWorkspace": "Nelze smazat jediný pracovní prostor", "fetchWorkspacesFailed": "Nepodařilo se načíst pracovní prostory", "leaveCurrentWorkspace": "Opustit pracovní prostor", "leaveCurrentWorkspacePrompt": "Opravdu chcete opustit aktuální pracovní prostor?" }, "shareAction": { "buttonText": "Sdílet", "workInProgress": "Ji brzy", "markdown": "Markdown", "html": "HTML", "clipboard": "Kopírovat do schránky", "csv": "CSV", "copyLink": "Kopírovat odkaz", "publishToTheWeb": "Publikovat na webu", "publishToTheWebHint": "Vytvořte si webové stránky s AppFlowy", "publish": "Zveřejnit", "unPublish": "Zrušit zveřejnění", "visitSite": "Navštívit stránku", "exportAsTab": "Exportovat jako", "publishTab": "Zveřejnit", "shareTab": "Sdílet", "publishOnAppFlowy": "Zveřejnit na AppFlowy", "shareTabTitle": "Pozvat ke spolupráci", "shareTabDescription": "Pro snadnou spolupráci s kýmkoli", "copyLinkSuccess": "Odkaz zkopírován do schránky", "copyShareLink": "Kopírovat odkaz pro sdílení", "copyLinkFailed": "Nepodařilo se zkopírovat odkaz do schránky", "copyLinkToBlockSuccess": "Odkaz na blok zkopírován do schránky", "copyLinkToBlockFailed": "Nepodařilo se zkopírovat odkaz bloku do schránky", "manageAllSites": "Spravovat všechny stránky", "updatePathName": "Aktualizovat název cesty" }, "moreAction": { "small": "malé", "medium": "střední", "large": "velké", "fontSize": "Velikost písma", "import": "Importovat", "moreOptions": "Ví", "wordCount": "Počet slov: {}", "charCount": "Počet znaků: {}", "createdAt": "Vytvořeno: {}", "deleteView": "Smazat", "duplicateView": "Duplikovat", "wordCountLabel": "Počet slov: ", "charCountLabel": "Počet znaků: ", "createdAtLabel": "Vytvořeno: ", "syncedAtLabel": "Synchronizováno: ", "saveAsNewPage": "Přidat zprávy na stránku", "saveAsNewPageDisabled": "Žádné zprávy k dispozici" }, "importPanel": { "textAndMarkdown": "Text a Markdown", "documentFromV010": "Dokument z v0.1.0", "databaseFromV010": "Databázi z v0.1.0", "notionZip": "Exportovaný soubor ZIP z Notion", "csv": "CSV", "database": "Databáze" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "Přetáhněte soubor, klikněte na ", "placeholderUpload": "Nahrát", "placeholderRight": "nebo vložte odkaz obrázku.", "dropToUpload": "Přetáhněte soubor k nahrání", "change": "Změnit" } }, "disclosureAction": { "rename": "Přejmenovat", "delete": "Smazat", "duplicate": "Duplikovat", "unfavorite": "Odebrat z oblíbených", "favorite": "Přidat do oblíbených", "openNewTab": "Otevřít v novém panelu", "moveTo": "Přesunout do", "addToFavorites": "Přidat do oblíbených", "copyLink": "Kopírovat odkaz", "changeIcon": "Změnit ikonu", "collapseAllPages": "Sbalit všechny podstránky", "movePageTo": "Přesunout stránku na", "move": "Přesunout", "lockPage": "Zamknout stránku" }, "blankPageTitle": "Prázdná stránka", "newPageText": "Nová stránka", "newDocumentText": "Nový dokument", "newGridText": "Nová mřížka", "newCalendarText": "Nový kalendář", "newBoardText": "Nová nástěnka", "chat": { "newChat": "AI Chat", "inputMessageHint": "Zeptejte se @:appName AI", "inputLocalAIMessageHint": "Zeptejte se @:appName Lokální AI", "unsupportedCloudPrompt": "Tato funkce je k dispozici pouze při použití @:appName Cloud", "relatedQuestion": "Navrhované", "serverUnavailable": "Připojení ztraceno. Zkontrolujte prosím připojení k internetu a", "aiServerUnavailable": "AI je dočasně nedostupná. Zkuste to prosím znovu později.", "retry": "Zkusit znovu", "clickToRetry": "Kliknutím zkusíte znovu", "regenerateAnswer": "Znovu generovat", "question1": "Jak používat Kanban ke správě úkolů", "question2": "Vysvětli metodu GTD", "question3": "Proč používat Rust", "question4": "Recept s tím, co mám v kuchyni", "question5": "Vytvoř ilustraci pro mou stránku", "question6": "Napsat si seznam úkolů na nadcházející týden", "aiMistakePrompt": "Umělá inteligence může dělat chyby. Zkontrolujte důležité informace.", "chatWithFilePrompt": "Chcete si s tím souborem popovídat?", "indexFileSuccess": "Indexování souboru úspěšně", "inputActionNoPages": "Žádné výsledky na stránce", "referenceSource": { "zero": "Nalezeno 0 zdrojů", "one": "{count} nalezený zdroj", "other": "{count} nalezených zdrojů" }, "clickToMention": "Zmínit stránku", "uploadFile": "Připojte PDF, textové soubory nebo soubory MarkDown", "questionDetail": "Ahoj {}! Jak ti dnes můžu pomoct?", "indexingFile": "Indexování {}", "generatingResponse": "Generování odpovědi", "selectSources": "Vyberte zdroje", "currentPage": "Aktuální stránka", "sourcesLimitReached": "Můžete vybrat maximálně 3 dokumenty nejvyšší úrovně a jejich podřízené dokumenty", "sourceUnsupported": "V současné době nepodporujeme chatování s databázemi.", "regenerate": "Zkuste to znovu", "addToPageButton": "Přidat zprávu na stránku", "addToPageTitle": "Přidat zprávu do...", "addToNewPage": "Vytvořit novou stránku", "addToNewPageName": "Zprávy extrahované z „{}“", "addToNewPageSuccessToast": "Zpráva přidána do", "openPagePreviewFailedToast": "Nepodařilo se otevřít stránku", "changeFormat": { "actionButton": "Změnit formát", "confirmButton": "Znovu generovat s tímto formátem", "textOnly": "Text", "imageOnly": "Pouze obrázek", "textAndImage": "Text a obrázek", "text": "Odstavec", "bullet": "Seznam s odrážkami", "number": "Číslovaný seznam", "table": "Tabulka", "blankDescription": "Formátovat odpověď", "defaultDescription": "Formát automatické odpovědi", "textWithImageDescription": "@:chat.changeFormat.text s obrázkem" }, "switchModel": { "label": "Změnit model", "localModel": "Lokální model", "cloudModel": "Cloudový model", "autoModel": "Auto" }, "selectBanner": { "saveButton": "Přidat k …", "selectMessages": "Vyberte zprávy", "nSelected": "{} vybráno", "allSelected": "Vše vybráno" }, "stopTooltip": "Zastavit generování" }, "trash": { "text": "Koš", "restoreAll": "Obnovit vše", "restore": "Obnovit", "deleteAll": "Smazat vše", "pageHeader": { "fileName": "Název souboru", "lastModified": "Změněno", "created": "Vytvořeno" }, "confirmDeleteAll": { "title": "Opravdu chcete smazat všechny stránky v Koši?", "caption": "Tento krok nelze vrátit." }, "confirmRestoreAll": { "title": "Opravdu chcete obnovit všechny stránky z Koše?", "caption": "Tuto kci ne" }, "restorePage": { "title": "Obnovit: {}", "caption": "Jste si jisti, že chcete tuto stránku obnovit?" }, "mobile": { "actions": "Koš - akce", "empty": "Koš je prázdný", "emptyDescription": "Nemáte žádný smazaný soubor", "isDeleted": "je smazaný", "isRestored": "je " }, "confirmDeleteTitle": "Opravdu chcete tuto stránku trvale smazat?" }, "deletePagePrompt": { "text": "Tato stránka je v Koši", "restore": "Obnovit stránku", "deletePermanent": "Trvale smazat", "deletePermanentDescription": "Jste si jisti, že chcete tuto stránku trvale smazat? Toto je nevratné." }, "dialogCreatePageNameHint": "Název stránky", "questionBubble": { "shortcuts": "Klávesové zkratky", "whatsNew": "Co je nového?", "helpAndDocumentation": "Nápověda a dokumentace", "getSupport": "Získejte podporu", "markdown": "Markdown", "debug": { "name": "Debug informace", "success": "Debug informace zkopírovány do schránky!", "fail": "Nepodařilo se zkopáí" }, "feedback": "Zpětná vazba", "help": "Pomoc a podpora" }, "menuAppHeader": { "moreButtonToolTip": "Smazat, přejmenovat, a další...", "addPageTooltip": "Rychle přidat podstránku", "defaultNewPageName": "Nepojmenovaný", "renameDialog": "Přejmenovat", "pageNameSuffix": "Kopírovat" }, "noPagesInside": "Neobsahuje žádné stránky", "toolbar": { "undo": "Zpět", "redo": "Znovu", "bold": "Tučné", "italic": "Kurzíva", "underline": "Podtřžení", "strike": "Přeškrtnutí", "numList": "Číslovaný seznam", "bulletList": "Seznam s odrážkami", "checkList": "Zaškrtávací seznam", "inlineCode": "Vložený kód", "quote": "Blok s citací", "header": "Nadpis", "highlight": "Zv", "color": "Barva", "addLink": "Přidat odkaz", "link": "Odkaz" }, "tooltip": { "lightMode": "Přepnout na světlý režim", "darkMode": "Přepnout na tmavý režim", "openAsPage": "Otevřít jako stránku", "addNewRow": "Přidat nový řádek", "openMenu": "Kliknutím otevřete menu", "dragRow": "Dlou", "viewDataBase": "Zobrazit databázi", "referencePage": "Zdroj", "addBlockBelow": "Přidat blok pod", "aiGenerate": "Generovat" }, "sideBar": { "closeSidebar": "Zavřít postranní panel", "openSidebar": "Otevřít postranní panel", "expandSidebar": "Rozbalit na celou stránku", "personal": "Osobní", "private": "Soukromé", "workspace": "Pracovní prostor", "favorites": "Oblíbené", "clickToHidePrivate": "Kliknutím skryjete soukromý prostor\nStránky, které jste zde vytvořili, jsou viditelné pouze pro vás", "clickToHideWorkspace": "Kliknutím skryjete pracovní plochu\nStránky, které jste zde vytvořili, jsou viditelné pro všechny členy", "clickToHidePersonal": "Kliknutím schováte sekci Osobní", "clickToHideFavorites": "Kliknutím schováte sekci Oblíbené", "addAPage": "Přidat stránku", "addAPageToPrivate": "Přidat stránku do soukromého prostoru", "addAPageToWorkspace": "Přidat stránku do pracovního prostoru", "recent": "Nedávné", "today": "Dnes", "thisWeek": "Tento týden", "others": "Dřívější oblíbené", "earlier": "Dříve", "justNow": "právě teď", "minutesAgo": "před {count} minutami", "lastViewed": "Naposledy zobrazené", "favoriteAt": "Přidáno do oblíbených", "emptyRecent": "Žádné nedávné stránky", "emptyRecentDescription": "Jakmile si zobrazíte stránky, objeví se zde pro snadné opětovné zobrazení.", "emptyFavorite": "Žádné oblíbené stránky", "emptyFavoriteDescription": "Označte si stránky jako oblíbené – budou zde uvedeny pro rychlý přístup!", "removePageFromRecent": "Odebrat tuto stránku ze složky Nedávné?", "removeSuccess": "Úspěšně odstraněno", "favoriteSpace": "Oblíbené", "RecentSpace": "Nedávné", "Spaces": "Prostory", "upgradeToPro": "Upgradujte na Pro verzi", "upgradeToAIMax": "Odemkněte neomezenou AI", "storageLimitDialogTitle": "Došlo vám bezplatné úložiště. Upgradujte a odemkněte si neomezené úložiště.", "storageLimitDialogTitleIOS": "Došlo vám bezplatné úložiště.", "aiResponseLimitTitle": "Došly vám bezplatné odpovědi s AI. Přejděte na tarif Pro nebo si zakupte doplněk s AI a odemkněte si neomezený počet odpovědí.", "aiResponseLimitDialogTitle": "Dosažen limit odpovědí umělé inteligence", "aiResponseLimit": "Došly vám bezplatné odpovědi AI.\nPřejděte do Nastavení -> Plán -> Klikněte na AI Max nebo Pro Plan a získejte více odpovědí AI", "askOwnerToUpgradeToPro": "Vašemu pracovnímu prostoru dochází bezplatné úložiště. Požádejte prosím vlastníka pracovního prostoru o upgrade na tarif Pro.", "askOwnerToUpgradeToProIOS": "Vašemu pracovnímu prostoru dochází bezplatné úložiště.", "askOwnerToUpgradeToAIMax": "Ve vašem pracovním prostoru došly bezplatné odpovědi AI. Požádejte vlastníka pracovního prostoru o upgrade tarifu nebo o zakoupení doplňků AI.", "askOwnerToUpgradeToAIMaxIOS": "Vašemu pracovnímu prostoru docházejí bezplatné odpovědi umělé inteligence.", "purchaseAIMax": "Ve vašem pracovním prostoru došly odpovědi AI Image. Požádejte prosím vlastníka pracovního prostoru o zakoupení AI Max.", "aiImageResponseLimit": "Došly vám obrazové odpovědi umělé inteligence.\nPřejděte do Nastavení -> Plán -> Klikněte na AI Max pro získání dalších obrázkových odpovědí s umělou inteligencí.", "purchaseStorageSpace": "Zakoupit úložný prostor", "singleFileProPlanLimitationDescription": "Překročili jste maximální povolenou velikost nahrávaného souboru v bezplatném tarifu. Pro nahrávání větších souborů prosím upgradujte na tarif Pro.", "purchaseAIResponse": "Nákup ", "askOwnerToUpgradeToLocalAI": "Požádejte vlastníka pracovního prostoru o povolení AI na zařízení", "upgradeToAILocal": "Spouštějte lokální modely na svém zařízení pro maximální soukromí", "upgradeToAILocalDesc": "Chatujte s PDF, vylepšete si psaní a automaticky vyplňujte tabulky pomocí lokální umělé inteligence" }, "notifications": { "export": { "markdown": "Poznámka exportována do Markdownu", "path": "Dokumenty/flowy" } }, "contactsPage": { "title": "KontaktyCo se ", "whatsHappening": "Co se děje v tomto týdnu?", "addContact": "Přidat kontakt", "editContact": "Upravit kontakt" }, "button": { "ok": "OK", "confirm": "Potvrdit", "done": "Hotovo", "cancel": "Zrušit", "signIn": "Přihlásit se", "signOut": "Odhlásit se", "complete": "Dokončit", "change": "Změnit", "save": "Uložit", "generate": "Vygenerovat", "esc": "ESC", "keep": "Z", "tryAgain": "Zkusit znovu", "discard": "Zahodit", "replace": "Nahradit", "insertBelow": "Vložit pod", "insertAbove": "Vložit nad", "upload": "Nahrát", "edit": "Upravit", "delete": "Smazat", "copy": "Kopírovat", "duplicate": "Duplikovat", "putback": "Odložit", "update": "Aktualizovat", "share": "Sdílet", "removeFromFavorites": "Odstranit z oblíbených", "removeFromRecent": "Odebrat z nedávných", "addToFavorites": "Přidat do oblíbených", "favoriteSuccessfully": "Úspěšně přidáno do oblíbených", "unfavoriteSuccessfully": "Úspěšně odebráno z oblíbených", "duplicateSuccessfully": "Duplikování proběhlo úspěšné", "rename": "Přejmenovat", "helpCenter": "Centrum pomoci", "add": "Přidat", "yes": "Ano", "no": "Ne", "clear": "Smazat", "remove": "Odebrat", "dontRemove": "Neodebírat", "copyLink": "Kopírovat odkaz", "align": "Zarovnat", "login": "Přihlášení", "logout": "Odhlásit se", "deleteAccount": "Smazat účet", "back": "Zpět", "signInGoogle": "Pokračovat s Googlem", "signInGithub": "Pokračovat s GitHubem", "signInDiscord": "Pokračovat s Discordem", "more": "Více", "create": "Vytvořit", "close": "Zavřít", "next": "Další", "previous": "Předchozí", "submit": "Odeslat", "download": "Stáhnout", "backToHome": "Zpět na domovskou stránku", "viewing": "Prohlížení", "editing": "Úprava", "gotIt": "Rozumím", "retry": "Zkusit znovu", "uploadFailed": "Nahrávání se nezdařilo.", "copyLinkOriginal": "Kopírovat odkaz na originál" }, "label": { "welcome": "Vítejte!", "firstName": "Křestní jméno", "middleName": "Prostřední jméno", "lastName": "Příjmení", "stepX": "Krok {X}" }, "oAuth": { "err": { "failedTitle": "Nepodařilo se připojit k Vašemu účtu.", "failedMsg": "Ujistěte se prosím, že jste se v prohlížeči přihlásili." }, "google": { "title": "Goohle přihlašování", "instruction1": "Tuto aplikaci musíte autorizovat, aby mohla importovat Vaše kontakty z Google Contacts.", "instruction2": "Zkopírujte tento k=od do schránky kliknutím na ikonku nebo označením textu", "instruction3": "Přejděte na tento odkaz kam vložte kód:", "instruction4": "Po dokončení registrace stiskněte tlačítko níže:" } }, "settings": { "title": "Nastavení", "popupMenuItem": { "settings": "Nastavení", "members": "Členové", "trash": "Koš", "helpAndDocumentation": "Nápověda a dokumentace", "getSupport": "Získejte podporu" }, "sites": { "title": "Stránky", "namespaceTitle": "Jmenný prostor", "namespaceDescription": "Spravujte svůj jmenný prostor a domovskou stránku", "namespaceHeader": "Jmenný prostor", "homepageHeader": "Domovská stránka", "updateNamespace": "Aktualizovat jmenný prostor", "removeHomepage": "Odebrat domovskou stránku", "selectHomePage": "Vyberte stránku", "clearHomePage": "Vymazat domovskou stránku pro tento jmenný prostor", "customUrl": "Vlastní URL", "homePage": { "upgradeToPro": "Upgradujte na Pro Plan a nastavte si domovskou stránku" }, "namespace": { "description": "Tato změna se vztahuje na všechny publikované stránky v tomto jmenném prostoru.", "tooltip": "Vyhrazujeme si právo odstranit jakékoli nevhodné jmenné prostory.", "updateExistingNamespace": "Aktualizovat existující jmenný prostor", "upgradeToPro": "Přejděte na tarif Pro a získejte vlastní jmenný prostor.", "redirectToPayment": "Přesměrování na platební stránku...", "onlyWorkspaceOwnerCanSetHomePage": "Domovskou stránku může nastavit pouze vlastník pracovního prostoru", "pleaseAskOwnerToSetHomePage": "Požádejte prosím vlastníka pracovního prostoru o upgrade na tarif Pro." }, "publishedPage": { "title": "Všechny publikované stránky", "description": "Spravujte své publikované stránky", "page": "Strana", "pathName": "Název cesty", "date": "Datum zveřejnění", "emptyHinText": "V tomto pracovním prostoru nemáte žádné zveřejněné stránky.", "noPublishedPages": "Žádné zveřejněné stránky", "settings": "Nastavení zveřejnění", "clickToOpenPageInApp": "Otevřít stránku v aplikaci", "clickToOpenPageInBrowser": "Otevřít stránku v prohlížeči" }, "error": { "failedToGeneratePaymentLink": "Nepodařilo se vygenerovat platební odkaz pro tarif Pro.", "failedToUpdateNamespace": "Nepodařilo se aktualizovat jmenný prostor.", "proPlanLimitation": "Pro aktualizaci jmenného prostoru je nutné upgradovat na tarif Pro.", "namespaceAlreadyInUse": "Jmenný prostor je již obsazen, zkuste prosím jiný", "invalidNamespace": "Neplatný jmenný prostor, zkuste prosím jiný", "namespaceLengthAtLeast2Characters": "Jmenný prostor musí mít délku alespoň 2 znaky", "onlyWorkspaceOwnerCanUpdateNamespace": "Pouze vlastník pracovního prostoru může aktualizovat jmenný prostor", "onlyWorkspaceOwnerCanRemoveHomepage": "Domovskou stránku může odstranit pouze vlastník pracovního prostoru.", "setHomepageFailed": "Nastavení domovské stránky se nezdařilo", "namespaceTooLong": "Jmenný prostor je příliš dlouhý, zkuste prosím jiný", "namespaceTooShort": "Jmenný prostor je příliš krátký, zkuste prosím jiný", "namespaceIsReserved": "Jmenný prostor je rezervovaný, zkuste prosím jiný", "updatePathNameFailed": "Nepodařilo se aktualizovat název cesty", "removeHomePageFailed": "Odstranění domovské stránky se nezdařilo", "publishNameContainsInvalidCharacters": "Název cesty obsahuje neplatné znaky, zkuste prosím jiný.", "publishNameTooShort": "Název cesty je příliš krátký, zkuste prosím jiný", "publishNameTooLong": "Název cesty je příliš dlouhý, zkuste prosím jiný", "publishNameAlreadyInUse": "Název cesty se již používá, zkuste prosím jiný" } }, "accountPage": { "login": { "title": "Přihlášení k účtu", "loginLabel": "Přihlásit se", "logoutLabel": "Odhlásit se" } }, "workspacePage": { "menuLabel": "Pracovní prostor", "title": "Pracovní prostor", "workspaceName": { "title": "Název pracovního prostoru" }, "workspaceIcon": { "title": "Ikona pracovního prostoru", "description": "Nahrajte obrázek nebo použijte emoji pro svůj pracovní prostor. Ikona se zobrazí v postranním panelu a v oznámeních." }, "appearance": { "options": { "light": "Světlý", "dark": "Tmavý" } }, "textDirection": { "leftToRight": "Zleva doprava", "rightToLeft": "Zprava doleva" }, "layoutDirection": { "leftToRight": "Zleva doprava", "rightToLeft": "Zprava doleva" }, "dateTime": { "title": "Datum a čas", "24HourTime": "24 hodinový čas", "dateFormat": { "label": "Formát datumu", "local": "Místní", "us": "US" } }, "language": { "title": "Jazyk" }, "deleteWorkspacePrompt": { "title": "Smazat pracovní prostor", "content": "Opravdu chcete smazat tento pracovní prostor? Tuto akci nelze vrátit zpět a všechny zveřejněné stránky budou zrušeny." }, "leaveWorkspacePrompt": { "title": "Opustit pracovní prostor", "fail": "Nepodařilo se opustit pracovní prostor." } }, "menu": { "appearance": "Vzhled", "language": "Jazyk", "user": "Uživatel", "files": "Soubory", "notifications": "Upozornění", "open": "Otevřít nastavení", "logout": "Odhlásit se", "logoutPrompt": "Opravdu se chcete odhlásit?", "selfEncryptionLogoutPrompt": "Opravdu se chcete odhlásit? Ujistěte se prosím, že jste si zkopírovali šifrovací klíč", "syncSetting": "Synchronizovat nastavení", "enableSync": "Zapnout synchronizaci", "enableEncrypt": "Šifrovat data", "cloudURL": "URL adresa serveru", "cloudAppFlowy": "@:appName Cloud Beta", "enableEncryptPrompt": "Zapněte šifrování a zabezpečte svá ", "inputEncryptPrompt": "Vložte prosím Váš šifrovací klíč k", "clickToCopySecret": "Kliknutím zkopírujete šifrovací klíč", "inputTextFieldHint": "Váš klíč", "historicalUserList": "Historie přihlášení uživatele", "historicalUserListTooltip": "V tomto seznamu vidíte anonymní účty. Kliknutím na účet zobrazíte jeho detaily. Anonymní účty vznikají kliknutím na tlačítko \"Začínáme\"", "openHistoricalUser": "Kliknutím založíte anonymní účet", "customPathPrompt": "Uložením složky s daty @:appName ve složce synchronizovanéí jako např. Google Drive může nést rizika. Pokud se databáze v složce navštíví nebo změní ", "cloudSetting": "Nastavení cloudu" }, "notifications": { "enableNotifications": { "label": "Po", "hint": "Vypn" } }, "appearance": { "resetSetting": "Obnovit tato nastavení", "fontFamily": { "label": "Písmo", "search": "Vyhledávání" }, "themeMode": { "label": "Téma vzhledu", "light": "Světlý vzhled", "dark": "Tmavý vzhled", "system": "Přizpůsobit systému" }, "layoutDirection": { "label": "Směr zobrazení", "hint": "Ovládejte tok obsahu na ", "ltr": "Zleva doprava", "rtl": "Zprava doleva" }, "textDirection": { "label": "Výchozí směr textu", "hint": "Vyberte, jestli má text ve výchozím nastavení začínat zprava nebo zleva.", "ltr": "Zleva doprava", "rtl": "Zprava doleva", "auto": "Automaticky", "fallback": "Stejné jako směr rozvržení" }, "themeUpload": { "button": "Nahrát", "uploadTheme": "Nahrát motiv vzhledu", "description": "Nahrajte vlastní motiv vzhledu pro @:appName stisknutím tlačítka níže.", "loading": "Prosím počkejte dokud nedokončíme kontrolu a nahrávání vašeho motivu vzhledu...", "uploadSuccess": "Váš motiv vzhledu byl úspěšně nahrán", "deletionFailure": "Nepodařilo se smazat motiv vzhledu. Zkuste ho smazat ručně.", "filePickerDialogTitle": "Vyberte soubor typu .flowy_plugin", "urlUploadFailure": "Nepodařilo se otevřít URL adresu: {}", "failure": "Nahrané téma vzhledu má neplatný formát." }, "theme": "Motiv vzhledu", "builtInsLabel": "Vestavěné motivy vzhledu", "pluginsLabel": "Dopl", "dateFormat": { "label": "Formát data", "local": "Místní", "us": "US", "iso": "ISO", "friendly": "Přátelský", "dmy": "D/M/Y" }, "timeFormat": { "label": "Formát času", "twelveHour": "12hodinový", "twentyFourHour": "24hodinový" }, "showNamingDialogWhenCreatingPage": "Zobrazit d" }, "files": { "copy": "Kopíá", "defaultLocation": "Umístění pro čtení a ukládání dat", "exportData": "Exportovat data", "doubleTapToCopy": "Dvojitým klepnutím zkopírujete cestu", "restoreLocation": "Obnovit výchozí @:appName cestu", "customizeLocation": "OtevřítProsím tre další složku", "restartApp": "Aby se projevily změny, restartujte prosím aplikaci.", "exportDatabase": "Exportovat databázi", "selectFiles": "Vyberte soubory k exportování", "selectAll": "Označit vše", "deselectAll": "Odznačit vše", "createNewFolder": "V", "createNewFolderDesc": "Řekněte nám, kam uložit Vaše data", "defineWhereYourDataIsStored": "Vyberte kde jsou ukládána Vaše data", "open": "Otevřít", "openFolder": "Otevřít existující složku", "openFolderDesc": "Číst a zapisovat do existující @:appName složky", "folderHintText": "název složky", "location": "Vytváření nové složky", "locationDesc": "Vyberte název pro složku, kam bude @:appName ukládat Vaše data", "browser": "Procházet", "create": "Vytvořit", "set": "Nastavit", "folderPath": "Umístění složkyU", "locationCannotBeEmpty": "Umístění nesmí být prázdné", "pathCopiedSnackbar": "Umístění souboru zkopírováno do schránky", "changeLocationTooltips": "Změnit složku pro ukládání dat", "change": "Změnit", "openLocationTooltips": "Otevřít jinou složku pro ukládání dat", "openCurrentDataFolder": "Otevřít současnou složku pro ukládání dat", "recoverLocationTooltips": "Obnovit výchozí složku s daty ", "exportFileSuccess": "Soubor byl úspěšně exportován!", "exportFileFail": "Soubor se nepodařilo exportovat!", "export": "Exportovat" }, "user": { "name": "Jméno", "email": "E-mail", "tooltipSelectIcon": "Vyberte ikonu", "selectAnIcon": "Vyberte ikonu", "pleaseInputYourOpenAIKey": "Prosím vložte svůj AI klíč", "clickToLogout": "Klin", "pleaseInputYourStabilityAIKey": "Prosím vložte svůj Stability AI klíč" }, "mobile": { "personalInfo": "Osobní informace", "username": "Uživatelské jméno", "usernameEmptyError": "Uživatelské jméno nesmí být prázdné", "about": "O aplikaci", "pushNotifications": "Push upozornění", "support": "Podpora", "joinDiscord": "Přidejte se k nám na Discordu", "privacyPolicy": "Podmínky použití osobních údajů", "userAgreement": "Uživatels", "userprofileError": "Nepodařilo se načíst uživatelský profil", "userprofileErrorDescription": "Prosím zkuste se odhlásit a znovu přihlásit a zkontrolujte, zda problém přetrvává" }, "shortcuts": { "shortcutsLabel": "Klávesové zkratky", "command": "Příkaz", "keyBinding": "Přiřazená klávesa", "addNewCommand": "Přidat nový příkaz", "updateShortcutStep": "Stiskněte požadovanou kombinaci kláves a stiskněte ENTER", "shortcutIsAlreadyUsed": "Tato zkratka je již použita pro: @@", "resetToDefault": "Obnovit výchozí klávesové zkratky", "couldNotLoadErrorMsg": "Nepodařilo se načíst klávesové zkratky, zkuste to znovu", "couldNotSaveErrorMsg": "Nepodařilo seuložit klávesové zkta" } }, "grid": { "deleteView": "Opravdu chcete tento pohled odstranit?", "createView": "Nový", "title": { "placeholder": "Bez nýzvu" }, "settings": { "filter": "Filtr", "sort": "Řadit", "sortBy": "Řadit podle", "properties": "Vlastnosti", "reorderPropertiesTooltip": "Přetažením uspořádáte vlastnosti", "group": "Skupiny", "addFilter": "Přidat filtr", "deleteFilter": "Smazat filtr", "filterBy": "Filtrovat podle...", "typeAValue": "Napište hodnotu...", "layout": "Rozložení", "databaseLayout": "Rozložení" }, "textFilter": { "contains": "Obsahuje", "doesNotContain": "Neobsahuje", "endsWith": "Končí", "startWith": "Začíná", "is": "Je", "isNot": "Není", "isEmpty": "Je pr", "isNotEmpty": "Není prázdné", "choicechipPrefix": { "isNot": "Kromě", "startWith": "Začíná", "endWith": "Končí", "isEmpty": "je prázdné", "isNotEmpty": "není prázdné" } }, "checkboxFilter": { "isChecked": "Zaškrtnuto", "isUnchecked": "Nezaškrtnuto", "choicechipPrefix": { "is": "je" } }, "checklistFilter": { "isComplete": "je hotový", "isIncomplted": "není hotový" }, "selectOptionFilter": { "is": "Je", "isNot": "Není", "contains": "Obsahuje", "doesNotContain": "Neobsahuje", "isEmpty": "Je prázdné", "isNotEmpty": "Není prázdný" }, "field": { "hide": "Schovat", "show": "Ukázat", "insertLeft": "Vložit vlevo", "insertRight": "Vložit vpravo", "duplicate": "Duplikovat", "delete": "Smazat", "textFieldName": "Text", "checkboxFieldName": "Zaškrtávací políčko", "dateFieldName": "Datum", "updatedAtFieldName": "Datum poslední úpravy", "createdAtFieldName": "Datum vytvoření", "numberFieldName": "Čísla", "singleSelectFieldName": "Výběr", "multiSelectFieldName": "Multivýběr", "urlFieldName": "URL adresa", "checklistFieldName": "Zaškrtávací seznam", "numberFormat": "Formát čásel", "dateFormat": "Formát data", "includeTime": "Včetně času", "isRange": "Datum kon", "dateFormatFriendly": "Měsíc Den, Rok", "dateFormatISO": "Rok-Měsíc-Den", "dateFormatLocal": "Měsíc/Den/Rok", "dateFormatUS": "Rok/Měsíc/Day", "dateFormatDayMonthYear": "Den/Měsíc/Rok", "timeFormat": "Formát času", "invalidTimeFormat": "Neplatný formát", "timeFormatTwelveHour": "12hodinový", "timeFormatTwentyFourHour": "24hodinový", "clearDate": "Vyčistit datum", "dateTime": "Vyčistit čas", "startDateTime": "Datum a čas začátku", "endDateTime": "Datum a čas konce", "failedToLoadDate": "Nepodařilo načíst datum", "selectTime": "Vyberte čas", "selectDate": "Vyberte datum", "visibility": "Viditelnost", "propertyType": "Typ vlastnosti", "addSelectOption": "Přidat možnost", "optionTitle": "Možnosti", "addOption": "Přidat možnost", "editProperty": "Upravit vlastnost", "newProperty": "Nová vlastnost", "deleteFieldPromptMessage": "Jste si jistí? Tato vlastnost bude smazána", "newColumn": "Nový sloupeček" }, "rowPage": { "newField": "Přidat nové pole", "fieldDragElementTooltip": "Kli", "showHiddenFields": { "one": "Zobrazit {} skryté pole", "many": "Zobrazit {} skrytá pole", "other": "Zobrazit {} skrytá pole" }, "hideHiddenFields": { "one": "Skrýt {} skryté pole", "many": "Skrýt {} skrytá pole", "other": "Skrýt {} skrytá pole" } }, "sort": { "ascending": "Vzestupně", "descending": "Sestupně", "deleteAllSorts": "Smazat všechna řazení", "addSort": "Přidat řazení" }, "row": { "duplicate": "Duplikovat", "delete": "Smazat", "titlePlaceholder": "Bez názvu", "textPlaceholder": "Prázdné", "copyProperty": "Vlastnost zkopírována do schránky", "count": "Počet", "newRow": "Nový řádek", "action": "Akce", "add": "Kliknutím přidáte pod", "drag": "Přetáhnout podržením", "dragAndClick": "Přetáhnout podržením, kliknutí otevírá menu", "insertRecordAbove": "Vložte záznam nad", "insertRecordBelow": "Vložte záznam pod" }, "selectOption": { "create": "Vytvořit", "purpleColor": "Fialová", "pinkColor": "Růžová", "lightPinkColor": "Světle růžová", "orangeColor": "Oranžová", "yellowColor": "Žlutá", "limeColor": "Limetková", "greenColor": "Zelená", "aquaColor": "Akvamarínová", "blueColor": "Modrá", "deleteTag": "Smazat štítek", "colorPanelTitle": "Barvy", "panelTitle": "Vyberte nebo vytvořte možnost", "searchOption": "Hledat možnost", "searchOrCreateOption": "Hledat nebo vytvořit možnost...", "createNew": "Vytvořit novou", "orSelectOne": "Nebo vybrat možnost" }, "checklist": { "taskHint": "Popis úkolu", "addNew": "Přidat úkol", "submitNewTask": "Vytvořit", "hideComplete": "Skrýt dokončené úkoly", "showComplete": "Zobrazit všechny úkoly" }, "menuName": "Mřížka", "referencedGridPrefix": "Pohled na" }, "document": { "menuName": "Dokument", "date": { "timeHintTextInTwelveHour": "01:00 Odpoledne", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Propojit s nástěnkou", "createANewBoard": "Vytvořit novou nástěnku" }, "grid": { "selectAGridToLinkTo": "Propojit s mřížkou", "createANewGrid": "Vytvořit mřížku" }, "calendar": { "selectACalendarToLinkTo": "Propojit s kalendářem", "createANewCalendar": "Vyto" }, "document": { "selectADocumentToLinkTo": "Propojit s dokumentem" } }, "selectionMenu": { "outline": "Obrys", "codeBlock": "Blok kódu" }, "plugins": { "referencedBoard": "Odkazovaná nástěnka", "referencedGrid": "Odkazovaná mřížka", "referencedCalendar": "Odkazovaný kalendář", "referencedDocument": "Odkazovaný dokument", "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Zeptej se AI na cokoliv...", "autoGeneratorLearnMore": "Zjistit více", "autoGeneratorGenerate": "Vygenerovat", "autoGeneratorHintText": "Zeptat se AI...", "autoGeneratorCantGetOpenAIKey": "Nepodařilo se získat klíč AI", "autoGeneratorRewrite": "Přepsat", "smartEdit": "AI asistenti", "aI": "AI", "smartEditFixSpelling": "Opravit pravopis", "warning": "⚠️ odpovědi AI mohou být nepřesné nebo zavádějící.", "smartEditSummarize": "Shrnout", "smartEditImproveWriting": "Vylepšit styl psaní", "smartEditMakeLonger": "Prodloužit", "smartEditCouldNotFetchResult": "Nepodařilo se stáhnout výsledek z AI", "smartEditCouldNotFetchKey": "Nepodařilo se stáhnout klíč AI", "smartEditDisabled": "Propojit s AI v Nastavení", "discardResponse": "Opravdu chcete zahodit odpovědi od AI?", "createInlineMathEquation": "Vytvořit rovnici", "fonts": "Písma", "toggleList": "Rozbalovací seznam", "quoteList": "Seznam citátů", "numberedList": "Číslovaný seznam", "bulletedList": "Odrážkový seznam", "todoList": "Úkolníček", "callout": "Vyhlásit", "cover": { "changeCover": "Změnit přebal", "colors": "Barvy", "images": "Obrázky", "clearAll": "Vyčistit vše", "abstract": "Abstraktní", "addCover": "Přidat přebal", "addLocalImage": "Přidat obrázek z lokálního úložiště", "invalidImageUrl": "URL adresa obrázku je neplatná", "failedToAddImageToGallery": "Nepodařilo se přidat obrázek do galerie", "enterImageUrl": "Zadejte URL adresu obrázku", "add": "Přidat", "back": "Zpět", "saveToGallery": "Uložit do galerie", "removeIcon": "Smazat ikonu", "pasteImageUrl": "Vložit URL adresu obrázku", "or": "NEBO", "pickFromFiles": "Vyberte ze souborů", "couldNotFetchImage": "Nepodařilo se načíst obrázky", "imageSavingFailed": "Ukládání obrázku se nezdařilo", "addIcon": "Přidat ikonu", "changeIcon": "Změnit ikonu", "coverRemoveAlert": "Po smazání bude odstraněno také z přebalu.", "alertDialogConfirmation": "Jste si jistí, že chcete pokračovat?" }, "mathEquation": { "name": "Matematick", "addMathEquation": "Přidat TeX ", "editMathEquation": "Upravit matematickou rovnici" }, "optionAction": { "click": "Kliknutím", "toOpenMenu": " otevřete menu", "delete": "Smazat", "duplicate": "Duplikovat", "turnInto": "Změnit na", "moveUp": "Posunout nahoru", "moveDown": "Posunout dolů", "color": "Barva", "align": "Zarovnání", "left": "Vlevo", "center": "Doprostřed", "right": "Vpravo", "defaultColor": "Výchozí" }, "image": { "addAnImage": "Přidat obrázek", "copiedToPasteBoard": "Odkaz na obrázek byl zkopírován do schránky" }, "outline": { "addHeadingToCreateOutline": "Přidáním nadpisů vytvoříte obsah dokumentu" }, "table": { "addAfter": "Přidat za", "addBefore": "Přidat před", "delete": "Smazat", "clear": "Vyčistit obsah", "duplicate": "Duplikovat", "bgColor": "Barva pozadí" }, "contextMenu": { "copy": "Kopírovat", "cut": "Vyjmout", "paste": "Vložit" }, "action": "Příkazy" }, "textBlock": { "placeholder": "Napište \"/\" pro zadání příkazu" }, "title": { "placeholder": "Bez názvu" }, "imageBlock": { "placeholder": "Kliknutím přidáte obrázek", "upload": { "label": "Nahrát", "placeholder": "Kliknutím nahrajete obrázek" }, "url": { "label": "URL adresa obrázku", "placeholder": "Vlože URL adresu obrázku" }, "ai": { "label": "Vygenerujte obrázek pomocí AI", "placeholder": "Prosím vlo" }, "stability_ai": { "label": "Generovat obrázek ze Stability AI", "placeholder": "Zadejte prosím prompt pro generování obrázku pomocí Stability AI" }, "support": "Maximální velikost obrázku je 5MB. Podporované formáty: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Neplatný obrázek", "invalidImageSize": "Velikost obrázku musí být menší než 5MB", "invalidImageFormat": "Formát obrázku není podporovaný. Podporované formáty: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Neplatná URL adresa obrázku" }, "embedLink": { "label": "Vložit odkaz (embed)", "placeholder": "Vložte nebo napište odkaz na obrázek" }, "searchForAnImage": "Hledat obrázek", "pleaseInputYourOpenAIKey": "zadejte prosím svůj AI klíč v Nastavení", "saveImageToGallery": "Uložit obrázek", "failedToAddImageToGallery": "Nepodařilo se přidat obrázek do galerie", "successToAddImageToGallery": "Obrázek byl úspěšně přidán do galerie", "unableToLoadImage": "Nepodařilo se nahrát obrázek", "pleaseInputYourStabilityAIKey": "prosím vložte svůjStability AI klíč v Nastavení" }, "codeBlock": { "language": { "label": "Jazyk", "placeholder": "Vyberte jazyk" } }, "inlineLink": { "placeholder": "Vložte nebo napište odkaz", "openInNewTab": "Otevřít v novém panelu", "copyLink": "Kopírovat odkaz", "removeLink": "Odstranit odkaz", "url": { "label": "URL adresa odkazu", "placeholder": "Zadejte URL adresu odkazu" }, "title": { "label": "Název odkazu", "placeholder": "Zadejte název odkazu" } }, "mention": { "placeholder": "Označit člověka, stránku nebo datum...", "page": { "label": "Odkaz na stránku", "tooltip": "Kliknutím otevřete stránku" } }, "toolbar": { "resetToDefaultFont": "Obnovit výchozí" }, "errorBlock": { "theBlockIsNotSupported": "Aktuální verze tento blok nepodporuje.", "blockContentHasBeenCopied": "Obsah bloku byl zkopírován." } }, "board": { "column": { "createNewCard": "Nová", "renameGroupTooltip": "Zmáčknutím přejmenujete skupinu", "createNewColumn": "Přidat novou skupinu", "addToColumnTopTooltip": "Přidá novou kartičku nahoru", "renameColumn": "Přejmenovat", "hideColumn": "Skrýt" }, "hiddenGroupSection": { "sectionTitle": "Skryté skupiny", "collapseTooltip": "Skrýt skryté skupiny", "expandTooltip": "Zobrazit skryté skupiny" }, "cardDetail": "Detail kartičky", "cardActions": "Kartička - příkazy", "cardDuplicated": "Kartička byla duplikována", "cardDeleted": "Kartička smazána", "menuName": "Nástěnka", "showUngrouped": "Zobrazit položky bez skupiny", "ungroupedButtonText": "Bez skupiny", "ungroupedButtonTooltip": "Obsahuje karty, které ", "ungroupedItemsTitle": "Kliknutím přidáte na nástěnku", "groupBy": "Seskupit podle", "referencedBoardPrefix": "Pohled", "mobile": { "editURL": "Upravit URL adresu", "showGroup": "Zobrazit skupinu", "showGroupContent": "Opravdu chcete tuto skupinu zobrazit na nástěnce?", "failedToLoad": "Zobrazení desky se nepodařilo načíst" } }, "calendar": { "menuName": "Kalendář", "defaultNewCalendarTitle": "Nepojmenovaný", "newEventButtonTooltip": "Přidat novou událost", "navigation": { "today": "Dnes", "jumpToday": "Přejít na dnešek", "previousMonth": "Předchozí měsíc", "nextMonth": "Následující měsíc" }, "settings": { "showWeekNumbers": "Zobrazit číslo týdne", "showWeekends": "Zobrazit víkendy", "firstDayOfWeek": "Počátek týdne", "layoutDateField": "Rozložit kalendář podle", "noDateTitle": "Žádné datum", "noDateHint": { "zero": "Zde uvidíte nenaplánované události", "one": "{} nenaplánovaná událost", "other": "{} nenaplánovaných událostí" }, "clickToAdd": "Přidat do kalendáře", "name": "Rozložení kalendáře" }, "referencedCalendarPrefix": "Pohled na" }, "errorDialog": { "title": "Chyba @:appName", "howToFixFallback": "Omlouváme se za nepříjemnost! Pošlete hlášení na náš GitHub, kde popíšete chybu na kterou jste narazili.", "github": "Zobrazit na GitHubu" }, "search": { "label": "Hledat", "placeholder": { "actions": "Hledat - příkazy..." } }, "message": { "copy": { "success": "Zkopírováno!", "fail": "Nepodařilo se zkopírovat" } }, "unSupportBlock": "Aktuální verze tento blok nepodporuje.", "views": { "deleteContentTitle": "Opravdu chcete smazat {pageType}?", "deleteContentCaption": "pokud " }, "colors": { "custom": "Vlastní", "default": "Výchozí", "red": "Červená", "orange": "Oranžová", "yellow": "Žlutá", "green": "Zelená", "blue": "Modrá", "purple": "Fialová", "pink": "Růžová", "brown": "Hnědá", "gray": "Šedá" }, "emoji": { "emojiTab": "Emoji", "search": "Hledat emoji", "noRecent": "Žádné nedávné emoji", "noEmojiFound": "Nenalezeno žádné emoji", "filter": "Filtr", "random": "Náhodný", "selectSkinTone": "Vybrat tón pleti", "remove": "Smazat emoji", "categories": { "smileys": "Smajlíci a emoce", "people": "Lidé a tělo", "animals": "Zvířata a příroda", "food": "Jídlo a pití", "activities": "Aktivity", "places": "Cestování a místa", "objects": "Věci", "symbols": "Symboly", "flags": "Vlajky", "nature": "Příroda", "frequentlyUsed": "Často používané" }, "skinTone": { "default": "Výchozí", "light": "Světlý", "mediumLight": "Středně světlý", "medium": "Střední", "mediumDark": "Středně tmavý", "dark": "Tmavý" } }, "inlineActions": { "noResults": "Žádné výsledky", "pageReference": "Odkazovaná stránka", "date": "Datum", "reminder": { "groupTitle": "Připomenutí", "shortKeyword": "připomenout" } }, "datePicker": { "dateTimeFormatTooltip": "Formát data a času změníte v Nastavení" }, "relativeDates": { "yesterday": "Včera", "today": "Dnes", "tomorrow": "Zítra", "oneWeek": "1 týden" }, "notificationHub": { "title": "Upozornění", "emptyTitle": "Nic Vám neuteklo!", "emptyBody": "Žádné nevyřízené akce nebo upozornění. Užijte si klid.", "tabs": { "inbox": "Schránka", "upcoming": "Nadcházející" }, "actions": { "markAllRead": "Označit vše jako přečtené", "showAll": "Vše", "showUnreads": "Nepřečtené" }, "filters": { "ascending": "Vzestupně", "descending": "Sestupně", "groupByDate": "Seskupit podle data", "showUnreadsOnly": "Zobrazit pouze nepřečtené", "resetToDefault": "Obnovit výchozí" } }, "reminderNotification": { "title": "Upomínka", "message": "Nezapomeňte to zkontrolovat než zapomenete!", "tooltipDelete": "Smazat", "tooltipMarkRead": "Označit jako přečtené", "tooltipMarkUnread": "Označit jako nepřečtené" }, "findAndReplace": { "find": "Najít", "previousMatch": "Předchozí shoda", "nextMatch": "Další shoda", "close": "Zavřít", "replace": "Nahradit", "replaceAll": "Nahradit vše", "noResult": "Žádné výsledky", "caseSensitive": "Citlivý na malá/velká písmena" }, "error": { "weAreSorry": "Omluváme se", "loadingViewError": "Nedaří se nám načíst tento pohled. Zkontrolujte prosím Vaše internetové připojení, obnovte aplikaci a neváhejte nás kontaktovat pokud problém přetrvává." }, "editor": { "bold": "Tučné", "bulletedList": "Odrážkový seznam", "checkbox": "Zaškrtávací políčko", "embedCode": "Vložit kód (embed)", "heading1": "Nadpis 1", "heading2": "Nadpis 2", "heading3": "Nadpis 3", "highlight": "Zvýrazenění", "color": "Barva", "image": "Obrázek", "italic": "Kurzíva", "link": "Odkaz", "numberedList": "Číslovaný seznam", "quote": "Citace", "strikethrough": "Přeškrtnutí", "text": "Text", "underline": "Podtržení", "fontColorDefault": "Výchozí", "fontColorGray": "Šedá", "fontColorBrown": "Hnědá", "fontColorOrange": "Oranžová", "fontColorYellow": "Žlutá", "fontColorGreen": "Zelená", "fontColorBlue": "Modrá", "fontColorPurple": "Fialová", "fontColorPink": "Růžová", "fontColorRed": "Červená", "backgroundColorDefault": "Výchozí pozadí", "backgroundColorGray": "Šedé pozadí", "backgroundColorBrown": "Hnědé pozadí", "backgroundColorOrange": "Oranžové pozadí", "backgroundColorYellow": "Žluté pozadí", "backgroundColorGreen": "Zelené pozadí", "backgroundColorBlue": "Modré pozadí", "backgroundColorPurple": "Fialové pozadí", "backgroundColorPink": "Růžové pozadí", "backgroundColorRed": "Červené pozadí", "done": "Hotovo", "cancel": "Zrušit", "tint1": "Odstín 1", "tint2": "Odstín 2", "tint3": "Odstín 3", "tint4": "Odstín 4", "tint5": "Odstín 5", "tint6": "Odstín 6", "tint7": "Odstín 7", "tint8": "Odstín 8", "tint9": "Odstín 9", "lightLightTint1": "Fialová", "lightLightTint2": "Růžová", "lightLightTint3": "Lehce růžová", "lightLightTint4": "Oranžová", "lightLightTint5": "Žlutá", "lightLightTint6": "Limetková", "lightLightTint7": "Zelená", "lightLightTint8": "Akvamarínová", "lightLightTint9": "Modrá", "urlHint": "URL adresa", "mobileHeading1": "Nadpis 1", "mobileHeading2": "Nadpis 2", "mobileHeading3": "Nadpis 3", "textColor": "Barva textu", "backgroundColor": "Barva pozadí", "addYourLink": "Přidejte odkaz", "openLink": "Otevřít odkaz", "copyLink": "Kopírovat odkaz", "removeLink": "Smazat odkaz", "editLink": "Upravit odkaz", "linkText": "Text", "linkTextHint": "Zadejte prosím text", "linkAddressHint": "Zadejte prosím URL adresu", "highlightColor": "Barva zvýraznění", "clearHighlightColor": "Obnovit barvu zvýraznění", "customColor": "Vlastní barva", "hexValue": "Hex hodnota", "opacity": "Průhlednost", "resetToDefaultColor": "Obnovit výchozí barvu", "ltr": "Zleva doprava", "rtl": "Zprava doleva", "auto": "Automaticky", "cut": "Vyjmout", "copy": "Kopírovat", "paste": "Vložit", "find": "Najít", "previousMatch": "Předchozí shoda", "nextMatch": "Další shoda", "closeFind": "Zavřít", "replace": "Nahradit", "replaceAll": "Nahradit vše", "regex": "Regulární výraz", "caseSensitive": "Citlivý na malá/velká písmena", "uploadImage": "Nahrát obrázek", "urlImage": "URL adresa obrázku", "incorrectLink": "Nesprávný odkaz", "upload": "Nahrát", "chooseImage": "Vyberte obrázek", "loading": "Načítání", "imageLoadFailed": "Nepodařilo se načíst obrázek", "divider": "Oddělovač", "table": "Tabulka", "colAddBefore": "Přidat před", "rowAddBefore": "Přidat za", "colAddAfter": "Přidat po", "rowAddAfter": "Přidat po", "colRemove": "Odstranit", "rowRemove": "Odstranit", "colDuplicate": "Duplikovat", "rowDuplicate": "Duplikovat", "colClear": "Vyčistit obsah", "rowClear": "Vyčistit obsah", "slashPlaceHolder": "Zadejte \"/\" pro vložení bloku, nebo začněte psát" }, "favorite": { "noFavorite": "Žádné oblíbené stránky", "noFavoriteHintText": "Swipnutím doleva přidáte stránku do oblíbených" }, "cardDetails": { "notesPlaceholder": "Napište / k " }, "blockPlaceholders": { "todoList": "Úkolníček", "bulletList": "Seznam", "numberList": "Seznam", "quote": "Citace", "heading": "Nadpis {}" }, "titleBar": { "pageIcon": "Ikona stránky", "language": "Jazyk", "font": "Písmo", "actions": "Příkazy" } } ================================================ FILE: frontend/resources/translations/de-DE.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Ich", "welcomeText": "Willkommen bei @:appName", "welcomeTo": "Willkommen zu", "githubStarText": "Mit einem Stern auf GitHub markieren", "subscribeNewsletterText": "Abonniere den Newsletter", "letsGoButtonText": "Los geht's", "title": "Titel", "youCanAlso": "Du kannst auch", "and": "und", "failedToOpenUrl": "URL konnte nicht geöffnet werden: {}", "blockActions": { "addBelowTooltip": "Unten klicken um etwas hinzuzufügen", "addAboveCmd": "Alt+Klick", "addAboveMacCmd": "Option+Klick", "addAboveTooltip": "oben hinzufügen", "dragTooltip": "Drag to Drop", "openMenuTooltip": "Klicken, um das Menü zu öffnen" }, "signUp": { "buttonText": "Registrieren", "title": "Registriere dich bei @:appName", "getStartedText": "Erste Schritte", "emptyPasswordError": "Passwort darf nicht leer sein", "repeatPasswordEmptyError": "Passwortwiederholung darf nicht leer sein", "unmatchedPasswordError": "Passwörter stimmen nicht überein", "alreadyHaveAnAccount": "Hast du schon ein Account?", "emailHint": "E-Mail", "passwordHint": "Passwort", "repeatPasswordHint": "Passwort wiederholen", "signUpWith": "Anmelden mit:" }, "signIn": { "loginTitle": "Bei @:appName einloggen", "loginButtonText": "Anmelden", "loginStartWithAnonymous": "Anonyme Sitzung starten", "continueAnonymousUser": "in anonymer Sitzung fortfahren", "buttonText": "Anmelden", "signingInText": "Anmelden...", "forgotPassword": "Passwort vergessen?", "emailHint": "E-Mail", "passwordHint": "Passwort", "dontHaveAnAccount": "Noch kein Konto?", "createAccount": "Benutzerkonto erstellen", "repeatPasswordEmptyError": "Passwortwiederholung darf nicht leer sein", "unmatchedPasswordError": "Passwörter stimmen nicht überein", "syncPromptMessage": "Synchronisation kann ein paar Minuten dauern. Diese Seite bitte nicht schließen", "or": "ODER", "signInWithGoogle": "Mit Google anmelden", "signInWithGithub": "Mit Github anmelden", "signInWithDiscord": "Mit Discord anmelden", "signInWithApple": "Weiter mit Apple", "continueAnotherWay": "Anders fortfahren", "signUpWithGoogle": "Mit Google registrieren", "signUpWithGithub": "Mit Github registrieren", "signUpWithDiscord": "Mit Discord registrieren", "signInWith": "Anmeldeoptionen:", "signInWithEmail": "Mit E-Mail anmelden", "signInWithMagicLink": "Mit Authentifizierungslink anmelden", "signUpWithMagicLink": "Mit Authentifizierungslink registrieren", "pleaseInputYourEmail": "Gib bitte deine E-Mail-Adresse ein", "settings": "Einstellungen", "magicLinkSent": "Wir haben dir einen Authentifizierungslink per E-Mail geschickt. Klicke auf den Link, um dich anzumelden.", "invalidEmail": "Bitte gib eine gültige E-Mail-Adresse ein", "alreadyHaveAnAccount": "Du hast bereits ein Konto?", "logIn": "Anmeldung", "generalError": "Etwas ist schiefgelaufen. Bitte versuche es später noch einmal", "limitRateError": "Aus Sicherheitsgründen kannst du nur alle 60 Sekunden einen Authentifizierungslink anfordern", "magicLinkSentDescription": "Ein Magic Link wurde an deine E-Mail-Adresse gesendet. Klicke auf den Link, um deine Anmeldung abzuschließen. Der Link läuft nach 5 Minuten ab.", "anonymous": "Anonym" }, "workspace": { "chooseWorkspace": "Arbeitsbereich wählen", "defaultName": "Mein Arbeitsbereich", "create": "Arbeitsbereich erstellen", "importFromNotion": "Von Notion importieren", "learnMore": "Mehr erfahren", "reset": "Arbeitsbereich zurücksetzen", "renameWorkspace": "Arbeitsbereich umbenennen", "workspaceNameCannotBeEmpty": "Arbeitsbereichname darf nicht leer sein", "resetWorkspacePrompt": "Das Zurücksetzen des Arbeitsbereiches löscht alle enthaltenen Seiten und Daten. Bist du sicher, dass du den Arbeitsbereich zurücksetzen möchtest? ", "hint": "Arbeitsbereich", "notFoundError": "Arbeitsbereich nicht gefunden", "failedToLoad": "Etwas ist schief gelaufen! Der Arbeitsbereich konnte nicht geladen werden. Versuche, alle @:appName Instanzen zu schließen und versuche es erneut.", "errorActions": { "reportIssue": "Problem melden", "reportIssueOnGithub": "Melde ein Problem auf GitHub", "exportLogFiles": "Exportiere Log-Dateien", "reachOut": "Kontaktiere uns auf Discord" }, "menuTitle": "Arbeitsbereiche", "deleteWorkspaceHintText": "Sicher, dass du deinen Arbeitsbereich löschen möchtest? Dies kann nicht mehr Rückgängig gemacht werden.", "createSuccess": "Arbeitsbereich erfolgreich erstellt", "createFailed": "Der Arbeitsbereich konnte nicht erstellt werden", "createLimitExceeded": "Du hast die für dein Benutzerkonto maximal zulässige Anzahl an Arbeitsbereichen erreicht. Benötigst du zum fortsetzen deiner Arbeit noch weitere Arbeitsbereiche, erstelle auf GitHub bitte eine entsprechende Anfrage.", "deleteSuccess": "Arbeitsbereich erfolgreich gelöscht", "deleteFailed": "Der Arbeitsbereich konnte nicht gelöscht werden", "openSuccess": "Arbeitsbereich erfolgreich geöffnet", "openFailed": "Der Arbeitsbereich konnte nicht geöffnet werden", "renameSuccess": "Arbeitsbereich erfolgreich umbenannt", "renameFailed": "Der Arbeitsbereich konnte nicht umbenannt werden", "updateIconSuccess": "Arbeitsbereich erfolgreich zurückgesetzt", "updateIconFailed": "Der Arbeitsbereich konnte nicht zurückgesetzt werden", "cannotDeleteTheOnlyWorkspace": "Der einzig vorhandene Arbeitsbereich kann nicht gelöscht werden", "fetchWorkspacesFailed": "Arbeitsbereiche konnten nicht abgerufen werden!", "leaveCurrentWorkspace": "Arbeitsbereich verlassen", "leaveCurrentWorkspacePrompt": "Möchtest du den aktuellen Arbeitsbereich wirklich verlassen?" }, "shareAction": { "buttonText": "Teilen", "workInProgress": "Demnächst verfügbar", "markdown": "Markdown", "html": "HTML", "clipboard": "In die Zwischenablage kopieren", "csv": "CSV", "copyLink": "Link kopieren", "publishToTheWeb": "Im Web veröffentlichen", "publishToTheWebHint": "Erstelle eine Website mit AppFlowy", "publish": "Veröffentlichen", "unPublish": "Veröffentlichung aufheben", "visitSite": "Seite aufrufen", "exportAsTab": "Exportieren als", "publishTab": "Veröffentlichen", "shareTab": "Teilen", "publishOnAppFlowy": "Auf AppFlowy veröffentlichen", "shareTabTitle": "Zum Mitmachen einladen", "shareTabDescription": "Für eine einfache Zusammenarbeit mit allen", "copyLinkSuccess": "Link in die Zwischenablage kopiert", "copyShareLink": "Link zum Teilen kopieren", "copyLinkFailed": "Link konnte nicht in die Zwischenablage kopiert werden", "copyLinkToBlockSuccess": "Blocklink in die Zwischenablage kopiert", "copyLinkToBlockFailed": "Blocklink konnte nicht in die Zwischenablage kopiert werden", "manageAllSites": "Alle Seiten verwalten", "updatePathName": "Pfadnamen aktualisieren" }, "moreAction": { "small": "klein", "medium": "mittel", "large": "groß", "fontSize": "Schriftgröße", "import": "Importieren", "moreOptions": "Weitere Optionen", "wordCount": "Wortanzahl: {}", "charCount": "Zeichenanzahl: {}", "createdAt": "Erstellt am: {}", "deleteView": "Löschen", "duplicateView": "Duplizieren" }, "importPanel": { "textAndMarkdown": "Text & Markdown", "documentFromV010": "Dokument ab v0.1.0", "databaseFromV010": "Datenbank ab v0.1.0", "notionZip": "von Notion exportierte Zip-Datei", "csv": "CSV", "database": "Datenbank" }, "disclosureAction": { "rename": "Umbenennen", "delete": "Löschen", "duplicate": "Duplizieren", "unfavorite": "Aus Favoriten entfernen", "favorite": "Zu Favoriten hinzufügen", "openNewTab": "In einem neuen Tab öffnen", "moveTo": "Verschieben nach", "addToFavorites": "Zu Favoriten hinzufügen", "copyLink": "Link kopieren", "changeIcon": "Symbol ändern", "collapseAllPages": "Alle Seiten einklappen" }, "blankPageTitle": "Leere Seite", "newPageText": "Neue Seite", "newDocumentText": "Neues Dokument", "newGridText": "Neue Datentabelle", "newCalendarText": "Neuer Kalender", "newBoardText": "Neues Board", "chat": { "newChat": "Neuer Chat", "inputMessageHint": "Nachricht an @:appName AI", "inputLocalAIMessageHint": "Nachricht an @:appName Lokale KI", "unsupportedCloudPrompt": "Diese Funktion ist nur bei Verwendung der @:appName Cloud verfügbar", "relatedQuestion": "Verwandt", "serverUnavailable": "Dienst vorübergehend nicht verfügbar. Bitte versuche es später erneut.", "aiServerUnavailable": "Beim Generieren einer Antwort ist ein Fehler aufgetreten.", "retry": "Wiederholen", "clickToRetry": "Erneut versuchen", "regenerateAnswer": "Regenerieren", "question1": "Wie verwendet man Kanban zur Aufgabenverwaltung?", "question2": "Erkläre mir die GTD-Methode", "question3": "Warum sollte ich Rust verwenden?", "question4": "Gebe mir ein Rezept mit dem, was in meiner Küche ist", "question5": "Eine Illustration für meine Seite erstellen", "question6": "Erstelle eine To-Do-Liste für meine kommende Woche", "aiMistakePrompt": "KI kann Fehler machen. Überprüfe wichtige Informationen.", "chatWithFilePrompt": "Möchtest du mit der Datei chatten?", "indexFileSuccess": "Datei erfolgreich indiziert", "inputActionNoPages": "Keine Seitenergebnisse", "referenceSource": { "zero": "0 Quellen gefunden", "one": "{count} Quelle gefunden", "other": "{count} Quellen gefunden" }, "clickToMention": "Klicke hier, um eine Seite zu erwähnen", "uploadFile": "Lade PDF-, md- oder txt-Dateien in den Chat hoch", "questionDetail": "Hallo {}! Wie kann ich dir heute helfen?", "indexingFile": "Indizierung {}", "generatingResponse": "Antwort generieren", "selectSources": "Quellen auswählen", "regenerate": "Versuchen Sie es erneut", "addToPageButton": "Zur Seite hinzufügen", "addToPageTitle": "Nachricht hinzufügen an...", "addToNewPage": "Zu einer neuen Seite hinzufügen" }, "trash": { "text": "Papierkorb", "restoreAll": "Alles wiederherstellen", "restore": "Wiederherstellen", "deleteAll": "Alles löschen", "pageHeader": { "fileName": "Dateiname", "lastModified": "Letzte Änderung", "created": "Erstellt" }, "confirmDeleteAll": { "title": "Bist du dir sicher? Das löscht alle Seiten in den Papierkorb.", "caption": "Diese Aktion kann nicht rückgängig gemacht werden." }, "confirmRestoreAll": { "title": "Möchtest du wirklich alle Seiten aus dem Papierkorb wiederherstellen?", "caption": "Diese Aktion kann nicht rückgängig gemacht werden." }, "restorePage": { "title": "Wiederherstellen: {}", "caption": "Möchten Sie diese Seite wirklich wiederherstellen?" }, "mobile": { "actions": "Papierkorb-Einstellungen", "empty": "Der Papierkorb ist leer.", "emptyDescription": "Es sind keine gelöschten Dateien vorhanden.", "isDeleted": "wurde gelöscht", "isRestored": "wurde wiederhergestellt" }, "confirmDeleteTitle": "Bist du dir sicher, dass du diese Seite unwiderruflich löschen möchtest?" }, "deletePagePrompt": { "text": "Diese Seite befindet sich im Papierkorb", "restore": "Seite wiederherstellen", "deletePermanent": "Dauerhaft löschen", "deletePermanentDescription": "Möchten Sie diese Seite wirklich dauerhaft löschen? Dies kann nicht rückgängig gemacht werden." }, "dialogCreatePageNameHint": "Seitenname", "questionBubble": { "shortcuts": "Tastenkürzel", "whatsNew": "Was gibt es Neues?", "markdown": "Markdown", "debug": { "name": "Debug-Informationen", "success": "Debug-Informationen in die Zwischenablage kopiert!", "fail": "Debug-Informationen konnten nicht in die Zwischenablage kopiert werden" }, "feedback": "Feedback", "help": "Hilfe & Support" }, "menuAppHeader": { "moreButtonToolTip": "Entfernen, umbenennen und mehr...", "addPageTooltip": "Schnell eine Seite hineinfügen", "defaultNewPageName": "Unbenannt", "renameDialog": "Umbenennen" }, "noPagesInside": "Keine Unterseiten", "toolbar": { "undo": "Rückgängig", "redo": "Wiederherstellen", "bold": "Fett", "italic": "Kursiv", "underline": "Unterstrichen", "strike": "Durchstrichen", "numList": "Nummerierte Liste", "bulletList": "Aufzählung", "checkList": "Checkliste", "inlineCode": "Inline-Code", "quote": "Zitat", "header": "Überschrift", "highlight": "Hervorhebung", "color": "Farbe", "addLink": "Link hinzufügen", "link": "Link" }, "tooltip": { "lightMode": "In den hellen Modus wechseln", "darkMode": "In den dunklen Modus wechseln", "openAsPage": "Als Seite öffnen", "addNewRow": "Neue Zeile hinzufügen", "openMenu": "Menü öffnen", "dragRow": "Gedrückt halten, um die Zeile neu anzuordnen", "viewDataBase": "Datenbank ansehen", "referencePage": "Auf diesen {Name} wird verwiesen", "addBlockBelow": "Einen Block hinzufügen", "aiGenerate": "Erzeugen", "urlLaunchAccessory": "Im Browser öffnen", "urlCopyAccessory": "Webadresse kopieren.", "genSummary": "Zusammenfassung generieren" }, "sideBar": { "closeSidebar": "Seitenleiste schließen", "openSidebar": "Seitenleiste öffnen", "personal": "Persönlich", "private": "Privat", "workspace": "Arbeitsbereich", "favorites": "Favoriten", "clickToHidePrivate": "Hier klicken, um den privaten Bereich auszublenden.\nVon dir hier erstellte Seiten sind nur für dich sichtbar.", "clickToHideWorkspace": "Klicken, um den Arbeitsbereich auszublenden\nDie hier erstellten Seiten sind für jedes Mitglied sichtbar", "clickToHidePersonal": "Klicken, um den persönlichen Abschnitt zu verbergen", "clickToHideFavorites": "Klicken, um Favoriten zu verbergen", "addAPage": "Seite hinzufügen", "addAPageToPrivate": "Eine Seite zum privaten Bereich hinzufügen.", "addAPageToWorkspace": "Eine Seite zum Arbeitsbereich hinzufügen", "recent": "Kürzlich", "today": "Heute", "thisWeek": "Diese Woche", "others": "Andere", "earlier": "Früher", "justNow": "soeben", "minutesAgo": "vor {count} Minuten", "lastViewed": "Zuletzt angesehen", "favoriteAt": "Zu Favoriten hinzugefügt bei", "emptyRecent": "Keine aktuellen Dokumente", "emptyRecentDescription": "Kürzlich aufgerufene Dokumente werden hier, zur vereinfachten Auffindbarkeit, angezeigt.", "emptyFavorite": "Keine favorisierten Dokumente", "emptyFavoriteDescription": "Beginne mit der Erkundung und markiere Dokumente als Favoriten. Diese werden hier für den schnellen Zugriff aufgelistet!", "removePageFromRecent": "Diese Seite aus „Kürzlich“ entfernen?", "removeSuccess": "Erfolgreich entfernt", "favoriteSpace": "Favoriten", "RecentSpace": "Kürzlich", "Spaces": "Bereiche", "upgradeToPro": "Upgrade auf Pro", "upgradeToAIMax": "Schalte unbegrenzte KI frei", "storageLimitDialogTitle": "Dein freier Speicherplatz ist aufgebraucht. Upgrade deinen Plan, um unbegrenzten Speicherplatz freizuschalten.", "storageLimitDialogTitleIOS": "Ihr freier Speicherplatz ist aufgebraucht.", "aiResponseLimitTitle": "Du hast keine kostenlosen KI-Antworten mehr. Upgrade auf den Pro-Plan oder kaufe ein KI-Add-on, um unbegrenzte Antworten freizuschalten", "aiResponseLimitDialogTitle": "Limit für KI-Antworten erreicht", "aiResponseLimit": "Du hast keine kostenlosen KI-Antworten mehr zur Verfügung.\n\nGehe zu Einstellungen -> Plan -> Klicke auf KI Max oder Pro Plan, um mehr KI-Antworten zu erhalten", "askOwnerToUpgradeToPro": "Dein Arbeitsbereich hat nicht mehr genügend freien Speicherplatz. Bitte den Eigentümer deines Arbeitsbereichs, auf den Pro-Plan hochzustufen.", "askOwnerToUpgradeToProIOS": "In Ihrem Arbeitsbereich ist nicht mehr genügend freier Speicherplatz verfügbar.", "askOwnerToUpgradeToAIMax": "In deinem Arbeitsbereich sind die kostenlosen KI-Antworten aufgebraucht. Bitte den Eigentümer deines Arbeitsbereichs, den Plan zu wechseln oder KI-Add-ons zu erwerben.", "askOwnerToUpgradeToAIMaxIOS": "In Ihrem Arbeitsbereich gehen die kostenlosen KI-Antworten aus.", "purchaseStorageSpace": "Speicherplatz kaufen", "singleFileProPlanLimitationDescription": "Sie haben die maximal zulässige Datei-Uploadgröße im kostenlosen Plan überschritten. Bitte aktualisieren Sie auf den Pro-Plan, um größere Dateien hochzuladen", "purchaseAIResponse": "Kaufen ", "askOwnerToUpgradeToLocalAI": "Bitte den Arbeitsbereichsbesitzer, KI auf dem Gerät zu aktivieren.", "upgradeToAILocal": "KI offline auf Ihrem Gerät", "upgradeToAILocalDesc": "Chatte mit PDFs, verbessere deine Schreibfähigkeiten und fülle Tabellen automatisch mithilfe lokaler KI aus.", "public": "Öffentlich", "clickToHidePublic": "Hier klicken, um den öffentlichen Bereich auszublenden.\nHier erstellte Seiten sind für jedes Mitglied sichtbar.", "addAPageToPublic": "Eine Seite zur öffentlichen Domäne hinzufügen." }, "notifications": { "export": { "markdown": "Notiz nach Markdown exportiert", "path": "Dokumente/flowy" } }, "contactsPage": { "title": "Kontakte", "whatsHappening": "Was passiert diese Woche?", "addContact": "Kontakte hinzufügen", "editContact": "Kontakte bearbeiten" }, "button": { "ok": "OK", "confirm": "Bestätigen", "done": "Erledigt!", "cancel": "Abbrechen", "signIn": "Anmelden", "signOut": "Abmelden", "complete": "Fertig", "save": "Speichern", "generate": "Erstellen", "esc": "ESC", "keep": "behalten", "tryAgain": "Nochmal versuchen", "discard": "Verwerfen", "replace": "Ersetzen", "insertBelow": "Unten einfügen", "insertAbove": "Oben einfügen", "upload": "Hochladen", "edit": "Bearbeiten", "delete": "Löschen", "copy": "kopieren", "duplicate": "Duplikat", "putback": "wieder zurückgeben", "update": "Update", "share": "Teilen", "removeFromFavorites": "Aus den Favoriten entfernen", "removeFromRecent": "Aus „Kürzlich“ entfernen", "addToFavorites": "Zu den Favoriten hinzufügen", "favoriteSuccessfully": "Erfolgreich favorisiert", "unfavoriteSuccessfully": "Erfolgreich entfavorisiert", "duplicateSuccessfully": "Erfolgreich dupliziert", "rename": "Umbenennen", "helpCenter": "Hilfe Center", "add": "Hinzufügen", "yes": "Ja", "no": "Nein", "clear": "Leeren", "remove": "Entfernen", "dontRemove": "Nicht entfernen", "copyLink": "Link kopieren", "align": "zentrieren", "login": "Anmelden", "logout": "Abmelden", "deleteAccount": "Benutzerkonto löschen", "back": "Zurück", "signInGoogle": "Mit einem Google Benutzerkonto anmelden", "signInGithub": "Mit einem Github Benutzerkonto anmelden", "signInDiscord": "Mit einem Discord Benutzerkonto anmelden", "more": "Mehr", "create": "Erstellen", "close": "Schließen", "next": "Weiter", "previous": "Zurück", "submit": "Einreichen", "download": "Herunterladen", "backToHome": "Zurück zur Startseite", "viewing": "anschauen", "editing": "Bearbeiten", "gotIt": "Verstanden" }, "label": { "welcome": "Willkommen!", "firstName": "Vorname", "middleName": "Zweiter Vorname", "lastName": "Nachname", "stepX": "Schritt {X}" }, "oAuth": { "err": { "failedTitle": "Keine Verbindung zum Konto möglich.", "failedMsg": "Prüfe, ob der Anmeldevorgang im Browser abgeschlossen wurde." }, "google": { "title": "Google Sign-In", "instruction1": "Um die Google-Kontakte zu importieren, muss die Anwendung über den Webbrowser autorisiert werden.", "instruction2": "Kopiere den Code in die Zwischenablage, über das Symbol oder indem du den Text auswählst:", "instruction3": "Rufe den folgenden Link im Webbrowser auf und gebe den Code ein:", "instruction4": "Klicke unten auf die Schaltfläche, wenn die Anmeldung abgeschlossen ist:" } }, "settings": { "title": "Einstellungen", "popupMenuItem": { "settings": "Einstellungen", "members": "Mitglieder", "trash": "Müll", "helpAndSupport": "Hilfe & Unterstützung" }, "sites": { "title": "Seiten", "namespaceTitle": "Namensraum", "namespaceDescription": "Verwalten Sie Ihren Namespace und Ihre Startseite", "namespaceHeader": "Namensraum", "homepageHeader": "Startseite", "updateNamespace": "Namespace aktualisieren", "removeHomepage": "Startseite entfernen", "selectHomePage": "Seite auswählen", "clearHomePage": "Löschen Sie die Startseite für diesen Namensraum", "customUrl": "Benutzerdefinierte URL", "namespace": { "description": "Diese Änderung gilt für alle veröffentlichten Seiten in diesem Namespace.", "tooltip": "Wir behalten uns das Recht vor, unangemessene Namespaces zu entfernen", "updateExistingNamespace": "Vorhandenen Namensraum aktualisieren", "upgradeToPro": "Aktualisieren Sie auf den Pro-Plan, um eine Startseite einzurichten", "redirectToPayment": "Weiterleitung zur Zahlungsseite ...", "onlyWorkspaceOwnerCanSetHomePage": "Nur der Arbeitsbereichsbesitzer kann eine Startseite festlegen", "pleaseAskOwnerToSetHomePage": "Bitten Sie den Arbeitsbereichsbesitzer, auf den Pro-Plan zu aktualisieren" }, "publishedPage": { "title": "Alle veröffentlichten Seiten", "description": "Verwalten Sie Ihre veröffentlichten Seiten", "page": "Seite", "pathName": "Pfadname", "date": "Veröffentlichungsdatum", "emptyHinText": "Sie haben keine veröffentlichten Seiten in diesem Arbeitsbereich", "noPublishedPages": "Keine veröffentlichten Seiten", "settings": "Veröffentlichungseinstellungen", "clickToOpenPageInApp": "Seite in App öffnen", "clickToOpenPageInBrowser": "Seite im Browser öffnen" }, "error": { "failedToGeneratePaymentLink": "Zahlungslink für Pro Plan konnte nicht generiert werden", "failedToUpdateNamespace": "Namensraum konnte nicht aktualisiert werden", "proPlanLimitation": "Sie müssen auf den Pro-Plan upgraden, um den Namespace zu aktualisieren", "namespaceAlreadyInUse": "Der Namespace ist bereits vergeben, bitte versuchen Sie es mit einem anderen", "invalidNamespace": "Ungültiger Namensraum, bitte versuchen Sie einen anderen", "namespaceLengthAtLeast2Characters": "Der Namensraum muss mindestens 2 Zeichen lang sein", "onlyWorkspaceOwnerCanUpdateNamespace": "Nur der Arbeitsbereichsbesitzer kann den Namespace aktualisieren", "onlyWorkspaceOwnerCanRemoveHomepage": "Nur der Arbeitsbereichsbesitzer kann die Homepage entfernen", "setHomepageFailed": "Startseite konnte nicht eingerichtet werden", "namespaceTooLong": "Der Namensraum ist zu lang. Bitte versuchen Sie es mit einem anderen.", "namespaceTooShort": "Der Namensraum ist zu kurz, bitte versuchen Sie es mit einem anderen", "namespaceIsReserved": "Der Namensraum ist reserviert, bitte versuchen Sie es mit einem anderen", "updatePathNameFailed": "Pfadname konnte nicht aktualisiert werden", "removeHomePageFailed": "Startseite konnte nicht entfernt werden", "publishNameContainsInvalidCharacters": "Der Pfadname enthält ungültige Zeichen. Bitte versuchen Sie es mit einem anderen.", "publishNameTooShort": "Der Pfadname ist zu kurz, bitte versuchen Sie es mit einem anderen", "publishNameTooLong": "Der Pfadname ist zu lang, bitte versuchen Sie es mit einem anderen", "publishNameAlreadyInUse": "Der Pfadname wird bereits verwendet. Bitte versuchen Sie einen anderen.", "namespaceContainsInvalidCharacters": "Der Namespace enthält ungültige Zeichen. Bitte versuchen Sie es mit einem anderen.", "publishPermissionDenied": "Nur der Arbeitsbereichsbesitzer oder Seitenherausgeber kann die Veröffentlichungseinstellungen verwalten", "publishNameCannotBeEmpty": "Der Pfadname darf nicht leer sein. Bitte versuchen Sie es mit einem anderen." }, "success": { "namespaceUpdated": "Namensraum erfolgreich aktualisiert", "setHomepageSuccess": "Startseite erfolgreich eingerichtet", "updatePathNameSuccess": "Pfadname erfolgreich aktualisiert", "removeHomePageSuccess": "Startseite erfolgreich entfernt" } }, "accountPage": { "menuLabel": "Mein Konto", "title": "Mein Konto", "general": { "title": "Kontoname und Profilbild", "changeProfilePicture": "Profilbild ändern" }, "email": { "title": "E-Mail", "actions": { "change": "E-Mail ändern" } }, "login": { "title": "Kontoanmeldung", "loginLabel": "Anmeldung", "logoutLabel": "Ausloggen" }, "description": "Passe dein Profil an, verwalte deine Sicherheitseinstellungen und KI API-Schlüssel oder melde dich bei deinem Konto an." }, "workspacePage": { "menuLabel": "Arbeitsbereich", "title": "Arbeitsbereich", "description": "Passe das Erscheinungsbild, das Design, die Schriftart, das Textlayout, das Datums-/Zeitformat und die Sprache deines Arbeitsbereiches an.", "workspaceName": { "title": "Name des Arbeitsbereiches", "savedMessage": "Name des Arbeitsbereiches gespeichert", "editTooltip": "Name des Arbeitsbereiches ändern" }, "workspaceIcon": { "title": "Symbol", "description": "Lade ein Bild hoch oder verwende ein Emoji für deinen Arbeitsbereich. Das Symbol wird in deiner Seitenleiste und in deinen Benachrichtigungen angezeigt." }, "appearance": { "title": "Aussehen", "description": "Passe das Erscheinungsbild, das Design, die Schriftart, das Textlayout, das Datums-/Zeitformat und die Sprache deines Arbeitsbereiches an.", "options": { "system": "Auto", "light": "Hell", "dark": "Dunkel" } }, "resetCursorColor": { "title": "Farbe des Dokumentcursors zurücksetzen", "description": "Möchtest du die Cursorfarbe wirklich zurücksetzen?" }, "resetSelectionColor": { "title": "Dokumentauswahlfarbe zurücksetzen", "description": "Möchtest du die Auswahlfarbe wirklich zurücksetzen?" }, "resetWidth": { "resetSuccess": "Dokumentbreite erfolgreich zurückgesetzt" }, "theme": { "title": "Design", "description": "Wähle ein voreingestelltes Design aus oder lade dein eigenes benutzerdefiniertes Design hoch.", "uploadCustomThemeTooltip": "Ein benutzerdefiniertes Theme hochladen" }, "workspaceFont": { "title": "Schriftart", "noFontHint": "Keine Schriftart gefunden, versuchen Sie einen anderen Begriff." }, "textDirection": { "title": "Textrichtung", "leftToRight": "Links nach rechts", "rightToLeft": "Rechts nach links", "auto": "Auto", "enableRTLItems": "RTL-Symbolleistenelemente aktivieren" }, "layoutDirection": { "title": "Layoutrichtung", "leftToRight": "Links nach rechts", "rightToLeft": "Rechts nach links" }, "dateTime": { "title": "Datum & Zeit", "example": "{} um {} ({})", "24HourTime": "24-Stunden-Zeit", "dateFormat": { "label": "Datumsformat", "local": "Lokal", "us": "US", "iso": "ISO", "friendly": "Leserlich", "dmy": "T/M/J" } }, "language": { "title": "Sprache" }, "deleteWorkspacePrompt": { "title": "Arbeitsbereich löschen", "content": "Möchtest du diesen Arbeitsbereich wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." }, "leaveWorkspacePrompt": { "title": "Arbeitsbereich verlassen", "content": "Möchtest du diesen Arbeitsbereich wirklich verlassen? Du verlierst den Zugriff auf alle darin enthaltenen Seiten und Daten.", "success": "Sie haben den Arbeitsbereich erfolgreich verlassen.", "fail": "Das Verlassen des Arbeitsbereichs ist fehlgeschlagen." }, "manageWorkspace": { "title": "Arbeitsbereich verwalten", "leaveWorkspace": "Arbeitsbereich verlassen", "deleteWorkspace": "Arbeitsbereich löschen" } }, "manageDataPage": { "menuLabel": "Daten verwalten", "title": "Daten verwalten", "description": "Verwalte den lokalen Datenspeicher oder importiere deine vorhandenen Daten in @:appName. Du kannst deine Daten mit Ende-zu-Ende-Verschlüsselung absichern.", "dataStorage": { "title": "Speicherort", "tooltip": "Das Verzeichnis, in dem deine Dateien gespeichert sind", "actions": { "change": "Pfad ändern", "open": "Ordner öffnen", "openTooltip": "Aktuellen Speicherort des Datenordners öffnen", "copy": "Pfad kopieren", "copiedHint": "Link kopiert!", "resetTooltip": "Auf Standardspeicherort zurücksetzen" }, "resetDialog": { "title": "Bist du sicher?", "description": "Durch das Zurücksetzen des Pfads auf das Standardverzeichnis werden deine Daten nicht gelöscht. Wenn du deine aktuellen Daten erneut importieren möchtest, solltest du zuerst den Pfad deines aktuellen Speicherorts kopieren." } }, "importData": { "title": "Daten importieren", "tooltip": "Daten aus @:appName Backups-/Datenordnern importieren", "description": "Daten aus einem externen @:appName Datenordner kopieren und in den aktuellen @:appName Datenordner importieren", "action": "Ordner durchsuchen" }, "encryption": { "title": "Verschlüsselung", "tooltip": "Verwalte, wie deine Daten gespeichert und verschlüsselt werden", "descriptionNoEncryption": "Durch das Einschalten der Verschlüsselung werden alle Daten verschlüsselt. Dieser Vorgang kann nicht rückgängig gemacht werden.", "descriptionEncrypted": "Deine Daten sind verschlüsselt.", "action": "Daten verschlüsseln", "dialog": { "title": "Alle deine Daten verschlüsseln?", "description": "Durch die Verschlüsselung all deiner Daten bleiben diese sicher und geschützt. Diese Aktion kann NICHT rückgängig gemacht werden. Möchtest du wirklich fortfahren?" } }, "cache": { "title": "Cache leeren", "description": "Wenn Bilder nicht geladen werden oder Schriftarten nicht richtig angezeigt werden, versuche den Cache zu leeren. Deine Benutzerdaten werden dadurch nicht gelöscht.", "dialog": { "title": "Bist du sicher?", "description": "Durch das Leeren des Caches werden Bilder und Schriftarten beim Laden erneut heruntergeladen. Deine Daten werden durch diese Aktion weder entfernt noch geändert.", "successHint": "Cache geleert!" } }, "data": { "fixYourData": "Deine Daten korrigieren", "fixButton": "Korrigieren", "fixYourDataDescription": "Wenn du Probleme mit deinen Daten hast, kannst du hier versuchen, diese zu beheben." } }, "shortcutsPage": { "menuLabel": "Tastenkombinationen", "title": "Shortcuts", "editBindingHint": "Neue Verknüpfung eingeben", "searchHint": "Suchen", "actions": { "resetDefault": "Standardeinstellung zurücksetzen" }, "errorPage": { "message": "Shortcuts konnten nicht geladen werden: {}", "howToFix": "Bitte versuche es erneut. Wenn das Problem weiterhin besteht, melde es bitte auf GitHub." }, "resetDialog": { "title": "Shortcuts zurücksetzen", "description": "Dies wird alle deine Shortcuts auf die Standardeinstellungen zurücksetzen, dies kann nicht rückgängig gemacht werden. Bist du dir sicher, dass du fortfahren möchtest?", "buttonLabel": "Zurücksetzen" }, "conflictDialog": { "title": "{} ist derzeit in Verwendung", "descriptionPrefix": "Diese Tastenkombination wird derzeit verwendet von ", "descriptionSuffix": ". Wenn du diese Tastaturbelegung ersetzt, wird sie aus {} entfernt.", "confirmLabel": "Weiter" }, "editTooltip": "Zum Starten der Bearbeitung der Tastaturbelegung drücken.", "keybindings": { "toggleToDoList": "Aufgabenliste ein-/ausblenden", "insertNewParagraphInCodeblock": "Neuen Absatz einfügen", "pasteInCodeblock": "In Codeblock einfügen", "selectAllCodeblock": "Alles auswählen", "indentLineCodeblock": "Zwei Leerzeichen am Zeilenanfang einfügen", "outdentLineCodeblock": "Zwei Leerzeichen am Zeilenanfang löschen", "twoSpacesCursorCodeblock": "Zwei Leerzeichen am Cursor einfügen", "copy": "Auswahl kopieren", "paste": "Inhalt einfügen", "cut": "Auswahl ausschneiden", "alignLeft": "Text links ausrichten", "alignCenter": "Text zentriert ausrichten", "alignRight": "Text rechts ausrichten", "undo": "Undo", "redo": "Redo", "convertToParagraph": "Block in Absatz umwandeln", "backspace": "Löschen", "deleteLeftWord": "Linkes Wort löschen", "deleteLeftSentence": "Linken Satz löschen", "delete": "Rechtes Zeichen löschen", "deleteMacOS": "Linkes Zeichen löschen", "deleteRightWord": "Rechtes Wort löschen", "moveCursorLeft": "Cursor nach links bewegen", "moveCursorBeginning": "Cursor an den Zeilenanfang bewegen", "moveCursorLeftWord": "Cursor ein Wort nach links bewegen", "moveCursorLeftSelect": "Auswählen und Cursor nach links bewegen", "moveCursorBeginSelect": "Auswählen und Cursor an den Zeilenanfang bewegen", "moveCursorLeftWordSelect": "Auswählen und Cursor ein Wort nach links bewegen", "moveCursorRight": "Cursor nach rechts bewegen", "moveCursorEnd": "Cursor an das Zeilenende bewegen", "moveCursorRightWord": "Cursor ein Wort nach rechts bewegen", "moveCursorRightSelect": "Auswählen und Cursor nach rechts bewegen", "moveCursorEndSelect": "Auswählen und Cursor an das Zeilenende bewegen", "moveCursorRightWordSelect": "Markiere das Wort und bewege den Cursor ein Wort nach rechts", "moveCursorUp": "Cursor nach oben bewegen", "moveCursorTopSelect": "Auswählen und Cursor zum Anfang bewegen", "moveCursorTop": "Cursor zum Anfang bewegen", "moveCursorUpSelect": "Auswählen und Cursor nach oben bewegen", "moveCursorBottomSelect": "Auswählen und Cursor ans Ende bewegen", "moveCursorBottom": "Cursor ans Ende bewegen", "moveCursorDown": "Cursor nach unten bewegen", "moveCursorDownSelect": "Auswählen und Cursor nach unten bewegen", "home": "Zum Anfang scrollen", "end": "Zum Ende scrollen", "toggleBold": "Fett ein-/ausschalten", "toggleItalic": "Kursivschrift ein-/ausschalten", "toggleUnderline": "Unterstreichung ein-/ausschalten", "toggleStrikethrough": "Durchgestrichen ein-/ausschalten", "toggleCode": "Inline-Code ein-/ausschalten", "toggleHighlight": "Hervorhebung ein-/ausschalten", "showLinkMenu": "Linkmenü anzeigen", "openInlineLink": "Inline-Link öffnen", "openLinks": "Alle ausgewählten Links öffnen", "indent": "Einzug", "outdent": "Ausrücken", "exit": "Bearbeitung beenden", "pageUp": "Eine Seite nach oben scrollen", "pageDown": "Eine Seite nach unten scrollen", "selectAll": "Alles auswählen", "pasteWithoutFormatting": "Inhalt ohne Formatierung einfügen", "showEmojiPicker": "Emoji-Auswahl anzeigen", "enterInTableCell": "Zeilenumbruch in Tabelle hinzufügen", "leftInTableCell": "In der Tabelle eine Zelle nach links verschieben", "rightInTableCell": "In der Tabelle eine Zelle nach rechts verschieben", "upInTableCell": "In der Tabelle eine Zelle nach oben verschieben", "downInTableCell": "In der Tabelle eine Zelle nach unten verschieben", "tabInTableCell": "Zur nächsten verfügbaren Zelle in der Tabelle gehen", "shiftTabInTableCell": "Zur zuvor verfügbaren Zelle in der Tabelle gehen", "backSpaceInTableCell": "Am Anfang der Zelle anhalten" }, "commands": { "codeBlockNewParagraph": "Füge einen neuen Absatz neben dem Codeblock ein", "codeBlockIndentLines": "Füge am Zeilenanfang im Codeblock zwei Leerzeichen ein", "codeBlockOutdentLines": "Lösche zwei Leerzeichen am Zeilenanfang im Codeblock", "codeBlockAddTwoSpaces": "Einfügen von zwei Leerzeichen an der Cursorposition im Codeblock", "codeBlockSelectAll": "Wähle den gesamten Inhalt innerhalb eines Codeblocks aus", "codeBlockPasteText": "Text in Codeblock einfügen", "textAlignLeft": "Text nach links ausrichten", "textAlignCenter": "Text nach rechts ausrichten", "textAlignRight": "Text rechtsbündig ausrichten" }, "couldNotLoadErrorMsg": "Konnte keine Shortcuts laden, versuche es erneut", "couldNotSaveErrorMsg": "Shortcuts konnten nicht gespeichert werden, versuche es erneut" }, "aiPage": { "title": "KI-Einstellungen", "menuLabel": "KI-Einstellungen", "keys": { "enableAISearchTitle": "KI-Suche", "aiSettingsDescription": "Wähle oder konfiguriere KI-Modelle, die in @:appName verwendet werden. Für eine optimale Leistung empfehlen wir die Verwendung der Standardmodelloptionen", "loginToEnableAIFeature": "KI-Funktionen werden erst nach der Anmeldung bei @:appName Cloud aktiviert. Wenn du kein @:appName-Konto hast, gehe zu „Mein Konto“, um dich zu registrieren", "llmModel": "Sprachmodell", "llmModelType": "Sprachmodelltyp", "downloadLLMPrompt": "Herunterladen {}", "downloadAppFlowyOfflineAI": "Durch das Herunterladen des KI-Offlinepakets kann KI auf deinem Gerät ausgeführt werden. Möchtest du fortfahren?", "downloadLLMPromptDetail": "Das Herunterladen des lokalen Modells {} beansprucht bis zu {} Speicherplatz. Möchtest du fortfahren?", "downloadBigFilePrompt": "Der Download kann etwa 10 Minuten dauern", "downloadAIModelButton": "KI-Modell herunterladen", "downloadingModel": "wird heruntergeladen", "localAILoaded": "Lokales KI-Modell erfolgreich hinzugefügt und einsatzbereit", "localAIStart": "Der lokale KI-Chat beginnt …", "localAILoading": "Das lokale KI-Chat-Modell wird geladen …", "localAIStopped": "Lokale KI wurde gestoppt", "failToLoadLocalAI": "Lokale KI konnte nicht gestartet werden", "restartLocalAI": "Lokale KI neustarten", "disableLocalAITitle": "Lokale KI deaktivieren", "disableLocalAIDescription": "Möchtest du die lokale KI deaktivieren?", "localAIToggleTitle": "Umschalten zum Aktivieren oder Deaktivieren der lokalen KI", "offlineAIInstruction1": "Folge der", "offlineAIInstruction2": "Anweisung", "offlineAIInstruction3": "um Offline-KI zu aktivieren.", "offlineAIDownload1": "Wenn du die AppFlowy KI noch nicht heruntergeladen hast,", "offlineAIDownload2": "lade", "offlineAIDownload3": "sie zuerst herunter", "activeOfflineAI": "Aktiv", "downloadOfflineAI": "Herunterladen", "openModelDirectory": "Ordner öffnen", "title": "KI-API-Schlüssel" } }, "planPage": { "menuLabel": "Plan", "title": "Tarifplan", "planUsage": { "title": "Zusammenfassung der Plannutzung", "storageLabel": "Speicher", "storageUsage": "{} von {} GB", "unlimitedStorageLabel": "Unbegrenzter Speicherplatz", "collaboratorsLabel": "Gastmitarbeiter", "collaboratorsUsage": "{} von {}", "aiResponseLabel": "KI-Antworten", "aiResponseUsage": "{} von {}", "unlimitedAILabel": "Unbegrenzte Antworten", "proBadge": "Pro", "aiMaxBadge": "KI Max", "aiOnDeviceBadge": "KI On-Device", "memberProToggle": "Unbegrenzte Mitgliederzahl", "aiMaxToggle": "Unbegrenzte KI-Antworten", "aiOnDeviceToggle": "KI auf dem Gerät für ultimative Privatsphäre", "aiCredit": { "title": "@:appName KI-Guthaben hinzufügen", "price": "{}", "priceDescription": "für 1.000 Credits", "purchase": "Kauf von KI", "info": "Füge 1.000 KI-Credits pro Arbeitsbereich hinzu und integriere anpassbare KI nahtlos in deinen Arbeitsablauf für intelligentere, schnellere Ergebnisse mit bis zu:", "infoItemOne": "10.000 Antworten pro Datenbank", "infoItemTwo": "1.000 Antworten pro Arbeitsbereich" }, "currentPlan": { "bannerLabel": "Derzeitiger Plan", "freeTitle": "Kostenfrei", "proTitle": "Pro", "teamTitle": "Team", "freeInfo": "Perfekt für Einzelpersonen oder kleine Teams mit bis zu 2 Mitgliedern.", "proInfo": "Perfekt für kleine und mittlere Teams mit bis zu 10 Mitgliedern.", "teamInfo": "Perfekt für alle produktiven und gut organisierten Teams.", "upgrade": "Vergleichen &\n Upgraden", "canceledInfo": "Dein Plan wurde gekündigt und du wirst am {} auf den kostenlosen Plan herabgestuft.", "freeProOne": "Gemeinsamer Arbeitsbereich", "freeProTwo": "Bis zu 2 Mitglieder (inkl. Eigentümer)", "freeProThree": "Unbegrenzte Anzahl an Gästen (nur anzeigen)", "freeProFour": "Speicher 5 GB", "freeProFive": "30 Tage Änderungshistorie", "freeConOne": "Gastmitarbeiter (Bearbeitungszugriff)", "freeConTwo": "Unbegrenzter Speicherplatz", "freeConThree": "6 Monate Änderungshistorie", "professionalProOne": "Gemeinsamer Arbeitsbereich", "professionalProTwo": "Unbegrenzte Mitgliederzahl", "professionalProThree": "Unbegrenzte Anzahl an Gästen (nur anzeigen)", "professionalProFour": "Unbegrenzter Speicherplatz", "professionalProFive": "6 Monate Änderungshistorie", "professionalConOne": "Unbegrenzte Anzahl an Gastmitarbeitern (Bearbeitungszugriff)", "professionalConTwo": "Unbegrenzte KI-Antworten", "professionalConThree": "1 Jahr Änderungshistorie" }, "addons": { "title": "Add-ons", "addLabel": "Hinzufügen", "activeLabel": "Hinzugefügt", "aiMax": { "title": "KI Max", "description": "Schalte unbegrenzte KI frei", "price": "{}", "priceInfo": "pro Benutzer und Monat", "billingInfo": "jährliche Abrechnung oder {} bei monatlicher Abrechnung" }, "aiOnDevice": { "title": "KI On-Device", "description": "KI offline auf deinem Gerät", "price": "{}", "priceInfo": "pro Benutzer und Monat", "recommend": "Empfohlen wird M1 oder neuer", "billingInfo": "jährliche Abrechnung oder {} bei monatlicher Abrechnung" } }, "deal": { "bannerLabel": "Neujahrsangebot!", "title": "Erweiter dein Team!", "info": "Upgraden und 10 % auf Pro- und Team-Pläne sparen! Steiger die Produktivität deines Arbeitsplatzes mit leistungsstarken neuen Funktionen, einschließlich @:appName KI.", "viewPlans": "Pläne anzeigen" }, "guestCollabToggle": "10 Gastmitarbeiter", "storageUnlimited": "Unbegrenzter Speicherplatz mit deinem Pro-Plan" } }, "billingPage": { "menuLabel": "Abrechnung", "title": "Abrechnung", "plan": { "title": "Plan", "freeLabel": "Kostenfrei", "proLabel": "Pro", "planButtonLabel": "Plan ändern", "billingPeriod": "Abrechnungszeitraum", "periodButtonLabel": "Zeitraum bearbeiten" }, "paymentDetails": { "title": "Zahlungsdetails", "methodLabel": "Zahlungsmethode", "methodButtonLabel": "Zahlungsmethode bearbeiten" }, "addons": { "title": "Add-ons", "addLabel": "Hinzufügen", "removeLabel": "Entfernen", "renewLabel": "Erneuern", "aiMax": { "label": "KI Max", "description": "Schalte unbegrenzte KI Antworten und erweiterte Modelle frei", "activeDescription": "Nächste Rechnung fällig am {}", "canceledDescription": "KI Max ist verfügbar bis {}" }, "aiOnDevice": { "label": "KI On-Device", "description": "Schalte unbegrenzte KI Antworten offline auf deinem Gerät frei", "activeDescription": "Nächste Rechnung fällig am {}", "canceledDescription": "KI On-Device ist verfügbar bis {}" }, "removeDialog": { "title": "Entfernen {}", "description": "Möchtest du den {plan} wirklich entfernen? Du verlierst dann sofort den Zugriff auf die Funktionen und Vorteile des {plan}." } }, "currentPeriodBadge": "AKTUELL", "changePeriod": "Zeitraum ändern", "planPeriod": "{} Zeitraum", "monthlyInterval": "Monatlich", "monthlyPriceInfo": "pro Sitzplatz, monatliche Abrechnung", "annualInterval": "Jährlich", "annualPriceInfo": "pro Sitzplatz, jährliche Abrechnung" }, "comparePlanDialog": { "title": "Plan vergleichen & auswählen", "planFeatures": "Plan\nFeatures", "current": "Aktuell", "actions": { "upgrade": "Upgrade", "downgrade": "Downgrade", "current": "Aktuell", "downgradeDisabledTooltip": "Du wirst am Ende des Abrechnungszeitraums automatisch herabgestuft" }, "freePlan": { "title": "Kostenlos", "description": "Für die Organisation jeder Ecke Ihres Lebens und Ihrer Arbeit.", "price": "0€", "priceInfo": "free forever" }, "proPlan": { "title": "Professionell", "description": "Ein Ort für kleine Gruppen zum Planen und Organisieren.", "price": "{} /Monat", "priceInfo": "jährlich abgerechnet" }, "planLabels": { "itemOne": "Arbeitsbereiche", "itemTwo": "Mitglieder", "itemThree": "Gäste", "itemFour": "Gäste", "itemFive": "Speicher", "itemSix": "Zusammenarbeit in Echtzeit", "itemSeven": "Mobile App", "itemFileUpload": "Datei-Uploads", "customNamespace": "Benutzerdefinierter Namensraum", "tooltipSix": "Lebenslang bedeutet, dass die Anzahl der Antworten nie zurückgesetzt wird", "intelligentSearch": "Intelligente Suche", "tooltipSeven": "Ermöglicht dir, einen Teil der URL für deinen Arbeitsbereich anzupassen", "customNamespaceTooltip": "Benutzerdefinierte veröffentlichte Seiten-URL", "tooltipThree": "Gäste haben nur Leserechte für die speziell freigegebenen Inhalte", "tooltipFour": "Gäste werden als ein Sitzplatz abgerechnet", "itemEight": "AI-Antworten", "tooltipEight": "Lebenslang bedeutet, dass die Anzahl der Antworten nie zurückgesetzt wird." }, "freeLabels": { "itemOne": "pro Arbeitsbereich berechnet", "itemTwo": "3", "itemThree": "5 GB", "itemFour": "0", "itemFive": "5 GB", "itemSix": "10 Lebenszeiten", "itemSeven": "ja", "itemFileUpload": "Bis zu 7 MB", "intelligentSearch": "Intelligente Suche", "itemEight": "1.000 lebenslang" }, "proLabels": { "itemOne": "pro Arbeitsbereich berechnet", "itemTwo": "bis zu 10", "itemThree": "unbegrenzt", "itemFour": "10 Gäste werden als ein Sitzplatz berechnet", "itemFive": "unbegrenzt", "itemSix": "ja", "itemSeven": "ja", "itemFileUpload": "Unbegrenzt", "intelligentSearch": "Intelligente Suche", "itemEight": "10.000 monatlich" }, "paymentSuccess": { "title": "Du bist jetzt im {} Plan!", "description": "Deine Zahlung wurde erfolgreich verarbeitet und dein Plan wurde auf @:appName {} aktualisiert. Du kannst die Details deines Plans auf der Seite \"Plan\" einsehen" }, "downgradeDialog": { "title": "Bist du sicher, dass du deinen Plan herabstufen willst?", "description": "Wenn du deinen Plan herabstufst, kehrst du zum kostenfreien Plan zurück. Mitglieder können den Zugang zu Arbeitsbereichen verlieren und du musst möglicherweise Speicherplatz freigeben, um die Speichergrenzen des kostenfreien Tarifs einzuhalten.", "downgradeLabel": "Downgrade Plan" } }, "cancelSurveyDialog": { "title": "Schade dich gehen zu sehen", "description": "Wir bedauern, dass du gehst. Wir würden uns über dein Feedback freuen, das uns hilft @:appName zu verbessern. Bitte nehme dir einen Moment Zeit, um ein paar Fragen zu beantworten.", "commonOther": "Andere", "otherHint": "Schreibe deine Antwort hier", "questionOne": { "question": "Was hat dich dazu veranlasst, dein @:appName Pro-Abonnement zu kündigen?", "answerOne": "Kosten zu hoch", "answerTwo": "Die Funktionen entsprachen nicht den Erwartungen", "answerThree": "Habe eine bessere Alternative gefunden", "answerFour": "Habe es nicht oft genug genutzt, um die Kosten zu rechtfertigen", "answerFive": "Serviceproblem oder technische Schwierigkeiten" }, "questionTwo": { "question": "Wie wahrscheinlich ist es, dass du in Zukunft ein erneutes Abonnement von @:appName Pro in Betracht ziehst?", "answerOne": "Sehr wahrscheinlich", "answerTwo": "Ziemlich wahrscheinlich", "answerThree": "Nicht sicher", "answerFour": "Unwahrscheinlich", "answerFive": "Sehr unwahrscheinlich" }, "questionThree": { "question": "Welche Pro-Funktion hast du während deines Abonnements am meisten geschätzt?", "answerOne": "Zusammenarbeit mehrerer Benutzer", "answerTwo": "Längerer Versionsverlauf", "answerThree": "Unbegrenzte KI-Antworten", "answerFour": "Zugriff auf lokale KI-Modelle" }, "questionFour": { "question": "Wie würdest du deine allgemeine Erfahrung mit @:appName beschreiben?", "answerOne": "Großartig", "answerTwo": "Gut", "answerThree": "Durchschnitt", "answerFour": "Unterdurchschnittlich", "answerFive": "Nicht zufrieden" } }, "common": { "uploadingFile": "Datei wird hochgeladen. Bitte beenden Sie die App nicht.", "uploadNotionSuccess": "Ihre Notion-ZIP-Datei wurde erfolgreich hochgeladen. Sobald der Import abgeschlossen ist, erhalten Sie eine Bestätigungs-E-Mail", "reset": "Zurücksetzen" }, "menu": { "appearance": "Oberfläche", "language": "Sprache", "user": "Nutzer", "files": "Dateien", "notifications": "Benachrichtigungen", "open": "Einstellungen öffnen", "logout": "Abmelden", "logoutPrompt": "Willst du dich wirklich abmelden?", "selfEncryptionLogoutPrompt": "Willst du dich wirklich Abmelden? Bitte stelle sicher, dass der Encryption Secret Code kopiert wurde.", "syncSetting": "Synchronisations-Einstellung", "cloudSettings": "Cloud Einstellungen", "enableSync": "Synchronisation aktivieren", "enableSyncLog": "Synchronisation der Protokolldateien aktivieren", "enableSyncLogWarning": "Vielen Dank für Ihre Hilfe bei der Diagnose von Synchronisierungsproblemen. Dadurch werden Ihre Dokumentänderungen in einer lokalen Datei protokolliert. Bitte beenden Sie die App und öffnen Sie sie erneut, nachdem Sie sie aktiviert haben.", "enableEncrypt": "Daten verschlüsseln", "cloudURL": "Basis URL", "invalidCloudURLScheme": "Ungültiges Format", "cloudServerType": "Cloud Server", "cloudServerTypeTip": "Bitte beachte, dass der aktuelle Benutzer ausgeloggt wird beim wechsel des Cloud-Servers", "cloudLocal": "Lokal", "cloudAppFlowy": "@:appName Cloud Beta", "cloudAppFlowySelfHost": "@:appName Cloud Self-hosted", "appFlowyCloudUrlCanNotBeEmpty": "Die Cloud-URL darf nicht leer sein", "clickToCopy": "Klicken, um zu kopieren", "selfHostStart": "Falls du keinen Server hast, konsultiere bitte", "selfHostContent": "Dokument", "selfHostEnd": "um einen einen eigenen Server aufzusetzen", "pleaseInputValidURL": "Bitte geben Sie eine gültige URL ein", "changeUrl": "Ändern Sie die selbst gehostete URL in {}", "cloudURLHint": "Eingabe der Basis- URL Ihres Servers", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Eingbe der Websocket Adresse Ihres Servers", "restartApp": "Neustart", "restartAppTip": "Programm neustarten, um die Änderungen zu übernehmen. Bitte beachten, dass der aktuelle Account eventuell ausgeloggt wird.", "changeServerTip": "Nach dem Wechsel des Servers muss auf die Schaltfläche „Neustart“ geklickt werden, damit die Änderungen wirksam werden", "enableEncryptPrompt": "Verschlüsselung aktivieren, um deine Daten mit dem Secret Key zu verschlüsseln. Verwahre den Schlüssel sicher! \nEinmal aktiviert kann es nicht mehr rückgängig gemacht werden.\nFalls der Schlüssel verloren geht sind die Daten unwiderbringlich verloren.\nKlicken, um zu kopieren.", "inputEncryptPrompt": "Bitte den Encryption Secret Code eingeben", "clickToCopySecret": "Klicken, um den Secret Code zu kopieren", "configServerSetting": "Deine Servereinstellungen anpassen", "configServerGuide": "`Schnellstart/Quick Start` auswählen, dann zu den `Einstellungen/Settings` wechseln und dann die Cloud-Einstellungen \"Cloud Settings\" auswählen, um deinen Server zu konfigurieren.", "inputTextFieldHint": "Dein Secret-Code", "historicalUserList": "Anmeldeverlauf", "historicalUserListTooltip": "Diese Liste zeigt deine anonymen Accounts. Du kannst einen Account anklicken, um mehr Informationen zu sehen.\nAnonyme Accounts werden über den 'Erste Schritte' Button erstellt.", "openHistoricalUser": "Klicken, um einen anonymen Account zu öffnen", "customPathPrompt": "Den @:appName Daten-Ordner in einem mit der Cloud synchronisierten Ordner (z.B. Google Drive) zu speichern, könnte Risiken bergen. Falls die Datenbank innerhalb dieses Ordners gleichzeitig von mehreren Orten zugegriffen oder verändert wird könnte dies zu Synchronisationskonflikten und potentiellen Daten-Beschädigungen führen", "importAppFlowyData": "Daten von einem externen @:appName Ordner importieren.", "importingAppFlowyDataTip": "Der Datenimport läuft. Bitte die App nicht schließen oder in den Hintergrund setzten", "importAppFlowyDataDescription": "Daten von einem externen @:appName Ordner kopieren und in den aktuellen @:appName Datenordner importieren.", "importSuccess": "Der @:appName Dateienordner wurde erfolgreich importiert", "importFailed": "Der @:appName Dateienordner-Import ist fehlgeschlagen", "importGuide": "Für weitere Details, bitte das verlinkte Dokument prüfen" }, "notifications": { "enableNotifications": { "label": "Benachrichtigungen aktivieren", "hint": "Wenn diese Funktion ausgeschaltet ist, werden keine lokalen Benachrichtigungen mehr angezeigt." }, "showNotificationsIcon": { "label": "Benachrichtigungssymbol anzeigen", "hint": "Deaktiviere diese Option, um das Benachrichtigungssymbol in der Seitenleiste auszublenden." }, "archiveNotifications": { "allSuccess": "Alle Benachrichtigungen erfolgreich archiviert", "success": "Benachrichtigung erfolgreich archiviert" }, "markAsReadNotifications": { "allSuccess": "Alle erfolgreich als gelesen markiert", "success": "Erfolgreich als gelesen markiert" }, "action": { "markAsRead": "Als gelesen markieren", "multipleChoice": "Mehrfachauswahl", "archive": "Archiv" }, "settings": { "settings": "Einstellungen", "markAllAsRead": "Alles als gelesen markieren", "archiveAll": "Alles archivieren" }, "emptyInbox": { "title": "Noch keine Benachrichtigungen", "description": "Du wirst hier über @Erwähnungen benachrichtigt" }, "emptyUnread": { "title": "Keine ungelesenen Benachrichtigungen", "description": "Du bist auf dem Laufenden!" }, "emptyArchived": { "title": "Keine archivierten Benachrichtigungen", "description": "Du hast noch keine Benachrichtigungen archiviert" }, "tabs": { "inbox": "Posteingang", "unread": "Ungelesen", "archived": "Archiviert" }, "refreshSuccess": "Benachrichtigungen erfolgreich aktualisiert", "titles": { "notifications": "Benachrichtigungen", "reminder": "Erinnerung" } }, "appearance": { "resetSetting": "Zurücksetzen", "fontFamily": { "label": "Schriftfamilie", "search": "Suchen", "defaultFont": "Standardschriftart" }, "themeMode": { "label": "Design", "light": "Helles Design", "dark": "Dunkles Design", "system": "Wie Betriebssystem" }, "fontScaleFactor": "Schriftgröße", "documentSettings": { "cursorColor": "Cursor-Farbe", "selectionColor": "Auswahl-Farbe", "width": "Dokumentbreite", "changeWidth": "Ändern", "pickColor": "Wähle eine Farbe", "colorShade": "Farbschattierung", "opacity": "Opazität", "hexEmptyError": "Hex-Farbe darf nicht leer sein", "hexLengthError": "Hex-Wert muss 6 Zeichen lang sein", "hexInvalidError": "Ungültiger Hex-Wert", "opacityEmptyError": "Transparenz darf nicht leer sein", "opacityRangeError": "Transparenz ist ein Wert zwischen 1 und 100", "app": "App", "flowy": "Flowy", "apply": "Verwenden" }, "layoutDirection": { "label": "Layoutrichtung", "hint": "Steuere den Umlauf der Inhalte auf deinem Bildschirm: Von Links nach Rechts oder von Rechts nach Links.", "ltr": "Links nach Rechts", "rtl": "Rechts nach Links" }, "textDirection": { "label": "Textrichtung", "hint": "Wie soll der Text laufen: von Links nach Rechts oder von Rechts nach Links?", "ltr": "Links nach Rechts", "rtl": "Rechts nach Links", "auto": "AUTO", "fallback": "Wie Layoutrichtung" }, "themeUpload": { "button": "Hochladen", "uploadTheme": "Theme hochladen", "description": "Lade eigenes @:appName-Theme über die untere Schaltfläche hoch.", "loading": "Bitte warte einen Moment . . .\nWir validieren gerade dein Theme und laden es hoch.", "uploadSuccess": "Das Theme wurde erfolgreich hochgeladen", "deletionFailure": "Das Theme konnte nicht gelöscht werden. Versuche, es manuell zu löschen.", "filePickerDialogTitle": "Wähle eine .flowy_plugin-Datei", "urlUploadFailure": "URL konnte nicht geöffnet werden: {}", "failure": "Das hochgeladene Theme hat ein ungültiges Format." }, "theme": "Theme", "builtInsLabel": "Integrierte Theme", "pluginsLabel": "Plugins", "dateFormat": { "label": "Datumsformat", "local": "Lokal", "us": "US", "iso": "ISO", "friendly": "Leserlich", "dmy": "TT/MM/JJJJ" }, "timeFormat": { "label": "Zeitformat", "twelveHour": "12 Stunden", "twentyFourHour": "24 Stunden" }, "showNamingDialogWhenCreatingPage": "Zeige Bennenungsfenster, wenn eine neue Seite erstellt wird", "enableRTLToolbarItems": "RTL-Symbolleistenelemente aktivieren", "members": { "title": "Mitglieder-Einstellungen", "inviteMembers": "Mitglieder einladen", "inviteHint": "Per E-Mail einladen", "sendInvite": "Einladung senden", "copyInviteLink": "Kopiere Einladungslink", "label": "Mitglieder", "user": "Nutzer", "role": "Rolle", "removeFromWorkspace": "Vom Arbeitsbereich entfernen", "removeFromWorkspaceSuccess": "Erfolgreich aus dem Arbeitsbereich entfernt", "removeFromWorkspaceFailed": "Entfernen aus Arbeitsbereich fehlgeschlagen", "owner": "Besitzer", "guest": "Gast", "member": "Mitglied", "memberHintText": "Ein Mitglied kann Seiten lesen, kommentieren und bearbeiten, sowie Einladungen an Mitglieder & Gäste versenden.", "guestHintText": "Ein Gast kann mit Erlaubnis bestimmte Seiten lesen, reagieren, kommentieren und bearbeiten.", "emailInvalidError": "Ungültige E-Mail. Bitte prüfe die E-Mail und versuche es erneut.", "emailSent": "E-Mail gesendet. Prüfe den Posteingang.", "members": "Mitglieder", "membersCount": { "zero": "{} Mitglieder", "one": "{} Mitglied", "other": "{} Mitglieder" }, "inviteFailedDialogTitle": "Einladung konnte nicht gesendet werden", "inviteFailedMemberLimit": "Das Mitgliederlimit wurde erreicht. Bitte führe ein Upgrade durch, um weitere Mitglieder einzuladen.", "inviteFailedMemberLimitMobile": "Ihr Arbeitsbereich hat das Mitgliederlimit erreicht.", "memberLimitExceeded": "Du hast die maximal zulässige Mitgliederzahl für dein Benutzerkonto erreicht. Benötigst du weitere Mitglieder, um deine Arbeit fortsetzen zu können, erstelle auf Github bitte eine entsprechende Anfrage.", "memberLimitExceededUpgrade": "upgrade", "memberLimitExceededPro": "Mitgliederlimit erreicht. Wenn du mehr Mitglieder benötigst, kontaktiere", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Mitglied konnte nicht hinzugefügt werden!", "addMemberSuccess": "Mitglied erfolgreich hinzugefügt", "removeMember": "Mitglied entfernen", "areYouSureToRemoveMember": "Möchtest du dieses Mitglied wirklich entfernen?", "inviteMemberSuccess": "Die Einladung wurde erfolgreich versendet", "failedToInviteMember": "Das Einladen des Mitglieds ist fehlgeschlagen", "workspaceMembersError": "Hoppla, da ist etwas schiefgelaufen", "workspaceMembersErrorDescription": "Wir konnten die Mitgliederliste derzeit nicht laden. Bitte versuchen Sie es später noch einmal." } }, "files": { "copy": "Kopieren", "defaultLocation": "@:appName Datenverzeichnis", "exportData": "Daten exportieren", "doubleTapToCopy": "Zweimal tippen, um den Pfad zu kopieren", "restoreLocation": "@:appName-Standardpfad wiederherstellen", "customizeLocation": "Einen anderen Ordner öffnen", "restartApp": "Bitte starte die App neu, damit die Änderungen wirksam werden.", "exportDatabase": "Datenbank exportieren", "selectFiles": "Dateien auswählen, die exportiert werden sollen", "selectAll": "Alle auswählen", "deselectAll": "Alle abwählen", "createNewFolder": "Einen neuen Ordner erstellen", "createNewFolderDesc": "Wo sollen die Daten gespeichert werden?", "defineWhereYourDataIsStored": "Wo sind die Daten gespeichert?", "open": "Offen", "openFolder": "Einen vorhandenen Ordner öffnen", "openFolderDesc": "Öffnen und speichern im vorhandenen @:appName-Ordner", "folderHintText": "Ordnernamen", "location": "Ein neuen Ordner erstellen", "locationDesc": "Einen Namen für den @:appName Datenordner festlegen", "browser": "Durchsuchen", "create": "Erstellen", "set": "Festlegen", "folderPath": "Pfad zum Speichern des Ordners", "locationCannotBeEmpty": "Der Pfad darf nicht leer sein", "pathCopiedSnackbar": "Dateispeicherpfad in Zwischenablage kopiert!", "changeLocationTooltips": "Datenverzeichnis ändern", "change": "Ändern", "openLocationTooltips": "Win anderes Datenverzeichnis öffnen", "openCurrentDataFolder": "Aktuelles Datenverzeichnis öffnen", "recoverLocationTooltips": "Zurücksetzen auf das Standarddatenverzeichnis von @:appName", "exportFileSuccess": "Datei erfolgreich exportiert!", "exportFileFail": "Datei-Export fehlgeschlagen!", "export": "Export", "clearCache": "Cache leeren", "clearCacheDesc": "Wenn Probleme auftreten, dass Bilder nicht geladen werden oder Schriftarten nicht richtig angezeigt werden, versuche, den Cache zu leeren. Durch diese Aktion werden die Benutzerdaten nicht entfernt.", "areYouSureToClearCache": "Möchtest du den Cache wirklich leeren?", "clearCacheSuccess": "Cache erfolgreich geleert!" }, "user": { "name": "Name", "email": "E-Mail", "tooltipSelectIcon": "Symbol auswählen", "selectAnIcon": "Ein Symbol auswählen", "pleaseInputYourOpenAIKey": "Bitte gebe den AI-Schlüssel ein", "clickToLogout": "Klicken, um den aktuellen Nutzer auszulogen", "pleaseInputYourStabilityAIKey": "Bitte gebe den Stability AI Schlüssel ein" }, "mobile": { "personalInfo": "Persönliche Informationen", "username": "Nutzername", "usernameEmptyError": "Der Nutzername darf nicht leer sein", "about": "Über", "pushNotifications": "Push Benachrichtigungen", "support": "Support", "joinDiscord": "Komm zu uns auf Discord", "privacyPolicy": "Datenschutz", "userAgreement": "Nutzungsbedingungen", "termsAndConditions": "Geschäftsbedingungen", "userprofileError": "Das Nutzerprofil konnte nicht geladen werden", "userprofileErrorDescription": "Bitte abmelden und wieder anmelden, um zu prüfen ob das Problem weiterhin bestehen bleibt.", "selectLayout": "Layout auswählen", "selectStartingDay": "Ersten Tag auswählen", "version": "Version" }, "shortcuts": { "shortcutsLabel": "Tastenkürzel", "command": "Befehl", "keyBinding": "Tastenkombination", "addNewCommand": "Neuen Befehl hinzufügen", "updateShortcutStep": "Die gewünschte Tastenkombination drücken und mit ENTER bestätigen", "shortcutIsAlreadyUsed": "Diese Verknüpfung wird bereits verwendet für: {conflict}", "resetToDefault": "Zurücksetzen", "couldNotLoadErrorMsg": "Tastenkürzel konnten nicht geladen werden. Bitte nochmal versuchen.", "couldNotSaveErrorMsg": "Tastenkürzel konnten nicht gespeichert werden. Bitte nochmal versuchen.", "commands": { "codeBlockNewParagraph": "Einen neuen Absatz neben dem Codeblock einfügen", "codeBlockIndentLines": "Zwei Leerzeichen am Zeilenanfang im Codeblock einfügen", "codeBlockOutdentLines": "Zwei Leerzeichen am Zeilenanfang im Codeblock löschen", "codeBlockAddTwoSpaces": "Zwei Leerzeichen am Zeilenanfang im Codeblock einfügen", "codeBlockSelectAll": "Gesamten Inhalt innerhalb eines Codeblocks auswählen", "codeBlockPasteText": "Text in Codeblock einfügen", "textAlignLeft": "Text linksbündig ausrichten", "textAlignCenter": "Text zentriert ausrichten", "textAlignRight": "Text rechtsbündig ausrichten" } } }, "grid": { "deleteView": "Möchtest du diese Ansicht wirklich löschen?", "createView": "Neu", "title": { "placeholder": "Unbenannt" }, "settings": { "filter": "Filter", "sort": "Sortieren", "sortBy": "Sortiere nach", "properties": "Eigenschaften", "reorderPropertiesTooltip": "Ziehen, um die Eigenschaften neu anzuordnen", "group": "Gruppe", "addFilter": "Filter hinzufügen", "deleteFilter": "Filter löschen", "filterBy": "Filtern nach...", "typeAValue": "Einen Wert eingeben...", "layout": "Layout", "databaseLayout": "Layout", "viewList": { "zero": "0 Aufrufe", "one": "{count} Aufruf", "other": "{count} Aufrufe" }, "editView": "Ansicht editieren", "boardSettings": "Board-Einstellungen", "calendarSettings": "Kalender-Einstellungen", "createView": "New Ansicht", "duplicateView": "Ansicht duplizieren", "deleteView": "Anslicht löschen", "numberOfVisibleFields": "{} angezeigt" }, "filter": { "empty": "Keine aktiven Filter", "addFilter": "Filter hinzufügen", "cannotFindCreatableField": "Es wurde kein geeignetes Feld zum Filtern gefunden.", "conditon": "Bedingung", "where": "Wo" }, "textFilter": { "contains": "Enthält", "doesNotContain": "Beinhaltet nicht", "endsWith": "Endet mit", "startWith": "Beginnt mit", "is": "Ist", "isNot": "Ist nicht", "isEmpty": "Ist leer", "isNotEmpty": "Ist nicht leer", "choicechipPrefix": { "isNot": "Nicht", "startWith": "Beginnt mit", "endWith": "Endet mit", "isEmpty": "ist leer", "isNotEmpty": "ist nicht leer" } }, "checkboxFilter": { "isChecked": "Geprüft", "isUnchecked": "Ungeprüft", "choicechipPrefix": { "is": "Ist" } }, "checklistFilter": { "isComplete": "ist komplett", "isIncomplted": "ist unvollständig" }, "selectOptionFilter": { "is": "Ist", "isNot": "Ist nicht", "contains": "Enthält", "doesNotContain": "Beinhaltet nicht", "isEmpty": "Ist leer", "isNotEmpty": "Ist nicht leer" }, "dateFilter": { "is": "Ist", "before": "Ist bevor", "after": "Ist nach", "onOrBefore": "Ist am oder vor", "onOrAfter": "Ist am oder nach", "between": "Ist zwischen", "empty": "Ist leer", "notEmpty": "Ist nicht leer", "startDate": "Startdatum", "endDate": "Enddatum", "choicechipPrefix": { "before": "Vorher", "after": "Danach", "between": "Zwischen", "onOrBefore": "Am oder davor", "onOrAfter": "Während oder danach", "isEmpty": "leer", "isNotEmpty": "nicht leer" } }, "numberFilter": { "equal": "gleich", "notEqual": "ungleich", "lessThan": "weniger als", "greaterThan": "größer als", "lessThanOrEqualTo": "weniger als oder gleich wie", "greaterThanOrEqualTo": "größer als oder gleich wie", "isEmpty": "leer", "isNotEmpty": "nicht leer" }, "field": { "label": "Eigenschaft", "hide": "Verstecken", "show": "Anzeigen", "insertLeft": "Links einfügen", "insertRight": "Rechts einfügen", "duplicate": "Duplikat", "delete": "Löschen", "wrapCellContent": "Zeilenumbruch", "clear": " Zelleninhalte löschen", "switchPrimaryFieldTooltip": "Feldtyp des Primärfelds kann nicht geändert werden", "textFieldName": "Text", "checkboxFieldName": "Kontrollkästchen", "dateFieldName": "Datum", "updatedAtFieldName": "Letzte Änderungszeit", "createdAtFieldName": "Erstellungsdatum", "numberFieldName": "Zahlen", "singleSelectFieldName": "Wählen", "multiSelectFieldName": "Mehrfachauswahl", "urlFieldName": "URL", "checklistFieldName": "Checkliste", "relationFieldName": "Beziehung", "summaryFieldName": "KI-Zusammenfassung", "timeFieldName": "Zeit", "mediaFieldName": "Dateien und Medien", "translateFieldName": "AI-Übersetzen", "translateTo": "Übersetzen in", "numberFormat": "Zahlenformat", "dateFormat": "Datumsformat", "includeTime": "Zeitangabe", "isRange": "Enddatum", "dateFormatFriendly": "Monat Tag, Jahr", "dateFormatISO": "Jahr-Monat-Tag", "dateFormatLocal": "Monat/Tag/Jahr", "dateFormatUS": "Jahr/Monat/Tag", "dateFormatDayMonthYear": "Tag/Monat/Jahr", "timeFormat": "Zeitformat", "invalidTimeFormat": "Ungültiges Format", "timeFormatTwelveHour": "12 Stunden", "timeFormatTwentyFourHour": "24 Stunden", "clearDate": "Datum löschen", "dateTime": "Datum und Zeit", "startDateTime": "Beginn Datum und Zeit", "endDateTime": "Ende Datum und Zeit", "failedToLoadDate": "Laden des Datums ist fehlgeschlagen", "selectTime": "Auswahl Zeit", "selectDate": "Auswahl Datum", "visibility": "Sichtbarkeit", "propertyType": "Eigenschaftstyp", "addSelectOption": "Füge Option hinzu", "typeANewOption": "Eine neue Option eingeben", "optionTitle": "Optionen", "addOption": "Option hinzufügen", "editProperty": "Eigenschaft bearbeiten", "newProperty": "Neue Eigenschaft", "openRowDocument": "Als Seite öffnen", "deleteFieldPromptMessage": "Sicher? Diese Eigenschaft wird gelöscht", "clearFieldPromptMessage": "Bist du dir sicher? Alle Zelleninhalte in dieser Spalte werden gelöscht!", "newColumn": "Neue Spalte", "format": "Format", "reminderOnDateTooltip": "Diese Zeile hat eine terminierte Erinnerung", "optionAlreadyExist": "Einstellung existiert bereits" }, "rowPage": { "newField": "Ein neues Feld hinzufügen", "fieldDragElementTooltip": "Klicken, um das Menü zu öffnen", "showHiddenFields": { "one": "Zeige {count} verstecktes Feld", "many": "Zeige {count} versteckte Felder", "other": "Zeige {count} versteckte Felder" }, "hideHiddenFields": { "one": "Blende {count} verstecktes Feld aus", "many": "Blende {count} versteckte Felder aus", "other": "Blende {count} versteckte Felder aus" }, "openAsFullPage": "Als ganze Seite öffnen", "moreRowActions": "Weitere Zeilenaktionen" }, "sort": { "ascending": "Aufsteigend", "descending": "Absteigend", "by": "von", "empty": "Keine Sortierung", "cannotFindCreatableField": "Es konnte kein geeignetes Feld zum Sortieren gefunden werden", "deleteAllSorts": "Alle Sortierungen entfernen", "addSort": "Sortierung hinzufügen", "removeSorting": "Möchtest du die Sortierung entfernen?", "fieldInUse": "Du sortierst bereits nach diesem Feld" }, "row": { "label": "Reihe", "duplicate": "Duplikat", "delete": "Löschen", "titlePlaceholder": "Unbenannt", "textPlaceholder": "Leer", "copyProperty": "Eigenschaft in die Zwischenablage kopiert", "count": "Zählen", "newRow": "Neue Zeile", "loadMore": "Mehr laden", "action": "Aktion", "add": "Klicken, um unten hinzuzufügen", "drag": "Ziehen, um zu verschieben", "deleteRowPrompt": "Bist du sicher, dass du diese Zeile löschen willst? Diese Aktion kann nicht rückgängig gemacht werden", "deleteCardPrompt": "Bist du sicher, dass du diese Karte löschen willst? Diese Aktion kann nicht rückgängig gemacht werden", "dragAndClick": "Ziehen, um zu verschieben. Klicke, um das Menü zu öffnen", "insertRecordAbove": "Füge Datensatz oben ein", "insertRecordBelow": "Füge Datensatz unten ein", "noContent": "Kein Inhalt", "reorderRowDescription": "Zeile neu anordnen", "createRowAboveDescription": "Erstelle oben eine Zeile", "createRowBelowDescription": "Unten eine Zeile einfügen" }, "selectOption": { "create": "Erstellen", "purpleColor": "Lila", "pinkColor": "Pink", "lightPinkColor": "Hell-Pink", "orangeColor": "Orange", "yellowColor": "Gelb", "limeColor": "Kalk", "greenColor": "Grün", "aquaColor": "Aqua", "blueColor": "Blau", "deleteTag": "Tag löschen", "colorPanelTitle": "Farbe", "panelTitle": "Eine Option auswählen oder erstellen", "searchOption": "Nach einer Option suchen", "searchOrCreateOption": "Eine Option suchen oder erstellen...", "createNew": "Neu erstellen", "orSelectOne": "Oder eine Option auswählen", "typeANewOption": "Tippe eine neue Option", "tagName": "Tag-Name" }, "checklist": { "taskHint": "Aufgabenbeschreibung", "addNew": "Füge eine Aufgabe hinzu", "submitNewTask": "Erstellen", "hideComplete": "Blende abgeschlossene Aufgaben aus", "showComplete": "Zeige alle Aufgaben" }, "url": { "launch": "Im Browser öffnen", "copy": "Webadresse kopieren", "textFieldHint": "Gebe eine URL ein" }, "relation": { "relatedDatabasePlaceLabel": "Verwandte Datenbank", "relatedDatabasePlaceholder": "Nichts", "inRelatedDatabase": "in", "rowSearchTextFieldPlaceholder": "Suchen", "noDatabaseSelected": "Keine Datenbank ausgewählt! Bitte wähle zuerst eine Datenbank aus der nachfolgenden Liste aus:", "emptySearchResult": "Nichts gefunden", "linkedRowListLabel": "{count} verknüpfte Zeilen", "unlinkedRowListLabel": "Eine weitere Zeile verknüpfen" }, "menuName": "Datentabelle", "referencedGridPrefix": "Sicht von", "calculate": "berechnet", "calculationTypeLabel": { "none": "nichts", "average": "Durchschnitt", "max": "Max", "median": "Mittelwert", "min": "Min", "sum": "Ergebnis", "count": "Zahl", "countEmpty": "Zahl leer", "countEmptyShort": "leer", "countNonEmpty": "Zahl nicht leer", "countNonEmptyShort": "nicht leer" }, "media": { "rename": "Umbenennen", "download": "Herunterladen", "expand": "Erweitern", "delete": "Löschen", "moreFilesHint": "+{}", "addFileOrImage": "Datei oder Link hinzufügen", "attachmentsHint": "{}", "addFileMobile": "Datei hinzufügen", "extraCount": "+{}", "deleteFileDescription": "Möchten Sie diese Datei wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "showFileNames": "Dateinamen anzeigen", "downloadSuccess": "Datei heruntergeladen", "downloadFailedToken": "Datei konnte nicht heruntergeladen werden, Benutzertoken nicht verfügbar", "setAsCover": "Als Cover festlegen", "openInBrowser": "Im Browser öffnen", "embedLink": "Dateilink einbetten" } }, "document": { "menuName": "Dokument", "date": { "timeHintTextInTwelveHour": "13:00 Uhr", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Ein Board zum Verknüpfen auswählen", "createANewBoard": "Ein neues Board erstellen" }, "grid": { "selectAGridToLinkTo": "Eine Datentabelle zum Verknüpfen auswählen", "createANewGrid": "Eine neue Datentabelle erstellen" }, "calendar": { "selectACalendarToLinkTo": "Einen Kalender zum Verknüpfen auswählen", "createANewCalendar": "Einen neuen Kalender erstellen" }, "document": { "selectADocumentToLinkTo": "Eine Datentabelle zum Verknüpfen auswählen" }, "name": { "text": "Text", "heading1": "Überschrift 1", "heading2": "Überschrift 2", "heading3": "Überschrift 3", "image": "Bild", "bulletedList": "Aufzählungsliste", "numberedList": "Nummerierte Liste", "todoList": "Aufgabenliste", "doc": "Dokument", "linkedDoc": "Link zur Seite", "grid": "Raster", "linkedGrid": "Verknüpftes Raster", "kanban": "Kanban", "linkedKanban": "Verknüpftes Kanban", "calendar": "Kalender", "linkedCalendar": "Verknüpfter Kalender", "quote": "Zitat", "divider": "Trenner", "table": "Tabelle", "outline": "Gliederung", "mathEquation": "Mathematische Gleichung", "code": "Code", "toggleList": "Liste ein-/ausblenden", "emoji": "Emoji", "aiWriter": "KI-Autor", "dateOrReminder": "Datum oder Erinnerung", "photoGallery": "Fotogalerie", "file": "Datei" }, "subPage": { "name": "Dokument", "keyword1": "Unterseite", "keyword2": "Seite", "keyword3": "untergeordnete Seite", "keyword4": "Seite einfügen", "keyword6": "neue Seite", "keyword7": "Seite erstellen", "keyword8": "Dokument" } }, "selectionMenu": { "outline": "Gliederung", "codeBlock": "Code Block" }, "plugins": { "referencedBoard": "Referenziertes Board", "referencedGrid": "Referenzierte Datentabelle", "referencedCalendar": "Referenzierter Kalender", "referencedDocument": "Referenziertes Dokument", "autoGeneratorMenuItemName": "AI-Autor", "autoGeneratorTitleName": "AI: Die KI bitten, etwas zu schreiben ...", "autoGeneratorLearnMore": "Mehr erfahren", "autoGeneratorGenerate": "Erstellen", "autoGeneratorHintText": "AI fragen ...", "autoGeneratorCantGetOpenAIKey": "Der AI-Schlüssel kann nicht abgerufen werden", "autoGeneratorRewrite": "Umschreiben", "smartEdit": "KI-Assistenten", "aI": "AI", "smartEditFixSpelling": "Korrigiere Rechtschreibung", "warning": "⚠️ KI-Antworten können ungenau oder irreführend sein.", "smartEditSummarize": "Zusammenfassen", "smartEditImproveWriting": "Das Geschriebene verbessern", "smartEditMakeLonger": "Länger machen", "smartEditCouldNotFetchResult": "Das Ergebnis konnte nicht von AI abgerufen werden", "smartEditCouldNotFetchKey": "Der AI-Schlüssel konnte nicht abgerufen werden", "smartEditDisabled": "AI in den Einstellungen verbinden", "appflowyAIEditDisabled": "Melde dich an, um KI-Funktionen zu aktivieren", "discardResponse": "Möchtest du die KI-Antworten verwerfen?", "createInlineMathEquation": "Formel erstellen", "fonts": "Schriftarten", "insertDate": "Datum einfügen", "emoji": "Emoji", "toggleList": "Liste umschalten", "quoteList": "Kursblatt", "numberedList": "Nummerierte Liste", "bulletedList": "Stichpunktliste", "todoList": "Offene Aufgaben Liste", "callout": "Hervorhebung", "simpleTable": { "moreActions": { "color": "Farbe", "align": "Ausrichten", "delete": "Löschen", "duplicate": "duplizieren", "insertLeft": "Links einfügen", "insertRight": "Rechts einfügen", "insertAbove": "Oben einfügen", "insertBelow": "Unten einfügen", "headerColumn": "Kopfspalte", "headerRow": "Kopfzeile", "clearContents": "Klarer Inhalt", "setToPageWidth": "Auf Seitenbreite einstellen", "distributeColumnsWidth": "Spalten gleichmäßig verteilen", "duplicateRow": "Zeile duplizieren", "duplicateColumn": "Spalte duplizieren", "textColor": "Textfarbe", "cellBackgroundColor": "Zellen-Hintergrundfarbe", "duplicateTable": "Tabelle duplizieren" }, "clickToAddNewRow": "Klicken Sie hier, um eine neue Zeile hinzuzufügen", "clickToAddNewColumn": "Klicken Sie hier, um eine neue Spalte hinzuzufügen", "clickToAddNewRowAndColumn": "Klicken Sie hier, um eine neue Zeile und Spalte hinzuzufügen", "headerName": { "table": "Tabelle", "alignText": "Text ausrichten" } }, "cover": { "changeCover": "Titelbild wechseln", "colors": "Farben", "images": "Bilder", "clearAll": "Alles löschen", "abstract": "Abstrakt", "addCover": "Titelbild hinzufügen", "addLocalImage": "Lokales Bild hinzufügen", "invalidImageUrl": "Ungültige Bild-URL", "failedToAddImageToGallery": "Das Bild konnte nicht zur Galerie hinzugefügt werden", "enterImageUrl": "Bild-URL eingeben", "add": "Hinzufügen", "back": "Zurück", "saveToGallery": "In der Galerie speichern", "removeIcon": "Symbol entfernen", "removeCover": "Cover entfernen", "pasteImageUrl": "Bild-URL einfügen", "or": "ODER", "pickFromFiles": "Dateien auswählen", "couldNotFetchImage": "Bild konnte nicht abgerufen werden", "imageSavingFailed": "Bildspeicherung fehlgeschlagen", "addIcon": "Symbol hinzufügen", "changeIcon": "Symbol wechseln", "coverRemoveAlert": "Nach dem Löschen wird es aus dem Titelbild entfernt.", "alertDialogConfirmation": "Sicher, dass du weitermachen willst?" }, "mathEquation": { "name": "Mathematische Formel", "addMathEquation": "Mathematische Formel hinzufügen", "editMathEquation": "Mathematische Formel bearbeiten" }, "optionAction": { "click": "Klicken", "toOpenMenu": " um das Menü zu öffnen", "drag": "Ziehen", "toMove": " bewegen", "delete": "Löschen", "duplicate": "Duplikat", "turnInto": "Umwandeln in", "moveUp": "Nach oben verschieben", "moveDown": "Nach unten verschieben", "color": "Farbe", "align": "Ausrichten", "left": "Links", "center": "Zentriert", "right": "Rechts", "defaultColor": "Standard", "depth": "Tiefe", "copyLinkToBlock": "Link zum Block kopieren" }, "image": { "addAnImage": "Ein Bild hinzufügen", "copiedToPasteBoard": "Der Bildlink wurde in die Zwischenablage kopiert", "addAnImageDesktop": "Bild(er) hier ablegen oder klicken, um Bild(er) hinzuzufügen", "addAnImageMobile": "Klicke, um ein oder mehrere Bilder hinzuzufügen", "dropImageToInsert": "Zum Einfügen Bilder hier ablegen", "imageUploadFailed": "Bild hochladen gescheitert", "imageDownloadFailed": "Das Hochladen des Bilds ist fehlgeschlagen. Bitte versuche es erneut.", "imageDownloadFailedToken": "Das Hochladen des Bilds ist aufgrund eines fehlenden Benutzertokens fehlgeschlagen. Bitte versuche es erneut.", "errorCode": "Fehlercode" }, "photoGallery": { "name": "Fotogallerie", "imageKeyword": "Bild", "imageGalleryKeyword": "Bildergalerie", "photoKeyword": "Foto", "photoBrowserKeyword": "Fotobrowser", "galleryKeyword": "Galerie", "addImageTooltip": "Bild hinzufügen", "changeLayoutTooltip": "Layout ändern", "browserLayout": "Browser", "gridLayout": "Datentabelle", "deleteBlockTooltip": "Ganze Galerie löschen" }, "math": { "copiedToPasteBoard": "Die mathematische Gleichung wurde in die Zwischenablage kopiert" }, "urlPreview": { "copiedToPasteBoard": "Der Link wurde in die Zwischenablage kopiert", "convertToLink": "Konvertieren zum eingebetteten Link" }, "outline": { "addHeadingToCreateOutline": "Füge Überschriften hinzu, um ein Inhaltsverzeichnis zu erstellen.", "noMatchHeadings": "Keine passenden Überschriften gefunden." }, "table": { "addAfter": "Danach einfügen", "addBefore": "Davor einfügen", "delete": "Löschen", "clear": "Inhalt löschen", "duplicate": "Duplikat", "bgColor": "Hintergrundfarbe" }, "contextMenu": { "copy": "Kopieren", "cut": "Ausschneiden", "paste": "Einfügen", "pasteAsPlainText": "Als einfachen Text einfügen" }, "action": "Aktionen", "database": { "selectDataSource": "Datenquelle auswählen", "noDataSource": "Keine Datenquelle", "selectADataSource": "Eine Datenquelle auswählen", "toContinue": "fortfahren", "newDatabase": "Neue Datenbank", "linkToDatabase": "Verknüpfung zur Datenbank" }, "date": "Datum", "video": { "label": "Video", "emptyLabel": "Video hinzufügen", "placeholder": "Videolink einfügen", "copiedToPasteBoard": "Der Videolink wurde in die Zwischenablage kopiert", "insertVideo": "Video hinzufügen", "invalidVideoUrl": "Die Quell-URL wird noch nicht unterstützt.", "invalidVideoUrlYouTube": "YouTube wird noch nicht unterstützt.", "supportedFormats": "Unterstützte Formate: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "Datei", "uploadTab": "Hochladen", "uploadMobile": "Wählen Sie eine Datei", "uploadMobileGallery": "Aus der Fotogalerie", "networkTab": "Link integrieren", "placeholderText": "Klicke oder ziehe eine Datei hoch, um sie hochzuladen", "placeholderDragging": "Lege die hochzuladende Datei hier ab", "dropFileToUpload": "Lege die hochzuladende Datei hier ab", "fileUploadHint": "Lege die hochzuladende Datei hier ab\noder klicke, um eine Datei auszuwählen.", "fileUploadHintSuffix": "Durchsuchen", "networkHint": "Gebe einen Link zu einer Datei ein", "networkUrlInvalid": "Ungültige URL. Bitte korrigiere die URL und versuche es erneut.", "networkAction": "Dateilink einbetten", "fileTooBigError": "Die Dateigröße ist zu groß. Bitte lade eine Datei mit einer Größe von weniger als 10 MB hoch.", "renameFile": { "title": "Datei umbenennen", "description": "Gebe den neuen Namen für diese Datei ein", "nameEmptyError": "Der Dateiname darf nicht leer bleiben." }, "uploadedAt": "Hochgeladen am {}", "linkedAt": "Link hinzugefügt am {}", "failedToOpenMsg": "Öffnen fehlgeschlagen, Datei nicht gefunden" }, "subPage": { "errors": { "failedDeletePage": "Seite konnte nicht gelöscht werden", "failedCreatePage": "Seite konnte nicht erstellt werden", "failedMovePage": "Seite konnte nicht in dieses Dokument verschoben werden", "failedDuplicatePage": "Seite konnte nicht dupliziert werden", "failedDuplicateFindView": "Seite konnte nicht dupliziert werden - Originalansicht nicht gefunden" } } }, "outlineBlock": { "placeholder": "Inhaltsverzeichnis" }, "textBlock": { "placeholder": "Gebe „/“ für Inhaltsblöcke ein" }, "title": { "placeholder": "Ohne Titel" }, "imageBlock": { "placeholder": "Klicken, um ein Bild hinzuzufügen", "upload": { "label": "Hochladen", "placeholder": "Klicken, um das Bild hochzuladen" }, "url": { "label": "Bild URL", "placeholder": "Bild-URL eingeben" }, "ai": { "label": "Bild mit AI erstellen", "placeholder": "Bitte den Prompt für AI eingeben, um ein Bild zu erstellen" }, "stability_ai": { "label": "Bild mit Stability AI erstellen", "placeholder": "Bitte den Prompt für Stability AI eingeben, um ein Bild zu erstellen" }, "support": "Die Bildgrößenbeschränkung beträgt 5 MB. Unterstützte Formate: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Ungültiges Bild", "invalidImageSize": "Die Bildgröße muss kleiner als 5 MB sein", "invalidImageFormat": "Das Bildformat wird nicht unterstützt. Unterstützte Formate: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Ungültige Bild-URL", "noImage": "Keine Datei oder Verzeichnis", "multipleImagesFailed": "Ein oder mehrere Bilder konnten nicht hochgeladen werden. Bitte versuche es erneut." }, "embedLink": { "label": "Eingebetteter Link", "placeholder": "Bild-Link einfügen oder eintippen" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Nach einem Bild suchen", "pleaseInputYourOpenAIKey": "biitte den AI Schlüssel in der Einstellungsseite eingeben", "saveImageToGallery": "Bild speichern", "failedToAddImageToGallery": "Das Bild konnte nicht zur Galerie hinzugefügt werden", "successToAddImageToGallery": "Das Bild wurde zur Galerie hinzugefügt werden", "unableToLoadImage": "Das Bild konnte nicht geladen werden", "maximumImageSize": "Die maximal unterstützte Upload-Bildgröße beträgt 10 MB", "uploadImageErrorImageSizeTooBig": "Die Bildgröße muss weniger als 10 MB betragen", "imageIsUploading": "Bild wird hochgeladen", "openFullScreen": "Im Vollbildmodus öffnen", "interactiveViewer": { "toolbar": { "previousImageTooltip": "Vorheriges Bild", "nextImageTooltip": "Nächstes Bild", "zoomOutTooltip": "Rauszoomen", "zoomInTooltip": "Hineinzoomen", "changeZoomLevelTooltip": "Zoomstufe ändern", "openLocalImage": "Bild öffnen", "downloadImage": "Bild herunterladen", "closeViewer": "Interaktive Anzeige schließen", "scalePercentage": "{}%", "deleteImageTooltip": "Bild löschen" } }, "pleaseInputYourStabilityAIKey": "biitte den Stability AI Schlüssel in der Einstellungsseite eingeben" }, "codeBlock": { "language": { "label": "Sprache", "placeholder": "Sprache auswählen", "auto": "Auto" }, "copyTooltip": "Inhalt des Codeblocks kopieren", "searchLanguageHint": "Nach einer Sprache suchen", "codeCopiedSnackbar": "Code in die Zwischenablage kopiert!" }, "inlineLink": { "placeholder": "Link einfügen oder eintippen", "openInNewTab": "Im neuen Tab öffnen", "copyLink": "Link kopieren", "removeLink": "Link entfernen", "url": { "label": "URL verknüpfen", "placeholder": "Link-URL eingeben" }, "title": { "label": "Linktitel", "placeholder": "Linktitel eingeben" } }, "mention": { "placeholder": "Eine Person, Seite oder Datum erwähnen...", "page": { "label": "Link zur Seite", "tooltip": "Klicken, um die Seite zu öffnen" }, "deleted": "gelöscht", "deletedContent": "Dieser Inhalt existiert nicht oder wurde gelöscht", "noAccess": "Kein Zugriff", "deletedPage": "Gelöschte Seite", "trashHint": " - im Papierkorb" }, "toolbar": { "resetToDefaultFont": "Auf den Standard zurücksetzen" }, "errorBlock": { "theBlockIsNotSupported": "Die aktuelle Version unterstützt diesen Block nicht.", "clickToCopyTheBlockContent": "Hier klicken, um den Blockinhalt zu kopieren", "blockContentHasBeenCopied": "Der Blockinhalt wurde kopiert.", "copyBlockContent": "Blockinhalt kopieren" }, "mobilePageSelector": { "title": "Seite auswählen", "failedToLoad": "Seitenliste konnte nicht geladen werden", "noPagesFound": "Keine Seiten gefunden" }, "attachmentMenu": { "choosePhoto": "Foto auswählen", "takePicture": "Ein Foto machen", "chooseFile": "Datei auswählen" } }, "board": { "column": { "label": "Spalte", "createNewCard": "Neu", "renameGroupTooltip": "Drücken, um die Gruppe umzubenennen", "createNewColumn": "Eine neue Gruppe hinzufügen", "addToColumnTopTooltip": "Eine neue Karte am oberen Ende hinzufügen", "addToColumnBottomTooltip": "Eine neue Karte am unteren Ende hinzufügen", "renameColumn": "Umbenennen", "hideColumn": "Verstecken", "newGroup": "Neue Gruppe", "deleteColumn": "Löschen", "deleteColumnConfirmation": "Das wird diese Gruppe und alle enthaltenen Karten löschen.\nSicher, dass du fortsetzen möchtest?", "groupActions": "Gruppenaktion" }, "hiddenGroupSection": { "sectionTitle": "Versteckte Gruppen", "collapseTooltip": "Verstecke die versteckten Gruppen", "expandTooltip": "Zeige die versteckten Gruppen" }, "cardDetail": "Kartendetail", "cardActions": "Kartenaktionen", "cardDuplicated": "Karte wurde dupliziert", "cardDeleted": "Karte wurde gelöscht", "showOnCard": "Zeige das Kartendetail", "setting": "Einstellung", "propertyName": "Eigenschaftname", "menuName": "Board", "showUngrouped": "Zeige nichtgruppierte Elemente", "ungroupedButtonText": "Nichtgruppiert", "ungroupedButtonTooltip": "Enthält Karten, die keiner Gruppe zugeordnet sind", "ungroupedItemsTitle": "Klicke, um dem Board hinzuzufügen", "groupBy": "Gruppiert nach", "groupCondition": "Gruppenbedingung", "referencedBoardPrefix": "Sicht von", "notesTooltip": "Notizen vorhanden", "mobile": { "editURL": "Bearbeite URL", "showGroup": "Zeige die Gruppe", "showGroupContent": "Sicher, dass diese Gruppe auf dem Board angezeigt werden soll?", "failedToLoad": "Boardansicht konnte nicht geladen werden" }, "dateCondition": { "weekOf": "Woche von {} - {}", "today": "Heute", "yesterday": "Gestern", "tomorrow": "Morgen", "lastSevenDays": "Letzte 7 Tage", "nextSevenDays": "Nächste 7 Tage", "lastThirtyDays": "Letzte 30 Tage", "nextThirtyDays": "Nächste 30 Tage" }, "noGroup": "Keine Gruppierung nach Eigenschaft", "noGroupDesc": "Board-Ansichten benötigen eine Eigenschaft zum Gruppieren, um angezeigt zu werden", "media": { "cardText": "{} {}", "fallbackName": "Dateien" } }, "calendar": { "menuName": "Kalender", "defaultNewCalendarTitle": "Ohne Titel", "newEventButtonTooltip": "Einen neues Ereignis eintragen", "navigation": { "today": "Heute", "jumpToday": "Springe zu Heute", "previousMonth": "Vorheriger Monat", "nextMonth": "Nächster Monat", "views": { "day": "Tag", "week": "Woche", "month": "Monat", "year": "Jahr" } }, "mobileEventScreen": { "emptyTitle": "Noch keine Events", "emptyBody": "Drücke die Plus-Taste, um für heute ein Ereignis zu erstellen." }, "settings": { "showWeekNumbers": "Wochennummern anzeigen", "showWeekends": "Wochenenden anzeigen", "firstDayOfWeek": "Wochenstart", "layoutDateField": "Layoutkalender von", "changeLayoutDateField": "Layoutfeld ändern", "noDateTitle": "Kein Datum", "noDateHint": { "zero": "Ereignisse ohne Datum werden hier angezeigt", "one": "{count} Ereignisse ohne Datum", "other": "{count} Ereignisse ohne Datum" }, "unscheduledEventsTitle": "Ungeplante Events", "clickToAdd": "Klicken zum hinzufügen im Kalender", "name": "Kalendereinstellungen", "clickToOpen": "Hier klicken, um den Eintrag zu öffnen" }, "referencedCalendarPrefix": "Sicht von", "quickJumpYear": "Spring zu", "duplicateEvent": "Doppeltes Ereignis" }, "errorDialog": { "title": "@:appName-Fehler", "howToFixFallback": "Wir entschuldigen uns für die Unannehmlichkeiten! Reiche auf unserer GitHub-Seite ein Problem ein, das deinen Fehler beschreibt.", "howToFixFallbackHint1": "Wir entschuldigen uns für die Unannehmlichkeiten! Melden Sie ein Problem auf unserer ", "howToFixFallbackHint2": " Seite, die Ihren Fehler beschreibt.", "github": "Auf GitHub ansehen" }, "search": { "label": "Suchen", "sidebarSearchIcon": "Suchen und schnell zu einer Seite springen", "placeholder": { "actions": "Suchaktionen..." } }, "message": { "copy": { "success": "Kopiert!", "fail": "Kopieren nicht möglich" } }, "unSupportBlock": "Die aktuelle Version unterstützt diesen Block nicht.", "views": { "deleteContentTitle": "Möchtest du den {pageType} wirklich löschen?", "deleteContentCaption": "Wenn du diesen {pageType} löschst, kannst du ihn aus dem Papierkorb wiederherstellen." }, "colors": { "custom": "Individuell", "default": "Standard", "red": "Rot", "orange": "Orange", "yellow": "Gelb", "green": "Grün", "blue": "Blau", "purple": "Lila", "pink": "Pink", "brown": "Braun", "gray": "Grau" }, "emoji": { "emojiTab": "Emoji", "search": "Emoji suchen", "noRecent": "Kein neuer Emoji", "noEmojiFound": "Kein Emoji gefunden", "filter": "Filter", "random": "Zufällig", "selectSkinTone": "Hautfarbe auswählen", "remove": "Emoji entfernen", "categories": { "smileys": "Smileys & Emotionen", "people": "Menschen & Körper", "animals": "Tiere & Natur", "food": "Essen & Trinken", "activities": "Aktivitäten", "places": "Reisen & Orte", "objects": "Objekte", "symbols": "Symbole", "flags": "Flaggen", "nature": "Natur", "frequentlyUsed": "Häufig verwendet" }, "skinTone": { "default": "Standard", "light": "Hell", "mediumLight": "Mittelhell", "medium": "Mittel", "mediumDark": "Mitteldunkel", "dark": "Dunkel" }, "openSourceIconsFrom": "Open-Source-Icons von" }, "inlineActions": { "noResults": "Keine Ergebnisse", "recentPages": "Kürzliche Seiten", "pageReference": "Seitenreferenz", "docReference": "Dokumentverweis", "boardReference": "Board-Referenz", "calReference": "Kalenderreferenz", "gridReference": "Gitter Referenz", "date": "Datum", "reminder": { "groupTitle": "Erinnerung", "shortKeyword": "erinnern" } }, "datePicker": { "dateTimeFormatTooltip": "Das Datums- und Zeitformat in den Einstellungen anpassen", "dateFormat": "Datumsformat", "includeTime": "Inkl. Zeit", "isRange": "Enddatum", "timeFormat": "Zeitformat", "clearDate": "Datum löschen", "reminderLabel": "Erinnerung", "selectReminder": "Erinnerung auswählen", "reminderOptions": { "none": "nichts", "atTimeOfEvent": "Uhrzeit des Events", "fiveMinsBefore": "5Min. vorher", "tenMinsBefore": "10Min. vorher", "fifteenMinsBefore": "15Min. vorher", "thirtyMinsBefore": "30Min. vorher", "oneHourBefore": "1Std. vorher", "twoHoursBefore": "2Std. vorher", "onDayOfEvent": "Am Tag des Events", "oneDayBefore": "1Tag vorher", "twoDaysBefore": "2Tage vorher", "oneWeekBefore": "1Woche vorher", "custom": "Benutzerdefiniert" } }, "relativeDates": { "yesterday": "Gestern", "today": "Heute", "tomorrow": "Morgen", "oneWeek": "1 Woche" }, "notificationHub": { "title": "Benachrichtigungen", "mobile": { "title": "Neuigkeiten" }, "emptyTitle": "Leer", "emptyBody": "Keine offenen Benachrichtigungen oder Aktionen. Genieße die Ruhe.", "tabs": { "inbox": "Eingang", "upcoming": "Demnächst" }, "actions": { "markAllRead": "Alle als gelesen markieren", "showAll": "Alle", "showUnreads": "Ungelesen" }, "filters": { "ascending": "Aufsteigend", "descending": "Absteigend", "groupByDate": "Nach Datum groupieren", "showUnreadsOnly": "Nur ungelesen zeigen", "resetToDefault": "Zurücksetzen" } }, "reminderNotification": { "title": "Erinnerung", "message": "Bitte denke daran, dass hier zu prüfen bevor du es vergisst.", "tooltipDelete": "Löschen", "tooltipMarkRead": "Als gelesen markieren", "tooltipMarkUnread": "Als ungelesen markieren" }, "findAndReplace": { "find": "Finden", "previousMatch": "Vorheriger Treffer", "nextMatch": "Nächster Treffer", "close": "Schließen", "replace": "Ersetzen", "replaceAll": "Alle ersetzen", "noResult": "Keine Ergebnisse", "caseSensitive": "Groß-/Kleinschreibung beachten", "searchMore": "Suche für mehr Ergebnisse" }, "error": { "weAreSorry": "Das tut uns leid", "loadingViewError": "Wir haben Schwierigkeiten diese Ansicht zu laden. Bitte prüfe die Internetverbindung, lade die App neu und zögere nicht, das Team zu kontaktieren, falls das Problem weiterhin besteht.", "syncError": "Daten werden nicht von einem anderen Gerät synchronisiert", "syncErrorHint": "Bitte öffnen Sie diese Seite erneut auf dem Gerät, auf dem sie zuletzt bearbeitet wurde, und öffnen Sie die Seite dann erneut auf dem aktuellen Gerät.", "clickToCopy": "Klicken Sie hier, um den Fehlercode zu kopieren" }, "editor": { "bold": "Fett", "bulletedList": "Stichpunktliste", "bulletedListShortForm": "Mit Aufzählungszeichen", "checkbox": "Checkbox", "embedCode": "Eingebetteter Code", "heading1": "Überschrift 1", "heading2": "Überschrift 2", "heading3": "Überschrift 3", "highlight": "Hervorhebung", "color": "Farbe", "image": "Bild", "date": "Datum", "page": "Seite", "italic": "Kursiv", "link": "Link", "numberedList": "Nummerierte Liste", "numberedListShortForm": "Nummeriert", "quote": "Zitat", "strikethrough": "Durgestrichen", "text": "Text", "underline": "Unterstrichen", "fontColorDefault": "Standard", "fontColorGray": "Grau", "fontColorBrown": "Braun", "fontColorOrange": "Orange", "fontColorYellow": "Gelb", "fontColorGreen": "Grün", "fontColorBlue": "Blau", "fontColorPurple": "Lila", "fontColorPink": "Pink", "fontColorRed": "Rot", "backgroundColorDefault": "Standardhintergrund", "backgroundColorGray": "Grauer Hintergrund", "backgroundColorBrown": "Brauner Hintergrund", "backgroundColorOrange": "Oranger Hintergrund", "backgroundColorYellow": "Gelber Hintergrund", "backgroundColorGreen": "Grüner Hintergrund", "backgroundColorBlue": "Blauer Hintergrund", "backgroundColorPurple": "Lila Hintergrund", "backgroundColorPink": "Pinker Hintergrund", "backgroundColorRed": "Roter Hintergrund", "backgroundColorLime": "Lime-Hintergrund", "backgroundColorAqua": "Aqua-Hintergrund", "done": "Erledigt", "cancel": "Abbrechen", "tint1": "Farbton 1", "tint2": "Farbton 2", "tint3": "Farbton 3", "tint4": "Farbton 4", "tint5": "Farbton 5", "tint6": "Farbton 6", "tint7": "Farbton 7", "tint8": "Farbton 8", "tint9": "Farbton 9", "lightLightTint1": "Lila", "lightLightTint2": "Pink", "lightLightTint3": "Helles Pink", "lightLightTint4": "Orange", "lightLightTint5": "Gelb", "lightLightTint6": "Hellgrün", "lightLightTint7": "Grün", "lightLightTint8": "Aqua", "lightLightTint9": "Blau", "urlHint": "URL", "mobileHeading1": "Überschrift 1", "mobileHeading2": "Überschrift 2", "mobileHeading3": "Überschrift 3", "textColor": "Textfarbe", "backgroundColor": "Hintergrundfarbe", "addYourLink": "Link hinzufügen", "openLink": "Link öffnen", "copyLink": "Link kopieren", "removeLink": "Link entfernen", "editLink": "Link bearbeiten", "linkText": "Text", "linkTextHint": "Bitte Text eingeben", "linkAddressHint": "Bitte URL eingeben", "highlightColor": "Hervorhebungsfarbe", "clearHighlightColor": "Hervorhebungsfarbe löschen", "customColor": "Eigene Farbe", "hexValue": "Hex-Wert", "opacity": "Transparenz", "resetToDefaultColor": "Auf Standardfarben zurücksetzen", "ltr": "LTR (Links nach rechts)", "rtl": "RTL (rechts nach links)", "auto": "Auto", "cut": "Ausschneiden", "copy": "Kopieren", "paste": "Einfügen", "find": "Finden", "select": "Auswählen", "selectAll": "Alle auswählen", "previousMatch": "Vorheriger Treffer", "nextMatch": "Nächster Treffer", "closeFind": "Schließen", "replace": "Ersetzen", "replaceAll": "Alle ersetzen", "regex": "Regulärer Ausdruck (Regex)", "caseSensitive": "Groß-/Kleinschreibung beachten", "uploadImage": "Bild hochladen", "urlImage": "Bild-URL", "incorrectLink": "Defekter Link", "upload": "Hochladen", "chooseImage": "Bild auswählen", "loading": "Lädt", "imageLoadFailed": "Bild konnte nicht geladen werden", "divider": "Trenner", "table": "Tabelle", "colAddBefore": "Davor einfügen", "rowAddBefore": "Davor einfügen", "colAddAfter": "Danach einfügen", "rowAddAfter": "Danach einfügen", "colRemove": "Enternen", "rowRemove": "Entfernen", "colDuplicate": "Duplikat", "rowDuplicate": "Duplikat", "colClear": "Inhalt löschen", "rowClear": "Inhalt löschen", "slashPlaceHolder": "'/'-Taste, um einen Block einzufügen oder Text eingeben", "typeSomething": "Etwas eingeben...", "toggleListShortForm": "Umschalten", "quoteListShortForm": "Zitat", "mathEquationShortForm": "Formel", "codeBlockShortForm": "Code" }, "favorite": { "noFavorite": "Leere Favoritenseite", "noFavoriteHintText": "Nach links wischen, um es den Favoriten hinzuzufügen", "removeFromSidebar": "Aus der Seitenleiste entfernen", "addToSidebar": "An Seitenleiste anheften" }, "cardDetails": { "notesPlaceholder": "'/'-Taste, um einen Block einzufügen oder Text eingeben" }, "blockPlaceholders": { "todoList": "To-Do", "bulletList": "Liste", "numberList": "Liste", "quote": "Zitat", "heading": "Überschrift {}" }, "titleBar": { "pageIcon": "Seitensymbol", "language": "Sprache", "font": "Schrift", "actions": "Aktionen", "date": "Datum", "addField": "Ein Feld hinzufügen", "userIcon": "Nutzerbild" }, "noLogFiles": "Hier gibt es kein Log-File", "newSettings": { "myAccount": { "title": "Mein Benutzerkonto", "subtitle": "Passe dein Profil an, verwalte Einstellungen zur Sicherheit deines Benutzerkontos, öffne AI-Schlüssel oder melde dich bei deinem Konto an.", "profileLabel": "Kontoname und Profilbild", "profileNamePlaceholder": "Gib deinen Namen ein", "accountSecurity": "Konto Sicherheit", "2FA": "Authentifizierung in zwei Schritten", "aiKeys": "AI-Schlüssel", "accountLogin": "Benutzerkonto Login", "updateNameError": "Namensaktualisierung fehlgeschlagen!", "updateIconError": "Symbol konnte nicht aktualisiert werden!", "deleteAccount": { "title": "Benutzerkonto löschen", "subtitle": "Benutzerkonto inkl. deiner persönlicher Daten unwiderruflich löschen.", "description": "Löschen Sie Ihr Konto dauerhaft und entfernen Sie den Zugriff auf alle Arbeitsbereiche.", "deleteMyAccount": "Mein Benutzerkonto löschen", "dialogTitle": "Benutzerkonto löschen", "dialogContent1": "Bist du sicher, dass du dein Benutzerkonto unwiderruflich löschen möchtest?", "dialogContent2": "Diese Aktion kann nicht rückgängig gemacht werden und führt dazu, dass der Zugriff auf alle Teambereiche aufgehoben wird, dein gesamtes Benutzerkonto, einschließlich privater Arbeitsbereiche, gelöscht wird und du aus allen freigegebenen Arbeitsbereichen entfernt wirst.", "confirmHint1": "Geben Sie zur Bestätigung bitte „@:newSettings.myAccount.deleteAccount.confirmHint3“ ein.", "confirmHint3": "MEIN KONTO LÖSCHEN", "checkToConfirmError": "Sie müssen das Kontrollkästchen aktivieren, um das Löschen zu bestätigen", "failedToGetCurrentUser": "Aktuelle Benutzer-E-Mail konnte nicht abgerufen werden.", "confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „@:newSettings.myAccount.deleteAccount.confirmHint3“ überein.", "deleteAccountSuccess": "Konto erfolgreich gelöscht" } }, "workplace": { "name": "Arbeitsbereich", "title": "Arbeitsbereichseinstellungen", "subtitle": "Passe das Erscheinungsbild, das Design, die Schriftart, das Textlayout, das Datum, die Uhrzeit und die Sprache deines Arbeitsbereiches an.", "workplaceName": "Name des Arbeitsbereiches", "workplaceNamePlaceholder": "Gib den Namen des Arbeitsbereiches ein", "workplaceIcon": "Symbol", "workplaceIconSubtitle": "Lade ein Bild hoch oder verwende ein Emoji für deinen Arbeitsbereich. Das Symbol wird in deiner Seitenleiste und in deinen Benachrichtigungen angezeigt.", "renameError": "Umbenennen des Arbeitsbereiches fehlgeschlagen", "updateIconError": "Symbol konnte nicht aktualisiert werden", "chooseAnIcon": "Symbol auswählen", "appearance": { "name": "Aussehen", "themeMode": { "auto": "Auto", "light": "Hell", "dark": "Dunkel" }, "language": "Sprache" } }, "syncState": { "syncing": "Synchronisierung", "synced": "Synchronisiert", "noNetworkConnected": "Kein Netzwerk verbunden" } }, "pageStyle": { "title": "Seitenformatierung", "layout": "Layout", "coverImage": "Titelbild", "pageIcon": "Seitensymbol", "colors": "Farben", "gradient": "Gradient", "backgroundImage": "Hintergrundbild", "presets": "Voreinstellungen", "photo": "Foto", "unsplash": "Unsplash", "pageCover": "Deckblatt", "none": "Keines", "openSettings": "Einstellungen öffnen", "photoPermissionTitle": "@:appName möchte auf deine Fotobibliothek zugreifen", "photoPermissionDescription": "Erlaube den Zugriff auf die Fotobibliothek zum Hochladen von Bildern.", "cameraPermissionTitle": "@:appName möchte auf Ihre Kamera zugreifen", "cameraPermissionDescription": "@:appName benötigt Zugriff auf Ihre Kamera, damit Sie Bilder von der Kamera zu Ihren Dokumenten hinzufügen können.", "doNotAllow": "Nicht zulassen", "image": "Bild" }, "commandPalette": { "placeholder": "Tippe, um nach Ansichten zu suchen...", "bestMatches": "Beste Ergebnisse", "recentHistory": "Aktuelle Historie", "navigateHint": "navigieren", "loadingTooltip": "Wir suchen nach Ergebnissen ...", "betaLabel": "BETA", "betaTooltip": "Wir unterstützen derzeit nur die Suche nach Seiten", "fromTrashHint": "Aus dem Mülleimer", "noResultsHint": "Wir haben nicht gefunden, wonach du gesucht hast. Versuche nach einem anderen Begriff zu suchen.", "clearSearchTooltip": "Suchfeld löschen" }, "space": { "delete": "Löschen", "deleteConfirmation": "Löschen:", "deleteConfirmationDescription": "Alle Seiten innerhalb dieses Space werden gelöscht und in den Papierkorb verschoben.", "rename": "Domäne umbennen", "changeIcon": "Symbol ändern", "manage": "Domäne verwalten", "addNewSpace": "Domäne erstellen", "collapseAllSubPages": "Alle Unterseiten einklappen", "createNewSpace": "Eine neue Domäne erstellen", "createSpaceDescription": "Erstellen mehrere öffentliche und private Domänen, um deine Arbeit besser zu organisieren.", "spaceName": "Name der Domäne", "spaceNamePlaceholder": "z.B. Marketing, Entwicklung, Personalabteilung", "permission": "Berechtigung", "publicPermission": "Öffentlich", "publicPermissionDescription": "Alle Mitglieder des Arbeitsbereichs mit Vollzugriff", "privatePermission": "Privat", "privatePermissionDescription": "Nur du hast Zugang zu dieser Domäne", "spaceIconBackground": "Hintergrundfarbe", "spaceIcon": "Symbol", "dangerZone": "Gefahrenzone", "unableToDeleteLastSpace": "Die letzte Domäne kann nicht gelöscht werden", "unableToDeleteSpaceNotCreatedByYou": "Von anderen erstellte Domänen können nicht gelöscht werden", "enableSpacesForYourWorkspace": "Domänen für deinen Arbeitsbereich aktivieren", "title": "Domänen", "defaultSpaceName": "Allgemein", "upgradeSpaceTitle": "Domänen aktivieren", "upgradeSpaceDescription": "Erstelle mehrere öffentliche und private Domänen, um deinen Arbeitsbereich besser zu organisieren.", "upgrade": "Update", "upgradeYourSpace": "Mehrere Domänen erstellen", "quicklySwitch": "Schnell zur nächsten Domäne wechseln", "duplicate": "Domäne duplizieren", "movePageToSpace": "Seite in die Domäne verschieben", "cannotMovePageToDatabase": "Seite kann nicht in die Datenbank verschoben werden", "switchSpace": "Domäne wechseln", "spaceNameCannotBeEmpty": "Der Space-Name darf nicht leer sein", "success": { "deleteSpace": "Domäne erfolgreich gelöscht", "renameSpace": "Domäne erfolgreich umbenannt", "duplicateSpace": "Domäne erfolgreich dupliziert", "updateSpace": "Domäne erfolgreich angepasst" }, "error": { "deleteSpace": "Löschung der Domäne fehlgeschlagen", "renameSpace": "Umbenennung der Domäne fehlgeschlagen", "duplicateSpace": "Duplizierung der Domäne fehlgeschlagen", "updateSpace": "Anpassung der Domäne fehlgeschlagen" }, "createSpace": "Erstelle Domäne", "manageSpace": "Verwalte Domäne", "renameSpace": "Domäne umbenennen", "mSpaceIconColor": "Domänen-Iconfarbe", "mSpaceIcon": "Domänen-Icon" }, "publish": { "hasNotBeenPublished": "Diese Seite wurde noch nicht veröffentlicht", "reportPage": "Berichtsseite", "databaseHasNotBeenPublished": "Das Veröffentlichen einer Datenbank wird noch nicht unterstützt.", "createdWith": "Erstellt mit", "downloadApp": "AppFlowy herunterladen", "copy": { "codeBlock": "Der Inhalt des Codeblocks wurde in die Zwischenablage kopiert", "imageBlock": "Der Bildlink wurde in die Zwischenablage kopiert", "mathBlock": "Die mathematische Gleichung wurde in die Zwischenablage kopiert" }, "containsPublishedPage": "Diese Seite enthält eine oder mehrere veröffentlichte Seiten. Wenn du fortfährst, werden sie nicht mehr veröffentlicht. Möchtest du mit dem Löschen fortfahren?", "publishSuccessfully": "Erfolgreich veröffentlicht", "unpublishSuccessfully": "Veröffentlichung erfolgreich aufgehoben", "publishFailed": "Veröffentlichung fehlgeschlagen", "unpublishFailed": "Die Veröffentlichung konnte nicht rückgängig gemacht werden.", "noAccessToVisit": "Kein Zugriff auf diese Seite...", "createWithAppFlowy": "Erstelle eine Website mit AppFlowy", "fastWithAI": "Schnell und einfach mit KI.", "tryItNow": "Probiere es jetzt", "onlyGridViewCanBePublished": "Nur die Datentabellenansicht kann veröffentlicht werden", "database": { "zero": "{} ausgewählte Ansichten veröffentlichen", "one": "{} ausgewählte Ansicht veröffentlichen", "many": "{} ausgewählte Ansichten veröffentlichen", "other": "{} ausgewählte Ansichten veröffentlichen" }, "mustSelectPrimaryDatabase": "Die primäre Ansicht muss ausgewählt sein", "noDatabaseSelected": "Keine Datenbank ausgewählt, bitte wähle mindestens eine Datenbank aus.", "unableToDeselectPrimaryDatabase": "Die Auswahl der primären Datenbank kann nicht aufgehoben werden.", "saveThisPage": "Diese Seite speichern", "duplicateTitle": "Wo möchten Sie hinzufügen", "selectWorkspace": "Einen Arbeitsbereich auswählen", "addTo": "Hinzufügen zu", "duplicateSuccessfully": "Duplizierung erfolgreich. Möchtest du die Dokumente anzeigen?", "duplicateSuccessfullyDescription": "Du hast die App nicht? Dein Download beginnt automatisch, nachdem du auf „Herunterladen“ geklickt hast.", "downloadIt": "Herunterladen", "openApp": "In App öffnen", "duplicateFailed": "Duplizierung fehlgeschlagen", "membersCount": { "zero": "Keine Mitglieder", "one": "1 Mitglied", "many": "{count} Mitglieder", "other": "{count} Mitglieder" }, "useThisTemplate": "Verwenden Sie die Vorlage" }, "web": { "continue": "Weiter", "or": "oder", "continueWithGoogle": "Weiter mit Google", "continueWithGithub": "Weiter mit GitHub", "continueWithDiscord": "Weiter mit Discord", "continueWithApple": "Weiter mit Apple ", "moreOptions": "Weitere Optionen", "signInAgreement": "Wenn du oben auf \"Weiter\" klickst, bestätigst du, dass\ndu folgende Dokumente gelesen, verstanden und akzeptiert hast:\nAppFlowys", "and": "und", "termOfUse": "Bedingungen", "privacyPolicy": "Datenschutzrichtlinie", "signInError": "Anmeldefehler", "login": "Registrieren oder anmelden", "fileBlock": { "uploadedAt": "Hochgeladen am {Zeit}", "linkedAt": "Link hinzugefügt am {Zeit}", "empty": "Hochladen oder Einbetten einer Datei" }, "importNotion": "Importieren aus Notion", "import": "Import", "importSuccess": "Erfolgreich hochgeladen", "importFailed": "Import fehlgeschlagen, bitte überprüfen Sie das Dateiformat" }, "globalComment": { "comments": "Kommentare", "addComment": "Einen Kommentar hinzufügen", "reactedBy": "Reaktion von", "addReaction": "Reaktion hinzufügen", "reactedByMore": "und {count} andere", "showSeconds": { "one": "vor 1 Sekunde", "other": "vor {count} Sekunden", "zero": "Soeben", "many": "vor {count} Sekunden" }, "showMinutes": { "one": "vor 1 Minute", "other": "vor {count} Minuten", "many": "vor {count} Minuten" }, "showHours": { "one": "vor 1 Stunde", "other": "vor {count} Stunden", "many": "vor {count} Stunden" }, "showDays": { "one": "vor 1 Tag", "other": "vor {count} Tagen", "many": "vor {count} Tagen" }, "showMonths": { "one": "vor 1 Monat", "other": "vor {count} Monaten", "many": "vor {count} Monaten" }, "showYears": { "one": "vor 1 Jahr", "other": "vor {count} Jahren", "many": "vor {count} Jahren" }, "reply": "Antworten", "deleteComment": "Kommentar löschen", "youAreNotOwner": "Du bist nicht der Verfasser dieses Kommentars", "confirmDeleteDescription": "Möchtest du diesen Kommentar wirklich löschen?", "hasBeenDeleted": "Gelöscht", "replyingTo": "Antwort auf", "noAccessDeleteComment": "Du bist nicht berechtigt, diesen Kommentar zu löschen", "collapse": "Zusammenklappen", "readMore": "Mehr lesen", "failedToAddComment": "Kommentar konnte nicht hinzugefügt werden", "commentAddedSuccessfully": "Kommentar erfolgreich hinzugefügt.", "commentAddedSuccessTip": "Du hast gerade einen Kommentar hinzugefügt oder darauf geantwortet. Möchtest du nach oben springen, um die neuesten Kommentare zu sehen?" }, "template": { "asTemplate": "Als Vorlage speichern", "name": "Vorlagenname", "description": "Vorlagenbeschreibung", "requiredField": "{field} ist erforderlich", "addCategory": "\"{category}\" hinzufügen", "addNewCategory": "Neue Kategorie hinzufügen", "deleteCategory": "Lösche Kategorie", "editCategory": "Bearbeite Kategorie", "category": { "name": "Kategoriename", "icon": "Kategorie-Icon", "bgColor": "Kategorie-Hintergrundfarbe", "priority": "Kategoriepriorität", "desc": "Kategoriebeschreibung", "type": "Kategorie-Typ", "icons": "Kategorie-Icons", "colors": "Kategoriefarbe", "deleteCategory": "Lösche Kategorie", "deleteCategoryDescription": "Möchten Sie diese Kategorie wirklich löschen?", "typeToSearch": "Tippen, um nach Kategorien zu suchen..." } }, "fileDropzone": { "dropFile": "Klicken oder ziehen Sie die Datei zum Hochladen in diesen Bereich", "uploading": "Hochladen...", "uploadFailed": "Hochladen fehlgeschlagen", "uploadSuccess": "Hochladen erfolgreich", "uploadSuccessDescription": "Die Datei wurde erfolgreich hochgeladen", "uploadFailedDescription": "Das Hochladen der Datei ist fehlgeschlagen", "uploadingDescription": "Die Datei wird hochgeladen" }, "gallery": { "preview": "Im Vollbildmodus öffnen", "copy": "Kopiere", "prev": "Vorherige", "next": "Nächste", "resetZoom": "Setze Zoom zurück", "zoomIn": "Vergrößern", "zoomOut": "Verkleinern" }, "invitation": { "joinWorkspace": "Arbeitsbereich beitreten", "success": "Sie sind dem Arbeitsbereich erfolgreich beigetreten", "successMessage": "Sie können nun auf alle darin enthaltenen Seiten und Arbeitsbereiche zugreifen.", "openWorkspace": "Öffne AppFlowy", "errorModal": { "description": "Ihr aktuelles Konto {email} hat möglicherweise keinen Zugriff auf diesen Arbeitsbereich. Bitte melden Sie sich mit dem richtigen Konto an oder wenden Sie sich an den Eigentümer des Arbeitsbereichs, um Hilfe zu erhalten." } }, "approveAccess": { "title": "Anfrage zum Beitritt zum Arbeitsbereich genehmigen", "requestSummary": " fragt den Beitritt zu und den Zugriff auf an." }, "time": { "justNow": "Soeben", "seconds": { "one": "1 Sekunde", "other": "{count} Sekunden" }, "minutes": { "one": "1 Minute", "other": "{count} Minuten" }, "hours": { "one": "1 Stunde", "other": "{count} Stunden" }, "days": { "one": "1 Tag", "other": "{count} Tage" }, "weeks": { "one": "1 Woche", "other": "{count} Wochen" }, "months": { "one": "1 Monat", "other": "{count} Monate" }, "years": { "one": "1 Jahr", "other": "{count} Jahre" }, "ago": "vor", "yesterday": "Gestern", "today": "Heute" }, "members": { "zero": "Keine Mitglieder", "one": "1 Mitglied", "many": "{count} Mitglieder", "other": "{count} Mitglieder" }, "tabMenu": { "close": "Schließen", "closeOthers": "Schließe andere Tabs", "favoriteDisabledHint": "Diese Ansicht kann nicht als Favorit markiert werden" }, "openFileMessage": { "success": "Datei erfolgreich geöffnet", "fileNotFound": "Datei nicht gefunden", "permissionDenied": "Keine Berechtigung zum Öffnen dieser Datei", "unknownError": "Öffnen der Datei fehlgeschlagen" } } ================================================ FILE: frontend/resources/translations/el-GR.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Me", "welcomeText": "Καλωσορίσατε στο @:appName", "welcomeTo": "Καλωσορίσατε στο", "githubStarText": "Star on GitHub", "subscribeNewsletterText": "Εγγραφείτε στο Newsletter", "letsGoButtonText": "Γρήγορη Εκκίνηση", "title": "Τίτλος", "youCanAlso": "Μπορείτε επίσης", "and": "και", "failedToOpenUrl": "Αποτυχία ανοίγματος διεύθυνσης url: {}", "blockActions": { "addBelowTooltip": "Κάντε κλικ για να προσθέσετε παρακάτω", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "για να προσθέσετε παραπάνω", "dragTooltip": "Σύρετε για μετακίνηση", "openMenuTooltip": "Κάντε κλικ για άνοιγμα μενού" }, "signUp": { "buttonText": "Εγγραφή", "title": "Εγγραφείτε στο @:appName", "getStartedText": "Ξεκινήστε", "emptyPasswordError": "Ο κωδικός πρόσβασης δεν μπορεί να είναι κενός", "repeatPasswordEmptyError": "Η επανάληψη κωδικού πρόσβασης δεν μπορεί να είναι κενή", "unmatchedPasswordError": "Η επανάληψη κωδικού πρόσβασης δεν είναι ίδια με τον κωδικό πρόσβασης", "alreadyHaveAnAccount": "Έχετε ήδη λογαριασμό;", "emailHint": "Email", "passwordHint": "Κωδικός", "repeatPasswordHint": "Επαναλάβετε τον κωδικό πρόσβασης", "signUpWith": "Εγγραφή με:" }, "signIn": { "loginTitle": "Συνδεθείτε στο @:appName", "loginButtonText": "Σύνδεση", "loginStartWithAnonymous": "Έναρξη με ανώνυμη συνεδρία", "continueAnonymousUser": "Συνέχεια με ανώνυμη συνεδρία", "buttonText": "Είσοδος", "signingInText": "Πραγματοποιείται σύνδεση...", "forgotPassword": "Ξεχάσατε το κωδικό;", "emailHint": "Email", "passwordHint": "Κωδικός", "dontHaveAnAccount": "Δεν έχετε λογαριασμό;", "repeatPasswordEmptyError": "Η επανάληψη κωδικού πρόσβασης δεν μπορεί να είναι κενή", "unmatchedPasswordError": "Η επανάληψη κωδικού πρόσβασης δεν είναι ίδια με τον κωδικό πρόσβασης", "syncPromptMessage": "Ο συγχρονισμός των δεδομένων μπορεί να διαρκέσει λίγο. Παρακαλώ μην κλείσετε αυτήν τη σελίδα", "or": "- Ή -", "LogInWithGoogle": "Σύνδεση μέσω Google", "LogInWithGithub": "Σύνδεση μέσω Github", "LogInWithDiscord": "Σύνδεση μέσω Discord", "signInWith": "Συνδεθείτε με:" }, "workspace": { "chooseWorkspace": "Επιλέξτε το χώρο εργασίας σας", "create": "Δημιουργία χώρου εργασίας", "reset": "Επαναφορά χώρου εργασίας", "resetWorkspacePrompt": "Η επαναφορά του χώρου εργασίας θα διαγράψει όλες τις σελίδες και τα δεδομένα μέσα σε αυτό. Είστε βέβαιοι ότι θέλετε να επαναφέρετε το χώρο εργασίας? Εναλλακτικά, μπορείτε να επικοινωνήσετε με την ομάδα υποστήριξης για να επαναφέρετε το χώρο εργασίας", "hint": "workspace", "notFoundError": "Workspace not found", "failedToLoad": "Something went wrong! Failed to load the workspace. Try to close any open instance of AppFlowy and try again.", "errorActions": { "reportIssue": "Report an issue", "reportIssueOnGithub": "Report an issue on Github", "exportLogFiles": "Export log files", "reachOut": "Reach out on Discord" }, "deleteWorkspaceHintText": "Are you sure you want to delete the workspace? This action cannot be undone.", "createSuccess": "Workspace created successfully", "createFailed": "Failed to create workspace", "deleteSuccess": "Workspace deleted successfully", "deleteFailed": "Failed to delete workspace", "openSuccess": "Open workspace successfully", "openFailed": "Failed to open workspace", "renameSuccess": "Workspace renamed successfully", "renameFailed": "Failed to rename workspace", "updateIconSuccess": "Updated workspace icon successfully", "updateIconFailed": "Updated workspace icon failed" }, "shareAction": { "buttonText": "Share", "workInProgress": "Coming soon", "markdown": "Markdown", "csv": "CSV", "copyLink": "Copy Link" }, "moreAction": { "small": "small", "medium": "medium", "large": "large", "fontSize": "Font size", "import": "Import", "moreOptions": "More options", "wordCount": "Word count: {}", "charCount": "Character count: {}", "createdAt": "Created: {}", "deleteView": "Delete", "duplicateView": "Duplicate" }, "importPanel": { "textAndMarkdown": "Text & Markdown", "documentFromV010": "Document from v0.1.0", "databaseFromV010": "Database from v0.1.0", "csv": "CSV", "database": "Database" }, "disclosureAction": { "rename": "Rename", "delete": "Delete", "duplicate": "Duplicate", "unfavorite": "Remove from favorites", "favorite": "Προσθήκη στα αγαπημένα", "openNewTab": "Άνοιγμα σε νέα καρτέλα", "moveTo": "Μετακίνηση στο", "addToFavorites": "Προσθήκη στα Αγαπημένα", "copyLink": "Αντιγραφή Συνδέσμου" }, "blankPageTitle": "Κενή σελίδα", "newPageText": "Νέα σελίδα", "newDocumentText": "Νέο έγγραφο", "newGridText": "Νέο πλέγμα", "newCalendarText": "Νέο ημερολόγιο", "newBoardText": "Νέος πίνακας", "trash": { "text": "Κάδος απορριμμάτων", "restoreAll": "Επαναφορά Όλων", "deleteAll": "Διαγραφή Όλων", "pageHeader": { "fileName": "Όνομα αρχείου", "lastModified": "Τελευταία Τροποποίηση", "created": "Δημιουργήθηκε" }, "confirmDeleteAll": { "title": "Είστε βέβαιοι οτι θέλετε να διαγράψετε όλες τις σελίδες στον κάδο απορριμμάτων;", "caption": "Αυτή η ενέργεια δεν μπορεί να ανεραιθεί." }, "confirmRestoreAll": { "title": "Είστε βέβαιοι οτι θέλετε να επαναφέρετε όλες τις σελίδες στον κάδο απορριμμάτων;", "caption": "Αυτή η ενέργεια δεν μπορεί να ανεραιθεί." }, "mobile": { "actions": "Ενέργειες Απορριμμάτων", "empty": "Ο κάδος απορριμμάτων είναι άδειος", "emptyDescription": "Δεν έχετε διαγράψει κανένα αρχείο", "isDeleted": "έχει διαγραφεί", "isRestored": "έγινε επαναφορά" } }, "deletePagePrompt": { "text": "Αυτή η σελίδα βρίσκεται στον κάδο απορριμμάτων", "restore": "Επαναφορά σελίδας", "deletePermanent": "Οριστική διαγραφή" }, "dialogCreatePageNameHint": "Όνομα σελίδας", "questionBubble": { "shortcuts": "Συντομεύσεις", "whatsNew": "Τι νέο υπάρχει;", "help": "Βοήθεια & Υποστήριξη", "markdown": "Markdown", "debug": { "name": "Debug Info", "success": "Copied debug info to clipboard!", "fail": "Unable to copy debug info to clipboard" }, "feedback": "Σχόλια" }, "menuAppHeader": { "moreButtonToolTip": "Αφαίρεση, μετονομασία και άλλα...", "addPageTooltip": "Γρήγορη προσθήκη σελίδας", "defaultNewPageName": "Χωρίς τίτλο", "renameDialog": "Μετονομασία" }, "noPagesInside": "Δεν υπάρχουν σελίδες", "toolbar": { "undo": "Αναίρεση", "redo": "Επαναφορά", "bold": "Έντονo", "italic": "Πλάγια", "underline": "Υπογράμμιση", "strike": "Διακριτή διαγραφή", "numList": "Αριθμημένη λίστα", "bulletList": "Bulleted list", "checkList": "Check List", "inlineCode": "Inline Code", "quote": "Quote Block", "header": "Header", "highlight": "Highlight", "color": "Color", "addLink": "Add Link", "link": "Link" }, "tooltip": { "lightMode": "Switch to Light mode", "darkMode": "Switch to Dark mode", "openAsPage": "Open as a Page", "addNewRow": "Add a new row", "openMenu": "Click to open menu", "dragRow": "Long press to reorder the row", "viewDataBase": "View database", "referencePage": "This {name} is referenced", "addBlockBelow": "Add a block below" }, "sideBar": { "closeSidebar": "Close sidebar", "openSidebar": "Open sidebar", "personal": "Personal", "favorites": "Favorites", "clickToHidePersonal": "Click to hide personal section", "clickToHideFavorites": "Click to hide favorite section", "addAPage": "Add a page", "recent": "Recent" }, "notifications": { "export": { "markdown": "Exported Note To Markdown", "path": "Documents/flowy" } }, "contactsPage": { "title": "Contacts", "whatsHappening": "What's happening this week?", "addContact": "Add Contact", "editContact": "Edit Contact" }, "button": { "ok": "OK", "done": "Done", "cancel": "Cancel", "signIn": "Sign In", "signOut": "Sign Out", "complete": "Complete", "save": "Save", "generate": "Generate", "esc": "ESC", "keep": "Keep", "tryAgain": "Try again", "discard": "Discard", "replace": "Replace", "insertBelow": "Insert below", "insertAbove": "Εισαγωγή από επάνω", "upload": "Μεταφόρτωση", "edit": "Επεξεργασία", "delete": "Διαγραφή", "duplicate": "Δημιουργία διπλότυπου", "putback": "Βάλτε Πίσω", "update": "Ενημέρωση", "share": "Κοινοποίηση", "removeFromFavorites": "Κατάργηση από τα αγαπημένα", "addToFavorites": "Προσθήκη στα αγαπημένα", "rename": "Μετονομασία", "helpCenter": "Κέντρο Βοήθειας", "add": "Προσθήκη", "yes": "Ναι", "clear": "Καθαρισμός", "remove": "Αφαίρεση", "dontRemove": "Να μην αφαιρεθεί", "copyLink": "Αντιγραφή Συνδέσμου", "align": "Στοίχιση", "login": "Σύνδεση", "logout": "Αποσύνδεση", "deleteAccount": "Διαγραφή λογαριασμού", "back": "Πίσω", "signInGoogle": "Συνδεθείτε μέσω λογαριασμού Google", "signInGithub": "Συνδεθείτε μέσω λογαριασμού Github", "signInDiscord": "Συνδεθείτε μέσω λογαριασμού Discord" }, "label": { "welcome": "Καλώς ήρθατε!", "firstName": "Όνομα", "middleName": "Μεσαίο όνομα", "lastName": "Επώνυμο", "stepX": "Step {X}" }, "oAuth": { "err": { "failedTitle": "Αδυναμία σύνδεσης στο λογαριασμό σας.", "failedMsg": "Παρακαλούμε βεβαιωθείτε ότι έχετε ολοκληρώσει τη διαδικασία εισόδου στο πρόγραμμα περιήγησης." }, "google": { "title": "GOOGLE SIGN-IN", "instruction1": "Για να εισαγάγετε τις Επαφές Google σας, θα πρέπει να εξουσιοδοτήσετε αυτήν την εφαρμογή χρησιμοποιώντας το πρόγραμμα περιήγησής σας.", "instruction2": "Αντιγράψτε αυτόν τον κώδικα στο πρόχειρο κάνοντας κλικ στο εικονίδιο ή επιλέγοντας το κείμενο:", "instruction3": "Μεταβείτε στον ακόλουθο σύνδεσμο στο πρόγραμμα περιήγησής σας και πληκτρολογήστε τον παραπάνω κωδικό:", "instruction4": "Πατήστε το κουμπί παρακάτω όταν ολοκληρώσετε την εγγραφή:" } }, "settings": { "title": "Ρυθμίσεις", "menu": { "appearance": "Εμφάνιση", "language": "Γλώσσα", "user": "Χρήστης", "files": "Αρχεία", "notifications": "Ειδοποιήσεις", "open": "Άνοιγμα Ρυθμίσεων", "logout": "Αποσυνδέση", "logoutPrompt": "Είστε σίγουροι ότι θέλετε να αποσυνδεθείτε;", "selfEncryptionLogoutPrompt": "Είστε σίγουροι ότι θέλετε να αποσυνδεθείτε; Παρακαλούμε βεβαιωθείτε ότι έχετε αντιγράψει το κρυπτογραφημένο μυστικό", "syncSetting": "Ρυθμίσεις συγχρονισμού", "cloudSettings": "Ρυθμίσεις Cloud", "enableSync": "Enable sync", "enableEncrypt": "Encrypt data", "cloudURL": "Base URL", "invalidCloudURLScheme": "Invalid Scheme", "cloudServerType": "Cloud server", "cloudServerTypeTip": "Please note that it might log out your current account after switching the cloud server", "cloudLocal": "Local", "cloudAppFlowy": "AppFlowy Cloud", "cloudAppFlowySelfHost": "AppFlowy Cloud Self-hosted", "appFlowyCloudUrlCanNotBeEmpty": "The cloud url can't be empty", "clickToCopy": "Click to copy", "selfHostStart": "If you don't have a server, please refer to the", "selfHostContent": "document", "selfHostEnd": "for guidance on how to self-host your own server", "cloudURLHint": "Input the base URL of your server", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Input the websocket address of your server", "restartApp": "Restart", "restartAppTip": "Restart the application for the changes to take effect. Please note that this might log out your current account", "changeServerTip": "After changing the server, you must click the restart button for the changes to take effect", "enableEncryptPrompt": "Activate encryption to secure your data with this secret. Store it safely; once enabled, it can't be turned off. If lost, your data becomes irretrievable. Click to copy", "inputEncryptPrompt": "Please enter your encryption secret for", "clickToCopySecret": "Click to copy secret", "configServerSetting": "Configurate your server settings", "configServerGuide": "After selecting `Quick Start`, navigate to `Settings` and then \"Cloud Settings\" to configure your self-hosted server.", "inputTextFieldHint": "Your secret", "historicalUserList": "User login history", "historicalUserListTooltip": "This list displays your anonymous accounts. You can click on an account to view its details. Anonymous accounts are created by clicking the 'Get Started' button", "openHistoricalUser": "Click to open the anonymous account", "customPathPrompt": "Storing the AppFlowy data folder in a cloud-synced folder such as Google Drive can pose risks. If the database within this folder is accessed or modified from multiple locations at the same time, it may result in synchronization conflicts and potential data corruption", "importAppFlowyData": "Import Data from External AppFlowy Folder", "importingAppFlowyDataTip": "Data import is in progress. Please do not close the app", "importAppFlowyDataDescription": "Copy data from an external AppFlowy data folder and import it into the current AppFlowy data folder", "importSuccess": "Successfully imported the AppFlowy data folder", "importFailed": "Importing the AppFlowy data folder failed", "importGuide": "For further details, please check the referenced document" }, "notifications": { "enableNotifications": { "label": "Enable notifications", "hint": "Turn off to stop local notifications from appearing." } }, "appearance": { "resetSetting": "Reset", "fontFamily": { "label": "Font Family", "search": "Search" }, "themeMode": { "label": "Theme Mode", "light": "Light Mode", "dark": "Σκοτεινό Θέμα", "system": "Προσαρμογή στο σύστημα" }, "fontScaleFactor": "Font Scale Factor", "documentSettings": { "cursorColor": "Χρώμα κέρσορα εγγράφου", "selectionColor": "Χρώμα επιλογής κειμένου", "hexEmptyError": "Το χρώμα σε δεκαεξαδική μορφή δεν μπορεί να είναι κενό", "hexLengthError": "Η τιμή δεκαεξαδικού πρέπει να είναι 6 ψηφία", "hexInvalidError": "Μη έγκυρη τιμή δεκαεξαδικού", "opacityEmptyError": "Η διαφάνεια δεν μπορεί να είναι κενή", "opacityRangeError": "Η διαφάνεια πρέπει να είναι μεταξύ 1 και 100", "app": "Εφαρμογή", "flowy": "Flowy", "apply": "Apply" }, "layoutDirection": { "label": "Κατεύθυνση Διάταξης", "hint": "Ελέγξτε τη ροή του περιεχομένου στην οθόνη σας, από αριστερά προς τα δεξιά ή δεξιά προς τα αριστερά.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "Προεπιλεγμένη κατεύθυνση κειμένου", "hint": "Καθορίστε αν το κείμενο θα ξεκινά από αριστερά ή δεξιά ως προεπιλογή.", "ltr": "LTR", "rtl": "RTL", "auto": "AUTO", "fallback": "Ίδια με την κατεύθυνση διάταξης" }, "themeUpload": { "button": "Μεταφόρτωση", "uploadTheme": "Μεταφόρτωση θέματος", "description": "Ανεβάστε το δικό σας θέμα για το AppFlowy χρησιμοποιώντας το παρακάτω κουμπί.", "loading": "Παρακαλώ περιμένετε ενώ επικυρώνουμε και ανεβάζουμε το θέμα σας...", "uploadSuccess": "Το θέμα σας μεταφορτώθηκε με επιτυχία", "deletionFailure": "Αποτυχία διαγραφής του θέματος. Προσπαθήστε να το διαγράψετε χειροκίνητα.", "filePickerDialogTitle": "Επιλέξτε ένα αρχείο .flowy_plugin", "urlUploadFailure": "Αποτυχία ανοίγματος url: {}" }, "theme": "Θέμα", "builtInsLabel": "Ενσωματωμένα Θέματα", "pluginsLabel": "Πρόσθετα", "dateFormat": { "label": "Μορφή ημερομηνίας", "local": "Τοπική", "us": "US", "iso": "ISO", "friendly": "Friendly", "dmy": "D/M/Y" }, "timeFormat": { "label": "Μορφή ώρας", "twelveHour": "12 ώρες", "twentyFourHour": "24 ώρες" }, "showNamingDialogWhenCreatingPage": "Εμφάνιση διαλόγου ονομασίας κατά τη δημιουργία μιας σελίδας", "enableRTLToolbarItems": "Enable RTL toolbar items", "members": { "title": "Members Settings", "inviteMembers": "Πρόσκληση Μέλους", "sendInvite": "Αποστολή Πρόσκλησης", "copyInviteLink": "Αντιγραφή Συνδέσμου Πρόσκλησης", "label": "Μέλη", "user": "User", "role": "Role", "removeFromWorkspace": "Remove from Workspace", "owner": "Owner", "guest": "Guest", "member": "Member", "memberHintText": "A member can read, comment, and edit pages. Invite members and guests.", "guestHintText": "A Guest can read, react, comment, and can edit certain pages with permission.", "emailInvalidError": "Invalid email, please check and try again", "emailSent": "Email sent, please check the inbox", "members": "members" } }, "files": { "copy": "Copy", "defaultLocation": "Read files and data storage location", "exportData": "Export your data", "doubleTapToCopy": "Double tap to copy the path", "restoreLocation": "Restore to AppFlowy default path", "customizeLocation": "Open another folder", "restartApp": "Please restart app for the changes to take effect.", "exportDatabase": "Export database", "selectFiles": "Select the files that need to be export", "selectAll": "Select all", "deselectAll": "Deselect all", "createNewFolder": "Create a new folder", "createNewFolderDesc": "Tell us where you want to store your data", "defineWhereYourDataIsStored": "Define where your data is stored", "open": "Open", "openFolder": "Open an existing folder", "openFolderDesc": "Read and write it to your existing AppFlowy folder", "folderHintText": "folder name", "location": "Creating a new folder", "locationDesc": "Pick a name for your AppFlowy data folder", "browser": "Browse", "create": "Create", "set": "Set", "folderPath": "Path to store your folder", "locationCannotBeEmpty": "Path cannot be empty", "pathCopiedSnackbar": "File storage path copied to clipboard!", "changeLocationTooltips": "Change the data directory", "change": "Αλλαγή", "openLocationTooltips": "Open another data directory", "openCurrentDataFolder": "Άνοιγμα του τρέχοντος φακέλου", "recoverLocationTooltips": "Reset to AppFlowy's default data directory", "exportFileSuccess": "Επιτυχής εξαγωγή αρχείου!", "exportFileFail": "Η εξαγωγή αρχείου απέτυχε!", "export": "Εξαγωγή", "clearCache": "Εκκαθάριση προσωρινής μνήμης", "clearCacheDesc": "Αν αντιμετωπίζετε προβλήματα με εικόνες που δεν φορτώνουν ή γραμματοσειρές που δεν εμφανίζονται σωστά, δοκιμάστε να καθαρίσετε την προσωρινή μνήμη. Αυτή η ενέργεια δεν θα διαγράψει τα δεδομένα χρήστη σας.", "areYouSureToClearCache": "Σίγουρα θέλετε να καθαρίσετε την προσωρινή μνήμη;", "clearCacheSuccess": "Επιτυχής εκκαθάριση προσωρινής μνήμης!" }, "user": { "name": "Όνομα", "email": "Email", "tooltipSelectIcon": "Select icon", "selectAnIcon": "Select an icon", "pleaseInputYourOpenAIKey": "παρακαλώ εισάγετε το AI κλειδί σας", "pleaseInputYourStabilityAIKey": "παρακαλώ εισάγετε το Stability AI κλειδί σας", "clickToLogout": "Κάντε κλικ για αποσύνδεση του τρέχοντος χρήστη" }, "shortcuts": { "shortcutsLabel": "Συντομεύσεις", "command": "Command", "keyBinding": "Keybinding", "addNewCommand": "Προσθήκη Νέας Εντολής", "updateShortcutStep": "Πατήστε τον επιθυμητό συνδυασμό πλήκτρων και πατήστε ENTER", "shortcutIsAlreadyUsed": "Αυτή η συντόμευση χρησιμοποιείται ήδη για: {conflict}", "resetToDefault": "Επαναφορά προεπιλεγμένων συντομεύσεων πληκτρολογίου", "couldNotLoadErrorMsg": "Αδυναμία φόρτωσης συντομεύσεων, Προσπαθήστε ξανά", "couldNotSaveErrorMsg": "Δεν ήταν δυνατή η αποθήκευση συντομεύσεων, Προσπαθήστε ξανά" }, "mobile": { "personalInfo": "Προσωπικά Στοιχεία", "username": "Όνομα Χρήστη", "usernameEmptyError": "Το όνομα χρήστη δεν μπορεί να είναι κενό", "about": "Σχετικά", "pushNotifications": "Ειδοποιήσεις Push", "support": "Υποστήριξη", "joinDiscord": "Ελάτε μαζί μας στο Discord", "privacyPolicy": "Πολιτική Απορρήτου", "userAgreement": "Όροι Χρήσης", "termsAndConditions": "Όροι και Προϋποθέσεις", "userprofileError": "Αποτυχία φόρτωσης προφίλ χρήστη", "userprofileErrorDescription": "Παρακαλώ προσπαθήστε να αποσυνδεθείτε και να συνδεθείτε ξανά για να ελέγξετε αν το πρόβλημα εξακολουθεί να υπάρχει.", "selectLayout": "Επιλέξτε διάταξη", "selectStartingDay": "Επιλέξτε ημέρα έναρξης", "version": "Έκδοση" } }, "grid": { "deleteView": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν τη προβολή;", "createView": "Νέο", "title": { "placeholder": "Χωρίς τίτλο" }, "settings": { "filter": "Φίλτρο", "sort": "Ταξινόμηση", "sortBy": "Ταξινόμηση κατά", "properties": "Properties", "reorderPropertiesTooltip": "Drag to reorder properties", "group": "Group", "addFilter": "Add Filter", "deleteFilter": "Delete filter", "filterBy": "Filter by...", "typeAValue": "Type a value...", "layout": "Layout", "databaseLayout": "Layout", "viewList": { "zero": "0 views", "one": "{count} view", "other": "{count} views" }, "editView": "Edit View", "boardSettings": "Board settings", "calendarSettings": "Calendar settings", "createView": "New view", "duplicateView": "Duplicate view", "deleteView": "Delete view", "numberOfVisibleFields": "{} shown" }, "textFilter": { "contains": "Contains", "doesNotContain": "Does not contain", "endsWith": "Ends with", "startWith": "Starts with", "is": "Is", "isNot": "Is not", "isEmpty": "Is empty", "isNotEmpty": "Is not empty", "choicechipPrefix": { "isNot": "Not", "startWith": "Starts with", "endWith": "Ends with", "isEmpty": "is empty", "isNotEmpty": "is not empty" } }, "checkboxFilter": { "isChecked": "Checked", "isUnchecked": "Unchecked", "choicechipPrefix": { "is": "is" } }, "checklistFilter": { "isComplete": "is complete", "isIncomplted": "is incomplete" }, "selectOptionFilter": { "is": "Is", "isNot": "Is not", "contains": "Contains", "doesNotContain": "Does not contain", "isEmpty": "Είναι κενό", "isNotEmpty": "Δεν είναι κενό" }, "dateFilter": { "is": "Is", "before": "Is before", "after": "Is after", "onOrBefore": "Is on or before", "onOrAfter": "Is on or after", "between": "Is between", "empty": "Είναι κενό", "notEmpty": "Δεν είναι κενό", "choicechipPrefix": { "before": "Before", "after": "After", "onOrBefore": "On or before", "onOrAfter": "On or after", "isEmpty": "Is empty", "isNotEmpty": "Is not empty" } }, "numberFilter": { "equal": "Equals", "notEqual": "Δεν ισούται με", "lessThan": "Είναι μικρότερο από", "greaterThan": "Είναι μεγαλύτερο από", "lessThanOrEqualTo": "Είναι μικρότερο από ή ίσο με", "greaterThanOrEqualTo": "Είναι μεγαλύτερο από ή ίσο με", "isEmpty": "Είναι κενό", "isNotEmpty": "Δεν είναι κενό" }, "field": { "hide": "Απόκρυψη", "show": "Εμφάνιση", "insertLeft": "Εισαγωγή από αριστερά", "insertRight": "Εισαγωγή από δεξιά", "duplicate": "Διπλότυπο", "delete": "Διαγραφή", "textFieldName": "Κείμενο", "checkboxFieldName": "Checkbox", "dateFieldName": "Date", "updatedAtFieldName": "Τελευταία τροποποίηση", "createdAtFieldName": "Δημιουργήθηκε στις", "numberFieldName": "Numbers", "singleSelectFieldName": "Επιλογή", "multiSelectFieldName": "Multiselect", "urlFieldName": "URL", "checklistFieldName": "Checklist", "relationFieldName": "Relation", "numberFormat": "Μορφή αριθμού", "dateFormat": "Μορφή ημερομηνίας", "includeTime": "Περιλαμβάνει χρόνο", "isRange": "End date", "dateFormatFriendly": "Μήνας Ημέρα, Έτος", "dateFormatISO": "Έτος-Μήνας-Ημέρα", "dateFormatLocal": "Μήνας/Ημέρα/Έτος", "dateFormatUS": "Έτος/Μήνας/Ημέρα", "dateFormatDayMonthYear": "Ημέρα/Μήνας/Έτος", "timeFormat": "Time format", "invalidTimeFormat": "Invalid format", "timeFormatTwelveHour": "12 hour", "timeFormatTwentyFourHour": "24 hour", "clearDate": "Clear date", "dateTime": "Date time", "startDateTime": "Start date time", "endDateTime": "End date time", "failedToLoadDate": "Failed to load date value", "selectTime": "Select time", "selectDate": "Select date", "visibility": "Visibility", "propertyType": "Property type", "addSelectOption": "Add an option", "typeANewOption": "Type a new option", "optionTitle": "Options", "addOption": "Add option", "editProperty": "Edit property", "newProperty": "New property", "deleteFieldPromptMessage": "Are you sure? This property will be deleted", "newColumn": "New Column", "format": "Format", "reminderOnDateTooltip": "This cell has a scheduled reminder", "optionAlreadyExist": "Option already exists" }, "rowPage": { "newField": "Add a new field", "fieldDragElementTooltip": "Click to open menu", "showHiddenFields": { "one": "Show {count} hidden field", "many": "Show {count} hidden fields", "other": "Show {count} hidden fields" }, "hideHiddenFields": { "one": "Απόκρυψη {count} κρυφού πεδίου", "many": "Απόκρυψη {count} κρυφών πεδίων", "other": "Απόκρυψη {count} κρυφών πεδίων" } }, "sort": { "ascending": "Αύξουσα", "descending": "Φθίνουσα", "by": "By", "empty": "No active sorts", "cannotFindCreatableField": "Αδυναμία εύρεσης κατάλληλου πεδίου για ταξινόμηση", "deleteAllSorts": "Delete all sorts", "addSort": "Add new sort", "removeSorting": "Θα θέλατε να αφαιρέσετε τη ταξινόμηση;", "fieldInUse": "You are already sorting by this field" }, "row": { "duplicate": "Duplicate", "delete": "Διαγραφή στήλης", "titlePlaceholder": "Χωρίς τίτλο", "textPlaceholder": "Άδειο", "copyProperty": "Copied property to clipboard", "count": "Count", "newRow": "Νέα γραμμή", "action": "Action", "add": "Click add to below", "drag": "Σύρετε για μετακίνηση", "dragAndClick": "Drag to move, click to open menu", "insertRecordAbove": "Εισαγωγή εγγραφής επάνω", "insertRecordBelow": "Εισαγωγή εγγραφής κάτω" }, "selectOption": { "create": "Δημιουργία", "purpleColor": "Μωβ", "pinkColor": "Ροζ", "lightPinkColor": "Απαλό ροζ", "orangeColor": "Πορτοκαλί", "yellowColor": "Κίτρινο", "limeColor": "Λάιμ", "greenColor": "Πράσινο", "aquaColor": "Θαλασσί", "blueColor": "Μπλέ", "deleteTag": "Διαγραφή ετικέτας", "colorPanelTitle": "Χρώμα", "panelTitle": "Select an option or create one", "searchOption": "Search for an option", "searchOrCreateOption": "Search or create an option...", "createNew": "Δημιουργία νέας", "orSelectOne": "Or select an option", "typeANewOption": "Type a new option", "tagName": "Όνομα ετικέτας" }, "checklist": { "taskHint": "Περιγραφή εργασίας", "addNew": "Προσθήκη νέας εργασίας", "submitNewTask": "Δημιουργία", "hideComplete": "Απόκρυψη ολοκληρωμένων εργασιών", "showComplete": "Εμφάνιση όλων των εργασιών" }, "url": { "launch": "Άνοιγμα συνδέσμου στο πρόγραμμα περιήγησης", "copy": "Copy link to clipboard", "textFieldHint": "Enter a URL" }, "relation": { "relatedDatabasePlaceLabel": "Related Database", "relatedDatabasePlaceholder": "None", "inRelatedDatabase": "In", "rowSearchTextFieldPlaceholder": "Search", "noDatabaseSelected": "No database selected, please select one first from the list below:", "emptySearchResult": "No records found" }, "menuName": "Grid", "referencedGridPrefix": "View of", "calculate": "Calculate", "calculationTypeLabel": { "none": "None", "average": "Average", "max": "Max", "median": "Median", "min": "Min", "sum": "Sum", "count": "Count", "countEmpty": "Count empty", "countEmptyShort": "EMPTY", "countNonEmpty": "Count not empty", "countNonEmptyShort": "FILLED" } }, "document": { "menuName": "Document", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Select a Board to link to", "createANewBoard": "Create a new Board" }, "grid": { "selectAGridToLinkTo": "Select a Grid to link to", "createANewGrid": "Create a new Grid" }, "calendar": { "selectACalendarToLinkTo": "Select a Calendar to link to", "createANewCalendar": "Create a new Calendar" }, "document": { "selectADocumentToLinkTo": "Select a Document to link to" } }, "selectionMenu": { "outline": "Outline", "codeBlock": "Code Block" }, "plugins": { "referencedBoard": "Referenced Board", "referencedGrid": "Referenced Grid", "referencedCalendar": "Referenced Calendar", "referencedDocument": "Referenced Document", "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Ask AI to write anything...", "autoGeneratorLearnMore": "Μάθετε περισσότερα", "autoGeneratorGenerate": "Generate", "autoGeneratorHintText": "Ρωτήστε Το AI ...", "autoGeneratorCantGetOpenAIKey": "Αδυναμία λήψης κλειδιού AI", "autoGeneratorRewrite": "Rewrite", "smartEdit": "AI Assistants", "aI": "AI", "smartEditFixSpelling": "Διόρθωση ορθογραφίας", "warning": "⚠️ Οι απαντήσεις AI μπορεί να είναι ανακριβείς ή παραπλανητικές.", "smartEditSummarize": "Summarize", "smartEditImproveWriting": "Improve writing", "smartEditMakeLonger": "Make longer", "smartEditCouldNotFetchResult": "Could not fetch result from AI", "smartEditCouldNotFetchKey": "Could not fetch AI key", "smartEditDisabled": "Connect AI in Settings", "discardResponse": "Do you want to discard the AI responses?", "createInlineMathEquation": "Create equation", "fonts": "Γραμματοσειρές", "toggleList": "Toggle list", "quoteList": "Quote list", "numberedList": "Αριθμημένη λίστα", "bulletedList": "Bulleted list", "todoList": "Todo list", "callout": "Callout", "cover": { "changeCover": "Change Cover", "colors": "Χρώματα", "images": "Εικόνες", "clearAll": "Εκκαθάριση όλων", "abstract": "Abstract", "addCover": "Προσθέστε ένα εξώφυλλο", "addLocalImage": "Add local image", "invalidImageUrl": "Μη έγκυρο URL εικόνας", "failedToAddImageToGallery": "Failed to add image to gallery", "enterImageUrl": "Enter image URL", "add": "Add", "back": "Back", "saveToGallery": "Save to gallery", "removeIcon": "Remove icon", "pasteImageUrl": "Paste image URL", "or": "OR", "pickFromFiles": "Pick from files", "couldNotFetchImage": "Could not fetch image", "imageSavingFailed": "Image Saving Failed", "addIcon": "Add icon", "changeIcon": "Change icon", "coverRemoveAlert": "It will be removed from cover after it is deleted.", "alertDialogConfirmation": "Are you sure, you want to continue?" }, "mathEquation": { "name": "Math Equation", "addMathEquation": "Add a TeX equation", "editMathEquation": "Edit Math Equation" }, "optionAction": { "click": "Click", "toOpenMenu": " to open menu", "delete": "Delete", "duplicate": "Duplicate", "turnInto": "Turn into", "moveUp": "Move up", "moveDown": "Move down", "color": "Color", "align": "Align", "left": "Left", "center": "Center", "right": "Right", "defaultColor": "Default", "depth": "Depth" }, "image": { "copiedToPasteBoard": "The image link has been copied to the clipboard", "addAnImage": "Add an image", "imageUploadFailed": "Image upload failed" }, "urlPreview": { "copiedToPasteBoard": "The link has been copied to the clipboard", "convertToLink": "Convert to embed link" }, "outline": { "addHeadingToCreateOutline": "Add headings to create a table of contents.", "noMatchHeadings": "No matching headings found." }, "table": { "addAfter": "Add after", "addBefore": "Add before", "delete": "Delete", "clear": "Clear content", "duplicate": "Duplicate", "bgColor": "Background color" }, "contextMenu": { "copy": "Copy", "cut": "Cut", "paste": "Paste" }, "action": "Actions", "database": { "selectDataSource": "Select data source", "noDataSource": "No data source", "selectADataSource": "Select a data source", "toContinue": "to continue", "newDatabase": "New Database", "linkToDatabase": "Link to Database" }, "date": "Date", "emoji": "Emoji" }, "outlineBlock": { "placeholder": "Table of Contents" }, "textBlock": { "placeholder": "Type '/' for commands" }, "title": { "placeholder": "Untitled" }, "imageBlock": { "placeholder": "Click to add image", "upload": { "label": "Upload", "placeholder": "Click to upload image" }, "url": { "label": "Image URL", "placeholder": "Enter image URL" }, "ai": { "label": "Generate image from AI", "placeholder": "Please input the prompt for AI to generate image" }, "stability_ai": { "label": "Generate image from Stability AI", "placeholder": "Please input the prompt for Stability AI to generate image" }, "support": "Image size limit is 5MB. Supported formats: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Invalid image", "invalidImageSize": "Image size must be less than 5MB", "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "Invalid image URL", "noImage": "No such file or directory" }, "embedLink": { "label": "Embed link", "placeholder": "Paste or type an image link" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Search for an image", "pleaseInputYourOpenAIKey": "please input your AI key in Settings page", "pleaseInputYourStabilityAIKey": "please input your Stability AI key in Settings page", "saveImageToGallery": "Save image", "failedToAddImageToGallery": "Failed to add image to gallery", "successToAddImageToGallery": "Image added to gallery successfully", "unableToLoadImage": "Unable to load image", "maximumImageSize": "Maximum supported upload image size is 10MB", "uploadImageErrorImageSizeTooBig": "Image size must be less than 10MB", "imageIsUploading": "Image is uploading" }, "codeBlock": { "language": { "label": "Language", "placeholder": "Select language" } }, "inlineLink": { "placeholder": "Paste or type a link", "openInNewTab": "Open in new tab", "copyLink": "Copy link", "removeLink": "Remove link", "url": { "label": "Link URL", "placeholder": "Enter link URL" }, "title": { "label": "Link Title", "placeholder": "Enter link title" } }, "mention": { "placeholder": "Mention a person or a page or date...", "page": { "label": "Link to page", "tooltip": "Click to open page" }, "deleted": "Deleted", "deletedContent": "This content does not exist or has been deleted" }, "toolbar": { "resetToDefaultFont": "Reset to default" }, "errorBlock": { "theBlockIsNotSupported": "The current version does not support this block.", "blockContentHasBeenCopied": "The block content has been copied." } }, "board": { "column": { "createNewCard": "New", "renameGroupTooltip": "Press to rename group", "createNewColumn": "Add a new group", "addToColumnTopTooltip": "Add a new card at the top", "addToColumnBottomTooltip": "Add a new card at the bottom", "renameColumn": "Rename", "hideColumn": "Hide", "newGroup": "New Group", "deleteColumn": "Delete", "deleteColumnConfirmation": "This will delete this group and all the cards in it.\nAre you sure you want to continue?" }, "hiddenGroupSection": { "sectionTitle": "Hidden Groups", "collapseTooltip": "Hide the hidden groups", "expandTooltip": "View the hidden groups" }, "cardDetail": "Card Detail", "cardActions": "Card Actions", "cardDuplicated": "Card has been duplicated", "cardDeleted": "Card has been deleted", "showOnCard": "Show on card detail", "setting": "Setting", "propertyName": "Property name", "menuName": "Board", "showUngrouped": "Show ungrouped items", "ungroupedButtonText": "Ungrouped", "ungroupedButtonTooltip": "Contains cards that don't belong in any group", "ungroupedItemsTitle": "Click to add to the board", "groupBy": "Group by", "referencedBoardPrefix": "View of", "notesTooltip": "Notes inside", "mobile": { "editURL": "Edit URL", "showGroup": "Show group", "showGroupContent": "Are you sure you want to show this group on the board?", "failedToLoad": "Failed to load board view" } }, "calendar": { "menuName": "Calendar", "defaultNewCalendarTitle": "Untitled", "newEventButtonTooltip": "Add a new event", "navigation": { "today": "Today", "jumpToday": "Jump to Today", "previousMonth": "Previous Month", "nextMonth": "Next Month" }, "mobileEventScreen": { "emptyTitle": "No events yet", "emptyBody": "Press the plus button to create an event on this day." }, "settings": { "showWeekNumbers": "Show week numbers", "showWeekends": "Show weekends", "firstDayOfWeek": "Start week on", "layoutDateField": "Layout calendar by", "changeLayoutDateField": "Change layout field", "noDateTitle": "No Date", "noDateHint": { "zero": "Unscheduled events will show up here", "one": "{count} unscheduled event", "other": "{count} unscheduled events" }, "unscheduledEventsTitle": "Unscheduled events", "clickToAdd": "Click to add to the calendar", "name": "Calendar settings" }, "referencedCalendarPrefix": "View of", "quickJumpYear": "Jump to", "duplicateEvent": "Duplicate event" }, "errorDialog": { "title": "AppFlowy Error", "howToFixFallback": "We're sorry for the inconvenience! Submit an issue on our GitHub page that describes your error.", "github": "View on GitHub" }, "search": { "label": "Search", "placeholder": { "actions": "Search actions..." } }, "message": { "copy": { "success": "Copied!", "fail": "Unable to copy" } }, "unSupportBlock": "The current version does not support this Block.", "views": { "deleteContentTitle": "Are you sure want to delete the {pageType}?", "deleteContentCaption": "if you delete this {pageType}, you can restore it from the trash." }, "colors": { "custom": "Custom", "default": "Default", "red": "Red", "orange": "Orange", "yellow": "Yellow", "green": "Green", "blue": "Blue", "purple": "Purple", "pink": "Pink", "brown": "Brown", "gray": "Gray" }, "emoji": { "emojiTab": "Emoji", "search": "Search emoji", "noRecent": "No recent emoji", "noEmojiFound": "No emoji found", "filter": "Filter", "random": "Random", "selectSkinTone": "Select skin tone", "remove": "Remove emoji", "categories": { "smileys": "Smileys & Emotion", "people": "People & Body", "animals": "Animals & Nature", "food": "Food & Drink", "activities": "Activities", "places": "Travel & Places", "objects": "Objects", "symbols": "Symbols", "flags": "Flags", "nature": "Nature", "frequentlyUsed": "Frequently Used" }, "skinTone": { "default": "Default", "light": "Light", "mediumLight": "Medium-Light", "medium": "Medium", "mediumDark": "Medium-Dark", "dark": "Dark" } }, "inlineActions": { "noResults": "No results", "pageReference": "Page reference", "docReference": "Document reference", "boardReference": "Board reference", "calReference": "Calendar reference", "gridReference": "Grid reference", "date": "Date", "reminder": { "groupTitle": "Reminder", "shortKeyword": "remind" } }, "datePicker": { "dateTimeFormatTooltip": "Change the date and time format in settings", "dateFormat": "Date format", "includeTime": "Include time", "isRange": "End date", "timeFormat": "Time format", "clearDate": "Clear date", "reminderLabel": "Reminder", "selectReminder": "Select reminder", "reminderOptions": { "none": "None", "atTimeOfEvent": "Time of event", "fiveMinsBefore": "5 mins before", "tenMinsBefore": "10 mins before", "fifteenMinsBefore": "15 mins before", "thirtyMinsBefore": "30 mins before", "oneHourBefore": "1 hour before", "twoHoursBefore": "2 hours before", "onDayOfEvent": "On day of event", "oneDayBefore": "1 day before", "twoDaysBefore": "2 days before", "oneWeekBefore": "1 week before", "custom": "Custom" } }, "relativeDates": { "yesterday": "Yesterday", "today": "Today", "tomorrow": "Tomorrow", "oneWeek": "1 week" }, "notificationHub": { "title": "Notifications", "mobile": { "title": "Updates" }, "emptyTitle": "All caught up!", "emptyBody": "No pending notifications or actions. Enjoy the calm.", "tabs": { "inbox": "Inbox", "upcoming": "Upcoming" }, "actions": { "markAllRead": "Mark all as read", "showAll": "All", "showUnreads": "Unread" }, "filters": { "ascending": "Ascending", "descending": "Descending", "groupByDate": "Group by date", "showUnreadsOnly": "Show unreads only", "resetToDefault": "Reset to default" } }, "reminderNotification": { "title": "Reminder", "message": "Remember to check this before you forget!", "tooltipDelete": "Delete", "tooltipMarkRead": "Mark as read", "tooltipMarkUnread": "Mark as unread" }, "findAndReplace": { "find": "Find", "previousMatch": "Previous match", "nextMatch": "Next match", "close": "Close", "replace": "Replace", "replaceAll": "Replace all", "noResult": "No results", "caseSensitive": "Case sensitive", "searchMore": "Search to find more results" }, "error": { "weAreSorry": "We're sorry", "loadingViewError": "We're having trouble loading this view. Please check your internet connection, refresh the app, and do not hesitate to reach out to the team if the issue continues." }, "editor": { "bold": "Bold", "bulletedList": "Bulleted list", "bulletedListShortForm": "Bulleted", "checkbox": "Checkbox", "embedCode": "Embed Code", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Highlight", "color": "Color", "image": "Image", "date": "Date", "italic": "Italic", "link": "Link", "numberedList": "Numbered list", "numberedListShortForm": "Numbered", "quote": "Quote", "strikethrough": "Strikethrough", "text": "Text", "underline": "Underline", "fontColorDefault": "Default", "fontColorGray": "Gray", "fontColorBrown": "Brown", "fontColorOrange": "Orange", "fontColorYellow": "Yellow", "fontColorGreen": "Green", "fontColorBlue": "Blue", "fontColorPurple": "Purple", "fontColorPink": "Pink", "fontColorRed": "Red", "backgroundColorDefault": "Default background", "backgroundColorGray": "Gray background", "backgroundColorBrown": "Brown background", "backgroundColorOrange": "Orange background", "backgroundColorYellow": "Yellow background", "backgroundColorGreen": "Green background", "backgroundColorBlue": "Blue background", "backgroundColorPurple": "Purple background", "backgroundColorPink": "Pink background", "backgroundColorRed": "Red background", "backgroundColorLime": "Lime background", "backgroundColorAqua": "Aqua background", "done": "Done", "cancel": "Cancel", "tint1": "Tint 1", "tint2": "Tint 2", "tint3": "Tint 3", "tint4": "Tint 4", "tint5": "Tint 5", "tint6": "Tint 6", "tint7": "Tint 7", "tint8": "Tint 8", "tint9": "Tint 9", "lightLightTint1": "Purple", "lightLightTint2": "Pink", "lightLightTint3": "Light Pink", "lightLightTint4": "Orange", "lightLightTint5": "Yellow", "lightLightTint6": "Lime", "lightLightTint7": "Green", "lightLightTint8": "Aqua", "lightLightTint9": "Blue", "urlHint": "URL", "mobileHeading1": "Heading 1", "mobileHeading2": "Heading 2", "mobileHeading3": "Heading 3", "textColor": "Text Color", "backgroundColor": "Background Color", "addYourLink": "Add your link", "openLink": "Open link", "copyLink": "Copy link", "removeLink": "Remove link", "editLink": "Edit link", "linkText": "Text", "linkTextHint": "Please enter text", "linkAddressHint": "Please enter URL", "highlightColor": "Highlight color", "clearHighlightColor": "Clear highlight color", "customColor": "Custom color", "hexValue": "Hex value", "opacity": "Opacity", "resetToDefaultColor": "Reset to default color", "ltr": "LTR", "rtl": "RTL", "auto": "Auto", "cut": "Cut", "copy": "Copy", "paste": "Paste", "find": "Find", "previousMatch": "Previous match", "nextMatch": "Next match", "closeFind": "Close", "replace": "Replace", "replaceAll": "Replace all", "regex": "Regex", "caseSensitive": "Case sensitive", "uploadImage": "Upload Image", "urlImage": "URL Image", "incorrectLink": "Incorrect Link", "upload": "Upload", "chooseImage": "Choose an image", "loading": "Loading", "imageLoadFailed": "Could not load the image", "divider": "Divider", "table": "Table", "colAddBefore": "Add before", "rowAddBefore": "Add before", "colAddAfter": "Add after", "rowAddAfter": "Add after", "colRemove": "Remove", "rowRemove": "Remove", "colDuplicate": "Duplicate", "rowDuplicate": "Duplicate", "colClear": "Clear Content", "rowClear": "Clear Content", "slashPlaceHolder": "Type '/' to insert a block, or start typing", "typeSomething": "Type something...", "toggleListShortForm": "Toggle", "quoteListShortForm": "Quote", "mathEquationShortForm": "Formula", "codeBlockShortForm": "Code" }, "favorite": { "noFavorite": "No favorite page", "noFavoriteHintText": "Swipe the page to the left to add it to your favorites" }, "cardDetails": { "notesPlaceholder": "Enter a / to insert a block, or start typing" }, "blockPlaceholders": { "todoList": "To-do", "bulletList": "List", "numberList": "List", "quote": "Quote", "heading": "Heading {}" }, "titleBar": { "pageIcon": "Page icon", "language": "Language", "font": "Font", "actions": "Actions", "date": "Date", "addField": "Add field", "userIcon": "User icon" }, "noLogFiles": "There're no log files", "newSettings": { "myAccount": { "title": "My account", "subtitle": "Customize your profile, manage account security, open AI keys, or login into your account.", "profileLabel": "Account name & Profile image", "profileNamePlaceholder": "Enter your name", "accountSecurity": "Account security", "2FA": "2-Step Authentication", "aiKeys": "AI keys", "accountLogin": "Account Login", "updateNameError": "Failed to update name", "updateIconError": "Failed to update icon", "deleteAccount": { "title": "Delete Account", "subtitle": "Permanently delete your account and all of your data.", "deleteMyAccount": "Delete my account", "dialogTitle": "Delete account", "dialogContent1": "Are you sure you want to permanently delete your account?", "dialogContent2": "This action cannot be undone, and will remove access from all teamspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces." } }, "workplace": { "name": "Workplace", "title": "Workplace Settings", "subtitle": "Customize your workspace appearance, theme, font, text layout, date, time, and language.", "workplaceName": "Workplace name", "workplaceNamePlaceholder": "Enter workplace name", "workplaceIcon": "Workplace icon", "workplaceIconSubtitle": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications", "renameError": "Failed to rename workplace", "updateIconError": "Failed to update icon", "appearance": { "name": "Appearance", "themeMode": { "auto": "Auto", "light": "Light", "dark": "Dark" }, "language": "Language" } } } } ================================================ FILE: frontend/resources/translations/en-GB.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Me", "welcomeText": "Welcome to @:appName", "welcomeTo": "Welcome to", "githubStarText": "Star on GitHub", "subscribeNewsletterText": "Subscribe to Newsletter", "letsGoButtonText": "Quick Start", "title": "Title", "youCanAlso": "You can also", "and": "and", "failedToOpenUrl": "Failed to open url: {}", "blockActions": { "addBelowTooltip": "Click to add below", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "to add above", "dragTooltip": "Drag to move", "openMenuTooltip": "Click to open menu" }, "signUp": { "buttonText": "Sign Up", "title": "Sign Up to @:appName", "getStartedText": "Get Started", "emptyPasswordError": "Password can't be empty", "repeatPasswordEmptyError": "Repeat password can't be empty", "unmatchedPasswordError": "Repeat password is not the same as password", "alreadyHaveAnAccount": "Already have an account?", "emailHint": "Email", "passwordHint": "Password", "repeatPasswordHint": "Repeat password", "signUpWith": "Sign up with:" }, "signIn": { "loginTitle": "Login to @:appName", "loginButtonText": "Login", "loginStartWithAnonymous": "Continue with an anonymous session", "continueAnonymousUser": "Continue with an anonymous session", "buttonText": "Sign In", "signingInText": "Signing in...", "forgotPassword": "Forgot Password?", "emailHint": "Email", "passwordHint": "Password", "dontHaveAnAccount": "Don't have an account?", "createAccount": "Create account", "repeatPasswordEmptyError": "Repeat password can't be empty", "unmatchedPasswordError": "Repeat password is not the same as password", "syncPromptMessage": "Syncing the data might take a while. Please don't close this page", "or": "OR", "signInWithGoogle": "Continue with Google", "signInWithGithub": "Continue with GitHub", "signInWithDiscord": "Continue with Discord", "signInWithApple": "Continue with Apple", "continueAnotherWay": "Continue another way", "signUpWithGoogle": "Sign up with Google", "signUpWithGithub": "Sign up with Github", "signUpWithDiscord": "Sign up with Discord", "signInWith": "Continue with:", "signInWithEmail": "Continue with Email", "signInWithMagicLink": "Continue", "signUpWithMagicLink": "Sign up with Magic Link", "pleaseInputYourEmail": "Please enter your email address", "settings": "Settings", "magicLinkSent": "Magic Link sent!", "invalidEmail": "Please enter a valid email address", "alreadyHaveAnAccount": "Already have an account?", "logIn": "Log in", "generalError": "Something went wrong. Please try again later", "limitRateError": "For security reasons, you can only request a magic link every 60 seconds", "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes.", "anonymous": "Anonymous" }, "workspace": { "chooseWorkspace": "Choose your workspace", "defaultName": "My Workspace", "create": "Create workspace", "new": "New workspace", "importFromNotion": "Import from Notion", "learnMore": "Learn more", "reset": "Reset workspace", "renameWorkspace": "Rename workspace", "workspaceNameCannotBeEmpty": "Workspace name cannot be empty", "resetWorkspacePrompt": "Resetting the workspace will delete all pages and data within it. Are you sure you want to reset the workspace? Alternatively, you can contact the support team to restore the workspace", "hint": "workspace", "notFoundError": "Workspace not found", "failedToLoad": "Something went wrong! Failed to load the workspace. Try to close any open instance of @:appName and try again.", "errorActions": { "reportIssue": "Report an issue", "reportIssueOnGithub": "Report an issue on Github", "exportLogFiles": "Export log files", "reachOut": "Reach out on Discord" }, "menuTitle": "Workspaces", "deleteWorkspaceHintText": "Are you sure you want to delete the workspace? This action cannot be undone, and any pages you have published will be unpublished.", "createSuccess": "Workspace created successfully", "createFailed": "Failed to create workspace", "createLimitExceeded": "You've reached the maximum workspace limit allowed for your account. If you need additional workspaces to continue your work, please request on Github", "deleteSuccess": "Workspace deleted successfully", "deleteFailed": "Failed to delete workspace", "openSuccess": "Open workspace successfully", "openFailed": "Failed to open workspace", "renameSuccess": "Workspace renamed successfully", "renameFailed": "Failed to rename workspace", "updateIconSuccess": "Updated workspace icon successfully", "updateIconFailed": "Updated workspace icon failed", "cannotDeleteTheOnlyWorkspace": "Cannot delete the only workspace", "fetchWorkspacesFailed": "Failed to fetch workspaces", "leaveCurrentWorkspace": "Leave workspace", "leaveCurrentWorkspacePrompt": "Are you sure you want to leave the current workspace?" }, "shareAction": { "buttonText": "Share", "workInProgress": "Coming soon", "markdown": "Markdown", "html": "HTML", "clipboard": "Copy to clipboard", "csv": "CSV", "copyLink": "Copy link", "publishToTheWeb": "Publish to Web", "publishToTheWebHint": "Create a website with AppFlowy", "publish": "Publish", "unPublish": "Unpublish", "visitSite": "Visit site", "exportAsTab": "Export as", "publishTab": "Publish", "shareTab": "Share", "publishOnAppFlowy": "Publish on AppFlowy", "shareTabTitle": "Invite to collaborate", "shareTabDescription": "For easy collaboration with anyone", "copyLinkSuccess": "Copied link to clipboard", "copyShareLink": "Copy share link", "copyLinkFailed": "Failed to copy link to clipboard", "copyLinkToBlockSuccess": "Copied block link to clipboard", "copyLinkToBlockFailed": "Failed to copy block link to clipboard", "manageAllSites": "Manage all sites", "updatePathName": "Update path name" }, "moreAction": { "small": "small", "medium": "medium", "large": "large", "fontSize": "Font size", "import": "Import", "moreOptions": "More options", "wordCount": "Word count: {}", "charCount": "Character count: {}", "createdAt": "Created: {}", "deleteView": "Delete", "duplicateView": "Duplicate", "wordCountLabel": "Word count: ", "charCountLabel": "Character count: ", "createdAtLabel": "Created: ", "syncedAtLabel": "Synced: ", "saveAsNewPage": "Add messages to page", "saveAsNewPageDisabled": "No messages available" }, "importPanel": { "textAndMarkdown": "Text & Markdown", "documentFromV010": "Document from v0.1.0", "databaseFromV010": "Database from v0.1.0", "notionZip": "Notion Exported Zip File", "csv": "CSV", "database": "Database" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "Drag & drop a file, click to ", "placeholderUpload": "Upload", "placeholderRight": ", or paste an image link.", "dropToUpload": "Drop a file to upload", "change": "Change" } }, "disclosureAction": { "rename": "Rename", "delete": "Delete", "duplicate": "Duplicate", "unfavorite": "Remove from Favourites", "favorite": "Add to Favourites", "openNewTab": "Open in a new tab", "moveTo": "Move to", "addToFavorites": "Add to Favourites", "copyLink": "Copy link", "changeIcon": "Change icon", "collapseAllPages": "Collapse all subpages", "movePageTo": "Move page to", "move": "Move", "lockPage": "Lock page" }, "blankPageTitle": "Blank page", "newPageText": "New page", "newDocumentText": "New document", "newGridText": "New grid", "newCalendarText": "New calendar", "newBoardText": "New board", "chat": { "newChat": "AI Chat", "inputMessageHint": "Ask @:appName AI", "inputLocalAIMessageHint": "Ask @:appName Local AI", "unsupportedCloudPrompt": "This feature is only available when using @:appName Cloud", "relatedQuestion": "Suggested", "serverUnavailable": "Connection lost. Please check your internet and", "aiServerUnavailable": "The AI service is temporarily unavailable. Please try again later.", "retry": "Retry", "clickToRetry": "Click to retry", "regenerateAnswer": "Regenerate", "question1": "How to use Kanban to manage tasks", "question2": "Explain the GTD method", "question3": "Why use Rust", "question4": "Recipe with what's in my kitchen", "question5": "Create an illustration for my page", "question6": "Draw up a to-do list for my upcoming week", "aiMistakePrompt": "AI can make mistakes. Check important info.", "chatWithFilePrompt": "Do you want to chat with the file?", "indexFileSuccess": "Indexing file successfully", "inputActionNoPages": "No page results", "referenceSource": { "zero": "0 sources found", "one": "{count} source found", "other": "{count} sources found" }, "clickToMention": "Mention a page", "uploadFile": "Attach PDFs, text or markdown files", "questionDetail": "Hi {}! How can I help you today?", "indexingFile": "Indexing {}", "generatingResponse": "Generating response", "selectSources": "Select Sources", "currentPage": "Current page", "sourcesLimitReached": "You can only select up to 3 top-level documents and its children", "sourceUnsupported": "We don't support chatting with databases at this time", "regenerate": "Try again", "addToPageButton": "Add message to page", "addToPageTitle": "Add message to...", "addToNewPage": "Create new page", "addToNewPageName": "Messages extracted from \"{}\"", "addToNewPageSuccessToast": "Message added to", "openPagePreviewFailedToast": "Failed to open page", "changeFormat": { "actionButton": "Change format", "confirmButton": "Regenerate with this format", "textOnly": "Text", "imageOnly": "Image only", "textAndImage": "Text and Image", "text": "Paragraph", "bullet": "Bullet list", "number": "Numbered list", "table": "Table", "blankDescription": "Format response", "defaultDescription": "Auto response format", "textWithImageDescription": "@:chat.changeFormat.text with image", "numberWithImageDescription": "@:chat.changeFormat.number with image", "bulletWithImageDescription": "@:chat.changeFormat.bullet with image", "tableWithImageDescription": "@:chat.changeFormat.table with image" }, "switchModel": { "label": "Switch model", "localModel": "Local Model", "cloudModel": "Cloud Model", "autoModel": "Auto" }, "selectBanner": { "saveButton": "Add to …", "selectMessages": "Select messages", "nSelected": "{} selected", "allSelected": "All selected" }, "stopTooltip": "Stop generating" }, "trash": { "text": "Bin", "restoreAll": "Restore All", "restore": "Restore", "deleteAll": "Delete All", "pageHeader": { "fileName": "File name", "lastModified": "Last Modified", "created": "Created" }, "confirmDeleteAll": { "title": "All pages in bin", "caption": "Are you sure you want to delete everything in Bin? This action cannot be undone." }, "confirmRestoreAll": { "title": "Restore all pages in bin", "caption": "This action cannot be undone." }, "restorePage": { "title": "Restore: {}", "caption": "Are you sure you want to restore this page?" }, "mobile": { "actions": "Bin Actions", "empty": "No pages or spaces in Bin", "emptyDescription": "Move things you don't need to the Bin.", "isDeleted": "is deleted", "isRestored": "is restored" }, "confirmDeleteTitle": "Are you sure you want to delete this page permanently?" }, "deletePagePrompt": { "text": "This page is in Bin", "restore": "Restore page", "deletePermanent": "Delete permanently", "deletePermanentDescription": "Are you sure you want to delete this page permanently? This is irreversible." }, "dialogCreatePageNameHint": "Page name", "questionBubble": { "shortcuts": "Shortcuts", "whatsNew": "What's new?", "helpAndDocumentation": "Help & documentation", "getSupport": "Get support", "markdown": "Markdown", "debug": { "name": "Debug Info", "success": "Copied debug info to clipboard!", "fail": "Unable to copy debug info to clipboard" }, "feedback": "Feedback" }, "menuAppHeader": { "moreButtonToolTip": "Remove, rename, and more...", "addPageTooltip": "Quickly add a page inside", "defaultNewPageName": "Untitled", "renameDialog": "Rename", "pageNameSuffix": "Copy" }, "noPagesInside": "No pages inside", "toolbar": { "undo": "Undo", "redo": "Redo", "bold": "Bold", "italic": "Italic", "underline": "Underline", "strike": "Strikethrough", "numList": "Numbered list", "bulletList": "Bulleted list", "checkList": "Checklist", "inlineCode": "Inline Code", "quote": "Quote Block", "header": "Header", "highlight": "Highlight", "color": "Colour", "addLink": "Add Link" }, "tooltip": { "lightMode": "Switch to Light mode", "darkMode": "Switch to Dark mode", "openAsPage": "Open as a Page", "addNewRow": "Add a new row", "openMenu": "Click to open menu", "dragRow": "Drag to reorder the row", "viewDataBase": "View database", "referencePage": "This {name} is referenced", "addBlockBelow": "Add a block below", "aiGenerate": "Generate" }, "sideBar": { "closeSidebar": "Close sidebar", "openSidebar": "Open sidebar", "expandSidebar": "Expand as full page", "personal": "Personal", "private": "Private", "workspace": "Workspace", "favorites": "Favourites", "clickToHidePrivate": "Click to hide private space\nPages you created here are only visible to you", "clickToHideWorkspace": "Click to hide workspace\nPages you created here are visible to every member", "clickToHidePersonal": "Click to hide personal space", "clickToHideFavorites": "Click to hide favourite space", "addAPage": "Add a new page", "addAPageToPrivate": "Add a page to private space", "addAPageToWorkspace": "Add a page to workspace", "recent": "Recent", "today": "Today", "thisWeek": "This week", "others": "Earlier favourites", "earlier": "Earlier", "justNow": "just now", "minutesAgo": "{count} minutes ago", "lastViewed": "Last viewed", "favoriteAt": "Favourited", "emptyRecent": "No Recent Pages", "emptyRecentDescription": "As you view pages, they will appear here for easy retrieval.", "emptyFavorite": "No Favourite Pages", "emptyFavoriteDescription": "Mark pages as favourites—they'll be listed here for quick access!", "removePageFromRecent": "Remove this page from the Recent?", "removeSuccess": "Removed successfully", "favoriteSpace": "Favourites", "RecentSpace": "Recent", "Spaces": "Spaces", "upgradeToPro": "Upgrade to Pro", "upgradeToAIMax": "Unlock unlimited AI", "storageLimitDialogTitle": "You have run out of free storage. Upgrade to unlock unlimited storage", "storageLimitDialogTitleIOS": "You have run out of free storage.", "aiResponseLimitTitle": "You have run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", "aiResponseLimitDialogTitle": "AI Responses limit reached", "aiResponseLimit": "You have run out of free AI responses.\n\nGo to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses", "askOwnerToUpgradeToPro": "Your workspace is running out of free storage. Please ask your workspace owner to upgrade to the Pro Plan", "askOwnerToUpgradeToProIOS": "Your workspace is running out of free storage.", "askOwnerToUpgradeToAIMax": "Your workspace has ran out of free AI responses. Please ask your workspace owner to upgrade the plan or purchase AI add-ons", "askOwnerToUpgradeToAIMaxIOS": "Your workspace is running out of free AI responses.", "purchaseAIMax": "Your workspace has ran out of AI Image responses. Please ask your workspace owner to purchase AI Max", "aiImageResponseLimit": "You have run out of AI image responses.\n\nGo to Settings -> Plan -> Click AI Max to get more AI image responses", "purchaseStorageSpace": "Purchase Storage Space", "singleFileProPlanLimitationDescription": "You have exceeded the maximum file upload size allowed in the free plan. Please upgrade to the Pro Plan to upload larger files", "purchaseAIResponse": "Purchase ", "askOwnerToUpgradeToLocalAI": "Ask workspace owner to enable AI On-device", "upgradeToAILocal": "Run local models on your device for ultimate privacy", "upgradeToAILocalDesc": "Chat with PDFs, improve your writing, and auto-fill tables using local AI" }, "notifications": { "export": { "markdown": "Exported Note To Markdown", "path": "Documents/flowy" } }, "contactsPage": { "title": "Contacts", "whatsHappening": "What's happening this week?", "addContact": "Add Contact", "editContact": "Edit Contact" }, "button": { "ok": "Ok", "confirm": "Confirm", "done": "Done", "cancel": "Cancel", "signIn": "Sign In", "signOut": "Sign Out", "complete": "Complete", "save": "Save", "generate": "Generate", "esc": "ESC", "keep": "Keep", "tryAgain": "Try again", "discard": "Discard", "replace": "Replace", "insertBelow": "Insert below", "insertAbove": "Insert above", "upload": "Upload", "edit": "Edit", "delete": "Delete", "copy": "Copy", "duplicate": "Duplicate", "putback": "Put Back", "update": "Update", "share": "Share", "removeFromFavorites": "Remove from Favourites", "removeFromRecent": "Remove from Recent", "addToFavorites": "Add to Favourites", "favoriteSuccessfully": "Favourited success", "unfavoriteSuccessfully": "Unfavourited success", "duplicateSuccessfully": "Duplicated successfully", "rename": "Rename", "helpCenter": "Help Centre", "add": "Add", "yes": "Yes", "no": "No", "clear": "Clear", "remove": "Remove", "dontRemove": "Don't remove", "copyLink": "Copy Link", "align": "Align", "login": "Login", "logout": "Log out", "deleteAccount": "Delete account", "back": "Back", "signInGoogle": "Continue with Google", "signInGithub": "Continue with GitHub", "signInDiscord": "Continue with Discord", "more": "More", "create": "Create", "close": "Close", "next": "Next", "previous": "Previous", "submit": "Submit", "download": "Download", "backToHome": "Back to Home", "viewing": "Viewing", "editing": "Editing", "gotIt": "Got it", "retry": "Retry", "uploadFailed": "Upload failed.", "copyLinkOriginal": "Copy link to original" }, "label": { "welcome": "Welcome!", "firstName": "First Name", "middleName": "Middle Name", "lastName": "Last Name", "stepX": "Step {X}" }, "oAuth": { "err": { "failedTitle": "Unable to connect to your account.", "failedMsg": "Please make sure you've completed the sign-in process in your browser." }, "google": { "title": "GOOGLE SIGN-IN", "instruction1": "In order to import your Google Contacts, you'll need to authorise this application using your web browser.", "instruction2": "Copy this code to your clipboard by clicking the icon or selecting the text:", "instruction3": "Navigate to the following link in your web browser, and enter the above code:", "instruction4": "Press the button below when you've completed signup:" } }, "settings": { "title": "Settings", "popupMenuItem": { "settings": "Settings", "members": "Members", "trash": "Bin", "helpAndDocumentation": "Help & documentation", "getSupport": "Get Support" }, "sites": { "title": "Sites", "namespaceTitle": "Namespace", "namespaceDescription": "Manage your namespace and homepage", "namespaceHeader": "Namespace", "homepageHeader": "Homepage", "updateNamespace": "Update namespace", "removeHomepage": "Remove homepage", "selectHomePage": "Select a page", "clearHomePage": "Clear the home page for this namespace", "customUrl": "Custom URL", "namespace": { "description": "This change will apply to all the published pages live on this namespace", "tooltip": "We reserve the rights to remove any inappropriate namespaces", "updateExistingNamespace": "Update existing namespace", "upgradeToPro": "Upgrade to Pro Plan to set a homepage", "redirectToPayment": "Redirecting to payment page...", "onlyWorkspaceOwnerCanSetHomePage": "Only the workspace owner can set a homepage", "pleaseAskOwnerToSetHomePage": "Please ask the workspace owner to upgrade to Pro Plan" }, "publishedPage": { "title": "All published pages", "description": "Manage your published pages", "page": "Page", "pathName": "Path name", "date": "Published date", "emptyHinText": "You have no published pages in this workspace", "noPublishedPages": "No published pages", "settings": "Publish settings", "clickToOpenPageInApp": "Open page in app", "clickToOpenPageInBrowser": "Open page in browser" }, "error": { "failedToGeneratePaymentLink": "Failed to generate payment link for Pro Plan", "failedToUpdateNamespace": "Failed to update namespace", "proPlanLimitation": "You need to upgrade to Pro Plan to update the namespace", "namespaceAlreadyInUse": "The namespace is already taken, please try another one", "invalidNamespace": "Invalid namespace, please try another one", "namespaceLengthAtLeast2Characters": "The namespace must be at least 2 characters long", "onlyWorkspaceOwnerCanUpdateNamespace": "Only workspace owner can update the namespace", "onlyWorkspaceOwnerCanRemoveHomepage": "Only workspace owner can remove the homepage", "setHomepageFailed": "Failed to set homepage", "namespaceTooLong": "The namespace is too long, please try another one", "namespaceTooShort": "The namespace is too short, please try another one", "namespaceIsReserved": "The namespace is reserved, please try another one", "updatePathNameFailed": "Failed to update path name", "removeHomePageFailed": "Failed to remove homepage", "publishNameContainsInvalidCharacters": "The path name contains invalid character(s), please try another one", "publishNameTooShort": "The path name is too short, please try another one", "publishNameTooLong": "The path name is too long, please try another one", "publishNameAlreadyInUse": "The path name is already in use, please try another one", "namespaceContainsInvalidCharacters": "The namespace contains invalid character(s), please try another one", "publishPermissionDenied": "Only the workspace owner or page publisher can manage the publish settings", "publishNameCannotBeEmpty": "The path name cannot be empty, please try another one" }, "success": { "namespaceUpdated": "Updated namespace successfully", "setHomepageSuccess": "Set homepage successfully", "updatePathNameSuccess": "Updated path name successfully", "removeHomePageSuccess": "Remove homepage successfully" } }, "accountPage": { "menuLabel": "Account & App", "title": "My account", "general": { "title": "Account name & profile image", "changeProfilePicture": "Change profile picture" }, "email": { "title": "Email", "actions": { "change": "Change email" } }, "login": { "title": "Account login", "loginLabel": "Log in", "logoutLabel": "Log out" }, "isUpToDate": "@:appName is up to date!", "officialVersion": "Version {version} (Official build)" }, "workspacePage": { "menuLabel": "Workspace", "title": "Workspace", "description": "Customise your workspace appearance, theme, font, text layout, date-/time-format, and language.", "workspaceName": { "title": "Workspace name" }, "workspaceIcon": { "title": "Workspace icon", "description": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications." }, "appearance": { "title": "Appearance", "description": "Customise your workspace appearance, theme, font, text layout, date, time, and language.", "options": { "system": "Auto", "light": "Light", "dark": "Dark" } }, "resetCursorColor": { "title": "Reset document cursor colour", "description": "Are you sure you want to reset the cursor colour?" }, "resetSelectionColor": { "title": "Reset document selection colour", "description": "Are you sure you want to reset the selection colour?" }, "resetWidth": { "resetSuccess": "Reset document width successfully" }, "theme": { "title": "Theme", "description": "Select a preset theme, or upload your own custom theme.", "uploadCustomThemeTooltip": "Upload a custom theme" }, "workspaceFont": { "title": "Workspace font", "noFontHint": "No font found, try another term." }, "textDirection": { "title": "Text direction", "leftToRight": "Left to right", "rightToLeft": "Right to left", "auto": "Auto", "enableRTLItems": "Enable RTL toolbar items" }, "layoutDirection": { "title": "Layout direction", "leftToRight": "Left to right", "rightToLeft": "Right to left" }, "dateTime": { "title": "Date & time", "example": "{} at {} ({})", "24HourTime": "24-hour time", "dateFormat": { "label": "Date format", "local": "Local", "us": "US", "iso": "ISO", "friendly": "Friendly", "dmy": "D/M/Y" } }, "language": { "title": "Language" }, "deleteWorkspacePrompt": { "title": "Delete workspace", "content": "Are you sure you want to delete this workspace? This action cannot be undone, and any pages you have published will be unpublished." }, "leaveWorkspacePrompt": { "title": "Leave workspace", "content": "Are you sure you want to leave this workspace? You will lose access to all pages and data within it.", "success": "You have left the workspace successfully.", "fail": "Failed to leave the workspace." }, "manageWorkspace": { "title": "Manage workspace", "leaveWorkspace": "Leave workspace", "deleteWorkspace": "Delete workspace" } }, "manageDataPage": { "menuLabel": "Manage data", "title": "Manage data", "description": "Manage data local storage or Import your existing data into @:appName.", "dataStorage": { "title": "File storage location", "tooltip": "The location where your files are stored", "actions": { "change": "Change path", "open": "Open folder", "openTooltip": "Open current data folder location", "copy": "Copy path", "copiedHint": "Path copied!", "resetTooltip": "Reset to default location" }, "resetDialog": { "title": "Are you sure?", "description": "Resetting the path to the default data location will not delete your data. If you want to re-import your current data, you should copy the path of your current location first." } }, "importData": { "title": "Import data", "tooltip": "Import data from @:appName backups/data folders", "description": "Copy data from an external @:appName data folder", "action": "Browse file" }, "encryption": { "title": "Encryption", "tooltip": "Manage how your data is stored and encrypted", "descriptionNoEncryption": "Turning on encryption will encrypt all data. This can not be undone.", "descriptionEncrypted": "Your data is encrypted.", "action": "Encrypt data", "dialog": { "title": "Encrypt all your data?", "description": "Encrypting all your data will keep your data safe and secure. This action can NOT be undone. Are you sure you want to continue?" } }, "cache": { "title": "Clear cache", "description": "Help resolve issues like image not loading, missing pages in a space, and fonts not loading. This will not affect your data.", "dialog": { "title": "Clear cache", "description": "Help resolve issues like image not loading, missing pages in a space, and fonts not loading. This will not affect your data.", "successHint": "Cache cleared!" } }, "data": { "fixYourData": "Fix your data", "fixButton": "Fix", "fixYourDataDescription": "If you're experiencing issues with your data, you can try to fix it here." } }, "shortcutsPage": { "menuLabel": "Shortcuts", "title": "Shortcuts", "editBindingHint": "Input new binding", "searchHint": "Search", "actions": { "resetDefault": "Reset default" }, "errorPage": { "message": "Failed to load shortcuts: {}", "howToFix": "Please try again, if the issue persists please reach out on GitHub." }, "resetDialog": { "title": "Reset shortcuts", "description": "This will reset all of your keybindings to the default, you cannot undo this later, are you sure you want to proceed?", "buttonLabel": "Reset" }, "conflictDialog": { "title": "{} is currently in use", "descriptionPrefix": "This keybinding is currently being used by ", "descriptionSuffix": ". If you replace this keybinding, it will be removed from {}.", "confirmLabel": "Continue" }, "editTooltip": "Press to start editing the keybinding", "keybindings": { "toggleToDoList": "Toggle to do list", "insertNewParagraphInCodeblock": "Insert new paragraph", "pasteInCodeblock": "Paste in codeblock", "selectAllCodeblock": "Select all", "indentLineCodeblock": "Insert two spaces at line start", "outdentLineCodeblock": "Delete two spaces at line start", "twoSpacesCursorCodeblock": "Insert two spaces at cursor", "copy": "Copy selection", "paste": "Paste in content", "cut": "Cut selection", "alignLeft": "Align text left", "alignCenter": "Align text centre", "alignRight": "Align text right", "insertInlineMathEquation": "Insert inline maths equation", "undo": "Undo", "redo": "Redo", "convertToParagraph": "Convert block to paragraph", "backspace": "Delete", "deleteLeftWord": "Delete left word", "deleteLeftSentence": "Delete left sentence", "delete": "Delete right character", "deleteMacOS": "Delete left character", "deleteRightWord": "Delete right word", "moveCursorLeft": "Move cursor left", "moveCursorBeginning": "Move cursor to the beginning", "moveCursorLeftWord": "Move cursor left one word", "moveCursorLeftSelect": "Select and move cursor left", "moveCursorBeginSelect": "Select and move cursor to the beginning", "moveCursorLeftWordSelect": "Select and move cursor left one word", "moveCursorRight": "Move cursor right", "moveCursorEnd": "Move cursor to the end", "moveCursorRightWord": "Move cursor right one word", "moveCursorRightSelect": "Select and move cursor right one", "moveCursorEndSelect": "Select and move cursor to the end", "moveCursorRightWordSelect": "Select and move cursor to the right one word", "moveCursorUp": "Move cursor up", "moveCursorTopSelect": "Select and move cursor to the top", "moveCursorTop": "Move cursor to the top", "moveCursorUpSelect": "Select and move cursor up", "moveCursorBottomSelect": "Select and move cursor to the bottom", "moveCursorBottom": "Move cursor to the bottom", "moveCursorDown": "Move cursor down", "moveCursorDownSelect": "Select and move cursor down", "home": "Scroll to the top", "end": "Scroll to the bottom", "toggleBold": "Toggle bold", "toggleItalic": "Toggle italic", "toggleUnderline": "Toggle underline", "toggleStrikethrough": "Toggle strikethrough", "toggleCode": "Toggle in-line code", "toggleHighlight": "Toggle highlight", "showLinkMenu": "Show link menu", "openInlineLink": "Open in-line link", "openLinks": "Open all selected links", "indent": "Indent", "outdent": "Outdent", "exit": "Exit editing", "pageUp": "Scroll one page up", "pageDown": "Scroll one page down", "selectAll": "Select all", "pasteWithoutFormatting": "Paste content without formatting", "showEmojiPicker": "Show emoji picker", "enterInTableCell": "Add linebreak in table", "leftInTableCell": "Move left one cell in table", "rightInTableCell": "Move right one cell in table", "upInTableCell": "Move up one cell in table", "downInTableCell": "Move down one cell in table", "tabInTableCell": "Go to next available cell in table", "shiftTabInTableCell": "Go to previously available cell in table", "backSpaceInTableCell": "Stop at the beginning of the cell" }, "commands": { "codeBlockNewParagraph": "Insert a new paragraph next to the code block", "codeBlockIndentLines": "Insert two spaces at the line start in code block", "codeBlockOutdentLines": "Delete two spaces at the line start in code block", "codeBlockAddTwoSpaces": "Insert two spaces at the cursor position in code block", "codeBlockSelectAll": "Select all content inside a code block", "codeBlockPasteText": "Paste text in codeblock", "textAlignLeft": "Align text to the left", "textAlignCenter": "Align text to the centre", "textAlignRight": "Align text to the right" }, "couldNotLoadErrorMsg": "Could not load shortcuts, Try again", "couldNotSaveErrorMsg": "Could not save shortcuts, Try again" }, "aiPage": { "title": "AI Settings", "menuLabel": "AI Settings", "keys": { "enableAISearchTitle": "AI Search", "aiSettingsDescription": "Choose your preferred model to power AppFlowy AI. Now includes GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet, and models available in Ollama", "loginToEnableAIFeature": "AI features are only enabled after logging in with @:appName Cloud. If you don't have an @:appName account, go to 'My Account' to sign up", "llmModel": "Language Model", "llmModelType": "Language Model Type", "downloadLLMPrompt": "Download {}", "downloadAppFlowyOfflineAI": "Downloading AI offline package will enable AI to run on your device. Do you want to continue?", "downloadLLMPromptDetail": "Downloading {} local model will take up to {} of storage. Do you want to continue?", "downloadBigFilePrompt": "It may take around 10 minutes to complete the download", "downloadAIModelButton": "Download", "downloadingModel": "Downloading", "localAILoaded": "Local AI Model successfully added and ready to use", "localAIStart": "Local AI is starting. If it's slow, try toggling it off and on", "localAILoading": "Local AI Chat Model is loading...", "localAIStopped": "Local AI stopped", "localAIRunning": "Local AI is running", "localAINotReadyRetryLater": "Local AI is initialising, please retry later", "localAIDisabled": "You are using local AI, but it is disabled. Please go to settings to enable it or try different model", "localAIInitializing": "Local AI is loading. This may take a few seconds depending on your device", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", "failToLoadLocalAI": "Failed to start local AI.", "restartLocalAI": "Restart", "disableLocalAITitle": "Disable local AI", "disableLocalAIDescription": "Do you want to disable local AI?", "localAIToggleTitle": "AppFlowy Local AI (LAI)", "localAIToggleSubTitle": "Run the most advanced local AI models within AppFlowy for ultimate privacy and security", "offlineAIInstruction1": "Follow the", "offlineAIInstruction2": "instruction", "offlineAIInstruction3": "to enable offline AI.", "offlineAIDownload1": "If you have not downloaded the AppFlowy AI, please", "offlineAIDownload2": "download", "offlineAIDownload3": "it first", "activeOfflineAI": "Active", "downloadOfflineAI": "Download", "openModelDirectory": "Open folder", "laiNotReady": "The Local AI app was not installed correctly.", "ollamaNotReady": "The Ollama server is not ready.", "pleaseFollowThese": "Please follow these", "instructions": "instructions", "installOllamaLai": "to set up Ollama and AppFlowy Local AI.", "modelsMissing": "Cannot find the required models.", "downloadModel": "to download them." } }, "planPage": { "menuLabel": "Plan", "title": "Pricing plan", "planUsage": { "title": "Plan usage summary", "storageLabel": "Storage", "storageUsage": "{} of {} GB", "unlimitedStorageLabel": "Unlimited storage", "collaboratorsLabel": "Members", "collaboratorsUsage": "{} of {}", "aiResponseLabel": "AI Responses", "aiResponseUsage": "{} of {}", "unlimitedAILabel": "Unlimited responses", "proBadge": "Pro", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "AI On-device for Mac", "memberProToggle": "More members & unlimited AI", "aiMaxToggle": "Unlimited AI and access to advanced models", "aiOnDeviceToggle": "Local AI for ultimate privacy", "aiCredit": { "title": "Add @:appName AI Credit", "price": "{}", "priceDescription": "for 1,000 credits", "purchase": "Purchase AI", "info": "Add 1,000 Ai credits per workspace and seamlessly integrate customisable AI into your workflow for smarter, faster results with up to:", "infoItemOne": "10,000 responses per database", "infoItemTwo": "1,000 responses per workspace" }, "currentPlan": { "bannerLabel": "Current plan", "freeTitle": "Free", "proTitle": "Pro", "teamTitle": "Team", "freeInfo": "Perfect for individuals up to 2 members to organise everything", "proInfo": "Perfect for small and medium teams up to 10 members.", "teamInfo": "Perfect for all productive and well-organised teams..", "upgrade": "Change plan", "canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}." }, "addons": { "title": "Add-ons", "addLabel": "Add", "activeLabel": "Added", "aiMax": { "title": "AI Max", "description": "Unlimited AI responses powered by advanced AI models, and 50 AI images per month", "price": "{}", "priceInfo": "Per user per month billed annually" }, "aiOnDevice": { "title": "AI On-device for Mac", "description": "Run Mistral 7B, LLAMA 3, and more local models on your machine", "price": "{}", "priceInfo": "Per user per month billed annually", "recommend": "Recommend M1 or newer" } }, "deal": { "bannerLabel": "New year deal!", "title": "Grow your team!", "info": "Upgrade and save 10% off Pro and Team plans! Boost your workspace productivity with powerful new features including @:appName AI.", "viewPlans": "View plans" } } }, "billingPage": { "menuLabel": "Billing", "title": "Billing", "plan": { "title": "Plan", "freeLabel": "Free", "proLabel": "Pro", "planButtonLabel": "Change plan", "billingPeriod": "Billing period", "periodButtonLabel": "Edit period" }, "paymentDetails": { "title": "Payment details", "methodLabel": "Payment method", "methodButtonLabel": "Edit method" }, "addons": { "title": "Add-ons", "addLabel": "Add", "removeLabel": "Remove", "renewLabel": "Renew", "aiMax": { "label": "AI Max", "description": "Unlock unlimited AI and advanced models", "activeDescription": "Next invoice due on {}", "canceledDescription": "AI Max will be available until {}" }, "aiOnDevice": { "label": "AI On-device for Mac", "description": "Unlock unlimited AI On-device on your device", "activeDescription": "Next invoice due on {}", "canceledDescription": "AI On-device for Mac will be available until {}" }, "removeDialog": { "title": "Remove {}", "description": "Are you sure you want to remove {plan}? You will lose access to the features and benefits of {plan} immediately." } }, "currentPeriodBadge": "CURRENT", "changePeriod": "Change period", "planPeriod": "{} period", "monthlyInterval": "Monthly", "monthlyPriceInfo": "per seat billed monthly", "annualInterval": "Annually", "annualPriceInfo": "per seat billed annually" }, "comparePlanDialog": { "title": "Compare & select plan", "planFeatures": "Plan\nFeatures", "current": "Current", "actions": { "upgrade": "Upgrade", "downgrade": "Downgrade", "current": "Current" }, "freePlan": { "title": "Free", "description": "For individuals up to 2 members to organise everything", "price": "{}", "priceInfo": "Free forever" }, "proPlan": { "title": "Pro", "description": "For small teams to manage projects and team knowledge", "price": "{}", "priceInfo": "Per user per month \nbilled annually\n\n{} billed monthly" }, "planLabels": { "itemOne": "Workspaces", "itemTwo": "Members", "itemThree": "Storage", "itemFour": "Real-time collaboration", "itemFive": "Mobile app", "itemSix": "AI Responses", "itemSeven": "AI Images", "itemFileUpload": "File uploads", "customNamespace": "Custom namespace", "tooltipSix": "Lifetime means the number of responses never reset", "intelligentSearch": "Intelligent search", "tooltipSeven": "Allows you to customise part of the URL for your workspace", "customNamespaceTooltip": "Custom published site URL" }, "freeLabels": { "itemOne": "Charged per workspace", "itemTwo": "Up to 2", "itemThree": "5 GB", "itemFour": "yes", "itemFive": "yes", "itemSix": "10 lifetime", "itemSeven": "2 lifetime", "itemFileUpload": "Up to 7 MB", "intelligentSearch": "Intelligent search" }, "proLabels": { "itemOne": "Charged per workspace", "itemTwo": "Up to 10", "itemThree": "Unlimited", "itemFour": "yes", "itemFive": "yes", "itemSix": "Unlimited", "itemSeven": "10 images per month", "itemFileUpload": "Unlimited", "intelligentSearch": "Intelligent search" }, "paymentSuccess": { "title": "You are now on the {} plan!", "description": "Your payment has been successfully processed and your plan is upgraded to @:appName {}. You can view your plan details on the Plan page" }, "downgradeDialog": { "title": "Are you sure you want to downgrade your plan?", "description": "Downgrading your plan will revert you back to the Free plan. Members may lose access to this workspace and you may need to free up space to meet the storage limits of the Free plan.", "downgradeLabel": "Downgrade plan" } }, "cancelSurveyDialog": { "title": "Sorry to see you go", "description": "We're sorry to see you go. We'd love to hear your feedback to help us improve @:appName. Please take a moment to answer a few questions.", "commonOther": "Other", "otherHint": "Write your answer here", "questionOne": { "question": "What prompted you to cancel your @:appName Pro subscription?", "answerOne": "Cost too high", "answerTwo": "Features did not meet expectations", "answerThree": "Found a better alternative", "answerFour": "Did not use it enough to justify the expense", "answerFive": "Service issue or technical difficulties" }, "questionTwo": { "question": "How likely are you to consider re-subscribing to @:appName Pro in the future?", "answerOne": "Very likely", "answerTwo": "Somewhat likely", "answerThree": "Not sure", "answerFour": "Unlikely", "answerFive": "Very unlikely" }, "questionThree": { "question": "Which Pro feature did you value the most during your subscription?", "answerOne": "Multi-user collaboration", "answerTwo": "Longer time version history", "answerThree": "Unlimited AI responses", "answerFour": "Access to local AI models" }, "questionFour": { "question": "How would you describe your overall experience with @:appName?", "answerOne": "Great", "answerTwo": "Good", "answerThree": "Average", "answerFour": "Below average", "answerFive": "Unsatisfied" } }, "common": { "uploadingFile": "File is uploading. Please do not quit the app", "uploadNotionSuccess": "Your Notion zip file has been uploaded successfully. Once the import is complete, you will receive a confirmation email", "reset": "Reset" }, "menu": { "appearance": "Appearance", "language": "Language", "user": "User", "files": "Files", "notifications": "Notifications", "open": "Open Settings", "logout": "Logout", "logoutPrompt": "Are you sure you want to logout?", "selfEncryptionLogoutPrompt": "Are you sure you want to log out? Please ensure you have copied the encryption secret", "syncSetting": "Sync Setting", "cloudSettings": "Cloud Settings", "enableSync": "Enable sync", "enableSyncLog": "Enable sync logging", "enableSyncLogWarning": "Thank you for helping diagnose sync issues. This will log your document edits to a local file. Please quit and reopen the app after enabling", "enableEncrypt": "Encrypt data", "cloudURL": "Base URL", "webURL": "Web URL", "invalidCloudURLScheme": "Invalid Scheme", "cloudServerType": "Cloud server", "cloudServerTypeTip": "Please note that it might log out your current account after switching the cloud server", "cloudLocal": "Local", "cloudAppFlowy": "@:appName Cloud", "cloudAppFlowySelfHost": "@:appName Cloud Self-hosted", "appFlowyCloudUrlCanNotBeEmpty": "The cloud url can't be empty", "clickToCopy": "Copy to clipboard", "selfHostStart": "If you don't have a server, please refer to the", "selfHostContent": "document", "selfHostEnd": "for guidance on how to self-host your own server", "pleaseInputValidURL": "Please input a valid URL", "changeUrl": "Change self-hosted url to {}", "cloudURLHint": "Input the base URL of your server", "webURLHint": "Input the base URL of your web server", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Input the websocket address of your server", "restartApp": "Restart", "restartAppTip": "Restart the application for the changes to take effect. Please note that this might log out your current account.", "changeServerTip": "After changing the server, you must click the restart button for the changes to take effect", "enableEncryptPrompt": "Activate encryption to secure your data with this secret. Store it safely; once enabled, it can't be turned off. If lost, your data becomes irretrievable. Click to copy", "inputEncryptPrompt": "Please enter your encryption secret for", "clickToCopySecret": "Click to copy secret", "configServerSetting": "Configurate your server settings", "configServerGuide": "After selecting `Quick Start`, navigate to `Settings` and then \"Cloud Settings\" to configure your self-hosted server.", "inputTextFieldHint": "Your secret", "historicalUserList": "User login history", "historicalUserListTooltip": "This list displays your anonymous accounts. You can click on an account to view its details. Anonymous accounts are created by clicking the 'Get Started' button", "openHistoricalUser": "Click to open the anonymous account", "customPathPrompt": "Storing the @:appName data folder in a cloud-synced folder such as Google Drive can pose risks. If the database within this folder is accessed or modified from multiple locations at the same time, it may result in synchronisation conflicts and potential data corruption", "importAppFlowyData": "Import Data from External @:appName Folder", "importingAppFlowyDataTip": "Data import is in progress. Please do not close the app", "importAppFlowyDataDescription": "Copy data from an external @:appName data folder and import it into the current AppFlowy data folder", "importSuccess": "Successfully imported the @:appName data folder", "importFailed": "Importing the @:appName data folder failed", "importGuide": "For further details, please check the referenced document" }, "notifications": { "enableNotifications": { "label": "Enable notifications", "hint": "Turn off to stop local notifications from appearing." }, "showNotificationsIcon": { "label": "Show notifications icon", "hint": "Toggle off to hide the notification icon in the sidebar." }, "archiveNotifications": { "allSuccess": "Archived all notifications successfully", "success": "Archived notification successfully" }, "markAsReadNotifications": { "allSuccess": "Marked all as read successfully", "success": "Marked as read successfully" }, "action": { "markAsRead": "Mark as read", "multipleChoice": "Select more", "archive": "Archive" }, "settings": { "settings": "Settings", "markAllAsRead": "Mark all as read", "archiveAll": "Archive all" }, "emptyInbox": { "title": "Inbox Zero!", "description": "Set reminders to receive notifications here." }, "emptyUnread": { "title": "No unread notifications", "description": "You're all caught up!" }, "emptyArchived": { "title": "No archived", "description": "Archived notifications will appear here." }, "tabs": { "inbox": "Inbox", "unread": "Unread", "archived": "Archived" }, "refreshSuccess": "Notifications refreshed successfully", "titles": { "notifications": "Notifications", "reminder": "Reminder" } }, "appearance": { "resetSetting": "Reset", "fontFamily": { "label": "Font Family", "search": "Search", "defaultFont": "System" }, "themeMode": { "label": "Theme Mode", "light": "Light Mode", "dark": "Dark Mode", "system": "Adapt to System" }, "fontScaleFactor": "Font Scale Factor", "displaySize": "Display Size", "documentSettings": { "cursorColor": "Document cursor colour", "selectionColor": "Document selection colour", "width": "Document width", "changeWidth": "Change", "pickColor": "Select a colour", "colorShade": "Colour shade", "opacity": "Opacity", "hexEmptyError": "Hex colour cannot be empty", "hexLengthError": "Hex value must be 6 digits long", "hexInvalidError": "Invalid hex value", "opacityEmptyError": "Opacity cannot be empty", "opacityRangeError": "Opacity must be between 1 and 100", "app": "App", "flowy": "Flowy", "apply": "Apply" }, "layoutDirection": { "label": "Layout Direction", "hint": "Control the flow of content on your screen, from left to right or right to left.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "Default Text Direction", "hint": "Specify whether text should start from left or right as the default.", "ltr": "LTR", "rtl": "RTL", "auto": "AUTO", "fallback": "Same as layout direction" }, "themeUpload": { "button": "Upload", "uploadTheme": "Upload theme", "description": "Upload your own @:appName theme using the button below.", "loading": "Please wait while we validate and upload your theme...", "uploadSuccess": "Your theme was uploaded successfully", "deletionFailure": "Failed to delete the theme. Try to delete it manually.", "filePickerDialogTitle": "Choose a .flowy_plugin file", "urlUploadFailure": "Failed to open url: {}" }, "theme": "Theme", "builtInsLabel": "Built-in Themes", "pluginsLabel": "Plugins", "dateFormat": { "label": "Date format", "local": "Local", "us": "US", "iso": "ISO", "friendly": "Friendly", "dmy": "D/M/Y" }, "timeFormat": { "label": "Time format", "twelveHour": "Twelve hour", "twentyFourHour": "Twenty four hour" }, "showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page", "enableRTLToolbarItems": "Enable RTL toolbar items", "members": { "title": "Members settings", "inviteMembers": "Invite members", "inviteHint": "Invite by email", "sendInvite": "Send invite", "copyInviteLink": "Copy invite link", "label": "Members", "user": "User", "role": "Role", "removeFromWorkspace": "Remove from Workspace", "removeFromWorkspaceSuccess": "Remove from workspace successfully", "removeFromWorkspaceFailed": "Remove from workspace failed", "owner": "Owner", "guest": "Guest", "member": "Member", "memberHintText": "A member can read and edit pages", "guestHintText": "A Guest can read, react, comment, and can edit certain pages with permission.", "emailInvalidError": "Invalid email, please check and try again", "emailSent": "Email sent, please check the inbox", "members": "members", "membersCount": { "zero": "{} members", "one": "{} member", "other": "{} members" }, "inviteFailedDialogTitle": "Failed to send invite", "inviteFailedMemberLimit": "Member limit has been reached, please upgrade to invite more members.", "inviteFailedMemberLimitMobile": "Your workspace has reached the member limit.", "memberLimitExceeded": "Member limit reached, to invite more members, please ", "memberLimitExceededUpgrade": "upgrade", "memberLimitExceededPro": "Member limit reached, if you require more members contact ", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Failed to add member", "addMemberSuccess": "Member added successfully", "removeMember": "Remove Member", "areYouSureToRemoveMember": "Are you sure you want to remove this member?", "inviteMemberSuccess": "The invitation has been sent successfully", "failedToInviteMember": "Failed to invite member", "workspaceMembersError": "Oops, something went wrong", "workspaceMembersErrorDescription": "We couldn't load the member list at this time. Please try again later" } }, "files": { "copy": "Copy", "defaultLocation": "Read files and data storage location", "exportData": "Export your data", "doubleTapToCopy": "Double tap to copy the path", "restoreLocation": "Restore to @:appName default path", "customizeLocation": "Open another folder", "restartApp": "Please restart app for the changes to take effect.", "exportDatabase": "Export database", "selectFiles": "Select the files that need to be export", "selectAll": "Select all", "deselectAll": "Deselect all", "createNewFolder": "Create a new folder", "createNewFolderDesc": "Tell us where you want to store your data", "defineWhereYourDataIsStored": "Define where your data is stored", "open": "Open", "openFolder": "Open an existing folder", "openFolderDesc": "Read and write it to your existing @:appName folder", "folderHintText": "folder name", "location": "Creating a new folder", "locationDesc": "Pick a name for your @:appName data folder", "browser": "Browse", "create": "Create", "set": "Set", "folderPath": "Path to store your folder", "locationCannotBeEmpty": "Path cannot be empty", "pathCopiedSnackbar": "File storage path copied to clipboard!", "changeLocationTooltips": "Change the data directory", "change": "Change", "openLocationTooltips": "Open another data directory", "openCurrentDataFolder": "Open current data directory", "recoverLocationTooltips": "Reset to @:appName's default data directory", "exportFileSuccess": "Export file successfully!", "exportFileFail": "Export file failed!", "export": "Export", "clearCache": "Clear cache", "clearCacheDesc": "If you encounter issues with images not loading or fonts not displaying correctly, try clearing your cache. This action will not remove your user data.", "areYouSureToClearCache": "Are you sure to clear the cache?", "clearCacheSuccess": "Cache cleared successfully!" }, "user": { "name": "Name", "email": "Email", "tooltipSelectIcon": "Select icon", "selectAnIcon": "Select an icon", "pleaseInputYourOpenAIKey": "please input your AI key", "clickToLogout": "Click to logout the current user" }, "mobile": { "personalInfo": "Personal Information", "username": "User Name", "usernameEmptyError": "User name cannot be empty", "about": "About", "pushNotifications": "Push Notifications", "support": "Support", "joinDiscord": "Join us in Discord", "privacyPolicy": "Privacy Policy", "userAgreement": "User Agreement", "termsAndConditions": "Terms and Conditions", "userprofileError": "Failed to load user profile", "userprofileErrorDescription": "Please try to log out and log back in to check if the issue still persists.", "selectLayout": "Select layout", "selectStartingDay": "Select starting day", "version": "Version" } }, "grid": { "deleteView": "Are you sure you want to delete this view?", "createView": "New", "title": { "placeholder": "Untitled" }, "settings": { "filter": "Filter", "sort": "Sort", "sortBy": "Sort by", "properties": "Properties", "reorderPropertiesTooltip": "Drag to reorder properties", "group": "Group", "addFilter": "Add Filter", "deleteFilter": "Delete filter", "filterBy": "Filter by", "typeAValue": "Type a value...", "layout": "Layout", "compactMode": "Compact mode", "databaseLayout": "Layout", "viewList": { "zero": "0 views", "one": "{count} view", "other": "{count} views" }, "editView": "Edit View", "boardSettings": "Board settings", "calendarSettings": "Calendar settings", "createView": "New view", "duplicateView": "Duplicate view", "deleteView": "Delete view", "numberOfVisibleFields": "{} shown" }, "filter": { "empty": "No active filters", "addFilter": "Add filter", "cannotFindCreatableField": "Cannot find a suitable field to filter by", "conditon": "Condition", "where": "Where" }, "textFilter": { "contains": "Contains", "doesNotContain": "Does not contain", "endsWith": "Ends with", "startWith": "Starts with", "is": "Is", "isNot": "Is not", "isEmpty": "Is empty", "isNotEmpty": "Is not empty", "choicechipPrefix": { "isNot": "Not", "startWith": "Starts with", "endWith": "Ends with", "isEmpty": "is empty", "isNotEmpty": "is not empty" } }, "checkboxFilter": { "isChecked": "Ticked", "isUnchecked": "Unticked", "choicechipPrefix": { "is": "is" } }, "checklistFilter": { "isComplete": "Is complete", "isIncomplted": "Is incomplete" }, "selectOptionFilter": { "is": "Is", "isNot": "Is not", "contains": "Contains", "doesNotContain": "Does not contain", "isEmpty": "Is empty", "isNotEmpty": "Is not empty" }, "dateFilter": { "is": "Is on", "before": "Is before", "after": "Is after", "onOrBefore": "Is on or before", "onOrAfter": "Is on or after", "between": "Is between", "empty": "Is empty", "notEmpty": "Is not empty", "startDate": "Start date", "endDate": "End date", "choicechipPrefix": { "before": "Before", "after": "After", "between": "Between", "onOrBefore": "On or before", "onOrAfter": "On or after", "isEmpty": "Is empty", "isNotEmpty": "Is not empty" } }, "numberFilter": { "equal": "Equals", "notEqual": "Does not equal", "lessThan": "Is less than", "greaterThan": "Is greater than", "lessThanOrEqualTo": "Is less than or equal to", "greaterThanOrEqualTo": "Is greater than or equal to", "isEmpty": "Is empty", "isNotEmpty": "Is not empty" }, "field": { "label": "Property", "hide": "Hide property", "show": "Show property", "insertLeft": "Insert left", "insertRight": "Insert right", "duplicate": "Duplicate", "delete": "Delete", "wrapCellContent": "Wrap text", "clear": "Clear cells", "switchPrimaryFieldTooltip": "Cannot change field type of primary field", "textFieldName": "Text", "checkboxFieldName": "Tick box", "dateFieldName": "Date", "updatedAtFieldName": "Last modified", "createdAtFieldName": "Created at", "numberFieldName": "Numbers", "singleSelectFieldName": "Select", "multiSelectFieldName": "Multiselect", "urlFieldName": "URL", "checklistFieldName": "Checklist", "relationFieldName": "Relation", "summaryFieldName": "AI Summary", "timeFieldName": "Time", "mediaFieldName": "Files & media", "translateFieldName": "AI Translate", "translateTo": "Translate to", "numberFormat": "Number format", "dateFormat": "Date format", "includeTime": "Include time", "isRange": "End date", "dateFormatFriendly": "Month Day, Year", "dateFormatISO": "Year-Month-Day", "dateFormatLocal": "Month/Day/Year", "dateFormatUS": "Year/Month/Day", "dateFormatDayMonthYear": "Day/Month/Year", "timeFormat": "Time format", "invalidTimeFormat": "Invalid format", "timeFormatTwelveHour": "12 hour", "timeFormatTwentyFourHour": "24 hour", "clearDate": "Clear date", "dateTime": "Date time", "startDateTime": "Start date time", "endDateTime": "End date time", "failedToLoadDate": "Failed to load date value", "selectTime": "Select time", "selectDate": "Select date", "visibility": "Visibility", "propertyType": "Property type", "addSelectOption": "Add an option", "typeANewOption": "Type a new option", "optionTitle": "Options", "addOption": "Add option", "editProperty": "Edit property", "newProperty": "New property", "openRowDocument": "Open as a page", "deleteFieldPromptMessage": "Are you sure? This property and all its data will be deleted", "clearFieldPromptMessage": "Are you sure? All cells in this column will be emptied", "newColumn": "New column", "format": "Format", "reminderOnDateTooltip": "This cell has a scheduled reminder", "optionAlreadyExist": "Option already exists" }, "rowPage": { "newField": "Add a new field", "fieldDragElementTooltip": "Click to open menu", "showHiddenFields": { "one": "Show {count} hidden field", "many": "Show {count} hidden fields", "other": "Show {count} hidden fields" }, "hideHiddenFields": { "one": "Hide {count} hidden field", "many": "Hide {count} hidden fields", "other": "Hide {count} hidden fields" }, "openAsFullPage": "Open as full page", "moreRowActions": "More row actions" }, "sort": { "ascending": "Ascending", "descending": "Descending", "by": "By", "empty": "No active sorts", "cannotFindCreatableField": "Cannot find a suitable field to sort by", "deleteAllSorts": "Delete all sorts", "addSort": "Add sort", "sortsActive": "Cannot {intention} while sorting", "removeSorting": "Would you like to remove all the sorts in this view and continue?", "fieldInUse": "You are already sorting by this field" }, "row": { "label": "Row", "duplicate": "Duplicate", "delete": "Delete", "titlePlaceholder": "Untitled", "textPlaceholder": "Empty", "copyProperty": "Copied property to clipboard", "count": "Count", "newRow": "New row", "loadMore": "Load more", "action": "Action", "add": "Click add to below", "drag": "Drag to move", "deleteRowPrompt": "Are you sure you want to delete this row? This action cannot be undone.", "deleteCardPrompt": "Are you sure you want to delete this card? This action cannot be undone.", "dragAndClick": "Drag to move, click to open menu", "insertRecordAbove": "Insert record above", "insertRecordBelow": "Insert record below", "noContent": "No content", "reorderRowDescription": "reorder row", "createRowAboveDescription": "create a row above", "createRowBelowDescription": "insert a row below" }, "selectOption": { "create": "Create", "purpleColor": "Purple", "pinkColor": "Pink", "lightPinkColor": "Light Pink", "orangeColor": "Orange", "yellowColor": "Yellow", "limeColor": "Lime", "greenColor": "Green", "aquaColor": "Aqua", "blueColor": "Blue", "deleteTag": "Delete tag", "colorPanelTitle": "Colour", "panelTitle": "Select an option or create one", "searchOption": "Search for an option", "searchOrCreateOption": "Search for an option or create one", "createNew": "Create a new", "orSelectOne": "Or select an option", "typeANewOption": "Type a new option", "tagName": "Tag name" }, "checklist": { "taskHint": "Task description", "addNew": "Add a new task", "submitNewTask": "Create", "hideComplete": "Hide completed tasks", "showComplete": "Show all tasks" }, "url": { "launch": "Open link in browser", "copy": "Copied link to clipboard", "textFieldHint": "Enter a URL", "copiedNotification": "Copied to clipboard!" }, "relation": { "relatedDatabasePlaceLabel": "Related Database", "relatedDatabasePlaceholder": "None", "inRelatedDatabase": "In", "rowSearchTextFieldPlaceholder": "Search", "noDatabaseSelected": "No database selected, please select one first from the list below:", "emptySearchResult": "No records found", "linkedRowListLabel": "{count} linked rows", "unlinkedRowListLabel": "Link another row" }, "menuName": "Grid", "referencedGridPrefix": "View of", "calculate": "Calculate", "calculationTypeLabel": { "none": "None", "average": "Average", "max": "Max", "median": "Median", "min": "Min", "sum": "Sum", "count": "Count", "countEmpty": "Count empty", "countEmptyShort": "EMPTY", "countNonEmpty": "Count not empty", "countNonEmptyShort": "FILLED" }, "media": { "rename": "Rename", "download": "Download", "expand": "Expand", "delete": "Delete", "moreFilesHint": "+{}", "addFileOrImage": "Add file or link", "attachmentsHint": "{}", "addFileMobile": "Add file", "extraCount": "+{}", "deleteFileDescription": "Are you sure you want to delete this file? This action is irreversible.", "showFileNames": "Show file name", "downloadSuccess": "File downloaded", "downloadFailedToken": "Failed to download file, user token unavailable", "setAsCover": "Set as cover", "openInBrowser": "Open in browser", "embedLink": "Embed file link" } }, "document": { "menuName": "Document", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "Creating...", "slashMenu": { "board": { "selectABoardToLinkTo": "Select a Board to link to", "createANewBoard": "Create a new Board" }, "grid": { "selectAGridToLinkTo": "Select a Grid to link to", "createANewGrid": "Create a new Grid" }, "calendar": { "selectACalendarToLinkTo": "Select a Calendar to link to", "createANewCalendar": "Create a new Calendar" }, "document": { "selectADocumentToLinkTo": "Select a Document to link to" }, "name": { "textStyle": "Text Style", "list": "List", "toggle": "Toggle", "fileAndMedia": "File & Media", "simpleTable": "Simple Table", "visuals": "Visuals", "document": "Document", "advanced": "Advanced", "text": "Text", "heading1": "Heading 1", "heading2": "Heading 2", "heading3": "Heading 3", "image": "Image", "bulletedList": "Bulleted list", "numberedList": "Numbered list", "todoList": "To-do list", "doc": "Doc", "linkedDoc": "Link to page", "grid": "Grid", "linkedGrid": "Linked Grid", "kanban": "Kanban", "linkedKanban": "Linked Kanban", "calendar": "Calendar", "linkedCalendar": "Linked Calendar", "quote": "Quote", "divider": "Divider", "table": "Table", "callout": "Callout", "outline": "Outline", "mathEquation": "Maths Equation", "code": "Code", "toggleList": "Toggle list", "toggleHeading1": "Toggle heading 1", "toggleHeading2": "Toggle heading 2", "toggleHeading3": "Toggle heading 3", "emoji": "Emoji", "aiWriter": "Ask AI Anything", "dateOrReminder": "Date or Reminder", "photoGallery": "Photo Gallery", "file": "File", "twoColumns": "2 Columns", "threeColumns": "3 Columns", "fourColumns": "4 Columns" }, "subPage": { "name": "Document", "keyword1": "sub page", "keyword2": "page", "keyword3": "child page", "keyword4": "insert page", "keyword5": "embed page", "keyword6": "new page", "keyword7": "create page", "keyword8": "document" } }, "selectionMenu": { "outline": "Outline", "codeBlock": "Code Block" }, "plugins": { "referencedBoard": "Referenced Board", "referencedGrid": "Referenced Grid", "referencedCalendar": "Referenced Calendar", "referencedDocument": "Referenced Document", "aiWriter": { "userQuestion": "Ask AI anything", "continueWriting": "Continue writing", "fixSpelling": "Fix spelling & grammar", "improveWriting": "Improve writing", "summarize": "Summarise", "explain": "Explain", "makeShorter": "Make shorter", "makeLonger": "Make longer" }, "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Ask AI to write anything...", "autoGeneratorLearnMore": "Learn more", "autoGeneratorGenerate": "Generate", "autoGeneratorHintText": "Ask AI ...", "autoGeneratorCantGetOpenAIKey": "Can't get AI key", "autoGeneratorRewrite": "Rewrite", "smartEdit": "Ask AI", "aI": "AI", "smartEditFixSpelling": "Fix spelling & grammar", "warning": "⚠️ AI responses can be inaccurate or misleading.", "smartEditSummarize": "Summarise", "smartEditImproveWriting": "Improve writing", "smartEditMakeLonger": "Make longer", "smartEditCouldNotFetchResult": "Could not fetch result from AI", "smartEditCouldNotFetchKey": "Could not fetch AI key", "smartEditDisabled": "Connect AI in Settings", "appflowyAIEditDisabled": "Sign in to enable AI features", "discardResponse": "Are you sure you want to discard the AI response?", "createInlineMathEquation": "Create equation", "fonts": "Fonts", "insertDate": "Insert date", "emoji": "Emoji", "toggleList": "Toggle list", "emptyToggleHeading": "Empty toggle h{}. Click to add content.", "emptyToggleList": "Empty toggle list. Click to add content.", "emptyToggleHeadingWeb": "Empty toggle h{level}. Click to add content", "quoteList": "Quote list", "numberedList": "Numbered list", "bulletedList": "Bulleted list", "todoList": "Todo list", "callout": "Callout", "simpleTable": { "moreActions": { "color": "Colour", "align": "Align", "delete": "Delete", "duplicate": "Duplicate", "insertLeft": "Insert left", "insertRight": "Insert right", "insertAbove": "Insert above", "insertBelow": "Insert below", "headerColumn": "Header column", "headerRow": "Header row", "clearContents": "Clear contents", "setToPageWidth": "Set to page width", "distributeColumnsWidth": "Distribute columns evenly", "duplicateRow": "Duplicate row", "duplicateColumn": "Duplicate column", "textColor": "Text colour", "cellBackgroundColor": "Cell background colour", "duplicateTable": "Duplicate table" }, "clickToAddNewRow": "Click to add a new row", "clickToAddNewColumn": "Click to add a new column", "clickToAddNewRowAndColumn": "Click to add a new row and column", "headerName": { "table": "Table", "alignText": "Align text" } }, "cover": { "changeCover": "Change Cover", "colors": "Colours", "images": "Images", "clearAll": "Clear All", "abstract": "Abstract", "addCover": "Add Cover", "addLocalImage": "Add local image", "invalidImageUrl": "Invalid image URL", "failedToAddImageToGallery": "Failed to add image to gallery", "enterImageUrl": "Enter image URL", "add": "Add", "back": "Back", "saveToGallery": "Save to gallery", "removeIcon": "Remove icon", "removeCover": "Remove cover", "pasteImageUrl": "Paste image URL", "or": "OR", "pickFromFiles": "Pick from files", "couldNotFetchImage": "Could not fetch image", "imageSavingFailed": "Image Saving Failed", "addIcon": "Add icon", "changeIcon": "Change icon", "coverRemoveAlert": "It will be removed from cover after it is deleted.", "alertDialogConfirmation": "Are you sure, you want to continue?" }, "mathEquation": { "name": "Maths Equation", "addMathEquation": "Add a TeX equation", "editMathEquation": "Edit Maths Equation" }, "optionAction": { "click": "Click", "toOpenMenu": " to open menu", "drag": "Drag", "toMove": " to move", "delete": "Delete", "duplicate": "Duplicate", "turnInto": "Turn into", "moveUp": "Move up", "moveDown": "Move down", "color": "Colour", "align": "Align", "left": "Left", "center": "Centre", "right": "Right", "defaultColor": "Default", "depth": "Depth", "copyLinkToBlock": "Copy link to block" }, "image": { "addAnImage": "Add images", "copiedToPasteBoard": "The image link has been copied to the clipboard", "addAnImageDesktop": "Add an image", "addAnImageMobile": "Click to add one or more images", "dropImageToInsert": "Drop images to insert", "imageUploadFailed": "Image upload failed", "imageDownloadFailed": "Image download failed, please try again", "imageDownloadFailedToken": "Image download failed due to missing user token, please try again", "errorCode": "Error code" }, "photoGallery": { "name": "Photo gallery", "imageKeyword": "image", "imageGalleryKeyword": "image gallery", "photoKeyword": "photo", "photoBrowserKeyword": "photo browser", "galleryKeyword": "gallery", "addImageTooltip": "Add image", "changeLayoutTooltip": "Change layout", "browserLayout": "Browser", "gridLayout": "Grid", "deleteBlockTooltip": "Delete whole gallery" }, "math": { "copiedToPasteBoard": "The maths equation has been copied to the clipboard" }, "urlPreview": { "copiedToPasteBoard": "The link has been copied to the clipboard", "convertToLink": "Convert to embed link" }, "outline": { "addHeadingToCreateOutline": "Add headings to create a table of contents.", "noMatchHeadings": "No matching headings found." }, "table": { "addAfter": "Add after", "addBefore": "Add before", "delete": "Delete", "clear": "Clear content", "duplicate": "Duplicate", "bgColor": "Background colour" }, "contextMenu": { "copy": "Copy", "cut": "Cut", "paste": "Paste", "pasteAsPlainText": "Paste as plain text" }, "action": "Actions", "database": { "selectDataSource": "Select data source", "noDataSource": "No data source", "selectADataSource": "Select a data source", "toContinue": "to continue", "newDatabase": "New Database", "linkToDatabase": "Link to Database" }, "date": "Date", "video": { "label": "Video", "emptyLabel": "Add a video", "placeholder": "Paste the video link", "copiedToPasteBoard": "The video link has been copied to the clipboard", "insertVideo": "Add video", "invalidVideoUrl": "The source URL is not supported yet.", "invalidVideoUrlYouTube": "YouTube is not supported yet.", "supportedFormats": "Supported formats: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "File", "uploadTab": "Upload", "uploadMobile": "Choose a file", "uploadMobileGallery": "From Photo Gallery", "networkTab": "Embed link", "placeholderText": "Upload or embed a file", "placeholderDragging": "Drop the file to upload", "dropFileToUpload": "Drop a file to upload", "fileUploadHint": "Drag & drop a file or click to ", "fileUploadHintSuffix": "Browse", "networkHint": "Paste a file link", "networkUrlInvalid": "Invalid URL. Check the URL and try again.", "networkAction": "Embed", "fileTooBigError": "File size is too big, please upload a file with size less than 10MB", "renameFile": { "title": "Rename file", "description": "Enter the new name for this file", "nameEmptyError": "File name cannot be left empty." }, "uploadedAt": "Uploaded on {}", "linkedAt": "Link added on {}", "failedToOpenMsg": "Failed to open, file not found" }, "subPage": { "handlingPasteHint": " - (handling paste)", "errors": { "failedDeletePage": "Failed to delete page", "failedCreatePage": "Failed to create page", "failedMovePage": "Failed to move page to this document", "failedDuplicatePage": "Failed to duplicate page", "failedDuplicateFindView": "Failed to duplicate page - original view not found" } }, "cannotMoveToItsChildren": "Cannot move to its children" }, "outlineBlock": { "placeholder": "Table of Contents" }, "textBlock": { "placeholder": "Type '/' for commands" }, "title": { "placeholder": "Untitled" }, "imageBlock": { "placeholder": "Click to add image(s)", "upload": { "label": "Upload", "placeholder": "Click to upload image" }, "url": { "label": "Image URL", "placeholder": "Enter image URL" }, "ai": { "label": "Generate image from AI", "placeholder": "Please input the prompt for AI to generate image" }, "stability_ai": { "label": "Generate image from Stability AI", "placeholder": "Please input the prompt for Stability AI to generate image" }, "support": "Image size limit is 5MB. Supported formats: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Invalid image", "invalidImageSize": "Image size must be less than 5MB", "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "Invalid image URL", "noImage": "No such file or directory", "multipleImagesFailed": "One or more images failed to upload, please try again" }, "embedLink": { "label": "Embed link", "placeholder": "Paste or type an image link" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Search for an image", "pleaseInputYourOpenAIKey": "please input your AI key in Settings page", "saveImageToGallery": "Save image", "failedToAddImageToGallery": "Failed to save image", "successToAddImageToGallery": "Saved image to Photos", "unableToLoadImage": "Unable to load image", "maximumImageSize": "Maximum supported upload image size is 10MB", "uploadImageErrorImageSizeTooBig": "Image size must be less than 10MB", "imageIsUploading": "Image is uploading", "openFullScreen": "Open in full screen", "interactiveViewer": { "toolbar": { "previousImageTooltip": "Previous image", "nextImageTooltip": "Next image", "zoomOutTooltip": "Zoom out", "zoomInTooltip": "Zoom in", "changeZoomLevelTooltip": "Change zoom level", "openLocalImage": "Open image", "downloadImage": "Download image", "closeViewer": "Close interactive viewer", "scalePercentage": "{}%", "deleteImageTooltip": "Delete image" } } }, "codeBlock": { "language": { "label": "Language", "placeholder": "Select language", "auto": "Auto" }, "copyTooltip": "Copy", "searchLanguageHint": "Search for a language", "codeCopiedSnackbar": "Code copied to clipboard!" }, "inlineLink": { "placeholder": "Paste or type a link", "openInNewTab": "Open in new tab", "copyLink": "Copy link", "removeLink": "Remove link", "url": { "label": "Link URL", "placeholder": "Enter link URL" }, "title": { "label": "Link Title", "placeholder": "Enter link title" } }, "mention": { "placeholder": "Mention a person or a page or date...", "page": { "label": "Link to page", "tooltip": "Click to open page" }, "deleted": "Deleted", "deletedContent": "This content does not exist or has been deleted", "noAccess": "No Access", "deletedPage": "Deleted page", "trashHint": " - in bin", "morePages": "more pages" }, "toolbar": { "resetToDefaultFont": "Reset to default", "textSize": "Text size", "textColor": "Text colour", "h1": "Heading 1", "h2": "Heading 2", "h3": "Heading 3", "alignLeft": "Align left", "alignRight": "Align right", "alignCenter": "Align centre", "link": "Link", "textAlign": "Text align", "moreOptions": "More options", "font": "Font", "inlineCode": "Inline code", "suggestions": "Suggestions", "turnInto": "Turn into", "equation": "Equation", "insert": "Insert", "linkInputHint": "Paste link or search pages", "pageOrURL": "Page or URL", "linkName": "Link Name", "linkNameHint": "Input link name" }, "errorBlock": { "theBlockIsNotSupported": "Unable to parse the block content", "clickToCopyTheBlockContent": "Click to copy the block content", "blockContentHasBeenCopied": "The block content has been copied.", "parseError": "An error occurred while parsing the {} block.", "copyBlockContent": "Copy block content" }, "mobilePageSelector": { "title": "Select page", "failedToLoad": "Failed to load page list", "noPagesFound": "No pages found" }, "attachmentMenu": { "choosePhoto": "Choose photo", "takePicture": "Take a picture", "chooseFile": "Choose file" } }, "board": { "column": { "label": "Column", "createNewCard": "New", "renameGroupTooltip": "Press to rename group", "createNewColumn": "Add a new group", "addToColumnTopTooltip": "Add a new card at the top", "addToColumnBottomTooltip": "Add a new card at the bottom", "renameColumn": "Rename", "hideColumn": "Hide", "newGroup": "New group", "deleteColumn": "Delete", "deleteColumnConfirmation": "This will delete this group and all the cards in it. Are you sure you want to continue?" }, "hiddenGroupSection": { "sectionTitle": "Hidden Groups", "collapseTooltip": "Hide the hidden groups", "expandTooltip": "View the hidden groups" }, "cardDetail": "Card Detail", "cardActions": "Card Actions", "cardDuplicated": "Card has been duplicated", "cardDeleted": "Card has been deleted", "showOnCard": "Show on card detail", "setting": "Setting", "propertyName": "Property name", "menuName": "Board", "showUngrouped": "Show ungrouped items", "ungroupedButtonText": "Ungrouped", "ungroupedButtonTooltip": "Contains cards that don't belong in any group", "ungroupedItemsTitle": "Click to add to the board", "groupBy": "Group by", "groupCondition": "Group condition", "referencedBoardPrefix": "View of", "notesTooltip": "Notes inside", "mobile": { "editURL": "Edit URL", "showGroup": "Show group", "showGroupContent": "Are you sure you want to show this group on the board?", "failedToLoad": "Failed to load board view" }, "dateCondition": { "weekOf": "Week of {} - {}", "today": "Today", "yesterday": "Yesterday", "tomorrow": "Tomorrow", "lastSevenDays": "Last 7 days", "nextSevenDays": "Next 7 days", "lastThirtyDays": "Last 30 days", "nextThirtyDays": "Next 30 days" }, "noGroup": "No group by property", "noGroupDesc": "Board views require a property to group by in order to display", "media": { "cardText": "{} {}", "fallbackName": "files" } }, "calendar": { "menuName": "Calendar", "defaultNewCalendarTitle": "Untitled", "newEventButtonTooltip": "Add a new event", "navigation": { "today": "Today", "jumpToday": "Jump to Today", "previousMonth": "Previous Month", "nextMonth": "Next Month", "views": { "day": "Day", "week": "Week", "month": "Month", "year": "Year" } }, "mobileEventScreen": { "emptyTitle": "No events yet", "emptyBody": "Press the plus button to create an event on this day." }, "settings": { "showWeekNumbers": "Show week numbers", "showWeekends": "Show weekends", "firstDayOfWeek": "Start week on", "layoutDateField": "Layout calendar by", "changeLayoutDateField": "Change layout field", "noDateTitle": "No Date", "noDateHint": { "zero": "Unscheduled events will show up here", "one": "{count} unscheduled event", "other": "{count} unscheduled events" }, "unscheduledEventsTitle": "Unscheduled events", "clickToAdd": "Click to add to the calendar", "name": "Calendar settings", "clickToOpen": "Click to open the record" }, "referencedCalendarPrefix": "View of", "quickJumpYear": "Jump to", "duplicateEvent": "Duplicate event" }, "errorDialog": { "title": "@:appName Error", "howToFixFallback": "We're sorry for the inconvenience! Submit an issue on our GitHub page that describes your error.", "howToFixFallbackHint1": "We're sorry for the inconvenience! Submit an issue on our ", "howToFixFallbackHint2": " page that describes your error.", "github": "View on GitHub" }, "search": { "label": "Search", "sidebarSearchIcon": "Search and quickly jump to a page", "placeholder": { "actions": "Search actions..." } }, "message": { "copy": { "success": "Copied!", "fail": "Unable to copy" } }, "unSupportBlock": "The current version does not support this Block.", "views": { "deleteContentTitle": "Are you sure want to delete the {pageType}?", "deleteContentCaption": "if you delete this {pageType}, you can restore it from the bin." }, "colors": { "custom": "Custom", "default": "Default", "red": "Red", "orange": "Orange", "yellow": "Yellow", "green": "Green", "blue": "Blue", "purple": "Purple", "pink": "Pink", "brown": "Brown", "gray": "Gray" }, "emoji": { "emojiTab": "Emoji", "search": "Search emoji", "noRecent": "No recent emoji", "noEmojiFound": "No emoji found", "filter": "Filter", "random": "Random", "selectSkinTone": "Select skin tone", "remove": "Remove emoji", "categories": { "smileys": "Smileys & Emotion", "people": "people", "animals": "nature", "food": "foods", "activities": "activities", "places": "places", "objects": "objects", "symbols": "symbols", "flags": "flags", "nature": "nature", "frequentlyUsed": "frequently Used" }, "skinTone": { "default": "Default", "light": "Light", "mediumLight": "Medium-Light", "medium": "Medium", "mediumDark": "Medium-Dark", "dark": "Dark" }, "openSourceIconsFrom": "Open source icons from" }, "inlineActions": { "noResults": "No results", "recentPages": "Recent pages", "pageReference": "Page reference", "docReference": "Document reference", "boardReference": "Board reference", "calReference": "Calendar reference", "gridReference": "Grid reference", "date": "Date", "reminder": { "groupTitle": "Reminder", "shortKeyword": "remind" }, "createPage": "Create \"{}\" sub-page" }, "datePicker": { "dateTimeFormatTooltip": "Change the date and time format in settings", "dateFormat": "Date format", "includeTime": "Include time", "isRange": "End date", "timeFormat": "Time format", "clearDate": "Clear date", "reminderLabel": "Reminder", "selectReminder": "Select reminder", "reminderOptions": { "none": "None", "atTimeOfEvent": "Time of event", "fiveMinsBefore": "5 mins before", "tenMinsBefore": "10 mins before", "fifteenMinsBefore": "15 mins before", "thirtyMinsBefore": "30 mins before", "oneHourBefore": "1 hour before", "twoHoursBefore": "2 hours before", "onDayOfEvent": "On day of event", "oneDayBefore": "1 day before", "twoDaysBefore": "2 days before", "oneWeekBefore": "1 week before", "custom": "Custom" } }, "relativeDates": { "yesterday": "Yesterday", "today": "Today", "tomorrow": "Tomorrow", "oneWeek": "1 week" }, "notificationHub": { "title": "Notifications", "mobile": { "title": "Updates" }, "emptyTitle": "All caught up!", "emptyBody": "No pending notifications or actions. Enjoy the calm.", "tabs": { "inbox": "Inbox", "upcoming": "Upcoming" }, "actions": { "markAllRead": "Mark all as read", "showAll": "All", "showUnreads": "Unread" }, "filters": { "ascending": "Ascending", "descending": "Descending", "groupByDate": "Group by date", "showUnreadsOnly": "Show unreads only", "resetToDefault": "Reset to default" } }, "reminderNotification": { "title": "Reminder", "message": "Remember to check this before you forget!", "tooltipDelete": "Delete", "tooltipMarkRead": "Mark as read", "tooltipMarkUnread": "Mark as unread" }, "findAndReplace": { "find": "Find", "previousMatch": "Previous match", "nextMatch": "Next match", "close": "Close", "replace": "Replace", "replaceAll": "Replace all", "noResult": "No results", "caseSensitive": "Case sensitive", "searchMore": "Search to find more results" }, "error": { "weAreSorry": "We're sorry", "loadingViewError": "We're having trouble loading this view. Please check your internet connection, refresh the app, and do not hesitate to reach out to the team if the issue continues.", "syncError": "Data is not synced from another device", "syncErrorHint": "Please reopen this page on the device where it was last edited, then open it again on the current device.", "clickToCopy": "Click to copy error code" }, "editor": { "bold": "Bold", "bulletedList": "Bulleted list", "bulletedListShortForm": "Bulleted", "checkbox": "Tick box", "embedCode": "Embed Code", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Highlight", "color": "Colour", "image": "Image", "date": "Date", "page": "Page", "italic": "Italic", "link": "Link", "numberedList": "Numbered list", "numberedListShortForm": "Numbered", "toggleHeading1ShortForm": "Toggle H1", "toggleHeading2ShortForm": "Toggle H2", "toggleHeading3ShortForm": "Toggle H3", "quote": "Quote", "strikethrough": "Strikethrough", "text": "Text", "underline": "Underline", "fontColorDefault": "Default", "fontColorGray": "Gray", "fontColorBrown": "Brown", "fontColorOrange": "Orange", "fontColorYellow": "Yellow", "fontColorGreen": "Green", "fontColorBlue": "Blue", "fontColorPurple": "Purple", "fontColorPink": "Pink", "fontColorRed": "Red", "backgroundColorDefault": "Default background", "backgroundColorGray": "Gray background", "backgroundColorBrown": "Brown background", "backgroundColorOrange": "Orange background", "backgroundColorYellow": "Yellow background", "backgroundColorGreen": "Green background", "backgroundColorBlue": "Blue background", "backgroundColorPurple": "Purple background", "backgroundColorPink": "Pink background", "backgroundColorRed": "Red background", "backgroundColorLime": "Lime background", "backgroundColorAqua": "Aqua background", "done": "Done", "cancel": "Cancel", "tint1": "Tint 1", "tint2": "Tint 2", "tint3": "Tint 3", "tint4": "Tint 4", "tint5": "Tint 5", "tint6": "Tint 6", "tint7": "Tint 7", "tint8": "Tint 8", "tint9": "Tint 9", "lightLightTint1": "Purple", "lightLightTint2": "Pink", "lightLightTint3": "Light Pink", "lightLightTint4": "Orange", "lightLightTint5": "Yellow", "lightLightTint6": "Lime", "lightLightTint7": "Green", "lightLightTint8": "Aqua", "lightLightTint9": "Blue", "urlHint": "URL", "mobileHeading1": "Heading 1", "mobileHeading2": "Heading 2", "mobileHeading3": "Heading 3", "mobileHeading4": "Heading 4", "mobileHeading5": "Heading 5", "mobileHeading6": "Heading 6", "textColor": "Text Colour", "backgroundColor": "Background Colour", "addYourLink": "Add your link", "openLink": "Open link", "copyLink": "Copy link", "removeLink": "Remove link", "editLink": "Edit link", "linkText": "Text", "linkTextHint": "Please enter text", "linkAddressHint": "Please enter URL", "highlightColor": "Highlight colour", "clearHighlightColor": "Clear highlight colour", "customColor": "Custom colour", "hexValue": "Hex value", "opacity": "Opacity", "resetToDefaultColor": "Reset to default colour", "ltr": "LTR", "rtl": "RTL", "auto": "Auto", "cut": "Cut", "copy": "Copy", "paste": "Paste", "find": "Find", "select": "Select", "selectAll": "Select all", "previousMatch": "Previous match", "nextMatch": "Next match", "closeFind": "Close", "replace": "Replace", "replaceAll": "Replace all", "regex": "Regex", "caseSensitive": "Case sensitive", "uploadImage": "Upload Image", "urlImage": "URL Image", "incorrectLink": "Incorrect Link", "upload": "Upload", "chooseImage": "Choose an image", "loading": "Loading", "imageLoadFailed": "Image load failed", "divider": "Divider", "table": "Table", "colAddBefore": "Add before", "rowAddBefore": "Add before", "colAddAfter": "Add after", "rowAddAfter": "Add after", "colRemove": "Remove", "rowRemove": "Remove", "colDuplicate": "Duplicate", "rowDuplicate": "Duplicate", "colClear": "Clear Content", "rowClear": "Clear Content", "slashPlaceHolder": "Type '/' to insert a block, or start typing", "typeSomething": "Type something...", "toggleListShortForm": "Toggle", "quoteListShortForm": "Quote", "mathEquationShortForm": "Formula", "codeBlockShortForm": "Code" }, "favorite": { "noFavorite": "No favourite page", "noFavoriteHintText": "Swipe the page to the left to add it to your favourites", "removeFromSidebar": "Remove from sidebar", "addToSidebar": "Pin to sidebar" }, "cardDetails": { "notesPlaceholder": "Enter a / to insert a block, or start typing" }, "blockPlaceholders": { "todoList": "To-do", "bulletList": "List", "numberList": "List", "quote": "Quote", "heading": "Heading {}" }, "titleBar": { "pageIcon": "Page icon", "language": "Language", "font": "Font", "actions": "Actions", "date": "Date", "addField": "Add field", "userIcon": "User icon" }, "noLogFiles": "There're no log files", "newSettings": { "myAccount": { "title": "My account", "subtitle": "Customise your profile, manage account security, open AI keys, or login into your account.", "profileLabel": "Account name & Profile image", "profileNamePlaceholder": "Enter your name", "accountSecurity": "Account security", "2FA": "2-Step Authentication", "aiKeys": "AI keys", "accountLogin": "Account Login", "updateNameError": "Failed to update name", "updateIconError": "Failed to update icon", "aboutAppFlowy": "About @:appName", "deleteAccount": { "title": "Delete Account", "subtitle": "Permanently delete your account and all of your data.", "description": "Permanently delete your account and remove access from all workspaces.", "deleteMyAccount": "Delete my account", "dialogTitle": "Delete account", "dialogContent1": "Are you sure you want to permanently delete your account?", "dialogContent2": "This action cannot be undone, and will remove access from all workspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces.", "confirmHint1": "Please type \"@:newSettings.myAccount.deleteAccount.confirmHint3\" to confirm.", "confirmHint2": "I understand that this action is irreversible and will permanently delete my account and all associated data.", "confirmHint3": "DELETE MY ACCOUNT", "checkToConfirmError": "You must tick the box to confirm deletion", "failedToGetCurrentUser": "Failed to get current user email", "confirmTextValidationFailed": "Your confirmation text does not match \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "Account deleted successfully" } }, "workplace": { "name": "Workplace", "title": "Workplace Settings", "subtitle": "Customise your workspace appearance, theme, font, text layout, date, time, and language.", "workplaceName": "Workplace name", "workplaceNamePlaceholder": "Enter workplace name", "workplaceIcon": "Workplace icon", "workplaceIconSubtitle": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications.", "renameError": "Failed to rename workplace", "updateIconError": "Failed to update icon", "chooseAnIcon": "Choose an icon", "appearance": { "name": "Appearance", "themeMode": { "auto": "Auto", "light": "Light", "dark": "Dark" }, "language": "Language" } }, "syncState": { "syncing": "Syncing", "synced": "Synced", "noNetworkConnected": "No network connected" } }, "pageStyle": { "title": "Page style", "layout": "Layout", "coverImage": "Cover image", "pageIcon": "Page icon", "colors": "Colours", "gradient": "Gradient", "backgroundImage": "Background image", "presets": "Presets", "photo": "Photo", "unsplash": "Unsplash", "pageCover": "Page cover", "none": "None", "openSettings": "Open Settings", "photoPermissionTitle": "@:appName would like to access your photo library", "photoPermissionDescription": "@:appName needs access to your photos to let you add images to your documents", "cameraPermissionTitle": "@:appName would like to access your camera", "cameraPermissionDescription": "@:appName needs access to your camera to let you add images to your documents from the camera", "doNotAllow": "Don't Allow", "image": "Image" }, "commandPalette": { "placeholder": "Search or ask a question...", "bestMatches": "Best matches", "recentHistory": "Recent history", "navigateHint": "to navigate", "loadingTooltip": "We are looking for results...", "betaLabel": "BETA", "betaTooltip": "We currently only support searching for pages and content in documents", "fromTrashHint": "From bin", "noResultsHint": "We didn't find what you're looking for, try searching for another term.", "clearSearchTooltip": "Clear search field" }, "space": { "delete": "Delete", "deleteConfirmation": "Delete: ", "deleteConfirmationDescription": "All pages within this Space will be deleted and moved to the Bin, and any published pages will be unpublished.", "rename": "Rename Space", "changeIcon": "Change icon", "manage": "Manage Space", "addNewSpace": "Create Space", "collapseAllSubPages": "Collapse all subpages", "createNewSpace": "Create a new space", "createSpaceDescription": "Create multiple public and private spaces to better organise your work.", "spaceName": "Space name", "spaceNamePlaceholder": "e.g. Marketing, Engineering, HR", "permission": "Space permission", "publicPermission": "Public", "publicPermissionDescription": "All workspace members with full access", "privatePermission": "Private", "privatePermissionDescription": "Only you can access this space", "spaceIconBackground": "Background colour", "spaceIcon": "Icon", "dangerZone": "Danger Zone", "unableToDeleteLastSpace": "Unable to delete the last Space", "unableToDeleteSpaceNotCreatedByYou": "Unable to delete spaces created by others", "enableSpacesForYourWorkspace": "Enable Spaces for your workspace", "title": "Spaces", "defaultSpaceName": "General", "upgradeSpaceTitle": "Enable Spaces", "upgradeSpaceDescription": "Create multiple public and private Spaces to better organise your workspace.", "upgrade": "Update", "upgradeYourSpace": "Create multiple Spaces", "quicklySwitch": "Quickly switch to the next space", "duplicate": "Duplicate Space", "movePageToSpace": "Move page to space", "cannotMovePageToDatabase": "Cannot move page to database", "switchSpace": "Switch space", "spaceNameCannotBeEmpty": "Space name cannot be empty", "success": { "deleteSpace": "Space deleted successfully", "renameSpace": "Space renamed successfully", "duplicateSpace": "Space duplicated successfully", "updateSpace": "Space updated successfully" }, "error": { "deleteSpace": "Failed to delete space", "renameSpace": "Failed to rename space", "duplicateSpace": "Failed to duplicate space", "updateSpace": "Failed to update space" }, "createSpace": "Create space", "manageSpace": "Manage space", "renameSpace": "Rename space", "mSpaceIconColor": "Space icon colour", "mSpaceIcon": "Space icon" }, "publish": { "hasNotBeenPublished": "This page hasn't been published yet", "spaceHasNotBeenPublished": "Haven't supported publishing a space yet", "reportPage": "Report page", "databaseHasNotBeenPublished": "Publishing a database is not supported yet.", "createdWith": "Created with", "downloadApp": "Download AppFlowy", "copy": { "codeBlock": "The content of code block has been copied to the clipboard", "imageBlock": "The image link has been copied to the clipboard", "mathBlock": "The maths equation has been copied to the clipboard", "fileBlock": "The file link has been copied to the clipboard" }, "containsPublishedPage": "This page contains one or more published pages. If you continue, they will be unpublished. Do you want to proceed with deletion?", "publishSuccessfully": "Published successfully", "unpublishSuccessfully": "Unpublished successfully", "publishFailed": "Failed to publish", "unpublishFailed": "Failed to unpublish", "noAccessToVisit": "No access to this page...", "createWithAppFlowy": "Create a website with AppFlowy", "fastWithAI": "Fast and easy with AI.", "tryItNow": "Try it now", "onlyGridViewCanBePublished": "Only Grid view can be published", "database": { "zero": "Publish {} selected view", "one": "Publish {} selected views", "many": "Publish {} selected views", "other": "Publish {} selected views" }, "mustSelectPrimaryDatabase": "The primary view must be selected", "noDatabaseSelected": "No database selected, please select at least one database.", "unableToDeselectPrimaryDatabase": "Unable to deselect primary database", "saveThisPage": "Start with this template", "duplicateTitle": "Where would you like to add", "selectWorkspace": "Select a workspace", "addTo": "Add to", "duplicateSuccessfully": "Added to your workspace", "duplicateSuccessfullyDescription": "Don't have AppFlowy installed? The download will start automatically after you click 'Download'.", "downloadIt": "Download", "openApp": "Open in app", "duplicateFailed": "Duplicated failed", "membersCount": { "zero": "No members", "one": "1 member", "many": "{count} members", "other": "{count} members" }, "useThisTemplate": "Use the template" }, "web": { "continue": "Continue", "or": "or", "continueWithGoogle": "Continue with Google", "continueWithGithub": "Continue with GitHub", "continueWithDiscord": "Continue with Discord", "continueWithApple": "Continue with Apple ", "moreOptions": "More options", "collapse": "Collapse", "signInAgreement": "By clicking \"Continue\" above, you agreed to AppFlowy's", "signInLocalAgreement": "By clicking \"Get Started\" above, you agreed to AppFlowy's", "and": "and", "termOfUse": "Terms", "privacyPolicy": "Privacy Policy", "signInError": "Sign in error", "login": "Sign up or log in", "fileBlock": { "uploadedAt": "Uploaded on {time}", "linkedAt": "Link added on {time}", "empty": "Upload or embed a file", "uploadFailed": "Upload failed, please try again", "retry": "Retry" }, "importNotion": "Import from Notion", "import": "Import", "importSuccess": "Uploaded successfully", "importSuccessMessage": "We'll notify you when the import is complete. After that, you can view your imported pages in the sidebar.", "importFailed": "Import failed, please check the file format", "dropNotionFile": "Drop your Notion zip file here to upload, or click to browse", "error": { "pageNameIsEmpty": "The page name is empty, please try another one" } }, "globalComment": { "comments": "Comments", "addComment": "Add a comment", "reactedBy": "reacted by", "addReaction": "Add reaction", "reactedByMore": "and {count} others", "showSeconds": { "one": "1 second ago", "other": "{count} seconds ago", "zero": "Just now", "many": "{count} seconds ago" }, "showMinutes": { "one": "1 minute ago", "other": "{count} minutes ago", "many": "{count} minutes ago" }, "showHours": { "one": "1 hour ago", "other": "{count} hours ago", "many": "{count} hours ago" }, "showDays": { "one": "1 day ago", "other": "{count} days ago", "many": "{count} days ago" }, "showMonths": { "one": "1 month ago", "other": "{count} months ago", "many": "{count} months ago" }, "showYears": { "one": "1 year ago", "other": "{count} years ago", "many": "{count} years ago" }, "reply": "Reply", "deleteComment": "Delete comment", "youAreNotOwner": "You are not the owner of this comment", "confirmDeleteDescription": "Are you sure you want to delete this comment?", "hasBeenDeleted": "Deleted", "replyingTo": "Replying to", "noAccessDeleteComment": "You're not allowed to delete this comment", "collapse": "Collapse", "readMore": "Read more", "failedToAddComment": "Failed to add comment", "commentAddedSuccessfully": "Comment added successfully.", "commentAddedSuccessTip": "You've just added or replied to a comment. Would you like to jump to the top to see the latest comments?" }, "template": { "asTemplate": "Save as template", "name": "Template name", "description": "Template Description", "about": "Template About", "deleteFromTemplate": "Delete from templates", "preview": "Template Preview", "categories": "Template Categories", "isNewTemplate": "PIN to New template", "featured": "PIN to Featured", "relatedTemplates": "Related Templates", "requiredField": "{field} is required", "addCategory": "Add \"{category}\"", "addNewCategory": "Add new category", "addNewCreator": "Add new creator", "deleteCategory": "Delete category", "editCategory": "Edit category", "editCreator": "Edit creator", "category": { "name": "Category name", "icon": "Category icon", "bgColor": "Category background colour", "priority": "Category priority", "desc": "Category description", "type": "Category type", "icons": "Category Icons", "colors": "Category Colours", "byUseCase": "By Use Case", "byFeature": "By Feature", "deleteCategory": "Delete category", "deleteCategoryDescription": "Are you sure you want to delete this category?", "typeToSearch": "Type to search categories..." }, "creator": { "label": "Template Creator", "name": "Creator name", "avatar": "Creator avatar", "accountLinks": "Creator account links", "uploadAvatar": "Click to upload avatar", "deleteCreator": "Delete creator", "deleteCreatorDescription": "Are you sure you want to delete this creator?", "typeToSearch": "Type to search creators..." }, "uploadSuccess": "Template uploaded successfully", "uploadSuccessDescription": "Your template has been uploaded successfully. You can now view it in the template gallery.", "viewTemplate": "View template", "deleteTemplate": "Delete template", "deleteSuccess": "Template deleted successfully", "deleteTemplateDescription": "This won't affect the current page or published status. Are you sure you want to delete this template?", "addRelatedTemplate": "Add related template", "removeRelatedTemplate": "Remove related template", "uploadAvatar": "Upload avatar", "searchInCategory": "Search in {category}", "label": "Templates" }, "fileDropzone": { "dropFile": "Click or drag file to this area to upload", "uploading": "Uploading...", "uploadFailed": "Upload failed", "uploadSuccess": "Upload success", "uploadSuccessDescription": "The file has been uploaded successfully", "uploadFailedDescription": "The file upload failed", "uploadingDescription": "The file is being uploaded" }, "gallery": { "preview": "Open in full screen", "copy": "Copy", "download": "Download", "prev": "Previous", "next": "Next", "resetZoom": "Reset zoom", "zoomIn": "Zoom in", "zoomOut": "Zoom out" }, "invitation": { "join": "Join", "on": "on", "invitedBy": "Invited by", "membersCount": { "zero": "{count} members", "one": "{count} member", "many": "{count} members", "other": "{count} members" }, "tip": "You’ve been invited to Join this workspace with the contact information below. If this is incorrect, contact your administrator to resend the invite.", "joinWorkspace": "Join workspace", "success": "You've successfully joined the workspace", "successMessage": "You can now access all the pages and workspaces within it.", "openWorkspace": "Open AppFlowy", "alreadyAccepted": "You've already accepted the invitation", "errorModal": { "title": "Something went wrong", "description": "Your current account {email} may not have access to this workspace. Please log in with the correct account or contact the workspace owner for help.", "contactOwner": "Contact owner", "close": "Back to home", "changeAccount": "Change account" } }, "requestAccess": { "title": "No access to this page", "subtitle": "You can request access from the owner of this page. Once approved, you can view the page.", "requestAccess": "Request access", "backToHome": "Back to home", "tip": "You're currently logged in as .", "mightBe": "You might need to with a different account.", "successful": "Request sent successfully", "successfulMessage": "You will be notified once the owner approves your request.", "requestError": "Failed to request access", "repeatRequestError": "You've already requested access to this page" }, "approveAccess": { "title": "Approve Workspace Join Request", "requestSummary": " requests to join and access ", "upgrade": "upgrade", "downloadApp": "Download AppFlowy", "approveButton": "Approve", "approveSuccess": "Approved successfully", "approveError": "Failed to approve, ensure the workspace plan limit is not exceeded", "getRequestInfoError": "Failed to get request info", "memberCount": { "zero": "No members", "one": "1 member", "many": "{count} members", "other": "{count} members" }, "alreadyProTitle": "You've reached the workspace plan limit", "alreadyProMessage": "Ask them to contact to unlock more members", "repeatApproveError": "You've already approved this request", "ensurePlanLimit": "Ensure the workspace plan limit is not exceeded. If the limit is exceeded, consider the workspace plan or .", "requestToJoin": "requested to join", "asMember": "as a member" }, "upgradePlanModal": { "title": "Upgrade to Pro", "message": "{name} has reached the free member limit. Upgrade to the Pro Plan to invite more members.", "upgradeSteps": "How to upgrade your plan on AppFlowy:", "step1": "1. Go to Settings", "step2": "2. Click on 'Plan'", "step3": "3. Select 'Change Plan'", "appNote": "Note: ", "actionButton": "Upgrade", "downloadLink": "Download App", "laterButton": "Later", "refreshNote": "After successful upgrade, click to activate your new features.", "refresh": "here" }, "breadcrumbs": { "label": "Breadcrumbs" }, "time": { "justNow": "Just now", "seconds": { "one": "1 second", "other": "{count} seconds" }, "minutes": { "one": "1 minute", "other": "{count} minutes" }, "hours": { "one": "1 hour", "other": "{count} hours" }, "days": { "one": "1 day", "other": "{count} days" }, "weeks": { "one": "1 week", "other": "{count} weeks" }, "months": { "one": "1 month", "other": "{count} months" }, "years": { "one": "1 year", "other": "{count} years" }, "ago": "ago", "yesterday": "Yesterday", "today": "Today" }, "members": { "zero": "No members", "one": "1 member", "many": "{count} members", "other": "{count} members" }, "tabMenu": { "close": "Close", "closeDisabledHint": "Cannot close a pinned tab, please unpin first", "closeOthers": "Close other tabs", "closeOthersHint": "This will close all unpinned tabs except this one", "closeOthersDisabledHint": "All tabs are pinned, cannot find any tabs to close", "favorite": "Favourite", "unfavorite": "Unfavourite", "favoriteDisabledHint": "Cannot favourite this view", "pinTab": "Pin", "unpinTab": "Unpin" }, "openFileMessage": { "success": "File opened successfully", "fileNotFound": "File not found", "noAppToOpenFile": "No app to open this file", "permissionDenied": "No permission to open this file", "unknownError": "File open failed" }, "inviteMember": { "requestInviteMembers": "Invite to your workspace", "inviteFailedMemberLimit": "Member limit has been reached, please ", "upgrade": "upgrade", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "Send invites", "inviteAlready": "You've already invited this email: {email}", "inviteSuccess": "Invitation sent successfully", "description": "Input emails below with commas between them. Charges are based on member count.", "emails": "Email" }, "quickNote": { "label": "Quick Note", "quickNotes": "Quick Notes", "search": "Search Quick Notes", "collapseFullView": "Collapse full view", "expandFullView": "Expand full view", "createFailed": "Failed to create Quick Note", "quickNotesEmpty": "No Quick Notes", "emptyNote": "Empty note", "deleteNotePrompt": "The selected note will be deleted permanently. Are you sure you want to delete it?", "addNote": "New Note", "noAdditionalText": "No additional text" }, "subscribe": { "upgradePlanTitle": "Compare & select plan", "yearly": "Yearly", "save": "Save {discount}%", "monthly": "Monthly", "priceIn": "Price in ", "free": "Free", "pro": "Pro", "freeDescription": "For individuals up to 2 members to organise everything", "proDescription": "For small teams to manage projects and team knowledge", "proDuration": { "monthly": "per member per month\nbilled monthly", "yearly": "per member per month\nbilled annually" }, "cancel": "Downgrade", "changePlan": "Upgrade to Pro Plan", "everythingInFree": "Everything in Free +", "currentPlan": "Current", "freeDuration": "forever", "freePoints": { "first": "1 collaborative workspace up to 2 members", "second": "Unlimited pages & blocks", "three": "5 GB storage", "four": "Intelligent search", "five": "20 AI responses", "six": "Mobile app", "seven": "Real-time collaboration" }, "proPoints": { "first": "Unlimited storage", "second": "Up to 10 workspace members", "three": "Unlimited AI responses", "four": "Unlimited file uploads", "five": "Custom namespace" }, "cancelPlan": { "title": "Sorry to see you go", "success": "Your subscription has been canceled successfully", "description": "We're sorry to see you go. We'd love to hear your feedback to help us improve AppFlowy. Please take a moment to answer a few questions.", "commonOther": "Other", "otherHint": "Write your answer here", "questionOne": { "question": "What prompted you to cancel your AppFlowy Pro subscription?", "answerOne": "Cost too high", "answerTwo": "Features did not meet expectations", "answerThree": "Found a better alternative", "answerFour": "Did not use it enough to justify the expense", "answerFive": "Service issue or technical difficulties" }, "questionTwo": { "question": "How likely are you to consider re-subscribing to AppFlowy Pro in the future?", "answerOne": "Very likely", "answerTwo": "Somewhat likely", "answerThree": "Not sure", "answerFour": "Unlikely", "answerFive": "Very unlikely" }, "questionThree": { "question": "Which Pro feature did you value the most during your subscription?", "answerOne": "Multi-user collaboration", "answerTwo": "Longer time version history", "answerThree": "Unlimited AI responses", "answerFour": "Access to local AI models" }, "questionFour": { "question": "How would you describe your overall experience with AppFlowy?", "answerOne": "Great", "answerTwo": "Good", "answerThree": "Average", "answerFour": "Below average", "answerFive": "Unsatisfied" } } }, "ai": { "contentPolicyViolation": "Image generation failed due to sensitive content. Please rephrase your input and try again", "textLimitReachedDescription": "Your workspace has run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", "imageLimitReachedDescription": "You've used up your free AI image quota. Please upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", "limitReachedAction": { "textDescription": "Your workspace has run out of free AI responses. To get more responses, please", "imageDescription": "You've used up your free AI image quota. Please", "upgrade": "upgrade", "toThe": "to the", "proPlan": "Pro Plan", "orPurchaseAn": "or purchase an", "aiAddon": "AI add-on" }, "editing": "Editing", "analyzing": "Analysing", "continueWritingEmptyDocumentTitle": "Continue writing error", "continueWritingEmptyDocumentDescription": "We are having trouble expanding on the content in your document. Write a small intro and we can take it from there!", "more": "More" }, "autoUpdate": { "criticalUpdateTitle": "Update required to continue", "criticalUpdateDescription": "We've made improvements to enhance your experience! Please update from {currentVersion} to {newVersion} to keep using the app.", "criticalUpdateButton": "Update", "bannerUpdateTitle": "New Version Available!", "bannerUpdateDescription": "Get the latest features and fixes. Click \"Update\" to install now", "bannerUpdateButton": "Update", "settingsUpdateTitle": "New Version ({newVersion}) Available!", "settingsUpdateDescription": "Current version: {currentVersion} (Official build) → {newVersion}", "settingsUpdateButton": "Update", "settingsUpdateWhatsNew": "What's new" }, "lockPage": { "lockPage": "Locked", "reLockPage": "Re-lock", "lockTooltip": "Page locked to prevent accidental editing. Click to unlock.", "pageLockedToast": "Page locked. Editing is disabled until someone unlocks it.", "lockedOperationTooltip": "Page locked to prevent accidental editing." }, "suggestion": { "accept": "Accept", "keep": "Keep", "discard": "Discard", "close": "Close", "tryAgain": "Try again", "rewrite": "Rewrite", "insertBelow": "Insert below" } } ================================================ FILE: frontend/resources/translations/en-US.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Me", "welcomeText": "Welcome to @:appName", "welcomeTo": "Welcome to", "githubStarText": "Star on GitHub", "subscribeNewsletterText": "Subscribe to Newsletter", "letsGoButtonText": "Quick Start", "title": "Title", "youCanAlso": "You can also", "and": "and", "failedToOpenUrl": "Failed to open url: {}", "blockActions": { "addBelowTooltip": "Click to add below", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "to add above", "dragTooltip": "Drag to move", "openMenuTooltip": "Click to open menu" }, "signUp": { "buttonText": "Sign Up", "title": "Sign Up to @:appName", "getStartedText": "Get Started", "emptyPasswordError": "Password can't be empty", "repeatPasswordEmptyError": "Repeat password can't be empty", "unmatchedPasswordError": "Repeat password is not the same as password", "alreadyHaveAnAccount": "Already have an account?", "emailHint": "Email", "passwordHint": "Password", "repeatPasswordHint": "Repeat password", "signUpWith": "Sign up with:" }, "signIn": { "loginTitle": "Login to @:appName", "loginButtonText": "Login", "loginStartWithAnonymous": "Continue with an anonymous session", "continueAnonymousUser": "Continue with an anonymous session", "continueWithLocalModel": "Continue with local model", "switchToAppFlowyCloud": "AppFlowy Cloud", "anonymousMode": "Anonymous mode", "buttonText": "Sign In", "signingInText": "Signing in...", "forgotPassword": "Forgot Password?", "emailHint": "Email", "passwordHint": "Password", "dontHaveAnAccount": "Don't have an account?", "createAccount": "Create account", "repeatPasswordEmptyError": "Repeat password can't be empty", "unmatchedPasswordError": "Repeat password is not the same as password", "passwordMustContain": "Password must contain at least one letter, one number, and one symbol.", "syncPromptMessage": "Syncing the data might take a while. Please don't close this page", "or": "or", "signInWithGoogle": "Continue with Google", "signInWithGithub": "Continue with GitHub", "signInWithDiscord": "Continue with Discord", "signInWithApple": "Continue with Apple", "continueAnotherWay": "Continue another way", "signUpWithGoogle": "Sign up with Google", "signUpWithGithub": "Sign up with Github", "signUpWithDiscord": "Sign up with Discord", "signInWith": "Continue with:", "signInWithEmail": "Continue with Email", "signInWithMagicLink": "Continue", "signUpWithMagicLink": "Sign up with Magic Link", "pleaseInputYourEmail": "Please enter your email address", "settings": "Settings", "magicLinkSent": "Magic Link sent!", "invalidEmail": "Please enter a valid email address", "alreadyHaveAnAccount": "Already have an account?", "logIn": "Log in", "generalError": "Something went wrong. Please try again later", "limitRateError": "For security reasons, you can only request a magic link every 60 seconds", "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes.", "tokenHasExpiredOrInvalid": "The code has expired or is invalid. Please try again.", "signingIn": "Signing in...", "checkYourEmail": "Check your email", "temporaryVerificationLinkSent": "A temporary verification link has been sent.\nPlease check your inbox at", "temporaryVerificationCodeSent": "A temporary verification code has been sent.\nPlease check your inbox at", "continueToSignIn": "Continue to sign in", "continueWithLoginCode": "Continue with login code", "backToLogin": "Back to login", "enterCode": "Enter code", "enterCodeManually": "Enter code manually", "continueWithEmail": "Continue with email", "enterPassword": "Enter password", "loginAs": "Login as", "invalidVerificationCode": "Please enter a valid verification code", "tooFrequentVerificationCodeRequest": "You have made too many requests. Please try again later.", "invalidLoginCredentials": "Your password is incorrect, please try again", "resetPassword": "Reset password", "resetPasswordDescription": "Enter your email to reset your password", "continueToResetPassword": "Continue to reset password", "resetPasswordSuccess": "Password reset successfully", "resetPasswordFailed": "Failed to reset password", "resetPasswordLinkSent": "A password reset link has been sent to your email. Please check your inbox at", "resetPasswordLinkExpired": "The password reset link has expired. Please request a new link.", "resetPasswordLinkInvalid": "The password reset link is invalid. Please request a new link.", "enterNewPasswordFor": "Enter new password for ", "newPassword": "New password", "enterNewPassword": "Enter new password", "confirmPassword": "Confirm password", "confirmNewPassword": "Enter new password", "newPasswordCannotBeEmpty": "New password cannot be empty", "confirmPasswordCannotBeEmpty": "Confirm password cannot be empty", "passwordsDoNotMatch": "Passwords do not match", "verifying": "Verifying...", "continueWithPassword": "Continue with password", "youAreInLocalMode": "You're in local mode.", "loginToAppFlowyCloud": "Login to AppFlowy Cloud" }, "workspace": { "chooseWorkspace": "Choose your workspace", "defaultName": "My Workspace", "create": "Create workspace", "new": "New workspace", "importFromNotion": "Import from Notion", "learnMore": "Learn more", "reset": "Reset workspace", "renameWorkspace": "Rename workspace", "workspaceNameCannotBeEmpty": "Workspace name cannot be empty", "resetWorkspacePrompt": "Resetting the workspace will delete all pages and data within it. Are you sure you want to reset the workspace? Alternatively, you can contact the support team to restore the workspace", "hint": "workspace", "notFoundError": "Workspace not found", "failedToLoad": "Something went wrong! Failed to load the workspace. Try to close any open instance of @:appName and try again.", "errorActions": { "reportIssue": "Report an issue", "reportIssueOnGithub": "Report an issue on Github", "exportLogFiles": "Export log files", "reachOut": "Reach out on Discord" }, "menuTitle": "Workspaces", "deleteWorkspaceHintText": "Are you sure you want to delete the workspace? This action cannot be undone, and any pages you have published will be unpublished.", "createSuccess": "Workspace created successfully", "createFailed": "Failed to create workspace", "createLimitExceeded": "You've reached the maximum workspace limit allowed for your account. If you need additional workspaces to continue your work, please request on Github", "deleteSuccess": "Workspace deleted successfully", "deleteFailed": "Failed to delete workspace", "openSuccess": "Opened workspace successfully", "openFailed": "Failed to open workspace", "renameSuccess": "Workspace renamed successfully", "renameFailed": "Failed to rename workspace", "updateIconSuccess": "Updated workspace icon successfully", "updateIconFailed": "Updated workspace icon failed", "cannotDeleteTheOnlyWorkspace": "Cannot delete the only workspace", "fetchWorkspacesFailed": "Failed to fetch workspaces", "leaveCurrentWorkspace": "Leave workspace", "leaveCurrentWorkspacePrompt": "Are you sure you want to leave the current workspace?" }, "shareAction": { "buttonText": "Share", "workInProgress": "Coming soon", "markdown": "Markdown", "html": "HTML", "clipboard": "Copy to clipboard", "csv": "CSV", "copyLink": "Copy link", "publishToTheWeb": "Publish to Web", "publishToTheWebHint": "Create a website with AppFlowy", "publish": "Publish", "unPublish": "Unpublish", "visitSite": "Visit site", "exportAsTab": "Export as", "publishTab": "Publish", "shareTab": "Share", "publishOnAppFlowy": "Publish on AppFlowy", "shareTabTitle": "Invite to collaborate", "shareTabDescription": "For easy collaboration with anyone", "copyLinkSuccess": "Copied link to clipboard", "copyShareLink": "Copy share link", "copyLinkFailed": "Failed to copy link to clipboard", "copyLinkToBlockSuccess": "Copied block link to clipboard", "copyLinkToBlockFailed": "Failed to copy block link to clipboard", "manageAllSites": "Manage all sites", "updatePathName": "Update path name" }, "moreAction": { "small": "small", "medium": "medium", "large": "large", "fontSize": "Font size", "import": "Import", "moreOptions": "More options", "wordCount": "Word count: {}", "charCount": "Character count: {}", "createdAt": "Created: {}", "deleteView": "Delete", "duplicateView": "Duplicate", "wordCountLabel": "Word count: ", "charCountLabel": "Character count: ", "createdAtLabel": "Created: ", "syncedAtLabel": "Synced: ", "saveAsNewPage": "Add messages to page", "saveAsNewPageDisabled": "No messages available" }, "importPanel": { "textAndMarkdown": "Text & Markdown", "documentFromV010": "Document from v0.1.0", "databaseFromV010": "Database from v0.1.0", "notionZip": "Notion Exported Zip File", "csv": "CSV", "database": "Database" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "Drag & drop a file, click to ", "placeholderUpload": "Upload", "placeholderRight": ", or paste an image link.", "dropToUpload": "Drop a file to upload", "change": "Change" } }, "disclosureAction": { "rename": "Rename", "delete": "Delete", "duplicate": "Duplicate", "unfavorite": "Remove from Favorites", "favorite": "Add to Favorites", "openNewTab": "Open in a new tab", "moveTo": "Move to", "addToFavorites": "Add to Favorites", "copyLink": "Copy link", "changeIcon": "Change icon", "collapseAllPages": "Collapse all subpages", "movePageTo": "Move page to", "move": "Move", "lockPage": "Lock page" }, "blankPageTitle": "Blank page", "newPageText": "New page", "newDocumentText": "New document", "newGridText": "New grid", "newCalendarText": "New calendar", "newBoardText": "New board", "chat": { "newChat": "AI Chat", "inputMessageHint": "Ask @:appName AI", "inputLocalAIMessageHint": "Ask @:appName Local AI", "unsupportedCloudPrompt": "This feature is only available when using @:appName Cloud", "relatedQuestion": "Suggested", "serverUnavailable": "Connection lost. Please check your internet and", "aiServerUnavailable": "The AI service is temporarily unavailable. Please try again later.", "retry": "Retry", "clickToRetry": "Click to retry", "regenerateAnswer": "Regenerate", "question1": "How to use Kanban to manage tasks", "question2": "Explain the GTD method", "question3": "Why use Rust", "question4": "Recipe with what's in my kitchen", "question5": "Create an illustration for my page", "question6": "Draw up a to-do list for my upcoming week", "aiMistakePrompt": "AI can make mistakes. Check important info.", "chatWithFilePrompt": "Do you want to chat with the file?", "indexFileSuccess": "Indexing file successfully", "inputActionNoPages": "No page results", "referenceSource": { "zero": "0 sources found", "one": "{count} source found", "other": "{count} sources found" }, "clickToMention": "Mention a page", "uploadFile": "Attach PDFs, text or markdown files", "questionDetail": "Hi {}! How can I help you today?", "indexingFile": "Indexing {}", "generatingResponse": "Generating response", "selectSources": "Select Sources", "currentPage": "Current page", "sourcesLimitReached": "You can only select up to 3 top-level documents and its children", "sourceUnsupported": "We don't support chatting with databases at this time", "regenerate": "Try again", "addToPageButton": "Add message to page", "addToPageTitle": "Add message to...", "addToNewPage": "Create new page", "addToNewPageName": "Messages extracted from \"{}\"", "addToNewPageSuccessToast": "Message added to", "openPagePreviewFailedToast": "Failed to open page", "changeFormat": { "actionButton": "Change format", "confirmButton": "Regenerate with this format", "textOnly": "Text", "imageOnly": "Image only", "textAndImage": "Text and Image", "text": "Paragraph", "bullet": "Bullet list", "number": "Numbered list", "table": "Table", "blankDescription": "Format response", "defaultDescription": "Auto response format", "textWithImageDescription": "@:chat.changeFormat.text with image", "numberWithImageDescription": "@:chat.changeFormat.number with image", "bulletWithImageDescription": "@:chat.changeFormat.bullet with image", "tableWithImageDescription": "@:chat.changeFormat.table with image" }, "switchModel": { "label": "Switch model", "localModel": "Local Model", "cloudModel": "Cloud Model", "autoModel": "Auto" }, "selectBanner": { "saveButton": "Add to …", "selectMessages": "Select messages", "nSelected": "{} selected", "allSelected": "All selected" }, "stopTooltip": "Stop generating" }, "trash": { "text": "Trash", "restoreAll": "Restore All", "restore": "Restore", "deleteAll": "Delete All", "pageHeader": { "fileName": "File name", "lastModified": "Last Modified", "created": "Created" }, "confirmDeleteAll": { "title": "All pages in trash", "caption": "Are you sure you want to delete everything in Trash? This action cannot be undone." }, "confirmRestoreAll": { "title": "Restore all pages in trash", "caption": "This action cannot be undone." }, "restorePage": { "title": "Restore: {}", "caption": "Are you sure you want to restore this page?" }, "mobile": { "actions": "Trash Actions", "empty": "No pages or spaces in Trash", "emptyDescription": "Move things you don't need to the Trash.", "isDeleted": "is deleted", "isRestored": "is restored" }, "confirmDeleteTitle": "Are you sure you want to delete this page permanently?" }, "deletePagePrompt": { "text": "This page is in Trash", "restore": "Restore page", "deletePermanent": "Delete permanently", "deletePermanentDescription": "Are you sure you want to delete this page permanently? This is irreversible." }, "dialogCreatePageNameHint": "Page name", "questionBubble": { "shortcuts": "Shortcuts", "whatsNew": "What's new?", "helpAndDocumentation": "Help & documentation", "getSupport": "Get support", "markdown": "Markdown", "debug": { "name": "Debug Info", "success": "Copied debug info to clipboard!", "fail": "Unable to copy debug info to clipboard" }, "feedback": "Feedback" }, "menuAppHeader": { "moreButtonToolTip": "Remove, rename, and more...", "addPageTooltip": "Quickly add a page inside", "defaultNewPageName": "Untitled", "renameDialog": "Rename", "pageNameSuffix": "Copy" }, "noPagesInside": "No pages inside", "toolbar": { "undo": "Undo", "redo": "Redo", "bold": "Bold", "italic": "Italic", "underline": "Underline", "strike": "Strikethrough", "numList": "Numbered list", "bulletList": "Bulleted list", "checkList": "Check List", "inlineCode": "Inline Code", "quote": "Quote Block", "header": "Header", "highlight": "Highlight", "color": "Color", "addLink": "Add Link" }, "tooltip": { "lightMode": "Switch to Light mode", "darkMode": "Switch to Dark mode", "openAsPage": "Open as a Page", "addNewRow": "Add a new row", "openMenu": "Click to open menu", "dragRow": "Drag to reorder the row", "viewDataBase": "View database", "referencePage": "This {name} is referenced", "addBlockBelow": "Add a block below", "aiGenerate": "Generate" }, "sideBar": { "closeSidebar": "Close sidebar", "openSidebar": "Open sidebar", "expandSidebar": "Expand as full page", "personal": "Personal", "private": "Private", "workspace": "Workspace", "favorites": "Favorites", "clickToHidePrivate": "Click to hide private space\nPages you created here are only visible to you", "clickToHideWorkspace": "Click to hide workspace\nPages you created here are visible to every member", "clickToHidePersonal": "Click to hide personal space", "clickToHideFavorites": "Click to hide favorite space", "addAPage": "Add a new page", "addAPageToPrivate": "Add a page to private space", "addAPageToWorkspace": "Add a page to workspace", "recent": "Recent", "today": "Today", "thisWeek": "This week", "others": "Earlier favorites", "earlier": "Earlier", "justNow": "just now", "minutesAgo": "{count} minutes ago", "lastViewed": "Last viewed", "favoriteAt": "Favorited", "emptyRecent": "No Recent Pages", "emptyRecentDescription": "As you view pages, they will appear here for easy retrieval.", "emptyFavorite": "No Favorite Pages", "emptyFavoriteDescription": "Mark pages as favorites—they'll be listed here for quick access!", "removePageFromRecent": "Remove this page from the Recent?", "removeSuccess": "Removed successfully", "favoriteSpace": "Favorites", "RecentSpace": "Recent", "Spaces": "Spaces", "upgradeToPro": "Upgrade to Pro", "upgradeToAIMax": "Unlock unlimited AI", "storageLimitDialogTitle": "You have run out of free storage. Upgrade to unlock unlimited storage", "storageLimitDialogTitleIOS": "You have run out of free storage.", "aiResponseLimitTitle": "You have run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", "aiResponseLimitDialogTitle": "AI Responses limit reached", "aiResponseLimit": "You have run out of free AI responses.\n\nGo to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses", "askOwnerToUpgradeToPro": "Your workspace is running out of free storage. Please ask your workspace owner to upgrade to the Pro Plan", "askOwnerToUpgradeToProIOS": "Your workspace is running out of free storage.", "askOwnerToUpgradeToAIMax": "Your workspace has ran out of free AI responses. Please ask your workspace owner to upgrade the plan or purchase AI add-ons", "askOwnerToUpgradeToAIMaxIOS": "Your workspace is running out of free AI responses.", "purchaseAIMax": "Your workspace has ran out of AI Image responses. Please ask your workspace owner to purchase AI Max", "aiImageResponseLimit": "You have run out of AI image responses.\n\nGo to Settings -> Plan -> Click AI Max to get more AI image responses", "purchaseStorageSpace": "Purchase Storage Space", "singleFileProPlanLimitationDescription": "You have exceeded the maximum file upload size allowed in the free plan. Please upgrade to the Pro Plan to upload larger files", "purchaseAIResponse": "Purchase ", "askOwnerToUpgradeToLocalAI": "Ask workspace owner to enable AI On-device", "upgradeToAILocal": "Run local models on your device for ultimate privacy", "upgradeToAILocalDesc": "Chat with PDFs, improve your writing, and auto-fill tables using local AI" }, "notifications": { "export": { "markdown": "Exported Note To Markdown", "path": "Documents/flowy" } }, "contactsPage": { "title": "Contacts", "whatsHappening": "What's happening this week?", "addContact": "Add Contact", "editContact": "Edit Contact" }, "button": { "ok": "Ok", "confirm": "Confirm", "done": "Done", "cancel": "Cancel", "signIn": "Sign In", "signOut": "Sign Out", "complete": "Complete", "change": "Change", "save": "Save", "generate": "Generate", "esc": "ESC", "keep": "Keep", "tryAgain": "Try again", "discard": "Discard", "replace": "Replace", "insertBelow": "Insert below", "insertAbove": "Insert above", "upload": "Upload", "edit": "Edit", "delete": "Delete", "copy": "Copy", "duplicate": "Duplicate", "putback": "Put Back", "update": "Update", "share": "Share", "removeFromFavorites": "Remove from Favorites", "removeFromRecent": "Remove from Recent", "addToFavorites": "Add to Favorites", "favoriteSuccessfully": "Favorited success", "unfavoriteSuccessfully": "Unfavorited success", "duplicateSuccessfully": "Duplicated successfully", "rename": "Rename", "helpCenter": "Help Center", "add": "Add", "yes": "Yes", "no": "No", "clear": "Clear", "remove": "Remove", "dontRemove": "Don't remove", "copyLink": "Copy Link", "align": "Align", "login": "Login", "logout": "Log out", "deleteAccount": "Delete account", "back": "Back", "signInGoogle": "Continue with Google", "signInGithub": "Continue with GitHub", "signInDiscord": "Continue with Discord", "more": "More", "create": "Create", "close": "Close", "next": "Next", "previous": "Previous", "submit": "Submit", "download": "Download", "backToHome": "Back to Home", "viewing": "Viewing", "editing": "Editing", "gotIt": "Got it", "retry": "Retry", "uploadFailed": "Upload failed.", "copyLinkOriginal": "Copy link to original" }, "label": { "welcome": "Welcome!", "firstName": "First Name", "middleName": "Middle Name", "lastName": "Last Name", "stepX": "Step {X}" }, "oAuth": { "err": { "failedTitle": "Unable to connect to your account.", "failedMsg": "Please make sure you've completed the sign-in process in your browser." }, "google": { "title": "GOOGLE SIGN-IN", "instruction1": "In order to import your Google Contacts, you'll need to authorize this application using your web browser.", "instruction2": "Copy this code to your clipboard by clicking the icon or selecting the text:", "instruction3": "Navigate to the following link in your web browser, and enter the above code:", "instruction4": "Press the button below when you've completed signup:" } }, "settings": { "title": "Settings", "popupMenuItem": { "settings": "Settings", "members": "Members", "trash": "Trash", "helpAndDocumentation": "Help & documentation", "getSupport": "Get Support" }, "sites": { "title": "Sites", "namespaceTitle": "Namespace", "namespaceDescription": "Manage your namespace and homepage", "namespaceHeader": "Namespace", "homepageHeader": "Homepage", "updateNamespace": "Update namespace", "removeHomepage": "Remove homepage", "selectHomePage": "Select a page", "clearHomePage": "Clear the home page for this namespace", "customUrl": "Custom URL", "homePage": { "upgradeToPro": "Upgrade to Pro Plan to set a homepage" }, "namespace": { "description": "This change will apply to all the published pages live on this namespace", "tooltip": "We reserve the rights to remove any inappropriate namespaces", "updateExistingNamespace": "Update existing namespace", "upgradeToPro": "Upgrade to Pro Plan to claim a custom namespace", "redirectToPayment": "Redirecting to payment page...", "onlyWorkspaceOwnerCanSetHomePage": "Only the workspace owner can set a homepage", "pleaseAskOwnerToSetHomePage": "Please ask the workspace owner to upgrade to Pro Plan" }, "publishedPage": { "title": "All published pages", "description": "Manage your published pages", "page": "Page", "pathName": "Path name", "date": "Published date", "emptyHinText": "You have no published pages in this workspace", "noPublishedPages": "No published pages", "settings": "Publish settings", "clickToOpenPageInApp": "Open page in app", "clickToOpenPageInBrowser": "Open page in browser" }, "error": { "failedToGeneratePaymentLink": "Failed to generate payment link for Pro Plan", "failedToUpdateNamespace": "Failed to update namespace", "proPlanLimitation": "You need to upgrade to Pro Plan to update the namespace", "namespaceAlreadyInUse": "The namespace is already taken, please try another one", "invalidNamespace": "Invalid namespace, please try another one", "namespaceLengthAtLeast2Characters": "The namespace must be at least 2 characters long", "onlyWorkspaceOwnerCanUpdateNamespace": "Only workspace owner can update the namespace", "onlyWorkspaceOwnerCanRemoveHomepage": "Only workspace owner can remove the homepage", "setHomepageFailed": "Failed to set homepage", "namespaceTooLong": "The namespace is too long, please try another one", "namespaceTooShort": "The namespace is too short, please try another one", "namespaceIsReserved": "The namespace is reserved, please try another one", "updatePathNameFailed": "Failed to update path name", "removeHomePageFailed": "Failed to remove homepage", "publishNameContainsInvalidCharacters": "The path name contains invalid character(s), please try another one", "publishNameTooShort": "The path name is too short, please try another one", "publishNameTooLong": "The path name is too long, please try another one", "publishNameAlreadyInUse": "The path name is already in use, please try another one", "namespaceContainsInvalidCharacters": "The namespace contains invalid character(s), please try another one", "publishPermissionDenied": "Only the workspace owner or page publisher can manage the publish settings", "publishNameCannotBeEmpty": "The path name cannot be empty, please try another one" }, "success": { "namespaceUpdated": "Updated namespace successfully", "setHomepageSuccess": "Set homepage successfully", "updatePathNameSuccess": "Updated path name successfully", "removeHomePageSuccess": "Remove homepage successfully" } }, "accountPage": { "menuLabel": "Account & App", "title": "My account", "general": { "title": "Account name & profile image", "changeProfilePicture": "Change profile picture" }, "email": { "title": "Email", "actions": { "change": "Change email" } }, "login": { "title": "Account login", "loginLabel": "Log in", "logoutLabel": "Log out" }, "isUpToDate": "@:appName is up to date!", "officialVersion": "Version {version} (官方構建)" }, "workspacePage": { "menuLabel": "Workspace", "title": "Workspace", "description": "Customize your workspace appearance, theme, font, text layout, date-/time-format, and language.", "workspaceName": { "title": "Workspace name" }, "workspaceIcon": { "title": "Workspace icon", "description": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications." }, "appearance": { "title": "Appearance", "description": "Customize your workspace appearance, theme, font, text layout, date, time, and language.", "options": { "system": "Auto", "light": "Light", "dark": "Dark" } }, "resetCursorColor": { "title": "Reset document cursor color", "description": "Are you sure you want to reset the cursor color?" }, "resetSelectionColor": { "title": "Reset document selection color", "description": "Are you sure you want to reset the selection color?" }, "resetWidth": { "resetSuccess": "Reset document width successfully" }, "theme": { "title": "Theme", "description": "Select a preset theme, or upload your own custom theme.", "uploadCustomThemeTooltip": "Upload a custom theme", "failedToLoadThemes": "Failed to load themes, please check your permission settings in System Settings > Privacy and Security > Files and Folders > @:appName" }, "workspaceFont": { "title": "Workspace font", "noFontHint": "No font found, try another term." }, "textDirection": { "title": "Text direction", "leftToRight": "Left to right", "rightToLeft": "Right to left", "auto": "Auto", "enableRTLItems": "Enable RTL toolbar items" }, "layoutDirection": { "title": "Layout direction", "leftToRight": "Left to right", "rightToLeft": "Right to left" }, "dateTime": { "title": "Date & time", "example": "{} at {} ({})", "24HourTime": "24-hour time", "dateFormat": { "label": "Date format", "local": "Local", "us": "US", "iso": "ISO", "friendly": "Friendly", "dmy": "D/M/Y" } }, "language": { "title": "Language" }, "deleteWorkspacePrompt": { "title": "Delete workspace", "content": "Are you sure you want to delete this workspace? This action cannot be undone, and any pages you have published will be unpublished." }, "leaveWorkspacePrompt": { "title": "Leave workspace", "content": "Are you sure you want to leave this workspace? You will lose access to all pages and data within it.", "success": "You have left the workspace successfully.", "fail": "Failed to leave the workspace." }, "manageWorkspace": { "title": "Manage workspace", "leaveWorkspace": "Leave workspace", "deleteWorkspace": "Delete workspace" } }, "manageDataPage": { "menuLabel": "Manage data", "title": "Manage data", "description": "Manage data local storage or Import your existing data into @:appName.", "dataStorage": { "title": "File storage location", "tooltip": "The location where your files are stored", "actions": { "change": "Change path", "open": "Open folder", "openTooltip": "Open current data folder location", "copy": "Copy path", "copiedHint": "Path copied!", "resetTooltip": "Reset to default location" }, "resetDialog": { "title": "Are you sure?", "description": "Resetting the path to the default data location will not delete your data. If you want to re-import your current data, you should copy the path of your current location first." } }, "importData": { "title": "Import data", "tooltip": "Import data from @:appName backups/data folders", "description": "Copy data from an external @:appName data folder", "action": "Browse file" }, "encryption": { "title": "Encryption", "tooltip": "Manage how your data is stored and encrypted", "descriptionNoEncryption": "Turning on encryption will encrypt all data. This can not be undone.", "descriptionEncrypted": "Your data is encrypted.", "action": "Encrypt data", "dialog": { "title": "Encrypt all your data?", "description": "Encrypting all your data will keep your data safe and secure. This action can NOT be undone. Are you sure you want to continue?" } }, "cache": { "title": "Clear cache", "description": "Help resolve issues like image not loading, missing pages in a space, and fonts not loading. This will not affect your data.", "dialog": { "title": "Clear cache", "description": "Help resolve issues like image not loading, missing pages in a space, and fonts not loading. This will not affect your data.", "successHint": "Cache cleared!" } }, "data": { "fixYourData": "Fix your data", "fixButton": "Fix", "fixYourDataDescription": "If you're experiencing issues with your data, you can try to fix it here." } }, "shortcutsPage": { "menuLabel": "Shortcuts", "title": "Shortcuts", "editBindingHint": "Input new binding", "searchHint": "Search", "actions": { "resetDefault": "Reset default" }, "errorPage": { "message": "Failed to load shortcuts: {}", "howToFix": "Please try again, if the issue persists please reach out on GitHub." }, "resetDialog": { "title": "Reset shortcuts", "description": "This will reset all of your keybindings to the default, you cannot undo this later, are you sure you want to proceed?", "buttonLabel": "Reset" }, "conflictDialog": { "title": "{} is currently in use", "descriptionPrefix": "This keybinding is currently being used by ", "descriptionSuffix": ". If you replace this keybinding, it will be removed from {}.", "confirmLabel": "Continue" }, "editTooltip": "Press to start editing the keybinding", "keybindings": { "toggleToDoList": "Toggle to do list", "insertNewParagraphInCodeblock": "Insert new paragraph", "pasteInCodeblock": "Paste in codeblock", "selectAllCodeblock": "Select all", "indentLineCodeblock": "Insert two spaces at line start", "outdentLineCodeblock": "Delete two spaces at line start", "twoSpacesCursorCodeblock": "Insert two spaces at cursor", "copy": "Copy selection", "paste": "Paste in content", "cut": "Cut selection", "alignLeft": "Align text left", "alignCenter": "Align text center", "alignRight": "Align text right", "insertInlineMathEquation": "Insert inline math eqaution", "undo": "Undo", "redo": "Redo", "convertToParagraph": "Convert block to paragraph", "backspace": "Delete", "deleteLeftWord": "Delete left word", "deleteLeftSentence": "Delete left sentence", "delete": "Delete right character", "deleteMacOS": "Delete left character", "deleteRightWord": "Delete right word", "moveCursorLeft": "Move cursor left", "moveCursorBeginning": "Move cursor to the beginning", "moveCursorLeftWord": "Move cursor left one word", "moveCursorLeftSelect": "Select and move cursor left", "moveCursorBeginSelect": "Select and move cursor to the beginning", "moveCursorLeftWordSelect": "Select and move cursor left one word", "moveCursorRight": "Move cursor right", "moveCursorEnd": "Move cursor to the end", "moveCursorRightWord": "Move cursor right one word", "moveCursorRightSelect": "Select and move cursor right one", "moveCursorEndSelect": "Select and move cursor to the end", "moveCursorRightWordSelect": "Select and move cursor to the right one word", "moveCursorUp": "Move cursor up", "moveCursorTopSelect": "Select and move cursor to the top", "moveCursorTop": "Move cursor to the top", "moveCursorUpSelect": "Select and move cursor up", "moveCursorBottomSelect": "Select and move cursor to the bottom", "moveCursorBottom": "Move cursor to the bottom", "moveCursorDown": "Move cursor down", "moveCursorDownSelect": "Select and move cursor down", "home": "Scroll to the top", "end": "Scroll to the bottom", "toggleBold": "Toggle bold", "toggleItalic": "Toggle italic", "toggleUnderline": "Toggle underline", "toggleStrikethrough": "Toggle strikethrough", "toggleCode": "Toggle in-line code", "toggleHighlight": "Toggle highlight", "showLinkMenu": "Show link menu", "openInlineLink": "Open in-line link", "openLinks": "Open all selected links", "indent": "Indent", "outdent": "Outdent", "exit": "Exit editing", "pageUp": "Scroll one page up", "pageDown": "Scroll one page down", "selectAll": "Select all", "pasteWithoutFormatting": "Paste content without formatting", "showEmojiPicker": "Show emoji picker", "enterInTableCell": "Add linebreak in table", "leftInTableCell": "Move left one cell in table", "rightInTableCell": "Move right one cell in table", "upInTableCell": "Move up one cell in table", "downInTableCell": "Move down one cell in table", "tabInTableCell": "Go to next available cell in table", "shiftTabInTableCell": "Go to previously available cell in table", "backSpaceInTableCell": "Stop at the beginning of the cell" }, "commands": { "codeBlockNewParagraph": "Insert a new paragraph next to the code block", "codeBlockIndentLines": "Insert two spaces at the line start in code block", "codeBlockOutdentLines": "Delete two spaces at the line start in code block", "codeBlockAddTwoSpaces": "Insert two spaces at the cursor position in code block", "codeBlockSelectAll": "Select all content inside a code block", "codeBlockPasteText": "Paste text in codeblock", "textAlignLeft": "Align text to the left", "textAlignCenter": "Align text to the center", "textAlignRight": "Align text to the right" }, "couldNotLoadErrorMsg": "Could not load shortcuts, Try again", "couldNotSaveErrorMsg": "Could not save shortcuts, Try again" }, "aiPage": { "title": "AI Settings", "menuLabel": "AI Settings", "keys": { "enableAISearchTitle": "AI Search", "aiSettingsDescription": "Choose your preferred model to power AppFlowy AI. Now includes GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet, and models available in Ollama", "loginToEnableAIFeature": "AI features are only enabled after logging in with @:appName Cloud. If you don't have an @:appName account, go to 'My Account' to sign up", "llmModel": "Language Model", "globalLLMModel": "Global Language Model", "readOnlyField": "This field is read-only", "llmModelType": "Language Model Type", "downloadLLMPrompt": "Download {}", "downloadAppFlowyOfflineAI": "Downloading AI offline package will enable AI to run on your device. Do you want to continue?", "downloadLLMPromptDetail": "Downloading {} local model will take up to {} of storage. Do you want to continue?", "downloadBigFilePrompt": "It may take around 10 minutes to complete the download", "downloadAIModelButton": "Download", "downloadingModel": "Downloading", "localAILoaded": "Local AI Model successfully added and ready to use", "localAIStart": "Local AI is starting. If it's slow, try toggling it off and on", "localAILoading": "Local AI Chat Model is loading...", "localAIStopped": "Local AI stopped", "localAIRunning": "Local AI is running", "localAINotReadyRetryLater": "Local AI is initializing, please retry later", "localAIDisabled": "You are using local AI, but it is disabled. Please go to settings to enable it or try different model", "localAIInitializing": "Local AI is loading. This may take a few seconds depending on your device", "localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading", "localAIDisabledTextFieldPrompt": "You can not edit while Local AI is disabled", "failToLoadLocalAI": "Failed to start local AI.", "restartLocalAI": "Restart", "disableLocalAITitle": "Disable local AI", "disableLocalAIDescription": "Do you want to disable local AI?", "localAIToggleTitle": "AppFlowy Local AI (LAI)", "localAIToggleSubTitle": "Run the most advanced local AI models within AppFlowy for ultimate privacy and security", "offlineAIInstruction1": "Follow the", "offlineAIInstruction2": "instruction", "offlineAIInstruction3": "to enable offline AI.", "offlineAIDownload1": "If you have not downloaded the AppFlowy AI, please", "offlineAIDownload2": "download", "offlineAIDownload3": "it first", "activeOfflineAI": "Active", "downloadOfflineAI": "Download", "openModelDirectory": "Open folder", "laiNotReady": "The Local AI app was not installed correctly.", "ollamaNotReady": "The Ollama server is not ready.", "pleaseFollowThese": "Please follow these", "instructions": "instructions", "installOllamaLai": "to set up Ollama and AppFlowy Local AI.", "modelsMissing": "Cannot find the required models: ", "downloadModel": "to download them." } }, "planPage": { "menuLabel": "Plan", "title": "Pricing plan", "planUsage": { "title": "Plan usage summary", "storageLabel": "Storage", "storageUsage": "{} of {} GB", "unlimitedStorageLabel": "Unlimited storage", "collaboratorsLabel": "Members", "collaboratorsUsage": "{} of {}", "aiResponseLabel": "AI Responses", "aiResponseUsage": "{} of {}", "unlimitedAILabel": "Unlimited responses", "proBadge": "Pro", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "AI On-device for Mac", "memberProToggle": "More members & unlimited AI & guest access", "aiMaxToggle": "Unlimited AI and access to advanced models", "aiOnDeviceToggle": "Local AI for ultimate privacy", "aiCredit": { "title": "Add @:appName AI Credit", "price": "{}", "priceDescription": "for 1,000 credits", "purchase": "Purchase AI", "info": "Add 1,000 Ai credits per workspace and seamlessly integrate customizable AI into your workflow for smarter, faster results with up to:", "infoItemOne": "10,000 responses per database", "infoItemTwo": "1,000 responses per workspace" }, "currentPlan": { "bannerLabel": "Current plan", "freeTitle": "Free", "proTitle": "Pro", "teamTitle": "Team", "freeInfo": "Perfect for individuals up to 2 members to organize everything", "proInfo": "Perfect for small and medium teams up to 10 members.", "teamInfo": "Perfect for all productive and well-organized teams..", "upgrade": "Change plan", "canceledInfo": "Your plan is cancelled, you will be downgraded to the Free plan on {}." }, "addons": { "title": "Add-ons", "addLabel": "Add", "activeLabel": "Added", "aiMax": { "title": "AI Max", "description": "Unlimited AI responses powered by advanced AI models, and 50 AI images per month", "price": "{}", "priceInfo": "Per user per month billed annually" }, "aiOnDevice": { "title": "AI On-device for Mac", "description": "Run Mistral 7B, LLAMA 3, and more local models on your machine", "price": "{}", "priceInfo": "Per user per month billed annually", "recommend": "Recommend M1 or newer" } }, "deal": { "bannerLabel": "New year deal!", "title": "Grow your team!", "info": "Upgrade and save 10% off Pro and Team plans! Boost your workspace productivity with powerful new features including @:appName AI.", "viewPlans": "View plans" } } }, "billingPage": { "menuLabel": "Billing", "title": "Billing", "plan": { "title": "Plan", "freeLabel": "Free", "proLabel": "Pro", "planButtonLabel": "Change plan", "billingPeriod": "Billing period", "periodButtonLabel": "Edit period" }, "paymentDetails": { "title": "Payment details", "methodLabel": "Payment method", "methodButtonLabel": "Edit method" }, "addons": { "title": "Add-ons", "addLabel": "Add", "removeLabel": "Remove", "renewLabel": "Renew", "aiMax": { "label": "AI Max", "description": "Unlock unlimited AI and advanced models", "activeDescription": "Next invoice due on {}", "canceledDescription": "AI Max will be available until {}" }, "aiOnDevice": { "label": "AI On-device for Mac", "description": "Unlock unlimited AI On-device on your device", "activeDescription": "Next invoice due on {}", "canceledDescription": "AI On-device for Mac will be available until {}" }, "removeDialog": { "title": "Remove {}", "description": "Are you sure you want to remove {plan}? You will lose access to the features and benefits of {plan} immediately." } }, "currentPeriodBadge": "CURRENT", "changePeriod": "Change period", "planPeriod": "{} period", "monthlyInterval": "Monthly", "monthlyPriceInfo": "per seat billed monthly", "annualInterval": "Annually", "annualPriceInfo": "per seat billed annually" }, "comparePlanDialog": { "title": "Compare & select plan", "planFeatures": "Plan\nFeatures", "current": "Current", "actions": { "upgrade": "Upgrade", "downgrade": "Downgrade", "current": "Current" }, "freePlan": { "title": "Free", "description": "For individuals up to 2 members to organize everything", "price": "{}", "priceInfo": "Free forever" }, "proPlan": { "title": "Pro", "description": "For small teams to manage projects and team knowledge", "price": "{}", "priceInfo": "Per user per month \nbilled annually\n\n{} billed monthly" }, "planLabels": { "itemOne": "Workspaces", "itemTwo": "Members", "itemThree": "Storage", "itemFour": "Real-time collaboration", "itemFive": "Guest editors", "itemSix": "AI Responses", "itemSeven": "AI Images", "itemFileUpload": "File uploads", "customNamespace": "Custom namespace", "tooltipFive": "Collaborate on specific pages with non-members", "tooltipSix": "Lifetime means the number of responses never reset", "intelligentSearch": "Intelligent search", "tooltipSeven": "Allows you to customize part of the URL for your workspace", "customNamespaceTooltip": "Custom published site URL" }, "freeLabels": { "itemOne": "Charged per workspace", "itemTwo": "Up to 2", "itemThree": "5 GB", "itemFour": "yes", "itemFive": "yes", "itemSix": "10 lifetime", "itemSeven": "2 lifetime", "itemFileUpload": "Up to 7 MB", "intelligentSearch": "Intelligent search" }, "proLabels": { "itemOne": "Charged per workspace", "itemTwo": "Up to 10", "itemThree": "Unlimited", "itemFour": "yes", "itemFive": "Up to 100", "itemSix": "Unlimited", "itemSeven": "50 images per month", "itemFileUpload": "Unlimited", "intelligentSearch": "Intelligent search" }, "paymentSuccess": { "title": "You are now on the {} plan!", "description": "Your payment has been successfully processed and your plan is upgraded to @:appName {}. You can view your plan details on the Plan page" }, "downgradeDialog": { "title": "Are you sure you want to downgrade your plan?", "description": "Downgrading your plan will revert you back to the Free plan. Members may lose access to this workspace and you may need to free up space to meet the storage limits of the Free plan.", "downgradeLabel": "Downgrade plan" } }, "cancelSurveyDialog": { "title": "Sorry to see you go", "description": "We're sorry to see you go. We'd love to hear your feedback to help us improve @:appName. Please take a moment to answer a few questions.", "commonOther": "Other", "otherHint": "Write your answer here", "questionOne": { "question": "What prompted you to cancel your @:appName Pro subscription?", "answerOne": "Cost too high", "answerTwo": "Features did not meet expectations", "answerThree": "Found a better alternative", "answerFour": "Did not use it enough to justify the expense", "answerFive": "Service issue or technical difficulties" }, "questionTwo": { "question": "How likely are you to consider re-subscribing to @:appName Pro in the future?", "answerOne": "Very likely", "answerTwo": "Somewhat likely", "answerThree": "Not sure", "answerFour": "Unlikely", "answerFive": "Very unlikely" }, "questionThree": { "question": "Which Pro feature did you value the most during your subscription?", "answerOne": "Multi-user collaboration", "answerTwo": "Longer time version history", "answerThree": "Unlimited AI responses", "answerFour": "Access to local AI models" }, "questionFour": { "question": "How would you describe your overall experience with @:appName?", "answerOne": "Great", "answerTwo": "Good", "answerThree": "Average", "answerFour": "Below average", "answerFive": "Unsatisfied" } }, "common": { "uploadingFile": "File is uploading. Please do not quit the app", "uploadNotionSuccess": "Your Notion zip file has been uploaded successfully. Once the import is complete, you will receive a confirmation email", "reset": "Reset" }, "menu": { "appearance": "Appearance", "language": "Language", "user": "User", "files": "Files", "notifications": "Notifications", "open": "Open Settings", "logout": "Log out", "logoutPrompt": "Are you sure you want to log out?", "selfEncryptionLogoutPrompt": "Are you sure you want to log out? Please ensure you have copied the encryption secret", "syncSetting": "Sync Setting", "cloudSettings": "Cloud Settings", "enableSync": "Enable sync", "enableSyncLog": "Enable sync logging", "enableSyncLogWarning": "Thank you for helping diagnose sync issues. This will log your document edits to a local file. Please quit and reopen the app after enabling", "enableEncrypt": "Encrypt data", "cloudURL": "Base URL", "webURL": "Web URL", "invalidCloudURLScheme": "Invalid Scheme", "cloudServerType": "Cloud server", "cloudServerTypeTip": "Please note that it might log out your current account after switching the cloud server", "cloudLocal": "Local", "cloudAppFlowy": "@:appName Cloud", "cloudAppFlowySelfHost": "@:appName Cloud Self-hosted", "appFlowyCloudUrlCanNotBeEmpty": "The cloud url can't be empty", "clickToCopy": "Copy to clipboard", "selfHostStart": "If you don't have a server, please refer to the", "selfHostContent": "document", "selfHostEnd": "for guidance on how to self-host your own server", "pleaseInputValidURL": "Please input a valid URL", "changeUrl": "Change self-hosted url to {}", "cloudURLHint": "Input the base URL of your server", "webURLHint": "Input the base URL of your web server", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Input the websocket address of your server", "restartApp": "Restart", "restartAppTip": "Restart the application for the changes to take effect. Please note that this might log out your current account.", "changeServerTip": "After changing the server, you must click the restart button for the changes to take effect", "enableEncryptPrompt": "Activate encryption to secure your data with this secret. Store it safely; once enabled, it can't be turned off. If lost, your data becomes irretrievable. Click to copy", "inputEncryptPrompt": "Please enter your encryption secret for", "clickToCopySecret": "Click to copy secret", "configServerSetting": "Configurate your server settings", "configServerGuide": "After selecting `Quick Start`, navigate to `Settings` and then \"Cloud Settings\" to configure your self-hosted server.", "inputTextFieldHint": "Your secret", "historicalUserList": "User login history", "historicalUserListTooltip": "This list displays your anonymous accounts. You can click on an account to view its details. Anonymous accounts are created by clicking the 'Get Started' button", "openHistoricalUser": "Click to open the anonymous account", "customPathPrompt": "Storing the @:appName data folder in a cloud-synced folder such as Google Drive can pose risks. If the database within this folder is accessed or modified from multiple locations at the same time, it may result in synchronization conflicts and potential data corruption", "importAppFlowyData": "Import Data from External @:appName Folder", "importingAppFlowyDataTip": "Data import is in progress. Please do not close the app", "importAppFlowyDataDescription": "Copy data from an external @:appName data folder and import it into the current AppFlowy data folder", "importSuccess": "Successfully imported the @:appName data folder", "importFailed": "Importing the @:appName data folder failed", "importGuide": "For further details, please check the referenced document" }, "notifications": { "enableNotifications": { "label": "Enable notifications", "hint": "Turn off to stop local notifications from appearing." }, "showNotificationsIcon": { "label": "Show notifications icon", "hint": "Toggle off to hide the notification icon in the sidebar." }, "archiveNotifications": { "allSuccess": "Archived all notifications successfully", "success": "Archived notification successfully" }, "markAsReadNotifications": { "allSuccess": "Marked all as read successfully", "success": "Marked as read successfully" }, "action": { "markAsRead": "Mark as read", "multipleChoice": "Select more", "archive": "Archive" }, "settings": { "settings": "Settings", "markAllAsRead": "Mark all as read", "archiveAll": "Archive all" }, "emptyInbox": { "title": "Inbox Zero!", "description": "Set reminders to receive notifications here." }, "emptyUnread": { "title": "No unread notifications", "description": "You're all caught up!" }, "emptyArchived": { "title": "No archived", "description": "Archived notifications will appear here." }, "tabs": { "inbox": "Inbox", "unread": "Unread", "archived": "Archived" }, "refreshSuccess": "Notifications refreshed successfully", "titles": { "notifications": "Notifications", "reminder": "Reminder" } }, "appearance": { "resetSetting": "Reset", "fontFamily": { "label": "Font Family", "search": "Search", "defaultFont": "System" }, "themeMode": { "label": "Theme Mode", "light": "Light Mode", "dark": "Dark Mode", "system": "Adapt to System" }, "fontScaleFactor": "Font Scale Factor", "displaySize": "Display Size", "documentSettings": { "cursorColor": "Document cursor color", "selectionColor": "Document selection color", "width": "Document width", "changeWidth": "Change", "pickColor": "Select a color", "colorShade": "Color shade", "opacity": "Opacity", "hexEmptyError": "Hex color cannot be empty", "hexLengthError": "Hex value must be 6 digits long", "hexInvalidError": "Invalid hex value", "opacityEmptyError": "Opacity cannot be empty", "opacityRangeError": "Opacity must be between 1 and 100", "app": "App", "flowy": "Flowy", "apply": "Apply" }, "layoutDirection": { "label": "Layout Direction", "hint": "Control the flow of content on your screen, from left to right or right to left.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "Default Text Direction", "hint": "Specify whether text should start from left or right as the default.", "ltr": "LTR", "rtl": "RTL", "auto": "AUTO", "fallback": "Same as layout direction" }, "themeUpload": { "button": "Upload", "uploadTheme": "Upload theme", "description": "Upload your own @:appName theme using the button below.", "loading": "Please wait while we validate and upload your theme...", "uploadSuccess": "Your theme was uploaded successfully", "deletionFailure": "Failed to delete the theme. Try to delete it manually.", "filePickerDialogTitle": "Choose a .flowy_plugin file", "urlUploadFailure": "Failed to open url: {}" }, "theme": "Theme", "builtInsLabel": "Built-in Themes", "pluginsLabel": "Plugins", "dateFormat": { "label": "Date format", "local": "Local", "us": "US", "iso": "ISO", "friendly": "Friendly", "dmy": "D/M/Y" }, "timeFormat": { "label": "Time format", "twelveHour": "Twelve hour", "twentyFourHour": "Twenty four hour" }, "showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page", "enableRTLToolbarItems": "Enable RTL toolbar items", "members": { "title": "Members", "inviteMembers": "Invite members", "inviteHint": "Invite by email", "sendInvite": "Invite", "copyInviteLink": "Copy invite link", "label": "Members", "user": "User", "role": "Role", "removeFromWorkspace": "Remove from Workspace", "removeFromWorkspaceSuccess": "Remove from workspace successfully", "removeFromWorkspaceFailed": "Remove from workspace failed", "owner": "Owner", "guest": "Guest", "member": "Member", "memberHintText": "A member can read and edit pages", "guestHintText": "A Guest can read, react, comment, and can edit certain pages with permission.", "emailInvalidError": "Invalid email, please check and try again", "emailSent": "Email sent, please check the inbox", "members": "members", "membersCount": { "zero": "{} members", "one": "{} member", "other": "{} members" }, "inviteFailedDialogTitle": "Upgrade to Pro Plan", "inviteFailedMemberLimit": "This workspace has reached the free limit. Please upgrade to Pro to unlock more members.", "inviteFailedMemberLimitMobile": "Your workspace has reached the member limit.", "memberLimitExceeded": "Member limit reached, to invite more members, please ", "memberLimitExceededUpgrade": "upgrade", "memberLimitExceededPro": "Member limit reached, if you require more members contact ", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Failed to add member", "addMemberSuccess": "Member added successfully", "removeMember": "Remove Member", "areYouSureToRemoveMember": "Are you sure you want to remove this member?", "inviteMemberSuccess": "The invitation has been sent successfully", "failedToInviteMember": "Failed to invite member", "workspaceMembersError": "Oops, something went wrong", "workspaceMembersErrorDescription": "We couldn't load the member list at this time. Please try again later", "inviteLinkToAddMember": "Invite link to add member", "clickToCopyLink": "Click to copy link", "or": "or", "generateANewLink": "generate a new link", "inviteMemberByEmail": "Invite member by email", "inviteMemberHintText": "Invite by email", "resetInviteLink": "Reset the invite link?", "resetInviteLinkDescription": "Resetting will deactivate the current link for all space members and generate a new one. The old link will no longer valid.", "adminPanel": "Admin Panel", "reset": "Reset", "resetInviteLinkSuccess": "Invite link reset successfully", "resetInviteLinkFailed": "Failed to reset the invite link", "resetInviteLinkFailedDescription": "Please try again later", "memberPageDescription1": "Access the", "memberPageDescription2": "for guest and advanced user management.", "noInviteLink": "You haven't generated an invite link yet.", "copyLink": "Copy link", "generatedLinkSuccessfully": "Generated link successfully", "generatedLinkFailed": "Failed to generate link", "resetLinkSuccessfully": "Reset link successfully", "resetLinkFailed": "Failed to reset link" } }, "files": { "copy": "Copy", "defaultLocation": "Read files and data storage location", "exportData": "Export your data", "doubleTapToCopy": "Double tap to copy the path", "restoreLocation": "Restore to @:appName default path", "customizeLocation": "Open another folder", "restartApp": "Please restart app for the changes to take effect.", "exportDatabase": "Export database", "selectFiles": "Select the files that need to be export", "selectAll": "Select all", "deselectAll": "Deselect all", "createNewFolder": "Create a new folder", "createNewFolderDesc": "Tell us where you want to store your data", "defineWhereYourDataIsStored": "Define where your data is stored", "open": "Open", "openFolder": "Open an existing folder", "openFolderDesc": "Read and write it to your existing @:appName folder", "folderHintText": "folder name", "location": "Creating a new folder", "locationDesc": "Pick a name for your @:appName data folder", "browser": "Browse", "create": "Create", "set": "Set", "folderPath": "Path to store your folder", "locationCannotBeEmpty": "Path cannot be empty", "pathCopiedSnackbar": "File storage path copied to clipboard!", "changeLocationTooltips": "Change the data directory", "change": "Change", "openLocationTooltips": "Open another data directory", "openCurrentDataFolder": "Open current data directory", "recoverLocationTooltips": "Reset to @:appName's default data directory", "exportFileSuccess": "Export file successfully!", "exportFileFail": "Export file failed!", "export": "Export", "clearCache": "Clear cache", "clearCacheDesc": "If you encounter issues with images not loading or fonts not displaying correctly, try clearing your cache. This action will not remove your user data.", "areYouSureToClearCache": "Are you sure to clear the cache?", "clearCacheSuccess": "Cache cleared successfully!" }, "user": { "name": "Name", "email": "Email", "tooltipSelectIcon": "Select icon", "selectAnIcon": "Select an icon", "pleaseInputYourOpenAIKey": "please input your AI key", "clickToLogout": "Click to log out the current user" }, "mobile": { "personalInfo": "Personal Information", "username": "User Name", "usernameEmptyError": "User name cannot be empty", "about": "About", "pushNotifications": "Push Notifications", "support": "Support", "joinDiscord": "Join us in Discord", "privacyPolicy": "Privacy Policy", "userAgreement": "User Agreement", "termsAndConditions": "Terms and Conditions", "userprofileError": "Failed to load user profile", "userprofileErrorDescription": "Please try to log out and log back in to check if the issue still persists.", "selectLayout": "Select layout", "selectStartingDay": "Select starting day", "version": "Version" } }, "grid": { "deleteView": "Are you sure you want to delete this view?", "createView": "New", "title": { "placeholder": "Untitled" }, "settings": { "filter": "Filter", "sort": "Sort", "sortBy": "Sort by", "properties": "Properties", "reorderPropertiesTooltip": "Drag to reorder properties", "group": "Group", "addFilter": "Add Filter", "deleteFilter": "Delete filter", "filterBy": "Filter by", "typeAValue": "Type a value...", "layout": "Layout", "compactMode": "Compact mode", "databaseLayout": "Layout", "viewList": { "zero": "0 views", "one": "{count} view", "other": "{count} views" }, "editView": "Edit View", "boardSettings": "Board settings", "calendarSettings": "Calendar settings", "createView": "New view", "duplicateView": "Duplicate view", "deleteView": "Delete view", "numberOfVisibleFields": "{} shown" }, "filter": { "empty": "No active filters", "addFilter": "Add filter", "cannotFindCreatableField": "Cannot find a suitable field to filter by", "conditon": "Condition", "where": "Where" }, "textFilter": { "contains": "Contains", "doesNotContain": "Does not contain", "endsWith": "Ends with", "startWith": "Starts with", "is": "Is", "isNot": "Is not", "isEmpty": "Is empty", "isNotEmpty": "Is not empty", "choicechipPrefix": { "isNot": "Not", "startWith": "Starts with", "endWith": "Ends with", "isEmpty": "is empty", "isNotEmpty": "is not empty" } }, "checkboxFilter": { "isChecked": "Checked", "isUnchecked": "Unchecked", "choicechipPrefix": { "is": "is" } }, "checklistFilter": { "isComplete": "Is complete", "isIncomplted": "Is incomplete" }, "selectOptionFilter": { "is": "Is", "isNot": "Is not", "contains": "Contains", "doesNotContain": "Does not contain", "isEmpty": "Is empty", "isNotEmpty": "Is not empty" }, "dateFilter": { "is": "Is on", "before": "Is before", "after": "Is after", "onOrBefore": "Is on or before", "onOrAfter": "Is on or after", "between": "Is between", "empty": "Is empty", "notEmpty": "Is not empty", "startDate": "Start date", "endDate": "End date", "choicechipPrefix": { "before": "Before", "after": "After", "between": "Between", "onOrBefore": "On or before", "onOrAfter": "On or after", "isEmpty": "Is empty", "isNotEmpty": "Is not empty" } }, "numberFilter": { "equal": "Equals", "notEqual": "Does not equal", "lessThan": "Is less than", "greaterThan": "Is greater than", "lessThanOrEqualTo": "Is less than or equal to", "greaterThanOrEqualTo": "Is greater than or equal to", "isEmpty": "Is empty", "isNotEmpty": "Is not empty" }, "field": { "label": "Property", "hide": "Hide property", "show": "Show property", "insertLeft": "Insert left", "insertRight": "Insert right", "duplicate": "Duplicate", "delete": "Delete", "wrapCellContent": "Wrap text", "clear": "Clear cells", "switchPrimaryFieldTooltip": "Cannot change field type of primary field", "textFieldName": "Text", "checkboxFieldName": "Checkbox", "dateFieldName": "Date", "updatedAtFieldName": "Last modified", "createdAtFieldName": "Created at", "numberFieldName": "Numbers", "singleSelectFieldName": "Select", "multiSelectFieldName": "Multiselect", "urlFieldName": "URL", "checklistFieldName": "Checklist", "relationFieldName": "Relation", "summaryFieldName": "AI Summary", "timeFieldName": "Time", "mediaFieldName": "Files & media", "translateFieldName": "AI Translate", "translateTo": "Translate to", "numberFormat": "Number format", "dateFormat": "Date format", "includeTime": "Include time", "isRange": "End date", "dateFormatFriendly": "Month Day, Year", "dateFormatISO": "Year-Month-Day", "dateFormatLocal": "Month/Day/Year", "dateFormatUS": "Year/Month/Day", "dateFormatDayMonthYear": "Day/Month/Year", "timeFormat": "Time format", "invalidTimeFormat": "Invalid format", "timeFormatTwelveHour": "12 hour", "timeFormatTwentyFourHour": "24 hour", "clearDate": "Clear date", "dateTime": "Date time", "startDateTime": "Start date time", "endDateTime": "End date time", "failedToLoadDate": "Failed to load date value", "selectTime": "Select time", "selectDate": "Select date", "visibility": "Visibility", "propertyType": "Property type", "addSelectOption": "Add an option", "typeANewOption": "Type a new option", "optionTitle": "Options", "addOption": "Add option", "editProperty": "Edit property", "newProperty": "New property", "openRowDocument": "Open as a page", "deleteFieldPromptMessage": "Are you sure? This property and all its data will be deleted", "clearFieldPromptMessage": "Are you sure? All cells in this column will be emptied", "newColumn": "New column", "format": "Format", "reminderOnDateTooltip": "This cell has a scheduled reminder", "optionAlreadyExist": "Option already exists" }, "rowPage": { "newField": "Add a new field", "fieldDragElementTooltip": "Click to open menu", "showHiddenFields": { "one": "Show {count} hidden field", "many": "Show {count} hidden fields", "other": "Show {count} hidden fields" }, "hideHiddenFields": { "one": "Hide {count} hidden field", "many": "Hide {count} hidden fields", "other": "Hide {count} hidden fields" }, "openAsFullPage": "Open as full page", "viewDatabase": "View the original Database", "moreRowActions": "More row actions" }, "sort": { "ascending": "Ascending", "descending": "Descending", "by": "By", "empty": "No active sorts", "cannotFindCreatableField": "Cannot find a suitable field to sort by", "deleteAllSorts": "Delete all sorts", "addSort": "Add sort", "sortsActive": "Cannot {intention} while sorting", "removeSorting": "Would you like to remove all the sorts in this view and continue?", "fieldInUse": "You are already sorting by this field" }, "row": { "label": "Row", "duplicate": "Duplicate", "delete": "Delete", "titlePlaceholder": "Untitled", "textPlaceholder": "Empty", "copyProperty": "Copied property to clipboard", "count": "Count", "newRow": "New row", "loadMore": "Load more", "action": "Action", "add": "Click add to below", "drag": "Drag to move", "deleteRowPrompt": "Are you sure you want to delete this row? This action cannot be undone.", "deleteCardPrompt": "Are you sure you want to delete this card? This action cannot be undone.", "dragAndClick": "Drag to move, click to open menu", "insertRecordAbove": "Insert record above", "insertRecordBelow": "Insert record below", "noContent": "No content", "reorderRowDescription": "reorder row", "createRowAboveDescription": "create a row above", "createRowBelowDescription": "insert a row below" }, "selectOption": { "create": "Create", "purpleColor": "Purple", "pinkColor": "Pink", "lightPinkColor": "Light Pink", "orangeColor": "Orange", "yellowColor": "Yellow", "limeColor": "Lime", "greenColor": "Green", "aquaColor": "Aqua", "blueColor": "Blue", "deleteTag": "Delete tag", "colorPanelTitle": "Color", "panelTitle": "Select an option or create one", "searchOption": "Search for an option", "searchOrCreateOption": "Search for an option or create one", "createNew": "Create a new", "orSelectOne": "Or select an option", "typeANewOption": "Type a new option", "tagName": "Tag name" }, "checklist": { "taskHint": "Task description", "addNew": "Add a new task", "submitNewTask": "Create", "hideComplete": "Hide completed tasks", "showComplete": "Show all tasks" }, "url": { "launch": "Open link in browser", "copy": "Copy link to clipboard", "textFieldHint": "Enter a URL" }, "relation": { "relatedDatabasePlaceLabel": "Related Database", "relatedDatabasePlaceholder": "None", "inRelatedDatabase": "In", "rowSearchTextFieldPlaceholder": "Search", "noDatabaseSelected": "No database selected, please select one first from the list below:", "emptySearchResult": "No records found", "linkedRowListLabel": "{count} linked rows", "unlinkedRowListLabel": "Link another row" }, "menuName": "Grid", "referencedGridPrefix": "View of", "calculate": "Calculate", "calculationTypeLabel": { "none": "None", "average": "Average", "max": "Max", "median": "Median", "min": "Min", "sum": "Sum", "count": "Count", "countEmpty": "Count empty", "countEmptyShort": "EMPTY", "countNonEmpty": "Count not empty", "countNonEmptyShort": "FILLED" }, "media": { "rename": "Rename", "download": "Download", "expand": "Expand", "delete": "Delete", "moreFilesHint": "+{}", "addFileOrImage": "Add file or link", "attachmentsHint": "{}", "addFileMobile": "Add file", "extraCount": "+{}", "deleteFileDescription": "Are you sure you want to delete this file? This action is irreversible.", "showFileNames": "Show file name", "downloadSuccess": "File downloaded", "downloadFailedToken": "Failed to download file, user token unavailable", "setAsCover": "Set as cover", "openInBrowser": "Open in browser", "embedLink": "Embed file link" } }, "document": { "menuName": "Document", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "Creating...", "slashMenu": { "board": { "selectABoardToLinkTo": "Select a Board to link to", "createANewBoard": "Create a new Board" }, "grid": { "selectAGridToLinkTo": "Select a Grid to link to", "createANewGrid": "Create a new Grid" }, "calendar": { "selectACalendarToLinkTo": "Select a Calendar to link to", "createANewCalendar": "Create a new Calendar" }, "document": { "selectADocumentToLinkTo": "Select a Document to link to" }, "name": { "textStyle": "Text Style", "list": "List", "toggle": "Toggle", "fileAndMedia": "File & Media", "simpleTable": "Simple Table", "visuals": "Visuals", "document": "Document", "advanced": "Advanced", "text": "Text", "heading1": "Heading 1", "heading2": "Heading 2", "heading3": "Heading 3", "image": "Image", "bulletedList": "Bulleted list", "numberedList": "Numbered list", "todoList": "To-do list", "doc": "Doc", "linkedDoc": "Link to page", "grid": "Grid", "linkedGrid": "Linked Grid", "kanban": "Kanban", "linkedKanban": "Linked Kanban", "calendar": "Calendar", "linkedCalendar": "Linked Calendar", "quote": "Quote", "divider": "Divider", "table": "Table", "callout": "Callout", "outline": "Outline", "mathEquation": "Math Equation", "code": "Code", "toggleList": "Toggle list", "toggleHeading1": "Toggle heading 1", "toggleHeading2": "Toggle heading 2", "toggleHeading3": "Toggle heading 3", "emoji": "Emoji", "aiWriter": "Ask AI Anything", "dateOrReminder": "Date or Reminder", "photoGallery": "Photo Gallery", "file": "File", "twoColumns": "2 Columns", "threeColumns": "3 Columns", "fourColumns": "4 Columns" }, "subPage": { "name": "Document", "keyword1": "sub page", "keyword2": "page", "keyword3": "child page", "keyword4": "insert page", "keyword5": "embed page", "keyword6": "new page", "keyword7": "create page", "keyword8": "document" } }, "selectionMenu": { "outline": "Outline", "codeBlock": "Code Block" }, "plugins": { "referencedBoard": "Referenced Board", "referencedGrid": "Referenced Grid", "referencedCalendar": "Referenced Calendar", "referencedDocument": "Referenced Document", "aiWriter": { "userQuestion": "Ask AI anything", "continueWriting": "Continue writing", "fixSpelling": "Fix spelling & grammar", "improveWriting": "Improve writing", "summarize": "Summarize", "explain": "Explain", "makeShorter": "Make shorter", "makeLonger": "Make longer" }, "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Ask AI to write anything...", "autoGeneratorLearnMore": "Learn more", "autoGeneratorGenerate": "Generate", "autoGeneratorHintText": "Ask AI ...", "autoGeneratorCantGetOpenAIKey": "Can't get AI key", "autoGeneratorRewrite": "Rewrite", "smartEdit": "Ask AI", "aI": "AI", "smartEditFixSpelling": "Fix spelling & grammar", "warning": "⚠️ AI responses can be inaccurate or misleading.", "smartEditSummarize": "Summarize", "smartEditImproveWriting": "Improve writing", "smartEditMakeLonger": "Make longer", "smartEditCouldNotFetchResult": "Could not fetch result from AI", "smartEditCouldNotFetchKey": "Could not fetch AI key", "smartEditDisabled": "Connect AI in Settings", "appflowyAIEditDisabled": "Sign in to enable AI features", "discardResponse": "Are you sure you want to discard the AI response?", "createInlineMathEquation": "Create equation", "fonts": "Fonts", "insertDate": "Insert date", "emoji": "Emoji", "toggleList": "Toggle list", "emptyToggleHeading": "Empty toggle h{}. Click to add content.", "emptyToggleList": "Empty toggle list. Click to add content.", "emptyToggleHeadingWeb": "Empty toggle h{level}. Click to add content", "quoteList": "Quote list", "numberedList": "Numbered list", "bulletedList": "Bulleted list", "todoList": "Todo list", "callout": "Callout", "simpleTable": { "moreActions": { "color": "Color", "align": "Align", "delete": "Delete", "duplicate": "Duplicate", "insertLeft": "Insert left", "insertRight": "Insert right", "insertAbove": "Insert above", "insertBelow": "Insert below", "headerColumn": "Header column", "headerRow": "Header row", "clearContents": "Clear contents", "setToPageWidth": "Set to page width", "distributeColumnsWidth": "Distribute columns evenly", "duplicateRow": "Duplicate row", "duplicateColumn": "Duplicate column", "textColor": "Text color", "cellBackgroundColor": "Cell background color", "duplicateTable": "Duplicate table" }, "clickToAddNewRow": "Click to add a new row", "clickToAddNewColumn": "Click to add a new column", "clickToAddNewRowAndColumn": "Click to add a new row and column", "headerName": { "table": "Table", "alignText": "Align text" } }, "cover": { "changeCover": "Change Cover", "colors": "Colors", "images": "Images", "clearAll": "Clear All", "abstract": "Abstract", "addCover": "Add Cover", "addLocalImage": "Add local image", "invalidImageUrl": "Invalid image URL", "failedToAddImageToGallery": "Failed to add image to gallery", "enterImageUrl": "Enter image URL", "add": "Add", "back": "Back", "saveToGallery": "Save to gallery", "removeIcon": "Remove icon", "removeCover": "Remove cover", "pasteImageUrl": "Paste image URL", "or": "OR", "pickFromFiles": "Pick from files", "couldNotFetchImage": "Could not fetch image", "imageSavingFailed": "Image Saving Failed", "addIcon": "Add icon", "changeIcon": "Change icon", "coverRemoveAlert": "It will be removed from cover after it is deleted.", "alertDialogConfirmation": "Are you sure, you want to continue?" }, "mathEquation": { "name": "Math Equation", "addMathEquation": "Add a TeX equation", "editMathEquation": "Edit Math Equation" }, "optionAction": { "click": "Click", "toOpenMenu": " to open menu", "drag": "Drag", "toMove": " to move", "delete": "Delete", "duplicate": "Duplicate", "turnInto": "Turn into", "moveUp": "Move up", "moveDown": "Move down", "color": "Color", "align": "Align", "left": "Left", "center": "Center", "right": "Right", "defaultColor": "Default", "depth": "Depth", "copyLinkToBlock": "Copy link to block" }, "image": { "addAnImage": "Add images", "copiedToPasteBoard": "The image link has been copied to the clipboard", "addAnImageDesktop": "Add an image", "addAnImageMobile": "Click to add one or more images", "dropImageToInsert": "Drop images to insert", "imageUploadFailed": "Image upload failed", "imageDownloadFailed": "Image download failed, please try again", "imageDownloadFailedToken": "Image download failed due to missing user token, please try again", "errorCode": "Error code" }, "photoGallery": { "name": "Photo gallery", "imageKeyword": "image", "imageGalleryKeyword": "image gallery", "photoKeyword": "photo", "photoBrowserKeyword": "photo browser", "galleryKeyword": "gallery", "addImageTooltip": "Add image", "changeLayoutTooltip": "Change layout", "browserLayout": "Browser", "gridLayout": "Grid", "deleteBlockTooltip": "Delete whole gallery" }, "math": { "copiedToPasteBoard": "The math equation has been copied to the clipboard" }, "urlPreview": { "copiedToPasteBoard": "The link has been copied to the clipboard", "convertToLink": "Convert to embed link" }, "outline": { "addHeadingToCreateOutline": "Add headings to create a table of contents.", "noMatchHeadings": "No matching headings found." }, "table": { "addAfter": "Add after", "addBefore": "Add before", "delete": "Delete", "clear": "Clear content", "duplicate": "Duplicate", "bgColor": "Background color" }, "contextMenu": { "copy": "Copy", "cut": "Cut", "paste": "Paste", "pasteAsPlainText": "Paste as plain text" }, "action": "Actions", "database": { "selectDataSource": "Select data source", "noDataSource": "No data source", "selectADataSource": "Select a data source", "toContinue": "to continue", "newDatabase": "New Database", "linkToDatabase": "Link to Database" }, "date": "Date", "video": { "label": "Video", "emptyLabel": "Add a video", "placeholder": "Paste the video link", "copiedToPasteBoard": "The video link has been copied to the clipboard", "insertVideo": "Add video", "invalidVideoUrl": "The source URL is not supported yet.", "invalidVideoUrlYouTube": "YouTube is not supported yet.", "supportedFormats": "Supported formats: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "File", "uploadTab": "Upload", "uploadMobile": "Choose a file", "uploadMobileGallery": "From Photo Gallery", "networkTab": "Embed link", "placeholderText": "Upload or embed a file", "placeholderDragging": "Drop the file to upload", "dropFileToUpload": "Drop a file to upload", "fileUploadHint": "Drag & drop a file or click to ", "fileUploadHintSuffix": "Browse", "networkHint": "Paste a file link", "networkUrlInvalid": "Invalid URL. Check the URL and try again.", "networkAction": "Embed", "fileTooBigError": "File size is too big, please upload a file with size less than 10MB", "renameFile": { "title": "Rename file", "description": "Enter the new name for this file", "nameEmptyError": "File name cannot be left empty." }, "uploadedAt": "Uploaded on {}", "linkedAt": "Link added on {}", "failedToOpenMsg": "Failed to open, file not found" }, "subPage": { "handlingPasteHint": " - (handling paste)", "errors": { "failedDeletePage": "Failed to delete page", "failedCreatePage": "Failed to create page", "failedMovePage": "Failed to move page to this document", "failedDuplicatePage": "Failed to duplicate page", "failedDuplicateFindView": "Failed to duplicate page - original view not found" } }, "cannotMoveToItsChildren": "Cannot move to its children", "linkPreview": { "typeSelection": { "pasteAs": "Paste as", "mention": "Mention", "URL": "URL", "bookmark": "Bookmark", "embed": "Embed" }, "linkPreviewMenu": { "toMetion": "Convert to Mention", "toUrl": "Convert to URL", "toEmbed": "Convert to Embed", "toBookmark": "Convert to Bookmark", "copyLink": "Copy Link", "replace": "Replace", "reload": "Reload", "removeLink": "Remove Link", "pasteHint": "Paste in https://...", "unableToDisplay": "unable to display" } } }, "outlineBlock": { "placeholder": "Table of Contents" }, "textBlock": { "placeholder": "Type '/' for commands" }, "title": { "placeholder": "Untitled" }, "imageBlock": { "placeholder": "Click to add image(s)", "upload": { "label": "Upload", "placeholder": "Click to upload image" }, "url": { "label": "Image URL", "placeholder": "Enter image URL" }, "ai": { "label": "Generate image from AI", "placeholder": "Please input the prompt for AI to generate image" }, "stability_ai": { "label": "Generate image from Stability AI", "placeholder": "Please input the prompt for Stability AI to generate image" }, "support": "Image size limit is 5MB. Supported formats: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Invalid image", "invalidImageSize": "Image size must be less than 5MB", "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "Invalid image URL", "noImage": "No such file or directory", "multipleImagesFailed": "One or more images failed to upload, please try again" }, "embedLink": { "label": "Embed link", "placeholder": "Paste or type an image link" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Search for an image", "pleaseInputYourOpenAIKey": "please input your AI key in Settings page", "saveImageToGallery": "Save image", "failedToAddImageToGallery": "Failed to save image", "successToAddImageToGallery": "Saved image to Photos", "unableToLoadImage": "Unable to load image", "maximumImageSize": "Maximum supported upload image size is 10MB", "uploadImageErrorImageSizeTooBig": "Image size must be less than 10MB", "imageIsUploading": "Image is uploading", "openFullScreen": "Open in full screen", "interactiveViewer": { "toolbar": { "previousImageTooltip": "Previous image", "nextImageTooltip": "Next image", "zoomOutTooltip": "Zoom out", "zoomInTooltip": "Zoom in", "changeZoomLevelTooltip": "Change zoom level", "openLocalImage": "Open image", "downloadImage": "Download image", "closeViewer": "Close interactive viewer", "scalePercentage": "{}%", "deleteImageTooltip": "Delete image" } } }, "codeBlock": { "language": { "label": "Language", "placeholder": "Select language", "auto": "Auto" }, "copyTooltip": "Copy", "searchLanguageHint": "Search for a language", "codeCopiedSnackbar": "Code copied to clipboard!" }, "inlineLink": { "placeholder": "Paste or type a link", "openInNewTab": "Open in new tab", "copyLink": "Copy link", "removeLink": "Remove link", "url": { "label": "Link URL", "placeholder": "Enter link URL" }, "title": { "label": "Link Title", "placeholder": "Enter link title" } }, "mention": { "placeholder": "Mention a person or a page or date...", "page": { "label": "Link to page", "tooltip": "Click to open page" }, "deleted": "Deleted", "deletedContent": "This content does not exist or has been deleted", "noAccess": "No Access", "deletedPage": "Deleted page", "trashHint": " - in trash", "morePages": "more pages" }, "toolbar": { "resetToDefaultFont": "Reset to default", "textSize": "Text size", "textColor": "Text color", "h1": "Heading 1", "h2": "Heading 2", "h3": "Heading 3", "alignLeft": "Align left", "alignRight": "Align right", "alignCenter": "Align center", "link": "Link", "textAlign": "Text align", "moreOptions": "More options", "font": "Font", "inlineCode": "Inline code", "suggestions": "Suggestions", "turnInto": "Turn into", "equation": "Equation", "insert": "Insert", "linkInputHint": "Paste link or search pages", "pageOrURL": "Page or URL", "linkName": "Link Name", "linkNameHint": "Input link name" }, "errorBlock": { "theBlockIsNotSupported": "Unable to parse the block content", "clickToCopyTheBlockContent": "Click to copy the block content", "blockContentHasBeenCopied": "The block content has been copied.", "parseError": "An error occurred while parsing the {} block.", "copyBlockContent": "Copy block content" }, "mobilePageSelector": { "title": "Select page", "failedToLoad": "Failed to load page list", "noPagesFound": "No pages found" }, "attachmentMenu": { "choosePhoto": "Choose photo", "takePicture": "Take a picture", "chooseFile": "Choose file" } }, "board": { "column": { "label": "Column", "createNewCard": "New", "renameGroupTooltip": "Press to rename group", "createNewColumn": "Add a new group", "addToColumnTopTooltip": "Add a new card at the top", "addToColumnBottomTooltip": "Add a new card at the bottom", "renameColumn": "Rename", "hideColumn": "Hide", "newGroup": "New group", "deleteColumn": "Delete", "deleteColumnConfirmation": "This will delete this group and all the cards in it. Are you sure you want to continue?" }, "hiddenGroupSection": { "sectionTitle": "Hidden Groups", "collapseTooltip": "Hide the hidden groups", "expandTooltip": "View the hidden groups" }, "cardDetail": "Card Detail", "cardActions": "Card Actions", "cardDuplicated": "Card has been duplicated", "cardDeleted": "Card has been deleted", "showOnCard": "Show on card detail", "setting": "Setting", "propertyName": "Property name", "menuName": "Board", "showUngrouped": "Show ungrouped items", "ungroupedButtonText": "Ungrouped", "ungroupedButtonTooltip": "Contains cards that don't belong in any group", "ungroupedItemsTitle": "Click to add to the board", "groupBy": "Group by", "groupCondition": "Group condition", "referencedBoardPrefix": "View of", "notesTooltip": "Notes inside", "mobile": { "editURL": "Edit URL", "showGroup": "Show group", "showGroupContent": "Are you sure you want to show this group on the board?", "failedToLoad": "Failed to load board view" }, "dateCondition": { "weekOf": "Week of {} - {}", "today": "Today", "yesterday": "Yesterday", "tomorrow": "Tomorrow", "lastSevenDays": "Last 7 days", "nextSevenDays": "Next 7 days", "lastThirtyDays": "Last 30 days", "nextThirtyDays": "Next 30 days" }, "noGroup": "No group by property", "noGroupDesc": "Board views require a property to group by in order to display", "media": { "cardText": "{} {}", "fallbackName": "files" } }, "calendar": { "menuName": "Calendar", "defaultNewCalendarTitle": "Untitled", "newEventButtonTooltip": "Add a new event", "navigation": { "today": "Today", "jumpToday": "Jump to Today", "previousMonth": "Previous Month", "nextMonth": "Next Month", "views": { "day": "Day", "week": "Week", "month": "Month", "year": "Year" } }, "mobileEventScreen": { "emptyTitle": "No events yet", "emptyBody": "Press the plus button to create an event on this day." }, "settings": { "showWeekNumbers": "Show week numbers", "showWeekends": "Show weekends", "firstDayOfWeek": "Start week on", "layoutDateField": "Layout calendar by", "changeLayoutDateField": "Change layout field", "noDateTitle": "No Date", "noDateHint": { "zero": "Unscheduled events will show up here", "one": "{count} unscheduled event", "other": "{count} unscheduled events" }, "unscheduledEventsTitle": "Unscheduled events", "clickToAdd": "Click to add to the calendar", "name": "Calendar settings", "clickToOpen": "Click to open the record" }, "referencedCalendarPrefix": "View of", "quickJumpYear": "Jump to", "duplicateEvent": "Duplicate event" }, "errorDialog": { "title": "@:appName Error", "howToFixFallback": "We're sorry for the inconvenience! Submit an issue on our GitHub page that describes your error.", "howToFixFallbackHint1": "We're sorry for the inconvenience! Submit an issue on our ", "howToFixFallbackHint2": " page that describes your error.", "github": "View on GitHub" }, "search": { "label": "Search", "sidebarSearchIcon": "Search and quickly jump to a page", "searchOrAskAI": "Search or ask AI", "searchFieldHint": "Search or ask a question in {}...", "askAIAnything": "Ask AI anything", "askAIFor": "Ask AI", "searching": "Searching...", "noResultForSearching": "No matches found", "noResultForSearchingHint": "Try different questions or keywords.\n Some pages may be in the Trash.", "noResultForSearchingHintWithoutTrash": "Try different questions or keywords\n Some pages may be in the ", "bestMatch": "Best match", "seeMore": "See more", "showMore": "Show more", "somethingWentWrong": "Something went wrong", "pageNotExist": "This page doesn't exist", "tryAgainOrLater": "Please try again later", "placeholder": { "actions": "Search actions..." } }, "message": { "copy": { "success": "Copied to clipboard", "fail": "Unable to copy" } }, "unSupportBlock": "The current version does not support this Block.", "views": { "deleteContentTitle": "Are you sure want to delete the {pageType}?", "deleteContentCaption": "if you delete this {pageType}, you can restore it from the trash." }, "colors": { "custom": "Custom", "default": "Default", "red": "Red", "orange": "Orange", "yellow": "Yellow", "green": "Green", "blue": "Blue", "purple": "Purple", "pink": "Pink", "brown": "Brown", "gray": "Gray" }, "emoji": { "emojiTab": "Emoji", "search": "Search emoji", "noRecent": "No recent emoji", "noEmojiFound": "No emoji found", "filter": "Filter", "random": "Random", "selectSkinTone": "Select skin tone", "remove": "Remove emoji", "categories": { "smileys": "Smileys & Emotion", "people": "people", "animals": "nature", "food": "foods", "activities": "activities", "places": "places", "objects": "objects", "symbols": "symbols", "flags": "flags", "nature": "nature", "frequentlyUsed": "frequently Used" }, "skinTone": { "default": "Default", "light": "Light", "mediumLight": "Medium-Light", "medium": "Medium", "mediumDark": "Medium-Dark", "dark": "Dark" }, "openSourceIconsFrom": "Open source icons from" }, "inlineActions": { "noResults": "No results", "recentPages": "Recent pages", "pageReference": "Page reference", "docReference": "Document reference", "boardReference": "Board reference", "calReference": "Calendar reference", "gridReference": "Grid reference", "date": "Date", "reminder": { "groupTitle": "Reminder", "shortKeyword": "remind" }, "createPage": "Create \"{}\" sub-page" }, "datePicker": { "dateTimeFormatTooltip": "Change the date and time format in settings", "dateFormat": "Date format", "includeTime": "Include time", "isRange": "End date", "timeFormat": "Time format", "clearDate": "Clear date", "reminderLabel": "Reminder", "selectReminder": "Select reminder", "reminderOptions": { "none": "None", "atTimeOfEvent": "Time of event", "fiveMinsBefore": "5 mins before", "tenMinsBefore": "10 mins before", "fifteenMinsBefore": "15 mins before", "thirtyMinsBefore": "30 mins before", "oneHourBefore": "1 hour before", "twoHoursBefore": "2 hours before", "onDayOfEvent": "On day of event", "oneDayBefore": "1 day before", "twoDaysBefore": "2 days before", "oneWeekBefore": "1 week before", "custom": "Custom" } }, "relativeDates": { "yesterday": "Yesterday", "today": "Today", "tomorrow": "Tomorrow", "oneWeek": "1 week" }, "notificationHub": { "title": "Notifications", "closeNotification": "Close notification", "viewNotifications": "View notifications", "noNotifications": "No notifications yet", "mentionedYou": "Mentioned you", "archivedTooltip": "Archive this notification", "unarchiveTooltip": "Unarchive this notification", "markAsReadTooltip": "Mark this notification as read", "markAsArchivedSucceedToast": "Archived successfully", "markAllAsArchivedSucceedToast": "Archived all successfully", "markAsReadSucceedToast": "Mark as read successfully", "markAllAsReadSucceedToast": "Mark all as read successfully", "today": "Today", "older": "Older", "mobile": { "title": "Updates" }, "emptyTitle": "All caught up!", "emptyBody": "No pending notifications or actions. Enjoy the calm.", "tabs": { "inbox": "Inbox", "upcoming": "Upcoming" }, "actions": { "markAllRead": "Mark all as read", "showAll": "All", "showUnreads": "Unread" }, "filters": { "ascending": "Ascending", "descending": "Descending", "groupByDate": "Group by date", "showUnreadsOnly": "Show unreads only", "resetToDefault": "Reset to default" } }, "reminderNotification": { "title": "Reminder", "message": "Remember to check this before you forget!", "tooltipDelete": "Delete", "tooltipMarkRead": "Mark as read", "tooltipMarkUnread": "Mark as unread" }, "findAndReplace": { "find": "Find", "previousMatch": "Previous match", "nextMatch": "Next match", "close": "Close", "replace": "Replace", "replaceAll": "Replace all", "noResult": "No results", "caseSensitive": "Case sensitive", "searchMore": "Search to find more results" }, "error": { "weAreSorry": "We're sorry", "loadingViewError": "We're having trouble loading this view. Please check your internet connection, refresh the app, and do not hesitate to reach out to the team if the issue continues.", "syncError": "Data is not synced from another device", "syncErrorHint": "Please reopen this page on the device where it was last edited, then open it again on the current device.", "clickToCopy": "Click to copy error code" }, "editor": { "bold": "Bold", "bulletedList": "Bulleted list", "bulletedListShortForm": "Bulleted", "checkbox": "Checkbox", "embedCode": "Embed Code", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Highlight", "color": "Color", "image": "Image", "date": "Date", "page": "Page", "italic": "Italic", "link": "Link", "numberedList": "Numbered list", "numberedListShortForm": "Numbered", "toggleHeading1ShortForm": "Toggle H1", "toggleHeading2ShortForm": "Toggle H2", "toggleHeading3ShortForm": "Toggle H3", "quote": "Quote", "strikethrough": "Strikethrough", "text": "Text", "underline": "Underline", "fontColorDefault": "Default", "fontColorGray": "Gray", "fontColorBrown": "Brown", "fontColorOrange": "Orange", "fontColorYellow": "Yellow", "fontColorGreen": "Green", "fontColorBlue": "Blue", "fontColorPurple": "Purple", "fontColorPink": "Pink", "fontColorRed": "Red", "backgroundColorDefault": "Default background", "backgroundColorGray": "Gray background", "backgroundColorBrown": "Brown background", "backgroundColorOrange": "Orange background", "backgroundColorYellow": "Yellow background", "backgroundColorGreen": "Green background", "backgroundColorBlue": "Blue background", "backgroundColorPurple": "Purple background", "backgroundColorPink": "Pink background", "backgroundColorRed": "Red background", "backgroundColorLime": "Lime background", "backgroundColorAqua": "Aqua background", "done": "Done", "cancel": "Cancel", "tint1": "Tint 1", "tint2": "Tint 2", "tint3": "Tint 3", "tint4": "Tint 4", "tint5": "Tint 5", "tint6": "Tint 6", "tint7": "Tint 7", "tint8": "Tint 8", "tint9": "Tint 9", "lightLightTint1": "Purple", "lightLightTint2": "Pink", "lightLightTint3": "Light Pink", "lightLightTint4": "Orange", "lightLightTint5": "Yellow", "lightLightTint6": "Lime", "lightLightTint7": "Green", "lightLightTint8": "Aqua", "lightLightTint9": "Blue", "urlHint": "URL", "mobileHeading1": "Heading 1", "mobileHeading2": "Heading 2", "mobileHeading3": "Heading 3", "mobileHeading4": "Heading 4", "mobileHeading5": "Heading 5", "mobileHeading6": "Heading 6", "textColor": "Text Color", "backgroundColor": "Background Color", "addYourLink": "Add your link", "openLink": "Open link", "copyLink": "Copy link", "removeLink": "Remove link", "editLink": "Edit link", "convertTo": "Convert to", "linkText": "Text", "linkTextHint": "Please enter text", "linkAddressHint": "Please enter URL", "highlightColor": "Highlight color", "clearHighlightColor": "Clear highlight color", "customColor": "Custom color", "hexValue": "Hex value", "opacity": "Opacity", "resetToDefaultColor": "Reset to default color", "ltr": "LTR", "rtl": "RTL", "auto": "Auto", "cut": "Cut", "copy": "Copy", "paste": "Paste", "find": "Find", "select": "Select", "selectAll": "Select all", "previousMatch": "Previous match", "nextMatch": "Next match", "closeFind": "Close", "replace": "Replace", "replaceAll": "Replace all", "regex": "Regex", "caseSensitive": "Case sensitive", "uploadImage": "Upload Image", "urlImage": "URL Image", "incorrectLink": "Incorrect Link", "upload": "Upload", "chooseImage": "Choose an image", "loading": "Loading", "imageLoadFailed": "Image load failed", "divider": "Divider", "table": "Table", "colAddBefore": "Add before", "rowAddBefore": "Add before", "colAddAfter": "Add after", "rowAddAfter": "Add after", "colRemove": "Remove", "rowRemove": "Remove", "colDuplicate": "Duplicate", "rowDuplicate": "Duplicate", "colClear": "Clear Content", "rowClear": "Clear Content", "slashPlaceHolder": "Type '/' to insert a block, or start typing", "typeSomething": "Type something...", "toggleListShortForm": "Toggle", "quoteListShortForm": "Quote", "mathEquationShortForm": "Formula", "codeBlockShortForm": "Code" }, "favorite": { "noFavorite": "No favorite page", "noFavoriteHintText": "Swipe the page to the left to add it to your favorites", "removeFromSidebar": "Remove from sidebar", "addToSidebar": "Pin to sidebar" }, "cardDetails": { "notesPlaceholder": "Enter a / to insert a block, or start typing" }, "blockPlaceholders": { "todoList": "To-do", "bulletList": "List", "numberList": "List", "quote": "Quote", "heading": "Heading {}" }, "titleBar": { "pageIcon": "Page icon", "language": "Language", "font": "Font", "actions": "Actions", "date": "Date", "addField": "Add field", "userIcon": "User icon" }, "noLogFiles": "There're no log files", "newSettings": { "myAccount": { "title": "Account & App", "subtitle": "Customize your profile, manage account security, open AI keys, or login into your account.", "profileLabel": "Account name & Profile image", "profileNamePlaceholder": "Enter your name", "accountSecurity": "Account security", "2FA": "2-Step Authentication", "aiKeys": "AI keys", "accountLogin": "Account Login", "updateNameError": "Failed to update name", "updateIconError": "Failed to update icon", "aboutAppFlowy": "About @:appName", "deleteAccount": { "title": "Delete Account", "subtitle": "Permanently delete your account and all of your data.", "description": "Permanently delete your account and remove access from all workspaces.", "deleteMyAccount": "Delete my account", "dialogTitle": "Delete account", "dialogContent1": "Are you sure you want to permanently delete your account?", "dialogContent2": "This action cannot be undone, and will remove access from all workspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces.", "confirmHint1": "Please type \"@:newSettings.myAccount.deleteAccount.confirmHint3\" to confirm.", "confirmHint2": "I understand that this action is irreversible and will permanently delete my account and all associated data.", "confirmHint3": "DELETE MY ACCOUNT", "checkToConfirmError": "You must check the box to confirm deletion", "failedToGetCurrentUser": "Failed to get current user email", "confirmTextValidationFailed": "Your confirmation text does not match \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "Account deleted successfully" }, "password": { "title": "Password", "confirmPassword": "Confirm password", "changePassword": "Change password", "currentPassword": "Current password", "newPassword": "New password", "confirmNewPassword": "Confirm new password", "setupPassword": "Setup password", "error": { "currentPasswordIsRequired": "Current password is required", "newPasswordIsRequired": "New password is required", "confirmPasswordIsRequired": "Confirm password is required", "passwordsDoNotMatch": "Passwords do not match", "newPasswordIsSameAsCurrent": "New password is same as current password", "currentPasswordIsIncorrect": "Current password is incorrect", "passwordShouldBeAtLeast6Characters": "Password should be at least {min} characters", "passwordCannotBeLongerThan72Characters": "Password cannot be longer than {max} characters" }, "toast": { "passwordUpdatedSuccessfully": "Password updated successfully", "passwordUpdatedFailed": "Failed to update password", "passwordSetupSuccessfully": "Password setup successfully", "passwordSetupFailed": "Failed to setup password" }, "hint": { "enterYourPassword": "Enter your password", "confirmYourPassword": "Confirm your password", "enterYourCurrentPassword": "Enter your current password", "enterYourNewPassword": "Enter your new password", "confirmYourNewPassword": "Confirm your new password" } }, "myAccount": "My Account", "myProfile": "My Profile" }, "workplace": { "name": "Workplace", "title": "Workplace Settings", "subtitle": "Customize your workspace appearance, theme, font, text layout, date, time, and language.", "workplaceName": "Workplace name", "workplaceNamePlaceholder": "Enter workplace name", "workplaceIcon": "Workplace icon", "workplaceIconSubtitle": "Upload an image or use an emoji for your workspace. Icon will show in your sidebar and notifications.", "renameError": "Failed to rename workplace", "updateIconError": "Failed to update icon", "chooseAnIcon": "Choose an icon", "appearance": { "name": "Appearance", "themeMode": { "auto": "Auto", "light": "Light", "dark": "Dark" }, "language": "Language" } }, "syncState": { "syncing": "Syncing", "synced": "Synced", "noNetworkConnected": "No network connected" } }, "pageStyle": { "title": "Page style", "layout": "Layout", "coverImage": "Cover image", "pageIcon": "Page icon", "colors": "Colors", "gradient": "Gradient", "backgroundImage": "Background image", "presets": "Presets", "photo": "Photo", "unsplash": "Unsplash", "pageCover": "Page cover", "none": "None", "openSettings": "Open Settings", "photoPermissionTitle": "@:appName would like to access your photo library", "photoPermissionDescription": "@:appName needs access to your photos to let you add images to your documents", "cameraPermissionTitle": "@:appName would like to access your camera", "cameraPermissionDescription": "@:appName needs access to your camera to let you add images to your documents from the camera", "doNotAllow": "Don't Allow", "image": "Image" }, "commandPalette": { "placeholder": "Search or ask a question...", "bestMatches": "Best matches", "aiOverview": "AI overview", "aiOverviewSource": "Reference sources", "aiOverviewMoreDetails": "More details", "aiAskFollowUp": "Ask follow-up", "pagePreview": "Content preview", "clickToOpenPage": "Click to open page", "recentHistory": "Recent history", "navigateHint": "to navigate", "loadingTooltip": "We are looking for results...", "betaLabel": "BETA", "betaTooltip": "We currently only support searching for pages and content in documents", "fromTrashHint": "From trash", "noResultsHint": "We didn't find what you're looking for, try searching for another term.", "clearSearchTooltip": "Clear input", "location": "Location", "created": "Created", "edited": "Edited" }, "space": { "delete": "Delete", "deleteConfirmation": "Delete: ", "deleteConfirmationDescription": "All pages within this Space will be deleted and moved to the Trash, and any published pages will be unpublished.", "rename": "Rename Space", "changeIcon": "Change icon", "manage": "Manage Space", "addNewSpace": "Create Space", "collapseAllSubPages": "Collapse all subpages", "createNewSpace": "Create a new space", "createSpaceDescription": "Create multiple public and private spaces to better organize your work.", "spaceName": "Space name", "spaceNamePlaceholder": "e.g. Marketing, Engineering, HR", "permission": "Space permission", "publicPermission": "Public", "publicPermissionDescription": "All workspace members with full access", "privatePermission": "Private", "privatePermissionDescription": "Only you can access this space", "spaceIconBackground": "Background color", "spaceIcon": "Icon", "dangerZone": "Danger Zone", "unableToDeleteLastSpace": "Unable to delete the last Space", "unableToDeleteSpaceNotCreatedByYou": "Unable to delete spaces created by others", "enableSpacesForYourWorkspace": "Enable Spaces for your workspace", "title": "Spaces", "defaultSpaceName": "General", "upgradeSpaceTitle": "Enable Spaces", "upgradeSpaceDescription": "Create multiple public and private Spaces to better organize your workspace.", "upgrade": "Update", "upgradeYourSpace": "Create multiple Spaces", "quicklySwitch": "Quickly switch to the next space", "duplicate": "Duplicate Space", "movePageToSpace": "Move page to space", "cannotMovePageToDatabase": "Cannot move page to database", "switchSpace": "Switch space", "spaceNameCannotBeEmpty": "Space name cannot be empty", "success": { "deleteSpace": "Space deleted successfully", "renameSpace": "Space renamed successfully", "duplicateSpace": "Space duplicated successfully", "updateSpace": "Space updated successfully" }, "error": { "deleteSpace": "Failed to delete space", "renameSpace": "Failed to rename space", "duplicateSpace": "Failed to duplicate space", "updateSpace": "Failed to update space" }, "createSpace": "Create space", "manageSpace": "Manage space", "renameSpace": "Rename space", "mSpaceIconColor": "Space icon color", "mSpaceIcon": "Space icon" }, "publish": { "hasNotBeenPublished": "This page hasn't been published yet", "spaceHasNotBeenPublished": "Haven't supported publishing a space yet", "reportPage": "Report page", "databaseHasNotBeenPublished": "Publishing a database is not supported yet.", "createdWith": "Created with", "downloadApp": "Download AppFlowy", "copy": { "codeBlock": "The content of code block has been copied to the clipboard", "imageBlock": "The image link has been copied to the clipboard", "mathBlock": "The math equation has been copied to the clipboard", "fileBlock": "The file link has been copied to the clipboard" }, "containsPublishedPage": "This page contains one or more published pages. If you continue, they will be unpublished. Do you want to proceed with deletion?", "publishSuccessfully": "Published successfully", "unpublishSuccessfully": "Unpublished successfully", "publishFailed": "Failed to publish", "unpublishFailed": "Failed to unpublish", "noAccessToVisit": "No access to this page...", "createWithAppFlowy": "Create a website with AppFlowy", "fastWithAI": "Fast and easy with AI.", "tryItNow": "Try it now", "onlyGridViewCanBePublished": "Only Grid view can be published", "database": { "zero": "Publish {} selected view", "one": "Publish {} selected views", "many": "Publish {} selected views", "other": "Publish {} selected views" }, "mustSelectPrimaryDatabase": "The primary view must be selected", "noDatabaseSelected": "No database selected, please select at least one database.", "unableToDeselectPrimaryDatabase": "Unable to deselect primary database", "saveThisPage": "Start with this template", "duplicateTitle": "Where would you like to add", "selectWorkspace": "Select a workspace", "addTo": "Add to", "duplicateSuccessfully": "Added to your workspace", "duplicateSuccessfullyDescription": "Don't have AppFlowy installed? The download will start automatically after you click 'Download'.", "downloadIt": "Download", "openApp": "Open in app", "duplicateFailed": "Duplicated failed", "membersCount": { "zero": "No members", "one": "1 member", "many": "{count} members", "other": "{count} members" }, "useThisTemplate": "Use the template" }, "web": { "continue": "Continue", "or": "or", "continueWithGoogle": "Continue with Google", "continueWithGithub": "Continue with GitHub", "continueWithDiscord": "Continue with Discord", "continueWithApple": "Continue with Apple ", "moreOptions": "More options", "collapse": "Collapse", "signInAgreement": "By clicking \"Continue\" above, you agreed to \nAppFlowy's ", "signInLocalAgreement": "By clicking \"Get Started\" above, you agreed to \nAppFlowy's ", "and": "and", "termOfUse": "Terms", "privacyPolicy": "Privacy Policy", "signInError": "Sign in error", "login": "Sign up or log in", "fileBlock": { "uploadedAt": "Uploaded on {time}", "linkedAt": "Link added on {time}", "empty": "Upload or embed a file", "uploadFailed": "Upload failed, please try again", "retry": "Retry" }, "importNotion": "Import from Notion", "import": "Import", "importSuccess": "Uploaded successfully", "importSuccessMessage": "We'll notify you when the import is complete. After that, you can view your imported pages in the sidebar.", "importFailed": "Import failed, please check the file format", "dropNotionFile": "Drop your Notion zip file here to upload, or click to browse", "error": { "pageNameIsEmpty": "The page name is empty, please try another one" } }, "globalComment": { "comments": "Comments", "addComment": "Add a comment", "reactedBy": "reacted by", "addReaction": "Add reaction", "reactedByMore": "and {count} others", "showSeconds": { "one": "1 second ago", "other": "{count} seconds ago", "zero": "Just now", "many": "{count} seconds ago" }, "showMinutes": { "one": "1 minute ago", "other": "{count} minutes ago", "many": "{count} minutes ago" }, "showHours": { "one": "1 hour ago", "other": "{count} hours ago", "many": "{count} hours ago" }, "showDays": { "one": "1 day ago", "other": "{count} days ago", "many": "{count} days ago" }, "showMonths": { "one": "1 month ago", "other": "{count} months ago", "many": "{count} months ago" }, "showYears": { "one": "1 year ago", "other": "{count} years ago", "many": "{count} years ago" }, "reply": "Reply", "deleteComment": "Delete comment", "youAreNotOwner": "You are not the owner of this comment", "confirmDeleteDescription": "Are you sure you want to delete this comment?", "hasBeenDeleted": "Deleted", "replyingTo": "Replying to", "noAccessDeleteComment": "You're not allowed to delete this comment", "collapse": "Collapse", "readMore": "Read more", "failedToAddComment": "Failed to add comment", "commentAddedSuccessfully": "Comment added successfully.", "commentAddedSuccessTip": "You've just added or replied to a comment. Would you like to jump to the top to see the latest comments?" }, "template": { "asTemplate": "Save as template", "name": "Template name", "description": "Template Description", "about": "Template About", "deleteFromTemplate": "Delete from templates", "preview": "Template Preview", "categories": "Template Categories", "isNewTemplate": "PIN to New template", "featured": "PIN to Featured", "relatedTemplates": "Related Templates", "requiredField": "{field} is required", "addCategory": "Add \"{category}\"", "addNewCategory": "Add new category", "addNewCreator": "Add new creator", "deleteCategory": "Delete category", "editCategory": "Edit category", "editCreator": "Edit creator", "category": { "name": "Category name", "icon": "Category icon", "bgColor": "Category background color", "priority": "Category priority", "desc": "Category description", "type": "Category type", "icons": "Category Icons", "colors": "Category Colors", "byUseCase": "By Use Case", "byFeature": "By Feature", "deleteCategory": "Delete category", "deleteCategoryDescription": "Are you sure you want to delete this category?", "typeToSearch": "Type to search categories..." }, "creator": { "label": "Template Creator", "name": "Creator name", "avatar": "Creator avatar", "accountLinks": "Creator account links", "uploadAvatar": "Click to upload avatar", "deleteCreator": "Delete creator", "deleteCreatorDescription": "Are you sure you want to delete this creator?", "typeToSearch": "Type to search creators..." }, "uploadSuccess": "Template uploaded successfully", "uploadSuccessDescription": "Your template has been uploaded successfully. You can now view it in the template gallery.", "viewTemplate": "View template", "deleteTemplate": "Delete template", "deleteSuccess": "Template deleted successfully", "deleteTemplateDescription": "This won't affect the current page or published status. Are you sure you want to delete this template?", "addRelatedTemplate": "Add related template", "removeRelatedTemplate": "Remove related template", "uploadAvatar": "Upload avatar", "searchInCategory": "Search in {category}", "label": "Templates" }, "fileDropzone": { "dropFile": "Click or drag file to this area to upload", "uploading": "Uploading...", "uploadFailed": "Upload failed", "uploadSuccess": "Upload success", "uploadSuccessDescription": "The file has been uploaded successfully", "uploadFailedDescription": "The file upload failed", "uploadingDescription": "The file is being uploaded" }, "gallery": { "preview": "Open in full screen", "copy": "Copy", "download": "Download", "prev": "Previous", "next": "Next", "resetZoom": "Reset zoom", "zoomIn": "Zoom in", "zoomOut": "Zoom out" }, "invitation": { "join": "Join", "on": "on", "invitedBy": "Invited by", "membersCount": { "zero": "{count} members", "one": "{count} member", "many": "{count} members", "other": "{count} members" }, "tip": "You've been invited to Join this workspace with the contact information below. If this is incorrect, contact your administrator to resend the invite.", "joinWorkspace": "Join workspace", "success": "You've successfully joined the workspace", "successMessage": "You can now access all the pages and workspaces within it.", "openWorkspace": "Open AppFlowy", "alreadyAccepted": "You've already accepted the invitation", "errorModal": { "title": "Something went wrong", "description": "Your current account {email} may not have access to this workspace. Please log in with the correct account or contact the workspace owner for help.", "contactOwner": "Contact owner", "close": "Back to home", "changeAccount": "Change account" } }, "requestAccess": { "title": "No access to this page", "subtitle": "You can request access from the owner of this page. Once approved, you can view the page.", "requestAccess": "Request access", "backToHome": "Back to home", "tip": "You're currently logged in as .", "mightBe": "You might need to with a different account.", "successful": "Request sent successfully", "successfulMessage": "You will be notified once the owner approves your request.", "requestError": "Failed to request access", "repeatRequestError": "You've already requested access to this page" }, "approveAccess": { "title": "Approve Workspace Join Request", "requestSummary": " requests to join and access ", "upgrade": "upgrade", "downloadApp": "Download AppFlowy", "approveButton": "Approve", "approveSuccess": "Approved successfully", "approveError": "Failed to approve, ensure the workspace plan limit is not exceeded", "getRequestInfoError": "Failed to get request info", "memberCount": { "zero": "No members", "one": "1 member", "many": "{count} members", "other": "{count} members" }, "alreadyProTitle": "You've reached the workspace plan limit", "alreadyProMessage": "Ask them to contact to unlock more members", "repeatApproveError": "You've already approved this request", "ensurePlanLimit": "Ensure the workspace plan limit is not exceeded. If the limit is exceeded, consider the workspace plan or .", "requestToJoin": "requested to join", "asMember": "as a member" }, "upgradePlanModal": { "title": "Upgrade to Pro", "message": "{name} has reached the free member limit. Upgrade to the Pro Plan to invite more members.", "upgradeSteps": "How to upgrade your plan on AppFlowy:", "step1": "1. Go to Settings", "step2": "2. Click on 'Plan'", "step3": "3. Select 'Change Plan'", "appNote": "Note: ", "actionButton": "Upgrade", "downloadLink": "Download App", "laterButton": "Later", "refreshNote": "After successful upgrade, click to activate your new features.", "refresh": "here" }, "breadcrumbs": { "label": "Breadcrumbs" }, "time": { "justNow": "Just now", "seconds": { "one": "1 second", "other": "{count} seconds" }, "minutes": { "one": "1 minute", "other": "{count} minutes" }, "hours": { "one": "1 hour", "other": "{count} hours" }, "days": { "one": "1 day", "other": "{count} days" }, "weeks": { "one": "1 week", "other": "{count} weeks" }, "months": { "one": "1 month", "other": "{count} months" }, "years": { "one": "1 year", "other": "{count} years" }, "ago": "ago", "yesterday": "Yesterday", "today": "Today" }, "members": { "zero": "No members", "one": "1 member", "many": "{count} members", "other": "{count} members" }, "tabMenu": { "close": "Close", "closeDisabledHint": "Cannot close a pinned tab, please unpin first", "closeOthers": "Close other tabs", "closeOthersHint": "This will close all unpinned tabs except this one", "closeOthersDisabledHint": "All tabs are pinned, cannot find any tabs to close", "favorite": "Favorite", "unfavorite": "Unfavorite", "favoriteDisabledHint": "Cannot favorite this view", "pinTab": "Pin", "unpinTab": "Unpin" }, "openFileMessage": { "success": "File opened successfully", "fileNotFound": "File not found", "noAppToOpenFile": "No app to open this file", "permissionDenied": "No permission to open this file", "unknownError": "File open failed" }, "inviteMember": { "requestInviteMembers": "Invite to your workspace", "inviteFailedMemberLimit": "Member limit has been reached, please ", "upgrade": "upgrade", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "Send invites", "inviteAlready": "You've already invited this email: {email}", "inviteSuccess": "Invitation sent successfully", "description": "Input emails below with commas between them. Charges are based on member count.", "emails": "Email" }, "quickNote": { "label": "Quick Note", "quickNotes": "Quick Notes", "search": "Search Quick Notes", "collapseFullView": "Collapse full view", "expandFullView": "Expand full view", "createFailed": "Failed to create Quick Note", "quickNotesEmpty": "No Quick Notes", "emptyNote": "Empty note", "deleteNotePrompt": "The selected note will be deleted permanently. Are you sure you want to delete it?", "addNote": "New Note", "noAdditionalText": "No additional text" }, "subscribe": { "upgradePlanTitle": "Compare & select plan", "yearly": "Yearly", "save": "Save {discount}%", "monthly": "Monthly", "priceIn": "Price in ", "free": "Free", "pro": "Pro", "freeDescription": "For individuals up to 2 members to organize everything", "proDescription": "For small teams to manage projects and team knowledge", "proDuration": { "monthly": "per member per month\nbilled monthly", "yearly": "per member per month\nbilled annually" }, "cancel": "Downgrade", "changePlan": "Upgrade to Pro Plan", "everythingInFree": "Everything in Free +", "currentPlan": "Current", "freeDuration": "forever", "freePoints": { "first": "1 collaborative workspace up to 2 members", "second": "Unlimited pages & blocks", "three": "5 GB storage", "four": "Intelligent search", "five": "20 AI responses", "six": "Mobile app", "seven": "Real-time collaboration" }, "proPoints": { "first": "Unlimited storage", "second": "Up to 10 workspace members", "three": "Unlimited AI responses", "four": "Unlimited file uploads", "five": "Custom namespace" }, "cancelPlan": { "title": "Sorry to see you go", "success": "Your subscription has been canceled successfully", "description": "We're sorry to see you go. We'd love to hear your feedback to help us improve AppFlowy. Please take a moment to answer a few questions.", "commonOther": "Other", "otherHint": "Write your answer here", "questionOne": { "question": "What prompted you to cancel your AppFlowy Pro subscription?", "answerOne": "Cost too high", "answerTwo": "Features did not meet expectations", "answerThree": "Found a better alternative", "answerFour": "Did not use it enough to justify the expense", "answerFive": "Service issue or technical difficulties" }, "questionTwo": { "question": "How likely are you to consider re-subscribing to AppFlowy Pro in the future?", "answerOne": "Very likely", "answerTwo": "Somewhat likely", "answerThree": "Not sure", "answerFour": "Unlikely", "answerFive": "Very unlikely" }, "questionThree": { "question": "Which Pro feature did you value the most during your subscription?", "answerOne": "Multi-user collaboration", "answerTwo": "Longer time version history", "answerThree": "Unlimited AI responses", "answerFour": "Access to local AI models" }, "questionFour": { "question": "How would you describe your overall experience with AppFlowy?", "answerOne": "Great", "answerTwo": "Good", "answerThree": "Average", "answerFour": "Below average", "answerFive": "Unsatisfied" } } }, "ai": { "contentPolicyViolation": "Image generation failed due to sensitive content. Please rephrase your input and try again", "textLimitReachedDescription": "Your workspace has run out of free AI responses. Upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", "imageLimitReachedDescription": "You've used up your free AI image quota. Please upgrade to the Pro Plan or purchase an AI add-on to unlock unlimited responses", "limitReachedAction": { "textDescription": "Your workspace has run out of free AI responses. To get more responses, please", "imageDescription": "You've used up your free AI image quota. Please", "upgrade": "upgrade", "toThe": "to the", "proPlan": "Pro Plan", "orPurchaseAn": "or purchase an", "aiAddon": "AI add-on" }, "editing": "Editing", "analyzing": "Analyzing", "continueWritingEmptyDocumentTitle": "Continue writing error", "continueWritingEmptyDocumentDescription": "We are having trouble expanding on the content in your document. Write a small intro and we can take it from there!", "more": "More", "customPrompt": { "browsePrompts": "Browse prompts", "usePrompt": "Use prompt", "featured": "Featured", "custom": "Custom", "customPrompt": "Custom Prompts", "databasePrompts": "Load prompts from your own database", "selectDatabase": "Select database", "promptDatabase": "Prompt database", "configureDatabase": "Configure Database", "title": "Title", "content": "Content", "example": "Example", "category": "Category", "selectField": "Select field", "loading": "Loading", "invalidDatabase": "Invalid Database", "invalidDatabaseHelp": "Ensure that the database has at least two text properties:\n ◦ One used for the prompt name\n ◦ One used for the prompt content\nYou can also optionally add properties for the prompt example and category.", "noResults": "No prompts found", "all": "All", "development": "Development", "writing": "Writing", "healthAndFitness": "Health & fitness", "business": "Business", "marketing": "Marketing", "travel": "Travel", "others": "Other", "prompt": "Prompt", "promptExample": "Prompt Example", "sampleOutput": "Sample output", "contentSeo": "Content/SEO", "emailMarketing": "Email Marketing", "paidAds": "Paid Ads", "prCommunication": "PR/Communication", "recruiting": "Recruiting", "sales": "Sales", "socialMedia": "Social Media", "strategy": "Strategy", "caseStudies": "Case Studies", "salesCopy": "Sales Copy", "education": "Education", "work": "Work", "podcastProduction": "Podcast Production", "copyWriting": "Copy Writing", "customerSuccess": "Customer Success" } }, "autoUpdate": { "criticalUpdateTitle": "Update required to continue", "criticalUpdateDescription": "We've made improvements to enhance your experience! Please update from {currentVersion} to {newVersion} to keep using the app.", "criticalUpdateButton": "Update", "bannerUpdateTitle": "New Version Available!", "bannerUpdateDescription": "Get the latest features and fixes. Click \"Update\" to install now", "bannerUpdateButton": "Update", "settingsUpdateTitle": "New Version ({newVersion}) Available!", "settingsUpdateDescription": "Current version: {currentVersion} (Official build) → {newVersion}", "settingsUpdateButton": "Update", "settingsUpdateWhatsNew": "What's new" }, "lockPage": { "lockPage": "Locked", "reLockPage": "Re-lock", "lockTooltip": "Page locked to prevent accidental editing. Click to unlock.", "pageLockedToast": "Page locked. Editing is disabled until someone unlocks it.", "lockedOperationTooltip": "Page locked to prevent accidental editing." }, "suggestion": { "accept": "Accept", "keep": "Keep", "discard": "Discard", "close": "Close", "tryAgain": "Try again", "rewrite": "Rewrite", "insertBelow": "Insert below" }, "shareTab": { "accessLevel": { "view": "View", "comment": "Comment", "edit": "Edit", "fullAccess": "Full access" }, "inviteByEmail": "Invite by email", "invite": "Invite", "anyoneAtWorkspace": "Anyone at {workspace}", "anyoneInGroupWithLinkCanEdit": "Anyone in this group with the link can edit", "copyLink": "Copy link", "copiedLinkToClipboard": "Copied link to clipboard", "removeAccess": "Remove access", "turnIntoMember": "Turn into Member", "you": "(You)", "guest": "Guest", "onlyFullAccessCanInvite": "Only user with full access can invite others", "invitationSent": "Invitation sent", "emailAlreadyInList": "The email is already in the list", "upgradeToProToInviteGuests": "Please upgrade to a Pro plan to invite more guests", "maxGuestsReached": "You have reached the maximum number of guests", "removedGuestSuccessfully": "Removed guest successfully", "updatedAccessLevelSuccessfully": "Updated access level successfully", "turnedIntoMemberSuccessfully": "Turned into member successfully", "peopleAboveCanAccessWithLink": "People above can access with the link", "cantMakeChanges": "Can't make changes", "canMakeAnyChanges": "Can make any changes", "generalAccess": "General access", "peopleWithAccess": "People with access", "peopleAboveCanAccessWithTheLink": "People above can access with the link", "upgrade": "Upgrade", "toProPlanToInviteGuests": " to the Pro plan to invite guests to this page", "upgradeToInviteGuest": { "title": { "owner": "Upgrade to invite guest", "member": "Upgrade to invite guest", "guest": "Upgrade to invite guest" }, "description": { "owner": "Your workspace is currently on the Free plan. Upgrade to the Pro plan to allow external users to access this page as guests.", "member": "Some invitees are outside your workspace. To invite them as guests, please contact your workspace owner to upgrade to the Pro plan.", "guest": "Some invitees are outside your workspace. To invite them as guests, please contact your workspace owner to upgrade to the Pro plan." } } }, "shareSection": { "shared": "Shared with me" } } ================================================ FILE: frontend/resources/translations/es-VE.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Mi", "welcomeText": "Bienvenido a @:appName", "welcomeTo": "Bienvenido a", "githubStarText": "Favorito en GitHub", "subscribeNewsletterText": "Suscribir al boletín", "letsGoButtonText": "Inicio rápido", "title": "Título", "youCanAlso": "Tú también puedes", "and": "y", "failedToOpenUrl": "No se pudo abrir la URL: {}", "blockActions": { "addBelowTooltip": "Haga clic para agregar a continuación", "addAboveCmd": "Alt+clic", "addAboveMacCmd": "Opción+clic", "addAboveTooltip": "para agregar arriba", "dragTooltip": "Arrastre para mover", "openMenuTooltip": "Haga clic para abrir el menú" }, "signUp": { "buttonText": "Registro", "title": "Registro en @:appName", "getStartedText": "Empezar", "emptyPasswordError": "La contraseña no puede estar en blanco", "repeatPasswordEmptyError": "La contraseña repetida no puede estar vacía", "unmatchedPasswordError": "Las contraseñas no coinciden", "alreadyHaveAnAccount": "¿Ya posee una cuenta?", "emailHint": "Correo electrónico", "passwordHint": "Contraseña", "repeatPasswordHint": "Repetir contraseña", "signUpWith": "Registro con:" }, "signIn": { "loginTitle": "Ingresa a @:appName", "loginButtonText": "Ingresar", "loginStartWithAnonymous": "Comience una sesión anónima", "continueAnonymousUser": "Continuar con una sesión anónima", "buttonText": "Ingresar", "signingInText": "Iniciando sesión...", "forgotPassword": "¿Olvidó su contraseña?", "emailHint": "Correo", "passwordHint": "Contraseña", "dontHaveAnAccount": "¿No posee credenciales?", "createAccount": "Crear una cuenta", "repeatPasswordEmptyError": "La contraseña no puede estar en blanco", "unmatchedPasswordError": "Las contraseñas no coinciden", "syncPromptMessage": "La sincronización de los datos puede tardar un poco. Por favor no cierres esta página", "or": "O", "signInWithGoogle": "Iniciar sesión con Google", "signInWithGithub": "Iniciar sesión con Github", "signInWithDiscord": "Iniciar sesión con Discord", "signInWithApple": "Continuar con Apple", "continueAnotherWay": "Continuar por otro camino", "signUpWithGoogle": "Registrarse con Google", "signUpWithGithub": "Registrarse con Github", "signUpWithDiscord": "Registrarse con Discord", "signInWith": "Inicia sesión con:", "signInWithEmail": "Iniciar sesión con correo electrónico", "signInWithMagicLink": "Iniciar sesión con enlace mágico", "signUpWithMagicLink": "Registrarse con enlace mágico", "pleaseInputYourEmail": "Por favor, introduzca su dirección de correo electrónico", "settings": "Configuración", "magicLinkSent": "Enlace mágico enviado a tu correo electrónico, por favor revisa tu bandeja de entrada", "invalidEmail": "Por favor, introduce una dirección de correo electrónico válida", "alreadyHaveAnAccount": "¿Ya tienes cuenta?", "logIn": "Iniciar sesión", "generalError": "Algo ha salido mal. Por favor, inténtalo más tarde", "limitRateError": "Por razones de seguridad, solo puedes solicitar un enlace mágico cada 60 segundos", "anonymous": "Anónimo" }, "workspace": { "chooseWorkspace": "Elige tu espacio de trabajo", "defaultName": "Mi espacio de trabajo", "create": "Crear espacio de trabajo", "new": "Nuevo espacio de trabajo", "learnMore": "Más información", "reset": "Restablecer espacio de trabajo", "renameWorkspace": "Cambiar el nombre del espacio de trabajo", "resetWorkspacePrompt": "Al restablecer el espacio de trabajo se eliminarán todas las páginas y datos que contiene. ¿Está seguro de que desea restablecer el espacio de trabajo? Alternativamente, puede comunicarse con el equipo de soporte para restaurar el espacio de trabajo.", "hint": "Espacio de trabajo", "notFoundError": "Espacio de trabajo no encontrado", "failedToLoad": "¡Algo salió mal! No se pudo cargar el espacio de trabajo. Intente cerrar cualquier instancia abierta de @:appName y vuelva a intentarlo.", "errorActions": { "reportIssue": "Reportar un problema", "reportIssueOnGithub": "Informar un problema en Github", "exportLogFiles": "Exportar archivos de registro (logs)", "reachOut": "Comuníquese con Discord" }, "menuTitle": "Espacios de trabajo", "deleteWorkspaceHintText": "¿Está seguro de que desea eliminar el espacio de trabajo? Esta acción no se puede deshacer.", "createSuccess": "Espacio de trabajo creado exitosamente", "createFailed": "No se pudo crear el espacio de trabajo", "createLimitExceeded": "Has alcanzado el límite máximo de espacio de trabajo permitido para su cuenta. Si necesita espacios de trabajo adicionales para continuar su trabajo, solicítelos en Github", "deleteSuccess": "Espacio de trabajo eliminado correctamente", "deleteFailed": "No se pudo eliminar el espacio de trabajo", "openSuccess": "Espacio de trabajo abierto correctamente", "openFailed": "No se pudo abrir el espacio de trabajo", "renameSuccess": "Espacio de trabajo renombrado exitosamente", "renameFailed": "No se pudo cambiar el nombre del espacio de trabajo", "updateIconSuccess": "Icono de espacio de trabajo actualizado correctamente", "updateIconFailed": "Fallo actualizando el icono del espacio de trabajo", "cannotDeleteTheOnlyWorkspace": "No se puede eliminar el único espacio de trabajo", "fetchWorkspacesFailed": "No se pudieron recuperar los espacios de trabajo", "leaveCurrentWorkspace": "Salir del espacio de trabajo", "leaveCurrentWorkspacePrompt": "¿Está seguro de que desea abandonar el espacio de trabajo actual?" }, "shareAction": { "buttonText": "Compartir", "workInProgress": "Próximamente", "markdown": "Marcador", "html": "HTML", "clipboard": "Copiar al portapapeles", "csv": "CSV", "copyLink": "Copiar enlace", "publish": "Publicar", "publishTab": "Publicar" }, "moreAction": { "small": "pequeño", "medium": "medio", "large": "grande", "fontSize": "Tamaño de fuente", "import": "Importar", "moreOptions": "Más opciones", "wordCount": "El recuento de palabras: {}", "charCount": "Número de caracteres : {}", "createdAt": "Creado: {}", "deleteView": "Borrar", "duplicateView": "Duplicar", "createdAtLabel": "Creado: ", "syncedAtLabel": "Sincronizado: " }, "importPanel": { "textAndMarkdown": "Texto y Markdown", "documentFromV010": "Documento de v0.1.0", "databaseFromV010": "Base de datos desde v0.1.0", "csv": "CSV", "database": "Base de datos" }, "disclosureAction": { "rename": "Renombrar", "delete": "Eliminar", "duplicate": "Duplicar", "unfavorite": "Quitar de los favoritos", "favorite": "Añadir a los favoritos", "openNewTab": "Abrir en una pestaña nueva", "moveTo": "Mover a", "addToFavorites": "Añadira los favoritos", "copyLink": "Copiar Enlace", "move": "Mover" }, "blankPageTitle": "Página en blanco", "newPageText": "Nueva página", "newDocumentText": "Nuevo documento", "newGridText": "Nueva patrón", "newCalendarText": "Nuevo calendario", "newBoardText": "Nuevo tablero", "chat": { "newChat": "Chat de IA", "relatedQuestion": "Relacionado", "serverUnavailable": "Servicio temporalmente no disponible. Por favor, inténtelo de nuevo más tarde.", "aiServerUnavailable": "🌈 ¡Uh-oh! 🌈. Un unicornio se comió nuestra respuesta. ¡Por favor, intenta de nuevo!", "retry": "Rever", "clickToRetry": "Haga clic para volver a intentarlo", "regenerateAnswer": "Regenerar", "question1": "Cómo utilizar Kanban para gestionar tareas", "question2": "Explica el método GTD", "question3": "¿Por qué usar Rust?", "aiMistakePrompt": "La IA puede cometer errores. Consulta información importante.", "referenceSource": { "one": "Se encontró {count} fuente", "other": "Se encontraron {count} fuentes" }, "regenerate": "Intentar otra vez", "addToNewPage": "Crear nueva página", "changeFormat": { "textOnly": "Texto", "text": "Párrafo" }, "selectBanner": { "saveButton": "Añadir …" } }, "trash": { "text": "Papelera", "restoreAll": "Recuperar todo", "restore": "Restaurar", "deleteAll": "Eliminar todo", "pageHeader": { "fileName": "Nombre de archivo", "lastModified": "Última modificación", "created": "Creado" }, "confirmDeleteAll": { "title": "¿Estás seguro de eliminar todas las páginas de la Papelera?", "caption": "Esta acción no se puede deshacer." }, "confirmRestoreAll": { "title": "¿Estás seguro de restaurar todas las páginas en la Papelera?", "caption": "Esta acción no se puede deshacer." }, "mobile": { "actions": "Acciones de papelera", "empty": "La papelera está vacío", "emptyDescription": "No tienes ningún archivo eliminado", "isDeleted": "esta eliminado", "isRestored": "esta restaurado" }, "confirmDeleteTitle": "¿Está seguro de que desea eliminar esta página de forma permanente?" }, "deletePagePrompt": { "text": "Esta página está en la Papelera", "restore": "Recuperar página", "deletePermanent": "Eliminar permanentemente" }, "dialogCreatePageNameHint": "Nombre de página", "questionBubble": { "shortcuts": "Atajos", "whatsNew": "¿Qué hay de nuevo?", "markdown": "Reducción", "debug": { "name": "Información de depuración", "success": "¡Información copiada!", "fail": "No fue posible copiar la información" }, "feedback": "Comentario", "help": "Ayuda y Soporte" }, "menuAppHeader": { "moreButtonToolTip": "Eliminar, renombrar y más...", "addPageTooltip": "Inserta una página", "defaultNewPageName": "Sin Título", "renameDialog": "Renombrar", "pageNameSuffix": "Copiar" }, "noPagesInside": "No hay páginas dentro", "toolbar": { "undo": "Deshacer", "redo": "Rehacer", "bold": "Negrita", "italic": "Cursiva", "underline": "Subrayado", "strike": "Tachado", "numList": "Lista numerada", "bulletList": "Lista con viñetas", "checkList": "Lista de verificación", "inlineCode": "Código embebido", "quote": "Cita", "header": "Título", "highlight": "Resaltado", "color": "Color", "addLink": "Añadir enlace", "link": "Enlace" }, "tooltip": { "lightMode": "Cambiar a modo Claro", "darkMode": "Cambiar a modo Oscuro", "openAsPage": "Abrir como una página", "addNewRow": "Agregar una nueva fila", "openMenu": "Haga clic para abrir el menú", "dragRow": "Pulsación larga para reordenar la fila", "viewDataBase": "Ver base de datos", "referencePage": "Se hace referencia a este {nombre}", "addBlockBelow": "Añadir un bloque a continuación", "aiGenerate": "Generar" }, "sideBar": { "closeSidebar": "Cerrar panel lateral", "openSidebar": "Abrir panel lateral", "personal": "Personal", "private": "Privado", "workspace": "Espacio de trabajo", "favorites": "Favoritos", "clickToHidePrivate": "Haz clic para ocultar el espacio privado\nLas páginas que creaste aquí solo son visibles para ti", "clickToHideWorkspace": "Haga clic para ocultar el espacio de trabajo\nLas páginas que creaste aquí son visibles para todos los miembros", "clickToHidePersonal": "Haga clic para ocultar la sección personal", "clickToHideFavorites": "Haga clic para ocultar la sección de favoritos", "addAPage": "Añadir una página", "addAPageToPrivate": "Agregar una página al espacio privado", "addAPageToWorkspace": "Agregar una página al espacio de trabajo", "recent": "Reciente", "today": "Hoy", "thisWeek": "Esta semana", "others": "Otros favoritos", "justNow": "En este momento", "lastViewed": "Visto por última vez", "emptyRecent": "Sin documentos recientes", "favoriteSpace": "Favoritos", "RecentSpace": "Reciente", "Spaces": "Espacios", "aiImageResponseLimit": "Se ha quedado sin respuestas de imágenes de IA.\n\nVaya a Configuración -> Plan -> Haga clic en AI Max para obtener más respuestas de imágenes de IA", "purchaseStorageSpace": "Comprar espacio de almacenamiento", "purchaseAIResponse": "Compra " }, "notifications": { "export": { "markdown": "Nota exportada a Markdown", "path": "Documentos/flowy" } }, "contactsPage": { "title": "Contactos", "whatsHappening": "¿Qué está pasando esta semana?", "addContact": "Agregar Contacto", "editContact": "Editar Contacto" }, "button": { "ok": "OK", "confirm": "Confirmar", "done": "Hecho", "cancel": "Cancelar", "signIn": "Ingresar", "signOut": "Salir", "complete": "Completar", "save": "Guardar", "generate": "Generar", "esc": "ESC", "keep": "Mantener", "tryAgain": "Intentar otra vez", "discard": "Desechar", "replace": "Reemplazar", "insertBelow": "Insertar a continuación", "insertAbove": "Insertar arriba", "upload": "Subir", "edit": "Editar", "delete": "Borrar", "copy": "Copiar", "duplicate": "Duplicar", "putback": "Volver", "update": "Actualizar", "share": "Compartir", "removeFromFavorites": "Quitar de favoritos", "removeFromRecent": "Eliminar de los recientes", "addToFavorites": "Añadir a favoritos", "rename": "Renombrar", "helpCenter": "Centro de ayuda", "add": "Añadir", "yes": "Si", "no": "No", "clear": "Limpiar", "remove": "Eliminar", "dontRemove": "no quitar", "copyLink": "Copiar enlace", "align": "Alinear", "login": "Inciar sessión", "logout": "Cerrar sesión", "deleteAccount": "Borrar cuenta", "back": "Atrás", "signInGoogle": "Inicia sesión con Google", "signInGithub": "Iniciar sesión con Github", "signInDiscord": "Iniciar sesión con discordia", "more": "Más", "create": "Crear", "close": "Cerca", "next": "Próximo", "previous": "Anterior", "submit": "Entregar", "download": "Descargar", "backToHome": "Volver a Inicio" }, "label": { "welcome": "¡Bienvenido!", "firstName": "Primer nombre", "middleName": "Segundo nombre", "lastName": "Apellido", "stepX": "Paso {X}" }, "oAuth": { "err": { "failedTitle": "Imposible conectarse con sus credenciales.", "failedMsg": "Por favor asegurese haber completado el proceso de ingreso en su navegador." }, "google": { "title": "Ingresar con Google", "instruction1": "Para importar sus contactos de Google, debe autorizar esta aplicación usando su navegador web.", "instruction2": "Copie este código al presionar el icono o al seleccionar el texto:", "instruction3": "Navege al siguiente enlace en su navegador web, e ingrese el código anterior:", "instruction4": "Presione el botón de abajo cuando haya completado su registro:" } }, "settings": { "title": "Ajustes", "popupMenuItem": { "settings": "Ajustes" }, "accountPage": { "menuLabel": "Mi cuenta", "title": "Mi cuenta", "general": { "title": "Nombre de cuenta e imagen de perfil", "changeProfilePicture": "Cambiar" }, "email": { "title": "Email", "actions": { "change": "Cambiar email" } }, "login": { "title": "Inicio de sesión en la cuenta", "loginLabel": "Inicio de sesión", "logoutLabel": "Cerrar sesión" }, "description": "Personaliza tu perfil, administra la seguridad de la cuenta y las claves API de IA, o inicia sesión en tu cuenta." }, "menu": { "appearance": "Apariencia", "language": "Lenguaje", "user": "Usuario", "files": "archivos", "notifications": "Notificaciones", "open": "Abrir ajustes", "logout": "Cerrar session", "logoutPrompt": "¿Estás seguro de cerrar sesión?", "selfEncryptionLogoutPrompt": "¿Está seguro de que quieres cerrar la sesión? Asegúrese de haber copiado el codigo de cifrado.", "syncSetting": "Configuración de sincronización", "cloudSettings": "Configuración de la nube", "enableSync": "Habilitar sincronización", "enableEncrypt": "Cifrar datos", "cloudURL": "URL base", "invalidCloudURLScheme": "Esquema no válido", "cloudServerType": "servidor en la nube", "cloudServerTypeTip": "Tenga en cuenta que es posible que se cierre la sesión de su cuenta actual después de cambiar el servidor en la nube.", "cloudLocal": "Local", "cloudAppFlowy": "Nube @:appName", "cloudAppFlowySelfHost": "@:appName Cloud autohospedado", "appFlowyCloudUrlCanNotBeEmpty": "La URL de la nube no puede estar vacía", "clickToCopy": "Haga clic para copiar", "selfHostStart": "Si no tiene un servidor, consulte la", "selfHostContent": "documento", "selfHostEnd": "para obtener orientación sobre cómo autohospedar su propio servidor", "cloudURLHint": "Ingrese la URL base de su servidor", "cloudWSURL": "URL del conector web / websocket", "cloudWSURLHint": "Ingrese la dirección websocket de su servidor", "restartApp": "Reiniciar", "restartAppTip": "Reinicie la aplicación para que se apliquen los cambios. Tenga en cuenta que esto podría cerrar la sesión de su cuenta actual.", "changeServerTip": "Después de cambiar el servidor, debes hacer clic en el botón reiniciar para que los cambios surtan efecto", "enableEncryptPrompt": "Activa el cifrado para proteger tus datos con esta clave. Guárdalo de forma segura; una vez habilitado, no se puede desactivar. Si se pierden, tus datos se vuelven irrecuperables. Haz clic para copiar", "inputEncryptPrompt": "Introduzca su secreto de cifrado para", "clickToCopySecret": "Haga clic para copiar el código secreto", "configServerSetting": "Configure los ajustes de su servidor", "configServerGuide": "Después de seleccionar \"Inicio rápido\", navega hasta \"Configuración\" y luego \"Configuración de la nube\" para configurar tu servidor autoalojado.", "inputTextFieldHint": "Su código secreto", "historicalUserList": "Historial de inicio de sesión del usuario", "historicalUserListTooltip": "Esta lista muestra tus cuentas anónimas. Puedes hacer clic en una cuenta para ver sus detalles. Las cuentas anónimas se crean haciendo clic en el botón \"Comenzar\".", "openHistoricalUser": "Haga clic para abrir la cuenta anónima", "customPathPrompt": "Almacenar la carpeta de datos de @:appName en una carpeta sincronizada en la nube, como Google Drive, puede presentar riesgos. Si se accede a la base de datos dentro de esta carpeta o se modifica desde varias ubicaciones al mismo tiempo, se pueden producir conflictos de sincronización y posibles daños en los datos", "importAppFlowyData": "Importar datos desde una carpeta externa de @:appName", "importingAppFlowyDataTip": "La importación de datos está en curso. Por favor no cierres la aplicación.", "importAppFlowyDataDescription": "Copia los datos de una carpeta de datos externa de @:appName e impórtalos a la carpeta de datos actual de @:appName", "importSuccess": "Importó exitosamente la carpeta de datos de @:appName", "importFailed": "Error al importar la carpeta de datos de @:appName", "importGuide": "Para obtener más detalles, consulte el documento de referencia." }, "notifications": { "enableNotifications": { "label": "Permitir notificaciones", "hint": "Desactívelo para evitar que aparezcan notificaciones locales." } }, "appearance": { "resetSetting": "restaurar", "fontFamily": { "label": "Familia tipográfica", "search": "Buscar", "defaultFont": "Fuente predeterminada" }, "themeMode": { "label": "Theme Mode", "light": "Modo Claro", "dark": "Modo Oscuro", "system": "Adapt to System" }, "fontScaleFactor": "Factor de escala de fuente", "documentSettings": { "cursorColor": "Color del cursor del documento", "selectionColor": "Color de selección de documento", "hexEmptyError": "El color hexadecimal no puede estar vacío", "hexLengthError": "El valor hexadecimal debe tener 6 dígitos", "hexInvalidError": "Valor hexadecimal no válido", "opacityEmptyError": "La opacidad no puede estar vacía", "opacityRangeError": "La opacidad debe estar entre 1 y 100.", "app": "Aplicación", "flowy": "Flowy", "apply": "Aplicar" }, "layoutDirection": { "label": "Dirección de diseño", "hint": "Controla el flujo de contenido en tu pantalla, de izquierda a derecha o de derecha a izquierda.", "ltr": "LTR (de izquierda hacia derecha)", "rtl": "RTL (de derecha hacia izquierda)" }, "textDirection": { "label": "Dirección de texto predeterminada", "hint": "Especifica si el texto debe comenzar desde la izquierda o desde la derecha de forma predeterminada.", "ltr": "LTR (de izquierda hacia derecha)", "rtl": "RTL (de derecha hacia izquierda)", "auto": "AUTO", "fallback": "Igual que la dirección del diseño" }, "themeUpload": { "button": "Subir", "uploadTheme": "Subir tema", "description": "Cargue su propio tema @:appName usando el botón de abajo.", "loading": "Espere mientras validamos y cargamos su tema...", "uploadSuccess": "Su tema se ha subido con éxito", "deletionFailure": "No se pudo eliminar el tema. Intenta eliminarlo manualmente.", "filePickerDialogTitle": "Elija un archivo .flowy_plugin", "urlUploadFailure": "No se pudo abrir la URL: {}", "failure": "El tema que se cargó tenía un formato no válido." }, "theme": "Tema", "builtInsLabel": "Temas incorporados", "pluginsLabel": "Complementos", "dateFormat": { "label": "Formato de fecha", "local": "Local", "us": "US (Estados Unidos)", "iso": "ISO ", "friendly": "Amigable", "dmy": "D/M/A" }, "timeFormat": { "label": "Formato de la hora", "twelveHour": "doce horas", "twentyFourHour": "veinticuatro horas" }, "showNamingDialogWhenCreatingPage": "Mostrar diálogo de nombres al crear una página", "enableRTLToolbarItems": "Habilitar elementos de la barra de herramientas RTL", "members": { "title": "Configuración de miembros", "inviteMembers": "Invitar a los miembros", "sendInvite": "Enviar invitación", "copyInviteLink": "Copiar enlace de invitación", "label": "Miembros", "user": "Usuario", "role": "Rol", "removeFromWorkspace": "Quitar del espacio de trabajo", "owner": "Dueño", "guest": "Invitado", "member": "Miembro", "memberHintText": "Un miembro puede leer, comentar y editar páginas. Invitar a miembros e invitados.", "emailInvalidError": "Correo electrónico no válido, compruébalo y vuelve a intentarlo.", "emailSent": "Email enviado, por favor revisa la bandeja de entrada", "members": "miembros", "membersCount": { "zero": "{} miembros", "one": "{} miembro", "other": "{} miembros" }, "memberLimitExceeded": "Has alcanzado el límite máximo de miembros permitidos para tu cuenta. Si deseas agregar más miembros adicionales para continuar con tu trabajo, solicítalo en Github.", "failedToAddMember": "No se pudo agregar el miembro", "addMemberSuccess": "Miembro agregado con éxito", "removeMember": "Eliminar miembro", "areYouSureToRemoveMember": "¿Estás seguro de que deseas eliminar a este miembro?", "inviteMemberSuccess": "La invitación ha sido enviada con éxito", "failedToInviteMember": "No se pudo invitar al miembro" } }, "files": { "copy": "Copiar", "defaultLocation": "Leer archivos y ubicación de almacenamiento de datos", "exportData": "Exporta tus datos", "doubleTapToCopy": "Toca dos veces para copiar la ruta", "restoreLocation": "Restaurar a la ruta predeterminada de @:appName", "customizeLocation": "Abrir otra carpeta", "restartApp": "Reinicie la aplicación para que los cambios surtan efecto.", "exportDatabase": "Exportar base de datos", "selectFiles": "Seleccione los archivos que necesitan ser exportados", "selectAll": "Seleccionar todo", "deselectAll": "Deseleccionar todo", "createNewFolder": "Crear una nueva carpeta", "createNewFolderDesc": "Dinos dónde quieres almacenar tus datos", "defineWhereYourDataIsStored": "Defina dónde se almacenan sus datos", "open": "Abierto", "openFolder": "Abrir una carpeta existente", "openFolderDesc": "Léalo y escríbalo en su carpeta @:appName existente", "folderHintText": "nombre de la carpeta", "location": "Creando una nueva carpeta", "locationDesc": "Elija un nombre para su carpeta de datos de @:appName", "browser": "Navegar", "create": "Crear", "set": "Colocar", "folderPath": "Ruta para almacenar su carpeta", "locationCannotBeEmpty": "La ruta no puede estar vacía", "pathCopiedSnackbar": "¡La ruta de almacenamiento de archivos se copió al portapapeles!", "changeLocationTooltips": "Cambiar el directorio de datos", "change": "Cambiar", "openLocationTooltips": "Abrir otro directorio de datos", "openCurrentDataFolder": "Abrir el directorio de datos actual", "recoverLocationTooltips": "Restablecer al directorio de datos predeterminado de @:appName", "exportFileSuccess": "¡Exportar archivo con éxito!", "exportFileFail": "¡Error en la exportación del archivo!", "export": "Exportar", "clearCache": "Limpiar caché", "clearCacheDesc": "Si tienes problemas con las imágenes que no cargan o las fuentes no se muestran correctamente, intenta limpiar la caché. Esta acción no eliminará tus datos de usuario.", "areYouSureToClearCache": "¿Estás seguro de limpiar el caché?", "clearCacheSuccess": "¡Caché limpiada exitosamente!" }, "user": { "name": "Nombre", "email": "Correo electrónico", "tooltipSelectIcon": "Seleccionar icono", "selectAnIcon": "Seleccione un icono", "pleaseInputYourOpenAIKey": "por favor ingrese su clave AI", "clickToLogout": "Haga clic para cerrar la sesión del usuario actual.", "pleaseInputYourStabilityAIKey": "por favor ingrese su clave de estabilidad AI" }, "mobile": { "personalInfo": "Informacion personal", "username": "Nombre de usuario", "usernameEmptyError": "El nombre de usuario no puede estar vacío", "about": "Acerca de", "pushNotifications": "Notificaciones ", "support": "Soporte", "joinDiscord": "Únete a nosotros en Discord", "privacyPolicy": "política de privacidad", "userAgreement": "Acuerdo del Usuario", "termsAndConditions": "Términos y condiciones", "userprofileError": "No se pudo cargar el perfil de usuario", "userprofileErrorDescription": "Intente cerrar sesión y volver a iniciarla para comprobar si el problema persiste.", "selectLayout": "Seleccionar diseño", "selectStartingDay": "Seleccione el día de inicio", "version": "Versión" }, "shortcuts": { "shortcutsLabel": "Atajos", "command": "Commando", "keyBinding": "Atajos", "addNewCommand": "Añadir nuevo comando", "updateShortcutStep": "Presione la combinación de teclas deseada y presione ENTER", "shortcutIsAlreadyUsed": "Este atajo ya se utiliza para: {conflict}", "resetToDefault": "Restablecer los atajos predeterminados", "couldNotLoadErrorMsg": "No se pudieron cargar los atajos. Inténtalo de nuevo.", "couldNotSaveErrorMsg": "No se pudieron guardar los atajos. Inténtalo de nuevo.", "commands": { "codeBlockNewParagraph": "Insertar un nuevo párrafo al lado del bloque de código", "codeBlockIndentLines": "Insertar dos espacios al inicio de la línea en el bloque de código", "codeBlockOutdentLines": "Eliminar dos espacios al inicio de la línea en el bloque de código", "codeBlockAddTwoSpaces": "Insertar dos espacios en la posición del cursor en el bloque de código", "codeBlockSelectAll": "Seleccionar todo el contenido dentro de un bloque de código", "textAlignLeft": "Alinear texto a la izquierda", "textAlignCenter": "Alinear el texto al centro", "textAlignRight": "Alinear el texto a la derecha" } } }, "grid": { "deleteView": "¿Está seguro de que desea eliminar esta vista?", "createView": "Nuevo", "title": { "placeholder": "Sin títutlo" }, "settings": { "filter": "Filtrar", "sort": "Clasificar", "sortBy": "Ordenar por", "properties": "Propiedades", "reorderPropertiesTooltip": "Arrastre para reordenar las propiedades", "group": "Grupo", "addFilter": "Añadir filtro", "deleteFilter": "Eliminar filtro", "filterBy": "Filtrado por...", "typeAValue": "Escriba un valor...", "layout": "Disposición", "databaseLayout": "Disposición", "editView": "Editar vista", "boardSettings": "Configuración del tablero", "calendarSettings": "Configuración del calendario", "createView": "Nueva vista", "duplicateView": "Duplicar vista", "deleteView": "Eliminar vista", "numberOfVisibleFields": "{} mostrado", "Properties": "Propiedades", "viewList": "Vistas de base de datos" }, "textFilter": { "contains": "Contiene", "doesNotContain": "No contiene", "endsWith": "Termina con", "startWith": "Comienza con", "is": "Es", "isNot": "No es", "isEmpty": "Esta vacio", "isNotEmpty": "No está vacío", "choicechipPrefix": { "isNot": "No", "startWith": "Comienza con", "endWith": "Termina con", "isEmpty": "esta vacio", "isNotEmpty": "no está vacío" } }, "checkboxFilter": { "isChecked": "Comprobado", "isUnchecked": "Desenfrenado", "choicechipPrefix": { "is": "es" } }, "checklistFilter": { "isComplete": "Esta completo", "isIncomplted": "esta incompleto" }, "selectOptionFilter": { "is": "Es", "isNot": "No es", "contains": "Contiene", "doesNotContain": "No contiene", "isEmpty": "Esta vacio", "isNotEmpty": "No está vacío" }, "dateFilter": { "is": "Es", "before": "es antes", "after": "Es despues", "onOrBefore": "Es en o antes", "onOrAfter": "Es en o después", "between": "Está entre", "empty": "Esta vacio", "notEmpty": "No está vacío", "choicechipPrefix": { "before": "Antes", "after": "Después", "onOrBefore": "En o antes", "onOrAfter": "Sobre o después", "isEmpty": "Está vacio", "isNotEmpty": "No está vacío" } }, "numberFilter": { "equal": "Es igual", "notEqual": "No es igual", "lessThan": "Es menor que", "greaterThan": "Es mayor que", "lessThanOrEqualTo": "Es menor o igual que", "greaterThanOrEqualTo": "Es mayor o igual que", "isEmpty": "Está vacío", "isNotEmpty": "No está vacío" }, "field": { "hide": "Ocultar", "show": "Mostrar", "insertLeft": "Insertar a la Izquierda", "insertRight": "Insertar a la Derecha", "duplicate": "Duplicar", "delete": "Eliminar", "wrapCellContent": "Ajustar texto", "clear": "Borrar celdas", "textFieldName": "Texto", "checkboxFieldName": "Casilla de verificación", "dateFieldName": "Fecha", "updatedAtFieldName": "Última hora de modificación", "createdAtFieldName": "tiempo creado", "numberFieldName": "Números", "singleSelectFieldName": "Seleccionar", "multiSelectFieldName": "Selección múltiple", "urlFieldName": "URL", "checklistFieldName": "Lista de Verificación", "relationFieldName": "Relación", "numberFormat": "Formato numérico", "dateFormat": "Formato de fecha", "includeTime": "Incluir tiempo", "isRange": "Fecha final", "dateFormatFriendly": "Mes Día, Año", "dateFormatISO": "Año-Mes-Día", "dateFormatLocal": "Mes/Día/Año", "dateFormatUS": "Año/Mes/Día", "dateFormatDayMonthYear": "Día mes año", "timeFormat": "Formato de tiempo", "invalidTimeFormat": "Formato de tiempo inválido", "timeFormatTwelveHour": "12 horas", "timeFormatTwentyFourHour": "24 horas", "clearDate": "Borrar fecha", "dateTime": "Fecha y hora", "startDateTime": "Fecha de inicio hora", "endDateTime": "Fecha de finalización hora", "failedToLoadDate": "No se pudo cargar el valor de la fecha", "selectTime": "Seleccionar hora", "selectDate": "Seleccione fecha", "visibility": "Visibilidad", "propertyType": "Tipo de propiedad", "addSelectOption": "Añadir una opción", "typeANewOption": "Escribe una nueva opción", "optionTitle": "Opciones", "addOption": "Añadir opción", "editProperty": "Editar propiedad", "newProperty": "Nueva propiedad", "deleteFieldPromptMessage": "¿Está seguro? Esta propiedad será eliminada", "clearFieldPromptMessage": "¿Estás seguro? Se vaciarán todas las celdas de esta columna.", "newColumn": "Nueva columna", "format": "Formato", "reminderOnDateTooltip": "Esta celda tiene un recordatorio programado", "optionAlreadyExist": "La opción ya existe" }, "rowPage": { "newField": "Agregar un nuevo campo", "fieldDragElementTooltip": "Haga clic para abrir el menú", "showHiddenFields": { "one": "Mostrar {count} campo oculto", "many": "Mostrar {count} campos ocultos", "other": "Mostrar {count} campos ocultos" }, "hideHiddenFields": { "one": "Ocultar {count} campo oculto", "many": "Ocultar {count} campos ocultos", "other": "Ocultar {count} campos ocultos" } }, "sort": { "ascending": "ascendente", "descending": "Descendente", "by": "Por", "empty": "Sin ordenamiento activo", "cannotFindCreatableField": "No se encuentra un campo adecuado para ordenar", "deleteAllSorts": "Eliminar todos filtros", "addSort": "Agregar clasificación", "removeSorting": "¿Le gustaría eliminar la ordenación?", "fieldInUse": "Ya estás ordenando por este campo", "deleteSort": "Borrar ordenar" }, "row": { "duplicate": "Duplicar", "delete": "Eliminar", "titlePlaceholder": "Intitulado", "textPlaceholder": "Vacío", "copyProperty": "Propiedad copiada al portapapeles", "count": "Contar", "newRow": "Nueva fila", "action": "Acción", "add": "Haga clic en agregar a continuación", "drag": "Arrastre para mover", "dragAndClick": "Arrastra para mover, haz clic para abrir el menú", "insertRecordAbove": "Insertar registro arriba", "insertRecordBelow": "Insertar registro a continuación" }, "selectOption": { "create": "Crear", "purpleColor": "Morado", "pinkColor": "Rosa", "lightPinkColor": "Rosa Claro", "orangeColor": "Naranja", "yellowColor": "Amarillo", "limeColor": "Lima", "greenColor": "Verde", "aquaColor": "Agua", "blueColor": "Azul", "deleteTag": "Borrar etiqueta", "colorPanelTitle": "Colores", "panelTitle": "Selecciona una opción o crea una", "searchOption": "Buscar una opción", "searchOrCreateOption": "Buscar o crear una opción...", "createNew": "Crear un nuevo", "orSelectOne": "O seleccione una opción", "typeANewOption": "Escribe una nueva opción", "tagName": "Nombre de etiqueta" }, "checklist": { "taskHint": "Descripción de la tarea", "addNew": "Agregar un elemento", "submitNewTask": "Crear", "hideComplete": "Ocultar tareas completadas", "showComplete": "Mostrar todas las tareas" }, "url": { "launch": "Abrir en el navegador", "copy": "Copiar URL", "textFieldHint": "Introduce una URL" }, "relation": { "relatedDatabasePlaceLabel": "Base de datos relacionada", "relatedDatabasePlaceholder": "Ninguno", "inRelatedDatabase": "En", "rowSearchTextFieldPlaceholder": "Buscar", "noDatabaseSelected": "No se seleccionó ninguna base de datos, seleccione una primero de la lista a continuación:", "emptySearchResult": "No se encontraron registros", "linkedRowListLabel": "{count} filas vinculadas", "unlinkedRowListLabel": "Vincular otra fila" }, "menuName": "Cuadrícula", "referencedGridPrefix": "Vista de", "calculate": "Calcular", "calculationTypeLabel": { "none": "Ninguno", "average": "Promedio", "max": "Max", "median": "Media", "min": "Min", "sum": "Suma", "count": "Contar", "countEmpty": "Contar vacío", "countEmptyShort": "VACÍO", "countNonEmpty": "Contar no vacíos", "countNonEmptyShort": "RELLENO" } }, "document": { "menuName": "Documento", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Seleccione un tablero para vincular", "createANewBoard": "Crear un nuevo tablero" }, "grid": { "selectAGridToLinkTo": "Seleccione una cuadrícula para vincular", "createANewGrid": "Crear una nueva cuadrícula" }, "calendar": { "selectACalendarToLinkTo": "Seleccione un calendario para vincular", "createANewCalendar": "Crear un nuevo Calendario" }, "document": { "selectADocumentToLinkTo": "Seleccione un documento para vincularlo" } }, "selectionMenu": { "outline": "Describir", "codeBlock": "Bloque de código" }, "plugins": { "referencedBoard": "Tablero referenciado", "referencedGrid": "Cuadrícula referenciada", "referencedCalendar": "Calendario referenciado", "referencedDocument": "Documento referenciado", "autoGeneratorMenuItemName": "Escritor de AI", "autoGeneratorTitleName": "AI: Pídele a AI que escriba cualquier cosa...", "autoGeneratorLearnMore": "Aprende más", "autoGeneratorGenerate": "Generar", "autoGeneratorHintText": "Pregúntale a AI...", "autoGeneratorCantGetOpenAIKey": "No puedo obtener la clave de AI", "autoGeneratorRewrite": "Volver a escribir", "smartEdit": "Asistentes de IA", "smartEditFixSpelling": "Corregir ortografía", "warning": "⚠️ Las respuestas de la IA pueden ser inexactas o engañosas.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "Mejorar la escritura", "smartEditMakeLonger": "hacer más largo", "smartEditCouldNotFetchResult": "No se pudo obtener el resultado de AI", "smartEditCouldNotFetchKey": "No se pudo obtener la clave de AI", "smartEditDisabled": "Conectar AI en Configuración", "discardResponse": "¿Quieres descartar las respuestas de IA?", "createInlineMathEquation": "Crear ecuación", "fonts": "Tipo de letra", "insertDate": "Insertar fecha", "emoji": "Emoji", "toggleList": "Alternar lista", "quoteList": "Lista de citas", "numberedList": "lista numerada", "bulletedList": "Lista con viñetas", "todoList": "Lista de tareas", "callout": "Callout", "cover": { "changeCover": "Cubierta de cambio", "colors": "Colores", "images": "Imágenes", "clearAll": "Limpiar todo", "abstract": "Abstracto", "addCover": "Agregar portada", "addLocalImage": "Agregar imagen local", "invalidImageUrl": "URL de imagen no válida", "failedToAddImageToGallery": "No se pudo agregar la imagen a la galería", "enterImageUrl": "Introduce la URL de la imagen", "add": "Agregar", "back": "Atrás", "saveToGallery": "Guardar en la galería", "removeIcon": "Eliminar icono", "pasteImageUrl": "Pegar URL de imagen", "or": "O", "pickFromFiles": "Seleccionar de archivos", "couldNotFetchImage": "No se pudo obtener la imagen", "imageSavingFailed": "Error al guardar la imagen", "addIcon": "Añadir icono", "changeIcon": "Cambiar el ícono", "coverRemoveAlert": "Se eliminará de la portada después de que se elimine.", "alertDialogConfirmation": "¿Estás seguro de que quieres continuar?" }, "mathEquation": { "name": "Ecuación matemática", "addMathEquation": "Agregar ecuación matemática", "editMathEquation": "Editar ecuación matemática" }, "optionAction": { "click": "Hacer clic", "toOpenMenu": " para abrir menú", "delete": "Borrar", "duplicate": "Duplicar", "turnInto": "convertir en", "moveUp": "Ascender", "moveDown": "Mover hacia abajo", "color": "Color", "align": "Alinear", "left": "Izquierda", "center": "Centro", "right": "Bien", "defaultColor": "Por defecto", "depth": "Profundidad" }, "image": { "addAnImage": "Añadir una imagen", "copiedToPasteBoard": "El enlace de la imagen se ha copiado en el portapapeles.", "imageUploadFailed": "Error al subir la imagen", "errorCode": "Código de error" }, "urlPreview": { "copiedToPasteBoard": "El enlace ha sido copiado al portapapeles.", "convertToLink": "Convertir en enlace incrustado" }, "outline": { "addHeadingToCreateOutline": "Agregue encabezados para crear una tabla de contenido.", "noMatchHeadings": "No se han encontrado títulos coincidentes." }, "table": { "addAfter": "Agregar después", "addBefore": "Añadir antes", "delete": "Borrar", "clear": "Borrar contenido", "duplicate": "Duplicar", "bgColor": "Color de fondo" }, "contextMenu": { "copy": "Copiar", "cut": "Cortar", "paste": "Pegar" }, "action": "Comportamiento", "database": { "selectDataSource": "Seleccionar fuente de datos", "noDataSource": "No hay fuente de datos", "selectADataSource": "Seleccione una fuente de datos", "toContinue": "continuar", "newDatabase": "Nueva base de datos", "linkToDatabase": "Enlace a la base de datos" }, "date": "Fecha", "openAI": "IA abierta" }, "outlineBlock": { "placeholder": "Tabla de contenidos" }, "textBlock": { "placeholder": "Escriba '/' para comandos" }, "title": { "placeholder": "Intitulado" }, "imageBlock": { "placeholder": "Haga clic para agregar imagen", "upload": { "label": "Subir", "placeholder": "Haga clic para cargar la imagen" }, "url": { "label": "URL de la imagen", "placeholder": "Introduce la URL de la imagen" }, "ai": { "label": "Generar imagen desde AI", "placeholder": "Ingrese el prompt para que AI genere una imagen" }, "stability_ai": { "label": "Generar imagen desde Stability AI", "placeholder": "Ingrese el prompt para que Stability AI genere una imagen" }, "support": "El límite de tamaño de la imagen es de 5 MB. Formatos admitidos: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Imagen inválida", "invalidImageSize": "El tamaño de la imagen debe ser inferior a 5 MB", "invalidImageFormat": "El formato de imagen no es compatible. Formatos admitidos: JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL de imagen no válida", "noImage": "El fichero o directorio no existe" }, "embedLink": { "label": "Insertar enlace", "placeholder": "Pega o escribe el enlace de una imagen" }, "unsplash": { "label": "Desempaquetar" }, "searchForAnImage": "Buscar una imagen", "pleaseInputYourOpenAIKey": "ingresa tu clave AI en la página de Configuración", "saveImageToGallery": "Guardar imagen", "failedToAddImageToGallery": "No se pudo agregar la imagen a la galería", "successToAddImageToGallery": "Imagen agregada a la galería con éxito", "unableToLoadImage": "No se puede cargar la imagen", "maximumImageSize": "El tamaño máximo de imagen es de 10 MB", "uploadImageErrorImageSizeTooBig": "El tamaño de la imagen debe ser inferior a 10 MB.", "imageIsUploading": "La imagen se está subiendo", "pleaseInputYourStabilityAIKey": "ingresa tu clave de Stability AI en la página de configuración" }, "codeBlock": { "language": { "label": "Idioma", "placeholder": "Seleccione el idioma", "auto": "Auto" }, "copyTooltip": "Copiar el contenido del bloque de código.", "searchLanguageHint": "Buscar un idioma", "codeCopiedSnackbar": "¡Código copiado al portapapeles!" }, "inlineLink": { "placeholder": "Pegar o escribir un enlace", "openInNewTab": "Abrir en una pestaña nueva", "copyLink": "Copiar link", "removeLink": "Remover enlace", "url": { "label": "URL del enlace", "placeholder": "Introduzca la URL del enlace" }, "title": { "label": "Título del enlace", "placeholder": "Introduce el título del enlace" } }, "mention": { "placeholder": "Menciona una persona, una página o fecha...", "page": { "label": "Enlace a la página", "tooltip": "Haga clic para abrir la página" }, "deleted": "Eliminado", "deletedContent": "Este contenido no existe o ha sido eliminado." }, "toolbar": { "resetToDefaultFont": "Restablecer a los predeterminados" }, "errorBlock": { "theBlockIsNotSupported": "La versión actual no admite este bloque.", "blockContentHasBeenCopied": "El contenido del bloque ha sido copiado." }, "mobilePageSelector": { "title": "Seleccionar página", "failedToLoad": "No se pudo cargar la lista de páginas", "noPagesFound": "No se encontraron páginas" } }, "board": { "column": { "createNewCard": "Nuevo", "renameGroupTooltip": "Presione para cambiar el nombre del grupo", "createNewColumn": "Agregar un nuevo grupo", "addToColumnTopTooltip": "Añade una nueva tarjeta en la parte superior", "addToColumnBottomTooltip": "Añade una nueva tarjeta en la parte inferior.", "renameColumn": "Renombrar", "hideColumn": "Ocultar", "newGroup": "Nuevo grupo", "deleteColumn": "Borrar", "deleteColumnConfirmation": "Esto eliminará este grupo y todas las tarjetas que contiene.\n¿Estás seguro de que quieres continuar?", "groupActions": "Acciones grupales" }, "hiddenGroupSection": { "sectionTitle": "Grupos ocultos", "collapseTooltip": "Ocultar los grupos ocultos", "expandTooltip": "Ver los grupos ocultos" }, "cardDetail": "Detalle de la tarjeta", "cardActions": "Acciones de tarjeta", "cardDuplicated": "La tarjeta ha sido duplicada.", "cardDeleted": "La tarjeta ha sido eliminada", "showOnCard": "Mostrar en el detalle de la tarjeta", "setting": "Configuración", "propertyName": "Nombre de la propiedad", "menuName": "Junta", "showUngrouped": "Mostrar elementos desagrupados", "ungroupedButtonText": "Desagrupados", "ungroupedButtonTooltip": "Contiene tarjetas que no pertenecen a ningún grupo.", "ungroupedItemsTitle": "Haga clic para agregar al tablero", "groupBy": "Agrupar por", "referencedBoardPrefix": "Vista de", "notesTooltip": "Notas en el interior", "mobile": { "editURL": "Editar URL", "showGroup": "Mostrar grupo", "showGroupContent": "¿Estás seguro de que quieres mostrar este grupo en el tablero?", "failedToLoad": "No se pudo cargar la vista del tablero" } }, "calendar": { "menuName": "Calendario", "defaultNewCalendarTitle": "Intitulado", "newEventButtonTooltip": "Agregar un nuevo evento", "navigation": { "today": "Hoy", "jumpToday": "Saltar a hoy", "previousMonth": "Mes anterior", "nextMonth": "Próximo mes" }, "mobileEventScreen": { "emptyTitle": "No hay eventos", "emptyBody": "Presiona el botón más para crear un evento en este día." }, "settings": { "showWeekNumbers": "Mostrar números de semana", "showWeekends": "Mostrar fines de semana", "firstDayOfWeek": "Empieza la semana en", "layoutDateField": "Diseño de calendario por", "changeLayoutDateField": "Cambiar campo de diseño", "noDateTitle": "Sin cita", "unscheduledEventsTitle": "Eventos no programados", "clickToAdd": "Haga clic para agregar al calendario", "name": "Diseño de calendario", "noDateHint": "Los eventos no programados se mostrarán aquí" }, "referencedCalendarPrefix": "Vista de", "quickJumpYear": "Ir a", "duplicateEvent": "duplicar evento" }, "errorDialog": { "title": "Error de flujo de aplicación", "howToFixFallback": "¡Lamentamos las molestias! Envíe un problema en nuestra página de GitHub que describa su error.", "github": "Ver en GitHub" }, "search": { "label": "Buscar", "placeholder": { "actions": "Acciones de búsqueda..." } }, "message": { "copy": { "success": "¡Copiado!", "fail": "no se puede copiar" } }, "unSupportBlock": "La versión actual no es compatible con este bloque.", "views": { "deleteContentTitle": "¿Está seguro de que desea eliminar {pageType}?", "deleteContentCaption": "si elimina este {pageType}, puede restaurarlo de la papelera." }, "colors": { "custom": "Costumbre", "default": "Por defecto", "red": "Rojo", "orange": "Naranja", "yellow": "Amarillo", "green": "Verde", "blue": "Azul", "purple": "Púrpura", "pink": "Rosa", "brown": "Marrón", "gray": "Gris" }, "emoji": { "emojiTab": "emojis", "search": "buscar emojis", "noRecent": "Ningún emoji reciente", "noEmojiFound": "No se encontraron emojis", "filter": "Filtrar", "random": "Aleatorio", "selectSkinTone": "Selecciona el tono de piel", "remove": "Quitar emojis", "categories": { "smileys": "Emoticonos y emociones", "people": "Personas y cuerpo", "animals": "Animales y naturaleza", "food": "Comida y bebida", "activities": "Actividades", "places": "Viajes y lugares", "objects": "Objetos", "symbols": "Símbolos", "flags": "Banderas", "nature": "Naturaleza", "frequentlyUsed": "Usado frecuentemente" }, "skinTone": { "default": "Por defecto", "light": "Luz", "mediumLight": "Luz media", "medium": "Medio", "mediumDark": "Medio-Oscuro", "dark": "Oscuro" } }, "inlineActions": { "noResults": "No hay resultados", "recentPages": "Paginas recientes", "pageReference": "Referencia de página", "docReference": "Referencia de documento", "boardReference": "Referencia del tablero", "calReference": "Referencia del calendario", "gridReference": "Referencia de cuadrícula", "date": "Fecha", "reminder": { "groupTitle": "Recordatorio", "shortKeyword": "recordar" } }, "datePicker": { "dateTimeFormatTooltip": "Cambiar el formato de fecha y hora en la configuración", "dateFormat": "Formato de fecha", "includeTime": "incluir tiempo", "isRange": "Fecha final", "timeFormat": "Formato de tiempo", "clearDate": "Borrar fecha", "reminderLabel": "Recordatorio", "selectReminder": "Seleccionar recordatorio", "reminderOptions": { "none": "Ninguno", "atTimeOfEvent": "Hora del evento", "fiveMinsBefore": "5 minutos antes", "tenMinsBefore": "10 minutos antes", "fifteenMinsBefore": "15 minutos antes", "thirtyMinsBefore": "30 minutos antes", "oneHourBefore": "1 hora antes", "twoHoursBefore": "2 horas antes", "onDayOfEvent": "El día del evento", "oneDayBefore": "1 dia antes", "twoDaysBefore": "2 dias antes", "oneWeekBefore": "1 semana antes", "custom": "Personalizado" } }, "relativeDates": { "yesterday": "Ayer", "today": "Hoy", "tomorrow": "Mañana", "oneWeek": "1 semana" }, "notificationHub": { "title": "Notificaciones", "mobile": { "title": "Actualizaciones" }, "emptyTitle": "¡Todo al día!", "emptyBody": "No hay notificaciones ni acciones pendientes. Disfruta de la calma.", "tabs": { "inbox": "Bandeja de entrada", "upcoming": "Próximo" }, "actions": { "markAllRead": "marcar todo como leido", "showAll": "Todo", "showUnreads": "No leído" }, "filters": { "ascending": "Ascendente", "descending": "Descendente", "groupByDate": "Agrupar por fecha", "showUnreadsOnly": "Mostrar solo no leídos", "resetToDefault": "Restablecen a los predeterminados" } }, "reminderNotification": { "title": "Recordatorio", "message": "¡Recuerda comprobar esto antes de que lo olvides!", "tooltipDelete": "Borrar", "tooltipMarkRead": "Marcar como leído", "tooltipMarkUnread": "marcar como no leído" }, "findAndReplace": { "find": "Encontrar", "previousMatch": "Partido anterior", "nextMatch": "Próximo partido", "close": "Cerca", "replace": "Reemplazar", "replaceAll": "Reemplaza todo", "noResult": "No hay resultados", "caseSensitive": "Distingue mayúsculas y minúsculas", "searchMore": "Busca para encontrar más resultados" }, "error": { "weAreSorry": "Lo lamentamos", "loadingViewError": "Estamos teniendo problemas para cargar esta vista. Verifica tu conexión a Internet, actualiza la aplicación y no dudes en comunicarte con el equipo si el problema continúa." }, "editor": { "bold": "Negrita", "bulletedList": "Lista con viñetas", "bulletedListShortForm": "Con viñetas", "checkbox": "Checkbox", "embedCode": "Código de inserción", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Destacar", "color": "Color", "image": "Imagen", "date": "Fecha", "page": "Página", "italic": "Itálico", "link": "Enlace", "numberedList": "Lista numerada", "numberedListShortForm": "Numerado", "quote": "Cita", "strikethrough": "Tachado", "text": "Texto", "underline": "Subrayar", "fontColorDefault": "Por defecto", "fontColorGray": "Gris", "fontColorBrown": "Marrón", "fontColorOrange": "Naranja", "fontColorYellow": "Amarillo", "fontColorGreen": "Verde", "fontColorBlue": "Azul", "fontColorPurple": "Púrpura", "fontColorPink": "Rosa", "fontColorRed": "Rojo", "backgroundColorDefault": "Fondo predeterminado", "backgroundColorGray": "fondo gris", "backgroundColorBrown": "fondo marrón", "backgroundColorOrange": "fondo naranja", "backgroundColorYellow": "fondo amarillo", "backgroundColorGreen": "fondo verde", "backgroundColorBlue": "Fondo azul", "backgroundColorPurple": "fondo morado", "backgroundColorPink": "fondo rosa", "backgroundColorRed": "fondo rojo", "backgroundColorLime": "Fondo lima", "backgroundColorAqua": "Fondo aguamarina", "done": "Hecho", "cancel": "Cancelar", "tint1": "Tono 1", "tint2": "Tono 2", "tint3": "Tono 3", "tint4": "Tono 4", "tint5": "Tono 5", "tint6": "Tono 6", "tint7": "Tono 7", "tint8": "Tono 8", "tint9": "Tono 9", "lightLightTint1": "Morado", "lightLightTint2": "Rosa", "lightLightTint3": "Rosa claro", "lightLightTint4": "Naranja", "lightLightTint5": "Amarillo", "lightLightTint6": "Lima", "lightLightTint7": "Verde", "lightLightTint8": "Aqua", "lightLightTint9": "Azul", "urlHint": "URL", "mobileHeading1": "Encabezado 1", "mobileHeading2": "Encabezado 2", "mobileHeading3": "Encabezado 3", "textColor": "Color de texto", "backgroundColor": "Color de fondo", "addYourLink": "Añadir enlace", "openLink": "Abrir enlace", "copyLink": "Copiar enlace", "removeLink": "Quitar enlace", "editLink": "Editar enlace", "linkText": "Texto", "linkTextHint": "Introduce un texto", "linkAddressHint": "Introduce una URL", "highlightColor": "Color de resaltado", "clearHighlightColor": "Quitar color de resaltado", "customColor": "Color personalizado", "hexValue": "Valor Hex", "opacity": "Transparencia", "resetToDefaultColor": "Reestablecer color predeterminado", "ltr": "LTR", "rtl": "RTL", "auto": "Auto", "cut": "Cortar", "copy": "Copiar", "paste": "Pegar", "find": "Buscar", "select": "Seleccionar", "selectAll": "Seleccionar todo", "previousMatch": "Resultado anterior", "nextMatch": "Siguiente resultado", "closeFind": "Cerrar", "replace": "Reemplazar", "replaceAll": "Reemplazar todo", "regex": "Expresión regular", "caseSensitive": "Distingue mayúsculas y minúsculas", "uploadImage": "Subir imagen", "urlImage": "URL de la Imagen", "incorrectLink": "Enlace incorrecto", "upload": "Subir", "chooseImage": "Elige una imagen", "loading": "Cargando", "imageLoadFailed": "Error al subir la imagen", "divider": "Divisor", "table": "Tabla", "colAddBefore": "Añadir antes", "rowAddBefore": "Añadir antes", "colAddAfter": "Añadir después", "rowAddAfter": "Añadir después", "colRemove": "Quitar", "rowRemove": "Quitar", "colDuplicate": "Duplicar", "rowDuplicate": "Duplicar", "colClear": "Borrar contenido", "rowClear": "Borrar contenido", "slashPlaceHolder": "Escribe '/' para insertar un bloque o comienza a escribir", "typeSomething": "Escribe algo...", "toggleListShortForm": "Alternar", "quoteListShortForm": "Cita", "mathEquationShortForm": "Fórmula", "codeBlockShortForm": "Código" }, "favorite": { "noFavorite": "Ninguna página favorita", "noFavoriteHintText": "Desliza la página hacia la izquierda para agregarla a tus favoritos" }, "cardDetails": { "notesPlaceholder": "Escribe una / para insertar un bloque o comienza a escribir" }, "blockPlaceholders": { "todoList": "Por hacer", "bulletList": "Lista", "numberList": "Lista", "quote": "Cita", "heading": "Título {}" }, "titleBar": { "pageIcon": "Icono de página", "language": "Idioma", "font": "Fuente", "actions": "Acciones", "date": "Fecha", "addField": "Añadir campo", "userIcon": "Icono de usuario" }, "noLogFiles": "No hay archivos de registro", "newSettings": { "myAccount": { "title": "Mi cuenta", "subtitle": "Personaliza tu perfil, administra la seguridad de la cuenta, abre claves IA o inicia sesión en tu cuenta.", "profileLabel": "Nombre de cuenta e imagen de perfil", "profileNamePlaceholder": "Introduce tu nombre", "accountSecurity": "Seguridad de la cuenta", "2FA": "Autenticación de 2 pasos", "aiKeys": "Claves IA", "accountLogin": "Inicio de sesión de la cuenta", "updateNameError": "No se pudo actualizar el nombre", "updateIconError": "No se pudo actualizar el ícono", "deleteAccount": { "title": "Borrar cuenta", "subtitle": "Elimina permanentemente tu cuenta y todos tus datos.", "deleteMyAccount": "Borrar mi cuenta", "dialogTitle": "Borrar cuenta", "dialogContent1": "¿Estás seguro de que deseas eliminar permanentemente tu cuenta?", "dialogContent2": "Esta acción no se puede deshacer y eliminará el acceso a todos los espacios de equipo, borrará toda tu cuenta, incluidos los espacios de trabajo privados, y lo eliminará de todos los espacios de trabajo compartidos." } }, "workplace": { "name": "Espacio de trabajo", "title": "Configuración del espacio de trabajo", "subtitle": "Personaliza la apariencia, el tema, la fuente, el diseño del texto, la fecha, la hora y el idioma de tu espacio de trabajo.", "workplaceName": "Nombre del espacio de trabajo", "workplaceNamePlaceholder": "Introduce el nombre del espacio de trabajo", "workplaceIcon": "Icono del espacio de trabajo", "workplaceIconSubtitle": "Sube una imagen o usa un emoji para tu espacio de trabajo. El icono se mostrará en la barra lateral y en las notificaciones.", "renameError": "Error al renombrar el espacio de trabajo", "updateIconError": "Error al actualizar el ícono", "appearance": { "name": "Apariencia", "themeMode": { "auto": "Auto", "light": "Claro", "dark": "Oscuro" }, "language": "Idioma" } }, "syncState": { "syncing": "Sincronización", "synced": "Sincronizado", "noNetworkConnected": "Ninguna red conectada" } }, "pageStyle": { "title": "Estilo de página", "layout": "Disposición", "coverImage": "Imagen de portada", "pageIcon": "Icono de página", "colors": "Colores", "gradient": "Degradado", "backgroundImage": "Imagen de fondo", "presets": "Preajustes", "photo": "Foto", "unsplash": "Desempaquetar", "pageCover": "Portada de página", "none": "Ninguno" }, "commandPalette": { "placeholder": "Escribe para buscar vistas...", "bestMatches": "Mejores resultados", "recentHistory": "Historial reciente", "navigateHint": "para navegar", "loadingTooltip": "Buscando resultados...", "betaLabel": "BETA", "betaTooltip": "Actualmente solo admitimos la búsqueda de páginas.", "fromTrashHint": "De la papelera" } } ================================================ FILE: frontend/resources/translations/eu-ES.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Ni", "welcomeText": "Ongietorri @:appName -ra", "githubStarText": "Izarra GitHub-en", "subscribeNewsletterText": "Harpidetu buletinera", "letsGoButtonText": "Hasi", "title": "Izenburua", "youCanAlso": "Zuk ere egin dezakezu", "and": "eta", "blockActions": { "addBelowTooltip": "Egin klik behean gehitzeko", "addAboveCmd": "Alt+klik", "addAboveMacCmd": "Aukera+klik", "addAboveTooltip": "goian gehitzeko" }, "signUp": { "buttonText": "Izena eman", "title": "Izena eman @:appName -ra", "getStartedText": "Hasi", "emptyPasswordError": "Pasahitzak ezin du hutsik egon", "repeatPasswordEmptyError": "Pasahitz errepikapenak ezin du hutsik egon", "unmatchedPasswordError": "Pasahitz errepikapena ez da berdina", "alreadyHaveAnAccount": "Kontu bat duzu jada?", "emailHint": "Emaila", "passwordHint": "Pasahitza", "repeatPasswordHint": "Pasahitza errepikatu" }, "signIn": { "loginTitle": "Hasi saioa @:appName -n", "loginButtonText": "Hasi saioa", "buttonText": "Sartu", "forgotPassword": "Pasahitza ahaztu duzu?", "emailHint": "Emaila", "passwordHint": "Pasahitza", "dontHaveAnAccount": "Ez daukazu konturik?", "repeatPasswordEmptyError": "Pasahitz errepikapenak ezin du hutsik egon", "unmatchedPasswordError": "Pasahitz errepikapena ez da berdina", "loginAsGuestButtonText": "Hasi" }, "workspace": { "create": "Lan-eremua", "hint": "lan-eremua", "notFoundError": "Lan-eremurik ez da aurkitu" }, "shareAction": { "buttonText": "Konpartitu", "workInProgress": "Laister", "markdown": "Markdown", "copyLink": "Esteka kopiatu" }, "moreAction": { "small": "txikia", "medium": "ertaina", "large": "handia", "fontSize": "Letra tamaina", "import": "Inportatu", "moreOptions": "Aukera gehiago" }, "importPanel": { "textAndMarkdown": "Testua eta Markdown", "documentFromV010": "v0.1.0 dokumentua", "databaseFromV010": "0.1.0 bertsioko datu-basea", "csv": "CSV", "database": "Datu-basea" }, "disclosureAction": { "rename": "Izena aldatu", "delete": "Ezabatu", "duplicate": "Duplikatu", "openNewTab": "Ireki fitxa berri batean" }, "blankPageTitle": "Orri zuria", "newPageText": "Orri berria", "trash": { "text": "Zaborrontzia", "restoreAll": "Guztia berreskuratu", "deleteAll": "Guztia ezabatu", "pageHeader": { "fileName": "Fitxategi izena", "lastModified": "Azken aldaketa", "created": "Sortua" }, "confirmDeleteAll": { "title": "Ziur zaborrontziko orrialde guztiak ezabatuko dituzula?", "caption": "Ekintza hau ezin da desegin." }, "confirmRestoreAll": { "title": "Ziur zaborrontziko orrialde guztiak leheneratuko dituzula?", "caption": "Ekintza hau ezin da desegin." } }, "deletePagePrompt": { "text": "Orri hau zaborrontzian dago", "restore": "Orria berreskuratu", "deletePermanent": "Betirako ezabatu" }, "dialogCreatePageNameHint": "Orriaren izena", "questionBubble": { "shortcuts": "Lasterbideak", "whatsNew": "Ze berri?", "markdown": "Markdown", "debug": { "name": "Debug informazioa", "success": "Debug informazioa kopiatu da!", "fail": "Ezin izan da debug informazioa kopiatu" }, "feedback": "Iritzia", "help": "Laguntza" }, "menuAppHeader": { "addPageTooltip": "Gehitu orri bat", "defaultNewPageName": "Izenbururik ez", "renameDialog": "Izena aldatu" }, "toolbar": { "undo": "Desegin", "redo": "Berregin", "bold": "Lodia", "italic": "Etzana", "underline": "Azpimarratua", "strike": "Markatua", "numList": "Zembakidun zerrenda", "bulletList": "Buletetako zerrenda", "checkList": "Egiaztapen zerrenda", "inlineCode": "Lerroko kodea", "quote": "Aipamena", "header": "Goiburua", "highlight": "Nabarmendu", "color": "Kolorea", "addLink": "Gehitu esteka", "link": "Esteka" }, "tooltip": { "lightMode": "Modu argira aldatu", "darkMode": "Modu ilunera aldatu", "openAsPage": "Orri gisa ireki", "addNewRow": "Ilara berri bat gehitu", "openMenu": "Egin klik menua irekitzeko", "dragRow": "Luze sakatu errenkada berrantolatzeko", "viewDataBase": "Ikusi datu-basea", "referencePage": "{izena} honi erreferentzia egiten zaio", "addBlockBelow": "Gehitu bloke bat behean" }, "sideBar": { "closeSidebar": "Alboko barra itxi", "openSidebar": "Alboko barra ireki" }, "notifications": { "export": { "markdown": "Oharra markdownera esportatuta", "path": "Documents/flowy" } }, "contactsPage": { "title": "Kontaktuak", "whatsHappening": "Ze berri aste honetan?", "addContact": "Kontaktua gehitu", "editContact": "Kontaktua editatu" }, "button": { "ok": "OK", "done": "Eginda", "cancel": "Ezteztatu", "signIn": "Saioa hasi", "signOut": "Saioa itxi", "complete": "Burututa", "save": "Gorde", "generate": "Sortu", "esc": "ESC", "keep": "Gorde", "tryAgain": "Saiatu berriro", "discard": "Baztertu", "replace": "Ordezkatu", "insertBelow": "Txertatu behean", "upload": "Kargatu", "edit": "Editatu", "delete": "Ezabatu", "duplicate": "Bikoiztu", "putback": "Jarri Atzera" }, "label": { "welcome": "Ongi etorri!", "firstName": "Izena", "middleName": "Bigarren izena", "lastName": "Abizena", "stepX": "{X}. pausoa" }, "oAuth": { "err": { "failedTitle": "Ezin izan da kontura sartu.", "failedMsg": "Mesedez, ziurtatu zure arakatzailean saioa hasteko prozesua amaitu duzula." }, "google": { "title": "GOOGLE SAIOA HASI", "instruction1": "Zure Google Kontaktuak inportatzeko, zure web arakatzailea erabiliz aplikazio hau baimendu beharko duzu.", "instruction2": "Kopiatu kode hau ikonoan klik eginez edo testua hautatuz:", "instruction3": "Nabigatu zure web arakatzailean esteka honetara eta idatzi goiko kodea:", "instruction4": "Sakatu beheko botoia erregistroa amaitzean:" } }, "settings": { "title": "Ezarpenak", "menu": { "appearance": "Itxura", "language": "Hizkuntza", "user": "Erabiltzailea", "files": "Fitxategiak", "open": "Ezarpenak ireki" }, "appearance": { "fontFamily": { "label": "Letra-tipoen familia", "search": "Bilatu" }, "themeMode": { "label": "Itxura modua", "light": "Modu argia", "dark": "Modu iluna", "system": "Zure sistemara moldatu" }, "themeUpload": { "button": "Kargatu", "description": "Kargatu zure @:appName gaia beheko botoia erabiliz.", "loading": "Mesedez, itxaron zure gaia balioztatzen eta kargatzen dugun bitartean...", "uploadSuccess": "Zure gaia behar bezala kargatu da", "deletionFailure": "Ezin izan da gaia ezabatu. Saiatu eskuz ezabatzen.", "filePickerDialogTitle": "Aukeratu .flowy_plugin fitxategi bat", "urlUploadFailure": "Ezin izan da ireki URLa: {}", "failure": "Kargatu zen gaiak formatu baliogabea zuen." }, "theme": "Itxura", "builtInsLabel": "Eraikitako gaiak", "pluginsLabel": "Pluginak" }, "files": { "copy": "Kopiatu", "defaultLocation": "Non gordetzen diren zure datuak", "exportData": "Esportatu zure datuak", "doubleTapToCopy": "Sakatu birritan bidea kopiatzeko", "restoreLocation": "Berrezarri @:appName-ren biden lehenetsira", "customizeLocation": "Beste karpeta bat ireki", "restartApp": "Mesedez, berrabiarazi aplikazioa aldaketak indarrean egon daitezen.", "exportDatabase": "Datubasea exportatu", "selectFiles": "Aukeratu exportatu nahi dituzun fitxategiak", "selectAll": "Hautatu guztiak", "deselectAll": "Deshautatu guztiak", "createNewFolder": "Karpeta berri bat sortu", "createNewFolderDesc": "Non nahi dituzu datuak gorde ...", "defineWhereYourDataIsStored": "Zure datuak non gordetzen diren zehaztu", "open": "Oreki", "openFolder": "Ireki karpeta bat", "openFolderDesc": "Irakurri eta idatzi zure @:appName karpetan...", "folderHintText": "karpetaren izena", "location": "Karpeta berria sortzen", "locationDesc": "Aukeratu izen bat @:appName datuen karpetarako", "browser": "Bilatu", "create": "Sortu", "set": "Ezarri", "folderPath": "Zure karpeta gordetzeko bidea", "locationCannotBeEmpty": "Bideak ezin du hutsa egon", "pathCopiedSnackbar": "Fitxategiak biltegiratzeko bidea arbelean kopiatu da!", "changeLocationTooltips": "Aldatu datuen direktorioa", "change": "Aldatu", "openLocationTooltips": "Ireki beste datu-direktorio bat", "openCurrentDataFolder": "Ireki uneko datuen direktorioa", "recoverLocationTooltips": "Berrezarri @:appNameren datu-direktorio lehenetsira", "exportFileSuccess": "Esportatu fitxategia behar bezala!", "exportFileFail": "Ezin izan da esportatu fitxategia!", "export": "Esportatu" }, "user": { "name": "Izena", "selectAnIcon": "Hautatu ikono bat", "pleaseInputYourOpenAIKey": "mesedez sartu zure AI gakoa" } }, "grid": { "deleteView": "Ziur ikuspegi hau ezabatu nahi duzula?", "createView": "Berria", "settings": { "filter": "Filtroa", "sort": "Ordenatu", "sortBy": "Ordenatu honekiko", "properties": "Propietateak", "reorderPropertiesTooltip": "Arrastatu propietateak berrantolatzeko", "group": "Taldea", "addFilter": "Gehitu iragazkia", "deleteFilter": "Ezabatu iragazkia", "filterBy": "Iragazi arabera...", "typeAValue": "Idatzi balio bat...", "layout": "Diseinua", "databaseLayout": "Diseinua", "Properties": "Propietateak" }, "textFilter": { "contains": "Dauka", "doesNotContain": "Ez dauka", "endsWith": "Honez amaitzen da", "startWith": "Honez hasten da", "is": "da", "isNot": "Ez da", "isEmpty": "Hutsa dago", "isNotEmpty": "Ez dago hutsik", "choicechipPrefix": { "isNot": "Ez da", "startWith": "Honez hasten da", "endWith": "Honez amaitzen da", "isEmpty": "hutsik dago", "isNotEmpty": "ez dago hutsik" } }, "checkboxFilter": { "isChecked": "Egiaztatuta", "isUnchecked": "Desmarkatua", "choicechipPrefix": { "is": "da", "da": "da" } }, "checklistFilter": { "isComplete": "osatu da", "isIncomplted": "osatu gabe dago" }, "selectOptionFilter": { "is": "da", "isNot": "Ez da", "contains": "Duen", "doesNotContain": "Ez dauka", "isEmpty": "Hutsa dago", "isNotEmpty": "Ez dago hutsik" }, "field": { "hide": "Ezkutatu", "insertLeft": "Txertatu ezkerrera", "insertRight": "Txertatu eskuinera", "duplicate": "Bikoiztu", "delete": "Ezabatu", "textFieldName": "Testua", "checkboxFieldName": "Markatu laukia", "dateFieldName": "Data", "updatedAtFieldName": "Azken aldaketaren ordua", "createdAtFieldName": "Sortutako denbora", "numberFieldName": "Zenbakiak", "singleSelectFieldName": "Hautatu", "multiSelectFieldName": "Multi-hautaketa", "urlFieldName": "URL", "checklistFieldName": "Kontrol zerrenda", "numberFormat": "Zenbaki formatua", "dateFormat": "Data formatua", "includeTime": "Sartu ordua", "dateFormatFriendly": "Hilabete Eguna, Urtea", "dateFormatISO": "Urtea-Hilabetea-Eguna", "dateFormatLocal": "Hilabetea/Eguna/Urtea", "dateFormatUS": "Urtea/Hilabetea/Eguna", "dateFormatDayMonthYear": "Eguna/Hilabetea/Urtea", "timeFormat": "Denboraren formatua", "invalidTimeFormat": "Formatu baliogabea", "timeFormatTwelveHour": "12 ordu", "timeFormatTwentyFourHour": "24 ordu", "addSelectOption": "Gehitu aukera bat", "optionTitle": "Aukerak", "addOption": "Gehitu aukera", "editProperty": "Editatu propietatea", "newProperty": "Zutabe berria", "deleteFieldPromptMessage": "Ziur al zaude? Propietate hau ezabatu egingo da" }, "sort": { "ascending": "Gorarantz", "descending": "Jaisten", "addSort": "Gehitu ordenatu", "deleteSort": "Ezabatu ordena" }, "row": { "duplicate": "Bikoiztu", "delete": "Ezabatu", "textPlaceholder": "Hutsik", "copyProperty": "Propietatea arbelean kopiatu da", "count": "Kontatu", "newRow": "Errenkada berria", "action": "Ekintza" }, "selectOption": { "create": "Sortu", "purpleColor": "Purple", "pinkColor": "Rosa", "lightPinkColor": "Arrosa argia", "orangeColor": "Laranja", "yellowColor": "Horia", "limeColor": "Lima", "greenColor": "Berdea", "aquaColor": "Aqua", "blueColor": "Urdina", "deleteTag": "Ezabatu etiketa", "colorPanelTitle": "Koloreak", "panelTitle": "Hautatu aukera bat edo sortu bat", "searchOption": "Aukera bat bilatu" }, "checklist": { "addNew": "Gehitu elementu bat" }, "menuName": "Sareta", "referencedGridPrefix": "-ren ikuspegia" }, "document": { "menuName": "Dokumentua", "date": { "timeHintTextInTwelveHour": "01:00etan", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Hautatu estekatzeko taula bat", "createANewBoard": "Sortu taula berri bat" }, "grid": { "selectAGridToLinkTo": "Hautatu estekatzeko sare bat", "createANewGrid": "Sortu sare berri bat" }, "calendar": { "selectACalendarToLinkTo": "Hautatu estekatzeko egutegia", "createANewCalendar": "Sortu egutegi berri bat" } }, "selectionMenu": { "outline": "Eskema" }, "plugins": { "referencedBoard": "Erreferentziazko Batzordea", "referencedGrid": "Erreferentziazko Sarea", "referencedCalendar": "Erreferentziazko Egutegia", "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Eskatu AIri edozer idazteko...", "autoGeneratorLearnMore": "Gehiago ikasi", "autoGeneratorGenerate": "Sortu", "autoGeneratorHintText": "Galdetu AI...", "autoGeneratorCantGetOpenAIKey": "Ezin da lortu AI gakoa", "autoGeneratorRewrite": "Berridatzi", "smartEdit": "AI Laguntzaileak", "aI": "AI", "smartEditFixSpelling": "Ortografia konpondu", "warning": "⚠️ AI erantzunak okerrak edo engainagarriak izan daitezke.", "smartEditSummarize": "Laburtu", "smartEditImproveWriting": "Hobetu idazkera", "smartEditMakeLonger": "Luzatu", "smartEditCouldNotFetchResult": "Ezin izan da emaitzarik eskuratu AI-tik", "smartEditCouldNotFetchKey": "Ezin izan da AI gakoa eskuratu", "smartEditDisabled": "Konektatu AI Ezarpenetan", "discardResponse": "AI erantzunak baztertu nahi dituzu?", "createInlineMathEquation": "Sortu ekuazioa", "toggleList": "Aldatu zerrenda", "cover": { "changeCover": "Aldatu Azala", "colors": "Koloreak", "images": "Irudiak", "clearAll": "Garbitu guztiak", "abstract": "Abstraktua", "addCover": "Gehitu estalkia", "addLocalImage": "Gehitu tokiko irudia", "invalidImageUrl": "Irudiaren URL baliogabea", "failedToAddImageToGallery": "Ezin izan da irudia galerian gehitu", "enterImageUrl": "Sartu irudiaren URLa", "add": "Gehitu", "back": "Itzuli", "saveToGallery": "Gorde galerian", "removeIcon": "Kendu ikonoa", "pasteImageUrl": "Itsatsi irudiaren URLa", "or": "EDO", "pickFromFiles": "Aukeratu fitxategietatik", "couldNotFetchImage": "Ezin izan da irudia eskuratu", "imageSavingFailed": "Irudiak gordetzean huts egin du", "addIcon": "Gehitu ikonoa", "coverRemoveAlert": "Ezabatu ondoren estalkitik kenduko da.", "alertDialogConfirmation": "Ziur al zaude, jarraitu nahi duzula?" }, "mathEquation": { "addMathEquation": "Gehitu Matematika Ekuazioa", "editMathEquation": "Editatu Matematika Ekuazioa" }, "optionAction": { "click": "Egin klik", "toOpenMenu": " menua irekitzeko", "delete": "Ezabatu", "duplicate": "Bikoiztu", "turnInto": "Bihurtu", "moveUp": "Gora", "moveDown": "Mugitu behera", "color": "Kolore", "align": "Lerrokatu", "left": "Ezkerra", "center": "Zentroa", "right": "Eskuin", "defaultColor": "Lehenetsia" }, "image": { "copiedToPasteBoard": "Irudiaren esteka arbelean kopiatu da" }, "outline": { "addHeadingToCreateOutline": "Gehitu goiburuak aurkibidea sortzeko." } }, "textBlock": { "placeholder": "Idatzi '/' komandoetarako" }, "title": { "placeholder": "Izenbururik gabe" }, "imageBlock": { "placeholder": "Egin klik irudia gehitzeko", "upload": { "label": "Kargatu", "placeholder": "Egin klik irudia igotzeko" }, "url": { "label": "Irudiaren URLa", "placeholder": "Sartu irudiaren URLa" }, "support": "Irudiaren tamainaren muga 5 MB da. Onartutako formatuak: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Irudi baliogabea", "invalidImageSize": "Irudiaren tamaina 5 MB baino txikiagoa izan behar da", "invalidImageFormat": "Irudi formatua ez da onartzen. Onartutako formatuak: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Irudiaren URL baliogabea" } }, "codeBlock": { "language": { "label": "Hizkuntza", "placeholder": "Hautatu hizkuntza" } }, "inlineLink": { "placeholder": "Itsatsi edo idatzi esteka bat", "url": { "label": "Estekaren URLa", "placeholder": "Sartu estekaren URLa" }, "title": { "label": "Estekaren izenburua", "placeholder": "Sartu estekaren izenburua" } }, "data": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" } }, "board": { "column": { "createNewCard": "Berria" }, "menuName": "Kontseilua", "referencedBoardPrefix": "-ren ikuspegia", "mobile": { "showGroup": "Erakutsi taldea", "showGroupContent": "Ziur talde hau arbelean erakutsi nahi duzula?", "failedToLoad": "Ezin izan da kargatu arbelaren ikuspegia" } }, "calendar": { "menuName": "Egutegia", "defaultNewCalendarTitle": "Izenbururik gabe", "navigation": { "today": "Gaur", "jumpToday": "Gaurko egunera salto egin", "previousMonth": "Aurreko hilabetea", "nextMonth": "Hurrengo hilabetea" }, "settings": { "showWeekNumbers": "Erakutsi asteko zenbakiak", "showWeekends": "Asteburuetan erakutsi", "firstDayOfWeek": "Hasi astea", "layoutDateField": "Egutegiaren diseinua arabera", "noDateTitle": "Datarik ez", "clickToAdd": "Egin klik egutegian gehitzeko", "name": "Egutegiaren diseinua", "noDateHint": "Programatu gabeko gertaerak hemen agertuko dira" }, "referencedCalendarPrefix": "-ren ikuspegia" }, "errorDialog": { "title": "@:appName errorea", "howToFixFallback": "Sentitzen dugu eragozpenak! Bidali zure errorea deskribatzen duen arazo bat gure GitHub orrian.", "github": "Ikusi GitHub-en" }, "search": { "label": "Bilatu", "placeholder": { "actions": "Bilatu ekintzak..." } }, "message": { "copy": { "success": "Kopiatu!", "fail": "Ezin da kopiatu" } }, "unSupportBlock": "Uneko bertsioak ez du bloke hau onartzen.", "views": { "deleteContentTitle": "Ziur {pageType} ezabatu nahi duzula?", "deleteContentCaption": "{pageType} hau ezabatzen baduzu, zaborrontzitik leheneratu dezakezu." } } ================================================ FILE: frontend/resources/translations/fa.json ================================================ { "appName": "AppFlowy", "defaultUsername": "من", "welcomeText": "به @:appName خوش آمدید", "welcomeTo": "خوش آمدید به", "githubStarText": "به گیت‌هاب ما ستاره دهید", "subscribeNewsletterText": "اشتراک در خبرنامه", "letsGoButtonText": "شروع کنید", "title": "عنوان", "youCanAlso": "همچنین می‌توانید", "and": "و", "failedToOpenUrl": "خطا در بازکردن نشانی وب: {}", "blockActions": { "addBelowTooltip": "برای افزودن در زیر کلیک کنید", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "برای افزودن در بالا", "dragTooltip": "برای حرکت بکشید", "openMenuTooltip": "برای باز کردن منو کلیک کنید" }, "signUp": { "buttonText": "ثبت نام", "title": "ثبت نام در @:appName", "getStartedText": "شروع کنید", "emptyPasswordError": "رمز عبور نمی تواند خالی باشد", "repeatPasswordEmptyError": "تکرار رمز عبور نمی‌تواند خالی باشد", "unmatchedPasswordError": "تکرار رمز عبور مشابه رمز عبور نیست", "alreadyHaveAnAccount": "از قبل حساب دارید؟", "emailHint": "ایمیل", "passwordHint": "رمز عبور", "repeatPasswordHint": "تکرار رمز عبور", "signUpWith": "ثبت نام با:" }, "signIn": { "loginTitle": "ورود به @:appName", "loginButtonText": "ورود", "loginStartWithAnonymous": "ادامه دادن با یک جلسه ناشناس", "continueAnonymousUser": "ادامه دادن به صورت کاربر مهمان", "buttonText": "ورود", "signingInText": "در حال ورود...", "forgotPassword": "رمز عبور را فراموش کرده اید؟", "emailHint": "ایمیل", "passwordHint": "رمز عبور", "dontHaveAnAccount": "آیا حساب کاربری ندارید؟", "createAccount": "ساخت حساب کاربری", "repeatPasswordEmptyError": "تکرار رمز عبور نمی‌تواند خالی باشد", "unmatchedPasswordError": "تکرار رمز عبور مشابه رمز عبور نیست", "syncPromptMessage": "همگام سازی داده ها ممکن است کمی طول بکشد. لطفا این صفحه را نبندید", "or": "یا", "signInWithGoogle": "ادامه دادن با گوگل", "signInWithGithub": "ادامه دادن با گیتهاب", "signInWithDiscord": "ادامه دادن با دیسکورد", "signInWithApple": "ادامه دادن با اپل", "continueAnotherWay": "ادامه دادن از طریق دیگر", "signUpWithGoogle": "ثبت نام با گوگل", "signUpWithGithub": "ثبت نام با گیتهاب", "signUpWithDiscord": "ثبت نام با دیسکورد", "signInWith": "ثبت نام با:", "signInWithEmail": "ادامه دادن با ایمیل", "signInWithMagicLink": "ادامه", "pleaseInputYourEmail": "لطفا آدرس ایمیل خود را وارد کنید", "settings": "تنظیمات", "invalidEmail": "لطفا یک آدرس ایمیل معتبر وارد کنید", "alreadyHaveAnAccount": "حساب کاربری دارید؟", "logIn": "ورود", "generalError": "مشکلی پیش آمد. لطفاً بعداً دوباره امتحان کنید", "anonymous": "ناشناس", "loginAsGuestButtonText": "شروع کنید" }, "workspace": { "chooseWorkspace": "فضای کار خود را انتخاب کنید", "defaultName": "فضای کار من", "create": "ایجاد فضای کار", "new": "فضای کار جدید", "learnMore": "بیشتر بدانید", "renameWorkspace": "حذف فضای کار", "workspaceNameCannotBeEmpty": "اسم فضای کار نمی‌تواند خالی باشد", "hint": "فضای کار", "notFoundError": "فضای کاری پیدا نشد" }, "shareAction": { "buttonText": "اشتراک گذاری", "workInProgress": "به زودی", "markdown": "Markdown", "copyLink": "کپی کردن لینک" }, "moreAction": { "small": "کوچک", "medium": "متوسط", "large": "بزرگ", "fontSize": "اندازه قلم", "import": "اضافه کردن", "moreOptions": "گزینه های بیشتر" }, "importPanel": { "textAndMarkdown": "Text & Markdown", "documentFromV010": "سند از نسخه 0.1.0", "databaseFromV010": "پایگاه داده از نسخه 0.1.0", "csv": "CSV", "database": "پایگاه داده" }, "disclosureAction": { "rename": "تغییر نام", "delete": "حذف", "duplicate": "تکرار کردن", "unfavorite": "حذف از موارد دلخواه", "favorite": "افزودن به موارد دلخواه", "openNewTab": "باز کردن در یک برگه جدید", "moveTo": "انتقال به", "addToFavorites": "افزودن به موارد دلخواه", "copyLink": "کپی کردن لینک" }, "blankPageTitle": "صفحه خالی", "newPageText": "صفحه جدید", "trash": { "text": "سطل زباله", "restoreAll": "بازیابی همه", "deleteAll": "حذف همه", "pageHeader": { "fileName": "نام فایل", "lastModified": "آخرین بازنگری", "created": "ایجاد شده" }, "confirmDeleteAll": { "title": "آیا می‌خواهید که همه صفحه‌ها را در سطل زباله حذف کنید؟", "caption": "این عمل قابل بازگشت نیست." }, "confirmRestoreAll": { "title": "آیا می‌خواهید که همه صفحه‌ها را در سطل زباله بازیابی کنید؟", "caption": "این عمل قابل بازگشت نیست." } }, "deletePagePrompt": { "text": "این صفحه در سطل زباله است", "restore": "بازیابی صفحه", "deletePermanent": "حذف دائمی" }, "dialogCreatePageNameHint": "نام صفحه", "questionBubble": { "shortcuts": "میانبرها", "whatsNew": "تازه‌ترین‌ها", "markdown": "Markdown", "debug": { "name": "اطلاعات اشکال‌زدایی", "success": "طلاعات اشکال زدایی در کلیپ بورد کپی شد!", "fail": "نمی توان اطلاعات اشکال زدایی را در کلیپ بورد کپی کرد" }, "feedback": "بازخورد", "help": "پشتیبانی و مستندات" }, "menuAppHeader": { "moreButtonToolTip": "حذف، تغییر نام، و موارد دیگر...", "addPageTooltip": "یک صفحه در داخل اضافه کنید", "defaultNewPageName": "بدون عنوان", "renameDialog": "تغییر نام" }, "toolbar": { "undo": "Undo", "redo": "Redo", "bold": "پررنگ", "italic": "ایتالیک", "underline": "با خط در زیر", "strike": "با خط در وسط", "numList": "فهرست شماره‌گذاری شده", "bulletList": "فهرست موردی", "checkList": "چک‌لیست", "inlineCode": "کد درونی", "quote": "Quote Block", "header": "سربرگ", "highlight": "برجسته کردن", "color": "رنگ", "addLink": "افزودن لینک", "link": "لینک" }, "tooltip": { "lightMode": "تغییر به مود روشن", "darkMode": "تغییر به مود تاریک", "openAsPage": "باز کردن به عنوان صفحه", "addNewRow": "اضافه کردن سطر جدید", "openMenu": "برای باز کردن منو کلیک کنید", "dragRow": "برای مرتب کردن مجدد ردیف فشار طولانی دهید", "viewDataBase": "مشاهده پایگاه داده", "referencePage": "این {name} ارجاع داده شده است", "addBlockBelow": "یک بلوک در زیر اضافه کنید" }, "sideBar": { "closeSidebar": "بستن نوار کناری", "openSidebar": "باز کردن نوار کناری", "personal": "شخصی", "favorites": "مورد علاقه", "clickToHidePersonal": "برای پنهان کردن قسمت شخصی کلیک کنید", "clickToHideFavorites": "برای پنهان کردن بخش دلخواه کلیک کنید", "addAPage": "افزودن یک صفحه" }, "notifications": { "export": { "markdown": "متن به یادداشت تبدیل شود", "path": "Documents/flowy" } }, "contactsPage": { "title": "مخاطبین", "whatsHappening": "این هفته چه اتفاقی می‌افتد؟", "addContact": "افزودن مخاطب", "editContact": "ویرایش مخاطب" }, "button": { "ok": "باشه", "done": "انجام شد", "cancel": "لغو", "signIn": "ورود", "signOut": "خروج", "complete": "کامل شد", "save": "ذخیره‌سازی", "generate": "تولید‌کردن", "esc": "ESC", "keep": "نگه داشتن", "tryAgain": "دوباره تلاش کنید", "discard": "در نظر نگرفتن", "replace": "جایگزین کردن", "insertBelow": "جاگذاری در پایین", "upload": "بارگذاری", "edit": "ویرایش", "delete": "حذف کردن", "duplicate": "تکرار کردن", "putback": "بازگشت" }, "label": { "welcome": "خوش آمدید!", "firstName": "نام", "middleName": "نام میانی", "lastName": "نام خانوادگی", "stepX": "Step {X}" }, "oAuth": { "err": { "failedTitle": "امکان اتصال به حساب شما وجود ندارد.", "failedMsg": "لطفا مطمئن شوید که فرآیند ورود را در مرورگر خود تکمیل کرده اید." }, "google": { "title": "ورود با اکانت گوگل", "instruction1": "برای دسترسی به مخاطبان خود در گوگل، می‌بایست به به این برنامه از طریق مرورگر خود دسترسی دهید.", "instruction2": "این کد را با کلیک کردن روی آیکون یا انتخاب متن در کلیپ بورد خود کپی کنید:", "instruction3": "به لینک زیر در مرورگر وب خود بروید و کد بالا را وارد کنید:", "instruction4": "پس از تکمیل ثبت نام، دکمه زیر را فشار دهید:" } }, "settings": { "title": "تنظیمات", "menu": { "appearance": "ظاهر برنامه", "language": "زبان‌ها", "user": "کاربر", "files": "فایل‌ها", "open": "باز کردن تنظیمات", "logout": "خروج", "logoutPrompt": "آیا مطمئن هستید که می‌خواهید خارج شوید؟", "syncSetting": "تنظیمات همگام‌سازی", "enableSync": "فعال کردن همگام‌سازی", "historicalUserList": "سابقه ورود کاربر", "historicalUserListTooltip": "این لیست اکانت‌های ناشناس شما را نمایش می‌دهد. می‌توانید روی یک حساب برای مشاهده جزییات آن کلیک کنید. حساب‌های ناشناس با کلیک کردن روی دکمه شروع‌کنید ایجاد می‌شوند", "openHistoricalUser": "برای باز کردن حساب ناشناس کلیک کنید" }, "appearance": { "resetSetting": "تنظیم کردن از اول", "fontFamily": { "label": "خانواده فونت", "search": "جستجو" }, "themeMode": { "label": "حالت تم", "light": "حالت روشن", "dark": "حالت تاریک", "system": "اعمال حالت" }, "themeUpload": { "button": "بارگذاری", "description": "تم قالب @:appName خود را با استفاده از دکمه زیر آپلود کنید.", "loading": "لطفاً منتظر بمانید تا تم قالب شما را اعتبارسنجی و آپلود کنیم...", "uploadSuccess": "تم قالب شما با موفقیت آپلود شد", "deletionFailure": "تم حذف نشد. سعی کنید آن را به صورت دستی حذف کنید.", "filePickerDialogTitle": "یک فایل .flowy_plugin را انتخاب کنید", "urlUploadFailure": "نشانی اینترنتی باز نشد: {}", "failure": "تم قالب آپلود شده نامعتبر است." }, "theme": "تم قالب", "builtInsLabel": "قالب‌های پیش‌ساخته", "pluginsLabel": "پلاگین‌ها" }, "files": { "copy": "کپی", "defaultLocation": "خواندن فایل‌ها و مکان ذخیره داده‌ها", "exportData": "از داده‌های خود خروجی بگیرید", "doubleTapToCopy": "برای کپی کردن دوبار کلیک کنید", "restoreLocation": "بازیابی به مسیر پیش فرض @:appName", "customizeLocation": "پوشه دیگری باز کنید", "restartApp": "لطفاً برنامه را مجدداً راه اندازی کنید تا تغییرات اعمال شوند.", "exportDatabase": "از پایگاه داده‌ها خروجی بگیرید", "selectFiles": "فایل‌هایی را که می‌خواهید از آنها خروجی بگیرید انتخاب کنید", "selectAll": "انتخاب همه", "deselectAll": "لغو انتخاب همه", "createNewFolder": "ایجاد یک پوشه جدید", "createNewFolderDesc": "کجا می خواهید داده‌های خود را ذخیره کنید", "defineWhereYourDataIsStored": "محل ذخیره داده های خود را مشخص کنید", "open": "باز کردن", "openFolder": "باز کردن یک پوشه موجود", "openFolderDesc": "خواندن و نوشتن آن در یک پوشه @:appName موجود", "folderHintText": "نام پوشه", "location": "ایجاد یک پوشه جدید", "locationDesc": "یک نام برای پوشه داده @:appName خود انتخاب کنید", "browser": "مرورگر", "create": "ایجاد کردن", "set": "تنظیم کردن", "folderPath": "مسیری برای ذخیره پوشه", "locationCannotBeEmpty": "مسیر نمی‌تواند خالی باشد", "pathCopiedSnackbar": "مسیر ذخیره‌سازی فایل در کلیپ‌بورد کپی شد!", "changeLocationTooltips": "تغییر دایرکتوری داده", "change": "تغییر", "openLocationTooltips": "باز کردن یک فهرست پوشه دیگر", "openCurrentDataFolder": "باز کردن فهرست پوشه فعلی", "recoverLocationTooltips": "بازنشانی به فهرست داده های پیش فرض @:appName", "exportFileSuccess": "خروجی گرفتن از فایل با موفقیت انجام شد.", "exportFileFail": "خروجی گرفتن از فایل انجام نشد!", "export": "خروجی گرفتن" }, "user": { "name": "نام", "selectAnIcon": "انتخاب یک آیکون", "pleaseInputYourOpenAIKey": "لطفا کلید AI خود را وارد کنید", "clickToLogout": "برای خروج از کاربر فعلی کلیک کنید" }, "shortcuts": { "shortcutsLabel": "میانبرها", "command": "دستور", "keyBinding": "میانبرهای کیبورد", "addNewCommand": "اضافه کردن فرمان جدید", "updateShortcutStep": "کلید ترکیبی دلخواه را فشار دهید و ENTER را فشار دهید", "shortcutIsAlreadyUsed": "این میانبر قبلاً برای: {conflict} استفاده شده است", "resetToDefault": "بازگشت به میانبرهای پیش‌فرض", "couldNotLoadErrorMsg": "میانبرها بارگذاری نشد، دوباره امتحان کنید", "couldNotSaveErrorMsg": "میانبرها ذخیره نشد، دوباره امتحان کنید" } }, "grid": { "deleteView": "آیا مطمئن هستید که می خواهید این نما را حذف کنید؟", "createView": "جدید", "settings": { "filter": "فیلتر", "sort": "مرتب کردن", "sortBy": "مرتب سازی بر اساس", "properties": "ویژگی‌ها", "reorderPropertiesTooltip": "برای مرتب کردن مجدد بکشید", "group": "گروه", "addFilter": "افزودن فیلتر", "deleteFilter": "حذف فیلتر", "filterBy": "فیلتر بر اساس...", "typeAValue": "یک مقدار را تایپ کنید...", "layout": "طرح‌بندی", "databaseLayout": "طرح‌بندی" }, "textFilter": { "contains": "شامل", "doesNotContain": "شامل نمی‌شود", "endsWith": "پایان با", "startWith": "شروع با", "is": "هست", "isNot": "نیست", "isEmpty": "خالی است", "isNotEmpty": "خالی نیست", "choicechipPrefix": { "isNot": "مخالف", "startWith": "شروع با", "endWith": "پایان با", "isEmpty": "خالی است", "isNotEmpty": "خالی نیست" } }, "checkboxFilter": { "isChecked": "بررسی شده", "isUnchecked": "بررسی نشده", "choicechipPrefix": { "is": "است" } }, "checklistFilter": { "isComplete": "کامل است", "isIncomplted": "کامل نیست" }, "selectOptionFilter": { "is": "است", "isNot": "نیست", "contains": "شامل", "doesNotContain": "شامل نیست", "isEmpty": "خالی است", "isNotEmpty": "خالی نیست" }, "field": { "hide": "پنهان کردن", "insertLeft": "درج در چپ", "insertRight": "درج در راست", "duplicate": "تکرار کردن", "delete": "حذف کردن", "textFieldName": "متن", "checkboxFieldName": "موارد انتخابی", "dateFieldName": "تاریخ", "updatedAtFieldName": "آخرین زمان بازنگری", "createdAtFieldName": "زمان ایجاد", "numberFieldName": "شماره‌ها", "singleSelectFieldName": "انتخاب", "multiSelectFieldName": "چند‌انتخابی", "urlFieldName": "نشانی اینترنتی", "checklistFieldName": "چک لیست", "numberFormat": "قالب شماره", "dateFormat": "قالب تاریخ", "includeTime": "شامل کردن زمان", "dateFormatFriendly": "Month Day, Year", "dateFormatISO": "Year-Month-Day", "dateFormatLocal": "Month/Day/Year", "dateFormatUS": "Year/Month/Day", "dateFormatDayMonthYear": "Day/Month/Year", "timeFormat": "قالب زمان", "invalidTimeFormat": "قالب نامعتبر", "timeFormatTwelveHour": "دوازده ساعته", "timeFormatTwentyFourHour": "بیست‌و‌چهار ساعته", "clearDate": "پاک کردن", "addSelectOption": "افزودن یک گزینه", "optionTitle": "گزینه‌ها", "addOption": "افزودن گزینه", "editProperty": "ویرایش ویژگی", "newProperty": "ویژگی جدید", "deleteFieldPromptMessage": "آیا مطمئن هستید؟ این ویژگی حذف خواهد شد", "newColumn": "ستون جدید" }, "sort": { "ascending": "صعودی", "descending": "نزولی", "deleteAllSorts": "حذف همه مرتب‌سازی‌ها", "addSort": "اضافه کردن مرتب‌سازی" }, "row": { "duplicate": "تکرار کردن", "delete": "حذف کردن", "textPlaceholder": "خالی", "copyProperty": "ویژگی در کلیپ‌بورد کپی شد.", "count": "شمارش", "newRow": "سطر جدید", "action": "اعمال" }, "selectOption": { "create": "ایجاد", "purpleColor": "بنفش", "pinkColor": "صورتی", "lightPinkColor": "صورتی روشن", "orangeColor": "نارنجی", "yellowColor": "زرد", "limeColor": "لیمویی", "greenColor": "سبز", "aquaColor": "آکوا", "blueColor": "آبی", "deleteTag": "حذف برچسب", "colorPanelTitle": "رنگ‌ها", "panelTitle": "یک گزینه انتخاب یا ایجاد کنید.", "searchOption": "جستجوی یک گزینه" }, "checklist": { "addNew": "یک مورد اضافه کنید" }, "menuName": "شبکه‌ای", "referencedGridPrefix": "نمایش" }, "document": { "menuName": "سند", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "یک بورد برای لینک کردن انتخاب کنید.", "createANewBoard": "ایجاد یک بورد جدید" }, "grid": { "selectAGridToLinkTo": "یک شبکه‌ نمایش برای لینک کردن انتخاب کنید.", "createANewGrid": "ایجاد یک شبکه نمایش جدید" }, "calendar": { "selectACalendarToLinkTo": "یک تقویم برای لینک کردن انتخاب کنید.", "createANewCalendar": "ایجاد یک تقویم جدید" } }, "selectionMenu": { "outline": "طرح کلی" }, "plugins": { "referencedBoard": "بورد مرجع", "referencedGrid": "شبکه‌نمایش مرجع", "referencedCalendar": "تقویم مرجع", "autoGeneratorMenuItemName": "AI نویسنده", "autoGeneratorTitleName": "از هوش مصنوعی بخواهید هر چیزی بنویسد...", "autoGeneratorLearnMore": "بیشتر بدانید", "autoGeneratorGenerate": "بنویس", "autoGeneratorHintText": "از AI بپرسید ...", "autoGeneratorCantGetOpenAIKey": "کلید AI را نمی توان دریافت کرد", "autoGeneratorRewrite": "بازنویس", "smartEdit": "دستیاران هوشمند", "smartEditFixSpelling": "اصلاح نگارش", "warning": "⚠️ پاسخ‌های هوش مصنوعی می‌توانند نادرست یا گمراه‌کننده باشند", "smartEditSummarize": "خلاصه‌نویسی", "smartEditImproveWriting": "بهبود نگارش", "smartEditMakeLonger": "به نوشته اضافه کن", "smartEditCouldNotFetchResult": "نتیجه‌ای از AI گرفته نشد", "smartEditCouldNotFetchKey": "کلید AI واکشی نشد", "smartEditDisabled": "به AI در تنظیمات وصل شوید", "discardResponse": "آیا می خواهید پاسخ های هوش مصنوعی را حذف کنید؟", "createInlineMathEquation": "ایجاد معادله", "toggleList": "Toggle لیست", "cover": { "changeCover": "تغییر جلد", "colors": "رنگ‌ها", "images": "تصویر‌ها", "clearAll": "پاک کردن همه", "abstract": "چکیده", "addCover": "افزودن جلد", "addLocalImage": "افزودن تصویر", "invalidImageUrl": "مسیر تصویر نامعتبر است", "failedToAddImageToGallery": "افزودن تصویر به گالری انجام نشد", "enterImageUrl": "مسیر تصویر را وارد کنید", "add": "افزودن", "back": "بازگشت", "saveToGallery": "ذخیره در گالری", "removeIcon": "حذف Icon", "pasteImageUrl": "وارد کردن مسیر تصویر", "or": "یا", "pickFromFiles": "انتخاب از فایل‌ها", "couldNotFetchImage": "تصویر واکشی نشد", "imageSavingFailed": "ذخیره تصویر انجام نشد", "addIcon": "افزودن آیکون", "coverRemoveAlert": "پس از حذف از روی جلد حذف خواهد شد.", "alertDialogConfirmation": "آیا مطمئن هستید که می‌خواهید ادامه دهید؟" }, "mathEquation": { "addMathEquation": "اضافه کردن معادله ریاضی", "editMathEquation": "ویرایش کردن معادله ریاضی" }, "optionAction": { "click": "کلیک کنید", "toOpenMenu": " برای باز کردن منو", "delete": "حذف کردن", "duplicate": "تکرار کردن", "turnInto": "تبدیل به", "moveUp": "بالا بردن", "moveDown": "پایین آوردن", "color": "رنگ", "align": "هم‌تراز کردن", "left": "چپ", "center": "وسط", "right": "راست", "defaultColor": "پیش فرض" }, "image": { "copiedToPasteBoard": "لینک تصویر در کلیپ‌بورد کپی شده است" }, "outline": { "addHeadingToCreateOutline": "برای ایجاد فهرست مطالب سر‌فصل‌ها را وارد کنید" }, "openAI": "AI" }, "textBlock": { "placeholder": "برای دستورات '/' را تایپ کنید" }, "title": { "placeholder": "بدون عنوان" }, "imageBlock": { "placeholder": "برای افزودن تصویر کلیک کنید", "upload": { "label": "بارگذاری", "placeholder": "برای بارگذاری تصویر کلیک کنید" }, "url": { "label": "لینک تصویر", "placeholder": "لینک تصویر را وارد کنید" }, "support": "محدودیت اندازه تصویر 5 مگابایت است. فرمت‌های پشتیبانی شده: JPEG، PNG، GIF، SVG", "error": { "invalidImage": "تصویر نامعتبر", "invalidImageSize": "اندازه تصویر باید کمتر از 5 مگابایت باشد", "invalidImageFormat": "فرمت تصویر پشتیبانی نمی‌شود. فرمت‌های پشتیبانی شده: JPEG، PNG، GIF، SVG", "invalidImageUrl": "مسیر تصویر نامعتبر است" } }, "codeBlock": { "language": { "label": "زبان", "placeholder": "انتخاب زبان" } }, "inlineLink": { "placeholder": "پیست کنید یا مسیر را تایپ کنید", "openInNewTab": "باز کردن در برگه جدید", "copyLink": "کپی لینک", "removeLink": "حذف لینک", "url": { "label": "لینک", "placeholder": "لینک را وارد کنید" }, "title": { "label": "عنوان لینک", "placeholder": "عنوان لینک را وارد کنید" } }, "mention": { "placeholder": "یک شخص یا یک صفحه یا تاریخ را ذکر کنید...", "page": { "label": "لینک به صفحه", "tooltip": "برای باز کردن صفحه کلیک کنید" } } }, "board": { "column": { "createNewCard": "ایجاد" }, "menuName": "بورد", "referencedBoardPrefix": "نمای", "mobile": { "showGroup": "نمایش گروه", "showGroupContent": "آیا مطمئن هستید که می خواهید این گروه را روی تابلو نشان دهید؟", "failedToLoad": "نمای تخته بارگیری نشد" } }, "calendar": { "menuName": "تقویم", "defaultNewCalendarTitle": "بدون عنوان", "navigation": { "today": "امروز", "jumpToday": "برو به امروز", "previousMonth": "ماه قبل", "nextMonth": "ماه بعد" }, "settings": { "showWeekNumbers": "نمایش اعداد هفته", "showWeekends": "نمایش تعطیلات آخر هفته", "firstDayOfWeek": "شروع هفته در", "layoutDateField": "طرح‌بندی تقویم با", "noDateTitle": "بدون تاریخ", "clickToAdd": "برای افزودن به تقویم کلیک کنید", "name": "طرح‌بندی تقویم", "noDateHint": "رویدادهای برنامه‌ریزی نشده در اینجا نشان داده می‌شوند" }, "referencedCalendarPrefix": "نمای" }, "errorDialog": { "title": "خطای @:appName", "howToFixFallback": "بابت مشکل پیش آمده متأسفیم! مشکل و شرح آن را در صفحه GitHub ما ارسال کنید.", "github": "مشاهده در GitHub" }, "search": { "label": "جستجو", "placeholder": { "actions": "جستجوی اعمال..." } }, "message": { "copy": { "success": "کپی شد!", "fail": "نمی‌توان کپی کرد" } }, "unSupportBlock": "نسخه فعلی از این بلوک پشتیبانی نمی‌کند.", "views": { "deleteContentTitle": "آیا مطمئن هستید که می‌خواهید {pageType} را حذف کنید؟", "deleteContentCaption": "اگر این {pageType} را حذف کنید، می‌توانید آن را از سطل زباله بازیابی کنید." }, "colors": { "custom": "سفارشی", "default": "پیش‌فرض", "red": "قرمز", "orange": "نارنجی", "yellow": "زرد", "green": "سبز", "blue": "آبی", "purple": "بنفش", "pink": "صورتی", "brown": "قهوه‌ای", "gray": "خاکستری" }, "emoji": { "filter": "فیلتر", "random": "تصادفی", "selectSkinTone": "انتخاب رنگ پوست", "remove": "حذف ایموجی", "categories": { "smileys": "لبخندی‌ها", "people": "آدمک‌ها", "animals": "حیوانات و طبیعت", "food": "غذا و نوشیدنی", "activities": "فعالیت‌ها", "places": "مسافرت", "objects": "اشیا", "symbols": "نماد‌ها", "flags": "پرچم‌ها", "nature": "طبیعت", "frequentlyUsed": "استفاده‌شده" } } } ================================================ FILE: frontend/resources/translations/fr-CA.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Moi", "welcomeText": "Bienvenue sur @:appName", "welcomeTo": "Bienvenue à", "githubStarText": "Étoiler sur GitHub", "subscribeNewsletterText": "Abonnez-vous à notre courriel", "letsGoButtonText": "Allons-y", "title": "Titre", "youCanAlso": "Vous pouvez aussi", "and": "et", "failedToOpenUrl": "Échec de l'ouverture de l'URL : {}", "blockActions": { "addBelowTooltip": "Cliquez pour ajouter ci-dessous", "addAboveCmd": "Alt+clic", "addAboveMacCmd": "Option+clic", "addAboveTooltip": "à ajouter au dessus", "dragTooltip": "Glisser pour déplacer", "openMenuTooltip": "Cliquez pour ouvrir le menu" }, "signUp": { "buttonText": "S'inscrire", "title": "S'inscrire à @:appName", "getStartedText": "Commencer", "emptyPasswordError": "Vous n'avez pas saisi votre mot de passe", "repeatPasswordEmptyError": "Vous n'avez pas ressaisi votre mot de passe", "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques", "alreadyHaveAnAccount": "Avez-vous déjà un compte?", "emailHint": "Courriel", "passwordHint": "Mot de passe", "repeatPasswordHint": "Ressaisir votre mot de passe", "signUpWith": "Se connecter avec:" }, "signIn": { "loginTitle": "Connexion à @:appName", "loginButtonText": "Connexion", "loginStartWithAnonymous": "Lancer avec une session anonyme", "continueAnonymousUser": "Continuer avec une session anonyme", "buttonText": "Se connecter", "signingInText": "Connexion en cours...", "forgotPassword": "Mot de passe oublié?", "emailHint": "Courriel", "passwordHint": "Mot de passe", "dontHaveAnAccount": "Vous n'avez pas encore de compte?", "createAccount": "Créer un compte", "repeatPasswordEmptyError": "Vous n'avez pas ressaisi votre mot de passe", "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques", "syncPromptMessage": "La synchronisation des données peut prendre un certain temps. Merci de ne pas fermer pas cette page.", "or": "OU", "signInWithGoogle": "Continuer avec Google", "signInWithGithub": "Continuer avec Github", "signInWithDiscord": "Continuer avec Discord", "signInWithApple": "Se connecter avec Apple", "continueAnotherWay": "Continuer avec une autre méthode", "signUpWithGoogle": "S'inscrire avec Google", "signUpWithGithub": "S'inscrire avec Github", "signUpWithDiscord": "S'inscrire avec Discord", "signInWith": "Se connecter avec:", "signInWithEmail": "Se connecter avec e-mail", "signInWithMagicLink": "Continuer", "signUpWithMagicLink": "S'inscrire avec un lien spécial", "pleaseInputYourEmail": "Veuillez entrer votre adresse e-mail", "settings": "Paramètres", "magicLinkSent": "Lien spécial envoyé à votre email, veuillez vérifier votre boîte de réception", "invalidEmail": "S'il vous plaît, mettez une adresse email valide", "alreadyHaveAnAccount": "Déjà un compte ?", "logIn": "Connexion", "generalError": "Une erreur s'est produite. Veuillez réessayer plus tard", "limitRateError": "Pour des raisons de sécurité, vous ne pouvez demander un lien spécial que toutes les 60 secondes", "magicLinkSentDescription": "Un lien spécial vous a été envoyé par e-mail. Cliquez sur le lien pour vous connecter. Le lien expirera dans 5 minutes.", "anonymous": "Anonyme", "LogInWithGoogle": "Se connecter avec Google", "LogInWithGithub": "Se connecter avec Github", "LogInWithDiscord": "Se connecter avec Discord", "loginAsGuestButtonText": "Commencer" }, "workspace": { "chooseWorkspace": "Choisissez votre espace de travail", "defaultName": "Mon espace de travail", "create": "Créer un espace de travail", "new": "Nouveau espace de travail", "importFromNotion": "Importer depuis Notion", "learnMore": "En savoir plus", "reset": "Réinitialiser l'espace de travail", "renameWorkspace": "Renommer l'espace de travail", "workspaceNameCannotBeEmpty": "Le nom de l'espace de travail ne peut être vide", "resetWorkspacePrompt": "La réinitialisation de l'espace de travail supprimera toutes les pages et données qu'elles contiennent. Êtes-vous sûr de vouloir réinitialiser l'espace de travail ? Alternativement, vous pouvez contacter l'équipe d'assistance pour restaurer l'espace de travail", "hint": "Espace de travail", "notFoundError": "Espace de travail introuvable", "failedToLoad": "Quelque chose s'est mal passé ! Échec du chargement de l'espace de travail. Essayez de fermer toute instance ouverte d'@:appName et réessayez.", "errorActions": { "reportIssue": "Signaler un problème", "reportIssueOnGithub": "Signaler un bug sur Github", "exportLogFiles": "Exporter les logs", "reachOut": "Contactez-nous sur Discord" }, "menuTitle": "Espaces de travail", "deleteWorkspaceHintText": "Êtes-vous sûr de vouloir supprimer l'espace de travail ? Cette action ne peut pas être annulée.", "createSuccess": "Espace de travail créé avec succès", "createFailed": "Échec de la création de l'espace de travail", "createLimitExceeded": "Vous avez atteint la limite maximale d'espace de travail autorisée pour votre compte. Si vous avez besoin d'espaces de travail supplémentaires pour continuer votre travail, veuillez en faire la demande sur Github.", "deleteSuccess": "Espace de travail supprimé avec succès", "deleteFailed": "Échec de la suppression de l'espace de travail", "openSuccess": "Ouverture de l'espace de travail réussie", "openFailed": "Échec de l'ouverture de l'espace de travail", "renameSuccess": "Espace de travail renommé avec succès", "renameFailed": "Échec du renommage de l'espace de travail", "updateIconSuccess": "L'icône de l'espace de travail a été mise à jour avec succès", "updateIconFailed": "La mise a jour de l'icône de l'espace de travail a échoué", "cannotDeleteTheOnlyWorkspace": "Impossible de supprimer le seul espace de travail", "fetchWorkspacesFailed": "Échec de la récupération des espaces de travail", "leaveCurrentWorkspace": "Quitter l'espace de travail", "leaveCurrentWorkspacePrompt": "Êtes-vous sûr de vouloir quitter l'espace de travail actuel ?" }, "shareAction": { "buttonText": "Partager", "workInProgress": "Bientôt disponible", "markdown": "Markdown", "html": "HTML", "clipboard": "Copier dans le presse-papier", "csv": "CSV", "copyLink": "Copier le lien", "publishToTheWeb": "Publier sur le Web", "publishToTheWebHint": "Créer un site Internet avec AppFlowy", "publish": "Partager", "unPublish": "Annuler la publication", "visitSite": "Visitez le site", "exportAsTab": "Exporter en tant que", "publishTab": "Partager", "shareTab": "Partager", "publishOnAppFlowy": "Partager sur AppFlowy" }, "moreAction": { "small": "petit", "medium": "moyen", "large": "grand", "fontSize": "Taille de police", "import": "Importer", "moreOptions": "Plus d'options" }, "importPanel": { "textAndMarkdown": "Texte et Markdown", "documentFromV010": "Document de la v0.1.0", "databaseFromV010": "Base de données à partir de la v0.1.0", "csv": "CSV", "database": "Base de données" }, "disclosureAction": { "rename": "Renommer", "delete": "Supprimer", "duplicate": "Dupliquer", "unfavorite": "Retirer des favoris", "favorite": "Ajouter aux favoris", "openNewTab": "Ouvrir dans un nouvel onglet", "moveTo": "Déplacer vers", "addToFavorites": "Ajouter aux Favoris", "copyLink": "Copier le lien" }, "blankPageTitle": "Page vierge", "newPageText": "Nouvelle page", "newDocumentText": "Nouveau document", "newGridText": "Nouvelle grille", "newCalendarText": "Nouveau calendrier", "newBoardText": "Nouveau tableau", "trash": { "text": "Corbeille", "restoreAll": "Tout récupérer", "deleteAll": "Tout supprimer", "pageHeader": { "fileName": "Nom de fichier", "lastModified": "Dernière modification", "created": "Créé" }, "confirmDeleteAll": { "title": "Voulez-vous vraiment supprimer toutes les pages de la corbeille ?", "caption": "Cette action ne peut pas être annulée." }, "confirmRestoreAll": { "title": "Êtes-vous sûr de vouloir restaurer toutes les pages dans la corbeille ?", "caption": "Cette action ne peut pas être annulée." }, "mobile": { "actions": "Actions de la corbeille", "empty": "La corbeille est vide", "emptyDescription": "Vous n'avez aucun fichier supprimé", "isDeleted": "a été supprimé", "isRestored": "a été restauré" } }, "deletePagePrompt": { "text": "Cette page se trouve dans la corbeille", "restore": "Récupérer la page", "deletePermanent": "Supprimer définitivement" }, "dialogCreatePageNameHint": "Nom de la page", "questionBubble": { "shortcuts": "Raccourcis", "whatsNew": "Nouveautés", "markdown": "Réduction", "debug": { "name": "Infos du système", "success": "Informations de débogage copiées dans le presse-papiers !", "fail": "Impossible de copier les informations de débogage dans le presse-papiers" }, "feedback": "Retour", "help": "Aide et Support Technique" }, "menuAppHeader": { "moreButtonToolTip": "Supprimer, renommer et plus...", "addPageTooltip": "Ajouter rapidement une page à l'intérieur", "defaultNewPageName": "Sans titre", "renameDialog": "Renommer" }, "noPagesInside": "Aucune page à l'intérieur", "toolbar": { "undo": "Annuler", "redo": "Rétablir", "bold": "Gras", "italic": "Italique", "underline": "Souligner", "strike": "Barré", "numList": "Liste numérotée", "bulletList": "Liste à puces", "checkList": "Liste de contrôle", "inlineCode": "Code en ligne", "quote": "Citation", "header": "En-tête", "highlight": "Surligner", "color": "Couleur", "addLink": "Ajouter un lien", "link": "Lien" }, "tooltip": { "lightMode": "Passer en mode clair", "darkMode": "Passer en mode sombre", "openAsPage": "Ouvrir en tant que page", "addNewRow": "Ajouter une ligne", "openMenu": "Cliquez pour ouvrir le menu", "dragRow": "Appuyez longuement pour réorganiser la ligne", "viewDataBase": "Voir la base de données", "referencePage": "Ce {nom} est référencé", "addBlockBelow": "Ajouter un bloc ci-dessous" }, "sideBar": { "closeSidebar": "Fermer le menu latéral", "openSidebar": "Ouvrir le menu latéral", "personal": "Personnel", "favorites": "Favoris", "clickToHidePersonal": "Cliquez pour cacher la section personnelle", "clickToHideFavorites": "Cliquez pour cacher la section favorite", "addAPage": "Ajouter une page", "recent": "Récent" }, "notifications": { "export": { "markdown": "Note exportée en Markdown", "path": "Documents/fluide" } }, "contactsPage": { "title": "Contacts", "whatsHappening": "Que se passe-t-il cette semaine ?", "addContact": "Ajouter un contact", "editContact": "Modifier le contact" }, "button": { "ok": "OK", "done": "Fait", "cancel": "Annuler", "signIn": "Se connecter", "signOut": "Se déconnecter", "complete": "Achevé", "save": "Sauvegarder", "generate": "Générer", "esc": "ESC", "keep": "Garder", "tryAgain": "Essayer à nouveau", "discard": "Jeter", "replace": "Remplacer", "insertBelow": "Insérer ci-dessous", "insertAbove": "Insérer ci-dessus", "upload": "Télécharger", "edit": "Modifier", "delete": "Supprimer", "duplicate": "Dupliquer", "putback": "Remettre", "update": "Mettre à jour", "share": "Partager", "removeFromFavorites": "Retirer des favoris", "addToFavorites": "Ajouter aux favoris", "rename": "Renommer", "helpCenter": "Centre d'aide", "add": "Ajouter", "yes": "Oui", "tryAGain": "Réessayer" }, "label": { "welcome": "Bienvenue!", "firstName": "Prénom", "middleName": "Deuxième nom", "lastName": "Nom de famille", "stepX": "Étape {X}" }, "oAuth": { "err": { "failedTitle": "Incapable de se connecter à votre compte.", "failedMsg": "SVP assurez-vous d'avoir complèté le processus d'enregistrement dans votre fureteur." }, "google": { "title": "S'identifier avec Google", "instruction1": "Pour importer vos contacts Google, vous devez autoriser cette application à l'aide de votre navigateur Web.", "instruction2": "Copiez ce code dans votre presse-papiers en cliquant sur l'icône ou en sélectionnant le texte:", "instruction3": "Accédez au lien suivant dans votre navigateur Web et saisissez le code ci-dessus:", "instruction4": "Appuyez sur le bouton ci-dessous lorsque vous avez terminé votre inscription:" } }, "settings": { "title": "Paramètres", "menu": { "appearance": "Apparence", "language": "Langue", "user": "Utilisateur", "files": "Dossiers", "notifications": "Notifications", "open": "Ouvrir les paramètres", "logout": "Se déconnecter", "logoutPrompt": "Êtes-vous sûr de vouloir vous déconnecter ?", "selfEncryptionLogoutPrompt": "Êtes-vous sûr de vouloir vous déconnecter ? Veuillez vous assurer d'avoir copié la clé de chiffrement.", "syncSetting": "Paramètres de synchronisation", "cloudSettings": "Paramètres cloud", "enableSync": "Activer la synchronisation", "enableEncrypt": "Chiffrer les données", "cloudURL": "URL de base", "invalidCloudURLScheme": "Schéma invalide", "cloudServerType": "Serveur cloud", "cloudServerTypeTip": "Veuillez noter qu'il est possible que votre compte actuel soit déconnecté après avoir changé de serveur cloud.", "cloudLocal": "Local", "cloudAppFlowy": "@:appName Cloud Bêta", "cloudAppFlowySelfHost": "@:appName Cloud auto-hébergé", "appFlowyCloudUrlCanNotBeEmpty": "L'URL cloud ne peut pas être vide", "clickToCopy": "Cliquez pour copier", "selfHostStart": "Si vous n'avez pas de serveur, veuillez vous référer au", "selfHostContent": "document", "selfHostEnd": "pour obtenir des conseils sur la façon d'auto-héberger votre propre serveur", "cloudURLHint": "Saisissez l'URL de base de votre serveur", "cloudWSURL": "URL du websocket", "cloudWSURLHint": "Saisissez l'adresse websocket de votre serveur", "restartApp": "Redémarer", "restartAppTip": "Redémarrez l'application pour que les modifications prennent effet. Veuillez noter que cela pourrait déconnecter votre compte actuel.", "changeServerTip": "Après avoir changé de serveur, vous devez cliquer sur le bouton de redémarrer pour que les modifications prennent effet", "enableEncryptPrompt": "Activez le chiffrement pour sécuriser vos données avec cette clé. Rangez-la en toute sécurité ; une fois activé, il ne peut pas être désactivé. En cas de perte, vos données deviennent irrécupérables. Cliquez pour copier", "inputEncryptPrompt": "Veuillez saisir votre mot ou phrase de passe pour", "clickToCopySecret": "Cliquez pour copier le mot ou la phrase de passe", "configServerSetting": "Configurez les paramètres de votre serveur", "configServerGuide": "Après avoir sélectionné « Démarrage rapide », accédez à « Paramètres » puis « Paramètres Cloud » pour configurer votre serveur auto-hébergé.", "inputTextFieldHint": "Votre mot ou phrase de passe", "historicalUserList": "Historique de connexion d'utilisateurs", "historicalUserListTooltip": "Cette liste affiche vos comptes anonymes. Vous pouvez cliquer sur un compte pour afficher ses détails. Les comptes anonymes sont créés en cliquant sur le bouton « Commencer »", "openHistoricalUser": "Cliquez pour ouvrir le compte anonyme", "customPathPrompt": "Le stockage du dossier de données @:appName dans un dossier synchronisé avec le cloud tel que Google Drive peut présenter des risques. Si la base de données de ce dossier est consultée ou modifiée à partir de plusieurs emplacements en même temps, cela peut entraîner des conflits de synchronisation et une corruption potentielle des données.", "importAppFlowyData": "Importer des données à partir du dossier @:appName externe", "importingAppFlowyDataTip": "L'importation des données est en cours. Veuillez ne pas fermer l'application", "importAppFlowyDataDescription": "Copiez les données d'un dossier de données @:appName externe et importez-les dans le dossier de données @:appName actuel", "importSuccess": "Importation réussie du dossier de données @:appName", "importFailed": "L'importation du dossier de données @:appName a échoué", "importGuide": "Pour plus de détails, veuillez consulter le document référencé" }, "notifications": { "enableNotifications": { "label": "Activer les notifications", "hint": "Désactivez-la pour empêcher l'affichage des notifications locales." } }, "appearance": { "resetSetting": "Réinitialiser ce paramètre", "fontFamily": { "label": "Famille de polices", "search": "Recherche" }, "themeMode": { "label": " Mode du Thème", "light": "Mode clair", "dark": "Mode sombre", "system": "S'adapter au système" }, "documentSettings": { "cursorColor": "Couleur du curseur du document", "selectionColor": "Couleur de sélection du document", "hexEmptyError": "La couleur hexadécimale ne peut pas être vide", "hexLengthError": "La valeur hexadécimale doit comporter 6 chiffres", "hexInvalidError": "Valeur hexadécimale invalide", "opacityEmptyError": "L'opacité ne peut pas être vide", "opacityRangeError": "L'opacité doit être comprise entre 1 et 100", "app": "Application", "flowy": "Flowy", "apply": "Appliquer" }, "layoutDirection": { "label": "Orientation de la mise en page", "hint": "Contrôlez l'orientation du contenu sur votre écran, de gauche à droite ou de droite à gauche.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "Direction du texte par défaut", "hint": "Spécifiez si le texte doit commencer à gauche ou à droite par défaut.", "ltr": "LTR", "rtl": "RTL", "auto": "AUTO", "fallback": "Identique au sens de mise en page" }, "themeUpload": { "button": "Téléverser", "uploadTheme": "Téléverser le thème", "description": "Téléversez votre propre thème @:appName en utilisant le bouton ci-dessous.", "loading": "Veuillez patienter pendant que nous validons et téléchargeons votre thème...", "uploadSuccess": "Votre thème a été téléversé avec succès", "deletionFailure": "Échec de la suppression du thème. Essayez de le supprimer manuellement.", "filePickerDialogTitle": "Choisissez un fichier .flowy_plugin", "urlUploadFailure": "Échec de l'ouverture de l'URL : {}", "failure": "Le thème qui a été téléchargé avait un format non valide." }, "theme": "Thème", "builtInsLabel": "Thèmes intégrés", "pluginsLabel": "Plugins", "dateFormat": { "label": "Format de la date", "local": "Local", "us": "US", "iso": "ISO", "friendly": "Convivial", "dmy": "J/M/A" }, "timeFormat": { "label": "Format de l'heure", "twelveHour": "Douze heures", "twentyFourHour": "Vingt-quatre heures" }, "showNamingDialogWhenCreatingPage": "Afficher la boîte de dialogue de nommage lors de la création d'une page" }, "files": { "copy": "Copie", "defaultLocation": "Lire les fichiers et l'emplacement de stockage des données", "exportData": "Exportez vos données", "doubleTapToCopy": "Appuyez deux fois pour copier le chemin", "restoreLocation": "Restaurer le chemin par défaut d'@:appName", "customizeLocation": "Ouvrir un autre dossier", "restartApp": "Veuillez redémarrer l'application pour que les modifications prennent effet.", "exportDatabase": "Exporter la base de données", "selectFiles": "Sélectionnez les fichiers qui doivent être exportés", "selectAll": "Tout sélectionner", "deselectAll": "Tout déselectionner", "createNewFolder": "Créer un nouveau dossier", "createNewFolderDesc": "Dites-nous où vous souhaitez stocker vos données", "defineWhereYourDataIsStored": "Définissez où vos données sont stockées", "open": "Ouvrir", "openFolder": "Ouvrir un dossier existant", "openFolderDesc": "Lisez-le et écrivez-le dans votre dossier @:appName existant", "folderHintText": "Nom de dossier", "location": "Création d'un nouveau dossier", "locationDesc": "Choisissez un nom pour votre dossier de données @:appName", "browser": "Parcourir", "create": "Créer", "set": "Définir", "folderPath": "Chemin pour stocker votre dossier", "locationCannotBeEmpty": "Le chemin ne peut pas être vide", "pathCopiedSnackbar": "Chemin de stockage des fichiers copié dans le presse-papier !", "changeLocationTooltips": "Changer le répertoire de données", "change": "Changer", "openLocationTooltips": "Ouvrir un autre répertoire de données", "openCurrentDataFolder": "Ouvrir le répertoire de données actuel", "recoverLocationTooltips": "Réinitialiser au répertoire de données par défaut d'@:appName", "exportFileSuccess": "Exporter le fichier avec succès !", "exportFileFail": "Échec de l'export du fichier !", "export": "Exporter" }, "user": { "name": "Nom", "email": "Courriel", "tooltipSelectIcon": "Sélectionner l'icône", "selectAnIcon": "Sélectionnez une icône", "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé AI", "clickToLogout": "Cliquez pour déconnecter l'utilisateur actuel", "pleaseInputYourStabilityAIKey": "Veuillez saisir votre clé de Stability AI" }, "mobile": { "personalInfo": "Informations personnelles", "username": "Nom d'utilisateur", "usernameEmptyError": "Le nom d'utilisateur ne peut pas être vide", "about": "À propos", "pushNotifications": "Notifications push", "support": "Support", "joinDiscord": "Rejoignez-nous sur Discord", "privacyPolicy": "Politique de Confidentialité", "userAgreement": "Accord de l'utilisateur", "termsAndConditions": "Termes et conditions", "userprofileError": "Échec du chargement du profil utilisateur", "userprofileErrorDescription": "Veuillez essayer de vous déconnecter et de vous reconnecter pour vérifier si le problème persiste.", "selectLayout": "Sélectionner la mise en page", "selectStartingDay": "Sélectionnez le jour de début", "version": "Version" }, "shortcuts": { "shortcutsLabel": "Raccourcis", "command": "Commande", "keyBinding": "Racourcis clavier", "addNewCommand": "Ajouter une Nouvelle Commande", "updateShortcutStep": "Appuyez sur la combinaison de touches souhaitée et appuyez sur ENTER", "shortcutIsAlreadyUsed": "Ce raccourci est déjà utilisé pour : {conflict}", "resetToDefault": "Réinitialiser les raccourcis clavier par défaut", "couldNotLoadErrorMsg": "Impossible de charger les raccourcis, réessayez", "couldNotSaveErrorMsg": "Impossible d'enregistrer les raccourcis. Réessayez" } }, "grid": { "deleteView": "Voulez-vous vraiment supprimer cette vue ?", "createView": "Nouveau", "title": { "placeholder": "Sans titre" }, "settings": { "filter": "Filtrer", "sort": "Trier", "sortBy": "Trier par", "properties": "Propriétés", "reorderPropertiesTooltip": "Faites glisser pour réorganiser les propriétés", "group": "Groupe", "addFilter": "Ajouter un filtre", "deleteFilter": "Supprimer le filtre", "filterBy": "Filtrer par...", "typeAValue": "Tapez une valeur...", "layout": "Mise en page", "databaseLayout": "Mise en page", "viewList": { "zero": "0 vue", "one": "{count} vue", "other": "{count} vues" }, "editView": "Modifier vue", "boardSettings": "Paramètres du tableau", "calendarSettings": "Paramètres du calendrier", "createView": "Nouvelle vue", "duplicateView": "Dupliquer la vue", "deleteView": "Supprimer la vue", "numberOfVisibleFields": "{} affiché(s)" }, "textFilter": { "contains": "Contient", "doesNotContain": "Ne contient pas", "endsWith": "Se termine par", "startWith": "Commence par", "is": "Est", "isNot": "N'est pas", "isEmpty": "Est vide", "isNotEmpty": "N'est pas vide", "choicechipPrefix": { "isNot": "Pas", "startWith": "Commence par", "endWith": "Se termine par", "isEmpty": "est vide", "isNotEmpty": "n'est pas vide" } }, "checkboxFilter": { "isChecked": "Coché", "isUnchecked": "Décoché", "choicechipPrefix": { "is": "est" } }, "checklistFilter": { "isComplete": "fait", "isIncomplted": "pas fait" }, "selectOptionFilter": { "is": "Est", "isNot": "N'est pas", "contains": "Contient", "doesNotContain": "Ne contient pas", "isEmpty": "Est vide", "isNotEmpty": "N'est pas vide" }, "dateFilter": { "is": "Est", "before": "Est avant", "after": "Est après", "onOrBefore": "Est le ou avant", "onOrAfter": "Est le ou après", "between": "Est entre", "empty": "Est vide", "notEmpty": "N'est pas vide" }, "field": { "hide": "Cacher", "show": "Afficher", "insertLeft": "Insérer à gauche", "insertRight": "Insérer à droite", "duplicate": "Dupliquer", "delete": "Supprimer", "textFieldName": "Texte", "checkboxFieldName": "Case à cocher", "dateFieldName": "Date", "updatedAtFieldName": "Dernière modification", "createdAtFieldName": "Créé le", "numberFieldName": "Nombre", "singleSelectFieldName": "Sélectionner", "multiSelectFieldName": "Sélection multiple", "urlFieldName": "URL", "checklistFieldName": "Liste de contrôle", "numberFormat": "Format de nombre", "dateFormat": "Format de la date", "includeTime": "Inclure l'heure", "isRange": "Date de fin", "dateFormatFriendly": "Mois Jour, Année", "dateFormatISO": "Année-Mois-Jour", "dateFormatLocal": "Mois/Jour/Année", "dateFormatUS": "Année/Mois/Jour", "dateFormatDayMonthYear": "Jour/Mois/Année", "timeFormat": "Format de l'heure", "invalidTimeFormat": "Format invalide", "timeFormatTwelveHour": "12 heures", "timeFormatTwentyFourHour": "24 heures", "clearDate": "Effacer la date", "dateTime": "Date et heure", "startDateTime": "Date et heure de début", "endDateTime": "Date et heure de fin", "failedToLoadDate": "Échec du chargement de la valeur de la date", "selectTime": "Sélectionnez l'heure", "selectDate": "Sélectionner une date", "visibility": "Visibilité", "propertyType": "Type de propriété", "addSelectOption": "Ajouter une option", "typeANewOption": "Saisissez une nouvelle option", "optionTitle": "Choix", "addOption": "Ajouter un choix", "editProperty": "Modifier la propriété", "newProperty": "Nouvelle propriété", "deleteFieldPromptMessage": "Vous voulez supprimer cette propriété ?", "newColumn": "Nouvelle colonne", "format": "Format", "reminderOnDateTooltip": "Cette cellule a un rappel programmé" }, "rowPage": { "newField": "Ajouter un nouveau champ", "fieldDragElementTooltip": "Cliquez pour ouvrir le menu", "showHiddenFields": { "one": "Afficher {count} champ masqué", "many": "Afficher {count} champs masqués", "other": "Afficher {count} champs masqués" }, "hideHiddenFields": { "one": "Cacher {count} champ caché", "many": "Cacher {count} champs masqués", "other": "Cacher {count} champs masqués" } }, "sort": { "ascending": "Ascendant", "descending": "Descendant", "deleteAllSorts": "Supprimer tous les tris", "addSort": "Ajouter un tri", "deleteSort": "Supprimer le tri" }, "row": { "duplicate": "Dupliquer", "delete": "Supprimer", "titlePlaceholder": "Sans titre", "textPlaceholder": "Vide", "copyProperty": "Propriété copiée dans le presse-papiers", "count": "Compte", "newRow": "Nouvelle ligne", "action": "Action", "add": "Cliquez sur ajouter ci-dessous", "drag": "Glisser pour déplacer", "dragAndClick": "Faites glisser pour déplacer, cliquez pour ouvrir le menu", "insertRecordAbove": "Insérer l'enregistrement ci-dessus", "insertRecordBelow": "Insérer l'enregistrement ci-dessous" }, "selectOption": { "create": "Créer", "purpleColor": "Violet", "pinkColor": "Rose", "lightPinkColor": "Rose clair", "orangeColor": "Orange", "yellowColor": "Jaune", "limeColor": "Lime", "greenColor": "Vert", "aquaColor": "Turquoise", "blueColor": "Bleu", "deleteTag": "Supprimer l'étiquette", "colorPanelTitle": "Couleurs", "panelTitle": "Sélectionnez une option ou créez-en une", "searchOption": "Rechercher une option", "searchOrCreateOption": "Rechercher ou créer une option...", "createNew": "Créer une nouvelle", "orSelectOne": "Ou sélectionnez une option", "typeANewOption": "Saisissez une nouvelle option", "tagName": "Nom de l'étiquette" }, "checklist": { "taskHint": "Description de la tâche", "addNew": "Ajouter un élément", "submitNewTask": "Créer", "hideComplete": "Cacher les tâches terminées", "showComplete": "Afficher toutes les tâches" }, "url": { "launch": "Ouvrir dans le navigateur", "copy": "Copier l'URL" }, "menuName": "Grille", "referencedGridPrefix": "Vue", "calculate": "Calculer", "calculationTypeLabel": { "none": "Aucun", "average": "Moyenne", "max": "Maximum", "median": "Médiane", "min": "Minimum", "sum": "Somme" } }, "document": { "menuName": "Document", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Sélectionnez un tableau à lier", "createANewBoard": "Créer un nouveau tableau" }, "grid": { "selectAGridToLinkTo": "Sélectionnez une grille à lier", "createANewGrid": "Créer une nouvelle Grille" }, "calendar": { "selectACalendarToLinkTo": "Sélectionnez un calendrier à lier", "createANewCalendar": "Créer un nouveau calendrier" }, "document": { "selectADocumentToLinkTo": "Sélectionnez un Document vers lequel créer un lien" } }, "selectionMenu": { "outline": "Contour", "codeBlock": "Bloc de code" }, "plugins": { "referencedBoard": "Tableau référencé", "referencedGrid": "Grille référencée", "referencedCalendar": "Calendrier référencé", "referencedDocument": "Document référencé", "autoGeneratorMenuItemName": "Rédacteur AI", "autoGeneratorTitleName": "AI : Demandez à l'IA d'écrire quelque chose...", "autoGeneratorLearnMore": "Apprendre encore plus", "autoGeneratorGenerate": "Générer", "autoGeneratorHintText": "Demandez à AI...", "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé AI", "autoGeneratorRewrite": "Réécrire", "smartEdit": "Assistants IA", "aI": "AI", "smartEditFixSpelling": "Corriger l'orthographe", "warning": "⚠️ Les réponses de l'IA peuvent être inexactes ou trompeuses.", "smartEditSummarize": "Résumer", "smartEditImproveWriting": "Améliorer l'écriture", "smartEditMakeLonger": "Rallonger", "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'AI", "smartEditCouldNotFetchKey": "Impossible de récupérer la clé AI", "smartEditDisabled": "Connectez AI dans les paramètres", "discardResponse": "Voulez-vous supprimer les réponses de l'IA ?", "createInlineMathEquation": "Créer une équation", "fonts": "Polices", "emoji": "Emoji", "toggleList": "Liste pliable", "quoteList": "Liste de citations", "numberedList": "Liste numérotée", "bulletedList": "Liste à puces", "todoList": "Liste de tâches", "callout": "Encadré", "cover": { "changeCover": "Changer la couverture", "colors": "Couleurs", "images": "Images", "clearAll": "Tout effacer", "abstract": "Abstrait", "addCover": "Ajouter une couverture", "addLocalImage": "Ajouter une image locale", "invalidImageUrl": "URL d'image non valide", "failedToAddImageToGallery": "Impossible d'ajouter l'image à la galerie", "enterImageUrl": "Entrez l'URL de l'image", "add": "Ajouter", "back": "Dos", "saveToGallery": "Sauvegarder dans la gallerie", "removeIcon": "Supprimer l'icône", "pasteImageUrl": "Coller l'URL de l'image", "or": "OU", "pickFromFiles": "Choisissez parmi les fichiers", "couldNotFetchImage": "Impossible de récupérer l'image", "imageSavingFailed": "Échec de l'enregistrement de l'image", "addIcon": "Ajouter une icône", "changeIcon": "Changer l'icône", "coverRemoveAlert": "Il sera retiré de la couverture après sa suppression.", "alertDialogConfirmation": "Voulez-vous vraiment continuer?" }, "mathEquation": { "name": "Équation mathématique", "addMathEquation": "Ajouter une équation mathématique", "editMathEquation": "Modifier l'équation mathématique" }, "optionAction": { "click": "Cliquez sur", "toOpenMenu": " pour ouvrir le menu", "delete": "Supprimer", "duplicate": "Dupliquer", "turnInto": "Changer en", "moveUp": "Déplacer vers le haut", "moveDown": "Descendre", "color": "Couleur", "align": "Aligner", "left": "Gauche", "center": "Centre", "right": "Droite", "defaultColor": "Défaut" }, "image": { "addAnImage": "Ajouter une image", "copiedToPasteBoard": "Le lien de l'image a été copié dans le presse-papiers" }, "urlPreview": { "copiedToPasteBoard": "Le lien a été copié dans le presse-papier" }, "outline": { "addHeadingToCreateOutline": "Ajoutez des titres pour créer une table des matières." }, "table": { "addAfter": "Ajouter après", "addBefore": "Ajouter avant", "delete": "Supprimer", "clear": "Éffacer contenu", "duplicate": "Dupliquer", "bgColor": "Couleur de fond" }, "contextMenu": { "copy": "Copier", "cut": "Couper", "paste": "Coller" }, "action": "Actions", "database": { "selectDataSource": "Sélectionnez la source de données", "noDataSource": "Aucune source de données", "selectADataSource": "Sélectionnez une source de données", "toContinue": "pour continuer", "newDatabase": "Nouvelle Base de données", "linkToDatabase": "Lien vers la Base de données" }, "date": "Date" }, "textBlock": { "placeholder": "Tapez '/' pour les commandes" }, "title": { "placeholder": "Sans titre" }, "imageBlock": { "placeholder": "Cliquez pour ajouter une image", "upload": { "label": "Téléverser", "placeholder": "Cliquez pour téléverser l'image" }, "url": { "label": "URL de l'image", "placeholder": "Entrez l'URL de l'image" }, "ai": { "label": "Générer une image à partir d'AI", "placeholder": "Veuillez saisir l'invite pour qu'AI génère l'image" }, "stability_ai": { "label": "Générer une image à partir de Stability AI", "placeholder": "Veuillez saisir l'invite permettant à Stability AI de générer une image." }, "support": "La limite de taille d'image est de 5 Mo. Formats pris en charge : JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Image invalide", "invalidImageSize": "La taille de l'image doit être inférieure à 5 Mo", "invalidImageFormat": "Le format d'image n'est pas pris en charge. Formats pris en charge : JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL d'image non valide" }, "embedLink": { "label": "Lien intégré", "placeholder": "Collez ou saisissez un lien d'image" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Rechercher une image", "pleaseInputYourOpenAIKey": "veuillez saisir votre clé AI dans la page Paramètres", "saveImageToGallery": "Enregistrer l'image", "failedToAddImageToGallery": "Échec de l'ajout d'une image à la galerie", "successToAddImageToGallery": "Image ajoutée à la galerie avec succès", "unableToLoadImage": "Impossible de charger l'image", "maximumImageSize": "La taille d'image maximale est 10Mo", "uploadImageErrorImageSizeTooBig": "L'image doit faire moins de 10Mo", "pleaseInputYourStabilityAIKey": "veuillez saisir votre clé Stability AI dans la page Paramètres" }, "codeBlock": { "language": { "label": "Langue", "placeholder": "Choisir la langue" } }, "inlineLink": { "placeholder": "Coller ou saisir un lien", "openInNewTab": "Ouvrir dans un nouvel onglet", "copyLink": "Copier le lien", "removeLink": "Supprimer le lien", "url": { "label": "URL du lien", "placeholder": "Entrez l'URL du lien" }, "title": { "label": "Titre du lien", "placeholder": "Entrez le titre du lien" } }, "mention": { "placeholder": "Mentionner une personne ou une page ou une date...", "page": { "label": "Lien vers la page", "tooltip": "Cliquez pour ouvrir la page" }, "deleted": "Supprimé", "deletedContent": "Ce document n'existe pas ou a été supprimé" }, "toolbar": { "resetToDefaultFont": "Réinitialiser aux valeurs par défaut" }, "errorBlock": { "theBlockIsNotSupported": "La version actuelle ne prend pas en charge ce bloc.", "blockContentHasBeenCopied": "Le contenu du bloc a été copié." } }, "board": { "column": { "createNewCard": "Nouveau", "renameGroupTooltip": "Appuyez pour renommer le groupe", "createNewColumn": "Ajouter un nouveau groupe", "addToColumnTopTooltip": "Ajouter une nouvelle carte en haut", "addToColumnBottomTooltip": "Ajouter une nouvelle carte en bas", "renameColumn": "Renommer", "hideColumn": "Cacher", "newGroup": "Nouveau groupe", "deleteColumn": "Supprimer", "deleteColumnConfirmation": "Cela supprimera ce groupe et toutes les cartes qu'il contient. \nÊtes-vous sûr de vouloir continuer?", "groupActions": "Actions de groupe" }, "hiddenGroupSection": { "sectionTitle": "Groupes cachés", "collapseTooltip": "Cacher les groupes cachés", "expandTooltip": "Afficher les groupes cachés" }, "cardDetail": "Détail de la Carte", "cardActions": "Actions des Cartes", "cardDuplicated": "La carte a été dupliquée", "cardDeleted": "La carte a été supprimée", "showOnCard": "Afficher les détails de la carte", "setting": "Paramètre", "propertyName": "Nom de la propriété", "menuName": "Tableau", "showUngrouped": "Afficher les éléments non regroupés", "ungroupedButtonText": "Non groupé", "ungroupedButtonTooltip": "Contient des cartes qui n'appartiennent à aucun groupe", "ungroupedItemsTitle": "Cliquez pour ajouter au tableau", "groupBy": "Regrouper par", "referencedBoardPrefix": "Vue", "notesTooltip": "Notes à l'intérieur", "mobile": { "editURL": "Modifier l'URL", "showGroup": "Afficher le groupe", "showGroupContent": "Êtes-vous sûr de vouloir afficher ce groupe sur le tableau ?", "failedToLoad": "Échec du chargement de la vue du tableau" } }, "calendar": { "menuName": "Calendrier", "defaultNewCalendarTitle": "Sans titre", "newEventButtonTooltip": "Ajouter un nouvel événement", "navigation": { "today": "Aujourd'hui", "jumpToday": "Aller à Aujourd'hui", "previousMonth": "Mois précédent", "nextMonth": "Mois prochain" }, "mobileEventScreen": { "emptyTitle": "Pas d'événements", "emptyBody": "Cliquez sur le bouton plus pour créer un événement à cette date." }, "settings": { "showWeekNumbers": "Afficher les numéros de semaine", "showWeekends": "Afficher les week-ends", "firstDayOfWeek": "Commencer la semaine le", "layoutDateField": "Calendrier de mise en page par", "changeLayoutDateField": "Modifier le champ de mise en page", "noDateTitle": "Pas de date", "unscheduledEventsTitle": "Événements non planifiés", "clickToAdd": "Cliquez pour ajouter au calendrier", "name": "Disposition du calendrier", "noDateHint": "Les événements non planifiés s'afficheront ici" }, "referencedCalendarPrefix": "Vue", "quickJumpYear": "Sauter à" }, "errorDialog": { "title": "Erreur @:appName", "howToFixFallback": "Nous sommes désolés pour le désagrément ! Soumettez un problème sur notre page GitHub qui décrit votre erreur.", "github": "Afficher sur GitHub" }, "search": { "label": "Recherche", "placeholder": { "actions": "Actions de recherche..." } }, "message": { "copy": { "success": "Copié !", "fail": "Impossible de copier" } }, "unSupportBlock": "La version actuelle ne prend pas en charge ce bloc.", "views": { "deleteContentTitle": "Voulez-vous vraiment supprimer le {pageType} ?", "deleteContentCaption": "si vous supprimez ce {pageType}, vous pouvez le restaurer à partir de la corbeille." }, "colors": { "custom": "Personnalisé", "default": "Défaut", "red": "Rouge", "orange": "Orange", "yellow": "Jaune", "green": "Vert", "blue": "Bleu", "purple": "Violet", "pink": "Rose", "brown": "Marron", "gray": "Gris" }, "emoji": { "emojiTab": "Émoji", "search": "Chercher un émoji", "noRecent": "Aucun émoji récent", "noEmojiFound": "Aucun émoji trouvé", "filter": "Filtrer", "random": "Aléatoire", "selectSkinTone": "Choisir le teint de la peau", "remove": "Supprimer l'émoji", "categories": { "smileys": "Smileys & émoticônes", "people": "Personnes & corps", "animals": "Animaux & Nature", "food": "Nourriture & Boisson", "activities": "Activités", "places": "Voyages & Lieux", "objects": "Objets", "symbols": "Symboles", "flags": "Drapeaux", "nature": "Nature", "frequentlyUsed": "Fréquemment utilisés" }, "skinTone": { "default": "Défaut", "light": "Claire", "mediumLight": "Moyennement claire", "medium": "Moyen", "mediumDark": "Moyennement foncé", "dark": "Foncé" } }, "inlineActions": { "noResults": "Aucun résultat", "pageReference": "Référence de page", "docReference": "Référence de document", "boardReference": "Référence du tableau", "calReference": "Référence du calendrier", "gridReference": "Référence de grille", "date": "Date", "reminder": { "groupTitle": "Rappel", "shortKeyword": "rappeler" } }, "datePicker": { "dateTimeFormatTooltip": "Modifier le format de la date et de l'heure dans les paramètres", "dateFormat": "Format de date", "includeTime": "Inclure l'heure", "isRange": "Date de fin", "timeFormat": "Format de l'heure", "clearDate": "Effacer la date", "reminderLabel": "Rappel", "selectReminder": "Sélectionnez un rappel", "reminderOptions": { "none": "Aucun", "atTimeOfEvent": "Heure de l'événement", "fiveMinsBefore": "5 minutes avant", "tenMinsBefore": "10 minutes avant", "fifteenMinsBefore": "15 minutes avant", "thirtyMinsBefore": "30 minutes avant", "oneHourBefore": "1 heure avant", "twoHoursBefore": "2 heures avant", "onDayOfEvent": "Le jour de l'événement", "oneDayBefore": "1 jour avant", "twoDaysBefore": "2 jours avant", "oneWeekBefore": "1 semaine avant", "custom": "Personnalisé" } }, "relativeDates": { "yesterday": "Hier", "today": "Aujourd'hui", "tomorrow": "Demain", "oneWeek": "1 semaine" }, "notificationHub": { "title": "Notifications", "mobile": { "title": "Mises à jour" }, "emptyTitle": "Vous êtes à jour !", "emptyBody": "Aucune notification ou action en attente. Profitez du calme.", "tabs": { "inbox": "Boîte de réception", "upcoming": "A venir" }, "actions": { "markAllRead": "Tout marquer comme lu", "showAll": "Tous", "showUnreads": "Non lu" }, "filters": { "ascending": "Ascendant", "descending": "Descendant", "groupByDate": "Regrouper par date", "showUnreadsOnly": "Afficher uniquement les éléments non lus", "resetToDefault": "Réinitialiser aux valeurs par défaut" } }, "reminderNotification": { "title": "Rappel", "message": "Pensez à vérifier cela avant d'oublier !", "tooltipDelete": "Supprimer", "tooltipMarkRead": "Marquer comme lu", "tooltipMarkUnread": "Marquer comme non lu" }, "findAndReplace": { "find": "Chercher", "previousMatch": "Occurence précedente", "nextMatch": "Prochaine occurence", "close": "Fermer", "replace": "Remplacer", "replaceAll": "Tout remplacer", "noResult": "Aucun résultat", "caseSensitive": "Sensible à la casse" }, "error": { "weAreSorry": "Nous sommes désolés", "loadingViewError": "Nous rencontrons des difficultés pour charger cette vue. Veuillez vérifier votre connexion Internet, actualiser l'application et n'hésitez pas à contacter l'équipe si le problème persiste." }, "editor": { "bold": "Gras", "bulletedList": "Liste à puces", "bulletedListShortForm": "Puces", "checkbox": "Case à cocher", "embedCode": "Code intégré", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Surligner", "color": "Couleur", "image": "Image", "date": "Date", "italic": "Italique", "link": "Lien", "numberedList": "Liste numérotée", "numberedListShortForm": "Numéroté", "quote": "Citation", "strikethrough": "Barré", "text": "Texte", "underline": "Souligner", "fontColorDefault": "Défaut", "fontColorGray": "Gris", "fontColorBrown": "Marron", "fontColorOrange": "Orange", "fontColorYellow": "Jaune", "fontColorGreen": "Vert", "fontColorBlue": "Bleu", "fontColorPurple": "Violet", "fontColorPink": "Rose", "fontColorRed": "Rouge", "backgroundColorDefault": "Fond par défaut", "backgroundColorGray": "Fond gris", "backgroundColorBrown": "Fond marron", "backgroundColorOrange": "Fond orange", "backgroundColorYellow": "Fond jaune", "backgroundColorGreen": "Fond vert", "backgroundColorBlue": "Fond bleu", "backgroundColorPurple": "Fond violet", "backgroundColorPink": "Fond rose", "backgroundColorRed": "Fond rouge", "done": "Fait", "cancel": "Annuler", "tint1": "Teinte 1", "tint2": "Teinte 2", "tint3": "Teinte 3", "tint4": "Teinte 4", "tint5": "Teinte 5", "tint6": "Teinte 6", "tint7": "Teinte 7", "tint8": "Teinte 8", "tint9": "Teinte 9", "lightLightTint1": "Violet", "lightLightTint2": "Rose", "lightLightTint3": "Rose clair", "lightLightTint4": "Orange", "lightLightTint5": "Jaune", "lightLightTint6": "Lime", "lightLightTint7": "Vert", "lightLightTint8": "Turquoise", "lightLightTint9": "Bleu", "urlHint": "URL", "mobileHeading1": "Titre 1", "mobileHeading2": "Titre 2", "mobileHeading3": "Titre 3", "textColor": "Couleur du texte", "backgroundColor": "Couleur du fond", "addYourLink": "Ajoutez votre lien", "openLink": "Ouvrir le lien", "copyLink": "Copier le lien", "removeLink": "Supprimer le lien", "editLink": "Modifier le lien", "linkText": "Texte", "linkTextHint": "Veuillez saisir du texte", "linkAddressHint": "Veuillez entrer l'URL", "highlightColor": "Couleur de surlignage", "clearHighlightColor": "Effacer la couleur de surlignage", "customColor": "Couleur personnalisée", "hexValue": "Valeur hexadécimale", "opacity": "Opacité", "resetToDefaultColor": "Réinitialiser la couleur par défaut", "ltr": "LTR", "rtl": "RTL", "auto": "Auto", "cut": "Couper", "copy": "Copier", "paste": "Color", "find": "Chercher", "previousMatch": "Occurence précédente", "nextMatch": "Occurence suivante", "closeFind": "Fermer", "replace": "Remplacer", "replaceAll": "Tout remplacer", "regex": "Regex", "caseSensitive": "Sensible à la casse", "uploadImage": "Téléverser une image", "urlImage": "URL de l'image ", "incorrectLink": "Lien incorrect", "upload": "Téléverser", "chooseImage": "Choisissez une image", "loading": "Chargement", "imageLoadFailed": "Impossible de charger l'image", "divider": "Séparateur", "table": "Tableau", "colAddBefore": "Ajouter avant", "rowAddBefore": "Ajouter avant", "colAddAfter": "Ajouter après", "rowAddAfter": "Ajouter après", "colRemove": "Retirer", "rowRemove": "Retirer", "colDuplicate": "Dupliquer", "rowDuplicate": "Dupliquer", "colClear": "Effacer le ontenu", "rowClear": "Effacer le ontenu", "slashPlaceHolder": "Tapez '/' pour insérer un bloc ou commencez à écrire", "typeSomething": "Écrivez quelque chose...", "toggleListShortForm": "Plier / Déplier", "quoteListShortForm": "Citation", "mathEquationShortForm": "Formule", "codeBlockShortForm": "Code" }, "favorite": { "noFavorite": "Aucune page favorite", "noFavoriteHintText": "Faites glisser la page vers la gauche pour l'ajouter à vos favoris" }, "cardDetails": { "notesPlaceholder": "Entrez un / pour insérer un bloc ou commencez à taper" }, "blockPlaceholders": { "todoList": "À faire", "bulletList": "Liste", "numberList": "Liste", "quote": "Citation", "heading": "Titre {}" }, "titleBar": { "pageIcon": "Icône de page", "language": "Langue", "font": "Police ", "actions": "Actions", "date": "Date", "addField": "Ajouter un champ", "userIcon": "Icône utilisateur" }, "noLogFiles": "Il n'y a pas de log" } ================================================ FILE: frontend/resources/translations/fr-FR.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Moi", "welcomeText": "Bienvenue sur @:appName", "welcomeTo": "Bienvenue à", "githubStarText": "Favoriser sur GitHub", "subscribeNewsletterText": "S'inscrire à la Newsletter", "letsGoButtonText": "Allons-y", "title": "Titre", "youCanAlso": "Vous pouvez aussi", "and": "et", "failedToOpenUrl": "Échec de l'ouverture de l'URL: {}", "blockActions": { "addBelowTooltip": "Cliquez pour ajouter ci-dessous", "addAboveCmd": "Alt+clic", "addAboveMacCmd": "Option+clic", "addAboveTooltip": "à ajouter au dessus", "dragTooltip": "Glisser pour déplacer", "openMenuTooltip": "Cliquez pour ouvrir le menu" }, "signUp": { "buttonText": "S'inscrire", "title": "Inscrivez-vous sur @:appName", "getStartedText": "Commencer", "emptyPasswordError": "Vous n'avez pas saisi votre mot de passe", "repeatPasswordEmptyError": "Vous n'avez pas ressaisi votre mot de passe", "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques", "alreadyHaveAnAccount": "Avez-vous déjà un compte ?", "emailHint": "Courriel", "passwordHint": "Mot de passe", "repeatPasswordHint": "Ressaisir votre mot de passe", "signUpWith": "Se connecter avec :" }, "signIn": { "loginTitle": "Connexion à @:appName", "loginButtonText": "Connexion", "loginStartWithAnonymous": "Lancer avec une session anonyme", "continueAnonymousUser": "Continuer avec une session anonyme", "continueWithLocalModel": "Continuer avec le modèle local", "switchToAppFlowyCloud": "AppFlowy Cloud", "anonymousMode": "Mode anonyme", "buttonText": "Se connecter", "signingInText": "Connexion en cours...", "forgotPassword": "Mot de passe oublié ?", "emailHint": "Courriel", "passwordHint": "Mot de passe", "dontHaveAnAccount": "Vous n'avez pas encore de compte ?", "createAccount": "Créer un compte", "repeatPasswordEmptyError": "Vous n'avez pas ressaisi votre mot de passe", "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques", "passwordMustContain": "Votre mot de passe doit contenir au moins une lettre, un chiffre et un symbole", "syncPromptMessage": "La synchronisation des données peut prendre un certain temps. Merci de ne pas fermer pas cette page.", "or": "OU", "signInWithGoogle": "Continuer avec Google", "signInWithGithub": "Continuer avec Github", "signInWithDiscord": "Continuer avec Discord", "signInWithApple": "Se connecter via Apple", "continueAnotherWay": "Continuer via une autre méthode", "signUpWithGoogle": "S'inscrire avec Google", "signUpWithGithub": "S'inscrire avec Github", "signUpWithDiscord": "S'inscrire avec Discord", "signInWith": "Se connecter avec :", "signInWithEmail": "Se connecter via e-mail", "signInWithMagicLink": "Continuer", "signUpWithMagicLink": "S'inscrire avec un lien magique", "pleaseInputYourEmail": "Veuillez entrer votre adresse e-mail", "settings": "Paramètres", "magicLinkSent": "Lien magique envoyé à votre email, veuillez vérifier votre boîte de réception", "invalidEmail": "S'il vous plaît, mettez une adresse email valide", "alreadyHaveAnAccount": "Déjà un compte ?", "logIn": "Connexion", "generalError": "Une erreur s'est produite. Veuillez réessayer plus tard", "limitRateError": "Pour des raisons de sécurité, vous ne pouvez demander un lien magique que toutes les 60 secondes", "magicLinkSentDescription": "Un lien magique vous a été envoyé par e-mail. Cliquez sur le lien pour vous connecter. Le lien expirera dans 5 minutes.", "tokenHasExpiredOrInvalid": "Le code a expiré ou est invalide. Veuillez réessayer.", "signingIn": "Connexion...", "checkYourEmail": "Vérifiez votre courrier électronique", "temporaryVerificationLinkSent": "Un lien de vérification temporaire a été envoyé.\nVeuillez vérifier votre boîte de réception sur", "temporaryVerificationCodeSent": "Un code de vérification temporaire a été envoyé.\nVeuillez vérifier votre boîte de réception sur", "continueToSignIn": "Continuer à se connecter", "continueWithLoginCode": "Continuer avec vos identifiants", "backToLogin": "Retour à la connexion", "enterCode": "Entrez le code", "enterCodeManually": "Entrez le code manuellement", "continueWithEmail": "Continuer avec l'e-mail", "enterPassword": "Entrez le mot de passe", "loginAs": "Connectez-vous en tant que", "invalidVerificationCode": "Veuillez saisir un code de vérification valide", "tooFrequentVerificationCodeRequest": "Vous avez fait trop de demandes. Veuillez réessayer plus tard.", "invalidLoginCredentials": "Votre mot de passe est incorrect, veuillez réessayer", "resetPassword": "Réinitialiser le mot de passe", "resetPasswordDescription": "Entrer votre courriel pour réinitialiser votre mot de passe", "continueToResetPassword": "Continuer pour réinitialiser le mot de passe", "resetPasswordSuccess": "Le mot de passe a été réinitialisé avec succès", "resetPasswordFailed": "Échec de la réinitialisation du mot de passe", "resetPasswordLinkSent": "Un mail permettant de réinitialiser le mot de passe a été envoyé à votre adresse.", "resetPasswordLinkExpired": "Ce lien de renouvellement de mot de passe est obsolète. Veuillez redemander un lien de réinitialisation.", "resetPasswordLinkInvalid": "Ce lien de renouvellement de mot de passe est erroné. Veuillez redemander un lien de réinitialisation.", "enterNewPasswordFor": "Entrez votre nouveau mot de passe", "newPassword": "Nouveau mot de passe", "enterNewPassword": "Entrer votre nouveau mot de passe", "confirmPassword": "Confirmer votre mot de passe", "confirmNewPassword": "Confirmer votre nouveau mot de passe", "newPasswordCannotBeEmpty": "Le champ \"nouveau mot de passe\" ne peut pas être laissé vide.", "confirmPasswordCannotBeEmpty": "Le champ \"confirmation du nouveau mot de passe\" ne peut pas être laissé vide.", "passwordsDoNotMatch": "Le mot de passe et sa confirmation ne correspondent pas.", "verifying": "En cours de vérification", "youAreInLocalMode": "Vous êtes en mode local", "loginToAppFlowyCloud": "Connexion au nuage d'AppFlowy", "anonymous": "Anonyme", "LogInWithGoogle": "Se connecter avec Google", "LogInWithGithub": "Se connecter avec Github", "LogInWithDiscord": "Se connecter avec Discord", "loginAsGuestButtonText": "Commencer", "logInWithMagicLink": "Connectez-vous avec Magic Link" }, "workspace": { "chooseWorkspace": "Choisissez votre espace de travail", "defaultName": "Mon espace de travail", "create": "Créer un espace de travail", "new": "Nouveau espace de travail", "importFromNotion": "Importer depuis Notion", "learnMore": "En savoir plus", "reset": "Réinitialiser l'espace de travail", "renameWorkspace": "Renommer l'espace de travail", "workspaceNameCannotBeEmpty": "Le nom de l'espace de travail ne peut être vide", "resetWorkspacePrompt": "La réinitialisation de l'espace de travail supprimera toutes les pages et données qu'elles contiennent. Êtes-vous sûr de vouloir réinitialiser l'espace de travail ? Alternativement, vous pouvez contacter l'équipe d'assistance pour restaurer l'espace de travail", "hint": "Espace de travail", "notFoundError": "Espace de travail introuvable", "failedToLoad": "Quelque chose s'est mal passé ! Échec du chargement de l'espace de travail. Essayez de fermer toute instance ouverte d'@:appName et réessayez.", "errorActions": { "reportIssue": "Signaler un problème", "reportIssueOnGithub": "Signaler un bug sur Github", "exportLogFiles": "Exporter les logs", "reachOut": "Contactez-nous sur Discord" }, "menuTitle": "Espaces de travail", "deleteWorkspaceHintText": "Êtes-vous sûr de vouloir supprimer l'espace de travail ? Cette action ne peut pas être annulée.", "createSuccess": "Espace de travail créé avec succès", "createFailed": "Échec de la création de l'espace de travail", "createLimitExceeded": "Vous avez atteint la limite maximale d'espace de travail autorisée pour votre compte. Si vous avez besoin d'espaces de travail supplémentaires pour continuer votre travail, veuillez en faire la demande sur Github.", "deleteSuccess": "Espace de travail supprimé avec succès", "deleteFailed": "Échec de la suppression de l'espace de travail", "openSuccess": "Ouverture de l'espace de travail réussie", "openFailed": "Échec de l'ouverture de l'espace de travail", "renameSuccess": "Espace de travail renommé avec succès", "renameFailed": "Échec du renommage de l'espace de travail", "updateIconSuccess": "L'icône de l'espace de travail a été mise à jour avec succès", "updateIconFailed": "La mise a jour de l'icône de l'espace de travail a échoué", "cannotDeleteTheOnlyWorkspace": "Impossible de supprimer le seul espace de travail", "fetchWorkspacesFailed": "Échec de la récupération des espaces de travail", "leaveCurrentWorkspace": "Quitter l'espace de travail", "leaveCurrentWorkspacePrompt": "Êtes-vous sûr de vouloir quitter l'espace de travail actuel ?" }, "shareAction": { "buttonText": "Partager", "workInProgress": "Bientôt disponible", "markdown": "Markdown", "html": "HTML", "clipboard": "Copier dans le presse-papier", "csv": "CSV", "copyLink": "Copier le lien", "publishToTheWeb": "Publier sur le Web", "publishToTheWebHint": "Créer un site Web avec AppFlowy", "publish": "Publier", "unPublish": "Annuler la publication", "visitSite": "Visitez le site", "exportAsTab": "Exporter en tant que", "publishTab": "Publier", "shareTab": "Partager", "publishOnAppFlowy": "Publier sur AppFlowy", "shareTabTitle": "Inviter à collaborer", "shareTabDescription": "Pour faciliter la collaboration avec n'importe qui", "copyLinkSuccess": "Lien copié", "copyShareLink": "Copier le lien de partage", "copyLinkFailed": "Impossible de copier le lien dans le presse-papiers", "copyLinkToBlockSuccess": "Lien de bloc copié dans le presse-papiers", "copyLinkToBlockFailed": "Impossible de copier le lien du bloc dans le presse-papiers", "manageAllSites": "Gérer tous les sites", "updatePathName": "Mettre à jour le nom du chemin" }, "moreAction": { "small": "petit", "medium": "moyen", "large": "grand", "fontSize": "Taille de police", "import": "Importer", "moreOptions": "Plus d'options", "wordCount": "Compteur de mot: {}", "charCount": "Compteur de caractère: {}", "createdAt": "Créé à: {}", "deleteView": "Supprimer", "duplicateView": "Dupliquer", "wordCountLabel": "Mots:", "charCountLabel": "Charactères: ", "createdAtLabel": "Créé:", "syncedAtLabel": "Synchronisé", "saveAsNewPage": "Ajouter des messages à la page", "saveAsNewPageDisabled": "Aucun message disponible" }, "importPanel": { "textAndMarkdown": "Texte et Markdown", "documentFromV010": "Document de la v0.1.0", "databaseFromV010": "Base de données à partir de la v0.1.0", "notionZip": "Fichier ZIP exporté depuis Notion", "csv": "CSV", "database": "Base de données" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "Faites glisser et déposez un fichier, cliquez pour ", "placeholderUpload": "Télécharger", "placeholderRight": ", ou collez un lien d'image.", "dropToUpload": "Déposez un fichier à télécharger", "change": "Changement" } }, "disclosureAction": { "rename": "Renommer", "delete": "Supprimer", "duplicate": "Dupliquer", "unfavorite": "Retirer des favoris", "favorite": "Ajouter aux favoris", "openNewTab": "Ouvrir dans un nouvel onglet", "moveTo": "Déplacer vers", "addToFavorites": "Ajouter aux Favoris", "copyLink": "Copier le lien", "changeIcon": "Changer d'icône", "collapseAllPages": "Réduire toutes les sous-pages", "movePageTo": "Déplacer vers", "move": "Déplacer", "lockPage": "Verrouiller la page" }, "blankPageTitle": "Page vierge", "newPageText": "Nouvelle page", "newDocumentText": "Nouveau document", "newGridText": "Nouvelle grille", "newCalendarText": "Nouveau calendrier", "newBoardText": "Nouveau tableau", "chat": { "newChat": "Chat IA", "inputMessageHint": "Demandez à l'IA @:appName", "inputLocalAIMessageHint": "Demander l'IA locale @:appName", "unsupportedCloudPrompt": "Cette fonctionnalité n'est disponible que lors de l'utilisation du cloud @:appName", "relatedQuestion": "Questions Associées", "serverUnavailable": "Service temporairement indisponible. Veuillez réessayer ultérieurement.", "aiServerUnavailable": "🌈 Oh-oh ! 🌈. Une licorne a mangé notre réponse. Veuillez réessayer !", "retry": "Réessayer", "clickToRetry": "Cliquez pour réessayer", "regenerateAnswer": "Régénérer", "question1": "Comment utiliser Kanban pour gérer les tâches", "question2": "Expliquez la méthode GTD", "question3": "Pourquoi utiliser Rust", "question4": "Recette avec ce qu'il y a dans ma cuisine", "question5": "Créer une illustration pour ma page", "question6": "Dresser une liste de choses à faire pour ma semaine à venir", "aiMistakePrompt": "L'IA peut faire des erreurs. Vérifiez les informations importantes.", "chatWithFilePrompt": "Voulez-vous discuter avec le fichier ?", "indexFileSuccess": "Indexation du fichier réussie", "inputActionNoPages": "Aucun résultat de page", "referenceSource": { "zero": "0 sources trouvées", "one": "{count} source trouvée", "other": "{count} sources trouvées" }, "clickToMention": "Cliquez pour mentionner une page", "uploadFile": "Téléchargez des fichiers PDF, MD ou TXT pour discuter avec", "questionDetail": "Bonjour {}! Comment puis-je vous aider aujourd'hui?", "indexingFile": "Indexation {}", "generatingResponse": "Générer une réponse", "selectSources": "Sélectionner Sources", "currentPage": "Page actuelle", "sourcesLimitReached": "Vous ne pouvez sélectionner que jusqu'à 3 documents de niveau supérieur et leurs enfants", "sourceUnsupported": "Nous ne prenons pas en charge le chat avec des bases de données pour le moment", "regenerate": "Réessayer", "addToPageButton": "Ajouter à la page", "addToPageTitle": "Ajouter un message à...", "addToNewPage": "Ajouter à une nouvelle page", "addToNewPageName": "Messages extraits de \"{}\"", "addToNewPageSuccessToast": "Message ajouté à", "openPagePreviewFailedToast": "Échec de l'ouverture de la page", "changeFormat": { "actionButton": "Changer de format", "confirmButton": "Régénérer avec ce format", "textOnly": "Texte", "imageOnly": "Image seulement", "textAndImage": "Texte et image", "text": "Paragraphe", "bullet": "Liste à puces", "number": "Liste numérotée", "table": "Tableau", "blankDescription": "Format de réponse", "defaultDescription": "Format de réponse automatique", "textWithImageDescription": "@:chat .changeFormat.text avec image", "numberWithImageDescription": "@:chat .changeFormat.number avec image", "bulletWithImageDescription": "@:chat .changeFormat.bullet avec image", "tableWithImageDescription": "@:chat .changeFormat.table avec image" }, "switchModel": { "label": "Modèle de commutateur", "localModel": "Modèle local", "cloudModel": "Modèle de nuage", "autoModel": "Auto" }, "selectBanner": { "saveButton": "Ajouter à …", "selectMessages": "Sélectionner les messages", "nSelected": "{} sélectionné", "allSelected": "Tous sélectionnés" }, "stopTooltip": "Arrêter de générer" }, "trash": { "text": "Corbeille", "restoreAll": "Tout restaurer", "restore": "Restaurer", "deleteAll": "Tout supprimer", "pageHeader": { "fileName": "Nom de fichier", "lastModified": "Dernière modification", "created": "Créé" }, "confirmDeleteAll": { "title": "Voulez-vous vraiment supprimer toutes les pages de la corbeille ?", "caption": "Cette action ne peut pas être annulée." }, "confirmRestoreAll": { "title": "Êtes-vous sûr de vouloir restaurer toutes les pages dans la corbeille ?", "caption": "Cette action ne peut pas être annulée." }, "restorePage": { "title": "Restaurer: {}", "caption": "Etes-vous sûr de vouloir restaurer cette page ?" }, "mobile": { "actions": "Actions de la corbeille", "empty": "La corbeille est vide", "emptyDescription": "Vous n'avez aucun fichier supprimé", "isDeleted": "a été supprimé", "isRestored": "a été restauré" }, "confirmDeleteTitle": "Etes-vous sûr de vouloir supprimer définitivement cette page ?" }, "deletePagePrompt": { "text": "Cette page se trouve dans la corbeille", "restore": "Restaurer la page", "deletePermanent": "Supprimer définitivement", "deletePermanentDescription": "Etes-vous sûr de vouloir supprimer définitivement cette page ? Cette action est irréversible." }, "dialogCreatePageNameHint": "Nom de la page", "questionBubble": { "shortcuts": "Raccourcis", "whatsNew": "Nouveautés", "helpAndDocumentation": "Aide et documentation", "getSupport": "Obtenir de l'aide", "markdown": "Rédaction", "debug": { "name": "Informations de Débogage", "success": "Informations de débogage copiées dans le presse-papiers !", "fail": "Impossible de copier les informations de débogage dans le presse-papiers" }, "feedback": "Retour", "help": "Aide et Support" }, "menuAppHeader": { "moreButtonToolTip": "Supprimer, renommer et plus...", "addPageTooltip": "Ajoutez rapidement une page à l'intérieur", "defaultNewPageName": "Sans-titre", "renameDialog": "Renommer", "pageNameSuffix": "Copier" }, "noPagesInside": "Aucune page à l'intérieur", "toolbar": { "undo": "Annuler", "redo": "Rétablir", "bold": "Gras", "italic": "Italique", "underline": "Souligner", "strike": "Barré", "numList": "Liste numérotée", "bulletList": "Liste à puces", "checkList": "To-Do list", "inlineCode": "Code en ligne", "quote": "Citation", "header": "En-tête", "highlight": "Surligner", "color": "Couleur", "addLink": "Ajouter un lien", "link": "Lien" }, "tooltip": { "lightMode": "Passer en mode clair", "darkMode": "Passer en mode sombre", "openAsPage": "Ouvrir en tant que page", "addNewRow": "Ajouter une ligne", "openMenu": "Cliquer pour ouvrir le menu", "dragRow": "Appuyez longuement pour réorganiser la ligne", "viewDataBase": "Voir la base de données", "referencePage": "Ce {nom} est référencé", "addBlockBelow": "Ajouter un bloc ci-dessous", "aiGenerate": "Générer" }, "sideBar": { "closeSidebar": "Fermer le menu latéral", "openSidebar": "Ouvrir le menu latéral", "expandSidebar": "Agrandir la page", "personal": "Personnel", "private": "Privé", "workspace": "Espace de travail", "favorites": "Favoris", "clickToHidePrivate": "Cliquez pour masquer l'espace privé\nLes pages que vous avez créées ici ne sont visibles que par vous", "clickToHideWorkspace": "Cliquez pour masquer l'espace de travail\nLes pages que vous avez créées ici sont visibles par tous les membres", "clickToHidePersonal": "Cliquez pour cacher la section personnelle", "clickToHideFavorites": "Cliquez pour cacher la section favorite", "addAPage": "Ajouter une page", "addAPageToPrivate": "Ajouter une page à l'espace privé", "addAPageToWorkspace": "Ajouter une page à l'espace de travail", "recent": "Récent", "today": "Aujourd'hui", "thisWeek": "Cette semaine", "others": "Favoris précédents", "earlier": "Plus tôt", "justNow": "tout à l' heure", "minutesAgo": "Il y a {count} minutes", "lastViewed": "Dernière consultation", "favoriteAt": "Favoris", "emptyRecent": "Aucun document récent", "emptyRecentDescription": "Quand vous consultez des documents, ils apparaîtront ici pour les retrouver facilement", "emptyFavorite": "Aucun document favori", "emptyFavoriteDescription": "Commencez à explorer et marquez les documents comme favoris. Ils seront répertoriés ici pour un accès rapide !", "removePageFromRecent": "Supprimer cette page des Récents ?", "removeSuccess": "Supprimé avec succès", "favoriteSpace": "Favoris", "RecentSpace": "Récent", "Spaces": "Espaces", "upgradeToPro": "Passer à Pro", "upgradeToAIMax": "Débloquez une l'IA illimitée", "storageLimitDialogTitle": "Vous n'avez plus d'espace de stockage gratuit. Effectuez une mise à niveau pour débloquer un espace de stockage illimité", "storageLimitDialogTitleIOS": "Vous n'avez plus d'espace de stockage gratuit.", "aiResponseLimitTitle": "Vous n'avez plus de réponses d'IA gratuites. Passez au plan Pro ou achetez un module complémentaire d'IA pour débloquer des réponses illimitées", "aiResponseLimitDialogTitle": "La limite des réponses de l'IA a été atteinte", "aiResponseLimit": "Vous n'avez plus de réponses IA gratuites.\n\nAccédez à Paramètres -> Plans -> Cliquez sur AI Max ou Pro Plan pour obtenir plus de réponses AI", "askOwnerToUpgradeToPro": "Votre espace de stockage gratuit est presque plein. Demandez au propriétaire de votre espace de travail de passer au plan Pro", "askOwnerToUpgradeToProIOS": "Votre espace de travail manque d’espace de stockage gratuit.", "askOwnerToUpgradeToAIMax": "Votre espace de travail est à court de réponses d'IA gratuites. Demandez au propriétaire de votre espace de travail de mettre à niveau le plan ou d'acheter des modules complémentaires d'IA", "askOwnerToUpgradeToAIMaxIOS": "Votre espace de travail est à court de réponses IA gratuites.", "purchaseAIMax": "Votre espace de travail est à court de réponses AI Image. Veuillez demander au propriétaire de votre espace d'acheter AI Max.", "aiImageResponseLimit": "Vous n’avez plus de réponses d’image IA.\nAccédez à Paramètres -> Plan -> Cliquez sur AI Max pour obtenir plus de réponses d'images AI", "purchaseStorageSpace": "Acheter un espace de stockage", "singleFileProPlanLimitationDescription": "Vous avez dépassé la taille maximale de téléchargement de fichiers autorisée dans le plan gratuit. Veuillez passer au plan Pro pour télécharger des fichiers plus volumineux", "purchaseAIResponse": "Acheter", "askOwnerToUpgradeToLocalAI": "Demander au propriétaire de l'espace de travail d'activer l'IA locale", "upgradeToAILocal": "Exécutez des modèles locaux sur votre appareil pour une confidentialité optimale", "upgradeToAILocalDesc": "Discutez avec des PDF, améliorez votre écriture et remplissez automatiquement des tableaux à l'aide de l'IA locale", "public": "Publique", "clickToHidePublic": "Cliquez pour masquer l'espace public\nLes pages que vous avez créées ici sont visibles par tous les membres", "addAPageToPublic": "Ajouter une page à l'espace public" }, "notifications": { "export": { "markdown": "Note exportée en Markdown", "path": "Documents/flowy" } }, "contactsPage": { "title": "Contacts", "whatsHappening": "Que se passe-t-il cette semaine ?", "addContact": "Ajouter un contact", "editContact": "Modifier le contact" }, "button": { "ok": "OK", "confirm": "Confirmer", "done": "Fait", "cancel": "Annuler", "signIn": "Se connecter", "signOut": "Se déconnecter", "complete": "Achevé", "save": "Enregistrer", "generate": "Générer", "esc": "ESC", "keep": "Garder", "tryAgain": "Essayer à nouveau", "discard": "Jeter", "replace": "Remplacer", "insertBelow": "Insérer ci-dessous", "insertAbove": "Insérer ci-dessus", "upload": "Télécharger", "edit": "Modifier", "delete": "Supprimer", "copy": "Copier", "duplicate": "Dupliquer", "putback": "Remettre", "update": "Mettre à jour", "share": "Partager", "removeFromFavorites": "Retirer des favoris", "removeFromRecent": "Supprimer des récents", "addToFavorites": "Ajouter aux favoris", "favoriteSuccessfully": "Succès en favoris", "unfavoriteSuccessfully": "Succès retiré des favoris", "duplicateSuccessfully": "Dupliqué avec succès", "rename": "Renommer", "helpCenter": "Centre d'aide", "add": "Ajouter", "yes": "Oui", "no": "Non", "clear": "Nettoyer", "remove": "Retirer", "dontRemove": "Ne pas retirer", "copyLink": "Copier le lien", "align": "Aligner", "login": "Se connecter", "logout": "Se déconnecter", "deleteAccount": "Supprimer le compte", "back": "Retour", "signInGoogle": "Se connecter avec Google", "signInGithub": "Se connecter avec Github", "signInDiscord": "Se connecter avec Discord", "more": "Plus", "create": "Créer", "close": "Fermer", "next": "Suivant", "previous": "Précédent", "submit": "Soumettre", "download": "Télécharger", "backToHome": "Retour à l'accueil", "viewing": "Affichage", "editing": "Édition", "gotIt": "Compris", "retry": "Réessayer ", "uploadFailed": "Échec du téléchargement.", "copyLinkOriginal": "Copier le lien vers l'original", "tryAGain": "Réessayer" }, "label": { "welcome": "Bienvenue !", "firstName": "Prénom", "middleName": "Deuxième prénom", "lastName": "Nom", "stepX": "Étape {X}" }, "oAuth": { "err": { "failedTitle": "Impossible de se connecter à votre compte.", "failedMsg": "Assurez-vous d'avoir terminé le processus de connexion dans votre navigateur." }, "google": { "title": "CONNEXION VIA GOOGLE", "instruction1": "Pour importer vos contacts Google, vous devez autoriser cette application à l'aide de votre navigateur web.", "instruction2": "Copiez ce code dans votre presse-papiers en cliquant sur l'icône ou en sélectionnant le texte :", "instruction3": "Accédez au lien suivant dans votre navigateur web et saisissez le code ci-dessus :", "instruction4": "Appuyez sur le bouton ci-dessous lorsque vous avez terminé votre inscription :" } }, "settings": { "title": "Paramètres", "popupMenuItem": { "settings": "Paramètres", "members": "Membres", "trash": "Corbeille", "helpAndDocumentation": "Aide et documentation", "getSupport": "Obtenir de l'aide", "helpAndSupport": "Aide & Support" }, "sites": { "title": "Sites", "namespaceTitle": "Espace", "namespaceDescription": "Gérez votre espace et votre page d'accueil", "namespaceHeader": "Espace de nom", "homepageHeader": "Page d'accueil", "updateNamespace": "Mettre à jour l'espace", "removeHomepage": "Supprimer la page d'accueil", "selectHomePage": "Sélectionnez une page", "clearHomePage": "Effacer la page d'accueil pour cet espace", "customUrl": "URL personnalisée", "homePage": { "upgradeToPro": "Passez à la formule pro pour désigner une page d'accueil" }, "namespace": { "description": "Ce changement s'appliquera à toutes les pages publiées en direct sur cet espace", "tooltip": "Nous nous réservons le droit de supprimer tout espace inapproprié", "updateExistingNamespace": "Mettre à jour l'espace existant", "upgradeToPro": "Passez au plan Pro pour définir une page d'accueil", "redirectToPayment": "Redirection vers la page de paiement...", "onlyWorkspaceOwnerCanSetHomePage": "Seul le propriétaire de l'espace de travail peut définir une page d'accueil", "pleaseAskOwnerToSetHomePage": "Veuillez demander au propriétaire de l'espace de travail de passer au plan Pro" }, "publishedPage": { "title": "Toutes les pages publiées", "description": "Gérez vos pages publiées", "page": "Page", "pathName": "Nom du chemin", "date": "Date de publication", "emptyHinText": "Vous n'avez aucune page publiée dans cet espace de travail", "noPublishedPages": "Aucune page publiée", "settings": "Paramètres de publication", "clickToOpenPageInApp": "Ouvrir la page dans l'application", "clickToOpenPageInBrowser": "Ouvrir la page dans le navigateur" }, "error": { "failedToGeneratePaymentLink": "Impossible de générer le lien de paiement pour le plan Pro", "failedToUpdateNamespace": "Échec de la mise à jour de l'espace", "proPlanLimitation": "Vous devez effectuer une mise à niveau vers le plan Pro pour mettre à jour l'espace", "namespaceAlreadyInUse": "Ce nom d'espace déjà pris, veuillez en essayer un autre", "invalidNamespace": "Nom d'espace invalide, veuillez en essayer un autre", "namespaceLengthAtLeast2Characters": "Le nom de l'espace doit comporter au moins 2 caractères", "onlyWorkspaceOwnerCanUpdateNamespace": "Seul le propriétaire de l'espace de travail peut mettre à jour l'espace", "onlyWorkspaceOwnerCanRemoveHomepage": "Seul le propriétaire de l'espace de travail peut supprimer la page d'accueil", "setHomepageFailed": "Impossible de définir la page d'accueil", "namespaceTooLong": "Le nom de l'espace est trop long, veuillez en essayer un autre", "namespaceTooShort": "Le nom de l'espace est trop court, veuillez en essayer un autre", "namespaceIsReserved": "Ce nom d'espace est réservé, veuillez en essayer un autre", "updatePathNameFailed": "Échec de la mise à jour du nom du chemin", "removeHomePageFailed": "Impossible de supprimer la page d'accueil", "publishNameContainsInvalidCharacters": "Le nom du chemin contient des caractères non valides, veuillez en essayer un autre", "publishNameTooShort": "Le nom du chemin est trop court, veuillez en essayer un autre", "publishNameTooLong": "Le nom du chemin est trop long, veuillez en essayer un autre", "publishNameAlreadyInUse": "Le nom du chemin est déjà utilisé, veuillez en essayer un autre", "namespaceContainsInvalidCharacters": "Le nom d'espace contient des caractères non valides, veuillez en essayer un autre", "publishPermissionDenied": "Seul le propriétaire de l'espace de travail ou l'éditeur de la page peut gérer les paramètres de publication", "publishNameCannotBeEmpty": "Le nom du chemin ne peut pas être vide, veuillez en essayer un autre" }, "success": { "namespaceUpdated": "Espace mis à jour avec succès", "setHomepageSuccess": "Définir la page d'accueil avec succès", "updatePathNameSuccess": "Nom du chemin mis à jour avec succès", "removeHomePageSuccess": "Supprimer la page d'accueil avec succès" } }, "accountPage": { "menuLabel": "Mon compte", "title": "Mon compte", "general": { "title": "Nom du compte et image de profil", "changeProfilePicture": "Changer la photo de profil" }, "email": { "title": "Email", "actions": { "change": "Modifier l'email" } }, "login": { "title": "Connexion au compte", "loginLabel": "Connexion", "logoutLabel": "Déconnexion" }, "isUpToDate": "@:appName est à jour !", "officialVersion": "Version {version} (version officielle)" }, "workspacePage": { "menuLabel": "Espace de travail", "title": "Espace de travail", "description": "Personnalisez l'apparence, le thème, la police, la disposition du texte, le format de la date/heure et la langue de votre espace de travail.", "workspaceName": { "title": "Nom de l'espace de travail", "savedMessage": "Nom de l'espace de travail enregistré" }, "workspaceIcon": { "title": "Icône de l'espace de travail", "description": "Personnalisez l'apparence, le thème, la police, la disposition du texte, la date, l'heure et la langue de votre espace de travail." }, "appearance": { "title": "Apparence", "description": "Personnalisez l'apparence, le thème, la police, la disposition du texte, la date, l'heure et la langue de votre espace de travail.", "options": { "system": "Auto", "light": "Clair", "dark": "Foncé" } }, "resetCursorColor": { "title": "Réinitialiser la couleur du curseur du document", "description": "Êtes-vous sûr de vouloir réinitialiser la couleur du curseur ?" }, "resetSelectionColor": { "title": "Réinitialiser la couleur de sélection du document", "description": "Êtes-vous sûr de vouloir réinitialiser la couleur de sélection ?" }, "resetWidth": { "resetSuccess": "Réinitialisation réussie de la largeur du document" }, "theme": { "title": "Thème", "description": "Sélectionnez un thème prédéfini ou téléchargez votre propre thème personnalisé.", "uploadCustomThemeTooltip": "Télécharger un thème personnalisé", "failedToLoadThemes": "Échec du chargement des thèmes. Veuillez vérifier vos paramètres d'autorisation dans Paramètres système > Confidentialité et sécurité > Fichiers et dossiers > @:appName" }, "workspaceFont": { "title": "Police de caractère de l'espace de travail", "noFontHint": "Aucune police trouvée, essayez un autre terme." }, "textDirection": { "title": "Sens du texte", "leftToRight": "De gauche à droite", "rightToLeft": "De droite à gauche", "auto": "Auto", "enableRTLItems": "Activer les éléments de la barre d'outils RTL" }, "layoutDirection": { "title": "Sens de mise en page", "leftToRight": "De gauche à droite", "rightToLeft": "De droite à gauche" }, "dateTime": { "title": "Date et heure", "example": "{} à {} ({})", "24HourTime": "Heure sur 24 heures", "dateFormat": { "label": "Format de date", "local": "Locale", "us": "US", "iso": "ISO", "friendly": "Facile à lire", "dmy": "J/M/A" } }, "language": { "title": "Langue" }, "deleteWorkspacePrompt": { "title": "Supprimer l'espace de travail", "content": "Êtes-vous sûr de vouloir supprimer cet espace de travail ? Cette action ne peut pas être annulée." }, "leaveWorkspacePrompt": { "title": "Quitter l'espace de travail", "content": "Êtes-vous sûr de vouloir quitter cet espace de travail ? Vous allez perdre l’accès à toutes les pages et données qu’il contient.", "success": "Vous avez quitté l'espace de travail avec succès.", "fail": "Impossible de quitter l'espace de travail." }, "manageWorkspace": { "title": "Gérer l'espace de travail", "leaveWorkspace": "Quitter l'espace de travail", "deleteWorkspace": "Supprimer l'espace de travail" } }, "manageDataPage": { "menuLabel": "Gérer les données", "title": "Gérer les données", "description": "Gérez le stockage local des données ou importez vos données existantes dans @:appName .", "dataStorage": { "title": "Emplacement de stockage des fichiers", "tooltip": "L'emplacement où vos fichiers sont stockés", "actions": { "change": "Changer de chemin", "open": "Ouvrir le répertoire", "openTooltip": "Ouvrir l’emplacement actuel du dossier de données", "copy": "Copier le chemin", "copiedHint": "Lien copié !", "resetTooltip": "Réinitialiser à l'emplacement par défaut" }, "resetDialog": { "title": "Êtes-vous sûr ?", "description": "La réinitialisation du chemin d'accès à l'emplacement de données par défaut ne supprimera pas vos données. Si vous souhaitez réimporter vos données actuelles, vous devriez sauvegarder le chemin d'accès actuel." } }, "importData": { "title": "Importer des données", "tooltip": "Importer des données depuis les dossiers de sauvegarde/données @:appName", "description": "Copier les données à partir d'un dossier de données externe @:appName", "action": "Parcourir le dossier" }, "encryption": { "title": "Chiffrement", "tooltip": "Gérez la manière dont vos données sont stockées et cryptées", "descriptionNoEncryption": "L'activation du cryptage crypte toutes les données. Cette opération ne peut pas être annulée.", "descriptionEncrypted": "Vos données sont cryptées.", "action": "Crypter les données", "dialog": { "title": "Crypter toutes vos données ?", "description": "Le cryptage de toutes vos données permettra de les protéger et de les sécuriser. Cette action NE PEUT PAS être annulée. Êtes-vous sûr de vouloir continuer ?" } }, "cache": { "title": "Vider le cache", "description": "Aide à résoudre des problèmes tels que des image qui ne se chargent pas, des pages manquantes dans un espace ou les polices qui ne se chargent pas. Cela n'affectera pas vos données.", "dialog": { "title": "Vider le cache", "description": "Aide à résoudre des problèmes tels que des image qui ne se chargent pas, des pages manquantes dans un espace ou les polices qui ne se chargent pas. Cela n'affectera pas vos données.", "successHint": "Cache vidé !" } }, "data": { "fixYourData": "Corrigez vos données", "fixButton": "Réparer", "fixYourDataDescription": "Si vous rencontrez des problèmes avec vos données, vous pouvez essayer de les résoudre ici." } }, "shortcutsPage": { "menuLabel": "Raccourcis", "title": "Raccourcis", "editBindingHint": "Saisir une nouvelle liaison", "searchHint": "Rechercher", "actions": { "resetDefault": "Réinitialiser les paramètres par défaut" }, "errorPage": { "message": "Échec du chargement des raccourcis : {}", "howToFix": "Veuillez réessayer. Si le problème persiste, veuillez nous contacter sur GitHub." }, "resetDialog": { "title": "Réinitialiser les raccourcis", "description": "Cela réinitialisera tous vos raccourcis clavier aux valeurs par défaut, vous ne pourrez pas annuler cette opération plus tard, êtes-vous sûr de vouloir continuer ?", "buttonLabel": "Réinitialiser" }, "conflictDialog": { "title": "{} est actuellement utilisé", "descriptionPrefix": "Ce raccourci clavier est actuellement utilisé par ", "descriptionSuffix": ". Si vous remplacez ce raccourci clavier, il sera supprimé de {}.", "confirmLabel": "Continuer" }, "editTooltip": "Appuyez pour commencer à modifier le raccourci clavier", "keybindings": { "toggleToDoList": "Basculer vers la liste des tâches", "insertNewParagraphInCodeblock": "Insérer un nouveau paragraphe", "pasteInCodeblock": "Coller dans le bloc de code", "selectAllCodeblock": "Sélectionner tout", "indentLineCodeblock": "Insérer deux espaces au début de la ligne", "outdentLineCodeblock": "Supprimer deux espaces au début de la ligne", "twoSpacesCursorCodeblock": "Insérer deux espaces au niveau du curseur", "copy": "Copier la sélection", "paste": "Coller le contenu", "cut": "Couper la sélection", "alignLeft": "Aligner le texte à gauche", "alignCenter": "Aligner le texte au centre", "alignRight": "Aligner le texte à droite", "insertInlineMathEquation": "Insérer une équation mathématique en ligne", "undo": "Annuler", "redo": "Rétablir", "convertToParagraph": "Convertir un bloc en paragraphe", "backspace": "Supprimer", "deleteLeftWord": "Supprimer le mot de gauche", "deleteLeftSentence": "Supprimer la phrase de gauche", "delete": "Supprimer le caractère de droite", "deleteMacOS": "Supprimer le caractère de gauche", "deleteRightWord": "Supprimer le mot de droite", "moveCursorLeft": "Déplacer le curseur vers la gauche", "moveCursorBeginning": "Déplacer le curseur au début", "moveCursorLeftWord": "Déplacer le curseur d'un mot vers la gauche", "moveCursorLeftSelect": "Sélectionnez et déplacez le curseur vers la gauche", "moveCursorBeginSelect": "Sélectionnez et déplacez le curseur au début", "moveCursorLeftWordSelect": "Sélectionnez et déplacez le curseur d'un mot vers la gauche", "moveCursorRight": "Déplacer le curseur vers la droite", "moveCursorEnd": "Déplacer le curseur jusqu'à la fin", "moveCursorRightWord": "Déplacer le curseur d'un mot vers la droite", "moveCursorRightSelect": "Sélectionnez et déplacez le curseur vers la droite", "moveCursorEndSelect": "Sélectionnez et déplacez le curseur jusqu'à la fin", "moveCursorRightWordSelect": "Sélectionnez et déplacez le curseur vers la droite d'un mot", "moveCursorUp": "Déplacer le curseur vers le haut", "moveCursorTopSelect": "Sélectionnez et déplacez le curseur vers le haut", "moveCursorTop": "Déplacer le curseur vers le haut", "moveCursorUpSelect": "Sélectionnez et déplacez le curseur vers le haut", "moveCursorBottomSelect": "Sélectionnez et déplacez le curseur vers le bas", "moveCursorBottom": "Déplacer le curseur vers le bas", "moveCursorDown": "Déplacer le curseur vers le bas", "moveCursorDownSelect": "Sélectionnez et déplacez le curseur vers le bas", "home": "Faites défiler vers le haut", "end": "Faites défiler vers le bas", "toggleBold": "Inverser le gras", "toggleItalic": "Inverser l'italique", "toggleUnderline": "Inverser le soulignement", "toggleStrikethrough": "Inverser le barré", "toggleCode": "Inverser la mise en forme code", "toggleHighlight": "Inverser la surbrillance", "showLinkMenu": "Afficher le menu des liens", "openInlineLink": "Ouvrir le lien en ligne", "openLinks": "Ouvrir tous les liens sélectionnés", "indent": "Augmenter le retrait", "outdent": "Diminuer le retrait", "exit": "Quitter l'édition", "pageUp": "Faites défiler une page vers le haut", "pageDown": "Faites défiler une page vers le bas", "selectAll": "Sélectionner tout", "pasteWithoutFormatting": "Coller le contenu sans formatage", "showEmojiPicker": "Afficher le sélecteur d'emoji", "enterInTableCell": "Ajouter un saut de ligne dans le tableau", "leftInTableCell": "Déplacer d'une cellule vers la gauche dans le tableau", "rightInTableCell": "Déplacer d'une cellule vers la droite dans le tableau", "upInTableCell": "Déplacer d'une cellule vers le haut dans le tableau", "downInTableCell": "Déplacer d'une cellule vers le bas dans le tableau", "tabInTableCell": "Aller à la prochaine cellule vide dans le tableau", "shiftTabInTableCell": "Aller à la précédente cellule vide dans le tableau", "backSpaceInTableCell": "S'arrêter au début de la cellule" }, "commands": { "codeBlockNewParagraph": "Insérer un nouveau paragraphe à côté du bloc de code", "codeBlockIndentLines": "Insérer deux retraits au début du bloc de code", "codeBlockOutdentLines": "Supprimer deux retraits au début du bloc de code", "codeBlockAddTwoSpaces": "Insérer deux retraits au niveau du curseur dans le bloc de code", "codeBlockSelectAll": "Sélectionner tout le contenu d'un bloc de code", "codeBlockPasteText": "Coller du texte dans le bloc de code", "textAlignLeft": "Aligner le texte à gauche", "textAlignCenter": "Aligner le texte au centre", "textAlignRight": "Aligner le texte à droite" }, "couldNotLoadErrorMsg": "Impossible de charger les raccourcis, réessayez", "couldNotSaveErrorMsg": "Impossible d'enregistrer les raccourcis, réessayez" }, "aiPage": { "title": "Paramètres de l'IA", "menuLabel": "Paramètres IA", "keys": { "enableAISearchTitle": "Recherche IA", "aiSettingsDescription": "Choisissez votre modèle préféré pour alimenter AppFlowy AI. Inclut désormais GPT 4-o, Claude 3,5, Llama 3.1 et Mistral 7B", "loginToEnableAIFeature": "Les fonctionnalités d'IA sont accessibles uniquement après s'être connecté avec @:appName Cloud. Pour créer un compte @:appName, voir dans la rubrique 'Mon Compte'.", "llmModel": "Modèle de langage", "readOnlyField": "Ce champ est en lecture seule", "llmModelType": "Type de modèle de langue", "downloadLLMPrompt": "Télécharger {}", "downloadAppFlowyOfflineAI": "Le téléchargement du package hors ligne AI permettra à AI de fonctionner sur votre appareil. Voulez-vous continuer ?", "downloadLLMPromptDetail": "Le téléchargement du modèle local {} prendra jusqu'à {} d'espace de stockage. Voulez-vous continuer ?", "downloadBigFilePrompt": "Le téléchargement peut prendre environ 10 minutes.", "downloadAIModelButton": "Télécharger", "downloadingModel": "Téléchargement", "localAILoaded": "Modèle d'IA local ajouté avec succès et prêt à être utilisé", "localAIStart": "Démarrage du chat avec l'IA locale...", "localAILoading": "Chargement du modèle d'IA locale...", "localAIStopped": "IA locale arrêtée", "localAIRunning": "L'IA locale est en cours d'exécution", "localAINotReadyRetryLater": "L'IA locale est en cours d'initialisation, veuillez réessayer plus tard", "localAIDisabled": "Vous utilisez l'IA locale, mais elle est désactivée. Veuillez accéder aux paramètres pour l'activer ou essayer un autre modèle.", "localAIInitializing": "L'IA locale est en cours de chargement. Cela peut prendre quelques secondes selon votre appareil.", "localAINotReadyTextFieldPrompt": "Vous ne pouvez pas modifier pendant le chargement de l'IA locale", "localAIDisabledTextFieldPrompt": "Vous ne pouvez pas modifier lorsque l'IA locale est désactivée", "failToLoadLocalAI": "Impossible de démarrer l'IA locale", "restartLocalAI": "Redémarrer l'IA locale", "disableLocalAITitle": "Désactiver l'IA locale", "disableLocalAIDescription": "Voulez-vous désactiver l'IA locale ?", "localAIToggleTitle": "Basculer pour activer ou désactiver l'IA locale", "localAIToggleSubTitle": "Exécutez les modèles d'IA locaux les plus avancés dans AppFlowy pour une confidentialité et une sécurité optimales", "offlineAIInstruction1": "Suivre les", "offlineAIInstruction2": "instructions", "offlineAIInstruction3": "pour activer l'IA hors ligne.", "offlineAIDownload1": "Si vous n'avez pas téléchargé l'IA AppFlowy, veuillez", "offlineAIDownload2": "télécharger", "offlineAIDownload3": "d'abord", "activeOfflineAI": "Activer", "downloadOfflineAI": "Télécharger", "openModelDirectory": "Ouvrir le dossier", "laiNotReady": "L'application Local AI n'a pas été installée correctement.", "ollamaNotReady": "Le serveur Ollama n'est pas prêt.", "pleaseFollowThese": "Veuillez suivre ces", "instructions": "instructions", "installOllamaLai": "pour configurer Ollama et AppFlowy Local AI.", "modelsMissing": "Impossible de trouver les modèles requis : ", "downloadModel": "pour les télécharger." } }, "planPage": { "menuLabel": "Offre", "title": "Tarif de l'offre", "planUsage": { "title": "Résumé de l'utilisation du plan", "storageLabel": "Stockage", "storageUsage": "{} sur {} GB", "unlimitedStorageLabel": "Stockage illimité", "collaboratorsLabel": "Membres", "collaboratorsUsage": "{} sur {}", "aiResponseLabel": "Réponses de l'IA", "aiResponseUsage": "{} sur {}", "unlimitedAILabel": "Réponses illimitées", "proBadge": "Pro", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "IA sur appareil pour Mac", "memberProToggle": "Plus de membres et une IA illimitée", "aiMaxToggle": "IA illimitée et accès à des modèles avancés", "aiOnDeviceToggle": "IA locale pour une confidentialité ultime", "aiCredit": { "title": "Ajoutez des crédit IA @:appName ", "price": "{}", "priceDescription": "pour 1 000 crédits", "purchase": "Acheter l'IA", "info": "Ajoutez 1 000 crédits d'IA par espace de travail et intégrez de manière transparente une IA personnalisable dans votre flux de travail pour des résultats plus intelligents et plus rapides avec jusqu'à :", "infoItemOne": "10 000 réponses par base de données", "infoItemTwo": "1 000 réponses par espace de travail" }, "currentPlan": { "bannerLabel": "Plan actuel", "freeTitle": "Gratuit", "proTitle": "Pro", "teamTitle": "Équipe", "freeInfo": "Idéal pour les particuliers jusqu'à 2 membres pour tout organiser", "proInfo": "Idéal pour les petites et moyennes équipes jusqu'à 10 membres.", "teamInfo": "Parfait pour toutes les équipes productives et bien organisées.", "upgrade": "Changer de plan", "canceledInfo": "Votre forfait est annulé, vous serez rétrogradé au plan gratuit le {}." }, "addons": { "title": "Compléments", "addLabel": "Ajouter", "activeLabel": "Ajouté", "aiMax": { "title": "AI Max", "description": "Réponses IA illimitées alimentées par GPT-4o, Claude 3.5 Sonnet et plus", "price": "{}", "priceInfo": "par utilisateur et par mois, facturé annuellement" }, "aiOnDevice": { "title": "IA sur appareil pour Mac", "description": "Exécutez Mistral 7B, LLAMA 3 et d'autres modèles locaux sur votre machine", "price": "{}", "priceInfo": "par utilisateur et par mois, facturé annuellement", "recommend": "Recommand2 M1 ou plus récent" } }, "deal": { "bannerLabel": "Offre de nouvelle année !", "title": "Développez votre équipe !", "info": "Effectuez une mise à niveau et économisez 10 % sur les forfaits Pro et Team ! Boostez la productivité de votre espace de travail avec de nouvelles fonctionnalités puissantes, notamment l'IA @:appName .", "viewPlans": "Voir les plans" } } }, "billingPage": { "menuLabel": "Facturation", "title": "Facturation", "plan": { "title": "Plan", "freeLabel": "Gratuit", "proLabel": "Pro", "planButtonLabel": "Changer de plan", "billingPeriod": "Période de facturation", "periodButtonLabel": "Changer la période " }, "paymentDetails": { "title": "Détails de paiement", "methodLabel": "Mode de paiement", "methodButtonLabel": "Changer de mode de paiement" }, "addons": { "title": "Compléments", "addLabel": "Ajouter", "removeLabel": "Retirer", "renewLabel": "Renouveler", "aiMax": { "label": "IA Max", "description": "Débloquez une IA illimitée et des modèles avancés", "activeDescription": "Prochaine facture due le {}", "canceledDescription": "IA Max sera disponible jusqu'au {}" }, "aiOnDevice": { "label": "IA local pour Mac", "description": "Débloquez une IA illimitée locale sur votre appareil", "activeDescription": "Prochaine facture due le {}", "canceledDescription": "IA locale pour Mac sera disponible jusqu'au {}" }, "removeDialog": { "title": "Supprimer", "description": "Êtes-vous sûr de vouloir supprimer {plan}? Vous perdrez l'accès aux fonctionnalités et bénéfices de {plan} de manière immédiate." } }, "currentPeriodBadge": "ACTUEL", "changePeriod": "Changer de période", "planPeriod": "{} période", "monthlyInterval": "Mensuel", "monthlyPriceInfo": "par personne, facturé mensuellement", "annualInterval": "Annuellement", "annualPriceInfo": "par personne, facturé annuellement" }, "comparePlanDialog": { "title": "Comparer et sélectionner un plan", "planFeatures": "Plan\nCaractéristiques", "current": "Actuel", "actions": { "upgrade": "Améliorer", "downgrade": "Rétrograder", "current": "Actuel" }, "freePlan": { "title": "Gratuit", "description": "Pour les particuliers jusqu'à 2 membres pour tout organiser", "price": "{}", "priceInfo": "gratuit pour toujours" }, "proPlan": { "title": "Pro", "description": "Pour les petites équipes pour gérer les projets et les bases de connaissance", "price": "{}", "priceInfo": "par utilisateur et par mois\nfacturé annuellement\n\n{} facturé mensuellement" }, "planLabels": { "itemOne": "Espaces de travail", "itemTwo": "Membres", "itemThree": "Stockage", "itemFour": "Collaboration en temps réel", "itemFive": "Application mobile", "itemSix": "Réponses de l'IA", "itemSeven": "Images IA", "itemFileUpload": "Téléchargements de fichiers", "customNamespace": "Nom d'espace personnalisé", "tooltipFive": "Collaborer sur des pages spécifiques avec des personnes qui ne sont pas membres", "tooltipSix": "La durée de vie signifie que le nombre de réponses n'est jamais réinitialisé", "intelligentSearch": "Recherche intelligente", "tooltipSeven": "Vous permet de personnaliser une partie de l'URL de votre espace de travail", "customNamespaceTooltip": "URL de site publiée personnalisée" }, "freeLabels": { "itemOne": "facturé par espace de travail", "itemTwo": "jusqu'à 2", "itemThree": "5 Go", "itemFour": "Oui", "itemFive": "Oui", "itemSix": "10 à vie", "itemSeven": "à vie", "itemFileUpload": "Jusqu'à 7 Mo", "intelligentSearch": "Recherche intelligente" }, "proLabels": { "itemOne": "facturé par espace de travail", "itemTwo": "jusqu'à 10", "itemThree": "illimité", "itemFour": "Oui", "itemFive": "Oui", "itemSix": "illimité", "itemSeven": "10 images par mois", "itemFileUpload": "Illimité", "intelligentSearch": "Recherche intelligente" }, "paymentSuccess": { "title": "Vous êtes maintenant sur le plan {} !", "description": "Votre paiement a été traité avec succès et votre forfait est mis à niveau vers @:appName {}. Vous pouvez consulter les détails de votre forfait sur la page Forfait" }, "downgradeDialog": { "title": "Êtes-vous sûr de vouloir rétrograder votre forfait ?", "description": "La baisse de votre offre vous ramènera au forfait gratuit. Les membres peuvent perdre l'accès à cet espace de travail et vous devrez peut-être libérer de l'espace pour respecter les limites de stockage du forfait gratuit.", "downgradeLabel": "Baisser l'offre" } }, "cancelSurveyDialog": { "title": "Désolé de vous voir partir", "description": "Nous sommes désolés de vous voir partir. Nous aimerions connaître votre avis pour nous aider à améliorer @:appName . Veuillez prendre un moment pour répondre à quelques questions.", "commonOther": "Autre", "otherHint": "Écrivez votre réponse ici", "questionOne": { "question": "Qu'est-ce qui vous a poussé à annuler votre @:appName Pro ?", "answerOne": "Coût trop élevé", "answerTwo": "Les fonctionnalités ne répondent pas à mes attentes", "answerThree": "J'ai trouvé une meilleure alternative", "answerFour": "Je ne l'ai pas suffisamment utilisé pour justifier la dépense", "answerFive": "Problème de service ou difficultés techniques" }, "questionTwo": { "question": "Quelle est la probabilité que vous envisagiez de vous réabonner à @:appName Pro à l'avenir ?", "answerOne": "Très probablement", "answerTwo": "Assez probable", "answerThree": "Pas sûr", "answerFour": "Peu probable", "answerFive": "Très peu probable" }, "questionThree": { "question": "Quelle fonctionnalité Pro avez-vous le plus appréciée lors de votre abonnement ?", "answerOne": "Collaboration multi-utilisateurs", "answerTwo": "Historique des versions plus long", "answerThree": "Réponses IA illimitées", "answerFour": "Accès aux modèles d'IA locaux" }, "questionFour": { "question": "Comment décririez-vous votre expérience globale avec @:appName ?", "answerOne": "Super", "answerTwo": "Bien", "answerThree": "Moyenne", "answerFour": "En dessous de la moyenne", "answerFive": "Insatisfait" } }, "common": { "uploadingFile": "Le fichier est en cours de téléchargement. Veuillez ne pas quitter l'application", "uploadNotionSuccess": "Votre fichier zip Notion a été téléchargé avec succès. Une fois l'importation terminée, vous recevrez un e-mail de confirmation", "reset": "Réinitialiser" }, "menu": { "appearance": "Apparence", "language": "Langue", "user": "Utilisateur", "files": "Dossiers", "notifications": "Notifications", "open": "Ouvrir les paramètres", "logout": "Se déconnecter", "logoutPrompt": "Êtes-vous sûr de vouloir vous déconnecter ?", "selfEncryptionLogoutPrompt": "Êtes-vous sûr de vouloir vous déconnecter ? Veuillez vous assurer d'avoir copié la clé de chiffrement.", "syncSetting": "Paramètres de synchronisation", "cloudSettings": "Paramètres cloud", "enableSync": "Activer la synchronisation", "enableSyncLog": "Activer la journalisation de synchronisation", "enableSyncLogWarning": "Merci de nous aider à diagnostiquer les problèmes de synchronisation. Cela enregistrera les modifications de votre document dans un fichier local. Veuillez quitter et rouvrir l'application après l'avoir activée", "enableEncrypt": "Chiffrer les données", "cloudURL": "URL de base", "webURL": "Web URL", "invalidCloudURLScheme": "Schéma invalide", "cloudServerType": "Serveur cloud", "cloudServerTypeTip": "Veuillez noter qu'il est possible que votre compte actuel soit déconnecté après avoir changé de serveur cloud.", "cloudLocal": "Local", "cloudAppFlowy": "@:appName Cloud Bêta", "cloudAppFlowySelfHost": "@:appName Cloud auto-hébergé", "appFlowyCloudUrlCanNotBeEmpty": "L'URL cloud ne peut pas être vide", "clickToCopy": "Cliquez pour copier", "selfHostStart": "Si vous n'avez pas de serveur, veuillez vous référer au", "selfHostContent": "document", "selfHostEnd": "pour obtenir des conseils sur la façon d'auto-héberger votre propre serveur", "pleaseInputValidURL": "Veuillez saisir une URL valide", "changeUrl": "Changer l'URL auto-hébergée en {}", "cloudURLHint": "Saisissez l'URL de base de votre serveur", "webURLHint": "Saisissez l'URL de base de votre serveur web", "cloudWSURL": "URL du websocket", "cloudWSURLHint": "Saisissez l'adresse websocket de votre serveur", "restartApp": "Redémarrer", "restartAppTip": "Redémarrez l'application pour que les modifications prennent effet. Veuillez noter que cela pourrait déconnecter votre compte actuel.", "changeServerTip": "Après avoir changé de serveur, vous devez cliquer sur le bouton de redémarrer pour que les modifications prennent effet", "enableEncryptPrompt": "Activez le chiffrement pour sécuriser vos données avec cette clé. Rangez-la en toute sécurité ; une fois activé, il ne peut pas être désactivé. En cas de perte, vos données deviennent irrécupérables. Cliquez pour copier", "inputEncryptPrompt": "Veuillez saisir votre mot ou phrase de passe pour", "clickToCopySecret": "Cliquez pour copier le mot ou la phrase de passe", "configServerSetting": "Configurez les paramètres de votre serveur", "configServerGuide": "Après avoir sélectionné « Démarrage rapide », accédez à « Paramètres » puis « Paramètres Cloud » pour configurer votre serveur auto-hébergé.", "inputTextFieldHint": "Votre mot ou phrase de passe", "historicalUserList": "Historique de connexion d'utilisateurs", "historicalUserListTooltip": "Cette liste affiche vos comptes anonymes. Vous pouvez cliquer sur un compte pour afficher ses détails. Les comptes anonymes sont créés en cliquant sur le bouton « Commencer »", "openHistoricalUser": "Cliquez pour ouvrir le compte anonyme", "customPathPrompt": "Le stockage du dossier de données @:appName dans un dossier synchronisé avec le cloud tel que Google Drive peut présenter des risques. Si la base de données de ce dossier est consultée ou modifiée à partir de plusieurs emplacements en même temps, cela peut entraîner des conflits de synchronisation et une corruption potentielle des données.", "importAppFlowyData": "Importer des données à partir du dossier @:appName externe", "importingAppFlowyDataTip": "L'importation des données est en cours. Veuillez ne pas fermer l'application", "importAppFlowyDataDescription": "Copiez les données d'un dossier de données @:appName externe et importez-les dans le dossier de données @:appName actuel", "importSuccess": "Importation réussie du dossier de données @:appName", "importFailed": "L'importation du dossier de données @:appName a échoué", "importGuide": "Pour plus de détails, veuillez consulter le document référencé" }, "notifications": { "enableNotifications": { "label": "Activer les notifications", "hint": "Désactivez-la pour empêcher l'affichage des notifications locales." }, "showNotificationsIcon": { "label": "Afficher l'icône des notifications", "hint": "Désactiver pour masquer l'icône de notification dans la barre latérale." }, "archiveNotifications": { "allSuccess": "Toutes les notifications ont été archivées avec succès", "success": "Notification archivée avec succès" }, "markAsReadNotifications": { "allSuccess": "Tout a été marqué comme lu avec succès", "success": "Marqué comme lu avec succès" }, "action": { "markAsRead": "Marquer comme lu", "multipleChoice": "Sélectionnez plus", "archive": "Archiver" }, "settings": { "settings": "Paramètres", "markAllAsRead": "Marquer tout comme lu", "archiveAll": "Archiver tout" }, "emptyInbox": { "title": "Aucune notification pour le moment", "description": "Vous serez averti ici des @mentions" }, "emptyUnread": { "title": "Aucune notification non lue", "description": "Vous êtes à jour !" }, "emptyArchived": { "title": "Aucune notification archivée", "description": "Vous n'avez pas encore archivé de notifications" }, "tabs": { "inbox": "Boîte de réception", "unread": "Non lu", "archived": "Archivé" }, "refreshSuccess": "Les notifications ont été actualisées avec succès", "titles": { "notifications": "Notifications", "reminder": "Rappel" } }, "appearance": { "resetSetting": "Réinitialiser ce paramètre", "fontFamily": { "label": "Famille de polices", "search": "Recherche", "defaultFont": "Système" }, "themeMode": { "label": " Mode du Thème", "light": "Mode clair", "dark": "Mode sombre", "system": "S'adapter au système" }, "fontScaleFactor": "Facteur d'échelle de police", "displaySize": "Taille de l'écran", "documentSettings": { "cursorColor": "Couleur du curseur du document", "selectionColor": "Couleur de sélection du document", "width": "Largeur du document", "changeWidth": "Changement", "pickColor": "Sélectionnez une couleur", "colorShade": "Nuance de couleur", "opacity": "Opacité", "hexEmptyError": "La couleur hexadécimale ne peut pas être vide", "hexLengthError": "La valeur hexadécimale doit comporter 6 chiffres", "hexInvalidError": "Valeur hexadécimale invalide", "opacityEmptyError": "L'opacité ne peut pas être vide", "opacityRangeError": "L'opacité doit être comprise entre 1 et 100", "app": "Application", "flowy": "Fluide", "apply": "Appliquer" }, "layoutDirection": { "label": "Orientation de la mise en page", "hint": "Contrôlez l'orientation du contenu sur votre écran, de gauche à droite ou de droite à gauche.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "Direction du texte par défaut", "hint": "Spécifiez si le texte doit commencer à gauche ou à droite par défaut.", "ltr": "LTR", "rtl": "RTL", "auto": "AUTO", "fallback": "Identique au sens de mise en page" }, "themeUpload": { "button": "Téléverser", "uploadTheme": "Téléverser le thème", "description": "Téléversez votre propre thème @:appName en utilisant le bouton ci-dessous.", "loading": "Veuillez patienter pendant que nous validons et téléchargeons votre thème...", "uploadSuccess": "Votre thème a été téléversé avec succès", "deletionFailure": "Échec de la suppression du thème. Essayez de le supprimer manuellement.", "filePickerDialogTitle": "Choisissez un fichier .flowy_plugin", "urlUploadFailure": "Échec de l'ouverture de l'URL : {}", "failure": "Le thème qui a été téléchargé avait un format non valide." }, "theme": "Thème", "builtInsLabel": "Thèmes intégrés", "pluginsLabel": "Plugins", "dateFormat": { "label": "Format de la date", "local": "Local", "us": "US", "iso": "ISO", "friendly": "Convivial", "dmy": "J/M/A" }, "timeFormat": { "label": "Format de l'heure", "twelveHour": "Douze heures", "twentyFourHour": "Vingt-quatre heures" }, "showNamingDialogWhenCreatingPage": "Afficher la boîte de dialogue de nommage lors de la création d'une page", "enableRTLToolbarItems": "Activer les éléments de la barre d'outils RTL", "members": { "title": "Paramètres des membres", "inviteMembers": "Inviter des membres", "inviteHint": "Invitation par email", "sendInvite": "Envoyer une invitation", "copyInviteLink": "Copier le lien d'invitation", "label": "Membres", "user": "Utilisateur", "role": "Rôle", "removeFromWorkspace": "Retirer de l'espace de travail", "removeFromWorkspaceSuccess": "Retiré de l'espace de travail avec succès", "removeFromWorkspaceFailed": "Suppression du membre échouée ", "owner": "Propriétaire", "guest": "Invité", "member": "Membre", "memberHintText": "Un membre peut lire, commenter, et éditer des pages. Inviter des membres et des invités.", "guestHintText": "Un invité peut lire, réagir, commenter, et peut éditer certaines pages avec une permission", "emailInvalidError": "Email invalide, veuillez le vérifier et recommencer", "emailSent": "Email envoyé, veuillez vérifier dans votre boîte mail.", "members": "membres", "membersCount": { "zero": "{} membres", "one": "{} membre", "other": "{} membres" }, "inviteFailedDialogTitle": "Échec de l'envoi de l'invitation", "inviteFailedMemberLimit": "La limite de membres a été atteinte, veuillez effectuer une mise à niveau pour inviter plus de membres.", "inviteFailedMemberLimitMobile": "Votre espace de travail a atteint la limite de membres. Utilisez l'application sur PC pour effectuez une mise à niveau et débloquer plus de fonctionnalités.", "memberLimitExceeded": "Vous avez atteint la limite maximale de membres autorisée pour votre compte. Si vous souhaitez ajouter d'autres membres pour continuer votre travail, veuillez en faire la demande sur Github.", "memberLimitExceededUpgrade": "mise à niveau", "memberLimitExceededPro": "Limite de membres atteinte, si vous avez besoin de plus de membres, contactez ", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Échec de l'ajout d'un membre", "addMemberSuccess": "Membre ajouté avec succès", "removeMember": "Supprimer un membre", "areYouSureToRemoveMember": "Êtes-vous sûr de vouloir supprimer ce membre ?", "inviteMemberSuccess": "L'invitation a été envoyée avec succès", "failedToInviteMember": "Impossible d'inviter un membre", "workspaceMembersError": "Une erreur s'est produite", "workspaceMembersErrorDescription": "Nous n'avons pas pu charger la liste des membres. Veuillez essayer plus tard s'il vous plait", "inviteLinkToAddMember": "Lien d'invitation pour ajouter un membre", "clickToCopyLink": "Cliquez pour copier le lien", "or": "ou", "generateANewLink": "générer un nouveau lien", "inviteMemberByEmail": "Inviter un membre par e-mail", "inviteMemberHintText": "Invitation par email", "resetInviteLink": "Réinitialiser le lien d'invitation ?", "resetInviteLinkDescription": "La réinitialisation désactivera le lien actuel pour tous les membres de l'espace et en générera un nouveau. L'ancien lien ne sera plus disponible.", "adminPanel": "Panneau d'administration", "reset": "Réinitialiser", "resetInviteLinkSuccess": "Le lien d'invitation a été réinitialisé avec succès", "resetInviteLinkFailed": "Échec de la réinitialisation du lien d'invitation", "resetInviteLinkFailedDescription": "Veuillez réessayer plus tard", "memberPageDescription1": "Accéder au", "memberPageDescription2": "pour la gestion des invités et des utilisateurs avancés.", "noInviteLink": "Vous n'avez pas généré de lien d'invitation pour le moment", "copyLink": "Copier le lien", "generatedLinkSuccessfully": "Lien généré avec succès", "generatedLinkFailed": "Échec de génération du lien" } }, "files": { "copy": "Copie", "defaultLocation": "Lire les fichiers et l'emplacement de stockage des données", "exportData": "Exportez vos données", "doubleTapToCopy": "Appuyez deux fois pour copier le chemin", "restoreLocation": "Restaurer le chemin par défaut d'@:appName", "customizeLocation": "Ouvrir un autre dossier", "restartApp": "Veuillez redémarrer l'application pour que les modifications prennent effet.", "exportDatabase": "Exporter la base de données", "selectFiles": "Sélectionnez les fichiers qui doivent être exportés", "selectAll": "Tout sélectionner", "deselectAll": "Tout déselectionner", "createNewFolder": "Créer un nouveau dossier", "createNewFolderDesc": "Dites-nous où vous souhaitez stocker vos données", "defineWhereYourDataIsStored": "Définissez où vos données sont stockées", "open": "Ouvrir", "openFolder": "Ouvrir un dossier existant", "openFolderDesc": "Lisez-le et écrivez-le dans votre dossier @:appName existant", "folderHintText": "Nom de dossier", "location": "Création d'un nouveau dossier", "locationDesc": "Choisissez un nom pour votre dossier de données @:appName", "browser": "Parcourir", "create": "Créer", "set": "Définir", "folderPath": "Chemin pour stocker votre dossier", "locationCannotBeEmpty": "Le chemin ne peut pas être vide", "pathCopiedSnackbar": "Chemin de stockage des fichiers copié dans le presse-papier !", "changeLocationTooltips": "Changer le répertoire de données", "change": "Changer", "openLocationTooltips": "Ouvrir un autre répertoire de données", "openCurrentDataFolder": "Ouvrir le répertoire de données actuel", "recoverLocationTooltips": "Réinitialiser au répertoire de données par défaut d'@:appName", "exportFileSuccess": "Exporter le fichier avec succès !", "exportFileFail": "Échec de l'export du fichier !", "export": "Exporter", "clearCache": "Vider le cache", "clearCacheDesc": "Si vous rencontrez des problèmes avec des images qui ne se chargent pas ou des polices qui ne s'affichent pas correctement, essayez de vider votre cache. Cette action ne supprimera pas vos données utilisateur.", "areYouSureToClearCache": "Êtes-vous sûr de vider le cache ?", "clearCacheSuccess": "Cache vidé avec succès !" }, "user": { "name": "Nom", "email": "Courriel", "tooltipSelectIcon": "Sélectionner l'icône", "selectAnIcon": "Sélectionnez une icône", "pleaseInputYourOpenAIKey": "Veuillez entrer votre clé AI", "clickToLogout": "Cliquez pour déconnecter l'utilisateur actuel", "pleaseInputYourStabilityAIKey": "Veuillez saisir votre clé de Stability AI" }, "mobile": { "personalInfo": "Informations personnelles", "username": "Nom d'utilisateur", "usernameEmptyError": "Le nom d'utilisateur ne peut pas être vide", "about": "À propos", "pushNotifications": "Notifications push", "support": "Support", "joinDiscord": "Rejoignez-nous sur Discord", "privacyPolicy": "Politique de Confidentialité", "userAgreement": "Accord de l'utilisateur", "termsAndConditions": "Termes et conditions", "userprofileError": "Échec du chargement du profil utilisateur", "userprofileErrorDescription": "Veuillez essayer de vous déconnecter et de vous reconnecter pour vérifier si le problème persiste.", "selectLayout": "Sélectionner la mise en page", "selectStartingDay": "Sélectionnez le jour de début", "version": "Version" }, "shortcuts": { "shortcutsLabel": "Raccourcis", "command": "Commande", "keyBinding": "Racourcis clavier", "addNewCommand": "Ajouter une Nouvelle Commande", "updateShortcutStep": "Appuyez sur la combinaison de touches souhaitée et appuyez sur ENTER", "shortcutIsAlreadyUsed": "Ce raccourci est déjà utilisé pour : {conflict}", "resetToDefault": "Réinitialiser les raccourcis clavier par défaut", "couldNotLoadErrorMsg": "Impossible de charger les raccourcis, réessayez", "couldNotSaveErrorMsg": "Impossible d'enregistrer les raccourcis. Réessayez", "commands": { "codeBlockNewParagraph": "Insérez un nouveau paragraphe à côté du bloc de code", "codeBlockAddTwoSpaces": "Insérez deux espaces au début de la ligne dans le bloc de code", "codeBlockSelectAll": "Sélectionnez tout le contenu à l'intérieur d'un bloc de code", "codeBlockPasteText": "Coller le texte dans le bloc de code", "textAlignLeft": "Aligner le texte à gauche", "textAlignCenter": "Aligner le texte au centre", "textAlignRight": "Aligner le texte à droite", "codeBlockDeleteTwoSpaces": "Supprimez deux espaces au début de la ligne dans le bloc de code" } } }, "grid": { "deleteView": "Voulez-vous vraiment supprimer cette vue ?", "createView": "Nouveau", "title": { "placeholder": "Sans titre" }, "settings": { "filter": "Filtrer", "sort": "Trier", "sortBy": "Trier par", "properties": "Propriétés", "reorderPropertiesTooltip": "Faites glisser pour réorganiser les propriétés", "group": "Groupe", "addFilter": "Ajouter un filtre", "deleteFilter": "Supprimer le filtre", "filterBy": "Filtrer par...", "typeAValue": "Tapez une valeur...", "layout": "Mise en page", "compactMode": "Mode compact", "databaseLayout": "Mise en page", "editView": "Modifier vue", "boardSettings": "Paramètres du tableau", "calendarSettings": "Paramètres du calendrier", "createView": "Nouvelle vue", "duplicateView": "Dupliquer la vue", "deleteView": "Supprimer la vue", "numberOfVisibleFields": "{} affiché(s)", "Properties": "Propriétés", "viewList": "Vues de base de données" }, "filter": { "empty": "Aucun filtre actif", "addFilter": "Ajouter un filtre", "cannotFindCreatableField": "Impossible de trouver un champ approprié pour filtrer", "conditon": "Condition", "where": "Où" }, "textFilter": { "contains": "Contient", "doesNotContain": "Ne contient pas", "endsWith": "Se termine par", "startWith": "Commence par", "is": "Est", "isNot": "N'est pas", "isEmpty": "Est vide", "isNotEmpty": "N'est pas vide", "choicechipPrefix": { "isNot": "Pas", "startWith": "Commence par", "endWith": "Se termine par", "isEmpty": "est vide", "isNotEmpty": "n'est pas vide" } }, "checkboxFilter": { "isChecked": "Coché", "isUnchecked": "Décoché", "choicechipPrefix": { "is": "est" } }, "checklistFilter": { "isComplete": "fait", "isIncomplted": "pas fait" }, "selectOptionFilter": { "is": "Est", "isNot": "N'est pas", "contains": "Contient", "doesNotContain": "Ne contient pas", "isEmpty": "Est vide", "isNotEmpty": "N'est pas vide" }, "dateFilter": { "is": "Est", "before": "Est avant", "after": "Est après", "onOrBefore": "Est le ou avant", "onOrAfter": "Est le ou après", "between": "Est entre", "empty": "Est vide", "notEmpty": "N'est pas vide", "startDate": "Date de début", "endDate": "Date de fin", "choicechipPrefix": { "before": "Avant", "after": "Après", "between": "Entre", "onOrBefore": "Pendant ou avant", "onOrAfter": "Pendant ou après", "isEmpty": "Est vide", "isNotEmpty": "N'est pas vide" } }, "numberFilter": { "equal": "Égal", "notEqual": "N'est pas égal", "lessThan": "Est moins que", "greaterThan": "Est plus que", "lessThanOrEqualTo": "Est inférieur ou égal à", "greaterThanOrEqualTo": "Est supérieur ou égal à ", "isEmpty": "Est vide", "isNotEmpty": "N'est pas vide" }, "field": { "label": "Propriété", "hide": "Cacher", "show": "Afficher", "insertLeft": "Insérer à gauche", "insertRight": "Insérer à droite", "duplicate": "Dupliquer", "delete": "Supprimer", "wrapCellContent": "Envelopper le texte", "clear": "Effacer les cellules", "switchPrimaryFieldTooltip": "Impossible de modifier le type de champ du champ principal", "textFieldName": "Texte", "checkboxFieldName": "Case à cocher", "dateFieldName": "Date", "updatedAtFieldName": "Dernière modification", "createdAtFieldName": "Créé le", "numberFieldName": "Nombre", "singleSelectFieldName": "Sélectionner", "multiSelectFieldName": "Sélection multiple", "urlFieldName": "URL", "checklistFieldName": "Check-list", "relationFieldName": "Relation", "summaryFieldName": "Résume IA", "timeFieldName": "Horaire", "mediaFieldName": "Fichiers et médias", "translateFieldName": "Traduction IA", "translateTo": "Traduire en", "numberFormat": "Format du nombre", "dateFormat": "Format de la date", "includeTime": "Inclure l'heure", "isRange": "Date de fin", "dateFormatFriendly": "Mois Jour, Année", "dateFormatISO": "Année-Mois-Jour", "dateFormatLocal": "Mois/Jour/Année", "dateFormatUS": "Année/Mois/Jour", "dateFormatDayMonthYear": "Jour/Mois/Année", "timeFormat": "Format de l'heure", "invalidTimeFormat": "Format invalide", "timeFormatTwelveHour": "12 heures", "timeFormatTwentyFourHour": "24 heures", "clearDate": "Effacer la date", "dateTime": "Date et heure", "startDateTime": "Date et heure de début", "endDateTime": "Date et heure de fin", "failedToLoadDate": "Échec du chargement de la valeur de la date", "selectTime": "Sélectionnez l'heure", "selectDate": "Sélectionner une date", "visibility": "Visibilité", "propertyType": "Type de propriété", "addSelectOption": "Ajouter une option", "typeANewOption": "Saisissez une nouvelle option", "optionTitle": "Options", "addOption": "Ajouter une option", "editProperty": "Modifier la propriété", "newProperty": "Nouvelle colonne", "openRowDocument": "Ouvrir en tant que page", "deleteFieldPromptMessage": "Vous voulez supprimer cette propriété ?", "clearFieldPromptMessage": "Es-tu sûr? Toutes les cellules de cette colonne seront vidées", "newColumn": "Nouvelle colonne", "format": "Format", "reminderOnDateTooltip": "Cette cellule a un rappel programmé", "optionAlreadyExist": "L'option existe déjà" }, "rowPage": { "newField": "Ajouter un nouveau champ", "fieldDragElementTooltip": "Cliquez pour ouvrir le menu", "showHiddenFields": { "one": "Afficher {count} champ masqué", "many": "Afficher {count} champs masqués", "other": "Afficher {count} champs masqués" }, "hideHiddenFields": { "one": "Cacher {count} champ caché", "many": "Cacher {count} champs masqués", "other": "Cacher {count} champs masqués" }, "openAsFullPage": "Ouvrir en pleine page", "moreRowActions": "Plus d'actions de ligne" }, "sort": { "ascending": "Ascendant", "descending": "Descendant", "by": "Par", "empty": "Tri pas actif", "cannotFindCreatableField": "Impossible de trouver un champ approprié pour trier", "deleteAllSorts": "Supprimer tous les tris", "addSort": "Ajouter un tri", "sortsActive": "Impossible {intention} lors du tri", "removeSorting": "Voulez-vous supprimer le tri ?", "fieldInUse": "Vous êtes déjà en train de trier par ce champ", "deleteSort": "Supprimer le tri" }, "row": { "label": "Ligne", "duplicate": "Dupliquer", "delete": "Supprimer", "titlePlaceholder": "Sans titre", "textPlaceholder": "Vide", "copyProperty": "Copie de la propriété dans le presse-papiers", "count": "Compte", "newRow": "Nouvelle ligne", "loadMore": "Charger plus", "action": "Action", "add": "Cliquez sur ajouter ci-dessous", "drag": "Glisser pour déplacer", "deleteRowPrompt": "Etes-vous sûr de vouloir supprimer cette ligne ? Cette action ne peut pas être annulée", "deleteCardPrompt": "Etes-vous sûr de vouloir supprimer cette carte ? Cette action ne peut pas être annulée", "dragAndClick": "Faites glisser pour déplacer, cliquez pour ouvrir le menu", "insertRecordAbove": "Insérer l'enregistrement ci-dessus", "insertRecordBelow": "Insérer l'enregistrement ci-dessous", "noContent": "Aucun contenu", "reorderRowDescription": "réorganiser la ligne", "createRowAboveDescription": "créer une ligne au dessus", "createRowBelowDescription": "insérer une ligne ci-dessous" }, "selectOption": { "create": "Créer", "purpleColor": "Violet", "pinkColor": "Rose", "lightPinkColor": "Rose clair", "orangeColor": "Orange", "yellowColor": "Jaune", "limeColor": "Citron vert", "greenColor": "Vert", "aquaColor": "Turquoise", "blueColor": "Bleu", "deleteTag": "Supprimer l'étiquette", "colorPanelTitle": "Couleurs", "panelTitle": "Sélectionnez une option ou créez-en une", "searchOption": "Rechercher une option", "searchOrCreateOption": "Rechercher ou créer une option...", "createNew": "Créer une nouvelle", "orSelectOne": "Ou sélectionnez une option", "typeANewOption": "Saisissez une nouvelle option", "tagName": "Nom de l'étiquette" }, "checklist": { "taskHint": "Description de la tâche", "addNew": "Ajouter un élément", "submitNewTask": "Créer", "hideComplete": "Cacher les tâches terminées", "showComplete": "Afficher toutes les tâches" }, "url": { "launch": "Ouvrir dans le navigateur", "copy": "Copier l'URL", "textFieldHint": "Entrez une URL" }, "relation": { "relatedDatabasePlaceLabel": "Base de données associée", "relatedDatabasePlaceholder": "Aucun", "inRelatedDatabase": "Dans", "rowSearchTextFieldPlaceholder": "Recherche", "noDatabaseSelected": "Aucune base de données sélectionnée, veuillez d'abord en sélectionner une dans la liste ci-dessous :", "emptySearchResult": "Aucun enregistrement trouvé", "linkedRowListLabel": "{count} lignes liées", "unlinkedRowListLabel": "Lier une autre ligne" }, "menuName": "Grille", "referencedGridPrefix": "Vue", "calculate": "Calculer", "calculationTypeLabel": { "none": "Vide", "average": "Moyenne", "max": "Maximum", "median": "Médiane", "min": "Minimum", "sum": "Somme", "count": "Compter", "countEmpty": "Compter les cellules vides", "countEmptyShort": "VIDE", "countNonEmpty": "Compter les cellules non vides", "countNonEmptyShort": "REMPLI" }, "media": { "rename": "Rebaptiser", "download": "Télécharger", "expand": "Développer", "delete": "Supprimer", "moreFilesHint": "+{}", "addFileOrImage": "Ajouter un fichier ou un lien", "attachmentsHint": "{}", "addFileMobile": "Ajouter un fichier", "extraCount": "+{}", "deleteFileDescription": "Etes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible.", "showFileNames": "Afficher le nom du fichier", "downloadSuccess": "Fichier téléchargé", "downloadFailedToken": "Échec du téléchargement du fichier, jeton utilisateur indisponible", "setAsCover": "Définir comme couverture", "openInBrowser": "Ouvrir dans le navigateur", "embedLink": "Intégrer le lien du fichier" } }, "document": { "menuName": "Document", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "Création...", "slashMenu": { "board": { "selectABoardToLinkTo": "Sélectionnez un tableau à lier", "createANewBoard": "Créer un nouveau tableau" }, "grid": { "selectAGridToLinkTo": "Sélectionnez une grille à lier", "createANewGrid": "Créer une nouvelle Grille" }, "calendar": { "selectACalendarToLinkTo": "Sélectionnez un calendrier à lier", "createANewCalendar": "Créer un nouveau calendrier" }, "document": { "selectADocumentToLinkTo": "Sélectionnez un Document vers lequel créer un lien" }, "name": { "textStyle": "Style de texte", "list": "Liste", "toggle": "Basculer", "fileAndMedia": "Fichiers et médias", "simpleTable": "Tableau simple", "visuals": "Visuels", "document": "Document", "advanced": "Avancé", "text": "Texte", "heading1": "Titre 1", "heading2": "Titre 2", "heading3": "Titre 3", "image": "Image", "bulletedList": "Liste à puces", "numberedList": "Liste numérotée", "todoList": "Liste de choses à faire", "doc": "Doc", "linkedDoc": "Lien vers la page", "grid": "Grille", "linkedGrid": "Grille liée", "kanban": "Kanban", "linkedKanban": "Kanban lié", "calendar": "Calendrier", "linkedCalendar": "Calendrier lié", "quote": "Citation", "divider": "Diviseur", "table": "Tableau", "callout": "Appeler", "outline": "Table des matières", "mathEquation": "Équation mathématique", "code": "Code", "toggleList": "Menu dépliant", "toggleHeading1": "Basculer en titre 1", "toggleHeading2": "Basculer en titre 2", "toggleHeading3": "Basculer en titre 3", "emoji": "Émoji", "aiWriter": "Rédacteur IA", "dateOrReminder": "Date ou rappel", "photoGallery": "Galerie de photos", "file": "Fichier", "twoColumns": "2 colonnes", "threeColumns": "3 colonnes", "fourColumns": "4 colonnes", "checkbox": "Case à cocher" }, "subPage": { "name": "Document", "keyword1": "sous-page", "keyword2": "page", "keyword3": "page enfant", "keyword4": "insérer une page", "keyword5": "page intégrée", "keyword6": "nouvelle page", "keyword7": "créer une page", "keyword8": "document" } }, "selectionMenu": { "outline": "Contour", "codeBlock": "Bloc de code" }, "plugins": { "referencedBoard": "Tableau référencé", "referencedGrid": "Grille référencée", "referencedCalendar": "Calendrier référencé", "referencedDocument": "Document référencé", "aiWriter": { "userQuestion": "Demandez n'importe quoi à l'IA", "continueWriting": "Continuer à écrire", "fixSpelling": "Corriger l'orthographe et la grammaire", "improveWriting": "Améliorer l'écriture", "summarize": "Résumer", "explain": "Expliquer", "makeShorter": "Rendre plus court", "makeLonger": "Rallonger" }, "autoGeneratorMenuItemName": "Rédacteur AI", "autoGeneratorTitleName": "AI : Demandez à l'IA d'écrire quelque chose...", "autoGeneratorLearnMore": "Apprendre encore plus", "autoGeneratorGenerate": "Générer", "autoGeneratorHintText": "Demandez à AI...", "autoGeneratorCantGetOpenAIKey": "Impossible d'obtenir la clé AI", "autoGeneratorRewrite": "Réécrire", "smartEdit": "Assistants IA", "aI": "AI", "smartEditFixSpelling": "Corriger l'orthographe", "warning": "⚠️ Les réponses de l'IA peuvent être inexactes ou trompeuses.", "smartEditSummarize": "Résumer", "smartEditImproveWriting": "Améliorer l'écriture", "smartEditMakeLonger": "Rallonger", "smartEditCouldNotFetchResult": "Impossible de récupérer le résultat d'AI", "smartEditCouldNotFetchKey": "Impossible de récupérer la clé AI", "smartEditDisabled": "Connectez AI dans les paramètres", "appflowyAIEditDisabled": "Connectez-vous pour activer les fonctionnalités de l'IA", "discardResponse": "Voulez-vous supprimer les réponses de l'IA ?", "createInlineMathEquation": "Créer une équation", "fonts": "Polices", "insertDate": "Insérer la date", "emoji": "Emoji", "toggleList": "Liste pliable", "emptyToggleHeading": "Bouton vide h{}. Cliquez pour ajouter du contenu.", "emptyToggleList": "Liste vide. Cliquez pour ajouter du contenu.", "emptyToggleHeadingWeb": "Bascule h{level} vide. Cliquer pour ajouter du contenu ", "quoteList": "Liste de citations", "numberedList": "Liste numérotée", "bulletedList": "Liste à puces", "todoList": "Liste de tâches", "callout": "Encadré", "simpleTable": { "moreActions": { "color": "Couleur", "align": "Aligner", "delete": "Supprimer", "duplicate": "Dupliquer", "insertLeft": "Insérer à gauche", "insertRight": "Insérer à droite", "insertAbove": "Insérer ci-dessus", "insertBelow": "Insérer ci-dessous", "headerColumn": "Colonne d'en-tête", "headerRow": "Ligne d'en-tête", "clearContents": "Supprimer le contenu", "setToPageWidth": "Définir sur la largeur de la page", "distributeColumnsWidth": "Répartir les colonnes uniformément", "duplicateRow": "Ligne dupliquée", "duplicateColumn": "Colonne dupliquée", "textColor": "Couleur du texte", "cellBackgroundColor": "Couleur d'arrière-plan de la cellule", "duplicateTable": "Tableau dupliqué" }, "clickToAddNewRow": "Cliquez pour ajouter une nouvelle ligne", "clickToAddNewColumn": "Cliquez pour ajouter une nouvelle colonne", "clickToAddNewRowAndColumn": "Cliquez pour ajouter une nouvelle ligne et une nouvelle colonne", "headerName": { "table": "Tableau", "alignText": "Aligner le texte" } }, "cover": { "changeCover": "Changer la couverture", "colors": "Couleurs", "images": "Images", "clearAll": "Tout Effacer", "abstract": "Abstrait", "addCover": "Ajouter une couverture", "addLocalImage": "Ajouter une image locale", "invalidImageUrl": "URL d'image non valide", "failedToAddImageToGallery": "Impossible d'ajouter l'image à la galerie", "enterImageUrl": "Entrez l'URL de l'image", "add": "Ajouter", "back": "Dos", "saveToGallery": "Sauvegarder dans la gallerie", "removeIcon": "Supprimer l'icône", "removeCover": "Supprimer la couverture", "pasteImageUrl": "Coller l'URL de l'image", "or": "OU", "pickFromFiles": "Choisissez parmi les fichiers", "couldNotFetchImage": "Impossible de récupérer l'image", "imageSavingFailed": "Échec de l'enregistrement de l'image", "addIcon": "Ajouter une icône", "changeIcon": "Changer l'icône", "coverRemoveAlert": "Il sera retiré de la couverture après sa suppression.", "alertDialogConfirmation": "Voulez-vous vraiment continuer?" }, "mathEquation": { "name": "Équation mathématique", "addMathEquation": "Ajouter une équation mathématique", "editMathEquation": "Modifier l'équation mathématique" }, "optionAction": { "click": "Cliquez sur", "toOpenMenu": " pour ouvrir le menu", "drag": "Glisser", "toMove": " à déplacer", "delete": "Supprimer", "duplicate": "Dupliquer", "turnInto": "Changer en", "moveUp": "Déplacer vers le haut", "moveDown": "Descendre", "color": "Couleur", "align": "Aligner", "left": "Gauche", "center": "Centre", "right": "Droite", "defaultColor": "Défaut", "depth": "Profond", "copyLinkToBlock": "Copier le lien pour bloquer" }, "image": { "addAnImage": "Ajouter une image", "copiedToPasteBoard": "Le lien de l'image a été copié dans le presse-papiers", "addAnImageDesktop": "Ajouter une image", "addAnImageMobile": "Cliquez pour ajouter une ou plusieurs images", "dropImageToInsert": "Déposez les images à insérer", "imageUploadFailed": "Téléchargement de l'image échoué", "imageDownloadFailed": "Le téléchargement de l'image a échoué, veuillez réessayer", "imageDownloadFailedToken": "Le téléchargement de l'image a échoué en raison d'un jeton d'utilisateur manquant, veuillez réessayer", "errorCode": "Code erreur" }, "photoGallery": { "name": "Galerie de photos", "imageKeyword": "image", "imageGalleryKeyword": "Galerie d'images", "photoKeyword": "photo", "photoBrowserKeyword": "navigateur de photos", "galleryKeyword": "galerie", "addImageTooltip": "Ajouter une image", "changeLayoutTooltip": "Changer la mise en page", "browserLayout": "Navigateur", "gridLayout": "Grille", "deleteBlockTooltip": "Supprimer toute la galerie" }, "math": { "copiedToPasteBoard": "L'équation mathématique a été copiée dans le presse-papiers" }, "urlPreview": { "copiedToPasteBoard": "Le lien a été copié dans le presse-papier", "convertToLink": "Convertir en lien intégré" }, "outline": { "addHeadingToCreateOutline": "Ajoutez des titres pour créer une table des matières.", "noMatchHeadings": "Aucune rubrique correspondante trouvée." }, "table": { "addAfter": "Ajouter après", "addBefore": "Ajouter avant", "delete": "Supprimer", "clear": "Éffacer contenu", "duplicate": "Dupliquer", "bgColor": "Couleur de fond" }, "contextMenu": { "copy": "Copier", "cut": "Couper", "paste": "Coller", "pasteAsPlainText": "Coller en tant que texte brut" }, "action": "Actions", "database": { "selectDataSource": "Sélectionnez la source de données", "noDataSource": "Aucune source de données", "selectADataSource": "Sélectionnez une source de données", "toContinue": "pour continuer", "newDatabase": "Nouvelle Base de données", "linkToDatabase": "Lien vers la Base de données" }, "date": "Date", "video": { "label": "Vidéo", "emptyLabel": "Ajouter une vidéo", "placeholder": "Collez le lien vidéo", "copiedToPasteBoard": "Le lien vidéo a été copié dans le presse-papiers", "insertVideo": "Ajouter une vidéo", "invalidVideoUrl": "L'URL source n'est pas encore prise en charge.", "invalidVideoUrlYouTube": "YouTube n'est pas encore pris en charge.", "supportedFormats": "Formats pris en charge : MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "Fichier", "uploadTab": "Télécharger", "uploadMobile": "Choisissez un fichier", "uploadMobileGallery": "Depuis la galerie photo", "networkTab": "Intégrer un lien", "placeholderText": "Télécharger ou intégrer un fichier", "placeholderDragging": "Glisser le fichier à télécharger", "dropFileToUpload": "Glisser le fichier à télécharger", "fileUploadHint": "Glisser un fichier ici pour le télécharger\nou cliquez pour parcourir", "fileUploadHintSuffix": "Parcourir", "networkHint": "Coller un lien de fichier", "networkUrlInvalid": "URL non valide, veuillez corriger l'URL et réessayer", "networkAction": "Intégrer", "fileTooBigError": "La taille du fichier est trop grande, veuillez télécharger un fichier d'une taille inférieure à 10 Mo", "renameFile": { "title": "Renommer le fichier", "description": "Entrez le nouveau nom pour ce fichier", "nameEmptyError": "Le nom du fichier ne peut pas être laissé vide." }, "uploadedAt": "Mis en ligne le {}", "linkedAt": "Lien ajouté le {}", "failedToOpenMsg": "Impossible d'ouvrir, fichier non trouvé" }, "subPage": { "errors": { "failedDeletePage": "Impossible de supprimer la page", "failedCreatePage": "Échec de la création de la page", "failedMovePage": "Impossible de déplacer la page vers ce document", "failedDuplicatePage": "Impossible de dupliquer la page", "failedDuplicateFindView": "Impossible de dupliquer la page - vue d'origine non trouvée" } }, "cannotMoveToItsChildren": "Ne peut pas se déplacer vers ses enfants", "linkPreview": { "typeSelection": { "pasteAs": "Coller comme", "mention": "Mention", "URL": "URL", "bookmark": "Signet", "embed": "Intégrer" }, "linkPreviewMenu": { "toMetion": "Convertir en mention", "toUrl": "Convertir en URL", "toEmbed": "Convertir en intégration", "toBookmark": "Convertir en signet", "copyLink": "Copier le lien", "replace": "Remplacer", "reload": "Recharger", "removeLink": "Supprimer le lien", "pasteHint": "Coller en https://...", "unableToDisplay": "impossible d'afficher" } } }, "outlineBlock": { "placeholder": "Table de contenu" }, "textBlock": { "placeholder": "Tapez '/' pour les commandes" }, "title": { "placeholder": "Sans titre" }, "imageBlock": { "placeholder": "Cliquez pour ajouter une image", "upload": { "label": "Téléverser", "placeholder": "Cliquez pour téléverser l'image" }, "url": { "label": "URL de l'image", "placeholder": "Entrez l'URL de l'image" }, "ai": { "label": "Générer une image à partir d'AI", "placeholder": "Veuillez saisir l'invite pour qu'AI génère l'image" }, "stability_ai": { "label": "Générer une image à partir de Stability AI", "placeholder": "Veuillez saisir l'invite permettant à Stability AI de générer une image." }, "support": "La limite de taille d'image est de 5 Mo. Formats pris en charge : JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Image invalide", "invalidImageSize": "La taille de l'image doit être inférieure à 5 Mo", "invalidImageFormat": "Le format d'image n'est pas pris en charge. Formats pris en charge : JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL d'image non valide", "noImage": "Aucun fichier ou répertoire de ce nom", "multipleImagesFailed": "Une ou plusieurs images n'ont pas pu être téléchargées, veuillez réessayer" }, "embedLink": { "label": "Lien intégré", "placeholder": "Collez ou saisissez un lien d'image" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Rechercher une image", "pleaseInputYourOpenAIKey": "veuillez saisir votre clé AI dans la page Paramètres", "saveImageToGallery": "Enregistrer l'image", "failedToAddImageToGallery": "Échec de l'ajout d'une image à la galerie", "successToAddImageToGallery": "Image ajoutée à la galerie avec succès", "unableToLoadImage": "Impossible de charger l'image", "maximumImageSize": "La taille d'image maximale est 10Mo", "uploadImageErrorImageSizeTooBig": "L'image doit faire moins de 10Mo", "imageIsUploading": "L'image est en cours de téléchargement", "openFullScreen": "Ouvrir en plein écran", "interactiveViewer": { "toolbar": { "previousImageTooltip": "Image précédente", "nextImageTooltip": "Image suivante", "zoomOutTooltip": "Zoom arrière", "zoomInTooltip": "Agrandir", "changeZoomLevelTooltip": "Changer le niveau de zoom", "openLocalImage": "Ouvrir l'image", "downloadImage": "Télécharger l'image", "closeViewer": "Fermer la visionneuse", "scalePercentage": "{}%", "deleteImageTooltip": "Supprimer l'image" } }, "pleaseInputYourStabilityAIKey": "veuillez saisir votre clé Stability AI dans la page Paramètres" }, "codeBlock": { "language": { "label": "Langue", "placeholder": "Choisir la langue", "auto": "Auto" }, "copyTooltip": "Copier le contenu du bloc de code", "searchLanguageHint": "Rechercher une langue", "codeCopiedSnackbar": "Code copié dans le presse-papier !" }, "inlineLink": { "placeholder": "Coller ou saisir un lien", "openInNewTab": "Ouvrir dans un nouvel onglet", "copyLink": "Copier le lien", "removeLink": "Supprimer le lien", "url": { "label": "URL du lien", "placeholder": "Entrez l'URL du lien" }, "title": { "label": "Titre du lien", "placeholder": "Entrez le titre du lien" } }, "mention": { "placeholder": "Mentionner une personne ou une page ou une date...", "page": { "label": "Lien vers la page", "tooltip": "Cliquez pour ouvrir la page" }, "deleted": "Supprimer", "deletedContent": "Ce document n'existe pas ou a été supprimé", "noAccess": "Pas d'accès", "deletedPage": "Page supprimée", "trashHint": " - à la corbeille", "morePages": "plus de pages" }, "toolbar": { "resetToDefaultFont": "Réinitialiser aux valeurs par défaut", "textSize": "Taille du texte", "textColor": "Couleur du texte", "h1": "Titre 1", "h2": "Titre 2", "h3": "Titre 3", "alignLeft": "Aligner à gauche", "alignRight": "Aligner à droite", "alignCenter": "Aligner le centre", "link": "Lien", "textAlign": "Alignement du texte", "moreOptions": "Plus d'options", "font": "Police", "inlineCode": "Code en ligne", "suggestions": "Suggestions", "turnInto": "Devenir", "equation": "Équation", "insert": "Insérer", "linkInputHint": "Coller un lien ou rechercher des pages", "pageOrURL": "Page ou URL", "linkName": "Nom du lien", "linkNameHint": "Nom du lien d'entrée" }, "errorBlock": { "theBlockIsNotSupported": "La version actuelle ne prend pas en charge ce bloc.", "clickToCopyTheBlockContent": "Cliquez pour copier le contenu du bloc", "blockContentHasBeenCopied": "Le contenu du bloc a été copié.", "parseError": "Une erreur s'est produite lors de l'analyse du bloc {}.", "copyBlockContent": "Copier le contenu du bloc" }, "mobilePageSelector": { "title": "Sélectionner une page", "failedToLoad": "Impossible de charger la liste des pages", "noPagesFound": "Aucune page trouvée" }, "attachmentMenu": { "choosePhoto": "Choisir une photo", "takePicture": "Prendre une photo", "chooseFile": "Choisir le fichier" } }, "board": { "column": { "label": "Colonne", "createNewCard": "Nouveau", "renameGroupTooltip": "Appuyez pour renommer le groupe", "createNewColumn": "Ajouter un nouveau groupe", "addToColumnTopTooltip": "Ajouter une nouvelle carte en haut", "addToColumnBottomTooltip": "Ajouter une nouvelle carte en bas", "renameColumn": "Renommer", "hideColumn": "Cacher", "newGroup": "Nouveau groupe", "deleteColumn": "Supprimer", "deleteColumnConfirmation": "Cela supprimera ce groupe et toutes les cartes qu'il contient. \nÊtes-vous sûr de vouloir continuer?", "groupActions": "Actions de groupe" }, "hiddenGroupSection": { "sectionTitle": "Groupes cachés", "collapseTooltip": "Cacher les groupes cachés", "expandTooltip": "Afficher les groupes cachés" }, "cardDetail": "Détail de la Carte", "cardActions": "Actions des Cartes", "cardDuplicated": "La carte a été dupliquée", "cardDeleted": "La carte a été supprimée", "showOnCard": "Afficher les détails de la carte", "setting": "Paramètre", "propertyName": "Nom de la propriété", "menuName": "Tableau", "showUngrouped": "Afficher les éléments non regroupés", "ungroupedButtonText": "Non groupé", "ungroupedButtonTooltip": "Contient des cartes qui n'appartiennent à aucun groupe", "ungroupedItemsTitle": "Cliquez pour ajouter au tableau", "groupBy": "Regrouper par", "groupCondition": "Condition de groupe", "referencedBoardPrefix": "Vue", "notesTooltip": "Notes à l'intérieur", "mobile": { "editURL": "Modifier l'URL", "showGroup": "Afficher le groupe", "showGroupContent": "Êtes-vous sûr de vouloir afficher ce groupe sur le tableau ?", "failedToLoad": "Échec du chargement de la vue du tableau" }, "dateCondition": { "weekOf": "Semaine de {} - {}", "today": "Aujourd'hui", "yesterday": "Hier", "tomorrow": "Demain", "lastSevenDays": "7 derniers jours", "nextSevenDays": "7 prochains jours", "lastThirtyDays": "30 derniers jours", "nextThirtyDays": "30 prochains jours" }, "noGroup": "Pas de groupe par propriété", "noGroupDesc": "Les vues du tableau nécessitent une propriété de regroupement pour pouvoir s'afficher", "media": { "cardText": "{} {}", "fallbackName": "fichiers" } }, "calendar": { "menuName": "Calendrier", "defaultNewCalendarTitle": "Sans titre", "newEventButtonTooltip": "Ajouter un nouvel événement", "navigation": { "today": "Aujourd'hui", "jumpToday": "Aller à aujourd'hui", "previousMonth": "Mois précédent", "nextMonth": "Mois prochain", "views": { "day": "Jour", "week": "Semaine", "month": "Mois", "year": "Année" } }, "mobileEventScreen": { "emptyTitle": "Pas d'événements", "emptyBody": "Cliquez sur le bouton plus pour créer un événement à cette date." }, "settings": { "showWeekNumbers": "Afficher les numéros de semaine", "showWeekends": "Afficher les week-ends", "firstDayOfWeek": "Commencer la semaine le", "layoutDateField": "Calendrier de mise en page par", "changeLayoutDateField": "Modifier le champ de mise en page", "noDateTitle": "Pas de date", "unscheduledEventsTitle": "Événements non planifiés", "clickToAdd": "Cliquez pour ajouter au calendrier", "name": "Disposition du calendrier", "clickToOpen": "Cliquez pour ouvrir l'évènement", "noDateHint": "Les événements non planifiés s'afficheront ici" }, "referencedCalendarPrefix": "Vue", "quickJumpYear": "Sauter à", "duplicateEvent": "Événement en double" }, "errorDialog": { "title": "Erreur @:appName", "howToFixFallback": "Nous sommes désolés pour le désagrément ! Soumettez un problème sur notre page GitHub qui décrit votre erreur.", "howToFixFallbackHint1": "Nous sommes désolés pour la gêne occasionnée ! Soumettez un problème sur notre ", "howToFixFallbackHint2": " page qui décrit votre erreur.", "github": "Afficher sur GitHub" }, "search": { "label": "Recherche", "sidebarSearchIcon": "Rechercher et accéder rapidement à une page", "searchOrAskAI": "Rechercher ou demander à l'IA", "askAIAnything": "Demandez n'importe quoi à l'IA", "askAIFor": "Demandez à l'IA", "noResultForSearching": "Aucun résultat pour « {} »", "noResultForSearchingHint": "Certains résultats peuvent être dans vos pages supprimées", "bestMatch": "Meilleure correspondance", "placeholder": { "actions": "Actions de recherche..." } }, "message": { "copy": { "success": "Copié !", "fail": "Impossible de copier" } }, "unSupportBlock": "La version actuelle ne prend pas en charge ce bloc.", "views": { "deleteContentTitle": "Voulez-vous vraiment supprimer le {pageType}?", "deleteContentCaption": "si vous supprimez ce {pageType}, vous pouvez le restaurer à partir de la corbeille." }, "colors": { "custom": "Personnalisé", "default": "Défaut", "red": "Rouge", "orange": "Orange", "yellow": "Jaune", "green": "Vert", "blue": "Bleu", "purple": "Violet", "pink": "Rose", "brown": "Marron", "gray": "Gris" }, "emoji": { "emojiTab": "Émoji", "search": "Chercher un émoji", "noRecent": "Aucun émoji récent", "noEmojiFound": "Aucun émoji trouvé", "filter": "Filtrer", "random": "Aléatoire", "selectSkinTone": "Choisir le teint de la peau", "remove": "Supprimer l'émoji", "categories": { "smileys": "Smileys & émoticônes", "people": "Personnes & corps", "animals": "Animaux & Nature", "food": "Nourriture & Boisson", "activities": "Activités", "places": "Voyages & Lieux", "objects": "Objets", "symbols": "Symboles", "flags": "Drapeaux", "nature": "Nature", "frequentlyUsed": "Fréquemment utilisés" }, "skinTone": { "default": "Défaut", "light": "Claire", "mediumLight": "Moyennement claire", "medium": "Moyen", "mediumDark": "Moyennement foncé", "dark": "Foncé" }, "openSourceIconsFrom": "Icônes open source de" }, "inlineActions": { "noResults": "Aucun résultat", "recentPages": "Pages récentes", "pageReference": "Référence de page", "docReference": "Référence de document", "boardReference": "Référence du tableau", "calReference": "Référence du calendrier", "gridReference": "Référence de grille", "date": "Date", "reminder": { "groupTitle": "Rappel", "shortKeyword": "rappeler" }, "createPage": "Créer une sous-page « {} »" }, "datePicker": { "dateTimeFormatTooltip": "Modifier le format de la date et de l'heure dans les paramètres", "dateFormat": "Format de date", "includeTime": "Inclure l'heure", "isRange": "Date de fin", "timeFormat": "Format de l'heure", "clearDate": "Effacer la date", "reminderLabel": "Rappel", "selectReminder": "Sélectionnez un rappel", "reminderOptions": { "none": "Aucun", "atTimeOfEvent": "Heure de l'événement", "fiveMinsBefore": "5 minutes avant", "tenMinsBefore": "10 minutes avant", "fifteenMinsBefore": "15 minutes avant", "thirtyMinsBefore": "30 minutes avant", "oneHourBefore": "1 heure avant", "twoHoursBefore": "2 heures avant", "onDayOfEvent": "Le jour de l'événement", "oneDayBefore": "1 jour avant", "twoDaysBefore": "2 jours avant", "oneWeekBefore": "1 semaine avant", "custom": "Personnalisé" } }, "relativeDates": { "yesterday": "Hier", "today": "Aujourd'hui", "tomorrow": "Demain", "oneWeek": "1 semaine" }, "notificationHub": { "title": "Notifications", "closeNotification": "Fermer la notification", "viewNotifications": "Afficher les notifications", "noNotifications": "Aucune notification pour le moment", "mentionedYou": "vous a mentionné", "markAsReadTooltip": "Marquer comme lu cette notification", "markAsReadSucceedToast": "Marquer comme lu avec succès", "markAllAsReadSucceedToast": "Tout marquer comme lu avec succès", "today": "Aujourd'hui", "older": "Plus vieux", "mobile": { "title": "Mises à jour" }, "emptyTitle": "Vous êtes à jour !", "emptyBody": "Aucune notification ou action en attente. Profitez du calme.", "tabs": { "inbox": "Boîte de réception", "upcoming": "A venir" }, "actions": { "markAllRead": "Tout marquer comme lu", "showAll": "Tous", "showUnreads": "Non lu" }, "filters": { "ascending": "Ascendant", "descending": "Descendant", "groupByDate": "Regrouper par date", "showUnreadsOnly": "Afficher uniquement les éléments non lus", "resetToDefault": "Réinitialiser aux valeurs par défaut" }, "archievedTooltip": "Archiver cette notification", "unarchievedTooltip": "Cette notification n'a pas été archivée.", "markAsArchievedSucceedToast": "Archivage réussi", "markAllAsArchievedSucceedToast": "Tout archiver avec succès" }, "reminderNotification": { "title": "Rappel", "message": "Pensez à vérifier cela avant d'oublier !", "tooltipDelete": "Supprimer", "tooltipMarkRead": "Marquer comme lu", "tooltipMarkUnread": "Marquer comme non lu" }, "findAndReplace": { "find": "Chercher", "previousMatch": "Occurence précedente", "nextMatch": "Prochaine occurence", "close": "Fermer", "replace": "Remplacer", "replaceAll": "Tout remplacer", "noResult": "Aucun résultat", "caseSensitive": "Sensible à la casse", "searchMore": "Chercher pour trouver plus de résultat" }, "error": { "weAreSorry": "Nous sommes désolés", "loadingViewError": "Nous rencontrons des difficultés pour charger cette vue. Veuillez vérifier votre connexion Internet, actualiser l'application et n'hésitez pas à contacter l'équipe si le problème persiste.", "syncError": "Les données ne sont pas synchronisées depuis un autre appareil", "syncErrorHint": "Veuillez rouvrir cette page sur l'appareil sur lequel elle a été modifiée pour la dernière fois, puis l'ouvrir à nouveau sur l'appareil actuel.", "clickToCopy": "Cliquez pour copier le code d'erreur" }, "editor": { "bold": "Gras", "bulletedList": "Liste à puces", "bulletedListShortForm": "Puces", "checkbox": "Case à cocher", "embedCode": "Code intégré", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Surligner", "color": "Couleur", "image": "Image", "date": "Date", "page": "Page", "italic": "Italique", "link": "Lien", "numberedList": "Liste numérotée", "numberedListShortForm": "Numéroté", "toggleHeading1ShortForm": "Bascule h1", "toggleHeading2ShortForm": "Bascule h2", "toggleHeading3ShortForm": "Bascule h3", "quote": "Citation", "strikethrough": "Barré", "text": "Texte", "underline": "Souligner", "fontColorDefault": "Défaut", "fontColorGray": "Gris", "fontColorBrown": "Marron", "fontColorOrange": "Orange", "fontColorYellow": "Jaune", "fontColorGreen": "Vert", "fontColorBlue": "Bleu", "fontColorPurple": "Violet", "fontColorPink": "Rose", "fontColorRed": "Rouge", "backgroundColorDefault": "Fond par défaut", "backgroundColorGray": "Fond gris", "backgroundColorBrown": "Fond marron", "backgroundColorOrange": "Fond orange", "backgroundColorYellow": "Fond jaune", "backgroundColorGreen": "Fond vert", "backgroundColorBlue": "Fond bleu", "backgroundColorPurple": "Fond violet", "backgroundColorPink": "Fond rose", "backgroundColorRed": "Fond rouge", "backgroundColorLime": "Fond citron vert", "backgroundColorAqua": "Fond turquoise ", "done": "Fait", "cancel": "Annuler", "tint1": "Teinte 1", "tint2": "Teinte 2", "tint3": "Teinte 3", "tint4": "Teinte 4", "tint5": "Teinte 5", "tint6": "Teinte 6", "tint7": "Teinte 7", "tint8": "Teinte 8", "tint9": "Teinte 9", "lightLightTint1": "Violet", "lightLightTint2": "Rose", "lightLightTint3": "Rose clair", "lightLightTint4": "Orange", "lightLightTint5": "Jaune", "lightLightTint6": "Citron vert", "lightLightTint7": "Vert", "lightLightTint8": "Turquoise", "lightLightTint9": "Bleu", "urlHint": "URL", "mobileHeading1": "Titre 1", "mobileHeading2": "Titre 2", "mobileHeading3": "Titre 3", "mobileHeading4": "Titre 4", "mobileHeading5": "Titre 5 ", "mobileHeading6": "Titre 6", "textColor": "Couleur du texte", "backgroundColor": "Couleur du fond", "addYourLink": "Ajoutez votre lien", "openLink": "Ouvrir le lien", "copyLink": "Copier le lien", "removeLink": "Supprimer le lien", "editLink": "Modifier le lien", "convertTo": "Convertir en", "linkText": "Texte", "linkTextHint": "Veuillez saisir du texte", "linkAddressHint": "Veuillez entrer l'URL", "highlightColor": "Couleur de surlignage", "clearHighlightColor": "Effacer la couleur de surlignage", "customColor": "Couleur personnalisée", "hexValue": "Valeur hexadécimale", "opacity": "Opacité", "resetToDefaultColor": "Réinitialiser la couleur par défaut", "ltr": "LTR", "rtl": "RTL", "auto": "Auto", "cut": "Couper", "copy": "Copier", "paste": "Coller", "find": "Chercher", "select": "Sélectionner", "selectAll": "Tout sélectionner", "previousMatch": "Occurence précédente", "nextMatch": "Occurence suivante", "closeFind": "Fermer", "replace": "Remplacer", "replaceAll": "Tout remplacer", "regex": "Regex", "caseSensitive": "Sensible à la casse", "uploadImage": "Téléverser une image", "urlImage": "URL de l'image ", "incorrectLink": "Lien incorrect", "upload": "Téléverser", "chooseImage": "Choisissez une image", "loading": "Chargement", "imageLoadFailed": "Impossible de charger l'image", "divider": "Séparateur", "table": "Tableau", "colAddBefore": "Ajouter avant", "rowAddBefore": "Ajouter avant", "colAddAfter": "Ajouter après", "rowAddAfter": "Ajouter après", "colRemove": "Retirer", "rowRemove": "Retirer", "colDuplicate": "Dupliquer", "rowDuplicate": "Dupliquer", "colClear": "Effacer le ontenu", "rowClear": "Effacer le ontenu", "slashPlaceHolder": "Tapez '/' pour insérer un bloc ou commencez à écrire", "typeSomething": "Écrivez quelque chose...", "toggleListShortForm": "Plier / Déplier", "quoteListShortForm": "Citation", "mathEquationShortForm": "Formule", "codeBlockShortForm": "Code" }, "favorite": { "noFavorite": "Aucune page favorite", "noFavoriteHintText": "Faites glisser la page vers la gauche pour l'ajouter à vos favoris", "removeFromSidebar": "Supprimer de la barre latérale", "addToSidebar": "Épingler sur la barre latérale" }, "cardDetails": { "notesPlaceholder": "Entrez un / pour insérer un bloc ou commencez à taper" }, "blockPlaceholders": { "todoList": "À faire", "bulletList": "Liste", "numberList": "Liste", "quote": "Citation", "heading": "Titre {}" }, "titleBar": { "pageIcon": "Icône de page", "language": "Langue", "font": "Police ", "actions": "Actions", "date": "Date", "addField": "Ajouter un champ", "userIcon": "Icône utilisateur" }, "noLogFiles": "Il n'y a pas de log", "newSettings": { "myAccount": { "title": "Mon compte", "subtitle": "Personnalisez votre profil, gérez la sécurité de votre compte, ouvrez les clés AI ou connectez-vous à votre compte.", "profileLabel": "Nom du compte et image de profil", "profileNamePlaceholder": "Entrez votre nom", "accountSecurity": "Sécurité du compte", "2FA": "Authentification en 2 étapes", "aiKeys": "Clés IA", "accountLogin": "Connexion au compte", "updateNameError": "Échec de la mise à jour du nom", "updateIconError": "Échec de la mise à jour de l'icône", "aboutAppFlowy": "À propos de @:appName", "deleteAccount": { "title": "Supprimer le compte", "subtitle": "Supprimez définitivement votre compte et toutes vos données.", "description": "Supprimez définitivement votre compte et supprimez l'accès à tous les espaces de travail.", "deleteMyAccount": "Supprimer mon compte", "dialogTitle": "Supprimer le compte", "dialogContent1": "Êtes-vous sûr de vouloir supprimer définitivement votre compte ?", "dialogContent2": "Cette action ne peut pas être annulée et supprimera l'accès à tous les espaces d'équipe, effaçant l'intégralité de votre compte, y compris les espaces de travail privés, et vous supprimant de tous les espaces de travail partagés.", "confirmHint1": "Veuillez taper « @:newSettings.myAccount.deleteAccount.confirmHint3 » pour confirmer.", "confirmHint2": "Je comprends que cette action est irréversible et supprimera définitivement mon compte et toutes les données associées.", "confirmHint3": "SUPPRIMER MON COMPTE", "checkToConfirmError": "Vous devez cocher la case pour confirmer la suppression", "failedToGetCurrentUser": "Impossible d'obtenir l'e-mail de l'utilisateur actuel", "confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « @:newSettings.myAccount.deleteAccount.confirmHint3 »", "deleteAccountSuccess": "Compte supprimé avec succès" }, "password": { "title": "Mot de passe", "changePassword": "Changer le mot de passe", "currentPassword": "Mot de passe actuel", "newPassword": "Nouveau mot de passe", "confirmNewPassword": "Confirmer le nouveau mot de passe", "setupPassword": "Configurer le mot de passe", "error": { "newPasswordIsRequired": "Un nouveau mot de passe est requis", "confirmPasswordIsRequired": "Confirmer le mot de passe est requis", "passwordsDoNotMatch": "Les mots de passe ne correspondent pas", "newPasswordIsSameAsCurrent": "Le nouveau mot de passe est identique au mot de passe actuel" }, "toast": { "passwordUpdatedSuccessfully": "Mot de passe mis à jour avec succès", "passwordUpdatedFailed": "Échec de la mise à jour du mot de passe", "passwordSetupSuccessfully": "Configuration du mot de passe réussie", "passwordSetupFailed": "Échec de la configuration du mot de passe" }, "hint": { "enterYourCurrentPassword": "Entrez votre mot de passe actuel", "enterYourNewPassword": "Entrez votre nouveau mot de passe", "confirmYourNewPassword": "Confirmez votre nouveau mot de passe" } }, "myAccount": "Mon compte", "myProfile": "Mon profil" }, "workplace": { "name": "Lieu de travail", "title": "Paramètres du lieu de travail", "subtitle": "Personnalisez l'apparence, le thème, la police, la disposition du texte, la date, l'heure et la langue de votre espace de travail.", "workplaceName": "Nom du lieu de travail", "workplaceNamePlaceholder": "Entrez le nom du lieu de travail", "workplaceIcon": "Icône du lieu de travail", "workplaceIconSubtitle": "Téléchargez une image ou utilisez un emoji pour votre espace de travail. L'icône s'affichera dans votre barre latérale et dans vos notifications", "renameError": "Échec du changement de nom du lieu de travail", "updateIconError": "Échec de la mise à jour de l'icône", "chooseAnIcon": "Choisissez une icône", "appearance": { "name": "Apparence", "themeMode": { "auto": "Auto", "light": "Claire", "dark": "Sombre" }, "language": "Langue" } }, "syncState": { "syncing": "Synchronisation", "synced": "Synchronisé", "noNetworkConnected": "Aucun réseau connecté" } }, "pageStyle": { "title": "Style de page", "layout": "Mise en page", "coverImage": "Image de couverture", "pageIcon": "Icône de page", "colors": "Couleurs", "gradient": "Dégradé", "backgroundImage": "Image d'arrière-plan", "presets": "Préréglages", "photo": "Photo", "unsplash": "Unsplash", "pageCover": "Couverture de page", "none": "Aucun", "openSettings": "Ouvrir les paramètres", "photoPermissionTitle": "@:appName souhaite accéder à votre photothèque", "photoPermissionDescription": "Autoriser l'accès à la photothèque pour le téléchargement d'images.", "cameraPermissionTitle": "@:appName souhaite accéder à votre caméra", "cameraPermissionDescription": "@:appName a besoin d'accéder à votre appareil photo pour vous permettre d'ajouter des images à vos documents à partir de l'appareil photo", "doNotAllow": "Ne pas autoriser", "image": "Image" }, "commandPalette": { "placeholder": "Tapez pour rechercher des vues...", "bestMatches": "Meilleurs résultats", "aiOverview": "Présentation de l'IA", "aiOverviewSource": "Sources de référence", "aiOverviewMoreDetails": "Plus de détails", "pagePreview": "Aperçu du contenu", "clickToOpenPage": "Cliquez pour ouvrir la page", "recentHistory": "Historique récent", "navigateHint": "naviguer", "loadingTooltip": "Nous recherchons des résultats...", "betaLabel": "BÊTA", "betaTooltip": "Nous ne prenons actuellement en charge que la recherche de pages", "fromTrashHint": "Depuis la poubelle", "noResultsHint": "Nous n'avons pas trouvé ce que vous cherchez, essayez avec un autre terme.", "clearSearchTooltip": "Effacer le champ de recherche" }, "space": { "delete": "Supprimer", "deleteConfirmation": "Supprimer: ", "deleteConfirmationDescription": "Toutes les pages de cet espace seront supprimées et déplacées vers la corbeille, et toutes les pages publiées seront dépubliées.", "rename": "Renommer l'espace", "changeIcon": "Changer d'icône", "manage": "Gérer l'espace", "addNewSpace": "Créer un espace", "collapseAllSubPages": "Réduire toutes les sous-pages", "createNewSpace": "Créer un nouvel espace", "createSpaceDescription": "Créez plusieurs espaces publics et privés pour mieux organiser votre travail.", "spaceName": "Nom de l'espace", "spaceNamePlaceholder": "par exemple, marketing, ingénierie, ressources humaines", "permission": "Autorisation", "publicPermission": "Publique", "publicPermissionDescription": "Tous les membres de l'espace de travail avec un accès complet", "privatePermission": "Privé", "privatePermissionDescription": "Vous seul pouvez accéder à cet espace", "spaceIconBackground": "Couleur d'arrière-plan", "spaceIcon": "Icône", "dangerZone": "Zone de danger", "unableToDeleteLastSpace": "Impossible de supprimer le dernier espace", "unableToDeleteSpaceNotCreatedByYou": "Impossible de supprimer les espaces créés par d'autres", "enableSpacesForYourWorkspace": "Activer les espaces pour votre espace de travail", "title": "Espaces", "defaultSpaceName": "Général", "upgradeSpaceTitle": "Activer les espaces", "upgradeSpaceDescription": "Créez plusieurs espaces publics et privés pour mieux organiser votre espace de travail.", "upgrade": "Mettre à jour", "upgradeYourSpace": "Créer plusieurs espaces", "quicklySwitch": "Passer rapidement à l’espace suivant", "duplicate": "dupliquer l'espace ", "movePageToSpace": "Déplacer la page vers l'espace", "cannotMovePageToDatabase": "Impossible de déplacer la page vers la base de données", "switchSpace": "Changer d'espace", "spaceNameCannotBeEmpty": "Le nom de l'espace ne peut pas être vide", "success": { "deleteSpace": "Espace supprimé avec succès", "renameSpace": "Espace renommé avec succès", "duplicateSpace": "Espace dupliqué avec succès", "updateSpace": "Espace mis à jour avec succès" }, "error": { "deleteSpace": "Impossible de supprimer l'espace", "renameSpace": "Impossible de renommer l'espace", "duplicateSpace": "Impossible de dupliquer l'espace", "updateSpace": "Échec de la mise à jour de l'espace" }, "createSpace": "Créer de l'espace", "manageSpace": "Gérer l'espace", "renameSpace": "Renommer l'espace", "mSpaceIconColor": "Couleur de l'icône de l'espace", "mSpaceIcon": "Icône de l'espace" }, "publish": { "hasNotBeenPublished": "Cette page n'a pas encore été publiée", "spaceHasNotBeenPublished": "Je n'ai pas encore pris en charge la publication d'un espace", "reportPage": "Page de rapport", "databaseHasNotBeenPublished": "La publication d'une base de données n'est pas encore prise en charge.", "createdWith": "Créé avec", "downloadApp": "Télécharger AppFlowy", "copy": { "codeBlock": "Le contenu du bloc de code a été copié dans le presse-papiers", "imageBlock": "Le lien de l'image a été copié dans le presse-papiers", "mathBlock": "L'équation mathématique a été copiée dans le presse-papiers", "fileBlock": "Le lien du fichier a été copié dans le presse-papiers" }, "containsPublishedPage": "Cette page contient une ou plusieurs pages publiées. Si vous continuez, elles ne seront plus publiées. Voulez-vous procéder à la suppression ?", "publishSuccessfully": "Publié avec succès", "unpublishSuccessfully": "Dépublié avec succès", "publishFailed": "Impossible de publier", "unpublishFailed": "Impossible de dépublier", "noAccessToVisit": "Pas d'accès à cette page...", "createWithAppFlowy": "Créer un site Web avec AppFlowy", "fastWithAI": "Rapide et facile avec l'IA.", "tryItNow": "Essayez maintenant", "onlyGridViewCanBePublished": "Seule la vue Grille peut être publiée", "database": { "zero": "Publier {} vue sélectionné", "one": "Publier {} vues sélectionnées", "many": "Publier {} vues sélectionnées", "other": "Publier {} vues sélectionnées" }, "mustSelectPrimaryDatabase": "La vue principale doit être sélectionnée", "noDatabaseSelected": "Aucune base de données sélectionnée, veuillez sélectionner au moins une base de données.", "unableToDeselectPrimaryDatabase": "Impossible de désélectionner la base de données principale", "saveThisPage": "Sauvegarder cette page", "duplicateTitle": "Où souhaitez-vous ajouter", "selectWorkspace": "Sélectionnez un espace de travail", "addTo": "Ajouter à", "duplicateSuccessfully": "Dupliqué avec succès. Vous souhaitez consulter les documents ?", "duplicateSuccessfullyDescription": "Vous n'avez pas l'application ? Le téléchargement commencera automatiquement après avoir cliqué sur « Télécharger ».", "downloadIt": "Télécharger", "openApp": "Ouvrir dans l'application", "duplicateFailed": "Duplication échouée", "membersCount": { "zero": "Aucun membre", "one": "1 membre", "many": "{count} membres", "other": "{count} membres" }, "useThisTemplate": "Utiliser le modèle" }, "web": { "continue": "Continuer", "or": "ou", "continueWithGoogle": "Continuer avec Google", "continueWithGithub": "Continuer avec GitHub", "continueWithDiscord": "Continuer avec Discord", "continueWithApple": "Continuer avec Apple ", "moreOptions": "Plus d'options", "collapse": "Réduire", "signInAgreement": "En cliquant sur « Continuer » ci-dessus, vous avez accepté les conditions d'utilisation d'AppFlowy.", "and": "et", "termOfUse": "Termes", "privacyPolicy": "politique de confidentialité", "signInError": "Erreur de connexion", "login": "Inscrivez-vous ou connectez-vous", "fileBlock": { "uploadedAt": "Mis en ligne le {time}", "linkedAt": "Lien ajouté le {time}", "empty": "Envoyer ou intégrer un fichier", "uploadFailed": "Échec du téléchargement, veuillez réessayer", "retry": "Réessayer" }, "importNotion": "Importer depuis Notion", "import": "Importer", "importSuccess": "Téléchargé avec succès", "importSuccessMessage": "Nous vous informerons lorsque l'importation sera terminée. Vous pourrez ensuite visualiser vos pages importées dans la barre latérale.", "importFailed": "L'importation a échoué, veuillez vérifier le format du fichier", "dropNotionFile": "Déposez votre fichier zip Notion ici pour le télécharger, ou cliquez pour parcourir", "error": { "pageNameIsEmpty": "Le nom de la page est vide, veuillez réessayer" } }, "globalComment": { "comments": "Commentaires", "addComment": "Ajouter un commentaire", "reactedBy": "réagi par", "addReaction": "Ajouter une réaction", "reactedByMore": "et {count} autres", "showSeconds": { "one": "Il y a 1 seconde", "other": "Il y a {count} secondes", "zero": "Tout à l' heure", "many": "Il y a {count} secondes" }, "showMinutes": { "one": "Il y a 1 minute", "other": "Il y a {count} minutes", "many": "Il y a {count} minutes" }, "showHours": { "one": "il y a 1 heure", "other": "Il y a {count} heures", "many": "Il y a {count} heures" }, "showDays": { "one": "Il y a 1 jour", "other": "Il y a {count} jours", "many": "Il y a {count} jours" }, "showMonths": { "one": "Il y a 1 mois", "other": "Il y a {count} mois", "many": "Il y a {count} mois" }, "showYears": { "one": "Il y a 1 an", "other": "Il y a {count} ans", "many": "Il y a {count} ans" }, "reply": "Répondre", "deleteComment": "Supprimer le commentaire", "youAreNotOwner": "Vous n'êtes pas le propriétaire de ce commentaire", "confirmDeleteDescription": "Etes-vous sûr de vouloir supprimer ce commentaire ?", "hasBeenDeleted": "Supprimé", "replyingTo": "En réponse à", "noAccessDeleteComment": "Vous n'êtes pas autorisé à supprimer ce commentaire", "collapse": "Réduire", "readMore": "En savoir plus", "failedToAddComment": "Problème lors de l'ajout du commentaire", "commentAddedSuccessfully": "Commentaire ajouté avec succès.", "commentAddedSuccessTip": "Vous venez d'ajouter ou de répondre à un commentaire. Souhaitez-vous passer en haut de la page pour voir les derniers commentaires ?" }, "template": { "asTemplate": "En tant que modèle", "name": "Nom du modèle", "description": "Description du modèle", "about": "À propos du modèle", "deleteFromTemplate": "Supprimer des modèles", "preview": "Aperçu du modèle", "categories": "Catégories de modèles", "isNewTemplate": "PIN vers un nouveau modèle", "featured": "PIN pour les éléments en vedette", "relatedTemplates": "Modèles associés", "requiredField": "{field} est requis", "addCategory": "Ajouter \"{category}\"", "addNewCategory": "Ajouter une nouvelle catégorie", "addNewCreator": "Ajouter un nouvel auteur", "deleteCategory": "Supprimer la catégorie", "editCategory": "Modifier la catégorie", "editCreator": "Modifier le créateur", "category": { "name": "Nom de la catégorie", "icon": "Icône de la catégorie", "bgColor": "Couleur d'arrière-plan de la catégorie", "priority": "Priorité de la catégorie", "desc": "Description de la catégorie", "type": "Type de catégorie", "icons": "Icônes de catégorie", "colors": "Couleurs de catégorie ", "byUseCase": "Par cas d'utilisation", "byFeature": "Par fonctionnalité", "deleteCategory": "Supprimer la catégorie", "deleteCategoryDescription": "Êtes-vous sûr de vouloir supprimer cette catégorie ?", "typeToSearch": "Tapez pour rechercher des catégories..." }, "creator": { "label": "Auteur du modèle", "name": "Nom de l'auteur", "avatar": "Avatar de l'auteur", "accountLinks": "Liens vers le compte de l'auteur", "uploadAvatar": "Cliquez pour ajouter un avatar", "deleteCreator": "Supprimer l'auteur", "deleteCreatorDescription": "Êtes-vous sûr de vouloir supprimer cet auteur ?", "typeToSearch": "Tapez pour rechercher des auteurs..." }, "uploadSuccess": "Modèle envoyé avec succès", "uploadSuccessDescription": "Votre modèle a été envoyé avec succès. Vous pouvez maintenant le visualiser dans la galerie de modèles.", "viewTemplate": "Voir le modèle", "deleteTemplate": "Supprimer le modèle", "deleteSuccess": "Modèle supprimé avec succès", "deleteTemplateDescription": "Êtes-vous sûr de vouloir supprimer ce modèle ?", "addRelatedTemplate": "Ajouter un modèle associé", "removeRelatedTemplate": "Supprimer le modèle associé", "uploadAvatar": "Envoyer l'avatar", "searchInCategory": "Rechercher dans {category}", "label": "Modèle" }, "fileDropzone": { "dropFile": "Cliquez ou faites glisser le fichier vers cette zone pour l'envoyer", "uploading": "Envoi en cours...", "uploadFailed": "Envoi échoué", "uploadSuccess": "Envoi réussi", "uploadSuccessDescription": "Le fichier a été envoyé avec succès", "uploadFailedDescription": "L'envoi du fichier a échoué", "uploadingDescription": "Le fichier est en cours d'envoi" }, "gallery": { "preview": "Ouvrir en plein écran", "copy": "Copier", "download": "Télécharger", "prev": "Précédent", "next": "Suivant", "resetZoom": "Réinitialiser le zoom", "zoomIn": "Agrandir", "zoomOut": "Rétrécir" }, "invitation": { "join": "Rejoindre", "on": "sur", "invitedBy": "Invité par", "membersCount": { "zero": "{count} membres", "one": "{count} membre", "many": "{count} membres", "other": "{count} membres" }, "tip": "Vous avez été invité à rejoindre cet espace de travail avec les coordonnées ci-dessous. Si celles-ci sont incorrectes, contactez votre administrateur pour renvoyer l'invitation.", "joinWorkspace": "Rejoindre l'espace de travail", "success": "Vous avez rejoint avec succès l'espace de travail", "successMessage": "Vous pouvez désormais accéder à toutes les pages et espaces de travail qu'il contient.", "openWorkspace": "Ouvrir AppFlowy", "alreadyAccepted": "Vous avez déjà accepté l'invitation", "errorModal": { "title": "Quelque chose s'est mal passé", "description": "Il est possible que votre compte actuel {email} n'ait pas accès à cet espace de travail. Veuillez vous connecter avec le compte approprié ou contacter le propriétaire de l'espace de travail pour obtenir de l'aide.", "contactOwner": "Contacter le propriétaire", "close": "Retour à l'accueil", "changeAccount": "Changer de compte" } }, "requestAccess": { "title": "Pas d'accès à cette page", "subtitle": "Vous pouvez demander l'accès au propriétaire de cette page. Une fois approuvé, vous pourrez consulter la page.", "requestAccess": "Demande d'accès", "backToHome": "Retour à l'accueil", "tip": "Vous êtes actuellement connecté en tant que .", "mightBe": "Vous pourriez avoir besoin de avec un compte différent.", "successful": "Demande envoyée avec succès", "successfulMessage": "Vous serez averti une fois que le propriétaire aura approuvé votre demande.", "requestError": "Échec de la demande d'accès", "repeatRequestError": "Vous avez déjà demandé l'accès à cette page" }, "approveAccess": { "title": "Approuver la demande d'adhésion à l'espace de travail", "requestSummary": "demandes d'adhésion et accès", "upgrade": "mise à niveau", "downloadApp": "Télécharger AppFlowy", "approveButton": "Approuver", "approveSuccess": "Approuvé avec succès", "approveError": "Échec de l'approbation, assurez-vous que la limite du plan d'espace de travail n'est pas dépassée", "getRequestInfoError": "Impossible d'obtenir les informations de la demande", "memberCount": { "zero": "Aucun membre", "one": "1 membre", "many": "{count} membres", "other": "{count} membres" }, "alreadyProTitle": "Vous avez atteint la limite du plan d'espace de travail", "alreadyProMessage": "Demandez-leur de contacter pour débloquer plus de membres", "repeatApproveError": "Vous avez déjà approuvé cette demande", "ensurePlanLimit": "Assurez-vous que la limite du plan d'espace de travail n'est pas dépassée. Si la limite est dépassée, envisagez le plan de l'espace de travail ou .", "requestToJoin": "demandé à rejoindre", "asMember": "en tant que membre" }, "upgradePlanModal": { "title": "Passer à Pro", "message": "{name} a atteint la limite de membres gratuits. Passez au plan Pro pour inviter plus de membres.", "upgradeSteps": "Comment mettre à niveau votre plan sur AppFlowy :", "step1": "1. Accédez aux paramètres", "step2": "2. Cliquez sur « Offre »", "step3": "3. Sélectionnez « Changer d'offre »", "appNote": "Note: ", "actionButton": "Mettre à niveau", "downloadLink": "Télécharger l'application", "laterButton": "Plus tard", "refreshNote": "Après une mise à niveau réussie, cliquez sur pour activer vos nouvelles fonctionnalités.", "refresh": "ici" }, "breadcrumbs": { "label": "Fil d'Ariane" }, "time": { "justNow": "A l'instant", "seconds": { "one": "1 seconde", "other": "{count} secondes" }, "minutes": { "one": "1 minute", "other": "{compter} minutes" }, "hours": { "one": "1 heure", "other": "{count} heures" }, "days": { "one": "1 jour", "other": "{count} jours" }, "weeks": { "one": "1 semaine", "other": "{count} semaines" }, "months": { "one": "1 mois", "other": "{count} mois" }, "years": { "one": "1 an", "other": "{count} années" }, "ago": "il y a", "yesterday": "Hier", "today": "Aujourd'hui" }, "members": { "zero": "Aucun membre", "one": "1 membre", "many": "{count} membres", "other": "{count} membres" }, "tabMenu": { "close": "Fermer", "closeDisabledHint": "Impossible de fermer un onglet épinglé, veuillez d'abord le désépingler", "closeOthers": "Fermer les autres onglets", "closeOthersHint": "Cela fermera tous les onglets non épinglés sauf celui-ci", "closeOthersDisabledHint": "Tous les onglets sont épinglés, je ne trouve aucun onglet à fermer", "favorite": "Favori", "unfavorite": "Non favori", "favoriteDisabledHint": "Impossible de mettre en favori cette vue", "pinTab": "Épingler", "unpinTab": "Désépingler" }, "openFileMessage": { "success": "Fichier ouvert avec succès", "fileNotFound": "Fichier introuvable", "noAppToOpenFile": "Aucune application pour ouvrir ce fichier", "permissionDenied": "Aucune autorisation d'ouvrir ce fichier", "unknownError": "Échec de l'ouverture du fichier" }, "inviteMember": { "requestInviteMembers": "Inviter à votre espace de travail", "inviteFailedMemberLimit": "La limite de membres a été atteinte, veuillez ", "upgrade": "mettre à niveau", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "Envoyer des invitations", "inviteAlready": "Vous avez déjà inviter cet email: {email}", "inviteSuccess": "Invitation envoyée avec succès", "description": "Saisissez les adresses emails en les séparant par une virgule. Les frais sont basés sur le nombre de membres.", "emails": "Email" }, "quickNote": { "label": "Note Rapide", "quickNotes": "Notes Rapides", "search": "Chercher les Notes Rapides", "collapseFullView": "Fermer la vue d'ensemble", "expandFullView": "Ouvrir la vue d'ensemble", "createFailed": "Échec de la création de la Note Rapide", "quickNotesEmpty": "Aucune Notes Rapides", "emptyNote": "Note vide", "deleteNotePrompt": "La note sélectionnée sera supprimée définitivement. Êtes-vous sûr de vouloir la supprimer ?", "addNote": "Nouvelle Note", "noAdditionalText": "Aucun texte supplémentaire" }, "subscribe": { "upgradePlanTitle": "Comparez et sélectionnez le plan", "yearly": "Annuel", "save": "Économisez {discount}%", "monthly": "Mensuel", "priceIn": "Prix en ", "free": "Gratuit", "pro": "Pro", "freeDescription": "Pour les particuliers jusqu'à 2 membres pour tout organiser", "proDescription": "Pour les petites équipes pour gérer les projets et les connaissances de l'équipe", "proDuration": { "monthly": "par membre et par mois\nfacturé mensuellement", "yearly": "par membre et par mois\nfacturé annuellement" }, "cancel": "Rétrograder", "changePlan": "Passer au plan Pro", "everythingInFree": "Tout en Gratuit +", "currentPlan": "Actuel", "freeDuration": "pour toujours", "freePoints": { "first": "1 espace de travail collaboratif jusqu'à 2 membres", "second": "Pages et blocs illimités", "three": "5 Go de stockage", "four": "Recherche intelligente", "five": "20 réponses de l'IA", "six": "Application mobile", "seven": "Collaboration en temps réel" }, "proPoints": { "first": "Stockage illimité", "second": "Jusqu'à 10 membres de l'espace de travail", "three": "Réponses IA illimitées", "four": "Téléchargements de fichiers illimités", "five": "Espace de noms personnalisé" }, "cancelPlan": { "title": "Désolé de te voir partir", "success": "Votre abonnement a été annulé avec succès", "description": "Nous sommes désolés de votre départ. Vos commentaires nous aideront à améliorer AppFlowy. Veuillez prendre quelques instants pour répondre à quelques questions.", "commonOther": "Autre", "otherHint": "Écrivez votre réponse ici", "questionOne": { "question": "Qu'est-ce qui vous a poussé à annuler votre abonnement AppFlowy Pro ?", "answerOne": "Coût trop élevé", "answerTwo": "Les fonctionnalités ne répondent pas aux attentes", "answerThree": "J'ai trouvé une meilleure alternative", "answerFour": "Je ne l'ai pas suffisamment utilisé pour justifier la dépense", "answerFive": "Problème de service ou difficultés techniques" }, "questionTwo": { "question": "Quelle est la probabilité que vous envisagiez de vous réabonner à AppFlowy Pro à l’avenir ?", "answerOne": "Très probable", "answerTwo": "Assez probable", "answerThree": "Pas sûr", "answerFour": "Peu probable", "answerFive": "Très peu probable" }, "questionThree": { "question": "Quelle fonctionnalité Pro avez-vous le plus appréciée pendant votre abonnement ?", "answerOne": "Collaboration multi-utilisateurs", "answerTwo": "Historique des versions à plus long terme", "answerThree": "Réponses IA illimitées", "answerFour": "Accès aux modèles d'IA locaux" }, "questionFour": { "question": "Comment décririez-vous votre expérience globale avec AppFlowy ?", "answerOne": "Super", "answerTwo": "Bien", "answerThree": "Moyenne", "answerFour": "En dessous de la moyenne", "answerFive": "Insatisfait" } } }, "ai": { "contentPolicyViolation": "La génération de l'image a échoué en raison d'un contenu sensible. Veuillez reformuler votre saisie et réessayer.", "textLimitReachedDescription": "Votre espace de travail est à court de réponses IA gratuites. Passez à l'offre Pro ou achetez une extension IA pour accéder à des réponses illimitées.", "imageLimitReachedDescription": "Vous avez épuisé votre quota d'images IA gratuites. Passez à l'offre Pro ou achetez une extension IA pour accéder à des réponses illimitées.", "limitReachedAction": { "textDescription": "Votre espace de travail est à court de réponses IA gratuites. Pour obtenir plus de réponses, veuillez", "imageDescription": "Vous avez épuisé votre quota d'images IA gratuites. Veuillez", "upgrade": "mise à niveau", "toThe": "au", "proPlan": "Plan Pro", "orPurchaseAn": "ou acheter un", "aiAddon": "module complémentaire d'IA" }, "editing": "Édition", "analyzing": "Analyser", "continueWritingEmptyDocumentTitle": "Continuer l'écriture d'erreur", "continueWritingEmptyDocumentDescription": "Nous avons du mal à développer le contenu de votre document. Rédigez une courte introduction et nous pourrons nous en occuper !", "more": "Plus", "customPrompt": { "browsePrompts": "Parcourir les invites", "usePrompt": "Utiliser l'invite", "featured": "En vedette", "example": "Exemple", "all": "Tous", "development": "Développement", "writing": "En écrivant", "healthAndFitness": "Santé et forme physique", "business": "Entreprise", "marketing": "Commercialisation", "travel": "Voyage", "others": "Autre", "sampleOutput": "Exemple de sortie", "contentSeo": "Contenu/SEO", "emailMarketing": "Marketing par e-mail", "paidAds": "Publicités payantes", "prCommunication": "Relations publiques/Communication", "recruiting": "Recrutement", "sales": "Ventes", "socialMedia": "Réseaux sociaux", "strategy": "Stratégie", "caseStudies": "Études de cas", "salesCopy": "Texte de vente", "learning": "Apprentissage" } }, "autoUpdate": { "criticalUpdateTitle": "Mise à jour requise pour continuer", "criticalUpdateDescription": "Nous avons apporté des améliorations pour améliorer votre expérience ! Veuillez mettre à jour la version {currentVersion} vers la version {newVersion} pour continuer à utiliser l'application.", "criticalUpdateButton": "Mise à jour", "bannerUpdateTitle": "Nouvelle version disponible !", "bannerUpdateDescription": "Obtenez les dernières fonctionnalités et correctifs. Cliquez sur « Mettre à jour » pour installer.", "bannerUpdateButton": "Mise à jour", "settingsUpdateTitle": "Nouvelle version ({newVersion}) disponible !", "settingsUpdateDescription": "Version actuelle : {currentVersion} (version officielle) → {newVersion}", "settingsUpdateButton": "Mise à jour", "settingsUpdateWhatsNew": "Quoi de neuf" }, "lockPage": { "lockPage": "Fermé", "reLockPage": "Reverrouiller", "lockTooltip": "Page verrouillée pour éviter toute modification accidentelle. Cliquez pour la déverrouiller.", "pageLockedToast": "Page verrouillée. La modification est impossible jusqu'à ce que quelqu'un la déverrouille.", "lockedOperationTooltip": "Page verrouillée pour éviter toute modification accidentelle." }, "suggestion": { "accept": "Accepter", "keep": "Garder", "discard": "Jeter", "close": "Fermer", "tryAgain": "Essayer à nouveau", "rewrite": "Récrire", "insertBelow": "Insérer ci-dessous" } } ================================================ FILE: frontend/resources/translations/ga-IE.json ================================================ { "appName": "Appflowy", "defaultUsername": "Liom", "welcomeText": "Fáilte go @:appName", "welcomeTo": "Fáilte chuig", "githubStarText": "Réalta ar GitHub", "subscribeNewsletterText": "Liostáil le Nuachtlitir", "letsGoButtonText": "Tús Tapa", "title": "Teideal", "youCanAlso": "Is féidir leat freisin", "and": "agus", "failedToOpenUrl": "Theip ar oscailt an url: {}", "blockActions": { "addBelowTooltip": "Cliceáil chun cur leis thíos", "addAboveCmd": "Alt+cliceáil", "addAboveMacCmd": "Option+cliceáil", "addAboveTooltip": "a chur thuas", "dragTooltip": "Tarraing chun bogadh", "openMenuTooltip": "Cliceáil chun an roghchlár a oscailt" }, "signUp": { "buttonText": "Cláraigh", "title": "Cláraigh le @:appName", "getStartedText": "Faigh Tosaigh", "emptyPasswordError": "Ní féidir le pasfhocal a bheith folamh", "repeatPasswordEmptyError": "Ní féidir an pasfhocal athdhéanta a bheith folamh", "unmatchedPasswordError": "Ní ionann pasfhocal athdhéanta agus pasfhocal", "alreadyHaveAnAccount": "An bhfuil cuntas agat cheana féin?", "emailHint": "Ríomhphost", "passwordHint": "Pasfhocal", "repeatPasswordHint": "Déan pasfhocal arís", "signUpWith": "Cláraigh le:" }, "signIn": { "loginTitle": "Logáil isteach ar @:appName", "loginButtonText": "Logáil isteach", "loginStartWithAnonymous": "Lean ar aghaidh le seisiún gan ainm", "continueAnonymousUser": "Lean ar aghaidh le seisiún gan ainm", "buttonText": "Sínigh Isteach", "signingInText": "Ag síniú isteach...", "forgotPassword": "Pasfhocal Dearmadta?", "emailHint": "Ríomhphost", "passwordHint": "Pasfhocal", "dontHaveAnAccount": "Nach bhfuil cuntas agat?", "createAccount": "Cruthaigh cuntas", "repeatPasswordEmptyError": "Ní féidir an pasfhocal athdhéanta a bheith folamh", "unmatchedPasswordError": "Ní hionann pasfhocal athdhéanta agus pasfhocal", "syncPromptMessage": "Seans go dtógfaidh sé tamall na sonraí a shioncronú. Ná dún an leathanach seo, le do thoil", "or": "NÓ", "signInWithGoogle": "Lean ar aghaidh le Google", "signInWithGithub": "Lean ar aghaidh le GitHub", "signInWithDiscord": "Lean ar aghaidh le Discord", "signInWithApple": "Lean ar aghaidh le Apple", "continueAnotherWay": "Lean ar aghaidh ar bhealach eile", "signUpWithGoogle": "Cláraigh le Google", "signUpWithGithub": "Cláraigh le Github", "signUpWithDiscord": "Cláraigh le Discord", "signInWith": "Lean ar aghaidh le:", "signInWithEmail": "Lean ar aghaidh le Ríomhphost", "signInWithMagicLink": "Lean ort", "signUpWithMagicLink": "Cláraigh le Magic Link", "pleaseInputYourEmail": "Cuir isteach do sheoladh ríomhphoist", "settings": "Socruithe", "magicLinkSent": "Magic Link seolta!", "invalidEmail": "Cuir isteach seoladh ríomhphoist bailí", "alreadyHaveAnAccount": "An bhfuil cuntas agat cheana féin?", "logIn": "Logáil isteach", "generalError": "Chuaigh rud éigin mícheart. Bain triail eile as ar ball", "limitRateError": "Ar chúiseanna slándála, ní féidir leat nasc draíochta a iarraidh ach gach 60 soicind", "anonymous": "Gan ainm" }, "workspace": { "chooseWorkspace": "Roghnaigh do spás oibre", "defaultName": "Mo Spás Oibre", "create": "Cruthaigh spás oibre", "new": "Spás oibre nua", "importFromNotion": "Iompórtáil ó Notion", "learnMore": "Foghlaim níos mó", "reset": "Athshocraigh spás oibre", "renameWorkspace": "Athainmnigh spás oibre", "workspaceNameCannotBeEmpty": "Ní féidir leis an ainm spás oibre a bheith folamh", "hint": "spás oibre", "notFoundError": "Spás oibre gan aimsiú", "errorActions": { "reportIssue": "Tuairiscigh saincheist", "reportIssueOnGithub": "Tuairiscigh ceist faoi Github", "exportLogFiles": "Easpórtáil comhaid logáil", "reachOut": "Bhaint amach le Discord" }, "menuTitle": "Spásanna oibre", "createSuccess": "Cruthaíodh spás oibre go rathúil", "leaveCurrentWorkspace": "Fág spás óibre" }, "shareAction": { "buttonText": "Comhroinn", "workInProgress": "Ag teacht go luath", "markdown": "Markdown", "html": "HTML", "clipboard": "Cóipeáil chuig an ngearrthaisce", "csv": "CSV", "copyLink": "Cóipeáil nasc", "publishToTheWeb": "Foilsigh don Ghréasán", "publishToTheWebHint": "Cruthaigh suíomh Gréasáin le AppFlowy", "publish": "Foilsigh", "unPublish": "Dífhoilsiú", "visitSite": "Tabhair cuairt ar an suíomh", "publishTab": "Foilsigh", "shareTab": "Comhroinn" }, "moreAction": { "small": "beag", "medium": "meánach", "large": "mór", "fontSize": "Clómhéid", "import": "Iompórtáil", "createdAt": "Cruthaithe: {}", "deleteView": "Scrios" } } ================================================ FILE: frontend/resources/translations/he.json ================================================ { "appName": "AppFlowy", "defaultUsername": "אני", "welcomeText": "ברוך בואך אל @:appName", "welcomeTo": "ברוך בואך אל", "githubStarText": "כוכב ב־GitHub", "subscribeNewsletterText": "הרשמה לרשימת הדיוור", "letsGoButtonText": "התחלה זריזה", "title": "כותרת", "youCanAlso": "אפשר גם", "and": "וגם", "failedToOpenUrl": "פתיחת הכתובת נכשלה: {}", "blockActions": { "addBelowTooltip": "יש ללחוץ כדי להוסיף להלן", "addAboveCmd": "Alt+לחיצה", "addAboveMacCmd": "Option+לחיצה", "addAboveTooltip": "כדי להוסיף מעל", "dragTooltip": "יש לגרור כדי להזיז", "openMenuTooltip": "יש ללחוץ כדי לפתוח תפריט" }, "signUp": { "buttonText": "הרשמה", "title": "הרשמה ל־@:appName", "getStartedText": "מאיפה מתחילים", "emptyPasswordError": "הסיסמה לא יכולה להיות ריקה", "repeatPasswordEmptyError": "הסיסמה החוזרת לא יכולה להיות ריקה", "unmatchedPasswordError": "הסיסמה החוזרת לא זהה לסיסמה", "alreadyHaveAnAccount": "כבר יש לך חשבון?", "emailHint": "דוא״ל", "passwordHint": "סיסמה", "repeatPasswordHint": "סיסמה חוזרת", "signUpWith": "הרשמה עם:" }, "signIn": { "loginTitle": "כניסה אל @:appName", "loginButtonText": "כניסה", "loginStartWithAnonymous": "התחלה אלמונית", "continueAnonymousUser": "המשך התהליך בצורה אלמונית", "buttonText": "כניסה", "signingInText": "מתבצעת כניסה…", "forgotPassword": "שכחת סיסמה?", "emailHint": "דוא״ל", "passwordHint": "סיסמה", "dontHaveAnAccount": "אין לך חשבון?", "createAccount": "ליצור חשבון", "repeatPasswordEmptyError": "הסיסמה החוזרת לא יכולה להיות ריקה", "unmatchedPasswordError": "הסיסמה החוזרת לא זהה לסיסמה", "syncPromptMessage": "סנכרון הנתונים עלול לארוך זמן מה. נא לא לסגור את העמוד הזה", "or": "או", "signInWithGoogle": "להיכנס עם Google", "signInWithGithub": "להיכנס עם Github", "signInWithDiscord": "להיכנס עם Discord", "signUpWithGoogle": "להירשם עם Google", "signUpWithGithub": "להירשם עם Github", "signUpWithDiscord": "להירשם עם Discord", "signInWith": "להיכנס עם:", "signInWithEmail": "להיכנס עם דוא״ל", "signInWithMagicLink": "כניסה עם קישור קסם", "signUpWithMagicLink": "הרשמה עם קישור קסם", "pleaseInputYourEmail": "נא למלא את כתובת הדוא״ל שלך", "settings": "הגדרות", "magicLinkSent": "קישור קסם נשלח!", "invalidEmail": "נא למלא כתובת דוא״ל תקפה", "alreadyHaveAnAccount": "כבר יש לך חשבון?", "logIn": "להיכנס", "generalError": "משהו השתבש. נא לנסות שוב מאוחר יותר", "limitRateError": "מטעמי אבטחת מידע, אפשר לבקש קישור קסם כל 60 שניות", "magicLinkSentDescription": "קישור קסם נשלח לדוא״ל שלך. יש ללחוץ על הקישור כדי להשלים את הכניסה שלך למערכת.הקישור יפוג תוך 5 דקות." }, "workspace": { "chooseWorkspace": "נא לבחור את מרחב העבודה שלך", "create": "יצירת מרחב עבודה", "reset": "איפוס מרחב עבודה", "renameWorkspace": "שינוי שם מרחב עבודה", "resetWorkspacePrompt": "איפוס מרחב העבודה ימחק את כל הדפים והנתונים שבו. לאפס את מרחב העבודה? לחלופין, אפשר ליצור קשר עם צוות התמיכה לשחזור מרחב העבודה", "hint": "מרחב עבודה", "notFoundError": "לא נמצא מרחב עבודה", "failedToLoad": "משהו השתבש! טעינת מרחב העבודה נכשלה. כדאי לנסות לסגור את כל העותקים הפתוחים של @:appName ולנסות שוב.", "errorActions": { "reportIssue": "דיווח על תקלה", "reportIssueOnGithub": "דיווח על תקלה דרך GitHub", "exportLogFiles": "ייצוא קובצי יומנים", "reachOut": "פנייה אלינו דרך Discord" }, "menuTitle": "מרחבי עבודה", "deleteWorkspaceHintText": "למחוק את מרחב העבודה? הפעולה הזאת היא בלתי הפיכה, ועמודים שפרסמת יוסתרו.", "createSuccess": "מרחב העבודה נוצר בהצלחה", "createFailed": "יצירת מרחב העבודה נכשלה", "createLimitExceeded": "הגעת לכמות מרחבי העבודה המרבית המותרת לחשבון שלך. כדי להוסיף מרחבי עבודה נוספים ולהמשיך בעבודה שלך, נא לבקש ב־GitHub", "deleteSuccess": "מרחב העבודה נמחק בהצלחה", "deleteFailed": "מחיקת מרחב העבודה נכשלה", "openSuccess": "פתיחת מרחב העבודה הצליחה", "openFailed": "פתיחת מרחב העבודה נכשלה", "renameSuccess": "שם מרחב העבודה השתנה בהצלחה", "renameFailed": "שינוי שם מרחב העבודה נכשל", "updateIconSuccess": "סמל מרחב העבודה עודכן בהצלחה", "updateIconFailed": "עדכון סמל מרחב העבודה נכשל", "cannotDeleteTheOnlyWorkspace": "לא ניתן למחוק את מרחב העבודה היחיד", "fetchWorkspacesFailed": "משיכת מרחבי העבודה נכשלה", "leaveCurrentWorkspace": "יציאה ממרחב העבודה", "leaveCurrentWorkspacePrompt": "לעזוב את סביבת העבודה הנוכחית?" }, "shareAction": { "buttonText": "שיתוף", "workInProgress": "בקרוב", "markdown": "Markdown", "html": "HTML", "clipboard": "העתקה ללוח הגזירים", "csv": "CSV", "copyLink": "העתקת קישור", "publishToTheWeb": "פרסום לאינטרנט", "publishToTheWebHint": "יצירת אתר עם", "publish": "פרסום", "unPublish": "הסתרה", "visitSite": "ביקור באתר", "exportAsTab": "ייצוא בתור", "publishTab": "פרסום", "shareTab": "שיתוף" }, "moreAction": { "small": "קטן", "medium": "בינוני", "large": "גדול", "fontSize": "גודל גופן", "import": "ייבוא", "moreOptions": "אפשרויות נוספות", "wordCount": "כמות מילים: {}", "charCount": "כמות תווים: {}", "createdAt": "מועד יצירה: {}", "deleteView": "מחיקה", "duplicateView": "שכפול" }, "importPanel": { "textAndMarkdown": "טקסט ו־Markdown", "documentFromV010": "מסמך מגרסה 0.1.0", "databaseFromV010": "מסד נתונים מגרסה 0.1.0", "csv": "CSV", "database": "מסד נתונים" }, "disclosureAction": { "rename": "שינוי שם", "delete": "מחיקה", "duplicate": "שכפול", "unfavorite": "הסרה מהמועדפים", "favorite": "הוספה למועדפים", "openNewTab": "פתיחה בלשונית חדשה", "moveTo": "העברה אל", "addToFavorites": "הוספה למועדפים", "copyLink": "העתקת קישור", "changeIcon": "החלפת סמל", "collapseAllPages": "צמצום כל תת־העמודים" }, "blankPageTitle": "עמוד ריק", "newPageText": "עמוד חדש", "newDocumentText": "מסמך חדש", "newGridText": "טבלה חדשה", "newCalendarText": "לוח שנה חדש", "newBoardText": "לוח חדש", "chat": { "newChat": "שיחה עם בינה מלאכותית", "inputMessageHint": "שליחת הודעה לבינה המלאכותית של @:appName", "unsupportedCloudPrompt": "היכולת הזאת זמינה רק עם @:appName בענן", "relatedQuestion": "קשורים", "serverUnavailable": "השירות אינו זמין באופן זמני. נא לנסות שוב מאוחר יותר.", "aiServerUnavailable": "🌈 אוי לא! 🌈. חד־קרן אכל לנו את התגובה. נא לנסות שוב!", "clickToRetry": "נא ללחוץ לניסיון חוזר", "regenerateAnswer": "יצירה מחדש", "question1": "איך להשתמש בקנבן לניהול משימות", "question2": "הסבר על שיטת החתימה למטרה", "question3": "למה להשתמש ב־Rust", "question4": "מתכון ממה שיש לי במטבח", "aiMistakePrompt": "בינה מלאכותית יכולה לטעות. כדאי לאשש את המידע." }, "trash": { "text": "אשפה", "restoreAll": "שחזור של הכול", "deleteAll": "מחיקה של הכול", "pageHeader": { "fileName": "שם קובץ", "lastModified": "שינוי אחרון", "created": "נוצר" }, "confirmDeleteAll": { "title": "למחוק את כל העמודים שבאשפה?", "caption": "זאת פעולה בלתי הפיכה." }, "confirmRestoreAll": { "title": "לשחזר את כל העמודים מהאשפה?", "caption": "זאת פעולה בלתי הפיכה." }, "mobile": { "actions": "פעולות אשפה", "empty": "סל האשפה ריק", "emptyDescription": "אין לך קבצים שנמחקו", "isDeleted": "נמחק", "isRestored": "משוחזר" }, "confirmDeleteTitle": "למחוק את העמוד הזה לצמיתות?" }, "deletePagePrompt": { "text": "העמוד הזה הוא באשפה", "restore": "שחזור עמוד", "deletePermanent": "למחוק לצמיתות" }, "dialogCreatePageNameHint": "שם העמוד", "questionBubble": { "shortcuts": "מקשי קיצור", "whatsNew": "מה חדש?", "markdown": "Markdown", "debug": { "name": "פרטי ניפוי שגיאות", "success": "פרטי ניפוי השגיאות הועתקו ללוח הגזירים!", "fail": "לא ניתן להעתיק את פרטי ניפוי השגיאות ללוח הגזירים" }, "feedback": "משוב", "help": "עזרה ותמיכה" }, "menuAppHeader": { "moreButtonToolTip": "הסרה, שינוי שם ועוד…", "addPageTooltip": "הוספת עמוד בפנים במהירות", "defaultNewPageName": "ללא שם", "renameDialog": "שינוי שם" }, "noPagesInside": "אין עמודים בפנים", "toolbar": { "undo": "הסגה", "redo": "ביצוע מחדש", "bold": "מודגש", "italic": "נטוי", "underline": "קו תחתי", "strike": "קו חוצה", "numList": "רשימה ממוספרת", "bulletList": "רשימת תבליטים", "checkList": "רשימת סימונים", "inlineCode": "קוד מוטבע", "quote": "מקטע ציטוט", "header": "כותרת עליונה", "highlight": "הדגשה", "color": "צבע", "addLink": "הוספת קישור", "link": "קישור" }, "tooltip": { "lightMode": "מעבר למצב בהיר", "darkMode": "מעבר למצב כהה", "openAsPage": "פתיחה כעמוד", "addNewRow": "הוספת שורה חדשה", "openMenu": "לחיצה תפתח את התפריט", "dragRow": "לחיצה ארוכה תסדר את השורה מחדש", "viewDataBase": "הצגת מסד הנתונים", "referencePage": "זאת הפנייה אל {name}", "addBlockBelow": "הוספת מקטע למטה", "aiGenerate": "יצירה" }, "sideBar": { "closeSidebar": "סגירת סרגל צד", "openSidebar": "פתיחת סרגל צד", "personal": "אישי", "private": "פרטי", "workspace": "מרחב עבודה", "favorites": "מועדפים", "clickToHidePrivate": "לחיצה תסתיר את המרחב הפרטי\nעמודים שיצרת כאן הם לעיניך בלבד", "clickToHideWorkspace": "לחיצה תסתיר את מרחב העבודה\nעמודים שיצרת כאן יהיו גלויים בפני כל החברים", "clickToHidePersonal": "לחיצה תסתיר את המרחב האישי", "clickToHideFavorites": "לחיצה תסתיר מרחב מועדף", "addAPage": "הוספת עמוד חדש", "addAPageToPrivate": "הוספת עמוד למרחב פרטי", "addAPageToWorkspace": "הוספת עמוד למרחב עבודה פרטי", "recent": "אחרונים", "today": "היום", "thisWeek": "השבוע", "others": "מועדפים אחרים", "justNow": "כרגע", "minutesAgo": "לפני {count} דקות", "lastViewed": "צפייה אחרונה", "favoriteAt": "הוספה למועדפים ב־", "emptyRecent": "אין מסמכים אחרונים", "emptyRecentDescription": "בעת צפייה במסמכים הם יופיעו כאן לאיתור פשוט יותר", "emptyFavorite": "אין מסמכים מועדפים", "emptyFavoriteDescription": "אפשר להתחיל לעיין ולסמן מסמכים כמועדפים. הם יופיעו כאן כדי להקל על הגישה אליהם!", "removePageFromRecent": "להסיר את העמוד הזה מהאחרונים?", "removeSuccess": "הוסר בהצלחה", "favoriteSpace": "מועדפים", "RecentSpace": "אחרונים", "Spaces": "מרחבים" }, "notifications": { "export": { "markdown": "פתקית יוצאה ל־Markdown", "path": "מסמכיםflowy" } }, "contactsPage": { "title": "אנשי קשר", "whatsHappening": "מה קורה השבוע?", "addContact": "הוספת איש קשר", "editContact": "עריכת איש קשר" }, "button": { "ok": "אישור", "confirm": "אישור", "done": "בוצע", "cancel": "ביטול", "signIn": "כניסה", "signOut": "יציאה", "complete": "השלמה", "save": "שמירה", "generate": "יצירה", "esc": "ESC", "keep": "להשאיר", "tryAgain": "לנסות שוב", "discard": "התעלמות", "replace": "החלפה", "insertBelow": "הוספה מתחת", "insertAbove": "הוספה מעל", "upload": "העלאה", "edit": "עריכה", "delete": "מחיקה", "duplicate": "שכפול", "putback": "החזרה למקום", "update": "עדכון", "share": "שיתוף", "removeFromFavorites": "הסרה מהמועדפים", "removeFromRecent": "הסרה מהאחרונים", "addToFavorites": "הוספה למועדפים", "rename": "שינוי שם", "helpCenter": "מרכז העזרה", "add": "הוספה", "yes": "כן", "clear": "פינוי", "remove": "להסיר", "dontRemove": "לא להסיר", "copyLink": "העתקת קישור", "align": "יישור", "login": "כניסה", "logout": "יציאה", "deleteAccount": "מחיקת חשבון", "back": "חזרה", "signInGoogle": "המשך עם Google", "signInGithub": "המשך עם GitHub", "signInDiscord": "המשך עם Discord", "more": "עוד", "create": "יצירה", "close": "סגירה" }, "label": { "welcome": "ברוך בואך!", "firstName": "שם פרטי", "middleName": "שם אמצעי", "lastName": "שם משפחה", "stepX": "שלב {X}" }, "oAuth": { "err": { "failedTitle": "לא ניתן להתחבר לחשבון שלך.", "failedMsg": "נא לוודא שהשלמת את תהליך הכניסה בדפדפן שלך." }, "google": { "title": "כניסה עם GOOGLE", "instruction1": "כדי לייבא את אנשי הקשר שלך מ־Google, צריך לאמת את היישום הזה בעזרת הדפדפן שלך.", "instruction2": "יש להעתיק את הקוד ללוח הגזירים שלך בלחיצה על הסמל או על ידי בחירת הטקסט:", "instruction3": "יש לנווט לקישור הבא בדפדפן שלך ולמלא את הקוד הבא:", "instruction4": "יש ללחוץ על הכפתור שלהלן לאחר השלמת ההרשמה:" } }, "settings": { "title": "הגדרות", "accountPage": { "menuLabel": "החשבון שלי", "title": "החשבון שלי", "general": { "title": "שם חשבון ותמונת פרופיל", "changeProfilePicture": "החלפת תמונת פרופיל" }, "email": { "title": "דוא״ל", "actions": { "change": "החלפת כתובת דוא״ל" } }, "login": { "title": "כניסה לחשבון", "loginLabel": "כניסה", "logoutLabel": "יציאה" } }, "workspacePage": { "menuLabel": "מרחב עבודה", "title": "מרחב עבודה", "description": "התאמת מראה, ערכת העיצוב, הגופן, תבנית התאריך והשעה והשפה של מרחב העבודה שלך.", "workspaceName": { "title": "שם מרחב העבודה" }, "workspaceIcon": { "title": "סמל מרחב העבודה", "description": "אפשר להעלות תמונה או להשתמש באמוג׳י למרחב העבודה שלך. הסמל יופיע בסרגל הצד ובהתראות שלך." }, "appearance": { "title": "מראה", "description": "התאמת המראה, ערכת העיצוב, גופן, פריסת הטקסט, התאריך, השעה והשפה של מרחב העבודה שלך.", "options": { "system": "אוטו׳", "light": "בהיר", "dark": "כהה" } }, "theme": { "title": "ערכת עיצוב", "description": "נא לבחור ערכת עיצוב מוגדרת מראש או להעלות ערכת עיצוב משופרת משלך.", "uploadCustomThemeTooltip": "העלאת ערכת עיצוב משופרת" }, "workspaceFont": { "title": "גופן מרחב עבודה", "noFontHint": "הגופן לא נמצא, נא לנסות ביטוי אחר." }, "textDirection": { "title": "כיוון טקסט", "leftToRight": "משמאל לימין", "rightToLeft": "מימין לשמאל", "auto": "אוטו׳", "enableRTLItems": "הפעלת פריטי סרגל כלים לכתיבה מימין לשמאל" }, "layoutDirection": { "title": "כיוון פריסה", "leftToRight": "משמאל לימין", "rightToLeft": "מימין לשמאל" }, "dateTime": { "title": "תאריך ושעה", "example": "{} ב־{} ({})", "24HourTime": "שעון 24 שעות", "dateFormat": { "label": "תבנית תאריך", "local": "מקומית", "us": "אמריקאית", "iso": "ISO", "friendly": "ידידותית", "dmy": "D/M/Y" } }, "language": { "title": "שפה" }, "deleteWorkspacePrompt": { "title": "מחיקת מרחב עבודה", "content": "למחוק את מרחב העבודה הזה? זאת פעולה בלתי הפיכה וכל הדפים שפרסמת יוסתרו." }, "leaveWorkspacePrompt": { "title": "עזיבת מרחב העבודה", "content": "לעזוב את מרחב העבודה הזה? הגישה שלך לכל העמודים והנתונים שבתוכו תלך לאיבוד." }, "manageWorkspace": { "title": "ניהול מרחב עבודה", "leaveWorkspace": "עזיבת מרחב עבודה", "deleteWorkspace": "מחיקת מרחב עבודה" } }, "manageDataPage": { "menuLabel": "ניהול נתונים", "title": "ניהול נתונים", "description": "ניהול אחסון הנתונים מקומית או ייבוא הנתונים הקיימים שלך אל @:appName.", "dataStorage": { "title": "מקום אחסון קבצים", "tooltip": "המקום בו יאוחסנו הקבצים שלך", "actions": { "change": "החלפת נתיב", "open": "פתיחת תיקייה", "openTooltip": "פתיחת מקום תיקיית הנתונים הנוכחית", "copy": "העתקת נתיב", "copiedHint": "הנתיב הועתק!", "resetTooltip": "איפוס למקום ברירת המחדל" }, "resetDialog": { "title": "להמשיך?", "description": "איפוס הנתיב למקום ברירת המחדל לאחסון הנתונים שלך. כדי לייבא את הנתונים הנוכחיים שלך מחדש, יש להעתיק את נתיב המקום הנוכחי שלך תחילה." } }, "importData": { "title": "ייבוא נתונים", "tooltip": "ייבוא נתונים מתיקיות גיבויים/נתונים של @:appName", "description": "העתקת נתונים מתיקיית נתונים חיצונית של @:appName", "action": "עיון בקובץ" }, "encryption": { "title": "הצפנה", "tooltip": "ניהול אופן האחסון וההצפנה של הנתוים שלך", "descriptionNoEncryption": "הפעלת הצפנה תצפין את כל הנתונים. זה תהליך בלתי הפיך.", "descriptionEncrypted": "הנתונים שלך מוצפנים.", "action": "הצפנת נתונים", "dialog": { "title": "להצפין את כל הנתונים שלך?", "description": "להצפין את כל הנתונים שלך ישמור עליהם בבטחה ובצורה מאובטחת. זאת פעולה בלתי הפיכה. להמשיך?" } }, "cache": { "title": "ביטול זיכרון המטמון", "description": "פינוי זיכרון המטמון של היישום, הפינוי יכול לסייע בפתרון תקלות כמו תמונות או גופנים שלא נטענים. אין לזה השפעה על הנתונים שלך.", "dialog": { "title": "להמשיך?", "description": "פינוי זיכרון המטמון יגרום להורדת כל התמונות מחדש עם הטעינה. הפעולה הזאת לא תסיר או תשנה את הנתונים שלך.", "successHint": "זיכרון המטמון התפנה!" } }, "data": { "fixYourData": "תיקון הנתונים שלך", "fixButton": "תיקון", "fixYourDataDescription": "אם נתקלת בבעיות עם הנתונים שלך, אפשר לנסות לתקן אותן כאן." } }, "shortcutsPage": { "menuLabel": "מקשי קיצור", "title": "מקשי קיצור", "editBindingHint": "נא להקליד צירוף חדש", "searchHint": "חיפוש", "actions": { "resetDefault": "איפוס ברירת מחדל" }, "errorPage": { "message": "טעינת מקשי הקיצור נכשלה: {}", "howToFix": "נא לנסות שוב, אם הבעיה נמשכת נא לפנות אלינו דרך GitHub." }, "resetDialog": { "title": "איפוס מקשי קיצור", "description": "הפעולה הזאת תאפס את כל צירופי המקשים שלך לברירת המחדל, היא בלתי הפיכה, להמשיך?", "buttonLabel": "איפוס" }, "conflictDialog": { "title": "{} נמצא בשימוש כרגע", "descriptionPrefix": "צירוף המקשים הזה כבר משמש לטובת ", "descriptionSuffix": ". החלפת צירוף המקשים הזה יסיר אותו מהשליטה ב־{}.", "confirmLabel": "המשך" }, "editTooltip": "יש ללחוץ כדי להתחיל לערוך את צירוף המקשים", "keybindings": { "toggleToDoList": "הצגת/הסתרת רשימת מטלות", "insertNewParagraphInCodeblock": "הוספת פסקה חדשה", "pasteInCodeblock": "הדבקה במקטע קוד", "selectAllCodeblock": "בחירה בהכול", "indentLineCodeblock": "הוספת שני רווחים בתחילת השורה", "outdentLineCodeblock": "מחיקת שני רווחים בתחילת השורה", "twoSpacesCursorCodeblock": "הוספת שני רווחים איפה שהסמן", "copy": "העתקת בחירה", "paste": "הדבקה בתוכן", "cut": "גזירת הבחירה", "alignLeft": "יישור הטקסט לשמאל", "alignCenter": "מירכוז הטקסט", "alignRight": "יישור הטקסט לימין", "undo": "הסגה", "redo": "ביצוע מחדש", "convertToParagraph": "המרת מקטע לפסקה", "backspace": "מחיקה", "deleteLeftWord": "מחיקת המילה משמאל", "deleteLeftSentence": "מחיקת המשפט משמאל", "delete": "מחיקת התו מימין", "deleteMacOS": "מחיקת השתו משמאל", "deleteRightWord": "מחיקת המילה מימין", "moveCursorLeft": "הזזת הסמל שמאלה", "moveCursorBeginning": "הזזת הסמן להתחלה", "moveCursorLeftWord": "הזזת הסמן מילה שמאלה", "moveCursorLeftSelect": "בחירה והזזת הסמן שמאלה", "moveCursorBeginSelect": "בחירה והזזת הסמן להתחלה", "moveCursorLeftWordSelect": "בחירה והזזת הסמן שמאלה במילה", "moveCursorRight": "הזזת הסמן ימינה", "moveCursorEnd": "הזזת הסמן לסוף", "moveCursorRightWord": "הזזת הסמן ימינה במילה", "moveCursorRightSelect": "בחירה והזזת הסמן אחד ימינה", "moveCursorEndSelect": "בחירה והזזת הסמן לסוף", "moveCursorRightWordSelect": "בחירה והזזת הסמן ימינה במילה", "moveCursorUp": "הזזת הסמן למעלה", "moveCursorTopSelect": "בחירה והזזת הסמן לראש", "moveCursorTop": "הזזת הסמן לראש", "moveCursorUpSelect": "בחירה והזזת הסמן למעלה", "moveCursorBottomSelect": "בחירה והזזת הסמן לתחתית", "moveCursorBottom": "הזזת הסמן לתחתית", "moveCursorDown": "הזזת הסמן למטה", "moveCursorDownSelect": "בחירה והזזת הסמן למטה", "home": "גלילה למעלה", "end": "גלילה לתחתית", "toggleBold": "החלפת מצב מודגש", "toggleItalic": "החלפת מצב הטייה", "toggleUnderline": "החלפת מצב קו תחתי", "toggleStrikethrough": "החלפת קו חוצה", "toggleCode": "החלפת קוד כחלק מהשורה", "toggleHighlight": "החלפת מצב הדגשה", "showLinkMenu": "הצגת תפריט קישורים", "openInlineLink": "פתיחת קישור מובנה", "openLinks": "פתיחת כל הקישורים הנבחרים", "indent": "הגדלת הזחה", "outdent": "הקטנת הזחה", "exit": "יציאה מעריכה", "pageUp": "גלילה עמוד למעלה", "pageDown": "גלילה עמוד למטה", "selectAll": "בחירה בהכול", "pasteWithoutFormatting": "הדבקת תוכן בלי עיצוב", "showEmojiPicker": "הצגת בורר אמוג׳י", "enterInTableCell": "הוספת מעבר שורה בטבלה", "leftInTableCell": "מעבר תא שמאלה בטבלה", "rightInTableCell": "מעבר תא ימינה בטבלה", "upInTableCell": "מעבר תא למעלה בטבלה", "downInTableCell": "מעבר תא למטה בטבלה", "tabInTableCell": "מעבר לתא הזמין הבא בטבלה", "shiftTabInTableCell": "מעבר לתא הזמין הקודם בטבלה", "backSpaceInTableCell": "עצירה בתחילת התא" }, "commands": { "codeBlockNewParagraph": "הוספת פסקה חדשה ליד מקטע הקוד", "codeBlockIndentLines": "הוספת שני רווחים בתחילת השורה במקטע קוד", "codeBlockOutdentLines": "מחיקת שני רווחים מתחילת השורה במקטע קוד", "codeBlockAddTwoSpaces": "הוספת שני רווחים במקום של הסמן במקטע קוד", "codeBlockSelectAll": "בחירת כל התוכן בתוך מקטע קוד", "codeBlockPasteText": "הדבקת טקסט במקטע קוד", "textAlignLeft": "יישור טקסט לשמאל", "textAlignCenter": "מירכוז טקסט", "textAlignRight": "יישור טקסט לימין" }, "couldNotLoadErrorMsg": "לא ניתן לטעון את קיצורי המקשים, נא לנסות שוב", "couldNotSaveErrorMsg": "לא ניתן לשמור את קיצורי המקשים, נא לנסות שוב" }, "aiPage": { "title": "הגדרת בינה מלאכותית", "menuLabel": "הגדרות בינה מלאכותית", "keys": { "enableAISearchTitle": "חיפוש בינה מלאכותית", "aiSettingsDescription": "בחירת או הגדרת מודלים של בינה מלאכותית לשימוש עם @:appName. לביצועים מיטביים אנו ממליצים להשתמש באפשרויות ברירת המחדל של המודל", "loginToEnableAIFeature": "יכולות בינה מלאכותית מופעלות רק לאחר כניסה עם @:appName בענן. אם אין לך חשבון של @:appName, יש לגשת אל ‚החשבון שלי’ כדי להירשם", "llmModel": "מודל שפה", "llmModelType": "סוג מודל שפה", "downloadLLMPrompt": "הורדת {}", "downloadLLMPromptDetail": "הורדת המודל המקומי {} תתפוס {} מהאחסון. להמשיך?", "downloadAIModelButton": "הורדת מודל בינה מלאכותית", "downloadingModel": "מתבצעת הורדה", "localAILoaded": "מודל הבינה המלאכותית המקומי נוסף והוא מוכן לשימוש", "localAILoading": "מודל הבינה המלאכותית המקומי נטען…", "localAIStopped": "מודל הבינה המלאכותית המקומי נעצר", "title": "מפתחות API לבינה מלאכותית", "openAILabel": "מפתח API ל־OpenAI", "openAITooltip": "אפשר למצוא את מפתח ה־API הסודי שלך בעמוד מפתח ה־API", "openAIHint": "מילוי מפתח ה־API שלך ב־OpenAI", "stabilityAILabel": "מפתח API של Stability", "stabilityAITooltip": "מפתח ה־API שלך ב־Stability, משמש לאימות הבקשות שלך", "stabilityAIHint": "נא למלא את מפתח ה־API שלך ב־Stability" } }, "planPage": { "menuLabel": "תוכנית", "title": "תוכנית חיוב", "planUsage": { "title": "תקציר שימוש בתוכנית", "storageLabel": "אחסון", "storageUsage": "{} מתוך {} ג״ב", "collaboratorsLabel": "שותפים", "collaboratorsUsage": "{} מתוך {}", "aiResponseLabel": "תגובות בינה מלאכותית", "aiResponseUsage": "{} מתוך {}", "proBadge": "פרו", "memberProToggle": "אין הגבלה על כמות חברים", "aiCredit": { "title": "הוספת קרדיט בינה מלאכותית ל־@:appName", "price": "{}", "priceDescription": "ל־1,000 קרדיטים", "purchase": "רכישת בינה מלאכותית", "info": "הוספת 1,000 קרדיטים של בינה מלאכותית לכל סביבת עבודה ושילוב בינה מלאכותית מותאמת למרחב העבודה שלך לתוצאות חכמות ומהירות יותר עם עד:", "infoItemOne": "10,000 תגובות למסד נתונים", "infoItemTwo": "1,000 תגובות לסביבת עבודה" }, "currentPlan": { "bannerLabel": "תוכנית נוכחית", "freeTitle": "חינם", "proTitle": "פרו", "teamTitle": "צוות", "freeInfo": "מעולה לעצמאיים או צוותים קטנים של עד 2 חברים.", "proInfo": "מעולה לצוות קטנים ובינוניים עד 10 חברים.", "teamInfo": "מעולה לכל סוג של צוות משימתי שמאורגן היטב.", "upgrade": "השוואה\n ושדרוג", "canceledInfo": "התוכנית שלך בוטלה, יבוצע שנמוך לתוכנית החופשית ב־{}.", "freeProOne": "מרחב עבודה שיתופי", "freeProTwo": "עד 2 חברים (כולל הבעלים)", "freeProThree": "כמות אורחים בלתי מוגבלת (צפייה בלבד)", "freeProFour": "אחסון של 5 ג״ב", "freeProFive": "30 ימי היסטוריית מהדורות", "freeConOne": "משתתפי אורח (גישת עריכה)", "freeConTwo": "ללא הגבלת אחסון", "freeConThree": "6 חודשי היסטוריית מהדורות", "professionalProOne": "מרחב עבודה שיתופי", "professionalProTwo": "כמות חברים בלתי מוגבלת", "professionalProThree": "כמות אורחים בלתי מוגבלת (צפייה בלבד)", "professionalProFour": "אחסון ללא הגבלה", "professionalProFive": "6 חודשי היסטוריית מהדורות", "professionalConOne": "משתתפי אורח ללא הגבלה (גישת עריכה)", "professionalConTwo": "תגובות מבינה מלאכותית ללא הגבלה", "professionalConThree": "שנה של היסטוריית מהדורות" }, "deal": { "bannerLabel": "מבצע לשנה החדשה!", "title": "הגדלת הצוות שלך!", "info": "שדרוג כעת יזכה אותך ב־10% הנחה מתוכניות פרו ולצוותים! אפשר לחזק את יעילות סביבת העבודה שלך עם יכולות חדשות ורבות עוצמה כולל הבינה המלאכותית של @:appName.", "viewPlans": "הצגת תוכניות" }, "guestCollabToggle": "10 משתתפי אורח", "storageUnlimited": "אחסון ללא הגבלה עם תוכנית הפרו שלך" } }, "billingPage": { "menuLabel": "חיוב", "title": "חיוב", "plan": { "title": "תוכנית", "freeLabel": "חינם", "proLabel": "פרו", "planButtonLabel": "החלפת תוכנית", "billingPeriod": "תקופת חיוב", "periodButtonLabel": "עריכת תקופה" }, "paymentDetails": { "title": "פרטי תשלום", "methodLabel": "שיטת תשלום", "methodButtonLabel": "עריכת שיטה" } }, "comparePlanDialog": { "title": "השוואה ובחירת תוכנית", "planFeatures": "יכולות\nהתוכנית", "current": "נוכחית", "actions": { "upgrade": "שדרוג", "downgrade": "שנמוך", "current": "נוכחית", "downgradeDisabledTooltip": "השנמוך יתבצע בסוף מחזור החיוב" }, "freePlan": { "title": "חינם", "description": "לארגון כל פינה בעבודה ובחיים שלך.", "price": "{}", "priceInfo": "חינם לנצח" }, "proPlan": { "title": "מקצועי", "description": "מקום קטן לתכנות והתארגנות של קבוצות קטנות.", "price": "{} לחודש", "priceInfo": "בחיוב שנתי" }, "planLabels": { "itemOne": "מרחבי עבודה", "itemTwo": "חברים", "itemThree": "אורחים", "itemFour": "משתתפי אורח", "itemFive": "אחסון", "itemSix": "שיתוף בזמן אמת", "itemSeven": "יישומון לניידים", "tooltipThree": "לאורחים יש הרשאות לקריאה בלבד לתוכן המסוים ששותף", "tooltipFour": "משתתפי אורח מחויבים כמושב אחד", "itemEight": "תגובות בינה מלאכותית", "tooltipEight": "הכוונה בביטוי „לכל החיים” כלומר שמספר התגובות לעולם לא יתאפס" }, "freeLabels": { "itemOne": "חיוב לפני מרחבי עבודה", "itemTwo": "3", "itemFour": "0", "itemFive": "5 ג״ב", "itemSix": "כן", "itemSeven": "כן", "itemEight": "1,000 לכל החיים" }, "proLabels": { "itemOne": "מחויב לפי מרחב עבודה", "itemTwo": "עד 10", "itemFour": "10 אורחים חויבו כמושב אחד", "itemFive": "ללא הגבלה", "itemSix": "כן", "itemSeven": "כן", "itemEight": "10,000 בחודש" }, "paymentSuccess": { "title": "עברת לתוכנית {}!", "description": "התשלום שלך עבר עיבוד והתוכנית שלך שודרגה ל־@:appName {}. אפשר לצפות בפרטי התוכנית שלך בעמוד התוכנית" }, "downgradeDialog": { "title": "לשנמך את התוכנית שלך?", "description": "שנמוך התוכנית שלך יחזיר אותך לתוכנית החינמית. חברים עלולים לאבד גישה למרחבי העבודה ויהיה עליך לפנות מקום אחסון כדי לעמוד במגבלות האחסון של התוכנית החינמית.", "downgradeLabel": "שנמוך תוכנית" } }, "common": { "reset": "איפוס" }, "menu": { "appearance": "מראה", "language": "שפה", "user": "משתמש", "files": "קבצים", "notifications": "התראות", "open": "פתיחת ההגדרות", "logout": "יציאה", "logoutPrompt": "לצאת?", "selfEncryptionLogoutPrompt": "לצאת מהמערכת? נא לוודא שהעתקת את סוד ההצפנה", "syncSetting": "סנכרון הגדרות", "cloudSettings": "הגדרות ענן", "enableSync": "הפעלת סנכרון", "enableEncrypt": "הצפנת נתונים", "cloudURL": "כתובת בסיס", "invalidCloudURLScheme": "סכמה שגויה", "cloudServerType": "שרת ענן", "cloudServerTypeTip": "נא לשים לב שהפעולה הזאת עלולה להוציא אותך מהחשבון הנוכחי שלך לאחר מעבר לשרת הענן", "cloudLocal": "מקומי", "cloudAppFlowy": "@:appName בענן בטא", "cloudAppFlowySelfHost": "@:appName בענן באירוח עצמי", "appFlowyCloudUrlCanNotBeEmpty": "כתובת הענן לא יכולה להישאר ריקה", "clickToCopy": "לחיצה להעתקה", "selfHostStart": "אם אין לך שרת, נא לפנות אל", "selfHostContent": "המסמך", "selfHostEnd": "להנחיה בנוגע לאירוח בשרת משלך", "cloudURLHint": "נא למלא את כתובת הבסיס של השרת שלך", "cloudWSURL": "כתובת Websocket", "cloudWSURLHint": "נא למלא את כתובת השקע המקוון (websocket) של השרת שלך", "restartApp": "הפעלה מחדש", "restartAppTip": "יש להפעיל את היישום מחדש כדי להחיל את השינויים. נא לשים לב שיכול להיות שתתבצע יציאה מהחשבון הנוכחי שלך.", "changeServerTip": "לאחר החלפת השרת, יש ללחוץ על כפתור ההפעלה מחדש כדי שהשינויים ייכנסו לתוקף", "enableEncryptPrompt": "אפשר להפעיל הצפנה כדי להגן על הנתונים שלך עם הסוד הזה. יש לאחסן אותו בצורה בטוחה, לאחר שהופעלה, אי אפשר לכבות אותה. אם הסוד אבד, לא תהיה עוד גישה לנתונים שלך. לחיצה להעתקה", "inputEncryptPrompt": "נא למלא את סוד ההצפנה שלך עבור", "clickToCopySecret": "לחיצה להעתקת סוד", "configServerSetting": "הגדרת השרת שלך", "configServerGuide": "לאחר בחירה ב`התחלה מהירה`, יש לנווט אל `הגדרות` ואז ל„הגדרות ענן” כדי להגדיר שרת באירוח עצמי.", "inputTextFieldHint": "הסוד שלך", "historicalUserList": "היסטוריית כניסת משתמשים", "historicalUserListTooltip": "הרשימה הזאת מציגה את החשבונות האלמוניים שלך. אפשר ללחוץ על חשבון כדי לצפות בפרטים שלו. חשבונות אלמוניים נוצרים בלחיצה על הכפתור ‚איך מתחילים’", "openHistoricalUser": "נא ללחוץ כדי לפתוח את החשבון האלמוני", "customPathPrompt": "אחסון תיקיית הנתונים של @:appName בתיקייה שמסונכרנת עם הענן כגון Google Drive יכול להוות סיכון. אם למסד הנתונים בתיקייה הזאת ניגשים או עורכים ממגוון מקומות בו־זמנית, יכולות לקרות סתירות סנכרון והנתונים יכולים להינזק", "importAppFlowyData": "ייבוא נתונים מתיקיית @:appName חיצונית", "importingAppFlowyDataTip": "מתבצע ייבוא נתונים. נא לא לסגור את היישום", "importAppFlowyDataDescription": "העתקת נתונים מתיקיית נתונים חיצונית של @:appName ולייבא אותה לתיקיית הנתונים הנוכחית של AppFlowy", "importSuccess": "תיקיית הנתונים של @:appName יובאה בהצלחה", "importFailed": "ייבוא תיקיית הנתונים של @:appName נכשל", "importGuide": "לפרטים נוספים, נא לעיין במסמך המוזכר" }, "notifications": { "enableNotifications": { "label": "הפעלת התראות", "hint": "יש לכבות כדי לעצור הצגת התראות מקומיות." }, "showNotificationsIcon": { "label": "הצגת סמל התראות", "hint": "יש לכבות כדי להסתיר את סמל ההתראות בסרגל הצד." } }, "appearance": { "resetSetting": "איפוס", "fontFamily": { "label": "משפחת גופנים", "search": "חיפוש", "defaultFont": "מערכת" }, "themeMode": { "label": "מצב ערכת עיצוב", "light": "מצב בהיר", "dark": "מצב כהה", "system": "התאמה למערכת" }, "fontScaleFactor": "מקדם קנה מידה לגופנים", "documentSettings": { "cursorColor": "צבע סמן מסמך", "selectionColor": "צבע בחירת מסמך", "pickColor": "נא לבחור צבע", "colorShade": "צללית צבע", "opacity": "שקיפות", "hexEmptyError": "צבע הקס׳ לא יכול להיות ריק", "hexLengthError": "ערך הקס׳ חייב להיות באורך 6 תווים לפחות", "hexInvalidError": "ערך הקס׳ שגוי", "opacityEmptyError": "האטימות לא יכולה להיות ריקה", "opacityRangeError": "האטימות חייבת להיות בין 1 ל־100", "app": "יישום", "flowy": "Flowy", "apply": "החלה" }, "layoutDirection": { "label": "כיוון פריסה", "hint": "שליטה בזרימת התוכן על המסך שלך, משמאל לימין או מימין לשמאל.", "ltr": "משמאל לימין", "rtl": "מימין לשמאל" }, "textDirection": { "label": "כיוון ברירת המחדל של טקסט", "hint": "נא לציין האם טקסט אמור להתחיל משמאל או מימין כברירת מחדל.", "ltr": "משמאל לימין", "rtl": "מימין לשמאל", "auto": "אוטו׳", "fallback": "כמו כיוון הפריסה" }, "themeUpload": { "button": "העלאה", "uploadTheme": "העלאת ערכת עיצוב", "description": "אפשר להעלות ערכת עיצוב משלך ל־@:appName בעזרת הכפתור שלהלן.", "loading": "נא להמתין בזמן תיקוף והעלאת ערכת העיצוב שלך…", "uploadSuccess": "ערכת העיצוב שלך הועלתה בהצלחה", "deletionFailure": "מחיקת ערכת העיצוב נכשלה. נא לנסות למחוק אותה ידנית.", "filePickerDialogTitle": "נא לבחור קובץ ‎.flowy_plugin", "urlUploadFailure": "פתיחת הכתובת נכשלה: {}" }, "theme": "ערכת עיצוב", "builtInsLabel": "ערכות עיצוב מובנות", "pluginsLabel": "תוספים", "dateFormat": { "label": "תבנית תאריך", "local": "מקומית", "us": "אמריקאית", "iso": "ISO", "friendly": "ידידותית", "dmy": "D/M/Y" }, "timeFormat": { "label": "תבנית שעה", "twelveHour": "12 שעות", "twentyFourHour": "24 שעות" }, "showNamingDialogWhenCreatingPage": "הצגת חלונית מתן שם בעת יצירת עמוד", "enableRTLToolbarItems": "הפעלת פריטי ימין לשמאל בסרגל הכלים", "members": { "title": "הגדרות חברים", "inviteMembers": "הזמנת חברים", "inviteHint": "הזמנה בדוא״ל", "sendInvite": "שליחת הזמנה", "copyInviteLink": "העתקת קישור הזמנה", "label": "חברים", "user": "משתמש", "role": "תפקיד", "removeFromWorkspace": "הסרה ממרחב העבודה", "owner": "בעלים", "guest": "אורח", "member": "חבר", "memberHintText": "חברים יכולים לקרוא ולערוך עמודים", "guestHintText": "אורחים יכולים לקרוא, להגיב עם סמל, לכתוב הערות ולערוך עמודים מסוימים לפי ההרשאה.", "emailInvalidError": "כתובת דוא״ל שגויה, נא לבדוק ולנסות שוב", "emailSent": "ההודעה נשלחה בדוא״ל, נא לבדוק את תיבת הדוא״ל הנכנס", "members": "חברים", "membersCount": { "zero": "{} חברים", "one": "חבר", "other": "{} חברים" }, "memberLimitExceeded": "הגעת למגבלת החברים המרבית לחשבון שלך. כדי להוסיף חברים נוספים ולהמשיך בעבודתך, נא להגיש בקשה ב־GitHub", "failedToAddMember": "הוספת החבר נכשלה", "addMemberSuccess": "החבר נוסף בהצלחה", "removeMember": "הסרת חבר", "areYouSureToRemoveMember": "להסיר את החבר הזה?", "inviteMemberSuccess": "ההזמנה נשלחה בהצלחה", "failedToInviteMember": "הזמנת החבר נכשלה" } }, "files": { "copy": "העתקה", "defaultLocation": "קריאת הקבצים ומקום אחסון הנתונים", "exportData": "ייצוא הנתונים שלך", "doubleTapToCopy": "הנתיב יועתק בלחיצה כפולה", "restoreLocation": "שחזור לנתיב ברירת המחדל של @:appName", "customizeLocation": "פתיחת תיקייה אחרת", "restartApp": "נא להפעיל את היישום מחדש כדי שהשינויים ייכנסו לתוקף.", "exportDatabase": "ייצוא מסד נתונים", "selectFiles": "נא לבחור את הקבצים לייצור", "selectAll": "בחירה בהכול", "deselectAll": "ביטול בחירה", "createNewFolder": "יצירת תיקייה חדשה", "createNewFolderDesc": "נא הגדיר לנו איפה לאחסן את הנתונים שלך", "defineWhereYourDataIsStored": "הגדרת מקום אחסון הנתונים שלך", "open": "פתיחה", "openFolder": "פתיחת תיקייה קיימת", "openFolderDesc": "קריאה וכתיבה אל תיקיית ה־@:appName הקיימת שלך", "folderHintText": "שם התיקייה", "location": "יצירת תיקייה חדשה", "locationDesc": "נא לבחור שם לתיקיית הנתונים של @:appName", "browser": "עיון", "create": "יצירה", "set": "הגדרה", "folderPath": "נתיב לאחסון התיקייה שלך", "locationCannotBeEmpty": "הנתיב לא יכול להיות ריק", "pathCopiedSnackbar": "נתיב אחסון הקבצים הועתק ללוח הגזירים!", "changeLocationTooltips": "החלפת תיקיית הנתונים", "change": "שינוי", "openLocationTooltips": "פתיחת תיקיית נתונים אחרת", "openCurrentDataFolder": "פתיחת תיקיית הנתונים הנוכחית", "recoverLocationTooltips": "איפוס לתיקיית הנתונים כברירת המחדל של @:appName", "exportFileSuccess": "ייצוא הקובץ הצליח!", "exportFileFail": "ייצוא הקובץ נכשל!", "export": "ייצוא", "clearCache": "פינוי זיכרון המטמון", "clearCacheDesc": "אם נתקלת בבעיות עם תמונות שלא נטענות או גופנים שלא מופיעים כראוי, כדאי לנסות לפנות את זיכרון המטמון. הפעולה הזאת לא תסיר את נתוני המשתמש שלך.", "areYouSureToClearCache": "לפנות את זיכרון המטמון?", "clearCacheSuccess": "זיכרון המטמון התפנה בהצלחה!" }, "user": { "name": "שם", "email": "דוא״ל", "tooltipSelectIcon": "בחירת סמל", "selectAnIcon": "נא לבחור סמל", "pleaseInputYourOpenAIKey": "נא למלא את המפתח שלך ב־OpenAI", "clickToLogout": "לחיצה תוציא את המשתמש הנוכחי", "pleaseInputYourStabilityAIKey": "נא למלא את המפתח שלך ב־Stability AI" }, "mobile": { "personalInfo": "פרטים אישיים", "username": "שם משתמש", "usernameEmptyError": "שם שמשתמש לא יכול להישאר ריק", "about": "על אודות", "pushNotifications": "התראות בדחיפה", "support": "תמיכה", "joinDiscord": "להצטרף אלינו ב־Discord", "privacyPolicy": "מדיניות פרטיות", "userAgreement": "הסכם שימוש", "termsAndConditions": "תנאים והתניות", "userprofileError": "טעינת פרופיל המשתמש נכשלה", "userprofileErrorDescription": "נא לנסות לצאת ולהיכנס בחזרה אם הבעיה נמשכת.", "selectLayout": "בחירת פריסה", "selectStartingDay": "בחירת יום ההתחלה", "version": "גרסה" } }, "grid": { "deleteView": "למחוק את התצוגה הזאת?", "createView": "חדש", "title": { "placeholder": "ללא שם" }, "settings": { "filter": "מסנן", "sort": "מיון", "sortBy": "מיון לפי", "properties": "מאפיינים", "reorderPropertiesTooltip": "אפשר לשנות את סדר המאפיינים בגרירה", "group": "קבוצה", "addFilter": "הוספת מסנן", "deleteFilter": "מחיקת מסנן", "filterBy": "סינון לפי…", "typeAValue": "נא להקליד ערך…", "layout": "פריסה", "databaseLayout": "פריסה", "viewList": { "zero": "0 צפיות", "one": "צפייה", "other": "{count} צפיות" }, "editView": "עריכת תצוגה", "boardSettings": "הגדרות לוח", "calendarSettings": "הגדרות לוח שנה", "createView": "תצוגה חדשה", "duplicateView": "שכפול תצוגה", "deleteView": "מחיקת תצוגה", "numberOfVisibleFields": "{} מופיע" }, "textFilter": { "contains": "מכיל", "doesNotContain": "לא מכיל", "endsWith": "מסתיים ב־", "startWith": "נפתח ב־", "is": "הוא", "isNot": "אינו", "isEmpty": "ריק", "isNotEmpty": "לא ריק", "choicechipPrefix": { "isNot": "לא", "startWith": "נפתח ב־", "endWith": "מסתיים ב־", "isEmpty": "ריק", "isNotEmpty": "לא ריק" } }, "checkboxFilter": { "isChecked": "מסומן", "isUnchecked": "לא מסומן", "choicechipPrefix": { "is": "הוא" } }, "checklistFilter": { "isComplete": "הושלם", "isIncomplted": "טרם הושלם" }, "selectOptionFilter": { "is": "הוא", "isNot": "אינו", "contains": "מכיל", "doesNotContain": "לא מכיל", "isEmpty": "ריק", "isNotEmpty": "לא ריק" }, "dateFilter": { "is": "הוא", "before": "הוא לפני", "after": "הוא אחרי", "onOrBefore": "בתאריך או לפני", "onOrAfter": "בתאריך או אחרי", "between": "בין", "empty": "ריק", "notEmpty": "לא ריק", "choicechipPrefix": { "before": "לפני", "after": "אחרי", "onOrBefore": "בתאריך או לפני", "onOrAfter": "בתאריך או אחרי", "isEmpty": "ריק", "isNotEmpty": "לא ריק" } }, "numberFilter": { "equal": "שווה ל־", "notEqual": "לא שווה ל־", "lessThan": "קטן מ־", "greaterThan": "גדול מ־", "lessThanOrEqualTo": "קטן או שווה ל־", "greaterThanOrEqualTo": "גדול או שווה ל־", "isEmpty": "ריק", "isNotEmpty": "לא ריק" }, "field": { "hide": "הסתרה", "show": "הצגה", "insertLeft": "הוספה משמאל", "insertRight": "הוספה מימין", "duplicate": "שכפול", "delete": "מחיקה", "wrapCellContent": "גלישת טקסט", "clear": "פינוי תאים", "textFieldName": "טקסט", "checkboxFieldName": "תיבת סימון", "dateFieldName": "תאריך", "updatedAtFieldName": "שינוי אחרון", "createdAtFieldName": "יצירה", "numberFieldName": "מספרים", "singleSelectFieldName": "בחירה", "multiSelectFieldName": "ריבוי בחירות", "urlFieldName": "כתובת", "checklistFieldName": "רשימת סימונים", "relationFieldName": "יחס", "summaryFieldName": "תקציר בינה מלאכותית", "timeFieldName": "שעה", "translateFieldName": "תרגום בינה מלאכותית", "translateTo": "תרגום ל־", "numberFormat": "תבנית מספר", "dateFormat": "תבנית תאריך", "includeTime": "כולל השעה", "isRange": "תאריך סיום", "dateFormatFriendly": "חודש יום, שנה", "dateFormatISO": "שנה-חודש-יום", "dateFormatLocal": "חודש/יום/שנה", "dateFormatUS": "שנה/חודש/יום", "dateFormatDayMonthYear": "יום/חודש/שנה", "timeFormat": "תבנית שעה", "invalidTimeFormat": "תבנית שגויה", "timeFormatTwelveHour": "12 שעות", "timeFormatTwentyFourHour": "24 שעות", "clearDate": "פינוי התאריך", "dateTime": "תאריך שעה", "startDateTime": "תאריך ושעת התחלה", "endDateTime": "תאריך ושעת סיום", "failedToLoadDate": "טעינת ערך התאריך נכשלה", "selectTime": "נא לבחור שעה", "selectDate": "נא לבחור תאריך", "visibility": "חשיפה", "propertyType": "סוג המאפיין", "addSelectOption": "הוספת אפשרות", "typeANewOption": "נא להקליד אפשרות חדשה", "optionTitle": "אפשרויות", "addOption": "הוספת אפשרות", "editProperty": "עריכת מאפיין", "newProperty": "מאפיין חדש", "deleteFieldPromptMessage": "להמשיך? המאפיין יימחק", "clearFieldPromptMessage": "להמשיך? כל התאים בעמודה הזאת יתרוקנו", "newColumn": "עמודה חדשה", "format": "תבנית", "reminderOnDateTooltip": "בתא הזה יש תזכורת מתוזמנת", "optionAlreadyExist": "האפשרות כבר קיימת" }, "rowPage": { "newField": "הוספת שדה חדש", "fieldDragElementTooltip": "נא ללחוץ לפתיחת התפריט", "showHiddenFields": { "one": "הצגת שדה מוסתר", "many": "הצגת {count} שדות מוסתרים", "other": "הצגת {count} שדות מוסתרים" }, "hideHiddenFields": { "one": "הסתרת שדה מוסתר", "many": "הסתרת {count} שדות מוסתרים", "other": "הסתרת {count} שדות מוסתרים" }, "openAsFullPage": "Open as full page", "moreRowActions": "פעולות שורה נוספות" }, "sort": { "ascending": "עולה", "descending": "יורד", "by": "לפי", "empty": "אין מיונים פעילים", "cannotFindCreatableField": "לא ניתן למצוא שדה מתאים למיין לפיו", "deleteAllSorts": "מחיקת כל המיונים", "addSort": "הוספת מיון חדש", "removeSorting": "להסיר מיון?", "fieldInUse": "כבר בחרת למיין לפי השדה הזה" }, "row": { "duplicate": "שכפול", "delete": "מחיקה", "titlePlaceholder": "ללא שם", "textPlaceholder": "ריק", "copyProperty": "המאפיין הועתק ללוח הגזירים", "count": "ספירה", "newRow": "שורה חדשה", "action": "פעולה", "add": "לחיצה תוסיף להלן", "drag": "גרירה להזזה", "deleteRowPrompt": "למחוק את השורה הזאת? זאת פעולה בלתי הפיכה", "deleteCardPrompt": "למחוק את הכרטיס הזה? זאת פעולה בלתי הפיכה", "dragAndClick": "גרירה להזזה, לחיצה לפתיחת התפריט", "insertRecordAbove": "הוספת רשומה למעלה", "insertRecordBelow": "הוספת רשומה מתחת", "noContent": "אין תוכן" }, "selectOption": { "create": "יצירה", "purpleColor": "סגול", "pinkColor": "ורוד", "lightPinkColor": "ורוד בהיר", "orangeColor": "כתום", "yellowColor": "צהוב", "limeColor": "ליים", "greenColor": "ירוק", "aquaColor": "אוקיינוס", "blueColor": "כחול", "deleteTag": "מחיקת תגית", "colorPanelTitle": "צבע", "panelTitle": "נא לבחור אפשרות או ליצור אחת", "searchOption": "חיפוש אפשרות", "searchOrCreateOption": "חיפוש אפשרות או ליצור אחת", "createNew": "ליצור חדשה", "orSelectOne": "או לבחור אפשרות", "typeANewOption": "נא למלא אפשרות חדשה", "tagName": "שם תגית" }, "checklist": { "taskHint": "תיאור משימה", "addNew": "הוספת משימה חדשה", "submitNewTask": "יצירה", "hideComplete": "הסתרת משימות שהושלמו", "showComplete": "הצגת כל המשימות" }, "url": { "launch": "פתיחת קישור בדפדפן", "copy": "העתקת קישור ללוח הגזירים", "textFieldHint": "נא למלא כתובת" }, "relation": { "relatedDatabasePlaceLabel": "מסד נתונים קשור", "relatedDatabasePlaceholder": "אין", "inRelatedDatabase": "בתוך", "rowSearchTextFieldPlaceholder": "חיפוש", "noDatabaseSelected": "לא נבחר מסד נתונים, נא לבחור אחד מהרשימה להלן תחילה:", "emptySearchResult": "לא נמצאו רשומות", "linkedRowListLabel": "{count} שורות מקושרות", "unlinkedRowListLabel": "קישור שורה נוספת" }, "menuName": "רשת", "referencedGridPrefix": "View of", "calculate": "חישוב", "calculationTypeLabel": { "none": "אין", "average": "ממוצע", "max": "מרבי", "median": "חציוני", "min": "מזערי", "sum": "סכום", "count": "ספירה", "countEmpty": "ספירת הריקים", "countEmptyShort": "ריק", "countNonEmpty": "ספירת הלא ריקים", "countNonEmptyShort": "מלאים" } }, "document": { "menuName": "מסמך", "date": { "timeHintTextInTwelveHour": "‎01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "נא לבחור לוח לקשר אליו", "createANewBoard": "יצירת לוח חדש" }, "grid": { "selectAGridToLinkTo": "נא לבחור רשת לקשר אליה", "createANewGrid": "יצירת רשת חדשה" }, "calendar": { "selectACalendarToLinkTo": "נא לבחור לוח שנה לקשר אליו", "createANewCalendar": "יצירת לוח שנה חדש" }, "document": { "selectADocumentToLinkTo": "נא לבחור מסמך לקשר אליו" } }, "selectionMenu": { "outline": "קו מתאר", "codeBlock": "מקטע קוד" }, "plugins": { "referencedBoard": "לוח מופנה", "referencedGrid": "טבלה מופנית", "referencedCalendar": "לוח שנה מופנה", "referencedDocument": "מסמך מופנה", "autoGeneratorMenuItemName": "כותב OpenAI", "autoGeneratorTitleName": "OpenAI: לבקש מהבינה המלאכותית לכתוב כל דבר שהוא…", "autoGeneratorLearnMore": "מידע נוסף", "autoGeneratorGenerate": "יצירה", "autoGeneratorHintText": "לבקש מ־OpenAI…", "autoGeneratorCantGetOpenAIKey": "לא ניתן למשוך את המפתח של OpenAI", "autoGeneratorRewrite": "שכתוב", "smartEdit": "סייעני בינה מלאכותית", "smartEditFixSpelling": "תיקון איות", "warning": "⚠️ תגובות הבינה המלאכותית יכולות להיות מסולפות או מטעות.", "smartEditSummarize": "סיכום", "smartEditImproveWriting": "שיפור הכתיבה", "smartEditMakeLonger": "הארכה", "smartEditCouldNotFetchResult": "לא ניתן למשוך את התוצאה מ־OpenAI", "smartEditCouldNotFetchKey": "לא ניתן למשוך מפתח OpenAI", "smartEditDisabled": "חיבור OpenAI בהגדרות", "appflowyAIEditDisabled": "יש להיכנס כדי להפעיל יכולות בינה מלאכותית", "discardResponse": "להתעלם מתגובות הבינה המלאכותית?", "createInlineMathEquation": "יצירת משוואה", "fonts": "גופנים", "insertDate": "הוספת תאריך", "emoji": "אמוג׳י", "toggleList": "רשימת מתגים", "quoteList": "רשימת ציטוטים", "numberedList": "רשימה ממוספרת", "bulletedList": "רשימת תבליטים", "todoList": "רשימת מטלות", "callout": "מסר", "cover": { "changeCover": "החלפת עטיפה", "colors": "צבעים", "images": "תמונות", "clearAll": "פינוי של הכול", "abstract": "מופשט", "addCover": "הוספת עטיפה", "addLocalImage": "הוספת תמונה מקומית", "invalidImageUrl": "כתובת תמונה שגויה", "failedToAddImageToGallery": "הוספת תמונה לגלריה נכשלה", "enterImageUrl": "נא למלא כתובת תמונה", "add": "הוספה", "back": "חזרה", "saveToGallery": "שמירה לגלריה", "removeIcon": "הסרת סמל", "pasteImageUrl": "הדבקת כתובת תמונה", "or": "או", "pickFromFiles": "בחירה מהקבצים", "couldNotFetchImage": "לא ניתן למשוך תמונה", "imageSavingFailed": "שמירת התמונה נכשלה", "addIcon": "הוספת סמל", "changeIcon": "החלפת סמל", "coverRemoveAlert": "הוא יוסר מהעטיפה לאחר מחיקתו.", "alertDialogConfirmation": "להמשיך?" }, "mathEquation": { "name": "משוואה מתמטית", "addMathEquation": "הוספת משוואת TeX", "editMathEquation": "עריכת משוואה מתמטית" }, "optionAction": { "click": "יש ללחוץ על", "toOpenMenu": " כדי לפתוח תפריט", "delete": "מחיקה", "duplicate": "שכפול", "turnInto": "הפיכה ל־", "moveUp": "העלאה למעלה", "moveDown": "הורדה למטה", "color": "צבע", "align": "יישור", "left": "שמאל", "center": "מרכז", "right": "ימין", "defaultColor": "ברירת מחדל", "depth": "עומק" }, "image": { "addAnImage": "הוספת תמונה", "copiedToPasteBoard": "קישור התמונה הועתק ללוח הגזירים", "imageUploadFailed": "העלאת התמונה נכשלה", "errorCode": "קוד שגיאה" }, "math": { "copiedToPasteBoard": "המשוואה המתמטית הועתקה ללוח הגזירים" }, "urlPreview": { "copiedToPasteBoard": "הקישור הועתק ללוח הגזירים", "convertToLink": "המרה לקישור להטמעה" }, "outline": { "addHeadingToCreateOutline": "יש להוסיף כותרות ראשיות כדי ליצור תוכן עניינים.", "noMatchHeadings": "לא נמצאו כותרות ראשויות תואמות." }, "table": { "addAfter": "הוספה לאחר", "addBefore": "הוספה לפני", "delete": "מחיקה", "clear": "פינוי התוכן", "duplicate": "שכפול", "bgColor": "צבע רקע" }, "contextMenu": { "copy": "העתקה", "cut": "גזירה", "paste": "הדבקה" }, "action": "פעולות", "database": { "selectDataSource": "בחירת מקור נתונים", "noDataSource": "אין מקור נתונים", "selectADataSource": "נא לבחור מקור נתונים", "toContinue": "כדי להמשיך", "newDatabase": "מסד נתונים חדש", "linkToDatabase": "קישור למסד נתונים" }, "date": "תאריך", "video": { "label": "סרטון", "emptyLabel": "הוספת סרטון", "placeholder": "הדבקת הקישור לסרטון", "copiedToPasteBoard": "הקישור לסרטון הועתק ללוח הגזירים", "insertVideo": "הוספת סרטון", "invalidVideoUrl": "כתובת המקור לא נתמכת עדיין.", "invalidVideoUrlYouTube": "עדיין אין תמיכה ב־YouTube.", "supportedFormats": "סוגים נתמכים: MP4,‏ WebM,‏ MOV,‏ AVI,‏ FLV,‏ MPEG/M4V,‏ H.264" }, "openAI": "OpenAI" }, "outlineBlock": { "placeholder": "תוכן עניינים" }, "textBlock": { "placeholder": "נא להקליד ‚/’ לקבלת פקודות" }, "title": { "placeholder": "ללא שם" }, "imageBlock": { "placeholder": "נא ללחוץ כדי להוסיף תמונה", "upload": { "label": "העלאה", "placeholder": "נא ללחוץ כדי להעלות תמונה" }, "url": { "label": "כתובת תמונה", "placeholder": "נא למלא כתובת תמונה" }, "ai": { "label": "יצירת תמונה מ־OpenAI", "placeholder": "נא למלא את הקלט ל־OpenAI כדי לייצר תמונה" }, "stability_ai": { "label": "יצירת תמונה מ־Stability AI", "placeholder": "נא למלא את הבקשה ל־Stability AI כדי לייצר תמונה" }, "support": "מגבלת גודל התמונה היא 5 מ״ב. הסוגים הנתמכים הם: JPEG,‏ PNG,‏ GIF,‏ SVG", "error": { "invalidImage": "תמונה שגויה", "invalidImageSize": "גודל התמונה חייב להיות קטן מ־5 מ״ב", "invalidImageFormat": "סוג התמונה לא נתמך. הסוגים הנתמכים: JPEG,‏ PNG,‏ JPG,‏ GIF,‏ SVG,‏ WEBP", "invalidImageUrl": "כתובת תמונה שגויה", "noImage": "אין קובץ או תיקייה כאלה" }, "embedLink": { "label": "קישור להטמעה", "placeholder": "נא להדביק או להקליד קישור לתמונה" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "חיפוש אחר תמונה", "pleaseInputYourOpenAIKey": "נא למלא את מפתח ה־OpenAI שלך בעמוד ההגדרות", "saveImageToGallery": "שמירת תמונה", "failedToAddImageToGallery": "הוספת התמונה לגלריה נכשלה", "successToAddImageToGallery": "התמונה נוספה לגלריה בהצלחה", "unableToLoadImage": "לא ניתן לטעון את התמונה", "maximumImageSize": "גודל ההעלאה המרבי הנתמך הוא 10 מ״ב", "uploadImageErrorImageSizeTooBig": "גודל התמונה חייב להיות גדול מ־10 מ״ב", "imageIsUploading": "התמונה נשלחת", "pleaseInputYourStabilityAIKey": "נא למלא את המפתח ל־Stability AI בעמוד ההגדרות" }, "codeBlock": { "language": { "label": "שפה", "placeholder": "בחירת שפה", "auto": "אוטו׳" }, "copyTooltip": "העתקת תוכן למקטע קוד", "searchLanguageHint": "חיפוש אחר שפה", "codeCopiedSnackbar": "הקוד הועתק ללוח הגזירים!" }, "inlineLink": { "placeholder": "נא להדביק או להקליד קישור", "openInNewTab": "פתיחה בלשונית חדשה", "copyLink": "העתקת קישור", "removeLink": "הסרת קישור", "url": { "label": "כתובת קישור", "placeholder": "נא למלא כתובת לקישור" }, "title": { "label": "כותרת קישור", "placeholder": "נא למלא כותרת לקישור" } }, "mention": { "placeholder": "אזכור משתמשים או עמוד או תאריך…", "page": { "label": "קישור לעמוד", "tooltip": "לחיצה תפתח את העמוד" }, "deleted": "נמחק", "deletedContent": "התוכן הזה לא קיים או שנמחק" }, "toolbar": { "resetToDefaultFont": "איפוס לברירת מחדל" }, "errorBlock": { "theBlockIsNotSupported": "לא ניתן לפענח את תוכן המקטע", "clickToCopyTheBlockContent": "לחיצה תעתיק את תוכן המקטע", "blockContentHasBeenCopied": "תוכן המקטע הועתק." }, "mobilePageSelector": { "title": "נא לבחור עמוד", "failedToLoad": "טעינת רשימת העמודים נכשלה", "noPagesFound": "לא נמצאו עמודים" } }, "board": { "column": { "createNewCard": "חדש", "renameGroupTooltip": "נא ללחוץ לשינוי שם הקבוצה", "createNewColumn": "הוספת קבוצה חדשה", "addToColumnTopTooltip": "הוספת כרטיס חדש בראש", "addToColumnBottomTooltip": "הוספת כרטיס חדש בתחתית", "renameColumn": "שינוי שם", "hideColumn": "הסתרה", "newGroup": "קבוצה חדשה", "deleteColumn": "מחיקה", "deleteColumnConfirmation": "הפעולה הזאת תמחק את הקבוצה הזאת ואת כל הכרטיסים שבה.\nלהמשיך?" }, "hiddenGroupSection": { "sectionTitle": "קבוצות מוסתרות", "collapseTooltip": "הסתרת הקבוצות המוסתרות", "expandTooltip": "הצגת הקבוצות המוסתרות" }, "cardDetail": "פרטי הכרטיס", "cardActions": "פעולות על הכרטיס", "cardDuplicated": "הכרטיס שוכפל", "cardDeleted": "הכרטיס נמחק", "showOnCard": "הצגה על פרט הכרטיס", "setting": "הגדרה", "propertyName": "שם מאפיין", "menuName": "לוח", "showUngrouped": "הצגת פריטים מחוץ לקבוצות", "ungroupedButtonText": "מחוץ לקבוצה", "ungroupedButtonTooltip": "מכיל כרטיסים שלא שייכים לאף קבוצה", "ungroupedItemsTitle": "נא ללחוץ כדי להוסיף ללוח", "groupBy": "קיבוץ לפי", "groupCondition": "תנאי קיבוץ", "referencedBoardPrefix": "View of", "notesTooltip": "הערות בפנים", "mobile": { "editURL": "עריכת כתובת", "showGroup": "הצגת קבוצה", "showGroupContent": "להציג את הקבוצה הזאת בלוח?", "failedToLoad": "טעינת תצוגת הלוח נכשלה" }, "dateCondition": { "weekOf": "שבוע {} - {}", "today": "היום", "yesterday": "אתמול", "tomorrow": "מחר", "lastSevenDays": "7 הימים האחרונים", "nextSevenDays": "7 הימים הבאים", "lastThirtyDays": "30 הימים האחרונים", "nextThirtyDays": "30 הימים הבאים" }, "noGroup": "אין קבוצה לפי מאפיין", "noGroupDesc": "תצוגות הלוח דורשות מאפיין לקיבוץ כדי שתוצגנה" }, "calendar": { "menuName": "לוח שנה", "defaultNewCalendarTitle": "ללא שם", "newEventButtonTooltip": "הוספת אירוע חדש", "navigation": { "today": "היום", "jumpToday": "דילוג להיום", "previousMonth": "החודש הקודם", "nextMonth": "החודש הבא", "views": { "day": "יום", "week": "שבוע", "month": "חודש", "year": "שנה" } }, "mobileEventScreen": { "emptyTitle": "אין אירועים עדיין", "emptyBody": "יש ללחוץ על כפתור הפלוס כדי להוסיף אירוע ביום הזה." }, "settings": { "showWeekNumbers": "הצגת מספרי השבועות", "showWeekends": "הצגת סופי שבוע", "firstDayOfWeek": "השבוע מתחיל ב־", "layoutDateField": "פריסת לוח השנה לפי", "changeLayoutDateField": "החלפת שדה פריסה", "noDateTitle": "אין תאריך", "noDateHint": { "zero": "אירועים לא מתוזמנים יופיעו כאן", "one": "אירוע לא מתוזמן", "other": "{count} אירועים לא מתוזמנים" }, "unscheduledEventsTitle": "אירועים לא מתוזמנים", "clickToAdd": "נא ללחוץ כדי להוסיף ללוח השנה", "name": "הגדרות לוח שנה", "clickToOpen": "לחיצה תפתח את הרשומה" }, "referencedCalendarPrefix": "תצוגה של", "quickJumpYear": "דילוג אל", "duplicateEvent": "שכפול אירוע" }, "errorDialog": { "title": "שגיאה ב־@:appName", "howToFixFallback": "סליחה על חוסר הנעימות! נא להגיש תיעוד תקלה בעמוד ה־GitHub שלנו שמתאר את השגיאה שלך.", "github": "הצגה ב־GitHub" }, "search": { "label": "חיפוש", "placeholder": { "actions": "חיפוש פעולות…" } }, "message": { "copy": { "success": "הועתק!", "fail": "לא ניתן להעתיק" } }, "unSupportBlock": "הגרסה הנוכחית לא תומכת במקטע הזה.", "views": { "deleteContentTitle": "למחוק את {pageType}?", "deleteContentCaption": "ניתן יהיה לשחזר את ה{pageType} הזה מהאשפה אם בחרת למחוק." }, "colors": { "custom": "משלך", "default": "ברירת מחדל", "red": "אדום", "orange": "כתום", "yellow": "צהוב", "green": "ירוק", "blue": "כחול", "purple": "סגול", "pink": "ורוד", "brown": "חום", "gray": "אפור" }, "emoji": { "emojiTab": "אמוג׳י", "search": "חיפוש אמוג׳י", "noRecent": "אין אמוג׳י אחרונים", "noEmojiFound": "לא נמצא אמוג׳י", "filter": "מסנן", "random": "אקראי", "selectSkinTone": "בחירת גוון עור", "remove": "הסרת אמוג׳י", "categories": { "smileys": "חייכנים וסמלי רגש", "people": "אנשים וגוף", "animals": "חיות וטבע", "food": "מזון ומשקאות", "activities": "פעילויות", "places": "טיול ומקומות", "objects": "חפצים", "symbols": "סמלים", "flags": "דגלים", "nature": "טבע", "frequentlyUsed": "בשימוש תדיר" }, "skinTone": { "default": "ברירת מחדל", "light": "בהיר", "mediumLight": "בהיר ממוצע", "medium": "ממוצע", "mediumDark": "כהה ממוצע", "dark": "כהה" } }, "inlineActions": { "noResults": "אין תוצאות", "recentPages": "עמודים אחרונים", "pageReference": "הפנייה לעמוד", "docReference": "הפנייה למסמך", "boardReference": "הפנייה ללוח", "calReference": "הפנייה ללוח שנה", "gridReference": "הפנייה לטבלה", "date": "תאריך", "reminder": { "groupTitle": "תזכורת", "shortKeyword": "להזכיר" } }, "datePicker": { "dateTimeFormatTooltip": "אפשר להחליף את התאריך והשעה בהגדרות", "dateFormat": "תבנית תאריך", "includeTime": "כולל השעה", "isRange": "תאריך סיום", "timeFormat": "תבנית שעה", "clearDate": "פינוי התאריך", "reminderLabel": "תזכורת", "selectReminder": "בחירת תזכורת", "reminderOptions": { "none": "בלי", "atTimeOfEvent": "בזמן האירוע", "fiveMinsBefore": "5 דק׳ לפני", "tenMinsBefore": "10 דק׳ לפני", "fifteenMinsBefore": "15 דק׳ לפני", "thirtyMinsBefore": "30 דק׳ לפני", "oneHourBefore": "שעה לפני", "twoHoursBefore": "שעתיים לפני", "onDayOfEvent": "ביום האירוע", "oneDayBefore": "יום לפני", "twoDaysBefore": "יומיים לפני", "oneWeekBefore": "שבוע לפני", "custom": "אחר" } }, "relativeDates": { "yesterday": "אתמול", "today": "היום", "tomorrow": "מחר", "oneWeek": "שבוע" }, "notificationHub": { "title": "התראות", "mobile": { "title": "עדכונים" }, "emptyTitle": "התעדכנת בהכול!", "emptyBody": "אין התראות ממתינות לפעולות. אפשר פשוט להירגע.", "tabs": { "inbox": "הודעות נכנסות", "upcoming": "בקרוב" }, "actions": { "markAllRead": "סימון של כולם כנקראו", "showAll": "הכול", "showUnreads": "לא נקראו" }, "filters": { "ascending": "עולה", "descending": "יורד", "groupByDate": "קיבוץ לפי תאריך", "showUnreadsOnly": "להציג כאלו שלא נקראו בלבד", "resetToDefault": "איפוס לברירת המחדל" } }, "reminderNotification": { "title": "תזכורת", "message": "חשוב לסמן את זה לפני ששוכחים!", "tooltipDelete": "מחיקה", "tooltipMarkRead": "סימון כנקראה", "tooltipMarkUnread": "סימון כלא נקראה" }, "findAndReplace": { "find": "איתור", "previousMatch": "התוצאה הקודמת", "nextMatch": "התוצאה הבאה", "close": "סגירה", "replace": "החלפה", "replaceAll": "החלפה של הכול", "noResult": "אין תוצאות", "caseSensitive": "תלוי רישיות", "searchMore": "אפשר לחפש לאיתור תוצאות נוספות" }, "error": { "weAreSorry": "אנו מתנצלים", "loadingViewError": "טעינת התצוגה הזאת נתקלת בקשיים. נא לבדוק שהחיבור שלך לאינטרנט תקין, לאחר מכן לרענן את היישום ולא להסס לפנות לצוות אם המצב הזה נמשך." }, "editor": { "bold": "מודגש", "bulletedList": "רשימת תבליטים", "bulletedListShortForm": "תבליטים", "checkbox": "תיבת סימון", "embedCode": "הטמעת קוד", "heading1": "כ1", "heading2": "כ2", "heading3": "כ3", "highlight": "הדגשה", "color": "צבע", "image": "תמונה", "date": "תאריך", "page": "עמוד", "italic": "נטוי", "link": "קישור", "numberedList": "רשימה ממוספרת", "numberedListShortForm": "ממוספר", "quote": "ציטוט", "strikethrough": "קו חוצה", "text": "טקסט", "underline": "קו תחתי", "fontColorDefault": "ברירת מחדל", "fontColorGray": "אפור", "fontColorBrown": "חום", "fontColorOrange": "כתום", "fontColorYellow": "צהוב", "fontColorGreen": "ירוק", "fontColorBlue": "כחול", "fontColorPurple": "סגול", "fontColorPink": "ורוד", "fontColorRed": "אדום", "backgroundColorDefault": "רקע ברירת מחדל", "backgroundColorGray": "רקע אפור", "backgroundColorBrown": "רקע חום", "backgroundColorOrange": "רקע כתום", "backgroundColorYellow": "רקע צהוב", "backgroundColorGreen": "רקע ירוק", "backgroundColorBlue": "רקע כחול", "backgroundColorPurple": "רקע סגול", "backgroundColorPink": "רקע ורוד", "backgroundColorRed": "רקע אדום", "backgroundColorLime": "רקע ליים", "backgroundColorAqua": "רקע אוקיינוס", "done": "בוצע", "cancel": "ביטול", "tint1": "גוון 1", "tint2": "גוון 2", "tint3": "גוון 3", "tint4": "גוון 4", "tint5": "גוון 5", "tint6": "גוון 6", "tint7": "גוון 7", "tint8": "גוון 8", "tint9": "גוון 9", "lightLightTint1": "סגול", "lightLightTint2": "ורוד", "lightLightTint3": "ורוד בהיר", "lightLightTint4": "כתום", "lightLightTint5": "צהוב", "lightLightTint6": "ליים", "lightLightTint7": "ירוק", "lightLightTint8": "אוקיינוס", "lightLightTint9": "כחול", "urlHint": "כתובת", "mobileHeading1": "כותרת 1", "mobileHeading2": "כותרת 2", "mobileHeading3": "כותרת 3", "textColor": "צבע טקסט", "backgroundColor": "צבע רקע", "addYourLink": "הוספת הקישור שלך", "openLink": "פתיחת קישור", "copyLink": "העתקת קישור", "removeLink": "הסרת קישור", "editLink": "עריכת קישור", "linkText": "טקסט", "linkTextHint": "נא למלא טקסט", "linkAddressHint": "נא למלא כתובת", "highlightColor": "צבע הדגשה", "clearHighlightColor": "איפוס צבע הדגשה", "customColor": "צבע משלך", "hexValue": "ערך הקס׳", "opacity": "אטימות", "resetToDefaultColor": "איפוס לצבע ברירת המחדל", "ltr": "משמאל לימין", "rtl": "מימין לשמאל", "auto": "אוטו׳", "cut": "גזירה", "copy": "העתקה", "paste": "הדבקה", "find": "איתור", "select": "בחירה", "selectAll": "בחירה בהכול", "previousMatch": "התוצאה הקודמת", "nextMatch": "התוצאה הבאה", "closeFind": "סגירה", "replace": "החלפה", "replaceAll": "החלפה של הכול", "regex": "ביטוי רגולרי", "caseSensitive": "תלוי רישיות", "uploadImage": "העלאת תמונה", "urlImage": "קישור לתמונה", "incorrectLink": "קישור שגוי", "upload": "העלאה", "chooseImage": "בחירת תמונה", "loading": "בטעינה", "imageLoadFailed": "טעינת התמונה נכשלה", "divider": "מפריד", "table": "טבלה", "colAddBefore": "הוספה לפני", "rowAddBefore": "הוספה לפני", "colAddAfter": "הוספה אחרי", "rowAddAfter": "הוספה אחרי", "colRemove": "הסרה", "rowRemove": "הסרה", "colDuplicate": "שכפול", "rowDuplicate": "שכפול", "colClear": "פינוי תוכן", "rowClear": "פינוי תוכן", "slashPlaceHolder": "נא להקליד ‚/’ כדי להוסיף מקטע או להתחיל להקליד", "typeSomething": "נא להקליד משהו…", "toggleListShortForm": "מתג", "quoteListShortForm": "ציטוט", "mathEquationShortForm": "נוסחה", "codeBlockShortForm": "קוד" }, "favorite": { "noFavorite": "לא עמוד מועדף", "noFavoriteHintText": "החלקת העמוד שמאלה תוסיף אותו למועדפים שלך", "removeFromSidebar": "הסרה מסרגל הצד", "addToSidebar": "הצמדה לסרגל צד" }, "cardDetails": { "notesPlaceholder": "יש להקליד / כדי להוסיף מקטע, או להתחיל להקליד" }, "blockPlaceholders": { "todoList": "מטלה", "bulletList": "רשימה", "numberList": "רשימה", "quote": "ציטוט", "heading": "כותרת {}" }, "titleBar": { "pageIcon": "סמל עמוד", "language": "שפה", "font": "גופן", "actions": "פעולות", "date": "תאריך", "addField": "הוספת שדה", "userIcon": "סמל משתמש" }, "noLogFiles": "אין קובצי יומנים", "newSettings": { "myAccount": { "title": "החשבון שלי", "subtitle": "התאמת הפרופיל שלך, ניהול אבטחת החשבון, מפתחות בינה מלאכותית או כניסה לחשבון שלך.", "profileLabel": "שם חשבון ותמונת פרופיל", "profileNamePlaceholder": "נא למלא את השם שלך", "accountSecurity": "אבטחת חשבון", "2FA": "אימות דו־שלבי", "aiKeys": "מפתחות בינה מלאכותית", "accountLogin": "כניסה לחשבון", "updateNameError": "עדכון השם נכשל", "updateIconError": "עדכון הסמל נכשל", "deleteAccount": { "title": "מחיקת חשבון", "subtitle": "מחיקת החשבון וכל הנתונים שלך לצמיתות.", "deleteMyAccount": "מחיקת החשבון שלי", "dialogTitle": "מחיקת חשבון", "dialogContent1": "למחוק את החשבון שלך לצמיתות?", "dialogContent2": "זאת פעולה בלתי הפיכה והיא תסיר את הגישה מכל מרחבי הצוותים תוך מחיקת החשבון כולו לרבות מרחבי עבודה פרטיים והסרתך מכל מרחבי העבודה המשותפים." } }, "workplace": { "name": "מקום עבודה", "title": "הגדרות מקום עבודה", "subtitle": "התאמת המראה, ערכת העיצוב, גופן, פריסת הטקסט, התאריך, השעה והשפה של מרחב העבודה שלך.", "workplaceName": "שם מקום העבודה", "workplaceNamePlaceholder": "נא למלא שם למקום העבודה", "workplaceIcon": "סמל מקום עבודה", "workplaceIconSubtitle": "אפשר להעלות תמונה או להשתמש באמוג׳י למרחב העבודה שלך. הסמל יופיע בסרגל הצד ובהתראות שלך.", "renameError": "שינוי שם מקום העבודה נכשל", "updateIconError": "עדכון הסמל נכשל", "chooseAnIcon": "נא לבחור סמל", "appearance": { "name": "מראה", "themeMode": { "auto": "אוטו׳", "light": "בהיר", "dark": "כהה" }, "language": "שפה" } }, "syncState": { "syncing": "סנכרון", "synced": "מסונכרן", "noNetworkConnected": "אין רשת מחוברת" } }, "pageStyle": { "title": "סגנון עמוד", "layout": "פריסה", "coverImage": "תמונת כריכה", "pageIcon": "סמל עמוד", "colors": "צבעים", "gradient": "מדרג", "backgroundImage": "תמונת רקע", "presets": "ערכות מוגדרות", "photo": "צילום", "unsplash": "Unsplash", "pageCover": "כריכת עמוד", "none": "אין", "openSettings": "פתיחת הגדרות", "photoPermissionTitle": "@:appName רוצה לגשת לספריית התמונות שלך", "photoPermissionDescription": "לאפשר גישה לספריית התמונות כדי להעלות תמונות.", "doNotAllow": "לא להרשות", "image": "תמונה" }, "commandPalette": { "placeholder": "נא להקליד כדי לחפש…", "bestMatches": "התוצאות הטובות ביותר", "recentHistory": "היסטוריה עדכנית", "navigateHint": "לניווט", "loadingTooltip": "אנו מחפשים תוצאות…", "betaLabel": "בטא", "betaTooltip": "אנו תומכים רק בחיפוש אחר עמודים ותוכן במסמכים", "fromTrashHint": "מהאשפה", "noResultsHint": "לא מצאנו מה שחיפשת, כדאי לנסות לחפש ביטוי אחר.", "clearSearchTooltip": "פינוי שדה חיפוש" }, "space": { "delete": "מחיקה", "deleteConfirmation": "מחיקה: ", "deleteConfirmationDescription": "כל העמודים במרחב הזה יימחקו ויעברו לאשפה, ועמודים שפורסמו יוסתרו.", "rename": "שינוי שם מרחב", "changeIcon": "החלפת סמל", "manage": "ניהול מרחב", "addNewSpace": "יצירת מרחב", "collapseAllSubPages": "צמצום כל תת־העמודים", "createNewSpace": "יצירת מרחב חדש", "createSpaceDescription": "אפשר ליצור מגוון מרחבים ציבוריים ופרטיים כדי לארגן את העבודה שלך בצורה טובה יותר.", "spaceName": "שם המרחב", "permission": "הרשאה", "publicPermission": "ציבורי", "publicPermissionDescription": "כל החברים במרחב העבודה שיש להם גישה מלאה", "privatePermission": "פרטי", "privatePermissionDescription": "רק לך יש גישה למרחב הזה", "spaceIconBackground": "צבע הרקע", "spaceIcon": "סמל", "dangerZone": "אזור הסכנה", "unableToDeleteLastSpace": "לא ניתן למחוק את המרחב האחרון", "unableToDeleteSpaceNotCreatedByYou": "לא ניתן למחוק מרחבים שנוצרו על ידי אחרים", "enableSpacesForYourWorkspace": "הפעלת מרחבים למרחב העבודה שלך", "title": "מרחבים", "defaultSpaceName": "כללי", "upgradeSpaceTitle": "הפעלת מרחבים", "upgradeSpaceDescription": "אפשר ליצור מגוון מרחבים ציבוריים ופרטיים כדי לארגן את מרחב העבודה שלך בצורה טובה יותר.", "upgrade": "עדכון", "upgradeYourSpace": "יצירת מרחבים במרוכז", "quicklySwitch": "מעבר למרחב הבא במהירות", "duplicate": "שכפול מרחב", "movePageToSpace": "העבר עמוד למרחב", "switchSpace": "החלפת מרחב" }, "publish": { "hasNotBeenPublished": "העמוד הזה לא פורסם עדיין", "reportPage": "דיווח על עמוד", "databaseHasNotBeenPublished": "עדיין אין תמיכה בפרסום למסדי נתונים.", "createdWith": "נוצר עם", "downloadApp": "הורדת AppFlowy", "copy": { "codeBlock": "תוכן מקטע הקוד הועתק ללוח הגזירים", "imageBlock": "קישור התמונה הועתק ללוח הגזירים", "mathBlock": "הנוסחה המתמטית הועתקה ללוח הגזירים" }, "containsPublishedPage": "העמוד הזה מכיל עמוד או יותר שפורסמו. המשך בתהליך יסתיר אותם. להמשיך במחיקה?", "publishSuccessfully": "הפרסום הצליח", "unpublishSuccessfully": "ההסתרה הצליחה", "publishFailed": "הפרסום נכשל", "unpublishFailed": "ההסתרה נכשלה", "noAccessToVisit": "אין גישה לעמוד הזה…", "createWithAppFlowy": "יצירת אתר עם AppFlowy", "fastWithAI": "מהיר וקל עם בינה מלאכותית.", "tryItNow": "לנסות כעת" }, "web": { "continue": "המשך", "or": "או", "continueWithGoogle": "להמשיך עם Google", "continueWithGithub": "להמשיך עם GitHub", "continueWithDiscord": "להמשיך עם Discord", "signInAgreement": "לחיצה על „המשך” להלן, מהווה את אישורך\nלכך שקראת, הבנת והסכמת לתנאים של AppFlowy", "and": "וגם", "termOfUse": "תנאים", "privacyPolicy": "מדיניות פרטיות", "signInError": "שגיאת כניסה", "login": "הרשמה או כניסה" } } ================================================ FILE: frontend/resources/translations/hin.json ================================================ { "appName": "AppFlowy", "defaultUsername": "मैं", "welcomeText": " @:appName में आपका स्वागत है", "githubStarText": "गिटहब पर स्टार करे", "subscribeNewsletterText": "समाचार पत्रिका के लिए सदस्यता लें", "letsGoButtonText": "जल्दी शुरू करे", "title": "शीर्षक", "youCanAlso": "आप भी कर सकते हैं", "and": "और", "blockActions": { "addBelowTooltip": "नीचे जोड़ने के लिए क्लिक करें", "addAboveCmd": "Alt+click ", "addAboveMacCmd": "Option+click", "addAboveTooltip": "ऊपर जोड़ने के लिए", "dragTooltip": "ले जाने के लिए ड्रैग करें", "openMenuTooltip": "मेनू खोलने के लिए क्लिक करें" }, "signUp": { "buttonText": "साइन अप करें", "title": "साइन अप करें @:appName", "getStartedText": "शुरू करे", "emptyPasswordError": "पासवर्ड खाली नहीं हो सकता", "repeatPasswordEmptyError": "रिपीट पासवर्ड खाली नहीं हो सकता", "unmatchedPasswordError": "रिपीट पासवर्ड और पासवर्ड एक नहीं है", "alreadyHaveAnAccount": "क्या आपके पास पहले से एक खाता मौजूद है?", "emailHint": "ईमेल", "passwordHint": "पासवर्ड", "repeatPasswordHint": "रिपीट पासवर्ड", "signUpWith": "इसके साथ साइन अप करें:" }, "signIn": { "loginTitle": "लॉग इन करें @:appName", "loginButtonText": "लॉग इन करें", "loginStartWithAnonymous": "एक अज्ञात सत्र से प्रारंभ करें", "continueAnonymousUser": "अज्ञात सत्र जारी रखें", "buttonText": "साइन इन", "forgotPassword": "पासवर्ड भूल गए?", "emailHint": "ईमेल", "passwordHint": "पासवर्ड", "dontHaveAnAccount": "कोई खाता नहीं है?", "repeatPasswordEmptyError": "रिपीट पासवर्ड खाली नहीं हो सकता", "unmatchedPasswordError": "रिपीट पासवर्ड और पासवर्ड एक नहीं है", "syncPromptMessage": "डेटा को सिंक करने में कुछ समय लग सकता है. कृपया इस पेज को बंद न करें", "or": "या", "LogInWithGoogle": "गूगल से लॉग इन करें", "LogInWithGithub": "गिटहब से लॉग इन करें", "LogInWithDiscord": "डिस्कॉर्ड से लॉग इन करें", "signInWith": "इसके साथ साइन इन करें:" }, "workspace": { "chooseWorkspace": "अपना कार्यक्षेत्र चुनें", "create": "कार्यक्षेत्र बनाएं", "reset": "कार्यक्षेत्र रीसेट करें", "resetWorkspacePrompt": "कार्यक्षेत्र को रीसेट करने से उसमें मौजूद सभी पृष्ठ और डेटा हट जाएंगे। क्या आप वाकई कार्यक्षेत्र को रीसेट करना चाहते हैं? वैकल्पिक रूप से, आप कार्यक्षेत्र को पुनर्स्थापित करने के लिए सहायता टीम से संपर्क कर सकते हैं", "hint": "कार्यक्षेत्र", "notFoundError": "कार्यस्थल नहीं मिला" }, "shareAction": { "buttonText": "शेयर", "workInProgress": "जल्द आ रहा है", "markdown": "markdown", "csv": "csv", "copyLink": "लिंक कॉपी करें" }, "moreAction": { "small": "छोटा", "medium": "मध्यम", "large": "बड़ा", "fontSize": "अक्षर का आकर", "import": "आयात", "moreOptions": "अधिक विकल्प" }, "importPanel": { "textAndMarkdown": "Text & Markdown", "documentFromV010": "Document from v0.1.0", "databaseFromV010": "Database from v0.1.0", "csv": "CSV", "database": "Database" }, "disclosureAction": { "rename": "नाम बदलें", "delete": "हटाएं", "duplicate": "डुप्लीकेट", "unfavorite": "पसंदीदा से हटाएँ", "favorite": "पसंदीदा में जोड़ें", "openNewTab": "एक नए टैब में खोलें", "moveTo": "स्थानांतरित करें", "addToFavorites": "पसंदीदा में जोड़ें", "copyLink": "कॉपी लिंक" }, "blankPageTitle": "रिक्त पेज", "newPageText": "नया पेज", "newDocumentText": "नया दस्तावेज़", "newGridText": "नया ग्रिड", "newCalendarText": "नया कैलेंडर", "newBoardText": "नया बोर्ड", "trash": { "text": "कचरा", "restoreAll": "सभी पुनर्स्थापित करें", "deleteAll": "सभी हटाएँ", "pageHeader": { "fileName": "फ़ाइलनाम", "lastModified": "अंतिम संशोधित", "created": "बनाया गया" }, "confirmDeleteAll": { "title": "क्या आप निश्चित रूप से ट्रैश में मौजूद सभी पेज को हटाना चाहते हैं?", "caption": "यह कार्रवाई पूर्ववत नहीं की जा सकती।" }, "confirmRestoreAll": { "title": "क्या आप निश्चित रूप से ट्रैश में सभी पेज को पुनर्स्थापित करना चाहते हैं?", "caption": "यह कार्रवाई पूर्ववत नहीं की जा सकती।" } }, "deletePagePrompt": { "text": "यह पेज कूड़ेदान में है", "restore": "पुनर्स्थापित पेज", "deletePermanent": "स्थायी रूप से हटाएँ" }, "dialogCreatePageNameHint": "पेज का नाम", "questionBubble": { "shortcuts": "शॉर्टकट", "whatsNew": "क्या नया है?", "help": "सहायता", "markdown": "markdown", "debug": { "name": "डीबग जानकारी", "success": "डिबग जानकारी क्लिपबोर्ड पर कॉपी की गई!", "fail": "डिबग जानकारी को क्लिपबोर्ड पर कॉपी करने में असमर्थ" }, "feedback": "जानकारी देना" }, "menuAppHeader": { "moreButtonToolTip": "निकालें, नाम बदलें, और भी बहुत कुछ...", "addPageTooltip": "जल्दी से अंदर एक पेज जोड़ें", "defaultNewPageName": "शीर्षकहीन", "renameDialog": "नाम बदलें" }, "toolbar": { "undo": "अनडू", "redo": "रीडू", "bold": "बोल्ड", "italic": "इटैलिक", "underline": "अंडरलाइन", "strike": "स्ट्राइकथ्रू", "numList": "क्रमांकित सूची", "bulletList": "बुलेट सूची", "checkList": "चेकलिस्ट", "inlineCode": "इनलाइन कोड", "quote": "कोट", "header": "हेडर", "highlight": "हाइलाइट करें", "color": "रंग", "addLink": "लिंक जोड़ें", "link": "लिंक" }, "tooltip": { "lightMode": "लाइट मोड पर स्विच करें", "darkMode": "डार्क मोड पर स्विच करें", "openAsPage": "पेज के रूप में खोलें", "addNewRow": "एक नई पंक्ति जोड़ें", "openMenu": "मेनू खोलने के लिए क्लिक करें", "dragRow": "पंक्ति को पुनः व्यवस्थित करने के लिए देर तक दबाएँ", "viewDataBase": "डेटाबेस देखें", "referencePage": "यह {name} रफेरेंसेड है", "addBlockBelow": "नीचे एक ब्लॉक जोड़ें" }, "sideBar": { "closeSidebar": "साइड बार बंद करें", "openSidebar": "साइड बार खोलें", "personal": "व्यक्तिगत", "favorites": "पसंदीदा", "clickToHidePersonal": "व्यक्तिगत अनुभाग को छिपाने के लिए क्लिक करें", "clickToHideFavorites": "पसंदीदा अनुभाग को छिपाने के लिए क्लिक करें", "addAPage": "एक पेज जोड़ें" }, "notifications": { "export": { "markdown": "आपका नोट मार्कडाउन के रूप में सफलतापूर्वक निर्यात कर दिया गया है।", "path": "दस्तावेज़/प्रवाह" } }, "contactsPage": { "title": "संपर्क", "whatsHappening": "इस सप्ताह क्या हो रहा है?", "addContact": "संपर्क जोड़ें", "editContact": "संपर्क संपादित करें" }, "button": { "ok": "ठीक है", "cancel": "रद्द करें", "signIn": "साइन इन करें", "signOut": "साइन आउट करें", "complete": "पूर्ण", "save": "सेव", "generate": "उत्पन्न करें", "esc": "एस्केप", "keep": "रखें", "tryAgain": "फिर से प्रयास करें", "discard": "त्यागें", "replace": "बदलें", "insertBelow": "नीचे डालें", "upload": "अपलोड करें", "edit": "संपादित करें", "delete": "हटाएं", "duplicate": "डुप्लिकेट", "done": "किया", "putback": "पुन्हा डालिए" }, "label": { "welcome": "आपका स्वागत है", "firstName": "पहला नाम", "middleName": "मध्य नाम", "lastName": "अंतिम नाम", "stepX": "स्टेप {X}" }, "oAuth": { "err": { "failedTitle": "आपके खाते से जुड़ने में असमर्थ।", "failedMsg": "कृपया सुनिश्चित करें कि आपने अपने ब्राउज़र में साइन-इन प्रक्रिया पूरी कर ली है।" }, "google": { "title": "Google साइन-इन", "instruction1": "अपने Google संपर्कों को आयात करने के लिए, आपको अपने वेब ब्राउज़र का उपयोग करके इस एप्लिकेशन को अधिकृत करना होगा।", "instruction2": "आइकन पर क्लिक करके या टेक्स्ट का चयन करके इस कोड को अपने क्लिपबोर्ड पर कॉपी करें:", "instruction3": "अपने वेब ब्राउज़र में निम्नलिखित लिंक पर जाएँ, और उपरोक्त कोड दर्ज करें", "instruction4": "साइनअप पूरा होने पर नीचे दिया गया बटन दबाएँ:" } }, "settings": { "title": "सेटिंग्स", "menu": { "appearance": "दृश्य", "language": "भाषा", "user": "उपयोगकर्ता", "files": "फ़ाइलें", "open": "सेटिंग्स खोलें", "logout": "लॉगआउट", "logoutPrompt": "क्या आप निश्चित रूप से लॉगआउट करना चाहते हैं?", "selfEncryptionLogoutPrompt": "क्या आप वाकई लॉग आउट करना चाहते हैं? कृपया सुनिश्चित करें कि आपने एन्क्रिप्शन रहस्य की कॉपी बना ली है", "syncSetting": "सिंक सेटिंग", "enableSync": "सिंक इनेबल करें", "enableEncrypt": "डेटा एन्क्रिप्ट करें", "enableEncryptPrompt": "इस रहस्य के साथ अपने डेटा को सुरक्षित करने के लिए एन्क्रिप्शन सक्रिय करें। इसे सुरक्षित रूप से संग्रहीत करें; एक बार सक्षम होने के बाद, इसे बंद नहीं किया जा सकता है। यदि खो जाता है, तो आपका डेटा पुनर्प्राप्त नहीं किया जा सकता है। कॉपी करने के लिए क्लिक करें", "inputEncryptPrompt": "कृपया अपना एन्क्रिप्शन रहस्य दर्ज करें", "clickToCopySecret": "गुप्त कॉपी बनाने के लिए क्लिक करें", "inputTextFieldHint": "आपका रहस्य", "historicalUserList": "उपयोगकर्ता लॉगिन इतिहास", "historicalUserListTooltip": "यह सूची आपके अज्ञात खातों को प्रदर्शित करती है। आप किसी खाते का विवरण देखने के लिए उस पर क्लिक कर सकते हैं। 'आरंभ करें' बटन पर क्लिक करके अज्ञात खाते बनाए जाते हैं", "openHistoricalUser": "अज्ञात खाता खोलने के लिए क्लिक करें" }, "appearance": { "resetSetting": "इस सेटिंग को रीसेट करें", "fontFamily": { "label": "फ़ॉन्ट फॅमिली", "search": "खोजें" }, "themeMode": { "label": "थीम मोड", "light": "लाइट मोड", "dark": "डार्क मोड", "system": "सिस्टम के अनुसार अनुकूलित करें" }, "layoutDirection": { "label": "लेआउट दिशा", "hint": "अपनी स्क्रीन पर सामग्री के प्रवाह को बाएँ से दाएँ या दाएँ से बाएँ नियंत्रित करें।", "ltr": "एलटीआर", "rtl": "आरटीएल" }, "textDirection": { "label": "डिफ़ॉल्ट वाक्य दिशा", "hint": "निर्दिष्ट करें कि वाक्य को डिफ़ॉल्ट के रूप में बाएँ या दाएँ से प्रारंभ करना चाहिए।", "ltr": "एलटीआर", "rtl": "आरटीएल", "auto": "ऑटो", "fallback": "लेआउट दिशा के समान" }, "themeUpload": { "button": "अपलोड करें", "uploadTheme": "थीम अपलोड करें", "description": "नीचे दिए गए बटन का उपयोग करके अपनी खुद की AppFlowy थीम अपलोड करें।", "failure": "जो थीम अपलोड किया गया था उसका प्रारूप अमान्य था।", "loading": "कृपया तब तक प्रतीक्षा करें जब तक हम आपकी थीम को सत्यापित और अपलोड नहीं कर देते...", "uploadSuccess": "आपका थीम सफलतापूर्वक अपलोड किया गया", "deletionFailure": "थीम को हटाने में विफल। इसे मैन्युअल रूप से हटाने का प्रयास करें।", "filePickerDialogTitle": "एक .flowy_plugin फ़ाइल चुनें", "urlUploadFailure": "URL खोलने में विफल: {}" }, "theme": "थीम", "builtInsLabel": "डिफ़ॉल्ट थीम", "pluginsLabel": "प्लगइन्स", "showNamingDialogWhenCreatingPage": "पेज बनाते समय उसका नाम लेने के लिए डायलॉग देखे" }, "files": { "copy": "कॉपी करें", "defaultLocation": "फ़ाइलें और डेटा संग्रहण स्थान पढ़ें", "exportData": "अपना डेटा निर्यात करें", "doubleTapToCopy": "पथ को कॉपी करने के लिए दो बार टैप करें", "restoreLocation": "AppFlowy डिफ़ॉल्ट पथ पर रीस्टार्ट करें", "customizeLocation": "कोई अन्य फ़ोल्डर खोलें", "restartApp": "परिवर्तनों को प्रभावी बनाने के लिए कृपया ऐप को रीस्टार्ट करें।", "exportDatabase": "डेटाबेस निर्यात करें", "selectFiles": "उन फ़ाइलों का चयन करें जिन्हें निर्यात करने की आवश्यकता है", "selectAll": "सभी का चयन करें", "deselectAll": "सभी को अचयनित करें", "createNewFolder": "एक नया फ़ोल्डर बनाएँ", "createNewFolderDesc": "हमें बताएं कि आप अपना डेटा कहां संग्रहीत करना चाहते हैं", "defineWhereYourDataIsStored": "परिभाषित करें कि आपका डेटा कहाँ संग्रहीत है", "open": "खोलें", "openFolder": "मौजूदा फ़ोल्डर खोलें", "openFolderDesc": "इसे पढ़ें और इसे अपने मौजूदा AppFlowy फ़ोल्डर में लिखें", "folderHintText": "फ़ोल्डर का नाम", "location": "एक नया फ़ोल्डर बनाना", "locationDesc": "अपने AppFlowy डेटा फ़ोल्डर के लिए एक नाम चुनें", "browser": "ब्राउज़ करें", "create": "बनाएँ", "set": "सेट", "folderPath": "आपके फ़ोल्डर को संग्रहीत करने का पथ", "locationCannotBeEmpty": "पथ खाली नहीं हो सकता", "pathCopiedSnackbar": "फ़ाइल संग्रहण पथ क्लिपबोर्ड पर कॉपी किया गया!", "changeLocationTooltips": "डेटा निर्देशिका बदलें", "change": "परिवर्तन", "openLocationTooltips": "अन्य डेटा निर्देशिका खोलें", "openCurrentDataFolder": "वर्तमान डेटा निर्देशिका खोलें", "recoverLocationTooltips": "AppFlowy की डिफ़ॉल्ट डेटा निर्देशिका पर रीसेट करें", "exportFileSuccess": "फ़ाइल सफलतापूर्वक निर्यात हुई", "exportFileFail": "फ़ाइल निर्यात विफल रहा!", "export": "निर्यात" }, "user": { "name": "नाम", "email": "ईमेल", "tooltipSelectIcon": "आइकन चुनें", "selectAnIcon": "एक आइकन चुनें", "pleaseInputYourOpenAIKey": "कृपया अपनी AI key इनपुट करें", "clickToLogout": "वर्तमान उपयोगकर्ता को लॉगआउट करने के लिए क्लिक करें" }, "shortcuts": { "shortcutsLabel": "शॉर्टकट", "command": "कमांड", "keyBinding": "कीबाइंडिंग", "addNewCommand": "नया कमांड जोड़ें", "updateShortcutStep": "इच्छित key संयोजन दबाएँ और ENTER दबाएँ", "shortcutIsAlreadyUsed": "यह शॉर्टकट पहले से ही इसके लिए उपयोग किया जा चुका है: {conflict}", "resetToDefault": "डिफ़ॉल्ट कीबाइंडिंग पर रीसेट करें", "couldNotLoadErrorMsg": "शॉर्टकट लोड नहीं हो सका, पुनः प्रयास करें", "couldNotSaveErrorMsg": "शॉर्टकट सेव नहीं किये जा सके, पुनः प्रयास करें" } }, "grid": { "deleteView": "क्या आप वाकई इस दृश्य को हटाना चाहते हैं?", "createView": "नया", "title": { "placeholder": "शीर्षकहीन" }, "settings": { "filter": "फ़िल्टर", "sort": "क्रमबद्ध करें", "sortBy": "क्रमबद्ध करें", "properties": "गुण", "reorderPropertiesTooltip": "गुणों को पुनः व्यवस्थित करने के लिए खींचें", "group": "समूह", "addFilter": "फ़िल्टर करें...", "deleteFilter": "फ़िल्टर हटाएँ", "filterBy": "फ़िल्टरबाय...", "typeAValue": "एक वैल्यू टाइप करें...", "layout": "लेआउट", "databaseLayout": "लेआउट" }, "textFilter": { "contains": "शामिल है", "doesNotContain": "इसमें शामिल नहीं है", "endsWith": "समाप्त होता है", "startWith": "से प्रारंभ होता है", "is": "है", "isNot": "नहीं है", "isEmpty": "खाली है", "isNotEmpty": "खाली नहीं है", "choicechipPrefix": { "isNot": "नहीं है", "startWith": "से प्रारंभ होता है", "endWith": "के साथ समाप्त होता है", "isEmpty": "खाली है", "isNotEmpty": "खाली नहीं है" } }, "checkboxFilter": { "isChecked": "चेक किया गया", "isUnchecked": "अनचेक किया हुआ", "choicechipPrefix": { "is": "है" } }, "checklistFilter": { "isComplete": "पूर्ण है", "isIncomplted": "अपूर्ण है" }, "selectOptionFilter": { "is": "है", "isNot": "नहीं है", "contains": "शामिल है", "doesNotContain": "इसमें शामिल नहीं है", "isEmpty": "खाली है", "isNotEmpty": "खाली नहीं है" }, "field": { "hide": "छिपाएँ", "insertLeft": "बायाँ सम्मिलित करें", "insertRight": "दाएँ सम्मिलित करें", "duplicate": "डुप्लिकेट", "delete": "हटाएं", "textFieldName": "लेख", "checkboxFieldName": "चेकबॉक्स", "dateFieldName": "दिनांक", "updatedAtFieldName": "अंतिम संशोधित समय", "createdAtFieldName": "बनाने का समय", "numberFieldName": "संख्या", "singleSelectFieldName": "चुनाव", "multiSelectFieldName": "बहु चुनाव", "urlFieldName": "URL", "checklistFieldName": "चेकलिस्ट", "numberFormat": "संख्या प्रारूप", "dateFormat": "दिनांक प्रारूप", "includeTime": "समय शामिल करें", "isRange": "अंतिम तिथि", "dateFormatFriendly": "माह दिन, वर्ष", "dateFormatISO": "वर्ष-महीना-दिन", "dateFormatLocal": "महीना/दिन/वर्ष", "dateFormatUS": "वर्ष/महीना/दिन", "dateFormatDayMonthYear": "दिन/माह/वर्ष", "timeFormat": "समय प्रारूप", "invalidTimeFormat": "अमान्य प्रारूप", "timeFormatTwelveHour": "१२ घंटा", "timeFormatTwentyFourHour": "२४ घंटे", "clearDate": "तिथि मिटाए", "addSelectOption": "एक विकल्प जोड़ें", "optionTitle": "विकल्प", "addOption": "विकल्प जोड़ें", "editProperty": "डेटा का प्रकार संपादित करें", "newProperty": "नया डेटा का प्रकार", "deleteFieldPromptMessage": "क्या आप निश्चित हैं? यह डेटा का प्रकार हटा दी जाएगी", "newColumn": "नया कॉलम" }, "sort": { "ascending": "असेंडिंग", "descending": "डिसेंडिंग", "deleteAllSorts": "सभी प्रकार हटाएँ", "addSort": "सॉर्ट जोड़ें" }, "row": { "duplicate": "डुप्लिकेट", "delete": "डिलीट", "titlePlaceholder": "शीर्षकहीन", "textPlaceholder": "रिक्त", "copyProperty": "डेटा के प्रकार को क्लिपबोर्ड पर कॉपी किया गया", "count": "गिनती", "newRow": "नई पंक्ति", "action": "कार्रवाई", "add": "नीचे जोड़ें पर क्लिक करें", "drag": "स्थानांतरित करने के लिए खींचें" }, "selectOption": { "create": "बनाएँ", "purpleColor": "बैंगनी", "pinkColor": "गुलाबी", "lightPinkColor": "हल्का गुलाबी", "orangeColor": "नारंगी", "yellowColor": "पीला", "limeColor": "नींबू", "greenColor": "हरा", "aquaColor": "एक्वा", "blueColor": "नीला", "deleteTag": "टैग हटाएँ", "colorPanelTitle": "रंग", "panelTitle": "एक विकल्प चुनें या एक बनाएं", "searchOption": "एक विकल्प खोजें", "searchOrCreateOption": "कोई विकल्प खोजें या बनाएँ...", "createNew": "एक नया बनाएँ", "orSelectOne": "या एक विकल्प चुनें" }, "checklist": { "taskHint": "कार्य विवरण", "addNew": "एक नया कार्य जोड़ें", "submitNewTask": "बनाएँ" }, "menuName": "ग्रिड", "referencedGridPrefix": "का दृश्य" }, "document": { "menuName": "दस्तावेज़ ", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "लिंक करने के लिए एक बोर्ड चुनें", "createANewBoard": "एक नया बोर्ड बनाएं" }, "grid": { "selectAGridToLinkTo": "लिंक करने के लिए एक ग्रिड चुनें", "createANewGrid": "एक नया ग्रिड बनाएं" }, "calendar": { "selectACalendarToLinkTo": "लिंक करने के लिए एक कैलेंडर चुनें", "createANewCalendar": "एक नया कैलेंडर बनाएं" } }, "selectionMenu": { "outline": "रूपरेखा", "codeBlock": "कोड ब्लॉक" }, "plugins": { "referencedBoard": "रेफेरेंस बोर्ड", "referencedGrid": "रेफेरेंस ग्रिड", "referencedCalendar": "रेफेरेंस कैलेंडर", "autoGeneratorMenuItemName": "AI लेखक", "autoGeneratorTitleName": "AI: AI को कुछ भी लिखने के लिए कहें...", "autoGeneratorLearnMore": "और जानें", "autoGeneratorGenerate": "उत्पन्न करें", "autoGeneratorHintText": "AI से पूछें...", "autoGeneratorCantGetOpenAIKey": "AI key नहीं मिल सकी", "autoGeneratorRewrite": "पुनः लिखें", "smartEdit": "AI सहायक", "aI": "AI", "smartEditFixSpelling": "वर्तनी ठीक करें", "warning": "⚠️ AI प्रतिक्रियाएँ गलत या भ्रामक हो सकती हैं।", "smartEditSummarize": "सारांश", "smartEditImproveWriting": "लेख में सुधार करें", "smartEditMakeLonger": "लंबा बनाएं", "smartEditCouldNotFetchResult": "AI से परिणाम प्राप्त नहीं किया जा सका", "smartEditCouldNotFetchKey": "AI key नहीं लायी जा सकी", "smartEditDisabled": "सेटिंग्स में AI कनेक्ट करें", "discardResponse": "क्या आप AI प्रतिक्रियाओं को छोड़ना चाहते हैं?", "createInlineMathEquation": "समीकरण बनाएं", "toggleList": "सूची टॉगल करें", "cover": { "changeCover": "कवर बदलें", "colors": "रंग", "images": "छवियां", "clearAll": "सभी साफ़ करें", "abstract": "सार", "addCover": "कवर जोड़ें", "addLocalImage": "स्थानीय छवि जोड़ें", "invalidImageUrl": "अमान्य छवि URL", "failedToAddImageToGallery": "गैलरी में छवि जोड़ने में विफल", "enterImageUrl": "छवि URL दर्ज करें", "add": "जोड़ें", "back": "पीछे", "saveToGallery": "गैलरी में सेव करे", "removeIcon": "आइकन हटाएँ", "pasteImageUrl": "छवि URL चिपकाएँ", "or": "या", "pickFromFiles": "फ़ाइलों में से चुनें", "couldNotFetchImage": "छवि नहीं लाया जा सका", "imageSavingFailed": "छवि सहेजना विफल", "addIcon": "आइकन जोड़ें", "coverRemoveAlert": "हटाने के बाद इसे कवर से हटा दिया जाएगा।", "alertDialogConfirmation": "क्या आप निश्चित हैं, आप जारी रखना चाहते हैं?" }, "mathEquation": { "addMathEquation": "गणित समीकरण जोड़ें", "editMathEquation": "गणित समीकरण संपादित करें" }, "optionAction": { "click": "क्लिक करें", "toOpenMenu": "मेनू खोलने के लिए", "delete": "हटाएं", "duplicate": "डुप्लिकेट", "turnInto": "टर्नइनटू", "moveUp": "ऊपर बढ़ें", "moveDown": "नीचे जाएँ", "color": "रंग", "align": "संरेखित करें", "left": "बांया", "center": "केंद्र", "right": "सही", "defaultColor": "डिफ़ॉल्ट" }, "image": { "copiedToPasteBoard": "छवि लिंक को क्लिपबोर्ड पर कॉपी कर दिया गया है" }, "outline": { "addHeadingToCreateOutline": "सामग्री की तालिका बनाने के लिए शीर्षक जोड़ें।" }, "table": { "addAfter": "बाद में जोड़ें", "addBefore": "पहले जोड़ें", "delete": "हटाएं", "clear": "साफ़ करें", "duplicate": "डुप्लिकेट", "bgColor": "पृष्ठभूमि रंग" }, "contextMenu": { "copy": "कॉपी करें", "cut": "कट करे", "paste": "पेस्ट करें" } }, "textBlock": { "placeholder": "कमांड के लिए '/' टाइप करें" }, "title": { "placeholder": "शीर्षकहीन" }, "imageBlock": { "placeholder": "छवि जोड़ने के लिए क्लिक करें", "अपलोड करें": { "label": "अपलोड करें", "placeholder": "छवि अपलोड करने के लिए क्लिक करें" }, "url": { "label": "छवि URL ", "placeholder": "छवि URL दर्ज करें" }, "support": "छवि आकार सीमा 5 एमबी है। समर्थित प्रारूप: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "अमान्य छवि", "invalidImageSize": "छवि का आकार 5MB से कम होना चाहिए", "invalidImageFormat": "छवि प्रारूप समर्थित नहीं है। समर्थित प्रारूप: JPEG, PNG, GIF, SVG", "invalidImageUrl": "अमान्य छवि URL" } }, "codeBlock": { "language": { "label": "भाषा", "placeholder": "भाषा चुनें" } }, "inlineLink": { "placeholder": "लिंक चिपकाएँ या टाइप करें", "openInNewTab": "नए टैब में खोलें", "copyLink": "लिंक कॉपी करें", "removeLink": "लिंक हटाएँ", "url": { "label": "लिंक URL", "placeholder": "लिंक URL दर्ज करें" }, "title": { "label": "लिंक शीर्षक", "placeholder": "लिंक शीर्षक दर्ज करें" } }, "mention": { "placeholder": "किसी व्यक्ति या पेज या दिनांक का उल्लेख करें...", "page": { "label": "पेज से लिंक करें", "tooltip": "पेज खोलने के लिए क्लिक करें" } }, "toolbar": { "resetToDefaultFont": "डिफ़ॉल्ट पर रीसेट करें" } }, "board": { "column": { "createNewCard": "नया" }, "menuName": "बोर्ड", "referencedBoardPrefix": "का दृश्य" }, "calendar": { "menuName": "कैलेंडर", "defaultNewCalendarTitle": "शीर्षकहीन", "newEventButtonTooltip": "एक नया ईवेंट जोड़ें", "navigation": { "today": "आज", "jumpToday": "जम्प टू टुडे", "previousMonth": "पिछला महीना", "nextMonth": "अगले महीने" }, "settings": { "showWeekNumbers": "सप्ताह संख्याएँ दिखाएँ", "showWeekends": "सप्ताहांत दिखाएँ", "firstDayOfWeek": "सप्ताह प्रारंभ करें", "layoutDateField": "लेआउट कैलेंडर", "noDateTitle": "कोई दिनांक नहीं", "noDateHint": "अनिर्धारित घटनाएँ यहाँ दिखाई देंगी", "clickToAdd": "कैलेंडर में जोड़ने के लिए क्लिक करें", "name": "कैलेंडर लेआउट" }, "referencedCalendarPrefix": "का दृश्य" }, "errorDialog": { "title": "AppFlowy error", "howToFixFallback": "असुविधा के लिए हमें खेद है! हमारे GitHub पेज पर एक मुद्दा सबमिट करें जो आपकी error का वर्णन करता है।", "github": "GitHub पर देखें " }, "search": { "label": "खोजें", "placeholder": { "actions": "खोज क्रियाएँ..." } }, "message": { "copy": { "success": "कॉपी सफलता पूर्ण हुआ!", "fail": "कॉपी करने में असमर्थ" } }, "unSupportBlock": "वर्तमान संस्करण इस ब्लॉक का समर्थन नहीं करता है।", "views": { "deleteContentTitle": "क्या आप वाकई {pageType} को हटाना चाहते हैं?", "deleteContentCaption": "यदि आप इस {pageType} को हटाते हैं, तो आप इसे ट्रैश से पुनर्स्थापित कर सकते हैं।" }, "colors": { "custom": "कस्टम", "default": "डिफ़ॉल्ट", "red": "लाल", "orange": "नारंगी", "yellow": "पीला", "green": "हरा", "blue": "नीला", "purple": "बैंगनी", "pink": "गुलाबी", "brown": "भूरा", "gray": "ग्रे" }, "emoji": { "filter": "फ़िल्टर", "random": "रैंडम", "selectSkinTone": "त्वचा का रंग चुनें", "remove": "इमोजी हटाएं", "categories": { "smileys": "स्माइलीज़ एंड इमोशन", "people": "लोग और शरीर", "animals": "जानवर और प्रकृति", "food": "खाद्य और पेय", "activities": "गतिविधियाँ", "places": "यात्रा एवं स्थान", "objects": "ऑब्जेक्ट्स", "symbols": "प्रतीक", "flags": "झंडे", "nature": "प्रकृति", "frequentlyUsed": "अक्सर उपयोग किया जाता है" } } } ================================================ FILE: frontend/resources/translations/hu-HU.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Én", "welcomeText": "Üdvözöl az @:appName", "githubStarText": "GitHub csillagozás", "subscribeNewsletterText": "Iratkozzon fel a hírlevelünkre", "letsGoButtonText": "Vágjunk bele", "title": "Cím", "youCanAlso": "Te is", "and": "és", "blockActions": { "addBelowTooltip": "Kattintson a hozzáadáshoz lent", "addAboveCmd": "Alt+kattintás", "addAboveMacCmd": "Option+kattintás", "addAboveTooltip": "a fentiek hozzáadásához" }, "signUp": { "buttonText": "Regisztráció", "title": "Regisztrálj az @:appName -ra", "getStartedText": "Kezdés", "emptyPasswordError": "A jelszó nem lehet üres", "repeatPasswordEmptyError": "A jelszó megerősítése nem lehet üres", "unmatchedPasswordError": "A jelszavak nem egyeznek", "alreadyHaveAnAccount": "Rendelkezik már fiókkal?", "emailHint": "Email", "passwordHint": "Jelszó", "repeatPasswordHint": "Jelszó megerősítése" }, "signIn": { "loginTitle": "Bejelentkezés az @:appName -ba", "loginButtonText": "Belépés", "buttonText": "Bejelentkezés", "forgotPassword": "Elfelejtett jelszó?", "emailHint": "Email", "passwordHint": "Jelszó", "dontHaveAnAccount": "Még nincs fiókod?", "repeatPasswordEmptyError": "A jelszó megerősítése nem lehet üres", "unmatchedPasswordError": "A jelszavak nem egyeznek", "loginAsGuestButtonText": "Fogj neki" }, "workspace": { "create": "Új munkaterület létrehozása", "hint": "munkaterület", "notFoundError": "munkaterület nem található" }, "shareAction": { "buttonText": "Megosztás", "workInProgress": "Hamarosan érkezik...", "markdown": "Markdown", "copyLink": "Link másolása" }, "moreAction": { "small": "kicsi", "medium": "közepes", "large": "nagy", "fontSize": "Betűméret", "import": "Importálás", "moreOptions": "Több lehetőség" }, "importPanel": { "textAndMarkdown": "Szöveg & Markdown", "documentFromV010": "Dokumentum a 0.1.0 verzióból", "databaseFromV010": "Adatbázis a 0.1.0 verziótól", "csv": "CSV", "database": "Adatbázis" }, "disclosureAction": { "rename": "Átnevezés", "delete": "Törlés", "duplicate": "Duplikálás", "openNewTab": "Nyissa meg egy új lapon" }, "blankPageTitle": "Üres oldal", "newPageText": "Új oldal", "newDocumentText": "Új dokumentum", "newGridText": "Új táblázat", "newCalendarText": "Új naptár", "newBoardText": "Új feladat tábla", "trash": { "text": "Kuka", "restoreAll": "Összes visszaállítása", "deleteAll": "Összes törlése", "pageHeader": { "fileName": "Fájlnév", "lastModified": "Utoljára módosítva", "created": "Létrehozva" }, "confirmDeleteAll": { "title": "Biztosan törli a kukában lévő összes oldalt?", "caption": "Ez a művelet nem visszavonható." }, "confirmRestoreAll": { "title": "Biztosan visszaállítja a kukában lévő összes oldalt?", "caption": "Ez a művelet nem visszavonható." } }, "deletePagePrompt": { "text": "Ez az oldal a kukában van", "restore": "Oldal visszaállítása", "deletePermanent": "Végleges törlés" }, "dialogCreatePageNameHint": "Oldalnév", "questionBubble": { "shortcuts": "Parancsikonok", "whatsNew": "Újdonságok", "markdown": "Markdown", "debug": { "name": "Debug Információ", "success": "Debug információ a vágólapra másolva", "fail": "A Debug információ nem másolható a vágólapra" }, "feedback": "Visszacsatolás", "help": "Segítség & Támogatás" }, "menuAppHeader": { "addPageTooltip": "Belső oldal hozzáadása", "defaultNewPageName": "Névtelen", "renameDialog": "Átnevezés" }, "toolbar": { "undo": "Vissza", "redo": "Előre", "bold": "Félkövér", "italic": "Dőlt", "underline": "Aláhúzott", "strike": "Áthúzott", "numList": "Számozott lista", "bulletList": "Felsorolás", "checkList": "Ellenőrző lista", "inlineCode": "Inline kód", "quote": "Idézet", "header": "Címsor", "highlight": "Kiemelés", "color": "Szín", "addLink": "Link hozzáadása", "link": "Link" }, "tooltip": { "lightMode": "Világos mód", "darkMode": "Éjjeli mód", "openAsPage": "Megnyitás oldalként", "addNewRow": "Új sor hozzáadása", "openMenu": "Kattintson a menü megnyitásához", "dragRow": "Nyomja meg hosszan a sor átrendezéséhez", "viewDataBase": "Adatbázis megtekintése", "referencePage": "Erre a(z) {name} címre hivatkoznak", "addBlockBelow": "Adjon hozzá egy blokkot alább" }, "sideBar": { "closeSidebar": "Close sidebar", "openSidebar": "Open sidebar" }, "notifications": { "export": { "markdown": "Megjegyzés exportálva a Markdownba", "path": "Dokumentumok/flowy" } }, "contactsPage": { "title": "Kontaktok", "whatsHappening": "Heti újdonságok", "addContact": "Új Kontakt", "editContact": "Kontakt Szerkesztése" }, "button": { "ok": "OK", "done": "Kész", "cancel": "Mégse", "signIn": "Bejelentkezés", "signOut": "Kijelentkezés", "complete": "Kész", "save": "Mentés", "generate": "generál", "esc": "KILÉPÉS", "keep": "Tart", "tryAgain": "Próbáld újra", "discard": "Eldobni", "replace": "Cserélje ki", "insertBelow": "Beszúrás alább", "upload": "Feltöltés", "edit": "Szerkesztés", "delete": "Töröl", "duplicate": "Másolat", "putback": "Visszatesz" }, "label": { "welcome": "Üdvözlünk!", "firstName": "Keresztnév", "middleName": "Középső név", "lastName": "Vezetéknév", "stepX": "{X}. lépés" }, "oAuth": { "err": { "failedTitle": "Sikertelen bejelentkezés.", "failedMsg": "Kérjük győződjön meg róla, hogy elvégezte a bejelentkezési folyamatot a böngészőben!" }, "google": { "title": "Bejelentkezés Google-lal", "instruction1": "Ahhoz, hogy hozzáférjen a Google Kontaktjaihoz, kérjük hatalmazza fel ezt az alkalmazást a böngészőben.", "instruction2": "Másolja ezt a kódot a vágólapra az ikonra kattintással vagy a szöveg kijelölésével:", "instruction3": "Nyissa meg ezt a linket a böngészőben, és írja be a fenti kódot:", "instruction4": "Nyomja meg az alábbi gombot, ha elvégezte a regisztrációt:" } }, "settings": { "title": "Beállítások", "menu": { "appearance": "Megjelenés", "language": "Nyelv", "user": "Felhasználó", "files": "Fájlok", "open": "Beállítások megnyitása" }, "appearance": { "fontFamily": { "label": "Betűtípus család", "search": "Keresés" }, "themeMode": { "label": "Téma", "light": "Világos mód", "dark": "Éjjeli mód", "system": "Rendszerbeállítás követése" }, "themeUpload": { "button": "Feltöltés", "description": "Töltse fel saját @:appName témáját az alábbi gomb segítségével.", "loading": "Kérjük, várjon, amíg ellenőrizzük és feltöltjük a témát...", "uploadSuccess": "A témát sikeresen feltöltötte", "deletionFailure": "Nem sikerült törölni a témát. Próbálja meg manuálisan törölni.", "filePickerDialogTitle": "Válasszon egy .flowy_plugin fájlt", "urlUploadFailure": "Nem sikerült megnyitni az URL-t: {}", "failure": "A feltöltött téma formátuma érvénytelen." }, "theme": "Téma", "builtInsLabel": "Beépített témák", "pluginsLabel": "Beépülő modulok" }, "files": { "copy": "Másolat", "defaultLocation": "Fájlok és adattárolási hely olvasása", "exportData": "Exportálja adatait", "doubleTapToCopy": "Koppintson duplán az útvonal másolásához", "restoreLocation": "Visszaállítás az @:appName alapértelmezett elérési útjára", "customizeLocation": "Nyisson meg egy másik mappát", "restartApp": "Kérjük, indítsa újra az alkalmazást, hogy a változtatások életbe lépjenek.", "exportDatabase": "Adatbázis exportálása", "selectFiles": "Válassza ki az exportálandó fájlokat", "selectAll": "Mindet kiválaszt", "deselectAll": "Törölje az összes kijelölését", "createNewFolder": "Hozzon létre egy új mappát", "createNewFolderDesc": "Mondja el, hol szeretné tárolni adatait", "defineWhereYourDataIsStored": "Határozza meg, hol tárolják adatait", "open": "Nyisd ki", "openFolder": "Nyisson meg egy meglévő mappát", "openFolderDesc": "Olvassa el és írja be a meglévő @:appName mappájába", "folderHintText": "mappa neve", "location": "Új mappa létrehozása", "locationDesc": "Válasszon nevet az @:appName adatmappájának", "browser": "Tallózás", "create": "Teremt", "set": "Készlet", "folderPath": "A mappa tárolásának elérési útja", "locationCannotBeEmpty": "Az útvonal nem lehet üres", "pathCopiedSnackbar": "A fájl tárolási útvonala a vágólapra másolva!", "changeLocationTooltips": "Módosítsa az adatkönyvtárat", "change": "változás", "openLocationTooltips": "Nyisson meg egy másik adatkönyvtárat", "openCurrentDataFolder": "Nyissa meg az aktuális adatkönyvtárat", "recoverLocationTooltips": "Állítsa vissza az @:appName alapértelmezett adatkönyvtárát", "exportFileSuccess": "A fájl exportálása sikeres volt!", "exportFileFail": "A fájl exportálása nem sikerült!", "export": "Export" }, "user": { "name": "Név", "selectAnIcon": "Válasszon ki egy ikont", "pleaseInputYourOpenAIKey": "kérjük, adja meg AI kulcsát" } }, "grid": { "deleteView": "Biztosan törli ezt a nézetet?", "createView": "Új", "settings": { "filter": "Szűrő", "sort": "Fajta", "sortBy": "Rendezés", "properties": "Tulajdonságok", "reorderPropertiesTooltip": "Húzza a tulajdonságok átrendezéséhez", "group": "Csoport", "addFilter": "Szűrő hozzáadása", "deleteFilter": "Szűrő törlése", "filterBy": "Szűrés vlami alapján...", "typeAValue": "Írjon be egy értéket...", "layout": "Elrendezés", "databaseLayout": "Elrendezés" }, "textFilter": { "contains": "Tartalmaz", "doesNotContain": "Nem tartalmaz", "endsWith": "Végződik", "startWith": "Ezzel kezdődik", "is": "Is", "isNot": "Nem", "isEmpty": "Üres", "isNotEmpty": "Nem üres", "choicechipPrefix": { "isNot": "Nem", "startWith": "Ezzel kezdődik", "endWith": "Végződik", "isEmpty": "üres", "isNotEmpty": "nem üres" } }, "checkboxFilter": { "isChecked": "Ellenőrizve", "isUnchecked": "Nincs bejelölve", "choicechipPrefix": { "is": "van" } }, "checklistFilter": { "isComplete": "teljes", "isIncomplted": "hiányos" }, "selectOptionFilter": { "is": "Is", "isNot": "Nem", "contains": "Tartalmaz", "doesNotContain": "Nem tartalmaz", "isEmpty": "Üres", "isNotEmpty": "Nem üres" }, "field": { "hide": "Elrejt", "insertLeft": "Beszúrás balra", "insertRight": "Jobb beszúrás", "duplicate": "Másolat", "delete": "Töröl", "textFieldName": "Szöveg", "checkboxFieldName": "Jelölőnégyzet", "dateFieldName": "Dátum", "updatedAtFieldName": "Utolsó módosítás időpontja", "createdAtFieldName": "Létrehozott idő", "numberFieldName": "Számok", "singleSelectFieldName": "Válassza ki", "multiSelectFieldName": "Többszörös választás", "urlFieldName": "URL", "checklistFieldName": "Ellenőrzőlista", "numberFormat": "Számformátum", "dateFormat": "Dátum formátum", "includeTime": "Tartalmazzon időt", "dateFormatFriendly": "Hónap nap év", "dateFormatISO": "Év hónap nap", "dateFormatLocal": "Hónap nap év", "dateFormatUS": "Év hónap nap", "dateFormatDayMonthYear": "Nap hónap év", "timeFormat": "Idő formátum", "invalidTimeFormat": "Érvénytelen formátum", "timeFormatTwelveHour": "12 óra", "timeFormatTwentyFourHour": "24 óra", "addSelectOption": "Adjon hozzá egy lehetőséget", "optionTitle": "Lehetőségek", "addOption": "Opció hozzáadása", "editProperty": "Tulajdonság szerkesztése", "newProperty": "Új tulajdonság", "deleteFieldPromptMessage": "Biztos ebben? Ez a tulajdonság törlésre kerül" }, "sort": { "ascending": "Emelkedő", "descending": "Csökkenő", "addSort": "Rendezés hozzáadása", "deleteSort": "Rendezés törlése" }, "row": { "duplicate": "Másolat", "delete": "Töröl", "textPlaceholder": "Üres", "copyProperty": "Tulajdonság a vágólapra másolva", "count": "Számol", "newRow": "Új sor", "action": "Akció" }, "selectOption": { "create": "Teremt", "purpleColor": "Lila", "pinkColor": "Rózsaszín", "lightPinkColor": "Világos rózsaszín", "orangeColor": "narancs", "yellowColor": "Sárga", "limeColor": "Mész", "greenColor": "Zöld", "aquaColor": "Aqua", "blueColor": "Kék", "deleteTag": "Címke törlése", "colorPanelTitle": "Színek", "panelTitle": "Válasszon egy lehetőséget, vagy hozzon létre egyet", "searchOption": "Keressen egy lehetőséget" }, "checklist": { "addNew": "Adjon hozzá egy elemet" }, "menuName": "Táblázat", "referencedGridPrefix": "Nézet" }, "document": { "menuName": "Dokumentum", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Válassza ki azt a feladat táblát, amelyre hivatkozni szeretne", "createANewBoard": "Hozzon létre egy új feladat táblát" }, "grid": { "selectAGridToLinkTo": "Válassza ki azt a táblázatot, amelyre hivatkozni szeretne", "createANewGrid": "Hozzon létre egy új táblázatot" }, "calendar": { "selectACalendarToLinkTo": "Válasszon egy naptárt a hivatkozáshoz", "createANewCalendar": "Hozzon létre egy új naptárat" } }, "selectionMenu": { "outline": "Vázlat" }, "plugins": { "referencedBoard": "Hivatkozott feladat tábla", "referencedGrid": "Hivatkozott táblázat", "referencedCalendar": "Hivatkozott naptár", "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Kérd meg az AI-t, hogy írjon bármit...", "autoGeneratorLearnMore": "Tudj meg többet", "autoGeneratorGenerate": "generál", "autoGeneratorHintText": "Kérdezd meg az AI-t...", "autoGeneratorCantGetOpenAIKey": "Nem lehet beszerezni az AI kulcsot", "autoGeneratorRewrite": "Újraírni", "smartEdit": "AI asszisztensek", "aI": "AI", "smartEditFixSpelling": "Helyesírás javítása", "warning": "⚠️ Az AI-válaszok pontatlanok vagy félrevezetőek lehetnek.", "smartEditSummarize": "Összesít", "smartEditImproveWriting": "Az írás javítása", "smartEditMakeLonger": "Hosszabb legyen", "smartEditCouldNotFetchResult": "Nem sikerült lekérni az eredményt az AI-ból", "smartEditCouldNotFetchKey": "Nem sikerült lekérni az AI kulcsot", "smartEditDisabled": "Csatlakoztassa az AI-t a Beállításokban", "discardResponse": "El szeretné vetni az AI-válaszokat?", "createInlineMathEquation": "Hozzon létre egyenletet", "toggleList": "Lista váltása", "cover": { "changeCover": "Borítót változatni", "colors": "Színek", "images": "Képek", "clearAll": "Mindent kitöröl", "abstract": "Absztrakt", "addCover": "Fedő hozzáadása", "addLocalImage": "Helyi kép hozzáadása", "invalidImageUrl": "Érvénytelen kép URL-je", "failedToAddImageToGallery": "Nem sikerült hozzáadni a képet a galériához", "enterImageUrl": "Adja meg a kép URL-jét", "add": "Hozzáadás", "back": "Vissza", "saveToGallery": "Mentés a galériába", "removeIcon": "Ikon eltávolítása", "pasteImageUrl": "Illessze be a kép URL-jét", "or": "VAGY", "pickFromFiles": "Válasszon fájlokból", "couldNotFetchImage": "Nem sikerült lekérni a képet", "imageSavingFailed": "A kép mentése nem sikerült", "addIcon": "Ikon hozzáadása", "coverRemoveAlert": "A törlés után eltávolítjuk a borítóról.", "alertDialogConfirmation": "Biztos vagy benne, hogy folytatni akarod?" }, "mathEquation": { "addMathEquation": "Adja hozzá a matematikai egyenletet", "editMathEquation": "Matematikai egyenlet szerkesztése" }, "optionAction": { "click": "Kattintson", "toOpenMenu": " menü megnyitásához", "delete": "Töröl", "duplicate": "Másolat", "turnInto": "Válik", "moveUp": "Lépj felfelé", "moveDown": "Mozgás lefelé", "color": "Szín", "align": "Igazítsa", "left": "Bal", "center": "Központ", "right": "Jobb", "defaultColor": "Alapértelmezett" }, "image": { "copiedToPasteBoard": "A kép linkjét a vágólapra másolta" }, "outline": { "addHeadingToCreateOutline": "Adjon hozzá címeket a tartalomjegyzék létrehozásához." } }, "textBlock": { "placeholder": "Írja be a '/' karaktert a parancsokhoz" }, "title": { "placeholder": "Névtelen" }, "imageBlock": { "placeholder": "Kattintson a kép hozzáadásához", "upload": { "label": "Feltöltés", "placeholder": "Kattintson a kép feltöltéséhez" }, "url": { "label": "Kép URL-je", "placeholder": "Adja meg a kép URL-jét" }, "support": "A képméret korlátja 5 MB. Támogatott formátumok: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Érvénytelen kép", "invalidImageSize": "A kép méretének 5 MB-nál kisebbnek kell lennie", "invalidImageFormat": "A képformátum nem támogatott. Támogatott formátumok: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Érvénytelen kép URL-je" } }, "codeBlock": { "language": { "label": "Nyelv", "placeholder": "Válasszon nyelvet" } }, "inlineLink": { "placeholder": "Illesszen be vagy írjon be egy hivatkozást", "url": { "label": "Link URL", "placeholder": "Adja meg a link URL-jét" }, "title": { "label": "Link címe", "placeholder": "Adja meg a link címét" } } }, "board": { "column": { "createNewCard": "Új" }, "menuName": "Feladat Tábla", "referencedBoardPrefix": "Nézet", "mobile": { "showGroup": "Csoport megjelenítése", "showGroupContent": "Biztos, hogy meg szeretnéd jeleníteni ezt a csoportot a táblán?", "failedToLoad": "Nem sikerült betölteni a tábla nézetét" } }, "calendar": { "menuName": "Naptár", "defaultNewCalendarTitle": "Névtelen", "navigation": { "today": "Ma", "jumpToday": "Ugrás a mai napra", "previousMonth": "Előző hónap", "nextMonth": "Következő hónap" }, "settings": { "showWeekNumbers": "Heti számok megjelenítése", "showWeekends": "Hétvégén mutatják be", "firstDayOfWeek": "Kezdje a hetet", "layoutDateField": "Elrendezés naptár által", "noDateTitle": "Nincs dátum", "clickToAdd": "Kattintson a naptárhoz való hozzáadáshoz", "name": "Naptár elrendezés", "noDateHint": "A nem tervezett események itt jelennek meg" }, "referencedCalendarPrefix": "Nézet" }, "errorDialog": { "title": "@:appName hiba", "howToFixFallback": "Elnézést kérünk a kellemetlenségért! Nyújtsa be a problémát a GitHub-oldalunkon, amely leírja a hibát.", "github": "Megtekintés a GitHubon" }, "search": { "label": "Keresés", "placeholder": { "actions": "Keresési műveletek..." } }, "message": { "copy": { "success": "Másolva!", "fail": "Nem lehet másolni" } }, "unSupportBlock": "A jelenlegi verzió nem támogatja ezt a blokkot.", "views": { "deleteContentTitle": "Biztosan törli a következőt: {pageType}?", "deleteContentCaption": "ha törli ezt a {pageType} oldalt, visszaállíthatja a kukából." } } ================================================ FILE: frontend/resources/translations/id-ID.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Saya", "welcomeText": "Selamat datang di @:appName", "welcomeTo": "Selamat datang di", "githubStarText": "Bintangi GitHub", "subscribeNewsletterText": "Berlangganan buletin", "letsGoButtonText": "Mulai", "title": "Judul", "youCanAlso": "Anda juga bisa", "and": "Dan", "failedToOpenUrl": "Gagal membuka url: {}", "blockActions": { "addBelowTooltip": "Klik untuk menambahkan di bawah", "addAboveCmd": "Alt+klik", "addAboveMacCmd": "Opsi+klik", "addAboveTooltip": "menambahkan di atas", "dragTooltip": "Seret untuk pindahkan", "openMenuTooltip": "Klik untuk membuka menu" }, "signUp": { "buttonText": "Daftar", "title": "Daftar ke @:appName", "getStartedText": "Mulai", "emptyPasswordError": "Kata sandi tidak boleh kosong", "repeatPasswordEmptyError": "Mohon ulangi, sandi tidak boleh kosong", "unmatchedPasswordError": "Kata sandi konfirmasi tidak sesuai dengan kata sandi awal", "alreadyHaveAnAccount": "Sudah punya akun?", "emailHint": "Surat elektronik", "passwordHint": "Kata sandi", "repeatPasswordHint": "Kata sandi ulang", "signUpWith": "Daftar menggunakan:" }, "signIn": { "loginTitle": "Masuk ke @:appName", "loginButtonText": "Masuk", "loginStartWithAnonymous": "Mulai dengan sesi anonim", "continueAnonymousUser": "Lanjutkan dengan sesi anonim", "buttonText": "Masuk", "signingInText": "Sedang masuk", "forgotPassword": "Lupa kata sandi?", "emailHint": "Surat elektronik", "passwordHint": "Kata sandi", "dontHaveAnAccount": "Belum punya akun?", "createAccount": "Membuat akun", "repeatPasswordEmptyError": "Sandi ulang tidak boleh kosong", "unmatchedPasswordError": "Kata sandi konfirmasi tidak sesuai dengan kata sandi awal", "syncPromptMessage": "Menyinkronkan data mungkin memerlukan waktu beberapa saat. Mohon jangan tutup halaman ini", "or": "ATAU", "signInWithGoogle": "Lanjutkan dengan Google", "signInWithGithub": "Lanjutkan dengan GitHub", "signInWithDiscord": "Lanjutkan dengan Discord", "signInWithApple": "Lanjutkan dengan Apple", "continueAnotherWay": "Lanjutkan dengan cara lain", "signUpWithGoogle": "Mendaftar dengan Google", "signUpWithGithub": "Mendaftar dengan GitHub", "signUpWithDiscord": "Mendaftar dengan Discord", "signInWith": "Masuk dengan:", "signInWithEmail": "Lanjutkan dengan Email", "signInWithMagicLink": "Lanjut", "signUpWithMagicLink": "Mendaftar dengan Magic Link", "pleaseInputYourEmail": "Masukkan alamat email Anda", "settings": "Pengaturan", "magicLinkSent": "Magic Link terkirim!", "invalidEmail": "Masukkan alamat email yang valid", "alreadyHaveAnAccount": "Sudah memiliki akun?", "logIn": "Masuk", "generalError": "Ada yang salah. Mohon coba kembali nanti", "limitRateError": "Demi keamanan, Anda hanya dapat meminta magic link setiap 60 detik", "magicLinkSentDescription": "Magic Link sudah terkirim ke email Anda. Klik pada tautan untuk menyelesaikan proses masuk Anda. Tautan akan kedaluwarsa setelah 5 menit.", "anonymous": "Anonim", "LogInWithGoogle": "Masuk dengan Google", "LogInWithGithub": "Masuk dengan Github", "LogInWithDiscord": "Masuk dengan Discord", "loginAsGuestButtonText": "Memulai" }, "workspace": { "chooseWorkspace": "Pilih workspace anda", "create": "Buat workspace", "reset": "Mengatur ulang area kerja", "resetWorkspacePrompt": "Mengatur ulang area kerja akan menghapus semua halaman dan data di dalamnya. Apakah anda yakin ingin Mengatur ulang area kerja? Selain itu, anda bisa menghubungi tim dukungan untuk mengembalikan area kerja", "hint": "Area kerja", "notFoundError": "Area kerja tidak ditemukan", "failedToLoad": "Ada yang tidak beres! Gagal memuat area kerja. Coba tutup @:appName yang terbuka dan coba lagi.", "errorActions": { "reportIssue": "Melaporkan isu", "reachOut": "Hubungi di Discord" } }, "shareAction": { "buttonText": "Bagikan", "workInProgress": "Segera", "markdown": "Markdown", "csv": "CSV", "copyLink": "Salin tautan" }, "moreAction": { "small": "kecil", "medium": "sedang", "large": "besar", "fontSize": "Ukuran huruf", "import": "Impor", "moreOptions": "Lebih banyak pilihan" }, "importPanel": { "textAndMarkdown": "Teks & Markdown", "documentFromV010": "Dokumen dari v0.1.0", "databaseFromV010": "Basis data dari v0.1.0", "csv": "CSV", "database": "Basis data" }, "disclosureAction": { "rename": "Ganti nama", "delete": "Hapus", "duplicate": "Duplikat", "unfavorite": "Menghapus dari favorit", "favorite": "Masukkan ke favorit", "openNewTab": "Buka di tab baru", "moveTo": "Pindahkan ke", "addToFavorites": "Masukkan ke Favorit", "copyLink": "Copy Link" }, "blankPageTitle": "Halaman kosong", "newPageText": "Halaman baru", "newDocumentText": "Dokumen Baru", "newGridText": "Grid baru", "newCalendarText": "Kalendar baru", "newBoardText": "Papan baru", "trash": { "text": "Tempat sampah", "restoreAll": "Pulihkan Semua", "deleteAll": "Hapus semua", "pageHeader": { "fileName": "Nama file", "lastModified": "Terakhir diubah", "created": "Dibuat" }, "confirmDeleteAll": { "title": "Yakin ingin menghapus semua laman di Sampah?", "caption": "Tindakan ini tidak bisa dibatalkan." }, "confirmRestoreAll": { "title": "Apakah Anda yakin akan memulihkan semua laman di Sampah?", "caption": "Tindakan ini tidak bisa dibatalkan." }, "mobile": { "actions": "Tindakan Sampah", "empty": "Tempat Sampah Kosong", "emptyDescription": "Anda tidak memiliki file yang dihapus", "isDeleted": "Telah dihapus", "isRestored": "Telah dipulihkan" } }, "deletePagePrompt": { "text": "Halaman ini di tempat sampah", "restore": "Pulihkan halaman", "deletePermanent": "Hapus secara permanen" }, "dialogCreatePageNameHint": "Nama halaman", "questionBubble": { "shortcuts": "Pintasan", "whatsNew": "Apa yang baru?", "markdown": "Penurunan harga", "debug": { "name": "Info debug", "success": "Info debug disalin ke papan klip!", "fail": "Tidak dapat menyalin info debug ke papan klip" }, "feedback": "Masukan", "help": "Bantuan & Dukungan" }, "menuAppHeader": { "moreButtonToolTip": "Menghapus, merubah nama, dan banyak lagi...", "addPageTooltip": "Menambahkan halaman di dalam dengan cepat", "defaultNewPageName": "Tanpa Judul", "renameDialog": "Ganti nama" }, "noPagesInside": "Tidak ada halaman di dalamnya", "toolbar": { "undo": "Kembalikan ke sebelumnya", "redo": "Kemblalikan ke setelahnya", "bold": "Tebal", "italic": "Miring", "underline": "Garis bawah", "strike": "Dicoret", "numList": "Daftar bernomor", "bulletList": "Daftar berpoin", "checkList": "Daftar periksa", "inlineCode": "Kode sebaris", "quote": "Blok kutipan", "header": "Tajuk", "highlight": "Sorotan", "color": "Warna", "addLink": "Tambahkan Tautan", "link": "Tautan" }, "tooltip": { "lightMode": "Ganti mode terang", "darkMode": "Ganti mode gelap", "openAsPage": "Buka sebagai Halaman", "addNewRow": "Tambahkan baris baru", "openMenu": "Klik untuk membuka menu", "dragRow": "Tekan lama untuk menyusun ulang baris", "viewDataBase": "Lihat basis data", "referencePage": "{nama} ini direferensikan", "addBlockBelow": "Tambahkan blok di bawah ini" }, "sideBar": { "closeSidebar": "Tutup sidebar", "openSidebar": "Buka sidebar", "personal": "Pribadi", "favorites": "Favorit", "clickToHidePersonal": "Klik untuk menutup Pribadi", "clickToHideFavorites": "Klik untuk menutup Favorit", "addAPage": "Tambah halaman baru" }, "notifications": { "export": { "markdown": "Mengekspor Catatan ke Markdown", "path": "Documents/flowy" } }, "contactsPage": { "title": "Kontak", "whatsHappening": "Apa yang terjadi minggu ini?", "addContact": "Tambahkan Kontak", "editContact": "Ubah Kontak" }, "button": { "ok": "OKE", "done": "Selesai", "cancel": "Batal", "signIn": "Masuk", "signOut": "Keluar", "complete": "Selesai", "save": "Simpan", "generate": "Menghasilkan", "esc": "ESC", "keep": "Menyimpan", "tryAgain": "Coba lagi", "discard": "Membuang", "replace": "Mengganti", "insertBelow": "Sisipkan Di Bawah", "upload": "Mengunggah", "edit": "Sunting", "delete": "Menghapus", "duplicate": "Duplikat", "putback": "Taruh kembali", "update": "Perbarui", "share": "Bagikan", "removeFromFavorites": "Hapus dari favorit", "addToFavorites": "Tambahkan ke Favorit", "rename": "Ganti nama", "helpCenter": "Pusat Bantuan" }, "label": { "welcome": "Selamat datang!", "firstName": "Nama Depan", "middleName": "Nama Tengah", "lastName": "Nama Akhir", "stepX": "Langkah {X}" }, "oAuth": { "err": { "failedTitle": "Tidak dapat terhubung ke akun anda", "failedMsg": "Mohon pastikan anda menyelesaikan proses pendaftaran pada browser anda." }, "google": { "title": "MASUK GOOGLE", "instruction1": "Untuk mengimpor kontak Google Contacts anda, anda harus mengizinkan aplikasi ini menggunakan browser web anda.", "instruction2": "Salin kode ini ke papan klip anda dengan cara mengklik ikon atau memilih teks:", "instruction3": "Arahkan ke tautan berikut di browser web Anda, dan masukkan kode di atas:", "instruction4": "Tekan tombol di bawah ini setelah Anda menyelesaikan pendaftaran:" } }, "settings": { "title": "Pengaturan", "menu": { "appearance": "Tampilan", "language": "Bahasa", "user": "Pengguna", "files": "File", "notifications": "Notifikasi", "open": "Buka Pengaturan", "logout": "Keluar", "logoutPrompt": "Apakah anda yakin untuk keluar?", "selfEncryptionLogoutPrompt": "Apakah anda yakin mau keluar? Dimohonkan anda telah mengcopy enkripsi rahasianya", "syncSetting": "Sinkronisasi setting", "enableSync": "Aktifkan sinkronisasi", "enableEncrypt": "Enkripsi data", "enableEncryptPrompt": "Aktifkan enkripsi untuk mensecure data anda dengan rahasia ini. Simpan dengan aman; stelah di aktifkan, tidak bisa di matikan lagi. Jika hilang, data anda akan tidak bisa terdapatkan lagi. Klik untuk mengcopy", "inputEncryptPrompt": "Silahkan masukan enkripsi rahasia anda untuk", "clickToCopySecret": "Klik untuk mengcopy rahasia", "inputTextFieldHint": "Rahasia anda", "historicalUserList": "History masuk user", "historicalUserListTooltip": "Daftar ini menampilkan akun anonim Anda. Anda dapat mengeklik salah satu akun untuk melihat detailnya. Akun anonim dibuat dengan mengeklik tombol 'Memulai'", "openHistoricalUser": "Klik untuk membuka akun anonim" }, "notifications": { "enableNotifications": { "label": "Mengaktifkan notifikasi", "hint": "Matikan untuk menghentikan munculnya notifikasi lokal." } }, "appearance": { "resetSetting": "Mengatur ulang pengaturan ini", "fontFamily": { "label": "Jenis Font", "search": "Cari" }, "themeMode": { "label": "Tema", "light": "Terang", "dark": "Gelap", "system": "Sesuai Sistem" }, "layoutDirection": { "label": "Arah Tampilan", "hint": "Mengatur arah tampilan konten, apakah dari kiri ke kanan atau kanan ke kiri.", "ltr": "Kiri ke Kanan", "rtl": "Kanan ke Kiri" }, "textDirection": { "label": "Arah Teks Bawaan", "hint": "Atur arah teks bawaan, apakah dari kiri ke kanan atau kanan ke kiri.", "ltr": "Kiri ke Kanan", "rtl": "Kanan ke Kiri", "auto": "Otomatis", "fallback": "Sesuai Arah Tampilan" }, "themeUpload": { "button": "Mengunggah", "uploadTheme": "Unggah tema", "description": "Unggah tema @:appName Anda sendiri menggunakan tombol di bawah ini.", "loading": "Harap tunggu sementara kami memvalidasi dan mengunggah tema Anda...", "uploadSuccess": "Tema Anda berhasil diunggah", "deletionFailure": "Gagal menghapus tema. Cobalah untuk menghapusnya secara manual.", "filePickerDialogTitle": "Pilih file .flowy_plugin", "urlUploadFailure": "Gagal membuka url: {}", "failure": "Tema yang diunggah memiliki format yang tidak valid." }, "theme": "Tema", "builtInsLabel": "Tema Bawaan", "pluginsLabel": "Plugin", "dateFormat": { "label": "Format tanggal", "local": "Lokal", "us": "US", "iso": "ISO", "friendly": "Friendly", "dmy": "D/M/Y" }, "timeFormat": { "label": "Format Waktu", "twelveHour": "Dua belas jam", "twentyFourHour": "Dua puluh empat jam" }, "showNamingDialogWhenCreatingPage": "Menampilkan dialog penamaan saat membuat halaman" }, "files": { "copy": "Menyalin", "defaultLocation": "Baca file dan lokasi penyimpanan data", "exportData": "Ekspor data Anda", "doubleTapToCopy": "Ketuk dua kali untuk menyalin jalur", "restoreLocation": "Pulihkan ke jalur default @:appName", "customizeLocation": "Buka folder lain", "restartApp": "Harap mulai ulang aplikasi agar perubahan diterapkan.", "exportDatabase": "Ekspor basis data", "selectFiles": "Pilih file yang perlu diekspor", "selectAll": "Pilih Semua", "deselectAll": "Batalkan pilihan semua", "createNewFolder": "Buat folder baru", "createNewFolderDesc": "Beritahu kami di mana Anda ingin menyimpan data Anda", "defineWhereYourDataIsStored": "Tentukan di mana data Anda disimpan", "open": "Membuka", "openFolder": "Buka folder yang ada", "openFolderDesc": "Baca dan tulis ke folder @:appName Anda yang sudah ada", "folderHintText": "nama folder", "location": "Membuat folder baru", "locationDesc": "Pilih nama untuk folder data @:appName Anda", "browser": "Jelajahi", "create": "Membuat", "set": "Mengatur", "folderPath": "Jalur untuk menyimpan folder Anda", "locationCannotBeEmpty": "Jalur tidak boleh kosong", "pathCopiedSnackbar": "Jalur penyimpanan file disalin ke clipboard!", "changeLocationTooltips": "Ubah direktori data", "change": "Mengubah", "openLocationTooltips": "Buka direktori data lain", "openCurrentDataFolder": "Buka direktori data saat ini", "recoverLocationTooltips": "Setel ulang ke direktori data default @:appName", "exportFileSuccess": "Ekspor file berhasil!", "exportFileFail": "File ekspor gagal!", "export": "Ekspor" }, "user": { "name": "Nama", "email": "Surel", "tooltipSelectIcon": "Pilih ikon", "selectAnIcon": "Pilih ikon", "pleaseInputYourOpenAIKey": "silakan masukkan kunci AI Anda", "clickToLogout": "Klik untuk keluar dari pengguna saat ini", "pleaseInputYourStabilityAIKey": "Masukkan kunci Stability AI anda" }, "mobile": { "personalInfo": "Informasi pribadi", "username": "Nama Pengguna", "usernameEmptyError": "Nama pengguna tidak boleh kosong", "about": "Tentang", "pushNotifications": "Pemberitahuan Dorong", "support": "Dukungan", "joinDiscord": "Bergabunglah dengan kami di Discord", "privacyPolicy": "Kebijakan Privasi", "userAgreement": "Perjanjian Pengguna" }, "shortcuts": { "shortcutsLabel": "Pintasan", "command": "Perintah", "keyBinding": "Keybindin", "addNewCommand": "Tambah Perintah Baru", "updateShortcutStep": "Tekan kombinasi tombol yang diinginkan dan tekan ENTER", "shortcutIsAlreadyUsed": "Pintasan ini sudah digunakan untuk: {conflict}", "resetToDefault": "Mengatur ulang ke keybinding default", "couldNotLoadErrorMsg": "Tidak dapat memuat pintasan, Coba lagi", "couldNotSaveErrorMsg": "Tidak dapat menyimpan pintasan, Coba lagi" } }, "grid": { "deleteView": "Yakin ingin menghapus tampilan ini?", "createView": "Baru", "title": { "placeholder": "Tanpa judul" }, "settings": { "filter": "Filter", "sort": "Menyortir", "sortBy": "Sortir dengan", "properties": "Properti", "reorderPropertiesTooltip": "Seret untuk mengurutkan ulang properti", "group": "Kelompok", "addFilter": "Tambahkan Filter", "deleteFilter": "Hapus filter", "filterBy": "Saring menurut...", "typeAValue": "Ketik nilai...", "layout": "Tata letak", "databaseLayout": "Tata letak", "Properties": "Properti" }, "textFilter": { "contains": "Mengandung", "doesNotContain": "Tidak mengandung", "endsWith": "Berakhir dengan", "startWith": "Dimulai dengan", "is": "Adalah", "isNot": "Tidak", "isEmpty": "Kosong", "isNotEmpty": "Tidak kosong", "choicechipPrefix": { "isNot": "Bukan", "startWith": "Dimulai dengan", "endWith": "Berakhir dengan", "isEmpty": "kosong", "isNotEmpty": "tidak kosong" } }, "checkboxFilter": { "isChecked": "Diperiksa", "isUnchecked": "Tidak dicentang", "choicechipPrefix": { "is": "adalah" } }, "checklistFilter": { "isComplete": "selesai", "isIncomplted": "tidak lengkap" }, "selectOptionFilter": { "is": "Adalah", "isNot": "Tidak", "contains": "Mengandung", "doesNotContain": "Tidak mengandung", "isEmpty": "Kosong", "isNotEmpty": "Tidak kosong" }, "field": { "hide": "Sembunyikan", "show": "Tampilkan", "insertLeft": "Sisipkan Kiri", "insertRight": "Sisipkan Kanan", "duplicate": "Duplikasi", "delete": "Hapus", "textFieldName": "Teks", "checkboxFieldName": "Kotak Centang", "dateFieldName": "Tanggal", "updatedAtFieldName": "Waktu terakhir diubah", "createdAtFieldName": "Waktu yang diciptakan", "numberFieldName": "Angka", "singleSelectFieldName": "seleksi", "multiSelectFieldName": "Multi seleksi", "urlFieldName": "URL", "checklistFieldName": "Daftar periksa", "numberFormat": "Format angka", "dateFormat": "Format tanggal", "includeTime": "Sertakan waktu", "isRange": "Tanggal akhir", "dateFormatFriendly": "Bulan Hari, Tahun", "dateFormatISO": "Tahun-Bulan-Hari", "dateFormatLocal": "Bulan/Hari/Tahun", "dateFormatUS": "Tahun/Bulan/Hari", "dateFormatDayMonthYear": "Hari bulan tahun", "timeFormat": "Format waktu", "invalidTimeFormat": "Format yang tidak valid", "timeFormatTwelveHour": "12 jam", "timeFormatTwentyFourHour": "24 jam", "clearDate": "Hapus tanggal", "addSelectOption": "Tambahkan opsi", "optionTitle": "Opsi", "addOption": "Tambahkan opsi", "editProperty": "Ubah properti", "newProperty": "Properti baru", "deleteFieldPromptMessage": "Apa kamu yakin? Properti ini akan dihapus", "newColumn": "Kolom baru" }, "rowPage": { "newField": "Tambah bidang baru", "fieldDragElementTooltip": "Klik untuk membuka menu", "showHiddenFields": { "one": "Tampilkan {} bidang tersembunyi", "many": "Tampilkan {} bidang tersembunyi", "other": "Tampilkan {} bidang tersembunyi" }, "hideHiddenFields": { "one": "Sembunyikan {} bidang tersembunyi", "many": "Sembunyikan {} bidang tersembunyi", "other": "Sembunyikan {} bidang tersembunyi" } }, "sort": { "ascending": "Naik", "descending": "Menurun", "deleteAllSorts": "Hapus semua sortir", "addSort": "Tambahkan semacam", "deleteSort": "Hapus urutan" }, "row": { "duplicate": "Duplikasi", "delete": "Hapus", "titlePlaceholder": "Tanpa judul", "textPlaceholder": "Kosong", "copyProperty": "Salin properti ke papan klip", "count": "Menghitung", "newRow": "Baris baru", "action": "Tindakan", "add": "Klik tambah ke bawah", "drag": "Seret untuk pindah" }, "selectOption": { "create": "Buat", "purpleColor": "Ungu", "pinkColor": "Merah Jambu", "lightPinkColor": "Merah Jambu Muda", "orangeColor": "Oranye", "yellowColor": "Kuning", "limeColor": "Limau", "greenColor": "Hijau", "aquaColor": "Air", "blueColor": "Biru", "deleteTag": "Hapus tag", "colorPanelTitle": "Warna", "panelTitle": "Pilih opsi atau buat baru", "searchOption": "Cari opsi", "searchOrCreateOption": "Cari atau buat opsi", "createNew": "Buat baru", "orSelectOne": "atau pilih opsi" }, "checklist": { "taskHint": "Deskripsi tugas", "addNew": "Tambahkan item", "submitNewTask": "Buat", "hideComplete": "Sembunyikan tugas yang sudah selesai", "showComplete": "Tampilkan semua tugas" }, "menuName": "Grid", "referencedGridPrefix": "Pemandangan dari" }, "document": { "menuName": "Dokter", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Pilih Papan untuk ditautkan", "createANewBoard": "Buat Dewan baru" }, "grid": { "selectAGridToLinkTo": "Pilih Grid untuk ditautkan", "createANewGrid": "Buat Kotak baru" }, "calendar": { "selectACalendarToLinkTo": "Pilih Kalender untuk ditautkan", "createANewCalendar": "Buat Kalender baru" } }, "selectionMenu": { "outline": "Garis besar", "codeBlock": "Blok Kode" }, "plugins": { "referencedBoard": "Papan Referensi", "referencedGrid": "Kisi yang Direferensikan", "referencedCalendar": "Kalender Referensi", "autoGeneratorMenuItemName": "Penulis AI", "autoGeneratorTitleName": "AI: Minta AI untuk menulis apa saja...", "autoGeneratorLearnMore": "Belajarlah lagi", "autoGeneratorGenerate": "Menghasilkan", "autoGeneratorHintText": "Tanya AI...", "autoGeneratorCantGetOpenAIKey": "Tidak bisa mendapatkan kunci AI", "autoGeneratorRewrite": "Menulis kembali", "smartEdit": "Asisten AI", "aI": "AI", "smartEditFixSpelling": "Perbaiki ejaan", "warning": "⚠️ Respons AI bisa jadi tidak akurat atau menyesatkan.", "smartEditSummarize": "Meringkaskan", "smartEditImproveWriting": "Perbaiki tulisan", "smartEditMakeLonger": "Buat lebih lama", "smartEditCouldNotFetchResult": "Tidak dapat mengambil hasil dari AI", "smartEditCouldNotFetchKey": "Tidak dapat mengambil kunci AI", "smartEditDisabled": "Hubungkan AI di Pengaturan", "discardResponse": "Apakah Anda ingin membuang respons AI?", "createInlineMathEquation": "Buat persamaan", "toggleList": "Beralih Daftar", "cover": { "changeCover": "Ganti Sampul", "colors": "Warna", "images": "Gambar-gambar", "clearAll": "Bersihkan semua", "abstract": "Abstrak", "addCover": "Tambahkan Penutup", "addLocalImage": "Tambahkan gambar lokal", "invalidImageUrl": "URL gambar tidak valid", "failedToAddImageToGallery": "Gagal menambahkan gambar ke galeri", "enterImageUrl": "Masukkan URL gambar", "add": "Menambahkan", "back": "Kembali", "saveToGallery": "Simpan ke galeri", "removeIcon": "Hapus Ikon", "pasteImageUrl": "Tempel URL gambar", "or": "ATAU", "pickFromFiles": "Pilih dari file", "couldNotFetchImage": "Tidak dapat mengambil gambar", "imageSavingFailed": "Penyimpanan Gambar Gagal", "addIcon": "Tambahkan Ikon", "coverRemoveAlert": "Itu akan dihapus dari sampul setelah dihapus.", "alertDialogConfirmation": "Apakah anda yakin ingin melanjutkan?" }, "mathEquation": { "addMathEquation": "Tambahkan Persamaan Matematika", "editMathEquation": "Edit Persamaan Matematika" }, "optionAction": { "click": "Klik", "toOpenMenu": " untuk membuka menu", "delete": "Menghapus", "duplicate": "Duplikat", "turnInto": "Berubah menjadi", "moveUp": "Naik", "moveDown": "Turunkan", "color": "Warna", "align": "Meluruskan", "left": "Kiri", "center": "Tengah", "right": "Benar", "defaultColor": "Bawaan" }, "image": { "addAnImage": "Tambah gambar", "copiedToPasteBoard": "Tautan gambar telah disalin ke papan klip" }, "outline": { "addHeadingToCreateOutline": "Tambahkan judul untuk membuat daftar isi." }, "table": { "addAfter": "Tambahkan setelah", "addBefore": "Tambahkan sebelum", "delete": "Hapus", "clear": "Hapus konten", "duplicate": "Duplikat", "bgColor": "Warna latar belakang" }, "contextMenu": { "copy": "Salin", "cut": "Potong", "paste": "Tempel" } }, "textBlock": { "placeholder": "Ketik '/' untuk perintah" }, "title": { "placeholder": "Tanpa judul" }, "imageBlock": { "placeholder": "Klik untuk menambahkan gambar", "upload": { "label": "Mengunggah", "placeholder": "Klik untuk mengunggah gambar" }, "url": { "label": "URL gambar", "placeholder": "Masukkan URL gambar" }, "ai": { "label": "Buat gambar dari AI", "placeholder": "Masukkan perintah agar AI menghasilkan gambar" }, "stability_ai": { "label": "Buat gambar dari Stability AI", "placeholder": "Masukkan perintah agar Stability AI menghasilkan gambar" }, "support": "Batas ukuran gambar adalah 5 MB. Format yang didukung: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Gambar tidak valid", "invalidImageSize": "Ukuran gambar harus kurang dari 5MB", "invalidImageFormat": "Format gambar tidak didukung. Format yang didukung: JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL gambar tidak valid" }, "embedLink": { "label": "Sematkan tautan", "placeholder": "Tempel atau ketik tautan gambar" }, "searchForAnImage": "Mencari gambar", "pleaseInputYourOpenAIKey": "masukkan kunci AI Anda di halaman Pengaturan", "pleaseInputYourStabilityAIKey": "masukkan kunci AI Stabilitas Anda di halaman Pengaturan" }, "codeBlock": { "language": { "label": "Bahasa", "placeholder": "Pilih bahasa" } }, "inlineLink": { "placeholder": "Tempel atau ketik tautan", "openInNewTab": "Buka di tab baru", "copyLink": "Salin tautan", "removeLink": "Hapus tautan", "url": { "label": "URL tautan", "placeholder": "Masukkan URL tautan" }, "title": { "label": "Judul Tautan", "placeholder": "Masukkan judul tautan" } }, "mention": { "placeholder": "Menyebutkan seseorang atau halaman atau tanggal...", "page": { "label": "Tautan ke halaman", "tooltip": "Klik untuk membuka halaman" } }, "toolbar": { "resetToDefaultFont": "Mengatur ulang ke default" }, "errorBlock": { "theBlockIsNotSupported": "Versi saat ini tidak mendukung blok ini.", "blockContentHasBeenCopied": "Konten blok telah disalin." } }, "board": { "column": { "createNewCard": "Baru", "renameGroupTooltip": "Tekan untuk mengganti nama grup" }, "menuName": "Papan", "showUngrouped": "Tampilkan item yang tidak dikelompokkan", "ungroupedButtonText": "Tidak dikelompokkan", "ungroupedButtonTooltip": "Berisi kartu yang tidak termasuk dalam grup mana pun", "ungroupedItemsTitle": "Klik untuk menambahkan ke papan", "groupBy": "Kelompokkan berdasarkan", "referencedBoardPrefix": "Pemandangan dari", "mobile": { "showGroup": "Tampilkan grup", "showGroupContent": "Apakah Anda yakin ingin menampilkan grup ini di papan?", "failedToLoad": "Gagal memuat tampilan papan" } }, "calendar": { "menuName": "Kalender", "defaultNewCalendarTitle": "Tanpa judul", "newEventButtonTooltip": "Menambahkan acara baru", "navigation": { "today": "Hari ini", "jumpToday": "Lompat ke Hari Ini", "previousMonth": "Bulan sebelumnya", "nextMonth": "Bulan depan" }, "settings": { "showWeekNumbers": "Tampilkan nomor minggu", "showWeekends": "Tampilkan akhir pekan", "firstDayOfWeek": "Mulai minggu", "layoutDateField": "Tata letak kalender oleh", "noDateTitle": "Tidak ada tanggal", "clickToAdd": "Klik untuk menambahkan ke kalender", "name": "Tata letak kalender", "noDateHint": "Acara yang tidak dijadwalkan akan muncul di sini" }, "referencedCalendarPrefix": "Pemandangan dari" }, "errorDialog": { "title": "Kesalahan @:appName", "howToFixFallback": "Kami mohon maaf atas ketidaknyamanan ini! Kirimkan masalah di halaman GitHub kami yang menjelaskan kesalahan Anda.", "github": "Lihat di GitHub" }, "search": { "label": "Mencari", "placeholder": { "actions": "Tindakan penelusuran..." } }, "message": { "copy": { "success": "Disalin!", "fail": "Tidak dapat menyalin" } }, "unSupportBlock": "Versi saat ini tidak mendukung Blok ini.", "views": { "deleteContentTitle": "Anda yakin ingin menghapus {pageType}?", "deleteContentCaption": "jika Anda menghapus {pageType} ini, Anda dapat memulihkannya dari sampah." }, "colors": { "custom": "Kustom", "default": "Default", "red": "Merah", "orange": "Oranye", "yellow": "Kuning", "green": "Hijau", "blue": "Biru", "purple": "Ungu", "pink": "Merah Muda", "brown": "Coklat", "gray": "Abu-abu" }, "emoji": { "search": "Cari emoji", "noRecent": "Tidak ada emoji terbaru", "noEmojiFound": "Emoji tidak ditemukan", "filter": "Saring", "random": "Acak", "selectSkinTone": "Pilih warna kulit", "remove": "Hapus emoji", "categories": { "smileys": "Senyum & Emosi", "people": "Orang & Tubuh", "animals": "Hewan & Alam", "food": "Makanan & Minuman", "activities": "Aktivitas", "places": "Travel & Tempat", "objects": "Objek", "symbols": "Simbol", "flags": "Bendera", "nature": "Alam", "frequentlyUsed": "Sering Digunakan" } }, "inlineActions": { "noResults": "Tidak ada hasil", "pageReference": "Referensi halaman", "date": "Tanggal", "reminder": { "groupTitle": "Pengingat", "shortKeyword": "mengingatkan" } }, "datePicker": { "dateTimeFormatTooltip": "Ubah format tanggal dan waktu di pengaturan" }, "relativeDates": { "yesterday": "Kemarin", "today": "Hari ini", "tomorrow": "Besok", "oneWeek": "1 minggu" }, "notificationHub": { "title": "Notifikasi", "emptyTitle": "Semua sudah ketahuan!", "emptyBody": "Tidak ada pemberitahuan atau tindakan yang tertunda. Nikmati ketenangannya.", "tabs": { "inbox": "Kotak masuk", "upcoming": "Mendatang" }, "actions": { "markAllRead": "tandai semua telah dibaca", "showAll": "Semua", "showUnreads": "Belum dibaca" }, "filters": { "ascending": "Ascending", "descending": "Descending", "groupByDate": "Kelompokkan berdasarkan tanggal", "showUnreadsOnly": "Tampilkan yang belum dibaca saja", "resetToDefault": "Mengatur ulang ke default" }, "empty": "Tidak ada yang dilihat disini!" }, "reminderNotification": { "title": "Pengingat", "message": "Ingatlah untuk memeriksa ini sebelum Anda lupa!", "tooltipDelete": "Hapus", "tooltipMarkRead": "Tandai telah dibaca", "tooltipMarkUnread": "Tandai belum dibaca" }, "findAndReplace": { "find": "Cari", "previousMatch": "Pencarian sebelumnya", "nextMatch": "Pencarian setelahnya", "close": "Tutup", "replace": "Timpa", "replaceAll": "Timpa semua", "noResult": "Tidak ada hasil", "caseSensitive": "Case sensitive" }, "error": { "weAreSorry": "Kami meminta maaf", "loadingViewError": "Kami mengalami masalah saat memuat tampilan ini. Silakan periksa koneksi internet Anda, segarkan aplikasi, dan jangan ragu untuk menghubungi tim jika masalah terus berlanjut." }, "editor": { "bold": "Tebal", "bulletedList": "Daftar Berpoin", "checkbox": "Kotak centang", "embedCode": "Sematkan Kode", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Sorotan", "color": "Warna", "image": "Gambar", "italic": "Miring", "link": "Tautan", "numberedList": "Daftar Bernomor", "quote": "Kutipan", "strikethrough": "Dicoret", "text": "Teks", "underline": "Garis Bawah", "fontColorDefault": "Bawaan", "fontColorGray": "Abu-abu", "fontColorBrown": "Cokelat", "fontColorOrange": "Oranye", "fontColorYellow": "Kuning", "fontColorGreen": "Hijau", "fontColorBlue": "Biru", "fontColorPurple": "Ungu", "fontColorPink": "Merah Jambu", "fontColorRed": "Merah", "backgroundColorDefault": "Latar belakang bawaan", "backgroundColorGray": "Latar belakang abu-abu", "backgroundColorBrown": "Latar belakang coklat", "backgroundColorOrange": "Latar belakang oranye", "backgroundColorYellow": "Latar belakang kuning", "backgroundColorGreen": "Latar belakang hijau", "backgroundColorBlue": "Latar belakang biru", "backgroundColorPurple": "Latar belakang ungu", "backgroundColorPink": "Latar belakang merah muda", "backgroundColorRed": "Latar belakang merah", "done": "Selesai", "cancel": "Batalkan", "tint1": "Warna 1", "tint2": "Warna 2", "tint3": "Warna 3", "tint4": "Warna 4", "tint5": "Warna 5", "tint6": "Warna 6", "tint7": "Warna 7", "tint8": "Warna 8", "tint9": "Warna 9", "lightLightTint1": "Ungu", "lightLightTint2": "Merah Jambu", "lightLightTint3": "Merah Jambu Muda", "lightLightTint4": "Oranye", "lightLightTint5": "Kuning", "lightLightTint6": "Hijau Jeruk Nipis", "lightLightTint7": "Hijau", "lightLightTint8": "Biru Air", "lightLightTint9": "Biru", "urlHint": "URL", "mobileHeading1": "Judul 1", "mobileHeading2": "Judul 2", "mobileHeading3": "Judul 3", "textColor": "Warna Teks", "backgroundColor": "Warna Latar Belakang", "addYourLink": "Tambahkan tautan Anda", "openLink": "Buka tautan", "copyLink": "Salin tautan", "removeLink": "Hapus tautan", "editLink": "Sunting tautan", "linkText": "Teks", "linkTextHint": "Silakan masukkan teks", "linkAddressHint": "Silakan masukkan URL", "highlightColor": "Sorot warna", "clearHighlightColor": "Hapus warna sorotan", "customColor": "Warna khusus", "hexValue": "Nilai hex", "opacity": "Kegelapan", "resetToDefaultColor": "Atur ulang ke warna default", "ltr": "LTR", "rtl": "RTL", "auto": "Otomatis", "cut": "Potong", "copy": "Salin", "paste": "Tempel", "find": "Temukan", "previousMatch": "Padanan sebelumnya", "nextMatch": "Padanan selanjutnya", "closeFind": "Tutup", "replace": "Ganti", "replaceAll": "Ganti semua", "regex": "Regex", "caseSensitive": "Huruf besar kecil dibedakan", "uploadImage": "Unggah Gambar", "urlImage": "Gambar URL", "incorrectLink": "Tautan Salah", "upload": "Unggah", "chooseImage": "Pilih gambar", "loading": "Memuat", "imageLoadFailed": "Tidak dapat memuat gambar", "divider": "Pembagi", "table": "Tabel", "colAddBefore": "Tambahkan sebelumnya", "rowAddBefore": "Tambahkan sebelumnya", "colAddAfter": "Tambahkan setelahnya", "rowAddAfter": "Tambahkan setelahnya", "colRemove": "Hapus", "rowRemove": "Hapus", "colDuplicate": "Duplikat", "rowDuplicate": "Duplikat", "colClear": "Hapus Konten", "rowClear": "Hapus Konten", "slashPlaceHolder": "Masukkan / untuk menyisipkan blok, atau mulai mengetik" }, "favorite": { "noFavorite": "Tidak ada halaman favorit", "noFavoriteHintText": "Geser halaman ke kiri untuk menambahkannya ke favorit Anda" } } ================================================ FILE: frontend/resources/translations/it-IT.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Me", "welcomeText": "Benvenuto in @:appName", "welcomeTo": "Benvenuto a", "githubStarText": "Vota su GitHub", "subscribeNewsletterText": "Sottoscrivi la Newsletter", "letsGoButtonText": "Andiamo", "title": "Titolo", "youCanAlso": "Puoi anche", "and": "E", "failedToOpenUrl": "Apertura URL fallita: {}", "blockActions": { "addBelowTooltip": "Fare clic per aggiungere di seguito", "addAboveCmd": "Alt+clic", "addAboveMacCmd": "Opzione+clic", "addAboveTooltip": "aggiungere sopra", "dragTooltip": "Trascina per spostare", "openMenuTooltip": "Cliccare per aprire il menu" }, "signUp": { "buttonText": "Registrati", "title": "Registrati per @:appName", "getStartedText": "Iniziamo", "emptyPasswordError": "La password non può essere vuota", "repeatPasswordEmptyError": "La password ripetuta non può essere vuota", "unmatchedPasswordError": "La password ripetuta non è uguale alla password", "alreadyHaveAnAccount": "Hai già un account?", "emailHint": "Email", "passwordHint": "Password", "repeatPasswordHint": "Ripeti password", "signUpWith": "Registrati con:" }, "signIn": { "loginTitle": "Accedi a @:appName", "loginButtonText": "Login", "loginStartWithAnonymous": "Inizia con una sessione anonima", "continueAnonymousUser": "Continua con una sessione anonima", "continueWithLocalModel": "Continua con il modello locale", "switchToAppFlowyCloud": "AppFlowy Cloud", "anonymousMode": "Modalità anonima", "buttonText": "Accedi", "signingInText": "Accesso in corso...", "forgotPassword": "Password Dimentica?", "emailHint": "Email", "passwordHint": "Password", "dontHaveAnAccount": "Non hai un account?", "createAccount": "Crea account", "repeatPasswordEmptyError": "La password ripetuta non può essere vuota", "unmatchedPasswordError": "La password ripetuta non è uguale alla password", "passwordMustContain": "La password deve contenere almeno una lettera, un numero e un simbolo.", "syncPromptMessage": "La sincronizzazione dei dati potrebbe richiedere del tempo. Per favore, non chiudere questa pagina", "or": "O", "signInWithGoogle": "Continua con Google", "signInWithGithub": "Continua con Github", "signInWithDiscord": "Continua con Discord", "signInWithApple": "Continua con Apple", "continueAnotherWay": "Continua in un altro modo", "signUpWithGoogle": "Registrati con Google", "signUpWithGithub": "Registrati con Github", "signUpWithDiscord": "Registrati con Discord", "signInWith": "Loggati con:", "signInWithEmail": "Continua con Email", "signInWithMagicLink": "Continua", "signUpWithMagicLink": "Registrati con un Link Magico", "pleaseInputYourEmail": "Per favore, inserisci il tuo indirizzo email", "settings": "Impostazioni", "magicLinkSent": "Link Magico inviato!", "invalidEmail": "Per favore, inserisci un indirizzo email valido", "alreadyHaveAnAccount": "Hai già un account?", "logIn": "Accedi", "generalError": "Qualcosa è andato storto. Per favore, riprova più tardi", "limitRateError": "Per ragioni di sicurezza, puoi richiedere un link magico ogni 60 secondi", "magicLinkSentDescription": "Un Link Magico è stato inviato alla tua email. Clicca il link per completare il tuo accesso. Il link scadrà dopo 5 minuti.", "tokenHasExpiredOrInvalid": "Il codice è scaduto o non è valido. Riprova.", "signingIn": "Accesso in corso...", "checkYourEmail": "Controlla la tua posta elettronica", "temporaryVerificationLinkSent": "È stato inviato un link di verifica temporaneo.\nSi prega di controllare la posta in arrivo di", "temporaryVerificationCodeSent": "È stato inviato un codice di verifica temporaneo.\nSi prega di controllare la posta in arrivo di", "continueToSignIn": "Continua ad accedere", "continueWithLoginCode": "Continua con il codice di accesso", "backToLogin": "Torna al login", "enterCode": "Inserisci il codice", "enterCodeManually": "Inserisci il codice manualmente", "continueWithEmail": "Continua con l'email", "enterPassword": "Inserisci la password", "loginAs": "Accedi come", "invalidVerificationCode": "Inserisci un codice di verifica valido", "tooFrequentVerificationCodeRequest": "Hai effettuato troppe richieste. Riprova più tardi.", "invalidLoginCredentials": "La tua password non è corretta, riprova", "resetPassword": "Reimposta password", "resetPasswordDescription": "Inserisci la tua email per reimpostare la password", "continueToResetPassword": "Continua per reimpostare la password", "resetPasswordSuccess": "Reimpostazione password riuscita", "resetPasswordFailed": "Impossibile reimpostare la password", "resetPasswordLinkSent": "Un link per reimpostare la password è stato inviato alla tua email. Controlla la tua casella di posta all'indirizzo", "resetPasswordLinkExpired": "Il link per reimpostare la password è scaduto. Richiedi un nuovo link.", "resetPasswordLinkInvalid": "Il link per reimpostare la password non è valido. Richiedi un nuovo link.", "enterNewPasswordFor": "Inserisci la nuova password per ", "newPassword": "Nuova password", "enterNewPassword": "Inserisci la nuova password", "confirmPassword": "Conferma password", "confirmNewPassword": "Inserisci la nuova password", "newPasswordCannotBeEmpty": "La nuova password non può essere vuota", "confirmPasswordCannotBeEmpty": "La password di conferma non può essere vuota", "passwordsDoNotMatch": "Le password non corrispondono", "verifying": "Verifica in corso...", "continueWithPassword": "Continua con la password", "youAreInLocalMode": "Sei in modalità locale.", "loginToAppFlowyCloud": "Accedi ad AppFlowy Cloud", "LogInWithGoogle": "Accedi con Google", "LogInWithGithub": "Accedi con Github", "LogInWithDiscord": "Accedi con Discord", "loginAsGuestButtonText": "Iniziare" }, "workspace": { "chooseWorkspace": "Scegli il tuo spazio di lavoro", "defaultName": "Il mio spazio di lavoro", "create": "Crea spazio di lavoro", "new": "Nuovo spazio di lavoro", "importFromNotion": "Importa da Notion", "learnMore": "Per saperne di più", "reset": "Ripristina lo spazio di lavoro", "renameWorkspace": "Rinomina workspace", "workspaceNameCannotBeEmpty": "Il nome dell'area di lavoro non può essere vuoto", "resetWorkspacePrompt": "Il ripristino dello spazio di lavoro eliminerà tutte le pagine e i dati al suo interno. Sei sicuro di voler ripristinare lo spazio di lavoro? In alternativa, puoi contattare il team di supporto per ristabilire lo spazio di lavoro", "hint": "spazio di lavoro", "notFoundError": "Spazio di lavoro non trovato", "failedToLoad": "Qualcosa è andato storto! Impossibile caricare lo spazio di lavoro. Prova a chiudere qualsiasi istanza aperta di @:appName e riprova.", "errorActions": { "reportIssue": "Segnala un problema", "reportIssueOnGithub": "Segnalate un problema su Github", "exportLogFiles": "Esporta i file di log", "reachOut": "Contattaci su Discord" }, "menuTitle": "Spazi di lavoro", "deleteWorkspaceHintText": "Sei sicuro di voler cancellare la workspace? Questa azione non è reversibile, e ogni pagina che hai pubblicato sarà rimossa.", "createSuccess": "Workspace creata con successo", "createFailed": "Creazione workspace fallita", "createLimitExceeded": "Hai raggiunto il numero massimo di workspace permesse per il tuo account. Se hai bisogno di ulteriori workspace per continuare il tuo lavoro, per favore fai richiesta su Github", "deleteSuccess": "Workspace cancellata con successo", "deleteFailed": "Cancellazione workspace fallita", "openSuccess": "Workspace aperta con successo", "openFailed": "Apertura workspace fallita", "renameSuccess": "Workspace rinominata con successo", "renameFailed": "Rinomina workspace fallita", "updateIconSuccess": "Icona della workspace aggiornata con successo", "updateIconFailed": "Aggiornamento icona della workspace fallito", "cannotDeleteTheOnlyWorkspace": "Impossibile cancellare l'unica workspace", "fetchWorkspacesFailed": "Recupero workspaces fallito", "leaveCurrentWorkspace": "Lascia workspace", "leaveCurrentWorkspacePrompt": "Sei sicuro di voler lasciare la workspace corrente?" }, "shareAction": { "buttonText": "Condividi", "workInProgress": "Prossimamente", "markdown": "Markdown", "html": "HTML", "clipboard": "Copia", "csv": "CSV", "copyLink": "Copia Link", "publishToTheWeb": "Pubblica sul Web", "publishToTheWebHint": "Crea un sito con AppFlowy", "publish": "Pubblica", "unPublish": "Annulla la pubblicazione", "visitSite": "Visita sito", "exportAsTab": "Esporta come", "publishTab": "Pubblica", "shareTab": "Condividi", "publishOnAppFlowy": "Pubblica su AppFlowy", "shareTabTitle": "Invita a collaborare", "shareTabDescription": "Per una facile collaborazione con chiunque", "copyLinkSuccess": "Collegamento copiato negli appunti", "copyShareLink": "Copia il link di condivisione", "copyLinkFailed": "Impossibile copiare il collegamento negli appunti", "copyLinkToBlockSuccess": "Collegamento al blocco copiato negli appunti", "copyLinkToBlockFailed": "Impossibile copiare il collegamento del blocco negli appunti", "manageAllSites": "Gestisci tutti i siti", "updatePathName": "Aggiorna il nome del percorso" }, "moreAction": { "small": "piccolo", "medium": "medio", "large": "grande", "fontSize": "Dimensione del font", "import": "Importare", "moreOptions": "Più opzioni", "wordCount": "Conteggio parole: {}", "charCount": "Numero di caratteri: {}", "createdAt": "Creata: {}", "deleteView": "Cancella", "duplicateView": "Duplica", "wordCountLabel": "Numero di parole: ", "charCountLabel": "Numero di caratteri: ", "createdAtLabel": "Creato: ", "syncedAtLabel": "Sincronizzato: ", "saveAsNewPage": "Aggiungi messaggi alla pagina", "saveAsNewPageDisabled": "Nessun messaggio disponibile" }, "importPanel": { "textAndMarkdown": "Testo e markdown", "documentFromV010": "Documento dalla v0.1.0", "databaseFromV010": "Database dalla v0.1.0", "notionZip": "File zip esportato da Notion", "csv": "CSV", "database": "Banca dati" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "Trascina e rilascia un file, fai clic per ", "placeholderUpload": "Carica", "placeholderRight": "oppure incolla il collegamento di un'immagine.", "dropToUpload": "Trascina un file per caricarlo", "change": "Modifica" } }, "disclosureAction": { "rename": "Rinomina", "delete": "Cancella", "duplicate": "Duplica", "unfavorite": "Rimuovi dai preferiti", "favorite": "Aggiungi ai preferiti", "openNewTab": "Apri in una nuova scheda", "moveTo": "Sposta in", "addToFavorites": "Aggiungi ai preferiti", "copyLink": "Copia link", "changeIcon": "Cambia icona", "collapseAllPages": "Comprimi le sottopagine", "movePageTo": "Sposta la pagina in", "move": "Sposta", "lockPage": "Blocca la pagina" }, "blankPageTitle": "Pagina vuota", "newPageText": "Nuova pagina", "newDocumentText": "Nuovo documento", "newGridText": "Nuova griglia", "newCalendarText": "Nuovo calendario", "newBoardText": "Nuova bacheca", "chat": { "newChat": "Chat AI", "inputMessageHint": "Chiedi a @:appName AI", "inputLocalAIMessageHint": "Chiedi a @:appName Local AI", "unsupportedCloudPrompt": "Questa funzione è disponibile solo quando si usa @:appName Cloud", "relatedQuestion": "Correlato", "serverUnavailable": "Servizio temporaneamente non disponibile. Per favore, riprova più tardi.", "aiServerUnavailable": "🌈 Uh-oh! 🌈. Un unicorno ha mangiato la nostra risposta. Per favore, riprova!", "retry": "Riprova", "clickToRetry": "Clicca per riprovare", "regenerateAnswer": "Rigenera", "question1": "Come usare Kanban per organizzare le attività", "question2": "Spiega il metodo GTD", "question3": "Perché usare Rust", "question4": "Ricetta con cos'è presente nella mia cucina", "question5": "Crea un'illustrazione per la mia pagina", "question6": "Stila una lista di cose da fare per la mia prossima settimana", "aiMistakePrompt": "Le IA possono fare errori. Controlla le informazioni importanti.", "chatWithFilePrompt": "Vuoi chattare col file?", "indexFileSuccess": "Indicizzazione file completata con successo", "inputActionNoPages": "Nessuna pagina risultante", "referenceSource": { "zero": "0 fonti trovate", "one": "{count} fonte trovata", "other": "{count} fonti trovate" }, "clickToMention": "Clicca per menzionare una pagina", "uploadFile": "Carica file PDF, MD o TXT con la quale chattare", "questionDetail": "Salve {}! Come posso aiutarti oggi?", "indexingFile": "Indicizzazione {}", "generatingResponse": "Sto generando una risposta", "selectSources": "Seleziona fonti", "currentPage": "Pagina corrente", "sourcesLimitReached": "È possibile selezionare solo fino a 3 documenti di livello superiore e i relativi figli", "sourceUnsupported": "Al momento non supportiamo la chat con i database", "regenerate": "Riprova", "addToPageButton": "Aggiungi messaggio alla pagina", "addToPageTitle": "Aggiungi messaggio a...", "addToNewPage": "Crea una nuova pagina", "addToNewPageName": "Messaggi estratti da \"{}\"", "addToNewPageSuccessToast": "Messaggio aggiunto a", "openPagePreviewFailedToast": "Impossibile aprire la pagina", "changeFormat": { "actionButton": "Cambia formato", "confirmButton": "Rigenera con questo formato", "textOnly": "Testo", "imageOnly": "Solo immagine", "textAndImage": "Testo e immagine", "text": "Paragrafo", "bullet": "Elenco puntato", "number": "Elenco numerato", "table": "Tavolo", "blankDescription": "Formato risposta", "defaultDescription": "Formato di risposta automatica", "textWithImageDescription": "@:chat .changeFormat.text con immagine", "numberWithImageDescription": "@:chat .changeFormat.number con immagine", "bulletWithImageDescription": "@:chat .changeFormat.bullet con immagine", "tableWithImageDescription": "@:chat .changeFormat.table con immagine" }, "switchModel": { "label": "Cambia modello", "localModel": "Modello locale", "cloudModel": "Modello in cloud", "autoModel": "Automatico" }, "selectBanner": { "saveButton": "Aggiungi a …", "selectMessages": "Seleziona i messaggi", "nSelected": "{} selezionati", "allSelected": "Tutti selezionati" }, "stopTooltip": "Smetti di generare" }, "trash": { "text": "Cestino", "restoreAll": "Ripristina Tutto", "restore": "Ripristina", "deleteAll": "Elimina Tutto", "pageHeader": { "fileName": "Nome file", "lastModified": "Ultima Modifica", "created": "Creato" }, "confirmDeleteAll": { "title": "Sei sicuro di eliminare tutte le pagine nel Cestino?", "caption": "Questa azione non può essere annullata." }, "confirmRestoreAll": { "title": "Sei sicuro di ripristinare tutte le pagine nel Cestino?", "caption": "Questa azione non può essere annullata." }, "restorePage": { "title": "Ripristina: {}", "caption": "Sei sicuro di voler ripristinare questa pagina?" }, "mobile": { "actions": "Azioni del cestino", "empty": "Il cestino è vuoto", "emptyDescription": "Non hai alcun file eliminato", "isDeleted": "è stato cancellato", "isRestored": "è stato ripristinato" }, "confirmDeleteTitle": "Sei sicuro di voler eliminare questa pagina permanentemente?" }, "deletePagePrompt": { "text": "Questa pagina è nel Cestino", "restore": "Ripristina pagina", "deletePermanent": "Elimina definitivamente", "deletePermanentDescription": "Vuoi davvero eliminare definitivamente questa pagina? L'operazione è irreversibile." }, "dialogCreatePageNameHint": "Nome pagina", "questionBubble": { "shortcuts": "Scorciatoie", "whatsNew": "Cosa c'è di nuovo?", "helpAndDocumentation": "Aiuto e documentazione", "getSupport": "Ottieni supporto", "markdown": "Markdown", "debug": { "name": "Informazioni di debug", "success": "Informazioni di debug copiate negli appunti!", "fail": "Impossibile copiare le informazioni di debug negli appunti" }, "feedback": "Feedback", "help": "Aiuto & Supporto" }, "menuAppHeader": { "moreButtonToolTip": "Rimuovi, rinomina e altro...", "addPageTooltip": "Aggiungi velocemente una pagina all'interno", "defaultNewPageName": "Senza titolo", "renameDialog": "Rinomina", "pageNameSuffix": "Copia" }, "noPagesInside": "Nessuna pagina all'interno", "toolbar": { "undo": "Undo", "redo": "Redo", "bold": "Grassetto", "italic": "Italico", "underline": "Sottolineato", "strike": "Barrato", "numList": "Lista numerata", "bulletList": "Lista a punti", "checkList": "Lista Controllo", "inlineCode": "Codice in linea", "quote": "Cita Blocco", "header": "intestazione", "highlight": "Evidenziare", "color": "Colore", "addLink": "Aggiungi collegamento", "link": "Collegamento" }, "tooltip": { "lightMode": "Passa alla modalità Chiara", "darkMode": "Passa alla modalità Scura", "openAsPage": "Apri come pagina", "addNewRow": "Aggiungi una nuova riga", "openMenu": "Fare clic per aprire il menu", "dragRow": "Premere a lungo per riordinare la riga", "viewDataBase": "Visualizza banca dati", "referencePage": "Questo {nome} è referenziato", "addBlockBelow": "Aggiungi un blocco qui sotto", "aiGenerate": "Genera" }, "sideBar": { "closeSidebar": "Close sidebar", "openSidebar": "Open sidebar", "expandSidebar": "Espandi a pagina intera", "personal": "Personale", "private": "Privato", "workspace": "Area di lavoro", "favorites": "Preferiti", "clickToHidePrivate": "Clicca per nascondere l'area privata\nLe pagine create qui sono visibili solo a te", "clickToHideWorkspace": "Clicca per nascondere la workspace\nLe pagine che crei qui sono visibili a ogni membro", "clickToHidePersonal": "Fare clic per nascondere la sezione personale", "clickToHideFavorites": "Fare clic per nascondere la sezione dei preferiti", "addAPage": "Aggiungi una pagina", "addAPageToPrivate": "Aggiungi pagina all'area privata", "addAPageToWorkspace": "Aggiungi pagina alla workspace", "recent": "Recente", "today": "Oggi", "thisWeek": "Questa settimana", "others": "Preferiti precedenti", "earlier": "Prima", "justNow": "poco fa", "minutesAgo": "{count} minuti fa", "lastViewed": "Visto per ultimo", "favoriteAt": "Aggiunto tra ai preferiti", "emptyRecent": "Nessun documento recente", "emptyRecentDescription": "Quando vedrai documenti, appariranno qui per accesso facilitato", "emptyFavorite": "Nessun documento preferito", "emptyFavoriteDescription": "Comincia a esplorare e marchia documenti come preferiti. Verranno elencati qui per accesso rapido!", "removePageFromRecent": "Rimuovere questa pagina da Recenti?", "removeSuccess": "Rimozione effettuata con successo", "favoriteSpace": "Preferiti", "RecentSpace": "Recenti", "Spaces": "Aree", "upgradeToPro": "Aggiorna a Pro", "upgradeToAIMax": "Sblocca AI illimitata", "storageLimitDialogTitle": "Hai esaurito lo spazio d'archiviazione gratuito. Aggiorna per avere spazio d'archiviazione illimitato!", "storageLimitDialogTitleIOS": "Hai esaurito lo spazio di archiviazione gratuito.", "aiResponseLimitTitle": "Hai esaurito le risposte AI gratuite. Aggiorna al Piano Pro per acquistare un add-on AI per avere risposte illimitate", "aiResponseLimitDialogTitle": "Numero di riposte AI raggiunto", "aiResponseLimit": "Hai esaurito le risposte AI gratuite.\n\nVai su Impostazioni -> Piano -> Fai clic su AI Max o Pro Plan per ottenere più risposte AI", "askOwnerToUpgradeToPro": "Lo spazio di archiviazione gratuito del tuo spazio di lavoro sta esaurendo. Chiedi al proprietario del tuo spazio di lavoro di passare al piano Pro.", "askOwnerToUpgradeToProIOS": "Lo spazio di archiviazione gratuito nel tuo spazio di lavoro sta esaurendo.", "askOwnerToUpgradeToAIMax": "Il tuo spazio di lavoro ha esaurito le risposte AI gratuite. Chiedi al proprietario del tuo spazio di lavoro di aggiornare il piano o di acquistare componenti aggiuntivi AI.", "askOwnerToUpgradeToAIMaxIOS": "Il tuo spazio di lavoro sta esaurendo le risposte AI gratuite.", "purchaseAIMax": "Il tuo spazio di lavoro ha esaurito le risposte delle immagini AI. Chiedi al proprietario del tuo spazio di lavoro di acquistare AI Max", "aiImageResponseLimit": "Hai esaurito le risposte delle immagini dell'IA.\n\nVai su Impostazioni -> Piano -> Fai clic su AI Max per ottenere più risposte di immagini AI", "purchaseStorageSpace": "Acquista spazio di archiviazione", "singleFileProPlanLimitationDescription": "Hai superato la dimensione massima di caricamento file consentita nel piano gratuito. Passa al piano Pro per caricare file più grandi.", "purchaseAIResponse": "Acquista", "askOwnerToUpgradeToLocalAI": "Chiedi al proprietario dell'area di lavoro di abilitare l'intelligenza artificiale sul dispositivo", "upgradeToAILocal": "Esegui modelli locali sul tuo dispositivo per la massima privacy", "upgradeToAILocalDesc": "Chatta con i PDF, migliora la tua scrittura e compila automaticamente le tabelle utilizzando l'intelligenza artificiale locale" }, "notifications": { "export": { "markdown": "Nota esportata in Markdown", "path": "Documenti/fluido" } }, "contactsPage": { "title": "Contatti", "whatsHappening": "Cosa accadrà la prossima settimana?", "addContact": "Aggiungi Contatti", "editContact": "Modifica Contatti" }, "button": { "ok": "OK", "confirm": "Conferma", "done": "Fatto", "cancel": "Annulla", "signIn": "Accedi", "signOut": "Esci", "complete": "Completa", "change": "Modifica", "save": "Salva", "generate": "creare", "esc": "ESC", "keep": "Mantenere", "tryAgain": "Riprova", "discard": "Scartare", "replace": "Sostituire", "insertBelow": "Inserisci sotto", "insertAbove": "Inserisci sopra", "upload": "Caricamento", "edit": "Modificare", "delete": "Eliminare", "copy": "Copia", "duplicate": "Duplicare", "putback": "Rimettere a posto", "update": "Aggiorna", "share": "Condividi", "removeFromFavorites": "Rimuovi dai preferiti", "removeFromRecent": "Rimuovi da Recenti", "addToFavorites": "Aggiungi ai preferiti", "favoriteSuccessfully": "Preferito aggiunto con successo", "unfavoriteSuccessfully": "Preferito rimosso con successo", "duplicateSuccessfully": "Duplicato con successo", "rename": "Rinomina", "helpCenter": "Centro assistenza", "add": "Aggiungi", "yes": "SÌ", "no": "No", "clear": "Pulisci", "remove": "Rimuovi", "dontRemove": "Non rimuovere", "copyLink": "Copia collegamento", "align": "Allinea", "login": "Login", "logout": "Disconnettiti", "deleteAccount": "Elimina l'account", "back": "Indietro", "signInGoogle": "Continua con Google", "signInGithub": "Continua con GitHub", "signInDiscord": "Continua con Discord", "more": "Di più", "create": "Creare", "close": "Chiudi", "next": "Prossimo", "previous": "Precedente", "submit": "Invia", "download": "Scarica", "backToHome": "Torna alla Home", "viewing": "Visualizzazione", "editing": "Modifica", "gotIt": "Fatto", "retry": "Riprova", "uploadFailed": "Caricamento non riuscito.", "copyLinkOriginal": "Copia il collegamento all'originale" }, "label": { "welcome": "Benvenuto!", "firstName": "Nome", "middleName": "Secondo Nome", "lastName": "Cognome", "stepX": "Passo {X}" }, "oAuth": { "err": { "failedTitle": "Impossibile collegarsi al tuo account.", "failedMsg": "Si prega di verificare di aver completato il processo di iscrizione nel tuo browser." }, "google": { "title": "GOOGLE SIGN-IN", "instruction1": "Al fine di importare i tuoi Contatti Google è necessario autorizzare questa applicazione ad utilizzare il tuo beowser web.", "instruction2": "Copia questo codice negli appunti premendo l'icona o selezionando il testo:", "instruction3": "Naviga sul seguente link con il tuo browser web e inserisci il codice seguente:", "instruction4": "Premi il bottone qui sotto quando hai completato l'iscrizione:" } }, "settings": { "title": "Impostazioni", "popupMenuItem": { "settings": "Impostazioni", "members": "Membri", "trash": "Spazzatura", "helpAndDocumentation": "Aiuto e documentazione", "getSupport": "Ottieni supporto" }, "sites": { "title": "Siti", "namespaceTitle": "Namespace", "namespaceDescription": "Gestisci il tuo namespace e la tua homepage", "namespaceHeader": "Namespace", "homepageHeader": "Homepage", "updateNamespace": "Aggiorna il namespace", "removeHomepage": "Rimuovi la homepage", "selectHomePage": "Seleziona una pagina", "clearHomePage": "Cancella la homepage per questo namespace", "customUrl": "URL personalizzato", "homePage": { "upgradeToPro": "Passa al piano Pro per impostare una homepage" }, "namespace": { "description": "Questa modifica verrà applicata a tutte le pagine pubblicate in questo namespace", "tooltip": "Ci riserviamo il diritto di rimuovere qualsiasi namespace inappropriato", "updateExistingNamespace": "Aggiorna il namexpace esistente", "upgradeToPro": "Passa al piano Pro per richiedere uno spazio dei nomi personalizzato", "redirectToPayment": "Reindirizzamento alla pagina di pagamento...", "onlyWorkspaceOwnerCanSetHomePage": "Solo il proprietario dell'area di lavoro può impostare una homepage", "pleaseAskOwnerToSetHomePage": "Chiedi al proprietario dell'area di lavoro di passare al piano Pro" }, "publishedPage": { "title": "Tutte le pagine pubblicate", "description": "Gestisci le tue pagine pubblicate", "page": "Pagina", "pathName": "Nome del percorso", "date": "Data di pubblicazione", "emptyHinText": "Non hai pagine pubblicate in questo spazio di lavoro", "noPublishedPages": "Nessuna pagina pubblicata", "settings": "Impostazioni di pubblicazione", "clickToOpenPageInApp": "Apri la pagina nell'app", "clickToOpenPageInBrowser": "Apri la pagina nel browser" }, "error": { "failedToGeneratePaymentLink": "Impossibile generare il collegamento di pagamento per il piano Pro", "failedToUpdateNamespace": "Impossibile aggiornare il namespace", "proPlanLimitation": "È necessario eseguire l'aggiornamento al piano Pro per aggiornare lo spazio dei nomi", "namespaceAlreadyInUse": "Il namespace è già stato preso, provane un altro", "invalidNamespace": "Namespace non valido, provane un altro", "namespaceLengthAtLeast2Characters": "Il namespace deve essere lungo almeno 2 caratteri", "onlyWorkspaceOwnerCanUpdateNamespace": "Solo il proprietario dell'area di lavoro può aggiornare il namespace", "onlyWorkspaceOwnerCanRemoveHomepage": "Solo il proprietario dell'area di lavoro può rimuovere la home page", "setHomepageFailed": "Impossibile impostare la homepage", "namespaceTooLong": "Il namespace è troppo lungo, provane un altro", "namespaceTooShort": "Il namespace è troppo breve, provane un altro", "namespaceIsReserved": "Il namespace è riservato, provane un altro", "updatePathNameFailed": "Impossibile aggiornare il percorso", "removeHomePageFailed": "Impossibile rimuovere la homepage", "publishNameContainsInvalidCharacters": "Il percorso contiene caratteri non validi, provane un altro", "publishNameTooShort": "Il percorso è troppo breve, provane un altro", "publishNameTooLong": "Il percorso è troppo lungo, provane un altro", "publishNameAlreadyInUse": "Il percorso è già in uso, provane un altro", "namespaceContainsInvalidCharacters": "Il namespace contiene caratteri non validi, provane un altro", "publishPermissionDenied": "Solo il proprietario dell'area di lavoro o l'editore della pagina può gestire le impostazioni di pubblicazione", "publishNameCannotBeEmpty": "Il percorso non può essere vuoto, provane un altro" }, "success": { "namespaceUpdated": "Namespace aggiornato correttamente", "setHomepageSuccess": "La homepage è stata impostata correttamente", "updatePathNameSuccess": "Percorso aggiornato correttamente", "removeHomePageSuccess": "Homepage rimossa con successo" } }, "accountPage": { "menuLabel": "Account e app", "title": "Il mio account", "general": { "title": "Nome dell'account e immagine del profilo", "changeProfilePicture": "Cambia l'immagine del profilo" }, "email": { "title": "Email", "actions": { "change": "Cambia l'email" } }, "login": { "title": "Accedi all'account", "loginLabel": "Login", "logoutLabel": "Disconnettiti" }, "isUpToDate": "@:appName è aggiornato!", "officialVersion": "Versione {versione} (官方構建)" }, "workspacePage": { "menuLabel": "Area di lavoro", "title": "Area di lavoro", "description": "Personalizza l'aspetto, il tema, il carattere, il layout del testo, il formato data/ora e la lingua del tuo spazio di lavoro.", "workspaceName": { "title": "Nome dell'area di lavoro" }, "workspaceIcon": { "title": "Icona dell'area di lavoro", "description": "Carica un'immagine o usa un'emoji per il tuo spazio di lavoro. L'icona verrà visualizzata nella barra laterale e nelle notifiche." }, "appearance": { "title": "Aspetto", "description": "Personalizza l'aspetto, il tema, il carattere, il layout del testo, la data, l'ora e la lingua del tuo spazio di lavoro.", "options": { "system": "Automatico", "light": "Chiaro", "dark": "Scuro" } }, "resetCursorColor": { "title": "Reimposta il colore del cursore del documento", "description": "Vuoi davvero reimpostare il colore del cursore?" }, "resetSelectionColor": { "title": "Reimposta il colore di selezione del documento", "description": "Vuoi davvero reimpostare il colore di selezione?" }, "resetWidth": { "resetSuccess": "Reimposta correttamente la larghezza del documento" }, "theme": { "title": "Tema", "description": "Seleziona un tema preimpostato o carica il tuo tema personalizzato.", "uploadCustomThemeTooltip": "Carica un tema personalizzato", "failedToLoadThemes": "Impossibile caricare i temi, controlla le impostazioni dei permessi in Impostazioni di sistema -> Privacy e sicurezza -> File e cartelle -> @:appName" }, "workspaceFont": { "title": "Carattere dell'area di lavoro", "noFontHint": "Nessun font trovato, prova un altro termine." }, "textDirection": { "title": "Direzione del testo", "leftToRight": "Da sinistra a destra", "rightToLeft": "Da destra a sinistra", "auto": "Auto", "enableRTLItems": "Abilita gli elementi della barra degli strumenti RTL" }, "layoutDirection": { "title": "Direzione del layout", "leftToRight": "Da sinistra a destra", "rightToLeft": "Da destra a sinistra" }, "dateTime": { "title": "Data e ora", "example": "{} alle {} ({})", "24HourTime": "formato 24 ore", "dateFormat": { "label": "Formato data", "local": "Locale", "us": "US", "iso": "ISO", "friendly": "Amichevole", "dmy": "G/M/A" } }, "language": { "title": "Lingua" }, "deleteWorkspacePrompt": { "title": "Elimina area di lavoro", "content": "Vuoi davvero eliminare questo spazio di lavoro? Questa azione non può essere annullata e tutte le pagine che hai pubblicato verranno rimosse dalla pubblicazione." }, "leaveWorkspacePrompt": { "title": "Lascia l'area di lavoro", "content": "Vuoi davvero uscire da questa area di lavoro? Perderai l'accesso a tutte le pagine e ai dati in essa contenuti.", "success": "Hai abbandonato l'area di lavoro con successo.", "fail": "Impossibile uscire dall'area di lavoro." }, "manageWorkspace": { "title": "Gestisci l'area di lavoro", "leaveWorkspace": "Lascia l'area di lavoro", "deleteWorkspace": "Elimina area di lavoro" } }, "manageDataPage": { "menuLabel": "Gestisci i dati", "title": "Gestisci i dati", "description": "Gestisci l'archiviazione locale dei dati o importa i tuoi dati esistenti in @:appName .", "dataStorage": { "title": "Posizione di archiviazione dei file", "tooltip": "La posizione in cui sono archiviati i tuoi file", "actions": { "change": "Cambia percorso", "open": "Apri cartella", "openTooltip": "Apri la posizione della cartella dati corrente", "copy": "Copia percorso", "copiedHint": "Percorso copiato!", "resetTooltip": "Ripristina la posizione predefinita" }, "resetDialog": { "title": "Sei sicuro?", "description": "Reimpostare il percorso alla posizione predefinita dei dati non eliminerà i dati. Se desideri reimportare i dati correnti, devi prima copiare il percorso della posizione corrente." } }, "importData": { "title": "Importa dati", "tooltip": "Importa dati da cartelle di backup/dati @:appName", "description": "Copia i dati da una cartella dati esterna @:appName", "action": "Sfoglia file" }, "encryption": { "title": "Crittografia", "tooltip": "Gestisci il modo in cui i tuoi dati vengono archiviati e crittografati", "descriptionNoEncryption": "L'attivazione della crittografia crittograferà tutti i dati. Questa operazione non può essere annullata.", "descriptionEncrypted": "I tuoi dati sono crittografati.", "action": "Crittografare i dati", "dialog": { "title": "Vuoi crittografare tutti i tuoi dati?", "description": "La crittografia di tutti i tuoi dati li manterrà al sicuro e protetti. Questa azione NON può essere annullata. Vuoi continuare?" } }, "cache": { "title": "Cancella cache", "description": "Aiuta a risolvere problemi come il mancato caricamento delle immagini, pagine mancanti in uno spazio e caratteri non caricati. Questo non influirà sui tuoi dati.", "dialog": { "title": "Cancella cache", "description": "Aiuta a risolvere problemi come il mancato caricamento delle immagini, pagine mancanti in uno spazio e caratteri non caricati. Questo non influirà sui tuoi dati.", "successHint": "Cache svuotata!" } }, "data": { "fixYourData": "Correggi i tuoi dati", "fixButton": "Correggi", "fixYourDataDescription": "Se riscontri problemi con i tuoi dati, puoi provare a risolverli qui." } }, "shortcutsPage": { "menuLabel": "Scorciatoie", "title": "Scorciatoie", "editBindingHint": "Inserisci nuova associazione", "searchHint": "Ricerca", "actions": { "resetDefault": "Ripristina predefinito" }, "errorPage": { "message": "Impossibile caricare i collegamenti: {}", "howToFix": "Riprova. Se il problema persiste, contattaci su GitHub." }, "resetDialog": { "title": "Reimposta le scorciatoie", "description": "Questa operazione ripristinerà tutte le combinazioni di tasti predefinite; non sarà possibile annullare questa operazione in seguito. Vuoi procedere?", "buttonLabel": "Ripristina" }, "conflictDialog": { "title": "{} è attualmente in uso", "descriptionPrefix": "Questa combinazione di tasti è attualmente utilizzata da ", "descriptionSuffix": ". Se si sostituisce questa combinazione di tasti, questa verrà rimossa da {}.", "confirmLabel": "Continua" }, "editTooltip": "Premere per iniziare a modificare la combinazione di tasti", "keybindings": { "toggleToDoList": "Attiva/disattiva l'elenco delle cose da fare", "insertNewParagraphInCodeblock": "Inserisci nuovo paragrafo", "pasteInCodeblock": "Incolla nel blocco di codice", "selectAllCodeblock": "Seleziona tutto", "indentLineCodeblock": "Inserisci due spazi all'inizio della riga", "outdentLineCodeblock": "Elimina due spazi all'inizio della riga", "twoSpacesCursorCodeblock": "Inserisci due spazi al cursore", "copy": "Copia selezione", "paste": "Incolla nel contenuto", "cut": "Taglia la selezione", "alignLeft": "Allinea il testo a sinistra", "alignCenter": "Allinea il testo al centro", "alignRight": "Allinea il testo a destra", "insertInlineMathEquation": "Inserisci equazione matematica in linea", "undo": "Annulla", "redo": "Rifai", "convertToParagraph": "Converti blocco in paragrafo", "backspace": "Elimina", "deleteLeftWord": "Elimina la parola a sinistra", "deleteLeftSentence": "Elimina la frase a sinistra", "delete": "Elimina il carattere corretto", "deleteMacOS": "Elimina il carattere a sinistra", "deleteRightWord": "Elimina la parola a destra", "moveCursorLeft": "Sposta il cursore a sinistra", "moveCursorBeginning": "Sposta il cursore all'inizio", "moveCursorLeftWord": "Sposta il cursore a sinistra di una parola", "moveCursorLeftSelect": "Seleziona e sposta il cursore a sinistra", "moveCursorBeginSelect": "Seleziona e sposta il cursore all'inizio", "moveCursorLeftWordSelect": "Seleziona e sposta il cursore a sinistra di una parola", "moveCursorRight": "Sposta il cursore a destra", "moveCursorEnd": "Sposta il cursore alla fine", "moveCursorRightWord": "Sposta il cursore a destra di una parola", "moveCursorRightSelect": "Seleziona e sposta il cursore a destra", "moveCursorEndSelect": "Seleziona e sposta il cursore alla fine", "moveCursorRightWordSelect": "Seleziona e sposta il cursore a destra di una parola", "moveCursorUp": "Sposta il cursore verso l'alto", "moveCursorTopSelect": "Seleziona e sposta il cursore in alto", "moveCursorTop": "Sposta il cursore in alto", "moveCursorUpSelect": "Seleziona e sposta il cursore verso l'alto", "moveCursorBottomSelect": "Seleziona e sposta il cursore in basso", "moveCursorBottom": "Sposta il cursore in basso", "moveCursorDown": "Sposta il cursore verso il basso", "moveCursorDownSelect": "Seleziona e sposta il cursore verso il basso", "home": "Scorri verso l'alto", "end": "Scorri fino in fondo", "toggleBold": "Attiva/disattiva grassetto", "toggleItalic": "Attiva/disattiva il corsivo", "toggleUnderline": "Attiva/disattiva la sottolineatura", "toggleStrikethrough": "Attiva/disattiva barrato", "toggleCode": "Attiva/disattiva il codice in linea", "toggleHighlight": "Attiva/disattiva l'evidenziazione", "showLinkMenu": "Mostra il menu dei link", "openInlineLink": "Apri collegamento in linea", "openLinks": "Apri tutti i link selezionati", "indent": "Aumenta rientro", "outdent": "Riduci rientro", "exit": "Esci dalla modifica", "pageUp": "Scorri una pagina verso l'alto", "pageDown": "Scorri una pagina verso il basso", "selectAll": "Seleziona tutto", "pasteWithoutFormatting": "Incolla il contenuto senza formattazione", "showEmojiPicker": "Mostra selettore emoji", "enterInTableCell": "Aggiungi interruzione di riga nella tabella", "leftInTableCell": "Sposta a sinistra di una cella nella tabella", "rightInTableCell": "Spostati a destra di una cella nella tabella", "upInTableCell": "Spostarsi su di una cella nella tabella", "downInTableCell": "Spostarsi verso il basso di una cella nella tabella", "tabInTableCell": "Vai alla cella disponibile successiva nella tabella", "shiftTabInTableCell": "Vai alla cella precedentemente disponibile nella tabella", "backSpaceInTableCell": "Fermati all'inizio della cella" }, "commands": { "codeBlockNewParagraph": "Inserisci un nuovo paragrafo accanto al blocco di codice", "codeBlockIndentLines": "Inserire due spazi all'inizio della riga nel blocco di codice", "codeBlockOutdentLines": "Elimina due spazi all'inizio della riga nel blocco di codice", "codeBlockAddTwoSpaces": "Inserisci due spazi nella posizione del cursore nel blocco di codice", "codeBlockSelectAll": "Seleziona tutto il contenuto all'interno di un blocco di codice", "codeBlockPasteText": "Incolla il testo nel blocco di codice", "textAlignLeft": "Allinea il testo a sinistra", "textAlignCenter": "Allinea il testo al centro", "textAlignRight": "Allinea il testo a destra" }, "couldNotLoadErrorMsg": "Impossibile caricare i collegamenti. Riprova.", "couldNotSaveErrorMsg": "Impossibile salvare i collegamenti. Riprova." }, "aiPage": { "title": "Impostazioni AI", "menuLabel": "Impostazioni AI", "keys": { "enableAISearchTitle": "Ricerca AI", "aiSettingsDescription": "Scegli il modello che preferisci per potenziare AppFlowy AI. Ora include GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet e modelli disponibili in Ollama.", "loginToEnableAIFeature": "Le funzionalità di intelligenza artificiale sono abilitate solo dopo aver effettuato l'accesso con @:appName Cloud. Se non hai un @:appName , vai su \"Il mio account\" per registrarti.", "llmModel": "Modello linguistico", "globalLLMModel": "Modello linguistico globale", "readOnlyField": "Questo campo è di sola lettura", "llmModelType": "Tipo di modello linguistico", "downloadLLMPrompt": "Scarica {}", "downloadAppFlowyOfflineAI": "Scaricando il pacchetto AI offline, l'AI potrà essere eseguita sul tuo dispositivo. Vuoi continuare?", "downloadLLMPromptDetail": "Il download del modello locale {} occuperà fino a {} di spazio di archiviazione. Vuoi continuare?", "downloadBigFilePrompt": "Potrebbero essere necessari circa 10 minuti per completare il download", "downloadAIModelButton": "Scarica", "downloadingModel": "Scaricando", "localAILoaded": "Modello AI locale aggiunto correttamente e pronto per l'uso", "localAIStart": "L'intelligenza artificiale locale si sta avviando. Se è lenta, prova a disattivarla e riattivarla.", "localAILoading": "Caricamento del modello di chat AI locale...", "localAIStopped": "L'IA locale si è fermata", "localAIRunning": "L'intelligenza artificiale locale è in esecuzione", "localAINotReadyRetryLater": "L'IA locale è in fase di inizializzazione, riprova più tardi", "localAIDisabled": "Stai utilizzando l'intelligenza artificiale locale, ma è disabilitata. Vai alle impostazioni per abilitarla o prova un modello diverso.", "localAIInitializing": "L'intelligenza artificiale locale è in fase di caricamento. Potrebbero volerci alcuni secondi, a seconda del dispositivo.", "localAINotReadyTextFieldPrompt": "Non è possibile modificare mentre l'IA locale è in fase di caricamento", "localAIDisabledTextFieldPrompt": "Non puoi modificare mentre l'IA locale è disabilitata", "failToLoadLocalAI": "Impossibile avviare l'IA locale.", "restartLocalAI": "Riavvia", "disableLocalAITitle": "Disabilita l'IA locale", "disableLocalAIDescription": "Vuoi disattivare l'IA locale?", "localAIToggleTitle": "AppFlowy IA locale (LAI)", "localAIToggleSubTitle": "Esegui i modelli di intelligenza artificiale locale più avanzati all'interno di AppFlowy per la massima privacy e sicurezza", "offlineAIInstruction1": "Segui l'", "offlineAIInstruction2": "istruzione", "offlineAIInstruction3": "per abilitare l'intelligenza artificiale offline.", "offlineAIDownload1": "Se non hai scaricato AppFlowy AI, per favore", "offlineAIDownload2": "scaricala", "offlineAIDownload3": "prima di tutto", "activeOfflineAI": "Attiva", "downloadOfflineAI": "Scarica", "openModelDirectory": "Apri cartella", "laiNotReady": "L'app Local AI non è stata installata correttamente.", "ollamaNotReady": "Il server Ollama non è pronto.", "pleaseFollowThese": "Per favore segui queste", "instructions": "istruzioni", "installOllamaLai": "per configurare Ollama e AppFlowy Local AI.", "modelsMissing": "Impossibile trovare i modelli richiesti: ", "downloadModel": "per scaricarli." } }, "planPage": { "menuLabel": "Piano", "title": "Piano tariffario", "planUsage": { "title": "Riepilogo dell'utilizzo del piano", "storageLabel": "Archiviazione", "storageUsage": "{} di {} GB", "unlimitedStorageLabel": "Spazio di archiviazione illimitato", "collaboratorsLabel": "Membri", "collaboratorsUsage": "{} di {}", "aiResponseLabel": "Risposte IA", "aiResponseUsage": "{} di {}", "unlimitedAILabel": "Risposte illimitate", "proBadge": "Pro", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "AI sul dispositivo per Mac", "memberProToggle": "Più membri e accesso illimitato all'IA e agli ospiti", "aiMaxToggle": "Intelligenza artificiale illimitata e accesso a modelli avanzati", "aiOnDeviceToggle": "Intelligenza artificiale locale per la massima privacy", "aiCredit": { "title": "Aggiungi credito AI @:appName", "price": "{}", "priceDescription": "per 1.000 crediti", "purchase": "Acquista AI", "info": "Aggiungi 1.000 crediti AI per area di lavoro e integra perfettamente l'AI personalizzabile nel tuo flusso di lavoro per risultati più intelligenti e rapidi con un massimo di:", "infoItemOne": "10.000 risposte per database", "infoItemTwo": "1.000 risposte per spazio di lavoro" }, "currentPlan": { "bannerLabel": "Piano attuale", "freeTitle": "Gratuito", "proTitle": "Pro", "teamTitle": "Team", "freeInfo": "Perfetto per singoli fino a 2 membri per organizzare tutto", "proInfo": "Perfetto per team piccoli e medi fino a 10 membri.", "teamInfo": "Perfetto per tutti i team produttivi e ben organizzati.", "upgrade": "Cambia piano", "canceledInfo": "Il tuo piano è stato annullato e il {} passerà al piano gratuito." }, "addons": { "title": "Componenti aggiuntivi", "addLabel": "Aggiungi", "activeLabel": "Aggiunto", "aiMax": { "title": "AI Max", "description": "Risposte AI illimitate basate su modelli AI avanzati e 50 immagini AI al mese", "price": "{}", "priceInfo": "Per utente, al mese, fatturato annualmente" }, "aiOnDevice": { "title": "AI sul dispositivo per Mac", "description": "Esegui Mistral 7B, LLAMA 3 e altri modelli locali sul tuo computer", "price": "{}", "priceInfo": "Per utente al mese fatturato annualmente", "recommend": "Si consiglia M1 o più recente" } }, "deal": { "bannerLabel": "Offerta per il nuovo anno!", "title": "Fai crescere il tuo team!", "info": "Esegui l'upgrade e risparmia il 10% sui piani Pro e Team! Aumenta la produttività del tuo spazio di lavoro con nuove potenti funzionalità, tra cui l'intelligenza artificiale @:appName .", "viewPlans": "Visualizza i piani" } } }, "billingPage": { "menuLabel": "Fatturazione", "title": "Fatturazione", "plan": { "title": "Piano", "freeLabel": "Gratuito", "proLabel": "Pro", "planButtonLabel": "Cambia piano", "billingPeriod": "Periodo di fatturazione", "periodButtonLabel": "Periodo di modifica" }, "paymentDetails": { "title": "Dettagli di pagamento", "methodLabel": "Metodo di pagamento", "methodButtonLabel": "Modifica il metodo" }, "addons": { "title": "Componenti aggiuntivi", "addLabel": "Aggiungi", "removeLabel": "Rimuovi", "renewLabel": "Rinnova", "aiMax": { "label": "AI Max", "description": "Sblocca IA illimitata e modelli avanzati", "activeDescription": "Prossima fattura in scadenza il {}", "canceledDescription": "AI Max sarà disponibile fino al {}" }, "aiOnDevice": { "label": "AI sul dispositivo per Mac", "description": "Sblocca l'intelligenza artificiale illimitata sul tuo dispositivo", "activeDescription": "Prossima fattura in scadenza il {}", "canceledDescription": "AI On-device per Mac sarà disponibile fino al {}" }, "removeDialog": { "title": "Rimuovi {}", "description": "Vuoi davvero rimuovere {plan}? Perderai immediatamente l'accesso alle funzionalità e ai vantaggi di {plan}." } }, "currentPeriodBadge": "ATTUALE", "changePeriod": "Modifica il periodo", "planPeriod": "{} periodo", "monthlyInterval": "Mensile", "monthlyPriceInfo": "per posto fatturato mensilmente", "annualInterval": "Annualmente", "annualPriceInfo": "per posto fatturato annualmente" }, "comparePlanDialog": { "title": "Confronta e seleziona il piano", "planFeatures": "Piano\nCaratteristiche", "current": "Attuale", "actions": { "upgrade": "Aggiorna", "downgrade": "Downgrade", "current": "Attuale" }, "freePlan": { "title": "Gratuito", "description": "Per singoli fino a 2 membri per organizzare tutto", "price": "{}", "priceInfo": "Gratuito per sempre" }, "proPlan": { "title": "Pro", "description": "Per piccoli team per gestire progetti e conoscenze di squadra", "price": "{}", "priceInfo": "Per utente al mese\nfatturato annualmente\n{} fatturato mensilmente" }, "planLabels": { "itemOne": "Spazi di lavoro", "itemTwo": "Membri", "itemThree": "Archiviazione", "itemFour": "Collaborazione in tempo reale", "itemFive": "Editor ospiti", "itemSix": "Risposte AI", "itemSeven": "Immagini AI", "itemFileUpload": "Caricamento di file", "customNamespace": "Namespace personalizzato", "tooltipFive": "Collaborare su pagine specifiche con i non membri", "tooltipSix": "Per sempre significa che il numero di risposte non viene mai azzerato", "intelligentSearch": "Ricerca intelligente", "tooltipSeven": "Ti consente di personalizzare parte dell'URL per il tuo spazio di lavoro", "customNamespaceTooltip": "URL del sito pubblicato personalizzato" }, "freeLabels": { "itemOne": "Addebitato per spazio di lavoro", "itemTwo": "Fino a 2", "itemThree": "5 GB", "itemFour": "si", "itemFive": "si", "itemSix": "10 a vita", "itemSeven": "2 a vita", "itemFileUpload": "Fino a 7 MB", "intelligentSearch": "Ricerca intelligente" }, "proLabels": { "itemOne": "Addebitato per spazio di lavoro", "itemTwo": "Fino a 10", "itemThree": "Illimitato", "itemFour": "si", "itemFive": "Fino a 100", "itemSix": "Illimitato", "itemSeven": "50 immagini al mese", "itemFileUpload": "Illimitato", "intelligentSearch": "Ricerca intelligente" }, "paymentSuccess": { "title": "Ora sei sul piano {}!", "description": "Il tuo pagamento è stato elaborato con successo e il tuo piano è stato aggiornato a @:appName {}. Puoi visualizzare i dettagli del tuo piano nella pagina Piano" }, "downgradeDialog": { "title": "Sei sicuro di voler effettuare il downgrade del tuo piano?", "description": "Effettuando il downgrade del tuo piano tornerai al piano gratuito. Gli utenti potrebbero perdere l'accesso a questo spazio di lavoro e potresti dover liberare spazio per rispettare i limiti di archiviazione del piano gratuito.", "downgradeLabel": "Effettua il downgrade del piano" } }, "cancelSurveyDialog": { "title": "Ci dispiace vederti andare", "description": "Ci dispiace vederti andare via. Ci piacerebbe ricevere il tuo feedback per aiutarci a migliorare @:appName . Ti preghiamo di dedicare un momento per rispondere ad alcune domande.", "commonOther": "Altro", "otherHint": "Scrivi qui la tua risposta", "questionOne": { "question": "Cosa ti ha spinto ad annullare il tuo abbonamento Pro @:appName ?", "answerOne": "Costo troppo alto", "answerTwo": "Le caratteristiche non hanno soddisfatto le aspettative", "answerThree": "Ho trovato un'alternativa migliore", "answerFour": "Non l'ho usato abbastanza per giustificare la spesa", "answerFive": "Problema di servizio o difficoltà tecniche" }, "questionTwo": { "question": "Quanto è probabile che in futuro prenderai in considerazione l'idea di riabbonarti a @:appName Pro?", "answerOne": "Molto probabile", "answerTwo": "Abbastanza probabile", "answerThree": "Non so", "answerFour": "Improbabile", "answerFive": "Molto improbabile" }, "questionThree": { "question": "Quale funzionalità Pro hai apprezzato di più durante il tuo abbonamento?", "answerOne": "Collaborazione multiutente", "answerTwo": "Cronologia delle versioni più lunghe", "answerThree": "Risposte AI illimitate", "answerFour": "Accesso ai modelli di intelligenza artificiale locali" }, "questionFour": { "question": "Come descriveresti la tua esperienza complessiva con @:appName ?", "answerOne": "Eccezionale", "answerTwo": "Buona", "answerThree": "Nella media", "answerFour": "Sotto la media", "answerFive": "Insoddisfatto" } }, "common": { "uploadingFile": "Il file è in fase di caricamento. Non uscire dall'app.", "uploadNotionSuccess": "Il tuo file zip Notion è stato caricato correttamente. Una volta completata l'importazione, riceverai un'email di conferma.", "reset": "Reset" }, "menu": { "appearance": "Aspetto", "language": "Lingua", "user": "Utente", "files": "File", "notifications": "Notifiche", "open": "Apri le impostazioni", "logout": "Disconnettersi", "logoutPrompt": "Sei sicuro di disconnetterti?", "selfEncryptionLogoutPrompt": "Sei sicuro di volerti disconnettere? Assicurati di aver copiato il segreto di crittografia", "syncSetting": "Impostazioni di sincronizzazione", "cloudSettings": "Impostazioni cloud", "enableSync": "Abilita la sincronizzazione", "enableEncrypt": "Crittografare i dati", "cloudURL": "URL di base", "invalidCloudURLScheme": "Schema non valido", "cloudServerType": "Server cloud", "cloudServerTypeTip": "Tieni presente che dopo aver cambiato il server cloud il tuo account corrente potrebbe disconnettersi", "cloudLocal": "Locale", "cloudAppFlowy": "@:appName Cloud Beta", "cloudAppFlowySelfHost": "@:appName Cloud Self-hosted (autogestito)", "appFlowyCloudUrlCanNotBeEmpty": "L'url del cloud non può essere vuoto", "clickToCopy": "Fare clic per copiare", "selfHostStart": "Se non disponi di un server, fai riferimento a", "selfHostContent": "documento", "selfHostEnd": "per indicazioni su come fare il self-host del proprio server", "cloudURLHint": "Inserisci l'URL di base del tuo server", "cloudWSURL": "URL del Websocket", "cloudWSURLHint": "Inserisci l'indirizzo websocket del tuo server", "restartApp": "Riavvia", "restartAppTip": "Riavvia l'applicazione affinché le modifiche abbiano effetto. Tieni presente che ciò potrebbe disconnettere il tuo account corrente", "changeServerTip": "Dopo aver modificato il server, è necessario fare clic sul pulsante di riavvio affinché le modifiche abbiano effetto.", "enableEncryptPrompt": "Attiva la crittografia per proteggere i tuoi dati con questo segreto. Conservarlo in modo sicuro; una volta abilitato, non può essere spento. In caso di perdita, i tuoi dati diventano irrecuperabili. Fare clic per copiare", "inputEncryptPrompt": "Inserisci il tuo segreto di crittografia per", "clickToCopySecret": "Fare clic per copiare il segreto", "configServerSetting": "Configura le impostazioni del tuo server", "configServerGuide": "Dopo aver selezionato \"Avvio rapido\", vai a \"Impostazioni\" e quindi a \"Impostazioni cloud\" per configurare il tuo server self-hosted.", "inputTextFieldHint": "Il tuo segreto", "historicalUserList": "Cronologia di accesso dell'utente", "historicalUserListTooltip": "Questo elenco mostra i tuoi account anonimi. È possibile fare clic su un account per visualizzarne i dettagli. Gli account anonimi vengono creati facendo clic sul pulsante \"Inizia\".", "openHistoricalUser": "Fare clic per aprire l'account anonimo", "customPathPrompt": "L'archiviazione della cartella dati di @:appName in una cartella sincronizzata sul cloud come Google Drive può comportare rischi. Se si accede o si modifica il database all'interno di questa cartella da più posizioni contemporaneamente, potrebbero verificarsi conflitti di sincronizzazione e potenziale danneggiamento dei dati", "importAppFlowyData": "Importa dati dalla cartella @:appName esterna", "importingAppFlowyDataTip": "L'importazione dei dati è in corso. Non chiudere l'applicazione", "importAppFlowyDataDescription": "Copia i dati da una cartella dati @:appName esterna e importali nella cartella dati @:appName corrente", "importSuccess": "Importazione della cartella dati @:appName riuscita", "importFailed": "L'importazione della cartella dati di @:appName non è riuscita", "importGuide": "Per ulteriori dettagli si prega di consultare il documento di riferimento" }, "notifications": { "enableNotifications": { "label": "Abilita le notifiche", "hint": "Disattiva per impedire la visualizzazione delle notifiche locali." } }, "appearance": { "resetSetting": "Ripristina", "fontFamily": { "label": "Famiglia di font", "search": "Ricerca" }, "themeMode": { "label": "Modalità tema", "light": "Modalità Chiara", "dark": "Modalità Scura", "system": "Segui il sistema" }, "documentSettings": { "cursorColor": "Colore del cursore del documento", "selectionColor": "Colore di selezione del documento", "hexEmptyError": "Il colore hex non può essere vuoto", "hexLengthError": "Il valore hex deve essere lungo 6 cifre", "hexInvalidError": "Valore hex non valido", "opacityRangeError": "L'opacità deve essere compresa tra 1 e 100", "app": "App", "flowy": "Flowy", "apply": "Applica" }, "layoutDirection": { "label": "Direzione dell'impaginazione", "hint": "Controlla il flusso dei contenuti sullo schermo, da sinistra a destra o da destra a sinistra.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "Direzione del testo predefinita", "hint": "Specifica se il testo deve iniziare da sinistra o da destra come impostazione predefinita.", "ltr": "LTR", "rtl": "RTL", "auto": "AUTO", "fallback": "Uguale alla direzione del layout" }, "themeUpload": { "button": "Caricamento", "uploadTheme": "Carica tema", "description": "Carica il tuo tema @:appName utilizzando il pulsante in basso.", "loading": "Attendi mentre convalidiamo e carichiamo il tuo tema...", "uploadSuccess": "Il tuo tema è stato caricato correttamente", "deletionFailure": "Impossibile eliminare il tema. Prova a eliminarlo manualmente.", "filePickerDialogTitle": "Scegli un file .flowy_plugin", "urlUploadFailure": "Impossibile aprire l'URL: {}", "failure": "Il tema che è stato caricato aveva un formato non valido." }, "theme": "Tema", "builtInsLabel": "Temi incorporati", "pluginsLabel": "Plugin", "dateFormat": { "label": "Formato data", "local": "Locale", "us": "US", "iso": "ISO", "friendly": "Amichevole", "dmy": "G/M/A" }, "timeFormat": { "label": "Formato orario", "twelveHour": "Dodici ore", "twentyFourHour": "Ventiquattro ore" }, "showNamingDialogWhenCreatingPage": "Mostra la finestra di dialogo per la denominazione durante la creazione di una pagina" }, "files": { "copy": "copia", "defaultLocation": "Leggi i file e la posizione di archiviazione dei dati", "exportData": "Esporta i tuoi dati", "doubleTapToCopy": "Tocca due volte per copiare il percorso", "restoreLocation": "Ripristina nel percorso predefinito di @:appName", "customizeLocation": "Apri un'altra cartella", "restartApp": "Riavvia l'app per rendere effettive le modifiche.", "exportDatabase": "Esporta banca dati", "selectFiles": "Seleziona i file che devono essere esportati", "selectAll": "Seleziona tutto", "deselectAll": "Deselezionare tutto", "createNewFolder": "Crea una nuova cartella", "createNewFolderDesc": "Dicci dove vuoi salvare i tuoi dati", "defineWhereYourDataIsStored": "Definisci dove sono archiviati i tuoi dati", "open": "Aprire", "openFolder": "Apri una cartella esistente", "openFolderDesc": "Leggilo e scrivilo nella tua cartella @:appName esistente", "folderHintText": "nome della cartella", "location": "Creazione di una nuova cartella", "locationDesc": "Scegli un nome per la cartella dei dati di @:appName", "browser": "Navigare", "create": "Creare", "set": "Impostato", "folderPath": "Percorso per memorizzare la tua cartella", "locationCannotBeEmpty": "Il percorso non può essere vuoto", "pathCopiedSnackbar": "Percorso di archiviazione file copiato negli appunti!", "changeLocationTooltips": "Modificare la directory dei dati", "change": "Modifica", "openLocationTooltips": "Apri un'altra directory di dati", "openCurrentDataFolder": "Apre la directory dei dati corrente", "recoverLocationTooltips": "Ripristina la directory dei dati predefinita di @:appName", "exportFileSuccess": "Esporta file con successo!", "exportFileFail": "File di esportazione non riuscito!", "export": "Esportare" }, "user": { "name": "Nome", "email": "E-mail", "tooltipSelectIcon": "Seleziona l'icona", "selectAnIcon": "Seleziona un'icona", "pleaseInputYourOpenAIKey": "inserisci la tua chiave AI", "clickToLogout": "Fare clic per disconnettere l'utente corrente", "pleaseInputYourStabilityAIKey": "per favore inserisci la tua chiave Stability AI" }, "mobile": { "personalInfo": "Informazione personale", "username": "Nome utente", "usernameEmptyError": "Il nome utente non può essere vuoto", "about": "Informazioni", "pushNotifications": "Notifiche push", "support": "Supporto", "joinDiscord": "Unisciti a noi su Discord", "privacyPolicy": "Politica sulla riservatezza", "userAgreement": "Accordo per gli utenti", "termsAndConditions": "Termini e Condizioni", "userprofileError": "Impossibile caricare il profilo utente", "userprofileErrorDescription": "Prova a disconnetterti e ad accedere nuovamente per verificare se il problema persiste.", "selectLayout": "Seleziona disposizione", "selectStartingDay": "Seleziona il giorno di inizio", "version": "Versione" }, "shortcuts": { "shortcutsLabel": "Scorciatoie", "command": "Comando", "keyBinding": "Combinazione", "addNewCommand": "Aggiungi un nuovo comando", "updateShortcutStep": "Premi la combinazione di tasti e poi premi INVIO", "shortcutIsAlreadyUsed": "Questa scorciatoia è già usata per: {conflict}", "resetToDefault": "Ripristina le combinazioni di default", "couldNotLoadErrorMsg": "Impossibile caricare le scorciatoie, Riprova.", "couldNotSaveErrorMsg": "Impossibile salvare le scorciatoie, Riprova." } }, "grid": { "deleteView": "Sei sicuro di voler eliminare questa vista?", "createView": "Nuovo", "title": { "placeholder": "Senza titolo" }, "settings": { "filter": "Filtro", "sort": "Ordinare", "sortBy": "Ordina per", "properties": "Proprietà", "reorderPropertiesTooltip": "Trascina per riordinare le proprietà", "group": "Gruppo", "addFilter": "Aggiungi filtro", "deleteFilter": "Elimina filtro", "filterBy": "Filtra per...", "typeAValue": "Digita un valore...", "layout": "Disposizione", "databaseLayout": "Disposizione", "editView": "Modifica vista", "boardSettings": "Impostazioni della bacheca", "calendarSettings": "Impostazioni del calendario", "createView": "Nuova vista", "duplicateView": "Duplica vista", "deleteView": "Elimina vista", "numberOfVisibleFields": "{} mostrato", "viewList": "Viste del database" }, "textFilter": { "contains": "Contiene", "doesNotContain": "Non contiene", "endsWith": "Finisce con", "startWith": "Inizia con", "is": "È", "isNot": "Non è", "isEmpty": "È vuoto", "isNotEmpty": "Non è vuoto", "choicechipPrefix": { "isNot": "Non", "startWith": "Inizia con", "endWith": "Finisce con", "isEmpty": "è vuoto", "isNotEmpty": "non è vuoto" } }, "checkboxFilter": { "isChecked": "Controllato", "isUnchecked": "Deselezionato", "choicechipPrefix": { "is": "È" } }, "checklistFilter": { "isComplete": "è completo", "isIncomplted": "è incompleto" }, "selectOptionFilter": { "is": "È", "isNot": "Non è", "contains": "Contiene", "doesNotContain": "Non contiene", "isEmpty": "È vuoto", "isNotEmpty": "Non è vuoto" }, "dateFilter": { "is": "È", "before": "È prima", "after": "È dopo", "onOrBefore": "È in corrispondenza o prima", "onOrAfter": "È in corrispondenza o dopo", "between": "È tra", "empty": "È vuoto", "notEmpty": "Non è vuoto" }, "numberFilter": { "equal": "Uguale", "notEqual": "Non è uguale", "lessThan": "È meno di", "greaterThan": "È maggiore di", "lessThanOrEqualTo": "È inferiore o uguale a", "greaterThanOrEqualTo": "È maggiore o uguale a", "isEmpty": "È vuoto", "isNotEmpty": "Non è vuoto" }, "field": { "hide": "Nascondere", "show": "Mostra", "insertLeft": "Inserisci a sinistra", "insertRight": "Inserisci a destra", "duplicate": "Duplicare", "delete": "Eliminare", "textFieldName": "Testo", "checkboxFieldName": "Casella di controllo", "dateFieldName": "Data", "updatedAtFieldName": "Ora dell'ultima modifica", "createdAtFieldName": "Tempo creato", "numberFieldName": "Numeri", "singleSelectFieldName": "Selezionare", "multiSelectFieldName": "Selezione multipla", "urlFieldName": "URL", "checklistFieldName": "Lista di controllo", "numberFormat": "Formato numerico", "dateFormat": "Formato data", "includeTime": "Includi il tempo", "isRange": "Data di fine", "dateFormatFriendly": "Mese giorno anno", "dateFormatISO": "Anno mese giorno", "dateFormatLocal": "Mese giorno anno", "dateFormatUS": "Anno mese giorno", "dateFormatDayMonthYear": "Giorno mese Anno", "timeFormat": "Formato orario", "invalidTimeFormat": "Formato non valido", "timeFormatTwelveHour": "12 ore", "timeFormatTwentyFourHour": "24 ore", "clearDate": "Pulisci data", "dateTime": "Data e ora", "failedToLoadDate": "Impossibile caricare il valore della data", "selectTime": "Seleziona l'ora", "selectDate": "Seleziona la data", "visibility": "Visibilità", "propertyType": "Tipo di proprietà", "addSelectOption": "Aggiungi un'opzione", "typeANewOption": "Digita una nuova opzione", "optionTitle": "Opzioni", "addOption": "Aggiungi opzione", "editProperty": "Modifica proprietà", "newProperty": "Nuova proprietà", "deleteFieldPromptMessage": "Sei sicuro? Questa proprietà verrà eliminata", "newColumn": "Nuova colonna", "format": "Formato", "reminderOnDateTooltip": "Questa cella ha un promemoria programmato", "optionAlreadyExist": "L'opzione esiste già" }, "rowPage": { "newField": "Aggiungi un nuovo campo", "fieldDragElementTooltip": "Fare clic per aprire il menu", "showHiddenFields": { "one": "Mostra {count} campo nascosto", "many": "Mostra {count} campi nascosti", "other": "Mostra {count} campi nascosti" }, "hideHiddenFields": { "one": "Nascondi {count} campo nascosto", "many": "Nascondi {count} campi nascosti", "other": "Nascondi {count} campi nascosti" } }, "sort": { "ascending": "Ascendente", "descending": "Discendente", "cannotFindCreatableField": "Impossibile trovare un campo adatto per l'ordinamento", "deleteAllSorts": "Elimina tutti gli ordinamenti", "addSort": "Aggiungi ordinamento", "removeSorting": "Si desidera rimuovere l'ordinamento?", "deleteSort": "Elimina ordinamento" }, "row": { "duplicate": "Duplicare", "delete": "Eliminare", "titlePlaceholder": "Senza titolo", "textPlaceholder": "Vuoto", "copyProperty": "Proprietà copiata negli appunti", "count": "Contare", "newRow": "Nuova fila", "action": "Azione", "add": "Fai clic su Aggiungi qui sotto", "drag": "Trascina per spostare", "dragAndClick": "Trascina per spostare, fai clic per aprire il menu", "insertRecordAbove": "Inserisci registrazione sopra", "insertRecordBelow": "Inserisci registazione sotto" }, "selectOption": { "create": "Creare", "purpleColor": "Viola", "pinkColor": "Rosa", "lightPinkColor": "Rosa chiaro", "orangeColor": "Arancia", "yellowColor": "Giallo", "limeColor": "Lime", "greenColor": "Verde", "aquaColor": "Acqua", "blueColor": "Blu", "deleteTag": "Elimina etichetta", "colorPanelTitle": "Colori", "panelTitle": "Seleziona un'opzione o creane una", "searchOption": "Cerca un'opzione", "searchOrCreateOption": "Cerca o crea un'opzione...", "createNew": "Crea un nuovo", "orSelectOne": "Oppure seleziona un'opzione", "typeANewOption": "Digita una nuova opzione", "tagName": "Nome dell'etichetta" }, "checklist": { "taskHint": "Descrizione dell'attività", "addNew": "Aggiungi un elemento", "submitNewTask": "Crea", "hideComplete": "Nascondi le attività completate", "showComplete": "Mostra tutte le attività" }, "url": { "launch": "Apri nel browser", "copy": "Copia l'URL" }, "menuName": "Griglia", "referencedGridPrefix": "Vista di", "calculate": "Calcolare", "calculationTypeLabel": { "average": "Media", "max": "Massimo", "median": "Medio", "min": "Minimo", "sum": "Somma" } }, "document": { "menuName": "Documento", "date": { "timeHintTextInTwelveHour": "13:00", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Seleziona una bacheca a cui collegarti", "createANewBoard": "Crea una nuova bacheca" }, "grid": { "selectAGridToLinkTo": "Seleziona una griglia a cui collegarti", "createANewGrid": "Crea una nuova griglia" }, "calendar": { "selectACalendarToLinkTo": "Seleziona un calendario a cui collegarti", "createANewCalendar": "Crea un nuovo calendario" }, "document": { "selectADocumentToLinkTo": "Selezionare un documento a cui collegare" } }, "selectionMenu": { "outline": "Contorno", "codeBlock": "Blocco di codice" }, "plugins": { "referencedBoard": "Bacheca referenziata", "referencedGrid": "Griglia di riferimento", "referencedCalendar": "Calendario referenziato", "referencedDocument": "Documento riferito", "autoGeneratorMenuItemName": "Scrittore AI", "autoGeneratorTitleName": "AI: chiedi all'AI di scrivere qualsiasi cosa...", "autoGeneratorLearnMore": "Saperne di più", "autoGeneratorGenerate": "creare", "autoGeneratorHintText": "Chiedi a AI...", "autoGeneratorCantGetOpenAIKey": "Impossibile ottenere la chiave AI", "autoGeneratorRewrite": "Riscrivere", "smartEdit": "Assistenti AI", "aI": "AI", "smartEditFixSpelling": "Correggi l'ortografia", "warning": "⚠️ Le risposte AI possono essere imprecise o fuorvianti.", "smartEditSummarize": "Riassumere", "smartEditImproveWriting": "Migliora la scrittura", "smartEditMakeLonger": "Rendi più lungo", "smartEditCouldNotFetchResult": "Impossibile recuperare il risultato da AI", "smartEditCouldNotFetchKey": "Impossibile recuperare la chiave AI", "smartEditDisabled": "Connetti AI in Impostazioni", "discardResponse": "Vuoi scartare le risposte AI?", "createInlineMathEquation": "Crea un'equazione", "fonts": "Caratteri", "emoji": "Emoji", "toggleList": "Elenco alternato", "quoteList": "Elenco citazioni", "numberedList": "Elenco numerato", "bulletedList": "Elenco puntato", "todoList": "Lista di cose da fare", "cover": { "changeCover": "Cambia copertina", "colors": "Colori", "images": "immagini", "clearAll": "Cancella tutto", "abstract": "Astratto", "addCover": "Aggiungi copertina", "addLocalImage": "Aggiungi immagine locale", "invalidImageUrl": "URL dell'immagine non valido", "failedToAddImageToGallery": "Impossibile aggiungere l'immagine alla galleria", "enterImageUrl": "Inserisci l'URL dell'immagine", "add": "Aggiungere", "back": "Indietro", "saveToGallery": "Salva nella galleria", "removeIcon": "Rimuovi icona", "pasteImageUrl": "Incolla l'URL dell'immagine", "or": "O", "pickFromFiles": "Scegli dai file", "couldNotFetchImage": "Impossibile recuperare l'immagine", "imageSavingFailed": "Salvataggio dell'immagine non riuscito", "addIcon": "Aggiungi icona", "changeIcon": "Cambia icona", "coverRemoveAlert": "Verrà rimosso dalla copertura dopo essere stato eliminato.", "alertDialogConfirmation": "Sei sicuro di voler continuare?" }, "mathEquation": { "name": "Equazione matematica", "addMathEquation": "Aggiungi equazione matematica", "editMathEquation": "Modifica equazione matematica" }, "optionAction": { "click": "Clic", "toOpenMenu": " per aprire il menu", "delete": "Eliminare", "duplicate": "Duplicare", "turnInto": "Trasforma in", "moveUp": "Andare avanti", "moveDown": "Abbassati", "color": "Colore", "align": "Allineare", "left": "Sinistra", "center": "Centro", "right": "Giusto", "defaultColor": "Predefinito" }, "image": { "addAnImage": "Aggiungi un'immagine", "copiedToPasteBoard": "Il link dell'immagine è stato copiato negli appunti", "imageUploadFailed": "Caricamento dell'immagine non riuscito" }, "urlPreview": { "copiedToPasteBoard": "Il link è stato copiato negli appunti", "convertToLink": "Convertire in link da incorporare" }, "outline": { "addHeadingToCreateOutline": "Aggiungi intestazioni per creare un sommario.", "noMatchHeadings": "Non sono stati trovati titoli corrispondenti." }, "table": { "addAfter": "Aggiungi dopo", "addBefore": "Aggiungi prima", "delete": "Cancella", "clear": "Rimuovi il contenuto", "duplicate": "Duplica", "bgColor": "Colore di sfondo" }, "contextMenu": { "copy": "Copia", "cut": "Taglia", "paste": "Incolla" }, "action": "Azioni", "database": { "selectDataSource": "Seleziona fonte dati", "noDataSource": "Nessuna fonte dati", "selectADataSource": "Seleziona una fonte dati", "toContinue": "continuare", "newDatabase": "Nuova banca dati", "linkToDatabase": "Collegamento alla banca dati" }, "date": "Data" }, "textBlock": { "placeholder": "Digita '/' per i comandi" }, "title": { "placeholder": "Senza titolo" }, "imageBlock": { "placeholder": "Fare clic per aggiungere l'immagine", "upload": { "label": "Caricamento", "placeholder": "Clicca per caricare l'immagine" }, "url": { "label": "URL dell'immagine", "placeholder": "Inserisci l'URL dell'immagine" }, "ai": { "label": "Genera immagine da AI", "placeholder": "Inserisci la richiesta affinché AI generi l'immagine" }, "stability_ai": { "label": "Genera immagine da Stability AI", "placeholder": "Inserisci la richiesta affinché Stability AI generi l'immagine" }, "support": "Il limite della dimensione dell'immagine è di 5 MB. Formati supportati: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Immagine non valida", "invalidImageSize": "La dimensione dell'immagine deve essere inferiore a 5 MB", "invalidImageFormat": "Il formato dell'immagine non è supportato. Formati supportati: JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL dell'immagine non valido" }, "embedLink": { "label": "Incorpora link", "placeholder": "Incolla o digita un link immagine" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Cerca un'immagine", "pleaseInputYourOpenAIKey": "inserisci la tua chiave AI nella pagina Impostazioni", "saveImageToGallery": "Salva immagine", "failedToAddImageToGallery": "Impossibile aggiungere l'immagine alla galleria", "successToAddImageToGallery": "Immagine aggiunta alla galleria con successo", "unableToLoadImage": "Impossibile caricare l'immagine", "maximumImageSize": "La dimensione massima supportata per il caricamento delle immagini è di 10 MB", "uploadImageErrorImageSizeTooBig": "Le dimensioni dell'immagine devono essere inferiori a 10 MB", "imageIsUploading": "L'immagine si sta caricando", "pleaseInputYourStabilityAIKey": "inserisci la chiave Stability AI nella pagina Impostazioni" }, "codeBlock": { "language": { "label": "Lingua", "placeholder": "Seleziona la lingua" } }, "inlineLink": { "placeholder": "Incolla o digita un link", "openInNewTab": "Apri in una nuova scheda", "copyLink": "Copia link", "removeLink": "Rimuovi link", "url": { "label": "URL di collegamento", "placeholder": "Inserisci l'URL del collegamento" }, "title": { "label": "Titolo collegamento", "placeholder": "Inserisci il titolo del collegamento" } }, "mention": { "placeholder": "Menziona una persona, una pagina o una data...", "page": { "label": "Collegamento alla pagina", "tooltip": "Fare clic per aprire la pagina" }, "deleted": "Eliminato", "deletedContent": "Questo contenuto non esiste o è stato cancellato" }, "toolbar": { "resetToDefaultFont": "Ripristina alle condizioni predefinite" }, "errorBlock": { "theBlockIsNotSupported": "La versione attuale non supporta questo blocco.", "blockContentHasBeenCopied": "Il contenuto del blocco è stato copiato." } }, "board": { "column": { "createNewCard": "Nuovo", "renameGroupTooltip": "Premi per rinominare il gruppo", "createNewColumn": "Aggiungi un nuovo gruppo", "addToColumnTopTooltip": "Aggiungi una nuova carta in alto", "addToColumnBottomTooltip": "Aggiungi una nuova carta in basso", "renameColumn": "Rinomina", "hideColumn": "Nascondi", "newGroup": "Nuovo gruppo", "deleteColumn": "Elimina", "deleteColumnConfirmation": "Ciò eliminerà questo gruppo e tutte le carte in esso contenute. Sei sicuro di voler continuare?" }, "hiddenGroupSection": { "sectionTitle": "Gruppi nascosti", "collapseTooltip": "Nascondi i gruppi nascosti", "expandTooltip": "Visualizza i gruppi nascosti" }, "cardDetail": "Dettagli della carta", "cardDuplicated": "La carta è stata duplicata", "cardDeleted": "La carta è stata eliminata", "showOnCard": "Mostra sui dettagli della carta", "setting": "Impostazioni", "propertyName": "Nome della proprietà", "menuName": "Bacheca", "showUngrouped": "Mostra elementi non raggruppati", "ungroupedButtonText": "Non raggruppato", "ungroupedButtonTooltip": "Contiene carte che non appartengono a nessun gruppo", "ungroupedItemsTitle": "Fare clic per aggiungere alla bacheca", "groupBy": "Raggruppa per", "referencedBoardPrefix": "Vista di", "notesTooltip": "Note all'interno", "mobile": { "editURL": "Modifica URL", "showGroup": "Mostra gruppo", "showGroupContent": "Sei sicuro di voler mostrare questo gruppo sulla bacheca?", "failedToLoad": "Impossibile caricare la visualizzazione della bacheca" } }, "calendar": { "menuName": "Calendario", "defaultNewCalendarTitle": "Senza titolo", "newEventButtonTooltip": "Aggiungi un nuovo evento", "navigation": { "today": "Oggi", "jumpToday": "Vai a Oggi", "previousMonth": "Il mese scorso", "nextMonth": "Il prossimo mese" }, "mobileEventScreen": { "emptyTitle": "Non ci sono ancora eventi", "emptyBody": "Premere il pulsante più per creare un evento in questo giorno." }, "settings": { "showWeekNumbers": "Mostra i numeri della settimana", "showWeekends": "Mostra i fine settimana", "firstDayOfWeek": "Inizia la settimana", "layoutDateField": "Layout calendario per", "changeLayoutDateField": "Modifica del campo di layout", "noDateTitle": "Nessuna data", "unscheduledEventsTitle": "Eventi non programmati", "clickToAdd": "Fare clic per aggiungere al calendario", "name": "Disposizione del calendario", "noDateHint": "Gli eventi non programmati verranno visualizzati qui" }, "referencedCalendarPrefix": "Vista di", "quickJumpYear": "Salta a" }, "errorDialog": { "title": "Errore @:appName", "howToFixFallback": "Ci scusiamo per l'inconveniente! Invia un problema sulla nostra pagina GitHub che descriva il tuo errore.", "github": "Visualizza su GitHub" }, "search": { "label": "Ricerca", "placeholder": { "actions": "Cerca azioni..." } }, "message": { "copy": { "success": "Copiato!", "fail": "Impossibile copiare" } }, "unSupportBlock": "La versione attuale non supporta questo blocco.", "views": { "deleteContentTitle": "Sei sicuro di voler eliminare {pageType}?", "deleteContentCaption": "se elimini questo {pageType}, puoi ripristinarlo dal cestino." }, "colors": { "custom": "Personalizzato", "default": "Predefinito", "red": "Rosso", "orange": "Arancione", "yellow": "Giallo", "green": "Verde", "blue": "Blu", "purple": "Viola", "pink": "Rosa", "brown": "Marrone", "gray": "Grigio" }, "emoji": { "emojiTab": "Emoji", "search": "Cerca emoji", "noRecent": "Nessuna emoji recente", "noEmojiFound": "Nessuna emoji trovata", "filter": "Filtro", "random": "Casuale", "selectSkinTone": "Seleziona la tonalità della pelle", "remove": "Rimuovi emoji", "categories": { "people": "Persone e corpo", "animals": "Animali e natura", "food": "Cibo e bevande", "activities": "Attività", "places": "Viaggi e luoghi", "objects": "Oggetti", "symbols": "Simboli", "flags": "Bandiere", "nature": "Natura", "frequentlyUsed": "Usate di frequente" }, "skinTone": { "default": "Predefinito", "light": "Chiaro", "mediumLight": "Medio-chiaro", "medium": "Medio", "mediumDark": "Medio-scuro", "dark": "Scuro" } }, "inlineActions": { "noResults": "Nessun risultato", "pageReference": "Riferimento della pagina", "docReference": "Riferimento al documento", "calReference": "Calendario di riferimento", "gridReference": "Riferimento griglia", "date": "Data", "reminder": { "groupTitle": "Promemoria", "shortKeyword": "ricordare" } }, "datePicker": { "dateTimeFormatTooltip": "Modifica il formato della data e dell'ora nelle impostazioni", "dateFormat": "Formato data", "includeTime": "Includere l'orario", "isRange": "Data di fine", "timeFormat": "Formato orario", "clearDate": "Rimuovi data", "reminderLabel": "Promemoria", "selectReminder": "Seleziona il promemoria", "reminderOptions": { "atTimeOfEvent": "Ora dell'evento", "fiveMinsBefore": "5 minuti prima", "tenMinsBefore": "10 minuti prima", "fifteenMinsBefore": "15 minuti prima", "thirtyMinsBefore": "30 minuti prima", "oneHourBefore": "1 ora prima", "twoHoursBefore": "2 hours before", "onDayOfEvent": "Il giorno dell'evento", "oneDayBefore": "1 giorno prima", "twoDaysBefore": "2 giorni prima", "oneWeekBefore": "1 settimana prima", "custom": "Personalizzato" } }, "relativeDates": { "yesterday": "Ieri", "today": "Oggi", "tomorrow": "Domani", "oneWeek": "1 settimana" }, "notificationHub": { "title": "Notifiche", "mobile": { "title": "Aggiornamenti" }, "emptyTitle": "Tutto a posto!", "emptyBody": "Nessuna notifica o azione in sospeso. Goditi la calma.", "tabs": { "inbox": "Posta in arrivo", "upcoming": "Prossimamente" }, "actions": { "markAllRead": "Segna tutto come letto", "showAll": "Tutto", "showUnreads": "Non letto" }, "filters": { "ascending": "Ascendente", "descending": "Discendente", "groupByDate": "Raggruppa per data", "showUnreadsOnly": "Mostra solo non letti", "resetToDefault": "Riportare alle condizioni predefinite" } }, "reminderNotification": { "title": "Promemoria", "message": "Ricordati di controllare questo prima di dimenticarlo!", "tooltipDelete": "Elimina", "tooltipMarkRead": "Segna come letto", "tooltipMarkUnread": "Segna come non letto" }, "findAndReplace": { "find": "Cerca", "previousMatch": "Corrispondenza precedente", "nextMatch": "Corrispondenza seguente", "close": "Chiudi", "replace": "Sostituisci", "replaceAll": "Sostituisci tutto", "noResult": "Nessun risultato", "caseSensitive": "Distinzione tra maiuscole e minuscole" }, "error": { "weAreSorry": "Ci dispiace", "loadingViewError": "Stiamo riscontrando problemi nel caricare questa visualizzazione. Controlla la tua connessione Internet, aggiorna l'app e non esitare a contattare il team se il problema persiste." }, "editor": { "bold": "Grassetto", "bulletedList": "Elenco puntato", "checkbox": "Casella di controllo", "embedCode": "Incorpora codice", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Evidenzia", "color": "Colore", "image": "Immagine", "date": "Data", "italic": "Corsivo", "link": "Link", "numberedList": "Elenco numerato", "quote": "Citazione", "strikethrough": "Barrato", "text": "Testo", "underline": "Sottolinea", "fontColorDefault": "Predefinito", "fontColorGray": "Grigio", "fontColorBrown": "Marrone", "fontColorOrange": "Arancione", "fontColorYellow": "Giallo", "fontColorGreen": "Verde", "fontColorBlue": "Blu", "fontColorPurple": "Viola", "fontColorPink": "Rosa", "fontColorRed": "Rosso", "backgroundColorDefault": "Sfondo predefinito", "backgroundColorGray": "Sfondo grigio", "backgroundColorBrown": "Sfondo marrone", "backgroundColorOrange": "Sfondo arancione", "backgroundColorYellow": "Sfondo giallo", "backgroundColorGreen": "Sfondo verde", "backgroundColorBlue": "Sfondo blu", "backgroundColorPurple": "Sfondo viola", "backgroundColorPink": "Sfondo rosa", "backgroundColorRed": "Sfondo rosso", "done": "Fatto", "cancel": "Annulla", "tint1": "Tinta 1", "tint2": "Tinta 2", "tint3": "Tinta 3", "tint4": "Tinta 4", "tint5": "Tinta 5", "tint6": "Tinta 6", "tint7": "Tinta 7", "tint8": "Tinta 8", "tint9": "Tinta 9", "lightLightTint1": "Viola", "lightLightTint2": "Rosa", "lightLightTint3": "Rosa chiaro", "lightLightTint4": "Arancione", "lightLightTint5": "Giallo", "lightLightTint6": "Lime", "lightLightTint7": "Verde", "lightLightTint8": "Acqua", "lightLightTint9": "Blu", "urlHint": "URL", "mobileHeading1": "Intestazione 1", "mobileHeading2": "Intestazione 2", "mobileHeading3": "Intestazione 3", "textColor": "Colore del testo", "backgroundColor": "Colore di sfondo", "addYourLink": "Aggiungi il tuo link", "openLink": "Apri link", "copyLink": "Copia link", "removeLink": "Rimuovi link", "editLink": "Modifica link", "linkText": "Testo", "linkTextHint": "Inserisci il testo", "linkAddressHint": "Inserisci l'URL", "highlightColor": "Colore evidenziazione", "clearHighlightColor": "Rimuovi colore evidenziazione", "customColor": "Colore personalizzato", "hexValue": "Valore hex", "opacity": "Opacità", "resetToDefaultColor": "Ripristina il colore predefinito", "ltr": "LTR", "rtl": "RTL", "auto": "Auto", "cut": "Taglia", "copy": "Copia", "paste": "Incolla", "find": "Cerca", "previousMatch": "Corrispondenza precedente", "nextMatch": "Corrispondenza seguente", "closeFind": "Chiudi", "replace": "Sostituisci", "replaceAll": "Sostituisci tutto", "regex": "Regex", "caseSensitive": "Distinzione tra maiuscole e minuscole", "uploadImage": "Carica immagine", "urlImage": "URL dell'immagine", "incorrectLink": "Link errato", "upload": "Carica", "chooseImage": "Scegli un'immagine", "loading": "Caricamento", "imageLoadFailed": "Impossibile caricare l'immagine", "divider": "Divisore", "table": "Tabella", "colAddBefore": "Aggiungi prima", "rowAddBefore": "Aggiungi prima", "colAddAfter": "Aggiungi dopo", "rowAddAfter": "Aggiungi dopo", "colRemove": "Rimuovi", "rowRemove": "Rimuovi", "colDuplicate": "Duplica", "rowDuplicate": "Duplica", "colClear": "Rimuovi contenuto", "rowClear": "Rimuovi contenuto", "slashPlaceHolder": "Digita \"/\" per inserire un blocco o inizia a digitare", "typeSomething": "Scrivi qualcosa...", "quoteListShortForm": "Citazione", "mathEquationShortForm": "Formula", "codeBlockShortForm": "Codice" }, "favorite": { "noFavorite": "Nessuna pagina preferita", "noFavoriteHintText": "Scorri la pagina verso sinistra per aggiungerla ai preferiti" }, "cardDetails": { "notesPlaceholder": "Inserisci un / per inserire un blocco o inizia a digitare" }, "blockPlaceholders": { "todoList": "Da fare", "bulletList": "Elenco", "numberList": "Elenco", "quote": "Citazione", "heading": "Intestazione {}" }, "titleBar": { "pageIcon": "Icona della pagina", "language": "Lingua", "font": "Font", "actions": "Azioni", "date": "Data", "addField": "Aggiungi campo", "userIcon": "Icona utente" }, "noLogFiles": "Non ci sono file di log" } ================================================ FILE: frontend/resources/translations/ja-JP.json ================================================ { "appName": "AppFlowy", "defaultUsername": "ユーザー", "welcomeText": "Welcome to @:appName", "welcomeTo": "ようこそ", "githubStarText": "Star on GitHub", "subscribeNewsletterText": "新着情報を受け取る", "letsGoButtonText": "Let's Go", "title": "タイトル", "youCanAlso": "他にもこんなことができます", "and": "と", "failedToOpenUrl": "URLを開けませんでした: {}", "blockActions": { "addBelowTooltip": "クリックして下に追加してください", "addAboveCmd": "Alt+クリック", "addAboveMacCmd": "Option+クリック", "addAboveTooltip": "上に追加する", "dragTooltip": "ドラッグして移動", "openMenuTooltip": "クリックしてメニューを開く" }, "signUp": { "buttonText": "新規登録", "title": "@:appNameに新規登録", "getStartedText": "はじめる", "emptyPasswordError": "パスワードを空にはできません", "repeatPasswordEmptyError": "パスワード(確認用)を空にはできません", "unmatchedPasswordError": "パスワード(確認用)が一致しません", "alreadyHaveAnAccount": "すでにアカウントを登録済ですか?", "emailHint": "メールアドレス", "passwordHint": "パスワード", "repeatPasswordHint": "パスワード(確認用)", "signUpWith": "サインアップ:" }, "signIn": { "loginTitle": "@:appNameにログイン", "loginButtonText": "ログイン", "loginStartWithAnonymous": "匿名で続行", "continueAnonymousUser": "匿名で続行", "buttonText": "サインイン", "signingInText": "サインイン中...", "forgotPassword": "パスワードを忘れましたか?", "emailHint": "メールアドレス", "passwordHint": "パスワード", "dontHaveAnAccount": "アカウントをお持ちでないですか?", "createAccount": "アカウントを作成", "repeatPasswordEmptyError": "パスワードを再入力してください", "unmatchedPasswordError": "パスワードが一致しません", "syncPromptMessage": "データの同期には時間がかかる場合があります。このページを閉じないでください", "or": "または", "signInWithGoogle": "Googleで続行", "signInWithGithub": "GitHubで続行", "signInWithDiscord": "Discordで続行", "signInWithApple": "Appleで続行", "continueAnotherWay": "別の方法で続行", "signUpWithGoogle": "Googleで登録", "signUpWithGithub": "GitHubで登録", "signUpWithDiscord": "Discordで登録", "signInWith": "続行方法:", "signInWithEmail": "メールで続行", "signInWithMagicLink": "続行", "signUpWithMagicLink": "Magic Linkで登録", "pleaseInputYourEmail": "メールアドレスを入力してください", "settings": "設定", "magicLinkSent": "Magic Linkが送信されました!", "invalidEmail": "有効なメールアドレスを入力してください", "alreadyHaveAnAccount": "すでにアカウントをお持ちですか?", "logIn": "ログイン", "generalError": "エラーが発生しました。後でもう一度お試しください", "limitRateError": "セキュリティのため、Magic Linkは60秒に1回しかリクエストできません", "magicLinkSentDescription": "Magic Linkがあなたのメールアドレスに送信されました。リンクをクリックしてログインを完了してください。リンクは5分後に無効になります。", "anonymous": "匿名" }, "workspace": { "chooseWorkspace": "ワークスペースを選択", "defaultName": "私のワークスペース", "create": "ワークスペースを作成", "new": "新しいワークスペース", "importFromNotion": "Notionからインポート", "learnMore": "もっと詳しく知る", "reset": "ワークスペースをリセット", "renameWorkspace": "ワークスペースの名前を変更", "workspaceNameCannotBeEmpty": "ワークスペース名は空にできません", "resetWorkspacePrompt": "ワークスペースをリセットすると、すべてのページとデータが削除されます。本当にリセットしますか?代わりに、サポートチームに連絡してワークスペースを復元することもできます。", "hint": "ワークスペース", "notFoundError": "ワークスペースが見つかりません", "failedToLoad": "問題が発生しました!ワークスペースの読み込みに失敗しました。@:appNameの開いているインスタンスをすべて閉じて、再試行してください。", "errorActions": { "reportIssue": "問題を報告", "reportIssueOnGithub": "Githubで問題を報告", "exportLogFiles": "ログファイルをエクスポート", "reachOut": "Discordで連絡" }, "menuTitle": "ワークスペース", "deleteWorkspaceHintText": "ワークスペースを削除してもよろしいですか?この操作は元に戻せません。公開したページはすべて非公開になります。", "createSuccess": "ワークスペースが正常に作成されました", "createFailed": "ワークスペースの作成に失敗しました", "createLimitExceeded": "アカウントで許可されている最大数のワークスペースに達しました。追加のワークスペースが必要な場合は、Githubでリクエストしてください。", "deleteSuccess": "ワークスペースが正常に削除されました", "deleteFailed": "ワークスペースの削除に失敗しました", "openSuccess": "ワークスペースを正常に開きました", "openFailed": "ワークスペースを開くことができませんでした", "renameSuccess": "ワークスペース名が正常に変更されました", "renameFailed": "ワークスペース名の変更に失敗しました", "updateIconSuccess": "ワークスペースアイコンを正常に更新しました", "updateIconFailed": "ワークスペースアイコンの更新に失敗しました", "cannotDeleteTheOnlyWorkspace": "ワークスペースは削除できません", "fetchWorkspacesFailed": "ワークスペースの取得に失敗しました", "leaveCurrentWorkspace": "ワークスペースを離れる", "leaveCurrentWorkspacePrompt": "現在のワークスペースから退出してもよろしいですか?" }, "shareAction": { "buttonText": "共有", "workInProgress": "近日公開", "markdown": "Markdown", "html": "HTML", "clipboard": "クリップボードにコピー", "csv": "CSV", "copyLink": "リンクをコピー", "publishToTheWeb": "Webに公開", "publishToTheWebHint": "AppFlowyでウェブサイトを作成", "publish": "公開", "unPublish": "非公開", "visitSite": "サイトを訪問", "exportAsTab": "エクスポート形式", "publishTab": "公開", "shareTab": "共有", "publishOnAppFlowy": "AppFlowyで公開", "shareTabTitle": "コラボレーションに招待", "shareTabDescription": "誰とでも簡単にコラボレーション", "copyLinkSuccess": "リンクをクリップボードにコピーしました", "copyShareLink": "共有リンクをコピー", "copyLinkFailed": "リンクをクリップボードにコピーできませんでした", "copyLinkToBlockSuccess": "ブロックリンクをクリップボードにコピーしました", "copyLinkToBlockFailed": "ブロックリンクをクリップボードにコピーできませんでした", "manageAllSites": "すべてのサイトを管理する", "updatePathName": "パス名を更新" }, "moreAction": { "small": "小", "medium": "中", "large": "大", "fontSize": "フォントサイズ", "import": "インポート", "moreOptions": "その他のオプション", "wordCount": "単語数: {}", "charCount": "文字数: {}", "createdAt": "作成日: {}", "deleteView": "削除", "duplicateView": "複製", "wordCountLabel": "単語数:", "charCountLabel": "文字数:", "createdAtLabel": "作成日:", "syncedAtLabel": "同期済み:" }, "importPanel": { "textAndMarkdown": "テキストとマークダウン", "documentFromV010": "v0.1.0 以降のドキュメント", "databaseFromV010": "v0.1.0 以降のデータベース", "notionZip": "Notion エクスポートされた Zip ファイル", "csv": "CSV", "database": "データベース" }, "disclosureAction": { "rename": "名前を変更", "delete": "削除", "duplicate": "複製", "unfavorite": "お気に入りから削除", "favorite": "お気に入りに追加", "openNewTab": "新しいタブで開く", "moveTo": "移動", "addToFavorites": "お気に入りに追加", "copyLink": "リンクをコピー", "changeIcon": "アイコンを変更", "collapseAllPages": "すべてのサブページを折りたたむ", "movePageTo": "ページを移動", "move": "動く" }, "blankPageTitle": "空白のページ", "newPageText": "新しいページ", "newDocumentText": "新しいドキュメント", "newGridText": "新しいグリッド", "newCalendarText": "新しいカレンダー", "newBoardText": "新しいボード", "chat": { "newChat": "AIチャット", "inputMessageHint": "@:appName AIに質問", "inputLocalAIMessageHint": "@:appName ローカルAIに質問", "unsupportedCloudPrompt": "この機能は@:appName Cloudでのみ利用可能です", "relatedQuestion": "関連質問", "serverUnavailable": "サービスが一時的に利用できません。後でもう一度お試しください。", "aiServerUnavailable": "🌈 おっと! 🌈 ユニコーンが返答を食べちゃいました。再試行してください!", "retry": "リトライ", "clickToRetry": "再試行をクリック", "regenerateAnswer": "回答を再生成", "question1": "タスク管理にKanbanを使用する方法", "question2": "GTDメソッドを説明してください", "question3": "なぜRustを使うのか", "question4": "キッチンにある材料のレシピ", "question5": "マイページ用のイラストを作成する", "question6": "来週のやることリストを作成する", "aiMistakePrompt": "AIは間違いを犯すことがあります。重要な情報は確認してください。", "chatWithFilePrompt": "ファイルとチャットしますか?", "indexFileSuccess": "ファイルのインデックス作成が成功しました", "inputActionNoPages": "ページ結果がありません", "referenceSource": { "zero": "0 件のソースが見つかりました", "one": "{count} 件のソースが見つかりました", "other": "{count} 件のソースが見つかりました" }, "clickToMention": "ページをメンションするにはクリック", "uploadFile": "PDF、md、txtファイルをアップロードしてチャット", "questionDetail": "こんにちは、{}!今日はどんなお手伝いをしましょうか?", "indexingFile": "{}をインデックス作成中", "generatingResponse": "レスポンスの作成", "selectSources": "ソースを選択", "sourcesLimitReached": "選択できるのは最上位のドキュメントとその子ドキュメントの3つまでです。", "sourceUnsupported": "現時点ではデータベースとのチャットはサポートされていません", "regenerate": "もう一度やり直してください", "addToPageButton": "ページにメッセージを追加", "addToPageTitle": "メッセージを追加...", "addToNewPageName": "\"{}\"から抽出されたメッセージ", "addToNewPageSuccessToast": "メッセージが追加されました", "openPagePreviewFailedToast": "ページを開けませんでした" }, "trash": { "text": "ゴミ箱", "restoreAll": "すべて復元", "restore": "復元する", "deleteAll": "すべて削除", "pageHeader": { "fileName": "ファイル名", "lastModified": "最終更新日", "created": "作成日" }, "confirmDeleteAll": { "title": "ゴミ箱内のすべてのページを削除してもよろしいですか?", "caption": "この操作は元に戻せません。" }, "confirmRestoreAll": { "title": "ゴミ箱内のすべてのページを復元してもよろしいですか?", "caption": "この操作は元に戻せません。" }, "restorePage": { "title": "復元する: {}", "caption": "このページを復元してもよろしいですか?" }, "mobile": { "actions": "ゴミ箱の操作", "empty": "ゴミ箱にページやスペースはありません", "emptyDescription": "不要なものをゴミ箱に移動します。", "isDeleted": "が削除されました", "isRestored": "が復元されました" }, "confirmDeleteTitle": "このページを完全に削除してもよろしいですか?" }, "deletePagePrompt": { "text": "このページはごみ箱にあります", "restore": "ページを元に戻す", "deletePermanent": "削除する", "deletePermanentDescription": "このページを完全に削除してもよろしいですか? 削除すると元に戻せません。" }, "dialogCreatePageNameHint": "ページ名", "questionBubble": { "shortcuts": "ショートカット", "whatsNew": "新着情報", "markdown": "Markdown", "debug": { "name": "デバッグ情報", "success": "デバッグ情報をクリップボードにコピーしました!", "fail": "デバッグ情報をクリップボードにコピーできませんでした" }, "feedback": "フィードバック", "help": "ヘルプ & サポート" }, "menuAppHeader": { "moreButtonToolTip": "削除、名前の変更、その他...", "addPageTooltip": "ページを追加", "defaultNewPageName": "無題", "renameDialog": "名前を変更", "pageNameSuffix": "コピー" }, "noPagesInside": "中にページがありません", "toolbar": { "undo": "元に戻す", "redo": "やり直し", "bold": "太字", "italic": "斜体", "underline": "下線", "strike": "取り消し線", "numList": "番号付きリスト", "bulletList": "箇条書きリスト", "checkList": "チェックリスト", "inlineCode": "インラインコード", "quote": "引用ブロック", "header": "ヘッダー", "highlight": "ハイライト", "color": "カラー", "addLink": "リンクを追加", "link": "リンク" }, "tooltip": { "lightMode": "ライトモードに切り替える", "darkMode": "ダークモードに切り替える", "openAsPage": "ページとして開く", "addNewRow": "新しい行を追加", "openMenu": "クリックしてメニューを開く", "dragRow": "長押しして行を並べ替える", "viewDataBase": "データベースを表示", "referencePage": "この {name} は参照されています", "addBlockBelow": "下にブロックを追加", "aiGenerate": "生成する" }, "sideBar": { "closeSidebar": "サイドバーを閉じる", "openSidebar": "サイドバーを開く", "expandSidebar": "全ページ展開", "personal": "個人", "private": "プライベート", "workspace": "ワークスペース", "favorites": "お気に入り", "clickToHidePrivate": "クリックしてプライベートスペースを非表示にする\nここで作成したページはあなただけが見えます", "clickToHideWorkspace": "クリックしてワークスペースを非表示にする\nここで作成したページはすべてのメンバーが閲覧できます", "clickToHidePersonal": "クリックして個人スペースを非表示にする", "clickToHideFavorites": "クリックしてお気に入りスペースを非表示にする", "addAPage": "新しいページを追加", "addAPageToPrivate": "プライベートスペースにページを追加", "addAPageToWorkspace": "ワークスペースにページを追加", "recent": "最近", "today": "今日", "thisWeek": "今週", "others": "以前のお気に入り", "earlier": "以前", "justNow": "たった今", "minutesAgo": "{count}分前", "lastViewed": "最後に表示したページ", "favoriteAt": "お気に入り登録", "emptyRecent": "最近表示したページはありません", "emptyRecentDescription": "ページを閲覧すると、ここに簡単にアクセスできるよう表示されます。", "emptyFavorite": "お気に入りのページはありません", "emptyFavoriteDescription": "ページをお気に入りに追加すると、ここに表示されて素早くアクセスできます!", "removePageFromRecent": "最近表示したページから削除しますか?", "removeSuccess": "削除に成功しました", "favoriteSpace": "お気に入り", "RecentSpace": "最近", "Spaces": "スペース", "upgradeToPro": "Proプランにアップグレード", "upgradeToAIMax": "無制限のAIを解放", "storageLimitDialogTitle": "無料のストレージが不足しています。無制限のストレージを解放するにはアップグレードしてください", "aiResponseLimitTitle": "無料のAIレスポンスが不足しています。Proプランにアップグレードするか、AIアドオンを購入して無制限のレスポンスを解放してください", "aiResponseLimitDialogTitle": "AIレスポンスの制限に達しました", "aiResponseLimit": "無料のAIレスポンスが不足しています。\n\n設定 -> プラン -> AI MaxまたはProプランをクリックして、さらにAIレスポンスを取得してください", "askOwnerToUpgradeToPro": "ワークスペースの無料ストレージが不足しています。ワークスペースのオーナーにProプランへのアップグレードを依頼してください", "askOwnerToUpgradeToProIOS": "ワークスペースの無料ストレージが不足しています。", "askOwnerToUpgradeToAIMax": "ワークスペースのAIレスポンスが不足しています。ワークスペースのオーナーにプランのアップグレードかAIアドオンの購入を依頼してください", "askOwnerToUpgradeToAIMaxIOS": "ワークスペースの無料AIレスポンスが不足しています。", "purchaseStorageSpace": "ストレージスペースを購入", "singleFileProPlanLimitationDescription": "無料プランで許可されている最大ファイルアップロードサイズを超えました。より大きなファイルをアップロードするには、プロプランにアップグレードしてください。", "purchaseAIResponse": "AIレスポンスを購入", "askOwnerToUpgradeToLocalAI": "ワークスペースのオーナーにオンデバイスAIを有効にするよう依頼してください", "upgradeToAILocal": "デバイス上でローカルモデルを実行して究極のプライバシーを実現", "upgradeToAILocalDesc": "PDFとチャットしたり、文章を改善したり、ローカルAIでテーブルを自動入力したりできます" }, "notifications": { "export": { "markdown": "ノートをMarkdownにエクスポート", "path": "Documents/flowy" } }, "contactsPage": { "title": "連絡先", "whatsHappening": "今週は何が起こっている?", "addContact": "連絡先を追加", "editContact": "連絡先を編集" }, "button": { "ok": "OK", "confirm": "確認", "done": "完了", "cancel": "キャンセル", "signIn": "サインイン", "signOut": "サインアウト", "complete": "完了", "save": "保存", "generate": "生成", "esc": "ESC", "keep": "保持", "tryAgain": "再試行", "discard": "破棄", "replace": "置き換え", "insertBelow": "下に挿入", "insertAbove": "上に挿入", "upload": "アップロード", "edit": "編集", "delete": "削除", "copy": "コピー", "duplicate": "複製", "putback": "元に戻す", "update": "更新", "share": "共有", "removeFromFavorites": "お気に入りから削除", "removeFromRecent": "最近の項目から削除", "addToFavorites": "お気に入りに追加", "favoriteSuccessfully": "お気に入りに追加しました", "unfavoriteSuccessfully": "お気に入りから削除しました", "duplicateSuccessfully": "複製が成功しました", "rename": "名前を変更", "helpCenter": "ヘルプセンター", "add": "追加", "yes": "はい", "no": "いいえ", "clear": "クリア", "remove": "削除", "dontRemove": "削除しない", "copyLink": "リンクをコピー", "align": "整列", "login": "ログイン", "logout": "ログアウト", "deleteAccount": "アカウントを削除", "back": "戻る", "signInGoogle": "Googleで続行", "signInGithub": "GitHubで続行", "signInDiscord": "Discordで続行", "more": "もっと見る", "create": "作成", "close": "閉じる", "next": "次へ", "previous": "前へ", "submit": "送信", "download": "ダウンロード", "backToHome": "Homeに戻る", "viewing": "閲覧", "editing": "編集", "gotIt": "わかった", "retry": "リトライ", "uploadFailed": "アップロードに失敗しました。", "copyLinkOriginal": "元のリンクをコピー" }, "label": { "welcome": "ようこそ!", "firstName": "名", "middleName": "ミドルネーム", "lastName": "姓", "stepX": "ステップ {X}" }, "oAuth": { "err": { "failedTitle": "アカウントに接続できません", "failedMsg": "サインインが完了したことをブラウザーで確認してください" }, "google": { "title": "Googleでサインイン", "instruction1": "Googleでのサインインを有効にするためには、Webブラウザーを使用してこのアプリケーションを認証する必要があります。", "instruction2": "アイコンをクリックするかテキストを選択して、このコードをクリップボードにコピーします。", "instruction3": "以下のリンク先をブラウザーで開いて、次のコードを入力します。", "instruction4": "登録が完了したら以下のボタンを押してください。" } }, "settings": { "title": "設定", "popupMenuItem": { "settings": "設定", "members": "メンバー", "trash": "ゴミ箱", "helpAndSupport": "ヘルプ & サポート" }, "sites": { "title": "サイト", "namespaceTitle": "名前空間", "namespaceDescription": "名前空間とホームページを管理する", "namespaceHeader": "名前空間", "homepageHeader": "ホームページ", "updateNamespace": "名前空間を更新する", "removeHomepage": "ホームページを削除する", "selectHomePage": "ページを選択", "clearHomePage": "この名前空間のホームページをクリアする", "customUrl": "カスタム URL", "namespace": { "description": "この変更は、この名前空間で公開されているすべてのページに適用されます。", "tooltip": "不適切な名前空間を削除する権利を留保します", "updateExistingNamespace": "既存の名前空間を更新する", "upgradeToPro": "ホームページを設定するにはプロプランにアップグレードしてください", "redirectToPayment": "支払いページにリダイレクトしています...", "onlyWorkspaceOwnerCanSetHomePage": "ワークスペースの所有者のみがホームページを設定できます", "pleaseAskOwnerToSetHomePage": "ワークスペースのオーナーにプロプランへのアップグレードを依頼してください" }, "publishedPage": { "title": "公開されたすべてのページ", "description": "公開したページを管理する", "page": "ページ", "pathName": "パス名", "date": "公開日", "emptyHinText": "このワークスペースには公開されたページはありません", "noPublishedPages": "公開ページはありません", "settings": "公開設定", "clickToOpenPageInApp": "アプリでページを開く", "clickToOpenPageInBrowser": "ブラウザでページを開く" }, "error": { "failedToGeneratePaymentLink": "プロプランの支払いリンクを生成できませんでした", "failedToUpdateNamespace": "名前空間の更新に失敗しました", "proPlanLimitation": "名前空間を更新するには、Proプランにアップグレードする必要があります", "namespaceAlreadyInUse": "名前空間はすでに使用されています。別の名前空間を試してください", "invalidNamespace": "名前空間が無効です。別の名前空間を試してください", "namespaceLengthAtLeast2Characters": "名前空間は少なくとも 2 文字の長さである必要があります", "onlyWorkspaceOwnerCanUpdateNamespace": "ワークスペースの所有者のみが名前空間を更新できます", "onlyWorkspaceOwnerCanRemoveHomepage": "ワークスペースの所有者のみがホームページを削除できます", "setHomepageFailed": "ホームページの設定に失敗しました", "namespaceTooLong": "名前空間が長すぎます。別の名前空間を試してください", "namespaceTooShort": "名前空間が短すぎます。別の名前空間を試してください。", "namespaceIsReserved": "名前空間は予約されています。別の名前空間を試してください", "updatePathNameFailed": "パス名の更新に失敗しました", "removeHomePageFailed": "ホームページを削除できませんでした", "publishNameContainsInvalidCharacters": "パス名に無効な文字が含まれています。別のパス名を試してください。", "publishNameTooShort": "パス名が短すぎます。別のパス名を試してください。", "publishNameTooLong": "パス名が長すぎます。別のパス名を試してください。", "publishNameAlreadyInUse": "パス名はすでに使用されています。別のパス名を試してください", "namespaceContainsInvalidCharacters": "名前空間に無効な文字が含まれています。別の名前空間を試してください。", "publishPermissionDenied": "公開設定を管理できるのはワークスペースの所有者またはページ発行者のみです。", "publishNameCannotBeEmpty": "パス名は空にできません。別のパス名を試してください。" }, "success": { "namespaceUpdated": "名前空間が正常に更新されました", "setHomepageSuccess": "ホームページの設定に成功しました", "updatePathNameSuccess": "パス名が正常に更新されました", "removeHomePageSuccess": "ホームページを削除しました" } }, "accountPage": { "menuLabel": "マイアカウント", "title": "マイアカウント", "general": { "title": "アカウント名 & プロフィール画像", "changeProfilePicture": "プロフィール画像を変更" }, "email": { "title": "メールアドレス", "actions": { "change": "メールアドレスを変更" } }, "login": { "title": "アカウントログイン", "loginLabel": "ログイン", "logoutLabel": "ログアウト" } }, "workspacePage": { "menuLabel": "ワークスペース", "title": "ワークスペース", "description": "ワークスペースの外観、テーマ、フォント、テキストレイアウト、日付/時間形式、言語をカスタマイズします。", "workspaceName": { "title": "ワークスペース名" }, "workspaceIcon": { "title": "ワークスペースアイコン", "description": "画像をアップロードするか、絵文字を使用してワークスペースにアイコンを設定します。アイコンはサイドバーや通知に表示されます。" }, "appearance": { "title": "外観", "description": "ワークスペースの外観、テーマ、フォント、テキストレイアウト、日付、時間、言語をカスタマイズします。", "options": { "system": "自動", "light": "ライト", "dark": "ダーク" } }, "resetCursorColor": { "title": "ドキュメントのカーソル色をリセット", "description": "カーソルの色をリセットしてもよろしいですか?" }, "resetSelectionColor": { "title": "ドキュメントの選択色をリセット", "description": "選択色をリセットしてもよろしいですか?" }, "resetWidth": { "resetSuccess": "ドキュメントの幅を正常にリセットしました" }, "theme": { "title": "テーマ", "description": "プリセットテーマを選択するか、独自のカスタムテーマをアップロードします。", "uploadCustomThemeTooltip": "カスタムテーマをアップロード" }, "workspaceFont": { "title": "ワークスペースのフォント", "noFontHint": "フォントが見つかりません。別のキーワードをお試しください。" }, "textDirection": { "title": "テキストの方向", "leftToRight": "左から右", "rightToLeft": "右から左", "auto": "自動", "enableRTLItems": "RTLツールバー項目を有効にする" }, "layoutDirection": { "title": "レイアウトの方向", "leftToRight": "左から右", "rightToLeft": "右から左" }, "dateTime": { "title": "日付と時間", "example": "{} at {} ({})", "24HourTime": "24時間表記", "dateFormat": { "label": "日付形式", "local": "ローカル", "us": "米国", "iso": "ISO", "friendly": "読み易さ", "dmy": "日/月/年" } }, "language": { "title": "言語" }, "deleteWorkspacePrompt": { "title": "ワークスペースの削除", "content": "このワークスペースを削除してもよろしいですか?この操作は元に戻せません。公開しているページはすべて非公開になります。" }, "leaveWorkspacePrompt": { "title": "ワークスペースを退出", "content": "このワークスペースを退出してもよろしいですか?ワークスペース内のすべてのページやデータへのアクセスを失います。" }, "manageWorkspace": { "title": "ワークスペースを管理", "leaveWorkspace": "ワークスペースを退出", "deleteWorkspace": "ワークスペースを削除" } }, "manageDataPage": { "menuLabel": "データ管理", "title": "データ管理", "description": "ローカルストレージデータの管理または既存のデータを@:appNameにインポートします。", "dataStorage": { "title": "ファイル保存場所", "tooltip": "ファイルが保存されている場所", "actions": { "change": "パスを変更", "open": "フォルダを開く", "openTooltip": "現在のデータフォルダの場所を開く", "copy": "パスをコピー", "copiedHint": "パスをコピーしました!", "resetTooltip": "デフォルトの場所にリセット" }, "resetDialog": { "title": "本当にリセットしますか?", "description": "パスをデフォルトのデータ保存場所にリセットしてもデータは削除されません。現在のデータを再インポートする場合、最初に現在の場所のパスをコピーしてください。" } }, "importData": { "title": "データのインポート", "tooltip": "@:appNameのバックアップ/データフォルダからデータをインポート", "description": "外部の@:appNameデータフォルダからデータをコピー", "action": "ファイルを参照" }, "encryption": { "title": "暗号化", "tooltip": "データの保存と暗号化方法を管理", "descriptionNoEncryption": "暗号化をオンにするとすべてのデータが暗号化されます。この操作は元に戻せません。", "descriptionEncrypted": "データは暗号化されています。", "action": "データを暗号化", "dialog": { "title": "すべてのデータを暗号化しますか?", "description": "すべてのデータを暗号化すると、データは安全かつ保護されます。この操作は元に戻せません。本当に続行しますか?" } }, "cache": { "title": "キャッシュをクリア", "description": "画像が読み込まれない、スペース内のページが見つからない、フォントが読み込まれないなどの問題を解決します。データには影響しません。", "dialog": { "title": "キャッシュをクリア", "description": "画像が読み込まれない、スペース内のページが見つからない、フォントが読み込まれないなどの問題を解決します。データには影響しません。", "successHint": "キャッシュをクリアしました!" } }, "data": { "fixYourData": "データを修復", "fixButton": "修復", "fixYourDataDescription": "データに問題がある場合、ここで修復を試みることができます。" } }, "shortcutsPage": { "menuLabel": "ショートカット", "title": "ショートカット", "editBindingHint": "新しいバインディングを入力", "searchHint": "検索", "actions": { "resetDefault": "デフォルトにリセット" }, "errorPage": { "message": "ショートカットの読み込みに失敗しました: {}", "howToFix": "もう一度お試しください。問題が続く場合はGitHubでお問い合わせください。" }, "resetDialog": { "title": "ショートカットをリセット", "description": "これにより、すべてのキー設定がデフォルトにリセットされます。この操作は元に戻せません。続行してもよろしいですか?", "buttonLabel": "リセット" }, "conflictDialog": { "title": "{}は現在使用されています", "descriptionPrefix": "このキー設定は現在 ", "descriptionSuffix": " によって使用されています。このキー設定を置き換えると、{} から削除されます。", "confirmLabel": "続行" }, "editTooltip": "キー設定の編集を開始するには押してください", "keybindings": { "toggleToDoList": "To-Doリストを切り替え", "insertNewParagraphInCodeblock": "新しい段落を挿入", "pasteInCodeblock": "コードブロックに貼り付け", "selectAllCodeblock": "すべて選択", "indentLineCodeblock": "行の先頭にスペースを2つ挿入", "outdentLineCodeblock": "行の先頭のスペースを2つ削除", "twoSpacesCursorCodeblock": "カーソル位置にスペースを2つ挿入", "copy": "選択をコピー", "paste": "コンテンツに貼り付け", "cut": "選択をカット", "alignLeft": "テキストを左揃え", "alignCenter": "テキストを中央揃え", "alignRight": "テキストを右揃え", "undo": "元に戻す", "redo": "やり直し", "convertToParagraph": "ブロックを段落に変換", "backspace": "削除", "deleteLeftWord": "左の単語を削除", "deleteLeftSentence": "左の文を削除", "delete": "右の文字を削除", "deleteMacOS": "左の文字を削除", "deleteRightWord": "右の単語を削除", "moveCursorLeft": "カーソルを左に移動", "moveCursorBeginning": "カーソルを先頭に移動", "moveCursorLeftWord": "カーソルを左に1単語移動", "moveCursorLeftSelect": "選択してカーソルを左に移動", "moveCursorBeginSelect": "選択してカーソルを先頭に移動", "moveCursorLeftWordSelect": "選択してカーソルを左に1単語移動", "moveCursorRight": "カーソルを右に移動", "moveCursorEnd": "カーソルを末尾に移動", "moveCursorRightWord": "カーソルを右に1単語移動", "moveCursorRightSelect": "選択してカーソルを右に1つ移動", "moveCursorEndSelect": "選択してカーソルを末尾に移動", "moveCursorRightWordSelect": "選択してカーソルを右に1単語移動", "moveCursorUp": "カーソルを上に移動", "moveCursorTopSelect": "選択してカーソルを最上部に移動", "moveCursorTop": "カーソルを最上部に移動", "moveCursorUpSelect": "選択してカーソルを上に移動", "moveCursorBottomSelect": "選択してカーソルを最下部に移動", "moveCursorBottom": "カーソルを最下部に移動", "moveCursorDown": "カーソルを下に移動", "moveCursorDownSelect": "選択してカーソルを下に移動", "home": "最上部にスクロール", "end": "最下部にスクロール", "toggleBold": "太字を切り替え", "toggleItalic": "斜体を切り替え", "toggleUnderline": "下線を切り替え", "toggleStrikethrough": "取り消し線を切り替え", "toggleCode": "インラインコードを切り替え", "toggleHighlight": "ハイライトを切り替え", "showLinkMenu": "リンクメニューを表示", "openInlineLink": "インラインリンクを開く", "openLinks": "選択されたすべてのリンクを開く", "indent": "インデント", "outdent": "アウトデント", "exit": "編集を終了", "pageUp": "1ページ上にスクロール", "pageDown": "1ページ下にスクロール", "selectAll": "すべて選択", "pasteWithoutFormatting": "フォーマットなしで貼り付け", "showEmojiPicker": "絵文字ピッカーを表示", "enterInTableCell": "表に改行を追加", "leftInTableCell": "表の左隣のセルに移動", "rightInTableCell": "表の右隣のセルに移動", "upInTableCell": "表の上隣のセルに移動", "downInTableCell": "表の下隣のセルに移動", "tabInTableCell": "表の次の利用可能なセルに移動", "shiftTabInTableCell": "表の前の利用可能なセルに移動", "backSpaceInTableCell": "セルの先頭で停止" }, "commands": { "codeBlockNewParagraph": "コードブロックの隣に新しい段落を挿入", "codeBlockIndentLines": "コードブロック内の行の先頭にスペースを2つ挿入", "codeBlockOutdentLines": "コードブロック内の行の先頭のスペースを2つ削除", "codeBlockAddTwoSpaces": "コードブロック内のカーソル位置にスペースを2つ挿入", "codeBlockSelectAll": "コードブロック内のすべてのコンテンツを選択", "codeBlockPasteText": "コードブロック内にテキストを貼り付け", "textAlignLeft": "テキストを左揃え", "textAlignCenter": "テキストを中央揃え", "textAlignRight": "テキストを右揃え" }, "couldNotLoadErrorMsg": "ショートカットを読み込めませんでした。もう一度お試しください。", "couldNotSaveErrorMsg": "ショートカットを保存できませんでした。もう一度お試しください。" }, "aiPage": { "title": "AI設定", "menuLabel": "AI設定", "keys": { "enableAISearchTitle": "AI検索", "aiSettingsDescription": "AppFlowy AIを駆動するモデルを選択します。GPT 4-o、Claude 3.5、Llama 3.1、Mistral 7Bが含まれます。", "loginToEnableAIFeature": "AI機能は@:appName Cloudにログインした後に有効になります。@:appNameアカウントがない場合は、「マイアカウント」でサインアップしてください。", "llmModel": "言語モデル", "llmModelType": "言語モデルのタイプ", "downloadLLMPrompt": "{}をダウンロード", "downloadAppFlowyOfflineAI": "AIオフラインパッケージをダウンロードすると、デバイス上でAIが動作します。続行しますか?", "downloadLLMPromptDetail": "{}のローカルモデルをダウンロードすると、{}のストレージを使用します。続行しますか?", "downloadBigFilePrompt": "ダウンロードが完了するまで約10分かかることがあります", "downloadAIModelButton": "ダウンロード", "downloadingModel": "ダウンロード中", "localAILoaded": "ローカルAIモデルが正常に追加され、使用可能です", "localAIStart": "ローカルAIチャットが開始しています...", "localAILoading": "ローカルAIチャットモデルを読み込み中...", "localAIStopped": "ローカルAIが停止しました", "failToLoadLocalAI": "ローカルAIの起動に失敗しました", "restartLocalAI": "ローカルAIを再起動", "disableLocalAITitle": "ローカルAIを無効化", "disableLocalAIDescription": "ローカルAIを無効にしますか?", "localAIToggleTitle": "ローカルAIを有効化または無効化", "offlineAIInstruction1": "次の手順に従って", "offlineAIInstruction2": "指示", "offlineAIInstruction3": "でオフラインAIを有効にします。", "offlineAIDownload1": "AppFlowy AIをまだダウンロードしていない場合は、", "offlineAIDownload2": "ダウンロード", "offlineAIDownload3": "してください", "activeOfflineAI": "アクティブ", "downloadOfflineAI": "ダウンロード", "openModelDirectory": "フォルダを開く" } }, "planPage": { "menuLabel": "プラン", "title": "料金プラン", "planUsage": { "title": "プラン使用状況の概要", "storageLabel": "ストレージ", "storageUsage": "{} / {} GB", "unlimitedStorageLabel": "無制限のストレージ", "collaboratorsLabel": "メンバー", "collaboratorsUsage": "{} / {}", "aiResponseLabel": "AIレスポンス", "aiResponseUsage": "{} / {}", "unlimitedAILabel": "無制限のAIレスポンス", "proBadge": "プロ", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "Mac用AIオンデバイス", "memberProToggle": "メンバーを追加 & 無制限のAI", "aiMaxToggle": "無制限のAI & 高度なモデルにアクセス", "aiOnDeviceToggle": "究極のプライバシーのためのローカルAI", "aiCredit": { "title": "@:appName AIクレジットを追加", "price": "{}", "priceDescription": "1,000クレジットの価格", "purchase": "AIを購入", "info": "ワークスペースごとに1,000 AIクレジットを追加し、より賢く迅速な結果を得るためにAIをワークフローにシームレスに統合します。最大で:", "infoItemOne": "データベースごとに10,000レスポンス", "infoItemTwo": "ワークスペースごとに1,000レスポンス" }, "currentPlan": { "bannerLabel": "現在のプラン", "freeTitle": "無料", "proTitle": "プロ", "teamTitle": "チーム", "freeInfo": "個人や2名までのメンバーに最適", "proInfo": "小規模・中規模チームに最適(最大10名)", "teamInfo": "生産的かつ整理されたチームに最適", "upgrade": "プランを変更", "canceledInfo": "プランはキャンセルされました。{}に無料プランにダウングレードされます。" }, "addons": { "title": "アドオン", "addLabel": "追加", "activeLabel": "追加済み", "aiMax": { "title": "AI Max", "description": "GPT-4o、Claude 3.5 Sonnetなどによる無制限のAIレスポンス", "price": "{}", "priceInfo": "1ユーザーあたり月額、年間請求" }, "aiOnDevice": { "title": "Mac用AIオンデバイス", "description": "Mistral 7B、LLAMA 3、その他のローカルモデルをマシンで実行", "price": "{}", "priceInfo": "1ユーザーあたり月額、年間請求", "recommend": "M1以上を推奨" } }, "deal": { "bannerLabel": "新年キャンペーン!", "title": "チームを成長させましょう!", "info": "Proプランとチームプランを10%オフでアップグレード!@:appName AIを含む強力な新機能でワークスペースの生産性を向上させましょう。", "viewPlans": "プランを表示" } } }, "billingPage": { "menuLabel": "請求", "title": "請求", "plan": { "title": "プラン", "freeLabel": "無料", "proLabel": "プロ", "planButtonLabel": "プランを変更", "billingPeriod": "請求期間", "periodButtonLabel": "期間を編集" }, "paymentDetails": { "title": "支払い詳細", "methodLabel": "支払い方法", "methodButtonLabel": "方法を編集" }, "addons": { "title": "アドオン", "addLabel": "追加", "removeLabel": "削除", "renewLabel": "更新", "aiMax": { "label": "AI Max", "description": "無制限のAIと高度なモデルを解放", "activeDescription": "次の請求日は{}", "canceledDescription": "AI Maxは{}まで利用可能です" }, "aiOnDevice": { "label": "Mac用AIオンデバイス", "description": "デバイス上で無制限のAIを解放", "activeDescription": "次の請求日は{}", "canceledDescription": "Mac用AIオンデバイスは{}まで利用可能です" }, "removeDialog": { "title": "{}を削除", "description": "{plan}を削除してもよろしいですか?{plan}の機能と特典へのアクセスをすぐに失います。" } }, "currentPeriodBadge": "現在の期間", "changePeriod": "期間を変更", "planPeriod": "{}期間", "monthlyInterval": "毎月", "monthlyPriceInfo": "毎月請求される座席あたりの料金", "annualInterval": "年次", "annualPriceInfo": "年間請求される座席あたりの料金" }, "comparePlanDialog": { "title": "プランの比較と選択", "planFeatures": "プラン\n機能", "current": "現在のプラン", "actions": { "upgrade": "アップグレード", "downgrade": "ダウングレード", "current": "現在のプラン" }, "freePlan": { "title": "無料", "description": "個人や2名までのメンバーに最適", "price": "{}", "priceInfo": "永久に無料" }, "proPlan": { "title": "プロ", "description": "小規模チームのプロジェクト管理とチーム知識の管理に最適", "price": "{}", "priceInfo": "1ユーザーあたり月額 \n年間請求\n\n{} 毎月請求" }, "planLabels": { "itemOne": "ワークスペース", "itemTwo": "メンバー", "itemThree": "ストレージ", "itemFour": "リアルタイムコラボレーション", "itemFive": "モバイルアプリ", "itemSix": "AIレスポンス", "itemFileUpload": "ファイルアップロード", "customNamespace": "カスタム名前空間", "tooltipSix": "ライフタイムはレスポンスの数がリセットされないことを意味します", "intelligentSearch": "インテリジェント検索", "tooltipSeven": "ワークスペースのURLの一部をカスタマイズできます", "customNamespaceTooltip": "カスタム公開サイト URL" }, "freeLabels": { "itemOne": "ワークスペースごとの料金", "itemTwo": "最大2名", "itemThree": "5 GB", "itemFour": "はい", "itemFive": "はい", "itemSix": "10回のライフタイムレスポンス", "itemFileUpload": "最大7 MB", "intelligentSearch": "インテリジェント検索" }, "proLabels": { "itemOne": "ワークスペースごとの料金", "itemTwo": "最大10名", "itemThree": "無制限", "itemFour": "はい", "itemFive": "はい", "itemSix": "無制限", "itemFileUpload": "無制限", "intelligentSearch": "インテリジェント検索" }, "paymentSuccess": { "title": "あなたは現在{}プランに加入しています!", "description": "お支払いが正常に処理され、プランが@:appName {}にアップグレードされました。プランの詳細はプランページで確認できます。" }, "downgradeDialog": { "title": "本当にプランをダウングレードしますか?", "description": "プランをダウングレードすると、無料プランに戻ります。メンバーがこのワークスペースにアクセスできなくなり、無料プランのストレージ制限を満たすためにスペースを空ける必要がある場合があります。", "downgradeLabel": "プランをダウングレード" } }, "cancelSurveyDialog": { "title": "ご利用いただきありがとうございました", "description": "ご解約されるのは残念です。@:appNameを改善するために、お客様のフィードバックをお聞かせください。いくつかの質問にお答えいただけますと幸いです。", "commonOther": "その他", "otherHint": "ここに回答を入力してください", "questionOne": { "question": "@:appName Proサブスクリプションをキャンセルした理由は何ですか?", "answerOne": "料金が高すぎる", "answerTwo": "機能が期待に応えなかった", "answerThree": "より良い代替案を見つけた", "answerFour": "コストに見合うほど使用しなかった", "answerFive": "サービスや技術的な問題があった" }, "questionTwo": { "question": "将来的に@:appName Proに再加入する可能性はどのくらいですか?", "answerOne": "非常に高い", "answerTwo": "やや高い", "answerThree": "わからない", "answerFour": "低い", "answerFive": "非常に低い" }, "questionThree": { "question": "サブスクリプション中に最も価値を感じたPro機能は何ですか?", "answerOne": "複数ユーザーのコラボレーション", "answerTwo": "長期間のバージョン履歴", "answerThree": "無制限のAIレスポンス", "answerFour": "ローカルAIモデルへのアクセス" }, "questionFour": { "question": "全体的な@:appNameの体験はどのようなものでしたか?", "answerOne": "素晴らしい", "answerTwo": "良い", "answerThree": "普通", "answerFour": "平均以下", "answerFive": "満足していない" } }, "common": { "uploadingFile": "ファイルをアップロード中です。アプリを終了しないでください", "uploadNotionSuccess": "Notion zipファイルが正常にアップロードされました。インポートが完了すると、確認メールが届きます。", "reset": "リセット" }, "menu": { "appearance": "外観", "language": "言語", "user": "ユーザー", "files": "ファイル", "notifications": "通知", "open": "設定を開く", "logout": "ログアウト", "logoutPrompt": "本当にログアウトしますか?", "selfEncryptionLogoutPrompt": "ログアウトしますか?暗号化キーをコピーしていることを確認してください。", "syncSetting": "同期設定", "cloudSettings": "クラウド設定", "enableSync": "同期を有効化", "enableSyncLog": "同期ログを有効にする", "enableSyncLogWarning": "同期の問題の診断にご協力いただきありがとうございます。これにより、ドキュメントの編集内容がローカルファイルに記録されます。有効にした後、アプリを終了して再度開いてください。", "enableEncrypt": "データを暗号化", "cloudURL": "基本URL", "webURL": "ウェブURL", "invalidCloudURLScheme": "無効なスキーム", "cloudServerType": "クラウドサーバー", "cloudServerTypeTip": "クラウドサーバーを変更すると現在のアカウントがログアウトされる可能性があります。", "cloudLocal": "ローカル", "cloudAppFlowy": "@:appName Cloud", "cloudAppFlowySelfHost": "@:appName クラウドセルフホスト", "appFlowyCloudUrlCanNotBeEmpty": "クラウドのURLを空にすることはできません", "clickToCopy": "クリックしてコピー", "selfHostStart": "サーバーをお持ちでない場合は、", "selfHostContent": "ドキュメント", "selfHostEnd": "を参照してセルフホストサーバーの設定方法をご確認ください", "pleaseInputValidURL": "有効なURLを入力してください", "changeUrl": "セルフホスト URL を {} に変更します", "cloudURLHint": "サーバーの基本URLを入力してください", "webURLHint": "ウェブサーバーのベースURLを入力してください", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "サーバーのWebsocketアドレスを入力してください", "restartApp": "再起動", "restartAppTip": "変更を反映するにはアプリケーションを再起動してください。再起動すると現在のアカウントがログアウトされる可能性があります。", "changeServerTip": "サーバーを変更後、変更を反映するには再起動ボタンをクリックしてください。", "enableEncryptPrompt": "この秘密キーでデータを暗号化します。安全に保管してください。有効にすると解除できません。キーを紛失するとデータが回復不可能になります。コピーするにはクリックしてください。", "inputEncryptPrompt": "暗号化キーを入力してください", "clickToCopySecret": "クリックして秘密キーをコピー", "configServerSetting": "サーバー設定を構成", "configServerGuide": "「クイックスタート」を選択後、「設定」→「クラウド設定」に進み、セルフホストサーバーを構成します。", "inputTextFieldHint": "秘密キー", "historicalUserList": "ユーザーログイン履歴", "historicalUserListTooltip": "このリストには匿名アカウントが表示されます。アカウントをクリックすると詳細が表示されます。匿名アカウントは「始める」ボタンをクリックすることで作成されます。", "openHistoricalUser": "匿名アカウントを開くにはクリック", "customPathPrompt": "Google Driveなどのクラウド同期フォルダに@:appNameデータフォルダを保存することはリスクを伴います。このフォルダ内のデータベースが複数の場所から同時にアクセスまたは変更されると、同期の競合やデータ破損が発生する可能性があります。", "importAppFlowyData": "外部@:appNameフォルダからデータをインポート", "importingAppFlowyDataTip": "データインポート中です。アプリを閉じないでください。", "importAppFlowyDataDescription": "外部@:appNameデータフォルダからデータをコピーし、現在のAppFlowyデータフォルダにインポートします。", "importSuccess": "@:appNameデータフォルダのインポートに成功しました", "importFailed": "@:appNameデータフォルダのインポートに失敗しました", "importGuide": "詳細については、参照されたドキュメントをご確認ください。" }, "notifications": { "enableNotifications": { "label": "通知を有効化", "hint": "ローカル通知の表示を停止するにはオフにします。" }, "showNotificationsIcon": { "label": "通知アイコンを表示", "hint": "サイドバーに通知アイコンを表示するにはオンにします。" }, "archiveNotifications": { "allSuccess": "すべての通知を正常にアーカイブしました", "success": "通知を正常にアーカイブしました" }, "markAsReadNotifications": { "allSuccess": "すべて既読にしました", "success": "正常に既読にしました" }, "action": { "markAsRead": "既読にする", "multipleChoice": "複数選択", "archive": "アーカイブ" }, "settings": { "settings": "設定", "markAllAsRead": "すべて既読にする", "archiveAll": "すべてアーカイブ" }, "emptyInbox": { "title": "インボックスゼロ!", "description": "リマインダーを設定してここで通知を受け取ります。" }, "emptyUnread": { "title": "未読の通知はありません", "description": "すべてが完了しました!" }, "emptyArchived": { "title": "アーカイブなし", "description": "アーカイブされた通知がここに表示されます。" }, "tabs": { "inbox": "受信箱", "unread": "未読", "archived": "アーカイブ" }, "refreshSuccess": "通知が正常に更新されました", "titles": { "notifications": "通知", "reminder": "リマインダー" } }, "appearance": { "resetSetting": "リセット", "fontFamily": { "label": "フォントファミリー", "search": "検索", "defaultFont": "システム" }, "themeMode": { "label": "テーマモード", "light": "ライトモード", "dark": "ダークモード", "system": "システムに適応" }, "fontScaleFactor": "フォントの拡大率", "documentSettings": { "cursorColor": "ドキュメントのカーソル色", "selectionColor": "ドキュメントの選択色", "width": "文書の幅", "changeWidth": "変更", "pickColor": "色を選択", "colorShade": "色の濃淡", "opacity": "不透明度", "hexEmptyError": "Hexカラーコードは空にできません", "hexLengthError": "Hex値は6桁である必要があります", "hexInvalidError": "無効なHex値です", "opacityEmptyError": "不透明度は空にできません", "opacityRangeError": "不透明度は1から100の間である必要があります", "app": "アプリ", "flowy": "Flowy", "apply": "適用" }, "layoutDirection": { "label": "レイアウト方向", "hint": "画面上のコンテンツの流れを、左から右、または右から左に制御します。", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "デフォルトのテキスト方向", "hint": "テキストがデフォルトで左から始まるか右から始まるかを指定します。", "ltr": "LTR", "rtl": "RTL", "auto": "自動", "fallback": "レイアウト方向と同じ" }, "themeUpload": { "button": "アップロード", "uploadTheme": "テーマをアップロード", "description": "下のボタンを使用して独自の@:appNameテーマをアップロードします。", "loading": "テーマの検証とアップロード中です。しばらくお待ちください...", "uploadSuccess": "テーマが正常にアップロードされました", "deletionFailure": "テーマの削除に失敗しました。手動で削除をお試しください。", "filePickerDialogTitle": ".flowy_pluginファイルを選択", "urlUploadFailure": "URLのオープンに失敗しました: {}" }, "theme": "テーマ", "builtInsLabel": "組み込みテーマ", "pluginsLabel": "プラグイン", "dateFormat": { "label": "日付形式", "local": "ローカル", "us": "US", "iso": "ISO", "friendly": "読み易さ", "dmy": "日/月/年" }, "timeFormat": { "label": "時間形式", "twelveHour": "12時間制", "twentyFourHour": "24時間制" }, "showNamingDialogWhenCreatingPage": "ページ作成時に名前付けダイアログを表示", "enableRTLToolbarItems": "RTLツールバー項目を有効にする", "members": { "title": "メンバー設定", "inviteMembers": "メンバーを招待", "inviteHint": "メールで招待", "sendInvite": "招待を送信", "copyInviteLink": "招待リンクをコピー", "label": "メンバー", "user": "ユーザー", "role": "役割", "removeFromWorkspace": "ワークスペースから削除", "removeFromWorkspaceSuccess": "ワークスペースから正常に削除されました", "removeFromWorkspaceFailed": "ワークスペースからの削除に失敗しました", "owner": "オーナー", "guest": "ゲスト", "member": "メンバー", "memberHintText": "メンバーはページを閲覧および編集できます", "guestHintText": "ゲストはページを閲覧、反応、コメントし、許可があれば一部のページを編集できます。", "emailInvalidError": "無効なメールアドレスです。確認してもう一度お試しください。", "emailSent": "メールが送信されました。受信箱を確認してください。", "members": "メンバー", "membersCount": { "zero": "{} 人のメンバー", "one": "{} 人のメンバー", "other": "{} 人のメンバー" }, "inviteFailedDialogTitle": "招待の送信に失敗しました", "inviteFailedMemberLimit": "メンバー制限に達しました。メンバーを追加するにはアップグレードしてください。", "inviteFailedMemberLimitMobile": "ワークスペースのメンバー制限に達しました。", "memberLimitExceeded": "メンバー制限に達しました。さらにメンバーを招待するには ", "memberLimitExceededUpgrade": "アップグレード", "memberLimitExceededPro": "メンバー制限に達しました。さらにメンバーが必要な場合は ", "memberLimitExceededProContact": "support@appflowy.io にご連絡ください", "failedToAddMember": "メンバーの追加に失敗しました", "addMemberSuccess": "メンバーが正常に追加されました", "removeMember": "メンバーを削除", "areYouSureToRemoveMember": "本当にこのメンバーを削除しますか?", "inviteMemberSuccess": "招待が正常に送信されました", "failedToInviteMember": "メンバーの招待に失敗しました", "workspaceMembersError": "問題が発生しました", "workspaceMembersErrorDescription": "現在、メンバーリストを読み込むことができません。後でもう一度お試しください" } }, "files": { "copy": "コピー", "defaultLocation": "ファイルとデータ保存場所を読み取る", "exportData": "データをエクスポート", "doubleTapToCopy": "パスをコピーするにはダブルタップ", "restoreLocation": "@:appName のデフォルトパスに復元", "customizeLocation": "別のフォルダを開く", "restartApp": "変更を反映するにはアプリを再起動してください。", "exportDatabase": "データベースをエクスポート", "selectFiles": "エクスポートするファイルを選択", "selectAll": "すべて選択", "deselectAll": "すべて選択解除", "createNewFolder": "新しいフォルダを作成", "createNewFolderDesc": "データを保存する場所を指定してください", "defineWhereYourDataIsStored": "データが保存される場所を設定", "open": "開く", "openFolder": "既存のフォルダを開く", "openFolderDesc": "既存の@:appName フォルダに読み書き", "folderHintText": "フォルダ名", "location": "新しいフォルダを作成", "locationDesc": "@:appName データフォルダの名前を指定", "browser": "参照", "create": "作成", "set": "設定", "folderPath": "フォルダを保存するパス", "locationCannotBeEmpty": "パスを空にすることはできません", "pathCopiedSnackbar": "ファイル保存パスがクリップボードにコピーされました!", "changeLocationTooltips": "データディレクトリを変更", "change": "変更", "openLocationTooltips": "別のデータディレクトリを開く", "openCurrentDataFolder": "現在のデータディレクトリを開く", "recoverLocationTooltips": "@:appName のデフォルトデータディレクトリにリセット", "exportFileSuccess": "ファイルのエクスポートに成功しました!", "exportFileFail": "ファイルのエクスポートに失敗しました!", "export": "エクスポート", "clearCache": "キャッシュをクリア", "clearCacheDesc": "画像が読み込まれない、フォントが表示されないなどの問題がある場合は、キャッシュをクリアしてください。この操作はユーザーデータを削除しません。", "areYouSureToClearCache": "キャッシュをクリアしますか?", "clearCacheSuccess": "キャッシュが正常にクリアされました!" }, "user": { "name": "名前", "email": "メールアドレス", "tooltipSelectIcon": "アイコンを選択", "selectAnIcon": "アイコンを選択", "pleaseInputYourOpenAIKey": "AIキーを入力してください", "clickToLogout": "現在のユーザーをログアウトするにはクリック" }, "mobile": { "personalInfo": "個人情報", "username": "ユーザー名", "usernameEmptyError": "ユーザー名は空にできません", "about": "概要", "pushNotifications": "プッシュ通知", "support": "サポート", "joinDiscord": "Discordに参加", "privacyPolicy": "プライバシーポリシー", "userAgreement": "利用規約", "termsAndConditions": "利用条件", "userprofileError": "ユーザープロフィールの読み込みに失敗しました", "userprofileErrorDescription": "ログアウトして再度ログインして、問題が解決するか確認してください。", "selectLayout": "レイアウトを選択", "selectStartingDay": "開始日を選択", "version": "バージョン" } }, "grid": { "deleteView": "このビューを削除してもよろしいですか?", "createView": "新規", "title": { "placeholder": "無題" }, "settings": { "filter": "フィルター", "sort": "並べ替え", "sortBy": "並べ替え基準", "properties": "プロパティ", "reorderPropertiesTooltip": "プロパティをドラッグして並べ替え", "group": "グループ", "addFilter": "フィルターを追加", "deleteFilter": "フィルターを削除", "filterBy": "フィルター条件...", "typeAValue": "値を入力...", "layout": "レイアウト", "databaseLayout": "レイアウト", "viewList": { "zero": "0 ビュー", "one": "{count} ビュー", "other": "{count} ビュー" }, "editView": "ビューを編集", "boardSettings": "ボード設定", "calendarSettings": "カレンダー設定", "createView": "新しいビュー", "duplicateView": "ビューを複製", "deleteView": "ビューを削除", "numberOfVisibleFields": "{} 表示中" }, "filter": { "empty": "アクティブなフィルターはありません", "addFilter": "フィルターを追加", "cannotFindCreatableField": "フィルタリングに適したフィールドが見つかりません", "conditon": "状態", "where": "どこ" }, "textFilter": { "contains": "含む", "doesNotContain": "含まない", "endsWith": "で終わる", "startWith": "で始まる", "is": "である", "isNot": "でない", "isEmpty": "空", "isNotEmpty": "空でない", "choicechipPrefix": { "isNot": "ではない", "startWith": "で始まる", "endWith": "で終わる", "isEmpty": "空", "isNotEmpty": "空でない" } }, "checkboxFilter": { "isChecked": "チェック済み", "isUnchecked": "未チェック", "choicechipPrefix": { "is": "である" } }, "checklistFilter": { "isComplete": "完了", "isIncomplted": "未完了" }, "selectOptionFilter": { "is": "である", "isNot": "ではない", "contains": "含む", "doesNotContain": "含まない", "isEmpty": "空", "isNotEmpty": "空でない" }, "dateFilter": { "is": "である", "before": "以前", "after": "以後", "onOrBefore": "以前またはその日", "onOrAfter": "以後またはその日", "between": "の間", "empty": "空", "notEmpty": "空でない", "startDate": "開始日", "endDate": "終了日", "choicechipPrefix": { "before": "以前", "after": "以後", "between": "間", "onOrBefore": "以前またはその日", "onOrAfter": "以後またはその日", "isEmpty": "空", "isNotEmpty": "空でない" } }, "numberFilter": { "equal": "等しい", "notEqual": "等しくない", "lessThan": "より小さい", "greaterThan": "より大きい", "lessThanOrEqualTo": "以下", "greaterThanOrEqualTo": "以上", "isEmpty": "空", "isNotEmpty": "空でない" }, "field": { "label": "プロパティ", "hide": "非表示", "show": "表示", "insertLeft": "左に挿入", "insertRight": "右に挿入", "duplicate": "複製", "delete": "削除", "wrapCellContent": "テキストを折り返し", "clear": "セルをクリア", "switchPrimaryFieldTooltip": "プライマリフィールドのフィールドタイプを変更できません", "textFieldName": "テキスト", "checkboxFieldName": "チェックボックス", "dateFieldName": "日付", "updatedAtFieldName": "最終更新日", "createdAtFieldName": "作成日", "numberFieldName": "数字", "singleSelectFieldName": "選択", "multiSelectFieldName": "マルチセレクト", "urlFieldName": "URL", "checklistFieldName": "チェックリスト", "relationFieldName": "リレーション", "summaryFieldName": "AIサマリー", "timeFieldName": "時間", "mediaFieldName": "ファイルとメディア", "translateFieldName": "AI翻訳", "translateTo": "翻訳先", "numberFormat": "数字の形式", "dateFormat": "日付の形式", "includeTime": "時間を含む", "isRange": "終了日", "dateFormatFriendly": "月 日, 年", "dateFormatISO": "年-月-日", "dateFormatLocal": "月/日/年", "dateFormatUS": "年/月/日", "dateFormatDayMonthYear": "日/月/年", "timeFormat": "時間形式", "invalidTimeFormat": "無効な形式", "timeFormatTwelveHour": "12時間制", "timeFormatTwentyFourHour": "24時間制", "clearDate": "日付をクリア", "dateTime": "日時", "startDateTime": "開始日時", "endDateTime": "終了日時", "failedToLoadDate": "日付の読み込みに失敗しました", "selectTime": "時間を選択", "selectDate": "日付を選択", "visibility": "表示", "propertyType": "プロパティの種類", "addSelectOption": "オプションを追加", "typeANewOption": "新しいオプションを入力", "optionTitle": "オプション", "addOption": "オプションを追加", "editProperty": "プロパティを編集", "newProperty": "新しいプロパティ", "openRowDocument": "ページとして開く", "deleteFieldPromptMessage": "本当によろしいですか?このプロパティとそのすべてのデータが削除されます", "clearFieldPromptMessage": "本当に空にしますか?この列のすべてのセルが空になります", "newColumn": "新しい列", "format": "形式", "reminderOnDateTooltip": "このセルにはリマインダーが設定されています", "optionAlreadyExist": "オプションはすでに存在します" }, "rowPage": { "newField": "新しいフィールドを追加", "fieldDragElementTooltip": "メニューを開くにはクリック", "showHiddenFields": { "one": "{count} 非表示フィールドを表示", "many": "{count} 非表示フィールドを表示", "other": "{count} 非表示フィールドを表示" }, "hideHiddenFields": { "one": "{count} 非表示フィールドを隠す", "many": "{count} 非表示フィールドを隠す", "other": "{count} 非表示フィールドを隠す" }, "openAsFullPage": "全画面で開く", "moreRowActions": "その他の行アクション" }, "sort": { "ascending": "昇順", "descending": "降順", "by": "基準", "empty": "アクティブな並べ替えがありません", "cannotFindCreatableField": "並べ替え可能なフィールドが見つかりません", "deleteAllSorts": "すべての並べ替えを削除", "addSort": "新しい並べ替えを追加", "sortsActive": "並べ替え中に{intention}できません", "removeSorting": "並べ替えを削除しますか?", "fieldInUse": "このフィールドですでに並べ替えが行われています" }, "row": { "label": "行", "duplicate": "複製", "delete": "削除", "titlePlaceholder": "無題", "textPlaceholder": "空", "copyProperty": "プロパティをクリップボードにコピー", "count": "カウント", "newRow": "新しい行", "loadMore": "さらに読み込む", "action": "アクション", "add": "下に追加をクリック", "drag": "ドラッグして移動", "deleteRowPrompt": "この行を削除してもよろしいですか?この操作は元に戻せません", "deleteCardPrompt": "このカードを削除してもよろしいですか?この操作は元に戻せません", "dragAndClick": "ドラッグして移動、クリックしてメニューを開く", "insertRecordAbove": "上にレコードを挿入", "insertRecordBelow": "下にレコードを挿入", "noContent": "コンテンツなし", "reorderRowDescription": "行を並べ替える", "createRowAboveDescription": "上に行を作成", "createRowBelowDescription": "下に行を挿入" }, "selectOption": { "create": "作成", "purpleColor": "パープル", "pinkColor": "ピンク", "lightPinkColor": "ライトピンク", "orangeColor": "オレンジ", "yellowColor": "イエロー", "limeColor": "ライム", "greenColor": "グリーン", "aquaColor": "アクア", "blueColor": "ブルー", "deleteTag": "タグを削除", "colorPanelTitle": "カラー", "panelTitle": "オプションを選択するか作成", "searchOption": "オプションを検索", "searchOrCreateOption": "オプションを検索するか作成", "createNew": "新しいものを作成", "orSelectOne": "またはオプションを選択", "typeANewOption": "新しいオプションを入力", "tagName": "タグ名" }, "checklist": { "taskHint": "タスクの説明", "addNew": "新しいタスクを追加", "submitNewTask": "作成", "hideComplete": "完了したタスクを非表示", "showComplete": "すべてのタスクを表示" }, "url": { "launch": "リンクをブラウザで開く", "copy": "リンクをクリップボードにコピー", "textFieldHint": "URLを入力" }, "relation": { "relatedDatabasePlaceLabel": "関連データベース", "relatedDatabasePlaceholder": "なし", "inRelatedDatabase": "に", "rowSearchTextFieldPlaceholder": "検索", "noDatabaseSelected": "データベースが選択されていません。まず以下のリストから1つ選択してください:", "emptySearchResult": "レコードが見つかりません", "linkedRowListLabel": "{count} リンクされた行", "unlinkedRowListLabel": "他の行をリンク" }, "menuName": "グリッド", "referencedGridPrefix": "ビュー", "calculate": "計算", "calculationTypeLabel": { "none": "なし", "average": "平均", "max": "最大", "median": "中央値", "min": "最小", "sum": "合計", "count": "カウント", "countEmpty": "空のカウント", "countEmptyShort": "空", "countNonEmpty": "空でないカウント", "countNonEmptyShort": "入力済み" }, "media": { "rename": "名前を変更", "download": "ダウンロード", "expand": "拡大", "delete": "削除", "moreFilesHint": "+{} ファイル", "addFileOrImage": "ファイル、画像、またはリンクを追加", "attachmentsHint": "{} 添付ファイル", "addFileMobile": "ファイルを追加", "extraCount": "+{}", "deleteFileDescription": "このファイルを削除してもよろしいですか? この操作は元に戻せません。", "showFileNames": "ファイル名を表示", "downloadSuccess": "ファイルをダウンロードしました", "downloadFailedToken": "ファイルのダウンロードに失敗しました。ユーザー トークンが利用できません", "setAsCover": "カバーとして設定", "openInBrowser": "ブラウザで開く", "embedLink": "ファイルリンクを埋め込む", "open": "開く", "showMore": "{} ファイルがさらにあります。クリックして表示" } }, "document": { "menuName": "ドキュメント", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "作成...", "slashMenu": { "board": { "selectABoardToLinkTo": "リンクするボードを選択", "createANewBoard": "新しいボードを作成" }, "grid": { "selectAGridToLinkTo": "リンクするグリッドを選択", "createANewGrid": "新しいグリッドを作成" }, "calendar": { "selectACalendarToLinkTo": "リンクするカレンダーを選択", "createANewCalendar": "新しいカレンダーを作成" }, "document": { "selectADocumentToLinkTo": "リンクするドキュメントを選択" }, "name": { "text": "テキスト", "heading1": "見出し1", "heading2": "見出し2", "heading3": "見出し3", "image": "画像", "bulletedList": "箇条書きリスト", "numberedList": "番号付きリスト", "todoList": "To-do リスト", "doc": "ドキュメント", "linkedDoc": "ページへのリンク", "grid": "グリッド", "linkedGrid": "リンクされたグリッド", "kanban": "カンバン", "linkedKanban": "リンクされたカンバン", "calendar": "カレンダー", "linkedCalendar": "リンクされたカレンダー", "quote": "引用", "divider": "区切り線", "table": "テーブル", "callout": "呼び出し", "outline": "アウトライン", "mathEquation": "数式", "code": "コード", "toggleList": "折りたたみリスト", "toggleHeading1": "見出し 1 を切り替える", "toggleHeading2": "見出し 2 を切り替える", "toggleHeading3": "見出し 3 を切り替える", "emoji": "絵文字", "aiWriter": "AIライター", "dateOrReminder": "日付またはリマインダー", "photoGallery": "フォトギャラリー", "file": "ファイル" }, "subPage": { "name": "書類", "keyword1": "サブページ", "keyword2": "ページ", "keyword3": "子ページ", "keyword4": "ページを挿入", "keyword5": "埋め込みページ", "keyword6": "新しいページ", "keyword7": "ページを作成", "keyword8": "書類" } }, "selectionMenu": { "outline": "アウトライン", "codeBlock": "コードブロック" }, "plugins": { "referencedBoard": "参照されたボード", "referencedGrid": "参照されたグリッド", "referencedCalendar": "参照されたカレンダー", "referencedDocument": "参照されたドキュメント", "autoGeneratorMenuItemName": "AIライター", "autoGeneratorTitleName": "AI: 任意の文章をAIに依頼...", "autoGeneratorLearnMore": "詳細を読む", "autoGeneratorGenerate": "生成", "autoGeneratorHintText": "AIに質問 ...", "autoGeneratorCantGetOpenAIKey": "AIキーを取得できません", "autoGeneratorRewrite": "書き直し", "smartEdit": "AIに依頼", "aI": "AI", "smartEditFixSpelling": "スペルと文法を修正", "warning": "⚠️ AIの回答は不正確または誤解を招く可能性があります。", "smartEditSummarize": "要約", "smartEditImproveWriting": "文章を改善", "smartEditMakeLonger": "長くする", "smartEditCouldNotFetchResult": "AIから結果を取得できませんでした", "smartEditCouldNotFetchKey": "AIキーを取得できませんでした", "smartEditDisabled": "設定でAIを接続してください", "appflowyAIEditDisabled": "AI機能を有効にするにはサインインしてください", "discardResponse": "AIの回答を破棄しますか?", "createInlineMathEquation": "数式を作成", "fonts": "フォント", "insertDate": "日付を挿入", "emoji": "絵文字", "toggleList": "折りたたみリスト", "emptyToggleHeading": "トグル h{} が空です。クリックしてコンテンツを追加します。", "emptyToggleList": "トグル リストが空です。クリックしてコンテンツを追加します。", "emptyToggleHeadingWeb": "トグル h{level} が空です。クリックしてコンテンツを追加してください", "quoteList": "引用リスト", "numberedList": "番号付きリスト", "bulletedList": "箇条書きリスト", "todoList": "To-do リスト", "callout": "呼び出し", "simpleTable": { "moreActions": { "color": "色", "align": "整列", "delete": "消去", "duplicate": "重複", "insertLeft": "左に挿入", "insertRight": "右に挿入", "insertAbove": "上に挿入", "insertBelow": "以下に挿入", "headerColumn": "ヘッダー列", "headerRow": "ヘッダー行", "clearContents": "内容をクリア", "setToPageWidth": "ページ幅に設定", "distributeColumnsWidth": "列を均等に分散する", "duplicateRow": "重複行", "duplicateColumn": "重複した列", "textColor": "テキストの色", "cellBackgroundColor": "セルの背景色", "duplicateTable": "重複テーブル" }, "clickToAddNewRow": "クリックして新しい行を追加します", "clickToAddNewColumn": "クリックして新しい列を追加します", "clickToAddNewRowAndColumn": "クリックして新しい行と列を追加します", "headerName": { "table": "テーブル", "alignText": "テキストを揃える" } }, "cover": { "changeCover": "カバーを変更", "colors": "色", "images": "画像", "clearAll": "すべてクリア", "abstract": "抽象的", "addCover": "カバーを追加", "addLocalImage": "ローカル画像を追加", "invalidImageUrl": "無効な画像URL", "failedToAddImageToGallery": "ギャラリーに画像を追加できませんでした", "enterImageUrl": "画像URLを入力", "add": "追加", "back": "戻る", "saveToGallery": "ギャラリーに保存", "removeIcon": "アイコンを削除", "removeCover": "カバーを削除", "pasteImageUrl": "画像URLを貼り付け", "or": "または", "pickFromFiles": "ファイルから選択", "couldNotFetchImage": "画像を取得できませんでした", "imageSavingFailed": "画像の保存に失敗しました", "addIcon": "アイコンを追加", "changeIcon": "アイコンを変更", "coverRemoveAlert": "削除するとカバーからも削除されます。", "alertDialogConfirmation": "本当に続けますか?" }, "mathEquation": { "name": "数式", "addMathEquation": "TeX数式を追加", "editMathEquation": "数式を編集" }, "optionAction": { "click": "クリック", "toOpenMenu": " でメニューを開く", "drag": "ドラッグ", "toMove": " 移動する", "delete": "削除", "duplicate": "複製", "turnInto": "変換", "moveUp": "上に移動", "moveDown": "下に移動", "color": "色", "align": "整列", "left": "左", "center": "中央", "right": "右", "defaultColor": "デフォルト", "depth": "深さ", "copyLinkToBlock": "リンクをブロックにコピーする" }, "image": { "addAnImage": "画像を追加", "copiedToPasteBoard": "画像リンクがクリップボードにコピーされました", "addAnImageDesktop": "画像を追加", "addAnImageMobile": "クリックして画像を追加", "dropImageToInsert": "挿入する画像をドロップ", "imageUploadFailed": "画像のアップロードに失敗しました", "imageDownloadFailed": "画像のダウンロードに失敗しました。もう一度お試しください", "imageDownloadFailedToken": "ユーザートークンがないため、画像のアップロードに失敗しました。もう一度お試しください", "errorCode": "エラーコード" }, "photoGallery": { "name": "フォトギャラリー", "imageKeyword": "画像", "imageGalleryKeyword": "画像ギャラリー", "photoKeyword": "写真", "photoBrowserKeyword": "フォトブラウザ", "galleryKeyword": "ギャラリー", "addImageTooltip": "画像を追加", "changeLayoutTooltip": "レイアウトを変更", "browserLayout": "ブラウザ", "gridLayout": "グリッド", "deleteBlockTooltip": "ギャラリー全体を削除" }, "math": { "copiedToPasteBoard": "数式がクリップボードにコピーされました" }, "urlPreview": { "copiedToPasteBoard": "リンクがクリップボードにコピーされました", "convertToLink": "埋め込みリンクに変換" }, "outline": { "addHeadingToCreateOutline": "目次を作成するには見出しを追加してください。", "noMatchHeadings": "一致する見出しが見つかりませんでした。" }, "table": { "addAfter": "後に追加", "addBefore": "前に追加", "delete": "削除", "clear": "内容をクリア", "duplicate": "複製", "bgColor": "背景色" }, "contextMenu": { "copy": "コピー", "cut": "切り取り", "paste": "貼り付け", "pasteAsPlainText": "プレーンテキストとして貼り付け" }, "action": "アクション", "database": { "selectDataSource": "データソースを選択", "noDataSource": "データソースなし", "selectADataSource": "データソースを選択", "toContinue": "続行する", "newDatabase": "新しいデータベース", "linkToDatabase": "データベースにリンク" }, "date": "日付", "video": { "label": "ビデオ", "emptyLabel": "ビデオを追加", "placeholder": "ビデオリンクを貼り付け", "copiedToPasteBoard": "ビデオリンクがクリップボードにコピーされました", "insertVideo": "ビデオを追加", "invalidVideoUrl": "ソースURLはサポートされていません。", "invalidVideoUrlYouTube": "YouTubeはまだサポートされていません。", "supportedFormats": "サポートされている形式: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "ファイル", "uploadTab": "アップロード", "uploadMobile": "ファイルを選択", "uploadMobileGallery": "フォトギャラリーより", "networkTab": "リンクを埋め込む", "placeholderText": "ファイルをアップロードまたは埋め込む", "placeholderDragging": "アップロードするファイルをドロップ", "dropFileToUpload": "アップロードするファイルをドロップ", "fileUploadHint": "ファイルをここにドロップ\nまたはクリックして参照", "fileUploadHintSuffix": "ブラウズ", "networkHint": "ファイルリンクを貼り付け", "networkUrlInvalid": "無効なURLです。URLを修正してもう一度お試しください", "networkAction": "ファイルリンクを埋め込む", "fileTooBigError": "ファイルサイズが大きすぎます。10MB未満のファイルをアップロードしてください", "renameFile": { "title": "ファイルの名前を変更", "description": "このファイルの新しい名前を入力してください", "nameEmptyError": "ファイル名を空にすることはできません。" }, "uploadedAt": "アップロード日: {}", "linkedAt": "リンク追加日: {}", "failedToOpenMsg": "開けませんでした。ファイルが見つかりません" }, "subPage": { "handlingPasteHint": " - (ペーストの取り扱い)", "errors": { "failedDeletePage": "ページの削除に失敗しました", "failedCreatePage": "ページの作成に失敗しました", "failedMovePage": "このドキュメントにページを移動できませんでした", "failedDuplicatePage": "ページの複製に失敗しました", "failedDuplicateFindView": "ページの複製に失敗しました - 元のビューが見つかりません" } }, "cannotMoveToItsChildren": "子に移動できません" }, "outlineBlock": { "placeholder": "目次" }, "textBlock": { "placeholder": "'/' を入力してコマンドを使用" }, "title": { "placeholder": "無題" }, "imageBlock": { "placeholder": "クリックして画像を追加", "upload": { "label": "アップロード", "placeholder": "クリックして画像をアップロード" }, "url": { "label": "画像URL", "placeholder": "画像URLを入力" }, "ai": { "label": "AIから画像を生成", "placeholder": "AIに画像を生成させるプロンプトを入力してください" }, "stability_ai": { "label": "Stability AIから画像を生成", "placeholder": "Stability AIに画像を生成させるプロンプトを入力してください" }, "support": "画像サイズの上限は5MBです。サポートされている形式: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "無効な画像です", "invalidImageSize": "画像サイズは5MB未満である必要があります", "invalidImageFormat": "サポートされていない画像形式です。サポートされている形式: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "無効な画像URLです", "noImage": "ファイルまたはディレクトリが見つかりません", "multipleImagesFailed": "1つ以上の画像のアップロードに失敗しました。再試行してください" }, "embedLink": { "label": "リンクを埋め込む", "placeholder": "画像リンクを貼り付けるか入力" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "画像を検索", "pleaseInputYourOpenAIKey": "設定ページでAIキーを入力してください", "saveImageToGallery": "画像をギャラリーに保存", "failedToAddImageToGallery": "ギャラリーに画像を追加できませんでした", "successToAddImageToGallery": "画像がギャラリーに正常に追加されました", "unableToLoadImage": "画像を読み込めませんでした", "maximumImageSize": "サポートされている画像の最大サイズは10MBです", "uploadImageErrorImageSizeTooBig": "画像サイズは10MB未満である必要があります", "imageIsUploading": "画像をアップロード中", "openFullScreen": "全画面で開く", "interactiveViewer": { "toolbar": { "previousImageTooltip": "前の画像", "nextImageTooltip": "次の画像", "zoomOutTooltip": "ズームアウト", "zoomInTooltip": "ズームイン", "changeZoomLevelTooltip": "ズームレベルを変更", "openLocalImage": "画像を開く", "downloadImage": "画像をダウンロード", "closeViewer": "インタラクティブビューアを閉じる", "scalePercentage": "{}%", "deleteImageTooltip": "画像を削除" } } }, "codeBlock": { "language": { "label": "言語", "placeholder": "言語を選択", "auto": "自動" }, "copyTooltip": "コピー", "searchLanguageHint": "言語を検索", "codeCopiedSnackbar": "コードがクリップボードにコピーされました!" }, "inlineLink": { "placeholder": "リンクを貼り付けるか入力", "openInNewTab": "新しいタブで開く", "copyLink": "リンクをコピー", "removeLink": "リンクを削除", "url": { "label": "リンクURL", "placeholder": "リンクURLを入力" }, "title": { "label": "リンクタイトル", "placeholder": "リンクタイトルを入力" } }, "mention": { "placeholder": "人物、ページ、日付をメンション...", "page": { "label": "ページへのリンク", "tooltip": "クリックしてページを開く" }, "deleted": "削除済み", "deletedContent": "このコンテンツは存在しないか、削除されました", "noAccess": "アクセス権がありません", "deletedPage": "削除されたページ", "trashHint": " - ゴミ箱に入れる", "morePages": "その他のページ" }, "toolbar": { "resetToDefaultFont": "デフォルトに戻す" }, "errorBlock": { "theBlockIsNotSupported": "ブロックコンテンツを解析できません", "clickToCopyTheBlockContent": "クリックしてブロックコンテンツをコピー", "blockContentHasBeenCopied": "ブロックコンテンツがコピーされました。", "parseError": "{}ブロックの解析中にエラーが発生しました。", "copyBlockContent": "ブロックコンテンツをコピー" }, "mobilePageSelector": { "title": "ページを選択", "failedToLoad": "ページリストの読み込みに失敗しました", "noPagesFound": "ページが見つかりません" }, "attachmentMenu": { "choosePhoto": "写真を選択", "takePicture": "写真を撮る", "chooseFile": "ファイルを選択" } }, "board": { "column": { "label": "カラム", "createNewCard": "新規", "renameGroupTooltip": "押してグループ名を変更", "createNewColumn": "新しいグループを追加", "addToColumnTopTooltip": "上に新しいカードを追加", "addToColumnBottomTooltip": "下に新しいカードを追加", "renameColumn": "名前を変更", "hideColumn": "非表示", "newGroup": "新しいグループ", "deleteColumn": "削除", "deleteColumnConfirmation": "このグループとその中のすべてのカードが削除されます。\n続行してもよろしいですか?" }, "hiddenGroupSection": { "sectionTitle": "非表示のグループ", "collapseTooltip": "非表示グループを隠す", "expandTooltip": "非表示グループを表示" }, "cardDetail": "カードの詳細", "cardActions": "カードアクション", "cardDuplicated": "カードが複製されました", "cardDeleted": "カードが削除されました", "showOnCard": "カードの詳細に表示", "setting": "設定", "propertyName": "プロパティ名", "menuName": "ボード", "showUngrouped": "グループ化されていない項目を表示", "ungroupedButtonText": "グループ化されていない", "ungroupedButtonTooltip": "どのグループにも属していないカードが含まれています", "ungroupedItemsTitle": "クリックしてボードに追加", "groupBy": "グループ化", "groupCondition": "グループ条件", "referencedBoardPrefix": "表示元", "notesTooltip": "内部のメモ", "mobile": { "editURL": "URLを編集", "showGroup": "グループを表示", "showGroupContent": "このグループをボード上に表示してもよろしいですか?", "failedToLoad": "ボードビューの読み込みに失敗しました" }, "dateCondition": { "weekOf": "{}週 - {}", "today": "今日", "yesterday": "昨日", "tomorrow": "明日", "lastSevenDays": "過去7日間", "nextSevenDays": "次の7日間", "lastThirtyDays": "過去30日間", "nextThirtyDays": "次の30日間" }, "noGroup": "プロパティによるグループ化なし", "noGroupDesc": "ボードビューを表示するには、グループ化するプロパティが必要です", "media": { "cardText": "{} {}", "fallbackName": "ファイル" } }, "calendar": { "menuName": "カレンダー", "defaultNewCalendarTitle": "無題", "newEventButtonTooltip": "新しいイベントを追加", "navigation": { "today": "今日", "jumpToday": "今日にジャンプ", "previousMonth": "前の月", "nextMonth": "次の月", "views": { "day": "日", "week": "週", "month": "月", "year": "年" } }, "mobileEventScreen": { "emptyTitle": "まだイベントがありません", "emptyBody": "プラスボタンを押してこの日にイベントを作成してください。" }, "settings": { "showWeekNumbers": "週番号を表示", "showWeekends": "週末を表示", "firstDayOfWeek": "週の開始日", "layoutDateField": "カレンダーのレイアウト", "changeLayoutDateField": "レイアウトフィールドを変更", "noDateTitle": "日付なし", "noDateHint": { "zero": "予定されていないイベントがここに表示されます", "one": "{count} 件の予定されていないイベント", "other": "{count} 件の予定されていないイベント" }, "unscheduledEventsTitle": "予定されていないイベント", "clickToAdd": "クリックしてカレンダーに追加", "name": "カレンダー設定", "clickToOpen": "クリックしてレコードを開く" }, "referencedCalendarPrefix": "表示元", "quickJumpYear": "ジャンプ", "duplicateEvent": "イベントを複製" }, "errorDialog": { "title": "@:appName エラー", "howToFixFallback": "ご不便をおかけして申し訳ありません!エラー内容をGitHubページに報告してください。", "howToFixFallbackHint1": "ご不便をおかけして申し訳ありません!エラー内容を報告するには、", "howToFixFallbackHint2": "ページにアクセスしてください。", "github": "GitHubで表示" }, "search": { "label": "検索", "sidebarSearchIcon": "検索してページに素早く移動", "placeholder": { "actions": "アクションを検索..." } }, "message": { "copy": { "success": "コピー完了!", "fail": "コピーに失敗しました" } }, "unSupportBlock": "このバージョンではこのブロックはサポートされていません。", "views": { "deleteContentTitle": "{pageType}を削除してもよろしいですか?", "deleteContentCaption": "この{pageType}を削除すると、ゴミ箱から復元できます。" }, "colors": { "custom": "カスタム", "default": "デフォルト", "red": "赤", "orange": "オレンジ", "yellow": "黄色", "green": "緑", "blue": "青", "purple": "紫", "pink": "ピンク", "brown": "茶色", "gray": "灰色" }, "emoji": { "emojiTab": "絵文字", "search": "絵文字を検索", "noRecent": "最近使用された絵文字なし", "noEmojiFound": "絵文字が見つかりません", "filter": "フィルター", "random": "ランダム", "selectSkinTone": "スキントーンを選択", "remove": "絵文字を削除", "categories": { "smileys": "スマイリー&感情", "people": "人々", "animals": "自然", "food": "食べ物", "activities": "アクティビティ", "places": "場所", "objects": "オブジェクト", "symbols": "記号", "flags": "旗", "nature": "自然", "frequentlyUsed": "よく使われる" }, "skinTone": { "default": "デフォルト", "light": "明るい", "mediumLight": "やや明るい", "medium": "普通", "mediumDark": "やや暗い", "dark": "暗い" }, "openSourceIconsFrom": "オープンソースのアイコン提供元" }, "inlineActions": { "noResults": "結果なし", "recentPages": "最近のページ", "pageReference": "ページ参照", "docReference": "ドキュメント参照", "boardReference": "ボード参照", "calReference": "カレンダー参照", "gridReference": "グリッド参照", "date": "日付", "reminder": { "groupTitle": "リマインダー", "shortKeyword": "リマインド" }, "createPage": "\"{}\" サブページを作成" }, "datePicker": { "dateTimeFormatTooltip": "設定で日付と時刻の形式を変更", "dateFormat": "日付形式", "includeTime": "時刻を含む", "isRange": "終了日", "timeFormat": "時刻形式", "clearDate": "日付をクリア", "reminderLabel": "リマインダー", "selectReminder": "リマインダーを選択", "reminderOptions": { "none": "なし", "atTimeOfEvent": "イベント時", "fiveMinsBefore": "5分前", "tenMinsBefore": "10分前", "fifteenMinsBefore": "15分前", "thirtyMinsBefore": "30分前", "oneHourBefore": "1時間前", "twoHoursBefore": "2時間前", "onDayOfEvent": "イベント当日", "oneDayBefore": "1日前", "twoDaysBefore": "2日前", "oneWeekBefore": "1週間前", "custom": "カスタム" } }, "relativeDates": { "yesterday": "昨日", "today": "今日", "tomorrow": "明日", "oneWeek": "1週間" }, "notificationHub": { "title": "通知", "mobile": { "title": "更新" }, "emptyTitle": "すべて完了!", "emptyBody": "保留中の通知やアクションはありません。", "tabs": { "inbox": "受信箱", "upcoming": "今後" }, "actions": { "markAllRead": "すべて既読にする", "showAll": "すべて表示", "showUnreads": "未読のみ表示" }, "filters": { "ascending": "昇順", "descending": "降順", "groupByDate": "日付でグループ化", "showUnreadsOnly": "未読のみ表示", "resetToDefault": "デフォルトにリセット" } }, "reminderNotification": { "title": "リマインダー", "message": "忘れないうちに確認してください!", "tooltipDelete": "削除", "tooltipMarkRead": "既読にする", "tooltipMarkUnread": "未読にする" }, "findAndReplace": { "find": "検索", "previousMatch": "前の一致", "nextMatch": "次の一致", "close": "閉じる", "replace": "置換", "replaceAll": "すべて置換", "noResult": "結果なし", "caseSensitive": "大文字と小文字を区別", "searchMore": "さらに検索して結果を探す" }, "error": { "weAreSorry": "申し訳ありません", "loadingViewError": "このビューの読み込みに問題があります。インターネット接続を確認し、アプリを更新してください。問題が続く場合は、チームにお問い合わせください。", "syncError": "別のデバイスからデータが同期されていません", "syncErrorHint": "最後に編集したデバイスでこのページを再度開き、次に現在のデバイスで再度開いてください。", "clickToCopy": "クリックしてエラーコードをコピー" }, "editor": { "bold": "太字", "bulletedList": "箇条書き", "bulletedListShortForm": "箇条書き", "checkbox": "チェックボックス", "embedCode": "コードを埋め込む", "heading1": "見出し1", "heading2": "見出し2", "heading3": "見出し3", "highlight": "ハイライト", "color": "色", "image": "画像", "date": "日付", "page": "ページ", "italic": "斜体", "link": "リンク", "numberedList": "番号付きリスト", "numberedListShortForm": "番号付き", "toggleHeading1ShortForm": "h1 トグル", "toggleHeading2ShortForm": "h2 トグル", "toggleHeading3ShortForm": "h3 トグル", "quote": "引用", "strikethrough": "取り消し線", "text": "テキスト", "underline": "下線", "fontColorDefault": "デフォルト", "fontColorGray": "灰色", "fontColorBrown": "茶色", "fontColorOrange": "オレンジ", "fontColorYellow": "黄色", "fontColorGreen": "緑", "fontColorBlue": "青", "fontColorPurple": "紫", "fontColorPink": "ピンク", "fontColorRed": "赤", "backgroundColorDefault": "デフォルト背景", "backgroundColorGray": "灰色背景", "backgroundColorBrown": "茶色背景", "backgroundColorOrange": "オレンジ背景", "backgroundColorYellow": "黄色背景", "backgroundColorGreen": "緑背景", "backgroundColorBlue": "青背景", "backgroundColorPurple": "紫背景", "backgroundColorPink": "ピンク背景", "backgroundColorRed": "赤背景", "backgroundColorLime": "ライム背景", "backgroundColorAqua": "アクア背景", "done": "完了", "cancel": "キャンセル", "tint1": "ティント1", "tint2": "ティント2", "tint3": "ティント3", "tint4": "ティント4", "tint5": "ティント5", "tint6": "ティント6", "tint7": "ティント7", "tint8": "ティント8", "tint9": "ティント9", "lightLightTint1": "紫", "lightLightTint2": "ピンク", "lightLightTint3": "ライトピンク", "lightLightTint4": "オレンジ", "lightLightTint5": "黄色", "lightLightTint6": "ライム", "lightLightTint7": "緑", "lightLightTint8": "アクア", "lightLightTint9": "青", "urlHint": "URL", "mobileHeading1": "見出し1", "mobileHeading2": "見出し2", "mobileHeading3": "見出し3", "mobileHeading4": "見出し4", "mobileHeading5": "見出し5", "mobileHeading6": "見出し6", "textColor": "テキスト色", "backgroundColor": "背景色", "addYourLink": "リンクを追加", "openLink": "リンクを開く", "copyLink": "リンクをコピー", "removeLink": "リンクを削除", "editLink": "リンクを編集", "linkText": "テキスト", "linkTextHint": "テキストを入力してください", "linkAddressHint": "URLを入力してください", "highlightColor": "ハイライト色", "clearHighlightColor": "ハイライト色をクリア", "customColor": "カスタムカラー", "hexValue": "16進数値", "opacity": "不透明度", "resetToDefaultColor": "デフォルト色にリセット", "ltr": "左から右", "rtl": "右から左", "auto": "自動", "cut": "切り取り", "copy": "コピー", "paste": "貼り付け", "find": "検索", "select": "選択", "selectAll": "すべて選択", "previousMatch": "前の一致", "nextMatch": "次の一致", "closeFind": "閉じる", "replace": "置換", "replaceAll": "すべて置換", "regex": "正規表現", "caseSensitive": "大文字小文字を区別", "uploadImage": "画像をアップロード", "urlImage": "URL画像", "incorrectLink": "不正なリンク", "upload": "アップロード", "chooseImage": "画像を選択", "loading": "読み込み中", "imageLoadFailed": "画像の読み込みに失敗しました", "divider": "区切り線", "table": "テーブル", "colAddBefore": "前に追加", "rowAddBefore": "前に追加", "colAddAfter": "後に追加", "rowAddAfter": "後に追加", "colRemove": "削除", "rowRemove": "削除", "colDuplicate": "複製", "rowDuplicate": "複製", "colClear": "内容をクリア", "rowClear": "内容をクリア", "slashPlaceHolder": "'/'を入力してブロックを挿入、または入力を開始", "typeSomething": "何かを入力...", "toggleListShortForm": "トグル", "quoteListShortForm": "引用", "mathEquationShortForm": "数式", "codeBlockShortForm": "コード" }, "favorite": { "noFavorite": "お気に入りページがありません", "noFavoriteHintText": "ページを左にスワイプして、お気に入りに追加します", "removeFromSidebar": "サイドバーから削除", "addToSidebar": "サイドバーにピン留め" }, "cardDetails": { "notesPlaceholder": "ブロックを挿入するには「/」を入力、または入力を開始" }, "blockPlaceholders": { "todoList": "To-do", "bulletList": "リスト", "numberList": "リスト", "quote": "引用", "heading": "見出し {}" }, "titleBar": { "pageIcon": "ページアイコン", "language": "言語", "font": "フォント", "actions": "アクション", "date": "日付", "addField": "フィールドを追加", "userIcon": "ユーザーアイコン" }, "noLogFiles": "ログファイルがありません", "newSettings": { "myAccount": { "title": "アカウント", "subtitle": "プロフィールのカスタマイズ、アカウントセキュリティ管理、AIキーの設定、アカウントへのログイン管理。", "profileLabel": "アカウント名とプロフィール画像", "profileNamePlaceholder": "名前を入力", "accountSecurity": "アカウントセキュリティ", "2FA": "2段階認証", "aiKeys": "AIキー", "accountLogin": "アカウントログイン", "updateNameError": "名前の更新に失敗しました", "updateIconError": "アイコンの更新に失敗しました", "deleteAccount": { "title": "アカウント削除", "subtitle": "アカウントとすべてのデータを完全に削除します。", "description": "アカウントを削除し、すべてのワークスペースへのアクセスを削除します。", "deleteMyAccount": "アカウントを削除", "dialogTitle": "アカウント削除", "dialogContent1": "アカウントを完全に削除してよろしいですか?", "dialogContent2": "この操作は元に戻せません。すべてのワークスペースへのアクセスが削除され、アカウント全体とプライベートワークスペースを含むすべてが削除されます。", "confirmHint1": "確認のために「DELETE MY ACCOUNT」と入力してください。", "confirmHint2": "この操作が元に戻せないことを理解しました。", "confirmHint3": "DELETE MY ACCOUNT", "checkToConfirmError": "削除の確認チェックボックスを選択してください", "failedToGetCurrentUser": "現在のユーザーの取得に失敗しました", "confirmTextValidationFailed": "確認テキストが「DELETE MY ACCOUNT」と一致しません", "deleteAccountSuccess": "アカウントが正常に削除されました" } }, "workplace": { "name": "ワークプレース", "title": "ワークプレース設定", "subtitle": "ワークスペースの外観、テーマ、フォント、テキストレイアウト、日付、時刻、言語をカスタマイズします。", "workplaceName": "ワークプレース名", "workplaceNamePlaceholder": "ワークプレース名を入力", "workplaceIcon": "ワークプレースアイコン", "workplaceIconSubtitle": "画像をアップロードするか、絵文字を使用してワークスペースを表現します。アイコンはサイドバーや通知で表示されます。", "renameError": "ワークプレースの名前変更に失敗しました", "updateIconError": "アイコンの更新に失敗しました", "chooseAnIcon": "アイコンを選択", "appearance": { "name": "外観", "themeMode": { "auto": "自動", "light": "ライト", "dark": "ダーク" }, "language": "言語" } }, "syncState": { "syncing": "同期中", "synced": "同期済み", "noNetworkConnected": "ネットワークに接続されていません" } }, "pageStyle": { "title": "ページスタイル", "layout": "レイアウト", "coverImage": "カバー画像", "pageIcon": "ページアイコン", "colors": "色", "gradient": "グラデーション", "backgroundImage": "背景画像", "presets": "プリセット", "photo": "写真", "unsplash": "Unsplash", "pageCover": "ページカバー", "none": "なし", "openSettings": "設定を開く", "photoPermissionTitle": "@:appName はフォトライブラリへのアクセスを希望します", "photoPermissionDescription": "@:appName に写真へのアクセス権が必要です。写真をドキュメントに追加するには許可が必要です。", "cameraPermissionTitle": "@:appNameカメラにアクセスしようとしています", "cameraPermissionDescription": "カメラからドキュメントに画像を追加するには、 @:appNameにカメラへのアクセスを許可する必要があります", "doNotAllow": "許可しない", "image": "画像" }, "commandPalette": { "placeholder": "検索ワードを入力...", "bestMatches": "最適な候補", "recentHistory": "最近の履歴", "navigateHint": "移動するには", "loadingTooltip": "結果を検索中...", "betaLabel": "ベータ", "betaTooltip": "現在はページやドキュメントの内容のみの検索をサポートしています", "fromTrashHint": "ゴミ箱から", "noResultsHint": "目的の結果が見つかりませんでした。別のキーワードで検索してください。", "clearSearchTooltip": "検索フィールドをクリア" }, "space": { "delete": "削除", "deleteConfirmation": "削除: ", "deleteConfirmationDescription": "このスペース内のすべてのページが削除され、ゴミ箱に移動されます。公開されたページは公開が取り消されます。", "rename": "スペースの名前変更", "changeIcon": "アイコンを変更", "manage": "スペースの管理", "addNewSpace": "スペースを作成", "collapseAllSubPages": "すべてのサブページを折りたたむ", "createNewSpace": "新しいスペースを作成", "createSpaceDescription": "複数のパブリックおよびプライベートスペースを作成し、作業を整理しましょう。", "spaceName": "スペース名", "spaceNamePlaceholder": "例: マーケティング、エンジニアリング、HR", "permission": "権限", "publicPermission": "パブリック", "publicPermissionDescription": "フルアクセスを持つすべてのワークスペースメンバー", "privatePermission": "プライベート", "privatePermissionDescription": "あなたのみがこのスペースにアクセス可能", "spaceIconBackground": "アイコンの背景色", "spaceIcon": "アイコン", "dangerZone": "危険ゾーン", "unableToDeleteLastSpace": "最後のスペースは削除できません", "unableToDeleteSpaceNotCreatedByYou": "他のユーザーが作成したスペースは削除できません", "enableSpacesForYourWorkspace": "ワークスペースでスペースを有効にする", "title": "スペース", "defaultSpaceName": "全般", "upgradeSpaceTitle": "スペースを有効にする", "upgradeSpaceDescription": "複数のパブリックおよびプライベートスペースを作成し、ワークスペースを整理しましょう。", "upgrade": "アップグレード", "upgradeYourSpace": "複数のスペースを作成", "quicklySwitch": "次のスペースに素早く切り替える", "duplicate": "スペースを複製", "movePageToSpace": "ページをスペースに移動", "cannotMovePageToDatabase": "ページをデータベースに移動できません", "switchSpace": "スペースを切り替え", "spaceNameCannotBeEmpty": "スペース名は空にできません", "success": { "deleteSpace": "スペースが正常に削除されました", "renameSpace": "スペース名の変更に成功しました", "duplicateSpace": "スペースが正常に複製されました", "updateSpace": "スペースが正常に更新されました" }, "error": { "deleteSpace": "スペースを削除できませんでした", "renameSpace": "スペースの名前を変更できませんでした", "duplicateSpace": "スペースの複製に失敗しました", "updateSpace": "スペースの更新に失敗しました" }, "createSpace": "スペースを作る", "manageSpace": "スペースを管理する", "renameSpace": "スペースの名前を変更する", "mSpaceIconColor": "スペースアイコンの色", "mSpaceIcon": "スペースアイコン" }, "publish": { "hasNotBeenPublished": "このページはまだ公開されていません", "spaceHasNotBeenPublished": "スペースの公開はまだサポートされていません", "reportPage": "ページを報告", "databaseHasNotBeenPublished": "データベースの公開はまだサポートされていません。", "createdWith": "作成元", "downloadApp": "AppFlowy をダウンロード", "copy": { "codeBlock": "コードブロックの内容がクリップボードにコピーされました", "imageBlock": "画像リンクがクリップボードにコピーされました", "mathBlock": "数式がクリップボードにコピーされました", "fileBlock": "ファイルリンクがクリップボードにコピーされました" }, "containsPublishedPage": "このページには公開済みのページが含まれています。続行すると公開が解除されます。削除してもよろしいですか?", "publishSuccessfully": "正常に公開されました", "unpublishSuccessfully": "正常に非公開にされました", "publishFailed": "公開に失敗しました", "unpublishFailed": "非公開に失敗しました", "noAccessToVisit": "このページへのアクセス権がありません...", "createWithAppFlowy": "AppFlowy でウェブサイトを作成", "fastWithAI": "AIを使って迅速かつ簡単に。", "tryItNow": "今すぐ試してみる", "onlyGridViewCanBePublished": "グリッドビューのみが公開可能です", "database": { "zero": "{} 選択したビューを公開", "one": "{} 選択したビューを公開", "many": "{} 選択したビューを公開", "other": "{} 選択したビューを公開" }, "mustSelectPrimaryDatabase": "プライマリビューを選択する必要があります", "noDatabaseSelected": "少なくとも1つのデータベースを選択してください。", "unableToDeselectPrimaryDatabase": "プライマリデータベースの選択を解除できません", "saveThisPage": "このテンプレートを使用", "duplicateTitle": "どこに追加しますか?", "selectWorkspace": "ワークスペースを選択", "addTo": "追加", "duplicateSuccessfully": "ワークスペースに追加されました", "duplicateSuccessfullyDescription": "AppFlowy がインストールされていませんか? 「ダウンロード」をクリックすると、自動的にダウンロードが開始されます。", "downloadIt": "ダウンロード", "openApp": "アプリで開く", "duplicateFailed": "複製に失敗しました", "membersCount": { "zero": "メンバーなし", "one": "1人のメンバー", "many": "{count}人のメンバー", "other": "{count}人のメンバー" }, "useThisTemplate": "このテンプレートを使用" }, "web": { "continue": "続ける", "or": "または", "continueWithGoogle": "Googleで続ける", "continueWithGithub": "GitHubで続ける", "continueWithDiscord": "Discordで続ける", "continueWithApple": "Appleで続ける", "moreOptions": "他のオプション", "collapse": "折りたたむ", "signInAgreement": "「続ける」をクリックすると、AppFlowyの", "and": "および", "termOfUse": "利用規約", "privacyPolicy": "プライバシーポリシー", "signInError": "サインインエラー", "login": "サインアップまたはログイン", "fileBlock": { "uploadedAt": "{time}にアップロード", "linkedAt": "{time}にリンク追加", "empty": "ファイルをアップロードまたは埋め込み", "uploadFailed": "アップロードに失敗しました。もう一度お試しください。", "retry": "リトライ" }, "importNotion": "Notionからインポート", "import": "輸入", "importSuccess": "アップロードに成功しました", "importSuccessMessage": "インポートが完了すると通知されます。その後、インポートしたページをサイドバーで表示できます。", "importFailed": "インポートに失敗しました。ファイル形式を確認してください", "dropNotionFile": "Notionのzipファイルをここにドロップしてアップロードするか、クリックして参照してください", "error": { "pageNameIsEmpty": "ページ名が空です。別の名前を試してください" } }, "globalComment": { "comments": "コメント", "addComment": "コメントを追加", "reactedBy": "リアクションした人", "addReaction": "リアクションを追加", "reactedByMore": "他 {count} 人", "showSeconds": { "one": "1秒前", "other": "{count}秒前", "zero": "たった今", "many": "{count}秒前" }, "showMinutes": { "one": "1分前", "other": "{count}分前", "many": "{count}分前" }, "showHours": { "one": "1時間前", "other": "{count}時間前", "many": "{count}時間前" }, "showDays": { "one": "1日前", "other": "{count}日前", "many": "{count}日前" }, "showMonths": { "one": "1ヶ月前", "other": "{count}ヶ月前", "many": "{count}ヶ月前" }, "showYears": { "one": "1年前", "other": "{count}年前", "many": "{count}年前" }, "reply": "返信", "deleteComment": "コメントを削除", "youAreNotOwner": "このコメントの所有者ではありません", "confirmDeleteDescription": "このコメントを削除してもよろしいですか?", "hasBeenDeleted": "削除済み", "replyingTo": "返信先", "noAccessDeleteComment": "このコメントを削除する権限がありません", "collapse": "折りたたむ", "readMore": "続きを読む", "failedToAddComment": "コメントの追加に失敗しました...", "commentAddedSuccessfully": "コメントが正常に追加されました。", "commentAddedSuccessTip": "コメントまたは返信を追加しました。最新のコメントを確認するためにトップに移動しますか?" }, "template": { "asTemplate": "テンプレートとして保存", "name": "テンプレート名", "description": "テンプレートの説明", "about": "テンプレートについて", "preview": "テンプレートのプレビュー", "categories": "テンプレートのカテゴリー", "isNewTemplate": "新しいテンプレートとしてピン留め", "featured": "おすすめとしてピン留め", "relatedTemplates": "関連テンプレート", "requiredField": "{field}は必須です", "addCategory": "「{category}」を追加", "addNewCategory": "新しいカテゴリーを追加", "addNewCreator": "新しい作成者を追加", "deleteCategory": "カテゴリーを削除", "editCategory": "カテゴリーを編集", "editCreator": "作成者を編集", "category": { "name": "カテゴリー名", "icon": "カテゴリーアイコン", "bgColor": "カテゴリー背景色", "priority": "カテゴリーの優先度", "desc": "カテゴリーの説明", "type": "カテゴリーの種類", "icons": "カテゴリーアイコン", "colors": "カテゴリーの色", "byUseCase": "ユースケース別", "byFeature": "機能別", "deleteCategory": "カテゴリーを削除", "deleteCategoryDescription": "このカテゴリーを削除してもよろしいですか?", "typeToSearch": "カテゴリーを検索..." }, "creator": { "label": "テンプレート作成者", "name": "作成者名", "avatar": "作成者のアバター", "accountLinks": "作成者のアカウントリンク", "uploadAvatar": "クリックしてアバターをアップロード", "deleteCreator": "作成者を削除", "deleteCreatorDescription": "この作成者を削除してもよろしいですか?", "typeToSearch": "作成者を検索..." }, "uploadSuccess": "テンプレートが正常にアップロードされました。", "uploadSuccessDescription": "テンプレートが正常にアップロードされました。テンプレートギャラリーで確認できます。", "viewTemplate": "テンプレートを表示", "deleteTemplate": "テンプレートを削除", "deleteSuccess": "テンプレートが正常に削除されました", "deleteTemplateDescription": "このテンプレートを削除してもよろしいですか?", "addRelatedTemplate": "関連テンプレートを追加", "removeRelatedTemplate": "関連テンプレートを削除", "uploadAvatar": "アバターをアップロード", "searchInCategory": "{category}で検索", "label": "テンプレート" }, "fileDropzone": { "dropFile": "クリックまたはドラッグしてファイルをアップロード", "uploading": "アップロード中...", "uploadFailed": "アップロードに失敗しました", "uploadSuccess": "アップロード成功", "uploadSuccessDescription": "ファイルが正常にアップロードされました", "uploadFailedDescription": "ファイルのアップロードに失敗しました", "uploadingDescription": "ファイルをアップロード中" }, "gallery": { "preview": "全画面表示で開く", "copy": "コピー", "download": "ダウンロード", "prev": "前へ", "next": "次へ", "resetZoom": "ズームをリセット", "zoomIn": "ズームイン", "zoomOut": "ズームアウト" }, "invitation": { "join": "参加する", "on": "の上", "invitedBy": "招待者", "membersCount": { "zero": "{count} 人のメンバー", "one": "{count} 人のメンバー", "many": "{count} 人のメンバー", "other": "{count} 人のメンバー" }, "tip": "以下の連絡先情報を使用して、このワークスペースに参加するよう招待されました。これが間違っている場合は、管理者に連絡して招待を再送信してください。", "joinWorkspace": "ワークスペースに参加", "success": "ワークスペースへの参加が完了しました", "successMessage": "これで、その中のすべてのページとワークスペースにアクセスできるようになります。", "openWorkspace": "AppFlowyを開く", "alreadyAccepted": "すでに招待を承諾しています", "errorModal": { "title": "問題が発生しました", "description": "現在のアカウント {email} ではこのワークスペースにアクセスできない可能性があります。正しいアカウントでログインするか、ワークスペースの所有者に問い合わせてください。", "contactOwner": "所有者に連絡する", "close": "家に戻る", "changeAccount": "アカウントを変更する" } }, "requestAccess": { "title": "このページにアクセスできません", "subtitle": "このページの所有者にアクセスをリクエストできます。承認されると、ページを表示できるようになります。", "requestAccess": "アクセスをリクエスト", "backToHome": "homeに戻る", "tip": "現在ログインしているのは。", "mightBe": "別のアカウントでのログインが必要になるかもしれません。", "successful": "リクエストは正常に送信されました", "successfulMessage": "所有者がリクエストを承認すると通知されます。", "requestError": "アクセスをリクエストできませんでした", "repeatRequestError": "このページへのアクセスはすでにリクエストされています" }, "approveAccess": { "title": "ワークスペース参加リクエストを承認", "requestSummary": "参加リクエストアクセス", "upgrade": "アップグレード", "downloadApp": "AppFlowyをダウンロード", "approveButton": "承認する", "approveSuccess": "承認されました", "approveError": "承認に失敗しました。ワークスペース プランの制限を超えていないことを確認してください。", "getRequestInfoError": "リクエスト情報を取得できませんでした", "memberCount": { "zero": "メンバーなし", "one": "メンバー 1 人", "many": "{count} 人のメンバー", "other": "{count} 人のメンバー" }, "alreadyProTitle": "ワークスペースプランの制限に達しました", "alreadyProMessage": "連絡を取るよう依頼するより多くのメンバーのロックを解除する", "repeatApproveError": "このリクエストはすでに承認されています", "ensurePlanLimit": "ワークスペースプランの制限を超えていないことを確認してください。制限を超えた場合は、ワークスペースプランまたは。", "requestToJoin": "参加をリクエストされた", "asMember": "メンバーとして" }, "upgradePlanModal": { "title": "プロにアップグレード", "message": "{name} は無料メンバーの上限に達しました。より多くのメンバーを招待するには、Pro プランにアップグレードしてください。", "upgradeSteps": "AppFlowyでプランをアップグレードする方法:", "step1": "1.設定へ移動", "step2": "2. 「プラン」をクリック", "step3": "3. 「プランの変更」を選択します", "appNote": "注記: ", "actionButton": "アップグレード", "downloadLink": "アプリをダウンロード", "laterButton": "後で", "refreshNote": "アップグレードが成功したら、新しい機能を有効にします。", "refresh": "ここ" }, "breadcrumbs": { "label": "パンくず" }, "time": { "justNow": "たった今", "seconds": { "one": "1秒", "other": "{count} 秒" }, "minutes": { "one": "1分", "other": "{count} 分" }, "hours": { "one": "1時間", "other": "{count} 時間" }, "days": { "one": "1日", "other": "{count} 日" }, "weeks": { "one": "1週間", "other": "{count} 週間" }, "months": { "one": "1ヶ月", "other": "{count} ヶ月" }, "years": { "one": "1年", "other": "{count} 年" }, "ago": "以前", "yesterday": "昨日", "today": "今日" }, "members": { "zero": "メンバーなし", "one": "メンバー 1 人", "many": "{count} 人のメンバー", "other": "{count} 人のメンバー" }, "tabMenu": { "close": "閉じる", "closeDisabledHint": "固定されたタブを閉じることはできません。まず固定を解除してください", "closeOthers": "他のタブを閉じる", "closeOthersHint": "これにより、このタブを除くすべての固定されていないタブが閉じられます", "closeOthersDisabledHint": "すべてのタブが固定されており、閉じるタブが見つかりません", "favorite": "お気に入り", "unfavorite": "お気に入りを解除", "favoriteDisabledHint": "このビューをお気に入りに登録できません", "pinTab": "ピン", "unpinTab": "ピンを外す" }, "openFileMessage": { "success": "ファイルは正常に開かれました", "fileNotFound": "ファイルが見つかりません", "noAppToOpenFile": "このファイルを開くアプリはありません", "permissionDenied": "このファイルを開く権限がありません", "unknownError": "ファイルのオープンに失敗しました" }, "inviteMember": { "requestInviteMembers": "ワークスペースに招待する", "inviteFailedMemberLimit": "メンバーの上限に達しました。 ", "upgrade": "アップグレード", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "招待状を送る", "inviteAlready": "このメールアドレスはすでに招待されています: {email}", "inviteSuccess": "招待状は正常に送信されました", "description": "以下にカンマで区切ってメールアドレスを入力してください。料金はメンバー数に基づいて決まります。", "emails": "メール" }, "quickNote": { "label": "クイックノート", "quickNotes": "クイックノート", "search": "クイックノートを検索", "collapseFullView": "全画面表示を折りたたむ", "expandFullView": "全画面表示を拡大", "createFailed": "クイックノートの作成に失敗しました", "quickNotesEmpty": "クイックノートなし", "emptyNote": "空のメモ", "deleteNotePrompt": "選択したメモは完全に削除されます。削除してもよろしいですか?", "addNote": "新しいメモ", "noAdditionalText": "追加テキストなし" }, "subscribe": { "upgradePlanTitle": "プランを比較して選択", "yearly": "年間", "save": "{discount}% 節約", "monthly": "月次", "priceIn": "価格", "free": "無料", "pro": "プロ", "freeDescription": "個人で最大2人までがすべてを管理", "proDescription": "小規模なチームがプロジェクトとチームの知識を管理する", "proDuration": { "monthly": "会員あたり月額\n毎月請求", "yearly": "メンバーあたり月額\n毎年請求" }, "cancel": "ダウングレード", "changePlan": "プロプランにアップグレード", "everythingInFree": "すべて無料+", "currentPlan": "現在", "freeDuration": "永遠", "freePoints": { "first": "最大 2 人のメンバーが参加できる 1 つの共同ワークスペース", "second": "ページとブロック無制限", "three": "5 GBのストレージ", "four": "インテリジェント検索", "five": "20件のAI回答", "six": "モバイルアプリ", "seven": "リアルタイムコラボレーション" }, "proPoints": { "first": "無制限のストレージ", "second": "ワークスペースメンバー最大10人", "three": "無制限のAI応答", "four": "無制限のファイルアップロード", "five": "カスタム名前空間" }, "cancelPlan": { "title": "去ってしまうのは残念です", "success": "サブスクリプションは正常にキャンセルされました", "description": "ご利用いただけなくなるのは残念です。AppFlowy の改善に役立てていただけるよう、皆様のフィードバックをお待ちしています。お時間をいただき、いくつかの質問にお答えください。", "commonOther": "他", "otherHint": "ここに答えを書いてください", "questionOne": { "question": "AppFlowy Pro サブスクリプションをキャンセルした理由は何ですか?", "answerOne": "コストが高すぎる", "answerTwo": "機能が期待に応えられなかった", "answerThree": "より良い代替案を見つけた", "answerFour": "費用に見合うほど使用しなかった", "answerFive": "サービスの問題または技術的な問題" }, "questionTwo": { "question": "将来的にAppFlowy Proへの再加入を検討する可能性はどのくらいありますか?", "answerOne": "非常に可能性が高い", "answerTwo": "可能性が高い", "answerThree": "わからない", "answerFour": "可能性が低い", "answerFive": "非常に可能性が低い" }, "questionThree": { "question": "サブスクリプション期間中に最も価値を感じた Pro 機能はどれですか?", "answerOne": "複数ユーザーコラボレーション", "answerTwo": "長期間のバージョン履歴", "answerThree": "無制限のAI応答", "answerFour": "ローカルAIモデルへのアクセス" }, "questionFour": { "question": "AppFlowy の全体的な使用感と満足感をどう思いますか?", "answerOne": "秀", "answerTwo": "優", "answerThree": "良", "answerFour": "可", "answerFive": "不可" } } } } ================================================ FILE: frontend/resources/translations/ko-KR.json ================================================ { "appName": "AppFlowy", "defaultUsername": "나", "welcomeText": "@:appName에 오신 것을 환영합니다", "welcomeTo": "환영합니다", "githubStarText": "GitHub에서 별표", "subscribeNewsletterText": "뉴스레터 구독", "letsGoButtonText": "빠른 시작", "title": "제목", "youCanAlso": "또한 할 수 있습니다", "and": "그리고", "failedToOpenUrl": "URL을 열지 못했습니다: {}", "blockActions": { "addBelowTooltip": "아래에 추가하려면 클릭", "addAboveCmd": "Alt+클릭", "addAboveMacCmd": "Option+클릭", "addAboveTooltip": "위에 추가하려면", "dragTooltip": "이동하려면 드래그", "openMenuTooltip": "메뉴를 열려면 클릭" }, "signUp": { "buttonText": "가입하기", "title": "@:appName에 가입하기", "getStartedText": "시작하기", "emptyPasswordError": "비밀번호는 비워둘 수 없습니다", "repeatPasswordEmptyError": "비밀번호 확인은 비워둘 수 없습니다", "unmatchedPasswordError": "비밀번호 확인이 비밀번호와 일치하지 않습니다", "alreadyHaveAnAccount": "이미 계정이 있으신가요?", "emailHint": "이메일", "passwordHint": "비밀번호", "repeatPasswordHint": "비밀번호 확인", "signUpWith": "다음으로 가입:" }, "signIn": { "loginTitle": "@:appName에 로그인", "loginButtonText": "로그인", "loginStartWithAnonymous": "익명 세션으로 계속", "continueAnonymousUser": "익명 세션으로 계속", "buttonText": "로그인", "signingInText": "로그인 중...", "forgotPassword": "비밀번호를 잊으셨나요?", "emailHint": "이메일", "passwordHint": "비밀번호", "dontHaveAnAccount": "계정이 없으신가요?", "createAccount": "계정 만들기", "repeatPasswordEmptyError": "비밀번호 확인은 비워둘 수 없습니다", "unmatchedPasswordError": "비밀번호 확인이 비밀번호와 일치하지 않습니다", "syncPromptMessage": "데이터 동기화에는 시간이 걸릴 수 있습니다. 이 페이지를 닫지 마세요", "or": "또는", "signInWithGoogle": "Google로 계속", "signInWithGithub": "GitHub로 계속", "signInWithDiscord": "Discord로 계속", "signInWithApple": "Apple로 계속", "continueAnotherWay": "다른 방법으로 계속", "signUpWithGoogle": "Google로 가입", "signUpWithGithub": "GitHub로 가입", "signUpWithDiscord": "Discord로 가입", "signInWith": "다음으로 계속:", "signInWithEmail": "이메일로 계속", "signInWithMagicLink": "계속", "signUpWithMagicLink": "Magic Link로 가입", "pleaseInputYourEmail": "이메일 주소를 입력하세요", "settings": "설정", "magicLinkSent": "Magic Link가 전송되었습니다!", "invalidEmail": "유효한 이메일 주소를 입력하세요", "alreadyHaveAnAccount": "이미 계정이 있으신가요?", "logIn": "로그인", "generalError": "문제가 발생했습니다. 나중에 다시 시도하세요", "limitRateError": "보안상의 이유로, 매 60초마다 한 번씩만 Magic Link를 요청할 수 있습니다", "magicLinkSentDescription": "Magic Link가 이메일로 전송되었습니다. 링크를 클릭하여 로그인을 완료하세요. 링크는 5분 후에 만료됩니다.", "anonymous": "익명" }, "workspace": { "chooseWorkspace": "작업 공간 선택", "defaultName": "내 작업 공간", "create": "작업 공간 생성", "new": "새 작업 공간", "importFromNotion": "Notion에서 가져오기", "learnMore": "자세히 알아보기", "reset": "작업 공간 재설정", "renameWorkspace": "작업 공간 이름 변경", "workspaceNameCannotBeEmpty": "작업 공간 이름은 비워둘 수 없습니다", "resetWorkspacePrompt": "작업 공간을 재설정하면 모든 페이지와 데이터가 삭제됩니다. 작업 공간을 재설정하시겠습니까? 또는 지원 팀에 문의하여 작업 공간을 복원할 수 있습니다", "hint": "작업 공간", "notFoundError": "작업 공간을 찾을 수 없습니다", "failedToLoad": "문제가 발생했습니다! 작업 공간을 로드하지 못했습니다. @:appName의 모든 열린 인스턴스를 닫고 다시 시도하세요.", "errorActions": { "reportIssue": "문제 보고", "reportIssueOnGithub": "GitHub에서 문제 보고", "exportLogFiles": "로그 파일 내보내기", "reachOut": "Discord에서 문의" }, "menuTitle": "작업 공간", "deleteWorkspaceHintText": "작업 공간을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며, 게시한 모든 페이지가 게시 취소됩니다.", "createSuccess": "작업 공간이 성공적으로 생성되었습니다", "createFailed": "작업 공간 생성 실패", "createLimitExceeded": "계정에 허용된 최대 작업 공간 수에 도달했습니다. 추가 작업 공간이 필요하면 GitHub에 요청하세요", "deleteSuccess": "작업 공간이 성공적으로 삭제되었습니다", "deleteFailed": "작업 공간 삭제 실패", "openSuccess": "작업 공간이 성공적으로 열렸습니다", "openFailed": "작업 공간 열기 실패", "renameSuccess": "작업 공간 이름이 성공적으로 변경되었습니다", "renameFailed": "작업 공간 이름 변경 실패", "updateIconSuccess": "작업 공간 아이콘이 성공적으로 업데이트되었습니다", "updateIconFailed": "작업 공간 아이콘 업데이트 실패", "cannotDeleteTheOnlyWorkspace": "유일한 작업 공간을 삭제할 수 없습니다", "fetchWorkspacesFailed": "작업 공간을 가져오지 못했습니다", "leaveCurrentWorkspace": "작업 공간 나가기", "leaveCurrentWorkspacePrompt": "현재 작업 공간을 나가시겠습니까?" }, "shareAction": { "buttonText": "공유", "workInProgress": "곧 출시 예정", "markdown": "Markdown", "html": "HTML", "clipboard": "클립보드에 복사", "csv": "CSV", "copyLink": "링크 복사", "publishToTheWeb": "웹에 게시", "publishToTheWebHint": "AppFlowy로 웹사이트 만들기", "publish": "게시", "unPublish": "게시 취소", "visitSite": "사이트 방문", "exportAsTab": "다음으로 내보내기", "publishTab": "게시", "shareTab": "공유", "publishOnAppFlowy": "AppFlowy에 게시", "shareTabTitle": "협업 초대", "shareTabDescription": "누구와도 쉽게 협업할 수 있습니다", "copyLinkSuccess": "링크가 클립보드에 복사되었습니다", "copyShareLink": "공유 링크 복사", "copyLinkFailed": "링크를 클립보드에 복사하지 못했습니다", "copyLinkToBlockSuccess": "블록 링크가 클립보드에 복사되었습니다", "copyLinkToBlockFailed": "블록 링크를 클립보드에 복사하지 못했습니다", "manageAllSites": "모든 사이트 관리", "updatePathName": "경로 이름 업데이트" }, "moreAction": { "small": "작게", "medium": "중간", "large": "크게", "fontSize": "글꼴 크기", "import": "가져오기", "moreOptions": "더 많은 옵션", "wordCount": "단어 수: {}", "charCount": "문자 수: {}", "createdAt": "생성일: {}", "deleteView": "삭제", "duplicateView": "복제", "wordCountLabel": "단어 수: ", "charCountLabel": "문자 수: ", "createdAtLabel": "생성일: ", "syncedAtLabel": "동기화됨: ", "saveAsNewPage": "페이지에 메시지 추가", "saveAsNewPageDisabled": "사용 가능한 메시지가 없습니다" }, "importPanel": { "textAndMarkdown": "텍스트 & Markdown", "documentFromV010": "v0.1.0에서 문서 가져오기", "databaseFromV010": "v0.1.0에서 데이터베이스 가져오기", "notionZip": "Notion 내보낸 Zip 파일", "csv": "CSV", "database": "데이터베이스" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "파일을 드래그 앤 드롭하거나 클릭하여 ", "placeholderUpload": "업로드", "placeholderRight": "하거나 이미지 링크를 붙여넣으세요.", "dropToUpload": "업로드할 파일을 드롭하세요", "change": "변경" } }, "disclosureAction": { "rename": "이름 변경", "delete": "삭제", "duplicate": "복제", "unfavorite": "즐겨찾기에서 제거", "favorite": "즐겨찾기에 추가", "openNewTab": "새 탭에서 열기", "moveTo": "이동", "addToFavorites": "즐겨찾기에 추가", "copyLink": "링크 복사", "changeIcon": "아이콘 변경", "collapseAllPages": "모든 하위 페이지 접기", "movePageTo": "페이지 이동", "move": "이동", "lockPage": "페이지 잠금" }, "blankPageTitle": "빈 페이지", "newPageText": "새 페이지", "newDocumentText": "새 문서", "newGridText": "새 그리드", "newCalendarText": "새 캘린더", "newBoardText": "새 보드", "chat": { "newChat": "AI 채팅", "inputMessageHint": "@:appName AI에게 물어보세요", "inputLocalAIMessageHint": "@:appName 로컬 AI에게 물어보세요", "unsupportedCloudPrompt": "이 기능은 @:appName Cloud를 사용할 때만 사용할 수 있습니다", "relatedQuestion": "추천 질문", "serverUnavailable": "연결이 끊어졌습니다. 인터넷을 확인하고", "aiServerUnavailable": "AI 서비스가 일시적으로 사용할 수 없습니다. 나중에 다시 시도하세요.", "retry": "다시 시도", "clickToRetry": "다시 시도하려면 클릭", "regenerateAnswer": "다시 생성", "question1": "Kanban을 사용하여 작업 관리하는 방법", "question2": "GTD 방법 설명", "question3": "Rust를 사용하는 이유", "question4": "내 주방에 있는 재료로 요리법 만들기", "question5": "내 페이지에 대한 일러스트레이션 만들기", "question6": "다가오는 주의 할 일 목록 작성", "aiMistakePrompt": "AI는 실수를 할 수 있습니다. 중요한 정보를 확인하세요.", "chatWithFilePrompt": "파일과 채팅하시겠습니까?", "indexFileSuccess": "파일 색인화 성공", "inputActionNoPages": "페이지 결과 없음", "referenceSource": { "zero": "0개의 출처 발견", "one": "{count}개의 출처 발견", "other": "{count}개의 출처 발견" }, "clickToMention": "페이지 언급", "uploadFile": "PDF, 텍스트 또는 마크다운 파일 첨부", "questionDetail": "안녕하세요 {}! 오늘 어떻게 도와드릴까요?", "indexingFile": "{} 색인화 중", "generatingResponse": "응답 생성 중", "selectSources": "출처 선택", "currentPage": "현재 페이지", "sourcesLimitReached": "최대 3개의 최상위 문서와 그 하위 문서만 선택할 수 있습니다", "sourceUnsupported": "현재 데이터베이스와의 채팅을 지원하지 않습니다", "regenerate": "다시 시도", "addToPageButton": "페이지에 메시지 추가", "addToPageTitle": "메시지 추가...", "addToNewPage": "새 페이지 만들기", "addToNewPageName": "\"{}\"에서 추출한 메시지", "addToNewPageSuccessToast": "메시지가 추가되었습니다", "openPagePreviewFailedToast": "페이지를 열지 못했습니다", "changeFormat": { "actionButton": "형식 변경", "confirmButton": "이 형식으로 다시 생성", "textOnly": "텍스트", "imageOnly": "이미지 전용", "textAndImage": "텍스트와 이미지", "text": "단락", "bullet": "글머리 기호 목록", "number": "번호 매기기 목록", "table": "표", "blankDescription": "응답 형식", "defaultDescription": "자동 모드", "textWithImageDescription": "@:chat.changeFormat.text와 이미지", "numberWithImageDescription": "@:chat.changeFormat.number와 이미지", "bulletWithImageDescription": "@:chat.changeFormat.bullet와 이미지", "tableWithImageDescription": "@:chat.changeFormat.table와 이미지" }, "selectBanner": { "saveButton": "추가 ...", "selectMessages": "메시지 선택", "nSelected": "{}개 선택됨", "allSelected": "모두 선택됨" }, "stopTooltip": "생성 중지" }, "trash": { "text": "휴지통", "restoreAll": "모두 복원", "restore": "복원", "deleteAll": "모두 삭제", "pageHeader": { "fileName": "파일 이름", "lastModified": "마지막 수정", "created": "생성됨" }, "confirmDeleteAll": { "title": "휴지통의 모든 페이지", "caption": "휴지통의 모든 항목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." }, "confirmRestoreAll": { "title": "휴지통의 모든 페이지 복원", "caption": "이 작업은 되돌릴 수 없습니다." }, "restorePage": { "title": "복원: {}", "caption": "이 페이지를 복원하시겠습니까?" }, "mobile": { "actions": "휴지통 작업", "empty": "휴지통에 페이지나 공간이 없습니다", "emptyDescription": "필요 없는 항목을 휴지통으로 이동하세요.", "isDeleted": "삭제됨", "isRestored": "복원됨" }, "confirmDeleteTitle": "이 페이지를 영구적으로 삭제하시겠습니까?" }, "deletePagePrompt": { "text": "이 페이지는 휴지통에 있습니다", "restore": "페이지 복원", "deletePermanent": "영구적으로 삭제", "deletePermanentDescription": "이 페이지를 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." }, "dialogCreatePageNameHint": "페이지 이름", "questionBubble": { "shortcuts": "단축키", "whatsNew": "새로운 기능", "markdown": "Markdown", "debug": { "name": "디버그 정보", "success": "디버그 정보를 클립보드에 복사했습니다!", "fail": "디버그 정보를 클립보드에 복사할 수 없습니다" }, "feedback": "피드백", "help": "도움말 및 지원" }, "menuAppHeader": { "moreButtonToolTip": "제거, 이름 변경 등...", "addPageTooltip": "빠르게 페이지 추가", "defaultNewPageName": "제목 없음", "renameDialog": "이름 변경", "pageNameSuffix": "복사본" }, "noPagesInside": "내부에 페이지가 없습니다", "toolbar": { "undo": "실행 취소", "redo": "다시 실행", "bold": "굵게", "italic": "기울임꼴", "underline": "밑줄", "strike": "취소선", "numList": "번호 매기기 목록", "bulletList": "글머리 기호 목록", "checkList": "체크리스트", "inlineCode": "인라인 코드", "quote": "인용 블록", "header": "헤더", "highlight": "강조", "color": "색상", "addLink": "링크 추가" }, "tooltip": { "lightMode": "라이트 모드로 전환", "darkMode": "다크 모드로 전환", "openAsPage": "페이지로 열기", "addNewRow": "새 행 추가", "openMenu": "메뉴 열기", "dragRow": "행 순서 변경", "viewDataBase": "데이터베이스 보기", "referencePage": "이 {name}이 참조됨", "addBlockBelow": "아래에 블록 추가", "aiGenerate": "생성" }, "sideBar": { "closeSidebar": "사이드바 닫기", "openSidebar": "사이드바 열기", "expandSidebar": "전체 페이지로 확장", "personal": "개인", "private": "비공개", "workspace": "작업 공간", "favorites": "즐겨찾기", "clickToHidePrivate": "비공개 공간 숨기기\n여기에서 만든 페이지는 본인만 볼 수 있습니다", "clickToHideWorkspace": "작업 공간 숨기기\n여기에서 만든 페이지는 모든 멤버가 볼 수 있습니다", "clickToHidePersonal": "개인 공간 숨기기", "clickToHideFavorites": "즐겨찾기 공간 숨기기", "addAPage": "새 페이지 추가", "addAPageToPrivate": "비공개 공간에 페이지 추가", "addAPageToWorkspace": "작업 공간에 페이지 추가", "recent": "최근", "today": "오늘", "thisWeek": "이번 주", "others": "이전 즐겨찾기", "earlier": "이전", "justNow": "방금", "minutesAgo": "{count}분 전", "lastViewed": "마지막으로 본", "favoriteAt": "즐겨찾기한", "emptyRecent": "최근 페이지 없음", "emptyRecentDescription": "페이지를 보면 여기에서 쉽게 찾을 수 있습니다.", "emptyFavorite": "즐겨찾기 페이지 없음", "emptyFavoriteDescription": "페이지를 즐겨찾기에 추가하면 여기에서 빠르게 접근할 수 있습니다!", "removePageFromRecent": "최근 항목에서 이 페이지를 제거하시겠습니까?", "removeSuccess": "성공적으로 제거되었습니다", "favoriteSpace": "즐겨찾기", "RecentSpace": "최근", "Spaces": "공간", "upgradeToPro": "Pro로 업그레이드", "upgradeToAIMax": "무제한 AI 잠금 해제", "storageLimitDialogTitle": "무료 저장 공간이 부족합니다. 무제한 저장 공간을 잠금 해제하려면 업그레이드하세요", "storageLimitDialogTitleIOS": "무료 저장 공간이 부족합니다.", "aiResponseLimitTitle": "무료 AI 응답이 부족합니다. Pro 플랜으로 업그레이드하거나 AI 애드온을 구매하여 무제한 응답을 잠금 해제하세요", "aiResponseLimitDialogTitle": "AI 응답 한도에 도달했습니다", "aiResponseLimit": "무료 AI 응답이 부족합니다.\n\n설정 -> 플랜 -> AI Max 또는 Pro 플랜을 클릭하여 더 많은 AI 응답을 받으세요", "askOwnerToUpgradeToPro": "작업 공간의 무료 저장 공간이 부족합니다. 작업 공간 소유자에게 Pro 플랜으로 업그레이드하도록 요청하세요", "askOwnerToUpgradeToProIOS": "작업 공간의 무료 저장 공간이 부족합니다.", "askOwnerToUpgradeToAIMax": "작업 공간의 무료 AI 응답이 부족합니다. 작업 공간 소유자에게 플랜을 업그레이드하거나 AI 애드온을 구매하도록 요청하세요", "askOwnerToUpgradeToAIMaxIOS": "작업 공간의 무료 AI 응답이 부족합니다.", "purchaseAIMax": "작업 공간의 AI 이미지 응답이 부족합니다. 작업 공간 소유자에게 AI Max를 구매하도록 요청하세요", "aiImageResponseLimit": "AI 이미지 응답이 부족합니다.\n\n설정 -> 플랜 -> AI Max를 클릭하여 더 많은 AI 이미지 응답을 받으세요", "purchaseStorageSpace": "저장 공간 구매", "singleFileProPlanLimitationDescription": "무료 플랜에서 허용되는 최대 파일 업로드 크기를 초과했습니다. 더 큰 파일을 업로드하려면 Pro 플랜으로 업그레이드하세요", "purchaseAIResponse": "구매 ", "askOwnerToUpgradeToLocalAI": "작업 공간 소유자에게 AI On-device를 활성화하도록 요청하세요", "upgradeToAILocal": "최고의 프라이버시를 위해 로컬 모델을 장치에서 실행", "upgradeToAILocalDesc": "PDF와 채팅하고, 글쓰기를 개선하고, 로컬 AI를 사용하여 테이블을 자동으로 채우세요" }, "notifications": { "export": { "markdown": "노트를 Markdown으로 내보냈습니다", "path": "Documents/flowy" } }, "contactsPage": { "title": "연락처", "whatsHappening": "이번 주에 무슨 일이 있나요?", "addContact": "연락처 추가", "editContact": "연락처 수정" }, "button": { "ok": "확인", "confirm": "확인", "done": "완료", "cancel": "취소", "signIn": "로그인", "signOut": "로그아웃", "complete": "완료", "save": "저장", "generate": "생성", "esc": "ESC", "keep": "유지", "tryAgain": "다시 시도", "discard": "버리기", "replace": "교체", "insertBelow": "아래에 삽입", "insertAbove": "위에 삽입", "upload": "업로드", "edit": "편집", "delete": "삭제", "copy": "복사", "duplicate": "복제", "putback": "되돌리기", "update": "업데이트", "share": "공유", "removeFromFavorites": "즐겨찾기에서 제거", "removeFromRecent": "최근 항목에서 제거", "addToFavorites": "즐겨찾기에 추가", "favoriteSuccessfully": "즐겨찾기에 성공적으로 추가되었습니다", "unfavoriteSuccessfully": "즐겨찾기에서 성공적으로 제거되었습니다", "duplicateSuccessfully": "성공적으로 복제되었습니다", "rename": "이름 변경", "helpCenter": "도움말 센터", "add": "추가", "yes": "예", "no": "아니요", "clear": "지우기", "remove": "제거", "dontRemove": "제거하지 않음", "copyLink": "링크 복사", "align": "정렬", "login": "로그인", "logout": "로그아웃", "deleteAccount": "계정 삭제", "back": "뒤로", "signInGoogle": "Google로 계속", "signInGithub": "GitHub로 계속", "signInDiscord": "Discord로 계속", "more": "더 보기", "create": "생성", "close": "닫기", "next": "다음", "previous": "이전", "submit": "제출", "download": "다운로드", "backToHome": "홈으로 돌아가기", "viewing": "보기", "editing": "편집 중", "gotIt": "알겠습니다", "retry": "다시 시도", "uploadFailed": "업로드 실패.", "copyLinkOriginal": "원본 링크 복사" }, "label": { "welcome": "환영합니다!", "firstName": "이름", "middleName": "중간 이름", "lastName": "성", "stepX": "단계 {X}" }, "oAuth": { "err": { "failedTitle": "계정에 연결할 수 없습니다.", "failedMsg": "브라우저에서 로그인 프로세스를 완료했는지 확인하세요." }, "google": { "title": "GOOGLE 로그인", "instruction1": "Google 연락처를 가져오려면 웹 브라우저를 사용하여 이 애플리케이션을 인증해야 합니다.", "instruction2": "아이콘을 클릭하거나 텍스트를 선택하여 이 코드를 클립보드에 복사하세요:", "instruction3": "웹 브라우저에서 다음 링크로 이동하고 위의 코드를 입력하세요:", "instruction4": "가입을 완료했으면 아래 버튼을 누르세요:" } }, "settings": { "title": "설정", "popupMenuItem": { "settings": "설정", "members": "멤버", "trash": "휴지통", "helpAndSupport": "도움말 및 지원" }, "sites": { "title": "사이트", "namespaceTitle": "네임스페이스", "namespaceDescription": "네임스페이스 및 홈페이지 관리", "namespaceHeader": "네임스페이스", "homepageHeader": "홈페이지", "updateNamespace": "네임스페이스 업데이트", "removeHomepage": "홈페이지 제거", "selectHomePage": "페이지 선택", "clearHomePage": "이 네임스페이스의 홈페이지를 지웁니다", "customUrl": "맞춤 URL", "namespace": { "description": "이 변경 사항은 이 네임스페이스에 라이브로 게시된 모든 페이지에 적용됩니다", "tooltip": "부적절한 네임스페이스를 제거할 권리를 보유합니다", "updateExistingNamespace": "기존 네임스페이스 업데이트", "upgradeToPro": "Pro 플랜으로 업그레이드하여 홈페이지 설정", "redirectToPayment": "결제 페이지로 리디렉션 중...", "onlyWorkspaceOwnerCanSetHomePage": "작업 공간 소유자만 홈페이지를 설정할 수 있습니다", "pleaseAskOwnerToSetHomePage": "작업 공간 소유자에게 Pro 플랜으로 업그레이드하도록 요청하세요" }, "publishedPage": { "title": "모든 게시된 페이지", "description": "게시된 페이지 관리", "page": "페이지", "pathName": "경로 이름", "date": "게시 날짜", "emptyHinText": "이 작업 공간에 게시된 페이지가 없습니다", "noPublishedPages": "게시된 페이지 없음", "settings": "게시 설정", "clickToOpenPageInApp": "앱에서 페이지 열기", "clickToOpenPageInBrowser": "브라우저에서 페이지 열기" }, "error": { "failedToGeneratePaymentLink": "Pro 플랜 결제 링크 생성 실패", "failedToUpdateNamespace": "네임스페이스 업데이트 실패", "proPlanLimitation": "네임스페이스를 업데이트하려면 Pro 플랜으로 업그레이드해야 합니다", "namespaceAlreadyInUse": "네임스페이스가 이미 사용 중입니다. 다른 네임스페이스를 시도하세요", "invalidNamespace": "잘못된 네임스페이스입니다. 다른 네임스페이스를 시도하세요", "namespaceLengthAtLeast2Characters": "네임스페이스는 최소 2자 이상이어야 합니다", "onlyWorkspaceOwnerCanUpdateNamespace": "작업 공간 소유자만 네임스페이스를 업데이트할 수 있습니다", "onlyWorkspaceOwnerCanRemoveHomepage": "작업 공간 소유자만 홈페이지를 제거할 수 있습니다", "setHomepageFailed": "홈페이지 설정 실패", "namespaceTooLong": "네임스페이스가 너무 깁니다. 다른 네임스페이스를 시도하세요", "namespaceTooShort": "네임스페이스가 너무 짧습니다. 다른 네임스페이스를 시도하세요", "namespaceIsReserved": "네임스페이스가 예약되어 있습니다. 다른 네임스페이스를 시도하세요", "updatePathNameFailed": "경로 이름 업데이트 실패", "removeHomePageFailed": "홈페이지 제거 실패", "publishNameContainsInvalidCharacters": "경로 이름에 잘못된 문자가 포함되어 있습니다. 다른 이름을 시도하세요", "publishNameTooShort": "경로 이름이 너무 짧습니다. 다른 이름을 시도하세요", "publishNameTooLong": "경로 이름이 너무 깁니다. 다른 이름을 시도하세요", "publishNameAlreadyInUse": "경로 이름이 이미 사용 중입니다. 다른 이름을 시도하세요", "namespaceContainsInvalidCharacters": "네임스페이스에 잘못된 문자가 포함되어 있습니다. 다른 네임스페이스를 시도하세요", "publishPermissionDenied": "작업 공간 소유자 또는 페이지 게시자만 게시 설정을 관리할 수 있습니다", "publishNameCannotBeEmpty": "경로 이름은 비워둘 수 없습니다. 다른 이름을 시도하세요" }, "success": { "namespaceUpdated": "네임스페이스가 성공적으로 업데이트되었습니다", "setHomepageSuccess": "홈페이지가 성공적으로 설정되었습니다", "updatePathNameSuccess": "경로 이름이 성공적으로 업데이트되었습니다", "removeHomePageSuccess": "홈페이지가 성공적으로 제거되었습니다" } }, "accountPage": { "menuLabel": "계정 및 앱", "title": "내 계정", "general": { "title": "계정 이름 및 프로필 이미지", "changeProfilePicture": "프로필 사진 변경" }, "email": { "title": "이메일", "actions": { "change": "이메일 변경" } }, "login": { "title": "계정 로그인", "loginLabel": "로그인", "logoutLabel": "로그아웃" }, "isUpToDate": "@:appName이 최신 상태입니다!", "officialVersion": "버전 {version} (공식 빌드)" }, "workspacePage": { "menuLabel": "작업 공간", "title": "작업 공간", "description": "작업 공간의 외관, 테마, 글꼴, 텍스트 레이아웃, 날짜/시간 형식 및 언어를 사용자 정의합니다.", "workspaceName": { "title": "작업 공간 이름" }, "workspaceIcon": { "title": "작업 공간 아이콘", "description": "작업 공간에 대한 이미지를 업로드하거나 이모지를 사용하세요. 아이콘은 사이드바와 알림에 표시됩니다." }, "appearance": { "title": "외관", "description": "작업 공간의 외관, 테마, 글꼴, 텍스트 레이아웃, 날짜, 시간 및 언어를 사용자 정의합니다.", "options": { "system": "자동", "light": "라이트", "dark": "다크" } }, "resetCursorColor": { "title": "문서 커서 색상 재설정", "description": "커서 색상을 재설정하시겠습니까?" }, "resetSelectionColor": { "title": "문서 선택 색상 재설정", "description": "선택 색상을 재설정하시겠습니까?" }, "resetWidth": { "resetSuccess": "문서 너비가 성공적으로 재설정되었습니다" }, "theme": { "title": "테마", "description": "미리 설정된 테마를 선택하거나 사용자 정의 테마를 업로드하세요.", "uploadCustomThemeTooltip": "사용자 정의 테마 업로드" }, "workspaceFont": { "title": "작업 공간 글꼴", "noFontHint": "글꼴을 찾을 수 없습니다. 다른 용어를 시도하세요." }, "textDirection": { "title": "텍스트 방향", "leftToRight": "왼쪽에서 오른쪽으로", "rightToLeft": "오른쪽에서 왼쪽으로", "auto": "자동", "enableRTLItems": "RTL 도구 모음 항목 활성화" }, "layoutDirection": { "title": "레이아웃 방향", "leftToRight": "왼쪽에서 오른쪽으로", "rightToLeft": "오른쪽에서 왼쪽으로" }, "dateTime": { "title": "날짜 및 시간", "example": "{}에 {} ({})", "24HourTime": "24시간 형식", "dateFormat": { "label": "날짜 형식", "local": "로컬", "us": "미국", "iso": "ISO", "friendly": "친숙한", "dmy": "일/월/년" } }, "language": { "title": "언어" }, "deleteWorkspacePrompt": { "title": "작업 공간 삭제", "content": "이 작업 공간을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며, 게시한 모든 페이지가 게시 취소됩니다." }, "leaveWorkspacePrompt": { "title": "작업 공간 나가기", "content": "이 작업 공간을 나가시겠습니까? 모든 페이지와 데이터에 대한 액세스를 잃게 됩니다.", "success": "작업 공간을 성공적으로 나갔습니다.", "fail": "작업 공간 나가기 실패." }, "manageWorkspace": { "title": "작업 공간 관리", "leaveWorkspace": "작업 공간 나가기", "deleteWorkspace": "작업 공간 삭제" } }, "manageDataPage": { "menuLabel": "데이터 관리", "title": "데이터 관리", "description": "로컬 저장소 데이터를 관리하거나 기존 데이터를 @:appName에 가져옵니다.", "dataStorage": { "title": "파일 저장 위치", "tooltip": "파일이 저장되는 위치", "actions": { "change": "경로 변경", "open": "폴더 열기", "openTooltip": "현재 데이터 폴더 위치 열기", "copy": "경로 복사", "copiedHint": "경로가 복사되었습니다!", "resetTooltip": "기본 위치로 재설정" }, "resetDialog": { "title": "확실합니까?", "description": "경로를 기본 데이터 위치로 재설정해도 데이터가 삭제되지 않습니다. 현재 데이터를 다시 가져오려면 현재 위치의 경로를 먼저 복사해야 합니다." } }, "importData": { "title": "데이터 가져오기", "tooltip": "@:appName 백업/데이터 폴더에서 데이터 가져오기", "description": "외부 @:appName 데이터 폴더에서 데이터 복사", "action": "파일 찾아보기" }, "encryption": { "title": "암호화", "tooltip": "데이터 저장 및 암호화 방법 관리", "descriptionNoEncryption": "암호화를 켜면 모든 데이터가 암호화됩니다. 이 작업은 되돌릴 수 없습니다.", "descriptionEncrypted": "데이터가 암호화되었습니다.", "action": "데이터 암호화", "dialog": { "title": "모든 데이터를 암호화하시겠습니까?", "description": "모든 데이터를 암호화하면 데이터가 안전하고 보호됩니다. 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?" } }, "cache": { "title": "캐시 지우기", "description": "이미지가 로드되지 않거나 공간에 페이지가 누락되거나 글꼴이 로드되지 않는 등의 문제를 해결하는 데 도움이 됩니다. 데이터에는 영향을 미치지 않습니다.", "dialog": { "title": "캐시 지우기", "description": "이미지가 로드되지 않거나 공간에 페이지가 누락되거나 글꼴이 로드되지 않는 등의 문제를 해결하는 데 도움이 됩니다. 데이터에는 영향을 미치지 않습니다.", "successHint": "캐시가 지워졌습니다!" } }, "data": { "fixYourData": "데이터 수정", "fixButton": "수정", "fixYourDataDescription": "데이터에 문제가 있는 경우 여기에서 수정할 수 있습니다." } }, "shortcutsPage": { "menuLabel": "단축키", "title": "단축키", "editBindingHint": "새 바인딩 입력", "searchHint": "검색", "actions": { "resetDefault": "기본값으로 재설정" }, "errorPage": { "message": "단축키를 로드하지 못했습니다: {}", "howToFix": "다시 시도해 보세요. 문제가 계속되면 GitHub에 문의하세요." }, "resetDialog": { "title": "단축키 재설정", "description": "모든 키 바인딩을 기본값으로 재설정합니다. 나중에 되돌릴 수 없습니다. 계속하시겠습니까?", "buttonLabel": "재설정" }, "conflictDialog": { "title": "{}가 이미 사용 중입니다", "descriptionPrefix": "이 키 바인딩은 현재 ", "descriptionSuffix": "에서 사용 중입니다. 이 키 바인딩을 교체하면 {}에서 제거됩니다.", "confirmLabel": "계속" }, "editTooltip": "키 바인딩 편집을 시작하려면 누르세요", "keybindings": { "toggleToDoList": "할 일 목록 전환", "insertNewParagraphInCodeblock": "새 단락 삽입", "pasteInCodeblock": "코드 블록에 붙여넣기", "selectAllCodeblock": "모두 선택", "indentLineCodeblock": "줄 시작에 두 칸 삽입", "outdentLineCodeblock": "줄 시작에서 두 칸 삭제", "twoSpacesCursorCodeblock": "커서 위치에 두 칸 삽입", "copy": "선택 항목 복사", "paste": "내용 붙여넣기", "cut": "선택 항목 잘라내기", "alignLeft": "텍스트 왼쪽 정렬", "alignCenter": "텍스트 가운데 정렬", "alignRight": "텍스트 오른쪽 정렬", "insertInlineMathEquation": "인라인 수학 방정식 삽입", "undo": "실행 취소", "redo": "다시 실행", "convertToParagraph": "블록을 단락으로 변환", "backspace": "삭제", "deleteLeftWord": "왼쪽 단어 삭제", "deleteLeftSentence": "왼쪽 문장 삭제", "delete": "오른쪽 문자 삭제", "deleteMacOS": "왼쪽 문자 삭제", "deleteRightWord": "오른쪽 단어 삭제", "moveCursorLeft": "커서를 왼쪽으로 이동", "moveCursorBeginning": "커서를 시작으로 이동", "moveCursorLeftWord": "커서를 왼쪽 단어로 이동", "moveCursorLeftSelect": "선택하고 커서를 왼쪽으로 이동", "moveCursorBeginSelect": "선택하고 커서를 시작으로 이동", "moveCursorLeftWordSelect": "선택하고 커서를 왼쪽 단어로 이동", "moveCursorRight": "커서를 오른쪽으로 이동", "moveCursorEnd": "커서를 끝으로 이동", "moveCursorRightWord": "커서를 오른쪽 단어로 이동", "moveCursorRightSelect": "선택하고 커서를 오른쪽으로 이동", "moveCursorEndSelect": "선택하고 커서를 끝으로 이동", "moveCursorRightWordSelect": "선택하고 커서를 오른쪽 단어로 이동", "moveCursorUp": "커서를 위로 이동", "moveCursorTopSelect": "선택하고 커서를 위로 이동", "moveCursorTop": "커서를 위로 이동", "moveCursorUpSelect": "선택하고 커서를 위로 이동", "moveCursorBottomSelect": "선택하고 커서를 아래로 이동", "moveCursorBottom": "커서를 아래로 이동", "moveCursorDown": "커서를 아래로 이동", "moveCursorDownSelect": "선택하고 커서를 아래로 이동", "home": "맨 위로 스크롤", "end": "맨 아래로 스크롤", "toggleBold": "굵게 전환", "toggleItalic": "기울임꼴 전환", "toggleUnderline": "밑줄 전환", "toggleStrikethrough": "취소선 전환", "toggleCode": "인라인 코드 전환", "toggleHighlight": "강조 전환", "showLinkMenu": "링크 메뉴 표시", "openInlineLink": "인라인 링크 열기", "openLinks": "선택한 모든 링크 열기", "indent": "들여쓰기", "outdent": "내어쓰기", "exit": "편집 종료", "pageUp": "한 페이지 위로 스크롤", "pageDown": "한 페이지 아래로 스크롤", "selectAll": "모두 선택", "pasteWithoutFormatting": "서식 없이 붙여넣기", "showEmojiPicker": "이모지 선택기 표시", "enterInTableCell": "테이블에 줄 바꿈 추가", "leftInTableCell": "테이블에서 왼쪽 셀로 이동", "rightInTableCell": "테이블에서 오른쪽 셀로 이동", "upInTableCell": "테이블에서 위쪽 셀로 이동", "downInTableCell": "테이블에서 아래쪽 셀로 이동", "tabInTableCell": "테이블에서 다음 사용 가능한 셀로 이동", "shiftTabInTableCell": "테이블에서 이전 사용 가능한 셀로 이동", "backSpaceInTableCell": "셀의 시작 부분에서 멈춤" }, "commands": { "codeBlockNewParagraph": "코드 블록 옆에 새 단락 삽입", "codeBlockIndentLines": "코드 블록에서 줄 시작에 두 칸 삽입", "codeBlockOutdentLines": "코드 블록에서 줄 시작에서 두 칸 삭제", "codeBlockAddTwoSpaces": "코드 블록에서 커서 위치에 두 칸 삽입", "codeBlockSelectAll": "코드 블록 내의 모든 내용 선택", "codeBlockPasteText": "코드 블록에 텍스트 붙여넣기", "textAlignLeft": "텍스트를 왼쪽으로 정렬", "textAlignCenter": "텍스트를 가운데로 정렬", "textAlignRight": "텍스트를 오른쪽으로 정렬" }, "couldNotLoadErrorMsg": "단축키를 로드할 수 없습니다. 다시 시도하세요", "couldNotSaveErrorMsg": "단축키를 저장할 수 없습니다. 다시 시도하세요" }, "aiPage": { "title": "AI 설정", "menuLabel": "AI 설정", "keys": { "enableAISearchTitle": "AI 검색", "aiSettingsDescription": "선호하는 모델을 선택하여 AppFlowy AI를 구동하세요. 이제 GPT 4-o, Claude 3,5, Llama 3.1 및 Mistral 7B를 포함합니다", "loginToEnableAIFeature": "AI 기능은 @:appName Cloud에 로그인한 후에만 활성화됩니다. @:appName 계정이 없는 경우 '내 계정'에서 가입하세요", "llmModel": "언어 모델", "llmModelType": "언어 모델 유형", "downloadLLMPrompt": "{} 다운로드", "downloadAppFlowyOfflineAI": "AI 오프라인 패키지를 다운로드하면 AI가 장치에서 실행됩니다. 계속하시겠습니까?", "downloadLLMPromptDetail": "{} 로컬 모델을 다운로드하면 최대 {}의 저장 공간이 필요합니다. 계속하시겠습니까?", "downloadBigFilePrompt": "다운로드 완료까지 약 10분이 소요될 수 있습니다", "downloadAIModelButton": "다운로드", "downloadingModel": "다운로드 중", "localAILoaded": "로컬 AI 모델이 성공적으로 추가되어 사용할 준비가 되었습니다", "localAIStart": "로컬 AI가 시작 중입니다. 느리다면 껐다가 다시 켜보세요", "localAILoading": "로컬 AI 채팅 모델이 로드 중입니다...", "localAIStopped": "로컬 AI가 중지되었습니다", "localAIRunning": "로컬 AI가 실행 중입니다", "localAIInitializing": "로컬 AI가 로드 중이며 장치에 따라 몇 분이 소요될 수 있습니다", "localAINotReadyTextFieldPrompt": "로컬 AI가 로드되는 동안 편집할 수 없습니다", "failToLoadLocalAI": "로컬 AI를 시작하지 못했습니다", "restartLocalAI": "로컬 AI 다시 시작", "disableLocalAITitle": "로컬 AI 비활성화", "disableLocalAIDescription": "로컬 AI를 비활성화하시겠습니까?", "localAIToggleTitle": "로컬 AI를 활성화 또는 비활성화하려면 전환", "offlineAIInstruction1": "다음을 따르세요", "offlineAIInstruction2": "지침", "offlineAIInstruction3": "오프라인 AI를 활성화하려면", "offlineAIDownload1": "AppFlowy AI를 다운로드하지 않은 경우 먼저", "offlineAIDownload2": "다운로드", "offlineAIDownload3": "하세요", "activeOfflineAI": "활성화됨", "downloadOfflineAI": "다운로드", "openModelDirectory": "폴더 열기", "pleaseFollowThese": "지침", "instructions": "이 지침을 따르세요", "installOllamaLai": "Ollama 및 AppFlowy 로컬 AI를 설정합니다. 이미 설정한 경우 건너뛰세요", "startLocalAI": "로컬 AI를 시작하는 데 몇 초가 소요될 수 있습니다" } }, "planPage": { "menuLabel": "플랜", "title": "가격 플랜", "planUsage": { "title": "플랜 사용 요약", "storageLabel": "저장 공간", "storageUsage": "{} / {} GB", "unlimitedStorageLabel": "무제한 저장 공간", "collaboratorsLabel": "멤버", "collaboratorsUsage": "{} / {}", "aiResponseLabel": "AI 응답", "aiResponseUsage": "{} / {}", "unlimitedAILabel": "무제한 응답", "proBadge": "Pro", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "Mac용 AI On-device", "memberProToggle": "더 많은 멤버 및 무제한 AI", "aiMaxToggle": "무제한 AI 및 고급 모델 액세스", "aiOnDeviceToggle": "최고의 프라이버시를 위한 로컬 AI", "aiCredit": { "title": "@:appName AI 크레딧 추가", "price": "{}", "priceDescription": "1,000 크레딧당", "purchase": "AI 구매", "info": "작업 공간당 1,000개의 AI 크레딧을 추가하여 더 스마트하고 빠른 결과를 위해 AI를 워크플로에 원활하게 통합하세요:", "infoItemOne": "데이터베이스당 최대 10,000개의 응답", "infoItemTwo": "작업 공간당 최대 1,000개의 응답" }, "currentPlan": { "bannerLabel": "현재 플랜", "freeTitle": "무료", "proTitle": "Pro", "teamTitle": "팀", "freeInfo": "모든 것을 정리하기 위한 최대 2명의 개인용", "proInfo": "최대 10명의 소규모 팀을 위한 완벽한 솔루션.", "teamInfo": "모든 생산적이고 잘 조직된 팀을 위한 완벽한 솔루션.", "upgrade": "플랜 변경", "canceledInfo": "플랜이 취소되었습니다. {}에 무료 플랜으로 다운그레이드됩니다." }, "addons": { "title": "애드온", "addLabel": "추가", "activeLabel": "추가됨", "aiMax": { "title": "AI Max", "description": "무제한 AI 응답 및 고급 AI 모델로 구동되는 50개의 AI 이미지를 매월 제공합니다", "price": "{}", "priceInfo": "연간 청구되는 사용자당 월별" }, "aiOnDevice": { "title": "Mac용 AI On-device", "description": "장치에서 Mistral 7B, LLAMA 3 및 기타 로컬 모델 실행", "price": "{}", "priceInfo": "연간 청구되는 사용자당 월별", "recommend": "M1 이상 권장" } }, "deal": { "bannerLabel": "새해 할인!", "title": "팀을 성장시키세요!", "info": "Pro 및 팀 플랜을 업그레이드하고 10% 할인 혜택을 받으세요! @:appName AI를 포함한 강력한 새로운 기능으로 작업 공간 생산성을 높이세요.", "viewPlans": "플랜 보기" } } }, "billingPage": { "menuLabel": "청구", "title": "청구", "plan": { "title": "플랜", "freeLabel": "무료", "proLabel": "Pro", "planButtonLabel": "플랜 변경", "billingPeriod": "청구 기간", "periodButtonLabel": "기간 수정" }, "paymentDetails": { "title": "결제 세부 정보", "methodLabel": "결제 방법", "methodButtonLabel": "방법 수정" }, "addons": { "title": "애드온", "addLabel": "추가", "removeLabel": "제거", "renewLabel": "갱신", "aiMax": { "label": "AI Max", "description": "무제한 AI 및 고급 모델 잠금 해제", "activeDescription": "다음 청구서가 {}에 만료됩니다", "canceledDescription": "AI Max는 {}까지 사용할 수 있습니다" }, "aiOnDevice": { "label": "Mac용 AI On-device", "description": "장치에서 무제한 AI 잠금 해제", "activeDescription": "다음 청구서가 {}에 만료됩니다", "canceledDescription": "Mac용 AI On-device는 {}까지 사용할 수 있습니다" }, "removeDialog": { "title": "{} 제거", "description": "{plan}을 제거하시겠습니까? {plan}의 기능과 혜택에 대한 액세스를 즉시 잃게 됩니다." } }, "currentPeriodBadge": "현재", "changePeriod": "기간 변경", "planPeriod": "{} 기간", "monthlyInterval": "월별", "monthlyPriceInfo": "월별 청구되는 좌석당", "annualInterval": "연간", "annualPriceInfo": "연간 청구되는 좌석당" }, "comparePlanDialog": { "title": "플랜 비교 및 선택", "planFeatures": "플랜\n기능", "current": "현재", "actions": { "upgrade": "업그레이드", "downgrade": "다운그레이드", "current": "현재" }, "freePlan": { "title": "무료", "description": "모든 것을 정리하기 위한 최대 2명의 개인용", "price": "{}", "priceInfo": "영원히 무료" }, "proPlan": { "title": "Pro", "description": "프로젝트 및 팀 지식을 관리하기 위한 소규모 팀용", "price": "{}", "priceInfo": "연간 청구되는 사용자당 월별\n\n{} 월별 청구" }, "planLabels": { "itemOne": "작업 공간", "itemTwo": "멤버", "itemThree": "저장 공간", "itemFour": "실시간 협업", "itemFive": "모바일 앱", "itemSix": "AI 응답", "itemSeven": "AI 이미지", "itemFileUpload": "파일 업로드", "customNamespace": "맞춤 네임스페이스", "tooltipSix": "평생 동안 응답 수는 재설정되지 않습니다", "intelligentSearch": "지능형 검색", "tooltipSeven": "작업 공간의 URL 일부를 사용자 정의할 수 있습니다", "customNamespaceTooltip": "맞춤 게시 사이트 URL" }, "freeLabels": { "itemOne": "작업 공간당 청구", "itemTwo": "최대 2명", "itemThree": "5 GB", "itemFour": "예", "itemFive": "예", "itemSix": "평생 10회", "itemSeven": "평생 2회", "itemFileUpload": "최대 7 MB", "intelligentSearch": "지능형 검색" }, "proLabels": { "itemOne": "작업 공간당 청구", "itemTwo": "최대 10명", "itemThree": "무제한", "itemFour": "예", "itemFive": "예", "itemSix": "무제한", "itemSeven": "월별 10개 이미지", "itemFileUpload": "무제한", "intelligentSearch": "지능형 검색" }, "paymentSuccess": { "title": "이제 {} 플랜을 사용 중입니다!", "description": "결제가 성공적으로 처리되었으며 플랜이 @:appName {}로 업그레이드되었습니다. 플랜 세부 정보를 플랜 페이지에서 확인할 수 있습니다" }, "downgradeDialog": { "title": "플랜을 다운그레이드하시겠습니까?", "description": "플랜을 다운그레이드하면 무료 플랜으로 돌아갑니다. 멤버는 이 작업 공간에 대한 액세스를 잃을 수 있으며 저장 공간 제한을 충족하기 위해 공간을 확보해야 할 수 있습니다.", "downgradeLabel": "플랜 다운그레이드" } }, "cancelSurveyDialog": { "title": "떠나셔서 아쉽습니다", "description": "@:appName을 개선하는 데 도움이 되도록 피드백을 듣고 싶습니다. 몇 가지 질문에 답변해 주세요.", "commonOther": "기타", "otherHint": "여기에 답변을 작성하세요", "questionOne": { "question": "@:appName Pro 구독을 취소한 이유는 무엇입니까?", "answerOne": "비용이 너무 높음", "answerTwo": "기능이 기대에 미치지 못함", "answerThree": "더 나은 대안을 찾음", "answerFour": "비용을 정당화할 만큼 충분히 사용하지 않음", "answerFive": "서비스 문제 또는 기술적 어려움" }, "questionTwo": { "question": "미래에 @:appName Pro를 다시 구독할 가능성은 얼마나 됩니까?", "answerOne": "매우 가능성이 높음", "answerTwo": "어느 정도 가능성이 있음", "answerThree": "잘 모르겠음", "answerFour": "가능성이 낮음", "answerFive": "매우 가능성이 낮음" }, "questionThree": { "question": "구독 기간 동안 가장 가치 있게 여긴 Pro 기능은 무엇입니까?", "answerOne": "다중 사용자 협업", "answerTwo": "더 긴 시간 버전 기록", "answerThree": "무제한 AI 응답", "answerFour": "로컬 AI 모델 액세스" }, "questionFour": { "question": "@:appName에 대한 전반적인 경험을 어떻게 설명하시겠습니까?", "answerOne": "훌륭함", "answerTwo": "좋음", "answerThree": "보통", "answerFour": "평균 이하", "answerFive": "불만족" } }, "common": { "uploadingFile": "파일 업로드 중입니다. 앱을 종료하지 마세요", "uploadNotionSuccess": "Notion zip 파일이 성공적으로 업로드되었습니다. 가져오기가 완료되면 확인 이메일을 받게 됩니다", "reset": "재설정" }, "menu": { "appearance": "외관", "language": "언어", "user": "사용자", "files": "파일", "notifications": "알림", "open": "설정 열기", "logout": "로그아웃", "logoutPrompt": "로그아웃하시겠습니까?", "selfEncryptionLogoutPrompt": "로그아웃하시겠습니까? 암호화 비밀을 복사했는지 확인하세요", "syncSetting": "동기화 설정", "cloudSettings": "클라우드 설정", "enableSync": "동기화 활성화", "enableSyncLog": "동기화 로그 활성화", "enableSyncLogWarning": "동기화 문제를 진단하는 데 도움을 주셔서 감사합니다. 이 작업은 문서 편집 내용을 로컬 파일에 기록합니다. 활성화 후 앱을 종료하고 다시 열어야 합니다", "enableEncrypt": "데이터 암호화", "cloudURL": "기본 URL", "webURL": "웹 URL", "invalidCloudURLScheme": "잘못된 스키마", "cloudServerType": "클라우드 서버", "cloudServerTypeTip": "클라우드 서버를 변경한 후 현재 계정에서 로그아웃될 수 있습니다", "cloudLocal": "로컬", "cloudAppFlowy": "@:appName Cloud", "cloudAppFlowySelfHost": "@:appName Cloud 셀프 호스팅", "appFlowyCloudUrlCanNotBeEmpty": "클라우드 URL은 비워둘 수 없습니다", "clickToCopy": "클립보드에 복사", "selfHostStart": "서버가 없는 경우", "selfHostContent": "문서", "selfHostEnd": "를 참조하여 셀프 호스팅 서버를 설정하는 방법을 확인하세요", "pleaseInputValidURL": "유효한 URL을 입력하세요", "changeUrl": "셀프 호스팅 URL을 {}로 변경", "cloudURLHint": "서버의 기본 URL을 입력하세요", "webURLHint": "웹 서버의 기본 URL을 입력하세요", "cloudWSURL": "웹소켓 URL", "cloudWSURLHint": "서버의 웹소켓 주소를 입력하세요", "restartApp": "재시작", "restartAppTip": "변경 사항을 적용하려면 애플리케이션을 재시작하세요. 현재 계정에서 로그아웃될 수 있습니다.", "changeServerTip": "서버를 변경한 후 변경 사항을 적용하려면 재시작 버튼을 클릭해야 합니다", "enableEncryptPrompt": "이 비밀로 데이터를 보호하려면 암호화를 활성화하세요. 안전하게 보관하세요. 활성화 후에는 비활성화할 수 없습니다. 비밀을 잃어버리면 데이터를 복구할 수 없습니다. 복사하려면 클릭하세요", "inputEncryptPrompt": "암호화 비밀을 입력하세요", "clickToCopySecret": "비밀을 복사하려면 클릭", "configServerSetting": "서버 설정 구성", "configServerGuide": "`빠른 시작`을 선택한 후 `설정`으로 이동하여 \"클라우드 설정\"을 구성하세요.", "inputTextFieldHint": "비밀", "historicalUserList": "사용자 로그인 기록", "historicalUserListTooltip": "이 목록에는 익명 계정이 표시됩니다. 계정을 클릭하여 세부 정보를 확인할 수 있습니다. 익명 계정은 '시작하기' 버튼을 클릭하여 생성됩니다", "openHistoricalUser": "익명 계정을 열려면 클릭", "customPathPrompt": "Google Drive와 같은 클라우드 동기화 폴더에 @:appName 데이터 폴더를 저장하면 위험이 발생할 수 있습니다. 이 폴더 내의 데이터베이스에 여러 위치에서 동시에 액세스하거나 수정하면 동기화 충돌 및 데이터 손상이 발생할 수 있습니다", "importAppFlowyData": "외부 @:appName 폴더에서 데이터 가져오기", "importingAppFlowyDataTip": "데이터 가져오는 중입니다. 앱을 종료하지 마세요", "importAppFlowyDataDescription": "외부 @:appName 데이터 폴더에서 데이터를 복사하여 현재 AppFlowy 데이터 폴더에 가져옵니다", "importSuccess": "성공적으로 @:appName 데이터 폴더를 가져왔습니다", "importFailed": "@:appName 데이터 폴더 가져오기 실패", "importGuide": "자세한 내용은 참조 문서를 확인하세요" }, "notifications": { "enableNotifications": { "label": "알림 활성화", "hint": "로컬 알림이 나타나지 않도록 하려면 끄세요." }, "showNotificationsIcon": { "label": "알림 아이콘 표시", "hint": "사이드바에서 알림 아이콘을 숨기려면 끄세요." }, "archiveNotifications": { "allSuccess": "모든 알림이 성공적으로 보관되었습니다", "success": "알림이 성공적으로 보관되었습니다" }, "markAsReadNotifications": { "allSuccess": "모두 읽음으로 표시되었습니다", "success": "읽음으로 표시되었습니다" }, "action": { "markAsRead": "읽음으로 표시", "multipleChoice": "더 선택", "archive": "보관" }, "settings": { "settings": "설정", "markAllAsRead": "모두 읽음으로 표시", "archiveAll": "모두 보관" }, "emptyInbox": { "title": "받은 편지함 비어 있음!", "description": "알림을 받으려면 알림을 설정하세요." }, "emptyUnread": { "title": "읽지 않은 알림 없음", "description": "모든 알림을 확인했습니다!" }, "emptyArchived": { "title": "보관된 항목 없음", "description": "보관된 알림이 여기에 표시됩니다." }, "tabs": { "inbox": "받은 편지함", "unread": "읽지 않음", "archived": "보관됨" }, "refreshSuccess": "알림이 성공적으로 새로고침되었습니다", "titles": { "notifications": "알림", "reminder": "알림" } }, "appearance": { "resetSetting": "재설정", "fontFamily": { "label": "글꼴", "search": "검색", "defaultFont": "시스템" }, "themeMode": { "label": "테마 모드", "light": "라이트 모드", "dark": "다크 모드", "system": "시스템에 맞춤" }, "fontScaleFactor": "글꼴 크기 비율", "displaySize": "디스플레이 크기", "documentSettings": { "cursorColor": "문서 커서 색상", "selectionColor": "문서 선택 색상", "width": "문서 너비", "changeWidth": "변경", "pickColor": "색상 선택", "colorShade": "색상 음영", "opacity": "불투명도", "hexEmptyError": "16진수 색상은 비워둘 수 없습니다", "hexLengthError": "16진수 값은 6자리여야 합니다", "hexInvalidError": "잘못된 16진수 값", "opacityEmptyError": "불투명도는 비워둘 수 없습니다", "opacityRangeError": "불투명도는 1에서 100 사이여야 합니다", "app": "앱", "flowy": "Flowy", "apply": "적용" }, "layoutDirection": { "label": "레이아웃 방향", "hint": "화면의 콘텐츠 흐름을 왼쪽에서 오른쪽 또는 오른쪽에서 왼쪽으로 제어합니다.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "기본 텍스트 방향", "hint": "텍스트가 기본적으로 왼쪽에서 시작할지 오른쪽에서 시작할지 지정합니다.", "ltr": "LTR", "rtl": "RTL", "auto": "자동", "fallback": "레이아웃 방향과 동일" }, "themeUpload": { "button": "업로드", "uploadTheme": "테마 업로드", "description": "아래 버튼을 사용하여 사용자 정의 @:appName 테마를 업로드하세요.", "loading": "테마를 검증하고 업로드하는 동안 기다려주세요...", "uploadSuccess": "테마가 성공적으로 업로드되었습니다", "deletionFailure": "테마를 삭제하지 못했습니다. 수동으로 삭제해 보세요.", "filePickerDialogTitle": ".flowy_plugin 파일 선택", "urlUploadFailure": "URL을 열지 못했습니다: {}" }, "theme": "테마", "builtInsLabel": "내장 테마", "pluginsLabel": "플러그인", "dateFormat": { "label": "날짜 형식", "local": "로컬", "us": "미국", "iso": "ISO", "friendly": "친숙한", "dmy": "일/월/년" }, "timeFormat": { "label": "시간 형식", "twelveHour": "12시간 형식", "twentyFourHour": "24시간 형식" }, "showNamingDialogWhenCreatingPage": "페이지 생성 시 이름 지정 대화 상자 표시", "enableRTLToolbarItems": "RTL 도구 모음 항목 활성화", "members": { "title": "멤버 설정", "inviteMembers": "멤버 초대", "inviteHint": "이메일로 초대", "sendInvite": "초대 보내기", "copyInviteLink": "초대 링크 복사", "label": "멤버", "user": "사용자", "role": "역할", "removeFromWorkspace": "작업 공간에서 제거", "removeFromWorkspaceSuccess": "작업 공간에서 성공적으로 제거되었습니다", "removeFromWorkspaceFailed": "작업 공간에서 제거 실패", "owner": "소유자", "guest": "게스트", "member": "멤버", "memberHintText": "멤버는 페이지를 읽고 편집할 수 있습니다", "guestHintText": "게스트는 페이지를 읽고, 반응하고, 댓글을 달 수 있으며, 권한이 있는 특정 페이지를 편집할 수 있습니다.", "emailInvalidError": "잘못된 이메일입니다. 확인하고 다시 시도하세요", "emailSent": "이메일이 전송되었습니다. 받은 편지함을 확인하세요", "members": "멤버", "membersCount": { "zero": "{}명의 멤버", "one": "{}명의 멤버", "other": "{}명의 멤버" }, "inviteFailedDialogTitle": "초대 전송 실패", "inviteFailedMemberLimit": "멤버 한도에 도달했습니다. 더 많은 멤버를 초대하려면 업그레이드하세요.", "inviteFailedMemberLimitMobile": "작업 공간의 멤버 한도에 도달했습니다.", "memberLimitExceeded": "멤버 한도에 도달했습니다. 더 많은 멤버를 초대하려면 ", "memberLimitExceededUpgrade": "업그레이드", "memberLimitExceededPro": "멤버 한도에 도달했습니다. 더 많은 멤버가 필요하면 ", "memberLimitExceededProContact": "support@appflowy.io에 문의하세요", "failedToAddMember": "멤버 추가 실패", "addMemberSuccess": "멤버가 성공적으로 추가되었습니다", "removeMember": "멤버 제거", "areYouSureToRemoveMember": "이 멤버를 제거하시겠습니까?", "inviteMemberSuccess": "초대가 성공적으로 전송되었습니다", "failedToInviteMember": "멤버 초대 실패", "workspaceMembersError": "문제가 발생했습니다", "workspaceMembersErrorDescription": "현재 멤버 목록을 로드할 수 없습니다. 나중에 다시 시도하세요" } }, "files": { "copy": "복사", "defaultLocation": "파일 및 데이터 저장 위치 읽기", "exportData": "데이터 내보내기", "doubleTapToCopy": "경로를 복사하려면 두 번 탭하세요", "restoreLocation": "@:appName 기본 경로로 복원", "customizeLocation": "다른 폴더 열기", "restartApp": "변경 사항을 적용하려면 앱을 재시작하세요.", "exportDatabase": "데이터베이스 내보내기", "selectFiles": "내보낼 파일 선택", "selectAll": "모두 선택", "deselectAll": "모두 선택 해제", "createNewFolder": "새 폴더 만들기", "createNewFolderDesc": "데이터를 저장할 위치를 알려주세요", "defineWhereYourDataIsStored": "데이터가 저장되는 위치 정의", "open": "열기", "openFolder": "기존 폴더 열기", "openFolderDesc": "기존 @:appName 폴더를 읽고 쓰기", "folderHintText": "폴더 이름", "location": "새 폴더 만들기", "locationDesc": "@:appName 데이터 폴더의 이름을 지정하세요", "browser": "찾아보기", "create": "생성", "set": "설정", "folderPath": "폴더를 저장할 경로", "locationCannotBeEmpty": "경로는 비워둘 수 없습니다", "pathCopiedSnackbar": "파일 저장 경로가 클립보드에 복사되었습니다!", "changeLocationTooltips": "데이터 디렉토리 변경", "change": "변경", "openLocationTooltips": "다른 데이터 디렉토리 열기", "openCurrentDataFolder": "현재 데이터 디렉토리 열기", "recoverLocationTooltips": "@:appName의 기본 데이터 디렉토리로 재설정", "exportFileSuccess": "파일이 성공적으로 내보내졌습니다!", "exportFileFail": "파일 내보내기 실패!", "export": "내보내기", "clearCache": "캐시 지우기", "clearCacheDesc": "이미지가 로드되지 않거나 글꼴이 제대로 표시되지 않는 등의 문제가 발생하면 캐시를 지워보세요. 이 작업은 사용자 데이터에는 영향을 미치지 않습니다.", "areYouSureToClearCache": "캐시를 지우시겠습니까?", "clearCacheSuccess": "캐시가 성공적으로 지워졌습니다!" }, "user": { "name": "이름", "email": "이메일", "tooltipSelectIcon": "아이콘 선택", "selectAnIcon": "아이콘 선택", "pleaseInputYourOpenAIKey": "AI 키를 입력하세요", "clickToLogout": "현재 사용자 로그아웃" }, "mobile": { "personalInfo": "개인 정보", "username": "사용자 이름", "usernameEmptyError": "사용자 이름은 비워둘 수 없습니다", "about": "정보", "pushNotifications": "푸시 알림", "support": "지원", "joinDiscord": "Discord에 참여", "privacyPolicy": "개인정보 보호정책", "userAgreement": "사용자 계약", "termsAndConditions": "이용 약관", "userprofileError": "사용자 프로필을 로드하지 못했습니다", "userprofileErrorDescription": "로그아웃 후 다시 로그인하여 문제가 계속되는지 확인하세요.", "selectLayout": "레이아웃 선택", "selectStartingDay": "시작일 선택", "version": "버전" } }, "grid": { "deleteView": "이 보기를 삭제하시겠습니까?", "createView": "새로 만들기", "title": { "placeholder": "제목 없음" }, "settings": { "filter": "필터", "sort": "정렬", "sortBy": "정렬 기준", "properties": "속성", "reorderPropertiesTooltip": "속성 순서 변경", "group": "그룹", "addFilter": "필터 추가", "deleteFilter": "필터 삭제", "filterBy": "필터 기준", "typeAValue": "값 입력...", "layout": "레이아웃", "compactMode": "압축 모드", "databaseLayout": "레이아웃", "viewList": { "zero": "0개의 보기", "one": "{count}개의 보기", "other": "{count}개의 보기" }, "editView": "보기 편집", "boardSettings": "보드 설정", "calendarSettings": "캘린더 설정", "createView": "새 보기", "duplicateView": "보기 복제", "deleteView": "보기 삭제", "numberOfVisibleFields": "{}개 표시됨" }, "filter": { "empty": "활성 필터 없음", "addFilter": "필터 추가", "cannotFindCreatableField": "필터링할 적절한 필드를 찾을 수 없습니다", "conditon": "조건", "where": "조건" }, "textFilter": { "contains": "포함", "doesNotContain": "포함하지 않음", "endsWith": "끝남", "startWith": "시작", "is": "일치", "isNot": "일치하지 않음", "isEmpty": "비어 있음", "isNotEmpty": "비어 있지 않음", "choicechipPrefix": { "isNot": "일치하지 않음", "startWith": "시작", "endWith": "끝남", "isEmpty": "비어 있음", "isNotEmpty": "비어 있지 않음" } }, "checkboxFilter": { "isChecked": "체크됨", "isUnchecked": "체크되지 않음", "choicechipPrefix": { "is": "체크됨" } }, "checklistFilter": { "isComplete": "완료됨", "isIncomplted": "미완료" }, "selectOptionFilter": { "is": "일치", "isNot": "일치하지 않음", "contains": "포함", "doesNotContain": "포함하지 않음", "isEmpty": "비어 있음", "isNotEmpty": "비어 있지 않음" }, "dateFilter": { "is": "일치", "before": "이전", "after": "이후", "onOrBefore": "이전 또는 일치", "onOrAfter": "이후 또는 일치", "between": "사이", "empty": "비어 있음", "notEmpty": "비어 있지 않음", "startDate": "시작 날짜", "endDate": "종료 날짜", "choicechipPrefix": { "before": "이전", "after": "이후", "between": "사이", "onOrBefore": "이전 또는 일치", "onOrAfter": "이후 또는 일치", "isEmpty": "비어 있음", "isNotEmpty": "비어 있지 않음" } }, "numberFilter": { "equal": "일치", "notEqual": "일치하지 않음", "lessThan": "미만", "greaterThan": "초과", "lessThanOrEqualTo": "이하", "greaterThanOrEqualTo": "이상", "isEmpty": "비어 있음", "isNotEmpty": "비어 있지 않음" }, "field": { "label": "속성", "hide": "속성 숨기기", "show": "속성 표시", "insertLeft": "왼쪽에 삽입", "insertRight": "오른쪽에 삽입", "duplicate": "복제", "delete": "삭제", "wrapCellContent": "텍스트 줄 바꿈", "clear": "셀 지우기", "switchPrimaryFieldTooltip": "기본 필드의 필드 유형을 변경할 수 없습니다", "textFieldName": "텍스트", "checkboxFieldName": "체크박스", "dateFieldName": "날짜", "updatedAtFieldName": "마지막 수정", "createdAtFieldName": "생성일", "numberFieldName": "숫자", "singleSelectFieldName": "선택", "multiSelectFieldName": "다중 선택", "urlFieldName": "URL", "checklistFieldName": "체크리스트", "relationFieldName": "관계", "summaryFieldName": "AI 요약", "timeFieldName": "시간", "mediaFieldName": "파일 및 미디어", "translateFieldName": "AI 번역", "translateTo": "번역 대상", "numberFormat": "숫자 형식", "dateFormat": "날짜 형식", "includeTime": "시간 포함", "isRange": "종료 날짜", "dateFormatFriendly": "월 일, 년", "dateFormatISO": "년-월-일", "dateFormatLocal": "월/일/년", "dateFormatUS": "년/월/일", "dateFormatDayMonthYear": "일/월/년", "timeFormat": "시간 형식", "invalidTimeFormat": "잘못된 형식", "timeFormatTwelveHour": "12시간", "timeFormatTwentyFourHour": "24시간", "clearDate": "날짜 지우기", "dateTime": "날짜 시간", "startDateTime": "시작 날짜 시간", "endDateTime": "종료 날짜 시간", "failedToLoadDate": "날짜 값을 로드하지 못했습니다", "selectTime": "시간 선택", "selectDate": "날짜 선택", "visibility": "가시성", "propertyType": "속성 유형", "addSelectOption": "옵션 추가", "typeANewOption": "새 옵션 입력", "optionTitle": "옵션", "addOption": "옵션 추가", "editProperty": "속성 편집", "newProperty": "새 속성", "openRowDocument": "페이지로 열기", "deleteFieldPromptMessage": "확실합니까? 이 속성과 모든 데이터가 삭제됩니다", "clearFieldPromptMessage": "확실합니까? 이 열의 모든 셀이 비워집니다", "newColumn": "새 열", "format": "형식", "reminderOnDateTooltip": "이 셀에는 예약된 알림이 있습니다", "optionAlreadyExist": "옵션이 이미 존재합니다" }, "rowPage": { "newField": "새 필드 추가", "fieldDragElementTooltip": "메뉴 열기", "showHiddenFields": { "one": "숨겨진 {count}개의 필드 표시", "many": "숨겨진 {count}개의 필드 표시", "other": "숨겨진 {count}개의 필드 표시" }, "hideHiddenFields": { "one": "숨겨진 {count}개의 필드 숨기기", "many": "숨겨진 {count}개의 필드 숨기기", "other": "숨겨진 {count}개의 필드 숨기기" }, "openAsFullPage": "전체 페이지로 열기", "moreRowActions": "더 많은 행 작업" }, "sort": { "ascending": "오름차순", "descending": "내림차순", "by": "기준", "empty": "활성 정렬 없음", "cannotFindCreatableField": "정렬할 적절한 필드를 찾을 수 없습니다", "deleteAllSorts": "모든 정렬 삭제", "addSort": "정렬 추가", "sortsActive": "정렬 중에는 {intention}할 수 없습니다", "removeSorting": "이 보기의 모든 정렬을 제거하고 계속하시겠습니까?", "fieldInUse": "이미 이 필드로 정렬 중입니다" }, "row": { "label": "행", "duplicate": "복제", "delete": "삭제", "titlePlaceholder": "제목 없음", "textPlaceholder": "비어 있음", "copyProperty": "속성이 클립보드에 복사되었습니다", "count": "개수", "newRow": "새 행", "loadMore": "더 로드", "action": "작업", "add": "아래에 추가하려면 클릭", "drag": "이동하려면 드래그", "deleteRowPrompt": "이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "deleteCardPrompt": "이 카드를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "dragAndClick": "이동하려면 드래그, 메뉴를 열려면 클릭", "insertRecordAbove": "위에 레코드 삽입", "insertRecordBelow": "아래에 레코드 삽입", "noContent": "내용 없음", "reorderRowDescription": "행 순서 변경", "createRowAboveDescription": "위에 행 생성", "createRowBelowDescription": "아래에 행 삽입" }, "selectOption": { "create": "생성", "purpleColor": "보라색", "pinkColor": "분홍색", "lightPinkColor": "연분홍색", "orangeColor": "주황색", "yellowColor": "노란색", "limeColor": "라임색", "greenColor": "녹색", "aquaColor": "청록색", "blueColor": "파란색", "deleteTag": "태그 삭제", "colorPanelTitle": "색상", "panelTitle": "옵션 선택 또는 생성", "searchOption": "옵션 검색", "searchOrCreateOption": "옵션 검색 또는 생성", "createNew": "새로 생성", "orSelectOne": "또는 옵션 선택", "typeANewOption": "새 옵션 입력", "tagName": "태그 이름" }, "checklist": { "taskHint": "작업 설명", "addNew": "새 작업 추가", "submitNewTask": "생성", "hideComplete": "완료된 작업 숨기기", "showComplete": "모든 작업 표시" }, "url": { "launch": "브라우저에서 링크 열기", "copy": "링크를 클립보드에 복사", "textFieldHint": "URL 입력" }, "relation": { "relatedDatabasePlaceLabel": "관련 데이터베이스", "relatedDatabasePlaceholder": "없음", "inRelatedDatabase": "에", "rowSearchTextFieldPlaceholder": "검색", "noDatabaseSelected": "선택된 데이터베이스가 없습니다. 아래 목록에서 하나를 먼저 선택하세요:", "emptySearchResult": "레코드를 찾을 수 없습니다", "linkedRowListLabel": "{count}개의 연결된 행", "unlinkedRowListLabel": "다른 행 연결" }, "menuName": "그리드", "referencedGridPrefix": "보기", "calculate": "계산", "calculationTypeLabel": { "none": "없음", "average": "평균", "max": "최대", "median": "중앙값", "min": "최소", "sum": "합계", "count": "개수", "countEmpty": "비어 있는 개수", "countEmptyShort": "비어 있음", "countNonEmpty": "비어 있지 않은 개수", "countNonEmptyShort": "채워짐" }, "media": { "rename": "이름 변경", "download": "다운로드", "expand": "확장", "delete": "삭제", "moreFilesHint": "+{}", "addFileOrImage": "파일 또는 링크 추가", "attachmentsHint": "{}", "addFileMobile": "파일 추가", "extraCount": "+{}", "deleteFileDescription": "이 파일을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "showFileNames": "파일 이름 표시", "downloadSuccess": "파일이 다운로드되었습니다", "downloadFailedToken": "파일을 다운로드하지 못했습니다. 사용자 토큰이 없습니다", "setAsCover": "표지로 설정", "openInBrowser": "브라우저에서 열기", "embedLink": "파일 링크 삽입" } }, "document": { "menuName": "문서", "date": { "timeHintTextInTwelveHour": "오후 01:00", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "생성 중...", "slashMenu": { "board": { "selectABoardToLinkTo": "연결할 보드 선택", "createANewBoard": "새 보드 생성" }, "grid": { "selectAGridToLinkTo": "연결할 그리드 선택", "createANewGrid": "새 그리드 생성" }, "calendar": { "selectACalendarToLinkTo": "연결할 캘린더 선택", "createANewCalendar": "새 캘린더 생성" }, "document": { "selectADocumentToLinkTo": "연결할 문서 선택" }, "name": { "textStyle": "텍스트 스타일", "list": "목록", "toggle": "토글", "fileAndMedia": "파일 및 미디어", "simpleTable": "간단한 테이블", "visuals": "시각 자료", "document": "문서", "advanced": "고급", "text": "텍스트", "heading1": "헤딩 1", "heading2": "헤딩 2", "heading3": "헤딩 3", "image": "이미지", "bulletedList": "글머리 기호 목록", "numberedList": "번호 매기기 목록", "todoList": "할 일 목록", "doc": "문서", "linkedDoc": "페이지로 연결", "grid": "그리드", "linkedGrid": "연결된 그리드", "kanban": "칸반", "linkedKanban": "연결된 칸반", "calendar": "캘린더", "linkedCalendar": "연결된 캘린더", "quote": "인용", "divider": "구분선", "table": "테이블", "callout": "콜아웃", "outline": "개요", "mathEquation": "수학 방정식", "code": "코드", "toggleList": "토글 목록", "toggleHeading1": "토글 헤딩 1", "toggleHeading2": "토글 헤딩 2", "toggleHeading3": "토글 헤딩 3", "emoji": "이모지", "aiWriter": "AI에게 물어보기", "dateOrReminder": "날짜 또는 알림", "photoGallery": "사진 갤러리", "file": "파일", "twoColumns": "2열", "threeColumns": "3열", "fourColumns": "4열" }, "subPage": { "name": "문서", "keyword1": "하위 페이지", "keyword2": "페이지", "keyword3": "자식 페이지", "keyword4": "페이지 삽입", "keyword5": "페이지 포함", "keyword6": "새 페이지", "keyword7": "페이지 생성", "keyword8": "문서" } }, "selectionMenu": { "outline": "개요", "codeBlock": "코드 블록" }, "plugins": { "referencedBoard": "참조된 보드", "referencedGrid": "참조된 그리드", "referencedCalendar": "참조된 캘린더", "referencedDocument": "참조된 문서", "aiWriter": { "userQuestion": "AI에게 물어보기", "continueWriting": "계속 작성", "fixSpelling": "맞춤법 및 문법 수정", "improveWriting": "글쓰기 개선", "summarize": "요약", "explain": "설명", "makeShorter": "짧게 만들기", "makeLonger": "길게 만들기" }, "autoGeneratorMenuItemName": "AI 작성기", "autoGeneratorTitleName": "AI: AI에게 무엇이든 물어보세요...", "autoGeneratorLearnMore": "자세히 알아보기", "autoGeneratorGenerate": "생성", "autoGeneratorHintText": "AI에게 물어보기 ...", "autoGeneratorCantGetOpenAIKey": "AI 키를 가져올 수 없습니다", "autoGeneratorRewrite": "다시 작성", "smartEdit": "AI에게 물어보기", "aI": "AI", "smartEditFixSpelling": "맞춤법 및 문법 수정", "warning": "⚠️ AI 응답은 부정확하거나 오해의 소지가 있을 수 있습니다.", "smartEditSummarize": "요약", "smartEditImproveWriting": "글쓰기 개선", "smartEditMakeLonger": "길게 만들기", "smartEditCouldNotFetchResult": "AI에서 결과를 가져올 수 없습니다", "smartEditCouldNotFetchKey": "AI 키를 가져올 수 없습니다", "smartEditDisabled": "설정에서 AI 연결", "appflowyAIEditDisabled": "AI 기능을 활성화하려면 로그인하세요", "discardResponse": "AI 응답을 버리시겠습니까?", "createInlineMathEquation": "방정식 생성", "fonts": "글꼴", "insertDate": "날짜 삽입", "emoji": "이모지", "toggleList": "토글 목록", "emptyToggleHeading": "빈 토글 h{}. 내용을 추가하려면 클릭하세요.", "emptyToggleList": "빈 토글 목록. 내용을 추가하려면 클릭하세요.", "emptyToggleHeadingWeb": "빈 토글 h{level}. 내용을 추가하려면 클릭하세요", "quoteList": "인용 목록", "numberedList": "번호 매기기 목록", "bulletedList": "글머리 기호 목록", "todoList": "할 일 목록", "callout": "콜아웃", "simpleTable": { "moreActions": { "color": "색상", "align": "정렬", "delete": "삭제", "duplicate": "복제", "insertLeft": "왼쪽에 삽입", "insertRight": "오른쪽에 삽입", "insertAbove": "위에 삽입", "insertBelow": "아래에 삽입", "headerColumn": "헤더 열", "headerRow": "헤더 행", "clearContents": "내용 지우기", "setToPageWidth": "페이지 너비로 설정", "distributeColumnsWidth": "열 너비 균등 분배", "duplicateRow": "행 복제", "duplicateColumn": "열 복제", "textColor": "텍스트 색상", "cellBackgroundColor": "셀 배경 색상", "duplicateTable": "테이블 복제" }, "clickToAddNewRow": "새 행을 추가하려면 클릭", "clickToAddNewColumn": "새 열을 추가하려면 클릭", "clickToAddNewRowAndColumn": "새 행과 열을 추가하려면 클릭", "headerName": { "table": "테이블", "alignText": "텍스트 정렬" } }, "cover": { "changeCover": "표지 변경", "colors": "색상", "images": "이미지", "clearAll": "모두 지우기", "abstract": "추상", "addCover": "표지 추가", "addLocalImage": "로컬 이미지 추가", "invalidImageUrl": "잘못된 이미지 URL", "failedToAddImageToGallery": "갤러리에 이미지를 추가하지 못했습니다", "enterImageUrl": "이미지 URL 입력", "add": "추가", "back": "뒤로", "saveToGallery": "갤러리에 저장", "removeIcon": "아이콘 제거", "removeCover": "표지 제거", "pasteImageUrl": "이미지 URL 붙여넣기", "or": "또는", "pickFromFiles": "파일에서 선택", "couldNotFetchImage": "이미지를 가져올 수 없습니다", "imageSavingFailed": "이미지 저장 실패", "addIcon": "아이콘 추가", "changeIcon": "아이콘 변경", "coverRemoveAlert": "삭제 후 표지에서 제거됩니다.", "alertDialogConfirmation": "계속하시겠습니까?" }, "mathEquation": { "name": "수학 방정식", "addMathEquation": "TeX 방정식 추가", "editMathEquation": "수학 방정식 편집" }, "optionAction": { "click": "클릭", "toOpenMenu": " 메뉴 열기", "drag": "드래그", "toMove": " 이동", "delete": "삭제", "duplicate": "복제", "turnInto": "변환", "moveUp": "위로 이동", "moveDown": "아래로 이동", "color": "색상", "align": "정렬", "left": "왼쪽", "center": "가운데", "right": "오른쪽", "defaultColor": "기본", "depth": "깊이", "copyLinkToBlock": "블록 링크 복사" }, "image": { "addAnImage": "이미지 추가", "copiedToPasteBoard": "이미지 링크가 클립보드에 복사되었습니다", "addAnImageDesktop": "이미지 추가", "addAnImageMobile": "하나 이상의 이미지를 추가하려면 클릭", "dropImageToInsert": "이미지를 드롭하여 삽입", "imageUploadFailed": "이미지 업로드 실패", "imageDownloadFailed": "이미지 다운로드 실패, 다시 시도하세요", "imageDownloadFailedToken": "사용자 토큰이 없어 이미지 다운로드 실패, 다시 시도하세요", "errorCode": "오류 코드" }, "photoGallery": { "name": "사진 갤러리", "imageKeyword": "이미지", "imageGalleryKeyword": "이미지 갤러리", "photoKeyword": "사진", "photoBrowserKeyword": "사진 브라우저", "galleryKeyword": "갤러리", "addImageTooltip": "이미지 추가", "changeLayoutTooltip": "레이아웃 변경", "browserLayout": "브라우저", "gridLayout": "그리드", "deleteBlockTooltip": "전체 갤러리 삭제" }, "math": { "copiedToPasteBoard": "수학 방정식이 클립보드에 복사되었습니다" }, "urlPreview": { "copiedToPasteBoard": "링크가 클립보드에 복사되었습니다", "convertToLink": "링크로 변환" }, "outline": { "addHeadingToCreateOutline": "목차를 만들려면 헤딩을 추가하세요.", "noMatchHeadings": "일치하는 헤딩이 없습니다." }, "table": { "addAfter": "뒤에 추가", "addBefore": "앞에 추가", "delete": "삭제", "clear": "내용 지우기", "duplicate": "복제", "bgColor": "배경 색상" }, "contextMenu": { "copy": "복사", "cut": "잘라내기", "paste": "붙여넣기", "pasteAsPlainText": "서식 없이 붙여넣기" }, "action": "작업", "database": { "selectDataSource": "데이터 소스 선택", "noDataSource": "데이터 소스 없음", "selectADataSource": "데이터 소스 선택", "toContinue": "계속하려면", "newDatabase": "새 데이터베이스", "linkToDatabase": "데이터베이스로 연결" }, "date": "날짜", "video": { "label": "비디오", "emptyLabel": "비디오 추가", "placeholder": "비디오 링크 붙여넣기", "copiedToPasteBoard": "비디오 링크가 클립보드에 복사되었습니다", "insertVideo": "비디오 추가", "invalidVideoUrl": "지원되지 않는 소스 URL입니다.", "invalidVideoUrlYouTube": "YouTube는 아직 지원되지 않습니다.", "supportedFormats": "지원되는 형식: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "파일", "uploadTab": "업로드", "uploadMobile": "파일 선택", "uploadMobileGallery": "사진 갤러리에서", "networkTab": "링크 삽입", "placeholderText": "파일 업로드 또는 삽입", "placeholderDragging": "업로드할 파일을 드롭하세요", "dropFileToUpload": "업로드할 파일을 드롭하세요", "fileUploadHint": "파일을 드래그 앤 드롭하거나 클릭하여 ", "fileUploadHintSuffix": "찾아보기", "networkHint": "파일 링크 붙여넣기", "networkUrlInvalid": "잘못된 URL입니다. URL을 확인하고 다시 시도하세요.", "networkAction": "삽입", "fileTooBigError": "파일 크기가 너무 큽니다. 10MB 미만의 파일을 업로드하세요", "renameFile": { "title": "파일 이름 변경", "description": "이 파일의 새 이름을 입력하세요", "nameEmptyError": "파일 이름은 비워둘 수 없습니다." }, "uploadedAt": "{}에 업로드됨", "linkedAt": "{}에 링크 추가됨", "failedToOpenMsg": "열지 못했습니다. 파일을 찾을 수 없습니다" }, "subPage": { "handlingPasteHint": " - (붙여넣기 처리 중)", "errors": { "failedDeletePage": "페이지 삭제 실패", "failedCreatePage": "페이지 생성 실패", "failedMovePage": "이 문서로 페이지 이동 실패", "failedDuplicatePage": "페이지 복제 실패", "failedDuplicateFindView": "페이지 복제 실패 - 원본 보기를 찾을 수 없습니다" } }, "cannotMoveToItsChildren": "자식으로 이동할 수 없습니다" }, "outlineBlock": { "placeholder": "목차" }, "textBlock": { "placeholder": "명령어를 입력하려면 '/'를 입력하세요" }, "title": { "placeholder": "제목 없음" }, "imageBlock": { "placeholder": "이미지 추가하려면 클릭", "upload": { "label": "업로드", "placeholder": "이미지 업로드하려면 클릭" }, "url": { "label": "이미지 URL", "placeholder": "이미지 URL 입력" }, "ai": { "label": "AI로 이미지 생성", "placeholder": "AI가 이미지를 생성할 프롬프트를 입력하세요" }, "stability_ai": { "label": "Stability AI로 이미지 생성", "placeholder": "Stability AI가 이미지를 생성할 프롬프트를 입력하세요" }, "support": "이미지 크기 제한은 5MB입니다. 지원되는 형식: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "잘못된 이미지", "invalidImageSize": "이미지 크기는 5MB 미만이어야 합니다", "invalidImageFormat": "지원되지 않는 이미지 형식입니다. 지원되는 형식: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "잘못된 이미지 URL", "noImage": "파일 또는 디렉토리가 없습니다", "multipleImagesFailed": "하나 이상의 이미지 업로드 실패, 다시 시도하세요" }, "embedLink": { "label": "링크 삽입", "placeholder": "이미지 링크를 붙여넣거나 입력하세요" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "이미지 검색", "pleaseInputYourOpenAIKey": "설정 페이지에서 AI 키를 입력하세요", "saveImageToGallery": "이미지 저장", "failedToAddImageToGallery": "이미지 저장 실패", "successToAddImageToGallery": "이미지가 사진에 저장되었습니다", "unableToLoadImage": "이미지를 로드할 수 없습니다", "maximumImageSize": "최대 지원 업로드 이미지 크기는 10MB입니다", "uploadImageErrorImageSizeTooBig": "이미지 크기는 10MB 미만이어야 합니다", "imageIsUploading": "이미지 업로드 중", "openFullScreen": "전체 화면으로 열기", "interactiveViewer": { "toolbar": { "previousImageTooltip": "이전 이미지", "nextImageTooltip": "다음 이미지", "zoomOutTooltip": "축소", "zoomInTooltip": "확대", "changeZoomLevelTooltip": "확대/축소 수준 변경", "openLocalImage": "이미지 열기", "downloadImage": "이미지 다운로드", "closeViewer": "인터랙티브 뷰어 닫기", "scalePercentage": "{}%", "deleteImageTooltip": "이미지 삭제" } } }, "codeBlock": { "language": { "label": "언어", "placeholder": "언어 선택", "auto": "자동" }, "copyTooltip": "복사", "searchLanguageHint": "언어 검색", "codeCopiedSnackbar": "코드가 클립보드에 복사되었습니다!" }, "inlineLink": { "placeholder": "링크를 붙여넣거나 입력하세요", "openInNewTab": "새 탭에서 열기", "copyLink": "링크 복사", "removeLink": "링크 제거", "url": { "label": "링크 URL", "placeholder": "링크 URL 입력" }, "title": { "label": "링크 제목", "placeholder": "링크 제목 입력" } }, "mention": { "placeholder": "사람, 페이지 또는 날짜 언급...", "page": { "label": "페이지로 연결", "tooltip": "페이지 열기" }, "deleted": "삭제됨", "deletedContent": "이 콘텐츠는 존재하지 않거나 삭제되었습니다", "noAccess": "액세스 불가", "deletedPage": "삭제된 페이지", "trashHint": " - 휴지통에 있음", "morePages": "더 많은 페이지" }, "toolbar": { "resetToDefaultFont": "기본값으로 재설정", "textSize": "텍스트 크기", "h1": "헤딩 1", "h2": "헤딩 2", "h3": "헤딩 3", "alignLeft": "왼쪽 정렬", "alignRight": "오른쪽 정렬", "alignCenter": "가운데 정렬", "link": "링크", "textAlign": "텍스트 정렬", "moreOptions": "더 많은 옵션", "font": "글꼴", "suggestions": "제안", "turnInto": "변환" }, "errorBlock": { "theBlockIsNotSupported": "블록 콘텐츠를 구문 분석할 수 없습니다", "clickToCopyTheBlockContent": "블록 콘텐츠를 복사하려면 클릭", "blockContentHasBeenCopied": "블록 콘텐츠가 복사되었습니다.", "parseError": "{} 블록을 구문 분석하는 동안 오류가 발생했습니다.", "copyBlockContent": "블록 콘텐츠 복사" }, "mobilePageSelector": { "title": "페이지 선택", "failedToLoad": "페이지 목록을 로드하지 못했습니다", "noPagesFound": "페이지를 찾을 수 없습니다" }, "attachmentMenu": { "choosePhoto": "사진 선택", "takePicture": "사진 찍기", "chooseFile": "파일 선택" } }, "board": { "column": { "label": "열", "createNewCard": "새로 만들기", "renameGroupTooltip": "그룹 이름 변경", "createNewColumn": "새 그룹 추가", "addToColumnTopTooltip": "맨 위에 새 카드 추가", "addToColumnBottomTooltip": "맨 아래에 새 카드 추가", "renameColumn": "이름 변경", "hideColumn": "숨기기", "newGroup": "새 그룹", "deleteColumn": "삭제", "deleteColumnConfirmation": "이 그룹과 그룹 내 모든 카드를 삭제합니다. 계속하시겠습니까?" }, "hiddenGroupSection": { "sectionTitle": "숨겨진 그룹", "collapseTooltip": "숨겨진 그룹 숨기기", "expandTooltip": "숨겨진 그룹 보기" }, "cardDetail": "카드 세부 정보", "cardActions": "카드 작업", "cardDuplicated": "카드가 복제되었습니다", "cardDeleted": "카드가 삭제되었습니다", "showOnCard": "카드 세부 정보에 표시", "setting": "설정", "propertyName": "속성 이름", "menuName": "보드", "showUngrouped": "그룹화되지 않은 항목 표시", "ungroupedButtonText": "그룹화되지 않음", "ungroupedButtonTooltip": "어떤 그룹에도 속하지 않는 카드가 포함되어 있습니다", "ungroupedItemsTitle": "보드에 추가하려면 클릭", "groupBy": "그룹 기준", "groupCondition": "그룹 조건", "referencedBoardPrefix": "보기", "notesTooltip": "내부에 노트 있음", "mobile": { "editURL": "URL 편집", "showGroup": "그룹 표시", "showGroupContent": "이 그룹을 보드에 표시하시겠습니까?", "failedToLoad": "보드 보기를 로드하지 못했습니다" }, "dateCondition": { "weekOf": "{} - {} 주", "today": "오늘", "yesterday": "어제", "tomorrow": "내일", "lastSevenDays": "지난 7일", "nextSevenDays": "다음 7일", "lastThirtyDays": "지난 30일", "nextThirtyDays": "다음 30일" }, "noGroup": "그룹화할 속성 없음", "noGroupDesc": "보드 보기를 표시하려면 그룹화할 속성이 필요합니다", "media": { "cardText": "{} {}", "fallbackName": "파일" } }, "calendar": { "menuName": "캘린더", "defaultNewCalendarTitle": "제목 없음", "newEventButtonTooltip": "새 이벤트 추가", "navigation": { "today": "오늘", "jumpToday": "오늘로 이동", "previousMonth": "이전 달", "nextMonth": "다음 달", "views": { "day": "일", "week": "주", "month": "월", "year": "년" } }, "mobileEventScreen": { "emptyTitle": "이벤트 없음", "emptyBody": "이 날에 이벤트를 생성하려면 더하기 버튼을 누르세요." }, "settings": { "showWeekNumbers": "주 번호 표시", "showWeekends": "주말 표시", "firstDayOfWeek": "주 시작일", "layoutDateField": "캘린더 레이아웃 기준", "changeLayoutDateField": "레이아웃 필드 변경", "noDateTitle": "날짜 없음", "noDateHint": { "zero": "일정이 없는 이벤트가 여기에 표시됩니다", "one": "{count}개의 일정이 없는 이벤트", "other": "{count}개의 일정이 없는 이벤트" }, "unscheduledEventsTitle": "일정이 없는 이벤트", "clickToAdd": "캘린더에 추가하려면 클릭", "name": "캘린더 설정", "clickToOpen": "레코드를 열려면 클릭" }, "referencedCalendarPrefix": "보기", "quickJumpYear": "이동", "duplicateEvent": "이벤트 복제" }, "errorDialog": { "title": "@:appName 오류", "howToFixFallback": "불편을 드려 죄송합니다! GitHub 페이지에 오류를 설명하는 문제를 제출하세요.", "howToFixFallbackHint1": "불편을 드려 죄송합니다! ", "howToFixFallbackHint2": " 페이지에 오류를 설명하는 문제를 제출하세요.", "github": "GitHub에서 보기" }, "search": { "label": "검색", "sidebarSearchIcon": "검색하고 페이지로 빠르게 이동", "placeholder": { "actions": "작업 검색..." } }, "message": { "copy": { "success": "복사됨!", "fail": "복사할 수 없음" } }, "unSupportBlock": "현재 버전에서는 이 블록을 지원하지 않습니다.", "views": { "deleteContentTitle": "{pageType}을(를) 삭제하시겠습니까?", "deleteContentCaption": "이 {pageType}을(를) 삭제하면 휴지통에서 복원할 수 있습니다." }, "colors": { "custom": "사용자 정의", "default": "기본", "red": "빨간색", "orange": "주황색", "yellow": "노란색", "green": "녹색", "blue": "파란색", "purple": "보라색", "pink": "분홍색", "brown": "갈색", "gray": "회색" }, "emoji": { "emojiTab": "이모지", "search": "이모지 검색", "noRecent": "최근 사용한 이모지 없음", "noEmojiFound": "이모지를 찾을 수 없음", "filter": "필터", "random": "무작위", "selectSkinTone": "피부 톤 선택", "remove": "이모지 제거", "categories": { "smileys": "스마일리 및 감정", "people": "사람", "animals": "자연", "food": "음식", "activities": "활동", "places": "장소", "objects": "사물", "symbols": "기호", "flags": "깃발", "nature": "자연", "frequentlyUsed": "자주 사용됨" }, "skinTone": { "default": "기본", "light": "밝은", "mediumLight": "중간 밝은", "medium": "중간", "mediumDark": "중간 어두운", "dark": "어두운" }, "openSourceIconsFrom": "오픈 소스 아이콘 제공" }, "inlineActions": { "noResults": "결과 없음", "recentPages": "최근 페이지", "pageReference": "페이지 참조", "docReference": "문서 참조", "boardReference": "보드 참조", "calReference": "캘린더 참조", "gridReference": "그리드 참조", "date": "날짜", "reminder": { "groupTitle": "알림", "shortKeyword": "알림" }, "createPage": "\"{}\" 하위 페이지 생성" }, "datePicker": { "dateTimeFormatTooltip": "설정에서 날짜 및 시간 형식 변경", "dateFormat": "날짜 형식", "includeTime": "시간 포함", "isRange": "종료 날짜", "timeFormat": "시간 형식", "clearDate": "날짜 지우기", "reminderLabel": "알림", "selectReminder": "알림 선택", "reminderOptions": { "none": "없음", "atTimeOfEvent": "이벤트 시간", "fiveMinsBefore": "5분 전", "tenMinsBefore": "10분 전", "fifteenMinsBefore": "15분 전", "thirtyMinsBefore": "30분 전", "oneHourBefore": "1시간 전", "twoHoursBefore": "2시간 전", "onDayOfEvent": "이벤트 당일", "oneDayBefore": "1일 전", "twoDaysBefore": "2일 전", "oneWeekBefore": "1주일 전", "custom": "사용자 정의" } }, "relativeDates": { "yesterday": "어제", "today": "오늘", "tomorrow": "내일", "oneWeek": "1주일" }, "notificationHub": { "title": "알림", "mobile": { "title": "업데이트" }, "emptyTitle": "모두 확인했습니다!", "emptyBody": "대기 중인 알림이나 작업이 없습니다. 평온을 즐기세요.", "tabs": { "inbox": "받은 편지함", "upcoming": "다가오는" }, "actions": { "markAllRead": "모두 읽음으로 표시", "showAll": "모두", "showUnreads": "읽지 않음" }, "filters": { "ascending": "오름차순", "descending": "내림차순", "groupByDate": "날짜별 그룹", "showUnreadsOnly": "읽지 않은 항목만 표시", "resetToDefault": "기본값으로 재설정" } }, "reminderNotification": { "title": "알림", "message": "잊기 전에 확인하세요!", "tooltipDelete": "삭제", "tooltipMarkRead": "읽음으로 표시", "tooltipMarkUnread": "읽지 않음으로 표시" }, "findAndReplace": { "find": "찾기", "previousMatch": "이전 일치 항목", "nextMatch": "다음 일치 항목", "close": "닫기", "replace": "교체", "replaceAll": "모두 교체", "noResult": "결과 없음", "caseSensitive": "대소문자 구분", "searchMore": "더 많은 결과를 찾으려면 검색" }, "error": { "weAreSorry": "죄송합니다", "loadingViewError": "이 보기를 로드하는 데 문제가 있습니다. 인터넷 연결을 확인하고 앱을 새로 고침하세요. 문제가 계속되면 팀에 문의하세요.", "syncError": "다른 장치에서 데이터가 동기화되지 않음", "syncErrorHint": "마지막으로 편집한 장치에서 이 페이지를 다시 열고 현재 장치에서 다시 열어보세요.", "clickToCopy": "오류 코드를 복사하려면 클릭" }, "editor": { "bold": "굵게", "bulletedList": "글머리 기호 목록", "bulletedListShortForm": "글머리 기호", "checkbox": "체크박스", "embedCode": "코드 삽입", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "강조", "color": "색상", "image": "이미지", "date": "날짜", "page": "페이지", "italic": "기울임꼴", "link": "링크", "numberedList": "번호 매기기 목록", "numberedListShortForm": "번호 매기기", "toggleHeading1ShortForm": "토글 h1", "toggleHeading2ShortForm": "토글 h2", "toggleHeading3ShortForm": "토글 h3", "quote": "인용", "strikethrough": "취소선", "text": "텍스트", "underline": "밑줄", "fontColorDefault": "기본", "fontColorGray": "회색", "fontColorBrown": "갈색", "fontColorOrange": "주황색", "fontColorYellow": "노란색", "fontColorGreen": "녹색", "fontColorBlue": "파란색", "fontColorPurple": "보라색", "fontColorPink": "분홍색", "fontColorRed": "빨간색", "backgroundColorDefault": "기본 배경", "backgroundColorGray": "회색 배경", "backgroundColorBrown": "갈색 배경", "backgroundColorOrange": "주황색 배경", "backgroundColorYellow": "노란색 배경", "backgroundColorGreen": "녹색 배경", "backgroundColorBlue": "파란색 배경", "backgroundColorPurple": "보라색 배경", "backgroundColorPink": "분홍색 배경", "backgroundColorRed": "빨간색 배경", "backgroundColorLime": "라임색 배경", "backgroundColorAqua": "청록색 배경", "done": "완료", "cancel": "취소", "tint1": "색조 1", "tint2": "색조 2", "tint3": "색조 3", "tint4": "색조 4", "tint5": "색조 5", "tint6": "색조 6", "tint7": "색조 7", "tint8": "색조 8", "tint9": "색조 9", "lightLightTint1": "보라색", "lightLightTint2": "분홍색", "lightLightTint3": "연분홍색", "lightLightTint4": "주황색", "lightLightTint5": "노란색", "lightLightTint6": "라임색", "lightLightTint7": "녹색", "lightLightTint8": "청록색", "lightLightTint9": "파란색", "urlHint": "URL", "mobileHeading1": "헤딩 1", "mobileHeading2": "헤딩 2", "mobileHeading3": "헤딩 3", "mobileHeading4": "헤딩 4", "mobileHeading5": "헤딩 5", "mobileHeading6": "헤딩 6", "textColor": "텍스트 색상", "backgroundColor": "배경 색상", "addYourLink": "링크 추가", "openLink": "링크 열기", "copyLink": "링크 복사", "removeLink": "링크 제거", "editLink": "링크 편집", "linkText": "텍스트", "linkTextHint": "텍스트를 입력하세요", "linkAddressHint": "URL을 입력하세요", "highlightColor": "강조 색상", "clearHighlightColor": "강조 색상 지우기", "customColor": "사용자 정의 색상", "hexValue": "16진수 값", "opacity": "불투명도", "resetToDefaultColor": "기본 색상으로 재설정", "ltr": "LTR", "rtl": "RTL", "auto": "자동", "cut": "잘라내기", "copy": "복사", "paste": "붙여넣기", "find": "찾기", "select": "선택", "selectAll": "모두 선택", "previousMatch": "이전 일치 항목", "nextMatch": "다음 일치 항목", "closeFind": "닫기", "replace": "교체", "replaceAll": "모두 교체", "regex": "정규식", "caseSensitive": "대소문자 구분", "uploadImage": "이미지 업로드", "urlImage": "URL 이미지", "incorrectLink": "잘못된 링크", "upload": "업로드", "chooseImage": "이미지 선택", "loading": "로드 중", "imageLoadFailed": "이미지 로드 실패", "divider": "구분선", "table": "테이블", "colAddBefore": "앞에 추가", "rowAddBefore": "앞에 추가", "colAddAfter": "뒤에 추가", "rowAddAfter": "뒤에 추가", "colRemove": "제거", "rowRemove": "제거", "colDuplicate": "복제", "rowDuplicate": "복제", "colClear": "내용 지우기", "rowClear": "내용 지우기", "slashPlaceHolder": "'/'를 입력하여 블록을 삽입하거나 입력 시작", "typeSomething": "무언가 입력...", "toggleListShortForm": "토글", "quoteListShortForm": "인용", "mathEquationShortForm": "수식", "codeBlockShortForm": "코드" }, "favorite": { "noFavorite": "즐겨찾기 페이지 없음", "noFavoriteHintText": "페이지를 왼쪽으로 스와이프하여 즐겨찾기에 추가하세요", "removeFromSidebar": "사이드바에서 제거", "addToSidebar": "사이드바에 고정" }, "cardDetails": { "notesPlaceholder": "/를 입력하여 블록을 삽입하거나 입력 시작" }, "blockPlaceholders": { "todoList": "할 일", "bulletList": "목록", "numberList": "목록", "quote": "인용", "heading": "헤딩 {}" }, "titleBar": { "pageIcon": "페이지 아이콘", "language": "언어", "font": "글꼴", "actions": "작업", "date": "날짜", "addField": "필드 추가", "userIcon": "사용자 아이콘" }, "noLogFiles": "로그 파일이 없습니다", "newSettings": { "myAccount": { "title": "내 계정", "subtitle": "프로필을 사용자 정의하고, 계정 보안을 관리하고, AI 키를 열거나 계정에 로그인하세요.", "profileLabel": "계정 이름 및 프로필 이미지", "profileNamePlaceholder": "이름 입력", "accountSecurity": "계정 보안", "2FA": "2단계 인증", "aiKeys": "AI 키", "accountLogin": "계정 로그인", "updateNameError": "이름 업데이트 실패", "updateIconError": "아이콘 업데이트 실패", "aboutAppFlowy": "@:appName 정보", "deleteAccount": { "title": "계정 삭제", "subtitle": "계정과 모든 데이터를 영구적으로 삭제합니다.", "description": "계정을 영구적으로 삭제하고 모든 작업 공간에서 액세스를 제거합니다.", "deleteMyAccount": "내 계정 삭제", "dialogTitle": "계정 삭제", "dialogContent1": "계정을 영구적으로 삭제하시겠습니까?", "dialogContent2": "이 작업은 되돌릴 수 없으며, 모든 작업 공간에서 액세스를 제거하고, 개인 작업 공간을 포함한 전체 계정을 삭제하며, 모든 공유 작업 공간에서 제거됩니다.", "confirmHint1": "\"@:newSettings.myAccount.deleteAccount.confirmHint3\"를 입력하여 확인하세요.", "confirmHint2": "이 작업은 되돌릴 수 없으며, 계정과 모든 관련 데이터를 영구적으로 삭제합니다.", "confirmHint3": "내 계정 삭제", "checkToConfirmError": "삭제를 확인하려면 확인란을 선택해야 합니다", "failedToGetCurrentUser": "현재 사용자 이메일을 가져오지 못했습니다", "confirmTextValidationFailed": "확인 텍스트가 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"와 일치하지 않습니다", "deleteAccountSuccess": "계정이 성공적으로 삭제되었습니다" } }, "workplace": { "name": "작업 공간", "title": "작업 공간 설정", "subtitle": "작업 공간의 외관, 테마, 글꼴, 텍스트 레이아웃, 날짜, 시간 및 언어를 사용자 정의합니다.", "workplaceName": "작업 공간 이름", "workplaceNamePlaceholder": "작업 공간 이름 입력", "workplaceIcon": "작업 공간 아이콘", "workplaceIconSubtitle": "작업 공간에 대한 이미지를 업로드하거나 이모지를 사용하세요. 아이콘은 사이드바와 알림에 표시됩니다.", "renameError": "작업 공간 이름 변경 실패", "updateIconError": "아이콘 업데이트 실패", "chooseAnIcon": "아이콘 선택", "appearance": { "name": "외관", "themeMode": { "auto": "자동", "light": "라이트", "dark": "다크" }, "language": "언어" } }, "syncState": { "syncing": "동기화 중", "synced": "동기화됨", "noNetworkConnected": "네트워크에 연결되지 않음" } }, "pageStyle": { "title": "페이지 스타일", "layout": "레이아웃", "coverImage": "표지 이미지", "pageIcon": "페이지 아이콘", "colors": "색상", "gradient": "그라데이션", "backgroundImage": "배경 이미지", "presets": "프리셋", "photo": "사진", "unsplash": "Unsplash", "pageCover": "페이지 표지", "none": "없음", "openSettings": "설정 열기", "photoPermissionTitle": "@:appName가 사진 라이브러리에 접근하려고 합니다", "photoPermissionDescription": "@:appName가 문서에 이미지를 추가할 수 있도록 사진에 접근해야 합니다", "cameraPermissionTitle": "@:appName가 카메라에 접근하려고 합니다", "cameraPermissionDescription": "카메라에서 문서에 이미지를 추가하려면 @:appName가 카메라에 액세스할 수 있어야 합니다.", "doNotAllow": "허용하지 않음", "image": "이미지" }, "commandPalette": { "placeholder": "검색하거나 질문하세요...", "bestMatches": "최고의 일치 항목", "recentHistory": "최근 기록", "navigateHint": "탐색하려면", "loadingTooltip": "결과를 찾고 있습니다...", "betaLabel": "베타", "betaTooltip": "현재 문서의 페이지 및 콘텐츠 검색만 지원합니다", "fromTrashHint": "휴지통에서", "noResultsHint": "찾고 있는 항목을 찾지 못했습니다. 다른 용어로 검색해 보세요.", "clearSearchTooltip": "검색 필드 지우기" }, "space": { "delete": "삭제", "deleteConfirmation": "삭제: ", "deleteConfirmationDescription": "이 공간 내의 모든 페이지가 삭제되어 휴지통으로 이동되며, 게시한 모든 페이지가 게시 취소됩니다.", "rename": "공간 이름 변경", "changeIcon": "아이콘 변경", "manage": "공간 관리", "addNewSpace": "새 공간 생성", "collapseAllSubPages": "모든 하위 페이지 접기", "createNewSpace": "새 공간 생성", "createSpaceDescription": "작업을 더 잘 조직하기 위해 여러 공용 및 비공개 공간을 생성하세요.", "spaceName": "공간 이름", "spaceNamePlaceholder": "예: 마케팅, 엔지니어링, 인사", "permission": "공간 권한", "publicPermission": "공용", "publicPermissionDescription": "전체 액세스 권한이 있는 모든 작업 공간 멤버", "privatePermission": "비공개", "privatePermissionDescription": "이 공간에 접근할 수 있는 사람은 본인뿐입니다", "spaceIconBackground": "배경 색상", "spaceIcon": "아이콘", "dangerZone": "위험 구역", "unableToDeleteLastSpace": "마지막 공간을 삭제할 수 없습니다", "unableToDeleteSpaceNotCreatedByYou": "다른 사람이 생성한 공간을 삭제할 수 없습니다", "enableSpacesForYourWorkspace": "작업 공간에 공간을 활성화하세요", "title": "공간", "defaultSpaceName": "일반", "upgradeSpaceTitle": "공간 활성화", "upgradeSpaceDescription": "작업 공간을 더 잘 조직하기 위해 여러 공용 및 비공개 공간을 생성하세요.", "upgrade": "업데이트", "upgradeYourSpace": "여러 공간 생성", "quicklySwitch": "다음 공간으로 빠르게 전환", "duplicate": "공간 복제", "movePageToSpace": "페이지를 공간으로 이동", "cannotMovePageToDatabase": "페이지를 데이터베이스로 이동할 수 없습니다", "switchSpace": "공간 전환", "spaceNameCannotBeEmpty": "공간 이름은 비워둘 수 없습니다", "success": { "deleteSpace": "공간이 성공적으로 삭제되었습니다", "renameSpace": "공간 이름이 성공적으로 변경되었습니다", "duplicateSpace": "공간이 성공적으로 복제되었습니다", "updateSpace": "공간이 성공적으로 업데이트되었습니다" }, "error": { "deleteSpace": "공간 삭제 실패", "renameSpace": "공간 이름 변경 실패", "duplicateSpace": "공간 복제 실패", "updateSpace": "공간 업데이트 실패" }, "createSpace": "공간 생성", "manageSpace": "공간 관리", "renameSpace": "공간 이름 변경", "mSpaceIconColor": "공간 아이콘 색상", "mSpaceIcon": "공간 아이콘" }, "publish": { "hasNotBeenPublished": "이 페이지는 아직 게시되지 않았습니다", "spaceHasNotBeenPublished": "아직 공간 게시를 지원하지 않습니다", "reportPage": "페이지 신고", "databaseHasNotBeenPublished": "데이터베이스 게시를 아직 지원하지 않습니다.", "createdWith": "제작", "downloadApp": "AppFlowy 다운로드", "copy": { "codeBlock": "코드 블록의 내용이 클립보드에 복사되었습니다", "imageBlock": "이미지 링크가 클립보드에 복사되었습니다", "mathBlock": "수학 방정식이 클립보드에 복사되었습니다", "fileBlock": "파일 링크가 클립보드에 복사되었습니다" }, "containsPublishedPage": "이 페이지에는 하나 이상의 게시된 페이지가 포함되어 있습니다. 계속하면 게시가 취소됩니다. 삭제를 진행하시겠습니까?", "publishSuccessfully": "성공적으로 게시되었습니다", "unpublishSuccessfully": "성공적으로 게시 취소되었습니다", "publishFailed": "게시 실패", "unpublishFailed": "게시 취소 실패", "noAccessToVisit": "이 페이지에 접근할 수 없습니다...", "createWithAppFlowy": "AppFlowy로 웹사이트 만들기", "fastWithAI": "AI로 빠르고 쉽게.", "tryItNow": "지금 시도해보세요", "onlyGridViewCanBePublished": "그리드 보기만 게시할 수 있습니다", "database": { "zero": "선택한 {} 보기 게시", "one": "선택한 {} 보기 게시", "many": "선택한 {} 보기 게시", "other": "선택한 {} 보기 게시" }, "mustSelectPrimaryDatabase": "기본 보기를 선택해야 합니다", "noDatabaseSelected": "선택된 데이터베이스가 없습니다. 최소 하나의 데이터베이스를 선택하세요.", "unableToDeselectPrimaryDatabase": "기본 보기를 선택 해제할 수 없습니다", "saveThisPage": "이 템플릿으로 시작", "duplicateTitle": "추가할 위치 선택", "selectWorkspace": "작업 공간 선택", "addTo": "추가", "duplicateSuccessfully": "작업 공간에 추가되었습니다", "duplicateSuccessfullyDescription": "AppFlowy가 설치되어 있지 않습니까? '다운로드'를 클릭하면 다운로드가 자동으로 시작됩니다.", "downloadIt": "다운로드", "openApp": "앱에서 열기", "duplicateFailed": "복제 실패", "membersCount": { "zero": "멤버 없음", "one": "1명의 멤버", "many": "{count}명의 멤버", "other": "{count}명의 멤버" }, "useThisTemplate": "템플릿 사용" }, "web": { "continue": "계속", "or": "또는", "continueWithGoogle": "Google로 계속", "continueWithGithub": "GitHub로 계속", "continueWithDiscord": "Discord로 계속", "continueWithApple": "Apple로 계속", "moreOptions": "더 많은 옵션", "collapse": "접기", "signInAgreement": "\"계속\"을 클릭하면 AppFlowy의", "and": "및", "termOfUse": "이용 약관", "privacyPolicy": "개인정보 보호정책", "signInError": "로그인 오류", "login": "가입 또는 로그인", "fileBlock": { "uploadedAt": "{time}에 업로드됨", "linkedAt": "{time}에 링크 추가됨", "empty": "파일 업로드 또는 삽입", "uploadFailed": "업로드 실패, 다시 시도하세요", "retry": "다시 시도" }, "importNotion": "Notion에서 가져오기", "import": "가져오기", "importSuccess": "성공적으로 업로드되었습니다", "importSuccessMessage": "가져오기가 완료되면 알림을 받게 됩니다. 이후 사이드바에서 가져온 페이지를 확인할 수 있습니다.", "importFailed": "가져오기 실패, 파일 형식을 확인하세요", "dropNotionFile": "Notion zip 파일을 여기에 드롭하여 업로드하거나 클릭하여 찾아보기", "error": { "pageNameIsEmpty": "페이지 이름이 비어 있습니다. 다른 이름을 시도하세요" } }, "globalComment": { "comments": "댓글", "addComment": "댓글 추가", "reactedBy": "반응한 사람", "addReaction": "반응 추가", "reactedByMore": "및 {count}명", "showSeconds": { "one": "1초 전", "other": "{count}초 전", "zero": "방금", "many": "{count}초 전" }, "showMinutes": { "one": "1분 전", "other": "{count}분 전", "many": "{count}분 전" }, "showHours": { "one": "1시간 전", "other": "{count}시간 전", "many": "{count}시간 전" }, "showDays": { "one": "1일 전", "other": "{count}일 전", "many": "{count}일 전" }, "showMonths": { "one": "1개월 전", "other": "{count}개월 전", "many": "{count}개월 전" }, "showYears": { "one": "1년 전", "other": "{count}년 전", "many": "{count}년 전" }, "reply": "답글", "deleteComment": "댓글 삭제", "youAreNotOwner": "이 댓글의 소유자가 아닙니다", "confirmDeleteDescription": "이 댓글을 삭제하시겠습니까?", "hasBeenDeleted": "삭제됨", "replyingTo": "답글 대상", "noAccessDeleteComment": "이 댓글을 삭제할 수 없습니다", "collapse": "접기", "readMore": "더 읽기", "failedToAddComment": "댓글 추가 실패", "commentAddedSuccessfully": "댓글이 성공적으로 추가되었습니다.", "commentAddedSuccessTip": "댓글을 추가하거나 답글을 달았습니다. 최신 댓글을 보려면 상단으로 이동하시겠습니까?" }, "template": { "asTemplate": "템플릿으로 저장", "name": "템플릿 이름", "description": "템플릿 설명", "about": "템플릿 정보", "deleteFromTemplate": "템플릿에서 삭제", "preview": "템플릿 미리보기", "categories": "템플릿 카테고리", "isNewTemplate": "새 템플릿에 고정", "featured": "추천에 고정", "relatedTemplates": "관련 템플릿", "requiredField": "{field}은(는) 필수 항목입니다", "addCategory": "\"{category}\" 추가", "addNewCategory": "새 카테고리 추가", "addNewCreator": "새 제작자 추가", "deleteCategory": "카테고리 삭제", "editCategory": "카테고리 편집", "editCreator": "제작자 편집", "category": { "name": "카테고리 이름", "icon": "카테고리 아이콘", "bgColor": "카테고리 배경 색상", "priority": "카테고리 우선순위", "desc": "카테고리 설명", "type": "카테고리 유형", "icons": "카테고리 아이콘", "colors": "카테고리 색상", "byUseCase": "사용 사례별", "byFeature": "기능별", "deleteCategory": "카테고리 삭제", "deleteCategoryDescription": "이 카테고리를 삭제하시겠습니까?", "typeToSearch": "카테고리 검색..." }, "creator": { "label": "템플릿 제작자", "name": "제작자 이름", "avatar": "제작자 아바타", "accountLinks": "제작자 계정 링크", "uploadAvatar": "아바타 업로드", "deleteCreator": "제작자 삭제", "deleteCreatorDescription": "이 제작자를 삭제하시겠습니까?", "typeToSearch": "제작자 검색..." }, "uploadSuccess": "템플릿이 성공적으로 업로드되었습니다", "uploadSuccessDescription": "템플릿이 성공적으로 업로드되었습니다. 이제 템플릿 갤러리에서 확인할 수 있습니다.", "viewTemplate": "템플릿 보기", "deleteTemplate": "템플릿 삭제", "deleteSuccess": "템플릿이 성공적으로 삭제되었습니다", "deleteTemplateDescription": "현재 페이지나 게시 상태에는 영향을 미치지 않습니다. 이 템플릿을 삭제하시겠습니까?", "addRelatedTemplate": "관련 템플릿 추가", "removeRelatedTemplate": "관련 템플릿 제거", "uploadAvatar": "아바타 업로드", "searchInCategory": "{category}에서 검색", "label": "템플릿" }, "fileDropzone": { "dropFile": "업로드하려면 파일을 클릭하거나 드래그하세요", "uploading": "업로드 중...", "uploadFailed": "업로드 실패", "uploadSuccess": "업로드 성공", "uploadSuccessDescription": "파일이 성공적으로 업로드되었습니다", "uploadFailedDescription": "파일 업로드 실패", "uploadingDescription": "파일이 업로드 중입니다" }, "gallery": { "preview": "전체 화면으로 열기", "copy": "복사", "download": "다운로드", "prev": "이전", "next": "다음", "resetZoom": "확대/축소 재설정", "zoomIn": "확대", "zoomOut": "축소" }, "invitation": { "join": "참여", "on": "에", "invitedBy": "초대자", "membersCount": { "zero": "{count}명의 멤버", "one": "{count}명의 멤버", "many": "{count}명의 멤버", "other": "{count}명의 멤버" }, "tip": "아래 연락처 정보로 이 작업 공간에 참여하도록 초대되었습니다. 정보가 잘못된 경우 관리자에게 연락하여 초대를 다시 보내달라고 요청하세요.", "joinWorkspace": "작업 공간 참여", "success": "작업 공간에 성공적으로 참여했습니다", "successMessage": "이제 작업 공간 내의 모든 페이지와 작업 공간에 접근할 수 있습니다.", "openWorkspace": "AppFlowy 열기", "alreadyAccepted": "이미 초대를 수락했습니다", "errorModal": { "title": "문제가 발생했습니다", "description": "현재 계정 {email}이(가) 이 작업 공간에 접근할 수 없을 수 있습니다. 올바른 계정으로 로그인하거나 작업 공간 소유자에게 도움을 요청하세요.", "contactOwner": "소유자에게 연락", "close": "홈으로 돌아가기", "changeAccount": "계정 변경" } }, "requestAccess": { "title": "이 페이지에 접근할 수 없습니다", "subtitle": "이 페이지의 소유자에게 접근을 요청할 수 있습니다. 승인되면 페이지를 볼 수 있습니다.", "requestAccess": "접근 요청", "backToHome": "홈으로 돌아가기", "tip": "현재 로 로그인 중입니다.", "mightBe": "다른 계정으로 해야 할 수 있습니다.", "successful": "요청이 성공적으로 전송되었습니다", "successfulMessage": "소유자가 요청을 승인하면 알림을 받게 됩니다.", "requestError": "접근 요청 실패", "repeatRequestError": "이미 이 페이지에 접근을 요청했습니다" }, "approveAccess": { "title": "작업 공간 참여 요청 승인", "requestSummary": "이(가) 에 참여하고 에 접근하려고 요청합니다", "upgrade": "업그레이드", "downloadApp": "AppFlowy 다운로드", "approveButton": "승인", "approveSuccess": "성공적으로 승인되었습니다", "approveError": "승인 실패, 작업 공간 플랜 한도를 초과하지 않았는지 확인하세요", "getRequestInfoError": "요청 정보를 가져오지 못했습니다", "memberCount": { "zero": "멤버 없음", "one": "1명의 멤버", "many": "{count}명의 멤버", "other": "{count}명의 멤버" }, "alreadyProTitle": "작업 공간 플랜 한도에 도달했습니다", "alreadyProMessage": "에 연락하여 더 많은 멤버를 잠금 해제하도록 요청하세요", "repeatApproveError": "이미 이 요청을 승인했습니다", "ensurePlanLimit": "작업 공간 플랜 한도를 초과하지 않았는지 확인하세요. 한도를 초과한 경우 작업 공간 플랜을 하거나 를 고려하세요.", "requestToJoin": "참여 요청", "asMember": "멤버로" }, "upgradePlanModal": { "title": "Pro로 업그레이드", "message": "{name}이(가) 무료 멤버 한도에 도달했습니다. 더 많은 멤버를 초대하려면 Pro 플랜으로 업그레이드하세요.", "upgradeSteps": "AppFlowy에서 플랜을 업그레이드하는 방법:", "step1": "1. 설정으로 이동", "step2": "2. '플랜' 클릭", "step3": "3. '플랜 변경' 선택", "appNote": "참고: ", "actionButton": "업그레이드", "downloadLink": "앱 다운로드", "laterButton": "나중에", "refreshNote": "성공적으로 업그레이드한 후 새 기능을 활성화하려면 를 클릭하세요.", "refresh": "여기" }, "breadcrumbs": { "label": "탐색 경로" }, "time": { "justNow": "방금", "seconds": { "one": "1초", "other": "{count}초" }, "minutes": { "one": "1분", "other": "{count}분" }, "hours": { "one": "1시간", "other": "{count}시간" }, "days": { "one": "1일", "other": "{count}일" }, "weeks": { "one": "1주일", "other": "{count}주일" }, "months": { "one": "1개월", "other": "{count}개월" }, "years": { "one": "1년", "other": "{count}년" }, "ago": "전", "yesterday": "어제", "today": "오늘" }, "members": { "zero": "멤버 없음", "one": "1명의 멤버", "many": "{count}명의 멤버", "other": "{count}명의 멤버" }, "tabMenu": { "close": "닫기", "closeDisabledHint": "고정된 탭은 닫을 수 없습니다. 먼저 고정을 해제하세요", "closeOthers": "다른 탭 닫기", "closeOthersHint": "이 탭을 제외한 모든 고정되지 않은 탭을 닫습니다", "closeOthersDisabledHint": "모든 탭이 고정되어 있어 닫을 탭을 찾을 수 없습니다", "favorite": "즐겨찾기", "unfavorite": "즐겨찾기 해제", "favoriteDisabledHint": "이 보기를 즐겨찾기에 추가할 수 없습니다", "pinTab": "고정", "unpinTab": "고정 해제" }, "openFileMessage": { "success": "파일이 성공적으로 열렸습니다", "fileNotFound": "파일을 찾을 수 없습니다", "noAppToOpenFile": "이 파일을 열 수 있는 앱이 없습니다", "permissionDenied": "이 파일을 열 수 있는 권한이 없습니다", "unknownError": "파일 열기 실패" }, "inviteMember": { "requestInviteMembers": "작업 공간에 초대", "inviteFailedMemberLimit": "멤버 한도에 도달했습니다. ", "upgrade": "업그레이드", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "초대 보내기", "inviteAlready": "이미 이 이메일을 초대했습니다: {email}", "inviteSuccess": "초대가 성공적으로 전송되었습니다", "description": "아래에 이메일을 쉼표로 구분하여 입력하세요. 멤버 수에 따라 요금이 부과됩니다.", "emails": "이메일" }, "quickNote": { "label": "빠른 노트", "quickNotes": "빠른 노트", "search": "빠른 노트 검색", "collapseFullView": "전체 보기 축소", "expandFullView": "전체 보기 확장", "createFailed": "빠른 노트 생성 실패", "quickNotesEmpty": "빠른 노트 없음", "emptyNote": "빈 노트", "deleteNotePrompt": "선택한 노트가 영구적으로 삭제됩니다. 삭제하시겠습니까?", "addNote": "새 노트", "noAdditionalText": "추가 텍스트 없음" }, "subscribe": { "upgradePlanTitle": "플랜 비교 및 선택", "yearly": "연간", "save": "{discount}% 절약", "monthly": "월별", "priceIn": "가격 ", "free": "무료", "pro": "Pro", "freeDescription": "모든 것을 정리하기 위한 최대 2명의 개인용", "proDescription": "프로젝트 및 팀 지식을 관리하기 위한 소규모 팀용", "proDuration": { "monthly": "월별 청구되는 멤버당 월별", "yearly": "연간 청구되는 멤버당 월별" }, "cancel": "다운그레이드", "changePlan": "Pro 플랜으로 업그레이드", "everythingInFree": "무료 플랜의 모든 기능 +", "currentPlan": "현재", "freeDuration": "영원히", "freePoints": { "first": "최대 2명의 협업 작업 공간", "second": "무제한 페이지 및 블록", "three": "5 GB 저장 공간", "four": "지능형 검색", "five": "20 AI 응답", "six": "모바일 앱", "seven": "실시간 협업" }, "proPoints": { "first": "무제한 저장 공간", "second": "최대 10명의 작업 공간 멤버", "three": "무제한 AI 응답", "four": "무제한 파일 업로드", "five": "맞춤 네임스페이스" }, "cancelPlan": { "title": "떠나셔서 아쉽습니다", "success": "구독이 성공적으로 취소되었습니다", "description": "@:appName을 개선하는 데 도움이 되도록 피드백을 듣고 싶습니다. 몇 가지 질문에 답변해 주세요.", "commonOther": "기타", "otherHint": "여기에 답변을 작성하세요", "questionOne": { "question": "@:appName Pro 구독을 취소한 이유는 무엇입니까?", "answerOne": "비용이 너무 높음", "answerTwo": "기능이 기대에 미치지 못함", "answerThree": "더 나은 대안을 찾음", "answerFour": "비용을 정당화할 만큼 충분히 사용하지 않음", "answerFive": "서비스 문제 또는 기술적 어려움" }, "questionTwo": { "question": "미래에 @:appName Pro를 다시 구독할 가능성은 얼마나 됩니까?", "answerOne": "매우 가능성이 높음", "answerTwo": "어느 정도 가능성이 있음", "answerThree": "잘 모르겠음", "answerFour": "가능성이 낮음", "answerFive": "매우 가능성이 낮음" }, "questionThree": { "question": "구독 기간 동안 가장 가치 있게 여긴 Pro 기능은 무엇입니까?", "answerOne": "다중 사용자 협업", "answerTwo": "더 긴 시간 버전 기록", "answerThree": "무제한 AI 응답", "answerFour": "로컬 AI 모델 액세스" }, "questionFour": { "question": "@:appName에 대한 전반적인 경험을 어떻게 설명하시겠습니까?", "answerOne": "훌륭함", "answerTwo": "좋음", "answerThree": "보통", "answerFour": "평균 이하", "answerFive": "불만족" } } }, "ai": { "contentPolicyViolation": "민감한 콘텐츠로 인해 이미지 생성에 실패했습니다. 입력을 다시 작성하고 다시 시도하세요", "textLimitReachedDescription": "작업 공간의 무료 AI 응답이 부족합니다. Pro 플랜으로 업그레이드하거나 AI 애드온을 구매하여 무제한 응답을 잠금 해제하세요", "imageLimitReachedDescription": "무료 AI 이미지 할당량을 모두 사용했습니다. Pro 플랜으로 업그레이드하거나 AI 애드온을 구매하여 무제한 응답을 잠금 해제하세요", "limitReachedAction": { "textDescription": "작업 공간의 무료 AI 응답이 부족합니다. 더 많은 응답을 받으려면 ", "imageDescription": "무료 AI 이미지 할당량을 모두 사용했습니다. ", "upgrade": "업그레이드", "toThe": " ", "proPlan": "Pro 플랜", "orPurchaseAn": " 또는 ", "aiAddon": "AI 애드온을 구매하세요" }, "editing": "편집 중", "analyzing": "분석 중", "continueWritingEmptyDocumentTitle": "계속 작성 오류", "continueWritingEmptyDocumentDescription": "문서의 내용을 확장하는 데 문제가 있습니다. 간단한 소개를 작성하면 나머지는 우리가 처리할 수 있습니다!" }, "autoUpdate": { "criticalUpdateTitle": "계속하려면 업데이트가 필요합니다", "criticalUpdateDescription": "경험을 향상시키기 위해 개선 사항을 추가했습니다! 앱을 계속 사용하려면 {currentVersion}에서 {newVersion}으로 업데이트하세요.", "criticalUpdateButton": "업데이트", "bannerUpdateTitle": "새 버전 사용 가능!", "bannerUpdateDescription": "최신 기능 및 수정 사항을 받으세요. 지금 설치하려면 \"업데이트\"를 클릭하세요", "bannerUpdateButton": "업데이트", "settingsUpdateTitle": "새 버전 ({newVersion}) 사용 가능!", "settingsUpdateDescription": "현재 버전: {currentVersion} (공식 빌드) → {newVersion}", "settingsUpdateButton": "업데이트", "settingsUpdateWhatsNew": "새로운 기능" }, "lockPage": { "lockPage": "잠금", "reLockPage": "다시 잠금", "lockTooltip": "실수로 편집하지 않도록 페이지가 잠겨 있습니다. 잠금 해제하려면 클릭하세요.", "pageLockedToast": "페이지가 잠겼습니다. 누군가 잠금을 해제할 때까지 편집이 비활성화됩니다.", "lockedOperationTooltip": "실수로 편집하지 않도록 페이지가 잠겨 있습니다." }, "suggestion": { "accept": "수락", "keep": "유지", "discard": "버리기", "close": "닫기", "tryAgain": "다시 시도", "rewrite": "다시 작성", "insertBelow": "아래에 삽입" } } ================================================ FILE: frontend/resources/translations/mr-IN.json ================================================ { "appName": "AppFlowy", "defaultUsername": "मी", "welcomeText": "@:appName मध्ये आ पले स्वागत आहे.", "welcomeTo": "मध्ये आ पले स्वागत आ हे", "githubStarText": "GitHub वर स्टार करा", "subscribeNewsletterText": "वृत्तपत्राची सदस्यता घ्या", "letsGoButtonText": "क्विक स्टार्ट", "title": "Title", "youCanAlso": "तुम्ही देखील", "and": "आ णि", "failedToOpenUrl": "URL उघडण्यात अयशस्वी: {}", "blockActions": { "addBelowTooltip": "खाली जोडण्यासाठी क्लिक करा", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "वर जोडण्यासाठी", "dragTooltip": "Drag to move", "openMenuTooltip": "मेनू उघडण्यासाठी क्लिक करा" }, "signUp": { "buttonText": "साइन अप", "title": "साइन अप to @:appName", "getStartedText": "सुरुवात करा", "emptyPasswordError": "पासवर्ड रिकामा असू शकत नाही", "repeatPasswordEmptyError": "Repeat पासवर्ड रिकामा असू शकत नाही", "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", "alreadyHaveAnAccount": "आधीच खाते आहे?", "emailHint": "Email", "passwordHint": "Password", "repeatPasswordHint": "पासवर्ड पुन्हा लिहा", "signUpWith": "यामध्ये साइन अप करा:" }, "signIn": { "loginTitle": "@:appName मध्ये लॉगिन करा", "loginButtonText": "लॉगिन", "loginStartWithAnonymous": "अनामिक सत्रासह पुढे जा", "continueAnonymousUser": "अनामिक सत्रासह पुढे जा", "anonymous": "अनामिक", "buttonText": "साइन इन", "signingInText": "साइन इन होत आहे...", "forgotPassword": "पासवर्ड विसरलात?", "emailHint": "ईमेल", "passwordHint": "पासवर्ड", "dontHaveAnAccount": "तुमचं खाते नाही?", "createAccount": "खाते तयार करा", "repeatPasswordEmptyError": "पुन्हा पासवर्ड रिकामा असू शकत नाही", "unmatchedPasswordError": "पुन्हा लिहिलेला पासवर्ड मूळ पासवर्डशी जुळत नाही", "syncPromptMessage": "डेटा सिंक होण्यास थोडा वेळ लागू शकतो. कृपया हे पृष्ठ बंद करू नका", "or": "किंवा", "signInWithGoogle": "Google सह पुढे जा", "signInWithGithub": "GitHub सह पुढे जा", "signInWithDiscord": "Discord सह पुढे जा", "signInWithApple": "Apple सह पुढे जा", "continueAnotherWay": "इतर पर्यायांनी पुढे जा", "signUpWithGoogle": "Google सह साइन अप करा", "signUpWithGithub": "GitHub सह साइन अप करा", "signUpWithDiscord": "Discord सह साइन अप करा", "signInWith": "यासह पुढे जा:", "signInWithEmail": "ईमेलसह पुढे जा", "signInWithMagicLink": "पुढे जा", "signUpWithMagicLink": "Magic Link सह साइन अप करा", "pleaseInputYourEmail": "कृपया तुमचा ईमेल पत्ता टाका", "settings": "सेटिंग्ज", "magicLinkSent": "Magic Link पाठवण्यात आली आहे!", "invalidEmail": "कृपया वैध ईमेल पत्ता टाका", "alreadyHaveAnAccount": "आधीच खाते आहे?", "logIn": "लॉगिन", "generalError": "काहीतरी चुकलं. कृपया नंतर प्रयत्न करा", "limitRateError": "सुरक्षेच्या कारणास्तव, तुम्ही दर ६० सेकंदांतून एकदाच Magic Link मागवू शकता", "magicLinkSentDescription": "तुमच्या ईमेलवर Magic Link पाठवण्यात आली आहे. लॉगिन पूर्ण करण्यासाठी लिंकवर क्लिक करा. ही लिंक ५ मिनिटांत कालबाह्य होईल." }, "workspace": { "chooseWorkspace": "तुमचे workspace निवडा", "defaultName": "माझे Workspace", "create": "नवीन workspace तयार करा", "new": "नवीन workspace", "importFromNotion": "Notion मधून आयात करा", "learnMore": "अधिक जाणून घ्या", "reset": "workspace रीसेट करा", "renameWorkspace": "workspace चे नाव बदला", "workspaceNameCannotBeEmpty": "workspace चे नाव रिकामे असू शकत नाही", "resetWorkspacePrompt": "workspace रीसेट केल्यास त्यातील सर्व पृष्ठे आणि डेटा हटवले जातील. तुम्हाला workspace रीसेट करायचे आहे का? पर्यायी म्हणून तुम्ही सपोर्ट टीमशी संपर्क करू शकता.", "hint": "workspace", "notFoundError": "workspace सापडले नाही", "failedToLoad": "काहीतरी चूक झाली! workspace लोड होण्यात अयशस्वी. कृपया @:appName चे कोणतेही उघडे instance बंद करा आणि पुन्हा प्रयत्न करा.", "errorActions": { "reportIssue": "समस्या नोंदवा", "reportIssueOnGithub": "Github वर समस्या नोंदवा", "exportLogFiles": "लॉग फाइल्स निर्यात करा", "reachOut": "Discord वर संपर्क करा" }, "menuTitle": "कार्यक्षेत्रे", "deleteWorkspaceHintText": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील.", "createSuccess": "कार्यक्षेत्र यशस्वीरित्या तयार झाले", "createFailed": "कार्यक्षेत्र तयार करण्यात अयशस्वी", "createLimitExceeded": "तुम्ही तुमच्या खात्यासाठी परवानगी दिलेल्या कार्यक्षेत्र मर्यादेपर्यंत पोहोचलात. अधिक कार्यक्षेत्रासाठी GitHub वर विनंती करा.", "deleteSuccess": "कार्यक्षेत्र यशस्वीरित्या हटवले गेले", "deleteFailed": "कार्यक्षेत्र हटवण्यात अयशस्वी", "openSuccess": "कार्यक्षेत्र यशस्वीरित्या उघडले", "openFailed": "कार्यक्षेत्र उघडण्यात अयशस्वी", "renameSuccess": "कार्यक्षेत्राचे नाव यशस्वीरित्या बदलले", "renameFailed": "कार्यक्षेत्राचे नाव बदलण्यात अयशस्वी", "updateIconSuccess": "कार्यक्षेत्राचे चिन्ह यशस्वीरित्या अद्यतनित केले", "updateIconFailed": "कार्यक्षेत्राचे चिन्ह अद्यतनित करण्यात अयशस्वी", "cannotDeleteTheOnlyWorkspace": "फक्त एकच कार्यक्षेत्र असल्यास ते हटवता येत नाही", "fetchWorkspacesFailed": "कार्यक्षेत्रे मिळवण्यात अयशस्वी", "leaveCurrentWorkspace": "कार्यक्षेत्र सोडा", "leaveCurrentWorkspacePrompt": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का?" }, "shareAction": { "buttonText": "शेअर करा", "workInProgress": "लवकरच येत आहे", "markdown": "Markdown", "html": "HTML", "clipboard": "क्लिपबोर्डवर कॉपी करा", "csv": "CSV", "copyLink": "लिंक कॉपी करा", "publishToTheWeb": "वेबवर प्रकाशित करा", "publishToTheWebHint": "AppFlowy सह वेबसाइट तयार करा", "publish": "प्रकाशित करा", "unPublish": "अप्रकाशित करा", "visitSite": "साइटला भेट द्या", "exportAsTab": "या स्वरूपात निर्यात करा", "publishTab": "प्रकाशित करा", "shareTab": "शेअर करा", "publishOnAppFlowy": "AppFlowy वर प्रकाशित करा", "shareTabTitle": "सहकार्य करण्यासाठी आमंत्रित करा", "shareTabDescription": "कोणासही सहज सहकार्य करण्यासाठी", "copyLinkSuccess": "लिंक क्लिपबोर्डवर कॉपी केली", "copyShareLink": "शेअर लिंक कॉपी करा", "copyLinkFailed": "लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", "copyLinkToBlockSuccess": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी केली", "copyLinkToBlockFailed": "ब्लॉकची लिंक क्लिपबोर्डवर कॉपी करण्यात अयशस्वी", "manageAllSites": "सर्व साइट्स व्यवस्थापित करा", "updatePathName": "पथाचे नाव अपडेट करा" }, "moreAction": { "small": "लहान", "medium": "मध्यम", "large": "मोठा", "fontSize": "फॉन्ट आकार", "import": "Import", "moreOptions": "अधिक पर्याय", "wordCount": "शब्द संख्या: {}", "charCount": "अक्षर संख्या: {}", "createdAt": "निर्मिती: {}", "deleteView": "हटवा", "duplicateView": "प्रत बनवा", "wordCountLabel": "शब्द संख्या: ", "charCountLabel": "अक्षर संख्या: ", "createdAtLabel": "निर्मिती: ", "syncedAtLabel": "सिंक केले: ", "saveAsNewPage": "संदेश पृष्ठात जोडा", "saveAsNewPageDisabled": "उपलब्ध संदेश नाहीत" }, "importPanel": { "textAndMarkdown": "मजकूर आणि Markdown", "documentFromV010": "v0.1.0 पासून दस्तऐवज", "databaseFromV010": "v0.1.0 पासून डेटाबेस", "notionZip": "Notion निर्यात केलेली Zip फाईल", "csv": "CSV", "database": "डेटाबेस" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "फाईल ड्रॅग आणि ड्रॉप करा, क्लिक करा ", "placeholderUpload": "अपलोड", "placeholderRight": ", किंवा इमेज लिंक पेस्ट करा.", "dropToUpload": "अपलोडसाठी फाईल ड्रॉप करा", "change": "बदला" } }, "disclosureAction": { "rename": "नाव बदला", "delete": "हटवा", "duplicate": "प्रत बनवा", "unfavorite": "आवडतीतून काढा", "favorite": "आवडतीत जोडा", "openNewTab": "नवीन टॅबमध्ये उघडा", "moveTo": "या ठिकाणी हलवा", "addToFavorites": "आवडतीत जोडा", "copyLink": "लिंक कॉपी करा", "changeIcon": "आयकॉन बदला", "collapseAllPages": "सर्व उपपृष्ठे संकुचित करा", "movePageTo": "पृष्ठ हलवा", "move": "हलवा", "lockPage": "पृष्ठ लॉक करा" }, "blankPageTitle": "रिक्त पृष्ठ", "newPageText": "नवीन पृष्ठ", "newDocumentText": "नवीन दस्तऐवज", "newGridText": "नवीन ग्रिड", "newCalendarText": "नवीन कॅलेंडर", "newBoardText": "नवीन बोर्ड", "chat": { "newChat": "AI गप्पा", "inputMessageHint": "@:appName AI ला विचार करा", "inputLocalAIMessageHint": "@:appName लोकल AI ला विचार करा", "unsupportedCloudPrompt": "ही सुविधा फक्त @:appName Cloud वापरताना उपलब्ध आहे", "relatedQuestion": "सूचवलेले", "serverUnavailable": "कनेक्शन गमावले. कृपया तुमचे इंटरनेट तपासा आणि पुन्हा प्रयत्न करा", "aiServerUnavailable": "AI सेवा सध्या अनुपलब्ध आहे. कृपया नंतर पुन्हा प्रयत्न करा.", "retry": "पुन्हा प्रयत्न करा", "clickToRetry": "पुन्हा प्रयत्न करण्यासाठी क्लिक करा", "regenerateAnswer": "उत्तर पुन्हा तयार करा", "question1": "Kanban वापरून कामे कशी व्यवस्थापित करायची", "question2": "GTD पद्धत समजावून सांगा", "question3": "Rust का वापरावा", "question4": "माझ्या स्वयंपाकघरात असलेल्या वस्तूंनी रेसिपी", "question5": "माझ्या पृष्ठासाठी एक चित्र तयार करा", "question6": "या आठवड्याची माझी कामांची यादी तयार करा", "aiMistakePrompt": "AI चुकू शकतो. महत्त्वाची माहिती तपासा.", "chatWithFilePrompt": "तुम्हाला फाइलसोबत गप्पा मारायच्या आहेत का?", "indexFileSuccess": "फाईल यशस्वीरित्या अनुक्रमित केली गेली", "inputActionNoPages": "काहीही पृष्ठे सापडली नाहीत", "referenceSource": { "zero": "0 स्रोत सापडले", "one": "{count} स्रोत सापडला", "other": "{count} स्रोत सापडले" } }, "clickToMention": "पृष्ठाचा उल्लेख करा", "uploadFile": "PDFs, मजकूर किंवा markdown फाइल्स जोडा", "questionDetail": "नमस्कार {}! मी तुम्हाला कशी मदत करू शकतो?", "indexingFile": "{} अनुक्रमित करत आहे", "generatingResponse": "उत्तर तयार होत आहे", "selectSources": "स्रोत निवडा", "currentPage": "सध्याचे पृष्ठ", "sourcesLimitReached": "तुम्ही फक्त ३ मुख्य दस्तऐवज आणि त्यांची उपदस्तऐवज निवडू शकता", "sourceUnsupported": "सध्या डेटाबेससह चॅटिंगसाठी आम्ही समर्थन करत नाही", "regenerate": "पुन्हा प्रयत्न करा", "addToPageButton": "संदेश पृष्ठावर जोडा", "addToPageTitle": "या पृष्ठात संदेश जोडा...", "addToNewPage": "नवीन पृष्ठ तयार करा", "addToNewPageName": "\"{}\" मधून काढलेले संदेश", "addToNewPageSuccessToast": "संदेश जोडण्यात आला", "openPagePreviewFailedToast": "पृष्ठ उघडण्यात अयशस्वी", "changeFormat": { "actionButton": "फॉरमॅट बदला", "confirmButton": "या फॉरमॅटसह पुन्हा तयार करा", "textOnly": "मजकूर", "imageOnly": "फक्त प्रतिमा", "textAndImage": "मजकूर आणि प्रतिमा", "text": "परिच्छेद", "bullet": "बुलेट यादी", "number": "क्रमांकित यादी", "table": "सारणी", "blankDescription": "उत्तराचे फॉरमॅट ठरवा", "defaultDescription": "स्वयंचलित उत्तर फॉरमॅट", "textWithImageDescription": "@:chat.changeFormat.text प्रतिमेसह", "numberWithImageDescription": "@:chat.changeFormat.number प्रतिमेसह", "bulletWithImageDescription": "@:chat.changeFormat.bullet प्रतिमेसह", " tableWithImageDescription": "@:chat.changeFormat.table प्रतिमेसह" }, "switchModel": { "label": "मॉडेल बदला", "localModel": "स्थानिक मॉडेल", "cloudModel": "क्लाऊड मॉडेल", "autoModel": "स्वयंचलित" }, "selectBanner": { "saveButton": "… मध्ये जोडा", "selectMessages": "संदेश निवडा", "nSelected": "{} निवडले गेले", "allSelected": "सर्व निवडले गेले" }, "stopTooltip": "उत्पन्न करणे थांबवा", "trash": { "text": "कचरा", "restoreAll": "सर्व पुनर्संचयित करा", "restore": "पुनर्संचयित करा", "deleteAll": "सर्व हटवा", "pageHeader": { "fileName": "फाईलचे नाव", "lastModified": "शेवटचा बदल", "created": "निर्मिती" } }, "confirmDeleteAll": { "title": "कचरापेटीतील सर्व पृष्ठे", "caption": "तुम्हाला कचरापेटीतील सर्व काही हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." }, "confirmRestoreAll": { "title": "कचरापेटीतील सर्व पृष्ठे पुनर्संचयित करा", "caption": "ही कृती पूर्ववत केली जाऊ शकत नाही." }, "restorePage": { "title": "पुनर्संचयित करा: {}", "caption": "तुम्हाला हे पृष्ठ पुनर्संचयित करायचे आहे का?" }, "mobile": { "actions": "कचरा क्रिया", "empty": "कचरापेटीत कोणतीही पृष्ठे किंवा जागा नाहीत", "emptyDescription": "जे आवश्यक नाही ते कचरापेटीत हलवा.", "isDeleted": "हटवले गेले आहे", "isRestored": "पुनर्संचयित केले गेले आहे" }, "confirmDeleteTitle": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का?", "deletePagePrompt": { "text": "हे पृष्ठ कचरापेटीत आहे", "restore": "पृष्ठ पुनर्संचयित करा", "deletePermanent": "कायमचे हटवा", "deletePermanentDescription": "तुम्हाला हे पृष्ठ कायमचे हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही." }, "dialogCreatePageNameHint": "पृष्ठाचे नाव", "questionBubble": { "shortcuts": "शॉर्टकट्स", "whatsNew": "नवीन काय आहे?", "help": "मदत आणि समर्थन", "markdown": "Markdown", "debug": { "name": "डीबग माहिती", "success": "डीबग माहिती क्लिपबोर्डवर कॉपी केली!", "fail": "डीबग माहिती कॉपी करता आली नाही" }, "feedback": "अभिप्राय" }, "menuAppHeader": { "moreButtonToolTip": "हटवा, नाव बदला आणि अधिक...", "addPageTooltip": "तत्काळ एक पृष्ठ जोडा", "defaultNewPageName": "शीर्षक नसलेले", "renameDialog": "नाव बदला", "pageNameSuffix": "प्रत" }, "noPagesInside": "अंदर कोणतीही पृष्ठे नाहीत", "toolbar": { "undo": "पूर्ववत करा", "redo": "पुन्हा करा", "bold": "ठळक", "italic": "तिरकस", "underline": "अधोरेखित", "strike": "मागे ओढलेले", "numList": "क्रमांकित यादी", "bulletList": "बुलेट यादी", "checkList": "चेक यादी", "inlineCode": "इनलाइन कोड", "quote": "उद्धरण ब्लॉक", "header": "शीर्षक", "highlight": "हायलाइट", "color": "रंग", "addLink": "लिंक जोडा" }, "tooltip": { "lightMode": "लाइट मोडमध्ये स्विच करा", "darkMode": "डार्क मोडमध्ये स्विच करा", "openAsPage": "पृष्ठ म्हणून उघडा", "addNewRow": "नवीन पंक्ती जोडा", "openMenu": "मेनू उघडण्यासाठी क्लिक करा", "dragRow": "पंक्तीचे स्थान बदलण्यासाठी ड्रॅग करा", "viewDataBase": "डेटाबेस पहा", "referencePage": "हे {name} संदर्भित आहे", "addBlockBelow": "खाली एक ब्लॉक जोडा", "aiGenerate": "निर्मिती करा" }, "sideBar": { "closeSidebar": "साइडबार बंद करा", "openSidebar": "साइडबार उघडा", "expandSidebar": "पूर्ण पृष्ठावर विस्तारित करा", "personal": "वैयक्तिक", "private": "खाजगी", "workspace": "कार्यक्षेत्र", "favorites": "आवडती", "clickToHidePrivate": "खाजगी जागा लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने फक्त तुम्हाला दिसतील", "clickToHideWorkspace": "कार्यक्षेत्र लपवण्यासाठी क्लिक करा\nयेथे तयार केलेली पाने सर्व सदस्यांना दिसतील", "clickToHidePersonal": "वैयक्तिक जागा लपवण्यासाठी क्लिक करा", "clickToHideFavorites": "आवडती जागा लपवण्यासाठी क्लिक करा", "addAPage": "नवीन पृष्ठ जोडा", "addAPageToPrivate": "खाजगी जागेत पृष्ठ जोडा", "addAPageToWorkspace": "कार्यक्षेत्रात पृष्ठ जोडा", "recent": "अलीकडील", "today": "आज", "thisWeek": "या आठवड्यात", "others": "पूर्वीच्या आवडती", "earlier": "पूर्वीचे", "justNow": "आत्ताच", "minutesAgo": "{count} मिनिटांपूर्वी", "lastViewed": "शेवटी पाहिलेले", "favoriteAt": "आवडते म्हणून चिन्हांकित", "emptyRecent": "अलीकडील पृष्ठे नाहीत", "emptyRecentDescription": "तुम्ही पाहिलेली पृष्ठे येथे सहज पुन्हा मिळवण्यासाठी दिसतील.", "emptyFavorite": "आवडती पृष्ठे नाहीत", "emptyFavoriteDescription": "पृष्ठांना आवडते म्हणून चिन्हांकित करा—ते येथे झपाट्याने प्रवेशासाठी दिसतील!", "removePageFromRecent": "हे पृष्ठ अलीकडील यादीतून काढायचे?", "removeSuccess": "यशस्वीरित्या काढले गेले", "favoriteSpace": "आवडती", "RecentSpace": "अलीकडील", "Spaces": "जागा", "upgradeToPro": "Pro मध्ये अपग्रेड करा", "upgradeToAIMax": "अमर्यादित AI अनलॉक करा", "storageLimitDialogTitle": "तुमचा मोफत स्टोरेज संपला आहे. अमर्यादित स्टोरेजसाठी अपग्रेड करा", "storageLimitDialogTitleIOS": "तुमचा मोफत स्टोरेज संपला आहे.", "aiResponseLimitTitle": "तुमचे मोफत AI प्रतिसाद संपले आहेत. कृपया Pro प्लॅनला अपग्रेड करा किंवा AI add-on खरेदी करा", "aiResponseLimitDialogTitle": "AI प्रतिसाद मर्यादा गाठली आहे", "aiResponseLimit": "तुमचे मोफत AI प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max किंवा Pro प्लॅन क्लिक करा", "askOwnerToUpgradeToPro": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे. कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा", "askOwnerToUpgradeToProIOS": "तुमच्या कार्यक्षेत्राचे मोफत स्टोरेज संपत चालले आहे.", "askOwnerToUpgradeToAIMax": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला प्लॅन अपग्रेड किंवा AI add-ons खरेदी करण्यास सांगा", "askOwnerToUpgradeToAIMaxIOS": "तुमच्या कार्यक्षेत्राचे मोफत AI प्रतिसाद संपले आहेत.", "purchaseAIMax": "तुमच्या कार्यक्षेत्राचे AI प्रतिमा प्रतिसाद संपले आहेत. कृपया कार्यक्षेत्र मालकाला AI Max खरेदी करण्यास सांगा", "aiImageResponseLimit": "तुमचे AI प्रतिमा प्रतिसाद संपले आहेत.\n\nसेटिंग्ज -> प्लॅन -> AI Max क्लिक करा", "purchaseStorageSpace": "स्टोरेज स्पेस खरेदी करा", "singleFileProPlanLimitationDescription": "तुम्ही मोफत प्लॅनमध्ये परवानगी दिलेल्या फाइल अपलोड आकाराची मर्यादा ओलांडली आहे. कृपया Pro प्लॅनमध्ये अपग्रेड करा", "purchaseAIResponse": "AI प्रतिसाद खरेदी करा", "askOwnerToUpgradeToLocalAI": "कार्यक्षेत्र मालकाला ऑन-डिव्हाइस AI सक्षम करण्यास सांगा", "upgradeToAILocal": "अत्याधिक गोपनीयतेसाठी तुमच्या डिव्हाइसवर लोकल मॉडेल चालवा", "upgradeToAILocalDesc": "PDFs सोबत गप्पा मारा, तुमचे लेखन सुधारा आणि लोकल AI वापरून टेबल्स आपोआप भरा" }, "notifications": { "export": { "markdown": "टीप Markdown मध्ये निर्यात केली", "path": "Documents/flowy" } }, "contactsPage": { "title": "संपर्क", "whatsHappening": "या आठवड्यात काय घडत आहे?", "addContact": "संपर्क जोडा", "editContact": "संपर्क संपादित करा" }, "button": { "ok": "ठीक आहे", "confirm": "खात्री करा", "done": "पूर्ण", "cancel": "रद्द करा", "signIn": "साइन इन", "signOut": "साइन आउट", "complete": "पूर्ण करा", "save": "जतन करा", "generate": "निर्माण करा", "esc": "ESC", "keep": "ठेवा", "tryAgain": "पुन्हा प्रयत्न करा", "discard": "टाका", "replace": "बदला", "insertBelow": "खाली घाला", "insertAbove": "वर घाला", "upload": "अपलोड करा", "edit": "संपादित करा", "delete": "हटवा", "copy": "कॉपी करा", "duplicate": "प्रत बनवा", "putback": "परत ठेवा", "update": "अद्यतनित करा", "share": "शेअर करा", "removeFromFavorites": "आवडतीतून काढा", "removeFromRecent": "अलीकडील यादीतून काढा", "addToFavorites": "आवडतीत जोडा", "favoriteSuccessfully": "आवडतीत यशस्वीरित्या जोडले", "unfavoriteSuccessfully": "आवडतीतून यशस्वीरित्या काढले", "duplicateSuccessfully": "प्रत यशस्वीरित्या तयार झाली", "rename": "नाव बदला", "helpCenter": "मदत केंद्र", "add": "जोड़ा", "yes": "होय", "no": "नाही", "clear": "साफ करा", "remove": "काढा", "dontRemove": "काढू नका", "copyLink": "लिंक कॉपी करा", "align": "जुळवा", "login": "लॉगिन", "logout": "लॉगआउट", "deleteAccount": "खाते हटवा", "back": "मागे", "signInGoogle": "Google सह पुढे जा", "signInGithub": "GitHub सह पुढे जा", "signInDiscord": "Discord सह पुढे जा", "more": "अधिक", "create": "तयार करा", "close": "बंद करा", "next": "पुढे", "previous": "मागील", "submit": "सबमिट करा", "download": "डाउनलोड करा", "backToHome": "मुख्यपृष्ठावर परत जा", "viewing": "पाहत आहात", "editing": "संपादन करत आहात", "gotIt": "समजले", "retry": "पुन्हा प्रयत्न करा", "uploadFailed": "अपलोड अयशस्वी.", "copyLinkOriginal": "मूळ दुव्याची कॉपी करा" }, "label": { "welcome": "स्वागत आहे!", "firstName": "पहिले नाव", "middleName": "मधले नाव", "lastName": "आडनाव", "stepX": "पायरी {X}" }, "oAuth": { "err": { "failedTitle": "तुमच्या खात्याशी कनेक्ट होता आले नाही.", "failedMsg": "कृपया खात्री करा की तुम्ही ब्राउझरमध्ये साइन-इन प्रक्रिया पूर्ण केली आहे." }, "google": { "title": "GOOGLE साइन-इन", "instruction1": "तुमचे Google Contacts आयात करण्यासाठी, तुम्हाला तुमच्या वेब ब्राउझरचा वापर करून या अ‍ॅप्लिकेशनला अधिकृत करणे आवश्यक आहे.", "instruction2": "ही कोड आयकॉनवर क्लिक करून किंवा मजकूर निवडून क्लिपबोर्डवर कॉपी करा:", "instruction3": "तुमच्या वेब ब्राउझरमध्ये खालील दुव्यावर जा आणि वरील कोड टाका:", "instruction4": "साइनअप पूर्ण झाल्यावर खालील बटण क्लिक करा:" } }, "settings": { "title": "सेटिंग्ज", "popupMenuItem": { "settings": "सेटिंग्ज", "members": "सदस्य", "trash": "कचरा", "helpAndSupport": "मदत आणि समर्थन" }, "sites": { "title": "साइट्स", "namespaceTitle": "नेमस्पेस", "namespaceDescription": "तुमचा नेमस्पेस आणि मुख्यपृष्ठ व्यवस्थापित करा", "namespaceHeader": "नेमस्पेस", "homepageHeader": "मुख्यपृष्ठ", "updateNamespace": "नेमस्पेस अद्यतनित करा", "removeHomepage": "मुख्यपृष्ठ हटवा", "selectHomePage": "एक पृष्ठ निवडा", "clearHomePage": "या नेमस्पेससाठी मुख्यपृष्ठ साफ करा", "customUrl": "स्वतःची URL", "namespace": { "description": "हे बदल सर्व प्रकाशित पृष्ठांवर लागू होतील जे या नेमस्पेसवर चालू आहेत", "tooltip": "कोणताही अनुचित नेमस्पेस आम्ही काढून टाकण्याचा अधिकार राखून ठेवतो", "updateExistingNamespace": "विद्यमान नेमस्पेस अद्यतनित करा", "upgradeToPro": "मुख्यपृष्ठ सेट करण्यासाठी Pro प्लॅनमध्ये अपग्रेड करा", "redirectToPayment": "पेमेंट पृष्ठावर वळवत आहे...", "onlyWorkspaceOwnerCanSetHomePage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ सेट करू शकतो", "pleaseAskOwnerToSetHomePage": "कृपया कार्यक्षेत्र मालकाला Pro प्लॅनमध्ये अपग्रेड करण्यास सांगा" }, "publishedPage": { "title": "सर्व प्रकाशित पृष्ठे", "description": "तुमची प्रकाशित पृष्ठे व्यवस्थापित करा", "page": "पृष्ठ", "pathName": "पथाचे नाव", "date": "प्रकाशन तारीख", "emptyHinText": "या कार्यक्षेत्रात तुमच्याकडे कोणतीही प्रकाशित पृष्ठे नाहीत", "noPublishedPages": "प्रकाशित पृष्ठे नाहीत", "settings": "प्रकाशन सेटिंग्ज", "clickToOpenPageInApp": "पृष्ठ अ‍ॅपमध्ये उघडा", "clickToOpenPageInBrowser": "पृष्ठ ब्राउझरमध्ये उघडा" } } }, "error": { "failedToGeneratePaymentLink": "Pro प्लॅनसाठी पेमेंट लिंक तयार करण्यात अयशस्वी", "failedToUpdateNamespace": "नेमस्पेस अद्यतनित करण्यात अयशस्वी", "proPlanLimitation": "नेमस्पेस अद्यतनित करण्यासाठी तुम्हाला Pro प्लॅनमध्ये अपग्रेड करणे आवश्यक आहे", "namespaceAlreadyInUse": "नेमस्पेस आधीच वापरात आहे, कृपया दुसरे प्रयत्न करा", "invalidNamespace": "अवैध नेमस्पेस, कृपया दुसरे प्रयत्न करा", "namespaceLengthAtLeast2Characters": "नेमस्पेस किमान २ अक्षरे लांब असावे", "onlyWorkspaceOwnerCanUpdateNamespace": "फक्त कार्यक्षेत्र मालकच नेमस्पेस अद्यतनित करू शकतो", "onlyWorkspaceOwnerCanRemoveHomepage": "फक्त कार्यक्षेत्र मालकच मुख्यपृष्ठ हटवू शकतो", "setHomepageFailed": "मुख्यपृष्ठ सेट करण्यात अयशस्वी", "namespaceTooLong": "नेमस्पेस खूप लांब आहे, कृपया दुसरे प्रयत्न करा", "namespaceTooShort": "नेमस्पेस खूप लहान आहे, कृपया दुसरे प्रयत्न करा", "namespaceIsReserved": "हा नेमस्पेस राखीव आहे, कृपया दुसरे प्रयत्न करा", "updatePathNameFailed": "पथाचे नाव अद्यतनित करण्यात अयशस्वी", "removeHomePageFailed": "मुख्यपृष्ठ हटवण्यात अयशस्वी", "publishNameContainsInvalidCharacters": "पथाच्या नावामध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", "publishNameTooShort": "पथाचे नाव खूप लहान आहे, कृपया दुसरे प्रयत्न करा", "publishNameTooLong": "पथाचे नाव खूप लांब आहे, कृपया दुसरे प्रयत्न करा", "publishNameAlreadyInUse": "हे पथाचे नाव आधीच वापरले गेले आहे, कृपया दुसरे प्रयत्न करा", "namespaceContainsInvalidCharacters": "नेमस्पेसमध्ये अवैध अक्षरे आहेत, कृपया दुसरे प्रयत्न करा", "publishPermissionDenied": "फक्त कार्यक्षेत्र मालक किंवा पृष्ठ प्रकाशकच प्रकाशन सेटिंग्ज व्यवस्थापित करू शकतो", "publishNameCannotBeEmpty": "पथाचे नाव रिकामे असू शकत नाही, कृपया दुसरे प्रयत्न करा" }, "success": { "namespaceUpdated": "नेमस्पेस यशस्वीरित्या अद्यतनित केला", "setHomepageSuccess": "मुख्यपृष्ठ यशस्वीरित्या सेट केले", "updatePathNameSuccess": "पथाचे नाव यशस्वीरित्या अद्यतनित केले", "removeHomePageSuccess": "मुख्यपृष्ठ यशस्वीरित्या हटवले" }, "accountPage": { "menuLabel": "खाते आणि अ‍ॅप", "title": "माझे खाते", "general": { "title": "खात्याचे नाव आणि प्रोफाइल प्रतिमा", "changeProfilePicture": "प्रोफाइल प्रतिमा बदला" }, "email": { "title": "ईमेल", "actions": { "change": "ईमेल बदला" } }, "login": { "title": "खाते लॉगिन", "loginLabel": "लॉगिन", "logoutLabel": "लॉगआउट" }, "isUpToDate": "@:appName अद्ययावत आहे!", "officialVersion": "आवृत्ती {version} (अधिकृत बिल्ड)" }, "workspacePage": { "menuLabel": "कार्यक्षेत्र", "title": "कार्यक्षेत्र", "description": "तुमचे कार्यक्षेत्र स्वरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक/वेळ फॉरमॅट आणि भाषा सानुकूलित करा.", "workspaceName": { "title": "कार्यक्षेत्राचे नाव" }, "workspaceIcon": { "title": "कार्यक्षेत्राचे चिन्ह", "description": "तुमच्या कार्यक्षेत्रासाठी प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे चिन्ह साइडबार आणि सूचना मध्ये दिसेल." }, "appearance": { "title": "दृश्यरूप", "description": "कार्यक्षेत्राचे दृश्यरूप, थीम, फॉन्ट, मजकूर रचना, दिनांक, वेळ आणि भाषा सानुकूलित करा.", "options": { "system": "स्वयंचलित", "light": "लाइट", "dark": "डार्क" } } }, "resetCursorColor": { "title": "दस्तऐवज कर्सरचा रंग रीसेट करा", "description": "तुम्हाला कर्सरचा रंग रीसेट करायचा आहे का?" }, "resetSelectionColor": { "title": "दस्तऐवज निवडीचा रंग रीसेट करा", "description": "तुम्हाला निवडीचा रंग रीसेट करायचा आहे का?" }, "resetWidth": { "resetSuccess": "दस्तऐवजाची रुंदी यशस्वीरित्या रीसेट केली" }, "theme": { "title": "थीम", "description": "पूर्व-निर्धारित थीम निवडा किंवा तुमची स्वतःची थीम अपलोड करा.", "uploadCustomThemeTooltip": "स्वतःची थीम अपलोड करा" }, "workspaceFont": { "title": "कार्यक्षेत्र फॉन्ट", "noFontHint": "कोणताही फॉन्ट सापडला नाही, कृपया दुसरा शब्द वापरून पहा." }, "textDirection": { "title": "मजकूर दिशा", "leftToRight": "डावीकडून उजवीकडे", "rightToLeft": "उजवीकडून डावीकडे", "auto": "स्वयंचलित", "enableRTLItems": "RTL टूलबार घटक सक्षम करा" }, "layoutDirection": { "title": "लेआउट दिशा", "leftToRight": "डावीकडून उजवीकडे", "rightToLeft": "उजवीकडून डावीकडे" }, "dateTime": { "title": "दिनांक आणि वेळ", "example": "{} वाजता {} ({})", "24HourTime": "२४-तास वेळ", "dateFormat": { "label": "दिनांक फॉरमॅट", "local": "स्थानिक", "us": "US", "iso": "ISO", "friendly": "सुलभ", "dmy": "D/M/Y" } }, "language": { "title": "भाषा" }, "deleteWorkspacePrompt": { "title": "कार्यक्षेत्र हटवा", "content": "तुम्हाला हे कार्यक्षेत्र हटवायचे आहे का? ही कृती पूर्ववत केली जाऊ शकत नाही, आणि तुम्ही प्रकाशित केलेली कोणतीही पृष्ठे अप्रकाशित होतील." }, "leaveWorkspacePrompt": { "title": "कार्यक्षेत्र सोडा", "content": "तुम्हाला हे कार्यक्षेत्र सोडायचे आहे का? यानंतर तुम्हाला यामधील सर्व पृष्ठे आणि डेटावर प्रवेश मिळणार नाही.", "success": "तुम्ही कार्यक्षेत्र यशस्वीरित्या सोडले.", "fail": "कार्यक्षेत्र सोडण्यात अयशस्वी." }, "manageWorkspace": { "title": "कार्यक्षेत्र व्यवस्थापित करा", "leaveWorkspace": "कार्यक्षेत्र सोडा", "deleteWorkspace": "कार्यक्षेत्र हटवा" }, "manageDataPage": { "menuLabel": "डेटा व्यवस्थापित करा", "title": "डेटा व्यवस्थापन", "description": "@:appName मध्ये स्थानिक डेटा संचयन व्यवस्थापित करा किंवा तुमचा विद्यमान डेटा आयात करा.", "dataStorage": { "title": "फाइल संचयन स्थान", "tooltip": "जिथे तुमच्या फाइल्स संग्रहित आहेत ते स्थान", "actions": { "change": "मार्ग बदला", "open": "फोल्डर उघडा", "openTooltip": "सध्याच्या डेटा फोल्डरचे स्थान उघडा", "copy": "मार्ग कॉपी करा", "copiedHint": "मार्ग कॉपी केला!", "resetTooltip": "मूलभूत स्थानावर रीसेट करा" }, "resetDialog": { "title": "तुम्हाला खात्री आहे का?", "description": "पथ मूलभूत डेटा स्थानावर रीसेट केल्याने तुमचा डेटा हटवला जाणार नाही. तुम्हाला सध्याचा डेटा पुन्हा आयात करायचा असल्यास, कृपया आधी त्याचा पथ कॉपी करा." } }, "importData": { "title": "डेटा आयात करा", "tooltip": "@:appName बॅकअप/डेटा फोल्डरमधून डेटा आयात करा", "description": "बाह्य @:appName डेटा फोल्डरमधून डेटा कॉपी करा", "action": "फाइल निवडा" }, "encryption": { "title": "एनक्रिप्शन", "tooltip": "तुमचा डेटा कसा संग्रहित आणि एनक्रिप्ट केला जातो ते व्यवस्थापित करा", "descriptionNoEncryption": "एनक्रिप्शन चालू केल्याने सर्व डेटा एनक्रिप्ट केला जाईल. ही क्रिया पूर्ववत केली जाऊ शकत नाही.", "descriptionEncrypted": "तुमचा डेटा एनक्रिप्टेड आहे.", "action": "डेटा एनक्रिप्ट करा", "dialog": { "title": "संपूर्ण डेटा एनक्रिप्ट करायचा?", "description": "तुमचा संपूर्ण डेटा एनक्रिप्ट केल्याने तो सुरक्षित राहील. ही क्रिया पूर्ववत केली जाऊ शकत नाही. तुम्हाला पुढे जायचे आहे का?" } }, "cache": { "title": "कॅशे साफ करा", "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", "dialog": { "title": "कॅशे साफ करा", "description": "प्रतिमा न दिसणे, पृष्ठे हरवणे, फॉन्ट लोड न होणे अशा समस्यांचे निराकरण करण्यासाठी मदत करा. याचा तुमच्या डेटावर परिणाम होणार नाही.", "successHint": "कॅशे साफ झाली!" } }, "data": { "fixYourData": "तुमचा डेटा सुधारा", "fixButton": "सुधारा", "fixYourDataDescription": "तुमच्या डेटामध्ये काही अडचण येत असल्यास, तुम्ही येथे ती सुधारू शकता." } }, "shortcutsPage": { "menuLabel": "शॉर्टकट्स", "title": "शॉर्टकट्स", "editBindingHint": "नवीन बाइंडिंग टाका", "searchHint": "शोधा", "actions": { "resetDefault": "मूलभूत रीसेट करा" }, "errorPage": { "message": "शॉर्टकट्स लोड करण्यात अयशस्वी: {}", "howToFix": "कृपया पुन्हा प्रयत्न करा. समस्या कायम राहिल्यास GitHub वर संपर्क साधा." }, "resetDialog": { "title": "शॉर्टकट्स रीसेट करा", "description": "हे सर्व कीबाइंडिंग्जना मूळ स्थितीत रीसेट करेल. ही क्रिया पूर्ववत करता येणार नाही. तुम्हाला खात्री आहे का?", "buttonLabel": "रीसेट करा" }, "conflictDialog": { "title": "{} आधीच वापरले जात आहे", "descriptionPrefix": "हे कीबाइंडिंग सध्या ", "descriptionSuffix": " यामध्ये वापरले जात आहे. तुम्ही हे कीबाइंडिंग बदलल्यास, ते {} मधून काढले जाईल.", "confirmLabel": "पुढे जा" }, "editTooltip": "कीबाइंडिंग संपादित करण्यासाठी क्लिक करा", "keybindings": { "toggleToDoList": "टू-डू सूची चालू/बंद करा", "insertNewParagraphInCodeblock": "नवीन परिच्छेद टाका", "pasteInCodeblock": "कोडब्लॉकमध्ये पेस्ट करा", "selectAllCodeblock": "सर्व निवडा", "indentLineCodeblock": "ओळीच्या सुरुवातीला दोन स्पेस टाका", "outdentLineCodeblock": "ओळीच्या सुरुवातीची दोन स्पेस काढा", "twoSpacesCursorCodeblock": "कर्सरवर दोन स्पेस टाका", "copy": "निवड कॉपी करा", "paste": "मजकुरात पेस्ट करा", "cut": "निवड कट करा", "alignLeft": "मजकूर डावीकडे संरेखित करा", "alignCenter": "मजकूर मधोमध संरेखित करा", "alignRight": "मजकूर उजवीकडे संरेखित करा", "insertInlineMathEquation": "इनलाइन गणितीय सूत्र टाका", "undo": "पूर्ववत करा", "redo": "पुन्हा करा", "convertToParagraph": "ब्लॉक परिच्छेदात रूपांतरित करा", "backspace": "हटवा", "deleteLeftWord": "डावीकडील शब्द हटवा", "deleteLeftSentence": "डावीकडील वाक्य हटवा", "delete": "उजवीकडील अक्षर हटवा", "deleteMacOS": "डावीकडील अक्षर हटवा", "deleteRightWord": "उजवीकडील शब्द हटवा", "moveCursorLeft": "कर्सर डावीकडे हलवा", "moveCursorBeginning": "कर्सर सुरुवातीला हलवा", "moveCursorLeftWord": "कर्सर एक शब्द डावीकडे हलवा", "moveCursorLeftSelect": "निवडा आणि कर्सर डावीकडे हलवा", "moveCursorBeginSelect": "निवडा आणि कर्सर सुरुवातीला हलवा", "moveCursorLeftWordSelect": "निवडा आणि कर्सर एक शब्द डावीकडे हलवा", "moveCursorRight": "कर्सर उजवीकडे हलवा", "moveCursorEnd": "कर्सर शेवटी हलवा", "moveCursorRightWord": "कर्सर एक शब्द उजवीकडे हलवा", "moveCursorRightSelect": "निवडा आणि कर्सर उजवीकडे हलवा", "moveCursorEndSelect": "निवडा आणि कर्सर शेवटी हलवा", "moveCursorRightWordSelect": "निवडा आणि कर्सर एक शब्द उजवीकडे हलवा", "moveCursorUp": "कर्सर वर हलवा", "moveCursorTopSelect": "निवडा आणि कर्सर वर हलवा", "moveCursorTop": "कर्सर वर हलवा", "moveCursorUpSelect": "निवडा आणि कर्सर वर हलवा", "moveCursorBottomSelect": "निवडा आणि कर्सर खाली हलवा", "moveCursorBottom": "कर्सर खाली हलवा", "moveCursorDown": "कर्सर खाली हलवा", "moveCursorDownSelect": "निवडा आणि कर्सर खाली हलवा", "home": "वर स्क्रोल करा", "end": "खाली स्क्रोल करा", "toggleBold": "बोल्ड चालू/बंद करा", "toggleItalic": "इटालिक चालू/बंद करा", "toggleUnderline": "अधोरेखित चालू/बंद करा", "toggleStrikethrough": "स्ट्राईकथ्रू चालू/बंद करा", "toggleCode": "इनलाइन कोड चालू/बंद करा", "toggleHighlight": "हायलाईट चालू/बंद करा", "showLinkMenu": "लिंक मेनू दाखवा", "openInlineLink": "इनलाइन लिंक उघडा", "openLinks": "सर्व निवडलेले लिंक उघडा", "indent": "इंडेंट", "outdent": "आउटडेंट", "exit": "संपादनातून बाहेर पडा", "pageUp": "एक पृष्ठ वर स्क्रोल करा", "pageDown": "एक पृष्ठ खाली स्क्रोल करा", "selectAll": "सर्व निवडा", "pasteWithoutFormatting": "फॉरमॅटिंगशिवाय पेस्ट करा", "showEmojiPicker": "इमोजी निवडकर्ता दाखवा", "enterInTableCell": "टेबलमध्ये नवीन ओळ जोडा", "leftInTableCell": "टेबलमध्ये डावीकडील सेलमध्ये जा", "rightInTableCell": "टेबलमध्ये उजवीकडील सेलमध्ये जा", "upInTableCell": "टेबलमध्ये वरील सेलमध्ये जा", "downInTableCell": "टेबलमध्ये खालील सेलमध्ये जा", "tabInTableCell": "टेबलमध्ये पुढील सेलमध्ये जा", "shiftTabInTableCell": "टेबलमध्ये मागील सेलमध्ये जा", "backSpaceInTableCell": "सेलच्या सुरुवातीला थांबा" }, "commands": { "codeBlockNewParagraph": "कोडब्लॉकच्या शेजारी नवीन परिच्छेद टाका", "codeBlockIndentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीला दोन स्पेस टाका", "codeBlockOutdentLines": "कोडब्लॉकमध्ये ओळीच्या सुरुवातीची दोन स्पेस काढा", "codeBlockAddTwoSpaces": "कोडब्लॉकमध्ये कर्सरच्या ठिकाणी दोन स्पेस टाका", "codeBlockSelectAll": "कोडब्लॉकमधील सर्व मजकूर निवडा", "codeBlockPasteText": "कोडब्लॉकमध्ये मजकूर पेस्ट करा", "textAlignLeft": "मजकूर डावीकडे संरेखित करा", "textAlignCenter": "मजकूर मधोमध संरेखित करा", "textAlignRight": "मजकूर उजवीकडे संरेखित करा" }, "couldNotLoadErrorMsg": "शॉर्टकट लोड करता आले नाहीत. पुन्हा प्रयत्न करा", "couldNotSaveErrorMsg": "शॉर्टकट सेव्ह करता आले नाहीत. पुन्हा प्रयत्न करा" }, "aiPage": { "title": "AI सेटिंग्ज", "menuLabel": "AI सेटिंग्ज", "keys": { "enableAISearchTitle": "AI शोध", "aiSettingsDescription": "AppFlowy AI चालवण्यासाठी तुमचा पसंतीचा मॉडेल निवडा. आता GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet आणि Ollama मधील मॉडेल्सचा समावेश आहे.", "loginToEnableAIFeature": "AI वैशिष्ट्ये फक्त @:appName Cloud मध्ये लॉगिन केल्यानंतरच सक्षम होतात. तुमच्याकडे @:appName खाते नसेल तर 'माय अकाउंट' मध्ये जाऊन साइन अप करा.", "llmModel": "भाषा मॉडेल", "llmModelType": "भाषा मॉडेल प्रकार", "downloadLLMPrompt": "{} डाउनलोड करा", "downloadAppFlowyOfflineAI": "AI ऑफलाइन पॅकेज डाउनलोड केल्याने तुमच्या डिव्हाइसवर AI चालवता येईल. तुम्हाला पुढे जायचे आहे का?", "downloadLLMPromptDetail": "{} स्थानिक मॉडेल डाउनलोड करण्यासाठी सुमारे {} संचयन लागेल. तुम्हाला पुढे जायचे आहे का?", "downloadBigFilePrompt": "डाउनलोड पूर्ण होण्यासाठी सुमारे १० मिनिटे लागू शकतात", "downloadAIModelButton": "डाउनलोड करा", "downloadingModel": "डाउनलोड करत आहे", "localAILoaded": "स्थानिक AI मॉडेल यशस्वीरित्या जोडले गेले आणि वापरण्यास सज्ज आहे", "localAIStart": "स्थानिक AI सुरू होत आहे. जर हळू वाटत असेल, तर ते बंद करून पुन्हा सुरू करून पहा", "localAILoading": "स्थानिक AI Chat मॉडेल लोड होत आहे...", "localAIStopped": "स्थानिक AI थांबले आहे", "localAIRunning": "स्थानिक AI चालू आहे", "localAINotReadyRetryLater": "स्थानिक AI सुरू होत आहे, कृपया नंतर पुन्हा प्रयत्न करा", "localAIDisabled": "तुम्ही स्थानिक AI वापरत आहात, पण ते अकार्यक्षम आहे. कृपया सेटिंग्जमध्ये जाऊन ते सक्षम करा किंवा दुसरे मॉडेल वापरून पहा", "localAIInitializing": "स्थानिक AI लोड होत आहे. तुमच्या डिव्हाइसवर अवलंबून याला काही मिनिटे लागू शकतात", "localAINotReadyTextFieldPrompt": "स्थानिक AI लोड होत असताना संपादन करता येणार नाही", "failToLoadLocalAI": "स्थानिक AI सुरू करण्यात अयशस्वी.", "restartLocalAI": "पुन्हा सुरू करा", "disableLocalAITitle": "स्थानिक AI अकार्यक्षम करा", "disableLocalAIDescription": "तुम्हाला स्थानिक AI अकार्यक्षम करायचा आहे का?", "localAIToggleTitle": "AppFlowy स्थानिक AI (LAI)", "localAIToggleSubTitle": "AppFlowy मध्ये अत्याधुनिक स्थानिक AI मॉडेल्स वापरून गोपनीयता आणि सुरक्षा सुनिश्चित करा", "offlineAIInstruction1": "हे अनुसरा", "offlineAIInstruction2": "सूचना", "offlineAIInstruction3": "ऑफलाइन AI सक्षम करण्यासाठी.", "offlineAIDownload1": "जर तुम्ही AppFlowy AI डाउनलोड केले नसेल, तर कृपया", "offlineAIDownload2": "डाउनलोड", "offlineAIDownload3": "करा", "activeOfflineAI": "सक्रिय", "downloadOfflineAI": "डाउनलोड करा", "openModelDirectory": "फोल्डर उघडा", "laiNotReady": "स्थानिक AI अ‍ॅप योग्य प्रकारे इन्स्टॉल झालेले नाही.", "ollamaNotReady": "Ollama सर्व्हर तयार नाही.", "pleaseFollowThese": "कृपया हे अनुसरा", "instructions": "सूचना", "installOllamaLai": "Ollama आणि AppFlowy स्थानिक AI सेटअप करण्यासाठी.", "modelsMissing": "आवश्यक मॉडेल्स सापडत नाहीत.", "downloadModel": "त्यांना डाउनलोड करण्यासाठी." } }, "planPage": { "menuLabel": "योजना", "title": "दर योजना", "planUsage": { "title": "योजनेचा वापर सारांश", "storageLabel": "स्टोरेज", "storageUsage": "{} पैकी {} GB", "unlimitedStorageLabel": "अमर्यादित स्टोरेज", "collaboratorsLabel": "सदस्य", "collaboratorsUsage": "{} पैकी {}", "aiResponseLabel": "AI प्रतिसाद", "aiResponseUsage": "{} पैकी {}", "unlimitedAILabel": "अमर्यादित AI प्रतिसाद", "proBadge": "प्रो", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "मॅकसाठी ऑन-डिव्हाइस AI", "memberProToggle": "अधिक सदस्य आणि अमर्यादित AI", "aiMaxToggle": "अमर्यादित AI आणि प्रगत मॉडेल्सचा प्रवेश", "aiOnDeviceToggle": "जास्त गोपनीयतेसाठी स्थानिक AI", "aiCredit": { "title": "@:appName AI क्रेडिट जोडा", "price": "{}", "priceDescription": "1,000 क्रेडिट्ससाठी", "purchase": "AI खरेदी करा", "info": "प्रत्येक कार्यक्षेत्रासाठी 1,000 AI क्रेडिट्स जोडा आणि आपल्या कामाच्या प्रवाहात सानुकूलनीय AI सहजपणे एकत्र करा — अधिक स्मार्ट आणि जलद निकालांसाठी:", "infoItemOne": "प्रत्येक डेटाबेससाठी 10,000 प्रतिसाद", "infoItemTwo": "प्रत्येक कार्यक्षेत्रासाठी 1,000 प्रतिसाद" }, "currentPlan": { "bannerLabel": "सद्य योजना", "freeTitle": "फ्री", "proTitle": "प्रो", "teamTitle": "टीम", "freeInfo": "2 सदस्यांपर्यंत वैयक्तिक वापरासाठी उत्तम", "proInfo": "10 सदस्यांपर्यंत लहान आणि मध्यम टीमसाठी योग्य", "teamInfo": "सर्व उत्पादनक्षम आणि सुव्यवस्थित टीमसाठी योग्य", "upgrade": "योजना बदला", "canceledInfo": "तुमची योजना रद्द करण्यात आली आहे, {} रोजी तुम्हाला फ्री योजनेवर डाऊनग्रेड केले जाईल." }, "addons": { "title": "ऍड-ऑन्स", "addLabel": "जोडा", "activeLabel": "जोडले गेले", "aiMax": { "title": "AI Max", "description": "प्रगत AI मॉडेल्ससह अमर्यादित AI प्रतिसाद आणि दरमहा 50 AI प्रतिमा", "price": "{}", "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)" }, "aiOnDevice": { "title": "मॅकसाठी ऑन-डिव्हाइस AI", "description": "तुमच्या डिव्हाइसवर Mistral 7B, LLAMA 3 आणि अधिक स्थानिक मॉडेल्स चालवा", "price": "{}", "priceInfo": "प्रति वापरकर्ता प्रति महिना (वार्षिक बिलिंग)", "recommend": "M1 किंवा नवीनतम शिफारस केली जाते" } }, "deal": { "bannerLabel": "नववर्षाचे विशेष ऑफर!", "title": "तुमची टीम वाढवा!", "info": "Pro आणि Team योजनांवर 10% सूट मिळवा! @:appName AI सह शक्तिशाली नवीन वैशिष्ट्यांसह तुमचे कार्यक्षेत्र अधिक कार्यक्षम बनवा.", "viewPlans": "योजना पहा" } } }, "billingPage": { "menuLabel": "बिलिंग", "title": "बिलिंग", "plan": { "title": "योजना", "freeLabel": "फ्री", "proLabel": "प्रो", "planButtonLabel": "योजना बदला", "billingPeriod": "बिलिंग कालावधी", "periodButtonLabel": "कालावधी संपादित करा" }, "paymentDetails": { "title": "पेमेंट तपशील", "methodLabel": "पेमेंट पद्धत", "methodButtonLabel": "पद्धत संपादित करा" }, "addons": { "title": "ऍड-ऑन्स", "addLabel": "जोडा", "removeLabel": "काढा", "renewLabel": "नवीन करा", "aiMax": { "label": "AI Max", "description": "अमर्यादित AI आणि प्रगत मॉडेल्स अनलॉक करा", "activeDescription": "पुढील बिलिंग तारीख {} आहे", "canceledDescription": "AI Max {} पर्यंत उपलब्ध असेल" }, "aiOnDevice": { "label": "मॅकसाठी ऑन-डिव्हाइस AI", "description": "तुमच्या डिव्हाइसवर अमर्यादित ऑन-डिव्हाइस AI अनलॉक करा", "activeDescription": "पुढील बिलिंग तारीख {} आहे", "canceledDescription": "मॅकसाठी ऑन-डिव्हाइस AI {} पर्यंत उपलब्ध असेल" }, "removeDialog": { "title": "{} काढा", "description": "तुम्हाला {plan} काढायचे आहे का? तुम्हाला तत्काळ {plan} चे सर्व फिचर्स आणि फायदे वापरण्याचा अधिकार गमावावा लागेल." } }, "currentPeriodBadge": "सद्य कालावधी", "changePeriod": "कालावधी बदला", "planPeriod": "{} कालावधी", "monthlyInterval": "मासिक", "monthlyPriceInfo": "प्रति सदस्य, मासिक बिलिंग", "annualInterval": "वार्षिक", "annualPriceInfo": "प्रति सदस्य, वार्षिक बिलिंग" }, "comparePlanDialog": { "title": "योजना तुलना आणि निवड", "planFeatures": "योजनेची\nवैशिष्ट्ये", "current": "सध्याची", "actions": { "upgrade": "अपग्रेड करा", "downgrade": "डाऊनग्रेड करा", "current": "सध्याची" }, "freePlan": { "title": "फ्री", "description": "२ सदस्यांपर्यंत व्यक्तींसाठी सर्व काही व्यवस्थित करण्यासाठी", "price": "{}", "priceInfo": "सदैव फ्री" }, "proPlan": { "title": "प्रो", "description": "लहान टीम्ससाठी प्रोजेक्ट्स व ज्ञान व्यवस्थापनासाठी", "price": "{}", "priceInfo": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग\n\n{} मासिक बिलिंगसाठी" }, "planLabels": { "itemOne": "वर्कस्पेसेस", "itemTwo": "सदस्य", "itemThree": "स्टोरेज", "itemFour": "रिअल-टाइम सहकार्य", "itemFive": "मोबाईल अ‍ॅप", "itemSix": "AI प्रतिसाद", "itemSeven": "AI प्रतिमा", "itemFileUpload": "फाइल अपलोड", "customNamespace": "सानुकूल नेमस्पेस", "tooltipSix": "‘Lifetime’ म्हणजे या प्रतिसादांची मर्यादा कधीही रीसेट केली जात नाही", "intelligentSearch": "स्मार्ट शोध", "tooltipSeven": "तुमच्या कार्यक्षेत्रासाठी URL चा भाग सानुकूलित करू देते", "customNamespaceTooltip": "सानुकूल प्रकाशित साइट URL" }, "freeLabels": { "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", "itemTwo": "२ पर्यंत", "itemThree": "५ GB", "itemFour": "होय", "itemFive": "होय", "itemSix": "१० कायमस्वरूपी", "itemSeven": "२ कायमस्वरूपी", "itemFileUpload": "७ MB पर्यंत", "intelligentSearch": "स्मार्ट शोध" }, "proLabels": { "itemOne": "प्रत्येक वर्कस्पेसवर शुल्क", "itemTwo": "१० पर्यंत", "itemThree": "अमर्यादित", "itemFour": "होय", "itemFive": "होय", "itemSix": "अमर्यादित", "itemSeven": "दर महिन्याला १० प्रतिमा", "itemFileUpload": "अमर्यादित", "intelligentSearch": "स्मार्ट शोध" }, "paymentSuccess": { "title": "तुम्ही आता {} योजनेवर आहात!", "description": "तुमचे पेमेंट यशस्वीरित्या पूर्ण झाले असून तुमची योजना @:appName {} मध्ये अपग्रेड झाली आहे. तुम्ही 'योजना' पृष्ठावर तपशील पाहू शकता." }, "downgradeDialog": { "title": "तुम्हाला योजना डाऊनग्रेड करायची आहे का?", "description": "तुमची योजना डाऊनग्रेड केल्यास तुम्ही फ्री योजनेवर परत जाल. काही सदस्यांना या कार्यक्षेत्राचा प्रवेश मिळणार नाही आणि तुम्हाला स्टोरेज मर्यादेप्रमाणे जागा रिकामी करावी लागू शकते.", "downgradeLabel": "योजना डाऊनग्रेड करा" } }, "cancelSurveyDialog": { "title": "तुम्ही जात आहात याचे दुःख आहे", "description": "तुम्ही जात आहात याचे आम्हाला खरोखरच दुःख वाटते. @:appName सुधारण्यासाठी तुमचा अभिप्राय आम्हाला महत्त्वाचा वाटतो. कृपया काही क्षण घेऊन काही प्रश्नांची उत्तरे द्या.", "commonOther": "इतर", "otherHint": "तुमचे उत्तर येथे लिहा", "questionOne": { "question": "तुम्ही तुमची @:appName Pro सदस्यता का रद्द केली?", "answerOne": "खर्च खूप जास्त आहे", "answerTwo": "वैशिष्ट्ये अपेक्षांनुसार नव्हती", "answerThree": "यापेक्षा चांगला पर्याय सापडला", "answerFour": "वापर फारसा केला नाही, त्यामुळे खर्च योग्य वाटला नाही", "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" }, "questionTwo": { "question": "भविष्यात @:appName Pro सदस्यता पुन्हा घेण्याची शक्यता किती आहे?", "answerOne": "खूप शक्यता आहे", "answerTwo": "काहीशी शक्यता आहे", "answerThree": "निश्चित नाही", "answerFour": "अल्प शक्यता", "answerFive": "एकदम कमी शक्यता" }, "questionThree": { "question": "तुमच्या सदस्यत्वादरम्यान कोणते Pro वैशिष्ट्य सर्वात उपयुक्त वाटले?", "answerOne": "अनेक वापरकर्त्यांशी सहकार्य", "answerTwo": "लांब कालावधीची आवृत्ती इतिहास", "answerThree": "अमर्यादित AI प्रतिसाद", "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" }, "questionFour": { "question": "@:appName वापरण्याचा तुमचा एकूण अनुभव कसा होता?", "answerOne": "खूप छान", "answerTwo": "चांगला", "answerThree": "सरासरी", "answerFour": "सरासरीपेक्षा कमी", "answerFive": "असंतोषजनक" } }, "common": { "uploadingFile": "फाईल अपलोड होत आहे. कृपया अ‍ॅप बंद करू नका", "uploadNotionSuccess": "तुमची Notion zip फाईल यशस्वीरित्या अपलोड झाली आहे. आयात पूर्ण झाल्यानंतर तुम्हाला पुष्टीकरण ईमेल मिळेल", "reset": "रीसेट करा" }, "menu": { "appearance": "दृश्यरूप", "language": "भाषा", "user": "वापरकर्ता", "files": "फाईल्स", "notifications": "सूचना", "open": "सेटिंग्ज उघडा", "logout": "लॉगआउट", "logoutPrompt": "तुम्हाला नक्की लॉगआउट करायचे आहे का?", "selfEncryptionLogoutPrompt": "तुम्हाला खात्रीने लॉगआउट करायचे आहे का? कृपया खात्री करा की तुम्ही एनक्रिप्शन गुप्तकी कॉपी केली आहे", "syncSetting": "सिंक्रोनायझेशन सेटिंग", "cloudSettings": "क्लाऊड सेटिंग्ज", "enableSync": "सिंक्रोनायझेशन सक्षम करा", "enableSyncLog": "सिंक लॉगिंग सक्षम करा", "enableSyncLogWarning": "सिंक समस्यांचे निदान करण्यात मदतीसाठी धन्यवाद. हे तुमचे डॉक्युमेंट संपादन स्थानिक फाईलमध्ये लॉग करेल. कृपया सक्षम केल्यानंतर अ‍ॅप बंद करून पुन्हा उघडा", "enableEncrypt": "डेटा एन्क्रिप्ट करा", "cloudURL": "बेस URL", "webURL": "वेब URL", "invalidCloudURLScheme": "अवैध स्कीम", "cloudServerType": "क्लाऊड सर्व्हर", "cloudServerTypeTip": "कृपया लक्षात घ्या की क्लाऊड सर्व्हर बदलल्यास तुम्ही सध्या लॉगिन केलेले खाते लॉगआउट होऊ शकते", "cloudLocal": "स्थानिक", "cloudAppFlowy": "@:appName Cloud", "cloudAppFlowySelfHost": "@:appName क्लाऊड सेल्फ-होस्टेड", "appFlowyCloudUrlCanNotBeEmpty": "क्लाऊड URL रिकामा असू शकत नाही", "clickToCopy": "क्लिपबोर्डवर कॉपी करा", "selfHostStart": "जर तुमच्याकडे सर्व्हर नसेल, तर कृपया हे पाहा", "selfHostContent": "दस्तऐवज", "selfHostEnd": "तुमचा स्वतःचा सर्व्हर कसा होस्ट करावा यासाठी मार्गदर्शनासाठी", "pleaseInputValidURL": "कृपया वैध URL टाका", "changeUrl": "सेल्फ-होस्टेड URL {} मध्ये बदला", "cloudURLHint": "तुमच्या सर्व्हरचा बेस URL टाका", "webURLHint": "तुमच्या वेब सर्व्हरचा बेस URL टाका", "cloudWSURL": "वेबसॉकेट URL", "cloudWSURLHint": "तुमच्या सर्व्हरचा वेबसॉकेट पत्ता टाका", "restartApp": "अ‍ॅप रीस्टार्ट करा", "restartAppTip": "बदल प्रभावी होण्यासाठी अ‍ॅप रीस्टार्ट करा. कृपया लक्षात घ्या की यामुळे सध्याचे खाते लॉगआउट होऊ शकते.", "changeServerTip": "सर्व्हर बदलल्यानंतर, बदल लागू करण्यासाठी तुम्हाला 'रीस्टार्ट' बटणावर क्लिक करणे आवश्यक आहे", "enableEncryptPrompt": "तुमचा डेटा सुरक्षित करण्यासाठी एन्क्रिप्शन सक्रिय करा. ही गुप्तकी सुरक्षित ठेवा; एकदा सक्षम केल्यावर ती बंद करता येणार नाही. जर हरवली तर तुमचा डेटा पुन्हा मिळवता येणार नाही. कॉपी करण्यासाठी क्लिक करा", "inputEncryptPrompt": "कृपया खालीलसाठी तुमची एनक्रिप्शन गुप्तकी टाका:", "clickToCopySecret": "गुप्तकी कॉपी करण्यासाठी क्लिक करा", "configServerSetting": "तुमच्या सर्व्हर सेटिंग्ज कॉन्फिगर करा", "configServerGuide": "`Quick Start` निवडल्यानंतर, `Settings` → \"Cloud Settings\" मध्ये जा आणि तुमचा सेल्फ-होस्टेड सर्व्हर कॉन्फिगर करा.", "inputTextFieldHint": "तुमची गुप्तकी", "historicalUserList": "वापरकर्ता लॉगिन इतिहास", "historicalUserListTooltip": "ही यादी तुमची अनामिक खाती दर्शवते. तपशील पाहण्यासाठी खात्यावर क्लिक करा. अनामिक खाती 'सुरुवात करा' बटणावर क्लिक करून तयार केली जातात", "openHistoricalUser": "अनामिक खाते उघडण्यासाठी क्लिक करा", "customPathPrompt": "@:appName डेटा फोल्डर Google Drive सारख्या क्लाऊड-सिंक फोल्डरमध्ये साठवणे धोकादायक ठरू शकते. फोल्डरमधील डेटाबेस अनेक ठिकाणांवरून एकाच वेळी ऍक्सेस/संपादित केल्यास डेटा सिंक समस्या किंवा भ्रष्ट होण्याची शक्यता असते.", "importAppFlowyData": "बाह्य @:appName फोल्डरमधून डेटा आयात करा", "importingAppFlowyDataTip": "डेटा आयात सुरू आहे. कृपया अ‍ॅप बंद करू नका", "importAppFlowyDataDescription": "बाह्य @:appName फोल्डरमधून डेटा कॉपी करून सध्याच्या AppFlowy डेटा फोल्डरमध्ये आयात करा", "importSuccess": "@:appName डेटा फोल्डर यशस्वीरित्या आयात झाला", "importFailed": "@:appName डेटा फोल्डर आयात करण्यात अयशस्वी", "importGuide": "अधिक माहितीसाठी, कृपया संदर्भित दस्तऐवज पहा" }, "notifications": { "enableNotifications": { "label": "सूचना सक्षम करा", "hint": "स्थानिक सूचना दिसू नयेत यासाठी बंद करा." }, "showNotificationsIcon": { "label": "सूचना चिन्ह दाखवा", "hint": "साइडबारमध्ये सूचना चिन्ह लपवण्यासाठी बंद करा." }, "archiveNotifications": { "allSuccess": "सर्व सूचना यशस्वीरित्या संग्रहित केल्या", "success": "सूचना यशस्वीरित्या संग्रहित केली" }, "markAsReadNotifications": { "allSuccess": "सर्व वाचलेल्या म्हणून चिन्हांकित केल्या", "success": "वाचलेले म्हणून चिन्हांकित केले" }, "action": { "markAsRead": "वाचलेले म्हणून चिन्हांकित करा", "multipleChoice": "अधिक निवडा", "archive": "संग्रहित करा" }, "settings": { "settings": "सेटिंग्ज", "markAllAsRead": "सर्व वाचलेले म्हणून चिन्हांकित करा", "archiveAll": "सर्व संग्रहित करा" }, "emptyInbox": { "title": "इनबॉक्स झिरो!", "description": "इथे सूचना मिळवण्यासाठी रिमाइंडर सेट करा." }, "emptyUnread": { "title": "कोणतीही न वाचलेली सूचना नाही", "description": "तुम्ही सर्व वाचले आहे!" }, "emptyArchived": { "title": "कोणतीही संग्रहित सूचना नाही", "description": "संग्रहित सूचना इथे दिसतील." }, "tabs": { "inbox": "इनबॉक्स", "unread": "न वाचलेले", "archived": "संग्रहित" }, "refreshSuccess": "सूचना यशस्वीरित्या रीफ्रेश केल्या", "titles": { "notifications": "सूचना", "reminder": "रिमाइंडर" } }, "appearance": { "resetSetting": "रीसेट", "fontFamily": { "label": "फॉन्ट फॅमिली", "search": "शोध", "defaultFont": "सिस्टम" }, "themeMode": { "label": "थीम मोड", "light": "लाइट मोड", "dark": "डार्क मोड", "system": "सिस्टमशी जुळवा" }, "fontScaleFactor": "फॉन्ट स्केल घटक", "displaySize": "डिस्प्ले आकार", "documentSettings": { "cursorColor": "डॉक्युमेंट कर्सरचा रंग", "selectionColor": "डॉक्युमेंट निवडीचा रंग", "width": "डॉक्युमेंटची रुंदी", "changeWidth": "बदला", "pickColor": "रंग निवडा", "colorShade": "रंगाची छटा", "opacity": "अपारदर्शकता", "hexEmptyError": "Hex रंग रिकामा असू शकत नाही", "hexLengthError": "Hex व्हॅल्यू 6 अंकांची असावी", "hexInvalidError": "अवैध Hex व्हॅल्यू", "opacityEmptyError": "अपारदर्शकता रिकामी असू शकत नाही", "opacityRangeError": "अपारदर्शकता 1 ते 100 दरम्यान असावी", "app": "अ‍ॅप", "flowy": "Flowy", "apply": "लागू करा" }, "layoutDirection": { "label": "लेआउट दिशा", "hint": "तुमच्या स्क्रीनवरील कंटेंटचा प्रवाह नियंत्रित करा — डावीकडून उजवीकडे किंवा उजवीकडून डावीकडे.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "मूलभूत मजकूर दिशा", "hint": "मजकूर डावीकडून सुरू व्हावा की उजवीकडून याचे पूर्वनिर्धारित निर्धारण करा.", "ltr": "LTR", "rtl": "RTL", "auto": "स्वयं", "fallback": "लेआउट दिशेशी जुळवा" }, "themeUpload": { "button": "अपलोड", "uploadTheme": "थीम अपलोड करा", "description": "खालील बटण वापरून तुमची स्वतःची @:appName थीम अपलोड करा.", "loading": "कृपया प्रतीक्षा करा. आम्ही तुमची थीम पडताळत आहोत आणि अपलोड करत आहोत...", "uploadSuccess": "तुमची थीम यशस्वीरित्या अपलोड झाली आहे", "deletionFailure": "थीम हटवण्यात अयशस्वी. कृपया ती मॅन्युअली हटवून पाहा.", "filePickerDialogTitle": ".flowy_plugin फाईल निवडा", "urlUploadFailure": "URL उघडण्यात अयशस्वी: {}" }, "theme": "थीम", "builtInsLabel": "अंतर्गत थीम्स", "pluginsLabel": "प्लगइन्स", "dateFormat": { "label": "दिनांक फॉरमॅट", "local": "स्थानिक", "us": "US", "iso": "ISO", "friendly": "अनौपचारिक", "dmy": "D/M/Y" }, "timeFormat": { "label": "वेळ फॉरमॅट", "twelveHour": "१२ तास", "twentyFourHour": "२४ तास" }, "showNamingDialogWhenCreatingPage": "पृष्ठ तयार करताना नाव विचारणारा डायलॉग दाखवा", "enableRTLToolbarItems": "RTL टूलबार आयटम्स सक्षम करा", "members": { "title": "सदस्य सेटिंग्ज", "inviteMembers": "सदस्यांना आमंत्रण द्या", "inviteHint": "ईमेलद्वारे आमंत्रण द्या", "sendInvite": "आमंत्रण पाठवा", "copyInviteLink": "आमंत्रण दुवा कॉपी करा", "label": "सदस्य", "user": "वापरकर्ता", "role": "भूमिका", "removeFromWorkspace": "वर्कस्पेसमधून काढा", "removeFromWorkspaceSuccess": "वर्कस्पेसमधून यशस्वीरित्या काढले", "removeFromWorkspaceFailed": "वर्कस्पेसमधून काढण्यात अयशस्वी", "owner": "मालक", "guest": "अतिथी", "member": "सदस्य", "memberHintText": "सदस्य पृष्ठे वाचू व संपादित करू शकतो", "guestHintText": "अतिथी पृष्ठे वाचू शकतो, प्रतिक्रिया देऊ शकतो, टिप्पणी करू शकतो, आणि परवानगी असल्यास काही पृष्ठे संपादित करू शकतो.", "emailInvalidError": "अवैध ईमेल, कृपया तपासा व पुन्हा प्रयत्न करा", "emailSent": "ईमेल पाठवला गेला आहे, कृपया इनबॉक्स तपासा", "members": "सदस्य", "membersCount": { "zero": "{} सदस्य", "one": "{} सदस्य", "other": "{} सदस्य" }, "inviteFailedDialogTitle": "आमंत्रण पाठवण्यात अयशस्वी", "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, अधिक सदस्यांसाठी कृपया अपग्रेड करा.", "inviteFailedMemberLimitMobile": "तुमच्या वर्कस्पेसने सदस्य मर्यादा गाठली आहे.", "memberLimitExceeded": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आमंत्रित करण्यासाठी कृपया ", "memberLimitExceededUpgrade": "अपग्रेड करा", "memberLimitExceededPro": "सदस्य मर्यादा गाठली आहे, अधिक सदस्य आवश्यक असल्यास संपर्क करा", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "सदस्य जोडण्यात अयशस्वी", "addMemberSuccess": "सदस्य यशस्वीरित्या जोडला गेला", "removeMember": "सदस्य काढा", "areYouSureToRemoveMember": "तुम्हाला हा सदस्य काढायचा आहे का?", "inviteMemberSuccess": "आमंत्रण यशस्वीरित्या पाठवले गेले", "failedToInviteMember": "सदस्य आमंत्रित करण्यात अयशस्वी", "workspaceMembersError": "अरे! काहीतरी चूक झाली आहे", "workspaceMembersErrorDescription": "आम्ही सध्या सदस्यांची यादी लोड करू शकत नाही. कृपया नंतर पुन्हा प्रयत्न करा" } }, "files": { "copy": "कॉपी करा", "defaultLocation": "फाईल्स आणि डेटाचा संचय स्थान", "exportData": "तुमचा डेटा निर्यात करा", "doubleTapToCopy": "पथ कॉपी करण्यासाठी दोनदा टॅप करा", "restoreLocation": "@:appName चे मूळ स्थान पुनर्संचयित करा", "customizeLocation": "इतर फोल्डर उघडा", "restartApp": "बदल लागू करण्यासाठी कृपया अ‍ॅप रीस्टार्ट करा.", "exportDatabase": "डेटाबेस निर्यात करा", "selectFiles": "निर्यात करण्यासाठी फाईल्स निवडा", "selectAll": "सर्व निवडा", "deselectAll": "सर्व निवड रद्द करा", "createNewFolder": "नवीन फोल्डर तयार करा", "createNewFolderDesc": "तुमचा डेटा कुठे साठवायचा हे सांगा", "defineWhereYourDataIsStored": "तुमचा डेटा कुठे साठवला जातो हे ठरवा", "open": "उघडा", "openFolder": "आधीक फोल्डर उघडा", "openFolderDesc": "तुमच्या विद्यमान @:appName फोल्डरमध्ये वाचन व लेखन करा", "folderHintText": "फोल्डरचे नाव", "location": "नवीन फोल्डर तयार करत आहे", "locationDesc": "तुमच्या @:appName डेटासाठी नाव निवडा", "browser": "ब्राउझ करा", "create": "तयार करा", "set": "सेट करा", "folderPath": "फोल्डर साठवण्याचा मार्ग", "locationCannotBeEmpty": "मार्ग रिकामा असू शकत नाही", "pathCopiedSnackbar": "फाईल संचय मार्ग क्लिपबोर्डवर कॉपी केला!", "changeLocationTooltips": "डेटा डिरेक्टरी बदला", "change": "बदला", "openLocationTooltips": "इतर डेटा डिरेक्टरी उघडा", "openCurrentDataFolder": "सध्याची डेटा डिरेक्टरी उघडा", "recoverLocationTooltips": "@:appName च्या मूळ डेटा डिरेक्टरीवर रीसेट करा", "exportFileSuccess": "फाईल यशस्वीरित्या निर्यात झाली!", "exportFileFail": "फाईल निर्यात करण्यात अयशस्वी!", "export": "निर्यात करा", "clearCache": "कॅशे साफ करा", "clearCacheDesc": "प्रतिमा लोड होत नाहीत किंवा फॉन्ट अयोग्यरित्या दिसत असल्यास, कॅशे साफ करून पाहा. ही क्रिया तुमचा वापरकर्ता डेटा हटवणार नाही.", "areYouSureToClearCache": "तुम्हाला नक्की कॅशे साफ करायची आहे का?", "clearCacheSuccess": "कॅशे यशस्वीरित्या साफ झाली!" }, "user": { "name": "नाव", "email": "ईमेल", "tooltipSelectIcon": "चिन्ह निवडा", "selectAnIcon": "चिन्ह निवडा", "pleaseInputYourOpenAIKey": "कृपया तुमची AI की टाका", "clickToLogout": "सध्याचा वापरकर्ता लॉगआउट करण्यासाठी क्लिक करा" }, "mobile": { "personalInfo": "वैयक्तिक माहिती", "username": "वापरकर्तानाव", "usernameEmptyError": "वापरकर्तानाव रिकामे असू शकत नाही", "about": "विषयी", "pushNotifications": "पुश सूचना", "support": "सपोर्ट", "joinDiscord": "Discord मध्ये सहभागी व्हा", "privacyPolicy": "गोपनीयता धोरण", "userAgreement": "वापरकर्ता करार", "termsAndConditions": "अटी व शर्ती", "userprofileError": "वापरकर्ता प्रोफाइल लोड करण्यात अयशस्वी", "userprofileErrorDescription": "कृपया लॉगआउट करून पुन्हा लॉगिन करा आणि त्रुटी अजूनही येते का ते पहा.", "selectLayout": "लेआउट निवडा", "selectStartingDay": "सप्ताहाचा प्रारंभ दिवस निवडा", "version": "आवृत्ती" }, "grid": { "deleteView": "तुम्हाला हे दृश्य हटवायचे आहे का?", "createView": "नवीन", "title": { "placeholder": "नाव नाही" }, "settings": { "filter": "फिल्टर", "sort": "क्रमवारी", "sortBy": "यावरून क्रमवारी लावा", "properties": "गुणधर्म", "reorderPropertiesTooltip": "गुणधर्मांचे स्थान बदला", "group": "समूह", "addFilter": "फिल्टर जोडा", "deleteFilter": "फिल्टर हटवा", "filterBy": "यावरून फिल्टर करा", "typeAValue": "मूल्य लिहा...", "layout": "लेआउट", "compactMode": "कॉम्पॅक्ट मोड", "databaseLayout": "लेआउट", "viewList": { "zero": "० दृश्ये", "one": "{count} दृश्य", "other": "{count} दृश्ये" }, "editView": "दृश्य संपादित करा", "boardSettings": "बोर्ड सेटिंग", "calendarSettings": "कॅलेंडर सेटिंग", "createView": "नवीन दृश्य", "duplicateView": "दृश्याची प्रत बनवा", "deleteView": "दृश्य हटवा", "numberOfVisibleFields": "{} दर्शविले" }, "filter": { "empty": "कोणतेही सक्रिय फिल्टर नाहीत", "addFilter": "फिल्टर जोडा", "cannotFindCreatableField": "फिल्टर करण्यासाठी योग्य फील्ड सापडले नाही", "conditon": "अट", "where": "जिथे" }, "textFilter": { "contains": "अंतर्भूत आहे", "doesNotContain": "अंतर्भूत नाही", "endsWith": "याने समाप्त होते", "startWith": "याने सुरू होते", "is": "आहे", "isNot": "नाही", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही", "choicechipPrefix": { "isNot": "नाही", "startWith": "याने सुरू होते", "endWith": "याने समाप्त होते", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही" } }, "checkboxFilter": { "isChecked": "निवडलेले आहे", "isUnchecked": "निवडलेले नाही", "choicechipPrefix": { "is": "आहे" } }, "checklistFilter": { "isComplete": "पूर्ण झाले आहे", "isIncomplted": "अपूर्ण आहे" }, "selectOptionFilter": { "is": "आहे", "isNot": "नाही", "contains": "अंतर्भूत आहे", "doesNotContain": "अंतर्भूत नाही", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही" }, "dateFilter": { "is": "या दिवशी आहे", "before": "पूर्वी आहे", "after": "नंतर आहे", "onOrBefore": "या दिवशी किंवा त्याआधी आहे", "onOrAfter": "या दिवशी किंवा त्यानंतर आहे", "between": "दरम्यान आहे", "empty": "रिकामे आहे", "notEmpty": "रिकामे नाही", "startDate": "सुरुवातीची तारीख", "endDate": "शेवटची तारीख", "choicechipPrefix": { "before": "पूर्वी", "after": "नंतर", "between": "दरम्यान", "onOrBefore": "या दिवशी किंवा त्याआधी", "onOrAfter": "या दिवशी किंवा त्यानंतर", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही" } }, "numberFilter": { "equal": "बरोबर आहे", "notEqual": "बरोबर नाही", "lessThan": "पेक्षा कमी आहे", "greaterThan": "पेक्षा जास्त आहे", "lessThanOrEqualTo": "किंवा कमी आहे", "greaterThanOrEqualTo": "किंवा जास्त आहे", "isEmpty": "रिकामे आहे", "isNotEmpty": "रिकामे नाही" }, "field": { "label": "गुणधर्म", "hide": "गुणधर्म लपवा", "show": "गुणधर्म दर्शवा", "insertLeft": "डावीकडे जोडा", "insertRight": "उजवीकडे जोडा", "duplicate": "प्रत बनवा", "delete": "हटवा", "wrapCellContent": "पाठ लपेटा", "clear": "सेल्स रिकामे करा", "switchPrimaryFieldTooltip": "प्राथमिक फील्डचा प्रकार बदलू शकत नाही", "textFieldName": "मजकूर", "checkboxFieldName": "चेकबॉक्स", "dateFieldName": "तारीख", "updatedAtFieldName": "शेवटचे अपडेट", "createdAtFieldName": "तयार झाले", "numberFieldName": "संख्या", "singleSelectFieldName": "सिंगल सिलेक्ट", "multiSelectFieldName": "मल्टीसिलेक्ट", "urlFieldName": "URL", "checklistFieldName": "चेकलिस्ट", "relationFieldName": "संबंध", "summaryFieldName": "AI सारांश", "timeFieldName": "वेळ", "mediaFieldName": "फाईल्स आणि मीडिया", "translateFieldName": "AI भाषांतर", "translateTo": "मध्ये भाषांतर करा", "numberFormat": "संख्या स्वरूप", "dateFormat": "तारीख स्वरूप", "includeTime": "वेळ जोडा", "isRange": "शेवटची तारीख", "dateFormatFriendly": "महिना दिवस, वर्ष", "dateFormatISO": "वर्ष-महिना-दिनांक", "dateFormatLocal": "महिना/दिवस/वर्ष", "dateFormatUS": "वर्ष/महिना/दिवस", "dateFormatDayMonthYear": "दिवस/महिना/वर्ष", "timeFormat": "वेळ स्वरूप", "invalidTimeFormat": "अवैध स्वरूप", "timeFormatTwelveHour": "१२ तास", "timeFormatTwentyFourHour": "२४ तास", "clearDate": "तारीख हटवा", "dateTime": "तारीख व वेळ", "startDateTime": "सुरुवातीची तारीख व वेळ", "endDateTime": "शेवटची तारीख व वेळ", "failedToLoadDate": "तारीख मूल्य लोड करण्यात अयशस्वी", "selectTime": "वेळ निवडा", "selectDate": "तारीख निवडा", "visibility": "दृश्यता", "propertyType": "गुणधर्माचा प्रकार", "addSelectOption": "पर्याय जोडा", "typeANewOption": "नवीन पर्याय लिहा", "optionTitle": "पर्याय", "addOption": "पर्याय जोडा", "editProperty": "गुणधर्म संपादित करा", "newProperty": "नवीन गुणधर्म", "openRowDocument": "पृष्ठ म्हणून उघडा", "deleteFieldPromptMessage": "तुम्हाला खात्री आहे का? हा गुणधर्म आणि त्याचा डेटा हटवला जाईल", "clearFieldPromptMessage": "तुम्हाला खात्री आहे का? या कॉलममधील सर्व सेल्स रिकामे होतील", "newColumn": "नवीन कॉलम", "format": "स्वरूप", "reminderOnDateTooltip": "या सेलमध्ये अनुस्मारक शेड्यूल केले आहे", "optionAlreadyExist": "पर्याय आधीच अस्तित्वात आहे" }, "rowPage": { "newField": "नवीन फील्ड जोडा", "fieldDragElementTooltip": "मेनू उघडण्यासाठी क्लिक करा", "showHiddenFields": { "one": "{count} लपलेले फील्ड दाखवा", "many": "{count} लपलेली फील्ड दाखवा", "other": "{count} लपलेली फील्ड दाखवा" }, "hideHiddenFields": { "one": "{count} लपलेले फील्ड लपवा", "many": "{count} लपलेली फील्ड लपवा", "other": "{count} लपलेली फील्ड लपवा" }, "openAsFullPage": "पूर्ण पृष्ठ म्हणून उघडा", "moreRowActions": "अधिक पंक्ती क्रिया" }, "sort": { "ascending": "चढत्या क्रमाने", "descending": "उतरत्या क्रमाने", "by": "द्वारे", "empty": "सक्रिय सॉर्ट्स नाहीत", "cannotFindCreatableField": "सॉर्टसाठी योग्य फील्ड सापडले नाही", "deleteAllSorts": "सर्व सॉर्ट्स हटवा", "addSort": "सॉर्ट जोडा", "sortsActive": "सॉर्टिंग करत असताना {intention} करू शकत नाही", "removeSorting": "या दृश्यातील सर्व सॉर्ट्स हटवायचे आहेत का?", "fieldInUse": "या फील्डवर आधीच सॉर्टिंग चालू आहे" }, "row": { "label": "पंक्ती", "duplicate": "प्रत बनवा", "delete": "हटवा", "titlePlaceholder": "शीर्षक नाही", "textPlaceholder": "रिक्त", "copyProperty": "गुणधर्म क्लिपबोर्डवर कॉपी केला", "count": "संख्या", "newRow": "नवीन पंक्ती", "loadMore": "अधिक लोड करा", "action": "क्रिया", "add": "खाली जोडा वर क्लिक करा", "drag": "हलवण्यासाठी ड्रॅग करा", "deleteRowPrompt": "तुम्हाला खात्री आहे का की ही पंक्ती हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", "deleteCardPrompt": "तुम्हाला खात्री आहे का की हे कार्ड हटवायचे आहे? ही क्रिया पूर्ववत करता येणार नाही.", "dragAndClick": "हलवण्यासाठी ड्रॅग करा, मेनू उघडण्यासाठी क्लिक करा", "insertRecordAbove": "वर रेकॉर्ड जोडा", "insertRecordBelow": "खाली रेकॉर्ड जोडा", "noContent": "माहिती नाही", "reorderRowDescription": "पंक्तीचे पुन्हा क्रमांकन", "createRowAboveDescription": "वर पंक्ती तयार करा", "createRowBelowDescription": "खाली पंक्ती जोडा" }, "selectOption": { "create": "तयार करा", "purpleColor": "जांभळा", "pinkColor": "गुलाबी", "lightPinkColor": "फिकट गुलाबी", "orangeColor": "नारंगी", "yellowColor": "पिवळा", "limeColor": "लिंबू", "greenColor": "हिरवा", "aquaColor": "आक्वा", "blueColor": "निळा", "deleteTag": "टॅग हटवा", "colorPanelTitle": "रंग", "panelTitle": "पर्याय निवडा किंवा नवीन तयार करा", "searchOption": "पर्याय शोधा", "searchOrCreateOption": "पर्याय शोधा किंवा तयार करा", "createNew": "नवीन तयार करा", "orSelectOne": "किंवा पर्याय निवडा", "typeANewOption": "नवीन पर्याय टाइप करा", "tagName": "टॅग नाव" }, "checklist": { "taskHint": "कार्याचे वर्णन", "addNew": "नवीन कार्य जोडा", "submitNewTask": "तयार करा", "hideComplete": "पूर्ण कार्ये लपवा", "showComplete": "सर्व कार्ये दाखवा" }, "url": { "launch": "बाह्य ब्राउझरमध्ये लिंक उघडा", "copy": "लिंक क्लिपबोर्डवर कॉपी करा", "textFieldHint": "URL टाका", "copiedNotification": "क्लिपबोर्डवर कॉपी केले!" }, "relation": { "relatedDatabasePlaceLabel": "संबंधित डेटाबेस", "relatedDatabasePlaceholder": "काही नाही", "inRelatedDatabase": "या मध्ये", "rowSearchTextFieldPlaceholder": "शोध", "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया खालील यादीतून एक निवडा:", "emptySearchResult": "कोणतीही नोंद सापडली नाही", "linkedRowListLabel": "{count} लिंक केलेल्या पंक्ती", "unlinkedRowListLabel": "आणखी एक पंक्ती लिंक करा" }, "menuName": "ग्रिड", "referencedGridPrefix": "दृश्य", "calculate": "गणना करा", "calculationTypeLabel": { "none": "काही नाही", "average": "सरासरी", "max": "कमाल", "median": "मध्यम", "min": "किमान", "sum": "बेरीज", "count": "मोजणी", "countEmpty": "रिकाम्यांची मोजणी", "countEmptyShort": "रिक्त", "countNonEmpty": "रिक्त नसलेल्यांची मोजणी", "countNonEmptyShort": "भरलेले" }, "media": { "rename": "पुन्हा नाव द्या", "download": "डाउनलोड करा", "expand": "मोठे करा", "delete": "हटवा", "moreFilesHint": "+{}", "addFileOrImage": "फाईल किंवा लिंक जोडा", "attachmentsHint": "{}", "addFileMobile": "फाईल जोडा", "extraCount": "+{}", "deleteFileDescription": "तुम्हाला खात्री आहे का की ही फाईल हटवायची आहे? ही क्रिया पूर्ववत करता येणार नाही.", "showFileNames": "फाईलचे नाव दाखवा", "downloadSuccess": "फाईल डाउनलोड झाली", "downloadFailedToken": "फाईल डाउनलोड अयशस्वी, वापरकर्ता टोकन अनुपलब्ध", "setAsCover": "कव्हर म्हणून सेट करा", "openInBrowser": "ब्राउझरमध्ये उघडा", "embedLink": "फाईल लिंक एम्बेड करा" } }, "document": { "menuName": "दस्तऐवज", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "तयार करत आहे...", "slashMenu": { "board": { "selectABoardToLinkTo": "लिंक करण्यासाठी बोर्ड निवडा", "createANewBoard": "नवीन बोर्ड तयार करा" }, "grid": { "selectAGridToLinkTo": "लिंक करण्यासाठी ग्रिड निवडा", "createANewGrid": "नवीन ग्रिड तयार करा" }, "calendar": { "selectACalendarToLinkTo": "लिंक करण्यासाठी दिनदर्शिका निवडा", "createANewCalendar": "नवीन दिनदर्शिका तयार करा" }, "document": { "selectADocumentToLinkTo": "लिंक करण्यासाठी दस्तऐवज निवडा" }, "name": { "textStyle": "मजकुराची शैली", "list": "यादी", "toggle": "टॉगल", "fileAndMedia": "फाईल व मीडिया", "simpleTable": "सोपे टेबल", "visuals": "दृश्य घटक", "document": "दस्तऐवज", "advanced": "प्रगत", "text": "मजकूर", "heading1": "शीर्षक 1", "heading2": "शीर्षक 2", "heading3": "शीर्षक 3", "image": "प्रतिमा", "bulletedList": "बुलेट यादी", "numberedList": "क्रमांकित यादी", "todoList": "करण्याची यादी", "doc": "दस्तऐवज", "linkedDoc": "पृष्ठाशी लिंक करा", "grid": "ग्रिड", "linkedGrid": "लिंक केलेला ग्रिड", "kanban": "कानबन", "linkedKanban": "लिंक केलेला कानबन", "calendar": "दिनदर्शिका", "linkedCalendar": "लिंक केलेली दिनदर्शिका", "quote": "उद्धरण", "divider": "विभाजक", "table": "टेबल", "callout": "महत्त्वाचा मजकूर", "outline": "रूपरेषा", "mathEquation": "गणिती समीकरण", "code": "कोड", "toggleList": "टॉगल यादी", "toggleHeading1": "टॉगल शीर्षक 1", "toggleHeading2": "टॉगल शीर्षक 2", "toggleHeading3": "टॉगल शीर्षक 3", "emoji": "इमोजी", "aiWriter": "AI ला काहीही विचारा", "dateOrReminder": "दिनांक किंवा स्मरणपत्र", "photoGallery": "फोटो गॅलरी", "file": "फाईल", "twoColumns": "२ स्तंभ", "threeColumns": "३ स्तंभ", "fourColumns": "४ स्तंभ" }, "subPage": { "name": "दस्तऐवज", "keyword1": "उपपृष्ठ", "keyword2": "पृष्ठ", "keyword3": "चाइल्ड पृष्ठ", "keyword4": "पृष्ठ जोडा", "keyword5": "एम्बेड पृष्ठ", "keyword6": "नवीन पृष्ठ", "keyword7": "पृष्ठ तयार करा", "keyword8": "दस्तऐवज" } }, "selectionMenu": { "outline": "रूपरेषा", "codeBlock": "कोड ब्लॉक" }, "plugins": { "referencedBoard": "संदर्भित बोर्ड", "referencedGrid": "संदर्भित ग्रिड", "referencedCalendar": "संदर्भित दिनदर्शिका", "referencedDocument": "संदर्भित दस्तऐवज", "aiWriter": { "userQuestion": "AI ला काहीही विचारा", "continueWriting": "लेखन सुरू ठेवा", "fixSpelling": "स्पेलिंग व व्याकरण सुधारणा", "improveWriting": "लेखन सुधारित करा", "summarize": "सारांश द्या", "explain": "स्पष्टीकरण द्या", "makeShorter": "लहान करा", "makeLonger": "मोठे करा" }, "autoGeneratorMenuItemName": "AI लेखक", "autoGeneratorTitleName": "AI: काहीही लिहिण्यासाठी AI ला विचारा...", "autoGeneratorLearnMore": "अधिक जाणून घ्या", "autoGeneratorGenerate": "उत्पन्न करा", "autoGeneratorHintText": "AI ला विचारा...", "autoGeneratorCantGetOpenAIKey": "AI की मिळवू शकलो नाही", "autoGeneratorRewrite": "पुन्हा लिहा", "smartEdit": "AI ला विचारा", "aI": "AI", "smartEditFixSpelling": "स्पेलिंग आणि व्याकरण सुधारा", "warning": "⚠️ AI उत्तरं चुकीची किंवा दिशाभूल करणारी असू शकतात.", "smartEditSummarize": "सारांश द्या", "smartEditImproveWriting": "लेखन सुधारित करा", "smartEditMakeLonger": "लांब करा", "smartEditCouldNotFetchResult": "AI कडून उत्तर मिळवता आले नाही", "smartEditCouldNotFetchKey": "AI की मिळवता आली नाही", "smartEditDisabled": "सेटिंग्जमध्ये AI कनेक्ट करा", "appflowyAIEditDisabled": "AI वैशिष्ट्ये सक्षम करण्यासाठी साइन इन करा", "discardResponse": "AI उत्तर फेकून द्यायचं आहे का?", "createInlineMathEquation": "समीकरण तयार करा", "fonts": "फॉन्ट्स", "insertDate": "तारीख जोडा", "emoji": "इमोजी", "toggleList": "टॉगल यादी", "emptyToggleHeading": "रिकामे टॉगल h{}. मजकूर जोडण्यासाठी क्लिक करा.", "emptyToggleList": "रिकामी टॉगल यादी. मजकूर जोडण्यासाठी क्लिक करा.", "emptyToggleHeadingWeb": "रिकामे टॉगल h{level}. मजकूर जोडण्यासाठी क्लिक करा", "quoteList": "उद्धरण यादी", "numberedList": "क्रमांकित यादी", "bulletedList": "बुलेट यादी", "todoList": "करण्याची यादी", "callout": "ठळक मजकूर", "simpleTable": { "moreActions": { "color": "रंग", "align": "पंक्तिबद्ध करा", "delete": "हटा", "duplicate": "डुप्लिकेट करा", "insertLeft": "डावीकडे घाला", "insertRight": "उजवीकडे घाला", "insertAbove": "वर घाला", "insertBelow": "खाली घाला", "headerColumn": "हेडर स्तंभ", "headerRow": "हेडर ओळ", "clearContents": "सामग्री साफ करा", "setToPageWidth": "पृष्ठाच्या रुंदीप्रमाणे सेट करा", "distributeColumnsWidth": "स्तंभ समान करा", "duplicateRow": "ओळ डुप्लिकेट करा", "duplicateColumn": "स्तंभ डुप्लिकेट करा", "textColor": "मजकूराचा रंग", "cellBackgroundColor": "सेलचा पार्श्वभूमी रंग", "duplicateTable": "टेबल डुप्लिकेट करा" }, "clickToAddNewRow": "नवीन ओळ जोडण्यासाठी क्लिक करा", "clickToAddNewColumn": "नवीन स्तंभ जोडण्यासाठी क्लिक करा", "clickToAddNewRowAndColumn": "नवीन ओळ आणि स्तंभ जोडण्यासाठी क्लिक करा", "headerName": { "table": "टेबल", "alignText": "मजकूर पंक्तिबद्ध करा" } }, "cover": { "changeCover": "कव्हर बदला", "colors": "रंग", "images": "प्रतिमा", "clearAll": "सर्व साफ करा", "abstract": "ऍबस्ट्रॅक्ट", "addCover": "कव्हर जोडा", "addLocalImage": "स्थानिक प्रतिमा जोडा", "invalidImageUrl": "अवैध प्रतिमा URL", "failedToAddImageToGallery": "प्रतिमा गॅलरीत जोडता आली नाही", "enterImageUrl": "प्रतिमा URL लिहा", "add": "जोडा", "back": "मागे", "saveToGallery": "गॅलरीत जतन करा", "removeIcon": "आयकॉन काढा", "removeCover": "कव्हर काढा", "pasteImageUrl": "प्रतिमा URL पेस्ट करा", "or": "किंवा", "pickFromFiles": "फाईल्समधून निवडा", "couldNotFetchImage": "प्रतिमा मिळवता आली नाही", "imageSavingFailed": "प्रतिमा जतन करणे अयशस्वी", "addIcon": "आयकॉन जोडा", "changeIcon": "आयकॉन बदला", "coverRemoveAlert": "हे हटवल्यानंतर कव्हरमधून काढले जाईल.", "alertDialogConfirmation": "तुम्हाला खात्री आहे का? तुम्हाला पुढे जायचे आहे?" }, "mathEquation": { "name": "गणिती समीकरण", "addMathEquation": "TeX समीकरण जोडा", "editMathEquation": "गणिती समीकरण संपादित करा" }, "optionAction": { "click": "क्लिक", "toOpenMenu": "मेनू उघडण्यासाठी", "drag": "ओढा", "toMove": "हलवण्यासाठी", "delete": "हटा", "duplicate": "डुप्लिकेट करा", "turnInto": "मध्ये बदला", "moveUp": "वर हलवा", "moveDown": "खाली हलवा", "color": "रंग", "align": "पंक्तिबद्ध करा", "left": "डावीकडे", "center": "मध्यभागी", "right": "उजवीकडे", "defaultColor": "डिफॉल्ट", "depth": "खोली", "copyLinkToBlock": "ब्लॉकसाठी लिंक कॉपी करा" }, "image": { "addAnImage": "प्रतिमा जोडा", "copiedToPasteBoard": "प्रतिमेची लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", "addAnImageDesktop": "प्रतिमा जोडा", "addAnImageMobile": "एक किंवा अधिक प्रतिमा जोडण्यासाठी क्लिक करा", "dropImageToInsert": "प्रतिमा ड्रॉप करून जोडा", "imageUploadFailed": "प्रतिमा अपलोड करण्यात अयशस्वी", "imageDownloadFailed": "प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", "imageDownloadFailedToken": "युजर टोकन नसल्यामुळे प्रतिमा डाउनलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा", "errorCode": "त्रुटी कोड" }, "photoGallery": { "name": "फोटो गॅलरी", "imageKeyword": "प्रतिमा", "imageGalleryKeyword": "प्रतिमा गॅलरी", "photoKeyword": "फोटो", "photoBrowserKeyword": "फोटो ब्राउझर", "galleryKeyword": "गॅलरी", "addImageTooltip": "प्रतिमा जोडा", "changeLayoutTooltip": "लेआउट बदला", "browserLayout": "ब्राउझर", "gridLayout": "ग्रिड", "deleteBlockTooltip": "संपूर्ण गॅलरी हटवा" }, "math": { "copiedToPasteBoard": "समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे" }, "urlPreview": { "copiedToPasteBoard": "लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", "convertToLink": "एंबेड लिंकमध्ये रूपांतर करा" }, "outline": { "addHeadingToCreateOutline": "सामग्री यादी तयार करण्यासाठी शीर्षके जोडा.", "noMatchHeadings": "जुळणारी शीर्षके आढळली नाहीत." }, "table": { "addAfter": "नंतर जोडा", "addBefore": "आधी जोडा", "delete": "हटा", "clear": "सामग्री साफ करा", "duplicate": "डुप्लिकेट करा", "bgColor": "पार्श्वभूमीचा रंग" }, "contextMenu": { "copy": "कॉपी करा", "cut": "कापा", "paste": "पेस्ट करा", "pasteAsPlainText": "साध्या मजकूराच्या स्वरूपात पेस्ट करा" }, "action": "कृती", "database": { "selectDataSource": "डेटा स्रोत निवडा", "noDataSource": "डेटा स्रोत नाही", "selectADataSource": "डेटा स्रोत निवडा", "toContinue": "पुढे जाण्यासाठी", "newDatabase": "नवीन डेटाबेस", "linkToDatabase": "डेटाबेसशी लिंक करा" }, "date": "तारीख", "video": { "label": "व्हिडिओ", "emptyLabel": "व्हिडिओ जोडा", "placeholder": "व्हिडिओ लिंक पेस्ट करा", "copiedToPasteBoard": "व्हिडिओ लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", "insertVideo": "व्हिडिओ जोडा", "invalidVideoUrl": "ही URL सध्या समर्थित नाही.", "invalidVideoUrlYouTube": "YouTube सध्या समर्थित नाही.", "supportedFormats": "समर्थित स्वरूप: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "फाईल", "uploadTab": "अपलोड", "uploadMobile": "फाईल निवडा", "uploadMobileGallery": "फोटो गॅलरीमधून", "networkTab": "लिंक एम्बेड करा", "placeholderText": "फाईल अपलोड किंवा एम्बेड करा", "placeholderDragging": "फाईल ड्रॉप करून अपलोड करा", "dropFileToUpload": "फाईल ड्रॉप करून अपलोड करा", "fileUploadHint": "फाईल ड्रॅग आणि ड्रॉप करा किंवा क्लिक करा ", "fileUploadHintSuffix": "ब्राउझ करा", "networkHint": "फाईल लिंक पेस्ट करा", "networkUrlInvalid": "अवैध URL. कृपया तपासा आणि पुन्हा प्रयत्न करा.", "networkAction": "एम्बेड", "fileTooBigError": "फाईल खूप मोठी आहे, कृपया 10MB पेक्षा कमी फाईल अपलोड करा", "renameFile": { "title": "फाईलचे नाव बदला", "description": "या फाईलसाठी नवीन नाव लिहा", "nameEmptyError": "फाईलचे नाव रिकामे असू शकत नाही." }, "uploadedAt": "{} रोजी अपलोड केले", "linkedAt": "{} रोजी लिंक जोडली", "failedToOpenMsg": "उघडण्यात अयशस्वी, फाईल सापडली नाही" }, "subPage": { "handlingPasteHint": " - (पेस्ट प्रक्रिया करत आहे)", "errors": { "failedDeletePage": "पृष्ठ हटवण्यात अयशस्वी", "failedCreatePage": "पृष्ठ तयार करण्यात अयशस्वी", "failedMovePage": "हे पृष्ठ हलवण्यात अयशस्वी", "failedDuplicatePage": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी", "failedDuplicateFindView": "पृष्ठ डुप्लिकेट करण्यात अयशस्वी - मूळ दृश्य सापडले नाही" } }, "cannotMoveToItsChildren": "मुलांमध्ये हलवू शकत नाही" }, "outlineBlock": { "placeholder": "सामग्री सूची" }, "textBlock": { "placeholder": "कमांडसाठी '/' टाइप करा" }, "title": { "placeholder": "शीर्षक नाही" }, "imageBlock": { "placeholder": "प्रतिमा जोडण्यासाठी क्लिक करा", "upload": { "label": "अपलोड", "placeholder": "प्रतिमा अपलोड करण्यासाठी क्लिक करा" }, "url": { "label": "प्रतिमेची URL", "placeholder": "प्रतिमेची URL टाका" }, "ai": { "label": "AI द्वारे प्रतिमा तयार करा", "placeholder": "AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" }, "stability_ai": { "label": "Stability AI द्वारे प्रतिमा तयार करा", "placeholder": "Stability AI द्वारे प्रतिमा तयार करण्यासाठी कृपया संकेत द्या" }, "support": "प्रतिमेचा कमाल आकार 5MB आहे. समर्थित स्वरूप: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "अवैध प्रतिमा", "invalidImageSize": "प्रतिमेचा आकार 5MB पेक्षा कमी असावा", "invalidImageFormat": "प्रतिमेचे स्वरूप समर्थित नाही. समर्थित स्वरूप: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "अवैध प्रतिमेची URL", "noImage": "अशी फाईल किंवा निर्देशिका नाही", "multipleImagesFailed": "एक किंवा अधिक प्रतिमा अपलोड करण्यात अयशस्वी, कृपया पुन्हा प्रयत्न करा" }, "embedLink": { "label": "लिंक एम्बेड करा", "placeholder": "प्रतिमेची लिंक पेस्ट करा किंवा टाका" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "प्रतिमा शोधा", "pleaseInputYourOpenAIKey": "कृपया सेटिंग्ज पृष्ठात आपला AI की प्रविष्ट करा", "saveImageToGallery": "प्रतिमा जतन करा", "failedToAddImageToGallery": "प्रतिमा जतन करण्यात अयशस्वी", "successToAddImageToGallery": "प्रतिमा 'Photos' मध्ये जतन केली", "unableToLoadImage": "प्रतिमा लोड करण्यात अयशस्वी", "maximumImageSize": "कमाल प्रतिमा अपलोड आकार 10MB आहे", "uploadImageErrorImageSizeTooBig": "प्रतिमेचा आकार 10MB पेक्षा कमी असावा", "imageIsUploading": "प्रतिमा अपलोड होत आहे", "openFullScreen": "पूर्ण स्क्रीनमध्ये उघडा", "interactiveViewer": { "toolbar": { "previousImageTooltip": "मागील प्रतिमा", "nextImageTooltip": "पुढील प्रतिमा", "zoomOutTooltip": "लहान करा", "zoomInTooltip": "मोठी करा", "changeZoomLevelTooltip": "झूम पातळी बदला", "openLocalImage": "प्रतिमा उघडा", "downloadImage": "प्रतिमा डाउनलोड करा", "closeViewer": "इंटरअॅक्टिव्ह व्ह्युअर बंद करा", "scalePercentage": "{}%", "deleteImageTooltip": "प्रतिमा हटवा" } } }, "codeBlock": { "language": { "label": "भाषा", "placeholder": "भाषा निवडा", "auto": "स्वयंचलित" }, "copyTooltip": "कॉपी करा", "searchLanguageHint": "भाषा शोधा", "codeCopiedSnackbar": "कोड क्लिपबोर्डवर कॉपी झाला!" }, "inlineLink": { "placeholder": "लिंक पेस्ट करा किंवा टाका", "openInNewTab": "नवीन टॅबमध्ये उघडा", "copyLink": "लिंक कॉपी करा", "removeLink": "लिंक काढा", "url": { "label": "लिंक URL", "placeholder": "लिंक URL टाका" }, "title": { "label": "लिंक शीर्षक", "placeholder": "लिंक शीर्षक टाका" } }, "mention": { "placeholder": "कोणीतरी, पृष्ठ किंवा तारीख नमूद करा...", "page": { "label": "पृष्ठाला लिंक करा", "tooltip": "पृष्ठ उघडण्यासाठी क्लिक करा" }, "deleted": "हटवले गेले", "deletedContent": "ही सामग्री अस्तित्वात नाही किंवा हटवण्यात आली आहे", "noAccess": "प्रवेश नाही", "deletedPage": "हटवलेले पृष्ठ", "trashHint": " - ट्रॅशमध्ये", "morePages": "अजून पृष्ठे" }, "toolbar": { "resetToDefaultFont": "डीफॉल्ट फॉन्टवर परत जा", "textSize": "मजकूराचा आकार", "textColor": "मजकूराचा रंग", "h1": "मथळा 1", "h2": "मथळा 2", "h3": "मथळा 3", "alignLeft": "डावीकडे संरेखित करा", "alignRight": "उजवीकडे संरेखित करा", "alignCenter": "मध्यभागी संरेखित करा", "link": "लिंक", "textAlign": "मजकूर संरेखन", "moreOptions": "अधिक पर्याय", "font": "फॉन्ट", "inlineCode": "इनलाइन कोड", "suggestions": "सूचना", "turnInto": "मध्ये रूपांतरित करा", "equation": "समीकरण", "insert": "घाला", "linkInputHint": "लिंक पेस्ट करा किंवा पृष्ठे शोधा", "pageOrURL": "पृष्ठ किंवा URL", "linkName": "लिंकचे नाव", "linkNameHint": "लिंकचे नाव प्रविष्ट करा" }, "errorBlock": { "theBlockIsNotSupported": "ब्लॉक सामग्री पार्स करण्यात अक्षम", "clickToCopyTheBlockContent": "ब्लॉक सामग्री कॉपी करण्यासाठी क्लिक करा", "blockContentHasBeenCopied": "ब्लॉक सामग्री कॉपी केली आहे.", "parseError": "{} ब्लॉक पार्स करताना त्रुटी आली.", "copyBlockContent": "ब्लॉक सामग्री कॉपी करा" }, "mobilePageSelector": { "title": "पृष्ठ निवडा", "failedToLoad": "पृष्ठ यादी लोड करण्यात अयशस्वी", "noPagesFound": "कोणतीही पृष्ठे सापडली नाहीत" }, "attachmentMenu": { "choosePhoto": "फोटो निवडा", "takePicture": "फोटो काढा", "chooseFile": "फाईल निवडा" } }, "board": { "column": { "label": "स्तंभ", "createNewCard": "नवीन", "renameGroupTooltip": "गटाचे नाव बदलण्यासाठी क्लिक करा", "createNewColumn": "नवीन गट जोडा", "addToColumnTopTooltip": "वर नवीन कार्ड जोडा", "addToColumnBottomTooltip": "खाली नवीन कार्ड जोडा", "renameColumn": "स्तंभाचे नाव बदला", "hideColumn": "लपवा", "newGroup": "नवीन गट", "deleteColumn": "हटवा", "deleteColumnConfirmation": "हा गट आणि त्यामधील सर्व कार्ड्स हटवले जातील. तुम्हाला खात्री आहे का?" }, "hiddenGroupSection": { "sectionTitle": "लपवलेले गट", "collapseTooltip": "लपवलेले गट लपवा", "expandTooltip": "लपवलेले गट पाहा" }, "cardDetail": "कार्ड तपशील", "cardActions": "कार्ड क्रिया", "cardDuplicated": "कार्डची प्रत तयार झाली", "cardDeleted": "कार्ड हटवले गेले", "showOnCard": "कार्ड तपशिलावर दाखवा", "setting": "सेटिंग", "propertyName": "गुणधर्माचे नाव", "menuName": "बोर्ड", "showUngrouped": "गटात नसलेली कार्ड्स दाखवा", "ungroupedButtonText": "गट नसलेली", "ungroupedButtonTooltip": "ज्या कार्ड्स कोणत्याही गटात नाहीत", "ungroupedItemsTitle": "बोर्डमध्ये जोडण्यासाठी क्लिक करा", "groupBy": "या आधारावर गट करा", "groupCondition": "गट स्थिती", "referencedBoardPrefix": "याचे दृश्य", "notesTooltip": "नोट्स आहेत", "mobile": { "editURL": "URL संपादित करा", "showGroup": "गट दाखवा", "showGroupContent": "हा गट बोर्डवर दाखवायचा आहे का?", "failedToLoad": "बोर्ड दृश्य लोड होण्यात अयशस्वी" }, "dateCondition": { "weekOf": "{} - {} ची आठवडा", "today": "आज", "yesterday": "काल", "tomorrow": "उद्या", "lastSevenDays": "शेवटचे ७ दिवस", "nextSevenDays": "पुढील ७ दिवस", "lastThirtyDays": "शेवटचे ३० दिवस", "nextThirtyDays": "पुढील ३० दिवस" }, "noGroup": "गटासाठी कोणताही गुणधर्म निवडलेला नाही", "noGroupDesc": "बोर्ड दृश्य दाखवण्यासाठी गट करण्याचा एक गुणधर्म आवश्यक आहे", "media": { "cardText": "{} {}", "fallbackName": "फायली" } }, "calendar": { "menuName": "कॅलेंडर", "defaultNewCalendarTitle": "नाव नाही", "newEventButtonTooltip": "नवीन इव्हेंट जोडा", "navigation": { "today": "आज", "jumpToday": "आजवर जा", "previousMonth": "मागील महिना", "nextMonth": "पुढील महिना", "views": { "day": "दिवस", "week": "आठवडा", "month": "महिना", "year": "वर्ष" } }, "mobileEventScreen": { "emptyTitle": "सध्या कोणतेही इव्हेंट नाहीत", "emptyBody": "या दिवशी इव्हेंट तयार करण्यासाठी प्लस बटणावर क्लिक करा." }, "settings": { "showWeekNumbers": "आठवड्याचे क्रमांक दाखवा", "showWeekends": "सप्ताहांत दाखवा", "firstDayOfWeek": "आठवड्याची सुरुवात", "layoutDateField": "कॅलेंडर मांडणी दिनांकानुसार", "changeLayoutDateField": "मांडणी फील्ड बदला", "noDateTitle": "तारीख नाही", "noDateHint": { "zero": "नियोजित नसलेली इव्हेंट्स येथे दिसतील", "one": "{count} नियोजित नसलेली इव्हेंट", "other": "{count} नियोजित नसलेल्या इव्हेंट्स" }, "unscheduledEventsTitle": "नियोजित नसलेल्या इव्हेंट्स", "clickToAdd": "कॅलेंडरमध्ये जोडण्यासाठी क्लिक करा", "name": "कॅलेंडर सेटिंग्ज", "clickToOpen": "रेकॉर्ड उघडण्यासाठी क्लिक करा" }, "referencedCalendarPrefix": "याचे दृश्य", "quickJumpYear": "या वर्षावर जा", "duplicateEvent": "इव्हेंट डुप्लिकेट करा" }, "errorDialog": { "title": "@:appName त्रुटी", "howToFixFallback": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया GitHub पेजवर त्रुटीबद्दल माहिती देणारे एक issue सबमिट करा.", "howToFixFallbackHint1": "या गैरसोयीबद्दल आम्ही दिलगीर आहोत! कृपया ", "howToFixFallbackHint2": " पेजवर त्रुटीचे वर्णन करणारे issue सबमिट करा.", "github": "GitHub वर पहा" }, "search": { "label": "शोध", "sidebarSearchIcon": "पृष्ठ शोधा आणि पटकन जा", "placeholder": { "actions": "कृती शोधा..." } }, "message": { "copy": { "success": "कॉपी झाले!", "fail": "कॉपी करू शकत नाही" } }, "unSupportBlock": "सध्याचा व्हर्जन या ब्लॉकला समर्थन देत नाही.", "views": { "deleteContentTitle": "तुम्हाला हे {pageType} हटवायचे आहे का?", "deleteContentCaption": "हे {pageType} हटवल्यास, तुम्ही ते trash मधून पुनर्संचयित करू शकता." }, "colors": { "custom": "सानुकूल", "default": "डीफॉल्ट", "red": "लाल", "orange": "संत्रा", "yellow": "पिवळा", "green": "हिरवा", "blue": "निळा", "purple": "जांभळा", "pink": "गुलाबी", "brown": "तपकिरी", "gray": "करड्या रंगाचा" }, "emoji": { "emojiTab": "इमोजी", "search": "इमोजी शोधा", "noRecent": "अलीकडील कोणतेही इमोजी नाहीत", "noEmojiFound": "कोणतेही इमोजी सापडले नाहीत", "filter": "फिल्टर", "random": "योगायोगाने", "selectSkinTone": "त्वचेचा टोन निवडा", "remove": "इमोजी काढा", "categories": { "smileys": "स्मायली आणि भावना", "people": "लोक", "animals": "प्राणी आणि निसर्ग", "food": "अन्न", "activities": "क्रिया", "places": "स्थळे", "objects": "वस्तू", "symbols": "चिन्हे", "flags": "ध्वज", "nature": "निसर्ग", "frequentlyUsed": "नेहमी वापरलेले" }, "skinTone": { "default": "डीफॉल्ट", "light": "हलका", "mediumLight": "मध्यम-हलका", "medium": "मध्यम", "mediumDark": "मध्यम-गडद", "dark": "गडद" }, "openSourceIconsFrom": "खुल्या स्रोताचे आयकॉन्स" }, "inlineActions": { "noResults": "निकाल नाही", "recentPages": "अलीकडील पृष्ठे", "pageReference": "पृष्ठ संदर्भ", "docReference": "दस्तऐवज संदर्भ", "boardReference": "बोर्ड संदर्भ", "calReference": "कॅलेंडर संदर्भ", "gridReference": "ग्रिड संदर्भ", "date": "तारीख", "reminder": { "groupTitle": "स्मरणपत्र", "shortKeyword": "remind" }, "createPage": "\"{}\" उप-पृष्ठ तयार करा" }, "datePicker": { "dateTimeFormatTooltip": "सेटिंग्जमध्ये तारीख आणि वेळ फॉरमॅट बदला", "dateFormat": "तारीख फॉरमॅट", "includeTime": "वेळ समाविष्ट करा", "isRange": "शेवटची तारीख", "timeFormat": "वेळ फॉरमॅट", "clearDate": "तारीख साफ करा", "reminderLabel": "स्मरणपत्र", "selectReminder": "स्मरणपत्र निवडा", "reminderOptions": { "none": "काहीही नाही", "atTimeOfEvent": "इव्हेंटच्या वेळी", "fiveMinsBefore": "५ मिनिटे आधी", "tenMinsBefore": "१० मिनिटे आधी", "fifteenMinsBefore": "१५ मिनिटे आधी", "thirtyMinsBefore": "३० मिनिटे आधी", "oneHourBefore": "१ तास आधी", "twoHoursBefore": "२ तास आधी", "onDayOfEvent": "इव्हेंटच्या दिवशी", "oneDayBefore": "१ दिवस आधी", "twoDaysBefore": "२ दिवस आधी", "oneWeekBefore": "१ आठवडा आधी", "custom": "सानुकूल" } }, "relativeDates": { "yesterday": "काल", "today": "आज", "tomorrow": "उद्या", "oneWeek": "१ आठवडा" }, "notificationHub": { "title": "सूचना", "mobile": { "title": "अपडेट्स" }, "emptyTitle": "सर्व पूर्ण झाले!", "emptyBody": "कोणतीही प्रलंबित सूचना किंवा कृती नाहीत. शांततेचा आनंद घ्या.", "tabs": { "inbox": "इनबॉक्स", "upcoming": "आगामी" }, "actions": { "markAllRead": "सर्व वाचलेल्या म्हणून चिन्हित करा", "showAll": "सर्व", "showUnreads": "न वाचलेल्या" }, "filters": { "ascending": "आरोही", "descending": "अवरोही", "groupByDate": "तारीखेनुसार गटबद्ध करा", "showUnreadsOnly": "फक्त न वाचलेल्या दाखवा", "resetToDefault": "डीफॉल्टवर रीसेट करा" } }, "reminderNotification": { "title": "स्मरणपत्र", "message": "तुम्ही विसरण्याआधी हे तपासण्याचे लक्षात ठेवा!", "tooltipDelete": "हटवा", "tooltipMarkRead": "वाचले म्हणून चिन्हित करा", "tooltipMarkUnread": "न वाचले म्हणून चिन्हित करा" }, "findAndReplace": { "find": "शोधा", "previousMatch": "मागील जुळणारे", "nextMatch": "पुढील जुळणारे", "close": "बंद करा", "replace": "बदला", "replaceAll": "सर्व बदला", "noResult": "कोणतेही निकाल नाहीत", "caseSensitive": "केस सेंसिटिव्ह", "searchMore": "अधिक निकालांसाठी शोधा" }, "error": { "weAreSorry": "आम्ही क्षमस्व आहोत", "loadingViewError": "हे दृश्य लोड करण्यात अडचण येत आहे. कृपया तुमचे इंटरनेट कनेक्शन तपासा, अ‍ॅप रीफ्रेश करा, आणि समस्या कायम असल्यास आमच्याशी संपर्क साधा.", "syncError": "इतर डिव्हाइसमधून डेटा सिंक झाला नाही", "syncErrorHint": "कृपया हे पृष्ठ शेवटचे जिथे संपादित केले होते त्या डिव्हाइसवर उघडा, आणि मग सध्याच्या डिव्हाइसवर पुन्हा उघडा.", "clickToCopy": "एरर कोड कॉपी करण्यासाठी क्लिक करा" }, "editor": { "bold": "जाड", "bulletedList": "बुलेट यादी", "bulletedListShortForm": "बुलेट", "checkbox": "चेकबॉक्स", "embedCode": "कोड एम्बेड करा", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "हायलाइट", "color": "रंग", "image": "प्रतिमा", "date": "तारीख", "page": "पृष्ठ", "italic": "तिरका", "link": "लिंक", "numberedList": "क्रमांकित यादी", "numberedListShortForm": "क्रमांकित", "toggleHeading1ShortForm": "Toggle H1", "toggleHeading2ShortForm": "Toggle H2", "toggleHeading3ShortForm": "Toggle H3", "quote": "कोट", "strikethrough": "ओढून टाका", "text": "मजकूर", "underline": "अधोरेखित", "fontColorDefault": "डीफॉल्ट", "fontColorGray": "धूसर", "fontColorBrown": "तपकिरी", "fontColorOrange": "केशरी", "fontColorYellow": "पिवळा", "fontColorGreen": "हिरवा", "fontColorBlue": "निळा", "fontColorPurple": "जांभळा", "fontColorPink": "पिंग", "fontColorRed": "लाल", "backgroundColorDefault": "डीफॉल्ट पार्श्वभूमी", "backgroundColorGray": "धूसर पार्श्वभूमी", "backgroundColorBrown": "तपकिरी पार्श्वभूमी", "backgroundColorOrange": "केशरी पार्श्वभूमी", "backgroundColorYellow": "पिवळी पार्श्वभूमी", "backgroundColorGreen": "हिरवी पार्श्वभूमी", "backgroundColorBlue": "निळी पार्श्वभूमी", "backgroundColorPurple": "जांभळी पार्श्वभूमी", "backgroundColorPink": "पिंग पार्श्वभूमी", "backgroundColorRed": "लाल पार्श्वभूमी", "backgroundColorLime": "लिंबू पार्श्वभूमी", "backgroundColorAqua": "पाण्याचा पार्श्वभूमी", "done": "पूर्ण", "cancel": "रद्द करा", "tint1": "टिंट 1", "tint2": "टिंट 2", "tint3": "टिंट 3", "tint4": "टिंट 4", "tint5": "टिंट 5", "tint6": "टिंट 6", "tint7": "टिंट 7", "tint8": "टिंट 8", "tint9": "टिंट 9", "lightLightTint1": "जांभळा", "lightLightTint2": "पिंग", "lightLightTint3": "फिकट पिंग", "lightLightTint4": "केशरी", "lightLightTint5": "पिवळा", "lightLightTint6": "लिंबू", "lightLightTint7": "हिरवा", "lightLightTint8": "पाणी", "lightLightTint9": "निळा", "urlHint": "URL", "mobileHeading1": "Heading 1", "mobileHeading2": "Heading 2", "mobileHeading3": "Heading 3", "mobileHeading4": "Heading 4", "mobileHeading5": "Heading 5", "mobileHeading6": "Heading 6", "textColor": "मजकूराचा रंग", "backgroundColor": "पार्श्वभूमीचा रंग", "addYourLink": "तुमची लिंक जोडा", "openLink": "लिंक उघडा", "copyLink": "लिंक कॉपी करा", "removeLink": "लिंक काढा", "editLink": "लिंक संपादित करा", "linkText": "मजकूर", "linkTextHint": "कृपया मजकूर प्रविष्ट करा", "linkAddressHint": "कृपया URL प्रविष्ट करा", "highlightColor": "हायलाइट रंग", "clearHighlightColor": "हायलाइट काढा", "customColor": "स्वतःचा रंग", "hexValue": "Hex मूल्य", "opacity": "अपारदर्शकता", "resetToDefaultColor": "डीफॉल्ट रंगावर रीसेट करा", "ltr": "LTR", "rtl": "RTL", "auto": "स्वयंचलित", "cut": "कट", "copy": "कॉपी", "paste": "पेस्ट", "find": "शोधा", "select": "निवडा", "selectAll": "सर्व निवडा", "previousMatch": "मागील जुळणारे", "nextMatch": "पुढील जुळणारे", "closeFind": "बंद करा", "replace": "बदला", "replaceAll": "सर्व बदला", "regex": "Regex", "caseSensitive": "केस सेंसिटिव्ह", "uploadImage": "प्रतिमा अपलोड करा", "urlImage": "URL प्रतिमा", "incorrectLink": "चुकीची लिंक", "upload": "अपलोड", "chooseImage": "प्रतिमा निवडा", "loading": "लोड करत आहे", "imageLoadFailed": "प्रतिमा लोड करण्यात अयशस्वी", "divider": "विभाजक", "table": "तक्त्याचे स्वरूप", "colAddBefore": "यापूर्वी स्तंभ जोडा", "rowAddBefore": "यापूर्वी पंक्ती जोडा", "colAddAfter": "यानंतर स्तंभ जोडा", "rowAddAfter": "यानंतर पंक्ती जोडा", "colRemove": "स्तंभ काढा", "rowRemove": "पंक्ती काढा", "colDuplicate": "स्तंभ डुप्लिकेट", "rowDuplicate": "पंक्ती डुप्लिकेट", "colClear": "सामग्री साफ करा", "rowClear": "सामग्री साफ करा", "slashPlaceHolder": "'/' टाइप करा आणि घटक जोडा किंवा टाइप सुरू करा", "typeSomething": "काहीतरी लिहा...", "toggleListShortForm": "टॉगल", "quoteListShortForm": "कोट", "mathEquationShortForm": "सूत्र", "codeBlockShortForm": "कोड" }, "favorite": { "noFavorite": "कोणतेही आवडते पृष्ठ नाही", "noFavoriteHintText": "पृष्ठाला डावीकडे स्वाइप करा आणि ते आवडत्या यादीत जोडा", "removeFromSidebar": "साइडबारमधून काढा", "addToSidebar": "साइडबारमध्ये पिन करा" }, "cardDetails": { "notesPlaceholder": "/ टाइप करा ब्लॉक घालण्यासाठी, किंवा टाइप करायला सुरुवात करा" }, "blockPlaceholders": { "todoList": "करण्याची यादी", "bulletList": "यादी", "numberList": "क्रमांकित यादी", "quote": "कोट", "heading": "मथळा {}" }, "titleBar": { "pageIcon": "पृष्ठ चिन्ह", "language": "भाषा", "font": "फॉन्ट", "actions": "क्रिया", "date": "तारीख", "addField": "फील्ड जोडा", "userIcon": "वापरकर्त्याचे चिन्ह" }, "noLogFiles": "कोणतीही लॉग फाइल्स नाहीत", "newSettings": { "myAccount": { "title": "माझे खाते", "subtitle": "तुमचा प्रोफाइल सानुकूल करा, खाते सुरक्षा व्यवस्थापित करा, AI कीज पहा किंवा लॉगिन करा.", "profileLabel": "खाते नाव आणि प्रोफाइल चित्र", "profileNamePlaceholder": "तुमचे नाव प्रविष्ट करा", "accountSecurity": "खाते सुरक्षा", "2FA": "2-स्टेप प्रमाणीकरण", "aiKeys": "AI कीज", "accountLogin": "खाते लॉगिन", "updateNameError": "नाव अपडेट करण्यात अयशस्वी", "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", "aboutAppFlowy": "@:appName विषयी", "deleteAccount": { "title": "खाते हटवा", "subtitle": "तुमचे खाते आणि सर्व डेटा कायमचे हटवा.", "description": "तुमचे खाते कायमचे हटवले जाईल आणि सर्व वर्कस्पेसमधून प्रवेश काढून टाकला जाईल.", "deleteMyAccount": "माझे खाते हटवा", "dialogTitle": "खाते हटवा", "dialogContent1": "तुम्हाला खात्री आहे की तुम्ही तुमचे खाते कायमचे हटवू इच्छिता?", "dialogContent2": "ही क्रिया पूर्ववत केली जाऊ शकत नाही. हे सर्व वर्कस्पेसमधून प्रवेश हटवेल, खाजगी वर्कस्पेस मिटवेल, आणि सर्व शेअर्ड वर्कस्पेसमधून काढून टाकेल.", "confirmHint1": "कृपया पुष्टीसाठी \"@:newSettings.myAccount.deleteAccount.confirmHint3\" टाइप करा.", "confirmHint2": "मला समजले आहे की ही क्रिया अपरिवर्तनीय आहे आणि माझे खाते व सर्व संबंधित डेटा कायमचा हटवला जाईल.", "confirmHint3": "DELETE MY ACCOUNT", "checkToConfirmError": "हटवण्यासाठी पुष्टी बॉक्स निवडणे आवश्यक आहे", "failedToGetCurrentUser": "वर्तमान वापरकर्त्याचा ईमेल मिळवण्यात अयशस्वी", "confirmTextValidationFailed": "तुमचा पुष्टी मजकूर \"@:newSettings.myAccount.deleteAccount.confirmHint3\" शी जुळत नाही", "deleteAccountSuccess": "खाते यशस्वीरित्या हटवले गेले" } }, "workplace": { "name": "वर्कस्पेस", "title": "वर्कस्पेस सेटिंग्स", "subtitle": "तुमचा वर्कस्पेस लुक, थीम, फॉन्ट, मजकूर लेआउट, तारीख, वेळ आणि भाषा सानुकूल करा.", "workplaceName": "वर्कस्पेसचे नाव", "workplaceNamePlaceholder": "वर्कस्पेसचे नाव टाका", "workplaceIcon": "वर्कस्पेस चिन्ह", "workplaceIconSubtitle": "एक प्रतिमा अपलोड करा किंवा इमोजी वापरा. हे साइडबार आणि सूचना मध्ये दर्शवले जाईल.", "renameError": "वर्कस्पेसचे नाव बदलण्यात अयशस्वी", "updateIconError": "चिन्ह अपडेट करण्यात अयशस्वी", "chooseAnIcon": "चिन्ह निवडा", "appearance": { "name": "दृश्यरूप", "themeMode": { "auto": "स्वयंचलित", "light": "प्रकाश मोड", "dark": "गडद मोड" }, "language": "भाषा" } }, "syncState": { "syncing": "सिंक्रोनायझ करत आहे", "synced": "सिंक्रोनायझ झाले", "noNetworkConnected": "नेटवर्क कनेक्ट केलेले नाही" } }, "pageStyle": { "title": "पृष्ठ शैली", "layout": "लेआउट", "coverImage": "मुखपृष्ठ प्रतिमा", "pageIcon": "पृष्ठ चिन्ह", "colors": "रंग", "gradient": "ग्रेडियंट", "backgroundImage": "पार्श्वभूमी प्रतिमा", "presets": "पूर्वनियोजित", "photo": "फोटो", "unsplash": "Unsplash", "pageCover": "पृष्ठ कव्हर", "none": "काही नाही", "openSettings": "सेटिंग्स उघडा", "photoPermissionTitle": "@:appName तुमच्या फोटो लायब्ररीमध्ये प्रवेश करू इच्छित आहे", "photoPermissionDescription": "तुमच्या कागदपत्रांमध्ये प्रतिमा जोडण्यासाठी @:appName ला तुमच्या फोटोंमध्ये प्रवेश आवश्यक आहे", "cameraPermissionTitle": "@:appName तुमच्या कॅमेऱ्याला प्रवेश करू इच्छित आहे", "cameraPermissionDescription": "कॅमेऱ्यातून प्रतिमा जोडण्यासाठी @:appName ला तुमच्या कॅमेऱ्याचा प्रवेश आवश्यक आहे", "doNotAllow": "परवानगी देऊ नका", "image": "प्रतिमा" }, "commandPalette": { "placeholder": "शोधा किंवा प्रश्न विचारा...", "bestMatches": "सर्वोत्तम जुळवणी", "recentHistory": "अलीकडील इतिहास", "navigateHint": "नेव्हिगेट करण्यासाठी", "loadingTooltip": "आम्ही निकाल शोधत आहोत...", "betaLabel": "बेटा", "betaTooltip": "सध्या आम्ही फक्त दस्तऐवज आणि पृष्ठ शोध समर्थन करतो", "fromTrashHint": "कचरापेटीतून", "noResultsHint": "आपण जे शोधत आहात ते सापडले नाही, कृपया दुसरा शब्द वापरून शोधा.", "clearSearchTooltip": "शोध फील्ड साफ करा" }, "space": { "delete": "हटवा", "deleteConfirmation": "हटवा: ", "deleteConfirmationDescription": "या स्पेसमधील सर्व पृष्ठे हटवली जातील आणि कचरापेटीत टाकली जातील, आणि प्रकाशित पृष्ठे अनपब्लिश केली जातील.", "rename": "स्पेसचे नाव बदला", "changeIcon": "चिन्ह बदला", "manage": "स्पेस व्यवस्थापित करा", "addNewSpace": "स्पेस तयार करा", "collapseAllSubPages": "सर्व उपपृष्ठे संकुचित करा", "createNewSpace": "नवीन स्पेस तयार करा", "createSpaceDescription": "तुमचे कार्य अधिक चांगल्या प्रकारे आयोजित करण्यासाठी अनेक सार्वजनिक व खाजगी स्पेस तयार करा.", "spaceName": "स्पेसचे नाव", "spaceNamePlaceholder": "उदा. मार्केटिंग, अभियांत्रिकी, HR", "permission": "स्पेस परवानगी", "publicPermission": "सार्वजनिक", "publicPermissionDescription": "पूर्ण प्रवेशासह सर्व वर्कस्पेस सदस्य", "privatePermission": "खाजगी", "privatePermissionDescription": "फक्त तुम्हाला या स्पेसमध्ये प्रवेश आहे", "spaceIconBackground": "पार्श्वभूमीचा रंग", "spaceIcon": "चिन्ह", "dangerZone": "धोकादायक क्षेत्र", "unableToDeleteLastSpace": "शेवटची स्पेस हटवता येणार नाही", "unableToDeleteSpaceNotCreatedByYou": "तुमच्याद्वारे तयार न केलेली स्पेस हटवता येणार नाही", "enableSpacesForYourWorkspace": "तुमच्या वर्कस्पेससाठी स्पेस सक्षम करा", "title": "स्पेसेस", "defaultSpaceName": "सामान्य", "upgradeSpaceTitle": "स्पेस सक्षम करा", "upgradeSpaceDescription": "तुमच्या वर्कस्पेसचे अधिक चांगल्या प्रकारे व्यवस्थापन करण्यासाठी अनेक सार्वजनिक आणि खाजगी स्पेस तयार करा.", "upgrade": "अपग्रेड", "upgradeYourSpace": "अनेक स्पेस तयार करा", "quicklySwitch": "पुढील स्पेसवर पटकन स्विच करा", "duplicate": "स्पेस डुप्लिकेट करा", "movePageToSpace": "पृष्ठ स्पेसमध्ये हलवा", "cannotMovePageToDatabase": "पृष्ठ डेटाबेसमध्ये हलवता येणार नाही", "switchSpace": "स्पेस स्विच करा", "spaceNameCannotBeEmpty": "स्पेसचे नाव रिकामे असू शकत नाही", "success": { "deleteSpace": "स्पेस यशस्वीरित्या हटवली", "renameSpace": "स्पेसचे नाव यशस्वीरित्या बदलले", "duplicateSpace": "स्पेस यशस्वीरित्या डुप्लिकेट केली", "updateSpace": "स्पेस यशस्वीरित्या अपडेट केली" }, "error": { "deleteSpace": "स्पेस हटवण्यात अयशस्वी", "renameSpace": "स्पेसचे नाव बदलण्यात अयशस्वी", "duplicateSpace": "स्पेस डुप्लिकेट करण्यात अयशस्वी", "updateSpace": "स्पेस अपडेट करण्यात अयशस्वी" }, "createSpace": "स्पेस तयार करा", "manageSpace": "स्पेस व्यवस्थापित करा", "renameSpace": "स्पेसचे नाव बदला", "mSpaceIconColor": "स्पेस चिन्हाचा रंग", "mSpaceIcon": "स्पेस चिन्ह" }, "publish": { "hasNotBeenPublished": "हे पृष्ठ अजून प्रकाशित केलेले नाही", "spaceHasNotBeenPublished": "स्पेस प्रकाशित करण्यासाठी समर्थन नाही", "reportPage": "पृष्ठाची तक्रार करा", "databaseHasNotBeenPublished": "डेटाबेस प्रकाशित करण्यास समर्थन नाही.", "createdWith": "यांनी तयार केले", "downloadApp": "AppFlowy डाउनलोड करा", "copy": { "codeBlock": "कोड ब्लॉकची सामग्री क्लिपबोर्डवर कॉपी केली गेली आहे", "imageBlock": "प्रतिमा लिंक क्लिपबोर्डवर कॉपी केली गेली आहे", "mathBlock": "गणितीय समीकरण क्लिपबोर्डवर कॉपी केले गेले आहे", "fileBlock": "फाइल लिंक क्लिपबोर्डवर कॉपी केली गेली आहे" }, "containsPublishedPage": "या पृष्ठात एक किंवा अधिक प्रकाशित पृष्ठे आहेत. तुम्ही पुढे गेल्यास ती अनपब्लिश होतील. तुम्हाला हटवणे चालू ठेवायचे आहे का?", "publishSuccessfully": "यशस्वीरित्या प्रकाशित झाले", "unpublishSuccessfully": "यशस्वीरित्या अनपब्लिश झाले", "publishFailed": "प्रकाशित करण्यात अयशस्वी", "unpublishFailed": "अनपब्लिश करण्यात अयशस्वी", "noAccessToVisit": "या पृष्ठावर प्रवेश नाही...", "createWithAppFlowy": "AppFlowy ने वेबसाइट तयार करा", "fastWithAI": "AI सह जलद आणि सोपे.", "tryItNow": "आत्ताच वापरून पहा", "onlyGridViewCanBePublished": "फक्त Grid view प्रकाशित केला जाऊ शकतो", "database": { "zero": "{} निवडलेले दृश्य प्रकाशित करा", "one": "{} निवडलेली दृश्ये प्रकाशित करा", "many": "{} निवडलेली दृश्ये प्रकाशित करा", "other": "{} निवडलेली दृश्ये प्रकाशित करा" }, "mustSelectPrimaryDatabase": "प्राथमिक दृश्य निवडणे आवश्यक आहे", "noDatabaseSelected": "कोणताही डेटाबेस निवडलेला नाही, कृपया किमान एक डेटाबेस निवडा.", "unableToDeselectPrimaryDatabase": "प्राथमिक डेटाबेस अननिवड करता येणार नाही", "saveThisPage": "या टेम्पलेटपासून सुरू करा", "duplicateTitle": "तुम्हाला हे कुठे जोडायचे आहे", "selectWorkspace": "वर्कस्पेस निवडा", "addTo": "मध्ये जोडा", "duplicateSuccessfully": "तुमच्या वर्कस्पेसमध्ये जोडले गेले", "duplicateSuccessfullyDescription": "AppFlowy स्थापित केले नाही? तुम्ही 'डाउनलोड' वर क्लिक केल्यावर डाउनलोड आपोआप सुरू होईल.", "downloadIt": "डाउनलोड करा", "openApp": "अ‍ॅपमध्ये उघडा", "duplicateFailed": "डुप्लिकेट करण्यात अयशस्वी", "membersCount": { "zero": "सदस्य नाहीत", "one": "1 सदस्य", "many": "{count} सदस्य", "other": "{count} सदस्य" }, "useThisTemplate": "हा टेम्पलेट वापरा" }, "web": { "continue": "पुढे जा", "or": "किंवा", "continueWithGoogle": "Google सह पुढे जा", "continueWithGithub": "GitHub सह पुढे जा", "continueWithDiscord": "Discord सह पुढे जा", "continueWithApple": "Apple सह पुढे जा", "moreOptions": "अधिक पर्याय", "collapse": "आकुंचन", "signInAgreement": "\"पुढे जा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", "signInLocalAgreement": "\"सुरुवात करा\" क्लिक करून, तुम्ही AppFlowy च्या अटींना सहमती दिली आहे", "and": "आणि", "termOfUse": "वापर अटी", "privacyPolicy": "गोपनीयता धोरण", "signInError": "साइन इन त्रुटी", "login": "साइन अप किंवा लॉग इन करा", "fileBlock": { "uploadedAt": "{time} रोजी अपलोड केले", "linkedAt": "{time} रोजी लिंक जोडली", "empty": "फाईल अपलोड करा किंवा एम्बेड करा", "uploadFailed": "अपलोड अयशस्वी, कृपया पुन्हा प्रयत्न करा", "retry": "पुन्हा प्रयत्न करा" }, "importNotion": "Notion वरून आयात करा", "import": "आयात करा", "importSuccess": "यशस्वीरित्या अपलोड केले", "importSuccessMessage": "आम्ही तुम्हाला आयात पूर्ण झाल्यावर सूचित करू. त्यानंतर, तुम्ही साइडबारमध्ये तुमची आयात केलेली पृष्ठे पाहू शकता.", "importFailed": "आयात अयशस्वी, कृपया फाईल फॉरमॅट तपासा", "dropNotionFile": "तुमची Notion zip फाईल येथे ड्रॉप करा किंवा ब्राउझ करा", "error": { "pageNameIsEmpty": "पृष्ठाचे नाव रिकामे आहे, कृपया दुसरे नाव वापरून पहा" } }, "globalComment": { "comments": "टिप्पण्या", "addComment": "टिप्पणी जोडा", "reactedBy": "यांनी प्रतिक्रिया दिली", "addReaction": "प्रतिक्रिया जोडा", "reactedByMore": "आणि {count} इतर", "showSeconds": { "one": "1 सेकंदापूर्वी", "other": "{count} सेकंदांपूर्वी", "zero": "आत्ताच", "many": "{count} सेकंदांपूर्वी" }, "showMinutes": { "one": "1 मिनिटापूर्वी", "other": "{count} मिनिटांपूर्वी", "many": "{count} मिनिटांपूर्वी" }, "showHours": { "one": "1 तासापूर्वी", "other": "{count} तासांपूर्वी", "many": "{count} तासांपूर्वी" }, "showDays": { "one": "1 दिवसापूर्वी", "other": "{count} दिवसांपूर्वी", "many": "{count} दिवसांपूर्वी" }, "showMonths": { "one": "1 महिन्यापूर्वी", "other": "{count} महिन्यांपूर्वी", "many": "{count} महिन्यांपूर्वी" }, "showYears": { "one": "1 वर्षापूर्वी", "other": "{count} वर्षांपूर्वी", "many": "{count} वर्षांपूर्वी" }, "reply": "उत्तर द्या", "deleteComment": "टिप्पणी हटवा", "youAreNotOwner": "तुम्ही या टिप्पणीचे मालक नाही", "confirmDeleteDescription": "तुम्हाला ही टिप्पणी हटवायची आहे याची खात्री आहे का?", "hasBeenDeleted": "हटवले गेले", "replyingTo": "याला उत्तर देत आहे", "noAccessDeleteComment": "तुम्हाला ही टिप्पणी हटवण्याची परवानगी नाही", "collapse": "संकुचित करा", "readMore": "अधिक वाचा", "failedToAddComment": "टिप्पणी जोडण्यात अयशस्वी", "commentAddedSuccessfully": "टिप्पणी यशस्वीरित्या जोडली गेली.", "commentAddedSuccessTip": "तुम्ही नुकतीच एक टिप्पणी जोडली किंवा उत्तर दिले आहे. वर जाऊन ताजी टिप्पण्या पाहायच्या का?" }, "template": { "asTemplate": "टेम्पलेट म्हणून जतन करा", "name": "टेम्पलेट नाव", "description": "टेम्पलेट वर्णन", "about": "टेम्पलेट माहिती", "deleteFromTemplate": "टेम्पलेटमधून हटवा", "preview": "टेम्पलेट पूर्वदृश्य", "categories": "टेम्पलेट श्रेणी", "isNewTemplate": "नवीन टेम्पलेटमध्ये पिन करा", "featured": "वैशिष्ट्यीकृतमध्ये पिन करा", "relatedTemplates": "संबंधित टेम्पलेट्स", "requiredField": "{field} आवश्यक आहे", "addCategory": "\"{category}\" जोडा", "addNewCategory": "नवीन श्रेणी जोडा", "addNewCreator": "नवीन निर्माता जोडा", "deleteCategory": "श्रेणी हटवा", "editCategory": "श्रेणी संपादित करा", "editCreator": "निर्माता संपादित करा", "category": { "name": "श्रेणीचे नाव", "icon": "श्रेणी चिन्ह", "bgColor": "श्रेणी पार्श्वभूमीचा रंग", "priority": "श्रेणी प्राधान्य", "desc": "श्रेणीचे वर्णन", "type": "श्रेणी प्रकार", "icons": "श्रेणी चिन्हे", "colors": "श्रेणी रंग", "byUseCase": "वापराच्या आधारे", "byFeature": "वैशिष्ट्यांनुसार", "deleteCategory": "श्रेणी हटवा", "deleteCategoryDescription": "तुम्हाला ही श्रेणी हटवायची आहे का?", "typeToSearch": "श्रेणी शोधण्यासाठी टाइप करा..." }, "creator": { "label": "टेम्पलेट निर्माता", "name": "निर्मात्याचे नाव", "avatar": "निर्मात्याचा अवतार", "accountLinks": "निर्मात्याचे खाते दुवे", "uploadAvatar": "अवतार अपलोड करण्यासाठी क्लिक करा", "deleteCreator": "निर्माता हटवा", "deleteCreatorDescription": "तुम्हाला हा निर्माता हटवायचा आहे का?", "typeToSearch": "निर्माते शोधण्यासाठी टाइप करा..." }, "uploadSuccess": "टेम्पलेट यशस्वीरित्या अपलोड झाले", "uploadSuccessDescription": "तुमचे टेम्पलेट यशस्वीरित्या अपलोड झाले आहे. आता तुम्ही ते टेम्पलेट गॅलरीमध्ये पाहू शकता.", "viewTemplate": "टेम्पलेट पहा", "deleteTemplate": "टेम्पलेट हटवा", "deleteSuccess": "टेम्पलेट यशस्वीरित्या हटवले गेले", "deleteTemplateDescription": "याचा वर्तमान पृष्ठ किंवा प्रकाशित स्थितीवर परिणाम होणार नाही. तुम्हाला हे टेम्पलेट हटवायचे आहे का?", "addRelatedTemplate": "संबंधित टेम्पलेट जोडा", "removeRelatedTemplate": "संबंधित टेम्पलेट हटवा", "uploadAvatar": "अवतार अपलोड करा", "searchInCategory": "{category} मध्ये शोधा", "label": "टेम्पलेट्स" }, "fileDropzone": { "dropFile": "फाइल अपलोड करण्यासाठी येथे क्लिक करा किंवा ड्रॅग करा", "uploading": "अपलोड करत आहे...", "uploadFailed": "अपलोड अयशस्वी", "uploadSuccess": "अपलोड यशस्वी", "uploadSuccessDescription": "फाइल यशस्वीरित्या अपलोड झाली आहे", "uploadFailedDescription": "फाइल अपलोड अयशस्वी झाली आहे", "uploadingDescription": "फाइल अपलोड होत आहे" }, "gallery": { "preview": "पूर्ण स्क्रीनमध्ये उघडा", "copy": "कॉपी करा", "download": "डाउनलोड", "prev": "मागील", "next": "पुढील", "resetZoom": "झूम रिसेट करा", "zoomIn": "झूम इन", "zoomOut": "झूम आउट" }, "invitation": { "join": "सामील व्हा", "on": "वर", "invitedBy": "यांनी आमंत्रित केले", "membersCount": { "zero": "{count} सदस्य", "one": "{count} सदस्य", "many": "{count} सदस्य", "other": "{count} सदस्य" }, "tip": "तुम्हाला खालील माहितीच्या आधारे या कार्यक्षेत्रात सामील होण्यासाठी आमंत्रित करण्यात आले आहे. ही माहिती चुकीची असल्यास, कृपया प्रशासकाशी संपर्क साधा.", "joinWorkspace": "वर्कस्पेसमध्ये सामील व्हा", "success": "तुम्ही यशस्वीरित्या वर्कस्पेसमध्ये सामील झाला आहात", "successMessage": "आता तुम्ही सर्व पृष्ठे आणि कार्यक्षेत्रे वापरू शकता.", "openWorkspace": "AppFlowy उघडा", "alreadyAccepted": "तुम्ही आधीच आमंत्रण स्वीकारले आहे", "errorModal": { "title": "काहीतरी चुकले आहे", "description": "तुमचे सध्याचे खाते {email} कदाचित या वर्कस्पेसमध्ये प्रवेशासाठी पात्र नाही. कृपया योग्य खात्याने लॉग इन करा किंवा वर्कस्पेस मालकाशी संपर्क साधा.", "contactOwner": "मालकाशी संपर्क करा", "close": "मुख्यपृष्ठावर परत जा", "changeAccount": "खाते बदला" } }, "requestAccess": { "title": "या पृष्ठासाठी प्रवेश नाही", "subtitle": "तुम्ही या पृष्ठाच्या मालकाकडून प्रवेशासाठी विनंती करू शकता. मंजुरीनंतर तुम्हाला हे पृष्ठ पाहता येईल.", "requestAccess": "प्रवेशाची विनंती करा", "backToHome": "मुख्यपृष्ठावर परत जा", "tip": "तुम्ही सध्या म्हणून लॉग इन आहात.", "mightBe": "कदाचित तुम्हाला दुसऱ्या खात्याने लॉग इन करणे आवश्यक आहे.", "successful": "विनंती यशस्वीपणे पाठवली गेली", "successfulMessage": "मालकाने मंजुरी दिल्यावर तुम्हाला सूचित केले जाईल.", "requestError": "प्रवेशाची विनंती अयशस्वी", "repeatRequestError": "तुम्ही यासाठी आधीच विनंती केली आहे" }, "approveAccess": { "title": "वर्कस्पेसमध्ये सामील होण्यासाठी विनंती मंजूर करा", "requestSummary": " यांनी मध्ये सामील होण्यासाठी आणि पाहण्यासाठी विनंती केली आहे", "upgrade": "अपग्रेड", "downloadApp": "AppFlowy डाउनलोड करा", "approveButton": "मंजूर करा", "approveSuccess": "मंजूर यशस्वी", "approveError": "मंजुरी अयशस्वी. कृपया वर्कस्पेस मर्यादा ओलांडलेली नाही याची खात्री करा", "getRequestInfoError": "विनंतीची माहिती मिळवण्यात अयशस्वी", "memberCount": { "zero": "कोणतेही सदस्य नाहीत", "one": "1 सदस्य", "many": "{count} सदस्य", "other": "{count} सदस्य" }, "alreadyProTitle": "वर्कस्पेस योजना मर्यादा गाठली आहे", "alreadyProMessage": "अधिक सदस्यांसाठी शी संपर्क साधा", "repeatApproveError": "तुम्ही ही विनंती आधीच मंजूर केली आहे", "ensurePlanLimit": "कृपया खात्री करा की योजना मर्यादा ओलांडलेली नाही. जर ओलांडली असेल तर वर्कस्पेस योजना करा किंवा करा.", "requestToJoin": "मध्ये सामील होण्यासाठी विनंती केली", "asMember": "सदस्य म्हणून" }, "upgradePlanModal": { "title": "Pro प्लॅनवर अपग्रेड करा", "message": "{name} ने फ्री सदस्य मर्यादा गाठली आहे. अधिक सदस्य आमंत्रित करण्यासाठी Pro प्लॅनवर अपग्रेड करा.", "upgradeSteps": "AppFlowy वर तुमची योजना कशी अपग्रेड करावी:", "step1": "1. सेटिंग्जमध्ये जा", "step2": "2. 'योजना' वर क्लिक करा", "step3": "3. 'योजना बदला' निवडा", "appNote": "नोंद:", "actionButton": "अपग्रेड करा", "downloadLink": "अ‍ॅप डाउनलोड करा", "laterButton": "नंतर", "refreshNote": "यशस्वी अपग्रेडनंतर, तुमची नवीन वैशिष्ट्ये सक्रिय करण्यासाठी वर क्लिक करा.", "refresh": "येथे" }, "breadcrumbs": { "label": "ब्रेडक्रम्स" }, "time": { "justNow": "आत्ताच", "seconds": { "one": "1 सेकंद", "other": "{count} सेकंद" }, "minutes": { "one": "1 मिनिट", "other": "{count} मिनिटे" }, "hours": { "one": "1 तास", "other": "{count} तास" }, "days": { "one": "1 दिवस", "other": "{count} दिवस" }, "weeks": { "one": "1 आठवडा", "other": "{count} आठवडे" }, "months": { "one": "1 महिना", "other": "{count} महिने" }, "years": { "one": "1 वर्ष", "other": "{count} वर्षे" }, "ago": "पूर्वी", "yesterday": "काल", "today": "आज" }, "members": { "zero": "सदस्य नाहीत", "one": "1 सदस्य", "many": "{count} सदस्य", "other": "{count} सदस्य" }, "tabMenu": { "close": "बंद करा", "closeDisabledHint": "पिन केलेले टॅब बंद करता येत नाही, कृपया आधी अनपिन करा", "closeOthers": "इतर टॅब बंद करा", "closeOthersHint": "हे सर्व अनपिन केलेले टॅब्स बंद करेल या टॅब वगळता", "closeOthersDisabledHint": "सर्व टॅब पिन केलेले आहेत, बंद करण्यासाठी कोणतेही टॅब सापडले नाहीत", "favorite": "आवडते", "unfavorite": "आवडते काढा", "favoriteDisabledHint": "हे दृश्य आवडते म्हणून जतन करता येत नाही", "pinTab": "पिन करा", "unpinTab": "अनपिन करा" }, "openFileMessage": { "success": "फाइल यशस्वीरित्या उघडली", "fileNotFound": "फाइल सापडली नाही", "noAppToOpenFile": "ही फाइल उघडण्यासाठी कोणतेही अ‍ॅप उपलब्ध नाही", "permissionDenied": "ही फाइल उघडण्यासाठी परवानगी नाही", "unknownError": "फाइल उघडण्यात अयशस्वी" }, "inviteMember": { "requestInviteMembers": "तुमच्या वर्कस्पेसमध्ये आमंत्रित करा", "inviteFailedMemberLimit": "सदस्य मर्यादा गाठली आहे, कृपया ", "upgrade": "अपग्रेड करा", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "आमंत्रण पाठवा", "inviteAlready": "तुम्ही या ईमेलला आधीच आमंत्रित केले आहे: {email}", "inviteSuccess": "आमंत्रण यशस्वीपणे पाठवले", "description": "खाली ईमेल कॉमा वापरून टाका. शुल्क सदस्य संख्येवर आधारित असते.", "emails": "ईमेल" }, "quickNote": { "label": "झटपट नोंद", "quickNotes": "झटपट नोंदी", "search": "झटपट नोंदी शोधा", "collapseFullView": "पूर्ण दृश्य लपवा", "expandFullView": "पूर्ण दृश्य उघडा", "createFailed": "झटपट नोंद तयार करण्यात अयशस्वी", "quickNotesEmpty": "कोणत्याही झटपट नोंदी नाहीत", "emptyNote": "रिकामी नोंद", "deleteNotePrompt": "निवडलेली नोंद कायमची हटवली जाईल. तुम्हाला नक्की ती हटवायची आहे का?", "addNote": "नवीन नोंद", "noAdditionalText": "अधिक माहिती नाही" }, "subscribe": { "upgradePlanTitle": "योजना तुलना करा आणि निवडा", "yearly": "वार्षिक", "save": "{discount}% बचत", "monthly": "मासिक", "priceIn": "किंमत येथे: ", "free": "फ्री", "pro": "प्रो", "freeDescription": "2 सदस्यांपर्यंत वैयक्तिक वापरकर्त्यांसाठी सर्व काही आयोजित करण्यासाठी", "proDescription": "प्रकल्प आणि टीम नॉलेज व्यवस्थापित करण्यासाठी लहान टीमसाठी", "proDuration": { "monthly": "प्रति सदस्य प्रति महिना\nमासिक बिलिंग", "yearly": "प्रति सदस्य प्रति महिना\nवार्षिक बिलिंग" }, "cancel": "खालच्या योजनेवर जा", "changePlan": "प्रो योजनेवर अपग्रेड करा", "everythingInFree": "फ्री योजनेतील सर्व काही +", "currentPlan": "सध्याची योजना", "freeDuration": "कायम", "freePoints": { "first": "1 सहकार्यात्मक वर्कस्पेस (2 सदस्यांपर्यंत)", "second": "अमर्यादित पृष्ठे आणि ब्लॉक्स", "three": "5 GB संचयन", "four": "बुद्धिमान शोध", "five": "20 AI प्रतिसाद", "six": "मोबाईल अ‍ॅप", "seven": "रिअल-टाइम सहकार्य" }, "proPoints": { "first": "अमर्यादित संचयन", "second": "10 वर्कस्पेस सदस्यांपर्यंत", "three": "अमर्यादित AI प्रतिसाद", "four": "अमर्यादित फाइल अपलोड्स", "five": "कस्टम नेमस्पेस" }, "cancelPlan": { "title": "आपल्याला जाताना पाहून वाईट वाटते", "success": "आपली सदस्यता यशस्वीरित्या रद्द झाली आहे", "description": "आपल्याला जाताना वाईट वाटते. कृपया आम्हाला सुधारण्यासाठी तुमचे अभिप्राय कळवा. खालील काही प्रश्नांना उत्तर द्या.", "commonOther": "इतर", "otherHint": "आपले उत्तर येथे लिहा", "questionOne": { "question": "तुम्ही AppFlowy Pro सदस्यता का रद्द केली?", "answerOne": "खर्च खूप जास्त आहे", "answerTwo": "वैशिष्ट्ये अपेक्षेनुसार नव्हती", "answerThree": "चांगला पर्याय सापडला", "answerFour": "खर्च योग्य ठरण्यासाठी वापर पुरेसा नव्हता", "answerFive": "सेवा समस्या किंवा तांत्रिक अडचणी" }, "questionTwo": { "question": "तुम्ही भविष्यात AppFlowy Pro पुन्हा घेण्याची शक्यता किती आहे?", "answerOne": "खूप शक्यता आहे", "answerTwo": "काहीशी शक्यता आहे", "answerThree": "निश्चित नाही", "answerFour": "अल्प शक्यता आहे", "answerFive": "शक्यता नाही" }, "questionThree": { "question": "सदस्यतेदरम्यान कोणते Pro फिचर तुम्हाला सर्वात जास्त उपयोगी वाटले?", "answerOne": "मल्टी-यूजर सहकार्य", "answerTwo": "लांब कालावधीसाठी आवृत्ती इतिहास", "answerThree": "अमर्यादित AI प्रतिसाद", "answerFour": "स्थानिक AI मॉडेल्सचा प्रवेश" }, "questionFour": { "question": "AppFlowy बाबत तुमचा एकूण अनुभव कसा होता?", "answerOne": "छान", "answerTwo": "चांगला", "answerThree": "सामान्य", "answerFour": "थोडासा वाईट", "answerFive": "असंतोषजनक" } } }, "ai": { "contentPolicyViolation": "संवेदनशील सामग्रीमुळे प्रतिमा निर्मिती अयशस्वी झाली. कृपया तुमचे इनपुट पुन्हा लिहा आणि पुन्हा प्रयत्न करा.", "textLimitReachedDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अनलिमिटेड प्रतिसादांसाठी प्रो योजना घेण्याचा किंवा AI अ‍ॅड-ऑन खरेदी करण्याचा विचार करा.", "imageLimitReachedDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया प्रो योजना घ्या किंवा AI अ‍ॅड-ऑन खरेदी करा.", "limitReachedAction": { "textDescription": "तुमच्या वर्कस्पेसमध्ये मोफत AI प्रतिसाद संपले आहेत. अधिक प्रतिसाद मिळवण्यासाठी कृपया", "imageDescription": "तुमचे मोफत AI प्रतिमा कोटा संपले आहे. कृपया", "upgrade": "अपग्रेड करा", "toThe": "या योजनेवर", "proPlan": "प्रो योजना", "orPurchaseAn": "किंवा खरेदी करा", "aiAddon": "AI अ‍ॅड-ऑन" }, "editing": "संपादन करत आहे", "analyzing": "विश्लेषण करत आहे", "continueWritingEmptyDocumentTitle": "लेखन सुरू ठेवता आले नाही", "continueWritingEmptyDocumentDescription": "तुमच्या दस्तऐवजातील मजकूर वाढवण्यात अडचण येत आहे. कृपया एक छोटं परिचय लिहा, मग आम्ही पुढे नेऊ!", "more": "अधिक" }, "autoUpdate": { "criticalUpdateTitle": "अद्यतन आवश्यक आहे", "criticalUpdateDescription": "तुमचा अनुभव सुधारण्यासाठी आम्ही सुधारणा केल्या आहेत! कृपया {currentVersion} वरून {newVersion} वर अद्यतन करा.", "criticalUpdateButton": "अद्यतन करा", "bannerUpdateTitle": "नवीन आवृत्ती उपलब्ध!", "bannerUpdateDescription": "नवीन वैशिष्ट्ये आणि सुधारणांसाठी. अद्यतनासाठी 'Update' क्लिक करा.", "bannerUpdateButton": "अद्यतन करा", "settingsUpdateTitle": "नवीन आवृत्ती ({newVersion}) उपलब्ध!", "settingsUpdateDescription": "सध्याची आवृत्ती: {currentVersion} (अधिकृत बिल्ड) → {newVersion}", "settingsUpdateButton": "अद्यतन करा", "settingsUpdateWhatsNew": "काय नवीन आहे" }, "lockPage": { "lockPage": "लॉक केलेले", "reLockPage": "पुन्हा लॉक करा", "lockTooltip": "अनवधानाने संपादन टाळण्यासाठी पृष्ठ लॉक केले आहे. अनलॉक करण्यासाठी क्लिक करा.", "pageLockedToast": "पृष्ठ लॉक केले आहे. कोणी अनलॉक करेपर्यंत संपादन अक्षम आहे.", "lockedOperationTooltip": "पृष्ठ अनवधानाने संपादनापासून वाचवण्यासाठी लॉक केले आहे." }, "suggestion": { "accept": "स्वीकारा", "keep": "जसे आहे तसे ठेवा", "discard": "रद्द करा", "close": "बंद करा", "tryAgain": "पुन्हा प्रयत्न करा", "rewrite": "पुन्हा लिहा", "insertBelow": "खाली टाका" } } ================================================ FILE: frontend/resources/translations/pl-PL.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Ja", "welcomeText": "Witaj w @:appName", "welcomeTo": "Witamy w", "githubStarText": "Gwiazdka na GitHub-ie", "subscribeNewsletterText": "Zapisz się do naszego Newslettera", "letsGoButtonText": "Start!", "title": "Tytuł", "youCanAlso": "Możesz również", "and": "i", "failedToOpenUrl": "Nie udało się otworzyć adresu URL: {}", "blockActions": { "addBelowTooltip": "Kliknij, aby dodać poniżej", "addAboveCmd": "Alt+kliknięcie", "addAboveMacCmd": "Opcja + kliknięcie", "addAboveTooltip": "aby dodać powyżej", "dragTooltip": "Przeciągnij aby przenieść", "openMenuTooltip": "Kliknij żeby otworzyć menu" }, "signUp": { "buttonText": "Rejestracja", "title": "Zarejestruj się w @:appName", "getStartedText": "Rozpocznij", "emptyPasswordError": "Hasło nie moze być puste", "repeatPasswordEmptyError": "Powtórzone hasło nie moze być puste", "unmatchedPasswordError": "Hasła nie są takie same", "alreadyHaveAnAccount": "Masz juz konto?", "emailHint": "Email", "passwordHint": "Hasło", "repeatPasswordHint": "Powtórz hasło", "signUpWith": "Zarejestruj się za pomocą:" }, "signIn": { "loginTitle": "Zaloguj do @:appName", "loginButtonText": "Logowanie", "loginStartWithAnonymous": "Rozpocznij sesję anonimową", "continueAnonymousUser": "Kontynuuj sesję anonimową", "buttonText": "Zaloguj", "signingInText": "Logowanie się...", "forgotPassword": "Zapomniałeś hasła?", "emailHint": "Email", "passwordHint": "Hasło", "dontHaveAnAccount": "Nie masz konta?", "createAccount": "Utwórz konto", "repeatPasswordEmptyError": "Powtórzone hasło nie moze być puste", "unmatchedPasswordError": "Hasła nie są takie same", "syncPromptMessage": "Synchronizacja danych może chwilę potrwać. Proszę nie zamykać tej strony", "or": "ALBO", "signInWithGoogle": "Zaloguj się za pomocą Google", "signInWithGithub": "Zaloguj się za pomocą Githuba", "signInWithDiscord": "Zaloguj się za pomocą Discorda", "signUpWithGoogle": "Zarejestruj się za pomocą Google", "signUpWithGithub": "Zarejestruj się za pomocą Githuba", "signUpWithDiscord": "Zarejestruj się za pomocą Discorda", "signInWith": "Zaloguj się korzystając z:", "pleaseInputYourEmail": "Podaj swój adres e-mail", "settings": "Ustawienia", "logIn": "Zaloguj się", "generalError": "Coś poszło nie tak. Spróbuj ponownie później", "LogInWithGoogle": "Zaloguj się za pomocą Google", "LogInWithGithub": "Zaloguj się za pomocą Githuba", "LogInWithDiscord": "Zaloguj się za pomocą Discorda", "loginAsGuestButtonText": "Rozpocznij" }, "workspace": { "chooseWorkspace": "Wybierz swoją przestrzeń do pracy", "create": "Utwórz przestrzeń", "reset": "Zresetuj przestrzeń roboczą", "renameWorkspace": "Zmień nazwę obszaru roboczego", "resetWorkspacePrompt": "Zresetowanie przestrzeni roboczej spowoduje usunięcie wszystkich znajdujących się w niej stron i danych. Czy na pewno chcesz zresetować przestrzeń roboczą? Alternatywnie możesz skontaktować się z zespołem pomocy technicznej, aby przywrócić przestrzeń roboczą", "hint": "przestrzeń robocza", "notFoundError": "Przestrzeni nie znaleziono", "failedToLoad": "Coś poszło nie tak! Wczytywanie przestrzeni roboczej nie powiodło się. Spróbuj wyłączyć wszystkie otwarte instancje @:appName i spróbuj ponownie.", "errorActions": { "reportIssue": "Zgłoś problem", "reportIssueOnGithub": "Zgłoś problem na Githubie", "reachOut": "Skontaktuj się na Discord" }, "menuTitle": "Obszary robocze", "deleteWorkspaceHintText": "Czy na pewno chcesz usunąć obszar roboczy? Tej akcji nie można cofnąć.", "createSuccess": "Obszar roboczy został utworzony pomyślnie", "createFailed": "Nie udało się utworzyć obszaru roboczego", "deleteSuccess": "Obszar roboczy został pomyślnie usunięty", "deleteFailed": "Nie udało się usunąć obszaru roboczego" }, "shareAction": { "buttonText": "Udostępnij", "workInProgress": "Dostępne wkrótce", "markdown": "Markdown", "html": "HTML", "clipboard": "Skopiuj do schowka", "csv": "CSV", "copyLink": "Skopiuj link" }, "moreAction": { "small": "mały", "medium": "średni", "large": "duży", "fontSize": "Rozmiar czcionki", "import": "Import", "moreOptions": "Więcej opcji", "wordCount": "Liczba słów: {}", "charCount": "Liczba znaków: {}", "createdAt": "Utworzony: {}", "deleteView": "Usuń" }, "importPanel": { "textAndMarkdown": "Tekst i Markdown", "documentFromV010": "Dokument z wersji 0.1.0", "databaseFromV010": "Baza danych z wersji 0.1.0", "csv": "CSV", "database": "Baza danych" }, "disclosureAction": { "rename": "Zmień nazwę", "delete": "Usuń", "duplicate": "Duplikuj", "unfavorite": "Usuń z ulubionych", "favorite": "Dodaj do ulubionych", "openNewTab": "Otwórz w nowej karcie", "moveTo": "Przenieś do", "addToFavorites": "Dodaj do ulubionych", "copyLink": "Skopiuj link", "changeIcon": "Zmień ikonę" }, "blankPageTitle": "Pusta strona", "newPageText": "Nowa strona", "newDocumentText": "Nowy dokument", "newGridText": "Nowa siatka", "newCalendarText": "Nowy kalendarz", "newBoardText": "Nowa tablica", "trash": { "text": "Kosz", "restoreAll": "Przywróć Wszystko", "deleteAll": "Usuń Wszystko", "pageHeader": { "fileName": "Nazwa Pliku", "lastModified": "Ostatnio Zmodyfikowano", "created": "Utworzono" }, "confirmDeleteAll": { "title": "Czy na pewno chcesz usunąć wszystkie strony z Kosza?", "caption": "Tej czynności nie można cofnąć." }, "confirmRestoreAll": { "title": "Czy na pewno chcesz przywrócić wszystkie strony w Koszu?", "caption": "Tej czynności nie można cofnąć." }, "mobile": { "actions": "Kosz - Akcje", "empty": "Koszt jest pusty", "emptyDescription": "Nie masz żadnych usuniętych plików", "isDeleted": "jest usunięty", "isRestored": "jest przywrócony" } }, "deletePagePrompt": { "text": "Ta strona jest w Koszu", "restore": "Przywróć strone", "deletePermanent": "Usuń bezpowrotnie" }, "dialogCreatePageNameHint": "Nazwa Strony", "questionBubble": { "shortcuts": "Skróty", "whatsNew": "Co nowego?", "markdown": "Markdown", "debug": { "name": "Informacje Debugowania", "success": "Skopiowano informacje debugowania do schowka!", "fail": "Nie mozna skopiować informacji debugowania do schowka" }, "feedback": "Feedback", "help": "Pomoc & Wsparcie" }, "menuAppHeader": { "moreButtonToolTip": "Usuń, zmień nazwę i więcej...", "addPageTooltip": "Szybko dodaj stronę", "defaultNewPageName": "Brak tytułu", "renameDialog": "Zmień nazwę" }, "noPagesInside": "Brak stron wewnątrz", "toolbar": { "undo": "Cofnij", "redo": "Powtórz", "bold": "Pogrubiony", "italic": "Kursywa", "underline": "Podkreślenie", "strike": "Przekreślenie", "numList": "Lista Numerowana", "bulletList": "Lista Punktowana", "checkList": "Lista Kontrolna", "inlineCode": "Wbudowany Kod", "quote": "Blok cytatu", "header": "Nagłówek", "highlight": "Podświetlenie", "color": "Kolor", "addLink": "Dodaj link", "link": "Połącz" }, "tooltip": { "lightMode": "Przełącz w Tryb Jasny", "darkMode": "Przełącz w Tryb Ciemny", "openAsPage": "Otwórz jako stronę", "addNewRow": "Dodaj nowy wiersz", "openMenu": "Kliknij, aby otworzyć menu", "dragRow": "Naciśnij i przytrzymaj, aby zmienić kolejność wierszy", "viewDataBase": "Zobacz bazę danych", "referencePage": "Odwołuje się do tego {nazwa}", "addBlockBelow": "Dodaj blok poniżej" }, "sideBar": { "closeSidebar": "Zamknij menu boczne", "openSidebar": "Otwórz menu boczne", "personal": "Osobisty", "favorites": "Ulubione", "clickToHidePersonal": "Kliknij, aby ukryć sekcję osobistą", "clickToHideFavorites": "Kliknij, aby ukryć ulubioną sekcję", "addAPage": "Dodaj stronę", "recent": "Najnowsze", "today": "Dziś", "thisWeek": "Ten tydzień", "favoriteSpace": "Ulubione" }, "notifications": { "export": { "markdown": "Wyeksportowano notatkę do Markdown", "path": "Dokumenty/flowy" } }, "contactsPage": { "title": "Kontakty", "whatsHappening": "Co się dzieje w tym tygodniu?", "addContact": "Dodaj Kontakt", "editContact": "Edytuj Kontakt" }, "button": { "ok": "OK", "confirm": "Potwierdź", "done": "Zrobione", "cancel": "Anuluj", "signIn": "Zaloguj", "signOut": "Wyloguj", "complete": "Zakończono", "save": "Zapisz", "generate": "Wygeneruj", "esc": "WYJŚCIE", "keep": "Trzymać", "tryAgain": "Spróbuj ponownie", "discard": "Odrzuć", "replace": "Zastąp", "insertBelow": "Wstaw poniżej", "insertAbove": "Wstaw powyżej", "upload": "Prześlij", "edit": "Edytuj", "delete": "Usuń", "duplicate": "Duplikuj", "putback": "Odłóż z powrotem", "update": "Zaktualizuj", "share": "Udostępnij", "removeFromFavorites": "Usuń z ulubionych", "addToFavorites": "Dodaj do ulubionych", "rename": "Zmień nazwę", "helpCenter": "Centrum Pomocy", "add": "Dodaj", "yes": "Tak", "clear": "Wyczyść", "remove": "Usuń", "login": "Zaloguj się", "logout": "Wyloguj", "deleteAccount": "Usuń konto", "back": "Wstecz", "signInGoogle": "Zaloguj się za pomocą Google", "signInGithub": "Zaloguj się za pomocą Githuba", "signInDiscord": "Zaloguj się za pomocą Discorda", "more": "Więcej", "create": "Utwórz", "close": "Zamknij" }, "label": { "welcome": "Witaj!", "firstName": "Imię", "middleName": "Drugie Imię", "lastName": "Nazwisko", "stepX": "Krok {X}" }, "oAuth": { "err": { "failedTitle": "Nie udało się połączyć z Twoim kontem.", "failedMsg": "Upewnij się, że zakończyłeś proces logowania w przeglądarce." }, "google": { "title": "LOGOWANIE GOOGLE", "instruction1": "Aby zaimportować Kontakty Google, musisz autoryzować tę aplikację za pomocą przeglądarki internetowej.", "instruction2": "Skopiuj ten kod do schowka, klikając ikonę lub zaznaczając tekst:", "instruction3": "Przejdź do następującego linku w przeglądarce internetowej i wprowadź powyższy kod:", "instruction4": "Naciśnij poniższy przycisk po zakończeniu rejestracji:" } }, "settings": { "title": "Ustawienia", "accountPage": { "menuLabel": "Moje konto", "title": "Moje konto", "general": { "title": "Nazwa konta i zdjęcie profilowe", "changeProfilePicture": "Zmień zdjęcie profilowe" }, "email": { "title": "E-mail", "actions": { "change": "Zmień adres e-mail" } }, "login": { "loginLabel": "Zaloguj się", "logoutLabel": "Wyloguj" } }, "workspacePage": { "menuLabel": "Obszar roboczy", "title": "Obszar roboczy" }, "menu": { "appearance": "Wygląd", "language": "Język", "user": "Użytkownik", "files": "Pliki", "notifications": "Powiadomienia", "open": "Otwórz Ustawienia", "logout": "Wyloguj", "logoutPrompt": "Czy na pewno chcesz się wylogować?", "selfEncryptionLogoutPrompt": "Czy na pewno chcesz się wylogować? Upewnij się, że skopiowałeś sekret szyfrowania", "syncSetting": "Ustawienie synchronizacji", "enableSync": "Włącz synchronizację", "enableEncrypt": "Szyfruj dane", "cloudURL": "URL Serwera", "enableEncryptPrompt": "Aktywuj szyfrowanie, aby zabezpieczyć swoje dane tym sekretem. Przechowuj go bezpiecznie; po włączeniu nie można go wyłączyć. Jeśli zostanie utracony, nie będziesz w stanie odzyskać danych. Kliknij, aby skopiować", "inputEncryptPrompt": "Wprowadź swój sekret szyfrowania dla", "clickToCopySecret": "Kliknij, aby skopiować sekret", "inputTextFieldHint": "Twój sekret", "historicalUserList": "Historia logowania użytkownika", "historicalUserListTooltip": "Na tej liście wyświetlane są Twoje anonimowe konta. Możesz kliknąć konto, aby wyświetlić jego szczegóły. Konta anonimowe tworzy się poprzez kliknięcie przycisku „Rozpocznij”.", "openHistoricalUser": "Kliknij, aby otworzyć anonimowe konto", "cloudSetting": "Ustawienia Chmury" }, "notifications": { "enableNotifications": { "label": "Włącz powiadomienia", "hint": "Wyłącz jeśli nie chcesz otrzymywać powiadomień." } }, "appearance": { "resetSetting": "Zresetuj to ustawienie", "fontFamily": { "label": "Rodzina czcionek", "search": "Szukaj" }, "themeMode": { "label": "Rodzaj motywu", "light": "Tryb Jasny", "dark": "Tryb Ciemny", "system": "Dostosuj do Systemu" }, "layoutDirection": { "label": "Kierunek układu", "hint": "Kontroluj przepływ treści na ekranie, od lewej do prawej lub od prawej do lewej.", "ltr": "LDP", "rtl": "PDL" }, "textDirection": { "label": "Domyślny kierunek tekstu", "hint": "Określ, czy tekst ma domyślnie zaczynać się od lewej czy prawej strony.", "ltr": "LDP", "rtl": "PDL", "auto": "AUTOMATYCZNY", "fallback": "Taki sam jak kierunek układu" }, "themeUpload": { "button": "Prześlij", "description": "Prześlij własny motyw @:appName za pomocą przycisku poniżej.", "loading": "Poczekaj, aż zweryfikujemy i prześlemy Twój motyw...", "uploadSuccess": "Twój motyw został przesłany pomyślnie", "deletionFailure": "Nie udało się usunąć motywu. Spróbuj usunąć go ręcznie.", "filePickerDialogTitle": "Wybierz plik .flowy_plugin", "urlUploadFailure": "Nie udało się otworzyć adresu URL: {}", "failure": "Przesłany motyw miał nieprawidłowy format." }, "theme": "Motyw", "builtInsLabel": "Wbudowane motywy", "pluginsLabel": "Wtyczki", "dateFormat": { "label": "Format daty", "local": "Lokalny", "us": "US", "iso": "ISO", "friendly": "Przyjazny", "dmy": "D/M/R" }, "timeFormat": { "label": "Format godziny", "twelveHour": "12 godzinny", "twentyFourHour": "24 godzinny" }, "showNamingDialogWhenCreatingPage": "Pokaż okno dialogowe nazewnictwa podczas tworzenia strony" }, "files": { "copy": "Kopiuj", "defaultLocation": "Ścieżka katalogu z plikami", "exportData": "Eksportuj swoje dane", "doubleTapToCopy": "Kliknij dwukrotnie, aby skopiować ścieżkę", "restoreLocation": "Przywróć domyślną ścieżkę @:appName", "customizeLocation": "Otwórz inny folder", "restartApp": "Uruchom ponownie aplikację, aby zmiany zaczęły obowiązywać.", "exportDatabase": "Eksportuj bazę danych", "selectFiles": "Wybierz pliki, które mają zostać wyeksportowane", "selectAll": "Zaznacz wszystko", "deselectAll": "Odznacz wszystkie", "createNewFolder": "Stwórz nowy folder", "createNewFolderDesc": "Powiedz nam, gdzie chcesz przechowywać swoje dane", "defineWhereYourDataIsStored": "Zdefiniuj miejsce przechowywania Twoich danych", "open": "Otwórz", "openFolder": "Otwórz istniejący folder", "openFolderDesc": "Przeczytaj i zapisz go w istniejącym folderze @:appName", "folderHintText": "Nazwa folderu", "location": "Tworzenie nowego folderu", "locationDesc": "Wybierz nazwę folderu danych @:appName", "browser": "Przeglądaj", "create": "Stwórz", "set": "Ustaw", "folderPath": "Ścieżka do przechowywania folderu", "locationCannotBeEmpty": "Ścieżka nie może być pusta", "pathCopiedSnackbar": "Ścieżka przechowywania plików została skopiowana do schowka!", "changeLocationTooltips": "Zmień katalog danych", "change": "Zmień", "openLocationTooltips": "Otwórz inny katalog danych", "openCurrentDataFolder": "Otwórz bieżący katalog danych", "recoverLocationTooltips": "Zresetuj do domyślnego katalogu danych @:appName", "exportFileSuccess": "Eksportowanie pliku zakończono pomyślnie!", "exportFileFail": "Eksport pliku nie powiódł się!", "export": "Eksport" }, "user": { "name": "Nazwa", "email": "E-mail", "tooltipSelectIcon": "Wybierz ikonę", "selectAnIcon": "Wybierz ikonę", "pleaseInputYourOpenAIKey": "wprowadź swój klucz AI", "clickToLogout": "Kliknij, aby wylogować bieżącego użytkownika", "pleaseInputYourStabilityAIKey": "wprowadź swój klucz Stability AI" }, "mobile": { "personalInfo": "Informacje Osobiste", "username": "Nazwa użytkownika", "usernameEmptyError": "Nazwa użytkownika nie może być pusta", "about": "About", "pushNotifications": "Powiadomienia Push", "support": "Wsparcie", "joinDiscord": "Dołącz do nas na Discord", "privacyPolicy": "Polityka prywatności", "userAgreement": "Regulamin Użytkowania", "userprofileError": "Nie udało się wczytać profilu użytkownika", "userprofileErrorDescription": "Spróbuj wylogować się i zalogować ponownie, aby zobaczyć czy problem nadal występuje." }, "shortcuts": { "shortcutsLabel": "Skróty", "command": "Polecenie", "keyBinding": "Skróty klawiszowe", "addNewCommand": "Dodaj nowe polecenie", "updateShortcutStep": "Naciśnij żądaną kombinację klawiszy i naciśnij ENTER", "shortcutIsAlreadyUsed": "Ten skrót jest już używany w przypadku: {conflict}", "resetToDefault": "Przywróć domyślne skróty klawiszowe", "couldNotLoadErrorMsg": "Nie udało się wczytać skrótów. Spróbuj ponownie", "couldNotSaveErrorMsg": "Nie udało się zapisać skrótów. Spróbuj ponownie" } }, "grid": { "deleteView": "Czy na pewno chcesz usunąć ten widok?", "createView": "Nowy", "title": { "placeholder": "Bez tytułu" }, "settings": { "filter": "Filtr", "sort": "Sortuj", "sortBy": "Sortuj według", "properties": "Właściwości", "reorderPropertiesTooltip": "Przeciągnij, aby zmienić kolejność właściwości", "group": "Grupa", "addFilter": "Dodaj filtr", "deleteFilter": "Usuń filtr", "filterBy": "Filtruj według...", "typeAValue": "Wpisz wartość...", "layout": "Układ", "databaseLayout": "Układ" }, "textFilter": { "contains": "Zawiera", "doesNotContain": "Nie zawiera", "endsWith": "Kończy się na", "startWith": "Zaczyna się od", "is": "Jest", "isNot": "Nie jest", "isEmpty": "Jest pusty", "isNotEmpty": "Nie jest pusty", "choicechipPrefix": { "isNot": "Nie", "startWith": "Zaczyna się na", "endWith": "Kończy się na", "isEmpty": "jest pusty", "isNotEmpty": "nie jest pusty" } }, "checkboxFilter": { "isChecked": "Zaznaczony", "isUnchecked": "Odznaczony", "choicechipPrefix": { "is": "Jest" } }, "checklistFilter": { "isComplete": "jest kompletna", "isIncomplted": "jest niekompletna" }, "selectOptionFilter": { "is": "Jest", "isNot": "Nie jest", "contains": "Zawiera", "doesNotContain": "Nie zawiera", "isEmpty": "Jest pusty", "isNotEmpty": "Nie jest pusty" }, "field": { "hide": "Ukryj", "show": "Pokaż", "insertLeft": "Wstaw w lewo", "insertRight": "Wstaw w prawo", "duplicate": "Duplikuj", "delete": "Usuń", "textFieldName": "Tekst", "checkboxFieldName": "Pole wyboru", "dateFieldName": "Data", "updatedAtFieldName": "Czas ostatniej modyfikacji", "createdAtFieldName": "Czas utworzenia", "numberFieldName": "Liczby", "singleSelectFieldName": "Jednokrotny wybór", "multiSelectFieldName": "Wielokrotny wybór", "urlFieldName": "Adres URL", "checklistFieldName": "Lista kontrolna", "numberFormat": "Format liczbowy", "dateFormat": "Format daty", "includeTime": "Uwzględnij czas", "isRange": "Końcowa data", "dateFormatFriendly": "Miesiąc Dzień, Rok", "dateFormatISO": "Rok-Miesiąc-Dzień", "dateFormatLocal": "Miesiąc/Dzień/Rok", "dateFormatUS": "Rok miesiąc dzień", "dateFormatDayMonthYear": "Dzień/Miesiąc/Rok", "timeFormat": "Format czasu", "invalidTimeFormat": "Niepoprawny format", "timeFormatTwelveHour": "12 godzinny", "timeFormatTwentyFourHour": "24 godzinny", "clearDate": "Wyczyść datę", "addSelectOption": "Dodaj opcję", "optionTitle": "Opcje", "addOption": "Dodaj opcję", "editProperty": "Edytuj właściwość", "newProperty": "Nowa właściwość", "deleteFieldPromptMessage": "Jesteś pewny? Ta właściwość zostanie usunięta", "newColumn": "Nowa kolumna" }, "rowPage": { "newField": "Dodaj nowe pole", "fieldDragElementTooltip": "Kliknij by otworzyć menu", "showHiddenFields": { "one": "Pokaż {} ukryte pole", "many": "Pokaż {} ukryte pola", "other": "Pokaż {} ukryte pola" }, "hideHiddenFields": { "one": "Ukryj {} ukryte pole", "many": "Ukryj {} ukryte pola", "other": "Ukryj {} ukryte pola" } }, "sort": { "ascending": "Rosnąco", "descending": "Malejąco", "deleteAllSorts": "Usuń wszystkie sortowania", "addSort": "Dodaj sortowanie", "deleteSort": "Usuń sortowanie" }, "row": { "duplicate": "Duplikuj", "delete": "Usuń", "titlePlaceholder": "Brak tytułu", "textPlaceholder": "Pusty", "copyProperty": "Skopiowano właściwość do schowka", "count": "Liczba", "newRow": "Nowy rząd", "action": "Akcja", "add": "Kliknij by dodać poniżej", "drag": "Przeciągnij aby przenieść", "dragAndClick": "Przeciągnij aby przenieść, kliknij by otworzyć menu", "insertRecordAbove": "Dodaj wpis powyżej", "insertRecordBelow": "Dodaj wpis poniżej" }, "selectOption": { "create": "Stwórz", "purpleColor": "Fioletowy", "pinkColor": "Różowy", "lightPinkColor": "Jasnoróżowy", "orangeColor": "Pomarańczowy", "yellowColor": "Żółty", "limeColor": "Limonka", "greenColor": "Zielony", "aquaColor": "Wodny", "blueColor": "Niebieski", "deleteTag": "Usuń tag", "colorPanelTitle": "Kolory", "panelTitle": "Wybierz opcję lub utwórz ją", "searchOption": "Wyszukaj opcję", "searchOrCreateOption": "Wyszukaj lub utwórz opcję...", "createNew": "Utwórz nowy", "orSelectOne": "Lub wybierz opcję" }, "checklist": { "taskHint": "Opis zadania", "addNew": "Dodaj element", "submitNewTask": "Stwórz", "hideComplete": "Ukryj zakończone zadania", "showComplete": "Pokaż wszystkie zadania" }, "menuName": "Siatka", "referencedGridPrefix": "Widok" }, "document": { "menuName": "Dokument", "date": { "timeHintTextInTwelveHour": "13:00", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Wybierz tablicę, do której chcesz utworzyć łącze", "createANewBoard": "Utwórz nową tablicę" }, "grid": { "selectAGridToLinkTo": "Wybierz siatkę do połączenia", "createANewGrid": "Utwórz nową siatkę" }, "calendar": { "selectACalendarToLinkTo": "Wybierz kalendarz do połączenia", "createANewCalendar": "Utwórz nowy kalendarz" }, "document": { "selectADocumentToLinkTo": "Wybierz dokument do połączenia" } }, "selectionMenu": { "outline": "Zarys", "codeBlock": "Blok kodu" }, "plugins": { "referencedBoard": "Tablica referencyjna", "referencedGrid": "Siatka referencyjna", "referencedCalendar": "Kalendarz referencyjny", "referencedDocument": "Dokument referencyjny", "autoGeneratorMenuItemName": "Pisarz AI", "autoGeneratorTitleName": "AI: Poproś AI o napisanie czegokolwiek...", "autoGeneratorLearnMore": "Dowiedz się więcej", "autoGeneratorGenerate": "Generuj", "autoGeneratorHintText": "Zapytaj AI...", "autoGeneratorCantGetOpenAIKey": "Nie można uzyskać klucza AI", "autoGeneratorRewrite": "Przepisz", "smartEdit": "Asystenci AI", "aI": "AI", "smartEditFixSpelling": "Popraw pisownię", "warning": "⚠️ Odpowiedzi AI mogą być niedokładne lub mylące.", "smartEditSummarize": "Podsumuj", "smartEditImproveWriting": "Popraw pisanie", "smartEditMakeLonger": "Dłużej", "smartEditCouldNotFetchResult": "Nie można pobrać wyniku z AI", "smartEditCouldNotFetchKey": "Nie można pobrać klucza AI", "smartEditDisabled": "Połącz AI w Ustawieniach", "discardResponse": "Czy chcesz odrzucić odpowiedzi AI?", "createInlineMathEquation": "Utwórz równanie", "toggleList": "Przełącz listę", "quoteList": "Lista cytatów", "numberedList": "Numerowana lista", "bulletedList": "Wypunktowana lista", "todoList": "Lista rzeczy do zrobienia", "callout": "Wywołanie", "cover": { "changeCover": "Zmień okładkę", "colors": "Kolory", "images": "Obrazy", "clearAll": "Wyczyść wszystko", "abstract": "Abstrakcyjny", "addCover": "Dodaj okładkę", "addLocalImage": "Dodaj obraz lokalny", "invalidImageUrl": "Nieprawidłowy adres URL obrazu", "failedToAddImageToGallery": "Nie udało się dodać obrazu do galerii", "enterImageUrl": "Wprowadź adres URL obrazu", "add": "Dodać", "back": "Z powrotem", "saveToGallery": "Zapisz w galerii", "removeIcon": "Usuń ikonę", "pasteImageUrl": "Wklej adres URL obrazu", "or": "LUB", "pickFromFiles": "Wybierz z plików", "couldNotFetchImage": "Nie można pobrać obrazu", "imageSavingFailed": "Zapisywanie obrazu nie powiodło się", "addIcon": "Dodaj ikonę", "changeIcon": "Zmień ikonę", "coverRemoveAlert": "Zostanie usunięty z okładki.", "alertDialogConfirmation": "Jesteś pewien, że chcesz kontynuować?" }, "mathEquation": { "name": "Równanie matematyczne", "addMathEquation": "Dodaj równanie matematyczne", "editMathEquation": "Edytuj równanie matematyczne" }, "optionAction": { "click": "Kliknij", "toOpenMenu": " aby otworzyć menu", "delete": "Usuń", "duplicate": "Duplikuj", "turnInto": "Zmień w", "moveUp": "Podnieś", "moveDown": "Upuść", "color": "Kolor", "align": "Wyrównaj", "left": "Lewo", "center": "Centrum", "right": "Prawo", "defaultColor": "Domyślny" }, "image": { "addAnImage": "Dodaj obraz", "copiedToPasteBoard": "Link do obrazu został skopiowany do schowka" }, "outline": { "addHeadingToCreateOutline": "Dodaj nagłówki, aby utworzyć spis treści." }, "table": { "addAfter": "Dodaj po", "addBefore": "Dodaj przed", "delete": "Usuń", "clear": "Wyczyść treść", "duplicate": "Duplikuj", "bgColor": "Kolor tła" }, "contextMenu": { "copy": "Kopiuj", "cut": "Wytnij", "paste": "Wklej" }, "action": "Akcje" }, "textBlock": { "placeholder": "Wpisz „/” dla poleceń" }, "title": { "placeholder": "Brak nazwy" }, "imageBlock": { "placeholder": "Kliknij, aby dodać obraz", "upload": { "label": "Prześlij", "placeholder": "Kliknij, aby przesłać obraz" }, "url": { "label": "URL obrazu", "placeholder": "Wprowadź adres URL obrazu" }, "ai": { "label": "Wygeneruj obraz z AI", "placeholder": "Wpisz treść podpowiedzi dla AI, aby wygenerować obraz" }, "stability_ai": { "label": "Wygeneruj obraz z Stability AI", "placeholder": "Wpisz treść podpowiedzi dla Stability AI, aby wygenerować obraz" }, "support": "Limit rozmiaru obrazu wynosi 5 MB. Obsługiwane formaty: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Nieprawidłowy obraz", "invalidImageSize": "Rozmiar obrazu musi być mniejszy niż 5 MB", "invalidImageFormat": "Format obrazu nie jest obsługiwany. Obsługiwane formaty: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Nieprawidłowy adres URL obrazu" }, "embedLink": { "label": "Osadź link", "placeholder": "Wklej lub wpisz link obrazu" }, "searchForAnImage": "Szukaj obrazu", "pleaseInputYourOpenAIKey": "wpisz swój klucz AI w ustawieniach", "saveImageToGallery": "Zapisz obraz", "failedToAddImageToGallery": "Nie udało się dodać obrazu do galerii", "successToAddImageToGallery": "Pomyślnie dodano obraz do galerii", "unableToLoadImage": "Nie udało się wczytać obrazu", "pleaseInputYourStabilityAIKey": "wpisz swój klucz Stability AI w ustawieniach" }, "codeBlock": { "language": { "label": "Język", "placeholder": "Wybierz język" } }, "inlineLink": { "placeholder": "Wklej lub wpisz link", "openInNewTab": "Otwórz w nowej karcie", "copyLink": "Skopiuj link", "removeLink": "Usuń link", "url": { "label": "Adres URL łącza", "placeholder": "Wprowadź adres URL łącza" }, "title": { "label": "Tytuł linku", "placeholder": "Wpisz tytuł linku" } }, "mention": { "placeholder": "Wspomnij o osobie, stronie lub dacie...", "page": { "label": "Link do strony", "tooltip": "Kliknij, aby otworzyć stronę" } }, "toolbar": { "resetToDefaultFont": "Przywróć ustawienia domyślne" }, "errorBlock": { "theBlockIsNotSupported": "Bieżąca wersja nie wspiera tego bloku.", "blockContentHasBeenCopied": "Treść bloku została skopiowana." } }, "board": { "column": { "createNewCard": "Nowy", "renameGroupTooltip": "Wciśnij by zmienić nazwę grupy", "createNewColumn": "Dodaj nową grupę", "addToColumnTopTooltip": "Dodaj nową kartę na górze", "renameColumn": "Zmień nazwę", "hideColumn": "Ukryj" }, "hiddenGroupSection": { "sectionTitle": "Ukryte Grupy", "collapseTooltip": "Ukryj ukryte grupy", "expandTooltip": "Pokaż ukryte grupy" }, "menuName": "Tablica", "ungroupedButtonText": "Niepogrupowane", "ungroupedButtonTooltip": "Zawiera karty, które nie należą do żadnej grupy", "ungroupedItemsTitle": "Kliknij by dodać tablicę", "groupBy": "Grupuj zgodnie z", "referencedBoardPrefix": "Widok", "mobile": { "showGroup": "Pokaż grupę", "showGroupContent": "Czy na pewno chcesz pokazać tę grupę na tablicy?", "failedToLoad": "Nie udało się załadować widoku tablicy" } }, "calendar": { "menuName": "Kalendarz", "defaultNewCalendarTitle": "Brak nazwy", "newEventButtonTooltip": "Dodaj nowe wydarzenie", "navigation": { "today": "Dzisiaj", "jumpToday": "Przejdź do dzisiaj", "previousMonth": "Poprzedni miesiac", "nextMonth": "W następnym miesiącu" }, "settings": { "showWeekNumbers": "Pokaż numery tygodni", "showWeekends": "Pokaż weekendy", "firstDayOfWeek": "Rozpocznij tydzień", "layoutDateField": "Układ kalendarza wg", "noDateTitle": "Brak daty", "noDateHint": { "zero": "Tutaj pojawią się nieplanowane wydarzenia", "one": "{} nieplanowane wydarzenie", "other": "{} nieplanowane wydarzenia" }, "clickToAdd": "Kliknij, aby dodać do kalendarza", "name": "Układ kalendarza" }, "referencedCalendarPrefix": "Widok" }, "errorDialog": { "title": "Błąd @:appName", "howToFixFallback": "Przepraszamy za niedogodności! Zgłoś problem na naszej stronie GitHub, który opisuje Twój błąd.", "github": "Zobacz na GitHubie" }, "search": { "label": "Szukaj", "placeholder": { "actions": "Wyszukiwanie działań..." } }, "message": { "copy": { "success": "Skopiowane!", "fail": "Nie można skopiować" } }, "unSupportBlock": "Bieżąca wersja nie obsługuje tego bloku.", "views": { "deleteContentTitle": "Czy na pewno chcesz usunąć {pageType}?", "deleteContentCaption": "jeśli usuniesz ten {pageType}, możesz go przywrócić z kosza." }, "colors": { "custom": "Niestandardowy", "default": "Domyślny", "red": "Czerwony", "orange": "Pomarańczowy", "green": "Zielony", "blue": "Niebieski", "purple": "Fioletowy", "pink": "Różowy", "brown": "brązowy", "gray": "Szary" }, "emoji": { "emojiTab": "Emoji", "search": "Szukaj emoji", "noRecent": "Brak aktualnych emoji", "noEmojiFound": "Nie odnaleziono emoji", "filter": "Filtr", "random": "Losowy", "selectSkinTone": "Wybierz kolor skóry", "remove": "Usuń emoji", "categories": { "smileys": "Buźki i Emocje", "people": "Ludzie i Ciało", "animals": "Zwierzęta i Przyroda", "food": "Jedzenie i Picie", "activities": "Aktywności", "places": "Podróże i Miejsca", "objects": "Obiekty", "symbols": "Symbole", "flags": "Flagi", "nature": "Natura", "frequentlyUsed": "Często używany" }, "skinTone": { "default": "Domyślny", "light": "Jasny", "mediumLight": "Średnio-Jasny", "medium": "Średni", "mediumDark": "Średnio-Ciemny", "dark": "Ciemny" } }, "inlineActions": { "noResults": "Brak wyników", "pageReference": "Strona referencyjna", "date": "Data", "reminder": { "groupTitle": "Przypomnienie", "shortKeyword": "przypomnij" } }, "datePicker": { "dateTimeFormatTooltip": "Zmień format daty i godziny w ustawieniach" }, "relativeDates": { "yesterday": "Wczoraj", "today": "Dziś", "tomorrow": "Jutro", "oneWeek": "1 tydzień" }, "notificationHub": { "title": "Powiadomienia", "emptyTitle": "Wszystko nadrobione!", "emptyBody": "Brak oczekujących powiadomień lub działań. Ciesz się ciszą.", "tabs": { "inbox": "Skrzynka odbiorcza", "upcoming": "Nadchodzące" }, "actions": { "markAllRead": "Oznacz wszystkie jako przeczytane", "showAll": "Wszystkie", "showUnreads": "Nieprzeczytane" }, "filters": { "ascending": "Rosnąco", "descending": "Malejąco", "groupByDate": "Grupuj według daty", "showUnreadsOnly": "Pokaż tylko nieprzeczytane", "resetToDefault": "Zresetuj do domyślnych" } }, "reminderNotification": { "title": "Przypomnienie", "message": "Pamiętaj aby to sprawdzić zanim zapomnisz!", "tooltipDelete": "Usuń", "tooltipMarkRead": "Oznacz jako przeczytane", "tooltipMarkUnread": "Oznacz jako nieprzeczytane" }, "findAndReplace": { "find": "Znajdź", "previousMatch": "Poprzednie dopasowanie", "nextMatch": "Kolejne dopasowanie", "close": "Zamknij", "replace": "Zastąp", "replaceAll": "Zastąp wszystkie", "noResult": "Brak wyników", "caseSensitive": "Uwzględnij wielkość znaków" }, "error": { "weAreSorry": "Przykro nam", "loadingViewError": "Mamy problem z wyświetleniem tego widoku. Sprawdź połączenie z internetem, odśwież stronę i nie wahaj się zgłosić problem naszemu zespołowi jesli nadal będzie występował." }, "editor": { "bold": "Pogrubienie", "bulletedList": "Wypunktowana Lista", "checkbox": "Pole wyboru", "embedCode": "Osadź Kod", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Podświetlenie", "color": "Kolor", "image": "Obraz", "italic": "Kursywa", "link": "Link", "numberedList": "Numerowana Lista", "quote": "Cytat", "strikethrough": "Przekreślenie", "text": "Tekst", "underline": "Podkreślenie", "fontColorDefault": "Domyślny", "fontColorGray": "Szary", "fontColorBrown": "Brązowy", "fontColorOrange": "Pomarańczony", "fontColorYellow": "Żółty", "fontColorGreen": "Zielony", "fontColorBlue": "Niebieski", "fontColorPurple": "Fioletowy", "fontColorPink": "Różowy", "fontColorRed": "Czerwony", "backgroundColorDefault": "Domyślne tło", "backgroundColorGray": "Szare tło", "backgroundColorBrown": "Brązowe tło", "backgroundColorOrange": "Pomarańczowe tło", "backgroundColorYellow": "Żółte tło", "backgroundColorGreen": "Zielone tło", "backgroundColorBlue": "Niebieskie tło", "backgroundColorPurple": "Fioletowe tło", "backgroundColorPink": "Różowe tło", "backgroundColorRed": "Czerwone tło", "done": "Zrobione", "cancel": "Anuluj", "tint1": "Odcień 1", "tint2": "Odcień 2", "tint3": "Odcień 3", "tint4": "Odcień 4", "tint5": "Odcień 5", "tint6": "Odcień 6", "tint7": "Odcień 7", "tint8": "Odcień 8", "tint9": "Odcień 9", "lightLightTint1": "Fioletowy", "lightLightTint2": "Różowy", "lightLightTint3": "Jasnoróżowy", "lightLightTint4": "Pomarańczony", "lightLightTint5": "Żółty", "lightLightTint6": "Limonkowy", "lightLightTint7": "Zielony", "lightLightTint8": "Morski", "lightLightTint9": "Niebieski", "urlHint": "URL", "mobileHeading1": "Nagłówek 1", "mobileHeading2": "Nagłówek 2", "mobileHeading3": "Nagłówek 3", "textColor": "Kolor tekstu", "backgroundColor": "Kolor tła", "addYourLink": "Dodaj swój link", "openLink": "Otwórz link", "copyLink": "Skopiuj link", "removeLink": "Usuń link", "editLink": "Edytuj link", "linkText": "Tekst", "linkTextHint": "Wpisz tekst", "linkAddressHint": "Podaj URL", "highlightColor": "Kolor podświetlenia", "clearHighlightColor": "Usuń kolor podświetlenia", "customColor": "Niestandardowy kolor", "hexValue": "Wartość hex", "opacity": "Przezroczystość", "resetToDefaultColor": "Przywróc domyślny kolor", "ltr": "LDP", "rtl": "PDL", "auto": "Auto", "cut": "Wytnij", "copy": "Skopiuj", "paste": "Wklej", "find": "Znajdź", "previousMatch": "Poprzednie dopasowanie", "nextMatch": "Kolejne dopasowanie", "closeFind": "Zamknij", "replace": "Zastąp", "replaceAll": "Zastąp wszystko", "regex": "Regex", "caseSensitive": "Uwzględnij wielkość znaków", "uploadImage": "Prześlij Obraz", "urlImage": "URL Obrazu", "incorrectLink": "Nieprawidłowy Link", "upload": "Prześlij", "chooseImage": "Wybierz obraz", "loading": "Wczytywanie", "imageLoadFailed": "Nie udało się wczytać obrazu", "divider": "Rozdzielacz", "table": "Tabela", "colAddBefore": "Dodaj przed", "rowAddBefore": "Dodaj przed", "colAddAfter": "Dodaj za", "rowAddAfter": "Dodaj za", "colRemove": "Usuń", "rowRemove": "Usuń", "colDuplicate": "Duplikuj", "rowDuplicate": "Duplikuj", "colClear": "Wyczyść Zawartość", "rowClear": "Wyczyść Zawartość", "slashPlaceHolder": "Wciśnij / by dodać blok, lub zacznij pisać" }, "favorite": { "noFavorite": "Brak ulubionej strony", "noFavoriteHintText": "Przesuń stronę do lewej aby dodać ją do ulubionych" }, "cardDetails": { "notesPlaceholder": "Wciśnij / by dodać blok, lub zacznij pisać" }, "blockPlaceholders": { "todoList": "Do zrobienia", "bulletList": "Lista", "numberList": "Lista", "quote": "Cytat", "heading": "Nagłówek {}" }, "titleBar": { "pageIcon": "Ikona strony", "language": "Język", "font": "Czcionka" } } ================================================ FILE: frontend/resources/translations/pt-BR.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Eu", "welcomeText": "Bem-vindo ao @:appName", "welcomeTo": "Bem-vindo ao", "githubStarText": "Dar uma estrela no Github", "subscribeNewsletterText": "Inscreva-se para receber novidades", "letsGoButtonText": "Vamos lá", "title": "Título", "youCanAlso": "Você também pode", "and": "e", "failedToOpenUrl": "Falha ao abrir url: {}", "blockActions": { "addBelowTooltip": "Clique para adicionar abaixo", "addAboveCmd": "Alt+clique", "addAboveMacCmd": "Opção+clique", "addAboveTooltip": "para adicionar acima", "dragTooltip": "Arraste para mover", "openMenuTooltip": "Clique para abrir o menu" }, "signUp": { "buttonText": "Inscreva-se", "title": "Inscreva-se no @:appName", "getStartedText": "Começar", "emptyPasswordError": "Senha não pode estar em branco.", "repeatPasswordEmptyError": "Senha não estar em branco.", "unmatchedPasswordError": "As senhas não conferem.", "alreadyHaveAnAccount": "Já possui uma conta?", "emailHint": "E-mail", "passwordHint": "Dica de senha", "repeatPasswordHint": "Confirme a senha", "signUpWith": "Inscreva-se com:" }, "signIn": { "loginTitle": "Conectar-se ao @:appName", "loginButtonText": "Conectar-se", "loginStartWithAnonymous": "Iniciar com uma sessão anônima", "continueAnonymousUser": "Continuar em uma sessão anônima", "buttonText": "Entre", "signingInText": "Entrando...", "forgotPassword": "Esqueceu sua senha?", "emailHint": "E-mail", "passwordHint": "Senha", "dontHaveAnAccount": "Não possui uma conta?", "createAccount": "Criar uma conta", "repeatPasswordEmptyError": "Senha não pode estar em branco.", "unmatchedPasswordError": "As senhas não conferem.", "syncPromptMessage": "A sincronização dos dados pode demorar um pouco. Por favor não feche esta página", "or": "OU", "signInWithGoogle": "Continuar com o Google", "signInWithGithub": "Continuar com o Github", "signInWithDiscord": "Continuar com o Discord", "signInWithApple": "Continuar com a Apple", "continueAnotherWay": "Continuar de outra forma", "signUpWithGoogle": "Cadastro com o Google", "signUpWithGithub": "Cadastro com o Github", "signUpWithDiscord": "Cadastro com o Discord", "signInWith": "Entrar com:", "signInWithEmail": "Continuar com e-mail", "signInWithMagicLink": "Continuar", "signUpWithMagicLink": "Cadastro com um Link Mágico", "pleaseInputYourEmail": "Por favor, insira seu endereço de e-mail", "settings": "Configurações", "magicLinkSent": "Link Mágico enviado!", "invalidEmail": "Por favor, insira um endereço de e-mail válido", "alreadyHaveAnAccount": "Já tem uma conta?", "logIn": "Entrar", "generalError": "Algo deu errado. Tente novamente mais tarde.", "limitRateError": "Por razões de segurança, você só pode solicitar um link mágico a cada 60 segundos", "magicLinkSentDescription": "Um Link Mágico foi enviado para seu e-mail. Clique no link para concluir seu login. O link expirará após 5 minutos.", "anonymous": "Anônimo", "LogInWithGoogle": "Entrar com o Google", "LogInWithGithub": "Entrar com o Github", "LogInWithDiscord": "Entrar com o Discord", "loginAsGuestButtonText": "Iniciar" }, "workspace": { "chooseWorkspace": "Escolha seu espaço de trabalho", "create": "Crie um espaço de trabalho", "reset": "Redefinir espaço de trabalho", "renameWorkspace": "Renomear espaço de trabalho", "resetWorkspacePrompt": "A redefinição do espaço de trabalho excluirá todas as páginas e dados contidos nele. Tem certeza de que deseja redefinir o espaço de trabalho? Alternativamente, você pode entrar em contato com a equipe de suporte para restaurar o espaço de trabalho", "hint": "Espaço de trabalho", "notFoundError": "Espaço de trabalho não encontrado", "failedToLoad": "Algo deu errado! Falha ao carregar o espaço de trabalho. Tente fechar qualquer instância aberta do @:appName e tente novamente.", "errorActions": { "reportIssue": "Reporte um problema", "reportIssueOnGithub": "Reportar um problema no Github", "exportLogFiles": "Exportar arquivos de log", "reachOut": "Entre em contato no Discord" }, "menuTitle": "Espaços de trabalho", "deleteWorkspaceHintText": "Tem certeza de que deseja excluir o espaço de trabalho? Esta ação não pode ser desfeita, e quaisquer páginas que você tenha publicado deixarão de estar publicadas.", "createSuccess": "Espaço de trabalho criado com sucesso", "createFailed": "Falha ao criar espaço de trabalho", "createLimitExceeded": "Você atingiu o limite máximo de espaços de trabalho permitido para sua conta. Se precisar de espaços de trabalho adicionais para continuar seu trabalho, solicite no Github", "deleteSuccess": "Espaço de trabalho excluído com sucesso", "deleteFailed": "Falha ao excluir o espaço de trabalho", "openSuccess": "Espaço de trabalho aberto com sucesso", "openFailed": "Falha ao abrir o espaço de trabalho", "renameSuccess": "Espaço de trabalho renomeado com sucesso", "renameFailed": "Falha ao renomear o espaço de trabalho", "updateIconSuccess": "Ícone do espaço de trabalho atualizado com sucesso", "updateIconFailed": "Falha ao atualizar ícone do espaço de trabalho", "cannotDeleteTheOnlyWorkspace": "Não é possível excluir o único espaço de trabalho", "fetchWorkspacesFailed": "Falha ao buscar espaços de trabalho", "leaveCurrentWorkspace": "Sair do espaço de trabalho", "leaveCurrentWorkspacePrompt": "Tem certeza de que deseja sair do espaço de trabalho atual?" }, "shareAction": { "buttonText": "Compartilhar", "workInProgress": "Em breve", "markdown": "Marcador", "clipboard": "Copiar para área de transferência", "csv": "CSV", "copyLink": "Copiar link", "publishToTheWeb": "Publicar na Web", "publishToTheWebHint": "Crie um site com AppFlowy", "publish": "Publicar", "unPublish": "Remover publicação", "visitSite": "Visitar site", "exportAsTab": "Exportar como", "publishTab": "Publicar", "shareTab": "Compartilhar" }, "moreAction": { "small": "pequeno", "medium": "médio", "large": "grande", "fontSize": "Tamanho da fonte", "import": "Importar", "moreOptions": "Mais opções", "wordCount": "Contagem de palavras: {}", "charCount": "Contagem de caracteres: {}", "createdAt": "Criado: {}", "deleteView": "Excluir", "duplicateView": "Duplicar" }, "importPanel": { "textAndMarkdown": "Texto e Remarcação", "documentFromV010": "Documento de v0.1.0", "databaseFromV010": "Banco de dados de v0.1.0", "csv": "CSV", "database": "Base de dados" }, "disclosureAction": { "rename": "Renomear", "delete": "Apagar", "duplicate": "Duplicar", "unfavorite": "Remover dos favoritos", "favorite": "Adicionar aos favoritos", "openNewTab": "Abrir em uma nova guia", "moveTo": "Mover para", "addToFavorites": "Adicionar aos favoritos", "copyLink": "Copiar link", "changeIcon": "Alterar ícone", "collapseAllPages": "Recolher todas as subpáginas" }, "blankPageTitle": "Página em branco", "newPageText": "Nova página", "newDocumentText": "Novo Documento", "newGridText": "Nova grelha", "newCalendarText": "Novo calendário", "newBoardText": "Novo quadro", "chat": { "newChat": "Bate-papo com IA", "inputMessageHint": "Pergunte a IA @:appName", "inputLocalAIMessageHint": "Pergunte a IA local @:appName", "unsupportedCloudPrompt": "Este recurso só está disponível ao usar a nuvem @:appName", "relatedQuestion": "Relacionado", "serverUnavailable": "Serviço Temporariamente Indisponível. Tente novamente mais tarde.", "aiServerUnavailable": "🌈 Uh-oh! 🌈. Um unicórnio comeu nossa resposta. Por favor, tente novamente!", "clickToRetry": "Clique para tentar novamente", "regenerateAnswer": "Gerar novamente", "question1": "Como usar Kanban para gerenciar tarefas", "question2": "Explique o método GTD", "question3": "Por que usar Rust", "question4": "Receita com o que tenho na cozinha", "aiMistakePrompt": "A IA pode cometer erros. Verifique informações importantes.", "chatWithFilePrompt": "Você quer conversar com o arquivo?", "indexFileSuccess": "Arquivo indexado com sucesso", "inputActionNoPages": "Nenhum resultado", "referenceSource": { "zero": "0 fontes encontradas", "one": "{count} fonte encontrada", "other": "{count} fontes encontradas" }, "clickToMention": "Clique para mencionar uma página", "uploadFile": "Carregue arquivos PDFs, md ou txt para conversar", "questionDetail": "Olá {}! Como posso te ajudar hoje?", "indexingFile": "Indexando {}" }, "trash": { "text": "Lixeira", "restoreAll": "Restaurar tudo", "deleteAll": "Apagar tudo", "pageHeader": { "fileName": "Nome do arquivo", "lastModified": "Última modificação", "created": "Criado" }, "confirmDeleteAll": { "title": "Tem certeza de que deseja excluir todas as páginas da Lixeira?", "caption": "Essa ação não pode ser desfeita." }, "confirmRestoreAll": { "title": "Tem certeza de que deseja restaurar todas as páginas da Lixeira?", "caption": "Essa ação não pode ser desfeita." }, "mobile": { "actions": "Ações de lixo", "empty": "A lixeira está vazia", "emptyDescription": "Você não tem nenhum arquivo excluído", "isDeleted": "foi deletado", "isRestored": "foi restaurado" }, "confirmDeleteTitle": "Tem certeza de que deseja excluir esta página permanentemente?" }, "deletePagePrompt": { "text": "Está página está na lixeira", "restore": "Restaurar a página", "deletePermanent": "Apagar permanentemente" }, "dialogCreatePageNameHint": "Nome da página", "questionBubble": { "shortcuts": "Atalhos", "whatsNew": "O que há de novo?", "markdown": "Remarcação", "debug": { "name": "Informação de depuração", "success": "Informação de depuração copiada para a área de transferência!", "fail": "Falha ao copiar a informação de depuração para a área de transferência" }, "feedback": "Opinião", "help": "Ajuda e Suporte" }, "menuAppHeader": { "moreButtonToolTip": "Remover, renomear e muito mais...", "addPageTooltip": "Adicionar uma nova página.", "defaultNewPageName": "Sem título", "renameDialog": "Renomear" }, "noPagesInside": "Sem páginas internas", "toolbar": { "undo": "Desfazer", "redo": "Refazer", "bold": "Negrito", "italic": "Itálico", "underline": "Sublinhado", "strike": "Tachado", "numList": "Lista numerada", "bulletList": "Lista com marcadores", "checkList": "Check list", "inlineCode": "Embutir código", "quote": "Citação em bloco", "header": "Cabeçalho", "highlight": "Destacar", "color": "Cor", "addLink": "Adicionar link", "link": "Link" }, "tooltip": { "lightMode": "Mudar para o modo claro", "darkMode": "Mudar para o modo escuro", "openAsPage": "Abrir como uma página", "addNewRow": "Adicionar uma nova linha", "openMenu": "Clique para abrir o menu", "dragRow": "Pressione e segure para reordenar a linha", "viewDataBase": "Visualizar banco de dados", "referencePage": "Esta {name} é uma referência", "addBlockBelow": "Adicione um bloco abaixo", "aiGenerate": "Gerar" }, "sideBar": { "closeSidebar": "Fechar barra lateral", "openSidebar": "Abrir barra lateral", "personal": "Pessoal", "private": "Privado", "workspace": "Espaço de trabalho", "favorites": "Favoritos", "clickToHidePrivate": "Clique para ocultar o espaço privado\nAs páginas que você criou aqui são visíveis apenas para você", "clickToHideWorkspace": "Clique para ocultar o espaço de trabalho\nAs páginas que você criou aqui são visíveis para todos os membros", "clickToHidePersonal": "Clique para ocultar a seção pessoal", "clickToHideFavorites": "Clique para ocultar a seção favorita", "addAPage": "Adicionar uma página", "addAPageToPrivate": "Adicionar uma página ao espaço privado", "addAPageToWorkspace": "Adicionar uma página ao espaço de trabalho", "recent": "Recentes", "today": "Hoje", "thisWeek": "Essa semana", "others": "Favoritos anteriores", "justNow": "agora mesmo", "minutesAgo": "{count} minutos atrás", "lastViewed": "Última visualização", "favoriteAt": "Favorito", "emptyRecent": "Nenhum documento recente", "emptyRecentDescription": "Conforme você visualiza os documentos, eles aparecerão aqui para fácil recuperação", "emptyFavorite": "Nenhum documento favorito", "emptyFavoriteDescription": "Comece a explorar e marque os documentos como favoritos. Eles serão listados aqui para acesso rápido!", "removePageFromRecent": "Remover esta página dos Recentes?", "removeSuccess": "Removido com sucesso", "favoriteSpace": "Favoritos", "RecentSpace": "Recente", "Spaces": "Espaços", "upgradeToPro": "Atualizar para Pro", "upgradeToAIMax": "Desbloqueie IA ilimitada", "storageLimitDialogTitle": "Você ficou sem armazenamento gratuito. Atualize para desbloquear armazenamento ilimitado", "aiResponseLimitTitle": "Você ficou sem respostas de IA gratuitas. Atualize para o Plano Pro ou adquira um complemento de IA para desbloquear respostas ilimitadas", "aiResponseLimitDialogTitle": "Limite de respostas de IA atingido", "aiResponseLimit": "Você ficou sem respostas de IA gratuitas.\n\nVá para Configurações -> Plano -> Clique em AI Max ou Plano Pro para obter mais respostas de IA", "askOwnerToUpgradeToPro": "Seu espaço de trabalho está ficando sem armazenamento gratuito. Peça ao proprietário do seu espaço de trabalho para atualizar para o Plano Pro", "askOwnerToUpgradeToAIMax": "Seu espaço de trabalho está ficando sem respostas de IA gratuitas. Peça ao proprietário do seu espaço de trabalho para atualizar o plano ou adquirir complementos de IA", "purchaseStorageSpace": "Adquirir espaço de armazenamento", "purchaseAIResponse": "Adquirir ", "askOwnerToUpgradeToLocalAI": "Peça ao proprietário do espaço de trabalho para habilitar a IA no dispositivo", "upgradeToAILocal": "Execute modelos locais no seu dispositivo para máxima privacidade", "upgradeToAILocalDesc": "Converse com PDFs, melhore sua escrita e preencha tabelas automaticamente usando IA local" }, "notifications": { "export": { "markdown": "Nota exportada como um marcador", "path": "Documentos/flowy" } }, "contactsPage": { "title": "Contatos", "whatsHappening": "O que está acontecendo essa semana?", "addContact": "Adicionar um contato", "editContact": "Editar um contato" }, "button": { "ok": "OK", "confirm": "Confirmar", "done": "Feito", "cancel": "Cancelar", "signIn": "Conectar", "signOut": "Desconectar", "complete": "Completar", "save": "Salvar", "generate": "Gerar", "esc": "Sair", "keep": "Manter", "tryAgain": "Tente novamente", "discard": "Descartar", "replace": "substituir", "insertBelow": "Inserir Abaixo", "insertAbove": "Insira acima", "upload": "Carregar", "edit": "Editar", "delete": "Excluir", "duplicate": "Duplicado", "putback": "Por de volta", "update": "Atualizar", "share": "Compartilhar", "removeFromFavorites": "Remover dos favoritos", "removeFromRecent": "Remover dos recentes", "addToFavorites": "Adicionar aos favoritos", "favoriteSuccessfully": "Adicionado aos favoritos", "unfavoriteSuccessfully": "Removido dos favoritos", "duplicateSuccessfully": "Duplicado com sucesso", "rename": "Renomear", "helpCenter": "Central de Ajuda", "add": "Adicionar", "yes": "Sim", "no": "Não", "clear": "Limpar", "remove": "Remover", "dontRemove": "Não remova", "copyLink": "Copiar Link", "align": "Alinhar", "login": "Entrar", "logout": "Sair", "deleteAccount": "Deletar conta", "back": "Voltar", "signInGoogle": "Continuar com o Google", "signInGithub": "Continuar com o Github", "signInDiscord": "Continuar com o Discord", "more": "Mais", "create": "Criar", "close": "Fechar", "next": "Próximo", "previous": "Anterior", "submit": "Enviar", "tryAGain": "Tentar novamente" }, "label": { "welcome": "Bem-vindo!", "firstName": "Nome", "middleName": "Sobrenome", "lastName": "Último nome", "stepX": "Passo {X}" }, "oAuth": { "err": { "failedTitle": "Erro ao conectar a sua conta.", "failedMsg": "Verifique se você concluiu o processo de conexão em seu navegador." }, "google": { "title": "Conexão com o GOOGLE", "instruction1": "Para importar seus Contatos do Google, você precisará autorizar este aplicativo usando seu navegador.", "instruction2": "Copie este código para sua área de transferência clicando no ícone ou selecionando o texto:", "instruction3": "Navegue até o link a seguir em seu navegador e digite o código acima:", "instruction4": "Pressione o botão abaixo ao concluir a inscrição:" } }, "settings": { "title": "Configurações", "popupMenuItem": { "settings": "Configurações", "members": "Membros", "trash": "Lixo", "helpAndSupport": "Ajuda e Suporte" }, "accountPage": { "menuLabel": "Minha conta", "title": "Minha conta", "general": { "title": "Nome da conta e foto de perfil", "changeProfilePicture": "Alterar foto do perfil" }, "email": { "title": "E-mail", "actions": { "change": "Alterar e-mail" } }, "login": { "title": "Entrar com uma conta", "loginLabel": "Entrar", "logoutLabel": "Sair" } }, "workspacePage": { "menuLabel": "Espaço de trabalho", "title": "Espaço de trabalho", "description": "Personalize a aparência do seu espaço de trabalho, tema, fonte, layout de texto, formato de data/hora e idioma.", "workspaceName": { "title": "Nome do espaço de trabalho" }, "workspaceIcon": { "title": "Ícone do espaço de trabalho", "description": "Carregue uma imagem ou use um emoji para seu espaço de trabalho. O ícone será exibido na sua barra lateral e notificações." }, "appearance": { "title": "Aparência", "description": "Personalize a aparência do seu espaço de trabalho, tema, fonte, layout de texto, data, hora e idioma.", "options": { "system": "Automático", "light": "Claro", "dark": "Escuro" } }, "resetCursorColor": { "title": "Redefinir a cor do cursor do documento", "description": "Tem certeza de que deseja redefinir a cor do cursor?" }, "resetSelectionColor": { "title": "Redefinir cor de seleção de documento", "description": "Tem certeza de que deseja redefinir a cor de seleção?" }, "theme": { "title": "Tema", "description": "Selecione um tema predefinido ou carregue seu próprio tema personalizado.", "uploadCustomThemeTooltip": "Carregar um tema personalizado" }, "workspaceFont": { "title": "Fonte do espaço de trabalho", "noFontHint": "Nenhuma fonte encontrada, tente outro termo." }, "textDirection": { "title": "Direção do texto", "leftToRight": "Da esquerda para a direita", "rightToLeft": "Da direita para a esquerda", "auto": "Automático", "enableRTLItems": "Habilitar items da barra de ferramenta da direita para a esquerda" }, "layoutDirection": { "title": "Direção do layout", "leftToRight": "Da esquerda para a direita", "rightToLeft": "Da direita para a esquerda" }, "dateTime": { "title": "Data e hora", "example": "{} as {} ({})", "24HourTime": "Tempo de 24 horas", "dateFormat": { "label": "Formato de data", "us": "EUA", "friendly": "Amigável" } }, "language": { "title": "Língua" }, "deleteWorkspacePrompt": { "title": "Excluir espaço de trabalho", "content": "Tem certeza de que deseja excluir este espaço de trabalho? Esta ação não pode ser desfeita, e quaisquer páginas que você tenha publicado deixarão de estar publicadas." }, "leaveWorkspacePrompt": { "title": "Sair do espaço de trabalho", "content": "Tem certeza de que deseja sair deste espaço de trabalho? Você perderá o acesso a todas as páginas e dados dentro dele." }, "manageWorkspace": { "title": "Gerenciar espaço de trabalho", "leaveWorkspace": "Sair do espaço de trabalho", "deleteWorkspace": "Excluir espaço de trabalho" } }, "manageDataPage": { "menuLabel": "Gerenciar dados", "title": "Gerenciar dados", "description": "Gerencie o armazenamento local de dados ou importe seus dados existentes para @:appName .", "dataStorage": { "title": "Local de armazenamento de arquivos", "tooltip": "O local onde seus arquivos são armazenados", "actions": { "change": "Mudar caminho", "open": "Abrir pasta", "openTooltip": "Abrir local da pasta de dados atual", "copy": "Copiar caminho", "copiedHint": "Caminho copiado!", "resetTooltip": "Redefinir para o local padrão" }, "resetDialog": { "title": "Tem certeza?", "description": "Redefinir o caminho para o local de dados padrão não excluirá seus dados. Se você quiser reimportar seus dados atuais, você deve copiar o caminho do seu local atual primeiro." } }, "importData": { "title": "Importar dados", "tooltip": "Importar dados das pastas de backups/dados de @:appName", "description": "Copiar dados de uma pasta de dados externa ao @:appName", "action": "Selecionar arquivo" }, "encryption": { "title": "Criptografia", "tooltip": "Gerencie como seus dados são armazenados e criptografados", "descriptionNoEncryption": "Ativar a criptografia criptografará todos os dados. Isso não pode ser desfeito.", "descriptionEncrypted": "Seus dados estão criptografados.", "action": "Criptografar dados", "dialog": { "title": "Criptografar todos os seus dados?", "description": "Criptografar todos os seus dados manterá seus dados seguros e protegidos. Esta ação NÃO pode ser desfeita. Tem certeza de que deseja continuar?" } }, "cache": { "title": "Limpar cache", "description": "Ajude a resolver problemas como imagem não carregando, páginas faltando em um espaço e fontes não carregando. Isso não afetará seus dados.", "dialog": { "title": "Limpar cache", "description": "Ajude a resolver problemas como imagem não carregando, páginas faltando em um espaço e fontes não carregando. Isso não afetará seus dados.", "successHint": "Cache limpo!" } }, "data": { "fixYourData": "Corrija seus dados", "fixButton": "Corrigir", "fixYourDataDescription": "Se estiver com problemas com seus dados, você pode tentar corrigi-los aqui." } }, "shortcutsPage": { "menuLabel": "Atalhos", "title": "Atalhos", "editBindingHint": "Insira uma nova combinação", "searchHint": "Pesquisar", "actions": { "resetDefault": "Redefinir padrão" }, "errorPage": { "message": "Falha ao carregar atalhos: {}", "howToFix": "Por favor, tente novamente. Se o problema persistir, entre em contato pelo GitHub." }, "resetDialog": { "title": "Redefinir atalhos", "description": "Isso redefinirá todas as suas combinações de teclas para o padrão. Você não poderá desfazer isso depois. Tem certeza de que deseja continuar?", "buttonLabel": "Redefinir" }, "conflictDialog": { "title": "{} já está em uso", "descriptionPrefix": "Esta combinação de teclas está sendo usada atualmente por ", "descriptionSuffix": ". Se você substituir esta combinação de teclas, ela será removida de {}.", "confirmLabel": "Continuar" }, "editTooltip": "Pressione para começar a editar a combinação de teclas", "keybindings": { "toggleToDoList": "Alternar para a lista de tarefas", "insertNewParagraphInCodeblock": "Inserir novo parágrafo", "pasteInCodeblock": "Colar bloco de código", "selectAllCodeblock": "Selecionar tudo" } }, "menu": { "appearance": "Aparência", "language": "Idioma", "user": "Usuário", "files": "Arquivos", "notifications": "Notificações", "open": "Abrir Configurações", "logout": "Sair", "logoutPrompt": "Tem certeza de que deseja sair?", "selfEncryptionLogoutPrompt": "Tem certeza que deseja sair? Certifique-se de ter copiado o segredo de criptografia", "syncSetting": "Configuração de sincronização", "cloudSettings": "Configurações de nuvem", "enableSync": "Ativar sincronização", "enableEncrypt": "Criptografar dados", "cloudURL": "URL base", "invalidCloudURLScheme": "Esquema inválido", "cloudServerType": "Servidor em nuvem", "cloudServerTypeTip": "Observe que ele pode desconectar sua conta atual após mudar o servidor em nuvem", "cloudLocal": "Local", "cloudAppFlowy": "@:appName Cloud Beta", "clickToCopy": "Clique para copiar", "selfHostStart": "Se você não possui um servidor, consulte o", "selfHostContent": "documento", "selfHostEnd": "para obter orientação sobre como hospedar seu próprio servidor", "cloudURLHint": "Insira o URL base do seu servidor", "cloudWSURL": "URL do WebSocket", "cloudWSURLHint": "Insira o endereço do websocket do seu servidor", "restartApp": "Reiniciar", "restartAppTip": "Reinicie o aplicativo para que as alterações tenham efeito. Observe que isso pode desconectar sua conta atual", "enableEncryptPrompt": "Ative a criptografia para proteger seus dados com este segredo. Armazene-o com segurança; uma vez ativado, não pode ser desativado. Se perdidos, seus dados se tornarão irrecuperáveis. Clique para copiar", "inputEncryptPrompt": "Por favor, insira seu segredo de criptografia para", "clickToCopySecret": "Clique para copiar o segredo", "configServerSetting": "Configure seu servidor", "configServerGuide": "Após selecionar `Quick Start`, navegue até `Settings` e depois \"Cloud Settings\" para configurar seu servidor auto-hospedado.", "inputTextFieldHint": "Seu segredo", "historicalUserList": "Histórico de login do usuário", "historicalUserListTooltip": "Esta lista exibe suas contas anônimas. Você pode clicar em uma conta para ver seus detalhes. Contas anônimas são criadas clicando no botão ‘Começar’", "openHistoricalUser": "Clique para abrir a conta anônima", "customPathPrompt": "Armazenar a pasta de dados do @:appName em uma pasta sincronizada na nuvem, como o Google Drive, pode representar riscos. Se o banco de dados nesta pasta for acessado ou modificado de vários locais ao mesmo tempo, isso poderá resultar em conflitos de sincronização e possível corrupção de dados", "importAppFlowyData": "Importar dados da pasta @:appName externa", "importAppFlowyDataDescription": "Copie dados de uma pasta de dados externa do @:appName e importe-os para a pasta de dados atual do @:appName", "importSuccess": "Importou com sucesso a pasta de dados do @:appName", "importFailed": "Falha ao importar a pasta de dados do @:appName", "importGuide": "Para mais detalhes, consulte o documento referenciado" }, "notifications": { "enableNotifications": { "label": "Habilitar notificações", "hint": "Desligue para impedir notificações locais de aparecer" } }, "appearance": { "resetSetting": "Redefinir esta configuração", "fontFamily": { "label": "Família de fontes", "search": "Procurar" }, "themeMode": { "label": "Tema", "light": "Modo claro", "dark": "Modo escuro", "system": "Adaptar-se ao sistema" }, "documentSettings": { "cursorColor": "Cor do cursor do documento", "selectionColor": "Cor de seleção do documento", "hexEmptyError": "A cor hexadecimal não pode estar vazia", "hexLengthError": "O valor hexadecimal deve ter 6 dígitos", "hexInvalidError": "Valor hexadecimal inválido", "opacityEmptyError": "A opacidade não pode estar vazia", "opacityRangeError": "A opacidade deve estar entre 1 e 100", "app": "App", "flowy": "Flowy", "apply": "Aplicar" }, "layoutDirection": { "label": "Direção do Layout", "hint": "Controle o fluxo do conteúdo na tela, da esquerda para a direita ou da direita para a esquerda.", "ltr": "LTR", "rtl": "RTL" }, "textDirection": { "label": "Direção de texto padrão", "hint": "Especifique se o texto deve começar da esquerda ou da direita como padrão.", "ltr": "LTR", "rtl": "RTL", "auto": "AUTO", "fallback": "Igual à direção do layout" }, "themeUpload": { "button": "Carregar", "uploadTheme": "Carregar tema", "description": "Carregue seu próprio tema @:appName usando o botão abaixo.", "loading": "Aguarde enquanto validamos e carregamos seu tema...", "uploadSuccess": "Seu tema foi carregado com sucesso", "deletionFailure": "Falha ao excluir o tema. Tente excluí-lo manualmente.", "filePickerDialogTitle": "Escolha um arquivo .flowy_plugin", "urlUploadFailure": "Falha ao abrir url: {}", "failure": "O tema que foi carregado tinha um formato inválido." }, "theme": "Tema", "builtInsLabel": "Temas embutidos", "pluginsLabel": "Plugins", "dateFormat": { "label": "Formato de data", "local": "Local", "us": "US", "iso": "ISO", "friendly": "Amigável", "dmy": "D/M/A" }, "timeFormat": { "label": "Formato de hora", "twelveHour": "Doze horas", "twentyFourHour": "Vinte e quatro horas" }, "showNamingDialogWhenCreatingPage": "Mostrar caixa de diálogo de nomenclatura ao criar uma página" }, "files": { "copy": "cópia de", "defaultLocation": "Onde os seus dados ficam armazenados", "exportData": "Exporte seus dados", "doubleTapToCopy": "Clique duas vezes para copiar o caminho", "restoreLocation": "Restaurar para o caminho padrão do @:appName", "customizeLocation": "Abrir outra pasta", "restartApp": "Reinicie o aplicativo para que as alterações entrem em vigor.", "exportDatabase": "Exportar banco de dados", "selectFiles": "Escolha os arquivos que precisam ser exportados", "selectAll": "Selecionar tudo", "deselectAll": "Desmarcar todos", "createNewFolder": "Criar uma nova pasta", "createNewFolderDesc": "Diga-nos onde pretende armazenar os seus dados ...", "defineWhereYourDataIsStored": "Defina onde seus dados são armazenados", "open": "Abrir", "openFolder": "Abra uma pasta existente", "openFolderDesc": "Gravar na pasta @:appName existente ...", "folderHintText": "nome da pasta", "location": "Criando nova pasta", "locationDesc": "Escolha um nome para sua pasta de dados do @:appName", "browser": "Navegar", "create": "Criar", "set": "Definir", "folderPath": "Caminho para armazenar sua pasta", "locationCannotBeEmpty": "O caminho não pode estar vazio", "pathCopiedSnackbar": "Caminho de armazenamento de arquivo copiado para a área de transferência!", "changeLocationTooltips": "Alterar o diretório de dados", "change": "Mudar", "openLocationTooltips": "Abra outro diretório de dados", "openCurrentDataFolder": "Abra o diretório de dados atual", "recoverLocationTooltips": "Redefinir para o diretório de dados padrão do @:appName", "exportFileSuccess": "Exportar arquivo com sucesso!", "exportFileFail": "Falha na exportação do arquivo!", "export": "Exportar" }, "user": { "name": "Nome", "email": "E-mail", "tooltipSelectIcon": "Selecionar ícone", "selectAnIcon": "Escolha um ícone", "pleaseInputYourOpenAIKey": "por favor insira sua chave AI", "clickToLogout": "Clique para sair do usuário atual", "pleaseInputYourStabilityAIKey": "insira sua chave Stability AI" }, "mobile": { "personalInfo": "Informações pessoais", "username": "Nome de usuário", "usernameEmptyError": "O nome de usuário não pode ficar vazio", "about": "Sobre", "pushNotifications": "Notificações via push", "support": "Apoiar", "joinDiscord": "Junte-se a nós no Discord", "privacyPolicy": "Política de Privacidade", "userAgreement": "Termo de Acordo do Usuário", "termsAndConditions": "Termos e Condições", "userprofileError": "Falha ao carregar o perfil do usuário", "userprofileErrorDescription": "Tente sair e fazer login novamente para verificar se o problema ainda persiste.", "selectLayout": "Selecione o layout", "selectStartingDay": "Selecione o dia de início", "version": "Versão" }, "shortcuts": { "shortcutsLabel": "Atalhos", "command": "Comando", "keyBinding": "Atalhos de teclado", "addNewCommand": "Adicionar novo comando", "updateShortcutStep": "Pressione a combinação de teclas desejada e pressione ENTER", "shortcutIsAlreadyUsed": "Este atalho já é usado para: {conflict}", "resetToDefault": "Redefinir para atalhos de teclado padrão", "couldNotLoadErrorMsg": "Não foi possível carregar os atalhos. Tente novamente", "couldNotSaveErrorMsg": "Não foi possível salvar os atalhos. Tente novamente", "commands": { "codeBlockNewParagraph": "Bloco de código: insirir um novo parágrafo", "codeBlockAddTwoSpaces": "Bloco de código: insirir dois espaços no início da linha", "codeBlockSelectAll": "Bloco de código: selecionar tudo", "codeBlockPasteText": "Bloco de códito: Colar texto", "textAlignLeft": "Alinhar texto à esquerda", "textAlignCenter": "Alinhar texto ao centro", "textAlignRight": "Alinhar texto à direita", "codeBlockDeleteTwoSpaces": "Bloco de código: excluir dois espaços no início da linha" } } }, "grid": { "deleteView": "Tem certeza de que deseja excluir esta visualização?", "createView": "Novo", "title": { "placeholder": "Sem título" }, "settings": { "filter": "Filtro", "sort": "Organizar", "sortBy": "Organizar por", "properties": "Propriedades", "reorderPropertiesTooltip": "Arraste para reordenar as propriedades", "group": "Grupo", "addFilter": "Adicionar filtro", "deleteFilter": "Apagar filtro", "filterBy": "Filtrar por...", "typeAValue": "Digite um valor...", "layout": "Disposição", "databaseLayout": "Disposição", "editView": "Editar visualização", "boardSettings": "Configurações do quadro", "calendarSettings": "Configurações do calendário", "createView": "Nova visualização", "duplicateView": "Visualização duplicada", "deleteView": "Excluir visualização", "numberOfVisibleFields": "{} mostrando", "Properties": "Propriedades", "viewList": "Visualizações de banco de dados" }, "textFilter": { "contains": "Contém", "doesNotContain": "Não contém", "endsWith": "Termina com", "startWith": "Inicia com", "is": "É", "isNot": "Não é", "isEmpty": "Está vazio", "isNotEmpty": "Não está vazio", "choicechipPrefix": { "isNot": "Não", "startWith": "Inicia com", "endWith": "Termina com", "isEmpty": "está vazio", "isNotEmpty": "não está vazio" } }, "checkboxFilter": { "isChecked": "Marcado", "isUnchecked": "Desmarcado", "choicechipPrefix": { "is": "está" } }, "checklistFilter": { "isComplete": "está completo", "isIncomplted": "está imcompleto" }, "selectOptionFilter": { "is": "Está", "isNot": "Não está", "contains": "Contém", "doesNotContain": "Não contém", "isEmpty": "Está vazio", "isNotEmpty": "Está vazio" }, "dateFilter": { "is": "É", "before": "É antes", "after": "é depois", "between": "Está entre", "empty": "Está vazia", "notEmpty": "Não está vazio" }, "field": { "hide": "Ocultar", "show": "Mostrar", "insertLeft": "Inserir a esquerda", "insertRight": "Inserir a direita", "duplicate": "Duplicar", "delete": "Apagar", "textFieldName": "Texto", "checkboxFieldName": "Caixa de seleção", "dateFieldName": "Data", "updatedAtFieldName": "Hora da última modificação", "createdAtFieldName": "hora criada", "numberFieldName": "Números", "singleSelectFieldName": "Selecionar", "multiSelectFieldName": "Multi seleção", "urlFieldName": "URL", "checklistFieldName": "Lista", "numberFormat": "Formato numérico", "dateFormat": "Formato de data", "includeTime": "Incluir hora", "isRange": "Data final", "dateFormatFriendly": "Mês Dia, Ano", "dateFormatISO": "Ano-Mês-Dia", "dateFormatLocal": "Mês/Dia/Ano", "dateFormatUS": "Ano/Mês/Dia", "dateFormatDayMonthYear": "Dia mês ano", "timeFormat": "Formato de hora", "invalidTimeFormat": "Formato inválido", "timeFormatTwelveHour": "12 horas", "timeFormatTwentyFourHour": "24 horas", "clearDate": "Limpar data", "dateTime": "Data hora", "startDateTime": "Data e hora de início", "endDateTime": "Data e hora de término", "failedToLoadDate": "Falha ao carregar o valor da data", "selectTime": "Selecione o horário", "selectDate": "Selecione a data", "visibility": "Visibilidade", "propertyType": "Tipo de Propriedade", "addSelectOption": "Adicionar uma opção", "typeANewOption": "Digite uma nova opção", "optionTitle": "Opções", "addOption": "Adicioar opção", "editProperty": "Editar propriedade", "newProperty": "Nova coluna", "deleteFieldPromptMessage": "Tem certeza? Esta propriedade será excluída", "newColumn": "Nova Coluna", "format": "Formato" }, "rowPage": { "newField": "Adicione um novo campo", "fieldDragElementTooltip": "Clique para abrir o menu", "showHiddenFields": { "one": "Mostrar campo oculto {count}", "many": "Mostrar {count} campos ocultos", "other": "Mostrar {count} campos ocultos" }, "hideHiddenFields": { "one": "Ocultar {count} campo oculto", "many": "Ocultar {count} campos ocultos", "other": "Ocultar {count} campos ocultos" } }, "sort": { "ascending": "Crescente", "descending": "Decrescente", "deleteAllSorts": "Apagar todas as ordenações", "addSort": "Adicionar ordenação", "deleteSort": "Apagar ordenação" }, "row": { "duplicate": "Duplicar", "delete": "Apagar", "titlePlaceholder": "Sem título", "textPlaceholder": "Vazio", "copyProperty": "Propriedade copiada para a área de transferência", "count": "Contagem", "newRow": "Nova linha", "action": "Ação", "add": "Clique em adicionar abaixo", "drag": "Arraste para mover", "dragAndClick": "Arraste para mover, clique para abrir o menu", "insertRecordAbove": "Insira o registro acima", "insertRecordBelow": "Insira o registro abaixo" }, "selectOption": { "create": "Criar", "purpleColor": "Roxo", "pinkColor": "Rosa", "lightPinkColor": "Rosa claro", "orangeColor": "Laranja", "yellowColor": "Amarelo", "limeColor": "Verde limão", "greenColor": "Verde", "aquaColor": "Áqua", "blueColor": "Azul", "deleteTag": "Apagar etiqueta", "colorPanelTitle": "cores", "panelTitle": "Selecione uma opção ou crie uma", "searchOption": "Procurar uma opção", "searchOrCreateOption": "Pesquise ou crie uma opção...", "createNew": "Crie um novo", "orSelectOne": "Ou selecione uma opção", "typeANewOption": "Digite uma nova opção", "tagName": "Nome da etiqueta" }, "checklist": { "taskHint": "Descrição da tarefa", "addNew": "Adicionar um item", "submitNewTask": "Criar", "hideComplete": "Ocultar tarefas concluídas", "showComplete": "Mostrar todas as tarefas" }, "url": { "launch": "Abrir com o navegador", "copy": "Copiar URL" }, "menuName": "Grade", "referencedGridPrefix": "Vista de" }, "document": { "menuName": "Documento", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Selecione um quadro para vincular", "createANewBoard": "Criar um novo Conselho" }, "grid": { "selectAGridToLinkTo": "Selecione um grade para vincular", "createANewGrid": "Criar uma nova grade" }, "calendar": { "selectACalendarToLinkTo": "Selecione um calendário para vincular", "createANewCalendar": "Criar um novo calendário" }, "document": { "selectADocumentToLinkTo": "Selecione um documento para vincular" } }, "selectionMenu": { "outline": "Contorno", "codeBlock": "Bloco de código" }, "plugins": { "referencedBoard": "Quadro referenciado", "referencedGrid": "Grade referenciada", "referencedCalendar": "Calendário referenciado", "referencedDocument": "Referenciar um documento", "autoGeneratorMenuItemName": "Gerar nome automaticamente", "autoGeneratorTitleName": "Gerar por IA", "autoGeneratorLearnMore": "Saiba mais", "autoGeneratorGenerate": "Gerar", "autoGeneratorHintText": "Diga-nos o que você deseja gerar por IA ...", "autoGeneratorCantGetOpenAIKey": "Não foi possível obter a chave da AI", "autoGeneratorRewrite": "Reescrever", "smartEdit": "Assistentes de IA", "aI": "AI", "smartEditFixSpelling": "Corrigir ortografia", "warning": "⚠️ As respostas da IA podem ser imprecisas ou enganosas.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "melhorar a escrita", "smartEditMakeLonger": "Faça mais", "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do AI", "smartEditCouldNotFetchKey": "Não foi possível obter a chave AI", "smartEditDisabled": "Conecte AI em Configurações", "discardResponse": "Deseja descartar as respostas de IA?", "createInlineMathEquation": "Criar equação", "fonts": "Fontes", "insertDate": "Inserir data", "emoji": "Emoji", "toggleList": "Alternar lista", "quoteList": "Lista de citações", "numberedList": "Lista numerada", "bulletedList": "Lista com marcadores", "todoList": "Lista de afazeres", "callout": "Destacar", "cover": { "changeCover": "Mudar capa", "colors": "cores", "images": "Imagens", "clearAll": "Limpar tudo", "abstract": "Abstrato", "addCover": "Adicionar Capa", "addLocalImage": "Adicionar imagem local", "invalidImageUrl": "URL de imagem inválido", "failedToAddImageToGallery": "Falha ao adicionar imagem à galeria", "enterImageUrl": "Insira o URL da imagem", "add": "Adicionar", "back": "Voltar", "saveToGallery": "Salvar na galeria", "removeIcon": "Remover ícone", "pasteImageUrl": "Colar URL da imagem", "or": "OU", "pickFromFiles": "Escolha entre os arquivos", "couldNotFetchImage": "Não foi possível obter a imagem", "imageSavingFailed": "Falha ao salvar a imagem", "addIcon": "Adicionar ícone", "changeIcon": "Alterar ícone", "coverRemoveAlert": "Ele será removido da capa após ser excluído.", "alertDialogConfirmation": "Você tem certeza que quer continuar?" }, "mathEquation": { "name": "Equação Matemática", "addMathEquation": "Adicionar equação matemática", "editMathEquation": "Editar equação matemática" }, "optionAction": { "click": "Clique", "toOpenMenu": " para abrir o menu", "delete": "Excluir", "duplicate": "Duplicado", "turnInto": "Transformar-se em", "moveUp": "Subir", "moveDown": "Mover para baixo", "color": "Cor", "align": "Alinhar", "left": "Esquerda", "center": "Centro", "right": "Certo", "defaultColor": "Padrão" }, "image": { "addAnImage": "Adicione uma imagem", "copiedToPasteBoard": "O link da imagem foi copiado para a área de transferência" }, "urlPreview": { "copiedToPasteBoard": "O link foi copiado para a área de transferência" }, "outline": { "addHeadingToCreateOutline": "Adicione títulos para criar um sumário." }, "table": { "addAfter": "Adicionar depois", "addBefore": "Adicionar antes", "delete": "Excluir", "clear": "Limpar conteúdo", "duplicate": "Duplicado", "bgColor": "Cor de fundo" }, "contextMenu": { "copy": "Cópia", "cut": "Corte", "paste": "Colar" }, "action": "Ações", "database": { "selectDataSource": "Selecione a fonte de dados", "noDataSource": "Nenhuma fonte de dados", "selectADataSource": "Selecione uma fonte de dados", "toContinue": "continuar", "newDatabase": "Novo banco de dados", "linkToDatabase": "Link para banco de dados" }, "autoCompletionMenuItemName": "Preenchimento Automático" }, "textBlock": { "placeholder": "Digite '/' para comandos" }, "title": { "placeholder": "Sem título" }, "imageBlock": { "placeholder": "Clique para adicionar imagem", "upload": { "label": "Carregar", "placeholder": "Clique para carregar a imagem" }, "url": { "label": "imagem URL", "placeholder": "Insira o URL da imagem" }, "ai": { "label": "Gerar imagem da AI", "placeholder": "Insira o prompt para AI gerar imagem" }, "stability_ai": { "label": "Gerar imagem da Stability AI", "placeholder": "Insira o prompt para Stability AI gerar imagem" }, "support": "O limite de tamanho da imagem é de 5 MB. Formatos suportados: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "imagem inválida", "invalidImageSize": "O tamanho da imagem deve ser inferior a 5 MB", "invalidImageFormat": "O formato da imagem não é suportado. Formatos suportados: JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL de imagem inválido" }, "embedLink": { "label": "Incorporar link", "placeholder": "Cole ou digite um link de imagem" }, "unsplash": { "label": "Remover respingo" }, "searchForAnImage": "Procurar uma imagem", "pleaseInputYourOpenAIKey": "insira sua chave AI na página configurações", "saveImageToGallery": "Salvar imagem", "failedToAddImageToGallery": "Falha ao adicionar imagem à galeria", "successToAddImageToGallery": "Imagem adicionada à galeria com sucesso", "unableToLoadImage": "Não foi possível carregar a imagem", "pleaseInputYourStabilityAIKey": "insira sua chave Stability AI na página Configurações" }, "codeBlock": { "language": { "label": "Linguagem", "placeholder": "Selecione o idioma" } }, "inlineLink": { "placeholder": "Cole ou digite um link", "openInNewTab": "Abrir em nova aba", "copyLink": "Cópia do link", "removeLink": "Remover link", "url": { "label": "URL do link", "placeholder": "Insira o URL do link" }, "title": { "label": "Título do link", "placeholder": "Insira o título do link" } }, "mention": { "placeholder": "Mencione uma pessoa, uma página ou uma data...", "page": { "label": "Link para a página", "tooltip": "Clique para abrir a página" } }, "toolbar": { "resetToDefaultFont": "Restaurar ao padrão" }, "errorBlock": { "theBlockIsNotSupported": "A versão atual não suporta este bloco.", "blockContentHasBeenCopied": "O conteúdo do bloco foi copiado." } }, "board": { "column": { "createNewCard": "Novo", "renameGroupTooltip": "Pressione para renomear o grupo", "createNewColumn": "Adicionar um novo grupo", "addToColumnTopTooltip": "Adicione um novo cartão na parte superior", "addToColumnBottomTooltip": "Adicione um novo cartão na parte inferior", "renameColumn": "Renomear", "hideColumn": "Esconder", "newGroup": "Novo grupo", "deleteColumn": "Excluir", "deleteColumnConfirmation": "Isso excluirá este grupo e todos os cartões nele contidos. Você tem certeza que quer continuar?" }, "hiddenGroupSection": { "sectionTitle": "Grupos ocultos", "collapseTooltip": "Ocultar os grupos ocultos", "expandTooltip": "Ver os grupos ocultos" }, "cardDetail": "Detalhe do cartão", "cardActions": "Ações de cartão", "cardDuplicated": "O cartão foi duplicado", "cardDeleted": "O cartão foi excluído", "showOnCard": "Mostrar detalhes no cartão", "setting": "Configurações", "propertyName": "Nome da propriedade", "menuName": "Quadro", "showUngrouped": "Mostrar itens desagrupados", "ungroupedButtonText": "Desagrupado", "ungroupedButtonTooltip": "Contém cartões que não pertencem a nenhum grupo", "ungroupedItemsTitle": "Clique para adicionar ao quadro", "groupBy": "Agrupar por", "referencedBoardPrefix": "Vista de", "notesTooltip": "Notas dentro", "mobile": { "editURL": "Editar URL", "showGroup": "Mostrar grupo", "showGroupContent": "Tem certeza de que deseja mostrar este grupo no quadro?", "failedToLoad": "Falha ao carregar a visualização do quadro" } }, "calendar": { "menuName": "Calendário", "defaultNewCalendarTitle": "Sem título", "newEventButtonTooltip": "Adicionar um novo evento", "navigation": { "today": "Hoje", "jumpToday": "Pular para hoje", "previousMonth": "Mês anterior", "nextMonth": "Próximo mês" }, "settings": { "showWeekNumbers": "Mostrar números da semana", "showWeekends": "Mostrar fins de semana", "firstDayOfWeek": "Comece a semana em", "layoutDateField": "Calendário de layout por", "changeLayoutDateField": "Alterar campo de layout", "noDateTitle": "sem data", "unscheduledEventsTitle": "Eventos não agendados", "clickToAdd": "Clique para adicionar ao calendário", "name": "Layout do calendário", "noDateHint": "Eventos não agendados aparecerão aqui" }, "referencedCalendarPrefix": "Vista de", "quickJumpYear": "Ir para" }, "errorDialog": { "title": "Erro do @:appName", "howToFixFallback": "Lamentamos o inconveniente! Envie um problema em nossa página do GitHub que descreva seu erro.", "github": "Ver no GitHub" }, "search": { "label": "Procurar", "placeholder": { "actions": "Pesquisar ações..." } }, "message": { "copy": { "success": "Copiado!", "fail": "Não é possível copiar" } }, "unSupportBlock": "A versão atual não suporta este bloco.", "views": { "deleteContentTitle": "Tem certeza de que deseja excluir {pageType}?", "deleteContentCaption": "se você excluir este {pageType}, poderá restaurá-lo da lixeira." }, "colors": { "custom": "Personalizado", "default": "Padrão", "red": "Vermelho", "orange": "Laranja", "yellow": "Amarelo", "green": "Verde", "blue": "Azul", "purple": "Roxo", "pink": "Rosa", "brown": "Marrom", "gray": "Cinza" }, "emoji": { "emojiTab": "Emoji", "search": "Buscar emoji", "noRecent": "Nenhum emoji recente", "noEmojiFound": "Nenhum emoji encontrado", "filter": "Filtro", "random": "Aleatório", "selectSkinTone": "Selecione o tom do tema", "remove": "Remover emoji", "categories": { "smileys": "Smileys e Emoções", "people": "Pessoas e Corpo", "animals": "Animais e Natureza", "food": "Comida e bebida", "activities": "Atividades", "places": "Viagens e lugares", "objects": "Objetos", "symbols": "Símbolos", "flags": "Bandeiras", "nature": "Natureza", "frequentlyUsed": "Usado frequentemente" }, "skinTone": { "default": "Padrão", "light": "Luz", "mediumLight": "Médio-leve", "medium": "Médio", "mediumDark": "Médio-escuro", "dark": "Escuro" } }, "inlineActions": { "noResults": "Nenhum resultado", "pageReference": "Referenciar pagina ", "docReference": "Referenciar documento", "boardReference": "Referenciar quadro", "calReference": "Referenciar calendário ", "gridReference": "Referenciar grade", "date": "Data", "reminder": { "groupTitle": "Lembrete", "shortKeyword": "lembrar" } }, "datePicker": { "dateTimeFormatTooltip": "Altere o formato de data e hora nas configurações", "dateFormat": "Formato de data", "includeTime": "Incluir tempo", "isRange": "Data final", "timeFormat": "Formato de hora", "clearDate": "Limpar data" }, "relativeDates": { "yesterday": "Ontem", "today": "Hoje", "tomorrow": "Amanhã", "oneWeek": "1 semana" }, "notificationHub": { "title": "Notificações", "mobile": { "title": "Atualizações" }, "emptyTitle": "A par de tudo!", "emptyBody": "Nenhuma notificação ou ação pendente. Aproveite a calmaria.", "tabs": { "inbox": "Caixa de entrada", "upcoming": "Por vir" }, "actions": { "markAllRead": "Marcar tudo como lido", "showAll": "Todos", "showUnreads": "Não lida" }, "filters": { "ascending": "Ascendente", "descending": "Descendente", "groupByDate": "Agrupar por data", "showUnreadsOnly": "Mostrar apenas os não lidos", "resetToDefault": "Restaurar ao padrão" } }, "reminderNotification": { "title": "Lembrete", "message": "Lembre-se de marcar isso antes que você esqueça!", "tooltipDelete": "Excluir", "tooltipMarkRead": "Marcar como lido", "tooltipMarkUnread": "Marcar como não lido" }, "findAndReplace": { "find": "Localizar", "previousMatch": "Localizar anterior", "nextMatch": "Localizar proxímo", "close": "Fechar", "replace": "Substituir", "replaceAll": "Substituir tudo", "noResult": "Nenhum resultado", "caseSensitive": "Sensível a maiúsculas e minúsculas" }, "error": { "weAreSorry": "Nós lamentamos", "loadingViewError": "Estamos tendo problemas para carregar esta visualização. Verifique sua conexão com a Internet, atualize o aplicativo e não hesite em entrar em contato com a equipe se o problema persistir." }, "editor": { "bold": "Negrito", "bulletedList": "Lista com marcadores", "checkbox": "Caixa de seleção", "embedCode": "Incorporar Código", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Destacar", "color": "Cor", "image": "Imagem", "italic": "Itálico", "link": "Link", "numberedList": "Lista Numerada", "quote": "Citar", "strikethrough": "Tachado", "text": "Texto", "underline": "Sublinhado", "fontColorDefault": "Padrão", "fontColorGray": "Cinza", "fontColorBrown": "Marrom", "fontColorOrange": "Laranja", "fontColorYellow": "Amarelo", "fontColorGreen": "Verde", "fontColorBlue": "Azul", "fontColorPurple": "Roxo", "fontColorPink": "Rosa", "fontColorRed": "Vermelho", "backgroundColorDefault": "Plano de fundo padrão", "backgroundColorGray": "Fundo cinza", "backgroundColorBrown": "Fundo marrom", "backgroundColorOrange": "Fundo laranja", "backgroundColorYellow": "Fundo amarelo", "backgroundColorGreen": "Fundo verde", "backgroundColorBlue": "Fundo azul", "backgroundColorPurple": "Fundo roxo", "backgroundColorPink": "Fundo rosa", "backgroundColorRed": "Fundo vermelho", "done": "Feito", "cancel": "Cancelar", "tint1": "Matiz 1", "tint2": "Matiz 2", "tint3": "Matiz 3", "tint4": "Matiz 4", "tint5": "Matiz 5", "tint6": "Matiz 6", "tint7": "Matiz 7", "tint8": "Matiz 8", "tint9": "Matiz 9", "lightLightTint1": "Roxo", "lightLightTint2": "Rosa", "lightLightTint3": "Rosa Claro", "lightLightTint4": "Laranja", "lightLightTint5": "Amarelo", "lightLightTint6": "Lima", "lightLightTint7": "Verde", "lightLightTint8": "Aqua", "lightLightTint9": "Azul", "urlHint": "URL", "mobileHeading1": "Cabeçallho 1", "mobileHeading2": "Título 2", "mobileHeading3": "Título 3", "textColor": "Cor do texto", "backgroundColor": "Cor de fundo", "addYourLink": "Adicionar seu link", "openLink": "Abrir link", "copyLink": "Copiar link", "removeLink": "Remover link", "editLink": "Editar link", "linkText": "Texto", "linkTextHint": "Por favor insira o texto", "linkAddressHint": "Por favor insira o URL", "highlightColor": "Cor de destaque", "clearHighlightColor": "Cor de destaque clara", "customColor": "Cor customizada", "hexValue": "Valor hexadecimal", "opacity": "Opacidade", "resetToDefaultColor": "Redefinir para a cor padrão", "ltr": "LTR", "rtl": "RTL", "auto": "Auto", "cut": "Cortar", "copy": "Copiar", "paste": "Colar", "find": "Encontrar", "previousMatch": "Localizar anterior", "nextMatch": "Localizar próxima", "closeFind": "Fechar", "replace": "Substituir", "replaceAll": "Substituir tudo", "regex": "Regex", "caseSensitive": "Maiúsculas e minúsculas", "uploadImage": "Enviar Imagem", "urlImage": "URL da imagem", "incorrectLink": "Link incorreto", "upload": "Carregar", "chooseImage": "Escolha uma imagem", "loading": "Carregando", "imageLoadFailed": "Não foi possível carregar a imagem", "divider": "Divisor", "table": "Tabela", "colAddBefore": "Acrescentar antes", "rowAddBefore": "Acrescentar antes", "colAddAfter": "Adicionar após", "rowAddAfter": "Adicionar após", "colRemove": "Remover", "rowRemove": "Remover", "colDuplicate": "Duplicado", "rowDuplicate": "Duplicado", "colClear": "Limpar conteúdo", "rowClear": "Limpar conteúdo", "slashPlaceHolder": "Digite '/' para inserir um bloco ou comece a digitar", "typeSomething": "Digite algo..." }, "favorite": { "noFavorite": "Nenhuma página favorita", "noFavoriteHintText": "Deslize a página para a esquerda para adicioná-la aos seus favoritos" }, "cardDetails": { "notesPlaceholder": "Digite um / para inserir um bloco ou comece a digitar" }, "blockPlaceholders": { "todoList": "A fazer", "bulletList": "Lista", "numberList": "Lista", "quote": "Citar", "heading": "Cabeçalho {}" }, "titleBar": { "pageIcon": "Ícone de página", "language": "Idioma", "font": "Fonte", "actions": "Ações", "date": "Data", "addField": "Adicionar campo", "userIcon": "Ícone do usuário" } } ================================================ FILE: frontend/resources/translations/pt-PT.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Eu", "welcomeText": "Bem vindo ao @:appName", "githubStarText": "Dar uma estrela no Github", "subscribeNewsletterText": "Inscreve-te no Newsletter", "letsGoButtonText": "Bora", "title": "Título", "youCanAlso": "Você também pode", "and": "e", "blockActions": { "addBelowTooltip": "Clique para adicionar abaixo", "addAboveCmd": "Alt+clique", "addAboveMacCmd": "Opção+clique", "addAboveTooltip": "para adicionar acima", "dragTooltip": "Arraste para mover", "openMenuTooltip": "Clique para abrir o menu" }, "signUp": { "buttonText": "Inscreve-te", "title": "Inscreve-te ao @:appName", "getStartedText": "Começar", "emptyPasswordError": "A palavra-passe não pode estar em branco.", "repeatPasswordEmptyError": "Confirmar a palavra-passe não pode estar em branco.", "unmatchedPasswordError": "As palavras-passes não coincidem.", "alreadyHaveAnAccount": "Já possuis uma conta?", "emailHint": "Email", "passwordHint": "Password", "repeatPasswordHint": "Confirma a tua password", "signUpWith": "Inscreve-te com:" }, "signIn": { "loginTitle": "Entre no @:appName", "loginButtonText": "Login", "loginStartWithAnonymous": "Começar com uma sessão anónima", "continueAnonymousUser": "Continuar com uma sessão anónima", "buttonText": "Entre", "forgotPassword": "Esqueceste-te da tua palavra-passe?", "emailHint": "Email", "passwordHint": "Palavra-passe", "dontHaveAnAccount": "Não possuis uma conta?", "repeatPasswordEmptyError": "Confirmar a palavra-passe não pode estar em branco.", "unmatchedPasswordError": "As palavras-passes não conferem.", "syncPromptMessage": "A sincronização dos dados pode demorar um pouco. Por favor não feche esta página", "or": "OU", "signInWith": "Entrar com:", "LogInWithGoogle": "Iniciar sessão com o Google", "LogInWithGithub": "Iniciar sessão com o Github", "LogInWithDiscord": "Iniciar sessão com o Discord", "loginAsGuestButtonText": "Iniciar" }, "workspace": { "chooseWorkspace": "Escolha o seu espaço de trabalho", "create": "Cria um ambiente de trabalho", "reset": "Reiniciar o ambiente de trabalho", "resetWorkspacePrompt": "Reinciar do espaço de trabalho excluirá todas as páginas e dados contidos. Tem certeza de que deseja reiniciar o espaço de trabalho? Alternativamente, podes entrar em contato com a equipe de suporte para restaurar o espaço de trabalho", "hint": "ambiente de trabalho", "notFoundError": "Ambiente de trabalho não encontrada", "failedToLoad": "Algo correu mal! Falha ao carregar o espaço de trabalho. Tente fechar qualquer instância aberta do @:appName e tente novamente.", "errorActions": { "reportIssue": "Relatar problema", "reachOut": "Entre em contacto no Discord" } }, "shareAction": { "buttonText": "Partilhar", "workInProgress": "Em breve", "markdown": "Markdown", "csv": "CSV", "copyLink": "Copiar o link" }, "moreAction": { "small": "pequeno", "medium": "médio", "large": "grande", "fontSize": "Tamanho da fonte", "import": "Importar", "moreOptions": "Mais opções" }, "importPanel": { "textAndMarkdown": "Texto e Remarcação", "documentFromV010": "Documento de v0.1.0", "databaseFromV010": "Banco de dados de v0.1.0", "csv": "CSV", "database": "Base de dados" }, "disclosureAction": { "rename": "Renomear", "delete": "Apagar", "duplicate": "Duplicar", "unfavorite": "Remover dos favoritos", "favorite": "Adicionar aos favoritos", "openNewTab": "Abrir em uma nova guia", "moveTo": "Mover para", "addToFavorites": "Adicionar aos favoritos", "copyLink": "Copiar o lin" }, "blankPageTitle": "Página em branco", "newPageText": "Nova página", "newDocumentText": "Novo Documento", "newGridText": "Nova grelha", "newCalendarText": "Novo calendário", "newBoardText": "Novo quadro", "trash": { "text": "Lixo", "restoreAll": "Restaurar todos", "deleteAll": "Apagar todos", "pageHeader": { "fileName": "Nome do ficheiro", "lastModified": "Última modificação", "created": "Criado" }, "confirmDeleteAll": { "title": "Tem certeza de que deseja excluir todas as páginas da Lixeira?", "caption": "Essa ação não pode ser desfeita." }, "confirmRestoreAll": { "title": "Tem certeza de que deseja restaurar todas as páginas da Lixeira?", "caption": "Essa ação não pode ser desfeita." } }, "deletePagePrompt": { "text": "Esta página está no lixo", "restore": "Restaurar a página", "deletePermanent": "Apagar permanentemente" }, "dialogCreatePageNameHint": "Nome da página", "questionBubble": { "shortcuts": "Atalhos", "whatsNew": "O que há de novo?", "markdown": "Remarcação", "debug": { "name": "Informação de depuração", "success": "Copiar informação de depuração para o clipboard!", "fail": "Falha em copiar a informação de depuração para o clipboard" }, "feedback": "Opinião", "help": "Ajuda & Suporte" }, "menuAppHeader": { "moreButtonToolTip": "Remover, renomear e muito mais...", "addPageTooltip": "Adiciona uma nova página.", "defaultNewPageName": "Sem título", "renameDialog": "Renomear" }, "toolbar": { "undo": "Desfazer", "redo": "Refazer", "bold": "Negrito", "italic": "Itálico", "underline": "Sublinhado", "strike": "Riscado", "numList": "Lista numerada", "bulletList": "Lista com marcadores", "checkList": "Lista de verificação", "inlineCode": "Embutir código", "quote": "Citação em bloco", "header": "Cabeçalho", "highlight": "Realçar", "color": "Cor", "addLink": "Adicionar link", "link": "Link" }, "tooltip": { "lightMode": "Mudar para o modo Claro.", "darkMode": "Mudar para o modo Escuro.", "openAsPage": "Abrir como uma página", "addNewRow": "Adicionar uma nova linha", "openMenu": "Clique para abrir o menu", "dragRow": "Pressione e segure para reordenar a linha", "viewDataBase": "Ver banco de dados", "referencePage": "Este {name} é referenciado", "addBlockBelow": "Adicione um bloco abaixo" }, "sideBar": { "closeSidebar": "Close sidebar", "openSidebar": "Open sidebar", "personal": "Pessoal", "favorites": "Favoritos", "clickToHidePersonal": "Clique para ocultar a seção pessoal", "clickToHideFavorites": "Clique para ocultar a seção favorita", "addAPage": "Adicionar uma página" }, "notifications": { "export": { "markdown": "Nota exportada para remarcação", "path": "Documentos/fluidos" } }, "contactsPage": { "title": "Conctatos", "whatsHappening": "O que está a acontecer nesta semana?", "addContact": "Adicionar um conctato", "editContact": "Editar um conctato" }, "button": { "ok": "OK", "done": "Feito", "cancel": "Cancelar", "signIn": "Entrar", "signOut": "Sair", "complete": "Completar", "save": "Guardar", "generate": "Gerar", "esc": "ESC", "keep": "Manter", "tryAgain": "Tente novamente", "discard": "Descartar", "replace": "Substituir", "insertBelow": "Inserir Abaixo", "upload": "Carregar", "edit": "Editar", "delete": "Excluir", "duplicate": "Duplicado", "putback": "Por de volta" }, "label": { "welcome": "Bem vindo!", "firstName": "Nome", "middleName": "Nome do Meio", "lastName": "Apelido", "stepX": "Passo {X}" }, "oAuth": { "err": { "failedTitle": "Erro ao conectar à sua conta.", "failedMsg": "Verifica se concluiste o processo de login no teu navegador." }, "google": { "title": "GOOGLE SIGN-IN", "instruction1": "Para importar os teus Conctatos do Google, tens de autorizar esta aplicação usando o teu navegador web.", "instruction2": "Copia este código para a tua área de transferências clicando no ícone ou selecionando o texto:", "instruction3": "Navega até o link a seguir no seu navegador e digite o código acima:", "instruction4": "Clica no botão abaixo ao concluir a inscrição:" } }, "settings": { "title": "Definições", "menu": { "appearance": "Aparência", "language": "Idioma", "user": "Do utilizador", "files": "arquivos", "notifications": "Notificações", "open": "Abrir as Definições", "logout": "Sair", "logoutPrompt": "Tem certeza de que deseja sair?", "selfEncryptionLogoutPrompt": "Tem certeza que deseja sair? Certifique-se de ter copiado o código de encriptação", "syncSetting": "Configuração da sincronização", "enableSync": "Ativar sincronização", "enableEncrypt": "Encriptar dados dados", "enableEncryptPrompt": "Ative a encriptação para proteger seus dados com esta palavra-chave. Armazene-o com segurança; uma vez ativado, não pode ser desativado. Se perdidos, seus dados se tornarão irrecuperáveis. Clique para copiar", "inputEncryptPrompt": "Por favor, insira a sua palavra-chave de encriptação para", "clickToCopySecret": "Clique para copiar a palvra-chave", "inputTextFieldHint": "A sua palavra-chave", "historicalUserList": "Histórico de login do utilizador", "historicalUserListTooltip": "Esta lista mostrs suas contas anónimas. Pode clicar numa conta para ver os detalhes. Contas anónimas são criadas clicando no botão ‘Começar’", "openHistoricalUser": "Clique para abrir a conta anónima" }, "notifications": { "enableNotifications": { "label": "Ativar notificações", "hint": "Desligue para impedir que notificações locais apareçam." } }, "appearance": { "resetSetting": "Reiniciar esta configuração", "fontFamily": { "label": "Família de fontes", "search": "Procurar" }, "themeMode": { "label": "Modo Tema", "light": "Modo claro", "dark": "Modo Escuro", "system": "Adaptar ao sistema" }, "layoutDirection": { "label": "Direção da disposição", "hint": "Controle o fluxo do conteúdo no ecrã, da esquerda para a direita ou da direita para a esquerda.", "ltr": "EPD", "rtl": "DPE" }, "textDirection": { "label": "Direção de texto padrão", "hint": "Especifique se o texto deve começar da esquerda ou da direita como padrão.", "ltr": "EPD", "rtl": "DPE", "auto": "AUTO", "fallback": "Igual à direção da disposição" }, "themeUpload": { "button": "Carregar", "uploadTheme": "Carregar tema", "description": "Carregue seu próprio tema @:appName usando o botão abaixo.", "loading": "Aguarde enquanto validamos e carregamos seu tema...", "uploadSuccess": "Seu tema foi carregado com sucesso", "deletionFailure": "Falha ao excluir o tema. Tente excluí-lo manualmente.", "filePickerDialogTitle": "Escolha um arquivo .flowy_plugin", "urlUploadFailure": "Falha ao abrir url: {}", "failure": "O tema que foi carregado tinha um formato inválido." }, "theme": "Tema", "builtInsLabel": "Temas integrados", "pluginsLabel": "Plugins", "dateFormat": { "label": "Formato de data", "local": "Local", "us": "EUA", "iso": "ISO", "friendly": "Amigável", "dmy": "D/M/A" }, "timeFormat": { "label": "Formato de hora", "twelveHour": "Doze horas", "twentyFourHour": "Vinte e quatro horas" }, "showNamingDialogWhenCreatingPage": "Mostrar caixa de diálogo de nomenclatura ao criar uma página", "lightLabel": "Modo Claro", "darkLabel": "Modo Escuro" }, "files": { "copy": "cópia de", "defaultLocation": "Leia arquivos e local de armazenamento de dados", "exportData": "Exporte seus dados", "doubleTapToCopy": "Toque duas vezes para copiar o caminho", "restoreLocation": "Restaurar para o caminho padrão do @:appName", "customizeLocation": "Abra outra pasta", "restartApp": "Reinicie o aplicativo para que as alterações entrem em vigor.", "exportDatabase": "Exportar banco de dados", "selectFiles": "Selecione os arquivos que precisam ser exportados", "selectAll": "Selecionar tudo", "deselectAll": "Desmarcar todos", "createNewFolder": "Criar uma nova pasta", "createNewFolderDesc": "Diga-nos onde pretende armazenar os seus dados", "defineWhereYourDataIsStored": "Defina onde seus dados são armazenados", "open": "Abrir", "openFolder": "Abra uma pasta existente", "openFolderDesc": "Leia e grave-o em sua pasta @:appName existente", "folderHintText": "nome da pasta", "location": "Criando uma nova pasta", "locationDesc": "Escolha um nome para sua pasta de dados do @:appName", "browser": "Navegar", "create": "Criar", "set": "Definir", "folderPath": "Caminho para armazenar sua pasta", "locationCannotBeEmpty": "O caminho não pode estar vazio", "pathCopiedSnackbar": "Caminho de armazenamento de arquivo copiado para a área de transferência!", "changeLocationTooltips": "Alterar o diretório de dados", "change": "Mudar", "openLocationTooltips": "Abra outro diretório de dados", "openCurrentDataFolder": "Abra o diretório de dados atual", "recoverLocationTooltips": "Redefinir para o diretório de dados padrão do @:appName", "exportFileSuccess": "Exportar arquivo com sucesso!", "exportFileFail": "Falha na exportação do arquivo!", "export": "Exportar" }, "user": { "name": "Nome", "email": "E-mail", "tooltipSelectIcon": "Selecione o ícone", "selectAnIcon": "Selecione um ícone", "pleaseInputYourOpenAIKey": "por favor insira sua chave AI", "clickToLogout": "Clique para fazer logout", "pleaseInputYourStabilityAIKey": "por favor, insira a sua chave Stability AI" }, "shortcuts": { "shortcutsLabel": "Atalhos", "command": "Comando", "keyBinding": "Atalhos de teclado", "addNewCommand": "Adicionar novo comando", "updateShortcutStep": "Pressione a combinação de teclas desejada e pressione ENTER", "shortcutIsAlreadyUsed": "Este atalho já é usado para: {conflict}", "resetToDefault": "Redefinir para atalhos de teclado padrão", "couldNotLoadErrorMsg": "Não foi possível carregar os atalhos. Tente novamente", "couldNotSaveErrorMsg": "Não foi possível salvar os atalhos. Tente novamente" } }, "grid": { "deleteView": "Tem certeza de que deseja excluir esta visualização?", "createView": "Novo", "title": { "placeholder": "Sem título" }, "settings": { "filter": "Filtro", "sort": "Organizar", "sortBy": "Ordenar por", "properties": "Propriedades", "reorderPropertiesTooltip": "Arraste para reordenar as propriedades", "group": "Grupo", "addFilter": "Adicionar filtro", "deleteFilter": "Excluir filtro", "filterBy": "Filtrar por...", "typeAValue": "Digite um valor...", "layout": "Disposição", "databaseLayout": "Disposição" }, "textFilter": { "contains": "contém", "doesNotContain": "Não contém", "endsWith": "Termina com", "startWith": "Começa com", "is": "É", "isNot": "não é", "isEmpty": "Está vazia", "isNotEmpty": "Não está vazio", "choicechipPrefix": { "isNot": "Não", "startWith": "Começa com", "endWith": "Termina com", "isEmpty": "está vazia", "isNotEmpty": "não está vazio" } }, "checkboxFilter": { "isChecked": "Verificado", "isUnchecked": "desmarcado", "choicechipPrefix": { "is": "é" } }, "checklistFilter": { "isComplete": "está completo", "isIncomplted": "está incompleto" }, "selectOptionFilter": { "is": "É", "isNot": "não é", "contains": "contém", "doesNotContain": "Não contém", "isEmpty": "Está vazia", "isNotEmpty": "Não está vazio" }, "field": { "hide": "Esconder", "show": "Mostrar", "insertLeft": "Inserir Esquerda", "insertRight": "Inserir à Direita", "duplicate": "Duplicado", "delete": "Excluir", "textFieldName": "Texto", "checkboxFieldName": "Caixa de seleção", "dateFieldName": "Data", "updatedAtFieldName": "Hora da última modificação", "createdAtFieldName": "hora criada", "numberFieldName": "Números", "singleSelectFieldName": "Selecione", "multiSelectFieldName": "Seleção múltipla", "urlFieldName": "URL", "checklistFieldName": "Lista de controle", "numberFormat": "Formato numérico", "dateFormat": "Formato de data", "includeTime": "Incluir tempo", "isRange": "Data de fim", "dateFormatFriendly": "Mês dia ano", "dateFormatISO": "Ano mês dia", "dateFormatLocal": "Mês dia ano", "dateFormatUS": "Ano mês dia", "dateFormatDayMonthYear": "Dia mês ano", "timeFormat": "Formato de hora", "invalidTimeFormat": "Formato Inválido", "timeFormatTwelveHour": "12 horas", "timeFormatTwentyFourHour": "24 horas", "clearDate": "Limpar data", "addSelectOption": "Adicionar uma opção", "optionTitle": "Opções", "addOption": "Adicionar opção", "editProperty": "Editar propriedade", "newProperty": "Nova propriedade", "deleteFieldPromptMessage": "Tem certeza? Esta propriedade será excluída", "newColumn": "Nova Coluna" }, "rowPage": { "newField": "Adicionar um novo campo", "fieldDragElementTooltip": "Clique para abrir o menu", "showHiddenFields": { "one": "Mostrar campo oculto {}", "many": "Mostrar campos ocultos {}", "other": "Mostrar campos ocultos {}" }, "hideHiddenFields": { "one": "Ocultar campo oculto {}", "many": "Ocultar campos ocultos {}", "other": "Ocultar campos ocultos {}" } }, "sort": { "ascending": "Ascendente", "descending": "descendente", "deleteAllSorts": "Apagar todas as ordenações", "addSort": "Adicionar classificação", "deleteSort": "Excluir classificação" }, "row": { "duplicate": "Duplicado", "delete": "Excluir", "titlePlaceholder": "Sem título", "textPlaceholder": "Vazio", "copyProperty": "Propriedade copiada para a área de transferência", "count": "Contar", "newRow": "Nova linha", "action": "Ação", "add": "Clique em adicionar abaixo", "drag": "Arraste para mover" }, "selectOption": { "create": "Criar", "purpleColor": "Roxo", "pinkColor": "Rosa", "lightPinkColor": "Luz rosa", "orangeColor": "Laranja", "yellowColor": "Amarelo", "limeColor": "Lima", "greenColor": "Verde", "aquaColor": "Aqua", "blueColor": "Azul", "deleteTag": "Excluir etiqueta", "colorPanelTitle": "cores", "panelTitle": "Selecione uma opção ou crie uma", "searchOption": "Pesquise uma opção", "searchOrCreateOption": "Pesquise ou crie uma opção...", "createNew": "Crie um novo", "orSelectOne": "Ou selecione uma opção" }, "checklist": { "taskHint": "Descrição da tarefa", "addNew": "Adicionar um item", "submitNewTask": "Criar" }, "menuName": "Grade", "referencedGridPrefix": "Vista de" }, "document": { "menuName": "Documento", "date": { "timeHintTextInTwelveHour": "13:00", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Selecione um quadro para vincular", "createANewBoard": "Criar um novo Conselho" }, "grid": { "selectAGridToLinkTo": "Selecione uma grade para vincular", "createANewGrid": "Criar uma nova grade" }, "calendar": { "selectACalendarToLinkTo": "Selecione um calendário para vincular", "createANewCalendar": "Criar um novo calendário" } }, "selectionMenu": { "outline": "Contorno", "codeBlock": "Bloco de código" }, "plugins": { "referencedBoard": "Conselho Referenciado", "referencedGrid": "grade referenciada", "referencedCalendar": "calendário referenciado", "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Peça à IA para escrever qualquer coisa...", "autoGeneratorLearnMore": "Saber mais", "autoGeneratorGenerate": "Gerar", "autoGeneratorHintText": "Pergunte ao AI...", "autoGeneratorCantGetOpenAIKey": "Não é possível obter a chave AI", "autoGeneratorRewrite": "Reescrever", "smartEdit": "Assistentes de IA", "aI": "AI", "smartEditFixSpelling": "corrigir ortografia", "warning": "⚠️ As respostas da IA podem ser imprecisas ou enganosas.", "smartEditSummarize": "Resumir", "smartEditImproveWriting": "melhorar a escrita", "smartEditMakeLonger": "Faça mais", "smartEditCouldNotFetchResult": "Não foi possível obter o resultado do AI", "smartEditCouldNotFetchKey": "Não foi possível obter a chave AI", "smartEditDisabled": "Conecte AI em Configurações", "discardResponse": "Deseja descartar as respostas de IA?", "createInlineMathEquation": "Criar equação", "toggleList": "Alternar lista", "cover": { "changeCover": "Mudar capa", "colors": "cores", "images": "Imagens", "clearAll": "Limpar tudo", "abstract": "Abstrato", "addCover": "Adicionar capa", "addLocalImage": "Adicionar imagem local", "invalidImageUrl": "URL de imagem inválido", "failedToAddImageToGallery": "Falha ao adicionar imagem à galeria", "enterImageUrl": "Insira o URL da imagem", "add": "Adicionar", "back": "Voltar", "saveToGallery": "Salvar na galeria", "removeIcon": "Remover ícone", "pasteImageUrl": "Colar URL da imagem", "or": "OU", "pickFromFiles": "Escolha entre os arquivos", "couldNotFetchImage": "Não foi possível obter a imagem", "imageSavingFailed": "Falha ao salvar a imagem", "addIcon": "Adicionar ícone", "coverRemoveAlert": "Ele será removido da capa após ser excluído.", "alertDialogConfirmation": "Você tem certeza que quer continuar?" }, "mathEquation": { "addMathEquation": "Adicionar equação matemática", "editMathEquation": "Editar equação matemática" }, "optionAction": { "click": "Clique", "toOpenMenu": " para abrir o menu", "delete": "Excluir", "duplicate": "Duplicado", "turnInto": "Transformar-se em", "moveUp": "Subir", "moveDown": "Mover para baixo", "color": "Cor", "align": "Alinhar", "left": "Esquerda", "center": "Centro", "right": "Certo", "defaultColor": "Padrão" }, "image": { "addAnImage": "Adicione uma imagem", "copiedToPasteBoard": "O link da imagem foi copiado para a área de transferência" }, "outline": { "addHeadingToCreateOutline": "Adicione títulos para criar um sumário." }, "table": { "addAfter": "Adicionar depois", "addBefore": "Adicionar antes", "delete": "Eliminar", "clear": "Limpar conteúdo", "duplicate": "Duplicado", "bgColor": "Cor de fundo" }, "contextMenu": { "copy": "Cópia", "cut": "Corte", "paste": "Colar" } }, "textBlock": { "placeholder": "Digite '/' para comandos" }, "title": { "placeholder": "Sem título" }, "imageBlock": { "placeholder": "Clique para adicionar imagem", "upload": { "label": "Carregar", "placeholder": "Clique para carregar a imagem" }, "url": { "label": "imagem URL", "placeholder": "Insira o URL da imagem" }, "ai": { "label": "Gerar imagem da AI", "placeholder": "Por favor, insira o comando para a AI gerar a imagem" }, "stability_ai": { "label": "Gerar imagem da Stability AI", "placeholder": "Por favor, insira o comando para a Stability AI gerar a imagem" }, "support": "O limite de tamanho da imagem é de 5 MB. Formatos suportados: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "imagem inválida", "invalidImageSize": "O tamanho da imagem deve ser inferior a 5 MB", "invalidImageFormat": "O formato da imagem não é suportado. Formatos suportados: JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL de imagem inválido" }, "embedLink": { "label": "Incorporar hiperligação", "placeholder": "Cole ou digite uma hiperligação de imagem" }, "searchForAnImage": "Procure uma imagem", "pleaseInputYourOpenAIKey": "por favor, insira a sua chave AI na página Configurações", "pleaseInputYourStabilityAIKey": "por favor, insira a sua chave Stability AI na página Configurações" }, "codeBlock": { "language": { "label": "Linguagem", "placeholder": "Selecione o idioma" } }, "inlineLink": { "placeholder": "Cole ou digite um link", "openInNewTab": "Abrir numa nova aba", "copyLink": "Cópia do link", "removeLink": "Remover link", "url": { "label": "URL do link", "placeholder": "Insira o URL do link" }, "title": { "label": "Título do Link", "placeholder": "Insira o título do link" } }, "mention": { "placeholder": "Mencione uma pessoa, uma página ou uma data...", "page": { "label": "Link para a página", "tooltip": "Clique para abrir a página" } }, "toolbar": { "resetToDefaultFont": "Restaurar para o padrão" }, "errorBlock": { "theBlockIsNotSupported": "A versão atual não suporta este bloco.", "blockContentHasBeenCopied": "O conteúdo do bloco foi copiado." } }, "board": { "column": { "createNewCard": "Novo" }, "menuName": "Quadro", "referencedBoardPrefix": "Vista de", "mobile": { "showGroup": "Mostrar grupo", "showGroupContent": "Tem certeza de que deseja mostrar este grupo no quadro?", "failedToLoad": "Falha ao carregar a visualização do quadro" } }, "calendar": { "menuName": "Calendário", "defaultNewCalendarTitle": "Sem título", "newEventButtonTooltip": "Adicionar um novo evento", "navigation": { "today": "Hoje", "jumpToday": "Ir para hoje", "previousMonth": "Mês anterior", "nextMonth": "Próximo mês" }, "settings": { "showWeekNumbers": "Mostrar números da semana", "showWeekends": "Mostrar fins de semana", "firstDayOfWeek": "Comece a semana em", "layoutDateField": "Calendário de layout por", "noDateTitle": "sem data", "clickToAdd": "Clique para adicionar ao calendário", "name": "Layout do calendário", "noDateHint": "Eventos não agendados aparecerão aqui" }, "referencedCalendarPrefix": "Vista de" }, "errorDialog": { "title": "Erro do @:appName", "howToFixFallback": "Lamentamos o inconveniente! Envie um problema em nossa página do GitHub que descreva seu erro.", "github": "Ver no GitHub" }, "search": { "label": "Procurar", "placeholder": { "actions": "Pesquisar ações..." } }, "message": { "copy": { "success": "Copiado!", "fail": "Não foi possível copiar" } }, "unSupportBlock": "A versão atual não suporta este bloco.", "views": { "deleteContentTitle": "Tem certeza de que deseja excluir {pageType}?", "deleteContentCaption": "se você excluir este {pageType}, poderá restaurá-lo da lixeira." }, "colors": { "custom": "Personalizado", "default": "Padrão", "red": "Vermelho", "orange": "Laranja", "yellow": "Amarelo", "green": "Verde", "blue": "Azul", "purple": "Roxo", "pink": "Rosa", "brown": "Castanho", "gray": "Cinzento" }, "emoji": { "search": "Pesquisar emoji", "noRecent": "Nenhum emoji recente", "noEmojiFound": "Nenhum emoji encontrado", "filter": "Filtro", "random": "Aleatório", "selectSkinTone": "Selecione o tom do tema", "remove": "Remover emoji", "categories": { "smileys": "Smileys e Emoções", "people": "Pessoas e Corpo", "animals": "Animais e Natureza", "food": "Comida e bebida", "activities": "Atividades", "places": "Viagens e lugares", "objects": "Objetos", "symbols": "Símbolos", "flags": "Bandeiras", "nature": "Natureza", "frequentlyUsed": "Usado frequentemente" } }, "inlineActions": { "noResults": "Nenhum resultado", "pageReference": "Referência de página", "date": "Data", "reminder": { "groupTitle": "Lembrete", "shortKeyword": "lembrete" } }, "relativeDates": { "yesterday": "Ontem", "today": "Hoje", "tomorrow": "Amanhã", "oneWeek": "1 semana" }, "notificationHub": { "title": "Notificações", "tabs": { "inbox": "Caixa de entrada", "upcoming": "A chegar" }, "filters": { "ascending": "Ascendente", "descending": "Descendente", "groupByDate": "Agrupar por data", "showUnreadsOnly": "Mostrar apenas os não lidos", "resetToDefault": "Restaurar para o padrão" }, "empty": "Nada para ver aqui!" }, "reminderNotification": { "title": "Lembrete", "message": "Lembre-se de verificar isto antes que esqueça!", "tooltipDelete": "Apagar", "tooltipMarkRead": "Marcar como lido", "tooltipMarkUnread": "Marcar como não lido" }, "findAndReplace": { "find": "Encontrar", "previousMatch": "Correspondência anterior", "nextMatch": "Correspondência seguinte", "close": "Fechar", "replace": "Substituir", "replaceAll": "Substituir tudo", "noResult": "Nenhum resultado", "caseSensitive": "Maiúsculas e minúsculas" } } ================================================ FILE: frontend/resources/translations/ru-RU.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Я", "welcomeText": "Добро пожаловать в @:appName", "welcomeTo": "Добро пожаловать в", "githubStarText": "Поставить звезду на GitHub", "subscribeNewsletterText": "Подписаться на рассылку", "letsGoButtonText": "Быстрый старт", "title": "Заголовок", "youCanAlso": "Вы также можете", "and": "и", "failedToOpenUrl": "Не удалось открыть URL: {}", "blockActions": { "addBelowTooltip": "Нажмите, чтобы добавить ниже", "addAboveCmd": "Alt+клик", "addAboveMacCmd": "Option+клик", "addAboveTooltip": "чтобы добавить выше", "dragTooltip": "Перетащите, чтобы переместить", "openMenuTooltip": "Нажмите, чтобы открыть меню" }, "signUp": { "buttonText": "Зарегистрироваться", "title": "Зарегистрироваться в @:appName", "getStartedText": "Начать", "emptyPasswordError": "Пароль не может быть пустым", "repeatPasswordEmptyError": "Повторный пароль не может быть пустым", "unmatchedPasswordError": "Повторный пароль не совпадает с паролем", "alreadyHaveAnAccount": "Уже есть аккаунт?", "emailHint": "Email", "passwordHint": "Пароль", "repeatPasswordHint": "Повторить пароль", "signUpWith": "Зарегистрироваться с помощью:" }, "signIn": { "loginTitle": "Войти в @:appName", "loginButtonText": "Войти", "loginStartWithAnonymous": "Продолжить в анонимной сессии", "continueAnonymousUser": "Продолжить в анонимной сессии", "continueWithLocalModel": "Продолжить с локальной моделью", "switchToAppFlowyCloud": "AppFlowy Cloud", "anonymousMode": "Анонимный режим", "buttonText": "Войти", "signingInText": "Выполняется вход...", "forgotPassword": "Забыли пароль?", "emailHint": "Email", "passwordHint": "Пароль", "dontHaveAnAccount": "Нет аккаунта?", "createAccount": "Создать аккаунт", "repeatPasswordEmptyError": "Повторный пароль не может быть пустым", "unmatchedPasswordError": "Повторный пароль не совпадает с паролем", "syncPromptMessage": "Синхронизация данных может занять некоторое время. Пожалуйста, не закрывайте эту страницу", "or": "или", "signInWithGoogle": "Продолжить с Google", "signInWithGithub": "Продолжить с GitHub", "signInWithDiscord": "Продолжить с Discord", "signInWithApple": "Продолжить с Apple", "continueAnotherWay": "Продолжить другим способом", "signUpWithGoogle": "Зарегистрироваться с Google", "signUpWithGithub": "Зарегистрироваться с Github", "signUpWithDiscord": "Зарегистрироваться с Discord", "signInWith": "Продолжить с помощью:", "signInWithEmail": "Продолжить с Email", "signInWithMagicLink": "Продолжить", "signUpWithMagicLink": "Зарегистрироваться с Magic Link", "pleaseInputYourEmail": "Пожалуйста, введите ваш адрес электронной почты", "settings": "Настройки", "magicLinkSent": "Magic Link отправлен!", "invalidEmail": "Пожалуйста, введите действительный адрес электронной почты", "alreadyHaveAnAccount": "Уже есть аккаунт?", "logIn": "Войти", "generalError": "Что-то пошло не так. Пожалуйста, попробуйте позже", "limitRateError": "По соображениям безопасности вы можете запрашивать magic link только раз в 60 секунд", "magicLinkSentDescription": "Magic Link был отправлен на вашу электронную почту. Нажмите на ссылку, чтобы завершить вход. Ссылка истекает через 5 минут.", "tokenHasExpiredOrInvalid": "Код истек или недействителен. Пожалуйста, попробуйте снова.", "signingIn": "Выполняется вход...", "checkYourEmail": "Проверьте свою почту", "temporaryVerificationLinkSent": "Временная ссылка для верификации отправлена.\nПожалуйста, проверьте свой почтовый ящик по адресу", "temporaryVerificationCodeSent": "Временный код верификации отправлен.\nПожалуйста, проверьте свой почтовый ящик по адресу", "continueToSignIn": "Продолжить вход", "backToLogin": "Назад ко входу", "enterCode": "Введите код", "enterCodeManually": "Ввести код вручную", "continueWithEmail": "Продолжить с email", "enterPassword": "Введите пароль", "loginAs": "Войти как", "invalidVerificationCode": "Пожалуйста, введите действительный код верификации", "tooFrequentVerificationCodeRequest": "Вы сделали слишком много запросов. Пожалуйста, попробуйте позже.", "invalidLoginCredentials": "Ваш пароль неверный, пожалуйста, попробуйте снова" }, "workspace": { "chooseWorkspace": "Выберите ваше рабочее пространство", "defaultName": "Мое рабочее пространство", "create": "Создать рабочее пространство", "new": "Новое рабочее пространство", "importFromNotion": "Импорт из Notion", "learnMore": "Узнать больше", "reset": "Сбросить рабочее пространство", "renameWorkspace": "Переименовать рабочее пространство", "workspaceNameCannotBeEmpty": "Имя рабочего пространства не может быть пустым", "resetWorkspacePrompt": "Сброс рабочего пространства приведет к удалению всех страниц и данных в нем. Вы уверены, что хотите сбросить рабочее пространство? Как вариант, вы можете связаться со службой поддержки для восстановления рабочего пространства", "hint": "рабочее пространство", "notFoundError": "Рабочее пространство не найдено", "failedToLoad": "Что-то пошло не так! Не удалось загрузить рабочее пространство. Попробуйте закрыть все открытые экземпляры @:appName и повторить попытку.", "errorActions": { "reportIssue": "Сообщить о проблеме", "reportIssueOnGithub": "Сообщить о проблеме на Github", "exportLogFiles": "Экспортировать файлы логов", "reachOut": "Связаться через Discord" }, "menuTitle": "Рабочие пространства", "deleteWorkspaceHintText": "Вы уверены, что хотите удалить рабочее пространство? Это действие нельзя отменить, и все опубликованные вами страницы будут сняты с публикации.", "createSuccess": "Рабочее пространство успешно создано", "createFailed": "Не удалось создать рабочее пространство", "createLimitExceeded": "Вы достигли максимального лимита рабочих пространств для вашей учетной записи. Если вам нужно больше рабочих пространств для продолжения работы, пожалуйста, запросите это на Github", "deleteSuccess": "Рабочее пространство успешно удалено", "deleteFailed": "Не удалось удалить рабочее пространство", "openSuccess": "Рабочее пространство успешно открыто", "openFailed": "Не удалось открыть рабочее пространство", "renameSuccess": "Рабочее пространство успешно переименовано", "renameFailed": "Не удалось переименовать рабочее пространство", "updateIconSuccess": "Иконка рабочего пространства успешно обновлена", "updateIconFailed": "Не удалось обновить иконку рабочего пространства", "cannotDeleteTheOnlyWorkspace": "Нельзя удалить единственное рабочее пространство", "fetchWorkspacesFailed": "Не удалось получить список рабочих пространств", "leaveCurrentWorkspace": "Покинуть рабочее пространство", "leaveCurrentWorkspacePrompt": "Вы уверены, что хотите покинуть текущее рабочее пространство?" }, "shareAction": { "buttonText": "Поделиться", "workInProgress": "Скоро будет доступно", "markdown": "Markdown", "html": "HTML", "clipboard": "Скопировать в буфер обмена", "csv": "CSV", "copyLink": "Скопировать ссылку", "publishToTheWeb": "Опубликовать в Интернете", "publishToTheWebHint": "Создать сайт с помощью AppFlowy", "publish": "Опубликовать", "unPublish": "Снять с публикации", "visitSite": "Посетить сайт", "exportAsTab": "Экспортировать как", "publishTab": "Опубликовать", "shareTab": "Поделиться", "publishOnAppFlowy": "Опубликовать на AppFlowy", "shareTabTitle": "Пригласить к совместной работе", "shareTabDescription": "Для простой совместной работы с кем угодно", "copyLinkSuccess": "Ссылка скопирована в буфер обмена", "copyShareLink": "Скопировать ссылку для доступа", "copyLinkFailed": "Не удалось скопировать ссылку в буфер обмена", "copyLinkToBlockSuccess": "Ссылка на блок скопирована в буфер обмена", "copyLinkToBlockFailed": "Не удалось скопировать ссылку на блок в буфер обмена", "manageAllSites": "Управление всеми сайтами", "updatePathName": "Обновить имя пути" }, "moreAction": { "small": "маленький", "medium": "средний", "large": "большой", "fontSize": "Размер шрифта", "import": "Импорт", "moreOptions": "Больше опций", "wordCount": "Количество слов: {}", "charCount": "Количество символов: {}", "createdAt": "Создано: {}", "deleteView": "Удалить", "duplicateView": "Дублировать", "wordCountLabel": "Количество слов: ", "charCountLabel": "Количество символов: ", "createdAtLabel": "Создано: ", "syncedAtLabel": "Синхронизировано: ", "saveAsNewPage": "Добавить сообщения на страницу", "saveAsNewPageDisabled": "Нет доступных сообщений" }, "importPanel": { "textAndMarkdown": "Текст и Markdown", "documentFromV010": "Документ из v0.1.0", "databaseFromV010": "База данных из v0.1.0", "notionZip": "Экспортированный Zip-файл Notion", "csv": "CSV", "database": "База данных" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "Перетащите файл, нажмите, чтобы ", "placeholderUpload": "Загрузить", "placeholderRight": ", или вставьте ссылку на изображение.", "dropToUpload": "Перетащите файл для загрузки", "change": "Изменить" } }, "disclosureAction": { "rename": "Переименовать", "delete": "Удалить", "duplicate": "Дублировать", "unfavorite": "Удалить из избранного", "favorite": "Добавить в избранное", "openNewTab": "Открыть в новой вкладке", "moveTo": "Переместить в", "addToFavorites": "Добавить в избранное", "copyLink": "Скопировать ссылку", "changeIcon": "Изменить иконку", "collapseAllPages": "Свернуть все подстраницы", "movePageTo": "Переместить страницу в", "move": "Переместить", "lockPage": "Заблокировать страницу" }, "blankPageTitle": "Пустая страница", "newPageText": "Новая страница", "newDocumentText": "Новый документ", "newGridText": "Новая таблица", "newCalendarText": "Новый календарь", "newBoardText": "Новая доска", "chat": { "newChat": "AI Чат", "inputMessageHint": "Спросить AI @:appName", "inputLocalAIMessageHint": "Спросить локальный AI @:appName", "unsupportedCloudPrompt": "Эта функция доступна только при использовании AppFlowy Cloud", "relatedQuestion": "Предложенные", "serverUnavailable": "Соединение потеряно. Пожалуйста, проверьте интернет и", "aiServerUnavailable": "Служба AI временно недоступна. Пожалуйста, попробуйте позже.", "retry": "Повторить", "clickToRetry": "Нажмите, чтобы повторить", "regenerateAnswer": "Перегенерировать", "question1": "Как использовать Kanban для управления задачами", "question2": "Объясните метод GTD", "question3": "Зачем использовать Rust", "question4": "Рецепт из того, что есть на кухне", "question5": "Создать иллюстрацию для моей страницы", "question6": "Составить список дел на следующую неделю", "aiMistakePrompt": "AI может совершать ошибки. Проверяйте важную информацию.", "chatWithFilePrompt": "Хотите пообщаться с файлом?", "indexFileSuccess": "Файл успешно проиндексирован", "inputActionNoPages": "Нет результатов по страницам", "referenceSource": { "zero": "Найдено 0 источников", "one": "Найден {count} источник", "other": "Найдено {count} источников" }, "clickToMention": "Упомянуть страницу", "uploadFile": "Прикрепить PDF, текстовые файлы или файлы markdown", "questionDetail": "Привет, {}! Чем могу помочь сегодня?", "indexingFile": "Индексация {}", "generatingResponse": "Генерация ответа", "selectSources": "Выбрать источники", "currentPage": "Текущая страница", "sourcesLimitReached": "Вы можете выбрать до 3 документов верхнего уровня и их дочерние элементы", "sourceUnsupported": "Мы пока не поддерживаем общение с базами данных", "regenerate": "Попробовать снова", "addToPageButton": "Добавить сообщение на страницу", "addToPageTitle": "Добавить сообщение в...", "addToNewPage": "Создать новую страницу", "addToNewPageName": "Сообщения, извлеченные из \"{}\"", "addToNewPageSuccessToast": "Сообщение добавлено на", "openPagePreviewFailedToast": "Не удалось открыть страницу", "changeFormat": { "actionButton": "Изменить формат", "confirmButton": "Перегенерировать в этом формате", "textOnly": "Только текст", "imageOnly": "Только изображение", "textAndImage": "Текст и изображение", "text": "Параграф", "bullet": "Маркированный список", "number": "Нумерованный список", "table": "Таблица", "blankDescription": "Формат ответа", "defaultDescription": "Автоматический формат ответа", "textWithImageDescription": "@:chat.changeFormat.text с изображением", "numberWithImageDescription": "@:chat.changeFormat.number с изображением", "bulletWithImageDescription": "@:chat.changeFormat.bullet с изображением", "tableWithImageDescription": "@:chat.changeFormat.table с изображением" }, "switchModel": { "label": "Сменить модель", "localModel": "Локальная модель", "cloudModel": "Облачная модель", "autoModel": "Авто" }, "selectBanner": { "saveButton": "Добавить в…", "selectMessages": "Выбрать сообщения", "nSelected": "Выбрано {}", "allSelected": "Все выбраны" }, "stopTooltip": "Остановить генерацию" }, "trash": { "text": "Корзина", "restoreAll": "Восстановить все", "restore": "Восстановить", "deleteAll": "Удалить все", "pageHeader": { "fileName": "Имя файла", "lastModified": "Последнее изменение", "created": "Создано" }, "confirmDeleteAll": { "title": "Все страницы в корзине", "caption": "Вы уверены, что хотите удалить все в Корзине? Это действие нельзя отменить." }, "confirmRestoreAll": { "title": "Восстановить все страницы в корзине", "caption": "Это действие нельзя отменить." }, "restorePage": { "title": "Восстановить: {}", "caption": "Вы уверены, что хотите восстановить эту страницу?" }, "mobile": { "actions": "Действия с корзиной", "empty": "Нет страниц или пространств в Корзине", "emptyDescription": "Перемещайте ненужные вещи в Корзину.", "isDeleted": "удалено", "isRestored": "восстановлено" }, "confirmDeleteTitle": "Вы уверены, что хотите удалить эту страницу навсегда?" }, "deletePagePrompt": { "text": "Эта страница в Корзине", "restore": "Восстановить страницу", "deletePermanent": "Удалить навсегда", "deletePermanentDescription": "Вы уверены, что хотите удалить эту страницу навсегда? Это необратимо." }, "dialogCreatePageNameHint": "Название страницы", "questionBubble": { "shortcuts": "Быстрые клавиши", "whatsNew": "Что нового?", "helpAndDocumentation": "Помощь и документация", "getSupport": "Получить поддержку", "markdown": "Markdown", "debug": { "name": "Отладочная информация", "success": "Отладочная информация скопирована в буфер обмена!", "fail": "Не удалось скопировать отладочную информацию в буфер обмена" }, "feedback": "Обратная связь" }, "menuAppHeader": { "moreButtonToolTip": "Удалить, переименовать и другое...", "addPageTooltip": "Быстро добавить страницу внутри", "defaultNewPageName": "Без названия", "renameDialog": "Переименовать", "pageNameSuffix": "Копия" }, "noPagesInside": "Нет страниц внутри", "toolbar": { "undo": "Отменить", "redo": "Повторить", "bold": "Жирный", "italic": "Курсив", "underline": "Подчеркнутый", "strike": "Зачеркнутый", "numList": "Нумерованный список", "bulletList": "Маркированный список", "checkList": "Список задач", "inlineCode": "Встроенный код", "quote": "Блок цитаты", "header": "Заголовок", "highlight": "Выделить", "color": "Цвет", "addLink": "Добавить ссылку" }, "tooltip": { "lightMode": "Переключиться в светлый режим", "darkMode": "Переключиться в темный режим", "openAsPage": "Открыть как страницу", "addNewRow": "Добавить новую строку", "openMenu": "Нажмите, чтобы открыть меню", "dragRow": "Перетащите, чтобы изменить порядок строк", "viewDataBase": "Просмотр базы данных", "referencePage": "На эту {name} есть ссылки", "addBlockBelow": "Добавить блок ниже", "aiGenerate": "Сгенерировать" }, "sideBar": { "closeSidebar": "Закрыть боковую панель", "openSidebar": "Открыть боковую панель", "expandSidebar": "Развернуть на всю страницу", "personal": "Личное", "private": "Приватное", "workspace": "Рабочее пространство", "favorites": "Избранное", "clickToHidePrivate": "Нажмите, чтобы скрыть приватное пространство\nСтраницы, созданные здесь, видны только вам", "clickToHideWorkspace": "Нажмите, чтобы скрыть рабочее пространство\nСтраницы, созданные здесь, видны всем участникам", "clickToHidePersonal": "Нажмите, чтобы скрыть личное пространство", "clickToHideFavorites": "Нажмите, чтобы скрыть избранное", "addAPage": "Добавить новую страницу", "addAPageToPrivate": "Добавить страницу в приватное пространство", "addAPageToWorkspace": "Добавить страницу в рабочее пространство", "recent": "Последние", "today": "Сегодня", "thisWeek": "На этой неделе", "others": "Более раннее избранное", "earlier": "Раньше", "justNow": "только что", "minutesAgo": "{count} мин назад", "lastViewed": "Последний просмотр", "favoriteAt": "Добавлено в избранное", "emptyRecent": "Нет последних страниц", "emptyRecentDescription": "При просмотре страниц они будут появляться здесь для быстрого доступа.", "emptyFavorite": "Нет избранных страниц", "emptyFavoriteDescription": "Отмечайте страницы как избранные — они будут перечислены здесь для быстрого доступа!", "removePageFromRecent": "Удалить эту страницу из последних?", "removeSuccess": "Успешно удалено", "favoriteSpace": "Избранное", "RecentSpace": "Последние", "Spaces": "Пространства", "upgradeToPro": "Обновиться до Pro", "upgradeToAIMax": "Разблокировать неограниченный AI", "storageLimitDialogTitle": "У вас закончилось свободное хранилище. Обновитесь, чтобы разблокировать неограниченное хранилище", "storageLimitDialogTitleIOS": "У вас закончилось свободное хранилище.", "aiResponseLimitTitle": "У вас закончились бесплатные ответы AI. Обновитесь до плана Pro или купите дополнение AI, чтобы разблокировать неограниченные ответы", "aiResponseLimitDialogTitle": "Достигнут лимит ответов AI", "aiResponseLimit": "У вас закончились бесплатные ответы AI.\n\nПерейдите в Настройки -> План -> Нажмите AI Max или план Pro, чтобы получить больше ответов AI", "askOwnerToUpgradeToPro": "В вашем рабочем пространстве заканчивается свободное хранилище. Пожалуйста, попросите владельца рабочего пространства обновиться до плана Pro", "askOwnerToUpgradeToProIOS": "В вашем рабочем пространстве заканчивается свободное хранилище.", "askOwnerToUpgradeToAIMax": "В вашем рабочем пространстве закончились бесплатные ответы AI. Пожалуйста, попросите владельца рабочего пространства обновить план или приобрести дополнения AI", "askOwnerToUpgradeToAIMaxIOS": "В вашем рабочем пространстве заканчиваются бесплатные ответы AI.", "purchaseAIMax": "В вашем рабочем пространстве закончились ответы AI Image. Пожалуйста, попросите владельца рабочего пространства приобрести AI Max", "aiImageResponseLimit": "У вас закончились ответы AI image.\n\nПерейдите в Настройки -> План -> Нажмите AI Max, чтобы получить больше ответов AI image", "purchaseStorageSpace": "Купить место для хранения", "singleFileProPlanLimitationDescription": "Вы превысили максимальный размер файла, разрешенный в бесплатном плане. Пожалуйста, обновитесь до плана Pro, чтобы загружать файлы большего размера", "purchaseAIResponse": "Купить", "askOwnerToUpgradeToLocalAI": "Попросите владельца рабочего пространства включить AI на устройстве", "upgradeToAILocal": "Запускайте локальные модели на вашем устройстве для максимальной конфиденциальности", "upgradeToAILocalDesc": "Общайтесь с PDF, улучшайте свое письмо и автоматически заполняйте таблицы с помощью локального AI" }, "notifications": { "export": { "markdown": "Заметка экспортирована в Markdown", "path": "Документы/flowy" } }, "contactsPage": { "title": "Контакты", "whatsHappening": "Что происходит на этой неделе?", "addContact": "Добавить контакт", "editContact": "Редактировать контакт" }, "button": { "ok": "Ок", "confirm": "Подтвердить", "done": "Готово", "cancel": "Отмена", "signIn": "Войти", "signOut": "Выйти", "complete": "Готово", "save": "Сохранить", "generate": "Сгенерировать", "esc": "ESC", "keep": "Сохранить", "tryAgain": "Попробовать снова", "discard": "Отменить", "replace": "Заменить", "insertBelow": "Вставить ниже", "insertAbove": "Вставить выше", "upload": "Загрузить", "edit": "Редактировать", "delete": "Удалить", "copy": "Копировать", "duplicate": "Дублировать", "putback": "Вернуть", "update": "Обновить", "share": "Поделиться", "removeFromFavorites": "Удалить из избранного", "removeFromRecent": "Удалить из последних", "addToFavorites": "Добавить в избранное", "favoriteSuccessfully": "Успешно добавлено в избранное", "unfavoriteSuccessfully": "Успешно удалено из избранного", "duplicateSuccessfully": "Успешно дублировано", "rename": "Переименовать", "helpCenter": "Справочный центр", "add": "Добавить", "yes": "Да", "no": "Нет", "clear": "Очистить", "remove": "Удалить", "dontRemove": "Не удалять", "copyLink": "Скопировать ссылку", "align": "Выровнять", "login": "Вход", "logout": "Выход", "deleteAccount": "Удалить аккаунт", "back": "Назад", "signInGoogle": "Продолжить с Google", "signInGithub": "Продолжить с GitHub", "signInDiscord": "Продолжить с Discord", "more": "Еще", "create": "Создать", "close": "Закрыть", "next": "Далее", "previous": "Назад", "submit": "Отправить", "download": "Скачать", "backToHome": "На главную", "viewing": "Просмотр", "editing": "Редактирование", "gotIt": "Понял", "retry": "Повторить", "uploadFailed": "Загрузка не удалась.", "copyLinkOriginal": "Скопировать ссылку на оригинал" }, "label": { "welcome": "Добро пожаловать!", "firstName": "Имя", "middleName": "Отчество", "lastName": "Фамилия", "stepX": "Шаг {X}" }, "oAuth": { "err": { "failedTitle": "Не удалось подключиться к вашей учетной записи.", "failedMsg": "Пожалуйста, убедитесь, что вы завершили процесс входа в браузере." }, "google": { "title": "ВХОД ЧЕРЕЗ GOOGLE", "instruction1": "Для импорта контактов Google вам необходимо авторизовать это приложение через веб-браузер.", "instruction2": "Скопируйте этот код в буфер обмена, нажав на значок или выделив текст:", "instruction3": "Перейдите по следующей ссылке в браузере и введите указанный выше код:", "instruction4": "Нажмите кнопку ниже, когда завершите регистрацию:" } }, "settings": { "title": "Настройки", "popupMenuItem": { "settings": "Настройки", "members": "Участники", "trash": "Корзина", "helpAndDocumentation": "Помощь и документация", "getSupport": "Получить поддержку" }, "sites": { "title": "Сайты", "namespaceTitle": "Пространство имен", "namespaceDescription": "Управляйте своим пространством имен и домашней страницей", "namespaceHeader": "Пространство имен", "homepageHeader": "Домашняя страница", "updateNamespace": "Обновить пространство имен", "removeHomepage": "Удалить домашнюю страницу", "selectHomePage": "Выбрать страницу", "clearHomePage": "Очистить домашнюю страницу для этого пространства имен", "customUrl": "Пользовательский URL", "namespace": { "description": "Это изменение будет применено ко всем опубликованным страницам в этом пространстве имен", "tooltip": "Мы оставляем за собой право удалять любые неприемлемые пространства имен", "updateExistingNamespace": "Обновить существующее пространство имен", "upgradeToPro": "Обновитесь до плана Pro, чтобы установить домашнюю страницу", "redirectToPayment": "Перенаправление на страницу оплаты...", "onlyWorkspaceOwnerCanSetHomePage": "Только владелец рабочего пространства может установить домашнюю страницу", "pleaseAskOwnerToSetHomePage": "Пожалуйста, попросите владельца рабочего пространства обновиться до плана Pro" }, "publishedPage": { "title": "Все опубликованные страницы", "description": "Управляйте своими опубликованными страницами", "page": "Страница", "pathName": "Имя пути", "date": "Дата публикации", "emptyHinText": "У вас нет опубликованных страниц в этом рабочем пространстве", "noPublishedPages": "Нет опубликованных страниц", "settings": "Настройки публикации", "clickToOpenPageInApp": "Открыть страницу в приложении", "clickToOpenPageInBrowser": "Открыть страницу в браузере" }, "error": { "failedToGeneratePaymentLink": "Не удалось сгенерировать ссылку для оплаты плана Pro", "failedToUpdateNamespace": "Не удалось обновить пространство имен", "proPlanLimitation": "Для обновления пространства имен вам нужно обновиться до плана Pro", "namespaceAlreadyInUse": "Пространство имен уже используется, пожалуйста, попробуйте другое", "invalidNamespace": "Недействительное пространство имен, пожалуйста, попробуйте другое", "namespaceLengthAtLeast2Characters": "Длина пространства имен должна быть не менее 2 символов", "onlyWorkspaceOwnerCanUpdateNamespace": "Только владелец рабочего пространства может обновить пространство имен", "onlyWorkspaceOwnerCanRemoveHomepage": "Только владелец рабочего пространства может удалить домашнюю страницу", "setHomepageFailed": "Не удалось установить домашнюю страницу", "namespaceTooLong": "Имя пространства слишком длинное, пожалуйста, попробуйте другое", "namespaceTooShort": "Имя пространства слишком короткое, пожалуйста, попробуйте другое", "namespaceIsReserved": "Пространство имен зарезервировано, пожалуйста, попробуйте другое", "updatePathNameFailed": "Не удалось обновить имя пути", "removeHomePageFailed": "Не удалось удалить домашнюю страницу", "publishNameContainsInvalidCharacters": "Имя пути содержит недопустимые символы, пожалуйста, попробуйте другое", "publishNameTooShort": "Имя пути слишком короткое, пожалуйста, попробуйте другое", "publishNameTooLong": "Имя пути слишком длинное, пожалуйста, попробуйте другое", "publishNameAlreadyInUse": "Имя пути уже используется, пожалуйста, попробуйте другое", "namespaceContainsInvalidCharacters": "Пространство имен содержит недопустимые символы, пожалуйста, попробуйте другое", "publishPermissionDenied": "Только владелец рабочего пространства или издатель страницы может управлять настройками публикации", "publishNameCannotBeEmpty": "Имя пути не может быть пустым, пожалуйста, попробуйте другое" }, "success": { "namespaceUpdated": "Пространство имен успешно обновлено", "setHomepageSuccess": "Домашняя страница успешно установлена", "updatePathNameSuccess": "Имя пути успешно обновлено", "removeHomePageSuccess": "Домашняя страница успешно удалена" } }, "accountPage": { "menuLabel": "Аккаунт и Приложение", "title": "Мой аккаунт", "general": { "title": "Имя аккаунта и изображение профиля", "changeProfilePicture": "Изменить фото профиля" }, "email": { "title": "Email", "actions": { "change": "Изменить email" } }, "login": { "title": "Вход в аккаунт", "loginLabel": "Войти", "logoutLabel": "Выйти" }, "isUpToDate": "@:appName обновлен!", "officialVersion": "Версия {version} (Официальная сборка)" }, "workspacePage": { "menuLabel": "Рабочее пространство", "title": "Рабочее пространство", "description": "Настройте внешний вид рабочего пространства, тему, шрифт, макет текста, формат даты/времени и язык.", "workspaceName": { "title": "Название рабочего пространства" }, "workspaceIcon": { "title": "Иконка рабочего пространства", "description": "Загрузите изображение или используйте эмодзи для вашего рабочего пространства. Иконка будет отображаться в боковой панели и уведомлениях." }, "appearance": { "title": "Внешний вид", "description": "Настройте внешний вид рабочего пространства, тему, шрифт, макет текста, дату, время и язык.", "options": { "system": "Авто", "light": "Светлая", "dark": "Темная" } }, "resetCursorColor": { "title": "Сбросить цвет курсора документа", "description": "Вы уверены, что хотите сбросить цвет курсора?" }, "resetSelectionColor": { "title": "Сбросить цвет выделения документа", "description": "Вы уверены, что хотите сбросить цвет выделения?" }, "resetWidth": { "resetSuccess": "Ширина документа успешно сброшена" }, "theme": { "title": "Тема", "description": "Выберите предустановленную тему или загрузите свою пользовательскую тему.", "uploadCustomThemeTooltip": "Загрузить пользовательскую тему", "failedToLoadThemes": "Не удалось загрузить темы, пожалуйста, проверьте настройки разрешений в Системные настройки > Конфиденциальность и безопасность > Файлы и папки > @:appName" }, "workspaceFont": { "title": "Шрифт рабочего пространства", "noFontHint": "Шрифт не найден, попробуйте другой запрос." }, "textDirection": { "title": "Направление текста", "leftToRight": "Слева направо", "rightToLeft": "Справа налево", "auto": "Авто", "enableRTLItems": "Включить элементы панели инструментов RTL" }, "layoutDirection": { "title": "Направление макета", "leftToRight": "Слева направо", "rightToLeft": "Справа налево" }, "dateTime": { "title": "Дата и время", "example": "{} в {} ({})", "24HourTime": "24-часовой формат", "dateFormat": { "label": "Формат даты", "local": "Локальный", "us": "США", "iso": "ISO", "friendly": "Удобный", "dmy": "Д/М/Г" } }, "language": { "title": "Язык" }, "deleteWorkspacePrompt": { "title": "Удалить рабочее пространство", "content": "Вы уверены, что хотите удалить это рабочее пространство? Это действие нельзя отменить, и все опубликованные вами страницы будут сняты с публикации." }, "leaveWorkspacePrompt": { "title": "Покинуть рабочее пространство", "content": "Вы уверены, что хотите покинуть это рабочее пространство? Вы потеряете доступ ко всем страницам и данным в нем.", "success": "Вы успешно покинули рабочее пространство.", "fail": "Не удалось покинуть рабочее пространство." }, "manageWorkspace": { "title": "Управление рабочим пространством", "leaveWorkspace": "Покинуть рабочее пространство", "deleteWorkspace": "Удалить рабочее пространство" } }, "manageDataPage": { "menuLabel": "Управление данными", "title": "Управление данными", "description": "Управление локальным хранилищем данных или импорт существующих данных в @:appName.", "dataStorage": { "title": "Местоположение хранилища файлов", "tooltip": "Место, где хранятся ваши файлы", "actions": { "change": "Изменить путь", "open": "Открыть папку", "openTooltip": "Открыть текущее местоположение папки с данными", "copy": "Скопировать путь", "copiedHint": "Путь скопирован!", "resetTooltip": "Сбросить до местоположения по умолчанию" }, "resetDialog": { "title": "Вы уверены?", "description": "Сброс пути к местоположению данных по умолчанию не приведет к удалению ваших данных. Если вы хотите повторно импортировать текущие данные, сначала скопируйте путь к текущему местоположению." } }, "importData": { "title": "Импорт данных", "tooltip": "Импорт данных из резервных копий/папок данных @:appName", "description": "Скопировать данные из внешней папки данных @:appName", "action": "Обзор файлов" }, "encryption": { "title": "Шифрование", "tooltip": "Управление способом хранения и шифрования ваших данных", "descriptionNoEncryption": "Включение шифрования зашифрует все данные. Это нельзя отменить.", "descriptionEncrypted": "Ваши данные зашифрованы.", "action": "Зашифровать данные", "dialog": { "title": "Зашифровать все ваши данные?", "description": "Шифрование всех ваших данных обеспечит их безопасность и защиту. Это действие НЕЛЬЗЯ отменить. Вы уверены, что хотите продолжить?" } }, "cache": { "title": "Очистить кеш", "description": "Помогает решить проблемы, такие как незагружающиеся изображения, отсутствующие страницы в пространстве и незагружающиеся шрифты. Это не повлияет на ваши данные.", "dialog": { "title": "Очистить кеш", "description": "Помогает решить проблемы, такие как незагружающиеся изображения, отсутствующие страницы в пространстве и незагружающиеся шрифты. Это не повлияет на ваши данные.", "successHint": "Кеш очищен!" } }, "data": { "fixYourData": "Исправить ваши данные", "fixButton": "Исправить", "fixYourDataDescription": "Если у вас возникли проблемы с данными, вы можете попробовать исправить их здесь." } }, "shortcutsPage": { "menuLabel": "Быстрые клавиши", "title": "Быстрые клавиши", "editBindingHint": "Введите новую привязку", "searchHint": "Поиск", "actions": { "resetDefault": "Сбросить до значений по умолчанию" }, "errorPage": { "message": "Не удалось загрузить быстрые клавиши: {}", "howToFix": "Попробуйте снова, если проблема сохраняется, пожалуйста, свяжитесь с нами на GitHub." }, "resetDialog": { "title": "Сбросить быстрые клавиши", "description": "Это сбросит все ваши привязки клавиш до значений по умолчанию, вы не сможете отменить это позже, вы уверены, что хотите продолжить?", "buttonLabel": "Сбросить" }, "conflictDialog": { "title": "{} используется", "descriptionPrefix": "Эта привязка клавиш в настоящее время используется ", "descriptionSuffix": ". Если вы замените эту привязку, она будет удалена из {}.", "confirmLabel": "Продолжить" }, "editTooltip": "Нажмите, чтобы начать редактирование привязки клавиш", "keybindings": { "toggleToDoList": "Переключить список задач", "insertNewParagraphInCodeblock": "Вставить новый абзац", "pasteInCodeblock": "Вставить в блок кода", "selectAllCodeblock": "Выбрать все", "indentLineCodeblock": "Вставить два пробела в начале строки", "outdentLineCodeblock": "Удалить два пробела в начале строки", "twoSpacesCursorCodeblock": "Вставить два пробела у курсора", "copy": "Копировать выделенное", "paste": "Вставить содержимое", "cut": "Вырезать выделенное", "alignLeft": "Выровнять текст по левому краю", "alignCenter": "Выровнять текст по центру", "alignRight": "Выровнять текст по правому краю", "insertInlineMathEquation": "Вставить встроенное математическое уравнение", "undo": "Отменить", "redo": "Повторить", "convertToParagraph": "Преобразовать блок в абзац", "backspace": "Удалить", "deleteLeftWord": "Удалить слово слева", "deleteLeftSentence": "Удалить предложение слева", "delete": "Удалить символ справа", "deleteMacOS": "Удалить символ слева", "deleteRightWord": "Удалить слово справа", "moveCursorLeft": "Переместить курсор влево", "moveCursorBeginning": "Переместить курсор в начало", "moveCursorLeftWord": "Переместить курсор на одно слово влево", "moveCursorLeftSelect": "Выделить и переместить курсор влево", "moveCursorBeginSelect": "Выделить и переместить курсор в начало", "moveCursorLeftWordSelect": "Выделить и переместить курсор на одно слово влево", "moveCursorRight": "Переместить курсор вправо", "moveCursorEnd": "Переместить курсор в конец", "moveCursorRightWord": "Переместить курсор на одно слово вправо", "moveCursorRightSelect": "Выделить и переместить курсор вправо", "moveCursorEndSelect": "Выделить и переместить курсор в конец", "moveCursorRightWordSelect": "Выделить и переместить курсор на одно слово вправо", "moveCursorUp": "Переместить курсор вверх", "moveCursorTopSelect": "Выделить и переместить курсор вверх", "moveCursorTop": "Переместить курсор вверх", "moveCursorUpSelect": "Выделить и переместить курсор вверх", "moveCursorBottomSelect": "Выделить и переместить курсор вниз", "moveCursorBottom": "Переместить курсор вниз", "moveCursorDown": "Переместить курсор вниз", "moveCursorDownSelect": "Выделить и переместить курсор вниз", "home": "Прокрутить вверх", "end": "Прокрутить вниз", "toggleBold": "Переключить жирный", "toggleItalic": "Переключить курсив", "toggleUnderline": "Переключить подчеркнутый", "toggleStrikethrough": "Переключить зачеркнутый", "toggleCode": "Переключить встроенный код", "toggleHighlight": "Переключить выделение", "showLinkMenu": "Показать меню ссылок", "openInlineLink": "Открыть встроенную ссылку", "openLinks": "Открыть все выделенные ссылки", "indent": "Сделать отступ", "outdent": "Убрать отступ", "exit": "Выйти из редактирования", "pageUp": "Прокрутить на одну страницу вверх", "pageDown": "Прокрутить на одну страницу вниз", "selectAll": "Выбрать все", "pasteWithoutFormatting": "Вставить содержимое без форматирования", "showEmojiPicker": "Показать выбор эмодзи", "enterInTableCell": "Добавить перенос строки в ячейке таблицы", "leftInTableCell": "Переместить влево на одну ячейку в таблице", "rightInTableCell": "Переместить вправо на одну ячейку в таблице", "upInTableCell": "Переместить вверх на одну ячейку в таблице", "downInTableCell": "Переместить вниз на одну ячейку в таблице", "tabInTableCell": "Перейти к следующей доступной ячейке в таблице", "shiftTabInTableCell": "Перейти к предыдущей доступной ячейке в таблице", "backSpaceInTableCell": "Остановиться в начале ячейки" }, "commands": { "codeBlockNewParagraph": "Вставить новый абзац рядом с блоком кода", "codeBlockIndentLines": "Вставить два пробела в начале строки в блоке кода", "codeBlockOutdentLines": "Удалить два пробела в начале строки в блоке кода", "codeBlockAddTwoSpaces": "Вставить два пробела в позиции курсора в блоке кода", "codeBlockSelectAll": "Выбрать все содержимое блока кода", "codeBlockPasteText": "Вставить текст в блок кода", "textAlignLeft": "Выровнять текст по левому краю", "textAlignCenter": "Выровнять текст по центру", "textAlignRight": "Выровнять текст по правому краю" }, "couldNotLoadErrorMsg": "Не удалось загрузить быстрые клавиши, попробуйте снова", "couldNotSaveErrorMsg": "Не удалось сохранить быстрые клавиши, попробуйте снова" }, "aiPage": { "title": "Настройки AI", "menuLabel": "Настройки AI", "keys": { "enableAISearchTitle": "Поиск AI", "aiSettingsDescription": "Выберите предпочтительную модель для работы AppFlowy AI. Включает GPT-4o, GPT-o3-mini, DeepSeek R1, Claude 3.5 Sonnet и модели, доступные в Ollama", "loginToEnableAIFeature": "Функции AI включены только после входа с AppFlowy Cloud. Если у вас нет учетной записи AppFlowy, перейдите в 'Мой аккаунт' для регистрации", "llmModel": "Языковая модель", "llmModelType": "Тип языковой модели", "downloadLLMPrompt": "Загрузить {}", "downloadAppFlowyOfflineAI": "Загрузка офлайн-пакета AI позволит AI работать на вашем устройстве. Вы хотите продолжить?", "downloadLLMPromptDetail": "Загрузка локальной модели {} займет до {} места на диске. Вы хотите продолжить?", "downloadBigFilePrompt": "Загрузка может занять около 10 минут", "downloadAIModelButton": "Загрузить", "downloadingModel": "Загрузка", "localAILoaded": "Локальная модель AI успешно добавлена и готова к использованию", "localAIStart": "Локальный AI запускается. Если работает медленно, попробуйте выключить и включить его", "localAILoading": "Загружается локальная модель AI Chat...", "localAIStopped": "Локальный AI остановлен", "localAIRunning": "Локальный AI запущен", "localAINotReadyRetryLater": "Локальный AI инициализируется, пожалуйста, попробуйте позже", "localAIDisabled": "Вы используете локальный AI, но он отключен. Пожалуйста, перейдите в настройки, чтобы включить его или попробуйте другую модель", "localAIInitializing": "Локальный AI загружается. Это может занять несколько секунд в зависимости от вашего устройства", "localAINotReadyTextFieldPrompt": "Вы не можете редактировать, пока загружается локальный AI", "localAIDisabledTextFieldPrompt": "Вы не можете редактировать, пока локальный AI отключен", "failToLoadLocalAI": "Не удалось запустить локальный AI.", "restartLocalAI": "Перезапустить", "disableLocalAITitle": "Отключить локальный AI", "disableLocalAIDescription": "Вы хотите отключить локальный AI?", "localAIToggleTitle": "AppFlowy Локальный AI (LAI)", "localAIToggleSubTitle": "Запускайте самые продвинутые локальные модели AI в AppFlowy для максимальной конфиденциальности и безопасности", "offlineAIInstruction1": "Следуйте", "offlineAIInstruction2": "инструкции", "offlineAIInstruction3": "чтобы включить офлайн-AI.", "offlineAIDownload1": "Если вы еще не скачали AppFlowy AI, пожалуйста, ", "offlineAIDownload2": "скачайте", "offlineAIDownload3": "его сначала", "activeOfflineAI": "Активно", "downloadOfflineAI": "Скачать", "openModelDirectory": "Открыть папку", "laiNotReady": "Приложение Local AI не установлено корректно.", "ollamaNotReady": "Сервер Ollama не готов.", "pleaseFollowThese": "Пожалуйста, следуйте этим", "instructions": "инструкциям", "installOllamaLai": "для настройки Ollama и AppFlowy Local AI.", "modelsMissing": "Не удалось найти необходимые модели: ", "downloadModel": "чтобы скачать их." } }, "planPage": { "menuLabel": "План", "title": "Тарифный план", "planUsage": { "title": "Сводка использования плана", "storageLabel": "Хранилище", "storageUsage": "{} из {} ГБ", "unlimitedStorageLabel": "Неограниченное хранилище", "collaboratorsLabel": "Участники", "collaboratorsUsage": "{} из {}", "aiResponseLabel": "Ответы AI", "aiResponseUsage": "{} из {}", "unlimitedAILabel": "Неограниченные ответы", "proBadge": "Pro", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "AI на устройстве для Mac", "memberProToggle": "Больше участников и неограниченный AI", "aiMaxToggle": "Неограниченный AI и доступ к продвинутым моделям", "aiOnDeviceToggle": "Локальный AI для максимальной конфиденциальности", "aiCredit": { "title": "Добавить кредиты AI @:appName", "price": "{}", "priceDescription": "за 1000 кредитов", "purchase": "Купить AI", "info": "Добавьте 1000 кредитов AI на каждое рабочее пространство и легко интегрируйте настраиваемый AI в свой рабочий процесс для получения более умных и быстрых результатов с помощью:", "infoItemOne": "10 000 ответов на базу данных", "infoItemTwo": "1 000 ответов на рабочее пространство" }, "currentPlan": { "bannerLabel": "Текущий план", "freeTitle": "Бесплатно", "proTitle": "Pro", "teamTitle": "Команда", "freeInfo": "Идеально подходит для индивидуального использования до 2 участников для организации всего", "proInfo": "Идеально подходит для небольших и средних команд до 10 участников.", "teamInfo": "Идеально подходит для всех продуктивных и хорошо организованных команд.", "upgrade": "Изменить план", "canceledInfo": "Ваш план отменен, вы будете переведены на бесплатный план {}." }, "addons": { "title": "Дополнения", "addLabel": "Добавить", "activeLabel": "Добавлено", "aiMax": { "title": "AI Max", "description": "Неограниченные ответы AI на базе продвинутых моделей AI и 50 изображений AI в месяц", "price": "{}", "priceInfo": "За пользователя в месяц при ежегодной оплате" }, "aiOnDevice": { "title": "AI на устройстве для Mac", "description": "Запускайте Mistral 7B, LLAMA 3 и другие локальные модели на вашем компьютере", "price": "{}", "priceInfo": "За пользователя в месяц при ежегодной оплате", "recommend": "Рекомендуется M1 или новее" } }, "deal": { "bannerLabel": "Предложение Нового года!", "title": "Развивайте свою команду!", "info": "Обновитесь и сэкономьте 10% на планах Pro и Team! Повысьте продуктивность своего рабочего пространства с помощью новых мощных функций, включая AppFlowy AI.", "viewPlans": "Посмотреть планы" } } }, "billingPage": { "menuLabel": "Оплата", "title": "Оплата", "plan": { "title": "План", "freeLabel": "Бесплатно", "proLabel": "Pro", "planButtonLabel": "Изменить план", "billingPeriod": "Платежный период", "periodButtonLabel": "Редактировать период" }, "paymentDetails": { "title": "Реквизиты платежа", "methodLabel": "Способ оплаты", "methodButtonLabel": "Редактировать способ" }, "addons": { "title": "Дополнения", "addLabel": "Добавить", "removeLabel": "Удалить", "renewLabel": "Продлить", "aiMax": { "label": "AI Max", "description": "Разблокируйте неограниченный AI и продвинутые модели", "activeDescription": "Следующий счет оплачивается {}", "canceledDescription": "AI Max будет доступен до {}" }, "aiOnDevice": { "label": "AI на устройстве для Mac", "description": "Разблокируйте неограниченный AI на вашем устройстве", "activeDescription": "Следующий счет оплачивается {}", "canceledDescription": "AI на устройстве для Mac будет доступен до {}" }, "removeDialog": { "title": "Удалить {}", "description": "Вы уверены, что хотите удалить {plan}? Вы сразу же потеряете доступ к функциям и преимуществам {plan}." } }, "currentPeriodBadge": "ТЕКУЩИЙ", "changePeriod": "Изменить период", "planPeriod": "{} период", "monthlyInterval": "Ежемесячно", "monthlyPriceInfo": "за место ежемесячно", "annualInterval": "Ежегодно", "annualPriceInfo": "за место ежегодно" }, "comparePlanDialog": { "title": "Сравнить и выбрать план", "planFeatures": "План\nФункции", "current": "Текущий", "actions": { "upgrade": "Обновить", "downgrade": "Понизить", "current": "Текущий" }, "freePlan": { "title": "Бесплатно", "description": "Для индивидуального использования до 2 участников для организации всего", "price": "{}", "priceInfo": "Бесплатно навсегда" }, "proPlan": { "title": "Pro", "description": "Для небольших команд для управления проектами и знаниями команды", "price": "{}", "priceInfo": "За пользователя в месяц\nпри ежегодной оплате\n\n{} при ежемесячной оплате" }, "planLabels": { "itemOne": "Рабочие пространства", "itemTwo": "Участники", "itemThree": "Хранилище", "itemFour": "Совместная работа в реальном времени", "itemFive": "Мобильное приложение", "itemSix": "Ответы AI", "itemSeven": "Изображения AI", "itemFileUpload": "Загрузка файлов", "customNamespace": "Пользовательское пространство имен", "tooltipSix": "Пожизненно означает, что количество ответов никогда не сбрасывается", "intelligentSearch": "Интеллектуальный поиск", "tooltipSeven": "Позволяет настроить часть URL для вашего рабочего пространства", "customNamespaceTooltip": "Пользовательский URL опубликованного сайта" }, "freeLabels": { "itemOne": "Оплата за рабочее пространство", "itemTwo": "До 2", "itemThree": "5 ГБ", "itemFour": "да", "itemFive": "да", "itemSix": "10 пожизненно", "itemSeven": "2 пожизненно", "itemFileUpload": "До 7 МБ", "intelligentSearch": "Интеллектуальный поиск" }, "proLabels": { "itemOne": "Оплата за рабочее пространство", "itemTwo": "До 10", "itemThree": "Неограниченно", "itemFour": "да", "itemFive": "да", "itemSix": "Неограниченно", "itemSeven": "10 изображений в месяц", "itemFileUpload": "Неограниченно", "intelligentSearch": "Интеллектуальный поиск" }, "paymentSuccess": { "title": "Вы теперь на плане {}!", "description": "Ваш платеж успешно обработан, и ваш план обновлен до AppFlowy {}. Вы можете просмотреть детали вашего плана на странице Плана" }, "downgradeDialog": { "title": "Вы уверены, что хотите понизить план?", "description": "Понижение плана приведет вас обратно к бесплатному плану. Участники могут потерять доступ к этому рабочему пространству, и вам может потребоваться освободить место, чтобы соответствовать лимитам хранилища бесплатного плана.", "downgradeLabel": "Понизить план" } }, "cancelSurveyDialog": { "title": "Жаль видеть, что вы уходите", "description": "Нам жаль, что вы уходите. Мы будем рады услышать ваш отзыв, чтобы помочь нам улучшить @:appName. Пожалуйста, уделите минутку, чтобы ответить на несколько вопросов.", "commonOther": "Другое", "otherHint": "Напишите свой ответ здесь", "questionOne": { "question": "Что побудило вас отменить подписку на @:appName Pro?", "answerOne": "Слишком высокая стоимость", "answerTwo": "Функции не соответствовали ожиданиям", "answerThree": "Нашел лучшую альтернативу", "answerFour": "Использовал недостаточно для оправдания расходов", "answerFive": "Проблема с сервисом или технические трудности" }, "questionTwo": { "question": "Насколько вероятно, что вы рассмотрите возможность повторной подписки на @:appName Pro в будущем?", "answerOne": "Очень вероятно", "answerTwo": "Несколько вероятно", "answerThree": "Не уверен", "answerFour": "Маловероятно", "answerFive": "Очень маловероятно" }, "questionThree": { "question": "Какую функцию Pro вы ценили больше всего во время подписки?", "answerOne": "Совместная работа нескольких пользователей", "answerTwo": "Более долгая история версий", "answerThree": "Неограниченные ответы AI", "answerFour": "Доступ к локальным моделям AI" }, "questionFour": { "question": "Как бы вы описали свой общий опыт использования @:appName?", "answerOne": "Отлично", "answerTwo": "Хорошо", "answerThree": "Средне", "answerFour": "Ниже среднего", "answerFive": "Недоволен" } }, "common": { "uploadingFile": "Файл загружается. Пожалуйста, не закрывайте приложение", "uploadNotionSuccess": "Ваш zip-файл Notion успешно загружен. Как только импорт завершится, вы получите письмо с подтверждением", "reset": "Сбросить" }, "menu": { "appearance": "Внешний вид", "language": "Язык", "user": "Пользователь", "files": "Файлы", "notifications": "Уведомления", "open": "Открыть настройки", "logout": "Выйти", "logoutPrompt": "Вы уверены, что хотите выйти?", "selfEncryptionLogoutPrompt": "Вы уверены, что хотите выйти? Пожалуйста, убедитесь, что вы скопировали секрет шифрования", "syncSetting": "Настройки синхронизации", "cloudSettings": "Настройки облака", "enableSync": "Включить синхронизацию", "enableSyncLog": "Включить логирование синхронизации", "enableSyncLogWarning": "Спасибо за помощь в диагностике проблем синхронизации. Это будет записывать ваши изменения документов в локальный файл. Пожалуйста, закройте и снова откройте приложение после включения", "enableEncrypt": "Зашифровать данные", "cloudURL": "Базовый URL", "webURL": "Веб URL", "invalidCloudURLScheme": "Недействительная схема", "cloudServerType": "Тип облачного сервера", "cloudServerTypeTip": "Обратите внимание, что после смены облачного сервера может потребоваться выход из текущей учетной записи", "cloudLocal": "Локальный", "cloudAppFlowy": "AppFlowy Cloud", "cloudAppFlowySelfHost": "AppFlowy Cloud Self-hosted", "appFlowyCloudUrlCanNotBeEmpty": "URL облака не может быть пустым", "clickToCopy": "Скопировать в буфер обмена", "selfHostStart": "Если у вас нет сервера, пожалуйста, обратитесь к", "selfHostContent": "документации", "selfHostEnd": "для руководства по самостоятельному размещению своего сервера", "pleaseInputValidURL": "Пожалуйста, введите действительный URL", "changeUrl": "Изменить URL самостоятельного хостинга на {}", "cloudURLHint": "Введите базовый URL вашего сервера", "webURLHint": "Введите базовый URL вашего веб-сервера", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Введите адрес websocket вашего сервера", "restartApp": "Перезапустить", "restartAppTip": "Перезапустите приложение, чтобы изменения вступили в силу. Обратите внимание, что это может привести к выходу из текущей учетной записи.", "changeServerTip": "После смены сервера необходимо нажать кнопку перезапуска, чтобы изменения вступили в силу", "enableEncryptPrompt": "Активируйте шифрование для защиты ваших данных с помощью этого секрета. Храните его в надежном месте; после включения его нельзя отключить. В случае потери ваши данные станут недоступными. Нажмите, чтобы скопировать", "inputEncryptPrompt": "Пожалуйста, введите секрет шифрования для", "clickToCopySecret": "Нажмите, чтобы скопировать секрет", "configServerSetting": "Настройте параметры вашего сервера", "configServerGuide": "После выбора «Быстрый старт», перейдите в «Настройки», затем «Настройки облака», чтобы настроить свой самостоятельно размещенный сервер.", "inputTextFieldHint": "Ваш секрет", "historicalUserList": "История входов пользователей", "historicalUserListTooltip": "Этот список отображает ваши анонимные учетные записи. Вы можете нажать на учетную запись, чтобы просмотреть ее детали. Анонимные учетные записи создаются при нажатии кнопки «Начать»", "openHistoricalUser": "Нажмите, чтобы открыть анонимную учетную запись", "customPathPrompt": "Хранение папки данных @:appName в облачной синхронизируемой папке, такой как Google Drive, может представлять риски. Если доступ к базе данных в этой папке или ее изменение осуществляется из нескольких мест одновременно, это может привести к конфликтам синхронизации и потенциальному повреждению данных.", "importAppFlowyData": "Импорт данных из внешней папки @:appName", "importingAppFlowyDataTip": "Идет импорт данных. Пожалуйста, не закрывайте приложение", "importAppFlowyDataDescription": "Копирование данных из внешней папки данных @:appName и импорт их в текущую папку данных AppFlowy", "importSuccess": "Папка данных @:appName успешно импортирована", "importFailed": "Не удалось импортировать папку данных @:appName", "importGuide": "Для получения более подробной информации, пожалуйста, ознакомьтесь с указанным документом" }, "notifications": { "enableNotifications": { "label": "Включить уведомления", "hint": "Отключите, чтобы остановить появление локальных уведомлений." }, "showNotificationsIcon": { "label": "Показать значок уведомлений", "hint": "Отключите, чтобы скрыть значок уведомлений на боковой панели." }, "archiveNotifications": { "allSuccess": "Все уведомления успешно заархивированы", "success": "Уведомление успешно заархивировано" }, "markAsReadNotifications": { "allSuccess": "Все отмечены как прочитанные успешно", "success": "Отмечено как прочитанное успешно" }, "action": { "markAsRead": "Отметить как прочитанное", "multipleChoice": "Выбрать больше", "archive": "Архивировать" }, "settings": { "settings": "Настройки", "markAllAsRead": "Отметить все как прочитанное", "archiveAll": "Архивировать все" }, "emptyInbox": { "title": "Ящик пуст!", "description": "Установите напоминания, чтобы получать уведомления здесь." }, "emptyUnread": { "title": "Нет непрочитанных уведомлений", "description": "Вы все прочитали!" }, "emptyArchived": { "title": "Нет заархивированных", "description": "Заархивированные уведомления появятся здесь." }, "tabs": { "inbox": "Входящие", "unread": "Непрочитанные", "archived": "Архивированные" }, "refreshSuccess": "Уведомления успешно обновлены", "titles": { "notifications": "Уведомления", "reminder": "Напоминание" } }, "appearance": { "resetSetting": "Сбросить", "fontFamily": { "label": "Семейство шрифтов", "search": "Поиск", "defaultFont": "Системный" }, "themeMode": { "label": "Режим темы", "light": "Светлый режим", "dark": "Темный режим", "system": "Адаптироваться к системе" }, "fontScaleFactor": "Коэффициент масштабирования шрифта", "displaySize": "Размер отображения", "documentSettings": { "cursorColor": "Цвет курсора документа", "selectionColor": "Цвет выделения документа", "width": "Ширина документа", "changeWidth": "Изменить", "pickColor": "Выберите цвет", "colorShade": "Оттенок цвета", "opacity": "Непрозрачность", "hexEmptyError": "Значение Hex не может быть пустым", "hexLengthError": "Значение Hex должно состоять из 6 цифр", "hexInvalidError": "Недопустимое значение Hex", "opacityEmptyError": "Непрозрачность не может быть пустой", "opacityRangeError": "Непрозрачность должна быть от 1 до 100", "app": "Приложение", "flowy": "Flowy", "apply": "Применить" }, "layoutDirection": { "label": "Направление макета", "hint": "Управляйте потоком контента на экране, слева направо или справа налево.", "ltr": "Слева направо", "rtl": "Справа налево" }, "textDirection": { "label": "Направление текста по умолчанию", "hint": "Укажите, должно ли текст начинаться слева или справа по умолчанию.", "ltr": "Слева направо", "rtl": "Справа налево", "auto": "АВТО", "fallback": "Как направление макета" }, "themeUpload": { "button": "Загрузить", "uploadTheme": "Загрузить тему", "description": "Загрузите свою собственную тему @:appName, используя кнопку ниже.", "loading": "Пожалуйста, подождите, пока мы проверим и загрузим вашу тему...", "uploadSuccess": "Ваша тема успешно загружена", "deletionFailure": "Не удалось удалить тему. Попробуйте удалить ее вручную.", "filePickerDialogTitle": "Выберите файл .flowy_plugin", "urlUploadFailure": "Не удалось открыть url: {}" }, "theme": "Тема", "builtInsLabel": "Встроенные темы", "pluginsLabel": "Плагины", "dateFormat": { "label": "Формат даты", "local": "Локальный", "us": "США", "iso": "ISO", "friendly": "Удобный", "dmy": "Д/М/Г" }, "timeFormat": { "label": "Формат времени", "twelveHour": "12-часовой", "twentyFourHour": "24-часовой" }, "showNamingDialogWhenCreatingPage": "Показывать диалог именования при создании страницы", "enableRTLToolbarItems": "Включить элементы панели инструментов RTL", "members": { "title": "Участники", "inviteMembers": "Пригласить участников", "inviteHint": "Пригласить по email", "sendInvite": "Пригласить", "copyInviteLink": "Скопировать ссылку для приглашения", "label": "Участники", "user": "Пользователь", "role": "Роль", "removeFromWorkspace": "Удалить из рабочего пространства", "removeFromWorkspaceSuccess": "Успешно удалено из рабочего пространства", "removeFromWorkspaceFailed": "Не удалось удалить из рабочего пространства", "owner": "Владелец", "guest": "Гость", "member": "Участник", "memberHintText": "Участник может читать и редактировать страницы", "guestHintText": "Гость может читать, реагировать, комментировать и редактировать определенные страницы с разрешением.", "emailInvalidError": "Недействительный email, пожалуйста, проверьте и попробуйте снова", "emailSent": "Email отправлен, пожалуйста, проверьте входящие", "members": "участников", "membersCount": { "zero": "{} участников", "one": "{} участник", "other": "{} участников" }, "inviteFailedDialogTitle": "Не удалось отправить приглашение", "inviteFailedMemberLimit": "Достигнут лимит участников, пожалуйста, обновитесь, чтобы пригласить больше.", "inviteFailedMemberLimitMobile": "Ваше рабочее пространство достигло лимита участников.", "memberLimitExceeded": "Достигнут лимит участников, чтобы пригласить больше, пожалуйста,", "memberLimitExceededUpgrade": "обновитесь", "memberLimitExceededPro": "Достигнут лимит участников, если вам нужно больше участников, свяжитесь с", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Не удалось добавить участника", "addMemberSuccess": "Участник успешно добавлен", "removeMember": "Удалить участника", "areYouSureToRemoveMember": "Вы уверены, что хотите удалить этого участника?", "inviteMemberSuccess": "Приглашение успешно отправлено", "failedToInviteMember": "Не удалось пригласить участника", "workspaceMembersError": "Ой, что-то пошло не так", "workspaceMembersErrorDescription": "Не удалось загрузить список участников сейчас. Пожалуйста, попробуйте позже", "inviteLinkToAddMember": "Ссылка для приглашения участника", "clickToCopyLink": "Нажмите, чтобы скопировать ссылку", "or": "или", "generateANewLink": "сгенерировать новую ссылку", "inviteMemberByEmail": "Пригласить участника по email", "inviteMemberHintText": "Пригласить по email", "resetInviteLink": "Сбросить ссылку для приглашения?", "resetInviteLinkDescription": "Сброс деактивирует текущую ссылку для всех участников пространства и сгенерирует новую. Старая ссылка больше не будет доступна.", "adminPanel": "Панель администратора", "reset": "Сбросить", "resetInviteLinkSuccess": "Ссылка для приглашения успешно сброшена", "resetInviteLinkFailed": "Не удалось сбросить ссылку для приглашения", "resetInviteLinkFailedDescription": "Пожалуйста, попробуйте позже", "memberPageDescription1": "Доступ к", "memberPageDescription2": "для управления гостями и продвинутыми пользователями.", "noInviteLink": "Вы еще не сгенерировали ссылку для приглашения.", "copyLink": "Скопировать ссылку" } }, "files": { "copy": "Копировать", "defaultLocation": "Местоположение хранилища файлов и данных", "exportData": "Экспортировать ваши данные", "doubleTapToCopy": "Дважды коснитесь, чтобы скопировать путь", "restoreLocation": "Восстановить путь по умолчанию @:appName", "customizeLocation": "Открыть другую папку", "restartApp": "Пожалуйста, перезапустите приложение, чтобы изменения вступили в силу.", "exportDatabase": "Экспортировать базу данных", "selectFiles": "Выберите файлы для экспорта", "selectAll": "Выбрать все", "deselectAll": "Отменить выбор всех", "createNewFolder": "Создать новую папку", "createNewFolderDesc": "Укажите, где вы хотите хранить свои данные", "defineWhereYourDataIsStored": "Определите, где хранятся ваши данные", "open": "Открыть", "openFolder": "Открыть существующую папку", "openFolderDesc": "Читать и записывать в существующую папку @:appName", "folderHintText": "название папки", "location": "Создание новой папки", "locationDesc": "Выберите имя для папки данных @:appName", "browser": "Обзор", "create": "Создать", "set": "Установить", "folderPath": "Путь для хранения вашей папки", "locationCannotBeEmpty": "Путь не может быть пустым", "pathCopiedSnackbar": "Путь к хранилищу файлов скопирован в буфер обмена!", "changeLocationTooltips": "Изменить каталог данных", "change": "Изменить", "openLocationTooltips": "Открыть другой каталог данных", "openCurrentDataFolder": "Открыть текущий каталог данных", "recoverLocationTooltips": "Сбросить до каталога данных по умолчанию @:appName", "exportFileSuccess": "Файл успешно экспортирован!", "exportFileFail": "Экспорт файла не удался!", "export": "Экспорт", "clearCache": "Очистить кеш", "clearCacheDesc": "Если вы столкнулись с проблемами загрузки изображений или отображения шрифтов, попробуйте очистить кеш. Это действие не приведет к удалению ваших пользовательских данных.", "areYouSureToClearCache": "Вы уверены, что хотите очистить кеш?", "clearCacheSuccess": "Кеш успешно очищен!" }, "user": { "name": "Имя", "email": "Email", "tooltipSelectIcon": "Выбрать иконку", "selectAnIcon": "Выберите иконку", "pleaseInputYourOpenAIKey": "пожалуйста, введите ваш ключ AI", "clickToLogout": "Нажмите, чтобы выйти из текущей учетной записи" }, "mobile": { "personalInfo": "Личная информация", "username": "Имя пользователя", "usernameEmptyError": "Имя пользователя не может быть пустым", "about": "О программе", "pushNotifications": "Push-уведомления", "support": "Поддержка", "joinDiscord": "Присоединяйтесь к нам в Discord", "privacyPolicy": "Политика конфиденциальности", "userAgreement": "Пользовательское соглашение", "termsAndConditions": "Правила и условия", "userprofileError": "Не удалось загрузить профиль пользователя", "userprofileErrorDescription": "Пожалуйста, попробуйте выйти и снова войти, чтобы проверить, сохраняется ли проблема.", "selectLayout": "Выбрать макет", "selectStartingDay": "Выбрать день начала", "version": "Версия" } }, "grid": { "deleteView": "Вы уверены, что хотите удалить этот вид?", "createView": "Новый", "title": { "placeholder": "Без названия" }, "settings": { "filter": "Фильтр", "sort": "Сортировка", "sortBy": "Сортировать по", "properties": "Свойства", "reorderPropertiesTooltip": "Перетащите, чтобы изменить порядок свойств", "group": "Группировать", "addFilter": "Добавить фильтр", "deleteFilter": "Удалить фильтр", "filterBy": "Фильтровать по", "typeAValue": "Введите значение...", "layout": "Макет", "compactMode": "Компактный режим", "databaseLayout": "Макет", "viewList": { "zero": "0 видов", "one": "{count} вид", "other": "{count} видов" }, "editView": "Редактировать вид", "boardSettings": "Настройки доски", "calendarSettings": "Настройки календаря", "createView": "Новый вид", "duplicateView": "Дублировать вид", "deleteView": "Удалить вид", "numberOfVisibleFields": "{} показано" }, "filter": { "empty": "Нет активных фильтров", "addFilter": "Добавить фильтр", "cannotFindCreatableField": "Не удалось найти подходящее поле для фильтрации", "conditon": "Условие", "where": "Где" }, "textFilter": { "contains": "Содержит", "doesNotContain": "Не содержит", "endsWith": "Заканчивается на", "startWith": "Начинается с", "is": "Есть", "isNot": "Нет", "isEmpty": "Пусто", "isNotEmpty": "Не пусто", "choicechipPrefix": { "isNot": "Не", "startWith": "Начинается с", "endWith": "Заканчивается на", "isEmpty": "пусто", "isNotEmpty": "не пусто" } }, "checkboxFilter": { "isChecked": "Отмечено", "isUnchecked": "Не отмечено", "choicechipPrefix": { "is": "есть" } }, "checklistFilter": { "isComplete": "Завершено", "isIncomplted": "Не завершено" }, "selectOptionFilter": { "is": "Есть", "isNot": "Нет", "contains": "Содержит", "doesNotContain": "Не содержит", "isEmpty": "Пусто", "isNotEmpty": "Не пусто" }, "dateFilter": { "is": "Приходится на", "before": "До", "after": "После", "onOrBefore": "Приходится на или до", "onOrAfter": "Приходится на или после", "between": "Между", "empty": "Пусто", "notEmpty": "Не пусто", "startDate": "Дата начала", "endDate": "Дата окончания", "choicechipPrefix": { "before": "До", "after": "После", "between": "Между", "onOrBefore": "На или до", "onOrAfter": "На или после", "isEmpty": "Пусто", "isNotEmpty": "Не пусто" } }, "numberFilter": { "equal": "Равно", "notEqual": "Не равно", "lessThan": "Меньше чем", "greaterThan": "Больше чем", "lessThanOrEqualTo": "Меньше или равно", "greaterThanOrEqualTo": "Больше или равно", "isEmpty": "Пусто", "isNotEmpty": "Не пусто" }, "field": { "label": "Свойство", "hide": "Скрыть свойство", "show": "Показать свойство", "insertLeft": "Вставить слева", "insertRight": "Вставить справа", "duplicate": "Дублировать", "delete": "Удалить", "wrapCellContent": "Переносить текст", "clear": "Очистить ячейки", "switchPrimaryFieldTooltip": "Невозможно изменить тип первичного поля", "textFieldName": "Текст", "checkboxFieldName": "Чекбокс", "dateFieldName": "Дата", "updatedAtFieldName": "Последнее изменение", "createdAtFieldName": "Дата создания", "numberFieldName": "Числа", "singleSelectFieldName": "Выбор", "multiSelectFieldName": "Множественный выбор", "urlFieldName": "URL", "checklistFieldName": "Список задач", "relationFieldName": "Связь", "summaryFieldName": "AI Сводка", "timeFieldName": "Время", "mediaFieldName": "Файлы и медиа", "translateFieldName": "AI Перевод", "translateTo": "Перевести на", "numberFormat": "Формат чисел", "dateFormat": "Формат даты", "includeTime": "Включать время", "isRange": "Дата окончания", "dateFormatFriendly": "Месяц День, Год", "dateFormatISO": "Год-Месяц-День", "dateFormatLocal": "Месяц/День/Год", "dateFormatUS": "Год/Месяц/День", "dateFormatDayMonthYear": "День/Месяц/Год", "timeFormat": "Формат времени", "invalidTimeFormat": "Неверный формат", "timeFormatTwelveHour": "12 часов", "timeFormatTwentyFourHour": "24 часа", "clearDate": "Очистить дату", "dateTime": "Дата время", "startDateTime": "Дата и время начала", "endDateTime": "Дата и время окончания", "failedToLoadDate": "Не удалось загрузить значение даты", "selectTime": "Выберите время", "selectDate": "Выберите дату", "visibility": "Видимость", "propertyType": "Тип свойства", "addSelectOption": "Добавить вариант", "typeANewOption": "Введите новый вариант", "optionTitle": "Варианты", "addOption": "Добавить вариант", "editProperty": "Редактировать свойство", "newProperty": "Новое свойство", "openRowDocument": "Открыть как страницу", "deleteFieldPromptMessage": "Вы уверены? Это свойство и все его данные будут удалены", "clearFieldPromptMessage": "Вы уверены? Все ячейки в этом столбце будут очищены", "newColumn": "Новый столбец", "format": "Формат", "reminderOnDateTooltip": "Эта ячейка имеет запланированное напоминание", "optionAlreadyExist": "Вариант уже существует" }, "rowPage": { "newField": "Добавить новое поле", "fieldDragElementTooltip": "Нажмите, чтобы открыть меню", "showHiddenFields": { "one": "Показать {count} скрытое поле", "many": "Показать {count} скрытых полей", "other": "Показать {count} скрытых полей" }, "hideHiddenFields": { "one": "Скрыть {count} скрытое поле", "many": "Скрыть {count} скрытых полей", "other": "Скрыть {count} скрытых полей" }, "openAsFullPage": "Открыть как полную страницу", "moreRowActions": "Больше действий со строкой" }, "sort": { "ascending": "По возрастанию", "descending": "По убыванию", "by": "По", "empty": "Нет активных сортировок", "cannotFindCreatableField": "Не удалось найти подходящее поле для сортировки", "deleteAllSorts": "Удалить все сортировки", "addSort": "Добавить сортировку", "sortsActive": "Невозможно {intention} при сортировке", "removeSorting": "Вы хотите удалить все сортировки в этом виде и продолжить?", "fieldInUse": "Вы уже сортируете по этому полю" }, "row": { "label": "Строка", "duplicate": "Дублировать", "delete": "Удалить", "titlePlaceholder": "Без названия", "textPlaceholder": "Пусто", "copyProperty": "Свойство скопировано в буфер обмена", "count": "Количество", "newRow": "Новая строка", "loadMore": "Загрузить еще", "action": "Действие", "add": "Нажмите, чтобы добавить ниже", "drag": "Перетащите, чтобы переместить", "deleteRowPrompt": "Вы уверены, что хотите удалить эту строку? Это действие нельзя отменить.", "deleteCardPrompt": "Вы уверены, что хотите удалить эту карточку? Это действие нельзя отменить.", "dragAndClick": "Перетащите, чтобы переместить, нажмите, чтобы открыть меню", "insertRecordAbove": "Вставить запись выше", "insertRecordBelow": "Вставить запись ниже", "noContent": "Нет содержимого", "reorderRowDescription": "изменить порядок строки", "createRowAboveDescription": "создать строку выше", "createRowBelowDescription": "вставить строку ниже" }, "selectOption": { "create": "Создать", "purpleColor": "Фиолетовый", "pinkColor": "Розовый", "lightPinkColor": "Светло-розовый", "orangeColor": "Оранжевый", "yellowColor": "Желтый", "limeColor": "Лайм", "greenColor": "Зеленый", "aquaColor": "Аква", "blueColor": "Синий", "deleteTag": "Удалить тег", "colorPanelTitle": "Цвет", "panelTitle": "Выберите вариант или создайте новый", "searchOption": "Поиск варианта", "searchOrCreateOption": "Поиск варианта или создание нового", "createNew": "Создать новый", "orSelectOne": "Или выберите вариант", "typeANewOption": "Введите новый вариант", "tagName": "Название тега" }, "checklist": { "taskHint": "Описание задачи", "addNew": "Добавить новую задачу", "submitNewTask": "Создать", "hideComplete": "Скрыть выполненные задачи", "showComplete": "Показать все задачи" }, "url": { "launch": "Открыть ссылку в браузере", "copy": "Скопировать ссылку в буфер обмена", "textFieldHint": "Введите URL" }, "relation": { "relatedDatabasePlaceLabel": "Связанная база данных", "relatedDatabasePlaceholder": "Нет", "inRelatedDatabase": "В", "rowSearchTextFieldPlaceholder": "Поиск", "noDatabaseSelected": "База данных не выбрана, пожалуйста, выберите ее из списка ниже:", "emptySearchResult": "Результаты не найдены", "linkedRowListLabel": "{count} связанных строк", "unlinkedRowListLabel": "Связать другую строку" }, "menuName": "Таблица", "referencedGridPrefix": "Вид", "calculate": "Вычислить", "calculationTypeLabel": { "none": "Нет", "average": "Среднее", "max": "Макс.", "median": "Медиана", "min": "Мин.", "sum": "Сумма", "count": "Количество", "countEmpty": "Количество пустых", "countEmptyShort": "ПУСТО", "countNonEmpty": "Количество не пустых", "countNonEmptyShort": "ЗАПОЛНЕНО" }, "media": { "rename": "Переименовать", "download": "Скачать", "expand": "Развернуть", "delete": "Удалить", "moreFilesHint": "+{}", "addFileOrImage": "Добавить файл или ссылку", "attachmentsHint": "{}", "addFileMobile": "Добавить файл", "extraCount": "+{}", "deleteFileDescription": "Вы уверены, что хотите удалить этот файл? Это действие необратимо.", "showFileNames": "Показать имена файлов", "downloadSuccess": "Файл скачан", "downloadFailedToken": "Не удалось скачать файл, токен пользователя недоступен", "setAsCover": "Установить как обложку", "openInBrowser": "Открыть в браузере", "embedLink": "Встроить ссылку на файл" } }, "document": { "menuName": "Документ", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "Создается...", "slashMenu": { "board": { "selectABoardToLinkTo": "Выберите доску для ссылки", "createANewBoard": "Создать новую доску" }, "grid": { "selectAGridToLinkTo": "Выберите таблицу для ссылки", "createANewGrid": "Создать новую таблицу" }, "calendar": { "selectACalendarToLinkTo": "Выберите календарь для ссылки", "createANewCalendar": "Создать новый календарь" }, "document": { "selectADocumentToLinkTo": "Выберите документ для ссылки" }, "name": { "textStyle": "Стиль текста", "list": "Список", "toggle": "Переключатель", "fileAndMedia": "Файл и медиа", "simpleTable": "Простая таблица", "visuals": "Визуальные элементы", "document": "Документ", "advanced": "Дополнительно", "text": "Текст", "heading1": "Заголовок 1", "heading2": "Заголовок 2", "heading3": "Заголовок 3", "image": "Изображение", "bulletedList": "Маркированный список", "numberedList": "Нумерованный список", "todoList": "Список дел", "doc": "Документ", "linkedDoc": "Ссылка на страницу", "grid": "Таблица", "linkedGrid": "Связанная таблица", "kanban": "Канбан", "linkedKanban": "Связанный Канбан", "calendar": "Календарь", "linkedCalendar": "Связанный календарь", "quote": "Цитата", "divider": "Разделитель", "table": "Таблица", "callout": "Врезка", "outline": "Структура", "mathEquation": "Математическое уравнение", "code": "Код", "toggleList": "Список-переключатель", "toggleHeading1": "Переключатель заголовка 1", "toggleHeading2": "Переключатель заголовка 2", "toggleHeading3": "Переключатель заголовка 3", "emoji": "Эмодзи", "aiWriter": "Спросить AI что угодно", "dateOrReminder": "Дата или напоминание", "photoGallery": "Фотогалерея", "file": "Файл", "twoColumns": "2 столбца", "threeColumns": "3 столбца", "fourColumns": "4 столбца" }, "subPage": { "name": "Документ", "keyword1": "подстраница", "keyword2": "страница", "keyword3": "дочерняя страница", "keyword4": "вставить страницу", "keyword5": "встроить страницу", "keyword6": "новая страница", "keyword7": "создать страницу", "keyword8": "документ" } }, "selectionMenu": { "outline": "Структура", "codeBlock": "Блок кода" }, "plugins": { "referencedBoard": "Связанная доска", "referencedGrid": "Связанная таблица", "referencedCalendar": "Связанный календарь", "referencedDocument": "Связанный документ", "aiWriter": { "userQuestion": "Спросить AI что угодно", "continueWriting": "Продолжить писать", "fixSpelling": "Исправить орфографию и грамматику", "improveWriting": "Улучшить написание", "summarize": "Сводка", "explain": "Объяснить", "makeShorter": "Сделать короче", "makeLonger": "Сделать длиннее" }, "autoGeneratorMenuItemName": "AI Писатель", "autoGeneratorTitleName": "AI: Попросить AI написать что угодно...", "autoGeneratorLearnMore": "Узнать больше", "autoGeneratorGenerate": "Сгенерировать", "autoGeneratorHintText": "Спросить AI...", "autoGeneratorCantGetOpenAIKey": "Не удается получить ключ AI", "autoGeneratorRewrite": "Переписать", "smartEdit": "Спросить AI", "aI": "AI", "smartEditFixSpelling": "Исправить орфографию и грамматику", "warning": "⚠️ Ответы AI могут быть неточными или вводящими в заблуждение.", "smartEditSummarize": "Сводка", "smartEditImproveWriting": "Улучшить написание", "smartEditMakeLonger": "Сделать длиннее", "smartEditCouldNotFetchResult": "Не удалось получить результат от AI", "smartEditCouldNotFetchKey": "Не удалось получить ключ AI", "smartEditDisabled": "Подключить AI в настройках", "appflowyAIEditDisabled": "Войдите, чтобы включить функции AI", "discardResponse": "Вы уверены, что хотите отменить ответ AI?", "createInlineMathEquation": "Создать уравнение", "fonts": "Шрифты", "insertDate": "Вставить дату", "emoji": "Эмодзи", "toggleList": "Список-переключатель", "emptyToggleHeading": "Пустой заголовок-переключатель h{}. Нажмите, чтобы добавить содержимое.", "emptyToggleList": "Пустой список-переключатель. Нажмите, чтобы добавить содержимое.", "emptyToggleHeadingWeb": "Пустой заголовок-переключатель h{level}. Нажмите, чтобы добавить содержимое", "quoteList": "Список цитат", "numberedList": "Нумерованный список", "bulletedList": "Маркированный список", "todoList": "Список дел", "callout": "Врезка", "simpleTable": { "moreActions": { "color": "Цвет", "align": "Выровнять", "delete": "Удалить", "duplicate": "Дублировать", "insertLeft": "Вставить слева", "insertRight": "Вставить справа", "insertAbove": "Вставить выше", "insertBelow": "Вставить ниже", "headerColumn": "Столбец заголовка", "headerRow": "Строка заголовка", "clearContents": "Очистить содержимое", "setToPageWidth": "Установить ширину страницы", "distributeColumnsWidth": "Распределить ширину столбцов равномерно", "duplicateRow": "Дублировать строку", "duplicateColumn": "Дублировать столбец", "textColor": "Цвет текста", "cellBackgroundColor": "Цвет фона ячейки", "duplicateTable": "Дублировать таблицу" }, "clickToAddNewRow": "Нажмите, чтобы добавить новую строку", "clickToAddNewColumn": "Нажмите, чтобы добавить новый столбец", "clickToAddNewRowAndColumn": "Нажмите, чтобы добавить новую строку и столбец", "headerName": { "table": "Таблица", "alignText": "Выровнять текст" } }, "cover": { "changeCover": "Сменить обложку", "colors": "Цвета", "images": "Изображения", "clearAll": "Очистить все", "abstract": "Абстракция", "addCover": "Добавить обложку", "addLocalImage": "Добавить локальное изображение", "invalidImageUrl": "Недействительный URL изображения", "failedToAddImageToGallery": "Не удалось добавить изображение в галерею", "enterImageUrl": "Введите URL изображения", "add": "Добавить", "back": "Назад", "saveToGallery": "Сохранить в галерею", "removeIcon": "Удалить иконку", "removeCover": "Удалить обложку", "pasteImageUrl": "Вставьте URL изображения", "or": "ИЛИ", "pickFromFiles": "Выбрать из файлов", "couldNotFetchImage": "Не удалось загрузить изображение", "imageSavingFailed": "Не удалось сохранить изображение", "addIcon": "Добавить иконку", "changeIcon": "Изменить иконку", "coverRemoveAlert": "Изображение будет удалено с обложки после удаления.", "alertDialogConfirmation": "Вы уверены, что хотите продолжить?" }, "mathEquation": { "name": "Математическое уравнение", "addMathEquation": "Добавить уравнение TeX", "editMathEquation": "Редактировать математическое уравнение" }, "optionAction": { "click": "Нажмите", "toOpenMenu": "чтобы открыть меню", "drag": "Перетащите", "toMove": "чтобы переместить", "delete": "Удалить", "duplicate": "Дублировать", "turnInto": "Превратить в", "moveUp": "Переместить вверх", "moveDown": "Переместить вниз", "color": "Цвет", "align": "Выровнять", "left": "По левому краю", "center": "По центру", "right": "По правому краю", "defaultColor": "По умолчанию", "depth": "Глубина", "copyLinkToBlock": "Скопировать ссылку на блок" }, "image": { "addAnImage": "Добавить изображения", "copiedToPasteBoard": "Ссылка на изображение скопирована в буфер обмена", "addAnImageDesktop": "Добавить изображение", "addAnImageMobile": "Нажмите, чтобы добавить одно или несколько изображений", "dropImageToInsert": "Перетащите изображения для вставки", "imageUploadFailed": "Не удалось загрузить изображение", "imageDownloadFailed": "Не удалось загрузить изображение, пожалуйста, попробуйте снова", "imageDownloadFailedToken": "Не удалось загрузить изображение из-за отсутствия токена пользователя, пожалуйста, попробуйте снова", "errorCode": "Код ошибки", "invalidImage": "Недействительное изображение", "invalidImageSize": "Размер изображения должен быть менее 5 МБ", "invalidImageFormat": "Формат изображения не поддерживается. Поддерживаемые форматы: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Недействительный URL изображения", "noImage": "Нет такого файла или каталога", "multipleImagesFailed": "Не удалось загрузить одно или несколько изображений, пожалуйста, попробуйте снова", "embedLink": { "label": "Встроить ссылку", "placeholder": "Вставьте или введите ссылку на изображение" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Поиск изображения", "pleaseInputYourOpenAIKey": "пожалуйста, введите ваш ключ AI на странице настроек", "saveImageToGallery": "Сохранить изображение", "failedToAddImageToGallery": "Не удалось сохранить изображение", "successToAddImageToGallery": "Изображение сохранено в Фото", "unableToLoadImage": "Невозможно загрузить изображение", "maximumImageSize": "Максимальный поддерживаемый размер загружаемого изображения - 10 МБ", "uploadImageErrorImageSizeTooBig": "Размер изображения должен быть меньше 10 МБ", "imageIsUploading": "Изображение загружается", "openFullScreen": "Открыть на весь экран", "interactiveViewer": { "toolbar": { "previousImageTooltip": "Предыдущее изображение", "nextImageTooltip": "Следующее изображение", "zoomOutTooltip": "Уменьшить", "zoomInTooltip": "Увеличить", "changeZoomLevelTooltip": "Изменить уровень масштабирования", "openLocalImage": "Открыть изображение", "downloadImage": "Скачать изображение", "closeViewer": "Закрыть интерактивный просмотрщик", "scalePercentage": "{}%", "deleteImageTooltip": "Удалить изображение" } } }, "photoGallery": { "name": "Фотогалерея", "imageKeyword": "изображение", "imageGalleryKeyword": "галерея изображений", "photoKeyword": "фото", "photoBrowserKeyword": "фотобраузер", "galleryKeyword": "галерея", "addImageTooltip": "Добавить изображение", "changeLayoutTooltip": "Изменить макет", "browserLayout": "Браузер", "gridLayout": "Сетка", "deleteBlockTooltip": "Удалить всю галерею" }, "math": { "copiedToPasteBoard": "Математическое уравнение скопировано в буфер обмена" }, "urlPreview": { "copiedToPasteBoard": "Ссылка скопирована в буфер обмена", "convertToLink": "Преобразовать в ссылку для встраивания" }, "outline": { "addHeadingToCreateOutline": "Добавьте заголовки для создания оглавления.", "noMatchHeadings": "Не найдено соответствующих заголовков." }, "table": { "addAfter": "Добавить после", "addBefore": "Добавить до", "delete": "Удалить", "clear": "Очистить содержимое", "duplicate": "Дублировать", "bgColor": "Цвет фона" }, "contextMenu": { "copy": "Копировать", "cut": "Вырезать", "paste": "Вставить", "pasteAsPlainText": "Вставить как обычный текст" }, "action": "Действия", "database": { "selectDataSource": "Выбрать источник данных", "noDataSource": "Нет источника данных", "selectADataSource": "Выберите источник данных", "toContinue": "для продолжения", "newDatabase": "Новая база данных", "linkToDatabase": "Ссылка на базу данных" }, "date": "Дата", "video": { "label": "Видео", "emptyLabel": "Добавить видео", "placeholder": "Вставьте ссылку на видео", "copiedToPasteBoard": "Ссылка на видео скопирована в буфер обмена", "insertVideo": "Добавить видео", "invalidVideoUrl": "Источник URL пока не поддерживается.", "invalidVideoUrlYouTube": "YouTube пока не поддерживается.", "supportedFormats": "Поддерживаемые форматы: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "Файл", "uploadTab": "Загрузить", "uploadMobile": "Выберите файл", "uploadMobileGallery": "Из фотогалереи", "networkTab": "Встроить ссылку", "placeholderText": "Загрузить или встроить файл", "placeholderDragging": "Перетащите файл для загрузки", "dropFileToUpload": "Перетащите файл для загрузки", "fileUploadHint": "Перетащите файл или нажмите, чтобы ", "fileUploadHintSuffix": "Обзор", "networkHint": "Вставьте ссылку на файл", "networkUrlInvalid": "Недействительный URL. Проверьте URL и попробуйте снова.", "networkAction": "Встроить", "fileTooBigError": "Размер файла слишком большой, пожалуйста, загрузите файл размером менее 10 МБ", "renameFile": { "title": "Переименовать файл", "description": "Введите новое имя для этого файла", "nameEmptyError": "Имя файла не может быть пустым." }, "uploadedAt": "Загружено {}", "linkedAt": "Ссылка добавлена {}", "failedToOpenMsg": "Не удалось открыть, файл не найден" }, "subPage": { "handlingPasteHint": " - (обработка вставки)", "errors": { "failedDeletePage": "Не удалось удалить страницу", "failedCreatePage": "Не удалось создать страницу", "failedMovePage": "Не удалось переместить страницу в этот документ", "failedDuplicatePage": "Не удалось дублировать страницу", "failedDuplicateFindView": "Не удалось дублировать страницу - оригинальный вид не найден" } }, "cannotMoveToItsChildren": "Невозможно переместить в его дочерние элементы", "linkPreview": { "typeSelection": { "pasteAs": "Вставить как", "mention": "Упоминание", "URL": "URL", "bookmark": "Закладка", "embed": "Встроить" }, "linkPreviewMenu": { "toMetion": "Преобразовать в упоминание", "toUrl": "Преобразовать в URL", "toEmbed": "Преобразовать во встраивание", "toBookmark": "Преобразовать в закладку", "copyLink": "Скопировать ссылку", "replace": "Заменить", "reload": "Перезагрузить", "removeLink": "Удалить ссылку", "pasteHint": "Вставить https://...", "unableToDisplay": "невозможно отобразить" } } }, "outlineBlock": { "placeholder": "Содержание" }, "textBlock": { "placeholder": "Введите '/' для команд" }, "title": { "placeholder": "Без названия" }, "imageBlock": { "placeholder": "Нажмите, чтобы добавить изображение(я)", "upload": { "label": "Загрузить", "placeholder": "Нажмите, чтобы загрузить изображение" }, "url": { "label": "URL изображения", "placeholder": "Введите URL изображения" }, "ai": { "label": "Сгенерировать изображение с помощью AI", "placeholder": "Пожалуйста, введите запрос для AI для генерации изображения" }, "stability_ai": { "label": "Сгенерировать изображение с помощью Stability AI", "placeholder": "Пожалуйста, введите запрос для Stability AI для генерации изображения" }, "support": "Размер изображения ограничен 5 МБ. Поддерживаемые форматы: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Недействительное изображение", "invalidImageSize": "Размер изображения должен быть меньше 5 МБ", "invalidImageFormat": "Формат изображения не поддерживается. Поддерживаемые форматы: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "Недействительный URL изображения", "noImage": "Нет такого файла или каталога", "multipleImagesFailed": "Одно или несколько изображений не удалось загрузить, пожалуйста, попробуйте снова" }, "embedLink": { "label": "Встроить ссылку", "placeholder": "Вставьте или введите ссылку на изображение" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Поиск изображения", "pleaseInputYourOpenAIKey": "пожалуйста, введите ваш ключ AI на странице настроек", "saveImageToGallery": "Сохранить изображение", "failedToAddImageToGallery": "Не удалось сохранить изображение", "successToAddImageToGallery": "Изображение сохранено в Фото", "unableToLoadImage": "Невозможно загрузить изображение", "maximumImageSize": "Максимальный поддерживаемый размер загружаемого изображения - 10 МБ", "uploadImageErrorImageSizeTooBig": "Размер изображения должен быть меньше 10 МБ", "imageIsUploading": "Изображение загружается", "openFullScreen": "Открыть на весь экран", "interactiveViewer": { "toolbar": { "previousImageTooltip": "Предыдущее изображение", "nextImageTooltip": "Следующее изображение", "zoomOutTooltip": "Уменьшить", "zoomInTooltip": "Увеличить", "changeZoomLevelTooltip": "Изменить уровень масштабирования", "openLocalImage": "Открыть изображение", "downloadImage": "Скачать изображение", "closeViewer": "Закрыть интерактивный просмотрщик", "scalePercentage": "{}%", "deleteImageTooltip": "Удалить изображение" } } }, "codeBlock": { "language": { "label": "Язык", "placeholder": "Выбрать язык", "auto": "Авто" }, "copyTooltip": "Копировать", "searchLanguageHint": "Поиск языка", "codeCopiedSnackbar": "Код скопирован в буфер обмена!" }, "inlineLink": { "placeholder": "Вставьте или введите ссылку", "openInNewTab": "Открыть в новой вкладке", "copyLink": "Скопировать ссылку", "removeLink": "Удалить ссылку", "url": { "label": "URL ссылки", "placeholder": "Введите URL ссылки" }, "title": { "label": "Название ссылки", "placeholder": "Введите название ссылки" } }, "mention": { "placeholder": "Упомянуть человека или страницу или дату...", "page": { "label": "Ссылка на страницу", "tooltip": "Нажмите, чтобы открыть страницу" }, "deleted": "Удалено", "deletedContent": "Этот контент не существует или был удален", "noAccess": "Нет доступа", "deletedPage": "Удаленная страница", "trashHint": " - в корзине", "morePages": "больше страниц" }, "toolbar": { "resetToDefaultFont": "Сбросить до шрифта по умолчанию", "textSize": "Размер текста", "textColor": "Цвет текста", "h1": "Заголовок 1", "h2": "Заголовок 2", "h3": "Заголовок 3", "alignLeft": "Выровнять по левому краю", "alignRight": "Выровнять по правому краю", "alignCenter": "Выровнять по центру", "link": "Ссылка", "textAlign": "Выравнивание текста", "moreOptions": "Больше опций", "font": "Шрифт", "inlineCode": "Встроенный код", "suggestions": "Предложения", "turnInto": "Превратить в", "equation": "Уравнение", "insert": "Вставить", "linkInputHint": "Вставьте ссылку или поиск по страницам", "pageOrURL": "Страница или URL", "linkName": "Название ссылки", "linkNameHint": "Введите название ссылки" }, "errorBlock": { "theBlockIsNotSupported": "Не удалось разобрать содержимое блока", "clickToCopyTheBlockContent": "Нажмите, чтобы скопировать содержимое блока", "blockContentHasBeenCopied": "Содержимое блока скопировано.", "parseError": "Произошла ошибка при разборе блока {}.", "copyBlockContent": "Скопировать содержимое блока" }, "mobilePageSelector": { "title": "Выбрать страницу", "failedToLoad": "Не удалось загрузить список страниц", "noPagesFound": "Страницы не найдены" }, "attachmentMenu": { "choosePhoto": "Выбрать фото", "takePicture": "Сделать снимок", "chooseFile": "Выбрать файл" } }, "board": { "column": { "label": "Столбец", "createNewCard": "Новый", "renameGroupTooltip": "Нажмите, чтобы переименовать группу", "createNewColumn": "Добавить новую группу", "addToColumnTopTooltip": "Добавить новую карточку в начало", "addToColumnBottomTooltip": "Добавить новую карточку в конец", "renameColumn": "Переименовать", "hideColumn": "Скрыть", "newGroup": "Новая группа", "deleteColumn": "Удалить", "deleteColumnConfirmation": "Это приведет к удалению этой группы и всех карточек в ней. Вы уверены, что хотите продолжить?" }, "hiddenGroupSection": { "sectionTitle": "Скрытые группы", "collapseTooltip": "Скрыть скрытые группы", "expandTooltip": "Просмотреть скрытые группы" }, "cardDetail": "Детали карточки", "cardActions": "Действия с карточкой", "cardDuplicated": "Карточка дублирована", "cardDeleted": "Карточка удалена", "showOnCard": "Показать на карточке", "setting": "Настройки", "propertyName": "Название свойства", "menuName": "Доска", "showUngrouped": "Показать негруппированные элементы", "ungroupedButtonText": "Негруппированные", "ungroupedButtonTooltip": "Содержит карточки, которые не принадлежат ни одной группе", "ungroupedItemsTitle": "Нажмите, чтобы добавить на доску", "groupBy": "Группировать по", "groupCondition": "Условие группировки", "referencedBoardPrefix": "Вид", "notesTooltip": "Заметки внутри", "mobile": { "editURL": "Редактировать URL", "showGroup": "Показать группу", "showGroupContent": "Вы уверены, что хотите показать эту группу на доске?", "failedToLoad": "Не удалось загрузить вид доски" }, "dateCondition": { "weekOf": "Неделя с {} по {}", "today": "Сегодня", "yesterday": "Вчера", "tomorrow": "Завтра", "lastSevenDays": "Последние 7 дней", "nextSevenDays": "Следующие 7 дней", "lastThirtyDays": "Последние 30 дней", "nextThirtyDays": "Следующие 30 дней" }, "noGroup": "Нет свойства для группировки", "noGroupDesc": "Виды доски требуют свойства для группировки для отображения", "media": { "cardText": "{} {}", "fallbackName": "файлы" } }, "calendar": { "menuName": "Календарь", "defaultNewCalendarTitle": "Без названия", "newEventButtonTooltip": "Добавить новое событие", "navigation": { "today": "Сегодня", "jumpToday": "Перейти на сегодня", "previousMonth": "Предыдущий месяц", "nextMonth": "Следующий месяц", "views": { "day": "День", "week": "Неделя", "month": "Месяц", "year": "Год" } }, "mobileEventScreen": { "emptyTitle": "Событий пока нет", "emptyBody": "Нажмите кнопку плюса, чтобы создать событие в этот день." }, "settings": { "showWeekNumbers": "Показать номера недель", "showWeekends": "Показать выходные", "firstDayOfWeek": "Начать неделю с", "layoutDateField": "Разместить календарь по", "changeLayoutDateField": "Изменить поле макета", "noDateTitle": "Нет даты", "noDateHint": { "zero": "Незапланированные события появятся здесь", "one": "{count} незапланированное событие", "other": "{count} незапланированных событий" }, "unscheduledEventsTitle": "Незапланированные события", "clickToAdd": "Нажмите, чтобы добавить в календарь", "name": "Настройки календаря", "clickToOpen": "Нажмите, чтобы открыть запись" }, "referencedCalendarPrefix": "Вид", "quickJumpYear": "Перейти на", "duplicateEvent": "Дублировать событие" }, "errorDialog": { "title": "Ошибка @:appName", "howToFixFallback": "Нам очень жаль! Сообщите о проблеме на нашей странице GitHub с описанием вашей ошибки.", "howToFixFallbackHint1": "Нам очень жаль! Сообщите о проблеме на нашей ", "howToFixFallbackHint2": " странице с описанием вашей ошибки.", "github": "Просмотреть на GitHub" }, "search": { "label": "Поиск", "sidebarSearchIcon": "Поиск и быстрый переход на страницу", "searchOrAskAI": "Поиск или спросить AI", "askAIAnything": "Спросить AI что угодно", "askAIFor": "Спросить AI", "noResultForSearching": "Нет результатов для \"{}\"", "noResultForSearchingHint": "Некоторые результаты могут находиться в удаленных страницах", "bestMatch": "Наилучшее соответствие", "placeholder": { "actions": "Поиск действий..." } }, "message": { "copy": { "success": "Скопировано в буфер обмена", "fail": "Невозможно скопировать" } }, "unSupportBlock": "Текущая версия не поддерживает этот блок.", "views": { "deleteContentTitle": "Вы уверены, что хотите удалить {pageType}?", "deleteContentCaption": "если вы удалите этот {pageType}, вы сможете восстановить его из корзины." }, "colors": { "custom": "Пользовательский", "default": "По умолчанию", "red": "Красный", "orange": "Оранжевый", "yellow": "Желтый", "green": "Зеленый", "blue": "Синий", "purple": "Фиолетовый", "pink": "Розовый", "brown": "Коричневый", "gray": "Серый" }, "emoji": { "emojiTab": "Эмодзи", "search": "Поиск эмодзи", "noRecent": "Нет последних эмодзи", "noEmojiFound": "Эмодзи не найдено", "filter": "Фильтр", "random": "Случайно", "selectSkinTone": "Выбрать оттенок кожи", "remove": "Удалить эмодзи", "categories": { "smileys": "Смайлики и эмоции", "people": "люди", "animals": "природа", "food": "еда", "activities": "занятия", "places": "места", "objects": "объекты", "symbols": "символы", "flags": "флаги", "nature": "природа", "frequentlyUsed": "часто используемые" }, "skinTone": { "default": "По умолчанию", "light": "Светлый", "mediumLight": "Средне-светлый", "medium": "Средний", "mediumDark": "Средне-темный", "dark": "Темный" }, "openSourceIconsFrom": "Иконки с открытым исходным кодом из" }, "inlineActions": { "noResults": "Нет результатов", "recentPages": "Последние страницы", "pageReference": "Ссылка на страницу", "docReference": "Ссылка на документ", "boardReference": "Ссылка на доску", "calReference": "Ссылка на календарь", "gridReference": "Ссылка на таблицу", "date": "Дата", "reminder": { "groupTitle": "Напоминание", "shortKeyword": "напомнить" }, "createPage": "Создать подстраницу \"{}\"" }, "datePicker": { "dateTimeFormatTooltip": "Изменить формат даты и времени в настройках", "dateFormat": "Формат даты", "includeTime": "Включать время", "isRange": "Дата окончания", "timeFormat": "Формат времени", "clearDate": "Очистить дату", "reminderLabel": "Напоминание", "selectReminder": "Выбрать напоминание", "reminderOptions": { "none": "Нет", "atTimeOfEvent": "Время события", "fiveMinsBefore": "За 5 минут до", "tenMinsBefore": "За 10 минут до", "fifteenMinsBefore": "За 15 минут до", "thirtyMinsBefore": "За 30 минут до", "oneHourBefore": "За 1 час до", "twoHoursBefore": "За 2 часа до", "onDayOfEvent": "В день события", "oneDayBefore": "За 1 день до", "twoDaysBefore": "За 2 дня до", "oneWeekBefore": "За 1 неделю до", "custom": "Пользовательский" } }, "relativeDates": { "yesterday": "Вчера", "today": "Сегодня", "tomorrow": "Завтра", "oneWeek": "1 неделя" }, "notificationHub": { "title": "Уведомления", "closeNotification": "Закрыть уведомление", "viewNotifications": "Просмотреть уведомления", "noNotifications": "Уведомлений пока нет", "mentionedYou": "Упомянул(а) вас", "markAsReadTooltip": "Отметить это уведомление как прочитанное", "markAsReadSucceedToast": "Отмечено как прочитанное успешно", "markAllAsReadSucceedToast": "Все успешно отмечено как прочитанное", "today": "Сегодня", "older": "Старше", "mobile": { "title": "Обновления" }, "emptyTitle": "Все прочитано!", "emptyBody": "Нет ожидающих уведомлений или действий. Наслаждайтесь спокойствием.", "tabs": { "inbox": "Входящие", "upcoming": "Предстоящие" }, "actions": { "markAllRead": "Отметить все как прочитанное", "showAll": "Все", "showUnreads": "Непрочитанные" }, "filters": { "ascending": "По возрастанию", "descending": "По убыванию", "groupByDate": "Группировать по дате", "showUnreadsOnly": "Показать только непрочитанные", "resetToDefault": "Сбросить до значений по умолчанию" }, "archievedTooltip": "Архивировать это уведомление", "unarchievedTooltip": "Разархивировать это уведомление", "markAsArchievedSucceedToast": "Успешно заархивировано", "markAllAsArchievedSucceedToast": "Все успешно заархивировано" }, "reminderNotification": { "title": "Напоминание", "message": "Не забудьте проверить это, прежде чем забудете!", "tooltipDelete": "Удалить", "tooltipMarkRead": "Отметить как прочитанное", "tooltipMarkUnread": "Отметить как непрочитанное" }, "findAndReplace": { "find": "Найти", "previousMatch": "Предыдущее совпадение", "nextMatch": "Следующее совпадение", "close": "Закрыть", "replace": "Заменить", "replaceAll": "Заменить все", "noResult": "Нет результатов", "caseSensitive": "Учитывать регистр", "searchMore": "Ищите, чтобы найти больше результатов" }, "error": { "weAreSorry": "Просим прощения", "loadingViewError": "У нас возникли проблемы с загрузкой этого вида. Пожалуйста, проверьте подключение к интернету, обновите приложение и не стесняйтесь обратиться к команде, если проблема сохранится.", "syncError": "Данные не синхронизированы с другого устройства", "syncErrorHint": "Пожалуйста, откройте эту страницу на устройстве, где она была последний раз отредактирована, затем снова откройте ее на текущем устройстве.", "clickToCopy": "Нажмите, чтобы скопировать код ошибки" }, "editor": { "bold": "Жирный", "bulletedList": "Маркированный список", "bulletedListShortForm": "Маркированный", "checkbox": "Чекбокс", "embedCode": "Встроить код", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Выделить", "color": "Цвет", "image": "Изображение", "date": "Дата", "page": "Страница", "italic": "Курсив", "link": "Ссылка", "numberedList": "Нумерованный список", "numberedListShortForm": "Нумерованный", "toggleHeading1ShortForm": "Переключить H1", "toggleHeading2ShortForm": "Переключить H2", "toggleHeading3ShortForm": "Переключить H3", "quote": "Цитата", "strikethrough": "Зачеркнутый", "text": "Текст", "underline": "Подчеркнутый", "fontColorDefault": "По умолчанию", "fontColorGray": "Серый", "fontColorBrown": "Коричневый", "fontColorOrange": "Оранжевый", "fontColorYellow": "Желтый", "fontColorGreen": "Зеленый", "fontColorBlue": "Синий", "fontColorPurple": "Фиолетовый", "fontColorPink": "Розовый", "fontColorRed": "Красный", "backgroundColorDefault": "Фон по умолчанию", "backgroundColorGray": "Серый фон", "backgroundColorBrown": "Коричневый фон", "backgroundColorOrange": "Оранжевый фон", "backgroundColorYellow": "Желтый фон", "backgroundColorGreen": "Зеленый фон", "backgroundColorBlue": "Синий фон", "backgroundColorPurple": "Фиолетовый фон", "backgroundColorPink": "Розовый фон", "backgroundColorRed": "Красный фон", "backgroundColorLime": "Фон лайм", "backgroundColorAqua": "Фон аква", "done": "Готово", "cancel": "Отмена", "tint1": "Оттенок 1", "tint2": "Оттенок 2", "tint3": "Оттенок 3", "tint4": "Оттенок 4", "tint5": "Оттенок 5", "tint6": "Оттенок 6", "tint7": "Оттенок 7", "tint8": "Оттенок 8", "tint9": "Оттенок 9", "lightLightTint1": "Фиолетовый", "lightLightTint2": "Розовый", "lightLightTint3": "Светло-розовый", "lightLightTint4": "Оранжевый", "lightLightTint5": "Желтый", "lightLightTint6": "Лайм", "lightLightTint7": "Зеленый", "lightLightTint8": "Аква", "lightLightTint9": "Синий", "urlHint": "URL", "mobileHeading1": "Заголовок 1", "mobileHeading2": "Заголовок 2", "mobileHeading3": "Заголовок 3", "mobileHeading4": "Заголовок 4", "mobileHeading5": "Заголовок 5", "mobileHeading6": "Заголовок 6", "textColor": "Цвет текста", "backgroundColor": "Цвет фона", "addYourLink": "Добавьте свою ссылку", "openLink": "Открыть ссылку", "copyLink": "Скопировать ссылку", "removeLink": "Удалить ссылку", "editLink": "Редактировать ссылку", "convertTo": "Преобразовать в", "linkText": "Текст", "linkTextHint": "Пожалуйста, введите текст", "linkAddressHint": "Пожалуйста, введите URL", "highlightColor": "Цвет выделения", "clearHighlightColor": "Очистить цвет выделения", "customColor": "Пользовательский цвет", "hexValue": "Значение Hex", "opacity": "Непрозрачность", "resetToDefaultColor": "Сбросить до цвета по умолчанию", "ltr": "Слева направо", "rtl": "Справа налево", "auto": "Авто", "cut": "Вырезать", "copy": "Копировать", "paste": "Вставить", "find": "Найти", "select": "Выбрать", "selectAll": "Выбрать все", "previousMatch": "Предыдущее совпадение", "nextMatch": "Следующее совпадение", "closeFind": "Закрыть", "replace": "Заменить", "replaceAll": "Заменить все", "regex": "Регулярное выражение", "caseSensitive": "Учитывать регистр", "uploadImage": "Загрузить изображение", "urlImage": "Изображение по URL", "incorrectLink": "Неверная ссылка", "upload": "Загрузить", "chooseImage": "Выберите изображение", "loading": "Загрузка", "imageLoadFailed": "Не удалось загрузить изображение", "divider": "Разделитель", "table": "Таблица", "colAddBefore": "Добавить до", "rowAddBefore": "Добавить до", "colAddAfter": "Добавить после", "rowAddAfter": "Добавить после", "colRemove": "Удалить", "rowRemove": "Удалить", "colDuplicate": "Дублировать", "rowDuplicate": "Дублировать", "colClear": "Очистить содержимое", "rowClear": "Очистить содержимое", "slashPlaceHolder": "Введите '/', чтобы вставить блок, или начните печатать", "typeSomething": "Введите что-нибудь...", "toggleListShortForm": "Переключить", "quoteListShortForm": "Цитата", "mathEquationShortForm": "Формула", "codeBlockShortForm": "Код" }, "favorite": { "noFavorite": "Нет избранных страниц", "noFavoriteHintText": "Смахните страницу влево, чтобы добавить ее в избранное", "removeFromSidebar": "Удалить из боковой панели", "addToSidebar": "Закрепить на боковой панели" }, "cardDetails": { "notesPlaceholder": "Введите / для вставки блока, или начните печатать" }, "blockPlaceholders": { "todoList": "Список дел", "bulletList": "Список", "numberList": "Список", "quote": "Цитата", "heading": "Заголовок {}" }, "titleBar": { "pageIcon": "Иконка страницы", "language": "Язык", "font": "Шрифт", "actions": "Действия", "date": "Дата", "addField": "Добавить поле", "userIcon": "Иконка пользователя" }, "noLogFiles": "Нет файлов логов", "newSettings": { "myAccount": { "title": "Аккаунт и Приложение", "subtitle": "Настройте свой профиль, управляйте безопасностью аккаунта, ключами AI или войдите в свой аккаунт.", "profileLabel": "Имя аккаунта и фото профиля", "profileNamePlaceholder": "Введите ваше имя", "accountSecurity": "Безопасность аккаунта", "2FA": "Двухфакторная аутентификация", "aiKeys": "Ключи AI", "accountLogin": "Вход в аккаунт", "updateNameError": "Не удалось обновить имя", "updateIconError": "Не удалось обновить иконку", "aboutAppFlowy": "О @:appName", "deleteAccount": { "title": "Удалить аккаунт", "subtitle": "Окончательно удалить ваш аккаунт и все ваши данные.", "description": "Окончательно удалить ваш аккаунт и удалить доступ из всех рабочих пространств.", "deleteMyAccount": "Удалить мой аккаунт", "dialogTitle": "Удалить аккаунт", "dialogContent1": "Вы уверены, что хотите окончательно удалить свой аккаунт?", "dialogContent2": "Это действие нельзя отменить, и оно удалит доступ из всех рабочих пространств, стерев весь ваш аккаунт, включая приватные рабочие пространства, и удалив вас из всех общих рабочих пространств.", "confirmHint1": "Пожалуйста, введите \"@:newSettings.myAccount.deleteAccount.confirmHint3\" для подтверждения.", "confirmHint2": "Я понимаю, что это действие необратимо и навсегда удалит мой аккаунт и все связанные данные.", "confirmHint3": "УДАЛИТЬ МОЙ АККАУНТ", "checkToConfirmError": "Вы должны отметить галочкой для подтверждения удаления", "failedToGetCurrentUser": "Не удалось получить email текущего пользователя", "confirmTextValidationFailed": "Ваш текст подтверждения не совпадает с \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "Аккаунт успешно удален" }, "password": { "title": "Пароль", "confirmPassword": "Подтвердить пароль", "changePassword": "Изменить пароль", "currentPassword": "Текущий пароль", "newPassword": "Новый пароль", "confirmNewPassword": "Подтвердить новый пароль", "setupPassword": "Установить пароль", "error": { "currentPasswordIsRequired": "Текущий пароль обязателен", "newPasswordIsRequired": "Новый пароль обязателен", "confirmPasswordIsRequired": "Подтверждение пароля обязательно", "passwordsDoNotMatch": "Пароли не совпадают", "newPasswordIsSameAsCurrent": "Новый пароль совпадает с текущим" }, "toast": { "passwordUpdatedSuccessfully": "Пароль успешно обновлен", "passwordUpdatedFailed": "Не удалось обновить пароль", "passwordSetupSuccessfully": "Пароль успешно установлен", "passwordSetupFailed": "Не удалось установить пароль" }, "hint": { "enterYourPassword": "Введите ваш пароль", "confirmYourPassword": "Подтвердите ваш пароль", "enterYourCurrentPassword": "Введите ваш текущий пароль", "enterYourNewPassword": "Введите ваш новый пароль", "confirmYourNewPassword": "Подтвердите ваш новый пароль" } }, "myAccount": "Мой аккаунт", "myProfile": "Мой профиль" }, "workplace": { "name": "Рабочее место", "title": "Настройки рабочего места", "subtitle": "Настройте внешний вид рабочего пространства, тему, шрифт, макет текста, дату, время и язык.", "workplaceName": "Название рабочего места", "workplaceNamePlaceholder": "Введите название рабочего места", "workplaceIcon": "Иконка рабочего места", "workplaceIconSubtitle": "Загрузите изображение или используйте эмодзи для вашего рабочего пространства. Иконка будет отображаться в боковой панели и уведомлениях.", "renameError": "Не удалось переименовать рабочее место", "updateIconError": "Не удалось обновить иконку", "chooseAnIcon": "Выберите иконку", "appearance": { "name": "Внешний вид", "themeMode": { "auto": "Авто", "light": "Светлая", "dark": "Темная" }, "language": "Язык" } }, "syncState": { "syncing": "Синхронизация", "synced": "Синхронизировано", "noNetworkConnected": "Нет подключения к сети" } }, "pageStyle": { "title": "Стиль страницы", "layout": "Макет", "coverImage": "Обложка", "pageIcon": "Иконка страницы", "colors": "Цвета", "gradient": "Градиент", "backgroundImage": "Фоновое изображение", "presets": "Пресеты", "photo": "Фото", "unsplash": "Unsplash", "pageCover": "Обложка страницы", "none": "Нет", "openSettings": "Открыть настройки", "photoPermissionTitle": "@:appName хочет получить доступ к вашей фотогалерее", "photoPermissionDescription": "@:appName нужен доступ к вашим фото, чтобы вы могли добавлять изображения в документы", "cameraPermissionTitle": "@:appName хочет получить доступ к вашей камере", "cameraPermissionDescription": "@:appName нужен доступ к вашей камере, чтобы вы могли добавлять изображения в документы с камеры", "doNotAllow": "Не разрешать", "image": "Изображение" }, "commandPalette": { "placeholder": "Поиск или задать вопрос...", "bestMatches": "Лучшие совпадения", "aiOverview": "Обзор AI", "aiOverviewSource": "Источники ссылок", "aiOverviewMoreDetails": "Подробнее", "pagePreview": "Предпросмотр содержимого", "clickToOpenPage": "Нажмите, чтобы открыть страницу", "recentHistory": "Недавняя история", "navigateHint": "для навигации", "loadingTooltip": "Идет поиск результатов...", "betaLabel": "БЕТА", "betaTooltip": "В настоящее время поддерживается поиск только страниц и содержимого документов", "fromTrashHint": "Из корзины", "noResultsHint": "Мы не нашли то, что вы ищете, попробуйте поискать другой запрос.", "clearSearchTooltip": "Очистить поле поиска" }, "space": { "delete": "Удалить", "deleteConfirmation": "Удалить: ", "deleteConfirmationDescription": "Все страницы в этом пространстве будут удалены и перемещены в корзину, а все опубликованные страницы будут сняты с публикации.", "rename": "Переименовать пространство", "changeIcon": "Изменить иконку", "manage": "Управлять пространством", "addNewSpace": "Создать пространство", "collapseAllSubPages": "Свернуть все подстраницы", "createNewSpace": "Создать новое пространство", "createSpaceDescription": "Создавайте несколько публичных и приватных пространств для лучшей организации работы.", "spaceName": "Название пространства", "spaceNamePlaceholder": "например, Маркетинг, Разработка, HR", "permission": "Разрешение пространства", "publicPermission": "Публичное", "publicPermissionDescription": "Всем участникам рабочего пространства с полным доступом", "privatePermission": "Приватное", "privatePermissionDescription": "Только вы можете получить доступ к этому пространству", "spaceIconBackground": "Цвет фона", "spaceIcon": "Иконка", "dangerZone": "Опасная зона", "unableToDeleteLastSpace": "Не удалось удалить последнее пространство", "unableToDeleteSpaceNotCreatedByYou": "Невозможно удалить пространства, созданные не вами", "enableSpacesForYourWorkspace": "Включить пространства для вашего рабочего пространства", "title": "Пространства", "defaultSpaceName": "Общее", "upgradeSpaceTitle": "Включить пространства", "upgradeSpaceDescription": "Создавайте несколько публичных и приватных пространств для лучшей организации вашего рабочего пространства.", "upgrade": "Обновить", "upgradeYourSpace": "Создайте несколько пространств", "quicklySwitch": "Быстро переключиться на следующее пространство", "duplicate": "Дублировать пространство", "movePageToSpace": "Переместить страницу в пространство", "cannotMovePageToDatabase": "Невозможно переместить страницу в базу данных", "switchSpace": "Переключить пространство", "spaceNameCannotBeEmpty": "Имя пространства не может быть пустым", "success": { "deleteSpace": "Пространство успешно удалено", "renameSpace": "Пространство успешно переименовано", "duplicateSpace": "Пространство успешно дублировано", "updateSpace": "Пространство успешно обновлено" }, "error": { "deleteSpace": "Не удалось удалить пространство", "renameSpace": "Не удалось переименовать пространство", "duplicateSpace": "Не удалось дублировать пространство", "updateSpace": "Не удалось обновить пространство" }, "createSpace": "Создать пространство", "manageSpace": "Управлять пространством", "renameSpace": "Переименовать пространство", "mSpaceIconColor": "Цвет иконки пространства", "mSpaceIcon": "Иконка пространства" }, "publish": { "hasNotBeenPublished": "Эта страница еще не опубликована", "spaceHasNotBeenPublished": "Публикация пространства пока не поддерживается", "reportPage": "Пожаловаться на страницу", "databaseHasNotBeenPublished": "Публикация базы данных пока не поддерживается.", "createdWith": "Создано с помощью", "downloadApp": "Скачать AppFlowy", "copy": { "codeBlock": "Содержимое блока кода скопировано в буфер обмена", "imageBlock": "Ссылка на изображение скопирована в буфер обмена", "mathBlock": "Математическое уравнение скопировано в буфер обмена", "fileBlock": "Ссылка на файл скопирована в буфер обмена" }, "containsPublishedPage": "Эта страница содержит одну или несколько опубликованных страниц. Если вы продолжите, они будут сняты с публикации. Вы хотите продолжить удаление?", "publishSuccessfully": "Успешно опубликовано", "unpublishSuccessfully": "Успешно снято с публикации", "publishFailed": "Не удалось опубликовать", "unpublishFailed": "Не удалось снять с публикации", "noAccessToVisit": "Нет доступа к этой странице...", "createWithAppFlowy": "Создать сайт с помощью AppFlowy", "fastWithAI": "Быстро и легко с помощью AI.", "tryItNow": "Попробуйте сейчас", "onlyGridViewCanBePublished": "Может быть опубликован только вид Таблица", "database": { "zero": "Опубликовать {} выбранных видов", "one": "Опубликовать {} выбранный вид", "many": "Опубликовать {} выбранных видов", "other": "Опубликовать {} выбранных видов" }, "mustSelectPrimaryDatabase": "Необходимо выбрать основной вид", "noDatabaseSelected": "База данных не выбрана, выберите хотя бы одну.", "unableToDeselectPrimaryDatabase": "Невозможно отменить выбор основной базы данных", "saveThisPage": "Начать с этого шаблона", "duplicateTitle": "Куда вы хотите добавить", "selectWorkspace": "Выберите рабочее пространство", "addTo": "Добавить в", "duplicateSuccessfully": "Добавлено в ваше рабочее пространство", "duplicateSuccessfullyDescription": "Не установлен AppFlowy? Загрузка начнется автоматически после нажатия 'Скачать'.", "downloadIt": "Скачать", "openApp": "Открыть в приложении", "duplicateFailed": "Дублирование не удалось", "membersCount": { "zero": "Нет участников", "one": "1 участник", "many": "{count} участников", "other": "{count} участников" }, "useThisTemplate": "Использовать этот шаблон" }, "web": { "continue": "Продолжить", "or": "или", "continueWithGoogle": "Продолжить с Google", "continueWithGithub": "Продолжить с GitHub", "continueWithDiscord": "Продолжить с Discord", "continueWithApple": "Продолжить с Apple", "moreOptions": "Больше опций", "collapse": "Свернуть", "signInAgreement": "Нажимая «Продолжить» выше, вы соглашаетесь с \n@:appName ", "signInLocalAgreement": "Нажимая «Начать» выше, вы соглашаетесь с \n@:appName ", "and": "и", "termOfUse": "Условиями использования", "privacyPolicy": "Политикой конфиденциальности", "signInError": "Ошибка входа", "login": "Зарегистрироваться или войти", "fileBlock": { "uploadedAt": "Загружено {time}", "linkedAt": "Ссылка добавлена {time}", "empty": "Загрузить или встроить файл", "uploadFailed": "Загрузка не удалась, пожалуйста, попробуйте снова", "retry": "Повторить" }, "importNotion": "Импорт из Notion", "import": "Импорт", "importSuccess": "Загружено успешно", "importSuccessMessage": "Мы уведомим вас, когда импорт завершится. После этого вы сможете просмотреть импортированные страницы в боковой панели.", "importFailed": "Импорт не удался, пожалуйста, проверьте формат файла", "dropNotionFile": "Перетащите сюда ваш zip-файл Notion для загрузки или нажмите для обзора", "error": { "pageNameIsEmpty": "Имя страницы пусто, пожалуйста, попробуйте другое" } }, "globalComment": { "comments": "Комментарии", "addComment": "Добавить комментарий", "reactedBy": "отреагировали:", "addReaction": "Добавить реакцию", "reactedByMore": "и еще {count}", "showSeconds": { "one": "1 секунду назад", "other": "{count} секунд назад", "zero": "Только что", "many": "{count} секунд назад" }, "showMinutes": { "one": "1 минуту назад", "other": "{count} минут назад", "many": "{count} минут назад" }, "showHours": { "one": "1 час назад", "other": "{count} часов назад", "many": "{count} часов назад" }, "showDays": { "one": "1 день назад", "other": "{count} дней назад", "many": "{count} дней назад" }, "showMonths": { "one": "1 месяц назад", "other": "{count} месяцев назад", "many": "{count} месяцев назад" }, "showYears": { "one": "1 год назад", "other": "{count} лет назад", "many": "{count} лет назад" }, "reply": "Ответить", "deleteComment": "Удалить комментарий", "youAreNotOwner": "Вы не владелец этого комментария", "confirmDeleteDescription": "Вы уверены, что хотите удалить этот комментарий?", "hasBeenDeleted": "Удалено", "replyingTo": "Ответ на", "noAccessDeleteComment": "Вам не разрешено удалять этот комментарий", "collapse": "Свернуть", "readMore": "Читать далее", "failedToAddComment": "Не удалось добавить комментарий", "commentAddedSuccessfully": "Комментарий успешно добавлен.", "commentAddedSuccessTip": "Вы только что добавили или ответили на комментарий. Хотите перейти вверх, чтобы увидеть последние комментарии?" }, "template": { "asTemplate": "Сохранить как шаблон", "name": "Название шаблона", "description": "Описание шаблона", "about": "О шаблоне", "deleteFromTemplate": "Удалить из шаблонов", "preview": "Предпросмотр шаблона", "categories": "Категории шаблонов", "isNewTemplate": "ЗАКРЕПИТЬ в Новые шаблоны", "featured": "ЗАКРЕПИТЬ в Рекомендуемые", "relatedTemplates": "Связанные шаблоны", "requiredField": "{field} обязателен", "addCategory": "Добавить \"{category}\"", "addNewCategory": "Добавить новую категорию", "addNewCreator": "Добавить нового автора", "deleteCategory": "Удалить категорию", "editCategory": "Редактировать категорию", "editCreator": "Редактировать автора", "category": { "name": "Название категории", "icon": "Иконка категории", "bgColor": "Цвет фона категории", "priority": "Приоритет категории", "desc": "Описание категории", "type": "Тип категории", "icons": "Иконки категорий", "colors": "Цвета категорий", "byUseCase": "По варианту использования", "byFeature": "По функции", "deleteCategory": "Удалить категорию", "deleteCategoryDescription": "Вы уверены, что хотите удалить эту категорию?", "typeToSearch": "Введите для поиска категорий..." }, "creator": { "label": "Автор шаблона", "name": "Имя автора", "avatar": "Аватар автора", "accountLinks": "Ссылки на аккаунт автора", "uploadAvatar": "Нажмите, чтобы загрузить аватар", "deleteCreator": "Удалить автора", "deleteCreatorDescription": "Вы уверены, что хотите удалить этого автора?", "typeToSearch": "Введите для поиска авторов..." }, "uploadSuccess": "Шаблон успешно загружен", "uploadSuccessDescription": "Ваш шаблон успешно загружен. Теперь вы можете просмотреть его в галерее шаблонов.", "viewTemplate": "Просмотреть шаблон", "deleteTemplate": "Удалить шаблон", "deleteSuccess": "Шаблон успешно удален", "deleteTemplateDescription": "Это не повлияет на текущую страницу или статус публикации. Вы уверены, что хотите удалить этот шаблон?", "addRelatedTemplate": "Добавить связанный шаблон", "removeRelatedTemplate": "Удалить связанный шаблон", "uploadAvatar": "Загрузить аватар", "searchInCategory": "Поиск в {category}", "label": "Шаблоны" }, "fileDropzone": { "dropFile": "Нажмите или перетащите файл в эту область для загрузки", "uploading": "Загрузка...", "uploadFailed": "Загрузка не удалась", "uploadSuccess": "Загрузка успешно", "uploadSuccessDescription": "Файл успешно загружен", "uploadFailedDescription": "Загрузка файла не удалась", "uploadingDescription": "Файл загружается" }, "gallery": { "preview": "Открыть на весь экран", "copy": "Копировать", "download": "Скачать", "prev": "Предыдущий", "next": "Следующий", "resetZoom": "Сбросить масштаб", "zoomIn": "Увеличить", "zoomOut": "Уменьшить" }, "invitation": { "join": "Присоединиться", "on": "в", "invitedBy": "Приглашен(а)", "membersCount": { "zero": "{count} участников", "one": "{count} участник", "many": "{count} участников", "other": "{count} участников" }, "tip": "Вас пригласили присоединиться к этому рабочему пространству, используя указанную ниже контактную информацию. Если это неверно, свяжитесь с администратором, чтобы отправить приглашение повторно.", "joinWorkspace": "Присоединиться к рабочему пространству", "success": "Вы успешно присоединились к рабочему пространству", "successMessage": "Теперь у вас есть доступ ко всем страницам и рабочим пространствам в нем.", "openWorkspace": "Открыть AppFlowy", "alreadyAccepted": "Вы уже приняли приглашение", "errorModal": { "title": "Что-то пошло не так", "description": "Ваш текущий аккаунт {email} может не иметь доступа к этому рабочему пространству. Пожалуйста, войдите с правильным аккаунтом или свяжитесь с владельцем рабочего пространства за помощью.", "contactOwner": "Связаться с владельцем", "close": "Назад на главную", "changeAccount": "Сменить аккаунт" } }, "requestAccess": { "title": "Нет доступа к этой странице", "subtitle": "Вы можете запросить доступ у владельца этой страницы. После одобрения вы сможете просмотреть страницу.", "requestAccess": "Запросить доступ", "backToHome": "Назад на главную", "tip": "Вы вошли как .", "mightBe": "Возможно, вам нужно с другой учетной записью.", "successful": "Запрос успешно отправлен", "successfulMessage": "Вы будете уведомлены, как только владелец одобрит ваш запрос.", "requestError": "Не удалось запросить доступ", "repeatRequestError": "Вы уже запросили доступ к этой странице" }, "approveAccess": { "title": "Одобрить запрос на присоединение к рабочему пространству", "requestSummary": " запрашивает присоединение к и доступ к ", "upgrade": "обновиться", "downloadApp": "Скачать AppFlowy", "approveButton": "Одобрить", "approveSuccess": "Успешно одобрено", "approveError": "Не удалось одобрить, убедитесь, что лимит плана рабочего пространства не превышен", "getRequestInfoError": "Не удалось получить информацию о запросе", "memberCount": { "zero": "Нет участников", "one": "1 участник", "many": "{count} участников", "other": "{count} участников" }, "alreadyProTitle": "Вы достигли лимита плана рабочего пространства", "alreadyProMessage": "Попросите их связаться с , чтобы разблокировать больше участников", "repeatApproveError": "Вы уже одобрили этот запрос", "ensurePlanLimit": "Убедитесь, что лимит плана рабочего пространства не превышен. Если лимит превышен, рассмотрите возможность плана рабочего пространства или .", "requestToJoin": "запросил(а) присоединиться", "asMember": "как участник" }, "upgradePlanModal": { "title": "Обновиться до Pro", "message": "{name} достиг бесплатного лимита участников. Обновитесь до плана Pro, чтобы пригласить больше участников.", "upgradeSteps": "Как обновить план в AppFlowy:", "step1": "1. Перейдите в Настройки", "step2": "2. Нажмите на 'План'", "step3": "3. Выберите 'Изменить план'", "appNote": "Примечание: ", "actionButton": "Обновить", "downloadLink": "Скачать приложение", "laterButton": "Позже", "refreshNote": "После успешного обновления нажмите , чтобы активировать новые функции.", "refresh": "здесь" }, "breadcrumbs": { "label": "Хлебные крошки" }, "time": { "justNow": "Только что", "seconds": { "one": "1 секунда", "other": "{count} секунд" }, "minutes": { "one": "1 минута", "other": "{count} минут" }, "hours": { "one": "1 час", "other": "{count} часов" }, "days": { "one": "1 день", "other": "{count} дней" }, "weeks": { "one": "1 неделя", "other": "{count} недель" }, "months": { "one": "1 месяц", "other": "{count} месяцев" }, "years": { "one": "1 год", "other": "{count} лет" }, "ago": "назад", "yesterday": "Вчера", "today": "Сегодня" }, "members": { "zero": "Нет участников", "one": "1 участник", "many": "{count} участников", "other": "{count} участников" }, "tabMenu": { "close": "Закрыть", "closeDisabledHint": "Нельзя закрыть закрепленную вкладку, сначала открепите", "closeOthers": "Закрыть другие вкладки", "closeOthersHint": "Это закроет все незакрепленные вкладки, кроме этой", "closeOthersDisabledHint": "Все вкладки закреплены, нет вкладок для закрытия", "favorite": "Избранное", "unfavorite": "Удалить из избранного", "favoriteDisabledHint": "Невозможно добавить этот вид в избранное", "pinTab": "Закрепить", "unpinTab": "Открепить" }, "openFileMessage": { "success": "Файл успешно открыт", "fileNotFound": "Файл не найден", "noAppToOpenFile": "Нет приложения для открытия этого файла", "permissionDenied": "Нет разрешения на открытие этого файла", "unknownError": "Ошибка открытия файла" }, "inviteMember": { "requestInviteMembers": "Пригласить в рабочее пространство", "inviteFailedMemberLimit": "Достигнут лимит участников, пожалуйста, ", "upgrade": "обновитесь", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "Отправить приглашения", "inviteAlready": "Вы уже приглашали этот email: {email}", "inviteSuccess": "Приглашение успешно отправлено", "description": "Введите адреса электронной почты ниже через запятую. Плата рассчитывается исходя из количества участников.", "emails": "Email" }, "quickNote": { "label": "Быстрая заметка", "quickNotes": "Быстрые заметки", "search": "Поиск быстрых заметок", "collapseFullView": "Свернуть полный вид", "expandFullView": "Развернуть полный вид", "createFailed": "Не удалось создать быструю заметку", "quickNotesEmpty": "Нет быстрых заметок", "emptyNote": "Пустая заметка", "deleteNotePrompt": "Выбранная заметка будет удалена навсегда. Вы уверены, что хотите ее удалить?", "addNote": "Новая заметка", "noAdditionalText": "Нет дополнительного текста" }, "subscribe": { "upgradePlanTitle": "Сравнить и выбрать план", "yearly": "Ежегодно", "save": "Сэкономить {discount}%", "monthly": "Ежемесячно", "priceIn": "Цена в ", "free": "Бесплатно", "pro": "Pro", "freeDescription": "Для индивидуального использования до 2 участников для организации всего", "proDescription": "Для небольших команд для управления проектами и знаниями команды", "proDuration": { "monthly": "за участника в месяц\nежемесячно", "yearly": "за участника в месяц\nежегодно" }, "cancel": "Понизить", "changePlan": "Обновить до плана Pro", "everythingInFree": "Все в бесплатном плане +", "currentPlan": "Текущий", "freeDuration": "навсегда", "freePoints": { "first": "1 совместное рабочее пространство до 2 участников", "second": "Неограниченное количество страниц и блоков", "three": "5 ГБ хранилища", "four": "Интеллектуальный поиск", "five": "20 ответов AI", "six": "Мобильное приложение", "seven": "Совместная работа в реальном времени" }, "proPoints": { "first": "Неограниченное хранилище", "second": "До 10 участников рабочего пространства", "three": "Неограниченные ответы AI", "four": "Неограниченная загрузка файлов", "five": "Пользовательское пространство имен" }, "cancelPlan": { "title": "Жаль видеть, что вы уходите", "success": "Ваша подписка успешно отменена", "description": "Нам жаль, что вы уходите. Мы будем рады услышать ваш отзыв, чтобы помочь нам улучшить AppFlowy. Пожалуйста, уделите минутку, чтобы ответить на несколько вопросов.", "commonOther": "Другое", "otherHint": "Напишите свой ответ здесь", "questionOne": { "question": "Что побудило вас отменить подписку на AppFlowy Pro?", "answerOne": "Слишком высокая стоимость", "answerTwo": "Функции не соответствовали ожиданиям", "answerThree": "Нашел лучшую альтернативу", "answerFour": "Использовал недостаточно для оправдания расходов", "answerFive": "Проблема с сервисом или технические трудности" }, "questionTwo": { "question": "Насколько вероятно, что вы рассмотрите возможность повторной подписки на AppFlowy Pro в будущем?", "answerOne": "Очень вероятно", "answerTwo": "Несколько вероятно", "answerThree": "Не уверен", "answerFour": "Маловероятно", "answerFive": "Очень маловероятно" }, "questionThree": { "question": "Какую функцию Pro вы ценили больше всего во время подписки?", "answerOne": "Совместная работа нескольких пользователей", "answerTwo": "Более долгая история версий", "answerThree": "Неограниченные ответы AI", "answerFour": "Доступ к локальным моделям AI" }, "questionFour": { "question": "Как бы вы описали свой общий опыт использования AppFlowy?", "answerOne": "Отлично", "answerTwo": "Хорошо", "answerThree": "Средне", "answerFour": "Ниже среднего", "answerFive": "Недоволен" } } }, "ai": { "contentPolicyViolation": "Не удалось сгенерировать изображение из-за конфиденциального содержимого. Пожалуйста, переформулируйте запрос и попробуйте снова", "textLimitReachedDescription": "В вашем рабочем пространстве закончились бесплатные ответы AI. Обновитесь до плана Pro или купите дополнение AI, чтобы разблокировать неограниченные ответы", "imageLimitReachedDescription": "Вы использовали квоту на бесплатные изображения AI. Обновитесь до плана Pro или купите дополнение AI, чтобы разблокировать неограниченные ответы", "limitReachedAction": { "textDescription": "В вашем рабочем пространстве закончились бесплатные ответы AI. Чтобы получить больше ответов, пожалуйста, ", "imageDescription": "Вы использовали квоту на бесплатные изображения AI. Пожалуйста, ", "upgrade": "обновитесь", "toThe": "до", "proPlan": "плана Pro", "orPurchaseAn": "или купите", "aiAddon": "дополнение AI" }, "editing": "Редактирование", "analyzing": "Анализ", "continueWritingEmptyDocumentTitle": "Ошибка продолжения написания", "continueWritingEmptyDocumentDescription": "У нас возникли проблемы с продолжением контента в вашем документе. Напишите небольшое введение, и мы сможем продолжить с него!", "more": "Еще", "customPrompt": { "browsePrompts": "Обзор запросов", "usePrompt": "Использовать запрос", "featured": "Рекомендуемые", "example": "Пример", "all": "Все", "development": "Разработка", "writing": "Написание", "healthAndFitness": "Здоровье и фитнес", "business": "Бизнес", "marketing": "Маркетинг", "travel": "Путешествия", "others": "Другое", "prompt": "Запрос", "sampleOutput": "Пример вывода", "contentSeo": "Контент/SEO", "emailMarketing": "Email маркетинг", "paidAds": "Платная реклама", "prCommunication": "PR/Коммуникации", "recruiting": "Подбор персонала", "sales": "Продажи", "socialMedia": "Социальные сети", "strategy": "Стратегия", "caseStudies": "Кейсы", "salesCopy": "Продающий текст", "learning": "Обучение" } }, "autoUpdate": { "criticalUpdateTitle": "Требуется обновление для продолжения", "criticalUpdateDescription": "Мы внесли улучшения для повышения удобства использования! Пожалуйста, обновите с {currentVersion} до {newVersion}, чтобы продолжить использовать приложение.", "criticalUpdateButton": "Обновить", "bannerUpdateTitle": "Доступна новая версия!", "bannerUpdateDescription": "Получите последние функции и исправления. Нажмите \"Обновить\" для установки сейчас", "bannerUpdateButton": "Обновить", "settingsUpdateTitle": "Доступна новая версия ({newVersion})!", "settingsUpdateDescription": "Текущая версия: {currentVersion} (Официальная сборка) → {newVersion}", "settingsUpdateButton": "Обновить", "settingsUpdateWhatsNew": "Что нового" }, "lockPage": { "lockPage": "Заблокировано", "reLockPage": "Переблокировать", "lockTooltip": "Страница заблокирована для предотвращения случайного редактирования. Нажмите, чтобы разблокировать.", "pageLockedToast": "Страница заблокирована. Редактирование отключено, пока кто-нибудь не разблокирует.", "lockedOperationTooltip": "Страница заблокирована для предотвращения случайного редактирования." }, "suggestion": { "accept": "Принять", "keep": "Сохранить", "discard": "Отменить", "close": "Закрыть", "tryAgain": "Попробовать снова", "rewrite": "Переписать", "insertBelow": "Вставить ниже" } } ================================================ FILE: frontend/resources/translations/sv-SE.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Jag", "welcomeText": "Välkommen till @:appName", "welcomeTo": "Välkommen till", "githubStarText": "Stjärna på GitHub", "subscribeNewsletterText": "Prenumerera på nyhetsbrev", "letsGoButtonText": "Snabbstart", "title": "Titel", "youCanAlso": "Du kan också", "and": "och", "failedToOpenUrl": "Det gick inte att öppna webbadressen: {}", "blockActions": { "addBelowTooltip": "Klicka för att lägga till nedan", "addAboveCmd": "Alt+klicka", "addAboveMacCmd": "Alternativ+klicka", "addAboveTooltip": "att lägga till ovan", "dragTooltip": "Dra för att flytta", "openMenuTooltip": "Klicka för att öppna menyn" }, "signUp": { "buttonText": "Registera", "title": "Registrera dig för @:appName", "getStartedText": "Kom igång", "emptyPasswordError": "Lösenordet får inte vara tomt", "repeatPasswordEmptyError": "Upprepa lösenordet får inte vara tomt", "unmatchedPasswordError": "Upprepa lösenord är inte detsamma som lösenord", "alreadyHaveAnAccount": "Har du redan ett konto?", "emailHint": "Epost", "passwordHint": "Lösenord", "repeatPasswordHint": "Upprepa lösenord", "signUpWith": "Registrera med" }, "signIn": { "loginTitle": "Logga in till @:appName", "loginButtonText": "Logga in", "loginStartWithAnonymous": "Börja med en anonym session", "continueAnonymousUser": "Fortsätt med en anonym session", "buttonText": "Registrera", "signingInText": "Loggar in...", "forgotPassword": "Glömt ditt lösenord?", "emailHint": "Epost", "passwordHint": "Lösenord", "dontHaveAnAccount": "Har du inget konto?", "repeatPasswordEmptyError": "Upprepat lösenord kan inte vara tomt", "unmatchedPasswordError": "Upprepat lösenord är inte samma som det första", "loginAsGuestButtonText": "Komma igång" }, "workspace": { "create": "Skapa arbetsyta", "hint": "Arbetsyta", "notFoundError": "Hittade ingen arbetsyta" }, "shareAction": { "buttonText": "Dela", "workInProgress": "Kommer snart", "markdown": "Markdown", "copyLink": "Kopiera länk" }, "moreAction": { "small": "små", "medium": "medium", "large": "stor", "fontSize": "Textstorlek", "import": "Importera", "moreOptions": "Fler alternativ" }, "importPanel": { "textAndMarkdown": "Text & Markdown", "documentFromV010": "Dokument från v0.1.0", "databaseFromV010": "Databas från v0.1.0", "csv": "CSV", "database": "Databas" }, "disclosureAction": { "rename": "Byt namn", "delete": "Ta bort", "duplicate": "Klona", "openNewTab": "Öppna i en ny flik" }, "blankPageTitle": "Tom sida", "newPageText": "Ny sida", "trash": { "text": "Skräp", "restoreAll": "Återställ alla", "deleteAll": "Ta bort alla", "pageHeader": { "fileName": "Filnamn", "lastModified": "Ändrad", "created": "Skapad" }, "confirmDeleteAll": { "title": "Är du säker på att ta bort alla sidor i papperskorgen?", "caption": "Denna åtgärd kan inte ångras." }, "confirmRestoreAll": { "title": "Är du säker på att återställa alla sidor i papperskorgen?", "caption": "Denna åtgärd kan inte ångras." } }, "deletePagePrompt": { "text": "Denna sida är i skräpmappen", "restore": "Återställ sida", "deletePermanent": "Radera permanent" }, "dialogCreatePageNameHint": "Sidnamn", "questionBubble": { "shortcuts": "Genvägar", "whatsNew": "Vad nytt?", "markdown": "Prissänkning", "debug": { "name": "Felsökningsinfo", "success": "Kopierade felsökningsinfo till urklipp!", "fail": "Kunde inte kopiera felsökningsinfo till urklipp" }, "feedback": "Återkoppling", "help": "Hjälp & Support" }, "menuAppHeader": { "addPageTooltip": "Lägg till en underliggande sida", "defaultNewPageName": "Namnlös", "renameDialog": "Byt namn" }, "toolbar": { "undo": "Ångra", "redo": "Upprepa", "bold": "Fet", "italic": "Kursiv", "underline": "Understruken", "strike": "Genomstruken", "numList": "Numrerad lista", "bulletList": "Punktlista", "checkList": "Checklista", "inlineCode": "Infogad kod", "quote": "Citatblock", "header": "Rubrik", "highlight": "Färgmarkera", "color": "Färg", "addLink": "Lägg till länk", "link": "Länk" }, "tooltip": { "lightMode": "Växla till ljust läge", "darkMode": "Växla till mörkt läge", "openAsPage": "Öppna som sida", "addNewRow": "Lägg till ny rad", "openMenu": "Klicka för att öppna meny", "dragRow": "Långt tryck för att ändra ordningen på raden", "viewDataBase": "Visa databas", "referencePage": "Det här {name} refereras till", "addBlockBelow": "Lägg till ett block nedan" }, "sideBar": { "closeSidebar": "Stäng sidofältet", "openSidebar": "Öppna sidofältet" }, "notifications": { "export": { "markdown": "Exporterade anteckning till Markdown", "path": "Dokument/flowy" } }, "contactsPage": { "title": "Kontakter", "whatsHappening": "Vad händer denna vecka?", "addContact": "Lägg till kontakt", "editContact": "Redigera kontakt" }, "button": { "ok": "OK", "done": "Gjort", "cancel": "Avbryt", "signIn": "Logga in", "signOut": "Logga ut", "complete": "Slutfört", "save": "Spara", "generate": "Generera", "esc": "ESC", "keep": "Ha kvar", "tryAgain": "Försök igen", "discard": "Kassera", "replace": "Byta ut", "insertBelow": "Infoga nedan", "upload": "Ladda upp", "edit": "Redigera", "delete": "Radera", "duplicate": "Duplicera", "putback": "Ställ tillbaka", "update": "Uppdatering", "share": "Dela", "removeFromFavorites": "Ta bort från favoriter", "addToFavorites": "Lägg till i favoriter", "rename": "Döp om", "helpCenter": "Hjälpcenter", "add": "Lägg till", "yes": "Ja", "clear": "Klar", "remove": "Ta bort", "dontRemove": "Ta inte bort", "copyLink": "Kopiera länk", "align": "Jämka", "login": "Logga in", "logout": "Logga ut", "back": "Tillbaka", "signInGoogle": "Logga in med Google", "signInGithub": "Logga in med Github", "signInDiscord": "Logga in med Discord" }, "label": { "welcome": "Välkommen!", "firstName": "Förnamn", "middleName": "Mellannamn", "lastName": "Efternamn", "stepX": "Steg {X}" }, "oAuth": { "err": { "failedTitle": "Det går inte att ansluta till ditt konto.", "failedMsg": "Se till att du har slutfört inloggningsprocessen i din webbläsare." }, "google": { "title": "GOOGLE LOGGA IN", "instruction1": "För att kunna importera dina Google-kontakter måste du auktorisera denna applikation med din webbläsare.", "instruction2": "Kopiera den här koden till ditt urklipp genom att klicka på ikonen eller välja texten:", "instruction3": "Navigera till följande länk i din webbläsare och ange koden ovan:", "instruction4": "Tryck på knappen nedan när du har slutfört registreringen:" } }, "settings": { "title": "Inställningar", "menu": { "appearance": "Utseende", "language": "Språk", "user": "Användare", "files": "Filer", "notifications": "Aviseringar", "open": "Öppna Inställningar", "logout": "Logga ut", "logoutPrompt": "Är du säker på att logga ut?", "selfEncryptionLogoutPrompt": "Är du säker på att du vill logga ut? Se till att du har kopierat krypteringshemligheten", "syncSetting": "Synkroniseringsinställning", "cloudSettings": "Molninställningar", "enableSync": "Aktivera synkronisering", "enableEncrypt": "Kryptera data", "cloudURL": "Grund-URL", "invalidCloudURLScheme": "Ogiltigt schema", "cloudServerType": "Molnserver", "cloudServerTypeTip": "Observera att det kan logga ut ditt nuvarande konto efter att ha bytt molnserver", "cloudLocal": "Lokal", "appFlowyCloudUrlCanNotBeEmpty": "Molnets webbadress får inte vara tom", "clickToCopy": "Klicka för att kopiera", "selfHostStart": "Om du inte har en server, vänligen se", "selfHostContent": "dokument", "selfHostEnd": "för vägledning om hur du själv är värd för din egen server", "cloudURLHint": "Ange grundadressen till din server", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Infoga websocket-adressen till din server", "restartApp": "Omstart", "restartAppTip": "Starta om programmet för att ändringarna ska träda i kraft. Observera att detta kan logga ut ditt nuvarande konto", "changeServerTip": "När du har bytt server måste du klicka på omstartsknappen för att ändringarna ska träda i kraft", "enableEncryptPrompt": "Aktivera kryptering för att säkra dina data med denna hemlighet. Förvara det säkert; när den väl är aktiverad kan den inte stängas av. Om din data går förlorad blir den omöjlig att återställa. Klicka för att kopiera", "inputEncryptPrompt": "Vänligen ange din krypteringshemlighet för", "clickToCopySecret": "Klicka för att kopiera hemlighet", "configServerSetting": "Konfigurera dina serverinställningar", "configServerGuide": "Efter att ha valt `Quick Start`, navigera till `Settings` och sedan \"Cloud Settings\" för att konfigurera din egen värdserver.", "inputTextFieldHint": "Din hemlighet", "historicalUserList": "Användarinloggningshistorik", "historicalUserListTooltip": "Den här listan visar dina anonyma konton. Du kan klicka på ett konto för att se dess detaljer. Anonyma konton skapas genom att klicka på knappen \"Kom igång\".", "openHistoricalUser": "Klicka för att öppna det anonyma kontot", "customPathPrompt": "Att lagra @:appName-datamappen i en molnsynkroniserad mapp som Google Drive kan innebära risker. Om databasen i den här mappen nås eller ändras från flera platser samtidigt, kan det resultera i synkroniseringskonflikter och potentiell datakorruption", "importAppFlowyData": "Importera data från extern @:appName-mapp", "importingAppFlowyDataTip": "Dataimport pågår. Stäng inte appen", "importAppFlowyDataDescription": "Kopiera data från en extern @:appName-datamapp och importera den till den aktuella @:appName-datamappen", "importSuccess": "@:appName-datamappen har importerats", "importFailed": "Det gick inte att importera @:appName-datamappen", "importGuide": "För ytterligare information, snälla se det refererade dokumentet" }, "appearance": { "fontFamily": { "label": "Typsnittsfamilj", "search": "Sök" }, "themeMode": { "label": "Temats läge", "light": "Ljust läge", "dark": "Mörkt läge", "system": "Samma som system" }, "themeUpload": { "button": "Ladda upp", "description": "Ladda upp ditt eget @:appName-tema med knappen nedan.", "loading": "Vänta medan vi validerar och laddar upp ditt tema...", "uploadSuccess": "Ditt tema laddades upp", "deletionFailure": "Det gick inte att ta bort temat. Försök att radera det manuellt.", "filePickerDialogTitle": "Välj en .flowy_plugin-fil", "urlUploadFailure": "Det gick inte att öppna webbadressen: {}", "failure": "Temat som laddades upp hade ett ogiltigt format." }, "theme": "Tema", "builtInsLabel": "Inbyggda teman", "pluginsLabel": "Plugin" }, "files": { "copy": "Kopiera", "defaultLocation": "Läs filer och datalagringsplats", "exportData": "Exportera dina data", "doubleTapToCopy": "Dubbeltryck för att kopiera sökvägen", "restoreLocation": "Återställ till @:appName standardsökväg", "customizeLocation": "Öppna en annan mapp", "restartApp": "Starta om appen för att ändringarna ska träda i kraft.", "exportDatabase": "Exportera databas", "selectFiles": "Välj de filer som behöver exporteras", "selectAll": "Välj alla", "deselectAll": "Avmarkera alla", "createNewFolder": "Skapa en ny mapp", "createNewFolderDesc": "Berätta för oss var du vill lagra din data", "defineWhereYourDataIsStored": "Definiera var din data lagras", "open": "Öppen", "openFolder": "Öppna en befintlig mapp", "openFolderDesc": "Läs och skriv det till din befintliga @:appName-mapp", "folderHintText": "mappnamn", "location": "Skapar en ny mapp", "locationDesc": "Välj ett namn för din @:appName-datamapp", "browser": "Bläddra", "create": "Skapa", "set": "Uppsättning", "folderPath": "Sökväg för att lagra din mapp", "locationCannotBeEmpty": "Sökvägen får inte vara tom", "pathCopiedSnackbar": "Fillagringssökväg kopierad till urklipp!", "changeLocationTooltips": "Byt datakatalog", "change": "Förändra", "openLocationTooltips": "Öppna en annan datakatalog", "openCurrentDataFolder": "Öppna aktuell datakatalog", "recoverLocationTooltips": "Återställ till @:appNames standarddatakatalog", "exportFileSuccess": "Exporterade filen framgångsrikt!", "exportFileFail": "Export av fil misslyckades!", "export": "Exportera" }, "user": { "name": "namn", "selectAnIcon": "Välj en ikon", "pleaseInputYourOpenAIKey": "vänligen ange din AI-nyckel" } }, "grid": { "deleteView": "Är du säker på att du vill ta bort den här vyn?", "createView": "Ny", "settings": { "filter": "Filter", "sort": "Sortera", "sortBy": "Sortera efter", "properties": "Egenskaper", "reorderPropertiesTooltip": "Dra för att ändra ordning på egenskaper", "group": "Grupp", "addFilter": "Lägg till filter", "deleteFilter": "Ta bort filter", "filterBy": "Filtrera efter...", "typeAValue": "Skriv ett värde...", "layout": "Layout", "databaseLayout": "Layout", "Properties": "Egenskaper" }, "textFilter": { "contains": "Innehåller", "doesNotContain": "Innehåller inte", "endsWith": "Slutar med", "startWith": "Börjar med", "is": "Är", "isNot": "Är inte", "isEmpty": "Är tom", "isNotEmpty": "Är inte tom", "choicechipPrefix": { "isNot": "Inte", "startWith": "Börjar med", "endWith": "Slutar med", "isEmpty": "är tom", "isNotEmpty": "är inte tom" } }, "checkboxFilter": { "isChecked": "Kontrollerade", "isUnchecked": "Okontrollerad", "choicechipPrefix": { "is": "är" } }, "checklistFilter": { "isComplete": "är komplett", "isIncomplted": "är ofullständig" }, "selectOptionFilter": { "is": "Är", "isNot": "Är inte", "contains": "Innehåller", "doesNotContain": "Innehåller inte", "isEmpty": "Är tom", "isNotEmpty": "Är inte tom" }, "field": { "hide": "Dölj", "insertLeft": "Infoga till vänster", "insertRight": "Infoga till höger", "duplicate": "Klona", "delete": "Ta bort", "textFieldName": "Text", "checkboxFieldName": "Checkruta", "dateFieldName": "Datum", "updatedAtFieldName": "Senast ändrad tid", "createdAtFieldName": "Skapat tid", "numberFieldName": "Siffror", "singleSelectFieldName": "Välj", "multiSelectFieldName": "Välj flera", "urlFieldName": "URL", "checklistFieldName": "Checklista", "numberFormat": "Sifferformat", "dateFormat": "Datumformat", "includeTime": "Inkludera tid", "dateFormatFriendly": "Månad Dag, År", "dateFormatISO": "År-Månad-Dag", "dateFormatLocal": "Månad/Dag/År", "dateFormatUS": "År/Månad/Dag", "dateFormatDayMonthYear": "Dag månad år", "timeFormat": "Tidsformat", "invalidTimeFormat": "Ogiltigt format", "timeFormatTwelveHour": "12-timmars", "timeFormatTwentyFourHour": "24-timmars", "addSelectOption": "Lägg till ett alternativ", "optionTitle": "Alternativ", "addOption": "Lägg till alternativ", "editProperty": "Redigera egenskap", "newProperty": "Ny kolumn", "deleteFieldPromptMessage": "Är du säker? Denna egenskap kommer att raderas." }, "sort": { "ascending": "Stigande", "descending": "Fallande", "addSort": "Lägg till sortering", "deleteSort": "Ta bort sortering" }, "row": { "duplicate": "Klona", "delete": "Ta bort", "textPlaceholder": "Tom", "copyProperty": "Kopierade egenskap till urklipp", "count": "Antal", "newRow": "Ny rad", "action": "Handling" }, "selectOption": { "create": "Skapa", "purpleColor": "Purpur", "pinkColor": "Rosa", "lightPinkColor": "Ljusrosa", "orangeColor": "Orange", "yellowColor": "Gul", "limeColor": "Lime", "greenColor": "Grön", "aquaColor": "Vatten", "blueColor": "Blå", "deleteTag": "Ta bort tagg", "colorPanelTitle": "Färger", "panelTitle": "Välj ett alternativ eller skapa ett", "searchOption": "Sök efter ett alternativ" }, "checklist": { "addNew": "Lägg till ett objekt" }, "menuName": "Tabell", "referencedGridPrefix": "Utsikt över" }, "document": { "menuName": "Dokument", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Välj en tavla att länka till", "createANewBoard": "Skapa en ny tavla" }, "grid": { "selectAGridToLinkTo": "Välj en tabell att länka till", "createANewGrid": "Skapa en ny tabell" }, "calendar": { "selectACalendarToLinkTo": "Välj en kalender att länka till", "createANewCalendar": "Skapa en ny kalender" } }, "selectionMenu": { "outline": "Skissera" }, "plugins": { "referencedBoard": "Refererad tavla", "referencedGrid": "Refererade tabell", "referencedCalendar": "Refererad kalender", "autoGeneratorMenuItemName": "AI Writer", "autoGeneratorTitleName": "AI: Be AI skriva vad som helst...", "autoGeneratorLearnMore": "Läs mer", "autoGeneratorGenerate": "Generera", "autoGeneratorHintText": "Fråga AI...", "autoGeneratorCantGetOpenAIKey": "Kan inte hämta AI-nyckeln", "autoGeneratorRewrite": "Skriva om", "smartEdit": "AI-assistenter", "aI": "AI", "smartEditFixSpelling": "Fixa stavningen", "warning": "⚠️ AI-svar kan vara felaktiga eller vilseledande.", "smartEditSummarize": "Sammanfatta", "smartEditImproveWriting": "Förbättra skrivandet", "smartEditMakeLonger": "Gör längre", "smartEditCouldNotFetchResult": "Det gick inte att hämta resultatet från AI", "smartEditCouldNotFetchKey": "Det gick inte att hämta AI-nyckeln", "smartEditDisabled": "Anslut AI i Inställningar", "discardResponse": "Vill du kassera AI-svaren?", "createInlineMathEquation": "Skapa ekvation", "toggleList": "Växla lista", "cover": { "changeCover": "Byt omslag", "colors": "Färger", "images": "Bilder", "clearAll": "Rensa alla", "abstract": "Abstrakt", "addCover": "Lägg till omslag", "addLocalImage": "Lägg till lokal bild", "invalidImageUrl": "Ogiltig bildadress", "failedToAddImageToGallery": "Det gick inte att lägga till bild i galleriet", "enterImageUrl": "Ange bildens URL", "add": "Lägg till", "back": "Tillbaka", "saveToGallery": "Spara i galleriet", "removeIcon": "Ta bort ikon", "pasteImageUrl": "Klistra in bildens URL", "or": "ELLER", "pickFromFiles": "Välj från filer", "couldNotFetchImage": "Det gick inte att hämta bilden", "imageSavingFailed": "Det gick inte att spara bild", "addIcon": "Lägg till ikon", "coverRemoveAlert": "Den kommer att tas bort från omslaget efter att den har raderats.", "alertDialogConfirmation": "Är du säker på att du vill fortsätta?" }, "mathEquation": { "addMathEquation": "Lägg till matematikekvation", "editMathEquation": "Redigera matematisk ekvation" }, "optionAction": { "click": "Klick", "toOpenMenu": " för att öppna menyn", "delete": "Radera", "duplicate": "Duplicera", "turnInto": "Bli till", "moveUp": "Flytta upp", "moveDown": "Flytta ner", "color": "Färg", "align": "Justera", "left": "Vänster", "center": "Centrum", "right": "Höger", "defaultColor": "Standard" }, "image": { "copiedToPasteBoard": "Bildlänken har kopierats till urklipp" }, "outline": { "addHeadingToCreateOutline": "Lägg till rubriker för att skapa en innehållsförteckning." } }, "textBlock": { "placeholder": "Skriv '/' för kommandon" }, "title": { "placeholder": "Ofrälse" }, "imageBlock": { "placeholder": "Klicka för att lägga till bild", "upload": { "label": "Ladda upp", "placeholder": "Klicka för att ladda upp bilden" }, "url": { "label": "bild URL", "placeholder": "Ange bildens URL" }, "support": "Bildstorleksgränsen är 5 MB. Format som stöds: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Ogiltig bild", "invalidImageSize": "Bildstorleken måste vara mindre än 5 MB", "invalidImageFormat": "Bildformat stöds inte. Format som stöds: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Ogiltig bildadress" } }, "codeBlock": { "language": { "label": "Språk", "placeholder": "Välj språk" } }, "inlineLink": { "placeholder": "Klistra in eller skriv en länk", "url": { "label": "Länk-URL", "placeholder": "Ange länkens URL" }, "title": { "label": "Länktitel", "placeholder": "Ange länkens titel" } } }, "board": { "column": { "createNewCard": "Nytt" }, "menuName": "Tavla", "referencedBoardPrefix": "Utsikt över", "mobile": { "showGroup": "Visa grupp", "showGroupContent": "Är du säker på att du vill visa den här gruppen på tavlan?", "failedToLoad": "Det gick inte att ladda tavlan" } }, "calendar": { "menuName": "Kalender", "defaultNewCalendarTitle": "Ofrälse", "navigation": { "today": "I dag", "jumpToday": "Hoppa till Idag", "previousMonth": "Förra månaden", "nextMonth": "Nästa månad" }, "settings": { "showWeekNumbers": "Visa veckonummer", "showWeekends": "Visa helger", "firstDayOfWeek": "Börja veckan på", "layoutDateField": "Layoutkalender av", "noDateTitle": "Inget datum", "clickToAdd": "Klicka för att lägga till i kalendern", "name": "Kalenderlayout", "noDateHint": "Icke schemalagda händelser kommer att dyka upp här" }, "referencedCalendarPrefix": "Utsikt över" }, "errorDialog": { "title": "@:appName-fel", "howToFixFallback": "Vi ber om ursäkt för besväret! Skapa en felrapport på vår GitHub-sida som beskriver ditt fel.", "github": "Visa på GitHub" }, "search": { "label": "Sök", "placeholder": { "actions": "Sökåtgärder..." } }, "message": { "copy": { "success": "Kopierade!", "fail": "Det går inte att kopiera" } }, "unSupportBlock": "Den aktuella versionen stöder inte detta block.", "views": { "deleteContentTitle": "Är du säker på att du vill ta bort {pageType}?", "deleteContentCaption": "om du tar bort denna {pageType} kan du återställa den från papperskorgen." } } ================================================ FILE: frontend/resources/translations/th-TH.json ================================================ { "appName": "AppFlowy", "defaultUsername": "ฉัน", "welcomeText": "ยินดีต้อนรับเข้าสู่ @:appName", "welcomeTo": "ยินดีต้อนรับเข้าสู่", "githubStarText": "กดดาวบน GitHub", "subscribeNewsletterText": "สมัครรับจดหมายข่าว", "letsGoButtonText": "เริ่มต้นอย่างรวดเร็ว", "title": "ชื่อ", "youCanAlso": "นอกจากนี้คุณยังสามารถ", "and": "และ", "failedToOpenUrl": "ไม่สามารถเปิด url ได้: {}", "blockActions": { "addBelowTooltip": "คลิกเพื่อเพิ่มด้านล่าง", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "เพื่อเพิ่มด้านบน", "dragTooltip": "ลากเพื่อย้าย", "openMenuTooltip": "คลิกเพื่อเปิดเมนู" }, "signUp": { "buttonText": "ลงทะเบียน", "title": "ลงทะเบียนเพื่อใช้ @:appName", "getStartedText": "เริ่มต้น", "emptyPasswordError": "รหัสผ่านต้องไม่เว้นว่าง", "repeatPasswordEmptyError": "ช่องยืนยันรหัสผ่านต้องไม่เว้นว่าง", "unmatchedPasswordError": "รหัสผ่านที่ยืนยันไม่ตรงกับรหัสผ่าน", "alreadyHaveAnAccount": "มีบัญชีอยู่แล้วหรือไม่?", "emailHint": "อีเมล", "passwordHint": "รหัสผ่าน", "repeatPasswordHint": "ยืนยันรหัสผ่าน", "signUpWith": "ลงทะเบียนกับ:" }, "signIn": { "loginTitle": "เข้าสู่ระบบเพื่อใช้งาน @:appName", "loginButtonText": "เข้าสู่ระบบ", "loginStartWithAnonymous": "เริ่มต้นด้วยเซสชั่นแบบไม่ระบุตัวตน", "continueAnonymousUser": "ดำเนินการต่อด้วยเซสชันแบบไม่ระบุตัวตน", "buttonText": "เข้าสู่ระบบ", "signingInText": "กำลังลงชื่อเข้าใช้...", "forgotPassword": "ลืมรหัสผ่านหรือไม่?", "emailHint": "อีเมล", "passwordHint": "รหัสผ่าน", "dontHaveAnAccount": "ยังไม่มีบัญชีใช่หรือไม่?", "createAccount": "สร้างบัญชี", "repeatPasswordEmptyError": "ช่องยืนยันรหัสผ่านต้องไม่เว้นว่าง", "unmatchedPasswordError": "รหัสผ่านที่ยืนยันไม่ตรงกับรหัสผ่าน", "syncPromptMessage": "กำลังซิงค์ข้อมูล ซึ่งอาจใช้เวลาสักครู่ กรุณาอย่าปิดหน้านี้", "or": "หรือ", "signInWithGoogle": "ดำเนินการต่อด้วย Google", "signInWithGithub": "ดำเนินการต่อด้วย GitHub", "signInWithDiscord": "ดำเนินการต่อด้วย Discord", "signInWithApple": "ดำเนินการต่อด้วย Apple", "continueAnotherWay": "ลองวิธีอื่นเพื่อดำเนินการต่อ", "signUpWithGoogle": "ลงทะเบียนด้วย Google", "signUpWithGithub": "ลงทะเบียนด้วย Github", "signUpWithDiscord": "ลงทะเบียนด้วย Discord", "signInWith": "ลงชื่อเข้าใช้ด้วย:", "signInWithEmail": "ดำเนินการต่อด้วยอีเมล์", "signInWithMagicLink": "ดำเนินการต่อ", "signUpWithMagicLink": "ลงทะเบียนด้วย Magic Link", "pleaseInputYourEmail": "กรุณากรอกที่อยู่อีเมล์ของคุณ", "settings": "การตั้งค่า", "magicLinkSent": "ส่ง Magic Link แล้ว!", "invalidEmail": "กรุณากรอกอีเมลที่ถูกต้อง", "alreadyHaveAnAccount": "มีบัญชีอยู่แล้วใช่ไหม?", "logIn": "เข้าสู่ระบบ", "generalError": "มีบางอย่างผิดพลาด โปรดลองอีกครั้งในภายหลัง", "limitRateError": "ด้วยเหตุผลด้านความปลอดภัย คุณสามารถขอ Magic Link ได้ทุกๆ 60 วินาทีเท่านั้น", "magicLinkSentDescription": "Magic Link ถูกส่งไปยังอีเมลของคุณแล้ว กรุณาคลิกลิงก์เพื่อเข้าสู่ระบบ ลิงก์จะหมดอายุภายใน 5 นาที", "anonymous": "ไม่ระบุตัวตน", "LogInWithGoogle": "เข้าสู่ระบบด้วย Google", "LogInWithGithub": "เข้าสู่ระบบด้วย Github", "LogInWithDiscord": "เข้าสู่ระบบด้วย Discord" }, "workspace": { "chooseWorkspace": "เลือกพื้นที่ทำงานของคุณ", "defaultName": "พื้นที่ทำงานของฉัน", "create": "สร้างพื้นที่ทำงาน", "importFromNotion": "นำเข้าจาก Notion", "learnMore": "เรียนรู้เพิ่มเติม", "reset": "รีเซ็ตพื้นที่ทำงาน", "renameWorkspace": "เปลี่ยนชื่อพื้นที่ทำงาน", "workspaceNameCannotBeEmpty": "ชื่อพื้นที่ทำงานไม่สามารถเว้นว่างได้", "resetWorkspacePrompt": "การรีเซ็ตพื้นที่ทำงานจะลบทุกหน้าและข้อมูลภายในนั้น คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตพื้นที่ทำงาน? หรือคุณสามารถติดต่อทีมสนับสนุนเพื่อกู้คืนพื้นที่ทำงานได้", "hint": "พื้นที่ทำงาน", "notFoundError": "ไม่พบพื้นที่ทำงาน", "failedToLoad": "เกิดข้อผิดพลาด! ไม่สามารถโหลดพื้นที่ทำงานได้ โปรดปิดแอปพลิเคชัน @:appName ที่เปิดอยู่ทั้งหมดแล้วลองอีกครั้ง", "errorActions": { "reportIssue": "รายงานปัญหา", "reportIssueOnGithub": "รายงานปัญหาบน Github", "exportLogFiles": "ส่งออกไฟล์บันทึก", "reachOut": "ติดต่อได้ที่ Discord" }, "menuTitle": "พื้นที่ทำงาน", "deleteWorkspaceHintText": "คุณแน่ใจหรือไม่ว่าต้องการลบพื้นที่ทำงาน? การกระทำนี้ไม่สามารถย้อนกลับได้ และทุกหน้าที่คุณเผยแพร่จะถูกยกเลิกการเผยแพร่", "createSuccess": "สร้างพื้นที่ทำงานสำเร็จแล้ว", "createFailed": "ไม่สามารถสร้างพื้นที่ทำงานได้", "createLimitExceeded": "คุณถึงขีดจำกัดสูงสุดของพื้นที่ทำงานที่บัญชีของคุณสามารถสร้างได้แล้ว หากคุณต้องการพื้นที่ทำงานเพิ่มเติมเพื่อดำเนินการต่อ โปรดส่งคำขอบน Github", "deleteSuccess": "ลบพื้นที่ทำงานสำเร็จแล้ว", "deleteFailed": "ไม่สามารถลบพื้นที่ทำงานได้", "openSuccess": "เปิดพื้นที่ทำงานได้สำเร็จ", "openFailed": "ไม่สามารถเปิดพื้นที่ทำงานได้", "renameSuccess": "เปลี่ยนชื่อพื้นที่ทำงานสำเร็จแล้ว", "renameFailed": "ไม่สามารถเปลี่ยนชื่อพื้นที่ทำงานได้", "updateIconSuccess": "อัปเดตไอคอนพื้นที่ทำงานสำเร็จแล้ว", "updateIconFailed": "ไม่สามารถอัปเดตไอคอนพื้นที่ทำงานได้", "cannotDeleteTheOnlyWorkspace": "ไม่สามารถลบพื้นที่ทำงานเพียงอย่างเดียวได้", "fetchWorkspacesFailed": "ไม่สามารถดึงข้อมูลพื้นที่ทำงานได้", "leaveCurrentWorkspace": "ออกจากพื้นที่ทำงาน", "leaveCurrentWorkspacePrompt": "คุณแน่ใจหรือไม่ว่าต้องการออกจากพื้นที่ทำงานปัจจุบัน?" }, "shareAction": { "buttonText": "แชร์", "workInProgress": "เร็วๆ นี้", "markdown": "Markdown", "html": "HTML", "clipboard": "คัดลอกไปยังคลิปบอร์ด", "csv": "CSV", "copyLink": "คัดลอกลิงค์", "publishToTheWeb": "เผยแพร่สู่เว็บ", "publishToTheWebHint": "สร้างเว็บไซต์ด้วย AppFlowy", "publish": "เผยแพร่", "unPublish": "ยกเลิกการเผยแพร่", "visitSite": "เยี่ยมชมเว็บไซต์", "exportAsTab": "ส่งออกเป็น", "publishTab": "เผยแพร่", "shareTab": "แชร์", "publishOnAppFlowy": "เผยแพร่บน AppFlowy", "shareTabTitle": "เชิญเพื่อการทำงานร่วมกัน", "shareTabDescription": "เพื่อการทำงานร่วมกันอย่างง่ายดายกับใครก็ได้", "copyLinkSuccess": "คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว", "copyShareLink": "คัดลอกลิงก์แชร์", "copyLinkFailed": "ไม่สามารถคัดลอกลิงก์ไปยังคลิปบอร์ดได้", "copyLinkToBlockSuccess": "คัดลอกลิงก์บล็อกไปยังคลิปบอร์ดแล้ว", "copyLinkToBlockFailed": "ไม่สามารถคัดลอกลิงก์บล็อกไปยังคลิปบอร์ด", "manageAllSites": "จัดการไซต์ทั้งหมด", "updatePathName": "อัปเดตชื่อเส้นทาง" }, "moreAction": { "small": "เล็ก", "medium": "กลาง", "large": "ใหญ่", "fontSize": "ขนาดตัวอักษร", "import": "นำเข้า", "moreOptions": "ตัวเลือกเพิ่มเติม", "wordCount": "จำนวนคำ: {}", "charCount": "จำนวนตัวอักษร: {}", "createdAt": "สร้างแล้ว: {}", "deleteView": "ลบ", "duplicateView": "ทำสำเนา" }, "importPanel": { "textAndMarkdown": "ข้อความ & Markdown", "documentFromV010": "เอกสารจาก v0.1.0", "databaseFromV010": "ฐานข้อมูลจาก v0.1.0", "notionZip": "ไฟล์ Zip ที่ส่งออกจาก Notion", "csv": "CSV", "database": "ฐานข้อมูล" }, "disclosureAction": { "rename": "เปลี่ยนชื่อ", "delete": "ลบ", "duplicate": "ทำสำเนา", "unfavorite": "ลบออกจากรายการโปรด", "favorite": "เพิ่มในรายการโปรด", "openNewTab": "เปิดในแท็บใหม่", "moveTo": "ย้ายไปยัง", "addToFavorites": "เพิ่มในรายการโปรด", "copyLink": "คัดลอกลิงค์", "changeIcon": "เปลี่ยนไอคอน", "collapseAllPages": "ยุบหน้าย่อยทั้งหมด" }, "blankPageTitle": "หน้าเปล่า", "newPageText": "หน้าใหม่", "newDocumentText": "เอกสารใหม่", "newGridText": "ตารางใหม่", "newCalendarText": "ปฏิทินใหม่", "newBoardText": "กระดานใหม่", "chat": { "newChat": "แชท AI", "inputMessageHint": "ถาม @:appName AI", "inputLocalAIMessageHint": "ถาม @:appName Local AI", "unsupportedCloudPrompt": "คุณสมบัตินี้ใช้ได้เมื่อใช้ @:appName Cloud เท่านั้น", "relatedQuestion": "ข้อเสนอแนะ", "serverUnavailable": "บริการไม่สามารถใช้งานได้ชั่วคราว กรุณาลองใหม่อีกครั้งในภายหลัง", "aiServerUnavailable": "การเชื่อมต่อขาดหาย กรุณาตรวจสอบอินเทอร์เน็ตของคุณ", "retry": "ลองใหม่อีกครั้ง", "clickToRetry": "คลิกเพื่อลองใหม่อีกครั้ง", "regenerateAnswer": "สร้างใหม่", "question1": "วิธีการใช้ Kanban เพื่อจัดการงาน", "question2": "อธิบายวิธี GTD", "question3": "เหตุใดจึงใช้ Rust", "question4": "สูตรอาหารจากสิ่งที่มีในครัวของฉัน", "question5": "สร้างภาพประกอบให้กับหน้าของฉัน", "question6": "จัดทำรายการสิ่งที่ต้องทำสำหรับสัปดาห์หน้าของฉัน", "aiMistakePrompt": "AI อาจทำผิดพลาดได้ กรุณาตรวจสอบข้อมูลที่สำคัญ", "chatWithFilePrompt": "คุณต้องการแชทกับไฟล์หรือไม่?", "indexFileSuccess": "สร้างดัชนีไฟล์สำเร็จแล้ว", "inputActionNoPages": "ไม่พบผลลัพธ์จากหน้า", "referenceSource": { "zero": "ไม่พบแหล่งข้อมูล 0 รายการ", "one": "พบแหล่งข้อมูล {count} รายการ", "other": "พบแหล่งข้อมูล {count} รายการ" }, "clickToMention": "อ้างอิงถึงหน้า", "uploadFile": "แนบไฟล์ PDF, ข้อความ หรือไฟล์ Markdown", "questionDetail": "สวัสดี {}! วันนี้ฉันสามารถช่วยอะไรคุณได้บ้าง?", "indexingFile": "การจัดทำดัชนี {}", "generatingResponse": "กำลังสร้างการตอบกลับ" }, "trash": { "text": "ขยะ", "restoreAll": "กู้คืนทั้งหมด", "restore": "กู้คืน", "deleteAll": "ลบทั้งหมด", "pageHeader": { "fileName": "ชื่อไฟล์", "lastModified": "แก้ไขครั้งล่าสุด", "created": "สร้างขึ้น" }, "confirmDeleteAll": { "title": "คุณแน่ใจหรือว่าจะลบหน้าทั้งหมดในถังขยะ", "caption": "การดำเนินการนี้ไม่สามารถยกเลิกได้" }, "confirmRestoreAll": { "title": "คุณแน่ใจหรือว่าจะกู้คืนทุกหน้าในถังขยะ", "caption": "การดำเนินการนี้ไม่สามารถยกเลิกได้" }, "restorePage": { "title": "กู้คืน: {}", "caption": "คุณแน่ใจหรือไม่ว่าต้องการกู้คืนหน้านี้?" }, "mobile": { "actions": "การดำเนินการถังขยะ", "empty": "ถังขยะว่างเปล่า", "emptyDescription": "คุณไม่มีไฟล์ที่ถูกลบ", "isDeleted": "ถูกลบแล้ว", "isRestored": "ถูกกู้คืนแล้ว" }, "confirmDeleteTitle": "คุณแน่ใจหรือไม่ว่าต้องการลบหน้านี้อย่างถาวร?" }, "deletePagePrompt": { "text": "หน้านี้อยู่ในถังขยะ", "restore": "กู้คืนหน้า", "deletePermanent": "ลบถาวร", "deletePermanentDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบหน้านี้อย่างถาวร? การกระทำนี้ไม่สามารถย้อนกลับได้" }, "dialogCreatePageNameHint": "ชื่อหน้า", "questionBubble": { "shortcuts": "ทางลัด", "whatsNew": "มีอะไรใหม่?", "markdown": "Markdown", "debug": { "name": "ข้อมูลดีบัก", "success": "คัดลอกข้อมูลดีบักไปยังคลิปบอร์ดแล้ว!", "fail": "ไม่สามารถคัดลอกข้อมูลดีบักไปยังคลิปบอร์ด" }, "feedback": "ข้อเสนอแนะ", "help": "ช่วยเหลือและสนับสนุน" }, "menuAppHeader": { "moreButtonToolTip": "ลบ เปลี่ยนชื่อ และอื่นๆ...", "addPageTooltip": "เพิ่มหน้าข้างในอย่างรวดเร็ว", "defaultNewPageName": "ไม่มีชื่อ", "renameDialog": "เปลี่ยนชื่อ" }, "noPagesInside": "ไม่มีหน้าข้างใน", "toolbar": { "undo": "เลิกทำ", "redo": "ทำซ้ำ", "bold": "ตัวหนา", "italic": "ตัวเอียง", "underline": "ขีดเส้นใต้", "strike": "ขีดฆ่า", "numList": "รายการลำดับตัวเลข", "bulletList": "รายการลำดับหัวข้อย่อย", "checkList": "รายการลำดับ Check", "inlineCode": "อินไลน์โค้ด", "quote": "บล็อกคำกล่าว", "header": "หัวข้อ", "highlight": "ไฮไลท์", "color": "สี", "addLink": "เพิ่มลิงก์", "link": "ลิงก์" }, "tooltip": { "lightMode": "สลับไปที่โหมดสว่าง", "darkMode": "สลับไปที่โหมดมืด", "openAsPage": "เปิดเป็นหน้า", "addNewRow": "เพิ่มแถวใหม่", "openMenu": "คลิกเพื่อเปิดเมนู", "dragRow": "กดค้างเพื่อเรียงลำดับแถวใหม่", "viewDataBase": "ดูฐานข้อมูล", "referencePage": "{name} ถูกอ้างอิงถึง", "addBlockBelow": "เพิ่มบล็อกด้านล่าง", "aiGenerate": "สร้าง" }, "sideBar": { "closeSidebar": "ปิดแถบด้านข้าง", "openSidebar": "เปิดแถบด้านข้าง", "personal": "ส่วนบุคคล", "private": "ส่วนตัว", "workspace": "พื้นที่ทำงาน", "favorites": "รายการโปรด", "clickToHidePrivate": "คลิกเพื่อซ่อนพื้นที่ส่วนตัว\nหน้าที่คุณสร้างที่นี่จะแสดงให้เห็นเฉพาะคุณเท่านั้น", "clickToHideWorkspace": "คลิกเพื่อซ่อนพื้นที่ทำงาน\nหน้าที่คุณสร้างที่นี่จะแสดงให้สมาชิกทุกคนเห็น", "clickToHidePersonal": "คลิกเพื่อซ่อนส่วนส่วนบุคคล", "clickToHideFavorites": "คลิกเพื่อซ่อนส่วนรายการโปรด", "addAPage": "เพิ่มหน้า", "addAPageToPrivate": "เพิ่มหน้าไปยังพื้นที่ส่วนตัว", "addAPageToWorkspace": "เพิ่มหน้าไปยังพื้นที่ทำงาน", "recent": "ล่าสุด", "today": "วันนี้", "thisWeek": "สัปดาห์นี้", "others": "รายการโปรดก่อนหน้านี้", "earlier": "ก่อนหน้านี้", "justNow": "เมื่อสักครู่", "minutesAgo": "{count} นาทีที่ผ่านมา", "lastViewed": "ดูล่าสุด", "favoriteAt": "รายการโปรด", "emptyRecent": "ไม่มีหน้าล่าสุด", "emptyRecentDescription": "เมื่อคุณดูหน้าเพจ หน้าเหล่านั้นจะปรากฏที่นี่เพื่อให้คุณสามารถเรียกดูได้ง่าย", "emptyFavorite": "ไม่มีหน้าโปรด", "emptyFavoriteDescription": "ทำเครื่องหมายหน้าเป็นรายการโปรด—หน้าเหล่านี้จะแสดงไว้ที่นี่เพื่อการเข้าถึงอย่างรวดเร็ว!", "removePageFromRecent": "ลบหน้านี้ออกจากรายการล่าสุดหรือไม่?", "removeSuccess": "ลบสำเร็จ", "favoriteSpace": "รายการโปรด", "RecentSpace": "ล่าสุด", "Spaces": "พื้นที่", "upgradeToPro": "อัพเกรดเป็น Pro", "upgradeToAIMax": "ปลดล็อค AI แบบไม่จำกัด", "storageLimitDialogTitle": "คุณใช้พื้นที่จัดเก็บฟรีหมดแล้ว กรุณาอัปเกรดเพื่อปลดล็อกพื้นที่จัดเก็บแบบไม่จำกัด", "storageLimitDialogTitleIOS": "คุณใช้พื้นที่จัดเก็บฟรีหมดแล้ว", "aiResponseLimitTitle": "คุณใช้จำนวนการตอบกลับ AI ฟรีหมดแล้ว กรุณาอัปเกรดเป็นแผน Pro หรือซื้อส่วนเสริม AI เพื่อปลดล็อกการตอบกลับไม่จำกัด", "aiResponseLimitDialogTitle": "ถึงขีดจำกัดการตอบกลับ AI แล้ว", "aiResponseLimit": "คุณใช้จำนวนการตอบกลับ AI ฟรีหมดแล้ว\nไปที่ การตั้งค่า -> แผน -> คลิก AI Max หรือแผน Pro เพื่อรับการตอบกลับ AI เพิ่มเติม", "askOwnerToUpgradeToPro": "พื้นที่จัดเก็บฟรีในพื้นที่ทำงานของคุณกำลังจะหมด กรุณาขอให้เจ้าของพื้นที่ทำงานของคุณอัปเกรดเป็นแผน Pro", "askOwnerToUpgradeToProIOS": "พื้นที่จัดเก็บฟรีในพื้นที่ทำงานของคุณกำลังจะหมด", "askOwnerToUpgradeToAIMax": "พื้นที่ทำงานของคุณใช้จำนวนการตอบกลับ AI ฟรีหมดแล้ว กรุณาขอให้เจ้าของพื้นที่ทำงานของคุณอัปเกรดแผนหรือซื้อส่วนเสริม AI", "askOwnerToUpgradeToAIMaxIOS": "จำนวนการตอบกลับ AI ฟรี ในพื้นที่ทำงานของคุณใกล้หมดแล้ว", "purchaseStorageSpace": "ซื้อพื้นที่จัดเก็บข้อมูล", "singleFileProPlanLimitationDescription": "คุณอัปโหลดไฟล์เกินขนาดสูงสุดที่อนุญาตในแผนฟรี โปรดอัปเกรดเป็นแผน Pro เพื่ออัปโหลดไฟล์ขนาดใหญ่ขึ้น", "purchaseAIResponse": "ซื้อ ", "askOwnerToUpgradeToLocalAI": "กรุณาขอให้เจ้าของพื้นที่ทำงานเปิดใช้งาน AI บนอุปกรณ์", "upgradeToAILocal": "เรียกใช้โมเดลบนอุปกรณ์ของคุณเพื่อความเป็นส่วนตัวสูงสุด", "upgradeToAILocalDesc": "สนทนากับ PDFs ปรับปรุงการเขียนของคุณ และเติมข้อมูลในตารางอัตโนมัติด้วย AI ภายในเครื่อง" }, "notifications": { "export": { "markdown": "ส่งออกบันทึกย่อไปยัง Markdown", "path": "Documents/flowy" } }, "contactsPage": { "title": "รายชื่อผู้ติดต่อ", "whatsHappening": "เกิดอะไรขึ้นในสัปดาห์นี้บ้าง?", "addContact": "เพิ่มผู้ติดต่อ", "editContact": "แก้ไขผู้ติดต่อ" }, "button": { "ok": "ตกลง", "confirm": "ยืนยัน", "done": "เสร็จแล้ว", "cancel": "ยกเลิก", "signIn": "ลงชื่อเข้าใช้", "signOut": "ออกจากระบบ", "complete": "สมบูรณ์", "save": "บันทึก", "generate": "สร้าง", "esc": "ESC", "keep": "เก็บ", "tryAgain": "ลองอีกครั้ง", "discard": "ทิ้ง", "replace": "แทนที่", "insertBelow": "ใส่ด้านล่าง", "insertAbove": "ใส่ด้านบน", "upload": "อัปโหลด", "edit": "แก้ไข", "delete": "ลบ", "copy": "คัดลอก", "duplicate": "ทำสำเนา", "putback": "นำกลับมา", "update": "อัปโหลด", "share": "แชร์", "removeFromFavorites": "ลบออกจากรายการโปรด", "removeFromRecent": "ลบออกจากรายการล่าสุด", "addToFavorites": "เพิ่มในรายการโปรด", "favoriteSuccessfully": "เพิ่มในรายการที่ชื่นชอบสำเร็จ", "unfavoriteSuccessfully": "นำออกจากรายการที่ชื่นชอบสำเร็จ", "duplicateSuccessfully": "ทำสำเนาสำเร็จแล้ว", "rename": "เปลี่ยนชื่อ", "helpCenter": "ศูนย์ช่วยเหลือ", "add": "เพิ่ม", "yes": "ใช่", "no": "ไม่", "clear": "ล้าง", "remove": "ลบ", "dontRemove": "ไม่ลบ", "copyLink": "คัดลอกลิงค์", "align": "จัดตำแหน่ง", "login": "เข้าสู่ระบบ", "logout": "ออกจากระบบ", "deleteAccount": "ลบบัญชี", "back": "กลับ", "signInGoogle": "ดำเนินการต่อด้วย Google", "signInGithub": "ดำเนินการต่อด้วย GitHub", "signInDiscord": "ดำเนินการต่อด้วย Discord", "more": "เพิ่มเติม", "create": "สร้าง", "close": "ปิด", "next": "ถัดไป", "previous": "ก่อนหน้า", "submit": "ส่ง", "download": "ดาวน์โหลด", "backToHome": "กลับสู่หน้าแรก", "viewing": "การดู", "editing": "การแก้ไข", "gotIt": "เข้าใจแล้ว" }, "label": { "welcome": "ยินดีต้อนรับ!", "firstName": "ชื่อจริง", "middleName": "ชื่อกลาง", "lastName": "นามสกุล", "stepX": "ขั้นตอนที่ {X}" }, "oAuth": { "err": { "failedTitle": "ไม่สามารถเชื่อมต่อกับบัญชีของคุณได้", "failedMsg": "โปรดตรวจสอบให้แน่ใจว่าคุณได้ดำเนินการลงชื่อเข้าใช้ในเบราว์เซอร์ของคุณเรียบร้อยแล้ว" }, "google": { "title": "ลงชื่อเข้าใช้ GOOGLE", "instruction1": "ในการนำเข้า Google Contacts คุณจะต้องอนุญาตแอปพลิเคชันนี้โดยใช้เว็บเบราว์เซอร์ของคุณ", "instruction2": "คัดลอกโค้ดนี้ไปยังคลิปบอร์ดของคุณโดยคลิกที่ไอคอนหรือเลือกข้อความ:", "instruction3": "ไปที่ลิงก์ต่อไปนี้ในเว็บเบราว์เซอร์ของคุณ และป้อนโค้ดด้านบน:", "instruction4": "กดปุ่มด้านล่างเมื่อคุณลงทะเบียนสำเร็จแล้ว:" } }, "settings": { "title": "การตั้งค่า", "popupMenuItem": { "settings": "การตั้งค่า", "members": "สมาชิก", "trash": "ถังขยะ", "helpAndSupport": "ความช่วยเหลือ & การสนับสนุน" }, "sites": { "title": "ไซต์", "namespaceTitle": "เนมสเปซ", "namespaceDescription": "จัดการเนมสเปซและโฮมเพจของคุณ", "namespaceHeader": "เนมสเปซ", "homepageHeader": "หน้าแรก", "updateNamespace": "อัปเดตเนมสเปซ", "removeHomepage": "ลบหน้าแรก", "selectHomePage": "เลือกหน้า", "clearHomePage": "ล้างโฮมเพจสำหรับเนมสเปซนี้", "customUrl": "URL แบบกำหนดเอง", "namespace": { "description": "การเปลี่ยนแปลงนี้จะมีผลกับหน้าที่เผยแพร่ทั้งหมดที่เปิดใช้งานในพื้นที่นี้", "tooltip": "เราขอสงวนสิทธิ์ในการลบเนมสเปซที่ไม่เหมาะสม", "updateExistingNamespace": "อัปเดตเนมสเปซที่มีอยู่", "upgradeToPro": "อัปเกรดเป็นแผน Pro เพื่อกำหนดหน้าโฮมเพจ", "redirectToPayment": "กำลังเปลี่ยนเส้นทางไปยังหน้าชำระเงิน...", "onlyWorkspaceOwnerCanSetHomePage": "เฉพาะเจ้าของพื้นที่ทำงานเท่านั้นที่สามารถกำหนดหน้าโฮมเพจได้", "pleaseAskOwnerToSetHomePage": "กรุณาขอให้เจ้าของพื้นที่ทำงานอัปเกรดเป็นแผน Pro" }, "publishedPage": { "title": "หน้าที่เผยแพร่ทั้งหมด", "description": "จัดการหน้าที่เผยแพร่ของคุณ", "page": "หน้า", "pathName": "ชื่อเส้นทาง", "date": "วันที่เผยแพร่", "emptyHinText": "คุณไม่มีหน้าที่เผยแพร่ในพื้นที่ทำงานนี้", "noPublishedPages": "ยังไม่มีหน้าที่เผยแพร่", "settings": "การตั้งค่าการเผยแพร่", "clickToOpenPageInApp": "เปิดหน้าในแอป", "clickToOpenPageInBrowser": "เปิดหน้าในเบราว์เซอร์" }, "error": { "failedToGeneratePaymentLink": "ไม่สามารถสร้างลิงก์การชำระเงินสำหรับแผน Pro ได้", "failedToUpdateNamespace": "ไม่สามารถอัปเดตเนมสเปซได้", "proPlanLimitation": "คุณต้องอัปเกรดเป็นแผน Pro เพื่ออัปเดตเนมสเปซ", "namespaceAlreadyInUse": "เนมสเปซนี้ถูกใช้ไปแล้ว กรุณาลองชื่ออื่น", "invalidNamespace": "เนมสเปซไม่ถูกต้อง กรุณาลองชื่ออื่น", "namespaceLengthAtLeast2Characters": "เนมสเปซต้องมีความยาวอย่างน้อย 2 ตัวอักษร", "onlyWorkspaceOwnerCanUpdateNamespace": "เฉพาะเจ้าของพื้นที่ทำงานเท่านั้นที่สามารถอัปเดตเนมสเปซได้", "onlyWorkspaceOwnerCanRemoveHomepage": "เฉพาะเจ้าของพื้นที่ทำงานเท่านั้นที่สามารถลบหน้าโฮมเพจได้", "setHomepageFailed": "ไม่สามารถตั้งค่าหน้าแรกได้", "namespaceTooLong": "เนมสเปซยาวเกินไป กรุณาลองชื่ออื่น", "namespaceTooShort": "เนมสเปซสั้นเกินไป กรุณาลองชื่ออื่น", "namespaceIsReserved": "เนมสเปซนี้ถูกจองไว้แล้ว กรุณาลองชื่ออื่น", "updatePathNameFailed": "ไม่สามารถอัปเดตชื่อเส้นทางได้", "removeHomePageFailed": "ไม่สามารถลบหน้าแรกได้", "publishNameContainsInvalidCharacters": "ชื่อเส้นทางมีอักขระที่ไม่ถูกต้อง โปรดลองอักขระอื่น", "publishNameTooShort": "ชื่อเส้นทางสั้นเกินไป กรุณาลองชื่ออื่น", "publishNameTooLong": "ชื่อเส้นทางยาวเกินไป กรุณาลองชื่ออื่น", "publishNameAlreadyInUse": "ชื่อเส้นทางนี้ถูกใช้ไปแล้ว กรุณาลองชื่ออื่น", "namespaceContainsInvalidCharacters": "เนมสเปซมีอักขระที่ไม่ถูกต้อง โปรดลองอักขระอื่น", "publishPermissionDenied": "เฉพาะเจ้าของพื้นที่ทำงานหรือผู้เผยแพร่เพจเท่านั้นที่สามารถจัดการการตั้งค่าการเผยแพร่ได้", "publishNameCannotBeEmpty": "ชื่อเส้นทางไม่สามารถเว้นว่างได้ กรุณาลองชื่ออื่น" }, "success": { "namespaceUpdated": "อัปเดตเนมสเปซสำเร็จ", "setHomepageSuccess": "ตั้งค่าหน้าแรกสำเร็จ", "updatePathNameSuccess": "อัปเดตชื่อเส้นทางสำเร็จ", "removeHomePageSuccess": "ลบหน้าแรกสำเร็จ" } }, "accountPage": { "menuLabel": "บัญชีของฉัน", "title": "บัญชีของฉัน", "general": { "title": "ชื่อบัญชีและรูปโปรไฟล์", "changeProfilePicture": "เปลี่ยนรูปโปรไฟล์" }, "email": { "title": "อีเมล", "actions": { "change": "เปลี่ยนอีเมล" } }, "login": { "title": "การเข้าสู่ระบบบัญชี", "loginLabel": "เข้าสู่ระบบ", "logoutLabel": "ออกจากระบบ" } }, "workspacePage": { "menuLabel": "พื้นที่ทำงาน", "title": "พื้นที่ทำงาน", "description": "ปรับแต่งรูปลักษณ์ของพื้นที่ทำงาน ธีม แบบอักษร เค้าโครงข้อความ รูปแบบวันที่/เวลา และภาษา", "workspaceName": { "title": "ชื่อพื้นที่ทำงาน" }, "workspaceIcon": { "title": "ไอคอนพื้นที่ทำงาน", "description": "อัปโหลดรูปภาพ หรือใช้อีโมจิสำหรับพื้นที่ทำงานของคุณ ไอคอนจะแสดงในแถบด้านข้าง และการแจ้งเตือนของคุณ" }, "appearance": { "title": "ลักษณะการแสดงผล", "description": "ปรับแต่งรูปลักษณ์พื้นที่ทำงาน ธีม แบบอักษร เค้าโครงข้อความ วันที่ เวลา และภาษาของคุณ", "options": { "system": "อัตโนมัติ", "light": "สว่าง", "dark": "มืด" } }, "resetCursorColor": { "title": "รีเซ็ตสีของเคอร์เซอร์ในเอกสาร", "description": "คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตสีของเคอร์เซอร์?" }, "resetSelectionColor": { "title": "รีเซ็ตสีการเลือกในเอกสาร", "description": "คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตสีการเลือก?" }, "resetWidth": { "resetSuccess": "รีเซ็ตความกว้างของเอกสารสำเร็จ" }, "theme": { "title": "ธีม", "description": "เลือกธีมที่ตั้งไว้ล่วงหน้าหรืออัปโหลดธีมที่คุณกำหนดเอง", "uploadCustomThemeTooltip": "อัพโหลดธีมที่กำหนดเอง" }, "workspaceFont": { "title": "ฟอนต์ของพื้นที่ทำงาน", "noFontHint": "ไม่พบฟอนต์ กรุณาลองคำค้นหาอื่น" }, "textDirection": { "title": "ทิศทางข้อความ", "leftToRight": "จากซ้ายไปขวา", "rightToLeft": "จากขวาไปซ้าย", "auto": "อัตโนมัติ", "enableRTLItems": "เปิดใช้งานแถบเครื่องมือ RTL (จากขวาไปซ้าย)" }, "layoutDirection": { "title": "ทิศทางการจัดวาง", "leftToRight": "จากซ้ายไปขวา", "rightToLeft": "จากขวาไปซ้าย" }, "dateTime": { "title": "วันที่และเวลา", "example": "{} ที่ {} ({})", "24HourTime": "เวลาแบบ 24 ชั่วโมง", "dateFormat": { "label": "รูปแบบวันที่", "local": "ท้องถิ่น", "us": "US", "iso": "ISO", "friendly": "ที่เข้าใจง่าย", "dmy": "D/M/Y" } }, "language": { "title": "ภาษา" }, "deleteWorkspacePrompt": { "title": "ลบพื้นที่ทำงาน", "content": "คุณแน่ใจหรือไม่ว่าต้องการลบพื้นที่ทำงานนี้? การดำเนินการนี้ไม่สามารถย้อนกลับได้ และหน้าใดๆ ที่คุณเผยแพร่ไปแล้วจะถูกยกเลิกการเผยแพร่" }, "leaveWorkspacePrompt": { "title": "ออกจากพื้นที่ทำงาน", "content": "คุณแน่ใจหรือไม่ว่าต้องการออกจากพื้นที่ทำงานนี้? คุณจะสูญเสียการเข้าถึงหน้าและข้อมูลทั้งหมดภายในพื้นที่นี้", "success": "คุณได้ออกจากพื้นที่ทำงานเรียบร้อยแล้ว", "fail": "ไม่สามารถออกจากพื้นที่ทำงานได้" }, "manageWorkspace": { "title": "จัดการพื้นที่ทำงาน", "leaveWorkspace": "ออกจากพื้นที่ทำงาน", "deleteWorkspace": "ลบพื้นที่ทำงาน" } }, "manageDataPage": { "menuLabel": "การจัดการข้อมูล", "title": "การจัดการข้อมูล", "description": "จัดการพื้นที่จัดเก็บข้อมูลในเครื่องหรือนำเข้าข้อมูลที่มีอยู่ของคุณลงใน @:appName", "dataStorage": { "title": "ตำแหน่งจัดเก็บไฟล์", "tooltip": "ตำแหน่งที่จัดเก็บไฟล์ของคุณ", "actions": { "change": "เปลี่ยนเส้นทาง", "open": "เปิดโฟลเดอร์", "openTooltip": "เปิดตำแหน่งโฟลเดอร์ข้อมูลปัจจุบัน", "copy": "คัดลอกเส้นทาง", "copiedHint": "คัดลอกเส้นทางแล้ว!", "resetTooltip": "รีเซ็ตเป็นตำแหน่งเริ่มต้น" }, "resetDialog": { "title": "คุณแน่ใจหรือไม่?", "description": "การรีเซ็ตเส้นทางไปยังตำแหน่งข้อมูลเริ่มต้นจะไม่ลบข้อมูลของคุณ หากคุณต้องการนำเข้าข้อมูลปัจจุบันอีกครั้ง ควรคัดลอกเส้นทางของตำแหน่งปัจจุบันก่อน" } }, "importData": { "title": "นำเข้าข้อมูล", "tooltip": "นำเข้าข้อมูลจากการสำรองข้อมูล/โฟลเดอร์ข้อมูล @:appName", "description": "คัดลอกข้อมูลจากโฟลเดอร์ข้อมูลภายนอกของ @:appName", "action": "เรียกดูไฟล์" }, "encryption": { "title": "การเข้ารหัส", "tooltip": "จัดการวิธีการจัดเก็บและเข้ารหัสข้อมูลของคุณ", "descriptionNoEncryption": "การเปิดใช้งานการเข้ารหัสจะทำให้ข้อมูลทั้งหมดถูกเข้ารหัส การกระทำนี้ไม่สามารถย้อนกลับได้", "descriptionEncrypted": "ข้อมูลของคุณได้รับการเข้ารหัสแล้ว", "action": "เข้ารหัสข้อมูล", "dialog": { "title": "ต้องการเข้ารหัสข้อมูลทั้งหมดของคุณหรือไม่?", "description": "การเข้ารหัสข้อมูลทั้งหมดของคุณจะช่วยให้ข้อมูลของคุณปลอดภัยและมั่นคง การดำเนินการนี้ไม่สามารถย้อนกลับได้ คุณแน่ใจหรือไม่ว่าต้องการดำเนินการต่อ?" } }, "cache": { "title": "ล้างแคช", "description": "ช่วยแก้ไขปัญหาต่างๆ เช่น รูปภาพไม่โหลด หน้าหายไปในช่องว่าง และฟอนต์ไม่โหลด ซึ่งจะไม่กระทบกับข้อมูลของคุณ", "dialog": { "title": "ล้างแคช", "description": "ช่วยแก้ไขปัญหาต่างๆ เช่น รูปภาพไม่โหลด หน้าหายไปในช่องว่าง และฟอนต์ไม่โหลด ซึ่งจะไม่กระทบกับข้อมูลของคุณ", "successHint": "ล้างแคชแล้ว!" } }, "data": { "fixYourData": "ซ่อมแซมข้อมูลของคุณ", "fixButton": "ซ่อมแซม", "fixYourDataDescription": "หากคุณพบปัญหากับข้อมูลของคุณ คุณสามารถลองแก้ไขได้ที่นี่" } }, "shortcutsPage": { "menuLabel": "ทางลัด", "title": "ทางลัด", "editBindingHint": "ป้อนการผูกปุ่มลัดใหม่", "searchHint": "ค้นหา", "actions": { "resetDefault": "รีเซ็ตค่าเริ่มต้น" }, "errorPage": { "message": "ไม่สามารถโหลดทางลัดได้: {}", "howToFix": "โปรดลองอีกครั้ง หากปัญหายังคงอยู่ โปรดติดต่อบน GitHub" }, "resetDialog": { "title": "รีเซ็ตทางลัด", "description": "การดำเนินการนี้จะรีเซ็ตการตั้งค่าปุ่มทั้งหมดของคุณเป็นค่าเริ่มต้น คุณไม่สามารถย้อนกลับได้ในภายหลัง คุณแน่ใจหรือไม่ว่าต้องการดำเนินการต่อ?", "buttonLabel": "รีเซ็ต" }, "conflictDialog": { "title": "{} กำลังใช้งานอยู่ในขณะนี้", "descriptionPrefix": "การกำหนดปุ่มลัดนี้กำลังถูกใช้งานโดย", "descriptionSuffix": ". หากคุณแทนที่ปุ่มลัดนี้ ปุ่มลัดนี้จะถูกลบออกจาก {}", "confirmLabel": "ดำเนินการต่อ" }, "editTooltip": "กดเพื่อเริ่มแก้ไขปุ่มลัด", "keybindings": { "toggleToDoList": "เปิดปิดรายการสิ่งที่ต้องทำ", "insertNewParagraphInCodeblock": "แทรกย่อหน้าใหม่", "pasteInCodeblock": "วางในโค้ดบล็อค", "selectAllCodeblock": "เลือกทั้งหมด", "indentLineCodeblock": "แทรกช่องว่างสองช่องที่จุดเริ่มต้นของบรรทัด", "outdentLineCodeblock": "ลบช่องว่างสองช่องที่จุดเริ่มต้นของบรรทัด", "twoSpacesCursorCodeblock": "แทรกช่องว่างสองช่องที่เคอร์เซอร์", "copy": "คัดลอกการเลือก", "paste": "วางในเนื้อหา", "cut": "ตัดการเลือก", "alignLeft": "จัดข้อความให้ชิดซ้าย", "alignCenter": "จัดตำแหน่งข้อความให้อยู่กึ่งกลาง", "alignRight": "จัดข้อความให้ชิดขวา", "undo": "เลิกทำ", "redo": "ทำซ้ำ", "convertToParagraph": "แปลงบล็อกเป็นย่อหน้า", "backspace": "ลบ", "deleteLeftWord": "ลบคำซ้าย", "deleteLeftSentence": "ลบประโยคซ้าย", "delete": "ลบตัวอักษรขวา", "deleteMacOS": "ลบตัวอักษรซ้าย", "deleteRightWord": "ลบคำขวา", "moveCursorLeft": "เลื่อนเคอร์เซอร์ไปทางซ้าย", "moveCursorBeginning": "เลื่อนเคอร์เซอร์ไปที่จุดเริ่มต้น", "moveCursorLeftWord": "เลื่อนเคอร์เซอร์ไปทางซ้ายหนึ่งคำ", "moveCursorLeftSelect": "เลือกและเลื่อนเคอร์เซอร์ไปทางซ้าย", "moveCursorBeginSelect": "เลือกและเลื่อนเคอร์เซอร์ไปที่จุดเริ่มต้น", "moveCursorLeftWordSelect": "เลือกและเลื่อนเคอร์เซอร์ไปทางซ้ายหนึ่งคำ", "moveCursorRight": "เลื่อนเคอร์เซอร์ไปทางขวา", "moveCursorEnd": "เลื่อนเคอร์เซอร์ไปที่ท้ายสุด", "moveCursorRightWord": "เลื่อนเคอร์เซอร์ไปทางขวาหนึ่งคำ", "moveCursorRightSelect": "เลือกและเลื่อนเคอร์เซอร์ไปทางขวาหนึ่งช่อง", "moveCursorEndSelect": "เลือกและเลื่อนเคอร์เซอร์ไปที่จุดสิ้นสุด", "moveCursorRightWordSelect": "เลือกและเลื่อนเคอร์เซอร์ไปทางขวาหนึ่งคำ", "moveCursorUp": "เลื่อนเคอร์เซอร์ขึ้น", "moveCursorTopSelect": "เลือกและเลื่อนเคอร์เซอร์ไปด้านบน", "moveCursorTop": "เลื่อนเคอร์เซอร์ไปด้านบน", "moveCursorUpSelect": "เลือกและเลื่อนเคอร์เซอร์ขึ้น", "moveCursorBottomSelect": "เลือกและเลื่อนเคอร์เซอร์ไปที่ด้านล่าง", "moveCursorBottom": "เลื่อนเคอร์เซอร์ไปที่ด้านล่าง", "moveCursorDown": "เลื่อนเคอร์เซอร์ลง", "moveCursorDownSelect": "เลือกและเลื่อนเคอร์เซอร์ลง", "home": "เลื่อนไปด้านบน", "end": "เลื่อนไปด้านล่าง", "toggleBold": "เปิดปิดตัวหนา", "toggleItalic": "เปิดปิดตัวเอียง", "toggleUnderline": "เปิดปิดขีดเส้นใต้", "toggleStrikethrough": "เปิดปิดการขีดฆ่า", "toggleCode": "เปิดปิดโค้ดในบรรทัด", "toggleHighlight": "เปิดปิดไฮไลท์", "showLinkMenu": "แสดงลิงค์เมนู", "openInlineLink": "เปิดลิงก์ในบรรทัด", "openLinks": "เปิดลิงก์ที่เลือกทั้งหมด", "indent": "เยื้อง", "outdent": "เยื้องออก", "exit": "ออกจากการแก้ไข", "pageUp": "เลื่อนขึ้นไปหนึ่งหน้า", "pageDown": "เลื่อนลงมาหนึ่งหน้า", "selectAll": "เลือกทั้งหมด", "pasteWithoutFormatting": "วางเนื้อหาที่ไม่มีการจัดรูปแบบ", "showEmojiPicker": "แสดงตัวเลือกอีโมจิ", "enterInTableCell": "เพิ่มการแบ่งบรรทัดในตาราง", "leftInTableCell": "เลื่อนไปทางซ้ายหนึ่งเซลล์ในตาราง", "rightInTableCell": "เลื่อนไปทางขวาหนึ่งเซลล์ในตาราง", "upInTableCell": "เลื่อนขึ้นหนึ่งเซลล์ในตาราง", "downInTableCell": "เลื่อนลงหนึ่งเซลล์ในตาราง", "tabInTableCell": "ไปยังเซลล์ถัดไปที่ว่างในตาราง", "shiftTabInTableCell": "ไปยังเซลล์ก่อนหน้าที่ว่างในตาราง", "backSpaceInTableCell": "หยุดที่จุดเริ่มต้นของเซลล์" }, "commands": { "codeBlockNewParagraph": "แทรกย่อหน้าใหม่ถัดจากโค้ดบล็อก", "codeBlockIndentLines": "แทรกช่องว่างสองช่องที่จุดเริ่มต้นบรรทัดในโค้ดบล็อก", "codeBlockOutdentLines": "ลบช่องว่างสองช่องที่จุดเริ่มต้นบรรทัดในโค้ดบล็อค", "codeBlockAddTwoSpaces": "แทรกช่องว่างสองช่องที่ตำแหน่งเคอร์เซอร์ในโค้ดบล็อก", "codeBlockSelectAll": "เลือกเนื้อหาทั้งหมดภายในโค้ดบล็อก", "codeBlockPasteText": "วางข้อความลงในโค้ดบล็อค", "textAlignLeft": "จัดตำแหน่งข้อความให้ชิดซ้าย", "textAlignCenter": "จัดตำแหน่งข้อความให้อยู่กึ่งกลาง", "textAlignRight": "จัดตำแหน่งข้อความให้ชิดขวา" }, "couldNotLoadErrorMsg": "ไม่สามารถโหลดทางลัดได้ ลองอีกครั้ง", "couldNotSaveErrorMsg": "ไม่สามารถบันทึกทางลัดได้ โปรดลองอีกครั้ง" }, "aiPage": { "title": "การตั้งค่า AI", "menuLabel": "การตั้งค่า AI", "keys": { "enableAISearchTitle": "การค้นหาด้วย AI", "aiSettingsDescription": "เลือกโมเดลที่คุณต้องการใช้เพื่อขับเคลื่อน AppFlowy AI ขณะนี้มี GPT-4, Claude 3.5, Llama 3.1, และ Mistral 7B", "loginToEnableAIFeature": "ฟีเจอร์ AI จะเปิดใช้งานได้หลังจากเข้าสู่ระบบด้วย @:appName Cloud เท่านั้น หากคุณไม่มีบัญชี @:appName ให้ไปที่ 'บัญชีของฉัน' เพื่อลงทะเบียน", "llmModel": "โมเดลภาษา", "llmModelType": "ประเภทของโมเดลภาษา", "downloadLLMPrompt": "ดาวน์โหลด {}", "downloadAppFlowyOfflineAI": "การดาวน์โหลดแพ็กเกจ AI แบบออฟไลน์จะทำให้ AI สามารถทำงานบนอุปกรณ์ของคุณได้ คุณต้องการดำเนินการต่อหรือไม่?", "downloadLLMPromptDetail": "การดาวน์โหลดโมเดล {} ในเครื่องของคุณ จะใช้พื้นที่เก็บข้อมูลสูงสุด {} คุณต้องการดำเนินการต่อหรือไม่", "downloadBigFilePrompt": "การดาวน์โหลดอาจใช้เวลาประมาณ 10 นาทีให้เสร็จสิ้น", "downloadAIModelButton": "ดาวน์โหลด", "downloadingModel": "กำลังดาวน์โหลด", "localAILoaded": "เพิ่มโมเดล AI ในเครื่องสำเร็จ และพร้อมใช้งานแล้ว", "localAIStart": "การแชท Local AI กำลังเริ่มต้น...", "localAILoading": "กำลังโหลดโมเดลแชท Local AI...", "localAIStopped": "Local AI หยุดทำงานแล้ว", "failToLoadLocalAI": "ไม่สามารถเริ่มต้น Local AI ได้", "restartLocalAI": "เริ่มต้น Local AI ใหม่", "disableLocalAITitle": "ปิดการใช้งาน Local AI", "disableLocalAIDescription": "คุณต้องการปิดการใช้งาน Local AI หรือไม่?", "localAIToggleTitle": "สลับเพื่อเปิดหรือปิดใช้งาน Local AI", "offlineAIInstruction1": "ติดตาม", "offlineAIInstruction2": "คำแนะนำ", "offlineAIInstruction3": "เพื่อเปิดใช้งาน AI แบบออฟไลน์", "offlineAIDownload1": "หากคุณยังไม่ได้ดาวน์โหลด AppFlowy AI, กรุณา", "offlineAIDownload2": "ดาวน์โหลด", "offlineAIDownload3": "เป็นอันดับแรก", "activeOfflineAI": "ใช้งานอยู่", "downloadOfflineAI": "ดาวน์โหลด", "openModelDirectory": "เปิดโฟลเดอร์" } }, "planPage": { "menuLabel": "แผน", "title": "แผนราคา", "planUsage": { "title": "สรุปการใช้งานแผน", "storageLabel": "พื้นที่จัดเก็บ", "storageUsage": "{} จาก {} GB", "unlimitedStorageLabel": "พื้นที่เก็บข้อมูลไม่จำกัด", "collaboratorsLabel": "สมาชิก", "collaboratorsUsage": "{} จาก {}", "aiResponseLabel": "การตอบกลับของ AI", "aiResponseUsage": "{} จาก {}", "unlimitedAILabel": "การตอบกลับแบบไม่จำกัด", "proBadge": "Pro", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "AI บนอุปกรณ์สำหรับ Mac", "memberProToggle": "สมาชิกมากขึ้น และ AI ไม่จำกัด", "aiMaxToggle": "AI ไม่จำกัด และการเข้าถึงโมเดลขั้นสูง", "aiOnDeviceToggle": "Local AI เพื่อความเป็นส่วนตัวสูงสุด", "aiCredit": { "title": "เพิ่ม @:appName AI เครดิต", "price": "{}", "priceDescription": "สำหรับ 1,000 เครดิต", "purchase": "ซื้อ AI", "info": "เพิ่มเครดิต AI 1,000 เครดิตต่อพื้นที่ทำงาน และผสานรวม AI ที่สามารถปรับแต่งได้ เข้าไปในกระบวนการทำงานของคุณได้อย่างราบรื่น เพื่อผลลัพธ์ที่ชาญฉลาด และรวดเร็วยิ่งขึ้นด้วย สูงสุดถึง:", "infoItemOne": "10,000 การตอบกลับต่อฐานข้อมูล", "infoItemTwo": "1,000 การตอบกลับต่อพื้นที่ทำงาน" }, "currentPlan": { "bannerLabel": "แผนปัจจุบัน", "freeTitle": "Free", "proTitle": "Pro", "teamTitle": "Team", "freeInfo": "เหมาะสำหรับบุคคลที่มีสมาชิกสูงสุด 2 คน เพื่อจัดระเบียบทุกอย่าง", "proInfo": "เหมาะสำหรับทีมขนาดเล็ก และขนาดกลางที่มีสมาชิกไม่เกิน 10 คน", "teamInfo": "เหมาะสำหรับทีมที่มีประสิทธิภาพ และการจัดระเบียบที่ดี", "upgrade": "เปลี่ยนแผน", "canceledInfo": "แผนของคุณถูกยกเลิก คุณจะถูกปรับลดเป็นแผน Free ในวันที่ {}" }, "addons": { "title": "ส่วนเสริม", "addLabel": "เพิ่ม", "activeLabel": "เพิ่มแล้ว", "aiMax": { "title": "AI Max", "description": "การตอบกลับ AI แบบไม่จำกัด ที่ขับเคลื่อนโดย GPT-4o, Claude 3.5 Sonnet และอื่นๆ", "price": "{}", "priceInfo": "ต่อผู้ใช้ ต่อเดือน เก็บค่าบริการเป็นรายปี" }, "aiOnDevice": { "title": "AI บนอุปกรณ์สำหรับ Mac", "description": "เรียกใช้ Mistral 7B, LLAMA 3 และโมเดล local อื่น ๆ บนเครื่องของคุณ", "price": "{}", "priceInfo": "ต่อผู้ใช้ ต่อเดือน เก็บค่าบริการเป็นรายปี", "recommend": "แนะนำ M1 หรือใหม่กว่า" } }, "deal": { "bannerLabel": "ดีลปีใหม่!", "title": "ขยายทีมของคุณ!", "info": "อัปเกรด และรับส่วนลด 10% สำหรับแผน Pro และ Team! เพิ่มประสิทธิภาพการทำงานในพื้นที่ทำงานของคุณด้วยฟีเจอร์ใหม่อันทรงพลัง รวมถึง @:appName AI", "viewPlans": "ดูแผน" } } }, "billingPage": { "menuLabel": "การเรียกเก็บเงิน", "title": "การเรียกเก็บเงิน", "plan": { "title": "แผน", "freeLabel": "Free", "proLabel": "Pro", "planButtonLabel": "เปลี่ยนแผน", "billingPeriod": "รอบบิล", "periodButtonLabel": "แก้ไขช่วงเวลา" }, "paymentDetails": { "title": "รายละเอียดการชำระเงิน", "methodLabel": "วิธีการชำระเงิน", "methodButtonLabel": "แก้ไขวิธีการ" }, "addons": { "title": "ส่วนเสริม", "addLabel": "เพิ่ม", "removeLabel": "ลบ", "renewLabel": "ต่ออายุ", "aiMax": { "label": "AI Max", "description": "ปลดล็อค AI และโมเดลขั้นสูงแบบไม่จำกัด", "activeDescription": "ใบแจ้งหนี้ถัดไปจะครบกำหนดในวันที่ {}", "canceledDescription": "AI Max จะพร้อมใช้งานจนถึงวันที่ {}" }, "aiOnDevice": { "label": "AI บนอุปกรณ์สำหรับ Mac", "description": "ปลดล็อค AI ไม่จำกัดบนอุปกรณ์ของคุณ", "activeDescription": "ใบแจ้งหนี้ถัดไปจะครบกำหนดในวันที่ {}", "canceledDescription": "AI บนอุปกรณ์สำหรับ Mac จะพร้อมใช้งานจนถึงวันที่ {}" }, "removeDialog": { "title": "ลบ {}", "description": "คุณแน่ใจหรือไม่ว่าต้องการลบ {plan}? คุณจะสูญเสียการเข้าถึงฟีเจอร์ และสิทธิประโยชน์ของ {plan} ทันที" } }, "currentPeriodBadge": "ปัจจุบัน", "changePeriod": "การเปลี่ยนแปลงช่วงเวลา", "planPeriod": "{} รอบ", "monthlyInterval": "รายเดือน", "monthlyPriceInfo": "คิดค่าบริการต่อที่นั่งแบบรายเดือน", "annualInterval": "รายปี", "annualPriceInfo": "คิดค่าบริการต่อที่นั่งแบบรายปี" }, "comparePlanDialog": { "title": "เปรียบเทียบและเลือกแผน", "planFeatures": "แผน\nฟีเจอร์", "current": "ปัจจุบัน", "actions": { "upgrade": "อัพเกรด", "downgrade": "ลดระดับ", "current": "ปัจจุบัน" }, "freePlan": { "title": "Free", "description": "สำหรับบุคคลตั้งแต่ 2 คนขึ้นไป เพื่อจัดระเบียบทุกอย่าง", "price": "{}", "priceInfo": "ฟรีตลอดไป" }, "proPlan": { "title": "Pro", "description": "สำหรับทีมขนาดเล็กเพื่อจัดการโครงการและความรู้ของทีม", "price": "{}", "priceInfo": "ต่อผู้ใช้ ต่อเดือน\nเรียกเก็บค่าบริการรายปี\n{} เรียกเก็บค่าบริการรายเดือน" }, "planLabels": { "itemOne": "พื้นที่ทำงาน", "itemTwo": "สมาชิก", "itemThree": "พื้นที่จัดเก็บ", "itemFour": "การทำงานร่วมกันแบบเรียลไทม์", "itemFive": "แอพมือถือ", "itemSix": "การตอบกลับของ AI", "itemFileUpload": "การอัพโหลดไฟล์", "customNamespace": "เนมสเปซที่กำหนดเอง", "tooltipSix": "ตลอดอายุการใช้งาน หมายถึง จำนวนการตอบกลับที่ไม่รีเซ็ต", "intelligentSearch": "การค้นหาอัจฉริยะ", "tooltipSeven": "อนุญาตให้คุณปรับแต่งส่วนหนึ่งของ URL สำหรับพื้นที่ทำงานของคุณ", "customNamespaceTooltip": "URL ของไซต์ที่เผยแพร่แบบกำหนดเอง" }, "freeLabels": { "itemOne": "คิดค่าบริการตามพื้นที่ทำงาน", "itemTwo": "สูงสุด 2", "itemThree": "5 GB", "itemFour": "ใช่", "itemFive": "ใช่", "itemSix": "10 ตลอดอายุการใช้งาน", "itemFileUpload": "ไม่เกิน 7 MB", "intelligentSearch": "การค้นหาอัจฉริยะ" }, "proLabels": { "itemOne": "คิดค่าบริการตามพื้นที่ทำงาน", "itemTwo": "สูงถึง 10", "itemThree": "ไม่จำกัด", "itemFour": "ใช่", "itemFive": "ใช่", "itemSix": "ไม่จำกัด", "itemFileUpload": "ไม่จำกัด", "intelligentSearch": "การค้นหาอัจฉริยะ" }, "paymentSuccess": { "title": "ตอนนี้คุณอยู่ในแผน {} แล้ว!", "description": "การชำระเงินของคุณได้รับการดำเนินการเรียบร้อย แล้วและแผนของคุณได้รับการอัปเกรดเป็น @:appName {} คุณสามารถดูรายละเอียดแผนของคุณได้ในหน้าแผน" }, "downgradeDialog": { "title": "คุณแน่ใจหรือไม่ว่าต้องการลดระดับแผนของคุณ?", "description": "การลดระดับแผนของคุณจะทำให้คุณกลับไปใช้แผนฟรี สมาชิกอาจสูญเสียสิทธิ์การเข้าถึงพื้นที่ทำงานนี้ และคุณอาจต้องเพิ่มพื้นที่ว่างเพื่อให้ตรงตามขีดจำกัดพื้นที่เก็บข้อมูลของแผนฟรี", "downgradeLabel": "การลดระดับแผน" } }, "cancelSurveyDialog": { "title": "เสียใจที่เห็นคุณไป", "description": "เราเสียใจที่เห็นคุณไป เรายินดีรับฟังคำติชมของคุณเพื่อช่วยเราปรับปรุง @:appName โปรดใช้เวลาสักครู่เพื่อตอบคำถามสองสามข้อ", "commonOther": "อื่นๆ", "otherHint": "เขียนคำตอบของคุณที่นี่", "questionOne": { "question": "อะไรเป็นเหตุผลที่ทำให้คุณยกเลิกการสมัครสมาชิก @:appName Pro?", "answerOne": "ราคาสูงเกินไป", "answerTwo": "ฟีเจอร์ไม่เป็นไปตามที่คาดไว้", "answerThree": "พบทางเลือกที่ดีกว่า", "answerFour": "ใช้ไม่มากพอที่จะคุ้มกับค่าใช้จ่าย", "answerFive": "ปัญหาด้านการบริการหรือความยุ่งยากทางเทคนิค" }, "questionTwo": { "question": "คุณมีความเป็นไปได้มากแค่ไหนที่จะพิจารณากลับไปสมัครสมาชิก @:appName Pro ในอนาคต?", "answerOne": "เป็นไปได้มาก", "answerTwo": "ค่อนข้างจะเป็นไปได้", "answerThree": "ไม่แน่ใจ", "answerFour": "ไม่น่าจะเป็นไปได้", "answerFive": "ไม่น่าจะเป็นไปได้มาก" }, "questionThree": { "question": "ฟีเจอร์ Pro อะไรที่คุณคิดว่าคุ้มค่าที่สุดในระหว่างที่ใช้บริการ?", "answerOne": "การทำงานร่วมกันระหว่างผู้ใช้หลายราย", "answerTwo": "ประวัติเวอร์ชันที่ยาวนานกว่า", "answerThree": "การตอบกลับของ AI ไม่จำกัด", "answerFour": "การเข้าถึงโมเดล AI ในเครื่อง" }, "questionFour": { "question": "คุณคิดว่าประสบการณ์โดยรวมกับ @:appName เป็นอย่างไร?", "answerOne": "ยอดเยี่ยม", "answerTwo": "ดี", "answerThree": "ปานกลาง", "answerFour": "ต่ำกว่าค่าเฉลี่ย", "answerFive": "ไม่พึงพอใจ" } }, "common": { "uploadingFile": "กำลังอัพโหลดไฟล์ กรุณาอย่าออกจากแอป", "uploadNotionSuccess": "ไฟล์ zip ของ Notion ของคุณได้รับการอัปโหลดเรียบร้อยแล้ว เมื่อการนำเข้าเสร็จสมบูรณ์ คุณจะได้รับอีเมลยืนยัน", "reset": "รีเซ็ต" }, "menu": { "appearance": "รูปลักษณ์", "language": "ภาษา", "user": "ผู้ใช้งาน", "files": "ไฟล์", "notifications": "การแจ้งเตือน", "open": "เปิดการตั้งค่า", "logout": "ออกจากระบบ", "logoutPrompt": "คุณแน่ใจหรือว่าจะออกจากระบบ?", "selfEncryptionLogoutPrompt": "คุณแน่ใจหรือไม่ว่าต้องการที่จะออกจากระบบ? โปรดตรวจสอบให้แน่ใจว่าคุณได้คัดลอกการเข้ารหัสลับไว้แล้ว", "syncSetting": "การตั้งค่าการซิงค์", "cloudSettings": "การตั้งค่าคลาวด์", "enableSync": "เปิดใช้งานการซิงค์", "enableEncrypt": "เข้ารหัสข้อมูล", "cloudURL": "URL หลัก", "invalidCloudURLScheme": "รูปแบบไม่ถูกต้อง", "cloudServerType": "เซิร์ฟเวอร์คลาวด์", "cloudServerTypeTip": "โปรดทราบว่าอาจมีการออกจากระบบบัญชีปัจจุบันของคุณหลังจากเปลี่ยนเซิร์ฟเวอร์คลาวด์", "cloudLocal": "ในเครื่อง", "cloudAppFlowy": "คลาวด์ของ AppFlowy", "cloudAppFlowySelfHost": "@:appName คลาวด์ที่โฮสต์เอง", "appFlowyCloudUrlCanNotBeEmpty": "URL ของคลาวด์ไม่สามารถเว้นว่างได้", "clickToCopy": "คลิกเพื่อคัดลอก", "selfHostStart": "หากคุณไม่มีเซิร์ฟเวอร์ โปรดดูที่", "selfHostContent": "เอกสาร", "selfHostEnd": "สำหรับคำแนะนำเกี่ยวกับวิธีการโฮสต์เซิร์ฟเวอร์ของคุณเอง", "pleaseInputValidURL": "กรุณาใส่ URL ที่ถูกต้อง", "changeUrl": "เปลี่ยน URL ที่โฮสต์เองเป็น {}", "cloudURLHint": "ป้อน URL หลักของเซิร์ฟเวอร์ของคุณ", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "ป้อนที่อยู่ websocket ของเซิร์ฟเวอร์ของคุณ", "restartApp": "รีสตาร์ท", "restartAppTip": "รีสตาร์ทแอปพลิเคชันเพื่อให้มีการเปลี่ยนแปลง โปรดทราบไว้ว่าการดำเนินการนี้อาจจะออกจากระบบบัญชีปัจจุบันของคุณ", "changeServerTip": "หลังจากเปลี่ยนเซิร์ฟเวอร์แล้ว คุณต้องคลิกปุ่มรีสตาร์ทเพื่อให้การเปลี่ยนแปลงมีผล", "enableEncryptPrompt": "เปิดใช้งานการเข้ารหัสเพื่อรักษาความปลอดภัยข้อมูลของคุณแบบเป็นความลับ จะเก็บไว้อย่างปลอดภัย เมื่อเปิดใช้งานแล้วจะไม่สามารถปิดได้ หากสูญหาย ข้อมูลของคุณจะไม่สามารถเรียกคืนได้ คลิกเพื่อคัดลอก", "inputEncryptPrompt": "กรุณาระบุการเข้ารหัสลับของคุณสำหรับ", "clickToCopySecret": "คลิกเพื่อคัดลอกรหัสลับ", "configServerSetting": "กำหนดการตั้งค่าเซิร์ฟเวอร์ของคุณ", "configServerGuide": "หลังจากเลือก 'เริ่มต้นอย่างรวดเร็ว' แล้ว ให้ไปที่ 'การตั้งค่า' จากนั้นไปที่ \"การตั้งค่าคลาวด์\" เพื่อกำหนดค่าเซิร์ฟเวอร์ที่โฮสต์เองของคุณ", "inputTextFieldHint": "รหัสลับของคุณ", "historicalUserList": "ประวัติการเข้าสู่ระบบของผู้ใช้", "historicalUserListTooltip": "รายการนี้จะแสดงบัญชีที่ไม่ระบุตัวตนของคุณ คุณสามารถคลิกที่บัญชีเพื่อดูรายละเอียดได้ บัญชีที่ไม่เปิดเผยตัวตนถูกสร้างขึ้นโดยการคลิกปุ่ม 'เริ่มต้น'", "openHistoricalUser": "คลิกเพื่อเปิดบัญชีที่ไม่ระบุตัวตน", "customPathPrompt": "การจัดเก็บโฟลเดอร์ข้อมูลของ AppFlowy ไว้ในโฟลเดอร์ที่ซิงค์บนคลาวด์ เช่น Google Drive อาจทำให้เกิดความเสี่ยงได้ หากมีการเข้าถึงหรือแก้ไขฐานข้อมูลภายในโฟลเดอร์นี้จากหลายตำแหน่งพร้อมกัน อาจส่งผลให้เกิดความขัดแย้งในการซิงโครไนซ์และข้อมูลอาจเสียหายได้", "importAppFlowyData": "นำเข้าข้อมูลจากโฟลเดอร์ @:appName ภายนอก", "importingAppFlowyDataTip": "กำลังดำเนินการนำเข้าข้อมูล โปรดอย่าปิดแอป", "importAppFlowyDataDescription": "คัดลอกข้อมูลจากโฟลเดอร์ข้อมูลภายนอกของ @:appName และนำเข้าไปยังโฟลเดอร์ข้อมูล AppFlowy ปัจจุบัน", "importSuccess": "นำเข้าโฟลเดอร์ข้อมูล @:appName สำเร็จแล้ว", "importFailed": "การนำเข้าโฟลเดอร์ข้อมูล @:appName ล้มเหลว", "importGuide": "สำหรับรายละเอียดเพิ่มเติม โปรดตรวจสอบเอกสารอ้างอิง" }, "notifications": { "enableNotifications": { "label": "เปิดใช้งานการแจ้งเตือน", "hint": "ปิดเพื่อหยุดไม่ให้แสดงการแจ้งเตือนในเครื่องของคุณ" }, "showNotificationsIcon": { "label": "แสดงไอคอนการแจ้งเตือน", "hint": "ปิดเพื่อซ่อนไอคอนการแจ้งเตือนในแถบด้านข้าง" }, "archiveNotifications": { "allSuccess": "จัดเก็บการแจ้งเตือนทั้งหมดเรียบร้อยแล้ว", "success": "จัดเก็บการแจ้งเตือนเรียบร้อยแล้ว" }, "markAsReadNotifications": { "allSuccess": "ทำเครื่องหมายทั้งหมดว่าอ่านแล้วสำเร็จแล้ว", "success": "ทำเครื่องหมายว่าอ่านแล้วสำเร็จ" }, "action": { "markAsRead": "ทำเครื่องหมายว่าอ่านแล้ว", "multipleChoice": "เลือกเพิ่มเติม", "archive": "คลังเก็บเอกสารสำคัญ" }, "settings": { "settings": "การตั้งค่า", "markAllAsRead": "ทำเครื่องหมายทั้งหมดว่าอ่านแล้ว", "archiveAll": "เก็บถาวรทั้งหมด" }, "emptyInbox": { "title": "กล่องข้อความว่างเปล่า", "description": "ตั้งค่าการเตือนเพื่อรับการแจ้งเตือนที่นี่" }, "emptyUnread": { "title": "ไม่มีการแจ้งเตือนที่ยังไม่ได้อ่าน", "description": "คุณตามทันทั้งหมดแล้ว!" }, "emptyArchived": { "title": "ไม่มีการเก็บถาวร", "description": "การแจ้งเตือนที่เก็บถาวรจะปรากฏที่นี่" }, "tabs": { "inbox": "กล่องข้อความ", "unread": "ยังไม่ได้อ่าน", "archived": "เก็บถาวร" }, "refreshSuccess": "รีเฟรชการแจ้งเตือนสำเร็จ", "titles": { "notifications": "การแจ้งเตือน", "reminder": "การเตือน" } }, "appearance": { "resetSetting": "รีเซ็ตการตั้งค่านี้", "fontFamily": { "label": "แบบตัวอักษร", "search": "ค้นหา", "defaultFont": "ระบบ" }, "themeMode": { "label": "โหมดธีม", "light": "โหมดสว่าง", "dark": "โหมดมืด", "system": "ใช้ตามระบบ" }, "fontScaleFactor": "ตัวปรับขนาดฟอนต์", "documentSettings": { "cursorColor": "สีเคอร์เซอร์เอกสาร", "selectionColor": "สีการเลือกเอกสาร", "width": "ความกว้างของเอกสาร", "changeWidth": "เปลี่ยน", "pickColor": "เลือกสี", "colorShade": "เฉดสี", "opacity": "ความทึบแสง", "hexEmptyError": "รหัสสีเลขฐานสิบหกไม่สามารถเว้นว่างได้", "hexLengthError": "ค่าเลขฐานสิบหกต้องมีความยาว 6 หลัก", "hexInvalidError": "ค่าเลขฐานสิบหกไม่ถูกต้อง", "opacityEmptyError": "ความทึบแสงไม่สามารถเว้นว่างได้", "opacityRangeError": "ความทึบแสงต้องอยู่ระหว่าง 1 และ 100", "app": "App", "flowy": "Flowy", "apply": "นำไปใช้" }, "layoutDirection": { "label": "ทิศทางของเค้าโครง", "hint": "ควบคุมการไหลของเนื้อหาบนหน้าจอของคุณ จากซ้ายไปขวาหรือจากขวาไปซ้าย", "ltr": "ซ้ายไปขวา", "rtl": "ขวาไปซ้าย" }, "textDirection": { "label": "ทิศทางข้อความเริ่มต้น", "hint": "ระบุว่าข้อความควรเริ่มจากซ้ายหรือขวาเป็นค่าเริ่มต้น", "ltr": "ซ้ายไปขวา", "rtl": "ขวาไปซ้าย", "auto": "อัตโนมัติ", "fallback": "เหมือนกับทิศทางของเค้าโครง" }, "themeUpload": { "button": "อัปโหลด", "uploadTheme": "อัปโหลดธีม", "description": "อัปโหลดธีม AppFlowy ของคุณเองโดยใช้ปุ่มด้านล่าง", "loading": "โปรดรอสักครู่ในขณะที่เราตรวจสอบและอัปโหลดธีมของคุณ...", "uploadSuccess": "อัปโหลดธีมของคุณสำเร็จแล้ว", "deletionFailure": "ไม่สามารถลบธีมได้ ลองลบมันด้วยตนเอง", "filePickerDialogTitle": "เลือกไฟล์ .flowy_plugin", "urlUploadFailure": "ไม่สามารถเปิด URL: {} ได้", "failure": "ธีมที่อัปโหลดมีรูปแบบที่ไม่ถูกต้อง" }, "theme": "ธีม", "builtInsLabel": "ธีมในตัวแอป", "pluginsLabel": "ปลั๊กอิน", "dateFormat": { "label": "รูปแบบวันที่", "local": "ใช้ตามเครื่องของคุณ", "us": "US", "iso": "สากล", "friendly": "แบบง่าย", "dmy": "วัน/เดือน/ปี" }, "timeFormat": { "label": "รูปแบบเวลา", "twelveHour": "สิบสองชั่วโมง", "twentyFourHour": "ยี่สิบสี่ชั่วโมง" }, "showNamingDialogWhenCreatingPage": "แสดงกล่องโต้ตอบการตั้งชื่อเมื่อสร้างหน้า", "enableRTLToolbarItems": "เปิดใช้งานแถบเครื่องมือ RTL (จากขวาไปซ้าย)", "members": { "title": "การตั้งค่าสมาชิก", "inviteMembers": "ส่งคำเชิญสมาชิก", "inviteHint": "ส่งคำเชิญทางอีเมล", "sendInvite": "ส่งคำเชิญ", "copyInviteLink": "คัดลอกลิงค์คำเชิญ", "label": "สมาชิก", "user": "ผู้ใช้", "role": "บทบาท", "removeFromWorkspace": "ลบออกจากพื้นที่ทำงาน", "removeFromWorkspaceSuccess": "ลบออกจากพื้นที่ทำงานสำเร็จ", "removeFromWorkspaceFailed": "ลบออกจากพื้นที่ทำงานไม่สำเร็จ", "owner": "เจ้าของ", "guest": "ผู้เยี่ยมชม", "member": "สมาชิก", "memberHintText": "สมาชิกสามารถอ่านและแก้ไขหน้าได้", "guestHintText": "ผู้เยี่ยมชมสามารถอ่าน ตอบสนอง แสดงความคิดเห็น และแก้ไขหน้าบางหน้าได้ด้วยการอนุญาต", "emailInvalidError": "อีเมล์ไม่ถูกต้อง กรุณาตรวจสอบและลองอีกครั้ง", "emailSent": "ส่งอีเมลแล้ว กรุณาตรวจสอบกล่องจดหมาย", "members": "สมาชิก", "membersCount": { "zero": "{} สมาชิก", "one": "{} สมาชิก", "other": "{} สมาชิก" }, "inviteFailedDialogTitle": "ไม่สามารถส่งคำเชิญได้", "inviteFailedMemberLimit": "จำนวนสมาชิกถึงขีดจำกัดแล้ว กรุณาอัปเกรดเพื่อเชิญสมาชิกเพิ่มเติม", "inviteFailedMemberLimitMobile": "พื้นที่ทำงานของคุณถึงขีดจำกัดจำนวนสมาชิกแล้ว", "memberLimitExceeded": "จำนวนสมาชิกถึงขีดจำกัดแล้ว เพื่อเชิญสมาชิกเพิ่มเติม กรุณา...", "memberLimitExceededUpgrade": "อัพเกรด", "memberLimitExceededPro": "จำนวนสมาชิกถึงขีดจำกัดแล้ว หากต้องการสมาชิกเพิ่มเติม กรุณาติดต่อ ", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "เพิ่มสมาชิกไม่สำเร็จ", "addMemberSuccess": "เพิ่มสมาชิกสำเร็จ", "removeMember": "ลบสมาชิก", "areYouSureToRemoveMember": "คุณแน่ใจหรือไม่ว่าต้องการลบสมาชิกคนนี้ออก?", "inviteMemberSuccess": "ส่งคำเชิญเรียบร้อยแล้ว", "failedToInviteMember": "เชิญสมาชิกไม่สำเร็จ", "workspaceMembersError": "อุ๊ปส์, มีบางอย่างผิดปกติ", "workspaceMembersErrorDescription": "เราไม่สามารถโหลดรายชื่อสมาชิกได้ในขณะนี้ กรุณาลองอีกครั้งในภายหลัง" } }, "files": { "copy": "คัดลอก", "defaultLocation": "อ่านไฟล์และสถานที่เก็บข้อมูล", "exportData": "ส่งออกข้อมูลของคุณ", "doubleTapToCopy": "แตะสองครั้งเพื่อคัดลอกเส้นทาง", "restoreLocation": "กู้คืนเป็นเส้นทางเริ่มต้นของ AppFlowy", "customizeLocation": "เปิดโฟลเดอร์อื่น", "restartApp": "โปรดรีสตาร์ทแอปเพื่อให้การเปลี่ยนแปลงมีผล", "exportDatabase": "ส่งออกฐานข้อมูล", "selectFiles": "เลือกไฟล์ที่ต้องการส่งออก", "selectAll": "เลือกทั้งหมด", "deselectAll": "เลิกเลือกทั้งหมด", "createNewFolder": "สร้างโฟลเดอร์ใหม่", "createNewFolderDesc": "บอกเราว่าคุณต้องการเก็บข้อมูลไว้ที่ไหน", "defineWhereYourDataIsStored": "ระบุตำแหน่งที่เก็บข้อมูลของคุณ", "open": "เปิด", "openFolder": "เปิดโฟลเดอร์ที่มีอยู่", "openFolderDesc": "อ่านและเขียนลงในโฟลเดอร์ AppFlowy ที่มีอยู่ของคุณ", "folderHintText": "ชื่อโฟลเดอร์", "location": "สร้างโฟลเดอร์ใหม่", "locationDesc": "เลือกชื่อสำหรับโฟลเดอร์ข้อมูล AppFlowy ของคุณ", "browser": "เรียกดู", "create": "สร้าง", "set": "ตั้งค่า", "folderPath": "เส้นทางการเก็บโฟลเดอร์ของคุณ", "locationCannotBeEmpty": "เส้นทางไม่สามารถว่างได้", "pathCopiedSnackbar": "คัดลอกเส้นทางการเก็บไฟล์ไปยังคลิปบอร์ด!", "changeLocationTooltips": "เปลี่ยนไดเร็กทอรีข้อมูล", "change": "เปลี่ยน", "openLocationTooltips": "เปิดไดเร็กทอรีข้อมูลอื่น", "openCurrentDataFolder": "เปิดไดเร็กทอรีข้อมูลปัจจุบัน", "recoverLocationTooltips": "รีเซ็ตเป็นไดเร็กทอรีข้อมูลเริ่มต้นของ AppFlowy", "exportFileSuccess": "ส่งออกไฟล์สำเร็จ!", "exportFileFail": "ส่งออกไฟล์ล้มเหลว!", "export": "ส่งออก", "clearCache": "ล้างแคช", "clearCacheDesc": "หากคุณพบปัญหาเกี่ยวกับรูปภาพไม่โหลด หรือฟอนต์ไม่แสดงอย่างถูกต้อง ให้ลองล้างแคช การดำเนินการนี้จะไม่ลบข้อมูลผู้ใช้ของคุณ", "areYouSureToClearCache": "คุณแน่ใจหรือไม่ที่จะล้างแคช?", "clearCacheSuccess": "ล้างแคชสำเร็จ!" }, "user": { "name": "ชื่อ", "email": "อีเมล", "tooltipSelectIcon": "เลือกไอคอน", "selectAnIcon": "เลือกไอคอน", "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ AI ของคุณ", "clickToLogout": "คลิกเพื่อออกจากระบบผู้ใช้ปัจจุบัน", "pleaseInputYourStabilityAIKey": "โปรดระบุคีย์ Stability AI ของคุณ" }, "mobile": { "personalInfo": "ข้อมูลส่วนตัว", "username": "ชื่อผู้ใช้", "usernameEmptyError": "โปรดกรอกชื่อผู้ใช้", "about": "เกี่ยวกับ", "pushNotifications": "การแจ้งเตือนแบบพุช", "support": "การสนับสนุน", "joinDiscord": "เข้าร่วมกับเราบน Discord", "privacyPolicy": "นโยบายความเป็นส่วนตัว", "userAgreement": "ข้อตกลงผู้ใช้", "termsAndConditions": "ข้อกำหนดและเงื่อนไข", "userprofileError": "ไม่สามารถโหลดโปรไฟล์ผู้ใช้ได้", "userprofileErrorDescription": "โปรดลองออกจากระบบแล้วเข้าสู่ระบบอีกครั้งเพื่อตรวจสอบว่าปัญหายังคงอยู่หรือไม่", "selectLayout": "เลือกเค้าโครง", "selectStartingDay": "เลือกวันเริ่มต้น", "version": "เวอร์ชัน" }, "shortcuts": { "shortcutsLabel": "ทางลัด", "command": "คำสั่ง", "keyBinding": "การผูกแป้นพิมพ์", "addNewCommand": "เพิ่มคำสั่งใหม่", "updateShortcutStep": "กดปุ่มแป้นพิมพ์ที่ต้องการแล้วกด ENTER", "shortcutIsAlreadyUsed": "ทางลัดนี้ถูกใช้แล้วสำหรับ: {conflict}", "resetToDefault": "รีเซ็ตการกำหนดแป้นพิมพ์เป็นค่าเริ่มต้น", "couldNotLoadErrorMsg": "ไม่สามารถโหลดทางลัดได้ ลองอีกครั้ง", "couldNotSaveErrorMsg": "ไม่สามารถบันทึกทางลัดได้ ลองอีกครั้ง" } }, "grid": { "deleteView": "คุณแน่ใจหรือไม่ว่าต้องการลบมุมมองนี้", "createView": "ใหม่", "title": { "placeholder": "ไม่มีชื่อ" }, "settings": { "filter": "ตัวกรอง", "sort": "เรียงลำดับ", "sortBy": "เรียงลำดับตาม", "properties": "คุณสมบัติ", "reorderPropertiesTooltip": "ลากเพื่อเปลี่ยนลำดับคุณสมบัติ", "group": "กลุ่ม", "addFilter": "เพิ่มตัวกรอง", "deleteFilter": "ลบตัวกรอง", "filterBy": "กรองตาม...", "typeAValue": "พิมพ์ค่า...", "layout": "เค้าโครง", "databaseLayout": "เค้าโครงฐานข้อมูล", "viewList": { "zero": "0 มุมมอง", "one": "{count} มุมมอง", "other": "{count} มุมมอง" }, "editView": "แก้ไขมุมมอง", "boardSettings": "การตั้งค่าบอร์ด", "calendarSettings": "การตั้งค่าปฏิทิน", "createView": "มุมมองใหม่", "duplicateView": "ทำสำเนามุมมอง", "deleteView": "ลบมุมมอง", "numberOfVisibleFields": "{} ที่แสดงอยู่" }, "filter": { "empty": "ไม่มีตัวกรองที่ใช้งานอยู่", "addFilter": "เพิ่มตัวกรอง", "cannotFindCreatableField": "ไม่พบฟิลด์ที่เหมาะสมในการกรอง", "conditon": "เงื่อนไข", "where": "โดยที่" }, "textFilter": { "contains": "ประกอบด้วย", "doesNotContain": "ไม่ประกอบด้วย", "endsWith": "ลงท้ายด้วย", "startWith": "ขึ้นต้นด้วย", "is": "เป็น", "isNot": "ไม่เป็น", "isEmpty": "ว่างเปล่า", "isNotEmpty": "ไม่ว่างเปล่า", "choicechipPrefix": { "isNot": "ไม่", "startWith": "ขึ้นต้นด้วย", "endWith": "ลงท้ายด้วย", "isEmpty": "ว่างเปล่า", "isNotEmpty": "ไม่ว่างเปล่า" } }, "checkboxFilter": { "isChecked": "เลือกแล้ว", "isUnchecked": "ไม่เลือก", "choicechipPrefix": { "is": "เป็น" } }, "checklistFilter": { "isComplete": "เสร็จสมบูรณ์", "isIncomplted": "ไม่เสร็จสมบูรณ์" }, "selectOptionFilter": { "is": "เป็น", "isNot": "ไม่เป็น", "contains": "ประกอบด้วย", "doesNotContain": "ไม่ประกอบด้วย", "isEmpty": "ว่างเปล่า", "isNotEmpty": "ไม่ว่างเปล่า" }, "dateFilter": { "is": "เป็น", "before": "อยู่ก่อน", "after": "อยู่หลัง", "onOrBefore": "อยู่ในหรือก่อนหน้า", "onOrAfter": "อยู่ในหรือหลังจาก", "between": "อยู่ระหว่าง", "empty": "มันว่างเปล่า", "notEmpty": "มันไม่ว่างเปล่า", "startDate": "วันที่เริ่มต้น", "endDate": "วันที่สิ้นสุด", "choicechipPrefix": { "before": "ก่อน", "after": "หลังจาก", "between": "ระหว่าง", "onOrBefore": "ภายในหรือก่อน", "onOrAfter": "ภายในหรือหลัง", "isEmpty": "ว่างเปล่า", "isNotEmpty": "ไม่ว่างเปล่า" } }, "numberFilter": { "equal": "เท่ากับ", "notEqual": "ไม่เท่ากับ", "lessThan": "น้อยกว่า", "greaterThan": "มากกว่า", "lessThanOrEqualTo": "น้อยกว่าหรือเท่ากับ", "greaterThanOrEqualTo": "มากกว่าหรือเท่ากับ", "isEmpty": "ว่างเปล่า", "isNotEmpty": "ไม่ว่างเปล่า" }, "field": { "label": "คุณสมบัติ", "hide": "ซ่อน", "show": "แสดง", "insertLeft": "แทรกทางซ้าย", "insertRight": "แทรกทางขวา", "duplicate": "ทำสำเนา", "delete": "ลบ", "wrapCellContent": "ตัดข้อความ", "clear": "ล้างเซลล์", "switchPrimaryFieldTooltip": "ไม่สามารถเปลี่ยนประเภทฟิลด์ของฟิลด์หลักได้", "textFieldName": "ข้อความ", "checkboxFieldName": "กล่องกาเครื่องหมาย", "dateFieldName": "วันที่", "updatedAtFieldName": "เวลาแก้ไขล่าสุด", "createdAtFieldName": "เวลาสร้าง", "numberFieldName": "ตัวเลข", "singleSelectFieldName": "การเลือก", "multiSelectFieldName": "การเลือกหลายรายการ", "urlFieldName": "URL", "checklistFieldName": "รายการตรวจสอบ", "relationFieldName": "ความสัมพันธ์", "summaryFieldName": "AI สรุป", "timeFieldName": "เวลา", "mediaFieldName": "ไฟล์และสื่อ", "translateFieldName": "AI แปล", "translateTo": "แปลเป็น", "numberFormat": "รูปแบบตัวเลข", "dateFormat": "รูปแบบวันที่", "includeTime": "รวมเวลา", "isRange": "วันที่สิ้นสุด", "dateFormatFriendly": "เดือน วัน, ปี", "dateFormatISO": "ปี-เดือน-วัน", "dateFormatLocal": "เดือน/วัน/ปี", "dateFormatUS": "ปี/เดือน/วัน", "dateFormatDayMonthYear": "วัน/เดือน/ปี", "timeFormat": "รูปแบบเวลา", "invalidTimeFormat": "รูปแบบไม่ถูกต้อง", "timeFormatTwelveHour": "12 ชั่วโมง", "timeFormatTwentyFourHour": "24 ชั่วโมง", "clearDate": "ล้างวันที่", "dateTime": "วันที่และเวลา", "startDateTime": "วันที่และเวลาเริ่มต้น", "endDateTime": "วันที่และเวลาสิ้นสุด", "failedToLoadDate": "ไม่สามารถโหลดค่าวันที่ได้", "selectTime": "เลือกเวลา", "selectDate": "เลือกวันที่", "visibility": "การมองเห็น", "propertyType": "ประเภทคุณสมบัติ", "addSelectOption": "เพิ่มตัวเลือก", "typeANewOption": "พิมพ์ตัวเลือกใหม่", "optionTitle": "ตัวเลือก", "addOption": "เพิ่มตัวเลือก", "editProperty": "แก้ไขคุณสมบัติ", "newProperty": "คุณสมบัติใหม่", "openRowDocument": "เปิดเป็นหน้า", "deleteFieldPromptMessage": "แน่ใจหรือไม่? คุณสมบัติเหล่านี้จะถูกลบ", "clearFieldPromptMessage": "คุณแน่ใจหรือไม่ฦ เซลล์ทั้งหมดในคอลัมน์นี้จะถูกล้างข้อมูล", "newColumn": "คอลัมน์ใหม่", "format": "รูปแบบ", "reminderOnDateTooltip": "เซลล์นี้มีการตั้งค่าการเตือนในวันที่กำหนด", "optionAlreadyExist": "ตัวเลือกมีอยู่แล้ว" }, "rowPage": { "newField": "เพิ่มฟิลด์ใหม่", "fieldDragElementTooltip": "คลิกเพื่อเปิดเมนู", "showHiddenFields": { "one": "แสดงฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์", "many": "แสดงฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์", "other": "แสดงฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์" }, "hideHiddenFields": { "one": "ซ่อนฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์", "many": "ซ่อนฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์", "other": "ซ่อนฟิลด์ที่ซ่อนอยู่ {count} ฟิลด์" }, "openAsFullPage": "เปิดแบบเต็มหน้า", "moreRowActions": "การดำเนินการแถวเพิ่มเติม" }, "sort": { "ascending": "เรียงลำดับจากน้อยไปมาก", "descending": "เรียงลำดับจากมากไปน้อย", "by": "โดย", "empty": "ไม่มีการเรียงลำดับที่ใช้งานอยู่", "cannotFindCreatableField": "ไม่พบฟิลด์ที่เหมาะสมในการเรียงลำดับ", "deleteAllSorts": "ลบการเรียงลำดับทั้งหมด", "addSort": "เพิ่มการเรียงลำดับ", "sortsActive": "ไม่สามารถ {intention} ขณะทำการจัดเรียง", "removeSorting": "คุณต้องการลบการจัดเรียงทั้งหมดในมุมมองนี้ และดำเนินการต่อหรือไม่?", "fieldInUse": "คุณกำลังเรียงลำดับตามฟิลด์นี้อยู่แล้ว" }, "row": { "label": "แถว", "duplicate": "ทำสำเนา", "delete": "ลบ", "titlePlaceholder": "ไม่มีชื่อ", "textPlaceholder": "ว่างเปล่า", "copyProperty": "คัดลอกคุณสมบัติไปยังคลิปบอร์ด", "count": "จำนวน", "newRow": "แถวใหม่", "action": "การดำเนินการ", "add": "คลิกเพิ่มด้านล่าง", "drag": "ลากเพื่อย้าย", "deleteRowPrompt": "คุณแน่ใจหรือไม่ว่าต้องการลบแถวนี้? การกระทำนี้ไม่สามารถย้อนกลับได้", "deleteCardPrompt": "คุณแน่ใจหรือไม่ว่าต้องการลบการ์ดนี้? การกระทำนี้ไม่สามารถย้อนกลับได้", "dragAndClick": "ลากเพื่อย้ายคลิกเพื่อเปิดเมนู", "insertRecordAbove": "แทรกเวิ่นระเบียนด้านบน", "insertRecordBelow": "แทรกเวิ่นระเบียนด้านล่าง", "noContent": "ไม่มีเนื้อหา", "reorderRowDescription": "เรียงลำดับแถวใหม่", "createRowAboveDescription": "สร้างแถวด้านบน", "createRowBelowDescription": "สร้างแถวด้านล่าง" }, "selectOption": { "create": "สร้าง", "purpleColor": "สีม่วง", "pinkColor": "สีชมพู", "lightPinkColor": "ชมพูอ่อน", "orangeColor": "สีส้ม", "yellowColor": "สีเหลือง", "limeColor": "สีมะนาว", "greenColor": "สีเขียว", "aquaColor": "สีฟ้าอมเขียว", "blueColor": "สีน้ำเงิน", "deleteTag": "ลบแท็ก", "colorPanelTitle": "สี", "panelTitle": "เลือกตัวเลือกหรือสร้างตัวเลือกใหม่", "searchOption": "ค้นหาตัวเลือก", "searchOrCreateOption": "ค้นหาหรือสร้างตัวเลือก...", "createNew": "สร้างใหม่", "orSelectOne": "หรือเลือกตัวเลือก", "typeANewOption": "พิมพ์ตัวเลือกใหม่", "tagName": "ชื่อแท็ก" }, "checklist": { "taskHint": "คำอธิบายงาน", "addNew": "เพิ่มงานใหม่", "submitNewTask": "สร้าง", "hideComplete": "ซ่อนงานเสร็จ", "showComplete": "แสดงงานทั้งหมด" }, "url": { "launch": "เปิดในเบราว์เซอร์", "copy": "คัดลอก URL", "textFieldHint": "ป้อน URL" }, "relation": { "relatedDatabasePlaceLabel": "ฐานข้อมูลที่เกี่ยวข้อง", "relatedDatabasePlaceholder": "ไม่มี", "inRelatedDatabase": "ใน", "rowSearchTextFieldPlaceholder": "ค้นหา", "noDatabaseSelected": "ยังไม่ได้เลือกฐานข้อมูล กรุณาเลือกฐานข้อมูลหนึ่งรายการจากรายการด้านล่างก่อน", "emptySearchResult": "ไม่พบข้อมูล", "linkedRowListLabel": "{count} แถวที่เชื่อมโยง", "unlinkedRowListLabel": "เชื่อมโยงแถวอื่น" }, "menuName": "ตาราง", "referencedGridPrefix": "มุมมองของ", "calculate": "คำนวณ", "calculationTypeLabel": { "none": "ไม่มี", "average": "เฉลี่ย", "max": "สูงสุด", "median": "มัธยฐาน", "min": "ต่ำสุด", "sum": "ผลรวม", "count": "นับ", "countEmpty": "นับที่ว่าง", "countEmptyShort": "ว่างเปล่า", "countNonEmpty": "นับที่ไม่ว่าง", "countNonEmptyShort": "เติมแล้ว" }, "media": { "rename": "เปลี่ยนชื่อ", "download": "ดาวน์โหลด", "expand": "ขยาย", "delete": "ลบ", "moreFilesHint": "+{}", "addFileOrImage": "เพิ่มไฟล์หรือลิงค์", "attachmentsHint": "{}", "addFileMobile": "เพิ่มไฟล์", "extraCount": "+{}", "deleteFileDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบไฟล์นี้? การกระทำนี้ไม่สามารถย้อนกลับได้", "showFileNames": "แสดงชื่อไฟล์", "downloadSuccess": "ดาวน์โหลดไฟล์แล้ว", "downloadFailedToken": "ไม่สามารถดาวน์โหลดไฟล์ได้ โทเค็นผู้ใช้ไม่พร้อมใช้งาน", "setAsCover": "ตั้งเป็นปก", "openInBrowser": "เปิดในเบราว์เซอร์", "embedLink": "ฝังลิงค์ไฟล์" } }, "document": { "menuName": "เอกสาร", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "เลือกกระดานเพื่อเชื่อมโยง", "createANewBoard": "สร้างกระดานใหม่" }, "grid": { "selectAGridToLinkTo": "เลือกตารางเพื่อเชื่อมโยง", "createANewGrid": "สร้างตารางใหม่" }, "calendar": { "selectACalendarToLinkTo": "เลือกปฏิทินเพื่อเชื่อมโยง", "createANewCalendar": "สร้างปฏิทินใหม่" }, "document": { "selectADocumentToLinkTo": "เลือกเอกสารเพื่อเชื่อมโยง" }, "name": { "text": "ข้อความ", "heading1": "หัวข้อที่ 1", "heading2": "หัวข้อที่ 2", "heading3": "หัวข้อที่ 3", "image": "รูปภาพ", "bulletedList": "รายการลำดับหัวข้อย่อย", "numberedList": "รายการลำดับตัวเลข", "todoList": "รายการสิ่งที่ต้องทำ", "doc": "เอกสาร", "linkedDoc": "ลิงค์ไปยังหน้า", "grid": "กริด", "linkedGrid": "กริดที่เชื่อมโยง", "kanban": "คันบัน", "linkedKanban": "คันบันที่เชื่อมโยง", "calendar": "ปฏิทิน", "linkedCalendar": "ปฏิทินที่เชื่อมโยง", "quote": "คำกล่าว", "divider": "ตัวคั่น", "table": "ตาราง", "callout": "การเน้นข้อความ", "outline": "โครงร่าง", "mathEquation": "สมการคณิตศาสตร์", "code": "โค้ด", "toggleList": "ตัวเปิดปิดรายการ", "toggleHeading1": "ตัวเปิดปิดหัวข้อ 1", "toggleHeading2": "ตัวเปิดปิดหัวข้อ 2", "toggleHeading3": "ตัวเปิดปิดหัวข้อ 3", "emoji": "อิโมจิ", "aiWriter": "นักเขียน AI", "dateOrReminder": "วันที่หรือการเตือน", "photoGallery": "แกลอรี่รูปภาพ", "file": "ไฟล์" }, "subPage": { "name": "เอกสาร", "keyword1": "หน้าย่อย", "keyword2": "หน้า", "keyword3": "หน้าย่อย", "keyword4": "แทรกหน้า", "keyword5": "ฝังหน้า", "keyword6": "หน้าใหม่", "keyword7": "สร้างหน้า", "keyword8": "เอกสาร" } }, "selectionMenu": { "outline": "โครงร่าง", "codeBlock": "โค้ดบล็อก" }, "plugins": { "referencedBoard": "กระดานที่อ้างอิง", "referencedGrid": "ตารางอ้างอิง", "referencedCalendar": "ปฏิทินที่อ้างอิง", "referencedDocument": "เอกสารอ้างอิง", "autoGeneratorMenuItemName": "นักเขียน AI", "autoGeneratorTitleName": "AI: สอบถาม AI เพื่อให้เขียนอะไรก็ได้...", "autoGeneratorLearnMore": "เรียนรู้เพิ่มเติม", "autoGeneratorGenerate": "สร้าง", "autoGeneratorHintText": "ถาม AI ...", "autoGeneratorCantGetOpenAIKey": "ไม่สามารถรับคีย์ AI ได้", "autoGeneratorRewrite": "เขียนใหม่", "smartEdit": "ผู้ช่วย AI", "aI": "AI", "smartEditFixSpelling": "แก้ไขการสะกด", "warning": "⚠️ คำตอบของ AI อาจจะไม่ถูกต้องหรืออาจจะเข้าใจผิดได้", "smartEditSummarize": "สรุป", "smartEditImproveWriting": "ปรับปรุงการเขียน", "smartEditMakeLonger": "ทำให้ยาวขึ้น", "smartEditCouldNotFetchResult": "ไม่สามารถดึงผลลัพธ์จาก AI ได้", "smartEditCouldNotFetchKey": "ไม่สามารถดึงคีย์ AI ได้", "smartEditDisabled": "เชื่อมต่อ AI ในการตั้งค่า", "appflowyAIEditDisabled": "ลงชื่อเข้าใช้เพื่อเปิดใช้งานฟีเจอร์ AI", "discardResponse": "คุณต้องการทิ้งการตอบกลับของ AI หรือไม่", "createInlineMathEquation": "สร้างสมการ", "fonts": "แบบอักษร", "insertDate": "ใส่วันที่", "emoji": "อิโมจิ", "toggleList": "ตัวเปิดปิดรายการ", "emptyToggleHeading": "ตัวเปิดปิด h{} ว่างเปล่า คลิกเพื่อเพิ่มเนื้อหา", "emptyToggleList": "ตัวเปิดปิดรายการว่างเปล่า คลิกเพื่อเพิ่มเนื้อหา", "quoteList": "รายการคำกล่าว", "numberedList": "รายการลำดับตัวเลข", "bulletedList": "รายการลำดับหัวข้อย่อย", "todoList": "รายการสิ่งที่ต้องทำ", "callout": "คำอธิบายประกอบ", "simpleTable": { "moreActions": { "color": "สี", "align": "จัดตำแหน่ง", "delete": "ลบ", "duplicate": "ทำสำเนา", "insertLeft": "แทรกซ้าย", "insertRight": "แทรกขวา", "insertAbove": "แทรกด้านบน", "insertBelow": "แทรกด้านล่าง", "headerColumn": "ส่วนหัวของคอลัมน์", "headerRow": "ส่วนหัวของแถว", "clearContents": "ล้างเนื้อหา" }, "clickToAddNewRow": "คลิกเพื่อเพิ่มแถวใหม่", "clickToAddNewColumn": "คลิกเพื่อเพิ่มคอลัมน์ใหม่", "clickToAddNewRowAndColumn": "คลิกเพื่อเพิ่มแถว และคอลัมน์ใหม่" }, "cover": { "changeCover": "เปลี่ยนปก", "colors": "สี", "images": "รูปภาพ", "clearAll": "ล้างทั้งหมด", "abstract": "นามธรรม", "addCover": "เพิ่มปก", "addLocalImage": "เพิ่มรูปภาพจากเครื่องของคุณ", "invalidImageUrl": "URL รูปภาพไม่ถูกต้อง", "failedToAddImageToGallery": "ไม่สามารถเพิ่มรูปภาพลงในแกลเลอรี่ได้", "enterImageUrl": "ป้อน URL รูปภาพ", "add": "เพิ่ม", "back": "ย้อนกลับ", "saveToGallery": "บันทึกลงในแกลเลอรี่", "removeIcon": "ลบไอคอน", "removeCover": "ลบปก", "pasteImageUrl": "วาง URL รูปภาพ", "or": "หรือ", "pickFromFiles": "เลือกจากไฟล์", "couldNotFetchImage": "ไม่สามารถดึงรูปภาพได้", "imageSavingFailed": "บันทึกภาพไม่สำเร็จ", "addIcon": "เพิ่มไอคอน", "changeIcon": "เปลี่ยนไอคอน", "coverRemoveAlert": "มันจะถูกลบจากปกหลังจากที่ถูกลบ", "alertDialogConfirmation": "คุณแน่ใจหรือไม่ว่าคุณต้องการดำเนินการต่อ?" }, "mathEquation": { "name": "สมการทางคณิตศาสตร์", "addMathEquation": "เพิ่มสมการ TeX", "editMathEquation": "แก้ไขสมการทางคณิตศาสตร์" }, "optionAction": { "click": "คลิก", "toOpenMenu": " เพื่อเปิดเมนู", "drag": "ลาก", "toMove": " เพื่อย้าย", "delete": "ลบ", "duplicate": "ทำสำเนา", "turnInto": "แปลงเป็น", "moveUp": "เลื่อนขึ้น", "moveDown": "เลื่อนลง", "color": "สี", "align": "จัดตำแหน่ง", "left": "ซ้าย", "center": "กึ่งกลาง", "right": "ขวา", "defaultColor": "สีเริ่มต้น", "depth": "ความลึก", "copyLinkToBlock": "คัดลอกลิงก์ไปยังบล็อก" }, "image": { "addAnImage": "เพิ่มรูปภาพ", "copiedToPasteBoard": "ลิงก์รูปภาพถูกคัดลอกไปที่คลิปบอร์ดแล้ว", "addAnImageDesktop": "เพิ่มรูปภาพ", "addAnImageMobile": "คลิกเพื่อเพิ่มรูปภาพหนึ่งรูป หรือมากกว่า", "dropImageToInsert": "วางภาพเพื่อแทรก", "imageUploadFailed": "อัพโหลดรูปภาพไม่สำเร็จ", "imageDownloadFailed": "ดาวน์โหลดรูปภาพล้มเหลว กรุณาลองอีกครั้ง", "imageDownloadFailedToken": "การดาวน์โหลดภาพล้มเหลวเนื่องจากขาดโทเค็นผู้ใช้ โปรดลองอีกครั้ง", "errorCode": "รหัสข้อผิดพลาด" }, "photoGallery": { "name": "แกลอรี่รูปภาพ", "imageKeyword": "รูปภาพ", "imageGalleryKeyword": "แกลอรี่รูปภาพ", "photoKeyword": "ภาพถ่าย", "photoBrowserKeyword": "เบราว์เซอร์รูปภาพ", "galleryKeyword": "แกลเลอรี่", "addImageTooltip": "เพิ่มรูปภาพ", "changeLayoutTooltip": "เปลี่ยนเค้าโครง", "browserLayout": "เบราว์เซอร์", "gridLayout": "กริด", "deleteBlockTooltip": "ลบทั้งแกลอรี่" }, "math": { "copiedToPasteBoard": "คัดลอกสมการคณิตศาสตร์ไปยังคลิปบอร์ดแล้ว" }, "urlPreview": { "copiedToPasteBoard": "คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว", "convertToLink": "แปลงเป็นลิงค์ฝัง" }, "outline": { "addHeadingToCreateOutline": "เพิ่มหัวข้อเพื่อสร้างสารบัญ", "noMatchHeadings": "ไม่พบหัวข้อที่ตรงกัน" }, "table": { "addAfter": "เพิ่มหลัง", "addBefore": "เพิ่มก่อน", "delete": "ลบ", "clear": "ล้างเนื้อหา", "duplicate": "ทำสำเนา", "bgColor": "สีพื้นหลัง" }, "contextMenu": { "copy": "คัดลอก", "cut": "ตัด", "paste": "วาง" }, "action": "การดำเนินการ", "database": { "selectDataSource": "เลือกแหล่งข้อมูล", "noDataSource": "ไม่มีแหล่งข้อมูล", "selectADataSource": "เลือกแหล่งข้อมูล", "toContinue": "เพื่อดำเนินการต่อ", "newDatabase": "ฐานข้อมูลใหม่", "linkToDatabase": "ลิงค์ไปยังฐานข้อมูล" }, "date": "วันที่", "video": { "label": "วีดีโอ", "emptyLabel": "เพิ่มวิดีโอ", "placeholder": "วางลิงค์วิดีโอ", "copiedToPasteBoard": "คัดลอกลิงก์วิดีโอไปยังคลิปบอร์ดแล้ว", "insertVideo": "เพิ่มวีดีโอ", "invalidVideoUrl": "URL แหล่งที่มายังไม่รองรับในขณะนี้", "invalidVideoUrlYouTube": "YouTube ยังไม่รองรับ", "supportedFormats": "รูปแบบที่รองรับ: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "ไฟล์", "uploadTab": "อัพโหลด", "uploadMobile": "เลือกไฟล์", "uploadMobileGallery": "จากแกลเลอรี่รูปภาพ", "networkTab": "ฝังลิงค์", "placeholderText": "อัพโหลดหรือฝังไฟล์", "placeholderDragging": "วางไฟล์เพื่ออัพโหลด", "dropFileToUpload": "วางไฟล์เพื่ออัพโหลด", "fileUploadHint": "ลากและวางไฟล์หรือคลิกเพื่อ ", "fileUploadHintSuffix": "เรียกดู", "networkHint": "วางลิงก์ไฟล์", "networkUrlInvalid": "URL ไม่ถูกต้อง ตรวจสอบ URL และลองอีกครั้ง", "networkAction": "ฝัง", "fileTooBigError": "ขนาดไฟล์ใหญ่เกินไป กรุณาอัพโหลดไฟล์ที่มีขนาดน้อยกว่า 10MB", "renameFile": { "title": "เปลี่ยนชื่อไฟล์", "description": "ป้อนชื่อใหม่สำหรับไฟล์นี้", "nameEmptyError": "ชื่อไฟล์ไม่สามารถเว้นว่างได้" }, "uploadedAt": "อัพโหลดเมื่อ {}", "linkedAt": "ลิงก์ถูกเพิ่มเมื่อ {}", "failedToOpenMsg": "ไม่สามารถเปิดได้ ไม่พบไฟล์" }, "subPage": { "handlingPasteHint": " - (การจัดการการวาง)", "errors": { "failedDeletePage": "ลบหน้าไม่สำเร็จ", "failedCreatePage": "สร้างหน้าไม่สำเร็จ", "failedMovePage": "ย้ายหน้ามายังเอกสารนี้ไม่สำเร็จ", "failedDuplicatePage": "ทำสำเนาหน้าไม่สำเร็จ", "failedDuplicateFindView": "ทำสำเนาหน้าไม่สำเร็จ - ไม่พบมุมมองต้นฉบับ" } }, "cannotMoveToItsChildren": "ไม่สามารถย้ายไปยังหน้าย่อยได้" }, "outlineBlock": { "placeholder": "สารบัญ" }, "textBlock": { "placeholder": "พิมพ์ '/' เพื่อดูคำสั่ง" }, "title": { "placeholder": "ไม่มีชื่อเรื่อง" }, "imageBlock": { "placeholder": "คลิกเพื่อเพิ่มรูปภาพ", "upload": { "label": "อัปโหลด", "placeholder": "คลิกเพื่ออัปโหลดรูปภาพ" }, "url": { "label": "URL รูปภาพ", "placeholder": "ป้อน URL รูปภาพ" }, "ai": { "label": "สร้างรูปภาพจาก AI", "placeholder": "โปรดระบุคำขอให้ AI สร้างรูปภาพ" }, "stability_ai": { "label": "สร้างรูปภาพจาก Stability AI", "placeholder": "โปรดระบุคำขอใช้ Stability AI สร้างรูปภาพ" }, "support": "ขนาดรูปภาพจำกัดอยู่ที่ 5MB รูปแบบที่รองรับ: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "รูปภาพไม่ถูกต้อง", "invalidImageSize": "ขนาดรูปภาพต้องไม่เกิน 5MB", "invalidImageFormat": "ไม่รองรับรูปแบบรูปภาพนี้ รูปแบบที่รองรับ: JPEG, PNG, GIF, SVG", "invalidImageUrl": "URL รูปภาพไม่ถูกต้อง", "noImage": "ไม่มีไฟล์หรือไดเร็กทอรีดังกล่าว", "multipleImagesFailed": "มีภาพหนึ่งหรือมากกว่าที่ไม่สามารถอัปโหลดได้ กรุณาลองใหม่อีกครั้ง" }, "embedLink": { "label": "ฝังลิงก์", "placeholder": "วางหรือพิมพ์ลิงก์รูปภาพ" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "ค้นหารูปภาพ", "pleaseInputYourOpenAIKey": "โปรดระบุคีย์ AI ของคุณในหน้าการตั้งค่า", "saveImageToGallery": "บันทึกภาพ", "failedToAddImageToGallery": "ไม่สามารถเพิ่มรูปภาพลงในแกลเลอรี่ได้", "successToAddImageToGallery": "เพิ่มรูปภาพลงในแกลเลอรี่เรียบร้อยแล้ว", "unableToLoadImage": "ไม่สามารถโหลดรูปภาพได้", "maximumImageSize": "ขนาดรูปภาพอัปโหลดที่รองรับสูงสุดคือ 10MB", "uploadImageErrorImageSizeTooBig": "ขนาดรูปภาพต้องน้อยกว่า 10MB", "imageIsUploading": "กำลังอัพโหลดรูปภาพ", "openFullScreen": "เปิดแบบเต็มจอ", "interactiveViewer": { "toolbar": { "previousImageTooltip": "ภาพก่อนหน้า", "nextImageTooltip": "ภาพถัดไป", "zoomOutTooltip": "ซูมออก", "zoomInTooltip": "ซูมเข้า", "changeZoomLevelTooltip": "เปลี่ยนระดับการซูม", "openLocalImage": "เปิดภาพ", "downloadImage": "ดาวน์โหลดภาพ", "closeViewer": "ปิดโปรแกรมดูแบบโต้ตอบ", "scalePercentage": "{}%", "deleteImageTooltip": "ลบรูปภาพ" } }, "pleaseInputYourStabilityAIKey": "โปรดระบุคีย์ Stability AI ของคุณในหน้าการตั้งค่า" }, "codeBlock": { "language": { "label": "ภาษา", "placeholder": "เลือกภาษา", "auto": "อัตโนมัติ" }, "copyTooltip": "สำเนา", "searchLanguageHint": "ค้นหาภาษา", "codeCopiedSnackbar": "คัดลอกโค้ดไปยังคลิปบอร์ดแล้ว!" }, "inlineLink": { "placeholder": "วางหรือพิมพ์ลิงก์", "openInNewTab": "เปิดในแท็บใหม่", "copyLink": "คัดลอกลิงก์", "removeLink": "ลบลิงก์", "url": { "label": "URL ลิงก์", "placeholder": "ป้อน URL ลิงก์" }, "title": { "label": "ชื่อหัวเรื่องลิงก์", "placeholder": "ป้อนชื่อหัวเรื่องลิงก์" } }, "mention": { "placeholder": "ระบุบุคคลหรือหน้าหรือวันที่...", "page": { "label": "ลิงก์ไปยังหน้า", "tooltip": "คลิกเพื่อเปิดหน้า" }, "deleted": "ลบแล้ว", "deletedContent": "เนื้อหานี้ไม่มีอยู่หรือถูกลบไปแล้ว", "noAccess": "ไม่มีการเข้าถึง", "deletedPage": "หน้าที่ถูกลบ", "trashHint": " - ในถังขยะ" }, "toolbar": { "resetToDefaultFont": "รีเซ็ตเป็นค่าเริ่มต้น" }, "errorBlock": { "theBlockIsNotSupported": "เวอร์ชันปัจจุบันไม่รองรับบล็อกนี้", "clickToCopyTheBlockContent": "คลิกเพื่อคัดลอกเนื้อหาบล็อค", "blockContentHasBeenCopied": "เนื้อหาบล็อกได้รับการคัดลอกแล้ว", "parseError": "เกิดข้อผิดพลาดขณะทำการแยกข้อมูลบล็อก {}", "copyBlockContent": "คัดลอกเนื้อหาบล็อค" }, "mobilePageSelector": { "title": "เลือกหน้า", "failedToLoad": "โหลดรายการหน้าไม่สำเร็จ", "noPagesFound": "ไม่พบหน้าใดๆ" }, "attachmentMenu": { "choosePhoto": "เลือกภาพถ่าย", "takePicture": "ถ่ายรูป", "chooseFile": "เลือกไฟล์" } }, "board": { "column": { "label": "คอลัมน์", "createNewCard": "สร้างใหม่", "renameGroupTooltip": "กดเพื่อเปลี่ยนชื่อกลุ่ม", "createNewColumn": "เพิ่มกลุ่มใหม่", "addToColumnTopTooltip": "เพิ่มการ์ดใหม่ที่ด้านบนสุด", "addToColumnBottomTooltip": "เพิ่มการ์ดใหม่ที่ด้านล่างสุด", "renameColumn": "เปลี่ยนชื่อ", "hideColumn": "ซ่อน", "newGroup": "กลุ่มใหม่", "deleteColumn": "ลบ", "deleteColumnConfirmation": "การดำเนินการนี้จะลบกลุ่มนี้และการ์ดทั้งหมดในกลุ่ม\nคุณแน่ใจหรือไม่ว่าต้องการดำเนินการต่อ?", "groupActions": "กลุ่มการดำเนินการ" }, "hiddenGroupSection": { "sectionTitle": "กลุ่มที่ซ่อนไว้", "collapseTooltip": "ซ่อนกลุ่มที่ซ่อนไว้", "expandTooltip": "ดูกลุ่มที่ซ่อนไว้" }, "cardDetail": "รายละเอียดการ์ด", "cardActions": "การดำเนินการการ์ด", "cardDuplicated": "การ์ดถูกคัดลอก", "cardDeleted": "การ์ดถูกลบ", "showOnCard": "แสดงบนรายละเอียดการ์ด", "setting": "การตั้งค่า", "propertyName": "ชื่อคุณสมบัติ", "menuName": "กระดาน", "showUngrouped": "แสดงรายการที่ยังไม่ได้จัดกลุ่ม", "ungroupedButtonText": "ยังไม่ได้จัดกลุ่ม", "ungroupedButtonTooltip": "ประกอบด้วยการ์ดที่ไม่อยู่ในกลุ่มใดๆ", "ungroupedItemsTitle": "คลิกเพื่อเพิ่มไปยังกระดาน", "groupBy": "จัดกลุ่มตาม", "groupCondition": "เงื่อนไขการจัดกลุ่ม", "referencedBoardPrefix": "มุมมองของ", "notesTooltip": "บันทึกย่อข้างใน", "mobile": { "editURL": "แก้ไข URL", "showGroup": "เลิกซ่อนกลุ่ม", "showGroupContent": "คุณแน่ใจหรือไม่ว่าต้องการแสดงกลุ่มนี้บนกระดาน?", "failedToLoad": "โหลดมุมมองกระดานไม่สำเร็จ" }, "dateCondition": { "weekOf": "สัปดาห์ที่ {} - {}", "today": "วันนี้", "yesterday": "เมื่อวาน", "tomorrow": "พรุ่งนี้", "lastSevenDays": "7 วันที่ผ่านมา", "nextSevenDays": "7 วันถัดไป", "lastThirtyDays": "30 วันที่ผ่านมา", "nextThirtyDays": "30 วันถัดไป" }, "noGroup": "ไม่มีการจัดกลุ่มตามคุณสมบัติ", "noGroupDesc": "มุมมองบอร์ดต้องการคุณสมบัติสำหรับการจัดกลุ่มเพื่อแสดงผล", "media": { "cardText": "{} {}", "fallbackName": "ไฟล์" } }, "calendar": { "menuName": "ปฏิทิน", "defaultNewCalendarTitle": "ยังไม่ได้ตั้งชื่อ", "newEventButtonTooltip": "เพิ่มกิจกรรมใหม่", "navigation": { "today": "วันนี้", "jumpToday": "ข้ามไปยังวันนี้", "previousMonth": "เดือนก่อนหน้า", "nextMonth": "เดือนถัดไป", "views": { "day": "วัน", "week": "สัปดาห์", "month": "เดือน", "year": "ปี" } }, "mobileEventScreen": { "emptyTitle": "ยังไม่มีกิจกรรม", "emptyBody": "กดปุ่มบวกเพื่อสร้างกิจกรรมในวันนี้" }, "settings": { "showWeekNumbers": "แสดงหมายเลขสัปดาห์", "showWeekends": "แสดงวันหยุดสุดสัปดาห์", "firstDayOfWeek": "เริ่มต้นสัปดาห์ในวัน", "layoutDateField": "จัดรูปแบบปฏิทินตาม", "changeLayoutDateField": "เปลี่ยนเค้าโครงฟิลด์", "noDateTitle": "ไม่มีวันที่", "noDateHint": { "zero": "กิจกรรมที่ไม่ได้กำหนดวันจะแสดงที่นี่", "one": "{count} กิจกรรมที่ไม่ได้กำหนดวัน", "other": "{count} กิจกรรมที่ไม่ได้กำหนดวัน" }, "unscheduledEventsTitle": "เหตุการณ์ที่ไม่ได้กำหนดไว้", "clickToAdd": "คลิกเพื่อเพิ่มไปยังปฏิทิน", "name": "การตั้งค่าปฏิทิน", "clickToOpen": "คลิกเพื่อเปิดบันทึก" }, "referencedCalendarPrefix": "มุมมองของ", "quickJumpYear": "ข้ามไปที่", "duplicateEvent": "ทำสำเนาเหตุการณ์" }, "errorDialog": { "title": "ข้อผิดพลาด AppFlowy", "howToFixFallback": "ขออภัยในความไม่สะดวก! ส่งปัญหาบนหน้า GitHub ของเราอธิบายถึงข้อผิดพลาดของคุณ", "howToFixFallbackHint1": "ขออภัยในความไม่สะดวก! ส่งปัญหาของคุณมาที่ ", "howToFixFallbackHint2": " หน้าที่อธิบายข้อผิดพลาดของคุณ", "github": "ดูบน GitHub" }, "search": { "label": "ค้นหา", "sidebarSearchIcon": "ค้นหาและไปยังหน้านั้นอย่างรวดเร็ว", "placeholder": { "actions": "ค้นหาการการดำเนินการ..." } }, "message": { "copy": { "success": "คัดลอกแล้ว!", "fail": "ไม่สามารถคัดลอกได้" } }, "unSupportBlock": "เวอร์ชันปัจจุบันไม่รองรับบล็อคนี้", "views": { "deleteContentTitle": "คุณแน่ใจหรือไม่ว่าต้องการลบ {pageType}?", "deleteContentCaption": "หากคุณลบ {pageType} นี้ คุณสามารถกู้คืนได้จากถังขยะ" }, "colors": { "custom": "แบบกำหนดเอง", "default": "ค่าเริ่มต้น", "red": "แดง", "orange": "ส้ม", "yellow": "เหลือง", "green": "เขียว", "blue": "น้ำเงิน", "purple": "ม่วง", "pink": "ชมพู", "brown": "น้ำตาล", "gray": "เทา" }, "emoji": { "emojiTab": "อิโมจิ", "search": "ค้นหาอิโมจิ", "noRecent": "ไม่มีอิโมจิล่าสุด", "noEmojiFound": "ไม่พบอิโมจิ", "filter": "ตัวกรอง", "random": "แบบสุ่ม", "selectSkinTone": "เลือกเฉดสี", "remove": "เอาอิโมจิออก", "categories": { "smileys": "ยิ้มและอารมณ์", "people": "ผู้คนและรูปร่าง", "animals": "สัตว์และธรรมชาติ", "food": "อาหารและเครื่องดื่ม", "activities": "กิจกรรม", "places": "การเดินทางและสถานที่", "objects": "วัตถุ", "symbols": "สัญลักษณ์", "flags": "ธงชาติ", "nature": "ธรรมชาติ", "frequentlyUsed": "ใช้บ่อย" }, "skinTone": { "default": "ค่าเริ่มต้น", "light": "สีอ่อน", "mediumLight": "ปานกลางอ่อน", "medium": "ปานกลาง", "mediumDark": "ปานกลางเข้ม", "dark": "เข้ม" }, "openSourceIconsFrom": "ไอคอนโอเพ่นซอร์สจาก" }, "inlineActions": { "noResults": "ไม่พบผลลัพธ์", "recentPages": "หน้าล่าสุด", "pageReference": "อ้างอิงหน้า", "docReference": "การอ้างอิงเอกสาร", "boardReference": "การอ้างอิงบอร์ด", "calReference": "การอ้างอิงปฏิทิน", "gridReference": "การอ้างอิงตาราง", "date": "วันที่", "reminder": { "groupTitle": "ตัวเตือน", "shortKeyword": "ตัวเตือน" }, "createPage": "สร้าง \"{}\" หน้าย่อย" }, "datePicker": { "dateTimeFormatTooltip": "เปลี่ยนรูปแบบวันที่และเวลาในการตั้งค่า", "dateFormat": "รูปแบบวันที่", "includeTime": "รวมถึงเวลา", "isRange": "วันที่สิ้นสุด", "timeFormat": "รูปแบบเวลา", "clearDate": "ล้างวันที่", "reminderLabel": "การเตือน", "selectReminder": "เลือกการเตือน", "reminderOptions": { "none": "ไม่มี", "atTimeOfEvent": "เวลาของงาน", "fiveMinsBefore": "5 นาทีก่อน", "tenMinsBefore": "10 นาทีก่อน", "fifteenMinsBefore": "15 นาทีก่อน", "thirtyMinsBefore": "30 นาทีก่อน", "oneHourBefore": "1 ชั่วโมงก่อน", "twoHoursBefore": "2 ชั่วโมงก่อน", "onDayOfEvent": "ในวันที่มีงาน", "oneDayBefore": "1 วันก่อน", "twoDaysBefore": "2 วันก่อน", "oneWeekBefore": "1 สัปดาห์ก่อน", "custom": "กำหนดเอง" } }, "relativeDates": { "yesterday": "เมื่อวานนี้", "today": "วันนี้", "tomorrow": "พรุ่งนี้", "oneWeek": "1 สัปดาห์" }, "notificationHub": { "title": "การแจ้งเตือน", "mobile": { "title": "อัปเดต" }, "emptyTitle": "ตามทันแล้ว!", "emptyBody": "ไม่มีการแจ้งเตือนหรือการดำเนินการที่รอดำเนินการ โปรดเพลิดเพลินกับความสงบได้", "tabs": { "inbox": "กล่องจดหมายเข้า", "upcoming": "กำลังจะเกิดขึ้น" }, "actions": { "markAllRead": "ทำเครื่องหมายทั้งหมดว่าอ่านแล้ว", "showAll": "ทั้งหมด", "showUnreads": "ยังไม่ได้อ่าน" }, "filters": { "ascending": "จากน้อยไปมาก", "descending": "จากมากไปน้อย", "groupByDate": "จัดกลุ่มตามวันที่", "showUnreadsOnly": "แสดงเฉพาะเนื้อหาที่ยังไม่ได้อ่าน", "resetToDefault": "รีเซ็ตเป็นค่าเริ่มต้น" } }, "reminderNotification": { "title": "แจ้งเตือน", "message": "โปรดจำไว้ตรวจสอบสิ่งนี้ก่อนที่คุณจะลืม!", "tooltipDelete": "ลบ", "tooltipMarkRead": "ทำเครื่องหมายว่าอ่านแล้ว", "tooltipMarkUnread": "ทำเครื่องหมายว่ายังไม่ได้อ่าน" }, "findAndReplace": { "find": "ค้นหา", "previousMatch": "การจับคู่ก่อนหน้า", "nextMatch": "การจับคู่ถัดไป", "close": "ปิด", "replace": "แทนที่", "replaceAll": "แทนที่ทั้งหมด", "noResult": "ไม่มีผลลัพธ์", "caseSensitive": "แบบเจาะจง", "searchMore": "ค้นหาเพื่อหาผลลัพธ์เพิ่มเติม" }, "error": { "weAreSorry": "ขออภัย", "loadingViewError": "เรากำลังพบปัญหาในการโหลดมุมมองนี้ โปรดตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณ โหลดแอปซ้ำ และอย่าลังเลที่จะติดต่อทีมงานหากปัญหายังคงไม่หาย", "syncError": "ข้อมูลไม่ได้รับการซิงค์จากอุปกรณ์อื่น", "syncErrorHint": "โปรดเปิดหน้านี้อีกครั้งบนอุปกรณ์ที่แก้ไขล่าสุด จากนั้นเปิดอีกครั้งบนอุปกรณ์ปัจจุบัน", "clickToCopy": "คลิกเพื่อคัดลอกรหัสข้อผิดพลาด" }, "editor": { "bold": "ตัวหนา", "bulletedList": "รายการลำดับหัวข้อย่อย", "bulletedListShortForm": "รายการสัญลักษณ์จุด", "checkbox": "กล่องกาเครื่องหมาย", "embedCode": "ฝังโค้ด", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "ไฮไลท์", "color": "สี", "image": "รูปภาพ", "date": "วันที่", "page": "หน้า", "italic": "ตัวเอียง", "link": "ลิงก์", "numberedList": "รายการลำดับตัวเลข", "numberedListShortForm": "แบบระบุหมายเลข", "toggleHeading1ShortForm": "ตัวเปิดปิดหัวข้อ h1", "toggleHeading2ShortForm": "ตัวเปิดปิดหัวข้อ h2", "toggleHeading3ShortForm": "ตัวเปิดปิดหัวข้อ h3", "quote": "คำกล่าว", "strikethrough": "ขีดฆ่า", "text": "ข้อความ", "underline": "ขีดเส้นใต้", "fontColorDefault": "สีเริ่มต้น", "fontColorGray": "สีเทา", "fontColorBrown": "สีน้ำตาล", "fontColorOrange": "สีส้ม", "fontColorYellow": "สีเหลือง", "fontColorGreen": "สีเขียว", "fontColorBlue": "สีน้ำเงิน", "fontColorPurple": "สีม่วง", "fontColorPink": "สีชมพู", "fontColorRed": "สีแดง", "backgroundColorDefault": "พื้นหลังเริ่มต้น", "backgroundColorGray": "พื้นหลังสีเทา", "backgroundColorBrown": "พื้นหลังสีน้ำตาล", "backgroundColorOrange": "พื้นหลังสีส้ม", "backgroundColorYellow": "พื้นหลังสีเหลือง", "backgroundColorGreen": "พื้นหลังสีเขียว", "backgroundColorBlue": "พื้นหลังสีน้ำเงิน", "backgroundColorPurple": "พื้นหลังสีม่วง", "backgroundColorPink": "พื้นหลังสีชมพู", "backgroundColorRed": "พื้นหลังสีแดง", "backgroundColorLime": "พื้นหลังสีเขียวมะนาว", "backgroundColorAqua": "พื้นหลังสีฟ้าน้ำทะเล", "done": "เสร็จสิ้น", "cancel": "ยกเลิก", "tint1": "สีจาง 1", "tint2": "สีจาง 2", "tint3": "สีจาง 3", "tint4": "สีจาง 4", "tint5": "สีจาง 5", "tint6": "สีจาง 6", "tint7": "สีจาง 7", "tint8": "สีจาง 8", "tint9": "สีจาง 9", "lightLightTint1": "สีม่วง", "lightLightTint2": "สีชมพู", "lightLightTint3": "สีชมพูอ่อน", "lightLightTint4": "สีส้ม", "lightLightTint5": "สีเหลือง", "lightLightTint6": "สีมะนาว", "lightLightTint7": "สีเขียว", "lightLightTint8": "สีฟ้าอมเขียว", "lightLightTint9": "สีน้ำเงิน", "urlHint": "URL", "mobileHeading1": "หัวข้อที่ 1", "mobileHeading2": "หัวข้อที่ 2", "mobileHeading3": "หัวข้อที่ 3", "textColor": "สีข้อความ", "backgroundColor": "สีพื้นหลัง", "addYourLink": "เพิ่มลิงก์ของคุณ", "openLink": "เปิดลิงก์", "copyLink": "คัดลอกลิงก์", "removeLink": "ลบลิงก์", "editLink": "แก้ไขลิงก์", "linkText": "ข้อความ", "linkTextHint": "โปรดป้อนข้อความ", "linkAddressHint": "โปรดป้อน URL", "highlightColor": "สีไฮไลท์", "clearHighlightColor": "ล้างสีไฮไลท์", "customColor": "สีแบบกำหนดเอง", "hexValue": "ค่า Hex", "opacity": "ความทึบ", "resetToDefaultColor": "รีเซ็ตเป็นสีเริ่มต้น", "ltr": "ซ้ายไปขวา", "rtl": "ขวาไปซ้าย", "auto": "อัตโนมัติ", "cut": "ตัด", "copy": "คัดลอก", "paste": "วาง", "find": "ค้นหา", "select": "เลือก", "selectAll": "เลือกทั้งหมด", "previousMatch": "จับคู่ก่อนหน้า", "nextMatch": "จับคู่ถัดไป", "closeFind": "ปิด", "replace": "แทนที่", "replaceAll": "แทนที่ทั้งหมด", "regex": "Regex", "caseSensitive": "แบบเจาะจง", "uploadImage": "อัปโหลดรูปภาพ", "urlImage": "URL รูปภาพ", "incorrectLink": "ลิงก์ผิด", "upload": "อัปโหลด", "chooseImage": "เลือกภาพ", "loading": "กำลังโหลด", "imageLoadFailed": "ไม่สามารถโหลดรูปภาพได้", "divider": "ตัวแบ่ง", "table": "ตาราง", "colAddBefore": "เพิ่มด้านหน้า", "rowAddBefore": "เพิ่มด้านหน้า", "colAddAfter": "เพิ่มด้านหลัง", "rowAddAfter": "เพิ่มด้านหลัง", "colRemove": "ลบ", "rowRemove": "ลบ", "colDuplicate": "ทำซ้ำ", "rowDuplicate": "ทำซ้ำ", "colClear": "ล้างเนื้อหา", "rowClear": "ล้างเนื้อหา", "slashPlaceHolder": "พิมพ์ / เพื่อแทรกบล็อก หรือเริ่มพิมพ์", "typeSomething": "พิมพ์อะไรบางอย่าง...", "toggleListShortForm": "สลับ", "quoteListShortForm": "คำกล่าว", "mathEquationShortForm": "สูตร", "codeBlockShortForm": "โค้ด" }, "favorite": { "noFavorite": "ไม่มีหน้ารายการโปรด", "noFavoriteHintText": "ปัดหน้าไปทางซ้ายเพื่อเพิ่มลงในรายการโปรด", "removeFromSidebar": "ลบออกจากแถบด้านข้าง", "addToSidebar": "ปักหมุดไปที่แถบด้านข้าง" }, "cardDetails": { "notesPlaceholder": "ป้อน / เพื่อแทรกบล็อก หรือเริ่มพิมพ์" }, "blockPlaceholders": { "todoList": "รายการสิ่งที่ต้องทำ", "bulletList": "รายการ", "numberList": "รายการ", "quote": "คำกล่าว", "heading": "หัวข้อ {}" }, "titleBar": { "pageIcon": "ไอคอนหน้า", "language": "ภาษา", "font": "แบบอักษร", "actions": "การดำเนินการ", "date": "วันที่", "addField": "เพิ่มฟิลด์", "userIcon": "ไอคอนผู้ใช้งาน" }, "noLogFiles": "ไม่มีไฟล์บันทึก", "newSettings": { "myAccount": { "title": "บัญชีของฉัน", "subtitle": "ปรับแต่งโปรไฟล์ของคุณ จัดการความปลอดภัยบัญชี เปิดคีย์ AI หรือเข้าสู่ระบบบัญชีของคุณ", "profileLabel": "ชื่อบัญชีและรูปโปรไฟล์", "profileNamePlaceholder": "กรุณากรอกชื่อของคุณ", "accountSecurity": "ความปลอดภัยของบัญชี", "2FA": "การยืนยันตัวตน 2 ขั้นตอน", "aiKeys": "คีย์ AI", "accountLogin": "การเข้าสู่ระบบบัญชี", "updateNameError": "อัปเดตชื่อไม่สำเร็จ", "updateIconError": "อัปเดตไอคอนไม่สำเร็จ", "deleteAccount": { "title": "ลบบัญชี", "subtitle": "ลบบัญชี และข้อมูลทั้งหมดของคุณอย่างถาวร", "description": "ลบบัญชีของคุณอย่างถาวร และลบสิทธิ์การเข้าถึงจากพื้นที่ทำงานทั้งหมด", "deleteMyAccount": "ลบบัญชีของฉัน", "dialogTitle": "ลบบัญชี", "dialogContent1": "คุณแน่ใจหรือไม่ว่าต้องการลบบัญชีของคุณอย่างถาวร?", "dialogContent2": "การดำเนินการนี้ไม่สามารถย้อนกลับได้ และจะลบสิทธิ์การเข้าถึงจากพื้นที่ทำงานทั้งหมด ลบบัญชีของคุณทั้งหมด รวมถึงพื้นที่ทำงานส่วนตัว และลบคุณออกจากพื้นที่ทำงานที่แชร์ทั้งหมด", "confirmHint1": "กรุณาพิมพ์ \"DELETE MY ACCOUNT\" เพื่อยืนยัน", "confirmHint2": "ฉันเข้าใจดีว่าการดำเนินการนี้ไม่สามารถย้อนกลับได้ และจะลบบัญชีของฉันรวมถึงข้อมูลที่เกี่ยวข้องทั้งหมดอย่างถาวร", "confirmHint3": "DELETE MY ACCOUNT", "checkToConfirmError": "คุณต้องทำเครื่องหมายในช่องเพื่อยืนยันการลบ", "failedToGetCurrentUser": "ไม่สามารถดึงอีเมลของผู้ใช้ปัจจุบันได้", "confirmTextValidationFailed": "ข้อความยืนยันของคุณไม่ตรงกับ \"DELETE MY ACCOUNT\"", "deleteAccountSuccess": "ลบบัญชีสำเร็จแล้ว" } }, "workplace": { "name": "พื้นไที่ทำงาน", "title": "การตั้งค่าพื้นไที่ทำงาน", "subtitle": "ปรับแต่งรูปลักษณ์พื้นที่ทำงาน ธีม แบบอักษร เค้าโครงข้อความ วันที่ เวลา และภาษาของคุณ", "workplaceName": "ชื่อพื้นไที่ทำงาน", "workplaceNamePlaceholder": "กรอกชื่อพื้นที่ทำงาน", "workplaceIcon": "ไอคอนพื้นที่ทำงาน", "workplaceIconSubtitle": "อัปโหลดรูปภาพ หรือใช้อีโมจิสำหรับพื้นที่ทำงานของคุณ ไอคอนจะแสดงในแถบด้านข้าง และการแจ้งเตือนของคุณ", "renameError": "ไม่สามารถเปลี่ยนชื่อสถานที่ทำงานได้", "updateIconError": "ไม่สามารถอัปเดตไอคอนได้", "chooseAnIcon": "เลือกไอคอน", "appearance": { "name": "ลักษณะการแสดงผล", "themeMode": { "auto": "อัตโนมัติ", "light": "สว่าง", "dark": "มืด" }, "language": "ภาษา" } }, "syncState": { "syncing": "กำลังซิงค์", "synced": "ซิงค์แล้ว", "noNetworkConnected": "ไม่มีการเชื่อมต่อเครือข่าย" } }, "pageStyle": { "title": "รูปแบบหน้า", "layout": "เค้าโครง", "coverImage": "ภาพปก", "pageIcon": "ไอคอนหน้าเพจ", "colors": "สีสัน", "gradient": "การไล่ระดับ", "backgroundImage": "ภาพพื้นหลัง", "presets": "พรีเซ็ต", "photo": "รูปถ่าย", "unsplash": "อันสแปลช", "pageCover": "ปกหน้า", "none": "ไม่มี", "openSettings": "เปิดการตั้งค่า", "photoPermissionTitle": "@:appName ต้องการเข้าถึงคลังรูปภาพของคุณ", "photoPermissionDescription": "@:appName ต้องการเข้าถึงรูปภาพของคุณเพื่อให้คุณสามารถเพิ่มภาพลงในเอกสารของคุณได้", "cameraPermissionTitle": "@:appName ต้องการเข้าถึงกล้องของคุณ", "cameraPermissionDescription": "@:appName ต้องการเข้าถึงกล้องของคุณเพื่อให้คุณสามารถเพิ่มรูปภาพลงในเอกสารจากกล้องได้", "doNotAllow": "ไม่อนุญาต", "image": "รูปภาพ" }, "commandPalette": { "placeholder": "พิมพ์เพื่อค้นหา...", "bestMatches": "การจับคู่ที่ดีที่สุด", "recentHistory": "ประวัติล่าสุด", "navigateHint": "เพื่อนำทาง", "loadingTooltip": "เรากำลังมองหาผลลัพธ์...", "betaLabel": "BETA", "betaTooltip": "ปัจจุบันเรารองรับเฉพาะการค้นหาหน้า หรือเนื้อหาภายในเอกสารเท่านั้น", "fromTrashHint": "จากถังขยะ", "noResultsHint": "เราไม่พบสิ่งที่คุณกำลังมองหา ลองค้นหาด้วยคำอื่นดู", "clearSearchTooltip": "ล้างช่องค้นหา" }, "space": { "delete": "ลบ", "deleteConfirmation": "ลบ: ", "deleteConfirmationDescription": "หน้าทั้งหมดในพื้นที่นี้จะถูกลบออก และย้ายไปยังถังขยะ และหน้าที่เผยแพร่ทั้งหมดจะถูกยกเลิกการเผยแพร่", "rename": "เปลี่ยนชื่อพื้นที่", "changeIcon": "เปลี่ยนไอคอน", "manage": "จัดการพื้นที่", "addNewSpace": "สร้างพื้นที่", "collapseAllSubPages": "ยุบหน้าย่อยทั้งหมด", "createNewSpace": "สร้างพื้นที่ใหม่", "createSpaceDescription": "สร้างพื้นที่สาธารณะ และส่วนตัวหลายแห่ง เพื่อจัดระเบียบงานของคุณได้ดีขึ้น", "spaceName": "ชื่อพื้นที่", "spaceNamePlaceholder": "เช่น การตลาด วิศวกรรม ทรัพยากรบุคคล", "permission": "การอนุญาตใช้พื้นที่", "publicPermission": "สาธารณะ", "publicPermissionDescription": "สมาชิกพื้นที่ทำงานทั้งหมดที่มีสิทธิ์เข้าถึงเต็มรูปแบบ", "privatePermission": "ส่วนตัว", "privatePermissionDescription": "เฉพาะคุณเท่านั้นที่สามารถเข้าถึงพื้นที่นี้ได้", "spaceIconBackground": "สีพื้นหลัง", "spaceIcon": "ไอคอน", "dangerZone": "โซนอันตราย", "unableToDeleteLastSpace": "ไม่สามารถลบพื้นที่สุดท้ายได้", "unableToDeleteSpaceNotCreatedByYou": "ไม่สามารถลบพื้นที่ที่สร้างโดยผู้อื่นได้", "enableSpacesForYourWorkspace": "เปิดใช้งานพื้นที่สำหรับพื้นที่ทำงานของคุณ", "title": "พื้นที่", "defaultSpaceName": "ทั่วไป", "upgradeSpaceTitle": "เปิดใช้งานพื้นที่", "upgradeSpaceDescription": "สร้างพื้นที่สาธารณะ และส่วนตัวหลายแห่ง เพื่อจัดระเบียบพื้นที่ทำงานของคุณได้ดีขึ้น", "upgrade": "อัปเดต", "upgradeYourSpace": "สร้างหลายพื้นที่", "quicklySwitch": "สลับไปยังพื้นที่ถัดไปอย่างรวดเร็ว", "duplicate": "ทำสำเนาพื้นที่", "movePageToSpace": "ย้ายหน้าไปยังพื้นที่", "cannotMovePageToDatabase": "ไม่สามารถย้ายหน้าไปยังฐานข้อมูลได้", "switchSpace": "สลับพื้นที่", "spaceNameCannotBeEmpty": "ชื่อพื้นที่ไม่สามารถปล่อยว่างได้", "success": { "deleteSpace": "ลบพื้นที่สำเร็จ", "renameSpace": "เปลี่ยนชื่อพื้นที่สำเร็จ", "duplicateSpace": "คัดลอกพื้นที่สำเร็จ", "updateSpace": "อัปเดตพื้นที่สำเร็จ" }, "error": { "deleteSpace": "ลบพื้นที่ไม่สำเร็จ", "renameSpace": "เปลี่ยนชื่อพื้นที่ไม่สำเร็จ", "duplicateSpace": "ทำสำเนาพื้นที่ไม่สำเร็จ", "updateSpace": "อัปเดตพื้นที่ไม่สำเร็จ" }, "createSpace": "สร้างพื้นที่", "manageSpace": "จัดการพื้นที่", "renameSpace": "เปลี่ยนชื่อพื้นที่", "mSpaceIconColor": "สีไอคอนพื้นที่", "mSpaceIcon": "ไอคอนพื้นที่" }, "publish": { "hasNotBeenPublished": "หน้านี้ยังไม่ได้เผยแพร่", "spaceHasNotBeenPublished": "ยังไม่รองรับการเผยแพร่พื้นที่", "reportPage": "รายงานหน้า", "databaseHasNotBeenPublished": "ยังไม่รองรับการเผยแพร่ฐานข้อมูล", "createdWith": "สร้างด้วย", "downloadApp": "ดาวน์โหลด AppFlowy", "copy": { "codeBlock": "คัดลอกเนื้อหาของโค้ดบล็อคไปยังคลิปบอร์ดแล้ว", "imageBlock": "คัดลอกลิงก์รูปภาพไปยังคลิปบอร์ดแล้ว", "mathBlock": "คัดลอกสมการคณิตศาสตร์ไปยังคลิปบอร์ดแล้ว", "fileBlock": "คัดลอกลิงก์ไฟล์ไปยังคลิปบอร์ดแล้ว" }, "containsPublishedPage": "หน้านี้มีหน้าที่เผยแพร่แล้วหนึ่งหน้าขึ้นไป หากคุณดำเนินการต่อ หน้าเหล่านั้นจะถูกยกเลิกการเผยแพร่ คุณต้องการดำเนินการลบหรือไม่", "publishSuccessfully": "เผยแพร่สำเร็จ", "unpublishSuccessfully": "ยกเลิกการเผยแพร่สำเร็จ", "publishFailed": "เผยแพร่ไม่สำเร็จ", "unpublishFailed": "ยกเลิกการเผยแพร่ไม่สำเร็จ", "noAccessToVisit": "ไม่มีการเข้าถึงหน้านี้...", "createWithAppFlowy": "สร้างเว็บไซต์ด้วย AppFlowy", "fastWithAI": "รวดเร็วและง่ายดายด้วย AI", "tryItNow": "ลองเลยตอนนี้", "onlyGridViewCanBePublished": "สามารถเผยแพร่ได้เฉพาะมุมมองแบบกริดเท่านั้น", "database": { "zero": "เผยแพร่ {} มุมมองที่เลือก", "one": "เผยแพร่ {} มุมมองที่เลือก", "many": "เผยแพร่ {} มุมมองที่เลือก", "other": "เผยแพร่ {} มุมมองที่เลือก" }, "mustSelectPrimaryDatabase": "ต้องเลือกมุมมองหลัก", "noDatabaseSelected": "ยังไม่ได้เลือกฐานข้อมูล กรุณาเลือกอย่างน้อยหนึ่งฐานข้อมูล", "unableToDeselectPrimaryDatabase": "ไม่สามารถยกเลิกการเลือกฐานข้อมูลหลักได้", "saveThisPage": "เริ่มต้นด้วยเทมเพลตนี้", "duplicateTitle": "คุณต้องการเพิ่มที่ไหน", "selectWorkspace": "เลือกพื้นที่ทำงาน", "addTo": "เพิ่มไปยัง", "duplicateSuccessfully": "เพิ่มไปที่พื้นที่ทำงานของคุณแล้ว", "duplicateSuccessfullyDescription": "ยังไม่ได้ติดตั้ง AppFlowy ใช่ไหม? การดาวน์โหลดจะเริ่มต้นโดยอัตโนมัติหลังจากที่คุณคลิก 'ดาวน์โหลด'", "downloadIt": "ดาวน์โหลด", "openApp": "เปิดในแอป", "duplicateFailed": "ทำสำเนาไม่สำเร็จ", "membersCount": { "zero": "ไม่มีสมาชิก", "one": "1 สมาชิก", "many": "{count} สมาชิก", "other": "{count} สมาชิก" }, "useThisTemplate": "ใช้เทมเพลต" }, "web": { "continue": "ดำเนินการต่อ", "or": "หรือ", "continueWithGoogle": "ดำเนินการต่อด้วย Google", "continueWithGithub": "ดำเนินการต่อด้วย GitHub", "continueWithDiscord": "ดำเนินการต่อด้วย Discord", "continueWithApple": "ดำเนินการต่อด้วย Apple ", "moreOptions": "ตัวเลือกเพิ่มเติม", "collapse": "ยุบ", "signInAgreement": "โดยการคลิก \"ดำเนินการต่อ\" ด้านบน, คุณตกลงตามข้อกำหนดของ AppFlowy", "and": "และ", "termOfUse": "เงื่อนไข", "privacyPolicy": "นโยบายความเป็นส่วนตัว", "signInError": "เกิดข้อผิดพลาดในการเข้าสู่ระบบ", "login": "ลงทะเบียนหรือเข้าสู่ระบบ", "fileBlock": { "uploadedAt": "อัพโหลดเมื่อ {เวลา}", "linkedAt": "เพิ่มลิงก์เมื่อ {เวลา}", "empty": "อัพโหลดหรือฝังไฟล์" }, "importNotion": "นำเข้าจาก Notion", "import": "นำเข้า", "importSuccess": "อัพโหลดสำเร็จ", "importSuccessMessage": "เราจะแจ้งให้คุณทราบเมื่อการนำเข้าเสร็จสมบูรณ์ หลังจากนั้นคุณสามารถดูหน้าที่นำเข้าได้ในแถบด้านข้าง", "importFailed": "การนำเข้าไม่สำเร็จ กรุณาตรวจสอบรูปแบบไฟล์", "dropNotionFile": "วางไฟล์ zip ของ Notion ไว้ที่นี่เพื่ออัพโหลด หรือคลิกเพื่อเรียกดู" }, "globalComment": { "comments": "ความคิดเห็น", "addComment": "เพิ่มความคิดเห็น", "reactedBy": "ตอบสนองโดย", "addReaction": "เพิ่มความรู้สึก", "reactedByMore": "และอีก {count}", "showSeconds": { "one": "1 วินาทีที่แล้ว", "other": "{count} วินาทีที่ผ่านมา", "zero": "เมื่อสักครู่", "many": "{count} วินาทีที่ผ่านมา" }, "showMinutes": { "one": "1 นาทีที่แล้ว", "other": "{count} นาทีที่ผ่านมา", "many": "{count} นาทีที่ผ่านมา" }, "showHours": { "one": "1 ชั่วโมงที่แล้ว", "other": "{count} ชั่วโมงที่ผ่านมา", "many": "{count} ชั่วโมงที่ผ่านมา" }, "showDays": { "one": "1 วันที่ผ่านมา", "other": "{count} วันที่ผ่านมา", "many": "{count} วันที่ผ่านมา" }, "showMonths": { "one": "1 เดือนที่ผ่านมา", "other": "{count} เดือนที่ผ่านมา", "many": "{count} เดือนที่ผ่านมา" }, "showYears": { "one": "1 ปีที่ผ่านมา", "other": "{count} ปีที่ผ่านมา", "many": "{count} ปีที่ผ่านมา" }, "reply": "ตอบกลับ", "deleteComment": "ลบความคิดเห็น", "youAreNotOwner": "คุณไม่ใช่เจ้าของความคิดเห็นนี้", "confirmDeleteDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบความคิดเห็นนี้?", "hasBeenDeleted": "ลบแล้ว", "replyingTo": "กำลังตอบกลับถึง", "noAccessDeleteComment": "คุณไม่มีสิทธิ์ลบความคิดเห็นนี้", "collapse": "ยุบ", "readMore": "อ่านเพิ่มเติม", "failedToAddComment": "เพิ่มความคิดเห็นไม่สำเร็จ", "commentAddedSuccessfully": "เพิ่มความคิดเห็นสำเร็จ", "commentAddedSuccessTip": "คุณเพิ่งเพิ่ม หรือตอบกลับความคิดเห็น คุณต้องการข้ามไปที่ด้านบนเพื่อดูความคิดเห็นล่าสุดหรือไม่" }, "template": { "asTemplate": "บันทึกเป็นเทมเพลต", "name": "ชื่อเทมเพลต", "description": "คำอธิบายเทมเพลต", "about": "เทมเพลตเกี่ยวกับ", "deleteFromTemplate": "ลบออกจากเทมเพลต", "preview": "ตัวอย่างเทมเพลต", "categories": "หมวดหมู่เทมเพลต", "isNewTemplate": "PIN ไปยังเทมเพลตใหม่", "featured": "PIN ไปที่ฟีเจอร์", "relatedTemplates": "เทมเพลตที่เกี่ยวข้อง", "requiredField": "{field} จำเป็นต้องกรอก", "addCategory": "เพิ่ม \"{category}\"", "addNewCategory": "เพิ่มหมวดหมู่ใหม่", "addNewCreator": "เพิ่มผู้สร้างใหม่", "deleteCategory": "ลบหมวดหมู่", "editCategory": "แก้ไขหมวดหมู่", "editCreator": "แก้ไขผู้สร้าง", "category": { "name": "ชื่อหมวดหมู่", "icon": "ไอคอนหมวดหมู่", "bgColor": "สีพื้นหลังหมวดหมู่", "priority": "ลำดับความสำคัญของหมวดหมู่", "desc": "คำอธิบายหมวดหมู่", "type": "ประเภทหมวดหมู่", "icons": "ไอคอนหมวดหมู่", "colors": "สีหมวดหมู่", "byUseCase": "ตามลักษณะการใช้งาน", "byFeature": "ตามคุณสมบัติ", "deleteCategory": "ลบหมวดหมู่", "deleteCategoryDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบหมวดหมู่นี้?", "typeToSearch": "พิมพ์เพื่อค้นหาหมวดหมู่..." }, "creator": { "label": "ผู้สร้างเทมเพลต", "name": "ชื่อผู้สร้าง", "avatar": "อวตาร์ของผู้สร้าง", "accountLinks": "ลิงค์บัญชีผู้สร้าง", "uploadAvatar": "คลิกเพื่ออัพโหลดอวาตาร์", "deleteCreator": "ลบผู้สร้าง", "deleteCreatorDescription": "คุณแน่ใจหรือไม่ว่าต้องการลบผู้สร้างรายนี้?", "typeToSearch": "พิมพ์เพื่อค้นหาผู้สร้าง..." }, "uploadSuccess": "อัพโหลดเทมเพลตสำเร็จ", "uploadSuccessDescription": "เทมเพลตของคุณได้รับการอัปโหลดสำเร็จแล้ว ขณะนี้คุณสามารถดูเทมเพลตได้ในแกลเลอรีเทมเพลต", "viewTemplate": "ดูเทมเพลต", "deleteTemplate": "ลบเทมเพลต", "deleteSuccess": "ลบเทมเพลตสำเร็จ", "deleteTemplateDescription": "การกระทำนี้จะไม่ส่งผลกระทบต่อหน้าปัจจุบัน หรือสถานะการเผยแพร่ คุณแน่ใจหรือไม่ว่าต้องการลบเทมเพลตนี้?", "addRelatedTemplate": "เพิ่มเทมเพลตที่เกี่ยวข้อง", "removeRelatedTemplate": "ลบเทมเพลตที่เกี่ยวข้อง", "uploadAvatar": "อัพโหลดอวาตาร์", "searchInCategory": "ค้นหาใน {category}", "label": "เทมเพลต" }, "fileDropzone": { "dropFile": "คลิก หรือลากไฟล์ ไปยังพื้นที่นี้เพื่ออัพโหลด", "uploading": "กำลังอัพโหลด...", "uploadFailed": "อัพโหลดไม่สำเร็จ", "uploadSuccess": "อัพโหลดสำเร็จ", "uploadSuccessDescription": "ไฟล์ได้รับการอัพโหลดเรียบร้อยแล้ว", "uploadFailedDescription": "อัพโหลดไฟล์ไม่สำเร็จ", "uploadingDescription": "ไฟล์กำลังถูกอัพโหลด" }, "gallery": { "preview": "เปิดแบบเต็มจอ", "copy": "สำเนา", "download": "ดาวน์โหลด", "prev": "ก่อนหน้า", "next": "ถัดไป", "resetZoom": "รีเซ็ตการซูม", "zoomIn": "ซูมเข้า", "zoomOut": "ซูมออก" }, "invitation": { "join": "เข้าร่วม", "on": "บน", "invitedBy": "ได้รับเชิญโดย", "membersCount": { "zero": "{count} สมาชิก", "one": "{count} สมาชิก", "many": "{count} สมาชิก", "other": "{count} สมาชิก" }, "tip": "คุณได้รับคำเชิญให้เข้าร่วมพื้นที่ทำงานนี้โดยใช้ข้อมูลติดต่อด้านล่าง หากข้อมูลนี้ไม่ถูกต้อง กรุณาติดต่อผู้ดูแลเพื่อขอให้ส่งคำเชิญใหม่", "joinWorkspace": "เข้าร่วมพื้นที่ทำงาน", "success": "คุณได้เข้าร่วมพื้นที่ทำงานเรียบร้อยแล้ว", "successMessage": "ตอนนี้คุณสามารถเข้าถึงหน้า และพื้นที่ทำงานทั้งหมดภายในนั้นได้แล้ว", "openWorkspace": "เปิด AppFlowy", "alreadyAccepted": "คุณได้ยอมรับคำเชิญแล้ว", "errorModal": { "title": "มีบางอย่างผิดพลาด", "description": "บัญชีปัจจุบันของคุณ {email} อาจไม่มีสิทธิ์เข้าถึงพื้นที่ทำงานนี้ กรุณาล็อกอินด้วยบัญชีที่ถูกต้องหรือ ติดต่อเจ้าของพื้นที่ทำงานเพื่อขอความช่วยเหลือ", "contactOwner": "ติดต่อเจ้าของ", "close": "กลับสู่หน้าแรก", "changeAccount": "เปลี่ยนบัญชี" } }, "requestAccess": { "title": "ไม่มีการเข้าถึงหน้านี้", "subtitle": "คุณสามารถขอสิทธิ์การเข้าถึงจากเจ้าขอหน้านี้ได้ เมื่อได้รับการอนุมัติแล้วคุณจะสามารถดูหน้าได้", "requestAccess": "ขอสิทธิ์การเข้าถึง", "backToHome": "กลับสู่หน้าแรก", "tip": "ขณะนี้คุณเข้าสู่ระบบเป็น ", "mightBe": "คุณอาจต้อง ด้วยบัญชีอื่น", "successful": "ส่งคำขอเรียบร้อยแล้ว", "successfulMessage": "คุณจะได้รับการแจ้งเตือนเมื่อเจ้าของอนุมัติคำขอของคุณ", "requestError": "ไม่สามารถขอการเข้าถึงได้", "repeatRequestError": "คุณได้ขอการเข้าถึงหน้านี้แล้ว" }, "approveAccess": { "title": "อนุมัติคำขอเข้าร่วมพื้นที่ทำงาน", "requestSummary": " ขอเข้าร่วม และเข้าถึง ", "upgrade": "อัพเกรด", "downloadApp": "ดาวน์โหลด AppFlowy", "approveButton": "อนุมัติ", "approveSuccess": "ได้รับการอนุมัติเรียบร้อยแล้ว", "approveError": "ไม่สามารถอนุมัติได้ โปรดตรวจสอบให้แน่ใจว่าไม่เกินขีดจำกัดของแผนพื้นที่ทำงาน", "getRequestInfoError": "ไม่สามารถรับข้อมูลคำขอได้", "memberCount": { "zero": "ไม่มีสมาชิก", "one": "1 สมาชิก", "many": "{count} สมาชิก", "other": "{count} สมาชิก" }, "alreadyProTitle": "คุณได้ถึงขีดจำกัดของแผนพื้นที่ทำงานแล้ว", "alreadyProMessage": "ขอให้พวกเขาติดต่อ เพื่อปลดล็อกสมาชิกเพิ่มเติม", "repeatApproveError": "คุณได้อนุมัติคำขอนี้แล้ว", "ensurePlanLimit": "โปรดตรวจสอบให้แน่ใจว่าไม่เกินขีดจำกัดของแผนพื้นที่ทำงาน หากเกินขีดจำกัดแล้ว ให้พิจารณา แผนพื้นที่ทำงานหรือ ", "requestToJoin": "ขอเข้าร่วม", "asMember": "ในฐานะสมาชิก" }, "upgradePlanModal": { "title": "อัพเกรดเป็น Pro", "message": "{name} ได้ถึงขีดจำกัดของสมาชิกฟรีแล้ว อัปเกรดเป็นแผน Pro เพื่อเชิญสมาชิกเพิ่ม", "upgradeSteps": "วิธีอัพเกรดแผนของคุณบน AppFlowy:", "step1": "1. ไปที่การตั้งค่า", "step2": "2. คลิกที่ 'แผน'", "step3": "3. เลือก 'เปลี่ยนแผน'", "appNote": "บันทึก: ", "actionButton": "อัพเกรด", "downloadLink": "ดาวน์โหลดแอป", "laterButton": "ภายหลัง", "refreshNote": "หลังจากอัปเกรดสำเร็จแล้ว คลิก เพื่อเปิดใช้งานฟีเจอร์ใหม่ของคุณ", "refresh": "ที่นี่" }, "breadcrumbs": { "label": "เส้นทางนำทาง" }, "time": { "justNow": "เมื่อสักครู่", "seconds": { "one": "1 วินาที", "other": "{count} วินาที" }, "minutes": { "one": "1 นาที", "other": "{count} นาที" }, "hours": { "one": "1 ชั่วโมง", "other": "{count} ชั่วโมง" }, "days": { "one": "1 วัน", "other": "{จำนวน} วัน" }, "weeks": { "one": "1 สัปดาห์", "other": "{count} สัปดาห์" }, "months": { "one": "1 เดือน", "other": "{count} เดือน" }, "years": { "one": "1 ปี", "other": "{count} ปี" }, "ago": "ที่ผ่านมา", "yesterday": "เมื่อวาน", "today": "วันนี้" }, "members": { "zero": "ไม่มีสมาชิก", "one": "1 สมาชิก", "many": "{count} สมาชิก", "other": "{count} สมาชิก" }, "tabMenu": { "close": "ปิด", "closeOthers": "ปิดแท็บอื่น ๆ", "favorite": "รายการโปรด", "unfavorite": "ยกเลิกรายการโปรด", "favoriteDisabledHint": "ไม่สามารถเพิ่มมุมมองนี้เป็นรายการโปรดได้", "pinTab": "ปักหมุด", "unpinTab": "ยกเลิกการปักหมุด" } } ================================================ FILE: frontend/resources/translations/tr-TR.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Kullanıcı", "welcomeText": "@:appName'ye Hoş Geldiniz", "welcomeTo": "Hoş Geldiniz", "githubStarText": "GitHub'da Yıldız Ver", "subscribeNewsletterText": "Bültenimize Abone Ol", "letsGoButtonText": "Hemen Başla", "title": "Başlık", "youCanAlso": "Ayrıca", "and": "ve", "failedToOpenUrl": "URL açılamadı: {}", "blockActions": { "addBelowTooltip": "Altına eklemek için tıklayın", "addAboveCmd": "Alt+tıklama", "addAboveMacCmd": "Option+tıklama", "addAboveTooltip": "Üstüne eklemek için", "dragTooltip": "Sürükleyerek taşıyın", "openMenuTooltip": "Menüyü aç" }, "signUp": { "buttonText": "Kayıt Ol", "title": "@:appName'e Kayıt Ol", "getStartedText": "Başlayın", "emptyPasswordError": "Parola boş olamaz", "repeatPasswordEmptyError": "Parola tekrarı alanı boş olamaz", "unmatchedPasswordError": "Parola tekrarı, parola ile aynı değil", "alreadyHaveAnAccount": "Hesabınız zaten var mı?", "emailHint": "E-posta adresi", "passwordHint": "Parola", "repeatPasswordHint": "Parolayı tekrarla", "signUpWith": "Kayıt ol:" }, "signIn": { "loginTitle": "@:appName'e Oturum Aç", "loginButtonText": "Oturum Aç", "loginStartWithAnonymous": "Anonim oturumla başla", "continueAnonymousUser": "Anonim oturumla devam et", "buttonText": "Oturum Aç", "signingInText": "Oturum açılıyor...", "forgotPassword": "Parolamı Unuttum?", "emailHint": "E-posta adresi", "passwordHint": "Parola", "dontHaveAnAccount": "Hesabınız yok mu?", "createAccount": "Hesap oluştur", "repeatPasswordEmptyError": "Parola tekrarı alanı boş bırakılamaz", "unmatchedPasswordError": "Parola tekrarı parolayla eşleşmiyor", "syncPromptMessage": "Verilerin senkronize edilmesi biraz zaman alabilir. Lütfen bu sayfayı kapatmayın", "or": "VEYA", "signInWithGoogle": "Google ile devam et", "signInWithGithub": "GitHub ile devam et", "signInWithDiscord": "Discord ile devam et", "signInWithApple": "Apple ile devam et", "continueAnotherWay": "Başka yöntemle devam et", "signUpWithGoogle": "Google ile kaydol", "signUpWithGithub": "GitHub ile kaydol", "signUpWithDiscord": "Discord ile kaydol", "signInWith": "Devam et:", "signInWithEmail": "E-posta ile devam et", "signInWithMagicLink": "Devam et", "signUpWithMagicLink": "Sihirli Bağlantı ile kaydol", "pleaseInputYourEmail": "Lütfen e-posta adresinizi girin", "settings": "Ayarlar", "magicLinkSent": "Sihirli Bağlantı gönderildi!", "invalidEmail": "Geçerli bir e-posta adresi girin", "alreadyHaveAnAccount": "Zaten hesabınız var mı?", "logIn": "Oturum Aç", "generalError": "Bir hata oluştu. Lütfen daha sonra tekrar deneyin.", "limitRateError": "Güvenlik önlemi olarak, sihirli bağlantı talepleri 60 saniyede bir ile sınırlandırılmıştır.", "magicLinkSentDescription": "E-posta adresinize sihirli bir bağlantı gönderdik. Giriş yapmak için bu bağlantıya tıklayın. Bağlantı 5 dakika içinde geçersiz hale gelecektir.", "anonymous": "Anonim" }, "workspace": { "chooseWorkspace": "Çalışma alanınızı seçin", "defaultName": "Çalışma Alanım", "create": "Çalışma alanı oluştur", "new": "Yeni çalışma alanı", "importFromNotion": "Notion'dan içe aktar", "learnMore": "Daha fazla bilgi", "reset": "Çalışma alanını sıfırla", "renameWorkspace": "Çalışma alanını yeniden adlandır", "workspaceNameCannotBeEmpty": "Çalışma alanı adı boş olamaz", "resetWorkspacePrompt": "Çalışma alanını sıfırlamak tüm sayfaları ve verileri silecektir. Çalışma alanını sıfırlamak istediğinizden emin misiniz? Geri yüklemek için destek ekibine ulaşabilirsiniz.", "hint": "çalışma alanı", "notFoundError": "Çalışma alanı bulunamadı", "failedToLoad": "Bir şeyler yanlış gitti! Çalışma alanı yüklenemedi. @:appName'in açık olan tüm örneklerini kapatıp tekrar deneyin.", "errorActions": { "reportIssue": "Hata bildir", "reportIssueOnGithub": "GitHub'da hata bildir", "exportLogFiles": "Günlük dosyalarını dışa aktar", "reachOut": "Discord'da iletişime geç" }, "menuTitle": "Çalışma Alanları", "deleteWorkspaceHintText": "Çalışma alanını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve yayınladığınız tüm sayfaların yayını kaldırılacaktır.", "createSuccess": "Çalışma alanı başarıyla oluşturuldu", "createFailed": "Çalışma alanı oluşturulamadı", "createLimitExceeded": "Hesabınız için izin verilen maksimum çalışma alanı limitine ulaştınız. Çalışmanıza devam etmek için ek çalışma alanlarına ihtiyacınız varsa, lütfen GitHub'da talepte bulunun", "deleteSuccess": "Çalışma alanı başarıyla silindi", "deleteFailed": "Çalışma alanı silinemedi", "openSuccess": "Çalışma alanı başarıyla açıldı", "openFailed": "Çalışma alanı açılamadı", "renameSuccess": "Çalışma alanı başarıyla yeniden adlandırıldı", "renameFailed": "Çalışma alanı yeniden adlandırılamadı", "updateIconSuccess": "Çalışma alanı simgesi başarıyla güncellendi", "updateIconFailed": "Çalışma alanı simgesi güncellenemedi", "cannotDeleteTheOnlyWorkspace": "Tek çalışma alanı silinemez", "fetchWorkspacesFailed": "Çalışma alanları getirilemedi", "leaveCurrentWorkspace": "Çalışma alanından ayrıl", "leaveCurrentWorkspacePrompt": "Mevcut çalışma alanından ayrılmak istediğinizden emin misiniz?" }, "shareAction": { "buttonText": "Paylaş", "workInProgress": "Yakında", "markdown": "Markdown", "html": "HTML", "clipboard": "Panoya kopyala", "csv": "CSV", "copyLink": "Bağlantıyı kopyala", "publishToTheWeb": "Web'de Yayınla", "publishToTheWebHint": "AppFlowy ile bir web sitesi oluşturun", "publish": "Yayınla", "unPublish": "Yayından kaldır", "visitSite": "Siteyi ziyaret et", "exportAsTab": "Farklı dışa aktar", "publishTab": "Yayınla", "shareTab": "Paylaş", "publishOnAppFlowy": "AppFlowy'de Yayınla", "shareTabTitle": "İşbirliği için davet et", "shareTabDescription": "Herhangi biriyle kolay işbirliği için", "copyLinkSuccess": "Bağlantı panoya kopyalandı", "copyShareLink": "Paylaşım bağlantısını kopyala", "copyLinkFailed": "Bağlantı panoya kopyalanamadı", "copyLinkToBlockSuccess": "Blok bağlantısı panoya kopyalandı", "copyLinkToBlockFailed": "Blok bağlantısı panoya kopyalanamadı", "manageAllSites": "Tüm siteleri yönet", "updatePathName": "Yol adını güncelle" }, "moreAction": { "small": "küçük", "medium": "orta", "large": "büyük", "fontSize": "Yazı tipi boyutu", "import": "İçe aktar", "moreOptions": "Daha fazla seçenek", "wordCount": "Kelime sayısı: {}", "charCount": "Karakter sayısı: {}", "createdAt": "Oluşturulma: {}", "deleteView": "Sil", "duplicateView": "Çoğalt", "wordCountLabel": "Kelime sayısı: ", "charCountLabel": "Karakter sayısı: ", "createdAtLabel": "Oluşturulma: ", "syncedAtLabel": "Senkronize edilme: ", "saveAsNewPage": "Mesajları sayfaya ekle" }, "importPanel": { "textAndMarkdown": "Metin ve Markdown", "documentFromV010": "v0.1.0'dan belge", "databaseFromV010": "v0.1.0'dan veritabanı", "notionZip": "Notion Dışa Aktarılmış Zip Dosyası", "csv": "CSV", "database": "Veritabanı" }, "disclosureAction": { "rename": "Yeniden adlandır", "delete": "Sil", "duplicate": "Çoğalt", "unfavorite": "Favorilerden kaldır", "favorite": "Favorilere ekle", "openNewTab": "Yeni sekmede aç", "moveTo": "Şuraya taşı", "addToFavorites": "Favorilere ekle", "copyLink": "Bağlantıyı kopyala", "changeIcon": "Simgeyi değiştir", "collapseAllPages": "Tüm alt sayfaları daralt", "movePageTo": "Sayfayı şuraya taşı", "move": "Taşı" }, "blankPageTitle": "Boş sayfa", "newPageText": "Yeni sayfa", "newDocumentText": "Yeni belge", "newGridText": "Yeni ızgara", "newCalendarText": "Yeni takvim", "newBoardText": "Yeni pano", "chat": { "newChat": "Yapay Zeka Sohbeti", "inputMessageHint": "@:appName Yapay Zekasına sorun", "inputLocalAIMessageHint": "@:appName Yerel Yapay Zekasına sorun", "unsupportedCloudPrompt": "Bu özellik yalnızca @:appName Cloud kullanırken kullanılabilir", "relatedQuestion": "Önerilen", "serverUnavailable": "Bağlantı kesildi. Lütfen internet bağlantınızı kontrol edin ve", "aiServerUnavailable": "Yapay zeka hizmeti geçici olarak kullanılamıyor. Lütfen daha sonra tekrar deneyin.", "retry": "Tekrar dene", "clickToRetry": "Tekrar denemek için tıklayın", "regenerateAnswer": "Yeniden oluştur", "question1": "Görevleri yönetmek için Kanban nasıl kullanılır", "question2": "GTD yöntemini açıkla", "question3": "Neden Rust kullanmalı", "question4": "Mutfağımdaki malzemelerle tarif", "question5": "Sayfam için bir illüstrasyon oluştur", "question6": "Önümüzdeki hafta için yapılacaklar listesi hazırla", "aiMistakePrompt": "Yapay zeka hata yapabilir. Önemli bilgileri kontrol edin.", "chatWithFilePrompt": "Dosya ile sohbet etmek ister misiniz?", "indexFileSuccess": "Dosya başarıyla indekslendi", "inputActionNoPages": "Sayfa sonucu yok", "referenceSource": { "zero": "0 kaynak bulundu", "one": "{count} kaynak bulundu", "other": "{count} kaynak bulundu" }, "clickToMention": "Bir sayfadan bahset", "uploadFile": "PDF, metin veya markdown dosyaları ekle", "questionDetail": "Merhaba {}! Size bugün nasıl yardımcı olabilirim?", "indexingFile": "{} indeksleniyor", "generatingResponse": "Yanıt oluşturuluyor", "selectSources": "Kaynakları Seç", "sourcesLimitReached": "En fazla 3 üst düzey belge ve alt öğelerini seçebilirsiniz", "sourceUnsupported": "Şu anda veritabanlarıyla sohbet etmeyi desteklemiyoruz", "regenerate": "Tekrar dene", "addToPageButton": "Mesajı sayfaya ekle", "addToPageTitle": "Mesajı şuraya ekle...", "addToNewPage": "Yeni sayfa oluştur", "addToNewPageName": "\"{}\" kaynağından çıkarılan mesajlar", "addToNewPageSuccessToast": "Mesaj şuraya eklendi:", "openPagePreviewFailedToast": "Sayfa açılamadı", "changeFormat": { "actionButton": "Biçimi değiştir", "confirmButton": "Bu biçimle yeniden oluştur", "textOnly": "Metin", "imageOnly": "Sadece görsel", "textAndImage": "Metin ve Görsel", "text": "Paragraf", "bullet": "Madde işaretli liste", "number": "Numaralı liste", "table": "Tablo", "blankDescription": "Yanıt biçimi", "defaultDescription": "Otomatik mod", "textWithImageDescription": "@:chat.changeFormat.text ve görsel", "numberWithImageDescription": "@:chat.changeFormat.number ve görsel", "bulletWithImageDescription": "@:chat.changeFormat.bullet ve görsel", "tableWithImageDescription": "@:chat.changeFormat.table ve görsel" }, "selectBanner": { "saveButton": "Şuraya ekle …", "selectMessages": "Mesajları seç", "nSelected": "{} seçildi", "allSelected": "Tümü seçildi" } }, "trash": { "text": "Çöp Kutusu", "restoreAll": "Tümünü Geri Yükle", "restore": "Geri Yükle", "deleteAll": "Tümünü Sil", "pageHeader": { "fileName": "Dosya adı", "lastModified": "Son Değiştirilme", "created": "Oluşturulma" }, "confirmDeleteAll": { "title": "Çöp kutusundaki tüm sayfalar", "caption": "Çöp kutusundaki her şeyi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." }, "confirmRestoreAll": { "title": "Çöp kutusundaki tüm sayfaları geri yükle", "caption": "Bu işlem geri alınamaz." }, "restorePage": { "title": "Geri Yükle: {}", "caption": "Bu sayfayı geri yüklemek istediğinizden emin misiniz?" }, "mobile": { "actions": "Çöp Kutusu İşlemleri", "empty": "Çöp kutusunda sayfa veya alan yok", "emptyDescription": "İhtiyacınız olmayan şeyleri Çöp Kutusuna taşıyın.", "isDeleted": "silindi", "isRestored": "geri yüklendi" }, "confirmDeleteTitle": "Bu sayfayı kalıcı olarak silmek istediğinizden emin misiniz?" }, "deletePagePrompt": { "text": "Bu sayfa Çöp Kutusunda", "restore": "Sayfayı geri yükle", "deletePermanent": "Kalıcı olarak sil", "deletePermanentDescription": "Bu sayfayı kalıcı olarak silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." }, "dialogCreatePageNameHint": "Sayfa adı", "questionBubble": { "shortcuts": "Kısayollar", "whatsNew": "Yenilikler", "markdown": "Markdown", "debug": { "name": "Hata Ayıklama Bilgisi", "success": "Hata ayıklama bilgisi panoya kopyalandı!", "fail": "Hata ayıklama bilgisi panoya kopyalanamadı" }, "feedback": "Geri Bildirim", "help": "Yardım ve Destek" }, "menuAppHeader": { "moreButtonToolTip": "Kaldır, yeniden adlandır ve daha fazlası...", "addPageTooltip": "Hızlıca içeri sayfa ekle", "defaultNewPageName": "Başlıksız", "renameDialog": "Yeniden adlandır", "pageNameSuffix": "Kopya" }, "noPagesInside": "İçeride sayfa yok", "toolbar": { "undo": "Geri al", "redo": "Yinele", "bold": "Kalın", "italic": "İtalik", "underline": "Altı çizili", "strike": "Üstü çizili", "numList": "Numaralı liste", "bulletList": "Madde işaretli liste", "checkList": "Kontrol Listesi", "inlineCode": "Satır İçi Kod", "quote": "Alıntı Bloğu", "header": "Başlık", "highlight": "Vurgula", "color": "Renk", "addLink": "Bağlantı Ekle", "link": "Bağlantı" }, "tooltip": { "lightMode": "Aydınlık moda geç", "darkMode": "Karanlık moda geç", "openAsPage": "Sayfa olarak aç", "addNewRow": "Yeni satır ekle", "openMenu": "Menüyü açmak için tıklayın", "dragRow": "Satırı yeniden sıralamak için sürükleyin", "viewDataBase": "Veritabanını görüntüle", "referencePage": "Bu {name} referans alındı", "addBlockBelow": "Alta blok ekle", "aiGenerate": "Oluştur" }, "sideBar": { "closeSidebar": "Kenar çubuğunu kapat", "openSidebar": "Kenar çubuğunu aç", "expandSidebar": "Tam sayfa olarak genişlet", "personal": "Kişisel", "private": "Özel", "workspace": "Çalışma Alanı", "favorites": "Favoriler", "clickToHidePrivate": "Özel alanı gizlemek için tıklayın\nBurada oluşturduğunuz sayfalar yalnızca size görünür", "clickToHideWorkspace": "Çalışma alanını gizlemek için tıklayın\nBurada oluşturduğunuz sayfalar tüm üyelere görünür", "clickToHidePersonal": "Kişisel alanı gizlemek için tıklayın", "clickToHideFavorites": "Favoriler alanını gizlemek için tıklayın", "addAPage": "Yeni sayfa ekle", "addAPageToPrivate": "Özel alana sayfa ekle", "addAPageToWorkspace": "Çalışma alanına sayfa ekle", "recent": "Son", "today": "Bugün", "thisWeek": "Bu hafta", "others": "Önceki favoriler", "earlier": "Daha önce", "justNow": "az önce", "minutesAgo": "{count} dakika önce", "lastViewed": "Son görüntüleme", "favoriteAt": "Favorilere eklendi", "emptyRecent": "Son Sayfa Yok", "emptyRecentDescription": "Sayfaları görüntüledikçe, kolay erişim için burada listelenecekler.", "emptyFavorite": "Favori Sayfa Yok", "emptyFavoriteDescription": "Sayfaları favori olarak işaretleyin—hızlı erişim için burada listelenecekler!", "removePageFromRecent": "Bu sayfayı Son'dan kaldır?", "removeSuccess": "Başarıyla kaldırıldı", "favoriteSpace": "Favoriler", "RecentSpace": "Son", "Spaces": "Alanlar", "upgradeToPro": "Pro'ya yükselt", "upgradeToAIMax": "Sınırsız yapay zekayı aç", "storageLimitDialogTitle": "Ücretsiz depolama alanınız bitti. Sınırsız depolama için yükseltin", "storageLimitDialogTitleIOS": "Ücretsiz depolama alanınız bitti.", "aiResponseLimitTitle": "Ücretsiz yapay zeka yanıtlarınız bitti. Sınırsız yanıt için Pro Plana yükseltin veya bir yapay zeka eklentisi satın alın", "aiResponseLimitDialogTitle": "Yapay zeka yanıt limitine ulaşıldı", "aiResponseLimit": "Ücretsiz yapay zeka yanıtlarınız bitti.\n\nDaha fazla yapay zeka yanıtı almak için Ayarlar -> Plan -> AI Max veya Pro Plan'a tıklayın", "askOwnerToUpgradeToPro": "Çalışma alanınızın ücretsiz depolama alanı bitiyor. Lütfen çalışma alanı sahibinden Pro Plana yükseltmesini isteyin", "askOwnerToUpgradeToProIOS": "Çalışma alanınızın ücretsiz depolama alanı bitiyor.", "askOwnerToUpgradeToAIMax": "Çalışma alanınızın ücretsiz yapay zeka yanıtları bitti. Lütfen çalışma alanı sahibinden planı yükseltmesini veya yapay zeka eklentileri satın almasını isteyin", "askOwnerToUpgradeToAIMaxIOS": "Çalışma alanınızın ücretsiz yapay zeka yanıtları bitiyor.", "purchaseAIMax": "Çalışma alanınızın yapay zeka görsel yanıtları bitti. Lütfen çalışma alanı sahibinden AI Max satın almasını isteyin", "aiImageResponseLimit": "Yapay zeka görsel yanıtlarınız bitti.\n\nDaha fazla yapay zeka görsel yanıtı almak için Ayarlar -> Plan -> AI Max'a tıklayın", "purchaseStorageSpace": "Depolama Alanı Satın Al", "singleFileProPlanLimitationDescription": "Ücretsiz planda izin verilen maksimum dosya yükleme boyutunu aştınız. Daha büyük dosyalar yüklemek için lütfen Pro Plana yükseltin", "purchaseAIResponse": "Satın Al ", "askOwnerToUpgradeToLocalAI": "Çalışma alanı sahibinden Cihaz Üzerinde Yapay Zekayı etkinleştirmesini isteyin", "upgradeToAILocal": "En üst düzey gizlilik için yerel modelleri cihazınızda çalıştırın", "upgradeToAILocalDesc": "Yerel yapay zeka kullanarak PDF'lerle sohbet edin, yazılarınızı geliştirin ve tabloları otomatik doldurun" }, "notifications": { "export": { "markdown": "Not Markdown Olarak Dışa Aktarıldı", "path": "Documents/flowy" } }, "contactsPage": { "title": "Kişiler", "whatsHappening": "Bu hafta neler oluyor?", "addContact": "Kişi Ekle", "editContact": "Kişiyi Düzenle" }, "button": { "ok": "Tamam", "confirm": "Onayla", "done": "Bitti", "cancel": "İptal", "signIn": "Giriş Yap", "signOut": "Çıkış Yap", "complete": "Tamamla", "save": "Kaydet", "generate": "Oluştur", "esc": "ESC", "keep": "Sakla", "tryAgain": "Tekrar dene", "discard": "Vazgeç", "replace": "Değiştir", "insertBelow": "Alta ekle", "insertAbove": "Üste ekle", "upload": "Yükle", "edit": "Düzenle", "delete": "Sil", "copy": "Kopyala", "duplicate": "Çoğalt", "putback": "Geri Koy", "update": "Güncelle", "share": "Paylaş", "removeFromFavorites": "Favorilerden kaldır", "removeFromRecent": "Son'dan kaldır", "addToFavorites": "Favorilere ekle", "favoriteSuccessfully": "Favorilere eklendi", "unfavoriteSuccessfully": "Favorilerden kaldırıldı", "duplicateSuccessfully": "Başarıyla çoğaltıldı", "rename": "Yeniden adlandır", "helpCenter": "Yardım Merkezi", "add": "Ekle", "yes": "Evet", "no": "Hayır", "clear": "Temizle", "remove": "Kaldır", "dontRemove": "Kaldırma", "copyLink": "Bağlantıyı Kopyala", "align": "Hizala", "login": "Giriş yap", "logout": "Çıkış yap", "deleteAccount": "Hesabı sil", "back": "Geri", "signInGoogle": "Google ile devam et", "signInGithub": "GitHub ile devam et", "signInDiscord": "Discord ile devam et", "more": "Daha fazla", "create": "Oluştur", "close": "Kapat", "next": "İleri", "previous": "Geri", "submit": "Gönder", "download": "İndir", "backToHome": "Ana Sayfaya Dön", "viewing": "Görüntüleme", "editing": "Düzenleme", "gotIt": "Anladım", "retry": "Tekrar dene", "uploadFailed": "Yükleme başarısız.", "copyLinkOriginal": "Orijinal bağlantıyı kopyala" }, "label": { "welcome": "Hoş Geldiniz!", "firstName": "Ad", "middleName": "İkinci Ad", "lastName": "Soyad", "stepX": "Adım {X}" }, "oAuth": { "err": { "failedTitle": "Hesabınıza bağlanılamıyor.", "failedMsg": "Lütfen tarayıcınızda oturum açma işlemini tamamladığınızdan emin olun." }, "google": { "title": "GOOGLE İLE GİRİŞ", "instruction1": "Google Kişilerinizi içe aktarmak için, bu uygulamaya web tarayıcınızı kullanarak yetki vermeniz gerekecek.", "instruction2": "Simgeye tıklayarak veya metni seçerek bu kodu panonuza kopyalayın:", "instruction3": "Web tarayıcınızda aşağıdaki bağlantıya gidin ve yukarıdaki kodu girin:", "instruction4": "Kaydı tamamladığınızda aşağıdaki düğmeye basın:" } }, "settings": { "title": "Ayarlar", "popupMenuItem": { "settings": "Ayarlar", "members": "Üyeler", "trash": "Çöp Kutusu", "helpAndSupport": "Yardım ve Destek" }, "sites": { "title": "Siteler", "namespaceTitle": "Alan Adı", "namespaceDescription": "Alan adınızı ve ana sayfanızı yönetin", "namespaceHeader": "Alan Adı", "homepageHeader": "Ana Sayfa", "updateNamespace": "Alan adını güncelle", "removeHomepage": "Ana sayfayı kaldır", "selectHomePage": "Bir sayfa seç", "clearHomePage": "Bu alan adı için ana sayfayı temizle", "customUrl": "Özel URL", "namespace": { "description": "Bu değişiklik, bu alan adında yayınlanan tüm canlı sayfalara uygulanacak", "tooltip": "Uygunsuz alan adlarını kaldırma hakkını saklı tutarız", "updateExistingNamespace": "Mevcut alan adını güncelle", "upgradeToPro": "Ana sayfa ayarlamak için Pro Plana yükseltin", "redirectToPayment": "Ödeme sayfasına yönlendiriliyor...", "onlyWorkspaceOwnerCanSetHomePage": "Yalnızca çalışma alanı sahibi ana sayfa ayarlayabilir", "pleaseAskOwnerToSetHomePage": "Lütfen çalışma alanı sahibinden Pro Plana yükseltmesini isteyin" }, "publishedPage": { "title": "Tüm yayınlanan sayfalar", "description": "Yayınlanan sayfalarınızı yönetin", "page": "Sayfa", "pathName": "Yol adı", "date": "Yayınlanma tarihi", "emptyHinText": "Bu çalışma alanında yayınlanmış sayfanız yok", "noPublishedPages": "Yayınlanmış sayfa yok", "settings": "Yayın ayarları", "clickToOpenPageInApp": "Sayfayı uygulamada aç", "clickToOpenPageInBrowser": "Sayfayı tarayıcıda aç" }, "error": { "failedToGeneratePaymentLink": "Pro Plan için ödeme bağlantısı oluşturulamadı", "failedToUpdateNamespace": "Alan adı güncellenemedi", "proPlanLimitation": "Alan adını güncellemek için Pro Plana yükseltmeniz gerekiyor", "namespaceAlreadyInUse": "Bu alan adı zaten alınmış, lütfen başka bir tane deneyin", "invalidNamespace": "Geçersiz alan adı, lütfen başka bir tane deneyin", "namespaceLengthAtLeast2Characters": "Alan adı en az 2 karakter uzunluğunda olmalıdır", "onlyWorkspaceOwnerCanUpdateNamespace": "Alan adını yalnızca çalışma alanı sahibi güncelleyebilir", "onlyWorkspaceOwnerCanRemoveHomepage": "Ana sayfayı yalnızca çalışma alanı sahibi kaldırabilir", "setHomepageFailed": "Ana sayfa ayarlanamadı", "namespaceTooLong": "Alan adı çok uzun, lütfen başka bir tane deneyin", "namespaceTooShort": "Alan adı çok kısa, lütfen başka bir tane deneyin", "namespaceIsReserved": "Bu alan adı rezerve edilmiş, lütfen başka bir tane deneyin", "updatePathNameFailed": "Yol adı güncellenemedi", "removeHomePageFailed": "Ana sayfa kaldırılamadı", "publishNameContainsInvalidCharacters": "Yol adı geçersiz karakter(ler) içeriyor, lütfen başka bir tane deneyin", "publishNameTooShort": "Yol adı çok kısa, lütfen başka bir tane deneyin", "publishNameTooLong": "Yol adı çok uzun, lütfen başka bir tane deneyin", "publishNameAlreadyInUse": "Bu yol adı zaten kullanımda, lütfen başka bir tane deneyin", "namespaceContainsInvalidCharacters": "Alan adı geçersiz karakter(ler) içeriyor, lütfen başka bir tane deneyin", "publishPermissionDenied": "Yayın ayarlarını yalnızca çalışma alanı sahibi veya sayfa yayıncısı yönetebilir", "publishNameCannotBeEmpty": "Yol adı boş olamaz, lütfen başka bir tane deneyin" }, "success": { "namespaceUpdated": "Alan adı başarıyla güncellendi", "setHomepageSuccess": "Ana sayfa başarıyla ayarlandı", "updatePathNameSuccess": "Yol adı başarıyla güncellendi", "removeHomePageSuccess": "Ana sayfa başarıyla kaldırıldı" } }, "accountPage": { "menuLabel": "Hesabım", "title": "Hesabım", "general": { "title": "Hesap adı ve profil resmi", "changeProfilePicture": "Profil resmini değiştir" }, "email": { "title": "E-posta", "actions": { "change": "E-postayı değiştir" } }, "login": { "title": "Hesap girişi", "loginLabel": "Giriş yap", "logoutLabel": "Çıkış yap" } }, "workspacePage": { "menuLabel": "Çalışma Alanı", "title": "Çalışma Alanı", "description": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih/saat biçimini ve dilini özelleştirin.", "workspaceName": { "title": "Çalışma alanı adı" }, "workspaceIcon": { "title": "Çalışma alanı simgesi", "description": "Çalışma alanınız için bir resim yükleyin veya emoji kullanın. Simge, kenar çubuğunuzda ve bildirimlerinizde görünecektir." }, "appearance": { "title": "Görünüm", "description": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih, saat ve dilini özelleştirin.", "options": { "system": "Otomatik", "light": "Aydınlık", "dark": "Karanlık" } }, "resetCursorColor": { "title": "Belge imleç rengini sıfırla", "description": "İmleç rengini sıfırlamak istediğinizden emin misiniz?" }, "resetSelectionColor": { "title": "Belge seçim rengini sıfırla", "description": "Seçim rengini sıfırlamak istediğinizden emin misiniz?" }, "resetWidth": { "resetSuccess": "Belge genişliği başarıyla sıfırlandı" }, "theme": { "title": "Tema", "description": "Önceden ayarlanmış bir tema seçin veya kendi özel temanızı yükleyin.", "uploadCustomThemeTooltip": "Özel tema yükle" }, "workspaceFont": { "title": "Çalışma alanı yazı tipi", "noFontHint": "Yazı tipi bulunamadı, başka bir terim deneyin." }, "textDirection": { "title": "Metin yönü", "leftToRight": "Soldan sağa", "rightToLeft": "Sağdan sola", "auto": "Otomatik", "enableRTLItems": "RTL araç çubuğu öğelerini etkinleştir" }, "layoutDirection": { "title": "Düzen yönü", "leftToRight": "Soldan sağa", "rightToLeft": "Sağdan sola" }, "dateTime": { "title": "Tarih ve saat", "example": "{} {} ({})", "24HourTime": "24 saat biçimi", "dateFormat": { "label": "Tarih biçimi", "local": "Yerel", "us": "ABD", "iso": "ISO", "friendly": "Kullanıcı dostu", "dmy": "G/A/Y" } }, "language": { "title": "Dil" }, "deleteWorkspacePrompt": { "title": "Çalışma alanını sil", "content": "Bu çalışma alanını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve yayınladığınız tüm sayfaların yayını kaldırılacaktır." }, "leaveWorkspacePrompt": { "title": "Çalışma alanından ayrıl", "content": "Bu çalışma alanından ayrılmak istediğinizden emin misiniz? İçindeki tüm sayfalara ve verilere erişiminizi kaybedeceksiniz.", "success": "Çalışma alanından başarıyla ayrıldınız.", "fail": "Çalışma alanından ayrılınamadı." }, "manageWorkspace": { "title": "Çalışma alanını yönet", "leaveWorkspace": "Çalışma alanından ayrıl", "deleteWorkspace": "Çalışma alanını sil" } }, "manageDataPage": { "menuLabel": "Verileri yönet", "title": "Verileri yönet", "description": "Yerel depolama verilerini yönetin veya mevcut verilerinizi @:appName'e aktarın.", "dataStorage": { "title": "Dosya depolama konumu", "tooltip": "Dosyalarınızın depolandığı konum", "actions": { "change": "Yolu değiştir", "open": "Klasörü aç", "openTooltip": "Mevcut veri klasörü konumunu aç", "copy": "Yolu kopyala", "copiedHint": "Yol kopyalandı!", "resetTooltip": "Varsayılan konuma sıfırla" }, "resetDialog": { "title": "Emin misiniz?", "description": "Yolu varsayılan veri konumuna sıfırlamak verilerinizi silmeyecektir. Mevcut verilerinizi yeniden içe aktarmak istiyorsanız, önce mevcut konumunuzun yolunu kopyalamalısınız." } }, "importData": { "title": "Veri içe aktar", "tooltip": "@:appName yedeklerinden/veri klasörlerinden veri içe aktar", "description": "Harici bir @:appName veri klasöründen veri kopyala", "action": "Dosyaya göz at" }, "encryption": { "title": "Şifreleme", "tooltip": "Verilerinizin nasıl depolandığını ve şifrelendiğini yönetin", "descriptionNoEncryption": "Şifrelemeyi açmak tüm verileri şifreleyecektir. Bu işlem geri alınamaz.", "descriptionEncrypted": "Verileriniz şifrelenmiş.", "action": "Verileri şifrele", "dialog": { "title": "Tüm verileriniz şifrelensin mi?", "description": "Tüm verilerinizi şifrelemek, verilerinizi güvenli ve emniyetli tutacaktır. Bu işlem GERİ ALINAMAZ. Devam etmek istediğinizden emin misiniz?" } }, "cache": { "title": "Önbelleği temizle", "description": "Görsel yüklenmemesi, bir alanda eksik sayfalar ve yazı tiplerinin yüklenmemesi gibi sorunları çözmeye yardımcı olur. Bu, verilerinizi etkilemeyecektir.", "dialog": { "title": "Önbelleği temizle", "description": "Görsel yüklenmemesi, bir alanda eksik sayfalar ve yazı tiplerinin yüklenmemesi gibi sorunları çözmeye yardımcı olur. Bu, verilerinizi etkilemeyecektir.", "successHint": "Önbellek temizlendi!" } }, "data": { "fixYourData": "Verilerinizi düzeltin", "fixButton": "Düzelt", "fixYourDataDescription": "Verilerinizle ilgili sorunlar yaşıyorsanız, burada düzeltmeyi deneyebilirsiniz." } }, "shortcutsPage": { "menuLabel": "Kısayollar", "title": "Kısayollar", "editBindingHint": "Yeni bağlama girin", "searchHint": "Ara", "actions": { "resetDefault": "Varsayılana sıfırla" }, "errorPage": { "message": "Kısayollar yüklenemedi: {}", "howToFix": "Lütfen tekrar deneyin, sorun devam ederse GitHub üzerinden bize ulaşın." }, "resetDialog": { "title": "Kısayolları sıfırla", "description": "Bu işlem tüm tuş bağlamalarınızı varsayılana sıfırlayacak, daha sonra geri alamazsınız, devam etmek istediğinizden emin misiniz?", "buttonLabel": "Sıfırla" }, "conflictDialog": { "title": "{} şu anda kullanımda", "descriptionPrefix": "Bu tuş bağlaması şu anda ", "descriptionSuffix": " tarafından kullanılıyor. Bu tuş bağlamasını değiştirirseniz, {} üzerinden kaldırılacak.", "confirmLabel": "Devam et" }, "editTooltip": "Tuş bağlamasını düzenlemeye başlamak için basın", "keybindings": { "toggleToDoList": "Yapılacaklar listesini aç/kapat", "insertNewParagraphInCodeblock": "Yeni paragraf ekle", "pasteInCodeblock": "Kod bloğuna yapıştır", "selectAllCodeblock": "Tümünü seç", "indentLineCodeblock": "Satır başına iki boşluk ekle", "outdentLineCodeblock": "Satır başından iki boşluk sil", "twoSpacesCursorCodeblock": "İmleç konumuna iki boşluk ekle", "copy": "Seçimi kopyala", "paste": "İçeriği yapıştır", "cut": "Seçimi kes", "alignLeft": "Metni sola hizala", "alignCenter": "Metni ortala", "alignRight": "Metni sağa hizala", "undo": "Geri al", "redo": "Yinele", "convertToParagraph": "Bloğu paragrafa dönüştür", "backspace": "Sil", "deleteLeftWord": "Sol kelimeyi sil", "deleteLeftSentence": "Sol cümleyi sil", "delete": "Sağdaki karakteri sil", "deleteMacOS": "Soldaki karakteri sil", "deleteRightWord": "Sağdaki kelimeyi sil", "moveCursorLeft": "İmleci sola taşı", "moveCursorBeginning": "İmleci başa taşı", "moveCursorLeftWord": "İmleci bir kelime sola taşı", "moveCursorLeftSelect": "Seç ve imleci sola taşı", "moveCursorBeginSelect": "Seç ve imleci başa taşı", "moveCursorLeftWordSelect": "Seç ve imleci bir kelime sola taşı", "moveCursorRight": "İmleci sağa taşı", "moveCursorEnd": "İmleci sona taşı", "moveCursorRightWord": "İmleci bir kelime sağa taşı", "moveCursorRightSelect": "Seç ve imleci sağa taşı", "moveCursorEndSelect": "Seç ve imleci sona taşı", "moveCursorRightWordSelect": "Seç ve imleci bir kelime sağa taşı", "moveCursorUp": "İmleci yukarı taşı", "moveCursorTopSelect": "Seç ve imleci en üste taşı", "moveCursorTop": "İmleci en üste taşı", "moveCursorUpSelect": "Seç ve imleci yukarı taşı", "moveCursorBottomSelect": "Seç ve imleci en alta taşı", "moveCursorBottom": "İmleci en alta taşı", "moveCursorDown": "İmleci aşağı taşı", "moveCursorDownSelect": "Seç ve imleci aşağı taşı", "home": "En üste kaydır", "end": "En alta kaydır", "toggleBold": "Kalın yazıyı aç/kapat", "toggleItalic": "İtalik yazıyı aç/kapat", "toggleUnderline": "Altı çizili yazıyı aç/kapat", "toggleStrikethrough": "Üstü çizili yazıyı aç/kapat", "toggleCode": "Satır içi kodu aç/kapat", "toggleHighlight": "Vurgulamayı aç/kapat", "showLinkMenu": "Bağlantı menüsünü göster", "openInlineLink": "Satır içi bağlantıyı aç", "openLinks": "Seçili tüm bağlantıları aç", "indent": "Girinti ekle", "outdent": "Girintiyi azalt", "exit": "Düzenlemeden çık", "pageUp": "Bir sayfa yukarı kaydır", "pageDown": "Bir sayfa aşağı kaydır", "selectAll": "Tümünü seç", "pasteWithoutFormatting": "İçeriği biçimlendirme olmadan yapıştır", "showEmojiPicker": "Emoji seçiciyi göster", "enterInTableCell": "Tabloda satır sonu ekle", "leftInTableCell": "Tabloda bir hücre sola git", "rightInTableCell": "Tabloda bir hücre sağa git", "upInTableCell": "Tabloda bir hücre yukarı git", "downInTableCell": "Tabloda bir hücre aşağı git", "tabInTableCell": "Tabloda sonraki kullanılabilir hücreye git", "shiftTabInTableCell": "Tabloda önceki kullanılabilir hücreye git", "backSpaceInTableCell": "Hücrenin başında dur" }, "commands": { "codeBlockNewParagraph": "Kod bloğunun yanına yeni bir paragraf ekle", "codeBlockIndentLines": "Kod bloğunda satır başına iki boşluk ekle", "codeBlockOutdentLines": "Kod bloğunda satır başından iki boşluk sil", "codeBlockAddTwoSpaces": "Kod bloğunda imleç konumuna iki boşluk ekle", "codeBlockSelectAll": "Kod bloğu içindeki tüm içeriği seç", "codeBlockPasteText": "Kod bloğuna metin yapıştır", "textAlignLeft": "Metni sola hizala", "textAlignCenter": "Metni ortala", "textAlignRight": "Metni sağa hizala" }, "couldNotLoadErrorMsg": "Kısayollar yüklenemedi, tekrar deneyin", "couldNotSaveErrorMsg": "Kısayollar kaydedilemedi, tekrar deneyin" }, "aiPage": { "title": "Yapay Zeka Ayarları", "menuLabel": "Yapay Zeka Ayarları", "keys": { "enableAISearchTitle": "Yapay Zeka Arama", "aiSettingsDescription": "AppFlowy Yapay Zeka'yı güçlendirmek için tercih ettiğiniz modeli seçin. Şu anda GPT 4-o, Claude 3,5, Llama 3.1 ve Mistral 7B içerir", "loginToEnableAIFeature": "Yapay Zeka özellikleri yalnızca @:appName Cloud ile giriş yaptıktan sonra etkinleştirilir. Bir @:appName hesabınız yoksa, kaydolmak için 'Hesabım'a gidin", "llmModel": "Dil Modeli", "llmModelType": "Dil Modeli Türü", "downloadLLMPrompt": "{} İndir", "downloadAppFlowyOfflineAI": "Yapay Zeka çevrimdışı paketini indirmek, Yapay Zeka'nın cihazınızda çalışmasını sağlayacak. Devam etmek istiyor musunuz?", "downloadLLMPromptDetail": "{} yerel modelini indirmek {} depolama alanı kullanacak. Devam etmek istiyor musunuz?", "downloadBigFilePrompt": "İndirmenin tamamlanması yaklaşık 10 dakika sürebilir", "downloadAIModelButton": "İndir", "downloadingModel": "İndiriliyor", "localAILoaded": "Yerel Yapay Zeka Modeli başarıyla eklendi ve kullanıma hazır", "localAIStart": "Yerel Yapay Zeka Sohbeti başlatılıyor...", "localAILoading": "Yerel Yapay Zeka Sohbet Modeli yükleniyor...", "localAIStopped": "Yerel Yapay Zeka durduruldu", "failToLoadLocalAI": "Yerel Yapay Zeka başlatılamadı", "restartLocalAI": "Yerel Yapay Zeka'yı Yeniden Başlat", "disableLocalAITitle": "Yerel Yapay Zeka'yı devre dışı bırak", "disableLocalAIDescription": "Yerel Yapay Zeka'yı devre dışı bırakmak istiyor musunuz?", "localAIToggleTitle": "Yerel Yapay Zeka'yı etkinleştirmek veya devre dışı bırakmak için değiştirin", "offlineAIInstruction1": "Çevrimdışı Yapay Zeka'yı etkinleştirmek için", "offlineAIInstruction2": "talimatları", "offlineAIInstruction3": "takip edin.", "offlineAIDownload1": "AppFlowy Yapay Zeka'yı henüz indirmediyseniz, lütfen", "offlineAIDownload2": "indirin", "offlineAIDownload3": "önce", "activeOfflineAI": "Etkin", "downloadOfflineAI": "İndir", "openModelDirectory": "Klasörü aç" } }, "planPage": { "menuLabel": "Plan", "title": "Fiyatlandırma planı", "planUsage": { "title": "Plan kullanım özeti", "storageLabel": "Depolama", "storageUsage": "{} / {} GB", "unlimitedStorageLabel": "Sınırsız depolama", "collaboratorsLabel": "Üyeler", "collaboratorsUsage": "{} / {}", "aiResponseLabel": "Yapay Zeka Yanıtları", "aiResponseUsage": "{} / {}", "unlimitedAILabel": "Sınırsız yanıt", "proBadge": "Pro", "aiMaxBadge": "Yapay Zeka Max", "aiOnDeviceBadge": "Mac için Cihaz Üzerinde Yapay Zeka", "memberProToggle": "Daha fazla üye ve sınırsız Yapay Zeka", "aiMaxToggle": "Sınırsız Yapay Zeka ve gelişmiş modellere erişim", "aiOnDeviceToggle": "Maksimum gizlilik için yerel Yapay Zeka", "aiCredit": { "title": "@:appName Yapay Zeka Kredisi Ekle", "price": "{}", "priceDescription": "1.000 kredi için", "purchase": "Yapay Zeka Satın Al", "info": "Çalışma alanı başına 1.000 Yapay Zeka kredisi ekleyin ve özelleştirilebilir Yapay Zeka'yı iş akışınıza sorunsuz bir şekilde entegre ederek daha akıllı, daha hızlı sonuçlar elde edin:", "infoItemOne": "Veritabanı başına 10.000 yanıt", "infoItemTwo": "Çalışma alanı başına 1.000 yanıt" }, "currentPlan": { "bannerLabel": "Mevcut plan", "freeTitle": "Ücretsiz", "proTitle": "Pro", "teamTitle": "Takım", "freeInfo": "2 üyeye kadar bireyler için her şeyi düzenlemek için mükemmel", "proInfo": "10 üyeye kadar küçük ve orta ölçekli takımlar için mükemmel.", "teamInfo": "Tüm üretken ve iyi organize edilmiş takımlar için mükemmel.", "upgrade": "Planı değiştir", "canceledInfo": "Planınız iptal edildi, {} tarihinde Ücretsiz plana düşürüleceksiniz." }, "addons": { "title": "Eklentiler", "addLabel": "Ekle", "activeLabel": "Eklendi", "aiMax": { "title": "Yapay Zeka Max", "description": "Gelişmiş Yapay Zeka modelleri tarafından desteklenen sınırsız Yapay Zeka yanıtları ve ayda 50 Yapay Zeka görüntüsü", "price": "{}", "priceInfo": "Kullanıcı başına aylık, yıllık faturalandırma" }, "aiOnDevice": { "title": "Mac için Cihaz Üzerinde Yapay Zeka", "description": "Mistral 7B, LLAMA 3 ve daha fazla yerel modeli makinenizde çalıştırın", "price": "{}", "priceInfo": "Kullanıcı başına aylık, yıllık faturalandırma", "recommend": "M1 veya daha yenisi önerilir" } }, "deal": { "bannerLabel": "Yeni yıl fırsatı!", "title": "Takımınızı büyütün!", "info": "Yükseltin ve Pro ve Takım planlarında %10 indirim kazanın! @:appName Yapay Zeka dahil güçlü yeni özelliklerle çalışma alanı verimliliğinizi artırın.", "viewPlans": "Planları görüntüle" } } }, "billingPage": { "menuLabel": "Faturalandırma", "title": "Faturalandırma", "plan": { "title": "Plan", "freeLabel": "Ücretsiz", "proLabel": "Pro", "planButtonLabel": "Planı değiştir", "billingPeriod": "Faturalandırma dönemi", "periodButtonLabel": "Dönemi düzenle" }, "paymentDetails": { "title": "Ödeme detayları", "methodLabel": "Ödeme yöntemi", "methodButtonLabel": "Yöntemi düzenle" }, "addons": { "title": "Eklentiler", "addLabel": "Ekle", "removeLabel": "Kaldır", "renewLabel": "Yenile", "aiMax": { "label": "Yapay Zeka Max", "description": "Sınırsız Yapay Zeka ve gelişmiş modellerin kilidini açın", "activeDescription": "Sonraki fatura tarihi: {}", "canceledDescription": "Yapay Zeka Max {} tarihine kadar kullanılabilir olacak" }, "aiOnDevice": { "label": "Mac için Cihaz Üzerinde Yapay Zeka", "description": "Cihazınızda sınırsız Cihaz Üzerinde Yapay Zeka'nın kilidini açın", "activeDescription": "Sonraki fatura tarihi: {}", "canceledDescription": "Mac için Cihaz Üzerinde Yapay Zeka {} tarihine kadar kullanılabilir olacak" }, "removeDialog": { "title": "{} Kaldır", "description": "{plan} planını kaldırmak istediğinizden emin misiniz? {plan} planının özelliklerine ve avantajlarına erişiminizi hemen kaybedeceksiniz." } }, "currentPeriodBadge": "MEVCUT", "changePeriod": "Dönemi değiştir", "planPeriod": "{} dönemi", "monthlyInterval": "Aylık", "monthlyPriceInfo": "koltuk başına aylık faturalandırma", "annualInterval": "Yıllık", "annualPriceInfo": "koltuk başına yıllık faturalandırma" }, "comparePlanDialog": { "title": "Plan karşılaştır ve seç", "planFeatures": "Plan\nÖzellikleri", "current": "Mevcut", "actions": { "upgrade": "Yükselt", "downgrade": "Düşür", "current": "Mevcut" }, "freePlan": { "title": "Ücretsiz", "description": "2 üyeye kadar bireyler için her şeyi düzenlemek için", "price": "{}", "priceInfo": "Sonsuza kadar ücretsiz" }, "proPlan": { "title": "Pro", "description": "Küçük takımların projeleri ve takım bilgisini yönetmesi için", "price": "{}", "priceInfo": "Kullanıcı başına aylık \nyıllık faturalandırma\n\n{} aylık faturalandırma" }, "planLabels": { "itemOne": "Çalışma Alanları", "itemTwo": "Üyeler", "itemThree": "Depolama", "itemFour": "Gerçek zamanlı işbirliği", "itemFive": "Mobil uygulama", "itemSix": "Yapay Zeka Yanıtları", "itemFileUpload": "Dosya yüklemeleri", "customNamespace": "Özel alan adı", "tooltipSix": "Ömür boyu demek, yanıt sayısının asla sıfırlanmayacağı anlamına gelir", "intelligentSearch": "Akıllı arama", "tooltipSeven": "Çalışma alanınızın URL'sinin bir kısmını özelleştirmenize olanak tanır", "customNamespaceTooltip": "Özel yayınlanmış site URL'si" }, "freeLabels": { "itemOne": "Çalışma alanı başına ücretlendirilir", "itemTwo": "2'ye kadar", "itemThree": "5 GB", "itemFour": "evet", "itemFive": "evet", "itemSix": "10 ömür boyu", "itemFileUpload": "7 MB'a kadar", "intelligentSearch": "Akıllı arama" }, "proLabels": { "itemOne": "Çalışma alanı başına ücretlendirilir", "itemTwo": "10'a kadar", "itemThree": "Sınırsız", "itemFour": "evet", "itemFive": "evet", "itemSix": "Sınırsız", "itemFileUpload": "Sınırsız", "intelligentSearch": "Akıllı arama" }, "paymentSuccess": { "title": "Artık {} planındasınız!", "description": "Ödemeniz başarıyla işleme alındı ve planınız @:appName {}'e yükseltildi. Plan detaylarınızı Plan sayfasında görüntüleyebilirsiniz" }, "downgradeDialog": { "title": "Planınızı düşürmek istediğinizden emin misiniz?", "description": "Planınızı düşürmek sizi Ücretsiz plana geri döndürecek. Üyeler bu çalışma alanına erişimlerini kaybedebilir ve Ücretsiz planın depolama sınırlarına uymak için alan açmanız gerekebilir.", "downgradeLabel": "Planı düşür" } }, "cancelSurveyDialog": { "title": "Gitmenize üzüldük", "description": "Gitmenize üzüldük. @:appName'i geliştirmemize yardımcı olmak için geri bildiriminizi duymak isteriz. Lütfen birkaç soruyu yanıtlamak için zaman ayırın.", "commonOther": "Diğer", "otherHint": "Yanıtınızı buraya yazın", "questionOne": { "question": "@:appName Pro aboneliğinizi iptal etmenize ne sebep oldu?", "answerOne": "Maliyet çok yüksek", "answerTwo": "Özellikler beklentileri karşılamadı", "answerThree": "Daha iyi bir alternatif buldum", "answerFour": "Maliyeti haklı çıkaracak kadar kullanmadım", "answerFive": "Hizmet sorunu veya teknik zorluklar" }, "questionTwo": { "question": "Gelecekte @:appName Pro'ya yeniden abone olma olasılığınız nedir?", "answerOne": "Çok muhtemel", "answerTwo": "Biraz muhtemel", "answerThree": "Emin değilim", "answerFour": "Muhtemel değil", "answerFive": "Hiç muhtemel değil" }, "questionThree": { "question": "Aboneliğiniz sırasında en çok hangi Pro özelliğine değer verdiniz?", "answerOne": "Çoklu kullanıcı işbirliği", "answerTwo": "Daha uzun süreli versiyon geçmişi", "answerThree": "Sınırsız Yapay Zeka yanıtları", "answerFour": "Yerel Yapay Zeka modellerine erişim" }, "questionFour": { "question": "@:appName ile genel deneyiminizi nasıl tanımlarsınız?", "answerOne": "Harika", "answerTwo": "İyi", "answerThree": "Ortalama", "answerFour": "Ortalamanın altında", "answerFive": "Memnun değilim" } }, "common": { "uploadingFile": "Dosya yükleniyor. Lütfen uygulamadan çıkmayın", "uploadNotionSuccess": "Notion zip dosyanız başarıyla yüklendi. İçe aktarma tamamlandığında bir onay e-postası alacaksınız", "reset": "Sıfırla" }, "menu": { "appearance": "Görünüm", "language": "Dil", "user": "Kullanıcı", "files": "Dosyalar", "notifications": "Bildirimler", "open": "Ayarları Aç", "logout": "Çıkış Yap", "logoutPrompt": "Çıkış yapmak istediğinizden emin misiniz?", "selfEncryptionLogoutPrompt": "Çıkış yapmak istediğinizden emin misiniz? Lütfen şifreleme anahtarınızı kopyaladığınızdan emin olun", "syncSetting": "Senkronizasyon Ayarı", "cloudSettings": "Bulut Ayarları", "enableSync": "Senkronizasyonu etkinleştir", "enableSyncLog": "Senkronizasyon günlüğünü etkinleştir", "enableSyncLogWarning": "Senkronizasyon sorunlarını teşhis etmeye yardımcı olduğunuz için teşekkür ederiz. Bu, belge düzenlemelerinizi yerel bir dosyaya kaydedecek. Lütfen etkinleştirdikten sonra uygulamayı kapatıp yeniden açın", "enableEncrypt": "Verileri şifrele", "cloudURL": "Temel URL", "webURL": "Web URL'si", "invalidCloudURLScheme": "Geçersiz Şema", "cloudServerType": "Bulut sunucusu", "cloudServerTypeTip": "Lütfen bulut sunucusunu değiştirdikten sonra mevcut hesabınızdan çıkış yapabileceğinizi unutmayın", "cloudLocal": "Yerel", "cloudAppFlowy": "@:appName Cloud", "cloudAppFlowySelfHost": "@:appName Cloud Kendi Kendine Barındırma", "appFlowyCloudUrlCanNotBeEmpty": "Bulut URL'si boş olamaz", "clickToCopy": "Panoya kopyala", "selfHostStart": "Bir sunucunuz yoksa, lütfen", "selfHostContent": "belgesine", "selfHostEnd": "bakarak kendi sunucunuzu nasıl barındıracağınızı öğrenin", "pleaseInputValidURL": "Lütfen geçerli bir URL girin", "changeUrl": "Kendi kendine barındırılan URL'yi {} olarak değiştir", "cloudURLHint": "Sunucunuzun temel URL'sini girin", "webURLHint": "Web sunucunuzun temel URL'sini girin", "cloudWSURL": "Websocket URL'si", "cloudWSURLHint": "Sunucunuzun websocket adresini girin", "restartApp": "Yeniden Başlat", "restartAppTip": "Değişikliklerin etkili olması için uygulamayı yeniden başlatın. Bu işlemin mevcut hesabınızdan çıkış yapabileceğini unutmayın.", "changeServerTip": "Sunucuyu değiştirdikten sonra, değişikliklerin etkili olması için yeniden başlat düğmesine tıklamalısınız", "enableEncryptPrompt": "Verilerinizi bu anahtar ile güvence altına almak için şifrelemeyi etkinleştirin. Güvenli bir şekilde saklayın; etkinleştirildikten sonra kapatılamaz. Kaybedilirse, verileriniz kurtarılamaz hale gelir. Kopyalamak için tıklayın", "inputEncryptPrompt": "Lütfen şifreleme anahtarlarını girin", "clickToCopySecret": "Anahtarı kopyalamak için tıklayın", "configServerSetting": "Sunucu ayarlarınızı yapılandırın", "configServerGuide": "'Hızlı Başlangıç'ı seçtikten sonra, 'Ayarlar'a ve ardından \"Bulut Ayarları\"na giderek kendi kendine barındırılan sunucunuzu yapılandırın.", "inputTextFieldHint": "Anahtarınız", "historicalUserList": "Kullanıcı giriş geçmişi", "historicalUserListTooltip": "Bu liste anonim hesaplarınızı gösterir. Detaylarını görüntülemek için bir hesaba tıklayabilirsiniz. Anonim hesaplar 'Başla' düğmesine tıklanarak oluşturulur", "openHistoricalUser": "Anonim hesabı açmak için tıklayın", "customPathPrompt": "@:appName veri klasörünü Google Drive gibi bulut senkronizasyonlu bir klasörde saklamak risk oluşturabilir. Bu klasördeki veritabanına aynı anda birden fazla konumdan erişilir veya değiştirilirse, senkronizasyon çakışmaları ve olası veri bozulmaları meydana gelebilir", "importAppFlowyData": "Harici @:appName Klasöründen Veri İçe Aktar", "importingAppFlowyDataTip": "Veri içe aktarma devam ediyor. Lütfen uygulamayı kapatmayın", "importAppFlowyDataDescription": "Harici bir @:appName veri klasöründen veri kopyalayın ve mevcut AppFlowy veri klasörüne aktarın", "importSuccess": "@:appName veri klasörü başarıyla içe aktarıldı", "importFailed": "@:appName veri klasörünün içe aktarılması başarısız oldu", "importGuide": "Daha fazla detay için lütfen referans belgeyi kontrol edin" }, "notifications": { "enableNotifications": { "label": "Bildirimleri etkinleştir", "hint": "Yerel bildirimlerin görünmesini durdurmak için kapatın." }, "showNotificationsIcon": { "label": "Bildirim simgesini göster", "hint": "Kenar çubuğundaki bildirim simgesini gizlemek için kapatın." }, "archiveNotifications": { "allSuccess": "Tüm bildirimler başarıyla arşivlendi", "success": "Bildirim başarıyla arşivlendi" }, "markAsReadNotifications": { "allSuccess": "Tümü okundu olarak işaretlendi", "success": "Okundu olarak işaretlendi" }, "action": { "markAsRead": "Okundu olarak işaretle", "multipleChoice": "Daha fazla seç", "archive": "Arşivle" }, "settings": { "settings": "Ayarlar", "markAllAsRead": "Tümünü okundu olarak işaretle", "archiveAll": "Tümünü arşivle" }, "emptyInbox": { "title": "Gelen Kutusu Boş!", "description": "Burada bildirim almak için hatırlatıcılar ayarlayın." }, "emptyUnread": { "title": "Okunmamış bildirim yok", "description": "Hepsini okudunuz!" }, "emptyArchived": { "title": "Arşivlenmiş öğe yok", "description": "Arşivlenen bildirimler burada görünecek." }, "tabs": { "inbox": "Gelen Kutusu", "unread": "Okunmamış", "archived": "Arşivlenmiş" }, "refreshSuccess": "Bildirimler başarıyla yenilendi", "titles": { "notifications": "Bildirimler", "reminder": "Hatırlatıcı" } }, "appearance": { "resetSetting": "Sıfırla", "fontFamily": { "label": "Yazı Tipi Ailesi", "search": "Ara", "defaultFont": "Sistem" }, "themeMode": { "label": "Tema Modu", "light": "Aydınlık Mod", "dark": "Karanlık Mod", "system": "Sisteme Uyum Sağla" }, "fontScaleFactor": "Yazı Tipi Ölçek Faktörü", "displaySize": "Görüntüleme Boyutu", "documentSettings": { "cursorColor": "Belge imleç rengi", "selectionColor": "Belge seçim rengi", "width": "Belge genişliği", "changeWidth": "Değiştir", "pickColor": "Bir renk seç", "colorShade": "Renk tonu", "opacity": "Opaklık", "hexEmptyError": "Hex renk boş olamaz", "hexLengthError": "Hex renk 6 haneli olmalıdır", "hexInvalidError": "Geçersiz hex renk", "opacityEmptyError": "Opaklık boş olamaz", "opacityRangeError": "Opaklık 1 ile 100 arasında olmalıdır", "app": "Uygulama", "flowy": "Flowy", "apply": "Uygula" }, "layoutDirection": { "label": "Düzen Yönü", "hint": "Ekranınızdaki içeriğin akışını soldan sağa veya sağdan sola olarak kontrol edin.", "ltr": "Soldan Sağa", "rtl": "Sağdan Sola" }, "textDirection": { "label": "Varsayılan Metin Yönü", "hint": "Metnin varsayılan olarak soldan mı yoksa sağdan mı başlayacağını belirtin.", "ltr": "Soldan Sağa", "rtl": "Sağdan Sola", "auto": "OTOMATİK", "fallback": "Düzen yönü ile aynı" }, "themeUpload": { "button": "Yükle", "uploadTheme": "Tema yükle", "description": "Aşağıdaki düğmeyi kullanarak kendi @:appName temanızı yükleyin.", "loading": "Temanızı doğrulayıp yüklerken lütfen bekleyin...", "uploadSuccess": "Temanız başarıyla yüklendi", "deletionFailure": "Tema silinemedi. Manuel olarak silmeyi deneyin.", "filePickerDialogTitle": "Bir .flowy_plugin dosyası seçin", "urlUploadFailure": "URL açılamadı: {}" }, "theme": "Tema", "builtInsLabel": "Yerleşik Temalar", "pluginsLabel": "Eklentiler", "dateFormat": { "label": "Tarih biçimi", "local": "Yerel", "us": "ABD", "iso": "ISO", "friendly": "Kullanıcı dostu", "dmy": "G/A/Y" }, "timeFormat": { "label": "Saat biçimi", "twelveHour": "12 saat", "twentyFourHour": "24 saat" }, "showNamingDialogWhenCreatingPage": "Sayfa oluştururken adlandırma iletişim kutusunu göster", "enableRTLToolbarItems": "Sağdan sola araç çubuğu öğelerini etkinleştir", "members": { "title": "Üye ayarları", "inviteMembers": "Üye davet et", "inviteHint": "E-posta ile davet et", "sendInvite": "Davet gönder", "copyInviteLink": "Davet bağlantısını kopyala", "label": "Üyeler", "user": "Kullanıcı", "role": "Rol", "removeFromWorkspace": "Çalışma Alanından Kaldır", "removeFromWorkspaceSuccess": "Çalışma alanından başarıyla kaldırıldı", "removeFromWorkspaceFailed": "Çalışma alanından kaldırma başarısız oldu", "owner": "Sahip", "guest": "Misafir", "member": "Üye", "memberHintText": "Bir üye sayfaları okuyabilir ve düzenleyebilir", "guestHintText": "Bir Misafir okuyabilir, tepki verebilir, yorum yapabilir ve izin verilen belirli sayfaları düzenleyebilir.", "emailInvalidError": "Geçersiz e-posta, lütfen kontrol edip tekrar deneyin", "emailSent": "E-posta gönderildi, lütfen gelen kutunuzu kontrol edin", "members": "üye", "membersCount": { "zero": "{} üye", "one": "{} üye", "other": "{} üye" }, "inviteFailedDialogTitle": "Davet gönderilemedi", "inviteFailedMemberLimit": "Üye sınırına ulaşıldı, daha fazla üye davet etmek için lütfen yükseltin.", "inviteFailedMemberLimitMobile": "Çalışma alanınız üye sınırına ulaştı.", "memberLimitExceeded": "Üye sınırına ulaşıldı, daha fazla üye davet etmek için lütfen ", "memberLimitExceededUpgrade": "yükseltin", "memberLimitExceededPro": "Üye sınırına ulaşıldı, daha fazla üye gerekiyorsa ", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Üye eklenemedi", "addMemberSuccess": "Üye başarıyla eklendi", "removeMember": "Üyeyi Kaldır", "areYouSureToRemoveMember": "Bu üyeyi kaldırmak istediğinizden emin misiniz?", "inviteMemberSuccess": "Davet başarıyla gönderildi", "failedToInviteMember": "Üye davet edilemedi", "workspaceMembersError": "Hay aksi, bir şeyler yanlış gitti", "workspaceMembersErrorDescription": "Şu anda üye listesini yükleyemedik. Lütfen daha sonra tekrar deneyin" } }, "files": { "copy": "Kopyala", "defaultLocation": "Dosyaları ve veri depolama konumunu oku", "exportData": "Verilerinizi dışa aktarın", "doubleTapToCopy": "Yolu kopyalamak için çift dokunun", "restoreLocation": "@:appName varsayılan yoluna geri yükle", "customizeLocation": "Başka bir klasör aç", "restartApp": "Değişikliklerin etkili olması için lütfen uygulamayı yeniden başlatın.", "exportDatabase": "Veritabanını dışa aktar", "selectFiles": "Dışa aktarılması gereken dosyaları seçin", "selectAll": "Tümünü seç", "deselectAll": "Tüm seçimi kaldır", "createNewFolder": "Yeni klasör oluştur", "createNewFolderDesc": "Verilerinizi nerede saklamak istediğinizi bize söyleyin", "defineWhereYourDataIsStored": "Verilerinizin nerede saklanacağını tanımlayın", "open": "Aç", "openFolder": "Mevcut bir klasör aç", "openFolderDesc": "Mevcut @:appName klasörünüzü okuyun ve yazın", "folderHintText": "klasör adı", "location": "Yeni klasör oluşturma", "locationDesc": "@:appName veri klasörünüz için bir ad seçin", "browser": "Göz at", "create": "Oluştur", "set": "Ayarla", "folderPath": "Klasörünüzü saklamak için yol", "locationCannotBeEmpty": "Yol boş olamaz", "pathCopiedSnackbar": "Dosya depolama yolu panoya kopyalandı!", "changeLocationTooltips": "Veri dizinini değiştir", "change": "Değiştir", "openLocationTooltips": "Başka bir veri dizini aç", "openCurrentDataFolder": "Mevcut veri dizinini aç", "recoverLocationTooltips": "@:appName'in varsayılan veri dizinine sıfırla", "exportFileSuccess": "Dosya başarıyla dışa aktarıldı!", "exportFileFail": "Dosya dışa aktarılamadı!", "export": "Dışa aktar", "clearCache": "Önbelleği temizle", "clearCacheDesc": "Resimlerin yüklenmemesi veya yazı tiplerinin doğru görüntülenmemesi gibi sorunlarla karşılaşırsanız, önbelleği temizlemeyi deneyin. Bu işlem kullanıcı verilerinizi kaldırmayacaktır.", "areYouSureToClearCache": "Önbelleği temizlemek istediğinizden emin misiniz?", "clearCacheSuccess": "Önbellek başarıyla temizlendi!" }, "user": { "name": "Ad", "email": "E-posta", "tooltipSelectIcon": "Simge seç", "selectAnIcon": "Bir simge seç", "pleaseInputYourOpenAIKey": "lütfen Yapay Zeka anahtarınızı girin", "clickToLogout": "Mevcut kullanıcının oturumunu kapatmak için tıklayın" }, "mobile": { "personalInfo": "Kişisel Bilgiler", "username": "Kullanıcı Adı", "usernameEmptyError": "Kullanıcı adı boş olamaz", "about": "Hakkında", "pushNotifications": "Anlık Bildirimler", "support": "Destek", "joinDiscord": "Discord'da bize katılın", "privacyPolicy": "Gizlilik Politikası", "userAgreement": "Kullanıcı Sözleşmesi", "termsAndConditions": "Şartlar ve Koşullar", "userprofileError": "Kullanıcı profili yüklenemedi", "userprofileErrorDescription": "Lütfen sorunun devam edip etmediğini kontrol etmek için oturumu kapatıp yeniden giriş yapmayı deneyin.", "selectLayout": "Düzen seç", "selectStartingDay": "Başlangıç gününü seç", "version": "Sürüm" } }, "grid": { "deleteView": "Bu görünümü silmek istediğinizden emin misiniz?", "createView": "Yeni", "title": { "placeholder": "Başlıksız" }, "settings": { "filter": "Filtre", "sort": "Sırala", "sortBy": "Sıralama ölçütü", "properties": "Özellikler", "reorderPropertiesTooltip": "Özellikleri yeniden sıralamak için sürükleyin", "group": "Grupla", "addFilter": "Filtre Ekle", "deleteFilter": "Filtreyi sil", "filterBy": "Filtreleme ölçütü", "typeAValue": "Bir değer yazın...", "layout": "Düzen", "databaseLayout": "Düzen", "viewList": { "zero": "0 görünüm", "one": "{count} görünüm", "other": "{count} görünüm" }, "editView": "Görünümü Düzenle", "boardSettings": "Pano ayarları", "calendarSettings": "Takvim ayarları", "createView": "Yeni görünüm", "duplicateView": "Görünümü çoğalt", "deleteView": "Görünümü sil", "numberOfVisibleFields": "{} gösteriliyor" }, "filter": { "empty": "Aktif filtre yok", "addFilter": "Filtre ekle", "cannotFindCreatableField": "Filtrelenecek uygun bir alan bulunamadı", "conditon": "Koşul", "where": "Koşul" }, "textFilter": { "contains": "İçerir", "doesNotContain": "İçermez", "endsWith": "İle biter", "startWith": "İle başlar", "is": "Eşittir", "isNot": "Eşit değildir", "isEmpty": "Boştur", "isNotEmpty": "Boş değildir", "choicechipPrefix": { "isNot": "Değil", "startWith": "İle başlar", "endWith": "İle biter", "isEmpty": "boştur", "isNotEmpty": "boş değildir" } }, "checkboxFilter": { "isChecked": "İşaretli", "isUnchecked": "İşaretsiz", "choicechipPrefix": { "is": "eşittir" } }, "checklistFilter": { "isComplete": "Tamamlandı", "isIncomplted": "Tamamlanmadı" }, "selectOptionFilter": { "is": "Eşittir", "isNot": "Eşit değildir", "contains": "İçerir", "doesNotContain": "İçermez", "isEmpty": "Boştur", "isNotEmpty": "Boş değildir" }, "dateFilter": { "is": "Tarihinde", "before": "Öncesinde", "after": "Sonrasında", "onOrBefore": "Tarihinde veya öncesinde", "onOrAfter": "Tarihinde veya sonrasında", "between": "Arasında", "empty": "Boştur", "notEmpty": "Boş değildir", "startDate": "Başlangıç tarihi", "endDate": "Bitiş tarihi", "choicechipPrefix": { "before": "Önce", "after": "Sonra", "between": "Arasında", "onOrBefore": "Tarihinde veya önce", "onOrAfter": "Tarihinde veya sonra", "isEmpty": "Boştur", "isNotEmpty": "Boş değildir" } }, "numberFilter": { "equal": "Eşittir", "notEqual": "Eşit değildir", "lessThan": "Küçüktür", "greaterThan": "Büyüktür", "lessThanOrEqualTo": "Küçük veya eşittir", "greaterThanOrEqualTo": "Büyük veya eşittir", "isEmpty": "Boştur", "isNotEmpty": "Boş değildir" }, "field": { "label": "Özellik", "hide": "Özelliği gizle", "show": "Özelliği göster", "insertLeft": "Sola ekle", "insertRight": "Sağa ekle", "duplicate": "Çoğalt", "delete": "Sil", "wrapCellContent": "Metni kaydır", "clear": "Hücreleri temizle", "switchPrimaryFieldTooltip": "Birincil alanın alan türü değiştirilemez", "textFieldName": "Metin", "checkboxFieldName": "Onay kutusu", "dateFieldName": "Tarih", "updatedAtFieldName": "Son değiştirilme", "createdAtFieldName": "Oluşturulma tarihi", "numberFieldName": "Sayılar", "singleSelectFieldName": "Seçim", "multiSelectFieldName": "Çoklu seçim", "urlFieldName": "URL", "checklistFieldName": "Kontrol listesi", "relationFieldName": "İlişki", "summaryFieldName": "Yapay Zeka Özeti", "timeFieldName": "Saat", "mediaFieldName": "Dosyalar ve medya", "translateFieldName": "Yapay Zeka Çeviri", "translateTo": "Şu dile çevir", "numberFormat": "Sayı biçimi", "dateFormat": "Tarih biçimi", "includeTime": "Saati dahil et", "isRange": "Bitiş tarihi", "dateFormatFriendly": "Ay Gün, Yıl", "dateFormatISO": "Yıl-Ay-Gün", "dateFormatLocal": "Ay/Gün/Yıl", "dateFormatUS": "Yıl/Ay/Gün", "dateFormatDayMonthYear": "Gün/Ay/Yıl", "timeFormat": "Saat biçimi", "invalidTimeFormat": "Geçersiz biçim", "timeFormatTwelveHour": "12 saat", "timeFormatTwentyFourHour": "24 saat", "clearDate": "Tarihi temizle", "dateTime": "Tarih saat", "startDateTime": "Başlangıç tarih saati", "endDateTime": "Bitiş tarih saati", "failedToLoadDate": "Tarih değeri yüklenemedi", "selectTime": "Saat seç", "selectDate": "Tarih seç", "visibility": "Görünürlük", "propertyType": "Özellik türü", "addSelectOption": "Bir seçenek ekle", "typeANewOption": "Yeni bir seçenek yazın", "optionTitle": "Seçenekler", "addOption": "Seçenek ekle", "editProperty": "Özelliği düzenle", "newProperty": "Yeni özellik", "openRowDocument": "Sayfa olarak aç", "deleteFieldPromptMessage": "Emin misiniz? Bu özellik ve tüm verileri silinecek", "clearFieldPromptMessage": "Emin misiniz? Bu sütundaki tüm hücreler boşaltılacak", "newColumn": "Yeni sütun", "format": "Biçim", "reminderOnDateTooltip": "Bu hücrede planlanmış bir hatırlatıcı var", "optionAlreadyExist": "Seçenek zaten mevcut" }, "rowPage": { "newField": "Yeni alan ekle", "fieldDragElementTooltip": "Menüyü açmak için tıklayın", "showHiddenFields": { "one": "{count} gizli alanı göster", "many": "{count} gizli alanı göster", "other": "{count} gizli alanı göster" }, "hideHiddenFields": { "one": "{count} gizli alanı gizle", "many": "{count} gizli alanı gizle", "other": "{count} gizli alanı gizle" }, "openAsFullPage": "Tam sayfa olarak aç", "moreRowActions": "Daha fazla satır işlemi" }, "sort": { "ascending": "Artan", "descending": "Azalan", "by": "Göre", "empty": "Aktif sıralama yok", "cannotFindCreatableField": "Sıralanacak uygun bir alan bulunamadı", "deleteAllSorts": "Tüm sıralamaları sil", "addSort": "Sıralama ekle", "sortsActive": "Sıralama yaparken {intention} yapılamaz", "removeSorting": "Bu görünümdeki tüm sıralamaları kaldırıp devam etmek istiyor musunuz?", "fieldInUse": "Bu alana göre zaten sıralama yapıyorsunuz" }, "row": { "label": "Satır", "duplicate": "Çoğalt", "delete": "Sil", "titlePlaceholder": "Başlıksız", "textPlaceholder": "Boş", "copyProperty": "Özellik panoya kopyalandı", "count": "Sayı", "newRow": "Yeni satır", "loadMore": "Daha fazla yükle", "action": "İşlem", "add": "Aşağıya eklemek için tıklayın", "drag": "Taşımak için sürükleyin", "deleteRowPrompt": "Bu satırı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "deleteCardPrompt": "Bu kartı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "dragAndClick": "Taşımak için sürükleyin, menüyü açmak için tıklayın", "insertRecordAbove": "Üste kayıt ekle", "insertRecordBelow": "Alta kayıt ekle", "noContent": "İçerik yok", "reorderRowDescription": "satırı yeniden sırala", "createRowAboveDescription": "üste bir satır oluştur", "createRowBelowDescription": "alta bir satır ekle" }, "selectOption": { "create": "Oluştur", "purpleColor": "Mor", "pinkColor": "Pembe", "lightPinkColor": "Açık Pembe", "orangeColor": "Turuncu", "yellowColor": "Sarı", "limeColor": "Limon", "greenColor": "Yeşil", "aquaColor": "Su Mavisi", "blueColor": "Mavi", "deleteTag": "Etiketi sil", "colorPanelTitle": "Renk", "panelTitle": "Bir seçenek seçin veya oluşturun", "searchOption": "Bir seçenek arayın", "searchOrCreateOption": "Bir seçenek arayın veya oluşturun", "createNew": "Yeni oluştur", "orSelectOne": "Veya bir seçenek seçin", "typeANewOption": "Yeni bir seçenek yazın", "tagName": "Etiket adı" }, "checklist": { "taskHint": "Görev açıklaması", "addNew": "Yeni görev ekle", "submitNewTask": "Oluştur", "hideComplete": "Tamamlanan görevleri gizle", "showComplete": "Tüm görevleri göster" }, "url": { "launch": "Bağlantıyı tarayıcıda aç", "copy": "Bağlantıyı panoya kopyala", "textFieldHint": "Bir URL girin" }, "relation": { "relatedDatabasePlaceLabel": "İlişkili Veritabanı", "relatedDatabasePlaceholder": "Yok", "inRelatedDatabase": "İçinde", "rowSearchTextFieldPlaceholder": "Ara", "noDatabaseSelected": "Veritabanı seçilmedi, lütfen aşağıdaki listeden önce bir tane seçin:", "emptySearchResult": "Kayıt bulunamadı", "linkedRowListLabel": "{count} bağlantılı satır", "unlinkedRowListLabel": "Başka bir satır bağla" }, "menuName": "Tablo", "referencedGridPrefix": "Görünümü", "calculate": "Hesapla", "calculationTypeLabel": { "none": "Yok", "average": "Ortalama", "max": "En büyük", "median": "Medyan", "min": "En küçük", "sum": "Toplam", "count": "Sayı", "countEmpty": "Boş sayısı", "countEmptyShort": "BOŞ", "countNonEmpty": "Boş olmayan sayısı", "countNonEmptyShort": "DOLU" }, "media": { "rename": "Yeniden adlandır", "download": "İndir", "expand": "Genişlet", "delete": "Sil", "moreFilesHint": "+{}", "addFileOrImage": "Dosya veya bağlantı ekle", "attachmentsHint": "{}", "addFileMobile": "Dosya ekle", "extraCount": "+{}", "deleteFileDescription": "Bu dosyayı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "showFileNames": "Dosya adını göster", "downloadSuccess": "Dosya indirildi", "downloadFailedToken": "Dosya indirilemedi, kullanıcı jetonu mevcut değil", "setAsCover": "Kapak olarak ayarla", "openInBrowser": "Tarayıcıda aç", "embedLink": "Dosya bağlantısını yerleştir" } }, "document": { "menuName": "Belge", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "Oluşturuluyor...", "slashMenu": { "board": { "selectABoardToLinkTo": "Bağlanacak bir Pano seçin", "createANewBoard": "Yeni bir Pano oluştur" }, "grid": { "selectAGridToLinkTo": "Bağlanacak bir Tablo seçin", "createANewGrid": "Yeni bir Tablo oluştur" }, "calendar": { "selectACalendarToLinkTo": "Bağlanacak bir Takvim seçin", "createANewCalendar": "Yeni bir Takvim oluştur" }, "document": { "selectADocumentToLinkTo": "Bağlanacak bir Belge seçin" }, "name": { "text": "Metin", "heading1": "Başlık 1", "heading2": "Başlık 2", "heading3": "Başlık 3", "image": "Görsel", "bulletedList": "Madde işaretli liste", "numberedList": "Numaralı liste", "todoList": "Yapılacaklar listesi", "doc": "Belge", "linkedDoc": "Sayfaya bağlantı", "grid": "Tablo", "linkedGrid": "Bağlantılı Tablo", "kanban": "Kanban", "linkedKanban": "Bağlantılı Kanban", "calendar": "Takvim", "linkedCalendar": "Bağlantılı Takvim", "quote": "Alıntı", "divider": "Ayırıcı", "table": "Tablo", "callout": "Not Kutusu", "outline": "Ana Hat", "mathEquation": "Matematik Denklemi", "code": "Kod", "toggleList": "Açılır liste", "toggleHeading1": "Açılır başlık 1", "toggleHeading2": "Açılır başlık 2", "toggleHeading3": "Açılır başlık 3", "emoji": "Emoji", "aiWriter": "Yapay Zeka Yazar", "dateOrReminder": "Tarih veya Hatırlatıcı", "photoGallery": "Fotoğraf Galerisi", "file": "Dosya" }, "subPage": { "name": "Belge", "keyword1": "alt sayfa", "keyword2": "sayfa", "keyword3": "alt sayfa", "keyword4": "sayfa ekle", "keyword5": "sayfa yerleştir", "keyword6": "yeni sayfa", "keyword7": "sayfa oluştur", "keyword8": "belge" } }, "selectionMenu": { "outline": "Ana Hat", "codeBlock": "Kod Bloğu" }, "plugins": { "referencedBoard": "Referans Pano", "referencedGrid": "Referans Tablo", "referencedCalendar": "Referans Takvim", "referencedDocument": "Referans Belge", "autoGeneratorMenuItemName": "Yapay Zeka Yazar", "autoGeneratorTitleName": "Yapay Zeka: Yapay zekadan herhangi bir şey yazmasını isteyin...", "autoGeneratorLearnMore": "Daha fazla bilgi", "autoGeneratorGenerate": "Oluştur", "autoGeneratorHintText": "Yapay zekaya sorun ...", "autoGeneratorCantGetOpenAIKey": "Yapay zeka anahtarı alınamıyor", "autoGeneratorRewrite": "Yeniden yaz", "smartEdit": "Yapay Zekaya Sor", "aI": "Yapay Zeka", "smartEditFixSpelling": "Yazım ve dilbilgisini düzelt", "warning": "⚠️ Yapay zeka yanıtları yanlış veya yanıltıcı olabilir.", "smartEditSummarize": "Özetle", "smartEditImproveWriting": "Yazımı geliştir", "smartEditMakeLonger": "Daha uzun yap", "smartEditCouldNotFetchResult": "Yapay zekadan sonuç alınamadı", "smartEditCouldNotFetchKey": "Yapay zeka anahtarı alınamadı", "smartEditDisabled": "Ayarlar'dan Yapay Zeka'yı bağlayın", "appflowyAIEditDisabled": "Yapay Zeka özelliklerini etkinleştirmek için giriş yapın", "discardResponse": "Yapay Zeka yanıtlarını silmek istiyor musunuz?", "createInlineMathEquation": "Denklem oluştur", "fonts": "Yazı tipleri", "insertDate": "Tarih ekle", "emoji": "Emoji", "toggleList": "Açılır liste", "emptyToggleHeading": "Boş açılır başlık {}. İçerik eklemek için tıklayın.", "emptyToggleList": "Boş açılır liste. İçerik eklemek için tıklayın.", "emptyToggleHeadingWeb": "Boş açılır başlık {level}. İçerik eklemek için tıklayın", "quoteList": "Alıntı listesi", "numberedList": "Numaralı liste", "bulletedList": "Madde işaretli liste", "todoList": "Yapılacaklar listesi", "callout": "Not Kutusu", "simpleTable": { "moreActions": { "color": "Renk", "align": "Hizala", "delete": "Sil", "duplicate": "Çoğalt", "insertLeft": "Sola ekle", "insertRight": "Sağa ekle", "insertAbove": "Üste ekle", "insertBelow": "Alta ekle", "headerColumn": "Başlık sütunu", "headerRow": "Başlık satırı", "clearContents": "İçeriği temizle", "setToPageWidth": "Sayfa genişliğine ayarla", "distributeColumnsWidth": "Sütunları eşit dağıt", "duplicateRow": "Satırı çoğalt", "duplicateColumn": "Sütunu çoğalt", "textColor": "Metin rengi", "cellBackgroundColor": "Hücre arka plan rengi", "duplicateTable": "Tabloyu çoğalt" }, "clickToAddNewRow": "Yeni satır eklemek için tıklayın", "clickToAddNewColumn": "Yeni sütun eklemek için tıklayın", "clickToAddNewRowAndColumn": "Yeni satır ve sütun eklemek için tıklayın", "headerName": { "table": "Tablo", "alignText": "Metni hizala" } }, "cover": { "changeCover": "Kapağı Değiştir", "colors": "Renkler", "images": "Görseller", "clearAll": "Tümünü Temizle", "abstract": "Soyut", "addCover": "Kapak Ekle", "addLocalImage": "Yerel görsel ekle", "invalidImageUrl": "Geçersiz görsel URL'si", "failedToAddImageToGallery": "Görsel galeriye eklenemedi", "enterImageUrl": "Görsel URL'si girin", "add": "Ekle", "back": "Geri", "saveToGallery": "Galeriye kaydet", "removeIcon": "Simgeyi kaldır", "removeCover": "Kapağı kaldır", "pasteImageUrl": "Görsel URL'sini yapıştırın", "or": "VEYA", "pickFromFiles": "Dosyalardan seç", "couldNotFetchImage": "Görsel alınamadı", "imageSavingFailed": "Görsel Kaydedilemedi", "addIcon": "Simge ekle", "changeIcon": "Simgeyi değiştir", "coverRemoveAlert": "Silindikten sonra kapaktan kaldırılacaktır.", "alertDialogConfirmation": "Devam etmek istediğinizden emin misiniz?" }, "mathEquation": { "name": "Matematik Denklemi", "addMathEquation": "Bir TeX denklemi ekle", "editMathEquation": "Matematik Denklemini Düzenle" }, "optionAction": { "click": "Tıkla", "toOpenMenu": " menüyü açmak için", "drag": "Sürükle", "toMove": " taşımak için", "delete": "Sil", "duplicate": "Çoğalt", "turnInto": "Dönüştür", "moveUp": "Yukarı taşı", "moveDown": "Aşağı taşı", "color": "Renk", "align": "Hizala", "left": "Sol", "center": "Orta", "right": "Sağ", "defaultColor": "Varsayılan", "depth": "Derinlik", "copyLinkToBlock": "Bloğa bağlantıyı kopyala" }, "image": { "addAnImage": "Görsel ekle", "copiedToPasteBoard": "Görsel bağlantısı panoya kopyalandı", "addAnImageDesktop": "Bir görsel ekle", "addAnImageMobile": "Bir veya daha fazla görsel eklemek için tıklayın", "dropImageToInsert": "Eklemek için görselleri bırakın", "imageUploadFailed": "Görsel yüklenemedi", "imageDownloadFailed": "Görsel indirilemedi, lütfen tekrar deneyin", "imageDownloadFailedToken": "Kullanıcı jetonu eksik olduğu için görsel indirilemedi, lütfen tekrar deneyin", "errorCode": "Hata kodu" }, "photoGallery": { "name": "Fotoğraf galerisi", "imageKeyword": "görsel", "imageGalleryKeyword": "görsel galerisi", "photoKeyword": "fotoğraf", "photoBrowserKeyword": "fotoğraf tarayıcısı", "galleryKeyword": "galeri", "addImageTooltip": "Görsel ekle", "changeLayoutTooltip": "Düzeni değiştir", "browserLayout": "Tarayıcı", "gridLayout": "Izgara", "deleteBlockTooltip": "Tüm galeriyi sil" }, "math": { "copiedToPasteBoard": "Matematik denklemi panoya kopyalandı" }, "urlPreview": { "copiedToPasteBoard": "Bağlantı panoya kopyalandı", "convertToLink": "Yerleşik bağlantıya dönüştür" }, "outline": { "addHeadingToCreateOutline": "İçindekiler tablosu oluşturmak için başlıklar ekleyin.", "noMatchHeadings": "Eşleşen başlık bulunamadı." }, "table": { "addAfter": "Sonrasına ekle", "addBefore": "Öncesine ekle", "delete": "Sil", "clear": "İçeriği temizle", "duplicate": "Çoğalt", "bgColor": "Arka plan rengi" }, "contextMenu": { "copy": "Kopyala", "cut": "Kes", "paste": "Yapıştır", "pasteAsPlainText": "Düz metin olarak yapıştır" }, "action": "İşlemler", "database": { "selectDataSource": "Veri kaynağı seç", "noDataSource": "Veri kaynağı yok", "selectADataSource": "Bir veri kaynağı seç", "toContinue": "devam etmek için", "newDatabase": "Yeni Veritabanı", "linkToDatabase": "Veritabanına Bağlantı" }, "date": "Tarih", "video": { "label": "Video", "emptyLabel": "Video ekle", "placeholder": "Video bağlantısını yapıştırın", "copiedToPasteBoard": "Video bağlantısı panoya kopyalandı", "insertVideo": "Video ekle", "invalidVideoUrl": "Kaynak URL'si henüz desteklenmiyor.", "invalidVideoUrlYouTube": "YouTube henüz desteklenmiyor.", "supportedFormats": "Desteklenen formatlar: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "Dosya", "uploadTab": "Yükle", "uploadMobile": "Bir dosya seç", "uploadMobileGallery": "Fotoğraf Galerisinden", "networkTab": "Bağlantı yerleştir", "placeholderText": "Bir dosya yükleyin veya yerleştirin", "placeholderDragging": "Yüklemek için dosyayı bırakın", "dropFileToUpload": "Yüklemek için bir dosya bırakın", "fileUploadHint": "Bir dosya sürükleyip bırakın veya tıklayarak ", "fileUploadHintSuffix": "Göz atın", "networkHint": "Bir dosya bağlantısı yapıştırın", "networkUrlInvalid": "Geçersiz URL. URL'yi kontrol edip tekrar deneyin.", "networkAction": "Yerleştir", "fileTooBigError": "Dosya boyutu çok büyük, lütfen 10MB'dan küçük bir dosya yükleyin", "renameFile": { "title": "Dosyayı yeniden adlandır", "description": "Bu dosya için yeni bir ad girin", "nameEmptyError": "Dosya adı boş bırakılamaz." }, "uploadedAt": "{} tarihinde yüklendi", "linkedAt": "Bağlantısı {} tarihinde eklendi", "failedToOpenMsg": "Açılamadı, dosya bulunamadı" }, "subPage": { "handlingPasteHint": " - (yapıştırma işlemi)", "errors": { "failedDeletePage": "Sayfa silinemedi", "failedCreatePage": "Sayfa oluşturulamadı", "failedMovePage": "Sayfa bu belgeye taşınamadı", "failedDuplicatePage": "Sayfa çoğaltılamadı", "failedDuplicateFindView": "Sayfa çoğaltılamadı - orijinal görünüm bulunamadı" } }, "cannotMoveToItsChildren": "Alt öğelerine taşınamaz" }, "outlineBlock": { "placeholder": "İçindekiler" }, "textBlock": { "placeholder": "Komutlar için '/' yazın" }, "title": { "placeholder": "Başlıksız" }, "imageBlock": { "placeholder": "Görsel eklemek için tıklayın", "upload": { "label": "Yükle", "placeholder": "Görsel yüklemek için tıklayın" }, "url": { "label": "Görsel URL'si", "placeholder": "Görsel URL'si girin" }, "ai": { "label": "Yapay zeka ile görsel oluştur", "placeholder": "Yapay zekanın görsel oluşturması için bir istek girin" }, "stability_ai": { "label": "Stability AI ile görsel oluştur", "placeholder": "Stability AI'nın görsel oluşturması için bir istek girin" }, "support": "Görsel boyut sınırı 5MB'dır. Desteklenen formatlar: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Geçersiz görsel", "invalidImageSize": "Görsel boyutu 5MB'dan küçük olmalıdır", "invalidImageFormat": "Görsel formatı desteklenmiyor. Desteklenen formatlar: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "Geçersiz görsel URL'si", "noImage": "Böyle bir dosya veya dizin yok", "multipleImagesFailed": "Bir veya daha fazla görsel yüklenemedi, lütfen tekrar deneyin" }, "embedLink": { "label": "Bağlantı yerleştir", "placeholder": "Bir görsel bağlantısı yapıştırın veya yazın" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Bir görsel ara", "pleaseInputYourOpenAIKey": "lütfen Ayarlar sayfasından yapay zeka anahtarınızı girin", "saveImageToGallery": "Görseli kaydet", "failedToAddImageToGallery": "Görsel galeriye eklenemedi", "successToAddImageToGallery": "Görsel başarıyla galeriye eklendi", "unableToLoadImage": "Görsel yüklenemedi", "maximumImageSize": "Desteklenen maksimum görsel yükleme boyutu 10MB'dır", "uploadImageErrorImageSizeTooBig": "Görsel boyutu 10MB'dan küçük olmalıdır", "imageIsUploading": "Görsel yükleniyor", "openFullScreen": "Tam ekran aç", "interactiveViewer": { "toolbar": { "previousImageTooltip": "Önceki görsel", "nextImageTooltip": "Sonraki görsel", "zoomOutTooltip": "Uzaklaştır", "zoomInTooltip": "Yakınlaştır", "changeZoomLevelTooltip": "Yakınlaştırma seviyesini değiştir", "openLocalImage": "Görseli aç", "downloadImage": "Görseli indir", "closeViewer": "Etkileşimli görüntüleyiciyi kapat", "scalePercentage": "%{}", "deleteImageTooltip": "Görseli sil" } } }, "codeBlock": { "language": { "label": "Dil", "placeholder": "Dil seçin", "auto": "Otomatik" }, "copyTooltip": "Kopyala", "searchLanguageHint": "Bir dil arayın", "codeCopiedSnackbar": "Kod panoya kopyalandı!" }, "inlineLink": { "placeholder": "Bir bağlantı yapıştırın veya yazın", "openInNewTab": "Yeni sekmede aç", "copyLink": "Bağlantıyı kopyala", "removeLink": "Bağlantıyı kaldır", "url": { "label": "Bağlantı URL'si", "placeholder": "Bağlantı URL'si girin" }, "title": { "label": "Bağlantı Başlığı", "placeholder": "Bağlantı başlığı girin" } }, "mention": { "placeholder": "Bir kişiden, sayfadan veya tarihten bahsedin...", "page": { "label": "Sayfaya bağlantı", "tooltip": "Sayfayı açmak için tıklayın" }, "deleted": "Silindi", "deletedContent": "Bu içerik mevcut değil veya silinmiş", "noAccess": "Erişim Yok", "deletedPage": "Silinmiş sayfa", "trashHint": " - çöp kutusunda", "morePages": "daha fazla sayfa" }, "toolbar": { "resetToDefaultFont": "Varsayılana sıfırla" }, "errorBlock": { "theBlockIsNotSupported": "Mevcut sürüm bu Bloğu desteklemiyor.", "clickToCopyTheBlockContent": "Blok içeriğini kopyalamak için tıklayın", "blockContentHasBeenCopied": "Blok içeriği kopyalandı.", "parseError": "{} bloğu ayrıştırılırken bir hata oluştu.", "copyBlockContent": "Blok içeriğini kopyala" }, "mobilePageSelector": { "title": "Sayfa seç", "failedToLoad": "Sayfa listesi yüklenemedi", "noPagesFound": "Sayfa bulunamadı" }, "attachmentMenu": { "choosePhoto": "Fotoğraf seç", "takePicture": "Fotoğraf çek", "chooseFile": "Dosya seç" } }, "board": { "column": { "label": "Sütun", "createNewCard": "Yeni", "renameGroupTooltip": "Grubu yeniden adlandırmak için basın", "createNewColumn": "Yeni bir grup ekle", "addToColumnTopTooltip": "Üste yeni bir kart ekle", "addToColumnBottomTooltip": "Alta yeni bir kart ekle", "renameColumn": "Yeniden adlandır", "hideColumn": "Gizle", "newGroup": "Yeni grup", "deleteColumn": "Sil", "deleteColumnConfirmation": "Bu işlem bu grubu ve içindeki tüm kartları silecektir. Devam etmek istediğinizden emin misiniz?" }, "hiddenGroupSection": { "sectionTitle": "Gizli Gruplar", "collapseTooltip": "Gizli grupları gizle", "expandTooltip": "Gizli grupları görüntüle" }, "cardDetail": "Kart Detayı", "cardActions": "Kart İşlemleri", "cardDuplicated": "Kart çoğaltıldı", "cardDeleted": "Kart silindi", "showOnCard": "Kart detayında göster", "setting": "Ayar", "propertyName": "Özellik adı", "menuName": "Pano", "showUngrouped": "Gruplanmamış öğeleri göster", "ungroupedButtonText": "Gruplanmamış", "ungroupedButtonTooltip": "Herhangi bir gruba ait olmayan kartları içerir", "ungroupedItemsTitle": "Panoya eklemek için tıklayın", "groupBy": "Grupla", "groupCondition": "Gruplama koşulu", "referencedBoardPrefix": "Görünümü", "notesTooltip": "İçerideki notlar", "mobile": { "editURL": "URL'yi düzenle", "showGroup": "Grubu göster", "showGroupContent": "Bu grubu panoda göstermek istediğinizden emin misiniz?", "failedToLoad": "Pano görünümü yüklenemedi" }, "dateCondition": { "weekOf": "{} - {} haftası", "today": "Bugün", "yesterday": "Dün", "tomorrow": "Yarın", "lastSevenDays": "Son 7 gün", "nextSevenDays": "Gelecek 7 gün", "lastThirtyDays": "Son 30 gün", "nextThirtyDays": "Gelecek 30 gün" }, "noGroup": "Özelliğe göre gruplama yok", "noGroupDesc": "Pano görünümleri görüntülemek için gruplamak üzere bir özellik gerektirir", "media": { "cardText": "{} {}", "fallbackName": "dosyalar" } }, "calendar": { "menuName": "Takvim", "defaultNewCalendarTitle": "Başlıksız", "newEventButtonTooltip": "Yeni etkinlik ekle", "navigation": { "today": "Bugün", "jumpToday": "Bugüne git", "previousMonth": "Önceki Ay", "nextMonth": "Sonraki Ay", "views": { "day": "Gün", "week": "Hafta", "month": "Ay", "year": "Yıl" } }, "mobileEventScreen": { "emptyTitle": "Henüz etkinlik yok", "emptyBody": "Bu güne etkinlik eklemek için artı düğmesine basın." }, "settings": { "showWeekNumbers": "Hafta numaralarını göster", "showWeekends": "Hafta sonlarını göster", "firstDayOfWeek": "Haftanın başlangıç günü", "layoutDateField": "Takvimi şuna göre düzenle", "changeLayoutDateField": "Düzen alanını değiştir", "noDateTitle": "Tarih Yok", "noDateHint": { "zero": "Planlanmamış etkinlikler burada görünecek", "one": "{count} planlanmamış etkinlik", "other": "{count} planlanmamış etkinlik" }, "unscheduledEventsTitle": "Planlanmamış etkinlikler", "clickToAdd": "Takvime eklemek için tıklayın", "name": "Takvim ayarları", "clickToOpen": "Kaydı açmak için tıklayın" }, "referencedCalendarPrefix": "Görünümü", "quickJumpYear": "Şuraya git", "duplicateEvent": "Etkinliği çoğalt" }, "errorDialog": { "title": "@:appName Hatası", "howToFixFallback": "Rahatsızlık için özür dileriz! GitHub sayfamızda hatanızı açıklayan bir sorun bildirin.", "howToFixFallbackHint1": "Rahatsızlık için özür dileriz! ", "howToFixFallbackHint2": " sayfamızda hatanızı açıklayan bir sorun bildirin.", "github": "GitHub'da görüntüle" }, "search": { "label": "Ara", "sidebarSearchIcon": "Ara ve hızlıca bir sayfaya git", "placeholder": { "actions": "Eylemleri ara..." } }, "message": { "copy": { "success": "Kopyalandı!", "fail": "Kopyalanamadı" } }, "unSupportBlock": "Mevcut sürüm bu Bloğu desteklemiyor.", "views": { "deleteContentTitle": "{pageType} silmek istediğinizden emin misiniz?", "deleteContentCaption": "Bu {pageType} silerseniz, çöp kutusundan geri yükleyebilirsiniz." }, "colors": { "custom": "Özel", "default": "Varsayılan", "red": "Kırmızı", "orange": "Turuncu", "yellow": "Sarı", "green": "Yeşil", "blue": "Mavi", "purple": "Mor", "pink": "Pembe", "brown": "Kahverengi", "gray": "Gri" }, "emoji": { "emojiTab": "Emoji", "search": "Emoji ara", "noRecent": "Son kullanılan emoji yok", "noEmojiFound": "Emoji bulunamadı", "filter": "Filtrele", "random": "Rastgele", "selectSkinTone": "Ten rengi seç", "remove": "Emojiyi kaldır", "categories": { "smileys": "İfadeler ve Duygular", "people": "insanlar", "animals": "doğa", "food": "yiyecekler", "activities": "aktiviteler", "places": "yerler", "objects": "nesneler", "symbols": "semboller", "flags": "bayraklar", "nature": "doğa", "frequentlyUsed": "sık kullanılanlar" }, "skinTone": { "default": "Varsayılan", "light": "Açık", "mediumLight": "Orta-Açık", "medium": "Orta", "mediumDark": "Orta-Koyu", "dark": "Koyu" }, "openSourceIconsFrom": "Açık kaynak ikonlar" }, "inlineActions": { "noResults": "Sonuç yok", "recentPages": "Son sayfalar", "pageReference": "Sayfa referansı", "docReference": "Belge referansı", "boardReference": "Pano referansı", "calReference": "Takvim referansı", "gridReference": "Tablo referansı", "date": "Tarih", "reminder": { "groupTitle": "Hatırlatıcı", "shortKeyword": "hatırlat" }, "createPage": "\"{}\"-alt sayfası oluştur" }, "datePicker": { "dateTimeFormatTooltip": "Tarih ve saat biçimini ayarlardan değiştirin", "dateFormat": "Tarih biçimi", "includeTime": "Saati dahil et", "isRange": "Bitiş tarihi", "timeFormat": "Saat biçimi", "clearDate": "Tarihi temizle", "reminderLabel": "Hatırlatıcı", "selectReminder": "Hatırlatıcı seç", "reminderOptions": { "none": "Yok", "atTimeOfEvent": "Etkinlik zamanında", "fiveMinsBefore": "5 dakika önce", "tenMinsBefore": "10 dakika önce", "fifteenMinsBefore": "15 dakika önce", "thirtyMinsBefore": "30 dakika önce", "oneHourBefore": "1 saat önce", "twoHoursBefore": "2 saat önce", "onDayOfEvent": "Etkinlik günü", "oneDayBefore": "1 gün önce", "twoDaysBefore": "2 gün önce", "oneWeekBefore": "1 hafta önce", "custom": "Özel" } }, "relativeDates": { "yesterday": "Dün", "today": "Bugün", "tomorrow": "Yarın", "oneWeek": "1 hafta" }, "notificationHub": { "title": "Bildirimler", "mobile": { "title": "Güncellemeler" }, "emptyTitle": "Hepsi tamamlandı!", "emptyBody": "Bekleyen bildirim veya eylem yok. Huzurun tadını çıkarın.", "tabs": { "inbox": "Gelen Kutusu", "upcoming": "Yaklaşan" }, "actions": { "markAllRead": "Tümünü okundu olarak işaretle", "showAll": "Tümü", "showUnreads": "Okunmamış" }, "filters": { "ascending": "Artan", "descending": "Azalan", "groupByDate": "Tarihe göre grupla", "showUnreadsOnly": "Sadece okunmamışları göster", "resetToDefault": "Varsayılana sıfırla" } }, "reminderNotification": { "title": "Hatırlatıcı", "message": "Unutmadan önce bunu kontrol etmeyi unutmayın!", "tooltipDelete": "Sil", "tooltipMarkRead": "Okundu olarak işaretle", "tooltipMarkUnread": "Okunmadı olarak işaretle" }, "findAndReplace": { "find": "Bul", "previousMatch": "Önceki eşleşme", "nextMatch": "Sonraki eşleşme", "close": "Kapat", "replace": "Değiştir", "replaceAll": "Tümünü değiştir", "noResult": "Sonuç yok", "caseSensitive": "Büyük/küçük harf duyarlı", "searchMore": "Daha fazla sonuç bulmak için arama yapın" }, "error": { "weAreSorry": "Üzgünüz", "loadingViewError": "Bu görünümü yüklerken sorun yaşıyoruz. Lütfen internet bağlantınızı kontrol edin, uygulamayı yenileyin ve sorun devam ederse ekiple iletişime geçmekten çekinmeyin.", "syncError": "Veriler başka bir cihazdan senkronize edilmedi", "syncErrorHint": "Lütfen bu sayfayı son düzenlemenin yapıldığı cihazda yeniden açın, ardından mevcut cihazda tekrar açın.", "clickToCopy": "Hata kodunu kopyalamak için tıklayın" }, "editor": { "bold": "Kalın", "bulletedList": "Madde işaretli liste", "bulletedListShortForm": "Madde işaretli", "checkbox": "Onay kutusu", "embedCode": "Kod Yerleştir", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Vurgula", "color": "Renk", "image": "Görsel", "date": "Tarih", "page": "Sayfa", "italic": "İtalik", "link": "Bağlantı", "numberedList": "Numaralı liste", "numberedListShortForm": "Numaralı", "toggleHeading1ShortForm": "B1'i aç/kapat", "toggleHeading2ShortForm": "B2'yi aç/kapat", "toggleHeading3ShortForm": "B3'ü aç/kapat", "quote": "Alıntı", "strikethrough": "Üstü çizili", "text": "Metin", "underline": "Altı çizili", "fontColorDefault": "Varsayılan", "fontColorGray": "Gri", "fontColorBrown": "Kahverengi", "fontColorOrange": "Turuncu", "fontColorYellow": "Sarı", "fontColorGreen": "Yeşil", "fontColorBlue": "Mavi", "fontColorPurple": "Mor", "fontColorPink": "Pembe", "fontColorRed": "Kırmızı", "backgroundColorDefault": "Varsayılan arka plan", "backgroundColorGray": "Gri arka plan", "backgroundColorBrown": "Kahverengi arka plan", "backgroundColorOrange": "Turuncu arka plan", "backgroundColorYellow": "Sarı arka plan", "backgroundColorGreen": "Yeşil arka plan", "backgroundColorBlue": "Mavi arka plan", "backgroundColorPurple": "Mor arka plan", "backgroundColorPink": "Pembe arka plan", "backgroundColorRed": "Kırmızı arka plan", "backgroundColorLime": "Limon yeşili arka plan", "backgroundColorAqua": "Su mavisi arka plan", "done": "Tamam", "cancel": "İptal", "tint1": "Ton 1", "tint2": "Ton 2", "tint3": "Ton 3", "tint4": "Ton 4", "tint5": "Ton 5", "tint6": "Ton 6", "tint7": "Ton 7", "tint8": "Ton 8", "tint9": "Ton 9", "lightLightTint1": "Mor", "lightLightTint2": "Pembe", "lightLightTint3": "Açık Pembe", "lightLightTint4": "Turuncu", "lightLightTint5": "Sarı", "lightLightTint6": "Limon yeşili", "lightLightTint7": "Yeşil", "lightLightTint8": "Su mavisi", "lightLightTint9": "Mavi", "urlHint": "URL", "mobileHeading1": "Başlık 1", "mobileHeading2": "Başlık 2", "mobileHeading3": "Başlık 3", "mobileHeading4": "Başlık 4", "mobileHeading5": "Başlık 5", "mobileHeading6": "Başlık 6", "textColor": "Metin Rengi", "backgroundColor": "Arka Plan Rengi", "addYourLink": "Bağlantınızı ekleyin", "openLink": "Bağlantıyı aç", "copyLink": "Bağlantıyı kopyala", "removeLink": "Bağlantıyı kaldır", "editLink": "Bağlantıyı düzenle", "linkText": "Metin", "linkTextHint": "Lütfen metin girin", "linkAddressHint": "Lütfen URL girin", "highlightColor": "Vurgulama rengi", "clearHighlightColor": "Vurgulama rengini temizle", "customColor": "Özel renk", "hexValue": "Hex değeri", "opacity": "Opaklık", "resetToDefaultColor": "Varsayılan renge sıfırla", "ltr": "Soldan sağa", "rtl": "Sağdan sola", "auto": "Otomatik", "cut": "Kes", "copy": "Kopyala", "paste": "Yapıştır", "find": "Bul", "select": "Seç", "selectAll": "Tümünü seç", "previousMatch": "Önceki eşleşme", "nextMatch": "Sonraki eşleşme", "closeFind": "Kapat", "replace": "Değiştir", "replaceAll": "Tümünü değiştir", "regex": "Düzenli ifade", "caseSensitive": "Büyük/küçük harf duyarlı", "uploadImage": "Görsel Yükle", "urlImage": "URL Görseli", "incorrectLink": "Hatalı Bağlantı", "upload": "Yükle", "chooseImage": "Bir görsel seçin", "loading": "Yükleniyor", "imageLoadFailed": "Görsel yüklenemedi", "divider": "Ayırıcı", "table": "Tablo", "colAddBefore": "Önce ekle", "rowAddBefore": "Önce ekle", "colAddAfter": "Sonra ekle", "rowAddAfter": "Sonra ekle", "colRemove": "Kaldır", "rowRemove": "Kaldır", "colDuplicate": "Çoğalt", "rowDuplicate": "Çoğalt", "colClear": "İçeriği Temizle", "rowClear": "İçeriği Temizle", "slashPlaceHolder": "Blok eklemek için '/' yazın veya yazmaya başlayın", "typeSomething": "Bir şeyler yazın...", "toggleListShortForm": "Aç/Kapat", "quoteListShortForm": "Alıntı", "mathEquationShortForm": "Formül", "codeBlockShortForm": "Kod" }, "favorite": { "noFavorite": "Favori sayfa yok", "noFavoriteHintText": "Favorilerinize eklemek için sayfayı sola kaydırın", "removeFromSidebar": "Kenar çubuğundan kaldır", "addToSidebar": "Kenar çubuğuna sabitle" }, "cardDetails": { "notesPlaceholder": "Blok eklemek için / yazın veya yazmaya başlayın" }, "blockPlaceholders": { "todoList": "Yapılacaklar", "bulletList": "Liste", "numberList": "Liste", "quote": "Alıntı", "heading": "Başlık {}" }, "titleBar": { "pageIcon": "Sayfa ikonu", "language": "Dil", "font": "Yazı tipi", "actions": "Eylemler", "date": "Tarih", "addField": "Alan ekle", "userIcon": "Kullanıcı ikonu" }, "noLogFiles": "Günlük dosyası yok", "newSettings": { "myAccount": { "title": "Hesabım", "subtitle": "Profilinizi özelleştirin, hesap güvenliğini yönetin, yapay zeka anahtarlarını ayarlayın veya hesabınıza giriş yapın.", "profileLabel": "Hesap adı ve Profil resmi", "profileNamePlaceholder": "Adınızı girin", "accountSecurity": "Hesap güvenliği", "2FA": "2 Adımlı Doğrulama", "aiKeys": "Yapay zeka anahtarları", "accountLogin": "Hesap Girişi", "updateNameError": "Ad güncellenemedi", "updateIconError": "İkon güncellenemedi", "deleteAccount": { "title": "Hesabı Sil", "subtitle": "Hesabınızı ve tüm verilerinizi kalıcı olarak silin.", "description": "Hesabınızı kalıcı olarak silin ve tüm çalışma alanlarından erişimi kaldırın.", "deleteMyAccount": "Hesabımı sil", "dialogTitle": "Hesabı sil", "dialogContent1": "Hesabınızı kalıcı olarak silmek istediğinizden emin misiniz?", "dialogContent2": "Bu işlem GERİ ALINAMAZ ve tüm çalışma alanlarından erişiminizi kaldıracak, özel çalışma alanları dahil tüm hesabınızı silecek ve sizi tüm paylaşılan çalışma alanlarından çıkaracaktır.", "confirmHint1": "Onaylamak için lütfen \"@:newSettings.myAccount.deleteAccount.confirmHint3\" yazın.", "confirmHint2": "Bu işlemin GERİ ALINAMAZ olduğunu ve hesabımı ve ilişkili tüm verileri kalıcı olarak sileceğini anlıyorum.", "confirmHint3": "HESABIMI SİL", "checkToConfirmError": "Silme işlemini onaylamak için kutuyu işaretlemelisiniz", "failedToGetCurrentUser": "Mevcut kullanıcı e-postası alınamadı", "confirmTextValidationFailed": "Onay metniniz \"@:newSettings.myAccount.deleteAccount.confirmHint3\" ile eşleşmiyor", "deleteAccountSuccess": "Hesap başarıyla silindi" } }, "workplace": { "name": "Çalışma alanı", "title": "Çalışma Alanı Ayarları", "subtitle": "Çalışma alanınızın görünümünü, temasını, yazı tipini, metin düzenini, tarih, saat ve dilini özelleştirin.", "workplaceName": "Çalışma alanı adı", "workplaceNamePlaceholder": "Çalışma alanı adını girin", "workplaceIcon": "Çalışma alanı ikonu", "workplaceIconSubtitle": "Bir görsel yükleyin veya çalışma alanınız için bir emoji kullanın. İkon kenar çubuğunuzda ve bildirimlerde görünecektir.", "renameError": "Çalışma alanı yeniden adlandırılamadı", "updateIconError": "İkon güncellenemedi", "chooseAnIcon": "Bir ikon seçin", "appearance": { "name": "Görünüm", "themeMode": { "auto": "Otomatik", "light": "Aydınlık", "dark": "Koyu" }, "language": "Dil" } }, "syncState": { "syncing": "Senkronize ediliyor", "synced": "Senkronize edildi", "noNetworkConnected": "Ağ bağlantısı yok" } }, "pageStyle": { "title": "Sayfa stili", "layout": "Düzen", "coverImage": "Kapak görseli", "pageIcon": "Sayfa ikonu", "colors": "Renkler", "gradient": "Gradyan", "backgroundImage": "Arka plan görseli", "presets": "Hazır ayarlar", "photo": "Fotoğraf", "unsplash": "Unsplash", "pageCover": "Sayfa kapağı", "none": "Yok", "openSettings": "Ayarları Aç", "photoPermissionTitle": "@:appName fotoğraf kitaplığınıza erişmek istiyor", "photoPermissionDescription": "@:appName belgelerinize görsel ekleyebilmeniz için fotoğraflarınıza erişmeye ihtiyaç duyuyor", "cameraPermissionTitle": "@:appName kameranıza erişmek istiyor", "cameraPermissionDescription": "@:appName belgelerinize kameradan görsel ekleyebilmeniz için kameranıza erişmeye ihtiyaç duyuyor", "doNotAllow": "İzin Verme", "image": "Görsel" }, "commandPalette": { "placeholder": "Ara veya bir soru sor...", "bestMatches": "En iyi eşleşmeler", "recentHistory": "Son geçmiş", "navigateHint": "gezinmek için", "loadingTooltip": "Sonuçları arıyoruz...", "betaLabel": "BETA", "betaTooltip": "Şu anda yalnızca sayfalarda ve belgelerde içerik aramayı destekliyoruz", "fromTrashHint": "Çöp kutusundan", "noResultsHint": "Aradığınızı bulamadık, başka bir terim aramayı deneyin.", "clearSearchTooltip": "Arama alanını temizle" }, "space": { "delete": "Sil", "deleteConfirmation": "Sil: ", "deleteConfirmationDescription": "Bu Alan içindeki tüm sayfalar silinecek ve Çöp Kutusuna taşınacak, yayınlanmış sayfaların yayını kaldırılacaktır.", "rename": "Alanı Yeniden Adlandır", "changeIcon": "İkonu değiştir", "manage": "Alanı Yönet", "addNewSpace": "Alan Oluştur", "collapseAllSubPages": "Tüm alt sayfaları daralt", "createNewSpace": "Yeni bir alan oluştur", "createSpaceDescription": "İşlerinizi daha iyi organize etmek için birden fazla genel ve özel alan oluşturun.", "spaceName": "Alan adı", "spaceNamePlaceholder": "örn. Pazarlama, Mühendislik, İK", "permission": "Alan izni", "publicPermission": "Genel", "publicPermissionDescription": "Tam erişime sahip tüm çalışma alanı üyeleri", "privatePermission": "Özel", "privatePermissionDescription": "Bu alana yalnızca siz erişebilirsiniz", "spaceIconBackground": "Arka plan rengi", "spaceIcon": "İkon", "dangerZone": "Tehlikeli Bölge", "unableToDeleteLastSpace": "Son Alan silinemez", "unableToDeleteSpaceNotCreatedByYou": "Başkaları tarafından oluşturulan alanlar silinemez", "enableSpacesForYourWorkspace": "Çalışma alanınız için Alanları etkinleştirin", "title": "Alanlar", "defaultSpaceName": "Genel", "upgradeSpaceTitle": "Alanları Etkinleştir", "upgradeSpaceDescription": "Çalışma alanınızı daha iyi organize etmek için birden fazla genel ve özel Alan oluşturun.", "upgrade": "Güncelle", "upgradeYourSpace": "Birden fazla Alan oluştur", "quicklySwitch": "Hızlıca sonraki alana geç", "duplicate": "Alanı Çoğalt", "movePageToSpace": "Sayfayı alana taşı", "cannotMovePageToDatabase": "Sayfa veritabanına taşınamaz", "switchSpace": "Alan değiştir", "spaceNameCannotBeEmpty": "Alan adı boş olamaz", "success": { "deleteSpace": "Alan başarıyla silindi", "renameSpace": "Alan başarıyla yeniden adlandırıldı", "duplicateSpace": "Alan başarıyla çoğaltıldı", "updateSpace": "Alan başarıyla güncellendi" }, "error": { "deleteSpace": "Alan silinemedi", "renameSpace": "Alan yeniden adlandırılamadı", "duplicateSpace": "Alan çoğaltılamadı", "updateSpace": "Alan güncellenemedi" }, "createSpace": "Alan oluştur", "manageSpace": "Alanı yönet", "renameSpace": "Alanı yeniden adlandır", "mSpaceIconColor": "Alan ikonu rengi", "mSpaceIcon": "Alan ikonu" }, "publish": { "hasNotBeenPublished": "Bu sayfa henüz yayınlanmadı", "spaceHasNotBeenPublished": "Henüz bir alanın yayınlanması desteklenmiyor", "reportPage": "Sayfayı bildir", "databaseHasNotBeenPublished": "Veritabanı yayınlama henüz desteklenmiyor.", "createdWith": "Şununla oluşturuldu", "downloadApp": "AppFlowy'yi İndir", "copy": { "codeBlock": "Kod bloğunun içeriği panoya kopyalandı", "imageBlock": "Görsel bağlantısı panoya kopyalandı", "mathBlock": "Matematik denklemi panoya kopyalandı", "fileBlock": "Dosya bağlantısı panoya kopyalandı" }, "containsPublishedPage": "Bu sayfa bir veya daha fazla yayınlanmış sayfa içeriyor. Devam ederseniz, yayından kaldırılacaklar. Silme işlemine devam etmek istiyor musunuz?", "publishSuccessfully": "Başarıyla yayınlandı", "unpublishSuccessfully": "Yayından kaldırma başarılı", "publishFailed": "Yayınlanamadı", "unpublishFailed": "Yayından kaldırılamadı", "noAccessToVisit": "Bu sayfaya erişim yok...", "createWithAppFlowy": "AppFlowy ile bir web sitesi oluşturun", "fastWithAI": "Yapay zeka ile hızlı ve kolay.", "tryItNow": "Şimdi deneyin", "onlyGridViewCanBePublished": "Yalnızca Tablo görünümü yayınlanabilir", "database": { "zero": "{} seçili görünümü yayınla", "one": "{} seçili görünümü yayınla", "many": "{} seçili görünümü yayınla", "other": "{} seçili görünümü yayınla" }, "mustSelectPrimaryDatabase": "Ana görünüm seçilmelidir", "noDatabaseSelected": "Veritabanı seçilmedi, lütfen en az bir veritabanı seçin.", "unableToDeselectPrimaryDatabase": "Ana veritabanının seçimi kaldırılamaz", "saveThisPage": "Bu şablonla başlayın", "duplicateTitle": "Nereye eklemek istersiniz", "selectWorkspace": "Bir çalışma alanı seçin", "addTo": "Şuraya ekle", "duplicateSuccessfully": "Çalışma alanınıza eklendi", "duplicateSuccessfullyDescription": "AppFlowy yüklü değil mi? 'İndir'e tıkladıktan sonra indirme otomatik olarak başlayacak.", "downloadIt": "İndir", "openApp": "Uygulamada aç", "duplicateFailed": "Çoğaltma başarısız", "membersCount": { "zero": "Üye yok", "one": "1 üye", "many": "{count} üye", "other": "{count} üye" }, "useThisTemplate": "Bu şablonu kullan" }, "web": { "continue": "Devam et", "or": "veya", "continueWithGoogle": "Google ile devam et", "continueWithGithub": "GitHub ile devam et", "continueWithDiscord": "Discord ile devam et", "continueWithApple": "Apple ile devam et", "moreOptions": "Daha fazla seçenek", "collapse": "Daralt", "signInAgreement": "Yukarıdaki \"Devam et\" düğmesine tıklayarak AppFlowy'nin şunlarını kabul etmiş olursunuz:", "and": "ve", "termOfUse": "Kullanım Koşulları", "privacyPolicy": "Gizlilik Politikası", "signInError": "Giriş hatası", "login": "Kaydol veya giriş yap", "fileBlock": { "uploadedAt": "{time} tarihinde yüklendi", "linkedAt": "Bağlantısı {time} tarihinde eklendi", "empty": "Bir dosya yükleyin veya yerleştirin", "uploadFailed": "Yükleme başarısız, lütfen tekrar deneyin", "retry": "Tekrar dene" }, "importNotion": "Notion'dan içe aktar", "import": "İçe aktar", "importSuccess": "Başarıyla yüklendi", "importSuccessMessage": "İçe aktarma tamamlandığında size bildirim göndereceğiz. Bundan sonra, içe aktarılan sayfalarınızı kenar çubuğunda görüntüleyebilirsiniz.", "importFailed": "İçe aktarma başarısız, lütfen dosya formatını kontrol edin", "dropNotionFile": "Yüklemek için Notion zip dosyanızı buraya bırakın veya göz atmak için tıklayın", "error": { "pageNameIsEmpty": "Sayfa adı boş, lütfen başka bir tane deneyin" } }, "globalComment": { "comments": "Yorumlar", "addComment": "Yorum ekle", "reactedBy": "tepki verenler", "addReaction": "Tepki ekle", "reactedByMore": "ve {count} diğer", "showSeconds": { "one": "1 saniye önce", "other": "{count} saniye önce", "zero": "Az önce", "many": "{count} saniye önce" }, "showMinutes": { "one": "1 dakika önce", "other": "{count} dakika önce", "many": "{count} dakika önce" }, "showHours": { "one": "1 saat önce", "other": "{count} saat önce", "many": "{count} saat önce" }, "showDays": { "one": "1 gün önce", "other": "{count} gün önce", "many": "{count} gün önce" }, "showMonths": { "one": "1 ay önce", "other": "{count} ay önce", "many": "{count} ay önce" }, "showYears": { "one": "1 yıl önce", "other": "{count} yıl önce", "many": "{count} yıl önce" }, "reply": "Yanıtla", "deleteComment": "Yorumu sil", "youAreNotOwner": "Bu yorumun sahibi siz değilsiniz", "confirmDeleteDescription": "Bu yorumu silmek istediğinizden emin misiniz?", "hasBeenDeleted": "Silindi", "replyingTo": "Yanıtlanıyor", "noAccessDeleteComment": "Bu yorumu silme izniniz yok", "collapse": "Daralt", "readMore": "Devamını oku", "failedToAddComment": "Yorum eklenemedi", "commentAddedSuccessfully": "Yorum başarıyla eklendi.", "commentAddedSuccessTip": "Az önce bir yorum eklediniz veya yanıtladınız. En son yorumları görmek için başa dönmek ister misiniz?" }, "template": { "asTemplate": "Şablon olarak kaydet", "name": "Şablon adı", "description": "Şablon Açıklaması", "about": "Şablon Hakkında", "deleteFromTemplate": "Şablonlardan sil", "preview": "Şablon Önizleme", "categories": "Şablon Kategorileri", "isNewTemplate": "Yeni şablona SABİTLE", "featured": "Öne Çıkanlara SABİTLE", "relatedTemplates": "İlgili Şablonlar", "requiredField": "{field} gereklidir", "addCategory": "\"{category}\" ekle", "addNewCategory": "Yeni kategori ekle", "addNewCreator": "Yeni oluşturucu ekle", "deleteCategory": "Kategoriyi sil", "editCategory": "Kategoriyi düzenle", "editCreator": "Oluşturucuyu düzenle", "category": { "name": "Kategori adı", "icon": "Kategori ikonu", "bgColor": "Kategori arka plan rengi", "priority": "Kategori önceliği", "desc": "Kategori açıklaması", "type": "Kategori türü", "icons": "Kategori İkonları", "colors": "Kategori Renkleri", "byUseCase": "Kullanım Durumuna Göre", "byFeature": "Özelliğe Göre", "deleteCategory": "Kategoriyi sil", "deleteCategoryDescription": "Bu kategoriyi silmek istediğinizden emin misiniz?", "typeToSearch": "Kategorilerde arama yapmak için yazın..." }, "creator": { "label": "Şablon Oluşturucu", "name": "Oluşturucu adı", "avatar": "Oluşturucu avatarı", "accountLinks": "Oluşturucu hesap bağlantıları", "uploadAvatar": "Avatar yüklemek için tıklayın", "deleteCreator": "Oluşturucuyu sil", "deleteCreatorDescription": "Bu oluşturucuyu silmek istediğinizden emin misiniz?", "typeToSearch": "Oluşturucularda arama yapmak için yazın..." }, "uploadSuccess": "Şablon başarıyla yüklendi", "uploadSuccessDescription": "Şablonunuz başarıyla yüklendi. Artık şablon galerisinde görüntüleyebilirsiniz.", "viewTemplate": "Şablonu görüntüle", "deleteTemplate": "Şablonu sil", "deleteSuccess": "Şablon başarıyla silindi", "deleteTemplateDescription": "Bu işlem mevcut sayfayı veya yayınlanma durumunu etkilemeyecektir. Bu şablonu silmek istediğinizden emin misiniz?", "addRelatedTemplate": "İlgili şablon ekle", "removeRelatedTemplate": "İlgili şablonu kaldır", "uploadAvatar": "Avatar yükle", "searchInCategory": "{category} içinde ara", "label": "Şablonlar" }, "fileDropzone": { "dropFile": "Yüklemek için dosyayı bu alana tıklayın veya sürükleyin", "uploading": "Yükleniyor...", "uploadFailed": "Yükleme başarısız", "uploadSuccess": "Yükleme başarılı", "uploadSuccessDescription": "Dosya başarıyla yüklendi", "uploadFailedDescription": "Dosya yüklenemedi", "uploadingDescription": "Dosya yükleniyor" }, "gallery": { "preview": "Tam ekran aç", "copy": "Kopyala", "download": "İndir", "prev": "Önceki", "next": "Sonraki", "resetZoom": "Yakınlaştırmayı sıfırla", "zoomIn": "Yakınlaştır", "zoomOut": "Uzaklaştır" }, "invitation": { "join": "Katıl", "on": "tarihinde", "invitedBy": "Davet eden", "membersCount": { "zero": "{count} üye", "one": "{count} üye", "many": "{count} üye", "other": "{count} üye" }, "tip": "Aşağıdaki iletişim bilgileriyle bu çalışma alanına katılmaya davet edildiniz. Bu bilgiler yanlışsa, davetiyeyi yeniden göndermesi için yöneticinizle iletişime geçin.", "joinWorkspace": "Çalışma alanına katıl", "success": "Çalışma alanına başarıyla katıldınız", "successMessage": "Artık içindeki tüm sayfalara ve çalışma alanlarına erişebilirsiniz.", "openWorkspace": "AppFlowy'yi Aç", "alreadyAccepted": "Bu daveti zaten kabul ettiniz", "errorModal": { "title": "Bir hata oluştu", "description": "Mevcut hesabınızın {email} bu çalışma alanına erişimi olmayabilir. Lütfen doğru hesapla giriş yapın veya yardım için çalışma alanı sahibiyle iletişime geçin.", "contactOwner": "Sahiple iletişime geç", "close": "Ana sayfaya dön", "changeAccount": "Hesap değiştir" } }, "requestAccess": { "title": "Bu sayfaya erişim yok", "subtitle": "Bu sayfanın sahibinden erişim talep edebilirsiniz. Onaylandığında, sayfayı görüntüleyebilirsiniz.", "requestAccess": "Erişim talep et", "backToHome": "Ana sayfaya dön", "tip": "Şu anda olarak giriş yapmış durumdasınız.", "mightBe": "Farklı bir hesapla yapmanız gerekebilir.", "successful": "Talep başarıyla gönderildi", "successfulMessage": "Sahibi talebinizi onayladığında size bildirim gönderilecek.", "requestError": "Erişim talebi başarısız oldu", "repeatRequestError": "Bu sayfa için zaten erişim talebinde bulundunuz" }, "approveAccess": { "title": "Çalışma Alanı Katılım Talebini Onayla", "requestSummary": ", 'a katılmak ve 'a erişmek istiyor", "upgrade": "yükselt", "downloadApp": "AppFlowy'yi İndir", "approveButton": "Onayla", "approveSuccess": "Başarıyla onaylandı", "approveError": "Onaylama başarısız, çalışma alanı plan limitinin aşılmadığından emin olun", "getRequestInfoError": "Talep bilgisi alınamadı", "memberCount": { "zero": "Üye yok", "one": "1 üye", "many": "{count} üye", "other": "{count} üye" }, "alreadyProTitle": "Çalışma alanı plan limitine ulaştınız", "alreadyProMessage": "Daha fazla üye eklemek için ile iletişime geçmelerini isteyin", "repeatApproveError": "Bu talebi zaten onayladınız", "ensurePlanLimit": "Çalışma alanı plan limitinin aşılmadığından emin olun. Limit aşılırsa, çalışma alanı planını veya .", "requestToJoin": "katılmak için talep etti", "asMember": "üye olarak" }, "upgradePlanModal": { "title": "Pro'ya Yükselt", "message": "{name} ücretsiz üye limitine ulaştı. Daha fazla üye davet etmek için Pro Plana yükseltin.", "upgradeSteps": "AppFlowy'de planınızı nasıl yükseltirsiniz:", "step1": "1. Ayarlar'a gidin", "step2": "2. 'Plan'a tıklayın", "step3": "3. 'Planı Değiştir'i seçin", "appNote": "Not: ", "actionButton": "Yükselt", "downloadLink": "Uygulamayı İndir", "laterButton": "Sonra", "refreshNote": "Başarılı yükseltmeden sonra, yeni özelliklerinizi etkinleştirmek için tıklayın.", "refresh": "buraya" }, "breadcrumbs": { "label": "Gezinti menüsü" }, "time": { "justNow": "Az önce", "seconds": { "one": "1 saniye", "other": "{count} saniye" }, "minutes": { "one": "1 dakika", "other": "{count} dakika" }, "hours": { "one": "1 saat", "other": "{count} saat" }, "days": { "one": "1 gün", "other": "{count} gün" }, "weeks": { "one": "1 hafta", "other": "{count} hafta" }, "months": { "one": "1 ay", "other": "{count} ay" }, "years": { "one": "1 yıl", "other": "{count} yıl" }, "ago": "önce", "yesterday": "Dün", "today": "Bugün" }, "members": { "zero": "Üye yok", "one": "1 üye", "many": "{count} üye", "other": "{count} üye" }, "tabMenu": { "close": "Kapat", "closeDisabledHint": "Sabitlenmiş bir sekme kapatılamaz, lütfen önce sabitlemeyi kaldırın", "closeOthers": "Diğer sekmeleri kapat", "closeOthersHint": "Bu işlem, bu sekme dışındaki tüm sabitlenmemiş sekmeleri kapatacaktır", "closeOthersDisabledHint": "Tüm sekmeler sabitlenmiş, kapatılacak sekme bulunamadı", "favorite": "Favorilere ekle", "unfavorite": "Favorilerden kaldır", "favoriteDisabledHint": "Bu görünüm favorilere eklenemez", "pinTab": "Sabitle", "unpinTab": "Sabitlemeyi kaldır" }, "openFileMessage": { "success": "Dosya başarıyla açıldı", "fileNotFound": "Dosya bulunamadı", "noAppToOpenFile": "Bu dosyayı açacak uygulama yok", "permissionDenied": "Bu dosyayı açma izni yok", "unknownError": "Dosya açılamadı" }, "inviteMember": { "requestInviteMembers": "Çalışma alanınıza davet et", "inviteFailedMemberLimit": "Üye limitine ulaşıldı, lütfen ", "upgrade": "yükselt", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "Davetleri gönder", "inviteAlready": "Bu e-postayı zaten davet ettiniz: {email}", "inviteSuccess": "Davet başarıyla gönderildi", "description": "E-postaları aralarına virgül koyarak aşağıya girin. Ücretlendirme üye sayısına göre yapılır.", "emails": "E-posta" }, "quickNote": { "label": "Hızlı Not", "quickNotes": "Hızlı Notlar", "search": "Hızlı Notlarda Ara", "collapseFullView": "Tam görünümü daralt", "expandFullView": "Tam görünümü genişlet", "createFailed": "Hızlı Not oluşturulamadı", "quickNotesEmpty": "Hızlı Not yok", "emptyNote": "Boş not", "deleteNotePrompt": "Seçilen not kalıcı olarak silinecektir. Silmek istediğinizden emin misiniz?", "addNote": "Yeni Not", "noAdditionalText": "Ek metin yok" }, "subscribe": { "upgradePlanTitle": "Planları karşılaştır ve seç", "yearly": "Yıllık", "save": "%{discount} tasarruf", "monthly": "Aylık", "priceIn": "Fiyat: ", "free": "Ücretsiz", "pro": "Pro", "freeDescription": "2 üyeye kadar bireyler için her şeyi organize etmek için", "proDescription": "Küçük ekipler için projeleri ve ekip bilgisini yönetmek için", "proDuration": { "monthly": "üye başına aylık\naylık faturalandırma", "yearly": "üye başına aylık\nyıllık faturalandırma" }, "cancel": "Alt plana geç", "changePlan": "Pro Plana yükselt", "everythingInFree": "Ücretsiz plandaki her şey +", "currentPlan": "Mevcut", "freeDuration": "süresiz", "freePoints": { "first": "2 üyeye kadar 1 işbirliği çalışma alanı", "second": "Sınırsız sayfa ve blok", "three": "5 GB depolama", "four": "Akıllı arama", "five": "20 yapay zeka yanıtı", "six": "Mobil uygulama", "seven": "Gerçek zamanlı işbirliği" }, "proPoints": { "first": "Sınırsız depolama", "second": "10 çalışma alanı üyesine kadar", "three": "Sınırsız yapay zeka yanıtı", "four": "Sınırsız dosya yükleme", "five": "Özel alan adı" }, "cancelPlan": { "title": "Gitmenize üzüldük", "success": "Aboneliğiniz başarıyla iptal edildi", "description": "Gitmenize üzüldük. AppFlowy'yi geliştirmemize yardımcı olmak için geri bildiriminizi almak isteriz. Lütfen birkaç soruyu yanıtlamak için zaman ayırın.", "commonOther": "Diğer", "otherHint": "Yanıtınızı buraya yazın", "questionOne": { "question": "AppFlowy Pro aboneliğinizi iptal etmenize ne sebep oldu?", "answerOne": "Maliyet çok yüksek", "answerTwo": "Özellikler beklentileri karşılamadı", "answerThree": "Daha iyi bir alternatif buldum", "answerFour": "Maliyeti karşılayacak kadar kullanmadım", "answerFive": "Hizmet sorunu veya teknik zorluklar" }, "questionTwo": { "question": "Gelecekte AppFlowy Pro'ya yeniden abone olma olasılığınız nedir?", "answerOne": "Çok muhtemel", "answerTwo": "Biraz muhtemel", "answerThree": "Emin değilim", "answerFour": "Muhtemel değil", "answerFive": "Hiç muhtemel değil" }, "questionThree": { "question": "Aboneliğiniz sırasında en çok hangi Pro özelliğine değer verdiniz?", "answerOne": "Çoklu kullanıcı işbirliği", "answerTwo": "Daha uzun süreli versiyon geçmişi", "answerThree": "Sınırsız yapay zeka yanıtları", "answerFour": "Yerel yapay zeka modellerine erişim" }, "questionFour": { "question": "AppFlowy ile genel deneyiminizi nasıl tanımlarsınız?", "answerOne": "Harika", "answerTwo": "İyi", "answerThree": "Ortalama", "answerFour": "Ortalamanın altında", "answerFive": "Memnun değilim" } } }, "ai": { "contentPolicyViolation": "Hassas içerik nedeniyle görsel oluşturma başarısız oldu. Lütfen girdinizi yeniden düzenleyip tekrar deneyin" } } ================================================ FILE: frontend/resources/translations/uk-UA.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Я", "welcomeText": "Ласкаво просимо в @:appName", "welcomeTo": "Ласкаво просимо до", "githubStarText": "Поставити зірку на GitHub", "subscribeNewsletterText": "Підпишіться на розсилку новин", "letsGoButtonText": "Почнемо", "title": "Заголовок", "youCanAlso": "Ви також можете", "and": "та", "failedToOpenUrl": "Не вдалося відкрити URL-адресу: {}", "blockActions": { "addBelowTooltip": "Клацніть, щоб додати нижче", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Виберіть варіант та натисніть", "addAboveTooltip": "додати вище", "dragTooltip": "Перетягніть, щоб пересунути", "openMenuTooltip": "Клацніть, щоб відкрити меню" }, "signUp": { "buttonText": "Зареєструватись", "title": "Реєстрація в @:appName", "getStartedText": "Почати", "emptyPasswordError": "Пароль не може бути порожнім", "repeatPasswordEmptyError": "Повтор пароля не може бути порожнім", "unmatchedPasswordError": "Паролі не збігаються", "alreadyHaveAnAccount": "Вже маєте обліковий запис?", "emailHint": "Електронна пошта", "passwordHint": "Пароль", "repeatPasswordHint": "Повторіть пароль", "signUpWith": "Зареєструватися за допомогою:" }, "signIn": { "loginTitle": "Увійдіть до @:appName", "loginButtonText": "Увійти", "loginStartWithAnonymous": "Почати анонімну сесію", "continueAnonymousUser": "Продовжити анонімну сесію", "buttonText": "Увійти", "signingInText": "Вхід...", "forgotPassword": "Забули пароль?", "emailHint": "Електронна пошта", "passwordHint": "Пароль", "dontHaveAnAccount": "Немаєте облікового запису?", "createAccount": "Створити акаунт", "repeatPasswordEmptyError": "Повторний пароль не може бути порожнім", "unmatchedPasswordError": "Повторний пароль не співпадає з паролем", "syncPromptMessage": "Синхронізація даних може зайняти трохи часу. Будь ласка, не закривайте цю сторінку", "or": "АБО", "signInWithGoogle": "Продовжуйте з Google", "signInWithGithub": "Продовжуйте з Github", "signInWithDiscord": "Продовжуйте з Discord", "signInWithApple": "Продовжуйте з Apple", "continueAnotherWay": "Продовжуйте іншим шляхом", "signUpWithGoogle": "Зареєструватися в Google", "signUpWithGithub": "Зареєструйтеся на Github", "signUpWithDiscord": "Зареєструйтеся в Discord", "signInWith": "Увійти за допомогою:", "signInWithEmail": "Продовжити з електронною поштою", "signInWithMagicLink": "Продовжити", "signUpWithMagicLink": "Зареєструйтеся за допомогою Magic Link", "pleaseInputYourEmail": "Будь ласка, введіть адресу електронної пошти", "settings": "Налаштування", "magicLinkSent": "Magic Link надіслано!", "invalidEmail": "Будь ласка, введіть дійсну адресу електронної пошти", "alreadyHaveAnAccount": "Вже є акаунт?", "logIn": "Авторизуватися", "generalError": "Щось пішло не так. Будь ласка спробуйте пізніше", "limitRateError": "З міркувань безпеки ви можете запитувати чарівне посилання лише кожні 60 секунд", "magicLinkSentDescription": "Чарівне посилання надіслано на вашу електронну адресу. Натисніть посилання, щоб завершити вхід. Термін дії посилання закінчиться через 5 хвилин.", "anonymous": "Анонім", "LogInWithGoogle": "Увійти за допомогою Google", "LogInWithGithub": "Увійти за допомогою Github", "LogInWithDiscord": "Увійти за допомогою Discord" }, "workspace": { "chooseWorkspace": "Виберіть свій робочий простір", "create": "Створити робочий простір", "reset": "Скинути робочий простір", "renameWorkspace": "Перейменувати робочу область", "resetWorkspacePrompt": "Скидання робочого простору призведе до видалення всіх сторінок та даних у ньому. Ви впевнені, що хочете скинути робочий простір? Також ви можете звернутися до служби підтримки для відновлення робочого простору", "hint": "робочий простір", "notFoundError": "Робочий простір не знайдено", "failedToLoad": "Щось пішло не так! Не вдалося завантажити робочий простір. Спробуйте закрити будь-який відкритий екземпляр AppFlowy та спробуйте ще раз.", "errorActions": { "reportIssue": "Повідомити про проблему", "reportIssueOnGithub": "Повідомити про проблему на Github", "exportLogFiles": "Експорт файлів журналу", "reachOut": "Звернутися на Discord" }, "menuTitle": "Робочі області", "deleteWorkspaceHintText": "Ви впевнені, що хочете видалити робочу область? Цю дію неможливо скасувати, і всі опубліковані вами сторінки буде скасовано.", "createSuccess": "Робочу область успішно створено", "createFailed": "Не вдалося створити робочу область", "createLimitExceeded": "Ви досягли максимального ліміту робочого простору, дозволеного для вашого облікового запису. Якщо вам потрібні додаткові робочі області, щоб продовжити роботу, надішліть запит на Github", "deleteSuccess": "Робочу область успішно видалено", "deleteFailed": "Не вдалося видалити робочу область", "openSuccess": "Робочу область відкрито", "openFailed": "Не вдалося відкрити робочу область", "renameSuccess": "Робочу область успішно перейменовано", "renameFailed": "Не вдалося перейменувати робочу область", "updateIconSuccess": "Значок робочої області успішно оновлено", "updateIconFailed": "Не вдалося оновити значок робочої області", "cannotDeleteTheOnlyWorkspace": "Неможливо видалити єдину робочу область", "fetchWorkspacesFailed": "Не вдалося отримати робочі області", "leaveCurrentWorkspace": "Залишити робочу область", "leaveCurrentWorkspacePrompt": "Ви впевнені, що бажаєте залишити поточну робочу область?" }, "shareAction": { "buttonText": "Поділитися", "workInProgress": "Скоро буде доступно", "markdown": "Markdown", "html": "HTML", "clipboard": "Копіювати в буфер обміну", "csv": "CSV", "copyLink": "Скопіювати посилання", "publishToTheWeb": "Опублікувати в Інтернеті", "publishToTheWebHint": "Створіть веб-сайт за допомогою AppFlowy", "publish": "Опублікувати", "unPublish": "Скасувати публікацію", "visitSite": "Відвідайте сайт", "exportAsTab": "Експортувати як", "publishTab": "Опублікувати", "shareTab": "Поділіться" }, "moreAction": { "small": "малий", "medium": "середній", "large": "великий", "fontSize": "Розмір шрифту", "import": "Імпортувати", "moreOptions": "Більше опцій", "wordCount": "Кількість слів: {}", "charCount": "Кількість символів: {}", "createdAt": "Створено: {}", "deleteView": "Видалити", "duplicateView": "Дублікат" }, "importPanel": { "textAndMarkdown": "Текст і Markdown", "documentFromV010": "Документ з v0.1.0", "databaseFromV010": "База даних з v0.1.0", "csv": "CSV", "database": "База даних" }, "disclosureAction": { "rename": "Перейменувати", "delete": "Видалити", "duplicate": "Дублювати", "unfavorite": "Видалити з обраного", "favorite": "Додати до обраного", "openNewTab": "Відкрити в новій вкладці", "moveTo": "Перемістити в", "addToFavorites": "Додати до обраного", "copyLink": "Скопіювати посилання", "changeIcon": "Змінити значок", "collapseAllPages": "Згорнути всі підсторінки" }, "blankPageTitle": "Порожня сторінка", "newPageText": "Нова сторінка", "newDocumentText": "Новий документ", "newGridText": "Нова таблиця", "newCalendarText": "Новий календар", "newBoardText": "Нова дошка", "chat": { "newChat": "ШІ Чат", "inputMessageHint": "Запитайте @:appName AI", "inputLocalAIMessageHint": "Запитайте @:appName локальний AI", "unsupportedCloudPrompt": "Ця функція доступна лише під час використання @:appName Cloud", "relatedQuestion": "Пов'язані", "serverUnavailable": "Сервіс тимчасово недоступний. Будь ласка спробуйте пізніше.", "aiServerUnavailable": "🌈 Ой-ой! 🌈. Єдиноріг з'їв нашу відповідь. Будь ласка, повторіть спробу!", "clickToRetry": "Натисніть, щоб повторити спробу", "regenerateAnswer": "Регенерувати", "question1": "Як використовувати Kanban для керування завданнями", "question2": "Поясніть метод GTD", "question3": "Навіщо використовувати Rust", "question4": "Рецепт з тим, що є на моїй кухні", "aiMistakePrompt": "ШІ може помилятися. Перевірте важливу інформацію.", "chatWithFilePrompt": "Хочете поспілкуватися з файлом?", "indexFileSuccess": "Файл успішно індексується", "inputActionNoPages": "Сторінка не знайдена", "referenceSource": { "zero": "0 джерел знайдено", "one": "{count} джерело знайдено", "other": "{count} джерел знайдено" }, "clickToMention": "Натисніть, щоб згадати сторінку", "uploadFile": "Завантажуйте PDF-файли, файли md або txt для спілкування в чаті", "questionDetail": "Привіт {}! Чим я можу тобі допомогти сьогодні?", "indexingFile": "Індексація {}" }, "trash": { "text": "Смітник", "restoreAll": "Відновити все", "deleteAll": "Видалити все", "pageHeader": { "fileName": "Ім'я файлу", "lastModified": "Останній змінений", "created": "Створено" }, "confirmDeleteAll": { "title": "Ви впевнені, що хочете видалити всі сторінки у кошику?", "caption": "Цю дію неможливо скасувати." }, "confirmRestoreAll": { "title": "Ви впевнені, що хочете відновити всі сторінки у кошику?", "caption": "Цю дію неможливо скасувати." }, "mobile": { "actions": "Дії щодо сміття", "empty": "Кошик порожній", "emptyDescription": "У вас немає видалених файлів", "isDeleted": "видалено", "isRestored": "відновлено" }, "confirmDeleteTitle": "Ви впевнені, що хочете остаточно видалити цю сторінку?" }, "deletePagePrompt": { "text": "Ця сторінка знаходиться у кошику", "restore": "Відновити сторінку", "deletePermanent": "Видалити назавжди" }, "dialogCreatePageNameHint": "Назва сторінки", "questionBubble": { "shortcuts": "Комбінації клавіш", "whatsNew": "Що нового?", "markdown": "Markdown", "debug": { "name": "Інформація для налагодження", "success": "Інформацію для налагодження скопійовано в буфер обміну!", "fail": "Не вдалося скопіювати інформацію для налагодження в буфер обміну" }, "feedback": "Зворотний зв'язок", "help": "Довідка та підтримка" }, "menuAppHeader": { "moreButtonToolTip": "Видалити, перейменувати та інше...", "addPageTooltip": "Швидко додати сторінку всередину", "defaultNewPageName": "Без назви", "renameDialog": "Перейменувати" }, "noPagesInside": "Всередині немає сторінок", "toolbar": { "undo": "Скасувати", "redo": "Повторити", "bold": "Жирний", "italic": "Курсив", "underline": "Підкреслення", "strike": "Закреслення", "numList": "Нумерований список", "bulletList": "Маркований список", "checkList": "Список із прапорцями", "inlineCode": "Вбудований код", "quote": "Цитата", "header": "Заголовок", "highlight": "Виділення", "color": "Колір", "addLink": "Додати посилання", "link": "Посилання" }, "tooltip": { "lightMode": "Перейти до світлого режиму", "darkMode": "Перейти до темного режиму", "openAsPage": "Відкрити як сторінку", "addNewRow": "Додати новий рядок", "openMenu": "Натисніть, щоб відкрити меню", "dragRow": "Тримайте натиснутим для зміни порядку рядка", "viewDataBase": "Переглянути базу даних", "referencePage": "Ця {name} знаходиться у зв'язку", "addBlockBelow": "Додати блок нижче", "aiGenerate": "Генерувати" }, "sideBar": { "closeSidebar": "Закрити бічну панель", "openSidebar": "Відкрити бічну панель", "personal": "Особисте", "private": "Приватний", "workspace": "Робоча область", "favorites": "Вибране", "clickToHidePrivate": "Натисніть, щоб приховати особистий простір\nСторінки, які ви тут створили, бачите лише ви", "clickToHideWorkspace": "Натисніть, щоб приховати робочу область\nСторінки, які ви тут створили, бачать усі учасники", "clickToHidePersonal": "Натисніть, щоб приховати особистий розділ", "clickToHideFavorites": "Натисніть, щоб приховати вибраний розділ", "addAPage": "Додати сторінку", "addAPageToPrivate": "Додайте сторінку до особистого простору", "addAPageToWorkspace": "Додати сторінку до робочої області", "recent": "Останні", "today": "Сьогодні", "thisWeek": "Цього тижня", "others": "Раніше улюблені", "justNow": "прямо зараз", "minutesAgo": "{count} хвилин тому", "lastViewed": "Останній перегляд", "favoriteAt": "Вибране", "emptyRecent": "Немає останніх документів", "emptyRecentDescription": "Під час перегляду документів вони з’являтимуться тут, щоб їх було легко знайти", "emptyFavorite": "Немає вибраних документів", "emptyFavoriteDescription": "Почніть досліджувати та позначайте документи як вибрані. Вони будуть перераховані тут для швидкого доступу!", "removePageFromRecent": "Видалити цю сторінку з останніх?", "removeSuccess": "Успішно видалено", "favoriteSpace": "Вибране", "RecentSpace": "Останні", "Spaces": "Пробіли", "upgradeToPro": "Оновлення до Pro", "upgradeToAIMax": "Розблокуйте необмежений ШІ", "storageLimitDialogTitle": "У вас вичерпано безкоштовне сховище. Оновіть, щоб розблокувати необмежений обсяг пам’яті", "aiResponseLimitTitle": "У вас закінчилися безкоштовні відповіді ШІ. Перейдіть до плану Pro або придбайте доповнення AI, щоб розблокувати необмежену кількість відповідей", "aiResponseLimitDialogTitle": "Досягнуто ліміту відповідей ШІ", "aiResponseLimit": "У вас закінчилися безкоштовні відповіді ШІ.\nПерейдіть до Налаштування -> План -> Натисніть AI Max або Pro Plan, щоб отримати більше відповідей AI", "askOwnerToUpgradeToPro": "У вашому робочому просторі закінчується безкоштовна пам’ять. Попросіть свого власника робочого місця перейти на план Pro", "askOwnerToUpgradeToAIMax": "У вашій робочій області закінчуються безкоштовні відповіді ШІ. Будь ласка, попросіть свого власника робочого простору оновити план або придбати додатки AI", "purchaseStorageSpace": "Придбайте місце для зберігання", "purchaseAIResponse": "Придбати", "askOwnerToUpgradeToLocalAI": "Попросіть власника робочої області ввімкнути ШІ на пристрої", "upgradeToAILocal": "Запустіть локальні моделі на своєму пристрої для повної конфіденційності", "upgradeToAILocalDesc": "Спілкуйтеся в чаті з PDF-файлами, вдосконалюйте свій текст і автоматично заповнюйте таблиці за допомогою локального штучного інтелекту" }, "notifications": { "export": { "markdown": "Експортовано нотатку в Markdown", "path": "Документи/flowy" } }, "contactsPage": { "title": "Контакти", "whatsHappening": "Що відбудеться на цьому тижні?", "addContact": "Додати контакт", "editContact": "Редагувати контакт" }, "button": { "ok": "Oк", "confirm": "Підтвердити", "done": "Готово", "cancel": "Скасувати", "signIn": "Увійти", "signOut": "Вийти", "complete": "Завершити", "save": "Зберегти", "generate": "Створити", "esc": "ESC", "keep": "Зберегти", "tryAgain": "Спробувати знову", "discard": "Відхилити", "replace": "Замінити", "insertBelow": "Вставити нижче", "insertAbove": "Вставте вище", "upload": "Завантажити", "edit": "Редагувати", "delete": "Видалити", "duplicate": "Дублювати", "putback": "Повернути", "update": "Оновлення", "share": "Поділіться", "removeFromFavorites": "Видалити з вибраного", "removeFromRecent": "Вилучити з останніх", "addToFavorites": "Додати в обране", "favoriteSuccessfully": "Улюблений успіх", "unfavoriteSuccessfully": "Неулюблений успіх", "duplicateSuccessfully": "Продубльовано успішно", "rename": "Перейменувати", "helpCenter": "Центр допомоги", "add": "Додати", "yes": "Так", "no": "Ні", "clear": "Очистити", "remove": "Видалити", "dontRemove": "Не видаляйте", "copyLink": "Копіювати посилання", "align": "Вирівняти", "login": "Логін", "logout": "Вийти", "deleteAccount": "Видалити аккаунт", "back": "Назад", "signInGoogle": "Продовжуйте з Google", "signInGithub": "Продовжуйте з Github", "signInDiscord": "Продовжуйте з Discord", "more": "Більше", "create": "Створити", "close": "Закрити", "next": "Далі", "previous": "Попередній", "submit": "Надіслати", "download": "Завантажити" }, "label": { "welcome": "Ласкаво просимо!", "firstName": "Ім'я", "middleName": "По батькові", "lastName": "Прізвище", "stepX": "Крок {X}" }, "oAuth": { "err": { "failedTitle": "Не вдалося підключитися до вашого облікового запису.", "failedMsg": "Будь ласка, переконайтеся, що ви завершили процес увіходу у своєму веб-переглядачі." }, "google": { "title": "УВІЙТИ ЗА ДОПОМОГОЮ GOOGLE", "instruction1": "Для імпортування ваших контактів Google вам потрібно авторизувати цю програму, використовуючи свій веб-переглядач.", "instruction2": "Скопіюйте цей код у буфер обміну, натиснувши на значок або вибравши текст:", "instruction3": "Перейдіть за наступним посиланням у своєму веб-переглядачі та введіть вищезазначений код:", "instruction4": "Натисніть кнопку нижче, коли завершите реєстрацію:" } }, "settings": { "title": "Налаштування", "popupMenuItem": { "settings": "Налаштування", "members": "Члени", "trash": "Cміття", "helpAndSupport": "Допомога та підтримка" }, "accountPage": { "menuLabel": "Мій рахунок", "title": "Мій рахунок", "general": { "title": "Назва облікового запису та зображення профілю", "changeProfilePicture": "Змінити зображення профілю" }, "email": { "title": "Електронна пошта", "actions": { "change": "Змінити електронну адресу" } }, "login": { "title": "Вхід в обліковий запис", "loginLabel": "Авторизуватися", "logoutLabel": "Вийти" } }, "workspacePage": { "menuLabel": "Робоча область", "title": "Робоча область", "description": "Налаштуйте зовнішній вигляд робочої області, тему, шрифт, макет тексту, формат дати/часу та мову.", "workspaceName": { "title": "Назва робочої області" }, "workspaceIcon": { "title": "Значок робочої області", "description": "Завантажте зображення або використовуйте емодзі для свого робочого простору. Значок відображатиметься на бічній панелі та в сповіщеннях." }, "appearance": { "title": "Зовнішній вигляд", "description": "Налаштуйте зовнішній вигляд робочої області, тему, шрифт, макет тексту, дату, час і мову.", "options": { "system": "Авто", "light": "Світлий", "dark": "Темний" } }, "resetCursorColor": { "title": "Скинути колір курсора документа", "description": "Ви впевнені, що хочете скинути колір курсору?" }, "resetSelectionColor": { "title": "Скинути колір вибору документа", "description": "Ви впевнені, що бажаєте скинути колір виділення?" }, "theme": { "title": "Тема", "description": "Виберіть попередньо встановлену тему або завантажте власну тему.", "uploadCustomThemeTooltip": "Завантажте спеціальну тему" }, "workspaceFont": { "title": "Шрифт робочої області", "noFontHint": "Шрифт не знайдено, спробуйте інший термін." }, "textDirection": { "title": "Напрямок тексту", "leftToRight": "Зліва направо", "rightToLeft": "Справа наліво", "auto": "Авто", "enableRTLItems": "Увімкнути елементи панелі інструментів RTL" }, "layoutDirection": { "title": "Напрямок макета", "leftToRight": "Зліва направо", "rightToLeft": "Справа наліво" }, "dateTime": { "title": "Дата, час", "example": "{} у {} ({})", "24HourTime": "24-годинний час", "dateFormat": { "label": "Формат дати", "local": "Місцевий", "us": "US", "iso": "ISO", "friendly": "дружній", "dmy": "Д/М/Р" } }, "language": { "title": "Мова" }, "deleteWorkspacePrompt": { "title": "Видалити робочу область", "content": "Ви впевнені, що хочете видалити цю робочу область? Цю дію неможливо скасувати, і всі опубліковані вами сторінки буде скасовано." }, "leaveWorkspacePrompt": { "title": "Залиште робочу область", "content": "Ви впевнені, що бажаєте залишити цю робочу область? Ви втратите доступ до всіх сторінок і даних на них." }, "manageWorkspace": { "title": "Керуйте робочим простором", "leaveWorkspace": "Залиште робочу область", "deleteWorkspace": "Видалити робочу область" } }, "manageDataPage": { "menuLabel": "Керувати даними", "title": "Керувати даними", "description": "Керуйте локальним сховищем даних або імпортуйте наявні дані в @:appName .", "dataStorage": { "title": "Місце зберігання файлів", "tooltip": "Місце, де зберігаються ваші файли", "actions": { "change": "Змінити шлях", "open": "Відкрити папку", "openTooltip": "Відкрити поточне розташування папки даних", "copy": "Копіювати шлях", "copiedHint": "Шлях скопійовано!", "resetTooltip": "Відновити розташування за умовчанням" }, "resetDialog": { "title": "Ти впевнений?", "description": "Скидання шляху до розташування даних за умовчанням не призведе до видалення даних. Якщо ви хочете повторно імпортувати поточні дані, вам слід спочатку скопіювати шлях вашого поточного місцезнаходження." } }, "importData": { "title": "Імпорт даних", "tooltip": "Імпорт даних із резервних копій/папок даних @:appName", "description": "Скопіюйте дані із зовнішньої папки даних @:appName", "action": "Переглянути файл" }, "encryption": { "title": "Шифрування", "tooltip": "Керуйте тим, як ваші дані зберігаються та шифруються", "descriptionNoEncryption": "Якщо ввімкнути шифрування, усі дані будуть зашифровані. Це не може бути скасоване.", "descriptionEncrypted": "Ваші дані зашифровані.", "action": "Шифрувати дані", "dialog": { "title": "Зашифрувати всі ваші дані?", "description": "Шифрування всіх ваших даних збереже ваші дані в безпеці. Цю дію НЕ можна скасувати. Ви впевнені, що бажаєте продовжити?" } }, "cache": { "title": "Очистити кеш", "description": "Допоможіть вирішити такі проблеми, як не завантаження зображення, відсутність сторінок у просторі та не завантаження шрифтів. Це не вплине на ваші дані.", "dialog": { "title": "Очистити кеш", "description": "Допоможіть вирішити такі проблеми, як не завантаження зображення, відсутність сторінок у просторі та не завантаження шрифтів. Це не вплине на ваші дані.", "successHint": "Кеш очищено!" } }, "data": { "fixYourData": "Виправте свої дані", "fixButton": "Виправити", "fixYourDataDescription": "Якщо у вас виникли проблеми з даними, ви можете спробувати їх виправити тут." } }, "shortcutsPage": { "menuLabel": "Ярлики", "title": "Ярлики", "editBindingHint": "Введіть нову прив'язку", "searchHint": "Пошук", "actions": { "resetDefault": "Скинути за замовчуванням" }, "errorPage": { "message": "Не вдалося завантажити ярлики: {}", "howToFix": "Спробуйте ще раз. Якщо проблема не зникне, зв’яжіться з GitHub." }, "resetDialog": { "title": "Скинути ярлики", "description": "Це скине всі ваші прив’язки клавіш до стандартних, ви не зможете скасувати це пізніше. Ви впевнені, що бажаєте продовжити?", "buttonLabel": "Скинути" }, "conflictDialog": { "title": "{} зараз використовується", "descriptionPrefix": "Цю комбінацію клавіш зараз використовує ", "descriptionSuffix": ". Якщо ви заміните цю комбінацію клавіш, її буде видалено з {}.", "confirmLabel": "Продовжити" }, "editTooltip": "Натисніть, щоб почати редагування сполучення клавіш", "keybindings": { "toggleToDoList": "Перемкнути список справ", "insertNewParagraphInCodeblock": "Вставте новий абзац", "pasteInCodeblock": "Вставте кодовий блок", "selectAllCodeblock": "Вибрати все", "indentLineCodeblock": "Вставте два пропуски на початку рядка", "outdentLineCodeblock": "Видалити два пропуски на початку рядка", "twoSpacesCursorCodeblock": "Вставте два пробіли біля курсору", "copy": "Вибір копії", "paste": "Вставте вміст", "cut": "Вирізати виділення", "alignLeft": "Вирівняти текст по лівому краю", "alignCenter": "Вирівняти текст по центру", "alignRight": "Вирівняти текст праворуч", "undo": "Скасувати", "redo": "Повторити", "convertToParagraph": "Перетворити блок на абзац", "backspace": "Видалити", "deleteLeftWord": "Видалити ліве слово", "deleteLeftSentence": "Видалити ліве речення", "delete": "Видалити правильний символ", "deleteMacOS": "Видалити лівий символ", "deleteRightWord": "Видалити правильне слово", "moveCursorLeft": "Перемістити курсор ліворуч", "moveCursorBeginning": "Перемістіть курсор на початок", "moveCursorLeftWord": "Перемістити курсор на одне слово вліво", "moveCursorLeftSelect": "Виберіть і перемістіть курсор ліворуч", "moveCursorBeginSelect": "Виберіть і перемістіть курсор на початок", "moveCursorLeftWordSelect": "Виберіть і перемістіть курсор ліворуч на одне слово", "moveCursorRight": "Перемістіть курсор праворуч", "moveCursorEnd": "Перемістіть курсор до кінця", "moveCursorRightWord": "Перемістіть курсор на одне слово вправо", "moveCursorRightSelect": "Виберіть і перемістіть курсор праворуч", "moveCursorEndSelect": "Виберіть і перемістіть курсор до кінця", "moveCursorRightWordSelect": "Виділіть і перемістіть курсор на одне слово вправо", "moveCursorUp": "Перемістіть курсор вгору", "moveCursorTopSelect": "Виберіть і перемістіть курсор угору", "moveCursorTop": "Перемістіть курсор вгору", "moveCursorUpSelect": "Виберіть і перемістіть курсор вгору", "moveCursorBottomSelect": "Виберіть і перемістіть курсор униз", "moveCursorBottom": "Перемістіть курсор униз", "moveCursorDown": "Перемістіть курсор вниз", "moveCursorDownSelect": "Виберіть і перемістіть курсор вниз", "home": "Прокрутіть до верху", "end": "Прокрутіть донизу", "toggleBold": "Переключити жирний шрифт", "toggleItalic": "Перемкнути курсив", "toggleUnderline": "Перемкнути підкреслення", "toggleStrikethrough": "Переключити закреслене", "toggleCode": "Перемкнути вбудований код", "toggleHighlight": "Перемкнути виділення", "showLinkMenu": "Показати меню посилань", "openInlineLink": "Відкрити вбудоване посилання", "openLinks": "Відкрити всі вибрані посилання", "indent": "Відступ", "outdent": "Відступ", "exit": "Вийти з редагування", "pageUp": "Прокрутіть одну сторінку вгору", "pageDown": "Прокрутіть одну сторінку вниз", "selectAll": "Вибрати все", "pasteWithoutFormatting": "Вставте вміст без форматування", "showEmojiPicker": "Показати засіб вибору емодзі", "enterInTableCell": "Додати розрив рядка в таблицю", "leftInTableCell": "Перемістити таблицю на одну клітинку вліво", "rightInTableCell": "Перемістити таблицю на одну клітинку праворуч", "upInTableCell": "Перейти на одну клітинку вгору в таблиці", "downInTableCell": "Перемістіть таблицю на одну клітинку вниз", "tabInTableCell": "Перейти до наступної доступної клітинки в таблиці", "shiftTabInTableCell": "Перейти до попередньо доступної клітинки в таблиці", "backSpaceInTableCell": "Зупинка на початку клітини" }, "commands": { "codeBlockNewParagraph": "Вставте новий абзац поруч із блоком коду", "codeBlockIndentLines": "Вставте два пробіли на початку рядка в блоці коду", "codeBlockOutdentLines": "Видалити два пробіли на початку рядка в блоці коду", "codeBlockAddTwoSpaces": "Вставте два пробіли в позиції курсора в блоці коду", "codeBlockSelectAll": "Виберіть весь вміст у блоці коду", "codeBlockPasteText": "Вставте текст у кодовий блок", "textAlignLeft": "Вирівняти текст по лівому краю", "textAlignCenter": "Вирівняти текст по центру", "textAlignRight": "Вирівняти текст праворуч" }, "couldNotLoadErrorMsg": "Не вдалося завантажити ярлики. Повторіть спробу", "couldNotSaveErrorMsg": "Не вдалося зберегти ярлики. Повторіть спробу" }, "aiPage": { "title": "Налаштування AI", "menuLabel": "Налаштування AI", "keys": { "enableAISearchTitle": "AI Пошук", "aiSettingsDescription": "Виберіть або налаштуйте моделі ШІ, які використовуються в @:appName . Для найкращої продуктивності ми рекомендуємо використовувати параметри моделі за замовчуванням", "loginToEnableAIFeature": "Функції ШІ вмикаються лише після входу в @:appName Cloud. Якщо у вас немає облікового запису @:appName , перейдіть до «Мого облікового запису», щоб зареєструватися", "llmModel": "Модель мови", "llmModelType": "Тип мовної моделі", "downloadLLMPrompt": "Завантажити {}", "downloadAppFlowyOfflineAI": "Завантаження офлайн-пакету AI дозволить працювати AI на вашому пристрої. Ви хочете продовжити?", "downloadLLMPromptDetail": "Завантаження {} локальної моделі займе до {} пам’яті. Ви хочете продовжити?", "downloadBigFilePrompt": "Завантаження може зайняти близько 10 хвилин", "downloadAIModelButton": "Завантажити", "downloadingModel": "Завантаження", "localAILoaded": "Локальну модель AI успішно додано та готово до використання", "localAIStart": "Розпочинається локальний AI Chat...", "localAILoading": "Локальна модель чату ШІ завантажується...", "localAIStopped": "Місцевий ШІ зупинився", "failToLoadLocalAI": "Не вдалося запустити локальний ШІ", "restartLocalAI": "Перезапустіть локальний AI", "disableLocalAITitle": "Вимкнути локальний ШІ", "disableLocalAIDescription": "Ви бажаєте вимкнути локальний ШІ?", "localAIToggleTitle": "Перемикач, щоб увімкнути або вимкнути локальний ШІ", "offlineAIInstruction1": "Дотримуйтесь", "offlineAIInstruction2": "інструкція", "offlineAIInstruction3": "увімкнути автономний AI.", "offlineAIDownload1": "Якщо ви не завантажили AppFlowy AI, будь ласка", "offlineAIDownload2": "завантажити", "offlineAIDownload3": "це перше", "activeOfflineAI": "Активний", "downloadOfflineAI": "Завантажити", "openModelDirectory": "Відкрити папку" } }, "planPage": { "menuLabel": "План", "title": "Ціновий план", "planUsage": { "title": "Підсумок використання плану", "storageLabel": "Зберігання", "storageUsage": "{} із {} Гб", "unlimitedStorageLabel": "Необмежене зберігання", "collaboratorsLabel": "Члени", "collaboratorsUsage": "{} з {}", "aiResponseLabel": "Відповіді ШІ", "aiResponseUsage": "{} з {}", "unlimitedAILabel": "Необмежена кількість відповідей", "proBadge": "Pro", "aiMaxBadge": "AI Макс", "aiOnDeviceBadge": "ШІ на пристрої для Mac", "memberProToggle": "Більше учасників і необмежений ШІ", "aiMaxToggle": "Необмежений ШІ та доступ до вдосконалених моделей", "aiOnDeviceToggle": "Локальний штучний інтелект для повної конфіденційності", "aiCredit": { "title": "Додати кредит @:appName AI", "price": "{}", "priceDescription": "за 1000 кредитів", "purchase": "Придбайте ШІ", "info": "Додайте 1000 кредитів штучного інтелекту на робочий простір і плавно інтегруйте настроюваний штучний інтелект у свій робочий процес, щоб отримати розумніші та швидші результати з до:", "infoItemOne": "10 000 відповідей на базу даних", "infoItemTwo": "1000 відповідей на робочу область" }, "currentPlan": { "bannerLabel": "Поточний план", "freeTitle": "Безкоштовно", "proTitle": "Pro", "teamTitle": "Команда", "freeInfo": "Ідеально підходить для окремих осіб до 2 учасників, щоб організувати все", "proInfo": "Ідеально підходить для малих і середніх команд до 10 учасників.", "teamInfo": "Ідеально підходить для всіх продуктивних і добре організованих команд.", "upgrade": "Змінити план", "canceledInfo": "Ваш план скасовано, вас буде переведено на безкоштовний план {}." }, "addons": { "title": "Додатки", "addLabel": "Додати", "activeLabel": "Додано", "aiMax": { "title": "AI Макс", "description": "Необмежені відповіді AI на основі GPT-4o, Claude 3.5 Sonnet тощо", "price": "{}", "priceInfo": "на користувача на місяць", "billingInfo": "рахунок виставляється щорічно або {} щомісяця" }, "aiOnDevice": { "title": "ШІ на пристрої для Mac", "description": "Запустіть Mistral 7B, LLAMA 3 та інші локальні моделі на вашому комп’ютері", "price": "{}", "priceInfo": "Оплата за користувача на місяць виставляється щорічно", "recommend": "Рекомендовано M1 або новіше", "billingInfo": "рахунок виставляється щорічно або {} щомісяця" } }, "deal": { "bannerLabel": "Новорічна угода!", "title": "Розвивайте свою команду!", "info": "Оновіть та заощаджуйте 10% на планах Pro та Team! Підвищте продуктивність робочого простору за допомогою нових потужних функцій, зокрема @:appName .", "viewPlans": "Переглянути плани" } } }, "billingPage": { "menuLabel": "Виставлення рахунків", "title": "Виставлення рахунків", "plan": { "title": "План", "freeLabel": "Безкоштовно", "proLabel": "Pro", "planButtonLabel": "Змінити план", "billingPeriod": "Розрахунковий період", "periodButtonLabel": "Період редагування" }, "paymentDetails": { "title": "Платіжні реквізити", "methodLabel": "Спосіб оплати", "methodButtonLabel": "Метод редагування" }, "addons": { "title": "Додатки", "addLabel": "Додати", "removeLabel": "Видалити", "renewLabel": "Відновити", "aiMax": { "label": "AI Макс", "description": "Розблокуйте необмежену кількість штучного інтелекту та вдосконалених моделей", "activeDescription": "Наступний рахунок-фактура має бути виставлено {}", "canceledDescription": "AI Max буде доступний до {}" }, "aiOnDevice": { "label": "ШІ на пристрої для Mac", "description": "Розблокуйте необмежений ШІ офлайн на своєму пристрої", "activeDescription": "Наступний рахунок-фактура має бути виставлено {}", "canceledDescription": "AI On-device для Mac буде доступний до {}" }, "removeDialog": { "title": "Видалити {}", "description": "Ви впевнені, що хочете видалити {plan}? Ви негайно втратите доступ до функцій і переваг {plan}." } }, "currentPeriodBadge": "ПОТОЧНИЙ", "changePeriod": "Період зміни", "planPeriod": "{} період", "monthlyInterval": "Щомісяця", "monthlyPriceInfo": "за місце сплачується щомісяця", "annualInterval": "Щорічно", "annualPriceInfo": "за місце, що виставляється щорічно" }, "comparePlanDialog": { "title": "Порівняйте та виберіть план", "planFeatures": "План\nособливості", "current": "Поточний", "actions": { "upgrade": "Оновлення", "downgrade": "Понизити", "current": "Поточний" }, "freePlan": { "title": "Безкоштовно", "description": "Для окремих осіб до 2 учасників, щоб організувати все", "price": "{}", "priceInfo": "Безплатний назавжди" }, "proPlan": { "title": "Pro", "description": "Для невеликих команд для управління проектами та знаннями команди", "price": "{}", "priceInfo": "на користувача на місяць\nвиставляється щорічно\n{} виставляється щомісяця" }, "planLabels": { "itemOne": "Робочі області", "itemTwo": "Члени", "itemThree": "Зберігання", "itemFour": "Співпраця в реальному часі", "itemFive": "Мобільний додаток", "itemSix": "Відповіді ШІ", "itemSeven": "Спеціальний простір імен", "itemFileUpload": "Завантаження файлів", "tooltipSix": "Термін служби означає кількість відповідей, які ніколи не скидаються", "intelligentSearch": "Інтелектуальний пошук", "tooltipSeven": "Дозволяє налаштувати частину URL-адреси для вашої робочої області" }, "freeLabels": { "itemOne": "сплачується за робоче місце", "itemTwo": "До 2", "itemThree": "5 ГБ", "itemFour": "так", "itemFive": "так", "itemSix": "10 років життя", "itemFileUpload": "До 7 Мб", "intelligentSearch": "Інтелектуальний пошук" }, "proLabels": { "itemOne": "Оплата за робоче місце", "itemTwo": "До 10", "itemThree": "Необмежений", "itemFour": "так", "itemFive": "так", "itemSix": "Необмежений", "itemFileUpload": "Необмежений", "intelligentSearch": "Інтелектуальний пошук" }, "paymentSuccess": { "title": "Тепер ви на плані {}!", "description": "Ваш платіж успішно оброблено, і ваш план оновлено до @:appName {}. Ви можете переглянути деталі свого плану на сторінці План" }, "downgradeDialog": { "title": "Ви впевнені, що хочете понизити свій план?", "description": "Пониження вашого плану призведе до повернення до безкоштовного плану. Учасники можуть втратити доступ до цього робочого простору, і вам може знадобитися звільнити місце, щоб досягти лімітів пам’яті безкоштовного плану.", "downgradeLabel": "Пониження плану" } }, "cancelSurveyDialog": { "title": "Шкода, що ви йдете", "description": "Нам шкода, що ви йдете. Ми будемо раді почути ваш відгук, щоб допомогти нам покращити @:appName . Знайдіть хвилинку, щоб відповісти на кілька запитань.", "commonOther": "Інший", "otherHint": "Напишіть свою відповідь тут", "questionOne": { "question": "Що спонукало вас скасувати підписку на AppFlowy Pro?", "answerOne": "Зависока вартість", "answerTwo": "Характеристики не виправдали очікувань", "answerThree": "Знайшов кращу альтернативу", "answerFour": "Використовував недостатньо, щоб виправдати витрати", "answerFive": "Проблема з обслуговуванням або технічні проблеми" }, "questionTwo": { "question": "Наскільки ймовірно, що ви подумаєте про повторну підписку на AppFlowy Pro у майбутньому?", "answerOne": "Ймовірно", "answerTwo": "Певною мірою ймовірно", "answerThree": "Не впевнений", "answerFour": "Малоймовірно", "answerFive": "Дуже малоймовірно" }, "questionThree": { "question": "Яку функцію Pro ви цінували найбільше під час підписки?", "answerOne": "Багатокористувацька співпраця", "answerTwo": "Довша історія версій", "answerThree": "Необмежена кількість відповідей ШІ", "answerFour": "Доступ до локальних моделей ШІ" }, "questionFour": { "question": "Як би ви описали свій загальний досвід роботи з AppFlowy?", "answerOne": "Чудово", "answerTwo": "Добре", "answerThree": "Середній", "answerFour": "Нижче середнього", "answerFive": "Незадоволений" } }, "common": { "reset": "Скинути" }, "menu": { "appearance": "Вигляд", "language": "Мова", "user": "Користувач", "files": "Файли", "notifications": "Сповіщення", "open": "Відкрити налаштування", "logout": "Вийти", "logoutPrompt": "Ви впевнені, що хочете вийти?", "selfEncryptionLogoutPrompt": "Ви впевнені, що хочете вийти? Будь ласка, переконайтеся, що ви скопіювали секрет шифрування", "syncSetting": "Налаштування синхронізації", "cloudSettings": "Налаштування хмари", "enableSync": "Увімкнути синхронізацію", "enableEncrypt": "Шифрувати дані", "cloudURL": "Базовий URL", "invalidCloudURLScheme": "Недійсна схема", "cloudServerType": "Хмарний сервер", "cloudServerTypeTip": "Зауважте, що після перемикання хмарного сервера ваш поточний обліковий запис може вийти з системи", "cloudLocal": "Місцевий", "cloudAppFlowy": "@:appName Cloud Beta", "cloudAppFlowySelfHost": "@:appName Cloud на власному сервері", "appFlowyCloudUrlCanNotBeEmpty": "URL-адреса хмари не може бути пустою", "clickToCopy": "Натисніть, щоб скопіювати", "selfHostStart": "Якщо у вас немає сервера, зверніться до", "selfHostContent": "документ", "selfHostEnd": "для вказівок щодо самостійного розміщення власного сервера", "cloudURLHint": "Введіть базову URL-адресу вашого сервера", "cloudWSURL": "URL-адреса Websocket", "cloudWSURLHint": "Введіть адресу веб-сокета вашого сервера", "restartApp": "Перезапустіть", "restartAppTip": "Перезапустіть програму, щоб зміни набули чинності. Зауважте, що це може привести до виходу з вашого поточного облікового запису.", "changeServerTip": "Після зміни сервера необхідно натиснути кнопку перезавантаження, щоб зміни набули чинності", "enableEncryptPrompt": "Активуйте шифрування для захисту ваших даних за допомогою цього секретного ключа. Зберігайте його надійно; після увімкнення його неможливо вимкнути. Якщо втрачено, ваші дані стають недоступними. Клацніть, щоб скопіювати", "inputEncryptPrompt": "Будь ласка, введіть свій секрет для шифрування для", "clickToCopySecret": "Клацніть, щоб скопіювати секрет", "configServerSetting": "Налаштуйте параметри свого сервера", "configServerGuide": "Вибравши `Швидкий старт`, перейдіть до `Налаштування`, а потім до «Налаштування хмари», щоб налаштувати свій автономний сервер.", "inputTextFieldHint": "Ваш секрет", "historicalUserList": "Історія входу користувача", "historicalUserListTooltip": "У цьому списку відображаються ваші анонімні облікові записи. Ви можете клацнути на обліковий запис, щоб переглянути його деталі. Анонімні облікові записи створюються, натискаючи кнопку 'Розпочати роботу'", "openHistoricalUser": "Клацніть, щоб відкрити анонімний обліковий запис", "customPathPrompt": "Зберігання папки даних @:appName у папці, що синхронізується з хмарою, як-от Google Drive, може становити ризик. Якщо база даних у цій папці доступна або змінена з кількох місць одночасно, це може призвести до конфліктів синхронізації та можливого пошкодження даних", "importAppFlowyData": "Імпорт даних із зовнішньої @:appName", "importingAppFlowyDataTip": "Виконується імпорт даних. Будь ласка, не закривайте додаток", "importAppFlowyDataDescription": "Скопіюйте дані із зовнішньої папки @:appName та імпортуйте їх у поточну папку даних AppFlowy", "importSuccess": "Папку даних @:appName успішно імпортовано", "importFailed": "Не вдалося імпортувати папку даних @:appName", "importGuide": "Щоб отримати додаткові відомості, перегляньте документ, на який посилається" }, "notifications": { "enableNotifications": { "label": "Увімкнути сповіщення", "hint": "Вимкніть, щоб локальні сповіщення не з’являлися." }, "showNotificationsIcon": { "label": "Показати значок сповіщень", "hint": "Вимкніть, щоб приховати піктограму сповіщень на бічній панелі." }, "archiveNotifications": { "allSuccess": "Усі сповіщення успішно заархівовано", "success": "Сповіщення успішно заархівовано" }, "markAsReadNotifications": { "allSuccess": "Усі позначені як успішно прочитані", "success": "Позначено як успішно прочитане" }, "action": { "markAsRead": "Відзначити як прочитане", "multipleChoice": "Виберіть більше", "archive": "Архів" }, "settings": { "settings": "Налаштування", "markAllAsRead": "Позначити все як прочитане", "archiveAll": "Архівувати все" }, "emptyInbox": { "title": "Ще немає сповіщень", "description": "Тут ви отримуватимете сповіщення про @згадки" }, "emptyUnread": { "title": "Немає непрочитаних сповіщень", "description": "Ви все наздогнали!" }, "emptyArchived": { "title": "Немає архівованих сповіщень", "description": "Ви ще не заархівували жодного сповіщення" }, "tabs": { "inbox": "Вхідні", "unread": "Непрочитаний", "archived": "Архівовано" }, "refreshSuccess": "Сповіщення успішно оновлено", "titles": { "notifications": "Сповіщення", "reminder": "Нагадування" } }, "appearance": { "resetSetting": "Скинути це налаштування", "fontFamily": { "label": "Шрифт", "search": "Пошук", "defaultFont": "Система" }, "themeMode": { "label": "Режим теми", "light": "Світлий режим", "dark": "Темний режим", "system": "Системна" }, "fontScaleFactor": "Коефіцієнт масштабування шрифту", "documentSettings": { "cursorColor": "Колір курсору документа", "selectionColor": "Колір виділення документа", "pickColor": "Виберіть колір", "colorShade": "Колірний відтінок", "opacity": "Непрозорість", "hexEmptyError": "Шістнадцяткове поле кольору не може бути порожнім", "hexLengthError": "Шістнадцяткове значення має містити 6 цифр", "hexInvalidError": "Недійсне шістнадцяткове значення", "opacityEmptyError": "Непрозорість не може бути пустою", "opacityRangeError": "Непрозорість має бути від 1 до 100", "app": "Додаток", "flowy": "Текучий", "apply": "Застосувати" }, "layoutDirection": { "label": "Напрямок макету", "hint": "Контролюйте напрямок контенту на вашому екрані, зліва направо або справа наліво.", "ltr": "Зліва направо", "rtl": "Справа наліво" }, "textDirection": { "label": "Текстовий напрямок за замовчуванням", "hint": "Вкажіть, чи має текст починатися зліва чи справа як за замовчуванням.", "ltr": "Зліва направо", "rtl": "Справа наліво", "auto": "АВТО", "fallback": "Такий же, як і напрямок макету" }, "themeUpload": { "button": "Завантажити", "uploadTheme": "Завантажити тему", "description": "Завантажте свою власну тему @:appName, скориставшись кнопкою нижче.", "loading": "Будь ласка, зачекайте, поки ми перевіряємо та завантажуємо вашу тему...", "uploadSuccess": "Вашу тему успішно завантажено", "deletionFailure": "Не вдалося видалити тему. Спробуйте видалити її вручну.", "filePickerDialogTitle": "Виберіть файл .flowy_plugin", "urlUploadFailure": "Не вдалося відкрити URL: {}", "failure": "Тему, яка була завантажена, має неправильний формат." }, "theme": "Тема", "builtInsLabel": "Вбудовані теми", "pluginsLabel": "Плагіни", "dateFormat": { "label": "Формат дати", "local": "Локальний", "us": "US", "iso": "ISO", "friendly": "Дружній", "dmy": "Д/М/Р" }, "timeFormat": { "label": "Формат часу", "twelveHour": "Дванадцятигодинний", "twentyFourHour": "Двадцять чотири години" }, "showNamingDialogWhenCreatingPage": "Показувати діалогове вікно імені при створенні сторінки", "enableRTLToolbarItems": "Увімкнути елементи панелі інструментів RTL", "members": { "title": "Налаштування учасників", "inviteMembers": "Запросити учасників", "inviteHint": "Запросити електронною поштою", "sendInvite": "Надіслати запрошення", "copyInviteLink": "Копіювати посилання для запрошення", "label": "Члени", "user": "Користувач", "role": "Роль", "removeFromWorkspace": "Видалити з робочої області", "removeFromWorkspaceSuccess": "Успішно видалено з робочої області", "removeFromWorkspaceFailed": "Не вдалося видалити з робочої області", "owner": "Власник", "guest": "Гість", "member": "Член", "memberHintText": "Учасник може читати та редагувати сторінки", "guestHintText": "Гість може читати, реагувати, коментувати та редагувати певні сторінки з дозволу.", "emailInvalidError": "Недійсна електронна адреса, будь ласка, перевірте та повторіть спробу", "emailSent": "Електронний лист надіслано, перевірте папку \"Вхідні\".", "members": "членів", "membersCount": { "zero": "{} учасників", "one": "{} член", "other": "{} учасників" }, "inviteFailedDialogTitle": "Не вдалося надіслати запрошення", "inviteFailedMemberLimit": "Досягнуто ліміту учасників. Оновіть його, щоб запросити більше учасників.", "inviteFailedMemberLimitMobile": "Ваша робоча область досягла ліміту учасників. Оновіть комп’ютер, щоб розблокувати більше функцій.", "memberLimitExceeded": "Досягнуто обмеження кількості учасників, будь ласка, запросіть більше учасників ", "memberLimitExceededUpgrade": "оновлення", "memberLimitExceededPro": "Досягнуто ліміту учасників, якщо вам потрібно більше учасників, зв’яжіться ", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Не вдалося додати учасника", "addMemberSuccess": "Учасника успішно додано", "removeMember": "Видалити учасника", "areYouSureToRemoveMember": "Ви впевнені, що хочете видалити цього учасника?", "inviteMemberSuccess": "Запрошення успішно надіслано", "failedToInviteMember": "Не вдалося запросити учасника", "workspaceMembersError": "На жаль, сталася помилка", "workspaceMembersErrorDescription": "Не вдалося завантажити список учасників. Спробуйте пізніше" } }, "files": { "copy": "Копіювати", "defaultLocation": "Де зараз зберігаються ваші дані", "exportData": "Експортуйте свої дані", "doubleTapToCopy": "Подвійний натиск для копіювання шляху", "restoreLocation": "Відновити до шляху за замовчуванням @:appName", "customizeLocation": "Відкрити іншу папку", "restartApp": "Будь ласка, перезапустіть програму для врахування змін.", "exportDatabase": "Експорт бази даних", "selectFiles": "Виберіть файли, які потрібно експортувати", "selectAll": "Вибрати всі", "deselectAll": "Зняти виділення з усіх", "createNewFolder": "Створити нову папку", "createNewFolderDesc": "Повідомте нам, де ви хочете зберегти свої дані", "defineWhereYourDataIsStored": "Визначте, де зберігаються ваші дані", "open": "Відкрити", "openFolder": "Відкрити існуючу папку", "openFolderDesc": "Читати та записувати в вашу існуючу папку @:appName", "folderHintText": "ім'я папки", "location": "Створення нової папки", "locationDesc": "Оберіть ім'я для папки з даними @:appName", "browser": "Перегляд", "create": "Створити", "set": "Встановити", "folderPath": "Шлях до зберігання вашої папки", "locationCannotBeEmpty": "Шлях не може бути порожнім", "pathCopiedSnackbar": "Шлях збереження файлів скопійовано в буфер обміну!", "changeLocationTooltips": "Змінити каталог даних", "change": "Змінити", "openLocationTooltips": "Відкрити інший каталог даних", "openCurrentDataFolder": "Відкрити поточний каталог даних", "recoverLocationTooltips": "Скинути до каталогу даних за замовчуванням @:appName", "exportFileSuccess": "Файл успішно експортовано!", "exportFileFail": "Помилка експорту файлу!", "export": "Експорт", "clearCache": "Очистити кеш", "clearCacheDesc": "Якщо у вас виникли проблеми із завантаженням зображень або неправильним відображенням шрифтів, спробуйте очистити кеш. Ця дія не видалить ваші дані користувача.", "areYouSureToClearCache": "Ви впевнені, що хочете очистити кеш?", "clearCacheSuccess": "Кеш успішно очищено!" }, "user": { "name": "Ім'я", "email": "Електронна пошта", "tooltipSelectIcon": "Обрати значок", "selectAnIcon": "Обрати значок", "pleaseInputYourOpenAIKey": "Будь ласка, введіть ваш ключ AI", "clickToLogout": "Натисніть, щоб вийти з поточного облікового запису", "pleaseInputYourStabilityAIKey": "будь ласка, введіть ключ стабільності ШІ" }, "mobile": { "personalInfo": "Персональна інформація", "username": "Ім'я користувача", "usernameEmptyError": "Ім'я користувача не може бути пустим", "about": "Про", "pushNotifications": "Push-сповіщення", "support": "Підтримка", "joinDiscord": "Приєднуйтесь до нас у Discord", "privacyPolicy": "Політика конфіденційності", "userAgreement": "Угода користувача", "termsAndConditions": "Правила та умови", "userprofileError": "Не вдалося завантажити профіль користувача", "userprofileErrorDescription": "Будь ласка, спробуйте вийти та увійти знову, щоб перевірити, чи проблема не зникає.", "selectLayout": "Виберіть макет", "selectStartingDay": "Виберіть день початку", "version": "Версія" }, "shortcuts": { "shortcutsLabel": "Сполучення клавіш", "command": "Команда", "keyBinding": "Сполучення клавіш", "addNewCommand": "Додати нову команду", "updateShortcutStep": "Натисніть бажану комбінацію клавіш і натисніть ENTER", "shortcutIsAlreadyUsed": "Це скорочення вже використовується для: {conflict}", "resetToDefault": "Скинути до стандартних налаштувань скорочень", "couldNotLoadErrorMsg": "Не вдалося завантажити скорочення. Спробуйте ще раз", "couldNotSaveErrorMsg": "Не вдалося зберегти скорочення. Спробуйте ще раз" } }, "grid": { "deleteView": "Ви впевнені, що хочете видалити цей вигляд?", "createView": "Нова", "title": { "placeholder": "Без назви" }, "settings": { "filter": "Фільтр", "sort": "Сортування", "sortBy": "Сортувати за", "properties": "Властивості", "reorderPropertiesTooltip": "Перетягніть для зміни порядку властивостей", "group": "Групувати", "addFilter": "Додати фільтр", "deleteFilter": "Видалити фільтр", "filterBy": "Фільтрувати за...", "typeAValue": "Введіть значення...", "layout": "Макет", "databaseLayout": "Вид бази даних", "viewList": { "zero": "0 переглядів", "one": "{count} перегляд", "other": "{count} переглядів" }, "editView": "Редагувати перегляд", "boardSettings": "Налаштування дошки", "calendarSettings": "Налаштування календаря", "createView": "Новий вигляд", "duplicateView": "Дубльований перегляд", "deleteView": "Видалити перегляд", "numberOfVisibleFields": "показано {}" }, "textFilter": { "contains": "Містить", "doesNotContain": "Не містить", "endsWith": "Закінчується на", "startWith": "Починається з", "is": "Є", "isNot": "Не є", "isEmpty": "Порожнє", "isNotEmpty": "Не порожнє", "choicechipPrefix": { "isNot": "Не", "startWith": "Починається з", "endWith": "Закінчується на", "isEmpty": "порожнє", "isNotEmpty": "не порожнє" } }, "checkboxFilter": { "isChecked": "Відзначено", "isUnchecked": "Не відзначено", "choicechipPrefix": { "is": "є" } }, "checklistFilter": { "isComplete": "є завершено", "isIncomplted": "є незавершено" }, "selectOptionFilter": { "is": "є", "isNot": "не є", "contains": "Містить", "doesNotContain": "Не містить", "isEmpty": "Порожнє", "isNotEmpty": "Не порожнє" }, "dateFilter": { "is": "Є", "before": "Є раніше", "after": "Є після", "onOrBefore": "Увімкнено або раніше", "onOrAfter": "Увімкнено або після", "between": "Знаходиться між", "empty": "Пусто", "notEmpty": "Не порожній", "choicechipPrefix": { "before": "Раніше", "after": "Після", "onOrBefore": "На або раніше", "onOrAfter": "На або після", "isEmpty": "Пусто", "isNotEmpty": "Не порожній" } }, "numberFilter": { "equal": "Дорівнює", "notEqual": "Не дорівнює", "lessThan": "Менше ніж", "greaterThan": "Більше ніж", "lessThanOrEqualTo": "Менше або дорівнює", "greaterThanOrEqualTo": "Більше або дорівнює", "isEmpty": "Пусто", "isNotEmpty": "Не порожній" }, "field": { "hide": "Сховати", "show": "Показати", "insertLeft": "Вставити зліва", "insertRight": "Вставити справа", "duplicate": "Дублювати", "delete": "Видалити", "wrapCellContent": "Обернути текст", "clear": "Очистити комірки", "textFieldName": "Текст", "checkboxFieldName": "Чекбокс", "dateFieldName": "Дата", "updatedAtFieldName": "Остання зміна", "createdAtFieldName": "Створено", "numberFieldName": "Число", "singleSelectFieldName": "Вибір", "multiSelectFieldName": "Вибір кількох", "urlFieldName": "URL", "checklistFieldName": "Чек-лист", "relationFieldName": "Відношення", "summaryFieldName": "AI Резюме", "timeFieldName": "Час", "translateFieldName": "ШІ Перекладач", "translateTo": "Перекласти на", "numberFormat": "Формат числа", "dateFormat": "Формат дати", "includeTime": "Включити час", "isRange": "Кінцева дата", "dateFormatFriendly": "Місяць День, Рік", "dateFormatISO": "Рік-Місяць-День", "dateFormatLocal": "Місяць/День/Рік", "dateFormatUS": "Рік/Місяць/День", "dateFormatDayMonthYear": "День/Місяць/Рік", "timeFormat": "Формат часу", "invalidTimeFormat": "Неправильний формат", "timeFormatTwelveHour": "12 годин", "timeFormatTwentyFourHour": "24 години", "clearDate": "Очистити дату", "dateTime": "Дата, час", "startDateTime": "Дата початку час", "endDateTime": "Кінцева дата час", "failedToLoadDate": "Не вдалося завантажити значення дати", "selectTime": "Виберіть час", "selectDate": "Виберіть дату", "visibility": "Видимість", "propertyType": "Тип власності", "addSelectOption": "Додати опцію", "typeANewOption": "Введіть новий параметр", "optionTitle": "Опції", "addOption": "Додати опцію", "editProperty": "Редагувати властивість", "newProperty": "Додати колонку", "openRowDocument": "Відкрити як сторінку", "deleteFieldPromptMessage": "Ви впевнені? Ця властивість буде видалена", "clearFieldPromptMessage": "Ти впевнений? Усі клітинки в цьому стовпці будуть порожні", "newColumn": "Нова колонка", "format": "Формат", "reminderOnDateTooltip": "Ця клітинка має заплановане нагадування", "optionAlreadyExist": "Варіант вже існує" }, "rowPage": { "newField": "Додати нове поле", "fieldDragElementTooltip": "Натисніть, щоб відкрити меню", "showHiddenFields": { "one": "Показати {} приховане поле", "many": "Показати {} приховані поля", "other": "Показати {} приховані полі" }, "hideHiddenFields": { "one": "Сховати {} приховане поле", "many": "Сховати {} приховані поля", "other": "Сховати {} приховані поля" }, "openAsFullPage": "Відкрити як повну сторінку", "moreRowActions": "Більше дій рядків" }, "sort": { "ascending": "У висхідному порядку", "descending": "У спадному порядку", "by": "За", "empty": "Немає активних сортувань", "cannotFindCreatableField": "Не вдається знайти відповідне поле для сортування", "deleteAllSorts": "Видалити всі сортування", "addSort": "Додати сортування", "removeSorting": "Ви хочете видалити сортування?", "fieldInUse": "Ви вже сортуєте за цим полем" }, "row": { "duplicate": "Дублювати", "delete": "Видалити", "titlePlaceholder": "Без назви", "textPlaceholder": "Порожньо", "copyProperty": "Скопійовано властивість в буфер обміну", "count": "Кількість", "newRow": "Новий рядок", "action": "Дія", "add": "Клацніть, щоб додати нижче", "drag": "Перетягніть для переміщення", "deleteRowPrompt": "Ви впевнені, що хочете видалити цей рядок? Цю дію не можна скасувати", "deleteCardPrompt": "Ви впевнені, що хочете видалити цю картку? Цю дію не можна скасувати", "dragAndClick": "Перетягніть, щоб перемістити, натисніть, щоб відкрити меню", "insertRecordAbove": "Вставте запис вище", "insertRecordBelow": "Вставте запис нижче", "noContent": "Немає вмісту" }, "selectOption": { "create": "Створити", "purpleColor": "Фіолетовий", "pinkColor": "Рожевий", "lightPinkColor": "Світло-рожевий", "orangeColor": "Помаранчевий", "yellowColor": "Жовтий", "limeColor": "Лаймовий", "greenColor": "Зелений", "aquaColor": "Аква", "blueColor": "Синій", "deleteTag": "Видалити тег", "colorPanelTitle": "Кольори", "panelTitle": "Виберіть опцію або створіть одну", "searchOption": "Шукати опцію", "searchOrCreateOption": "Шукати чи створити опцію...", "createNew": "Створити нову", "orSelectOne": "Або виберіть опцію", "typeANewOption": "Введіть новий параметр", "tagName": "Назва тегу" }, "checklist": { "taskHint": "Опис завдання", "addNew": "Додати нове завдання", "submitNewTask": "Створити", "hideComplete": "Сховати виконані завдання", "showComplete": "Показати всі завдання" }, "url": { "launch": "Відкрити посилання в браузері", "copy": "Копіювати посилання в буфер обміну", "textFieldHint": "Введіть URL" }, "relation": { "relatedDatabasePlaceLabel": "Пов'язана база даних", "relatedDatabasePlaceholder": "Жодного", "inRelatedDatabase": "В", "rowSearchTextFieldPlaceholder": "Пошук", "noDatabaseSelected": "Базу даних не вибрано, будь ласка, спочатку виберіть одну зі списку нижче:", "emptySearchResult": "Записів не знайдено", "linkedRowListLabel": "{count} пов’язаних рядків", "unlinkedRowListLabel": "Зв'яжіть інший ряд" }, "menuName": "Сітка", "referencedGridPrefix": "Вигляд", "calculate": "Обчислити", "calculationTypeLabel": { "none": "Жодного", "average": "Середній", "max": "Макс", "median": "Медіана", "min": "Мін", "sum": "Сума", "count": "Рахувати", "countEmpty": "Рахунок порожній", "countEmptyShort": "ПУСТИЙ", "countNonEmpty": "Граф не пустий", "countNonEmptyShort": "ЗАПОВНЕНО" } }, "document": { "menuName": "Документ", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Виберіть дошку для посилання", "createANewBoard": "Створити нову дошку" }, "grid": { "selectAGridToLinkTo": "Виберіть сітку для посилання", "createANewGrid": "Створити нову сітку" }, "calendar": { "selectACalendarToLinkTo": "Виберіть календар для посилання", "createANewCalendar": "Створити новий календар" }, "document": { "selectADocumentToLinkTo": "Виберіть документ для посилання" }, "name": { "text": "Текст", "heading1": "Заголовок 1", "heading2": "Заголовок 2", "heading3": "Заголовок 3", "image": "Зображення", "bulletedList": "Маркірований список", "numberedList": "Нумерований список", "todoList": "Список справ", "doc": "Док", "linkedDoc": "Посилання на сторінку", "grid": "Сітка", "linkedGrid": "Зв'язана сітка", "kanban": "Канбан", "linkedKanban": "Пов’язаний канбан", "calendar": "Календар", "linkedCalendar": "Пов’язаний календар", "quote": "Цитата", "divider": "Роздільник", "table": "Таблиця", "callout": "Виноска", "outline": "Контур", "mathEquation": "Математичне рівняння", "code": "Код", "toggleList": "Переключити список", "emoji": "Emoji", "aiWriter": "ШІ Письменник", "dateOrReminder": "Дата або нагадування", "photoGallery": "Фотогалерея", "file": "Файл", "checkbox": "Прапорець" } }, "selectionMenu": { "outline": "Контур", "codeBlock": "Блок коду" }, "plugins": { "referencedBoard": "Пов'язані дошки", "referencedGrid": "Пов'язані сітки", "referencedCalendar": "Календар посилань", "referencedDocument": "Посилальний документ", "autoGeneratorMenuItemName": "ШІ Письменник", "autoGeneratorTitleName": "AI: Запитайте штучний інтелект написати будь-що...", "autoGeneratorLearnMore": "Дізнатися більше", "autoGeneratorGenerate": "Генерувати", "autoGeneratorHintText": "Запитайте AI...", "autoGeneratorCantGetOpenAIKey": "Не вдається отримати ключ AI", "autoGeneratorRewrite": "Переписати", "smartEdit": "Запитай ШІ", "aI": "ШІ", "smartEditFixSpelling": "Виправити правопис", "warning": "⚠️ Відповіді ШІ можуть бути неточними або вводити в оману.", "smartEditSummarize": "Підсумувати", "smartEditImproveWriting": "Покращити написання", "smartEditMakeLonger": "Зробити довше", "smartEditCouldNotFetchResult": "Не вдалося отримати результат від ШІ", "smartEditCouldNotFetchKey": "Не вдалося отримати ключ ШІ", "smartEditDisabled": "Підключіть ШІ в Налаштуваннях", "appflowyAIEditDisabled": "Увійдіть, щоб увімкнути функції ШІ", "discardResponse": "Ви хочете відкинути відповіді AI?", "createInlineMathEquation": "Створити рівняння", "fonts": "Шрифти", "insertDate": "Вставте дату", "emoji": "Emoji", "toggleList": "Перемкнути список", "quoteList": "Цитатний список", "numberedList": "Нумерований список", "bulletedList": "Маркірований список", "todoList": "Список справ", "callout": "Виноска", "cover": { "changeCover": "Змінити Обгортку", "colors": "Кольори", "images": "Зображення", "clearAll": "Очистити все", "abstract": "Абстракція", "addCover": "Додати Обгортку", "addLocalImage": "Додати локальне зображення", "invalidImageUrl": "Невірна URL адреса зображення", "failedToAddImageToGallery": "Не вдалося додати зображення до галереї", "enterImageUrl": "Введіть URL адресу зображення", "add": "Додати", "back": "Назад", "saveToGallery": "Зберегти в галерею", "removeIcon": "Видалити іконку", "pasteImageUrl": "Вставити URL адресу зображення", "or": "АБО", "pickFromFiles": "Вибрати з власних файлів", "couldNotFetchImage": "Не вдалося отримати зображення", "imageSavingFailed": "Не вдалося зберегти зображення", "addIcon": "Додати іконку", "changeIcon": "Змінити значок", "coverRemoveAlert": "Це буде видалено з обгортки після видалення.", "alertDialogConfirmation": "Ви впевнені, що хочете продовжити?" }, "mathEquation": { "name": "Математичне рівняння", "addMathEquation": "Додати математичне рівняння", "editMathEquation": "Редагувати математичне рівняння" }, "optionAction": { "click": "Клацніть", "toOpenMenu": " для відкриття меню", "delete": "Видалити", "duplicate": "Дублювати", "turnInto": "Перетворити у", "moveUp": "Перемістити вгору", "moveDown": "Перемістити вниз", "color": "Колір", "align": "Вирівнювання", "left": "Ліворуч", "center": "По центру", "right": "По праву", "defaultColor": "За замовчуванням", "depth": "Глибина" }, "image": { "addAnImage": "Додати зображення", "copiedToPasteBoard": "Посилання на зображення скопійовано в буфер обміну", "addAnImageDesktop": "Перетягніть зображення або натисніть, щоб додати зображення", "addAnImageMobile": "Натисніть, щоб додати одне або кілька зображень", "dropImageToInsert": "Перетягніть зображення, щоб вставити", "imageUploadFailed": "Помилка завантаження зображення", "imageDownloadFailed": "Помилка завантаження зображення, повторіть спробу", "imageDownloadFailedToken": "Помилка завантаження зображення через відсутність маркера користувача. Повторіть спробу", "errorCode": "Код помилки" }, "photoGallery": { "name": "Фотогалерея", "imageKeyword": "зображення", "imageGalleryKeyword": "галерея зображень", "photoKeyword": "фото", "photoBrowserKeyword": "браузер фотографій", "galleryKeyword": "галерея", "addImageTooltip": "Додайте зображення", "changeLayoutTooltip": "Змінити макет", "browserLayout": "Браузер", "gridLayout": "Сітка", "deleteBlockTooltip": "Видалити всю галерею" }, "math": { "copiedToPasteBoard": "Математичне рівняння скопійовано в буфер обміну" }, "urlPreview": { "copiedToPasteBoard": "Посилання скопійовано в буфер обміну", "convertToLink": "Перетворити на вбудоване посилання" }, "outline": { "addHeadingToCreateOutline": "Додайте заголовки, щоб створити зміст.", "noMatchHeadings": "Відповідних заголовків не знайдено." }, "table": { "addAfter": "Додати після", "addBefore": "Додати перед", "delete": "Видалити", "clear": "Очистити вміст", "duplicate": "Дублювати", "bgColor": "Колір фону" }, "contextMenu": { "copy": "Копіювати", "cut": "Вирізати", "paste": "Вставити" }, "action": "Дії", "database": { "selectDataSource": "Виберіть джерело даних", "noDataSource": "Немає джерела даних", "selectADataSource": "Виберіть джерело даних", "toContinue": "продовжувати", "newDatabase": "Нова база даних", "linkToDatabase": "Посилання на базу даних" }, "date": "Дата", "video": { "label": "Відео", "emptyLabel": "Додайте відео", "placeholder": "Вставте посилання на відео", "copiedToPasteBoard": "Посилання на відео скопійовано в буфер обміну", "insertVideo": "Додайте відео", "invalidVideoUrl": "Вихідна URL-адреса ще не підтримується.", "invalidVideoUrlYouTube": "YouTube ще не підтримується.", "supportedFormats": "Підтримувані формати: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "Файл", "uploadTab": "Завантажити", "networkTab": "Інтегрувати посилання", "placeholderText": "Натисніть або перетягніть, щоб завантажити файл", "placeholderDragging": "Перетягніть файл, щоб завантажити", "dropFileToUpload": "Перетягніть файл, щоб завантажити", "fileUploadHint": "Перетягніть файл сюди\nабо натисніть, щоб вибрати файл.", "networkHint": "Введіть посилання на файл", "networkUrlInvalid": "Недійсна URL-адреса, виправте URL-адресу та повторіть спробу", "networkAction": "Вставити посилання на файл", "fileTooBigError": "Розмір файлу завеликий, будь ласка, завантажте файл розміром менше 10 МБ", "renameFile": { "title": "Перейменувати файл", "description": "Введіть нову назву для цього файлу", "nameEmptyError": "Ім'я файлу не може бути пустим." }, "uploadedAt": "Завантажено {}", "linkedAt": "Посилання додано {}" } }, "outlineBlock": { "placeholder": "Зміст" }, "textBlock": { "placeholder": "Тип '/' для команд" }, "title": { "placeholder": "Без назви" }, "imageBlock": { "placeholder": "Клацніть, щоб додати зображення", "upload": { "label": "Завантажити", "placeholder": "Клацніть, щоб завантажити зображення" }, "url": { "label": "URL зображення", "placeholder": "Введіть URL зображення" }, "ai": { "label": "Створення зображення за допомогою AI", "placeholder": "Будь ласка, введіть підказку для AI для створення зображення" }, "stability_ai": { "label": "Створюйте зображення за допомогою Stability AI", "placeholder": "Будь ласка, введіть підказку для ШІ стабільності, щоб створити зображення" }, "support": "Розмір зображення обмежений 5 МБ. Підтримувані формати: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Невірне зображення", "invalidImageSize": "Розмір зображення повинен бути менше 5 МБ", "invalidImageFormat": "Формат зображення не підтримується. Підтримувані формати: JPEG, PNG, GIF, SVG", "invalidImageUrl": "Невірний URL зображення", "noImage": "Такого файлу чи каталогу немає", "multipleImagesFailed": "Не вдалося завантажити одне чи кілька зображень. Повторіть спробу" }, "embedLink": { "label": "Вставити посилання", "placeholder": "Вставте або введіть посилання на зображення" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "Пошук зображення", "pleaseInputYourOpenAIKey": "будь ласка, введіть ключ ШІ на сторінці налаштувань", "saveImageToGallery": "Зберегти зображення", "failedToAddImageToGallery": "Не вдалося додати зображення до галереї", "successToAddImageToGallery": "Зображення додано до галереї", "unableToLoadImage": "Не вдалося завантажити зображення", "maximumImageSize": "Максимальний підтримуваний розмір зображення для завантаження становить 10 МБ", "uploadImageErrorImageSizeTooBig": "Розмір зображення має бути менше 10 Мб", "imageIsUploading": "Зображення завантажується", "openFullScreen": "Відкрити на весь екран", "interactiveViewer": { "toolbar": { "previousImageTooltip": "Попереднє зображення", "nextImageTooltip": "Наступне зображення", "zoomOutTooltip": "Зменшення", "zoomInTooltip": "Збільшувати", "changeZoomLevelTooltip": "Змінити рівень масштабування", "openLocalImage": "Відкрите зображення", "downloadImage": "Завантажити зображення", "closeViewer": "Закрити інтерактивний засіб перегляду", "scalePercentage": "{}%", "deleteImageTooltip": "Видалити зображення" } }, "pleaseInputYourStabilityAIKey": "будь ласка, введіть ключ стабільності AI на сторінці налаштувань" }, "codeBlock": { "language": { "label": "Мова", "placeholder": "Виберіть мову", "auto": "Авто" }, "copyTooltip": "Скопіюйте вміст блоку коду", "searchLanguageHint": "Пошук мови", "codeCopiedSnackbar": "Код скопійовано в буфер обміну!" }, "inlineLink": { "placeholder": "Вставте або введіть посилання", "openInNewTab": "Відкрити в новій вкладці", "copyLink": "Скопіювати посилання", "removeLink": "Видалити посилання", "url": { "label": "URL посилання", "placeholder": "Введіть URL посилання" }, "title": { "label": "Заголовок посилання", "placeholder": "Введіть заголовок посилання" } }, "mention": { "placeholder": "Згадати особу, сторінку або дату...", "page": { "label": "Посилання на сторінку", "tooltip": "Клацніть, щоб відкрити сторінку" }, "deleted": "Видалено", "deletedContent": "Цей вміст не існує або його видалено", "noAccess": "Немає доступу" }, "toolbar": { "resetToDefaultFont": "Скинути до стандартного" }, "errorBlock": { "theBlockIsNotSupported": "Не вдалося проаналізувати вміст блоку", "clickToCopyTheBlockContent": "Натисніть, щоб скопіювати вміст блоку", "blockContentHasBeenCopied": "Вміст блоку скопійовано." }, "mobilePageSelector": { "title": "Виберіть сторінку", "failedToLoad": "Не вдалося завантажити список сторінок", "noPagesFound": "Сторінок не знайдено" }, "board": { "column": { "createNewCard": "Нова" }, "menuName": "Дошка", "referencedBoardPrefix": "Вид на" }, "calendar": { "menuName": "Календар", "defaultNewCalendarTitle": "Без назви", "newEventButtonTooltip": "Додати нову подію", "navigation": { "today": "Сьогодні", "jumpToday": "Перейти до сьогодні", "previousMonth": "Попередній місяць", "nextMonth": "Наступний місяць" }, "settings": { "showWeekNumbers": "Показувати номери тижня", "showWeekends": "Показувати вихідні", "firstDayOfWeek": "Почати тиждень з", "layoutDateField": "Календарний вигляд за", "noDateTitle": "Без дати", "noDateHint": "Несплановані події будуть відображатися тут", "clickToAdd": "Клацніть, щоб додати до календаря", "name": "Календарний вигляд" }, "referencedCalendarPrefix": "Вид на" }, "errorDialog": { "title": "Помилка в @:appName", "howToFixFallback": "Вибачте за незручності! Надішліть звіт про помилку на нашу сторінку GitHub, де ви опишіть свою помилку.", "github": "Переглянути на GitHub" }, "search": { "label": "Пошук", "placeholder": { "actions": "Шукати дії..." } }, "message": { "copy": { "success": "Скопійовано!", "fail": "Не вдалося скопіювати" } }, "unSupportBlock": "Поточна версія не підтримує цей блок.", "views": { "deleteContentTitle": "Ви впевнені, що хочете видалити {pageType}?", "deleteContentCaption": "якщо ви видалите цю {pageType}, ви зможете відновити її з кошика." }, "colors": { "custom": "Власний", "default": "Стандартний", "red": "Червоний", "orange": "Помаранчевий", "yellow": "Жовтий", "green": "Зелений", "blue": "Синій", "purple": "Фіолетовий", "pink": "Рожевий", "brown": "Коричневий", "gray": "Сірий" } }, "board": { "column": { "createNewCard": "Новий", "renameGroupTooltip": "Натисніть, щоб перейменувати групу", "createNewColumn": "Додайте нову групу", "addToColumnTopTooltip": "Додайте нову картку вгорі", "addToColumnBottomTooltip": "Додайте нову картку внизу", "renameColumn": "Перейменувати", "hideColumn": "Сховати", "newGroup": "Нова група", "deleteColumn": "Видалити", "deleteColumnConfirmation": "Це призведе до видалення цієї групи та всіх карток у ній.\nВи впевнені, що бажаєте продовжити?" }, "hiddenGroupSection": { "sectionTitle": "Приховані групи", "collapseTooltip": "Приховати приховані групи", "expandTooltip": "Переглянути приховані групи" }, "cardDetail": "Деталі картки", "cardActions": "Дії з картками", "cardDuplicated": "Картку дублювали", "cardDeleted": "Картку видалено", "showOnCard": "Показати на реквізитах картки", "setting": "Налаштування", "propertyName": "Назва власності", "menuName": "Дошка", "showUngrouped": "Показати не згруповані елементи", "ungroupedButtonText": "Розгрупований", "ungroupedButtonTooltip": "Містить картки, які не належать до жодної групи", "ungroupedItemsTitle": "Натисніть, щоб додати до дошки", "groupBy": "Групувати за", "groupCondition": "Стан групи", "referencedBoardPrefix": "Вид на", "notesTooltip": "Нотатки всередині", "mobile": { "editURL": "Редагувати URL", "showGroup": "Показати групу", "showGroupContent": "Ви впевнені, що хочете показати цю групу на дошці?", "failedToLoad": "Не вдалося завантажити вигляд дошки" }, "dateCondition": { "weekOf": "Тиждень з {} по {}", "today": "Сьогодні", "yesterday": "вчора", "tomorrow": "Завтра", "lastSevenDays": "Останні 7 днів", "nextSevenDays": "Наступні 7 днів", "lastThirtyDays": "Останні 30 днів", "nextThirtyDays": "Наступні 30 днів" }, "noGroup": "Немає груп за властивостями", "noGroupDesc": "Для відображення подання дошки потрібна властивість для групування" }, "calendar": { "menuName": "Календар", "defaultNewCalendarTitle": "Без назви", "newEventButtonTooltip": "Додайте нову подію", "navigation": { "today": "Сьогодні", "jumpToday": "Перейти до сьогоднішнього дня", "previousMonth": "Попередній місяць", "nextMonth": "Наступного місяця", "views": { "day": "День", "week": "Тиждень", "month": "Місяць", "year": "Рік" } }, "mobileEventScreen": { "emptyTitle": "Подій ще немає", "emptyBody": "Натисніть кнопку плюс, щоб створити подію в цей день." }, "settings": { "showWeekNumbers": "Показати номери тижнів", "showWeekends": "Показати вихідні", "firstDayOfWeek": "Почніть тиждень", "layoutDateField": "Верстка календаря по", "changeLayoutDateField": "Змінити поле макета", "noDateTitle": "Без дати", "noDateHint": { "zero": "Тут відображатимуться незаплановані події", "one": "{count} незапланована подія", "other": "{count} незапланованих подій" }, "unscheduledEventsTitle": "Позапланові заходи", "clickToAdd": "Натисніть, щоб додати до календаря", "name": "Налаштування календаря", "clickToOpen": "Натисніть, щоб відкрити запис" }, "referencedCalendarPrefix": "Вид на", "quickJumpYear": "Перейти до", "duplicateEvent": "Дубльована подія" }, "errorDialog": { "title": "@:appName Помилка", "howToFixFallback": "Просимо вибачення за незручності! Надішліть проблему на нашій сторінці GitHub з описом вашої помилки.", "howToFixFallbackHint1": "Просимо вибачення за незручності! Надішліть питання на нашому ", "howToFixFallbackHint2": " сторінка, яка описує вашу помилку.", "github": "Переглянути на GitHub" }, "search": { "label": "Пошук", "sidebarSearchIcon": "Шукайте та швидко переходьте на сторінку", "placeholder": { "actions": "Пошукові дії..." } }, "message": { "copy": { "success": "Скопійовано!", "fail": "Неможливо скопіювати" } }, "unSupportBlock": "Поточна версія не підтримує цей блок.", "views": { "deleteContentTitle": "Ви дійсно хочете видалити {pageType}?", "deleteContentCaption": "якщо ви видалите цей {pageType}, ви зможете відновити його з кошика." }, "colors": { "custom": "Користувальницькі", "default": "За замовчуванням", "red": "Червоний", "orange": "Помаранчевий", "yellow": "Жовтий", "green": "Зелений", "blue": "Синій", "purple": "Фіолетовий", "pink": "Рожевий", "brown": "Коричневий", "gray": "Сірий" }, "emoji": { "emojiTab": "Emoji", "search": "Пошук емодзі", "noRecent": "Немає недавніх емодзі", "noEmojiFound": "Емодзі не знайдено", "filter": "Фільтр", "random": "Випадковий", "selectSkinTone": "Вибрати відтінок шкіри", "remove": "Видалити емодзі", "categories": { "smileys": "Смайли та емоції", "people": "люди", "animals": "природа", "food": "їжа", "activities": "активності", "places": "місця", "objects": "об'єкти", "symbols": "символи", "flags": "прапори", "nature": "природа", "frequentlyUsed": "часто використовувані" }, "skinTone": { "default": "За замовчуванням", "light": "Світла", "mediumLight": "Середньосвітлий", "medium": "Середній", "mediumDark": "Середньо-темний", "dark": "Темний" }, "openSourceIconsFrom": "Піктограми з відкритим кодом" }, "inlineActions": { "noResults": "Немає результатів", "recentPages": "Останні сторінки", "pageReference": "Посилання на сторінку", "docReference": "Посилання на документ", "boardReference": "Довідка дошки", "calReference": "Довідка календаря", "gridReference": "Посилання на сітку", "date": "Дата", "reminder": { "groupTitle": "Нагадування", "shortKeyword": "нагадати" } }, "datePicker": { "dateTimeFormatTooltip": "Змініть формат дати та часу в налаштуваннях", "dateFormat": "Формат дати", "includeTime": "Включіть час", "isRange": "Дата закінчення", "timeFormat": "Формат часу", "clearDate": "Ясна дата", "reminderLabel": "Нагадування", "selectReminder": "Виберіть нагадування", "reminderOptions": { "none": "Жодного", "atTimeOfEvent": "Час події", "fiveMinsBefore": "5 хвилин до", "tenMinsBefore": "за 10 хвилин до", "fifteenMinsBefore": "15 хвилин до", "thirtyMinsBefore": "30 хвилин до", "oneHourBefore": "за 1 годину до", "twoHoursBefore": "за 2 години до", "onDayOfEvent": "У день події", "oneDayBefore": "за 1 день до", "twoDaysBefore": "за 2 дні до", "oneWeekBefore": "1 тиждень тому", "custom": "Налаштовуване" } }, "relativeDates": { "yesterday": "Вчора", "today": "Сьогодні", "tomorrow": "Завтра", "oneWeek": "1 тиждень" }, "notificationHub": { "title": "Сповіщення", "mobile": { "title": "Оновлення" }, "emptyTitle": "Все наздогнали!", "emptyBody": "Немає незавершених сповіщень або дій. Насолоджуйся спокоєм.", "tabs": { "inbox": "Вхідні", "upcoming": "Майбутні" }, "actions": { "markAllRead": "Позначити все як прочитане", "showAll": "Все", "showUnreads": "Непрочитаний" }, "filters": { "ascending": "Висхідний", "descending": "Спускається", "groupByDate": "Групувати за датою", "showUnreadsOnly": "Показати лише непрочитані", "resetToDefault": "Скинути до замовчування" }, "empty": "Тут нічого немає!" }, "reminderNotification": { "title": "Нагадування", "message": "Не забудьте перевірити це, перш ніж забути!", "tooltipDelete": "Видалити", "tooltipMarkRead": "Позначити як прочитане", "tooltipMarkUnread": "Позначити як непрочитане" }, "findAndReplace": { "find": "Знайти", "previousMatch": "Попередній збіг", "nextMatch": "Наступний збіг", "close": "Закрити", "replace": "Замінити", "replaceAll": "Замінити всі", "noResult": "Немає результатів", "caseSensitive": "З урахуванням регістру", "searchMore": "Шукайте, щоб знайти більше результатів" }, "error": { "weAreSorry": "Нам шкода", "loadingViewError": "У нас виникла проблема із завантаженням цього перегляду. Перевірте підключення до Інтернету, оновіть програму та не соромтеся зв’язатися з командою, якщо проблема не зникне.", "syncError": "Дані не синхронізуються з іншого пристрою", "syncErrorHint": "Будь ласка, повторно відкрийте цю сторінку на пристрої, де її востаннє редагували, а потім знову відкрийте її на поточному пристрої.", "clickToCopy": "Натисніть, щоб скопіювати код помилки" }, "editor": { "bold": "Жирний", "bulletedList": "Маркірований список", "bulletedListShortForm": "Маркірований", "checkbox": "Прапорець", "embedCode": "Вставити код", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Виділіть", "color": "Колір", "image": "Зображення", "date": "Дата", "page": "Сторінка", "italic": "Курсив", "link": "Посилання", "numberedList": "Нумерований список", "numberedListShortForm": "Пронумерований", "quote": "Цитата", "strikethrough": "Закреслення", "text": "Текст", "underline": "Підкреслити", "fontColorDefault": "За замовчуванням", "fontColorGray": "Сірий", "fontColorBrown": "Коричневий", "fontColorOrange": "Помаранчевий", "fontColorYellow": "Жовтий", "fontColorGreen": "Зелений", "fontColorBlue": "Синій", "fontColorPurple": "Фіолетовий", "fontColorPink": "Рожевий", "fontColorRed": "Червоний", "backgroundColorDefault": "Фон за умовчанням", "backgroundColorGray": "Сірий фон", "backgroundColorBrown": "Коричневий фон", "backgroundColorOrange": "Помаранчевий фон", "backgroundColorYellow": "Жовтий фон", "backgroundColorGreen": "Зелений фон", "backgroundColorBlue": "Блакитний фон", "backgroundColorPurple": "Фіолетовий фон", "backgroundColorPink": "Рожевий фон", "backgroundColorRed": "Червоний фон", "backgroundColorLime": "Вапно фону", "backgroundColorAqua": "Аква фону", "done": "Готово", "cancel": "Скасувати", "tint1": "Відтінок 1", "tint2": "Відтінок 2", "tint3": "Відтінок 3", "tint4": "Відтінок 4", "tint5": "Відтінок 5", "tint6": "Відтінок 6", "tint7": "Відтінок 7", "tint8": "Відтінок 8", "tint9": "Відтінок 9", "lightLightTint1": "Фіолетовий", "lightLightTint2": "Рожевий", "lightLightTint3": "Світло-рожевий", "lightLightTint4": "Помаранчевий", "lightLightTint5": "Жовтий", "lightLightTint6": "Вапно", "lightLightTint7": "Зелений", "lightLightTint8": "Аква", "lightLightTint9": "Синій", "urlHint": "URL", "mobileHeading1": "Заголовок 1", "mobileHeading2": "Заголовок 2", "mobileHeading3": "Заголовок 3", "textColor": "Колір тексту", "backgroundColor": "Колір фону", "addYourLink": "Додайте своє посилання", "openLink": "Відкрити посилання", "copyLink": "Копіювати посилання", "removeLink": "Видалити посилання", "editLink": "Редагувати посилання", "linkText": "Текст", "linkTextHint": "Будь ласка, введіть текст", "linkAddressHint": "Будь ласка, введіть URL", "highlightColor": "Колір виділення", "clearHighlightColor": "Чіткий колір виділення", "customColor": "Індивідуальний колір", "hexValue": "Шістнадцяткове значення", "opacity": "Непрозорість", "resetToDefaultColor": "Відновити колір за замовчуванням", "ltr": "LTR", "rtl": "RTL", "auto": "Авто", "cut": "Вирізати", "copy": "Копіювати", "paste": "Вставити", "find": "Знайти", "select": "Виберіть", "selectAll": "Вибрати все", "previousMatch": "Попередній матч", "nextMatch": "Наступний матч", "closeFind": "Закрити", "replace": "Замінити", "replaceAll": "Замінити все", "regex": "Регулярний вираз", "caseSensitive": "Чутливий до регістру", "uploadImage": "Завантажити зображення", "urlImage": "URL зображення", "incorrectLink": "Невірне посилання", "upload": "Завантажити", "chooseImage": "Виберіть зображення", "loading": "Завантаження", "imageLoadFailed": "Не вдалося завантажити зображення", "divider": "Роздільник", "table": "Таблиця", "colAddBefore": "Додати перед", "rowAddBefore": "Додати перед", "colAddAfter": "Додайте після", "rowAddAfter": "Додайте після", "colRemove": "Видалити", "rowRemove": "Видалити", "colDuplicate": "Дублювати", "rowDuplicate": "Дублювати", "colClear": "Очистити вміст", "rowClear": "Очистити вміст", "slashPlaceHolder": "Введіть '/', щоб вставити блок, або почніть вводити", "typeSomething": "Введіть щось...", "toggleListShortForm": "Перемикач", "quoteListShortForm": "Цитата", "mathEquationShortForm": "Формула", "codeBlockShortForm": "Код" }, "favorite": { "noFavorite": "Немає улюбленої сторінки", "noFavoriteHintText": "Проведіть пальцем по сторінці вліво, щоб додати її до вибраного", "removeFromSidebar": "Видалити з бічної панелі", "addToSidebar": "Закріпити на бічній панелі" }, "cardDetails": { "notesPlaceholder": "Введіть /, щоб вставити блок, або почніть вводити текст" }, "blockPlaceholders": { "todoList": "Зробити", "bulletList": "Список", "numberList": "Список", "quote": "Цитата", "heading": "Заголовок {}" }, "titleBar": { "pageIcon": "Значок сторінки", "language": "Мова", "font": "Шрифт", "actions": "Дії", "date": "Дата", "addField": "Додати поле", "userIcon": "Значок користувача" }, "noLogFiles": "Немає файлів журналу", "newSettings": { "myAccount": { "title": "Мій рахунок", "subtitle": "Налаштуйте свій профіль, керуйте безпекою облікового запису, відкривайте ключі AI або ввійдіть у свій обліковий запис.", "profileLabel": "Назва облікового запису та зображення профілю", "profileNamePlaceholder": "Введіть ім'я", "accountSecurity": "Безпека облікового запису", "2FA": "2-етапна автентифікація", "aiKeys": "AI ключі", "accountLogin": "Вхід до облікового запису", "updateNameError": "Не вдалося оновити назву", "updateIconError": "Не вдалося оновити значок", "deleteAccount": { "title": "Видалити аккаунт", "subtitle": "Назавжди видалити свій обліковий запис і всі ваші дані.", "deleteMyAccount": "Видалити мій обліковий запис", "dialogTitle": "Видалити аккаунт", "dialogContent1": "Ви впевнені, що хочете остаточно видалити свій обліковий запис?", "dialogContent2": "Цю дію неможливо скасувати. Вона призведе до скасування доступу до всіх командних просторів, видалення всього вашого облікового запису, включаючи приватні робочі простори, і видалення вас із усіх спільних робочих просторів." } }, "workplace": { "name": "Робоче місце", "title": "Налаштування робочого місця", "subtitle": "Налаштуйте зовнішній вигляд робочої області, тему, шрифт, макет тексту, дату, час і мову.", "workplaceName": "Назва робочого місця", "workplaceNamePlaceholder": "Введіть назву робочого місця", "workplaceIcon": "Значок на робочому місці", "workplaceIconSubtitle": "Завантажте зображення або використовуйте емодзі для свого робочого простору. Значок з’явиться на бічній панелі та в сповіщеннях.", "renameError": "Не вдалося перейменувати робоче місце", "updateIconError": "Не вдалося оновити значок", "chooseAnIcon": "Виберіть значок", "appearance": { "name": "Зовнішній вигляд", "themeMode": { "auto": "Авто", "light": "Світла", "dark": "Темна" }, "language": "Мова" } }, "syncState": { "syncing": "Синхронізація", "synced": "Синхронізовано", "noNetworkConnected": "Немає підключення до мережі" } }, "pageStyle": { "title": "Стиль сторінки", "layout": "Макет", "coverImage": "Зображення обкладинки", "pageIcon": "Значок сторінки", "colors": "Кольори", "gradient": "Градієнт", "backgroundImage": "Фонове зображення", "presets": "Предустановки", "photo": "Фото", "unsplash": "Unsplash", "pageCover": "Обкладинка сторінки", "none": "Жодного", "openSettings": "Відкрийте налаштування", "photoPermissionTitle": "@:appName хоче отримати доступ до вашої бібліотеки фотографій", "photoPermissionDescription": "Дозвольте доступ до бібліотеки фотографій для завантаження зображень.", "doNotAllow": "Не дозволяти", "image": "Зображення" }, "commandPalette": { "placeholder": "Введіть для пошуку...", "bestMatches": "Найкращі збіги", "recentHistory": "Новітня історія", "navigateHint": "для навігації", "loadingTooltip": "Чекаємо результатів...", "betaLabel": "БЕТА", "betaTooltip": "Зараз ми підтримуємо лише пошук сторінок і вмісту в документах", "fromTrashHint": "Зі сміття", "noResultsHint": "Ми не знайшли те, що ви шукали, спробуйте знайти інший термін.", "clearSearchTooltip": "Очистити поле пошуку" }, "space": { "delete": "Видалити", "deleteConfirmation": "Видалити: ", "deleteConfirmationDescription": "Усі сторінки в цьому просторі буде видалено та переміщено до кошика, а опубліковані сторінки буде скасовано.", "rename": "Перейменувати простір", "changeIcon": "Змінити значок", "manage": "Керуйте простором", "addNewSpace": "Створити простір", "collapseAllSubPages": "Згорнути всі підсторінки", "createNewSpace": "Створіть новий простір", "createSpaceDescription": "Створіть кілька публічних і приватних просторів, щоб краще організувати свою роботу.", "spaceName": "Назва простору", "spaceNamePlaceholder": "наприклад, маркетинг, інженерія, HR", "permission": "Дозвіл", "publicPermission": "Громадський", "publicPermissionDescription": "Усі учасники робочої області з повним доступом", "privatePermission": "Приватний", "privatePermissionDescription": "Тільки ви маєте доступ до цього простору", "spaceIconBackground": "Колір фону", "spaceIcon": "Значок", "dangerZone": "НЕБЕЗПЕЧНА ЗОНА", "unableToDeleteLastSpace": "Неможливо видалити останній пробіл", "unableToDeleteSpaceNotCreatedByYou": "Неможливо видалити простори, створені іншими", "enableSpacesForYourWorkspace": "Увімкніть Spaces для своєї робочої області", "title": "Пробіли", "defaultSpaceName": "Загальний", "upgradeSpaceTitle": "Увімкнути Spaces", "upgradeSpaceDescription": "Створіть кілька публічних і приватних просторів, щоб краще організувати свій робочий простір.", "upgrade": "Оновити", "upgradeYourSpace": "Створіть кілька просторів", "quicklySwitch": "Швидко перейдіть до наступного місця", "duplicate": "Дубльований простір", "movePageToSpace": "Перемістити сторінку в простір", "switchSpace": "Переключити простір", "spaceNameCannotBeEmpty": "Назва групи не може бути пустою" }, "publish": { "hasNotBeenPublished": "Ця сторінка ще не опублікована", "reportPage": "Сторінка звіту", "databaseHasNotBeenPublished": "Публікація бази даних ще не підтримується.", "createdWith": "Створено за допомогою", "downloadApp": "Завантажити AppFlowy", "copy": { "codeBlock": "Вміст блоку коду скопійовано в буфер обміну", "imageBlock": "Посилання на зображення скопійовано в буфер обміну", "mathBlock": "Математичне рівняння скопійовано в буфер обміну", "fileBlock": "Посилання на файл скопійовано в буфер обміну" }, "containsPublishedPage": "Ця сторінка містить одну або кілька опублікованих сторінок. Якщо ви продовжите, їх публікацію буде скасовано. Ви хочете продовжити видалення?", "publishSuccessfully": "Успішно опубліковано", "unpublishSuccessfully": "Публікацію скасовано", "publishFailed": "Не вдалося опублікувати", "unpublishFailed": "Не вдалося скасувати публікацію", "noAccessToVisit": "Немає доступу до цієї сторінки...", "createWithAppFlowy": "Створіть веб-сайт за допомогою AppFlowy", "fastWithAI": "Швидко та легко з AI.", "tryItNow": "Спробуй зараз", "onlyGridViewCanBePublished": "Можна опублікувати лише режим сітки", "database": { "zero": "Опублікувати {} вибране представлення", "one": "Опублікувати {} вибраних представлень", "many": "Опублікувати {} вибраних представлень", "other": "Опублікувати {} вибраних представлень" }, "mustSelectPrimaryDatabase": "Необхідно вибрати основний вид", "noDatabaseSelected": "Базу даних не вибрано, виберіть принаймні одну базу даних.", "unableToDeselectPrimaryDatabase": "Неможливо скасувати вибір основної бази даних", "saveThisPage": "Зберегти цю сторінку", "duplicateTitle": "Куди б ви хотіли додати", "selectWorkspace": "Виберіть робочу область", "addTo": "Додати до", "duplicateSuccessfully": "Дубльований успіх. Хочете переглянути документи?", "duplicateSuccessfullyDescription": "Немає програми? Ваше завантаження розпочнеться автоматично після натискання кнопки «Завантажити».", "downloadIt": "Завантажити", "openApp": "Відкрити в додатку", "duplicateFailed": "Не вдалося створити копію", "membersCount": { "zero": "Немає учасників", "one": "1 учасник", "many": "{count} учасників", "other": "{count} учасників" } }, "web": { "continue": "Продовжити", "or": "або", "continueWithGoogle": "Продовжуйте з Google", "continueWithGithub": "Продовжити з GitHub", "continueWithDiscord": "Продовжуйте з Discord", "signInAgreement": "Натиснувши «Продовжити» вище, ви підтверджуєте це\nви прочитали, зрозуміли та погодилися\nAppFlowy", "and": "і", "termOfUse": "Умови", "privacyPolicy": "Політика конфіденційності", "signInError": "Помилка входу", "login": "Зареєструйтесь або увійдіть", "fileBlock": { "uploadedAt": "Завантажено {time}", "linkedAt": "Посилання додано {time}", "empty": "Завантажте або вставте файл" } }, "globalComment": { "comments": "Коментарі", "addComment": "Додати коментар", "reactedBy": "відреагував", "addReaction": "Додайте реакцію", "reactedByMore": "та {count} інших", "showSeconds": { "one": "1 секунду тому", "other": "{count} секунд тому", "zero": "Прямо зараз", "many": "{count} секунд тому" }, "showMinutes": { "one": "1 хвилину тому", "other": "{count} хвилин тому", "many": "{count} хвилин тому" }, "showHours": { "one": "1 годину тому", "other": "{count} годин тому", "many": "{count} годин тому" }, "showDays": { "one": "1 день тому", "other": "{count} днів тому", "many": "{count} днів тому" }, "showMonths": { "one": "1 місяць тому", "other": "{count} місяців тому", "many": "{count} місяців тому" }, "showYears": { "one": "1 рік тому", "other": "{count} років тому", "many": "{count} років тому" }, "reply": "Відповісти", "deleteComment": "Видалити коментар", "youAreNotOwner": "Ви не є власником цього коментаря", "confirmDeleteDescription": "Ви впевнені, що хочете видалити цей коментар?", "hasBeenDeleted": "Видалено", "replyingTo": "Відповідаючи на", "noAccessDeleteComment": "Ви не можете видалити цей коментар", "collapse": "Згорнути", "readMore": "Читати далі", "failedToAddComment": "Не вдалося додати коментар", "commentAddedSuccessfully": "Коментар успішно додано.", "commentAddedSuccessTip": "Ви щойно додали коментар або відповіли на нього. Бажаєте перейти вгору, щоб переглянути останні коментарі?" }, "template": { "asTemplate": "Як шаблон", "name": "Назва шаблону", "description": "Опис шаблону", "about": "Шаблон Про", "preview": "Попередній перегляд шаблону", "categories": "Категорії шаблонів", "isNewTemplate": "PIN для нового шаблону", "featured": "PIN-код для рекомендованого", "relatedTemplates": "Пов’язані шаблони", "requiredField": "{field} є обов’язковим", "addCategory": "Додати \"{category}\"", "addNewCategory": "Додати нову категорію", "addNewCreator": "Додати нового творця", "deleteCategory": "Видалити категорію", "editCategory": "Редагувати категорію", "editCreator": "Редагувати творця", "category": { "name": "Назва категорії", "icon": "Значок категорії", "bgColor": "Колір фону категорії", "priority": "Пріоритет категорії", "desc": "Опис категорії", "type": "Тип категорії", "icons": "Іконки категорій", "colors": "Категорія кольори", "byUseCase": "За випадком використання", "byFeature": "За ознакою", "deleteCategory": "Видалити категорію", "deleteCategoryDescription": "Ви впевнені, що хочете видалити цю категорію?", "typeToSearch": "Введіть для пошуку категорій..." }, "creator": { "label": "Творець шаблонів", "name": "Ім'я творця", "avatar": "Аватар творця", "accountLinks": "Посилання на акаунт творця", "uploadAvatar": "Натисніть, щоб завантажити аватар", "deleteCreator": "Видалити творця", "deleteCreatorDescription": "Ви впевнені, що хочете видалити цього творця?", "typeToSearch": "Введіть для пошуку творців..." }, "uploadSuccess": "Шаблон успішно завантажено", "uploadSuccessDescription": "Ваш шаблон успішно завантажено. Тепер ви можете переглянути його в галереї шаблонів.", "viewTemplate": "Переглянути шаблон", "deleteTemplate": "Видалити шаблон", "deleteTemplateDescription": "Ви впевнені, що хочете видалити цей шаблон?", "addRelatedTemplate": "Додайте відповідний шаблон", "removeRelatedTemplate": "Видалити пов’язаний шаблон", "uploadAvatar": "Завантажити аватар", "searchInCategory": "Шукати в {category}", "label": "Шаблон" }, "fileDropzone": { "dropFile": "Натисніть або перетягніть файл у цю область, щоб завантажити", "uploading": "Завантаження...", "uploadFailed": "Помилка завантаження", "uploadSuccess": "Завантаження успішне", "uploadSuccessDescription": "Файл успішно завантажено", "uploadFailedDescription": "Не вдалося завантажити файл", "uploadingDescription": "Файл завантажується" } } ================================================ FILE: frontend/resources/translations/ur.json ================================================ { "appName": "AppFlowy", "defaultUsername": "میں", "welcomeText": "میں خوش آمدید @:appName", "githubStarText": "GitHub پر ستارہ دیں", "subscribeNewsletterText": "نیوزلیٹر سبسکرائب کریں", "letsGoButtonText": "چلو", "title": "عنوان", "youCanAlso": "پ یہ بھی کر سکتے ہیں", "and": "اور", "blockActions": { "addBelowTooltip": "نیچے شامل کرنے کے لیے کلک کریں", "addAboveCmd": "Alt+click", "addAboveMacCmd": "Option+click", "addAboveTooltip": "اوپر شامل کرنے کے لیے", "dragTooltip": "منتقل کرنے کے لیے ڈریگ کریں", "openMenuTooltip": "Click to open menu" }, "signUp": { "buttonText": "سائن اپ کریں", "title": "@:appName پر سائن اپ کریں", "getStartedText": "شروع کریں", "emptyPasswordError": "پاس ورڈ خالی نہیں ہونا چاہیے", "repeatPasswordEmptyError": "دہرائیں پاس ورڈ خالی نہیں ہونا چاہیے", "unmatchedPasswordError": "دہرائیں پاس ورڈ پاس ورڈ سے مماثل نہیں ہے", "alreadyHaveAnAccount": "پہلے ہی اکاؤنٹ ہے؟", "emailHint": "ای میل", "passwordHint": "پاس ورڈ", "repeatPasswordHint": "دہرائیں پاس ورڈ", "signUpWith": "اس کے ساتھ سائن اپ کریں:" }, "signIn": { "loginTitle": "@:appName پر لاگ ان کریں", "loginButtonText": "لاگ ان کریں", "loginStartWithAnonymous": "ایک ناشناس سیشن سے شروع کریں", "continueAnonymousUser": "ایک ناشناس سیشن کے ساتھ جاری رکھیں", "buttonText": "سائن ان کریں", "forgotPassword": "پاس ورڈ بھول گئے؟", "emailHint": "ای میل", "passwordHint": "پاس ورڈ", "dontHaveAnAccount": "اکاؤنٹ نہیں ہے؟", "repeatPasswordEmptyError": "دہرائیں پاس ورڈ خالی نہیں ہونا چاہیے", "unmatchedPasswordError": "دہرائیں پاس ورڈ پاس ورڈ سے مماثل نہیں ہے", "syncPromptMessage": "ڈیٹا کو ہمگام بنانے میں کچھ وقت لگ سکتا ہے۔ براہ کرم یہ صفحہ بند نہ کریں۔", "or": "یا", "LogInWithGoogle": "Google کے ساتھ لاگ ان کریں", "LogInWithGithub": "Github کے ساتھ لاگ ان کریں", "LogInWithDiscord": "Discord کے ساتھ لاگ ان کریں", "signInWith": "اس کے ساتھ سائن ان کریں:" }, "workspace": { "chooseWorkspace": "اپنا ورک اسپیس منتخب کریں", "create": "ورک اسپیس بنائیں", "reset": "ورک اسپیس ری سیٹ کریں", "resetWorkspacePrompt": "ورک اسپیس کو ری سیٹ کرنے سے اس کے اندر تمام صفحات اور ڈیٹا حذف ہو جائے گا۔ کیا آپ یقینی طور پر ورک اسپیس کو ری سیٹ کرنا چاہتے ہیں؟ متبادل طور پر، آپ ورک اسپیس کو بحال کرنے کے لیے سپورٹ ٹیم سے رابطہ کر سکتے ہیں۔", "hint": "ورک اسپیس", "notFoundError": "ورک اسپیس نہیں ملا" }, "shareAction": { "buttonText": "اشتراک کریں", "workInProgress": "جلد آرہا ہے", "markdown": "مارک ڈاؤن", "csv": "CSV", "copyLink": "لنک کاپی کریں" }, "moreAction": { "small": "چھوٹا", "medium": "متوسط", "large": "بڑا", "fontSize": "فونٹ کا سائز", "import": " درآمد کریں", "moreOptions": "مزید اختیارات" }, "importPanel": { "textAndMarkdown": "ٹیکسٹ اور مارک ڈاؤن", "documentFromV010": "v0.1.0 سے دستاویز", "databaseFromV010": "v0.1.0 سے ڈیٹابیس", "csv": "CSV", "database": "ڈیٹابیس" }, "disclosureAction": { "rename": "نام بدلیں", "delete": "حذف کریں", "duplicate": "نقل کریں", "unfavorite": "پسندیدہ سے ہٹائیں", "favorite": "پسندیدہ میں شامل کریں", "openNewTab": "نئے ٹیب میں کھولیں", "moveTo": "منتقل کریں", "addToFavorites": "پسندیدہ میں شامل کریں", "copyLink": "لنک کاپی کریں" }, "blankPageTitle": "خالی صفحہ", "newPageText": "نیا صفحہ", "newDocumentText": "نیا دستاویز", "newGridText": "نیا گرڈ", "newCalendarText": "نیا کیلنڈر", "newBoardText": "نیا بورڈ", "trash": { "text": "ٹوکری", "restoreAll": "تمام بحال کریں", "deleteAll": "تمام حذف کریں", "pageHeader": { "fileName": "فائل کا نام", "lastModified": "آخری بار ترمیم کیا گیا", "created": "بنائی گئی" }, "confirmDeleteAll": { "title": "کیا آپ ٹوکری میں تمام صفحات حذف کرنا چاہتے ہیں؟", "caption": "اس کارروائی کو کالعدم نہیں کیا جا سکتا۔" }, "confirmRestoreAll": { "title": "کیا آپ ٹوکری میں تمام صفحات بحال کرنا چاہتے ہیں؟", "caption": "اس کارروائی کو کالعدم نہیں کیا جا سکتا۔" } }, "deletePagePrompt": { "text": "یہ صفحہ ٹوکری میں ہے", "restore": "صفحہ بحال کریں", "deletePermanent": "مستقل طور پر حذف کریں" }, "dialogCreatePageNameHint": "صفحہ کا نام", "questionBubble": { "shortcuts": "شارٹ کٹس", "whatsNew": "کیا نیا ہے؟", "help": "مدد اور سپورٹ", "markdown": "مارک ڈاؤن", "debug": { "name": "ڈیبگ معلومات", "success": "ڈیبگ معلومات کلپ بورڈ پر کاپی ہو گئیں!", "fail": "ڈیبگ معلومات کلپ بورڈ پر کاپی نہیں کی جا سکیں" }, "feedback": "تاثرات" }, "menuAppHeader": { "moreButtonToolTip": "حذف کریں، نام بدلیں، اور مزید...", "addPageTooltip": "اندر جلد از جلد ایک صفحہ شامل کریں۔", "defaultNewPageName": "بغیر عنوان", "renameDialog": "نام بدلیں" }, "toolbar": { "undo": "واپس لو", "redo": "دوبارہ کرنا", "bold": "موٹا", "italic": "ترچھی", "underline": "اندرونی خط", "strike": "لائن کے ذریعے", "numList": "نمبر والی فہرست", "bulletList": "بلیٹ والی فہرست", "checkList": "چیک لسٹ", "inlineCode": "ان لائن کوڈ", "quote": "اقتباس بلاک", "header": "ہیڈر", "highlight": "ہائی لائٹ", "color": "رنگ", "addLink": "لنک شامل کریں", "link": "لنک" }, "tooltip": { "lightMode": "لائٹ موڈ پر سوئچ کریں", "darkMode": "ڈارک موڈ پر سوئچ کریں", "openAsPage": "ایک صفحہ کے طور پر کھولیں", "addNewRow": "ایک نئی قطار شامل کریں", "openMenu": "مینو کھولنے کے لیے کلک کریں۔", "dragRow": "قطار کو دوبارہ ترتیب دینے کے لیے طویل پریس کریں۔", "viewDataBase": "ڈیٹابیس دیکھیں", "referencePage": "اس {name} کا حوالہ دیا گیا ہے", "addBlockBelow": "نیچے ایک بلاک شامل کریں" }, "sideBar": { "closeSidebar": "سائیڈ بار بند کریں", "openSidebar": "سائیڈ بار کھولیں", "personal": "ذاتی", "favorites": "پسندیدہ", "clickToHidePersonal": "ذاتی سیکشن چھپانے کے لیے کلک کریں۔", "clickToHideFavorites": "پسندیدہ سیکشن چھپانے کے لیے کلک کریں۔", "addAPage": "صفحہ شامل کریں" }, "notifications": { "export": { "markdown": "مارک ڈاؤن کو نوٹ برآمد کریں", "path": "دستخط/flowy" } }, "contactsPage": { "title": "رابطے", "whatsHappening": "اس ہفتے کیا ہو رہا ہے؟", "addContact": "رابطہ شامل کریں", "editContact": "رابطہ ایڈیٹ کریں" }, "button": { "ok": "ٹھیک ہے", "cancel": "منسوخ کریں", "signIn": "سائن ان کریں", "signOut": "سائن آؤٹ کریں", "complete": "مکمل", "save": "محفوظ کریں", "generate": "جنریٹ کریں", "esc": "ESC", "keep": "رکھیں", "tryAgain": "دوبارہ کوشش کریں", "discard": "چھوڑ دیں", "replace": "تبدیل کریں", "insertBelow": "نیچے داخل کریں", "upload": "اپ لوڈ کریں", "edit": "تبدیل کریں", "delete": "حذف کریں", "duplicate": "نقل کریں", "done": "ہو گیا", "putback": "واپس رکھیں" }, "label": { "welcome": "خیر مقدم ہے!", "firstName": "پہلا نام", "middleName": "وسطی نام", "lastName": "آخری نام", "stepX": "مرحلہ {X}" }, "oAuth": { "err": { "failedTitle": "آپ کے اکاؤنٹ سے کنیکٹ نہیں ہو سکا۔", "failedMsg": "براہ کرم یقینی بنائیں کہ آپ نے اپنے ویب براؤزر میں سائن ان کا عمل مکمل کر لیا ہے۔" }, "google": { "title": "گوگل سائن ان کریں", "instruction1": "اپنے گوگل رابطے درآمد کرنے کے لیے، آپ کو اپنے ویب براؤزر کا استعمال کرتے ہوئے اس ایپلی کیشن کو مجاز کرنا ہوگا۔", "instruction2": "آئیکن پر کلک کرکے یا متن منتخب کرکے اس کوڈ کو اپنے کلپ بورڈ پر کاپی کریں:", "instruction3": "اپنے ویب براؤزر میں مندرجہ ذیل لنک پر جائیں اور اوپر دیا گیا کوڈ درج کریں:", "instruction4": "جب آپ سائن اپ مکمل کر لیں تو نیچے دیا گیا بٹن دبائیں:" } }, "settings": { "title": "ترتیبات", "menu": { "appearance": "ظہور", "language": "زبان", "user": "صارف", "files": "فائلیں", "open": "ترتیبات کھولیں", "logout": "لاگ آؤٹ کریں", "logoutPrompt": "کیا آپ یقینی طور پر لاگ آؤٹ کرنا چاہتے ہیں؟", "selfEncryptionLogoutPrompt": "کیا آپ یقینی طور پر لاگ آؤٹ کرنا چاہتے ہیں؟ براہ کرم یقینی بنائیں کہ آپ نے انکرپشن راز کاپی کر لیا ہے", "syncSetting": "ترتیبات کو ہم وقت کریں", "enableSync": "ہم وقت سازی کو فعال کریں", "enableEncrypt": "ڈیٹا کو انکرپٹ کریں", "enableEncryptPrompt": "اپنے ڈیٹا کو اس راز کے ساتھ محفوظ کرنے کے لیے انکرپشن کو فعال کریں۔ اسے محفوظ طریقے سے ذخیرہ کریں؛ ایک بار فعال ہونے کے بعد، اسے بند نہیں کیا جا سکتا۔ اگر کھو جائے تو، آپ کا ڈیٹا ناقابل بازیافت ہو جاتا ہے۔ کاپی کرنے کے لیے کلک کریں", "inputEncryptPrompt": "براہ کرم کے لیے اپنا انکرپشن راز درج کریں", "clickToCopySecret": "راز کاپی کرنے کے لیے کلک کریں", "inputTextFieldHint": "آپ کا راز", "historicalUserList": "صارف لاگ ان کی تاریخ", "historicalUserListTooltip": "یہ فہرست آپ کے گمنام اکاؤنٹس دکھاتی ہے۔ آپ اکاؤنٹ پر اس کی تفصیلات دیکھنے کے لیے کلک کر سکتے ہیں۔ گمنام اکاؤنٹ 'شروع کریں' بٹن پر کلک کرنے سے بنائے جاتے ہیں", "openHistoricalUser": "گمنام اکاؤنٹ کھولنے کے لیے کلک کریں" }, "appearance": { "resetSetting": "اس ترتیب کو ری سیٹ کریں", "fontFamily": { "label": "فونٹ فیملی", "search": "تلاش کریں" }, "themeMode": { "label": "تھیم موڈ", "light": "لائٹ موڈ", "dark": "ڈارک موڈ", "system": "سسٹم کے ساتھ موافقت کریں" }, "layoutDirection": { "label": "لے آؤٹ کی سمت", "hint": "اسکرین کے بائیں یا دائیں طرف سے عناصر کو ترتیب دینا شروع کرنے کے لیے۔", "ltr": "ltr", "rtl": "rtl" }, "textDirection": { "label": "ڈیفالٹ ٹیکسٹ سمت", "hint": "جب عنصر پر ٹیکسٹ سمت سیٹ نہیں ہو تو ڈیفالٹ ٹیکسٹ سمت۔", "ltr": "LTR", "rtl": "RTL", "auto": "خودکار", "fallback": "لے آؤٹ کی سمت کے مطابق" }, "themeUpload": { "button": "اپ لوڈ کریں", "description": "نیچے دیے گئے بٹن کا استعمال کرتے ہوئے اپنا اپنا AppFlowy تھیم اپ لوڈ کریں۔", "failure": "اپ لوڈ کیا گیا تھیم ایک غیر معتبر فارمیٹ میں تھا۔", "loading": "براہ کرم انتظار کریں جب ہم آپ کے تھیم کو تصدیق اور اپ لوڈ کرتے ہیں۔...", "uploadSuccess": "آپ کا تھیم کامیابی کے ساتھ اپ لوڈ کر دیا گیا", "deletionFailure": "تھیم حذف کرنے میں ناکام ہو گیا۔ اسے دستی طور پر حذف کرنے کی کوشش کریں۔", "filePickerDialogTitle": ".flowy_plugin فائل منتخب کریں", "urlUploadFailure": "url کھولنے میں ناکام: {}" }, "theme": "تھیم", "builtInsLabel": "بلٹ ان تھیمز", "pluginsLabel": "پلگ انز", "showNamingDialogWhenCreatingPage": "صفحہ بناتے وقت نامزدگی کا مکالمہ دکھائیں" }, "files": { "copy": "کاپی کریں", "defaultLocation": "فائلیں اور ڈیٹا اسٹوریج کی جگہ پڑھیں", "exportData": "اپنے ڈیٹا کو برآمد کریں", "doubleTapToCopy": "راستہ کاپی کرنے کے لیے دو بار ٹیپ کریں", "restoreLocation": "AppFlowy ڈیفالٹ راستے پر بحال کریں", "customizeLocation": "دوسرا فولڈر کھولیں", "restartApp": "تبدیلیوں کو لاگو کرنے کے لیے براہ کرم ایپ کو دوبارہ شروع کریں۔", "exportDatabase": "ڈیٹابیس برآمد کریں", "selectFiles": "ان فائلوں کو منتخب کریں جنہیں برآمد کرنے کی ضرورت ہے", "selectAll": "تمام کا انتخاب کریں", "deselectAll": "تمام کو غیر منتخب کریں", "createNewFolder": "نیا فولڈر بنائیں", "createNewFolderDesc": "ہمیں بتائیں کہ آپ اپنا ڈیٹا کہاں ذخیرہ کرنا چاہتے ہیں", "defineWhereYourDataIsStored": "تعریف کریں کہ آپ کا ڈیٹا کہاں ذخیرہ کیا گیا ہے", "open": "کھولیں", "openFolder": "موجودہ فولڈر کھولیں", "openFolderDesc": "اسے پڑھیں اور اپنے موجودہ AppFlowy فولڈر میں لکھیں", "folderHintText": "فولڈر کا نام", "location": "نیا فولڈر بنانا", "locationDesc": "اپنے AppFlowy ڈیٹا فولڈر کے لیے نام منتخب کریں", "browser": "براؤز کریں", "create": "بنائیں", "set": "سیٹ کریں", "folderPath": "اپنا فولڈر ذخیرہ کرنے کا راستہ", "locationCannotBeEmpty": "راستہ خالی نہیں ہو سکتا", "pathCopiedSnackbar": "فائل اسٹوریج کا راستہ کلپ بورڈ پر کاپی ہو گیا!", "changeLocationTooltips": "ڈیٹا ڈائرکٹری تبدیل کریں", "change": "تبدیل کریں", "openLocationTooltips": "دوسرا ڈیٹا ڈائرکٹری کھولیں", "openCurrentDataFolder": "موجودہ ڈیٹا ڈائرکٹری کھولیں", "recoverLocationTooltips": "AppFlowy کی ڈیفالٹ ڈیٹا ڈائرکٹری پر ری سیٹ کریں", "exportFileSuccess": "فائل کو کامیابی کے ساتھ برآمد کیا!", "exportFileFail": "فائل برآمد کرنے میں ناکام ہو گیا!", "export": "برآمد کریں" }, "user": { "name": "نام", "email": "ای میل", "selectAnIcon": "آئیکن منتخب کریں", "pleaseInputYourOpenAIKey": "براہ کرم اپنی AI کی درج کریں", "clickToLogout": "موجودہ صارف سے لاگ آؤٹ کرنے کے لیے کلک کریں" }, "shortcuts": { "shortcutsLabel": "شارٹ کٹس", "command": "کمانڈ", "keyBinding": "کی بائنڈنگ", "addNewCommand": "نیا کمانڈ شامل کریں", "updateShortcutStep": "مطلوبہ کی مجموعہ دبائیں اور ENTER دبائیں", "shortcutIsAlreadyUsed": "یہ شارٹ کٹ پہلے ہی اس کے لیے استعمال ہو چکا ہے: {conflict}", "resetToDefault": "ڈیفالٹ کی بائنڈنگ پر ری سیٹ کریں", "couldNotLoadErrorMsg": "شارٹ کٹس لوڈ نہیں کر سکا، دوبارہ کوشش کریں", "couldNotSaveErrorMsg": "شارٹ کٹس محفوظ نہیں کر سکا، دوبارہ کوشش کریں" } }, "grid": { "deleteView": "کیا آپ یقینی طور پر اس نظارے کو حذف کرنا چاہتے ہیں؟", "createView": "نیا", "settings": { "filter": "فلٹر", "sort": "مرتب کریں", "sortBy": "مرتب کریں", "properties": "خصوصیات", "reorderPropertiesTooltip": "خصوصیات کو دوبارہ ترتیب دینے کے لیے ڈریگ کریں", "group": "گروپ", "addFilter": "فلٹر شامل کریں", "deleteFilter": "فلٹر حذف کریں", "filterBy": "فلٹر کریں...", "typeAValue": "کوئی قیمت ٹائپ کریں...", "layout": "لے آؤٹ", "databaseLayout": "لے آؤٹ" }, "textFilter": { "contains": "شامل ہے", "doesNotContain": "شامل نہیں ہے", "endsWith": "ختم ہوتا ہے", "startWith": "شروع ہوتا ہے", "is": "ہے", "isNot": "نہیں ہے", "isEmpty": "خالی ہے", "isNotEmpty": "خالی نہیں ہے", "choicechipPrefix": { "isNot": "نہیں", "startWith": "شروع ہوتا ہے", "endWith": "ختم ہوتا ہے", "isEmpty": "خالی ہے", "isNotEmpty": "خالی نہیں ہے" } }, "checkboxFilter": { "isChecked": "چیک شدہ", "isUnchecked": "غیر چیک شدہ", "choicechipPrefix": { "is": "ہے" } }, "checklistFilter": { "isComplete": "مکمل ہے", "isIncomplted": "نامکمل ہے" }, "selectOptionFilter": { "is": "ہے", "isNot": "نہیں ہے", "isEmpty": "خالی ہے", "isNotEmpty": "خالی نہیں ہے" }, "multiSelectOptionFilter": { "contains": "شامل ہے", "doesNotContain": "شامل نہیں ہے", "isEmpty": "خالی ہے", "isNotEmpty": "خالی نہیں ہے" }, "field": { "hide": "چھپائیں", "insertLeft": "بائیں طرف درج کریں", "insertRight": "دائیں طرف درج کریں", "duplicate": "ڈپلیکیٹ کریں", "delete": "حذف کریں", "textFieldName": "متن", "checkboxFieldName": "چیک باکس", "dateFieldName": "تاریخ", "updatedAtFieldName": "آخری بار ترمیم کا وقت", "createdAtFieldName": "بنائے جانے کا وقت", "numberFieldName": "اعداد", "singleSelectFieldName": "منتخب کریں", "multiSelectFieldName": "ملٹی سلیکٹ", "urlFieldName": "URL", "checklistFieldName": "چیک لسٹ", "numberFormat": "نمبر فارمیٹ", "dateFormat": "تاریخ کا فارمیٹ", "includeTime": "وقت شامل کریں", "dateFormatFriendly": "مہینہ دن، سال", "dateFormatISO": "سال-مہینہ-دن", "dateFormatLocal": "مہینہ/دن/سال", "dateFormatUS": "سال/مہینہ/دن", "dateFormatDayMonthYear": "دن/مہینہ/سال", "timeFormat": "وقت کا فارمیٹ", "invalidTimeFormat": "غلط فارمیٹ", "timeFormatTwelveHour": "12 گھنٹے", "timeFormatTwentyFourHour": "24 گھنٹے", "clearDate": "تاریخ صاف کریں", "addSelectOption": "ایک آپشن شامل کریں", "optionTitle": "آپشنز", "addOption": "آپشن شامل کریں", "editProperty": "خاصیت میں ترمیم کریں", "newProperty": "نیا پراپرٹی", "deleteFieldPromptMessage": "کیا آپ یقینی ہیں؟ یہ پراپرٹی حذف کر دی جائے گی", "newColumn": "نیا کالم" }, "sort": { "ascending": "چڑھائی", "descending": "اترائی", "deleteAllSorts": "تمام ترتیب کو حذف کریں", "addSort": "ترتیب شامل کریں" }, "row": { "duplicate": "ڈپلیکیٹ کریں", "delete": "حذف کریں", "titlePlaceholder": "عنوان", "textPlaceholder": "خالی", "copyProperty": "خاصیت کو کلپ بورڈ پر کاپی کر دیا گیا", "count": "گِنتی", "newRow": "نیا سطر", "action": "عمل" }, "selectOption": { "create": "بنائیں", "purpleColor": "جامنی", "pinkColor": "گلابی", "lightPinkColor": "ہلکا گلابی", "orangeColor": "سنتری", "yellowColor": "پیلا", "limeColor": "چونا", "greenColor": "سبز", "aquaColor": "کوا", "blueColor": "نیلا", "deleteTag": "ٹैग حذف کریں", "colorPanelTitle": "رنگ", "panelTitle": "کوئی آپشن منتخب کریں یا بنائیں", "searchOption": "آپشن تلاش کریں", "searchOrCreateOption": "آپشن تلاش کریں یا بنائیں...", "createNew": "نیا بنائیں", "orSelectOne": "یا کوئی آپشن منتخب کریں" }, "checklist": { "taskHint": "ٹاسک کی وضاحت", "addNew": "نیا ٹاسک شامل کریں", "submitNewTask": "بنائیں" }, "menuName": "گرِڈ", "referencedGridPrefix": "کا نظارہ" }, "document": { "menuName": "دستایز", "date": { "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "جس بورڈ سے لنک کرنا چاہتے ہیں اسے منتخب کریں", "createANewBoard": "نیا بورڈ بنائیں" }, "grid": { "selectAGridToLinkTo": "جس گرِڈ سے لنک کرنا چاہتے ہیں اسے منتخب کریں", "createANewGrid": "نیا گرِڈ بنائیں" }, "calendar": { "selectACalendarToLinkTo": "جس کیلنڈر سے لنک کرنا چاہتے ہیں اسے منتخب کریں", "createANewCalendar": "نیا کیلنڈر بنائیں" } }, "selectionMenu": { "outline": "آؤٹ لائن", "codeBlock": "کوڈ بلاک" }, "plugins": { "referencedBoard": "حوالہ شدہ بورڈ", "referencedGrid": "حوالہ شدہ گرِڈ", "referencedCalendar": "حوالہ شدہ کیلنڈر", "autoGeneratorMenuItemName": "AI رائٹر", "autoGeneratorTitleName": "AI: AI سے کچھ بھی لکھنے کے لیے کہیں...", "autoGeneratorLearnMore": "مزید جانئے", "autoGeneratorGenerate": "جنریٹ کریں", "autoGeneratorHintText": "AI سے پوچھیں...", "autoGeneratorCantGetOpenAIKey": "AI کی حاصل نہیں کر سکتا", "autoGeneratorRewrite": "دوبارہ لکھیں", "smartEdit": "AI اسسٹنٹ", "aI": "AI", "smartEditFixSpelling": "املا درست کریں", "warning": "⚠️ AI کی پاسخیں غلط یا گمراہ کن ہو سکتی ہیں۔", "smartEditSummarize": "سارے لکھیں", "smartEditImproveWriting": "تحریر بہتر بنائیں", "smartEditMakeLonger": "طویل تر بنائیں", "smartEditCouldNotFetchResult": "AI سے نتیجہ حاصل نہیں کر سکا", "smartEditCouldNotFetchKey": "AI کی حاصل نہیں کر سکا", "smartEditDisabled": "Settings میں AI سے منسلک کریں", "discardResponse": "کیا آپ AI کی پاسخیں حذف کرنا چاہتے ہیں؟", "createInlineMathEquation": "مساوات بنائیں", "toggleList": "فہرست ٹوگل کریں", "cover": { "changeCover": "سرورق تبدیل کریں", "colors": "رنگ", "images": "تصاویر", "clearAll": "تمام کو صاف کریں", "abstract": "خلاصہ", "addCover": "سرورق شامل کریں", "addLocalImage": "مقامی تصویر شامل کریں", "invalidImageUrl": "غلط تصویر URL", "failedToAddImageToGallery": "تصویر کو گیلری میں شامل کرنے میں ناکام", "enterImageUrl": "تصویر URL درج کریں", "add": "شامل کریں", "back": "پیچھے", "saveToGallery": "گیلری میں محفوظ کریں", "removeIcon": "آئیکن کو ہٹائیں", "pasteImageUrl": "تصویر URL پیسٹ کریں", "or": "یا", "pickFromFiles": "فائلوں سے منتخب کریں", "couldNotFetchImage": "تصویر حاصل نہیں کر سکا", "imageSavingFailed": "تصویر محفوظ کرنے میں ناکام", "addIcon": "آئیکن شامل کریں", "coverRemoveAlert": "اسے ہٹانے کے بعد اسے سرورق سے ہٹا دیا جائے گا۔", "alertDialogConfirmation": "کیا آپ یقینی ہیں کہ آپ جاری رکھنا چاہتے ہیں؟" }, "mathEquation": { "addMathEquation": "ریاضی کی مساوات شامل کریں", "editMathEquation": "ریاضی کی مساوات میں ترمیم کریں" }, "optionAction": { "click": "کلک کریں", "toOpenMenu": " مینو کھولنے کے لیے", "delete": "حذف کریں", "duplicate": "ڈپلیکیٹ کریں", "turnInto": "میں تبدیل کریں", "moveUp": "اوپر منتقل کریں", "moveDown": "نیچے منتقل کریں", "color": "رنگ", "align": "ترتیب دیں", "left": "بائیں", "center": "وسط", "right": "دائیں", "defaultColor": "ڈیفالٹ" }, "image": { "copiedToPasteBoard": "تصویری لنک کو کلپ بورڈ پر کاپی کر دیا گیا ہے" }, "outline": { "addHeadingToCreateOutline": "目次 بنانے کے لیے عنوانات شامل کریں۔" }, "table": { "addAfter": "بعد میں شامل کریں", "addBefore": "پہلے شامل کریں", "delete": "حذف کریں", "clear": "محتاجات کو صاف کریں", "duplicate": "ڈپلیکیٹ کریں", "bgColor": "پس منظر کا رنگ" }, "contextMenu": { "copy": "کاپی کریں", "cut": "کاٹیں", "paste": "چسپاں کریں" } }, "textBlock": { "placeholder": "کمانڈز کے لیے '/' ٹائپ کریں" }, "title": { "placeholder": "بغیر عنوان" }, "imageBlock": { "placeholder": "تصویریں شامل کرنے کے لیے کلک کریں", "upload": { "label": "اپ لوڈ کریں", "placeholder": "تصویریں اپ لوڈ کرنے کے لیے کلک کریں" }, "url": { "label": "تصویری URL", "placeholder": "تصویری URL درج کریں" }, "support": "تصویری سائز کی حد 5MB ہے۔ سپورٹڈ فارمیٹس: JPEG، PNG، GIF، SVG", "error": { "invalidImage": "غلط تصویر", "invalidImageSize": "تصویری سائز 5MB سے کم ہونا چاہیے", "invalidImageFormat": "تصویری فارمیٹ سپورٹڈ نہیں ہے۔ سپورٹڈ فارمیٹس: JPEG، PNG، GIF، SVG", "invalidImageUrl": "غلط تصویری URL" } }, "codeBlock": { "language": { "label": "زبان", "placeholder": "زبان منتخب کریں" } }, "inlineLink": { "placeholder": "لنک پیسٹ یا ٹائپ کریں", "openInNewTab": "نیا ٹیب میں کھولیں", "copyLink": "لنک کاپی کریں", "removeLink": "لنک ہٹائیں", "url": { "label": "لنک URL", "placeholder": "لنک URL درج کریں" }, "title": { "label": "لنک کا عنوان", "placeholder": "لنک کا عنوان درج کریں" } }, "mention": { "placeholder": "کسی شخص، صفحے یا تاریخ کا ذکر کریں...", "page": { "label": "صفحے سے لنک کریں", "tooltip": "صفحہ کھولنے کے لیے کلک کریں" } } }, "board": { "column": { "createNewCard": "نواں" }, "menuName": "بورڈ", "referencedBoardPrefix": "کے نظارہ" }, "calendar": { "menuName": "کیلنڈر", "defaultNewCalendarTitle": "بغیر عنوان", "navigation": { "today": "آج", "jumpToday": "آج پر جائیں", "previousMonth": "پچھلا مہینہ", "nextMonth": "اگلا مہینہ" }, "settings": { "showWeekNumbers": "ہفتے کے نمبر دکھائیں", "showWeekends": "ہفتے کے آخر میں دکھائیں", "firstDayOfWeek": "ہفتہ شروع کریں", "layoutDateField": "کیلنڈر کی ترتیب دیں", "noDateTitle": "کوئی تاریخ نہیں", "noDateHint": "غیر شیڈول شدہ واقعات یہاں دکھائے جائیں گے", "clickToAdd": "کیلنڈر میں شامل کرنے کے لیے کلک کریں", "name": "کیلنڈر کا ترتیب" }, "referencedCalendarPrefix": "کے نظارہ" }, "errorDialog": { "title": "AppFlowy کی غلطی", "howToFixFallback": "اس پریشانی کے لیے معذرت چاہتے ہیں! اپنی غلطی کی وضاحت کرنے والا ایک ایشو ہماری گٹ ہب پیج پر جمع کریں۔", "github": "گٹ ہب پر دیکھیں" }, "search": { "label": "تلاش کریں", "placeholder": { "actions": "کارروائیاں تلاش کریں..." } }, "message": { "copy": { "success": "کاپی ہو چکا ہے!", "fail": "کاپی نہیں کر سکے" } }, "unSupportBlock": "موجودہ ورژن اس بلاک کی حمایت نہیں کرتا۔", "views": { "deleteContentTitle": "کیا آپ یقینی طور پر {pageType} کو حذف کرنا چاہتے ہیں؟", "deleteContentCaption": "اگر آپ اس {pageType} کو حذف کرتے ہیں، تو آپ اسے ٹریش سے بحال کر سکتے ہیں۔" }, "colors": { "custom": "اپنی مرضی کے مطابق", "default": "ڈیفالٹ", "red": "سرخ", "orange": "سنتری", "yellow": "پیلا", "green": "سبز", "blue": "نیلا", "purple": "جامنی", "pink": "گلابی", "brown": "براؤن", "gray": "سرمئی" }, "emoji": { "filter": "فلٹر", "random": "رینڈم", "selectSkinTone": "جلد کا رنگ منتخب کریں", "remove": "ایموجی کو ہٹائیں", "categories": { "smileys": "مسکراہٹیں اور جذبات", "people": "لوگ اور جسم", "animals": "جانور اور فطرت", "food": "کھانا اور پینا", "activities": "سرگرمیاں", "places": "سفر اور مقامات", "objects": "آब्جیکٹس", "symbols": "علامات", "flags": "پرچم", "nature": "فطرت", "frequentlyUsed": "کثرت سے استعمال ہونے والے" } } } ================================================ FILE: frontend/resources/translations/vi-VN.json ================================================ { "appName": "AppFlowy", "defaultUsername": "Tôi", "welcomeText": "Chào mừng bạn đến @:appName", "welcomeTo": "Chào mừng bạn đến", "githubStarText": "Sao trên GitHub", "subscribeNewsletterText": "Đăng ký bản tin", "letsGoButtonText": "Bắt đầu nhanh", "title": "Tiêu đề", "youCanAlso": "Bạn cũng có thể", "and": "và", "failedToOpenUrl": "Không mở được url: {}", "blockActions": { "addBelowTooltip": "Nhấn để thêm vào bên dưới", "addAboveCmd": "Alt+nhấp chuột", "addAboveMacCmd": "Option+nhấp chuột", "addAboveTooltip": "để thêm ở trên", "dragTooltip": "Kéo để di chuyển", "openMenuTooltip": "Bấm để mở menu" }, "signUp": { "buttonText": "Đăng ký", "title": "Đăng ký @:appName", "getStartedText": "Bắt đầu", "emptyPasswordError": "Mật khẩu không thể trống", "repeatPasswordEmptyError": "Mật khẩu nhập lại không được để trống", "unmatchedPasswordError": "Mật khẩu nhập lại không giống mật khẩu", "alreadyHaveAnAccount": "Đã có tài khoản?", "emailHint": "E-mail", "passwordHint": "Mật khẩu", "repeatPasswordHint": "Nhập lại mật khẩu", "signUpWith": "Đăng ký với:" }, "signIn": { "loginTitle": "Đăng nhập vào @:appName", "loginButtonText": "Đăng nhập", "loginStartWithAnonymous": "Bắt đầu với một phiên ẩn danh", "continueAnonymousUser": "Tiếp tục với một phiên ẩn danh", "buttonText": "Đăng nhập", "signingInText": "Đang đăng nhập...", "forgotPassword": "Quên mật khẩu?", "emailHint": "E-mail", "passwordHint": "Mật khẩu", "dontHaveAnAccount": "Bạn chưa có tài khoản?", "createAccount": "Tạo tài khoản", "repeatPasswordEmptyError": "Mật khẩu nhập lại không được để trống", "unmatchedPasswordError": "Mật khẩu nhập lại không giống mật khẩu", "syncPromptMessage": "Việc đồng bộ hóa dữ liệu có thể mất một lúc. Xin đừng đóng trang này", "or": "HOẶC", "signInWithGoogle": "Tiếp tục với Google", "signInWithGithub": "Tiếp tục với Github", "signInWithDiscord": "Tiếp tục với Discord", "signInWithApple": "Tiếp tục với Apple", "continueAnotherWay": "Tiếp tục theo cách khác", "signUpWithGoogle": "Đăng ký với Google", "signUpWithGithub": "Đăng ký với Github", "signUpWithDiscord": "Đăng ký với Discord", "signInWith": "Đăng nhập bằng:", "signInWithEmail": "Đăng nhập bằng Email", "signInWithMagicLink": "Tiếp tục", "signUpWithMagicLink": "Đăng ký với Magic Link", "pleaseInputYourEmail": "Vui lòng nhập địa chỉ email của bạn", "settings": "Cài đặt", "magicLinkSent": "Đã gửi Magic Link!", "invalidEmail": "Vui lòng nhập địa chỉ email hợp lệ", "alreadyHaveAnAccount": "Bạn đã có tài khoản?", "logIn": "Đăng nhập", "generalError": "Có gì đó không ổn. Vui lòng thử lại sau", "limitRateError": "Vì lý do bảo mật, bạn chỉ có thể yêu cầu Magic Link sau mỗi 60 giây", "magicLinkSentDescription": "Một Magic Link đã được gửi đến email của bạn. Nhấp vào liên kết để hoàn tất đăng nhập. Liên kết sẽ hết hạn sau 5 phút.", "anonymous": "Ẩn danh", "LogInWithGoogle": "Đăng nhập bằng Google", "LogInWithGithub": "Đăng nhập bằng Github", "LogInWithDiscord": "Đăng nhập bằng Discord", "loginAsGuestButtonText": "Đăng nhập với chế độ khách" }, "workspace": { "chooseWorkspace": "Chọn không gian làm việc của bạn", "create": "Tạo không gian làm việc", "reset": "Đặt lại không gian làm việc", "renameWorkspace": "Đổi tên không gian làm việc", "resetWorkspacePrompt": "Đặt lại không gian làm việc sẽ xóa tất cả các trang và dữ liệu trong đó. Bạn có chắc chắn muốn đặt lại không gian làm việc? Ngoài ra, bạn có thể liên hệ với nhóm hỗ trợ để khôi phục không gian làm việc", "hint": "không gian làm việc", "notFoundError": "Không tìm thấy không gian làm việc", "failedToLoad": "Đã xảy ra lỗi! Không tải được không gian làm việc. Hãy thử đóng mọi phiên bản đang mở của @:appName và thử lại.", "errorActions": { "reportIssue": "Báo cáo một vấn đề", "reportIssueOnGithub": "Báo cáo sự cố trên Github", "exportLogFiles": "Xuất tệp nhật ký", "reachOut": "Báo cáo trên Discord" }, "menuTitle": "Không gian làm việc", "deleteWorkspaceHintText": "Bạn có chắc chắn muốn xóa không gian làm việc không? Hành động này không thể được hoàn tác.", "createSuccess": "Không gian làm việc được tạo thành công", "createFailed": "Không tạo được không gian làm việc", "createLimitExceeded": "Bạn đã đạt đến giới hạn không gian làm việc tối đa được phép cho tài khoản của mình. Nếu bạn cần thêm không gian làm việc để tiếp tục công việc của mình, vui lòng yêu cầu trên Github", "deleteSuccess": "Đã xóa thành công không gian làm việc", "deleteFailed": "Không thể xóa không gian làm việc", "openSuccess": "Mở không gian làm việc thành công", "openFailed": "Không thể mở không gian làm việc", "renameSuccess": "Đã đổi tên không gian làm việc thành công", "renameFailed": "Không đổi tên được không gian làm việc", "updateIconSuccess": "Đã cập nhật biểu tượng không gian làm việc thành công", "updateIconFailed": "Biểu tượng không gian làm việc không được cập nhật thành công", "cannotDeleteTheOnlyWorkspace": "Không thể xóa không gian làm việc duy nhất", "fetchWorkspacesFailed": "Không thể lấy được không gian làm việc", "leaveCurrentWorkspace": "Rời khỏi không gian làm việc", "leaveCurrentWorkspacePrompt": "Bạn có chắc chắn muốn rời khỏi không gian làm việc hiện tại không?" }, "shareAction": { "buttonText": "Chia sẻ", "workInProgress": "Sắp ra mắt", "markdown": "Markdown", "html": "HTML", "clipboard": "Sao chép vào clipboard", "csv": "CSV", "copyLink": "Sao chép đường dẫn", "publishToTheWeb": "Xuất bản lên Web", "publishToTheWebHint": "Tạo trang web với AppFlowy", "publish": "Xuất bản", "unPublish": "Hủy xuất bản", "visitSite": "Truy cập trang web", "exportAsTab": "Xuất khẩu dưới dạng", "publishTab": "Xuất bản", "shareTab": "Chia sẻ" }, "moreAction": { "small": "nhỏ", "medium": "trung bình", "large": "lớn", "fontSize": "Cỡ chữ", "import": "Nhập", "moreOptions": "Lựa chọn khác", "wordCount": "Số từ: {}", "charCount": "Số ký tự: {}", "createdAt": "Tạo: {}", "deleteView": "Xóa", "duplicateView": "Nhân bản" }, "importPanel": { "textAndMarkdown": "Văn bản & Markdown", "documentFromV010": "Tài liệu từ v0.1.0", "databaseFromV010": "Cơ sở dữ liệu từ v0.1.0", "csv": "CSV", "database": "Cơ sở dữ liệu" }, "disclosureAction": { "rename": "Đổi tên", "delete": "Xóa", "duplicate": "Nhân bản", "unfavorite": "Xoá khỏi mục ưa thích", "favorite": "Thêm vào mục yêu thích", "openNewTab": "Mở trong tab mới", "moveTo": "Chuyển tới", "addToFavorites": "Thêm vào mục yêu thích", "copyLink": "Sao chép đường dẫn", "changeIcon": "Thay đổi biểu tượng", "collapseAllPages": "Thu gọn tất cả các trang con" }, "blankPageTitle": "Trang trống", "newPageText": "Trang mới", "newDocumentText": "Tài liệu mới", "newGridText": "Lưới mới", "newCalendarText": "Lịch mới", "newBoardText": "Bảng mới", "chat": { "newChat": "AI Chat", "inputMessageHint": "Hỏi @:appName AI", "inputLocalAIMessageHint": "Hỏi @:appName AI cục bộ", "unsupportedCloudPrompt": "Tính năng này chỉ khả dụng khi sử dụng @:appName Cloud", "relatedQuestion": "Có liên quan", "serverUnavailable": "Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.", "aiServerUnavailable": "🌈 Ồ không! 🌈. Một con kỳ lân đã ăn mất câu trả lời của chúng tôi. Vui lòng thử lại!", "clickToRetry": "Nhấp để thử lại", "regenerateAnswer": "Tạo lại", "question1": "Cách sử dụng Kanban để quản lý nhiệm vụ", "question2": "Giải thích phương pháp GTD", "question3": "Tại sao sử dụng Rust", "question4": "Công thức với những gì đang có", "aiMistakePrompt": "AI có thể mắc lỗi. Hãy kiểm tra thông tin quan trọng.", "chatWithFilePrompt": "Bạn có muốn trò chuyện với tập tin không?", "indexFileSuccess": "Đang lập chỉ mục tệp thành công", "inputActionNoPages": "Không có kết quả trang", "referenceSource": { "zero": "0 nguồn được tìm thấy", "one": "{count} nguồn đã tìm thấy", "other": "{count} nguồn được tìm thấy" }, "clickToMention": "Nhấp để đề cập đến một trang", "uploadFile": "Tải lên các tệp PDF, md hoặc txt để trò chuyện", "questionDetail": "Xin chào {}! Tôi có thể giúp gì cho bạn hôm nay?", "indexingFile": "Đang lập chỉ mục {}" }, "trash": { "text": "Thùng rác", "restoreAll": "Khôi phục lại tất cả", "deleteAll": "Xóa tất cả ", "pageHeader": { "fileName": "Tên tập tin", "lastModified": "Sửa đổi lần cuối", "created": "Đã tạo" }, "confirmDeleteAll": { "title": "Bạn có chắc chắn xóa tất cả các trang trong Thùng rác không?", "caption": "Hành động này không thể được hoàn tác." }, "confirmRestoreAll": { "title": "Bạn có chắc chắn khôi phục tất cả các trang trong Thùng rác không?", "caption": "Hành động này không thể được hoàn tác." }, "mobile": { "actions": "Hành động Thùng rác", "empty": "Thùng rác rỗng", "emptyDescription": "Bạn không có tập tin nào bị xóa", "isDeleted": "đã bị xóa", "isRestored": "đã được phục hồi" }, "confirmDeleteTitle": "Bạn có chắc chắn muốn xóa trang này vĩnh viễn không?" }, "deletePagePrompt": { "text": "Trang này nằm trong Thùng rác", "restore": "Khôi phục trang", "deletePermanent": "Xóa vĩnh viễn" }, "dialogCreatePageNameHint": "Tên trang", "questionBubble": { "shortcuts": "Phím tắt", "whatsNew": "Có gì mới?", "markdown": "Markdown", "debug": { "name": "Thông tin gỡ lỗi", "success": "Đã sao chép thông tin gỡ lỗi vào khay nhớ tạm!", "fail": "Không thể sao chép thông tin gỡ lỗi vào khay nhớ tạm" }, "feedback": "Nhận xét", "help": "Trợ giúp & Hỗ trợ" }, "menuAppHeader": { "moreButtonToolTip": "Xóa, đổi tên và hơn thế nữa...", "addPageTooltip": "Nhanh chóng thêm một trang bên trong", "defaultNewPageName": "Không tiêu đề", "renameDialog": "Đổi tên" }, "noPagesInside": "Không có trang bên trong", "toolbar": { "undo": "Hoàn tác", "redo": "Làm lại", "bold": "In đậm", "italic": "In nghiêng", "underline": "Gạch chân", "strike": "Gạch ngang", "numList": "Danh sách đánh số", "bulletList": "Danh sách có dấu đầu dòng", "checkList": "Danh mục", "inlineCode": "Mã nội tuyến", "quote": "Khối trích dẫn", "header": "Tiêu đề", "highlight": "Điểm nổi bật", "color": "Màu", "addLink": "Thêm liên kết", "link": "Liên kết" }, "tooltip": { "lightMode": "Chuyển sang chế độ sáng", "darkMode": "Chuyển sang chế độ tối", "openAsPage": "Mở dưới dạng Trang", "addNewRow": "Thêm một hàng mới", "openMenu": "Bấm để mở menu", "dragRow": "Nhấn và giữ để sắp xếp lại hàng", "viewDataBase": "Xem cơ sở dữ liệu", "referencePage": "{name} này được tham chiếu", "addBlockBelow": "Thêm một khối bên dưới", "aiGenerate": "Tạo" }, "sideBar": { "closeSidebar": "Đóng thanh bên", "openSidebar": "Thanh bên mở", "personal": "Riêng tư", "private": "Riêng tư", "workspace": "Không gian làm việc", "favorites": "Yêu thích", "clickToHidePrivate": "Nhấp để ẩn không gian riêng tư\nCác trang bạn tạo ở đây chỉ hiển thị với bạn", "clickToHideWorkspace": "Nhấp để ẩn không gian làm việc\nCác trang bạn tạo ở đây có thể được mọi thành viên nhìn thấy", "clickToHidePersonal": "Bấm để ẩn mục riêng tư", "clickToHideFavorites": "Bấm để ẩn mục yêu thích", "addAPage": "Thêm một trang", "addAPageToPrivate": "Thêm một trang vào không gian riêng tư", "addAPageToWorkspace": "Thêm một trang vào không gian làm việc", "recent": "Gần đây", "today": "Hôm nay", "thisWeek": "Tuần này", "others": "Yêu thích trước đó", "justNow": "ngay bây giờ", "minutesAgo": "{count} phút trước", "lastViewed": "Đã xem lần cuối", "favoriteAt": "Đã yêu thích", "emptyRecent": "Không có tài liệu gần đây", "emptyRecentDescription": "Khi bạn xem tài liệu, chúng sẽ xuất hiện ở đây để dễ dàng truy xuất", "emptyFavorite": "Không có tài liệu yêu thích", "emptyFavoriteDescription": "Bắt đầu khám phá và đánh dấu tài liệu là mục yêu thích. Chúng sẽ được liệt kê ở đây để truy cập nhanh!", "removePageFromRecent": "Xóa trang này khỏi mục Gần đây?", "removeSuccess": "Đã xóa thành công", "favoriteSpace": "Yêu thích", "RecentSpace": "Gần đây", "Spaces": "Khoảng cách", "upgradeToPro": "Nâng cấp lên Pro", "upgradeToAIMax": "Mở khóa AI không giới hạn", "storageLimitDialogTitle": "Bạn đã hết dung lượng lưu trữ miễn phí. Nâng cấp để mở khóa dung lượng lưu trữ không giới hạn", "storageLimitDialogTitleIOS": "Bạn đã hết dung lượng lưu trữ miễn phí.", "aiResponseLimitTitle": "Bạn đã hết phản hồi AI miễn phí. Nâng cấp lên Gói Pro hoặc mua tiện ích bổ sung AI để mở khóa phản hồi không giới hạn", "aiResponseLimitDialogTitle": "Đã đạt đến giới hạn sử dụng AI", "aiResponseLimit": "Bạn đã hết lượt dùng AI miễn phí.\nVào Cài đặt -> Gói đăng ký -> Nhấp vào AI Max hoặc Gói Pro để có thêm lượt dùng AI", "askOwnerToUpgradeToPro": "Không gian làm việc của bạn sắp hết dung lượng lưu trữ miễn phí. Vui lòng yêu cầu chủ sở hữu không gian làm việc của bạn nâng cấp lên Gói Pro", "askOwnerToUpgradeToProIOS": "Không gian làm việc của bạn sắp hết dung lượng lưu trữ miễn phí.", "askOwnerToUpgradeToAIMax": "Không gian làm việc của bạn sắp hết phản hồi AI miễn phí. Vui lòng yêu cầu chủ sở hữu không gian làm việc của bạn nâng cấp gói hoặc mua tiện ích bổ sung AI", "askOwnerToUpgradeToAIMaxIOS": "Không gian làm việc của bạn sắp hết lượt sử dụng AI miễn phí.", "purchaseStorageSpace": "Mua không gian lưu trữ", "purchaseAIResponse": "Mua ", "askOwnerToUpgradeToLocalAI": "Yêu cầu chủ sở hữu không gian làm việc bật AI trên thiết bị", "upgradeToAILocal": "Chạy các mô hình cục bộ trên thiết bị của bạn để có quyền riêng tư tối đa", "upgradeToAILocalDesc": "Trò chuyện với PDF, cải thiện khả năng viết và tự động điền bảng bằng AI cục bộ" }, "notifications": { "export": { "markdown": "Đã xuất ghi chú sang Markdown", "path": "Tài liệu/flowy" } }, "contactsPage": { "title": "Danh bạ", "whatsHappening": "Những gì đang xảy ra trong tuần này?", "addContact": "Thêm liên hệ", "editContact": "Chỉnh sửa liên hệ" }, "button": { "ok": "OK", "confirm": "Xác nhận", "done": "Xong", "cancel": "Hủy", "signIn": "Đăng nhập", "signOut": "Đăng xuất", "complete": "Hoàn thành", "save": "Lưu", "generate": "Tạo", "esc": "ESC", "keep": "Giữ", "tryAgain": "Thử lại", "discard": "Bỏ", "replace": "Thay thế", "insertBelow": "Chèn bên dưới", "insertAbove": "Chèn ở trên", "upload": "Tải lên", "edit": "Sửa", "delete": "Xoá", "duplicate": "Nhân bản", "putback": "Để lại", "update": "Cập nhật", "share": "Chia sẻ", "removeFromFavorites": "Loại bỏ khỏi mục yêu thích", "removeFromRecent": "Xóa khỏi gần đây", "addToFavorites": "Thêm vào mục yêu thích", "favoriteSuccessfully": "Đã thêm vào yêu thích", "unfavoriteSuccessfully": "Đã xoá khỏi yêu thích", "duplicateSuccessfully": "Đã sao chép thành công", "rename": "Đổi tên", "helpCenter": "Trung tâm trợ giúp", "add": "Thêm", "yes": "Đúng", "no": "Không", "clear": "Xoá", "remove": "Di chuyển", "dontRemove": "Không xóa", "copyLink": "Sao chép đường dẫn", "align": "Căn chỉnh", "login": "Đăng nhập", "logout": "Đăng xuất", "deleteAccount": "Xóa tài khoản", "back": "Quay lại", "signInGoogle": "Đăng nhập bằng Google", "signInGithub": "Đăng nhập bằng Github", "signInDiscord": "Đăng nhập bằng Discord", "more": "Hơn nữa", "create": "Tạo mới", "close": "Đóng", "next": "Kế tiếp", "previous": "Trước đó", "submit": "Gửi", "download": "Tải về", "tryAGain": "Thử lại" }, "label": { "welcome": "Chào mừng!", "firstName": "Tên", "middleName": "Tên đệm", "lastName": "Họ", "stepX": "Bước {X}" }, "oAuth": { "err": { "failedTitle": "Không thể kết nối với tài khoản của bạn.", "failedMsg": "Vui lòng đảm bảo bạn đã hoàn tất quá trình đăng nhập trong trình duyệt của mình." }, "google": { "title": "ĐĂNG NHẬP GOOGLE", "instruction1": "Để nhập Danh bạ Google của bạn, bạn cần cấp quyền cho ứng dụng này bằng trình duyệt web của mình.", "instruction2": "Sao chép mã này vào khay nhớ tạm của bạn bằng cách nhấp vào biểu tượng hoặc chọn văn bản:", "instruction3": "Điều hướng đến liên kết sau trong trình duyệt web của bạn và nhập mã ở trên:", "instruction4": "Nhấn nút bên dưới khi bạn hoàn tất đăng ký:" } }, "settings": { "title": "Cài đặt", "popupMenuItem": { "settings": "Cài đặt", "members": "Thành viên", "trash": "Thùng Rác", "helpAndSupport": "Trợ giúp & Hỗ trợ" }, "accountPage": { "menuLabel": "Tài khoản của tôi", "title": "Tài khoản của tôi", "general": { "title": "Tên tài khoản & ảnh đại diện", "changeProfilePicture": "Thay đổi ảnh đại diện" }, "email": { "title": "E-mail", "actions": { "change": "Thay đổi email" } }, "login": { "title": "Đăng nhập tài khoản", "loginLabel": "Đăng nhập", "logoutLabel": "Đăng xuất" } }, "workspacePage": { "menuLabel": "Không gian làm việc", "title": "Không gian làm việc", "description": "Tùy chỉnh giao diện không gian làm việc, chủ đề, phông chữ, bố cục văn bản, định dạng ngày/giờ và ngôn ngữ.", "workspaceName": { "title": "Tên không gian làm việc" }, "workspaceIcon": { "title": "Biểu tượng không gian làm việc", "description": "Tải lên hình ảnh hoặc sử dụng biểu tượng cảm xúc cho không gian làm việc của bạn. Biểu tượng sẽ hiển thị trên thanh bên và thông báo của bạn." }, "appearance": { "title": "Vẻ bề ngoài", "description": "Tùy chỉnh giao diện không gian làm việc, chủ đề, phông chữ, bố cục văn bản, ngày, giờ và ngôn ngữ.", "options": { "system": "Tự động", "light": "Sáng", "dark": "Tối" } }, "resetCursorColor": { "title": "Đặt lại màu con trỏ tài liệu", "description": "Bạn có chắc chắn muốn thiết lập lại màu con trỏ không?" }, "resetSelectionColor": { "title": "Đặt lại màu lựa chọn tài liệu", "description": "Bạn có chắc chắn muốn thiết lập lại màu đã chọn không?" }, "theme": { "title": "Chủ đề", "description": "Chọn chủ đề có sẵn hoặc tải lên chủ đề tùy chỉnh của riêng bạn.", "uploadCustomThemeTooltip": "Tải lên một chủ đề tùy chỉnh" }, "workspaceFont": { "title": "Phông chữ không gian làm việc", "noFontHint": "Không tìm thấy phông chữ, hãy thử thuật ngữ khác." }, "textDirection": { "title": "Hướng văn bản", "leftToRight": "Từ trái sang phải", "rightToLeft": "Từ phải sang trái", "auto": "Tự động", "enableRTLItems": "Bật các mục thanh công cụ RTL" }, "layoutDirection": { "title": "Hướng bố trí", "leftToRight": "Từ trái sang phải", "rightToLeft": "Từ phải sang trái" }, "dateTime": { "title": "Ngày & giờ", "example": "{} tại {} ({})", "24HourTime": "thời gian 24 giờ", "dateFormat": { "label": "Định dạng ngày tháng", "local": "Địa phương", "us": "US", "iso": "ISO", "friendly": "Thân thiện", "dmy": "D/M/Y" } }, "language": { "title": "Ngôn ngữ" }, "deleteWorkspacePrompt": { "title": "Xóa không gian làm việc", "content": "Bạn có chắc chắn muốn xóa không gian làm việc này không? Hành động này không thể hoàn tác và bất kỳ trang nào bạn đã xuất bản sẽ bị hủy xuất bản." }, "leaveWorkspacePrompt": { "title": "Rời khỏi không gian làm việc", "content": "Bạn có chắc chắn muốn rời khỏi không gian làm việc này không? Bạn sẽ mất quyền truy cập vào tất cả các trang và dữ liệu trong đó." }, "manageWorkspace": { "title": "Quản lý không gian làm việc", "leaveWorkspace": "Rời khỏi không gian làm việc", "deleteWorkspace": "Xóa không gian làm việc" } }, "manageDataPage": { "menuLabel": "Quản lý dữ liệu", "title": "Quản lý dữ liệu", "description": "Quản lý dữ liệu lưu trữ cục bộ hoặc Nhập dữ liệu hiện có của bạn vào @:appName .", "dataStorage": { "title": "Vị trí lưu trữ tập tin", "tooltip": "Vị trí lưu trữ các tập tin của bạn", "actions": { "change": "Thay đổi đường dẫn", "open": "Mở thư mục", "openTooltip": "Mở vị trí thư mục dữ liệu hiện tại", "copy": "Sao chép đường dẫn", "copiedHint": "Đã sao chép đường dẫn!", "resetTooltip": "Đặt lại về vị trí mặc định" }, "resetDialog": { "title": "Bạn có chắc không?", "description": "Đặt lại đường dẫn đến vị trí dữ liệu mặc định sẽ không xóa dữ liệu của bạn. Nếu bạn muốn nhập lại dữ liệu hiện tại, trước tiên bạn nên sao chép đường dẫn đến vị trí hiện tại của mình." } }, "importData": { "title": "Nhập dữ liệu", "tooltip": "Nhập dữ liệu từ các thư mục sao lưu/dữ liệu @:appName", "description": "Sao chép dữ liệu từ thư mục dữ liệu @:appName bên ngoài", "action": "Duyệt tập tin" }, "encryption": { "title": "Mã hóa", "tooltip": "Quản lý cách dữ liệu của bạn được lưu trữ và mã hóa", "descriptionNoEncryption": "Bật mã hóa sẽ mã hóa toàn bộ dữ liệu. Không thể hoàn tác thao tác này.", "descriptionEncrypted": "Dữ liệu của bạn được mã hóa.", "action": "Mã hóa dữ liệu", "dialog": { "title": "Mã hóa toàn bộ dữ liệu của bạn?", "description": "Mã hóa tất cả dữ liệu của bạn sẽ giữ cho dữ liệu của bạn an toàn và bảo mật. Hành động này KHÔNG THỂ hoàn tác. Bạn có chắc chắn muốn tiếp tục không?" } }, "cache": { "title": "Xóa bộ nhớ đệm", "description": "Giúp giải quyết các vấn đề như hình ảnh không tải được, thiếu trang trong một khoảng trắng và phông chữ không tải được. Điều này sẽ không ảnh hưởng đến dữ liệu của bạn.", "dialog": { "title": "Xóa bộ nhớ đệm", "description": "Giúp giải quyết các vấn đề như hình ảnh không tải được, thiếu trang trong một khoảng trắng và phông chữ không tải được. Điều này sẽ không ảnh hưởng đến dữ liệu của bạn.", "successHint": "Đã xóa bộ nhớ đệm!" } }, "data": { "fixYourData": "Sửa dữ liệu của bạn", "fixButton": "Sửa", "fixYourDataDescription": "Nếu bạn gặp sự cố với dữ liệu, bạn có thể thử khắc phục tại đây." } }, "shortcutsPage": { "menuLabel": "Phím tắt", "title": "Phím tắt", "editBindingHint": "Nhập phím tắt mới", "searchHint": "Tìm kiếm", "actions": { "resetDefault": "Đặt lại mặc định" }, "errorPage": { "message": "Không tải được phím tắt: {}", "howToFix": "Vui lòng thử lại. Nếu sự cố vẫn tiếp diễn, vui lòng liên hệ trên GitHub." }, "resetDialog": { "title": "Đặt lại phím tắt", "description": "Thao tác này sẽ khôi phục tất cả các phím tắt của bạn về mặc định, bạn không thể hoàn tác thao tác này sau đó, bạn có chắc chắn muốn tiếp tục không?", "buttonLabel": "Cài lại" }, "conflictDialog": { "title": "{} hiện đang được sử dụng", "descriptionPrefix": "Phím tắt này hiện đang được sử dụng bởi ", "descriptionSuffix": ". Nếu bạn thay thế phím tắt này, nó sẽ bị xóa khỏi {}.", "confirmLabel": "Tiếp tục" }, "editTooltip": "Nhấn để bắt đầu chỉnh sửa phím tắt", "keybindings": { "toggleToDoList": "Chuyển sang danh sách việc cần làm", "insertNewParagraphInCodeblock": "Chèn đoạn văn mới", "pasteInCodeblock": "Dán vào codeblock", "selectAllCodeblock": "Chọn tất cả", "indentLineCodeblock": "Chèn hai khoảng trắng vào đầu dòng", "outdentLineCodeblock": "Xóa hai khoảng trắng ở đầu dòng", "twoSpacesCursorCodeblock": "Chèn hai khoảng trắng vào con trỏ", "copy": "Sao chép lựa chọn", "paste": "Dán vào nội dung", "cut": "Cắt lựa chọn", "alignLeft": "Căn chỉnh văn bản sang trái", "alignCenter": "Căn giữa văn bản", "alignRight": "Căn chỉnh văn bản bên phải", "undo": "Hoàn tác", "redo": "Làm lại", "convertToParagraph": "Chuyển đổi khối thành đoạn văn", "backspace": "Xóa bỏ", "deleteLeftWord": "Xóa từ bên trái", "deleteLeftSentence": "Xóa câu bên trái", "delete": "Xóa ký tự bên phải", "deleteMacOS": "Xóa ký tự bên trái", "deleteRightWord": "Xóa từ bên phải", "moveCursorLeft": "Di chuyển con trỏ sang trái", "moveCursorBeginning": "Di chuyển con trỏ đến đầu", "moveCursorLeftWord": "Di chuyển con trỏ sang trái một từ", "moveCursorLeftSelect": "Chọn và di chuyển con trỏ sang trái", "moveCursorBeginSelect": "Chọn và di chuyển con trỏ đến đầu", "moveCursorLeftWordSelect": "Chọn và di chuyển con trỏ sang trái một từ", "moveCursorRight": "Di chuyển con trỏ sang phải", "moveCursorEnd": "Di chuyển con trỏ đến cuối", "moveCursorRightWord": "Di chuyển con trỏ sang phải một từ", "moveCursorRightSelect": "Chọn và di chuyển con trỏ sang phải một", "moveCursorEndSelect": "Chọn và di chuyển con trỏ đến cuối", "moveCursorRightWordSelect": "Chọn và di chuyển con trỏ sang phải một từ", "moveCursorUp": "Di chuyển con trỏ lên", "moveCursorTopSelect": "Chọn và di chuyển con trỏ lên trên cùng", "moveCursorTop": "Di chuyển con trỏ lên trên cùng", "moveCursorUpSelect": "Chọn và di chuyển con trỏ lên", "moveCursorBottomSelect": "Chọn và di chuyển con trỏ xuống dưới cùng", "moveCursorBottom": "Di chuyển con trỏ xuống dưới cùng", "moveCursorDown": "Di chuyển con trỏ xuống", "moveCursorDownSelect": "Chọn và di chuyển con trỏ xuống", "home": "Cuộn lên đầu trang", "end": "Cuộn xuống dưới cùng", "toggleBold": "Chuyển đổi chữ đậm", "toggleItalic": "Chuyển đổi nghiêng", "toggleUnderline": "Chuyển đổi gạch chân", "toggleStrikethrough": "Chuyển đổi gạch ngang", "toggleCode": "Chuyển đổi mã trong dòng", "toggleHighlight": "Chuyển đổi nổi bật", "showLinkMenu": "Hiển thị menu liên kết", "openInlineLink": "Mở liên kết nội tuyến", "openLinks": "Mở tất cả các liên kết đã chọn", "indent": "thụt lề", "outdent": "Nhô ra ngoài", "exit": "Thoát khỏi chỉnh sửa", "pageUp": "Cuộn lên một trang", "pageDown": "Cuộn xuống một trang", "selectAll": "Chọn tất cả", "pasteWithoutFormatting": "Dán nội dung không định dạng", "showEmojiPicker": "Hiển thị bộ chọn biểu tượng cảm xúc", "enterInTableCell": "Thêm ngắt dòng trong bảng", "leftInTableCell": "Di chuyển sang trái một ô trong bảng", "rightInTableCell": "Di chuyển sang phải một ô trong bảng", "upInTableCell": "Di chuyển lên một ô trong bảng", "downInTableCell": "Di chuyển xuống một ô trong bảng", "tabInTableCell": "Đi tới ô có sẵn tiếp theo trong bảng", "shiftTabInTableCell": "Đi đến ô có sẵn trước đó trong bảng", "backSpaceInTableCell": "Dừng lại ở đầu ô" }, "commands": { "codeBlockNewParagraph": "Chèn một đoạn văn mới bên cạnh khối mã", "codeBlockIndentLines": "Chèn hai khoảng trắng vào đầu dòng trong khối mã", "codeBlockOutdentLines": "Xóa hai khoảng trắng ở đầu dòng trong khối mã", "codeBlockAddTwoSpaces": "Chèn hai khoảng trắng vào vị trí con trỏ trong khối mã", "codeBlockSelectAll": "Chọn tất cả nội dung bên trong một khối mã", "codeBlockPasteText": "Dán văn bản vào codeblock", "textAlignLeft": "Căn chỉnh văn bản sang trái", "textAlignCenter": "Căn chỉnh văn bản vào giữa", "textAlignRight": "Căn chỉnh văn bản sang phải" }, "couldNotLoadErrorMsg": "Không thể tải phím tắt, hãy thử lại", "couldNotSaveErrorMsg": "Không thể lưu phím tắt, hãy thử lại" }, "aiPage": { "title": "Cài đặt AI", "menuLabel": "Cài đặt AI", "keys": { "enableAISearchTitle": "Tìm kiếm AI", "aiSettingsDescription": "Chọn mô hình ưa thích của bạn để hỗ trợ AppFlowy AI. Bây giờ bao gồm GPT 4-o, Claude 3,5, Llama 3.1 và Mistral 7B", "loginToEnableAIFeature": "Các tính năng AI chỉ được bật sau khi đăng nhập bằng @:appName Cloud. Nếu bạn không có tài khoản @:appName , hãy vào 'Tài khoản của tôi' để đăng ký", "llmModel": "Mô hình ngôn ngữ", "llmModelType": "Kiểu mô hình ngôn ngữ", "downloadLLMPrompt": "Tải xuống {}", "downloadAppFlowyOfflineAI": "Tải xuống gói AI ngoại tuyến sẽ cho phép AI chạy trên thiết bị của bạn. Bạn có muốn tiếp tục không?", "downloadLLMPromptDetail": "Tải xuống mô hình cục bộ {} sẽ chiếm tới {} dung lượng lưu trữ. Bạn có muốn tiếp tục không?", "downloadBigFilePrompt": "Có thể mất khoảng 10 phút để hoàn tất việc tải xuống", "downloadAIModelButton": "Tải về", "downloadingModel": "Đang tải xuống", "localAILoaded": "Mô hình AI cục bộ đã được thêm thành công và sẵn sàng sử dụng", "localAIStart": "Trò chuyện AI cục bộ đang bắt đầu...", "localAILoading": "Mô hình trò chuyện AI cục bộ đang tải...", "localAIStopped": "AI cục bộ đã dừng", "failToLoadLocalAI": "Không thể khởi động AI cục bộ", "restartLocalAI": "Khởi động lại AI cục bộ", "disableLocalAITitle": "Vô hiệu hóa AI cục bộ", "disableLocalAIDescription": "Bạn có muốn tắt AI cục bộ không?", "localAIToggleTitle": "Chuyển đổi để bật hoặc tắt AI cục bộ", "offlineAIInstruction1": "Theo dõi", "offlineAIInstruction2": "chỉ dẫn", "offlineAIInstruction3": "để kích hoạt AI ngoại tuyến.", "offlineAIDownload1": "Nếu bạn chưa tải xuống AppFlowy AI, vui lòng", "offlineAIDownload2": "tải về", "offlineAIDownload3": "nó đầu tiên", "activeOfflineAI": "Tích cực", "downloadOfflineAI": "Tải về", "openModelDirectory": "Mở thư mục" } }, "planPage": { "menuLabel": "Kế hoạch", "title": "Giá gói", "planUsage": { "title": "Tóm tắt sử dụng kế hoạch", "storageLabel": "Kho", "storageUsage": "{} của {} GB", "unlimitedStorageLabel": "Lưu trữ không giới hạn", "collaboratorsLabel": "Thành viên", "collaboratorsUsage": "{} của {}", "aiResponseLabel": "Phản hồi của AI", "aiResponseUsage": "{} của {}", "unlimitedAILabel": "Phản hồi không giới hạn", "proBadge": "Chuyên nghiệp", "aiMaxBadge": "AI Tối đa", "aiOnDeviceBadge": "AI trên thiết bị dành cho máy Mac", "memberProToggle": "Thêm thành viên và AI không giới hạn", "aiMaxToggle": "AI không giới hạn và quyền truy cập vào các mô hình tiên tiến", "aiOnDeviceToggle": "AI cục bộ cho sự riêng tư tối đa", "aiCredit": { "title": "Thêm @:appName Tín dụng AI", "price": "{}", "priceDescription": "cho 1.000 tín dụng", "purchase": "Mua AI", "info": "Thêm 1.000 tín dụng Ai cho mỗi không gian làm việc và tích hợp AI tùy chỉnh vào quy trình làm việc của bạn một cách liền mạch để có kết quả thông minh hơn, nhanh hơn với tối đa:", "infoItemOne": "10.000 phản hồi cho mỗi cơ sở dữ liệu", "infoItemTwo": "1.000 phản hồi cho mỗi không gian làm việc" }, "currentPlan": { "bannerLabel": "Gói hiện tại", "freeTitle": "Miễn phí", "proTitle": "Pro", "teamTitle": "Nhóm", "freeInfo": "Hoàn hảo cho cá nhân có tối đa 2 thành viên để sắp xếp mọi thứ", "proInfo": "Hoàn hảo cho các nhóm vừa và nhỏ có tối đa 10 thành viên.", "teamInfo": "Hoàn hảo cho tất cả các nhóm làm việc hiệu quả và có tổ chức tốt.", "upgrade": "Thay đổi gói đăng ký", "canceledInfo": "Gói đăng ký của bạn đã bị hủy, bạn sẽ được hạ cấp xuống gói Miễn phí vào ngày {}." }, "addons": { "title": "Tiện ích bổ sung", "addLabel": "Thêm vào", "activeLabel": "Đã thêm", "aiMax": { "title": "AI Max", "description": "Phản hồi AI không giới hạn được hỗ trợ bởi GPT-4o, Claude 3.5 Sonnet và nhiều hơn nữa", "price": "{}", "priceInfo": "cho mỗi người dùng mỗi tháng được thanh toán hàng năm" }, "aiOnDevice": { "title": "AI trên thiết bị dành cho máy Mac", "description": "Chạy Mistral 7B, LLAMA 3 và nhiều mô hình cục bộ khác trên máy của bạn", "price": "{}", "priceInfo": "cho mỗi người dùng mỗi tháng được thanh toán hàng năm", "recommend": "Khuyến nghị M1 hoặc mới hơn" } }, "deal": { "bannerLabel": "Khuyến mãi năm mới!", "title": "Phát triển nhóm của bạn!", "info": "Nâng cấp và tiết kiệm 10% cho gói Pro và Team! Tăng năng suất làm việc của bạn với các tính năng mới mạnh mẽ bao gồm @:appName AI.", "viewPlans": "Xem các gói đăng ký" } } }, "billingPage": { "menuLabel": "Thanh toán", "title": "Thanh toán", "plan": { "title": "Gói đăng ký", "freeLabel": "Miễn phí", "proLabel": "Pro", "planButtonLabel": "Thay đổi gói đăng ký", "billingPeriod": "Chu kỳ thanh toán", "periodButtonLabel": "Chỉnh sửa chu kỳ" }, "paymentDetails": { "title": "Chi tiết thanh toán", "methodLabel": "Phương thức thanh toán", "methodButtonLabel": "Phương pháp chỉnh sửa" }, "addons": { "title": "Tiện ích bổ sung", "addLabel": "Thêm vào", "removeLabel": "Xoá", "renewLabel": "Làm mới", "aiMax": { "label": "AI Max", "description": "Mở khóa AI không giới hạn và các mô hình tiên tiến", "activeDescription": "Hóa đơn tiếp theo phải trả vào ngày {}", "canceledDescription": "AI Max sẽ có sẵn cho đến {}" }, "aiOnDevice": { "label": "AI trên thiết bị dành cho máy Mac", "description": "Mở khóa AI không giới hạn trên thiết bị của bạn", "activeDescription": "Hóa đơn tiếp theo phải trả vào ngày {}", "canceledDescription": "AI On-device dành cho Mac sẽ khả dụng cho đến {}" }, "removeDialog": { "title": "Xoá {}", "description": "Bạn có chắc chắn muốn xóa {plan} không? Bạn sẽ mất quyền truy cập vào các tính năng và lợi ích của {plan} ngay lập tức." } }, "currentPeriodBadge": "HIỆN TẠI", "changePeriod": "Thay đổi chu kỳ", "planPeriod": "{} chu kỳ", "monthlyInterval": "Hàng tháng", "monthlyPriceInfo": "mỗi thành viên được thanh toán hàng tháng", "annualInterval": "Hàng năm", "annualPriceInfo": "mỗi thành viên được thanh toán hàng năm" }, "comparePlanDialog": { "title": "So sánh và lựa chọn gói đăng ký", "planFeatures": "Gói đăng ký\nTính năng", "current": "Hiện tại", "actions": { "upgrade": "Nâng cấp", "downgrade": "Hạ cấp", "current": "Hiện tại" }, "freePlan": { "title": "Miễn phí", "description": "Dành cho cá nhân có tối đa 2 thành viên để tổ chức mọi thứ", "price": "{}", "priceInfo": "miễn phí mãi mãi" }, "proPlan": { "title": "Pro", "description": "Dành cho các nhóm nhỏ để quản lý dự án và kiến thức của nhóm", "price": "{}", "priceInfo": "cho mỗi người dùng mỗi tháng\nđược thanh toán hàng năm\n\n{} được thanh toán hàng tháng" }, "planLabels": { "itemOne": "Không gian làm việc", "itemTwo": "Thành viên", "itemThree": "Kho", "itemFour": "Chỉnh sửa thời gian thực", "itemFive": "Ứng dụng di động", "itemSix": "Phản hồi của AI", "itemFileUpload": "Tải tập tin lên", "tooltipSix": "Trọn đời có nghĩa là số lượng phản hồi không bao giờ được thiết lập lại", "intelligentSearch": "Tìm kiếm thông minh", "tooltipSeven": "Cho phép bạn tùy chỉnh một phần URL cho không gian làm việc của bạn" }, "freeLabels": { "itemOne": "tính phí theo không gian làm việc", "itemTwo": "lên đến 2", "itemThree": "5 GB", "itemFour": "Đúng", "itemFive": "Đúng", "itemSix": "10 trọn đời", "itemFileUpload": "Lên đến 7 MB", "intelligentSearch": "Tìm kiếm thông minh" }, "proLabels": { "itemOne": "tính phí theo không gian làm việc", "itemTwo": "lên đến 10", "itemThree": "không giới hạn", "itemFour": "Đúng", "itemFive": "Đúng", "itemSix": "không giới hạn", "itemFileUpload": "Không giới hạn", "intelligentSearch": "Tìm kiếm thông minh" }, "paymentSuccess": { "title": "Bạn hiện đang sử dụng gói {}!", "description": "Thanh toán của bạn đã được xử lý thành công và gói của bạn đã được nâng cấp lên @:appName {}. Bạn có thể xem chi tiết gói đăng ký của mình trên trang Gói đăng ký" }, "downgradeDialog": { "title": "Bạn có chắc chắn muốn hạ cấp gói đăng ký của mình không?", "description": "Việc hạ cấp gói đăng ký của bạn sẽ đưa bạn trở lại gói Miễn phí. Các thành viên có thể mất quyền truy cập vào không gian làm việc này và bạn có thể cần giải phóng dung lượng để đáp ứng giới hạn lưu trữ của gói Miễn phí.", "downgradeLabel": "Hạ cấp gói đăng ký" } }, "cancelSurveyDialog": { "title": "Rất tiếc khi phải tạm biệt bạn", "description": "Chúng tôi rất tiếc khi phải tạm biệt bạn. Chúng tôi rất muốn nghe phản hồi của bạn để giúp chúng tôi cải thiện @:appName . Vui lòng dành chút thời gian để trả lời một vài câu hỏi.", "commonOther": "Khác", "otherHint": "Viết câu trả lời của bạn ở đây", "questionOne": { "question": "Điều gì khiến bạn hủy đăng ký @:appName Pro?", "answerOne": "Chi phí quá cao", "answerTwo": "Các tính năng không đáp ứng được kỳ vọng", "answerThree": "Đã tìm thấy một giải pháp thay thế tốt hơn", "answerFour": "Không sử dụng đủ để bù đắp chi phí", "answerFive": "Vấn đề dịch vụ hoặc khó khăn kỹ thuật" }, "questionTwo": { "question": "Bạn có khả năng cân nhắc đăng ký lại @:appName Pro trong tương lai không?", "answerOne": "Rất có thể", "answerTwo": "Có khả năng", "answerThree": "Không chắc chắn", "answerFour": "Không có khả năng", "answerFive": "Rất không có khả năng" }, "questionThree": { "question": "Bạn đánh giá cao tính năng Pro nào nhất trong suốt thời gian đăng ký?", "answerOne": "Sự hợp tác của nhiều người dùng", "answerTwo": "Lịch sử phiên bản thời gian dài hơn", "answerThree": "Phản hồi AI không giới hạn", "answerFour": "Truy cập vào các mô hình AI cục bộ" }, "questionFour": { "question": "Bạn sẽ mô tả trải nghiệm chung của bạn với @:appName như thế nào?", "answerOne": "Tuyệt", "answerTwo": "Tốt", "answerThree": "Trung bình", "answerFour": "Dưới mức trung bình", "answerFive": "Không hài lòng" } }, "common": { "reset": "Đặt lại" }, "menu": { "appearance": "Giao diện", "language": "Ngôn ngữ", "user": "Người dùng", "files": "Tập tin", "notifications": "Thông báo", "open": "Mở cài đặt", "logout": "Đăng xuất", "logoutPrompt": "Bạn chắc chắn muốn đăng xuất?", "selfEncryptionLogoutPrompt": "Bạn có chắc chắn bạn muốn thoát? Hãy đảm bảo bạn đã sao chép bí mật mã hóa", "syncSetting": "Cài đặt đồng bộ", "cloudSettings": "Cài đặt đám mây", "enableSync": "Bật tính năng đồng bộ", "enableEncrypt": "Mã hoá dữ liệu", "cloudURL": "URL", "invalidCloudURLScheme": "Scheme không hợp lệ", "cloudServerType": "Máy chủ đám mây", "cloudServerTypeTip": "Xin lưu ý rằng có thể bạn sẽ bị đăng xuất sau khi chuyển máy chủ đám mây", "cloudLocal": "Cục bộ", "cloudAppFlowy": "@:appName Cloud Beta", "cloudAppFlowySelfHost": "@:appName Cloud Tự lưu trữ", "appFlowyCloudUrlCanNotBeEmpty": "URL đám mây không được để trống", "clickToCopy": "Bấm để sao chép", "selfHostStart": "Nếu bạn không có máy chủ, vui lòng tham khảo", "selfHostContent": "tài liệu", "selfHostEnd": "để được hướng dẫn cách tự lưu trữ máy chủ của riêng bạn", "cloudURLHint": "Nhập URL của máy chủ của bạn", "cloudWSURL": "Websocket URL", "cloudWSURLHint": "Nhập địa chỉ websocket của máy chủ của bạn", "restartApp": "Khởi động lại", "restartAppTip": "Khởi động lại ứng dụng để thay đổi có hiệu lực. Xin lưu ý rằng điều này có thể đăng xuất tài khoản hiện tại của bạn", "changeServerTip": "Sau khi thay đổi máy chủ, bạn phải nhấp vào nút khởi động lại để những thay đổi có hiệu lực", "enableEncryptPrompt": "Kích hoạt mã hóa để bảo mật dữ liệu của bạn với bí mật này. Lưu trữ nó một cách an toàn; một khi đã bật thì không thể tắt được. Nếu bị mất, dữ liệu của bạn sẽ không thể phục hồi được. Bấm để sao chép", "inputEncryptPrompt": "Vui lòng nhập bí mật mã hóa của bạn cho", "clickToCopySecret": "Bấm để sao chép bí mật", "configServerSetting": "Cài đặt cấu hình máy chủ của bạn", "configServerGuide": "Sau khi chọn `Bắt đầu nhanh`, điều hướng đến `Cài đặt` rồi đến \"Cài đặt đám mây\" để định cấu hình máy chủ tự lưu trữ của bạn.", "inputTextFieldHint": "Bí mật của bạn", "historicalUserList": "Lịch sử đăng nhập", "historicalUserListTooltip": "Danh sách này hiển thị các tài khoản ẩn danh của bạn. Bạn có thể nhấp vào một tài khoản để xem thông tin chi tiết của tài khoản đó. Các tài khoản ẩn danh được tạo bằng cách nhấp vào nút 'Bắt đầu'", "openHistoricalUser": "Ấn để mở tài khoản ẩn danh", "customPathPrompt": "Lưu trữ thư mục dữ liệu @:appName trong thư mục được đồng bộ hóa trên đám mây như Google Drive có thể gây ra rủi ro. Nếu cơ sở dữ liệu trong thư mục này được truy cập hoặc sửa đổi từ nhiều vị trí cùng một lúc, có thể dẫn đến xung đột đồng bộ hóa và hỏng dữ liệu tiềm ẩn", "importAppFlowyData": "Nhập dữ liệu từ thư mục @:appName bên ngoài", "importingAppFlowyDataTip": "Quá trình nhập dữ liệu đang diễn ra. Vui lòng không đóng ứng dụng", "importAppFlowyDataDescription": "Sao chép dữ liệu từ thư mục dữ liệu @:appName bên ngoài và nhập dữ liệu đó vào thư mục dữ liệu @:appName hiện tại", "importSuccess": "Đã nhập thành công thư mục dữ liệu @:appName", "importFailed": "Nhập thư mục dữ liệu @:appName không thành công", "importGuide": "Để biết thêm chi tiết, vui lòng kiểm tra tài liệu được tham chiếu" }, "notifications": { "enableNotifications": { "label": "Bật thông báo", "hint": "Tắt để ngăn thông báo xuất hiện." }, "showNotificationsIcon": { "label": "Hiển thị biểu tượng thông báo", "hint": "Tắt để ẩn biểu tượng thông báo ở thanh bên." }, "archiveNotifications": { "allSuccess": "Đã lưu trữ tất cả thông báo thành công", "success": "Đã lưu trữ thông báo thành công" }, "markAsReadNotifications": { "allSuccess": "Đã đánh dấu tất cả là đã đọc thành công", "success": "Đã đánh dấu là đã đọc thành công" }, "action": { "markAsRead": "Đánh dấu là đã đọc", "multipleChoice": "Chọn thêm", "archive": "Lưu trữ" }, "settings": { "settings": "Cài đặt", "markAllAsRead": "Đánh dấu tất cả là đã đọc", "archiveAll": "Lưu trữ tất cả" }, "emptyInbox": { "title": "Chưa có thông báo nào", "description": "Bạn sẽ được thông báo ở đây về @đề cập" }, "emptyUnread": { "title": "Không có thông báo chưa đọc", "description": "Bạn đã hiểu hết rồi!" }, "emptyArchived": { "title": "Không có thông báo lưu trữ", "description": "Bạn chưa lưu trữ bất kỳ thông báo nào" }, "tabs": { "inbox": "Hộp thư đến", "unread": "Chưa đọc", "archived": "Đã lưu trữ" }, "refreshSuccess": "Thông báo đã được làm mới thành công", "titles": { "notifications": "Thông báo", "reminder": "Lời nhắc nhở" } }, "appearance": { "resetSetting": "Đặt lại cài đặt", "fontFamily": { "label": "Phông chữ", "search": "Tìm kiếm", "defaultFont": "Hệ thống" }, "themeMode": { "label": "Chế độ Theme", "light": "Chế độ sáng", "dark": "Chế độ tối", "system": "Thích ứng với hệ thống" }, "fontScaleFactor": "Hệ số tỷ lệ phông chữ", "documentSettings": { "cursorColor": "Màu con trỏ", "selectionColor": "Màu lựa chọn tài liệu", "pickColor": "Chọn một màu", "colorShade": "Màu sắc bóng râm", "opacity": "Độ mờ đục", "hexEmptyError": "Màu hex không được để trống", "hexLengthError": "Giá trị hex phải dài 6 chữ số", "hexInvalidError": "Giá trị hex không hợp lệ", "opacityEmptyError": "Độ mờ không được để trống", "opacityRangeError": "Độ mờ phải nằm trong khoảng từ 1 đến 100", "app": "App", "flowy": "Flowy", "apply": "Áp dụng" }, "layoutDirection": { "label": "Hướng bố cục", "hint": "Kiểm soát hướng bố cục nội dung trên màn hình của bạn, từ trái sang phải hoặc phải sang trái.", "ltr": "Trái sang phải", "rtl": "Phải sang trái" }, "textDirection": { "label": "Hướng kí tự mặc định", "hint": "Chỉ định xem văn bản nên bắt đầu từ trái hay phải làm mặc định.", "ltr": "Trái sang phải", "rtl": "Phải sang trái", "auto": "TỰ ĐỘNG", "fallback": "Tương tự như hướng bố cục" }, "themeUpload": { "button": "Tải lên", "uploadTheme": "Tải theme lên", "description": "Tải lên @:appName theme của riêng bạn bằng nút bên dưới.", "loading": "Vui lòng đợi trong khi chúng tôi xác thực và tải theme của bạn lên...", "uploadSuccess": "Theme của bạn đã được tải lên thành công", "deletionFailure": "Không xóa được theme. Hãy thử xóa nó bằng tay.", "filePickerDialogTitle": "Chọn tệp .flowy_plugin", "urlUploadFailure": "Không mở được url: {}" }, "theme": "Chủ đề", "builtInsLabel": "Theme có sẵn", "pluginsLabel": "Các plugin", "dateFormat": { "label": "Định dạng ngày", "local": "Địa phương", "us": "US", "iso": "ISO", "friendly": "Thân thiện", "dmy": "D/M/Y" }, "timeFormat": { "label": "Định dạng giờ", "twelveHour": "12 tiếng", "twentyFourHour": "Hai mươi bốn tiếng" }, "showNamingDialogWhenCreatingPage": "Hiển thị hộp thoại đặt tên khi tạo trang", "enableRTLToolbarItems": "Bật các mục thanh công cụ RTL", "members": { "title": "Cài đặt thành viên", "inviteMembers": "Mời thành viên", "inviteHint": "Mời qua email", "sendInvite": "Gửi lời mời", "copyInviteLink": "Sao chép liên kết mời", "label": "Các thành viên", "user": "Người dùng", "role": "Vai trò", "removeFromWorkspace": "Xóa khỏi Workspace", "removeFromWorkspaceSuccess": "Xóa khỏi không gian làm việc thành công", "removeFromWorkspaceFailed": "Xóa khỏi không gian làm việc không thành công", "owner": "Người sở hữu", "guest": "Khách", "member": "Thành viên", "memberHintText": "Một thành viên có thể đọc và chỉnh sửa các trang", "guestHintText": "Khách có thể đọc, phản hồi, bình luận và chỉnh sửa một số trang nhất định khi được phép.", "emailInvalidError": "Email không hợp lệ, vui lòng kiểm tra và thử lại", "emailSent": "Email đã được gửi, vui lòng kiểm tra hộp thư đến", "members": "các thành viên", "membersCount": { "zero": "{} thành viên", "one": "{} thành viên", "other": "{} thành viên" }, "inviteFailedDialogTitle": "Không gửi được lời mời", "inviteFailedMemberLimit": "Đã đạt đến giới hạn thành viên, vui lòng nâng cấp để mời thêm thành viên.", "inviteFailedMemberLimitMobile": "Không gian làm việc của bạn đã đạt đến giới hạn thành viên. Nâng cấp trên Desktop để mở khóa thêm nhiều tính năng.", "memberLimitExceeded": "Đã đạt đến giới hạn thành viên, vui lòng mời thêm thành viên ", "memberLimitExceededUpgrade": "nâng cấp", "memberLimitExceededPro": "Đã đạt đến giới hạn thành viên, nếu bạn cần thêm thành viên hãy liên hệ ", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "Không thêm được thành viên", "addMemberSuccess": "Thành viên đã được thêm thành công", "removeMember": "Xóa thành viên", "areYouSureToRemoveMember": "Bạn có chắc chắn muốn xóa thành viên này không?", "inviteMemberSuccess": "Lời mời đã được gửi thành công", "failedToInviteMember": "Không mời được thành viên", "workspaceMembersError": "Ồ, có gì đó không ổn", "workspaceMembersErrorDescription": "Chúng tôi không thể tải danh sách thành viên vào lúc này. Vui lòng thử lại sau" } }, "files": { "copy": "Sao chép", "defaultLocation": "Đọc tập tin và vị trí lưu trữ dữ liệu", "exportData": "Xuất dữ liệu của bạn", "doubleTapToCopy": "Nhấn đúp để sao chép đường dẫn", "restoreLocation": "Khôi phục về đường dẫn mặc định của @:appName", "customizeLocation": "Mở thư mục khác", "restartApp": "Vui lòng khởi động lại ứng dụng để những thay đổi có hiệu lực.", "exportDatabase": "Xuất cơ sở dữ liệu", "selectFiles": "Chọn các file cần xuất", "selectAll": "Chọn tất cả", "deselectAll": "Bỏ chọn tất cả", "createNewFolder": "Tạo một thư mục mới", "createNewFolderDesc": "Hãy cho chúng tôi biết nơi bạn muốn lưu trữ dữ liệu của mình", "defineWhereYourDataIsStored": "Xác định nơi dữ liệu của bạn được lưu trữ", "open": "Mở", "openFolder": "Mở một thư mục hiện có", "openFolderDesc": "Đọc và ghi nó vào thư mục @:appName hiện có của bạn", "folderHintText": "tên thư mục", "location": "Tạo một thư mục mới", "locationDesc": "Chọn tên cho thư mục dữ liệu @:appName của bạn", "browser": "Duyệt", "create": "Tạo", "set": "Bộ", "folderPath": "Đường dẫn lưu trữ thư mục của bạn", "locationCannotBeEmpty": "Đường dẫn không thể trống", "pathCopiedSnackbar": "Đã sao chép đường dẫn lưu trữ tệp vào khay nhớ tạm!", "changeLocationTooltips": "Thay đổi thư mục dữ liệu", "change": "Thay đổi", "openLocationTooltips": "Mở thư mục dữ liệu khác", "openCurrentDataFolder": "Mở thư mục dữ liệu hiện tại", "recoverLocationTooltips": "Đặt lại về thư mục dữ liệu mặc định của @:appName", "exportFileSuccess": "Xuất tập tin thành công!", "exportFileFail": "Xuất tập tin thất bại!", "export": "Xuất", "clearCache": "Xóa bộ nhớ đệm", "clearCacheDesc": "Nếu bạn gặp sự cố với hình ảnh không tải hoặc phông chữ không hiển thị đúng, hãy thử xóa bộ nhớ đệm. Hành động này sẽ không xóa dữ liệu người dùng của bạn.", "areYouSureToClearCache": "Bạn có chắc chắn muốn xóa bộ nhớ đệm không?", "clearCacheSuccess": "Đã xóa bộ nhớ đệm thành công!" }, "user": { "name": "Tên", "email": "E-mail", "tooltipSelectIcon": "Chọn biểu tượng", "selectAnIcon": "Chọn một biểu tượng", "pleaseInputYourOpenAIKey": "vui lòng nhập khóa AI của bạn", "clickToLogout": "Nhấn để đăng xuất", "pleaseInputYourStabilityAIKey": "vui lòng nhập khóa Stability AI của bạn" }, "mobile": { "personalInfo": "Thông tin cá nhân", "username": "Tên người dùng ", "usernameEmptyError": "Tên người dùng không được để trống", "about": "Về", "pushNotifications": "Thông báo", "support": "Ủng hộ", "joinDiscord": "Tham gia cùng chúng tôi trên Discord", "privacyPolicy": "Chính sách bảo mật", "userAgreement": "Thoả thuận người dùng", "termsAndConditions": "Điều khoản và điều kiện", "userprofileError": "Không tải được hồ sơ người dùng", "userprofileErrorDescription": "Vui lòng thử đăng xuất và đăng nhập lại để kiểm tra xem sự cố vẫn còn.", "selectLayout": "Chọn bố cục", "selectStartingDay": "Chọn ngày bắt đầu", "version": "Phiên bản" }, "shortcuts": { "shortcutsLabel": "Phím tắt", "updateShortcutStep": "Nhấn tổ hợp phím mong muốn và nhấn ENTER", "shortcutIsAlreadyUsed": "Phím tắt này đã được sử dụng cho: {conflict}" } }, "grid": { "deleteView": "Bạn có chắc chắn muốn xóa chế độ xem này không?", "createView": "Mới", "title": { "placeholder": "Không tiêu đề" }, "settings": { "filter": "Lọc", "sort": "Sắp xếp", "sortBy": "Sắp xếp bằng", "properties": "Thuộc tính", "reorderPropertiesTooltip": "Kéo để sắp xếp lại thuộc tính", "group": "Nhóm", "addFilter": "Thêm bộ lọc", "deleteFilter": "Xóa bộ lọc", "filterBy": "Lọc bằng...", "typeAValue": "Nhập một giá trị...", "layout": "Bố cục", "databaseLayout": "Bố cục", "editView": "Chỉnh sửa chế độ xem", "boardSettings": "Cài đặt bảng", "calendarSettings": "Cài đặt lịch", "createView": "Góc nhìn mới", "duplicateView": "Xem trùng lặp", "deleteView": "Xóa chế độ xem", "numberOfVisibleFields": "{} đã hiển thị", "Properties": "Thuộc tính", "viewList": "Database Views" }, "textFilter": { "contains": "Chứa", "doesNotContain": "Không chứa", "endsWith": "Kết thúc bằng", "startWith": "Bắt đầu với", "is": "Là", "isNot": "Không phải", "isEmpty": "Rỗng", "isNotEmpty": "Không rỗng", "choicechipPrefix": { "isNot": "Không phải", "startWith": "Bắt đầu với", "endWith": "Kết thúc bằng", "isEmpty": "rỗng", "isNotEmpty": "không rỗng" } }, "checkboxFilter": { "isChecked": "Đã chọn", "isUnchecked": "Không chọn", "choicechipPrefix": { "is": "là" } }, "checklistFilter": { "isComplete": "hoàn tất", "isIncomplted": "chưa hoàn tất" }, "selectOptionFilter": { "is": "Là", "isNot": "Không phải", "contains": "Chứa", "doesNotContain": "Không chứa", "isEmpty": "Rỗng", "isNotEmpty": "Không rỗng" }, "dateFilter": { "is": "Là", "before": "Trước", "after": "Sau", "onOrBefore": "Đang diễn ra hoặc trước đó", "onOrAfter": "Đang bật hoặc sau", "between": "Ở giữa", "empty": "Rỗng", "notEmpty": "Không rỗng", "choicechipPrefix": { "before": "Trước", "after": "Sau đó", "onOrBefore": "Vào hoặc trước", "onOrAfter": "Vào hoặc sau", "isEmpty": "Đang trống", "isNotEmpty": "Không trống" } }, "numberFilter": { "equal": "Bằng nhau", "notEqual": "Không bằng", "lessThan": "Ít hơn", "greaterThan": "Lớn hơn", "lessThanOrEqualTo": "Nhỏ hơn hoặc bằng", "greaterThanOrEqualTo": "Lớn hơn hoặc bằng", "isEmpty": "Đang trống", "isNotEmpty": "Không trống" }, "field": { "label": "Thuộc tính", "hide": "Ẩn", "show": "Hiện", "insertLeft": "Chèn trái", "insertRight": "Chèn phải", "duplicate": "Nhân bản", "delete": "Xóa", "wrapCellContent": "Bao quanh văn bản", "clear": "Xóa tế bào", "textFieldName": "Chữ", "checkboxFieldName": "Hộp kiểm", "dateFieldName": "Ngày", "updatedAtFieldName": "Sửa đổi lần cuối", "createdAtFieldName": "Được tạo vào lúc", "numberFieldName": "Số", "singleSelectFieldName": "Lựa chọn", "multiSelectFieldName": "Chọn nhiều", "urlFieldName": "URL", "checklistFieldName": "Danh mục", "relationFieldName": "Mối quan hệ", "summaryFieldName": "Tóm tắt AI", "timeFieldName": "Thời gian", "mediaFieldName": "Tệp tin & phương tiện", "translateFieldName": "AI Dịch", "translateTo": "Dịch sang", "numberFormat": "Định dạng số", "dateFormat": "Định dạng ngày tháng", "includeTime": "Bao gồm thời gian", "isRange": "Ngày cuối", "dateFormatFriendly": "Tháng Ngày, Năm", "dateFormatISO": "Năm-Tháng-Ngày", "dateFormatLocal": "Tháng/Ngày/Năm", "dateFormatUS": "Năm/Tháng/Ngày", "dateFormatDayMonthYear": "Ngày/Tháng/Năm", "timeFormat": "Định dạng thời gian", "invalidTimeFormat": "Định dạng không hợp lệ", "timeFormatTwelveHour": "12 giờ", "timeFormatTwentyFourHour": "24 giờ", "clearDate": "Xóa ngày", "dateTime": "Ngày giờ", "startDateTime": "Ngày giờ bắt đầu", "endDateTime": "Ngày giờ kết thúc", "failedToLoadDate": "Không thể tải giá trị ngày", "selectTime": "Chọn thời gian", "selectDate": "Chọn ngày", "visibility": "Hiển thị", "propertyType": "Loại thuộc tính", "addSelectOption": "Thêm một tùy chọn", "typeANewOption": "Nhập một tùy chọn mới", "optionTitle": "Tùy chọn", "addOption": "Thêm tùy chọn", "editProperty": "Chỉnh sửa thuộc tính", "newProperty": "Thuộc tính mới", "openRowDocument": "Mở như một trang", "deleteFieldPromptMessage": "Bạn có chắc không? Thuộc tính này sẽ bị xóa", "clearFieldPromptMessage": "Bạn có chắc chắn không? Tất cả các ô trong cột này sẽ được làm trống", "newColumn": "Cột mới", "format": "Định dạng", "reminderOnDateTooltip": "Ô này có lời nhắc được lên lịch", "optionAlreadyExist": "Tùy chọn đã tồn tại" }, "rowPage": { "newField": "Thêm một trường mới", "fieldDragElementTooltip": "Bấm để mở menu", "showHiddenFields": { "one": "Hiển thị {count} trường ẩn ", "many": "Hiển thị {count} trường ẩn", "other": "Hiển thị {count} trường ẩn" }, "hideHiddenFields": { "one": "Ẩn {count} trường ẩn", "many": "Ẩn {count} trường ẩn", "other": "Ẩn {count} trường ẩn" }, "openAsFullPage": "Mở dưới dạng trang đầy đủ", "moreRowActions": "Thêm hành động hàng" }, "sort": { "ascending": "Tăng dần", "descending": "Giảm dần", "by": "Qua", "empty": "Không có loại hoạt động", "cannotFindCreatableField": "Không tìm thấy trường phù hợp để sắp xếp theo", "deleteAllSorts": "Xóa tất cả sắp xếp", "addSort": "Thêm sắp xếp", "removeSorting": "Bạn có muốn xóa chế độ sắp xếp không?", "fieldInUse": "Bạn đang sắp xếp theo trường này" }, "row": { "duplicate": "Nhân bản", "delete": "Xóa", "titlePlaceholder": "Không tiêu đề", "textPlaceholder": "Rỗng", "copyProperty": "Đã sao chép thuộc tính", "count": "Số lượng", "newRow": "Hàng mới", "action": "Hành động", "add": "Nhấp vào thêm vào bên dưới", "drag": "Kéo để di chuyển", "deleteRowPrompt": "Bạn có chắc chắn muốn xóa hàng này không? Hành động này không thể hoàn tác", "deleteCardPrompt": "Bạn có chắc chắn muốn xóa thẻ này không? Hành động này không thể hoàn tác", "dragAndClick": "Kéo để di chuyển, nhấp để mở menu", "insertRecordAbove": "Chèn bản ghi ở trên", "insertRecordBelow": "Chèn bản ghi bên dưới", "noContent": "Không có nội dung" }, "selectOption": { "create": "Tạo", "purpleColor": "Tím", "pinkColor": "Hồng", "lightPinkColor": "Hồng nhạt", "orangeColor": "Cam", "yellowColor": "Vàng", "limeColor": "Xanh vàng", "greenColor": "Xanh là cây ", "aquaColor": "Thủy", "blueColor": "Xanh da trời", "deleteTag": "Xóa thẻ", "colorPanelTitle": "Màu", "panelTitle": "Chọn một tùy chọn hoặc tạo mới", "searchOption": "Tìm kiếm một lựa chọn", "searchOrCreateOption": "Tìm kiếm hoặc tạo một tùy chọn...", "createNew": "Tạo một cái mới", "orSelectOne": "Hoặc chọn một tùy chọn", "typeANewOption": "Nhập một tùy chọn mới", "tagName": "Tên thẻ" }, "checklist": { "taskHint": "Mô tả nhiệm vụ", "addNew": "Thêm một nhiệm vụ mới", "submitNewTask": "Tạo nên", "hideComplete": "Ẩn các tác vụ đã hoàn thành", "showComplete": "Hiển thị tất cả các nhiệm vụ" }, "url": { "launch": "Mở liên kết trong trình duyệt", "copy": "Sao chép URL", "textFieldHint": "Nhập một URL" }, "relation": { "relatedDatabasePlaceLabel": "Cơ sở dữ liệu liên quan", "relatedDatabasePlaceholder": "Không có", "inRelatedDatabase": "TRONG", "rowSearchTextFieldPlaceholder": "Tìm kiếm", "noDatabaseSelected": "Chưa chọn cơ sở dữ liệu, vui lòng chọn một cơ sở dữ liệu trước từ danh sách bên dưới:", "emptySearchResult": "Không tìm thấy hồ sơ nào", "linkedRowListLabel": "{count} hàng được liên kết", "unlinkedRowListLabel": "Liên kết một hàng khác" }, "menuName": "Lưới", "referencedGridPrefix": "Xem của", "calculate": "Tính toán", "calculationTypeLabel": { "none": "Không có", "average": "Trung bình", "max": "Tối đa", "median": "Trung vị", "min": "Tối thiểu", "sum": "Tổng", "count": "Đếm", "countEmpty": "Đếm trống", "countEmptyShort": "TRỐNG", "countNonEmpty": "Đếm không trống", "countNonEmptyShort": "ĐIỀN" }, "media": { "rename": "Đổi tên", "download": "Tải về", "delete": "Xóa bỏ", "moreFilesHint": "+{}", "addFileOrImage": "Thêm tệp, hình ảnh hoặc liên kết", "attachmentsHint": "{}", "addFileMobile": "Thêm tập tin", "deleteFileDescription": "Bạn có chắc chắn muốn xóa tệp này không? Hành động này không thể hoàn tác.", "downloadSuccess": "Đã lưu tập tin thành công", "downloadFailedToken": "Không tải được tệp, mã thông báo người dùng không khả dụng", "open": "Mở", "hideFileNames": "Ẩn tên tập tin", "showFile": "Hiển thị 1 tập tin", "showFiles": "Hiển thị {} tập tin", "hideFile": "Ẩn 1 tập tin", "hideFiles": "Ẩn {} tập tin" } }, "document": { "menuName": "Tài liệu", "date": { "timeHintTextInTwelveHour": "01:00 Chiều", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "Chọn một bảng để liên kết", "createANewBoard": "Tạo một bảng mới" }, "grid": { "selectAGridToLinkTo": "Chọn một lưới để liên kết đến", "createANewGrid": "Tạo một lưới mới" }, "calendar": { "selectACalendarToLinkTo": "Chọn Lịch để liên kết đến", "createANewCalendar": "Tạo một Lịch mới" }, "document": { "selectADocumentToLinkTo": "Chọn một Tài liệu để liên kết đến" }, "name": { "text": "Chữ", "heading1": "Tiêu đề 1", "heading2": "Tiêu đề 2", "heading3": "Tiêu đề 3", "image": "Hình ảnh", "bulletedList": "Danh sách có dấu đầu dòng", "numberedList": "Danh sách được đánh số", "todoList": "Danh sách việc cần làm", "doc": "Tài liệu", "linkedDoc": "Liên kết đến trang", "grid": "Lưới", "linkedGrid": "Lưới liên kết", "kanban": "Kanban", "linkedKanban": "Kanban liên kết", "calendar": "Lịch", "linkedCalendar": "Lịch liên kết", "quote": "Trích dẫn", "divider": "Bộ chia", "table": "Bàn", "callout": "Chú thích", "outline": "phác thảo", "mathEquation": "Phương trình toán học", "code": "Mã số", "toggleList": "Chuyển đổi danh sách", "emoji": "Biểu tượng cảm xúc", "aiWriter": "Nhà văn AI", "dateOrReminder": "Ngày hoặc nhắc nhở", "photoGallery": "Thư viện ảnh", "file": "Tài liệu" } }, "selectionMenu": { "outline": "phác thảo", "codeBlock": "Khối mã" }, "plugins": { "referencedBoard": "Hội đồng tham khảo", "referencedGrid": "Lưới tham chiếu", "referencedCalendar": "Lịch tham khảo", "referencedDocument": "Tài liệu tham khảo", "autoGeneratorMenuItemName": "Nhà văn AI", "autoGeneratorTitleName": "AI: Yêu cầu AI viết bất cứ điều gì...", "autoGeneratorLearnMore": "Tìm hiểu thêm", "autoGeneratorGenerate": "Phát ra", "autoGeneratorHintText": "Hỏi AI ...", "autoGeneratorCantGetOpenAIKey": "Không thể lấy được chìa khóa AI", "autoGeneratorRewrite": "Viết lại", "smartEdit": "Hỏi AI", "aI": "AI", "smartEditFixSpelling": "Sửa lỗi chính tả và ngữ pháp", "warning": "⚠️ Phản hồi của AI có thể không chính xác hoặc gây hiểu lầm.", "smartEditSummarize": "Tóm tắt", "smartEditImproveWriting": "Cải thiện khả năng viết", "smartEditMakeLonger": "Làm dài hơn", "smartEditCouldNotFetchResult": "Không thể lấy kết quả từ AI", "smartEditCouldNotFetchKey": "Không thể lấy được khóa AI", "smartEditDisabled": "Kết nối AI trong Cài đặt", "appflowyAIEditDisabled": "Đăng nhập để bật tính năng AI", "discardResponse": "Bạn có muốn loại bỏ phản hồi của AI không?", "createInlineMathEquation": "Tạo phương trình", "fonts": "Phông chữ", "insertDate": "Chèn ngày", "emoji": "Biểu tượng cảm xúc", "toggleList": "Chuyển đổi danh sách", "quoteList": "Danh sách trích dẫn", "numberedList": "Danh sách được đánh số", "bulletedList": "Danh sách có dấu đầu dòng", "todoList": "Danh sách việc cần làm", "callout": "Chú thích", "cover": { "changeCover": "Thay đổi bìa", "colors": "Màu sắc", "images": "Hình ảnh", "clearAll": "Xóa tất cả", "abstract": "Tóm tắt", "addCover": "Thêm Bìa", "addLocalImage": "Thêm hình ảnh cục bộ", "invalidImageUrl": "URL hình ảnh không hợp lệ", "failedToAddImageToGallery": "Không thể thêm hình ảnh vào thư viện", "enterImageUrl": "Nhập URL hình ảnh", "add": "Thêm vào", "back": "Quay lại", "saveToGallery": "Lưu vào thư viện", "removeIcon": "Xóa biểu tượng", "pasteImageUrl": "Dán URL hình ảnh", "or": "HOẶC", "pickFromFiles": "Chọn từ các tập tin", "couldNotFetchImage": "Không thể tải hình ảnh", "imageSavingFailed": "Lưu hình ảnh không thành công", "addIcon": "Thêm biểu tượng", "changeIcon": "Thay đổi biểu tượng", "coverRemoveAlert": "Nó sẽ được gỡ bỏ khỏi trang bìa sau khi bị xóa.", "alertDialogConfirmation": "Bạn có chắc chắn muốn tiếp tục không?" }, "mathEquation": { "name": "Phương trình toán học", "addMathEquation": "Thêm một phương trình TeX", "editMathEquation": "Chỉnh sửa phương trình toán học" }, "optionAction": { "click": "Nhấp chuột", "toOpenMenu": " để mở menu", "delete": "Xóa", "duplicate": "Nhân bản", "turnInto": "Biến thành", "moveUp": "Di chuyển lên", "moveDown": "Di chuyển xuống", "color": "Màu", "align": "Căn chỉnh", "left": "Trái", "center": "Giữa", "right": "Phải", "defaultColor": "Mặc định", "depth": "Độ sâu" }, "image": { "addAnImage": "Thêm hình ảnh", "copiedToPasteBoard": "Liên kết hình ảnh đã được sao chép vào clipboard", "addAnImageDesktop": "Thêm một hình ảnh", "addAnImageMobile": "Nhấp để thêm một hoặc nhiều hình ảnh", "dropImageToInsert": "Thả hình ảnh để chèn", "imageUploadFailed": "Tải hình ảnh lên không thành công", "imageDownloadFailed": "Tải hình ảnh lên không thành công, vui lòng thử lại", "imageDownloadFailedToken": "Tải lên hình ảnh không thành công do thiếu mã thông báo người dùng, vui lòng thử lại", "errorCode": "Mã lỗi" }, "photoGallery": { "name": "Thư viện ảnh", "imageKeyword": "hình ảnh", "imageGalleryKeyword": "thư viện hình ảnh", "photoKeyword": "ảnh", "photoBrowserKeyword": "trình duyệt ảnh", "galleryKeyword": "phòng trưng bày", "addImageTooltip": "Thêm hình ảnh", "changeLayoutTooltip": "Thay đổi bố cục", "browserLayout": "Trình duyệt", "gridLayout": "Lưới", "deleteBlockTooltip": "Xóa toàn bộ thư viện" }, "math": { "copiedToPasteBoard": "Phương trình toán học đã được sao chép vào clipboard" }, "urlPreview": { "copiedToPasteBoard": "Liên kết đã được sao chép vào clipboard", "convertToLink": "Chuyển đổi thành liên kết nhúng" }, "outline": { "addHeadingToCreateOutline": "Thêm tiêu đề để tạo mục lục.", "noMatchHeadings": "Không tìm thấy tiêu đề phù hợp." }, "table": { "addAfter": "Thêm sau", "addBefore": "Thêm trước", "delete": "Xoá", "clear": "Xóa nội dung", "duplicate": "Nhân bản", "bgColor": "Màu nền" }, "contextMenu": { "copy": "Sao chép", "cut": "Cắt", "paste": "Dán" }, "action": "Hành động", "database": { "selectDataSource": "Chọn nguồn dữ liệu", "noDataSource": "Không có nguồn dữ liệu", "selectADataSource": "Chọn nguồn dữ liệu", "toContinue": "để tiếp tục", "newDatabase": "Cơ sở dữ liệu mới", "linkToDatabase": "Liên kết đến Cơ sở dữ liệu" }, "date": "Ngày", "video": { "label": "Băng hình", "emptyLabel": "Thêm video", "placeholder": "Dán liên kết video", "copiedToPasteBoard": "Liên kết video đã được sao chép vào clipboard", "insertVideo": "Thêm video", "invalidVideoUrl": "URL nguồn chưa được hỗ trợ.", "invalidVideoUrlYouTube": "YouTube hiện chưa được hỗ trợ.", "supportedFormats": "Các định dạng được hỗ trợ: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" }, "file": { "name": "Tài liệu", "uploadTab": "Tải lên", "uploadMobile": "Chọn tệp", "networkTab": "Nhúng liên kết", "placeholderText": "Tải lên hoặc nhúng một tập tin", "placeholderDragging": "Thả tệp để tải lên", "dropFileToUpload": "Thả tệp để tải lên", "fileUploadHint": "Thả một tập tin ở đây để tải lên\nhoặc nhấp để duyệt", "networkHint": "Dán liên kết tệp", "networkUrlInvalid": "URL không hợp lệ, vui lòng sửa URL và thử lại", "networkAction": "Nhúng liên kết tập tin", "fileTooBigError": "Kích thước tệp quá lớn, vui lòng tải lên tệp có kích thước nhỏ hơn 10MB", "renameFile": { "title": "Đổi tên tập tin", "description": "Nhập tên mới cho tập tin này", "nameEmptyError": "Tên tệp không được để trống." }, "uploadedAt": "Đã tải lên vào {}", "linkedAt": "Liên kết đã được thêm vào {}", "failedToOpenMsg": "Không mở được, không tìm thấy tệp" } }, "outlineBlock": { "placeholder": "Mục lục" }, "textBlock": { "placeholder": "Gõ '/' cho lệnh" }, "title": { "placeholder": "Trống" }, "imageBlock": { "placeholder": "Ấn để thêm ảnh", "upload": { "label": "Tải lên", "placeholder": "Ấn để tải lên ảnh" }, "url": { "label": "Đường dẫn đến ảnh", "placeholder": "Nhập đường dẫn đến ảnh" }, "ai": { "label": "Tạo hình ảnh từ AI", "placeholder": "Vui lòng nhập lời nhắc để AI tạo hình ảnh" }, "stability_ai": { "label": "Tạo hình ảnh từ Stability AI", "placeholder": "Vui lòng nhập lời nhắc cho Stability AI để tạo hình ảnh" }, "support": "Giới hạn kích thước hình ảnh là 5MB. Định dạng được hỗ trợ: JPEG, PNG, GIF, SVG", "error": { "invalidImage": "Ảnh không hợp lệ", "invalidImageSize": "Kích thước hình ảnh phải nhỏ hơn 5MB", "invalidImageFormat": "Định dạng hình ảnh không được hỗ trợ. Các định dạng được hỗ trợ: JPEG, PNG, JPG, GIF, SVG, WEBP", "invalidImageUrl": "URL hình ảnh không hợp lệ", "noImage": "Không có tập tin hoặc thư mục như vậy", "multipleImagesFailed": "Một hoặc nhiều hình ảnh không tải lên được, vui lòng thử lại" }, "embedLink": { "label": "Nhúng liên kết", "placeholder": "Dán hoặc nhập liên kết hình ảnh" }, "unsplash": { "label": "Bỏ qua" }, "searchForAnImage": "Tìm kiếm hình ảnh", "pleaseInputYourOpenAIKey": "vui lòng nhập khóa AI của bạn vào trang Cài đặt", "saveImageToGallery": "Lưu hình ảnh", "failedToAddImageToGallery": "Không thể thêm hình ảnh vào thư viện", "successToAddImageToGallery": "Hình ảnh đã được thêm vào thư viện thành công", "unableToLoadImage": "Không thể tải hình ảnh", "maximumImageSize": "Kích thước hình ảnh tải lên được hỗ trợ tối đa là 10MB", "uploadImageErrorImageSizeTooBig": "Kích thước hình ảnh phải nhỏ hơn 10MB", "imageIsUploading": "Hình ảnh đang được tải lên", "openFullScreen": "Mở toàn màn hình", "interactiveViewer": { "toolbar": { "previousImageTooltip": "Hình ảnh trước đó", "nextImageTooltip": "Hình ảnh tiếp theo", "zoomOutTooltip": "Thu nhỏ", "zoomInTooltip": "Phóng to", "changeZoomLevelTooltip": "Thay đổi mức độ thu phóng", "openLocalImage": "Mở hình ảnh", "downloadImage": "Tải xuống hình ảnh", "closeViewer": "Đóng trình xem tương tác", "scalePercentage": "{}%", "deleteImageTooltip": "Xóa hình ảnh" } } }, "codeBlock": { "language": { "label": "Ngôn ngữ", "placeholder": "Chọn ngôn ngữ", "auto": "Tự động" }, "copyTooltip": "Sao chép", "searchLanguageHint": "Tìm kiếm một ngôn ngữ", "codeCopiedSnackbar": "Đã sao chép mã vào bảng tạm!" }, "inlineLink": { "placeholder": "Dán hoặc nhập liên kết", "openInNewTab": "Mở trong tab mới", "copyLink": "Sao chép liên kết", "removeLink": "Xóa liên kết", "url": { "label": "URL liên kết", "placeholder": "Nhập URL liên kết" }, "title": { "label": "Tiêu đề liên kết", "placeholder": "Nhập tiêu đề liên kết" } }, "mention": { "placeholder": "Nhắc đến một người, một trang hoặc một ngày...", "page": { "label": "Liên kết đến trang", "tooltip": "Nhấp để mở trang" }, "deleted": "Đã xóa", "deletedContent": "Nội dung này không tồn tại hoặc đã bị xóa", "noAccess": "Không có quyền truy cập" }, "toolbar": { "resetToDefaultFont": "Chỉnh về mặc định" }, "errorBlock": { "theBlockIsNotSupported": "Không thể phân tích nội dung khối", "clickToCopyTheBlockContent": "Nhấp để sao chép nội dung khối", "blockContentHasBeenCopied": "Nội dung khối đã được sao chép.", "parseError": "Đã xảy ra lỗi khi phân tích khối {}.", "copyBlockContent": "Sao chép nội dung khối" }, "mobilePageSelector": { "title": "Chọn trang", "failedToLoad": "Không tải được danh sách trang", "noPagesFound": "Không tìm thấy trang nào" } }, "board": { "column": { "label": "Cột", "createNewCard": "Mới", "renameGroupTooltip": "Nhấn để đổi tên nhóm", "createNewColumn": "Thêm một nhóm mới", "addToColumnTopTooltip": "Thêm một thẻ mới ở trên cùng", "addToColumnBottomTooltip": "Thêm một thẻ mới ở phía dưới", "renameColumn": "Đổi tên", "hideColumn": "Ẩn", "newGroup": "Nhóm mới", "deleteColumn": "Xoá", "deleteColumnConfirmation": "Thao tác này sẽ xóa nhóm này và tất cả các thẻ trong đó.\nBạn có chắc chắn muốn tiếp tục không?" }, "hiddenGroupSection": { "sectionTitle": "Nhóm ẩn", "collapseTooltip": "Ẩn các nhóm ẩn", "expandTooltip": "Xem các nhóm ẩn" }, "cardDetail": "Chi tiết thẻ", "cardActions": "Hành động thẻ", "cardDuplicated": "Thẻ đã được sao chép", "cardDeleted": "Thẻ đã bị xóa", "showOnCard": "Hiển thị trên chi tiết thẻ", "setting": "Cài đặt", "propertyName": "Tên tài sản", "menuName": "Bảng", "showUngrouped": "Hiển thị các mục chưa nhóm", "ungroupedButtonText": "Không nhóm", "ungroupedButtonTooltip": "Chứa các thẻ không thuộc bất kỳ nhóm nào", "ungroupedItemsTitle": "Nhấp để thêm vào bảng", "groupBy": "Nhóm theo", "groupCondition": "Điều kiện nhóm", "referencedBoardPrefix": "Xem của", "notesTooltip": "Ghi chú bên trong", "mobile": { "editURL": "Chỉnh sửa URL", "showGroup": "Hiển thị nhóm", "showGroupContent": "Bạn có chắc chắn muốn hiển thị nhóm này trên bảng không?", "failedToLoad": "Không tải được chế độ xem bảng" }, "dateCondition": { "weekOf": "Tuần của {} - {}", "today": "Hôm nay", "yesterday": "Hôm qua", "tomorrow": "Ngày mai", "lastSevenDays": "7 ngày qua", "nextSevenDays": "7 ngày tiếp theo", "lastThirtyDays": "30 ngày qua", "nextThirtyDays": "30 ngày tiếp theo" }, "noGroup": "Không có nhóm theo tài sản", "noGroupDesc": "Các chế độ xem bảng yêu cầu một thuộc tính để nhóm theo để hiển thị", "media": { "cardText": "{} {}", "fallbackName": "tập tin" } }, "calendar": { "menuName": "Lịch", "defaultNewCalendarTitle": "Trống", "newEventButtonTooltip": "Thêm sự kiện mới", "navigation": { "today": "Hôm nay", "jumpToday": "Nhảy tới Hôm nay", "previousMonth": "Tháng trước", "nextMonth": "Tháng sau", "views": { "day": "Ngày", "week": "Tuần", "month": "Tháng", "year": "Năm" } }, "mobileEventScreen": { "emptyTitle": "Chưa có sự kiện nào", "emptyBody": "Nhấn nút dấu cộng để tạo sự kiện vào ngày này." }, "settings": { "showWeekNumbers": "Hiện thứ tự của tuần", "showWeekends": "Hiện cuối tuần", "firstDayOfWeek": "Ngày bắt đầu trong tuần", "layoutDateField": "Bố trí lịch theo", "changeLayoutDateField": "Thay đổi trường bố trí", "noDateTitle": "Không có ngày", "noDateHint": { "zero": "Các sự kiện không theo lịch trình sẽ hiển thị ở đây", "one": "{count} sự kiện không theo lịch trình", "other": "{count} sự kiện không theo lịch trình" }, "unscheduledEventsTitle": "Sự kiện không theo lịch trình", "clickToAdd": "Ấn để thêm lịch", "name": "Cài đặt lịch", "clickToOpen": "Nhấp để mở hồ sơ" }, "referencedCalendarPrefix": "Xem của", "quickJumpYear": "Nhảy tới", "duplicateEvent": "Sự kiện trùng lặp" }, "errorDialog": { "title": "Lỗi của @:appName", "howToFixFallback": "Chúng tôi xin lỗi vì sự cố này! Vui lòng mở sự cố trên GitHub để báo lỗi.", "howToFixFallbackHint1": "Chúng tôi xin lỗi vì sự bất tiện này! Gửi một vấn đề trên ", "howToFixFallbackHint2": " trang mô tả lỗi của bạn.", "github": "Xem trên GitHub" }, "search": { "label": "Tìm kiếm", "sidebarSearchIcon": "Tìm kiếm và nhanh chóng chuyển đến một trang", "placeholder": { "actions": "Hành động tìm kiếm..." } }, "message": { "copy": { "success": "Đã sao chép!", "fail": "Không thể sao chép" } }, "unSupportBlock": "Phiên bản hiện tại không hỗ trợ Block này.", "views": { "deleteContentTitle": "Bạn chắc chắn muốn xoá {pageType}?", "deleteContentCaption": "Nếu bạn xoá {pageType}, bạn có thể phục hồi từ thùng rác." }, "colors": { "custom": "Tuỳ chỉnh", "default": "Mặc định", "red": "Đỏ", "orange": "Cam", "yellow": "Vàng", "green": "Xanh lá cây", "blue": "Xanh dương", "purple": "Tím", "pink": "Hồng", "brown": "Nâu", "gray": "Xám" }, "emoji": { "emojiTab": "Biểu tượng cảm xúc", "search": "Tìm kiếm biểu tượng", "noRecent": "Không có biểu tượng cảm xúc gần đây", "noEmojiFound": "Không tìm thấy biểu tượng", "filter": "Bộ lọc", "random": "Ngẫu nhiên", "selectSkinTone": "Chọn tông màu da", "remove": "Loại bỏ biểu tượng", "categories": { "smileys": "Biểu tượng mặt cười & Cảm xúc", "people": "Con người và cơ thể", "animals": "Động vật và thiên nhiên", "food": "Đồ ăn và thức uống", "activities": "Hoạt động", "places": "Du lịch và địa điểm", "objects": "Vật thể", "symbols": "Biểu tượng", "flags": "Cờ", "nature": "Tự nhiên", "frequentlyUsed": "Thường dùng" }, "skinTone": { "default": "Mặc định", "light": "Sáng", "mediumLight": "Sáng-Vừa", "medium": "Vừa", "mediumDark": "Tối-Vừa", "dark": "Tối" }, "openSourceIconsFrom": "Biểu tượng nguồn mở từ" }, "inlineActions": { "noResults": "Không có kết quả", "recentPages": "Các trang gần đây", "pageReference": "Trang tham khảo", "docReference": "Tài liệu tham khảo", "boardReference": "Tham khảo bảng", "calReference": "Tham khảo lịch", "gridReference": "Tham chiếu lưới", "date": "Ngày", "reminder": { "groupTitle": "Lời nhắc", "shortKeyword": "nhắc nhở" } }, "datePicker": { "dateTimeFormatTooltip": "Thay đổi định dạng ngày và giờ trong cài đặt", "dateFormat": "Định dạng ngày tháng", "includeTime": "Bao gồm thời gian", "isRange": "Ngày kết thúc", "timeFormat": "Định dạng thời gian", "clearDate": "Ngày xóa", "reminderLabel": "Lời nhắc nhở", "selectReminder": "Chọn lời nhắc", "reminderOptions": { "none": "Không có", "atTimeOfEvent": "Thời gian diễn ra sự kiện", "fiveMinsBefore": "5 phút trước", "tenMinsBefore": "10 phút trước", "fifteenMinsBefore": "15 phút trước", "thirtyMinsBefore": "30 phút trước", "oneHourBefore": "1 giờ trước", "twoHoursBefore": "2 giờ trước", "onDayOfEvent": "Vào ngày diễn ra sự kiện", "oneDayBefore": "1 ngày trước", "twoDaysBefore": "2 ngày trước", "oneWeekBefore": "1 tuần trước", "custom": "Phong tục" } }, "relativeDates": { "yesterday": "Hôm qua", "today": "Hôm nay", "tomorrow": "Ngày mai", "oneWeek": "1 tuần" }, "notificationHub": { "title": "Thông báo", "mobile": { "title": "Cập nhật" }, "emptyTitle": "Đã cập nhật đầy đủ!", "emptyBody": "Không có thông báo hoặc hành động nào đang chờ xử lý. Hãy tận hưởng sự bình yên.", "tabs": { "inbox": "Hộp thư đến", "upcoming": "Sắp tới" }, "actions": { "markAllRead": "Đánh dấu tất cả là đã đọc", "showAll": "Tất cả", "showUnreads": "Chưa đọc" }, "filters": { "ascending": "Tăng dần", "descending": "Giảm dần", "groupByDate": "Nhóm theo ngày", "showUnreadsOnly": "Chỉ hiển thị những tin chưa đọc", "resetToDefault": "Đặt lại về mặc định" }, "empty": "Không có gì ở đây!" }, "reminderNotification": { "title": "Lời nhắc", "message": "Hãy nhớ kiểm tra điều này trước khi bạn quên nhé!", "tooltipDelete": "Xoá", "tooltipMarkRead": "Đánh dấu là đã đọc", "tooltipMarkUnread": "Đánh dấu là chưa đọc" }, "findAndReplace": { "find": "Tìm kiếm", "previousMatch": "Kết quả phía trước", "nextMatch": "Kết quả tiếp theo", "close": "Đóng", "replace": "Thay thế", "replaceAll": "Thay thế tất cả", "noResult": "Không thấy kết quả", "caseSensitive": "Phân biệt chữ hoa chữ thường", "searchMore": "Tìm kiếm để tìm thêm kết quả" }, "error": { "weAreSorry": "Chúng tôi xin lỗi", "loadingViewError": "Chúng tôi đang gặp sự cố khi tải chế độ xem này. Vui lòng kiểm tra kết nối internet, làm mới ứng dụng và đừng ngần ngại liên hệ với nhóm nếu sự cố vẫn tiếp diễn.", "syncError": "Dữ liệu không được đồng bộ từ thiết bị khác", "syncErrorHint": "Vui lòng mở lại trang này trên thiết bị mà bạn đã chỉnh sửa lần cuối, sau đó mở lại trên thiết bị hiện tại.", "clickToCopy": "Nhấp để sao chép mã lỗi" }, "editor": { "bold": "In đậm", "bulletedList": "Danh sách có dấu đầu dòng", "bulletedListShortForm": "Có dấu đầu dòng", "checkbox": "Đánh dấu", "embedCode": "Mã nhúng", "heading1": "H1", "heading2": "H2", "heading3": "H3", "highlight": "Điểm nổi bật", "color": "Màu sắc", "image": "Hình ảnh", "date": "Ngày", "page": "Trang", "italic": "nghiêng", "link": "Liên kết", "numberedList": "Danh sách được đánh số", "numberedListShortForm": "Đã đánh số", "quote": "Trích dẫn", "strikethrough": "gạch xuyên", "text": "Chữ", "underline": "Gạch chân", "fontColorDefault": "Mặc định", "fontColorGray": "Xám", "fontColorBrown": "Màu nâu", "fontColorOrange": "Quả cam", "fontColorYellow": "Màu vàng", "fontColorGreen": "Màu xanh lá", "fontColorBlue": "Màu xanh da trời", "fontColorPurple": "Màu tím", "fontColorPink": "Hồng", "fontColorRed": "Màu đỏ", "backgroundColorDefault": "Nền mặc định", "backgroundColorGray": "Nền xám", "backgroundColorBrown": "Nền màu nâu", "backgroundColorOrange": "Nền màu cam", "backgroundColorYellow": "Nền vàng", "backgroundColorGreen": "Nền xanh", "backgroundColorBlue": "Nền xanh", "backgroundColorPurple": "Nền màu tím", "backgroundColorPink": "Nền màu hồng", "backgroundColorRed": "Nền đỏ", "backgroundColorLime": "Nền vôi", "backgroundColorAqua": "Nền màu nước", "done": "Xong", "cancel": "Hủy bỏ", "tint1": "Màu 1", "tint2": "Màu 2", "tint3": "Màu 3", "tint4": "Màu 4", "tint5": "Màu 5", "tint6": "Màu 6", "tint7": "Màu 7", "tint8": "Màu 8", "tint9": "Màu 9", "lightLightTint1": "Màu tím", "lightLightTint2": "Hồng", "lightLightTint3": "Hồng nhạt", "lightLightTint4": "Quả cam", "lightLightTint5": "Màu vàng", "lightLightTint6": "Chanh xanh", "lightLightTint7": "Màu xanh lá", "lightLightTint8": "Nước", "lightLightTint9": "Màu xanh da trời", "urlHint": "Địa chỉ URL", "mobileHeading1": "Tiêu đề 1", "mobileHeading2": "Tiêu đề 2", "mobileHeading3": "Tiêu đề 3", "textColor": "Màu chữ", "backgroundColor": "Màu nền", "addYourLink": "Thêm liên kết của bạn", "openLink": "Mở liên kết", "copyLink": "Sao chép liên kết", "removeLink": "Xóa liên kết", "editLink": "Chỉnh sửa liên kết", "linkText": "Chữ", "linkTextHint": "Vui lòng nhập văn bản", "linkAddressHint": "Vui lòng nhập URL", "highlightColor": "Màu sắc nổi bật", "clearHighlightColor": "Xóa màu nổi bật", "customColor": "Màu tùy chỉnh", "hexValue": "Giá trị hex", "opacity": "Độ mờ đục", "resetToDefaultColor": "Đặt lại màu mặc định", "ltr": "LTR", "rtl": "RTL", "auto": "Tự động", "cut": "Cắt", "copy": "Sao chép", "paste": "Dán", "find": "Tìm thấy", "select": "Lựa chọn", "selectAll": "Chọn tất cả", "previousMatch": "Trùng khớp trước đó", "nextMatch": "Trùng khớp tiếp theo", "closeFind": "Đóng", "replace": "Thay thế", "replaceAll": "Thay thế tất cả", "regex": "Biểu thức chính quy", "caseSensitive": "Phân biệt chữ hoa chữ thường", "uploadImage": "Tải lên hình ảnh", "urlImage": "Hình ảnh URL", "incorrectLink": "Liên kết không đúng", "upload": "Tải lên", "chooseImage": "Chọn một hình ảnh", "loading": "Đang tải", "imageLoadFailed": "Tải hình ảnh không thành công", "divider": "Bộ chia", "table": "Bàn", "colAddBefore": "Thêm trước", "rowAddBefore": "Thêm trước", "colAddAfter": "Thêm sau", "rowAddAfter": "Thêm sau", "colRemove": "Xoá", "rowRemove": "Xoá", "colDuplicate": "Nhân bản", "rowDuplicate": "Nhân bản", "colClear": "Xóa nội dung", "rowClear": "Xóa nội dung", "slashPlaceHolder": "Nhập '/' để chèn một khối hoặc bắt đầu nhập", "typeSomething": "Hãy nhập gì đó...", "toggleListShortForm": "Chuyển đổi", "quoteListShortForm": "Trích dẫn", "mathEquationShortForm": "Công thức", "codeBlockShortForm": "Mã" }, "favorite": { "noFavorite": "Không có trang yêu thích", "noFavoriteHintText": "Vuốt trang sang trái để thêm vào mục yêu thích của bạn", "removeFromSidebar": "Xóa khỏi thanh bên", "addToSidebar": "Ghim vào thanh bên" }, "cardDetails": { "notesPlaceholder": "Nhập / để chèn một khối hoặc bắt đầu nhập" }, "blockPlaceholders": { "todoList": "Việc cần làm", "bulletList": "Danh sách", "numberList": "Danh sách", "quote": "Trích dẫn", "heading": "Tiêu đề {}" }, "titleBar": { "pageIcon": "Biểu tượng trang", "language": "Ngôn ngữ", "font": "Phông chữ", "actions": "Hành động", "date": "Ngày", "addField": "Thêm trường", "userIcon": "Biểu tượng người dùng" }, "noLogFiles": "Không có tệp nhật ký nào", "newSettings": { "myAccount": { "title": "Tài khoản của tôi", "subtitle": "Tùy chỉnh hồ sơ, quản lý bảo mật tài khoản, mở khóa AI hoặc đăng nhập vào tài khoản của bạn.", "profileLabel": "Tên tài khoản & Ảnh đại diện", "profileNamePlaceholder": "Nhập tên của bạn", "accountSecurity": "Bảo mật tài khoản", "2FA": "Xác thực 2 bước", "aiKeys": "Phím AI", "accountLogin": "Đăng nhập tài khoản", "updateNameError": "Không cập nhật được tên", "updateIconError": "Không cập nhật được biểu tượng", "deleteAccount": { "title": "Xóa tài khoản", "subtitle": "Xóa vĩnh viễn tài khoản và toàn bộ dữ liệu của bạn.", "description": "Xóa vĩnh viễn tài khoản của bạn và xóa quyền truy cập khỏi mọi không gian làm việc.", "deleteMyAccount": "Xóa tài khoản của tôi", "dialogTitle": "Xóa tài khoản", "dialogContent1": "Bạn có chắc chắn muốn xóa vĩnh viễn tài khoản của mình không?", "dialogContent2": "Không thể hoàn tác hành động này và sẽ xóa quyền truy cập khỏi mọi không gian làm việc nhóm, xóa toàn bộ tài khoản của bạn, bao gồm cả không gian làm việc riêng tư và xóa bạn khỏi mọi không gian làm việc được chia sẻ.", "confirmHint1": "Vui lòng nhập \"@:newSettings.myAccount.deleteAccount.confirmHint3\" để xác nhận.", "confirmHint2": "Tôi hiểu rằng hành động này là không thể đảo ngược và sẽ xóa vĩnh viễn tài khoản của tôi cùng mọi dữ liệu liên quan.", "confirmHint3": "XÓA TÀI KHOẢN CỦA TÔI", "checkToConfirmError": "Bạn phải đánh dấu vào ô để xác nhận việc xóa", "failedToGetCurrentUser": "Không lấy được email người dùng hiện tại", "confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "Tài khoản đã được xóa thành công" } }, "workplace": { "name": "Nơi làm việc", "title": "Cài đặt nơi làm việc", "subtitle": "Tùy chỉnh giao diện không gian làm việc, chủ đề, phông chữ, bố cục văn bản, ngày, giờ và ngôn ngữ.", "workplaceName": "Tên nơi làm việc", "workplaceNamePlaceholder": "Nhập tên nơi làm việc", "workplaceIcon": "Biểu tượng nơi làm việc", "workplaceIconSubtitle": "Tải lên hình ảnh hoặc sử dụng biểu tượng cảm xúc cho không gian làm việc của bạn. Biểu tượng sẽ hiển thị trên thanh bên và thông báo của bạn.", "renameError": "Không đổi được tên nơi làm việc", "updateIconError": "Không cập nhật được biểu tượng", "chooseAnIcon": "Chọn một biểu tượng", "appearance": { "name": "Vẻ bề ngoài", "themeMode": { "auto": "Tự động", "light": "Ánh sáng", "dark": "Tối tăm" }, "language": "Ngôn ngữ" } }, "syncState": { "syncing": "Đồng bộ hóa", "synced": "Đã đồng bộ", "noNetworkConnected": "Không có kết nối mạng" } }, "pageStyle": { "title": "Kiểu trang", "layout": "Cách trình bày", "coverImage": "Ảnh bìa", "pageIcon": "Biểu tượng trang", "colors": "Màu sắc", "gradient": "Độ dốc", "backgroundImage": "Hình nền", "presets": "Cài đặt trước", "photo": "Ảnh", "unsplash": "Bỏ qua", "pageCover": "Trang bìa", "none": "Không có", "openSettings": "Mở Cài đặt", "photoPermissionTitle": "@:appName muốn truy cập vào thư viện ảnh của bạn", "photoPermissionDescription": "Cho phép truy cập vào thư viện ảnh để tải ảnh lên.", "doNotAllow": "Không cho phép", "image": "Hình ảnh" }, "commandPalette": { "placeholder": "Nhập để tìm kiếm...", "bestMatches": "Trận đấu hay nhất", "recentHistory": "Lịch sử gần đây", "navigateHint": "để điều hướng", "loadingTooltip": "Chúng tôi đang tìm kiếm kết quả...", "betaLabel": "BETA", "betaTooltip": "Hiện tại chúng tôi chỉ hỗ trợ tìm kiếm các trang và nội dung trong tài liệu", "fromTrashHint": "Từ rác", "noResultsHint": "Chúng tôi không tìm thấy những gì bạn đang tìm kiếm, hãy thử tìm kiếm thuật ngữ khác.", "clearSearchTooltip": "Xóa trường tìm kiếm" }, "space": { "delete": "Xóa bỏ", "deleteConfirmation": "Xóa bỏ: ", "deleteConfirmationDescription": "Tất cả các trang trong Không gian này sẽ bị xóa và chuyển vào Thùng rác, và bất kỳ trang nào đã xuất bản sẽ bị hủy xuất bản.", "rename": "Đổi tên không gian", "changeIcon": "Thay đổi biểu tượng", "manage": "Quản lý không gian", "addNewSpace": "Tạo không gian", "collapseAllSubPages": "Thu gọn tất cả các trang con", "createNewSpace": "Tạo không gian mới", "createSpaceDescription": "Tạo nhiều không gian công cộng và riêng tư để sắp xếp công việc tốt hơn.", "spaceName": "Tên không gian", "spaceNamePlaceholder": "ví dụ Marketing, Kỹ thuật, Nhân sự", "permission": "Sự cho phép", "publicPermission": "Công cộng", "publicPermissionDescription": "Tất cả các thành viên không gian làm việc có quyền truy cập đầy đủ", "privatePermission": "Riêng tư", "privatePermissionDescription": "Chỉ bạn mới có thể truy cập vào không gian này", "spaceIconBackground": "Màu nền", "spaceIcon": "Biểu tượng", "dangerZone": "Khu vực nguy hiểm", "unableToDeleteLastSpace": "Không thể xóa khoảng trắng cuối cùng", "unableToDeleteSpaceNotCreatedByYou": "Không thể xóa các Không gian do người khác tạo", "enableSpacesForYourWorkspace": "Bật Spaces cho không gian làm việc của bạn", "title": "Khoảng cách", "defaultSpaceName": "Tổng quan", "upgradeSpaceTitle": "Kích hoạt Khoảng cách", "upgradeSpaceDescription": "Tạo nhiều Không gian công cộng và riêng tư để sắp xếp không gian làm việc của bạn tốt hơn.", "upgrade": "Cập nhật", "upgradeYourSpace": "Tạo nhiều khoảng trống", "quicklySwitch": "Nhanh chóng chuyển sang không gian tiếp theo", "duplicate": "Không gian trùng lặp", "movePageToSpace": "Di chuyển trang đến khoảng trống", "switchSpace": "Chuyển đổi không gian", "spaceNameCannotBeEmpty": "Tên khoảng trắng không được để trống" }, "publish": { "hasNotBeenPublished": "Trang này chưa được xuất bản", "spaceHasNotBeenPublished": "Chưa hỗ trợ xuất bản không gian", "reportPage": "Báo cáo trang", "databaseHasNotBeenPublished": "Việc xuất bản cơ sở dữ liệu hiện chưa được hỗ trợ.", "createdWith": "Được tạo ra với", "downloadApp": "Tải AppFlowy", "copy": { "codeBlock": "Nội dung của khối mã đã được sao chép vào bảng tạm", "imageBlock": "Liên kết hình ảnh đã được sao chép vào clipboard", "mathBlock": "Phương trình toán học đã được sao chép vào clipboard", "fileBlock": "Liên kết tập tin đã được sao chép vào clipboard" }, "containsPublishedPage": "Trang này chứa một hoặc nhiều trang đã xuất bản. Nếu bạn tiếp tục, chúng sẽ bị hủy xuất bản. Bạn có muốn tiếp tục xóa không?", "publishSuccessfully": "Đã xuất bản thành công", "unpublishSuccessfully": "Đã hủy xuất bản thành công", "publishFailed": "Không thể xuất bản", "unpublishFailed": "Không thể hủy xuất bản", "noAccessToVisit": "Không thể truy cập vào trang này...", "createWithAppFlowy": "Tạo trang web với AppFlowy", "fastWithAI": "Nhanh chóng và dễ dàng với AI.", "tryItNow": "Thử ngay bây giờ", "onlyGridViewCanBePublished": "Chỉ có thể xuất bản chế độ xem Lưới", "database": { "zero": "Xuất bản {} chế độ xem đã chọn", "one": "Xuất bản {} chế độ xem đã chọn", "many": "Xuất bản {} chế độ xem đã chọn", "other": "Xuất bản {} chế độ xem đã chọn" }, "mustSelectPrimaryDatabase": "Phải chọn chế độ xem chính", "noDatabaseSelected": "Chưa chọn cơ sở dữ liệu, vui lòng chọn ít nhất một cơ sở dữ liệu.", "unableToDeselectPrimaryDatabase": "Không thể bỏ chọn cơ sở dữ liệu chính", "saveThisPage": "Lưu trang này", "duplicateTitle": "Bạn muốn thêm vào đâu?", "selectWorkspace": "Chọn không gian làm việc", "addTo": "Thêm vào", "duplicateSuccessfully": "Tạo bản sao thành công. Bạn muốn xem tài liệu?", "duplicateSuccessfullyDescription": "Bạn không có ứng dụng? Quá trình tải xuống của bạn sẽ tự động bắt đầu sau khi nhấp vào 'Tải xuống'.", "downloadIt": "Tải về", "openApp": "Mở trong ứng dụng", "duplicateFailed": "Sao chép không thành công", "membersCount": { "zero": "Không có thành viên", "one": "1 thành viên", "many": "{đếm} thành viên", "other": "{đếm} thành viên" }, "useThisTemplate": "Sử dụng mẫu" }, "web": { "continue": "Tiếp tục", "or": "hoặc", "continueWithGoogle": "Tiếp tục với Google", "continueWithGithub": "Tiếp tục với GitHub", "continueWithDiscord": "Tiếp tục với Discord", "continueWithApple": "Tiếp tục với Apple ", "moreOptions": "Thêm tùy chọn", "collapse": "Thu gọn", "signInAgreement": "Bằng cách nhấp vào \"Tiếp tục\" ở trên, bạn đã đồng ý với AppFlowy", "and": "và", "termOfUse": "Điều khoản", "privacyPolicy": "Chính sách bảo mật", "signInError": "Lỗi đăng nhập", "login": "Đăng ký hoặc đăng nhập", "fileBlock": { "uploadedAt": "Đã tải lên vào {time}", "linkedAt": "Liên kết được thêm vào {time}", "empty": "Tải lên hoặc nhúng một tập tin" } }, "globalComment": { "comments": "Bình luận", "addComment": "Thêm bình luận", "reactedBy": "phản ứng bởi", "addReaction": "Thêm phản ứng", "reactedByMore": "và {đếm} người khác", "showSeconds": { "one": "1 giây trước", "other": "{count} giây trước", "zero": "Vừa xong", "many": "{count} giây trước" }, "showMinutes": { "one": "1 phút trước", "other": "{count} phút trước", "many": "{count} phút trước" }, "showHours": { "one": "1 giờ trước", "other": "{count} giờ trước", "many": "{count} giờ trước" }, "showDays": { "one": "1 ngày trước", "other": "{count} ngày trước", "many": "{count} ngày trước" }, "showMonths": { "one": "1 tháng trước", "other": "{count} tháng trước", "many": "{count} tháng trước" }, "showYears": { "one": "1 năm trước", "other": "{đếm} năm trước", "many": "{đếm} năm trước" }, "reply": "Hồi đáp", "deleteComment": "Xóa bình luận", "youAreNotOwner": "Bạn không phải là chủ sở hữu của bình luận này", "confirmDeleteDescription": "Bạn có chắc chắn muốn xóa bình luận này không?", "hasBeenDeleted": "Đã xóa", "replyingTo": "Trả lời cho", "noAccessDeleteComment": "Bạn không được phép xóa bình luận này", "collapse": "Sụp đổ", "readMore": "Đọc thêm", "failedToAddComment": "Không thêm được bình luận", "commentAddedSuccessfully": "Đã thêm bình luận thành công.", "commentAddedSuccessTip": "Bạn vừa thêm hoặc trả lời bình luận. Bạn có muốn chuyển lên đầu trang để xem các bình luận mới nhất không?" }, "template": { "asTemplate": "Như mẫu", "name": "Tên mẫu", "description": "Mô tả mẫu", "about": "Mẫu Giới Thiệu", "preview": "Bản xem trước mẫu", "categories": "Danh mục mẫu", "isNewTemplate": "PIN vào mẫu mới", "featured": "PIN vào mục Nổi bật", "relatedTemplates": "Mẫu liên quan", "requiredField": "{field} là bắt buộc", "addCategory": "Thêm \"{category}\"", "addNewCategory": "Thêm danh mục mới", "addNewCreator": "Thêm người sáng tạo mới", "deleteCategory": "Xóa danh mục", "editCategory": "Chỉnh sửa danh mục", "editCreator": "Chỉnh sửa người tạo", "category": { "name": "Tên danh mục", "icon": "Biểu tượng danh mục", "bgColor": "Màu nền danh mục", "priority": "Ưu tiên danh mục", "desc": "Mô tả danh mục", "type": "Loại danh mục", "icons": "Biểu tượng danh mục", "colors": "Thể loại Màu sắc", "byUseCase": "Theo trường hợp sử dụng", "byFeature": "Theo tính năng", "deleteCategory": "Xóa danh mục", "deleteCategoryDescription": "Bạn có chắc chắn muốn xóa danh mục này không?", "typeToSearch": "Nhập để tìm kiếm theo danh mục..." }, "creator": { "label": "Người tạo mẫu", "name": "Tên người sáng tạo", "avatar": "Avatar của người sáng tạo", "accountLinks": "Liên kết tài khoản người sáng tạo", "uploadAvatar": "Nhấp để tải lên hình đại diện", "deleteCreator": "Xóa người tạo", "deleteCreatorDescription": "Bạn có chắc chắn muốn xóa người sáng tạo này không?", "typeToSearch": "Nhập để tìm kiếm người sáng tạo..." }, "uploadSuccess": "Mẫu đã được tải lên thành công", "uploadSuccessDescription": "Mẫu của bạn đã được tải lên thành công. Bây giờ bạn có thể xem nó trong thư viện mẫu.", "viewTemplate": "Xem mẫu", "deleteTemplate": "Xóa mẫu", "deleteTemplateDescription": "Bạn có chắc chắn muốn xóa mẫu này không?", "addRelatedTemplate": "Thêm mẫu liên quan", "removeRelatedTemplate": "Xóa mẫu liên quan", "uploadAvatar": "Tải lên hình đại diện", "searchInCategory": "Tìm kiếm trong {category}", "label": "Bản mẫu" }, "fileDropzone": { "dropFile": "Nhấp hoặc kéo tệp vào khu vực này để tải lên", "uploading": "Đang tải lên...", "uploadFailed": "Tải lên không thành công", "uploadSuccess": "Tải lên thành công", "uploadSuccessDescription": "Tệp đã được tải lên thành công", "uploadFailedDescription": "Tải tệp lên không thành công", "uploadingDescription": "Tập tin đang được tải lên" }, "gallery": { "preview": "Mở toàn màn hình", "copy": "Sao chép", "download": "Tải về", "prev": "Trước", "next": "Kế tiếp", "resetZoom": "Đặt lại chế độ thu phóng", "zoomIn": "Phóng to", "zoomOut": "Thu nhỏ" } } ================================================ FILE: frontend/resources/translations/vi.json ================================================ { "board": { "mobile": { "showGroup": "Hiển thị nhóm", "showGroupContent": "Bạn có chắc chắn muốn hiển thị nhóm này trên bảng không?", "failedToLoad": "Không tải được chế độ xem bảng" } } } ================================================ FILE: frontend/resources/translations/zh-CN.json ================================================ { "appName": "AppFlowy", "defaultUsername": "我", "welcomeText": "欢迎使用 @:appName", "welcomeTo": "欢迎来到", "githubStarText": "在 GitHub 上 Star", "subscribeNewsletterText": "消息订阅", "letsGoButtonText": "开始", "title": "标题", "youCanAlso": "您还可以", "and": "以及", "failedToOpenUrl": "打开 url:{}失败", "blockActions": { "addBelowTooltip": "点击下方添加", "addAboveCmd": "Alt+单击", "addAboveMacCmd": "+单击", "addAboveTooltip": "添加上面", "dragTooltip": "拖拽以移动", "openMenuTooltip": "点击打开菜单" }, "signUp": { "buttonText": "注册", "title": "注册 @:appName 账户", "getStartedText": "开始", "emptyPasswordError": "密码不能为空", "repeatPasswordEmptyError": "确认密码不能为空", "unmatchedPasswordError": "两次密码输入不一致", "alreadyHaveAnAccount": "已有账户?", "emailHint": "邮箱", "passwordHint": "密码", "repeatPasswordHint": "确认密码", "signUpWith": "注册方式:" }, "signIn": { "loginTitle": "登录 @:appName", "loginButtonText": "登录", "loginStartWithAnonymous": "从匿名会话开始", "continueAnonymousUser": "继续匿名会话", "buttonText": "登录", "signingInText": "登录中...", "forgotPassword": "忘记密码?", "emailHint": "邮箱", "passwordHint": "密码", "dontHaveAnAccount": "没有账户?", "createAccount": "新建账户", "repeatPasswordEmptyError": "确认密码不能为空", "unmatchedPasswordError": "两次密码输入不一致", "syncPromptMessage": "同步数据可能需要一段时间,请不要关闭此页面", "or": "或", "signInWithGoogle": "使用 Google 账户登录", "signInWithGithub": "使用 Github 账户登录", "signInWithDiscord": "使用 Discord 账户登录", "signInWithApple": "使用 Apple 账户登录", "continueAnotherWay": "使用其他方式登录", "signUpWithGoogle": "使用 Google 账户注册", "signUpWithGithub": "使用 Github 账户注册", "signUpWithDiscord": "使用 Discord 账户注册", "signInWith": "用其他方式登入:", "signInWithEmail": "使用邮箱登录", "signInWithMagicLink": "使用魔法链接登录", "signUpWithMagicLink": "使用魔法链接注册", "pleaseInputYourEmail": "请输入邮箱地址", "settings": "设置", "magicLinkSent": "魔法链接已经发送到您的邮箱,请检查!", "invalidEmail": "请输入一个有效的邮箱地址", "alreadyHaveAnAccount": "已经有账户了?", "logIn": "登陆", "generalError": "出现错误,请稍后再试", "limitRateError": "出于安全原因,每60秒仅可以发送一次链接", "magicLinkSentDescription": "一个验证链接已发送到您的电子邮箱。点击该链接即可完成登录。该链接将在 5 分钟后失效。", "anonymous": "匿名", "LogInWithGoogle": "使用 Google 登录", "LogInWithGithub": "使用 Github 登录", "LogInWithDiscord": "使用 Discord 登录", "loginAsGuestButtonText": "开始使用", "logInWithMagicLink": "使用魔法链接登录" }, "workspace": { "chooseWorkspace": "选择您的工作区", "defaultName": "我的工作区", "create": "新建工作区", "new": "新的工作空间", "importFromNotion": "从 Notion 导入", "learnMore": "了解更多", "reset": "重置工作区", "renameWorkspace": "重命名工作区", "workspaceNameCannotBeEmpty": "工作区名称不可为空", "resetWorkspacePrompt": "重置工作区将删除其中的所有页面和数据。您确定要重置工作区吗?您也可以联系技术支持团队来恢复工作区", "hint": "工作区", "notFoundError": "找不到工作区", "failedToLoad": "出了些问题!我们无法加载工作区。请尝试关闭所有打开的 @:appName 实例,然后重试。", "errorActions": { "reportIssue": "上报问题", "reportIssueOnGithub": "在 Github 上报告问题", "exportLogFiles": "导出日志文件", "reachOut": "在 Discord 联系我们" }, "menuTitle": "工作区", "deleteWorkspaceHintText": "您确定要删除这个工作区嘛?这个操作不可恢复。", "createSuccess": "工作区创建成功", "createFailed": "工作区创建失败", "createLimitExceeded": "您已达到账户允许的最大工作空间限制。如果您需要额外的工作空间来继续工作,请在 Github 上申请", "deleteSuccess": "工作区删除成功", "deleteFailed": "工作区删除失败", "openSuccess": "打开工作区成功", "openFailed": "打开工作区失败", "renameSuccess": "工作区已成功重命名", "renameFailed": "工作区重命名失败", "updateIconSuccess": "工作区图标已修改", "updateIconFailed": "修改工作区图标失败", "cannotDeleteTheOnlyWorkspace": "不能删除仅存的工作区", "fetchWorkspacesFailed": "获取工作区失败", "leaveCurrentWorkspace": "退出工作区", "leaveCurrentWorkspacePrompt": "您确定要离开当前工作区吗?" }, "shareAction": { "buttonText": "分享", "workInProgress": "敬请期待", "markdown": "Markdown", "html": "HTML", "clipboard": "拷贝到剪贴板", "csv": "CSV", "copyLink": "复制链接", "publishToTheWeb": "发布至网络", "publishToTheWebHint": "利用AppFlowy创建一个网站", "publish": "发布", "unPublish": "取消发布", "visitSite": "访问网站", "exportAsTab": "导出为", "publishTab": "发布", "shareTab": "分享", "publishOnAppFlowy": "在 AppFlowy 发布", "shareTabTitle": "邀请他人来协作", "shareTabDescription": "与任何人轻松协作", "copyLinkSuccess": "链接已复制到剪贴板", "copyShareLink": "复制分享链接", "copyLinkFailed": "无法将链接复制到剪贴板", "copyLinkToBlockSuccess": "区块链接已复制到剪贴板", "copyLinkToBlockFailed": "无法将区块链接复制到剪贴板", "manageAllSites": "管理所有站点", "updatePathName": "更新路径名称" }, "moreAction": { "small": "小", "medium": "中", "large": "大", "fontSize": "字号", "import": "导入", "moreOptions": "更多选项", "wordCount": "字数:{}", "charCount": "字符数:{}", "createdAt": "创建于:{}", "deleteView": "删除", "duplicateView": "复制", "wordCountLabel": "词总数:", "charCountLabel": "字符总数:", "createdAtLabel": "已创建的:", "syncedAtLabel": "已同步的:", "saveAsNewPage": "在页面中添加消息" }, "importPanel": { "textAndMarkdown": "文本 和 Markdown", "documentFromV010": "来自 v0.1.0 的文档", "databaseFromV010": "来自 v0.1.0 的数据库", "notionZip": "Notion 导出的 Zip 文件", "csv": "CSV", "database": "数据库" }, "disclosureAction": { "rename": "重命名", "delete": "删除", "duplicate": "复制", "unfavorite": "从收藏夹中删除", "favorite": "添加到收藏夹", "openNewTab": "在新选项卡中打开", "moveTo": "移动", "addToFavorites": "添加到收藏夹", "copyLink": "复制链接", "changeIcon": "更改图标", "collapseAllPages": "收起全部子页面", "movePageTo": "将页面移动至", "move": "移动", "lockPage": "锁定页面" }, "blankPageTitle": "空白页", "newPageText": "新页面", "newDocumentText": "新文件", "newGridText": "新建网格", "newCalendarText": "新日历", "newBoardText": "新建看板", "chat": { "newChat": "AI 对话", "inputMessageHint": "问 @:appName AI", "inputLocalAIMessageHint": "问 @:appName 本地 AI", "unsupportedCloudPrompt": "该功能仅在使用 @:appName Cloud 时可用", "relatedQuestion": "相关问题", "serverUnavailable": "服务暂时不可用,请稍后再试", "aiServerUnavailable": "🌈 不妙!🌈 一只独角兽吃掉了我们的回复。请重试!", "retry": "重试", "clickToRetry": "点击重试", "regenerateAnswer": "重新生成", "question1": "如何使用 Kanban 来管理任务", "question2": "介绍一下 GTD 工作法", "question3": "为什么使用 Rust", "question4": "使用现有食材制定一份菜谱", "aiMistakePrompt": "AI 可能出错,请验证重要信息", "chatWithFilePrompt": "你想与文件对话吗?", "indexFileSuccess": "文件索引成功", "inputActionNoPages": "无页面结果", "clickToMention": "点击以提及页面", "uploadFile": "上传聊天使用的 PDF、md 或 txt 文件", "questionDetail": "{} 你好!我能怎么帮到你?", "indexingFile": "正在索引 {}", "generatingResponse": "正在生成相应", "sourceUnsupported": "我们当前不支持基于数据库的交流", "regenerate": "请重试", "addToPageTitle": "添加消息至......", "addToNewPage": "创建新的页面", "openPagePreviewFailedToast": "打开页面失败", "changeFormat": { "actionButton": "变更样式", "confirmButton": "基于该样式重新生成", "imageOnly": "仅图片", "textAndImage": "文本与图片", "text": "段落", "table": "表格" }, "referenceSource": "找到 {} 个来源", "referenceSources": "找到 {} 个来源", "questionTitle": "想法" }, "trash": { "text": "回收站", "restoreAll": "全部恢复", "restore": "恢复", "deleteAll": "全部删除", "pageHeader": { "fileName": "文件名", "lastModified": "最近修改", "created": "创建时间" }, "confirmDeleteAll": { "title": "您确定要删除回收站中的所有页面吗?", "caption": "此操作无法撤消。" }, "confirmRestoreAll": { "title": "您确定要恢复回收站中的所有页面吗?", "caption": "此操作无法撤消。" }, "restorePage": { "title": "恢复:{}", "caption": "你确定要恢复此页面吗?" }, "mobile": { "actions": "垃圾桶操作", "empty": "垃圾桶是空的", "emptyDescription": "您没有任何已删除的文件", "isDeleted": "已删除", "isRestored": "已恢复" }, "confirmDeleteTitle": "您确定要永久删除此页面吗?" }, "deletePagePrompt": { "text": "此页面已被移动至垃圾桶", "restore": "恢复页面", "deletePermanent": "彻底删除", "deletePermanentDescription": "你确定要永久删除此页面吗?此操作无法撤销。" }, "dialogCreatePageNameHint": "页面名称", "questionBubble": { "shortcuts": "快捷键", "whatsNew": "新功能", "markdown": "Markdown", "debug": { "name": "调试信息", "success": "已将调试信息复制到剪贴板!", "fail": "无法将调试信息复制到剪贴板" }, "feedback": "反馈", "help": "帮助和支持" }, "menuAppHeader": { "moreButtonToolTip": "更多按钮工具", "addPageTooltip": "在其中快速添加页面", "defaultNewPageName": "未命名页面", "renameDialog": "重命名", "pageNameSuffix": "复制" }, "noPagesInside": "里面没有页面", "toolbar": { "undo": "撤销", "redo": "恢复", "bold": "加粗", "italic": "斜体", "underline": "下划线", "strike": "删除线", "numList": "有序列表", "bulletList": "无序列表", "checkList": "任务列表", "inlineCode": "内联代码", "quote": "块引用", "header": "标题", "highlight": "高亮", "color": "颜色", "addLink": "添加链接", "link": "关联" }, "tooltip": { "lightMode": "切换到亮色模式", "darkMode": "切换到暗色模式", "openAsPage": "作为页面打开", "addNewRow": "添加一行", "openMenu": "点击打开菜单", "dragRow": "拖动来调整行", "viewDataBase": "查看数据库", "referencePage": "这个 {name} 正在被引用", "addBlockBelow": "在下面添加一个块", "aiGenerate": "生成", "urlLaunchAccessory": "在浏览器中打开", "urlCopyAccessory": "复制链接" }, "sideBar": { "closeSidebar": "关闭侧边栏", "openSidebar": "打开侧边栏", "personal": "个人的", "private": "私人的", "workspace": "工作区", "favorites": "收藏夹", "clickToHidePrivate": "点击以隐藏私人空间\n您在此处创建的页面仅对您可见", "clickToHideWorkspace": "点击以隐藏私人空间\n您在此处创建的页面对所有人可见", "clickToHidePersonal": "点击隐藏个人部分", "clickToHideFavorites": "单击隐藏收藏夹栏目", "addAPage": "添加页面", "addAPageToPrivate": "添加页面到私人空间", "addAPageToWorkspace": "添加页面到工作空间", "recent": "最近的", "today": "今日", "thisWeek": "本周", "others": "其他", "earlier": "更早", "justNow": "现在", "minutesAgo": "{count} 分钟以前", "lastViewed": "最近一次查看", "favoriteAt": "已收藏", "emptyRecent": "没有最近页面", "emptyRecentDescription": "在你查看页面时,它们会出现在这里,方便检索。", "emptyFavorite": "无收藏页面", "emptyFavoriteDescription": "将页面收藏起来——它们会列在这里,方便快速访问!", "removePageFromRecent": "从最近页移除此页面吗?", "removeSuccess": "成功移除", "favoriteSpace": "收藏", "RecentSpace": "最近", "Spaces": "空间", "upgradeToPro": "升级至专业版", "upgradeToAIMax": "解锁无限制 AI", "storageLimitDialogTitle": "你已用尽免费存储。升级以解锁无限制存储", "storageLimitDialogTitleIOS": "你已用尽免费存储。", "aiResponseLimitTitle": "你已用尽免费 AI 回应。升级到专业版或者购买 AI 插件来解锁无限制回应", "aiResponseLimitDialogTitle": "已达到 AI 回应限额", "aiResponseLimit": "你已用尽了免费 AI 回应。\n\n转到“设置 -> 计划 -> 点击 AI Max 或 Pro 计划”获取更多 AI 回应", "askOwnerToUpgradeToPro": "你的工作区即将用尽免费存储。请联系工作区所有者升级到专业版计划", "askOwnerToUpgradeToProIOS": "你的工作区即将用尽免费存储。", "askOwnerToUpgradeToAIMax": "你的工作区即将用尽免费 AI 回应。请联系工作区所有者升级计划或购买 AI 插件", "askOwnerToUpgradeToAIMaxIOS": "你的工作区即将用尽免费 AI 回应限额。", "aiImageResponseLimit": "你已消耗完你的 AI 图像响应额度。\n转到 设置 -> 方案 -> 点击 AI Max 去获得更多的图像响应额度", "purchaseStorageSpace": "购买存储空间", "purchaseAIResponse": "购买", "askOwnerToUpgradeToLocalAI": "联系工作区所有者启用设备上 AI", "upgradeToAILocal": "在你的设备上运行本地模型,极致保护隐私", "upgradeToAILocalDesc": "使用本地 AI 用 PDF 聊天、改善写作、自动填充表格" }, "notifications": { "export": { "markdown": "导出笔记为Markdown文档", "path": "文档/flowy" } }, "contactsPage": { "title": "联系人", "whatsHappening": "这周发生了哪些事", "addContact": "添加联系人", "editContact": "编辑联系人" }, "button": { "ok": "OK", "confirm": "确认", "done": "完成", "cancel": "取消", "signIn": "登录", "signOut": "登出", "complete": "完成", "save": "保存", "generate": "生成", "esc": "退出", "keep": "保留", "tryAgain": "重试", "discard": "放弃", "replace": "替换", "insertBelow": "在下方插入", "insertAbove": "在上方插入", "upload": "上传", "edit": "编辑", "delete": "删除", "copy": "复制", "duplicate": "复制", "putback": "放回去", "update": "更新", "share": "分享", "removeFromFavorites": "从收藏夹中", "removeFromRecent": "从最近页移除", "addToFavorites": "添加到收藏夹", "favoriteSuccessfully": "收藏成功", "unfavoriteSuccessfully": "取消收藏成功", "duplicateSuccessfully": "副本创建成功", "rename": "重命名", "helpCenter": "帮助中心", "add": "添加", "yes": "是", "no": "否", "clear": "清空", "remove": "移除", "dontRemove": "不移除", "copyLink": "复制链接", "align": "对齐", "login": "登录", "logout": "退出", "deleteAccount": "删除账户", "back": "返回", "signInGoogle": "使用 Google 账户登录", "signInGithub": "使用 Github 账户登录", "signInDiscord": "使用 Discord 账户登录", "more": "更多", "create": "创建", "close": "关闭", "next": "下一个", "previous": "上一个", "submit": "提交", "download": "下载", "backToHome": "返回主页", "viewing": "查看", "editing": "编辑", "gotIt": "我知道了", "retry": "重试", "uploadFailed": "上传失败", "Done": "完成", "Cancel": "取消", "OK": "确认" }, "label": { "welcome": "欢迎!", "firstName": "名", "middleName": "中间名", "lastName": "姓", "stepX": "第{X}步" }, "oAuth": { "err": { "failedTitle": "无法连接到您的账户。", "failedMsg": "请确认您已在浏览器中完成登录。" }, "google": { "title": "Google 账号登录", "instruction1": "为了导入您的 Google 联系人,您需要在浏览器中给予本程序授权。", "instruction2": "单击图标或选择文本复制到剪贴板:", "instruction3": "进入下面的链接,然后输入上面的代码:", "instruction4": "完成注册后,点击下面的按钮:" } }, "settings": { "title": "设置", "popupMenuItem": { "settings": "设置", "members": "成员", "trash": "回收站", "helpAndSupport": "帮助与支持" }, "sites": { "title": "站点", "namespaceTitle": "名字空间", "namespaceDescription": "管理你的名字空间与主页", "namespaceHeader": "名字空间", "homepageHeader": "主页", "updateNamespace": "更新名字空间", "removeHomepage": "移除主页", "selectHomePage": "选择一个页面", "customUrl": "自定义 URL", "namespace": { "updateExistingNamespace": "更新现有的名称空间", "upgradeToPro": "请升级至 Pro 订阅计划以设置主页", "redirectToPayment": "正在重定向至付款页面......", "onlyWorkspaceOwnerCanSetHomePage": "仅工作空间的所有者能为其设置主页", "pleaseAskOwnerToSetHomePage": "请联系工作空间所有者更新至 Pro 订阅计划" }, "publishedPage": { "title": "所有已发布页面", "description": "管理您已发布的页面", "date": "已发布的数据", "emptyHinText": "在当前工作空间没有已发布的页面", "noPublishedPages": "没有已发布的页面", "settings": "发布设置", "clickToOpenPageInApp": "在 App 中打开页面", "clickToOpenPageInBrowser": "在浏览器中打开页面" }, "error": { "failedToUpdateNamespace": "更新名称空间失败", "proPlanLimitation": "您需要升级至 Pro 方案以更新名称空间", "namespaceAlreadyInUse": "该名称空间已被占用你,请尝试其他名称空间", "invalidNamespace": "无效的名称空间,请尝试其他的名称空间", "namespaceLengthAtLeast2Characters": "名称空间应不少于 2 个字符长度", "onlyWorkspaceOwnerCanUpdateNamespace": "仅工作空间所有者可更新名称空间", "onlyWorkspaceOwnerCanRemoveHomepage": "仅工作空间所有者可移除主页", "setHomepageFailed": "设置主页失败", "namespaceTooLong": "该名称空间过长,请尝试其他名称空间", "namespaceTooShort": "该名称空间过短,请尝试其他名称空间", "namespaceIsReserved": "该名称空间已被占用,请尝试其他名称空间", "updatePathNameFailed": "更新路径名称失败", "removeHomePageFailed": "移除主页失败", "publishNameContainsInvalidCharacters": "该路径名称包含无效的字符,请尝试其他的路径名称", "publishNameTooShort": "路径名称过短,请尝试其他路径名称", "publishNameTooLong": "路径名称过长,请尝试其他路径名称", "publishNameAlreadyInUse": "该路径名称已被使用,请尝试其他路径名称", "namespaceContainsInvalidCharacters": "该名称空间包含无效的字符,请尝试其他名称空间", "publishPermissionDenied": "仅工作空间所有者或页面发布者可管理发布设置", "publishNameCannotBeEmpty": "该路径名称不能为空,请尝试其他路径名称" }, "success": { "namespaceUpdated": "更新名称空间成功", "setHomepageSuccess": "成功设置主页", "updatePathNameSuccess": "更新路径名称成功", "removeHomePageSuccess": "成功删除主页" } }, "accountPage": { "menuLabel": "我的账户", "title": "我的账户", "general": { "title": "账户名与资料图像", "changeProfilePicture": "更换头像" }, "email": { "title": "邮箱", "actions": { "change": "更改邮箱" } }, "login": { "title": "登录账户", "loginLabel": "登录", "logoutLabel": "退出登录" }, "description": "自定义您的简介,管理账户安全信息和 AI API keys,或登陆您的账户" }, "workspacePage": { "menuLabel": "工作区", "title": "工作区", "description": "自定义你的工作区外观、主题、字体、文本布局、日期/时间格式和语言。", "workspaceName": { "title": "工作区名称", "savedMessage": "已保存的工作区名称", "editTooltip": "编辑工作区名称" }, "workspaceIcon": { "title": "工作区图标", "description": "上传图片或使用表情符号到你的工作区。图标将显示在您的侧边栏和通知中。" }, "appearance": { "title": "外观", "description": "自定义你的工作区外观、主题、字体、文本布局、日期、时间和语言。", "options": { "system": "自动", "light": "明亮", "dark": "黑暗" } }, "resetCursorColor": { "title": "重置文档光标颜色", "description": "确定要重置光标颜色吗?" }, "resetSelectionColor": { "title": "重置文档选取颜色", "description": "确定要重置选区颜色吗?" }, "resetWidth": { "resetSuccess": "文档宽度重置成功" }, "theme": { "title": "主题", "description": "选择预设主题,或上传你自己的自定义主题。", "uploadCustomThemeTooltip": "上传自定义主题" }, "workspaceFont": { "title": "工作区字体", "noFontHint": "找不到字体,换个词试试。" }, "textDirection": { "title": "文字方向", "leftToRight": "从左到右", "rightToLeft": "从右到左", "auto": "自动", "enableRTLItems": "启用从右向左工具栏项目" }, "layoutDirection": { "title": "布局方向", "leftToRight": "从左到右", "rightToLeft": "从右到左" }, "dateTime": { "title": "日期 & 时间", "example": "{} 于 {} ({})", "24HourTime": "24 小时制", "dateFormat": { "label": "日期格式", "local": "本地", "us": "US", "iso": "ISO", "friendly": "友好", "dmy": "日/月/年" } }, "language": { "title": "语言" }, "deleteWorkspacePrompt": { "title": "删除工作区", "content": "你确定要删除此工作区吗?此操作无法撤消。" }, "leaveWorkspacePrompt": { "title": "离开工作区", "content": "你确定要离开此工作区吗?你将无法访问其中的所有页面和数据。", "success": "你已经成功离开工作区。", "fail": "无法离开工作区。" }, "manageWorkspace": { "title": "管理工作区", "leaveWorkspace": "离开工作区", "deleteWorkspace": "删除工作区" } }, "manageDataPage": { "menuLabel": "管理数据", "title": "管理数据", "description": "管理数据本地存储或将现有数据导入@:appName 。", "dataStorage": { "title": "文件存储位置", "tooltip": "你的文件存储位置", "actions": { "change": "更改路径", "open": "打开文件夹", "openTooltip": "打开当前数据文件夹位置", "copy": "复制路径", "copiedHint": "路径已复制!", "resetTooltip": "重置为默认位置" }, "resetDialog": { "title": "你确定吗?", "description": "将数据路径重置为默认位置不会删除你的数据。如果你想重新导入当前数据,你应该先复制当前位置的路径。" } }, "importData": { "title": "导入数据", "tooltip": "从 @:appName 备份/数据文件夹导入数据", "description": "从外部 @:appName 数据文件夹复制数据", "action": "浏览文件夹" }, "encryption": { "title": "加密", "tooltip": "管理你的数据存储和加密方式", "descriptionNoEncryption": "开启加密将会加密所有数据。此操作无法撤消。", "descriptionEncrypted": "你的数据已加密。", "action": "加密数据", "dialog": { "title": "加密您的所有数据?", "description": "加密所有数据将保证数据安全。此操作无法撤消。您确定要继续吗?" } }, "cache": { "title": "清除缓存", "description": "如果你遇到图片无法加载或字体无法正确显示的问题,请尝试清除缓存。此操作不会删除你的用户数据。", "dialog": { "title": "清除缓存", "description": "清除缓存会导致加载时重新下载图像和字体。此操作不会删除或修改你的数据。", "successHint": "缓存已清除!" } }, "data": { "fixYourData": "修复数据", "fixButton": "修复" } }, "shortcutsPage": { "menuLabel": "快捷键", "title": "快捷键", "actions": { "resetDefault": "重置为默认" }, "errorPage": { "howToFix": "请再次尝试,如果该问题依然存在,请在 Github 上联系我们" }, "resetDialog": { "title": "重置快捷键", "description": "这将会将所有按键绑定重置为默认,之后无法撤销。你确定要继续吗?" }, "conflictDialog": { "confirmLabel": "继续" }, "keybindings": { "insertNewParagraphInCodeblock": "插入新的段落", "pasteInCodeblock": "粘贴为代码块", "selectAllCodeblock": "全选", "copy": "复制选中的内容", "alignLeft": "文本居左对齐", "alignCenter": "文本居中对齐", "alignRight": "文本居右对齐", "undo": "撤销", "redo": "重做", "convertToParagraph": "将块转换为段落", "backspace": "删除", "deleteLeftWord": "删除左侧文字", "deleteLeftSentence": "删除左侧句子", "delete": "删除右侧字符", "deleteMacOS": "删除左侧字符", "deleteRightWord": "删除右侧文字", "moveCursorLeft": "将光标移至左侧", "moveCursorBeginning": "将光标移至开头", "moveCursorLeftWord": "将光标移至文字左侧", "moveCursorRight": "将光标移至右侧", "moveCursorEnd": "将光标移至末尾", "moveCursorRightWord": "将光标移至文字右侧", "home": "滚动至顶部", "end": "滚动至底部" } }, "aiPage": { "title": "AI 设置", "menuLabel": "AI 设置", "keys": { "enableAISearchTitle": "AI 搜索", "aiSettingsDescription": "选择你偏好的模型来赋能 AppFlowy AI。目前可以使用 GPT 4-o、Claude 3,5、Llama 3.1 与 Mistral 7B", "llmModel": "语言模型", "llmModelType": "语言模型类别" } }, "planPage": { "planUsage": { "currentPlan": { "freeTitle": "免费" } } }, "comparePlanDialog": { "proLabels": { "itemTwo": "最多十个", "itemThree": "无限", "itemFour": "是", "itemFive": "是", "itemSix": "无限", "itemFileUpload": "无限" } }, "cancelSurveyDialog": { "questionOne": { "answerOne": "价格太高", "answerTwo": "功能未达预期", "answerThree": "找到更好替代方案" }, "questionTwo": { "answerOne": "非常可能", "answerTwo": "稍有可能", "answerThree": "不确定", "answerFour": "不太可能", "answerFive": "非常不可能" }, "questionFour": { "answerOne": "极好", "answerTwo": "良好", "answerThree": "一般", "answerFour": "较差", "answerFive": "未满足" } }, "common": { "reset": "重置" }, "menu": { "appearance": "外观", "language": "语言", "user": "用户", "files": "文件", "notifications": "通知", "open": "打开设置", "logout": "登出", "logoutPrompt": "您确定要登出吗?", "selfEncryptionLogoutPrompt": "您确定要登出吗?请确保您已复制加密密钥", "syncSetting": "同步设置", "cloudSettings": "云设置", "enableSync": "启用同步", "enableEncrypt": "加密数据", "cloudURL": "服务器 URL", "invalidCloudURLScheme": "无效主题", "cloudServerType": "云服务器", "cloudServerTypeTip": "请注意,切换云服务器后可能会登出您当前的账户", "cloudLocal": "本地", "cloudAppFlowy": "@:appName Cloud Beta", "cloudAppFlowySelfHost": "@:appName Cloud 自托管", "appFlowyCloudUrlCanNotBeEmpty": "云地址不能为空", "clickToCopy": "点击复制", "selfHostStart": "如果您没有服务器,请参阅", "selfHostContent": "文档", "selfHostEnd": "有关如何自行托管自己的服务器的指南", "cloudURLHint": "输入您的服务器的 URL", "cloudWSURL": "WebSocket URL", "cloudWSURLHint": "输入您的服务器的 WebSocket 地址", "restartApp": "重启", "restartAppTip": "重新启动应用程序以使更改生效。请注意,这可能会注销您当前的帐户", "changeServerTip": "更改服务器后,必须单击重新启动按钮才能使更改生效", "enableEncryptPrompt": "启用加密以使用此密钥保护您的数据。安全存放;一旦启用,就无法关闭。如果丢失,您的数据将无法恢复。点击复制", "inputEncryptPrompt": "请输入您的加密密钥", "clickToCopySecret": "点击复制密钥", "configServerSetting": "配置您的服务器设置", "configServerGuide": "选择“快速启动”后,导航至“设置”,然后选择“云设置”以配置您的自托管服务器。", "inputTextFieldHint": "你的秘密", "historicalUserList": "用户登录历史记录", "historicalUserListTooltip": "此列表显示您的匿名帐户。您可以单击某个帐户来查看其详细信息。单击“开始”按钮即可创建匿名帐户", "openHistoricalUser": "点击开设匿名账户", "customPathPrompt": "将 @:appName 数据文件夹存储在云同步文件夹(例如 Google Drive)中可能会带来风险。如果同时从多个位置访问或修改此文件夹中的数据库,可能会导致同步冲突和潜在的数据损坏", "importAppFlowyData": "从外部 @:appName 文件夹导入数据", "importingAppFlowyDataTip": "数据导入正在进行中。请不要关闭应用程序", "importAppFlowyDataDescription": "从外部 @:appName 数据文件夹复制数据并将其导入到当前 @:appName 数据文件夹中", "importSuccess": "成功导入@:appName数据文件夹", "importFailed": "导入 @:appName 数据文件夹失败", "importGuide": "有关详细信息,请参阅参考文档", "cloudSetting": "云设置" }, "notifications": { "enableNotifications": { "label": "启用通知", "hint": "关闭以阻止本地通知出现。" }, "showNotificationsIcon": { "label": "显示通知图标", "hint": "关闭开关以隐藏侧边栏中的通知图标。" }, "markAsReadNotifications": { "allSuccess": "成功全部标为已读", "success": "成功标为已读" }, "action": { "markAsRead": "标为已读", "multipleChoice": "选择更多" }, "settings": { "settings": "设置" }, "tabs": { "unread": "未读" }, "refreshSuccess": "成功刷新通知", "titles": { "notifications": "通知", "reminder": "提醒" } }, "appearance": { "resetSetting": "重置此设置", "fontFamily": { "label": "字体系列", "search": "搜索", "defaultFont": "系统" }, "themeMode": { "label": "主题模式", "light": "日间模式", "dark": "夜间模式", "system": "系统自适应" }, "fontScaleFactor": "字体缩放", "documentSettings": { "cursorColor": "文档光标颜色", "selectionColor": "文档选择颜色", "pickColor": "选择颜色", "colorShade": "色深", "opacity": "透明度", "hexEmptyError": "十六进制颜色不能为空", "hexLengthError": "十六进制值必须为 6 位数字", "hexInvalidError": "十六进制值无效", "opacityEmptyError": "不透明度不能为空", "opacityRangeError": "不透明度必须介于 1 到 100 之间", "app": "应用程序", "flowy": "弗洛菲", "apply": "申请" }, "layoutDirection": { "label": "布局方向", "hint": "控制屏幕上内容的流动,从左到右或从右到左。", "ltr": "从左到右", "rtl": "从右到左" }, "textDirection": { "label": "默认文本方向", "hint": "指定文本默认从左开始还是从右开始。", "ltr": "从左到右", "rtl": "从右到左", "auto": "汽车", "fallback": "与布局方向相同" }, "themeUpload": { "button": "上传", "uploadTheme": "上传主题", "description": "使用下面的按钮上传您自己的 @:appName 主题。", "loading": "我们正在验证并上传您的主题,请稍候...", "uploadSuccess": "您的主题已上传成功", "deletionFailure": "删除主题失败,请尝试手动删除。", "filePickerDialogTitle": "选择 .flowy_plugin 文件", "urlUploadFailure": "无法打开网址:{}", "failure": "上传的主题格式无效。" }, "theme": "主题", "builtInsLabel": "内置主题", "pluginsLabel": "插件", "dateFormat": { "label": "日期格式", "local": "本地", "us": "美国", "iso": "ISO", "friendly": "易读", "dmy": "日/月/年" }, "timeFormat": { "label": "时间格式", "twelveHour": "十二小时制", "twentyFourHour": "二十四小时制" }, "showNamingDialogWhenCreatingPage": "创建页面时显示命名对话框", "enableRTLToolbarItems": "显示从右到左按钮", "members": { "title": "成员设置", "inviteMembers": "添加成员", "inviteHint": "使用电子邮件邀请", "sendInvite": "发送邀请", "copyInviteLink": "复制邀请链接", "label": "成员", "user": "用户", "role": "角色", "removeFromWorkspace": "从工作区移除", "owner": "所有者", "guest": "访客", "member": "成员", "emailSent": "邮件已发送,请检查您的邮箱", "memberLimitExceededUpgrade": "升级", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "添加成员失败", "addMemberSuccess": "添加成员成功", "removeMember": "移除成员", "areYouSureToRemoveMember": "您确定要删除该成员吗?", "inviteMemberSuccess": "成功发送邀请", "failedToInviteMember": "邀请成员失败" } }, "files": { "copy": "复制", "defaultLocation": "读取文件和数据存储位置", "exportData": "导出您的数据", "doubleTapToCopy": "双击复制路径", "restoreLocation": "恢复为 @:appName 默认路径", "customizeLocation": "打开另一个文件夹", "restartApp": "请重启 App 使设置生效", "exportDatabase": "导出数据库", "selectFiles": "选择需要导出的文件", "selectAll": "全选", "deselectAll": "取消全选", "createNewFolder": "新建文件夹", "createNewFolderDesc": "告诉我们您要将数据存储在何处", "defineWhereYourDataIsStored": "定义数据存储位置", "open": "打开", "openFolder": "打开现有文件夹", "openFolderDesc": "读取并将其写入您现有的 @:appName 文件夹", "folderHintText": "文件夹名", "location": "正在新建文件夹", "locationDesc": "为您的 @:appName 数据文件夹选择一个名称", "browser": "浏览", "create": "新建", "set": "设置", "folderPath": "保存文件夹的路径", "locationCannotBeEmpty": "路径不能为空", "pathCopiedSnackbar": "文件存储路径已被复制到剪贴板!", "changeLocationTooltips": "更改数据目录", "change": "更改", "openLocationTooltips": "打开另一个数据目录", "openCurrentDataFolder": "打开当前数据目录", "recoverLocationTooltips": "恢复为 @:appName 默认数据目录", "exportFileSuccess": "导出成功!", "exportFileFail": "导出失败!", "export": "导出", "clearCache": "清空缓存", "clearCacheDesc": "如果您遇到图片无法加载或字体无法正确显示的问题,请尝试清除缓存。此操作不会删除您的用户数据。", "areYouSureToClearCache": "您确定要清除缓存吗?", "clearCacheSuccess": "缓存清除成功!" }, "user": { "name": "名字", "email": "电子邮件", "tooltipSelectIcon": "选择图标", "selectAnIcon": "选择一个图标", "pleaseInputYourOpenAIKey": "请输入您的 AI 密钥", "clickToLogout": "点击退出当前用户", "pleaseInputYourStabilityAIKey": "请输入您的 Stability AI 密钥" }, "mobile": { "personalInfo": "个人信息", "username": "用户名", "usernameEmptyError": "用户名不能为空", "about": "关于", "pushNotifications": "推送通知", "support": "支持", "joinDiscord": "在 Discord 中加入我们", "privacyPolicy": "隐私政策", "userAgreement": "用户协议", "termsAndConditions": "条款和条件", "userprofileError": "无法加载用户配置文件", "userprofileErrorDescription": "请尝试注销并重新登录以检查问题是否仍然存在。", "selectLayout": "选择布局", "selectStartingDay": "选择开始日期", "version": "版本" }, "shortcuts": { "shortcutsLabel": "快捷方式", "command": "指令", "keyBinding": "按键绑定", "addNewCommand": "添加新指令", "updateShortcutStep": "按所需的组合键并按 ENTER", "shortcutIsAlreadyUsed": "此快捷方式已用于:{conflict}", "resetToDefault": "重置为默认组合键", "couldNotLoadErrorMsg": "无法加载快捷方式,请重试", "couldNotSaveErrorMsg": "无法保存快捷方式,请重试", "commands": { "codeBlockNewParagraph": "在代码块旁边插入一个新段落", "codeBlockAddTwoSpaces": "在代码块的行首插入两个空格", "codeBlockSelectAll": "选择代码块内的所有内容", "codeBlockPasteText": "在代码块中粘贴文本", "textAlignLeft": "左对齐文本", "textAlignCenter": "将文本居中对齐", "textAlignRight": "右对齐文本", "codeBlockDeleteTwoSpaces": "删除代码块中行首的两个空格" } } }, "grid": { "deleteView": "您确定要删除这个表格吗?", "createView": "新建", "title": { "placeholder": "无标题" }, "settings": { "filter": "筛选", "sort": "以……排序", "sortBy": "排序", "properties": "特性", "reorderPropertiesTooltip": "拖动以重新排序属性", "group": "组", "addFilter": "添加筛选", "deleteFilter": "删除筛选", "filterBy": "以……筛选", "typeAValue": "请输入一个值", "layout": "布局", "databaseLayout": "布局", "viewList": { "zero": "0 次观看", "one": "{count} 次查看", "other": "{count} 次浏览" }, "editView": "编辑视图", "boardSettings": "看板设置", "calendarSettings": "日历设置", "createView": "新视图", "duplicateView": "复制视图", "deleteView": "删除视图", "numberOfVisibleFields": "显示 {}", "Properties": "属性" }, "textFilter": { "contains": "包含", "doesNotContain": "不包含", "endsWith": "以……结束", "startWith": "以……开始", "is": "等于", "isNot": "不等于", "isEmpty": "为空", "isNotEmpty": "不为空", "choicechipPrefix": { "isNot": "不等于", "startWith": "以……开始", "endWith": "以……结束", "isEmpty": "为空", "isNotEmpty": "不为空" } }, "checkboxFilter": { "isChecked": "已勾选", "isUnchecked": "未勾选", "choicechipPrefix": { "is": "是" } }, "checklistFilter": { "isComplete": "已完成", "isIncomplted": "未完成" }, "selectOptionFilter": { "is": "是", "isNot": "不是", "contains": "包含", "doesNotContain": "不含", "isEmpty": "为空", "isNotEmpty": "不为空" }, "dateFilter": { "is": "是", "before": "之前", "after": "之后", "onOrBefore": "在或之前", "onOrAfter": "在或之后", "between": "之间", "empty": "为空", "notEmpty": "不为空", "choicechipPrefix": { "before": "之前", "after": "之后", "onOrBefore": "今天或之前", "onOrAfter": "今天或之后", "isEmpty": "为空", "isNotEmpty": "不为空" } }, "numberFilter": { "equal": "等于", "notEqual": "不相等", "lessThan": "小于", "greaterThan": "大于", "lessThanOrEqualTo": "小于或等于", "greaterThanOrEqualTo": "大于或等于", "isEmpty": "为空", "isNotEmpty": "不为空" }, "field": { "label": "属性", "hide": "隐藏", "show": "展示", "insertLeft": "左侧插入", "insertRight": "右侧插入", "duplicate": "复制", "delete": "删除", "clear": "清空单元格", "textFieldName": "文本", "checkboxFieldName": "勾选框", "dateFieldName": "日期", "updatedAtFieldName": "修改时间", "createdAtFieldName": "创建时间", "numberFieldName": "数字", "singleSelectFieldName": "单项选择器", "multiSelectFieldName": "多项选择器", "urlFieldName": "链接", "checklistFieldName": "清单", "summaryFieldName": "AI 总结", "timeFieldName": "时间", "numberFormat": "数字格式", "dateFormat": "日期格式", "includeTime": "包含时间", "isRange": "结束日期", "dateFormatFriendly": "月 日, 年", "dateFormatISO": "年-月-日", "dateFormatLocal": "月/日/年", "dateFormatUS": "年/月/日", "dateFormatDayMonthYear": "日/月/年", "timeFormat": "时间格式", "invalidTimeFormat": "时间格式错误", "timeFormatTwelveHour": "十二小时制", "timeFormatTwentyFourHour": "24小时制", "clearDate": "清除日期", "dateTime": "日期时间", "startDateTime": "开始日期和时间", "endDateTime": "结束日期和时间", "failedToLoadDate": "无法加载日期值", "selectTime": "选择时间", "selectDate": "选择日期", "visibility": "可见性", "propertyType": "属性类型", "addSelectOption": "添加一个标签", "typeANewOption": "输入新选项", "optionTitle": "标签", "addOption": "添加标签", "editProperty": "编辑列属性", "newProperty": "添加一列", "deleteFieldPromptMessage": "确定要删除这个属性吗? ", "clearFieldPromptMessage": "您确定吗?此列中的所有单元格都将被清空", "newColumn": "新建列", "format": "格式", "reminderOnDateTooltip": "此单元格有预定的提醒", "optionAlreadyExist": "选项已存在", "wrap": "自动换行" }, "rowPage": { "newField": "添加新字段", "fieldDragElementTooltip": "点击打开菜单", "showHiddenFields": { "one": "显示 {count} 个隐藏字段", "many": "显示 {count} 个隐藏字段", "other": "显示 {count} 个隐藏字段" }, "hideHiddenFields": { "one": "隐藏 {count} 个隐藏字段", "many": "隐藏 {count} 个隐藏字段", "other": "隐藏 {count} 个隐藏字段" } }, "sort": { "ascending": "升序", "descending": "降序", "by": "通过", "cannotFindCreatableField": "找不到合适的排序字段", "deleteAllSorts": "删除所有排序", "addSort": "添加排序", "removeSorting": "您想删除排序吗?", "fieldInUse": "您已按此字段排序", "deleteSort": "删除排序" }, "row": { "duplicate": "复制", "delete": "删除", "titlePlaceholder": "无标题", "textPlaceholder": "空", "copyProperty": "复制列", "count": "数量", "newRow": "添加一行", "action": "执行", "add": "点击添加到下方", "drag": "拖动以移动", "deleteRowPrompt": "您确定要删除此行吗?此操作无法撤消", "dragAndClick": "拖拽移动,点击打开菜单", "insertRecordAbove": "在上方插入记录", "insertRecordBelow": "点击添加到下方" }, "selectOption": { "create": "新建", "purpleColor": "紫色", "pinkColor": "粉色", "lightPinkColor": "浅粉色", "orangeColor": "橙色", "yellowColor": "黄色", "limeColor": "鲜绿色", "greenColor": "绿色", "aquaColor": "水蓝色", "blueColor": "蓝色", "deleteTag": "删除标签", "colorPanelTitle": "颜色", "panelTitle": "选择或新建一个标签", "searchOption": "搜索标签", "searchOrCreateOption": "搜索或创建选项...", "createNew": "创建一个新的", "orSelectOne": "或者选择一个选项", "typeANewOption": "输入一个新的选项", "tagName": "标签名" }, "checklist": { "taskHint": "任务描述", "addNew": "添加项", "submitNewTask": "创建", "hideComplete": "隐藏已完成的任务", "showComplete": "显示所有任务" }, "url": { "launch": "在浏览器中打开链接", "copy": "将链接复制到剪贴板", "textFieldHint": "输入 URL" }, "relation": { "rowSearchTextFieldPlaceholder": "搜索" }, "menuName": "网格", "referencedGridPrefix": "视图", "calculate": "计算", "calculationTypeLabel": { "none": "没有任何", "average": "平均的", "max": "最大限度", "median": "中位数", "min": "分钟", "sum": "和" }, "media": { "rename": "重命名", "download": "下载", "delete": "删除", "open": "打开" }, "singleSelectOptionFilter": { "is": "等于", "isNot": "不等于", "isEmpty": "为空", "isNotEmpty": "不为空" }, "multiSelectOptionFilter": { "contains": "包含", "doesNotContain": "不包含", "isEmpty": "为空", "isNotEmpty": "不为空" } }, "document": { "menuName": "文档", "date": { "timeHintTextInTwelveHour": "下午 01:00", "timeHintTextInTwentyFourHour": "13:00" }, "slashMenu": { "board": { "selectABoardToLinkTo": "选择要链接到的看板", "createANewBoard": "新建看板" }, "grid": { "selectAGridToLinkTo": "选择要链接到的网格", "createANewGrid": "新建网格" }, "calendar": { "selectACalendarToLinkTo": "选择要链接到的日历", "createANewCalendar": "新建日历" }, "document": { "selectADocumentToLinkTo": "选择要链接到的文档" }, "name": { "textStyle": "文本样式", "list": "列表", "toggle": "切换", "fileAndMedia": "文件与媒体", "simpleTable": "简单表格", "visuals": "视觉元素", "document": "文档", "advanced": "高级", "text": "文本", "heading1": "一级标题", "heading2": "二级标题", "heading3": "三级标题", "image": "图片", "bulletedList": "项目符号列表", "numberedList": "编号列表", "todoList": "待办事项列表", "doc": "文档", "linkedDoc": "链接到页面", "grid": "网格", "linkedGrid": "链接网格", "kanban": "看板", "linkedKanban": "链接看板", "calendar": "日历", "linkedCalendar": "链接日历", "quote": "引用", "divider": "分隔符", "table": "表格", "callout": "提示框", "outline": "大纲", "mathEquation": "数学公式", "code": "代码", "toggleList": "切换列表", "toggleHeading1": "切换标题1", "toggleHeading2": "切换标题2", "toggleHeading3": "切换标题3", "emoji": "表情符号", "aiWriter": "向AI提问", "dateOrReminder": "日期或提醒", "photoGallery": "图片库", "file": "文件", "twoColumns": "两列", "threeColumns": "三列", "fourColumns": "四列" }, "subPage": { "name": "文档", "keyword1": "子页面", "keyword2": "页面", "keyword3": "子页面", "keyword4": "插入页面", "keyword5": "嵌入页面", "keyword6": "新页面", "keyword7": "创建页面", "keyword8": "文档" } }, "selectionMenu": { "outline": "大纲", "codeBlock": "代码块" }, "plugins": { "referencedBoard": "引用的看板", "referencedGrid": "引用的网格", "referencedCalendar": "引用的日历", "referencedDocument": "参考文档", "aiWriter": { "userQuestion": "向AI提问", "continueWriting": "继续写作", "fixSpelling": "修正拼写和语法", "improveWriting": "优化写作", "summarize": "总结", "explain": "解释", "makeShorter": "缩短", "makeLonger": "扩展" }, "autoGeneratorMenuItemName": "AI 创作", "autoGeneratorTitleName": "AI: 让 AI 写些什么...", "autoGeneratorLearnMore": "学习更多", "autoGeneratorGenerate": "生成", "autoGeneratorHintText": "让 AI ...", "autoGeneratorCantGetOpenAIKey": "无法获得 AI 密钥", "autoGeneratorRewrite": "重写", "smartEdit": "AI 助手", "aI": "AI", "smartEditFixSpelling": "修正拼写", "warning": "⚠️ AI 可能不准确或具有误导性.", "smartEditSummarize": "总结", "smartEditImproveWriting": "提高写作水平", "smartEditMakeLonger": "丰富内容", "smartEditCouldNotFetchResult": "无法从 AI 获取到结果", "smartEditCouldNotFetchKey": "无法获取到 AI 密钥", "smartEditDisabled": "在设置中连接 AI", "discardResponse": "您是否要放弃 AI 继续写作?", "createInlineMathEquation": "创建方程", "fonts": "字体", "insertDate": "插入日期", "emoji": "表情符号", "toggleList": "切换列表", "quoteList": "引用列表", "numberedList": "编号列表", "bulletedList": "项目符号列表", "todoList": "待办事项列表", "callout": "标注", "cover": { "changeCover": "修改封面", "colors": "颜色", "images": "图像", "clearAll": "清除所有", "abstract": "摘要", "addCover": "添加封面", "addLocalImage": "添加本地图像", "invalidImageUrl": "无效的图像网址", "failedToAddImageToGallery": "无法将图片添加到库", "enterImageUrl": "输入图像网址", "add": "添加", "back": "返回", "saveToGallery": "保存至库", "removeIcon": "移除图标", "pasteImageUrl": "粘贴图片网址", "or": "或", "pickFromFiles": "从文件中选取", "couldNotFetchImage": "无法获取到图像", "imageSavingFailed": "图像保存失败", "addIcon": "添加图标", "changeIcon": "更改图标", "coverRemoveAlert": "删除后将从封面中移除。", "alertDialogConfirmation": "您确定您要继续吗?" }, "mathEquation": { "name": "数学方程", "addMathEquation": "添加数学公式", "editMathEquation": "编辑数学公式" }, "optionAction": { "click": "点击", "toOpenMenu": "打开菜单", "delete": "删除", "duplicate": "复制", "turnInto": "变成", "moveUp": "上移", "moveDown": "下移", "color": "颜色", "align": "对齐", "left": "左", "center": "中心", "right": "右", "defaultColor": "默认", "depth": "深度", "copyLinkToBlock": "粘贴块链接" }, "image": { "addAnImage": "添加图像", "copiedToPasteBoard": "图片链接已复制到剪贴板" }, "urlPreview": { "copiedToPasteBoard": "链接已复制到剪贴板" }, "outline": { "addHeadingToCreateOutline": "添加标题以创建目录。" }, "table": { "addAfter": "在后面添加", "addBefore": "在前面添加", "delete": "删除", "clear": "清空内容", "duplicate": "创建副本", "bgColor": "背景颜色" }, "contextMenu": { "copy": "复制", "cut": "剪切", "paste": "粘贴" }, "action": "操作", "database": { "selectDataSource": "选择数据源", "noDataSource": "没有数据源", "selectADataSource": "选择数据源", "toContinue": "继续", "newDatabase": "新建数据库", "linkToDatabase": "链接至数据库" }, "date": "日期", "video": { "label": "视频", "emptyLabel": "添加视频", "placeholder": "粘贴视频链接", "copiedToPasteBoard": "视频链接已复制到剪贴板", "insertVideo": "添加视频" }, "linkPreview": { "typeSelection": { "pasteAs": "粘贴为", "mention": "提及", "URL": "URL", "bookmark": "书签", "embed": "嵌入" }, "linkPreviewMenu": { "toMetion": "转换为提及", "toUrl": "转换为URL", "toEmbed": "转换为嵌入", "toBookmark": "转换为书签", "copyLink": "复制链接", "replace": "替换", "reload": "重新加载", "removeLink": "移除链接", "pasteHint": "粘贴 https://...", "unableToDisplay": "无法显示" } } }, "outlineBlock": { "placeholder": "目录" }, "textBlock": { "placeholder": "输入 “/” 作为命令" }, "title": { "placeholder": "无标题" }, "imageBlock": { "placeholder": "点击添加图片", "upload": { "label": "上传", "placeholder": "点击上传图片" }, "url": { "label": "图片网址", "placeholder": "输入图片网址" }, "ai": { "label": "从 AI 生成图像", "placeholder": "请输入 AI 生成图像的提示" }, "stability_ai": { "label": "从 Stability AI 生成图像", "placeholder": "请输入 Stability AI 生成图像的提示" }, "support": "图片大小限制为 5MB。支持的格式:JPEG、PNG、GIF、SVG", "error": { "invalidImage": "图片无效", "invalidImageSize": "图片大小必须小于 5MB", "invalidImageFormat": "不支持图像格式。支持的格式:JPEG、PNG、GIF、SVG", "invalidImageUrl": "图片网址无效", "noImage": "没有这样的文件或目录" }, "embedLink": { "label": "内嵌链接", "placeholder": "粘贴或输入图像链接" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "搜索图像", "pleaseInputYourOpenAIKey": "请在设置页面输入您的 AI 密钥", "saveImageToGallery": "保存图片", "failedToAddImageToGallery": "无法将图像添加到图库", "successToAddImageToGallery": "图片已成功添加到图库", "unableToLoadImage": "无法加载图像", "maximumImageSize": "支持的最大上传图片大小为 10MB", "uploadImageErrorImageSizeTooBig": "图片大小必须小于 10MB", "imageIsUploading": "图片正在上传", "pleaseInputYourStabilityAIKey": "请在设置页面输入您的 Stability AI 密钥" }, "codeBlock": { "language": { "label": "语言", "placeholder": "选择语言", "auto": "自动" }, "copyTooltip": "复制代码块的内容" }, "inlineLink": { "placeholder": "粘贴或输入链接", "openInNewTab": "在新选项卡中打开", "copyLink": "复制链接", "removeLink": "删除链接", "url": { "label": "链接网址", "placeholder": "输入链接网址" }, "title": { "label": "链接标题", "placeholder": "输入链接标题" } }, "mention": { "placeholder": "提及一个人、或日期...", "page": { "label": "链接到页面", "tooltip": "点击打开页面" } }, "toolbar": { "resetToDefaultFont": "重置为默认" }, "errorBlock": { "theBlockIsNotSupported": "当前版本不支持该块。", "blockContentHasBeenCopied": "块内容已被复制。" } }, "board": { "column": { "createNewCard": "新建", "renameGroupTooltip": "按下以重命名群组", "createNewColumn": "添加新组", "addToColumnTopTooltip": "在顶部添加一张新卡片", "addToColumnBottomTooltip": "在底部添加一张新卡片", "renameColumn": "改名", "hideColumn": "隐藏", "newGroup": "新建组", "deleteColumn": "删除", "deleteColumnConfirmation": "这将删除该组及其中的所有卡片。你确定你要继续吗?", "groupActions": "组操作" }, "hiddenGroupSection": { "sectionTitle": "隐藏组", "collapseTooltip": "隐藏隐藏组", "expandTooltip": "查看隐藏的组" }, "cardDetail": "卡片详情", "cardActions": "卡片操作", "cardDuplicated": "卡片已被复制", "cardDeleted": "卡片已被删除", "showOnCard": "显示卡片详细信息", "setting": "设置", "propertyName": "属性名称", "menuName": "看板", "showUngrouped": "显示未分组的项目", "ungroupedButtonText": "未分组的", "ungroupedButtonTooltip": "包含不属于任何组的卡片", "ungroupedItemsTitle": "点击添加到看板", "groupBy": "通过...分组", "referencedBoardPrefix": "视图", "notesTooltip": "内含笔记", "mobile": { "editURL": "编辑 URL", "showGroup": "“显示”组", "showGroupContent": "您确定要在公告板上显示该组吗?", "failedToLoad": "无法加载版面视图", "unhideGroup": "显示隐藏的组", "unhideGroupContent": "您确定要在看板上显示该组吗?", "faildToLoad": "无法加载看板视图" }, "dateCondition": { "today": "今天", "yesterday": "昨天", "tomorrow": "明天", "lastSevenDays": "过去 7 天", "nextSevenDays": "未来 7 天", "lastThirtyDays": "过去 30 天", "nextThirtyDays": "未来 30 天" } }, "calendar": { "menuName": "日历", "defaultNewCalendarTitle": "未命名", "newEventButtonTooltip": "添加新事件", "navigation": { "today": "今天", "jumpToday": "跳转到今天", "previousMonth": "上一月", "nextMonth": "下一月", "views": { "day": "天", "week": "周", "month": "月", "year": "年" } }, "settings": { "showWeekNumbers": "显示周数", "showWeekends": "显示周末", "firstDayOfWeek": "一周开始于", "layoutDateField": "以……为日历布局", "noDateTitle": "没有日期", "unscheduledEventsTitle": "未安排的事件", "clickToAdd": "单击以添加到日历", "name": "日历布局", "noDateHint": "计划外事件将显示在此处" }, "referencedCalendarPrefix": "视图", "quickJumpYear": "跳转到" }, "errorDialog": { "title": "@:appName 错误", "howToFixFallback": "对于给您带来的不便, 我们深表歉意! 请在我们的 GitHub 页面上提交 issue 并描述您遇到的错误。", "github": "在 GitHub 查看" }, "search": { "label": "搜索", "placeholder": { "actions": "搜索操作..." } }, "message": { "copy": { "success": "已复制", "fail": "复制失败" } }, "unSupportBlock": "当前版本不支持该块。", "views": { "deleteContentTitle": "您确定要删除 {pageType} 吗?", "deleteContentCaption": "如果您删除此{pageType},您可以从回收站中将其恢复。" }, "colors": { "custom": "自定义", "default": "默认", "red": "红色", "orange": "橙色", "yellow": "黄色", "green": "绿色", "blue": "蓝色", "purple": "紫色", "pink": "粉色", "brown": "棕色", "gray": "灰色" }, "emoji": { "emojiTab": "Emoji 表情", "search": "查找 Emoji", "noRecent": "没有最近的 Emoji", "noEmojiFound": "没有找到 Emoji", "filter": "筛选", "random": "随机", "selectSkinTone": "选择肤色", "remove": "移除", "categories": { "smileys": "表情与情感", "people": "人与身体", "animals": "动物与自然", "food": "食物和饮料", "activities": "活动", "places": "旅行与地点", "objects": "物体", "symbols": "符号", "flags": "旗帜", "nature": "自然", "frequentlyUsed": "经常使用" }, "skinTone": { "default": "默认", "light": "亮", "mediumLight": "偏亮", "medium": "适中", "mediumDark": "偏暗", "dark": "暗" } }, "inlineActions": { "noResults": "没有结果", "pageReference": "页面参考", "date": "日期", "reminder": { "groupTitle": "提醒", "shortKeyword": "提醒" } }, "datePicker": { "dateTimeFormatTooltip": "在设置中更改日期和时间格式", "reminderOptions": { "fiveMinsBefore": "5 分钟以前", "tenMinsBefore": "10 分钟以前", "fifteenMinsBefore": "15 分钟以前", "thirtyMinsBefore": "30 分钟以前", "oneHourBefore": "1 小时以前", "twoHoursBefore": "2 小时以前", "oneDayBefore": "1 天以前", "twoDaysBefore": "2 天以前", "oneWeekBefore": "1 周以前" } }, "relativeDates": { "yesterday": "昨天", "today": "今天", "tomorrow": "明天", "oneWeek": "一周" }, "notificationHub": { "title": "通知", "mobile": { "title": "更新" }, "emptyTitle": "都处理了!", "emptyBody": "没有待处理的通知或操作。享受平静。", "tabs": { "inbox": "收件箱", "upcoming": "即将推出" }, "actions": { "markAllRead": "标记为已读", "showAll": "全部", "showUnreads": "未读" }, "filters": { "ascending": "升序", "descending": "降序", "groupByDate": "按日期分组", "showUnreadsOnly": "仅显示未读", "resetToDefault": "重置为默认" } }, "reminderNotification": { "title": "提醒", "message": "记得在你忘记之前检查一下!", "tooltipDelete": "删除", "tooltipMarkRead": "标记为已读", "tooltipMarkUnread": "标记为未读" }, "findAndReplace": { "find": "寻找", "previousMatch": "上一个匹配", "nextMatch": "下一个匹配", "close": "关闭", "replace": "替换", "replaceAll": "全部替换", "noResult": "没有结果", "caseSensitive": "区分大小写" }, "error": { "weAreSorry": "我们很抱歉", "loadingViewError": "我们在加载此视图时遇到问题。请检查您的互联网连接,刷新应用程序,如果问题仍然存在,请随时联系开发团队。" }, "editor": { "bold": "加粗", "bulletedList": "符号列表", "checkbox": "复选框", "embedCode": "嵌入代码", "heading1": "一级标题", "heading2": "二级标题", "heading3": "三级标题", "highlight": "强调", "color": "颜色", "image": "图像", "italic": "斜体", "link": "链接", "numberedList": "编号列表", "quote": "引用", "strikethrough": "删除线", "text": "文本", "underline": "下划线", "fontColorDefault": "默认", "fontColorGray": "灰色", "fontColorBrown": "棕色", "fontColorOrange": "橙色", "fontColorYellow": "黄色", "fontColorGreen": "绿色", "fontColorBlue": "蓝色", "fontColorPurple": "紫色", "fontColorPink": "粉色", "fontColorRed": "红色", "backgroundColorDefault": "默认背景颜色", "backgroundColorGray": "灰色背景", "backgroundColorBrown": "棕色背景", "backgroundColorOrange": "橙色背景", "backgroundColorYellow": "黄色背景", "backgroundColorGreen": "绿色背景", "backgroundColorBlue": "蓝色背景", "backgroundColorPurple": "紫色背景", "backgroundColorPink": "粉色背景", "backgroundColorRed": "红色背景", "done": "完成", "cancel": "取消", "tint1": "色彩 1", "tint2": "色彩 2", "tint3": "色彩 3", "tint4": "色彩 4", "tint5": "色彩 5", "tint6": "色彩 6", "tint7": "色彩 7", "tint8": "色彩 8", "tint9": "色彩 9", "lightLightTint1": "紫色", "lightLightTint2": "粉色", "lightLightTint3": "浅粉色", "lightLightTint4": "橙色", "lightLightTint5": "黄色", "lightLightTint6": "鲜绿色", "lightLightTint7": "绿色", "lightLightTint8": "淡绿色", "lightLightTint9": "蓝色", "urlHint": "URL", "mobileHeading1": "标题 1", "mobileHeading2": "标题 2", "mobileHeading3": "标题 3", "textColor": "文字颜色", "backgroundColor": "背景颜色", "addYourLink": "添加您的链接", "openLink": "打开链接", "copyLink": "复制链接", "removeLink": "移除链接", "editLink": "编辑链接", "linkText": "文本", "linkTextHint": "请输入文本", "linkAddressHint": "请输入 URL", "highlightColor": "高亮颜色", "clearHighlightColor": "清除高亮颜色", "customColor": "定制颜色", "hexValue": "十六进制值", "opacity": "不透明度", "resetToDefaultColor": "重置为默认颜色", "ltr": "从左到右", "rtl": "从右到左", "auto": "自动", "cut": "剪切", "copy": "复制", "paste": "粘贴", "find": "查找", "previousMatch": "上一个匹配", "nextMatch": "下一个匹配", "closeFind": "关闭", "replace": "替换", "replaceAll": "替换全部", "regex": "正则表达式", "caseSensitive": "区分大小写", "uploadImage": "上传图片", "urlImage": "URL 图片", "incorrectLink": "错误链接", "upload": "上传", "chooseImage": "选择图像", "loading": "加载中", "imageLoadFailed": "无法加载图像", "divider": "分隔线", "table": "表格", "colAddBefore": "在前面添加", "rowAddBefore": "在前面添加", "colAddAfter": "在后面添加", "rowAddAfter": "在后面添加", "colRemove": "移除列", "rowRemove": "移除行", "colDuplicate": "复制列", "rowDuplicate": "复制行", "colClear": "清空本列内容", "rowClear": "清空本行内容", "slashPlaceHolder": "输入 '/' 以插入块,或开始键入" }, "favorite": { "noFavorite": "没有收藏页面", "noFavoriteHintText": "向左滑动页面即可将其添加到您的收藏夹" }, "cardDetails": { "notesPlaceholder": "输入 “/” 以插入块,或开始键入" }, "blockPlaceholders": { "todoList": "待办", "bulletList": "列表", "numberList": "列表", "quote": "引用", "heading": "标题 {}" }, "titleBar": { "pageIcon": "页面图标", "language": "语言", "font": "字体", "actions": "操作", "date": "日期", "addField": "添加字段", "userIcon": "用户图标" }, "newSettings": { "myAccount": { "title": "我的账户", "subtitle": "自定义您的个人资料,管理账户安全,设置 AI keys,或登录您的账户。", "profileLabel": "帐户名称 & 头像", "profileNamePlaceholder": "输入你的名字", "accountSecurity": "帐户安全", "2FA": "两步验证", "aiKeys": "AI keys", "accountLogin": "登录账户", "updateNameError": "更新名称失败", "updateIconError": "更新图标失败", "deleteAccount": { "title": "删除帐户", "subtitle": "永久删除你的帐户和所有数据。", "description": "永久删除你的账户,并移除所有工作区的访问权限。", "deleteMyAccount": "删除我的账户", "dialogTitle": "删除帐户", "dialogContent1": "你确定要永久删除您的帐户吗?", "dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。", "confirmHint1": "请输入 \"@:newSettings.myAccount.deleteAccount.confirmHint3\" 以确认。", "confirmHint2": "我理解此操作是不可逆的,并且将永久删除我的帐户和所有关联数据。", "confirmHint3": "删除我的账户", "checkToConfirmError": "你必须勾选以确认删除。", "failedToGetCurrentUser": "获取当前用户邮箱失败", "confirmTextValidationFailed": "你的确认文本不匹配 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "账户删除成功" } }, "workplace": { "updateIconError": "图标更新失败", "chooseAnIcon": "选择一个图标", "appearance": { "name": "外观", "themeMode": { "auto": "自动", "light": "明亮", "dark": "黑暗" }, "language": "语言" } } }, "pageStyle": { "pageIcon": "页面图标", "openSettings": "打开设置", "image": "图像" }, "commandPalette": { "placeholder": "输入你要搜索的内容..." }, "space": { "defaultSpaceName": "一般" }, "publish": { "saveThisPage": "使用此模板创建" }, "web": { "continueWithGoogle": "使用 Google 账户登录", "continueWithGithub": "使用 GitHub 账户登录", "continueWithDiscord": "使用 Discord 账户登录" }, "template": { "deleteFromTemplate": "从模板中删除", "relatedTemplates": "相关模板", "deleteTemplate": "删除模板", "removeRelatedTemplate": "移除相关模板", "label": "模板" }, "time": { "justNow": "刚刚", "seconds": { "one": "1 秒", "other": "{count} 秒" }, "minutes": { "one": "1 分钟", "other": "{count} 分钟" }, "hours": { "one": "1 小时", "other": "{count} 小时" }, "days": { "one": "1 天", "other": "{count} 天" }, "weeks": { "one": "1 周", "other": "{count} 周" }, "months": { "one": "1 月", "other": "{count} 月" }, "years": { "one": "1 年", "other": "{count} 年" }, "ago": "前", "yesterday": "昨天", "today": "今天" } } ================================================ FILE: frontend/resources/translations/zh-TW.json ================================================ { "appName": "AppFlowy", "defaultUsername": "我", "welcomeText": "歡迎使用 @:appName", "welcomeTo": "歡迎來到", "githubStarText": "在 GitHub 上按讚/星標", "subscribeNewsletterText": "訂閱電子報", "letsGoButtonText": "快速開始", "title": "標題", "youCanAlso": "你也可以", "and": "和", "failedToOpenUrl": "無法開啟網址:{}", "blockActions": { "addBelowTooltip": "點選以在下方新增", "addAboveCmd": "Alt+點選", "addAboveMacCmd": "Option+點選", "addAboveTooltip": "在上方新增", "dragTooltip": "拖曳以移動", "openMenuTooltip": "點選以開啟選單" }, "signUp": { "buttonText": "註冊", "title": "註冊 @:appName", "getStartedText": "開始使用", "emptyPasswordError": "密碼不能為空", "repeatPasswordEmptyError": "確認密碼不能為空", "unmatchedPasswordError": "重複的密碼與密碼不相同。", "alreadyHaveAnAccount": "已有帳戶?", "emailHint": "電子郵件", "passwordHint": "密碼", "repeatPasswordHint": "確認密碼", "signUpWith": "透過以下方式註冊:" }, "signIn": { "loginTitle": "登入 @:appName", "loginButtonText": "登入", "loginStartWithAnonymous": "繼續匿名對話", "continueAnonymousUser": "繼續匿名對話", "continueWithLocalModel": "繼續使用本地模型", "switchToAppFlowyCloud": "AppFlowy 雲端", "anonymousMode": "匿名模式", "buttonText": "登入", "signingInText": "正在登入...", "forgotPassword": "忘記密碼?", "emailHint": "電子郵件", "passwordHint": "密碼", "dontHaveAnAccount": "還沒有帳號?", "createAccount": "建立帳戶", "repeatPasswordEmptyError": "確認密碼不能為空", "unmatchedPasswordError": "重複的密碼與密碼不相同。請重新輸入。", "passwordMustContain": "密碼必須包含至少一個字母、一個數字和一個符號。", "syncPromptMessage": "同步資料可能需要一些時間。請不要關閉此頁面", "or": "或", "signInWithGoogle": "使用 Google 登入", "signInWithGithub": "使用 Github 登入", "signInWithDiscord": "使用 Discord 登入", "signInWithApple": "使用 Apple 帳號登入", "continueAnotherWay": "使用其他方式登入", "signUpWithGoogle": "使用 Google 註冊", "signUpWithGithub": "使用 Github 註冊", "signUpWithDiscord": "使用 Discord 註冊", "signInWith": "透過以下方式登入:", "signInWithEmail": "使用電子郵件登入", "signInWithMagicLink": "繼續", "signUpWithMagicLink": "使用 Magic Link 註冊", "pleaseInputYourEmail": "請輸入您的電郵地址", "settings": "設定", "magicLinkSent": "我們己發送 Magic Link 到您的電子郵件,點擊連結登入", "invalidEmail": "請輸入有效的電郵地址", "alreadyHaveAnAccount": "已經有帳戶?", "logIn": "登入", "generalError": "出了些問題。請稍後再試", "limitRateError": "出於安全原因,您只能每 60 秒申請一次 Magic Link", "magicLinkSentDescription": "連結已發送到您的電子信箱。點擊連結即可登入。連結將在 5 分鐘後過期。", "tokenHasExpiredOrInvalid": "此代碼已過期或無效,請重新嘗試。", "signingIn": "正在登入…", "checkYourEmail": "檢查您的電子郵件信箱", "temporaryVerificationLinkSent": "已發送一封暫時驗證連結。 請查看您的收件匣,地址為:", "temporaryVerificationCodeSent": "已發送一封暫時驗證碼。 請查看您的收件匣,地址為:", "continueToSignIn": "繼續登入", "continueWithLoginCode": "繼續使用登入代碼", "backToLogin": "返回登入頁面", "enterCode": "輸入代碼", "enterCodeManually": "手動輸入代碼", "continueWithEmail": "繼續使用電子郵件信箱", "enterPassword": "輸入密碼", "loginAs": "身分登入以", "invalidVerificationCode": "請輸入有效的驗證碼", "tooFrequentVerificationCodeRequest": "您發起了過多的請求,請稍後再試。", "invalidLoginCredentials": "您的密碼不正確,請重新嘗試。", "resetPassword": "重設密碼", "resetPasswordDescription": "輸入您的電子郵件信箱以重設您的密碼", "continueToResetPassword": "繼續重設密碼", "resetPasswordSuccess": "密碼已成功重設", "resetPasswordFailed": "密碼重設失敗", "resetPasswordLinkSent": "一封密碼重置連結已發送至您的電子郵件信箱。請查看您的收件匣於", "resetPasswordLinkExpired": "密碼重置連結已過期,請重新要求新的連結。", "resetPasswordLinkInvalid": "密碼重置連結無效,請重新要求新的連結。", "enterNewPasswordFor": "輸入新密碼為", "newPassword": "新密碼", "enterNewPassword": "輸入新密碼", "confirmPassword": "確認密碼", "confirmNewPassword": "輸入新密碼", "newPasswordCannotBeEmpty": "新密碼不能為空", "confirmPasswordCannotBeEmpty": "確認密碼不能為空", "passwordsDoNotMatch": "密碼不相符。請重新輸入", "verifying": "正在驗證…", "continueWithPassword": "繼續使用密碼", "youAreInLocalMode": "您目前處於本地模式。", "loginToAppFlowyCloud": "登入 AppFlowy Cloud" }, "workspace": { "chooseWorkspace": "選擇你的工作區", "defaultName": "我的工作區", "create": "建立工作區", "new": "新的工作區", "importFromNotion": "從 Notion 匯入", "learnMore": "了解更多", "reset": "重設工作區", "renameWorkspace": "重新命名工作區", "workspaceNameCannotBeEmpty": "工作區名稱不能為空", "resetWorkspacePrompt": "重設工作區將刪除其中所有頁面和資料。你確定要重設工作區嗎?或者,你可以聯絡支援團隊來恢復工作區。", "hint": "工作區", "notFoundError": "找不到工作區", "failedToLoad": "出了些問題!無法載入工作區。請嘗試關閉 @:appName 的任何開啟執行個體,然後再試一次。", "errorActions": { "reportIssue": "回報問題", "reportIssueOnGithub": "在 GitHub 上回報問題", "exportLogFiles": "匯出記錄檔", "reachOut": "在 Discord 上聯絡我們" }, "menuTitle": "工作區", "deleteWorkspaceHintText": "您確定要刪除工作區嗎? 此動作無法復原,您已發布的任何頁面都將會取消發布。", "createSuccess": "成功創建工作區", "createFailed": "無法創建工作區", "createLimitExceeded": "您已達到帳戶允許的最大工作區限制。如果您需要額外的工作空間,請在 Github 上申請", "deleteSuccess": "工作區刪除成功", "deleteFailed": "工作區刪除失敗", "openSuccess": "成功開啟工作區", "openFailed": "無法開啟工作區", "renameSuccess": "工作區重新命名成功", "renameFailed": "無法重新命名工作區", "updateIconSuccess": "更新工作區圖示成功", "updateIconFailed": "無法更新工作區圖示", "cannotDeleteTheOnlyWorkspace": "無法刪除唯一的工作區", "fetchWorkspacesFailed": "無法取得工作區", "leaveCurrentWorkspace": "離開工作區", "leaveCurrentWorkspacePrompt": "您確定要離開當前工作區嗎?" }, "shareAction": { "buttonText": "分享", "workInProgress": "即將推出", "markdown": "Markdown", "html": "HTML", "clipboard": "複製到剪貼簿", "csv": "CSV", "copyLink": "複製連結", "publishToTheWeb": "發佈到網頁", "publishToTheWebHint": "使用 AppFlowy 建立網站", "publish": "發佈", "unPublish": "取消發佈", "visitSite": "前往網站", "exportAsTab": "匯出為", "publishTab": "發佈", "shareTab": "分享", "publishOnAppFlowy": "在 AppFlowy 上發布", "shareTabTitle": "邀請協作", "shareTabDescription": "方便與任何人協作", "copyLinkSuccess": "連結已複製到剪貼簿", "copyShareLink": "複製分享連結", "copyLinkFailed": "無法將連結複製到剪貼簿", "copyLinkToBlockSuccess": "區塊連結已複製到剪貼簿", "copyLinkToBlockFailed": "無法將區塊連結複製到剪貼簿", "manageAllSites": "管理所有網站", "updatePathName": "更新路徑名稱" }, "moreAction": { "small": "小", "medium": "中", "large": "大", "fontSize": "字型大小", "import": "匯入", "moreOptions": "更多選項", "wordCount": "字數: {}", "charCount": "字元數: {}", "createdAt": "建立於: {}", "deleteView": "刪除", "duplicateView": "副本", "wordCountLabel": "字數:", "charCountLabel": "字元數:", "createdAtLabel": "建立於:", "syncedAtLabel": "已同步:", "saveAsNewPage": "新增訊息到頁面", "saveAsNewPageDisabled": "沒有可用的訊息" }, "importPanel": { "textAndMarkdown": "文字 & Markdown", "documentFromV010": "文件來自 v0.1.0", "databaseFromV010": "資料庫來自 v0.1.0", "notionZip": "Notion 匯出 ZIP 檔案", "csv": "CSV", "database": "資料庫" }, "emojiIconPicker": { "iconUploader": { "placeholderLeft": "拖放檔案,或點擊以", "placeholderUpload": "上傳", "placeholderRight": ", 或貼上圖片連結。", "dropToUpload": "將檔案拖放到這裡以上傳", "change": "變更\n" } }, "disclosureAction": { "rename": "重新命名", "delete": "刪除", "duplicate": "副本", "unfavorite": "從最愛中移除", "favorite": "加入最愛", "openNewTab": "在新分頁中開啟", "moveTo": "移動到", "addToFavorites": "加入最愛", "copyLink": "複製連結", "changeIcon": "更改圖示", "collapseAllPages": "折疊所有子頁面", "movePageTo": "將頁面移動到", "move": "移動", "lockPage": "鎖定頁面" }, "blankPageTitle": "空白頁面", "newPageText": "新增頁面", "newDocumentText": "新增文件", "newGridText": "新增網格", "newCalendarText": "新增日曆", "newBoardText": "新增看板", "chat": { "newChat": "AI 聊天", "inputMessageHint": "詢問 @:appName AI", "inputLocalAIMessageHint": "詢問 @:appName 本地 AI", "unsupportedCloudPrompt": "此功能僅在使用 @:appName Cloud 時可用", "relatedQuestion": "相關問題", "serverUnavailable": "服務暫時無法使用,請稍後再試。", "aiServerUnavailable": "AI 服務目前暫時不可用。請稍後再試一次。", "retry": "重試", "clickToRetry": "點擊重試", "regenerateAnswer": "重新產生", "question1": "如何使用看板來管理任務", "question2": "解釋 GTD 方法", "question3": "為什麼要使用 Rust", "question4": "用廚房裡現有的食材製作食譜", "question5": "為我的頁面創建插圖", "question6": "為我即將到來的下週擬定待辦事項清單", "aiMistakePrompt": "AI 可能會犯錯,請檢查重要資訊。", "chatWithFilePrompt": "您想與檔案聊天嗎?", "indexFileSuccess": "檔案索引成功", "inputActionNoPages": "沒有頁面結果", "referenceSource": { "zero": "未找到 0 個來源", "one": "已找到 {count} 個來源", "other": "已找到 {count} 個來源" }, "clickToMention": "提及頁面", "uploadFile": "附加 PDF、文字或 Markdown 檔案。", "questionDetail": "嗨,{}!今天我能如何幫助您?", "indexingFile": "正在索引 {}", "generatingResponse": "正在產生回應", "selectSources": "選擇來源", "currentPage": "目前頁面", "sourcesLimitReached": "您只能選取最多 3 個頂層文件及其子文件", "sourceUnsupported": "目前我們不支援與資料庫聊天", "regenerate": "再試一次", "addToPageButton": "新增訊息到頁面", "addToPageTitle": "新增訊息到...", "addToNewPage": "建立新頁面", "addToNewPageName": "從\"{}\"中提取的訊息", "addToNewPageSuccessToast": "已將訊息新增至", "openPagePreviewFailedToast": "開啟頁面失敗", "changeFormat": { "actionButton": "變更格式", "confirmButton": "用此格式重新產生", "textOnly": "文字", "imageOnly": "僅限圖片", "textAndImage": "文字和圖片", "text": "段落", "bullet": "項目符號清單", "number": "編號清單", "table": "表格", "blankDescription": "格式化回應", "defaultDescription": "自動回應格式", "textWithImageDescription": "@:chat .changeFormat.text 搭配圖像", "numberWithImageDescription": "@:chat .changeFormat.number 搭配圖像", "bulletWithImageDescription": "@:chat .changeFormat.bullet 搭配圖像", "tableWithImageDescription": "@:chat .changeFormat.table 搭配圖像" }, "switchModel": { "label": "切換模型", "localModel": "本地模型", "cloudModel": "雲端模型", "autoModel": "自動" }, "selectBanner": { "saveButton": "新增到…", "selectMessages": "選擇訊息", "nSelected": " {} 已選取", "allSelected": "全部已選取" }, "stopTooltip": "停止生成" }, "trash": { "text": "垃圾桶", "restoreAll": "全部還原", "restore": "還原", "deleteAll": "全部刪除", "pageHeader": { "fileName": "檔案名稱", "lastModified": "最近修改時間", "created": "建立時間" }, "confirmDeleteAll": { "title": "垃圾桶中的所有頁面", "caption": "您確定要刪除垃圾桶中的所有內容嗎?此動作無法復原。" }, "confirmRestoreAll": { "title": "還原垃圾桶中的所有頁面", "caption": "這個動作無法復原。" }, "restorePage": { "title": "還原: {}", "caption": "您確定要還原此頁面嗎?" }, "mobile": { "actions": "垃圾桶操作", "empty": "垃圾桶中沒有頁面或空間", "emptyDescription": "將不需要的項目移至垃圾桶。", "isDeleted": "已刪除", "isRestored": "已還原" }, "confirmDeleteTitle": "您確定要永久刪除此頁面嗎?" }, "deletePagePrompt": { "text": "此頁面在垃圾桶中", "restore": "還原頁面", "deletePermanent": "永久刪除", "deletePermanentDescription": "您確定要永久刪除此頁面嗎?這是一個不可逆的動作。" }, "dialogCreatePageNameHint": "頁面名稱", "questionBubble": { "shortcuts": "快捷鍵", "whatsNew": "最新消息?", "helpAndDocumentation": "說明與文件。", "getSupport": "取得支援", "markdown": "Markdown", "debug": { "name": "除錯資訊", "success": "已將除錯資訊複製到剪貼簿", "fail": "無法將除錯資訊複製到剪貼簿" }, "feedback": "意見回饋" }, "menuAppHeader": { "moreButtonToolTip": "移除、重新命名等等...", "addPageTooltip": "快速在此新增頁面", "defaultNewPageName": "無標題", "renameDialog": "重新命名", "pageNameSuffix": "複製" }, "noPagesInside": "裡面沒有頁面。", "toolbar": { "undo": "復原", "redo": "重做", "bold": "粗體", "italic": "斜體", "underline": "底線", "strike": "刪除線", "numList": "編號清單", "bulletList": "項目符號清單", "checkList": "核取清單", "inlineCode": "行內程式碼", "quote": "區塊引述", "header": "標題", "highlight": "醒目提示", "color": "顏色", "addLink": "新增連結" }, "tooltip": { "lightMode": "切換至淺色模式", "darkMode": "切換至深色模式", "openAsPage": "以頁面開啟", "addNewRow": "新增一列", "openMenu": "點選以開啟選單", "dragRow": "長按以重新排序列", "viewDataBase": "檢視資料庫", "referencePage": "這個 {name} 已被引用", "addBlockBelow": "在下方新增一個區塊", "aiGenerate": "生成" }, "sideBar": { "closeSidebar": "關閉側欄", "openSidebar": "開啟側欄", "expandSidebar": "展開為完整頁面", "personal": "個人", "private": "私人", "workspace": "工作區", "favorites": "最愛", "clickToHidePrivate": "點擊以隱藏私人空間\n您在此處建立的頁面只有您自己可見", "clickToHideWorkspace": "點擊以隱藏工作區\n您在此處建立的頁面對每個成員都可見", "clickToHidePersonal": "點選以隱藏個人區塊", "clickToHideFavorites": "點選以隱藏最愛區塊", "addAPage": "新增頁面", "addAPageToPrivate": "新增頁面到私人空間", "addAPageToWorkspace": "將頁面新增至工作區", "recent": "最近", "today": "今天", "thisWeek": "本週", "others": "較早的收藏夾", "earlier": "之前", "justNow": "剛剛", "minutesAgo": "{count} 分鐘前", "lastViewed": "上次瀏覽", "favoriteAt": "已收藏", "emptyRecent": "沒有最近的頁面", "emptyRecentDescription": "當您檢視頁面時,它們將會出現在這裡,方便您取回。", "emptyFavorite": "沒有收藏的頁面", "emptyFavoriteDescription": "將頁面標記為收藏 - 它們將列在此處以便您可以快速存取!", "removePageFromRecent": "要從最近的頁面中刪除此頁面嗎?", "removeSuccess": "刪除成功", "favoriteSpace": "收藏", "RecentSpace": "最近的", "Spaces": "空間", "upgradeToPro": "升級到專業版", "upgradeToAIMax": "解鎖無限 AI", "storageLimitDialogTitle": "您的免費儲存空間已用完,升級以解鎖無限儲存空間", "storageLimitDialogTitleIOS": "您的免費儲存空間已用完。", "aiResponseLimitTitle": "您的免費 AI 回覆已用完,升級到專業版或購買 AI 附加方案以解鎖無限回覆", "aiResponseLimitDialogTitle": "AI 回覆已達到限制", "aiResponseLimit": "您已用完免費的 AI 回應。\n前往 設定 -> 方案 -> 點擊 AI Max 或專業版方案以獲得更多 AI 回應。", "askOwnerToUpgradeToPro": "您的工作區即將耗盡免費儲存空間。請要求您的工作區擁有者升級到專業版", "askOwnerToUpgradeToProIOS": "您的工作區即將耗盡免費儲存空間。", "askOwnerToUpgradeToAIMax": "您的工作區已用完免費的 AI 回應。請要求您的工作區擁有者升級方案或購買 AI 附加功能", "askOwnerToUpgradeToAIMaxIOS": "您的工作區即將耗盡免費的 AI 回應。", "purchaseAIMax": "您的工作區已用完 AI 圖片回應。請要求您的工作區擁有者購買 AI Max。", "aiImageResponseLimit": "您已用完 AI 圖像回應。\n前往 設定 -> 方案 -> 點擊 AI Max 以獲得更多 AI 圖像回應", "purchaseStorageSpace": "購買儲存空間", "singleFileProPlanLimitationDescription": "您已超過免費方案允許的最大檔案上傳容量。請升級到專業版以上傳更大的檔案", "purchaseAIResponse": "購買", "askOwnerToUpgradeToLocalAI": "要求工作區擁有者啟用 AI 裝置端功能", "upgradeToAILocal": "在您的設備上執行本地模型,以獲得終極隱私保護", "upgradeToAILocalDesc": "在您的設備上執行本地模型,以獲得終極隱私保護" }, "notifications": { "export": { "markdown": "已將筆記匯出成 Markdown", "path": "文件/flowy" } }, "contactsPage": { "title": "聯絡人", "whatsHappening": "本週有什麼事發生?", "addContact": "新增聯絡人", "editContact": "編輯聯絡人" }, "button": { "ok": "確定", "confirm": "確認", "done": "完成", "cancel": "取消", "signIn": "登入", "signOut": "登出", "complete": "完成", "change": "變更", "save": "儲存", "generate": "生成", "esc": "ESC", "keep": "保留", "tryAgain": "再試一次", "discard": "放棄變更", "replace": "取代", "insertBelow": "在下方插入", "insertAbove": "在上方插入", "upload": "上傳", "edit": "編輯", "delete": "刪除", "copy": "複製", "duplicate": "複製", "putback": "放回", "update": "更新", "share": "分享", "removeFromFavorites": "從最愛中移除", "removeFromRecent": "從最近刪除", "addToFavorites": "加入最愛", "favoriteSuccessfully": "收藏成功", "unfavoriteSuccessfully": "已成功取消收藏", "duplicateSuccessfully": "複製成功", "rename": "重新命名", "helpCenter": "支援中心", "add": "新增", "yes": "是", "no": "否", "clear": "清除", "remove": "刪除", "dontRemove": "不要刪除", "copyLink": "複製連結", "align": "對齊", "login": "登入", "logout": "登出", "deleteAccount": "刪除帳號", "back": "返回", "signInGoogle": "使用 Google 登入", "signInGithub": "使用 Github 登入", "signInDiscord": "使用 Discord 登入", "more": "更多", "create": "新增", "close": "關閉", "next": "下一步", "previous": "上一步", "submit": "提交", "download": "下載", "backToHome": "回首頁", "viewing": "檢視中", "editing": "編輯中", "gotIt": "知道了", "retry": "重試", "uploadFailed": "上傳失敗。", "copyLinkOriginal": "複製原始連結" }, "label": { "welcome": "歡迎!", "firstName": "名字", "middleName": "中間名", "lastName": "姓氏", "stepX": "步驟 {X}" }, "oAuth": { "err": { "failedTitle": "無法連接至您的帳號。", "failedMsg": "請確認您已在瀏覽器中完成登入程序。" }, "google": { "title": "GOOGLE 帳號登入", "instruction1": "若要匯入您的 Google 聯絡人,您必須透過瀏覽器授權此應用程式。", "instruction2": "點選圖示或選取文字以複製程式碼到剪貼簿:", "instruction3": "前往下列網址,並輸入上述程式碼:", "instruction4": "完成註冊後,請點選下方按鈕:" } }, "settings": { "title": "設定", "popupMenuItem": { "settings": "設定", "members": "成員", "trash": "垃圾桶", "helpAndDocumentation": "說明與文件", "getSupport": "取得支援" }, "sites": { "title": "網站", "namespaceTitle": "命名空間", "namespaceDescription": "管理您的命名空間和首頁", "namespaceHeader": "命名空間", "homepageHeader": "首頁", "updateNamespace": "更新命名空間", "removeHomepage": "移除首頁", "selectHomePage": "選擇一個頁面", "clearHomePage": "清除此命名空間的首頁", "customUrl": "自訂網址", "homePage": { "upgradeToPro": "要設定首頁,請升級到專業版" }, "namespace": { "description": "此變更將適用於此命名空間中所有已發佈的頁面", "tooltip": "我們保留刪除任何不當命名空間的權利", "updateExistingNamespace": "更新現有命名空間", "upgradeToPro": "要擁有自訂命名空間,請升級到專業版", "redirectToPayment": "正在重新導向至付款頁面…", "onlyWorkspaceOwnerCanSetHomePage": "只有工作區擁有者才能設定首頁", "pleaseAskOwnerToSetHomePage": "請要求工作區擁有者升級到專業版" }, "publishedPage": { "title": "所有已發佈的頁面", "description": "管理您的已發佈頁面", "page": "頁面", "pathName": "路徑名稱", "date": "發布日期", "emptyHinText": "這個工作區沒有已發佈的頁面", "noPublishedPages": "沒有已發布的頁面", "settings": "發布設定", "clickToOpenPageInApp": "在應用程式中開啟頁面", "clickToOpenPageInBrowser": "在瀏覽器中開啟頁面" }, "error": { "failedToGeneratePaymentLink": "專業版付款連結產生失敗", "failedToUpdateNamespace": "更新命名空間失敗", "proPlanLimitation": "您需要升級到專業版才能更新命名空間", "namespaceAlreadyInUse": "這個命名空間已經被佔用,請嘗試另一個", "invalidNamespace": "無效的命名空間,請嘗試另一個", "namespaceLengthAtLeast2Characters": "命名空間至少必須有 2 個字元長度", "onlyWorkspaceOwnerCanUpdateNamespace": "只有工作區擁有者才能更新命名空間", "onlyWorkspaceOwnerCanRemoveHomepage": "只有工作區擁有者才能移除首頁", "setHomepageFailed": "設定首頁失敗", "namespaceTooLong": "命名空間太長,請嘗試另一個", "namespaceTooShort": "命名空間太短,請嘗試另一個", "namespaceIsReserved": "命名空間已保留,請嘗試另一個", "updatePathNameFailed": "更新路徑名稱失敗", "removeHomePageFailed": "移除首頁失敗", "publishNameContainsInvalidCharacters": "路徑名稱包含無效字元,請嘗試另一個", "publishNameTooShort": "徑名稱太短,請嘗試另一個", "publishNameTooLong": "路徑名稱太長,請嘗試另一個", "publishNameAlreadyInUse": "路徑名稱已經在使用中,請嘗試另一個", "namespaceContainsInvalidCharacters": "命名空間包含無效字元,請嘗試另一個", "publishPermissionDenied": "只有工作區擁有者或頁面發布者才能管理發佈設定", "publishNameCannotBeEmpty": "路徑名稱不能為空,請嘗試另一個" }, "success": { "namespaceUpdated": "命名空間更新成功", "setHomepageSuccess": "設定首頁成功", "updatePathNameSuccess": "路徑名稱已成功更新", "removeHomePageSuccess": "移除首頁成功" } }, "accountPage": { "menuLabel": "我帳戶與應用程式", "title": "我的帳號", "general": { "title": "帳號名稱和個人資料圖片", "changeProfilePicture": "更改個人資料圖片" }, "email": { "title": "電子郵件", "actions": { "change": "變更電子郵件地址" } }, "login": { "title": "帳戶登入", "loginLabel": "登入", "logoutLabel": "登出" }, "isUpToDate": "@:appName 已更新!", "officialVersion": "版本 {version} (Official build)" }, "workspacePage": { "menuLabel": "工作區", "title": "工作區", "description": "自訂您的工作區外觀,包括主題、字型、文字佈局、日期/時間格式和語言。", "workspaceName": { "title": "工作區名稱" }, "workspaceIcon": { "title": "工作區圖示", "description": "上傳圖片或使用表情符號作為您的工作區圖示。圖示將顯示在側邊欄和通知中。" }, "appearance": { "title": "外觀", "description": "自訂您的工作區外觀,包括主題、字型、文字佈局、日期、時間和語言。", "options": { "system": "自動", "light": "淺色", "dark": "深色" } }, "resetCursorColor": { "title": "重設文件游標顏色", "description": "您確定要重設游標顏色嗎?" }, "resetSelectionColor": { "title": "重設文件選取顏色", "description": "您確定要重設選取顏色嗎?" }, "resetWidth": { "resetSuccess": "文件寬度已成功重設。" }, "theme": { "title": "主題", "description": "選擇預設主題,或上傳您自己的自訂主題。", "uploadCustomThemeTooltip": "上傳自訂主題", "failedToLoadThemes": "載入主題失敗,請檢查您的系統設定中的權限設定:系統設定 > 隱私與安全性 > 檔案和資料夾 > @:appName" }, "workspaceFont": { "title": "工作區字型", "noFontHint": "找不到字型,請嘗試另一個詞彙。" }, "textDirection": { "title": "文字方向", "leftToRight": "從左到右", "rightToLeft": "從右到左", "auto": "自動", "enableRTLItems": "啟用從右到左工具列項目" }, "layoutDirection": { "title": "版面配置方向", "leftToRight": "從左到右", "rightToLeft": "從右到左" }, "dateTime": { "title": "日期與時間", "example": "{} 在 {} ({})", "24HourTime": "24 小時制", "dateFormat": { "label": "日期格式", "local": "本地", "us": "美國", "iso": "ISO", "friendly": "友善", "dmy": "日/月/年" } }, "language": { "title": "語言" }, "deleteWorkspacePrompt": { "title": "刪除工作區", "content": "您確定要刪除這個工作區嗎?此動作無法復原,您發佈的所有頁面都將會取消發佈。" }, "leaveWorkspacePrompt": { "title": "離開工作區", "content": "您確定要離開這個工作區嗎? 您將失去存取其中所有頁面和資料的權限。", "success": "您已成功離開工作區。", "fail": "離開工作區失敗。" }, "manageWorkspace": { "title": "管理工作區", "leaveWorkspace": "離開工作區", "deleteWorkspace": "刪除工作區" } }, "manageDataPage": { "menuLabel": "管理資料", "title": "管理資料", "description": "管理資料本機儲存或將現有資料匯入到 @:appName。", "dataStorage": { "title": "檔案儲存位置", "tooltip": "您的檔案儲存位置", "actions": { "change": "變更路徑", "open": "開啟資料夾", "openTooltip": "開啟目前資料夾位置", "copy": "複製路徑", "copiedHint": "已複製路徑!", "resetTooltip": "重設至預設位置" }, "resetDialog": { "title": "您確定嗎?", "description": "將路徑重設為預設資料位置不會刪除您的資料。如果您想重新匯入目前資料,應該先複製您目前位置的路徑。" } }, "importData": { "title": "匯入資料", "tooltip": "從 @:appName 備份/資料夾匯入資料", "description": "從外部 @:appName 資料夾複製資料", "action": "瀏覽檔案" }, "encryption": { "title": "加密", "tooltip": "管理您的資料儲存和加密方式", "descriptionNoEncryption": "啟用加密將會加密所有資料。這無法逆轉。", "descriptionEncrypted": "您的資料已加密。", "action": "加密資料", "dialog": { "title": "是否為所有資料加密?", "description": "加密您的所有資料將能確保您的資料安全。此動作無法復原。您確定要繼續嗎?" } }, "cache": { "title": "清除快取", "description": "協助解決圖像無法載入、空間中缺少頁面和字型無法載入等問題。這不會影響您的資料。", "dialog": { "title": "清除快取", "description": "協助解決圖像無法載入、空間中缺少頁面和字型無法載入等問題。這不會影響您的資料。", "successHint": "已清除快取!" } }, "data": { "fixYourData": "修復您的資料", "fixButton": "修復", "fixYourDataDescription": "如果您在使用資料時遇到問題,您可以在這裡嘗試修復它。" } }, "shortcutsPage": { "menuLabel": "快捷鍵", "title": "快捷鍵", "editBindingHint": "輸入新的綁定", "searchHint": "搜尋", "actions": { "resetDefault": "重設預設值" }, "errorPage": { "message": "載入快捷鍵失敗: {}", "howToFix": "請再試一次,如果問題持續發生,請透過 GitHub 聯繫我們。" }, "resetDialog": { "title": "重設快捷鍵", "description": "這將會將您的所有快捷鍵重設為預設值,之後無法復原此動作,您確定要繼續嗎?", "buttonLabel": "重設" }, "conflictDialog": { "title": "{} 目前正在使用中", "descriptionPrefix": "這個快捷鍵目前使用中由", "descriptionSuffix": ". 如果您取代此快捷鍵,它將從 {} 中移除。", "confirmLabel": "繼續" }, "editTooltip": "按一下以開始編輯快捷鍵。", "keybindings": { "toggleToDoList": "切換至待辦事項清單", "insertNewParagraphInCodeblock": "插入新段落", "pasteInCodeblock": "貼上程式碼區塊", "selectAllCodeblock": "全選", "indentLineCodeblock": "在行首插入兩個空格", "outdentLineCodeblock": "刪除行首的兩個空格", "twoSpacesCursorCodeblock": "在游標處插入兩個空格", "copy": "複製選取項目", "paste": "貼上內容", "cut": "剪下選取項目", "alignLeft": "將文字左對齊", "alignCenter": "將文字置中對齊", "alignRight": "將文字右對齊", "insertInlineMathEquation": "插入行內數學方程式", "undo": "復原", "redo": "重做", "convertToParagraph": "將區塊轉換為段落", "backspace": "刪除", "deleteLeftWord": "刪除左邊的單字", "deleteLeftSentence": "刪除左邊的句子", "delete": "刪除右邊的字元", "deleteMacOS": "刪除左邊的字元", "deleteRightWord": "刪除右邊的單字", "moveCursorLeft": "將游標向左移動", "moveCursorBeginning": "將游標移至開頭", "moveCursorLeftWord": "將游標向左移動一個單字", "moveCursorLeftSelect": "選取並將游標向左移動", "moveCursorBeginSelect": "選取並將游標移至開頭", "moveCursorLeftWordSelect": "選取並將游標向左移動一個單字", "moveCursorRight": "將游標向右移動", "moveCursorEnd": "將游標移至結尾", "moveCursorRightWord": "將游標向右移動一個單字", "moveCursorRightSelect": "選取並將游標向右移動一個位置", "moveCursorEndSelect": "選取並將游標移至結尾", "moveCursorRightWordSelect": "選取並將游標向右移動一個單字", "moveCursorUp": "向上移動游標", "moveCursorTopSelect": "選取並將游標移至頂端", "moveCursorTop": "將游標移至頂端", "moveCursorUpSelect": "選取並將游標向上移動", "moveCursorBottomSelect": "選取並將游標移到底部", "moveCursorBottom": "將游標移到底部", "moveCursorDown": "將游標向下移動", "moveCursorDownSelect": "選取並將游標向下移動", "home": "捲動至頂部", "end": "捲動至底部", "toggleBold": "切換粗體", "toggleItalic": "切換斜體", "toggleUnderline": "切換底線", "toggleStrikethrough": "切換刪除線", "toggleCode": "切換行內程式碼", "toggleHighlight": "切換標示重點", "showLinkMenu": "顯示連結選單", "openInlineLink": "開啟行內連結", "openLinks": "開啟所有已選取的連結", "indent": "縮排", "outdent": "取消縮排", "exit": "結束編輯模式", "pageUp": "向上滾動一頁", "pageDown": "向下滾動一頁", "selectAll": "全選", "pasteWithoutFormatting": "貼上內容,不含格式", "showEmojiPicker": "顯示表情符號選擇器", "enterInTableCell": "在表格中新增換行符號", "leftInTableCell": "在表格中向左移動一個儲存格", "rightInTableCell": "在表格中向右移動一個儲存格", "upInTableCell": "在表格中向上移動一個儲存格", "downInTableCell": "在表格中向下移動一個儲存格", "tabInTableCell": "前往表格中的下一個可用儲存格", "shiftTabInTableCell": "前往表格中先前可用的儲存格", "backSpaceInTableCell": "在儲存格開頭停止" }, "commands": { "codeBlockNewParagraph": "在程式碼區塊旁邊插入一個新段落", "codeBlockIndentLines": "在程式碼區塊開頭插入兩個空格", "codeBlockOutdentLines": "刪除程式碼區塊開頭的兩個空格", "codeBlockAddTwoSpaces": "在程式碼區塊中光標位置插入兩個空格", "codeBlockSelectAll": "選取程式碼區塊內的所有內容", "codeBlockPasteText": "將文字貼到程式碼區塊中", "textAlignLeft": "將文字左對齊", "textAlignCenter": "將文字置中對齊", "textAlignRight": "將文字右對齊" }, "couldNotLoadErrorMsg": "無法載入快捷鍵,請再次嘗試", "couldNotSaveErrorMsg": "無法儲存快捷鍵,請再次嘗試" }, "aiPage": { "title": "AI 設定", "menuLabel": "AI 設定", "keys": { "enableAISearchTitle": "AI 搜尋", "aiSettingsDescription": "選擇用於支援 AppFlowy AI 的偏好模型。現在包含 GPT-4o、GPT-o3-mini、DeepSeek R1、Claude 3.5 Sonnet,以及在 Ollama 中可用的模型", "loginToEnableAIFeature": "AI 功能僅在您使用 @:appName Cloud 登入後才能啟用。如果您沒有 @:appName 帳戶,請前往「我的帳戶」註冊。", "llmModel": "語言模型", "globalLLMModel": "全域語言模型", "readOnlyField": "此欄位為唯讀設定", "llmModelType": "語言模型類型", "downloadLLMPrompt": "下載 {}", "downloadAppFlowyOfflineAI": "下載 AI 離線套件將啟用裝置上的 AI 執行。您要繼續嗎?", "downloadLLMPromptDetail": "下載 {} 本地模型將佔用高達 {} 的儲存空間。您要繼續嗎?", "downloadBigFilePrompt": "下載完成可能需要大約 10 分鐘的時間", "downloadAIModelButton": "下載", "downloadingModel": "正在下載中", "localAILoaded": "本地 AI 模型已成功新增且可使用", "localAIStart": "本地 AI 正在啟動中。如果速度較慢,請嘗試關閉並重新開啟它", "localAILoading": "本地 AI 對話模型正在載入中...", "localAIStopped": "本地 AI 已停止", "localAIRunning": "本地 AI 正在執行中", "localAINotReadyRetryLater": "本地 AI 正在初始化,請稍後重試", "localAIDisabled": "您正在使用本地 AI,但已停用。請前往設定啟用它或嘗試不同的模型", "localAIInitializing": "本地 AI 正在載入中。這可能需要幾秒鐘的時間,取決於您的裝置", "localAINotReadyTextFieldPrompt": "在本地 AI 載入期間,您無法編輯", "localAIDisabledTextFieldPrompt": "在本地 AI 停用期間,您無法編輯", "failToLoadLocalAI": "啟動本地 AI 失敗。", "restartLocalAI": "重新啟動", "disableLocalAITitle": "停用本地 AI", "disableLocalAIDescription": "您要停用本地 AI 嗎?", "localAIToggleTitle": "AppFlowy 本地 AI (LAI)", "localAIToggleSubTitle": "在 AppFlowy 中執行最先進的本地 AI 模型,以獲得終極隱私和安全性", "offlineAIInstruction1": "跟隨", "offlineAIInstruction2": "指示", "offlineAIInstruction3": "以啟用離線 AI。", "offlineAIDownload1": "如果您尚未下載 AppFlowy AI,請...", "offlineAIDownload2": "下載", "offlineAIDownload3": "先下載它。", "activeOfflineAI": "啟用", "downloadOfflineAI": "下載", "openModelDirectory": "開啟資料夾", "laiNotReady": "本地 AI 應用程式未正確安裝。", "ollamaNotReady": "Ollama 伺服器尚未就緒。", "pleaseFollowThese": "請依照這些...", "instructions": "指示", "installOllamaLai": "來設定 Ollama 和 AppFlowy 本地 AI。", "modelsMissing": "找不到所需的模型:", "downloadModel": "以下載它們。" } }, "planPage": { "menuLabel": "方案", "title": "價格方案", "planUsage": { "title": "方案使用摘要", "storageLabel": "儲存空間", "storageUsage": "{} / {} GB", "unlimitedStorageLabel": "無限儲存空間", "collaboratorsLabel": "成員", "collaboratorsUsage": "{} / {}", "aiResponseLabel": "AI 回應", "aiResponseUsage": "{} / {}", "unlimitedAILabel": "無限回應", "proBadge": "專業版", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "Mac 上的 AI 本機處理", "memberProToggle": "更多成員、無限 AI 和來賓存取權", "aiMaxToggle": "無限的 AI 和存取進階模型", "aiOnDeviceToggle": "本地 AI 確保極致隱私。", "aiCredit": { "title": "新增 @:appName AI 額度", "price": "{}", "priceDescription": "用於 1,000 個額度", "purchase": "購買 AI", "info": "每個工作區新增 1,000 個 AI 積分,並無縫整合可自訂的 AI 功能到您的工作流程中,以更聰明、更快速地獲得結果,最高可達:", "infoItemOne": "每個資料庫 10,000 次回應", "infoItemTwo": "每個工作區 1,000 次回應" }, "currentPlan": { "bannerLabel": "目前方案", "freeTitle": "免費版", "proTitle": "專業版", "teamTitle": "團隊", "freeInfo": "非常適合最多 2 名個人使用,以整理所有內容", "proInfo": "非常適合小型和中型團隊,最多 10 名成員。", "teamInfo": "非常適合所有高效且井然有序的團隊。", "upgrade": "變更計劃", "canceledInfo": "您的方案已取消,您將在 {} 時降級為免費方案。" }, "addons": { "title": "附加組件", "addLabel": "新增", "activeLabel": "已新增", "aiMax": { "title": "AI Max", "description": "每月享有 50 張人工智慧圖片,並由進階人工智慧模型提供無限的 AI 回應。", "price": "{}", "priceInfo": "每位使用者每月,按年計費" }, "aiOnDevice": { "title": "Mac 上的 AI 本機處理", "description": "在您的機器上執行 Mistral 7B、LLAMA 3 和更多本機模型", "price": "{}", "priceInfo": "每位使用者每月,按年計費", "recommend": "建議使用 M1 或更新版本" } }, "deal": { "bannerLabel": "新年優惠!", "title": "擴展您的團隊!", "info": "升級即可享受專業版和團隊方案 10% 的折扣!透過強大的新功能,包括 @:appName AI,提升工作區生產力。", "viewPlans": "查看方案" } } }, "billingPage": { "menuLabel": "帳單", "title": "帳單", "plan": { "title": "方案", "freeLabel": "免費版", "proLabel": "專業版", "planButtonLabel": "變更計劃", "billingPeriod": "計費週期", "periodButtonLabel": "編輯期間" }, "paymentDetails": { "title": "付款資訊", "methodLabel": "付款方式", "methodButtonLabel": "編輯方式" }, "addons": { "title": "附加組件", "addLabel": "新增", "removeLabel": "移除", "renewLabel": "續訂", "aiMax": { "label": "AI Max", "description": "解鎖無限 AI 和進階模型", "activeDescription": "下期帳單到期日為 {}", "canceledDescription": "AI 最大將於 {} 啟用" }, "aiOnDevice": { "label": "Mac 上的 AI 本機處理", "description": "解鎖裝置上的無限 AI", "activeDescription": "下期帳單到期日為 {}", "canceledDescription": "Mac 的 AI 在裝置上可用至 {}" }, "removeDialog": { "title": "移除 {}", "description": "您確定要移除 {plan} 嗎? 您將立即失去 {plan} 的功能和權益。" } }, "currentPeriodBadge": "目前", "changePeriod": "變更週期", "planPeriod": "{} 週期", "monthlyInterval": "每月", "monthlyPriceInfo": "每座位每月計費", "annualInterval": "每年", "annualPriceInfo": "每座位按年計費" }, "comparePlanDialog": { "title": "比較並選擇方案", "planFeatures": "方案\n功能", "current": "目前", "actions": { "upgrade": "升級", "downgrade": "降級", "current": "目前" }, "freePlan": { "title": "免費版", "description": "適用於最多 2 名成員,以整理所有事務", "price": "{}", "priceInfo": "永遠免費" }, "proPlan": { "title": "專業版", "description": "適合小型團隊,用來管理專案與團隊知識", "price": "{}", "priceInfo": "每位用戶每月\n按年計費\n\n{} 按月計費" }, "planLabels": { "itemOne": "工作區", "itemTwo": "成員", "itemThree": "儲存空間", "itemFour": "即時協作", "itemFive": "客戶編輯器", "itemSix": "AI 回應", "itemSeven": "AI 圖像", "itemFileUpload": "檔案上傳", "customNamespace": "自訂命名空間", "tooltipFive": "與非會員共同編輯特定頁面", "tooltipSix": "終身代表回應次數永遠不會重設", "intelligentSearch": "智慧搜尋", "tooltipSeven": "允許您自訂工作區部分的網址", "customNamespaceTooltip": "自訂發佈網站網址" }, "freeLabels": { "itemOne": "每個工作區收取費用", "itemTwo": "最多 2 個", "itemThree": "5 GB", "itemFour": "是的", "itemFive": "是的", "itemSix": "終身 10 次", "itemSeven": "終身 2 次", "itemFileUpload": "最多 7 MB", "intelligentSearch": "智慧搜尋" }, "proLabels": { "itemOne": "每個工作區收取費用", "itemTwo": "最多 10 個", "itemThree": "無限", "itemFour": "是的", "itemFive": "最多 100 個", "itemSix": "無限", "itemSeven": "每月 50 張圖片", "itemFileUpload": "無限", "intelligentSearch": "智慧搜尋" }, "paymentSuccess": { "title": "您現在在{}方案中!", "description": "您的付款已成功處理,您的方案已升級至 @:appName {}。 您可以在方案頁面查看您的方案詳細資訊。" }, "downgradeDialog": { "title": "您確定要降級您的方案嗎?", "description": "降級您的方案將會讓您回歸至免費方案。成員可能會失去存取這個工作區的權限,您可能需要釋放空間以符合免費方案的儲存空間限制。", "downgradeLabel": "降級計劃" } }, "cancelSurveyDialog": { "title": "很遺憾看到你離開", "description": "很遺憾看到您離開。我們非常希望聽到您的回饋,以幫助我們改善 @:appName。請花一點時間回答幾個問題。", "commonOther": "其他", "otherHint": "在這裡寫下你的答案", "questionOne": { "question": "是什麼促使您取消了您的 @:appName Pro 訂閱?", "answerOne": "成本太高", "answerTwo": "功能未達預期", "answerThree": "找到更好的替代方案", "answerFour": "使用量不足以證明費用合理", "answerFive": "服務問題或技術困難" }, "questionTwo": { "question": "您未來考慮重新訂閱 @:appName Pro 的可能性有多大?", "answerOne": "很有可能", "answerTwo": "有可能", "answerThree": "不确定", "answerFour": "不太可能", "answerFive": "不太可能" }, "questionThree": { "question": "您在訂閱期間最重視哪個Pro功能?", "answerOne": "多用戶協作", "answerTwo": "更長的歷史版本記錄時間", "answerThree": "無限的 AI 回應", "answerFour": "存取本地 AI 模型" }, "questionFour": { "question": "您如何描述您對 @:appName 的整體體驗?", "answerOne": "很棒", "answerTwo": "好的", "answerThree": "平均", "answerFour": "低於平均水平", "answerFive": "不滿意" } }, "common": { "uploadingFile": "檔案正在上傳中,請勿關閉應用程式", "uploadNotionSuccess": "您的 Notion 壓縮檔已成功上傳。匯入完成後,您將收到確認電子郵件", "reset": "重設" }, "menu": { "appearance": "外觀", "language": "語言", "user": "使用者", "files": "檔案", "notifications": "通知", "open": "開啟設定", "logout": "登出", "logoutPrompt": "您確定要登出嗎?", "selfEncryptionLogoutPrompt": "您確定要登出嗎? 請確保您已複製加密金鑰", "syncSetting": "同步設定", "cloudSettings": "雲端設定", "enableSync": "啟用同步", "enableSyncLog": "啟用同步記錄", "enableSyncLogWarning": "感謝您協助診斷同步問題。這將會將您的文件編輯記錄到本機檔案中。啟用後請關閉並重新開啟應用程式。", "enableEncrypt": "加密資料", "cloudURL": "基礎網址", "webURL": "網頁網址", "invalidCloudURLScheme": "網址格式無效", "cloudServerType": "雲端伺服器種類", "cloudServerTypeTip": "請注意,切換雲端伺服器後可能會登出您目前的帳號", "cloudLocal": "本地", "cloudAppFlowy": "@:appName Cloud", "cloudAppFlowySelfHost": "@:appName 雲端 自架設", "appFlowyCloudUrlCanNotBeEmpty": "雲端網址不能為空", "clickToCopy": "點選以複製", "selfHostStart": "若您尚未設定伺服器,請參閱", "selfHostContent": "文件", "selfHostEnd": "有關如何自架個人伺服器的指南", "pleaseInputValidURL": "請輸入有效的網址", "changeUrl": "將自架設網址變更為 {}", "cloudURLHint": "請輸入您伺服器的基礎網址", "webURLHint": "輸入您的網路伺服器基本網址", "cloudWSURL": "Websocket 網址", "cloudWSURLHint": "請輸入您伺服器的 Websocket 位址", "restartApp": "重新啟動", "restartAppTip": "重新啟動應用程式以使變更生效。請注意,這可能會登出您目前的帳號", "changeServerTip": "更換伺服器後,必須點選重新啟動按鈕以使變更生效", "enableEncryptPrompt": "啟用加密以保護您的資料。請妥善保管這個金鑰;一旦啟用,將無法關閉。如果遺失,您的資料將無法恢復。點選這裡複製", "inputEncryptPrompt": "請為 {username} 輸入您的加密金鑰", "clickToCopySecret": "點選以複製金鑰", "configServerSetting": "設定您的伺服器選項", "configServerGuide": "在選擇「快速開始」之後,請前往「設定」並選擇「雲端設定」,以進行自架伺服器的設定。", "inputTextFieldHint": "您的金鑰", "historicalUserList": "使用者登入歷史", "historicalUserListTooltip": "此列表顯示您的匿名帳號。您可以點選帳號以檢視其詳細資訊。透過點選「開始使用」按鈕來建立匿名帳號", "openHistoricalUser": "點選以開啟匿名帳號", "customPathPrompt": "將 @:appName 資料資料夾儲存在如 Google 雲端硬碟等雲端同步資料夾中可能會帶來風險。如果該資料庫在多處同時被存取或修改,可能會導致同步衝突和潛在的資料損壞", "importAppFlowyData": "從外部 @:appName 資料夾匯入資料", "importingAppFlowyDataTip": "資料正在匯入中。請勿關閉應用程式", "importAppFlowyDataDescription": "從外部 @:appName 資料夾複製資料並匯入到目前的 @:appName 資料夾", "importSuccess": "成功匯入 @:appName 資料夾", "importFailed": "匯入 @:appName 資料夾失敗", "importGuide": "欲瞭解更多詳細資訊,請查閱參考文件" }, "notifications": { "enableNotifications": { "label": "啟用通知", "hint": "關閉以停止顯示本機通知。" }, "showNotificationsIcon": { "label": "顯示通知圖示", "hint": "關閉以隱藏側邊欄中的通知圖示。" }, "archiveNotifications": { "allSuccess": "已成功存檔所有通知", "success": "已成功存檔通知" }, "markAsReadNotifications": { "allSuccess": "已成功標記全部為已讀", "success": "已成功標記為已讀" }, "action": { "markAsRead": "標記為已讀", "multipleChoice": "選擇更多", "archive": "歸檔" }, "settings": { "settings": "設定", "markAllAsRead": "全部標記為已讀", "archiveAll": "全部存檔" }, "emptyInbox": { "title": "收件匣清零!", "description": "在此處設定提醒以接收通知。" }, "emptyUnread": { "title": "沒有未讀取的通知", "description": "您已完成所有更新!" }, "emptyArchived": { "title": "沒有歸檔", "description": "在此設定提醒,以接收通知。" }, "tabs": { "inbox": "收件匣", "unread": "未讀", "archived": "已歸檔" }, "refreshSuccess": "已成功重新整理通知", "titles": { "notifications": "通知", "reminder": "提醒事項" } }, "appearance": { "resetSetting": "重設", "fontFamily": { "label": "字型", "search": "搜尋", "defaultFont": "系統預設" }, "themeMode": { "label": "主題模式", "light": "淺色模式", "dark": "深色模式", "system": "調整至系統設定。" }, "fontScaleFactor": "字型縮放比例", "displaySize": "顯示大小", "documentSettings": { "cursorColor": "文件游標顏色", "selectionColor": "文件選取顏色", "width": "文件寬度", "changeWidth": "變更", "pickColor": "選擇顏色", "colorShade": "色彩色調", "opacity": "不透明度", "hexEmptyError": "十六進位顏色不能為空", "hexLengthError": "十六進位值必須為 6 位數", "hexInvalidError": "無效的十六進位值", "opacityEmptyError": "不透明度不能為空", "opacityRangeError": "不透明度必須在 1 到 100 之間", "app": "App", "flowy": "Flowy", "apply": "套用" }, "layoutDirection": { "label": "版面配置方向", "hint": "控制螢幕上內容的流向,從左到右或從右到左。", "ltr": "從左到右", "rtl": "從右到左" }, "textDirection": { "label": "預設文字方向", "hint": "預設指定文字應從左邊或右邊開始。", "ltr": "從左到右", "rtl": "從右到左", "auto": "自動", "fallback": "與版面配置方向相同" }, "themeUpload": { "button": "上傳", "uploadTheme": "上傳主題", "description": "使用下方的按鈕上傳您自己的 @:appName 主題。", "loading": "我們正在驗證並上傳您的主題,請稍候...", "uploadSuccess": "您的主題已成功上傳", "deletionFailure": "刪除主題失敗。請嘗試手動刪除。", "filePickerDialogTitle": "選擇 .flowy_plugin 檔案", "urlUploadFailure": "無法開啟網址: {}" }, "theme": "主題", "builtInsLabel": "內建主題", "pluginsLabel": "外掛", "dateFormat": { "label": "日期格式", "local": "本地", "us": "美國", "iso": "ISO", "friendly": "友善", "dmy": "日/月/年" }, "timeFormat": { "label": "時間格式", "twelveHour": "12 小時制", "twentyFourHour": "24 小時制" }, "showNamingDialogWhenCreatingPage": "建立頁面時顯示命名對話框", "enableRTLToolbarItems": "啟用從右到左工具列項目", "members": { "title": "成員設定", "inviteMembers": "邀請成員", "inviteHint": "透過電子郵件邀請", "sendInvite": "邀請", "copyInviteLink": "複製邀請連結", "label": "成員", "user": "使用者", "role": "角色", "removeFromWorkspace": "從工作區中刪除", "removeFromWorkspaceSuccess": "成功從工作區移除。", "removeFromWorkspaceFailed": "從工作區移除失敗", "owner": "擁有者", "guest": "訪客", "member": "成員", "memberHintText": "成員可以閱讀、評論和編輯頁面。邀請其他成員和訪客", "guestHintText": "訪客可以閱讀、做出回應、發表評論,並且可以在獲得許可的情況下編輯頁面", "emailInvalidError": "電子郵件無效,請檢查並再次嘗試", "emailSent": "已發送電子郵件,請查看收件匣", "members": "成員", "membersCount": { "zero": "{} 個成員", "one": "{} 個成員", "other": "{} 個成員" }, "inviteFailedDialogTitle": "升級至專業版方案", "inviteFailedMemberLimit": "此工作區已達到免費限制。請升級到專業版以解鎖更多成員。", "inviteFailedMemberLimitMobile": "您的工作區已達到成員上限。", "memberLimitExceeded": "已達成員上限,若要邀請更多成員,請...", "memberLimitExceededUpgrade": "升級", "memberLimitExceededPro": "已達成員上限,若需要更多成員,請聯繫...", "memberLimitExceededProContact": "support@appflowy.io", "failedToAddMember": "新增成員失敗", "addMemberSuccess": "成員新增成功", "removeMember": "刪除成員", "areYouSureToRemoveMember": "確定要刪除該成員?", "inviteMemberSuccess": "邀請已成功發送", "failedToInviteMember": "邀請成員失敗", "workspaceMembersError": "哎呀,出錯了", "workspaceMembersErrorDescription": "我們目前無法載入成員清單。請稍後再試", "inviteLinkToAddMember": "邀請連結以新增成員", "clickToCopyLink": "點擊複製連結", "or": "或", "generateANewLink": "產生新連結", "inviteMemberByEmail": "透過電子郵件邀請會員", "inviteMemberHintText": "透過電子郵件邀請", "resetInviteLink": "重設邀請連結?", "resetInviteLinkDescription": "重設將會停用目前所有空間成員的連結,並產生新的連結。舊連結將不再有效。", "adminPanel": "管理面板", "reset": "重設", "resetInviteLinkSuccess": "邀請連結已成功重置", "resetInviteLinkFailed": "無法重設邀請連結", "resetInviteLinkFailedDescription": "請稍後再試一次", "memberPageDescription1": "存取", "memberPageDescription2": "用於訪客和進階使用者管理。", "noInviteLink": "您尚未產生邀請連結。", "copyLink": "複製連結", "generatedLinkSuccessfully": "連結已成功產生", "generatedLinkFailed": "連結產生失敗", "resetLinkSuccessfully": "連結已成功重置", "resetLinkFailed": "連結重置失敗" } }, "files": { "copy": "複製", "defaultLocation": "@:appName 資料儲存位置", "exportData": "匯出您的資料", "doubleTapToCopy": "點選兩下以複製路徑", "restoreLocation": "恢復為 @:appName 預設路徑", "customizeLocation": "開啟其他資料夾", "restartApp": "請重新啟動應用程式以使變更生效。", "exportDatabase": "匯出資料庫", "selectFiles": "選擇需要匯出的檔案", "selectAll": "全選", "deselectAll": "取消全選", "createNewFolder": "建立新資料夾", "createNewFolderDesc": "選擇您想儲存資料的位置", "defineWhereYourDataIsStored": "定義您的資料儲存位置", "open": "開啟", "openFolder": "開啟一個已經存在的資料夾", "openFolderDesc": "讀取並寫入到現有的 @:appName 資料夾", "folderHintText": "資料夾名稱", "location": "建立新資料夾", "locationDesc": "為您的 @:appName 資料夾選擇一個名稱", "browser": "瀏覽", "create": "建立", "set": "設定", "folderPath": "儲存資料夾的路徑", "locationCannotBeEmpty": "路徑不能為空", "pathCopiedSnackbar": "儲存檔案的路徑已被複製到剪貼簿!", "changeLocationTooltips": "更改資料目錄", "change": "變更", "openLocationTooltips": "開啟另一個資料目錄", "openCurrentDataFolder": "開啟目前資料目錄", "recoverLocationTooltips": "重設為 @:appName 的預設資料目錄", "exportFileSuccess": "匯出檔案成功!", "exportFileFail": "匯出檔案失敗!", "export": "匯出", "clearCache": "清除快取", "clearCacheDesc": "如果您遇到圖片無法載入或字型顯示不正確的問題,請嘗試清除快取。 此動作不會移除您的使用者資料。", "areYouSureToClearCache": "確定清除快取?", "clearCacheSuccess": "快取清除成功!" }, "user": { "name": "名稱", "email": "電子郵件", "tooltipSelectIcon": "選擇圖示", "selectAnIcon": "選擇圖示", "pleaseInputYourOpenAIKey": "請輸入您的 AI 金鑰", "clickToLogout": "點選以登出目前使用者" }, "mobile": { "personalInfo": "個人資料", "username": "使用者名稱", "usernameEmptyError": "使用者名稱為必填", "about": "關於", "pushNotifications": "推播通知", "support": "支援", "joinDiscord": "加入我們的 Discord", "privacyPolicy": "隱私權政策", "userAgreement": "使用者協議", "termsAndConditions": "條款與條件", "userprofileError": "載入使用者檔案失敗", "userprofileErrorDescription": "請嘗試登出並重新登入以確認問題是否仍然存在。", "selectLayout": "選擇版面配置", "selectStartingDay": "選擇一週的起始日", "version": "版本" } }, "grid": { "deleteView": "您確定要刪除此檢視嗎?", "createView": "建立", "title": { "placeholder": "無標題" }, "settings": { "filter": "篩選", "sort": "排序", "sortBy": "排序方式", "properties": "屬性", "reorderPropertiesTooltip": "拖曳以重新排序屬性", "group": "群組", "addFilter": "新增篩選器", "deleteFilter": "刪除篩選器", "filterBy": "依據篩選", "typeAValue": "輸入一個值……", "layout": "版面配置", "compactMode": "緊湊模式", "databaseLayout": "版面佈局", "viewList": { "zero": "0 次瀏覽", "one": "{count} 次瀏覽", "other": "{count} 次瀏覽" }, "editView": "編輯檢視", "boardSettings": "看板設定", "calendarSettings": "日曆設定", "createView": "建立檢視", "duplicateView": "副本檢視", "deleteView": "刪除檢視", "numberOfVisibleFields": "顯示 {}" }, "filter": { "empty": "沒有任何作用中的篩選器", "addFilter": "新增篩選器", "cannotFindCreatableField": "找不到適合用於篩選的欄位", "conditon": "條件", "where": "在哪裡" }, "textFilter": { "contains": "包含", "doesNotContain": "不包含", "endsWith": "以……結尾", "startWith": "以……開頭", "is": "是", "isNot": "不是", "isEmpty": "為空", "isNotEmpty": "不為空", "choicechipPrefix": { "isNot": "不是", "startWith": "以……開頭", "endWith": "以……結尾", "isEmpty": "為空", "isNotEmpty": "不為空" } }, "checkboxFilter": { "isChecked": "已勾選", "isUnchecked": "未勾選", "choicechipPrefix": { "is": "是" } }, "checklistFilter": { "isComplete": "已完成", "isIncomplted": "未完成" }, "selectOptionFilter": { "is": "是", "isNot": "不是", "contains": "包含", "doesNotContain": "不包含", "isEmpty": "為空", "isNotEmpty": "不為空" }, "dateFilter": { "is": "是", "before": "在...之前", "after": "在...之後", "onOrBefore": "在...之前或當天", "onOrAfter": "在...之後或當天", "between": "在...之間", "empty": "為空", "notEmpty": "不為空", "startDate": "開始日期", "endDate": "結束日期", "choicechipPrefix": { "before": "之前", "after": "之後", "between": "之間", "onOrBefore": "在或之前", "onOrAfter": "在或之後", "isEmpty": "為空", "isNotEmpty": "不為空" } }, "numberFilter": { "equal": "等於", "notEqual": "不等於", "lessThan": "小於", "greaterThan": "大於", "lessThanOrEqualTo": "小於或等於", "greaterThanOrEqualTo": "大於或等於", "isEmpty": "為空", "isNotEmpty": "不為空" }, "field": { "label": "屬性", "hide": "隱藏屬性", "show": "顯示屬性", "insertLeft": "左方插入", "insertRight": "右方插入", "duplicate": "副本", "delete": "刪除", "wrapCellContent": "文字換行", "clear": "清除儲存格", "switchPrimaryFieldTooltip": "無法變更主要欄位的類型", "textFieldName": "文字", "checkboxFieldName": "核取方塊", "dateFieldName": "日期", "updatedAtFieldName": "最後修改時間", "createdAtFieldName": "建立時間", "numberFieldName": "數字", "singleSelectFieldName": "單選", "multiSelectFieldName": "多選", "urlFieldName": "網址", "checklistFieldName": "核取清單", "relationFieldName": "關係", "summaryFieldName": "AI 總結", "timeFieldName": "時間", "mediaFieldName": "檔案和媒體", "translateFieldName": "AI 翻譯", "translateTo": "翻譯為", "numberFormat": "數字格式", "dateFormat": "日期格式", "includeTime": "包含時間", "isRange": "結束日期", "dateFormatFriendly": "月 日, 年", "dateFormatISO": "年-月-日", "dateFormatLocal": "月/日/年", "dateFormatUS": "年/月/日", "dateFormatDayMonthYear": "日/月/年", "timeFormat": "時間格式", "invalidTimeFormat": "格式無效", "timeFormatTwelveHour": "12 小時制", "timeFormatTwentyFourHour": "24 小時制", "clearDate": "清除日期", "dateTime": "日期時間", "startDateTime": "開始日期時間", "endDateTime": "結束日期時間", "failedToLoadDate": "載入日期值失敗", "selectTime": "選擇時間", "selectDate": "選擇日期", "visibility": "可見性", "propertyType": "屬性類型", "addSelectOption": "新增選項", "typeANewOption": "輸入新選項", "optionTitle": "選項", "addOption": "新增選項", "editProperty": "編輯屬性", "newProperty": "新增屬性", "openRowDocument": "作為頁面打開", "deleteFieldPromptMessage": "您確定嗎? 這個屬性將被刪除", "clearFieldPromptMessage": "確定操作,該列中的所有單元格都將被清空", "newColumn": "新增欄位", "format": "格式", "reminderOnDateTooltip": "此欄位設有預定提醒", "optionAlreadyExist": "選項已存在" }, "rowPage": { "newField": "新增欄位", "fieldDragElementTooltip": "點選以開啟選單", "showHiddenFields": { "one": "顯示 {count} 個隱藏欄位", "many": "顯示 {count} 個隱藏欄位", "other": "顯示 {count} 個隱藏欄位" }, "hideHiddenFields": { "one": "隱藏 {count} 個隱藏欄位", "many": "隱藏 {count} 個隱藏欄位", "other": "隱藏 {count} 個隱藏欄位" }, "openAsFullPage": "以整頁形式打開", "viewDatabase": "查看原始資料庫", "moreRowActions": "更多列動作" }, "sort": { "ascending": "升冪", "descending": "降冪", "by": "由", "empty": "沒有任何作用中的排序", "cannotFindCreatableField": "找不到適合用於排序的欄位", "deleteAllSorts": "刪除所有排序", "addSort": "新增排序", "sortsActive": "在排序時無法 {intention}", "removeSorting": "您想移除此檢視中的所有排序,並繼續嗎?", "fieldInUse": "您已經按照此欄位進行排序" }, "row": { "label": "列", "duplicate": "複製", "delete": "刪除", "titlePlaceholder": "無標題", "textPlaceholder": "空白", "copyProperty": "已將屬性複製到剪貼簿", "count": "計數", "newRow": "新增列", "loadMore": "載入更多", "action": "操作", "add": "點選以在下方新增", "drag": "拖曳以移動", "deleteRowPrompt": "您確定要刪除這列嗎?此動作無法復原。", "deleteCardPrompt": "您確定要刪除這張卡片嗎?此動作無法復原。", "dragAndClick": "拖曳以移動,點選以開啟選單", "insertRecordAbove": "在上方插入記錄", "insertRecordBelow": "在下方插入記錄", "noContent": "無內容", "reorderRowDescription": "重新排列列", "createRowAboveDescription": "在上方建立一個列", "createRowBelowDescription": "在下方插入一個列" }, "selectOption": { "create": "建立", "purpleColor": "紫色", "pinkColor": "粉紅色", "lightPinkColor": "淡粉色", "orangeColor": "橘色", "yellowColor": "黃色", "limeColor": "萊姆色", "greenColor": "綠色", "aquaColor": "水藍色", "blueColor": "藍色", "deleteTag": "刪除標籤", "colorPanelTitle": "顏色", "panelTitle": "選擇或建立選項", "searchOption": "搜尋選項", "searchOrCreateOption": "搜尋選項或建立一個", "createNew": "建立新的", "orSelectOne": "或選擇一個選項", "typeANewOption": "輸入新選項", "tagName": "標籤名稱" }, "checklist": { "taskHint": "任務描述", "addNew": "新增任務", "submitNewTask": "建立", "hideComplete": "隱藏已完成任務", "showComplete": "顯示所有任務" }, "url": { "launch": "在瀏覽器中開啟", "copy": "複製連結到剪貼簿", "textFieldHint": "輸入網址" }, "relation": { "relatedDatabasePlaceLabel": "相關資料庫", "relatedDatabasePlaceholder": "沒有任何", "inRelatedDatabase": "在", "rowSearchTextFieldPlaceholder": "搜尋", "noDatabaseSelected": "未選取資料庫,請先從下方清單中選擇一個:", "emptySearchResult": "找不到任何記錄", "linkedRowListLabel": "{} 個關聯資料列", "unlinkedRowListLabel": "連結另一個資料列" }, "menuName": "網格", "referencedGridPrefix": "檢視", "calculate": "計算", "calculationTypeLabel": { "none": "無", "average": "平均", "max": "最大值", "median": "中間值", "min": "最小值", "sum": "總和", "count": "計數", "countEmpty": "計數為空", "countEmptyShort": "空的", "countNonEmpty": "計數不為空", "countNonEmptyShort": "已滿" }, "media": { "rename": "重新命名", "download": "下載", "expand": "展開", "delete": "刪除", "moreFilesHint": "+{}", "addFileOrImage": "新增檔案或連結", "attachmentsHint": "{}", "addFileMobile": "新增檔案", "extraCount": "+{}", "deleteFileDescription": "您確定要刪除此檔案嗎? 此動作不可逆轉。", "showFileNames": "顯示檔案名稱", "downloadSuccess": "檔案已下載", "downloadFailedToken": "下載檔案失敗,使用者權杖無效", "setAsCover": "設為封面", "openInBrowser": "在瀏覽器中開啟", "embedLink": "嵌入檔案連結" } }, "document": { "menuName": "文件", "date": { "timeHintTextInTwelveHour": "下午 1:00", "timeHintTextInTwentyFourHour": "13:00" }, "creating": "正在建立…", "slashMenu": { "board": { "selectABoardToLinkTo": "選擇要連結的看板", "createANewBoard": "建立新的看板" }, "grid": { "selectAGridToLinkTo": "選擇要連結的網格", "createANewGrid": "建立新網格" }, "calendar": { "selectACalendarToLinkTo": "選擇要連結到的日曆", "createANewCalendar": "建立新日曆" }, "document": { "selectADocumentToLinkTo": "選擇要連結的文件" }, "name": { "textStyle": "文字樣式", "list": "清單", "toggle": "切換", "fileAndMedia": "檔案和媒體", "simpleTable": "簡單表格", "visuals": "視覺效果", "document": "文件", "advanced": "進階", "text": "文字", "heading1": "標題 1", "heading2": "標題 2", "heading3": "標題 3", "image": "圖像", "bulletedList": "項目符號清單", "numberedList": "編號清單", "todoList": "待辦事項清單", "doc": "文件", "linkedDoc": "連結到頁面", "grid": "網格", "linkedGrid": "連結網格", "kanban": "看板", "linkedKanban": "連結看板", "calendar": "日曆", "linkedCalendar": "連結日曆", "quote": "引用", "divider": "分隔線", "table": "表格", "callout": "醒目提示框", "outline": "大綱", "mathEquation": "數學方程式", "code": "程式碼", "toggleList": "切換列表", "toggleHeading1": "切換標題 1", "toggleHeading2": "切換標題 2", "toggleHeading3": "切換標題 3", "emoji": "表情符號", "aiWriter": "向 AI 提問", "dateOrReminder": "日期或提醒事項", "photoGallery": "照片圖庫", "file": "檔案", "twoColumns": "2 欄", "threeColumns": "3 欄", "fourColumns": "4 欄" }, "subPage": { "name": "文件", "keyword1": "子頁面", "keyword2": "頁面", "keyword3": "子頁面", "keyword4": "插入頁面", "keyword5": "嵌入頁面", "keyword6": "新頁面", "keyword7": "建立頁面", "keyword8": "文件" } }, "selectionMenu": { "outline": "大綱", "codeBlock": "程式碼區塊" }, "plugins": { "referencedBoard": "已連結的看板", "referencedGrid": "已連結的網格", "referencedCalendar": "已連結的日曆", "referencedDocument": "已連結的文件", "aiWriter": { "userQuestion": "向 AI提問", "continueWriting": "繼續寫作", "fixSpelling": "修正拼字和語法", "improveWriting": "改善寫作品質", "summarize": "總結", "explain": "解釋", "makeShorter": "縮短篇幅", "makeLonger": "擴展內容" }, "autoGeneratorMenuItemName": "AI 作者", "autoGeneratorTitleName": "AI: 請 AI 撰寫任何內容…", "autoGeneratorLearnMore": "瞭解更多", "autoGeneratorGenerate": "生成", "autoGeneratorHintText": "詢問 AI…", "autoGeneratorCantGetOpenAIKey": "無法取得 AI 金鑰", "autoGeneratorRewrite": "重新撰寫", "smartEdit": "詢問 AI", "aI": "AI", "smartEditFixSpelling": "修正拼字和文法", "warning": "⚠️ AI 回應可能不準確或具有誤導性。", "smartEditSummarize": "總結", "smartEditImproveWriting": "改善寫作", "smartEditMakeLonger": "使其更長", "smartEditCouldNotFetchResult": "無法取得 AI 的結果", "smartEditCouldNotFetchKey": "無法取得 AI 金鑰", "smartEditDisabled": "在設定連結 AI ", "appflowyAIEditDisabled": "登入以啟用 AI 功能", "discardResponse": "您確定要捨棄 AI 回應嗎?", "createInlineMathEquation": "建立公式", "fonts": "字型", "insertDate": "插入日期", "emoji": "表情符號", "toggleList": "切換列表", "emptyToggleHeading": " 空的切換器 h{}。點擊以新增內容。", "emptyToggleList": "空的切換器清單。點擊以新增內容。", "emptyToggleHeadingWeb": "空的切換器 h{level}。點擊以新增內容", "quoteList": "引述列表", "numberedList": "編號列表", "bulletedList": "項目符號列表", "todoList": "待辦事項列表", "callout": "引述", "simpleTable": { "moreActions": { "color": "顏色", "align": "對齊", "delete": "刪除", "duplicate": "副本", "insertLeft": "插入左側", "insertRight": "插入右側", "insertAbove": "插入上方", "insertBelow": "在下面插入", "headerColumn": "標題欄位", "headerRow": "標題列", "clearContents": "清除內容", "setToPageWidth": "設定為頁面寬度", "distributeColumnsWidth": "平均分配欄位", "duplicateRow": "副本列", "duplicateColumn": "副本欄位", "textColor": "文字顏色", "cellBackgroundColor": "儲存格背景顏色", "duplicateTable": "副本表格" }, "clickToAddNewRow": "點擊以新增一列", "clickToAddNewColumn": "點擊以新增一個欄位", "clickToAddNewRowAndColumn": "點擊新增列和欄", "headerName": { "table": "表格", "alignText": "對齊文字" } }, "cover": { "changeCover": "更換封面", "colors": "顏色", "images": "圖片", "clearAll": "全部清除", "abstract": "摘要", "addCover": "新增封面", "addLocalImage": "新增本地圖片", "invalidImageUrl": "無效的圖片網址", "failedToAddImageToGallery": "無法新增圖片到相簿", "enterImageUrl": "輸入圖片網址", "add": "新增", "back": "返回", "saveToGallery": "儲存到相簿", "removeIcon": "移除圖示", "removeCover": "移除封面", "pasteImageUrl": "貼上圖片網址", "or": "或", "pickFromFiles": "從檔案中挑選", "couldNotFetchImage": "無法抓取圖片", "imageSavingFailed": "圖片儲存失敗", "addIcon": "新增圖示", "changeIcon": "更換圖示", "coverRemoveAlert": "刪除後將從封面中刪除。", "alertDialogConfirmation": "你確定你要繼續嗎?" }, "mathEquation": { "name": "數學方程式", "addMathEquation": "新增 TeX 方程式", "editMathEquation": "編輯數學方程式" }, "optionAction": { "click": "點選", "toOpenMenu": " 開啟選單", "drag": "拖曳", "toMove": "移動", "delete": "刪除", "duplicate": "複製", "turnInto": "變成", "moveUp": "上移", "moveDown": "下移", "color": "顏色", "align": "對齊", "left": "左", "center": "中", "right": "右", "defaultColor": "預設值", "depth": "深度", "copyLinkToBlock": "Copy link to block複製連結到區塊" }, "image": { "addAnImage": "新增圖片", "copiedToPasteBoard": "圖片連結已複製到剪貼簿", "addAnImageDesktop": "新增圖片", "addAnImageMobile": "點擊新增一張或多張圖片", "dropImageToInsert": "將圖片拖曳至此插入", "imageUploadFailed": "圖片上傳失敗", "imageDownloadFailed": "圖片下載失敗,請再試一次", "imageDownloadFailedToken": "圖片下載因缺少用戶憑證而失敗,請再試一次\n", "errorCode": "錯誤代碼" }, "photoGallery": { "name": "照片庫", "imageKeyword": "圖像", "imageGalleryKeyword": "圖片庫", "photoKeyword": "照片", "photoBrowserKeyword": "照片瀏覽器", "galleryKeyword": "圖庫", "addImageTooltip": "新增圖片", "changeLayoutTooltip": "變更版面", "browserLayout": "瀏覽器", "gridLayout": "網格", "deleteBlockTooltip": "刪除整個圖庫" }, "math": { "copiedToPasteBoard": "數學方程式已複製到剪貼簿" }, "urlPreview": { "copiedToPasteBoard": "連結已複製到剪貼簿", "convertToLink": "轉換為嵌入鏈接" }, "outline": { "addHeadingToCreateOutline": "新增標題以建立目錄。", "noMatchHeadings": "未找到匹配的標題" }, "table": { "addAfter": "在後方新增", "addBefore": "在前方新增", "delete": "刪除", "clear": "清除內容", "duplicate": "複製", "bgColor": "背景顏色" }, "contextMenu": { "copy": "複製", "cut": "剪下", "paste": "貼上", "pasteAsPlainText": "以純文字貼上" }, "action": "操作", "database": { "selectDataSource": "選擇資料來源", "noDataSource": "無資料來源", "selectADataSource": "選擇一個資料來源", "toContinue": "以繼續", "newDatabase": "新建資料庫", "linkToDatabase": "連結至資料庫" }, "date": "日期", "video": { "label": "影片", "emptyLabel": "新增影片", "placeholder": "貼上影片連結", "copiedToPasteBoard": "影片連結已複製到剪貼簿", "insertVideo": "新增影片", "invalidVideoUrl": "來源網址目前不受支援。", "invalidVideoUrlYouTube": "目前不支援 YouTube。", "supportedFormats": "支援格式: MP4、WebM、MOV、AVI、FLV、MPEG/M4V、H.264" }, "file": { "name": "檔案", "uploadTab": "上傳", "uploadMobile": "選擇一個檔案", "uploadMobileGallery": "來自照片庫", "networkTab": "嵌入連結", "placeholderText": "上傳或嵌入檔案", "placeholderDragging": "將檔案拖曳以上傳", "dropFileToUpload": "拖放檔案進行上傳", "fileUploadHint": "拖放檔案或點擊以", "fileUploadHintSuffix": "瀏覽", "networkHint": "貼上檔案連結", "networkUrlInvalid": "網址無效。請檢查網址並再次嘗試。", "networkAction": "嵌入", "fileTooBigError": "檔案容量太大,請上傳容量低於 10MB 的檔案。", "renameFile": { "title": "重新命名檔案", "description": "輸入此檔案的新名稱", "nameEmptyError": "檔案名稱不能為空。" }, "uploadedAt": "已上傳至 {}", "linkedAt": "連結已添加於 {}", "failedToOpenMsg": "開啟失敗,找不到檔案。" }, "subPage": { "handlingPasteHint": "- (處理貼上)", "errors": { "failedDeletePage": "刪除頁面失敗", "failedCreatePage": "建立頁面失敗", "failedMovePage": "將頁面移至此文件失敗", "failedDuplicatePage": "複製頁面失敗", "failedDuplicateFindView": "複製頁面失敗 - 找不到原始視圖" } }, "cannotMoveToItsChildren": "無法移動到其子代", "linkPreview": { "typeSelection": { "pasteAs": "貼上為", "mention": "提及", "URL": "網址", "bookmark": "書籤", "embed": "嵌入" }, "linkPreviewMenu": { "toMetion": "轉換為提及", "toUrl": "轉換為網址", "toEmbed": "轉換為嵌入", "toBookmark": "轉換為書籤", "copyLink": "複製連結", "replace": "代替", "reload": "重新載入", "removeLink": "移除連結", "pasteHint": "貼上 https://...", "unableToDisplay": "無法顯示" } } }, "outlineBlock": { "placeholder": "目錄" }, "textBlock": { "placeholder": "輸入“/”作為命令" }, "title": { "placeholder": "無標題" }, "imageBlock": { "placeholder": "點選以新增圖片", "upload": { "label": "上傳", "placeholder": "點選以上傳圖片" }, "url": { "label": "圖片網址", "placeholder": "輸入圖片網址" }, "ai": { "label": "由 AI 生成圖片", "placeholder": "請輸入提示讓 AI 生成圖片" }, "stability_ai": { "label": "由 Stability AI 生成圖片", "placeholder": "請輸入提示讓 Stability AI 生成圖片" }, "support": "圖片大小限制為 5MB。支援的格式:JPEG、PNG、GIF、SVG", "error": { "invalidImage": "無效的圖片", "invalidImageSize": "圖片大小必須小於 5MB", "invalidImageFormat": "不支援的圖片格式。支援的格式:JPEG、PNG、GIF、SVG", "invalidImageUrl": "無效的圖片網址", "noImage": "沒有該檔案或目錄", "multipleImagesFailed": "部分圖片上傳失敗,請再試一次" }, "embedLink": { "label": "嵌入連結", "placeholder": "貼上或輸入圖片連結" }, "unsplash": { "label": "Unsplash" }, "searchForAnImage": "搜尋圖片", "pleaseInputYourOpenAIKey": "請在設定頁面輸入您的 AI 金鑰", "saveImageToGallery": "儲存圖片", "failedToAddImageToGallery": "無法將圖片新增到相簿", "successToAddImageToGallery": "圖片已成功新增到相簿", "unableToLoadImage": "無法載入圖片", "maximumImageSize": "支援的最大上傳圖片大小為 10MB", "uploadImageErrorImageSizeTooBig": "圖片大小必須小於 10MB", "imageIsUploading": "圖片上傳中", "openFullScreen": "全螢幕打開", "interactiveViewer": { "toolbar": { "previousImageTooltip": "上一張圖片", "nextImageTooltip": "下一張圖片", "zoomOutTooltip": "縮小", "zoomInTooltip": "放大", "changeZoomLevelTooltip": "更改縮放級別", "openLocalImage": "打開圖片", "downloadImage": "下載圖片", "closeViewer": "關閉互動式檢視器", "scalePercentage": "{}%", "deleteImageTooltip": "刪除圖片" } } }, "codeBlock": { "language": { "label": "語言", "placeholder": "選擇語言", "auto": "自動" }, "copyTooltip": "複製", "searchLanguageHint": "搜尋語言", "codeCopiedSnackbar": "程式碼已複製到剪貼簿" }, "inlineLink": { "placeholder": "貼上或輸入連結", "openInNewTab": "在新分頁中開啟", "copyLink": "複製連結", "removeLink": "移除連結", "url": { "label": "連結網址", "placeholder": "輸入連結網址" }, "title": { "label": "連結標題", "placeholder": "輸入連結標題" } }, "mention": { "placeholder": "提及一個人、頁面或日期...", "page": { "label": "連結到頁面", "tooltip": "點選以開啟頁面" }, "deleted": "已刪除", "deletedContent": "該內容不存在或已經刪除", "noAccess": "無權限", "deletedPage": "已刪除頁面", "trashHint": " - 在垃圾桶裡", "morePages": "更多頁面" }, "toolbar": { "resetToDefaultFont": "重設為預設字型", "textSize": "文字大小", "textColor": "文字顏色", "h1": "標題 1", "h2": "標題 2", "h3": "標題 3", "alignLeft": "左對齊", "alignRight": "右對齊", "alignCenter": "居中對齊", "link": "關聯", "textAlign": "文字對齊", "moreOptions": "更多選項", "font": "字型", "inlineCode": "內聯程式碼", "suggestions": "建議事項", "turnInto": "變成", "equation": "方程式", "insert": "插入", "linkInputHint": "貼上連結或搜尋頁面", "pageOrURL": "頁面或網址", "linkName": "連結名稱", "linkNameHint": "輸入連結名稱" }, "errorBlock": { "theBlockIsNotSupported": "目前版本不支援此區塊。", "clickToCopyTheBlockContent": "點擊以複製區塊內容", "blockContentHasBeenCopied": "區塊內容已被複製。", "parseError": "解析 {} 區塊時發生錯誤。", "copyBlockContent": "複製區塊內容" }, "mobilePageSelector": { "title": "選擇頁面", "failedToLoad": "載入頁面清單失敗", "noPagesFound": "沒有找到該頁面" }, "attachmentMenu": { "choosePhoto": "選擇照片", "takePicture": "拍照", "chooseFile": "選擇檔案" } }, "board": { "column": { "label": "欄位", "createNewCard": "新的", "renameGroupTooltip": "點選以重新命名群組", "createNewColumn": "新增群組", "addToColumnTopTooltip": "在頂部新增卡片", "addToColumnBottomTooltip": "在底部新增卡片", "renameColumn": "重新命名", "hideColumn": "隱藏", "newGroup": "新群組", "deleteColumn": "刪除", "deleteColumnConfirmation": "這將刪除此群組及其中所有卡片。您確定要繼續嗎?" }, "hiddenGroupSection": { "sectionTitle": "隱藏的群組", "collapseTooltip": "隱藏已隱藏的群組", "expandTooltip": "檢視隱藏的群組" }, "cardDetail": "卡片詳細資訊", "cardActions": "卡片操作", "cardDuplicated": "卡片已複製", "cardDeleted": "卡片已刪除", "showOnCard": "在卡片詳細資訊中顯示", "setting": "設定", "propertyName": "屬性名稱", "menuName": "看板", "showUngrouped": "顯示未分組的項目", "ungroupedButtonText": "未分組", "ungroupedButtonTooltip": "包含不屬於任何群組的卡片", "ungroupedItemsTitle": "點選以新增到看板", "groupBy": "依據分組", "groupCondition": "群組條件", "referencedBoardPrefix": "檢視...", "notesTooltip": "內部備註", "mobile": { "editURL": "編輯網址", "showGroup": "顯示群組", "showGroupContent": "您確定要在看板上顯示此群組嗎?", "failedToLoad": "無法載入看板" }, "dateCondition": { "weekOf": "第 {} 週 - {}", "today": "今天", "yesterday": "昨天", "tomorrow": "明天", "lastSevenDays": "過去 7 天", "nextSevenDays": "未來 7天", "lastThirtyDays": "過去 30 天", "nextThirtyDays": "未來 30 天" }, "noGroup": "沒有按屬性分組", "noGroupDesc": "看板視圖需要一個用於分組的屬性才能顯示。", "media": { "cardText": "{} {}", "fallbackName": "檔案" } }, "calendar": { "menuName": "日曆", "defaultNewCalendarTitle": "無標題", "newEventButtonTooltip": "新增活動", "navigation": { "today": "今天", "jumpToday": "跳到今天", "previousMonth": "上個月", "nextMonth": "下個月", "views": { "day": "天", "week": "星期", "month": "月", "year": "年" } }, "mobileEventScreen": { "emptyTitle": "目前尚目前沒有任何活動", "emptyBody": "點選加號按鈕以在此日新增事件。" }, "settings": { "showWeekNumbers": "顯示週數", "showWeekends": "顯示週末", "firstDayOfWeek": "週開始於", "layoutDateField": "排列方式", "changeLayoutDateField": "更改排列欄位", "noDateTitle": "未註明日無日期", "noDateHint": { "zero": "未排程的事件將顯示在這裡", "one": "有 {count} 個未排程的事件", "other": "有 {count} 個未排程的事件" }, "unscheduledEventsTitle": "未排定的活動", "clickToAdd": "點選以新增至日曆", "name": "日曆設定", "clickToOpen": "點擊以開啟記錄" }, "referencedCalendarPrefix": "檢視", "quickJumpYear": "跳到", "duplicateEvent": "重複事件" }, "errorDialog": { "title": "@:appName 錯誤", "howToFixFallback": "對於給您帶來的不便,我們深表歉意!在我們的 GitHub 頁面上提交描述您的錯誤的問題。", "howToFixFallbackHint1": "對於造成的不便,我們深感抱歉!在我們的...上提交問題。", "howToFixFallbackHint2": " 頁面描述您的錯誤。", "github": "在 GitHub 上檢視" }, "search": { "label": "搜尋", "sidebarSearchIcon": "搜尋並快速跳至頁面", "searchOrAskAI": "搜尋或詢問 AI", "searchFieldHint": "在{}中搜尋或提問...", "askAIAnything": "向 AI 提問任何問題", "askAIFor": "詢問 AI", "searching": "正在搜尋…", "noResultForSearching": "沒有找到符合的結果", "noResultForSearchingHint": "嘗試不同的問題或關鍵字。\n 部分頁面可能在垃圾桶中。", "noResultForSearchingHintWithoutTrash": "嘗試不同的問題或關鍵字。\n 部分頁面可能在", "bestMatch": "最佳匹配", "seeMore": "查看更多", "showMore": "顯示更多", "somethingWentWrong": "出了點問題", "pageNotExist": "這個頁面不存在", "tryAgainOrLater": "請稍後再試一次", "placeholder": { "actions": "搜尋操作..." } }, "message": { "copy": { "success": "已複製已複製到剪貼簿", "fail": "無法複製" } }, "unSupportBlock": "目前版本不支援此區塊。", "views": { "deleteContentTitle": "您確定要刪除 {pageType} 嗎?", "deleteContentCaption": "如果您刪除此 {pageType},您可以從垃圾桶中將其復原。" }, "colors": { "custom": "自訂", "default": "預設值", "red": "紅色", "orange": "橙色", "yellow": "黃色", "green": "綠色", "blue": "藍色", "purple": "紫色", "pink": "粉紅色", "brown": "棕色", "gray": "灰色" }, "emoji": { "emojiTab": "表情符號", "search": "搜尋表情符號", "noRecent": "無最近使用的表情符號", "noEmojiFound": "找不到表情符號", "filter": "篩選", "random": "隨機", "selectSkinTone": "選擇膚色", "remove": "移除表情符號", "categories": { "smileys": "笑臉與情緒", "people": "人物", "animals": "自然", "food": "食物", "activities": "活動", "places": "地點", "objects": "物件", "symbols": "符號", "flags": "旗幟", "nature": "自然", "frequentlyUsed": "常用" }, "skinTone": { "default": "預設值", "light": "淺色", "mediumLight": "中淺色", "medium": "中色", "mediumDark": "中深色", "dark": "深色" }, "openSourceIconsFrom": "從來源取得圖示" }, "inlineActions": { "noResults": "無結果", "recentPages": "最近的頁面", "pageReference": "頁面參照", "docReference": "文件參照", "boardReference": "看板參照", "calReference": "日曆參照", "gridReference": "網格參照", "date": "日期", "reminder": { "groupTitle": "提醒", "shortKeyword": "提醒" }, "createPage": "建立\"{}\"子頁面" }, "datePicker": { "dateTimeFormatTooltip": "在設定中更改日期和時間格式", "dateFormat": "日期格式", "includeTime": "包含時間", "isRange": "結束日期", "timeFormat": "時間格式", "clearDate": "清除日期", "reminderLabel": "提醒", "selectReminder": "選擇提醒", "reminderOptions": { "none": "無", "atTimeOfEvent": "活動時間", "fiveMinsBefore": "提前 5 分鐘", "tenMinsBefore": "提前 10 分鐘", "fifteenMinsBefore": "提前 15 分鐘", "thirtyMinsBefore": "提前 30 分鐘", "oneHourBefore": "提前 1 小時", "twoHoursBefore": "提前 2 小時", "onDayOfEvent": "活動當天", "oneDayBefore": "提前 1 天", "twoDaysBefore": "提前 2 天", "oneWeekBefore": "一週前", "custom": "自訂" } }, "relativeDates": { "yesterday": "昨天", "today": "今天", "tomorrow": "明天", "oneWeek": "1 週" }, "notificationHub": { "title": "通知", "closeNotification": "關閉通知", "viewNotifications": "查看通知", "noNotifications": "目前沒有任何通知", "mentionedYou": "已提及您", "archivedTooltip": "將此通知歸檔", "unarchiveTooltip": "取消將此通知歸檔", "markAsReadTooltip": "此通知標記為已讀取", "markAsArchivedSucceedToast": "已成功歸檔", "markAllAsArchivedSucceedToast": "已成功歸檔全部項目", "markAsReadSucceedToast": "已成功標記為已讀取", "markAllAsReadSucceedToast": "已成功將所有項目標記為已讀取", "today": "今天", "older": "較舊", "mobile": { "title": "更新" }, "emptyTitle": "全部處理完畢!", "emptyBody": "沒有待處理的通知或動作。享受這份寧靜。", "tabs": { "inbox": "收件匣", "upcoming": "即將到來" }, "actions": { "markAllRead": "全部標為已讀", "showAll": "全部", "showUnreads": "未讀" }, "filters": { "ascending": "升冪", "descending": "降冪", "groupByDate": "依日期分組", "showUnreadsOnly": "只顯示未讀", "resetToDefault": "重設為預設值" } }, "reminderNotification": { "title": "提醒", "message": "記得在忘記之前檢查一下!", "tooltipDelete": "刪除", "tooltipMarkRead": "標為已讀", "tooltipMarkUnread": "標為未讀" }, "findAndReplace": { "find": "尋找", "previousMatch": "上一個符合", "nextMatch": "下一個符合", "close": "關閉", "replace": "取代", "replaceAll": "全部取代", "noResult": "無結果", "caseSensitive": "區分大小寫", "searchMore": "搜尋以查找更多結果" }, "error": { "weAreSorry": "我們很抱歉", "loadingViewError": "我們在載入此檢視時遇到問題。請檢查您的網路連線,重新整理應用程式,並且如果問題持續,請隨時聯絡我們的團隊。", "syncError": "資料尚未從其他裝置同步", "syncErrorHint": "請在上次編輯的設備上重新開啟此頁面,然後在目前的設備上再次開啟它。", "clickToCopy": "點擊以複製錯誤碼" }, "editor": { "bold": "粗體", "bulletedList": "項目符號清單", "bulletedListShortForm": "項目符號", "checkbox": "核取方塊", "embedCode": "嵌入代碼", "heading1": "標題 1", "heading2": "標題 2", "heading3": "標題 3", "highlight": "醒目提示", "color": "顏色", "image": "圖片", "date": "日期", "page": "頁面", "italic": "斜體", "link": "連結", "numberedList": "編號清單", "numberedListShortForm": "編號", "toggleHeading1ShortForm": "切換標題 1", "toggleHeading2ShortForm": "切換標題 2", "toggleHeading3ShortForm": "切換標題 3", "quote": "引述", "strikethrough": "刪除線", "text": "文字", "underline": "底線", "fontColorDefault": "預設值", "fontColorGray": "灰色", "fontColorBrown": "棕色", "fontColorOrange": "橙色", "fontColorYellow": "黃色", "fontColorGreen": "綠色", "fontColorBlue": "藍色", "fontColorPurple": "紫色", "fontColorPink": "粉紅色", "fontColorRed": "紅色", "backgroundColorDefault": "預設值背景", "backgroundColorGray": "灰色背景", "backgroundColorBrown": "棕色背景", "backgroundColorOrange": "橙色背景", "backgroundColorYellow": "黃色背景", "backgroundColorGreen": "綠色背景", "backgroundColorBlue": "藍色背景", "backgroundColorPurple": "紫色背景", "backgroundColorPink": "粉紅色背景", "backgroundColorRed": "紅色背景", "backgroundColorLime": "青色背景", "backgroundColorAqua": "水藍色背景", "done": "完成", "cancel": "取消", "tint1": "色調 1", "tint2": "色調 2", "tint3": "色調 3", "tint4": "色調 4", "tint5": "色調 5", "tint6": "色調 6", "tint7": "色調 7", "tint8": "色調 8", "tint9": "色調 9", "lightLightTint1": "紫色", "lightLightTint2": "粉紅色", "lightLightTint3": "淺粉紅色", "lightLightTint4": "橙色", "lightLightTint5": "黃色", "lightLightTint6": "萊姆色", "lightLightTint7": "綠色", "lightLightTint8": "水綠色", "lightLightTint9": "藍色", "urlHint": "網址", "mobileHeading1": "標題 1", "mobileHeading2": "標題 2", "mobileHeading3": "標題 3", "mobileHeading4": "標題 4", "mobileHeading5": "標題 5", "mobileHeading6": "標題 6", "textColor": "文字顏色", "backgroundColor": "背景色", "addYourLink": "新增你的連結", "openLink": "開啟連結", "copyLink": "複製連結", "removeLink": "移除連結", "editLink": "編輯連結", "convertTo": "轉換為", "linkText": "文字", "linkTextHint": "請輸入文字", "linkAddressHint": "請輸入網址", "highlightColor": "醒目提示色", "clearHighlightColor": "清除醒目提示色", "customColor": "自訂顏色", "hexValue": "十六進位值", "opacity": "不透明度", "resetToDefaultColor": "重設為預設值顏色", "ltr": "由左至右", "rtl": "由右至左", "auto": "自動", "cut": "剪下", "copy": "複製", "paste": "貼上", "find": "尋找", "select": "選擇", "selectAll": "全選", "previousMatch": "上一個符合", "nextMatch": "下一個符合", "closeFind": "關閉", "replace": "取代", "replaceAll": "全部取代", "regex": "正則表達式", "caseSensitive": "區分大小寫", "uploadImage": "上傳圖片", "urlImage": "網址圖片", "incorrectLink": "連結錯誤", "upload": "上傳", "chooseImage": "選擇一張圖片", "loading": "載入中", "imageLoadFailed": "無法載入圖片", "divider": "分隔線", "table": "表格", "colAddBefore": "新增前", "rowAddBefore": "新增前", "colAddAfter": "新增後", "rowAddAfter": "在後新增後", "colRemove": "移除", "rowRemove": "移除", "colDuplicate": "副本", "rowDuplicate": "複製", "colClear": "清除內容", "rowClear": "清除內容", "slashPlaceHolder": "輸入 '/' 以插入區塊,或開始輸入", "typeSomething": "輸入一些東西...", "toggleListShortForm": "切換", "quoteListShortForm": "引述", "mathEquationShortForm": "公式", "codeBlockShortForm": "程式碼" }, "favorite": { "noFavorite": "無最愛頁面", "noFavoriteHintText": "向左滑動頁面以將其加入你的最愛", "removeFromSidebar": "從側邊欄移除", "addToSidebar": "釘選到側邊欄" }, "cardDetails": { "notesPlaceholder": "輸入 / 以插入區塊,或開始輸入" }, "blockPlaceholders": { "todoList": "待辦事項", "bulletList": "清單", "numberList": "清單", "quote": "引述", "heading": "標題 {}" }, "titleBar": { "pageIcon": "頁面圖示", "language": "語言", "font": "字型", "actions": "動作", "date": "日期", "addField": "新增欄位", "userIcon": "使用者圖示" }, "noLogFiles": "沒有日誌檔案", "newSettings": { "myAccount": { "title": "帳戶與應用程式", "subtitle": "自訂您的個人資料、管理帳戶安全性、Open AI 金鑰或登入您的帳戶", "profileLabel": "帳戶名稱和個人資料圖片", "profileNamePlaceholder": "輸入你的名字", "accountSecurity": "帳戶安全性", "2FA": "兩步驟驗證", "aiKeys": "AI 金鑰", "accountLogin": "帳戶登入", "updateNameError": "名稱更新失敗", "updateIconError": "個人頭像更新失敗", "aboutAppFlowy": "關於 @:appName", "deleteAccount": { "title": "刪除帳號", "subtitle": "永久刪除您的帳戶和所有資料", "description": "永久刪除您的帳戶並從所有工作區移除存取權。", "deleteMyAccount": "刪除我的帳戶", "dialogTitle": "刪除帳戶", "dialogContent1": "確定要永久刪除您的帳戶", "dialogContent2": "此操作無法撤銷,此操作將刪除所有團隊空間的存取權限,刪除您的整個帳戶(包括私人工作區),並將您從所有共用工作區中刪除", "confirmHint1": "請輸入「@:newSettings.myAccount.deleteAccount.confirmHint3」以確認。", "confirmHint2": "我了解此動作不可逆轉,且將永久刪除我的帳戶及所有相關資料。", "confirmHint3": "刪除我的帳戶", "checkToConfirmError": "您必須勾選方塊以確認刪除。", "failedToGetCurrentUser": "取得目前使用者電子郵件失敗", "confirmTextValidationFailed": "您的確認文字不符合\n\"@:newSettings.myAccount.deleteAccount.confirmHint3\"", "deleteAccountSuccess": "帳戶已成功刪除" }, "password": { "title": "密碼", "confirmPassword": "確認密碼", "changePassword": "變更密碼", "currentPassword": "目前密碼", "newPassword": "新密碼", "confirmNewPassword": "確認新密碼", "setupPassword": "設定密碼", "error": { "currentPasswordIsRequired": "需要目前的密碼", "newPasswordIsRequired": "需要新密碼", "confirmPasswordIsRequired": "確認需要輸入密碼", "passwordsDoNotMatch": "密碼不相符", "newPasswordIsSameAsCurrent": "新密碼與目前密碼相同", "currentPasswordIsIncorrect": "目前的密碼錯誤", "passwordShouldBeAtLeast6Characters": "密碼至少應有 {min} 個字元", "passwordCannotBeLongerThan72Characters": "密碼不能長於 {max} 個字元" }, "toast": { "passwordUpdatedSuccessfully": "密碼已成功更新", "passwordUpdatedFailed": "密碼更新失敗", "passwordSetupSuccessfully": "密碼設定成功", "passwordSetupFailed": "密碼設定失敗" }, "hint": { "enterYourPassword": "輸入您的密碼", "confirmYourPassword": "確認您的密碼", "enterYourCurrentPassword": "輸入您的目前密碼", "enterYourNewPassword": "輸入您的新密碼", "confirmYourNewPassword": "確認您的新密碼" } }, "myAccount": "我的帳戶", "myProfile": "我的個人資料" }, "workplace": { "name": "工作區", "title": "工作區設定", "subtitle": "自訂您的工作區外觀、主題、字體、文字佈局、日期、時間和語言", "workplaceName": "工作區名稱", "workplaceNamePlaceholder": "輸入工作區名稱", "workplaceIcon": "工作區圖標", "workplaceIconSubtitle": "為您的工作區上傳圖像或表情符號。圖示將顯示在您的側邊欄和通知中", "renameError": "工作區重新命名失敗", "updateIconError": "更新圖像失敗", "chooseAnIcon": "選擇一個圖示", "appearance": { "name": "外觀", "themeMode": { "auto": "自動", "light": "淺色", "dark": "深色" }, "language": "語言" } }, "syncState": { "syncing": "同步中", "synced": "已同步", "noNetworkConnected": "沒有連線網絡" } }, "pageStyle": { "title": "頁面樣式", "layout": "版面配置", "coverImage": "封面圖片", "pageIcon": "頁面圖片", "colors": "顏色", "gradient": "漸變", "backgroundImage": "背景圖片", "presets": "預設", "photo": "圖片", "unsplash": "Unsplash", "pageCover": "頁面封面", "none": "無", "openSettings": "打開設定", "photoPermissionTitle": "@:appName 希望存取您的圖片庫", "photoPermissionDescription": "允許存取圖片庫以上傳圖片", "cameraPermissionTitle": "@:appName 想要存取您的攝影機", "cameraPermissionDescription": "@:appName 需要存取您的相機,才能讓您從相機新增圖片到您的文件。", "doNotAllow": "不允許", "image": "圖像" }, "commandPalette": { "placeholder": "搜尋或提出問題…", "bestMatches": "最佳匹配", "aiOverview": "AI 概覽", "aiOverviewSource": "參考來源", "aiOverviewMoreDetails": "更多細節", "aiAskFollowUp": "詢問後續問題", "pagePreview": "內容預覽", "clickToOpenPage": "點擊以開啟頁面", "recentHistory": "最近歷史", "navigateHint": "用於導航", "loadingTooltip": "我們正在尋找結果...", "betaLabel": "BETA", "betaTooltip": "目前我們只支援搜尋頁面", "fromTrashHint": "從垃圾桶", "noResultsHint": "我們找不到您要搜尋的內容,請嘗試搜尋另一個詞語。", "clearSearchTooltip": "清除輸入", "location": "位置", "created": "已建立", "edited": "已編輯" }, "space": { "delete": "刪除", "deleteConfirmation": "刪除:", "deleteConfirmationDescription": "這個空間中的所有頁面都將被刪除並移至垃圾桶,任何已發布的頁面都會取消發布。", "rename": "重新命名空間", "changeIcon": "變更圖示", "manage": "管理空間", "addNewSpace": "創建空間", "collapseAllSubPages": "折疊所有子頁面", "createNewSpace": "創建新空間", "createSpaceDescription": "建立多個公開與私人空間,以更好地整理您的工作。", "spaceName": "空間名稱", "spaceNamePlaceholder": "例如行銷、工程、人力資源", "permission": "空間許可", "publicPermission": "民眾", "publicPermissionDescription": "所有具有完全存取權的工作區成員", "privatePermission": "私人", "privatePermissionDescription": "只有您可以訪問此空間", "spaceIconBackground": "背景色", "spaceIcon": "圖示", "dangerZone": "危險區域", "unableToDeleteLastSpace": "無法刪除最後一個空間", "unableToDeleteSpaceNotCreatedByYou": "無法刪除他人建立的工作區", "enableSpacesForYourWorkspace": "啟用工作區的 Spaces", "title": "空間", "defaultSpaceName": "一般", "upgradeSpaceTitle": "啟用空間", "upgradeSpaceDescription": "建立多個公開和私人空間,以更好地組織您的工作區。", "upgrade": "更新", "upgradeYourSpace": "創建多個空間", "quicklySwitch": "快速切換到下一個空間", "duplicate": "副本空間", "movePageToSpace": "將頁面移動到空間", "cannotMovePageToDatabase": "無法將頁面移至資料庫", "switchSpace": "切換空間", "spaceNameCannotBeEmpty": "空間名稱不能為空", "success": { "deleteSpace": "空間刪除成功", "renameSpace": "已成功重新命名空間", "duplicateSpace": "已成功複製空間", "updateSpace": "已成功更新空間" }, "error": { "deleteSpace": "刪除空間失敗", "renameSpace": "重新命名空間失敗", "duplicateSpace": "複製空間失敗", "updateSpace": "更新空間失敗" }, "createSpace": "創造空間", "manageSpace": "管理空間", "renameSpace": "重新命名空間", "mSpaceIconColor": "空間圖示顏色", "mSpaceIcon": "空間圖示" }, "publish": { "hasNotBeenPublished": "此頁面尚未發布。", "spaceHasNotBeenPublished": "目前尚不支援發布工作區", "reportPage": "報告頁面", "databaseHasNotBeenPublished": "目前尚不支援發布資料庫。", "createdWith": "創建於", "downloadApp": "下載 AppFlowy", "copy": { "codeBlock": "程式碼區塊的內容已複製到剪貼簿", "imageBlock": "圖片連結已複製到剪貼簿", "mathBlock": "數學方程式已複製到剪貼簿", "fileBlock": "檔案連結已複製到剪貼簿" }, "containsPublishedPage": "此頁面包含一個或多個已發佈的頁面。如果您繼續,它們將會被取消發布。您要繼續刪除嗎?", "publishSuccessfully": "發布成功", "unpublishSuccessfully": "取消發布成功", "publishFailed": "發布失敗", "unpublishFailed": "取消發布失敗", "noAccessToVisit": "無法存取此頁面...", "createWithAppFlowy": "使用 AppFlowy 建立網站", "fastWithAI": "使用 AI 快速且輕鬆。", "tryItNow": "立即嘗試", "onlyGridViewCanBePublished": "僅限網格檢視可發布", "database": { "zero": "發布所選取的 {} 檢視", "one": "發布所選取的 {} 多個檢視", "many": "發布所選取的 {} 多個檢視", "other": "發布所選取的 {} 多個檢視" }, "mustSelectPrimaryDatabase": "必須選擇主要檢視", "noDatabaseSelected": "未選取資料庫,請至少選取一個資料庫。", "unableToDeselectPrimaryDatabase": "無法取消選擇主要資料庫", "saveThisPage": "使用此範本開始", "duplicateTitle": "您想將其新增到哪裡?", "selectWorkspace": "選擇工作區", "addTo": "添加", "duplicateSuccessfully": "已新增至您的工作區", "duplicateSuccessfullyDescription": "尚未安裝AppFlowy?點擊「下載」後,下載將自動開始。", "downloadIt": "下載", "openApp": "在應用程式中打開", "duplicateFailed": "副本失敗", "membersCount": { "zero": "沒有成員", "one": "1名成員", "many": "{count} 名成員", "other": "{count} 名成員" }, "useThisTemplate": "使用範本" }, "web": { "continue": "繼續", "or": "或", "continueWithGoogle": "繼續使用 Google", "continueWithGithub": "繼續使用 GitHub", "continueWithDiscord": "繼續使用 Discord", "continueWithApple": "繼續使用 Apple ", "moreOptions": "更多選項", "collapse": "摺疊", "signInAgreement": "點擊上方「繼續」,即表示您同意 AppFlowy 的…", "signInLocalAgreement": "點擊上方「開始」即表示您同意 AppFlowy 的…", "and": "和", "termOfUse": "條款", "privacyPolicy": "隱私權政策", "signInError": "登入錯誤", "login": "註冊或登入", "fileBlock": { "uploadedAt": "已上傳於 {time}", "linkedAt": "連結已添加於 {time}", "empty": "上傳或嵌入檔案", "uploadFailed": "上傳失敗,請重試", "retry": "重試" }, "importNotion": "從 Notion 匯入", "import": "匯入", "importSuccess": "上傳成功", "importSuccessMessage": "匯入完成時,我們會通知您。之後,您可以在側邊欄中查看您的匯入頁面。", "importFailed": "匯入失敗,請檢查檔案格式。", "dropNotionFile": "在此處拖放您的Notion zip 檔案以上傳,或點擊瀏覽。", "error": { "pageNameIsEmpty": "頁面名稱為空,請嘗試另一個。" } }, "globalComment": { "comments": "評論", "addComment": "新增評論", "reactedBy": "反應由", "addReaction": "新增反應", "reactedByMore": "和 {count} 其他人", "showSeconds": { "one": "1 秒前", "other": "{count} 秒前", "zero": "剛剛", "many": "{count} 秒前" }, "showMinutes": { "one": "1 分鐘前", "other": "{count} 分鐘前", "many": "{count} 分鐘前" }, "showHours": { "one": "1 小時前", "other": "{count} 小時前", "many": "{count} 小時前" }, "showDays": { "one": "1 天前", "other": "{count} 天前", "many": "{count} 天前" }, "showMonths": { "one": "1 個月前", "other": "{count} 個月前", "many": "{count} 個月前" }, "showYears": { "one": "1 年前", "other": "{count} 年前", "many": "{count} 年前" }, "reply": "回覆", "deleteComment": "刪除評論", "youAreNotOwner": "您不是此評論的所有者。", "confirmDeleteDescription": "您確定要刪除這則評論嗎?", "hasBeenDeleted": "已刪除", "replyingTo": "回復給", "noAccessDeleteComment": "您沒有權限刪除此評論。", "collapse": "摺疊", "readMore": "閱讀更多", "failedToAddComment": "新增評論失敗", "commentAddedSuccessfully": "評論已成功新增。", "commentAddedSuccessTip": "您剛才新增或回覆了一則評論。是否要跳至頂端,以查看最新的評論?" }, "template": { "asTemplate": "另存為範本", "name": "範本名稱", "description": "範本說明", "about": "關於範本", "deleteFromTemplate": "從範本中刪除", "preview": "範本預覽", "categories": "範本類別", "isNewTemplate": "釘選到新範本", "featured": "釘選到精選區", "relatedTemplates": "相關範本", "requiredField": "{field} 必填", "addCategory": "新增 “{category}”", "addNewCategory": "新增類別", "addNewCreator": "新增創作者", "deleteCategory": "刪除類別", "editCategory": "編輯類別", "editCreator": "編輯創建者", "category": { "name": "類別名稱", "icon": "類別圖示", "bgColor": "類別背景顏色", "priority": "類別優先級", "desc": "類別描述", "type": "類別類型", "icons": "類別圖示", "colors": "類別顏色", "byUseCase": "依使用案例", "byFeature": "按功能", "deleteCategory": "刪除類別", "deleteCategoryDescription": "您確定要刪除此類別嗎?", "typeToSearch": "在搜尋類別時輸入..." }, "creator": { "label": "範本創作者", "name": "創作者姓名", "avatar": "創作者頭像", "accountLinks": "創作者帳戶連結", "uploadAvatar": "點擊上傳頭像", "deleteCreator": "刪除創建者", "deleteCreatorDescription": "您確定要刪除此創作者嗎?", "typeToSearch": "輸入以搜尋創作者..." }, "uploadSuccess": "範本已成功上傳", "uploadSuccessDescription": "您的範本已成功上傳。您現在可以在範本庫中檢視它。", "viewTemplate": "查看範本", "deleteTemplate": "刪除範本", "deleteSuccess": "範本刪除成功", "deleteTemplateDescription": "這不會影響目前頁面或發布狀態。您確定要刪除此範本嗎?", "addRelatedTemplate": "新增相關範本", "removeRelatedTemplate": "刪除相關範本", "uploadAvatar": "上傳頭像", "searchInCategory": "在 {category} 中搜尋", "label": "範本" }, "fileDropzone": { "dropFile": "點擊或拖曳檔案到此區域以上傳", "uploading": "正在上傳...", "uploadFailed": "上傳失敗", "uploadSuccess": "上傳成功", "uploadSuccessDescription": "檔案已成功上傳", "uploadFailedDescription": "檔案上傳失敗", "uploadingDescription": "檔案正在上傳" }, "gallery": { "preview": "全螢幕打開", "copy": "複製", "download": "下載", "prev": "上一個", "next": "下一個", "resetZoom": "重設縮放", "zoomIn": "放大", "zoomOut": "縮小" }, "invitation": { "join": "加入", "on": "在", "invitedBy": "受邀者", "membersCount": { "zero": "{count} 名成員", "one": "{count} 名成員", "many": "{count} 名成員", "other": "{count} 名成員" }, "tip": "您已收到邀請,使用下方聯絡資訊加入這個工作區。如果資訊有誤,請聯繫您的管理員重新發送邀請。", "joinWorkspace": "加入工作區", "success": "您已成功加入工作區", "successMessage": "現在您可以存取其中的所有頁面和工作區。", "openWorkspace": "開啟 AppFlowy", "alreadyAccepted": "您已經接受邀請", "errorModal": { "title": "發生錯誤", "description": "您目前的帳戶 {email} 可能沒有權限存取這個工作區。請使用正確的帳戶登入,或聯絡工作區擁有者尋求協助。", "contactOwner": "聯絡擁有者", "close": "返回首頁", "changeAccount": "更改帳戶" } }, "requestAccess": { "title": "沒有此頁面的存取權", "subtitle": "您可以向此頁面的擁有者請求存取權。獲得批准後,您就可以查看該頁面。", "requestAccess": "要求存取", "backToHome": "返回首頁", "tip": "您目前以 身分登入。", "mightBe": "您可能需要使用不同的帳戶 ", "successful": "請求已成功發送", "successfulMessage": "當擁有者批准您的請求時,您會收到通知。", "requestError": "請求存取權失敗", "repeatRequestError": "您已經要求存取此頁面" }, "approveAccess": { "title": "批准工作區加入請求", "requestSummary": " 請求加入 並存取 ", "upgrade": "升級", "downloadApp": "下載 AppFlowy", "approveButton": "核准", "approveSuccess": "已成功批准", "approveError": "批准失敗,請確認工作區方案限制是否已達上限。", "getRequestInfoError": "取得請求資訊失敗", "memberCount": { "zero": "沒有成員", "one": "1 名成員", "many": "{count} 名成員", "other": "{count} 名成員" }, "alreadyProTitle": "您已達到工作區方案限制", "alreadyProMessage": "請他們聯繫 > 以解鎖更多成員。", "repeatApproveError": "您已經批准此請求", "ensurePlanLimit": "請確認工作區方案限制是否已達上限。如果已達上限,請考慮 工作區方案或 。", "requestToJoin": "已請求加入", "asMember": "作為會員" }, "upgradePlanModal": { "title": "升級到專業版", "message": "{name} 已達到免費成員上限。升級至專業版以邀請更多成員。", "upgradeSteps": "如何在 AppFlowy 上升級您的方案:", "step1": "1. 前往設定", "step2": "2. 按一下「方案」", "step3": "3. 選擇「變更方案」", "appNote": "注意:", "actionButton": "升級", "downloadLink": "下載應用程式", "laterButton": "之後", "refreshNote": "成功升級後,請點擊 以啟用您的新功能。", "refresh": "這裡" }, "breadcrumbs": { "label": "麵包屑導航" }, "time": { "justNow": "剛剛", "seconds": { "one": "1 秒", "other": "{count} 秒" }, "minutes": { "one": "1 分鐘", "other": "{count} 分鐘" }, "hours": { "one": "1 小時", "other": "{count} 小時" }, "days": { "one": "1 天", "other": "{count} 天" }, "weeks": { "one": "1 週", "other": "{count} 週" }, "months": { "one": "1 個月", "other": "{count} 個月" }, "years": { "one": "1 年", "other": "{count} 年" }, "ago": "前", "yesterday": "昨天", "today": "今天" }, "members": { "zero": "沒有成員", "one": "1 名成員", "many": "{count} 名成員", "other": "{count} 名成員" }, "tabMenu": { "close": "關閉", "closeDisabledHint": "無法關閉釘選的分頁,請先取消釘選", "closeOthers": "關閉其他分頁", "closeOthersHint": "這將關閉所有未釘選的分頁,除了這個", "closeOthersDisabledHint": "所有分頁都已釘選,找不到要關閉的分頁", "favorite": "最愛", "unfavorite": "取消最愛", "favoriteDisabledHint": "無法將此檢視設為最愛", "pinTab": "釘選", "unpinTab": "取消釘選" }, "openFileMessage": { "success": "檔案已成功開啟", "fileNotFound": "未找到檔案", "noAppToOpenFile": "沒有應用程式可以開啟這個檔案", "permissionDenied": "沒有權限開啟此檔案", "unknownError": "檔案開啟失敗" }, "inviteMember": { "requestInviteMembers": "邀請加入您的工作區", "inviteFailedMemberLimit": "成員限制已達到,請...", "upgrade": "升級", "addEmail": "email@example.com, email2@example.com...", "requestInvites": "發送邀請", "inviteAlready": "您已經邀請了這個電子郵件: {email}", "inviteSuccess": "邀請已成功發送", "description": "在下方輸入電子郵件地址,以逗號分隔。費用將根據成員數量計算。", "emails": "電子郵件" }, "quickNote": { "label": "快速筆記", "quickNotes": "快速筆記", "search": "搜尋快速筆記", "collapseFullView": "摺疊全螢幕檢視", "expandFullView": "展開全螢幕檢視", "createFailed": "建立快速筆記失敗", "quickNotesEmpty": "沒有快速筆記", "emptyNote": "空筆記", "deleteNotePrompt": "所選筆記將會永久刪除。您確定要刪除嗎?", "addNote": "新筆記", "noAdditionalText": "沒有額外文字" }, "subscribe": { "upgradePlanTitle": "比較並選擇計劃", "yearly": "每年", "save": "節省 {discount}%。", "monthly": "每月", "priceIn": "價格", "free": "免費", "pro": "專業版", "freeDescription": "適合最多 2 名成員,用來整理所有事務。", "proDescription": "適合小型團隊,用來管理專案與團隊知識", "proDuration": { "monthly": "每位成員每月\n按月計費", "yearly": "每位成員每月\n年度計費" }, "cancel": "降級", "changePlan": "升級到專業計劃", "everythingInFree": "所有內容免費+", "currentPlan": "目前", "freeDuration": "永遠", "freePoints": { "first": "1 個協作工作區,最多 2 名成員", "second": "無限頁面和區塊", "three": "5GB 儲存空間", "four": "智慧搜尋", "five": "20 次 AI 回應", "six": "行動應用程式", "seven": "即時協作" }, "proPoints": { "first": "無限儲存空間", "second": "最多 10 個工作區成員", "three": "無限的 AI 回應", "four": "無限檔案上傳", "five": "自定義命名空間" }, "cancelPlan": { "title": "很遺憾看到您離開", "success": "您的訂閱已成功取消", "description": "很遺憾看到您離開。我們非常希望聽到您的回饋,以幫助我們改善 AppFlowy。請花一點時間回答幾個問題。", "commonOther": "其他", "otherHint": "在此處輸入您的答案", "questionOne": { "question": "是什麼促使你取消你的 AppFlowy Pro 訂閱?", "answerOne": "成本太高", "answerTwo": "功能未達預期", "answerThree": "找到更好的替代方案", "answerFour": "使用量不足以證明費用合理", "answerFive": "服務問題或技術困難" }, "questionTwo": { "question": "未來您幾何可能考慮重新訂閱?", "answerOne": "非常有可能", "answerTwo": "相當有可能", "answerThree": "不確定", "answerFour": "不太可能", "answerFive": "非常不可能" }, "questionThree": { "question": "您在訂閱期間最重視哪個專業版功能?", "answerOne": "多人協作", "answerTwo": "更長的歷史版本時間", "answerThree": "無限的 AI 回應", "answerFour": "存取本地 AI 模型" }, "questionFour": { "question": "您如何描述您使用 AppFlowy 的整體體驗?", "answerOne": "很棒", "answerTwo": "好的", "answerThree": "平均", "answerFour": "低於平均水平", "answerFive": "不滿意" } } }, "ai": { "contentPolicyViolation": "圖片生成因敏感內容而失敗。請重新措辭您的輸入並再次嘗試。", "textLimitReachedDescription": "您的工作區已用完免費的 AI 回應。升級至專業版或購買 AI 附加組件以解鎖無限回應", "imageLimitReachedDescription": "您已用完免費的 AI 圖片配額。請升級至專業版或購買 AI 附加組件以解鎖無限回應。", "limitReachedAction": { "textDescription": "您的工作區已用完免費的 AI 回應。要獲得更多回應,請…", "imageDescription": "您已用完免費的 AI 圖片配額。請…", "upgrade": "升級", "toThe": "到", "proPlan": "專業方案", "orPurchaseAn": "或購買一個", "aiAddon": "AI 插件" }, "editing": "編輯中", "analyzing": "分析中", "continueWritingEmptyDocumentTitle": "繼續寫作錯誤", "continueWritingEmptyDocumentDescription": "我們在擴展文件中內容時遇到困難。請寫一個簡短的介紹,然後我們可以從那裡開始!", "more": "更多", "customPrompt": { "browsePrompts": "瀏覽提示", "usePrompt": "使用提示", "featured": "精選", "custom": "自訂", "customPrompt": "自訂提示", "databasePrompts": "從您自己的資料庫載入提示", "selectDatabase": "選擇資料庫", "promptDatabase": "提示資料庫", "configureDatabase": "配置資料庫", "title": "標題", "content": "內容", "example": "範例", "category": "類別", "selectField": "選擇欄位", "loading": "載入中", "invalidDatabase": "無效資料庫", "invalidDatabaseHelp": "請確保資料庫至少具有兩個文字屬性:\n ◦ 一個用於提示名稱 ◦ 一個用於提示內容\n您也可以選擇性地新增屬性以用於提示範例和類別。", "noResults": "找不到任何提示", "all": "全部", "development": "開發", "writing": "寫作", "healthAndFitness": "健康與健身", "business": "商業", "marketing": "行銷", "travel": "旅行", "others": "其他", "prompt": "提示", "promptExample": "提示範例", "sampleOutput": "範例輸出", "contentSeo": "內容/搜尋引擎優化", "emailMarketing": "電子郵件行銷", "paidAds": "付費廣告", "prCommunication": "公關/傳播", "recruiting": "招募", "sales": "銷售量", "socialMedia": "社群媒體", "strategy": "策略", "caseStudies": "案例研究", "salesCopy": "銷售副本", "education": "教育", "work": "工作", "podcastProduction": "播客製作", "copyWriting": "文案撰寫", "customerSuccess": "客戶成功" } }, "autoUpdate": { "criticalUpdateTitle": "需要更新才能繼續", "criticalUpdateDescription": "我們已進行改進以提升您的體驗!請從 {currentVersion} 更新至 {newVersion} 以繼續使用應用程式。", "criticalUpdateButton": "更新", "bannerUpdateTitle": "新版本發布!", "bannerUpdateDescription": "取得最新功能和修復。點擊「更新」即可立即安裝", "bannerUpdateButton": "更新", "settingsUpdateTitle": "版本 ({newVersion}) 可用!", "settingsUpdateDescription": "目前版本: {currentVersion} (正式發布版)→ {newVersion}", "settingsUpdateButton": "更新", "settingsUpdateWhatsNew": "最新消息" }, "lockPage": { "lockPage": "已鎖定", "reLockPage": "重新鎖定", "lockTooltip": "頁面鎖定以防止意外編輯。點擊以解鎖。", "pageLockedToast": "頁面已鎖定。除非有人將其解鎖,否則無法編輯。", "lockedOperationTooltip": "頁面已鎖定以防止意外編輯。" }, "suggestion": { "accept": "接受", "keep": "保留", "discard": "丟棄", "close": "關閉", "tryAgain": "再試一次", "rewrite": "重新撰寫", "insertBelow": "在下面插入" }, "shareTab": { "accessLevel": { "view": "檢視", "comment": "評論", "edit": "編輯", "fullAccess": "完全存取權限" }, "inviteByEmail": "透過電子郵件邀請", "invite": "邀請", "anyoneAtWorkspace": "{workspace} 中的任何人", "anyoneInGroupWithLinkCanEdit": "擁有連結的群組中的任何人都可以編輯", "copyLink": "複製連結", "copiedLinkToClipboard": "已將連結複製到剪貼簿", "removeAccess": "移除存取權", "turnIntoMember": "轉為會員", "you": "(你)", "guest": "客人", "onlyFullAccessCanInvite": "只有擁有完整存取權的使用者才能邀請其他人", "invitationSent": "邀請已發送", "emailAlreadyInList": "這封電子郵件已經在清單中", "upgradeToProToInviteGuests": "請升級到專業方案以邀請更多訪客", "maxGuestsReached": "您已達到最大訪客人數", "removedGuestSuccessfully": "已成功移除訪客", "updatedAccessLevelSuccessfully": "已成功更新存取層級", "turnedIntoMemberSuccessfully": "已成功轉為會員", "peopleAboveCanAccessWithLink": "以上人士可以使用連結存取", "cantMakeChanges": "無法進行變更", "canMakeAnyChanges": "可以進行任何變更", "generalAccess": "一般存取權", "peopleWithAccess": "擁有存取權的人員", "peopleAboveCanAccessWithTheLink": "擁有連結的人士可以存取", "upgrade": "升級", "toProPlanToInviteGuests": "要邀請訪客到此頁面,需要升級至專業版", "upgradeToInviteGuest": { "title": { "owner": "升級為邀請嘉賓", "member": "升級為邀請嘉賓", "guest": "升級為邀請嘉賓" }, "description": { "owner": "您的工作區目前使用的是免費方案。升級至專業版,以允許外部使用者作為訪客存取此頁面。", "member": "部分受邀者不在您的工作區內。若要將他們邀請為訪客,請聯繫您的工作區擁有者以升級至專業版。", "guest": "部分受邀者不在您的工作區內。若要將他們邀請為訪客,請聯繫您的工作區擁有者以升級至專業版。" } } }, "shareSection": { "shared": "與我分享" } } ================================================ FILE: frontend/rust-lib/.gitignore ================================================ # Generated by Cargo # will have compiled files and executables /target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html # These are backup files generated by rustfmt **/*.rs.bk **/**/*.log* **/**/temp bin/ **/src/protobuf **/resources/proto .idea/ AppFlowy-Collab/ .env .env.** **/unit_test** jniLibs/ **/pkg/ ================================================ FILE: frontend/rust-lib/.vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "AF-desktop: Debug Rust", "type": "lldb", // "request": "attach", // "pid": "${command:pickMyProcess}" // To launch the application directly, use the following configuration: "request": "launch", "program": "/Users/lucas.xu/Desktop/appflowy_backup/frontend/appflowy_flutter/build/macos/Build/Products/Debug/AppFlowy.app", }, ] } ================================================ FILE: frontend/rust-lib/Cargo.toml ================================================ [workspace] members = [ "lib-dispatch", "lib-log", "flowy-core", "dart-ffi", "flowy-user", "flowy-user-pub", "event-integration-test", "flowy-sqlite", "flowy-folder", "flowy-folder-pub", "flowy-notification", "flowy-document", "flowy-document-pub", "flowy-error", "flowy-database2", "flowy-database-pub", "flowy-server", "flowy-server-pub", "flowy-storage", "collab-integrate", "flowy-date", "flowy-search", "lib-infra", "build-tool/flowy-ast", "build-tool/flowy-codegen", "build-tool/flowy-derive", "flowy-search-pub", "flowy-ai", "flowy-ai-pub", "flowy-storage-pub", "flowy-sqlite-vec", ] resolver = "2" [workspace.dependencies] lib-dispatch = { path = "lib-dispatch" } lib-log = { path = "lib-log" } lib-infra = { path = "lib-infra" } flowy-ast = { path = "build-tool/flowy-ast" } flowy-codegen = { path = "build-tool/flowy-codegen" } flowy-derive = { path = "build-tool/flowy-derive" } flowy-core = { path = "flowy-core" } dart-ffi = { path = "dart-ffi" } flowy-user = { path = "flowy-user" } flowy-user-pub = { path = "flowy-user-pub" } flowy-sqlite = { path = "flowy-sqlite" } flowy-folder = { path = "flowy-folder" } flowy-folder-pub = { path = "flowy-folder-pub" } flowy-notification = { path = "flowy-notification" } flowy-document = { path = "flowy-document" } flowy-document-pub = { path = "flowy-document-pub" } flowy-error = { path = "flowy-error" } flowy-database2 = { path = "flowy-database2" } flowy-database-pub = { path = "flowy-database-pub" } flowy-server = { path = "flowy-server" } flowy-server-pub = { path = "flowy-server-pub" } flowy-storage = { path = "flowy-storage" } flowy-storage-pub = { path = "flowy-storage-pub" } flowy-search = { path = "flowy-search" } flowy-search-pub = { path = "flowy-search-pub" } collab-integrate = { path = "collab-integrate" } flowy-date = { path = "flowy-date" } flowy-ai = { path = "flowy-ai" } flowy-ai-pub = { path = "flowy-ai-pub" } anyhow = "1.0" arc-swap = "1.7" tracing = "0.1.40" bytes = "1.5.0" serde_json = "1.0.108" serde = "1.0.194" protobuf = { version = "2.28.0" } diesel = { version = "2.1.0", features = [ "sqlite", "chrono", "r2d2", "serde_json", ] } diesel_derives = { version = "2.1.0", features = ["sqlite", "r2d2"] } uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] } serde_repr = "0.1" futures = "0.3.31" tokio = "1.38.0" tokio-stream = "0.1.17" ollama-rs = { version = "0.3.0" } async-trait = "0.1.81" chrono = { version = "0.4.31", default-features = false, features = ["clock"] } collab = { version = "0.2" } collab-entity = { version = "0.2" } collab-folder = { version = "0.2" } collab-document = { version = "0.2" } collab-database = { version = "0.2" } collab-plugins = { version = "0.2" } collab-user = { version = "0.2" } collab-importer = { version = "0.1" } yrs = "0.21.0" validator = { version = "0.18", features = ["derive"] } tokio-util = "0.7.11" zip = "2.2.0" dashmap = "6.0.1" derive_builder = "0.20.2" tantivy = { version = "0.24.1" } num_enum = "0.7.3" flowy-sqlite-vec = { path = "flowy-sqlite-vec" } # Please using the following command to update the revision id # Current directory: frontend # Run the script.add_workspace_members: # scripts/tool/update_client_api_rev.sh new_rev_id # ⚠️⚠️⚠️️ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "592f644" } client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "592f644" } workspace-template = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "592f644" } [profile.dev] opt-level = 0 lto = false codegen-units = 16 debug = true [profile.release] lto = true opt-level = 3 codegen-units = 1 [profile.profiling] inherits = "release" debug = true codegen-units = 16 lto = false #strip = "debuginfo" incremental = true [patch.crates-io] # We're using a specific commit here because rust-rocksdb doesn't publish the latest version that includes the memory alignment fix. # For more details, see https://github.com/rust-rocksdb/rust-rocksdb/pull/868 rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120e4549e04ba3baa6a1ee5a5a801fa45a72" } # Please use the following script to update collab. # Working directory: frontend # # To update the commit ID, run: # scripts/tool/update_collab_rev.sh new_rev_id # # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4dfccef" } collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4dfccef" } collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4dfccef" } collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4dfccef" } collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4dfccef" } collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4dfccef" } collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4dfccef" } collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4dfccef" } langchain-rust = { version = "4.6.0", git = "https://github.com/appflowy/langchain-rust", branch = "af" } ================================================ FILE: frontend/rust-lib/build-tool/flowy-ast/Cargo.toml ================================================ [package] name = "flowy-ast" version = "0.1.0" edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] syn = { version = "1.0.109", features = ["extra-traits", "parsing", "derive", "full"]} quote = "1.0" proc-macro2 = "1.0" ================================================ FILE: frontend/rust-lib/build-tool/flowy-ast/src/ast.rs ================================================ #![allow(clippy::all)] #![allow(unused_attributes)] #![allow(unused_assignments)] use crate::event_attrs::EventEnumAttrs; use crate::node_attrs::NodeStructAttrs; use crate::{ is_recognizable_field, ty_ext::*, ASTResult, PBAttrsContainer, PBStructAttrs, NODE_TYPE, }; use proc_macro2::Ident; use syn::Meta::NameValue; use syn::{self, punctuated::Punctuated}; pub struct ASTContainer<'a> { /// The struct or enum name (without generics). pub ident: syn::Ident, pub node_type: Option, /// Attributes on the structure. pub pb_attrs: PBAttrsContainer, /// The contents of the struct or enum. pub data: ASTData<'a>, } impl<'a> ASTContainer<'a> { pub fn from_ast(ast_result: &ASTResult, ast: &'a syn::DeriveInput) -> Option> { let attrs = PBAttrsContainer::from_ast(ast_result, ast); // syn::DeriveInput // 1. syn::DataUnion // 2. syn::DataStruct // 3. syn::DataEnum let data = match &ast.data { syn::Data::Struct(data) => { // https://docs.rs/syn/1.0.48/syn/struct.DataStruct.html let (style, fields) = struct_from_ast(ast_result, &data.fields); ASTData::Struct(style, fields) }, syn::Data::Union(_) => { ast_result.error_spanned_by(ast, "Does not support derive for unions"); return None; }, syn::Data::Enum(data) => { // https://docs.rs/syn/1.0.48/syn/struct.DataEnum.html ASTData::Enum(enum_from_ast( ast_result, &ast.ident, &data.variants, &ast.attrs, )) }, }; let ident = ast.ident.clone(); let node_type = get_node_type(ast_result, &ident, &ast.attrs); let item = ASTContainer { ident, pb_attrs: attrs, node_type, data, }; Some(item) } } pub enum ASTData<'a> { Struct(ASTStyle, Vec>), Enum(Vec>), } impl<'a> ASTData<'a> { pub fn all_fields(&'a self) -> Box> + 'a> { match self { ASTData::Enum(variants) => { Box::new(variants.iter().flat_map(|variant| variant.fields.iter())) }, ASTData::Struct(_, fields) => Box::new(fields.iter()), } } pub fn all_variants(&'a self) -> Box + 'a> { match self { ASTData::Enum(variants) => { let iter = variants.iter().map(|variant| &variant.attrs); Box::new(iter) }, ASTData::Struct(_, fields) => { let iter = fields.iter().flat_map(|_| None); Box::new(iter) }, } } pub fn all_idents(&'a self) -> Box + 'a> { match self { ASTData::Enum(variants) => Box::new(variants.iter().map(|v| &v.ident)), ASTData::Struct(_, fields) => { let iter = fields.iter().flat_map(|f| match &f.member { syn::Member::Named(ident) => Some(ident), _ => None, }); Box::new(iter) }, } } } /// A variant of an enum. pub struct ASTEnumVariant<'a> { pub ident: syn::Ident, pub attrs: EventEnumAttrs, pub style: ASTStyle, pub fields: Vec>, pub original: &'a syn::Variant, } impl<'a> ASTEnumVariant<'a> { pub fn name(&self) -> String { self.ident.to_string() } } pub enum BracketCategory { Other, Opt, Vec, Map((String, String)), } pub struct ASTField<'a> { pub member: syn::Member, pub pb_attrs: PBStructAttrs, pub node_attrs: NodeStructAttrs, pub ty: &'a syn::Type, pub original: &'a syn::Field, // If the field is Vec, then the bracket_ty will be Vec pub bracket_ty: Option, // If the field is Vec, then the bracket_inner_ty will be String pub bracket_inner_ty: Option, pub bracket_category: Option, } impl<'a> ASTField<'a> { pub fn new(cx: &ASTResult, field: &'a syn::Field, index: usize) -> Result { let mut bracket_inner_ty = None; let mut bracket_ty = None; let mut bracket_category = Some(BracketCategory::Other); match parse_ty(cx, &field.ty) { Ok(Some(inner)) => { match inner.primitive_ty { PrimitiveTy::Map(map_info) => { bracket_category = Some(BracketCategory::Map((map_info.key.clone(), map_info.value))) }, PrimitiveTy::Vec => { bracket_category = Some(BracketCategory::Vec); }, PrimitiveTy::Opt => { bracket_category = Some(BracketCategory::Opt); }, PrimitiveTy::Other => { bracket_category = Some(BracketCategory::Other); }, } match *inner.bracket_ty_info { Some(bracketed_inner_ty) => { bracket_inner_ty = Some(bracketed_inner_ty.ident.clone()); bracket_ty = Some(inner.ident.clone()); }, None => { bracket_ty = Some(inner.ident.clone()); }, } }, Ok(None) => { let msg = format!("Fail to get the ty inner type: {:?}", field); return Err(msg); }, Err(e) => { eprintln!("ASTField parser failed: {:?} with error: {}", field, e); return Err(e); }, } Ok(ASTField { member: match &field.ident { Some(ident) => syn::Member::Named(ident.clone()), None => syn::Member::Unnamed(index.into()), }, pb_attrs: PBStructAttrs::from_ast(cx, index, field), node_attrs: NodeStructAttrs::from_ast(cx, index, field), ty: &field.ty, original: field, bracket_ty, bracket_inner_ty, bracket_category, }) } pub fn ty_as_str(&self) -> String { match self.bracket_inner_ty { Some(ref ty) => ty.to_string(), None => self.bracket_ty.as_ref().unwrap().clone().to_string(), } } pub fn name(&self) -> Option { if let syn::Member::Named(ident) = &self.member { Some(ident.clone()) } else { None } } } #[derive(Copy, Clone)] pub enum ASTStyle { Struct, /// Many unnamed fields. Tuple, /// One unnamed field. NewType, /// No fields. Unit, } pub fn struct_from_ast<'a>( cx: &ASTResult, fields: &'a syn::Fields, ) -> (ASTStyle, Vec>) { match fields { syn::Fields::Named(fields) => (ASTStyle::Struct, fields_from_ast(cx, &fields.named)), syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { (ASTStyle::NewType, fields_from_ast(cx, &fields.unnamed)) }, syn::Fields::Unnamed(fields) => (ASTStyle::Tuple, fields_from_ast(cx, &fields.unnamed)), syn::Fields::Unit => (ASTStyle::Unit, Vec::new()), } } pub fn enum_from_ast<'a>( cx: &ASTResult, ident: &syn::Ident, variants: &'a Punctuated, enum_attrs: &[syn::Attribute], ) -> Vec> { variants .iter() .flat_map(|variant| { let attrs = EventEnumAttrs::from_ast(cx, ident, variant, enum_attrs); let (style, fields) = struct_from_ast(cx, &variant.fields); Some(ASTEnumVariant { ident: variant.ident.clone(), attrs, style, fields, original: variant, }) }) .collect() } fn fields_from_ast<'a>( cx: &ASTResult, fields: &'a Punctuated, ) -> Vec> { fields .iter() .enumerate() .flat_map(|(index, field)| { if is_recognizable_field(field) { ASTField::new(cx, field, index).ok() } else { None } }) .collect() } fn get_node_type( ast_result: &ASTResult, struct_name: &Ident, attrs: &[syn::Attribute], ) -> Option { let mut node_type = None; attrs .iter() .filter(|attr| attr.path.segments.iter().any(|s| s.ident == NODE_TYPE)) .for_each(|attr| { if let Ok(NameValue(named_value)) = attr.parse_meta() { if node_type.is_some() { ast_result.error_spanned_by(struct_name, "Duplicate node type definition"); } if let syn::Lit::Str(s) = named_value.lit { node_type = Some(s.value()); } } }); node_type } ================================================ FILE: frontend/rust-lib/build-tool/flowy-ast/src/ctxt.rs ================================================ use quote::ToTokens; use std::{cell::RefCell, fmt::Display, thread}; #[derive(Default)] pub struct ASTResult { errors: RefCell>>, } impl ASTResult { pub fn new() -> Self { ASTResult { errors: RefCell::new(Some(Vec::new())), } } pub fn error_spanned_by(&self, obj: A, msg: T) { self .errors .borrow_mut() .as_mut() .unwrap() .push(syn::Error::new_spanned(obj.into_token_stream(), msg)); } pub fn syn_error(&self, err: syn::Error) { self.errors.borrow_mut().as_mut().unwrap().push(err); } pub fn check(self) -> Result<(), Vec> { let errors = self.errors.borrow_mut().take().unwrap(); match errors.len() { 0 => Ok(()), _ => Err(errors), } } } impl Drop for ASTResult { fn drop(&mut self) { if !thread::panicking() && self.errors.borrow().is_some() { panic!("forgot to check for errors"); } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-ast/src/event_attrs.rs ================================================ use crate::{get_event_meta_items, parse_lit_str, symbol::*, ASTResult}; use syn::{ self, Meta::{NameValue, Path}, NestedMeta::{Lit, Meta}, }; #[derive(Debug, Clone)] pub struct EventAttrs { input: Option, output: Option, error_ty: Option, pub ignore: bool, } #[derive(Debug, Clone)] pub struct EventEnumAttrs { pub enum_name: String, pub enum_item_name: String, pub value: String, pub event_attrs: EventAttrs, } impl EventEnumAttrs { pub fn from_ast( ast_result: &ASTResult, ident: &syn::Ident, variant: &syn::Variant, enum_attrs: &[syn::Attribute], ) -> Self { let enum_item_name = variant.ident.to_string(); let enum_name = ident.to_string(); let mut value = String::new(); if variant.discriminant.is_some() { if let syn::Expr::Lit(ref expr_list) = variant.discriminant.as_ref().unwrap().1 { let lit_int = if let syn::Lit::Int(ref int_value) = expr_list.lit { int_value } else { unimplemented!() }; value = lit_int.base10_digits().to_string(); } } let event_attrs = get_event_attrs_from(ast_result, &variant.attrs, enum_attrs); EventEnumAttrs { enum_name, enum_item_name, value, event_attrs, } } pub fn event_input(&self) -> Option { self.event_attrs.input.clone() } pub fn event_output(&self) -> Option { self.event_attrs.output.clone() } pub fn event_error(&self) -> String { self.event_attrs.error_ty.as_ref().unwrap().clone() } } fn get_event_attrs_from( ast_result: &ASTResult, variant_attrs: &[syn::Attribute], enum_attrs: &[syn::Attribute], ) -> EventAttrs { let mut event_attrs = EventAttrs { input: None, output: None, error_ty: None, ignore: false, }; enum_attrs .iter() .filter(|attr| attr.path.segments.iter().any(|s| s.ident == EVENT_ERR)) .for_each(|attr| { if let Ok(NameValue(named_value)) = attr.parse_meta() { if let syn::Lit::Str(s) = named_value.lit { event_attrs.error_ty = Some(s.value()); } else { eprintln!("❌ {} should not be empty", EVENT_ERR); } } else { eprintln!("❌ Can not find any {} on attr: {:#?}", EVENT_ERR, attr); } }); let mut extract_event_attr = |attr: &syn::Attribute, meta_item: &syn::NestedMeta| match &meta_item { Meta(NameValue(name_value)) => { if name_value.path == EVENT_INPUT { if let syn::Lit::Str(s) = &name_value.lit { let input_type = parse_lit_str(s) .map_err(|_| { ast_result.error_spanned_by( s, format!("failed to parse request deserializer {:?}", s.value()), ) }) .unwrap(); event_attrs.input = Some(input_type); } } if name_value.path == EVENT_OUTPUT { if let syn::Lit::Str(s) = &name_value.lit { let output_type = parse_lit_str(s) .map_err(|_| { ast_result.error_spanned_by( s, format!("failed to parse response deserializer {:?}", s.value()), ) }) .unwrap(); event_attrs.output = Some(output_type); } } }, Meta(Path(word)) => { if word == EVENT_IGNORE && attr.path == EVENT { event_attrs.ignore = true; } }, Lit(s) => ast_result.error_spanned_by(s, "unexpected attribute"), _ => ast_result.error_spanned_by(meta_item, "unexpected attribute"), }; let attr_meta_items_info = variant_attrs .iter() .flat_map(|attr| match get_event_meta_items(ast_result, attr) { Ok(items) => Some((attr, items)), Err(_) => None, }) .collect::)>>(); for (attr, nested_metas) in attr_meta_items_info { nested_metas .iter() .for_each(|meta_item| extract_event_attr(attr, meta_item)) } // eprintln!("😁{:#?}", event_attrs); event_attrs } ================================================ FILE: frontend/rust-lib/build-tool/flowy-ast/src/lib.rs ================================================ #[macro_use] extern crate syn; mod ast; mod ctxt; mod pb_attrs; mod event_attrs; mod node_attrs; pub mod symbol; pub mod ty_ext; pub use self::{symbol::*, ty_ext::*}; pub use ast::*; pub use ctxt::ASTResult; pub use event_attrs::*; pub use pb_attrs::*; ================================================ FILE: frontend/rust-lib/build-tool/flowy-ast/src/node_attrs.rs ================================================ use crate::{get_node_meta_items, parse_lit_into_expr_path, symbol::*, ASTAttr, ASTResult}; use quote::ToTokens; use syn::{ self, LitStr, Meta::NameValue, NestedMeta::{Lit, Meta}, }; pub struct NodeStructAttrs { pub rename: Option, pub has_child: bool, pub child_name: Option, pub child_index: Option, pub get_node_value_with: Option, pub set_node_value_with: Option, pub with_children: Option, } impl NodeStructAttrs { /// Extract out the `#[node(...)]` attributes from a struct field. pub fn from_ast(ast_result: &ASTResult, _index: usize, field: &syn::Field) -> Self { let mut rename = ASTAttr::none(ast_result, RENAME_NODE); let mut child_name = ASTAttr::none(ast_result, CHILD_NODE_NAME); let mut child_index = ASTAttr::none(ast_result, CHILD_NODE_INDEX); let mut get_node_value_with = ASTAttr::none(ast_result, GET_NODE_VALUE_WITH); let mut set_node_value_with = ASTAttr::none(ast_result, SET_NODE_VALUE_WITH); let mut with_children = ASTAttr::none(ast_result, WITH_CHILDREN); for meta_item in field .attrs .iter() .flat_map(|attr| get_node_meta_items(ast_result, attr)) .flatten() { match &meta_item { // Parse '#[node(rename = x)]' Meta(NameValue(m)) if m.path == RENAME_NODE => { if let syn::Lit::Str(lit) = &m.lit { rename.set(&m.path, lit.clone()); } }, // Parse '#[node(child_name = x)]' Meta(NameValue(m)) if m.path == CHILD_NODE_NAME => { if let syn::Lit::Str(lit) = &m.lit { child_name.set(&m.path, lit.clone()); } }, // Parse '#[node(child_index = x)]' Meta(NameValue(m)) if m.path == CHILD_NODE_INDEX => { if let syn::Lit::Int(lit) = &m.lit { child_index.set(&m.path, lit.clone()); } }, // Parse `#[node(get_node_value_with = "...")]` Meta(NameValue(m)) if m.path == GET_NODE_VALUE_WITH => { if let Ok(path) = parse_lit_into_expr_path(ast_result, GET_NODE_VALUE_WITH, &m.lit) { get_node_value_with.set(&m.path, path); } }, // Parse `#[node(set_node_value_with= "...")]` Meta(NameValue(m)) if m.path == SET_NODE_VALUE_WITH => { if let Ok(path) = parse_lit_into_expr_path(ast_result, SET_NODE_VALUE_WITH, &m.lit) { set_node_value_with.set(&m.path, path); } }, // Parse `#[node(with_children= "...")]` Meta(NameValue(m)) if m.path == WITH_CHILDREN => { if let Ok(path) = parse_lit_into_expr_path(ast_result, WITH_CHILDREN, &m.lit) { with_children.set(&m.path, path); } }, Meta(meta_item) => { let path = meta_item .path() .into_token_stream() .to_string() .replace(' ', ""); ast_result.error_spanned_by( meta_item.path(), format!("unknown node field attribute `{}`", path), ); }, Lit(lit) => { ast_result.error_spanned_by(lit, "unexpected literal in field attribute"); }, } } let child_name = child_name.get(); NodeStructAttrs { rename: rename.get(), child_index: child_index.get(), has_child: child_name.is_some(), child_name, get_node_value_with: get_node_value_with.get(), set_node_value_with: set_node_value_with.get(), with_children: with_children.get(), } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-ast/src/pb_attrs.rs ================================================ #![allow(clippy::all)] use crate::{symbol::*, ASTResult}; use proc_macro2::{Group, Span, TokenStream, TokenTree}; use quote::ToTokens; use syn::{ self, parse::{self, Parse}, Meta::{List, NameValue, Path}, NestedMeta::{Lit, Meta}, }; #[allow(dead_code)] pub struct PBAttrsContainer { name: String, pb_struct_type: Option, pb_enum_type: Option, } impl PBAttrsContainer { /// Extract out the `#[pb(...)]` attributes from an item. pub fn from_ast(ast_result: &ASTResult, item: &syn::DeriveInput) -> Self { let mut pb_struct_type = ASTAttr::none(ast_result, PB_STRUCT); let mut pb_enum_type = ASTAttr::none(ast_result, PB_ENUM); for meta_item in item .attrs .iter() .flat_map(|attr| get_pb_meta_items(ast_result, attr)) .flatten() { match &meta_item { // Parse `#[pb(struct = "Type")] Meta(NameValue(m)) if m.path == PB_STRUCT => { if let Ok(into_ty) = parse_lit_into_ty(ast_result, PB_STRUCT, &m.lit) { pb_struct_type.set_opt(&m.path, Some(into_ty)); } }, // Parse `#[pb(enum = "Type")] Meta(NameValue(m)) if m.path == PB_ENUM => { if let Ok(into_ty) = parse_lit_into_ty(ast_result, PB_ENUM, &m.lit) { pb_enum_type.set_opt(&m.path, Some(into_ty)); } }, Meta(meta_item) => { let path = meta_item .path() .into_token_stream() .to_string() .replace(' ', ""); ast_result.error_spanned_by( meta_item.path(), format!("unknown container attribute `{}`", path), ); }, Lit(lit) => { ast_result.error_spanned_by(lit, "unexpected literal in container attribute"); }, } } match &item.data { syn::Data::Struct(_) => { pb_struct_type.set_if_none(default_pb_type(&ast_result, &item.ident)); }, syn::Data::Enum(_) => { pb_enum_type.set_if_none(default_pb_type(&ast_result, &item.ident)); }, _ => {}, } PBAttrsContainer { name: item.ident.to_string(), pb_struct_type: pb_struct_type.get(), pb_enum_type: pb_enum_type.get(), } } pub fn pb_struct_type(&self) -> Option<&syn::Type> { self.pb_struct_type.as_ref() } pub fn pb_enum_type(&self) -> Option<&syn::Type> { self.pb_enum_type.as_ref() } } pub struct ASTAttr<'c, T> { ast_result: &'c ASTResult, name: Symbol, tokens: TokenStream, value: Option, } impl<'c, T> ASTAttr<'c, T> { pub(crate) fn none(ast_result: &'c ASTResult, name: Symbol) -> Self { ASTAttr { ast_result, name, tokens: TokenStream::new(), value: None, } } pub(crate) fn set(&mut self, obj: A, value: T) { let tokens = obj.into_token_stream(); if self.value.is_some() { self .ast_result .error_spanned_by(tokens, format!("duplicate attribute `{}`", self.name)); } else { self.tokens = tokens; self.value = Some(value); } } fn set_opt(&mut self, obj: A, value: Option) { if let Some(value) = value { self.set(obj, value); } } pub(crate) fn set_if_none(&mut self, value: T) { if self.value.is_none() { self.value = Some(value); } } pub(crate) fn get(self) -> Option { self.value } #[allow(dead_code)] fn get_with_tokens(self) -> Option<(TokenStream, T)> { match self.value { Some(v) => Some((self.tokens, v)), None => None, } } } pub struct PBStructAttrs { #[allow(dead_code)] name: String, pb_index: Option, pb_one_of: bool, skip_pb_serializing: bool, skip_pb_deserializing: bool, serialize_pb_with: Option, deserialize_pb_with: Option, } pub fn is_recognizable_field(field: &syn::Field) -> bool { field .attrs .iter() .any(|attr| is_recognizable_attribute(attr)) } impl PBStructAttrs { /// Extract out the `#[pb(...)]` attributes from a struct field. pub fn from_ast(ast_result: &ASTResult, index: usize, field: &syn::Field) -> Self { let mut pb_index = ASTAttr::none(ast_result, PB_INDEX); let mut pb_one_of = BoolAttr::none(ast_result, PB_ONE_OF); let mut serialize_pb_with = ASTAttr::none(ast_result, SERIALIZE_PB_WITH); let mut skip_pb_serializing = BoolAttr::none(ast_result, SKIP_PB_SERIALIZING); let mut deserialize_pb_with = ASTAttr::none(ast_result, DESERIALIZE_PB_WITH); let mut skip_pb_deserializing = BoolAttr::none(ast_result, SKIP_PB_DESERIALIZING); let ident = match &field.ident { Some(ident) => ident.to_string(), None => index.to_string(), }; for meta_item in field .attrs .iter() .flat_map(|attr| get_pb_meta_items(ast_result, attr)) .flatten() { match &meta_item { // Parse `#[pb(skip)]` Meta(Path(word)) if word == SKIP => { skip_pb_serializing.set_true(word); skip_pb_deserializing.set_true(word); }, // Parse '#[pb(index = x)]' Meta(NameValue(m)) if m.path == PB_INDEX => { if let syn::Lit::Int(lit) = &m.lit { pb_index.set(&m.path, lit.clone()); } }, // Parse `#[pb(one_of)]` Meta(Path(path)) if path == PB_ONE_OF => { pb_one_of.set_true(path); }, // Parse `#[pb(serialize_pb_with = "...")]` Meta(NameValue(m)) if m.path == SERIALIZE_PB_WITH => { if let Ok(path) = parse_lit_into_expr_path(ast_result, SERIALIZE_PB_WITH, &m.lit) { serialize_pb_with.set(&m.path, path); } }, // Parse `#[pb(deserialize_pb_with = "...")]` Meta(NameValue(m)) if m.path == DESERIALIZE_PB_WITH => { if let Ok(path) = parse_lit_into_expr_path(ast_result, DESERIALIZE_PB_WITH, &m.lit) { deserialize_pb_with.set(&m.path, path); } }, Meta(meta_item) => { let path = meta_item .path() .into_token_stream() .to_string() .replace(' ', ""); ast_result.error_spanned_by( meta_item.path(), format!("unknown pb field attribute `{}`", path), ); }, Lit(lit) => { ast_result.error_spanned_by(lit, "unexpected literal in field attribute"); }, } } PBStructAttrs { name: ident, pb_index: pb_index.get(), pb_one_of: pb_one_of.get(), skip_pb_serializing: skip_pb_serializing.get(), skip_pb_deserializing: skip_pb_deserializing.get(), serialize_pb_with: serialize_pb_with.get(), deserialize_pb_with: deserialize_pb_with.get(), } } #[allow(dead_code)] pub fn pb_index(&self) -> Option { self .pb_index .as_ref() .map(|lit| lit.base10_digits().to_string()) } pub fn is_one_of(&self) -> bool { self.pb_one_of } pub fn serialize_pb_with(&self) -> Option<&syn::ExprPath> { self.serialize_pb_with.as_ref() } pub fn deserialize_pb_with(&self) -> Option<&syn::ExprPath> { self.deserialize_pb_with.as_ref() } pub fn skip_pb_serializing(&self) -> bool { self.skip_pb_serializing } pub fn skip_pb_deserializing(&self) -> bool { self.skip_pb_deserializing } } pub enum Default { /// Field must always be specified because it does not have a default. None, /// The default is given by `std::default::Default::default()`. Default, /// The default is given by this function. Path(syn::ExprPath), } pub fn is_recognizable_attribute(attr: &syn::Attribute) -> bool { attr.path == PB_ATTRS || attr.path == EVENT || attr.path == NODE_ATTRS || attr.path == NODES_ATTRS } pub fn get_pb_meta_items( cx: &ASTResult, attr: &syn::Attribute, ) -> Result, ()> { // Only handle the attribute that we have defined if attr.path != PB_ATTRS { return Ok(vec![]); } // http://strymon.systems.ethz.ch/typename/syn/enum.Meta.html match attr.parse_meta() { Ok(List(meta)) => Ok(meta.nested.into_iter().collect()), Ok(other) => { cx.error_spanned_by(other, "expected #[pb(...)]"); Err(()) }, Err(err) => { cx.error_spanned_by(attr, "attribute must be str, e.g. #[pb(xx = \"xxx\")]"); cx.syn_error(err); Err(()) }, } } pub fn get_node_meta_items( cx: &ASTResult, attr: &syn::Attribute, ) -> Result, ()> { // Only handle the attribute that we have defined if attr.path != NODE_ATTRS && attr.path != NODES_ATTRS { return Ok(vec![]); } // http://strymon.systems.ethz.ch/typename/syn/enum.Meta.html match attr.parse_meta() { Ok(List(meta)) => Ok(meta.nested.into_iter().collect()), Ok(_) => Ok(vec![]), Err(err) => { cx.error_spanned_by(attr, "attribute must be str, e.g. #[node(xx = \"xxx\")]"); cx.syn_error(err); Err(()) }, } } pub fn get_event_meta_items( cx: &ASTResult, attr: &syn::Attribute, ) -> Result, ()> { // Only handle the attribute that we have defined if attr.path != EVENT { return Ok(vec![]); } // http://strymon.systems.ethz.ch/typename/syn/enum.Meta.html match attr.parse_meta() { Ok(List(meta)) => Ok(meta.nested.into_iter().collect()), Ok(other) => { cx.error_spanned_by(other, "expected #[event(...)]"); Err(()) }, Err(err) => { cx.error_spanned_by(attr, "attribute must be str, e.g. #[event(xx = \"xxx\")]"); cx.syn_error(err); Err(()) }, } } pub fn parse_lit_into_expr_path( ast_result: &ASTResult, attr_name: Symbol, lit: &syn::Lit, ) -> Result { let string = get_lit_str(ast_result, attr_name, lit)?; parse_lit_str(string).map_err(|_| { ast_result.error_spanned_by(lit, format!("failed to parse path: {:?}", string.value())) }) } fn get_lit_str<'a>( ast_result: &ASTResult, attr_name: Symbol, lit: &'a syn::Lit, ) -> Result<&'a syn::LitStr, ()> { if let syn::Lit::Str(lit) = lit { Ok(lit) } else { ast_result.error_spanned_by( lit, format!( "expected pb {} attribute to be a string: `{} = \"...\"`", attr_name, attr_name ), ); Err(()) } } fn parse_lit_into_ty( ast_result: &ASTResult, attr_name: Symbol, lit: &syn::Lit, ) -> Result { let string = get_lit_str(ast_result, attr_name, lit)?; parse_lit_str(string).map_err(|_| { ast_result.error_spanned_by( lit, format!("failed to parse type: {} = {:?}", attr_name, string.value()), ) }) } pub fn parse_lit_str(s: &syn::LitStr) -> parse::Result where T: Parse, { let tokens = spanned_tokens(s)?; syn::parse2(tokens) } fn spanned_tokens(s: &syn::LitStr) -> parse::Result { let stream = syn::parse_str(&s.value())?; Ok(respan_token_stream(stream, s.span())) } fn respan_token_stream(stream: TokenStream, span: Span) -> TokenStream { stream .into_iter() .map(|token| respan_token_tree(token, span)) .collect() } fn respan_token_tree(mut token: TokenTree, span: Span) -> TokenTree { if let TokenTree::Group(g) = &mut token { *g = Group::new(g.delimiter(), respan_token_stream(g.stream(), span)); } token.set_span(span); token } fn default_pb_type(ast_result: &ASTResult, ident: &syn::Ident) -> syn::Type { let take_ident = ident.to_string(); let lit_str = syn::LitStr::new(&take_ident, ident.span()); if let Ok(tokens) = spanned_tokens(&lit_str) { if let Ok(pb_struct_ty) = syn::parse2(tokens) { return pb_struct_ty; } } ast_result.error_spanned_by( ident, format!("❌ Can't find {} protobuf struct", take_ident), ); panic!() } #[allow(dead_code)] pub fn is_option(ty: &syn::Type) -> bool { let path = match ungroup(ty) { syn::Type::Path(ty) => &ty.path, _ => { return false; }, }; let seg = match path.segments.last() { Some(seg) => seg, None => { return false; }, }; let args = match &seg.arguments { syn::PathArguments::AngleBracketed(bracketed) => &bracketed.args, _ => { return false; }, }; seg.ident == "Option" && args.len() == 1 } #[allow(dead_code)] pub fn ungroup(mut ty: &syn::Type) -> &syn::Type { while let syn::Type::Group(group) = ty { ty = &group.elem; } ty } struct BoolAttr<'c>(ASTAttr<'c, ()>); impl<'c> BoolAttr<'c> { fn none(ast_result: &'c ASTResult, name: Symbol) -> Self { BoolAttr(ASTAttr::none(ast_result, name)) } fn set_true(&mut self, obj: A) { self.0.set(obj, ()); } fn get(&self) -> bool { self.0.value.is_some() } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-ast/src/symbol.rs ================================================ use std::fmt::{self, Display}; use syn::{Ident, Path}; #[derive(Copy, Clone)] pub struct Symbol(&'static str); // Protobuf pub const PB_ATTRS: Symbol = Symbol("pb"); //#[pb(skip)] pub const SKIP: Symbol = Symbol("skip"); //#[pb(index = "1")] pub const PB_INDEX: Symbol = Symbol("index"); //#[pb(one_of)] pub const PB_ONE_OF: Symbol = Symbol("one_of"); //#[pb(skip_pb_deserializing = "...")] pub const SKIP_PB_DESERIALIZING: Symbol = Symbol("skip_pb_deserializing"); //#[pb(skip_pb_serializing)] pub const SKIP_PB_SERIALIZING: Symbol = Symbol("skip_pb_serializing"); //#[pb(serialize_pb_with = "...")] pub const SERIALIZE_PB_WITH: Symbol = Symbol("serialize_pb_with"); //#[pb(deserialize_pb_with = "...")] pub const DESERIALIZE_PB_WITH: Symbol = Symbol("deserialize_pb_with"); //#[pb(struct="some struct")] pub const PB_STRUCT: Symbol = Symbol("struct"); //#[pb(enum="some enum")] pub const PB_ENUM: Symbol = Symbol("enum"); // Event pub const EVENT_INPUT: Symbol = Symbol("input"); pub const EVENT_OUTPUT: Symbol = Symbol("output"); pub const EVENT_IGNORE: Symbol = Symbol("ignore"); pub const EVENT: Symbol = Symbol("event"); pub const EVENT_ERR: Symbol = Symbol("event_err"); // Node pub const NODE_ATTRS: Symbol = Symbol("node"); pub const NODES_ATTRS: Symbol = Symbol("nodes"); pub const NODE_TYPE: Symbol = Symbol("node_type"); pub const NODE_INDEX: Symbol = Symbol("index"); pub const RENAME_NODE: Symbol = Symbol("rename"); pub const CHILD_NODE_NAME: Symbol = Symbol("child_name"); pub const CHILD_NODE_INDEX: Symbol = Symbol("child_index"); pub const SKIP_NODE_ATTRS: Symbol = Symbol("skip_node_attribute"); pub const GET_NODE_VALUE_WITH: Symbol = Symbol("get_value_with"); pub const SET_NODE_VALUE_WITH: Symbol = Symbol("set_value_with"); pub const GET_VEC_ELEMENT_WITH: Symbol = Symbol("get_element_with"); pub const GET_MUT_VEC_ELEMENT_WITH: Symbol = Symbol("get_mut_element_with"); pub const WITH_CHILDREN: Symbol = Symbol("with_children"); impl PartialEq for Ident { fn eq(&self, word: &Symbol) -> bool { self == word.0 } } impl PartialEq for &Ident { fn eq(&self, word: &Symbol) -> bool { *self == word.0 } } impl PartialEq for Path { fn eq(&self, word: &Symbol) -> bool { self.is_ident(word.0) } } impl PartialEq for &Path { fn eq(&self, word: &Symbol) -> bool { self.is_ident(word.0) } } impl Display for Symbol { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str(self.0) } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-ast/src/ty_ext.rs ================================================ use crate::ASTResult; use syn::{self, AngleBracketedGenericArguments, PathSegment}; #[derive(Eq, PartialEq, Debug)] pub enum PrimitiveTy { Map(MapInfo), Vec, Opt, Other, } #[derive(Debug)] pub struct TyInfo<'a> { pub ident: &'a syn::Ident, pub ty: &'a syn::Type, pub primitive_ty: PrimitiveTy, pub bracket_ty_info: Box>>, } #[derive(Debug, Eq, PartialEq)] pub struct MapInfo { pub key: String, pub value: String, } impl MapInfo { fn new(key: String, value: String) -> Self { MapInfo { key, value } } } impl<'a> TyInfo<'a> { #[allow(dead_code)] pub fn bracketed_ident(&'a self) -> &'a syn::Ident { match self.bracket_ty_info.as_ref() { Some(b_ty) => b_ty.ident, None => { panic!() }, } } } pub fn parse_ty<'a>( ast_result: &ASTResult, ty: &'a syn::Type, ) -> Result>, String> { // Type -> TypePath -> Path -> PathSegment -> PathArguments -> // AngleBracketedGenericArguments -> GenericArgument -> Type. if let syn::Type::Path(ref p) = ty { if p.path.segments.len() != 1 { return Ok(None); } let seg = match p.path.segments.last() { Some(seg) => seg, None => return Ok(None), }; let _is_option = seg.ident == "Option"; return if let syn::PathArguments::AngleBracketed(ref bracketed) = seg.arguments { match seg.ident.to_string().as_ref() { "HashMap" => generate_hashmap_ty_info(ast_result, ty, seg, bracketed), "Vec" => generate_vec_ty_info(ast_result, seg, bracketed), "Option" => generate_option_ty_info(ast_result, ty, seg, bracketed), _ => { let msg = format!("Unsupported type: {}", seg.ident); ast_result.error_spanned_by(&seg.ident, &msg); return Err(msg); }, } } else { return Ok(Some(TyInfo { ident: &seg.ident, ty, primitive_ty: PrimitiveTy::Other, bracket_ty_info: Box::new(None), })); }; } Err("Unsupported inner type, get inner type fail".to_string()) } fn parse_bracketed(bracketed: &AngleBracketedGenericArguments) -> Vec<&syn::Type> { bracketed .args .iter() .flat_map(|arg| { if let syn::GenericArgument::Type(ref ty_in_bracket) = arg { Some(ty_in_bracket) } else { None } }) .collect::>() } pub fn generate_hashmap_ty_info<'a>( ast_result: &ASTResult, ty: &'a syn::Type, path_segment: &'a PathSegment, bracketed: &'a AngleBracketedGenericArguments, ) -> Result>, String> { // The args of map must greater than 2 if bracketed.args.len() != 2 { return Ok(None); } let types = parse_bracketed(bracketed); let key = parse_ty(ast_result, types[0])?.unwrap().ident.to_string(); let value = parse_ty(ast_result, types[1])?.unwrap().ident.to_string(); let bracket_ty_info = Box::new(parse_ty(ast_result, types[1])?); Ok(Some(TyInfo { ident: &path_segment.ident, ty, primitive_ty: PrimitiveTy::Map(MapInfo::new(key, value)), bracket_ty_info, })) } fn generate_option_ty_info<'a>( ast_result: &ASTResult, ty: &'a syn::Type, path_segment: &'a PathSegment, bracketed: &'a AngleBracketedGenericArguments, ) -> Result>, String> { assert_eq!(path_segment.ident.to_string(), "Option".to_string()); let types = parse_bracketed(bracketed); let bracket_ty_info = Box::new(parse_ty(ast_result, types[0])?); Ok(Some(TyInfo { ident: &path_segment.ident, ty, primitive_ty: PrimitiveTy::Opt, bracket_ty_info, })) } fn generate_vec_ty_info<'a>( ast_result: &ASTResult, path_segment: &'a PathSegment, bracketed: &'a AngleBracketedGenericArguments, ) -> Result>, String> { if bracketed.args.len() != 1 { return Ok(None); } if let syn::GenericArgument::Type(ref bracketed_type) = bracketed.args.first().unwrap() { let bracketed_ty_info = Box::new(parse_ty(ast_result, bracketed_type)?); return Ok(Some(TyInfo { ident: &path_segment.ident, ty: bracketed_type, primitive_ty: PrimitiveTy::Vec, bracket_ty_info: bracketed_ty_info, })); } Ok(None) } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/Cargo.toml ================================================ [package] name = "flowy-codegen" version = "0.1.0" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] log = "0.4.17" serde = { workspace = true, features = ["derive"] } serde_json.workspace = true flowy-ast.workspace = true quote = "1.0" cmd_lib = { version = "1.9.5", optional = true } protoc-rust = { version = "2.28.0", optional = true } #protobuf-codegen = { version = "3.7.1" } walkdir = { version = "2", optional = true } similar = { version = "1.3.0", optional = true } syn = { version = "1.0.109", features = ["extra-traits", "parsing", "derive", "full"] } fancy-regex = { version = "0.10.0", optional = true } lazy_static = { version = "1.4.0", optional = true } tera = { version = "1.17.1", optional = true } itertools = { version = "0.10", optional = true } phf = { version = "0.8.0", features = ["macros"], optional = true } console = { version = "0.14.1", optional = true } protoc-bin-vendored = { version = "3.1.0", optional = true } toml = { version = "0.5.11", optional = true } [features] proto_gen = [ "similar", "fancy-regex", "lazy_static", "tera", "itertools", "phf", "walkdir", "console", "toml", "cmd_lib", "protoc-rust", "walkdir", "protoc-bin-vendored", ] dart_event = ["walkdir", "tera", ] dart = ["proto_gen", "dart_event"] ts_event = ["walkdir", "tera", ] ts = ["proto_gen", "ts_event"] ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/ast.rs ================================================ use flowy_ast::EventEnumAttrs; use quote::format_ident; #[allow(dead_code)] pub struct EventASTContext { pub event: syn::Ident, pub event_ty: syn::Ident, pub event_request_struct: syn::Ident, pub event_input: Option, pub event_output: Option, pub event_error: String, } impl EventASTContext { #[allow(dead_code)] pub fn from(enum_attrs: &EventEnumAttrs) -> EventASTContext { let command_name = enum_attrs.enum_item_name.clone(); if command_name.is_empty() { panic!("Invalid command name: {}", enum_attrs.enum_item_name); } let event = format_ident!("{}", &command_name); let splits = command_name.split('_').collect::>(); let event_ty = format_ident!("{}", enum_attrs.enum_name); let event_request_struct = format_ident!("{}Event", &splits.join("")); let event_input = enum_attrs.event_input(); let event_output = enum_attrs.event_output(); let event_error = enum_attrs.event_error(); EventASTContext { event, event_ty, event_request_struct, event_input, event_output, event_error, } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/dart_event.rs ================================================ use std::fs::File; use std::io::Write; use std::path::PathBuf; use syn::Item; use walkdir::WalkDir; use flowy_ast::ASTResult; use crate::ast::EventASTContext; use crate::flowy_toml::{CrateConfig, parse_crate_config_from}; use crate::util::{is_crate_dir, is_hidden, path_string_with_component, read_file}; use super::event_template::*; pub fn r#gen(crate_name: &str) { if std::env::var("CARGO_MAKE_WORKING_DIRECTORY").is_err() { println!("CARGO_MAKE_WORKING_DIRECTORY was not set, skip generate dart pb"); return; } if std::env::var("FLUTTER_FLOWY_SDK_PATH").is_err() { println!("FLUTTER_FLOWY_SDK_PATH was not set, skip generate dart pb"); return; } let crate_path = std::fs::canonicalize(".") .unwrap() .as_path() .display() .to_string(); let event_crates = parse_dart_event_files(vec![crate_path]); let event_ast = event_crates .iter() .flat_map(parse_event_crate) .collect::>(); let event_render_ctx = ast_to_event_render_ctx(event_ast.as_ref()); let mut render_result = DART_IMPORTED.to_owned(); for (index, render_ctx) in event_render_ctx.into_iter().enumerate() { let mut event_template = EventTemplate::new(); if let Some(content) = event_template.render(render_ctx, index) { render_result.push_str(content.as_ref()) } } let dart_event_folder: PathBuf = [ &std::env::var("CARGO_MAKE_WORKING_DIRECTORY").unwrap(), &std::env::var("FLUTTER_FLOWY_SDK_PATH").unwrap(), "lib", "dispatch", "dart_event", crate_name, ] .iter() .collect(); if !dart_event_folder.as_path().exists() { std::fs::create_dir_all(dart_event_folder.as_path()).unwrap(); } let dart_event_file_path = path_string_with_component(&dart_event_folder, vec!["dart_event.dart"]); println!("cargo:rerun-if-changed={}", dart_event_file_path); match std::fs::OpenOptions::new() .create(true) .write(true) .append(false) .truncate(true) .open(&dart_event_file_path) { Ok(ref mut file) => { file.write_all(render_result.as_bytes()).unwrap(); File::flush(file).unwrap(); }, Err(err) => { panic!("Failed to open file: {}, {:?}", dart_event_file_path, err); }, } } const DART_IMPORTED: &str = r#" /// Auto generate. Do not edit part of '../../dispatch.dart'; "#; #[derive(Debug)] pub struct DartEventCrate { crate_path: PathBuf, event_files: Vec, } impl DartEventCrate { pub fn from_config(config: &CrateConfig) -> Self { DartEventCrate { crate_path: config.crate_path.clone(), event_files: config.flowy_config.event_files.clone(), } } } pub fn parse_dart_event_files(crate_paths: Vec) -> Vec { let mut dart_event_crates: Vec = vec![]; crate_paths.iter().for_each(|path| { let crates = WalkDir::new(path) .into_iter() .filter_entry(|e| !is_hidden(e)) .filter_map(|e| e.ok()) .filter(is_crate_dir) .flat_map(|e| parse_crate_config_from(&e)) .map(|crate_config| DartEventCrate::from_config(&crate_config)) .collect::>(); dart_event_crates.extend(crates); }); dart_event_crates } pub fn parse_event_crate(event_crate: &DartEventCrate) -> Vec { event_crate .event_files .iter() .flat_map(|event_file| { let file_path = path_string_with_component(&event_crate.crate_path, vec![event_file.as_str()]); let file_content = read_file(file_path.as_ref()).unwrap(); let ast = syn::parse_file(file_content.as_ref()).expect("Unable to parse file"); ast .items .iter() .flat_map(|item| match item { Item::Enum(item_enum) => { let ast_result = ASTResult::new(); let attrs = flowy_ast::enum_from_ast( &ast_result, &item_enum.ident, &item_enum.variants, &item_enum.attrs, ); ast_result.check().unwrap(); attrs .iter() .filter(|attr| !attr.attrs.event_attrs.ignore) .map(|variant| EventASTContext::from(&variant.attrs)) .collect::>() }, _ => vec![], }) .collect::>() }) .collect::>() } pub fn ast_to_event_render_ctx(ast: &[EventASTContext]) -> Vec { ast .iter() .map(|event_ast| { let input_deserializer = event_ast .event_input .as_ref() .map(|event_input| event_input.get_ident().unwrap().to_string()); let output_deserializer = event_ast .event_output .as_ref() .map(|event_output| event_output.get_ident().unwrap().to_string()); EventRenderContext { input_deserializer, output_deserializer, error_deserializer: event_ast.event_error.clone(), event: event_ast.event.to_string(), event_ty: event_ast.event_ty.to_string(), } }) .collect::>() } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.rs ================================================ use crate::util::get_tera; use tera::Context; pub struct EventTemplate { tera_context: Context, } pub struct EventRenderContext { pub input_deserializer: Option, pub output_deserializer: Option, pub error_deserializer: String, pub event: String, pub event_ty: String, } #[allow(dead_code)] impl EventTemplate { pub fn new() -> Self { EventTemplate { tera_context: Context::new(), } } pub fn render(&mut self, ctx: EventRenderContext, index: usize) -> Option { self.tera_context.insert("index", &index); let dart_class_name = format!("{}{}", ctx.event_ty, ctx.event); let event = format!("{}.{}", ctx.event_ty, ctx.event); self.tera_context.insert("event_class", &dart_class_name); self.tera_context.insert("event", &event); self .tera_context .insert("has_input", &ctx.input_deserializer.is_some()); match ctx.input_deserializer { None => self.tera_context.insert("input_deserializer", "void"), Some(ref input) => self.tera_context.insert("input_deserializer", input), } // eprintln!( // "😁 {:?} / {:?}", // &ctx.input_deserializer, &ctx.output_deserializer // ); let has_output = ctx.output_deserializer.is_some(); self.tera_context.insert("has_output", &has_output); match ctx.output_deserializer { None => self.tera_context.insert("output_deserializer", "void"), Some(ref output) => self.tera_context.insert("output_deserializer", output), } self .tera_context .insert("error_deserializer", &ctx.error_deserializer); let tera = get_tera("dart_event"); match tera.render("event_template.tera", &self.tera_context) { Ok(r) => Some(r), Err(e) => { log::error!("{:?}", e); None }, } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/event_template.tera ================================================ class {{ event_class }} { {%- if has_input %} {{ input_deserializer }} request; {{ event_class }}(this.request); {%- else %} {{ event_class }}(); {%- endif %} Future> send() { {%- if has_input %} final request = FFIRequest.create() ..event = {{ event }}.toString() ..payload = requestToBytes(this.request); return Dispatch.asyncRequest(request) .then((bytesResult) => bytesResult.fold( {%- if has_output %} (okBytes) => FlowySuccess({{ output_deserializer }}.fromBuffer(okBytes)), {%- else %} (bytes) => FlowySuccess(null), {%- endif %} (errBytes) => FlowyFailure({{ error_deserializer }}.fromBuffer(errBytes)), )); {%- else %} final request = FFIRequest.create() ..event = {{ event }}.toString(); {%- if has_input %} ..payload = bytes; {%- endif %} return Dispatch.asyncRequest(request).then((bytesResult) => bytesResult.fold( {%- if has_output %} (okBytes) => FlowySuccess({{ output_deserializer }}.fromBuffer(okBytes)), {%- else %} (bytes) => FlowySuccess(null), {%- endif %} (errBytes) => FlowyFailure({{ error_deserializer }}.fromBuffer(errBytes)), )); {%- endif %} } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/dart_event/mod.rs ================================================ #![allow(clippy::module_inception)] mod dart_event; mod event_template; pub use dart_event::*; ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/flowy_toml.rs ================================================ use std::fs; use std::path::{Path, PathBuf}; #[derive(serde::Deserialize, Clone, Debug)] pub struct FlowyConfig { #[serde(default)] pub event_files: Vec, // Collect AST from the file or directory specified by proto_input to generate the proto files. #[serde(default)] pub proto_input: Vec, // Output path for the generated proto files. The default value is default_proto_output() #[serde(default = "default_proto_output")] pub proto_output: String, // Create a crate that stores the generated protobuf Rust structures. The default value is default_protobuf_crate() #[serde(default = "default_protobuf_crate")] pub protobuf_crate_path: String, } fn default_proto_output() -> String { let mut path = PathBuf::from("resources"); path.push("proto"); path.to_str().unwrap().to_owned() } fn default_protobuf_crate() -> String { let mut path = PathBuf::from("src"); path.push("protobuf"); path.to_str().unwrap().to_owned() } impl FlowyConfig { pub fn from_toml_file(path: &Path) -> Self { let content = fs::read_to_string(path).unwrap(); let config: FlowyConfig = toml::from_str(content.as_ref()).unwrap(); config } } pub struct CrateConfig { pub crate_path: PathBuf, pub crate_folder: String, pub flowy_config: FlowyConfig, } pub fn parse_crate_config_from(entry: &walkdir::DirEntry) -> Option { let mut config_path = entry.path().parent().unwrap().to_path_buf(); config_path.push("Flowy.toml"); if !config_path.as_path().exists() { return None; } let crate_path = entry.path().parent().unwrap().to_path_buf(); let flowy_config = FlowyConfig::from_toml_file(config_path.as_path()); let crate_folder = crate_path .file_stem() .unwrap() .to_str() .unwrap() .to_string(); Some(CrateConfig { crate_path, crate_folder, flowy_config, }) } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/lib.rs ================================================ #[cfg(feature = "proto_gen")] pub mod protobuf_file; #[cfg(feature = "dart_event")] pub mod dart_event; #[cfg(feature = "ts_event")] pub mod ts_event; #[cfg(any(feature = "proto_gen", feature = "dart_event", feature = "ts_event"))] mod flowy_toml; pub(crate) mod ast; #[cfg(any(feature = "proto_gen", feature = "dart_event", feature = "ts_event"))] pub mod util; #[derive(serde::Serialize, serde::Deserialize)] pub struct ProtoCache { pub structs: Vec, pub enums: Vec, } pub enum Project { Tauri, TauriApp, Native, } impl Project { pub fn dst(&self) -> String { match self { Project::Tauri => "appflowy_tauri/src/services/backend".to_string(), Project::TauriApp => { "appflowy_web_app/src/application/services/tauri-services/backend".to_string() }, Project::Native => panic!("Native project is not supported yet."), } } pub fn event_root(&self) -> String { match self { Project::Tauri | Project::TauriApp => "../../".to_string(), Project::Native => panic!("Native project is not supported yet."), } } pub fn model_root(&self) -> String { match self { Project::Tauri | Project::TauriApp => "../../".to_string(), Project::Native => panic!("Native project is not supported yet."), } } pub fn event_imports(&self) -> String { match self { Project::TauriApp | Project::Tauri => r#" /// Auto generate. Do not edit import { Ok, Err, Result } from "ts-results"; import { invoke } from "@tauri-apps/api/tauri"; import * as pb from "../.."; "# .to_string(), Project::Native => panic!("Native project is not supported yet."), } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/ast.rs ================================================ #![allow(unused_attributes)] #![allow(dead_code)] #![allow(unused_imports)] #![allow(unused_results)] use crate::protobuf_file::template::{EnumTemplate, RUST_TYPE_MAP, StructTemplate}; use crate::protobuf_file::{ProtoFile, ProtobufCrateContext, parse_crate_info_from_path}; use crate::util::*; use fancy_regex::Regex; use flowy_ast::*; use lazy_static::lazy_static; use std::path::PathBuf; use std::{fs::File, io::Read, path::Path}; use syn::Item; use walkdir::WalkDir; pub fn parse_protobuf_context_from(crate_paths: Vec) -> Vec { let crate_infos = parse_crate_info_from_path(crate_paths); crate_infos .into_iter() .map(|crate_info| { let proto_output_path = crate_info.proto_output_path(); let files = crate_info .proto_input_paths() .iter() .flat_map(|proto_crate_path| parse_files_protobuf(proto_crate_path, &proto_output_path)) .collect::>(); ProtobufCrateContext::from_crate_info(crate_info, files) }) .collect::>() } fn parse_files_protobuf(proto_crate_path: &Path, proto_output_path: &Path) -> Vec { let mut gen_proto_vec: Vec = vec![]; // file_stem https://doc.rust-lang.org/std/path/struct.Path.html#method.file_stem for (path, file_name) in WalkDir::new(proto_crate_path) .into_iter() .filter_entry(|e| !is_hidden(e)) .filter_map(|e| e.ok()) .filter(|e| !e.file_type().is_dir()) .map(|e| { let path = e.path().to_str().unwrap().to_string(); let file_name = e.path().file_stem().unwrap().to_str().unwrap().to_string(); (path, file_name) }) { if file_name == "mod" { continue; } // https://docs.rs/syn/1.0.54/syn/struct.File.html let ast = syn::parse_file(read_file(&path).unwrap().as_ref()) .unwrap_or_else(|_| panic!("Unable to parse file at {}", path)); let structs = get_ast_structs(&ast); let proto_file = format!("{}.proto", &file_name); let proto_file_path = path_string_with_component(proto_output_path, vec![&proto_file]); let proto_syntax = find_proto_syntax(proto_file_path.as_ref()); let mut proto_content = String::new(); // The types that are not defined in the current file. let mut ref_types: Vec = vec![]; structs.iter().for_each(|s| { let mut struct_template = StructTemplate::new(); struct_template.set_message_struct_name(&s.name); s.fields .iter() .filter(|field| field.pb_attrs.pb_index().is_some()) .for_each(|field| { ref_types.push(field.ty_as_str()); struct_template.set_field(field); }); let s = struct_template.render().unwrap(); proto_content.push_str(s.as_ref()); proto_content.push('\n'); }); let enums = get_ast_enums(&ast); enums.iter().for_each(|e| { let mut enum_template = EnumTemplate::new(); enum_template.set_message_enum(e); let s = enum_template.render().unwrap(); proto_content.push_str(s.as_ref()); ref_types.push(e.name.clone()); proto_content.push('\n'); }); if !enums.is_empty() || !structs.is_empty() { let structs: Vec = structs.iter().map(|s| s.name.clone()).collect(); let enums: Vec = enums.iter().map(|e| e.name.clone()).collect(); ref_types.retain(|s| !structs.contains(s)); ref_types.retain(|s| !enums.contains(s)); let info = ProtoFile { file_path: path.clone(), file_name: file_name.clone(), ref_types, structs, enums, syntax: proto_syntax, content: proto_content, }; gen_proto_vec.push(info); } } gen_proto_vec } pub fn get_ast_structs(ast: &syn::File) -> Vec { // let mut content = format!("{:#?}", &ast); // let mut file = File::create("./foo.txt").unwrap(); // file.write_all(content.as_bytes()).unwrap(); let ast_result = ASTResult::new(); let mut proto_structs: Vec = vec![]; ast.items.iter().for_each(|item| { if let Item::Struct(item_struct) = item { let (_, fields) = struct_from_ast(&ast_result, &item_struct.fields); if fields .iter() .filter(|f| f.pb_attrs.pb_index().is_some()) .count() > 0 { proto_structs.push(Struct { name: item_struct.ident.to_string(), fields, }); } } }); ast_result.check().unwrap(); proto_structs } pub fn get_ast_enums(ast: &syn::File) -> Vec { let mut flowy_enums: Vec = vec![]; let ast_result = ASTResult::new(); ast.items.iter().for_each(|item| { // https://docs.rs/syn/1.0.54/syn/enum.Item.html if let Item::Enum(item_enum) = item { let attrs = flowy_ast::enum_from_ast( &ast_result, &item_enum.ident, &item_enum.variants, &ast.attrs, ); flowy_enums.push(FlowyEnum { name: item_enum.ident.to_string(), attrs, }); } }); ast_result.check().unwrap(); flowy_enums } pub struct FlowyEnum<'a> { pub name: String, pub attrs: Vec>, } pub struct Struct<'a> { pub name: String, pub fields: Vec>, } lazy_static! { static ref SYNTAX_REGEX: Regex = Regex::new("syntax.*;").unwrap(); // static ref IMPORT_REGEX: Regex = Regex::new("(import\\s).*;").unwrap(); } fn find_proto_syntax(path: &str) -> String { if !Path::new(path).exists() { return String::from("syntax = \"proto3\";\n"); } let mut result = String::new(); let mut file = File::open(path).unwrap(); let mut content = String::new(); file.read_to_string(&mut content).unwrap(); content.lines().for_each(|line| { ////Result>> if let Ok(Some(m)) = SYNTAX_REGEX.find(line) { result.push_str(m.as_str()); } // if let Ok(Some(m)) = IMPORT_REGEX.find(line) { // result.push_str(m.as_str()); // result.push('\n'); // } }); result.push('\n'); result } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/mod.rs ================================================ #![allow(unused_imports)] #![allow(unused_attributes)] #![allow(dead_code)] mod ast; mod proto_gen; mod proto_info; mod template; use crate::Project; use crate::util::path_string_with_component; use itertools::Itertools; use log::info; pub use proto_gen::*; pub use proto_info::*; use std::fs; use std::fs::File; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use walkdir::WalkDir; pub fn dart_gen(crate_name: &str) { // 1. generate the proto files to proto_file_dir #[cfg(feature = "proto_gen")] let proto_crates = gen_proto_files(crate_name); for proto_crate in proto_crates { let mut proto_file_paths = vec![]; let mut file_names = vec![]; let proto_file_output_path = proto_crate .proto_output_path() .to_str() .unwrap() .to_string(); let protobuf_output_path = proto_crate .protobuf_crate_path() .to_str() .unwrap() .to_string(); for (path, file_name) in WalkDir::new(&proto_file_output_path) .into_iter() .filter_map(|e| e.ok()) .map(|e| { let path = e.path().to_str().unwrap().to_string(); let file_name = e.path().file_stem().unwrap().to_str().unwrap().to_string(); (path, file_name) }) { if path.ends_with(".proto") { // https://stackoverflow.com/questions/49077147/how-can-i-force-build-rs-to-run-again-without-cleaning-my-whole-project println!("cargo:rerun-if-changed={}", path); proto_file_paths.push(path); file_names.push(file_name); } } let protoc_bin_path = protoc_bin_vendored::protoc_bin_path().unwrap(); // 2. generate the protobuf files(Dart) #[cfg(feature = "dart")] generate_dart_protobuf_files( crate_name, &proto_file_output_path, &proto_file_paths, &file_names, &protoc_bin_path, ); // 3. generate the protobuf files(Rust) generate_rust_protobuf_files( &protoc_bin_path, &proto_file_paths, &proto_file_output_path, &protobuf_output_path, ); } } // #[allow(unused_variables)] // fn ts_gen(crate_name: &str, dest_folder_name: &str, project: Project) { // // 1. generate the proto files to proto_file_dir // #[cfg(feature = "proto_gen")] // let proto_crates = gen_proto_files(crate_name); // // for proto_crate in proto_crates { // let mut proto_file_paths = vec![]; // let mut file_names = vec![]; // let proto_file_output_path = proto_crate // .proto_output_path() // .to_str() // .unwrap() // .to_string(); // let protobuf_output_path = proto_crate // .protobuf_crate_path() // .to_str() // .unwrap() // .to_string(); // // for (path, file_name) in WalkDir::new(&proto_file_output_path) // .into_iter() // .filter_map(|e| e.ok()) // .map(|e| { // let path = e.path().to_str().unwrap().to_string(); // let file_name = e.path().file_stem().unwrap().to_str().unwrap().to_string(); // (path, file_name) // }) // { // if path.ends_with(".proto") { // // https://stackoverflow.com/questions/49077147/how-can-i-force-build-rs-to-run-again-without-cleaning-my-whole-project // println!("cargo:rerun-if-changed={}", path); // proto_file_paths.push(path); // file_names.push(file_name); // } // } // let protoc_bin_path = protoc_bin_vendored::protoc_bin_path().unwrap(); // // // 2. generate the protobuf files(Dart) // #[cfg(feature = "ts")] // generate_ts_protobuf_files( // dest_folder_name, // &proto_file_output_path, // &proto_file_paths, // &file_names, // &protoc_bin_path, // &project, // ); // // // 3. generate the protobuf files(Rust) // generate_rust_protobuf_files( // &protoc_bin_path, // &proto_file_paths, // &proto_file_output_path, // &protobuf_output_path, // ); // } // } fn generate_rust_protobuf_files( protoc_bin_path: &Path, proto_file_paths: &[String], proto_file_output_path: &str, protobuf_output_path: &str, ) { protoc_rust::Codegen::new() .out_dir(protobuf_output_path) .protoc_path(protoc_bin_path) .inputs(proto_file_paths) .include(proto_file_output_path) .run() .expect("Running rust protoc failed."); remove_box_pointers_lint_from_all_except_mod(protobuf_output_path); } fn remove_box_pointers_lint_from_all_except_mod(dir_path: &str) { let dir = fs::read_dir(dir_path).expect("Failed to read directory"); for entry in dir { let entry = entry.expect("Failed to read directory entry"); let path = entry.path(); // Skip directories and mod.rs if path.is_file() { if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) { if file_name != "mod.rs" { remove_box_pointers_lint(&path); } } } } } fn remove_box_pointers_lint(file_path: &Path) { let file = File::open(file_path).expect("Failed to open file"); let reader = BufReader::new(file); let lines: Vec = reader .lines() .map_while(Result::ok) .filter(|line| !line.contains("#![allow(box_pointers)]")) .collect(); let mut file = File::create(file_path).expect("Failed to create file"); for line in lines { writeln!(file, "{}", line).expect("Failed to write line"); } } #[cfg(feature = "ts")] fn generate_ts_protobuf_files( name: &str, proto_file_output_path: &str, paths: &[String], file_names: &Vec, protoc_bin_path: &Path, project: &Project, ) { let root = project.model_root(); let backend_service_path = project.dst(); let mut output = PathBuf::new(); output.push(root); output.push(backend_service_path); output.push("models"); output.push(name); if !output.as_path().exists() { std::fs::create_dir_all(&output).unwrap(); } let protoc_bin_path = protoc_bin_path.to_str().unwrap().to_owned(); paths.iter().for_each(|path| { // if let Err(err) = Command::new(protoc_bin_path.clone()) // .arg(format!("--ts_out={}", output.to_str().unwrap())) // .arg(format!("--proto_path={}", proto_file_output_path)) // .arg(path) // .spawn() // { // panic!("Generate ts pb file failed: {}, {:?}", path, err); // } println!("cargo:rerun-if-changed={}", output.to_str().unwrap()); let result = cmd_lib::run_cmd! { ${protoc_bin_path} --ts_out=${output} --proto_path=${proto_file_output_path} ${path} }; if result.is_err() { panic!("Generate ts pb file failed with: {}, {:?}", path, result) }; }); let ts_index = path_string_with_component(&output, vec!["index.ts"]); match std::fs::OpenOptions::new() .create(true) .write(true) .append(false) .truncate(true) .open(ts_index) { Ok(ref mut file) => { let mut export = String::new(); export.push_str("// Auto-generated, do not edit \n"); for file_name in file_names { let c = format!("export * from \"./{}\";\n", file_name); export.push_str(c.as_ref()); } file.write_all(export.as_bytes()).unwrap(); File::flush(file).unwrap(); }, Err(err) => { panic!("Failed to open file: {}", err); }, } } #[cfg(feature = "dart")] fn generate_dart_protobuf_files( name: &str, proto_file_output_path: &str, paths: &[String], file_names: &Vec, protoc_bin_path: &Path, ) { if std::env::var("CARGO_MAKE_WORKING_DIRECTORY").is_err() { log::error!("CARGO_MAKE_WORKING_DIRECTORY was not set, skip generate dart pb"); return; } if std::env::var("FLUTTER_FLOWY_SDK_PATH").is_err() { log::error!("FLUTTER_FLOWY_SDK_PATH was not set, skip generate dart pb"); return; } let mut output = PathBuf::new(); output.push(std::env::var("CARGO_MAKE_WORKING_DIRECTORY").unwrap()); output.push(std::env::var("FLUTTER_FLOWY_SDK_PATH").unwrap()); output.push("lib"); output.push("protobuf"); output.push(name); if !output.as_path().exists() { std::fs::create_dir_all(&output).unwrap(); } check_pb_dart_plugin(); let protoc_bin_path = protoc_bin_path.to_str().unwrap().to_owned(); paths.iter().for_each(|path| { let result = cmd_lib::run_cmd! { ${protoc_bin_path} --dart_out=${output} --proto_path=${proto_file_output_path} ${path} }; if result.is_err() { panic!("Generate dart pb file failed with: {}, {:?}", path, result) }; }); let protobuf_dart = path_string_with_component(&output, vec!["protobuf.dart"]); println!("cargo:rerun-if-changed={}", protobuf_dart); match std::fs::OpenOptions::new() .create(true) .write(true) .append(false) .truncate(true) .open(Path::new(&protobuf_dart)) { Ok(ref mut file) => { let mut export = String::new(); export.push_str("// Auto-generated, do not edit \n"); for file_name in file_names { let c = format!("export './{}.pb.dart';\n", file_name); export.push_str(c.as_ref()); } file.write_all(export.as_bytes()).unwrap(); File::flush(file).unwrap(); }, Err(err) => { panic!("Failed to open file: {}", err); }, } } pub fn check_pb_dart_plugin() { if cfg!(target_os = "windows") { //Command::new("cmd") // .arg("/C") // .arg(cmd) // .status() // .expect("failed to execute process"); //panic!("{}", format!("\n❌ The protoc-gen-dart was not installed correctly.")) } else { let exit_result = Command::new("sh") .arg("-c") .arg("command -v protoc-gen-dart") .status() .expect("failed to execute process"); if !exit_result.success() { let mut msg = "\n❌ Can't find protoc-gen-dart in $PATH:\n".to_string(); let output = Command::new("sh").arg("-c").arg("echo $PATH").output(); let paths = String::from_utf8(output.unwrap().stdout) .unwrap() .split(':') .map(|s| s.to_string()) .collect::>(); paths.iter().for_each(|s| msg.push_str(&format!("{}\n", s))); if let Ok(output) = Command::new("sh") .arg("-c") .arg("which protoc-gen-dart") .output() { msg.push_str(&format!( "Installed protoc-gen-dart path: {:?}\n", String::from_utf8(output.stdout).unwrap() )); } msg.push_str("✅ You can fix that by adding:"); msg.push_str("\n\texport PATH=\"$PATH\":\"$HOME/.pub-cache/bin\"\n"); msg.push_str("to your shell's config file.(.bashrc, .bash, .profile, .zshrc etc.)"); panic!("{}", msg) } } } #[cfg(feature = "proto_gen")] pub fn gen_proto_files(crate_name: &str) -> Vec { let crate_path = std::fs::canonicalize(".") .unwrap() .as_path() .display() .to_string(); let crate_context = ProtoGenerator::r#gen(crate_name, &crate_path); let proto_crates = crate_context .iter() .map(|info| info.protobuf_crate.clone()) .collect::>(); crate_context .into_iter() .flat_map(|info| info.files) .for_each(|file| { println!("cargo:rerun-if-changed={}", file.file_path); }); proto_crates } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/proto_gen.rs ================================================ #![allow(unused_attributes)] #![allow(dead_code)] #![allow(unused_imports)] #![allow(unused_results)] use crate::ProtoCache; use crate::protobuf_file::ProtoFile; use crate::protobuf_file::ast::parse_protobuf_context_from; use crate::protobuf_file::proto_info::ProtobufCrateContext; use crate::util::*; use std::collections::HashMap; use std::fs::File; use std::path::Path; use std::{fs::OpenOptions, io::Write}; pub struct ProtoGenerator(); impl ProtoGenerator { pub fn r#gen(crate_name: &str, crate_path: &str) -> Vec { let crate_contexts = parse_protobuf_context_from(vec![crate_path.to_owned()]); write_proto_files(&crate_contexts); write_rust_crate_mod_file(&crate_contexts); let proto_cache = ProtoCache::from_crate_contexts(&crate_contexts); let proto_cache_str = serde_json::to_string(&proto_cache).unwrap(); let crate_cache_dir = path_buf_with_component(&cache_dir(), vec![crate_name]); if !crate_cache_dir.as_path().exists() { std::fs::create_dir_all(&crate_cache_dir).unwrap(); } let protobuf_cache_path = path_string_with_component(&crate_cache_dir, vec!["proto_cache"]); match std::fs::OpenOptions::new() .create(true) .write(true) .append(false) .truncate(true) .open(&protobuf_cache_path) { Ok(ref mut file) => { file.write_all(proto_cache_str.as_bytes()).unwrap(); File::flush(file).unwrap(); }, Err(_err) => { panic!("Failed to open file: {}", protobuf_cache_path); }, } crate_contexts } } fn write_proto_files(crate_contexts: &[ProtobufCrateContext]) { let file_path_content_map = crate_contexts .iter() .flat_map(|ctx| { ctx .files .iter() .map(|file| { ( file.file_path.clone(), ProtoFileSymbol { file_name: file.file_name.clone(), symbols: file.symbols(), }, ) }) .collect::>() }) .collect::>(); for context in crate_contexts { let dir = context.protobuf_crate.proto_output_path(); context.files.iter().for_each(|file| { // syntax let mut file_content = file.syntax.clone(); // import file_content.push_str(&gen_import_content(file, &file_path_content_map)); // content file_content.push_str(&file.content); let proto_file = format!("{}.proto", &file.file_name); let proto_file_path = path_string_with_component(&dir, vec![&proto_file]); save_content_to_file_with_diff_prompt(&file_content, proto_file_path.as_ref()); }); } } fn gen_import_content( current_file: &ProtoFile, file_path_symbols_map: &HashMap, ) -> String { let mut import_files: Vec = vec![]; file_path_symbols_map .iter() .for_each(|(file_path, proto_file_symbols)| { if file_path != ¤t_file.file_path { current_file.ref_types.iter().for_each(|ref_type| { if proto_file_symbols.symbols.contains(ref_type) { let import_file = format!("import \"{}.proto\";", proto_file_symbols.file_name); if !import_files.contains(&import_file) { import_files.push(import_file); } } }); } }); if import_files.len() == 1 { format!("{}\n", import_files.pop().unwrap()) } else { import_files.join("\n") } } struct ProtoFileSymbol { file_name: String, symbols: Vec, } fn write_rust_crate_mod_file(crate_contexts: &[ProtobufCrateContext]) { for context in crate_contexts { let mod_path = context.protobuf_crate.proto_model_mod_file(); match OpenOptions::new() .create(true) .write(true) .append(false) .truncate(true) .open(&mod_path) { Ok(ref mut file) => { let mut mod_file_content = String::new(); mod_file_content.push_str("#![cfg_attr(rustfmt, rustfmt::skip)]\n"); mod_file_content.push_str(" #![allow(ambiguous_glob_reexports)]\n"); mod_file_content.push_str("// Auto-generated, do not edit\n"); walk_dir( context.protobuf_crate.proto_output_path(), |e| !e.file_type().is_dir() && !e.file_name().to_string_lossy().starts_with('.'), |_, name| { let c = format!("\nmod {};\npub use {}::*;\n", &name, &name); mod_file_content.push_str(c.as_ref()); }, ); file.write_all(mod_file_content.as_bytes()).unwrap(); }, Err(err) => { panic!("Failed to open file: {}", err); }, } } } impl ProtoCache { fn from_crate_contexts(crate_contexts: &[ProtobufCrateContext]) -> Self { let proto_files = crate_contexts .iter() .flat_map(|crate_info| &crate_info.files) .collect::>(); let structs: Vec = proto_files .iter() .flat_map(|info| info.structs.clone()) .collect(); let enums: Vec = proto_files .iter() .flat_map(|info| info.enums.clone()) .collect(); Self { structs, enums } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/proto_info.rs ================================================ #![allow(dead_code)] use crate::flowy_toml::{CrateConfig, FlowyConfig, parse_crate_config_from}; use crate::util::*; use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; use std::str::FromStr; use walkdir::WalkDir; #[derive(Debug)] pub struct ProtobufCrateContext { pub files: Vec, pub protobuf_crate: ProtobufCrate, } impl ProtobufCrateContext { pub fn from_crate_info(inner: ProtobufCrate, files: Vec) -> Self { Self { files, protobuf_crate: inner, } } pub fn create_crate_mod_file(&self) { // mod model; // pub use model::*; let mod_file_path = path_string_with_component(&self.protobuf_crate.protobuf_crate_path(), vec!["mod.rs"]); let mut content = "#![cfg_attr(rustfmt, rustfmt::skip)]\n".to_owned(); content.push_str(" #![allow(ambiguous_glob_reexports)]\n"); content.push_str("// Auto-generated, do not edit\n"); content.push_str("mod model;\npub use model::*;"); match OpenOptions::new() .create(true) .write(true) .append(false) .truncate(true) .open(Path::new(&mod_file_path)) { Ok(ref mut file) => { file.write_all(content.as_bytes()).unwrap(); }, Err(err) => { panic!("Failed to open protobuf mod file: {}", err); }, } } #[allow(dead_code)] pub fn flutter_mod_dir(&self, root: &str) -> String { let crate_module_dir = format!("{}/{}", root, self.protobuf_crate.crate_folder); crate_module_dir } #[allow(dead_code)] pub fn flutter_mod_file(&self, root: &str) -> String { let crate_module_dir = format!( "{}/{}/protobuf.dart", root, self.protobuf_crate.crate_folder ); crate_module_dir } } #[derive(Clone, Debug)] pub struct ProtobufCrate { pub crate_folder: String, pub crate_path: PathBuf, flowy_config: FlowyConfig, } impl ProtobufCrate { pub fn from_config(config: CrateConfig) -> Self { ProtobufCrate { crate_path: config.crate_path, crate_folder: config.crate_folder, flowy_config: config.flowy_config, } } // Return the file paths for each rust file that used to generate the proto file. pub fn proto_input_paths(&self) -> Vec { self .flowy_config .proto_input .iter() .map(|name| path_buf_with_component(&self.crate_path, vec![name])) .collect::>() } // The protobuf_crate_path is used to store the generated protobuf Rust structures. pub fn protobuf_crate_path(&self) -> PathBuf { let crate_path = PathBuf::from(&self.flowy_config.protobuf_crate_path); create_dir_if_not_exist(&crate_path); crate_path } // The proto_output_path is used to store the proto files pub fn proto_output_path(&self) -> PathBuf { let output_dir = PathBuf::from(&self.flowy_config.proto_output); create_dir_if_not_exist(&output_dir); output_dir } pub fn proto_model_mod_file(&self) -> String { path_string_with_component(&self.protobuf_crate_path(), vec!["mod.rs"]) } } #[derive(Debug)] pub struct ProtoFile { pub file_path: String, pub file_name: String, pub structs: Vec, // store the type of current file using pub ref_types: Vec, pub enums: Vec, // proto syntax. "proto3" or "proto2" pub syntax: String, // proto message content pub content: String, } impl ProtoFile { pub fn symbols(&self) -> Vec { let mut symbols = self.structs.clone(); let mut enum_symbols = self.enums.clone(); symbols.append(&mut enum_symbols); symbols } } pub fn parse_crate_info_from_path(roots: Vec) -> Vec { let mut protobuf_crates: Vec = vec![]; roots.iter().for_each(|root| { let crates = WalkDir::new(root) .into_iter() .filter_entry(|e| !is_hidden(e)) .filter_map(|e| e.ok()) .filter(is_crate_dir) .flat_map(|e| parse_crate_config_from(&e)) .map(ProtobufCrate::from_config) .collect::>(); protobuf_crates.extend(crates); }); protobuf_crates } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/template/derive_meta/derive_meta.rs ================================================ use crate::util::get_tera; use itertools::Itertools; use tera::Context; pub struct ProtobufDeriveMeta { context: Context, structs: Vec, enums: Vec, } #[allow(dead_code)] impl ProtobufDeriveMeta { pub fn new(structs: Vec, enums: Vec) -> Self { let enums: Vec<_> = enums.into_iter().unique().collect(); ProtobufDeriveMeta { context: Context::new(), structs, enums, } } pub fn render(&mut self) -> Option { self.context.insert("names", &self.structs); self.context.insert("enums", &self.enums); let tera = get_tera("protobuf_file/template/derive_meta"); match tera.render("derive_meta.tera", &self.context) { Ok(r) => Some(r), Err(e) => { log::error!("{:?}", e); None }, } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/template/derive_meta/derive_meta.tera ================================================ #![cfg_attr(rustfmt, rustfmt::skip)] pub enum TypeCategory { Array, Map, Str, Protobuf, Bytes, Enum, Opt, Primitive, } // auto generate, do not edit pub fn category_from_str(type_str: &str) -> TypeCategory { match type_str { "Vec" => TypeCategory::Array, "HashMap" => TypeCategory::Map, "u8" => TypeCategory::Bytes, "String" => TypeCategory::Str, {%- for name in names -%} {%- if loop.first %} "{{ name }}" {%- else %} | "{{ name }}" {%- endif -%} {%- if loop.last %} => TypeCategory::Protobuf, {%- endif %} {%- endfor %} {%- for enum in enums -%} {%- if loop.first %} "{{ enum }}" {%- else %} | "{{ enum }}" {%- endif -%} {%- if loop.last %} => TypeCategory::Enum, {%- endif %} {%- endfor %} "Option" => TypeCategory::Opt, _ => TypeCategory::Primitive, } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/template/derive_meta/mod.rs ================================================ #![allow(clippy::module_inception)] mod derive_meta; pub use derive_meta::*; ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/template/mod.rs ================================================ mod derive_meta; mod proto_file; pub use derive_meta::*; pub use proto_file::*; ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/template/proto_file/enum.tera ================================================ enum {{ enum_name }} { {%- for item in items %} {{ item }} {%- endfor %} } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/template/proto_file/enum_template.rs ================================================ use crate::protobuf_file::ast::FlowyEnum; use crate::util::get_tera; use tera::Context; pub struct EnumTemplate { context: Context, items: Vec, } #[allow(dead_code)] impl EnumTemplate { pub fn new() -> Self { EnumTemplate { context: Context::new(), items: vec![], } } pub fn set_message_enum(&mut self, flowy_enum: &FlowyEnum) { self.context.insert("enum_name", &flowy_enum.name); flowy_enum.attrs.iter().for_each(|item| { self.items.push(format!( "{} = {};", item.attrs.enum_item_name, item.attrs.value )) }) } pub fn render(&mut self) -> Option { self.context.insert("items", &self.items); let tera = get_tera("protobuf_file/template/proto_file"); match tera.render("enum.tera", &self.context) { Ok(r) => Some(r), Err(e) => { log::error!("{:?}", e); None }, } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/template/proto_file/mod.rs ================================================ mod enum_template; mod struct_template; pub use enum_template::*; pub use struct_template::*; ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/template/proto_file/struct.tera ================================================ message {{ struct_name }} { {%- for field in fields %} {{ field }} {%- endfor %} } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/protobuf_file/template/proto_file/struct_template.rs ================================================ use crate::util::get_tera; use flowy_ast::*; use phf::phf_map; use tera::Context; // Protobuf data type : https://developers.google.com/protocol-buffers/docs/proto3 pub static RUST_TYPE_MAP: phf::Map<&'static str, &'static str> = phf_map! { "String" => "string", "i64" => "int64", "i32" => "int32", "u64" => "uint64", "u32" => "uint32", "Vec" => "repeated", "f64" => "double", "HashMap" => "map", }; pub struct StructTemplate { context: Context, fields: Vec, } #[allow(dead_code)] impl StructTemplate { pub fn new() -> Self { StructTemplate { context: Context::new(), fields: vec![], } } pub fn set_message_struct_name(&mut self, name: &str) { self.context.insert("struct_name", name); } pub fn set_field(&mut self, field: &ASTField) { // {{ field_type }} {{ field_name }} = {{index}}; let name = field.name().unwrap().to_string(); let index = field.pb_attrs.pb_index().unwrap(); let ty: &str = &field.ty_as_str(); let mut mapped_ty: &str = ty; if RUST_TYPE_MAP.contains_key(ty) { mapped_ty = RUST_TYPE_MAP[ty]; } if let Some(ref category) = field.bracket_category { match category { BracketCategory::Opt => match &field.bracket_inner_ty { None => {}, Some(inner_ty) => match inner_ty.to_string().as_str() { //TODO: support hashmap or something else wrapped by Option "Vec" => { self.fields.push(format!( "oneof one_of_{} {{ bytes {} = {}; }};", name, name, index )); }, _ => { self.fields.push(format!( "oneof one_of_{} {{ {} {} = {}; }};", name, mapped_ty, name, index )); }, }, }, BracketCategory::Map((k, v)) => { let key: &str = k; let value: &str = v; self.fields.push(format!( // map attrs = 1; "map<{}, {}> {} = {};", RUST_TYPE_MAP.get(key).unwrap_or(&key), RUST_TYPE_MAP.get(value).unwrap_or(&value), name, index )); }, BracketCategory::Vec => { let bracket_ty: &str = &field.bracket_ty.as_ref().unwrap().to_string(); // Vec if mapped_ty == "u8" && bracket_ty == "Vec" { self.fields.push(format!("bytes {} = {};", name, index)) } else { self.fields.push(format!( "{} {} {} = {};", RUST_TYPE_MAP[bracket_ty], mapped_ty, name, index )) } }, BracketCategory::Other => self .fields .push(format!("{} {} = {};", mapped_ty, name, index)), } } } pub fn render(&mut self) -> Option { self.context.insert("fields", &self.fields); let tera = get_tera("protobuf_file/template/proto_file"); match tera.render("struct.tera", &self.context) { Ok(r) => Some(r), Err(e) => { log::error!("{:?}", e); None }, } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/event_template.rs ================================================ use crate::util::get_tera; use tera::Context; pub struct EventTemplate { tera_context: Context, } pub struct EventRenderContext { pub input_deserializer: Option, pub output_deserializer: Option, pub error_deserializer: String, pub event: String, pub event_ty: String, pub prefix: String, } #[allow(dead_code)] impl EventTemplate { pub fn new() -> Self { EventTemplate { tera_context: Context::new(), } } pub fn render(&mut self, ctx: EventRenderContext, index: usize) -> Option { self.tera_context.insert("index", &index); let event_func_name = format!("{}{}", ctx.event_ty, ctx.event); self .tera_context .insert("event_func_name", &event_func_name); self .tera_context .insert("event_name", &format!("{}.{}", ctx.prefix, ctx.event_ty)); self.tera_context.insert("event", &ctx.event); self .tera_context .insert("has_input", &ctx.input_deserializer.is_some()); match ctx.input_deserializer { None => {}, Some(ref input) => self .tera_context .insert("input_deserializer", &format!("{}.{}", ctx.prefix, input)), } let has_output = ctx.output_deserializer.is_some(); self.tera_context.insert("has_output", &has_output); match ctx.output_deserializer { None => self.tera_context.insert("output_deserializer", "void"), Some(ref output) => self .tera_context .insert("output_deserializer", &format!("{}.{}", ctx.prefix, output)), } self.tera_context.insert( "error_deserializer", &format!("{}.{}", ctx.prefix, ctx.error_deserializer), ); let tera = get_tera("ts_event"); match tera.render("event_template.tera", &self.tera_context) { Ok(r) => Some(r), Err(e) => { log::error!("{:?}", e); None }, } } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/event_template.tera ================================================ {%- if has_input %} export async function {{ event_func_name }}(payload: {{ input_deserializer }}): Promise> { {%- else %} export async function {{ event_func_name }}(): Promise> { {%- endif %} {%- if has_input %} let args = { request: { ty: {{ event_name }}[{{ event_name }}.{{ event }}], payload: Array.from(payload.serializeBinary()), }, }; {%- else %} let args = { request: { ty: {{ event_name }}[{{ event_name }}.{{ event }}], payload: Array.from([]), }, }; {%- endif %} let result: { code: number; payload: Uint8Array } = await invoke("invoke_request", args); if (result.code == 0) { {%- if has_output %} let object = {{ output_deserializer }}.deserializeBinary(result.payload); return Ok(object); {%- else %} return Ok.EMPTY; {%- endif %} } else { let error = {{ error_deserializer }}.deserializeBinary(result.payload); console.log({{ event_func_name }}.name, error); return Err(error); } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/ts_event/mod.rs ================================================ mod event_template; use crate::Project; use crate::ast::EventASTContext; use crate::flowy_toml::{CrateConfig, parse_crate_config_from}; use crate::ts_event::event_template::{EventRenderContext, EventTemplate}; use crate::util::{is_crate_dir, is_hidden, path_string_with_component, read_file}; use flowy_ast::ASTResult; use std::collections::HashSet; use std::fs::File; use std::io::Write; use std::path::PathBuf; use syn::Item; use walkdir::WalkDir; pub fn r#gen(dest_folder_name: &str, project: Project) { let root = project.event_root(); let backend_service_path = project.dst(); let crate_path = std::fs::canonicalize(".") .unwrap() .as_path() .display() .to_string(); let event_crates = parse_ts_event_files(vec![crate_path]); let event_ast = event_crates .iter() .flat_map(parse_event_crate) .collect::>(); let event_render_ctx = ast_to_event_render_ctx(event_ast.as_ref()); let mut render_result = project.event_imports(); for (index, render_ctx) in event_render_ctx.into_iter().enumerate() { let mut event_template = EventTemplate::new(); if let Some(content) = event_template.render(render_ctx, index) { render_result.push_str(content.as_ref()) } } render_result.push_str(TS_FOOTER); let ts_event_folder: PathBuf = [&root, &backend_service_path, "events", dest_folder_name] .iter() .collect(); if !ts_event_folder.as_path().exists() { std::fs::create_dir_all(ts_event_folder.as_path()).unwrap(); } let event_file = "event"; let event_file_ext = "ts"; let ts_event_file_path = path_string_with_component( &ts_event_folder, vec![&format!("{}.{}", event_file, event_file_ext)], ); println!("cargo:rerun-if-changed={}", ts_event_file_path); match std::fs::OpenOptions::new() .create(true) .write(true) .append(false) .truncate(true) .open(&ts_event_file_path) { Ok(ref mut file) => { file.write_all(render_result.as_bytes()).unwrap(); File::flush(file).unwrap(); }, Err(err) => { panic!("Failed to open file: {}, {:?}", ts_event_file_path, err); }, } let ts_index = path_string_with_component(&ts_event_folder, vec!["index.ts"]); match std::fs::OpenOptions::new() .create(true) .write(true) .append(false) .truncate(true) .open(ts_index) { Ok(ref mut file) => { let mut export = String::new(); export.push_str("// Auto-generated, do not edit \n"); export.push_str(&format!( "export * from '../../models/{}';\n", dest_folder_name )); export.push_str(&format!("export * from './{}';\n", event_file)); file.write_all(export.as_bytes()).unwrap(); File::flush(file).unwrap(); }, Err(err) => { panic!("Failed to open file: {}", err); }, } } #[derive(Debug)] pub struct TsEventCrate { crate_path: PathBuf, event_files: Vec, } impl TsEventCrate { pub fn from_config(config: &CrateConfig) -> Self { TsEventCrate { crate_path: config.crate_path.clone(), event_files: config.flowy_config.event_files.clone(), } } } pub fn parse_ts_event_files(crate_paths: Vec) -> Vec { let mut ts_event_crates: Vec = vec![]; crate_paths.iter().for_each(|path| { let crates = WalkDir::new(path) .into_iter() .filter_entry(|e| !is_hidden(e)) .filter_map(|e| e.ok()) .filter(is_crate_dir) .flat_map(|e| parse_crate_config_from(&e)) .map(|crate_config| TsEventCrate::from_config(&crate_config)) .collect::>(); ts_event_crates.extend(crates); }); ts_event_crates } pub fn parse_event_crate(event_crate: &TsEventCrate) -> Vec { event_crate .event_files .iter() .flat_map(|event_file| { let file_path = path_string_with_component(&event_crate.crate_path, vec![event_file.as_str()]); let file_content = read_file(file_path.as_ref()).unwrap(); let ast = syn::parse_file(file_content.as_ref()).expect("Unable to parse file"); ast .items .iter() .flat_map(|item| match item { Item::Enum(item_enum) => { let ast_result = ASTResult::new(); let attrs = flowy_ast::enum_from_ast( &ast_result, &item_enum.ident, &item_enum.variants, &item_enum.attrs, ); ast_result.check().unwrap(); attrs .iter() .filter(|attr| !attr.attrs.event_attrs.ignore) .map(|variant| EventASTContext::from(&variant.attrs)) .collect::>() }, _ => vec![], }) .collect::>() }) .collect::>() } pub fn ast_to_event_render_ctx(ast: &[EventASTContext]) -> Vec { let mut import_objects = HashSet::new(); ast.iter().for_each(|event_ast| { if let Some(input) = event_ast.event_input.as_ref() { import_objects.insert(input.get_ident().unwrap().to_string()); } if let Some(output) = event_ast.event_output.as_ref() { import_objects.insert(output.get_ident().unwrap().to_string()); } }); ast .iter() .map(|event_ast| { let input_deserializer = event_ast .event_input .as_ref() .map(|event_input| event_input.get_ident().unwrap().to_string()); let output_deserializer = event_ast .event_output .as_ref() .map(|event_output| event_output.get_ident().unwrap().to_string()); EventRenderContext { input_deserializer, output_deserializer, error_deserializer: event_ast.event_error.to_string(), event: event_ast.event.to_string(), event_ty: event_ast.event_ty.to_string(), prefix: "pb".to_string(), } }) .collect::>() } const TS_FOOTER: &str = r#" "#; ================================================ FILE: frontend/rust-lib/build-tool/flowy-codegen/src/util.rs ================================================ use console::Style; use similar::{ChangeTag, TextDiff}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::{ fs::{File, OpenOptions}, io::{Read, Write}, }; use tera::Tera; use walkdir::WalkDir; pub fn read_file(path: &str) -> Option { let mut file = File::open(path).unwrap_or_else(|_| panic!("Unable to open file at {}", path)); let mut content = String::new(); match file.read_to_string(&mut content) { Ok(_) => Some(content), Err(e) => { log::error!("{}, with error: {:?}", path, e); Some("".to_string()) }, } } pub fn save_content_to_file_with_diff_prompt(content: &str, output_file: &str) { if Path::new(output_file).exists() { let old_content = read_file(output_file).unwrap(); let new_content = content.to_owned(); let write_to_file = || match OpenOptions::new() .create(true) .write(true) .append(false) .truncate(true) .open(output_file) { Ok(ref mut file) => { file.write_all(new_content.as_bytes()).unwrap(); }, Err(err) => { panic!("Failed to open log file: {}", err); }, }; if new_content != old_content { print_diff(old_content, new_content.clone()); write_to_file() } } else { match OpenOptions::new() .create(true) .truncate(true) .write(true) .open(output_file) { Ok(ref mut file) => file.write_all(content.as_bytes()).unwrap(), Err(err) => panic!("Open or create to {} fail: {}", output_file, err), } } } pub fn print_diff(old_content: String, new_content: String) { let diff = TextDiff::from_lines(&old_content, &new_content); for op in diff.ops() { for change in diff.iter_changes(op) { let (sign, style) = match change.tag() { ChangeTag::Delete => ("-", Style::new().red()), ChangeTag::Insert => ("+", Style::new().green()), ChangeTag::Equal => (" ", Style::new()), }; match change.tag() { ChangeTag::Delete => { print!("{}{}", style.apply_to(sign).bold(), style.apply_to(change)); }, ChangeTag::Insert => { print!("{}{}", style.apply_to(sign).bold(), style.apply_to(change)); }, ChangeTag::Equal => {}, }; } println!("---------------------------------------------------"); } } #[allow(dead_code)] pub fn is_crate_dir(e: &walkdir::DirEntry) -> bool { let cargo = e.path().file_stem().unwrap().to_str().unwrap().to_string(); cargo == *"Cargo" } #[allow(dead_code)] pub fn is_proto_file(e: &walkdir::DirEntry) -> bool { if e.path().extension().is_none() { return false; } let ext = e.path().extension().unwrap().to_str().unwrap().to_string(); ext == *"proto" } pub fn is_hidden(entry: &walkdir::DirEntry) -> bool { entry .file_name() .to_str() .map(|s| s.starts_with('.')) .unwrap_or(false) } pub fn create_dir_if_not_exist(dir: &Path) { if !dir.exists() { std::fs::create_dir_all(dir).unwrap(); } } pub fn path_string_with_component(path: &Path, components: Vec<&str>) -> String { path_buf_with_component(path, components) .to_str() .unwrap() .to_string() } #[allow(dead_code)] pub fn path_buf_with_component(path: &Path, components: Vec<&str>) -> PathBuf { let mut path_buf = path.to_path_buf(); for component in components { path_buf.push(component); } path_buf } #[allow(dead_code)] pub fn walk_dir, F1, F2>(dir: P, filter: F2, mut path_and_name: F1) where F1: FnMut(String, String), F2: Fn(&walkdir::DirEntry) -> bool, { for (path, name) in WalkDir::new(dir) .into_iter() .filter_map(|e| e.ok()) .filter(|e| filter(e)) .map(|e| { ( e.path().to_str().unwrap().to_string(), e.path().file_stem().unwrap().to_str().unwrap().to_string(), ) }) { path_and_name(path, name); } } #[allow(dead_code)] pub fn suffix_relative_to_path(path: &str, base: &str) -> String { let base = Path::new(base); let path = Path::new(path); path .strip_prefix(base) .unwrap() .to_str() .unwrap() .to_owned() } pub fn get_tera(directory: &str) -> Tera { let mut root = format!("{}/src/", env!("CARGO_MANIFEST_DIR")); root.push_str(directory); let root_absolute_path = match std::fs::canonicalize(&root) { Ok(p) => p.as_path().display().to_string(), Err(e) => { panic!("❌ Canonicalize file path {} failed {:?}", root, e); }, }; let mut template_path = format!("{}/**/*.tera", root_absolute_path); if cfg!(windows) { // remove "\\?\" prefix on windows template_path = format!("{}/**/*.tera", &root_absolute_path[4..]); } match Tera::new(template_path.as_ref()) { Ok(t) => t, Err(e) => { log::error!("Parsing error(s): {}", e); ::std::process::exit(1); }, } } pub fn cache_dir() -> PathBuf { let mut path_buf = PathBuf::from_str(env!("CARGO_MANIFEST_DIR")).unwrap(); path_buf.push(".cache"); path_buf } ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/.gitignore ================================================ /target Cargo.lock ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/Cargo.toml ================================================ [package] name = "flowy-derive" version = "0.1.0" edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] proc-macro = true name = "flowy_derive" [dependencies] syn = { version = "1.0.109", features = ["extra-traits", "visit"] } quote = "1.0" proc-macro2 = "1.0" flowy-ast.workspace = true lazy_static = { version = "1.4.0" } dashmap.workspace = true flowy-codegen.workspace = true serde_json.workspace = true walkdir = "2.3.2" ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/src/dart_event/mod.rs ================================================ use proc_macro2::TokenStream; // #[proc_macro_derive(DartEvent, attributes(event_ty))] pub fn expand_enum_derive(_input: &syn::DeriveInput) -> Result> { Ok(TokenStream::default()) } // use flowy_ast::{ASTContainer, Ctxt}; // use proc_macro2::TokenStream; // // // #[proc_macro_derive(DartEvent, attributes(event_ty))] // pub fn expand_enum_derive(input: &syn::DeriveInput) -> Result> { let ctxt = Ctxt::new(); // let cont = match ASTContainer::from_ast(&ctxt, input) { // Some(cont) => cont, // None => return Err(ctxt.check().unwrap_err()), // }; // // let enum_ident = &cont.ident; // let pb_enum = cont.attrs.pb_enum_type().unwrap(); // // let build_display_pb_enum = cont.data.all_idents().map(|i| { // let a = format_ident!("{}", i.to_string()); // let token_stream: TokenStream = quote! { // #enum_ident::#i => f.write_str(&#a)?, // }; // token_stream // }); // // ctxt.check()?; // // Ok(quote! { // impl std::fmt::Display for #enum_ident { // fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result // { match self { // #(#build_display_pb_enum)* // } // } // } // }) // } ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/src/lib.rs ================================================ // https://docs.rs/syn/1.0.48/syn/struct.DeriveInput.html extern crate proc_macro; use proc_macro::TokenStream; use quote::quote; use syn::{parse_macro_input, DeriveInput}; #[macro_use] extern crate quote; mod dart_event; mod node; mod proto_buf; // Inspired by https://serde.rs/attributes.html #[proc_macro_derive(ProtoBuf, attributes(pb))] pub fn derive_proto_buf(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); proto_buf::expand_derive(&input) .unwrap_or_else(to_compile_errors) .into() } #[proc_macro_derive(ProtoBuf_Enum, attributes(pb))] pub fn derive_proto_buf_enum(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); proto_buf::expand_enum_derive(&input) .unwrap_or_else(to_compile_errors) .into() } #[proc_macro_derive(Flowy_Event, attributes(event, event_err))] pub fn derive_dart_event(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); dart_event::expand_enum_derive(&input) .unwrap_or_else(to_compile_errors) .into() } #[proc_macro_derive(Node, attributes(node, nodes, node_type))] pub fn derive_node(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); node::expand_derive(&input) .unwrap_or_else(to_compile_errors) .into() } fn to_compile_errors(errors: Vec) -> proc_macro2::TokenStream { let compile_errors = errors.iter().map(syn::Error::to_compile_error); quote!(#(#compile_errors)*) } ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/src/node/mod.rs ================================================ use flowy_ast::{ASTContainer, ASTField, ASTResult}; use proc_macro2::TokenStream; pub fn expand_derive(input: &syn::DeriveInput) -> Result> { let ast_result = ASTResult::new(); let cont = match ASTContainer::from_ast(&ast_result, input) { Some(cont) => cont, None => return Err(ast_result.check().unwrap_err()), }; let mut token_stream: TokenStream = TokenStream::default(); token_stream.extend(make_helper_funcs_token_stream(&cont)); token_stream.extend(make_to_node_data_token_stream(&cont)); if let Some(get_value_token_stream) = make_get_set_value_token_steam(&cont) { token_stream.extend(get_value_token_stream); } token_stream.extend(make_alter_children_token_stream(&ast_result, &cont)); ast_result.check()?; Ok(token_stream) } pub fn make_helper_funcs_token_stream(ast: &ASTContainer) -> TokenStream { let mut token_streams = TokenStream::default(); let struct_ident = &ast.ident; token_streams.extend(quote! { impl #struct_ident { pub fn get_path(&self) -> Option { let node_id = &self.node_id?; Some(self.tree.read().path_from_node_id(node_id.clone())) } } }); token_streams } pub fn make_alter_children_token_stream(ast_result: &ASTResult, ast: &ASTContainer) -> TokenStream { let mut token_streams = TokenStream::default(); let children_fields = ast .data .all_fields() .filter(|field| field.node_attrs.has_child) .collect::>(); if !children_fields.is_empty() { let struct_ident = &ast.ident; if children_fields.len() > 1 { ast_result.error_spanned_by(struct_ident, "Only one children property"); return token_streams; } let children_field = children_fields.first().unwrap(); let field_name = children_field.name().unwrap(); let child_name = children_field.node_attrs.child_name.as_ref().unwrap(); let get_func_name = format_ident!("get_{}", child_name.value()); let get_mut_func_name = format_ident!("get_mut_{}", child_name.value()); let add_func_name = format_ident!("add_{}", child_name.value()); let remove_func_name = format_ident!("remove_{}", child_name.value()); let ty = children_field.bracket_inner_ty.as_ref().unwrap().clone(); token_streams.extend(quote! { impl #struct_ident { pub fn #get_func_name>(&self, id: T) -> Option<&#ty> { let id = id.as_ref(); self.#field_name.iter().find(|element| element.id == id) } pub fn #get_mut_func_name>(&mut self, id: T) -> Option<&mut #ty> { let id = id.as_ref(); self.#field_name.iter_mut().find(|element| element.id == id) } pub fn #remove_func_name>(&mut self, id: T) { let id = id.as_ref(); if let Some(index) = self.#field_name.iter().position(|element| element.id == id && element.node_id.is_some()) { let element = self.#field_name.remove(index); let element_path = element.get_path().unwrap(); let mut write_guard = self.tree.write(); let mut nodes = vec![]; if let Some(node_data) = element.node_id.and_then(|node_id| write_guard.get_node_data(node_id.clone())) { nodes.push(node_data); } let _ = write_guard.apply_op(NodeOperation::Delete { path: element_path, nodes, }); } } pub fn #add_func_name(&mut self, mut value: #ty) -> Result<(), String> { if self.node_id.is_none() { return Err("The node id is empty".to_owned()); } let mut transaction = Transaction::new(); let parent_path = self.get_path().unwrap(); let path = parent_path.clone_with(self.#field_name.len()); let node_data = value.to_node_data(); transaction.push_operation(NodeOperation::Insert { path: path.clone(), nodes: vec![node_data], }); let _ = self.tree.write().apply_transaction(transaction); let child_node_id = self.tree.read().node_id_at_path(path).unwrap(); value.node_id = Some(child_node_id); self.#field_name.push(value); Ok(()) } } }); } token_streams } pub fn make_to_node_data_token_stream(ast: &ASTContainer) -> TokenStream { let struct_ident = &ast.ident; let mut token_streams = TokenStream::default(); let node_type = ast .node_type .as_ref() .expect("Define the type of the node by using #[node_type = \"xx\" in the struct"); let set_key_values = ast .data .all_fields() .filter(|field| !field.node_attrs.has_child) .flat_map(|field| { let mut field_name = field .name() .expect("the name of the field should not be empty"); let original_field_name = field .name() .expect("the name of the field should not be empty"); if let Some(rename) = &field.node_attrs.rename { field_name = format_ident!("{}", rename.value()); } let field_name_str = field_name.to_string(); quote! { .insert_attribute(#field_name_str, self.#original_field_name.clone()) } }); let children_fields = ast .data .all_fields() .filter(|field| field.node_attrs.has_child) .collect::>(); let childrens_token_streams = match children_fields.is_empty() { true => { quote! { let children = vec![]; } }, false => { let children_field = children_fields.first().unwrap(); let original_field_name = children_field .name() .expect("the name of the field should not be empty"); quote! { let children = self.#original_field_name.iter().map(|value| value.to_node_data()).collect::>(); } }, }; token_streams.extend(quote! { impl ToNodeData for #struct_ident { fn to_node_data(&self) -> NodeData { #childrens_token_streams let builder = NodeDataBuilder::new(#node_type) #(#set_key_values)* .extend_node_data(children); builder.build() } } }); token_streams } pub fn make_get_set_value_token_steam(ast: &ASTContainer) -> Option { let struct_ident = &ast.ident; let mut token_streams = TokenStream::default(); let tree = format_ident!("tree"); for field in ast.data.all_fields() { if field.node_attrs.has_child { continue; } let mut field_name = field .name() .expect("the name of the field should not be empty"); if let Some(rename) = &field.node_attrs.rename { field_name = format_ident!("{}", rename.value()); } let field_name_str = field_name.to_string(); let get_func_name = format_ident!("get_{}", field_name); let set_func_name = format_ident!("set_{}", field_name); let get_value_return_ty = field.ty; let set_value_input_ty = field.ty; if let Some(get_value_with_fn) = &field.node_attrs.get_node_value_with { token_streams.extend(quote! { impl #struct_ident { pub fn #get_func_name(&self) -> Option<#get_value_return_ty> { let node_id = self.node_id.as_ref()?; #get_value_with_fn(self.#tree.clone(), node_id, #field_name_str) } } }); } if let Some(set_value_with_fn) = &field.node_attrs.set_node_value_with { token_streams.extend(quote! { impl #struct_ident { pub fn #set_func_name(&self, value: #set_value_input_ty) { if let Some(node_id) = self.node_id.as_ref() { let _ = #set_value_with_fn(self.#tree.clone(), node_id, #field_name_str, value); } } } }); } } Some(token_streams) } ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/src/proto_buf/deserialize.rs ================================================ use proc_macro2::{Span, TokenStream}; use flowy_ast::*; use crate::proto_buf::util::*; pub fn make_de_token_steam(ast_result: &ASTResult, ast: &ASTContainer) -> Option { let pb_ty = ast.pb_attrs.pb_struct_type()?; let struct_ident = &ast.ident; let build_take_fields = ast .data .all_fields() .filter(|f| !f.pb_attrs.skip_pb_deserializing()) .flat_map(|field| { if let Some(func) = field.pb_attrs.deserialize_pb_with() { let member = &field.member; Some(quote! { o.#member=#struct_ident::#func(pb); }) } else if field.pb_attrs.is_one_of() { token_stream_for_one_of(ast_result, field) } else { token_stream_for_field(ast_result, &field.member, field.ty, false) } }); let de_token_stream: TokenStream = quote! { impl std::convert::TryFrom for #struct_ident { type Error = ::protobuf::ProtobufError; fn try_from(bytes: bytes::Bytes) -> Result { Self::try_from(&bytes) } } impl std::convert::TryFrom<&bytes::Bytes> for #struct_ident { type Error = ::protobuf::ProtobufError; fn try_from(bytes: &bytes::Bytes) -> Result { let pb: crate::protobuf::#pb_ty = ::protobuf::Message::parse_from_bytes(bytes)?; Ok(#struct_ident::from(pb)) } } impl std::convert::TryFrom<&[u8]> for #struct_ident { type Error = ::protobuf::ProtobufError; fn try_from(bytes: &[u8]) -> Result { let pb: crate::protobuf::#pb_ty = ::protobuf::Message::parse_from_bytes(bytes)?; Ok(#struct_ident::from(pb)) } } impl std::convert::From for #struct_ident { fn from(mut pb: crate::protobuf::#pb_ty) -> Self { let mut o = Self::default(); #(#build_take_fields)* o } } }; Some(de_token_stream) // None } fn token_stream_for_one_of(ast_result: &ASTResult, field: &ASTField) -> Option { let member = &field.member; let ident = get_member_ident(ast_result, member)?; let ty_info = match parse_ty(ast_result, field.ty) { Ok(ty_info) => ty_info, Err(e) => { eprintln!( "token_stream_for_one_of failed: {:?} with error: {}", member, e ); panic!(); }, }?; let bracketed_ty_info = ty_info.bracket_ty_info.as_ref().as_ref(); let has_func = format_ident!("has_{}", ident.to_string()); match ident_category(bracketed_ty_info.unwrap().ident) { TypeCategory::Enum => { let get_func = format_ident!("get_{}", ident.to_string()); let ty = bracketed_ty_info.unwrap().ty; Some(quote! { if pb.#has_func() { let enum_de_from_pb = #ty::from(&pb.#get_func()); o.#member = Some(enum_de_from_pb); } }) }, TypeCategory::Primitive => { let get_func = format_ident!("get_{}", ident.to_string()); Some(quote! { if pb.#has_func() { o.#member=Some(pb.#get_func()); } }) }, TypeCategory::Str => { let take_func = format_ident!("take_{}", ident.to_string()); Some(quote! { if pb.#has_func() { o.#member=Some(pb.#take_func()); } }) }, TypeCategory::Array => { let take_func = format_ident!("take_{}", ident.to_string()); Some(quote! { if pb.#has_func() { o.#member=Some(pb.#take_func()); } }) }, _ => { let take_func = format_ident!("take_{}", ident.to_string()); let ty = bracketed_ty_info.unwrap().ty; Some(quote! { if pb.#has_func() { let val = #ty::from(pb.#take_func()); o.#member=Some(val); } }) }, } } fn token_stream_for_field( ast_result: &ASTResult, member: &syn::Member, ty: &syn::Type, is_option: bool, ) -> Option { let ident = get_member_ident(ast_result, member)?; let ty_info = match parse_ty(ast_result, ty) { Ok(ty_info) => ty_info, Err(e) => { eprintln!("token_stream_for_field: {:?} with error: {}", member, e); panic!() }, }?; match ident_category(ty_info.ident) { TypeCategory::Array => { assert_bracket_ty_is_some(ast_result, &ty_info); token_stream_for_vec(ast_result, member, &ty_info.bracket_ty_info.unwrap()) }, TypeCategory::Map => { assert_bracket_ty_is_some(ast_result, &ty_info); token_stream_for_map(ast_result, member, &ty_info.bracket_ty_info.unwrap()) }, TypeCategory::Protobuf => { // if the type wrapped by SingularPtrField, should call take first let take = syn::Ident::new("take", Span::call_site()); // inner_type_ty would be the type of the field. (e.g value of AnyData) let ty = ty_info.ty; Some(quote! { let some_value = pb.#member.#take(); if some_value.is_some() { let struct_de_from_pb = #ty::from(some_value.unwrap()); o.#member = struct_de_from_pb; } }) }, TypeCategory::Enum => { let ty = ty_info.ty; Some(quote! { let enum_de_from_pb = #ty::from(&pb.#member); o.#member = enum_de_from_pb; }) }, TypeCategory::Str => { let take_ident = syn::Ident::new(&format!("take_{}", ident), Span::call_site()); if is_option { Some(quote! { if pb.#member.is_empty() { o.#member = None; } else { o.#member = Some(pb.#take_ident()); } }) } else { Some(quote! { o.#member = pb.#take_ident(); }) } }, TypeCategory::Opt => token_stream_for_field( ast_result, member, ty_info.bracket_ty_info.unwrap().ty, true, ), TypeCategory::Primitive | TypeCategory::Bytes => { // eprintln!("😄 #{:?}", &field.name().unwrap()); if is_option { Some(quote! { o.#member = Some(pb.#member.clone()); }) } else { Some(quote! { o.#member = pb.#member.clone(); }) } }, } } fn token_stream_for_vec( ctxt: &ASTResult, member: &syn::Member, bracketed_type: &TyInfo, ) -> Option { let ident = get_member_ident(ctxt, member)?; match ident_category(bracketed_type.ident) { TypeCategory::Protobuf => { let ty = bracketed_type.ty; // Deserialize from pb struct of type vec, should call take_xx(), get the // repeated_field and then calling the into_iter。 let take_ident = format_ident!("take_{}", ident.to_string()); Some(quote! { o.#member = pb.#take_ident() .into_iter() .map(|m| #ty::from(m)) .collect(); }) }, TypeCategory::Bytes => { // Vec Some(quote! { o.#member = pb.#member.clone(); }) }, TypeCategory::Str => { let take_ident = format_ident!("take_{}", ident.to_string()); Some(quote! { o.#member = pb.#take_ident().into_vec(); }) }, _ => { let take_ident = format_ident!("take_{}", ident.to_string()); Some(quote! { o.#member = pb.#take_ident(); }) }, } } fn token_stream_for_map( ast_result: &ASTResult, member: &syn::Member, ty_info: &TyInfo, ) -> Option { let ident = get_member_ident(ast_result, member)?; let take_ident = format_ident!("take_{}", ident.to_string()); let ty = ty_info.ty; match ident_category(ty_info.ident) { TypeCategory::Protobuf => Some(quote! { let mut m: std::collections::HashMap = std::collections::HashMap::new(); pb.#take_ident().into_iter().for_each(|(k,v)| { m.insert(k.clone(), #ty::from(v)); }); o.#member = m; }), _ => Some(quote! { let mut m: std::collections::HashMap = std::collections::HashMap::new(); pb.#take_ident().into_iter().for_each(|(k,mut v)| { m.insert(k.clone(), v); }); o.#member = m; }), } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/src/proto_buf/enum_serde.rs ================================================ use flowy_ast::*; use proc_macro2::TokenStream; #[allow(dead_code)] pub fn make_enum_token_stream(_ast_result: &ASTResult, cont: &ASTContainer) -> Option { let enum_ident = &cont.ident; let pb_enum = cont.pb_attrs.pb_enum_type()?; let build_to_pb_enum = cont.data.all_idents().map(|i| { let token_stream: TokenStream = quote! { #enum_ident::#i => crate::protobuf::#pb_enum::#i, }; token_stream }); let build_from_pb_enum = cont.data.all_idents().map(|i| { let token_stream: TokenStream = quote! { crate::protobuf::#pb_enum::#i => #enum_ident::#i, }; token_stream }); Some(quote! { impl std::convert::From<&crate::protobuf::#pb_enum> for #enum_ident { fn from(pb:&crate::protobuf::#pb_enum) -> Self { match pb { #(#build_from_pb_enum)* } } } impl std::convert::From<#enum_ident> for crate::protobuf::#pb_enum{ fn from(o: #enum_ident) -> crate::protobuf::#pb_enum { match o { #(#build_to_pb_enum)* } } } }) } ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/src/proto_buf/mod.rs ================================================ mod deserialize; mod enum_serde; mod serialize; mod util; use crate::proto_buf::{ deserialize::make_de_token_steam, enum_serde::make_enum_token_stream, serialize::make_se_token_stream, }; use flowy_ast::*; use proc_macro2::TokenStream; use std::default::Default; pub fn expand_derive(input: &syn::DeriveInput) -> Result> { let ast_result = ASTResult::new(); let cont = match ASTContainer::from_ast(&ast_result, input) { Some(cont) => cont, None => return Err(ast_result.check().unwrap_err()), }; let mut token_stream: TokenStream = TokenStream::default(); if let Some(de_token_stream) = make_de_token_steam(&ast_result, &cont) { token_stream.extend(de_token_stream); } if let Some(se_token_stream) = make_se_token_stream(&ast_result, &cont) { token_stream.extend(se_token_stream); } ast_result.check()?; Ok(token_stream) } pub fn expand_enum_derive(input: &syn::DeriveInput) -> Result> { let ast_result = ASTResult::new(); let cont = match ASTContainer::from_ast(&ast_result, input) { Some(cont) => cont, None => return Err(ast_result.check().unwrap_err()), }; let mut token_stream: TokenStream = TokenStream::default(); if let Some(enum_token_stream) = make_enum_token_stream(&ast_result, &cont) { token_stream.extend(enum_token_stream); } ast_result.check()?; Ok(token_stream) } // #[macro_use] // macro_rules! impl_try_for_primitive_type { // ($target:ident) => { // impl std::convert::TryFrom<&$target> for $target { // type Error = String; // fn try_from(val: &$target) -> Result { // Ok(val.clone()) } } // // impl std::convert::TryInto<$target> for $target { // type Error = String; // // fn try_into(self) -> Result { Ok(self) } // } // }; // } // // impl_try_for_primitive_type!(String); // impl_try_for_primitive_type!(i64); // impl_try_for_primitive_type!(i32); // impl_try_for_primitive_type!(i16); // impl_try_for_primitive_type!(u64); // impl_try_for_primitive_type!(u32); // impl_try_for_primitive_type!(u16); // impl_try_for_primitive_type!(bool); // impl_try_for_primitive_type!(f64); // impl_try_for_primitive_type!(f32); ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/src/proto_buf/serialize.rs ================================================ #![allow(clippy::while_let_on_iterator)] use proc_macro2::TokenStream; use flowy_ast::*; use crate::proto_buf::util::{get_member_ident, ident_category, TypeCategory}; pub fn make_se_token_stream(ast_result: &ASTResult, ast: &ASTContainer) -> Option { let pb_ty = ast.pb_attrs.pb_struct_type()?; let struct_ident = &ast.ident; let build_set_pb_fields = ast .data .all_fields() .filter(|f| !f.pb_attrs.skip_pb_serializing()) .flat_map(|field| se_token_stream_for_field(ast_result, field, false)); let se_token_stream: TokenStream = quote! { impl std::convert::TryInto for #struct_ident { type Error = ::protobuf::ProtobufError; fn try_into(self) -> Result { use protobuf::Message; let pb: crate::protobuf::#pb_ty = self.into(); let bytes = pb.write_to_bytes()?; Ok(bytes::Bytes::from(bytes)) } } impl std::convert::TryInto> for #struct_ident { type Error = ::protobuf::ProtobufError; fn try_into(self) -> Result, Self::Error> { use protobuf::Message; let pb: crate::protobuf::#pb_ty = self.into(); let bytes = pb.write_to_bytes()?; Ok(bytes) } } impl std::convert::From<#struct_ident> for crate::protobuf::#pb_ty { fn from(mut o: #struct_ident) -> crate::protobuf::#pb_ty { let mut pb = crate::protobuf::#pb_ty::new(); #(#build_set_pb_fields)* pb } } }; Some(se_token_stream) } fn se_token_stream_for_field( ast_result: &ASTResult, field: &ASTField, _take: bool, ) -> Option { if let Some(func) = &field.pb_attrs.serialize_pb_with() { let member = &field.member; Some(quote! { pb.#member=o.#func(); }) } else if field.pb_attrs.is_one_of() { token_stream_for_one_of(ast_result, field) } else { gen_token_stream(ast_result, &field.member, field.ty, false) } } fn token_stream_for_one_of(ast_result: &ASTResult, field: &ASTField) -> Option { let member = &field.member; let ident = get_member_ident(ast_result, member)?; let ty_info = match parse_ty(ast_result, field.ty) { Ok(ty_info) => ty_info, Err(e) => { eprintln!( "token_stream_for_one_of failed: {:?} with error: {}", member, e ); panic!(); }, }?; let bracketed_ty_info = ty_info.bracket_ty_info.as_ref().as_ref(); let set_func = format_ident!("set_{}", ident.to_string()); match ident_category(bracketed_ty_info.unwrap().ident) { TypeCategory::Protobuf => Some(quote! { match o.#member { Some(s) => { pb.#set_func(s.into()) } None => {} } }), TypeCategory::Enum => Some(quote! { match o.#member { Some(s) => { pb.#set_func(s.into()) } None => {} } }), _ => Some(quote! { match o.#member { Some(ref s) => { pb.#set_func(s.clone()) } None => {} } }), } } fn gen_token_stream( ast_result: &ASTResult, member: &syn::Member, ty: &syn::Type, is_option: bool, ) -> Option { let ty_info = match parse_ty(ast_result, ty) { Ok(ty_info) => ty_info, Err(e) => { eprintln!("gen_token_stream failed: {:?} with error: {}", member, e); panic!(); }, }?; match ident_category(ty_info.ident) { TypeCategory::Array => { token_stream_for_vec(ast_result, member, ty_info.bracket_ty_info.unwrap().ty) }, TypeCategory::Map => { token_stream_for_map(ast_result, member, ty_info.bracket_ty_info.unwrap().ty) }, TypeCategory::Str => { if is_option { Some(quote! { match o.#member { Some(ref s) => { pb.#member = s.to_string().clone(); } None => { pb.#member = String::new(); } } }) } else { Some(quote! { pb.#member = o.#member.clone(); }) } }, TypeCategory::Protobuf => { Some(quote! { pb.#member = ::protobuf::SingularPtrField::some(o.#member.into()); }) }, TypeCategory::Opt => gen_token_stream( ast_result, member, ty_info.bracket_ty_info.unwrap().ty, true, ), TypeCategory::Enum => { // let pb_enum_ident = format_ident!("{}", ty_info.ident.to_string()); // Some(quote! { // flowy_protobuf::#pb_enum_ident::from_i32(self.#member.value()).unwrap(); // }) Some(quote! { pb.#member = o.#member.into(); }) }, _ => Some(quote! { pb.#member = o.#member; }), } } // e.g. pub cells: Vec, the member will be cells, ty would be Vec fn token_stream_for_vec( ast_result: &ASTResult, member: &syn::Member, ty: &syn::Type, ) -> Option { let ty_info = match parse_ty(ast_result, ty) { Ok(ty_info) => ty_info, Err(e) => { eprintln!( "token_stream_for_vec failed: {:?} with error: {}", member, e ); panic!(); }, }?; match ident_category(ty_info.ident) { TypeCategory::Protobuf => Some(quote! { pb.#member = ::protobuf::RepeatedField::from_vec( o.#member .into_iter() .map(|m| m.into()) .collect()); }), TypeCategory::Bytes => Some(quote! { pb.#member = o.#member.clone(); }), TypeCategory::Primitive => Some(quote! { pb.#member = o.#member.clone(); }), _ => Some(quote! { pb.#member = ::protobuf::RepeatedField::from_vec(o.#member.clone()); }), } } // e.g. pub cells: HashMap fn token_stream_for_map( ast_result: &ASTResult, member: &syn::Member, ty: &syn::Type, ) -> Option { // The key of the hashmap must be string let ty_info = match parse_ty(ast_result, ty) { Ok(ty_info) => ty_info, Err(e) => { eprintln!( "token_stream_for_map failed: {:?} with error: {}", member, e ); panic!(); }, }?; let value_ty = ty_info.ty; match ident_category(ty_info.ident) { TypeCategory::Protobuf => Some(quote! { let mut m: std::collections::HashMap = std::collections::HashMap::new(); o.#member.into_iter().for_each(|(k,v)| { m.insert(k.clone(), v.into()); }); pb.#member = m; }), _ => Some(quote! { let mut m: std::collections::HashMap = std::collections::HashMap::new(); o.#member.iter().for_each(|(k,v)| { m.insert(k.clone(), v.clone()); }); pb.#member = m; }), } } ================================================ FILE: frontend/rust-lib/build-tool/flowy-derive/src/proto_buf/util.rs ================================================ use dashmap::{DashMap, DashSet}; use flowy_ast::{ASTResult, TyInfo}; use flowy_codegen::ProtoCache; use lazy_static::lazy_static; use std::fs::File; use std::io::Read; use std::sync::atomic::{AtomicBool, Ordering}; use walkdir::WalkDir; pub fn ident_category(ident: &syn::Ident) -> TypeCategory { let ident_str = ident.to_string(); category_from_str(ident_str) } pub(crate) fn get_member_ident<'a>( ast_result: &ASTResult, member: &'a syn::Member, ) -> Option<&'a syn::Ident> { if let syn::Member::Named(ref ident) = member { Some(ident) } else { ast_result.error_spanned_by( member, "Unsupported member, shouldn't be self.0".to_string(), ); None } } pub fn assert_bracket_ty_is_some(ast_result: &ASTResult, ty_info: &TyInfo) { if ty_info.bracket_ty_info.is_none() { ast_result.error_spanned_by( ty_info.ty, "Invalid bracketed type when gen de token steam".to_string(), ); } } lazy_static! { static ref READ_FLAG: DashSet = DashSet::new(); static ref CACHE_INFO: DashMap> = DashMap::new(); static ref IS_LOAD: AtomicBool = AtomicBool::new(false); } #[derive(Eq, Hash, PartialEq)] pub enum TypeCategory { Array, Map, Str, Protobuf, Bytes, Enum, Opt, Primitive, } // auto generate, do not edit pub fn category_from_str(type_str: String) -> TypeCategory { if !IS_LOAD.load(Ordering::SeqCst) { IS_LOAD.store(true, Ordering::SeqCst); // Dependents on another crate file is not good, just leave it here. // Maybe find another way to read the .cache in the future. let cache_dir = format!("{}/../flowy-codegen/.cache", env!("CARGO_MANIFEST_DIR")); for path in WalkDir::new(cache_dir) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.path().file_stem().unwrap().to_str().unwrap() == "proto_cache") .map(|e| e.path().to_str().unwrap().to_string()) { match read_file(&path) { None => {}, Some(s) => { let cache: ProtoCache = serde_json::from_str(&s).unwrap(); CACHE_INFO .entry(TypeCategory::Protobuf) .or_default() .extend(cache.structs); CACHE_INFO .entry(TypeCategory::Enum) .or_default() .extend(cache.enums); }, } } } if let Some(protobuf_tys) = CACHE_INFO.get(&TypeCategory::Protobuf) { if protobuf_tys.contains(&type_str) { return TypeCategory::Protobuf; } } if let Some(enum_tys) = CACHE_INFO.get(&TypeCategory::Enum) { if enum_tys.contains(&type_str) { return TypeCategory::Enum; } } match type_str.as_str() { "Vec" => TypeCategory::Array, "HashMap" => TypeCategory::Map, "u8" => TypeCategory::Bytes, "String" => TypeCategory::Str, "Option" => TypeCategory::Opt, _ => TypeCategory::Primitive, } } fn read_file(path: &str) -> Option { match File::open(path) { Ok(mut file) => { let mut content = String::new(); match file.read_to_string(&mut content) { Ok(_) => Some(content), Err(_) => None, } }, Err(_) => None, } } ================================================ FILE: frontend/rust-lib/collab-integrate/Cargo.toml ================================================ [package] name = "collab-integrate" version = "0.1.0" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] crate-type = ["cdylib", "rlib"] [dependencies] collab = { workspace = true } collab-plugins = { workspace = true } collab-entity = { workspace = true } collab-document = { workspace = true } collab-folder = { workspace = true } collab-user = { workspace = true } collab-database = { workspace = true } serde.workspace = true serde_json.workspace = true anyhow.workspace = true tracing.workspace = true tokio = { workspace = true, features = ["sync"] } lib-infra = { workspace = true } arc-swap = "1.7" flowy-error.workspace = true uuid.workspace = true flowy-ai-pub.workspace = true [target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies] twox-hash = { version = "2.1.0", features = ["xxhash64"] } [features] default = [] ================================================ FILE: frontend/rust-lib/collab-integrate/src/collab_builder.rs ================================================ use std::borrow::BorrowMut; use std::fmt::{Debug, Display}; use std::sync::{Arc, Weak}; use crate::CollabKVDB; use anyhow::{Error, anyhow}; use arc_swap::{ArcSwap, ArcSwapOption}; use collab::core::collab::DataSource; use collab::core::collab_plugin::CollabPersistence; use collab::entity::EncodedCollab; use collab::error::CollabError; use collab::preclude::{Collab, CollabBuilder}; use collab_database::workspace_database::{DatabaseCollabService, WorkspaceDatabaseManager}; use collab_document::blocks::DocumentData; use collab_document::document::Document; use collab_entity::{CollabObject, CollabType}; use collab_folder::{Folder, FolderData, FolderNotify}; use collab_plugins::connect_state::{CollabConnectReachability, CollabConnectState}; use collab_plugins::local_storage::kv::snapshot::SnapshotPersistence; if_native! { use collab_plugins::local_storage::rocksdb::rocksdb_plugin::{RocksdbBackup, RocksdbDiskPlugin}; } if_wasm! { use collab_plugins::local_storage::indexeddb::IndexeddbDiskPlugin; } pub use crate::plugin_provider::CollabCloudPluginProvider; use collab::lock::RwLock; use collab_plugins::local_storage::CollabPersistenceConfig; use collab_plugins::local_storage::kv::KVTransactionDB; use collab_plugins::local_storage::kv::doc::CollabKVAction; use collab_user::core::{UserAwareness, UserAwarenessNotifier}; use crate::instant_indexed_data_provider::InstantIndexedDataWriter; use flowy_error::FlowyError; use lib_infra::{if_native, if_wasm}; use tracing::{error, instrument, trace, warn}; use uuid::Uuid; #[derive(Clone, Debug)] pub enum CollabPluginProviderType { Local, AppFlowyCloud, } pub enum CollabPluginProviderContext { Local, AppFlowyCloud { uid: i64, collab_object: CollabObject, local_collab: Weak + Send + Sync + 'static>>, }, } impl Display for CollabPluginProviderContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let str = match self { CollabPluginProviderContext::Local => "Local".to_string(), CollabPluginProviderContext::AppFlowyCloud { uid: _, collab_object, .. } => collab_object.to_string(), }; write!(f, "{}", str) } } pub trait WorkspaceCollabIntegrate: Send + Sync { fn workspace_id(&self) -> Result; fn device_id(&self) -> Result; } pub struct AppFlowyCollabBuilder { network_reachability: CollabConnectReachability, plugin_provider: ArcSwap>, snapshot_persistence: ArcSwapOption>, #[cfg(not(target_arch = "wasm32"))] rocksdb_backup: ArcSwapOption>, workspace_integrate: Arc, embeddings_writer: Option>, } impl AppFlowyCollabBuilder { pub fn new( storage_provider: impl CollabCloudPluginProvider + 'static, workspace_integrate: impl WorkspaceCollabIntegrate + 'static, embeddings_writer: Option>, ) -> Self { Self { embeddings_writer, network_reachability: CollabConnectReachability::new(), plugin_provider: ArcSwap::new(Arc::new(Arc::new(storage_provider))), snapshot_persistence: Default::default(), #[cfg(not(target_arch = "wasm32"))] rocksdb_backup: Default::default(), workspace_integrate: Arc::new(workspace_integrate), } } pub fn set_snapshot_persistence(&self, snapshot_persistence: Arc) { self .snapshot_persistence .store(Some(snapshot_persistence.into())); } #[cfg(not(target_arch = "wasm32"))] pub fn set_rocksdb_backup(&self, rocksdb_backup: Arc) { self.rocksdb_backup.store(Some(rocksdb_backup.into())); } pub fn update_network(&self, reachable: bool) { if reachable { self .network_reachability .set_state(CollabConnectState::Connected) } else { self .network_reachability .set_state(CollabConnectState::Disconnected) } } pub fn collab_object( &self, workspace_id: &Uuid, uid: i64, object_id: &Uuid, collab_type: CollabType, ) -> Result { // Compare the workspace_id with the currently opened workspace_id. Return an error if they do not match. // This check is crucial in asynchronous code contexts where the workspace_id might change during operation. let actual_workspace_id = self.workspace_integrate.workspace_id()?; if workspace_id != &actual_workspace_id { return Err(anyhow::anyhow!( "workspace_id not match when build collab. expect workspace_id: {}, actual workspace_id: {}", workspace_id, actual_workspace_id )); } let device_id = self.workspace_integrate.device_id()?; Ok(CollabObject::new( uid, object_id.to_string(), collab_type, workspace_id.to_string(), device_id, )) } #[allow(clippy::too_many_arguments)] #[instrument( level = "trace", skip(self, data_source, collab_db, builder_config, data) )] pub async fn create_document( &self, object: CollabObject, data_source: DataSource, collab_db: Weak, builder_config: CollabBuilderConfig, data: Option, ) -> Result>, Error> { let expected_collab_type = CollabType::Document; assert_eq!(object.collab_type, expected_collab_type); let mut collab = self.build_collab(&object, &collab_db, data_source).await?; collab.enable_undo_redo(); let document = match data { None => Document::open(collab)?, Some(data) => { let document = Document::create_with_data(collab, data)?; if let Err(err) = self.write_collab_to_disk( object.uid, &object.workspace_id, &object.object_id, collab_db.clone(), &object.collab_type, &document, ) { error!( "build_collab: flush document collab to disk failed: {}", err ); } document }, }; let document = Arc::new(RwLock::new(document)); self.finalize(object, builder_config, document) } #[allow(clippy::too_many_arguments)] #[instrument( level = "trace", skip(self, object, doc_state, collab_db, builder_config, folder_notifier) )] pub async fn create_folder( &self, object: CollabObject, doc_state: DataSource, collab_db: Weak, builder_config: CollabBuilderConfig, folder_notifier: Option, folder_data: Option, ) -> Result>, Error> { let expected_collab_type = CollabType::Folder; assert_eq!(object.collab_type, expected_collab_type); let folder = match folder_data { None => { let collab = self.build_collab(&object, &collab_db, doc_state).await?; Folder::open(object.uid, collab, folder_notifier)? }, Some(data) => { let collab = self.build_collab(&object, &collab_db, doc_state).await?; let folder = Folder::create(object.uid, collab, folder_notifier, data); if let Err(err) = self.write_collab_to_disk( object.uid, &object.workspace_id, &object.object_id, collab_db.clone(), &object.collab_type, &folder, ) { error!("build_collab: flush folder collab to disk failed: {}", err); } folder }, }; let folder = Arc::new(RwLock::new(folder)); self.finalize(object, builder_config, folder) } #[allow(clippy::too_many_arguments)] #[instrument( level = "trace", skip(self, object, doc_state, collab_db, builder_config, notifier) )] pub async fn create_user_awareness( &self, object: CollabObject, doc_state: DataSource, collab_db: Weak, builder_config: CollabBuilderConfig, notifier: Option, ) -> Result>, Error> { let expected_collab_type = CollabType::UserAwareness; assert_eq!(object.collab_type, expected_collab_type); let collab = self.build_collab(&object, &collab_db, doc_state).await?; let user_awareness = UserAwareness::create(collab, notifier)?; let user_awareness = Arc::new(RwLock::new(user_awareness)); self.finalize(object, builder_config, user_awareness) } #[allow(clippy::too_many_arguments)] #[instrument(level = "trace", skip_all)] pub fn create_workspace_database_manager( &self, object: CollabObject, collab: Collab, _collab_db: Weak, builder_config: CollabBuilderConfig, collab_service: impl DatabaseCollabService, ) -> Result>, Error> { let expected_collab_type = CollabType::WorkspaceDatabase; assert_eq!(object.collab_type, expected_collab_type); let workspace = WorkspaceDatabaseManager::open(&object.object_id, collab, collab_service)?; let workspace = Arc::new(RwLock::new(workspace)); self.finalize(object, builder_config, workspace) } pub async fn build_collab( &self, object: &CollabObject, collab_db: &Weak, data_source: DataSource, ) -> Result { let object = object.clone(); let collab_db = collab_db.clone(); let device_id = self.workspace_integrate.device_id()?; let collab = tokio::task::spawn_blocking(move || { let collab = CollabBuilder::new(object.uid, &object.object_id, data_source) .with_device_id(device_id) .build()?; let persistence_config = CollabPersistenceConfig::default(); let db_plugin = RocksdbDiskPlugin::new_with_config( object.uid, object.workspace_id.clone(), object.object_id.to_string(), object.collab_type, collab_db, persistence_config, ); collab.add_plugin(Box::new(db_plugin)); Ok::<_, Error>(collab) }) .await??; Ok(collab) } pub fn finalize( &self, object: CollabObject, build_config: CollabBuilderConfig, collab: Arc>, ) -> Result>, Error> where T: BorrowMut + Send + Sync + 'static, { let cloned_object = object.clone(); let weak_collab = Arc::downgrade(&collab); let weak_embedding_writer = self.embeddings_writer.clone(); tokio::spawn(async move { if let Some(embedding_writer) = weak_embedding_writer.and_then(|w| w.upgrade()) { embedding_writer .queue_collab_embed(cloned_object, weak_collab) .await; } }); let mut write_collab = collab.try_write()?; let has_cloud_plugin = write_collab.borrow().has_cloud_plugin(); if has_cloud_plugin { drop(write_collab); return Ok(collab); } if build_config.sync_enable { trace!("🚀finalize collab:{}", object); let plugin_provider = self.plugin_provider.load_full(); let provider_type = plugin_provider.provider_type(); let span = tracing::span!(tracing::Level::TRACE, "collab_builder", object_id = %object.object_id); let _enter = span.enter(); match provider_type { CollabPluginProviderType::AppFlowyCloud => { let local_collab = Arc::downgrade(&collab); let plugins = plugin_provider.get_plugins(CollabPluginProviderContext::AppFlowyCloud { uid: object.uid, collab_object: object, local_collab, }); // at the moment when we get the lock, the collab object is not yet exposed outside for plugin in plugins { write_collab.borrow().add_plugin(plugin); } }, CollabPluginProviderType::Local => {}, } } (*write_collab).borrow_mut().initialize(); drop(write_collab); Ok(collab) } /// Remove all updates in disk and write the final state vector to disk. #[instrument(level = "trace", skip_all, err)] pub fn write_collab_to_disk( &self, uid: i64, workspace_id: &str, object_id: &str, collab_db: Weak, collab_type: &CollabType, collab: &T, ) -> Result<(), Error> where T: BorrowMut + Send + Sync + 'static, { if let Some(collab_db) = collab_db.upgrade() { let write_txn = collab_db.write_txn(); trace!( "flush workspace: {} {}:collab:{} to disk", workspace_id, collab_type, object_id ); let collab: &Collab = collab.borrow(); let encode_collab = collab.encode_collab_v1(|collab| collab_type.validate_require_data(collab))?; write_txn.flush_doc( uid, workspace_id, object_id, encode_collab.state_vector.to_vec(), encode_collab.doc_state.to_vec(), )?; write_txn.commit_transaction()?; } else { error!("collab_db is dropped"); } Ok(()) } } pub struct CollabBuilderConfig { pub sync_enable: bool, } impl Default for CollabBuilderConfig { fn default() -> Self { Self { sync_enable: true } } } impl CollabBuilderConfig { pub fn sync_enable(mut self, sync_enable: bool) -> Self { self.sync_enable = sync_enable; self } } pub struct CollabPersistenceImpl { pub db: Weak, pub uid: i64, pub workspace_id: Uuid, } impl CollabPersistenceImpl { pub fn new(db: Weak, uid: i64, workspace_id: Uuid) -> Self { Self { db, uid, workspace_id, } } pub fn into_data_source(self) -> DataSource { DataSource::Disk(Some(Box::new(self))) } } impl CollabPersistence for CollabPersistenceImpl { fn load_collab_from_disk(&self, collab: &mut Collab) -> Result<(), CollabError> { let collab_db = self .db .upgrade() .ok_or_else(|| CollabError::Internal(anyhow!("collab_db is dropped")))?; let object_id = collab.object_id().to_string(); let rocksdb_read = collab_db.read_txn(); let workspace_id = self.workspace_id.to_string(); if rocksdb_read.is_exist(self.uid, &workspace_id, &object_id) { let mut txn = collab.transact_mut(); match rocksdb_read.load_doc_with_txn(self.uid, &workspace_id, &object_id, &mut txn) { Ok(update_count) => { trace!( "did load collab:{}-{} from disk, update_count:{}", self.uid, object_id, update_count ); }, Err(err) => { error!("🔴 load doc:{} failed: {}", object_id, err); }, } drop(rocksdb_read); txn.commit(); drop(txn); } Ok(()) } fn save_collab_to_disk( &self, object_id: &str, encoded_collab: EncodedCollab, ) -> Result<(), CollabError> { let workspace_id = self.workspace_id.to_string(); let collab_db = self .db .upgrade() .ok_or_else(|| CollabError::Internal(anyhow!("collab_db is dropped")))?; let write_txn = collab_db.write_txn(); write_txn .flush_doc( self.uid, workspace_id.as_str(), object_id, encoded_collab.state_vector.to_vec(), encoded_collab.doc_state.to_vec(), ) .map_err(|err| CollabError::Internal(err.into()))?; write_txn .commit_transaction() .map_err(|err| CollabError::Internal(err.into()))?; Ok(()) } } ================================================ FILE: frontend/rust-lib/collab-integrate/src/config.rs ================================================ use std::str::FromStr; use serde::{Deserialize, Serialize}; pub enum CollabDBPluginProvider { AWS, Supabase, } #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct CollabPluginConfig { /// Only one of the following two fields should be set. aws_config: Option, } impl CollabPluginConfig { pub fn from_env() -> Self { let aws_config = AWSDynamoDBConfig::from_env(); Self { aws_config } } pub fn aws_config(&self) -> Option<&AWSDynamoDBConfig> { self.aws_config.as_ref() } } impl CollabPluginConfig {} impl FromStr for CollabPluginConfig { type Err = serde_json::Error; fn from_str(s: &str) -> Result { serde_json::from_str(s) } } pub const AWS_ACCESS_KEY_ID: &str = "AWS_ACCESS_KEY_ID"; pub const AWS_SECRET_ACCESS_KEY: &str = "AWS_SECRET_ACCESS_KEY"; pub const AWS_REGION: &str = "AWS_REGION"; // To enable this test, you should set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in your environment variables. // or create the ~/.aws/credentials file following the instructions in https://docs.aws.amazon.com/sdk-for-rust/latest/dg/credentials.html #[derive(Default, Clone, Debug, Serialize, Deserialize)] pub struct AWSDynamoDBConfig { pub access_key_id: String, pub secret_access_key: String, // Region list: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html pub region: String, pub enable: bool, } impl AWSDynamoDBConfig { pub fn from_env() -> Option { let access_key_id = std::env::var(AWS_ACCESS_KEY_ID).ok()?; let secret_access_key = std::env::var(AWS_SECRET_ACCESS_KEY).ok()?; let region = std::env::var(AWS_REGION).unwrap_or_else(|_| "us-east-1".to_string()); Some(Self { access_key_id, secret_access_key, region, enable: true, }) } pub fn write_env(&self) { unsafe { std::env::set_var(AWS_ACCESS_KEY_ID, &self.access_key_id); std::env::set_var(AWS_SECRET_ACCESS_KEY, &self.secret_access_key); std::env::set_var(AWS_REGION, &self.region); } } } ================================================ FILE: frontend/rust-lib/collab-integrate/src/instant_indexed_data_provider.rs ================================================ use collab::core::collab::DataSource; use collab::core::origin::CollabOrigin; use collab::entity::EncodedCollab; use collab::lock::RwLock; use collab::preclude::{Collab, Transact}; use collab_document::document::DocumentBody; use collab_entity::{CollabObject, CollabType}; use flowy_ai_pub::entities::{UnindexedCollab, UnindexedCollabMetadata, UnindexedData}; use flowy_error::{FlowyError, FlowyResult}; use lib_infra::async_trait::async_trait; use std::borrow::BorrowMut; use std::collections::HashMap; use std::sync::{Arc, Weak}; use std::time::Duration; use tokio::runtime::Runtime; use tokio::time::interval; use tracing::{error, info, trace}; use uuid::Uuid; pub struct WriteObject { pub collab_object: CollabObject, pub collab: Weak, } pub struct InstantIndexedDataWriter { collab_by_object: Arc>>, consumers: Arc>>>, } impl Default for InstantIndexedDataWriter { fn default() -> Self { Self::new() } } impl InstantIndexedDataWriter { pub fn new() -> InstantIndexedDataWriter { let collab_by_object = Arc::new(RwLock::new(HashMap::::new())); let consumers = Arc::new(RwLock::new( Vec::>::new(), )); InstantIndexedDataWriter { collab_by_object, consumers, } } pub async fn num_consumers(&self) -> usize { let consumers = self.consumers.read().await; consumers.len() } pub async fn clear_consumers(&self) { let mut consumers = self.consumers.write().await; consumers.clear(); info!("[Indexing] Cleared all instant index consumers"); } pub async fn register_consumer(&self, consumer: Box) { info!( "[Indexing] Registering instant index consumer: {}", consumer.consumer_id() ); let mut guard = self.consumers.write().await; guard.push(consumer); } pub async fn spawn_instant_indexed_provider(&self, runtime: &Runtime) -> FlowyResult<()> { let weak_collab_by_object = Arc::downgrade(&self.collab_by_object); let consumers_weak = Arc::downgrade(&self.consumers); let interval_dur = Duration::from_secs(30); runtime.spawn(async move { let mut ticker = interval(interval_dur); ticker.tick().await; loop { ticker.tick().await; // Upgrade our state holders let collab_by_object = match weak_collab_by_object.upgrade() { Some(m) => m, None => break, // provider dropped }; let consumers = match consumers_weak.upgrade() { Some(c) => c, None => break, }; // Snapshot keys and consumers under read locks let (object_ids, mut to_remove) = { let guard = collab_by_object.read().await; let keys: Vec<_> = guard.keys().cloned().collect(); (keys, Vec::new()) }; let guard = collab_by_object.read().await; for id in object_ids { // Check if the collab is still alive match guard.get(&id) { Some(wo) => { if let Some(collab_rc) = wo.collab.upgrade() { let data = collab_rc .get_unindexed_data(&wo.collab_object.collab_type) .await; let consumers_guard = consumers.read().await; for consumer in consumers_guard.iter() { let workspace_id = match Uuid::parse_str(&wo.collab_object.workspace_id) { Ok(id) => id, Err(err) => { error!( "Invalid workspace_id {}: {}", wo.collab_object.workspace_id, err ); continue; }, }; let object_id = match Uuid::parse_str(&wo.collab_object.object_id) { Ok(id) => id, Err(err) => { error!("Invalid object_id {}: {}", wo.collab_object.object_id, err); continue; }, }; match consumer .consume_collab( &workspace_id, data.clone(), &object_id, wo.collab_object.collab_type, ) .await { Ok(is_indexed) => { if is_indexed { trace!("[Indexing] {} consumed {}", consumer.consumer_id(), id); } }, Err(err) => { error!( "Consumer {} failed on {}: {}", consumer.consumer_id(), id, err ); }, } } } else { // Mark for removal if collab was dropped to_remove.push(id); } }, None => continue, } } if !to_remove.is_empty() { let mut guard = collab_by_object.write().await; guard.retain(|k, _| !to_remove.contains(k)); trace!("[Indexing] Removed {} stale entries", to_remove.len()); } } info!("[Indexing] Instant indexed data provider stopped"); }); Ok(()) } pub fn support_collab_type(&self, t: &CollabType) -> bool { matches!(t, CollabType::Document) } pub async fn index_encoded_collab( &self, workspace_id: Uuid, object_id: Uuid, data: EncodedCollab, collab_type: CollabType, ) -> FlowyResult<()> { match unindexed_collab_from_encoded_collab(workspace_id, object_id, data, collab_type) { None => Err(FlowyError::internal().with_context("Failed to create unindexed collab")), Some(data) => { self.index_unindexed_collab(data).await?; Ok(()) }, } } pub async fn index_unindexed_collab(&self, data: UnindexedCollab) -> FlowyResult<()> { let consumers_guard = self.consumers.read().await; for consumer in consumers_guard.iter() { match consumer .consume_collab( &data.workspace_id, data.data.clone(), &data.object_id, data.collab_type, ) .await { Ok(is_indexed) => { if is_indexed { trace!( "[Indexing] {} consumed {}", consumer.consumer_id(), data.object_id ); } }, Err(err) => { error!( "Consumer {} failed on {}: {}", consumer.consumer_id(), data.object_id, err ); }, } } Ok(()) } pub async fn queue_collab_embed( &self, collab_object: CollabObject, collab: Weak, ) { if !self.support_collab_type(&collab_object.collab_type) { return; } let mut map = self.collab_by_object.write().await; map.insert( collab_object.object_id.clone(), WriteObject { collab_object, collab, }, ); } } pub fn unindexed_data_form_collab( collab: &Collab, collab_type: &CollabType, ) -> Option { match collab_type { CollabType::Document => { let txn = collab.doc().try_transact().ok()?; let doc = DocumentBody::from_collab(collab)?; let paras = doc.paragraphs(txn); Some(UnindexedData::Paragraphs(paras)) }, _ => None, } } pub fn unindexed_collab_from_encoded_collab( workspace_id: Uuid, object_id: Uuid, encoded_collab: EncodedCollab, collab_type: CollabType, ) -> Option { match collab_type { CollabType::Document => { let collab = Collab::new_with_source( CollabOrigin::Empty, &object_id.to_string(), DataSource::DocStateV1(encoded_collab.doc_state.to_vec()), vec![], false, ) .ok()?; let data = unindexed_data_form_collab(&collab, &collab_type)?; Some(UnindexedCollab { workspace_id, object_id, collab_type, data: Some(data), metadata: UnindexedCollabMetadata::default(), // default means do not update metadata }) }, _ => None, } } #[async_trait] pub trait CollabIndexedData: Send + Sync + 'static { async fn get_unindexed_data(&self, collab_type: &CollabType) -> Option; } #[async_trait] impl CollabIndexedData for RwLock where T: BorrowMut + Send + Sync + 'static, { async fn get_unindexed_data(&self, collab_type: &CollabType) -> Option { let collab = self.try_read().ok()?; unindexed_data_form_collab(collab.borrow(), collab_type) } } /// writer interface #[async_trait] pub trait InstantIndexedDataConsumer: Send + Sync + 'static { fn consumer_id(&self) -> String; async fn consume_collab( &self, workspace_id: &Uuid, data: Option, object_id: &Uuid, collab_type: CollabType, ) -> Result; async fn did_delete_collab( &self, workspace_id: &Uuid, object_id: &Uuid, ) -> Result<(), FlowyError>; } ================================================ FILE: frontend/rust-lib/collab-integrate/src/lib.rs ================================================ pub use collab::preclude::Snapshot; pub use collab_plugins::CollabKVDB; pub use collab_plugins::local_storage::CollabPersistenceConfig; pub mod collab_builder; pub mod config; pub mod instant_indexed_data_provider; mod plugin_provider; pub use collab_plugins::local_storage::kv::doc::CollabKVAction; pub use collab_plugins::local_storage::kv::error::PersistenceError; pub use collab_plugins::local_storage::kv::snapshot::{CollabSnapshot, SnapshotPersistence}; ================================================ FILE: frontend/rust-lib/collab-integrate/src/plugin_provider.rs ================================================ use collab::preclude::CollabPlugin; use crate::collab_builder::{CollabPluginProviderContext, CollabPluginProviderType}; pub trait CollabCloudPluginProvider: Send + Sync + 'static { fn provider_type(&self) -> CollabPluginProviderType; fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec>; fn is_sync_enabled(&self) -> bool; } impl CollabCloudPluginProvider for std::sync::Arc where U: CollabCloudPluginProvider, { fn provider_type(&self) -> CollabPluginProviderType { (**self).provider_type() } fn get_plugins(&self, context: CollabPluginProviderContext) -> Vec> { (**self).get_plugins(context) } fn is_sync_enabled(&self) -> bool { (**self).is_sync_enabled() } } ================================================ FILE: frontend/rust-lib/covtest.rs ================================================ ================================================ FILE: frontend/rust-lib/dart-ffi/Cargo.toml ================================================ [package] name = "dart-ffi" version = "0.1.0" edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] name = "dart_ffi" # this value will change depending on the target os # default static library crate-type = ["staticlib"] [dependencies] allo-isolate = { version = "^0.1", features = ["catch-unwind"] } byteorder = { version = "1.4.3" } protobuf.workspace = true tokio = { workspace = true, features = ["full", "rt-multi-thread", "tracing"] } serde.workspace = true serde_repr.workspace = true serde_json.workspace = true bytes.workspace = true crossbeam-utils = "0.8.15" lazy_static = "1.4.0" tracing.workspace = true lib-log.workspace = true semver = "1.0.22" # workspace lib-dispatch = { workspace = true, features = ["local_set"] } # Core #flowy-core = { workspace = true, features = ["profiling"] } #flowy-core = { workspace = true, features = ["verbose_log"] } flowy-core = { workspace = true } flowy-notification = { workspace = true, features = ["dart"] } flowy-document = { workspace = true, features = ["dart"] } flowy-user = { workspace = true, features = ["dart"] } flowy-date = { workspace = true, features = ["dart"] } flowy-server = { workspace = true } flowy-server-pub = { workspace = true } collab-integrate = { workspace = true } flowy-derive.workspace = true serde_yaml = "0.9.27" flowy-error = { workspace = true, features = ["impl_from_sqlite", "impl_from_dispatch_error", "impl_from_appflowy_cloud", "impl_from_reqwest", "impl_from_serde", "dart"] } futures = "0.3.31" [features] default = ["dart"] dart = ["flowy-core/dart"] http_sync = ["flowy-core/http_sync"] openssl_vendored = ["flowy-core/openssl_vendored"] verbose_log = [] [build-dependencies] flowy-codegen = { workspace = true, features = ["dart"] } ================================================ FILE: frontend/rust-lib/dart-ffi/Flowy.toml ================================================ # Check out the FlowyConfig (located in flowy_toml.rs) for more details. proto_input = ["src/model"] ================================================ FILE: frontend/rust-lib/dart-ffi/binding.h ================================================ #include #include #include #include int64_t init_sdk(int64_t port, char *data); void async_event(int64_t port, const uint8_t *input, uintptr_t len); const uint8_t *sync_event(const uint8_t *input, uintptr_t len); int32_t set_stream_port(int64_t port); int32_t set_log_stream_port(int64_t port); void link_me_please(void); void rust_log(int64_t level, const char *data); void set_env(const char *data); ================================================ FILE: frontend/rust-lib/dart-ffi/build.rs ================================================ fn main() { flowy_codegen::protobuf_file::dart_gen(env!("CARGO_PKG_NAME")); } ================================================ FILE: frontend/rust-lib/dart-ffi/src/appflowy_yaml.rs ================================================ use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::path::Path; use serde::{Deserialize, Serialize}; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct AppFlowyYamlConfiguration { cloud_config: Vec, } pub fn save_appflowy_cloud_config( root: impl AsRef, new_config: &AFCloudConfiguration, ) -> Result<(), Box> { let file_path = root.as_ref().join("appflowy.yaml"); let mut config = read_yaml_file(&file_path).unwrap_or_default(); if !config .cloud_config .iter() .any(|c| c.base_url == new_config.base_url) { config.cloud_config.push(new_config.clone()); write_yaml_file(&file_path, &config)?; } Ok(()) } fn read_yaml_file( file_path: impl AsRef, ) -> Result> { let mut file = File::open(file_path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; let config: AppFlowyYamlConfiguration = serde_yaml::from_str(&contents)?; Ok(config) } fn write_yaml_file( file_path: impl AsRef, config: &AppFlowyYamlConfiguration, ) -> Result<(), Box> { let yaml_string = serde_yaml::to_string(config)?; let mut file = OpenOptions::new() .create(true) .write(true) .truncate(true) .open(file_path)?; file.write_all(yaml_string.as_bytes())?; Ok(()) } ================================================ FILE: frontend/rust-lib/dart-ffi/src/c.rs ================================================ use byteorder::{BigEndian, ByteOrder}; use std::mem::forget; pub fn forget_rust(buf: Vec) -> *const u8 { let ptr = buf.as_ptr(); forget(buf); ptr } #[allow(unused_attributes)] #[allow(dead_code)] pub fn reclaim_rust(ptr: *mut u8, length: u32) { unsafe { let len: usize = length as usize; Vec::from_raw_parts(ptr, len, len); } } pub fn extend_front_four_bytes_into_bytes(bytes: &[u8]) -> Vec { let mut output = Vec::with_capacity(bytes.len() + 4); let mut marker_bytes = [0; 4]; BigEndian::write_u32(&mut marker_bytes, bytes.len() as u32); output.extend_from_slice(&marker_bytes); output.extend_from_slice(bytes); output } ================================================ FILE: frontend/rust-lib/dart-ffi/src/env_serde.rs ================================================ use std::collections::HashMap; use serde::Deserialize; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; #[derive(Deserialize, Debug)] pub struct AppFlowyDartConfiguration { /// The root path of the application pub root: String, pub app_version: String, /// This path will be used to store the user data pub custom_app_path: String, pub origin_app_path: String, pub device_id: String, pub platform: String, pub authenticator_type: AuthenticatorType, pub(crate) appflowy_cloud_config: AFCloudConfiguration, #[serde(default)] pub(crate) envs: HashMap, } impl AppFlowyDartConfiguration { pub fn from_str(s: &str) -> Self { serde_json::from_str::(s).unwrap() } pub fn write_env(&self) { self.authenticator_type.write_env(); self.appflowy_cloud_config.write_env(); for (k, v) in self.envs.iter() { std::env::set_var(k, v); } } } ================================================ FILE: frontend/rust-lib/dart-ffi/src/lib.rs ================================================ #![allow(clippy::not_unsafe_ptr_arg_deref)] use allo_isolate::Isolate; use futures::ready; use lazy_static::lazy_static; use semver::Version; use std::future::Future; use std::pin::Pin; use std::sync::{Arc, RwLock}; use std::task::{Context, Poll}; use std::{ffi::CStr, os::raw::c_char}; use tokio::sync::mpsc; use tokio::task::LocalSet; use tracing::{debug, error, info, trace, warn}; use flowy_core::config::AppFlowyCoreConfig; use flowy_core::*; use flowy_notification::{register_notification_sender, unregister_all_notification_sender}; use flowy_server_pub::AuthenticatorType; use lib_dispatch::prelude::ToBytes; use lib_dispatch::prelude::*; use lib_dispatch::runtime::AFPluginRuntime; use lib_log::stream_log::StreamLogSender; use crate::appflowy_yaml::save_appflowy_cloud_config; use crate::env_serde::AppFlowyDartConfiguration; use crate::notification::DartNotificationSender; use crate::{ c::{extend_front_four_bytes_into_bytes, forget_rust}, model::{FFIRequest, FFIResponse}, }; mod appflowy_yaml; mod c; mod env_serde; mod model; mod notification; mod protobuf; lazy_static! { static ref DART_APPFLOWY_CORE: DartAppFlowyCore = DartAppFlowyCore::new(); static ref LOG_STREAM_ISOLATE: RwLock> = RwLock::new(None); } pub struct Task { dispatcher: Arc, request: AFPluginRequest, port: i64, ret: Option>, } unsafe impl Send for Task {} unsafe impl Sync for DartAppFlowyCore {} struct DartAppFlowyCore { core: Arc>>, handle: RwLock>>, sender: RwLock>>, } impl DartAppFlowyCore { fn new() -> Self { Self { #[allow(clippy::arc_with_non_send_sync)] core: Arc::new(RwLock::new(None)), handle: RwLock::new(None), sender: RwLock::new(None), } } fn dispatcher(&self) -> Option> { let binding = self .core .read() .expect("Failed to acquire read lock for core"); let core = binding.as_ref(); core.map(|core| core.event_dispatcher.clone()) } fn dispatch( &self, request: AFPluginRequest, port: i64, ret: Option>, ) { if let Ok(sender_guard) = self.sender.read() { let dispatcher = match self.dispatcher() { Some(dispatcher) => dispatcher, None => { error!("Failed to get dispatcher: dispatcher is None"); return; }, }; if let Some(sender) = sender_guard.as_ref() { if let Err(e) = sender.send(Task { dispatcher, request, port, ret, }) { error!("Failed to send task: {}", e); } } else { error!("Failed to send task: sender is None"); } } else { warn!("Failed to acquire read lock for sender"); } } } #[no_mangle] pub extern "C" fn init_sdk(_port: i64, data: *mut c_char) -> i64 { let c_str = unsafe { if data.is_null() { return -1; } CStr::from_ptr(data) }; let serde_str = c_str .to_str() .expect("Failed to convert C string to Rust string"); let configuration = AppFlowyDartConfiguration::from_str(serde_str); configuration.write_env(); if configuration.authenticator_type == AuthenticatorType::AppFlowyCloud { let _ = save_appflowy_cloud_config(&configuration.root, &configuration.appflowy_cloud_config); } let mut app_version = Version::parse(&configuration.app_version).unwrap_or_else(|_| Version::new(0, 5, 8)); let min_version = Version::new(0, 5, 8); if app_version < min_version { app_version = min_version; } let config = AppFlowyCoreConfig::new( app_version, configuration.custom_app_path, configuration.origin_app_path, configuration.device_id, configuration.platform, DEFAULT_NAME.to_string(), ); if let Some(core) = &*DART_APPFLOWY_CORE.core.write().unwrap() { core.close_db(); } let log_stream = LOG_STREAM_ISOLATE .write() .unwrap() .take() .map(|isolate| Arc::new(LogStreamSenderImpl { isolate }) as Arc); let (sender, task_rx) = mpsc::unbounded_channel::(); let runtime = Arc::new(AFPluginRuntime::new().unwrap()); let cloned_runtime = runtime.clone(); let handle = std::thread::spawn(move || { let local_set = LocalSet::new(); cloned_runtime.block_on(local_set.run_until(Runner { rx: task_rx })); }); *DART_APPFLOWY_CORE.sender.write().unwrap() = Some(sender); *DART_APPFLOWY_CORE.handle.write().unwrap() = Some(handle); let cloned_runtime = runtime.clone(); *DART_APPFLOWY_CORE.core.write().unwrap() = runtime .block_on(async move { Some(AppFlowyCore::new(config, cloned_runtime, log_stream).await) }); 0 } #[no_mangle] #[allow(clippy::let_underscore_future)] pub extern "C" fn async_event(port: i64, input: *const u8, len: usize) { let request: AFPluginRequest = FFIRequest::from_u8_pointer(input, len).into(); #[cfg(feature = "verbose_log")] trace!( "[FFI]: {} Async Event: {:?} with {} port", &request.id, &request.event, port ); DART_APPFLOWY_CORE.dispatch(request, port, None); } /// A persistent future that processes [Arbiter] commands. struct Runner { rx: mpsc::UnboundedReceiver, } impl Future for Runner { type Output = (); fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { loop { match ready!(self.rx.poll_recv(cx)) { None => return Poll::Ready(()), Some(task) => { let Task { dispatcher, request, port, ret, } = task; tokio::task::spawn_local(async move { let resp = AFPluginDispatcher::boxed_async_send_with_callback( dispatcher.as_ref(), request, move |resp: AFPluginEventResponse| { #[cfg(feature = "verbose_log")] trace!("[FFI]: Post data to dart through {} port", port); Box::pin(post_to_flutter(resp, port)) }, ) .await; if let Some(ret) = ret { let _ = ret.send(resp).await; } }); }, } } } } #[no_mangle] pub extern "C" fn sync_event(_input: *const u8, _len: usize) -> *const u8 { error!("unimplemented sync_event"); let response_bytes = vec![]; let result = extend_front_four_bytes_into_bytes(&response_bytes); forget_rust(result) } #[no_mangle] pub extern "C" fn set_stream_port(notification_port: i64) -> i32 { unregister_all_notification_sender(); register_notification_sender(DartNotificationSender::new(notification_port)); 0 } #[no_mangle] pub extern "C" fn set_log_stream_port(port: i64) -> i32 { *LOG_STREAM_ISOLATE.write().unwrap() = Some(Isolate::new(port)); 0 } #[inline(never)] #[no_mangle] pub extern "C" fn link_me_please() {} #[inline(always)] #[allow(clippy::blocks_in_conditions)] async fn post_to_flutter(response: AFPluginEventResponse, port: i64) { let isolate = allo_isolate::Isolate::new(port); match isolate .catch_unwind(async { let ffi_resp = FFIResponse::from(response); ffi_resp.into_bytes().unwrap().to_vec() }) .await { Ok(_) => { #[cfg(feature = "verbose_log")] trace!("[FFI]: Post data to dart success"); }, Err(err) => { error!("[FFI]: allo_isolate post failed: {:?}", err); }, } } #[no_mangle] pub extern "C" fn rust_log(level: i64, data: *const c_char) { if data.is_null() { error!("[flutter error]: null pointer provided to backend_log"); return; } let log_result = unsafe { CStr::from_ptr(data) }.to_str(); let log_str = match log_result { Ok(str) => str, Err(e) => { error!( "[flutter error]: Failed to convert C string to Rust string: {:?}", e ); return; }, }; match level { 0 => info!("[Flutter]: {}", log_str), 1 => debug!("[Flutter]: {}", log_str), 2 => trace!("[Flutter]: {}", log_str), 3 => warn!("[Flutter]: {}", log_str), 4 => error!("[Flutter]: {}", log_str), _ => warn!("[flutter error]: Unsupported log level: {}", level), } } #[no_mangle] pub extern "C" fn set_env(_data: *const c_char) { // Deprecated } struct LogStreamSenderImpl { isolate: Isolate, } impl StreamLogSender for LogStreamSenderImpl { fn send(&self, message: &[u8]) { self.isolate.post(message.to_vec()); } } ================================================ FILE: frontend/rust-lib/dart-ffi/src/model/ffi_request.rs ================================================ use bytes::Bytes; use flowy_derive::ProtoBuf; use lib_dispatch::prelude::AFPluginRequest; use std::convert::TryFrom; #[derive(Default, ProtoBuf)] pub struct FFIRequest { #[pb(index = 1)] pub(crate) event: String, #[pb(index = 2)] pub(crate) payload: Vec, } impl FFIRequest { pub fn from_u8_pointer(pointer: *const u8, len: usize) -> Self { let buffer = unsafe { std::slice::from_raw_parts(pointer, len) }.to_vec(); let bytes = Bytes::from(buffer); let request: FFIRequest = FFIRequest::try_from(bytes).unwrap(); request } } impl std::convert::From for AFPluginRequest { fn from(ffi_request: FFIRequest) -> Self { AFPluginRequest::new(ffi_request.event).payload(ffi_request.payload) } } ================================================ FILE: frontend/rust-lib/dart-ffi/src/model/ffi_response.rs ================================================ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use lib_dispatch::prelude::{AFPluginEventResponse, Payload, StatusCode}; #[derive(ProtoBuf_Enum, Clone, Copy, Default)] pub enum FFIStatusCode { #[default] Ok = 0, Err = 1, Internal = 2, } #[derive(ProtoBuf, Default)] pub struct FFIResponse { #[pb(index = 1)] payload: Vec, #[pb(index = 2)] code: FFIStatusCode, } impl std::convert::From for FFIResponse { fn from(resp: AFPluginEventResponse) -> Self { let payload = match resp.payload { Payload::Bytes(bytes) => bytes.to_vec(), Payload::None => vec![], }; let code = match resp.status_code { StatusCode::Ok => FFIStatusCode::Ok, StatusCode::Err => FFIStatusCode::Err, }; // let msg = match resp.error { // None => "".to_owned(), // Some(e) => format!("{:?}", e), // }; FFIResponse { payload, code } } } ================================================ FILE: frontend/rust-lib/dart-ffi/src/model/mod.rs ================================================ mod ffi_request; mod ffi_response; pub use ffi_request::*; pub use ffi_response::*; ================================================ FILE: frontend/rust-lib/dart-ffi/src/notification/mod.rs ================================================ mod sender; pub use sender::*; ================================================ FILE: frontend/rust-lib/dart-ffi/src/notification/sender.rs ================================================ use allo_isolate::Isolate; use bytes::Bytes; use flowy_notification::entities::SubscribeObject; use flowy_notification::NotificationSender; use std::convert::TryInto; pub struct DartNotificationSender { isolate: Isolate, } impl DartNotificationSender { pub fn new(port: i64) -> Self { Self { isolate: Isolate::new(port), } } } impl NotificationSender for DartNotificationSender { fn send_subject(&self, subject: SubscribeObject) -> Result<(), String> { let bytes: Bytes = subject.try_into().unwrap(); self.isolate.post(bytes.to_vec()); Ok(()) } } ================================================ FILE: frontend/rust-lib/event-integration-test/Cargo.toml ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/src/chat_event.rs ================================================ use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; use flowy_ai::entities::{ ChatId, ChatMessageListPB, ChatMessageTypePB, LoadNextChatMessagePB, LoadPrevChatMessagePB, StreamChatPayloadPB, UpdateChatSettingsPB, }; use flowy_ai::event_map::AIEvent; use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; use flowy_folder::event_map::FolderEvent; impl EventIntegrationTest { pub async fn create_chat(&self, parent_id: &str) -> ViewPB { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name: "chat".to_string(), thumbnail: None, layout: ViewLayoutPB::Chat, initial_data: vec![], meta: Default::default(), set_as_current: true, index: None, section: None, view_id: None, extra: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn set_chat_rag_ids(&self, chat_id: &str, rag_ids: Vec) { let payload = UpdateChatSettingsPB { chat_id: ChatId { value: chat_id.to_string(), }, rag_ids, }; EventBuilder::new(self.clone()) .event(AIEvent::UpdateChatSettings) .payload(payload) .async_send() .await; } pub async fn send_message( &self, chat_id: &str, message: impl ToString, message_type: ChatMessageTypePB, ) { let payload = StreamChatPayloadPB { chat_id: chat_id.to_string(), message: message.to_string(), message_type, answer_stream_port: 0, question_stream_port: 0, format: None, prompt_id: None, }; EventBuilder::new(self.clone()) .event(AIEvent::StreamMessage) .payload(payload) .async_send() .await; } pub async fn load_prev_message( &self, chat_id: &str, limit: i64, before_message_id: Option, ) -> ChatMessageListPB { let payload = LoadPrevChatMessagePB { chat_id: chat_id.to_string(), limit, before_message_id, }; EventBuilder::new(self.clone()) .event(AIEvent::LoadPrevMessage) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn load_next_message( &self, chat_id: &str, limit: i64, after_message_id: Option, ) -> ChatMessageListPB { let payload = LoadNextChatMessagePB { chat_id: chat_id.to_string(), limit, after_message_id, }; EventBuilder::new(self.clone()) .event(AIEvent::LoadNextMessage) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn toggle_local_ai(&self) { EventBuilder::new(self.clone()) .event(AIEvent::ToggleLocalAI) .async_send() .await; } } ================================================ FILE: frontend/rust-lib/event-integration-test/src/database_event.rs ================================================ use std::collections::HashMap; use std::convert::TryFrom; use bytes::Bytes; use collab_database::database::timestamp; use collab_database::fields::select_type_option::{ MultiSelectTypeOption, SelectOption, SingleSelectTypeOption, }; use collab_database::fields::Field; use collab_database::rows::{Row, RowId}; use flowy_database2::entities::*; use flowy_database2::event_map::DatabaseEvent; use flowy_database2::services::cell::CellBuilder; use flowy_database2::services::field::checklist_filter::ChecklistCellInsertChangeset; use flowy_database2::services::share::csv::CSVFormat; use flowy_folder::entities::*; use flowy_folder::event_map::FolderEvent; use flowy_user::errors::FlowyError; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; impl EventIntegrationTest { pub async fn get_database_export_data(&self, database_view_id: &str) -> String { self .appflowy_core .database_manager .get_database_editor_with_view_id(database_view_id) .await .unwrap() .export_csv(CSVFormat::Original) .await .unwrap() } /// The initial data can refer to the [FolderOperationHandler::create_view_with_view_data] method. pub async fn create_grid(&self, parent_id: &str, name: String, initial_data: Vec) -> ViewPB { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, thumbnail: None, layout: ViewLayoutPB::Grid, initial_data, meta: Default::default(), set_as_current: true, index: None, section: None, view_id: None, extra: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn open_database(&self, view_id: &str) -> DatabasePB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetDatabase) .payload(DatabaseViewIdPB { value: view_id.to_string(), }) .async_send() .await .parse_or_panic::() } pub async fn create_board(&self, parent_id: &str, name: String, initial_data: Vec) -> ViewPB { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, thumbnail: None, layout: ViewLayoutPB::Board, initial_data, meta: Default::default(), set_as_current: true, index: None, section: None, view_id: None, extra: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn create_calendar( &self, parent_id: &str, name: String, initial_data: Vec, ) -> ViewPB { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, thumbnail: None, layout: ViewLayoutPB::Calendar, initial_data, meta: Default::default(), set_as_current: true, index: None, section: None, view_id: None, extra: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn get_database(&self, view_id: &str) -> DatabasePB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetDatabase) .payload(DatabaseViewIdPB { value: view_id.to_string(), }) .async_send() .await .parse_or_panic::() } pub async fn get_all_database_fields(&self, view_id: &str) -> RepeatedFieldPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetFields) .payload(GetFieldPayloadPB { view_id: view_id.to_string(), field_ids: None, }) .async_send() .await .parse_or_panic::() } pub async fn create_field(&self, view_id: &str, field_type: FieldType) -> FieldPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::CreateField) .payload(CreateFieldPayloadPB { view_id: view_id.to_string(), field_type, ..Default::default() }) .async_send() .await .parse_or_panic::() } pub async fn update_field(&self, changeset: FieldChangesetPB) { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateField) .payload(changeset) .async_send() .await; } pub async fn delete_field(&self, view_id: &str, field_id: &str) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::DeleteField) .payload(DeleteFieldPayloadPB { view_id: view_id.to_string(), field_id: field_id.to_string(), }) .async_send() .await .error() } pub async fn remove_calculate( &self, changeset: RemoveCalculationChangesetPB, ) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::RemoveCalculation) .payload(changeset) .async_send() .await .error() } pub async fn get_all_calculations(&self, database_view_id: &str) -> RepeatedCalculationsPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetAllCalculations) .payload(DatabaseViewIdPB { value: database_view_id.to_string(), }) .async_send() .await .parse_or_panic::() } pub async fn update_calculation( &self, changeset: UpdateCalculationChangesetPB, ) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateCalculation) .payload(changeset) .async_send() .await .error() } pub async fn update_field_type( &self, view_id: &str, field_id: &str, field_type: FieldType, ) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateFieldType) .payload(UpdateFieldTypePayloadPB { view_id: view_id.to_string(), field_id: field_id.to_string(), field_type, field_name: None, }) .async_send() .await .error() } pub async fn duplicate_field(&self, view_id: &str, field_id: &str) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::DuplicateField) .payload(DuplicateFieldPayloadPB { view_id: view_id.to_string(), field_id: field_id.to_string(), }) .async_send() .await .error() } pub async fn get_primary_field(&self, database_view_id: &str) -> FieldPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetPrimaryField) .payload(DatabaseViewIdPB { value: database_view_id.to_string(), }) .async_send() .await .parse_or_panic::() } pub async fn summary_row(&self, data: SummaryRowPB) { EventBuilder::new(self.clone()) .event(DatabaseEvent::SummarizeRow) .payload(data) .async_send() .await; } pub async fn translate_row(&self, data: TranslateRowPB) { EventBuilder::new(self.clone()) .event(DatabaseEvent::TranslateRow) .payload(data) .async_send() .await; } pub async fn create_row( &self, view_id: &str, row_position: OrderObjectPositionPB, data: Option>, ) -> RowMetaPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::CreateRow) .payload(CreateRowPayloadPB { view_id: view_id.to_string(), row_position, group_id: None, data: data.unwrap_or_default(), }) .async_send() .await .parse_or_panic::() } pub async fn delete_row(&self, view_id: &str, row_id: &str) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::DeleteRows) .payload(RepeatedRowIdPB { view_id: view_id.to_string(), row_ids: vec![row_id.to_string()], }) .async_send() .await .error() } pub async fn get_row(&self, view_id: &str, row_id: &str) -> OptionalRowPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetRow) .payload(DatabaseViewRowIdPB { view_id: view_id.to_string(), row_id: row_id.to_string(), group_id: None, }) .async_send() .await .parse_or_panic::() } pub async fn get_row_meta(&self, view_id: &str, row_id: &str) -> RowMetaPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetRowMeta) .payload(DatabaseViewRowIdPB { view_id: view_id.to_string(), row_id: row_id.to_string(), group_id: None, }) .async_send() .await .parse_or_panic::() } pub async fn update_row_meta(&self, changeset: UpdateRowMetaChangesetPB) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateRowMeta) .payload(changeset) .async_send() .await .error() } pub async fn duplicate_row(&self, view_id: &str, row_id: &str) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::DuplicateRow) .payload(DatabaseViewRowIdPB { view_id: view_id.to_string(), row_id: row_id.to_string(), group_id: None, }) .async_send() .await .error() } pub async fn move_row(&self, view_id: &str, row_id: &str, to_row_id: &str) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::MoveRow) .payload(MoveRowPayloadPB { view_id: view_id.to_string(), from_row_id: row_id.to_string(), to_row_id: to_row_id.to_string(), }) .async_send() .await .error() } pub async fn update_cell(&self, changeset: CellChangesetPB) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateCell) .payload(changeset) .async_send() .await .error() } pub async fn update_date_cell(&self, changeset: DateCellChangesetPB) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateDateCell) .payload(changeset) .async_send() .await .error() } pub async fn get_cell(&self, view_id: &str, row_id: &str, field_id: &str) -> CellPB { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetCell) .payload(CellIdPB { view_id: view_id.to_string(), row_id: row_id.to_string(), field_id: field_id.to_string(), }) .async_send() .await .parse_or_panic::() } pub async fn get_text_cell(&self, view_id: &str, row_id: &str, field_id: &str) -> String { let cell = self.get_cell(view_id, row_id, field_id).await; String::from_utf8(cell.data).unwrap() } pub async fn get_date_cell(&self, view_id: &str, row_id: &str, field_id: &str) -> DateCellDataPB { let cell = self.get_cell(view_id, row_id, field_id).await; DateCellDataPB::try_from(Bytes::from(cell.data)).unwrap() } pub async fn get_checklist_cell( &self, view_id: &str, field_id: &str, row_id: &str, ) -> ChecklistCellDataPB { let cell = self.get_cell(view_id, row_id, field_id).await; ChecklistCellDataPB::try_from(Bytes::from(cell.data)).unwrap() } pub async fn get_relation_cell( &self, view_id: &str, field_id: &str, row_id: &str, ) -> RelationCellDataPB { let cell = self.get_cell(view_id, row_id, field_id).await; RelationCellDataPB::try_from(Bytes::from(cell.data)).unwrap_or_default() } pub async fn update_checklist_cell( &self, changeset: ChecklistCellDataChangesetPB, ) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateChecklistCell) .payload(changeset) .async_send() .await .error() } pub async fn insert_option( &self, view_id: &str, field_id: &str, row_id: &str, name: &str, ) -> Option { let option = EventBuilder::new(self.clone()) .event(DatabaseEvent::CreateSelectOption) .payload(CreateSelectOptionPayloadPB { field_id: field_id.to_string(), view_id: view_id.to_string(), option_name: name.to_string(), }) .async_send() .await .parse_or_panic::(); EventBuilder::new(self.clone()) .event(DatabaseEvent::InsertOrUpdateSelectOption) .payload(RepeatedSelectOptionPayload { view_id: view_id.to_string(), field_id: field_id.to_string(), row_id: row_id.to_string(), items: vec![option], }) .async_send() .await .error() } pub async fn get_groups(&self, view_id: &str) -> Vec { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetGroups) .payload(DatabaseViewIdPB { value: view_id.to_string(), }) .async_send() .await .parse_or_panic::() .items } pub async fn move_group(&self, view_id: &str, from_id: &str, to_id: &str) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::MoveGroup) .payload(MoveGroupPayloadPB { view_id: view_id.to_string(), from_group_id: from_id.to_string(), to_group_id: to_id.to_string(), }) .async_send() .await .error() } pub async fn set_group_by_field( &self, view_id: &str, field_id: &str, setting_content: Vec, ) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::SetGroupByField) .payload(GroupByFieldPayloadPB { field_id: field_id.to_string(), view_id: view_id.to_string(), setting_content, }) .async_send() .await .error() } pub async fn update_group( &self, view_id: &str, group_id: &str, name: Option, visible: Option, ) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateGroup) .payload(UpdateGroupPB { view_id: view_id.to_string(), group_id: group_id.to_string(), name, visible, }) .async_send() .await .error() } pub async fn delete_group(&self, view_id: &str, group_id: &str) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::DeleteGroup) .payload(DeleteGroupPayloadPB { view_id: view_id.to_string(), group_id: group_id.to_string(), }) .async_send() .await .error() } pub async fn update_setting(&self, changeset: DatabaseSettingChangesetPB) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateDatabaseSetting) .payload(changeset) .async_send() .await .error() } pub async fn get_all_calendar_events(&self, view_id: &str) -> Vec { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetAllCalendarEvents) .payload(CalendarEventRequestPB { view_id: view_id.to_string(), }) .async_send() .await .parse_or_panic::() .items } pub async fn update_relation_cell( &self, changeset: RelationCellChangesetPB, ) -> Option { EventBuilder::new(self.clone()) .event(DatabaseEvent::UpdateRelationCell) .payload(changeset) .async_send() .await .error() } pub async fn get_related_row_data( &self, database_id: String, row_ids: Vec, ) -> Vec { EventBuilder::new(self.clone()) .event(DatabaseEvent::GetRelatedRowDatas) .payload(GetRelatedRowDataPB { database_id, row_ids, }) .async_send() .await .parse_or_panic::() .rows } } pub struct TestRowBuilder<'a> { database_id: &'a str, row_id: RowId, fields: &'a [Field], cell_build: CellBuilder<'a>, } impl<'a> TestRowBuilder<'a> { pub fn new(database_id: &'a str, row_id: RowId, fields: &'a [Field]) -> Self { let cell_build = CellBuilder::with_cells(Default::default(), fields); Self { database_id, row_id, fields, cell_build, } } pub fn insert_text_cell(&mut self, data: &str) -> String { let text_field = self.field_with_type(&FieldType::RichText); self .cell_build .insert_text_cell(&text_field.id, data.to_string()); text_field.id.clone() } pub fn insert_number_cell(&mut self, data: &str) -> String { let number_field = self.field_with_type(&FieldType::Number); self .cell_build .insert_text_cell(&number_field.id, data.to_string()); number_field.id.clone() } pub fn insert_date_cell( &mut self, timestamp: i64, include_time: Option, field_type: &FieldType, ) -> String { let date_field = self.field_with_type(field_type); self .cell_build .insert_date_cell(&date_field.id, timestamp, include_time); date_field.id.clone() } pub fn insert_checkbox_cell(&mut self, data: &str) -> String { let checkbox_field = self.field_with_type(&FieldType::Checkbox); self .cell_build .insert_text_cell(&checkbox_field.id, data.to_string()); checkbox_field.id.clone() } pub fn insert_url_cell(&mut self, content: &str) -> String { let url_field = self.field_with_type(&FieldType::URL); self .cell_build .insert_url_cell(&url_field.id, content.to_string()); url_field.id.clone() } pub fn insert_single_select_cell(&mut self, f: F) -> String where F: Fn(Vec) -> SelectOption, { let single_select_field = self.field_with_type(&FieldType::SingleSelect); let type_option = single_select_field .get_type_option::(FieldType::SingleSelect) .unwrap() .0; let option = f(type_option.options); self .cell_build .insert_select_option_cell(&single_select_field.id, vec![option.id]); single_select_field.id.clone() } pub fn insert_multi_select_cell(&mut self, f: F) -> String where F: Fn(Vec) -> Vec, { let multi_select_field = self.field_with_type(&FieldType::MultiSelect); let type_option = multi_select_field .get_type_option::(FieldType::MultiSelect) .unwrap() .0; let options = f(type_option.options); let ops_ids = options .iter() .map(|option| option.id.clone()) .collect::>(); self .cell_build .insert_select_option_cell(&multi_select_field.id, ops_ids); multi_select_field.id.clone() } pub fn insert_checklist_cell(&mut self, new_tasks: Vec) -> String { let checklist_field = self.field_with_type(&FieldType::Checklist); self .cell_build .insert_checklist_cell(&checklist_field.id, new_tasks); checklist_field.id.clone() } pub fn insert_time_cell(&mut self, time: i64) -> String { let time_field = self.field_with_type(&FieldType::Time); self.cell_build.insert_number_cell(&time_field.id, time); time_field.id.clone() } pub fn insert_media_cell(&mut self, media: String) -> String { let media_field = self.field_with_type(&FieldType::Media); self.cell_build.insert_text_cell(&media_field.id, media); media_field.id.clone() } pub fn field_with_type(&self, field_type: &FieldType) -> Field { self .fields .iter() .find(|field| { let t_field_type = FieldType::from(field.field_type); &t_field_type == field_type }) .unwrap() .clone() } pub fn build(self) -> Row { let timestamp = timestamp(); Row { id: self.row_id, database_id: self.database_id.to_string(), cells: self.cell_build.build(), height: 60, visibility: true, modified_at: timestamp, created_at: timestamp, } } } ================================================ FILE: frontend/rust-lib/event-integration-test/src/document/document_event.rs ================================================ use collab::entity::EncodedCollab; use std::collections::HashMap; use flowy_document::entities::*; use flowy_document::event_map::DocumentEvent; use flowy_document::parser::parser_entities::{ ConvertDataToJsonPayloadPB, ConvertDataToJsonResponsePB, ConvertDocumentPayloadPB, ConvertDocumentResponsePB, }; use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; use flowy_folder::event_map::FolderEvent; use serde_json::Value; use uuid::Uuid; use crate::document::utils::{gen_delta_str, gen_id, gen_text_block_data}; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; const TEXT_BLOCK_TY: &str = "paragraph"; pub struct DocumentEventTest { event_test: EventIntegrationTest, } pub struct OpenDocumentData { pub id: String, pub data: DocumentDataPB, } impl DocumentEventTest { pub async fn new() -> Self { let sdk = EventIntegrationTest::new_anon().await; Self { event_test: sdk } } pub fn new_with_core(core: EventIntegrationTest) -> Self { Self { event_test: core } } pub async fn get_encoded_v1(&self, doc_id: &Uuid) -> EncodedCollab { let doc = self .event_test .appflowy_core .document_manager .editable_document(doc_id) .await .unwrap(); let guard = doc.read().await; guard.encode_collab().unwrap() } pub async fn get_encoded_collab(&self, doc_id: &str) -> EncodedCollabPB { let core = &self.event_test; let payload = OpenDocumentPayloadPB { document_id: doc_id.to_string(), }; EventBuilder::new(core.clone()) .event(DocumentEvent::GetDocEncodedCollab) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn create_document(&self) -> ViewPB { let core = &self.event_test; let current_workspace = core.get_current_workspace().await; let parent_id = current_workspace.id.clone(); let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name: "document".to_string(), thumbnail: None, layout: ViewLayoutPB::Document, initial_data: vec![], meta: Default::default(), set_as_current: true, index: None, section: None, view_id: None, extra: None, }; EventBuilder::new(core.clone()) .event(FolderEvent::CreateView) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn open_document(&self, doc_id: String) -> OpenDocumentData { self.event_test.open_document(doc_id).await } pub async fn get_block(&self, doc_id: &str, block_id: &str) -> Option { let document_data = self.event_test.open_document(doc_id.to_string()).await; document_data.data.blocks.get(block_id).cloned() } pub async fn get_page_id(&self, doc_id: &str) -> String { let data = self.get_document_data(doc_id).await; data.page_id } pub async fn get_document_data(&self, doc_id: &str) -> DocumentDataPB { let document_data = self.event_test.open_document(doc_id.to_string()).await; document_data.data } pub async fn get_block_children(&self, doc_id: &str, block_id: &str) -> Option> { let block = self.get_block(doc_id, block_id).await; block.as_ref()?; let document_data = self.get_document_data(doc_id).await; let children_map = document_data.meta.children_map; let children_id = block.unwrap().children_id; children_map.get(&children_id).map(|c| c.children.clone()) } pub async fn get_text_id(&self, doc_id: &str, block_id: &str) -> Option { let block = self.get_block(doc_id, block_id).await?; block.external_id } pub async fn get_delta(&self, doc_id: &str, text_id: &str) -> Option { let document_data = self.get_document_data(doc_id).await; document_data.meta.text_map.get(text_id).cloned() } pub async fn apply_actions(&self, payload: ApplyActionPayloadPB) { let core = &self.event_test; EventBuilder::new(core.clone()) .event(DocumentEvent::ApplyAction) .payload(payload) .async_send() .await; } pub async fn convert_document( &self, payload: ConvertDocumentPayloadPB, ) -> ConvertDocumentResponsePB { let core = &self.event_test; EventBuilder::new(core.clone()) .event(DocumentEvent::ConvertDocument) .payload(payload) .async_send() .await .parse_or_panic::() } // convert data to json for document event test pub async fn convert_data_to_json( &self, payload: ConvertDataToJsonPayloadPB, ) -> ConvertDataToJsonResponsePB { let core = &self.event_test; EventBuilder::new(core.clone()) .event(DocumentEvent::ConvertDataToJSON) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn create_text(&self, payload: TextDeltaPayloadPB) { let core = &self.event_test; EventBuilder::new(core.clone()) .event(DocumentEvent::CreateText) .payload(payload) .async_send() .await; } pub async fn apply_text_delta(&self, payload: TextDeltaPayloadPB) { let core = &self.event_test; EventBuilder::new(core.clone()) .event(DocumentEvent::ApplyTextDeltaEvent) .payload(payload) .async_send() .await; } pub async fn undo(&self, doc_id: String) -> DocumentRedoUndoResponsePB { let core = &self.event_test; let payload = DocumentRedoUndoPayloadPB { document_id: doc_id.clone(), }; EventBuilder::new(core.clone()) .event(DocumentEvent::Undo) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn redo(&self, doc_id: String) -> DocumentRedoUndoResponsePB { let core = &self.event_test; let payload = DocumentRedoUndoPayloadPB { document_id: doc_id.clone(), }; EventBuilder::new(core.clone()) .event(DocumentEvent::Redo) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn can_undo_redo(&self, doc_id: String) -> DocumentRedoUndoResponsePB { let core = &self.event_test; let payload = DocumentRedoUndoPayloadPB { document_id: doc_id.clone(), }; EventBuilder::new(core.clone()) .event(DocumentEvent::CanUndoRedo) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn apply_delta_for_block(&self, document_id: &str, block_id: &str, delta: String) { let block = self.get_block(document_id, block_id).await; // Here is unsafe, but it should be fine for testing. let text_id = block.unwrap().external_id.unwrap(); self .apply_text_delta(TextDeltaPayloadPB { document_id: document_id.to_string(), text_id, delta: Some(delta), }) .await; } pub async fn get_document_snapshot_metas(&self, doc_id: &str) -> Vec { let core = &self.event_test; let payload = OpenDocumentPayloadPB { document_id: doc_id.to_string(), }; EventBuilder::new(core.clone()) .event(DocumentEvent::GetDocumentSnapshotMeta) .payload(payload) .async_send() .await .parse_or_panic::() .items } pub async fn get_document_snapshot( &self, snapshot_meta: DocumentSnapshotMetaPB, ) -> DocumentSnapshotPB { let core = &self.event_test; EventBuilder::new(core.clone()) .event(DocumentEvent::GetDocumentSnapshot) .payload(snapshot_meta) .async_send() .await .parse_or_panic::() } /// Insert a new text block at the index of parent's children. /// return the new block id. pub async fn insert_index( &self, document_id: &str, text: &str, index: usize, parent_id: Option<&str>, ) -> String { let text = text.to_string(); let page_id = self.get_page_id(document_id).await; let parent_id = parent_id .map(|id| id.to_string()) .unwrap_or_else(|| page_id); let parent_children = self.get_block_children(document_id, &parent_id).await; let prev_id = { // If index is 0, then the new block will be the first child of parent. if index == 0 { None } else { parent_children.and_then(|children| { // If index is greater than the length of children, then the new block will be the last child of parent. if index >= children.len() { children.last().cloned() } else { children.get(index - 1).cloned() } }) } }; let new_block_id = gen_id(); let data = gen_text_block_data(); let external_id = gen_id(); let external_type = "text".to_string(); self .create_text(TextDeltaPayloadPB { document_id: document_id.to_string(), text_id: external_id.clone(), delta: Some(gen_delta_str(&text)), }) .await; let new_block = BlockPB { id: new_block_id.clone(), ty: TEXT_BLOCK_TY.to_string(), data, parent_id: parent_id.clone(), children_id: gen_id(), external_id: Some(external_id), external_type: Some(external_type), }; let action = BlockActionPB { action: BlockActionTypePB::Insert, payload: BlockActionPayloadPB { block: Some(new_block), prev_id, parent_id: Some(parent_id), text_id: None, delta: None, }, }; let payload = ApplyActionPayloadPB { document_id: document_id.to_string(), actions: vec![action], }; self.apply_actions(payload).await; new_block_id } pub async fn update_data(&self, document_id: &str, block_id: &str, data: HashMap) { let block = self.get_block(document_id, block_id).await.unwrap(); let new_block = { let mut new_block = block.clone(); new_block.data = serde_json::to_string(&data).unwrap(); new_block }; let action = BlockActionPB { action: BlockActionTypePB::Update, payload: BlockActionPayloadPB { block: Some(new_block), prev_id: None, parent_id: Some(block.parent_id.clone()), text_id: None, delta: None, }, }; let payload = ApplyActionPayloadPB { document_id: document_id.to_string(), actions: vec![action], }; self.apply_actions(payload).await; } pub async fn delete(&self, document_id: &str, block_id: &str) { let block = self.get_block(document_id, block_id).await.unwrap(); let parent_id = block.parent_id.clone(); let action = BlockActionPB { action: BlockActionTypePB::Delete, payload: BlockActionPayloadPB { block: Some(block), prev_id: None, parent_id: Some(parent_id), text_id: None, delta: None, }, }; let payload = ApplyActionPayloadPB { document_id: document_id.to_string(), actions: vec![action], }; self.apply_actions(payload).await; } } ================================================ FILE: frontend/rust-lib/event-integration-test/src/document/mod.rs ================================================ pub mod document_event; pub mod utils; ================================================ FILE: frontend/rust-lib/event-integration-test/src/document/utils.rs ================================================ use crate::document::document_event::*; use flowy_document::entities::*; use nanoid::nanoid; use serde_json::json; use std::collections::HashMap; pub fn gen_id() -> String { nanoid!(10) } pub fn gen_text_block_data() -> String { json!({}).to_string() } pub fn gen_delta_str(text: &str) -> String { json!([{ "insert": text }]).to_string() } pub struct ParseDocumentData { pub doc_id: String, pub page_id: String, pub blocks: HashMap, pub children_map: HashMap, pub first_block_id: String, } pub fn parse_document_data(document: OpenDocumentData) -> ParseDocumentData { let doc_id = document.id.clone(); let data = document.data; let page_id = data.page_id; let blocks = data.blocks; let children_map = data.meta.children_map; let page_block = blocks.get(&page_id).unwrap(); let children_id = page_block.children_id.clone(); let children = children_map.get(&children_id).unwrap(); let block_id = children.children.first().unwrap().to_string(); ParseDocumentData { doc_id, page_id, blocks, children_map, first_block_id: block_id, } } pub fn gen_insert_block_action(document: OpenDocumentData) -> BlockActionPB { let parse_data = parse_document_data(document); let first_block_id = parse_data.first_block_id; let block = parse_data.blocks.get(&first_block_id).unwrap(); let page_id = parse_data.page_id; let data = block.data.clone(); let new_block_id = gen_id(); let new_block = BlockPB { id: new_block_id, ty: block.ty.clone(), data, parent_id: page_id.clone(), children_id: gen_id(), external_id: None, external_type: None, }; BlockActionPB { action: BlockActionTypePB::Insert, payload: BlockActionPayloadPB { block: Some(new_block), prev_id: Some(first_block_id), parent_id: Some(page_id), text_id: None, delta: None, }, } } ================================================ FILE: frontend/rust-lib/event-integration-test/src/document_event.rs ================================================ use collab::core::origin::CollabOrigin; use collab::preclude::updates::decoder::Decode; use collab::preclude::{Collab, Update}; use collab_document::blocks::DocumentData; use collab_document::document::Document; use collab_entity::CollabType; use flowy_document::entities::{DocumentDataPB, OpenDocumentPayloadPB}; use flowy_document::event_map::DocumentEvent; use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB}; use flowy_folder::event_map::FolderEvent; use crate::document::document_event::{DocumentEventTest, OpenDocumentData}; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; impl EventIntegrationTest { pub async fn create_document(&self, name: &str) -> ViewPB { let current_workspace = self.get_current_workspace().await; self .create_and_open_document(¤t_workspace.id, name.to_string(), vec![]) .await } pub async fn create_and_open_document( &self, parent_id: &str, name: String, initial_data: Vec, ) -> ViewPB { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, thumbnail: None, layout: ViewLayoutPB::Document, initial_data, meta: Default::default(), set_as_current: true, index: None, section: None, view_id: None, extra: None, }; let view = EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) .payload(payload) .async_send() .await .parse_or_panic::(); let payload = OpenDocumentPayloadPB { document_id: view.id.clone(), }; let _ = EventBuilder::new(self.clone()) .event(DocumentEvent::OpenDocument) .payload(payload) .async_send() .await .parse_or_panic::(); view } pub async fn open_document(&self, doc_id: String) -> OpenDocumentData { let payload = OpenDocumentPayloadPB { document_id: doc_id.clone(), }; let data = EventBuilder::new(self.clone()) .event(DocumentEvent::OpenDocument) .payload(payload) .async_send() .await .parse_or_panic::(); OpenDocumentData { id: doc_id, data } } pub async fn insert_document_text(&self, document_id: &str, text: &str, index: usize) { let document_event = DocumentEventTest::new_with_core(self.clone()); document_event .insert_index(document_id, text, index, None) .await; } pub async fn get_document_data(&self, view_id: &str) -> DocumentData { let pb = EventBuilder::new(self.clone()) .event(DocumentEvent::GetDocumentData) .payload(OpenDocumentPayloadPB { document_id: view_id.to_string(), }) .async_send() .await .parse_or_panic::(); DocumentData::from(pb) } pub async fn get_document_doc_state(&self, document_id: &str) -> Vec { self .get_collab_doc_state(document_id, CollabType::Document) .await .unwrap() } } pub fn assert_document_data_equal(doc_state: &[u8], doc_id: &str, expected: DocumentData) { let mut collab = Collab::new_with_origin(CollabOrigin::Server, doc_id, vec![], false); { let update = Update::decode_v1(doc_state).unwrap(); let mut txn = collab.transact_mut(); txn.apply_update(update).unwrap(); }; let document = Document::open(collab).unwrap(); let actual = document.get_document_data().unwrap(); assert_eq!(actual, expected); } ================================================ FILE: frontend/rust-lib/event-integration-test/src/event_builder.rs ================================================ use crate::EventIntegrationTest; use flowy_user::errors::{internal_error, FlowyError, FlowyResult}; use lib_dispatch::prelude::{ AFPluginDispatcher, AFPluginEventResponse, AFPluginFromBytes, AFPluginRequest, ToBytes, *, }; use std::sync::Arc; use std::{ convert::TryFrom, fmt::{Debug, Display}, hash::Hash, }; use tokio::task::LocalSet; // #[derive(Clone)] pub struct EventBuilder { context: TestContext, local_set: LocalSet, } impl EventBuilder { pub fn new(sdk: EventIntegrationTest) -> Self { Self { context: TestContext::new(sdk), local_set: Default::default(), } } pub fn payload

(mut self, payload: P) -> Self where P: ToBytes, { match payload.into_bytes() { Ok(bytes) => { let module_request = self.get_request(); self.context.request = Some(module_request.payload(bytes)) }, Err(e) => { tracing::error!("Set payload failed: {:?}", e); }, } self } pub fn event(mut self, event: Event) -> Self where Event: Eq + Hash + Debug + Clone + Display, { self.context.request = Some(AFPluginRequest::new(event)); self } pub async fn async_send(mut self) -> Self { let request = self.get_request(); let resp = self .local_set .run_until(AFPluginDispatcher::async_send( self.dispatch().as_ref(), request, )) .await; self.context.response = Some(resp); self } pub fn parse_or_panic(self) -> R where R: AFPluginFromBytes, { let response = self.get_response(); match response.clone().parse::() { Ok(Ok(data)) => data, Ok(Err(e)) => { panic!("Parser {:?} failed: {:?}", std::any::type_name::(), e) }, Err(e) => { panic!("Parser {:?} failed: {:?}", std::any::type_name::(), e) }, } } pub fn parse(self) -> FlowyResult where R: AFPluginFromBytes, { let response = self.get_response(); match response.clone().parse::() { Ok(Ok(data)) => Ok(data), Ok(Err(e)) => Err(e), Err(e) => { panic!("Parser {:?} failed: {:?}", std::any::type_name::(), e) }, } } pub fn try_parse(self) -> Result where R: AFPluginFromBytes, { let response = self.get_response(); response.parse::().map_err(internal_error)? } pub fn error(self) -> Option { let response = self.get_response(); >::try_from(response.payload) .ok() .map(|data| data.into_inner()) } fn dispatch(&self) -> Arc { self.context.sdk.dispatcher() } fn get_response(&self) -> AFPluginEventResponse { self .context .response .as_ref() .expect("must call sync_send/async_send first") .clone() } fn get_request(&mut self) -> AFPluginRequest { self.context.request.take().expect("must call event first") } } #[derive(Clone)] pub struct TestContext { pub sdk: EventIntegrationTest, request: Option, response: Option, } impl TestContext { pub fn new(sdk: EventIntegrationTest) -> Self { Self { sdk, request: None, response: None, } } } ================================================ FILE: frontend/rust-lib/event-integration-test/src/folder_event.rs ================================================ use flowy_folder::view_operation::{GatherEncodedCollab, ViewData}; use std::str::FromStr; use std::sync::Arc; use collab_folder::{FolderData, View}; use flowy_folder::entities::icon::UpdateViewIconPayloadPB; use flowy_folder::event_map::FolderEvent; use flowy_folder::event_map::FolderEvent::*; use flowy_folder::{entities::*, ViewLayout}; use flowy_folder_pub::entities::PublishPayload; use flowy_search::services::manager::{SearchHandler, SearchType}; use flowy_user::entities::{ AcceptWorkspaceInvitationPB, QueryWorkspacePB, RemoveWorkspaceMemberPB, RepeatedWorkspaceInvitationPB, RepeatedWorkspaceMemberPB, UserWorkspaceIdPB, UserWorkspacePB, WorkspaceMemberInvitationPB, WorkspaceMemberPB, }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; use flowy_user_pub::entities::Role; use uuid::Uuid; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; impl EventIntegrationTest { pub async fn invite_workspace_member(&self, workspace_id: &str, email: &str, role: Role) { EventBuilder::new(self.clone()) .event(UserEvent::InviteWorkspaceMember) .payload(WorkspaceMemberInvitationPB { workspace_id: workspace_id.to_string(), invitee_email: email.to_string(), role: role.into(), }) .async_send() .await; } // convenient function to add workspace member by inviting and accepting the invitation pub async fn add_workspace_member(&self, workspace_id: &str, other: &EventIntegrationTest) { let other_email = other.get_user_profile().await.unwrap().email; self .invite_workspace_member(workspace_id, &other_email, Role::Member) .await; let invitations = other.list_workspace_invitations().await; let target_invi = invitations .items .into_iter() .find(|i| i.workspace_id == workspace_id) .unwrap(); other .accept_workspace_invitation(&target_invi.invite_id) .await; } pub async fn list_workspace_invitations(&self) -> RepeatedWorkspaceInvitationPB { EventBuilder::new(self.clone()) .event(UserEvent::ListWorkspaceInvitations) .async_send() .await .parse_or_panic() } pub async fn accept_workspace_invitation(&self, invitation_id: &str) { if let Some(err) = EventBuilder::new(self.clone()) .event(UserEvent::AcceptWorkspaceInvitation) .payload(AcceptWorkspaceInvitationPB { invite_id: invitation_id.to_string(), }) .async_send() .await .error() { panic!("Accept workspace invitation failed: {:?}", err) }; } pub async fn delete_workspace_member(&self, workspace_id: &str, email: &str) { if let Some(err) = EventBuilder::new(self.clone()) .event(UserEvent::RemoveWorkspaceMember) .payload(RemoveWorkspaceMemberPB { workspace_id: workspace_id.to_string(), email: email.to_string(), }) .async_send() .await .error() { panic!("Delete workspace member failed: {:?}", err) }; } pub async fn get_workspace_members(&self, workspace_id: &str) -> Vec { EventBuilder::new(self.clone()) .event(UserEvent::GetWorkspaceMembers) .payload(QueryWorkspacePB { workspace_id: workspace_id.to_string(), }) .async_send() .await .parse_or_panic::() .items } pub async fn get_current_workspace(&self) -> WorkspacePB { EventBuilder::new(self.clone()) .event(FolderEvent::ReadCurrentWorkspace) .async_send() .await .parse_or_panic::() } pub async fn get_workspace_id(&self) -> Uuid { let a = EventBuilder::new(self.clone()) .event(FolderEvent::ReadCurrentWorkspace) .async_send() .await .parse_or_panic::(); Uuid::from_str(&a.id).unwrap() } pub async fn get_user_workspace(&self, workspace_id: &str) -> UserWorkspacePB { let payload = UserWorkspaceIdPB { workspace_id: workspace_id.to_string(), }; EventBuilder::new(self.clone()) .event(UserEvent::GetUserWorkspace) .payload(payload) .async_send() .await .parse_or_panic::() } pub fn get_folder_search_handler(&self) -> Arc { self .appflowy_core .search_manager .get_handler(SearchType::Folder) .unwrap() } /// create views in the folder. pub async fn create_views(&self, views: Vec) { let create_view_params = views .into_iter() .map(|view| CreateViewParams { parent_view_id: Uuid::from_str(&view.parent_view_id).unwrap(), name: view.name, layout: view.layout.into(), view_id: Uuid::from_str(&view.id).unwrap(), initial_data: ViewData::Empty, meta: Default::default(), set_as_current: false, index: None, section: None, icon: view.icon, extra: view.extra, }) .collect::>(); for params in create_view_params { self .appflowy_core .folder_manager .create_view_with_params(params, true) .await .unwrap(); } } /// Create orphan views in the folder. /// Orphan view: the parent_view_id equal to the view_id /// Normally, the orphan view will be created in nested database pub async fn create_orphan_view(&self, name: &str, view_id: &str, layout: ViewLayoutPB) { let payload = CreateOrphanViewPayloadPB { name: name.to_string(), layout, view_id: view_id.to_string(), initial_data: vec![], }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateOrphanView) .payload(payload) .async_send() .await; } pub async fn get_folder_data(&self) -> FolderData { self .appflowy_core .folder_manager .get_folder_data() .await .unwrap() } pub async fn get_publish_payload( &self, view_id: &str, include_children: bool, ) -> Vec { let manager = self.folder_manager.clone(); let payload = manager .get_batch_publish_payload(view_id, None, include_children) .await; if payload.is_err() { panic!("Get publish payload failed") } payload.unwrap() } pub async fn gather_encode_collab_from_disk( &self, view_id: &str, layout: ViewLayout, ) -> GatherEncodedCollab { let view_id = Uuid::from_str(view_id).unwrap(); self .folder_manager .gather_publish_encode_collab(&view_id, &layout) .await .unwrap() } pub async fn get_all_workspace_views(&self) -> Vec { EventBuilder::new(self.clone()) .event(FolderEvent::ReadCurrentWorkspaceViews) .async_send() .await .parse_or_panic::() .items } // get all the views in the current workspace, including the views in the trash and the orphan views pub async fn get_all_views(&self) -> Vec { EventBuilder::new(self.clone()) .event(FolderEvent::GetAllViews) .async_send() .await .parse_or_panic::() .items } pub async fn get_trash(&self) -> RepeatedTrashPB { EventBuilder::new(self.clone()) .event(FolderEvent::ListTrashItems) .async_send() .await .parse_or_panic::() } pub async fn delete_view(&self, view_id: &str) { let payload = RepeatedViewIdPB { items: vec![view_id.to_string()], }; // delete the view. the view will be moved to trash if let Some(err) = EventBuilder::new(self.clone()) .event(FolderEvent::DeleteView) .payload(payload) .async_send() .await .error() { panic!("Delete view failed: {:?}", err) }; } pub async fn update_view(&self, changeset: UpdateViewPayloadPB) -> Option { // delete the view. the view will be moved to trash EventBuilder::new(self.clone()) .event(FolderEvent::UpdateView) .payload(changeset) .async_send() .await .error() } pub async fn update_view_icon(&self, payload: UpdateViewIconPayloadPB) -> Option { EventBuilder::new(self.clone()) .event(FolderEvent::UpdateViewIcon) .payload(payload) .async_send() .await .error() } pub async fn create_view(&self, parent_id: &str, name: String) -> ViewPB { self .create_view_with_layout(parent_id, name, Default::default()) .await } pub async fn create_view_with_layout( &self, parent_id: &str, name: String, layout: ViewLayoutPB, ) -> ViewPB { let payload = CreateViewPayloadPB { parent_view_id: parent_id.to_string(), name, thumbnail: None, layout, initial_data: vec![], meta: Default::default(), set_as_current: false, index: None, section: None, view_id: None, extra: None, }; EventBuilder::new(self.clone()) .event(FolderEvent::CreateView) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn get_view(&self, view_id: &str) -> ViewPB { EventBuilder::new(self.clone()) .event(FolderEvent::GetView) .payload(ViewIdPB { value: view_id.to_string(), }) .async_send() .await .parse_or_panic::() } pub async fn import_data(&self, data: ImportPayloadPB) -> FlowyResult { EventBuilder::new(self.clone()) .event(FolderEvent::ImportData) .payload(data) .async_send() .await .parse::() } pub async fn get_view_ancestors(&self, view_id: &str) -> Vec { EventBuilder::new(self.clone()) .event(FolderEvent::GetViewAncestors) .payload(ViewIdPB { value: view_id.to_string(), }) .async_send() .await .parse_or_panic::() .items } } pub struct ViewTest { pub sdk: EventIntegrationTest, pub workspace: WorkspacePB, pub child_view: ViewPB, } impl ViewTest { #[allow(dead_code)] pub async fn new(sdk: &EventIntegrationTest, layout: ViewLayout, data: Vec) -> Self { let workspace = sdk.folder_manager.get_current_workspace().await.unwrap(); let payload = CreateViewPayloadPB { parent_view_id: workspace.id.clone(), name: "View A".to_string(), thumbnail: Some("http://1.png".to_string()), layout: layout.into(), initial_data: data, meta: Default::default(), set_as_current: true, index: None, section: None, view_id: None, extra: None, }; let view = EventBuilder::new(sdk.clone()) .event(CreateView) .payload(payload) .async_send() .await .parse_or_panic::(); Self { sdk: sdk.clone(), workspace, child_view: view, } } pub async fn new_grid_view(sdk: &EventIntegrationTest, data: Vec) -> Self { Self::new(sdk, ViewLayout::Grid, data).await } pub async fn new_board_view(sdk: &EventIntegrationTest, data: Vec) -> Self { Self::new(sdk, ViewLayout::Board, data).await } pub async fn new_calendar_view(sdk: &EventIntegrationTest, data: Vec) -> Self { Self::new(sdk, ViewLayout::Calendar, data).await } } ================================================ FILE: frontend/rust-lib/event-integration-test/src/lib.rs ================================================ use crate::user_event::TestNotificationSender; use collab::core::collab::DataSource; use collab::core::origin::CollabOrigin; use collab::preclude::Collab; use collab_document::blocks::DocumentData; use collab_document::document::Document; use collab_entity::CollabType; use flowy_core::config::AppFlowyCoreConfig; use flowy_core::AppFlowyCore; use flowy_notification::register_notification_sender; use flowy_user::entities::AuthTypePB; use flowy_user::errors::FlowyError; use lib_dispatch::runtime::AFPluginRuntime; use nanoid::nanoid; use semver::Version; use std::env::temp_dir; use std::path::PathBuf; use std::str::FromStr; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::select; use tokio::task::LocalSet; use tokio::time::sleep; use uuid::Uuid; mod chat_event; pub mod database_event; pub mod document; pub mod document_event; pub mod event_builder; pub mod folder_event; pub mod user_event; #[derive(Clone)] pub struct EventIntegrationTest { pub authenticator: Arc, pub appflowy_core: AppFlowyCore, #[allow(dead_code)] cleaner: Arc, pub notification_sender: TestNotificationSender, local_set: Arc, } pub const SINGLE_FILE_UPLOAD_SIZE: usize = 15 * 1024 * 1024; impl EventIntegrationTest { pub async fn new() -> Self { Self::new_with_name(nanoid!(6)).await } pub async fn new_with_name(name: T) -> Self { let temp_dir = temp_dir().join(nanoid!(6)); std::fs::create_dir_all(&temp_dir).unwrap(); Self::new_with_user_data_path(temp_dir, name.to_string()).await } pub async fn new_with_config(config: AppFlowyCoreConfig) -> Self { let clean_path = config.storage_path.clone(); let inner = init_core(config).await; let notification_sender = TestNotificationSender::new(); let authenticator = Arc::new(AtomicU8::new(AuthTypePB::Local as u8)); register_notification_sender(notification_sender.clone()); // In case of dropping the runtime that runs the core, we need to forget the dispatcher std::mem::forget(inner.dispatcher()); Self { appflowy_core: inner, authenticator, notification_sender, cleaner: Arc::new(Cleaner::new(PathBuf::from(clean_path))), #[allow(clippy::arc_with_non_send_sync)] local_set: Arc::new(Default::default()), } } pub async fn new_with_user_data_path(path_buf: PathBuf, name: String) -> Self { let path = path_buf.to_str().unwrap().to_string(); let device_id = uuid::Uuid::new_v4().to_string(); let mut config = AppFlowyCoreConfig::new( Version::new(0, 7, 0), path.clone(), path, device_id, "test".to_string(), name, ) .log_filter( "trace", vec![ "flowy_test".to_string(), "tokio".to_string(), // "lib_dispatch".to_string(), ], ); if let Some(cloud_config) = config.cloud_config.as_mut() { cloud_config.maximum_upload_file_size_in_bytes = Some(SINGLE_FILE_UPLOAD_SIZE as u64); } Self::new_with_config(config).await } pub fn skip_auto_remove_temp_dir(&mut self) { self.cleaner.should_clean.store(false, Ordering::Release); } pub fn instance_name(&self) -> String { self.appflowy_core.config.name.clone() } pub fn user_data_path(&self) -> String { self.appflowy_core.config.application_path.clone() } pub async fn wait_ws_connected(&self) { if self .appflowy_core .server_provider .get_server() .unwrap() .get_ws_state() .is_connected() { return; } let mut ws_state = self .appflowy_core .server_provider .get_server() .unwrap() .subscribe_ws_state() .unwrap(); loop { select! { _ = sleep(Duration::from_secs(20)) => { panic!("wait_ws_connected timeout"); } state = ws_state.recv() => { if let Ok(state) = &state { if state.is_connected() { break; } } } } } } pub async fn get_collab_doc_state( &self, oid: &str, collab_type: CollabType, ) -> Result, FlowyError> { let server = self.server_provider.get_server()?; let workspace_id = self.get_current_workspace().await.id; let oid = Uuid::from_str(oid)?; let uid = self.get_user_profile().await?.id; let doc_state = server .folder_service() .get_folder_doc_state( &Uuid::from_str(&workspace_id).unwrap(), uid, collab_type, &oid, ) .await?; Ok(doc_state) } } pub fn document_data_from_document_doc_state(doc_id: &str, doc_state: Vec) -> DocumentData { document_from_document_doc_state(doc_id, doc_state) .get_document_data() .unwrap() } pub fn document_from_document_doc_state(doc_id: &str, doc_state: Vec) -> Document { let collab = Collab::new_with_source( CollabOrigin::Empty, doc_id, DataSource::DocStateV1(doc_state), vec![], true, ) .unwrap(); Document::open(collab).unwrap() } async fn init_core(config: AppFlowyCoreConfig) -> AppFlowyCore { let runtime = Arc::new(AFPluginRuntime::new().unwrap()); let cloned_runtime = runtime.clone(); AppFlowyCore::new(config, cloned_runtime, None).await } impl std::ops::Deref for EventIntegrationTest { type Target = AppFlowyCore; fn deref(&self) -> &Self::Target { &self.appflowy_core } } pub struct Cleaner { dir: PathBuf, should_clean: AtomicBool, } impl Cleaner { pub fn new(dir: PathBuf) -> Self { Self { dir, should_clean: AtomicBool::new(true), } } fn cleanup(dir: &PathBuf) { let _ = std::fs::remove_dir_all(dir); } } impl Drop for Cleaner { fn drop(&mut self) { if self.should_clean.load(Ordering::Acquire) { Self::cleanup(&self.dir) } } } ================================================ FILE: frontend/rust-lib/event-integration-test/src/user_event.rs ================================================ use std::collections::HashMap; use std::convert::TryFrom; use std::sync::atomic::Ordering; use std::sync::Arc; use bytes::Bytes; use flowy_folder::entities::{RepeatedViewPB, WorkspacePB}; use protobuf::ProtobufError; use tokio::sync::broadcast::{channel, Sender}; use tracing::error; use uuid::Uuid; use crate::event_builder::EventBuilder; use crate::EventIntegrationTest; use flowy_folder::event_map::FolderEvent; use flowy_notification::entities::SubscribeObject; use flowy_notification::NotificationSender; use flowy_server::af_cloud::define::{USER_DEVICE_ID, USER_EMAIL, USER_SIGN_IN_URL, USER_UUID}; use flowy_server_pub::af_cloud_config::AFCloudConfiguration; use flowy_server_pub::AuthenticatorType; use flowy_user::entities::{ AuthTypePB, ChangeWorkspaceIconPB, CloudSettingPB, CreateWorkspacePB, ImportAppFlowyDataPB, OauthSignInPB, OpenUserWorkspacePB, RenameWorkspacePB, RepeatedUserWorkspacePB, SignInUrlPB, SignInUrlPayloadPB, SignUpPayloadPB, UpdateCloudConfigPB, UpdateUserProfilePayloadPB, UserProfilePB, UserWorkspaceIdPB, UserWorkspacePB, WorkspaceTypePB, }; use flowy_user::errors::{FlowyError, FlowyResult}; use flowy_user::event_map::UserEvent; use flowy_user_pub::entities::WorkspaceType; use lib_dispatch::prelude::{AFPluginDispatcher, AFPluginRequest, ToBytes}; impl EventIntegrationTest { pub async fn enable_encryption(&self) -> String { let config = EventBuilder::new(self.clone()) .event(UserEvent::GetCloudConfig) .async_send() .await .parse_or_panic::(); let update = UpdateCloudConfigPB { enable_sync: None, enable_encrypt: Some(true), }; let error = EventBuilder::new(self.clone()) .event(UserEvent::SetCloudConfig) .payload(update) .async_send() .await .error(); assert!(error.is_none()); config.encrypt_secret } /// Create a anonymous user for given test. pub async fn new_anon() -> Self { let test = Self::new().await; test.sign_up_as_anon().await; test } pub async fn sign_up_as_anon(&self) -> SignUpContext { let password = login_password(); let email = "anon@appflowy.io".to_string(); let payload = SignUpPayloadPB { email, name: "appflowy".to_string(), password: password.clone(), auth_type: AuthTypePB::Local, device_id: uuid::Uuid::new_v4().to_string(), } .into_bytes() .unwrap(); let request = AFPluginRequest::new(UserEvent::SignUp).payload(payload); let user_profile = self .local_set .run_until(AFPluginDispatcher::async_send( &self.appflowy_core.dispatcher(), request, )) .await .parse::() .unwrap() .unwrap(); // let _ = create_default_workspace_if_need(dispatch.clone(), &user_profile.id); SignUpContext { user_profile, password, } } pub async fn af_cloud_sign_up(&self) -> UserProfilePB { let email = unique_email(); match self.af_cloud_sign_in_with_email(&email).await { Ok(profile) => profile, Err(err) => { tracing::warn!( "Failed to sign up with email: {}, error: {}, retrying", email, err ); tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; self.af_cloud_sign_in_with_email(&email).await.unwrap() }, } } pub async fn sign_out(&self) { EventBuilder::new(self.clone()) .event(UserEvent::SignOut) .async_send() .await; } pub fn set_auth_type(&self, auth_type: AuthTypePB) { self.authenticator.store(auth_type as u8, Ordering::Release); } pub async fn init_anon_user(&self) -> UserProfilePB { self.sign_up_as_anon().await.user_profile } pub async fn get_user_profile(&self) -> Result { EventBuilder::new(self.clone()) .event(UserEvent::GetUserProfile) .async_send() .await .try_parse::() } pub async fn update_user_profile(&self, params: UpdateUserProfilePayloadPB) { EventBuilder::new(self.clone()) .event(UserEvent::UpdateUserProfile) .payload(params) .async_send() .await; } pub async fn af_cloud_sign_in_with_email(&self, email: &str) -> FlowyResult { let payload = SignInUrlPayloadPB { email: email.to_string(), authenticator: AuthTypePB::Server, }; let sign_in_url = EventBuilder::new(self.clone()) .event(UserEvent::GenerateSignInURL) .payload(payload) .async_send() .await .try_parse::()? .sign_in_url; let mut map = HashMap::new(); map.insert(USER_SIGN_IN_URL.to_string(), sign_in_url); map.insert(USER_DEVICE_ID.to_string(), Uuid::new_v4().to_string()); let payload = OauthSignInPB { map, auth_type: AuthTypePB::Server, }; let user_profile = EventBuilder::new(self.clone()) .event(UserEvent::OauthSignIn) .payload(payload) .async_send() .await .try_parse::()?; Ok(user_profile) } pub async fn import_appflowy_data( &self, path: String, name: Option, ) -> Result<(), FlowyError> { let payload = ImportAppFlowyDataPB { path, import_container_name: name, parent_view_id: None, }; match EventBuilder::new(self.clone()) .event(UserEvent::ImportAppFlowyDataFolder) .payload(payload) .async_send() .await .error() { Some(err) => Err(err), None => Ok(()), } } pub async fn create_workspace( &self, name: &str, workspace_type: WorkspaceType, ) -> UserWorkspacePB { let payload = CreateWorkspacePB { name: name.to_string(), workspace_type: WorkspaceTypePB::from(workspace_type), }; EventBuilder::new(self.clone()) .event(UserEvent::CreateWorkspace) .payload(payload) .async_send() .await .parse_or_panic::() } pub async fn rename_workspace( &self, workspace_id: &str, new_name: &str, ) -> Result<(), FlowyError> { let payload = RenameWorkspacePB { workspace_id: workspace_id.to_owned(), new_name: new_name.to_owned(), }; match EventBuilder::new(self.clone()) .event(UserEvent::RenameWorkspace) .payload(payload) .async_send() .await .error() { Some(err) => Err(err), None => Ok(()), } } pub async fn change_workspace_icon( &self, workspace_id: &str, new_icon: &str, ) -> Result<(), FlowyError> { let payload = ChangeWorkspaceIconPB { workspace_id: workspace_id.to_owned(), new_icon: new_icon.to_owned(), }; match EventBuilder::new(self.clone()) .event(UserEvent::ChangeWorkspaceIcon) .payload(payload) .async_send() .await .error() { Some(err) => Err(err), None => Ok(()), } } pub async fn folder_read_current_workspace(&self) -> WorkspacePB { EventBuilder::new(self.clone()) .event(FolderEvent::ReadCurrentWorkspace) .async_send() .await .parse_or_panic() } pub async fn folder_read_current_workspace_views(&self) -> RepeatedViewPB { EventBuilder::new(self.clone()) .event(FolderEvent::ReadCurrentWorkspaceViews) .async_send() .await .parse_or_panic() } pub async fn get_all_workspaces(&self) -> RepeatedUserWorkspacePB { EventBuilder::new(self.clone()) .event(UserEvent::GetAllWorkspace) .async_send() .await .parse_or_panic::() } pub async fn delete_workspace(&self, workspace_id: &str) { let payload = UserWorkspaceIdPB { workspace_id: workspace_id.to_string(), }; EventBuilder::new(self.clone()) .event(UserEvent::DeleteWorkspace) .payload(payload) .async_send() .await; } pub async fn open_workspace(&self, workspace_id: &str, workspace_type: WorkspaceTypePB) { let payload = OpenUserWorkspacePB { workspace_id: workspace_id.to_string(), workspace_type, }; EventBuilder::new(self.clone()) .event(UserEvent::OpenWorkspace) .payload(payload) .async_send() .await; } pub async fn leave_workspace(&self, workspace_id: &str) { let payload = UserWorkspaceIdPB { workspace_id: workspace_id.to_string(), }; EventBuilder::new(self.clone()) .event(UserEvent::LeaveWorkspace) .payload(payload) .async_send() .await; } } #[derive(Clone)] pub struct TestNotificationSender { sender: Arc>, } impl Default for TestNotificationSender { fn default() -> Self { let (sender, _) = channel(1000); Self { sender: Arc::new(sender), } } } impl TestNotificationSender { pub fn new() -> Self { Self::default() } pub fn subscribe(&self, id: &str, ty: impl Into + Send) -> tokio::sync::mpsc::Receiver where T: TryFrom + Send + 'static, { let id = id.to_string(); let (tx, rx) = tokio::sync::mpsc::channel::(10); let mut receiver = self.sender.subscribe(); let ty = ty.into(); tokio::spawn(async move { // DatabaseNotification::DidUpdateDatabaseSnapshotState while let Ok(value) = receiver.recv().await { if value.id == id && value.ty == ty { if let Some(payload) = value.payload { match T::try_from(Bytes::from(payload)) { Ok(object) => { let _ = tx.send(object).await; }, Err(e) => { panic!( "Failed to parse notification payload to type: {:?} with error: {}", std::any::type_name::(), e ); }, } } } } }); rx } pub fn subscribe_without_payload( &self, id: &str, ty: impl Into + Send, ) -> tokio::sync::mpsc::Receiver<()> { let id = id.to_string(); let (tx, rx) = tokio::sync::mpsc::channel::<()>(10); let mut receiver = self.sender.subscribe(); let ty = ty.into(); tokio::spawn(async move { // DatabaseNotification::DidUpdateDatabaseSnapshotState while let Ok(value) = receiver.recv().await { if value.id == id && value.ty == ty { let _ = tx.send(()).await; } } }); rx } pub fn subscribe_with_condition(&self, id: &str, when: F) -> tokio::sync::mpsc::Receiver where T: TryFrom + Send + 'static, F: Fn(&T) -> bool + Send + 'static, { let id = id.to_string(); let (tx, rx) = tokio::sync::mpsc::channel::(1); let mut receiver = self.sender.subscribe(); tokio::spawn(async move { while let Ok(value) = receiver.recv().await { if value.id == id { if let Some(payload) = value.payload { if let Ok(object) = T::try_from(Bytes::from(payload)) { if when(&object) { let _ = tx.send(object).await; } } } } } }); rx } } impl NotificationSender for TestNotificationSender { fn send_subject(&self, subject: SubscribeObject) -> Result<(), String> { if let Err(err) = self.sender.send(subject) { error!("Failed to send notification: {:?}", err); } Ok(()) } } pub fn third_party_sign_up_param(uuid: String) -> HashMap { let mut params = HashMap::new(); params.insert(USER_UUID.to_string(), uuid); params.insert( USER_EMAIL.to_string(), format!("{}@test.com", Uuid::new_v4()), ); params.insert(USER_DEVICE_ID.to_string(), Uuid::new_v4().to_string()); params } pub fn unique_email() -> String { format!("{}@appflowy.io", Uuid::new_v4()) } pub fn login_email() -> String { "annie2@appflowy.io".to_string() } pub fn login_password() -> String { "HelloWorld!123".to_string() } pub struct SignUpContext { pub user_profile: UserProfilePB, pub password: String, } pub async fn use_local_mode() { AuthenticatorType::Local.write_env(); AFCloudConfiguration::default().write_env(); } pub async fn use_localhost_af_cloud() { AuthenticatorType::AppFlowyCloud.write_env(); let base_url = std::env::var("af_cloud_test_base_url").unwrap_or("http://localhost:8000".to_string()); let ws_base_url = std::env::var("af_cloud_test_ws_url").unwrap_or("ws://localhost:8000/ws/v1".to_string()); let gotrue_url = std::env::var("af_cloud_test_gotrue_url").unwrap_or("http://localhost:9999".to_string()); AFCloudConfiguration { base_url, ws_base_url, gotrue_url, enable_sync_trace: true, maximum_upload_file_size_in_bytes: None, } .write_env(); std::env::set_var("GOTRUE_ADMIN_EMAIL", "admin@example.com"); std::env::set_var("GOTRUE_ADMIN_PASSWORD", "password"); } #[allow(dead_code)] pub async fn user_localhost_af_cloud_with_nginx() { std::env::set_var("af_cloud_test_base_url", "http://localhost"); std::env::set_var("af_cloud_test_ws_url", "ws://localhost/ws/v1"); std::env::set_var("af_cloud_test_gotrue_url", "http://localhost/gotrue"); use_localhost_af_cloud().await } ================================================ FILE: frontend/rust-lib/event-integration-test/tests/asset/database_template_1.afdb ================================================ "{""id"":""2_OVWb"",""name"":""Name"",""field_type"":0,""visibility"":true,""width"":150,""type_options"":{""0"":{""data"":""""}},""is_primary"":true}","{""id"":""xjmOSi"",""name"":""Type"",""field_type"":3,""visibility"":true,""width"":150,""type_options"":{""3"":{""content"":""{\""options\"":[{\""id\"":\""t1WZ\"",\""name\"":\""s6\"",\""color\"":\""Lime\""},{\""id\"":\""GzNa\"",\""name\"":\""s5\"",\""color\"":\""Yellow\""},{\""id\"":\""l_8w\"",\""name\"":\""s4\"",\""color\"":\""Orange\""},{\""id\"":\""TzVT\"",\""name\"":\""s3\"",\""color\"":\""LightPink\""},{\""id\"":\""b5WF\"",\""name\"":\""s2\"",\""color\"":\""Pink\""},{\""id\"":\""AcHA\"",\""name\"":\""s1\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}","{""id"":""Hpbiwr"",""name"":""Done"",""field_type"":5,""visibility"":true,""width"":150,""type_options"":{""5"":{""is_selected"":false}},""is_primary"":false}","{""id"":""F7WLnw"",""name"":""checklist"",""field_type"":7,""visibility"":true,""width"":120,""type_options"":{""0"":{""data"":""""},""7"":{}},""is_primary"":false}","{""id"":""KABhMe"",""name"":""number"",""field_type"":1,""visibility"":true,""width"":120,""type_options"":{""1"":{""format"":0,""symbol"":""RUB"",""scale"":0,""name"":""Number""},""0"":{""scale"":0,""data"":"""",""format"":0,""name"":""Number"",""symbol"":""RUB""}},""is_primary"":false}","{""id"":""lEn6Bv"",""name"":""date"",""field_type"":2,""visibility"":true,""width"":120,""type_options"":{""2"":{""field_type"":2,""time_format"":1,""timezone_id"":"""",""date_format"":3},""0"":{""field_type"":2,""date_format"":3,""time_format"":1,""data"":"""",""timezone_id"":""""}},""is_primary"":false}","{""id"":""B8Prnx"",""name"":""url"",""field_type"":6,""visibility"":true,""width"":120,""type_options"":{""6"":{""content"":"""",""url"":""""},""0"":{""content"":"""",""data"":"""",""url"":""""}},""is_primary"":false}","{""id"":""MwUow4"",""name"":""multi-select"",""field_type"":4,""visibility"":true,""width"":240,""type_options"":{""0"":{""content"":""{\""options\"":[],\""disable_color\"":false}"",""data"":""""},""4"":{""content"":""{\""options\"":[{\""id\"":\""__Us\"",\""name\"":\""m7\"",\""color\"":\""Green\""},{\""id\"":\""n9-g\"",\""name\"":\""m6\"",\""color\"":\""Lime\""},{\""id\"":\""KFYu\"",\""name\"":\""m5\"",\""color\"":\""Yellow\""},{\""id\"":\""KftP\"",\""name\"":\""m4\"",\""color\"":\""Orange\""},{\""id\"":\""5lWo\"",\""name\"":\""m3\"",\""color\"":\""LightPink\""},{\""id\"":\""Djrz\"",\""name\"":\""m2\"",\""color\"":\""Pink\""},{\""id\"":\""2uRu\"",\""name\"":\""m1\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}" "{""field_type"":0,""created_at"":1686793246,""data"":""A"",""last_modified"":1686793246}","{""last_modified"":1686793275,""created_at"":1686793261,""data"":""AcHA"",""field_type"":3}","{""created_at"":1686793241,""field_type"":5,""last_modified"":1686793241,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""pi1A\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""6Pym\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""erEe\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""pi1A\"",\""6Pym\""]}"",""created_at"":1686793302,""field_type"":7,""last_modified"":1686793308}","{""created_at"":1686793333,""field_type"":1,""data"":""-1"",""last_modified"":1686793333}","{""last_modified"":1686793370,""field_type"":2,""data"":""1685583770"",""include_time"":false,""created_at"":1686793370}","{""created_at"":1686793395,""data"":""appflowy.io"",""field_type"":6,""last_modified"":1686793399,""url"":""https://appflowy.io""}","{""last_modified"":1686793446,""field_type"":4,""data"":""2uRu"",""created_at"":1686793428}" "{""last_modified"":1686793247,""data"":""B"",""field_type"":0,""created_at"":1686793247}","{""created_at"":1686793278,""data"":""b5WF"",""field_type"":3,""last_modified"":1686793278}","{""created_at"":1686793292,""last_modified"":1686793292,""data"":""Yes"",""field_type"":5}","{""data"":""{\""options\"":[{\""id\"":\""YHDO\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""QjtW\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""K2nM\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""YHDO\""]}"",""field_type"":7,""last_modified"":1686793318,""created_at"":1686793311}","{""data"":""-2"",""last_modified"":1686793335,""created_at"":1686793335,""field_type"":1}","{""field_type"":2,""data"":""1685670174"",""include_time"":false,""created_at"":1686793374,""last_modified"":1686793374}","{""last_modified"":1686793403,""field_type"":6,""created_at"":1686793399,""url"":"""",""data"":""no url""}","{""data"":""2uRu,Djrz"",""field_type"":4,""last_modified"":1686793449,""created_at"":1686793449}" "{""data"":""C"",""created_at"":1686793248,""last_modified"":1686793248,""field_type"":0}","{""created_at"":1686793280,""field_type"":3,""data"":""TzVT"",""last_modified"":1686793280}","{""data"":""Yes"",""last_modified"":1686793292,""field_type"":5,""created_at"":1686793292}","{""last_modified"":1686793329,""field_type"":7,""created_at"":1686793322,""data"":""{\""options\"":[{\""id\"":\""iWM1\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""WDvF\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""w3k7\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""iWM1\"",\""WDvF\"",\""w3k7\""]}""}","{""field_type"":1,""last_modified"":1686793339,""data"":""0.1"",""created_at"":1686793339}","{""last_modified"":1686793377,""data"":""1685756577"",""created_at"":1686793377,""include_time"":false,""field_type"":2}","{""created_at"":1686793403,""field_type"":6,""data"":""appflowy.io"",""last_modified"":1686793408,""url"":""https://appflowy.io""}","{""data"":""2uRu,Djrz,5lWo"",""created_at"":1686793453,""last_modified"":1686793454,""field_type"":4}" "{""data"":""D"",""last_modified"":1686793249,""created_at"":1686793249,""field_type"":0}","{""data"":""l_8w"",""created_at"":1686793284,""last_modified"":1686793284,""field_type"":3}","{""data"":""Yes"",""created_at"":1686793293,""last_modified"":1686793293,""field_type"":5}",,"{""field_type"":1,""last_modified"":1686793341,""created_at"":1686793341,""data"":""0.2""}","{""created_at"":1686793379,""last_modified"":1686793379,""field_type"":2,""data"":""1685842979"",""include_time"":false}","{""last_modified"":1686793419,""field_type"":6,""created_at"":1686793408,""data"":""https://github.com/AppFlowy-IO/"",""url"":""https://github.com/AppFlowy-IO/""}","{""data"":""2uRu,Djrz,5lWo"",""last_modified"":1686793459,""field_type"":4,""created_at"":1686793459}" "{""field_type"":0,""last_modified"":1686793250,""created_at"":1686793250,""data"":""E""}","{""field_type"":3,""last_modified"":1686793290,""created_at"":1686793290,""data"":""GzNa""}","{""last_modified"":1686793294,""created_at"":1686793294,""data"":""Yes"",""field_type"":5}",,"{""created_at"":1686793346,""field_type"":1,""last_modified"":1686793346,""data"":""1""}","{""last_modified"":1686793383,""data"":""1685929383"",""field_type"":2,""include_time"":false,""created_at"":1686793383}","{""field_type"":6,""url"":"""",""data"":"""",""last_modified"":1686793421,""created_at"":1686793419}","{""field_type"":4,""last_modified"":1686793465,""data"":""2uRu,Djrz,5lWo,KFYu,KftP"",""created_at"":1686793463}" "{""field_type"":0,""created_at"":1686793251,""data"":"""",""last_modified"":1686793289}",,,,"{""data"":""2"",""field_type"":1,""created_at"":1686793347,""last_modified"":1686793347}","{""include_time"":false,""data"":""1685929385"",""last_modified"":1686793385,""field_type"":2,""created_at"":1686793385}",, "{""created_at"":1686793254,""field_type"":0,""last_modified"":1686793288,""data"":""""}",,,,"{""created_at"":1686793351,""last_modified"":1686793351,""data"":""10"",""field_type"":1}","{""include_time"":false,""data"":""1686879792"",""field_type"":2,""created_at"":1686793392,""last_modified"":1686793392}",, ,,,,"{""last_modified"":1686793354,""created_at"":1686793354,""field_type"":1,""data"":""11""}",,, ,,,,"{""field_type"":1,""last_modified"":1686793356,""data"":""12"",""created_at"":1686793356}",,, ,,,,,,, ================================================ FILE: frontend/rust-lib/event-integration-test/tests/asset/japan_trip.md ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/asset/project.csv ================================================ Project name,Status,Owner,Dates,Priority,Completion,Tasks,Blocked By,Is Blocking,Summary,Delay,Checkbox,Files & media Research study,In Progress,Nate Martins,"April 23, 2024 → May 22, 2024",High,0.25,"Develop survey questions (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Develop%20survey%20questions%2086ce25c6bb214b4b9e35beed8bc8b821.md), Interpret findings (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Interpret%20findings%20c418ae6385f94dcdadca1423ad60146b.md), Write research report (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Write%20research%20report%20cca8f4321cd44dceba9c266cdf544f15.md), Conduct interviews (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Conduct%20interviews%208f40c17883904d769fee19baeb0d46a5.md)",,Marketing campaign (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Projects%2058b8977d6e4444a98ec4d64176a071e5/Marketing%20campaign%2088ac0cea4cb245efb44d63ace0a37d1e.md),"A research study is underway to understand customer satisfaction and identify improvement areas. The team is developing a survey to gather data from customers, aiming to analyze insights for strategic decision-making and enhance satisfaction.",7,No,Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/DO010003572.jpeg Marketing campaign,In Progress,Sohrab Amin,"April 24, 2024 → May 7, 2024",Low,0.5,"Define target audience (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Define%20target%20audience%2045e2d3342bfe44848009f8e19e65b2af.md), Develop advertising plan (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Develop%20advertising%20plan%20a8e534ad763040029d0feb27fdb1820d.md), Report on marketing ROI (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Report%20on%20marketing%20ROI%202458b6a0f0174f72af3e5daac8b36b08.md), Create social media plan (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Create%20social%20media%20plan%204e70ea0b7d40427a9648bcf554a121f6.md), Create performance marketing plan (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Create%20performance%20marketing%20plan%20b6aa6a9e9cc1446490984eaecc4930c7.md), Develop new creative assets (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Develop%20new%20creative%20assets%203268a1d63424427ba1f7e3cac109cefa.md)",Research study (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Projects%2058b8977d6e4444a98ec4d64176a071e5/Research%20study%20e445ee1fb7ff4591be2de17d906df97e.md),Website redesign (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Projects%2058b8977d6e4444a98ec4d64176a071e5/Website%20redesign%20bb934cb9f0e44daab4368247d62b0f39.md),"The marketing campaign, led by Sohrab Amin, focuses on enhancing mobile performance, building on last year's success in reducing page load times by 50%. The team aims to further improve performance by investing in native components for iOS and Android from April 24 to May 7, 2024.",19,No,Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/appflowy_2x.png Website redesign,Planning,Nate Martins,"May 10, 2024 → May 26, 2024",Medium,,"Conduct website audit (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Conduct%20website%20audit%20aee812f0c962462e8b91e8044a268eb5.md), Write website copy (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Write%20website%20copy%209ab9309ceae34f6f8dc19b2eeda8f8f2.md), Test website functionality (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Test%20website%20functionality%208ccf7153028747b19a351f6d36100f0d.md), Develop creative assets (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Develop%20creative%20assets%206fe86230e30843ebb60c67724f0f922f.md)",Marketing campaign (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Projects%2058b8977d6e4444a98ec4d64176a071e5/Marketing%20campaign%2088ac0cea4cb245efb44d63ace0a37d1e.md),,"The website redesign project, led by Nate Martins from May 10 to May 26, 2024, aims to create a more effective onboarding process to enhance user experience and increase 7-day retention by 25%.",,Yes, Product launch,In Progress,Ben Lang,"May 8, 2024 → May 22, 2024",High,0.6666666666666666,"Create product demo video (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Create%20product%20demo%20video%202e2e2eb53df84019a613fe386e4c79da.md), Create product positioning (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Create%20product%20positioning%20c0d2728f79594cc3a1869a3c67bcdf45.md), Monitor launch performance (Projects%20&%20Tasks%20104d4deadd2c805fb3abcaab6d3727e7/Tasks%2076aaf8a4637542ed8175259692ca08bb/Monitor%20launch%20performance%207c0b846d1da2463892eff490f06e0d0b.md)",,,"A new product launch is scheduled from May 8 to May 22, 2024, initiated to fill a market gap and expand the product line. The development team is focused on creating a high-quality product, while the marketing team is crafting a strategy to achieve a 10% market share within the first six months post-launch.",16,Yes, ================================================ FILE: frontend/rust-lib/event-integration-test/tests/chat/chat_message_test.rs ================================================ [File too large to display: 5.6 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/chat/local_chat_test.rs ================================================ use crate::util::load_text_file_content; use event_integration_test::user_event::use_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_user_pub::entities::WorkspaceType; #[tokio::test] async fn local_ollama_test_create_chat_with_selected_sources() { use_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; test.af_cloud_sign_up().await; test.toggle_local_ai().await; let local_workspace = test .create_workspace("my workspace", WorkspaceType::Local) .await; // create a chat document test .open_workspace( &local_workspace.workspace_id, local_workspace.workspace_type, ) .await; let doc = test .create_and_open_document( &local_workspace.workspace_id, "japan trip".to_string(), vec![], ) .await; let content = load_text_file_content("japan_trip.md"); test.insert_document_text(&doc.id, &content, 0).await; //chat with the document let chat = test.create_chat(&local_workspace.workspace_id).await; test .set_chat_rag_ids(&chat.id, vec![doc.id.to_string()]) .await; // test // .send_message(&chat.id, "why use rust?", ChatMessageTypePB::User) // .await; } ================================================ FILE: frontend/rust-lib/event-integration-test/tests/chat/mod.rs ================================================ mod chat_message_test; ================================================ FILE: frontend/rust-lib/event-integration-test/tests/database/af_cloud/mod.rs ================================================ [File too large to display: 64 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/database/af_cloud/summarize_row_test.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/database/af_cloud/translate_row_test.rs ================================================ use crate::database::af_cloud::util::make_test_summary_grid; use std::time::Duration; use tokio::time::sleep; use event_integration_test::user_event::user_localhost_af_cloud; use event_integration_test::EventIntegrationTest; use flowy_database2::entities::{FieldType, TranslateRowPB}; #[tokio::test] async fn af_cloud_translate_row_test() { user_localhost_af_cloud().await; let test = EventIntegrationTest::new().await; test.af_cloud_sign_up().await; // create document and then insert content let current_workspace = test.get_current_workspace().await; let initial_data = make_test_summary_grid().to_json_bytes().unwrap(); let view = test .create_grid( ¤t_workspace.id, "translate database".to_string(), initial_data, ) .await; let database_pb = test.get_database(&view.id).await; let field = test .get_all_database_fields(&view.id) .await .items .into_iter() .find(|field| field.field_type == FieldType::Translate) .unwrap(); let row_id = database_pb.rows[0].id.clone(); let data = TranslateRowPB { view_id: view.id.clone(), row_id: row_id.clone(), field_id: field.id.clone(), }; test.translate_row(data).await; sleep(Duration::from_secs(1)).await; let cell = test .get_text_cell(&view.id, &row_id, &field.id) .await .to_lowercase(); println!("cell: {}", cell); // default translation is in French. So it should be something like this: // Prix:2,6 $,Nom du produit:Pomme,Statut:TERMINÉ assert!(cell.contains("pomme")); assert!(cell.contains("produit")); assert!(cell.contains("prix")); } ================================================ FILE: frontend/rust-lib/event-integration-test/tests/database/af_cloud/util.rs ================================================ [File too large to display: 4.3 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/database/local_test/calculate_test.rs ================================================ [File too large to display: 2.7 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/database/local_test/event_test.rs ================================================ [File too large to display: 29.5 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/database/local_test/group_test.rs ================================================ [File too large to display: 4.9 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/database/local_test/mod.rs ================================================ [File too large to display: 52 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/database/mod.rs ================================================ mod af_cloud; mod local_test; ================================================ FILE: frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/edit_test.rs ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/file_upload_test.rs ================================================ [File too large to display: 6.1 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/document/af_cloud_test/mod.rs ================================================ [File too large to display: 37 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/document/local_test/edit_test.rs ================================================ [File too large to display: 7.6 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/document/local_test/mod.rs ================================================ [File too large to display: 37 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/document/local_test/snapshot_test.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/document/mod.rs ================================================ [File too large to display: 464 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/folder/local_test/folder_test.rs ================================================ [File too large to display: 8.4 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/folder/local_test/import_test.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/folder/local_test/mod.rs ================================================ [File too large to display: 134 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_database_test.rs ================================================ [File too large to display: 5.0 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/folder/local_test/publish_document_test.rs ================================================ [File too large to display: 6.9 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/folder/local_test/script.rs ================================================ [File too large to display: 10.5 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/folder/local_test/subscription_test.rs ================================================ [File too large to display: 4.2 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/folder/local_test/test.rs ================================================ [File too large to display: 15.5 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/folder/mod.rs ================================================ [File too large to display: 16 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/main.rs ================================================ [File too large to display: 103 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/search/document_title_content_search.rs ================================================ [File too large to display: 7.4 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/search/mod.rs ================================================ [File too large to display: 35 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_ordering_test.rs ================================================ [File too large to display: 21.0 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/sql_test/chat_message_test.rs ================================================ [File too large to display: 28.9 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/sql_test/mod.rs ================================================ [File too large to display: 55 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/auth_test.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/import_af_data_folder_test.rs ================================================ [File too large to display: 16.0 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/member_test.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/mod.rs ================================================ [File too large to display: 94 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/util.rs ================================================ [File too large to display: 722 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/af_cloud_test/workspace_test.rs ================================================ [File too large to display: 14.8 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/local_test/auth_test.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/local_test/helper.rs ================================================ [File too large to display: 681 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/local_test/import_af_data_local_test.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/local_test/mod.rs ================================================ [File too large to display: 106 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/local_test/user_awareness_test.rs ================================================ [File too large to display: 1007 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/local_test/user_profile_test.rs ================================================ [File too large to display: 3.4 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/migration_test/document_test.rs ================================================ [File too large to display: 799 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/migration_test/history_user_db/README.md ================================================ [File too large to display: 201 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/migration_test/mod.rs ================================================ [File too large to display: 37 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/migration_test/version_test.rs ================================================ [File too large to display: 4.7 KB] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/user/mod.rs ================================================ [File too large to display: 56 B] ================================================ FILE: frontend/rust-lib/event-integration-test/tests/util.rs ================================================ [File too large to display: 5.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/Cargo.toml ================================================ [File too large to display: 2.1 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/Flowy.toml ================================================ [File too large to display: 184 B] ================================================ FILE: frontend/rust-lib/flowy-ai/build.rs ================================================ [File too large to display: 179 B] ================================================ FILE: frontend/rust-lib/flowy-ai/dev.env ================================================ [File too large to display: 93 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/ai_manager.rs ================================================ [File too large to display: 24.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/ai_tool/markdown.rs ================================================ [File too large to display: 1.0 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/ai_tool/mod.rs ================================================ [File too large to display: 31 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/ai_tool/pdf.rs ================================================ [File too large to display: 870 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/chat.rs ================================================ [File too large to display: 20.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/completion.rs ================================================ [File too large to display: 6.6 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/embeddings/context.rs ================================================ [File too large to display: 2.9 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/embeddings/document_indexer.rs ================================================ [File too large to display: 5.6 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/embeddings/embedder.rs ================================================ [File too large to display: 959 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/embeddings/indexer.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/embeddings/mod.rs ================================================ [File too large to display: 107 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/embeddings/scheduler.rs ================================================ [File too large to display: 11.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/embeddings/store.rs ================================================ [File too large to display: 8.9 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/entities.rs ================================================ [File too large to display: 17.2 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/event_handler.rs ================================================ [File too large to display: 12.6 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/event_map.rs ================================================ [File too large to display: 4.7 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/lib.rs ================================================ [File too large to display: 482 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/chains/context_question_chain.rs ================================================ [File too large to display: 4.6 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/chains/conversation_chain.rs ================================================ [File too large to display: 18.7 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/chains/mod.rs ================================================ [File too large to display: 92 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/chains/related_question_chain.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/format_prompt.rs ================================================ [File too large to display: 3.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/llm.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/llm_chat.rs ================================================ [File too large to display: 7.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/mod.rs ================================================ [File too large to display: 7.3 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/retriever/mod.rs ================================================ [File too large to display: 815 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/retriever/multi_source_retriever.rs ================================================ [File too large to display: 3.0 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/retriever/sqlite_retriever.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/chat/summary_memory.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/completion/chain.rs ================================================ [File too large to display: 4.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/completion/impls.rs ================================================ [File too large to display: 1 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/completion/mod.rs ================================================ [File too large to display: 74 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/completion/stream_interpreter.rs ================================================ [File too large to display: 18.0 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/completion/writer.rs ================================================ [File too large to display: 8.4 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/controller.rs ================================================ [File too large to display: 16.0 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/database/mod.rs ================================================ [File too large to display: 36 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/database/summary.rs ================================================ [File too large to display: 2.6 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/database/translate.rs ================================================ [File too large to display: 7.1 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/mod.rs ================================================ [File too large to display: 141 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/prompt/mod.rs ================================================ [File too large to display: 5.3 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/request.rs ================================================ [File too large to display: 3.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/resource.rs ================================================ [File too large to display: 5.4 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/local_ai/stream_util.rs ================================================ [File too large to display: 82 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/mcp/manager.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/mcp/mod.rs ================================================ [File too large to display: 13 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/middleware/chat_service_mw.rs ================================================ [File too large to display: 7.5 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/middleware/mod.rs ================================================ [File too large to display: 32 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/model_select.rs ================================================ [File too large to display: 13.3 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/model_select_test.rs ================================================ [File too large to display: 13.0 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/notification.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/offline/mod.rs ================================================ [File too large to display: 30 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/offline/offline_message_sync.rs ================================================ [File too large to display: 6.6 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/search/mod.rs ================================================ [File too large to display: 17 B] ================================================ FILE: frontend/rust-lib/flowy-ai/src/search/summary.rs ================================================ [File too large to display: 4.9 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/src/stream_message.rs ================================================ [File too large to display: 2.2 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/tests/asset/japan_trip.md ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/tests/chat_test/mod.rs ================================================ [File too large to display: 40 B] ================================================ FILE: frontend/rust-lib/flowy-ai/tests/chat_test/qa_test.rs ================================================ [File too large to display: 5.4 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/tests/chat_test/related_question_test.rs ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/tests/complete_test/mod.rs ================================================ [File too large to display: 20.4 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/tests/main.rs ================================================ [File too large to display: 3.7 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/tests/summary_test/mod.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-ai/tests/translate_test/mod.rs ================================================ [File too large to display: 965 B] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/Cargo.toml ================================================ [File too large to display: 563 B] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/cloud.rs ================================================ [File too large to display: 4.8 KB] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/entities.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/lib.rs ================================================ [File too large to display: 76 B] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/persistence/chat_message_sql.rs ================================================ [File too large to display: 7.3 KB] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/persistence/chat_sql.rs ================================================ [File too large to display: 5.9 KB] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/persistence/collab_metadata_sql.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/persistence/collab_sql.rs ================================================ [File too large to display: 2.4 KB] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/persistence/local_model_sql.rs ================================================ [File too large to display: 1.3 KB] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/persistence/mod.rs ================================================ [File too large to display: 232 B] ================================================ FILE: frontend/rust-lib/flowy-ai-pub/src/user_service.rs ================================================ [File too large to display: 628 B] ================================================ FILE: frontend/rust-lib/flowy-core/.gitignore ================================================ [File too large to display: 329 B] ================================================ FILE: frontend/rust-lib/flowy-core/Cargo.toml ================================================ [File too large to display: 2.5 KB] ================================================ FILE: frontend/rust-lib/flowy-core/assets/read_me.json ================================================ [File too large to display: 6.1 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/app_life_cycle.rs ================================================ [File too large to display: 14.7 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/config.rs ================================================ [File too large to display: 5.1 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/chat_deps.rs ================================================ [File too large to display: 7.6 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/cloud_service_impl.rs ================================================ [File too large to display: 24.0 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/collab_deps.rs ================================================ [File too large to display: 6.2 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/database_deps.rs ================================================ [File too large to display: 3.5 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs ================================================ [File too large to display: 3.6 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/file_storage_deps.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_chat_impl.rs ================================================ [File too large to display: 2.2 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_database_impl.rs ================================================ [File too large to display: 12.0 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/folder_deps_doc_impl.rs ================================================ [File too large to display: 5.6 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/folder_deps/mod.rs ================================================ [File too large to display: 8.0 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/mod.rs ================================================ [File too large to display: 370 B] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/reminder_deps.rs ================================================ [File too large to display: 1.8 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/search_deps.rs ================================================ [File too large to display: 704 B] ================================================ FILE: frontend/rust-lib/flowy-core/src/deps_resolve/user_deps.rs ================================================ [File too large to display: 2.4 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/folder_view_observer.rs ================================================ [File too large to display: 1.9 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/full_indexed_data_provider.rs ================================================ [File too large to display: 10.0 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/indexed_data_consumer.rs ================================================ [File too large to display: 11.0 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/indexing_data_runner.rs ================================================ [File too large to display: 7.6 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/lib.rs ================================================ [File too large to display: 11.8 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/log_filter.rs ================================================ [File too large to display: 3.1 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/module.rs ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/flowy-core/src/server_layer.rs ================================================ [File too large to display: 6.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database-pub/Cargo.toml ================================================ [File too large to display: 378 B] ================================================ FILE: frontend/rust-lib/flowy-database-pub/src/cloud.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database-pub/src/lib.rs ================================================ [File too large to display: 15 B] ================================================ FILE: frontend/rust-lib/flowy-database2/Cargo.toml ================================================ [File too large to display: 1.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/Flowy.toml ================================================ [File too large to display: 195 B] ================================================ FILE: frontend/rust-lib/flowy-database2/build.rs ================================================ [File too large to display: 734 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/board_entities.rs ================================================ [File too large to display: 790 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_changeset.rs ================================================ [File too large to display: 2.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs ================================================ [File too large to display: 3.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/calculation/mod.rs ================================================ [File too large to display: 121 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/calendar_entities.rs ================================================ [File too large to display: 3.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/cell_entities.rs ================================================ [File too large to display: 4.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/database_entities.rs ================================================ [File too large to display: 9.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/field_entities.rs ================================================ [File too large to display: 15.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/field_settings_entities.rs ================================================ [File too large to display: 2.8 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/file_entities.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/checkbox_filter.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/checklist_filter.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/date_filter.rs ================================================ [File too large to display: 3.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/filter_changeset.rs ================================================ [File too large to display: 480 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/media_filter.rs ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs ================================================ [File too large to display: 498 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/number_filter.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/relation_filter.rs ================================================ [File too large to display: 559 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/select_option_filter.rs ================================================ [File too large to display: 2.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/text_filter.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/time_filter.rs ================================================ [File too large to display: 566 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs ================================================ [File too large to display: 8.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/group_entities/configuration.rs ================================================ [File too large to display: 3.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/group_entities/group.rs ================================================ [File too large to display: 5.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/group_entities/group_changeset.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/group_entities/mod.rs ================================================ [File too large to display: 124 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/macros.rs ================================================ [File too large to display: 2.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/mod.rs ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/parser.rs ================================================ [File too large to display: 318 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/position_entities.rs ================================================ [File too large to display: 2.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/row_entities.rs ================================================ [File too large to display: 11.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/setting_entities.rs ================================================ [File too large to display: 5.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/share_entities.rs ================================================ [File too large to display: 378 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/sort_entities.rs ================================================ [File too large to display: 4.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checkbox_entities.rs ================================================ [File too large to display: 1.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/checklist_entities.rs ================================================ [File too large to display: 973 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs ================================================ [File too large to display: 3.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/media_entities.rs ================================================ [File too large to display: 4.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs ================================================ [File too large to display: 660 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/number_entities.rs ================================================ [File too large to display: 5.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/relation_entities.rs ================================================ [File too large to display: 1.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/select_option_entities.rs ================================================ [File too large to display: 7.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/summary_entities.rs ================================================ [File too large to display: 626 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/text_entities.rs ================================================ [File too large to display: 518 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/time_entities.rs ================================================ [File too large to display: 572 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/timestamp_entities.rs ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/translate_entities.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/type_option_entities/url_entities.rs ================================================ [File too large to display: 686 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/entities/view_entities.rs ================================================ [File too large to display: 3.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/event_handler.rs ================================================ [File too large to display: 46.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/event_map.rs ================================================ [File too large to display: 17.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/lib.rs ================================================ [File too large to display: 177 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/manager.rs ================================================ [File too large to display: 38.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/notification.rs ================================================ [File too large to display: 3.8 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/calculations/cache.rs ================================================ [File too large to display: 126 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/calculations/controller.rs ================================================ [File too large to display: 12.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/calculations/entities.rs ================================================ [File too large to display: 2.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/calculations/mod.rs ================================================ [File too large to display: 185 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/calculations/service.rs ================================================ [File too large to display: 4.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/calculations/task.rs ================================================ [File too large to display: 1015 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/cell/cell_data_cache.rs ================================================ [File too large to display: 106 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs ================================================ [File too large to display: 12.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/cell/mod.rs ================================================ [File too large to display: 144 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/cell/type_cell_data.rs ================================================ [File too large to display: 2.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs ================================================ [File too large to display: 75.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database/database_observe.rs ================================================ [File too large to display: 11.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database/entities.rs ================================================ [File too large to display: 1.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database/mod.rs ================================================ [File too large to display: 174 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database/util.rs ================================================ [File too large to display: 2.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/layout_deps.rs ================================================ [File too large to display: 5.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/mod.rs ================================================ [File too large to display: 284 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/notifier.rs ================================================ [File too large to display: 4.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/view_calculations.rs ================================================ [File too large to display: 2.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/view_editor.rs ================================================ [File too large to display: 43.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/view_filter.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/view_group.rs ================================================ [File too large to display: 4.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/view_operation.rs ================================================ [File too large to display: 4.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/view_sort.rs ================================================ [File too large to display: 2.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/database_view/views.rs ================================================ [File too large to display: 2.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/field_builder.rs ================================================ [File too large to display: 1008 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/field_operation.rs ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/mod.rs ================================================ [File too large to display: 180 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_option_transform.rs ================================================ [File too large to display: 4.8 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_filter.rs ================================================ [File too large to display: 1.8 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs ================================================ [File too large to display: 3.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/checkbox_type_option/mod.rs ================================================ [File too large to display: 214 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_filter.rs ================================================ [File too large to display: 3.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/checklist_type_option.rs ================================================ [File too large to display: 5.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/checklist_type_option/mod.rs ================================================ [File too large to display: 93 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_filter.rs ================================================ [File too large to display: 17.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_tests.rs ================================================ [File too large to display: 5.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs ================================================ [File too large to display: 6.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/mod.rs ================================================ [File too large to display: 99 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_file.rs ================================================ [File too large to display: 2.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_filter.rs ================================================ [File too large to display: 615 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_type_option.rs ================================================ [File too large to display: 3.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/mod.rs ================================================ [File too large to display: 96 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs ================================================ [File too large to display: 752 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/mod.rs ================================================ [File too large to display: 206 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_filter.rs ================================================ [File too large to display: 5.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option.rs ================================================ [File too large to display: 6.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/number_type_option/number_type_option_entities.rs ================================================ [File too large to display: 997 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/mod.rs ================================================ [File too large to display: 68 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation.rs ================================================ [File too large to display: 2.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/relation_type_option/relation_entities.rs ================================================ [File too large to display: 176 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/mod.rs ================================================ [File too large to display: 188 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/multi_select_type_option.rs ================================================ [File too large to display: 4.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_filter.rs ================================================ [File too large to display: 11.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_option_tests.rs ================================================ [File too large to display: 6.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_type_option.rs ================================================ [File too large to display: 8.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/single_select_type_option.rs ================================================ [File too large to display: 3.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/type_option_transform.rs ================================================ [File too large to display: 4.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/mod.rs ================================================ [File too large to display: 17 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/summary_type_option/summary.rs ================================================ [File too large to display: 2.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/mod.rs ================================================ [File too large to display: 121 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_filter.rs ================================================ [File too large to display: 5.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs ================================================ [File too large to display: 3.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs ================================================ [File too large to display: 4.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/mod.rs ================================================ [File too large to display: 45 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time.rs ================================================ [File too large to display: 2.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/time_type_option/time_filter.rs ================================================ [File too large to display: 2.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/mod.rs ================================================ [File too large to display: 63 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/timestamp_type_option/timestamp_type_option.rs ================================================ [File too large to display: 2.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/translate_type_option/mod.rs ================================================ [File too large to display: 19 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/translate_type_option/translate.rs ================================================ [File too large to display: 2.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs ================================================ [File too large to display: 11.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs ================================================ [File too large to display: 19.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/mod.rs ================================================ [File too large to display: 168 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_tests.rs ================================================ [File too large to display: 946 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option.rs ================================================ [File too large to display: 3.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/url_type_option/url_type_option_entities.rs ================================================ [File too large to display: 708 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field/type_options/util.rs ================================================ [File too large to display: 983 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field_settings/entities.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field_settings/field_settings_builder.rs ================================================ [File too large to display: 2.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/field_settings/mod.rs ================================================ [File too large to display: 99 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/filter/controller.rs ================================================ [File too large to display: 17.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/filter/entities.rs ================================================ [File too large to display: 17.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/filter/mod.rs ================================================ [File too large to display: 109 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/filter/task.rs ================================================ [File too large to display: 931 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/action.rs ================================================ [File too large to display: 7.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/configuration.rs ================================================ [File too large to display: 15.8 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller.rs ================================================ [File too large to display: 14.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller_impls/checkbox_controller.rs ================================================ [File too large to display: 5.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs ================================================ [File too large to display: 14.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller_impls/default_controller.rs ================================================ [File too large to display: 4.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller_impls/mod.rs ================================================ [File too large to display: 276 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/mod.rs ================================================ [File too large to display: 160 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs ================================================ [File too large to display: 5.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/single_select_controller.rs ================================================ [File too large to display: 5.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs ================================================ [File too large to display: 5.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/controller_impls/url_controller.rs ================================================ [File too large to display: 6.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/entities.rs ================================================ [File too large to display: 4.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs ================================================ [File too large to display: 5.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/group/mod.rs ================================================ [File too large to display: 289 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/mod.rs ================================================ [File too large to display: 211 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/setting/entities.rs ================================================ [File too large to display: 2.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/setting/mod.rs ================================================ [File too large to display: 36 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/share/csv/export.rs ================================================ [File too large to display: 3.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/share/csv/import.rs ================================================ [File too large to display: 6.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/share/csv/mod.rs ================================================ [File too large to display: 63 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/share/mod.rs ================================================ [File too large to display: 13 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/snapshot/entities.rs ================================================ [File too large to display: 1 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/snapshot/mod.rs ================================================ [File too large to display: 18 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/sort/controller.rs ================================================ [File too large to display: 10.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/sort/entities.rs ================================================ [File too large to display: 3.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/sort/mod.rs ================================================ [File too large to display: 102 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/services/sort/task.rs ================================================ [File too large to display: 998 B] ================================================ FILE: frontend/rust-lib/flowy-database2/src/template.rs ================================================ [File too large to display: 5.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/utils/cache.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/src/utils/mod.rs ================================================ [File too large to display: 15 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/block_test/mod.rs ================================================ [File too large to display: 26 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/block_test/row_test.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/block_test/script.rs ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/calculations_test/calculation_test.rs ================================================ [File too large to display: 5.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/calculations_test/mod.rs ================================================ [File too large to display: 34 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/calculations_test/script.rs ================================================ [File too large to display: 1.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/cell_test/mod.rs ================================================ [File too large to display: 22 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/cell_test/script.rs ================================================ [File too large to display: 842 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs ================================================ [File too large to display: 6.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/database_editor.rs ================================================ [File too large to display: 8.8 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/field_settings_test/mod.rs ================================================ [File too large to display: 22 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/field_settings_test/script.rs ================================================ [File too large to display: 3.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/field_settings_test/test.rs ================================================ [File too large to display: 7.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/field_test/mod.rs ================================================ [File too large to display: 36 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/field_test/script.rs ================================================ [File too large to display: 3.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/field_test/test.rs ================================================ [File too large to display: 7.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs ================================================ [File too large to display: 4.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/advanced_filter_test.rs ================================================ [File too large to display: 8.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/checkbox_filter_test.rs ================================================ [File too large to display: 1.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/checklist_filter_test.rs ================================================ [File too large to display: 2.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/date_filter_test.rs ================================================ [File too large to display: 3.6 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/mod.rs ================================================ [File too large to display: 212 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/number_filter_test.rs ================================================ [File too large to display: 4.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/script.rs ================================================ [File too large to display: 9.0 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/select_option_filter_test.rs ================================================ [File too large to display: 5.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/text_filter_test.rs ================================================ [File too large to display: 7.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/filter_test/time_filter_test.rs ================================================ [File too large to display: 3.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs ================================================ [File too large to display: 3.8 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/group_test/mod.rs ================================================ [File too large to display: 63 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs ================================================ [File too large to display: 7.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/group_test/test.rs ================================================ [File too large to display: 6.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/group_test/url_group_test.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/layout_test/mod.rs ================================================ [File too large to display: 22 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/layout_test/script.rs ================================================ [File too large to display: 3.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/layout_test/test.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs ================================================ [File too large to display: 10.8 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/mock_data/calendar_mock_data.rs ================================================ [File too large to display: 3.8 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs ================================================ [File too large to display: 14.9 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/mock_data/mod.rs ================================================ [File too large to display: 537 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/mod.rs ================================================ [File too large to display: 236 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/mod.rs ================================================ [File too large to display: 91 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_according_to_filter_test.rs ================================================ [File too large to display: 9.1 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/pre_fill_row_with_payload_test.rs ================================================ [File too large to display: 9.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/pre_fill_cell_test/script.rs ================================================ [File too large to display: 3.3 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs ================================================ [File too large to display: 9.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/share_test/mod.rs ================================================ [File too large to display: 17 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/sort_test/mod.rs ================================================ [File too large to display: 55 B] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/sort_test/multi_sort_test.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/sort_test/script.rs ================================================ [File too large to display: 5.5 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/database/sort_test/single_sort_test.rs ================================================ [File too large to display: 7.4 KB] ================================================ FILE: frontend/rust-lib/flowy-database2/tests/main.rs ================================================ [File too large to display: 14 B] ================================================ FILE: frontend/rust-lib/flowy-date/Cargo.toml ================================================ [File too large to display: 626 B] ================================================ FILE: frontend/rust-lib/flowy-date/Flowy.toml ================================================ [File too large to display: 162 B] ================================================ FILE: frontend/rust-lib/flowy-date/build.rs ================================================ [File too large to display: 179 B] ================================================ FILE: frontend/rust-lib/flowy-date/src/entities.rs ================================================ [File too large to display: 250 B] ================================================ FILE: frontend/rust-lib/flowy-date/src/event_handler.rs ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/flowy-date/src/event_map.rs ================================================ [File too large to display: 506 B] ================================================ FILE: frontend/rust-lib/flowy-date/src/lib.rs ================================================ [File too large to display: 78 B] ================================================ FILE: frontend/rust-lib/flowy-document/Cargo.toml ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-document/Flowy.toml ================================================ [File too large to display: 217 B] ================================================ FILE: frontend/rust-lib/flowy-document/build.rs ================================================ [File too large to display: 179 B] ================================================ FILE: frontend/rust-lib/flowy-document/src/deps.rs ================================================ [File too large to display: 47 B] ================================================ FILE: frontend/rust-lib/flowy-document/src/document.rs ================================================ [File too large to display: 2.5 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/document_data.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/entities.rs ================================================ [File too large to display: 15.8 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/event_handler.rs ================================================ [File too large to display: 17.2 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/event_map.rs ================================================ [File too large to display: 4.0 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/lib.rs ================================================ [File too large to display: 275 B] ================================================ FILE: frontend/rust-lib/flowy-document/src/manager.rs ================================================ [File too large to display: 16.7 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/notification.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/parse.rs ================================================ [File too large to display: 475 B] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/constant.rs ================================================ [File too large to display: 4.1 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/document_data_parser.rs ================================================ [File too large to display: 4.6 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/external/mod.rs ================================================ [File too large to display: 27 B] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/external/parser.rs ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/external/utils.rs ================================================ [File too large to display: 15.6 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/json/block.rs ================================================ [File too large to display: 450 B] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/json/mod.rs ================================================ [File too large to display: 31 B] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/json/parser.rs ================================================ [File too large to display: 3.5 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/mod.rs ================================================ [File too large to display: 120 B] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/parser_entities.rs ================================================ [File too large to display: 14.3 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/parser/utils.rs ================================================ [File too large to display: 3.3 KB] ================================================ FILE: frontend/rust-lib/flowy-document/src/reminder.rs ================================================ [File too large to display: 632 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/bulleted_list.html ================================================ [File too large to display: 90 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/callout.html ================================================ [File too large to display: 248 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/code.html ================================================ [File too large to display: 175 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/divider.html ================================================ [File too large to display: 28 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/google_docs.html ================================================ [File too large to display: 12.5 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/heading.html ================================================ [File too large to display: 73 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/image.html ================================================ [File too large to display: 137 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/math_equation.html ================================================ [File too large to display: 37 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/notion.html ================================================ [File too large to display: 1.3 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/numbered_list.html ================================================ [File too large to display: 90 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/paragraph.html ================================================ [File too large to display: 728 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/quote.html ================================================ [File too large to display: 95 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/simple.html ================================================ [File too large to display: 452 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/todo_list.html ================================================ [File too large to display: 163 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/html/toggle_list.html ================================================ [File too large to display: 207 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/bulleted_list.json ================================================ [File too large to display: 607 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/callout.json ================================================ [File too large to display: 749 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/code.json ================================================ [File too large to display: 290 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/divider.json ================================================ [File too large to display: 109 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/google_docs.json ================================================ [File too large to display: 7.1 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/heading.json ================================================ [File too large to display: 558 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/image.json ================================================ [File too large to display: 285 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/initial_document.json ================================================ [File too large to display: 7.4 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/math_equation.json ================================================ [File too large to display: 120 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/notion.json ================================================ [File too large to display: 7.1 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/numbered_list.json ================================================ [File too large to display: 607 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/paragraph.json ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/plain_text.json ================================================ [File too large to display: 9.0 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/quote.json ================================================ [File too large to display: 388 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/range_1.json ================================================ [File too large to display: 2.7 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/range_2.json ================================================ [File too large to display: 4.9 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/simple.json ================================================ [File too large to display: 184 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/todo_list.json ================================================ [File too large to display: 652 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/json/toggle_list.json ================================================ [File too large to display: 573 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/bulleted_list.txt ================================================ [File too large to display: 28 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/callout.txt ================================================ [File too large to display: 69 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/code.txt ================================================ [File too large to display: 108 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/divider.txt ================================================ [File too large to display: 1 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/heading.txt ================================================ [File too large to display: 27 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/image.txt ================================================ [File too large to display: 1 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/math_equation.txt ================================================ [File too large to display: 9 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/numbered_list.txt ================================================ [File too large to display: 28 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/paragraph.txt ================================================ [File too large to display: 205 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/plain_text.txt ================================================ [File too large to display: 1022 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/quote.txt ================================================ [File too large to display: 36 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/todo_list.txt ================================================ [File too large to display: 28 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/assets/text/toggle_list.txt ================================================ [File too large to display: 92 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/document/document_insert_test.rs ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/document/document_redo_undo_test.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/document/document_test.rs ================================================ [File too large to display: 6.6 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/document/event_handler_test.rs ================================================ [File too large to display: 979 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/document/mod.rs ================================================ [File too large to display: 112 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/document/util.rs ================================================ [File too large to display: 6.6 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/file_storage.rs ================================================ [File too large to display: 1 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/main.rs ================================================ [File too large to display: 26 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/document_data_parser_test.rs ================================================ [File too large to display: 3.6 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/html/mod.rs ================================================ [File too large to display: 17 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/html/parser_test.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/json/block_test.rs ================================================ [File too large to display: 2.4 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/json/mod.rs ================================================ [File too large to display: 33 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/json/parser_test.rs ================================================ [File too large to display: 3.0 KB] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/mod.rs ================================================ [File too large to display: 75 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/parse_to_html_text/mod.rs ================================================ [File too large to display: 21 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/parse_to_html_text/test.rs ================================================ [File too large to display: 992 B] ================================================ FILE: frontend/rust-lib/flowy-document/tests/parser/parse_to_html_text/utils.rs ================================================ [File too large to display: 742 B] ================================================ FILE: frontend/rust-lib/flowy-document-pub/Cargo.toml ================================================ [File too large to display: 345 B] ================================================ FILE: frontend/rust-lib/flowy-document-pub/src/cloud.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-document-pub/src/lib.rs ================================================ [File too large to display: 15 B] ================================================ FILE: frontend/rust-lib/flowy-error/Cargo.toml ================================================ [File too large to display: 1.9 KB] ================================================ FILE: frontend/rust-lib/flowy-error/Flowy.toml ================================================ [File too large to display: 120 B] ================================================ FILE: frontend/rust-lib/flowy-error/build.rs ================================================ [File too large to display: 107 B] ================================================ FILE: frontend/rust-lib/flowy-error/src/code.rs ================================================ [File too large to display: 9.3 KB] ================================================ FILE: frontend/rust-lib/flowy-error/src/errors.rs ================================================ [File too large to display: 8.6 KB] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/cloud.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/collab.rs ================================================ [File too large to display: 1.0 KB] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/collab_persistence.rs ================================================ [File too large to display: 746 B] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/database.rs ================================================ [File too large to display: 451 B] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/dispatch.rs ================================================ [File too large to display: 344 B] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/mod.rs ================================================ [File too large to display: 656 B] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/reqwest.rs ================================================ [File too large to display: 185 B] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/serde.rs ================================================ [File too large to display: 180 B] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/tantivy.rs ================================================ [File too large to display: 639 B] ================================================ FILE: frontend/rust-lib/flowy-error/src/impl_from/url.rs ================================================ [File too large to display: 212 B] ================================================ FILE: frontend/rust-lib/flowy-error/src/lib.rs ================================================ [File too large to display: 96 B] ================================================ FILE: frontend/rust-lib/flowy-folder/Cargo.toml ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/Flowy.toml ================================================ [File too large to display: 181 B] ================================================ FILE: frontend/rust-lib/flowy-folder/build.rs ================================================ [File too large to display: 179 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/icon.rs ================================================ [File too large to display: 2.5 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/import.rs ================================================ [File too large to display: 3.3 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/mod.rs ================================================ [File too large to display: 217 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/empty_str.rs ================================================ [File too large to display: 318 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/mod.rs ================================================ [File too large to display: 83 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/trash/mod.rs ================================================ [File too large to display: 14 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/trash/trash_id.rs ================================================ [File too large to display: 375 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/view/mod.rs ================================================ [File too large to display: 118 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/view/view_id.rs ================================================ [File too large to display: 354 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/view/view_name.rs ================================================ [File too large to display: 397 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/view/view_thumbnail.rs ================================================ [File too large to display: 453 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/workspace/mod.rs ================================================ [File too large to display: 138 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/workspace/workspace_desc.rs ================================================ [File too large to display: 423 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/workspace/workspace_id.rs ================================================ [File too large to display: 383 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/parser/workspace/workspace_name.rs ================================================ [File too large to display: 509 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/publish.rs ================================================ [File too large to display: 2.6 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/trash.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/view.rs ================================================ [File too large to display: 26.1 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/entities/workspace.rs ================================================ [File too large to display: 4.8 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/event_handler.rs ================================================ [File too large to display: 20.4 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/event_map.rs ================================================ [File too large to display: 8.7 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/lib.rs ================================================ [File too large to display: 284 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/manager.rs ================================================ [File too large to display: 87.0 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/manager_init.rs ================================================ [File too large to display: 5.0 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/manager_observer.rs ================================================ [File too large to display: 9.1 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/notification.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/publish_util.rs ================================================ [File too large to display: 1.3 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/share/import.rs ================================================ [File too large to display: 856 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/share/mod.rs ================================================ [File too large to display: 32 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/user_default.rs ================================================ [File too large to display: 2.7 KB] ================================================ FILE: frontend/rust-lib/flowy-folder/src/util.rs ================================================ [File too large to display: 446 B] ================================================ FILE: frontend/rust-lib/flowy-folder/src/view_operation.rs ================================================ [File too large to display: 5.5 KB] ================================================ FILE: frontend/rust-lib/flowy-folder-pub/Cargo.toml ================================================ [File too large to display: 653 B] ================================================ FILE: frontend/rust-lib/flowy-folder-pub/src/cloud.rs ================================================ [File too large to display: 3.8 KB] ================================================ FILE: frontend/rust-lib/flowy-folder-pub/src/entities.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/flowy-folder-pub/src/lib.rs ================================================ [File too large to display: 61 B] ================================================ FILE: frontend/rust-lib/flowy-folder-pub/src/query.rs ================================================ [File too large to display: 963 B] ================================================ FILE: frontend/rust-lib/flowy-folder-pub/src/sql/mod.rs ================================================ [File too large to display: 70 B] ================================================ FILE: frontend/rust-lib/flowy-folder-pub/src/sql/workspace_shared_user_sql.rs ================================================ [File too large to display: 4.8 KB] ================================================ FILE: frontend/rust-lib/flowy-folder-pub/src/sql/workspace_shared_view_sql.rs ================================================ [File too large to display: 3.1 KB] ================================================ FILE: frontend/rust-lib/flowy-notification/Cargo.toml ================================================ [File too large to display: 643 B] ================================================ FILE: frontend/rust-lib/flowy-notification/Flowy.toml ================================================ [File too large to display: 104 B] ================================================ FILE: frontend/rust-lib/flowy-notification/build.rs ================================================ [File too large to display: 107 B] ================================================ FILE: frontend/rust-lib/flowy-notification/src/builder.rs ================================================ [File too large to display: 2.2 KB] ================================================ FILE: frontend/rust-lib/flowy-notification/src/debounce.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-notification/src/entities/mod.rs ================================================ [File too large to display: 34 B] ================================================ FILE: frontend/rust-lib/flowy-notification/src/entities/subject.rs ================================================ [File too large to display: 1002 B] ================================================ FILE: frontend/rust-lib/flowy-notification/src/lib.rs ================================================ [File too large to display: 1.3 KB] ================================================ FILE: frontend/rust-lib/flowy-search/Cargo.toml ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/flowy-search/Flowy.toml ================================================ [File too large to display: 86 B] ================================================ FILE: frontend/rust-lib/flowy-search/build.rs ================================================ [File too large to display: 179 B] ================================================ FILE: frontend/rust-lib/flowy-search/src/document/cloud_search_handler.rs ================================================ [File too large to display: 5.4 KB] ================================================ FILE: frontend/rust-lib/flowy-search/src/document/local_search_handler.rs ================================================ [File too large to display: 2.8 KB] ================================================ FILE: frontend/rust-lib/flowy-search/src/document/mod.rs ================================================ [File too large to display: 60 B] ================================================ FILE: frontend/rust-lib/flowy-search/src/entities/mod.rs ================================================ [File too large to display: 149 B] ================================================ FILE: frontend/rust-lib/flowy-search/src/entities/notification.rs ================================================ [File too large to display: 746 B] ================================================ FILE: frontend/rust-lib/flowy-search/src/entities/query.rs ================================================ [File too large to display: 300 B] ================================================ FILE: frontend/rust-lib/flowy-search/src/entities/result.rs ================================================ [File too large to display: 4.0 KB] ================================================ FILE: frontend/rust-lib/flowy-search/src/entities/search_filter.rs ================================================ [File too large to display: 164 B] ================================================ FILE: frontend/rust-lib/flowy-search/src/event_handler.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-search/src/event_map.rs ================================================ [File too large to display: 617 B] ================================================ FILE: frontend/rust-lib/flowy-search/src/lib.rs ================================================ [File too large to display: 114 B] ================================================ FILE: frontend/rust-lib/flowy-search/src/services/manager.rs ================================================ [File too large to display: 6.7 KB] ================================================ FILE: frontend/rust-lib/flowy-search/src/services/mod.rs ================================================ [File too large to display: 17 B] ================================================ FILE: frontend/rust-lib/flowy-search/tests/main.rs ================================================ [File too large to display: 34 B] ================================================ FILE: frontend/rust-lib/flowy-search/tests/tantivy_test.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-search-pub/Cargo.toml ================================================ [File too large to display: 495 B] ================================================ FILE: frontend/rust-lib/flowy-search-pub/src/cloud.rs ================================================ [File too large to display: 590 B] ================================================ FILE: frontend/rust-lib/flowy-search-pub/src/entities.rs ================================================ [File too large to display: 1.0 KB] ================================================ FILE: frontend/rust-lib/flowy-search-pub/src/lib.rs ================================================ [File too large to display: 97 B] ================================================ FILE: frontend/rust-lib/flowy-search-pub/src/schema.rs ================================================ [File too large to display: 990 B] ================================================ FILE: frontend/rust-lib/flowy-search-pub/src/tantivy_state.rs ================================================ [File too large to display: 13.2 KB] ================================================ FILE: frontend/rust-lib/flowy-search-pub/src/tantivy_state_init.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/flowy-server/Cargo.toml ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/define.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/chat.rs ================================================ [File too large to display: 7.1 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/database.rs ================================================ [File too large to display: 5.2 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/document.rs ================================================ [File too large to display: 3.1 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/file_storage.rs ================================================ [File too large to display: 4.1 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/folder.rs ================================================ [File too large to display: 8.5 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/mod.rs ================================================ [File too large to display: 289 B] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/search.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/user/cloud_service_impl.rs ================================================ [File too large to display: 21.5 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs ================================================ [File too large to display: 2.8 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/user/mod.rs ================================================ [File too large to display: 75 B] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/user/util.rs ================================================ [File too large to display: 313 B] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/impls/util.rs ================================================ [File too large to display: 1.0 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/mod.rs ================================================ [File too large to display: 63 B] ================================================ FILE: frontend/rust-lib/flowy-server/src/af_cloud/server.rs ================================================ [File too large to display: 12.9 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/lib.rs ================================================ [File too large to display: 101 B] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/impls/chat.rs ================================================ [File too large to display: 10.0 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/impls/database.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/impls/document.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/impls/folder.rs ================================================ [File too large to display: 5.5 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/impls/mod.rs ================================================ [File too large to display: 229 B] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/impls/search.rs ================================================ [File too large to display: 2.6 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/impls/user.rs ================================================ [File too large to display: 11.7 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/mod.rs ================================================ [File too large to display: 91 B] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/server.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/template/create_workspace.rs ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/template/mod.rs ================================================ [File too large to display: 26 B] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/uid.rs ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/local_server/util.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/response.rs ================================================ [File too large to display: 688 B] ================================================ FILE: frontend/rust-lib/flowy-server/src/server.rs ================================================ [File too large to display: 5.4 KB] ================================================ FILE: frontend/rust-lib/flowy-server/src/util.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/flowy-server-pub/Cargo.toml ================================================ [File too large to display: 305 B] ================================================ FILE: frontend/rust-lib/flowy-server-pub/src/af_cloud_config.rs ================================================ [File too large to display: 2.1 KB] ================================================ FILE: frontend/rust-lib/flowy-server-pub/src/lib.rs ================================================ [File too large to display: 1.3 KB] ================================================ FILE: frontend/rust-lib/flowy-server-pub/src/native/af_cloud_config.rs ================================================ [File too large to display: 2.7 KB] ================================================ FILE: frontend/rust-lib/flowy-server-pub/src/native/mod.rs ================================================ [File too large to display: 25 B] ================================================ FILE: frontend/rust-lib/flowy-server-pub/src/wasm/af_cloud_config.rs ================================================ [File too large to display: 497 B] ================================================ FILE: frontend/rust-lib/flowy-server-pub/src/wasm/mod.rs ================================================ [File too large to display: 25 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/Cargo.toml ================================================ [File too large to display: 822 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/diesel.toml ================================================ [File too large to display: 136 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-06-05-023648_user/down.sql ================================================ [File too large to display: 69 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-06-05-023648_user/up.sql ================================================ [File too large to display: 290 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-06-05-135652_collab_snapshot/down.sql ================================================ [File too large to display: 73 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-06-05-135652_collab_snapshot/up.sql ================================================ [File too large to display: 331 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-07-12-135810_user_auth_type/down.sql ================================================ [File too large to display: 91 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-07-12-135810_user_auth_type/up.sql ================================================ [File too large to display: 93 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-07-21-081348_user_workspace/down.sql ================================================ [File too large to display: 72 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-07-21-081348_user_workspace/up.sql ================================================ [File too large to display: 209 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-08-02-083250_user_migration/down.sql ================================================ [File too large to display: 85 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-08-02-083250_user_migration/up.sql ================================================ [File too large to display: 206 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-08-14-162155_user_encrypt/down.sql ================================================ [File too large to display: 90 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-08-14-162155_user_encrypt/up.sql ================================================ [File too large to display: 98 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-10-09-094834_user_stability_ai_key/down.sql ================================================ [File too large to display: 99 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-10-09-094834_user_stability_ai_key/up.sql ================================================ [File too large to display: 99 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-10-24-074032_user_updated_at/down.sql ================================================ [File too large to display: 93 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-10-24-074032_user_updated_at/up.sql ================================================ [File too large to display: 94 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-11-19-040403_rocksdb_backup/down.sql ================================================ [File too large to display: 72 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2023-11-19-040403_rocksdb_backup/up.sql ================================================ [File too large to display: 168 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-01-07-041005_recreate_snapshot_table/down.sql ================================================ [File too large to display: 84 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-01-07-041005_recreate_snapshot_table/up.sql ================================================ [File too large to display: 453 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-03-09-031208_user_workspace_icon/down.sql ================================================ [File too large to display: 102 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-03-09-031208_user_workspace_icon/up.sql ================================================ [File too large to display: 97 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/down.sql ================================================ [File too large to display: 99 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-05-23-061639_chat_message/up.sql ================================================ [File too large to display: 695 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-06-14-020242_workspace_member/down.sql ================================================ [File too large to display: 46 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-06-14-020242_workspace_member/up.sql ================================================ [File too large to display: 320 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-06-16-131359_file_upload/down.sql ================================================ [File too large to display: 76 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-06-16-131359_file_upload/up.sql ================================================ [File too large to display: 558 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-06-22-082201_user_ai_model/down.sql ================================================ [File too large to display: 46 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-06-22-082201_user_ai_model/up.sql ================================================ [File too large to display: 91 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-06-26-015936_chat_setting/down.sql ================================================ [File too large to display: 46 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-06-26-015936_chat_setting/up.sql ================================================ [File too large to display: 500 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-08-05-024351_chat_message_metadata/down.sql ================================================ [File too large to display: 100 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-08-05-024351_chat_message_metadata/up.sql ================================================ [File too large to display: 79 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-08-07-093650_chat_metadata/down.sql ================================================ [File too large to display: 46 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-08-07-093650_chat_metadata/up.sql ================================================ [File too large to display: 160 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-08-20-061727_file_upload_finish/down.sql ================================================ [File too large to display: 99 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-08-20-061727_file_upload_finish/up.sql ================================================ [File too large to display: 105 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-11-08-102351_workspace_member_count/down.sql ================================================ [File too large to display: 59 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-11-08-102351_workspace_member_count/up.sql ================================================ [File too large to display: 84 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-12-12-102351_workspace_role/down.sql ================================================ [File too large to display: 51 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-12-12-102351_workspace_role/up.sql ================================================ [File too large to display: 54 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-12-29-061706_collab_metadata/down.sql ================================================ [File too large to display: 77 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2024-12-29-061706_collab_metadata/up.sql ================================================ [File too large to display: 206 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/down.sql ================================================ [File too large to display: 268 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-042326_chat_metadata/up.sql ================================================ [File too large to display: 195 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/down.sql ================================================ [File too large to display: 142 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-17-142713_offline_chat_message/up.sql ================================================ [File too large to display: 183 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/down.sql ================================================ [File too large to display: 102 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-18-132232_user_workspace_auth_type/up.sql ================================================ [File too large to display: 880 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-22-150142_workspace_member_joined_at/down.sql ================================================ [File too large to display: 105 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-22-150142_workspace_member_joined_at/up.sql ================================================ [File too large to display: 104 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-25-071459_local_ai_model/down.sql ================================================ [File too large to display: 78 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-25-071459_local_ai_model/up.sql ================================================ [File too large to display: 143 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-28-070644_collab_table/down.sql ================================================ [File too large to display: 70 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-04-28-070644_collab_table/up.sql ================================================ [File too large to display: 326 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-05-06-131915_chat_summary/down.sql ================================================ [File too large to display: 98 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-05-06-131915_chat_summary/up.sql ================================================ [File too large to display: 135 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-05-19-074647_create_shared_views_table/down.sql ================================================ [File too large to display: 90 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-05-19-074647_create_shared_views_table/up.sql ================================================ [File too large to display: 280 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-072900_create_shared_users_table/down.sql ================================================ [File too large to display: 90 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-072900_create_shared_users_table/up.sql ================================================ [File too large to display: 307 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-123016_update_shared_users_table_order/down.sql ================================================ [File too large to display: 807 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-123016_update_shared_users_table_order/up.sql ================================================ [File too large to display: 104 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/kv/kv.rs ================================================ [File too large to display: 5.0 KB] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/kv/mod.rs ================================================ [File too large to display: 73 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/kv/schema.rs ================================================ [File too large to display: 233 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/lib.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/schema.rs ================================================ [File too large to display: 4.0 KB] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/sqlite_impl/conn_ext.rs ================================================ [File too large to display: 826 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/sqlite_impl/database.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/sqlite_impl/errors.rs ================================================ [File too large to display: 483 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/sqlite_impl/mod.rs ================================================ [File too large to display: 174 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pool.rs ================================================ [File too large to display: 3.7 KB] ================================================ FILE: frontend/rust-lib/flowy-sqlite/src/sqlite_impl/pragma.rs ================================================ [File too large to display: 4.8 KB] ================================================ FILE: frontend/rust-lib/flowy-sqlite-vec/Cargo.toml ================================================ [File too large to display: 627 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite-vec/migrations/001-init/up.sql ================================================ [File too large to display: 866 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite-vec/src/db.rs ================================================ [File too large to display: 17.9 KB] ================================================ FILE: frontend/rust-lib/flowy-sqlite-vec/src/entities.rs ================================================ [File too large to display: 574 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite-vec/src/lib.rs ================================================ [File too large to display: 315 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite-vec/src/migration.rs ================================================ [File too large to display: 585 B] ================================================ FILE: frontend/rust-lib/flowy-sqlite-vec/tests/main.rs ================================================ [File too large to display: 10.9 KB] ================================================ FILE: frontend/rust-lib/flowy-storage/Cargo.toml ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/flowy-storage/Flowy.toml ================================================ [File too large to display: 185 B] ================================================ FILE: frontend/rust-lib/flowy-storage/build.rs ================================================ [File too large to display: 179 B] ================================================ FILE: frontend/rust-lib/flowy-storage/src/entities.rs ================================================ [File too large to display: 405 B] ================================================ FILE: frontend/rust-lib/flowy-storage/src/event_handler.rs ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/flowy-storage/src/event_map.rs ================================================ [File too large to display: 802 B] ================================================ FILE: frontend/rust-lib/flowy-storage/src/file_cache.rs ================================================ [File too large to display: 2.8 KB] ================================================ FILE: frontend/rust-lib/flowy-storage/src/lib.rs ================================================ [File too large to display: 151 B] ================================================ FILE: frontend/rust-lib/flowy-storage/src/manager.rs ================================================ [File too large to display: 27.0 KB] ================================================ FILE: frontend/rust-lib/flowy-storage/src/notification.rs ================================================ [File too large to display: 635 B] ================================================ FILE: frontend/rust-lib/flowy-storage/src/sqlite_sql.rs ================================================ [File too large to display: 7.4 KB] ================================================ FILE: frontend/rust-lib/flowy-storage/src/uploader.rs ================================================ [File too large to display: 10.0 KB] ================================================ FILE: frontend/rust-lib/flowy-storage/tests/multiple_part_upload_test.rs ================================================ [File too large to display: 5.6 KB] ================================================ FILE: frontend/rust-lib/flowy-storage-pub/Cargo.toml ================================================ [File too large to display: 535 B] ================================================ FILE: frontend/rust-lib/flowy-storage-pub/src/chunked_byte.rs ================================================ [File too large to display: 13.1 KB] ================================================ FILE: frontend/rust-lib/flowy-storage-pub/src/cloud.rs ================================================ [File too large to display: 4.3 KB] ================================================ FILE: frontend/rust-lib/flowy-storage-pub/src/lib.rs ================================================ [File too large to display: 54 B] ================================================ FILE: frontend/rust-lib/flowy-storage-pub/src/storage.rs ================================================ [File too large to display: 3.0 KB] ================================================ FILE: frontend/rust-lib/flowy-user/Cargo.toml ================================================ [File too large to display: 1.9 KB] ================================================ FILE: frontend/rust-lib/flowy-user/Flowy.toml ================================================ [File too large to display: 181 B] ================================================ FILE: frontend/rust-lib/flowy-user/build.rs ================================================ [File too large to display: 179 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/auth.rs ================================================ [File too large to display: 5.5 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/date_time.rs ================================================ [File too large to display: 1.9 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/import_data.rs ================================================ [File too large to display: 419 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/mod.rs ================================================ [File too large to display: 305 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/parser/mod.rs ================================================ [File too large to display: 582 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/parser/user_email.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/parser/user_icon.rs ================================================ [File too large to display: 256 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/parser/user_id.rs ================================================ [File too large to display: 384 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/parser/user_name.rs ================================================ [File too large to display: 2.1 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/parser/user_openai_key.rs ================================================ [File too large to display: 276 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/parser/user_password.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/parser/user_stability_ai_key.rs ================================================ [File too large to display: 296 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/realtime.rs ================================================ [File too large to display: 148 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/reminder.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/user_profile.rs ================================================ [File too large to display: 4.9 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/user_setting.rs ================================================ [File too large to display: 6.3 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/entities/workspace.rs ================================================ [File too large to display: 19.1 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/event_handler.rs ================================================ [File too large to display: 28.8 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/event_map.rs ================================================ [File too large to display: 12.0 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/lib.rs ================================================ [File too large to display: 235 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/migrations/anon_user_workspace.rs ================================================ [File too large to display: 1.8 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/migrations/doc_key_with_workspace.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/migrations/document_empty_content.rs ================================================ [File too large to display: 4.0 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/migrations/migration.rs ================================================ [File too large to display: 4.6 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/migrations/mod.rs ================================================ [File too large to display: 349 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/migrations/session_migration.rs ================================================ [File too large to display: 4.0 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/migrations/util.rs ================================================ [File too large to display: 503 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/migrations/workspace_and_favorite_v1.rs ================================================ [File too large to display: 2.6 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/migrations/workspace_trash_v1.rs ================================================ [File too large to display: 2.3 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/notification.rs ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/authenticate_user.rs ================================================ [File too large to display: 5.8 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/billing_check.rs ================================================ [File too large to display: 2.8 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/cloud_config.rs ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/collab_interact.rs ================================================ [File too large to display: 538 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/data_import/appflowy_data_import.rs ================================================ [File too large to display: 42.9 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/data_import/importer.rs ================================================ [File too large to display: 1.3 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/data_import/mod.rs ================================================ [File too large to display: 174 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/db.rs ================================================ [File too large to display: 10.8 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/entities.rs ================================================ [File too large to display: 2.6 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/services/mod.rs ================================================ [File too large to display: 155 B] ================================================ FILE: frontend/rust-lib/flowy-user/src/user_manager/manager.rs ================================================ [File too large to display: 30.6 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/user_manager/manager_history_user.rs ================================================ [File too large to display: 2.4 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/user_manager/manager_user_awareness.rs ================================================ [File too large to display: 10.5 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/user_manager/manager_user_encryption.rs ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs ================================================ [File too large to display: 23.8 KB] ================================================ FILE: frontend/rust-lib/flowy-user/src/user_manager/mod.rs ================================================ [File too large to display: 189 B] ================================================ FILE: frontend/rust-lib/flowy-user-pub/Cargo.toml ================================================ [File too large to display: 761 B] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/cloud.rs ================================================ [File too large to display: 11.1 KB] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/entities.rs ================================================ [File too large to display: 9.8 KB] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/lib.rs ================================================ [File too large to display: 158 B] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/session.rs ================================================ [File too large to display: 961 B] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/sql/member_sql.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/sql/mod.rs ================================================ [File too large to display: 181 B] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/sql/user_sql.rs ================================================ [File too large to display: 5.2 KB] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/sql/workspace_setting_sql.rs ================================================ [File too large to display: 2.2 KB] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/sql/workspace_sql.rs ================================================ [File too large to display: 8.2 KB] ================================================ FILE: frontend/rust-lib/flowy-user-pub/src/workspace_service.rs ================================================ [File too large to display: 585 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/Cargo.toml ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/byte_trait.rs ================================================ [File too large to display: 1.8 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/data.rs ================================================ [File too large to display: 3.6 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/dispatcher.rs ================================================ [File too large to display: 9.6 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/errors/errors.rs ================================================ [File too large to display: 3.4 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/errors/mod.rs ================================================ [File too large to display: 68 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/lib.rs ================================================ [File too large to display: 327 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/macros.rs ================================================ [File too large to display: 148 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/module/container.rs ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/module/data.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/module/mod.rs ================================================ [File too large to display: 133 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/module/module.rs ================================================ [File too large to display: 6.9 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/request/mod.rs ================================================ [File too large to display: 107 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/request/payload.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/request/request.rs ================================================ [File too large to display: 2.7 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/response/builder.rs ================================================ [File too large to display: 870 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/response/mod.rs ================================================ [File too large to display: 142 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/response/responder.rs ================================================ [File too large to display: 989 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/response/response.rs ================================================ [File too large to display: 2.0 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/runtime.rs ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/service/boxed.rs ================================================ [File too large to display: 3.9 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/service/handler.rs ================================================ [File too large to display: 7.0 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/service/mod.rs ================================================ [File too large to display: 132 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/service/service.rs ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/util/mod.rs ================================================ [File too large to display: 15 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/src/util/ready.rs ================================================ [File too large to display: 548 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/tests/api/main.rs ================================================ [File too large to display: 12 B] ================================================ FILE: frontend/rust-lib/lib-dispatch/tests/api/module.rs ================================================ [File too large to display: 788 B] ================================================ FILE: frontend/rust-lib/lib-infra/Cargo.toml ================================================ [File too large to display: 1.4 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/box_any.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/compression.rs ================================================ [File too large to display: 619 B] ================================================ FILE: frontend/rust-lib/lib-infra/src/encryption/mod.rs ================================================ [File too large to display: 6.0 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/file_util.rs ================================================ [File too large to display: 5.6 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/isolate_stream.rs ================================================ [File too large to display: 1.0 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/lib.rs ================================================ [File too large to display: 495 B] ================================================ FILE: frontend/rust-lib/lib-infra/src/native/future.rs ================================================ [File too large to display: 701 B] ================================================ FILE: frontend/rust-lib/lib-infra/src/native/mod.rs ================================================ [File too large to display: 23 B] ================================================ FILE: frontend/rust-lib/lib-infra/src/priority_task/mod.rs ================================================ [File too large to display: 117 B] ================================================ FILE: frontend/rust-lib/lib-infra/src/priority_task/queue.rs ================================================ [File too large to display: 2.9 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/priority_task/scheduler.rs ================================================ [File too large to display: 5.2 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/priority_task/store.rs ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/priority_task/task.rs ================================================ [File too large to display: 3.2 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/ref_map.rs ================================================ [File too large to display: 1.8 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/stream_util.rs ================================================ [File too large to display: 568 B] ================================================ FILE: frontend/rust-lib/lib-infra/src/util.rs ================================================ [File too large to display: 3.3 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/validator_fn.rs ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/wasm/future.rs ================================================ [File too large to display: 1.3 KB] ================================================ FILE: frontend/rust-lib/lib-infra/src/wasm/mod.rs ================================================ [File too large to display: 16 B] ================================================ FILE: frontend/rust-lib/lib-infra/tests/main.rs ================================================ [File too large to display: 15 B] ================================================ FILE: frontend/rust-lib/lib-infra/tests/task_test/mod.rs ================================================ [File too large to display: 55 B] ================================================ FILE: frontend/rust-lib/lib-infra/tests/task_test/script.rs ================================================ [File too large to display: 5.4 KB] ================================================ FILE: frontend/rust-lib/lib-infra/tests/task_test/task_cancel_test.rs ================================================ [File too large to display: 2.6 KB] ================================================ FILE: frontend/rust-lib/lib-infra/tests/task_test/task_order_test.rs ================================================ [File too large to display: 3.4 KB] ================================================ FILE: frontend/rust-lib/lib-log/Cargo.toml ================================================ [File too large to display: 498 B] ================================================ FILE: frontend/rust-lib/lib-log/src/layer.rs ================================================ [File too large to display: 7.9 KB] ================================================ FILE: frontend/rust-lib/lib-log/src/lib.rs ================================================ [File too large to display: 4.5 KB] ================================================ FILE: frontend/rust-lib/lib-log/src/stream_log.rs ================================================ [File too large to display: 683 B] ================================================ FILE: frontend/rust-lib/rust-toolchain.toml ================================================ [File too large to display: 63 B] ================================================ FILE: frontend/rust-lib/rustfmt.toml ================================================ [File too large to display: 318 B] ================================================ FILE: frontend/rust-toolchain.toml ================================================ [File too large to display: 64 B] ================================================ FILE: frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.cmd ================================================ [File too large to display: 644 B] ================================================ FILE: frontend/scripts/code_generation/flowy_icons/generate_flowy_icons.sh ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/scripts/code_generation/freezed/generate_freezed.cmd ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/scripts/code_generation/freezed/generate_freezed.sh ================================================ [File too large to display: 3.1 KB] ================================================ FILE: frontend/scripts/code_generation/generate.cmd ================================================ [File too large to display: 895 B] ================================================ FILE: frontend/scripts/code_generation/generate.sh ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/scripts/code_generation/language_files/generate_language_files.cmd ================================================ [File too large to display: 927 B] ================================================ FILE: frontend/scripts/code_generation/language_files/generate_language_files.sh ================================================ [File too large to display: 2.1 KB] ================================================ FILE: frontend/scripts/docker-buildfiles/Dockerfile ================================================ [File too large to display: 2.7 KB] ================================================ FILE: frontend/scripts/docker-buildfiles/README.md ================================================ [File too large to display: 324 B] ================================================ FILE: frontend/scripts/docker-buildfiles/docker-compose.yml ================================================ [File too large to display: 922 B] ================================================ FILE: frontend/scripts/flatpack-buildfiles/.gitignore ================================================ [File too large to display: 29 B] ================================================ FILE: frontend/scripts/flatpack-buildfiles/dbus-interface.xml ================================================ [File too large to display: 560 B] ================================================ FILE: frontend/scripts/flatpack-buildfiles/io.appflowy.AppFlowy.desktop ================================================ [File too large to display: 191 B] ================================================ FILE: frontend/scripts/flatpack-buildfiles/io.appflowy.AppFlowy.launcher.desktop ================================================ [File too large to display: 120 B] ================================================ FILE: frontend/scripts/flatpack-buildfiles/io.appflowy.AppFlowy.metainfo.xml ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/scripts/flatpack-buildfiles/io.appflowy.AppFlowy.service ================================================ [File too large to display: 56 B] ================================================ FILE: frontend/scripts/flatpack-buildfiles/io.appflowy.AppFlowy.yml ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/scripts/flatpack-buildfiles/launcher.sh ================================================ [File too large to display: 171 B] ================================================ FILE: frontend/scripts/flutter_release_build/build_flowy.dart ================================================ [File too large to display: 1.9 KB] ================================================ FILE: frontend/scripts/flutter_release_build/build_universal_package_for_macos.sh ================================================ [File too large to display: 1.2 KB] ================================================ FILE: frontend/scripts/flutter_release_build/tool.dart ================================================ [File too large to display: 3.6 KB] ================================================ FILE: frontend/scripts/install_dev_env/install_ios.sh ================================================ [File too large to display: 2.6 KB] ================================================ FILE: frontend/scripts/install_dev_env/install_linux.sh ================================================ [File too large to display: 3.3 KB] ================================================ FILE: frontend/scripts/install_dev_env/install_macos.sh ================================================ [File too large to display: 2.3 KB] ================================================ FILE: frontend/scripts/install_dev_env/install_windows.sh ================================================ [File too large to display: 3.5 KB] ================================================ FILE: frontend/scripts/linux_distribution/appimage/AppImageBuilder.yml ================================================ [File too large to display: 3.1 KB] ================================================ FILE: frontend/scripts/linux_distribution/appimage/build_appimage.sh ================================================ [File too large to display: 689 B] ================================================ FILE: frontend/scripts/linux_distribution/appimage/io.appflowy.AppFlowy.desktop ================================================ ================================================ FILE: frontend/scripts/linux_distribution/deb/AppFlowy.desktop ================================================ [File too large to display: 236 B] ================================================ FILE: frontend/scripts/linux_distribution/deb/DEBIAN/control ================================================ [File too large to display: 193 B] ================================================ FILE: frontend/scripts/linux_distribution/deb/DEBIAN/postinst ================================================ [File too large to display: 280 B] ================================================ FILE: frontend/scripts/linux_distribution/deb/DEBIAN/postrm ================================================ [File too large to display: 118 B] ================================================ FILE: frontend/scripts/linux_distribution/deb/README.md ================================================ [File too large to display: 540 B] ================================================ FILE: frontend/scripts/linux_distribution/deb/build_deb.sh ================================================ [File too large to display: 1.0 KB] ================================================ FILE: frontend/scripts/linux_distribution/flatpak/README.md ================================================ [File too large to display: 70 B] ================================================ FILE: frontend/scripts/linux_distribution/packaging/io.appflowy.AppFlowy.launcher.desktop ================================================ [File too large to display: 142 B] ================================================ FILE: frontend/scripts/linux_distribution/packaging/io.appflowy.AppFlowy.metainfo.xml ================================================ [File too large to display: 1.7 KB] ================================================ FILE: frontend/scripts/linux_distribution/packaging/io.appflowy.AppFlowy.service ================================================ [File too large to display: 56 B] ================================================ FILE: frontend/scripts/linux_distribution/packaging/launcher.sh ================================================ [File too large to display: 171 B] ================================================ FILE: frontend/scripts/linux_installer/control ================================================ [File too large to display: 193 B] ================================================ FILE: frontend/scripts/linux_installer/postinst ================================================ [File too large to display: 225 B] ================================================ FILE: frontend/scripts/linux_installer/postrm ================================================ [File too large to display: 90 B] ================================================ FILE: frontend/scripts/makefile/desktop.toml ================================================ [File too large to display: 4.6 KB] ================================================ FILE: frontend/scripts/makefile/docker.toml ================================================ [File too large to display: 176 B] ================================================ FILE: frontend/scripts/makefile/env.toml ================================================ [File too large to display: 4.6 KB] ================================================ FILE: frontend/scripts/makefile/flutter.toml ================================================ [File too large to display: 8.4 KB] ================================================ FILE: frontend/scripts/makefile/mobile.toml ================================================ [File too large to display: 4.9 KB] ================================================ FILE: frontend/scripts/makefile/protobuf.toml ================================================ [File too large to display: 1.6 KB] ================================================ FILE: frontend/scripts/makefile/tauri.toml ================================================ [File too large to display: 1.1 KB] ================================================ FILE: frontend/scripts/makefile/tests.toml ================================================ [File too large to display: 9.5 KB] ================================================ FILE: frontend/scripts/makefile/tool.toml ================================================ [File too large to display: 2.7 KB] ================================================ FILE: frontend/scripts/makefile/web.toml ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/scripts/tool/update_client_api_rev.sh ================================================ [File too large to display: 861 B] ================================================ FILE: frontend/scripts/tool/update_collab_rev.sh ================================================ [File too large to display: 900 B] ================================================ FILE: frontend/scripts/tool/update_collab_source.sh ================================================ [File too large to display: 1.5 KB] ================================================ FILE: frontend/scripts/tool/update_local_ai_rev.sh ================================================ [File too large to display: 933 B] ================================================ FILE: frontend/scripts/white_label/code_white_label.sh ================================================ [File too large to display: 1.9 KB] ================================================ FILE: frontend/scripts/white_label/font_white_label.sh ================================================ [File too large to display: 6.0 KB] ================================================ FILE: frontend/scripts/white_label/i18n_white_label.sh ================================================ [File too large to display: 3.3 KB] ================================================ FILE: frontend/scripts/white_label/icon_white_label.sh ================================================ [File too large to display: 3.0 KB] ================================================ FILE: frontend/scripts/white_label/white_label.sh ================================================ [File too large to display: 4.2 KB] ================================================ FILE: frontend/scripts/white_label/windows_white_label.sh ================================================ [File too large to display: 4.8 KB] ================================================ FILE: frontend/scripts/windows_installer/inno_setup_config.iss ================================================ [File too large to display: 1.1 KB] ================================================ FILE: install.sh ================================================ [File too large to display: 9.0 KB] ================================================ FILE: project.inlang/project_id ================================================ [File too large to display: 65 B] ================================================ FILE: project.inlang/settings.json ================================================ [File too large to display: 1.1 KB] ================================================ FILE: project.inlang.json ================================================ [File too large to display: 1.1 KB]